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/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/.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/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/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 021eb6f9..8cb35cdb 100644 --- a/apps/worker/pyproject.toml +++ b/apps/worker/pyproject.toml @@ -30,6 +30,13 @@ dependencies = [ "rapidocr-onnxruntime>=1.4.4", ] +# 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", +] + [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 new file mode 100644 index 00000000..c85c531f --- /dev/null +++ b/apps/worker/scripts/debug_agent_explore_episode.py @@ -0,0 +1,92 @@ +"""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 +``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. + +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 + uv run python scripts/debug_agent_explore_episode.py --query-id q04 --harness cursor_sdk +""" + +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) + parser.add_argument("--harness", default=None, choices=["openai", "cursor_sdk"]) + 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.harness import resolve_harness + + budget = EpisodeBudget(token_limit=args.token_limit) if args.token_limit else EpisodeBudget() + harness = resolve_harness(args.harness) + + print(f"query: {query!r}") + 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}") + 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..1d6acfa6 --- /dev/null +++ b/apps/worker/scripts/debug_finish_raw_args.py @@ -0,0 +1,66 @@ +"""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.harness import openai_harness + + 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) + + 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}") + 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}") + + +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..752ed091 --- /dev/null +++ b/apps/worker/scripts/run_agentic_router_eval.py @@ -0,0 +1,305 @@ +"""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. + +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 --use-agentic false +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import os +import sys +import time +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", 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" +_MODES = ("classic", "agent_explore") +TOP_K = 10 + + +def _count_llm_steps(decision_trace: list[dict[str, Any]], mode: str) -> int: + del mode + 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 # "classic" | "agent_explore" + 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, + use_agentic: bool, + 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: + 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 + ) + 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 _MODES: + 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" + ) + + 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: + if not values: + return None + ordered = sorted(values) + return ordered[len(ordered) // 2] + + lines.extend( + [ + "\n## Aggregate latency (successful runs only)\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", + "- ``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="Smoke-run agent_explore eval fixture") + 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( + "--use-agentic", + choices=["true", "false", "both"], + default="both", + help="classic (false), agent_explore (true), or both", + ) + 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] + + 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) + + 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, + use_agentic=router != "classic", + 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/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 74% rename from packages/shared-python/shared/services/retrieval/nav/nav_knowhere.py rename to deprecated/mapnav/nav/nav_knowhere.py index cd569631..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,86 +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") -# 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() @@ -134,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() @@ -300,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, ) @@ -646,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 != "text": - 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 94% rename from packages/shared-python/shared/services/retrieval/nav_config.py rename to deprecated/mapnav/nav_config.py index 48067194..c7338f09 100644 --- a/packages/shared-python/shared/services/retrieval/nav_config.py +++ b/deprecated/mapnav/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/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/assets/knowhere-banner-2.0.png b/docs/assets/knowhere-banner-2.0.png new file mode 100644 index 00000000..6332a6ee Binary files /dev/null and b/docs/assets/knowhere-banner-2.0.png differ 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/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 new file mode 100644 index 00000000..d313f765 --- /dev/null +++ b/packages/shared-python/shared/services/retrieval/agent_explore/__init__.py @@ -0,0 +1,23 @@ +"""In-process tool-calling agentic retrieval route. + +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 (Cursor SDK or OpenAI-compatible) is the +``AGENT_EXPLORE_HARNESS`` switch resolved via ``resolve_harness()``. +""" + +from __future__ import annotations + +from shared.services.retrieval.agent_explore.budget import EpisodeBudget +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", + "Harness", + "resolve_harness", +] 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..4f840426 --- /dev/null +++ b/packages/shared-python/shared/services/retrieval/agent_explore/bridge.py @@ -0,0 +1,47 @@ +"""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. +""" + +from __future__ import annotations + +from shared.services.retrieval.agent_explore.types import AgentStep +from shared.services.retrieval.trace import DecisionTraceStep + +# Cap raw trace text before it goes into the public decision_trace response. +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..0d467633 --- /dev/null +++ b/packages/shared-python/shared/services/retrieval/agent_explore/config.py @@ -0,0 +1,110 @@ +"""Production config for the ``agent_explore`` in-process tool-loop. + +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 + +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). +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. + +`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. + +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/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/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..0d84b566 --- /dev/null +++ b/packages/shared-python/shared/services/retrieval/agent_explore/harness/cursor_harness.py @@ -0,0 +1,303 @@ +"""``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 ``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. +``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, + budget_status_line, + 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 " + "'cursor-sdk' dependency, which is not installed in this " + "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 + + +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) + # 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_with_budget, + 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_with_budget + + 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..2d48f505 --- /dev/null +++ b/packages/shared-python/shared/services/retrieval/agent_explore/harness/openai_harness.py @@ -0,0 +1,390 @@ +"""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 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 +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, + budget_status_line, + 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]: + """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 + + 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 + + # 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: + 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..cf19cd86 --- /dev/null +++ b/packages/shared-python/shared/services/retrieval/agent_explore/harness/resolve.py @@ -0,0 +1,40 @@ +"""``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 +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 = "cursor_sdk" + + +def resolve_harness_name() -> str: + """``cursor_sdk`` (default) or ``openai``.""" + 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/ref_resolution.py b/packages/shared-python/shared/services/retrieval/agent_explore/ref_resolution.py new file mode 100644 index 00000000..5edeeac4 --- /dev/null +++ b/packages/shared-python/shared/services/retrieval/agent_explore/ref_resolution.py @@ -0,0 +1,106 @@ +"""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 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 + +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.agent_tools.section_path_lookup import ( + resolve_section_path_anchor, +) + +_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 + + 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) + .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 == resolved_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/shared.py b/packages/shared-python/shared/services/retrieval/agent_explore/shared.py new file mode 100644 index 00000000..36223c4d --- /dev/null +++ b/packages/shared-python/shared/services/retrieval/agent_explore/shared.py @@ -0,0 +1,126 @@ +"""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_explore.budget import EpisodeBudget +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 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 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() + 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_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/CORPUS_SCHEMA.md b/packages/shared-python/shared/services/retrieval/agent_tools/CORPUS_SCHEMA.md index 496b1607..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 @@ -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,8 +135,8 @@ 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. | -| `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. | +| `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. 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/__init__.py b/packages/shared-python/shared/services/retrieval/agent_tools/__init__.py new file mode 100644 index 00000000..9f5b4284 --- /dev/null +++ b/packages/shared-python/shared/services/retrieval/agent_tools/__init__.py @@ -0,0 +1,39 @@ +"""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, + capped_limit, + register_tool, +) +from shared.services.retrieval.agent_tools.schema_doc import load_corpus_schema_text + +__all__ = [ + "REGISTRY", + "ToolBudget", + "ToolContext", + "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 new file mode 100644 index 00000000..d5aa871c --- /dev/null +++ b/packages/shared-python/shared/services/retrieval/agent_tools/registry.py @@ -0,0 +1,142 @@ +"""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 + +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``. + + ``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.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 final evidence packing via ``EVIDENCE_TEXT_CHAR_BUDGET`` + (12_000). + """ + + 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.""" + + 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/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/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/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/__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..af7224c7 --- /dev/null +++ b/packages/shared-python/shared/services/retrieval/agent_tools/tools/grep.py @@ -0,0 +1,168 @@ +"""``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. + +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 + +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, + capped_limit, + register_tool, +) +from shared.services.retrieval.agent_tools.snippet import ( + HIT_CONTEXT_CHARS, + build_snippet, +) + +_DEFAULT_MAX_RESULTS = 30 +_DEFAULT_CONTEXT_CHARS = HIT_CONTEXT_CHARS + + +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) + 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() + ] + 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) + snippet = build_snippet( + text, match.span() if match else None, hit_context=context_chars + ) + 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)}"] + 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}") + + 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..6c0269fa --- /dev/null +++ b/packages/shared-python/shared/services/retrieval/agent_tools/tools/node_filter.py @@ -0,0 +1,199 @@ +"""``corpus.node_filter`` — deterministic FOR-ALL/EXISTS/ANY/NOT predicate over sections. + +Reuses the exact predicate compile/match semantics from +``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 +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.scoring.node_filter_predicates 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]), + DocumentChunk.job_result_id.in_([r for _, r 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..f5f99117 --- /dev/null +++ b/packages/shared-python/shared/services/retrieval/agent_tools/tools/read.py @@ -0,0 +1,399 @@ +"""``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). + +``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 + +import re +from typing import Any + +from sqlalchemy import 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.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, + _image_display_content, +) +from shared.services.retrieval.hydration.row_utils import normalize_chunk_type +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") + +# ``_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( + 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 + + 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( + 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() + ) + 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_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": + 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..6e906cd6 --- /dev/null +++ b/packages/shared-python/shared/services/retrieval/agent_tools/tools/recall.py @@ -0,0 +1,270 @@ +"""``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 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. + +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 ``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 + +tail, ``...``-joined, overlap-merged) — the same mechanism ``corpus.grep`` +uses, so window-slicing constants live in one place. +""" + +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, + 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 = 10 + +_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 + 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, + "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": build_snippet(haystack, hit), + } + ) + 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") + 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() + ] + 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 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: + lines.append(f"note: capped to budget.max_items={ctx.budget.max_items}") + for row in fused: + 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}" + ) + + 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/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 3f6a8270..b14a84e9 100644 --- a/packages/shared-python/shared/services/retrieval/execution/routes.py +++ b/packages/shared-python/shared/services/retrieval/execution/routes.py @@ -1,7 +1,5 @@ from __future__ import annotations -import asyncio -import resource import time from contextlib import AbstractAsyncContextManager @@ -25,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]: @@ -67,11 +61,10 @@ 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 → agent_explore. if context.use_agentic is False: return await _run_classic_topk_route(context) - - return await _run_mapnav_route(context) + return await _run_agent_explore_route(context) async def _try_run_small_corpus_route( @@ -190,198 +183,114 @@ async def _run_classic_topk_route( ) -async def _run_mapnav_route( +async def _run_agent_explore_route( context: RetrievalRouteContext, ) -> RetrievalRouteOutcome: - """Default agentic path: PLANNER + HARVEST + CONTROL (checklist map-nav). + """Default agentic path: in-process ``corpus.*`` tool-calling loop. - 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/``. + Which provider actually runs the tool-calling loop is the + ``AGENT_EXPLORE_HARNESS`` switch resolved by ``resolve_harness()``. """ - 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.agent_explore.bridge import build_decision_trace + 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.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, + from shared.services.retrieval.trace import TraceRecorder + + harness = resolve_harness() + episode_started = time.perf_counter() + 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={} " + "steps={} tokens={} stop_reason={}".format( + time.perf_counter() - episode_started, + len(episode.refs), + len(episode.steps), + episode.tokens_used, + episode.stop_reason, + ) ) - snapshot_started = time.perf_counter() - snapshot_pins = context.revision_pins - snapshot = await load_nav_snapshot( + # 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, - lazy=True, - revision_pins=snapshot_pins, - generation=(snapshot_pins.generation if snapshot_pins is not None else None), + 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")} ) - if snapshot_pins is not None and not await is_revision_generation_stable( + + trace = TraceRecorder( 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 "", - ) + query=context.query, + top_k=context.top_k, + chunk_types=context.allowed_chunk_types, + policy_name="agent_explore_v1", ) - - # 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), - ) + 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, ) - stop_reason = str(getattr(episode, "stop_reason", "") or "completed") - evidence_text = str(getattr(episode, "evidence_text", "") or "") + evidence_text = _render_rows_evidence(assembled_rows) response = { "namespace": context.namespace, "query": context.query, - "router_used": "mapnav", + "router_used": "agent_explore", "evidence_text": evidence_text, "answer_text": "", "referenced_chunks": resolved.refs, "results": assembled_rows, - "stop_reason": stop_reason, + "stop_reason": episode.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_label="AGENT EXPLORE RETRIEVAL", completion_count=len(resolved.refs), - completion_detail=completion_detail, + completion_detail=( + f"chunks | evidence={len(evidence_text)} chars | router=agent_explore" + ), ) + 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/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): diff --git a/packages/shared-python/shared/services/retrieval/settings.py b/packages/shared-python/shared/services/retrieval/settings.py index f2e24b56..4dd3130b 100644 --- a/packages/shared-python/shared/services/retrieval/settings.py +++ b/packages/shared-python/shared/services/retrieval/settings.py @@ -6,6 +6,10 @@ RRF_K = 60 DEFAULT_TOP_K = 10 +# 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"} ASSET_CHUNK_TYPES: set[str] = {"image", "table"} 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 new file mode 100644 index 00000000..7b9740ce --- /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_cursor_sdk() -> None: + assert resolve_harness_name() == "cursor_sdk" + + +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() == "cursor_sdk" + + +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_cursor_harness() -> None: + from shared.services.retrieval.agent_explore.harness.cursor_harness import CursorHarness + + harness = resolve_harness() + assert isinstance(harness, CursorHarness) + 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/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/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 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 77a0eee5..2e598298 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" @@ -1397,6 +1413,7 @@ dependencies = [ { name = "aiohttp" }, { name = "alembic" }, { name = "celery" }, + { name = "cursor-sdk" }, { name = "fastapi" }, { name = "httpx" }, { name = "knowhere-shared" }, @@ -1431,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" }, @@ -1588,6 +1606,11 @@ dependencies = [ { name = "tqdm" }, ] +[package.optional-dependencies] +cursor-harness = [ + { name = "cursor-sdk" }, +] + [package.dev-dependencies] dev = [ { name = "fakeredis", extra = ["lua"] }, @@ -1601,6 +1624,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 +1648,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 = [