From 2addb0d3e6a88e74a9ef0b46e12d16b59bbaba70 Mon Sep 17 00:00:00 2001 From: chengke <404835780@qq.com> Date: Tue, 8 Sep 2026 15:02:34 +0800 Subject: [PATCH 1/6] feat(retrieval): introduce body chunk type constants for asset embedding Added constants for body chunk types that can own Root-parked assets in the serving manifest and navigation modules. Updated relevant logic to utilize these constants for improved clarity and maintainability. Enhanced documentation in the CORPUS_SCHEMA to reflect the ownership rules of body chunks. --- docs/design/entity-node-graph.md | 133 ++++++ .../retrieval/agent_tools/CORPUS_SCHEMA.md | 14 +- .../retrieval/agent_tools/__init__.py | 35 ++ .../retrieval/agent_tools/registry.py | 124 ++++++ .../retrieval/agent_tools/tools/__init__.py | 29 ++ .../retrieval/agent_tools/tools/assets.py | 193 +++++++++ .../retrieval/agent_tools/tools/grep.py | 158 +++++++ .../agent_tools/tools/list_documents.py | 81 ++++ .../retrieval/agent_tools/tools/neighbors.py | 126 ++++++ .../agent_tools/tools/node_filter.py | 198 +++++++++ .../retrieval/agent_tools/tools/outline.py | 155 +++++++ .../retrieval/agent_tools/tools/read.py | 401 ++++++++++++++++++ .../retrieval/agent_tools/tools/recall.py | 250 +++++++++++ .../services/retrieval/nav/nav_knowhere.py | 5 +- .../services/retrieval/serving_manifest.py | 8 +- 15 files changed, 1906 insertions(+), 4 deletions(-) create mode 100644 docs/design/entity-node-graph.md create mode 100644 packages/shared-python/shared/services/retrieval/agent_tools/__init__.py create mode 100644 packages/shared-python/shared/services/retrieval/agent_tools/registry.py create mode 100644 packages/shared-python/shared/services/retrieval/agent_tools/tools/__init__.py create mode 100644 packages/shared-python/shared/services/retrieval/agent_tools/tools/assets.py create mode 100644 packages/shared-python/shared/services/retrieval/agent_tools/tools/grep.py create mode 100644 packages/shared-python/shared/services/retrieval/agent_tools/tools/list_documents.py create mode 100644 packages/shared-python/shared/services/retrieval/agent_tools/tools/neighbors.py create mode 100644 packages/shared-python/shared/services/retrieval/agent_tools/tools/node_filter.py create mode 100644 packages/shared-python/shared/services/retrieval/agent_tools/tools/outline.py create mode 100644 packages/shared-python/shared/services/retrieval/agent_tools/tools/read.py create mode 100644 packages/shared-python/shared/services/retrieval/agent_tools/tools/recall.py diff --git a/docs/design/entity-node-graph.md b/docs/design/entity-node-graph.md new file mode 100644 index 00000000..3fe6540a --- /dev/null +++ b/docs/design/entity-node-graph.md @@ -0,0 +1,133 @@ +# Chunk-level entity graph (deferred) + +**Status:** Deferred — not scheduled, no code written. This replaces the +old "entity 节点 / 共现边:schema 文档预留,发布侧不改" non-goal line in +`.cursor/plans/agentic_corpus_explore_retrieval_c2c4ea21.plan.md` §四 with +an actual future phase (see that plan's todo list for the tracking id). + +**Relation to Phase 2 (agent_tools registry)**: does not block or change +it. Reasoning below in "Why this doesn't affect Phase 2." + +## Problem + +Today `DocumentGraphService.publish_document_graph` +(`packages/shared-python/shared/services/retrieval/graph/service.py:70`) +only ever creates **one graph node per document** and only ever creates +**document ↔ document** `related` edges. The entity/keyword overlap that +drives those edges is computed by first collapsing *every chunk in the +document* into one flat entity set +(`get_normalized_entity_set(chunk_metadata_list)`, `service.py:100`) before +any comparison happens — so by the time two documents get connected, you +have already lost which section/chunk in document A actually shares an +entity with which section/chunk in document B. + +The ask: connect the actual **chunks** that share an entity, not just the +two documents they happen to live in. + +## What "node" means here (resolved) + +A chunk is already, in the overwhelming majority of cases, the +bottom-level unit — one leaf section, one body chunk. The one documented +exception (see `CORPUS_SCHEMA.md` §2, and verified against real published +data: 170 of 801 sections in a two-document local corpus) is **`chunk` +track (text-track) only**: a section that has its own content directly +under its heading, before its first child heading, owns a body chunk of +its own even though it also has children. `page_memory` (page) track has +no such exception — only leaves ever own a body chunk there. + +So there is no separate "section-level" option distinct from "chunk-level" +— they are the same thing, because a section owns *at most one* body +chunk. The node for this graph is the chunk: every `text`/`page` body +chunk, and — no reason to special-case them out — every `image`/`table` +chunk too, since they already carry their own `entities` in +`chunk_metadata` independent of which section they are `connect_to`-linked +from. Structural sections that own no chunk of their own simply have no +entities and never become nodes; no rollup needed, no ambiguity. + +**Naming collision to keep in mind when this is built**: the Phase 2 +`node_filter` tool's "node" means a node in the *section/hierarchy tree* +(operates on `section_path` and `summary`, see `CORPUS_SCHEMA.md` §6). The +graph's "node" (`graph_nodes` table) is a completely different structure — +today only `node_kind='document'` rows, this proposal adds +`node_kind='chunk'` rows. Do not conflate the two when writing tool +descriptions or code comments for whichever tool eventually exposes this +graph. + +## Why the DB schema needs no migration + +Verified against the actual model +(`packages/shared-python/shared/models/database/document.py:495-575`): + +- `GraphNode.node_kind` is a plain `String(32)`, not an enum constrained to + `'document'` — a new `'chunk'` value needs no schema change. +- `GraphNode.ref_section_id` already exists as a nullable column + (`document.py:515`) and is already set to `None` for document nodes + (`service.py:137`) — it is unused, not absent. +- `GraphEdge.source_node_id` / `target_node_id` are plain FKs to + `graph_nodes.node_id` (`document.py:546-555`) with no constraint that + both ends share a `node_kind` — a chunk-node ↔ chunk-node edge is already + legal today, mechanically. + +So this is additive: keep the existing `node_kind='document'` nodes/edges +exactly as they are (`neighbors` in Phase 2 keeps querying those, unaffected +— see below), and add `node_kind='chunk'` rows and edges alongside them. + +## What actually needs new design (not just "add a node_kind") + +1. **Stop collapsing to one set per document.** Index each qualifying + chunk's own `entities` (`chunk_metadata.entities`, already extracted per + chunk via `extract_entities_from_chunk_metadata` in + `graph/keywords.py:53`) as its own node, instead of merging via + `get_normalized_entity_set` before any comparison. + +2. **Replace the O(other documents) peer loop with an inverted index.** + `service.py:152-161` currently loads every other `node_kind='document'` + row in the namespace and compares against it — fine at "tens/hundreds + of documents" scale. At chunk scale (tens of thousands of chunks per + namespace) this must become an `entity_key → chunk_node_id` lookup + (a dedicated join table with a plain index beats a JSONB containment + scan at this volume) so a newly published chunk only compares against + chunks that already share at least one entity key, not every chunk in + the namespace. + +3. **Retune the overlap threshold — it does not transfer.** + `graph/keywords.py:7-11`: `MIN_ENTITY_OVERLAP = 2`, + `MIN_SCORE_THRESHOLD = 0.8`, and `compute_entity_score` + (`keywords.py:80-96`) is `weight * shared_weight / min(weight_a, + weight_b)`. This was tuned for whole-document aggregate sets (tens of + entities). A single chunk typically carries 1-3 entities (verified: + one real image chunk had exactly 2). Requiring `≥2` shared entities out + of a 1-3 entity set will rarely fire; and when a tiny set *does* overlap + by even one entity, the length-weighted score can trivially hit 1.0. + Neither behavior is useful. Proposed direction: connect on **≥1** shared + entity, but gate on that entity's **rarity across the namespace** + (inverse document frequency over chunks, not documents) so a common + entity (a year, a generic org name) does not wire every chunk to every + other chunk. This namespace-wide chunk-frequency count does not exist + today (`compute_tfidf_keywords` in `keywords.py:100` only computes + document-frequency *within one document's own chunks*, for its + `top_keywords`, not across the namespace) — it is new infrastructure, + not a reuse of an existing utility. + +4. **Only materialize nodes for chunks that have ≥1 entity.** Chunks with + an empty `entities` list should not get a `graph_nodes` row at all, or + the table balloons with rows that can never have an edge. + +5. **Recommend keeping this additive, not a replacement.** Leave + `node_kind='document'` publication in `publish_document_graph` exactly + as-is; add chunk-level publication as a new, separate write path (can + live in the same service or a sibling one). Lower risk, and the + existing document-level `related` edges stay useful for + `list_documents`/`neighbors`-style "what else is like this document" + overviews that don't need chunk precision. + +## Why this doesn't affect Phase 2 + +Phase 2's tool list (`agent_tools/registry.py` and the 8 tools in +`CORPUS_SCHEMA.md` §6) touches the graph only through `neighbors`, which is +scoped to `node_kind='document'` `related` edges — exactly what exists +today, unchanged by this proposal. `node_filter`, `outline`, `recall`, +`grep`, `read`, `assets`, `list_documents` never touch `graph_nodes` / +`graph_edges` at all. This entity-node-graph work is a later, additive +phase with its own exposure decision (new tool? extend `neighbors` with a +granularity param? not decided — out of scope until this is scheduled). diff --git a/packages/shared-python/shared/services/retrieval/agent_tools/CORPUS_SCHEMA.md b/packages/shared-python/shared/services/retrieval/agent_tools/CORPUS_SCHEMA.md index 496b1607..904d0f5f 100644 --- a/packages/shared-python/shared/services/retrieval/agent_tools/CORPUS_SCHEMA.md +++ b/packages/shared-python/shared/services/retrieval/agent_tools/CORPUS_SCHEMA.md @@ -54,6 +54,14 @@ they differ in shape, and in **which sections get a body chunk at all**: body chunk. Internal/structural sections never carry a `page` chunk themselves — their summaries aggregate from their leaf descendants. +A section owns **at most one** body chunk either way (verified against +real published data: a section with children and a section without both +had exactly 0 or 1, never more). So a "chunk" and "the section that owns +it" are the same unit, not two different granularities — there is no +separate finer-or-coarser level to choose between. The only structural +sections with no chunk of their own are `chunk`-track sections whose +entire content lives in their descendants. + For `page` chunks specifically: one leaf section's body may span one or more physical pages. A page's text is stored **once**, under whichever leaf is first in reading order to cover that page (the "owner"). Every other @@ -115,7 +123,9 @@ for anything finer-grained than a document pair. ## 5. Reserved / not yet available - **Vector channel**: `recall`'s `channels` parameter reserves a `vector` - option; it does not exist yet. `recall` today is lexical only. + option; it does not exist yet. `recall` today fuses two lexical channels + (`path_content`: persisted map-unit BM25 over path+content; `term`: + substring match over `document_map_units.term_search_text_lower`) via RRF. ## 6. Tools and when to use each @@ -125,7 +135,7 @@ for anything finer-grained than a document pair. | `outline` | The task only needs titles/summaries — overview, "what does chapter N cover," picking where to look before reading | one document, or a `section_path` prefix within it | Titles + summaries + `chunk_count`, no body text, no folding. Depth-limited by argument, not by a token budget. Use this to build your own map instead of relying on a pre-folded one. | | `node_filter` | The task is a traversal/exclusion predicate — FOR ALL / EXISTS / ANY / NOT — over section titles or summaries ("which docs mention X in a heading," "sections NOT about Y") | one or more documents | Deterministic substring/regex match against `section_path` and `summary` only, not body text. Returns the full matching set and count, never a truncated top-K. If the predicate must run against body text, use `grep` instead. | | `grep` | Exact string / regex / identifier / number lookup that must run against body text | scoped by document/section/chunk_type | Returns match count plus snippets, so ANY/ALL logic can also close over body text, not just titles. | -| `recall` | A fuzzy question where you don't know where the answer lives | namespace or scoped | Ranked candidates (path + content BM25 today; term and vector are separate/reserved — see §5) with path and snippet, not full content. | +| `recall` | A fuzzy question where you don't know where the answer lives | namespace or scoped | Ranked candidates from `path_content` (BM25) + `term` (substring) channels fused by RRF; `vector` is reserved — see §5. Returns path and snippet, not full content. | | `read` | You already know which section(s)/chunk(s) to read | one or more sections/chunks | Returns full body content, resolves `SAME-AS` markers into the owner's text, expands `connect_to` assets, and converts `page_assets` into URLs. | | `assets` | You need images/tables directly, or need to find which section(s) host a given asset | one or more documents | Forward (by type/query) and reverse (asset → hosting section) lookup — see §3. | | `neighbors` | You need related documents in the same namespace | one document | Document-level `related` edges only — see §4. | diff --git a/packages/shared-python/shared/services/retrieval/agent_tools/__init__.py b/packages/shared-python/shared/services/retrieval/agent_tools/__init__.py new file mode 100644 index 00000000..2e228499 --- /dev/null +++ b/packages/shared-python/shared/services/retrieval/agent_tools/__init__.py @@ -0,0 +1,35 @@ +"""Provider-agnostic corpus exploration tools. + +See ``.cursor/plans/agentic_corpus_explore_retrieval_c2c4ea21.plan.md`` (Phase 2) +for the design. Tools in this package query the published, DB-served corpus +described in ``CORPUS_SCHEMA.md`` (``documents`` / ``document_sections`` / +``document_chunks`` / ``graph_nodes`` / ``graph_edges``) — not the on-disk +parse artifacts. + +The same ``REGISTRY`` is meant to be consumed by two harnesses (Phase 3): +the API ``/mcp`` server (Cursor/Codex/Claude) and the in-process +``agent_explore`` tool-loop. Importing ``agent_tools.tools`` registers every +tool as a side effect. +""" + +from __future__ import annotations + +from shared.services.retrieval.agent_tools.registry import ( + REGISTRY, + ToolBudget, + ToolContext, + ToolRegistry, + ToolResult, + ToolSpec, + register_tool, +) + +__all__ = [ + "REGISTRY", + "ToolBudget", + "ToolContext", + "ToolRegistry", + "ToolResult", + "ToolSpec", + "register_tool", +] diff --git a/packages/shared-python/shared/services/retrieval/agent_tools/registry.py b/packages/shared-python/shared/services/retrieval/agent_tools/registry.py new file mode 100644 index 00000000..a4286f52 --- /dev/null +++ b/packages/shared-python/shared/services/retrieval/agent_tools/registry.py @@ -0,0 +1,124 @@ +"""Provider-agnostic tool contracts for corpus exploration. + +Mirrors the shape of ``apps/worker/app/services/document_agent/registry.py`` +(``ToolSpec`` + a decorator-based registry), adapted for the async DB-backed +corpus tools in this package: ``ToolSpec(name, description, json_schema, run)``, +``ToolContext(db, user_id, namespace, budget)``, ``ToolResult(text, payload, refs)``. + +Both the API ``/mcp`` server and the in-process ``agent_explore`` tool-loop +(Phase 3) dispatch through the same ``REGISTRY`` — this module has no +provider-specific (MCP / OpenAI tool-calling) concerns. +""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession + + +@dataclass(frozen=True) +class ToolBudget: + """Per-call output budget passed down to every tool via ``ToolContext``. + + Tools that return an unbounded/ranked list (``recall``, ``grep``, asset + forward search) truncate to ``max_items`` and note the omission. Tools + that promise a complete, non-truncated set by contract (``node_filter``, + ``outline``) do not apply this budget to their matched-set cardinality — + see ``CORPUS_SCHEMA.md`` §6. + """ + + max_chars: int = 8000 + max_items: int = 50 + + +@dataclass +class ToolContext: + """Per-call execution context. One instance is built per tool dispatch.""" + + db: AsyncSession + user_id: str + namespace: str + budget: ToolBudget = field(default_factory=ToolBudget) + + +@dataclass +class ToolResult: + """Uniform tool output. + + ``text`` is the human/LLM-facing rendering; ``payload`` is the structured + data (for programmatic callers and for building ``refs``); ``refs`` are + resolvable evidence pointers (``{document_id, section_path|chunk_id}``) + that a harness can fold into ``referenced_chunks`` (Phase 3 bridge). + ``error`` is set instead of raising for caller-facing input mistakes (bad + args, unknown document_id) so a tool-loop agent can see and correct them. + """ + + text: str + payload: dict[str, Any] = field(default_factory=dict) + refs: list[dict[str, Any]] = field(default_factory=list) + error: str | None = None + + +ToolHandler = Callable[[ToolContext, dict[str, Any]], Awaitable[ToolResult]] + + +@dataclass(frozen=True) +class ToolSpec: + name: str + description: str + json_schema: dict[str, Any] + run: ToolHandler + + +class ToolRegistry: + """Name -> ``ToolSpec`` map. Provider-agnostic; no MCP/OpenAI coupling.""" + + def __init__(self) -> None: + self._tools: dict[str, ToolSpec] = {} + + def register(self, spec: ToolSpec) -> None: + if spec.name in self._tools: + raise ValueError(f"tool already registered: {spec.name}") + self._tools[spec.name] = spec + + def get(self, name: str) -> ToolSpec | None: + return self._tools.get(name) + + def all(self) -> list[ToolSpec]: + return list(self._tools.values()) + + async def dispatch( + self, name: str, ctx: ToolContext, args: dict[str, Any] + ) -> ToolResult: + spec = self.get(name) + if spec is None: + return ToolResult(text="", error=f"unknown tool: {name}") + return await spec.run(ctx, args) + + +REGISTRY = ToolRegistry() + + +def register_tool( + *, + name: str, + description: str, + json_schema: dict[str, Any], +) -> Callable[[ToolHandler], ToolHandler]: + """Decorator mirroring worker's ``register_tool`` for the async corpus tools.""" + + def _decorator(handler: ToolHandler) -> ToolHandler: + REGISTRY.register( + ToolSpec( + name=name, + description=description, + json_schema=json_schema, + run=handler, + ) + ) + return handler + + return _decorator diff --git a/packages/shared-python/shared/services/retrieval/agent_tools/tools/__init__.py b/packages/shared-python/shared/services/retrieval/agent_tools/tools/__init__.py new file mode 100644 index 00000000..f94f447e --- /dev/null +++ b/packages/shared-python/shared/services/retrieval/agent_tools/tools/__init__.py @@ -0,0 +1,29 @@ +"""Importing this package registers every ``corpus.*`` tool into ``REGISTRY``. + +One module per tool, mirroring +``apps/worker/app/services/document_agent/tools/``. +""" + +from __future__ import annotations + +from shared.services.retrieval.agent_tools.tools import ( + assets as _assets, + grep as _grep, + list_documents as _list_documents, + neighbors as _neighbors, + node_filter as _node_filter, + outline as _outline, + read as _read, + recall as _recall, +) + +__all__ = [ + "_assets", + "_grep", + "_list_documents", + "_neighbors", + "_node_filter", + "_outline", + "_read", + "_recall", +] diff --git a/packages/shared-python/shared/services/retrieval/agent_tools/tools/assets.py b/packages/shared-python/shared/services/retrieval/agent_tools/tools/assets.py new file mode 100644 index 00000000..18c5a1b6 --- /dev/null +++ b/packages/shared-python/shared/services/retrieval/agent_tools/tools/assets.py @@ -0,0 +1,193 @@ +"""``corpus.assets`` — forward asset search and reverse asset -> hosts lookup. + +Image/table chunks are parked under their document's synthetic ``Root`` +section in the DB (§3 of ``CORPUS_SCHEMA.md``); the real association to a +body section lives in ``chunk_metadata.connect_to`` on the *body* chunk, not +on the asset. There is no stored asset -> body back-link, so the reverse +lookup (``host_of``) scans the candidate documents' text/page chunks in +Python and checks ``connect_to`` for the requested target ids. + +``chunk_metadata`` is a plain ``JSON`` column (not ``JSONB``), so a +containment query (``@>``) is not available here — that operator is +JSONB-only in PostgreSQL. +""" + +from __future__ import annotations + +from typing import Any + +from sqlalchemy import select + +from shared.models.database.document import Document, DocumentChunk, DocumentSection +from shared.services.retrieval.agent_tools.registry import ( + ToolContext, + ToolResult, + register_tool, +) +from shared.services.retrieval.hydration.row_utils import iter_connected_target_ids +from shared.services.retrieval.settings import ASSET_CHUNK_TYPES + +_BODY_CHUNK_TYPES = ("text", "page") + + +@register_tool( + name="corpus.assets", + description=( + "Forward search for image/table chunks by type/query, or reverse " + "lookup: given asset chunk_ids (host_of), find which body " + "section(s) embed or reference them via connect_to." + ), + json_schema={ + "type": "object", + "properties": { + "document_ids": {"type": "array", "items": {"type": "string"}}, + "type": { + "type": "string", + "enum": ["image", "table", "any"], + "default": "any", + }, + "query": { + "type": "string", + "description": "Substring match against summary/keywords (forward search only).", + }, + "host_of": { + "type": "array", + "items": {"type": "string"}, + "description": "Asset chunk_ids to reverse-resolve to hosting sections.", + }, + }, + "required": [], + }, +) +async def assets(ctx: ToolContext, args: dict[str, Any]) -> ToolResult: + document_ids = [ + str(d).strip() for d in (args.get("document_ids") or []) if str(d).strip() + ] + host_of = [str(c).strip() for c in (args.get("host_of") or []) if str(c).strip()] + + scope_filters: list[Any] = [ + Document.user_id == ctx.user_id, + Document.namespace == ctx.namespace, + Document.status == "active", + Document.current_job_result_id == DocumentChunk.job_result_id, + ] + if document_ids: + scope_filters.append(Document.document_id.in_(document_ids)) + + if host_of: + return await _reverse_lookup(ctx, scope_filters=scope_filters, target_ids=host_of) + return await _forward_search( + ctx, + scope_filters=scope_filters, + asset_type=str(args.get("type") or "any").strip().lower(), + query=str(args.get("query") or "").strip().lower(), + ) + + +async def _forward_search( + ctx: ToolContext, + *, + scope_filters: list[Any], + asset_type: str, + query: str, +) -> ToolResult: + types = {asset_type} if asset_type in ASSET_CHUNK_TYPES else set(ASSET_CHUNK_TYPES) + stmt = ( + select(DocumentChunk, DocumentSection.section_path, Document.source_file_name) + .select_from(DocumentChunk) + .join(Document, Document.document_id == DocumentChunk.document_id) + .outerjoin(DocumentSection, DocumentSection.section_id == DocumentChunk.section_id) + .where(*scope_filters) + .where(DocumentChunk.chunk_type.in_(sorted(types))) + .order_by(DocumentChunk.document_id, DocumentChunk.sort_order) + ) + rows = (await ctx.db.execute(stmt)).all() + + results: list[dict[str, Any]] = [] + for chunk, section_path, source_file_name in rows: + metadata = chunk.chunk_metadata if isinstance(chunk.chunk_metadata, dict) else {} + summary = str(metadata.get("summary") or "").strip() + keywords = metadata.get("keywords") or [] + if query: + haystack = " ".join( + [summary.lower(), " ".join(str(k).lower() for k in keywords)] + ) + if query not in haystack: + continue + results.append( + { + "chunk_id": chunk.chunk_id, + "document_id": chunk.document_id, + "source_file_name": source_file_name, + "chunk_type": chunk.chunk_type, + "file_path": chunk.file_path, + "summary": summary, + "keywords": keywords, + "section_path": section_path, + } + ) + if len(results) >= ctx.budget.max_items: + break + + lines = [f"assets={len(results)}"] + for r in results: + lines.append(f"- [{r['chunk_type']}] {r['file_path']} — {r['summary']}") + + return ToolResult( + text="\n".join(lines), + payload={"assets": results}, + refs=[{"document_id": r["document_id"], "chunk_id": r["chunk_id"]} for r in results], + ) + + +async def _reverse_lookup( + ctx: ToolContext, + *, + scope_filters: list[Any], + target_ids: list[str], +) -> ToolResult: + target_set = set(target_ids) + stmt = ( + select(DocumentChunk, DocumentSection.section_path, Document.source_file_name) + .select_from(DocumentChunk) + .join(Document, Document.document_id == DocumentChunk.document_id) + .outerjoin(DocumentSection, DocumentSection.section_id == DocumentChunk.section_id) + .where(*scope_filters) + .where(DocumentChunk.chunk_type.in_(_BODY_CHUNK_TYPES)) + ) + rows = (await ctx.db.execute(stmt)).all() + + hosts_by_target: dict[str, list[dict[str, Any]]] = {tid: [] for tid in target_set} + for chunk, section_path, source_file_name in rows: + row = {"chunk_metadata": chunk.chunk_metadata} + for target_id in iter_connected_target_ids(row): + if target_id in target_set: + hosts_by_target[target_id].append( + { + "document_id": chunk.document_id, + "source_file_name": source_file_name, + "section_path": section_path, + "chunk_id": chunk.chunk_id, + "chunk_type": chunk.chunk_type, + } + ) + + lines = [] + for target_id, hosts in hosts_by_target.items(): + if not hosts: + lines.append(f"- {target_id}: no host found (unresolved Root asset)") + continue + for host in hosts: + lines.append( + f"- {target_id} <- {host['source_file_name']} / {host['section_path']}" + ) + + return ToolResult( + text="\n".join(lines) if lines else "no hosts found", + payload={"hosts_by_target": hosts_by_target}, + refs=[ + {"document_id": host["document_id"], "section_path": host["section_path"]} + for hosts in hosts_by_target.values() + for host in hosts + ], + ) diff --git a/packages/shared-python/shared/services/retrieval/agent_tools/tools/grep.py b/packages/shared-python/shared/services/retrieval/agent_tools/tools/grep.py new file mode 100644 index 00000000..81bca87e --- /dev/null +++ b/packages/shared-python/shared/services/retrieval/agent_tools/tools/grep.py @@ -0,0 +1,158 @@ +"""``corpus.grep`` — exact string/regex lookup against body text. + +SQL ``ILIKE`` / ``~*`` on ``document_chunks.content``, scoped to the current +revision. Reports a total match count (over the full in-scope corpus, not +just the returned page) alongside capped snippets, so ANY/ALL logic can close +over body text the same way ``corpus.node_filter`` closes over titles/summaries. +""" + +from __future__ import annotations + +import re +from typing import Any + +from sqlalchemy import func, select + +from shared.models.database.document import Document, DocumentChunk, DocumentSection +from shared.services.retrieval.agent_tools.registry import ( + ToolContext, + ToolResult, + register_tool, +) + +_DEFAULT_MAX_RESULTS = 30 +_DEFAULT_CONTEXT_CHARS = 80 + + +def _build_scope_filters( + *, + user_id: str, + namespace: str, + document_ids: list[str], + chunk_types: set[str], +) -> list[Any]: + filters: list[Any] = [ + Document.user_id == user_id, + Document.namespace == namespace, + Document.status == "active", + Document.current_job_result_id == DocumentChunk.job_result_id, + ] + if document_ids: + filters.append(Document.document_id.in_(document_ids)) + if chunk_types: + filters.append(func.lower(DocumentChunk.chunk_type).in_(sorted(chunk_types))) + return filters + + +@register_tool( + name="corpus.grep", + description=( + "Exact string or regex search against chunk body text (content), " + "not titles/summaries (use corpus.node_filter for that). Returns the " + "total number of matching chunks plus a capped list of snippets." + ), + json_schema={ + "type": "object", + "properties": { + "pattern": {"type": "string"}, + "document_ids": {"type": "array", "items": {"type": "string"}}, + "chunk_types": {"type": "array", "items": {"type": "string"}}, + "is_regex": {"type": "boolean", "default": False}, + "context_chars": {"type": "integer", "default": _DEFAULT_CONTEXT_CHARS}, + "max_results": {"type": "integer", "default": _DEFAULT_MAX_RESULTS}, + }, + "required": ["pattern"], + }, +) +async def grep(ctx: ToolContext, args: dict[str, Any]) -> ToolResult: + pattern = str(args.get("pattern") or "").strip() + if not pattern: + return ToolResult(text="", error="grep requires pattern") + is_regex = bool(args.get("is_regex", False)) + context_chars = int(args.get("context_chars") or _DEFAULT_CONTEXT_CHARS) + max_results = int(args.get("max_results") or _DEFAULT_MAX_RESULTS) + document_ids = [ + str(d).strip() for d in (args.get("document_ids") or []) if str(d).strip() + ] + chunk_types = { + str(t).strip().lower() for t in (args.get("chunk_types") or []) if str(t).strip() + } + + if is_regex: + try: + compiled = re.compile(pattern, flags=re.IGNORECASE) + except re.error as exc: + return ToolResult(text="", error=f"invalid regex: {exc}") + else: + compiled = re.compile(re.escape(pattern), flags=re.IGNORECASE) + + filters = _build_scope_filters( + user_id=ctx.user_id, + namespace=ctx.namespace, + document_ids=document_ids, + chunk_types=chunk_types, + ) + content_filter = ( + DocumentChunk.content.op("~*")(pattern) + if is_regex + else DocumentChunk.content.ilike(f"%{pattern}%") + ) + + count_stmt = ( + select(func.count(DocumentChunk.id)) + .select_from(DocumentChunk) + .join(Document, Document.document_id == DocumentChunk.document_id) + .where(*filters, content_filter) + ) + total_matches = int((await ctx.db.execute(count_stmt)).scalar_one()) + + rows_stmt = ( + select( + DocumentChunk.chunk_id, + DocumentChunk.document_id, + DocumentChunk.chunk_type, + DocumentChunk.content, + DocumentSection.section_path, + Document.source_file_name, + ) + .select_from(DocumentChunk) + .join(Document, Document.document_id == DocumentChunk.document_id) + .outerjoin(DocumentSection, DocumentSection.section_id == DocumentChunk.section_id) + .where(*filters, content_filter) + .order_by(DocumentChunk.document_id, DocumentChunk.sort_order) + .limit(max_results) + ) + rows = (await ctx.db.execute(rows_stmt)).all() + + results: list[dict[str, Any]] = [] + for chunk_id, document_id, chunk_type, content, section_path, source_file_name in rows: + text = str(content or "") + match = compiled.search(text) + if match is None: + snippet = text[: context_chars * 2] + else: + start = max(match.start() - context_chars, 0) + end = min(match.end() + context_chars, len(text)) + snippet = text[start:end] + results.append( + { + "document_id": document_id, + "source_file_name": source_file_name, + "chunk_id": chunk_id, + "chunk_type": chunk_type, + "section_path": section_path, + "snippet": snippet, + } + ) + + lines = [f"total_matches={total_matches} returned={len(results)}"] + for r in results: + lines.append(f"- {r['source_file_name']} / {r['section_path']}: {r['snippet']!r}") + + return ToolResult( + text="\n".join(lines), + payload={"total_matches": total_matches, "results": results}, + refs=[ + {"document_id": r["document_id"], "chunk_id": r["chunk_id"]} for r in results + ], + ) diff --git a/packages/shared-python/shared/services/retrieval/agent_tools/tools/list_documents.py b/packages/shared-python/shared/services/retrieval/agent_tools/tools/list_documents.py new file mode 100644 index 00000000..d0ddffe1 --- /dev/null +++ b/packages/shared-python/shared/services/retrieval/agent_tools/tools/list_documents.py @@ -0,0 +1,81 @@ +"""``corpus.list_documents`` — namespace-level document overview. + +Joins ``documents`` with the document-level ``graph_nodes`` row (§4 of +``CORPUS_SCHEMA.md``) to surface per-document keywords/summary/type-mix +without reading any chunk content. +""" + +from __future__ import annotations + +from typing import Any + +from sqlalchemy import select + +from shared.models.database.document import Document, GraphNode +from shared.services.retrieval.agent_tools.registry import ( + ToolContext, + ToolResult, + register_tool, +) + + +@register_tool( + name="corpus.list_documents", + description=( + "List every active document in the namespace with its parse_track " + "and, when available, document-level graph metadata (top_keywords, " + "top_summary, chunk type mix). Use this to start cold: which " + "documents exist and what are they about, before picking one for " + "outline/node_filter/recall/read." + ), + json_schema={ + "type": "object", + "properties": {}, + "required": [], + }, +) +async def list_documents(ctx: ToolContext, _args: dict[str, Any]) -> ToolResult: + stmt = ( + select(Document, GraphNode.properties) + .outerjoin( + GraphNode, + (GraphNode.owner_document_id == Document.document_id) + & (GraphNode.node_kind == "document"), + ) + .where(Document.user_id == ctx.user_id) + .where(Document.namespace == ctx.namespace) + .where(Document.status == "active") + .order_by(Document.source_file_name) + ) + rows = (await ctx.db.execute(stmt)).all() + + documents: list[dict[str, Any]] = [] + lines: list[str] = [] + for document, properties in rows: + props = properties if isinstance(properties, dict) else {} + entry = { + "document_id": document.document_id, + "source_file_name": document.source_file_name, + "parse_track": document.parse_track, + "top_keywords": props.get("top_keywords") or [], + "top_summary": props.get("top_summary") or "", + "types": props.get("types") or {}, + "chunks_count": props.get("chunks_count"), + } + documents.append(entry) + summary_line = ( + f"- {entry['source_file_name']} ({entry['document_id']}, " + f"track={entry['parse_track']}, chunks={entry['chunks_count']})" + ) + if entry["top_summary"]: + summary_line += f"\n summary: {entry['top_summary']}" + if entry["top_keywords"]: + summary_line += f"\n keywords: {', '.join(entry['top_keywords'])}" + lines.append(summary_line) + + text = f"documents={len(documents)}\n" + "\n".join(lines) if documents else "documents=0" + return ToolResult( + text=text, + payload={"documents": documents}, + refs=[{"document_id": doc["document_id"]} for doc in documents], + ) diff --git a/packages/shared-python/shared/services/retrieval/agent_tools/tools/neighbors.py b/packages/shared-python/shared/services/retrieval/agent_tools/tools/neighbors.py new file mode 100644 index 00000000..2da21d68 --- /dev/null +++ b/packages/shared-python/shared/services/retrieval/agent_tools/tools/neighbors.py @@ -0,0 +1,126 @@ +"""``corpus.neighbors`` — document-level ``related`` graph edges. + +Document-to-document only (§4 of ``CORPUS_SCHEMA.md``): no section- or +entity-level graph nodes exist yet. Edges are undirected and were written by +``DocumentGraphService.publish_document_graph`` with ``shared_entities`` (typed +entity overlap, preferred) or ``shared_keywords`` (TF-IDF fallback). +""" + +from __future__ import annotations + +from typing import Any + +from sqlalchemy import or_, select + +from shared.models.database.document import Document, GraphEdge, GraphNode +from shared.services.retrieval.agent_tools.registry import ( + ToolContext, + ToolResult, + register_tool, +) + + +@register_tool( + name="corpus.neighbors", + description=( + "Return documents related to the given document via the persisted " + "document graph (typed-entity overlap, falling back to TF-IDF " + "keyword overlap), along with the shared terms that justify each " + "edge. Document-level only — there is no section- or entity-level " + "graph yet." + ), + json_schema={ + "type": "object", + "properties": {"document_id": {"type": "string"}}, + "required": ["document_id"], + }, +) +async def neighbors(ctx: ToolContext, args: dict[str, Any]) -> ToolResult: + document_id = str(args.get("document_id") or "").strip() + if not document_id: + return ToolResult(text="", error="neighbors requires document_id") + + document = ( + await ctx.db.execute( + select(Document) + .where(Document.document_id == document_id) + .where(Document.user_id == ctx.user_id) + .where(Document.namespace == ctx.namespace) + .where(Document.status == "active") + ) + ).scalar_one_or_none() + if document is None: + return ToolResult(text="", error=f"unknown document_id: {document_id}") + + node_id = f"doc:{document_id}" + edges = ( + ( + await ctx.db.execute( + select(GraphEdge) + .where(GraphEdge.user_id == ctx.user_id) + .where(GraphEdge.namespace == ctx.namespace) + .where(GraphEdge.edge_kind == "related") + .where( + or_( + GraphEdge.source_node_id == node_id, + GraphEdge.target_node_id == node_id, + ) + ) + ) + ) + .scalars() + .all() + ) + if not edges: + return ToolResult(text="neighbors=0", payload={"neighbors": []}) + + peer_node_ids = { + edge.target_node_id if edge.source_node_id == node_id else edge.source_node_id + for edge in edges + } + peer_nodes = ( + ( + await ctx.db.execute( + select(GraphNode).where(GraphNode.node_id.in_(peer_node_ids)) + ) + ) + .scalars() + .all() + ) + peer_by_id = {n.node_id: n for n in peer_nodes} + + neighbor_list: list[dict[str, Any]] = [] + for edge in sorted(edges, key=lambda e: -(e.weight or 0.0)): + peer_node_id = ( + edge.target_node_id if edge.source_node_id == node_id else edge.source_node_id + ) + peer = peer_by_id.get(peer_node_id) + if peer is None: + continue + props = edge.properties or {} + peer_props = peer.properties or {} + neighbor_list.append( + { + "document_id": peer.owner_document_id, + "source_file_name": peer_props.get("source_file_name"), + "weight": edge.weight, + "edge_basis": props.get("edge_basis"), + "shared_entities": props.get("shared_entities"), + "shared_keywords": props.get("shared_keywords"), + "connection_count": props.get("connection_count"), + } + ) + + lines = [f"neighbors={len(neighbor_list)}"] + for n in neighbor_list: + shared = n["shared_entities"] or n["shared_keywords"] or [] + lines.append( + f"- {n['source_file_name']} ({n['document_id']}) weight={n['weight']} " + f"basis={n['edge_basis']} shared={shared}" + ) + + return ToolResult( + text="\n".join(lines), + payload={"neighbors": neighbor_list}, + refs=[{"document_id": n["document_id"]} for n in neighbor_list], + ) diff --git a/packages/shared-python/shared/services/retrieval/agent_tools/tools/node_filter.py b/packages/shared-python/shared/services/retrieval/agent_tools/tools/node_filter.py new file mode 100644 index 00000000..84116afb --- /dev/null +++ b/packages/shared-python/shared/services/retrieval/agent_tools/tools/node_filter.py @@ -0,0 +1,198 @@ +"""``corpus.node_filter`` — deterministic FOR-ALL/EXISTS/ANY/NOT predicate over sections. + +Reuses the exact predicate compile/match semantics from +``nav.nav_node_filter`` (path/summary substring|regex, fields AND together, +terms OR together) — see that module's docstring — but walks +``document_sections`` rows for the requested documents' current revision +instead of the in-memory map-nav tree. No top-K: returns the full matched set +and its count, per ``CORPUS_SCHEMA.md`` §6. +""" + +from __future__ import annotations + +from typing import Any + +from sqlalchemy import select + +from shared.models.database.document import Document, DocumentChunk, DocumentSection +from shared.services.retrieval.agent_tools.registry import ( + ToolContext, + ToolResult, + register_tool, +) +from shared.services.retrieval.nav.nav_node_filter import ( + FieldPredicate, + _compile_predicates, + _node_matches, + field_predicate, +) + + +@register_tool( + name="corpus.node_filter", + description=( + "Deterministic FOR-ALL/EXISTS/ANY/NOT filter over section titles " + "(section_path) and summaries — not body text (use corpus.grep for " + "that). Predicates AND together across fields; terms within one " + "field's 'terms' list OR together. Returns the complete matched set " + "and its count, never a truncated top-K." + ), + json_schema={ + "type": "object", + "properties": { + "document_ids": { + "type": "array", + "items": {"type": "string"}, + "minItems": 1, + }, + "predicates": { + "type": "array", + "items": { + "type": "object", + "properties": { + "field": {"type": "string", "enum": ["path", "summary"]}, + "terms": {"type": "array", "items": {"type": "string"}}, + "match": { + "type": "string", + "enum": ["substring", "regex"], + "default": "substring", + }, + }, + "required": ["field", "terms"], + }, + "minItems": 1, + }, + "chunk_types": { + "type": "array", + "items": {"type": "string"}, + "description": ( + "Narrow matched sections to those owning a body chunk of " + "one of these chunk_type values (e.g. ['page'] to filter " + "to page-track leaves only). Omit for no narrowing." + ), + }, + }, + "required": ["document_ids", "predicates"], + }, +) +async def node_filter(ctx: ToolContext, args: dict[str, Any]) -> ToolResult: + document_ids = [ + str(did).strip() for did in (args.get("document_ids") or []) if str(did).strip() + ] + if not document_ids: + return ToolResult(text="", error="node_filter requires document_ids") + raw_predicates = args.get("predicates") or [] + if not raw_predicates: + return ToolResult(text="", error="node_filter requires predicates") + + predicates: list[FieldPredicate] = [] + for raw in raw_predicates: + try: + predicates.append( + field_predicate( + raw.get("field"), + raw.get("terms") or [], + raw.get("match", "substring"), + ) + ) + except ValueError as exc: + return ToolResult(text="", error=str(exc)) + + compiled, failed = _compile_predicates(predicates) + if failed: + return ToolResult( + text=f"failed_predicates={failed}", + payload={"cardinality": 0, "matched_sections": [], "failed_predicates": failed}, + error="one or more predicates failed to compile", + ) + + documents = ( + ( + await ctx.db.execute( + select(Document) + .where(Document.document_id.in_(document_ids)) + .where(Document.user_id == ctx.user_id) + .where(Document.namespace == ctx.namespace) + .where(Document.status == "active") + ) + ) + .scalars() + .all() + ) + revision_by_doc = { + d.document_id: d.current_job_result_id for d in documents if d.current_job_result_id + } + if not revision_by_doc: + return ToolResult(text="", error="no active documents found for document_ids") + + revision_pairs = list(revision_by_doc.items()) + sections = ( + ( + await ctx.db.execute( + select(DocumentSection).where( + DocumentSection.document_id.in_([d for d, _ in revision_pairs]) + ) + ) + ) + .scalars() + .all() + ) + sections = [ + s for s in sections if revision_by_doc.get(s.document_id) == s.job_result_id + ] + + chunk_types = { + str(t).strip().lower() for t in (args.get("chunk_types") or []) if str(t).strip() + } + if chunk_types: + chunk_rows = await ctx.db.execute( + select(DocumentChunk.section_id, DocumentChunk.chunk_type).where( + DocumentChunk.document_id.in_([d for d, _ in revision_pairs]) + ) + ) + allowed_section_ids = { + str(section_id) + for section_id, chunk_type in chunk_rows.all() + if section_id and str(chunk_type or "").strip().lower() in chunk_types + } + sections = [s for s in sections if s.section_id in allowed_section_ids] + + matched_sections: list[dict[str, Any]] = [] + matched_doc_ids: list[str] = [] + seen_docs: set[str] = set() + for section in sorted(sections, key=lambda s: (s.document_id, s.sort_order)): + values = {"path": section.section_path, "summary": section.summary or ""} + if not _node_matches(values, compiled): + continue + matched_sections.append( + { + "document_id": section.document_id, + "section_id": section.section_id, + "section_path": section.section_path, + "summary": section.summary or "", + } + ) + if section.document_id not in seen_docs: + seen_docs.add(section.document_id) + matched_doc_ids.append(section.document_id) + + header = f"hits={len(matched_sections)}" + lines = [header] + for entry in matched_sections: + block = [entry["section_path"]] + if entry["summary"]: + block.append(f" summary: {entry['summary']}") + lines.append("\n".join(block)) + + return ToolResult( + text="\n".join(lines), + payload={ + "cardinality": len(matched_sections), + "matched_sections": matched_sections, + "matched_document_ids": matched_doc_ids, + }, + refs=[ + {"document_id": entry["document_id"], "section_path": entry["section_path"]} + for entry in matched_sections + ], + ) diff --git a/packages/shared-python/shared/services/retrieval/agent_tools/tools/outline.py b/packages/shared-python/shared/services/retrieval/agent_tools/tools/outline.py new file mode 100644 index 00000000..db155d77 --- /dev/null +++ b/packages/shared-python/shared/services/retrieval/agent_tools/tools/outline.py @@ -0,0 +1,155 @@ +"""``corpus.outline`` — titles + summaries, no body text, no folding. + +Reads ``document_sections`` (+ a ``document_chunks`` count aggregate) for one +document's current revision, optionally scoped to a ``section_path`` prefix +and depth-limited by the caller's own argument — never by a token budget +(see ``CORPUS_SCHEMA.md`` §6). This queries the live tables directly rather +than the compressed ``RetrievalNamespaceMapSnapshot``/serving-manifest blob: +that snapshot is namespace-wide and decoding it to read one document's +subtree would cost more than this document-scoped, index-backed query. +""" + +from __future__ import annotations + +from typing import Any + +from sqlalchemy import func, select + +from shared.models.database.document import Document, DocumentChunk, DocumentSection +from shared.services.retrieval.agent_tools.registry import ( + ToolContext, + ToolResult, + register_tool, +) +from shared.services.retrieval.search.lexical_text import normalize_section_path + + +@register_tool( + name="corpus.outline", + description=( + "Return the section outline (title + summary + chunk_count) for one " + "document, or the subtree under a section_path prefix, without any " + "body text. Use for overviews, tables of contents, or picking where " + "to look before calling read. Depth is limited by the 'depth' " + "argument only, never truncated by a token budget." + ), + json_schema={ + "type": "object", + "properties": { + "document_id": {"type": "string"}, + "path_prefix": { + "type": "string", + "description": ( + "DB section_path (' / '-joined, e.g. 'Chapter 1 / Section " + "1.1'). Omit or use 'Root' for the whole document." + ), + }, + "depth": { + "type": "integer", + "description": ( + "Max levels below path_prefix (or below the document root " + "when path_prefix is omitted) to include. Omit for the " + "full subtree." + ), + }, + }, + "required": ["document_id"], + }, +) +async def outline(ctx: ToolContext, args: dict[str, Any]) -> ToolResult: + document_id = str(args.get("document_id") or "").strip() + if not document_id: + return ToolResult(text="", error="outline requires document_id") + depth_raw = args.get("depth") + depth = int(depth_raw) if depth_raw is not None else None + if depth is not None and depth < 0: + return ToolResult(text="", error="depth must be >= 0") + raw_prefix = str(args.get("path_prefix") or "").strip() + prefix = normalize_section_path(raw_prefix) if raw_prefix else "Root" + + document = ( + await ctx.db.execute( + select(Document) + .where(Document.document_id == document_id) + .where(Document.user_id == ctx.user_id) + .where(Document.namespace == ctx.namespace) + .where(Document.status == "active") + ) + ).scalar_one_or_none() + if document is None or not document.current_job_result_id: + return ToolResult(text="", error=f"unknown document_id: {document_id}") + job_result_id = document.current_job_result_id + + section_stmt = ( + select(DocumentSection) + .where(DocumentSection.document_id == document_id) + .where(DocumentSection.job_result_id == job_result_id) + .order_by(DocumentSection.sort_order, DocumentSection.section_id) + ) + all_sections = list((await ctx.db.execute(section_stmt)).scalars().all()) + + base_level: int | None = None + if prefix == "Root": + base_level = 0 + scoped = all_sections + else: + prefix_section = next( + (s for s in all_sections if s.section_path == prefix), None + ) + if prefix_section is None: + return ToolResult( + text="", error=f"unknown path_prefix for {document_id}: {prefix}" + ) + base_level = prefix_section.section_level + scoped = [ + s + for s in all_sections + if s.section_path == prefix or s.section_path.startswith(f"{prefix} / ") + ] + + if depth is not None: + scoped = [s for s in scoped if (s.section_level - base_level) <= depth] + + section_ids = [s.section_id for s in scoped] + chunk_counts: dict[str, int] = {} + if section_ids: + count_rows = await ctx.db.execute( + select(DocumentChunk.section_id, func.count(DocumentChunk.id)) + .where(DocumentChunk.document_id == document_id) + .where(DocumentChunk.job_result_id == job_result_id) + .where(DocumentChunk.section_id.in_(section_ids)) + .group_by(DocumentChunk.section_id) + ) + chunk_counts = {str(sid): int(count) for sid, count in count_rows.all()} + + nodes: list[dict[str, Any]] = [] + lines: list[str] = [] + for section in scoped: + relative_depth = section.section_level - base_level + node = { + "section_id": section.section_id, + "section_path": section.section_path, + "section_title": section.section_title, + "section_level": section.section_level, + "relative_depth": relative_depth, + "summary": section.summary or "", + "chunk_count": chunk_counts.get(section.section_id, 0), + } + nodes.append(node) + indent = " " * max(relative_depth, 0) + line = f"{indent}- {node['section_title'] or node['section_path']} (chunks={node['chunk_count']})" + if node["summary"]: + line += f"\n{indent} summary: {node['summary']}" + lines.append(line) + + text = f"document={document.source_file_name} sections={len(nodes)}\n" + "\n".join( + lines + ) + return ToolResult( + text=text, + payload={"document_id": document_id, "sections": nodes}, + refs=[ + {"document_id": document_id, "section_path": node["section_path"]} + for node in nodes + ], + ) diff --git a/packages/shared-python/shared/services/retrieval/agent_tools/tools/read.py b/packages/shared-python/shared/services/retrieval/agent_tools/tools/read.py new file mode 100644 index 00000000..2b532a3f --- /dev/null +++ b/packages/shared-python/shared/services/retrieval/agent_tools/tools/read.py @@ -0,0 +1,401 @@ +"""``corpus.read`` — full body content for already-located sections/chunks. + +Unlike ``hydration.result_assembly.assemble_retrieval_results`` (which +down-weights ``page`` chunks to their summary — see that module's +``_page_summary``, a deliberate trade-off for the retrieval-answer surface), +``read`` returns the page chunk's full body content, with ``[SAME-AS +p]`` markers resolved to the owner section's text (§2 of +``CORPUS_SCHEMA.md``) rather than stripped or summarized. ``connect_to`` +assets are still inlined via the same placeholder mechanism as retrieval, and +``page_assets``/asset ``file_path`` are converted to URLs via the existing +``enrich_rows_with_retrieval_asset_url``. + +SAME-AS resolution is single-level: the owner chunk's full content is +embedded as-is. If that owner chunk itself still contains an unrelated +SAME-AS marker (a different leaf's page), it is not recursively resolved in +this pass — a disclosed scope limit, not a silent gap (the raw marker stays +visible in the embedded text). +""" + +from __future__ import annotations + +import re +from typing import Any + +from sqlalchemy import or_, select + +from shared.models.database.document import ( + Document, + DocumentChunk, + DocumentSection, +) +from shared.models.database.job_result import JobResult +from shared.services.retrieval.agent_tools.registry import ( + ToolContext, + ToolResult, + register_tool, +) +from shared.services.retrieval.hydration.asset_inline import ( + inline_assets_at_placeholders, +) +from shared.services.retrieval.hydration.assets import ( + enrich_rows_with_retrieval_asset_url, +) +from shared.services.retrieval.hydration.connected import hydrate_connected_target_rows +from shared.services.retrieval.hydration.result_assembly import ( + _compose_table_content, + _compose_text_content, + _connected_display_by_target, + _image_display_content, +) +from shared.services.retrieval.hydration.row_utils import normalize_chunk_type +from shared.services.retrieval.search.lexical_text import ( + normalize_section_path, + section_path_from_chunk_path, +) + +_SAME_AS_MARKER_RE = re.compile(r"\[SAME-AS (.+?) p(\d+)\]") +_BODY_CHUNK_TYPES = ("text", "page") + + +def _compose_page_content( + row: dict[str, Any], rows_by_chunk_id: dict[str, dict[str, Any]] +) -> str: + """Like ``_compose_text_content`` but never downgraded to a summary.""" + base_content = str(row.get("content") or "") + display_by_target = _connected_display_by_target(row, rows_by_chunk_id) + if not display_by_target: + return base_content + metadata = row.get("chunk_metadata") or {} + connections = metadata.get("connect_to") if isinstance(metadata, dict) else None + content, _embedded = inline_assets_at_placeholders( + base_content, + connections=connections if isinstance(connections, list) else [], + display_by_target=display_by_target, + ) + return content + + +async def _resolve_same_as_markers( + db: Any, + rows: list[dict[str, Any]], + *, + revision_by_doc: dict[str, str], + source_file_name_by_doc: dict[str, str], +) -> None: + """Mutate ``page`` rows in place, replacing SAME-AS markers with owner text.""" + matches_by_index: dict[int, list[tuple[str, str]]] = {} + needed: set[tuple[str, str]] = set() + for index, row in enumerate(rows): + if normalize_chunk_type(row.get("chunk_type")) != "page": + continue + content = str(row.get("content") or "") + found = list(_SAME_AS_MARKER_RE.finditer(content)) + if not found: + continue + document_id = str(row.get("document_id") or "") + source_file_name = source_file_name_by_doc.get(document_id) + row_matches: list[tuple[str, str]] = [] + for match in found: + owner_db_path = section_path_from_chunk_path( + match.group(1), source_file_name=source_file_name + ) + needed.add((document_id, owner_db_path)) + row_matches.append((match.group(0), owner_db_path)) + matches_by_index[index] = row_matches + + if not needed: + return + + owner_content: dict[tuple[str, str], str] = {} + for document_id, owner_path in needed: + job_result_id = revision_by_doc.get(document_id) + if not job_result_id: + continue + result = await db.execute( + select(DocumentChunk.content) + .select_from(DocumentChunk) + .join(DocumentSection, DocumentSection.section_id == DocumentChunk.section_id) + .where(DocumentChunk.document_id == document_id) + .where(DocumentChunk.job_result_id == job_result_id) + .where(DocumentSection.section_path == owner_path) + .where(DocumentChunk.chunk_type == "page") + ) + content_row = result.first() + owner_content[(document_id, owner_path)] = ( + str(content_row[0]) if content_row and content_row[0] else "" + ) + + for index, row_matches in matches_by_index.items(): + row = rows[index] + content = str(row.get("content") or "") + document_id = str(row.get("document_id") or "") + for marker_text, owner_path in row_matches: + resolved = owner_content.get((document_id, owner_path), "") + if resolved: + replacement = f"(SAME-AS {owner_path} resolved)\n{resolved}" + else: + replacement = f"(SAME-AS {owner_path} — page not found)" + content = content.replace(marker_text, replacement, 1) + row["content"] = content + + +@register_tool( + name="corpus.read", + description=( + "Read full body content for already-located sections or chunks. " + "Resolves page-track SAME-AS pointers to the owner section's text, " + "inlines connect_to assets, and converts asset/page_assets " + "references to URLs. Use after outline/node_filter/recall/grep " + "have located where to look." + ), + json_schema={ + "type": "object", + "properties": { + "refs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "document_id": {"type": "string"}, + "section_path": {"type": "string"}, + "chunk_id": {"type": "string"}, + }, + "required": ["document_id"], + }, + "minItems": 1, + "description": "Each ref needs document_id and either section_path or chunk_id.", + }, + "mode": { + "type": "string", + "enum": ["self", "descendants"], + "default": "self", + "description": ( + "'descendants' also reads every section under a " + "section_path ref; ignored for chunk_id refs." + ), + }, + "include_assets": {"type": "boolean", "default": True}, + "resolve_same_as": {"type": "boolean", "default": True}, + }, + "required": ["refs"], + }, +) +async def read(ctx: ToolContext, args: dict[str, Any]) -> ToolResult: + refs = args.get("refs") or [] + if not refs: + return ToolResult(text="", error="read requires refs") + mode = str(args.get("mode") or "self").strip().lower() + if mode not in ("self", "descendants"): + return ToolResult(text="", error=f"unsupported mode: {mode}") + include_assets = bool(args.get("include_assets", True)) + resolve_same_as_flag = bool(args.get("resolve_same_as", True)) + + document_ids = { + str(ref.get("document_id") or "").strip() for ref in refs if ref.get("document_id") + } + documents = ( + ( + await ctx.db.execute( + select(Document) + .where(Document.document_id.in_(document_ids)) + .where(Document.user_id == ctx.user_id) + .where(Document.namespace == ctx.namespace) + .where(Document.status == "active") + ) + ) + .scalars() + .all() + ) + revision_by_doc = { + d.document_id: d.current_job_result_id for d in documents if d.current_job_result_id + } + source_file_name_by_doc = {d.document_id: d.source_file_name or "" for d in documents} + job_result_ids = sorted(set(revision_by_doc.values())) + job_id_by_revision: dict[str, str] = {} + if job_result_ids: + job_rows = await ctx.db.execute( + select(JobResult.id, JobResult.job_id).where(JobResult.id.in_(job_result_ids)) + ) + job_id_by_revision = {str(rid): str(jid) for rid, jid in job_rows.all() if rid and jid} + + base_rows: list[dict[str, Any]] = [] + errors: list[str] = [] + for ref in refs: + document_id = str(ref.get("document_id") or "").strip() + job_result_id = revision_by_doc.get(document_id) + if not job_result_id: + errors.append(f"unknown document_id: {document_id}") + continue + chunk_id = str(ref.get("chunk_id") or "").strip() + section_path = str(ref.get("section_path") or "").strip() + source_file_name = source_file_name_by_doc.get(document_id, "") + job_id = job_id_by_revision.get(job_result_id) + + if chunk_id: + row = ( + await ctx.db.execute( + select(DocumentChunk, DocumentSection.section_path) + .select_from(DocumentChunk) + .outerjoin( + DocumentSection, + DocumentSection.section_id == DocumentChunk.section_id, + ) + .where(DocumentChunk.document_id == document_id) + .where(DocumentChunk.job_result_id == job_result_id) + .where(DocumentChunk.chunk_id == chunk_id) + ) + ).first() + if row is None: + errors.append(f"unknown chunk_id: {chunk_id} in {document_id}") + continue + chunk, resolved_section_path = row + base_rows.append( + { + "document_id": document_id, + "job_result_id": job_result_id, + "job_id": job_id, + "source_file_name": source_file_name, + "chunk_id": chunk.chunk_id, + "section_id": chunk.section_id, + "section_path": resolved_section_path, + "chunk_type": chunk.chunk_type, + "content": chunk.content, + "chunk_metadata": chunk.chunk_metadata or {}, + "file_path": chunk.file_path, + } + ) + continue + + if not section_path: + errors.append(f"ref for {document_id} needs section_path or chunk_id") + continue + + normalized = normalize_section_path(section_path) + path_filter = DocumentSection.section_path == normalized + if mode == "descendants": + path_filter = or_( + path_filter, DocumentSection.section_path.like(f"{normalized} / %") + ) + section_rows = ( + ( + await ctx.db.execute( + select(DocumentSection) + .where(DocumentSection.document_id == document_id) + .where(DocumentSection.job_result_id == job_result_id) + .where(path_filter) + .order_by(DocumentSection.sort_order) + ) + ) + .scalars() + .all() + ) + if not section_rows: + errors.append(f"unknown section_path for {document_id}: {normalized}") + continue + section_ids = [s.section_id for s in section_rows] + chunk_rows = ( + await ctx.db.execute( + select(DocumentChunk).where( + DocumentChunk.document_id == document_id, + DocumentChunk.job_result_id == job_result_id, + DocumentChunk.section_id.in_(section_ids), + # Body chunks only (text/page). image/table chunks share a + # section_id with whichever section happens to store them + # in the DB (always Root — CORPUS_SCHEMA.md §3), which is + # not the same as "belonging" to that section; their real + # association is connect_to on the body chunk, resolved + # below via hydrate_connected_target_rows. Without this + # filter, reading Root would return every still-unmounted + # asset in the document as spurious top-level entries. + DocumentChunk.chunk_type.in_(_BODY_CHUNK_TYPES), + ) + ) + ).scalars().all() + section_path_by_id = {s.section_id: s.section_path for s in section_rows} + for chunk in chunk_rows: + base_rows.append( + { + "document_id": document_id, + "job_result_id": job_result_id, + "job_id": job_id, + "source_file_name": source_file_name, + "chunk_id": chunk.chunk_id, + "section_id": chunk.section_id, + "section_path": ( + section_path_by_id.get(chunk.section_id) + if chunk.section_id + else None + ), + "chunk_type": chunk.chunk_type, + "content": chunk.content, + "chunk_metadata": chunk.chunk_metadata or {}, + "file_path": chunk.file_path, + } + ) + + if not base_rows: + return ToolResult( + text="", + error="no chunks resolved for given refs" + (f" ({'; '.join(errors)})" if errors else ""), + ) + + if resolve_same_as_flag: + await _resolve_same_as_markers( + ctx.db, + base_rows, + revision_by_doc=revision_by_doc, + source_file_name_by_doc=source_file_name_by_doc, + ) + + connected_rows: list[dict[str, Any]] = [] + if include_assets: + connected_rows = await hydrate_connected_target_rows( + db=ctx.db, + rows=base_rows, + exclude_document_ids=[], + exclude_sections=[], + ) + rows_by_chunk_id = { + str(row.get("chunk_id") or ""): row + for row in [*base_rows, *connected_rows] + if row.get("chunk_id") + } + + assembled: list[dict[str, Any]] = [] + for row in base_rows: + chunk_type = normalize_chunk_type(row.get("chunk_type")) + composed = dict(row) + if chunk_type == "text": + composed["content"] = _compose_text_content(row, rows_by_chunk_id) if include_assets else row.get("content") + elif chunk_type == "page": + composed["content"] = _compose_page_content(row, rows_by_chunk_id) if include_assets else row.get("content") + elif chunk_type == "table": + composed["content"] = _compose_table_content(row, rows_by_chunk_id) + elif chunk_type == "image": + composed["content"] = _image_display_content(row) + assembled.append(composed) + + if include_assets: + assembled = await enrich_rows_with_retrieval_asset_url( + assembled, log_context="agent_tools.read" + ) + + lines = [] + if errors: + lines.append(f"errors: {'; '.join(errors)}") + for row in assembled: + lines.append( + f"### {row.get('source_file_name')} / {row.get('section_path')} " + f"[{row.get('chunk_type')}]" + ) + lines.append(str(row.get("content") or "")) + + return ToolResult( + text="\n".join(lines), + payload={"chunks": assembled, "errors": errors}, + refs=[ + {"document_id": row["document_id"], "chunk_id": row["chunk_id"]} + for row in assembled + ], + ) diff --git a/packages/shared-python/shared/services/retrieval/agent_tools/tools/recall.py b/packages/shared-python/shared/services/retrieval/agent_tools/tools/recall.py new file mode 100644 index 00000000..62b870a2 --- /dev/null +++ b/packages/shared-python/shared/services/retrieval/agent_tools/tools/recall.py @@ -0,0 +1,250 @@ +"""``corpus.recall`` — fuzzy ranked candidate search. + +Two real channels today, fused by the existing RRF utility +(``search.scoring.merge_channels_rrf``, same ``RRF_K`` as every other RRF +fusion in retrieval): + +- ``path_content``: reuses ``search.map_unit_discovery.map_unit_discovery`` + (persisted map-unit BM25 over path+content, already RRF-fused internally). +- ``term``: a fresh substring channel over + ``document_map_units.term_search_text_lower`` — this column is persisted at + index time but, before this tool, was only read by map_unit_discovery's + *legacy* PG-FTS fallback, never as an independently-ranked channel (see + ``search/map_unit_discovery.py`` module docstring and AGENTS.md Stage ⑤). + +``vector`` is accepted in ``channels`` but rejected as reserved/not +implemented (``CORPUS_SCHEMA.md`` §5) — it is not silently ignored. + +Fusing an already-doubly-fused channel (path_content) with a fresh single +channel (term) at equal RRF weight is a necessary, disclosed design choice: +there is no persisted precedent for a different weight ratio between them +(the old 3-channel weights of path=1.0/content=2.0/term=1.5 no longer exist +in code — only path=1.0/content=2.0 survive in ``nav.knowhere_hybrid``). +""" + +from __future__ import annotations + +from typing import Any + +from sqlalchemy import select, text +from sqlalchemy.ext.asyncio import AsyncSession + +from shared.models.database.document import Document, DocumentChunk +from shared.services.retrieval.agent_tools.registry import ( + ToolContext, + ToolResult, + register_tool, +) +from shared.services.retrieval.search.map_unit_discovery import map_unit_discovery +from shared.services.retrieval.search.scoring import merge_channels_rrf + +_SUPPORTED_CHANNELS = {"path_content", "term"} +_RESERVED_CHANNELS = {"vector"} +_DEFAULT_TOP_K = 20 + +_TERM_CHANNEL_SQL = """ +SELECT dmu.document_id, dmu.job_result_id, dmu.section_id, ds.section_path, + d.source_file_name, dmu.term_search_text_lower +FROM document_map_units dmu +JOIN documents d + ON d.document_id = dmu.document_id + AND d.current_job_result_id = dmu.job_result_id +JOIN document_sections ds ON ds.section_id = dmu.section_id +WHERE d.user_id = :user_id + AND d.namespace = :namespace + AND d.status = 'active' + {doc_clause} + AND dmu.term_search_text_lower LIKE :like_pattern +ORDER BY POSITION(:needle IN dmu.term_search_text_lower) ASC +LIMIT :limit +""" + + +async def _excluded_document_ids( + db: AsyncSession, *, user_id: str, namespace: str, document_ids: list[str] +) -> list[str]: + if not document_ids: + return [] + rows = await db.execute( + select(Document.document_id) + .where(Document.user_id == user_id) + .where(Document.namespace == namespace) + .where(Document.status == "active") + .where(Document.document_id.notin_(document_ids)) + ) + return [str(r[0]) for r in rows.all()] + + +async def _term_channel_rows( + db: AsyncSession, + *, + user_id: str, + namespace: str, + query: str, + document_ids: list[str], + chunk_types: set[str] | None, + top_k: int, +) -> list[dict[str, Any]]: + needle = query.strip().lower() + if not needle: + return [] + params: dict[str, Any] = { + "user_id": user_id, + "namespace": namespace, + "like_pattern": f"%{needle}%", + "needle": needle, + "limit": top_k, + } + doc_clause = "" + if document_ids: + doc_clause = "AND d.document_id = ANY(:doc_ids)" + params["doc_ids"] = document_ids + statement = text(_TERM_CHANNEL_SQL.format(doc_clause=doc_clause)) + unit_rows = [dict(row._mapping) for row in (await db.execute(statement, params)).all()] + if not unit_rows: + return [] + + keys = [ + (row["document_id"], row["job_result_id"], row["section_id"]) for row in unit_rows + ] + chunk_result = await db.execute( + select(DocumentChunk).where( + DocumentChunk.document_id.in_({k[0] for k in keys}), + DocumentChunk.job_result_id.in_({k[1] for k in keys}), + DocumentChunk.section_id.in_({k[2] for k in keys}), + ) + ) + chunk_by_key = { + (c.document_id, c.job_result_id, c.section_id): c + for c in chunk_result.scalars().all() + } + + results: list[dict[str, Any]] = [] + for unit_row in unit_rows: + key = (unit_row["document_id"], unit_row["job_result_id"], unit_row["section_id"]) + chunk = chunk_by_key.get(key) + if chunk is None: + continue + if chunk_types and chunk.chunk_type not in chunk_types: + continue + needle_pos = unit_row["term_search_text_lower"].find(needle) + window_start = max(needle_pos - 80, 0) + window_end = min(needle_pos + len(needle) + 80, len(unit_row["term_search_text_lower"])) + results.append( + { + "chunk_id": chunk.chunk_id, + "document_id": chunk.document_id, + "section_id": chunk.section_id, + "section_path": unit_row["section_path"], + "source_file_name": unit_row["source_file_name"], + "chunk_type": chunk.chunk_type, + "snippet": unit_row["term_search_text_lower"][window_start:window_end], + } + ) + return results + + +@register_tool( + name="corpus.recall", + description=( + "Fuzzy ranked candidate search for a question when you don't know " + "where the answer lives. Fuses a path+content BM25 channel with a " + "term substring channel via RRF. Returns candidates with path and " + "snippet, not full content — call corpus.read on the winners." + ), + json_schema={ + "type": "object", + "properties": { + "query": {"type": "string"}, + "document_ids": {"type": "array", "items": {"type": "string"}}, + "chunk_types": {"type": "array", "items": {"type": "string"}}, + "channels": { + "type": "array", + "items": { + "type": "string", + "enum": ["path_content", "term", "vector"], + }, + "default": ["path_content", "term"], + "description": "'vector' is reserved and not implemented yet.", + }, + "top_k": {"type": "integer", "default": _DEFAULT_TOP_K}, + }, + "required": ["query"], + }, +) +async def recall(ctx: ToolContext, args: dict[str, Any]) -> ToolResult: + query = str(args.get("query") or "").strip() + if not query: + return ToolResult(text="", error="recall requires query") + top_k = int(args.get("top_k") or _DEFAULT_TOP_K) + document_ids = [ + str(d).strip() for d in (args.get("document_ids") or []) if str(d).strip() + ] + chunk_types = { + str(t).strip().lower() for t in (args.get("chunk_types") or []) if str(t).strip() + } or None + requested_channels = set(args.get("channels") or list(_SUPPORTED_CHANNELS)) + reserved_requested = requested_channels & _RESERVED_CHANNELS + active_channels = requested_channels & _SUPPORTED_CHANNELS + unknown_channels = requested_channels - _SUPPORTED_CHANNELS - _RESERVED_CHANNELS + if unknown_channels: + return ToolResult(text="", error=f"unsupported channels: {sorted(unknown_channels)}") + if not active_channels: + return ToolResult( + text="", + error="no runnable channels requested (vector is reserved, not implemented)", + ) + + channel_rows: list[list[dict[str, Any]]] = [] + weights: list[float] = [] + + if "path_content" in active_channels: + exclude_document_ids = await _excluded_document_ids( + ctx.db, user_id=ctx.user_id, namespace=ctx.namespace, document_ids=document_ids + ) + discovery = await map_unit_discovery( + ctx.db, + user_id=ctx.user_id, + namespace=ctx.namespace, + query=query, + top_k=top_k, + exclude_document_ids=exclude_document_ids, + exclude_sections=[], + chunk_types=chunk_types, + ) + channel_rows.append(list(discovery.payload.get("fused_rows") or [])) + weights.append(1.0) + + if "term" in active_channels: + term_rows = await _term_channel_rows( + ctx.db, + user_id=ctx.user_id, + namespace=ctx.namespace, + query=query, + document_ids=document_ids, + chunk_types=chunk_types, + top_k=top_k, + ) + channel_rows.append(term_rows) + weights.append(1.0) + + fused = merge_channels_rrf(channel_rows, weights, top_k) + + lines = [f"candidates={len(fused)}"] + if reserved_requested: + lines.append(f"note: channels {sorted(reserved_requested)} are reserved, not run") + for row in fused: + snippet = str(row.get("content") or row.get("snippet") or "")[:200] + lines.append( + f"- {row.get('source_file_name')} / {row.get('section_path')} " + f"score={row.get('score')}: {snippet!r}" + ) + + return ToolResult( + text="\n".join(lines), + payload={"candidates": fused, "reserved_channels": sorted(reserved_requested)}, + refs=[ + {"document_id": row.get("document_id"), "chunk_id": row.get("chunk_id")} + for row in fused + ], + ) diff --git a/packages/shared-python/shared/services/retrieval/nav/nav_knowhere.py b/packages/shared-python/shared/services/retrieval/nav/nav_knowhere.py index cd569631..bacce7f0 100644 --- a/packages/shared-python/shared/services/retrieval/nav/nav_knowhere.py +++ b/packages/shared-python/shared/services/retrieval/nav/nav_knowhere.py @@ -49,6 +49,9 @@ ) _ASSET_TYPES = ("table", "image") +# Body chunk types that can own a Root-parked asset via connect_to. Both +# chunk-track ("text") and page-track ("page") body chunks can embed assets. +_BODY_CHUNK_TYPES = ("text", "page") # Knowhere sentinel path for the virtual document container (not a collectable leaf). ROOT_SECTION_PATH = "Root" _DEFAULT_DSN = "postgresql://root:root123@127.0.0.1:5433/Knowhere" @@ -736,7 +739,7 @@ def _remount_root_assets(self) -> None: if row is None or is_root_section_path(row.section_path): continue for unit in units: - if unit.chunk_type != "text": + if unit.chunk_type not in _BODY_CHUNK_TYPES: continue for target in _connect_to_targets(unit.metadata or {}): if target in root_assets and target not in owner_by_asset: diff --git a/packages/shared-python/shared/services/retrieval/serving_manifest.py b/packages/shared-python/shared/services/retrieval/serving_manifest.py index 77a33e1c..11b727ee 100644 --- a/packages/shared-python/shared/services/retrieval/serving_manifest.py +++ b/packages/shared-python/shared/services/retrieval/serving_manifest.py @@ -23,6 +23,10 @@ SERVING_MANIFEST_FORMAT_VERSION = 1 NAMESPACE_MAP_SNAPSHOT_FORMAT_VERSION = 2 +# Body chunk types that can own a Root-parked asset via connect_to. Both +# chunk-track ("text") and page-track ("page") body chunks can embed assets. +_BODY_CHUNK_TYPES = {"text", "page"} + def build_revision_serving_payload( db: Session, @@ -66,7 +70,9 @@ def build_revision_serving_payload( } remounted_assets: dict[str, list[str]] = {} for chunk in chunks: - if chunk.chunk_type != "text" or not isinstance(chunk.chunk_metadata, dict): + if chunk.chunk_type not in _BODY_CHUNK_TYPES or not isinstance( + chunk.chunk_metadata, dict + ): continue connections = chunk.chunk_metadata.get("connect_to") if not isinstance(connections, list): From 38ecbe759495a180267b9e325da0b4edd4cb2bf3 Mon Sep 17 00:00:00 2001 From: chengke <404835780@qq.com> Date: Tue, 8 Sep 2026 16:42:01 +0800 Subject: [PATCH 2/6] feat(retrieval): enhance retrieval server and agent tools Updated the README to reflect the new Knowhere 2.0 branding. Enhanced the retrieval server by integrating corpus tools for improved functionality and backward compatibility. Introduced a character budget for evidence text across various tools, ensuring consistent output limits. Refactored agent tools to utilize shared snippet building logic, improving code maintainability and reducing duplication. Added support for a new agentic routing mechanism to facilitate exploration through the agent_explore path. --- README.md | 2 +- apps/api/app/mcp/dynamic_tools.py | 80 +++++ apps/api/app/mcp/retrieval_server.py | 12 +- docs/assets/knowhere-banner-2.0.png | Bin 0 -> 187094 bytes .../retrieval/agent_explore/__init__.py | 25 ++ .../retrieval/agent_explore/bridge.py | 52 +++ .../retrieval/agent_explore/budget.py | 80 +++++ .../retrieval/agent_explore/config.py | 98 ++++++ .../retrieval/agent_explore/episode.py | 311 ++++++++++++++++++ .../retrieval/agent_explore/ref_resolution.py | 94 ++++++ .../services/retrieval/agent_explore/types.py | 32 ++ .../retrieval/agent_tools/__init__.py | 4 + .../retrieval/agent_tools/registry.py | 30 +- .../retrieval/agent_tools/schema_doc.py | 21 ++ .../services/retrieval/agent_tools/snippet.py | 72 ++++ .../retrieval/agent_tools/tools/grep.py | 26 +- .../agent_tools/tools/node_filter.py | 3 +- .../retrieval/agent_tools/tools/read.py | 29 +- .../retrieval/agent_tools/tools/recall.py | 24 +- .../services/retrieval/execution/routes.py | 132 +++++++- .../shared/services/retrieval/nav_config.py | 3 +- .../shared/services/retrieval/settings.py | 5 + 22 files changed, 1083 insertions(+), 52 deletions(-) create mode 100644 apps/api/app/mcp/dynamic_tools.py create mode 100644 docs/assets/knowhere-banner-2.0.png create mode 100644 packages/shared-python/shared/services/retrieval/agent_explore/__init__.py create mode 100644 packages/shared-python/shared/services/retrieval/agent_explore/bridge.py create mode 100644 packages/shared-python/shared/services/retrieval/agent_explore/budget.py create mode 100644 packages/shared-python/shared/services/retrieval/agent_explore/config.py create mode 100644 packages/shared-python/shared/services/retrieval/agent_explore/episode.py create mode 100644 packages/shared-python/shared/services/retrieval/agent_explore/ref_resolution.py create mode 100644 packages/shared-python/shared/services/retrieval/agent_explore/types.py create mode 100644 packages/shared-python/shared/services/retrieval/agent_tools/schema_doc.py create mode 100644 packages/shared-python/shared/services/retrieval/agent_tools/snippet.py diff --git a/README.md b/README.md index 8f0178ca..1b961d4f 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -20260506-102713 +Knowhere 2.0

Prepare unstructured data for AI Agents

diff --git a/apps/api/app/mcp/dynamic_tools.py b/apps/api/app/mcp/dynamic_tools.py new file mode 100644 index 00000000..3a52b2fb --- /dev/null +++ b/apps/api/app/mcp/dynamic_tools.py @@ -0,0 +1,80 @@ +"""Bridge ``shared.services.retrieval.agent_tools.REGISTRY`` onto FastMCP. + +FastMCP's public registration API (``FastMCP.tool`` / ``ToolManager.add_tool``) +only builds a tool's schema by introspecting a Python function's *signature* +(``mcp.server.fastmcp.tools.base.Tool.from_function`` -> ``func_metadata``); +there is no public entry point to register a tool from an already-built JSON +Schema dict, which is what every ``agent_tools.ToolSpec`` carries. Since our +schema is the one already shipped to ``agent_explore`` and meant to be +verbatim-identical across harnesses (see ``CORPUS_SCHEMA.md``), we construct +``Tool`` objects directly instead of round-tripping through a synthetic +Python function signature, and insert them into the tool manager's registry +dict — the same dict ``ToolManager.__init__`` accepts a ``tools=`` list for, +just with no public single-tool equivalent of that constructor path. +""" + +from __future__ import annotations + +from typing import Any, AsyncContextManager, Callable + +from mcp.server.fastmcp import Context, FastMCP +from mcp.server.fastmcp.tools.base import Tool +from mcp.server.fastmcp.utilities.func_metadata import ArgModelBase, FuncMetadata +from pydantic import create_model +from sqlalchemy.ext.asyncio import AsyncSession + +from shared.services.retrieval.agent_tools import REGISTRY, ToolContext, ToolSpec +from shared.services.retrieval.agent_tools import tools as _agent_tools_registered # noqa: F401 + +DbFactory = Callable[[], AsyncContextManager[AsyncSession]] + + +def _lenient_arg_model(spec: ToolSpec) -> type[ArgModelBase]: + """Build a permissive pydantic arg model for FastMCP's internal validation. + + The schema actually exposed to MCP clients is ``spec.json_schema`` + (``Tool.parameters``, returned verbatim in ``tools/list`` — see + ``FastMCP.list_tools``); this model only has to satisfy + ``FuncMetadata.call_fn_with_arg_validation`` well enough to forward + whatever the client sent through to ``_dispatch_tool``'s ``**kwargs``. + Every property is optional/``Any`` — each tool already validates its own + required args and reports a caller-facing ``ToolResult.error`` for a + missing one, so duplicating "required" enforcement here would just + produce a less informative MCP-level error instead. + """ + properties = spec.json_schema.get("properties", {}) + fields: dict[str, Any] = {key: (Any, None) for key in properties} + return create_model(f"{spec.name.replace('.', '_')}_Args", __base__=ArgModelBase, **fields) + + +def _make_tool(spec: ToolSpec, *, db_factory: DbFactory) -> Tool: + async def _dispatch_tool( + ctx: Context | None = None, **kwargs: Any + ) -> dict[str, Any]: + from app.mcp.retrieval_server import resolve_mcp_namespace, resolve_mcp_user_id + + namespace = resolve_mcp_namespace(ctx=ctx) + async with db_factory() as db: + user_id = await resolve_mcp_user_id(ctx=ctx, db=db) + tool_ctx = ToolContext(db=db, user_id=user_id, namespace=namespace) + result = await REGISTRY.dispatch(spec.name, tool_ctx, kwargs) + return {"text": result.text, "payload": result.payload, "refs": result.refs, "error": result.error} + + return Tool( + fn=_dispatch_tool, + name=spec.name, + title=None, + description=spec.description, + parameters=spec.json_schema, + fn_metadata=FuncMetadata(arg_model=_lenient_arg_model(spec)), + is_async=True, + context_kwarg="ctx", + annotations=None, + ) + + +def register_corpus_tools(server: FastMCP, *, db_factory: DbFactory) -> None: + """Register every ``agent_tools.REGISTRY`` tool onto ``server``.""" + for spec in REGISTRY.all(): + tool = _make_tool(spec, db_factory=db_factory) + server._tool_manager._tools[tool.name] = tool diff --git a/apps/api/app/mcp/retrieval_server.py b/apps/api/app/mcp/retrieval_server.py index 81210b86..89845b64 100644 --- a/apps/api/app/mcp/retrieval_server.py +++ b/apps/api/app/mcp/retrieval_server.py @@ -10,8 +10,10 @@ from pydantic import Field from sqlalchemy.ext.asyncio import AsyncSession +from app.mcp.dynamic_tools import register_corpus_tools from shared.core.database import get_db_context from shared.models.schemas.retrieval_namespace import normalize_retrieval_namespace +from shared.services.retrieval.agent_tools import load_corpus_schema_text from shared.services.retrieval.app_service import run_retrieval_query from shared.services.retrieval.settings import DEFAULT_TOP_K @@ -85,16 +87,16 @@ def create_retrieval_mcp_server( server = FastMCP( "knowhere-retrieval", instructions=( - "Use this server to search published documents. " - "It returns evidence_text (hierarchical evidence tree), " - "referenced_chunks (structured chunk citations), and " - "decision_trace (navigation decisions). " - "Downstream agents should synthesize answers from evidence_text." + "retrieval.query is a one-shot legacy search tool retained for " + "backward compatibility (see its own tool description below). " + "Prefer the corpus.* tools for exploration; the schema below " + "describes what they operate on.\n\n" + load_corpus_schema_text() ), streamable_http_path=streamable_http_path, stateless_http=True, transport_security=create_public_mcp_transport_security(), ) + register_corpus_tools(server, db_factory=db_factory) @server.tool( name="retrieval.query", diff --git a/docs/assets/knowhere-banner-2.0.png b/docs/assets/knowhere-banner-2.0.png new file mode 100644 index 0000000000000000000000000000000000000000..6332a6eeee9fc9e93731627ae55b96399d1da309 GIT binary patch literal 187094 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

0M201kgYqczfX7n8`5Ne2^#3fa{ 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

F!EpyX{(2cvUq$-e|ge9W{pJ=qmi8>OE-LgDr zfQEa-W>_WA2xw+Uf+k|a{>Jc<0M05ySpV-40*@B_M>E69lu>9JaieJN;v31$#w}&f zWE6DBj5&I2TCvGv$7I+5t^2MUfCfu+tf ztsy^JCZ7To&513OvxPc4h+D(sO#&uLA1f-52HsEstxi@=OCgPIE`b7!i(#80&LPTN z!5i}`tr>+}Ah{0ljIFqd4cn#o8V<``jG(igLy6Lun2ZDoJpPr1T3d?Dn}QmeGsCbP zVTA(QKplER3Jn-2wS0-%7{HuM}PC}k3 z^P0O17|>Wln>0XC2m?cd^r<_7ACpurlNr&Q^&$K$Kt=E|vd%0h&l($NEmvPxFUyoh zbQ#V*{u|Y2^UdMMSw_9r~_Vh=uIw=ErPW-1G4xrZ1HeSH?se);Q<9Qe?$?*G<}%iC63bj-V~FXD8exXNa)yZP1o{ z%M`-9Cjj}FRjY_cEg3~z0k*YKN4GLjIto#UXFgmEXhnNR2V{|@KUBpQWX`jL=kF8`t3G&rc^tvIa`#y~8 z?(dc{9D*`~KpjQtz{AeBULWR*+J!S`oE?4$9mnJdlLd+B0Xdkb4mB$}COlYfdySk)WzuFh7mkRba~<6otuCExwUOO@SBi1 zJ>(%buI)8!>F~zkM?HNP&f4*XRNW9_F)am~f^GgKiGnoU6zw1h8<3P`GIvL-KozFt zv^{8hLdcWUaLfAxet7quW43+prFY--nZw)h>#F$aKUN&$#pi}2u>R18lx=ZAimspp zKUwGzQBN9d;I5%R|MzRVk2?5_f4OsUJ8kfTBQYDoxaDY|911iz5v@-%&&~uXw)$$- z_C_&+3(X8!zUk#rZPUhud1N#<5MMDdi zvs_fNq|~9Q3-^k^6oi~^-1#j#tG8m&Mdg>yI8++xO%6C{06}alYPICV7|jaQ(%#?G z5C0U)y%^fE22=uBsg+S(L+|NSX||JPO01EnRqXj1Beb(i8&zzIgPU3IwayiJPCIj_ zje!7=FCwggFRc|4O;petiU0sW07*naR6O=bY@qK_>nKgus&fHqN;-GNXUZ1ShE6hH z(|#b}I2zlbbl4*f)1YgH9-7SPMKB_8bj-|PqGkKeXc~;`FfXoE0e}Lwm{=Jof_hX2 z1c;Eeu9%b*+pJ;0K){zLIRZ+J9)UpyOEoYj7tDG7=DO)gZJn1{P77hKgG!&E1MwyTo&$ zL0g9T412W7ipizRFL)F@YclY#rpDCGa#m!x8b!jiQDgaKFMC-vY5}7K?R*0nIx|^Y zfdYVAi6tSAK!g|n(moqz+T`lWzgt%$L#}!#Nvtd>pbEcJy}Wqy7Z3mSAK@3*Sy)X+ zO(x80oZRDNck}Sl=K9j=!Ho;9SibT(`!F_$V-G+iZa7(+pdJuuX1m01O+>*=oo~~u zqm}mbv?Vm$hK$>s1SV&8xQ_*w-j6$d+eKGyzwMSofB%JbNO{AInZrzk_<~C)gTtKX z!4npc*$zM@|Az@IdEp(m6n*8h>l=4(TzKX7W&HH4{q7rta5QJEYd1rfjmv@1rNVAF4zxQ4*u)Dj7Vze2RN*_WjsU}( zdV>%UJ+L&K`7Ij-*yl-emPinU>d*okmW+I43qx2&G8aRvvq8Cm@vcj@M#vqFsklNT zv7s@;#F8||W~4B5l!UIVLO@uX@#_RocFo-?D}Sn(QO#;R5fy^4v>vA~&2TM9Vd?FI z9bE6hzzp%mJ@JTIF%d|TyecyGf&{88H4UsjR!FMYFe9$-$r4OhB0z`?8+;+21`-WL zl*_=bx`~yko#DU~A^NEWJur<+tVwO?Hq|44Tc(thk-#t`cPJ#yj^n|9wzQ_XV-S*- zrmLt;)It8kH924@5naOZoG8W6yY=`&7M|nKjgm4f6U>75dJSl*0(cM(Hm!KDNV<}1 zEl;kiwE$BIf{th^;}<=4`#lkwSFfZZ0@MF(h`mtB-J@&Lk++f3bYp0}U zUs*8ntOYDQi;)H7&}#I?aHgPAX6aqg!kfw}Ph&Nh8XkYSv~b|g%|Ckm?f=(X4lb?R zFEnuv;x%#*2?ER>3)gn6RFfjBTWqC+=Mruu+SoY!v=^*g^}@Y}@u9@R0?y#S=Y<(L zh1g{c3w%^4AX-5vQ4ycrX zFiMJ(*f7Rf43LdKreGGlCe#8|skH$zrd_S(R~wd*16l({lR?MAv4qk4!i{7o)G!<^ ztlBLdRs*soFq4TDIM8lbd`8f2ivJ`^c-3vCY88cY7>T8!)lgb7CfbulPP?4vgtf$4We}!tsyMX`MRhIXL@kXW%_6Uaga*?D3SbUeEhu!@A@{?eEhjRB}U_}HA8JKtsJ#})i3Qi^`dROfqR+Hf7CK2;0jlB zqv}svaw2nNr?mo6vX&9dy0HwCBj4K2zp=2gd-1|6cI-Q5@hhKMUA-IcLi}sWI&o)x z>`(7yi0&2l2AF^mo$yJ8|83&xJ?^6U^8Z*r=GetEFR^oWp1y?dT#i0)BOA!u)VCpt zcT6Kwr#O}mB*Q%)*by1vv!_i$FzvYu)EJ~XqJtFRTaUk%5fK9xKn93#|^B;5j8)qzt)5!vfuNH=ruJIYhUJZ0c|agYSvN zM2uPmz(<6zj4+T-A3w`*gwH&379l{JL*MFYaMJk#Q)$)jJ^%mjBd%V0CPJ0W)Wn(% z&E%>1tXvE#_MJ2fK?DkP&E}~Y92Vhm&$r?;Io6^>1O5@3uTeVcDlpc;EhEF8E|RA- z8c?OCBTuib7FtxZ+Ah8wRK@L7#mSnZ=?(kpQriRH8p9MLfo6s~&BQ678kDp|BiqHw z_l_!=Xpah2>7k9YQui8KLY3n+0Xth%@nJ>gL*#I6`44#M>AcC$0;d?9bdj&K77Tqcb@z36?`GJ8-a3YpuZUDc%UoS6xnyh zV96m23)?c7jFV?JWG{1L%vHAtuW)evOgZbpG{J3yk8WIi%+KyS?!Mc9?;o#d!1NdeZ!OcVx zi=A=NYxs>U?1MUy21lEQIm*3)WeCwbf^`9|))oc2rY>3vonFf$s24jnC&cE%d5*;i zM^XxB8q*RWdv45rHypH$MfSzOBOw((goI^mS!%Iqp+XnibWRg84{jQQLGeS?3Mfh; zhYRE-)d9X){U3o)+QzTFpv4sw8k@7^X4Ve8iAkjvZf3 zz4GQ5E;$Twdnh%bfln$1R(xjFSpy)gtxU^x)B_48D5kjbahjbdgpki=| zt^dUF{a154qqGK#8zCg^Q!Ta@0(@HNri|8>HZo8V!YOjJ!a#@s!}^%O(lYsg04qkv zx+sYzCF$WPhKrz_K!NDwgNVhfR%06nCvXQgb+n2CO(z=JW*Eu~RLY0m)|;nm=B*Y@ z##WPCIK*{HDQjQNM8H*bK28M-tQY^`Z`L-}Hdl5Kg0mQI5aU}}{0sp%vg7wb7tXzG z<&vlF0cE~_BW??rs8Xn_ou7kei>7mo7JZ{gW?`Hq3LA1@oLIaEx8XcapfL39OgRsc zz9;b6m$(mTee(g=>^$n;3-5dJcW?OA;?9-r_!_GffWctRFRj|3LI_G+!HrXf`ydm5 zG{3CMH$xQM{oTd)y?Foj-McS(+;)V(CmH75(abJ9tP~JfY5X9w#qIyZx;|eD3oR;5G^SwBE{g5!G%2uOU*-=aW!ZJ0L#$$ zBM#;4IwostP4Y>9SGpB?@@EkG4wgY5T08Q%#mJa)Bajggw^0q?)Yf=ehtv*2hcCBK zq>;%pO0kj!qciv6l6@GdO(QV(>J6m{k8_b{uds>33g_ce-w?oK*hbJ!F%$-+M$D0 z%EL?BHpDR^91dI3Q24Af|Ib@0leA6ul#D|UKx;LI0fEra=tDaE4A!&rBViYahKXr_ z<`xB_x(#D#nIcxOZn*=^7UL5Z!!3ArG`cR#dkIbw@POn3o{$=Pb>)S%TWZq6%X0M8 zGEGecW0MiP^aq7djNs4+jMAyoIL+KU`mJIqG1@ip+-ocyy~>0ivKYs;W2ECn5h`GW z!XCh~S?N+{!q)SaDoG|b$FsIBC!L@qk1{Q#Pz}?_8^)hAr?XdirL_e44U|AmkQoAs zI~xUn5Oa>|MB^n~PK-)`1~iF?*3OhW%i86B2Dj!JEVzYXBqZ!Lmi>%HA~Q6f``pMU#z& z5?%t7yVj!91*I9n#nM7_l4b*sIGbTLJ(BY_+Ad56gpdC)x9kGmwkA<%ClCoj9T5f| z=)kg69Pe*&7~$dwB?@9khGd=-K*CVTn`_y?ERzCe9gb$omc!GgC|pWCBX|^&Hi>PE zIh%4Pz0gt;r62)ugAfMFGc40AeGRcO5&_27AJw)5K0ApfRb3l%qhM0O7*@4X+h~(( zvo{4(h$ZL`LmD>=<)~h_5@Rf3?t(35gJuTdZtAx57#OM3WEgV(+1UBoUhrxtl1Xp_ zX!HU}nFcz!niJh>rdCT6c9`MckbR~Il4Ty?7%VHozc4j2*0q+oc3p=n8^Lzq&<7)$ zqCkmAOa3(oS!%6%YfVdAvH`Za3pf8_#Bzl~UI~>Tp=au}kKqeBhu+&x)ei_wZWXi2 zFfUmHKSMNOf`*F(ky%k&vmqduXk?)?heSyE+Ikaymid!3L&3XwZ`hO!|gX$=Z+`lGpxnj55VKzEcv%&lStN7s;8 z89N5WQryr}IzmpRx>Cs;&nCC4&&3%{fT)($P4 zcK+hezGLqx4_L%^1Mt=eW-t_tP_PDbBp$g#i8x&6m=aSX2zlphD8h7`!O#T8II0}W z92>ivNo1yui#AfsI0S7P(+5io+strbKx4yU#uiL2LkQcoANfwTzV1enMMA_L7aQsi)p zW8aKZ6KuJs+GZ3^IAoT_X_=DZ!KNFOkyZ6Nv+R;2Ucc?23$|=)ZluMip`^xUnQW>+ zG6|1hEVbDsVuHaWk;fpCY!PTQe<}_bW-+rdhj#V#Hj5aAhe&M;nx!!D)~F)T*&+Rk z!=SK8uIx`7SSF!pYSrujv%>UB-KcIYW*xY+cWeg9#a*~3j!wnW$QlZrOoqyd1ZAM$ zwM2;^)$(=MUDsjNE{wEDn9kK^-1e+ug2F%&mzzU3in6H&9V}G&=PJyk5`q(}b|WZ_ zxf`=a(A$xh)y9HnL9X7l`FEdO^?N;dwHSYRZp`BP>e8Xr^@lul=Yy`^!S^=&p-|%c z;olm>3jyKEl5vV`&}1&{1WepKd*2x0fsaifMl|0}74r1%^n8K> zjyZAThELyp_wDwDQ@)R3RCl(X1&F+nCl}_zKVG4?PfM5ariYJ|`OY^M{^9c*KXB#p zo?}r`8$mvPl6W~O=^u;Q7%f8$>6~pD^omnG8hVuu+r3SJ9U9K-agLdu6^bw zvm^`KFzn;^VazUtu%^TKz(+*03M#C%MFc>)t09T6hdP>$>}fJpmND`W%ot6gb#_*b zl=XB|Q5p!%WyCdWP;7_IxqOZ!^bn5YNXfW9A#sCZLkDQFjL0x46Z-t25f`{nj-#2> zl9n~Zd5BJv+|i0*5H2DE9;EIcqnM3Aq^_(G1xp4kc&!-&M8E3WEUe|REpcy0_0kft zvOTU_Q$?%Vdz**4X%^Mk0onk;$wJ4f@bHN{09D6cb_4~%1b*<;76w>oIh2okv>QvS zWK_DU?AihY;2;Gvs!_HM8qCKyH6k%B$@7Y7(2Rq%-*o;w|(4lU`PWQxEm*B)46Zm zjaqDNc@Hd%YiCdDN3>+hnNbG^0|2!O%pNnvJd)X%%!-R9OKPlPp}SeqWhUApIdo=E zxoF+lNND%zhMz?2A&FUsw=kxO_(_7c{vbzLFegCJYl_I$*v_3 zRpFU3?q=b0b*}iyeb>JA=o8LgJ8+OsbObPzOSt4tlSnHP^AgyV4W3RQ68`hM0k~TR zKQp_rbJyk#pIQ6hOYXk&o107alT4&4L%!aNTEB+aQ*ur496MM+WYy)Hpo1XAJ`H?c zwZ6@?>=qaB$iLKQLoBss3Q#av`?HbL@TGliG}SoAYG-YM8J0y47kxZW1C0Ke6W>%9FF(!SzKBq0c%Ns^*GtFgt202 zFq|m^_Z9-_ltjRReoVl@&#hj9F>h$SeIqcRBu+iVjdV7>UJ zGYX>N6|OF98wgwmXa-d%nZ5zA00oB-8J%+JgB^OjID*AuvC)N-$kxIYK(#j?M?fs+ zRvgikkg_aY6S05-r$Lb`aR?N&+0f3GpbZ-=X5d!b6o*7j*`O@u&Xq!Dw`)w(bR!@q zI7Hdb&>dLqYBE4#th@-$rTQDFs>QYa%i__HmzLV#5Ee}AB!mqMz?A|egDfLLMjJ|t zW=2Y!a9q-_Ye=g=zD{E?g9VN)=>}~YPzYYOh=cNxgoe$}&9JeMt*cf^p;x&Fu^i5f zD|Cw!1e#d^S`ZQVrj!_(K^FYnEF{No8dy{UsSR3jZOH z_mW`Ma+a8H3SlPU;yNBtZb@nFc1FRmc;L;Ujxd2FuF+#nQ$qJ4B7aU ze`)tgXRqK#QVrW{pqgksgtdTW)sl^x$g7x;7%HSumV7u&)z;fBv^0~{>$dJZQGV@iXx7^sX<4@w!l)c%r;9RJbO$F3)V|( zYYUe@W#K2^aMa4)#lveRLan*5wpuEskaYu>;;kDMjR{7rjC2b+9|$0W;)HciU2?NV zvqK+%gpE8pONTeA2+?WBk`lJF#R!R`(gorXEK#EHq)T&542|jxylLNA#-+UJsEFvc z&_nlD&m>$ZO^#OrWE4u|HRa%Dg`N;3uf#wGs)o_X$>ScZIb^{v0U;V3dSOt*7kDu# z0yYx|?P>r_3uW)b6EW)9fs}ox*L?r|*$ELl>S1xZQXhG2#_%{t63z>36W+(ysxC5= zl(JSph-;CPAcW~55LZd6c_@*Arz08_-+c>O;;>P1Tru86e4AJtc{_l2b82Rw9?wJi z6eXGuSqE6BP_0If_x<#zX76}7av%nwitjz5mwpU11+7YU)#ySr-+=2322@8Kx?_Q_ z)|eh_ZLyeFFqUu3pO_*u5N2g+?m)C-%N7`5E7}uOOA#6aF;w3Os9D_|P?iDG5W1GK z2pUk*-FzG7mLPV3Ra_$2QKH6C6&hfeM%}AzYY!3;F$*LMXyuN3+)+ddD_rN?P_5+z zEV1e(v+K#yYC&K~R1BF>`n|vJJjKTnsNvE@FUCc5>Sz5gHYa3tq=<5Ibh4lls zFYMg2aMnYYA9&Tyy~i){E0w%wqz9QKob=Qf)WJ&J;&!GS9(S5!;}%;%jj->5fK%5F z!*~O8*RAXS;a}eN-`|6$?(Eox+tc~;N~{(}#mZ`oMH}K68EfJ;zA*yXOn)J)qc`DsYr9m9_XmiA7GHX_*aACHq@8fE97?sc+ItZqw09hyitr zJAV@9Yv}wCI1C^Qw<+PsFpd!%b?cI37SS4TR4!N(1PC-ia6i6iW{4`*ZK4ostEKSi zOvg~7;fbVm#G54GF2pzc^%0Tbbxp}pXZ3gMj1jNt;IIu?v* z+MJAzZy7G!J5|?hrGNr1A~D1SdnoyTMzu}TuabitT|FWOZA%a>v*t=gMq`-BV1O#I zKK{JED0$q>+B`@>p);-;#tA0a(oxO=8in%QMbF$l*2wZm1X)Rqspx5~A**BEYG7xDI)x{82yg9^6Qpn!R^YC(H~^yoOloye*;KpW>=Z8}^tu15d@ z+QiO`&^4<;knB{vIc8~3Si`TzlIXSjlC`IPDDAUY4I@>)9@mAXg;SsM?7sU$#! zW=5;#V}_&sW-0-CD}>b=u@X=qb5V#rIu$q_0@*7h%~9mF?QH+Sp>5{6lbG)Jq} zt|A-S0K91xhOd;AVO8R~m4!BSbDP}~(a^gaK(X|dY~9&Vdi-nNA2 z!~FN(x$DEfzJLF1D=Rzjycs-56In1D3rS0_k@eB_8N!k9^8+(q#5V`<8?;Y-@vf_1 zv=ajlOWqBS2ly~vU>F=7W27>N!Vn$`&Jbdt+^s)anJ3J&ze!!t+`fSt2M>@uhNzaD zkpR!&JTz&rXcf99hYRMLH3c>1~OKpw_2I)m3lxEooAA$YhbH78?wS<+ve z(}^!KK3G|h=4kfr>n<${1*)N8IvJBR1lWvE5fW-v!{P!YDKS+qU+-f!qkXzq&2}wD zqb9wgp03Pp))28p)hq^tQ-X%o#CSTAMnMTNkb#8GhE=n=1WGOORE*>kIuDUvG8pb4 z@NJ6`8w^&0n^AJ^Y=qEQBNNVU!eNtp9AjAsDH-;sxIt(+>zej~4Rt`B8a0~AL{K&x% zUU&O9zqD=p3cnYCI-$~dGJpn{FaBI?rnLB;Cv|3r#`?y_wrxv4@v2={K5y6I!<%@H z%`$%e3ll7+O7kwQah2xV7uV?~&vIB#(RZ83g=`?gf0(EsO=Uu9=@zquP0cq-XDnM9 zq@TeGe2}{thzzT0ByG!;OEghTYDwB$@=PTt#%5MmtV-=ido1_LI5O=>pzK+ddBAW> zx%#B~{VoHjN`3a6>Y`SsIc(rHX*rfBzU-TUhBXR>SVuUG$EEIg2F`Hzg;9%b9zkdz zF4HoZ3x6_SP0Im-Sy$ic=yOhLn=MtVKy4i*YLa{2LKUJQboQc@iCSmr9C)WZ$6~&! z7tShl0FCP0&EZ#S<$6TCP%Y6dE*zG=HZkCs!5N|D1? z`BIgNDPPEvYKNXvF6ESz0jhXD9A)2M&UI7)lQdebRou}oq60U;}cnK(p88? zZdA==K)RNBHj<4Rl6S&+U7J8oChWi}lab8JVWHhB=3J%#mu$P&$VSr@+R!9e^R1yf zK)1Gy!lgm=s%9`4t0YO^B*9}%w@A{_?QCXuYoJOaL*o&dSn#{2fB%I;A9(5QfBR>f z+qTav>4?a+4oeVn+Bbt8+E5T*MRJS9Wjw)N6NL`lCDf7*gD(g-2K5 zZi%I2Hv*DZs>bNY`=m^?Dm!_2n@sGfL3zHb)c~D5flBA8bE9NYEBOALYq;ia? zS5yH`@To?IPEvfqa@(A)7|IlihRp#DbXZq@(I!H+?GVYG4RP_O1>IPL8NIfJDU8xC zJextbWT~!u6?_DRFq2XjUm_G>lZ&TW0t)i9$w1?V)62sSQncN{#+l|8y!mCaGU&9; z&d8(|4cR-alC*K;04WoT#_(8-1MAIQCreo1&A0U}67A9$4VKLpsgh5K#iNZ;$emFa zB(<$Ei~_kMh6Tuvf`;yhx5^(lna^}h3{X~HvaH4t$AY{2DwjY|#6n?!s2z>IVK4R8 z1>{h-|FiH1!O$7uCg;vFE0*#|#85_r-KGZX5l*yE&CwQ>MS{PpB*T@ z9K>VW23}S;6rl!HO`4?O15Kr-c2*G_dtw56we1kZ*Hj}wBy;&bR|&49=AlKYXVwjb zFf}9DgzAc!@|cKLm7(;AQY#hCniQD6*$?gwklk&>V21Btt5Dj2AvqXb5308*Fl7T@wS8a8aL5nzaBmzi82|6>&Aav%4RRs?j z(@-7Knj%$9My?MJXblT1GK|2PPB?jW)vdXx4OiYav$A;m*Vq63uiyE}-#fIhzJxo} zkrKYeN^r!rNJQ{+9>Z)aL}z>m@Q}#W)up5Fwf_9y+H=9-Haiznn#|@2y7c@1czYXIUAL_)Y^}Z5KKq>e!wpz&X{@v; zhQwer)wIQ0Oie@5pHLc0q9%n1c%varnzV%i8li+X#HuZAgn|u?EnJ`qN-KhmnuucB zQj5kIh)RS85i9rJbMCqKoU?z{?(;Lom}AcQzTaB=K>D7&-*3z@p7D%lj5)t=eS4kt z(@IAnL>nO|qfz5t8tt)sIB~J4V0PBM#30JreG9ZMBn;7ljK;q6y{LIe&@R`allc)` zFby82X$`9^#73e<6ljUtN$lYqcwsgA#)2)KUm>7j{5Gyj}7lRSiGs=B2?^T~eaa^!SJ zX$+lNu4hc0_CVTIG#z(8sv=bTe1&|zo;Jxb4S8!aOe~RV(#7nrIV=jIK_wnfrZ@*3 zH->}?vBy@r6E3xYkLVmYm=c(^5x~2_=)U(QlD#jMa86d4B$+w+0U>ieJ?4{^1mkg{ zlG`35qO8adni@2@nd;GF+#6}|#6w>9p4gDo2N0u6aA%l8vzTHCjH5|{$F+(8l~urH zVgU4s5r%Q8uf^ZBO`4ldSBa>2;@CK#yV&!2-TW|70#W?wwcw{mY$3i?oRC?O=RZmM z1Ui^9`h{H6c4xDfX&};>`?8S7Z`>Q_Fb}q21e^htD$?%!5uXd41TW@uecr+2+PJ>HzlARVF{xVd2TKvH^ z-e>=%@A&u+{*h0<^HF@+41On*zR3*&Nv;=nZ6hnWw%+OSsi8PN^x%_~FK!<`edqZ< z{h8Na`JM0k%-?*&AKK%OQ%XU#t_QXyS!xs9+;^Qfk|1-O913AIjZ(KkZ!M723NXqX zCk*T_pH)4p|0ebB5;^HpUlAR=0)sG;ZF#Ap>78mM3lJX2lH~q5a}aI;PiNyH6dE5 z-a8xF>K#a3xN;ftHLi^&K_`#SDYx|W46}x8A9zVw8_`psODF)Gwy7D^=RNkiyUns5 zmu2jTdw4(+69YJ#jUL_C{mC=kWtFWblXc!h%mN7+7GN2r_rdmYwWd=<-ijVqCa)GI*-q3XJ+w{b5G|e<) ztIX2cVD#{7@6mP%oF$P6} zsMd^zFnB6=FgL4b5!^yi2%fCogWVokb4S!uFXIadq}7mRcfztQewpy@>@QQAg1*_m z#!+-?u-wu6Hc7)rQ*E$~iGwX+1{ZUOO?J&kMPq9`ndAy5CpYo+a^M{Dl-|K36^mIb0|z%Y|OdBfLV^Y-!294w(R&&RfGjIC==00I%0I4 zP}u`e95&eO;%WzpA(0n<9-BuB3XEo_pZ za*WO!t&D@4Ctz^5BVBAR*Q<2=0jXRUX6q1tjypz(|9WB59e5DPXRo71?tN13HU*~4 zPVlk z5EIDP2$$%pI!U*C3EQD9so=z?-tkrM_ywy!`#0bD?yq{|r+*AKevuX5wBKsDs1CT^ za=}7IHpt1x#cyj>@SP_Qe%)_<@caJ6dq40iAL3hHxqA%;jKw?cOe#gj*g}ielsS9aS7D!^|;2OPZTa4vS%W<#wIjW(odti2vd8!@-;QMm5POu6bYk5Ms+E znB}Z_9%<3sX|N-*NRS_M2vxMSl!tI^Bl`2-oUI5BZW>0+pSd%{^=DtskIWILu3DHx zQbQHmNaYJvF2NI(hs8Ep{kut$OpkSV8U+Kr!6n476xUQW%0S@(xlTVwWbz;*wBNy-n#DQQNt*6evVtJ3MFsv~n< z)>*j#XZo=QA!BZvY__>M7aq{}YTj%N+%u2mlGBzKX5}GMz*HcT5B5E23y;sZ>zz~v z`XhTTU<rr8MFr6OD-vSx#|@fO>DR@KH;GP}nx6vWf>-6QOLPS(jmB3y0CKP}f}Ba|(ZWP1Ipz+{vF7wx;x1{d}w zB_xhQ8qCD~5(}^`r6L()%DpbSF0!2O-pHn6$Y{Ea`%qXNY)0!$Lcu0=RpnjR30Ol( z#na{g;5q!~7*kT%PK*l*$EVRqWdrWQ_qkj_ zoe)Fz8Erk-?^y)(%+s)80%p9|C8#@>5SAez0+kdk#B;|{-;bKAcb17{0kP77Uc)9iRSkI!0B=fZ8;4q!#<$6aYXC`_ny5jG*URZllZElop zJ;uRSpkO_BwvUP^4gG?HGxRlY6Md~{VBYP+RH%{IuU1mY`6-3o-Y*wWh(CO?^BTQi zVZQ=LdD0TYbGSm%QUm!sDW3$oMkBL(?~H9!m>xFSqXxubOXpoTmzpTj})PWP4IDZqItR< z2-CCQI!Cmtt^x|f$wk1Zu$0KZ%4qAQLDQMdB+(s4EY4VVew~n|qRR5tN|mo0qdt)p zaXA#*dNE`|)=aD_B#xQHJ{9>rj6R2g-_=V2DF_SvO)V{C69CPPfE?xpolC#s*han; z?bRo=D@s7h;js^V@^sMQR2rQ{g;4 z*_0=+<{GMdg{X_)C$>qa6lqc8fzQDjJ*4I=$en|-$mrKpb9bddikwPfC*tnhrF4OZ zp#*}nedneq$xHz*gZo~65NdNR!%oV|dC>Bj*0`e6;fO};n8b*`fR{ew9vi8dc0rYV z+2}gQZe7U92?iwhyJxVI6Dfx?tjyC*&&_R(z&#=6K@cl@i}#@%)|JfBQdt?F;|Fd*DDa-n;8p)y9z# zjox`P)lPCkIcW6E3M6%txGm}8m8v739wPOh0C(PqSJB8#+sg}MB(o)nflFPMv};vU zlN-osZSg69JarP5LVgwVOnetr?OYSzh?_Jnyis+|jO|J!;P!vReD>2|rMMePSP` z!&yz~Nhu~CroloP4K@OITBg=h!29L9pWS(0Q7POqTH%@B`b+H`JYIqKly1u?9X%_C zre_wZ=B>2u`*>zbRy%Ok`Kj|zxfkZMvgJB*wQ-$U=U_V3 zMxK?p9L;Kc=ft;II$$or&>1O01#-7b8S#)|@q1mgwNLPnP_Gm(v3)+)u2EZVCyVPc zXCl+fN9M_|7HY_7jan=cZ|ai1r$)3wgel8o@k~ZSO*XZyfB|h*SKi#+gn$|*{Uu1C z3eBppVtqQ7^dzXng);m#D$EZ*Y6kDnudg0Hy#8B%;jQocnm7L6zkhS{m_L%87XU2A zx8|znx9Ku10x+CAj1O<|`DAV+%ZAO7aCJ{cLHV5-u^58&oOJQujQT=+s&!QJ5c0A={J5V!Z+z@9VUK zKNAZU20GQn2Tc!^BDIXfgnxVBj+fKm!&XzFdszgK02Q-giwI^pQ&v?9Y)q!y+Phdu z28k>B@&+~J1Y($-L?xD9x}=P&J0~XFMAJe&y%gDLK(lgQ^V;PaTG; zc*NjfpOSfswq<7pz9vRIUQG8|l@j6cv`8BncUqnz%iUvlz;;hq=)YJ#rtJZ0EY*AESsq`y z`-?DSC~KEp{$dCtCm)2!RG@VBzJMua_<3bHuU__ERVtC+oQg8I;7T#0tHQBxo*04? z8nTGvp-6YnOK+Ybq!&zOwc&Ik%hQPTj%4+E7C8+biI^J zf1OU`N~ATsh*i4S2$sje!@P7(kZJPPV`kjj#Zh3{1h?QN6V1lYc?POY{!-2svq-zW zckTgSGlNgP^PB?zUl7{7{E0f%Tev}a>t@oZ+ zPj4UG+}wWYH^2I?e%Wic&$K`I`R;uCuFcXz?I3yQt7b^U`UI#6a&*4@?Da4`XZZaZ zr-8TZOwnVNuouffY7kLquI>?amV)&v3$yX3xmg~MY^om^hwL<|Itq;O!qixDwVtZ# z4m!z%1Qydy+RzF+!br<@+49)1Q|a6tR}b-0H~=$S6!8Q95ttu}QVt@Oi(23`d6ZDB z#E@o}YI1;z$es?CNDDSItHPNT{lu?4_UHpPT$e0@b&>Pvg15$HaG{ual2w>tpnZX- zGM3sV1N=~;9tT6WlC6BymRnFx8-qr3gR?Y@=}80q^Yo151X9>wOCCoBS#L!9invV%HTno$Ff^ih*(6!2kpXVn?Cf_>d+_wwiM@WQj43p5 zb)MbQFJdr)T8F%lYUX0OEN{eMcaZ>plh+tOqpsUarVk=4>FOiPAEfi)=ob%NG z_7d!njga|CReXwY#VUIB7z}l`q=B;Y;sls8dE^LBj3|{49Fx?VWRuS`m7gnlN)-X^ z(#Xlsd@_@y`vD@GD&TgIm1wIi&33a0{z-*zSN*xC-}m*e|M(9)yS{q#5T7;osPxGq z49v_FganR7uRf%ZCZM0cc+Y#UzT&@m|8MxBM^B%;!0&kC(|jpLbSUI}YB-iM-#sGu zs-qZ}1XR>X1JgLm*@+crc0|<@n~*kpT?hau#6=@1EV`HxjYzt@=Bh%OJ02GiSgO_w z{cKkBKDLj!AjHni2KmriWHbIX^Wu6w44>t2w7bU8obp=6H)7= z$Rj9A9A4@dQVpo|1s}cOd#Uk{)Fb}RU0`ek8_&z+U@n(67oQC~GbzD+fTZKz=aL%gcfTZSO+B{1?tQNggU#P3&< z1Q@Ih$&p6ek=gkNS(21TL-Ny9zJ)}cLJeNR^q*eacru2_MXS3rzvmwSH3e1rZvLoG zp82xu5U&(g?=ZDMC+9kzs>Ns{o+ns1IiEPAT5DlAFIb@|5uT)Vq_01sY@4MTXmas_ z)j#%HBk`s+5$vNkf4vHiB4E0T1WNnGum_k(Ifx2{_8ZePn{7^4@+8h%A8C?QKy=r- zw%pQ?8^vVEva-bB(GK3zIm+lT#4?dy8tYsW!-Fe^uYQ(sfj^3i!Yv0m9^Aaoo+bvi z*n&(%x{By2nbFJ}OeD(`dr8#>GC^xT+N`c>=vVp~L%>v#cGc2IS2(43j?&QkPkZ)J{~kEOw=SD2r-Bs96A} z((HT@)G=(u{TQq~F}q+yW8waNcVf1P61aj*!XicFw1eLT?4ZUVNFD*{$7P=vB*))y z$0Jud;StFcx-Eu?gDaQ;s$LT15bmteqt(dbS5biD->=y_&q_-PJ*4GRl7j+Z!{j(Z zjpb2{PIGwY2S>Z_#qA6Hn8=5ZuHSm|_J{xLH-6;X-nxDJ@gw}8NPt)xKLL`5h=1C| zR0OK@`&TIq?~SfL^@G{36@Y3 z8fHK5W^rx~q$3%JUksRlmwMnlogV+6YU?Q47pk_^RAePrT%`(Ym1=&aTW}BpBV2&= zF!;3$yx2fq%bo%fWJNC-u(SLY!%np?6qiFVhBNUP3;+*29y|O3o6|7Ze)kCje+I}q1kCF38;pf+OCXA18Nl*8=s-sa0Z203Al*Ck%hbF@( zZg z55Fc>#X?`BVmr7}08HoBMl4GM$m0~G!|+|6DouqV52_aRe4MCBF-Ai?Arebn9ISY_ z8WytYH+dNZz-3A1^sI`ZIAyS6yUgZ00wDS&U(Bfp%hC(Dzc9r`SszH}{xT)1+bt9)By+5`_r_AwIB11I()`^6n>9h56tE0%Ay5H8jIIcIksd}x1cz-Hp_Ud+_=^@?V~JS1 zir+sD8_SU90NEqAIp`wCT!;YqE!}z`f=KyI?I@kuxXI*UTQ(F&`RQOpC<|d(csD4$ ziHgU3D5_pU!Jd`qqely!(_I(nP$CQoQzZS~^b`QkQZZ%@k}O+OGHv14Ws_7XDMdS% zgjQ%CR{@@N8$)4Bk3B95yJWD0+My>ILcHcPkU1H97X6c|HrSqSjH~ZT^a+fuBy*JK zG%ssF!6MyumMmsv@xsH$?bPQ_X{&P@xJ-he(YO*Ml!w+|V@b+6Te)Z?x{{H{|{h$78ZvaR@x4)i0 zB!-gmfgwv*QOod> zb_QIRw8|n!Lgk}=^E)H+h{9zIv(&=G1DASHEWot;m2@m&aE_}`ZqQ3tgv~-ZG;lv=EZmruAu}){D;UcQ!$`}P4HXM)=(45;CUYAj;sd-_v1c0*HQ@VZ zaIcV*kWKCI|6#f>J}l~}fW-1gn_}|ww){v7`2afwcL^qARwPx9Yv{34c+3}{=8yw% z7IaY<;RTiXSx5V*!h#mo1gRun1A{Sxd~{J6gTDYCQI?B(?nTv^C$M^qg`T!h8+;~X zFLZ@-2+h;w=~d;*D!-?}W%e7MIj8ghWk69CaBvDl|6tcjR;8!H+b*<{OUQ{VN>V@ zUpa%P4jFOBccAh=oDZ$>-u?O2JD+^^xu5^&L*M@1_x&>uZlCe$uSwMI}sC*`-3>OuoL^I=pW>Siu!AUG+co;vu)o6=5>=g7mOkn``j>rfSjwULWI z8gwibri0}vMSms0&Mc>-B0Dumn_~eLmL6cFyR=-{cT%ZbF5=O-eDw#<5uj8V8EJ`Z z^Mw$1mSy?xL9sHDPUVBM*l4JRll^4_unm~fqIQOo6UY)-F;xz~qoX24af(6i_eKg^ zR=kVc(azLIrtP6B92y&=b$PM!?Fk{P1%cK&UKK?Zg8fG&C0{W#L_L6E~y`B zlIN2J8B9{8z;iux`JZ+OaWV?UR`$9oha5hNIf3qj?r78)417;lE}_1n`O!r9H7ILN zsLrhv!V?ML2T0((R$+=~w1&Fbh|^=K?rqy2QCCDt>!?CjIHsUAt7i#1fX;8~r_K6= zn1q083Qn)JWj28;0CyS5&Rlv=(jbWB@t~R-4EXrMI#e^2FQUW43ML!`VF485-9p{r zAw-X6lLBDq?Ba*AK>!RcOUTYrUKa+?NTP+h_Vn}xRRrOuyl9uX_)AZ^ESOswgBJuJ znD9!bQn)2{5K@X}03$Dlb>p^*x zN^<>!-#9kQ64<1E#w#z35VN`|a`CxPISrsNe5njsD3V3_6|XV6xv&@)7UD(8OxT6- zbg-t!y#yrZr-YUsIP*}~+yDs8o+Ypf!HFzw29BjmB|cVo5T|9)simX1NBKF+{z|b1 z7J>yCd1T?ik{_iSCjq$N;Y}R!^v0RU8oueHMK19;U1qS$`0}J?%K)U8$PAW`e;oV6t6%)N=YQ%OUjOm$e~#a^bi*Ipj{n#l-?-yKe|nS& zHCXUxL~1(M@4WruU;4tEFZ+wC~N)az*$RKUmMMzbU!7BtKq^o;i_lhQrc0qoI*I*uE}K)J={W9kq!-?I_2_5X8<9GKvYbk2lPv`r6R72On;R7&U3kUh!9%XH%9vfqts_#$sT z0?a7ff@UGtQqUAIQwH$(V>WF%V`rC2TffVAs+@UGQQht#Sc4`KwY0^Nk<~ka^>~CP zca}J2pjP2m7d;#=Qu3Q8I>msR2KR!*7+gwXr)t@uP=!T0$23*7@T`?$$|(pqVlc&A z{>5WI?);29cWID@&O&s<1&V7eFX*OQRpLpjCvF@nk`+}GKo<6q&2U{fhja$*tDXtF z>zcs16G$rhk`aS+D3y$;rF*LsHNN-Whf`85084s=V0HX$%CN-IaQlV7&{;_hOgmU4 z0afZGl7;8lDy>MO1wW*>sT;FeoQ)WVia+K3;iId!KmOu}|JcWWM zg})E@{+x|lQCOjrrTO(S_&KHj+Q0wkcm5ZTZ{BnD{HZ?g9&bCTxK~#P@*r25EyjM| z;c*J@9*6?cz>@IqS;gR71AOPpueneWEc%BD@RZPsxt7z5jj*PnM=1qTjnL6z7QjAc zx-(avN@SK{Hu5fMTDTk%inH)Z?h;N7d1}Ppy=q~I218~ILg(ZfD~`f+u7!|Zz9~L0 zDug6>EEwa5_NQswi5XP_^y2{Y*oyQYfZfX26}^4B5%v(&LOO zC+u{(Ceo7OBbp?ZL6Vub%9)n6vJ!!u!K|v zamq^-;U;mJDJ&Q=1@R?o$Us0k18dD(@J!HgwaoBL8ci)M&OU1ZQz>)>&?)hfSJGJgEXmJvZtkE>{v zW>9f^+_mAuPMt+XT051?7dFx-Y9N-Hy;OVxYX|DP4uF|dEmT>BE&Km6C295L{VM&G z2NG*g7U!j`QAmz(r&=>GF*WF;RAf8XDZXn;vCEEjOv=QdqWdwfx#d(h74lfx*ZxYS zWt)001`nIh%EcQFFzA0=IuXtSgh1w|R#hq_!ATpBnapw;8B0r~0pZ7j&x6QDQekW) zO|X(|uQh_S2!Om8$RK3Qe(8Z78Z;J$l!&@6ZI7LZ@qc$U{lUfuUhx%{BC!W|w*$7l z3#O#P!rNG?_b6nT6HapijLWt|SWYRkBquJIT7n@-uJBbZn#k2({jN8^{~JH?iC^Ro zc?UJiUEynI_#`4X68OOw2Ik|Rig5klA>Q0SxP9mOzxjJ_zUUj@dxfu-aUP#L#8$<{ zc2s0~mbS1;L@pnqGV2zb#Ve?Kq{A*FK4zl&dE#`SjW=iH;=xm8b0ZgSz-rBMLh^>c zeBdLg+-BN7g&_lJGi{ML5m9_{oouA(y6hWw5Ej7XSmLs6Nfta<6a!|kG%LD5T~S9Q zC*Z8g<(YSzGXPn=2=%lQ#W_j$+)@?rrLVd`qD#}070t#H@538wr;%NZ>^=$y-7oJ*(U~E} zRJdE!!Xv+_*hLhcL1ust0Be>(ZCJ{Mn=dTsRfz56sh|uynLv=AZTvr8Ajz+haV~$_ z48Zs!J#Yr+P(q3-4V0fFgjXS!;ls zqjyszFa0?p*ph^s-I+vUMc^2{ML7l(R1%Sfs8I)4l3a>$fh!1A$R5SRteIyyq%zw) z00B@K{NJ{;4F|G#SiqN@Qp-KH9`l3~faF2ONX-6{CbzFTt$+-$fgV@L=$(SU#38-D ze4t^#!A_+F8ln@?ArxQX*>t-2!F}c=AREN&uzB)CpDrgzlx9>)Y~Ws)#ow2OahLH6 zn4DaVV|jRuiO;huQf489yL;;_*&BV_0X~kzxwZ@0=lK$;y-~V+aCyoc6+K+eb)CzQ zYDTWJa&|$2nCh9tPN%~?8jmvG9P;@J!MRK?$Hal?gR8qO&PcSFQeIQCKhGJ!V?u?2 z7eduCXh)B-&0iDnGMq{ojkj2lC^~AY&O`=J{o}Y(Ifu}A{ei}-pZMWVe$Usv{?WgW z_wJ9lN&KWdKGVZbq3fG0i1w44KmQSQ{50=_=g(hUKYQ^zzUH+r_>W$}2!X&?;KUa@ zYd)?OyOR?Wm+NcfY8K9Fje>L`U1lupJiPY?4XECsTu)b@Xa$p>iaCZ6ih+U-7%aTM zX;ZFp1GY$s38l9!*?<#Sk3}zBEJ`?^r!We=l&+x^z^X!GB}NZw)1{>Z0>m)Alu^}; zu}+pQ*-XoqW`|AHh7wX8MPgMH4}nFPAz)LfdarU>qJ)9mr8t}hb5Zg{YcVA1zKLc35oC9F5^KlNo!KohJ6|+ycwB zPL(`;=tUglL8vEkC5Dn@=tbSKx0MX|TbBktfdQ}vU~2v|zKg7gjp#W#KN4T$QsUBm zj3y6Bn*g?N2JIsc%y!Y$rd08fTdqT+ZNWht%Hm{_IE7)pVJb($@(=FmX=t}g2fY|PhuYI=$ek)>BR2-CbV6Fo<{5e% zlD4TYmyYKbsrAuLR7l336l8f8Qd22iA-)N?j7Q@5jC-+^5!0zP(2~9vw#U(-7CMA4 zf=UF^V7}p2Z&*fDS%XBQDTc*Big`4zb~yS7JUuB1T18{q7DgvFlg#qh|ERo!_X6H|{`V)ZhJ0k3at_9^-vH-njEEJpmR=;VxQw*17524q?V=m^K|k3ozV>?<;MlbbmD7 zT_6S08&sNR<3M5CCM#h^-%2xZKaw1{MN$^UXr@ptW*Ov9hKdB%REoMe+$S0=fGGy^ zC=19GPzTD}#FmD#Dj8}8=GHRXMH@r|;t3&@Cu4RC>wZVw8ZlB8nMk+Di8`K}!sblW zuS*!K!Zclxmc`JbQ;%`0SuK7qd$jB#sX_=%q@!?@t6adi9hzi*-$l^)q4Us_%0nJ< zXwMic0@VzEtw`T7WP;A zoD?e-it(~LgDDPH?f4N@iSLD&B;qJ7+nik_-C48@0u1$>3jnNy0YFUVCYFxFs{rgv zh)6eDi^8;F>CK6I*Bux#Bs4{YDv(XI7B4(POWo>;obE+8eE6E&W*1=BpKqF0fKB7?5D5I1FU#d!!BR9(=|f z|A&!`_FAaZ+)qbW<)~9@l@(2M$ux4{>zFPBMZ;Se zLV%`C!9ub};Nmrk?x|~2iJ37qt4H3DMUOGL>>h%aD+R#qqCIXx>)-dE(JEF(tJ|q; zLmzm~3N?RrQ&4 zpL9((ZhYVu&hYc@SC2pC>dXJw`7w5tS z9_<$@gWA*ZoV4obNl-j(uYiNaa#R56cZItSkHK3S=qZPC9 zchaqt?wksBLfc@Qy4w+}2Ld=5{K;)1Ld#H6oi3P_Db6;sJcnbcQrJ7{G|purG&McP zR6R3KmzA?4k3#chIdA`A3y02`94l%wr$UrelW}xOQIm!t!iF>%3-P{-gAiX1m0cKm z?k6&GrN{QA`^IU7hba+43j3C3-MNPD#joWdPdPQ7SRvhd`qwZpvzbW)gz}RYyoJCayjwV^b zFg!^t)^WA2*BpS;#Xj>YCmh|M@pujF?W5@4QAw&q7(sr7r*us$s600+h}UhuOe z-UatsFJ-%dAjg9@!@Z8BD5u8q40%$Kb!TPZ@D&g0FcOtw`#3ubIqMiBug6ie*-1QM zszfw}PLb7=95dH3N`sxk_Q1SVm1&Ftxjm%^DB8v2W#!((@Q_`ZT_8mQ;J35Li%e=M zB>*iz@X1B;xwSsqp425R{!tO<7oRwfZ&$s3`;FTl`mbL9^WXB$^|MEhZuqlz&uV@X-q{4idpP(PstUhs9P{Lw`Kwi z8Xa#e)sb824Y@>vhuQmzmPt~hVzrQ$v+A^F34nez%8!D%WS4xj<+mqXoiVh5Gd-O8%i zACpvt8jQ?sIAOh)o5KN6RNamVWJe4E11MT_mo*FTZn|AH@LKrA2uaR9lw~p&Ma9+^ zc#KE&-6rtG6+U;a>&0LBPW*P&x8C~L4L* z-*>$CpZgd2irS96P)yO|6cLzj7l-)w_NGEVzsyES+qAOwWX)N0qB(?@3j9 z^FwwQ0Pg}SB}=Vk$wX0>{wjc()7E4Ld5M$XfIwE}W;i`c;DPh5b_2~~xe+5~SR3QY zoqBo~xqqtE6T~*1B}GGClSEH0LyVV;5;*NCqnDG7PRI0mv=Mf|Jtwo%`ei37U>thN zNggeCs0k-@~)2rvcccJEIa?0sy_Tu~aQ3?dt|R-;h0Y(h`oGD-~lRFy27 zCv7fGZF%y$yl_f#RFOc>ZPCM}cm&~mn64{4ei-7o9_0isWJd*U4IDiINBX?7on2%} z0-=Xcn3hkS!9~$Dy8ws$fzIa$9;P%6oz3_Qcb7v6vgK(6V^0YTTv=sQoKM9;R;3`@ zwB^YvdxPLXiN$Xjy~F}=7Vh@v{2~KRGgNOLRQZ`6*!EDGAzSv@cr}x;WxaaPIz8KG z4S4!e*}<>=iIF}Vylaf5GiD-^Rai0yW~tuz<0oZrVqDJY9;rRs9dcNlm?)-6s$%#f z3V&%QO~Ps8sw(T(xD%KC`J{FpjcDQ}*-1YPsc;pu=a>C{tg7`~0xUiI+k|){bDCQi z4ySGtNazUYNma|Jh=+3RDPk`OHlk!Fc6tO;&`xeuh!%KE5G1nBsK8UAw@*ik!F=}e zMI^J7G;U|52jawHU1(C1Kk?_@{+_RT^CLfd^~$RcZuJvT^6TAIRp*z#oe4593N~^5 z^vU%<|2Ypn^d0Z{jL*G({^a-@Rrkt=ljK}4qg*BbW3_Ig19raA&!9k`$nfUd~->Y&vC#MI}7+`jtHy13tM&=8%1U zAbaW=VaM$D<*evYX=-v194C6~wQ&R5tb;71oa0+6O`R8T#F67V5%wCBK0OK&ADNXn zl6q;wv~vb`sokE)Rd49;go(S9D-4HRn%IweYBOPL?dmO;-Qou(H5_SMpuqGUchu7C zw~{C-g?!JQmtQ$Yzgxu-%9JWG>P}fES6NNTc+03<8f{$mpWP=7>a9wbL3j0a&fKY- z>sj^EmO5snSm`hS4%-Pso`hB6sMSVACze$vO>fXhnJw^JKkiX$24DqB5Ep>3x#y~R zppd)7_?)rTm>mTklGK`&80(^k6=$+siL^^C^7~L#BIwSF9`opIBg8C?F7B!8r5=N) z=ZJqe+7caCj|0%GWoxR(vj!_QI@PgUpN35Uc0Ec|W*(>J80Mm<0{q^={sztO_xYCn z&Z|kJ=zL<=3F|Ym)3E5!{MnKoUDWYQ<%2CNVir|BST&2ct>ynJ3(Zpp~QS6PfO zFIt1&b?J!HqWk(X06BP5fz@ zxB37Gc^VdffDF{JnMi){?41Xn^P3-j#eeter+pTGsVYZ`Z&p?FT};^!gYYy5t~mhx z?UTq^WCJ8IH9bb666GXBfqW`jM=$Mgj2Me#D~yB-#oNI(iZwBNq1|)9b>z}8q8*Ew zdOJ#ik4W^Oc3hex;g}Vf=QCQ^*do_{ZAw8b!;)%lE?bx~Mu|?6@}N{NXgPhSVP$Zo z81H9I^jG!6SOrWfOAd{ZFuA#4l8dIog4Dr)XEaseGLSy!7ALc+HRG#6l2X^8p^9lC!GNwL$VD}S0&8#yGjkN0wPnJ^ISbTD zoMjnZ!05VtnAl;F?gl+x-BIE|4?^Wf+#j38Z?yT>Dwt!ZGjFe02LK`Y4X-C-^};Fu zC@}Pu&?6%@(X;{_i>#3N&&ZM6Lu;pKL{Dr^rZrvmzHuelIxRNnWK(u%9mQsynlECAYcu^N|~s=Np&eSn%(Lx3-`vWsQZHH4NO z=7NM5p5FeFVthGwDtegP8`q2)lPDbMek$Z;5KqrV0@O5ux=s?URE7TwuKW1(4Xlzmon zO56mH2M7tMhbe^;4qgun6RBEzkcX`ozKqxytos&bI28?g&&TVj#MTrzbP|ZIsV?RE z0JG-$xM0XIy%8RGlkerahiJXp;_dtO&DB5onP(sV+K>J1KX-fc;L+7XzrW&7ip256 zt0Z=X>hT+&K@IB#5D%Wbb@dy5+x3@!+k4;hLHyod|6(Sx)?ov*`c)uq2mZu~oiG;Z zIcalq;)JG4kDZfJkJ5=@;DGq7oThvH>9Zfu5Qiye|*QoX8<-1n5F7|Q0V9clDoC3Qt#$=Fuiv}v*wuA-rjro)v5K5 zXqzVzy*0tmj;=iyqJ2+2%$l%sNq#ocLYx@d&3yj)AGO{!;AfGgkQ(sEEt{4vJABEN z3Yl~6UVNYD@U-(>iP3n9h#|X5Q<8DzDLS?zxm;-SFYVOxr7EYmYhMl1&to7r~&^w$g8jBP6` z>C#c8lIjpwhUOjq#p4L^d9-%gVoREMjMs6kYL7bjtXBDvF&Lua~L9S{z%beip>V93!MiV6x`S@iiLKvE@O}4 z?K!I9M?HSxBe#F{KY#tNe&^E{x3A#S=U3OyL5_FzeB;g@NCSR6USB=Lt1f;fzGkH%a%nGfUmOE99tBt};|tee)?$?n_3d05cVZzZh&( zQm;JxRi15Dmlq|>W**(EGz{Y_t3vEl(xMO&OI0Hbr)bws0CUk1h(78y zGfH%Ffnz?&0AERk%`7ofZNn&)NX3QnY2PqL)}8GuD;O&9OEx^%nrifwv@s6#5~Wjy z6VFs3kxeF;xhCXg$Cs%g#G#i$9R4Vrv!*Unj(p zdNvH5PG9a87aP39=B1D;5Np@MeY&!sX6qt|JH3|l9KLbKIp(VjIP*j=^u5-qDWzaz z1!HQ;>3OslK>^G0sk^E2U=-6?X#}S<32;3$k84d_*np77H`uO=(4EIzWdenvnN--{dYb3U4QiPL*lRS zO|I%`ZJFnnb7E)9K*n;L35Lvs&41u&-A(44+vj5?wU04#se#7&=uVhUfj!ZJgdvyd z9!aZORYHFYSviZy*?WfvOY!kuBt84go(e?^?Ws3~8wZAy6~~-goMvKRQkE}e(ZttU z;nLEt{>H>G8B-HKXL8mb%SgWmt5uWciil-9F>|aHGL#)1 zK8491*TAS8yv3OiDN(2NWJRBc+Uv?1o(@hf!wMrve5}3m+&HTWB_$uj>9Hu#KcahQ zJHC%)k%VQJ=Qc;zXunk{&QZco5MkhKd&WbECH~MbpoW=#5F&KYV{su-VKU36HnEkM zF!Cwl)8Cr{8LqA^Qmic(gw4|^6^1N2Swc#7=8o*WPlRqKt4DXaxuJi?fVTHlsWdcO z@=H>&AyNXS>P+E(ozSwohs+nRy;G!jC$eLCG%JL9IVfDVpr_ZIdn28p*Ij9k4uJ6; z$zF5jjCz>#<|VFuT6e>CqDJ&}OH<){aESi*n`jm;31lt<7ctnMn8cU_qYBot!Ca<_8tJE+D&kxK7T za};K$Jn$qBLe-98JM%%hT%K5DwWd1ZWi}%ZG2A)@j-#r27&=tjx32(QKb(jAa+t_o z@geE~f0OF}{=IK}_z%AMiC=hp^BA9Q=kI0WAO24oe5pHF(FeAiCm%Nac+>xH{QjGN z_Zwcj#=Ca#&`m|V7!Wb3U51B417}QQjL%`Axnk#Ij*oXZJ8M z`db@hf-f}^X8M-LX!E4UgSuV%%;kw%Re!D27~4=%_7K+g!z4&qCSb4!br=G`(JrbZ zFXuYFc> z(mWnu>hGju8abSr6<~EX;0$C>#>lX&M%vk!QChX?AEXuufiDCf$7=Bh{nnJexc^EZ1jAW4Fwnx7des8(E?{Z0ax(!`(YJ21(9_41p54t)5B{?OMixjnqwVD`0#$^$^z7Yu+7 z_X#O${`{N?PL%-?yT^N0ir26`B|Ql|e@x`qZ)3Bs7y%#7duWo7O{z@a3y%;=w=0@L zV$T&-_1oYyi|Rtf!YsKjyEM;Bu{BlffZu-V&!4`y`pn<>_@DjtH$VNi-u}qX<0rMF zVSc}=zCG1laR~S(R;J+hr}B$x9{jx@fA;wC^I!XUkMM&g@kvzH8{1z*XY}idtnYeE{!Q6s*l-`$^1N)mAD(D%pQ|x zuf6~S(9hw;99NWVGU-dqa-K;|0{7AGXRPksQQW;urg**EbMI`t7Eg}p5YZn&&(|q?ZMabS0`z}b@6D?Wr=xqgcUFQk`X_>=x>R9LWN-aWNaNsko zlTswn-Ny;{()~R2qPEGpd(S;pjz&M^sTuwv>>WipJ#+djM(C!>FFi)GTv+iq7-YGY$r4Ckz{(I0*}A7}83^sCA`X&~xGO)|Q%murf(#rzqThxe zRA82$8ak$JM}NNb#3&=AS=eb67h8B*>5A`$l~`xk0!_3|OL?Q*Ojmvzyf+OMos+uy zh*a-L>DzS1&}FaWS_dKQrsoi8CkdT%2+H9z2xM|HOaFx<4m#o?yeP|a_h;Ohllej8 zr9|}U97;WQCZ~0jtPYOR7gDiN4jLo??m!9BEnK6ESv*3$7h%T@GOCfmvz4d>fVEMzc_TeA8epH8{U*2FrE1_jU(3~n1w+=f zU?~A=60j_x1>b3kci#W-r=EWJ54`?&{NwiLoC-H{r8 z^)mWNhMk-kfjgl&J9sauyx6JH>B5qA-`yi-BU0hSnhi6f?-ik4P2;LJS7jF!q)`cjxiNomPG|4=H^*JSd$*1 zLgze-cBBgvseq}$%Cj+PXw+i}stz#tdyn>v*Y@^4sk)9@DsArSql{KFyH;8Ou<97r zHDR#x!C)%{xN@18^C7Nd_%Y8WV)Ch0{(>ef5++XKU*k zU;|Ko+f>BoY>Mfcr2`QnrC^zt%?!#05@%jo=Unft9(vbjK#ZrAj0rHtc|jf`B;az6W0Y3p%}aRWz3n?J>vand)y$gW%kzuu=Epo_wNh@-}NPF>mF-}zhkGA0O zfRZW?x}Ipk?T1Lw0GBn2wa@#K$4{Sd82E-)j`?URmcg>w zQ9W~}eu>~%YOtJ)J&KH*d5%JdV;PCEE@LK1DVS%wikdFBk1F((sVbM31+&q2-yYWT z^J0N+%a{-RN?GE??2Ztvqqr!nM3*LVX_gfZ4$=@HKraEi8kuszoN7ykg_X|IbIA~o zQ>rhQi7waZvWJqO@ZDts}4KK&!YMcCMAv5je@h@fSG zC>I!ONLW`AK5gMnSQ(R{MgaD{*X|p$Xzqy%atdj2hX4-{t%1lL)c{+A5OBuBJQOld zFMn>2bH#>niF#UeG0XN% zxO*?|AW>h$oDB0o|M>yiMe)^t+lw7`!GkS!3mwxv1S}=ISxAu7en@(v56QakZSQ); z``CT&)OS=nf8&k}Y3&kGsggUR6_~Q{B8b%zD#h;5RON(0PjpYKLd|MVq|A?uOs^~( zk((QUrSod;@Kkq|g6NsZ!zU1FT_RO^r$HD3nyi8_n!+64C)UA02Mu>AZInK$(h3ff zY%S3NP>~BEQTg83CCU&d_`aQ#l4?HOyQgd$#uVT4XZ*bTvv*$nuYdIQzwj+jpFer! z=F#o-6@FJLI_0;e;`>(l0;~N8!!>>;>@W0QurFDEkoX-rTHQ3=0$_B1R5aT1#EK37!L@Y~xD0Lcy>vBUxa6EIiyDS$^}ZUco4?8ek!8`&rzG#MNfxwGJrz~sZK=xQ4>SdX2#3oA||*+UJ~g;W|k z2$*G5N+*XCd8Nph<<2U)dZM>MH8b2F)utUz@456RV2|!%s>;$nXhNGhEtu;v>S=hb znXEDjIvWRt8kHwDmD39$LvxD3=nPc)(sWR!r&(e3nT4 z+(MqKr58AdCyQEANNpNaE!HSH<-+Z-NCsz1KQ-BP1o`yrk}@YszdZRWl6%YRz6!JF z8ZsN#c`N4VyO?u~sr}tNX4xUtvdvXu#>Zdq(Wo;t)K6CC(pH#^H=<2+)_M zoWS;cG^R%v?jj~8x7(%q1kP&hZ1g106a}faiOw>F&M*GE(#+DdZ zzLU(?unj|D7*0&U8`O?yFAbZU*j;&$Oxq)?U7JKvFL9h)uCkZeqHgUYt#*jkMD$p7 zXC$RoNV6BQXk|s+$gr2mJL+k6(T83xD_7+i%>y_NfmMK_6Ba zs4RzN_xFBHNIhpGH7_Z~+f8Imgy&W-r1jH>Ol!O~bu_(}ro5 zWpMYAwpZ;|8In6qj~v*&%2}G5J&O*~y>6<#oNK}L^ep}586{+O&2SWcfvrVT%mQPT zwhM?(b*riN%wo~Tf^SRb3doZZ4NDm3X{1U!dbuf!WC>lp<*o$&62A~Sk@t3NIzBj4 z5_qyoFu}tYKChC!%7K_{28?21K_oJ8C8un8cvH*(Eb?uZ9*2xMr3i}zM$U(nny`nH z%mM8hdUm%9!`wy{7V}PlR-~<4u zM(9*xdFCXom@~5$TDh3U2#ankOI|VrIF}TbMIf$-hm6!WJiBE~QZb&>2rQOSF{I^j z+PUOO!U`VrCz<|%4Ke)O6+5h{U z4}bMX|L$LZ@%Yt8_^C1)uf9hWJ?eW@@#*)Qspvu=~3Nu;S;z=EucCA z&K^J4s7O<&<7A>GtEW_@;LIn5hZ)nxoI&k=1z}tr$8u2{0&L4I#HarDB+^XP|*tpr7}MV3b}|Oq$+}6_%$a1$kkMeY&8ZI zc}ar=+-tBg3+07V_H-*57Jt%pPp2@J37_?Q(R=ABqvBj{Z7?}&l63~a;9(9bN;obu zVKnn;Ry{1<>Sc}8Ff%YVrIofY9zxZ^J%m9W3-cIHLURa(XA1rrE`yD8?b|rs1w*LX zFK{0boCM?Rcl9}i0=tTeY`1m>Ccy;4q4$mXETKyk#!MO?#W~!Z2PWrp;BGExYO@{z ziJ3}l$YmVJ1-`~QsM2C6uTo`&Jp>4%ng`LYGAT)t{z%vi>Vqq(Gb9KinsX>^oxf}g zuH|Zg#M?Y8mv0=Y$ZFt~XI}UD_~p3f17PrGU8gcdp2L<0MzBkmq%BMh(m;0`Mlfz_)mY} z;cM7F?sfkhOBJfpu2}aw_5uSC^ms$jSME3mRkpL*^M9116DuC_;)J%+Sq4Ex2qR|* zKP6?kB4PEybQ@s8$H}?Wk-&#H3WFfjb3PyWpG6+aPV5jV>MmxgMRscC%>mhC4tCaE zlDNw9K_Eb#rL|nE!qm1AK_kGV86GJS@q8-{a!Yz>m*YTtie$9?4ua*nqdqqB11d?9*W9^DmgFFHf;lz&fyt4v?(qGCl0(qe(mPIr^mJ+ z@8(I-3!V*-=Oj)e~7x#ES^I!S9j^R97p%cZoK+X#`z(32J8>}p02j4ML_t(nIRY-j0k}l=?vTo_W9I|^K77c-^6bfr4}8YMFZm9 zNOdDIwG&jfU3#kouOtWFg}bMxm4HAi*3}xYc*fGou$Ix8V@HrO<5k*ac(`oA2)x!_R+K&sk#|N9?0S(G_l=T42rG|imFxPR@Sm?@#aU*1TYE_7Lz$lHo)any?BgDm5~!X zm1x1K7H;ExAL1gL64GqXoyKMc3&XwRD=`dpG&vjjfV#E28tJgg zMPk~Dv%z}Cx{=OV8oJYZP|ijV9oR0H;!0Z~qgkyxVYdss7=dX`Wu2f4yv8*`lEmD3 zj08Bel*yfS$~Y5wepa|!h8K)wW6tesIAacL=Croz;c8Um=HJ+YKC3QIkja6%(^~9f zGFmP-2_$)K`1Z`N}8$6T&2ozma*Oo*(qN=@OIA)F&*#8 zFg(YWDkzj)EJG-% zl2c_}TY)H)^j;QK1M)=ysKS>qz}COfc9K4U=+2#{fc}WxZ-;w*DamG{9v-IYccNy8 zn7J3mCBwZ;B3tgUsZk$?R0Ne5iKT5K@?=UtZ$Y#Z*afvCOCu?T$~lLHHlGfAR`83HvWuuSRX!NeU^SDT zaJYI;NV_I84o8NHwy_=*GQ1CDsC5CZ6H)Ag&R6fFFQjR_6IfwSohA}JG4TM2KfO@5 zDWh!@LT09sV`F?tTL#GOwl5*;vS2 zCt=yCwk+m_Hmi-tLfWV(M+*o(sN5KjCuw}5$=>bjFOJ`G%x zKLlh8Yp`TY0W=`$iiRprK1KE@@D!iduynlTCdBciPxL(=r;&ONn#s_RB!``H2ULEP z&P`$7_EMu@Zz9?Ch@us{jGWWjxt#)Jt43@T{|x*ktsoWoV*)12-8`heB*uq9qbTu* zB!|wG`A~+QYc&d_l)Z3X2oaf5rz}-mWf+lSE^Y1+XWz%E7A3IMI{XIm3AY|1Td11^ z%v3B2!RhbdvM)7)^pkSd>=)aiM_j?YJ#nhyFmjk<7s?}0pUjy~D(qS3{Z=oeHSDar z+23VASEM(R7&~JA0C$wmiYm)((p<;f=(%($CH9?miPv@?XS!RRD(jEW&QON<${^qN zk!wVIpIHiwV$SvHBTpcL$!1Z9Xiv4z#Ig-tlOmcYrc%@Um52dO83YlNox<(PN=)kIgt7bWIsNz}IpjoEDso!Dsk;Q|Wmn z7T%^RTv+_>XDFWrhk{4fpiSgBk*E0COpe|Ei`kJOmrw zjpK(wKD_$GFTVJPKY9DcKYsD}z1JW7HP@g1Sr70jc78$pL?5MkiBjd#Bh=sNkGaj> zlx z_!yt^8C40xu{PI`H2Ict85%8X7Wq+UAv#)GQqW2mID3`8WVuCl%3D%Us(Y$(2@X$gikE!Fm^~vA zmgI3_#^5dw9S9cGhN&8+$465FAU8I`c&mh6{+?X+LDh;mHS`7f6qIojNoasI6pv2b)A|IPf z%5iVXTmrC)9P$I;u1m)7Y_hAUs}}R{H>-MVzF~&EYGxkz*ocuqYZ5u91dxcBb_syd zrEW`Pv^VZSQkJPo+rBjwG9Ei!_gt3Hj0 z>H)dcphrD5E80ERSu9$+*-7uT#n{vxCq$K|5V*sM(dpePrV@tOd>9IHXjIWX$|^^b z-2EgGm7DHIwq=PI1^=>R;F$p zG`t*6*V7_0#ZswQm0idvR43tx&~p8;suCyTe0qRYw?OItk?Fl)ZvC#m>+B7NjMg+g z;F0*wGZ<2s)kd?+%yZ)oheKNNn?VIxb_W-r43`7a%9&L$8Ss|}2A1KOWzR{MRxG4; zPUT6{6x}=|X~MMPEwqjG(JR}=#>6(^g~YWc&FVe;Rckl)!+h_dL60ZEtx0Wrg;L(f zWnr~zm9_L${%%aU65*)XsKh7D^-|ac=83RZ3SJQ>Pi@20j-#%NO)m}F?xNiL?IBFl zJ6O(RqqQ7>+<@Y*?I@8-s(2%AMJubql|DSJqrZEMlAjPL*GK>4_I@WLGZq9PM-D z_ckl#klS6aR6f~lTo)z`+B?K>bEfyxR&rU`q1s<|ryCu#mpd7_$?E%pz_Og6xwsF; zSWn3t%FRl}>y~-K85MAx7Kyt1!>|tna{WB%8)O09!xD#wF!ve&URpw}$=4J_aY1%o zDB)VmGnxo@mz^$Rl3;rjeyzKq?sQmyic$yg%NCrKI2>J-W7I>it3Ah;39w=fr}AZh z#Gy+wSqhoK!(7<3ba%d^wX^P~)I&DZm2x9HPq!x?FFY2dA4E@FEQV>|-ljTERAxnY zZJtcr_7t8Fc{n|#5R=KKO?Nz2l>2Aj-V_2NNhV40BI&RSf;~Mk@f^|8r=lW@QBl%n z%=|H~bn@}SUO-H?6&Yt>_)nDAV8GC;k7-y##!7HheAf>soSfv)>OSO?u%@{h=>SjI zbqwfb9X;#-Da6M^K*&>NbR^7t$Z*+tP`d9?+eepZW#jeSvs&8mE;EFArUEYOsALs) z)@9h^B%$nNI&@&yt^_ zk$wqUD;V3M>#zWgU_B%F5Rib|mlst{98-A2YdKA~d*xwjH3>Yq?bn zq%&Ct+Kj2DY|}>6)29uU3~pc8z@bP;s|@@jau8bl2%93?rpbP75gi;mMYivgB^n-w zJ0Z=PJC!GSK(=K`2=A(jY^WbLyf%XQ=FtLQhi4N3qQ6sCP{2~aB^fM|=08%{;zce^ zkl?w3$!m8SCh%A0TVQpOg%l#s%?11|VG_qtCnF^a<4LmQ+SIUBYW6sBVX8cMw|MhB z-;HFd%fYsSh`XK6W+NsfSkuTZCN;q-R00Ds;K({ysm5@tme0wIB^7|`_GT(Svphzq zs@!Kyb5=~2^y{60DZ;|!pA0bG?M%yaXUQ`Zt&XVKD&^33Ltv2W@(c;$zKH@S=AR6!0eSI#DTX=C=t=mbqm7>Cbk4|Uo{hXo`@NDF>tgEy zMDB4r>G*W^z4p**`s=;LhVALA#;L`gf&5-Hxa3ZP$~ z&ZK@p7V6_0V94>`CMgrF(mJ)KIJ=%876YEemK7q}V)8Mxtl8J@6$WQ?6G6Y244uT9mmmK?5Ya!N#4Id85oxO?}V9AHUD)V@()9<87Y8M7N|9&5P z@AS`6^7M#~8)6AE%@9TVrE89>Ctntt{!T5{{;8>w{jhRAEHVFUP(;4sL(5KE%T!5j z*x2V(^lxccJ~aueP)Y^I(3aMh6=syAFXRspHall$j;Z1)e3pRou?^I8R7S4IfYT`@ z%+vQLv-i40`v}IvEL^g+ppg4GU^jn7awamie0Gf^9v#29@Ss8e_sjo``{%j-awqHw zgL_HxFkbOb*2Xnk5GaqzBb)e;*F^SXGx!i%A0c=8MT(y)-h`)#ozZ3xOP@Pr z?QWllpo?i_LETG_&ajo8l@hc2$-{3}hx3=kk3-N*f8qOziOF!Dm|ej0YfyyWOO-RF zlYV1;LRAM+1nc7hL4$a_4&TB(GmY_5CVm_fJI$m z53@da$?)OBpHyx<+C0j=C5c|JwEZlg@ERR}Hd!_CW}a;_?y<=Os|oyNEb+DeFlx?o z*z91sda#@!k9RSg`4Ru{aT^Ywv49lz7oS)v;C}tplHh)!xVMkWg z&g*vIbB^`y#nbcezo{PlM6LyUt=cO$fj(Etx``i{SzWV5jpo3rU5xVA99StJ8`s*u zu!_x*ej>Wd>AaZLuzMC0z8ds z^F2c(jr#a;v9vS(zG3EQ(8Q+ahkHh!j(o{z_iT|B}J=`^@_9EYNbIbkYJ zW}cMh4y0c^iS9b5kt4OVv&H6;F$b>Kh1p8f%e#vjt_`{IT$;@hcG(?4ipu%Lv`bWYnlAhWh`OfQ326AuHB*KnMzXY`a^KMAMs z*$9Hu8t2*QipxO9&UX@y6PwgFJGJP79aB=(7#MXcF@E=cx_Iqnqok2P{kGsfaAceq)ZQ4!?jx#cl%T-RG@CU}_`ZWgdKmS3RqKQgN zjwc{3ZB?<^Y{bu|d7ca@VWxz=duc^xbzXQ+Y3Acl62{bkbEJn`nc#;@j0juEokGu_ z3(HtbPF9;Dx%(oZJ6~Y-AwfT7rfMT%HQ=1W)(ohvAQJ5xDaL)}x;t!5a{i+-j#f@@ z&BG07agj#Vu>a>dosXenySn|N63rHhv|v!vs}PayA3;xKWoISW#_5P^$LFKmUsm^v zHTWFbjC4qp^;O&d_t4!lJep@1*k&{}P5AE47WmPc;DJHBXkVOcM5nb3Ac9w@BQ-+4 zcGg-$XmF8WYZBwmr}-H5JsF;eU|FtDGKBTjTlZivR9?KJz8eDuukFi0EA0KtR{c#t&6Bu1vt8Y2qFYFFXi12jJ*ay!{=$YBF<=<>GcRd3$Bp0sEG zdr66Q(52c`*>jb2=~fybm+Pyid%K2ms1JMG^XW|(FZEJ>K7ct()WDVcSv-@OacPQ00? zMmIYw9up9A#$gkN8Oh4u*KO6EdWN0=IocA*ExnK3_(RQ9#0U7mcw3|1bM7dI%u9GR zNEJz>N)SSQ^|33wc9O^hZS+CU;%JHjZqrENIFpMlnLn+|uBg^v!6_>iA|`$=ma%qH zg$sjKo?5Jp`Sy&Fx)F;?sQz3JqfneS z$Lbf_^4XrJHct>i!}t^ugxMPT!`>l>D$@?n0yG;VI`=gnOGZ1cV%K;zr+1Eww-vls za#P{hYzHVnijJ=*8l(j07*qoM6N<$f~M}|dH?_b literal 0 HcmV?d00001 diff --git a/packages/shared-python/shared/services/retrieval/agent_explore/__init__.py b/packages/shared-python/shared/services/retrieval/agent_explore/__init__.py new file mode 100644 index 00000000..f08306a7 --- /dev/null +++ b/packages/shared-python/shared/services/retrieval/agent_explore/__init__.py @@ -0,0 +1,25 @@ +"""In-process tool-calling agentic retrieval route. + +See ``.cursor/plans/agentic_corpus_explore_retrieval_c2c4ea21.plan.md`` +(Phase 3) for the design. Runs the ``agent_tools`` corpus registry through an +LLM tool-calling loop instead of map-nav's PLANNER/HARVEST/CONTROL episode. +Selected via ``RETRIEVAL_AGENTIC_ROUTER=agent_explore`` +(``execution/routes.py``); ``mapnav`` remains the default until this route +passes its Phase 4 evaluation gate. + +No runtime dependency on ``shared.services.retrieval.nav`` — see +``config.py``'s module docstring. +""" + +from __future__ import annotations + +from shared.services.retrieval.agent_explore.budget import EpisodeBudget +from shared.services.retrieval.agent_explore.episode import run_agent_explore_episode +from shared.services.retrieval.agent_explore.types import AgentStep, EpisodeResult + +__all__ = [ + "AgentStep", + "EpisodeBudget", + "EpisodeResult", + "run_agent_explore_episode", +] diff --git a/packages/shared-python/shared/services/retrieval/agent_explore/bridge.py b/packages/shared-python/shared/services/retrieval/agent_explore/bridge.py new file mode 100644 index 00000000..8b114ed9 --- /dev/null +++ b/packages/shared-python/shared/services/retrieval/agent_explore/bridge.py @@ -0,0 +1,52 @@ +"""Bridge from an ``agent_explore`` episode to the existing decision-trace shape. + +``DecisionTraceStep`` / ``TraceRecorder`` (``shared/services/retrieval/trace/``) +are already provider-agnostic — this module only maps this package's own +``AgentStep`` records onto that shared shape, mirroring what +``trace/mapnav.py`` does for the map-nav episode object, without importing +anything from ``nav/``. +""" + +from __future__ import annotations + +from shared.services.retrieval.agent_explore.types import AgentStep +from shared.services.retrieval.trace import DecisionTraceStep + +# Mirrors nav_config.MAPNAV_TRACE_RAW_CHARS's existing practice of capping +# raw trace text before it goes into the public decision_trace response — +# redeclared locally (not imported) to keep this package decoupled from +# nav_config.py per config.py's module docstring. +TRACE_OBSERVATION_MAX_CHARS = 2_000 + + +def build_decision_trace(steps: list[AgentStep]) -> list[DecisionTraceStep]: + trace_steps: list[DecisionTraceStep] = [] + for step in steps: + observation_text = step.observation_text + if len(observation_text) > TRACE_OBSERVATION_MAX_CHARS: + observation_text = observation_text[:TRACE_OBSERVATION_MAX_CHARS] + "..." + phase = "finish" if step.tool_name == "finish" else ( + "stop" if not step.tool_name else "tool_call" + ) + trace_steps.append( + DecisionTraceStep( + step_index=step.step_index, + agent="agent_explore", + phase=phase, + observation={"observation_text": observation_text}, + decision={ + "action": step.tool_name or "no_tool_call", + "args": step.tool_args, + }, + result={ + "status": "error" if step.error else "ok", + "error": step.error, + }, + budget={ + "tokens_used_delta": step.tokens_used_delta, + "tokens_used_total": step.tokens_used_total, + }, + elapsed_ms=step.elapsed_ms, + ) + ) + return trace_steps diff --git a/packages/shared-python/shared/services/retrieval/agent_explore/budget.py b/packages/shared-python/shared/services/retrieval/agent_explore/budget.py new file mode 100644 index 00000000..47a0c606 --- /dev/null +++ b/packages/shared-python/shared/services/retrieval/agent_explore/budget.py @@ -0,0 +1,80 @@ +"""Per-episode budget enforcement for ``agent_explore``. + +Standalone from ``nav/nav_token_budget.py`` on purpose — this package must +have no runtime dependency on ``nav/`` (see ``config.py``'s module +docstring). Re-reads the same ``RETRIEVAL_NAV_TOKEN_LIMIT`` env var the plan +calls for, so operators keep one token-limit knob across both agentic +routes, but the counting mechanism is a fresh, request-scoped object (this +episode runs as one async function, not a separate thread with recursive +calls), not the nav contextvar machinery ``nav_token_budget`` needed for its +own call shape. +""" + +from __future__ import annotations + +import os +import time +from dataclasses import dataclass, field +from typing import Any + +from shared.services.retrieval.agent_explore.config import ( + AGENT_EXPLORE_MAX_STEPS, + AGENT_EXPLORE_WALL_CLOCK_SECONDS, +) + +_ENV_TOKEN_LIMIT = "RETRIEVAL_NAV_TOKEN_LIMIT" +_DEFAULT_TOKEN_LIMIT = 100_000 + +StopReason = str # one of: "token_limit" | "max_steps" | "wall_clock" + + +def resolve_token_limit() -> int: + """Always a positive limit: env override, else the shared default.""" + try: + limit = int(os.environ.get(_ENV_TOKEN_LIMIT, "").strip()) + except ValueError: + limit = 0 + return limit if limit > 0 else _DEFAULT_TOKEN_LIMIT + + +@dataclass +class EpisodeBudget: + """Tracks one episode's LLM-token / step / wall-clock spend.""" + + token_limit: int = field(default_factory=resolve_token_limit) + max_steps: int = AGENT_EXPLORE_MAX_STEPS + wall_clock_seconds: float = AGENT_EXPLORE_WALL_CLOCK_SECONDS + tokens_used: int = 0 + steps_used: int = 0 + _started_at: float = field(default_factory=time.monotonic, repr=False) + + def record_usage(self, usage: dict[str, Any] | None) -> None: + try: + add = int((usage or {}).get("total_tokens", 0) or 0) + except (TypeError, ValueError): + add = 0 + if add > 0: + self.tokens_used += add + + def record_step(self) -> None: + self.steps_used += 1 + + def exhausted(self) -> StopReason | None: + """Return which budget dimension is exceeded, if any, else None.""" + if self.tokens_used >= self.token_limit: + return "token_limit" + if self.steps_used >= self.max_steps: + return "max_steps" + if time.monotonic() - self._started_at >= self.wall_clock_seconds: + return "wall_clock" + return None + + def snapshot(self) -> dict[str, Any]: + return { + "token_limit": self.token_limit, + "tokens_used": self.tokens_used, + "max_steps": self.max_steps, + "steps_used": self.steps_used, + "wall_clock_seconds": self.wall_clock_seconds, + "elapsed_seconds": round(time.monotonic() - self._started_at, 3), + } diff --git a/packages/shared-python/shared/services/retrieval/agent_explore/config.py b/packages/shared-python/shared/services/retrieval/agent_explore/config.py new file mode 100644 index 00000000..a4d0cec1 --- /dev/null +++ b/packages/shared-python/shared/services/retrieval/agent_explore/config.py @@ -0,0 +1,98 @@ +"""Production config for the ``agent_explore`` in-process tool-loop. + +Deliberately independent from ``nav_config.py`` / ``nav/`` — see +``nav_config.py``'s "not read by agent_explore" note. This package must have +no runtime import from ``nav/`` so Phase 5 can delete that package once +``agent_explore`` passes its Phase 4 evaluation gate, without having to first +extract anything out of ``nav/`` for this package to keep working. + +Model choice: reuses the same literal model name as ``nav_config.MAPNAV_MODEL`` +(``deepseek-v4-flash``) — not by importing that module, but as its own +constant — because tool-calling (single, parallel, and forced ``tool_choice``) +was verified live against this exact model during Phase 3 design; no other +model has been verified for this codebase's OpenAI-compatible client. + +``AGENT_EXPLORE_MAX_STEPS`` / ``AGENT_EXPLORE_WALL_CLOCK_SECONDS`` are new +product constants (not specified by the plan text, which only named the two +budget *dimensions* to add). Disclosed here rather than buried: revisit in +Phase 4 evaluation once real latency data exists. +""" + +from __future__ import annotations + +AGENT_EXPLORE_MODEL = "deepseek-v4-flash" + +# One LLM turn = one round-trip that may contain several parallel tool calls +# (see episode.py). Kept low relative to map-nav's per-node dispatch depth +# (≤5) because each turn here can already resolve several tools at once. +AGENT_EXPLORE_MAX_STEPS = 12 + +# Wall-clock ceiling for the whole episode (LLM round-trips + tool +# dispatch), independent of the token budget. New constant, not tuned yet. +AGENT_EXPLORE_WALL_CLOCK_SECONDS = 180.0 + +# Max tokens requested per LLM completion turn (thinking disabled — see +# episode.py; this is completion budget, not context window). +AGENT_EXPLORE_MAX_COMPLETION_TOKENS = 1024 + +FINISH_TOOL_NAME = "finish" + +FINISH_TOOL_SCHEMA: dict[str, object] = { + "type": "object", + "properties": { + "refs": { + "type": "array", + "description": ( + "Final cited evidence, in priority order. Each item " + "identifies one section or chunk you have already looked at " + "via corpus.read (or, for an asset, corpus.assets)." + ), + "items": { + "type": "object", + "properties": { + "document_id": {"type": "string"}, + "section_path": {"type": "string"}, + "chunk_id": {"type": "string"}, + }, + "required": ["document_id"], + }, + }, + "notes": { + "type": "string", + "description": ( + "Optional short note on why these refs answer the query, " + "or why none were found." + ), + }, + }, + "required": ["refs"], +} + +FINISH_TOOL_DESCRIPTION = ( + "Call this when you have gathered enough evidence to answer the query, " + "or when you are certain the corpus does not contain an answer. This " + "ends the exploration — do not answer in plain text; the final answer " + "is synthesized downstream from the refs you cite here." +) + +# Appended after the verbatim CORPUS_SCHEMA.md text (schema_doc.py) to form +# this harness's system prompt. Kept out of CORPUS_SCHEMA.md itself because +# the finish-tool loop contract is agent_explore-specific, not something the +# MCP-facing harnesses (Cursor/Codex/Claude) need — see that file's own +# "single source... do not duplicate" header. +LOOP_CONTRACT_SUFFIX = f""" + +--- + +## Exploration loop contract + +You are exploring this corpus autonomously to answer one query. Use the +tools above to navigate; you may call several tools in one turn when they +are independent. When you have enough evidence, call `{FINISH_TOOL_NAME}` +with the `refs` you want cited as the answer — do not write the final answer +as plain text yourself, it is synthesized downstream from your cited refs. +If you exhaust your tool budget without a confident answer, call +`{FINISH_TOOL_NAME}` with your best-effort `refs` (or an empty list plus a +`notes` explanation of why nothing was found) rather than continuing to +call other tools. +""" diff --git a/packages/shared-python/shared/services/retrieval/agent_explore/episode.py b/packages/shared-python/shared/services/retrieval/agent_explore/episode.py new file mode 100644 index 00000000..fe1ae0de --- /dev/null +++ b/packages/shared-python/shared/services/retrieval/agent_explore/episode.py @@ -0,0 +1,311 @@ +"""In-process tool-calling episode over the ``agent_tools`` corpus registry. + +Uses ``OpenAICompatibleClientSync.chat_completion_raw_with_usage`` — the RAW +response, not ``chat_completion_with_usage`` — because the latter only +returns ``.content`` and silently drops ``message.tool_calls``. Verified live +(2026-09-08) against ``deepseek-v4-flash`` via this codebase's client: +single tool call, parallel tool calls in one turn, tool-result feedback + +final synthesis, and forced ``tool_choice`` (used for the budget-exhaustion +cutoff below) all work. + +Tool calls within one turn are dispatched sequentially against the single +shared ``ToolContext.db`` (``AsyncSession``), not via ``asyncio.gather``: +SQLAlchemy's ``AsyncSession`` is not safe for concurrent use from multiple +coroutines. Batching several tool calls into one LLM turn already removes +the LLM round-trip per tool (the dominant cost); true DB-level concurrency +within a turn is not implemented here. + +No import from ``nav/`` or ``nav_config.py`` — see ``config.py``. +""" + +from __future__ import annotations + +import asyncio +import json +import time +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession + +from shared.services.retrieval.agent_explore.budget import EpisodeBudget +from shared.services.retrieval.agent_explore.config import ( + AGENT_EXPLORE_MAX_COMPLETION_TOKENS, + AGENT_EXPLORE_MODEL, + FINISH_TOOL_DESCRIPTION, + FINISH_TOOL_NAME, + FINISH_TOOL_SCHEMA, + LOOP_CONTRACT_SUFFIX, +) +from shared.services.retrieval.agent_explore.types import AgentStep, EpisodeResult +from shared.services.retrieval.agent_tools import ( + REGISTRY, + ToolContext, + ToolResult, + load_corpus_schema_text, +) +from shared.services.retrieval.agent_tools import tools as _agent_tools_registered # noqa: F401 + + +def _resolve_client_and_model() -> tuple[Any, str]: + """Mirrors ``nav_llm_backend.nav_chat_sync_backend``'s resolve pattern, + pinned to ``AGENT_EXPLORE_MODEL`` instead of ``nav_config.MAPNAV_MODEL``. + """ + from shared.services.ai.llm_overrides import resolve_text + from shared.services.ai.openai_compatible_client_sync import get_openai_client + + requested = AGENT_EXPLORE_MODEL + effective_model, api_key, api_url = resolve_text(requested) + model = effective_model or requested + client = get_openai_client(model=model, api_key=api_key, api_url=api_url) + return client, model + + +def _openai_safe_name(name: str) -> str: + """DeepSeek's (OpenAI-compatible) function-calling API rejects ``.`` in + ``tools[].function.name`` (must match ``^[a-zA-Z0-9_-]+$``, verified + live), but every ``agent_tools`` name is dotted (``corpus.read``) and + that's also what MCP clients see (no such restriction there) — so the + dotted name stays canonical in ``REGISTRY``/MCP, and this harness-local + underscore form exists only for the wire format to this one provider. + """ + return name.replace(".", "_") + + +def _build_openai_tools() -> tuple[list[dict[str, Any]], dict[str, str]]: + """Return ``(tools, name_map)`` where ``name_map`` maps the OpenAI-safe + name back to the canonical ``REGISTRY`` name (``finish`` maps to itself). + """ + name_map: dict[str, str] = {FINISH_TOOL_NAME: FINISH_TOOL_NAME} + tools: list[dict[str, Any]] = [] + for spec in REGISTRY.all(): + safe_name = _openai_safe_name(spec.name) + name_map[safe_name] = spec.name + tools.append( + { + "type": "function", + "function": { + "name": safe_name, + "description": spec.description, + "parameters": spec.json_schema, + }, + } + ) + tools.append( + { + "type": "function", + "function": { + "name": FINISH_TOOL_NAME, + "description": FINISH_TOOL_DESCRIPTION, + "parameters": FINISH_TOOL_SCHEMA, + }, + } + ) + return tools, name_map + + +def _safe_json_loads(raw: str | None) -> dict[str, Any]: + if not raw: + return {} + try: + parsed = json.loads(raw) + except (TypeError, ValueError): + return {} + return parsed if isinstance(parsed, dict) else {} + + +def _normalize_finish_refs(raw: Any) -> list[dict[str, Any]]: + if not isinstance(raw, list): + return [] + normalized: list[dict[str, Any]] = [] + for item in raw: + if isinstance(item, dict) and str(item.get("document_id") or "").strip(): + normalized.append(item) + return normalized + + +def _tool_message_content(result: ToolResult, *, max_chars: int) -> str: + """Cap a tool's rendered text before it enters LLM context. + + Uses ``ToolBudget.max_chars`` (``EVIDENCE_TEXT_CHAR_BUDGET``, aligned with + map-nav evidence packing) so tools like ``read`` can return unbounded body + text while the harness still bounds what the model sees per turn. + """ + if result.error: + return f"error: {result.error}" + text = result.text or "(empty result)" + if len(text) <= max_chars: + return text + omitted = len(text) - max_chars + return ( + text[:max_chars] + + f"\n...[truncated, {omitted} more chars — call corpus.read again " + "with a narrower/more specific ref if you need the rest]" + ) + + +async def run_agent_explore_episode( + *, + db: AsyncSession, + user_id: str, + namespace: str, + query: str, + budget: EpisodeBudget | None = None, +) -> EpisodeResult: + budget = budget or EpisodeBudget() + tool_ctx = ToolContext(db=db, user_id=user_id, namespace=namespace) + client, model = _resolve_client_and_model() + openai_tools, tool_name_map = _build_openai_tools() + + system_prompt = load_corpus_schema_text() + LOOP_CONTRACT_SUFFIX + messages: list[dict[str, Any]] = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": query}, + ] + + steps: list[AgentStep] = [] + stop_reason = "finished" + result_refs: list[dict[str, Any]] = [] + result_notes = "" + + while True: + forced_reason = budget.exhausted() + tool_choice: Any = "auto" + if forced_reason is not None: + tool_choice = {"type": "function", "function": {"name": FINISH_TOOL_NAME}} + + turn_started = time.perf_counter() + response, usage = await asyncio.to_thread( + client.chat_completion_raw_with_usage, + messages=messages, + model=model, + temperature=0.0, + max_tokens=AGENT_EXPLORE_MAX_COMPLETION_TOKENS, + tools=openai_tools, + tool_choice=tool_choice, + ) + budget.record_usage(usage) + budget.record_step() + turn_elapsed_ms = int((time.perf_counter() - turn_started) * 1000) + turn_tokens = int((usage or {}).get("total_tokens", 0) or 0) + + message = response.choices[0].message + tool_calls = list(message.tool_calls or []) + + if not tool_calls: + stop_reason = f"budget_{forced_reason}" if forced_reason else "no_tool_call" + result_notes = str(message.content or "") + steps.append( + AgentStep( + step_index=len(steps), + tool_name="", + tool_args={}, + observation_text=result_notes, + error=None, + elapsed_ms=turn_elapsed_ms, + tokens_used_delta=turn_tokens, + tokens_used_total=budget.tokens_used, + ) + ) + break + + finish_call = next( + (tc for tc in tool_calls if tc.function.name == FINISH_TOOL_NAME), None + ) + if finish_call is not None: + args = _safe_json_loads(finish_call.function.arguments) + result_refs = _normalize_finish_refs(args.get("refs")) + result_notes = str(args.get("notes") or "") + stop_reason = f"budget_{forced_reason}" if forced_reason else "finished" + steps.append( + AgentStep( + step_index=len(steps), + tool_name=FINISH_TOOL_NAME, + tool_args=args, + observation_text=f"refs={len(result_refs)} notes={result_notes!r}", + error=None, + elapsed_ms=turn_elapsed_ms, + tokens_used_delta=turn_tokens, + tokens_used_total=budget.tokens_used, + ) + ) + break + + if forced_reason is not None: + # Forced tool_choice=finish but the provider returned a + # different tool anyway (not observed in verification, but a + # budget cutoff must never loop past). Stop here regardless. + stop_reason = f"budget_{forced_reason}" + result_notes = str(message.content or "") or ( + "budget exhausted; provider did not return finish" + ) + steps.append( + AgentStep( + step_index=len(steps), + tool_name="", + tool_args={}, + observation_text=result_notes, + error="forced_finish_not_honored", + elapsed_ms=turn_elapsed_ms, + tokens_used_delta=turn_tokens, + tokens_used_total=budget.tokens_used, + ) + ) + break + + messages.append( + { + "role": "assistant", + "content": message.content or "", + "tool_calls": [ + { + "id": tc.id, + "type": "function", + "function": { + "name": tc.function.name, + "arguments": tc.function.arguments, + }, + } + for tc in tool_calls + ], + } + ) + first_tool_tokens_recorded = False + for tc in tool_calls: + tool_started = time.perf_counter() + args = _safe_json_loads(tc.function.arguments) + requested_name = str(tc.function.name or "") + canonical_name = tool_name_map.get(requested_name, requested_name) + try: + tool_result = await REGISTRY.dispatch(canonical_name, tool_ctx, args) + except Exception as exc: # noqa: BLE001 - one broken tool must not kill the episode + tool_result = ToolResult(text="", error=f"{type(exc).__name__}: {exc}") + tool_elapsed_ms = int((time.perf_counter() - tool_started) * 1000) + content = _tool_message_content(tool_result, max_chars=tool_ctx.budget.max_chars) + messages.append( + {"role": "tool", "tool_call_id": tc.id, "content": content} + ) + # Turn-level token usage is attributed to the first tool step in + # this turn (the completion that decided all calls in it); the + # rest are 0 to avoid double-counting the same LLM usage. + steps.append( + AgentStep( + step_index=len(steps), + tool_name=canonical_name, + tool_args=args, + observation_text=content, + error=tool_result.error, + elapsed_ms=tool_elapsed_ms if first_tool_tokens_recorded else turn_elapsed_ms + tool_elapsed_ms, + tokens_used_delta=0 if first_tool_tokens_recorded else turn_tokens, + tokens_used_total=budget.tokens_used, + ) + ) + first_tool_tokens_recorded = True + + return EpisodeResult( + refs=result_refs, + notes=result_notes, + steps=steps, + stop_reason=stop_reason, + tokens_used=budget.tokens_used, + model_name=model, + ) diff --git a/packages/shared-python/shared/services/retrieval/agent_explore/ref_resolution.py b/packages/shared-python/shared/services/retrieval/agent_explore/ref_resolution.py new file mode 100644 index 00000000..36e1e982 --- /dev/null +++ b/packages/shared-python/shared/services/retrieval/agent_explore/ref_resolution.py @@ -0,0 +1,94 @@ +"""Resolve ``finish.refs`` into the chunk_id-bearing shape ``resolve_workflow_references`` requires. + +Verified live: ``resolve_workflow_references`` -> ``hydrate_referenced_chunk_rows`` +drops any ref whose lookup key has an empty ``chunk_id`` (``row_utils. +build_reference_lookup_key`` + the ``ref_keys = [k for k in ref_keys if k[0] +and k[1]]`` guard in ``hydration/reference.py``) — a ``{document_id, +section_path}``-only ref silently resolves to zero ``referenced_chunks``, +which is exactly the shape ``agent_tools.CORPUS_SCHEMA.md``/``corpus.read`` +teaches the agent to cite (``corpus.read``'s rendered ``text`` — the only +thing the LLM ever sees — shows ``section_path``, never ``chunk_id``; +``chunk_id`` only appears in its structured ``payload``/``refs``, which the +LLM does not see). This module closes that gap at the harness boundary +instead of changing what the agent is taught to cite: for any ref missing +``chunk_id``, resolve that section's own body chunk — the same "one section, +one body chunk" lookup ``corpus.read``'s ``section_path`` branch already +performs (``agent_tools/tools/read.py``), not a new resolution rule. +""" + +from __future__ import annotations + +from typing import Any + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from shared.models.database.document import Document, DocumentChunk, DocumentSection +from shared.services.retrieval.search.lexical_text import normalize_section_path + +_BODY_CHUNK_TYPES = ("text", "page") + + +async def resolve_finish_refs( + db: AsyncSession, + *, + user_id: str, + namespace: str, + refs: list[dict[str, Any]], +) -> list[dict[str, Any]]: + """Return refs with ``chunk_id`` populated; drops refs that don't resolve.""" + document_ids = { + str(ref.get("document_id") or "").strip() for ref in refs if ref.get("document_id") + } + if not document_ids: + return [] + + documents = ( + ( + await db.execute( + select(Document) + .where(Document.document_id.in_(document_ids)) + .where(Document.user_id == user_id) + .where(Document.namespace == namespace) + .where(Document.status == "active") + ) + ) + .scalars() + .all() + ) + revision_by_doc = { + d.document_id: d.current_job_result_id for d in documents if d.current_job_result_id + } + + resolved: list[dict[str, Any]] = [] + for ref in refs: + document_id = str(ref.get("document_id") or "").strip() + chunk_id = str(ref.get("chunk_id") or "").strip() + if document_id and chunk_id: + resolved.append({"document_id": document_id, "chunk_id": chunk_id}) + continue + + section_path = str(ref.get("section_path") or "").strip() + job_result_id = revision_by_doc.get(document_id) + if not (document_id and section_path and job_result_id): + continue + + row = ( + await db.execute( + select(DocumentChunk.chunk_id) + .select_from(DocumentChunk) + .join( + DocumentSection, + DocumentSection.section_id == DocumentChunk.section_id, + ) + .where(DocumentChunk.document_id == document_id) + .where(DocumentChunk.job_result_id == job_result_id) + .where(DocumentSection.section_path == normalize_section_path(section_path)) + .where(DocumentChunk.chunk_type.in_(_BODY_CHUNK_TYPES)) + ) + ).first() + if row is None: + continue + resolved.append({"document_id": document_id, "chunk_id": str(row[0])}) + + return resolved diff --git a/packages/shared-python/shared/services/retrieval/agent_explore/types.py b/packages/shared-python/shared/services/retrieval/agent_explore/types.py new file mode 100644 index 00000000..89bc90da --- /dev/null +++ b/packages/shared-python/shared/services/retrieval/agent_explore/types.py @@ -0,0 +1,32 @@ +"""Result types shared between ``episode.py`` and ``bridge.py``.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class AgentStep: + """One tool call executed during the episode (one LLM turn may yield several).""" + + step_index: int + tool_name: str + tool_args: dict[str, Any] + observation_text: str + error: str | None + elapsed_ms: int + tokens_used_delta: int + tokens_used_total: int + + +@dataclass +class EpisodeResult: + """Everything ``bridge.py`` / the route need after the episode ends.""" + + refs: list[dict[str, Any]] + notes: str + steps: list[AgentStep] = field(default_factory=list) + stop_reason: str = "finished" + tokens_used: int = 0 + model_name: str = "" diff --git a/packages/shared-python/shared/services/retrieval/agent_tools/__init__.py b/packages/shared-python/shared/services/retrieval/agent_tools/__init__.py index 2e228499..9f5b4284 100644 --- a/packages/shared-python/shared/services/retrieval/agent_tools/__init__.py +++ b/packages/shared-python/shared/services/retrieval/agent_tools/__init__.py @@ -21,8 +21,10 @@ ToolRegistry, ToolResult, ToolSpec, + capped_limit, register_tool, ) +from shared.services.retrieval.agent_tools.schema_doc import load_corpus_schema_text __all__ = [ "REGISTRY", @@ -31,5 +33,7 @@ "ToolRegistry", "ToolResult", "ToolSpec", + "capped_limit", + "load_corpus_schema_text", "register_tool", ] diff --git a/packages/shared-python/shared/services/retrieval/agent_tools/registry.py b/packages/shared-python/shared/services/retrieval/agent_tools/registry.py index a4286f52..609de82e 100644 --- a/packages/shared-python/shared/services/retrieval/agent_tools/registry.py +++ b/packages/shared-python/shared/services/retrieval/agent_tools/registry.py @@ -18,22 +18,40 @@ from sqlalchemy.ext.asyncio import AsyncSession +from shared.services.retrieval.settings import EVIDENCE_TEXT_CHAR_BUDGET + @dataclass(frozen=True) class ToolBudget: """Per-call output budget passed down to every tool via ``ToolContext``. - Tools that return an unbounded/ranked list (``recall``, ``grep``, asset - forward search) truncate to ``max_items`` and note the omission. Tools - that promise a complete, non-truncated set by contract (``node_filter``, - ``outline``) do not apply this budget to their matched-set cardinality — - see ``CORPUS_SCHEMA.md`` §6. + ``max_items`` is a hard ceiling on how many rows a tool that returns an + unbounded/ranked list (``recall``, ``grep``, asset forward search) may + return in one call: each such tool keeps its own smaller, tool-appropriate + default (e.g. ``grep``'s ``max_results``, ``recall``'s ``top_k``) but + clamps the caller-requested value to this ceiling via ``capped_limit()`` + below, and notes it in ``ToolResult.text`` when the request was clamped. + Tools that promise a complete, non-truncated set by contract + (``node_filter``, ``outline``) do not apply this budget to their + matched-set cardinality — see ``CORPUS_SCHEMA.md`` §6. + + ``max_chars`` caps the rendered ``ToolResult.text`` before it enters LLM + context. Applied in ``agent_explore.episode._tool_message_content`` (not + inside individual tools) so ``read`` can return full body text from the + tool while the harness still bounds what the model sees per turn. Aligned + with map-nav final evidence packing via ``EVIDENCE_TEXT_CHAR_BUDGET`` + (12_000). """ - max_chars: int = 8000 + max_chars: int = EVIDENCE_TEXT_CHAR_BUDGET max_items: int = 50 +def capped_limit(requested: int, budget: ToolBudget) -> int: + """Clamp a caller-requested row count to ``budget.max_items`` (min 1).""" + return max(1, min(requested, budget.max_items)) + + @dataclass class ToolContext: """Per-call execution context. One instance is built per tool dispatch.""" diff --git a/packages/shared-python/shared/services/retrieval/agent_tools/schema_doc.py b/packages/shared-python/shared/services/retrieval/agent_tools/schema_doc.py new file mode 100644 index 00000000..37569589 --- /dev/null +++ b/packages/shared-python/shared/services/retrieval/agent_tools/schema_doc.py @@ -0,0 +1,21 @@ +"""Single loader for ``CORPUS_SCHEMA.md`` — the agent-facing corpus schema text. + +Both harnesses (Phase 3) read through this function instead of the file +directly, so there is exactly one place that resolves the path: the API +``/mcp`` server's ``instructions`` and ``agent_explore``'s system prompt must +stay byte-identical for the shared schema portion (see the module docstring +at the top of ``CORPUS_SCHEMA.md`` — "do not duplicate it elsewhere"). +""" + +from __future__ import annotations + +from functools import lru_cache +from pathlib import Path + +_SCHEMA_PATH = Path(__file__).with_name("CORPUS_SCHEMA.md") + + +@lru_cache(maxsize=1) +def load_corpus_schema_text() -> str: + """Return the verbatim contents of ``CORPUS_SCHEMA.md``.""" + return _SCHEMA_PATH.read_text(encoding="utf-8") diff --git a/packages/shared-python/shared/services/retrieval/agent_tools/snippet.py b/packages/shared-python/shared/services/retrieval/agent_tools/snippet.py new file mode 100644 index 00000000..6dfed42c --- /dev/null +++ b/packages/shared-python/shared/services/retrieval/agent_tools/snippet.py @@ -0,0 +1,72 @@ +"""Shared hit-anchored snippet builder for ``corpus.grep`` and ``corpus.recall``. + +Both tools locate a single pattern/term inside one chunk's text and need to +show a bounded excerpt around it. The shape is: head anchor + the window +around the (first) match + tail anchor, with ``...`` between spans that do +not touch. Overlapping/adjacent spans are merged before rendering so short +chunks never produce duplicate text or a stray ``...`` inside otherwise +continuous text. + +Only the first match is windowed. A single call passes a single +pattern/term, but that pattern can still occur more than once inside one +chunk (verified against real data); later occurrences in the same chunk are +not separately windowed here — use ``corpus.read`` on the chunk for the rest. +""" + +from __future__ import annotations + +HIT_CONTEXT_CHARS = 80 +HEAD_TAIL_CHARS = 50 + + +def _merge_spans(spans: list[tuple[int, int]]) -> list[tuple[int, int]]: + ordered = sorted(s for s in spans if s[1] > s[0]) + merged: list[list[int]] = [] + for start, end in ordered: + if merged and start <= merged[-1][1]: + merged[-1][1] = max(merged[-1][1], end) + else: + merged.append([start, end]) + return [(start, end) for start, end in merged] + + +def build_snippet( + text: str, + hit: tuple[int, int] | None = None, + *, + hit_context: int = HIT_CONTEXT_CHARS, + head_tail: int = HEAD_TAIL_CHARS, +) -> str: + """Head/tail anchor + first-match window, joined by ``...`` where spans don't touch. + + ``hit`` is the ``(start, end)`` char offset of the located match in + ``text``, or ``None`` when no specific position is known (falls back to + head/tail anchors only). Short text (<= ``head_tail * 2`` chars) is + returned unchanged. + """ + if not text: + return "" + if len(text) <= head_tail * 2: + return text + + spans: list[tuple[int, int]] = [ + (0, head_tail), + (max(len(text) - head_tail, 0), len(text)), + ] + if hit is not None: + hit_start, hit_end = hit + spans.append( + (max(hit_start - hit_context, 0), min(hit_end + hit_context, len(text))) + ) + + merged = _merge_spans(spans) + parts: list[str] = [] + prev_end = 0 + for start, end in merged: + if start > prev_end: + parts.append("...") + parts.append(text[start:end]) + prev_end = end + if prev_end < len(text): + parts.append("...") + return "".join(parts) diff --git a/packages/shared-python/shared/services/retrieval/agent_tools/tools/grep.py b/packages/shared-python/shared/services/retrieval/agent_tools/tools/grep.py index 81bca87e..af7224c7 100644 --- a/packages/shared-python/shared/services/retrieval/agent_tools/tools/grep.py +++ b/packages/shared-python/shared/services/retrieval/agent_tools/tools/grep.py @@ -4,6 +4,11 @@ revision. Reports a total match count (over the full in-scope corpus, not just the returned page) alongside capped snippets, so ANY/ALL logic can close over body text the same way ``corpus.node_filter`` closes over titles/summaries. + +Snippets are built by the shared ``agent_tools.snippet.build_snippet`` (head ++ first-match window + tail, ``...``-joined, overlap-merged) — the same +mechanism ``corpus.recall``'s term channel uses, so the two tools don't carry +duplicate window-slicing logic or drift to different constants. """ from __future__ import annotations @@ -17,11 +22,16 @@ from shared.services.retrieval.agent_tools.registry import ( ToolContext, ToolResult, + capped_limit, register_tool, ) +from shared.services.retrieval.agent_tools.snippet import ( + HIT_CONTEXT_CHARS, + build_snippet, +) _DEFAULT_MAX_RESULTS = 30 -_DEFAULT_CONTEXT_CHARS = 80 +_DEFAULT_CONTEXT_CHARS = HIT_CONTEXT_CHARS def _build_scope_filters( @@ -70,7 +80,8 @@ async def grep(ctx: ToolContext, args: dict[str, Any]) -> ToolResult: return ToolResult(text="", error="grep requires pattern") is_regex = bool(args.get("is_regex", False)) context_chars = int(args.get("context_chars") or _DEFAULT_CONTEXT_CHARS) - max_results = int(args.get("max_results") or _DEFAULT_MAX_RESULTS) + requested_max_results = int(args.get("max_results") or _DEFAULT_MAX_RESULTS) + max_results = capped_limit(requested_max_results, ctx.budget) document_ids = [ str(d).strip() for d in (args.get("document_ids") or []) if str(d).strip() ] @@ -128,12 +139,9 @@ async def grep(ctx: ToolContext, args: dict[str, Any]) -> ToolResult: for chunk_id, document_id, chunk_type, content, section_path, source_file_name in rows: text = str(content or "") match = compiled.search(text) - if match is None: - snippet = text[: context_chars * 2] - else: - start = max(match.start() - context_chars, 0) - end = min(match.end() + context_chars, len(text)) - snippet = text[start:end] + snippet = build_snippet( + text, match.span() if match else None, hit_context=context_chars + ) results.append( { "document_id": document_id, @@ -146,6 +154,8 @@ async def grep(ctx: ToolContext, args: dict[str, Any]) -> ToolResult: ) lines = [f"total_matches={total_matches} returned={len(results)}"] + if requested_max_results > max_results: + lines.append(f"note: capped to budget.max_items={ctx.budget.max_items}") for r in results: lines.append(f"- {r['source_file_name']} / {r['section_path']}: {r['snippet']!r}") diff --git a/packages/shared-python/shared/services/retrieval/agent_tools/tools/node_filter.py b/packages/shared-python/shared/services/retrieval/agent_tools/tools/node_filter.py index 84116afb..d715a063 100644 --- a/packages/shared-python/shared/services/retrieval/agent_tools/tools/node_filter.py +++ b/packages/shared-python/shared/services/retrieval/agent_tools/tools/node_filter.py @@ -147,7 +147,8 @@ async def node_filter(ctx: ToolContext, args: dict[str, Any]) -> ToolResult: if chunk_types: chunk_rows = await ctx.db.execute( select(DocumentChunk.section_id, DocumentChunk.chunk_type).where( - DocumentChunk.document_id.in_([d for d, _ in revision_pairs]) + DocumentChunk.document_id.in_([d for d, _ in revision_pairs]), + DocumentChunk.job_result_id.in_([r for _, r in revision_pairs]), ) ) allowed_section_ids = { diff --git a/packages/shared-python/shared/services/retrieval/agent_tools/tools/read.py b/packages/shared-python/shared/services/retrieval/agent_tools/tools/read.py index 2b532a3f..dd1f09e3 100644 --- a/packages/shared-python/shared/services/retrieval/agent_tools/tools/read.py +++ b/packages/shared-python/shared/services/retrieval/agent_tools/tools/read.py @@ -35,9 +35,6 @@ ToolResult, register_tool, ) -from shared.services.retrieval.hydration.asset_inline import ( - inline_assets_at_placeholders, -) from shared.services.retrieval.hydration.assets import ( enrich_rows_with_retrieval_asset_url, ) @@ -45,7 +42,6 @@ from shared.services.retrieval.hydration.result_assembly import ( _compose_table_content, _compose_text_content, - _connected_display_by_target, _image_display_content, ) from shared.services.retrieval.hydration.row_utils import normalize_chunk_type @@ -57,23 +53,12 @@ _SAME_AS_MARKER_RE = re.compile(r"\[SAME-AS (.+?) p(\d+)\]") _BODY_CHUNK_TYPES = ("text", "page") - -def _compose_page_content( - row: dict[str, Any], rows_by_chunk_id: dict[str, dict[str, Any]] -) -> str: - """Like ``_compose_text_content`` but never downgraded to a summary.""" - base_content = str(row.get("content") or "") - display_by_target = _connected_display_by_target(row, rows_by_chunk_id) - if not display_by_target: - return base_content - metadata = row.get("chunk_metadata") or {} - connections = metadata.get("connect_to") if isinstance(metadata, dict) else None - content, _embedded = inline_assets_at_placeholders( - base_content, - connections=connections if isinstance(connections, list) else [], - display_by_target=display_by_target, - ) - return content +# ``_compose_text_content`` doesn't branch on chunk_type — it just inlines +# connect_to placeholders — so the ``page`` branch below reuses it directly +# instead of carrying a near-identical copy. The behavioral difference from +# retrieval's own page handling (never downgrading to a summary — see the +# module docstring) comes entirely from *not* calling ``_page_summary`` +# first, which this module never did. async def _resolve_same_as_markers( @@ -369,7 +354,7 @@ async def read(ctx: ToolContext, args: dict[str, Any]) -> ToolResult: if chunk_type == "text": composed["content"] = _compose_text_content(row, rows_by_chunk_id) if include_assets else row.get("content") elif chunk_type == "page": - composed["content"] = _compose_page_content(row, rows_by_chunk_id) if include_assets else row.get("content") + composed["content"] = _compose_text_content(row, rows_by_chunk_id) if include_assets else row.get("content") elif chunk_type == "table": composed["content"] = _compose_table_content(row, rows_by_chunk_id) elif chunk_type == "image": diff --git a/packages/shared-python/shared/services/retrieval/agent_tools/tools/recall.py b/packages/shared-python/shared/services/retrieval/agent_tools/tools/recall.py index 62b870a2..a4c7f61d 100644 --- a/packages/shared-python/shared/services/retrieval/agent_tools/tools/recall.py +++ b/packages/shared-python/shared/services/retrieval/agent_tools/tools/recall.py @@ -20,6 +20,11 @@ there is no persisted precedent for a different weight ratio between them (the old 3-channel weights of path=1.0/content=2.0/term=1.5 no longer exist in code — only path=1.0/content=2.0 survive in ``nav.knowhere_hybrid``). + +The term channel's snippet and the rendered ``text`` preview both go through +the shared ``agent_tools.snippet.build_snippet`` (head + first-match window + +tail, ``...``-joined, overlap-merged) — the same mechanism ``corpus.grep`` +uses, so window-slicing constants live in one place. """ from __future__ import annotations @@ -33,14 +38,16 @@ from shared.services.retrieval.agent_tools.registry import ( ToolContext, ToolResult, + capped_limit, register_tool, ) +from shared.services.retrieval.agent_tools.snippet import build_snippet from shared.services.retrieval.search.map_unit_discovery import map_unit_discovery from shared.services.retrieval.search.scoring import merge_channels_rrf _SUPPORTED_CHANNELS = {"path_content", "term"} _RESERVED_CHANNELS = {"vector"} -_DEFAULT_TOP_K = 20 +_DEFAULT_TOP_K = 10 _TERM_CHANNEL_SQL = """ SELECT dmu.document_id, dmu.job_result_id, dmu.section_id, ds.section_path, @@ -127,9 +134,9 @@ async def _term_channel_rows( continue if chunk_types and chunk.chunk_type not in chunk_types: continue - needle_pos = unit_row["term_search_text_lower"].find(needle) - window_start = max(needle_pos - 80, 0) - window_end = min(needle_pos + len(needle) + 80, len(unit_row["term_search_text_lower"])) + haystack = unit_row["term_search_text_lower"] + needle_pos = haystack.find(needle) + hit = (needle_pos, needle_pos + len(needle)) if needle_pos >= 0 else None results.append( { "chunk_id": chunk.chunk_id, @@ -138,7 +145,7 @@ async def _term_channel_rows( "section_path": unit_row["section_path"], "source_file_name": unit_row["source_file_name"], "chunk_type": chunk.chunk_type, - "snippet": unit_row["term_search_text_lower"][window_start:window_end], + "snippet": build_snippet(haystack, hit), } ) return results @@ -176,7 +183,8 @@ async def recall(ctx: ToolContext, args: dict[str, Any]) -> ToolResult: query = str(args.get("query") or "").strip() if not query: return ToolResult(text="", error="recall requires query") - top_k = int(args.get("top_k") or _DEFAULT_TOP_K) + requested_top_k = int(args.get("top_k") or _DEFAULT_TOP_K) + top_k = capped_limit(requested_top_k, ctx.budget) document_ids = [ str(d).strip() for d in (args.get("document_ids") or []) if str(d).strip() ] @@ -233,8 +241,10 @@ async def recall(ctx: ToolContext, args: dict[str, Any]) -> ToolResult: lines = [f"candidates={len(fused)}"] if reserved_requested: lines.append(f"note: channels {sorted(reserved_requested)} are reserved, not run") + if requested_top_k > top_k: + lines.append(f"note: capped to budget.max_items={ctx.budget.max_items}") for row in fused: - snippet = str(row.get("content") or row.get("snippet") or "")[:200] + snippet = build_snippet(str(row.get("content") or row.get("snippet") or "")) lines.append( f"- {row.get('source_file_name')} / {row.get('section_path')} " f"score={row.get('score')}: {snippet!r}" diff --git a/packages/shared-python/shared/services/retrieval/execution/routes.py b/packages/shared-python/shared/services/retrieval/execution/routes.py index 3f6a8270..23b17859 100644 --- a/packages/shared-python/shared/services/retrieval/execution/routes.py +++ b/packages/shared-python/shared/services/retrieval/execution/routes.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import os import resource import time from contextlib import AbstractAsyncContextManager @@ -60,6 +61,20 @@ def _render_rows_evidence(rows: list[dict]) -> str: return render_evidence_blocks(list(groups.items())) +_AGENTIC_ROUTERS = {"mapnav", "agent_explore"} +_AGENTIC_ROUTER_ENV = "RETRIEVAL_AGENTIC_ROUTER" + + +def _resolve_agentic_router() -> str: + """``RETRIEVAL_AGENTIC_ROUTER`` env switch: ``mapnav`` (default, current + production route) or ``agent_explore`` (Phase 3 of + ``.cursor/plans/agentic_corpus_explore_retrieval_c2c4ea21.plan.md``, + pending its Phase 4 evaluation gate before it can become the default). + """ + value = os.environ.get(_AGENTIC_ROUTER_ENV, "").strip().lower() + return value if value in _AGENTIC_ROUTERS else "mapnav" + + async def run_retrieval_route( context: RetrievalRouteContext, ) -> RetrievalRouteOutcome: @@ -67,10 +82,13 @@ async def run_retrieval_route( if small_corpus_outcome is not None: return small_corpus_outcome - # Explicit False → classic 3-channel top-K. None/True → map-nav (default). + # Explicit False → classic map-unit BM25 top-K. None/True → agentic, + # routed by RETRIEVAL_AGENTIC_ROUTER (default mapnav). if context.use_agentic is False: return await _run_classic_topk_route(context) + if _resolve_agentic_router() == "agent_explore": + return await _run_agent_explore_route(context) return await _run_mapnav_route(context) @@ -190,6 +208,118 @@ async def _run_classic_topk_route( ) +async def _run_agent_explore_route( + context: RetrievalRouteContext, +) -> RetrievalRouteOutcome: + """Phase 3 agentic path: in-process ``corpus.*`` tool-calling loop. + + Selected when ``RETRIEVAL_AGENTIC_ROUTER=agent_explore``; ``mapnav`` + remains the default route until this one passes its Phase 4 evaluation + gate. See ``shared/services/retrieval/agent_explore/``. + """ + from shared.services.retrieval.agent_explore.bridge import build_decision_trace + from shared.services.retrieval.agent_explore.episode import ( + run_agent_explore_episode, + ) + from shared.services.retrieval.agent_explore.ref_resolution import ( + resolve_finish_refs, + ) + from shared.services.retrieval.trace import TraceRecorder + + episode_started = time.perf_counter() + episode = await run_agent_explore_episode( + db=context.db, + user_id=context.user_id, + namespace=context.namespace, + query=context.query, + ) + logger.info( + "retrieval agent_explore stage=episode seconds={:.3f} refs={} " + "steps={} tokens={} stop_reason={}".format( + time.perf_counter() - episode_started, + len(episode.refs), + len(episode.steps), + episode.tokens_used, + episode.stop_reason, + ) + ) + + # episode.refs are document_id + section_path (what the agent actually + # sees in tool text); resolve_workflow_references requires chunk_id — + # see ref_resolution.py's module docstring for why this bridge exists. + chunk_refs = await resolve_finish_refs( + context.db, + user_id=context.user_id, + namespace=context.namespace, + refs=episode.refs, + ) + resolved = await resolve_workflow_references( + db=context.db, + user_id=context.user_id, + namespace=context.namespace, + refs=chunk_refs, + revision_pins=context.revision_pins, + ) + assembled_rows = await assemble_retrieval_results( + db=context.db, + rows=resolved.rows, + exclude_document_ids=context.exclude_document_ids, + exclude_sections=context.exclude_sections, + allowed_chunk_types=context.allowed_chunk_types, + revision_pins=context.revision_pins, + ) + + decision_steps = build_decision_trace(episode.steps) + decision_trace = [step.to_dict() for step in decision_steps] + selected_doc_ids = list( + {row.get("document_id", "") for row in resolved.rows if row.get("document_id")} + ) + + trace = TraceRecorder( + context.db, + user_id=context.user_id, + namespace=context.namespace, + query=context.query, + top_k=context.top_k, + chunk_types=context.allowed_chunk_types, + policy_name="agent_explore_v1", + ) + await trace.create_run() + for step in decision_steps: + trace.record_decision_trace_step(step) + if episode.stop_reason.startswith("budget_"): + trace.record_budget_stop(episode.stop_reason.removeprefix("budget_")) + await trace.complete( + assembled_rows, + "agent_explore", + token_count=episode.tokens_used, + model_name=episode.model_name, + selected_doc_ids=selected_doc_ids, + ) + + evidence_text = _render_rows_evidence(assembled_rows) + response = { + "namespace": context.namespace, + "query": context.query, + "router_used": "agent_explore", + "evidence_text": evidence_text, + "answer_text": "", + "referenced_chunks": resolved.refs, + "results": assembled_rows, + "stop_reason": episode.stop_reason, + "decision_trace": decision_trace, + } + return RetrievalRouteOutcome( + response=response, + hit_stats_results=resolved.refs, + completion_label="AGENT EXPLORE RETRIEVAL", + completion_count=len(resolved.refs), + completion_detail=( + f"chunks | evidence={len(evidence_text)} chars | router=agent_explore" + ), + ) + + async def _run_mapnav_route( context: RetrievalRouteContext, ) -> RetrievalRouteOutcome: diff --git a/packages/shared-python/shared/services/retrieval/nav_config.py b/packages/shared-python/shared/services/retrieval/nav_config.py index 48067194..c7338f09 100644 --- a/packages/shared-python/shared/services/retrieval/nav_config.py +++ b/packages/shared-python/shared/services/retrieval/nav_config.py @@ -16,10 +16,11 @@ from typing import Any from shared.services.retrieval.nav.nav_types import NavConfig +from shared.services.retrieval.settings import EVIDENCE_TEXT_CHAR_BUDGET # Migrated probe / llm_api.env stack. MAPNAV_MODEL = "deepseek-v4-flash" -MAPNAV_EVIDENCE_CHARS = 12_000 +MAPNAV_EVIDENCE_CHARS = EVIDENCE_TEXT_CHAR_BUDGET MAPNAV_TOKEN_LIMIT = 100_000 MAPNAV_PLANNER_THINK_MAX_TOKENS = 16_384 MAPNAV_TRACE_RAW_CHARS = 2_000 diff --git a/packages/shared-python/shared/services/retrieval/settings.py b/packages/shared-python/shared/services/retrieval/settings.py index f2e24b56..7e7df316 100644 --- a/packages/shared-python/shared/services/retrieval/settings.py +++ b/packages/shared-python/shared/services/retrieval/settings.py @@ -6,6 +6,11 @@ RRF_K = 60 DEFAULT_TOP_K = 10 +# Final evidence / tool-observation text budget (characters). Shared by map-nav +# evidence packing (``nav_config.MAPNAV_EVIDENCE_CHARS``) and agent tool-loop +# harness caps (``ToolBudget.max_chars`` in ``agent_explore/episode.py``). +EVIDENCE_TEXT_CHAR_BUDGET = 12_000 + VALID_CHUNK_TYPES: set[str] = {"text", "image", "table", "page"} ASSET_CHUNK_TYPES: set[str] = {"image", "table"} From 1e80906f1206a2f7002dc8cde410cde8b97eb82f Mon Sep 17 00:00:00 2001 From: chengke <404835780@qq.com> Date: Tue, 8 Sep 2026 17:29:08 +0800 Subject: [PATCH 3/6] feat(retrieval): improve agent exploration and message handling Enhanced the agent exploration logic by implementing a two-phase fix for stale tool-message collapsing, ensuring efficient message management across turns. Introduced a fallback mechanism for trajectory references, allowing the system to utilize previously read content when no current references are available. Updated documentation to clarify the requirements for `refs` when calling `{FINISH_TOOL_NAME}` and refined the handling of tool messages to prevent unnecessary resends, optimizing performance and clarity in the exploration process. --- .../scripts/debug_agent_explore_episode.py | 90 +++++ apps/worker/scripts/debug_finish_raw_args.py | 65 ++++ .../changheba_archive_eval_queries.json | 93 +++++ .../worker/scripts/run_agentic_router_eval.py | 321 ++++++++++++++++++ .../retrieval/agent_explore/config.py | 7 + .../retrieval/agent_explore/episode.py | 130 ++++++- 6 files changed, 703 insertions(+), 3 deletions(-) create mode 100644 apps/worker/scripts/debug_agent_explore_episode.py create mode 100644 apps/worker/scripts/debug_finish_raw_args.py create mode 100644 apps/worker/scripts/fixtures/changheba_archive_eval_queries.json create mode 100644 apps/worker/scripts/run_agentic_router_eval.py diff --git a/apps/worker/scripts/debug_agent_explore_episode.py b/apps/worker/scripts/debug_agent_explore_episode.py new file mode 100644 index 00000000..a74e9928 --- /dev/null +++ b/apps/worker/scripts/debug_agent_explore_episode.py @@ -0,0 +1,90 @@ +"""Audit-only: dump every step of one ``agent_explore`` episode. + +Prints, for each LLM turn / tool call: tool name, args, elapsed ms, +tokens_used_delta (turn-level, attributed to the first tool step — see +``episode.py``), tokens_used_total (cumulative), observation length (chars +sent back into the LLM's context), and error. Also prints the final +``EpisodeResult`` (refs/notes/stop_reason). + +Read-only diagnostic; does not modify any behavior. + +Usage: + cd apps/worker + uv run python scripts/debug_agent_explore_episode.py --query-id q04 + uv run python scripts/debug_agent_explore_episode.py --query "..." --token-limit 200000 +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import os +import sys +from pathlib import Path + +from dotenv import load_dotenv + +_SCRIPT_DIR = Path(__file__).resolve().parent +_REPO_ROOT = _SCRIPT_DIR.parents[1] +sys.path.insert(0, str(_REPO_ROOT / "packages" / "shared-python")) +sys.path.insert(0, str(_SCRIPT_DIR.parent)) + +load_dotenv(_SCRIPT_DIR.parent / ".env") +os.environ.setdefault("LOCAL_DEBUG", "0") +os.environ.setdefault("LLM_MOCK_ENABLED", "false") + +FIXTURE_PATH = _SCRIPT_DIR / "fixtures" / "changheba_archive_eval_queries.json" + + +async def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--query-id", default=None) + parser.add_argument("--query", default=None) + parser.add_argument("--user-id", default="debug_local_user") + parser.add_argument("--namespace", default="default") + parser.add_argument("--token-limit", type=int, default=None) + args = parser.parse_args() + + query = args.query + if args.query_id: + fixture = json.loads(FIXTURE_PATH.read_text(encoding="utf-8")) + match = next(q for q in fixture["queries"] if q["id"] == args.query_id) + query = match["query"] + if not query: + raise SystemExit("need --query or --query-id") + + from shared.core.database import get_db_context + from shared.services.retrieval.agent_explore.budget import EpisodeBudget + from shared.services.retrieval.agent_explore.episode import run_agent_explore_episode + + budget = EpisodeBudget(token_limit=args.token_limit) if args.token_limit else None + + print(f"query: {query!r}") + async with get_db_context() as db: + episode = await run_agent_explore_episode( + db=db, + user_id=args.user_id, + namespace=args.namespace, + query=query, + budget=budget, + ) + + print(f"\nstop_reason={episode.stop_reason} tokens_used={episode.tokens_used} " + f"model={episode.model_name}") + print(f"final refs ({len(episode.refs)}): {json.dumps(episode.refs, ensure_ascii=False)}") + print(f"final notes: {episode.notes!r}") + print(f"\n{'#':>3} {'tool':<28} {'ms':>6} {'delta':>7} {'total':>7} {'obs_chars':>9} err") + for step in episode.steps: + args_preview = json.dumps(step.tool_args, ensure_ascii=False)[:80] + err = step.error or "" + print( + f"{step.step_index:>3} {step.tool_name or '(none)':<28} " + f"{step.elapsed_ms:>6} {step.tokens_used_delta:>7} " + f"{step.tokens_used_total:>7} {len(step.observation_text):>9} {err}" + ) + print(f" args: {args_preview}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/apps/worker/scripts/debug_finish_raw_args.py b/apps/worker/scripts/debug_finish_raw_args.py new file mode 100644 index 00000000..9a6099fe --- /dev/null +++ b/apps/worker/scripts/debug_finish_raw_args.py @@ -0,0 +1,65 @@ +"""Audit-only: monkeypatch to see the RAW ``finish`` tool_call.function.arguments +string the LLM actually sent, before ``_safe_json_loads`` parses it. Does not +modify any file — patches the imported module object in this process only. +""" + +from __future__ import annotations + +import asyncio +import json +import os +import sys +from pathlib import Path + +from dotenv import load_dotenv + +_SCRIPT_DIR = Path(__file__).resolve().parent +_REPO_ROOT = _SCRIPT_DIR.parents[1] +sys.path.insert(0, str(_REPO_ROOT / "packages" / "shared-python")) +sys.path.insert(0, str(_SCRIPT_DIR.parent)) + +load_dotenv(_SCRIPT_DIR.parent / ".env") +os.environ.setdefault("LOCAL_DEBUG", "0") +os.environ.setdefault("LLM_MOCK_ENABLED", "false") + +FIXTURE_PATH = _SCRIPT_DIR / "fixtures" / "changheba_archive_eval_queries.json" + + +async def main() -> None: + import argparse + + parser = argparse.ArgumentParser() + parser.add_argument("--query-id", required=True) + args = parser.parse_args() + + fixture = json.loads(FIXTURE_PATH.read_text(encoding="utf-8")) + match = next(q for q in fixture["queries"] if q["id"] == args.query_id) + query = match["query"] + + from shared.services.retrieval.agent_explore import episode as episode_mod + + original_safe_json_loads = episode_mod._safe_json_loads + + def _spy(raw): + print(f"[SPY] raw arguments received: {raw!r}") + return original_safe_json_loads(raw) + + episode_mod._safe_json_loads = _spy + + from shared.core.database import get_db_context + + print(f"query: {query!r}") + async with get_db_context() as db: + result = await episode_mod.run_agent_explore_episode( + db=db, + user_id="debug_local_user", + namespace="default", + query=query, + ) + print(f"\nfinal refs: {result.refs}") + print(f"final notes: {result.notes!r}") + print(f"stop_reason: {result.stop_reason}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/apps/worker/scripts/fixtures/changheba_archive_eval_queries.json b/apps/worker/scripts/fixtures/changheba_archive_eval_queries.json new file mode 100644 index 00000000..b1c640b8 --- /dev/null +++ b/apps/worker/scripts/fixtures/changheba_archive_eval_queries.json @@ -0,0 +1,93 @@ +{ + "version": "1.0", + "source_docx": "/Users/wuchengke/Desktop/temp/test_docs/yideng/zh_档案知识库测试样例.docx", + "corpus": { + "user_id": "debug_local_user", + "namespace": "default", + "documents": [ + "zh_四川省大渡河长河坝水电站可行性研究报告1.pdf", + "zh_四川省大渡河长河坝水电站可行性研究报告2.pdf" + ] + }, + "queries": [ + { + "id": "q01", + "label": "roles_wangrenkun", + "query": "王仁坤作为不同角色参与了哪些设计成果的编制?", + "answer_notes": [ + "作为总工程师:报告1综合说明封面、报告2水文泥沙封面", + "作为审查人:报告1审查意见汇编(报告2水文泥沙正文未检出审查人署名)" + ], + "expected_keywords": ["王仁坤", "总工程师", "审查"] + }, + { + "id": "q02", + "label": "hydro_stations", + "query": "长河坝水电站上下游分别设置了几个水文站?水文站的水位观测、流量测验情况分别是怎样的?", + "answer_notes": [ + "报告2水文泥沙2.3:干流3站(上大金/丹巴、下泸定),支流金汤、康定", + "水位枯2/汛4(涨水加测),流量流速仪与浮标结合" + ], + "expected_keywords": ["水文", "丹巴", "泸定", "水位", "流量"] + }, + { + "id": "q03", + "label": "project_status_power_scope", + "query": "长河坝水电站的工程地位和作用是什么?供电范围是什么?", + "answer_notes": [ + "报告1综合说明1.4:装机约2600MW,大渡河重点开发工程,川电东送/西电东送", + "供电范围:四川主网,华中、华东电网" + ], + "expected_keywords": ["2600", "川电东送", "华东", "华中"] + }, + { + "id": "q04", + "label": "survey_design_challenges", + "query": "长河坝水电站勘察设计过程中遇到的难点有哪些?采取了哪些关键技术解决难点?", + "answer_notes": [ + "难点:高地震烈度、深厚覆盖层、世界级砾石土心墙堆石坝", + "关键技术:砾石土直心墙、混凝土防渗墙、覆盖层处理、抗震措施" + ], + "expected_keywords": ["砾石土", "防渗墙", "地震", "覆盖层"] + }, + { + "id": "q05", + "label": "installed_capacity", + "query": "长河坝水电站在可行性研究阶段推荐的水电站装机容量是多少?安装几台机组?单机容量是多少?", + "answer_notes": [ + "报告1综合说明1.4.11/1.4.13:推荐260万kW,4台,单机65万kW混流式" + ], + "expected_keywords": ["260", "4", "65", "万 kW"] + }, + { + "id": "q06", + "label": "reservoir_operation", + "query": "水库和电站运行方式是怎样的?", + "answer_notes": [ + "报告1综合说明1.4.14:日/周调节,1690m-1680m,特枯可降至1650m", + "黄金坪投产后自由调峰;投产前须保证5%生态流量" + ], + "expected_keywords": ["1690", "1680", "调峰", "1650"] + }, + { + "id": "q07", + "label": "project_class_standards", + "query": "长河坝水电站的工程等别和设计标准是怎样的?", + "answer_notes": [ + "报告1综合说明1.6.1:一等大(1)型,1级建筑物", + "洪水:挡泄1000年一遇7650,厂房200年6670;地震:壅水Ⅸ度359gal,非壅水Ⅷ度222gal" + ], + "expected_keywords": ["一等大", "1000", "7650", "Ⅸ"] + }, + { + "id": "q08", + "label": "dam_type_selection", + "query": "长河坝水电站有几种坝型选择?最终选择的是哪种?依据是什么?", + "answer_notes": [ + "报告1综合说明1.6.2:比选3种(直心墙、斜心墙、沥青混凝土心墙)", + "最终选择砾石土直心墙堆石坝;沥青心墙接头复杂、经验少" + ], + "expected_keywords": ["砾石土直心墙", "沥青", "三种", "斜心墙"] + } + ] +} diff --git a/apps/worker/scripts/run_agentic_router_eval.py b/apps/worker/scripts/run_agentic_router_eval.py new file mode 100644 index 00000000..4f4932dc --- /dev/null +++ b/apps/worker/scripts/run_agentic_router_eval.py @@ -0,0 +1,321 @@ +"""Phase 4 router comparison: mapnav vs agent_explore on a fixed query set. + +Reads ``fixtures/changheba_archive_eval_queries.json`` (sourced from +``zh_档案知识库测试样例.docx``) and runs each query through +``run_retrieval_route`` directly — bypassing the Redis result cache, which +does not key on ``RETRIEVAL_AGENTIC_ROUTER`` and would otherwise return the +first router's answer for both runs of the same query. + +Usage: + cd apps/worker + uv run python scripts/run_agentic_router_eval.py + uv run python scripts/run_agentic_router_eval.py --query-id q05 + uv run python scripts/run_agentic_router_eval.py --router agent_explore +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import os +import sys +import time +from contextlib import contextmanager +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Any + +from dotenv import load_dotenv + +_SCRIPT_DIR = Path(__file__).resolve().parent +_REPO_ROOT = _SCRIPT_DIR.parents[1] +_SHARED_PYTHON = _REPO_ROOT / "packages" / "shared-python" +sys.path.insert(0, str(_SHARED_PYTHON)) +sys.path.insert(0, str(_SCRIPT_DIR.parent)) + +load_dotenv(_SCRIPT_DIR.parent / ".env") +os.environ.setdefault("LOCAL_DEBUG", "0") +os.environ.setdefault("LLM_MOCK_ENABLED", "false") + +FIXTURE_PATH = _SCRIPT_DIR / "fixtures" / "changheba_archive_eval_queries.json" +ROUTERS = ("mapnav", "agent_explore") +TOP_K = 10 + + +@contextmanager +def temporary_env(overrides: dict[str, str]): + previous = {key: os.environ.get(key) for key in overrides} + os.environ.update(overrides) + try: + yield + finally: + for key, old in previous.items(): + if old is None: + os.environ.pop(key, None) + else: + os.environ[key] = old + + +def _count_llm_steps(decision_trace: list[dict[str, Any]], router: str) -> int: + if router == "mapnav": + return sum( + 1 + for step in decision_trace + if step.get("phase") in ("plan", "harvest", "plan_control") + ) + return sum( + 1 + for step in decision_trace + if step.get("phase") in ("tool_call", "finish") + or (step.get("decision") or {}).get("action") not in ("", "no_tool_call", None) + ) + + +def _keyword_hits(evidence_text: str, keywords: list[str]) -> tuple[int, list[str]]: + text = evidence_text or "" + hits = [kw for kw in keywords if kw and kw in text] + return len(hits), hits + + +@dataclass +class RunMetrics: + query_id: str + router: str + total_ms: int + router_used: str + stop_reason: str + refs: int + results: int + evidence_chars: int + llm_steps: int + keyword_hits: int + keyword_total: int + matched_keywords: list[str] + error: str | None = None + + +async def _run_one( + *, + user_id: str, + namespace: str, + query: str, + query_id: str, + router: str, + expected_keywords: list[str], +) -> RunMetrics: + from dataclasses import replace + + from shared.core.database import get_db_context + from shared.services.retrieval.execution.plan import project_public_retrieval_response + from shared.services.retrieval.execution.query_request import RetrievalQuery + from shared.services.retrieval.execution.revision_pins import ( + capture_revision_pins, + is_revision_generation_stable, + ) + from shared.services.retrieval.execution.routes import run_retrieval_route + from shared.services.retrieval.settings import INTERNAL_RECALL_K_MULTIPLIER + + started = time.monotonic() + error: str | None = None + response: dict[str, Any] = {} + try: + with temporary_env({"RETRIEVAL_AGENTIC_ROUTER": router}): + async with get_db_context() as db: + request = RetrievalQuery.from_parameters( + db=db, + user_id=user_id, + namespace=namespace, + query=query, + top_k=TOP_K, + exclude_document_ids=[], + exclude_sections=[], + use_agentic=True, + ) + revision_pins = await capture_revision_pins( + db, user_id=user_id, namespace=namespace + ) + if not await is_revision_generation_stable( + db, + user_id=user_id, + namespace=namespace, + pins=revision_pins, + ): + revision_pins = await capture_revision_pins( + db, user_id=user_id, namespace=namespace + ) + effective_recall_k = ( + request.internal_recall_k + if request.internal_recall_k is not None + else TOP_K * INTERNAL_RECALL_K_MULTIPLIER + ) + context = replace( + request.build_route_context(), + revision_pins=revision_pins, + effective_recall_k=effective_recall_k, + ) + outcome = await run_retrieval_route(context) + response = await project_public_retrieval_response(outcome.response) + except Exception as exc: # noqa: BLE001 - eval runner must continue + error = f"{type(exc).__name__}: {exc}" + + elapsed_ms = int((time.monotonic() - started) * 1000) + evidence = str(response.get("evidence_text") or "") + decision_trace = response.get("decision_trace") or [] + hits, matched = _keyword_hits(evidence, expected_keywords) + return RunMetrics( + query_id=query_id, + router=router, + total_ms=elapsed_ms, + router_used=str(response.get("router_used") or router), + stop_reason=str(response.get("stop_reason") or ""), + refs=len(response.get("referenced_chunks") or []), + results=len(response.get("results") or []), + evidence_chars=len(evidence), + llm_steps=_count_llm_steps(decision_trace, router), + keyword_hits=hits, + keyword_total=len(expected_keywords), + matched_keywords=matched, + error=error, + ) + + +def _render_markdown( + fixture: dict[str, Any], + runs: list[RunMetrics], + output_dir: Path, +) -> str: + by_key = {(r.query_id, r.router): r for r in runs} + lines = [ + "# Agentic Router Eval (Phase 4)\n", + f"Fixture: `{FIXTURE_PATH.name}`\n", + f"Corpus: `{fixture['corpus']['namespace']}` / " + f"{len(fixture['corpus']['documents'])} documents\n", + f"Generated: {datetime.now().isoformat(timespec='seconds')}\n", + f"Output dir: `{output_dir}`\n", + "\n## Summary\n", + "| Q | Query (short) | Router | ms | LLM steps | refs | kw hit | stop |\n", + "|---|---|---|---:|---:|---:|---:|---|\n", + ] + for item in fixture["queries"]: + qid = item["id"] + short = item["query"][:28] + ("…" if len(item["query"]) > 28 else "") + for router in ROUTERS: + r = by_key.get((qid, router)) + if r is None: + continue + kw = f"{r.keyword_hits}/{r.keyword_total}" + stop = (r.stop_reason or r.error or "")[:24] + lines.append( + f"| {qid} | {short} | {router} | {r.total_ms} | " + f"{r.llm_steps} | {r.refs} | {kw} | {stop} |\n" + ) + + mapnav_ms = [r.total_ms for r in runs if r.router == "mapnav" and not r.error] + agent_ms = [r.total_ms for r in runs if r.router == "agent_explore" and not r.error] + + def _p50(values: list[int]) -> int | None: + if not values: + return None + ordered = sorted(values) + return ordered[len(ordered) // 2] + + lines.extend( + [ + "\n## Aggregate latency (successful runs only)\n", + f"- mapnav p50: {_p50(mapnav_ms)} ms ({len(mapnav_ms)} runs)\n", + f"- agent_explore p50: {_p50(agent_ms)} ms ({len(agent_ms)} runs)\n", + "\n## Notes\n", + "- Runs bypass Redis retrieval cache (direct ``run_retrieval_route``).\n", + "- ``kw hit`` = expected keywords found in ``evidence_text`` " + "(proxy for recall quality, not a full answer judge).\n", + "- Phase 0 ad-hoc queries are separate; this set is the 8 questions " + "from ``zh_档案知识库测试样例.docx``.\n", + ] + ) + return "".join(lines) + + +async def main() -> None: + parser = argparse.ArgumentParser(description="Compare mapnav vs agent_explore") + parser.add_argument( + "--fixture", + default=str(FIXTURE_PATH), + help="Path to eval queries JSON", + ) + parser.add_argument( + "--output-dir", + "-o", + default=None, + help="Output directory (default: /tmp/agentic_router_eval/)", + ) + parser.add_argument( + "--query-id", + action="append", + default=[], + help="Run only these query ids (repeatable, e.g. --query-id q01)", + ) + parser.add_argument( + "--router", + choices=[*ROUTERS, "both"], + default="both", + help="Which router(s) to run", + ) + args = parser.parse_args() + + fixture = json.loads(Path(args.fixture).read_text(encoding="utf-8")) + queries = fixture["queries"] + if args.query_id: + allowed = set(args.query_id) + queries = [q for q in queries if q["id"] in allowed] + + routers = list(ROUTERS) if args.router == "both" else [args.router] + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + output_dir = Path(args.output_dir or f"/tmp/agentic_router_eval/{timestamp}") + output_dir.mkdir(parents=True, exist_ok=True) + + user_id = fixture["corpus"]["user_id"] + namespace = fixture["corpus"]["namespace"] + + all_runs: list[RunMetrics] = [] + for item in queries: + for router in routers: + label = f"{item['id']}_{router}" + print(f"▶ {label}: {item['query'][:60]}…", flush=True) + metrics = await _run_one( + user_id=user_id, + namespace=namespace, + query=item["query"], + query_id=item["id"], + router=router, + expected_keywords=item.get("expected_keywords") or [], + ) + all_runs.append(metrics) + print( + f" done {metrics.total_ms}ms refs={metrics.refs} " + f"kw={metrics.keyword_hits}/{metrics.keyword_total} " + f"stop={metrics.stop_reason or metrics.error}", + flush=True, + ) + + report = { + "fixture": args.fixture, + "generated_at": datetime.now().isoformat(timespec="seconds"), + "routers": routers, + "runs": [r.__dict__ for r in all_runs], + } + json_path = output_dir / "eval_report.json" + json_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") + + md = _render_markdown(fixture, all_runs, output_dir) + md_path = output_dir / "eval_report.md" + md_path.write_text(md, encoding="utf-8") + + print(f"\nWrote {json_path}") + print(f"Wrote {md_path}") + print("\n" + md) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/packages/shared-python/shared/services/retrieval/agent_explore/config.py b/packages/shared-python/shared/services/retrieval/agent_explore/config.py index a4d0cec1..3daf602a 100644 --- a/packages/shared-python/shared/services/retrieval/agent_explore/config.py +++ b/packages/shared-python/shared/services/retrieval/agent_explore/config.py @@ -95,4 +95,11 @@ `{FINISH_TOOL_NAME}` with your best-effort `refs` (or an empty list plus a `notes` explanation of why nothing was found) rather than continuing to call other tools. + +`refs` is REQUIRED and must not be omitted or left empty if you called +`corpus.read` (or `corpus.assets`) even once during this exploration: copy +the `document_id` and `chunk_id`/`section_path` of every section/chunk you +read that supports your answer into `refs` before calling +`{FINISH_TOOL_NAME}`. Calling `{FINISH_TOOL_NAME}` with no `refs` after +having already read relevant content discards that evidence. """ diff --git a/packages/shared-python/shared/services/retrieval/agent_explore/episode.py b/packages/shared-python/shared/services/retrieval/agent_explore/episode.py index fe1ae0de..4b38acea 100644 --- a/packages/shared-python/shared/services/retrieval/agent_explore/episode.py +++ b/packages/shared-python/shared/services/retrieval/agent_explore/episode.py @@ -16,6 +16,30 @@ within a turn is not implemented here. No import from ``nav/`` or ``nav_config.py`` — see ``config.py``. + +Two Phase 4 fixes (audited live against the eval fixture in +``apps/worker/scripts/fixtures/changheba_archive_eval_queries.json``): + +1. **Stale tool-message collapsing** (``_TOOL_MESSAGE_FRESH_TURNS``, + ``_collapse_stale_tool_messages``): ``messages`` only ever appended, so a + single ``corpus.outline``/``corpus.node_filter`` call (each capped at + ``ToolBudget.max_chars`` — currently ``EVIDENCE_TEXT_CHAR_BUDGET=12_000``, + see ``registry.py``) was resent in full on every later turn. Verified + live: two independent queries (q04, q06 in the eval fixture) hit + ``RETRIEVAL_NAV_TOKEN_LIMIT`` (100k default) within 7-8 LLM turns from + this resend alone, not from query difficulty — per-turn token cost grew + monotonically (q04: 4.4k -> 4.8k -> 19.8k -> 21.5k -> 24.2k -> 29.6k). +2. **Trajectory refs fallback** (``_dedup_refs`` + the fallback at the end of + ``run_agent_explore_episode``): verified live that ``finish`` can be + called with no ``refs`` key at all (raw ``function.arguments`` was + literally ``'{}'``) even after the model had already read clearly + relevant sections via ``corpus.read`` — the ``FINISH_TOOL_SCHEMA``'s + ``"required": ["refs"]`` is a schema hint, not a provider-enforced + constraint. When ``finish``'s own ``refs`` end up empty (whether from + this, from ``no_tool_call``, or from a forced-finish the provider ignored + — all three exit paths), the episode now falls back to the refs already + returned by every ``corpus.read``/``corpus.assets`` call in the + trajectory, deduped, instead of citing nothing. """ from __future__ import annotations @@ -45,6 +69,21 @@ ) from shared.services.retrieval.agent_tools import tools as _agent_tools_registered # noqa: F401 +# Tools whose ToolResult.refs point at evidence the agent has actually looked +# at (full body content), as opposed to candidate/listing refs from +# list_documents/outline/node_filter/recall/grep — those describe *where +# things are*, not *what was read*, and would inject unread noise into the +# trajectory-refs fallback below if included. +_EVIDENCE_TOOL_NAMES = frozenset({"corpus.read", "corpus.assets"}) + +# A tool-role message is kept in full for the turn it was produced plus this +# many additional turns, then collapsed to a placeholder — see module +# docstring point 1. Not tuned against a real recall-vs-token tradeoff yet; +# 2 was chosen so a result stays fully visible for one full turn after the +# one it was produced in (enough for the model to act on it immediately), +# revisit with more Phase 4 data. +_TOOL_MESSAGE_FRESH_TURNS = 2 + def _resolve_client_and_model() -> tuple[Any, str]: """Mirrors ``nav_llm_backend.nav_chat_sync_backend``'s resolve pattern, @@ -128,7 +167,13 @@ def _tool_message_content(result: ToolResult, *, max_chars: int) -> str: Uses ``ToolBudget.max_chars`` (``EVIDENCE_TEXT_CHAR_BUDGET``, aligned with map-nav evidence packing) so tools like ``read`` can return unbounded body - text while the harness still bounds what the model sees per turn. + text while the harness still bounds what the model sees per turn. This + cap applies uniformly to every tool's rendered text (not just ``read``'s + body content) — a tool that returns a "complete, non-truncated" *matched + set* by contract (``outline``, ``node_filter`` — see ``registry.py``) + still has its *rendered text* capped here the same as any other tool; + that promise is about payload/refs cardinality, not about how much of it + is shown to the LLM per turn. """ if result.error: return f"error: {result.error}" @@ -138,11 +183,54 @@ def _tool_message_content(result: ToolResult, *, max_chars: int) -> str: omitted = len(text) - max_chars return ( text[:max_chars] - + f"\n...[truncated, {omitted} more chars — call corpus.read again " - "with a narrower/more specific ref if you need the rest]" + + f"\n...[truncated, {omitted} more chars — narrow the scope " + "(e.g. depth/path_prefix for outline, a tighter predicate for " + "node_filter, or a more specific ref for read) and call again if " + "you need the rest]" ) +def _collapse_stale_tool_messages( + messages: list[dict[str, Any]], + tool_message_log: list[dict[str, Any]], + *, + current_turn: int, + fresh_turns: int, +) -> None: + """Replace tool messages older than ``fresh_turns`` with a placeholder. + + ``messages`` only ever grows within one episode (see module docstring + point 1); this is what keeps that growth bounded instead of resending + every past tool result on every later turn. + """ + for entry in tool_message_log: + if entry["collapsed"]: + continue + if current_turn - entry["turn_index"] < fresh_turns: + continue + messages[entry["message_index"]]["content"] = ( + f"[collapsed: {entry['tool_name']} result from turn " + f"{entry['turn_index']} was {entry['original_chars']} chars — " + "call the tool again if you need it back in view]" + ) + entry["collapsed"] = True + + +def _dedup_refs(refs: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Dedup by ``(document_id, chunk_id)``, keeping first-seen order.""" + seen: set[tuple[str, str]] = set() + deduped: list[dict[str, Any]] = [] + for ref in refs: + document_id = str(ref.get("document_id") or "").strip() + chunk_id = str(ref.get("chunk_id") or "").strip() + key = (document_id, chunk_id) + if not document_id or not chunk_id or key in seen: + continue + seen.add(key) + deduped.append(ref) + return deduped + + async def run_agent_explore_episode( *, db: AsyncSession, @@ -166,8 +254,24 @@ async def run_agent_explore_episode( stop_reason = "finished" result_refs: list[dict[str, Any]] = [] result_notes = "" + # Refs from every corpus.read/corpus.assets call this episode, in call + # order — the fallback source when finish's own refs end up empty (see + # module docstring point 2). + trajectory_refs: list[dict[str, Any]] = [] + # One entry per appended tool-role message: {message_index, turn_index, + # tool_name, original_chars, collapsed} — see _collapse_stale_tool_messages. + tool_message_log: list[dict[str, Any]] = [] + turn_index = 0 while True: + turn_index += 1 + _collapse_stale_tool_messages( + messages, + tool_message_log, + current_turn=turn_index, + fresh_turns=_TOOL_MESSAGE_FRESH_TURNS, + ) + forced_reason = budget.exhausted() tool_choice: Any = "auto" if forced_reason is not None: @@ -284,6 +388,17 @@ async def run_agent_explore_episode( messages.append( {"role": "tool", "tool_call_id": tc.id, "content": content} ) + tool_message_log.append( + { + "message_index": len(messages) - 1, + "turn_index": turn_index, + "tool_name": canonical_name, + "original_chars": len(content), + "collapsed": False, + } + ) + if canonical_name in _EVIDENCE_TOOL_NAMES and not tool_result.error: + trajectory_refs.extend(tool_result.refs) # Turn-level token usage is attributed to the first tool step in # this turn (the completion that decided all calls in it); the # rest are 0 to avoid double-counting the same LLM usage. @@ -301,6 +416,15 @@ async def run_agent_explore_episode( ) first_tool_tokens_recorded = True + if not result_refs: + fallback_refs = _dedup_refs(trajectory_refs) + if fallback_refs: + result_refs = fallback_refs + result_notes = (result_notes + " " if result_notes else "") + ( + "[refs auto-filled from corpus.read/corpus.assets trajectory; " + "finish did not cite any]" + ) + return EpisodeResult( refs=result_refs, notes=result_notes, From 605da2bda2b9be84628276ca28abaa130aff1278 Mon Sep 17 00:00:00 2001 From: chengke <404835780@qq.com> Date: Tue, 8 Sep 2026 19:16:40 +0800 Subject: [PATCH 4/6] feat(retrieval): integrate Cursor SDK for agent exploration Added support for the Cursor SDK as an alternative harness for agent exploration. Introduced a new optional dependency, `cursor-sdk`, and updated the agent exploration logic to allow switching between OpenAI-compatible and Cursor SDK providers. Enhanced the `debug_agent_explore_episode` script to accept a `--harness` argument for selecting the desired harness. Updated relevant documentation and configuration to reflect these changes, ensuring clarity on the new harness integration. --- apps/worker/pyproject.toml | 9 + .../scripts/debug_agent_explore_episode.py | 28 +- apps/worker/scripts/debug_finish_raw_args.py | 21 +- .../retrieval/agent_explore/__init__.py | 10 +- .../retrieval/agent_explore/config.py | 9 + .../retrieval/agent_explore/dispatch.py | 56 +++ .../retrieval/agent_explore/episode.py | 435 ------------------ .../agent_explore/harness/__init__.py | 14 + .../retrieval/agent_explore/harness/base.py | 42 ++ .../agent_explore/harness/cursor_harness.py | 298 ++++++++++++ .../agent_explore/harness/openai_harness.py | 383 +++++++++++++++ .../agent_explore/harness/resolve.py | 41 ++ .../retrieval/agent_explore/shared.py | 106 +++++ .../retrieval/agent_tools/registry.py | 2 +- .../services/retrieval/execution/routes.py | 16 +- .../tests/test_agent_explore_harness.py | 195 ++++++++ uv.lock | 23 + 17 files changed, 1222 insertions(+), 466 deletions(-) create mode 100644 packages/shared-python/shared/services/retrieval/agent_explore/dispatch.py delete mode 100644 packages/shared-python/shared/services/retrieval/agent_explore/episode.py create mode 100644 packages/shared-python/shared/services/retrieval/agent_explore/harness/__init__.py create mode 100644 packages/shared-python/shared/services/retrieval/agent_explore/harness/base.py create mode 100644 packages/shared-python/shared/services/retrieval/agent_explore/harness/cursor_harness.py create mode 100644 packages/shared-python/shared/services/retrieval/agent_explore/harness/openai_harness.py create mode 100644 packages/shared-python/shared/services/retrieval/agent_explore/harness/resolve.py create mode 100644 packages/shared-python/shared/services/retrieval/agent_explore/shared.py create mode 100644 packages/shared-python/shared/tests/test_agent_explore_harness.py diff --git a/apps/worker/pyproject.toml b/apps/worker/pyproject.toml index 021eb6f9..5593b776 100644 --- a/apps/worker/pyproject.toml +++ b/apps/worker/pyproject.toml @@ -30,6 +30,15 @@ dependencies = [ "rapidocr-onnxruntime>=1.4.4", ] +# AGENT_EXPLORE_HARNESS=cursor_sdk (shared/services/retrieval/agent_explore/ +# harness/cursor_harness.py). Not in the base dependency set: this harness +# is an alternative to the default OpenAI-compatible one (openai_harness.py), +# not required for it. Install with `uv sync --extra cursor-harness`. +[project.optional-dependencies] +cursor-harness = [ + "cursor-sdk>=1.0.31", +] + [dependency-groups] dev = [ "fakeredis[lua]>=2.31.0", diff --git a/apps/worker/scripts/debug_agent_explore_episode.py b/apps/worker/scripts/debug_agent_explore_episode.py index a74e9928..c85c531f 100644 --- a/apps/worker/scripts/debug_agent_explore_episode.py +++ b/apps/worker/scripts/debug_agent_explore_episode.py @@ -2,9 +2,9 @@ Prints, for each LLM turn / tool call: tool name, args, elapsed ms, tokens_used_delta (turn-level, attributed to the first tool step — see -``episode.py``), tokens_used_total (cumulative), observation length (chars -sent back into the LLM's context), and error. Also prints the final -``EpisodeResult`` (refs/notes/stop_reason). +``harness/openai_harness.py``), tokens_used_total (cumulative), observation +length (chars sent back into the LLM's context), and error. Also prints the +final ``EpisodeResult`` (refs/notes/stop_reason). Read-only diagnostic; does not modify any behavior. @@ -12,6 +12,7 @@ cd apps/worker uv run python scripts/debug_agent_explore_episode.py --query-id q04 uv run python scripts/debug_agent_explore_episode.py --query "..." --token-limit 200000 + uv run python scripts/debug_agent_explore_episode.py --query-id q04 --harness cursor_sdk """ from __future__ import annotations @@ -44,6 +45,7 @@ async def main() -> None: parser.add_argument("--user-id", default="debug_local_user") parser.add_argument("--namespace", default="default") parser.add_argument("--token-limit", type=int, default=None) + parser.add_argument("--harness", default=None, choices=["openai", "cursor_sdk"]) args = parser.parse_args() query = args.query @@ -56,19 +58,19 @@ async def main() -> None: from shared.core.database import get_db_context from shared.services.retrieval.agent_explore.budget import EpisodeBudget - from shared.services.retrieval.agent_explore.episode import run_agent_explore_episode + from shared.services.retrieval.agent_explore.harness import resolve_harness - budget = EpisodeBudget(token_limit=args.token_limit) if args.token_limit else None + budget = EpisodeBudget(token_limit=args.token_limit) if args.token_limit else EpisodeBudget() + harness = resolve_harness(args.harness) print(f"query: {query!r}") - async with get_db_context() as db: - episode = await run_agent_explore_episode( - db=db, - user_id=args.user_id, - namespace=args.namespace, - query=query, - budget=budget, - ) + episode = await harness.run_episode( + db_factory=get_db_context, + user_id=args.user_id, + namespace=args.namespace, + query=query, + budget=budget, + ) print(f"\nstop_reason={episode.stop_reason} tokens_used={episode.tokens_used} " f"model={episode.model_name}") diff --git a/apps/worker/scripts/debug_finish_raw_args.py b/apps/worker/scripts/debug_finish_raw_args.py index 9a6099fe..1d6acfa6 100644 --- a/apps/worker/scripts/debug_finish_raw_args.py +++ b/apps/worker/scripts/debug_finish_raw_args.py @@ -36,26 +36,27 @@ async def main() -> None: match = next(q for q in fixture["queries"] if q["id"] == args.query_id) query = match["query"] - from shared.services.retrieval.agent_explore import episode as episode_mod + from shared.services.retrieval.agent_explore.harness import openai_harness - original_safe_json_loads = episode_mod._safe_json_loads + original_safe_json_loads = openai_harness._safe_json_loads def _spy(raw): print(f"[SPY] raw arguments received: {raw!r}") return original_safe_json_loads(raw) - episode_mod._safe_json_loads = _spy + openai_harness._safe_json_loads = _spy from shared.core.database import get_db_context + from shared.services.retrieval.agent_explore.budget import EpisodeBudget print(f"query: {query!r}") - async with get_db_context() as db: - result = await episode_mod.run_agent_explore_episode( - db=db, - user_id="debug_local_user", - namespace="default", - query=query, - ) + result = await openai_harness.OpenAIHarness().run_episode( + db_factory=get_db_context, + user_id="debug_local_user", + namespace="default", + query=query, + budget=EpisodeBudget(), + ) print(f"\nfinal refs: {result.refs}") print(f"final notes: {result.notes!r}") print(f"stop_reason: {result.stop_reason}") diff --git a/packages/shared-python/shared/services/retrieval/agent_explore/__init__.py b/packages/shared-python/shared/services/retrieval/agent_explore/__init__.py index f08306a7..8988dbd9 100644 --- a/packages/shared-python/shared/services/retrieval/agent_explore/__init__.py +++ b/packages/shared-python/shared/services/retrieval/agent_explore/__init__.py @@ -7,6 +7,11 @@ (``execution/routes.py``); ``mapnav`` remains the default until this route passes its Phase 4 evaluation gate. +Which provider runs that loop (OpenAI-compatible/DeepSeek, Cursor SDK) is a +second, independent switch — ``AGENT_EXPLORE_HARNESS`` — resolved via +``resolve_harness()``. See ``harness/`` (Phase 3.5) for the pluggable +``Harness`` interface and its two implementations. + No runtime dependency on ``shared.services.retrieval.nav`` — see ``config.py``'s module docstring. """ @@ -14,12 +19,13 @@ from __future__ import annotations from shared.services.retrieval.agent_explore.budget import EpisodeBudget -from shared.services.retrieval.agent_explore.episode import run_agent_explore_episode +from shared.services.retrieval.agent_explore.harness import Harness, resolve_harness from shared.services.retrieval.agent_explore.types import AgentStep, EpisodeResult __all__ = [ "AgentStep", "EpisodeBudget", "EpisodeResult", - "run_agent_explore_episode", + "Harness", + "resolve_harness", ] diff --git a/packages/shared-python/shared/services/retrieval/agent_explore/config.py b/packages/shared-python/shared/services/retrieval/agent_explore/config.py index 3daf602a..52d5cd87 100644 --- a/packages/shared-python/shared/services/retrieval/agent_explore/config.py +++ b/packages/shared-python/shared/services/retrieval/agent_explore/config.py @@ -22,6 +22,15 @@ AGENT_EXPLORE_MODEL = "deepseek-v4-flash" +# Model for AGENT_EXPLORE_HARNESS=cursor_sdk (harness/cursor_harness.py) — +# a separate constant from AGENT_EXPLORE_MODEL because that harness's +# provider (Cursor SDK) is a disjoint model catalog from the OpenAI-compatible +# client's, not an interchangeable choice. "composer-2.5" is what the PoC +# (apps/worker/scripts/debug_cursor_agent_explore.py) verified live and +# noticeably outperformed deepseek-v4-flash on the two hardest eval-fixture +# queries (q04/q06) — see the Phase 3.5 landing record. +AGENT_EXPLORE_CURSOR_MODEL = "composer-2.5" + # One LLM turn = one round-trip that may contain several parallel tool calls # (see episode.py). Kept low relative to map-nav's per-node dispatch depth # (≤5) because each turn here can already resolve several tools at once. diff --git a/packages/shared-python/shared/services/retrieval/agent_explore/dispatch.py b/packages/shared-python/shared/services/retrieval/agent_explore/dispatch.py new file mode 100644 index 00000000..22621db5 --- /dev/null +++ b/packages/shared-python/shared/services/retrieval/agent_explore/dispatch.py @@ -0,0 +1,56 @@ +"""Concurrency-safe per-call tool dispatch shared by every ``agent_explore`` harness. + +Mirrors ``apps/api/app/mcp/dynamic_tools.py``'s ``_dispatch_tool``: SQLAlchemy's +``AsyncSession`` is not safe for concurrent use from multiple coroutines, and a +harness cannot always control whether the orchestrating model issues tool +calls in parallel (verified against the Cursor SDK harness — see +``harness/cursor_harness.py``'s module docstring). Opening a short-lived +session per tool call, instead of sharing one ``AsyncSession`` across the +whole episode, makes dispatch safe regardless of how a harness calls it — +strictly sequential (``harness/openai_harness.py``) or genuinely concurrent +(``harness/cursor_harness.py``). + +This is a behavior change for the OpenAI harness too (previously one shared +session for the whole episode), but a safe one: it already dispatched +sequentially, so a fresh session per call only adds one extra connection +checkout per tool call, never a correctness risk. +""" + +from __future__ import annotations + +from collections.abc import Callable +from contextlib import AbstractAsyncContextManager +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession + +from shared.services.retrieval.agent_tools import REGISTRY, ToolBudget, ToolContext, ToolResult + +DbFactory = Callable[[], AbstractAsyncContextManager[AsyncSession]] + + +async def dispatch_tool_call( + name: str, + args: dict[str, Any], + *, + db_factory: DbFactory, + user_id: str, + namespace: str, + budget: ToolBudget | None = None, +) -> ToolResult: + """Run one ``REGISTRY`` tool call against a fresh, call-scoped DB session. + + ``budget`` is forwarded into the ``ToolContext`` built for this one call + (defaults to ``ToolBudget()``, matching prior behavior); callers that need + the same budget value for their own text-capping (``shared.tool_message_content``) + should hold onto the ``ToolBudget`` they pass here rather than reach back + into the (call-scoped, already-closed) ``ToolContext``. + """ + try: + async with db_factory() as db: + tool_ctx = ToolContext( + db=db, user_id=user_id, namespace=namespace, budget=budget or ToolBudget() + ) + return await REGISTRY.dispatch(name, tool_ctx, args) + except Exception as exc: # noqa: BLE001 - one broken tool must not kill the episode + return ToolResult(text="", error=f"{type(exc).__name__}: {exc}") diff --git a/packages/shared-python/shared/services/retrieval/agent_explore/episode.py b/packages/shared-python/shared/services/retrieval/agent_explore/episode.py deleted file mode 100644 index 4b38acea..00000000 --- a/packages/shared-python/shared/services/retrieval/agent_explore/episode.py +++ /dev/null @@ -1,435 +0,0 @@ -"""In-process tool-calling episode over the ``agent_tools`` corpus registry. - -Uses ``OpenAICompatibleClientSync.chat_completion_raw_with_usage`` — the RAW -response, not ``chat_completion_with_usage`` — because the latter only -returns ``.content`` and silently drops ``message.tool_calls``. Verified live -(2026-09-08) against ``deepseek-v4-flash`` via this codebase's client: -single tool call, parallel tool calls in one turn, tool-result feedback + -final synthesis, and forced ``tool_choice`` (used for the budget-exhaustion -cutoff below) all work. - -Tool calls within one turn are dispatched sequentially against the single -shared ``ToolContext.db`` (``AsyncSession``), not via ``asyncio.gather``: -SQLAlchemy's ``AsyncSession`` is not safe for concurrent use from multiple -coroutines. Batching several tool calls into one LLM turn already removes -the LLM round-trip per tool (the dominant cost); true DB-level concurrency -within a turn is not implemented here. - -No import from ``nav/`` or ``nav_config.py`` — see ``config.py``. - -Two Phase 4 fixes (audited live against the eval fixture in -``apps/worker/scripts/fixtures/changheba_archive_eval_queries.json``): - -1. **Stale tool-message collapsing** (``_TOOL_MESSAGE_FRESH_TURNS``, - ``_collapse_stale_tool_messages``): ``messages`` only ever appended, so a - single ``corpus.outline``/``corpus.node_filter`` call (each capped at - ``ToolBudget.max_chars`` — currently ``EVIDENCE_TEXT_CHAR_BUDGET=12_000``, - see ``registry.py``) was resent in full on every later turn. Verified - live: two independent queries (q04, q06 in the eval fixture) hit - ``RETRIEVAL_NAV_TOKEN_LIMIT`` (100k default) within 7-8 LLM turns from - this resend alone, not from query difficulty — per-turn token cost grew - monotonically (q04: 4.4k -> 4.8k -> 19.8k -> 21.5k -> 24.2k -> 29.6k). -2. **Trajectory refs fallback** (``_dedup_refs`` + the fallback at the end of - ``run_agent_explore_episode``): verified live that ``finish`` can be - called with no ``refs`` key at all (raw ``function.arguments`` was - literally ``'{}'``) even after the model had already read clearly - relevant sections via ``corpus.read`` — the ``FINISH_TOOL_SCHEMA``'s - ``"required": ["refs"]`` is a schema hint, not a provider-enforced - constraint. When ``finish``'s own ``refs`` end up empty (whether from - this, from ``no_tool_call``, or from a forced-finish the provider ignored - — all three exit paths), the episode now falls back to the refs already - returned by every ``corpus.read``/``corpus.assets`` call in the - trajectory, deduped, instead of citing nothing. -""" - -from __future__ import annotations - -import asyncio -import json -import time -from typing import Any - -from sqlalchemy.ext.asyncio import AsyncSession - -from shared.services.retrieval.agent_explore.budget import EpisodeBudget -from shared.services.retrieval.agent_explore.config import ( - AGENT_EXPLORE_MAX_COMPLETION_TOKENS, - AGENT_EXPLORE_MODEL, - FINISH_TOOL_DESCRIPTION, - FINISH_TOOL_NAME, - FINISH_TOOL_SCHEMA, - LOOP_CONTRACT_SUFFIX, -) -from shared.services.retrieval.agent_explore.types import AgentStep, EpisodeResult -from shared.services.retrieval.agent_tools import ( - REGISTRY, - ToolContext, - ToolResult, - load_corpus_schema_text, -) -from shared.services.retrieval.agent_tools import tools as _agent_tools_registered # noqa: F401 - -# Tools whose ToolResult.refs point at evidence the agent has actually looked -# at (full body content), as opposed to candidate/listing refs from -# list_documents/outline/node_filter/recall/grep — those describe *where -# things are*, not *what was read*, and would inject unread noise into the -# trajectory-refs fallback below if included. -_EVIDENCE_TOOL_NAMES = frozenset({"corpus.read", "corpus.assets"}) - -# A tool-role message is kept in full for the turn it was produced plus this -# many additional turns, then collapsed to a placeholder — see module -# docstring point 1. Not tuned against a real recall-vs-token tradeoff yet; -# 2 was chosen so a result stays fully visible for one full turn after the -# one it was produced in (enough for the model to act on it immediately), -# revisit with more Phase 4 data. -_TOOL_MESSAGE_FRESH_TURNS = 2 - - -def _resolve_client_and_model() -> tuple[Any, str]: - """Mirrors ``nav_llm_backend.nav_chat_sync_backend``'s resolve pattern, - pinned to ``AGENT_EXPLORE_MODEL`` instead of ``nav_config.MAPNAV_MODEL``. - """ - from shared.services.ai.llm_overrides import resolve_text - from shared.services.ai.openai_compatible_client_sync import get_openai_client - - requested = AGENT_EXPLORE_MODEL - effective_model, api_key, api_url = resolve_text(requested) - model = effective_model or requested - client = get_openai_client(model=model, api_key=api_key, api_url=api_url) - return client, model - - -def _openai_safe_name(name: str) -> str: - """DeepSeek's (OpenAI-compatible) function-calling API rejects ``.`` in - ``tools[].function.name`` (must match ``^[a-zA-Z0-9_-]+$``, verified - live), but every ``agent_tools`` name is dotted (``corpus.read``) and - that's also what MCP clients see (no such restriction there) — so the - dotted name stays canonical in ``REGISTRY``/MCP, and this harness-local - underscore form exists only for the wire format to this one provider. - """ - return name.replace(".", "_") - - -def _build_openai_tools() -> tuple[list[dict[str, Any]], dict[str, str]]: - """Return ``(tools, name_map)`` where ``name_map`` maps the OpenAI-safe - name back to the canonical ``REGISTRY`` name (``finish`` maps to itself). - """ - name_map: dict[str, str] = {FINISH_TOOL_NAME: FINISH_TOOL_NAME} - tools: list[dict[str, Any]] = [] - for spec in REGISTRY.all(): - safe_name = _openai_safe_name(spec.name) - name_map[safe_name] = spec.name - tools.append( - { - "type": "function", - "function": { - "name": safe_name, - "description": spec.description, - "parameters": spec.json_schema, - }, - } - ) - tools.append( - { - "type": "function", - "function": { - "name": FINISH_TOOL_NAME, - "description": FINISH_TOOL_DESCRIPTION, - "parameters": FINISH_TOOL_SCHEMA, - }, - } - ) - return tools, name_map - - -def _safe_json_loads(raw: str | None) -> dict[str, Any]: - if not raw: - return {} - try: - parsed = json.loads(raw) - except (TypeError, ValueError): - return {} - return parsed if isinstance(parsed, dict) else {} - - -def _normalize_finish_refs(raw: Any) -> list[dict[str, Any]]: - if not isinstance(raw, list): - return [] - normalized: list[dict[str, Any]] = [] - for item in raw: - if isinstance(item, dict) and str(item.get("document_id") or "").strip(): - normalized.append(item) - return normalized - - -def _tool_message_content(result: ToolResult, *, max_chars: int) -> str: - """Cap a tool's rendered text before it enters LLM context. - - Uses ``ToolBudget.max_chars`` (``EVIDENCE_TEXT_CHAR_BUDGET``, aligned with - map-nav evidence packing) so tools like ``read`` can return unbounded body - text while the harness still bounds what the model sees per turn. This - cap applies uniformly to every tool's rendered text (not just ``read``'s - body content) — a tool that returns a "complete, non-truncated" *matched - set* by contract (``outline``, ``node_filter`` — see ``registry.py``) - still has its *rendered text* capped here the same as any other tool; - that promise is about payload/refs cardinality, not about how much of it - is shown to the LLM per turn. - """ - if result.error: - return f"error: {result.error}" - text = result.text or "(empty result)" - if len(text) <= max_chars: - return text - omitted = len(text) - max_chars - return ( - text[:max_chars] - + f"\n...[truncated, {omitted} more chars — narrow the scope " - "(e.g. depth/path_prefix for outline, a tighter predicate for " - "node_filter, or a more specific ref for read) and call again if " - "you need the rest]" - ) - - -def _collapse_stale_tool_messages( - messages: list[dict[str, Any]], - tool_message_log: list[dict[str, Any]], - *, - current_turn: int, - fresh_turns: int, -) -> None: - """Replace tool messages older than ``fresh_turns`` with a placeholder. - - ``messages`` only ever grows within one episode (see module docstring - point 1); this is what keeps that growth bounded instead of resending - every past tool result on every later turn. - """ - for entry in tool_message_log: - if entry["collapsed"]: - continue - if current_turn - entry["turn_index"] < fresh_turns: - continue - messages[entry["message_index"]]["content"] = ( - f"[collapsed: {entry['tool_name']} result from turn " - f"{entry['turn_index']} was {entry['original_chars']} chars — " - "call the tool again if you need it back in view]" - ) - entry["collapsed"] = True - - -def _dedup_refs(refs: list[dict[str, Any]]) -> list[dict[str, Any]]: - """Dedup by ``(document_id, chunk_id)``, keeping first-seen order.""" - seen: set[tuple[str, str]] = set() - deduped: list[dict[str, Any]] = [] - for ref in refs: - document_id = str(ref.get("document_id") or "").strip() - chunk_id = str(ref.get("chunk_id") or "").strip() - key = (document_id, chunk_id) - if not document_id or not chunk_id or key in seen: - continue - seen.add(key) - deduped.append(ref) - return deduped - - -async def run_agent_explore_episode( - *, - db: AsyncSession, - user_id: str, - namespace: str, - query: str, - budget: EpisodeBudget | None = None, -) -> EpisodeResult: - budget = budget or EpisodeBudget() - tool_ctx = ToolContext(db=db, user_id=user_id, namespace=namespace) - client, model = _resolve_client_and_model() - openai_tools, tool_name_map = _build_openai_tools() - - system_prompt = load_corpus_schema_text() + LOOP_CONTRACT_SUFFIX - messages: list[dict[str, Any]] = [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": query}, - ] - - steps: list[AgentStep] = [] - stop_reason = "finished" - result_refs: list[dict[str, Any]] = [] - result_notes = "" - # Refs from every corpus.read/corpus.assets call this episode, in call - # order — the fallback source when finish's own refs end up empty (see - # module docstring point 2). - trajectory_refs: list[dict[str, Any]] = [] - # One entry per appended tool-role message: {message_index, turn_index, - # tool_name, original_chars, collapsed} — see _collapse_stale_tool_messages. - tool_message_log: list[dict[str, Any]] = [] - turn_index = 0 - - while True: - turn_index += 1 - _collapse_stale_tool_messages( - messages, - tool_message_log, - current_turn=turn_index, - fresh_turns=_TOOL_MESSAGE_FRESH_TURNS, - ) - - forced_reason = budget.exhausted() - tool_choice: Any = "auto" - if forced_reason is not None: - tool_choice = {"type": "function", "function": {"name": FINISH_TOOL_NAME}} - - turn_started = time.perf_counter() - response, usage = await asyncio.to_thread( - client.chat_completion_raw_with_usage, - messages=messages, - model=model, - temperature=0.0, - max_tokens=AGENT_EXPLORE_MAX_COMPLETION_TOKENS, - tools=openai_tools, - tool_choice=tool_choice, - ) - budget.record_usage(usage) - budget.record_step() - turn_elapsed_ms = int((time.perf_counter() - turn_started) * 1000) - turn_tokens = int((usage or {}).get("total_tokens", 0) or 0) - - message = response.choices[0].message - tool_calls = list(message.tool_calls or []) - - if not tool_calls: - stop_reason = f"budget_{forced_reason}" if forced_reason else "no_tool_call" - result_notes = str(message.content or "") - steps.append( - AgentStep( - step_index=len(steps), - tool_name="", - tool_args={}, - observation_text=result_notes, - error=None, - elapsed_ms=turn_elapsed_ms, - tokens_used_delta=turn_tokens, - tokens_used_total=budget.tokens_used, - ) - ) - break - - finish_call = next( - (tc for tc in tool_calls if tc.function.name == FINISH_TOOL_NAME), None - ) - if finish_call is not None: - args = _safe_json_loads(finish_call.function.arguments) - result_refs = _normalize_finish_refs(args.get("refs")) - result_notes = str(args.get("notes") or "") - stop_reason = f"budget_{forced_reason}" if forced_reason else "finished" - steps.append( - AgentStep( - step_index=len(steps), - tool_name=FINISH_TOOL_NAME, - tool_args=args, - observation_text=f"refs={len(result_refs)} notes={result_notes!r}", - error=None, - elapsed_ms=turn_elapsed_ms, - tokens_used_delta=turn_tokens, - tokens_used_total=budget.tokens_used, - ) - ) - break - - if forced_reason is not None: - # Forced tool_choice=finish but the provider returned a - # different tool anyway (not observed in verification, but a - # budget cutoff must never loop past). Stop here regardless. - stop_reason = f"budget_{forced_reason}" - result_notes = str(message.content or "") or ( - "budget exhausted; provider did not return finish" - ) - steps.append( - AgentStep( - step_index=len(steps), - tool_name="", - tool_args={}, - observation_text=result_notes, - error="forced_finish_not_honored", - elapsed_ms=turn_elapsed_ms, - tokens_used_delta=turn_tokens, - tokens_used_total=budget.tokens_used, - ) - ) - break - - messages.append( - { - "role": "assistant", - "content": message.content or "", - "tool_calls": [ - { - "id": tc.id, - "type": "function", - "function": { - "name": tc.function.name, - "arguments": tc.function.arguments, - }, - } - for tc in tool_calls - ], - } - ) - first_tool_tokens_recorded = False - for tc in tool_calls: - tool_started = time.perf_counter() - args = _safe_json_loads(tc.function.arguments) - requested_name = str(tc.function.name or "") - canonical_name = tool_name_map.get(requested_name, requested_name) - try: - tool_result = await REGISTRY.dispatch(canonical_name, tool_ctx, args) - except Exception as exc: # noqa: BLE001 - one broken tool must not kill the episode - tool_result = ToolResult(text="", error=f"{type(exc).__name__}: {exc}") - tool_elapsed_ms = int((time.perf_counter() - tool_started) * 1000) - content = _tool_message_content(tool_result, max_chars=tool_ctx.budget.max_chars) - messages.append( - {"role": "tool", "tool_call_id": tc.id, "content": content} - ) - tool_message_log.append( - { - "message_index": len(messages) - 1, - "turn_index": turn_index, - "tool_name": canonical_name, - "original_chars": len(content), - "collapsed": False, - } - ) - if canonical_name in _EVIDENCE_TOOL_NAMES and not tool_result.error: - trajectory_refs.extend(tool_result.refs) - # Turn-level token usage is attributed to the first tool step in - # this turn (the completion that decided all calls in it); the - # rest are 0 to avoid double-counting the same LLM usage. - steps.append( - AgentStep( - step_index=len(steps), - tool_name=canonical_name, - tool_args=args, - observation_text=content, - error=tool_result.error, - elapsed_ms=tool_elapsed_ms if first_tool_tokens_recorded else turn_elapsed_ms + tool_elapsed_ms, - tokens_used_delta=0 if first_tool_tokens_recorded else turn_tokens, - tokens_used_total=budget.tokens_used, - ) - ) - first_tool_tokens_recorded = True - - if not result_refs: - fallback_refs = _dedup_refs(trajectory_refs) - if fallback_refs: - result_refs = fallback_refs - result_notes = (result_notes + " " if result_notes else "") + ( - "[refs auto-filled from corpus.read/corpus.assets trajectory; " - "finish did not cite any]" - ) - - return EpisodeResult( - refs=result_refs, - notes=result_notes, - steps=steps, - stop_reason=stop_reason, - tokens_used=budget.tokens_used, - model_name=model, - ) diff --git a/packages/shared-python/shared/services/retrieval/agent_explore/harness/__init__.py b/packages/shared-python/shared/services/retrieval/agent_explore/harness/__init__.py new file mode 100644 index 00000000..e4559f30 --- /dev/null +++ b/packages/shared-python/shared/services/retrieval/agent_explore/harness/__init__.py @@ -0,0 +1,14 @@ +"""Pluggable ``agent_explore`` harness implementations. + +``base.Harness`` is the provider-agnostic interface; ``openai_harness`` and +``cursor_harness`` are the two implementations selected via +``AGENT_EXPLORE_HARNESS`` (``execution/routes.py``). See the Phase 3.5 +section of ``.cursor/plans/agentic_corpus_explore_retrieval_c2c4ea21.plan.md``. +""" + +from __future__ import annotations + +from shared.services.retrieval.agent_explore.harness.base import Harness +from shared.services.retrieval.agent_explore.harness.resolve import resolve_harness + +__all__ = ["Harness", "resolve_harness"] diff --git a/packages/shared-python/shared/services/retrieval/agent_explore/harness/base.py b/packages/shared-python/shared/services/retrieval/agent_explore/harness/base.py new file mode 100644 index 00000000..45c236f7 --- /dev/null +++ b/packages/shared-python/shared/services/retrieval/agent_explore/harness/base.py @@ -0,0 +1,42 @@ +"""Provider-agnostic ``Harness`` interface for ``agent_explore``. + +Any backend-hosted tool-loop implementation (OpenAI-compatible/DeepSeek, +Cursor SDK, ...) implements this single method. ``_run_agent_explore_route`` +(``execution/routes.py``) resolves one ``Harness`` via ``AGENT_EXPLORE_HARNESS`` +(see ``harness/resolve.py``) and calls it the same way regardless of which +provider is behind it — the route/bridge/budget-object shapes do not change +per harness. +""" + +from __future__ import annotations + +from typing import Protocol, runtime_checkable + +from shared.services.retrieval.agent_explore.budget import EpisodeBudget +from shared.services.retrieval.agent_explore.dispatch import DbFactory +from shared.services.retrieval.agent_explore.types import EpisodeResult + + +@runtime_checkable +class Harness(Protocol): + """One backend-hosted tool-loop implementation over ``agent_tools.REGISTRY``.""" + + async def run_episode( + self, + *, + db_factory: DbFactory, + user_id: str, + namespace: str, + query: str, + budget: EpisodeBudget, + ) -> EpisodeResult: + """Explore the corpus for ``query`` and return the cited evidence. + + ``db_factory`` is a call-scoped DB session factory (see + ``dispatch.py``) — implementations must not hold one shared + ``AsyncSession`` across the whole episode; every ``REGISTRY.dispatch`` + call goes through ``dispatch.dispatch_tool_call(..., db_factory=db_factory)`` + so concurrent tool calls (a real Cursor SDK behavior, not just a + theoretical one — see ``harness/cursor_harness.py``) are always safe. + """ + ... diff --git a/packages/shared-python/shared/services/retrieval/agent_explore/harness/cursor_harness.py b/packages/shared-python/shared/services/retrieval/agent_explore/harness/cursor_harness.py new file mode 100644 index 00000000..f4436518 --- /dev/null +++ b/packages/shared-python/shared/services/retrieval/agent_explore/harness/cursor_harness.py @@ -0,0 +1,298 @@ +"""``Harness`` implementation over the Cursor SDK local agent. + +Promoted from ``apps/worker/scripts/debug_cursor_agent_explore.py`` (PoC) — +see Phase 3.5 of +``.cursor/plans/agentic_corpus_explore_retrieval_c2c4ea21.plan.md``. Uses +``cursor_sdk``'s ``local.custom_tools`` so this host process still executes +``agent_tools.REGISTRY`` (same ``corpus.*`` tools as ``harness/openai_harness.py``), +while the orchestration model comes from Cursor (default +``config.AGENT_EXPLORE_CURSOR_MODEL``, e.g. ``composer-2.5``) instead of +DeepSeek. + +Requires the optional ``cursor-sdk`` dependency (see +``apps/worker/pyproject.toml``'s ``cursor-harness`` extra) and +``CURSOR_API_KEY``. The guarded import in ``_require_cursor_sdk`` raises a +clear, actionable error at harness-selection time if ``cursor-sdk`` isn't +installed, instead of failing deep inside a running episode. + +Architectural difference from ``openai_harness.py`` that budget enforcement +has to work around: this harness does not control the LLM turn loop. +``agent.send(...)`` + ``await run.wait()`` hands the *entire* multi-turn +tool-calling loop to the Cursor SDK; the host process only sees (a) +``execute`` callbacks for each ``corpus.*``/``finish`` tool call — run +synchronously off the SDK's own thread and bridged back onto this event +loop via ``asyncio.run_coroutine_threadsafe`` (mirrors the PoC's +``_dispatch_sync``) — and (b) the terminal ``RunResult`` once ``wait()`` +returns. There is no per-turn hook to inspect budget mid-turn and force +``tool_choice`` the way ``openai_harness.py`` does. Each budget dimension is +therefore enforced (or explicitly not) differently here: + +- **``max_steps``**: a plain counter (``budget.steps_used``, incremented once + per ``corpus.*`` dispatch — ``finish`` does not count, it ends the episode + on its own) checked in ``_dispatch_sync`` *before* dispatching. Once the + counter reaches ``budget.max_steps``, further ``corpus.*`` calls are not + forwarded to ``REGISTRY`` at all — the callback returns a fixed + "budget exhausted, call finish now" string instead. This is a real, + synchronous cutoff (unlike the two dimensions below): no reliance on + cancelling the SDK run from a background task. +- **``wall_clock``**: ``asyncio.wait_for(run.wait(), timeout=budget.wall_clock_seconds)``, + per the plan. On timeout, best-effort ``await run.cancel()`` (own short + timeout, so a hung cancel RPC can't hang this call forever) so the + underlying agent run actually stops server-side instead of merely being + abandoned by this process, then the episode result is built from whatever + ``finish``/tool-trajectory refs were captured before the timeout. +- **``token_limit``**: **not actively enforced mid-run.** Verified by reading + the installed ``cursor-sdk`` package's source + (``cursor_sdk._run_base._RunBase.usage``, 2026-09) that cumulative token + usage is incrementally accumulated from ``SDKUsageMessage`` stream events + as ``run.wait()`` consumes them, and exposed as a live ``run.usage`` + property — so a mid-run cutoff (e.g. a polling task calling + ``run.cancel()``) is *plausible*. It was **not** exercised against a real + ``CURSOR_API_KEY`` run in this change (no key available in the + implementation environment), so building an active cutoff on top of an + unverified live-update assumption was judged higher-risk than shipping. + ``EpisodeBudget.exhausted()``'s ``token_limit`` dimension is therefore left + unchecked here by design; ``budget.tokens_used`` is only populated + *post-hoc* from the terminal ``RunResult.usage`` for observability + (``EpisodeResult.tokens_used``), after the episode has already finished. + If ``eval-cursor-harness`` confirms ``run.usage`` does update live against + a real key, promoting this to an active cutoff (mirroring the + ``max_steps``/``wall_clock`` pattern above) is straightforward follow-up + work — deliberately not done speculatively here. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +import os +import time +from typing import Any + +from shared.services.retrieval.agent_explore.budget import EpisodeBudget +from shared.services.retrieval.agent_explore.config import ( + AGENT_EXPLORE_CURSOR_MODEL, + FINISH_TOOL_DESCRIPTION, + FINISH_TOOL_NAME, + FINISH_TOOL_SCHEMA, + LOOP_CONTRACT_SUFFIX, +) +from shared.services.retrieval.agent_explore.dispatch import DbFactory, dispatch_tool_call +from shared.services.retrieval.agent_explore.shared import ( + EVIDENCE_TOOL_NAMES, + dedup_refs, + normalize_finish_refs, + tool_message_content, + wire_safe_tool_name, +) +from shared.services.retrieval.agent_explore.types import AgentStep, EpisodeResult +from shared.services.retrieval.agent_tools import ( + REGISTRY, + ToolBudget, + ToolResult, + load_corpus_schema_text, +) +from shared.services.retrieval.agent_tools import tools as _agent_tools_registered # noqa: F401 + +# Grace period for the underlying agent run to actually stop, after a +# best-effort run.cancel() following a wall_clock timeout, before this +# process gives up waiting for a terminal RunResult and falls back to +# whatever refs the trajectory already captured. Not tuned against real +# cancel-RPC latency yet (no CURSOR_API_KEY in the implementation +# environment) — revisit with eval-cursor-harness data. +_CANCEL_GRACE_SECONDS = 30.0 + +_BUDGET_EXHAUSTED_MESSAGE = ( + "error: step budget exhausted for this episode — do not call any more " + "corpus.* tools; call finish now with whatever refs you already have " + "(or an empty list plus a notes explanation)." +) + + +def _require_cursor_sdk() -> Any: + try: + import cursor_sdk + except ImportError as exc: + raise RuntimeError( + "AGENT_EXPLORE_HARNESS=cursor_sdk requires the optional " + "'cursor-sdk' dependency, which is not installed in this " + "interpreter. Install with: uv sync --extra cursor-harness " + "(apps/worker/pyproject.toml)." + ) from exc + return cursor_sdk + + +class CursorHarness: + """``Harness`` implementation over the Cursor SDK local agent.""" + + def __init__(self, *, model: str | None = None) -> None: + self._model = model or AGENT_EXPLORE_CURSOR_MODEL + + async def run_episode( + self, + *, + db_factory: DbFactory, + user_id: str, + namespace: str, + query: str, + budget: EpisodeBudget, + ) -> EpisodeResult: + cursor_sdk = _require_cursor_sdk() + + api_key = os.environ.get("CURSOR_API_KEY", "").strip() + if not api_key: + raise RuntimeError( + "AGENT_EXPLORE_HARNESS=cursor_sdk requires CURSOR_API_KEY to be set" + ) + + tool_budget = ToolBudget() + loop = asyncio.get_running_loop() + + steps: list[AgentStep] = [] + trajectory_refs: list[dict[str, Any]] = [] + # None until finish is actually called — distinguishes "finish + # called with an empty refs list" (respect it) from "finish never + # called" (fall back to trajectory_refs below), same contract as + # openai_harness.py's normalize_finish_refs + fallback. + finish_state: dict[str, Any] = {"refs": None, "notes": ""} + stop_reason = "finished" + + def _dispatch_sync(tool_name: str, args: dict[str, Any]) -> str: + if budget.steps_used >= budget.max_steps: + steps.append( + AgentStep( + step_index=len(steps), + tool_name=tool_name, + tool_args=args, + observation_text=_BUDGET_EXHAUSTED_MESSAGE, + error="budget_max_steps", + elapsed_ms=0, + tokens_used_delta=0, + tokens_used_total=budget.tokens_used, + ) + ) + return _BUDGET_EXHAUSTED_MESSAGE + budget.record_step() + tool_started = time.perf_counter() + future = asyncio.run_coroutine_threadsafe( + dispatch_tool_call( + tool_name, + args, + db_factory=db_factory, + user_id=user_id, + namespace=namespace, + budget=tool_budget, + ), + loop, + ) + try: + tool_result = future.result(timeout=180) + except Exception as exc: # noqa: BLE001 - one broken tool must not kill the episode + tool_result = ToolResult(text="", error=f"{type(exc).__name__}: {exc}") + elapsed_ms = int((time.perf_counter() - tool_started) * 1000) + content = tool_message_content(tool_result, max_chars=tool_budget.max_chars) + steps.append( + AgentStep( + step_index=len(steps), + tool_name=tool_name, + tool_args=args, + observation_text=content, + error=tool_result.error, + elapsed_ms=elapsed_ms, + tokens_used_delta=0, + tokens_used_total=budget.tokens_used, + ) + ) + if tool_name in EVIDENCE_TOOL_NAMES and not tool_result.error: + trajectory_refs.extend(tool_result.refs) + return content + + custom_tools: dict[str, Any] = {} + for spec in REGISTRY.all(): + wire_name = wire_safe_tool_name(spec.name) + + def _make_execute(resolved_name: str): + def execute(args: dict[str, Any], _ctx: Any) -> str: + return _dispatch_sync(resolved_name, dict(args or {})) + + return execute + + custom_tools[wire_name] = cursor_sdk.CustomTool( + execute=_make_execute(spec.name), + description=f"{spec.description} (canonical name: {spec.name})", + input_schema=spec.json_schema, + ) + + def finish_execute(args: dict[str, Any], _ctx: Any) -> str: + finish_state["refs"] = normalize_finish_refs(args.get("refs")) + finish_state["notes"] = str(args.get("notes") or "") + return json.dumps({"status": "finished", "refs": len(finish_state["refs"])}) + + custom_tools[FINISH_TOOL_NAME] = cursor_sdk.CustomTool( + execute=finish_execute, + description=FINISH_TOOL_DESCRIPTION, + input_schema=FINISH_TOOL_SCHEMA, + ) + + system_prompt = load_corpus_schema_text() + LOOP_CONTRACT_SUFFIX + user_prompt = ( + f"{system_prompt}\n\n---\n\nUser query:\n{query}\n\n" + "Tool names on the wire use underscores " + f"({', '.join(sorted(custom_tools))}). Explore with those tools, " + "then call finish with cited refs." + ) + + result: Any = None + async with await cursor_sdk.AsyncClient.launch_bridge( + workspace=os.getcwd(), + ) as client: + async with await client.agents.create( + cursor_sdk.AgentOptions( + api_key=api_key, + model=self._model, + local=cursor_sdk.LocalAgentOptions( + cwd=os.getcwd(), + custom_tools=custom_tools, + ), + ) + ) as agent: + run = await agent.send(user_prompt) + try: + result = await asyncio.wait_for( + run.wait(), timeout=budget.wall_clock_seconds + ) + except asyncio.TimeoutError: + stop_reason = "budget_wall_clock" + with contextlib.suppress(Exception): + await asyncio.wait_for(run.cancel(), timeout=10) + with contextlib.suppress(Exception): + result = await asyncio.wait_for( + run.wait(), timeout=_CANCEL_GRACE_SECONDS + ) + + result_usage_total_tokens = 0 + if result is not None and result.usage is not None: + result_usage_total_tokens = result.usage.total_tokens + budget.record_usage({"total_tokens": result_usage_total_tokens}) + + result_refs = finish_state["refs"] + result_notes = str(finish_state["notes"] or "") + if not result_refs: + fallback_refs = dedup_refs(trajectory_refs) + if fallback_refs: + result_refs = fallback_refs + result_notes = (result_notes + " " if result_notes else "") + ( + "[refs auto-filled from corpus.read/corpus.assets trajectory; " + "finish did not cite any]" + ) + result_refs = result_refs or [] + + return EpisodeResult( + refs=result_refs, + notes=result_notes, + steps=steps, + stop_reason=stop_reason, + tokens_used=budget.tokens_used, + model_name=self._model, + ) diff --git a/packages/shared-python/shared/services/retrieval/agent_explore/harness/openai_harness.py b/packages/shared-python/shared/services/retrieval/agent_explore/harness/openai_harness.py new file mode 100644 index 00000000..dd8df212 --- /dev/null +++ b/packages/shared-python/shared/services/retrieval/agent_explore/harness/openai_harness.py @@ -0,0 +1,383 @@ +"""In-process tool-calling episode over the ``agent_tools`` corpus registry, +via an OpenAI-compatible (DeepSeek) function-calling loop. + +Uses ``OpenAICompatibleClientSync.chat_completion_raw_with_usage`` — the RAW +response, not ``chat_completion_with_usage`` — because the latter only +returns ``.content`` and silently drops ``message.tool_calls``. Verified live +(2026-09-08) against ``deepseek-v4-flash`` via this codebase's client: +single tool call, parallel tool calls in one turn, tool-result feedback + +final synthesis, and forced ``tool_choice`` (used for the budget-exhaustion +cutoff below) all work. + +Tool calls within one turn are dispatched sequentially through +``dispatch.dispatch_tool_call`` (fresh DB session per call — safe for any +harness, not just this one), not via ``asyncio.gather``: batching several +tool calls into one LLM turn already removes the LLM round-trip per tool +(the dominant cost); true DB-level concurrency within a turn is not +implemented here since this provider's tool calls are handled one at a time +by design, not because concurrent dispatch would be unsafe (it no longer is +— see ``dispatch.py``). + +No import from ``nav/`` or ``nav_config.py`` — see ``config.py``. + +Two Phase 4 fixes (audited live against the eval fixture in +``apps/worker/scripts/fixtures/changheba_archive_eval_queries.json``), both +now shared with any other harness via ``shared.py`` except stale-message +collapsing (fix 1), which stays here — it mutates this harness's own +``messages: list[dict]`` history, a mechanism the Cursor SDK harness has no +equivalent hook for: + +1. **Stale tool-message collapsing** (``_TOOL_MESSAGE_FRESH_TURNS``, + ``_collapse_stale_tool_messages``): ``messages`` only ever appended, so a + single ``corpus.outline``/``corpus.node_filter`` call (each capped at + ``ToolBudget.max_chars`` — currently ``EVIDENCE_TEXT_CHAR_BUDGET=12_000``, + see ``registry.py``) was resent in full on every later turn. Verified + live: two independent queries (q04, q06 in the eval fixture) hit + ``RETRIEVAL_NAV_TOKEN_LIMIT`` (100k default) within 7-8 LLM turns from + this resend alone, not from query difficulty — per-turn token cost grew + monotonically (q04: 4.4k -> 4.8k -> 19.8k -> 21.5k -> 24.2k -> 29.6k). +2. **Trajectory refs fallback** (``shared.dedup_refs`` + the fallback at the + end of ``run_episode``): verified live that ``finish`` can be called with + no ``refs`` key at all (raw ``function.arguments`` was literally ``'{}'``) + even after the model had already read clearly relevant sections via + ``corpus.read`` — the ``FINISH_TOOL_SCHEMA``'s ``"required": ["refs"]`` is + a schema hint, not a provider-enforced constraint. When ``finish``'s own + ``refs`` end up empty (whether from this, from ``no_tool_call``, or from a + forced-finish the provider ignored — all three exit paths), the episode + now falls back to the refs already returned by every + ``corpus.read``/``corpus.assets`` call in the trajectory, deduped, instead + of citing nothing. +""" + +from __future__ import annotations + +import asyncio +import json +import time +from typing import Any + +from shared.services.retrieval.agent_explore.budget import EpisodeBudget +from shared.services.retrieval.agent_explore.config import ( + AGENT_EXPLORE_MAX_COMPLETION_TOKENS, + AGENT_EXPLORE_MODEL, + FINISH_TOOL_DESCRIPTION, + FINISH_TOOL_NAME, + FINISH_TOOL_SCHEMA, + LOOP_CONTRACT_SUFFIX, +) +from shared.services.retrieval.agent_explore.dispatch import DbFactory, dispatch_tool_call +from shared.services.retrieval.agent_explore.shared import ( + EVIDENCE_TOOL_NAMES, + build_wire_tool_name_map, + dedup_refs, + normalize_finish_refs, + tool_message_content, + wire_safe_tool_name, +) +from shared.services.retrieval.agent_explore.types import AgentStep, EpisodeResult +from shared.services.retrieval.agent_tools import REGISTRY, ToolBudget, load_corpus_schema_text +from shared.services.retrieval.agent_tools import tools as _agent_tools_registered # noqa: F401 + +# A tool-role message is kept in full for the turn it was produced plus this +# many additional turns, then collapsed to a placeholder — see module +# docstring point 1. Not tuned against a real recall-vs-token tradeoff yet; +# 2 was chosen so a result stays fully visible for one full turn after the +# one it was produced in (enough for the model to act on it immediately), +# revisit with more Phase 4 data. +_TOOL_MESSAGE_FRESH_TURNS = 2 + + +def _resolve_client_and_model() -> tuple[Any, str]: + """Mirrors ``nav_llm_backend.nav_chat_sync_backend``'s resolve pattern, + pinned to ``AGENT_EXPLORE_MODEL`` instead of ``nav_config.MAPNAV_MODEL``. + """ + from shared.services.ai.llm_overrides import resolve_text + from shared.services.ai.openai_compatible_client_sync import get_openai_client + + requested = AGENT_EXPLORE_MODEL + effective_model, api_key, api_url = resolve_text(requested) + model = effective_model or requested + client = get_openai_client(model=model, api_key=api_key, api_url=api_url) + return client, model + + +def _build_openai_tools() -> tuple[list[dict[str, Any]], dict[str, str]]: + """Return ``(tools, name_map)`` where ``name_map`` maps the wire-safe + name back to the canonical ``REGISTRY`` name (``finish`` maps to itself). + """ + specs = REGISTRY.all() + name_map = build_wire_tool_name_map([spec.name for spec in specs]) + name_map[FINISH_TOOL_NAME] = FINISH_TOOL_NAME + tools: list[dict[str, Any]] = [ + { + "type": "function", + "function": { + "name": wire_safe_tool_name(spec.name), + "description": spec.description, + "parameters": spec.json_schema, + }, + } + for spec in specs + ] + tools.append( + { + "type": "function", + "function": { + "name": FINISH_TOOL_NAME, + "description": FINISH_TOOL_DESCRIPTION, + "parameters": FINISH_TOOL_SCHEMA, + }, + } + ) + return tools, name_map + + +def _safe_json_loads(raw: str | None) -> dict[str, Any]: + if not raw: + return {} + try: + parsed = json.loads(raw) + except (TypeError, ValueError): + return {} + return parsed if isinstance(parsed, dict) else {} + + +def _collapse_stale_tool_messages( + messages: list[dict[str, Any]], + tool_message_log: list[dict[str, Any]], + *, + current_turn: int, + fresh_turns: int, +) -> None: + """Replace tool messages older than ``fresh_turns`` with a placeholder. + + ``messages`` only ever grows within one episode (see module docstring + point 1); this is what keeps that growth bounded instead of resending + every past tool result on every later turn. + """ + for entry in tool_message_log: + if entry["collapsed"]: + continue + if current_turn - entry["turn_index"] < fresh_turns: + continue + messages[entry["message_index"]]["content"] = ( + f"[collapsed: {entry['tool_name']} result from turn " + f"{entry['turn_index']} was {entry['original_chars']} chars — " + "call the tool again if you need it back in view]" + ) + entry["collapsed"] = True + + +class OpenAIHarness: + """``Harness`` implementation over an OpenAI-compatible function-calling loop.""" + + async def run_episode( + self, + *, + db_factory: DbFactory, + user_id: str, + namespace: str, + query: str, + budget: EpisodeBudget, + ) -> EpisodeResult: + tool_budget = ToolBudget() + client, model = _resolve_client_and_model() + openai_tools, tool_name_map = _build_openai_tools() + + system_prompt = load_corpus_schema_text() + LOOP_CONTRACT_SUFFIX + messages: list[dict[str, Any]] = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": query}, + ] + + steps: list[AgentStep] = [] + stop_reason = "finished" + result_refs: list[dict[str, Any]] = [] + result_notes = "" + # Refs from every corpus.read/corpus.assets call this episode, in call + # order — the fallback source when finish's own refs end up empty (see + # module docstring point 2). + trajectory_refs: list[dict[str, Any]] = [] + # One entry per appended tool-role message: {message_index, turn_index, + # tool_name, original_chars, collapsed} — see _collapse_stale_tool_messages. + tool_message_log: list[dict[str, Any]] = [] + turn_index = 0 + + while True: + turn_index += 1 + _collapse_stale_tool_messages( + messages, + tool_message_log, + current_turn=turn_index, + fresh_turns=_TOOL_MESSAGE_FRESH_TURNS, + ) + + forced_reason = budget.exhausted() + tool_choice: Any = "auto" + if forced_reason is not None: + tool_choice = {"type": "function", "function": {"name": FINISH_TOOL_NAME}} + + turn_started = time.perf_counter() + response, usage = await asyncio.to_thread( + client.chat_completion_raw_with_usage, + messages=messages, + model=model, + temperature=0.0, + max_tokens=AGENT_EXPLORE_MAX_COMPLETION_TOKENS, + tools=openai_tools, + tool_choice=tool_choice, + ) + budget.record_usage(usage) + budget.record_step() + turn_elapsed_ms = int((time.perf_counter() - turn_started) * 1000) + turn_tokens = int((usage or {}).get("total_tokens", 0) or 0) + + message = response.choices[0].message + tool_calls = list(message.tool_calls or []) + + if not tool_calls: + stop_reason = f"budget_{forced_reason}" if forced_reason else "no_tool_call" + result_notes = str(message.content or "") + steps.append( + AgentStep( + step_index=len(steps), + tool_name="", + tool_args={}, + observation_text=result_notes, + error=None, + elapsed_ms=turn_elapsed_ms, + tokens_used_delta=turn_tokens, + tokens_used_total=budget.tokens_used, + ) + ) + break + + finish_call = next( + (tc for tc in tool_calls if tc.function.name == FINISH_TOOL_NAME), None + ) + if finish_call is not None: + args = _safe_json_loads(finish_call.function.arguments) + result_refs = normalize_finish_refs(args.get("refs")) + result_notes = str(args.get("notes") or "") + stop_reason = f"budget_{forced_reason}" if forced_reason else "finished" + steps.append( + AgentStep( + step_index=len(steps), + tool_name=FINISH_TOOL_NAME, + tool_args=args, + observation_text=f"refs={len(result_refs)} notes={result_notes!r}", + error=None, + elapsed_ms=turn_elapsed_ms, + tokens_used_delta=turn_tokens, + tokens_used_total=budget.tokens_used, + ) + ) + break + + if forced_reason is not None: + # Forced tool_choice=finish but the provider returned a + # different tool anyway (not observed in verification, but a + # budget cutoff must never loop past). Stop here regardless. + stop_reason = f"budget_{forced_reason}" + result_notes = str(message.content or "") or ( + "budget exhausted; provider did not return finish" + ) + steps.append( + AgentStep( + step_index=len(steps), + tool_name="", + tool_args={}, + observation_text=result_notes, + error="forced_finish_not_honored", + elapsed_ms=turn_elapsed_ms, + tokens_used_delta=turn_tokens, + tokens_used_total=budget.tokens_used, + ) + ) + break + + messages.append( + { + "role": "assistant", + "content": message.content or "", + "tool_calls": [ + { + "id": tc.id, + "type": "function", + "function": { + "name": tc.function.name, + "arguments": tc.function.arguments, + }, + } + for tc in tool_calls + ], + } + ) + first_tool_tokens_recorded = False + for tc in tool_calls: + tool_started = time.perf_counter() + args = _safe_json_loads(tc.function.arguments) + requested_name = str(tc.function.name or "") + canonical_name = tool_name_map.get(requested_name, requested_name) + tool_result = await dispatch_tool_call( + canonical_name, + args, + db_factory=db_factory, + user_id=user_id, + namespace=namespace, + budget=tool_budget, + ) + tool_elapsed_ms = int((time.perf_counter() - tool_started) * 1000) + content = tool_message_content(tool_result, max_chars=tool_budget.max_chars) + messages.append( + {"role": "tool", "tool_call_id": tc.id, "content": content} + ) + tool_message_log.append( + { + "message_index": len(messages) - 1, + "turn_index": turn_index, + "tool_name": canonical_name, + "original_chars": len(content), + "collapsed": False, + } + ) + if canonical_name in EVIDENCE_TOOL_NAMES and not tool_result.error: + trajectory_refs.extend(tool_result.refs) + # Turn-level token usage is attributed to the first tool step in + # this turn (the completion that decided all calls in it); the + # rest are 0 to avoid double-counting the same LLM usage. + steps.append( + AgentStep( + step_index=len(steps), + tool_name=canonical_name, + tool_args=args, + observation_text=content, + error=tool_result.error, + elapsed_ms=( + tool_elapsed_ms + if first_tool_tokens_recorded + else turn_elapsed_ms + tool_elapsed_ms + ), + tokens_used_delta=0 if first_tool_tokens_recorded else turn_tokens, + tokens_used_total=budget.tokens_used, + ) + ) + first_tool_tokens_recorded = True + + if not result_refs: + fallback_refs = dedup_refs(trajectory_refs) + if fallback_refs: + result_refs = fallback_refs + result_notes = (result_notes + " " if result_notes else "") + ( + "[refs auto-filled from corpus.read/corpus.assets trajectory; " + "finish did not cite any]" + ) + + return EpisodeResult( + refs=result_refs, + notes=result_notes, + steps=steps, + stop_reason=stop_reason, + tokens_used=budget.tokens_used, + model_name=model, + ) diff --git a/packages/shared-python/shared/services/retrieval/agent_explore/harness/resolve.py b/packages/shared-python/shared/services/retrieval/agent_explore/harness/resolve.py new file mode 100644 index 00000000..449a636e --- /dev/null +++ b/packages/shared-python/shared/services/retrieval/agent_explore/harness/resolve.py @@ -0,0 +1,41 @@ +"""``AGENT_EXPLORE_HARNESS`` env switch — same pattern as ``execution/routes.py``'s +``_resolve_agentic_router()`` (``RETRIEVAL_AGENTIC_ROUTER``): unrecognized or +unset values fall back to the default rather than raising, so a typo'd env +var degrades to known-good behavior instead of breaking the route. + +Each branch below imports its harness implementation lazily so that +selecting ``openai`` never imports ``cursor_sdk``-dependent code (and +vice versa) — see ``cursor_harness.py``'s guarded import. +""" + +from __future__ import annotations + +import os + +from shared.services.retrieval.agent_explore.harness.base import Harness + +_HARNESS_ENV = "AGENT_EXPLORE_HARNESS" +_HARNESSES = {"openai", "cursor_sdk"} +_DEFAULT_HARNESS = "openai" + + +def resolve_harness_name() -> str: + """``openai`` (default, current production behavior) or ``cursor_sdk``.""" + value = os.environ.get(_HARNESS_ENV, "").strip().lower() + return value if value in _HARNESSES else _DEFAULT_HARNESS + + +def resolve_harness(name: str | None = None) -> Harness: + """Build the ``Harness`` implementation for ``name`` (default: env-resolved).""" + resolved = (name or resolve_harness_name()).strip().lower() + if resolved == "cursor_sdk": + from shared.services.retrieval.agent_explore.harness.cursor_harness import ( + CursorHarness, + ) + + return CursorHarness() + from shared.services.retrieval.agent_explore.harness.openai_harness import ( + OpenAIHarness, + ) + + return OpenAIHarness() diff --git a/packages/shared-python/shared/services/retrieval/agent_explore/shared.py b/packages/shared-python/shared/services/retrieval/agent_explore/shared.py new file mode 100644 index 00000000..dbb61cf0 --- /dev/null +++ b/packages/shared-python/shared/services/retrieval/agent_explore/shared.py @@ -0,0 +1,106 @@ +"""Provider-agnostic helpers shared by every ``agent_explore`` harness. + +Extracted from ``episode.py`` (originally OpenAI-harness-specific) once a +second harness (``harness/cursor_harness.py``) needed the exact same logic +and, as a debug-script PoC, had started duplicating and drifting from it +instead of sharing it — see the Phase 3.5 section of +``.cursor/plans/agentic_corpus_explore_retrieval_c2c4ea21.plan.md``. + +Deliberately excludes anything that assumes an editable ``messages: +list[dict]`` conversation history (that's OpenAI-harness-specific — see +``harness/openai_harness.py``'s ``_collapse_stale_tool_messages``, which is +NOT here because the Cursor SDK manages its own context with no equivalent +hook exposed to the host process). +""" + +from __future__ import annotations + +from typing import Any + +from shared.services.retrieval.agent_tools import ToolResult + +# Tools whose ToolResult.refs point at evidence the agent has actually looked +# at (full body content), as opposed to candidate/listing refs from +# list_documents/outline/node_filter/recall/grep — those describe *where +# things are*, not *what was read*, and would inject unread noise into the +# trajectory-refs fallback below if included. +EVIDENCE_TOOL_NAMES = frozenset({"corpus.read", "corpus.assets"}) + + +def wire_safe_tool_name(name: str) -> str: + """Replace ``.`` with ``_`` in a tool name for function-calling wire formats. + + Every ``agent_tools`` name is dotted (``corpus.read``); DeepSeek's + (OpenAI-compatible) function-calling API rejects ``.`` in + ``tools[].function.name`` (must match ``^[a-zA-Z0-9_-]+$``, verified + live), and the Cursor SDK PoC needed the identical replacement for its + own ``custom_tools`` wire names — this is a function-calling wire-format + restriction shared by both providers, not an OpenAI-specific quirk. The + dotted name stays canonical in ``REGISTRY``/MCP; this underscore form + exists only for providers whose wire format rejects dots. + """ + return name.replace(".", "_") + + +def build_wire_tool_name_map(names: list[str]) -> dict[str, str]: + """``{wire_safe_name: canonical_name}`` for every name in ``names``. + + Names that are already wire-safe (e.g. ``finish``, which has no dot) map + to themselves. Used by a harness to translate a provider's tool-call + name back to the canonical ``REGISTRY`` name before dispatch. + """ + return {wire_safe_tool_name(name): name for name in names} + + +def tool_message_content(result: ToolResult, *, max_chars: int) -> str: + """Cap a tool's rendered text before it enters LLM context. + + Uses the caller's ``ToolBudget.max_chars`` (``EVIDENCE_TEXT_CHAR_BUDGET``, + aligned with map-nav evidence packing — see ``agent_tools/registry.py``) + so tools like ``read`` can return unbounded body text while the harness + still bounds what the model sees per turn. This cap applies uniformly to + every tool's rendered text (not just ``read``'s body content) — a tool + that returns a "complete, non-truncated" *matched set* by contract + (``outline``, ``node_filter``) still has its *rendered text* capped here + the same as any other tool; that promise is about payload/refs + cardinality, not about how much of it is shown to the LLM per turn. + """ + if result.error: + return f"error: {result.error}" + text = result.text or "(empty result)" + if len(text) <= max_chars: + return text + omitted = len(text) - max_chars + return ( + text[:max_chars] + + f"\n...[truncated, {omitted} more chars — narrow the scope " + "(e.g. depth/path_prefix for outline, a tighter predicate for " + "node_filter, or a more specific ref for read) and call again if " + "you need the rest]" + ) + + +def normalize_finish_refs(raw: Any) -> list[dict[str, Any]]: + """Keep only dict items with a non-empty ``document_id`` from a raw ``finish.refs``.""" + if not isinstance(raw, list): + return [] + normalized: list[dict[str, Any]] = [] + for item in raw: + if isinstance(item, dict) and str(item.get("document_id") or "").strip(): + normalized.append(item) + return normalized + + +def dedup_refs(refs: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Dedup by ``(document_id, chunk_id)``, keeping first-seen order.""" + seen: set[tuple[str, str]] = set() + deduped: list[dict[str, Any]] = [] + for ref in refs: + document_id = str(ref.get("document_id") or "").strip() + chunk_id = str(ref.get("chunk_id") or "").strip() + key = (document_id, chunk_id) + if not document_id or not chunk_id or key in seen: + continue + seen.add(key) + deduped.append(ref) + return deduped diff --git a/packages/shared-python/shared/services/retrieval/agent_tools/registry.py b/packages/shared-python/shared/services/retrieval/agent_tools/registry.py index 609de82e..61861fd0 100644 --- a/packages/shared-python/shared/services/retrieval/agent_tools/registry.py +++ b/packages/shared-python/shared/services/retrieval/agent_tools/registry.py @@ -36,7 +36,7 @@ class ToolBudget: matched-set cardinality — see ``CORPUS_SCHEMA.md`` §6. ``max_chars`` caps the rendered ``ToolResult.text`` before it enters LLM - context. Applied in ``agent_explore.episode._tool_message_content`` (not + context. Applied in ``agent_explore.shared.tool_message_content`` (not inside individual tools) so ``read`` can return full body text from the tool while the harness still bounds what the model sees per turn. Aligned with map-nav final evidence packing via ``EVIDENCE_TEXT_CHAR_BUDGET`` diff --git a/packages/shared-python/shared/services/retrieval/execution/routes.py b/packages/shared-python/shared/services/retrieval/execution/routes.py index 23b17859..66811ef8 100644 --- a/packages/shared-python/shared/services/retrieval/execution/routes.py +++ b/packages/shared-python/shared/services/retrieval/execution/routes.py @@ -216,22 +216,28 @@ async def _run_agent_explore_route( Selected when ``RETRIEVAL_AGENTIC_ROUTER=agent_explore``; ``mapnav`` remains the default route until this one passes its Phase 4 evaluation gate. See ``shared/services/retrieval/agent_explore/``. + + Which provider actually runs the tool-calling loop (OpenAI-compatible + default, or Cursor SDK) is the separate ``AGENT_EXPLORE_HARNESS`` switch + (Phase 3.5) resolved by ``resolve_harness()`` below — independent of + this route selection. """ from shared.services.retrieval.agent_explore.bridge import build_decision_trace - from shared.services.retrieval.agent_explore.episode import ( - run_agent_explore_episode, - ) + from shared.services.retrieval.agent_explore.budget import EpisodeBudget + from shared.services.retrieval.agent_explore.harness import resolve_harness from shared.services.retrieval.agent_explore.ref_resolution import ( resolve_finish_refs, ) from shared.services.retrieval.trace import TraceRecorder + harness = resolve_harness() episode_started = time.perf_counter() - episode = await run_agent_explore_episode( - db=context.db, + episode = await harness.run_episode( + db_factory=open_fresh_database_context, user_id=context.user_id, namespace=context.namespace, query=context.query, + budget=EpisodeBudget(), ) logger.info( "retrieval agent_explore stage=episode seconds={:.3f} refs={} " diff --git a/packages/shared-python/shared/tests/test_agent_explore_harness.py b/packages/shared-python/shared/tests/test_agent_explore_harness.py new file mode 100644 index 00000000..f005de2d --- /dev/null +++ b/packages/shared-python/shared/tests/test_agent_explore_harness.py @@ -0,0 +1,195 @@ +"""Unit tests for the Phase 3.5 ``agent_explore`` harness layer. + +Covers only the pure functions and the ``AGENT_EXPLORE_HARNESS`` switch +resolution — none of these need a real DB or LLM. Does not cover +``dispatch.dispatch_tool_call`` (needs a DB session) or a full +``Harness.run_episode`` loop (needs an LLM/Cursor SDK backend) — those stay +integration-level, exercised via the debug scripts and +``eval-cursor-harness``, not here. +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+asyncpg://test:test@localhost/test") +os.environ.setdefault("TMP_PATH", "/tmp/knowhere-test") +os.environ.setdefault("S3_BUCKET_NAME", "test-uploads") +os.environ.setdefault("S3_ACCESS_KEY_ID", "test") +os.environ.setdefault("S3_SECRET_ACCESS_KEY", "test") +os.environ.setdefault("S3_TEMP_PATH", "/tmp") + +import pytest + +from shared.services.retrieval.agent_explore.harness.base import Harness +from shared.services.retrieval.agent_explore.harness.resolve import ( + _HARNESS_ENV, + resolve_harness, + resolve_harness_name, +) +from shared.services.retrieval.agent_explore.shared import ( + EVIDENCE_TOOL_NAMES, + build_wire_tool_name_map, + dedup_refs, + normalize_finish_refs, + tool_message_content, + wire_safe_tool_name, +) +from shared.services.retrieval.agent_tools import ToolResult + + +# -------------------------------------------------------------------------- +# shared.py: wire-safe tool name mapping +# -------------------------------------------------------------------------- + + +def test_wire_safe_tool_name_replaces_dots() -> None: + assert wire_safe_tool_name("corpus.read") == "corpus_read" + assert wire_safe_tool_name("corpus.node_filter") == "corpus_node_filter" + # Already wire-safe names are untouched. + assert wire_safe_tool_name("finish") == "finish" + + +def test_build_wire_tool_name_map_round_trips_to_canonical() -> None: + mapping = build_wire_tool_name_map(["corpus.read", "corpus.recall", "finish"]) + assert mapping == { + "corpus_read": "corpus.read", + "corpus_recall": "corpus.recall", + "finish": "finish", + } + + +# -------------------------------------------------------------------------- +# shared.py: tool_message_content (max_chars capping) +# -------------------------------------------------------------------------- + + +def test_tool_message_content_passes_through_short_text() -> None: + result = ToolResult(text="short body") + assert tool_message_content(result, max_chars=100) == "short body" + + +def test_tool_message_content_caps_long_text_with_note() -> None: + result = ToolResult(text="x" * 200) + content = tool_message_content(result, max_chars=100) + assert content.startswith("x" * 100) + assert "truncated, 100 more chars" in content + assert len(content) > 100 # capped body + truncation note, not silently dropped + + +def test_tool_message_content_surfaces_error_instead_of_text() -> None: + result = ToolResult(text="ignored", error="bad args: missing document_id") + assert tool_message_content(result, max_chars=100) == "error: bad args: missing document_id" + + +def test_tool_message_content_empty_text_placeholder() -> None: + result = ToolResult(text="") + assert tool_message_content(result, max_chars=100) == "(empty result)" + + +# -------------------------------------------------------------------------- +# shared.py: normalize_finish_refs / dedup_refs +# -------------------------------------------------------------------------- + + +def test_normalize_finish_refs_drops_non_dict_and_empty_document_id() -> None: + raw = [ + {"document_id": "doc_a", "chunk_id": "c1"}, + {"document_id": "", "chunk_id": "c2"}, + {"chunk_id": "c3"}, + "not a dict", + None, + {"document_id": "doc_b"}, + ] + normalized = normalize_finish_refs(raw) + assert normalized == [ + {"document_id": "doc_a", "chunk_id": "c1"}, + {"document_id": "doc_b"}, + ] + + +def test_normalize_finish_refs_non_list_input_is_empty() -> None: + assert normalize_finish_refs(None) == [] + assert normalize_finish_refs("refs") == [] + assert normalize_finish_refs({"document_id": "doc_a"}) == [] + + +def test_dedup_refs_keeps_first_seen_and_drops_missing_ids() -> None: + refs = [ + {"document_id": "doc_a", "chunk_id": "c1", "section_path": "first"}, + {"document_id": "doc_a", "chunk_id": "c1", "section_path": "duplicate"}, + {"document_id": "doc_a", "chunk_id": "c2"}, + {"document_id": "doc_a"}, # missing chunk_id -> dropped + {"chunk_id": "c3"}, # missing document_id -> dropped + {"document_id": "doc_b", "chunk_id": "c1"}, + ] + deduped = dedup_refs(refs) + assert deduped == [ + {"document_id": "doc_a", "chunk_id": "c1", "section_path": "first"}, + {"document_id": "doc_a", "chunk_id": "c2"}, + {"document_id": "doc_b", "chunk_id": "c1"}, + ] + + +def test_evidence_tool_names_is_read_and_assets_only() -> None: + assert EVIDENCE_TOOL_NAMES == frozenset({"corpus.read", "corpus.assets"}) + + +# -------------------------------------------------------------------------- +# harness/resolve.py: AGENT_EXPLORE_HARNESS switch +# -------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _clean_harness_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv(_HARNESS_ENV, raising=False) + + +def test_resolve_harness_name_defaults_to_openai() -> None: + assert resolve_harness_name() == "openai" + + +def test_resolve_harness_name_reads_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(_HARNESS_ENV, "cursor_sdk") + assert resolve_harness_name() == "cursor_sdk" + + +def test_resolve_harness_name_unknown_value_falls_back_to_default( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv(_HARNESS_ENV, "not_a_real_harness") + assert resolve_harness_name() == "openai" + + +def test_resolve_harness_name_is_case_insensitive(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(_HARNESS_ENV, "CURSOR_SDK") + assert resolve_harness_name() == "cursor_sdk" + + +def test_resolve_harness_default_builds_openai_harness() -> None: + from shared.services.retrieval.agent_explore.harness.openai_harness import OpenAIHarness + + harness = resolve_harness() + assert isinstance(harness, OpenAIHarness) + assert isinstance(harness, Harness) + + +def test_resolve_harness_cursor_sdk_builds_cursor_harness_without_sdk_installed() -> None: + """Selecting cursor_sdk must not require the optional cursor-sdk package + to be importable — only actually running an episode does (see + cursor_harness.py's guarded _require_cursor_sdk, exercised at + run_episode() call time, not at harness construction time). + """ + from shared.services.retrieval.agent_explore.harness.cursor_harness import CursorHarness + + harness = resolve_harness("cursor_sdk") + assert isinstance(harness, CursorHarness) + assert isinstance(harness, Harness) + + +def test_resolve_harness_explicit_name_overrides_env(monkeypatch: pytest.MonkeyPatch) -> None: + from shared.services.retrieval.agent_explore.harness.openai_harness import OpenAIHarness + + monkeypatch.setenv(_HARNESS_ENV, "cursor_sdk") + harness = resolve_harness("openai") + assert isinstance(harness, OpenAIHarness) diff --git a/uv.lock b/uv.lock index 77a0eee5..4c1be47b 100644 --- a/uv.lock +++ b/uv.lock @@ -805,6 +805,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/2a/1b016902351a523aa2bd446b50a5bc1175d7a7d1cf90fe2ef904f9b84ebc/cryptography-46.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4", size = 3412829, upload-time = "2026-04-08T01:57:48.874Z" }, ] +[[package]] +name = "cursor-sdk" +version = "1.0.31" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/ca/60a7ea6a8b08a430a499797261487cf9114fbbfb652e10379a1f98dde463/cursor_sdk-1.0.31.tar.gz", hash = "sha256:fcdd279852d0b3eea4e4c4562dcd1c7d18360f507d2830bd2de79b6d855276a5", size = 1177, upload-time = "2026-09-03T21:04:04.352Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/18/1ea7bd9823dde860a44c68a0e920d07a415e5f73c4c3693cc8f22a081220/cursor_sdk-1.0.31-py3-none-macosx_11_0_arm64.whl", hash = "sha256:0b7b1e8fb677fe400c9d63518ff08465a7527cd23f20a3d857bf9454ab99c411", size = 49516626, upload-time = "2026-09-03T21:03:49.927Z" }, + { url = "https://files.pythonhosted.org/packages/ca/21/df0f17f3bd3d956897756e5bf4dbed355ad9261d0b7efa3d1af703912fee/cursor_sdk-1.0.31-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:22cb11230491cd989eb7015cb3778f09ba1e6a974661ddace5bee1e68f85ee8b", size = 51027071, upload-time = "2026-09-03T21:03:52.837Z" }, + { url = "https://files.pythonhosted.org/packages/28/96/de1b8b0275c792d9a472b8e61fe12e0d9bcd3f49551658361f43d8d381ac/cursor_sdk-1.0.31-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ae97e49570887953922eb6b3b84cb02c2d13e795fe4b3968f25e23ab569cf21f", size = 58623238, upload-time = "2026-09-03T21:03:55.741Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f1/25188d01c7bd0b42edee594225353176f90747047c1508bc315c0d2a3706/cursor_sdk-1.0.31-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e9b87cd80fe428b1a2ee88b4a08aac6297179a33f809d650f0383901fb60a766", size = 59314084, upload-time = "2026-09-03T21:03:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/de/60/e36b7d68ae702781fac63fef564fc9d643495b114fb959e442d449e537cf/cursor_sdk-1.0.31-py3-none-win_amd64.whl", hash = "sha256:12d87c639ac1bdb50958028e81f295f856e8e9a4d784977ea0e07d196716410e", size = 45610437, upload-time = "2026-09-03T21:04:01.878Z" }, +] + [[package]] name = "deprecated" version = "1.3.1" @@ -1588,6 +1604,11 @@ dependencies = [ { name = "tqdm" }, ] +[package.optional-dependencies] +cursor-harness = [ + { name = "cursor-sdk" }, +] + [package.dev-dependencies] dev = [ { name = "fakeredis", extra = ["lua"] }, @@ -1601,6 +1622,7 @@ dev = [ requires-dist = [ { name = "beautifulsoup4", specifier = "==4.13.4" }, { name = "cryptography", specifier = "==46.0.7" }, + { name = "cursor-sdk", marker = "extra == 'cursor-harness'", specifier = ">=1.0.31" }, { name = "gevent", specifier = ">=24.11.1" }, { name = "httpcore", specifier = ">=1.0.6" }, { name = "jieba", specifier = "==0.42.1" }, @@ -1624,6 +1646,7 @@ requires-dist = [ { name = "tabula-py", specifier = ">=2.10.0" }, { name = "tqdm", specifier = "==4.67.1" }, ] +provides-extras = ["cursor-harness"] [package.metadata.requires-dev] dev = [ From 94fbf038e6b59a44077116bce2dddad48c43c8ae Mon Sep 17 00:00:00 2001 From: chengke <404835780@qq.com> Date: Tue, 8 Sep 2026 19:56:58 +0800 Subject: [PATCH 5/6] feat(retrieval): enhance agent exploration with budget tracking and path resolution Updated the agent exploration logic to include a budget status line in tool observations, allowing the model to self-regulate based on remaining budget. Improved section path resolution by integrating a new method for handling ambiguous paths and ensuring accurate references during retrieval. Enhanced documentation to clarify the behavior of the `read` tool regarding section paths and added notes for better guidance on recall tool usage. --- .../retrieval/agent_explore/config.py | 9 ++ .../agent_explore/harness/cursor_harness.py | 10 ++- .../agent_explore/harness/openai_harness.py | 9 ++ .../retrieval/agent_explore/ref_resolution.py | 20 ++++- .../retrieval/agent_explore/shared.py | 20 +++++ .../retrieval/agent_tools/CORPUS_SCHEMA.md | 2 +- .../agent_tools/section_path_lookup.py | 87 +++++++++++++++++++ .../retrieval/agent_tools/tools/read.py | 39 ++++++--- .../retrieval/agent_tools/tools/recall.py | 12 +++ .../shared/tests/test_section_path_lookup.py | 39 +++++++++ 10 files changed, 227 insertions(+), 20 deletions(-) create mode 100644 packages/shared-python/shared/services/retrieval/agent_tools/section_path_lookup.py create mode 100644 packages/shared-python/shared/tests/test_section_path_lookup.py diff --git a/packages/shared-python/shared/services/retrieval/agent_explore/config.py b/packages/shared-python/shared/services/retrieval/agent_explore/config.py index 52d5cd87..961003cf 100644 --- a/packages/shared-python/shared/services/retrieval/agent_explore/config.py +++ b/packages/shared-python/shared/services/retrieval/agent_explore/config.py @@ -111,4 +111,13 @@ read that supports your answer into `refs` before calling `{FINISH_TOOL_NAME}`. Calling `{FINISH_TOOL_NAME}` with no `refs` after having already read relevant content discards that evidence. + +Before calling `{FINISH_TOOL_NAME}`, if you have not called `corpus.read` +(or `corpus.assets`) even once this exploration, you have not actually +verified anything yet — a search tool returning candidates is not the same +as having read them. In that case, either read your best candidate section +first, or — only if you have positively confirmed there is nothing to read +(e.g. a structural check came back with zero matching sections) — say so +explicitly in `notes`. Repeatedly rephrasing the same search instead of +reading a candidate you already found is not a substitute for reading it. """ diff --git a/packages/shared-python/shared/services/retrieval/agent_explore/harness/cursor_harness.py b/packages/shared-python/shared/services/retrieval/agent_explore/harness/cursor_harness.py index f4436518..8196f05b 100644 --- a/packages/shared-python/shared/services/retrieval/agent_explore/harness/cursor_harness.py +++ b/packages/shared-python/shared/services/retrieval/agent_explore/harness/cursor_harness.py @@ -81,6 +81,7 @@ from shared.services.retrieval.agent_explore.dispatch import DbFactory, dispatch_tool_call from shared.services.retrieval.agent_explore.shared import ( EVIDENCE_TOOL_NAMES, + budget_status_line, dedup_refs, normalize_finish_refs, tool_message_content, @@ -192,12 +193,17 @@ def _dispatch_sync(tool_name: str, args: dict[str, Any]) -> str: tool_result = ToolResult(text="", error=f"{type(exc).__name__}: {exc}") elapsed_ms = int((time.perf_counter() - tool_started) * 1000) content = tool_message_content(tool_result, max_chars=tool_budget.max_chars) + # Appended per call, unlike openai_harness.py's once-per-turn + # placement — this harness has no batched-turn concept exposed to + # the host process (see module docstring): each corpus.* dispatch + # is the only per-step hook available to surface budget state. + content_with_budget = content + "\n" + budget_status_line(budget) steps.append( AgentStep( step_index=len(steps), tool_name=tool_name, tool_args=args, - observation_text=content, + observation_text=content_with_budget, error=tool_result.error, elapsed_ms=elapsed_ms, tokens_used_delta=0, @@ -206,7 +212,7 @@ def _dispatch_sync(tool_name: str, args: dict[str, Any]) -> str: ) if tool_name in EVIDENCE_TOOL_NAMES and not tool_result.error: trajectory_refs.extend(tool_result.refs) - return content + return content_with_budget custom_tools: dict[str, Any] = {} for spec in REGISTRY.all(): diff --git a/packages/shared-python/shared/services/retrieval/agent_explore/harness/openai_harness.py b/packages/shared-python/shared/services/retrieval/agent_explore/harness/openai_harness.py index dd8df212..0f86c2fd 100644 --- a/packages/shared-python/shared/services/retrieval/agent_explore/harness/openai_harness.py +++ b/packages/shared-python/shared/services/retrieval/agent_explore/harness/openai_harness.py @@ -68,6 +68,7 @@ from shared.services.retrieval.agent_explore.dispatch import DbFactory, dispatch_tool_call from shared.services.retrieval.agent_explore.shared import ( EVIDENCE_TOOL_NAMES, + budget_status_line, build_wire_tool_name_map, dedup_refs, normalize_finish_refs, @@ -364,6 +365,14 @@ async def run_episode( ) first_tool_tokens_recorded = True + # Appended once per turn, to the last tool message only (not + # every AgentStep's recorded observation_text above) — the model + # only needs to see current remaining budget once before its next + # completion call, not once per parallel tool call in this turn. + messages[-1]["content"] = ( + str(messages[-1]["content"]) + "\n" + budget_status_line(budget) + ) + if not result_refs: fallback_refs = dedup_refs(trajectory_refs) if fallback_refs: diff --git a/packages/shared-python/shared/services/retrieval/agent_explore/ref_resolution.py b/packages/shared-python/shared/services/retrieval/agent_explore/ref_resolution.py index 36e1e982..5edeeac4 100644 --- a/packages/shared-python/shared/services/retrieval/agent_explore/ref_resolution.py +++ b/packages/shared-python/shared/services/retrieval/agent_explore/ref_resolution.py @@ -12,8 +12,9 @@ LLM does not see). This module closes that gap at the harness boundary instead of changing what the agent is taught to cite: for any ref missing ``chunk_id``, resolve that section's own body chunk — the same "one section, -one body chunk" lookup ``corpus.read``'s ``section_path`` branch already -performs (``agent_tools/tools/read.py``), not a new resolution rule. +one body chunk" lookup ``corpus.read``'s ``section_path`` branch performs +(``agent_tools/section_path_lookup.py``), including the same suffix fallback +when the agent cites a path without ancestor prefixes. """ from __future__ import annotations @@ -24,7 +25,9 @@ from sqlalchemy.ext.asyncio import AsyncSession from shared.models.database.document import Document, DocumentChunk, DocumentSection -from shared.services.retrieval.search.lexical_text import normalize_section_path +from shared.services.retrieval.agent_tools.section_path_lookup import ( + resolve_section_path_anchor, +) _BODY_CHUNK_TYPES = ("text", "page") @@ -73,6 +76,15 @@ async def resolve_finish_refs( if not (document_id and section_path and job_result_id): continue + resolved_path, path_error = await resolve_section_path_anchor( + db, + document_id=document_id, + job_result_id=job_result_id, + section_path=section_path, + ) + if path_error or not resolved_path: + continue + row = ( await db.execute( select(DocumentChunk.chunk_id) @@ -83,7 +95,7 @@ async def resolve_finish_refs( ) .where(DocumentChunk.document_id == document_id) .where(DocumentChunk.job_result_id == job_result_id) - .where(DocumentSection.section_path == normalize_section_path(section_path)) + .where(DocumentSection.section_path == resolved_path) .where(DocumentChunk.chunk_type.in_(_BODY_CHUNK_TYPES)) ) ).first() diff --git a/packages/shared-python/shared/services/retrieval/agent_explore/shared.py b/packages/shared-python/shared/services/retrieval/agent_explore/shared.py index dbb61cf0..6b37821f 100644 --- a/packages/shared-python/shared/services/retrieval/agent_explore/shared.py +++ b/packages/shared-python/shared/services/retrieval/agent_explore/shared.py @@ -17,6 +17,7 @@ from typing import Any +from shared.services.retrieval.agent_explore.budget import EpisodeBudget from shared.services.retrieval.agent_tools import ToolResult # Tools whose ToolResult.refs point at evidence the agent has actually looked @@ -91,6 +92,25 @@ def normalize_finish_refs(raw: Any) -> list[dict[str, Any]]: return normalized +def budget_status_line(budget: EpisodeBudget) -> str: + """One-line remaining-budget summary appended to a tool observation. + + Neither harness previously surfaced ``EpisodeBudget``'s own counters + (``steps_used``/``max_steps``, ``tokens_used``/``token_limit``, + elapsed/wall_clock) to the model at all — it had no way to tell "I'm on + step 3 of 12" from "I'm on step 11 of 12", so it could not self-regulate + when to stop exploring and call ``finish``. This exposes the same + ``EpisodeBudget.snapshot()`` data already used for the hard cutoff, + reused as a soft signal the model can read every turn. + """ + snap = budget.snapshot() + return ( + f"[budget: steps {snap['steps_used']}/{snap['max_steps']}, " + f"tokens {snap['tokens_used']}/{snap['token_limit']}, " + f"elapsed {snap['elapsed_seconds']:.0f}s/{snap['wall_clock_seconds']:.0f}s]" + ) + + def dedup_refs(refs: list[dict[str, Any]]) -> list[dict[str, Any]]: """Dedup by ``(document_id, chunk_id)``, keeping first-seen order.""" seen: set[tuple[str, str]] = set() diff --git a/packages/shared-python/shared/services/retrieval/agent_tools/CORPUS_SCHEMA.md b/packages/shared-python/shared/services/retrieval/agent_tools/CORPUS_SCHEMA.md index 904d0f5f..ec65d189 100644 --- a/packages/shared-python/shared/services/retrieval/agent_tools/CORPUS_SCHEMA.md +++ b/packages/shared-python/shared/services/retrieval/agent_tools/CORPUS_SCHEMA.md @@ -136,7 +136,7 @@ for anything finer-grained than a document pair. | `node_filter` | The task is a traversal/exclusion predicate — FOR ALL / EXISTS / ANY / NOT — over section titles or summaries ("which docs mention X in a heading," "sections NOT about Y") | one or more documents | Deterministic substring/regex match against `section_path` and `summary` only, not body text. Returns the full matching set and count, never a truncated top-K. If the predicate must run against body text, use `grep` instead. | | `grep` | Exact string / regex / identifier / number lookup that must run against body text | scoped by document/section/chunk_type | Returns match count plus snippets, so ANY/ALL logic can also close over body text, not just titles. | | `recall` | A fuzzy question where you don't know where the answer lives | namespace or scoped | Ranked candidates from `path_content` (BM25) + `term` (substring) channels fused by RRF; `vector` is reserved — see §5. Returns path and snippet, not full content. | -| `read` | You already know which section(s)/chunk(s) to read | one or more sections/chunks | Returns full body content, resolves `SAME-AS` markers into the owner's text, expands `connect_to` assets, and converts `page_assets` into URLs. | +| `read` | You already know which section(s)/chunk(s) to read | one or more sections/chunks | Returns full body content, resolves `SAME-AS` markers into the owner's text, expands `connect_to` assets, and converts `page_assets` into URLs. If your `section_path` omits ancestor segments (e.g. missing a top-level volume like `附件目录 /`), `read` tries a unique suffix match within the document; if several sections match, it returns an ambiguity error listing the full paths — copy the full path from `outline`/`grep`/`refs` when that happens. | | `assets` | You need images/tables directly, or need to find which section(s) host a given asset | one or more documents | Forward (by type/query) and reverse (asset → hosting section) lookup — see §3. | | `neighbors` | You need related documents in the same namespace | one document | Document-level `related` edges only — see §4. | diff --git a/packages/shared-python/shared/services/retrieval/agent_tools/section_path_lookup.py b/packages/shared-python/shared/services/retrieval/agent_tools/section_path_lookup.py new file mode 100644 index 00000000..3df35992 --- /dev/null +++ b/packages/shared-python/shared/services/retrieval/agent_tools/section_path_lookup.py @@ -0,0 +1,87 @@ +"""Shared section_path resolution for agent tools and harness bridge. + +``corpus.read`` and ``resolve_finish_refs`` both need to turn an agent-supplied +``section_path`` into one canonical DB path. Agents often cite a suffix (e.g. +``3 工程地质 / 3.2 覆盖层``) while the stored path includes ancestors +(``附件目录 / 3 工程地质 / 3.2 覆盖层``). Exact match alone fails silently +downstream; this module adds a segment-bound suffix fallback and surfaces +ambiguity instead of guessing. +""" + +from __future__ import annotations + +from sqlalchemy import or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from shared.models.database.document import DocumentSection +from shared.services.retrieval.search.lexical_text import normalize_section_path + + +def paths_matching_section_ref(normalized: str, candidate_paths: list[str]) -> list[str]: + """Return paths that equal ``normalized`` or end with `` / {normalized}``.""" + if not normalized: + return [] + suffix = f" / {normalized}" + matches = [ + path + for path in candidate_paths + if path == normalized or (normalized != "Root" and path.endswith(suffix)) + ] + return sorted(dict.fromkeys(matches)) + + +def format_ambiguous_section_path_error(normalized: str, matches: list[str]) -> str: + return ( + f"ambiguous section_path {normalized!r}: matches {len(matches)} sections — " + "use the full path from outline/grep/refs: " + + "; ".join(matches) + ) + + +def section_path_anchor_filter(resolved_path: str): + """SQL filter for one section (``mode=self`` anchor).""" + return DocumentSection.section_path == resolved_path + + +def section_path_subtree_filter(resolved_path: str): + """SQL filter for a section and all descendants (``mode=descendants``).""" + return or_( + DocumentSection.section_path == resolved_path, + DocumentSection.section_path.like(f"{resolved_path} / %"), + ) + + +async def resolve_section_path_anchor( + db: AsyncSession, + *, + document_id: str, + job_result_id: str, + section_path: str, +) -> tuple[str | None, str | None]: + """Resolve one canonical ``section_path`` or return ``(None, error)``.""" + normalized = normalize_section_path(section_path) + base = ( + select(DocumentSection.section_path) + .where(DocumentSection.document_id == document_id) + .where(DocumentSection.job_result_id == job_result_id) + ) + + exact_row = (await db.execute(base.where(DocumentSection.section_path == normalized))).first() + if exact_row is not None and exact_row[0]: + return str(exact_row[0]), None + + suffix_filter = or_( + DocumentSection.section_path == normalized, + DocumentSection.section_path.like(f"% / {normalized}"), + ) + suffix_rows = ( + await db.execute(base.where(suffix_filter).order_by(DocumentSection.sort_order)) + ).all() + matches = paths_matching_section_ref( + normalized, [str(row[0]) for row in suffix_rows if row and row[0]] + ) + if not matches: + return None, f"unknown section_path for {document_id}: {normalized}" + if len(matches) > 1: + return None, format_ambiguous_section_path_error(normalized, matches) + return matches[0], None diff --git a/packages/shared-python/shared/services/retrieval/agent_tools/tools/read.py b/packages/shared-python/shared/services/retrieval/agent_tools/tools/read.py index dd1f09e3..f5f99117 100644 --- a/packages/shared-python/shared/services/retrieval/agent_tools/tools/read.py +++ b/packages/shared-python/shared/services/retrieval/agent_tools/tools/read.py @@ -15,6 +15,11 @@ SAME-AS marker (a different leaf's page), it is not recursively resolved in this pass — a disclosed scope limit, not a silent gap (the raw marker stays visible in the embedded text). + +``section_path`` refs are resolved via ``agent_tools.section_path_lookup``: +exact match first, then a unique segment-bound suffix match when the agent +omits ancestor segments; ambiguous suffix matches return an error listing +candidate full paths instead of picking one silently. """ from __future__ import annotations @@ -22,7 +27,7 @@ import re from typing import Any -from sqlalchemy import or_, select +from sqlalchemy import select from shared.models.database.document import ( Document, @@ -45,10 +50,12 @@ _image_display_content, ) from shared.services.retrieval.hydration.row_utils import normalize_chunk_type -from shared.services.retrieval.search.lexical_text import ( - normalize_section_path, - section_path_from_chunk_path, +from shared.services.retrieval.agent_tools.section_path_lookup import ( + resolve_section_path_anchor, + section_path_anchor_filter, + section_path_subtree_filter, ) +from shared.services.retrieval.search.lexical_text import section_path_from_chunk_path _SAME_AS_MARKER_RE = re.compile(r"\[SAME-AS (.+?) p(\d+)\]") _BODY_CHUNK_TYPES = ("text", "page") @@ -256,12 +263,21 @@ async def read(ctx: ToolContext, args: dict[str, Any]) -> ToolResult: errors.append(f"ref for {document_id} needs section_path or chunk_id") continue - normalized = normalize_section_path(section_path) - path_filter = DocumentSection.section_path == normalized - if mode == "descendants": - path_filter = or_( - path_filter, DocumentSection.section_path.like(f"{normalized} / %") - ) + resolved_path, path_error = await resolve_section_path_anchor( + ctx.db, + document_id=document_id, + job_result_id=job_result_id, + section_path=section_path, + ) + if path_error or not resolved_path: + errors.append(path_error or f"unknown section_path for {document_id}") + continue + + path_filter = ( + section_path_subtree_filter(resolved_path) + if mode == "descendants" + else section_path_anchor_filter(resolved_path) + ) section_rows = ( ( await ctx.db.execute( @@ -275,9 +291,6 @@ async def read(ctx: ToolContext, args: dict[str, Any]) -> ToolResult: .scalars() .all() ) - if not section_rows: - errors.append(f"unknown section_path for {document_id}: {normalized}") - continue section_ids = [s.section_id for s in section_rows] chunk_rows = ( await ctx.db.execute( diff --git a/packages/shared-python/shared/services/retrieval/agent_tools/tools/recall.py b/packages/shared-python/shared/services/retrieval/agent_tools/tools/recall.py index a4c7f61d..57523885 100644 --- a/packages/shared-python/shared/services/retrieval/agent_tools/tools/recall.py +++ b/packages/shared-python/shared/services/retrieval/agent_tools/tools/recall.py @@ -239,6 +239,18 @@ async def recall(ctx: ToolContext, args: dict[str, Any]) -> ToolResult: fused = merge_channels_rrf(channel_rows, weights, top_k) lines = [f"candidates={len(fused)}"] + if len(fused) < 2: + # No tool name named here on purpose — this fires on *every* weak + # recall regardless of what a better next step happens to be for + # this corpus/query, so it nudges the agent to change approach + # without prescribing which other tool to reach for (that's already + # covered generically in CORPUS_SCHEMA.md §6's tool-selection table). + lines.append( + "note: few or no candidates for this phrasing — rephrasing the " + "query and calling recall again rarely surfaces more; a " + "different exploration approach is more likely to help than " + "repeating recall with synonyms." + ) if reserved_requested: lines.append(f"note: channels {sorted(reserved_requested)} are reserved, not run") if requested_top_k > top_k: diff --git a/packages/shared-python/shared/tests/test_section_path_lookup.py b/packages/shared-python/shared/tests/test_section_path_lookup.py new file mode 100644 index 00000000..037d7e1f --- /dev/null +++ b/packages/shared-python/shared/tests/test_section_path_lookup.py @@ -0,0 +1,39 @@ +"""Pure tests for agent_tools section_path suffix resolution.""" + +from __future__ import annotations + +from shared.services.retrieval.agent_tools.section_path_lookup import ( + format_ambiguous_section_path_error, + paths_matching_section_ref, +) + + +def test_paths_matching_section_ref_exact() -> None: + paths = ["附件目录 / 1.1 概述", "综合说明 / 1.1 概述"] + assert paths_matching_section_ref("附件目录 / 1.1 概述", paths) == ["附件目录 / 1.1 概述"] + + +def test_paths_matching_section_ref_suffix() -> None: + paths = [ + "附件目录 / 3 工程地质 / 3.2 覆盖层", + "综合说明 / 3 工程地质 / 3.2 覆盖层", + ] + assert paths_matching_section_ref("3 工程地质 / 3.2 覆盖层", paths) == sorted(paths) + + +def test_paths_matching_section_ref_no_substring_false_positive() -> None: + paths = ["附件目录 / 3.2 覆盖层处理", "附件目录 / 13.2 覆盖层"] + assert paths_matching_section_ref("3.2 覆盖层", paths) == [] + + +def test_paths_matching_section_ref_top_level() -> None: + paths = ["附件目录", "附件目录 / 1.1 概述"] + assert paths_matching_section_ref("附件目录", paths) == ["附件目录"] + + +def test_format_ambiguous_section_path_error_lists_candidates() -> None: + matches = ["A / X", "B / X"] + msg = format_ambiguous_section_path_error("X", matches) + assert "ambiguous section_path 'X'" in msg + assert "A / X" in msg + assert "B / X" in msg From 2531c0fc81cc74753d69af110f091d83cad1c2c2 Mon Sep 17 00:00:00 2001 From: chengke <404835780@qq.com> Date: Tue, 8 Sep 2026 21:16:45 +0800 Subject: [PATCH 6/6] feat(retrieval): default to agent_explore and retire map-nav Archive map-nav out of the live path, keep shared scoring for classic BM25 and corpus.recall, drop the FTS fallback and unused chunk tsvector columns, and use cursor_sdk unless use_agentic is false. Co-authored-by: Cursor --- AGENTS.md | 35 +- apps/api/.env.example | 8 +- apps/api/alembic/env.py | 23 +- ...5a6b7c8d9_drop_chunk_search_tsv_columns.py | 55 +++ apps/api/app/api/v1/routes/retrieval.py | 4 +- apps/api/main.py | 12 + apps/api/pyproject.toml | 1 + .../scripts/backfill_map_unit_statistics.py | 2 +- ...est_retrieval_classic_map_unit_contract.py | 381 +++++++++--------- .../tests/contract/test_retrieval_contract.py | 298 +++++++------- apps/worker/.env.example | 8 +- apps/worker/pyproject.toml | 6 +- .../worker/scripts/run_agentic_router_eval.py | 126 +++--- deploy/ecs/task-definition-api.staging.json | 3 +- .../ecs/task-definition-worker.staging.json | 3 +- deprecated/mapnav/README.md | 26 ++ .../mapnav}/nav/__init__.py | 0 .../mapnav}/nav/_compat.py | 0 .../mapnav}/nav/nav_actions.py | 0 .../mapnav}/nav/nav_address.py | 0 .../mapnav}/nav/nav_agent.py | 0 .../mapnav}/nav/nav_assets.py | 0 .../mapnav}/nav/nav_compose.py | 0 .../mapnav}/nav/nav_control.py | 0 .../mapnav}/nav/nav_harvest.py | 0 deprecated/mapnav/nav/nav_hierarchy.py | 111 +++++ .../mapnav}/nav/nav_knowhere.py | 368 +---------------- .../mapnav}/nav/nav_llm.py | 0 .../mapnav}/nav/nav_map_scores.py | 271 +------------ .../mapnav}/nav/nav_navigate.py | 0 .../mapnav}/nav/nav_node_filter.py | 82 +--- .../mapnav}/nav/nav_orchestrate.py | 0 .../mapnav}/nav/nav_plan.py | 0 .../mapnav}/nav/nav_policy.py | 0 .../mapnav}/nav/nav_projection.py | 0 .../mapnav}/nav/nav_scope_filter.py | 3 +- .../mapnav}/nav/nav_token_budget.py | 0 .../mapnav}/nav/nav_types.py | 0 .../mapnav}/nav/nav_verify.py | 0 .../mapnav}/nav_bridge.py | 0 .../mapnav}/nav_config.py | 0 .../mapnav}/nav_llm_backend.py | 0 .../mapnav}/nav_snapshot.py | 6 +- ...etrieval_lazy_snapshot_quality_contract.py | 14 +- .../test_retrieval_lazy_tree_contract.py | 6 +- ...est_retrieval_map_score_parity_contract.py | 0 .../test_retrieval_map_unit_index_contract.py | 14 +- .../test_retrieval_mapnav_session_contract.py | 2 +- ...test_retrieval_relit_map_cache_contract.py | 0 ...st_retrieval_snapshot_batching_contract.py | 0 ...retrieval_snapshot_consistency_contract.py | 0 ...etrieval_snapshot_large_corpus_contract.py | 0 .../test_retrieval_snapshot_redis_contract.py | 0 .../tests/shared}/test_nav_bridge_config.py | 2 +- .../tests/shared}/test_nav_llm_backend.py | 0 .../tests/shared}/test_nav_node_filter.py | 8 +- .../shared}/test_nav_node_filter_wire.py | 6 +- .../shared}/test_nav_plan_node_filter.py | 0 .../tests/shared}/test_nav_plan_query_only.py | 0 .../tests/shared}/test_nav_projection_prod.py | 4 +- .../tests/shared}/test_nav_scope_filter.py | 9 +- .../mapnav/tests/shared}/test_nav_snapshot.py | 2 +- .../mapnav/tests/shared}/test_nav_stamp.py | 0 .../tests/shared}/test_nav_trace_map.py | 0 .../mapnav/trace_mapnav.py | 0 ...al-heart-vessel-trace-optimization-plan.md | 6 +- ...retrieval-serving-index-rollout-runbook.md | 25 +- docs/design/retrieval-streaming-sse.md | 32 +- .../shared/models/database/document.py | 28 -- .../retrieval/agent_explore/__init__.py | 18 +- .../retrieval/agent_explore/bridge.py | 9 +- .../retrieval/agent_explore/config.py | 23 +- .../agent_explore/harness/cursor_harness.py | 15 +- .../agent_explore/harness/openai_harness.py | 6 +- .../agent_explore/harness/resolve.py | 11 +- .../retrieval/agent_explore/shared.py | 2 +- .../retrieval/agent_tools/registry.py | 2 +- .../agent_tools/tools/node_filter.py | 4 +- .../retrieval/agent_tools/tools/recall.py | 6 +- .../services/retrieval/cache_service.py | 2 + .../retrieval/execution/query_request.py | 4 + .../execution/response_projection.py | 2 +- .../services/retrieval/execution/routes.py | 237 +---------- .../services/retrieval/map_unit_index.py | 12 +- .../services/retrieval/scoring/__init__.py | 1 + .../nav_hierarchy.py => scoring/hierarchy.py} | 129 +----- .../{nav => scoring}/knowhere_hybrid.py | 2 +- .../retrieval/scoring/knowhere_provider.py | 342 ++++++++++++++++ .../scoring/node_filter_predicates.py | 83 ++++ .../{nav => scoring}/persisted_score_load.py | 2 +- .../services/retrieval/scoring/score_units.py | 188 +++++++++ .../retrieval/search/map_unit_discovery.py | 129 +----- .../shared/services/retrieval/settings.py | 5 +- .../services/retrieval/trace/__init__.py | 12 - .../services/retrieval/trace/recorder.py | 5 +- .../tests/test_agent_explore_harness.py | 12 +- .../shared/tests/test_asset_inline.py | 4 +- .../tests/test_knowhere_hybrid_tokenize.py | 2 +- pyproject.toml | 3 +- pytest.ini | 2 + uv.lock | 2 + 101 files changed, 1441 insertions(+), 1825 deletions(-) create mode 100644 apps/api/alembic/versions/e4f5a6b7c8d9_drop_chunk_search_tsv_columns.py create mode 100644 deprecated/mapnav/README.md rename {packages/shared-python/shared/services/retrieval => deprecated/mapnav}/nav/__init__.py (100%) rename {packages/shared-python/shared/services/retrieval => deprecated/mapnav}/nav/_compat.py (100%) rename {packages/shared-python/shared/services/retrieval => deprecated/mapnav}/nav/nav_actions.py (100%) rename {packages/shared-python/shared/services/retrieval => deprecated/mapnav}/nav/nav_address.py (100%) rename {packages/shared-python/shared/services/retrieval => deprecated/mapnav}/nav/nav_agent.py (100%) rename {packages/shared-python/shared/services/retrieval => deprecated/mapnav}/nav/nav_assets.py (100%) rename {packages/shared-python/shared/services/retrieval => deprecated/mapnav}/nav/nav_compose.py (100%) rename {packages/shared-python/shared/services/retrieval => deprecated/mapnav}/nav/nav_control.py (100%) rename {packages/shared-python/shared/services/retrieval => deprecated/mapnav}/nav/nav_harvest.py (100%) create mode 100644 deprecated/mapnav/nav/nav_hierarchy.py rename {packages/shared-python/shared/services/retrieval => deprecated/mapnav}/nav/nav_knowhere.py (73%) rename {packages/shared-python/shared/services/retrieval => deprecated/mapnav}/nav/nav_llm.py (100%) rename {packages/shared-python/shared/services/retrieval => deprecated/mapnav}/nav/nav_map_scores.py (52%) rename {packages/shared-python/shared/services/retrieval => deprecated/mapnav}/nav/nav_navigate.py (100%) rename {packages/shared-python/shared/services/retrieval => deprecated/mapnav}/nav/nav_node_filter.py (70%) rename {packages/shared-python/shared/services/retrieval => deprecated/mapnav}/nav/nav_orchestrate.py (100%) rename {packages/shared-python/shared/services/retrieval => deprecated/mapnav}/nav/nav_plan.py (100%) rename {packages/shared-python/shared/services/retrieval => deprecated/mapnav}/nav/nav_policy.py (100%) rename {packages/shared-python/shared/services/retrieval => deprecated/mapnav}/nav/nav_projection.py (100%) rename {packages/shared-python/shared/services/retrieval => deprecated/mapnav}/nav/nav_scope_filter.py (99%) rename {packages/shared-python/shared/services/retrieval => deprecated/mapnav}/nav/nav_token_budget.py (100%) rename {packages/shared-python/shared/services/retrieval => deprecated/mapnav}/nav/nav_types.py (100%) rename {packages/shared-python/shared/services/retrieval => deprecated/mapnav}/nav/nav_verify.py (100%) rename {packages/shared-python/shared/services/retrieval => deprecated/mapnav}/nav_bridge.py (100%) rename {packages/shared-python/shared/services/retrieval => deprecated/mapnav}/nav_config.py (100%) rename {packages/shared-python/shared/services/retrieval => deprecated/mapnav}/nav_llm_backend.py (100%) rename {packages/shared-python/shared/services/retrieval => deprecated/mapnav}/nav_snapshot.py (99%) rename {apps/api/tests/contract => deprecated/mapnav/tests/api-contract}/test_retrieval_lazy_snapshot_quality_contract.py (97%) rename {apps/api/tests/contract => deprecated/mapnav/tests/api-contract}/test_retrieval_lazy_tree_contract.py (93%) rename {apps/api/tests/contract => deprecated/mapnav/tests/api-contract}/test_retrieval_map_score_parity_contract.py (100%) rename {apps/api/tests/contract => deprecated/mapnav/tests/api-contract}/test_retrieval_map_unit_index_contract.py (99%) rename {apps/api/tests/contract => deprecated/mapnav/tests/api-contract}/test_retrieval_mapnav_session_contract.py (98%) rename {apps/api/tests/contract => deprecated/mapnav/tests/api-contract}/test_retrieval_relit_map_cache_contract.py (100%) rename {apps/api/tests/contract => deprecated/mapnav/tests/api-contract}/test_retrieval_snapshot_batching_contract.py (100%) rename {apps/api/tests/contract => deprecated/mapnav/tests/api-contract}/test_retrieval_snapshot_consistency_contract.py (100%) rename {apps/api/tests/contract => deprecated/mapnav/tests/api-contract}/test_retrieval_snapshot_large_corpus_contract.py (100%) rename {apps/api/tests/contract => deprecated/mapnav/tests/api-contract}/test_retrieval_snapshot_redis_contract.py (100%) rename {packages/shared-python/shared/tests => deprecated/mapnav/tests/shared}/test_nav_bridge_config.py (98%) rename {packages/shared-python/shared/tests => deprecated/mapnav/tests/shared}/test_nav_llm_backend.py (100%) rename {packages/shared-python/shared/tests => deprecated/mapnav/tests/shared}/test_nav_node_filter.py (93%) rename {packages/shared-python/shared/tests => deprecated/mapnav/tests/shared}/test_nav_node_filter_wire.py (97%) rename {packages/shared-python/shared/tests => deprecated/mapnav/tests/shared}/test_nav_plan_node_filter.py (100%) rename {packages/shared-python/shared/tests => deprecated/mapnav/tests/shared}/test_nav_plan_query_only.py (100%) rename {packages/shared-python/shared/tests => deprecated/mapnav/tests/shared}/test_nav_projection_prod.py (95%) rename {packages/shared-python/shared/tests => deprecated/mapnav/tests/shared}/test_nav_scope_filter.py (95%) rename {packages/shared-python/shared/tests => deprecated/mapnav/tests/shared}/test_nav_snapshot.py (97%) rename {packages/shared-python/shared/tests => deprecated/mapnav/tests/shared}/test_nav_stamp.py (100%) rename {packages/shared-python/shared/tests => deprecated/mapnav/tests/shared}/test_nav_trace_map.py (100%) rename packages/shared-python/shared/services/retrieval/trace/mapnav.py => deprecated/mapnav/trace_mapnav.py (100%) create mode 100644 packages/shared-python/shared/services/retrieval/scoring/__init__.py rename packages/shared-python/shared/services/retrieval/{nav/nav_hierarchy.py => scoring/hierarchy.py} (75%) rename packages/shared-python/shared/services/retrieval/{nav => scoring}/knowhere_hybrid.py (99%) create mode 100644 packages/shared-python/shared/services/retrieval/scoring/knowhere_provider.py create mode 100644 packages/shared-python/shared/services/retrieval/scoring/node_filter_predicates.py rename packages/shared-python/shared/services/retrieval/{nav => scoring}/persisted_score_load.py (96%) create mode 100644 packages/shared-python/shared/services/retrieval/scoring/score_units.py diff --git a/AGENTS.md b/AGENTS.md index 503a16ad..fa13a28f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -103,13 +103,12 @@ flowchart TB subgraph RETRIEVE["⑤ Retrieval (shared)"] Query["POST /v1|/v2 retrieval/query"] --> Pipeline["run_retrieval_query"] Pipeline --> Classic["classic_topk / small_corpus (use_agentic=False)"] - Pipeline --> MapNav["mapnav checklist (default / use_agentic≠False)"] + Pipeline --> Explore["agent_explore + cursor_sdk (default / use_agentic≠False)"] Classic --> Channels["map_unit_discovery: path+content BM25 -> RRF"] Channels --> Rank["rank_retrieval_candidates"] - MapNav --> NavSnap["nav_snapshot + run_nav_episode"] - NavSnap --> Bridge["nav_bridge referenced_chunks"] + Explore --> Tools["corpus.* tools + harness.run_episode"] Rank --> Assemble["assemble_retrieval_results"] - Bridge --> Assemble + Tools --> Assemble Assemble --> Results["Cited Evidence Results"] end ``` @@ -493,8 +492,6 @@ debug CSVs (`preds_*.csv`) are saved alongside for troubleshooting. | `content_search_text` | `Text` | Pre-tokenized for BM25 content channel | | `path_search_text` | `Text` | Pre-tokenized for BM25 path channel | | `term_search_text` | `Text` | Pre-tokenized for term/grep channel | -| `content_search_tsv` | `TSVECTOR` (computed) | PostgreSQL GIN index for full-text | -| `path_search_tsv` | `TSVECTOR` (computed) | PostgreSQL GIN index for path | | `source_chunk_path` | `Text` | Original parser path | | `file_path` | `Text` | Asset reference (`images/x.jpg`) | | `chunk_metadata` | `JSON` | Keywords, tokens, connect_to, etc. | @@ -545,23 +542,23 @@ debug CSVs (`preds_*.csv`) are saved alongside for troubleshooting. Core retrieval internals are grouped by ownership: -- `execution/`: request shaping, route selection (classic / mapnav / small_corpus), and public response projection. -- `search/`: `map_unit_discovery` (persisted map-unit BM25 discovery, with a legacy chunk-level PG FTS fallback), scoring, section filters, candidate ranking. +- `execution/`: request shaping, route selection (classic / agent_explore / small_corpus), and public response projection. +- `search/`: `map_unit_discovery` (persisted map-unit BM25 discovery; incomplete or incompatible indexes raise), scoring, section filters, candidate ranking. - `hydration/`: row/path/reference hydration, inline assets, and result assembly. -- `nav/` + `nav_*.py`: map-nav checklist episode (PLANNER / HARVEST / CONTROL). +- `agent_explore/` + `agent_tools/`: default agentic route (cursor_sdk harness). Map-nav is archived under `deprecated/mapnav/`. - `trace/`: `DecisionTraceStep` mapping and `TraceRecorder`. - `graph/`: document graph publication/query support. - `stats/`: retrieval hit recording. ### Two Retrieval Modes -Per-request `use_agentic`: `False` → classic top-K (map-unit BM25); `None`/`True` → map-nav (default). +Per-request `use_agentic`: `False` → classic top-K (map-unit BM25); `None`/`True` → agent_explore (default harness `cursor_sdk`). -#### Classic Mode (map-unit BM25 + legacy FTS fallback) +#### Classic Mode (map-unit BM25) Primary path is `search.map_unit_discovery.map_unit_discovery`: Python BM25Okapi over the persisted `document_map_unit_tokens` index, path and content channels -only, fused by RRF. +only, fused by RRF. An incomplete or incompatible map-unit index raises. ```mermaid flowchart LR @@ -577,15 +574,15 @@ flowchart LR `score = weight / (k + rank + 1)` per channel, summed across channels, `k=60`. There is no scored term channel in this primary path. `term_search_text` / -`term_search_text_lower` are persisted at publish time but are only read by -the **legacy fallback** (`_legacy_chunk_discovery`), which runs only when a -revision's map-unit index is missing or incomplete: a single SQL query -scoring `GREATEST(ts_rank_cd(path_search_tsv), 2 * ts_rank_cd(content_search_tsv))` -OR `term_search_text LIKE '%query%'`, not three independently-ranked channels. +`term_search_text_lower` are persisted at publish time and are read by +`corpus.recall`'s term channel, not by classic discovery. -#### Map-nav Mode (default) +#### Agent-explore Mode (default) -Default agentic path is checklist map-nav (`nav/`): PLANNER (`plan_query`) → HARVEST (`execute_plan` / `harvest`, recursive DISPATCH) → CONTROL (`plan_control`). Episode config lives in `nav_config.py`. Exit bridge expands kept chunks to `referenced_chunks`; `decision_trace` is mapped in `trace/mapnav.py`. Token hard-stop uses `NavConfig.token_limit`. +Default agentic path is `agent_explore`: a `corpus.*` tool loop run by the +resolved harness (`AGENT_EXPLORE_HARNESS`, default `cursor_sdk`). Finish refs +are resolved to chunks, then assembled like classic. Map-nav is archived at +`deprecated/mapnav/` and is no longer a live route. ### Result Assembly diff --git a/apps/api/.env.example b/apps/api/.env.example index ef00d0ff..56743634 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -100,8 +100,12 @@ ARK_API_KEY= # Optional retrieval overrides have code defaults. Retrieval is evidence-only: # evidence_text is the primary output and answer_text is always empty. -# Default path is map-nav (PLANNER+HARVEST+CONTROL); set use_agentic=false for -# classic 3-channel RRF. Classic BM25 may use Postgres FTS prefilter: +# Default path is agent_explore (AGENT_EXPLORE_HARNESS=cursor_sdk). +# Set use_agentic=false for classic map-unit BM25. +# AGENT_EXPLORE_HARNESS=cursor_sdk +# CURSOR_API_KEY= # required when harness is cursor_sdk +# AGENT_EXPLORE_CURSOR_MODEL=composer-2.5 +# Classic BM25 may use Postgres FTS prefilter: # RETRIEVAL_POSTGRES_FTS_CANDIDATE_LIMIT=2000 # File handling defaults diff --git a/apps/api/alembic/env.py b/apps/api/alembic/env.py index 8e3a5d5a..021cc507 100644 --- a/apps/api/alembic/env.py +++ b/apps/api/alembic/env.py @@ -40,21 +40,6 @@ "session", } ) -_AUTOGENERATE_IGNORED_COLUMNS: frozenset[tuple[str, str]] = frozenset( - { - ("document_chunks", "content_search_tsv"), - ("document_chunks", "path_search_tsv"), - } -) - - -def _resolve_table_name(object_: object, compare_to: object | None) -> str | None: - for candidate in (object_, compare_to): - table = getattr(candidate, "table", None) - table_name = getattr(table, "name", None) - if isinstance(table_name, str): - return table_name - return None def include_object( @@ -64,14 +49,10 @@ def include_object( reflected: bool, compare_to: object | None, ) -> bool: - """Exclude externally managed auth tables and generated TSV columns.""" - del reflected + """Exclude externally managed auth tables.""" + del object_, compare_to, reflected if type_ == "table" and isinstance(name, str) and name in _EXTERNALLY_MANAGED_TABLES: return False - if type_ == "column" and isinstance(name, str): - table_name = _resolve_table_name(object_, compare_to) - if table_name is not None and (table_name, name) in _AUTOGENERATE_IGNORED_COLUMNS: - return False return True diff --git a/apps/api/alembic/versions/e4f5a6b7c8d9_drop_chunk_search_tsv_columns.py b/apps/api/alembic/versions/e4f5a6b7c8d9_drop_chunk_search_tsv_columns.py new file mode 100644 index 00000000..94316d6e --- /dev/null +++ b/apps/api/alembic/versions/e4f5a6b7c8d9_drop_chunk_search_tsv_columns.py @@ -0,0 +1,55 @@ +"""Drop unused PostgreSQL FTS columns on document_chunks. + +Retrieval no longer reads ``content_search_tsv`` / ``path_search_tsv``. +They were generated from ``content_search_text`` / ``path_search_text`` and +only served the retired FTS fallback. +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from alembic import op + + +revision: str = "e4f5a6b7c8d9" +down_revision: str | None = "d3e4f5a6b7c8" +branch_labels: Sequence[str] | None = None +depends_on: Sequence[str] | None = None + +__all__ = [ + "revision", + "down_revision", + "branch_labels", + "depends_on", + "upgrade", + "downgrade", +] + + +def upgrade() -> None: + op.execute("DROP INDEX IF EXISTS idx_chunk_path_search_tsv") + op.execute("DROP INDEX IF EXISTS idx_chunk_content_search_tsv") + op.execute("ALTER TABLE document_chunks DROP COLUMN IF EXISTS path_search_tsv") + op.execute("ALTER TABLE document_chunks DROP COLUMN IF EXISTS content_search_tsv") + + +def downgrade() -> None: + op.execute( + "ALTER TABLE document_chunks ADD COLUMN content_search_tsv TSVECTOR " + "GENERATED ALWAYS AS (to_tsvector('simple', COALESCE(content_search_text, ''))) " + "STORED" + ) + op.execute( + "ALTER TABLE document_chunks ADD COLUMN path_search_tsv TSVECTOR " + "GENERATED ALWAYS AS (to_tsvector('simple', COALESCE(path_search_text, ''))) " + "STORED" + ) + op.execute( + "CREATE INDEX IF NOT EXISTS idx_chunk_content_search_tsv " + "ON document_chunks USING GIN (content_search_tsv)" + ) + op.execute( + "CREATE INDEX IF NOT EXISTS idx_chunk_path_search_tsv " + "ON document_chunks USING GIN (path_search_tsv)" + ) diff --git a/apps/api/app/api/v1/routes/retrieval.py b/apps/api/app/api/v1/routes/retrieval.py index c7d79595..5d84f57f 100644 --- a/apps/api/app/api/v1/routes/retrieval.py +++ b/apps/api/app/api/v1/routes/retrieval.py @@ -80,8 +80,8 @@ class RetrievalQueryRequest(BaseModel): use_agentic: bool | None = Field( None, description=( - "Map-nav (PLANNER+HARVEST+CONTROL) is the default when unset/true. " - "Set false to force classic 3-channel top-K retrieval." + "Agent explore (cursor_sdk harness by default) when unset/true. " + "Set false to force classic map-unit BM25 top-K retrieval." ), ) conversation_id: str | None = Field( diff --git a/apps/api/main.py b/apps/api/main.py index 0af144c8..16912958 100644 --- a/apps/api/main.py +++ b/apps/api/main.py @@ -104,6 +104,18 @@ async def _redis_ping() -> bool: mcp_server = getattr(app.state, "retrieval_mcp_server", None) mcp_session_manager = getattr(mcp_server, "session_manager", None) + from shared.services.retrieval.agent_explore.harness.resolve import ( + resolve_harness_name, + ) + + if resolve_harness_name() == "cursor_sdk" and not os.environ.get( + "CURSOR_API_KEY", "" + ).strip(): + logger.error( + "Default retrieval harness is cursor_sdk but CURSOR_API_KEY is unset; " + "agentic retrieval requests will fail until the key is set" + ) + logger.info("Document API service started!") if mcp_session_manager is not None: async with mcp_session_manager.run(): diff --git a/apps/api/pyproject.toml b/apps/api/pyproject.toml index cb8aaf85..fb67b582 100644 --- a/apps/api/pyproject.toml +++ b/apps/api/pyproject.toml @@ -23,6 +23,7 @@ dependencies = [ "limits>=5.8,<6", "logfire[celery,fastapi,httpx,sqlalchemy]>=4.25.0", "mcp>=1.27.0", + "cursor-sdk>=1.0.31", ] [dependency-groups] diff --git a/apps/api/scripts/backfill_map_unit_statistics.py b/apps/api/scripts/backfill_map_unit_statistics.py index 86214ef7..1ae6ea79 100644 --- a/apps/api/scripts/backfill_map_unit_statistics.py +++ b/apps/api/scripts/backfill_map_unit_statistics.py @@ -51,7 +51,7 @@ def _bootstrap_python_path() -> None: DocumentMapUnit, DocumentMapUnitIndex, ) -from shared.services.retrieval.nav.knowhere_hybrid import MAP_UNIT_INDEX_FORMAT_VERSION +from shared.services.retrieval.scoring.knowhere_hybrid import MAP_UNIT_INDEX_FORMAT_VERSION @dataclass(frozen=True) diff --git a/apps/api/tests/contract/test_retrieval_classic_map_unit_contract.py b/apps/api/tests/contract/test_retrieval_classic_map_unit_contract.py index fe5b732c..d42148d8 100644 --- a/apps/api/tests/contract/test_retrieval_classic_map_unit_contract.py +++ b/apps/api/tests/contract/test_retrieval_classic_map_unit_contract.py @@ -6,10 +6,11 @@ from uuid import uuid4 import pytest -from httpx import AsyncClient, Response +from httpx import AsyncClient from sqlalchemy import Engine, event, select from shared.models.database.document import DocumentMapUnit +from shared.services.retrieval.hydration.connected import hydrate_connected_target_rows from shared.services.retrieval.publication_content import ( replace_document_revision_content, ) @@ -395,100 +396,62 @@ async def test_classic_discovery_returns_empty_for_an_empty_revision_pin( assert result.payload["fused_rows"] == [] -async def test_classic_route_falls_back_for_v1_index_with_excluded_document( +async def test_classic_route_raises_for_v1_index( developer_api_client_factory: Callable[ [], AbstractAsyncContextManager[AsyncClient] ], ) -> None: identifier = uuid4().hex[:8] - namespace = f"classic-v1-fallback-{identifier}" - legacy_queries: list[str] = [] - - def capture_legacy_query( - _connection: Any, - _cursor: Any, - statement: str, - _parameters: Any, - _context: Any, - _executemany: bool, - ) -> None: - if "plainto_tsquery('simple'" in statement: - legacy_queries.append(statement) - - event.listen(Engine, "before_cursor_execute", capture_legacy_query) - try: - async with developer_api_client_factory() as api_client: - first = await _publish_document( - namespace=namespace, - source_file_name="legacy-fallback.pdf", - chunks=[ - { - "chunk_id": f"legacy-hit-{identifier}", - "type": "text", - "content": "legacy fallback marker", - "path": "legacy-fallback.pdf/Root/Section/body", - "order": 1, - "metadata": {}, - }, - { - "chunk_id": f"legacy-filler-a-{identifier}", - "type": "text", - "content": "unrelated legacy filler a", - "path": "legacy-fallback.pdf/Root/Section/a", - "order": 2, - "metadata": {}, - }, - { - "chunk_id": f"legacy-filler-b-{identifier}", - "type": "text", - "content": "unrelated legacy filler b", - "path": "legacy-fallback.pdf/Root/Section/b", - "order": 3, - "metadata": {}, - }, - ], - ) - excluded = await _publish_document( - namespace=namespace, - source_file_name="excluded.pdf", - chunks=[ - { - "chunk_id": f"excluded-{identifier}", - "type": "text", - "content": "unrelated filler", - "path": "excluded.pdf/Root/Section/body", - "order": 1, - "metadata": {}, - } - ], - ) - await ContractDatabase.execute( - """ - UPDATE document_map_unit_indexes - SET format_version = 1 - WHERE document_id = :document_id - """, - {"document_id": first["document_id"]}, - ) - response = await api_client.post( + namespace = f"classic-v1-raise-{identifier}" + async with developer_api_client_factory() as api_client: + first = await _publish_document( + namespace=namespace, + source_file_name="legacy-index.pdf", + chunks=[ + { + "chunk_id": f"legacy-hit-{identifier}", + "type": "text", + "content": "legacy index marker", + "path": "legacy-index.pdf/Root/Section/body", + "order": 1, + "metadata": {}, + }, + { + "chunk_id": f"legacy-filler-a-{identifier}", + "type": "text", + "content": "unrelated filler a", + "path": "legacy-index.pdf/Root/Section/a", + "order": 2, + "metadata": {}, + }, + { + "chunk_id": f"legacy-filler-b-{identifier}", + "type": "text", + "content": "unrelated filler b", + "path": "legacy-index.pdf/Root/Section/b", + "order": 3, + "metadata": {}, + }, + ], + ) + await ContractDatabase.execute( + """ + UPDATE document_map_unit_indexes + SET format_version = 1 + WHERE document_id = :document_id + """, + {"document_id": first["document_id"]}, + ) + with pytest.raises(RuntimeError, match="map-unit index is incomplete"): + await api_client.post( "/api/v1/retrieval/query", json={ "namespace": namespace, - "query": "legacy fallback marker", + "query": "legacy index marker", "top_k": 1, "use_agentic": False, - "exclude_document_ids": [excluded["document_id"]], }, ) - finally: - event.remove(Engine, "before_cursor_execute", capture_legacy_query) - - assert response.status_code == 200 - body = cast(dict[str, object], response.json()) - results = cast(list[dict[str, object]], body["results"]) - assert len(results) == 1 - assert results[0]["chunk_id"] == f"legacy-hit-{identifier}" - assert legacy_queries async def test_classic_discovery_preserves_results_before_statistics_backfill( @@ -604,133 +567,95 @@ def result_signature(result: DiscoveryResult) -> list[tuple[Any, ...]]: @pytest.mark.parametrize( "incomplete_index_kind", ["legacy_format", "missing_index", "missing_tokens"] ) -async def test_unfiltered_classic_route_falls_back_when_selective_rows_are_unavailable( +async def test_unfiltered_classic_route_raises_when_index_is_unusable( developer_api_client_factory: Callable[ [], AbstractAsyncContextManager[AsyncClient] ], incomplete_index_kind: str, ) -> None: identifier: str = uuid4().hex[:8] - namespace: str = f"classic-token-fallback-{incomplete_index_kind}-{identifier}" - legacy_queries: list[str] = [] - - def capture_legacy_query( - _connection: object, - _cursor: object, - statement: str, - _parameters: object, - _context: object, - _executemany: bool, - ) -> None: - if "plainto_tsquery('simple'" in statement: - legacy_queries.append(statement) - - event.listen(Engine, "before_cursor_execute", capture_legacy_query) - try: - async with developer_api_client_factory() as api_client: - document: dict[str, str] = await _publish_document( - namespace=namespace, - source_file_name="legacy-token-hash.pdf", - chunks=[ - { - "chunk_id": f"legacy-token-hit-{identifier}", - "type": "text", - "content": "legacy token fallback marker", - "path": "legacy-token-hash.pdf/Root/Section/body", - "order": 1, - "metadata": {}, - }, - { - "chunk_id": f"legacy-token-filler-a-{identifier}", - "type": "text", - "content": "unrelated legacy filler a", - "path": "legacy-token-hash.pdf/Root/Section/a", - "order": 2, - "metadata": {}, - }, - { - "chunk_id": f"legacy-token-filler-b-{identifier}", - "type": "text", - "content": "unrelated legacy filler b", - "path": "legacy-token-hash.pdf/Root/Section/b", - "order": 3, - "metadata": {}, - }, - ], + namespace: str = f"classic-unusable-{incomplete_index_kind}-{identifier}" + async with developer_api_client_factory() as api_client: + document: dict[str, str] = await _publish_document( + namespace=namespace, + source_file_name="unusable-index.pdf", + chunks=[ + { + "chunk_id": f"unusable-hit-{identifier}", + "type": "text", + "content": "unusable index marker", + "path": "unusable-index.pdf/Root/Section/body", + "order": 1, + "metadata": {}, + }, + { + "chunk_id": f"unusable-filler-a-{identifier}", + "type": "text", + "content": "unrelated filler a", + "path": "unusable-index.pdf/Root/Section/a", + "order": 2, + "metadata": {}, + }, + { + "chunk_id": f"unusable-filler-b-{identifier}", + "type": "text", + "content": "unrelated filler b", + "path": "unusable-index.pdf/Root/Section/b", + "order": 3, + "metadata": {}, + }, + ], + ) + if incomplete_index_kind == "legacy_format": + await ContractDatabase.execute( + """ + UPDATE document_map_unit_indexes + SET format_version = 1 + WHERE document_id = :document_id + """, + {"document_id": document["document_id"]}, ) - if incomplete_index_kind == "legacy_format": - await ContractDatabase.execute( - """ - UPDATE document_map_unit_indexes - SET format_version = 1 + elif incomplete_index_kind == "missing_tokens": + await ContractDatabase.execute( + """ + DELETE FROM document_map_unit_tokens + WHERE map_unit_id IN ( + SELECT id + FROM document_map_units WHERE document_id = :document_id - """, - {"document_id": document["document_id"]}, - ) - await ContractDatabase.execute( - """ - UPDATE document_map_unit_tokens - SET token_hash = :legacy_token_hash - WHERE map_unit_id IN ( - SELECT id - FROM document_map_units - WHERE document_id = :document_id - ) - """, - { - "document_id": document["document_id"], - "legacy_token_hash": "legacy-token-hash", - }, ) - elif incomplete_index_kind == "missing_tokens": - await ContractDatabase.execute( - """ - DELETE FROM document_map_unit_tokens - WHERE map_unit_id IN ( - SELECT id - FROM document_map_units - WHERE document_id = :document_id - ) - """, - {"document_id": document["document_id"]}, - ) - else: - await ContractDatabase.execute( - """ - DELETE FROM document_map_unit_indexes + """, + {"document_id": document["document_id"]}, + ) + else: + await ContractDatabase.execute( + """ + DELETE FROM document_map_unit_indexes + WHERE document_id = :document_id + """, + {"document_id": document["document_id"]}, + ) + await ContractDatabase.execute( + """ + DELETE FROM document_map_unit_tokens + WHERE map_unit_id IN ( + SELECT id + FROM document_map_units WHERE document_id = :document_id - """, - {"document_id": document["document_id"]}, ) - await ContractDatabase.execute( - """ - DELETE FROM document_map_unit_tokens - WHERE map_unit_id IN ( - SELECT id - FROM document_map_units - WHERE document_id = :document_id - ) - """, - {"document_id": document["document_id"]}, - ) - response: Response = await api_client.post( + """, + {"document_id": document["document_id"]}, + ) + with pytest.raises(RuntimeError, match="map-unit index is incomplete"): + await api_client.post( "/api/v1/retrieval/query", json={ "namespace": namespace, - "query": "legacy token fallback marker", + "query": "unusable index marker", "top_k": 1, "use_agentic": False, }, ) - finally: - event.remove(Engine, "before_cursor_execute", capture_legacy_query) - - assert response.status_code == 200 - body = cast(dict[str, object], response.json()) - results = cast(list[dict[str, object]], body["results"]) - assert len(results) == 1 - assert results[0]["chunk_id"] == f"legacy-token-hit-{identifier}" - assert legacy_queries async def test_classic_route_image_filter_scores_only_units_with_images( @@ -856,6 +781,78 @@ async def test_classic_route_image_filter_scores_only_units_with_images( assert results[0]["chunk_type"] == "image" +async def test_connected_hydration_does_not_load_legacy_job_chunks( + developer_api_client_factory: Callable[ + [], AbstractAsyncContextManager[AsyncClient] + ], +) -> None: + identifier = uuid4().hex[:8] + namespace = f"connected-job-{identifier}" + statements: list[str] = [] + + def capture_job_chunk_query( + _connection: Any, + _cursor: Any, + statement: str, + _parameters: Any, + _context: Any, + _executemany: bool, + ) -> None: + if "job_chunks" in statement.lower(): + statements.append(statement) + + async with developer_api_client_factory(): + published = await _publish_document( + namespace=namespace, + source_file_name="connected.pdf", + chunks=[ + { + "chunk_id": "body-connected", + "type": "text", + "content": "body connected evidence", + "path": "connected.pdf/Root/Section/body", + "order": 1, + "metadata": {"connect_to": [{"target": "asset-connected"}]}, + }, + { + "chunk_id": "asset-connected", + "type": "image", + "content": "asset connected summary", + "path": "images/asset-connected.png", + "order": 2, + "file_path": "images/asset-connected.png", + "metadata": {}, + }, + ], + ) + event.listen(Engine, "before_cursor_execute", capture_job_chunk_query) + try: + async with contract_db_session() as db: + hydrated = await hydrate_connected_target_rows( + db=db, + rows=[ + { + "document_id": published["document_id"], + "job_result_id": published["job_result_id"], + "chunk_id": "body-connected", + "chunk_type": "text", + "chunk_metadata": { + "connect_to": [{"target": "asset-connected"}] + }, + } + ], + exclude_document_ids=[], + exclude_sections=[], + revision_pins={published["document_id"]: published["job_result_id"]}, + ) + finally: + event.remove(Engine, "before_cursor_execute", capture_job_chunk_query) + + assert [row["chunk_id"] for row in hydrated] == ["asset-connected"] + assert hydrated[0]["job_id"] == published["job_id"] + assert statements == [] + + def _publish_revision_with_generation_lock( sync_db: Any, *, diff --git a/apps/api/tests/contract/test_retrieval_contract.py b/apps/api/tests/contract/test_retrieval_contract.py index 90dc7e4f..49f676b6 100644 --- a/apps/api/tests/contract/test_retrieval_contract.py +++ b/apps/api/tests/contract/test_retrieval_contract.py @@ -245,20 +245,38 @@ async def test_retrieval_should_use_classic_topk_when_agentic_is_false( [], AbstractAsyncContextManager[AsyncClient] ], ) -> None: + from tests.contract.test_retrieval_classic_map_unit_contract import ( + _publish_document, + ) + async with developer_api_client_factory() as api_client: - await _seed_retrieval_document( - user_id="local-dev-user", + await _publish_document( namespace="contract-agentic-only", source_file_name="a.pdf", - section_path="agentic/a", - content="same ranking marker a", - ) - await _seed_retrieval_document( - user_id="local-dev-user", + chunks=[ + { + "chunk_id": "classic-a", + "type": "text", + "content": "same ranking marker a", + "path": "a.pdf/Root/agentic/a", + "order": 1, + "metadata": {}, + } + ], + ) + await _publish_document( namespace="contract-agentic-only", source_file_name="b.pdf", - section_path="agentic/b", - content="same ranking marker b", + chunks=[ + { + "chunk_id": "classic-b", + "type": "text", + "content": "same ranking marker b", + "path": "b.pdf/Root/agentic/b", + "order": 1, + "metadata": {}, + } + ], ) response = await api_client.post( "/api/v1/retrieval/query", @@ -413,62 +431,59 @@ async def test_should_exclude_matching_sections_from_the_response( -def _episode_keeping_chunks( +def _episode_keeping_refs( *, documents: list[dict[str, str]], - evidence_text: str = "mapnav evidence", + notes: str = "", ) -> Any: - """Build a minimal EpisodeResult whose kept_chunks use real seeded chunk_ids.""" - from shared.services.retrieval.nav._compat import AgentStep, Chunk, EpisodeResult + from shared.services.retrieval.agent_explore.types import AgentStep, EpisodeResult - kept: list[Chunk] = [] - scored: list[tuple[Chunk, float]] = [] + refs: list[dict[str, str]] = [] for doc in documents: - chunk = Chunk( - node_id=doc["chunk_id"], - doc_id=doc["document_id"], - text=str(doc.get("content") or evidence_text), - line_ids=(0,), - section_id=doc.get("section_id"), - ) - kept.append(chunk) - scored.append((chunk, 1.0)) + ref = {"document_id": doc["document_id"]} + if doc.get("chunk_id"): + ref["chunk_id"] = doc["chunk_id"] + if doc.get("section_path"): + ref["section_path"] = doc["section_path"] + refs.append(ref) return EpisodeResult( - representation="mapnav", + refs=refs, + notes=notes, steps=[ AgentStep( - step_idx=1, - action="query_plan", - detail={ - "plan": {"subgoals": [{"id": "s1"}], "coverage_checklist": []}, - "token_limit": 100000, - "tokens_used_total": 1, - "tokens_used_delta": 1, - "elapsed_ms": 1, - }, + step_index=1, + tool_name="finish", + tool_args={"refs": refs}, + observation_text=notes or "done", + error=None, + elapsed_ms=1, + tokens_used_delta=1, + tokens_used_total=1, ) ], - scored_chunks=scored, - kept_chunks=kept, - evidence_text=evidence_text, - evidence_chars_actual=len(evidence_text), - retrieved_nodes=[d["chunk_id"] for d in documents], - stop_reason="completed", + stop_reason="finished", + tokens_used=1, + model_name="test", ) -def _patch_run_nav_episode(monkeypatch: MonkeyPatch, episode: Any) -> None: - def _fake_run_nav_episode(*_args: Any, **_kwargs: Any) -> Any: - return episode +class _FakeHarness: + def __init__(self, episode: Any) -> None: + self._episode = episode + + async def run_episode(self, **_kwargs: Any) -> Any: + return self._episode + +def _patch_harness(monkeypatch: MonkeyPatch, episode: Any) -> None: monkeypatch.setattr( - "shared.services.retrieval.nav.run_nav_episode", - _fake_run_nav_episode, + "shared.services.retrieval.agent_explore.harness.resolve_harness", + lambda: _FakeHarness(episode), ) @pytest.mark.asyncio -async def test_mapnav_retrieval_should_return_seeded_chunk_via_fake_episode( +async def test_agent_explore_retrieval_should_return_seeded_chunk_via_fake_episode( developer_api_client_factory: Callable[ [], AbstractAsyncContextManager[AsyncClient] ], @@ -477,31 +492,30 @@ async def test_mapnav_retrieval_should_return_seeded_chunk_via_fake_episode( async with developer_api_client_factory() as api_client: target = await _seed_retrieval_document( user_id="local-dev-user", - namespace="contract-mapnav-seed", + namespace="contract-explore-seed", source_file_name="target.pdf", section_path="Findings", - content="mapnav seeded EBITDA marker content", + content="explore seeded EBITDA marker content", ) await _seed_retrieval_document( user_id="local-dev-user", - namespace="contract-mapnav-seed", + namespace="contract-explore-seed", source_file_name="filler.pdf", section_path="filler/section", content="unrelated filler content", ) - target_with_content = {**target, "content": "mapnav seeded EBITDA marker content"} - _patch_run_nav_episode( + _patch_harness( monkeypatch, - _episode_keeping_chunks( - documents=[target_with_content], - evidence_text="mapnav seeded EBITDA marker content", + _episode_keeping_refs( + documents=[target], + notes="explore seeded EBITDA marker content", ), ) response = await api_client.post( "/api/v1/retrieval/query", json={ - "namespace": "contract-mapnav-seed", + "namespace": "contract-explore-seed", "query": "EBITDA marker", "top_k": 1, "use_agentic": True, @@ -513,23 +527,17 @@ async def test_mapnav_retrieval_should_return_seeded_chunk_via_fake_episode( referenced_chunks = cast(list[dict[str, object]], response_json["referenced_chunks"]) results = cast(list[dict[str, object]], response_json["results"]) - assert response_json["router_used"] == "mapnav" - assert response_json["stop_reason"] == "completed" + assert response_json["router_used"] == "agent_explore" + assert response_json["stop_reason"] == "finished" assert isinstance(response_json.get("decision_trace"), list) assert response_json["decision_trace"] - assert response_json["decision_trace"][-1]["phase"] == "terminal" - assert { - "chunk_id": target["chunk_id"], - "document_id": target["document_id"], - "chunk_type": "text", - "section_path": target["section_path"], - "file_path": "", - "job_id": target["job_id"], - } in [ - {k: v for k, v in ref.items() if k != "score"} + assert response_json["decision_trace"][-1]["phase"] == "finish" + assert any( + ref.get("chunk_id") == target["chunk_id"] + and ref.get("document_id") == target["document_id"] for ref in referenced_chunks - ] - assert results[0]["content"] == "mapnav seeded EBITDA marker content" + ) + assert results[0]["content"] == "explore seeded EBITDA marker content" assert results[0]["source"] == { "document_id": target["document_id"], "source_file_name": "target.pdf", @@ -538,63 +546,80 @@ async def test_mapnav_retrieval_should_return_seeded_chunk_via_fake_episode( @pytest.mark.asyncio -async def test_mapnav_retrieval_should_not_hydrate_references_outside_request_scope( +async def test_agentic_router_env_mapnav_is_ignored( developer_api_client_factory: Callable[ [], AbstractAsyncContextManager[AsyncClient] ], monkeypatch: MonkeyPatch, ) -> None: + monkeypatch.setenv("RETRIEVAL_AGENTIC_ROUTER", "mapnav") async with developer_api_client_factory() as api_client: + target = await _seed_retrieval_document( + user_id="local-dev-user", + namespace="contract-explore-env", + source_file_name="target.pdf", + section_path="Findings", + content="env ignored content", + ) await _seed_retrieval_document( user_id="local-dev-user", - namespace="contract-mapnav-visible", + namespace="contract-explore-env", + source_file_name="filler.pdf", + section_path="filler/section", + content="unrelated filler content", + ) + _patch_harness( + monkeypatch, + _episode_keeping_refs(documents=[target], notes="env ignored"), + ) + response = await api_client.post( + "/api/v1/retrieval/query", + json={ + "namespace": "contract-explore-env", + "query": "ignored", + "top_k": 1, + "use_agentic": True, + }, + ) + assert response.status_code == 200 + assert cast(dict[str, object], response.json())["router_used"] == "agent_explore" + + +@pytest.mark.asyncio +async def test_agent_explore_should_not_hydrate_references_outside_request_scope( + developer_api_client_factory: Callable[ + [], AbstractAsyncContextManager[AsyncClient] + ], + monkeypatch: MonkeyPatch, +) -> None: + async with developer_api_client_factory() as api_client: + await _seed_retrieval_document( + user_id="local-dev-user", + namespace="contract-explore-visible", source_file_name="visible.pdf", section_path="visible/section", content="visible scoped content", ) await _seed_retrieval_document( user_id="local-dev-user", - namespace="contract-mapnav-visible", + namespace="contract-explore-visible", source_file_name="visible-filler.pdf", section_path="visible/filler", content="visible scoped filler content", ) foreign = await _seed_retrieval_document( user_id="local-dev-user", - namespace="contract-mapnav-foreign", + namespace="contract-explore-foreign", source_file_name="foreign.pdf", section_path="foreign/section", content="foreign scoped content should not leak", ) - - def _fake_bridge(_episode: Any, _snapshot: Any) -> tuple[list[dict[str, Any]], dict[str, float]]: - return ( - [ - { - "chunk_id": foreign["chunk_id"], - "document_id": foreign["document_id"], - "chunk_type": "text", - "section_path": foreign["section_path"], - "file_path": None, - "job_id": foreign["job_id"], - } - ], - {foreign["chunk_id"]: 1.0}, - ) - - _patch_run_nav_episode( - monkeypatch, - _episode_keeping_chunks(documents=[{**foreign, "content": "x"}]), - ) - monkeypatch.setattr( - "shared.services.retrieval.nav_bridge.build_referenced_chunks", - _fake_bridge, - ) + _patch_harness(monkeypatch, _episode_keeping_refs(documents=[foreign])) response = await api_client.post( "/api/v1/retrieval/query", json={ - "namespace": "contract-mapnav-visible", + "namespace": "contract-explore-visible", "query": "visible", "top_k": 1, "use_agentic": True, @@ -603,13 +628,13 @@ def _fake_bridge(_episode: Any, _snapshot: Any) -> tuple[list[dict[str, Any]], d assert response.status_code == 200 response_json = cast(dict[str, object], response.json()) - assert response_json["router_used"] == "mapnav" + assert response_json["router_used"] == "agent_explore" assert response_json["referenced_chunks"] == [] assert response_json["results"] == [] @pytest.mark.asyncio -async def test_mapnav_retrieval_should_drop_references_with_mismatched_section_path( +async def test_agent_explore_should_drop_references_with_mismatched_section_path( developer_api_client_factory: Callable[ [], AbstractAsyncContextManager[AsyncClient] ], @@ -618,47 +643,28 @@ async def test_mapnav_retrieval_should_drop_references_with_mismatched_section_p async with developer_api_client_factory() as api_client: visible = await _seed_retrieval_document( user_id="local-dev-user", - namespace="contract-mapnav-section-mismatch", + namespace="contract-explore-section-mismatch", source_file_name="visible.pdf", section_path="visible/section", content="visible scoped content", ) await _seed_retrieval_document( user_id="local-dev-user", - namespace="contract-mapnav-section-mismatch", + namespace="contract-explore-section-mismatch", source_file_name="filler.pdf", section_path="filler/section", content="filler content", ) - - def _fake_bridge(_episode: Any, _snapshot: Any) -> tuple[list[dict[str, Any]], dict[str, float]]: - return ( - [ - { - "chunk_id": visible["chunk_id"], - "document_id": visible["document_id"], - "chunk_type": "text", - "section_path": "wrong/section/path", - "file_path": None, - "job_id": visible["job_id"], - } - ], - {visible["chunk_id"]: 1.0}, - ) - - _patch_run_nav_episode( - monkeypatch, - _episode_keeping_chunks(documents=[{**visible, "content": "x"}]), - ) - monkeypatch.setattr( - "shared.services.retrieval.nav_bridge.build_referenced_chunks", - _fake_bridge, - ) + mismatched = { + "document_id": visible["document_id"], + "section_path": "wrong/section/path", + } + _patch_harness(monkeypatch, _episode_keeping_refs(documents=[mismatched])) response = await api_client.post( "/api/v1/retrieval/query", json={ - "namespace": "contract-mapnav-section-mismatch", + "namespace": "contract-explore-section-mismatch", "query": "visible", "top_k": 1, "use_agentic": True, @@ -667,13 +673,13 @@ def _fake_bridge(_episode: Any, _snapshot: Any) -> tuple[list[dict[str, Any]], d assert response.status_code == 200 response_json = cast(dict[str, object], response.json()) - assert response_json["router_used"] == "mapnav" + assert response_json["router_used"] == "agent_explore" assert response_json["referenced_chunks"] == [] assert response_json["results"] == [] @pytest.mark.asyncio -async def test_mapnav_retrieval_should_fail_when_final_hydration_db_fails( +async def test_agent_explore_should_fail_when_final_hydration_db_fails( developer_api_client_factory: Callable[ [], AbstractAsyncContextManager[AsyncClient] ], @@ -685,26 +691,21 @@ async def fail_final_hydration(**_kwargs: object) -> object: async with developer_api_client_factory() as api_client: visible = await _seed_retrieval_document( user_id="local-dev-user", - namespace="contract-mapnav-hydration-failure", + namespace="contract-explore-hydration-failure", source_file_name="visible.pdf", section_path="visible/section", content="visible scoped content", ) await _seed_retrieval_document( user_id="local-dev-user", - namespace="contract-mapnav-hydration-failure", + namespace="contract-explore-hydration-failure", source_file_name="filler.pdf", section_path="filler/section", content="filler content", ) from shared.services.retrieval.execution import routes as retrieval_routes - _patch_run_nav_episode( - monkeypatch, - _episode_keeping_chunks( - documents=[{**visible, "content": "visible scoped content"}] - ), - ) + _patch_harness(monkeypatch, _episode_keeping_refs(documents=[visible])) monkeypatch.setattr( retrieval_routes, "resolve_workflow_references", @@ -718,7 +719,7 @@ async def fail_final_hydration(**_kwargs: object) -> object: await api_client.post( "/api/v1/retrieval/query", json={ - "namespace": "contract-mapnav-hydration-failure", + "namespace": "contract-explore-hydration-failure", "query": "visible", "top_k": 1, "use_agentic": True, @@ -727,7 +728,7 @@ async def fail_final_hydration(**_kwargs: object) -> object: @pytest.mark.asyncio -async def test_mapnav_should_preserve_same_chunk_id_across_documents( +async def test_agent_explore_should_preserve_same_chunk_id_across_documents( developer_api_client_factory: Callable[ [], AbstractAsyncContextManager[AsyncClient] ], @@ -738,7 +739,7 @@ async def test_mapnav_should_preserve_same_chunk_id_across_documents( async with developer_api_client_factory() as api_client: first = await _seed_retrieval_document( user_id="local-dev-user", - namespace="contract-mapnav-shared-chunk", + namespace="contract-explore-shared-chunk", source_file_name="first.pdf", section_path="shared/first", content="first shared reference content", @@ -746,34 +747,29 @@ async def test_mapnav_should_preserve_same_chunk_id_across_documents( ) second_doc = await _seed_retrieval_document( user_id="local-dev-user", - namespace="contract-mapnav-shared-chunk", + namespace="contract-explore-shared-chunk", source_file_name="second.pdf", section_path="shared/second-host", content="host content for second document", ) second = await _seed_retrieval_chunk_for_existing_document( user_id="local-dev-user", - namespace="contract-mapnav-shared-chunk", + namespace="contract-explore-shared-chunk", document=second_doc, section_path="shared/second", content="second shared reference content", chunk_id=shared_chunk_id, ) - _patch_run_nav_episode( + _patch_harness( monkeypatch, - _episode_keeping_chunks( - documents=[ - {**first, "content": "first shared reference content"}, - {**second, "content": "second shared reference content"}, - ] - ), + _episode_keeping_refs(documents=[first, second]), ) response = await api_client.post( "/api/v1/retrieval/query", json={ - "namespace": "contract-mapnav-shared-chunk", + "namespace": "contract-explore-shared-chunk", "query": "shared reference", "top_k": 1, "use_agentic": True, @@ -785,7 +781,7 @@ async def test_mapnav_should_preserve_same_chunk_id_across_documents( referenced_chunks = cast(list[dict[str, object]], response_json["referenced_chunks"]) results = cast(list[dict[str, object]], response_json["results"]) - assert response_json["router_used"] == "mapnav" + assert response_json["router_used"] == "agent_explore" assert len(referenced_chunks) == 2 assert {ref["document_id"] for ref in referenced_chunks} == { first["document_id"], diff --git a/apps/worker/.env.example b/apps/worker/.env.example index 46a347c8..3b73cf4f 100644 --- a/apps/worker/.env.example +++ b/apps/worker/.env.example @@ -95,8 +95,12 @@ ARK_API_KEY= # Optional retrieval overrides have code defaults. Retrieval is evidence-only: # evidence_text is the primary output and answer_text is always empty. -# Default path is map-nav (PLANNER+HARVEST+CONTROL); set use_agentic=false for -# classic 3-channel RRF. Classic BM25 may use Postgres FTS prefilter: +# Default path is agent_explore (AGENT_EXPLORE_HARNESS=cursor_sdk). +# Set use_agentic=false for classic map-unit BM25. +# AGENT_EXPLORE_HARNESS=cursor_sdk +# CURSOR_API_KEY= # required when harness is cursor_sdk +# AGENT_EXPLORE_CURSOR_MODEL=composer-2.5 +# Classic BM25 may use Postgres FTS prefilter: # RETRIEVAL_POSTGRES_FTS_CANDIDATE_LIMIT=2000 # Required for specific features: billing and analytics diff --git a/apps/worker/pyproject.toml b/apps/worker/pyproject.toml index 5593b776..8cb35cdb 100644 --- a/apps/worker/pyproject.toml +++ b/apps/worker/pyproject.toml @@ -30,10 +30,8 @@ dependencies = [ "rapidocr-onnxruntime>=1.4.4", ] -# AGENT_EXPLORE_HARNESS=cursor_sdk (shared/services/retrieval/agent_explore/ -# harness/cursor_harness.py). Not in the base dependency set: this harness -# is an alternative to the default OpenAI-compatible one (openai_harness.py), -# not required for it. Install with `uv sync --extra cursor-harness`. +# Production retrieval runs in apps/api, where cursor-sdk is a base +# dependency. This extra is only for worker debug/eval scripts. [project.optional-dependencies] cursor-harness = [ "cursor-sdk>=1.0.31", diff --git a/apps/worker/scripts/run_agentic_router_eval.py b/apps/worker/scripts/run_agentic_router_eval.py index 4f4932dc..752ed091 100644 --- a/apps/worker/scripts/run_agentic_router_eval.py +++ b/apps/worker/scripts/run_agentic_router_eval.py @@ -1,16 +1,14 @@ -"""Phase 4 router comparison: mapnav vs agent_explore on a fixed query set. +"""Smoke-run agent_explore on a fixed query set. Reads ``fixtures/changheba_archive_eval_queries.json`` (sourced from ``zh_档案知识库测试样例.docx``) and runs each query through -``run_retrieval_route`` directly — bypassing the Redis result cache, which -does not key on ``RETRIEVAL_AGENTIC_ROUTER`` and would otherwise return the -first router's answer for both runs of the same query. +``run_retrieval_route`` directly, bypassing the Redis result cache. Usage: cd apps/worker uv run python scripts/run_agentic_router_eval.py uv run python scripts/run_agentic_router_eval.py --query-id q05 - uv run python scripts/run_agentic_router_eval.py --router agent_explore + uv run python scripts/run_agentic_router_eval.py --use-agentic false """ from __future__ import annotations @@ -21,7 +19,6 @@ import os import sys import time -from contextlib import contextmanager from dataclasses import dataclass from datetime import datetime from pathlib import Path @@ -35,36 +32,17 @@ sys.path.insert(0, str(_SHARED_PYTHON)) sys.path.insert(0, str(_SCRIPT_DIR.parent)) -load_dotenv(_SCRIPT_DIR.parent / ".env") +load_dotenv(_SCRIPT_DIR.parent / ".env", override=True) os.environ.setdefault("LOCAL_DEBUG", "0") os.environ.setdefault("LLM_MOCK_ENABLED", "false") FIXTURE_PATH = _SCRIPT_DIR / "fixtures" / "changheba_archive_eval_queries.json" -ROUTERS = ("mapnav", "agent_explore") +_MODES = ("classic", "agent_explore") TOP_K = 10 -@contextmanager -def temporary_env(overrides: dict[str, str]): - previous = {key: os.environ.get(key) for key in overrides} - os.environ.update(overrides) - try: - yield - finally: - for key, old in previous.items(): - if old is None: - os.environ.pop(key, None) - else: - os.environ[key] = old - - -def _count_llm_steps(decision_trace: list[dict[str, Any]], router: str) -> int: - if router == "mapnav": - return sum( - 1 - for step in decision_trace - if step.get("phase") in ("plan", "harvest", "plan_control") - ) +def _count_llm_steps(decision_trace: list[dict[str, Any]], mode: str) -> int: + del mode return sum( 1 for step in decision_trace @@ -82,7 +60,7 @@ def _keyword_hits(evidence_text: str, keywords: list[str]) -> tuple[int, list[st @dataclass class RunMetrics: query_id: str - router: str + router: str # "classic" | "agent_explore" total_ms: int router_used: str stop_reason: str @@ -103,6 +81,7 @@ async def _run_one( query: str, query_id: str, router: str, + use_agentic: bool, expected_keywords: list[str], ) -> RunMetrics: from dataclasses import replace @@ -121,42 +100,41 @@ async def _run_one( error: str | None = None response: dict[str, Any] = {} try: - with temporary_env({"RETRIEVAL_AGENTIC_ROUTER": router}): - async with get_db_context() as db: - request = RetrievalQuery.from_parameters( - db=db, - user_id=user_id, - namespace=namespace, - query=query, - top_k=TOP_K, - exclude_document_ids=[], - exclude_sections=[], - use_agentic=True, - ) + async with get_db_context() as db: + request = RetrievalQuery.from_parameters( + db=db, + user_id=user_id, + namespace=namespace, + query=query, + top_k=TOP_K, + exclude_document_ids=[], + exclude_sections=[], + use_agentic=use_agentic, + ) + revision_pins = await capture_revision_pins( + db, user_id=user_id, namespace=namespace + ) + if not await is_revision_generation_stable( + db, + user_id=user_id, + namespace=namespace, + pins=revision_pins, + ): revision_pins = await capture_revision_pins( db, user_id=user_id, namespace=namespace ) - if not await is_revision_generation_stable( - db, - user_id=user_id, - namespace=namespace, - pins=revision_pins, - ): - revision_pins = await capture_revision_pins( - db, user_id=user_id, namespace=namespace - ) - effective_recall_k = ( - request.internal_recall_k - if request.internal_recall_k is not None - else TOP_K * INTERNAL_RECALL_K_MULTIPLIER - ) - context = replace( - request.build_route_context(), - revision_pins=revision_pins, - effective_recall_k=effective_recall_k, - ) - outcome = await run_retrieval_route(context) - response = await project_public_retrieval_response(outcome.response) + effective_recall_k = ( + request.internal_recall_k + if request.internal_recall_k is not None + else TOP_K * INTERNAL_RECALL_K_MULTIPLIER + ) + context = replace( + request.build_route_context(), + revision_pins=revision_pins, + effective_recall_k=effective_recall_k, + ) + outcome = await run_retrieval_route(context) + response = await project_public_retrieval_response(outcome.response) except Exception as exc: # noqa: BLE001 - eval runner must continue error = f"{type(exc).__name__}: {exc}" @@ -201,7 +179,7 @@ def _render_markdown( for item in fixture["queries"]: qid = item["id"] short = item["query"][:28] + ("…" if len(item["query"]) > 28 else "") - for router in ROUTERS: + for router in _MODES: r = by_key.get((qid, router)) if r is None: continue @@ -212,7 +190,7 @@ def _render_markdown( f"{r.llm_steps} | {r.refs} | {kw} | {stop} |\n" ) - mapnav_ms = [r.total_ms for r in runs if r.router == "mapnav" and not r.error] + classic_ms = [r.total_ms for r in runs if r.router == "classic" and not r.error] agent_ms = [r.total_ms for r in runs if r.router == "agent_explore" and not r.error] def _p50(values: list[int]) -> int | None: @@ -224,7 +202,7 @@ def _p50(values: list[int]) -> int | None: lines.extend( [ "\n## Aggregate latency (successful runs only)\n", - f"- mapnav p50: {_p50(mapnav_ms)} ms ({len(mapnav_ms)} runs)\n", + f"- classic p50: {_p50(classic_ms)} ms ({len(classic_ms)} runs)\n", f"- agent_explore p50: {_p50(agent_ms)} ms ({len(agent_ms)} runs)\n", "\n## Notes\n", "- Runs bypass Redis retrieval cache (direct ``run_retrieval_route``).\n", @@ -238,7 +216,7 @@ def _p50(values: list[int]) -> int | None: async def main() -> None: - parser = argparse.ArgumentParser(description="Compare mapnav vs agent_explore") + parser = argparse.ArgumentParser(description="Smoke-run agent_explore eval fixture") parser.add_argument( "--fixture", default=str(FIXTURE_PATH), @@ -257,10 +235,10 @@ async def main() -> None: help="Run only these query ids (repeatable, e.g. --query-id q01)", ) parser.add_argument( - "--router", - choices=[*ROUTERS, "both"], + "--use-agentic", + choices=["true", "false", "both"], default="both", - help="Which router(s) to run", + help="classic (false), agent_explore (true), or both", ) args = parser.parse_args() @@ -270,7 +248,12 @@ async def main() -> None: allowed = set(args.query_id) queries = [q for q in queries if q["id"] in allowed] - routers = list(ROUTERS) if args.router == "both" else [args.router] + if args.use_agentic == "true": + routers = ["agent_explore"] + elif args.use_agentic == "false": + routers = ["classic"] + else: + routers = list(_MODES) timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") output_dir = Path(args.output_dir or f"/tmp/agentic_router_eval/{timestamp}") output_dir.mkdir(parents=True, exist_ok=True) @@ -289,6 +272,7 @@ async def main() -> None: query=item["query"], query_id=item["id"], router=router, + use_agentic=router != "classic", expected_keywords=item.get("expected_keywords") or [], ) all_runs.append(metrics) diff --git a/deploy/ecs/task-definition-api.staging.json b/deploy/ecs/task-definition-api.staging.json index 0b4af2c5..dde385e2 100644 --- a/deploy/ecs/task-definition-api.staging.json +++ b/deploy/ecs/task-definition-api.staging.json @@ -75,7 +75,8 @@ {"name": "LOGFIRE_TOKEN", "valueFrom": "${SECRETS_ARN}:LOGFIRE_TOKEN::"}, {"name": "QSTASH_TOKEN", "valueFrom": "${SECRETS_ARN}:QSTASH_TOKEN::"}, {"name": "QSTASH_CURRENT_SIGNING_KEY", "valueFrom": "${SECRETS_ARN}:QSTASH_CURRENT_SIGNING_KEY::"}, - {"name": "QSTASH_NEXT_SIGNING_KEY", "valueFrom": "${SECRETS_ARN}:QSTASH_NEXT_SIGNING_KEY::"} + {"name": "QSTASH_NEXT_SIGNING_KEY", "valueFrom": "${SECRETS_ARN}:QSTASH_NEXT_SIGNING_KEY::"}, + {"name": "CURSOR_API_KEY", "valueFrom": "${SECRETS_ARN}:CURSOR_API_KEY::"} ], "healthCheck": { "command": ["CMD-SHELL", "curl -f http://localhost:5005/health || exit 1"], diff --git a/deploy/ecs/task-definition-worker.staging.json b/deploy/ecs/task-definition-worker.staging.json index 202339d1..8f167696 100644 --- a/deploy/ecs/task-definition-worker.staging.json +++ b/deploy/ecs/task-definition-worker.staging.json @@ -81,7 +81,8 @@ {"name": "LOGFIRE_TOKEN", "valueFrom": "${SECRETS_ARN}:LOGFIRE_TOKEN::"}, {"name": "QSTASH_TOKEN", "valueFrom": "${SECRETS_ARN}:QSTASH_TOKEN::"}, {"name": "QSTASH_CURRENT_SIGNING_KEY", "valueFrom": "${SECRETS_ARN}:QSTASH_CURRENT_SIGNING_KEY::"}, - {"name": "QSTASH_NEXT_SIGNING_KEY", "valueFrom": "${SECRETS_ARN}:QSTASH_NEXT_SIGNING_KEY::"} + {"name": "QSTASH_NEXT_SIGNING_KEY", "valueFrom": "${SECRETS_ARN}:QSTASH_NEXT_SIGNING_KEY::"}, + {"name": "CURSOR_API_KEY", "valueFrom": "${SECRETS_ARN}:CURSOR_API_KEY::"} ], "healthCheck": { "command": ["CMD-SHELL", "python -c \"from shared.services.worker_health import assert_worker_healthy; assert_worker_healthy()\""], diff --git a/deprecated/mapnav/README.md b/deprecated/mapnav/README.md new file mode 100644 index 00000000..a7d0c19b --- /dev/null +++ b/deprecated/mapnav/README.md @@ -0,0 +1,26 @@ +# Archived map-nav retrieval route + +This directory holds the retired checklist map-nav episode (PLANNER / HARVEST / CONTROL). + +It is **not imported** by production code. Lint, typecheck, and pytest skip it. + +## Why it was archived + +Retrieval now has two live routes: + +- `use_agentic is False` → classic map-unit BM25 +- otherwise → `agent_explore` (default harness: `cursor_sdk`) + +The `RETRIEVAL_AGENTIC_ROUTER=mapnav` switch was removed. Setting that env var has no effect. + +Shared scoring used by publication and classic recall was extracted first, to `packages/shared-python/shared/services/retrieval/scoring/`. + +## How to restore (manual) + +1. Copy these files back to their original paths: + - `nav/` → `packages/shared-python/shared/services/retrieval/nav/` + - `nav_config.py`, `nav_snapshot.py`, `nav_bridge.py`, `nav_llm_backend.py` → `packages/shared-python/shared/services/retrieval/` + - `trace_mapnav.py` → `packages/shared-python/shared/services/retrieval/trace/mapnav.py` +2. Reverse the Phase 1 import moves: leftover map-nav modules expect `shared.services.retrieval.nav.*` for types that now live under `scoring/`. +3. Re-attach a third branch in `execution/routes.py` (`use_agentic is False` → classic, else map-nav or `agent_explore`). +4. Restore the archived tests from `tests/` and stop excluding this directory from pytest. diff --git a/packages/shared-python/shared/services/retrieval/nav/__init__.py b/deprecated/mapnav/nav/__init__.py similarity index 100% rename from packages/shared-python/shared/services/retrieval/nav/__init__.py rename to deprecated/mapnav/nav/__init__.py diff --git a/packages/shared-python/shared/services/retrieval/nav/_compat.py b/deprecated/mapnav/nav/_compat.py similarity index 100% rename from packages/shared-python/shared/services/retrieval/nav/_compat.py rename to deprecated/mapnav/nav/_compat.py diff --git a/packages/shared-python/shared/services/retrieval/nav/nav_actions.py b/deprecated/mapnav/nav/nav_actions.py similarity index 100% rename from packages/shared-python/shared/services/retrieval/nav/nav_actions.py rename to deprecated/mapnav/nav/nav_actions.py diff --git a/packages/shared-python/shared/services/retrieval/nav/nav_address.py b/deprecated/mapnav/nav/nav_address.py similarity index 100% rename from packages/shared-python/shared/services/retrieval/nav/nav_address.py rename to deprecated/mapnav/nav/nav_address.py diff --git a/packages/shared-python/shared/services/retrieval/nav/nav_agent.py b/deprecated/mapnav/nav/nav_agent.py similarity index 100% rename from packages/shared-python/shared/services/retrieval/nav/nav_agent.py rename to deprecated/mapnav/nav/nav_agent.py diff --git a/packages/shared-python/shared/services/retrieval/nav/nav_assets.py b/deprecated/mapnav/nav/nav_assets.py similarity index 100% rename from packages/shared-python/shared/services/retrieval/nav/nav_assets.py rename to deprecated/mapnav/nav/nav_assets.py diff --git a/packages/shared-python/shared/services/retrieval/nav/nav_compose.py b/deprecated/mapnav/nav/nav_compose.py similarity index 100% rename from packages/shared-python/shared/services/retrieval/nav/nav_compose.py rename to deprecated/mapnav/nav/nav_compose.py diff --git a/packages/shared-python/shared/services/retrieval/nav/nav_control.py b/deprecated/mapnav/nav/nav_control.py similarity index 100% rename from packages/shared-python/shared/services/retrieval/nav/nav_control.py rename to deprecated/mapnav/nav/nav_control.py diff --git a/packages/shared-python/shared/services/retrieval/nav/nav_harvest.py b/deprecated/mapnav/nav/nav_harvest.py similarity index 100% rename from packages/shared-python/shared/services/retrieval/nav/nav_harvest.py rename to deprecated/mapnav/nav/nav_harvest.py diff --git a/deprecated/mapnav/nav/nav_hierarchy.py b/deprecated/mapnav/nav/nav_hierarchy.py new file mode 100644 index 00000000..cabef82e --- /dev/null +++ b/deprecated/mapnav/nav/nav_hierarchy.py @@ -0,0 +1,111 @@ +"""In-memory hierarchy fixtures for map-nav tests. + +``ProviderToolSpace`` / ``NodeMeta`` / ``HierarchyProvider`` live in +``shared.services.retrieval.scoring.hierarchy``. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Sequence, Set, Tuple + +from shared.services.retrieval.scoring.hierarchy import NodeMeta + +@dataclass +class InMemoryNode: + section_id: str + title: str + content: str = "" + children: List[str] = field(default_factory=list) + + +class InMemoryHierarchyProvider: + """Minimal reference ``HierarchyProvider``: no scoring, no ToolSpace. + + Built directly from a ``{doc_id: [InMemoryNode, ...]}`` map plus a + ``{doc_id: [root_section_id, ...]}`` map — the "hierarchy + summary is + enough" claim's simplest possible witness. + """ + + def __init__( + self, + *, + roots_by_doc: Dict[str, Sequence[str]], + nodes: Dict[str, InMemoryNode], + summaries: Optional[Dict[str, str]] = None, + ) -> None: + self._roots_by_doc = {k: list(v) for k, v in roots_by_doc.items()} + self._nodes = dict(nodes) + self._summaries = dict(summaries or {}) + self._parent: Dict[str, str] = {} + for node in self._nodes.values(): + for child_id in node.children: + self._parent[child_id] = node.section_id + self._owner: Dict[str, str] = {} + for doc_id, root_ids in self._roots_by_doc.items(): + stack = list(root_ids) + while stack: + sid = stack.pop() + if sid in self._owner: + continue + self._owner[sid] = doc_id + node = self._nodes.get(sid) + if node: + stack.extend(node.children) + + def owner_document(self, node_id: str) -> Optional[str]: + return self._owner.get(str(node_id or "").strip()) + + def roots(self, doc_id: str) -> Sequence[str]: + return list(self._roots_by_doc.get(doc_id, ())) + + def children(self, section_id: str) -> Sequence[str]: + node = self._nodes.get(section_id) + return list(node.children) if node else [] + + def node_meta(self, section_id: str) -> NodeMeta: + node = self._nodes.get(section_id) + if node is None: + return NodeMeta() + return NodeMeta( + title=node.title, + summary=self._summaries.get(section_id, ""), + has_children=bool(node.children), + ) + + def parent_id(self, section_id: str) -> Optional[str]: + return self._parent.get(section_id) + + def relations(self, section_id: str) -> Tuple[Set[str], Set[str]]: + ancestors: Set[str] = set() + cur = self._parent.get(section_id) + while cur: + ancestors.add(cur) + cur = self._parent.get(cur) + descendants: Set[str] = set() + stack = list(self.children(section_id)) + while stack: + cid = stack.pop() + if cid in descendants: + continue + descendants.add(cid) + stack.extend(self.children(cid)) + return ancestors, descendants + + def content(self, section_id: str) -> str: + node = self._nodes.get(section_id) + if node is None: + return "" + parts: List[str] = [] + + def walk(sid: str) -> None: + cur = self._nodes.get(sid) + if cur is None: + return + if cur.content: + parts.append(cur.content) + for cid in cur.children: + walk(cid) + + walk(section_id) + return "\n".join(parts) diff --git a/packages/shared-python/shared/services/retrieval/nav/nav_knowhere.py b/deprecated/mapnav/nav/nav_knowhere.py similarity index 73% rename from packages/shared-python/shared/services/retrieval/nav/nav_knowhere.py rename to deprecated/mapnav/nav/nav_knowhere.py index bacce7f0..90d5cf3d 100644 --- a/packages/shared-python/shared/services/retrieval/nav/nav_knowhere.py +++ b/deprecated/mapnav/nav/nav_knowhere.py @@ -1,21 +1,8 @@ -"""Knowhere-native hierarchy provider for MAP-NAV. +"""Knowhere-native hierarchy provider leftovers for MAP-NAV. -Knowhere stores everything MAP-NAV needs in two tables: - -``document_sections`` - ``section_id`` (PK) / ``parent_section_id`` (self FK) / ``section_path`` / - ``section_title`` / ``section_level`` / ``summary`` / ``sort_order`` -``document_chunks`` - ``chunk_id`` / ``section_id`` (FK) / ``chunk_type`` / ``content`` / - ``chunk_metadata`` / ``sort_order`` - -``SectionRow`` / ``UnitRow`` mirror those shapes. ``KnowhereProvider`` is a -synchronous in-memory snapshot (so the nav kernel stays sync inside knowhere's -async path). Load from the production Postgres schema via -``load_document_from_db`` / ``load_namespace_from_db`` (local Docker or prod). - -Hierarchy comes from ``parent_section_id`` and depth from ``section_level``, -not from parsing ``section_id`` or ``section_path`` separators. +Publication types and the eager ``KnowhereProvider`` live in +``shared.services.retrieval.scoring``. This module keeps the episode-local +lazy/DB loaders used by map-nav snapshot loading. """ from __future__ import annotations @@ -23,11 +10,9 @@ import os import logging import time -from dataclasses import dataclass, field from hashlib import sha256 from typing import ( Any, - Callable, Dict, Iterable, List, @@ -39,89 +24,25 @@ Tuple, ) -from .nav_address import NavLevel -from .nav_hierarchy import NodeMeta -from .knowhere_hybrid import ( +from shared.services.retrieval.nav.nav_address import NavLevel +from shared.services.retrieval.scoring.hierarchy import NodeMeta +from shared.services.retrieval.scoring.knowhere_hybrid import ( MAP_UNIT_INDEX_FORMAT_VERSION, PersistedScoreCorpus, PersistedScoreUnit, tokenize_query_for_ranker, ) +from shared.services.retrieval.scoring.knowhere_provider import ( + KnowhereProvider, + SectionRow, + UnitRow, + is_root_section_path, +) -_ASSET_TYPES = ("table", "image") -# Body chunk types that can own a Root-parked asset via connect_to. Both -# chunk-track ("text") and page-track ("page") body chunks can embed assets. -_BODY_CHUNK_TYPES = ("text", "page") -# Knowhere sentinel path for the virtual document container (not a collectable leaf). -ROOT_SECTION_PATH = "Root" _DEFAULT_DSN = "postgresql://root:root123@127.0.0.1:5433/Knowhere" _MAP_SCORE_CHANNELS: Tuple[str, str] = ("path", "content") _logger = logging.getLogger(__name__) - -@dataclass(frozen=True) -class SectionRow: - """One ``document_sections`` row.""" - - section_id: str - parent_section_id: Optional[str] - section_path: str - section_title: str - section_level: int - summary: str - sort_order: int - - -@dataclass(frozen=True) -class UnitRow: - """One ``document_chunks`` row.""" - - chunk_id: str - section_id: Optional[str] - chunk_type: str - content: str - sort_order: int - source_chunk_path: str = "" - file_path: str = "" - metadata: Dict[str, Any] = field(default_factory=dict) - - -def asset_display_text(unit: UnitRow) -> str: - """Body text for an asset unit, whose ``content`` is only a file path. - - Mirrors knowhere's own assembly: an asset contributes its summary, not its - path. Without this an asset unit is unscorable and unreadable. - """ - meta = unit.metadata or {} - title = str(meta.get("asset_title") or "").strip() - summary = str(meta.get("summary") or "").strip() - ref = unit.file_path or unit.source_chunk_path or unit.content - label = "Table" if unit.chunk_type == "table" else "Image" - parts = [f"[{label}: {ref}]"] if ref else [f"[{label}]"] - if title: - parts.append(title) - if summary: - parts.append(summary) - return "\n".join(parts) - - -def normalize_section_path(path: str) -> str: - """Canonical path for gold/lookup: ``a / b`` (accepts ``a/b`` or ``a / b``).""" - raw = str(path or "").strip().strip("/") - if not raw or raw == ROOT_SECTION_PATH: - return "" - if " / " in raw: - parts = [p.strip() for p in raw.split(" / ") if p.strip()] - else: - parts = [p.strip() for p in raw.split("/") if p.strip()] - return " / ".join(parts) - - -def is_root_section_path(path: str) -> bool: - """True when the raw ``section_path`` is Knowhere's Root container.""" - return str(path or "").strip() == ROOT_SECTION_PATH - - def is_root_section(provider_or_ts: Any, section_id: str) -> bool: """True when ``section_id`` is a Root container (raw path, not normalized).""" sid = str(section_id or "").strip() @@ -137,21 +58,6 @@ def is_root_section(provider_or_ts: Any, section_id: str) -> bool: return False -def _connect_to_targets(metadata: Dict[str, Any]) -> List[str]: - """``chunk_metadata.connect_to[].target`` ids (document order, first wins upstream).""" - raw = metadata.get("connect_to") if isinstance(metadata, dict) else None - if not isinstance(raw, list): - return [] - out: List[str] = [] - for conn in raw: - if not isinstance(conn, dict): - continue - target = str(conn.get("target") or "").strip() - if target: - out.append(target) - return out - - def knowhere_database_url() -> str: configured = ( str(os.environ.get("KNOWHERE_DATABASE_URL") or "").strip() @@ -303,7 +209,7 @@ def load_persisted_score_corpus( ``document_map_unit_tokens`` filtered to the query tokens. Average IDF comes from ``document_map_unit_indexes`` (written at index time). """ - from shared.services.retrieval.nav.persisted_score_load import ( + from shared.services.retrieval.scoring.persisted_score_load import ( build_channel_bm25_stats, combine_average_idf, ) @@ -649,252 +555,6 @@ def _unit_from_row(row: Sequence[object]) -> UnitRow: ) -class KnowhereProvider: - """``HierarchyProvider`` over knowhere section/chunk rows.""" - - def __init__( - self, - *, - doc_id: str, - sections: Sequence[SectionRow], - units: Sequence[UnitRow], - lazy_loader: Optional[Callable[[str], Sequence[UnitRow]]] = None, - known_chunk_ids: Optional[Sequence[str]] = None, - ) -> None: - self.doc_id = str(doc_id) - self._lazy_loader = lazy_loader - self._loaded_sections: Set[str] = set() - self._sections: Dict[str, SectionRow] = {s.section_id: s for s in sections} - self._children: Dict[str, List[str]] = {} - self._roots: List[str] = [] - self._path_to_id: Dict[str, str] = {} - for row in sorted(sections, key=lambda s: (s.sort_order, s.section_id)): - parent = row.parent_section_id - if parent and parent in self._sections: - self._children.setdefault(parent, []).append(row.section_id) - else: - self._roots.append(row.section_id) - key = normalize_section_path(row.section_path) - if key: - self._path_to_id[key] = row.section_id - - self._units_by_section: Dict[str, List[UnitRow]] = {} - self._chunk_ids: Set[str] = set() - if known_chunk_ids: - self._chunk_ids.update( - str(chunk_id).strip() - for chunk_id in known_chunk_ids - if str(chunk_id).strip() - ) - for unit in sorted(units, key=lambda u: (u.sort_order, u.chunk_id)): - sid = unit.section_id - if not sid or sid not in self._sections: - continue - self._units_by_section.setdefault(sid, []).append(unit) - if unit.chunk_id: - self._chunk_ids.add(unit.chunk_id) - self._remount_root_assets() - - def _ensure_section_loaded(self, section_id: str) -> None: - if self._lazy_loader is None or section_id in self._loaded_sections: - return - loaded = list(self._lazy_loader(section_id) or ()) - self._loaded_sections.add(section_id) - if not loaded: - return - current = self._units_by_section.setdefault(section_id, []) - known = {unit.chunk_id for unit in current} - for unit in loaded: - if unit.chunk_id and unit.chunk_id not in known: - current.append(unit) - known.add(unit.chunk_id) - current.sort(key=lambda unit: (unit.sort_order, unit.chunk_id)) - - def _remount_root_assets(self) -> None: - """Reattach Root-FK image|table units to host sections via ``connect_to``. - - Aligns with Knowhere ``resolve_root_asset_owners``: assets whose FK still - points at Root are owned by the text chunk that lists them in - ``metadata.connect_to``. Unresolved Root assets leave the evidence surface. - """ - root_sids = [ - sid - for sid, row in self._sections.items() - if is_root_section_path(row.section_path) - ] - if not root_sids: - return - - root_assets: Dict[str, UnitRow] = {} - for sid in root_sids: - for unit in self._units_by_section.get(sid, ()): - if unit.chunk_type in _ASSET_TYPES and unit.chunk_id: - root_assets[unit.chunk_id] = unit - if not root_assets: - return - - owner_by_asset: Dict[str, str] = {} - for sid, units in self._units_by_section.items(): - row = self._sections.get(sid) - if row is None or is_root_section_path(row.section_path): - continue - for unit in units: - if unit.chunk_type not in _BODY_CHUNK_TYPES: - continue - for target in _connect_to_targets(unit.metadata or {}): - if target in root_assets and target not in owner_by_asset: - owner_by_asset[target] = sid - - touched_owners: Set[str] = set() - for chunk_id, owner_sid in owner_by_asset.items(): - unit = root_assets[chunk_id] - remounted = UnitRow( - chunk_id=unit.chunk_id, - section_id=owner_sid, - chunk_type=unit.chunk_type, - content=unit.content, - sort_order=unit.sort_order, - source_chunk_path=unit.source_chunk_path, - file_path=unit.file_path, - metadata=dict(unit.metadata or {}), - ) - self._units_by_section.setdefault(owner_sid, []).append(remounted) - touched_owners.add(owner_sid) - - for sid in root_sids: - self._units_by_section[sid] = [ - u - for u in self._units_by_section.get(sid, ()) - if u.chunk_type not in _ASSET_TYPES - ] - for sid in touched_owners: - self._units_by_section[sid].sort(key=lambda u: (u.sort_order, u.chunk_id)) - - def address_level(self, node_id: str) -> Optional[NavLevel]: - sid = str(node_id or "").strip() - if not sid: - return NavLevel.NAMESPACE - if sid == self.doc_id: - return NavLevel.DOCUMENT - if sid in self._sections: - return NavLevel.SECTION - if sid in self._chunk_ids: - return NavLevel.CHUNK - return None - - def owner_document(self, node_id: str) -> Optional[str]: - sid = str(node_id or "").strip() - if not sid: - return None - if sid == self.doc_id or sid in self._sections or sid in self._chunk_ids: - return self.doc_id - return None - - def roots(self, doc_id: str) -> Sequence[str]: - return list(self._roots) if str(doc_id) == self.doc_id else [] - - def children(self, section_id: str) -> Sequence[str]: - return list(self._children.get(section_id, ())) - - def node_meta(self, section_id: str) -> NodeMeta: - row = self._sections.get(section_id) - if row is None: - return NodeMeta() - return NodeMeta( - title=row.section_title, - summary=row.summary, - has_children=bool(self._children.get(section_id)), - ) - - def relations(self, section_id: str) -> Tuple[Set[str], Set[str]]: - ancestors: Set[str] = set() - cur = self._sections.get(section_id) - while cur is not None and cur.parent_section_id: - parent = cur.parent_section_id - if parent in ancestors: - break - ancestors.add(parent) - cur = self._sections.get(parent) - descendants: Set[str] = set() - stack = list(self.children(section_id)) - while stack: - cid = stack.pop() - if cid in descendants: - continue - descendants.add(cid) - stack.extend(self.children(cid)) - return ancestors, descendants - - def content(self, section_id: str) -> str: - units = self.subtree_units(section_id) - return "\n".join(self.unit_text(u) for u in units if self.unit_text(u)) - - def self_units(self, section_id: str) -> List[UnitRow]: - self._ensure_section_loaded(section_id) - return list(self._units_by_section.get(section_id, ())) - - def subtree_units(self, section_id: str) -> List[UnitRow]: - out = list(self.self_units(section_id)) - for cid in self.relations(section_id)[1]: - out.extend(self.self_units(cid)) - out.sort(key=lambda u: (u.sort_order, u.chunk_id)) - return out - - def leaf_ids(self, section_id: str) -> List[str]: - out: List[str] = [] - - def rec(sid: str) -> None: - kids = self.children(sid) - if not kids: - out.append(sid) - return - for kid in kids: - rec(kid) - - rec(section_id) - return out - - def path_titles(self, section_id: str) -> str: - chain: List[str] = [] - cur = self._sections.get(section_id) - while cur is not None: - if cur.section_title: - chain.append(cur.section_title) - parent = cur.parent_section_id - cur = self._sections.get(parent) if parent else None - return " / ".join(reversed(chain)) - - def parent_id(self, section_id: str) -> Optional[str]: - row = self._sections.get(section_id) - return row.parent_section_id if row else None - - def section_path(self, section_id: str) -> str: - row = self._sections.get(section_id) - return str(row.section_path or "") if row else "" - - def resolve_path(self, path: str) -> Optional[str]: - """Map a human/gold path to ``section_id`` (``sec_*``).""" - key = normalize_section_path(path) - if not key: - return None - return self._path_to_id.get(key) - - def unit_text(self, unit: UnitRow) -> str: - if unit.chunk_type in _ASSET_TYPES: - return asset_display_text(unit) - return str(unit.content or "").strip() - - def summaries(self) -> Dict[str, str]: - return { - sid: row.summary - for sid, row in self._sections.items() - if str(row.summary or "").strip() - } - - def all_section_ids(self) -> List[str]: - return list(self._sections) - - class LazyKnowhereProvider(KnowhereProvider): """Hierarchy provider that loads full chunk rows only on first access.""" diff --git a/packages/shared-python/shared/services/retrieval/nav/nav_llm.py b/deprecated/mapnav/nav/nav_llm.py similarity index 100% rename from packages/shared-python/shared/services/retrieval/nav/nav_llm.py rename to deprecated/mapnav/nav/nav_llm.py diff --git a/packages/shared-python/shared/services/retrieval/nav/nav_map_scores.py b/deprecated/mapnav/nav/nav_map_scores.py similarity index 52% rename from packages/shared-python/shared/services/retrieval/nav/nav_map_scores.py rename to deprecated/mapnav/nav/nav_map_scores.py index 35ffd890..8877451a 100644 --- a/packages/shared-python/shared/services/retrieval/nav/nav_map_scores.py +++ b/deprecated/mapnav/nav/nav_map_scores.py @@ -4,18 +4,8 @@ import time from typing import Any, Dict, List, Optional, Sequence, Set, Tuple -from .knowhere_hybrid import ( - build_content_search_text, - build_path_search_text, - build_term_search_text, - PersistedScoreCorpus, - PersistedScoreUnit, - score_persisted_corpus_many, -) -from .persisted_score_load import ( - average_idf_from_unit_dfs, - build_channel_bm25_stats, -) +from shared.services.retrieval.scoring.knowhere_hybrid import score_persisted_corpus_many +from shared.services.retrieval.scoring.score_units import _walk_tree _logger = logging.getLogger(__name__) @@ -35,188 +25,6 @@ def _count_tree_shape( leaf_sections: int = sum(len(value[1]) for value in tree_by_doc.values()) return section_nodes, section_edges, leaf_sections - -def _build_legacy_score_corpus(ts: Any, doc_ids: Sequence[str]) -> PersistedScoreCorpus: - """Build the retired in-memory scorer input when persisted indexes are absent.""" - raw_units: List[dict] = [] - for doc_id in doc_ids: - raw_units.extend(build_score_units(ts, doc_id)) - frequencies: Dict[Tuple[str, str], Dict[str, int]] = {} - unit_rows: List[dict] = [] - path_dfs: Dict[str, int] = {} - content_dfs: Dict[str, int] = {} - for unit in raw_units: - unit_id = str(unit.get("chunk_id") or "").strip() - if not unit_id: - continue - path_tokens = str(unit.get("path_search_text") or "").split() - content_tokens = str(unit.get("content_search_text") or "").split() - path_freq: Dict[str, int] = {} - content_freq: Dict[str, int] = {} - for token in path_tokens: - path_freq[token] = path_freq.get(token, 0) + 1 - for token in content_tokens: - content_freq[token] = content_freq.get(token, 0) + 1 - frequencies[(unit_id, "path")] = path_freq - frequencies[(unit_id, "content")] = content_freq - for token in path_freq: - path_dfs[token] = path_dfs.get(token, 0) + 1 - for token in content_freq: - content_dfs[token] = content_dfs.get(token, 0) + 1 - unit_rows.append( - { - "unit_id": unit_id, - "path_length": len(path_tokens), - "content_length": len(content_tokens), - } - ) - unit_count = len(unit_rows) - return PersistedScoreCorpus( - units=[ - PersistedScoreUnit( - unit_id=str(row["unit_id"]), - path_length=int(row["path_length"]), - content_length=int(row["content_length"]), - path_frequencies=frequencies[(str(row["unit_id"]), "path")], - content_frequencies=frequencies[(str(row["unit_id"]), "content")], - ) - for row in unit_rows - ], - path_stats=build_channel_bm25_stats( - unit_rows=unit_rows, - map_unit_id_field="unit_id", - length_field="path_length", - channel="path", - query_tokens=list(path_dfs), - frequencies=frequencies, - average_idf=average_idf_from_unit_dfs( - unit_count=unit_count, token_document_frequency=path_dfs - ), - ), - content_stats=build_channel_bm25_stats( - unit_rows=unit_rows, - map_unit_id_field="unit_id", - length_field="content_length", - channel="content", - query_tokens=list(content_dfs), - frequencies=frequencies, - average_idf=average_idf_from_unit_dfs( - unit_count=unit_count, token_document_frequency=content_dfs - ), - ), - ) - - -def _children_ids(ts: Any, section_id: str, doc_id: str) -> List[str]: - children_fn = getattr(ts, "_children_for_section_path", None) - if not callable(children_fn): - st = ts.get_structure(section_id) - rows = st.get("children") or [] - return [ - str(r.get("section_id") or "").strip() for r in rows if r.get("section_id") - ] - rows = children_fn(section_id, doc_id) - return [str(r.get("section_id") or "").strip() for r in rows if r.get("section_id")] - - -def _line_content(ts: Any, section_id: str, doc_id: str) -> str: - """Raw line text for a section node (no truncation).""" - idx = getattr(ts, "_idx", None) - b = getattr(idx, "_bundles", {}).get(doc_id) if idx is not None else None - if b is None: - path_fn = getattr(ts, "path_titles", None) - if callable(path_fn): - path = str(path_fn(section_id, doc_id) or "").strip() - return path.rsplit(" / ", 1)[-1] if path else "" - st = ts.get_structure(section_id) - return str(st.get("preview") or "").strip() - loc = getattr(idx, "_node_to_doc_line", {}).get(section_id) - if not loc: - return "" - _doc, line_idx = loc - if line_idx < 0 or line_idx >= len(b.lines): - return "" - return str(b.lines[line_idx].content or "").strip() - - -def _ancestor_path_titles(ts: Any, section_id: str, doc_id: str) -> str: - idx = getattr(ts, "_idx", None) - if idx is None: - # Provider-backed spaces expose the title chain directly; without this - # the path channel would score every unit as empty. - path_fn = getattr(ts, "path_titles", None) - return str(path_fn(section_id, doc_id) or "") if callable(path_fn) else "" - try: - ancestors = list(idx.ancestor_line_node_ids(section_id)) - except Exception: - ancestors = [] - titles: List[str] = [] - for aid in reversed(ancestors): - if not str(aid).startswith(f"{doc_id}:"): - continue - titles.append(_line_content(ts, aid, doc_id)) - titles.append(_line_content(ts, section_id, doc_id)) - return " / ".join(t for t in titles if t) - - -def _self_only_text(ts: Any, section_id: str, doc_id: str) -> Tuple[str, bool]: - """Return (self_text, has_interstitial_body). - - Interstitial means self_only span contains content beyond the heading line - itself (structural: more than one line/chunk in the self span). - """ - self_fn = getattr(ts, "materialize_self_only_chunks", None) - if not callable(self_fn): - return "", False - chunks = list(self_fn(section_id, doc_id) or []) - if not chunks: - return "", False - texts = [str(getattr(c, "text", "") or "").strip() for c in chunks] - texts = [t for t in texts if t] - if not texts: - return "", False - # Structural interstitial: self span covers more than the node heading line. - has_interstitial = len(chunks) > 1 - return "\n".join(texts), has_interstitial - - -def _section_body_text(ts: Any, section_id: str, doc_id: str) -> str: - """Heading + lines until first structural child (leaf body / parent self span).""" - text, _ = _self_only_text(ts, section_id, doc_id) - if text: - return text - return _line_content(ts, section_id, doc_id) - - -def _walk_tree( - ts: Any, - doc_id: str, - root_ids: Sequence[str], -) -> Tuple[Dict[str, List[str]], Set[str], Dict[str, str]]: - """Return children map, leaf ids, and title map for reachable nodes.""" - children_map: Dict[str, List[str]] = {} - titles: Dict[str, str] = {} - leaves: Set[str] = set() - seen: Set[str] = set() - - def walk(sid: str) -> None: - if not sid or sid in seen: - return - seen.add(sid) - titles[sid] = _line_content(ts, sid, doc_id) - kids = [c for c in _children_ids(ts, sid, doc_id) if c] - children_map[sid] = kids - if not kids: - leaves.add(sid) - return - for kid in kids: - walk(kid) - - for rid in root_ids: - walk(rid) - return children_map, leaves, titles - - def _collect_descendant_leaves( section_id: str, children_map: Dict[str, List[str]], @@ -271,74 +79,6 @@ def _pool_unit_scores_to_tree( return map_scores -def build_score_units( - ts: Any, doc_id: str, root_ids: Optional[Sequence[str]] = None -) -> List[dict]: - """Build leaf (+ interstitial self_only) units for hybrid scoring.""" - if root_ids is None: - root_ids = list(ts.sections_for_doc(doc_id)) - children_map, leaves, titles = _walk_tree(ts, doc_id, root_ids) - units: List[dict] = [] - seen_unit_ids: Set[str] = set() - - for leaf_id in sorted(leaves): - content = _section_body_text(ts, leaf_id, doc_id) or ( - titles.get(leaf_id) or _line_content(ts, leaf_id, doc_id) - ) - path_text = _ancestor_path_titles(ts, leaf_id, doc_id) - unit_id = leaf_id - if unit_id in seen_unit_ids: - continue - seen_unit_ids.add(unit_id) - title = titles.get(leaf_id) or _line_content(ts, leaf_id, doc_id) - units.append( - { - "chunk_id": unit_id, - "section_id": leaf_id, - "kind": "leaf", - "content": content, - "path_text": path_text, - "path_search_text": build_path_search_text( - section_path=path_text, section_title=title or content - ), - "content_search_text": build_content_search_text(content), - "term_search_text": build_term_search_text( - content, path_text=path_text - ), - } - ) - - # Parents with interstitial self body. - for sid, kids in children_map.items(): - if not kids: - continue - self_text, has_interstitial = _self_only_text(ts, sid, doc_id) - if not has_interstitial or not self_text: - continue - unit_id = f"{sid}__self" - if unit_id in seen_unit_ids: - continue - seen_unit_ids.add(unit_id) - path_text = _ancestor_path_titles(ts, sid, doc_id) - units.append( - { - "chunk_id": unit_id, - "section_id": sid, - "kind": "self_only", - "content": self_text, - "path_text": path_text, - "path_search_text": build_path_search_text( - section_path=path_text, section_title=titles.get(sid) or "" - ), - "content_search_text": build_content_search_text(self_text), - "term_search_text": build_term_search_text( - self_text, path_text=path_text - ), - } - ) - return units - - def compute_map_scores( ts: Any, *, @@ -453,13 +193,6 @@ def compute_corpus_map_and_unit_scores_many( time.perf_counter() - loader_started, persisted_corpus is not None, ) - if persisted_corpus is None: - _logger.warning( - "retrieval map index unavailable; using bounded legacy in-memory scorer " - "documents=%d", - len(valid_doc_ids), - ) - persisted_corpus = _build_legacy_score_corpus(ts, valid_doc_ids) score_started = time.perf_counter() unit_scores_by_query = ( score_persisted_corpus_many(persisted_corpus, unique_queries) diff --git a/packages/shared-python/shared/services/retrieval/nav/nav_navigate.py b/deprecated/mapnav/nav/nav_navigate.py similarity index 100% rename from packages/shared-python/shared/services/retrieval/nav/nav_navigate.py rename to deprecated/mapnav/nav/nav_navigate.py diff --git a/packages/shared-python/shared/services/retrieval/nav/nav_node_filter.py b/deprecated/mapnav/nav/nav_node_filter.py similarity index 70% rename from packages/shared-python/shared/services/retrieval/nav/nav_node_filter.py rename to deprecated/mapnav/nav/nav_node_filter.py index 45b2c1eb..5e895ee2 100644 --- a/packages/shared-python/shared/services/retrieval/nav/nav_node_filter.py +++ b/deprecated/mapnav/nav/nav_node_filter.py @@ -7,21 +7,14 @@ from __future__ import annotations -import re from dataclasses import dataclass, field -from typing import Any, Dict, Iterable, List, Literal, Sequence, Tuple +from typing import Any, Dict, Iterable, List, Sequence, Tuple -MatchKind = Literal["substring", "regex"] -FilterField = Literal["path", "summary"] - -_MAX_REGEX_PATTERN_LEN = 256 - - -@dataclass(frozen=True) -class FieldPredicate: - field: FilterField - terms: Tuple[str, ...] - match: MatchKind = "substring" +from shared.services.retrieval.scoring.node_filter_predicates import ( + FieldPredicate, + _compile_predicates, + _node_matches, +) @dataclass(frozen=True) @@ -36,26 +29,9 @@ class FilterResult: cardinality: int failed_predicates: List[str] = field(default_factory=list) - -def field_predicate( - field: str, - terms: Sequence[str], - match: str = "substring", -) -> FieldPredicate: - key = str(field or "").strip().lower() - if key not in {"path", "summary"}: - raise ValueError(f"unsupported filter field: {field!r}") - kind = str(match or "substring").strip().lower() - if kind not in {"substring", "regex"}: - raise ValueError(f"unsupported filter match: {match!r}") - cleaned = tuple(str(term) for term in terms if str(term)) - return FieldPredicate(field=key, terms=cleaned, match=kind) # type: ignore[arg-type] - - def node_filter(predicates: Sequence[FieldPredicate]) -> NodeFilter: return NodeFilter(predicates=tuple(predicates)) - def apply_node_filter( ts: Any, doc_ids: Sequence[str], @@ -133,52 +109,6 @@ def render_submap_observation( lines.append("\n".join(block)) return "\n".join(lines) - -def _compile_predicates( - predicates: Sequence[FieldPredicate], -) -> Tuple[List[Tuple[FieldPredicate, List[Any]]], List[str]]: - compiled: List[Tuple[FieldPredicate, List[Any]]] = [] - failed: List[str] = [] - for pred in predicates: - if pred.match != "regex": - compiled.append((pred, [])) - continue - patterns: List[Any] = [] - ok = True - for term in pred.terms: - if len(term) > _MAX_REGEX_PATTERN_LEN: - failed.append(f"{pred.field}:regex:too_long") - ok = False - break - try: - patterns.append(re.compile(term, flags=re.IGNORECASE)) - except re.error: - failed.append(f"{pred.field}:regex:invalid") - ok = False - break - if ok: - compiled.append((pred, patterns)) - return compiled, failed - - -def _node_matches( - values: Dict[str, str], - compiled: Sequence[Tuple[FieldPredicate, List[Any]]], -) -> bool: - if not compiled: - return True - for pred, patterns in compiled: - text = values.get(pred.field, "") - if pred.match == "regex": - if not patterns or not any(p.search(text or "") for p in patterns): - return False - continue - haystack = (text or "").lower() - if not pred.terms or not any(term.lower() in haystack for term in pred.terms): - return False - return True - - def _iter_doc_nodes(ts: Any, doc_id: str) -> Iterable[Tuple[str, str, bool]]: yield doc_id, doc_id, True stack = list(_roots(ts, doc_id)) diff --git a/packages/shared-python/shared/services/retrieval/nav/nav_orchestrate.py b/deprecated/mapnav/nav/nav_orchestrate.py similarity index 100% rename from packages/shared-python/shared/services/retrieval/nav/nav_orchestrate.py rename to deprecated/mapnav/nav/nav_orchestrate.py diff --git a/packages/shared-python/shared/services/retrieval/nav/nav_plan.py b/deprecated/mapnav/nav/nav_plan.py similarity index 100% rename from packages/shared-python/shared/services/retrieval/nav/nav_plan.py rename to deprecated/mapnav/nav/nav_plan.py diff --git a/packages/shared-python/shared/services/retrieval/nav/nav_policy.py b/deprecated/mapnav/nav/nav_policy.py similarity index 100% rename from packages/shared-python/shared/services/retrieval/nav/nav_policy.py rename to deprecated/mapnav/nav/nav_policy.py diff --git a/packages/shared-python/shared/services/retrieval/nav/nav_projection.py b/deprecated/mapnav/nav/nav_projection.py similarity index 100% rename from packages/shared-python/shared/services/retrieval/nav/nav_projection.py rename to deprecated/mapnav/nav/nav_projection.py diff --git a/packages/shared-python/shared/services/retrieval/nav/nav_scope_filter.py b/deprecated/mapnav/nav/nav_scope_filter.py similarity index 99% rename from packages/shared-python/shared/services/retrieval/nav/nav_scope_filter.py rename to deprecated/mapnav/nav/nav_scope_filter.py index 886a0527..a381a9a8 100644 --- a/packages/shared-python/shared/services/retrieval/nav/nav_scope_filter.py +++ b/deprecated/mapnav/nav/nav_scope_filter.py @@ -11,11 +11,12 @@ from dataclasses import dataclass, field from typing import Any, Dict, List, Literal, Optional, Sequence +from shared.services.retrieval.scoring.node_filter_predicates import field_predicate + from .nav_node_filter import ( FilterResult, NodeFilter, apply_node_filter, - field_predicate, node_filter, render_submap_observation, ) diff --git a/packages/shared-python/shared/services/retrieval/nav/nav_token_budget.py b/deprecated/mapnav/nav/nav_token_budget.py similarity index 100% rename from packages/shared-python/shared/services/retrieval/nav/nav_token_budget.py rename to deprecated/mapnav/nav/nav_token_budget.py diff --git a/packages/shared-python/shared/services/retrieval/nav/nav_types.py b/deprecated/mapnav/nav/nav_types.py similarity index 100% rename from packages/shared-python/shared/services/retrieval/nav/nav_types.py rename to deprecated/mapnav/nav/nav_types.py diff --git a/packages/shared-python/shared/services/retrieval/nav/nav_verify.py b/deprecated/mapnav/nav/nav_verify.py similarity index 100% rename from packages/shared-python/shared/services/retrieval/nav/nav_verify.py rename to deprecated/mapnav/nav/nav_verify.py diff --git a/packages/shared-python/shared/services/retrieval/nav_bridge.py b/deprecated/mapnav/nav_bridge.py similarity index 100% rename from packages/shared-python/shared/services/retrieval/nav_bridge.py rename to deprecated/mapnav/nav_bridge.py diff --git a/packages/shared-python/shared/services/retrieval/nav_config.py b/deprecated/mapnav/nav_config.py similarity index 100% rename from packages/shared-python/shared/services/retrieval/nav_config.py rename to deprecated/mapnav/nav_config.py diff --git a/packages/shared-python/shared/services/retrieval/nav_llm_backend.py b/deprecated/mapnav/nav_llm_backend.py similarity index 100% rename from packages/shared-python/shared/services/retrieval/nav_llm_backend.py rename to deprecated/mapnav/nav_llm_backend.py diff --git a/packages/shared-python/shared/services/retrieval/nav_snapshot.py b/deprecated/mapnav/nav_snapshot.py similarity index 99% rename from packages/shared-python/shared/services/retrieval/nav_snapshot.py rename to deprecated/mapnav/nav_snapshot.py index 9f92617c..5e1016dd 100644 --- a/packages/shared-python/shared/services/retrieval/nav_snapshot.py +++ b/deprecated/mapnav/nav_snapshot.py @@ -41,12 +41,14 @@ from shared.models.database.job_result import JobResult from shared.services.retrieval.nav.nav_knowhere import ( LazyKnowhereProvider, - KnowhereProvider, NamespaceKnowhereProvider, ReadOnlyChunkStore, + knowhere_database_url, +) +from shared.services.retrieval.scoring.knowhere_provider import ( + KnowhereProvider, SectionRow, UnitRow, - knowhere_database_url, ) from shared.services.retrieval.search.section_filters import is_excluded_section from shared.services.retrieval.serving_manifest import ( diff --git a/apps/api/tests/contract/test_retrieval_lazy_snapshot_quality_contract.py b/deprecated/mapnav/tests/api-contract/test_retrieval_lazy_snapshot_quality_contract.py similarity index 97% rename from apps/api/tests/contract/test_retrieval_lazy_snapshot_quality_contract.py rename to deprecated/mapnav/tests/api-contract/test_retrieval_lazy_snapshot_quality_contract.py index c796e2a1..00855e6b 100644 --- a/apps/api/tests/contract/test_retrieval_lazy_snapshot_quality_contract.py +++ b/deprecated/mapnav/tests/api-contract/test_retrieval_lazy_snapshot_quality_contract.py @@ -6,26 +6,28 @@ import math from typing import Any -from shared.services.retrieval.nav.nav_hierarchy import ProviderToolSpace from shared.services.retrieval.nav.nav_knowhere import ( - KnowhereProvider, LazyKnowhereProvider, NamespaceKnowhereProvider, - SectionRow, - UnitRow, knowhere_database_url, ) from shared.services.retrieval.nav.nav_map_scores import ( - build_score_units, compute_corpus_map_and_unit_scores, compute_corpus_map_and_unit_scores_many, ) -from shared.services.retrieval.nav.knowhere_hybrid import ( +from shared.services.retrieval.scoring.hierarchy import ProviderToolSpace +from shared.services.retrieval.scoring.knowhere_hybrid import ( PersistedBm25Stats, PersistedScoreCorpus, PersistedScoreUnit, score_persisted_corpus_many, ) +from shared.services.retrieval.scoring.knowhere_provider import ( + KnowhereProvider, + SectionRow, + UnitRow, +) +from shared.services.retrieval.scoring.score_units import build_score_units @dataclass diff --git a/apps/api/tests/contract/test_retrieval_lazy_tree_contract.py b/deprecated/mapnav/tests/api-contract/test_retrieval_lazy_tree_contract.py similarity index 93% rename from apps/api/tests/contract/test_retrieval_lazy_tree_contract.py rename to deprecated/mapnav/tests/api-contract/test_retrieval_lazy_tree_contract.py index 9571f151..038756a9 100644 --- a/apps/api/tests/contract/test_retrieval_lazy_tree_contract.py +++ b/deprecated/mapnav/tests/api-contract/test_retrieval_lazy_tree_contract.py @@ -2,9 +2,9 @@ from __future__ import annotations -from shared.services.retrieval.nav.nav_hierarchy import NodeMeta, ProviderToolSpace -from shared.services.retrieval.nav.nav_knowhere import KnowhereProvider, SectionRow -from shared.services.retrieval.nav.nav_map_scores import _walk_tree +from shared.services.retrieval.scoring.hierarchy import NodeMeta, ProviderToolSpace +from shared.services.retrieval.scoring.knowhere_provider import KnowhereProvider, SectionRow +from shared.services.retrieval.scoring.score_units import _walk_tree from shared.services.retrieval.nav._compat import Chunk from shared.services.retrieval.nav.nav_compose import pack_nav_evidence from shared.services.retrieval.nav.nav_types import NavConfig, NavState diff --git a/apps/api/tests/contract/test_retrieval_map_score_parity_contract.py b/deprecated/mapnav/tests/api-contract/test_retrieval_map_score_parity_contract.py similarity index 100% rename from apps/api/tests/contract/test_retrieval_map_score_parity_contract.py rename to deprecated/mapnav/tests/api-contract/test_retrieval_map_score_parity_contract.py diff --git a/apps/api/tests/contract/test_retrieval_map_unit_index_contract.py b/deprecated/mapnav/tests/api-contract/test_retrieval_map_unit_index_contract.py similarity index 99% rename from apps/api/tests/contract/test_retrieval_map_unit_index_contract.py rename to deprecated/mapnav/tests/api-contract/test_retrieval_map_unit_index_contract.py index 5128382f..9cde1b65 100644 --- a/apps/api/tests/contract/test_retrieval_map_unit_index_contract.py +++ b/deprecated/mapnav/tests/api-contract/test_retrieval_map_unit_index_contract.py @@ -13,23 +13,25 @@ DocumentMapUnitIndex, DocumentMapUnitToken, ) -from shared.services.retrieval.nav.nav_hierarchy import ProviderToolSpace from shared.services.retrieval.nav._compat import Chunk, EpisodeResult from shared.services.retrieval.nav import nav_knowhere +from shared.services.retrieval.nav.nav_knowhere import ( + LazyKnowhereProvider, + NamespaceKnowhereProvider, + ReadOnlyChunkStore, +) from shared.services.retrieval.nav.nav_map_scores import ( - build_score_units, compute_corpus_map_and_unit_scores, select_map_highlights, ) from shared.services.retrieval.hydration.connected import hydrate_connected_target_rows -from shared.services.retrieval.nav.nav_knowhere import ( +from shared.services.retrieval.scoring.hierarchy import ProviderToolSpace +from shared.services.retrieval.scoring.knowhere_provider import ( KnowhereProvider, - LazyKnowhereProvider, - NamespaceKnowhereProvider, - ReadOnlyChunkStore, SectionRow, UnitRow, ) +from shared.services.retrieval.scoring.score_units import build_score_units from shared.services.retrieval.nav_snapshot import load_nav_snapshot from shared.services.retrieval.publication_content import ( replace_document_revision_content, diff --git a/apps/api/tests/contract/test_retrieval_mapnav_session_contract.py b/deprecated/mapnav/tests/api-contract/test_retrieval_mapnav_session_contract.py similarity index 98% rename from apps/api/tests/contract/test_retrieval_mapnav_session_contract.py rename to deprecated/mapnav/tests/api-contract/test_retrieval_mapnav_session_contract.py index d267941b..f5ca83f6 100644 --- a/apps/api/tests/contract/test_retrieval_mapnav_session_contract.py +++ b/deprecated/mapnav/tests/api-contract/test_retrieval_mapnav_session_contract.py @@ -16,7 +16,7 @@ ) from shared.services.retrieval.execution.route_types import RetrievalRouteContext from shared.services.retrieval.nav._compat import AgentStep, Chunk, EpisodeResult -from shared.services.retrieval.nav.nav_knowhere import SectionRow, UnitRow +from shared.services.retrieval.scoring.knowhere_provider import SectionRow, UnitRow from shared.services.retrieval.nav_snapshot import build_nav_snapshot RouteRow = dict[str, object] diff --git a/apps/api/tests/contract/test_retrieval_relit_map_cache_contract.py b/deprecated/mapnav/tests/api-contract/test_retrieval_relit_map_cache_contract.py similarity index 100% rename from apps/api/tests/contract/test_retrieval_relit_map_cache_contract.py rename to deprecated/mapnav/tests/api-contract/test_retrieval_relit_map_cache_contract.py diff --git a/apps/api/tests/contract/test_retrieval_snapshot_batching_contract.py b/deprecated/mapnav/tests/api-contract/test_retrieval_snapshot_batching_contract.py similarity index 100% rename from apps/api/tests/contract/test_retrieval_snapshot_batching_contract.py rename to deprecated/mapnav/tests/api-contract/test_retrieval_snapshot_batching_contract.py diff --git a/apps/api/tests/contract/test_retrieval_snapshot_consistency_contract.py b/deprecated/mapnav/tests/api-contract/test_retrieval_snapshot_consistency_contract.py similarity index 100% rename from apps/api/tests/contract/test_retrieval_snapshot_consistency_contract.py rename to deprecated/mapnav/tests/api-contract/test_retrieval_snapshot_consistency_contract.py diff --git a/apps/api/tests/contract/test_retrieval_snapshot_large_corpus_contract.py b/deprecated/mapnav/tests/api-contract/test_retrieval_snapshot_large_corpus_contract.py similarity index 100% rename from apps/api/tests/contract/test_retrieval_snapshot_large_corpus_contract.py rename to deprecated/mapnav/tests/api-contract/test_retrieval_snapshot_large_corpus_contract.py diff --git a/apps/api/tests/contract/test_retrieval_snapshot_redis_contract.py b/deprecated/mapnav/tests/api-contract/test_retrieval_snapshot_redis_contract.py similarity index 100% rename from apps/api/tests/contract/test_retrieval_snapshot_redis_contract.py rename to deprecated/mapnav/tests/api-contract/test_retrieval_snapshot_redis_contract.py diff --git a/packages/shared-python/shared/tests/test_nav_bridge_config.py b/deprecated/mapnav/tests/shared/test_nav_bridge_config.py similarity index 98% rename from packages/shared-python/shared/tests/test_nav_bridge_config.py rename to deprecated/mapnav/tests/shared/test_nav_bridge_config.py index 89e8469d..1ee16569 100644 --- a/packages/shared-python/shared/tests/test_nav_bridge_config.py +++ b/deprecated/mapnav/tests/shared/test_nav_bridge_config.py @@ -14,7 +14,7 @@ os.environ.setdefault("S3_TEMP_PATH", "/tmp") from shared.services.retrieval.nav._compat import Chunk -from shared.services.retrieval.nav.nav_knowhere import SectionRow, UnitRow +from shared.services.retrieval.scoring.knowhere_provider import SectionRow, UnitRow from shared.services.retrieval.nav_bridge import build_referenced_chunks from shared.services.retrieval.nav_config import ( MAPNAV_MODEL, diff --git a/packages/shared-python/shared/tests/test_nav_llm_backend.py b/deprecated/mapnav/tests/shared/test_nav_llm_backend.py similarity index 100% rename from packages/shared-python/shared/tests/test_nav_llm_backend.py rename to deprecated/mapnav/tests/shared/test_nav_llm_backend.py diff --git a/packages/shared-python/shared/tests/test_nav_node_filter.py b/deprecated/mapnav/tests/shared/test_nav_node_filter.py similarity index 93% rename from packages/shared-python/shared/tests/test_nav_node_filter.py rename to deprecated/mapnav/tests/shared/test_nav_node_filter.py index ef662b64..bca04462 100644 --- a/packages/shared-python/shared/tests/test_nav_node_filter.py +++ b/deprecated/mapnav/tests/shared/test_nav_node_filter.py @@ -2,18 +2,18 @@ from __future__ import annotations -from shared.services.retrieval.nav.nav_hierarchy import ProviderToolSpace -from shared.services.retrieval.nav.nav_knowhere import ( +from shared.services.retrieval.nav.nav_knowhere import NamespaceKnowhereProvider +from shared.services.retrieval.scoring.hierarchy import ProviderToolSpace +from shared.services.retrieval.scoring.knowhere_provider import ( KnowhereProvider, - NamespaceKnowhereProvider, SectionRow, ) from shared.services.retrieval.nav.nav_node_filter import ( apply_node_filter, - field_predicate, node_filter, render_submap_observation, ) +from shared.services.retrieval.scoring.node_filter_predicates import field_predicate def _section( diff --git a/packages/shared-python/shared/tests/test_nav_node_filter_wire.py b/deprecated/mapnav/tests/shared/test_nav_node_filter_wire.py similarity index 97% rename from packages/shared-python/shared/tests/test_nav_node_filter_wire.py rename to deprecated/mapnav/tests/shared/test_nav_node_filter_wire.py index f3129736..1025a647 100644 --- a/packages/shared-python/shared/tests/test_nav_node_filter_wire.py +++ b/deprecated/mapnav/tests/shared/test_nav_node_filter_wire.py @@ -4,10 +4,10 @@ from typing import Any -from shared.services.retrieval.nav.nav_hierarchy import ProviderToolSpace -from shared.services.retrieval.nav.nav_knowhere import ( +from shared.services.retrieval.nav.nav_knowhere import NamespaceKnowhereProvider +from shared.services.retrieval.scoring.hierarchy import ProviderToolSpace +from shared.services.retrieval.scoring.knowhere_provider import ( KnowhereProvider, - NamespaceKnowhereProvider, SectionRow, ) from shared.services.retrieval.nav.nav_orchestrate import _execute_subgoal_harvest_once diff --git a/packages/shared-python/shared/tests/test_nav_plan_node_filter.py b/deprecated/mapnav/tests/shared/test_nav_plan_node_filter.py similarity index 100% rename from packages/shared-python/shared/tests/test_nav_plan_node_filter.py rename to deprecated/mapnav/tests/shared/test_nav_plan_node_filter.py diff --git a/packages/shared-python/shared/tests/test_nav_plan_query_only.py b/deprecated/mapnav/tests/shared/test_nav_plan_query_only.py similarity index 100% rename from packages/shared-python/shared/tests/test_nav_plan_query_only.py rename to deprecated/mapnav/tests/shared/test_nav_plan_query_only.py diff --git a/packages/shared-python/shared/tests/test_nav_projection_prod.py b/deprecated/mapnav/tests/shared/test_nav_projection_prod.py similarity index 95% rename from packages/shared-python/shared/tests/test_nav_projection_prod.py rename to deprecated/mapnav/tests/shared/test_nav_projection_prod.py index 1d5219d6..d03a30ba 100644 --- a/packages/shared-python/shared/tests/test_nav_projection_prod.py +++ b/deprecated/mapnav/tests/shared/test_nav_projection_prod.py @@ -12,8 +12,8 @@ os.environ.setdefault("S3_SECRET_ACCESS_KEY", "test") os.environ.setdefault("S3_TEMP_PATH", "/tmp") -from shared.services.retrieval.nav.nav_hierarchy import ProviderToolSpace -from shared.services.retrieval.nav.nav_knowhere import SectionRow, UnitRow +from shared.services.retrieval.scoring.hierarchy import ProviderToolSpace +from shared.services.retrieval.scoring.knowhere_provider import SectionRow, UnitRow from shared.services.retrieval.nav.nav_projection import ( _section_summary_for_map, build_map, diff --git a/packages/shared-python/shared/tests/test_nav_scope_filter.py b/deprecated/mapnav/tests/shared/test_nav_scope_filter.py similarity index 95% rename from packages/shared-python/shared/tests/test_nav_scope_filter.py rename to deprecated/mapnav/tests/shared/test_nav_scope_filter.py index 2228d362..ecf02fc9 100644 --- a/packages/shared-python/shared/tests/test_nav_scope_filter.py +++ b/deprecated/mapnav/tests/shared/test_nav_scope_filter.py @@ -5,13 +5,14 @@ import json from typing import Any, List -from shared.services.retrieval.nav.nav_hierarchy import ProviderToolSpace -from shared.services.retrieval.nav.nav_knowhere import ( +from shared.services.retrieval.nav.nav_knowhere import NamespaceKnowhereProvider +from shared.services.retrieval.scoring.hierarchy import ProviderToolSpace +from shared.services.retrieval.scoring.knowhere_provider import ( KnowhereProvider, - NamespaceKnowhereProvider, SectionRow, ) -from shared.services.retrieval.nav.nav_node_filter import field_predicate, node_filter +from shared.services.retrieval.nav.nav_node_filter import node_filter +from shared.services.retrieval.scoring.node_filter_predicates import field_predicate from shared.services.retrieval.nav.nav_scope_filter import run_scope_filter from shared.services.retrieval.nav.nav_types import NavConfig diff --git a/packages/shared-python/shared/tests/test_nav_snapshot.py b/deprecated/mapnav/tests/shared/test_nav_snapshot.py similarity index 97% rename from packages/shared-python/shared/tests/test_nav_snapshot.py rename to deprecated/mapnav/tests/shared/test_nav_snapshot.py index f8bf76f6..76911ac1 100644 --- a/packages/shared-python/shared/tests/test_nav_snapshot.py +++ b/deprecated/mapnav/tests/shared/test_nav_snapshot.py @@ -13,7 +13,7 @@ import pytest -from shared.services.retrieval.nav.nav_knowhere import SectionRow, UnitRow +from shared.services.retrieval.scoring.knowhere_provider import SectionRow, UnitRow from shared.services.retrieval.nav_snapshot import build_nav_snapshot diff --git a/packages/shared-python/shared/tests/test_nav_stamp.py b/deprecated/mapnav/tests/shared/test_nav_stamp.py similarity index 100% rename from packages/shared-python/shared/tests/test_nav_stamp.py rename to deprecated/mapnav/tests/shared/test_nav_stamp.py diff --git a/packages/shared-python/shared/tests/test_nav_trace_map.py b/deprecated/mapnav/tests/shared/test_nav_trace_map.py similarity index 100% rename from packages/shared-python/shared/tests/test_nav_trace_map.py rename to deprecated/mapnav/tests/shared/test_nav_trace_map.py diff --git a/packages/shared-python/shared/services/retrieval/trace/mapnav.py b/deprecated/mapnav/trace_mapnav.py similarity index 100% rename from packages/shared-python/shared/services/retrieval/trace/mapnav.py rename to deprecated/mapnav/trace_mapnav.py diff --git a/docs/design/retrieval-heart-vessel-trace-optimization-plan.md b/docs/design/retrieval-heart-vessel-trace-optimization-plan.md index ed62a57b..f4521260 100644 --- a/docs/design/retrieval-heart-vessel-trace-optimization-plan.md +++ b/docs/design/retrieval-heart-vessel-trace-optimization-plan.md @@ -17,7 +17,7 @@ request, not a historical request: - Trace: `01a05e530f7787333c0e32ca16633b85` - Route: `/api/v1/retrieval/query` - HTTP status: `200` -- Router: `mapnav` +- Router: `agent_explore` - Stop reason: `completed` - Server span: `42.365 s` - Client wall time: `44.690 s` @@ -302,13 +302,13 @@ Acceptance criteria: demonstrated; otherwise this remains a validation note, not an optimization slice; - selected chunk IDs, scores, ordering, and evidence remain unchanged; -- benchmark results record the route family (`classic`, `mapnav`, or +- benchmark results record the route family (`classic`, `agent_explore`, or `small_corpus`) and separate cold, warm, and response-cache-hit requests; cache-hit timings are not mixed into cold-request latency claims. ### P0: Make map-unit projection token-selective -The current map-nav reader already makes its frequency lookup token-selective, +The current map-unit reader already makes its frequency lookup token-selective, but it still loads every revision-scoped map unit before applying the query tokens. Change only the unit projection: start from `document_map_unit_tokens` filtered by `channel` and `token_hash`, then join the diff --git a/docs/design/retrieval-serving-index-rollout-runbook.md b/docs/design/retrieval-serving-index-rollout-runbook.md index 56d2c3cc..2d91c97b 100644 --- a/docs/design/retrieval-serving-index-rollout-runbook.md +++ b/docs/design/retrieval-serving-index-rollout-runbook.md @@ -153,15 +153,16 @@ index with `DROP INDEX CONCURRENTLY`, and rerun the migration. Deploy the application after the additive migrations finish. New publications will write coherent format-v2 statistics. Existing revisions with NULL channel statistics remain on the full scope-first map-unit reader until maintenance -completes; missing, legacy, or unusable indexes remain on the legacy reader. +completes; missing, legacy, or unusable indexes raise. Immediately verify: - API health checks pass; - no migration or model-loading error appears in API logs; -- classic and map-nav requests still complete; +- classic and agent_explore requests still complete; - incomplete-index warnings distinguish statistics-incomplete map-unit serving - from `fallback=legacy_fts`; neither case may return partial or empty results; + from an unusable index, which must raise; statistics-incomplete serving + must not return partial or empty results; - no increase appears in retrieval errors or timeouts. ## Phase 4: Backfill existing format-v2 indexes @@ -317,12 +318,12 @@ Exercise at least: 1. v1 `use_agentic=false`; 2. v2 `use_agentic=false` with equivalent retrieval fields; -3. one `use_agentic=true` map-nav smoke; -4. one request with `use_agentic` omitted, confirming it routes to map-nav; +3. one `use_agentic=true` agent_explore smoke; +4. one request with `use_agentic` omitted, confirming it routes to agent_explore; 5. one filtered request, confirming filtered-scope semantics and the safe fallback where required. -Map-nav LLM output is nondeterministic. For production smoke, require successful +Agent-explore LLM output is nondeterministic. For production smoke, require successful completion, valid citations, expected namespace isolation, and relevant evidence. Do not require byte-identical ordering between independent Planner runs. Deterministic map-score parity remains covered by the contract suite. @@ -339,13 +340,13 @@ recorded baseline, and retrieval error/timeout rates must not regress. - retrieval request p50/p95 and maximum latency, separated by `router_used`; - classic `search.map_unit_discovery` stages: units, frequencies, indexes, statistics, scoring, and hydration; -- map-nav snapshot, episode, and hydration stages; +- agent_explore episode, tool, and hydration stages; - PostgreSQL statement timeouts, lock waits, CPU, I/O, and connection usage; - Redis errors and namespace snapshot cache misses; -- retrieval errors, incomplete-index fallbacks, and response timeouts; -- process CPU and maximum RSS from the corrected map-nav resource log. +- retrieval errors, unusable-index failures, and response timeouts; +- process CPU and maximum RSS from agent_explore resource logs. -Do not mix classic and map-nav latency distributions. Do not treat Redis-warm +Do not mix classic and agent_explore latency distributions. Do not treat Redis-warm snapshot measurements as cold-request performance. ## Pause and resume @@ -365,7 +366,7 @@ If application errors, timeouts, or quality regressions occur: 1. stop the backfill process; 2. redeploy the previous application version; -3. verify classic and map-nav requests using the frozen quality set; +3. verify classic and agent_explore requests using the frozen quality set; 4. retain the additive columns, index, and already-computed statistics unless database health specifically requires their removal. @@ -387,7 +388,7 @@ Attach the following to the deployment ticket: - final `--check` output; - ready/current revision counts; - frozen-query parity results; -- classic and map-nav latency summaries; +- classic and agent_explore latency summaries; - observed fallback, error, and timeout counts; - rollback decision or explicit confirmation that rollback was not required. diff --git a/docs/design/retrieval-streaming-sse.md b/docs/design/retrieval-streaming-sse.md index b0b5a73e..47420fde 100644 --- a/docs/design/retrieval-streaming-sse.md +++ b/docs/design/retrieval-streaming-sse.md @@ -7,7 +7,7 @@ ## Purpose Online Brain users currently wait for a complete retrieval response while -map-nav planning, searching, source review, and final hydration run. This +agentic retrieval, tool calls, and final hydration run. This design makes that work visible without exposing chain-of-thought or changing who owns answer generation. @@ -61,7 +61,7 @@ response as the authoritative result: "sequence": 7, "elapsed_ms": 2410, "status": "completed", - "response": { "namespace": "default", "query": "...", "router_used": "mapnav", "evidence_text": "...", "referenced_chunks": [], "results": [] } + "response": { "namespace": "default", "query": "...", "router_used": "agent_explore", "evidence_text": "...", "referenced_chunks": [], "results": [] } } ``` @@ -90,9 +90,9 @@ terminal SSE event because the HTTP status can no longer be changed. ## Internal implementation seam -Keep the synchronous map-nav implementation. Add an optional callback that -receives a sanitized progress projection after each completed planner, -search, or review step. The SSE route bridges this callback to an +Keep the live retrieval implementation. Add an optional callback that +receives a sanitized progress projection after each completed agent +or classic step. The SSE route bridges this callback to an `asyncio.Queue` using a thread-safe loop handoff while retrieval continues in its existing worker thread. @@ -106,12 +106,12 @@ Add cooperative cancellation checks between steps. An in-flight synchronous provider call may finish before cancellation takes effect. Cancelled runs do not perform final hydration when cancellation is observed in time. -Phase ownership is explicit: the route emits `started`; the map-nav adapter -emits `planning` before `plan_query` and `searching` before navigation or -classic discovery; the route emits `reviewing_sources` after retrieval -selection and before reference hydration; and it emits `finalizing` before -public projection. Counts are sourced from existing snapshot, reference, and -assembled-result counts and are omitted when not yet known. +Phase ownership is explicit: the route emits `started`; the live route +emits `searching` during classic discovery or agent_explore tool steps; the +route emits `reviewing_sources` after retrieval selection and before +reference hydration; and it emits `finalizing` before public projection. +Counts are sourced from existing reference and assembled-result counts and +are omitted when not yet known. ## Correct duration accounting @@ -132,15 +132,15 @@ Required changes: - pass the execution start timestamp into `TraceRecorder`; - set `retrieval_runs.latency_ms` from that timestamp; - record cache-hit runs with the same definition; -- ensure classic, map-nav, small-corpus, cache-hit, failed, and cancelled +- ensure classic, agent_explore, small-corpus, cache-hit, failed, and cancelled retrievals all have an explicit timing/observability outcome; - expose separate `time_to_first_event_ms`, `retrieval_latency_ms`, and downstream `time_to_first_token_ms` measurements; - retain per-step `elapsed_ms` as step latency, not total request latency. -`retrieval_runs` is the ledger for every retrieval execution, not only -map-nav. Each row records the route type, `agentic_enabled`, `cache_hit`, -canonical latency, and terminal status for classic, map-nav, small-corpus, +`retrieval_runs` is the ledger for every retrieval execution. Each row +records the route type, `agentic_enabled`, `cache_hit`, +canonical latency, and terminal status for classic, agent_explore, small-corpus, cache-hit, failed, and cancelled runs. Add a backward-compatible status field and migration rather than overloading free-form error text. @@ -186,7 +186,7 @@ later local constructor time or include its own flush duration. ## Verification gates -- correct phase order for map-nav, classic, and small-corpus routes; +- correct phase order for agent_explore, classic, and small-corpus routes; - cache-hit streams emit only applicable phases and identify the cache hit; - no sensitive planner or evidence data before the terminal event; - terminal citations match the existing JSON endpoint; diff --git a/packages/shared-python/shared/models/database/document.py b/packages/shared-python/shared/models/database/document.py index 6a46fdd2..5b847ab9 100644 --- a/packages/shared-python/shared/models/database/document.py +++ b/packages/shared-python/shared/models/database/document.py @@ -7,7 +7,6 @@ from uuid import uuid4 from sqlalchemy import ( - Computed, JSON, DateTime, Float, @@ -20,7 +19,6 @@ Text, UniqueConstraint, ) -from sqlalchemy.dialects.postgresql import TSVECTOR from sqlalchemy.orm import Mapped, mapped_column, relationship from shared.core.database import Base @@ -176,22 +174,6 @@ class DocumentChunk(Base): content_search_text: Mapped[Optional[str]] = mapped_column(Text, nullable=True) path_search_text: Mapped[Optional[str]] = mapped_column(Text, nullable=True) term_search_text: Mapped[Optional[str]] = mapped_column(Text, nullable=True) - content_search_tsv: Mapped[Optional[str]] = mapped_column( - TSVECTOR, - Computed( - "to_tsvector('simple', COALESCE(content_search_text, ''))", - persisted=True, - ), - nullable=True, - ) - path_search_tsv: Mapped[Optional[str]] = mapped_column( - TSVECTOR, - Computed( - "to_tsvector('simple', COALESCE(path_search_text, ''))", - persisted=True, - ), - nullable=True, - ) source_chunk_path: Mapped[Optional[str]] = mapped_column(Text, nullable=True) file_path: Mapped[Optional[str]] = mapped_column(Text, nullable=True) chunk_metadata: Mapped[Optional[Dict[str, Any]]] = mapped_column( @@ -232,16 +214,6 @@ class DocumentChunk(Base): "id", ), Index("idx_document_chunks_section", "section_id"), - Index( - "idx_chunk_content_search_tsv", - "content_search_tsv", - postgresql_using="gin", - ), - Index( - "idx_chunk_path_search_tsv", - "path_search_tsv", - postgresql_using="gin", - ), ) diff --git a/packages/shared-python/shared/services/retrieval/agent_explore/__init__.py b/packages/shared-python/shared/services/retrieval/agent_explore/__init__.py index 8988dbd9..d313f765 100644 --- a/packages/shared-python/shared/services/retrieval/agent_explore/__init__.py +++ b/packages/shared-python/shared/services/retrieval/agent_explore/__init__.py @@ -1,19 +1,11 @@ """In-process tool-calling agentic retrieval route. -See ``.cursor/plans/agentic_corpus_explore_retrieval_c2c4ea21.plan.md`` -(Phase 3) for the design. Runs the ``agent_tools`` corpus registry through an -LLM tool-calling loop instead of map-nav's PLANNER/HARVEST/CONTROL episode. -Selected via ``RETRIEVAL_AGENTIC_ROUTER=agent_explore`` -(``execution/routes.py``); ``mapnav`` remains the default until this route -passes its Phase 4 evaluation gate. +Default agentic path when ``use_agentic`` is unset or true. Runs the +``agent_tools`` corpus registry through an LLM tool-calling loop. Map-nav is +archived under ``deprecated/mapnav/`` and is not a live route. -Which provider runs that loop (OpenAI-compatible/DeepSeek, Cursor SDK) is a -second, independent switch — ``AGENT_EXPLORE_HARNESS`` — resolved via -``resolve_harness()``. See ``harness/`` (Phase 3.5) for the pluggable -``Harness`` interface and its two implementations. - -No runtime dependency on ``shared.services.retrieval.nav`` — see -``config.py``'s module docstring. +Which provider runs that loop (Cursor SDK or OpenAI-compatible) is the +``AGENT_EXPLORE_HARNESS`` switch resolved via ``resolve_harness()``. """ from __future__ import annotations diff --git a/packages/shared-python/shared/services/retrieval/agent_explore/bridge.py b/packages/shared-python/shared/services/retrieval/agent_explore/bridge.py index 8b114ed9..4f840426 100644 --- a/packages/shared-python/shared/services/retrieval/agent_explore/bridge.py +++ b/packages/shared-python/shared/services/retrieval/agent_explore/bridge.py @@ -2,9 +2,7 @@ ``DecisionTraceStep`` / ``TraceRecorder`` (``shared/services/retrieval/trace/``) are already provider-agnostic — this module only maps this package's own -``AgentStep`` records onto that shared shape, mirroring what -``trace/mapnav.py`` does for the map-nav episode object, without importing -anything from ``nav/``. +``AgentStep`` records onto that shared shape. """ from __future__ import annotations @@ -12,10 +10,7 @@ from shared.services.retrieval.agent_explore.types import AgentStep from shared.services.retrieval.trace import DecisionTraceStep -# Mirrors nav_config.MAPNAV_TRACE_RAW_CHARS's existing practice of capping -# raw trace text before it goes into the public decision_trace response — -# redeclared locally (not imported) to keep this package decoupled from -# nav_config.py per config.py's module docstring. +# Cap raw trace text before it goes into the public decision_trace response. TRACE_OBSERVATION_MAX_CHARS = 2_000 diff --git a/packages/shared-python/shared/services/retrieval/agent_explore/config.py b/packages/shared-python/shared/services/retrieval/agent_explore/config.py index 961003cf..0d467633 100644 --- a/packages/shared-python/shared/services/retrieval/agent_explore/config.py +++ b/packages/shared-python/shared/services/retrieval/agent_explore/config.py @@ -1,21 +1,9 @@ """Production config for the ``agent_explore`` in-process tool-loop. -Deliberately independent from ``nav_config.py`` / ``nav/`` — see -``nav_config.py``'s "not read by agent_explore" note. This package must have -no runtime import from ``nav/`` so Phase 5 can delete that package once -``agent_explore`` passes its Phase 4 evaluation gate, without having to first -extract anything out of ``nav/`` for this package to keep working. - -Model choice: reuses the same literal model name as ``nav_config.MAPNAV_MODEL`` -(``deepseek-v4-flash``) — not by importing that module, but as its own -constant — because tool-calling (single, parallel, and forced ``tool_choice``) -was verified live against this exact model during Phase 3 design; no other -model has been verified for this codebase's OpenAI-compatible client. - -``AGENT_EXPLORE_MAX_STEPS`` / ``AGENT_EXPLORE_WALL_CLOCK_SECONDS`` are new -product constants (not specified by the plan text, which only named the two -budget *dimensions* to add). Disclosed here rather than buried: revisit in -Phase 4 evaluation once real latency data exists. +Model choice for the OpenAI-compatible harness is ``deepseek-v4-flash``: +tool-calling (single, parallel, and forced ``tool_choice``) was verified +live against this exact model; no other model has been verified for this +codebase's OpenAI-compatible client. """ from __future__ import annotations @@ -32,8 +20,7 @@ AGENT_EXPLORE_CURSOR_MODEL = "composer-2.5" # One LLM turn = one round-trip that may contain several parallel tool calls -# (see episode.py). Kept low relative to map-nav's per-node dispatch depth -# (≤5) because each turn here can already resolve several tools at once. +# (see episode.py). AGENT_EXPLORE_MAX_STEPS = 12 # Wall-clock ceiling for the whole episode (LLM round-trips + tool diff --git a/packages/shared-python/shared/services/retrieval/agent_explore/harness/cursor_harness.py b/packages/shared-python/shared/services/retrieval/agent_explore/harness/cursor_harness.py index 8196f05b..0d84b566 100644 --- a/packages/shared-python/shared/services/retrieval/agent_explore/harness/cursor_harness.py +++ b/packages/shared-python/shared/services/retrieval/agent_explore/harness/cursor_harness.py @@ -9,11 +9,10 @@ ``config.AGENT_EXPLORE_CURSOR_MODEL``, e.g. ``composer-2.5``) instead of DeepSeek. -Requires the optional ``cursor-sdk`` dependency (see -``apps/worker/pyproject.toml``'s ``cursor-harness`` extra) and -``CURSOR_API_KEY``. The guarded import in ``_require_cursor_sdk`` raises a -clear, actionable error at harness-selection time if ``cursor-sdk`` isn't -installed, instead of failing deep inside a running episode. +Requires ``cursor-sdk`` (base dependency of ``apps/api``; worker debug +scripts use the ``cursor-harness`` extra) and ``CURSOR_API_KEY``. The +guarded import in ``_require_cursor_sdk`` raises a clear error at episode +start if ``cursor-sdk`` isn't installed. Architectural difference from ``openai_harness.py`` that budget enforcement has to work around: this harness does not control the LLM turn loop. @@ -116,10 +115,10 @@ def _require_cursor_sdk() -> Any: import cursor_sdk except ImportError as exc: raise RuntimeError( - "AGENT_EXPLORE_HARNESS=cursor_sdk requires the optional " + "AGENT_EXPLORE_HARNESS=cursor_sdk requires the " "'cursor-sdk' dependency, which is not installed in this " - "interpreter. Install with: uv sync --extra cursor-harness " - "(apps/worker/pyproject.toml)." + "interpreter. For the API service it is a base dependency " + "(apps/api). Worker debug scripts: uv sync --extra cursor-harness." ) from exc return cursor_sdk diff --git a/packages/shared-python/shared/services/retrieval/agent_explore/harness/openai_harness.py b/packages/shared-python/shared/services/retrieval/agent_explore/harness/openai_harness.py index 0f86c2fd..2d48f505 100644 --- a/packages/shared-python/shared/services/retrieval/agent_explore/harness/openai_harness.py +++ b/packages/shared-python/shared/services/retrieval/agent_explore/harness/openai_harness.py @@ -18,7 +18,7 @@ by design, not because concurrent dispatch would be unsafe (it no longer is — see ``dispatch.py``). -No import from ``nav/`` or ``nav_config.py`` — see ``config.py``. +No import from archived map-nav modules. Two Phase 4 fixes (audited live against the eval fixture in ``apps/worker/scripts/fixtures/changheba_archive_eval_queries.json``), both @@ -89,9 +89,7 @@ def _resolve_client_and_model() -> tuple[Any, str]: - """Mirrors ``nav_llm_backend.nav_chat_sync_backend``'s resolve pattern, - pinned to ``AGENT_EXPLORE_MODEL`` instead of ``nav_config.MAPNAV_MODEL``. - """ + """Resolve the OpenAI-compatible client and ``AGENT_EXPLORE_MODEL``.""" from shared.services.ai.llm_overrides import resolve_text from shared.services.ai.openai_compatible_client_sync import get_openai_client diff --git a/packages/shared-python/shared/services/retrieval/agent_explore/harness/resolve.py b/packages/shared-python/shared/services/retrieval/agent_explore/harness/resolve.py index 449a636e..cf19cd86 100644 --- a/packages/shared-python/shared/services/retrieval/agent_explore/harness/resolve.py +++ b/packages/shared-python/shared/services/retrieval/agent_explore/harness/resolve.py @@ -1,7 +1,6 @@ -"""``AGENT_EXPLORE_HARNESS`` env switch — same pattern as ``execution/routes.py``'s -``_resolve_agentic_router()`` (``RETRIEVAL_AGENTIC_ROUTER``): unrecognized or -unset values fall back to the default rather than raising, so a typo'd env -var degrades to known-good behavior instead of breaking the route. +"""``AGENT_EXPLORE_HARNESS`` env switch: unrecognized or unset values fall +back to the default rather than raising, so a typo'd env var degrades to +known-good behavior instead of breaking the route. Each branch below imports its harness implementation lazily so that selecting ``openai`` never imports ``cursor_sdk``-dependent code (and @@ -16,11 +15,11 @@ _HARNESS_ENV = "AGENT_EXPLORE_HARNESS" _HARNESSES = {"openai", "cursor_sdk"} -_DEFAULT_HARNESS = "openai" +_DEFAULT_HARNESS = "cursor_sdk" def resolve_harness_name() -> str: - """``openai`` (default, current production behavior) or ``cursor_sdk``.""" + """``cursor_sdk`` (default) or ``openai``.""" value = os.environ.get(_HARNESS_ENV, "").strip().lower() return value if value in _HARNESSES else _DEFAULT_HARNESS diff --git a/packages/shared-python/shared/services/retrieval/agent_explore/shared.py b/packages/shared-python/shared/services/retrieval/agent_explore/shared.py index 6b37821f..36223c4d 100644 --- a/packages/shared-python/shared/services/retrieval/agent_explore/shared.py +++ b/packages/shared-python/shared/services/retrieval/agent_explore/shared.py @@ -57,7 +57,7 @@ def tool_message_content(result: ToolResult, *, max_chars: int) -> str: """Cap a tool's rendered text before it enters LLM context. Uses the caller's ``ToolBudget.max_chars`` (``EVIDENCE_TEXT_CHAR_BUDGET``, - aligned with map-nav evidence packing — see ``agent_tools/registry.py``) + aligned with evidence packing — see ``agent_tools/registry.py``) so tools like ``read`` can return unbounded body text while the harness still bounds what the model sees per turn. This cap applies uniformly to every tool's rendered text (not just ``read``'s body content) — a tool diff --git a/packages/shared-python/shared/services/retrieval/agent_tools/registry.py b/packages/shared-python/shared/services/retrieval/agent_tools/registry.py index 61861fd0..d5aa871c 100644 --- a/packages/shared-python/shared/services/retrieval/agent_tools/registry.py +++ b/packages/shared-python/shared/services/retrieval/agent_tools/registry.py @@ -39,7 +39,7 @@ class ToolBudget: context. Applied in ``agent_explore.shared.tool_message_content`` (not inside individual tools) so ``read`` can return full body text from the tool while the harness still bounds what the model sees per turn. Aligned - with map-nav final evidence packing via ``EVIDENCE_TEXT_CHAR_BUDGET`` + with final evidence packing via ``EVIDENCE_TEXT_CHAR_BUDGET`` (12_000). """ diff --git a/packages/shared-python/shared/services/retrieval/agent_tools/tools/node_filter.py b/packages/shared-python/shared/services/retrieval/agent_tools/tools/node_filter.py index d715a063..6c0269fa 100644 --- a/packages/shared-python/shared/services/retrieval/agent_tools/tools/node_filter.py +++ b/packages/shared-python/shared/services/retrieval/agent_tools/tools/node_filter.py @@ -1,7 +1,7 @@ """``corpus.node_filter`` — deterministic FOR-ALL/EXISTS/ANY/NOT predicate over sections. Reuses the exact predicate compile/match semantics from -``nav.nav_node_filter`` (path/summary substring|regex, fields AND together, +``scoring.node_filter_predicates`` (path/summary substring|regex, fields AND together, terms OR together) — see that module's docstring — but walks ``document_sections`` rows for the requested documents' current revision instead of the in-memory map-nav tree. No top-K: returns the full matched set @@ -20,7 +20,7 @@ ToolResult, register_tool, ) -from shared.services.retrieval.nav.nav_node_filter import ( +from shared.services.retrieval.scoring.node_filter_predicates import ( FieldPredicate, _compile_predicates, _node_matches, diff --git a/packages/shared-python/shared/services/retrieval/agent_tools/tools/recall.py b/packages/shared-python/shared/services/retrieval/agent_tools/tools/recall.py index 57523885..6e906cd6 100644 --- a/packages/shared-python/shared/services/retrieval/agent_tools/tools/recall.py +++ b/packages/shared-python/shared/services/retrieval/agent_tools/tools/recall.py @@ -8,9 +8,7 @@ (persisted map-unit BM25 over path+content, already RRF-fused internally). - ``term``: a fresh substring channel over ``document_map_units.term_search_text_lower`` — this column is persisted at - index time but, before this tool, was only read by map_unit_discovery's - *legacy* PG-FTS fallback, never as an independently-ranked channel (see - ``search/map_unit_discovery.py`` module docstring and AGENTS.md Stage ⑤). + index time and is ranked here as an independent substring channel. ``vector`` is accepted in ``channels`` but rejected as reserved/not implemented (``CORPUS_SCHEMA.md`` §5) — it is not silently ignored. @@ -19,7 +17,7 @@ channel (term) at equal RRF weight is a necessary, disclosed design choice: there is no persisted precedent for a different weight ratio between them (the old 3-channel weights of path=1.0/content=2.0/term=1.5 no longer exist -in code — only path=1.0/content=2.0 survive in ``nav.knowhere_hybrid``). +in code — only path=1.0/content=2.0 survive in ``scoring.knowhere_hybrid``). The term channel's snippet and the rendered ``text`` preview both go through the shared ``agent_tools.snippet.build_snippet`` (head + first-match window + diff --git a/packages/shared-python/shared/services/retrieval/cache_service.py b/packages/shared-python/shared/services/retrieval/cache_service.py index c327eb46..07ea6b6a 100644 --- a/packages/shared-python/shared/services/retrieval/cache_service.py +++ b/packages/shared-python/shared/services/retrieval/cache_service.py @@ -79,6 +79,7 @@ def _cache_shape_digest( use_agentic: bool | None = None, llm_text_model: str | None = None, llm_vision_model: str | None = None, + harness: str | None = None, ) -> str: normalized_excludes = sorted(exclude_document_ids) normalized_sections = _normalize_exclude_sections(exclude_sections) @@ -96,6 +97,7 @@ def _cache_shape_digest( str(use_agentic), str(llm_text_model or ""), str(llm_vision_model or ""), + str(harness or ""), ] ) payload = f"{query}|{top_k}|{'|'.join(normalized_excludes)}|{'|'.join(normalized_sections)}|{extra}" diff --git a/packages/shared-python/shared/services/retrieval/execution/query_request.py b/packages/shared-python/shared/services/retrieval/execution/query_request.py index 9e831bce..c6d3488d 100644 --- a/packages/shared-python/shared/services/retrieval/execution/query_request.py +++ b/packages/shared-python/shared/services/retrieval/execution/query_request.py @@ -8,6 +8,7 @@ from shared.models.schemas.llm_config import LLMConfig from shared.models.schemas.retrieval_namespace import normalize_retrieval_namespace from shared.services.retrieval.execution.route_types import RetrievalRouteContext +from shared.services.retrieval.agent_explore.harness.resolve import resolve_harness_name from shared.services.retrieval.settings import ( INTERNAL_RECALL_K_MULTIPLIER, ) @@ -98,6 +99,9 @@ def build_cache_extra(self) -> dict[str, Any]: "use_agentic": self.use_agentic, "llm_text_model": text_model, "llm_vision_model": vision_model, + "harness": ( + resolve_harness_name() if self.use_agentic is not False else "classic" + ), } def resolve_allowed_chunk_types(self) -> set[str] | None: diff --git a/packages/shared-python/shared/services/retrieval/execution/response_projection.py b/packages/shared-python/shared/services/retrieval/execution/response_projection.py index 656533c6..1ac29076 100644 --- a/packages/shared-python/shared/services/retrieval/execution/response_projection.py +++ b/packages/shared-python/shared/services/retrieval/execution/response_projection.py @@ -16,7 +16,7 @@ def to_public_source(row: dict[str, Any]) -> dict[str, Any]: async def enrich_referenced_chunks_with_asset_url(refs: list[dict[str, Any]]) -> list[dict[str, Any]]: return await enrich_rows_with_retrieval_asset_url( refs, - log_context='mapnav referenced chunk', + log_context='referenced chunk', ) diff --git a/packages/shared-python/shared/services/retrieval/execution/routes.py b/packages/shared-python/shared/services/retrieval/execution/routes.py index 66811ef8..b14a84e9 100644 --- a/packages/shared-python/shared/services/retrieval/execution/routes.py +++ b/packages/shared-python/shared/services/retrieval/execution/routes.py @@ -1,8 +1,5 @@ from __future__ import annotations -import asyncio -import os -import resource import time from contextlib import AbstractAsyncContextManager @@ -26,10 +23,6 @@ count_scoped_chunks, load_all_scoped_chunks, ) -from shared.services.retrieval.execution.revision_pins import ( - capture_revision_pins, - is_revision_generation_stable, -) def open_fresh_database_context() -> AbstractAsyncContextManager[AsyncSession]: @@ -61,20 +54,6 @@ def _render_rows_evidence(rows: list[dict]) -> str: return render_evidence_blocks(list(groups.items())) -_AGENTIC_ROUTERS = {"mapnav", "agent_explore"} -_AGENTIC_ROUTER_ENV = "RETRIEVAL_AGENTIC_ROUTER" - - -def _resolve_agentic_router() -> str: - """``RETRIEVAL_AGENTIC_ROUTER`` env switch: ``mapnav`` (default, current - production route) or ``agent_explore`` (Phase 3 of - ``.cursor/plans/agentic_corpus_explore_retrieval_c2c4ea21.plan.md``, - pending its Phase 4 evaluation gate before it can become the default). - """ - value = os.environ.get(_AGENTIC_ROUTER_ENV, "").strip().lower() - return value if value in _AGENTIC_ROUTERS else "mapnav" - - async def run_retrieval_route( context: RetrievalRouteContext, ) -> RetrievalRouteOutcome: @@ -82,14 +61,10 @@ async def run_retrieval_route( if small_corpus_outcome is not None: return small_corpus_outcome - # Explicit False → classic map-unit BM25 top-K. None/True → agentic, - # routed by RETRIEVAL_AGENTIC_ROUTER (default mapnav). + # Explicit False → classic map-unit BM25 top-K. None/True → agent_explore. if context.use_agentic is False: return await _run_classic_topk_route(context) - - if _resolve_agentic_router() == "agent_explore": - return await _run_agent_explore_route(context) - return await _run_mapnav_route(context) + return await _run_agent_explore_route(context) async def _try_run_small_corpus_route( @@ -211,16 +186,10 @@ async def _run_classic_topk_route( async def _run_agent_explore_route( context: RetrievalRouteContext, ) -> RetrievalRouteOutcome: - """Phase 3 agentic path: in-process ``corpus.*`` tool-calling loop. + """Default agentic path: in-process ``corpus.*`` tool-calling loop. - Selected when ``RETRIEVAL_AGENTIC_ROUTER=agent_explore``; ``mapnav`` - remains the default route until this one passes its Phase 4 evaluation - gate. See ``shared/services/retrieval/agent_explore/``. - - Which provider actually runs the tool-calling loop (OpenAI-compatible - default, or Cursor SDK) is the separate ``AGENT_EXPLORE_HARNESS`` switch - (Phase 3.5) resolved by ``resolve_harness()`` below — independent of - this route selection. + Which provider actually runs the tool-calling loop is the + ``AGENT_EXPLORE_HARNESS`` switch resolved by ``resolve_harness()``. """ from shared.services.retrieval.agent_explore.bridge import build_decision_trace from shared.services.retrieval.agent_explore.budget import EpisodeBudget @@ -325,199 +294,3 @@ async def _run_agent_explore_route( ), ) - -async def _run_mapnav_route( - context: RetrievalRouteContext, -) -> RetrievalRouteOutcome: - """Default agentic path: PLANNER + HARVEST + CONTROL (checklist map-nav). - - LEGACY, PENDING REPLACEMENT: ``agent_explore`` will become the default - agentic route once it passes its evaluation gate; this route then stays - only as the ``RETRIEVAL_AGENTIC_ROUTER=mapnav`` fallback until Phase 5 - cleanup. Do not add new capabilities here — new agentic-retrieval work - belongs in ``shared/services/retrieval/agent_tools/`` and - ``shared/services/retrieval/agent_explore/``. - """ - process_started = resource.getrusage(resource.RUSAGE_SELF) - from shared.services.retrieval import nav_llm_backend # noqa: F401 - from shared.services.retrieval.nav import run_nav_episode - from shared.services.retrieval.nav.nav_hierarchy import ProviderToolSpace - from shared.services.retrieval.nav_bridge import build_referenced_chunks - from shared.services.retrieval.nav_config import ( - MAPNAV_MODEL, - build_nav_config, - nav_evidence_chars, - ) - from shared.services.retrieval.nav_snapshot import load_nav_snapshot - from shared.services.retrieval.trace import ( - TraceRecorder, - build_decision_trace, - episode_selected_doc_ids, - episode_selected_paths, - episode_token_count, - episode_workflow_plan, - ) - - snapshot_started = time.perf_counter() - snapshot_pins = context.revision_pins - snapshot = await load_nav_snapshot( - context.db, - user_id=context.user_id, - namespace=context.namespace, - exclude_document_ids=context.exclude_document_ids, - exclude_sections=context.exclude_sections, - lazy=True, - revision_pins=snapshot_pins, - generation=(snapshot_pins.generation if snapshot_pins is not None else None), - ) - if snapshot_pins is not None and not await is_revision_generation_stable( - context.db, - user_id=context.user_id, - namespace=context.namespace, - pins=snapshot_pins, - ): - snapshot.close() - snapshot_pins = await capture_revision_pins( - context.db, - user_id=context.user_id, - namespace=context.namespace, - ) - snapshot = await load_nav_snapshot( - context.db, - user_id=context.user_id, - namespace=context.namespace, - exclude_document_ids=context.exclude_document_ids, - exclude_sections=context.exclude_sections, - lazy=True, - revision_pins=snapshot_pins, - generation=(snapshot_pins.generation if snapshot_pins is not None else None), - ) - snapshot_seconds = time.perf_counter() - snapshot_started - logger.info( - "retrieval mapnav stage=snapshot_load seconds={:.3f} documents={} refs={} " - "conversation_id={}".format( - snapshot_seconds, - len(snapshot.document_ids), - len(snapshot.chunk_ref_index), - context.conversation_id or "", - ) - ) - - # Small-corpus count / snapshot reads may leave a checkout; drop it before - # the sync LLM episode (same pattern as the retired workflow route). - await context.db.rollback() - - budget = nav_evidence_chars() - cfg = build_nav_config() - toolspace = ProviderToolSpace(snapshot.provider) - - episode_started = time.perf_counter() - try: - episode = await asyncio.to_thread( - run_nav_episode, - None, - context.query, - corpus_doc_ids=list(snapshot.document_ids), - budget_chars=budget, - compose_answer=False, - policy="llm", - config=cfg, - toolspace=toolspace, - ) - - refs, score_by_chunk_id = build_referenced_chunks(episode, snapshot) - logger.info( - "retrieval mapnav stage=episode seconds={:.3f} refs={}".format( - time.perf_counter() - episode_started, - len(refs), - ) - ) - finally: - snapshot.close() - - hydration_started = time.perf_counter() - async with open_fresh_database_context() as final_db: - resolved = await resolve_workflow_references( - db=final_db, - user_id=context.user_id, - namespace=context.namespace, - refs=refs, - score_by_chunk_id=score_by_chunk_id or None, - revision_pins=snapshot.document_revisions, - ) - assembled_rows = await assemble_retrieval_results( - db=final_db, - rows=resolved.rows, - exclude_document_ids=context.exclude_document_ids, - exclude_sections=context.exclude_sections, - allowed_chunk_types=context.allowed_chunk_types, - revision_pins=snapshot.document_revisions, - ) - - decision_steps = build_decision_trace( - episode, - evidence_char_budget=budget, - n_refs=len(resolved.refs), - ) - decision_trace = [step.to_dict() for step in decision_steps] - selected_paths = episode_selected_paths(episode, resolved.refs) - selected_docs = episode_selected_doc_ids(resolved.refs) - tokens_used = episode_token_count(episode) - trace = TraceRecorder( - final_db, - user_id=context.user_id, - namespace=context.namespace, - query=context.query, - top_k=context.top_k, - chunk_types=context.allowed_chunk_types, - workflow_plan=episode_workflow_plan(episode), - policy_name="mapnav_checklist_v1", - ) - await trace.create_run() - for step in decision_steps: - trace.record_decision_trace_step(step) - await trace.complete( - assembled_rows, - "mapnav", - token_count=tokens_used, - model_name=MAPNAV_MODEL, - selected_paths=selected_paths, - selected_doc_ids=selected_docs, - ) - logger.info( - "retrieval mapnav stage=hydration seconds={:.3f} results={}".format( - time.perf_counter() - hydration_started, - len(assembled_rows), - ) - ) - - stop_reason = str(getattr(episode, "stop_reason", "") or "completed") - evidence_text = str(getattr(episode, "evidence_text", "") or "") - response = { - "namespace": context.namespace, - "query": context.query, - "router_used": "mapnav", - "evidence_text": evidence_text, - "answer_text": "", - "referenced_chunks": resolved.refs, - "results": assembled_rows, - "stop_reason": stop_reason, - "decision_trace": decision_trace, - } - - completion_detail = f"chunks | evidence={len(evidence_text)} chars | router=mapnav" - process_finished = resource.getrusage(resource.RUSAGE_SELF) - logger.info( - "retrieval mapnav stage=process_resources cpu_seconds={:.3f} " - "process_max_rss_kb={}", - (process_finished.ru_utime + process_finished.ru_stime) - - (process_started.ru_utime + process_started.ru_stime), - int(process_finished.ru_maxrss), - ) - return RetrievalRouteOutcome( - response=response, - hit_stats_results=resolved.refs, - completion_label="MAPNAV RETRIEVAL", - completion_count=len(resolved.refs), - completion_detail=completion_detail, - ) diff --git a/packages/shared-python/shared/services/retrieval/map_unit_index.py b/packages/shared-python/shared/services/retrieval/map_unit_index.py index ab7a0523..c58e5f8e 100644 --- a/packages/shared-python/shared/services/retrieval/map_unit_index.py +++ b/packages/shared-python/shared/services/retrieval/map_unit_index.py @@ -1,4 +1,4 @@ -"""Publication-time materialization of exact map-nav lexical units.""" +"""Publication-time materialization of persisted map-unit lexical units.""" from __future__ import annotations @@ -16,15 +16,15 @@ DocumentMapUnitToken, DocumentSection, ) -from shared.services.retrieval.nav.nav_hierarchy import ProviderToolSpace -from shared.services.retrieval.nav.nav_knowhere import ( +from shared.services.retrieval.scoring.hierarchy import ProviderToolSpace +from shared.services.retrieval.scoring.knowhere_hybrid import MAP_UNIT_INDEX_FORMAT_VERSION +from shared.services.retrieval.scoring.knowhere_provider import ( KnowhereProvider, SectionRow, UnitRow, ) -from shared.services.retrieval.nav.nav_map_scores import build_score_units -from shared.services.retrieval.nav.knowhere_hybrid import MAP_UNIT_INDEX_FORMAT_VERSION -from shared.services.retrieval.nav.persisted_score_load import average_idf_from_unit_dfs +from shared.services.retrieval.scoring.persisted_score_load import average_idf_from_unit_dfs +from shared.services.retrieval.scoring.score_units import build_score_units from shared.services.retrieval.publication_models import DocumentPublicationScope __all__ = ["MAP_UNIT_INDEX_FORMAT_VERSION", "replace_document_map_units"] diff --git a/packages/shared-python/shared/services/retrieval/scoring/__init__.py b/packages/shared-python/shared/services/retrieval/scoring/__init__.py new file mode 100644 index 00000000..404eaf74 --- /dev/null +++ b/packages/shared-python/shared/services/retrieval/scoring/__init__.py @@ -0,0 +1 @@ +"""Shared retrieval scoring primitives used by publication and classic recall.""" diff --git a/packages/shared-python/shared/services/retrieval/nav/nav_hierarchy.py b/packages/shared-python/shared/services/retrieval/scoring/hierarchy.py similarity index 75% rename from packages/shared-python/shared/services/retrieval/nav/nav_hierarchy.py rename to packages/shared-python/shared/services/retrieval/scoring/hierarchy.py index 577de014..f9dcfe0d 100644 --- a/packages/shared-python/shared/services/retrieval/nav/nav_hierarchy.py +++ b/packages/shared-python/shared/services/retrieval/scoring/hierarchy.py @@ -21,7 +21,7 @@ from __future__ import annotations -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import ( Any, Dict, @@ -41,7 +41,17 @@ ) if TYPE_CHECKING: - from .knowhere_hybrid import PersistedScoreCorpus + from shared.services.retrieval.scoring.knowhere_hybrid import PersistedScoreCorpus + + +@dataclass +class Chunk: + node_id: str + doc_id: str + text: str + line_ids: Tuple[int, ...] + section_id: Optional[str] = None + text_line_id_groups: Optional[Tuple[Tuple[int, ...], ...]] = None @dataclass @@ -107,10 +117,6 @@ class ProviderToolSpace: def __init__(self, provider: HierarchyProvider) -> None: self._provider = provider - def address_level(self, node_id: str): - fn = getattr(self._provider, "address_level", None) - return fn(node_id) if callable(fn) else None - def owner_document(self, node_id: str) -> Optional[str]: fn = getattr(self._provider, "owner_document", None) if not callable(fn): @@ -123,7 +129,7 @@ def document_ids(self) -> List[str]: fn = getattr(self._provider, "document_ids", None) if not callable(fn): return [] - return [str(x) for x in (fn() or ()) if str(x).strip()] + return [str(x) for x in cast(Sequence[Any], fn() or ()) if str(x).strip()] def sections_for_doc(self, doc_id: str) -> List[str]: return [str(s) for s in self._provider.roots(doc_id)] @@ -190,7 +196,7 @@ def _node_unit_span(self, section_id: str) -> Tuple[str, int, int]: unit_text = getattr(self._provider, "unit_text", None) if not callable(self_units) or not callable(unit_text): return "", 0, 0 - units = list(self_units(section_id) or ()) + units = list(cast(Sequence[Any], self_units(section_id) or ())) if not units: return "", 0, 0 first_order = int(getattr(units[0], "sort_order", 0) or 0) @@ -251,8 +257,6 @@ def _node_unit_span(self, section_id: str) -> Tuple[str, int, int]: def _make_chunk( self, node_id: str, doc_id: str, text: str, order: int, section_id: str ) -> Any: - from ._compat import Chunk # type: ignore - return Chunk( node_id=node_id, doc_id=doc_id, @@ -267,7 +271,7 @@ def materialize_self_only_chunks(self, section_id: str, doc_id: str) -> List[Any if not callable(self_units) or not callable(unit_text): return [] out: List[Any] = [] - for unit in self_units(section_id) or (): + for unit in cast(Sequence[Any], self_units(section_id) or ()): text = str(unit_text(unit) or "").strip() if not text: continue @@ -293,9 +297,9 @@ def _materialize_leaf_path_chunks(self, section_id: str, doc_id: str) -> List[An ] # One unit per descendant leaf, plus one per interstitial parent, so - # node ids line up with the keys nav_map_scores.build_score_units emits. + # node ids line up with the keys scoring.score_units.build_score_units emits. out: List[Any] = [] - for leaf_id in leaf_fn(section_id) or (): + for leaf_id in cast(Sequence[Any], leaf_fn(section_id) or ()): text, order, _count = self._node_unit_span(leaf_id) if text: out.append(self._make_chunk(leaf_id, doc_id, text, order, leaf_id)) @@ -320,102 +324,3 @@ def load_persisted_score_corpus( return None return cast(Optional["PersistedScoreCorpus"], fn(doc_ids, queries)) - -@dataclass -class InMemoryNode: - section_id: str - title: str - content: str = "" - children: List[str] = field(default_factory=list) - - -class InMemoryHierarchyProvider: - """Minimal reference ``HierarchyProvider``: no scoring, no ToolSpace. - - Built directly from a ``{doc_id: [InMemoryNode, ...]}`` map plus a - ``{doc_id: [root_section_id, ...]}`` map — the "hierarchy + summary is - enough" claim's simplest possible witness. - """ - - def __init__( - self, - *, - roots_by_doc: Dict[str, Sequence[str]], - nodes: Dict[str, InMemoryNode], - summaries: Optional[Dict[str, str]] = None, - ) -> None: - self._roots_by_doc = {k: list(v) for k, v in roots_by_doc.items()} - self._nodes = dict(nodes) - self._summaries = dict(summaries or {}) - self._parent: Dict[str, str] = {} - for node in self._nodes.values(): - for child_id in node.children: - self._parent[child_id] = node.section_id - self._owner: Dict[str, str] = {} - for doc_id, root_ids in self._roots_by_doc.items(): - stack = list(root_ids) - while stack: - sid = stack.pop() - if sid in self._owner: - continue - self._owner[sid] = doc_id - node = self._nodes.get(sid) - if node: - stack.extend(node.children) - - def owner_document(self, node_id: str) -> Optional[str]: - return self._owner.get(str(node_id or "").strip()) - - def roots(self, doc_id: str) -> Sequence[str]: - return list(self._roots_by_doc.get(doc_id, ())) - - def children(self, section_id: str) -> Sequence[str]: - node = self._nodes.get(section_id) - return list(node.children) if node else [] - - def node_meta(self, section_id: str) -> NodeMeta: - node = self._nodes.get(section_id) - if node is None: - return NodeMeta() - return NodeMeta( - title=node.title, - summary=self._summaries.get(section_id, ""), - has_children=bool(node.children), - ) - - def parent_id(self, section_id: str) -> Optional[str]: - return self._parent.get(section_id) - - def relations(self, section_id: str) -> Tuple[Set[str], Set[str]]: - ancestors: Set[str] = set() - cur = self._parent.get(section_id) - while cur: - ancestors.add(cur) - cur = self._parent.get(cur) - descendants: Set[str] = set() - stack = list(self.children(section_id)) - while stack: - cid = stack.pop() - if cid in descendants: - continue - descendants.add(cid) - stack.extend(self.children(cid)) - return ancestors, descendants - - def content(self, section_id: str) -> str: - node = self._nodes.get(section_id) - if node is None: - return "" - parts: List[str] = [] - - def walk(sid: str) -> None: - cur = self._nodes.get(sid) - if cur is None: - return - if cur.content: - parts.append(cur.content) - for cid in cur.children: - walk(cid) - - walk(section_id) - return "\n".join(parts) diff --git a/packages/shared-python/shared/services/retrieval/nav/knowhere_hybrid.py b/packages/shared-python/shared/services/retrieval/scoring/knowhere_hybrid.py similarity index 99% rename from packages/shared-python/shared/services/retrieval/nav/knowhere_hybrid.py rename to packages/shared-python/shared/services/retrieval/scoring/knowhere_hybrid.py index 6e5a95f8..ca73845b 100644 --- a/packages/shared-python/shared/services/retrieval/nav/knowhere_hybrid.py +++ b/packages/shared-python/shared/services/retrieval/scoring/knowhere_hybrid.py @@ -247,7 +247,7 @@ def empty(cls) -> "_StreamingBm25Stats": def score( self, document_length: int, - frequencies: Dict[str, int], + frequencies: Mapping[str, int], query_tokens: List[str], ) -> float: if ( diff --git a/packages/shared-python/shared/services/retrieval/scoring/knowhere_provider.py b/packages/shared-python/shared/services/retrieval/scoring/knowhere_provider.py new file mode 100644 index 00000000..34c3b5c6 --- /dev/null +++ b/packages/shared-python/shared/services/retrieval/scoring/knowhere_provider.py @@ -0,0 +1,342 @@ +"""Publication-time hierarchy provider over section/chunk rows. + +Extracted from the map-nav package so document publication and classic +recall can build map-unit indexes without the episode kernel. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import ( + Any, + Callable, + Dict, + List, + Optional, + Sequence, + Set, + Tuple, +) + +from shared.services.retrieval.scoring.hierarchy import NodeMeta + + +_ASSET_TYPES = ("table", "image") +# Body chunk types that can own a Root-parked asset via connect_to. Both +# chunk-track ("text") and page-track ("page") body chunks can embed assets. +_BODY_CHUNK_TYPES = ("text", "page") +# Knowhere sentinel path for the virtual document container (not a collectable leaf). +ROOT_SECTION_PATH = "Root" + + +@dataclass(frozen=True) +class SectionRow: + """One ``document_sections`` row.""" + + section_id: str + parent_section_id: Optional[str] + section_path: str + section_title: str + section_level: int + summary: str + sort_order: int + + +@dataclass(frozen=True) +class UnitRow: + """One ``document_chunks`` row.""" + + chunk_id: str + section_id: Optional[str] + chunk_type: str + content: str + sort_order: int + source_chunk_path: str = "" + file_path: str = "" + metadata: Dict[str, Any] = field(default_factory=dict) + + +def asset_display_text(unit: UnitRow) -> str: + """Body text for an asset unit, whose ``content`` is only a file path. + + Mirrors knowhere's own assembly: an asset contributes its summary, not its + path. Without this an asset unit is unscorable and unreadable. + """ + meta = unit.metadata or {} + title = str(meta.get("asset_title") or "").strip() + summary = str(meta.get("summary") or "").strip() + ref = unit.file_path or unit.source_chunk_path or unit.content + label = "Table" if unit.chunk_type == "table" else "Image" + parts = [f"[{label}: {ref}]"] if ref else [f"[{label}]"] + if title: + parts.append(title) + if summary: + parts.append(summary) + return "\n".join(parts) + + +def normalize_section_path(path: str) -> str: + """Canonical path for gold/lookup: ``a / b`` (accepts ``a/b`` or ``a / b``).""" + raw = str(path or "").strip().strip("/") + if not raw or raw == ROOT_SECTION_PATH: + return "" + if " / " in raw: + parts = [p.strip() for p in raw.split(" / ") if p.strip()] + else: + parts = [p.strip() for p in raw.split("/") if p.strip()] + return " / ".join(parts) + + +def is_root_section_path(path: str) -> bool: + """True when the raw ``section_path`` is Knowhere's Root container.""" + return str(path or "").strip() == ROOT_SECTION_PATH + + +def _connect_to_targets(metadata: Dict[str, Any]) -> List[str]: + """``chunk_metadata.connect_to[].target`` ids (document order, first wins upstream).""" + raw = metadata.get("connect_to") if isinstance(metadata, dict) else None + if not isinstance(raw, list): + return [] + out: List[str] = [] + for conn in raw: + if not isinstance(conn, dict): + continue + target = str(conn.get("target") or "").strip() + if target: + out.append(target) + return out + + +class KnowhereProvider: + """``HierarchyProvider`` over knowhere section/chunk rows.""" + + def __init__( + self, + *, + doc_id: str, + sections: Sequence[SectionRow], + units: Sequence[UnitRow], + lazy_loader: Optional[Callable[[str], Sequence[UnitRow]]] = None, + known_chunk_ids: Optional[Sequence[str]] = None, + ) -> None: + self.doc_id = str(doc_id) + self._lazy_loader = lazy_loader + self._loaded_sections: Set[str] = set() + self._sections: Dict[str, SectionRow] = {s.section_id: s for s in sections} + self._children: Dict[str, List[str]] = {} + self._roots: List[str] = [] + self._path_to_id: Dict[str, str] = {} + for row in sorted(sections, key=lambda s: (s.sort_order, s.section_id)): + parent = row.parent_section_id + if parent and parent in self._sections: + self._children.setdefault(parent, []).append(row.section_id) + else: + self._roots.append(row.section_id) + key = normalize_section_path(row.section_path) + if key: + self._path_to_id[key] = row.section_id + + self._units_by_section: Dict[str, List[UnitRow]] = {} + self._chunk_ids: Set[str] = set() + if known_chunk_ids: + self._chunk_ids.update( + str(chunk_id).strip() + for chunk_id in known_chunk_ids + if str(chunk_id).strip() + ) + for unit in sorted(units, key=lambda u: (u.sort_order, u.chunk_id)): + sid = unit.section_id + if not sid or sid not in self._sections: + continue + self._units_by_section.setdefault(sid, []).append(unit) + if unit.chunk_id: + self._chunk_ids.add(unit.chunk_id) + self._remount_root_assets() + + def _ensure_section_loaded(self, section_id: str) -> None: + if self._lazy_loader is None or section_id in self._loaded_sections: + return + loaded = list(self._lazy_loader(section_id) or ()) + self._loaded_sections.add(section_id) + if not loaded: + return + current = self._units_by_section.setdefault(section_id, []) + known = {unit.chunk_id for unit in current} + for unit in loaded: + if unit.chunk_id and unit.chunk_id not in known: + current.append(unit) + known.add(unit.chunk_id) + current.sort(key=lambda unit: (unit.sort_order, unit.chunk_id)) + + def _remount_root_assets(self) -> None: + """Reattach Root-FK image|table units to host sections via ``connect_to``. + + Aligns with Knowhere ``resolve_root_asset_owners``: assets whose FK still + points at Root are owned by the text chunk that lists them in + ``metadata.connect_to``. Unresolved Root assets leave the evidence surface. + """ + root_sids = [ + sid + for sid, row in self._sections.items() + if is_root_section_path(row.section_path) + ] + if not root_sids: + return + + root_assets: Dict[str, UnitRow] = {} + for sid in root_sids: + for unit in self._units_by_section.get(sid, ()): + if unit.chunk_type in _ASSET_TYPES and unit.chunk_id: + root_assets[unit.chunk_id] = unit + if not root_assets: + return + + owner_by_asset: Dict[str, str] = {} + for sid, units in self._units_by_section.items(): + row = self._sections.get(sid) + if row is None or is_root_section_path(row.section_path): + continue + for unit in units: + if unit.chunk_type not in _BODY_CHUNK_TYPES: + continue + for target in _connect_to_targets(unit.metadata or {}): + if target in root_assets and target not in owner_by_asset: + owner_by_asset[target] = sid + + touched_owners: Set[str] = set() + for chunk_id, owner_sid in owner_by_asset.items(): + unit = root_assets[chunk_id] + remounted = UnitRow( + chunk_id=unit.chunk_id, + section_id=owner_sid, + chunk_type=unit.chunk_type, + content=unit.content, + sort_order=unit.sort_order, + source_chunk_path=unit.source_chunk_path, + file_path=unit.file_path, + metadata=dict(unit.metadata or {}), + ) + self._units_by_section.setdefault(owner_sid, []).append(remounted) + touched_owners.add(owner_sid) + + for sid in root_sids: + self._units_by_section[sid] = [ + u + for u in self._units_by_section.get(sid, ()) + if u.chunk_type not in _ASSET_TYPES + ] + for sid in touched_owners: + self._units_by_section[sid].sort(key=lambda u: (u.sort_order, u.chunk_id)) + + def owner_document(self, node_id: str) -> Optional[str]: + sid = str(node_id or "").strip() + if not sid: + return None + if sid == self.doc_id or sid in self._sections or sid in self._chunk_ids: + return self.doc_id + return None + + def roots(self, doc_id: str) -> Sequence[str]: + return list(self._roots) if str(doc_id) == self.doc_id else [] + + def children(self, section_id: str) -> Sequence[str]: + return list(self._children.get(section_id, ())) + + def node_meta(self, section_id: str) -> NodeMeta: + row = self._sections.get(section_id) + if row is None: + return NodeMeta() + return NodeMeta( + title=row.section_title, + summary=row.summary, + has_children=bool(self._children.get(section_id)), + ) + + def relations(self, section_id: str) -> Tuple[Set[str], Set[str]]: + ancestors: Set[str] = set() + cur = self._sections.get(section_id) + while cur is not None and cur.parent_section_id: + parent = cur.parent_section_id + if parent in ancestors: + break + ancestors.add(parent) + cur = self._sections.get(parent) + descendants: Set[str] = set() + stack = list(self.children(section_id)) + while stack: + cid = stack.pop() + if cid in descendants: + continue + descendants.add(cid) + stack.extend(self.children(cid)) + return ancestors, descendants + + def content(self, section_id: str) -> str: + units = self.subtree_units(section_id) + return "\n".join(self.unit_text(u) for u in units if self.unit_text(u)) + + def self_units(self, section_id: str) -> List[UnitRow]: + self._ensure_section_loaded(section_id) + return list(self._units_by_section.get(section_id, ())) + + def subtree_units(self, section_id: str) -> List[UnitRow]: + out = list(self.self_units(section_id)) + for cid in self.relations(section_id)[1]: + out.extend(self.self_units(cid)) + out.sort(key=lambda u: (u.sort_order, u.chunk_id)) + return out + + def leaf_ids(self, section_id: str) -> List[str]: + out: List[str] = [] + + def rec(sid: str) -> None: + kids = self.children(sid) + if not kids: + out.append(sid) + return + for kid in kids: + rec(kid) + + rec(section_id) + return out + + def path_titles(self, section_id: str) -> str: + chain: List[str] = [] + cur = self._sections.get(section_id) + while cur is not None: + if cur.section_title: + chain.append(cur.section_title) + parent = cur.parent_section_id + cur = self._sections.get(parent) if parent else None + return " / ".join(reversed(chain)) + + def parent_id(self, section_id: str) -> Optional[str]: + row = self._sections.get(section_id) + return row.parent_section_id if row else None + + def section_path(self, section_id: str) -> str: + row = self._sections.get(section_id) + return str(row.section_path or "") if row else "" + + def resolve_path(self, path: str) -> Optional[str]: + """Map a human/gold path to ``section_id`` (``sec_*``).""" + key = normalize_section_path(path) + if not key: + return None + return self._path_to_id.get(key) + + def unit_text(self, unit: UnitRow) -> str: + if unit.chunk_type in _ASSET_TYPES: + return asset_display_text(unit) + return str(unit.content or "").strip() + + def summaries(self) -> Dict[str, str]: + return { + sid: row.summary + for sid, row in self._sections.items() + if str(row.summary or "").strip() + } + + def all_section_ids(self) -> List[str]: + return list(self._sections) + diff --git a/packages/shared-python/shared/services/retrieval/scoring/node_filter_predicates.py b/packages/shared-python/shared/services/retrieval/scoring/node_filter_predicates.py new file mode 100644 index 00000000..91bfa047 --- /dev/null +++ b/packages/shared-python/shared/services/retrieval/scoring/node_filter_predicates.py @@ -0,0 +1,83 @@ +"""Predicate compile/match for section path/summary filters. + +Extracted from the map-nav tree walker. Live consumers evaluate these +predicates against persisted section rows, not an in-memory episode tree. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Any, List, Literal, Sequence, Tuple + +MatchKind = Literal["substring", "regex"] +FilterField = Literal["path", "summary"] + +_MAX_REGEX_PATTERN_LEN = 256 + + +@dataclass(frozen=True) +class FieldPredicate: + field: FilterField + terms: Tuple[str, ...] + match: MatchKind = "substring" + + +def field_predicate( + field: str, + terms: Sequence[str], + match: str = "substring", +) -> FieldPredicate: + key = str(field or "").strip().lower() + if key not in {"path", "summary"}: + raise ValueError(f"unsupported filter field: {field!r}") + kind = str(match or "substring").strip().lower() + if kind not in {"substring", "regex"}: + raise ValueError(f"unsupported filter match: {match!r}") + cleaned = tuple(str(term) for term in terms if str(term)) + return FieldPredicate(field=key, terms=cleaned, match=kind) # type: ignore[arg-type] + + +def _compile_predicates( + predicates: Sequence[FieldPredicate], +) -> Tuple[List[Tuple[FieldPredicate, List[Any]]], List[str]]: + compiled: List[Tuple[FieldPredicate, List[Any]]] = [] + failed: List[str] = [] + for pred in predicates: + if pred.match != "regex": + compiled.append((pred, [])) + continue + patterns: List[Any] = [] + ok = True + for term in pred.terms: + if len(term) > _MAX_REGEX_PATTERN_LEN: + failed.append(f"{pred.field}:regex:too_long") + ok = False + break + try: + patterns.append(re.compile(term, flags=re.IGNORECASE)) + except re.error: + failed.append(f"{pred.field}:regex:invalid") + ok = False + break + if ok: + compiled.append((pred, patterns)) + return compiled, failed + + +def _node_matches( + values: dict[str, str], + compiled: Sequence[Tuple[FieldPredicate, List[Any]]], +) -> bool: + if not compiled: + return True + for pred, patterns in compiled: + text = values.get(pred.field, "") + if pred.match == "regex": + if not patterns or not any(p.search(text or "") for p in patterns): + return False + continue + haystack = (text or "").lower() + if not pred.terms or not any(term.lower() in haystack for term in pred.terms): + return False + return True diff --git a/packages/shared-python/shared/services/retrieval/nav/persisted_score_load.py b/packages/shared-python/shared/services/retrieval/scoring/persisted_score_load.py similarity index 96% rename from packages/shared-python/shared/services/retrieval/nav/persisted_score_load.py rename to packages/shared-python/shared/services/retrieval/scoring/persisted_score_load.py index 712f23b5..53924b01 100644 --- a/packages/shared-python/shared/services/retrieval/nav/persisted_score_load.py +++ b/packages/shared-python/shared/services/retrieval/scoring/persisted_score_load.py @@ -6,7 +6,7 @@ from collections.abc import Mapping, Sequence from typing import Any -from shared.services.retrieval.nav.knowhere_hybrid import PersistedBm25Stats +from shared.services.retrieval.scoring.knowhere_hybrid import PersistedBm25Stats def average_idf_from_unit_dfs( diff --git a/packages/shared-python/shared/services/retrieval/scoring/score_units.py b/packages/shared-python/shared/services/retrieval/scoring/score_units.py new file mode 100644 index 00000000..e9c05b0a --- /dev/null +++ b/packages/shared-python/shared/services/retrieval/scoring/score_units.py @@ -0,0 +1,188 @@ +"""Build persisted map-unit scoring rows from a hierarchy tool space.""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional, Sequence, Set, Tuple, cast + +from shared.services.retrieval.scoring.knowhere_hybrid import ( + build_content_search_text, + build_path_search_text, + build_term_search_text, +) + +def _children_ids(ts: Any, section_id: str, doc_id: str) -> List[str]: + children_fn = getattr(ts, "_children_for_section_path", None) + if not callable(children_fn): + st = ts.get_structure(section_id) + rows = st.get("children") or [] + return [ + str(r.get("section_id") or "").strip() for r in rows if r.get("section_id") + ] + rows = cast(Sequence[Any], children_fn(section_id, doc_id)) + return [str(r.get("section_id") or "").strip() for r in rows if r.get("section_id")] + + +def _line_content(ts: Any, section_id: str, doc_id: str) -> str: + """Raw line text for a section node (no truncation).""" + idx = getattr(ts, "_idx", None) + b = getattr(idx, "_bundles", {}).get(doc_id) if idx is not None else None + if b is None: + path_fn = getattr(ts, "path_titles", None) + if callable(path_fn): + path = str(path_fn(section_id, doc_id) or "").strip() + return path.rsplit(" / ", 1)[-1] if path else "" + st = ts.get_structure(section_id) + return str(st.get("preview") or "").strip() + loc = getattr(idx, "_node_to_doc_line", {}).get(section_id) + if not loc: + return "" + _doc, line_idx = loc + if line_idx < 0 or line_idx >= len(b.lines): + return "" + return str(b.lines[line_idx].content or "").strip() + + +def _ancestor_path_titles(ts: Any, section_id: str, doc_id: str) -> str: + idx = getattr(ts, "_idx", None) + if idx is None: + # Provider-backed spaces expose the title chain directly; without this + # the path channel would score every unit as empty. + path_fn = getattr(ts, "path_titles", None) + return str(path_fn(section_id, doc_id) or "") if callable(path_fn) else "" + try: + ancestors = list(idx.ancestor_line_node_ids(section_id)) + except Exception: + ancestors = [] + titles: List[str] = [] + for aid in reversed(ancestors): + if not str(aid).startswith(f"{doc_id}:"): + continue + titles.append(_line_content(ts, aid, doc_id)) + titles.append(_line_content(ts, section_id, doc_id)) + return " / ".join(t for t in titles if t) + + +def _self_only_text(ts: Any, section_id: str, doc_id: str) -> Tuple[str, bool]: + """Return (self_text, has_interstitial_body). + + Interstitial means self_only span contains content beyond the heading line + itself (structural: more than one line/chunk in the self span). + """ + self_fn = getattr(ts, "materialize_self_only_chunks", None) + if not callable(self_fn): + return "", False + chunks = list(cast(Sequence[Any], self_fn(section_id, doc_id) or [])) + if not chunks: + return "", False + texts = [str(getattr(c, "text", "") or "").strip() for c in chunks] + texts = [t for t in texts if t] + if not texts: + return "", False + # Structural interstitial: self span covers more than the node heading line. + has_interstitial = len(chunks) > 1 + return "\n".join(texts), has_interstitial + + +def _section_body_text(ts: Any, section_id: str, doc_id: str) -> str: + """Heading + lines until first structural child (leaf body / parent self span).""" + text, _ = _self_only_text(ts, section_id, doc_id) + if text: + return text + return _line_content(ts, section_id, doc_id) + + +def _walk_tree( + ts: Any, + doc_id: str, + root_ids: Sequence[str], +) -> Tuple[Dict[str, List[str]], Set[str], Dict[str, str]]: + """Return children map, leaf ids, and title map for reachable nodes.""" + children_map: Dict[str, List[str]] = {} + titles: Dict[str, str] = {} + leaves: Set[str] = set() + seen: Set[str] = set() + + def walk(sid: str) -> None: + if not sid or sid in seen: + return + seen.add(sid) + titles[sid] = _line_content(ts, sid, doc_id) + kids = [c for c in _children_ids(ts, sid, doc_id) if c] + children_map[sid] = kids + if not kids: + leaves.add(sid) + return + for kid in kids: + walk(kid) + + for rid in root_ids: + walk(rid) + return children_map, leaves, titles + + +def build_score_units( + ts: Any, doc_id: str, root_ids: Optional[Sequence[str]] = None +) -> List[dict]: + """Build leaf (+ interstitial self_only) units for hybrid scoring.""" + if root_ids is None: + root_ids = list(ts.sections_for_doc(doc_id)) + children_map, leaves, titles = _walk_tree(ts, doc_id, root_ids) + units: List[dict] = [] + seen_unit_ids: Set[str] = set() + + for leaf_id in sorted(leaves): + content = _section_body_text(ts, leaf_id, doc_id) or ( + titles.get(leaf_id) or _line_content(ts, leaf_id, doc_id) + ) + path_text = _ancestor_path_titles(ts, leaf_id, doc_id) + unit_id = leaf_id + if unit_id in seen_unit_ids: + continue + seen_unit_ids.add(unit_id) + title = titles.get(leaf_id) or _line_content(ts, leaf_id, doc_id) + units.append( + { + "chunk_id": unit_id, + "section_id": leaf_id, + "kind": "leaf", + "content": content, + "path_text": path_text, + "path_search_text": build_path_search_text( + section_path=path_text, section_title=title or content + ), + "content_search_text": build_content_search_text(content), + "term_search_text": build_term_search_text( + content, path_text=path_text + ), + } + ) + + # Parents with interstitial self body. + for sid, kids in children_map.items(): + if not kids: + continue + self_text, has_interstitial = _self_only_text(ts, sid, doc_id) + if not has_interstitial or not self_text: + continue + unit_id = f"{sid}__self" + if unit_id in seen_unit_ids: + continue + seen_unit_ids.add(unit_id) + path_text = _ancestor_path_titles(ts, sid, doc_id) + units.append( + { + "chunk_id": unit_id, + "section_id": sid, + "kind": "self_only", + "content": self_text, + "path_text": path_text, + "path_search_text": build_path_search_text( + section_path=path_text, section_title=titles.get(sid) or "" + ), + "content_search_text": build_content_search_text(self_text), + "term_search_text": build_term_search_text( + self_text, path_text=path_text + ), + } + ) + return units diff --git a/packages/shared-python/shared/services/retrieval/search/map_unit_discovery.py b/packages/shared-python/shared/services/retrieval/search/map_unit_discovery.py index 68e27402..b2a72978 100644 --- a/packages/shared-python/shared/services/retrieval/search/map_unit_discovery.py +++ b/packages/shared-python/shared/services/retrieval/search/map_unit_discovery.py @@ -1,7 +1,6 @@ """Classic-route discovery via the persisted map-unit BM25 scorer. -Replaces the retired chunk-level 3-channel SQL scan. Scoring uses the same -``score_persisted_corpus_many`` formula as map-nav (path + content only). +Scoring uses ``score_persisted_corpus_many`` over path + content only. ``chunk_types`` is optional: omitted means score every in-scope unit. When the request is image/table only, ``has_image`` / ``has_table`` (written at @@ -31,14 +30,14 @@ iter_connected_target_ids, normalize_chunk_type, ) -from shared.services.retrieval.nav.knowhere_hybrid import ( +from shared.services.retrieval.scoring.knowhere_hybrid import ( MAP_UNIT_INDEX_FORMAT_VERSION, PersistedScoreCorpus, PersistedScoreUnit, score_persisted_corpus_many, tokenize_query_for_ranker, ) -from shared.services.retrieval.nav.persisted_score_load import ( +from shared.services.retrieval.scoring.persisted_score_load import ( build_channel_bm25_stats, combine_average_idf, ) @@ -508,26 +507,11 @@ async def map_unit_discovery( ) except Exception as exc: logger.warning("retrieval index readiness publish failed: %s", exc) - logger.warning( - "retrieval map index incomplete user_id=%s namespace=%s " - "expected_revisions=%d indexed_revisions=%d fallback=legacy_fts", - user_id, - namespace, - len(expected_revisions), - len(index_parts), - ) - return await _legacy_chunk_discovery( - db, - user_id=user_id, - namespace=namespace, - query=query, - top_k=top_k, - exclude_document_ids=exclude_document_ids, - exclude_sections=exclude_sections, - chunk_types=chunk_types, - signal_paths=signal_paths or [], - filter_mode=filter_mode, - revision_pins=revision_pins, + raise RuntimeError( + "retrieval map-unit index is incomplete or incompatible " + f"(user_id={user_id} namespace={namespace} " + f"expected_revisions={len(expected_revisions)} " + f"indexed_revisions={len(index_parts)})" ) if has_incomplete_index_statistics: logger.warning( @@ -701,103 +685,6 @@ async def map_unit_discovery( ) -async def _legacy_chunk_discovery( - db: AsyncSession, - *, - user_id: str, - namespace: str, - query: str, - top_k: int, - exclude_document_ids: list[str], - exclude_sections: list[dict[str, str]], - chunk_types: set[str] | None, - signal_paths: list[str], - filter_mode: str, - revision_pins: Mapping[str, str] | None, -) -> DiscoveryResult: - """Bounded lexical fallback used while a serving index is incomplete.""" - clauses = [ - "d.user_id = :user_id", - "d.namespace = :namespace", - "d.status = 'active'", - ] - params: dict[str, Any] = { - "user_id": user_id, - "namespace": namespace, - "query": query, - "limit": max(1, int(top_k)), - } - if revision_pins is None: - clauses.append("d.current_job_result_id = dc.job_result_id") - else: - pairs = [ - (str(document_id), str(job_result_id)) - for document_id, job_result_id in revision_pins.items() - ] - if not pairs: - return DiscoveryResult(status="discovery_done", payload={"fused_rows": []}) - placeholders = [] - for index, (document_id, job_result_id) in enumerate(pairs): - document_key = f"_legacy_doc_{index}" - revision_key = f"_legacy_revision_{index}" - placeholders.append(f"(:{document_key}, :{revision_key})") - params[document_key] = document_id - params[revision_key] = job_result_id - clauses.append(f"(dc.document_id, dc.job_result_id) IN ({', '.join(placeholders)})") - if exclude_document_ids: - clauses.append("d.document_id <> ALL(:excluded_doc_ids)") - params["excluded_doc_ids"] = exclude_document_ids - if chunk_types: - type_keys = [] - for index, chunk_type in enumerate(sorted(chunk_types)): - key = f"_legacy_type_{index}" - type_keys.append(f":{key}") - params[key] = chunk_type - clauses.append(f"LOWER(dc.chunk_type) IN ({', '.join(type_keys)})") - if signal_paths: - signal_parts = [] - for index, signal in enumerate(signal_paths): - key = f"_legacy_signal_{index}" - signal_parts.append("LOWER(COALESCE(ds.section_path, '')) LIKE :" + key) - params[key] = f"%{signal.lower()}%" - combined = " OR ".join(signal_parts) - clauses.append(f"({combined})" if filter_mode == "keep" else f"NOT ({combined})") - for index, item in enumerate(exclude_sections): - document_id = str(item.get("document_id") or "").strip() - section_path = str(item.get("section_path") or "").strip() - if not document_id or not section_path: - continue - doc_key = f"_legacy_exclude_doc_{index}" - path_key = f"_legacy_exclude_path_{index}" - params[doc_key] = document_id - params[path_key] = section_path - clauses.append( - "NOT (dc.document_id = :" + doc_key + " AND (" - "COALESCE(ds.section_path, '') = :" + path_key + " OR " - "POSITION(:" + path_key + " || ' / ' IN COALESCE(ds.section_path, '')) = 1))" - ) - where_sql = " AND ".join(clauses) - statement = text( - "SELECT dc.chunk_id, dc.document_id, dc.section_id, dc.chunk_type, " - "dc.content, dc.source_chunk_path, dc.file_path, dc.chunk_metadata, " - "dc.job_result_id, dc.sort_order, ds.section_path, d.source_file_name, " - "jr.job_id, GREATEST(ts_rank_cd(dc.path_search_tsv, plainto_tsquery('simple', :query)), " - "2 * ts_rank_cd(dc.content_search_tsv, plainto_tsquery('simple', :query))) AS score " - "FROM document_chunks dc JOIN documents d ON d.document_id = dc.document_id " - "LEFT JOIN document_sections ds ON ds.section_id = dc.section_id " - "LEFT JOIN job_results jr ON jr.id = dc.job_result_id " - f"WHERE {where_sql} AND (dc.path_search_tsv @@ plainto_tsquery('simple', :query) " - "OR dc.content_search_tsv @@ plainto_tsquery('simple', :query) " - "OR LOWER(COALESCE(dc.term_search_text, '')) LIKE LOWER(:term_query)) " - "ORDER BY score DESC, dc.sort_order, dc.chunk_id LIMIT :limit" - ) - params["term_query"] = f"%{query}%" - rows = [dict(row._mapping) for row in (await db.execute(statement, params)).all()] - if rows: - normalize_row_scores(rows, source_field="score", target_field="discovery_score", default=0.5) - return DiscoveryResult(status="discovery_done", payload={"fused_rows": rows}) - - def _as_metadata_dict(value: object) -> dict[str, Any]: if isinstance(value, dict): return value diff --git a/packages/shared-python/shared/services/retrieval/settings.py b/packages/shared-python/shared/services/retrieval/settings.py index 7e7df316..4dd3130b 100644 --- a/packages/shared-python/shared/services/retrieval/settings.py +++ b/packages/shared-python/shared/services/retrieval/settings.py @@ -6,9 +6,8 @@ RRF_K = 60 DEFAULT_TOP_K = 10 -# Final evidence / tool-observation text budget (characters). Shared by map-nav -# evidence packing (``nav_config.MAPNAV_EVIDENCE_CHARS``) and agent tool-loop -# harness caps (``ToolBudget.max_chars`` in ``agent_explore/episode.py``). +# Final evidence / tool-observation text budget (characters). Used by +# agent tool-loop harness caps (``ToolBudget.max_chars``). EVIDENCE_TEXT_CHAR_BUDGET = 12_000 VALID_CHUNK_TYPES: set[str] = {"text", "image", "table", "page"} diff --git a/packages/shared-python/shared/services/retrieval/trace/__init__.py b/packages/shared-python/shared/services/retrieval/trace/__init__.py index 54152432..657a5ebe 100644 --- a/packages/shared-python/shared/services/retrieval/trace/__init__.py +++ b/packages/shared-python/shared/services/retrieval/trace/__init__.py @@ -1,21 +1,9 @@ """Retrieval decision-trace package (replaces agentic/core trace types).""" -from shared.services.retrieval.trace.mapnav import ( - build_decision_trace, - episode_selected_doc_ids, - episode_selected_paths, - episode_token_count, - episode_workflow_plan, -) from shared.services.retrieval.trace.recorder import TraceRecorder from shared.services.retrieval.trace.types import DecisionTraceStep __all__ = [ "DecisionTraceStep", "TraceRecorder", - "build_decision_trace", - "episode_workflow_plan", - "episode_selected_paths", - "episode_selected_doc_ids", - "episode_token_count", ] diff --git a/packages/shared-python/shared/services/retrieval/trace/recorder.py b/packages/shared-python/shared/services/retrieval/trace/recorder.py index 79d5cea7..8b5156a3 100644 --- a/packages/shared-python/shared/services/retrieval/trace/recorder.py +++ b/packages/shared-python/shared/services/retrieval/trace/recorder.py @@ -15,7 +15,6 @@ from loguru import logger from sqlalchemy.ext.asyncio import AsyncSession -from shared.services.retrieval.nav_config import MAPNAV_MODEL from shared.services.retrieval.settings import DEFAULT_TOP_K from shared.services.retrieval.trace.types import DecisionTraceStep @@ -47,7 +46,7 @@ def __init__( policy_name: str = "llm_policy_v1", config: Any = None, ) -> None: - del config # legacy AgentRunConfig; ignored on map-nav path + del config # unused AgentRunConfig leftover; ignored self._db = db self._run_id = f"aret_{uuid4().hex[:12]}" self._user_id = user_id @@ -236,7 +235,7 @@ async def complete( "router": router_used, "step_count": len(self._steps), "final_doc_ids": doc_ids_in_result, - "model_name": model_name or MAPNAV_MODEL, + "model_name": model_name or "unknown", } if selected_paths is not None: provenance["selected_paths"] = selected_paths diff --git a/packages/shared-python/shared/tests/test_agent_explore_harness.py b/packages/shared-python/shared/tests/test_agent_explore_harness.py index f005de2d..7b9740ce 100644 --- a/packages/shared-python/shared/tests/test_agent_explore_harness.py +++ b/packages/shared-python/shared/tests/test_agent_explore_harness.py @@ -145,8 +145,8 @@ def _clean_harness_env(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv(_HARNESS_ENV, raising=False) -def test_resolve_harness_name_defaults_to_openai() -> None: - assert resolve_harness_name() == "openai" +def test_resolve_harness_name_defaults_to_cursor_sdk() -> None: + assert resolve_harness_name() == "cursor_sdk" def test_resolve_harness_name_reads_env(monkeypatch: pytest.MonkeyPatch) -> None: @@ -158,7 +158,7 @@ def test_resolve_harness_name_unknown_value_falls_back_to_default( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setenv(_HARNESS_ENV, "not_a_real_harness") - assert resolve_harness_name() == "openai" + assert resolve_harness_name() == "cursor_sdk" def test_resolve_harness_name_is_case_insensitive(monkeypatch: pytest.MonkeyPatch) -> None: @@ -166,11 +166,11 @@ def test_resolve_harness_name_is_case_insensitive(monkeypatch: pytest.MonkeyPatc assert resolve_harness_name() == "cursor_sdk" -def test_resolve_harness_default_builds_openai_harness() -> None: - from shared.services.retrieval.agent_explore.harness.openai_harness import OpenAIHarness +def test_resolve_harness_default_builds_cursor_harness() -> None: + from shared.services.retrieval.agent_explore.harness.cursor_harness import CursorHarness harness = resolve_harness() - assert isinstance(harness, OpenAIHarness) + assert isinstance(harness, CursorHarness) assert isinstance(harness, Harness) diff --git a/packages/shared-python/shared/tests/test_asset_inline.py b/packages/shared-python/shared/tests/test_asset_inline.py index a59652f0..d8345c24 100644 --- a/packages/shared-python/shared/tests/test_asset_inline.py +++ b/packages/shared-python/shared/tests/test_asset_inline.py @@ -10,8 +10,8 @@ from shared.services.retrieval.hydration.result_assembly import ( assemble_retrieval_results, ) -from shared.services.retrieval.nav.nav_hierarchy import ProviderToolSpace -from shared.services.retrieval.nav.nav_knowhere import ( +from shared.services.retrieval.scoring.hierarchy import ProviderToolSpace +from shared.services.retrieval.scoring.knowhere_provider import ( KnowhereProvider, SectionRow, UnitRow, diff --git a/packages/shared-python/shared/tests/test_knowhere_hybrid_tokenize.py b/packages/shared-python/shared/tests/test_knowhere_hybrid_tokenize.py index 2d4b96e1..8b559ebb 100644 --- a/packages/shared-python/shared/tests/test_knowhere_hybrid_tokenize.py +++ b/packages/shared-python/shared/tests/test_knowhere_hybrid_tokenize.py @@ -2,7 +2,7 @@ from __future__ import annotations -from shared.services.retrieval.nav.knowhere_hybrid import ( +from shared.services.retrieval.scoring.knowhere_hybrid import ( MAP_UNIT_INDEX_FORMAT_VERSION, build_content_search_text, build_path_search_text, diff --git a/pyproject.toml b/pyproject.toml index 19876076..b764a83e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ exclude = [ "packages/shared-python/build", "packages/shared-python/dist", "packages/shared-python/shared/tests", - "packages/shared-python/shared/services/retrieval/nav", + "deprecated", ] executionEnvironments = [ { root = "apps/api", extraPaths = ["packages/shared-python"] }, @@ -63,6 +63,7 @@ src = [ "apps/worker", "packages/shared-python", ] +exclude = ["deprecated"] [tool.ruff.lint] select = ["E", "F"] diff --git a/pytest.ini b/pytest.ini index e83f7c02..cd7e4782 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,4 +1,6 @@ [pytest] +norecursedirs = deprecated .* +addopts = --ignore=deprecated asyncio_mode = auto asyncio_default_fixture_loop_scope = function filterwarnings = diff --git a/uv.lock b/uv.lock index 4c1be47b..2e598298 100644 --- a/uv.lock +++ b/uv.lock @@ -1413,6 +1413,7 @@ dependencies = [ { name = "aiohttp" }, { name = "alembic" }, { name = "celery" }, + { name = "cursor-sdk" }, { name = "fastapi" }, { name = "httpx" }, { name = "knowhere-shared" }, @@ -1447,6 +1448,7 @@ requires-dist = [ { name = "aiohttp", specifier = "==3.13.4" }, { name = "alembic", specifier = "==1.13.1" }, { name = "celery", specifier = "==5.5.3" }, + { name = "cursor-sdk", specifier = ">=1.0.31" }, { name = "fastapi", specifier = "==0.135.1" }, { name = "httpx", specifier = "==0.28.1" }, { name = "knowhere-shared", editable = "packages/shared-python" },