diff --git a/AGENTS.md b/AGENTS.md
index 9e68dc180..c9afaa80c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -102,13 +102,14 @@ flowchart TB
subgraph RETRIEVE["⑤ Retrieval (shared)"]
Query["GET /v1/retrieval/query"] --> Pipeline["run_retrieval_query"]
- Pipeline --> Channels["3-Channel BM25 (path/content/term)"]
- Pipeline --> Agentic["WorkflowOrchestrator (Planner + DAG)"]
- Channels --> RRF["RRF Fusion"]
- Agentic --> Hydrate["hydrate_paths_to_rows"]
- RRF --> Rank["_rank_candidates_by_path"]
- Hydrate --> Rank
+ Pipeline --> Classic["classic_topk / small_corpus (use_agentic=False)"]
+ Pipeline --> MapNav["mapnav checklist (default / use_agentic≠False)"]
+ Classic --> Channels["3-Channel BM25 (path/content/term)"]
+ Channels --> Rank["rank_retrieval_candidates"]
+ MapNav --> NavSnap["nav_snapshot + run_nav_episode"]
+ NavSnap --> Bridge["nav_bridge referenced_chunks"]
Rank --> Assemble["assemble_retrieval_results"]
+ Bridge --> Assemble
Assemble --> Results["Cited Evidence Results"]
end
```
@@ -544,22 +545,19 @@ debug CSVs (`preds_*.csv`) are saved alongside for troubleshooting.
Core retrieval internals are grouped by ownership:
-- `execution/`: request shaping, route selection, legacy route execution, and public response projection.
-- `search/`: lexical channels, scoring, section filters, and candidate ranking.
+- `execution/`: request shaping, route selection (classic / mapnav / small_corpus), and public response projection.
+- `search/`: lexical channels, scoring, section filters, candidate ranking, and classic `bottom_discovery`.
- `hydration/`: row/path/reference hydration, inline assets, and result assembly.
+- `nav/` + `nav_*.py`: map-nav checklist episode (PLANNER / HARVEST / CONTROL).
+- `trace/`: `DecisionTraceStep` mapping and `TraceRecorder`.
- `graph/`: document graph publication/query support.
- `stats/`: retrieval hit recording.
-- `workflow/`: query planning, retrieve step execution, and wallet state.
-- `agentic/core/`: agentic run types, token budgets, runtime config, and traces.
-- `agentic/discovery/`: bottom discovery and document selection.
-- `agentic/navigation/`: section-tree navigation, selection hydration, and asset tools.
-- `agentic/evidence/`: evidence tree rendering and budget trimming.
### Two Retrieval Modes
-The system supports two modes, controlled globally by `RETRIEVAL_AGENTIC_ENABLED` and locally via the per-request `use_agentic` toggle.
+Per-request `use_agentic`: `False` → classic 3-channel top-K; `None`/`True` → map-nav (default).
-#### Legacy Mode (3-Channel RRF)
+#### Classic Mode (3-Channel RRF)
```mermaid
flowchart LR
@@ -569,40 +567,16 @@ flowchart LR
P --> RRF["RRF Fusion (k=60)"]
C --> RRF
T --> RRF
- RRF --> Graph[Legacy Graph Routing]
- Graph --> Rank[Dual-priority ranking]
+ RRF --> Rank[rank_retrieval_candidates]
Rank --> Assemble[hydration.result_assembly]
```
**Channel weights** (default): path=1.0, content=2.0, term=1.5
**RRF formula**: `score = weight / (k + rank + 1)` per channel, summed across channels.
-#### Agentic Mode (Workflow Orchestrator)
+#### Map-nav Mode (default)
-The agentic pipeline uses `WorkflowOrchestrator` to handle complex queries via a DAG-based planning and budget-constrained execution engine:
-
-1. **Planning (`PlannerAgent`)**: The query is analyzed and decomposed into a DAG of retrieval steps.
- - Simple queries generate a single `retrieve` step.
- - Complex queries are broken into multiple `retrieve` steps. KNOWHERE does not plan answer synthesis steps.
-2. **Budget Ledger (`BudgetLedger`)**: A strict token budget mechanism is enforced across the entire DAG execution. If the budget is exhausted, the pipeline halts safely and returns the best-effort evidence collected so far.
-3. **Execution (`RetrievalAgent`)**: For each `retrieve` step, a multi-phase navigation engine runs:
- - **Phase 1 (Discovery)**: 3-channel RRF keyword search and KG document selection.
- - **Phase 2 (Navigation)**: Constrained Breadth-First Search (BFS) over the document's section tree. Discovered orphan leaves are merged into the tree to prevent data loss.
- - **Phase 3 (Evidence Rendering)**: The hydrated document tree is rendered as `evidence_text`.
-4. **Evidence-Only Contract**: Retrieval responses always expose `evidence_text` as the primary output. `answer_text` is retained only as a deprecated empty string. Downstream agents decide whether the evidence is sufficient and synthesize answers outside KNOWHERE.
-
-### Tree Rendering & Hydration
-
-Unlike legacy retrieval which relied on static `hydrate_mode` tags, hydration is now determined dynamically by the `DocTreeNode` structure:
-- **Structural Context (Outlines)**: Sections not drilled into are simply rendered as structural outlines (`title` + `summary`) to guide the LLM.
-- **Leaf Content (Hydration)**: Sections that the LLM explicitly selects for drill-down have their raw chunks (`text`, `image`, `table`) fully hydrated into the `leaf_content` of the tree.
-- **Multi-Modal Inline Embedding**: During hydration, connected inline assets (images/tables) are natively resolved and embedded directly into the text chunk content, supporting multi-modal LLM processing without brittle string-replacement placeholders.
-
-**`_rank_candidates_by_path()`** — Dual-priority ranking:
-
-- When agent results exist: agent_score is primary, discovery_score is tiebreaker
-- Rows with agent_score=0 are demoted to fallback pool
-- Sort key: `(agent_score, discovery_score, dual_hit_flag, importance_norm_score)`
+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`.
### Result Assembly
diff --git a/apps/api/.env.example b/apps/api/.env.example
index e33e8bc37..400d74747 100644
--- a/apps/api/.env.example
+++ b/apps/api/.env.example
@@ -99,10 +99,10 @@ ARK_API_KEY=
# IMAGE_MODEL_MAX=qwen3.6-flash
# Optional retrieval overrides have code defaults. Retrieval is evidence-only:
-# evidence_text is the primary output and answer_text is always empty. Set
-# RETRIEVAL_AGENTIC_ENABLED=false only when you need to fall back to legacy
-# 3-channel RRF mode.
-# RETRIEVAL_WORKFLOW_PLANNER_TIMEOUT_SECONDS=10.0
+# 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:
+# RETRIEVAL_POSTGRES_FTS_CANDIDATE_LIMIT=2000
# File handling defaults
SUPPORTED_EXTENSIONS=.doc,.docx,.pdf,.txt,.xls,.xlsx,.csv,.pptx,.jpg,.jpeg,.png,.md,.html,.htm
diff --git a/apps/api/app/api/v1/routes/retrieval.py b/apps/api/app/api/v1/routes/retrieval.py
index bdba6660e..758f111e0 100644
--- a/apps/api/app/api/v1/routes/retrieval.py
+++ b/apps/api/app/api/v1/routes/retrieval.py
@@ -72,7 +72,10 @@ class RetrievalQueryRequest(BaseModel):
)
use_agentic: bool | None = Field(
None,
- description="Set to true to enable agentic retrieval (LLM doc-select + navigation). Default (None/false) uses classic 3-channel top-K.",
+ description=(
+ "Map-nav (PLANNER+HARVEST+CONTROL) is the default when unset/true. "
+ "Set false to force classic 3-channel top-K retrieval."
+ ),
)
@field_validator("channels")
diff --git a/apps/api/app/mcp/retrieval_server.py b/apps/api/app/mcp/retrieval_server.py
index 4223ccca7..81210b866 100644
--- a/apps/api/app/mcp/retrieval_server.py
+++ b/apps/api/app/mcp/retrieval_server.py
@@ -157,7 +157,7 @@ async def query_documents(
top_k=top_k,
exclude_document_ids=exclude_document_ids,
exclude_sections=[item for item in exclude_sections],
- use_agentic=True,
+ use_agentic=None,
)
return to_mcp_query_response(response)
diff --git a/apps/api/app/services/document_ingestion/service.py b/apps/api/app/services/document_ingestion/service.py
index 270563328..a0f049c29 100644
--- a/apps/api/app/services/document_ingestion/service.py
+++ b/apps/api/app/services/document_ingestion/service.py
@@ -48,7 +48,7 @@
_PUBLIC_MODE_SELECTOR_FIELDS = {"mode", "processing"}
_PARSE_TRACK_FIELD = "parse_track"
_PAGE_MEMORY_FIELD_PREFIX = "page_memory"
-_PUBLIC_COMPATIBILITY_EXTRA_FIELDS = frozenset({_PARSE_TRACK_FIELD})
+_PUBLIC_COMPATIBILITY_EXTRA_FIELDS = frozenset({_PARSE_TRACK_FIELD, "result_mode"})
IngestionCommandFactory = Callable[[str], DocumentIngestionCommand]
diff --git a/apps/api/tests/contract/test_agentic_discovery_selection_contract.py b/apps/api/tests/contract/test_agentic_discovery_selection_contract.py
deleted file mode 100644
index 09858a1df..000000000
--- a/apps/api/tests/contract/test_agentic_discovery_selection_contract.py
+++ /dev/null
@@ -1,315 +0,0 @@
-from shared.services.retrieval.agentic.navigation.actions import build_legal_actions
-from shared.services.retrieval.agentic.navigation.state import RejectionRecord
-
-
-def _rejected(paths: dict[str, str]) -> dict[str, RejectionRecord]:
- """Build a rejection ledger from {path: reason}."""
- return {
- path: RejectionRecord(path=path, reason=reason, step=1, detail="")
- for path, reason in paths.items()
- }
-
-
-def test_discovery_hint_is_projected_as_collect_action() -> None:
- action_set = build_legal_actions(
- items=[],
- current_scope=None,
- collected_paths=[],
- expanded_scopes=set(),
- discovery_hints=[
- {
- "section_path": "2 阶段性调整还是牛熊切换? / 2.1 牛熊切换缘何开启?",
- "discovery_score": 0.82,
- "chunk_type": "text",
- }
- ],
- rejected={},
- total_images=0,
- total_tables=0,
- budget_snapshot=None,
- )
-
- assert len(action_set.collect) == 1
- action = action_set.collect[0]
- assert action.id == "D1"
- assert action.action == "COLLECT"
- assert action.source == "discovery"
- assert action.path == "2 阶段性调整还是牛熊切换? / 2.1 牛熊切换缘何开启?"
- assert action.score == 0.82
-
-
-def test_discovery_hint_under_collected_path_is_not_repeated() -> None:
- action_set = build_legal_actions(
- items=[],
- current_scope=None,
- collected_paths=[
- {
- "path": "2 阶段性调整还是牛熊切换?",
- "hydrate_mode": "chunks",
- }
- ],
- expanded_scopes=set(),
- discovery_hints=[
- {
- "section_path": "2 阶段性调整还是牛熊切换? / 2.1 牛熊切换缘何开启?",
- "discovery_score": 0.82,
- }
- ],
- rejected={},
- total_images=0,
- total_tables=0,
- budget_snapshot=None,
- )
-
- assert action_set.collect == []
-
-
-def test_discovery_hint_under_tool_adjudicated_path_is_not_repeated() -> None:
- """tool_adjudicated rejections are not revived by discovery this round."""
- action_set = build_legal_actions(
- items=[],
- current_scope=None,
- collected_paths=[],
- expanded_scopes=set(),
- discovery_hints=[
- {
- "section_path": "1、2016:机构行为助推行情演绎 / 二是英国“脱欧”影响下",
- "discovery_score": 0.7,
- }
- ],
- rejected=_rejected({
- "1、2016:机构行为助推行情演绎": "tool_adjudicated",
- }),
- total_images=0,
- total_tables=0,
- budget_snapshot=None,
- )
-
- assert action_set.collect == []
-
-
-# ─── NavigationState ledger (Phase 0) ───────────────────────────────────────
-
-
-def test_mark_rejected_collect_records_tool_adjudicated_reason() -> None:
- from shared.services.retrieval.agentic.navigation.state import NavigationState
-
- state = NavigationState(
- document_id="d1",
- document_name="doc.pdf",
- job_result_id="j1",
- )
- state.mark_rejected_collect("Chapter 1", step=3, detail="no matching asset")
-
- assert "Chapter 1" in state.rejected
- record = state.rejected["Chapter 1"]
- assert record.reason == "tool_adjudicated"
- assert record.step == 3
- assert record.detail == "no matching asset"
-
-
-def test_mark_rejected_if_unproductive_records_navigational_abandon() -> None:
- from shared.services.retrieval.agentic.navigation.state import NavigationState
-
- state = NavigationState(
- document_id="d1",
- document_name="doc.pdf",
- job_result_id="j1",
- )
- state.mark_rejected_if_unproductive("Chapter 2", step=5, detail="back_from_unproductive")
-
- assert state.rejected["Chapter 2"].reason == "navigational_abandon"
-
-
-def test_tool_adjudicated_overrides_weak_abandon_record() -> None:
- from shared.services.retrieval.agentic.navigation.state import NavigationState
-
- state = NavigationState(
- document_id="d1",
- document_name="doc.pdf",
- job_result_id="j1",
- )
- state.mark_rejected_if_unproductive("Chapter 3", step=2)
- state.mark_rejected_collect("Chapter 3", step=4, detail="asset mismatch")
-
- # Stronger reason wins.
- assert state.rejected["Chapter 3"].reason == "tool_adjudicated"
- assert state.rejected["Chapter 3"].step == 4
-
-
-def test_weak_abandon_does_not_overwrite_strong_record() -> None:
- from shared.services.retrieval.agentic.navigation.state import NavigationState
-
- state = NavigationState(
- document_id="d1",
- document_name="doc.pdf",
- job_result_id="j1",
- )
- state.mark_rejected_collect("Chapter 4", step=1)
- state.mark_rejected_if_unproductive("Chapter 4", step=5)
-
- assert state.rejected["Chapter 4"].reason == "tool_adjudicated"
- assert state.rejected["Chapter 4"].step == 1
-
-
-def test_coverage_helpers_derive_from_collected_paths() -> None:
- from shared.services.retrieval.agentic.navigation.state import NavigationState
-
- state = NavigationState(
- document_id="d1",
- document_name="doc.pdf",
- job_result_id="j1",
- )
- state.add_collected(
- {"path": "A", "hydrate_mode": "chunks", "confidence": 0.9},
- step=1,
- scope_context=None,
- )
- state.add_collected(
- {"path": "B", "hydrate_mode": "outline", "confidence": 0.6},
- step=2,
- scope_context=None,
- )
- # A path upgraded from outline to full counts as covered, not outline.
- state.add_collected(
- {"path": "C", "hydrate_mode": "outline", "confidence": 0.5},
- step=3,
- scope_context=None,
- )
- state.add_collected(
- {"path": "C", "hydrate_mode": "chunks", "confidence": 0.8},
- step=4,
- scope_context=None,
- )
-
- assert state.covered_paths() == {"A", "C"}
- assert state.outline_paths() == {"B"}
-
-
-def test_snapshot_delta_records_rejection_reasons() -> None:
- from shared.services.retrieval.agentic.navigation.state import NavigationState
-
- state = NavigationState(
- document_id="d1",
- document_name="doc.pdf",
- job_result_id="j1",
- )
- state.step_count = 2
- state.mark_rejected_if_unproductive("X", step=2)
-
- state.step_count = 3
- state.mark_rejected_collect("Y", step=3, detail="asset mismatch")
-
- delta = state.snapshot_delta(
- before_scope=None,
- expanded_before=set(),
- rejected_before={},
- collected_before_count=0,
- )
- rejected_added = {item["path"]: item["reason"] for item in delta["rejected_added"]}
- assert rejected_added == {"X": "navigational_abandon", "Y": "tool_adjudicated"}
-
-
-def test_rejected_paths_with_reason_partitions_by_label() -> None:
- from shared.services.retrieval.agentic.navigation.state import NavigationState
-
- state = NavigationState(
- document_id="d1",
- document_name="doc.pdf",
- job_result_id="j1",
- )
- state.mark_rejected_collect("A", step=1)
- state.mark_rejected_if_unproductive("B", step=1)
- state.mark_rejected_collect("C", step=1)
-
- assert state.rejected_paths_with_reason("tool_adjudicated") == {"A", "C"}
- assert state.rejected_paths_with_reason("navigational_abandon") == {"B"}
-
-
-# ─── Reason-aware action filtering (T7-style regression) ────────────────────
-
-
-def test_tool_adjudicated_rejection_blocks_collect_even_with_discovery() -> None:
- """A tool-adjudicated path stays out of COLLECT even when discovery hints it."""
- action_set = build_legal_actions(
- items=[],
- current_scope=None,
- collected_paths=[],
- expanded_scopes=set(),
- discovery_hints=[{"section_path": "X", "discovery_score": 0.95}],
- rejected=_rejected({"X": "tool_adjudicated"}),
- total_images=0,
- total_tables=0,
- budget_snapshot=None,
- )
- assert action_set.collect == []
-
-
-def test_navigational_abandon_is_revived_by_discovery_for_collect() -> None:
- """A soft-abandoned path CAN still be COLLECTed when discovery signals it."""
- action_set = build_legal_actions(
- items=[],
- current_scope=None,
- collected_paths=[],
- expanded_scopes=set(),
- discovery_hints=[{"section_path": "Y", "discovery_score": 0.9}],
- rejected=_rejected({"Y": "navigational_abandon"}),
- total_images=0,
- total_tables=0,
- budget_snapshot=None,
- )
- assert any(action.path == "Y" for action in action_set.collect)
-
-
-def test_navigational_abandon_suppresses_expand_without_discovery_signal() -> None:
- """EXPAND is suppressed for soft-abandoned scopes lacking any discovery signal."""
- items = [{"path": "Z", "level": 1, "is_leaf": False, "chunk_count": 5}]
- action_set = build_legal_actions(
- items=items,
- current_scope=None,
- collected_paths=[],
- expanded_scopes=set(),
- discovery_hints=[],
- rejected=_rejected({"Z": "navigational_abandon"}),
- total_images=0,
- total_tables=0,
- budget_snapshot=None,
- )
- assert any(action.path == "Z" for action in action_set.collect)
- assert not any(action.path == "Z" for action in action_set.expand)
-
-
-def test_navigational_abandon_revives_expand_with_discovery_signal() -> None:
- """EXPAND is offered for soft-abandoned scopes when a discovery signal exists."""
- items = [{"path": "Z", "level": 1, "is_leaf": False, "chunk_count": 5}]
- action_set = build_legal_actions(
- items=items,
- current_scope=None,
- collected_paths=[],
- expanded_scopes=set(),
- discovery_hints=[{"section_path": "Z / child", "discovery_score": 0.7}],
- rejected=_rejected({"Z": "navigational_abandon"}),
- total_images=0,
- total_tables=0,
- budget_snapshot=None,
- )
- assert any(action.path == "Z" for action in action_set.expand)
-
-
-def test_covered_path_excluded_from_actions() -> None:
- """Regression: a path already collected as full evidence is not re-offered."""
- items = [{"path": "A", "level": 1, "is_leaf": False, "chunk_count": 3}]
- action_set = build_legal_actions(
- items=items,
- current_scope=None,
- collected_paths=[{"path": "A", "hydrate_mode": "chunks"}],
- expanded_scopes=set(),
- discovery_hints=[{"section_path": "A", "discovery_score": 0.9}],
- rejected={},
- total_images=0,
- total_tables=0,
- budget_snapshot=None,
- )
- assert not any(action.path == "A" for action in action_set.collect)
- assert not any(action.path == "A" for action in action_set.expand)
-
diff --git a/apps/api/tests/contract/test_bm25_fts_prefilter_contract.py b/apps/api/tests/contract/test_bm25_fts_prefilter_contract.py
new file mode 100644
index 000000000..6b9583298
--- /dev/null
+++ b/apps/api/tests/contract/test_bm25_fts_prefilter_contract.py
@@ -0,0 +1,199 @@
+"""Contract tests for the BM25 channel Postgres FTS prefilter.
+
+These run against a real Postgres so the prefilter is validated against the
+same generated tsvector columns and GIN indexes production uses. A pure-Python
+fake would not catch a mismatch between the query configuration and the one
+the columns were generated with.
+"""
+
+from __future__ import annotations
+
+from collections.abc import AsyncGenerator
+
+import pytest
+import pytest_asyncio
+from shared.services.retrieval.search.channels import content_channel, path_channel
+from shared.testing.contract_runtime import PostgreSQLProcess
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
+from sqlalchemy import text
+
+_SCHEMA = """
+CREATE TABLE documents (
+ document_id TEXT PRIMARY KEY,
+ user_id TEXT,
+ namespace TEXT,
+ status TEXT,
+ current_job_result_id INTEGER,
+ source_file_name TEXT
+);
+CREATE TABLE job_results (id INTEGER PRIMARY KEY, job_id TEXT);
+CREATE TABLE document_sections (section_id TEXT PRIMARY KEY, section_path TEXT);
+CREATE TABLE document_chunks (
+ id SERIAL PRIMARY KEY,
+ chunk_id TEXT,
+ document_id TEXT,
+ section_id TEXT,
+ chunk_type TEXT,
+ content TEXT,
+ source_chunk_path TEXT,
+ file_path TEXT,
+ chunk_metadata JSONB,
+ job_result_id INTEGER,
+ sort_order INTEGER,
+ content_search_text TEXT,
+ content_search_tsv TSVECTOR GENERATED ALWAYS AS
+ (to_tsvector('simple', COALESCE(content_search_text, ''))) STORED,
+ path_search_text TEXT,
+ path_search_tsv TSVECTOR GENERATED ALWAYS AS
+ (to_tsvector('simple', COALESCE(path_search_text, ''))) STORED,
+ term_search_text TEXT
+);
+CREATE INDEX idx_chunk_content_search_tsv ON document_chunks USING GIN (content_search_tsv);
+CREATE INDEX idx_chunk_path_search_tsv ON document_chunks USING GIN (path_search_tsv);
+"""
+
+_NOISE_ROWS = 300
+
+
+@pytest_asyncio.fixture
+async def seeded_session(
+ postgresql_proc: PostgreSQLProcess,
+) -> AsyncGenerator[AsyncSession, None]:
+ dsn = (
+ f"postgresql+asyncpg://{postgresql_proc.user}@"
+ f"{postgresql_proc.host}:{postgresql_proc.port}/postgres"
+ )
+ engine = create_async_engine(dsn, isolation_level="AUTOCOMMIT")
+ async with engine.begin() as conn:
+ await conn.execute(text("DROP SCHEMA IF EXISTS bm25_fts CASCADE"))
+ await conn.execute(text("CREATE SCHEMA bm25_fts"))
+ await conn.execute(text("SET search_path TO bm25_fts"))
+ for statement in filter(None, (s.strip() for s in _SCHEMA.split(";"))):
+ await conn.execute(text(statement))
+ await conn.execute(text("INSERT INTO job_results VALUES (1, 'job1')"))
+ await conn.execute(
+ text(
+ "INSERT INTO documents VALUES "
+ "('d1', 'u1', 'ns1', 'active', 1, 'sample.pdf')"
+ )
+ )
+ await conn.execute(text("INSERT INTO document_sections VALUES ('s1', '/root')"))
+ await conn.execute(
+ text(
+ "INSERT INTO document_chunks "
+ "(chunk_id, document_id, section_id, chunk_type, content, "
+ " job_result_id, sort_order, content_search_text, path_search_text) "
+ "VALUES "
+ "('hit-en', 'd1', 's1', 'text', 'body', 1, 1, "
+ " 'alpha beta gamma', 'invoices alpha'), "
+ "('hit-cjk', 'd1', 's1', 'text', 'body', 1, 2, "
+ " '合同 条款 甲方', '合同 目录')"
+ )
+ )
+ await conn.execute(
+ text(
+ "INSERT INTO document_chunks "
+ "(chunk_id, document_id, section_id, chunk_type, content, "
+ " job_result_id, sort_order, content_search_text, path_search_text) "
+ "SELECT 'noise-' || i, 'd1', 's1', 'text', 'body', 1, i + 10, "
+ " 'filler unrelated wording ' || i, 'misc path ' || i "
+ "FROM generate_series(1, :noise) AS i"
+ ),
+ {"noise": _NOISE_ROWS},
+ )
+
+ session_factory = async_sessionmaker(engine, expire_on_commit=False)
+ async with session_factory() as session:
+ await session.execute(text("SET search_path TO bm25_fts"))
+ yield session
+ await engine.dispose()
+
+
+async def _content_hits(session: AsyncSession, query: str) -> list[str]:
+ rows = await content_channel(
+ session,
+ user_id="u1",
+ namespace="ns1",
+ query=query,
+ top_k=50,
+ exclude_document_ids=[],
+ exclude_sections=[],
+ )
+ return [str(row["chunk_id"]) for row in rows]
+
+
+@pytest.mark.asyncio
+async def test_content_channel_returns_only_query_matching_chunks(
+ seeded_session: AsyncSession,
+) -> None:
+ # The corpus holds hundreds of unrelated chunks. Before the prefilter every
+ # one of them was loaded into Python for BM25 scoring.
+ assert await _content_hits(seeded_session, "alpha") == ["hit-en"]
+
+
+@pytest.mark.asyncio
+async def test_content_channel_matches_cjk_tokens(
+ seeded_session: AsyncSession,
+) -> None:
+ assert await _content_hits(seeded_session, "合同") == ["hit-cjk"]
+
+
+@pytest.mark.asyncio
+async def test_content_channel_uses_or_semantics_across_tokens(
+ seeded_session: AsyncSession,
+) -> None:
+ # A row matching any single query token must survive, matching how the
+ # Python BM25 ranker admits rows.
+ hits = await _content_hits(seeded_session, "alpha 合同")
+ assert sorted(hits) == ["hit-cjk", "hit-en"]
+
+
+@pytest.mark.asyncio
+async def test_tsquery_operators_in_query_do_not_change_filter_shape(
+ seeded_session: AsyncSession,
+) -> None:
+ # Tokens are lexed by Postgres as data. If operators leaked into tsquery
+ # syntax, "alpha & zzzz" would AND and drop the row.
+ assert await _content_hits(seeded_session, "alpha & zzzz") == ["hit-en"]
+ assert await _content_hits(seeded_session, "!alpha") == ["hit-en"]
+
+
+@pytest.mark.asyncio
+async def test_query_matching_nothing_returns_no_rows(
+ seeded_session: AsyncSession,
+) -> None:
+ # The fallback re-runs the unfiltered scan, and BM25 then scores no row
+ # above zero, so the channel still yields nothing.
+ assert await _content_hits(seeded_session, "zzzznomatch") == []
+
+
+@pytest.mark.asyncio
+async def test_path_channel_prefilters_on_path_search_tsv(
+ seeded_session: AsyncSession,
+) -> None:
+ rows = await path_channel(
+ seeded_session,
+ user_id="u1",
+ namespace="ns1",
+ query="invoices",
+ top_k=50,
+ exclude_document_ids=[],
+ exclude_sections=[],
+ )
+ assert [str(row["chunk_id"]) for row in rows] == ["hit-en"]
+
+
+@pytest.mark.asyncio
+async def test_exclusions_still_apply_under_the_prefilter(
+ seeded_session: AsyncSession,
+) -> None:
+ rows = await content_channel(
+ seeded_session,
+ user_id="u1",
+ namespace="ns1",
+ query="alpha",
+ top_k=50,
+ exclude_document_ids=["d1"],
+ exclude_sections=[],
+ )
+ assert rows == []
diff --git a/apps/api/tests/contract/test_demo_documents_contract.py b/apps/api/tests/contract/test_demo_documents_contract.py
index a1e704747..bb8efbbcb 100644
--- a/apps/api/tests/contract/test_demo_documents_contract.py
+++ b/apps/api/tests/contract/test_demo_documents_contract.py
@@ -294,6 +294,7 @@ async def test_should_materialize_demo_source_without_parse_or_credit_charge(
"namespace": "contract-demo",
"query": "xAI investment",
"top_k": 5,
+ "use_agentic": False,
},
)
first_response = await api_client.post(
@@ -316,6 +317,7 @@ async def test_should_materialize_demo_source_without_parse_or_credit_charge(
"namespace": "contract-demo",
"query": "xAI investment",
"top_k": 5,
+ "use_agentic": False,
},
)
document_chunks_response = await api_client.get(
diff --git a/apps/api/tests/contract/test_retrieval_contract.py b/apps/api/tests/contract/test_retrieval_contract.py
index 9b8eb1074..78dffed98 100644
--- a/apps/api/tests/contract/test_retrieval_contract.py
+++ b/apps/api/tests/contract/test_retrieval_contract.py
@@ -6,12 +6,8 @@
import pytest
from httpx import AsyncClient
from pytest import MonkeyPatch
-from sqlalchemy.ext.asyncio import AsyncSession
from tests.support.contract_database import ContractDatabase
-from shared.services.retrieval.agentic.core.types import AgenticResult
-from shared.services.retrieval.workflow.run_request import WorkflowRunRequest
-from shared.services.retrieval.workflow.types import PlannedStep, QueryPlan, WorkflowResult
LLMFnInput = str | Sequence[dict[str, Any]]
LLMFn = Callable[[LLMFnInput], Coroutine[Any, Any, str]]
@@ -147,118 +143,7 @@ async def _seed_retrieval_chunk_for_existing_document(
def _result_source(result: dict[str, object]) -> dict[str, object]:
return cast(dict[str, object], result["source"])
-@pytest.mark.asyncio
-async def test_agentic_workflow_should_pass_full_request_policy_to_step_adapter(
- developer_api_client_factory: Callable[
- [], AbstractAsyncContextManager[AsyncClient]
- ],
- monkeypatch: MonkeyPatch,
-) -> None:
- captured_requests: list[dict[str, object]] = []
-
- async def fake_plan(
- self: object,
- *,
- query: str,
- corpus_total_docs: int = 0,
- corpus_total_chunks: int = 0,
- ) -> QueryPlan:
- return QueryPlan(
- original_query=query,
- steps=[PlannedStep(id="request-policy", sub_query="policy marker")],
- final_strategy="concat_final_parts",
- reasoning_summary=(
- f"request policy contract for {corpus_total_docs} docs "
- f"and {corpus_total_chunks} chunks"
- ),
- )
-
- async def fake_retrieval_run(
- self: object,
- db: object,
- **kwargs: object,
- ) -> AgenticResult:
- del self, db
- captured_requests.append(kwargs)
- return AgenticResult(
- evidence_text="policy evidence",
- answer_text="",
- referenced_chunks=[
- {
- "chunk_id": policy_document["chunk_id"],
- "document_id": policy_document["document_id"],
- "chunk_type": "page",
- "section_path": policy_document["section_path"],
- "file_path": "",
- "job_id": policy_document["job_id"],
- }
- ],
- router_used="contract_fake_agent",
- )
-
- async with developer_api_client_factory() as api_client:
- policy_document = await _seed_retrieval_document(
- user_id="local-dev-user",
- namespace="contract-agentic-request-policy",
- source_file_name="policy.pdf",
- section_path="Root/Policy",
- content="policy marker content",
- chunk_type="page",
- )
- await _seed_retrieval_document(
- user_id="local-dev-user",
- namespace="contract-agentic-request-policy",
- source_file_name="filler.pdf",
- section_path="Root/Filler",
- content="filler content",
- chunk_type="page",
- )
- monkeypatch.setattr(
- "shared.services.retrieval.workflow.planner.QueryPlanner.plan",
- fake_plan,
- )
- monkeypatch.setattr(
- "shared.services.retrieval.workflow.step_runner.RetrievalAgent.run",
- fake_retrieval_run,
- )
- response = await api_client.post(
- "/api/v1/retrieval/query",
- json={
- "namespace": "contract-agentic-request-policy",
- "query": "policy marker",
- "top_k": 1,
- "chunk_types": ["page"],
- "signal_paths": ["Root"],
- "filter_mode": "keep",
- "channels": ["content"],
- "channel_weights": {"content": 2.0},
- "internal_recall_k": 23,
- "threshold": 0.4,
- "rerank": True,
- "use_agentic": True,
- },
- )
-
- assert response.status_code == 200
-
- assert len(captured_requests) == 1
- request = captured_requests[0]
- assert request["user_id"] == "local-dev-user"
- assert request["namespace"] == "contract-agentic-request-policy"
- assert request["query"] == "policy marker"
- assert request["top_k"] == 1
- assert request["exclude_document_ids"] == []
- assert request["exclude_sections"] == []
- assert request["chunk_types"] == {"page"}
- assert request["signal_paths"] == ["Root"]
- assert request["filter_mode"] == "keep"
- assert request["channels"] == ["content"]
- assert request["channel_weights"] == {"content": 2.0}
- assert request["internal_recall_k"] == 23
-
-
-@pytest.mark.asyncio
async def test_should_return_seeded_retrieval_results_for_the_authenticated_user(
developer_api_client_factory: Callable[
[], AbstractAsyncContextManager[AsyncClient]
@@ -301,7 +186,6 @@ async def test_should_return_seeded_retrieval_results_for_the_authenticated_user
}
-@pytest.mark.asyncio
async def test_should_default_the_namespace_to_default_when_it_is_omitted(
developer_api_client_factory: Callable[
[], AbstractAsyncContextManager[AsyncClient]
@@ -334,7 +218,6 @@ async def test_should_default_the_namespace_to_default_when_it_is_omitted(
assert _result_source(results[0])["document_id"] == seeded_document["document_id"]
-@pytest.mark.asyncio
async def test_should_return_empty_results_for_an_empty_query(
developer_api_client_factory: Callable[
[], AbstractAsyncContextManager[AsyncClient]
@@ -357,7 +240,6 @@ async def test_should_return_empty_results_for_an_empty_query(
assert response_json["referenced_chunks"] == []
-@pytest.mark.asyncio
async def test_retrieval_should_use_classic_topk_when_agentic_is_false(
developer_api_client_factory: Callable[
[], AbstractAsyncContextManager[AsyncClient]
@@ -394,305 +276,303 @@ async def test_retrieval_should_use_classic_topk_when_agentic_is_false(
assert response_json["router_used"] == "classic_topk"
-@pytest.mark.asyncio
-async def test_agentic_retrieval_should_reference_root_only_document_content(
+async def test_should_return_request_validation_failure_for_an_invalid_channel(
developer_api_client_factory: Callable[
[], AbstractAsyncContextManager[AsyncClient]
],
- monkeypatch: MonkeyPatch,
) -> None:
- monkeypatch.setenv("LLM_MOCK_ENABLED", "true")
+ async with developer_api_client_factory() as api_client:
+ response = await api_client.post(
+ "/api/v1/retrieval/query",
+ json={
+ "namespace": "default",
+ "query": "alpha",
+ "channels": ["invalid-channel"],
+ },
+ )
+ assert response.status_code == 400
+ assert response.headers["x-request-id"]
+
+ response_json = cast(dict[str, object], response.json())
+ error = cast(dict[str, object], response_json["error"])
+ details = cast(dict[str, object], error["details"])
+ violations = cast(list[dict[str, object]], details["violations"])
+
+ assert response_json["success"] is False
+ assert error["code"] == "INVALID_ARGUMENT"
+ assert error["message"] == "Request validation failed"
+ assert violations[0]["field"] == "body.channels"
+ assert "Invalid channel" in cast(str, violations[0]["description"])
+
+
+async def test_should_exclude_matching_document_ids_from_the_response(
+ developer_api_client_factory: Callable[
+ [], AbstractAsyncContextManager[AsyncClient]
+ ],
+) -> None:
async with developer_api_client_factory() as api_client:
- rooted_document = await _seed_retrieval_document(
+ included_document = await _seed_retrieval_document(
user_id="local-dev-user",
- namespace="contract-root-retrieval",
- source_file_name="root-only.pdf",
- section_path="Root",
- content="root only diluted earnings marker content",
+ namespace="contract-retrieval",
+ source_file_name="included.pdf",
+ section_path="contract/included",
+ content="retrieval included content",
)
- await _seed_retrieval_document(
+ excluded_document = await _seed_retrieval_document(
user_id="local-dev-user",
- namespace="contract-root-retrieval",
- source_file_name="filler.pdf",
- section_path="filler/section",
- content="unrelated filler content",
+ namespace="contract-retrieval",
+ source_file_name="excluded.pdf",
+ section_path="contract/excluded",
+ content="retrieval excluded content",
)
response = await api_client.post(
"/api/v1/retrieval/query",
json={
- "namespace": "contract-root-retrieval",
- "query": "diluted earnings marker",
- "top_k": 1,
- "use_agentic": True,
+ "namespace": "contract-retrieval",
+ "query": "retrieval",
+ "exclude_document_ids": [excluded_document["document_id"]],
},
)
assert response.status_code == 200
response_json = cast(dict[str, object], response.json())
- 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"] == "workflow_single_step"
- assert {
- "chunk_id": rooted_document["chunk_id"],
- "document_id": rooted_document["document_id"],
- "chunk_type": "text",
- "section_path": "root-only.pdf",
- "file_path": None,
- "job_id": rooted_document["job_id"],
- } in [
- {k: v for k, v in ref.items() if k != "score"}
- for ref in referenced_chunks
- ]
- assert results[0]["content"] == "root only diluted earnings marker content"
- assert results[0]["source"] == {
- "document_id": rooted_document["document_id"],
- "source_file_name": "root-only.pdf",
- "section_path": "Root",
- }
+ assert len(results) == 1
+ assert _result_source(results[0])["document_id"] == included_document["document_id"]
-@pytest.mark.asyncio
-async def test_agentic_retrieval_should_reference_discovery_content_when_navigation_selects_nothing(
+async def test_should_exclude_matching_sections_from_the_response(
developer_api_client_factory: Callable[
[], AbstractAsyncContextManager[AsyncClient]
],
- monkeypatch: MonkeyPatch,
) -> None:
- monkeypatch.setenv("LLM_MOCK_ENABLED", "true")
-
async with developer_api_client_factory() as api_client:
- discovered_document = await _seed_retrieval_document(
+ included_document = await _seed_retrieval_document(
user_id="local-dev-user",
- namespace="contract-discovery-fallback",
- source_file_name="discovery.pdf",
- section_path="Findings",
- content="discovery fallback EBITDA margin marker content",
+ namespace="contract-retrieval",
+ source_file_name="included-section.pdf",
+ section_path="contract/keep",
+ content="section keep content",
)
- await _seed_retrieval_document(
+ excluded_document = await _seed_retrieval_document(
user_id="local-dev-user",
- namespace="contract-discovery-fallback",
- source_file_name="filler.pdf",
- section_path="filler/section",
- content="unrelated filler content",
+ namespace="contract-retrieval",
+ source_file_name="excluded-section.pdf",
+ section_path="contract/exclude",
+ content="section exclude content",
)
response = await api_client.post(
"/api/v1/retrieval/query",
json={
- "namespace": "contract-discovery-fallback",
- "query": "EBITDA margin marker",
- "top_k": 1,
- "use_agentic": True,
+ "namespace": "contract-retrieval",
+ "query": "section",
+ "exclude_sections": [
+ {
+ "document_id": excluded_document["document_id"],
+ "section_path": excluded_document["section_path"],
+ }
+ ],
},
)
assert response.status_code == 200
response_json = cast(dict[str, object], response.json())
- 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"] == "workflow_single_step"
- assert {
- "chunk_id": discovered_document["chunk_id"],
- "document_id": discovered_document["document_id"],
- "chunk_type": "text",
- "section_path": discovered_document["section_path"],
- "file_path": None,
- "job_id": discovered_document["job_id"],
- } in [
- {k: v for k, v in ref.items() if k != "score"}
- for ref in referenced_chunks
- ]
- assert results[0]["content"] == "discovery fallback EBITDA margin marker content"
- assert results[0]["source"] == {
- "document_id": discovered_document["document_id"],
- "source_file_name": "discovery.pdf",
- "section_path": discovered_document["section_path"],
- }
+ assert len(results) == 1
+ assert _result_source(results[0])["document_id"] == included_document["document_id"]
+ assert _result_source(results[0])["section_path"] == included_document["section_path"]
+
+
+
+def _episode_keeping_chunks(
+ *,
+ documents: list[dict[str, str]],
+ evidence_text: str = "mapnav evidence",
+) -> Any:
+ """Build a minimal EpisodeResult whose kept_chunks use real seeded chunk_ids."""
+ from shared.services.retrieval.nav._compat import AgentStep, Chunk, EpisodeResult
+
+ kept: list[Chunk] = []
+ scored: list[tuple[Chunk, float]] = []
+ 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))
+ return EpisodeResult(
+ representation="mapnav",
+ 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,
+ },
+ )
+ ],
+ 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",
+ )
+
+
+def _patch_run_nav_episode(monkeypatch: MonkeyPatch, episode: Any) -> None:
+ def _fake_run_nav_episode(*_args: Any, **_kwargs: Any) -> Any:
+ return episode
+
+ monkeypatch.setattr(
+ "shared.services.retrieval.nav.run_nav_episode",
+ _fake_run_nav_episode,
+ )
@pytest.mark.asyncio
-async def test_agentic_retrieval_should_not_send_table_artifacts_to_vlm(
+async def test_mapnav_retrieval_should_return_seeded_chunk_via_fake_episode(
developer_api_client_factory: Callable[
[], AbstractAsyncContextManager[AsyncClient]
],
monkeypatch: MonkeyPatch,
) -> None:
- monkeypatch.setenv("LLM_MOCK_ENABLED", "true")
- vlm_calls: list[LLMFnInput] = []
-
- async def fake_vlm(prompt: LLMFnInput) -> str:
- vlm_calls.append(prompt)
- return '{"status":"DONE","answer":"unexpected table VLM answer"}'
-
- def fake_create_retrieval_vlm_fn(**_kwargs: object) -> LLMFn:
- return fake_vlm
-
- class FakeResultStorage:
- def generate_artifact_url(
- self,
- *,
- job_id: str,
- artifact_ref: str,
- expires_in: int = 3600,
- ) -> str | None:
- del expires_in
- return f"https://assets.example.com/{job_id}/{artifact_ref}"
-
- def normalize_artifact_ref(self, artifact_ref: str | None) -> str | None:
- if not artifact_ref:
- return None
- normalized = artifact_ref.strip().replace("\\", "/").lstrip("/")
- if not normalized:
- return None
- root_dir = normalized.split("/", 1)[0]
- if root_dir not in {"images", "tables"}:
- return None
- return normalized
-
- def fake_get_result_storage() -> FakeResultStorage:
- return FakeResultStorage()
-
async with developer_api_client_factory() as api_client:
- monkeypatch.setattr(
- "shared.services.retrieval.llm_adapter.create_retrieval_vlm_fn",
- fake_create_retrieval_vlm_fn,
- )
- monkeypatch.setattr(
- "shared.services.retrieval.hydration.assets.get_result_storage",
- fake_get_result_storage,
- )
- table_document = await _seed_retrieval_document(
+ target = await _seed_retrieval_document(
user_id="local-dev-user",
- namespace="contract-agentic-table-vlm-filter",
- source_file_name="table-report.md",
- section_path="Realdata Results Summary / Main Metrics",
- content=(
- "
| budget | metric | value |
"
- "| 1000 | Flat inspect_evidence_score_mean | "
- "0.5674 |
"
- ),
- chunk_type="table",
- file_path="tables/table-0-main-metrics.html",
+ namespace="contract-mapnav-seed",
+ source_file_name="target.pdf",
+ section_path="Findings",
+ content="mapnav seeded EBITDA marker content",
)
await _seed_retrieval_document(
user_id="local-dev-user",
- namespace="contract-agentic-table-vlm-filter",
- source_file_name="filler-table-report.md",
- section_path="Appendix / Filler Metrics",
- content=(
- "| metric | value |
"
- "| unrelated filler metric | 999 |
"
+ namespace="contract-mapnav-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(
+ monkeypatch,
+ _episode_keeping_chunks(
+ documents=[target_with_content],
+ evidence_text="mapnav seeded EBITDA marker content",
),
- chunk_type="table",
- file_path="tables/table-1-filler-metrics.html",
)
response = await api_client.post(
"/api/v1/retrieval/query",
json={
- "namespace": "contract-agentic-table-vlm-filter",
- "query": "budget 1000 Flat inspect_evidence_score_mean",
+ "namespace": "contract-mapnav-seed",
+ "query": "EBITDA marker",
"top_k": 1,
- "chunk_types": ["table"],
"use_agentic": True,
},
)
assert response.status_code == 200
-
response_json = cast(dict[str, object], response.json())
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"] == "workflow_single_step"
- assert vlm_calls == []
- matching_references = [
- reference
- for reference in referenced_chunks
- if reference["chunk_id"] == table_document["chunk_id"]
+ assert response_json["router_used"] == "mapnav"
+ assert response_json["stop_reason"] == "completed"
+ 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"}
+ for ref in referenced_chunks
]
- assert len(matching_references) == 1
- assert matching_references[0]["document_id"] == table_document["document_id"]
- assert matching_references[0]["chunk_type"] == "table"
- assert matching_references[0]["section_path"] == table_document["section_path"]
- assert matching_references[0]["file_path"] == "tables/table-0-main-metrics.html"
- assert matching_references[0]["job_id"] == table_document["job_id"]
- assert str(matching_references[0]["asset_url"]).startswith(
- "https://assets.example.com/"
- )
- assert len(results) == 1
- assert results[0]["chunk_type"] == "table"
- assert _result_source(results[0])["document_id"] == table_document["document_id"]
+ assert results[0]["content"] == "mapnav seeded EBITDA marker content"
+ assert results[0]["source"] == {
+ "document_id": target["document_id"],
+ "source_file_name": "target.pdf",
+ "section_path": target["section_path"],
+ }
@pytest.mark.asyncio
-async def test_agentic_retrieval_should_not_hydrate_references_outside_request_scope(
+async def test_mapnav_retrieval_should_not_hydrate_references_outside_request_scope(
developer_api_client_factory: Callable[
[], AbstractAsyncContextManager[AsyncClient]
],
monkeypatch: MonkeyPatch,
) -> None:
- class FakeWorkflowOrchestrator:
- async def run_request(
- self,
- _db: AsyncSession,
- *,
- request: WorkflowRunRequest,
- ) -> WorkflowResult:
- return WorkflowResult(
- namespace=request.namespace,
- query=request.query,
- router_used="workflow_single_step",
- answer_text="",
- referenced_chunks=[
- {
- "chunk_id": foreign_document["chunk_id"],
- "document_id": foreign_document["document_id"],
- "chunk_type": "text",
- "section_path": foreign_document["section_path"],
- "file_path": None,
- "job_id": foreign_document["job_id"],
- }
- ],
- )
-
async with developer_api_client_factory() as api_client:
- request_document = await _seed_retrieval_document(
+ await _seed_retrieval_document(
user_id="local-dev-user",
- namespace="contract-visible-scope",
+ namespace="contract-mapnav-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-visible-scope",
+ namespace="contract-mapnav-visible",
source_file_name="visible-filler.pdf",
section_path="visible/filler",
content="visible scoped filler content",
)
- foreign_document = await _seed_retrieval_document(
+ foreign = await _seed_retrieval_document(
user_id="local-dev-user",
- namespace="contract-foreign-scope",
+ namespace="contract-mapnav-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.workflow.orchestrator.WorkflowOrchestrator",
- FakeWorkflowOrchestrator,
+ "shared.services.retrieval.nav_bridge.build_referenced_chunks",
+ _fake_bridge,
)
response = await api_client.post(
"/api/v1/retrieval/query",
json={
- "namespace": "contract-visible-scope",
+ "namespace": "contract-mapnav-visible",
"query": "visible",
"top_k": 1,
"use_agentic": True,
@@ -700,71 +580,63 @@ async def run_request(
)
assert response.status_code == 200
-
response_json = cast(dict[str, object], response.json())
- referenced_chunks = cast(list[dict[str, object]], response_json["referenced_chunks"])
- results = cast(list[dict[str, object]], response_json["results"])
-
- assert request_document["document_id"] != foreign_document["document_id"]
- assert referenced_chunks == []
- assert results == []
+ assert response_json["router_used"] == "mapnav"
+ assert response_json["referenced_chunks"] == []
+ assert response_json["results"] == []
@pytest.mark.asyncio
-async def test_agentic_retrieval_should_drop_references_that_do_not_match_the_hydrated_section(
+async def test_mapnav_retrieval_should_drop_references_with_mismatched_section_path(
developer_api_client_factory: Callable[
[], AbstractAsyncContextManager[AsyncClient]
],
monkeypatch: MonkeyPatch,
) -> None:
- class FakeWorkflowOrchestrator:
- async def run_request(
- self,
- _db: AsyncSession,
- *,
- request: WorkflowRunRequest,
- ) -> WorkflowResult:
- return WorkflowResult(
- namespace=request.namespace,
- query=request.query,
- router_used="workflow_single_step",
- answer_text="",
- referenced_chunks=[
- {
- "chunk_id": visible_chunk["chunk_id"],
- "document_id": visible_chunk["document_id"],
- "chunk_type": "text",
- "section_path": "wrong/section",
- "file_path": None,
- "job_id": visible_chunk["job_id"],
- }
- ],
- )
-
async with developer_api_client_factory() as api_client:
- visible_chunk = await _seed_retrieval_document(
+ visible = await _seed_retrieval_document(
user_id="local-dev-user",
- namespace="contract-reference-section-match",
+ namespace="contract-mapnav-section-mismatch",
source_file_name="visible.pdf",
- section_path="right/section",
+ section_path="visible/section",
content="visible scoped content",
)
await _seed_retrieval_document(
user_id="local-dev-user",
- namespace="contract-reference-section-match",
+ namespace="contract-mapnav-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.workflow.orchestrator.WorkflowOrchestrator",
- FakeWorkflowOrchestrator,
+ "shared.services.retrieval.nav_bridge.build_referenced_chunks",
+ _fake_bridge,
)
response = await api_client.post(
"/api/v1/retrieval/query",
json={
- "namespace": "contract-reference-section-match",
+ "namespace": "contract-mapnav-section-mismatch",
"query": "visible",
"top_k": 1,
"use_agentic": True,
@@ -772,69 +644,44 @@ async def run_request(
)
assert response.status_code == 200
-
response_json = cast(dict[str, object], response.json())
- referenced_chunks = cast(list[dict[str, object]], response_json["referenced_chunks"])
- results = cast(list[dict[str, object]], response_json["results"])
-
- assert referenced_chunks == []
- assert results == []
+ assert response_json["router_used"] == "mapnav"
+ assert response_json["referenced_chunks"] == []
+ assert response_json["results"] == []
@pytest.mark.asyncio
-async def test_agentic_retrieval_should_fail_when_final_hydration_db_fails(
+async def test_mapnav_retrieval_should_fail_when_final_hydration_db_fails(
developer_api_client_factory: Callable[
[], AbstractAsyncContextManager[AsyncClient]
],
monkeypatch: MonkeyPatch,
) -> None:
- class FakeWorkflowOrchestrator:
- async def run_request(
- self,
- _db: AsyncSession,
- *,
- request: WorkflowRunRequest,
- ) -> WorkflowResult:
- return WorkflowResult(
- namespace=request.namespace,
- query=request.query,
- router_used="workflow_single_step",
- answer_text="",
- referenced_chunks=[
- {
- "chunk_id": visible_document["chunk_id"],
- "document_id": visible_document["document_id"],
- "chunk_type": "text",
- "section_path": visible_document["section_path"],
- "file_path": None,
- "job_id": visible_document["job_id"],
- }
- ],
- )
-
async def fail_final_hydration(**_kwargs: object) -> object:
raise RuntimeError("forced final hydration database failure")
async with developer_api_client_factory() as api_client:
- visible_document = await _seed_retrieval_document(
+ visible = await _seed_retrieval_document(
user_id="local-dev-user",
- namespace="contract-final-hydration-failure",
+ namespace="contract-mapnav-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-final-hydration-failure",
+ namespace="contract-mapnav-hydration-failure",
source_file_name="filler.pdf",
section_path="filler/section",
content="filler content",
)
from shared.services.retrieval.execution import routes as retrieval_routes
- monkeypatch.setattr(
- "shared.services.retrieval.workflow.orchestrator.WorkflowOrchestrator",
- FakeWorkflowOrchestrator,
+ _patch_run_nav_episode(
+ monkeypatch,
+ _episode_keeping_chunks(
+ documents=[{**visible, "content": "visible scoped content"}]
+ ),
)
monkeypatch.setattr(
retrieval_routes,
@@ -849,7 +696,7 @@ async def fail_final_hydration(**_kwargs: object) -> object:
await api_client.post(
"/api/v1/retrieval/query",
json={
- "namespace": "contract-final-hydration-failure",
+ "namespace": "contract-mapnav-hydration-failure",
"query": "visible",
"top_k": 1,
"use_agentic": True,
@@ -858,7 +705,7 @@ async def fail_final_hydration(**_kwargs: object) -> object:
@pytest.mark.asyncio
-async def test_agentic_workflow_should_preserve_references_with_the_same_chunk_id_across_documents(
+async def test_mapnav_should_preserve_same_chunk_id_across_documents(
developer_api_client_factory: Callable[
[], AbstractAsyncContextManager[AsyncClient]
],
@@ -866,331 +713,61 @@ async def test_agentic_workflow_should_preserve_references_with_the_same_chunk_i
) -> None:
shared_chunk_id = f"chunk_{uuid4().hex[:12]}"
- async def fake_plan(
- self: object,
- *,
- query: str,
- corpus_total_docs: int = 0,
- corpus_total_chunks: int = 0,
- ) -> QueryPlan:
- return QueryPlan(
- original_query=query,
- steps=[
- PlannedStep(id="first", sub_query="first shared reference"),
- PlannedStep(id="second", sub_query="second shared reference"),
- ],
- final_strategy="concat_final_parts",
- reasoning_summary=(
- f"forced two-step contract plan for {corpus_total_docs} docs "
- f"and {corpus_total_chunks} chunks"
- ),
- )
-
- async def fake_retrieval_run(
- self: object,
- db: object,
- **kwargs: object,
- ) -> AgenticResult:
- query = str(kwargs["query"])
- document = first_document if query == "first shared reference" else second_document
- return AgenticResult(
- evidence_text=f"evidence for {document['document_id']}",
- answer_text="",
- referenced_chunks=[
- {
- "chunk_id": shared_chunk_id,
- "document_id": document["document_id"],
- "chunk_type": "text",
- "section_path": document["section_path"],
- "file_path": "",
- "job_id": document["job_id"],
- }
- ],
- router_used="contract_fake_agent",
- )
-
async with developer_api_client_factory() as api_client:
- first_document = await _seed_retrieval_document(
+ first = await _seed_retrieval_document(
user_id="local-dev-user",
- namespace="contract-shared-chunk-id",
+ namespace="contract-mapnav-shared-chunk",
source_file_name="first.pdf",
- section_path="first/section",
- content="shared deterministic content",
+ section_path="shared/first",
+ content="first shared reference content",
chunk_id=shared_chunk_id,
)
- second_document = await _seed_retrieval_document(
+ second_doc = await _seed_retrieval_document(
user_id="local-dev-user",
- namespace="contract-shared-chunk-id",
+ namespace="contract-mapnav-shared-chunk",
source_file_name="second.pdf",
- section_path="second/section",
- content="shared deterministic content",
- chunk_id=shared_chunk_id,
+ section_path="shared/second-host",
+ content="host content for second document",
)
- monkeypatch.setattr(
- "shared.services.retrieval.workflow.planner.QueryPlanner.plan",
- fake_plan,
- )
- monkeypatch.setattr(
- "shared.services.retrieval.workflow.step_runner.RetrievalAgent.run",
- fake_retrieval_run,
- )
-
- response = await api_client.post(
- "/api/v1/retrieval/query",
- json={
- "namespace": "contract-shared-chunk-id",
- "query": "show both shared references",
- "top_k": 1,
- "use_agentic": True,
- },
- )
-
- assert response.status_code == 200
-
- response_json = cast(dict[str, object], response.json())
- referenced_chunks = cast(list[dict[str, object]], response_json["referenced_chunks"])
- results = cast(list[dict[str, object]], response_json["results"])
-
- referenced_document_ids = {
- cast(str, reference["document_id"]) for reference in referenced_chunks
- }
- result_document_ids = {
- cast(str, _result_source(result)["document_id"]) for result in results
- }
-
- assert referenced_document_ids == {
- first_document["document_id"],
- second_document["document_id"],
- }
- assert result_document_ids == {
- first_document["document_id"],
- second_document["document_id"],
- }
-
-
-@pytest.mark.asyncio
-async def test_agentic_workflow_should_preserve_references_with_the_same_chunk_id_across_sections(
- developer_api_client_factory: Callable[
- [], AbstractAsyncContextManager[AsyncClient]
- ],
- monkeypatch: MonkeyPatch,
-) -> None:
- shared_chunk_id = f"chunk_{uuid4().hex[:12]}"
-
- async def fake_plan(
- self: object,
- *,
- query: str,
- corpus_total_docs: int = 0,
- corpus_total_chunks: int = 0,
- ) -> QueryPlan:
- return QueryPlan(
- original_query=query,
- steps=[
- PlannedStep(id="first", sub_query="first shared section"),
- PlannedStep(id="second", sub_query="second shared section"),
- ],
- final_strategy="concat_final_parts",
- reasoning_summary=(
- f"forced section identity contract plan for {corpus_total_docs} docs "
- f"and {corpus_total_chunks} chunks"
- ),
- )
-
- async def fake_retrieval_run(
- self: object,
- db: object,
- **kwargs: object,
- ) -> AgenticResult:
- query = str(kwargs["query"])
- chunk = first_chunk if query == "first shared section" else second_chunk
- return AgenticResult(
- evidence_text=f"evidence for {chunk['section_path']}",
- answer_text="",
- referenced_chunks=[
- {
- "chunk_id": shared_chunk_id,
- "document_id": chunk["document_id"],
- "chunk_type": "text",
- "section_path": chunk["section_path"],
- "file_path": "",
- "job_id": chunk["job_id"],
- }
- ],
- router_used="contract_fake_agent",
- )
-
- async with developer_api_client_factory() as api_client:
- first_chunk = await _seed_retrieval_document(
- user_id="local-dev-user",
- namespace="contract-shared-section-chunk-id",
- source_file_name="same-document.pdf",
- section_path="first/section",
- content="repeated deterministic content",
- chunk_id=shared_chunk_id,
- )
- second_chunk = await _seed_retrieval_chunk_for_existing_document(
+ second = await _seed_retrieval_chunk_for_existing_document(
user_id="local-dev-user",
- namespace="contract-shared-section-chunk-id",
- document=first_chunk,
- section_path="second/section",
- content="repeated deterministic content",
+ namespace="contract-mapnav-shared-chunk",
+ document=second_doc,
+ section_path="shared/second",
+ content="second shared reference content",
chunk_id=shared_chunk_id,
)
- monkeypatch.setattr(
- "shared.services.retrieval.workflow.planner.QueryPlanner.plan",
- fake_plan,
- )
- monkeypatch.setattr(
- "shared.services.retrieval.workflow.step_runner.RetrievalAgent.run",
- fake_retrieval_run,
+
+ _patch_run_nav_episode(
+ monkeypatch,
+ _episode_keeping_chunks(
+ documents=[
+ {**first, "content": "first shared reference content"},
+ {**second, "content": "second shared reference content"},
+ ]
+ ),
)
response = await api_client.post(
"/api/v1/retrieval/query",
json={
- "namespace": "contract-shared-section-chunk-id",
- "query": "show both shared section references",
+ "namespace": "contract-mapnav-shared-chunk",
+ "query": "shared reference",
"top_k": 1,
"use_agentic": True,
},
)
assert response.status_code == 200
-
response_json = cast(dict[str, object], response.json())
referenced_chunks = cast(list[dict[str, object]], response_json["referenced_chunks"])
results = cast(list[dict[str, object]], response_json["results"])
- referenced_section_paths = {
- cast(str, reference["section_path"]) for reference in referenced_chunks
- }
- result_section_paths = {
- cast(str, _result_source(result)["section_path"]) for result in results
+ assert response_json["router_used"] == "mapnav"
+ assert len(referenced_chunks) == 2
+ assert {ref["document_id"] for ref in referenced_chunks} == {
+ first["document_id"],
+ second["document_id"],
}
-
- assert referenced_section_paths == {
- first_chunk["section_path"],
- second_chunk["section_path"],
- }
- assert result_section_paths == {
- first_chunk["section_path"],
- second_chunk["section_path"],
- }
-
-
-@pytest.mark.asyncio
-async def test_should_return_request_validation_failure_for_an_invalid_channel(
- developer_api_client_factory: Callable[
- [], AbstractAsyncContextManager[AsyncClient]
- ],
-) -> None:
- async with developer_api_client_factory() as api_client:
- response = await api_client.post(
- "/api/v1/retrieval/query",
- json={
- "namespace": "default",
- "query": "alpha",
- "channels": ["invalid-channel"],
- },
- )
-
- assert response.status_code == 400
- assert response.headers["x-request-id"]
-
- response_json = cast(dict[str, object], response.json())
- error = cast(dict[str, object], response_json["error"])
- details = cast(dict[str, object], error["details"])
- violations = cast(list[dict[str, object]], details["violations"])
-
- assert response_json["success"] is False
- assert error["code"] == "INVALID_ARGUMENT"
- assert error["message"] == "Request validation failed"
- assert violations[0]["field"] == "body.channels"
- assert "Invalid channel" in cast(str, violations[0]["description"])
-
-
-@pytest.mark.asyncio
-async def test_should_exclude_matching_document_ids_from_the_response(
- developer_api_client_factory: Callable[
- [], AbstractAsyncContextManager[AsyncClient]
- ],
-) -> None:
- async with developer_api_client_factory() as api_client:
- included_document = await _seed_retrieval_document(
- user_id="local-dev-user",
- namespace="contract-retrieval",
- source_file_name="included.pdf",
- section_path="contract/included",
- content="retrieval included content",
- )
- excluded_document = await _seed_retrieval_document(
- user_id="local-dev-user",
- namespace="contract-retrieval",
- source_file_name="excluded.pdf",
- section_path="contract/excluded",
- content="retrieval excluded content",
- )
-
- response = await api_client.post(
- "/api/v1/retrieval/query",
- json={
- "namespace": "contract-retrieval",
- "query": "retrieval",
- "exclude_document_ids": [excluded_document["document_id"]],
- },
- )
-
- assert response.status_code == 200
-
- response_json = cast(dict[str, object], response.json())
- results = cast(list[dict[str, object]], response_json["results"])
-
- assert len(results) == 1
- assert _result_source(results[0])["document_id"] == included_document["document_id"]
-
-
-@pytest.mark.asyncio
-async def test_should_exclude_matching_sections_from_the_response(
- developer_api_client_factory: Callable[
- [], AbstractAsyncContextManager[AsyncClient]
- ],
-) -> None:
- async with developer_api_client_factory() as api_client:
- included_document = await _seed_retrieval_document(
- user_id="local-dev-user",
- namespace="contract-retrieval",
- source_file_name="included-section.pdf",
- section_path="contract/keep",
- content="section keep content",
- )
- excluded_document = await _seed_retrieval_document(
- user_id="local-dev-user",
- namespace="contract-retrieval",
- source_file_name="excluded-section.pdf",
- section_path="contract/exclude",
- content="section exclude content",
- )
-
- response = await api_client.post(
- "/api/v1/retrieval/query",
- json={
- "namespace": "contract-retrieval",
- "query": "section",
- "exclude_sections": [
- {
- "document_id": excluded_document["document_id"],
- "section_path": excluded_document["section_path"],
- }
- ],
- },
- )
-
- assert response.status_code == 200
-
- response_json = cast(dict[str, object], response.json())
- results = cast(list[dict[str, object]], response_json["results"])
-
- assert len(results) == 1
- assert _result_source(results[0])["document_id"] == included_document["document_id"]
- assert _result_source(results[0])["section_path"] == included_document["section_path"]
+ assert {ref["chunk_id"] for ref in referenced_chunks} == {shared_chunk_id}
+ assert len(results) == 2
diff --git a/apps/api/tests/contract/test_retrieval_mapnav_session_contract.py b/apps/api/tests/contract/test_retrieval_mapnav_session_contract.py
new file mode 100644
index 000000000..ca271f66f
--- /dev/null
+++ b/apps/api/tests/contract/test_retrieval_mapnav_session_contract.py
@@ -0,0 +1,224 @@
+"""Map-nav route session lifetime: route rollback before fresh final hydration."""
+
+from __future__ import annotations
+
+from collections.abc import AsyncGenerator
+from contextlib import asynccontextmanager
+from typing import Any, cast
+
+import pytest
+from pytest import MonkeyPatch
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from shared.services.retrieval.execution import routes as route_module
+from shared.services.retrieval.execution.reference_resolver import (
+ ResolvedWorkflowReferences,
+)
+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.nav_snapshot import build_nav_snapshot
+
+RouteRow = dict[str, object]
+
+
+class _RecordingRouteSession:
+ def __init__(self, events: list[str]) -> None:
+ self._events = events
+
+ async def rollback(self) -> None:
+ self._events.append("route_rollback")
+
+
+@pytest.mark.asyncio
+async def test_mapnav_route_should_release_route_session_before_fresh_final_hydration(
+ monkeypatch: MonkeyPatch,
+) -> None:
+ events: list[str] = []
+ route_db = _RecordingRouteSession(events)
+ fresh_db = object()
+
+ snap = build_nav_snapshot(
+ document_titles={"doc_contract": "Contract"},
+ sections_by_doc={
+ "doc_contract": [
+ SectionRow(
+ section_id="sec_contract",
+ parent_section_id=None,
+ section_path="contract/section",
+ section_title="section",
+ section_level=0,
+ summary="",
+ sort_order=0,
+ )
+ ]
+ },
+ units_by_doc={
+ "doc_contract": [
+ UnitRow(
+ chunk_id="chunk_contract",
+ section_id="sec_contract",
+ chunk_type="text",
+ content="contract content",
+ sort_order=0,
+ )
+ ]
+ },
+ chunk_ref_index={
+ "chunk_contract": {
+ "document_id": "doc_contract",
+ "section_path": "contract/section",
+ "chunk_type": "text",
+ "file_path": None,
+ "job_id": "job_contract",
+ }
+ },
+ )
+
+ @asynccontextmanager
+ async def fake_get_db_context() -> AsyncGenerator[AsyncSession, None]:
+ events.append("fresh_db_open")
+ try:
+ yield cast(AsyncSession, fresh_db)
+ finally:
+ events.append("fresh_db_close")
+
+ async def fake_load_nav_snapshot(
+ db: AsyncSession,
+ **_kwargs: Any,
+ ) -> Any:
+ assert db is route_db
+ events.append("snapshot_loaded")
+ return snap
+
+ def fake_run_nav_episode(*_args: Any, **_kwargs: Any) -> EpisodeResult:
+ events.append("nav_episode")
+ assert events[:3] == [
+ "snapshot_loaded",
+ "route_rollback",
+ "nav_episode",
+ ]
+ chunk = Chunk(
+ node_id="chunk_contract",
+ doc_id="doc_contract",
+ text="contract content",
+ line_ids=(0,),
+ section_id="sec_contract",
+ )
+ return EpisodeResult(
+ representation="mapnav",
+ steps=[
+ AgentStep(
+ step_idx=1,
+ action="query_plan",
+ detail={
+ "plan": {"subgoals": [], "coverage_checklist": []},
+ "token_limit": 100000,
+ "tokens_used_total": 1,
+ "tokens_used_delta": 1,
+ "elapsed_ms": 1,
+ },
+ )
+ ],
+ scored_chunks=[(chunk, 1.0)],
+ kept_chunks=[chunk],
+ evidence_text="contract content",
+ evidence_chars_actual=len("contract content"),
+ retrieved_nodes=["chunk_contract"],
+ stop_reason="completed",
+ )
+
+ async def fake_resolve_workflow_references(
+ *,
+ db: AsyncSession,
+ user_id: str,
+ namespace: str,
+ refs: list[RouteRow],
+ score_by_chunk_id: dict[str, float] | None = None,
+ ) -> ResolvedWorkflowReferences:
+ assert db is fresh_db
+ assert user_id == "contract-user"
+ assert namespace == "contract-namespace"
+ assert refs
+ assert score_by_chunk_id is not None
+ events.append("resolve_references")
+ row = {
+ "document_id": "doc_contract",
+ "chunk_id": "chunk_contract",
+ "source_file_name": "contract.pdf",
+ "section_path": "contract/section",
+ "chunk_type": "text",
+ "content": "contract content",
+ }
+ return ResolvedWorkflowReferences(refs=refs, rows=[row])
+
+ async def fake_assemble_retrieval_results(
+ *,
+ db: AsyncSession,
+ rows: list[RouteRow],
+ exclude_document_ids: list[str],
+ exclude_sections: list[dict[str, str]],
+ allowed_chunk_types: set[str] | None,
+ ) -> list[RouteRow]:
+ assert db is fresh_db
+ assert exclude_document_ids == []
+ assert exclude_sections == []
+ assert allowed_chunk_types is None
+ events.append("assemble_results")
+ return rows
+
+ monkeypatch.setattr(
+ "shared.services.retrieval.nav_snapshot.load_nav_snapshot",
+ fake_load_nav_snapshot,
+ )
+ monkeypatch.setattr(
+ "shared.services.retrieval.nav.run_nav_episode",
+ fake_run_nav_episode,
+ )
+ monkeypatch.setattr(route_module, "open_fresh_database_context", fake_get_db_context)
+ monkeypatch.setattr(
+ route_module,
+ "resolve_workflow_references",
+ fake_resolve_workflow_references,
+ )
+ monkeypatch.setattr(
+ route_module,
+ "assemble_retrieval_results",
+ fake_assemble_retrieval_results,
+ )
+
+ outcome = await route_module._run_mapnav_route(
+ RetrievalRouteContext(
+ db=cast(AsyncSession, route_db),
+ user_id="contract-user",
+ namespace="contract-namespace",
+ query="session lifetime",
+ top_k=1,
+ exclude_document_ids=[],
+ exclude_sections=[],
+ allowed_chunk_types=None,
+ chunk_types=None,
+ signal_paths=None,
+ filter_mode="delete",
+ channels=None,
+ channel_weights=None,
+ rerank=False,
+ threshold=0.0,
+ internal_recall_k=None,
+ effective_recall_k=3,
+ use_agentic=True,
+ )
+ )
+
+ assert events == [
+ "snapshot_loaded",
+ "route_rollback",
+ "nav_episode",
+ "fresh_db_open",
+ "resolve_references",
+ "assemble_results",
+ "fresh_db_close",
+ ]
+ assert outcome.response["router_used"] == "mapnav"
+ assert outcome.response["results"][0]["citation"]["document_id"] == "doc_contract"
+ assert outcome.completion_label == "MAPNAV RETRIEVAL"
diff --git a/apps/api/tests/contract/test_retrieval_workflow_session_contract.py b/apps/api/tests/contract/test_retrieval_workflow_session_contract.py
deleted file mode 100644
index cbe7e472c..000000000
--- a/apps/api/tests/contract/test_retrieval_workflow_session_contract.py
+++ /dev/null
@@ -1,373 +0,0 @@
-from __future__ import annotations
-
-import asyncio
-from collections.abc import AsyncGenerator
-from contextlib import asynccontextmanager
-from typing import cast
-
-import pytest
-from pytest import MonkeyPatch
-from sqlalchemy.ext.asyncio import AsyncSession
-
-from shared.core.exceptions.domain_exceptions import LLMServiceException
-from shared.services.retrieval.execution import routes as route_module
-from shared.services.retrieval.execution.reference_resolver import (
- ResolvedWorkflowReferences,
-)
-from shared.services.retrieval.execution.route_types import RetrievalRouteContext
-from shared.services.retrieval.llm_adapter import LLMFn, create_retrieval_planner_fn
-from shared.services.retrieval.workflow.orchestrator import DbSessionFactory, WorkflowOrchestrator
-from shared.services.retrieval.workflow.plan_service import WorkflowPlanService
-from shared.services.retrieval.workflow.planner import QueryPlanner
-from shared.services.retrieval.workflow.run_request import WorkflowRunRequest
-from shared.services.retrieval.workflow.step_runner import WorkflowStepRunner
-from shared.services.retrieval.workflow.types import (
- PlannedStep,
- QueryPlan,
- StepResult,
- WorkflowResult,
-)
-
-RouteRow = dict[str, object]
-
-
-def _build_planner(
- llm_fn: LLMFn,
- *,
- timeout_seconds: float = 1.0,
-) -> QueryPlanner:
- return QueryPlanner(
- llm_fn=llm_fn,
- planner_ledger=None,
- max_steps=3,
- total_budget=100,
- per_step_budget=10,
- timeout_seconds=timeout_seconds,
- )
-
-
-@pytest.mark.asyncio
-async def test_workflow_planner_timeout_should_return_single_step_fallback() -> None:
- async def slow_llm(_prompt: object) -> str:
- await asyncio.sleep(0.05)
- return "{}"
-
- planner = _build_planner(slow_llm, timeout_seconds=0.001)
-
- plan = await planner.plan(query="original query")
-
- assert plan.planner_status == "fallback"
- assert plan.steps[0].sub_query == "original query"
- assert plan.planner_error is not None
- assert "timed out" in plan.planner_error
-
-
-@pytest.mark.asyncio
-async def test_workflow_planner_provider_error_should_return_single_step_fallback() -> None:
- async def failing_llm(_prompt: object) -> str:
- raise LLMServiceException(internal_message="provider unavailable")
-
- planner = _build_planner(failing_llm)
-
- plan = await planner.plan(query="provider failure query")
-
- assert plan.planner_status == "fallback"
- assert plan.steps[0].sub_query == "provider failure query"
- assert plan.planner_error is not None
-
-
-@pytest.mark.asyncio
-async def test_workflow_planner_invalid_json_should_return_single_step_fallback() -> None:
- async def invalid_llm(_prompt: object) -> str:
- return "not json"
-
- planner = _build_planner(invalid_llm)
-
- plan = await planner.plan(query="invalid planner output")
-
- assert plan.planner_status == "fallback"
- assert plan.steps[0].sub_query == "invalid planner output"
- assert plan.planner_error is not None
- assert "JSON" in plan.planner_error
-
-
-@pytest.mark.asyncio
-async def test_workflow_planner_unexpected_code_error_should_propagate() -> None:
- async def buggy_llm(_prompt: object) -> str:
- raise RuntimeError("unexpected planner bug")
-
- planner = _build_planner(buggy_llm)
-
- with pytest.raises(RuntimeError, match="unexpected planner bug"):
- await planner.plan(query="buggy planner query")
-
-
-@pytest.mark.asyncio
-async def test_workflow_planner_llm_should_pass_timeout_to_provider_client(
- monkeypatch: MonkeyPatch,
-) -> None:
- observed_timeouts: list[object] = []
-
- class FakeClient:
- def chat_completion_with_usage(
- self,
- _prompt: object,
- **kwargs: object,
- ) -> tuple[str, dict[str, int]]:
- observed_timeouts.append(kwargs.get("timeout"))
- return "{}", {"total_tokens": 1}
-
- def fake_build_client_for_channel(
- *,
- channel: str,
- model: str,
- ) -> tuple[FakeClient, str]:
- assert channel == "text"
- return FakeClient(), model
-
- monkeypatch.setattr(
- "shared.services.retrieval.llm_adapter._has_llm_credentials",
- lambda: True,
- )
- monkeypatch.setattr(
- "shared.services.retrieval.llm_adapter._build_client_for_channel",
- fake_build_client_for_channel,
- )
-
- planner_llm = create_retrieval_planner_fn(timeout_seconds=2.1)
-
- assert planner_llm is not None
- await planner_llm("timeout contract")
- assert observed_timeouts == [3]
-
-
-@pytest.mark.asyncio
-async def test_workflow_inventory_session_should_close_before_planner_starts(
- monkeypatch: MonkeyPatch,
-) -> None:
- events: list[str] = []
- inventory_db = object()
-
- @asynccontextmanager
- async def fake_db_factory() -> AsyncGenerator[AsyncSession, None]:
- events.append("inventory_open")
- try:
- yield cast(AsyncSession, inventory_db)
- finally:
- events.append("inventory_close")
-
- async def fake_load_budget_inventory(
- db: AsyncSession,
- *,
- user_id: str,
- namespace: str,
- exclude_document_ids: list[str],
- ) -> tuple[int, int, dict[str, int]]:
- assert db is inventory_db
- assert user_id == "contract-user"
- assert namespace == "contract-namespace"
- assert exclude_document_ids == []
- events.append("inventory_loaded")
- return 3, 2, {}
-
- class RecordingPlanService:
- async def load_or_create(self, **kwargs: object) -> QueryPlan:
- events.append("planner_start")
- assert events == [
- "inventory_open",
- "inventory_loaded",
- "inventory_close",
- "planner_start",
- ]
- return QueryPlan.single_step(str(kwargs["query"]))
-
- class RecordingStepRunner:
- async def run_step(self, **kwargs: object) -> None:
- step = cast(PlannedStep, kwargs["step"])
- results_by_id = cast(dict[str, StepResult], kwargs["results_by_id"])
- results_by_id[step.id] = StepResult(
- step_id=step.id,
- sub_query=step.sub_query,
- step_kind=step.step_kind,
- depends_on=step.depends_on,
- output_role=step.output_role,
- status="done",
- answer_text="",
- )
-
- def create_step_runner(
- db_factory: DbSessionFactory,
- parent_run_id: str,
- ) -> WorkflowStepRunner:
- assert db_factory is fake_db_factory
- assert parent_run_id
- return cast(WorkflowStepRunner, RecordingStepRunner())
-
- monkeypatch.setattr(
- "shared.services.retrieval.workflow.orchestrator._load_budget_inventory",
- fake_load_budget_inventory,
- )
- orchestrator = WorkflowOrchestrator(
- db_factory=fake_db_factory,
- plan_service=cast(WorkflowPlanService, RecordingPlanService()),
- step_runner_factory=create_step_runner,
- )
-
- await orchestrator.run_request(
- cast(AsyncSession, object()),
- request=WorkflowRunRequest(
- user_id="contract-user",
- namespace="contract-namespace",
- query="session lifetime",
- top_k=1,
- exclude_document_ids=[],
- exclude_sections=[],
- ),
- llm_fn=None,
- )
-
- assert events[:4] == [
- "inventory_open",
- "inventory_loaded",
- "inventory_close",
- "planner_start",
- ]
-
-
-@pytest.mark.asyncio
-async def test_agentic_route_should_release_route_session_before_fresh_final_hydration(
- monkeypatch: MonkeyPatch,
-) -> None:
- events: list[str] = []
- route_db = _RecordingRouteSession(events)
- fresh_db = object()
-
- @asynccontextmanager
- async def fake_get_db_context() -> AsyncGenerator[AsyncSession, None]:
- events.append("fresh_db_open")
- try:
- yield cast(AsyncSession, fresh_db)
- finally:
- events.append("fresh_db_close")
-
- class FakeWorkflowOrchestrator:
- async def run_request(
- self,
- db: AsyncSession,
- *,
- request: WorkflowRunRequest,
- ) -> WorkflowResult:
- events.append("workflow_start")
- assert db is route_db
- assert events[:2] == ["route_rollback", "workflow_start"]
- return WorkflowResult(
- namespace=request.namespace,
- query=request.query,
- router_used="workflow_single_step",
- answer_text="",
- referenced_chunks=[
- {
- "chunk_id": "chunk_contract",
- "document_id": "doc_contract",
- "chunk_type": "text",
- "section_path": "contract/section",
- "file_path": None,
- "job_id": "job_contract",
- }
- ],
- )
-
- async def fake_resolve_workflow_references(
- *,
- db: AsyncSession,
- user_id: str,
- namespace: str,
- refs: list[RouteRow],
- score_by_chunk_id: dict[str, float] | None = None,
- ) -> ResolvedWorkflowReferences:
- assert db is fresh_db
- assert user_id == "contract-user"
- assert namespace == "contract-namespace"
- assert score_by_chunk_id is None
- events.append("resolve_references")
- row = {
- "document_id": "doc_contract",
- "chunk_id": "chunk_contract",
- "source_file_name": "contract.pdf",
- "section_path": "contract/section",
- "chunk_type": "text",
- "content": "contract content",
- }
- return ResolvedWorkflowReferences(refs=refs, rows=[row])
-
- async def fake_assemble_retrieval_results(
- *,
- db: AsyncSession,
- rows: list[RouteRow],
- exclude_document_ids: list[str],
- exclude_sections: list[dict[str, str]],
- allowed_chunk_types: set[str] | None,
- ) -> list[RouteRow]:
- assert db is fresh_db
- assert exclude_document_ids == []
- assert exclude_sections == []
- assert allowed_chunk_types is None
- events.append("assemble_results")
- return rows
-
- monkeypatch.setattr(
- "shared.services.retrieval.workflow.orchestrator.WorkflowOrchestrator",
- FakeWorkflowOrchestrator,
- )
- monkeypatch.setattr(route_module, "open_fresh_database_context", fake_get_db_context)
- monkeypatch.setattr(
- route_module,
- "resolve_workflow_references",
- fake_resolve_workflow_references,
- )
- monkeypatch.setattr(
- route_module,
- "assemble_retrieval_results",
- fake_assemble_retrieval_results,
- )
-
- outcome = await route_module._run_agentic_route(
- RetrievalRouteContext(
- db=cast(AsyncSession, route_db),
- user_id="contract-user",
- namespace="contract-namespace",
- query="session lifetime",
- top_k=1,
- exclude_document_ids=[],
- exclude_sections=[],
- allowed_chunk_types=None,
- chunk_types=None,
- signal_paths=None,
- filter_mode="delete",
- channels=None,
- channel_weights=None,
- rerank=False,
- threshold=0.0,
- internal_recall_k=None,
- effective_recall_k=3,
- use_agentic=True,
- )
- )
-
- assert events == [
- "route_rollback",
- "workflow_start",
- "fresh_db_open",
- "resolve_references",
- "assemble_results",
- "fresh_db_close",
- ]
- assert outcome.response["results"][0]["citation"]["document_id"] == "doc_contract"
-
-
-class _RecordingRouteSession:
- def __init__(self, events: list[str]) -> None:
- self._events = events
-
- async def rollback(self) -> None:
- self._events.append("route_rollback")
diff --git a/apps/api/tests/unit/test_bm25_channel_tsquery.py b/apps/api/tests/unit/test_bm25_channel_tsquery.py
new file mode 100644
index 000000000..aea2d9dfa
--- /dev/null
+++ b/apps/api/tests/unit/test_bm25_channel_tsquery.py
@@ -0,0 +1,45 @@
+"""Unit tests for BM25 channel Postgres FTS prefilter token preparation."""
+
+from __future__ import annotations
+
+from shared.services.retrieval.search.channels import (
+ _MAX_FTS_QUERY_TOKENS,
+ _prepare_fts_tokens,
+)
+
+
+def test_returns_empty_for_no_tokens() -> None:
+ assert _prepare_fts_tokens([]) == []
+
+
+def test_keeps_token_order() -> None:
+ assert _prepare_fts_tokens(["alpha", "beta"]) == ["alpha", "beta"]
+
+
+def test_strips_surrounding_whitespace() -> None:
+ assert _prepare_fts_tokens([" alpha ", "beta"]) == ["alpha", "beta"]
+
+
+def test_drops_blank_tokens() -> None:
+ assert _prepare_fts_tokens(["", " ", "alpha"]) == ["alpha"]
+
+
+def test_returns_empty_when_every_token_is_blank() -> None:
+ assert _prepare_fts_tokens(["", " "]) == []
+
+
+def test_caps_token_count() -> None:
+ tokens = [f"tok{index}" for index in range(_MAX_FTS_QUERY_TOKENS + 25)]
+ assert len(_prepare_fts_tokens(tokens)) == _MAX_FTS_QUERY_TOKENS
+
+
+def test_preserves_cjk_tokens() -> None:
+ assert _prepare_fts_tokens(["合同", "条款"]) == ["合同", "条款"]
+
+
+def test_passes_tsquery_operators_through_untouched() -> None:
+ # Tokens travel to Postgres as a text[] parameter and are lexed there, so
+ # operator characters are data rather than syntax. Nothing is escaped or
+ # dropped here.
+ raw = ["alpha' & 'zzzz", "!beta", "a|b"]
+ assert _prepare_fts_tokens(raw) == raw
diff --git a/apps/worker/.env.example b/apps/worker/.env.example
index 7a682e776..3742d1e30 100644
--- a/apps/worker/.env.example
+++ b/apps/worker/.env.example
@@ -94,9 +94,10 @@ ARK_API_KEY=
# IMAGE_MODEL_MAX=qwen3.6-flash
# Optional retrieval overrides have code defaults. Retrieval is evidence-only:
-# evidence_text is the primary output and answer_text is always empty. Set
-# RETRIEVAL_AGENTIC_ENABLED=false only when you need to fall back to legacy
-# 3-channel RRF mode.
+# 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:
+# RETRIEVAL_POSTGRES_FTS_CANDIDATE_LIMIT=2000
# Required for specific features: billing and analytics
BILLING_ENABLED=false
diff --git a/apps/worker/app/services/common/file_utils.py b/apps/worker/app/services/common/file_utils.py
index c266afc97..90eb7a1eb 100644
--- a/apps/worker/app/services/common/file_utils.py
+++ b/apps/worker/app/services/common/file_utils.py
@@ -4,6 +4,10 @@
import pandas as pd
+# Shared cap for cosmetic asset filename stems (images/tables). Keeps OS
+# basename limits safe while preserving a short debug-friendly context.
+MAX_ASSET_FILE_NAME_CHARS = 80
+
def clean_file(path_, mode="remove", cols=None):
"""
diff --git a/apps/worker/app/services/document_agent/executor/prompts.py b/apps/worker/app/services/document_agent/executor/prompts.py
index 8c225d2cc..06fe0458a 100644
--- a/apps/worker/app/services/document_agent/executor/prompts.py
+++ b/apps/worker/app/services/document_agent/executor/prompts.py
@@ -1,15 +1,15 @@
"""Prompts for executor reflexion."""
REFLEXION_INSTRUCTIONS = (
- "You are the executor of a document profiling agent. Decide the next action "
- "from the blackboard facts and available tools. Return strict JSON with keys: "
- "action (tool_call or verdict_now), rationale, optional tool_name/tool_args, "
- "optional verdict {status, rationale}. Use inspect.pages when more visual "
- "evidence is needed, grep.text when native-PDF text evidence is needed, "
- "propose.shard_plan when evidence is sufficient to shard, validate.anatomy_map "
- "after a shard plan exists, and verdict only after validation succeeds. If a "
- "tool failed or validation is invalid, either gather targeted evidence and "
- "retry the relevant tool or abort with a clear rationale."
+ "You are the executor of a document profiling agent. Decide the next tool "
+ "call from the blackboard facts and available tools. Return strict JSON with "
+ "keys: action (must be tool_call), rationale, tool_name, tool_args. "
+ "Use inspect.pages when more visual evidence is needed, grep.text when "
+ "native-PDF text evidence is needed, propose.shard_plan when evidence is "
+ "sufficient to shard, validate.anatomy_map after a shard plan exists, and "
+ "the verdict tool to finish: verdict(status=success) only after validation "
+ "succeeds, or verdict(status=abort, rationale=...) only when the document "
+ "cannot be profiled. Do not invent other finish actions."
)
__all__ = ["REFLEXION_INSTRUCTIONS"]
diff --git a/apps/worker/app/services/document_agent/executor/react_loop.py b/apps/worker/app/services/document_agent/executor/react_loop.py
index a890f702f..9917ac4fa 100644
--- a/apps/worker/app/services/document_agent/executor/react_loop.py
+++ b/apps/worker/app/services/document_agent/executor/react_loop.py
@@ -56,27 +56,49 @@ def _compact_blackboard(ctx: ToolContext) -> dict[str, Any]:
}
+def _coerce_legacy_finish(data: dict[str, Any]) -> ReflexionDecision:
+ """Map obsolete ``action=verdict_now`` into a real tool call.
+
+ Finish belongs exclusively to the ``verdict`` tool. A bare ``verdict_now``
+ without an explicit status is treated as ready-to-shard, never as abort.
+ """
+ rationale = str(data.get("rationale") or "")
+ raw_verdict = data.get("verdict")
+ if isinstance(raw_verdict, dict):
+ status = str(raw_verdict.get("status") or "").strip().lower()
+ if status in {"success", "abort"}:
+ return ReflexionDecision(
+ action="tool_call",
+ rationale=rationale,
+ tool_name="verdict",
+ tool_args={
+ "status": status,
+ "rationale": str(
+ raw_verdict.get("rationale") or rationale or status
+ ),
+ },
+ )
+ return ReflexionDecision(
+ action="tool_call",
+ rationale=rationale or "Legacy verdict_now without status; propose shard plan.",
+ tool_name="propose.shard_plan",
+ tool_args={},
+ )
+
+
def _parse_decision(raw: str) -> ReflexionDecision:
data = json.loads(raw)
- action = str(data.get("action") or "tool_call")
- if action not in {"tool_call", "verdict_now"}:
+ action = str(data.get("action") or "tool_call").strip().lower()
+ if action == "verdict_now":
+ return _coerce_legacy_finish(data if isinstance(data, dict) else {})
+ if action != "tool_call":
action = "tool_call"
- verdict = None
- if isinstance(data.get("verdict"), dict):
- verdict_data = data["verdict"]
- status = str(verdict_data.get("status") or "abort")
- if status not in {"success", "abort"}:
- status = "abort"
- verdict = AgentVerdict(
- status=status, # type: ignore[arg-type]
- rationale=str(verdict_data.get("rationale") or data.get("rationale") or ""),
- )
return ReflexionDecision(
- action=action, # type: ignore[arg-type]
+ action="tool_call",
rationale=str(data.get("rationale") or ""),
tool_name=data.get("tool_name"),
tool_args=dict(data.get("tool_args") or {}),
- verdict=verdict,
+ verdict=None,
)
@@ -98,6 +120,15 @@ def run(self) -> ExecutorResult:
for round_index in range(self.max_rounds):
pending_recovery_verdict: AgentVerdict | None = None
decision, result = self._next_decision(round_index)
+ if decision.action != "tool_call":
+ # Defensive: only tool_call is a legal executor step.
+ decision = ReflexionDecision(
+ action="tool_call",
+ rationale=decision.rationale
+ or "Non-tool executor action coerced to propose.shard_plan.",
+ tool_name="propose.shard_plan",
+ tool_args={},
+ )
self.ctx.blackboard.global_signals.setdefault("reflexion_decisions", []).append(
decision.to_dict()
)
@@ -111,14 +142,9 @@ def run(self) -> ExecutorResult:
tool_args=decision.tool_args,
)
- tool_name: str | None = None
- tool_args: dict[str, Any] = {}
- if decision.action == "verdict_now":
- verdict = decision.verdict or AgentVerdict(
- status="abort",
- rationale=decision.rationale or "Executor stopped without verdict.",
- )
- if verdict.status == "success" and not (
+ tool_name, tool_args = self._resolve_tool_call(decision)
+ if tool_name == "verdict" and str(tool_args.get("status") or "") == "success":
+ if not (
self.ctx.blackboard.validation_report
and self.ctx.blackboard.validation_report.get("valid") is True
):
@@ -131,11 +157,6 @@ def run(self) -> ExecutorResult:
tool_args={},
)
tool_name, tool_args = self._resolve_tool_call(decision)
- else:
- self.ctx.blackboard.verdict = verdict
- return ExecutorResult(verdict=verdict, rounds=round_index + 1)
- else:
- tool_name, tool_args = self._resolve_tool_call(decision)
if not tool_name:
verdict = AgentVerdict(
@@ -205,6 +226,22 @@ def _is_deterministic_mode(self) -> bool:
def _next_decision(self, round_index: int) -> tuple[ReflexionDecision, ToolResult]:
if round_index == 0 and self._initial_decision is not None:
decision = self._initial_decision
+ if decision.action != "tool_call":
+ decision = ReflexionDecision(
+ action="tool_call",
+ rationale=decision.rationale
+ or "Initial non-tool decision coerced to propose.shard_plan.",
+ tool_name="propose.shard_plan",
+ tool_args={},
+ )
+ elif not decision.tool_name:
+ decision = ReflexionDecision(
+ action="tool_call",
+ rationale=decision.rationale
+ or "Initial decision missing tool; propose shard plan.",
+ tool_name="propose.shard_plan",
+ tool_args={},
+ )
return decision, ToolResult(status="ok", payload=decision.to_dict())
model = self.ctx.settings.get("executor_model") or self.ctx.settings.get("model")
if not model:
@@ -224,9 +261,13 @@ def _next_decision(self, round_index: int) -> tuple[ReflexionDecision, ToolResul
est = estimate_tokens(prompt)
if not self.ctx.budget.try_reserve("plan", est):
decision = ReflexionDecision(
- action="verdict_now",
+ action="tool_call",
rationale="Planner budget exhausted.",
- verdict=AgentVerdict(status="abort", rationale="Planner budget exhausted."),
+ tool_name="verdict",
+ tool_args={
+ "status": "abort",
+ "rationale": "Planner budget exhausted.",
+ },
)
return decision, ToolResult(
status="ok",
@@ -303,4 +344,3 @@ def _deterministic_decision(self) -> ReflexionDecision:
tool_name="validate.anatomy_map",
tool_args={},
)
-
diff --git a/apps/worker/app/services/document_agent/manifest.py b/apps/worker/app/services/document_agent/manifest.py
index d078f9d58..d422a55b8 100644
--- a/apps/worker/app/services/document_agent/manifest.py
+++ b/apps/worker/app/services/document_agent/manifest.py
@@ -10,7 +10,10 @@
PageKind = Literal["normal", "landscape"]
TocFailureKind = Literal["none", "confirm_failed", "rejected_all", "degraded"]
-ReflexionAction = Literal["tool_call", "verdict_now"]
+# Executor loop steps are always tool calls. Profile success/abort is owned
+# exclusively by the ``verdict`` tool (AgentVerdict.status), not by a separate
+# ReflexionAction shortcut.
+ReflexionAction = Literal["tool_call"]
VerdictStatus = Literal["success", "abort"]
@@ -28,8 +31,6 @@ class PageFeature:
height: float
has_asset: bool
is_blank_like: bool
- # PDF-space boxes for detected assets; None when none were extracted.
- asset_bboxes: list[dict[str, Any]] | None = None
def to_dict(self) -> dict[str, Any]:
return asdict(self)
diff --git a/apps/worker/app/services/document_agent/persist/__init__.py b/apps/worker/app/services/document_agent/persist/__init__.py
index a34439602..26c0d806a 100644
--- a/apps/worker/app/services/document_agent/persist/__init__.py
+++ b/apps/worker/app/services/document_agent/persist/__init__.py
@@ -1,8 +1,13 @@
"""Persist anatomy map artifacts."""
from app.services.document_agent.tools.persist_anatomy_map import (
+ DOC_PROFILE_FILENAME,
build_anatomy_map,
persist_anatomy_map,
)
-__all__ = ["build_anatomy_map", "persist_anatomy_map"]
+__all__ = [
+ "DOC_PROFILE_FILENAME",
+ "build_anatomy_map",
+ "persist_anatomy_map",
+]
diff --git a/apps/worker/app/services/document_agent/planner/planner.py b/apps/worker/app/services/document_agent/planner/planner.py
index 7e5e1ce01..acf656003 100644
--- a/apps/worker/app/services/document_agent/planner/planner.py
+++ b/apps/worker/app/services/document_agent/planner/planner.py
@@ -156,7 +156,11 @@ def _parse_profile_and_decision(raw: str) -> tuple[DocumentProfile, ReflexionDec
header_y=header_y,
footer_y=footer_y,
)
- next_action = str(data.get("next_action") or "ready_to_shard")
+ next_action = str(data.get("next_action") or "ready_to_shard").strip().lower()
+ # Legacy models may still emit verdict_now; that is not a planner finish
+ # signal — fall through to ready_to_shard so the executor owns success/abort.
+ if next_action == "verdict_now":
+ next_action = "ready_to_shard"
tool_name: str | None = None
tool_args: dict[str, Any] = {}
if next_action == "inspect_more":
@@ -171,12 +175,6 @@ def _parse_profile_and_decision(raw: str) -> tuple[DocumentProfile, ReflexionDec
if query:
tool_name = "grep.text"
tool_args = {"query": query, "max_results": 20}
- elif next_action == "verdict_now":
- return profile, ReflexionDecision(
- action="verdict_now",
- rationale=profile.rationale,
- verdict=None,
- )
if tool_name:
return profile, ReflexionDecision(
action="tool_call",
diff --git a/apps/worker/app/services/document_agent/planner/prompts.py b/apps/worker/app/services/document_agent/planner/prompts.py
index 4ca93ef8e..2c6c48145 100644
--- a/apps/worker/app/services/document_agent/planner/prompts.py
+++ b/apps/worker/app/services/document_agent/planner/prompts.py
@@ -17,10 +17,12 @@
"footer_y is the highest footer line you observe (smallest y) when any "
"footer is present, otherwise null. When both are set, require "
"header_y < footer_y. "
- "next_action must be one of inspect_more, grep_text, ready_to_shard, "
- "verdict_now. Use inspect_more only when extra page screenshots are needed. "
+ "next_action must be one of inspect_more, grep_text, ready_to_shard. "
+ "Use inspect_more only when extra page screenshots are needed. "
"Use grep_text only for native PDFs when a global text search would clarify "
- "structure. Do not output a fixed step plan."
+ "structure. Use ready_to_shard when evidence is sufficient to propose shards. "
+ "Do not finish or abort the profile run from next_action; the executor owns "
+ "success/abort via the verdict tool. Do not output a fixed step plan."
)
__all__ = ["PLANNER_INSTRUCTIONS"]
diff --git a/apps/worker/app/services/document_agent/structure/hierarchy_locator.py b/apps/worker/app/services/document_agent/structure/hierarchy_locator.py
index 5815a4120..f33717875 100644
--- a/apps/worker/app/services/document_agent/structure/hierarchy_locator.py
+++ b/apps/worker/app/services/document_agent/structure/hierarchy_locator.py
@@ -651,8 +651,9 @@ def _entries_to_tree(entries: list[dict[str, Any]]) -> list[TitleNode]:
stack: list[tuple[int, TitleNode]] = []
for entry in entries:
- raw_title = str(entry.get("heading") or "").strip()
- title = clean_toc_title(raw_title) or normalize_heading_text(raw_title)
+ # Keep original TOC heading (incl. numbering). Prefix stripping belongs
+ # only in text/compact match helpers used for null-page parents.
+ title = normalize_heading_text(str(entry.get("heading") or ""))
level = _safe_int(entry.get("level")) or 1
if not title or len(title) < 2:
continue
diff --git a/apps/worker/app/services/document_agent/tools/extract_toc_with_boundaries.py b/apps/worker/app/services/document_agent/tools/extract_toc_with_boundaries.py
index a2de3d295..1480eb4d8 100644
--- a/apps/worker/app/services/document_agent/tools/extract_toc_with_boundaries.py
+++ b/apps/worker/app/services/document_agent/tools/extract_toc_with_boundaries.py
@@ -6,6 +6,7 @@
import json
import os
import time
+from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, cast
@@ -20,6 +21,7 @@
)
from app.services.document_agent.registry import register_tool
from app.services.document_agent.tools.vlm_toc_extractor import (
+ TOC_VLM_MAX_TOKENS,
vlm_entries_to_toc_hierarchies,
)
from app.services.document_parser.formats.pdf.pymupdf_subprocess import (
@@ -34,81 +36,150 @@
MAX_BOUNDARY_ROUNDS = 6
MAX_TOC_PAGES = BOUNDARY_STEP_PAGES * MAX_BOUNDARY_ROUNDS # 30
+_CONFIRM_STAGE = "toc_confirm"
+_CONFIRM_TOKENS_PER_PAGE = 800
+
+_CONFIRM_PROMPT = (
+ "You are a document structure analysis expert. "
+ "Below are screenshot(s) of candidate pages extracted from a PDF. "
+ "These pages contained keywords such as 'Table of Contents' / 'Contents' "
+ "during a text scan.\n\n"
+ "For each page, determine whether it is truly the **start page** of a "
+ "Table of Contents (TOC).\n\n"
+ "Criteria for a real TOC page:\n"
+ "- Contains a list of section titles paired with page numbers\n"
+ "- Titles are connected to page numbers via dots, ellipses, or spaces\n"
+ "- Titles have a systematic numbering scheme (e.g. 1. / 1.1 / Chapter 1)\n\n"
+ "NOT a TOC page:\n"
+ "- Body text that casually mentions 'contents'\n"
+ "- A page with only a 'Contents' heading but body text below\n\n"
+ "Return strict JSON (no markdown fences):\n"
+ '{"pages": [{"page": , "is_toc_start": true/false, '
+ '"reason": "brief reason"}]}'
+)
+
# -- PyMuPDF workers (must be top-level for multiprocessing pickle) ------------
@worker
-def _render_single_page_worker(
- queue, pdf_path: str, page_num: int, output_path: str, dpi: int
+def _render_expand_window_worker(
+ queue,
+ pdf_path: str,
+ pages: list[int],
+ output_dir: str,
+ dpi: int,
+ anchor_page: int,
) -> None:
+ """Render one Phase2 expand window in a single child process.
+
+ Opens the PDF once and writes ``toc_a{anchor}_p{page}.png`` for each page.
+ """
import pymupdf # type: ignore[import]
+ results: list[dict[str, Any]] = []
+ doc = None
try:
doc = pymupdf.open(pdf_path)
- idx = page_num - 1
- if 0 <= idx < doc.page_count:
- page = doc[idx]
- mat = pymupdf.Matrix(dpi / 72.0, dpi / 72.0)
- pix = page.get_pixmap(matrix=mat)
- pix.save(output_path)
+ mat = pymupdf.Matrix(dpi / 72.0, dpi / 72.0)
+ for page_num in pages:
+ idx = page_num - 1
+ if not (0 <= idx < doc.page_count):
+ continue
+ pix = doc[idx].get_pixmap(matrix=mat)
+ png_path = os.path.join(
+ output_dir,
+ f"toc_a{anchor_page}_p{page_num}.png",
+ )
+ pix.save(png_path)
+ results.append({"page": page_num, "png_path": png_path})
finally:
try:
- doc.close()
+ if doc is not None:
+ doc.close()
except Exception:
pass
gc.collect()
- queue.put({"ok": True, "png_path": output_path})
+ queue.put({"ok": True, "results": results})
# -- VLM helpers ---------------------------------------------------------------
-def _vlm_confirm_anchors(
- anchor_pages: list[TocAnchorPage],
- model: str,
- budget: Any | None = None,
-) -> tuple[list[TocAnchorPage], bool, list[TocEvidence]]:
- """Phase 1: send all anchor PNGs to VLM, ask which are real TOC starts."""
- from shared.services.ai.llm_overrides import get_vision_client
+def _iter_chunks(items: list[Any], size: int) -> list[list[Any]]:
+ if size <= 0:
+ raise ValueError("chunk size must be positive")
+ return [items[i : i + size] for i in range(0, len(items), size)]
+
+
+def _parse_confirm_items(raw: str) -> list[dict[str, Any]]:
+ data = json.loads(raw)
+ if isinstance(data, dict):
+ items = data.get("pages") or data.get("results") or data.get("data") or []
+ if not items and len(data) == 1:
+ items = list(data.values())[0]
+ elif isinstance(data, list):
+ items = data
+ else:
+ items = []
+ return [item for item in items if isinstance(item, dict)]
+
+
+def _evidence_from_confirm_items(
+ items: list[dict[str, Any]],
+) -> tuple[set[int], dict[int, TocEvidence]]:
+ confirmed_pages: set[int] = set()
+ evidence_by_page: dict[int, TocEvidence] = {}
+ for item in items:
+ if "page" not in item:
+ continue
+ page = int(item["page"])
+ is_toc_start = bool(item.get("is_toc_start"))
+ if is_toc_start:
+ confirmed_pages.add(page)
+ raw_confidence = item.get("confidence")
+ try:
+ confidence = (
+ float(raw_confidence)
+ if raw_confidence is not None
+ else (0.95 if is_toc_start else 0.05)
+ )
+ except (TypeError, ValueError):
+ confidence = 0.95 if is_toc_start else 0.05
+ evidence_by_page[page] = TocEvidence(
+ page_index=page,
+ source="vlm",
+ confidence=max(0.0, min(1.0, confidence)),
+ reason=str(item.get("reason") or ""),
+ )
+ return confirmed_pages, evidence_by_page
- if not anchor_pages:
- return [], False, []
+def _confirm_anchor_chunk(
+ chunk: list[TocAnchorPage],
+ *,
+ model: str,
+ budget: Any | None,
+) -> tuple[set[int], dict[int, TocEvidence], bool]:
+ """Confirm one BOUNDARY_STEP_PAGES-sized anchor chunk.
+
+ Returns ``(confirmed_pages, evidence_by_page, failed)``.
+ """
import base64
- # Build multi-image message
+ from shared.services.ai.llm_overrides import get_vision_client
+
+ if not chunk:
+ return set(), {}, False
+
content_parts: list[dict[str, Any]] = [
- {
- "type": "text",
- "text": (
- "You are a document structure analysis expert. "
- "Below are screenshot(s) of candidate pages extracted from a PDF. "
- "These pages contained keywords such as 'Table of Contents' / 'Contents' "
- "during a text scan.\n\n"
- "For each page, determine whether it is truly the **start page** of a "
- "Table of Contents (TOC).\n\n"
- "Criteria for a real TOC page:\n"
- "- Contains a list of section titles paired with page numbers\n"
- "- Titles are connected to page numbers via dots, ellipses, or spaces\n"
- "- Titles have a systematic numbering scheme (e.g. 1. / 1.1 / Chapter 1)\n\n"
- "NOT a TOC page:\n"
- "- Body text that casually mentions 'contents'\n"
- "- A page with only a 'Contents' heading but body text below\n\n"
- "Return a strict JSON array (no markdown fences):\n"
- '[{"page": , "is_toc_start": true/false, "reason": "brief reason"}]'
- ),
- }
+ {"type": "text", "text": _CONFIRM_PROMPT},
]
-
- for anchor in anchor_pages:
+ for anchor in chunk:
with open(anchor.png_path, "rb") as f:
img_b64 = base64.b64encode(f.read()).decode()
content_parts.append(
- {
- "type": "text",
- "text": f"\n--- Page {anchor.page} ---",
- }
+ {"type": "text", "text": f"\n--- Page {anchor.page} ---"}
)
content_parts.append(
{
@@ -118,20 +189,22 @@ def _vlm_confirm_anchors(
)
messages = cast(Any, [{"role": "user", "content": content_parts}])
- est = estimate_tokens(str(content_parts[0]["text"])) + len(anchor_pages) * 800
- stage = "toc_confirm"
- if budget and not budget.try_reserve("visual", est, stage=stage):
- logger.warning("[extract.toc] insufficient visual budget for anchor confirmation")
- return [], True, []
+ est = estimate_tokens(_CONFIRM_PROMPT) + len(chunk) * _CONFIRM_TOKENS_PER_PAGE
+ if budget and not budget.try_reserve("visual", est, stage=_CONFIRM_STAGE):
+ logger.warning(
+ "[extract.toc] insufficient visual budget for confirm chunk pages={}",
+ [a.page for a in chunk],
+ )
+ return set(), {}, True
try:
client, resolved_model = get_vision_client(requested_model=model)
- model = resolved_model or model
+ resolved = resolved_model or model
raw, usage = client.chat_completion_with_usage(
messages=messages,
- model=model,
+ model=resolved,
temperature=0.1,
- max_tokens=500,
+ max_tokens=TOC_VLM_MAX_TOKENS,
response_format={"type": "json_object"},
usage_task="document_agent.toc_anchor_confirm",
)
@@ -140,72 +213,361 @@ def _vlm_confirm_anchors(
"visual",
actual=usage.get("total_tokens", est),
est=est,
- stage=stage,
+ stage=_CONFIRM_STAGE,
)
- data = json.loads(raw)
- if isinstance(data, dict):
- items = data.get("pages") or data.get("results") or data.get("data") or []
- if not items and len(data) == 1:
- items = list(data.values())[0]
- elif isinstance(data, list):
- items = data
+ confirmed_pages, evidence_by_page = _evidence_from_confirm_items(
+ _parse_confirm_items(raw)
+ )
+ return confirmed_pages, evidence_by_page, False
+ except Exception as exc:
+ if budget:
+ budget.refund("visual", est=est, stage=_CONFIRM_STAGE)
+ logger.warning(
+ "[extract.toc] VLM confirm chunk failed pages={}: {}",
+ [a.page for a in chunk],
+ exc,
+ )
+ return set(), {}, True
+
+
+def _vlm_confirm_anchors(
+ anchor_pages: list[TocAnchorPage],
+ model: str,
+ budget: Any | None = None,
+) -> tuple[list[TocAnchorPage], bool, list[TocEvidence]]:
+ """Phase 1: confirm TOC starts in BOUNDARY_STEP_PAGES batches (concurrent)."""
+ if not anchor_pages:
+ return [], False, []
+
+ from gevent.pool import Pool as GeventPool
+
+ chunks = _iter_chunks(anchor_pages, BOUNDARY_STEP_PAGES)
+ logger.info(
+ "[extract.toc] Phase 1 confirm: {} anchors → {} chunks (size={}, concurrency={})",
+ len(anchor_pages),
+ len(chunks),
+ BOUNDARY_STEP_PAGES,
+ min(BOUNDARY_STEP_PAGES, len(chunks)),
+ )
+
+ pool = GeventPool(size=min(BOUNDARY_STEP_PAGES, len(chunks)))
+ jobs = [
+ pool.spawn(_confirm_anchor_chunk, chunk, model=model, budget=budget)
+ for chunk in chunks
+ ]
+ pool.join()
+
+ confirmed_pages: set[int] = set()
+ evidence_by_page: dict[int, TocEvidence] = {}
+ chunk_failures = 0
+ chunk_successes = 0
+ for job in jobs:
+ try:
+ chunk_confirmed, chunk_evidence, failed = job.get()
+ except Exception as exc:
+ chunk_failures += 1
+ logger.warning("[extract.toc] confirm greenlet failed: {}", exc)
+ continue
+ if failed:
+ chunk_failures += 1
+ continue
+ chunk_successes += 1
+ confirmed_pages.update(chunk_confirmed)
+ evidence_by_page.update(chunk_evidence)
+
+ confirm_failed = chunk_successes == 0 and chunk_failures > 0
+ confirmed = [a for a in anchor_pages if a.page in confirmed_pages]
+ rejected = [a.page for a in anchor_pages if a.page not in confirmed_pages]
+ evidence = [
+ evidence_by_page.get(
+ a.page,
+ TocEvidence(
+ page_index=a.page,
+ source="vlm",
+ confidence=0.05,
+ reason=(
+ "confirm batch failed for this candidate"
+ if confirm_failed
+ else "VLM response omitted this candidate page"
+ ),
+ ),
+ )
+ for a in anchor_pages
+ ]
+ logger.info(
+ "[extract.toc] Phase 1 done: confirmed={} rejected={} "
+ "chunk_ok={} chunk_fail={} confirm_failed={}",
+ len(confirmed),
+ rejected,
+ chunk_successes,
+ chunk_failures,
+ confirm_failed,
+ )
+ return confirmed, confirm_failed, evidence
+
+
+# -- Phase 2: expand + extract per confirmed start -----------------------------
+
+
+@dataclass
+class _TocRegionResult:
+ anchor_page: int
+ entries: list[dict[str, Any]] = field(default_factory=list)
+ toc_pages: list[int] = field(default_factory=list)
+ hierarchies: list[dict[str, Any]] = field(default_factory=list)
+ batch_meta: list[dict[str, Any]] = field(default_factory=list)
+ batch_trace: list[dict[str, Any]] = field(default_factory=list)
+ error: str | None = None
+
+
+def _render_toc_page_batch(
+ *,
+ pdf_path: str,
+ output_dir: str,
+ dpi: int,
+ anchor_page: int,
+ batch_pages: list[int],
+ render_lock: Any,
+ reuse_png_by_page: dict[int, str] | None = None,
+) -> list[tuple[int, str]]:
+ """Render one expand window under the Phase2 global render lock.
+
+ One child process renders the whole window (PDF opened once). Across
+ anchors, PyMuPDF stays serial to avoid gevent ThreadPool deadlocks.
+ VLM calls happen outside this lock.
+ """
+ reuse = reuse_png_by_page or {}
+ planned: dict[int, str] = {}
+ pages_to_render: list[int] = []
+ for page_num in batch_pages:
+ existing = reuse.get(page_num)
+ if existing and os.path.isfile(existing):
+ planned[page_num] = existing
else:
- items = []
+ pages_to_render.append(page_num)
+
+ with render_lock:
+ if pages_to_render:
+ result = run_in_child_process(
+ _render_expand_window_worker,
+ pdf_path,
+ pages_to_render,
+ output_dir,
+ dpi,
+ anchor_page,
+ timeout=120,
+ )
+ for item in result.get("results") or []:
+ planned[int(item["page"])] = str(item["png_path"])
+
+ page_pngs: list[tuple[int, str]] = []
+ missing: list[int] = []
+ for page_num in batch_pages:
+ png_path = planned.get(page_num)
+ if not png_path:
+ missing.append(page_num)
+ continue
+ page_pngs.append((page_num, png_path))
+ if missing:
+ raise RuntimeError(
+ f"TOC expand render missing pages {missing} for anchor {anchor_page}"
+ )
+ return page_pngs
- confirmed_pages: set[int] = set()
- evidence_by_page: dict[int, TocEvidence] = {}
- for item in items:
- if not isinstance(item, dict) or "page" not in item:
- continue
- page = int(item["page"])
- is_toc_start = bool(item.get("is_toc_start"))
- if is_toc_start:
- confirmed_pages.add(page)
- raw_confidence = item.get("confidence")
- try:
- confidence = (
- float(raw_confidence)
- if raw_confidence is not None
- else (0.95 if is_toc_start else 0.05)
+
+def _extract_region_for_anchor(
+ anchor: TocAnchorPage,
+ *,
+ pdf_path: str,
+ page_count: int,
+ output_dir: str,
+ dpi: int,
+ model: str,
+ render_lock: Any,
+) -> _TocRegionResult:
+ """Expand + extract for one confirmed TOC start.
+
+ Rounds within a start stay serial (continuation context). Different
+ starts run concurrently for VLM, but page renders share ``render_lock``.
+ """
+ from app.services.document_agent.tools.vlm_toc_extractor import (
+ vlm_extract_toc_batch,
+ )
+
+ anchor_page = anchor.page
+ region_entries: list[dict[str, Any]] = []
+ region_toc_pages: list[int] = []
+ region_scan_end = anchor_page
+ batch_meta: list[dict[str, Any]] = []
+ batch_trace: list[dict[str, Any]] = []
+
+ try:
+ for round_idx in range(MAX_BOUNDARY_ROUNDS):
+ batch_start = anchor_page + round_idx * BOUNDARY_STEP_PAGES
+ batch_end = min(batch_start + BOUNDARY_STEP_PAGES - 1, page_count)
+ if batch_start > page_count:
+ break
+
+ batch_pages = list(range(batch_start, batch_end + 1))
+ logger.info(
+ "[extract.toc] batch round {}: pages {}-{} for anchor {}",
+ round_idx,
+ batch_start,
+ batch_end,
+ anchor_page,
+ )
+
+ reuse_png_by_page: dict[int, str] = {}
+ if (
+ round_idx == 0
+ and anchor.png_path
+ and os.path.isfile(anchor.png_path)
+ ):
+ # Phase1 already rendered the confirmed start page.
+ reuse_png_by_page[anchor_page] = anchor.png_path
+
+ page_pngs = _render_toc_page_batch(
+ pdf_path=pdf_path,
+ output_dir=output_dir,
+ dpi=dpi,
+ anchor_page=anchor_page,
+ batch_pages=batch_pages,
+ render_lock=render_lock,
+ reuse_png_by_page=reuse_png_by_page,
+ )
+
+ batch_result = vlm_extract_toc_batch(
+ page_pngs=page_pngs,
+ model=model,
+ previous_entries=region_entries if region_entries else None,
+ )
+ batch_meta.append(batch_result.meta)
+ region_entries.extend(batch_result.all_entries)
+ region_toc_pages.extend(batch_result.toc_pages)
+ region_scan_end = batch_end
+ batch_trace.append(
+ {
+ "anchor": anchor_page,
+ "round": round_idx,
+ "batch_pages": batch_pages,
+ "toc_pages": batch_result.toc_pages,
+ "non_toc_pages": batch_result.non_toc_pages,
+ "entries_found": len(batch_result.all_entries),
+ }
+ )
+
+ last_page_is_toc = (
+ batch_result.page_results
+ and batch_result.page_results[-1].is_toc
+ )
+ if not last_page_is_toc:
+ logger.info(
+ "[extract.toc] boundary found: last page {} is not TOC",
+ batch_end,
)
- except (TypeError, ValueError):
- confidence = 0.95 if is_toc_start else 0.05
- evidence_by_page[page] = TocEvidence(
- page_index=page,
- source="vlm",
- confidence=max(0.0, min(1.0, confidence)),
- reason=str(item.get("reason") or ""),
+ break
+ if batch_end >= page_count:
+ break
+ logger.info(
+ "[extract.toc] last page {} still TOC, expanding window",
+ batch_end,
)
- confirmed = [a for a in anchor_pages if a.page in confirmed_pages]
- rejected = [a.page for a in anchor_pages if a.page not in confirmed_pages]
- evidence = [
- evidence_by_page.get(
- a.page,
- TocEvidence(
- page_index=a.page,
- source="vlm",
- confidence=0.05,
- reason="VLM response omitted this candidate page",
- ),
+ hierarchies: list[dict[str, Any]] = []
+ if region_entries:
+ hierarchies = vlm_entries_to_toc_hierarchies(
+ region_entries,
+ toc_page_nums=region_toc_pages,
+ scan_end_page=region_scan_end,
+ page_count=page_count,
)
- for a in anchor_pages
- ]
- logger.info(
- "[extract.toc] VLM confirmed {} TOC starts, rejected pages: {}",
- len(confirmed),
- rejected,
+ return _TocRegionResult(
+ anchor_page=anchor_page,
+ entries=region_entries,
+ toc_pages=region_toc_pages,
+ hierarchies=hierarchies,
+ batch_meta=batch_meta,
+ batch_trace=batch_trace,
)
- return confirmed, False, evidence
except Exception as exc:
- if budget:
- budget.refund("visual", est=est, stage=stage)
logger.warning(
- "[extract.toc] VLM anchor confirmation failed: {}, "
- "falling back to no confirmed anchors (safe degradation)",
+ "[extract.toc] anchor {} region extract failed: {}",
+ anchor_page,
exc,
)
- return [], True, []
+ return _TocRegionResult(
+ anchor_page=anchor_page,
+ entries=region_entries,
+ toc_pages=region_toc_pages,
+ batch_meta=batch_meta,
+ batch_trace=batch_trace,
+ error=str(exc),
+ )
+
+
+def _extract_regions_for_confirmed_anchors(
+ confirmed: list[TocAnchorPage],
+ *,
+ pdf_path: str,
+ page_count: int,
+ output_dir: str,
+ dpi: int,
+ model: str,
+) -> list[_TocRegionResult]:
+ """Phase 2: concurrent VLM per start; serial PyMuPDF renders across starts."""
+ if not confirmed:
+ return []
+
+ from gevent.lock import Semaphore
+ from gevent.pool import Pool as GeventPool
+
+ pool_size = min(BOUNDARY_STEP_PAGES, len(confirmed))
+ render_lock = Semaphore(1)
+ logger.info(
+ "[extract.toc] Phase 2 extract: {} confirmed starts, "
+ "vlm_concurrency={}, render=serial",
+ len(confirmed),
+ pool_size,
+ )
+ pool = GeventPool(size=pool_size)
+ jobs = [
+ pool.spawn(
+ _extract_region_for_anchor,
+ anchor,
+ pdf_path=pdf_path,
+ page_count=page_count,
+ output_dir=output_dir,
+ dpi=dpi,
+ model=model,
+ render_lock=render_lock,
+ )
+ for anchor in confirmed
+ ]
+ pool.join()
+
+ by_anchor: dict[int, _TocRegionResult] = {}
+ for job in jobs:
+ try:
+ result = job.get()
+ except Exception as exc:
+ logger.warning("[extract.toc] region greenlet failed: {}", exc)
+ continue
+ by_anchor[result.anchor_page] = result
+
+ # Preserve document page order when merging regions.
+ ordered: list[_TocRegionResult] = []
+ for anchor in confirmed:
+ result = by_anchor.get(anchor.page)
+ if result is None:
+ ordered.append(
+ _TocRegionResult(
+ anchor_page=anchor.page,
+ error="region greenlet failed",
+ )
+ )
+ else:
+ ordered.append(result)
+ return ordered
# -- Main tool -----------------------------------------------------------------
@@ -261,7 +623,7 @@ def extract_toc_with_boundaries(
)
os.makedirs(output_dir, exist_ok=True)
- # -- Phase 1: VLM confirm anchors -----------------------------------------
+ # -- Phase 1: VLM confirm anchors (batched + concurrent) -------------------
confirmed, confirm_failed, confirm_evidence = _vlm_confirm_anchors(
anchors, model, budget=ctx.budget
)
@@ -305,14 +667,14 @@ def extract_toc_with_boundaries(
debug=debug_info,
)
- # -- Phase 2+3 (unified): batch classify + extract ---------------------------
- # Instead of separate boundary detection (Phase 2) then per-page extraction
- # (Phase 3), we send batches of BOUNDARY_STEP_PAGES images to VLM in one
- # call. The VLM classifies each page (TOC vs non-TOC) AND extracts entries
- # from TOC pages simultaneously. If the last page in a batch is still TOC,
- # we expand the window and use prior entries as continuation context.
- from app.services.document_agent.tools.vlm_toc_extractor import (
- vlm_extract_toc_batch,
+ # -- Phase 2: per-confirmed-start expand + extract (concurrent across starts)
+ region_results = _extract_regions_for_confirmed_anchors(
+ confirmed,
+ pdf_path=ctx.pdf_path,
+ page_count=page_count,
+ output_dir=output_dir,
+ dpi=dpi,
+ model=model,
)
all_entries: list[dict[str, Any]] = []
@@ -321,98 +683,27 @@ def extract_toc_with_boundaries(
batch_meta: list[dict[str, Any]] = []
batch_trace: list[dict[str, Any]] = []
- for anchor in confirmed:
- anchor_page = anchor.page
- region_entries: list[dict[str, Any]] = []
- region_toc_pages: list[int] = []
- region_scan_end = anchor_page
-
- for round_idx in range(MAX_BOUNDARY_ROUNDS):
- batch_start = anchor_page + round_idx * BOUNDARY_STEP_PAGES
- batch_end = min(
- batch_start + BOUNDARY_STEP_PAGES - 1, page_count
- )
- if batch_start > page_count:
- break
-
- batch_pages = list(range(batch_start, batch_end + 1))
- logger.info(
- "[extract.toc] batch round {}: pages {}-{} for anchor {}",
- round_idx, batch_start, batch_end, anchor_page,
- )
-
- # Render all pages in this batch
- page_pngs: list[tuple[int, str]] = []
- for page_num in batch_pages:
- png_path = os.path.join(output_dir, f"toc_page_{page_num}.png")
- run_in_child_process(
- _render_single_page_worker,
- ctx.pdf_path,
- page_num,
- png_path,
- dpi,
- timeout=60,
- )
- page_pngs.append((page_num, png_path))
-
- # Send batch to VLM — classify + extract in one call
- batch_result = vlm_extract_toc_batch(
- page_pngs=page_pngs,
- model=model,
- previous_entries=region_entries if region_entries else None,
- )
- batch_meta.append(batch_result.meta)
-
- # Collect results
- region_entries.extend(batch_result.all_entries)
- region_toc_pages.extend(batch_result.toc_pages)
- region_scan_end = batch_end
-
- batch_trace.append({
- "anchor": anchor_page,
- "round": round_idx,
- "batch_pages": batch_pages,
- "toc_pages": batch_result.toc_pages,
- "non_toc_pages": batch_result.non_toc_pages,
- "entries_found": len(batch_result.all_entries),
- })
-
- # Determine if we need to continue expanding
- # If the last page in the batch is NOT TOC, boundary found
- last_page_is_toc = (
- batch_result.page_results
- and batch_result.page_results[-1].is_toc
+ for region in region_results:
+ if region.error:
+ warnings.append(
+ f"toc_region_failed:anchor={region.anchor_page}:{region.error}"
)
- if not last_page_is_toc:
- logger.info(
- "[extract.toc] boundary found: last page {} is not TOC",
- batch_end,
- )
- break
-
- # Last page is still TOC — continue expanding
- if batch_end >= page_count:
- break
- logger.info(
- "[extract.toc] last page {} still TOC, expanding window",
- batch_end,
- )
-
- all_entries.extend(region_entries)
- all_toc_pages.extend(region_toc_pages)
-
- if region_entries:
- region_hierarchies = vlm_entries_to_toc_hierarchies(
- region_entries,
- toc_page_nums=region_toc_pages,
- scan_end_page=region_scan_end,
- page_count=page_count,
+ logger.warning(
+ "[extract.toc] anchor {} region failed: {}",
+ region.anchor_page,
+ region.error,
)
- toc_hierarchies.extend(region_hierarchies)
- else:
+ continue
+ all_entries.extend(region.entries)
+ all_toc_pages.extend(region.toc_pages)
+ batch_meta.extend(region.batch_meta)
+ batch_trace.extend(region.batch_trace)
+ if region.hierarchies:
+ toc_hierarchies.extend(region.hierarchies)
+ elif not region.entries:
logger.warning(
"[extract.toc] anchor {} produced no TOC entries",
- anchor_page,
+ region.anchor_page,
)
if not all_entries:
diff --git a/apps/worker/app/services/document_agent/tools/persist_anatomy_map.py b/apps/worker/app/services/document_agent/tools/persist_anatomy_map.py
index 1e17a2202..f5865d856 100644
--- a/apps/worker/app/services/document_agent/tools/persist_anatomy_map.py
+++ b/apps/worker/app/services/document_agent/tools/persist_anatomy_map.py
@@ -10,6 +10,10 @@
from app.services.document_agent.manifest import PageAnatomyMap, ToolContext, ToolResult
+# Debug / package-root artifact name. Keep exported so page_memory debug scripts
+# can import it from ``document_agent.persist``.
+DOC_PROFILE_FILENAME = "doc_profile.json"
+
def _artifact_dir(ctx: ToolContext) -> Path:
if ctx.output_dir:
diff --git a/apps/worker/app/services/document_agent/tools/probe_page_features.py b/apps/worker/app/services/document_agent/tools/probe_page_features.py
index be92b853c..2c3de3598 100644
--- a/apps/worker/app/services/document_agent/tools/probe_page_features.py
+++ b/apps/worker/app/services/document_agent/tools/probe_page_features.py
@@ -203,7 +203,7 @@ def _probe_visual_assets(
header_y: float | None,
footer_y: float | None,
) -> dict[str, Any]:
- """Collect counts + coarse asset bboxes from images / tables / drawings."""
+ """Collect counts + has_asset gate from images / tables / drawings."""
image_area = 0.0
bboxes: list[dict[str, Any]] = []
seen_image_rects: set[tuple[float, float, float, float]] = set()
@@ -276,7 +276,8 @@ def _probe_visual_assets(
"image_count": image_count,
"table_count": table_count,
"drawings_count": drawings_count,
- "asset_bboxes": bboxes or None,
+ # Geometry is only used to derive the gate; do not persist bboxes.
+ "has_asset": bool(bboxes),
}
@@ -364,7 +365,6 @@ def probe_page_features(ctx: ToolContext, _args: dict[str, Any]) -> ToolResult:
height=float(item.get("height") or 0.0),
has_asset=False,
is_blank_like=bool(item.get("is_blank_like")),
- asset_bboxes=None,
)
for item in (result.get("features") or [])
]
@@ -412,7 +412,7 @@ def probe_page_assets(ctx: ToolContext, _args: dict[str, Any]) -> ToolResult:
updated: list[PageFeature] = []
for feature in ctx.blackboard.page_features:
visual = by_page.get(feature.page) or {}
- has_asset = visual.get("asset_bboxes") is not None
+ has_asset = bool(visual.get("has_asset"))
updated.append(
PageFeature(
page=feature.page,
@@ -427,11 +427,6 @@ def probe_page_assets(ctx: ToolContext, _args: dict[str, Any]) -> ToolResult:
height=feature.height,
has_asset=has_asset,
is_blank_like=feature.raw_text_length < 50 and not has_asset,
- asset_bboxes=(
- list(visual["asset_bboxes"])
- if isinstance(visual.get("asset_bboxes"), list)
- else None
- ),
)
)
ctx.blackboard.page_features = sorted(updated, key=lambda f: f.page)
diff --git a/apps/worker/app/services/document_agent/tools/vlm_toc_extractor.py b/apps/worker/app/services/document_agent/tools/vlm_toc_extractor.py
index 9c6c87c95..cc331b290 100644
--- a/apps/worker/app/services/document_agent/tools/vlm_toc_extractor.py
+++ b/apps/worker/app/services/document_agent/tools/vlm_toc_extractor.py
@@ -8,6 +8,9 @@
from dataclasses import dataclass
from typing import Any, cast
+# Shared completion budget for TOC VLM calls (confirm batches + extract batches).
+TOC_VLM_MAX_TOKENS = 8192
+
# ---------------------------------------------------------------------------
# Batch-mode prompt: send a window of candidate pages in one VLM call.
@@ -196,7 +199,7 @@ def vlm_extract_toc_batch(
messages=cast(Any, [{"role": "user", "content": content_parts}]),
model=model,
temperature=0.1,
- max_tokens=8192,
+ max_tokens=TOC_VLM_MAX_TOKENS,
response_format={"type": "json_object"},
usage_task="document_agent.vlm_toc_batch",
)
diff --git a/apps/worker/app/services/document_parser/formats/markdown/deferred_summary.py b/apps/worker/app/services/document_parser/formats/markdown/deferred_summary.py
index 06ff1ad06..7684fee73 100644
--- a/apps/worker/app/services/document_parser/formats/markdown/deferred_summary.py
+++ b/apps/worker/app/services/document_parser/formats/markdown/deferred_summary.py
@@ -29,7 +29,7 @@
from shared.services.ai.summary.engine import summarize
from shared.services.ai.summary.model import AssetSummary, BodySummary
from shared.utils.chunk_refs import build_chunk_ref
-from app.services.common.file_utils import path_handle
+from app.services.common.file_utils import MAX_ASSET_FILE_NAME_CHARS, path_handle
# Each deferred task now carries the engine's typed contract straight through to
# the apply step (audit §4.5): assets → AssetSummary, text → BodySummary. The row
@@ -280,7 +280,10 @@ def _apply_image_summary_result(
image_dir = original_task.image_dir
old_img_name = original_task.image_name
image_suffix = original_task.image_suffix
- safe_title = path_handle(str(img_title), mode="clean_single")
+ cleaned_title = path_handle(str(img_title), mode="clean_single")
+ if not isinstance(cleaned_title, str) or not cleaned_title:
+ return
+ safe_title = cleaned_title[:MAX_ASSET_FILE_NAME_CHARS]
img_num_match = re.match(r"image-(\d+)", str(old_img_name))
img_num = (
img_num_match.group(1)
@@ -290,6 +293,8 @@ def _apply_image_summary_result(
else "0"
)
new_img_name = path_handle(f"image-{img_num}-{safe_title}", mode="clean_single")
+ if not isinstance(new_img_name, str) or not new_img_name:
+ return
old_path = os.path.join(image_dir, f"{old_img_name}{image_suffix}")
new_path = os.path.join(image_dir, f"{new_img_name}{image_suffix}")
if old_path == new_path or not os.path.exists(old_path):
diff --git a/apps/worker/app/services/document_parser/formats/markdown/image_asset.py b/apps/worker/app/services/document_parser/formats/markdown/image_asset.py
index 52305689d..1958bdbf0 100644
--- a/apps/worker/app/services/document_parser/formats/markdown/image_asset.py
+++ b/apps/worker/app/services/document_parser/formats/markdown/image_asset.py
@@ -19,7 +19,7 @@
from loguru import logger
from shared.utils.chunk_refs import build_chunk_ref
-from app.services.common.file_utils import path_handle
+from app.services.common.file_utils import MAX_ASSET_FILE_NAME_CHARS, path_handle
@dataclass(frozen=True)
@@ -135,6 +135,7 @@ def build_markdown_image_asset(
def build_markdown_image_name(*, image_count: int, last_context: str) -> str:
image_name_context = path_handle(last_context.strip(), mode="clean_single")
if image_name_context:
+ image_name_context = image_name_context[:MAX_ASSET_FILE_NAME_CHARS]
return f"image-{image_count}-{image_name_context}"
return f"image-{image_count}"
diff --git a/apps/worker/app/services/document_parser/structure/heading_llm_executor.py b/apps/worker/app/services/document_parser/structure/heading_llm_executor.py
index 0af790288..07fe1c6d0 100644
--- a/apps/worker/app/services/document_parser/structure/heading_llm_executor.py
+++ b/apps/worker/app/services/document_parser/structure/heading_llm_executor.py
@@ -263,6 +263,16 @@ def demote_consecutive_same_level(
Note: a candidate the LLM already demoted to -1 naturally breaks the run,
because -1 never equals a positive level — it acts like a body separator.
+
+ TODO(heading-demote): Consider an iterative fixpoint pass that re-runs this
+ demotion while treating already-demoted ids as transparent (skipped for
+ adjacency only; final level stays -1). That would clean leftover singleton
+ TOC lines (e.g. ``2.4 foo....11``) after their same-level siblings were
+ demoted, i.e. pure outline regions with no body placeholders. Do NOT ship
+ without guarding the false-positive where empty subsections demote first
+ (``2.4 / 2.4.1 / 2.4.2 / 2.5 / 2.5.1 / [BODY]``) and a later pass then
+ wrongly merges real sibling parents ``2.4`` and ``2.5`` into one same-level
+ run. Deferred until we have TOC-page vs empty-subsection regression cases.
"""
demoted: set[int] = set()
run: list[tuple[int, int]] = [] # (row_id, level) for a no-body candidate run
diff --git a/apps/worker/app/services/document_parser/tables/table_text_parser.py b/apps/worker/app/services/document_parser/tables/table_text_parser.py
index 261bbe653..935f04e6c 100644
--- a/apps/worker/app/services/document_parser/tables/table_text_parser.py
+++ b/apps/worker/app/services/document_parser/tables/table_text_parser.py
@@ -8,7 +8,7 @@
import pandas as pd
from bs4 import BeautifulSoup, Tag
-_MAX_TABLE_NAME_CHARS = 80
+from app.services.common.file_utils import MAX_ASSET_FILE_NAME_CHARS
def sanitize_table_name_from_header(raw_header_text: str) -> str:
@@ -31,8 +31,8 @@ def sanitize_table_name_from_header(raw_header_text: str) -> str:
meaningful = [field for field in unique if _is_meaningful_token(field)]
result = " ".join(meaningful)
- if len(result) > _MAX_TABLE_NAME_CHARS:
- result = result[:_MAX_TABLE_NAME_CHARS].rstrip()
+ if len(result) > MAX_ASSET_FILE_NAME_CHARS:
+ result = result[:MAX_ASSET_FILE_NAME_CHARS].rstrip()
return result
diff --git a/apps/worker/app/services/page_memory/skeleton_extractor.py b/apps/worker/app/services/page_memory/skeleton_extractor.py
index 43f664029..ce835659d 100644
--- a/apps/worker/app/services/page_memory/skeleton_extractor.py
+++ b/apps/worker/app/services/page_memory/skeleton_extractor.py
@@ -28,9 +28,6 @@
locate_title_compact_strict,
resolve_hierarchy_page_ranges,
)
-from app.services.document_parser.structure.body_boundary import (
- clean_toc_title,
-)
from loguru import logger
from shared.services.chunks.path_segments import (
append_document_path,
@@ -298,7 +295,8 @@ def _range_to_skeleton(
) -> SectionSkeleton:
start_page = _clamp_page(item.start_page, page_count)
end_page = _clamp_page(item.end_page, page_count)
- path_titles = [clean_toc_title(title) or title for title in item.path_titles]
+ # Keep original TOC titles (incl. numbering) in section_path / HIERARCHY.
+ path_titles = [str(title).strip() for title in item.path_titles if str(title).strip()]
section_path = join_document_path([filename, *path_titles])
parent_path = (
join_document_path([filename, *path_titles[:-1]])
diff --git a/apps/worker/tests/contract/test_agentic_evidence_renderer_contract.py b/apps/worker/tests/contract/test_agentic_evidence_renderer_contract.py
deleted file mode 100644
index e3db6597e..000000000
--- a/apps/worker/tests/contract/test_agentic_evidence_renderer_contract.py
+++ /dev/null
@@ -1,128 +0,0 @@
-from shared.services.retrieval.agentic.evidence.renderer import render_leaf_chunks
-
-
-def test_render_direct_table_chunk_uses_summary_and_asset_url() -> None:
- parts: list[str] = []
- render_leaf_chunks(
- parts,
- [
- {
- "chunk_id": "table-1",
- "chunk_type": "table",
- "content": "",
- "file_path": "tables/table-企业入驻信息表.html",
- "chunk_metadata": {
- "summary": "企业入驻信息登记模板",
- "keywords": ["企业信息", "入驻管理"],
- },
- }
- ],
- " ",
- asset_lookup={"table-1": "http://localhost:4566/table.html?signature=test"},
- )
-
- rendered = "\n".join(parts)
- assert "[Table: http://localhost:4566/table.html?signature=test]" in rendered
- assert "企业入驻信息登记模板" in rendered
- assert "企业信息;入驻管理" in rendered
- assert "SHOULD NOT LEAK" not in rendered
- assert " None:
- parts: list[str] = []
- render_leaf_chunks(
- parts,
- [
- {
- "chunk_id": "table-1",
- "chunk_type": "table",
- "content": "",
- "file_path": "tables/table-企业入驻信息表.html",
- "source_chunk_path": "企业信息汇总260509 (1).xlsx/企业批量录入",
- "chunk_metadata": {
- "summary": "table-企业批量录入\n企业入驻信息登记模板",
- "keywords": ["企业信息", "入驻管理"],
- },
- }
- ],
- " ",
- asset_lookup={"table-1": "http://localhost:4566/table.html?signature=test"},
- )
-
- rendered = "\n".join(parts)
- assert "[Table: http://localhost:4566/table.html?signature=test]" in rendered
- assert "Table path: 企业信息汇总260509 (1).xlsx/企业批量录入" in rendered
- assert "Table asset: tables/table-企业入驻信息表.html" in rendered
- assert "table-企业批量录入" in rendered
- assert "Main columns:" in rendered
- assert "企业信息;入驻管理" in rendered
- assert "" not in rendered
-
-
-def test_render_connected_table_chunk_includes_asset_url() -> None:
- parts: list[str] = []
- render_leaf_chunks(
- parts,
- [
- {
- "chunk_id": "text-1",
- "chunk_type": "text",
- "content": "见表 [tables/table-1.html]",
- "chunk_metadata": {
- "connect_to": [
- {
- "target": "table-1",
- "ref": "[tables/table-1.html]",
- }
- ]
- },
- },
- {
- "chunk_id": "table-1",
- "chunk_type": "table",
- "content": "",
- "file_path": "tables/table-1.html",
- "chunk_metadata": {"summary": "A 表摘要"},
- },
- ],
- " ",
- asset_lookup={"table-1": "http://localhost:4566/table-1.html?signature=test"},
- )
-
- rendered = "\n".join(parts)
- assert "[Table: http://localhost:4566/table-1.html?signature=test]" in rendered
- assert "A 表摘要" in rendered
- assert "SHOULD NOT LEAK" not in rendered
- assert " None:
- parts: list[str] = []
- render_leaf_chunks(
- parts,
- [
- {
- "chunk_id": "page-node-1",
- "chunk_type": "page",
- "content": "RAW OCR SHOULD NOT LEAK",
- "chunk_metadata": {
- "summary": "制度标准总则摘要",
- "page_nums": [225, 226],
- },
- }
- ],
- " ",
- asset_lookup={
- "page-node-1": "http://localhost:4566/page_pdfs/225-226.pdf?signature=test"
- },
- )
-
- rendered = "\n".join(parts)
- assert "Pages 225-226" in rendered
- assert "制度标准总则摘要" in rendered
- assert (
- "Page PDF (pages 225-226): "
- "http://localhost:4566/page_pdfs/225-226.pdf?signature=test"
- ) in rendered
- assert "RAW OCR SHOULD NOT LEAK" not in rendered
diff --git a/apps/worker/tests/contract/test_doc_profile_anatomy_contract.py b/apps/worker/tests/contract/test_doc_profile_anatomy_contract.py
index 839b61654..ae11c7750 100644
--- a/apps/worker/tests/contract/test_doc_profile_anatomy_contract.py
+++ b/apps/worker/tests/contract/test_doc_profile_anatomy_contract.py
@@ -58,7 +58,6 @@ def _page_feature(page: int = 1) -> PageFeature:
height=72.0,
has_asset=False,
is_blank_like=False,
- asset_bboxes=None,
)
@@ -158,6 +157,7 @@ def test_run_lightweight_anatomy_builds_single_shard_without_planner_llm(
)
assert list(anatomy_data)[:2] == ["version", "toc_hierarchies"]
assert "text_lines_preview" not in anatomy_data["page_features"][0]
+ assert "asset_bboxes" not in anatomy_data["page_features"][0]
trace_data = json.loads((output_dir / "trace.json").read_text(encoding="utf-8"))
assert "visual_stages" in trace_data["summary"]["budget"]
diff --git a/apps/worker/tests/contract/test_profile_agent_protocol_contract.py b/apps/worker/tests/contract/test_profile_agent_protocol_contract.py
new file mode 100644
index 000000000..b16233a5e
--- /dev/null
+++ b/apps/worker/tests/contract/test_profile_agent_protocol_contract.py
@@ -0,0 +1,214 @@
+"""Protocol tests: planner next_action and executor finish ownership."""
+
+from __future__ import annotations
+
+import json
+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")
+
+from app.services.document_agent.budget import BudgetTracker
+from app.services.document_agent.executor.react_loop import (
+ ReActExecutor,
+ _parse_decision,
+)
+from app.services.document_agent.manifest import (
+ DocumentProfile,
+ ReflexionDecision,
+ ToolContext,
+)
+from app.services.document_agent.planner.planner import _parse_profile_and_decision
+from app.services.document_agent.registry import REGISTRY
+from app.services.document_agent import tools as _registered_tools # noqa: F401
+from app.services.document_agent.state import AgentBlackboard
+
+
+def test_planner_verdict_now_falls_through_to_ready_to_shard() -> None:
+ raw = json.dumps(
+ {
+ "is_scanned": True,
+ "category": "Feasibility Study Report",
+ "routing_category": "generic",
+ "category_rationale": "scanned prose",
+ "language": "zh",
+ "rationale": "scanned PDF not atlas",
+ "header_y": None,
+ "footer_y": None,
+ "next_action": "verdict_now",
+ "inspect_pages": [],
+ "grep_query": "",
+ }
+ )
+ profile, decision = _parse_profile_and_decision(raw)
+ assert profile.is_scanned is True
+ assert decision.action == "tool_call"
+ assert decision.tool_name == "propose.shard_plan"
+ assert decision.verdict is None
+
+
+def test_planner_ready_to_shard_proposes_shard_plan() -> None:
+ raw = json.dumps(
+ {
+ "is_scanned": False,
+ "category": "Report",
+ "routing_category": "generic",
+ "language": "en",
+ "rationale": "enough evidence",
+ "next_action": "ready_to_shard",
+ }
+ )
+ _profile, decision = _parse_profile_and_decision(raw)
+ assert decision.action == "tool_call"
+ assert decision.tool_name == "propose.shard_plan"
+
+
+def test_planner_inspect_more_maps_to_inspect_pages() -> None:
+ raw = json.dumps(
+ {
+ "is_scanned": False,
+ "category": "Report",
+ "routing_category": "generic",
+ "language": "en",
+ "rationale": "need more pages",
+ "next_action": "inspect_more",
+ "inspect_pages": [3, 8],
+ }
+ )
+ _profile, decision = _parse_profile_and_decision(raw)
+ assert decision.tool_name == "inspect.pages"
+ assert decision.tool_args["pages"] == [3, 8]
+
+
+def test_executor_legacy_verdict_now_without_status_becomes_shard() -> None:
+ decision = _parse_decision(
+ json.dumps(
+ {
+ "action": "verdict_now",
+ "rationale": "classification done",
+ }
+ )
+ )
+ assert decision.action == "tool_call"
+ assert decision.tool_name == "propose.shard_plan"
+
+
+def test_executor_legacy_verdict_now_with_abort_status_uses_verdict_tool() -> None:
+ decision = _parse_decision(
+ json.dumps(
+ {
+ "action": "verdict_now",
+ "rationale": "cannot profile",
+ "verdict": {"status": "abort", "rationale": "cannot profile"},
+ }
+ )
+ )
+ assert decision.action == "tool_call"
+ assert decision.tool_name == "verdict"
+ assert decision.tool_args["status"] == "abort"
+
+
+def _seed_pages(blackboard: AgentBlackboard, page_count: int) -> None:
+ from app.services.document_agent.manifest import PageFeature, PageLabel
+
+ blackboard.page_count = page_count
+ blackboard.doc_stats = {"page_count": page_count}
+ blackboard.page_features = [
+ PageFeature(
+ page=page,
+ raw_text_length=0,
+ text_density=0.0,
+ image_coverage=1.0,
+ image_count=1,
+ table_count=0,
+ drawings_count=0,
+ orientation="portrait",
+ width=612.0,
+ height=792.0,
+ has_asset=True,
+ is_blank_like=True,
+ )
+ for page in range(1, page_count + 1)
+ ]
+ blackboard.page_labels = [
+ PageLabel(page=page, kind="normal", confidence=0.9)
+ for page in range(1, page_count + 1)
+ ]
+
+
+def test_executor_initial_ready_to_shard_reaches_success_without_abort() -> None:
+ blackboard = AgentBlackboard()
+ _seed_pages(blackboard, 4)
+ blackboard.document_profile = DocumentProfile(
+ is_scanned=True,
+ category="Feasibility Study Report",
+ routing_category="generic",
+ rationale="scanned PDF not atlas",
+ )
+ from app.services.document_agent.manifest import TocResult
+
+ blackboard.toc_result = TocResult(method="none", notes="no toc")
+ ctx = ToolContext(
+ pdf_path="/tmp/scanned.pdf",
+ job_id="job-scanned",
+ blackboard=blackboard,
+ budget=BudgetTracker(plan_budget=50_000, visual_budget=80_000),
+ trace=None,
+ settings={}, # deterministic executor (no LLM)
+ )
+ initial = ReflexionDecision(
+ action="tool_call",
+ rationale="ready",
+ tool_name="propose.shard_plan",
+ tool_args={},
+ )
+ result = ReActExecutor(
+ ctx,
+ registry=REGISTRY,
+ max_rounds=10,
+ initial_decision=initial,
+ ).run()
+ assert result.verdict.status == "success"
+ assert blackboard.shard_plan is not None
+ assert len(blackboard.shard_plan.shards) >= 1
+
+
+def test_executor_empty_initial_tool_falls_through_to_success() -> None:
+ """Missing tool_name must coerce to propose.shard_plan, not abort."""
+ blackboard = AgentBlackboard()
+ _seed_pages(blackboard, 3)
+ from app.services.document_agent.manifest import TocResult
+
+ blackboard.toc_result = TocResult(method="none", notes="no toc")
+ blackboard.document_profile = DocumentProfile(
+ is_scanned=True,
+ category="Report",
+ routing_category="generic",
+ )
+ ctx = ToolContext(
+ pdf_path="/tmp/scanned.pdf",
+ job_id="job-legacy",
+ blackboard=blackboard,
+ budget=BudgetTracker(plan_budget=50_000, visual_budget=80_000),
+ trace=None,
+ settings={},
+ )
+ initial = ReflexionDecision(
+ action="tool_call",
+ rationale="stale empty decision",
+ tool_name=None,
+ tool_args={},
+ )
+ result = ReActExecutor(
+ ctx,
+ registry=REGISTRY,
+ max_rounds=10,
+ initial_decision=initial,
+ ).run()
+ assert result.verdict.status == "success"
+ assert blackboard.shard_plan is not None
+ assert len(blackboard.shard_plan.shards) == 1
diff --git a/apps/worker/tests/contract/test_table_embedded_images_contract.py b/apps/worker/tests/contract/test_table_embedded_images_contract.py
index 0da70fad8..79cff2e3e 100644
--- a/apps/worker/tests/contract/test_table_embedded_images_contract.py
+++ b/apps/worker/tests/contract/test_table_embedded_images_contract.py
@@ -32,11 +32,6 @@
from shared.services.chunks.dataframe_chunk_converter import ( # noqa: E402
dataframe_to_chunks,
)
-from shared.services.retrieval.agentic.evidence.renderer import ( # noqa: E402
- render_table_chunk_lines,
-)
-
-
def _write_jpeg(
path: Path,
*,
@@ -183,28 +178,6 @@ def test_table_embedded_images_are_extracted_rewritten_and_linked(
for item in table_connect
)
- rendered = render_table_chunk_lines(
- {
- "chunk_id": table_id,
- "chunk_type": "table",
- "file_path": by_type["table"]["metadata"].get("file_path"),
- "chunk_metadata": by_type["table"]["metadata"],
- },
- display_ref="tables/demo.html",
- chunk_by_id={
- image_id: {
- "chunk_id": image_id,
- "chunk_type": "image",
- "file_path": by_type["image"]["metadata"].get("file_path"),
- "content": by_type["image"]["content"],
- }
- },
- asset_lookup={image_id: "https://example.com/img.jpg"},
- rendered_ids=set(),
- )
- assert any("[Image: https://example.com/img.jpg]" in line for line in rendered)
-
-
def test_table_embedded_images_skip_below_img_min_size(tmp_path: Path) -> None:
output_dir = tmp_path / "doc"
images_dir = output_dir / "images"
diff --git a/apps/worker/tests/contract/test_toc_confirm_batch_contract.py b/apps/worker/tests/contract/test_toc_confirm_batch_contract.py
new file mode 100644
index 000000000..f92992519
--- /dev/null
+++ b/apps/worker/tests/contract/test_toc_confirm_batch_contract.py
@@ -0,0 +1,126 @@
+"""Contract tests for TOC Phase-1 batched VLM anchor confirmation."""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from typing import Any
+
+import pytest
+
+from app.services.document_agent.budget import BudgetTracker, StageEnvelope
+from app.services.document_agent.manifest import TocAnchorPage
+from app.services.document_agent.tools import extract_toc_with_boundaries as toc_tool
+
+
+def _anchors(tmp_path: Path, pages: list[int]) -> list[TocAnchorPage]:
+ out: list[TocAnchorPage] = []
+ for page in pages:
+ png = tmp_path / f"toc_anchor_page_{page}.png"
+ png.write_bytes(b"fake-png")
+ out.append(
+ TocAnchorPage(
+ page=page,
+ png_path=str(png),
+ source="text_scan",
+ )
+ )
+ return out
+
+
+def test_iter_chunks_uses_boundary_step_size() -> None:
+ items = list(range(12))
+ chunks = toc_tool._iter_chunks(items, toc_tool.BOUNDARY_STEP_PAGES) # noqa: SLF001
+ assert chunks == [
+ [0, 1, 2, 3, 4],
+ [5, 6, 7, 8, 9],
+ [10, 11],
+ ]
+
+
+def test_vlm_confirm_anchors_batches_and_merges_partial_failures(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """One confirm chunk may fail; successful chunks still contribute confirmed pages."""
+ anchors = _anchors(tmp_path, [5, 11, 40, 44, 55, 78, 97])
+ assert toc_tool.BOUNDARY_STEP_PAGES == 5
+ # 7 anchors → 2 chunks: [5..55] and [78, 97]
+
+ call_pages: list[list[int]] = []
+
+ class _FakeClient:
+ def chat_completion_with_usage(self, **kwargs: Any) -> tuple[str, dict[str, int]]:
+ content = kwargs["messages"][0]["content"]
+ pages = []
+ for part in content:
+ if not isinstance(part, dict) or part.get("type") != "text":
+ continue
+ text = str(part.get("text") or "")
+ if text.startswith("\n--- Page ") and text.endswith(" ---"):
+ pages.append(int(text[len("\n--- Page ") : -len(" ---")]))
+ call_pages.append(pages)
+ if 78 in pages:
+ raise RuntimeError("simulated truncated JSON")
+ payload = {
+ "pages": [
+ {
+ "page": page,
+ "is_toc_start": page in {5, 11, 40},
+ "reason": "ok",
+ }
+ for page in pages
+ ]
+ }
+ return json.dumps(payload), {"total_tokens": 100}
+
+ monkeypatch.setattr(
+ "shared.services.ai.llm_overrides.get_vision_client",
+ lambda requested_model=None: (_FakeClient(), requested_model or "fake-vlm"),
+ )
+
+ budget = BudgetTracker(
+ plan_budget=50_000,
+ visual_budget=200_000,
+ visual_stage_envelopes={
+ "toc_confirm": StageEnvelope(min_guarantee=0, cap=None),
+ },
+ )
+ confirmed, confirm_failed, evidence = toc_tool._vlm_confirm_anchors( # noqa: SLF001
+ anchors,
+ model="fake-vlm",
+ budget=budget,
+ )
+
+ assert sorted(call_pages[0] + call_pages[1]) == [5, 11, 40, 44, 55, 78, 97]
+ assert {tuple(pages) for pages in call_pages} == {
+ (5, 11, 40, 44, 55),
+ (78, 97),
+ }
+ assert confirm_failed is False
+ assert [a.page for a in confirmed] == [5, 11, 40]
+ assert {e.page_index for e in evidence} == {5, 11, 40, 44, 55, 78, 97}
+
+
+def test_vlm_confirm_anchors_all_chunks_fail_sets_confirm_failed(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ anchors = _anchors(tmp_path, [5, 11])
+
+ class _FakeClient:
+ def chat_completion_with_usage(self, **kwargs: Any) -> tuple[str, dict[str, int]]:
+ raise RuntimeError("boom")
+
+ monkeypatch.setattr(
+ "shared.services.ai.llm_overrides.get_vision_client",
+ lambda requested_model=None: (_FakeClient(), requested_model or "fake-vlm"),
+ )
+
+ confirmed, confirm_failed, _evidence = toc_tool._vlm_confirm_anchors( # noqa: SLF001
+ anchors,
+ model="fake-vlm",
+ budget=None,
+ )
+ assert confirmed == []
+ assert confirm_failed is True
diff --git a/apps/worker/tests/contract/test_toc_phase2_region_concurrency_contract.py b/apps/worker/tests/contract/test_toc_phase2_region_concurrency_contract.py
new file mode 100644
index 000000000..a068b0b88
--- /dev/null
+++ b/apps/worker/tests/contract/test_toc_phase2_region_concurrency_contract.py
@@ -0,0 +1,276 @@
+"""Contract tests for TOC Phase-2 concurrent VLM with serial batch renders."""
+
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Any
+
+import gevent
+import pytest
+
+from app.services.document_agent.manifest import TocAnchorPage
+from app.services.document_agent.tools import extract_toc_with_boundaries as toc_tool
+from app.services.document_agent.tools.vlm_toc_extractor import (
+ BatchPageResult,
+ BatchTocResult,
+)
+
+
+def _anchors(tmp_path: Path, pages: list[int]) -> list[TocAnchorPage]:
+ out: list[TocAnchorPage] = []
+ for page in pages:
+ png = tmp_path / f"toc_anchor_page_{page}.png"
+ png.write_bytes(b"anchor-png")
+ out.append(
+ TocAnchorPage(page=page, png_path=str(png), source="vlm")
+ )
+ return out
+
+
+def _anchor_from_png(png_path: str) -> int:
+ name = Path(png_path).name
+ if name.startswith("toc_anchor_page_"):
+ return int(name[len("toc_anchor_page_") : -len(".png")])
+ # toc_a{anchor}_p{page}.png
+ stem = name.removesuffix(".png")
+ anchor_part, _sep, _page_part = stem.partition("_p")
+ assert anchor_part.startswith("toc_a"), png_path
+ return int(anchor_part[len("toc_a") :])
+
+
+def _batch_result(
+ *,
+ toc_pages: list[int],
+ non_toc_pages: list[int],
+ entries: list[dict[str, Any]] | None = None,
+) -> BatchTocResult:
+ page_results: list[BatchPageResult] = [
+ BatchPageResult(page=page, is_toc=True, entries=[]) for page in toc_pages
+ ]
+ page_results.extend(
+ BatchPageResult(page=page, is_toc=False, entries=[]) for page in non_toc_pages
+ )
+ if page_results and page_results[-1].is_toc:
+ page_results[-1] = BatchPageResult(
+ page=page_results[-1].page,
+ is_toc=False,
+ entries=[],
+ )
+ return BatchTocResult(
+ page_results=page_results,
+ toc_pages=list(toc_pages),
+ non_toc_pages=list(non_toc_pages),
+ all_entries=list(entries or []),
+ meta={"ok": True},
+ )
+
+
+def _fake_batch_render(worker_fn: Any, *args: Any, **kwargs: Any) -> dict[str, Any]:
+ """Match ``_render_expand_window_worker`` args: pages list in one spawn."""
+ pages = list(args[1])
+ output_dir = Path(args[2])
+ anchor_page = int(args[4])
+ output_dir.mkdir(parents=True, exist_ok=True)
+ results: list[dict[str, Any]] = []
+ for page_num in pages:
+ png_path = output_dir / f"toc_a{anchor_page}_p{page_num}.png"
+ png_path.write_bytes(b"png")
+ results.append({"page": page_num, "png_path": str(png_path)})
+ return {"ok": True, "results": results}
+
+
+def test_extract_regions_runs_per_anchor_and_merges_in_page_order(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ confirmed = _anchors(tmp_path, [10, 40, 80])
+ calls: list[int] = []
+ render_calls: list[list[int]] = []
+
+ def _tracking_render(worker_fn: Any, *args: Any, **kwargs: Any) -> dict[str, Any]:
+ render_calls.append(list(args[1]))
+ return _fake_batch_render(worker_fn, *args, **kwargs)
+
+ def _fake_batch(
+ *,
+ page_pngs: list[tuple[int, str]],
+ model: str,
+ previous_entries: list[dict[str, Any]] | None = None,
+ ) -> BatchTocResult:
+ first_page = page_pngs[0][0]
+ anchor = _anchor_from_png(page_pngs[0][1])
+ calls.append(anchor)
+ assert previous_entries in (None, [])
+ return _batch_result(
+ toc_pages=[first_page],
+ non_toc_pages=[p for p, _ in page_pngs[1:]],
+ entries=[
+ {
+ "title": f"Section {anchor}",
+ "page": first_page,
+ "level": 1,
+ }
+ ],
+ )
+
+ monkeypatch.setattr(toc_tool, "run_in_child_process", _tracking_render)
+ monkeypatch.setattr(
+ "app.services.document_agent.tools.vlm_toc_extractor.vlm_extract_toc_batch",
+ _fake_batch,
+ )
+ monkeypatch.setattr(
+ toc_tool,
+ "vlm_entries_to_toc_hierarchies",
+ lambda entries, **kwargs: [
+ {
+ "toc_range": [entries[0]["page"], entries[0]["page"]],
+ "toc_tree": {entries[0]["title"]: {}},
+ }
+ ],
+ )
+
+ regions = toc_tool._extract_regions_for_confirmed_anchors( # noqa: SLF001
+ confirmed,
+ pdf_path="/tmp/doc.pdf",
+ page_count=100,
+ output_dir=str(tmp_path / "toc_pages"),
+ dpi=72,
+ model="fake-vlm",
+ )
+
+ assert [r.anchor_page for r in regions] == [10, 40, 80]
+ assert all(r.error is None for r in regions)
+ assert sorted(calls) == [10, 40, 80]
+ # One spawn per window; start page reused from Phase1 anchor PNG.
+ assert sorted(render_calls) == [
+ [11, 12, 13, 14],
+ [41, 42, 43, 44],
+ [81, 82, 83, 84],
+ ]
+
+
+def test_extract_regions_keeps_success_when_one_anchor_fails(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ confirmed = _anchors(tmp_path, [10, 40])
+
+ def _fake_batch(
+ *,
+ page_pngs: list[tuple[int, str]],
+ model: str,
+ previous_entries: list[dict[str, Any]] | None = None,
+ ) -> BatchTocResult:
+ anchor = _anchor_from_png(page_pngs[0][1])
+ if anchor == 40:
+ raise RuntimeError("simulated region VLM failure")
+ first_page = page_pngs[0][0]
+ return _batch_result(
+ toc_pages=[first_page],
+ non_toc_pages=[p for p, _ in page_pngs[1:]],
+ entries=[{"title": "Intro", "page": first_page, "level": 1}],
+ )
+
+ monkeypatch.setattr(toc_tool, "run_in_child_process", _fake_batch_render)
+ monkeypatch.setattr(
+ "app.services.document_agent.tools.vlm_toc_extractor.vlm_extract_toc_batch",
+ _fake_batch,
+ )
+ monkeypatch.setattr(
+ toc_tool,
+ "vlm_entries_to_toc_hierarchies",
+ lambda entries, **kwargs: [
+ {
+ "toc_range": [entries[0]["page"], entries[0]["page"]],
+ "toc_tree": {entries[0]["title"]: {}},
+ }
+ ],
+ )
+
+ regions = toc_tool._extract_regions_for_confirmed_anchors( # noqa: SLF001
+ confirmed,
+ pdf_path="/tmp/doc.pdf",
+ page_count=100,
+ output_dir=str(tmp_path / "toc_pages"),
+ dpi=72,
+ model="fake-vlm",
+ )
+
+ assert regions[0].anchor_page == 10
+ assert regions[0].error is None
+ assert regions[0].entries
+ assert regions[1].anchor_page == 40
+ assert regions[1].error is not None
+ assert "simulated region VLM failure" in regions[1].error
+
+
+def test_phase2_serial_batch_render_with_concurrent_vlm(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Batch renders never overlap; VLM calls may overlap across anchors."""
+ confirmed = _anchors(tmp_path, [10, 40, 80])
+ render_active = 0
+ render_max = 0
+ vlm_active = 0
+ vlm_max = 0
+ spawn_count = 0
+
+ def _fake_render(worker_fn: Any, *args: Any, **kwargs: Any) -> dict[str, Any]:
+ nonlocal render_active, render_max, spawn_count
+ spawn_count += 1
+ render_active += 1
+ render_max = max(render_max, render_active)
+ gevent.sleep(0.02)
+ result = _fake_batch_render(worker_fn, *args, **kwargs)
+ render_active -= 1
+ return result
+
+ def _fake_batch(
+ *,
+ page_pngs: list[tuple[int, str]],
+ model: str,
+ previous_entries: list[dict[str, Any]] | None = None,
+ ) -> BatchTocResult:
+ nonlocal vlm_active, vlm_max
+ vlm_active += 1
+ vlm_max = max(vlm_max, vlm_active)
+ gevent.sleep(0.2)
+ first_page = page_pngs[0][0]
+ anchor = _anchor_from_png(page_pngs[0][1])
+ vlm_active -= 1
+ return _batch_result(
+ toc_pages=[first_page],
+ non_toc_pages=[p for p, _ in page_pngs[1:]],
+ entries=[{"title": f"S{anchor}", "page": first_page, "level": 1}],
+ )
+
+ monkeypatch.setattr(toc_tool, "run_in_child_process", _fake_render)
+ monkeypatch.setattr(
+ "app.services.document_agent.tools.vlm_toc_extractor.vlm_extract_toc_batch",
+ _fake_batch,
+ )
+ monkeypatch.setattr(
+ toc_tool,
+ "vlm_entries_to_toc_hierarchies",
+ lambda entries, **kwargs: [
+ {
+ "toc_range": [entries[0]["page"], entries[0]["page"]],
+ "toc_tree": {entries[0]["title"]: {}},
+ }
+ ],
+ )
+
+ regions = toc_tool._extract_regions_for_confirmed_anchors( # noqa: SLF001
+ confirmed,
+ pdf_path="/tmp/doc.pdf",
+ page_count=100,
+ output_dir=str(tmp_path / "toc_pages"),
+ dpi=72,
+ model="fake-vlm",
+ )
+
+ assert all(r.error is None for r in regions)
+ assert render_max == 1
+ assert spawn_count == 3 # one batch spawn per anchor window
+ assert vlm_max >= 2
diff --git a/packages/shared-python/shared/core/config/__init__.py b/packages/shared-python/shared/core/config/__init__.py
index 6889df8a5..5f07b4179 100644
--- a/packages/shared-python/shared/core/config/__init__.py
+++ b/packages/shared-python/shared/core/config/__init__.py
@@ -19,6 +19,7 @@
from .mineru import MineruConfig
from .qstash import QStashConfig
from .redis import RedisConfig, RedisConfigManager, RedisPoolManager
+from .retrieval import RetrievalConfig
from .storage import StorageConfig
__all__ = [
@@ -33,6 +34,7 @@
"JobConfig",
"AIConfig",
"MineruConfig",
+ "RetrievalConfig",
"AppConfig",
"app_config",
"settings",
diff --git a/packages/shared-python/shared/core/config/ai.py b/packages/shared-python/shared/core/config/ai.py
index 487a35808..9cbbdd367 100644
--- a/packages/shared-python/shared/core/config/ai.py
+++ b/packages/shared-python/shared/core/config/ai.py
@@ -44,34 +44,6 @@ class AIConfig(BaseModel):
"Same alternates as IMAGE_MODEL (e.g. qwen3-vl-32b-instruct)."
),
)
- RETRIEVAL_PLANNER_MODEL: str = Field(
- default="",
- description="Reasoning-capable model used by the workflow query planner.",
- )
- RETRIEVAL_PLANNER_THINKING_BUDGET: int = Field(
- default=4000,
- description="Token budget for the query planner thinking call.",
- )
- RETRIEVAL_DECOMPOSITION_MAX_STEPS: int = Field(
- default=5,
- description="Maximum number of planned workflow steps.",
- )
- RETRIEVAL_WALLET_TOTAL_BUDGET: int = Field(
- default=200000,
- description="Total workflow token wallet for decomposed retrieval.",
- )
- RETRIEVAL_WALLET_PER_RETRIEVE_STEP_BUDGET: int = Field(
- default=40000,
- description="Default token budget issued to each retrieve step.",
- )
- RETRIEVAL_WORKFLOW_PARALLEL_MAX: int = Field(
- default=3,
- description="Maximum concurrent workflow steps in the same DAG batch.",
- )
- RETRIEVAL_WORKFLOW_PLANNER_TIMEOUT_SECONDS: float = Field(
- default=10.0,
- description="Timeout for the optional retrieval workflow planner LLM call.",
- )
# Runtime LLM controls.
LLM_MOCK_ENABLED: bool = Field(
diff --git a/packages/shared-python/shared/core/config/app.py b/packages/shared-python/shared/core/config/app.py
index 5ce5f9396..9b03f7889 100644
--- a/packages/shared-python/shared/core/config/app.py
+++ b/packages/shared-python/shared/core/config/app.py
@@ -13,6 +13,7 @@
from .mineru import MineruConfig
from .qstash import QStashConfig
from .redis import RedisConfig, RedisConfigManager, RedisPoolManager
+from .retrieval import RetrievalConfig
from .storage import StorageConfig
@@ -27,6 +28,7 @@ class AppConfig(
MineruConfig,
BillingConfig,
JobConfig,
+ RetrievalConfig,
):
"""Application configuration — all config components merged."""
diff --git a/packages/shared-python/shared/core/config/retrieval.py b/packages/shared-python/shared/core/config/retrieval.py
new file mode 100644
index 000000000..5484c2f67
--- /dev/null
+++ b/packages/shared-python/shared/core/config/retrieval.py
@@ -0,0 +1,17 @@
+"""Retrieval configuration settings"""
+
+from pydantic import Field
+from pydantic_settings import BaseSettings
+
+
+class RetrievalConfig(BaseSettings):
+ """Retrieval configuration settings"""
+
+ RETRIEVAL_POSTGRES_FTS_CANDIDATE_LIMIT: int = Field(
+ default=2000,
+ ge=1,
+ description=(
+ "Maximum rows the Postgres FTS prefilter returns per BM25 channel "
+ "before Python BM25 reranking. Larger values trade memory for recall."
+ ),
+ )
diff --git a/packages/shared-python/shared/services/ai/llm_mock.py b/packages/shared-python/shared/services/ai/llm_mock.py
index af8aa3214..75de9c111 100644
--- a/packages/shared-python/shared/services/ai/llm_mock.py
+++ b/packages/shared-python/shared/services/ai/llm_mock.py
@@ -1,7 +1,5 @@
"""Helpers for deterministic mock responses from OpenAI-compatible LLM calls."""
-import json
-import re
from typing import Any, Dict, List
from loguru import logger
@@ -20,13 +18,6 @@ def build_mock_chat_completion_response(
model_name,
task_name,
)
- # For agentic tasks that need dynamic content extraction, build the response here.
- if task_name == "agentic-planner":
- return _build_planner_mock_response(prompt_text)
- if task_name == "agentic-navigate":
- return _build_navigate_mock_response(prompt_text)
- if task_name == "agentic-discovery-select":
- return _build_discovery_select_mock_response(prompt_text)
return _build_mock_response(task_name)
@@ -77,23 +68,6 @@ def _detect_mock_task(prompt_text: str) -> str:
"""Infer the prompt task so the mock can return a compatible response shape."""
normalized_prompt = prompt_text.lower()
- # ── Agentic retrieval prompts (check first — they are structurally distinct) ──
- if (
- "you are a retrieval workflow planner" in normalized_prompt
- and "concat_final_parts" in normalized_prompt
- ):
- return "agentic-planner"
- if (
- "you are a document navigation agent" in normalized_prompt
- and "=== section tree ==" in normalized_prompt
- ):
- return "agentic-navigate"
- if (
- "=== discovery candidates ==" in normalized_prompt
- and "\"selections\"" in normalized_prompt
- ):
- return "agentic-discovery-select"
-
# ── Document parsing / ingestion prompts ──
if (
"generate a concise title" in normalized_prompt
@@ -149,135 +123,9 @@ def _detect_mock_task(prompt_text: str) -> str:
return "default"
-def _extract_first_section_path(prompt_text: str) -> str | None:
- """Pull the first path value from a COLLECTOR_PROMPT section tree block.
-
- The section tree is rendered by section_prompt_projection.format_items_for_llm()
- and each item line looks like::
-
- ▸ [L1] path="Root" [text=1] ~100 tokens [Leaf]
- └ [L2] path="Root / Sub" [text=2] ~200 tokens
-
- We extract the value inside path="..." from the first matching line
- within the === Section Tree === block.
- """
- tree_match = re.search(
- r"=== Section Tree ===(.*?)=== End Section Tree ===",
- prompt_text,
- re.DOTALL | re.IGNORECASE,
- )
- if not tree_match:
- return None
- tree_block = tree_match.group(1)
- # Match path="..." in the section tree — this is the canonical format
- path_match = re.search(r'path="([^"]+)"', tree_block)
- if path_match:
- return path_match.group(1)
- return None
-
-
-
-def _extract_user_query(prompt_text: str) -> str:
- """Extract the user query line from a planner/navigation prompt.
-
- The PLANNER_PROMPT and COLLECTOR_PROMPT both contain::
- User query: {query}
- """
- match = re.search(r"User query:\s*(.+)", prompt_text)
- if match:
- return match.group(1).strip()
- return "mock query"
-
-
-def _build_planner_mock_response(prompt_text: str) -> str:
- """Return a valid single-step QueryPlan JSON using the real query from the prompt."""
- query = _extract_user_query(prompt_text)
- response = {
- "reasoning_summary": "mock single-step plan",
- "steps": [
- {
- "id": "s1",
- "sub_query": query,
- "step_kind": "retrieve",
- "depends_on": [],
- "output_role": "final_part",
- "top_k": 10,
- }
- ],
- "final_strategy": "concat_final_parts",
- }
- return json.dumps(response)
-
-
-def _build_navigate_mock_response(prompt_text: str) -> str:
- """Return a mock COLLECTOR_PROMPT response that COLLECTs the first visible path."""
- path = _extract_first_section_path(prompt_text)
- if path:
- response = {
- "collect": [{"path": path, "confidence": 0.9, "outline": False}],
- "action": "STOP",
- "drill_into": None,
- "tools": [],
- "reason": "Mock: collected first available section",
- }
- else:
- # No path found — STOP without collecting (safe fallback)
- response = {
- "collect": [],
- "action": "STOP",
- "drill_into": None,
- "tools": [],
- "reason": "Mock: no section path found in tree",
- }
- return json.dumps(response)
-
-
-def _extract_first_discovery_path(prompt_text: str) -> str | None:
- """Pull the first path value from a DISCOVERY_SELECT_PROMPT candidates block.
-
- Discovery hints are rendered by selection._project_discovery_hints() as::
-
- ▸ path="Findings"
-
-
- We extract the value inside path="..." from the candidates block.
- """
- candidates_match = re.search(
- r"=== Discovery Candidates ===(.*?)=== End Discovery Candidates ===",
- prompt_text,
- re.DOTALL | re.IGNORECASE,
- )
- if not candidates_match:
- return None
- block = candidates_match.group(1)
- # Match path="..." — same canonical format as section tree
- path_match = re.search(r'path="([^"]+)"', block)
- if path_match:
- return path_match.group(1)
- return None
-
-
-def _build_discovery_select_mock_response(prompt_text: str) -> str:
- """Return a mock DISCOVERY_SELECT_PROMPT response selecting the first candidate."""
- path = _extract_first_discovery_path(prompt_text)
- if path:
- response = {"selections": [{"path": path, "confidence": 0.85}]}
- else:
- response = {"selections": []}
- return json.dumps(response)
-
-
def _build_mock_response(task_name: str) -> str:
"""Return a canned response compatible with the inferred task contract."""
response_by_task: Dict[str, str] = {
- # Agentic retrieval — static fallbacks (dynamic responses built elsewhere)
- "agentic-planner": (
- '{"reasoning_summary": "mock single-step plan", '
- '"steps": [{"id": "s1", "sub_query": "mock query", '
- '"step_kind": "retrieve", "depends_on": [], '
- '"output_role": "final_part", "top_k": 10}], '
- '"final_strategy": "concat_final_parts"}'
- ),
# Document parsing / ingestion tasks
"fragment-title": "Mock Fragment Title",
"detect-toc-range": '{"toc_start": null, "toc_end": null, "confidence": "low"}',
diff --git a/packages/shared-python/shared/services/retrieval/agentic/__init__.py b/packages/shared-python/shared/services/retrieval/agentic/__init__.py
deleted file mode 100644
index 961ba1dad..000000000
--- a/packages/shared-python/shared/services/retrieval/agentic/__init__.py
+++ /dev/null
@@ -1,11 +0,0 @@
-"""Agentic evidence retrieval orchestration for Knowhere.
-
-Flow:
- Phase 1: Document selection (discovery + KG LLM select)
- Phase 2: Per-document iterative navigation (navigate_step)
- Phase 3: Render evidence text for downstream agents
-
-Each navigate_step chooses one observe-act action plus optional collection
-side effects. KNOWHERE does not generate final answers; downstream agents
-decide whether the evidence is sufficient.
-"""
diff --git a/packages/shared-python/shared/services/retrieval/agentic/core/__init__.py b/packages/shared-python/shared/services/retrieval/agentic/core/__init__.py
deleted file mode 100644
index 8b1378917..000000000
--- a/packages/shared-python/shared/services/retrieval/agentic/core/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/packages/shared-python/shared/services/retrieval/agentic/core/budget.py b/packages/shared-python/shared/services/retrieval/agentic/core/budget.py
deleted file mode 100644
index ff7c7f2f7..000000000
--- a/packages/shared-python/shared/services/retrieval/agentic/core/budget.py
+++ /dev/null
@@ -1,361 +0,0 @@
-"""Token budget ledger for agentic retrieval runs."""
-from __future__ import annotations
-
-import asyncio
-import copy
-from dataclasses import dataclass
-from typing import Any, Literal
-
-
-BudgetPoolName = Literal["bootstrap", "planning", "context"]
-BudgetStatus = Literal["HEALTHY", "TIGHT", "CRITICAL", "EXHAUSTED"]
-
-
-def status_from_usage(*, remaining: int, used_pct: int) -> BudgetStatus:
- if remaining <= 0:
- return "EXHAUSTED"
- if used_pct >= 80:
- return "CRITICAL"
- if used_pct >= 50:
- return "TIGHT"
- return "HEALTHY"
-
-
-def project_budget_snapshot(
- snapshot: dict | None,
- *,
- pool: BudgetPoolName,
- additional_tokens: int,
-) -> dict | None:
- """Return a snapshot projected after an estimated upcoming token cost."""
- if not snapshot:
- return snapshot
- adjusted = copy.deepcopy(snapshot)
- pool_data = adjusted.get(pool)
- if not isinstance(pool_data, dict):
- return adjusted
-
- capacity = int(pool_data.get("capacity") or 0)
- used = int(pool_data.get("used") or 0)
- reserved = int(pool_data.get("reserved") or 0)
- projected_used_total = max(0, used + reserved + max(int(additional_tokens), 0))
- used_pct = (
- int(round(projected_used_total * 100 / capacity))
- if capacity > 0 else 100
- )
- remaining = max(capacity - projected_used_total, 0)
- pool_data["used_pct"] = used_pct
- pool_data["remaining"] = remaining
- pool_data["status"] = status_from_usage(
- remaining=remaining,
- used_pct=used_pct,
- )
- return adjusted
-
-
-def budget_status_from_snapshot(
- snapshot: dict[str, Any] | None,
- *,
- pool: BudgetPoolName = "planning",
-) -> str:
- """Read a pool status from a serialized budget snapshot."""
- if not isinstance(snapshot, dict):
- return "UNKNOWN"
- pool_data = snapshot.get(pool)
- if not isinstance(pool_data, dict):
- return "UNKNOWN"
- return str(pool_data.get("status") or "UNKNOWN")
-
-
-class BudgetExceeded(Exception):
- """Raised when a planned LLM call cannot reserve budget."""
-
- def __init__(self, message: str, *, details: dict[str, Any] | None = None) -> None:
- super().__init__(message)
- self.details = details or {}
-
-
-@dataclass
-class BudgetPool:
- name: BudgetPoolName
- capacity: int
- used: int = 0
- reserved: int = 0
-
- @property
- def remaining(self) -> int:
- return max(self.capacity - self.used - self.reserved, 0)
-
- @property
- def used_pct(self) -> int:
- if self.capacity <= 0:
- return 100
- return int(round((self.used + self.reserved) * 100 / self.capacity))
-
-
-class BudgetLedger:
- """Concurrency-safe ledger with bootstrap/planning/context pools."""
-
- def __init__(
- self,
- *,
- total: int,
- planning_ratio: float,
- bootstrap: int = 2000,
- per_doc_min_share: int = 1500,
- ) -> None:
- total = max(int(total), 1)
- bootstrap = max(0, min(int(bootstrap), total))
- remaining = max(total - bootstrap, 0)
- planning_ratio = min(max(float(planning_ratio), 0.0), 1.0)
- planning_capacity = int(remaining * planning_ratio)
- context_capacity = remaining - planning_capacity
-
- self._lock = asyncio.Lock()
- self._pools: dict[BudgetPoolName, BudgetPool] = {
- "bootstrap": BudgetPool("bootstrap", bootstrap),
- "planning": BudgetPool("planning", planning_capacity),
- "context": BudgetPool("context", context_capacity),
- }
- self._doc_caps: dict[str, int] = {}
- self._doc_used: dict[str, int] = {}
- self._doc_reserved: dict[str, int] = {}
- self._per_doc_min_share = max(int(per_doc_min_share), 0)
- self.total_chunks = 0
- self.total_docs = 0
- self.explored_chunks = 0
- self.explored_docs = 0
- self.trimmed_paths: list[dict[str, Any]] = []
- self._overdraft_events: list[dict[str, Any]] = []
-
- def remaining(self, pool: BudgetPoolName) -> int:
- return self._pools[pool].remaining
-
- def status(self, pool: BudgetPoolName) -> BudgetStatus:
- pool_state = self._pools[pool]
- return status_from_usage(
- remaining=pool_state.remaining,
- used_pct=pool_state.used_pct,
- )
-
- async def allocate_doc_caps(self, doc_chunks: dict[str, int]) -> None:
- """Allocate planning soft caps by document chunk counts."""
- async with self._lock:
- self._doc_caps.clear()
- self._doc_used.clear()
- self._doc_reserved.clear()
- if not doc_chunks:
- return
-
- planning_capacity = self._pools["planning"].capacity
- total_weight = sum(max(int(count), 1) for count in doc_chunks.values())
- for doc_id, count in doc_chunks.items():
- weight = max(int(count), 1)
- weighted = int(planning_capacity * weight / total_weight)
- self._doc_caps[doc_id] = min(
- planning_capacity,
- max(self._per_doc_min_share, weighted),
- )
-
- async def try_reserve(
- self,
- pool: BudgetPoolName,
- est: int,
- doc_id: str | None = None,
- *,
- priority: Literal["normal", "low"] = "normal",
- ) -> bool:
- reservation = await self.reserve(
- pool,
- est,
- doc_id=doc_id,
- priority=priority,
- allow_overdraft=False,
- )
- return bool(reservation.get("reserved"))
-
- async def reserve(
- self,
- pool: BudgetPoolName,
- est: int,
- doc_id: str | None = None,
- *,
- priority: Literal["normal", "low"] = "normal",
- allow_overdraft: bool = False,
- overdraft_reason: str = "",
- ) -> dict[str, Any]:
- est = max(int(est), 0)
- if est == 0:
- return {"reserved": True, "overdraft": False, "failure": None}
-
- async with self._lock:
- pool_state = self._pools[pool]
- if priority == "low" and self.status(pool) == "CRITICAL":
- failure = self._reserve_failure(pool, est, doc_id, "low_priority_critical")
- return {"reserved": False, "overdraft": False, "failure": failure}
-
- failure_reason = ""
- if pool_state.remaining < est:
- failure_reason = "pool_remaining_lt_est"
-
- # Per-doc cap enforcement: prevent one document from consuming
- # the entire planning pool.
- doc_remaining: int | None = None
- if pool == "planning" and doc_id and doc_id in self._doc_caps:
- doc_remaining = self._doc_caps[doc_id] - (
- self._doc_used.get(doc_id, 0)
- + self._doc_reserved.get(doc_id, 0)
- )
- if doc_remaining < est:
- failure_reason = "doc_remaining_lt_est"
-
- if failure_reason:
- failure = self._reserve_failure(pool, est, doc_id, failure_reason)
- if not allow_overdraft or pool != "planning":
- return {"reserved": False, "overdraft": False, "failure": failure}
- self._record_overdraft(
- pool=pool,
- est=est,
- doc_id=doc_id,
- reason=overdraft_reason or failure_reason,
- failure=failure,
- )
-
- pool_state.reserved += est
- if pool == "planning" and doc_id:
- self._doc_reserved[doc_id] = self._doc_reserved.get(doc_id, 0) + est
- return {
- "reserved": True,
- "overdraft": bool(failure_reason),
- "failure": self._reserve_failure(pool, est, doc_id, failure_reason)
- if failure_reason else None,
- }
-
- async def commit(
- self,
- pool: BudgetPoolName,
- *,
- actual: int,
- est: int,
- doc_id: str | None = None,
- ) -> None:
- actual = max(int(actual), 0)
- est = max(int(est), 0)
- async with self._lock:
- pool_state = self._pools[pool]
- reserved_delta = min(est, pool_state.reserved)
- pool_state.reserved -= reserved_delta
- pool_state.used = max(0, pool_state.used + actual)
-
- if pool == "planning" and doc_id:
- doc_reserved = min(est, self._doc_reserved.get(doc_id, 0))
- if doc_reserved:
- self._doc_reserved[doc_id] -= doc_reserved
- if self._doc_reserved[doc_id] <= 0:
- self._doc_reserved.pop(doc_id, None)
- self._doc_used[doc_id] = self._doc_used.get(doc_id, 0) + actual
-
- async def refund(
- self,
- pool: BudgetPoolName,
- *,
- est: int,
- doc_id: str | None = None,
- ) -> None:
- est = max(int(est), 0)
- async with self._lock:
- pool_state = self._pools[pool]
- pool_state.reserved = max(pool_state.reserved - est, 0)
- if pool == "planning" and doc_id:
- current = self._doc_reserved.get(doc_id, 0)
- remaining = max(current - est, 0)
- if remaining:
- self._doc_reserved[doc_id] = remaining
- else:
- self._doc_reserved.pop(doc_id, None)
-
- def mark_explored(
- self,
- *,
- chunks: int = 0,
- docs: int = 0,
- ) -> None:
- self.explored_chunks += max(int(chunks), 0)
- self.explored_docs += max(int(docs), 0)
-
- def snapshot(self) -> dict[str, object]:
- snapshot: dict[str, object] = {
- name: {
- "capacity": pool.capacity,
- "used": pool.used,
- "reserved": pool.reserved,
- "remaining": pool.remaining,
- "used_pct": pool.used_pct,
- "overdraft": max(pool.used + pool.reserved - pool.capacity, 0),
- "status": self.status(name),
- }
- for name, pool in self._pools.items()
- }
- if self._overdraft_events:
- snapshot["overdraft_events"] = list(self._overdraft_events)
- snapshot.update({
- "total_chunks": self.total_chunks,
- "total_docs": self.total_docs,
- "explored_chunks": min(self.explored_chunks, self.total_chunks)
- if self.total_chunks else self.explored_chunks,
- "explored_docs": min(self.explored_docs, self.total_docs)
- if self.total_docs else self.explored_docs,
- "trimmed_paths": list(self.trimmed_paths),
- })
- return snapshot
-
- def _reserve_failure(
- self,
- pool: BudgetPoolName,
- est: int,
- doc_id: str | None,
- reason: str,
- ) -> dict[str, Any]:
- pool_state = self._pools[pool]
- details: dict[str, Any] = {
- "reason": reason,
- "pool": pool,
- "prompt_est": max(int(est), 0),
- "pool_capacity": pool_state.capacity,
- "pool_used": pool_state.used,
- "pool_reserved": pool_state.reserved,
- "pool_remaining": pool_state.remaining,
- }
- if pool == "planning" and doc_id and doc_id in self._doc_caps:
- doc_used = self._doc_used.get(doc_id, 0)
- doc_reserved = self._doc_reserved.get(doc_id, 0)
- details.update({
- "doc_id": doc_id,
- "doc_cap": self._doc_caps[doc_id],
- "doc_used": doc_used,
- "doc_reserved": doc_reserved,
- "doc_remaining": max(self._doc_caps[doc_id] - doc_used - doc_reserved, 0),
- })
- return details
-
- def _record_overdraft(
- self,
- *,
- pool: BudgetPoolName,
- est: int,
- doc_id: str | None,
- reason: str,
- failure: dict[str, Any],
- ) -> None:
- pool_shortfall = max(int(est) - int(failure.get("pool_remaining") or 0), 0)
- doc_shortfall = 0
- if "doc_remaining" in failure:
- doc_shortfall = max(int(est) - int(failure.get("doc_remaining") or 0), 0)
- self._overdraft_events.append({
- "pool": pool,
- "doc_id": doc_id,
- "prompt_est": max(int(est), 0),
- "shortfall": max(pool_shortfall, doc_shortfall),
- "reason": reason,
- "failure": failure,
- })
diff --git a/packages/shared-python/shared/services/retrieval/agentic/core/runtime.py b/packages/shared-python/shared/services/retrieval/agentic/core/runtime.py
deleted file mode 100644
index 91d7a9a80..000000000
--- a/packages/shared-python/shared/services/retrieval/agentic/core/runtime.py
+++ /dev/null
@@ -1,171 +0,0 @@
-"""Runtime setup helpers for agentic retrieval."""
-from __future__ import annotations
-
-
-import json
-import os
-from typing import Any
-
-from sqlalchemy import func, select
-from sqlalchemy.ext.asyncio import AsyncSession
-
-from shared.models.database.document import Document, DocumentChunk
-from shared.services.retrieval.agentic.core.budget import BudgetExceeded, BudgetPoolName
-from shared.services.retrieval.agentic.core.types import AgentRunConfig, AgentState
-from shared.services.retrieval.llm_adapter import LLMFn, current_llm_usage
-from shared.utils.token_estimate import estimate_tokens
-
-
-def build_config_from_env() -> AgentRunConfig:
- return AgentRunConfig(
- max_nav_steps=int(os.environ.get("RETRIEVAL_AGENTIC_MAX_NAV_STEPS", "6")),
- latency_budget_ms=int(os.environ.get("RETRIEVAL_AGENTIC_LATENCY_BUDGET_MS", "30000")),
- token_budget_total=int(os.environ.get("RETRIEVAL_AGENTIC_TOKEN_BUDGET_TOTAL", "40000")),
- planning_ratio=float(os.environ.get("RETRIEVAL_AGENTIC_PLANNING_RATIO", "0.5")),
- bootstrap_budget=int(os.environ.get("RETRIEVAL_AGENTIC_BOOTSTRAP_BUDGET", "2000")),
- per_doc_min_share=int(os.environ.get("RETRIEVAL_AGENTIC_PER_DOC_MIN_SHARE", "1500")),
- inventory_aware=os.environ.get("RETRIEVAL_AGENTIC_INVENTORY_AWARE", "true") == "true",
- )
-
-
-async def load_budget_inventory(
- db: AsyncSession,
- *,
- user_id: str,
- namespace: str,
- exclude_document_ids: list[str],
-) -> tuple[int, int, dict[str, int]]:
- stmt = (
- select(Document.document_id, func.count(DocumentChunk.id))
- .join(
- DocumentChunk,
- (DocumentChunk.document_id == Document.document_id)
- & (DocumentChunk.job_result_id == Document.current_job_result_id),
- )
- .where(Document.user_id == user_id)
- .where(Document.namespace == namespace)
- .where(Document.status == "active")
- .group_by(Document.document_id)
- )
- if exclude_document_ids:
- stmt = stmt.where(Document.document_id.notin_(list(exclude_document_ids)))
-
- result = await db.execute(stmt)
- doc_chunks = {str(doc_id): int(count or 0) for doc_id, count in result.all()}
- return sum(doc_chunks.values()), len(doc_chunks), doc_chunks
-
-
-class AgentLlmBudget:
- def __init__(self, state: AgentState) -> None:
- self._state = state
-
- async def call(
- self,
- llm_fn: LLMFn,
- prompt: Any,
- *,
- pool: BudgetPoolName,
- doc_id: str | None = None,
- priority: str = "normal",
- allow_overdraft: bool = False,
- overdraft_reason: str = "",
- ) -> str:
- ledger = self._state.ledger
- if ledger is None:
- return await llm_fn(prompt)
-
- prompt_text = _stringify_llm_input(prompt)
- est = estimate_tokens(prompt_text)
- reservation = await ledger.reserve(
- pool,
- est,
- doc_id=doc_id,
- priority="low" if priority == "low" else "normal",
- allow_overdraft=allow_overdraft,
- overdraft_reason=overdraft_reason,
- )
- if not reservation.get("reserved"):
- raise BudgetExceeded(
- f"{pool} budget exhausted",
- details=reservation.get("failure") or {},
- )
-
- try:
- response = await llm_fn(prompt)
- except Exception:
- await ledger.refund(pool, est=est, doc_id=doc_id)
- raise
-
- usage = current_llm_usage.get() or {}
- actual = _extract_actual_tokens(usage, est)
- await ledger.commit(pool, actual=actual, est=est, doc_id=doc_id)
- return response
-
- def for_pool(self, llm_fn: LLMFn, *, pool: BudgetPoolName) -> LLMFn:
- async def _call(prompt: Any) -> str:
- return await self.call(llm_fn, prompt, pool=pool)
-
- return _call
-
- def for_document(
- self,
- llm_fn: LLMFn,
- *,
- doc_id: str,
- step: int = 0,
- allow_overdraft: bool = False,
- overdraft_reason: str = "",
- ) -> LLMFn:
- async def _call(prompt: Any) -> str:
- return await self.call(
- llm_fn,
- prompt,
- pool="planning",
- doc_id=doc_id,
- priority="normal",
- allow_overdraft=allow_overdraft,
- overdraft_reason=overdraft_reason,
- )
-
- return _call
-
- def for_discovery(
- self,
- llm_fn: LLMFn,
- *,
- doc_id: str,
- low_priority: bool,
- ) -> LLMFn:
- async def _call(prompt: Any) -> str:
- return await self.call(
- llm_fn,
- prompt,
- pool="planning",
- doc_id=doc_id,
- priority="low" if low_priority else "normal",
- )
-
- return _call
-
-
-def _extract_actual_tokens(usage: dict, est: int) -> int:
- """Derive actual token consumption from LLM usage dict.
-
- Checks ``total_tokens`` first, then sums ``prompt_tokens`` and
- ``completion_tokens``. Falls back to the pre-call estimate.
- """
- total = usage.get("total_tokens")
- if total:
- return int(total)
- prompt = int(usage.get("prompt_tokens") or 0)
- completion = int(usage.get("completion_tokens") or 0)
- return (prompt + completion) or est
-
-
-def _stringify_llm_input(prompt: Any) -> str:
- if isinstance(prompt, str):
- return prompt
- try:
- return json.dumps(prompt, ensure_ascii=False, default=str)
- except Exception:
- return str(prompt)
diff --git a/packages/shared-python/shared/services/retrieval/agentic/core/trace.py b/packages/shared-python/shared/services/retrieval/agentic/core/trace.py
deleted file mode 100644
index 4d7dd2aa9..000000000
--- a/packages/shared-python/shared/services/retrieval/agentic/core/trace.py
+++ /dev/null
@@ -1,249 +0,0 @@
-"""Lightweight trace recorder for agentic retrieval runs.
-
-Records each run and its steps into retrieval_runs / retrieval_steps tables.
-All writes are best-effort — failures are logged but never propagate to the
-caller. This ensures trace recording cannot break the retrieval pipeline.
-"""
-from __future__ import annotations
-
-import hashlib
-import time
-from datetime import datetime, timezone
-from typing import Any
-from uuid import uuid4
-
-from loguru import logger
-from sqlalchemy.ext.asyncio import AsyncSession
-
-from shared.services.retrieval.agentic.core.types import (
- AgentRunConfig,
- DecisionTraceStep,
- ToolResult,
-)
-from shared.services.retrieval.settings import DEFAULT_TOP_K
-
-
-def _now_utc() -> datetime:
- return datetime.now(timezone.utc).replace(tzinfo=None)
-
-
-def _query_hash(query: str) -> str:
- return hashlib.sha256(query.encode('utf-8')).hexdigest()[:16]
-
-
-class TraceRecorder:
- """Records a single agentic retrieval run and its steps.
-
- Usage::
-
- trace = TraceRecorder(db, user_id=..., namespace=..., query=..., config=...)
- await trace.create_run()
- ...
- trace.record_step(action_type, tool_result)
- ...
- await trace.complete(ranked_rows, router_used)
- """
-
- def __init__(
- self,
- db: AsyncSession,
- *,
- user_id: str,
- namespace: str,
- query: str,
- config: AgentRunConfig,
- top_k: int = DEFAULT_TOP_K,
- chunk_types: set[str] | None = None,
- filters: dict[str, Any] | None = None,
- parent_run_id: str | None = None,
- workflow_step_id: str | None = None,
- workflow_plan: dict[str, Any] | None = None,
- ) -> None:
- self._db = db
- self._run_id = f'aret_{uuid4().hex[:12]}'
- self._user_id = user_id
- self._namespace = namespace
- self._query = query
- self._config = config
- self._top_k = top_k
- self._chunk_types = chunk_types
- self._filters = filters or {}
- self._parent_run_id = parent_run_id
- self._workflow_step_id = workflow_step_id
- self._workflow_plan = workflow_plan
- self._steps: list[dict[str, Any]] = []
- self._start_time = time.monotonic()
- self._created = False
-
- @property
- def run_id(self) -> str:
- return self._run_id
-
- async def create_run(self) -> None:
- """Insert the retrieval_runs row. Best-effort."""
- try:
- from shared.models.database.document import RetrievalRun
-
- run = RetrievalRun(
- run_id=self._run_id,
- user_id=self._user_id,
- namespace=self._namespace,
- query=self._query,
- query_hash=_query_hash(self._query),
- top_k=self._top_k,
- chunk_types=sorted(self._chunk_types) if self._chunk_types else None,
- filters=self._filters,
- policy_name='llm_policy_v1',
- agentic_enabled=True,
- cache_hit=False,
- result_count=0,
- parent_run_id=self._parent_run_id,
- workflow_step_id=self._workflow_step_id,
- workflow_plan=self._workflow_plan,
- latency_ms=0,
- created_at=_now_utc(),
- )
- self._db.add(run)
- await self._db.flush()
- self._created = True
- except Exception as e:
- logger.debug(f'agentic trace: failed to create run {self._run_id}: {e}')
- try:
- await self._db.rollback()
- except Exception as rollback_error:
- logger.debug(
- f'agentic trace: failed to roll back create failure '
- f'{self._run_id}: {rollback_error}'
- )
-
- def record_step(
- self,
- action_type: str,
- result: ToolResult,
- *,
- decision_reason: str = '',
- ) -> None:
- """Buffer a step record. Flushed on complete()."""
- self._steps.append({
- 'step_index': len(self._steps),
- 'action_type': action_type,
- 'action_input': {'decision_reason': decision_reason} if decision_reason else {},
- 'observation_status': result.status,
- 'observation_payload_keys': list(result.payload.keys()) if result.payload else [],
- 'latency_ms': result.latency_ms,
- 'error': result.error,
- 'tokens_used': result.tokens_used,
- 'created_at': _now_utc(),
- })
-
- def record_decision_trace_step(self, step: DecisionTraceStep) -> None:
- """Buffer a DB trace row derived from the public decision trace step."""
- result_status = str(step.result.get("status") or "unknown")
- self._steps.append({
- "step_index": len(self._steps),
- "action_type": f"{step.phase}:{step.agent}:{step.decision.get('action', '')}",
- "action_input": {
- "public_step_index": step.step_index,
- "decision": step.decision,
- "scope": step.scope,
- "document_id": step.document_id,
- "parent_step_index": step.parent_step_index,
- },
- "observation_status": result_status,
- "observation_payload_keys": list(step.observation.keys()),
- "latency_ms": step.elapsed_ms or 0,
- "error": step.result.get("error"),
- "tokens_used": 0,
- "created_at": _now_utc(),
- })
-
- def record_budget_stop(self, reason: str) -> None:
- """Record that the agent loop stopped due to a budget guard."""
- self._steps.append({
- 'step_index': len(self._steps),
- 'action_type': f'budget_stop_{reason}',
- 'action_input': {},
- 'observation_status': 'budget_stop',
- 'observation_payload_keys': [],
- 'latency_ms': 0,
- 'error': None,
- 'tokens_used': 0,
- 'created_at': _now_utc(),
- })
-
- async def complete(
- self,
- ranked_rows: list[dict[str, Any]],
- router_used: str,
- budget_snapshot: dict[str, Any] | None = None,
- ) -> None:
- """Flush all step records and update the run row. Best-effort."""
- if not self._created:
- return
-
- total_latency = int((time.monotonic() - self._start_time) * 1000)
-
- try:
- from shared.models.database.document import RetrievalStep
-
- for step_data in self._steps:
- step = RetrievalStep(
- step_id=f'arst_{uuid4().hex[:12]}',
- run_id=self._run_id,
- step_index=step_data['step_index'],
- action_type=step_data['action_type'],
- action_input=step_data.get('action_input'),
- observation={
- 'status': step_data['observation_status'],
- 'payload_keys': step_data['observation_payload_keys'],
- 'tokens_used': step_data.get('tokens_used', 0),
- },
- latency_ms=step_data['latency_ms'],
- token_count=step_data.get('tokens_used', 0),
- error=step_data.get('error'),
- created_at=step_data['created_at'],
- )
- self._db.add(step)
-
- # Update run row
- from sqlalchemy import update
- from shared.models.database.document import RetrievalRun
-
- # Build provenance: which docs contributed to final results
- doc_ids_in_result = list({r.get('document_id', '') for r in ranked_rows if r.get('document_id')})
- provenance = {
- 'router': router_used,
- 'step_count': len(self._steps),
- 'final_doc_ids': doc_ids_in_result,
- }
- if budget_snapshot is not None:
- provenance['budget_snapshot'] = budget_snapshot
- if self._parent_run_id:
- provenance['parent_run_id'] = self._parent_run_id
- if self._workflow_step_id:
- provenance['workflow_step_id'] = self._workflow_step_id
-
- stmt = (
- update(RetrievalRun)
- .where(RetrievalRun.run_id == self._run_id)
- .values(
- result_count=len(ranked_rows),
- final_doc_ids=doc_ids_in_result,
- result_provenance=provenance,
- latency_ms=total_latency,
- token_count=sum(step.get('tokens_used', 0) for step in self._steps),
- completed_at=_now_utc(),
- )
- )
- await self._db.execute(stmt)
- await self._db.flush()
-
- except Exception as e:
- logger.debug(f'agentic trace: failed to complete run {self._run_id}: {e}')
- try:
- await self._db.rollback()
- except Exception as rollback_error:
- logger.debug(
- f'agentic trace: failed to roll back completion failure '
- f'{self._run_id}: {rollback_error}'
- )
diff --git a/packages/shared-python/shared/services/retrieval/agentic/core/types.py b/packages/shared-python/shared/services/retrieval/agentic/core/types.py
deleted file mode 100644
index a3bb1293b..000000000
--- a/packages/shared-python/shared/services/retrieval/agentic/core/types.py
+++ /dev/null
@@ -1,364 +0,0 @@
-"""Core data types for agentic retrieval.
-
-Defines the state machine primitives: configuration, state, actions,
-observations, and tool results. All types are plain dataclasses with
-no business logic — they are pure data containers.
-"""
-from __future__ import annotations
-
-import time
-from dataclasses import dataclass, field
-from typing import Any, Literal
-
-from shared.services.retrieval.agentic.core.budget import BudgetLedger
-
-
-@dataclass
-class AgentRunConfig:
- """Budget and limit configuration for a single agent run."""
- max_nav_steps: int = 6 # max navigation steps per document (no depth limit)
- latency_budget_ms: int = 12000
- token_budget_total: int = 40000
- planning_ratio: float = 0.5
- bootstrap_budget: int = 2000
- per_doc_min_share: int = 1500
- inventory_aware: bool = True
-
-
-@dataclass
-class ToolResult:
- """Unified return type for all agentic tools.
-
- Every tool returns one of these, regardless of success or failure.
- The orchestrator reads ``status`` to decide next action.
- """
- status: str
- payload: dict[str, Any] = field(default_factory=dict)
- latency_ms: int = 0
- error: str | None = None
- tokens_used: int = 0
-
-
-@dataclass
-class DocTreeNode:
- """Unified navigation result tree for one document.
-
- Produced by ``navigate_step``. Captures the full
- navigation outcome for rendering as a single hierarchy:
-
- - ``outline_items``: section tree items at this scope level
- - ``leaf_content``: hydrated chunk rows keyed by section path
- (leaf selections from LLM)
- - ``children``: recursive child trees keyed by section path
- (non-leaf selections, populated by orchestrator BFS queue)
- - ``confidence``: per-selection confidence for trimming
- """
- scope_path: str | None = None
-
- # Outline items at this level (title + summary)
- outline_items: list[dict[str, Any]] = field(default_factory=list)
-
- # Leaf results, keyed by section path:
- leaf_content: dict[str, list[dict[str, Any]]] = field(default_factory=dict)
- children: dict[str, 'DocTreeNode'] = field(default_factory=dict)
-
- # Confidence per selection (for trimming)
- confidence: dict[str, float] = field(default_factory=dict)
-
- @staticmethod
- def empty(scope_path: str | None = None) -> 'DocTreeNode':
- return DocTreeNode(scope_path=scope_path)
-
- def has_content(self) -> bool:
- """Check if this tree has any meaningful content (outline, chunks, or children)."""
- if self.outline_items:
- return True
- if self.leaf_content:
- return True
- if self.children:
- return any(c.has_content() for c in self.children.values())
- return False
-
- def has_leaf_content(self) -> bool:
- """Check if this tree has any actual hydrated chunk content (not just outline).
-
- Unlike ``has_content()`` which returns True for outline-only trees,
- this method only returns True when real text/table/image chunks have
- been hydrated into leaf_content.
- """
- if self.leaf_content:
- return True
- return any(c.has_leaf_content() for c in self.children.values())
-
- def flatten_chunk_rows(self) -> list[dict[str, Any]]:
- """Recursively collect all hydrated chunk rows (document order)."""
- rows: list[dict[str, Any]] = []
- for chunks in self.leaf_content.values():
- rows.extend(chunks)
- for child in self.children.values():
- rows.extend(child.flatten_chunk_rows())
- return rows
-
- def add_leaf_chunks(self, path: str, chunks: list[dict[str, Any]]) -> None:
- """Merge chunks into a leaf path, deduplicating by (chunk_id, path)."""
- if not path or not chunks:
- return
- existing = self.leaf_content.setdefault(path, [])
- seen: set[tuple[str, str]] = {
- (str(row.get('chunk_id') or ''), path)
- for row in existing
- if row.get('chunk_id')
- }
- for chunk in chunks:
- chunk_id = str(chunk.get('chunk_id') or '')
- key = (chunk_id, path)
- if chunk_id and key in seen:
- continue
- if chunk_id:
- seen.add(key)
- existing.append(chunk)
-
- def reparent_leaf_content(self) -> None:
- """Move descendant leaf paths into matching child nodes.
-
- Only moves true descendants (prefix match). Content whose path
- exactly equals a child key stays here — the renderer handles the
- case where a path is both a child and a leaf (section with own
- content *and* sub-sections).
- """
- for child_path, child in list(self.children.items()):
- for leaf_path in list(self.leaf_content.keys()):
- if leaf_path.startswith(child_path + ' / '):
- child.add_leaf_chunks(leaf_path, self.leaf_content.pop(leaf_path))
- child.reparent_leaf_content()
-
- def collect_referenced_ids(self, *, document_name: str = '') -> list[dict[str, Any]]:
- """Extract minimal chunk references from all hydrated leaves.
-
- Returns deduplicated list of {chunk_id, document_id, chunk_type,
- section_path, file_path, job_id, score} for hit stats and frontend
- display. ``score`` carries the real retrieval score (discovery RRF or
- navigation confidence) so callers can surface it in results.
- """
- refs: list[dict[str, Any]] = []
- seen: set[str] = set()
- for row in self.flatten_chunk_rows():
- cid = row.get('chunk_id', '')
- if cid and cid not in seen:
- seen.add(cid)
- section_path = row.get('section_path', '')
- if section_path == 'Root' and document_name:
- section_path = document_name
- refs.append({
- 'chunk_id': cid,
- 'document_id': row.get('document_id', ''),
- 'chunk_type': row.get('chunk_type', ''),
- 'section_path': section_path,
- 'file_path': row.get('file_path', ''),
- 'job_id': row.get('job_id', ''),
- 'score': row.get('score'), # None if no real score available
- })
- return refs
-
- def merge(self, other: 'DocTreeNode') -> None:
- """Additive merge for navigation results.
-
- Merges outline items, leaf content, children, and confidence from
- ``other`` into this node. Existing data is preserved; new data is
- added. For confidence values, the higher value wins.
- """
- existing_paths = {item['path'] for item in self.outline_items}
- for item in other.outline_items:
- if item.get('path', '') not in existing_paths:
- self.outline_items.append(item)
- for path, chunks in other.leaf_content.items():
- self.add_leaf_chunks(path, chunks)
- for path, child in other.children.items():
- if path in self.children:
- self.children[path].merge(child)
- else:
- self.children[path] = child
- for path, conf in other.confidence.items():
- self.confidence[path] = max(self.confidence.get(path, 0), conf)
- self.reparent_leaf_content()
-
-
-NavAction = Literal[
- "EXPAND",
- "BACK",
- "FINISH",
- "SEARCH_IMAGES",
- "SEARCH_TABLES",
- "ERROR",
-]
-
-
-@dataclass
-class DecisionTraceStep:
- """Uniform observe-act-result trace entry exposed to downstream agents."""
-
- step_index: int
- agent: str
- phase: str
- observation: dict[str, Any]
- decision: dict[str, Any]
- result: dict[str, Any]
- parent_step_index: int | None = None
- document_id: str | None = None
- document: str | None = None
- scope: str | None = None
- budget: dict[str, Any] | None = None
- elapsed_ms: int | None = None
-
- def to_dict(self) -> dict[str, Any]:
- data: dict[str, Any] = {
- "step_index": self.step_index,
- "agent": self.agent,
- "parent_step_index": self.parent_step_index,
- "phase": self.phase,
- "document_id": self.document_id,
- "document": self.document,
- "scope": self.scope,
- "observation": self.observation,
- "decision": self.decision,
- "result": self.result,
- }
- if self.budget is not None:
- data["budget"] = self.budget
- if self.elapsed_ms is not None:
- data["elapsed_ms"] = self.elapsed_ms
- return data
-
-
-@dataclass
-class NavigateStepResult:
- """Return type for navigate_step — Collector Agent model.
-
- Each step returns:
- - ``collect``: paths to add to the evidence collection (full hydration)
- - ``drill``: paths to explore deeper in subsequent steps
- - ``action``: one explicit action — EXPAND/BACK/FINISH/SEARCH_*/ERROR
- - ``node``: outline tree node for rendering context
- - ``reason``: LLM reasoning for trace
- - ``error_reason``: set when action is ERROR — distinguishes system
- errors from intentional FINISH so callers can decide retry vs skip.
- - ``search_assets_params``: parameters for SEARCH_IMAGES/SEARCH_TABLES
- - ``observation``: what the navigator saw before choosing the action
- - ``result_status`` / ``result_note``: executor-visible action validation
- """
- action: NavAction = "FINISH"
- collect: list[dict[str, Any]] = field(default_factory=list)
- drill: list[dict[str, Any]] = field(default_factory=list)
- back_to: str | None = None # BACK target ancestor path (None = root)
- tools: list[str] = field(default_factory=list)
- node: DocTreeNode = field(default_factory=DocTreeNode)
- reason: str = ""
- error_reason: str | None = None
- search_assets_params: dict[str, Any] | None = None
- observation: dict[str, Any] = field(default_factory=dict)
- result_status: str = "ok"
- result_note: str | None = None
-
- @property
- def drill_into(self) -> str | None:
- """Single drill target path, or None."""
- return self.drill[0]["path"] if self.drill else None
-
- @property
- def is_terminal(self) -> bool:
- """True only for explicit terminal actions."""
- return self.action in ("FINISH", "ERROR")
-
- @staticmethod
- def stop(scope_path: str | None = None, *, reason: str = "") -> 'NavigateStepResult':
- return NavigateStepResult(
- action="FINISH",
- node=DocTreeNode.empty(scope_path),
- reason=reason,
- )
-
- @staticmethod
- def error(scope_path: str | None = None, *, reason: str = "") -> 'NavigateStepResult':
- """Return an ERROR result distinguishable from intentional FINISH."""
- return NavigateStepResult(
- action="ERROR",
- node=DocTreeNode.empty(scope_path),
- reason=f"navigation_error: {reason[:200]}" if reason else "navigation_error",
- error_reason=reason[:500] if reason else "unknown_error",
- result_status="error",
- result_note=reason[:500] if reason else "unknown_error",
- )
-
-
-@dataclass
-class CandidateDoc:
- """A document selected by kg_document_select."""
- document_id: str
- source_file_name: str = ''
- confidence: float = 0.0
- reason: str = ''
- source: str = '' # 'kg_llm_select' | 'grep' | 'edge_expand' | 'discovery_hint'
-
-
-@dataclass
-class AgenticResult:
- """Output of agentic retrieval.
-
- - ``evidence_text``: complete hierarchical context for LLM answering
- (rendered doc tree with outline + leaf content + inline tables)
- - ``answer_text``: deprecated; always empty because KNOWHERE returns
- evidence only and downstream agents synthesize answers.
- - ``referenced_chunks``: minimal chunk references for hit stats
- and frontend display (chunk_id, document_id, chunk_type, etc.)
- - ``router_used``: routing path identifier
- - ``budget_snapshot``: final budget ledger state at run completion
- - ``stop_reason``: why the run terminated (evidence_only /
- budget / latency / max_steps / error / llm_stop)
- - ``failure_reason``: fatal retrieval failure reason, if any.
- - ``decision_trace``: per-step navigation decisions with reasons,
- exposed to downstream agents for stop/retry/modify-query decisions.
- """
- evidence_text: str
- answer_text: str = ''
- referenced_chunks: list[dict[str, Any]] = field(default_factory=list)
- router_used: str = 'agentic_discovery_only'
- budget_snapshot: dict[str, Any] | None = None
- stop_reason: str = ''
- failure_reason: str = ''
- decision_trace: list[dict[str, Any]] = field(default_factory=list)
-
-
-@dataclass
-class AgentState:
- """Mutable state carried through the 2-phase orchestrator.
-
- Phase 1: Document selection (discovery + KG)
- Phase 2: Per-document navigation (navigate_step per doc)
- Phase 3: Assembly + final verdict
- """
- # Timing
- start_time: float = field(default_factory=time.monotonic)
- step_count: int = 0
-
- # Phase 1: Discovery
- discovery_top_doc_ids: list[str] = field(default_factory=list)
-
- # Phase 1: KG document selection
- selected_docs: list[CandidateDoc] = field(default_factory=list)
- doc_id_to_name: dict[str, str] = field(default_factory=dict)
- doc_job_map: dict[str, str] = field(default_factory=dict)
-
- # Phase 2: Per-document navigation results
- doc_trees: dict[str, DocTreeNode] = field(default_factory=dict) # doc_id → DocTreeNode
-
- ever_explored_doc_ids: set[str] = field(default_factory=set)
-
- # Token budget + KG inventory
- ledger: BudgetLedger | None = None
- kg_total_chunks: int = 0
- kg_total_docs: int = 0
- explored_chunks: int = 0
-
- @property
- def elapsed_ms(self) -> int:
- return int((time.monotonic() - self.start_time) * 1000)
diff --git a/packages/shared-python/shared/services/retrieval/agentic/discovery/__init__.py b/packages/shared-python/shared/services/retrieval/agentic/discovery/__init__.py
deleted file mode 100644
index 8b1378917..000000000
--- a/packages/shared-python/shared/services/retrieval/agentic/discovery/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/packages/shared-python/shared/services/retrieval/agentic/discovery/phase.py b/packages/shared-python/shared/services/retrieval/agentic/discovery/phase.py
deleted file mode 100644
index b3fc54a08..000000000
--- a/packages/shared-python/shared/services/retrieval/agentic/discovery/phase.py
+++ /dev/null
@@ -1,191 +0,0 @@
-"""Discovery and document selection phase for agentic retrieval."""
-from __future__ import annotations
-
-from typing import Any
-
-from loguru import logger
-from sqlalchemy.ext.asyncio import AsyncSession
-
-from shared.services.retrieval.agentic import tools
-from shared.services.retrieval.agentic.core.budget import BudgetExceeded
-from shared.services.retrieval.agentic.core.trace import TraceRecorder
-from shared.services.retrieval.agentic.core.types import AgentState, CandidateDoc, ToolResult
-from shared.services.retrieval.llm_adapter import LLMFn
-from shared.services.retrieval.search.lexical_text import normalize_section_path
-
-
-async def run_initial_discovery(
- db: AsyncSession,
- *,
- state: AgentState,
- trace: TraceRecorder,
- trace_enabled: bool,
- 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] | None,
- filter_mode: str,
- channels: list[str] | None,
- channel_weights: dict[str, float] | None,
- internal_recall_k: int | None,
- bootstrap_llm_fn: LLMFn | None,
-) -> list[dict[str, Any]]:
- discovery_kwargs: dict[str, Any] = {
- "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,
- "filter_mode": filter_mode,
- "channels": channels,
- "channel_weights": channel_weights,
- "internal_recall_k": internal_recall_k,
- }
-
- logger.info(" agentic: Phase 1 — discovery + document selection")
- discovery_result = await tools.bottom_discovery(db, **discovery_kwargs)
- state.step_count += 1
- discovery_rows = (
- discovery_result.payload.get("fused_rows", [])
- if discovery_result.status != "error"
- else []
- )
- state.discovery_top_doc_ids = (
- discovery_result.payload.get("top_doc_ids", [])
- if discovery_result.status != "error"
- else []
- )
-
- if trace_enabled:
- trace.record_step(
- "bottom_discovery",
- discovery_result,
- decision_reason="phase_1_mandatory",
- )
-
- logger.info(
- f" agentic step {state.step_count}: bottom_discovery "
- f"status={discovery_result.status} latency={discovery_result.latency_ms}ms"
- )
-
- # Build per-document discovery signals for KG soft-prompting
- discovery_signals = build_discovery_signals(discovery_rows)
-
- if bootstrap_llm_fn is not None:
- await _select_documents(
- db,
- state=state,
- trace=trace,
- trace_enabled=trace_enabled,
- user_id=user_id,
- namespace=namespace,
- query=query,
- exclude_document_ids=exclude_document_ids,
- bootstrap_llm_fn=bootstrap_llm_fn,
- discovery_signals=discovery_signals,
- )
-
- return discovery_rows
-
-
-def build_discovery_signals(
- discovery_rows: list[dict[str, Any]],
-) -> dict[str, list[str]]:
- """Build per-document discovery signals from bottom discovery results.
-
- Returns a mapping of ``{doc_id: [path1, path2, ...]}`` for documents
- where keyword/semantic search found potentially relevant section paths.
- These signals are injected as soft hints into the KG document selection
- prompt, allowing the LLM to make an informed decision rather than
- force-injecting documents.
- """
- signals: dict[str, list[str]] = {}
- seen: dict[str, set[str]] = {}
- for row in discovery_rows:
- doc_id = row.get("document_id", "")
- section_path = normalize_section_path(
- str(row.get("section_path", "") or "").strip()
- )
- if not doc_id or not section_path or section_path == "Root":
- continue
- if doc_id not in seen:
- seen[doc_id] = set()
- signals[doc_id] = []
- if section_path not in seen[doc_id]:
- seen[doc_id].add(section_path)
- signals[doc_id].append(section_path)
- return signals
-
-
-async def _select_documents(
- db: AsyncSession,
- *,
- state: AgentState,
- trace: TraceRecorder,
- trace_enabled: bool,
- user_id: str,
- namespace: str,
- query: str,
- exclude_document_ids: list[str],
- bootstrap_llm_fn: LLMFn,
- discovery_signals: dict[str, list[str]] | None = None,
-) -> None:
- try:
- kg_result = await tools.kg_document_select(
- db,
- user_id=user_id,
- namespace=namespace,
- query=query,
- llm_fn=bootstrap_llm_fn,
- exclude_document_ids=list(state.ever_explored_doc_ids | set(exclude_document_ids)),
- budget_snapshot=state.ledger.snapshot() if state.ledger else None,
- discovery_signals=discovery_signals,
- )
- except BudgetExceeded:
- logger.info(" agentic: bootstrap budget exhausted during document selection")
- if trace_enabled:
- trace.record_budget_stop("bootstrap_exhausted")
- kg_result = ToolResult(
- status="no_confident_doc",
- payload={"reason": "bootstrap budget exhausted"},
- )
- state.step_count += 1
-
- if trace_enabled:
- trace.record_step(
- "kg_document_select",
- kg_result,
- decision_reason="phase_1_doc_selection",
- )
-
- _append_selected_docs(state, kg_result)
-
- logger.info(
- f" agentic step {state.step_count}: kg_document_select "
- f"status={kg_result.status} docs={len(state.selected_docs)} "
- f"latency={kg_result.latency_ms}ms"
- )
-
-
-def _append_selected_docs(state: AgentState, kg_result: ToolResult) -> None:
- if kg_result.status != "selected_docs":
- return
- for doc_data in kg_result.payload.get("candidate_docs", []):
- state.selected_docs.append(
- CandidateDoc(
- document_id=doc_data.get("document_id", ""),
- source_file_name=doc_data.get("source_file_name", ""),
- confidence=doc_data.get("confidence", 0.0),
- reason=doc_data.get("reason", ""),
- source=doc_data.get("source", ""),
- )
- )
- state.doc_id_to_name.update(kg_result.payload.get("doc_id_to_name", {}))
- state.doc_job_map.update(kg_result.payload.get("doc_job_map", {}))
diff --git a/packages/shared-python/shared/services/retrieval/agentic/discovery/tools.py b/packages/shared-python/shared/services/retrieval/agentic/discovery/tools.py
deleted file mode 100644
index ae62618b7..000000000
--- a/packages/shared-python/shared/services/retrieval/agentic/discovery/tools.py
+++ /dev/null
@@ -1,329 +0,0 @@
-"""Agentic retrieval discovery tools.
-
-This Module owns phase-1 retrieval: lexical bottom discovery and document
-selection from the document-level knowledge map. The public tool adapter stays
-in ``tools.py`` so orchestrator call sites keep a stable interface.
-"""
-from __future__ import annotations
-
-import time
-from typing import Any
-
-from loguru import logger
-from sqlalchemy import select
-from sqlalchemy.ext.asyncio import AsyncSession
-
-from shared.models.database.document import Document
-from shared.services.retrieval.agentic.core.budget import BudgetExceeded
-from shared.services.retrieval.agentic.navigation.knowledge_map import build_knowledge_map_overview
-from shared.services.retrieval.agentic.prompts import (
- FILE_SELECT_PROMPT,
- format_budget_block,
- parse_json_array,
-)
-from shared.services.retrieval.agentic.core.types import ToolResult
-from shared.services.retrieval.search.channels import content_channel, path_channel, term_channel
-from shared.services.retrieval.llm_adapter import LLMFn
-from shared.services.retrieval.search.scoring import (
- merge_channels_rrf,
- merge_same_section_rows,
- normalize_row_scores,
-)
-from shared.services.retrieval.settings import (
- CHANNEL_WEIGHT_CONTENT,
- CHANNEL_WEIGHT_PATH,
- CHANNEL_WEIGHT_TERM,
- INTERNAL_RECALL_K_MULTIPLIER,
-)
-
-
-async def bottom_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 = None,
- signal_paths: list[str] | None = None,
- filter_mode: str = "delete",
- channels: list[str] | None = None,
- channel_weights: dict[str, float] | None = None,
- internal_recall_k: int | None = None,
- **_kwargs: Any,
-) -> ToolResult:
- """Run 3-channel BM25 discovery plus RRF fusion."""
- t0 = time.monotonic()
- try:
- allowed_chunk_types = chunk_types
- effective_recall_k = (
- internal_recall_k
- if internal_recall_k is not None
- else top_k * INTERNAL_RECALL_K_MULTIPLIER
- )
- active_channels = set(channels) if channels else {"path", "content", "term"}
-
- path_rows: list[dict[str, Any]] = []
- content_rows: list[dict[str, Any]] = []
- term_rows: list[dict[str, Any]] = []
-
- if "path" in active_channels:
- path_rows = await path_channel(
- db,
- user_id=user_id,
- namespace=namespace,
- query=query,
- top_k=effective_recall_k,
- exclude_document_ids=exclude_document_ids,
- exclude_sections=exclude_sections,
- allowed_chunk_types=allowed_chunk_types,
- signal_paths=signal_paths,
- filter_mode=filter_mode,
- )
-
- if "content" in active_channels:
- content_rows = await content_channel(
- db,
- user_id=user_id,
- namespace=namespace,
- query=query,
- top_k=effective_recall_k,
- exclude_document_ids=exclude_document_ids,
- exclude_sections=exclude_sections,
- allowed_chunk_types=allowed_chunk_types,
- signal_paths=signal_paths,
- filter_mode=filter_mode,
- )
-
- if "term" in active_channels:
- term_rows = await term_channel(
- db,
- user_id=user_id,
- namespace=namespace,
- query=query,
- top_k=effective_recall_k,
- exclude_document_ids=exclude_document_ids,
- exclude_sections=exclude_sections,
- allowed_chunk_types=allowed_chunk_types,
- signal_paths=signal_paths,
- filter_mode=filter_mode,
- )
-
- default_weights = {
- "path": CHANNEL_WEIGHT_PATH,
- "content": CHANNEL_WEIGHT_CONTENT,
- "term": CHANNEL_WEIGHT_TERM,
- }
- effective_weights = {**default_weights, **(channel_weights or {})}
-
- channel_lists: list[list[dict[str, Any]]] = []
- weight_list: list[float] = []
- if path_rows:
- channel_lists.append(path_rows)
- weight_list.append(effective_weights.get("path", CHANNEL_WEIGHT_PATH))
- if content_rows:
- channel_lists.append(content_rows)
- weight_list.append(effective_weights.get("content", CHANNEL_WEIGHT_CONTENT))
- if term_rows:
- channel_lists.append(term_rows)
- weight_list.append(effective_weights.get("term", CHANNEL_WEIGHT_TERM))
-
- fused_rows = (
- merge_channels_rrf(channel_lists, weight_list, effective_recall_k)
- if channel_lists
- else []
- )
- fused_rows = merge_same_section_rows(fused_rows)
-
- if fused_rows:
- normalize_row_scores(
- fused_rows,
- source_field="score",
- target_field="discovery_score",
- default=0.5,
- )
-
- doc_id_counts: dict[str, int] = {}
- for row in fused_rows:
- did = row.get("document_id", "")
- if did:
- doc_id_counts[did] = doc_id_counts.get(did, 0) + 1
- top_doc_ids = sorted(
- doc_id_counts,
- key=lambda document_id: doc_id_counts[document_id],
- reverse=True,
- )[:5]
-
- latency = int((time.monotonic() - t0) * 1000)
- logger.info(
- f" agentic.bottom_discovery: {len(fused_rows)} fused rows, "
- f"top_doc_ids={top_doc_ids}, {latency}ms"
- )
- return ToolResult(
- status="discovery_done",
- payload={
- "fused_rows": fused_rows,
- "top_doc_ids": top_doc_ids,
- "channel_counts": {
- "path": len(path_rows),
- "content": len(content_rows),
- "term": len(term_rows),
- },
- },
- latency_ms=latency,
- )
- except Exception as exc:
- latency = int((time.monotonic() - t0) * 1000)
- logger.error(f" agentic.bottom_discovery failed: {exc}")
- return ToolResult(status="error", error=str(exc), latency_ms=latency)
-
-
-async def kg_document_select(
- db: AsyncSession,
- *,
- user_id: str,
- namespace: str,
- query: str,
- llm_fn: LLMFn | None,
- exclude_document_ids: list[str],
- discovery_signals: dict[str, list[str]] | None = None,
- **_kwargs: Any,
-) -> ToolResult:
- """Select candidate documents from document-level KG."""
- t0 = time.monotonic()
- try:
- overview_text, doc_id_to_name = await build_knowledge_map_overview(
- db,
- user_id=user_id,
- namespace=namespace,
- )
- if overview_text == "(empty)":
- latency = int((time.monotonic() - t0) * 1000)
- return ToolResult(
- status="no_confident_doc",
- payload={"reason": "no active documents in namespace"},
- latency_ms=latency,
- )
-
- if llm_fn is None:
- latency = int((time.monotonic() - t0) * 1000)
- return ToolResult(
- status="no_confident_doc",
- payload={"reason": "LLM not available"},
- latency_ms=latency,
- )
-
- # Inject discovery signals as soft hints into the overview
- if discovery_signals:
- overview_text = _inject_discovery_signals(
- overview_text, discovery_signals,
- )
-
- file_prompt = FILE_SELECT_PROMPT.format(
- overview=overview_text,
- query=query,
- budget_block=format_budget_block(_kwargs.get("budget_snapshot")),
- )
- file_response = await llm_fn(file_prompt)
- selected_ids = parse_json_array(file_response)
-
- exclude_set = set(exclude_document_ids)
- valid_ids = [
- document_id
- for document_id in selected_ids
- if document_id in doc_id_to_name and document_id not in exclude_set
- ]
-
- if not valid_ids:
- latency = int((time.monotonic() - t0) * 1000)
- logger.info(
- f" agentic.kg_document_select: LLM returned no valid docs, {latency}ms"
- )
- return ToolResult(
- status="no_confident_doc",
- payload={
- "reason": "LLM returned no valid document IDs",
- "raw_ids": selected_ids,
- },
- latency_ms=latency,
- )
-
- doc_job_map: dict[str, str] = {}
- doc_stmt = (
- select(Document.document_id, Document.current_job_result_id)
- .where(Document.document_id.in_(valid_ids))
- )
- doc_result = await db.execute(doc_stmt)
- for document_id, job_result_id in doc_result.all():
- if job_result_id:
- doc_job_map[document_id] = job_result_id
-
- candidate_docs = [
- {
- "document_id": document_id,
- "source_file_name": doc_id_to_name.get(document_id, ""),
- "confidence": 1.0,
- "reason": "LLM selected from KG overview",
- "source": "kg_llm_select",
- }
- for document_id in valid_ids
- ]
-
- latency = int((time.monotonic() - t0) * 1000)
- logger.info(
- f" agentic.kg_document_select: {len(candidate_docs)} docs selected, {latency}ms"
- )
- return ToolResult(
- status="selected_docs",
- payload={
- "candidate_docs": candidate_docs,
- "doc_id_to_name": doc_id_to_name,
- "doc_job_map": doc_job_map,
- },
- latency_ms=latency,
- )
- except BudgetExceeded:
- raise
- except Exception as exc:
- latency = int((time.monotonic() - t0) * 1000)
- logger.error(f" agentic.kg_document_select failed: {exc}")
- return ToolResult(status="error", error=str(exc), latency_ms=latency)
-
-
-def _inject_discovery_signals(
- overview_text: str,
- signals: dict[str, list[str]],
- *,
- max_paths_per_doc: int = 5,
-) -> str:
- """Inject discovery hint lines into the document overview text.
-
- For each document that has discovery signals, append hint lines
- directly after the document's overview entry. The LLM sees these
- as advisory information — it is free to select or ignore the document.
- """
- if not signals:
- return overview_text
-
- lines = overview_text.split("\n")
- result: list[str] = []
- for line in lines:
- result.append(line)
- # Match overview lines: "- [doc_xxx] filename chunks=..."
- if not line.startswith("- ["):
- continue
- bracket_end = line.find("]")
- if bracket_end < 0:
- continue
- doc_id = line[3:bracket_end]
- paths = signals.get(doc_id)
- if not paths:
- continue
- display_paths = paths[:max_paths_per_doc]
- hints_line = ", ".join(f'"{p}"' for p in display_paths)
- if len(paths) > max_paths_per_doc:
- hints_line += f" (+{len(paths) - max_paths_per_doc} more)"
- result.append(f" 🔍 Discovery hints: {hints_line}")
- return "\n".join(result)
diff --git a/packages/shared-python/shared/services/retrieval/agentic/evidence/__init__.py b/packages/shared-python/shared/services/retrieval/agentic/evidence/__init__.py
deleted file mode 100644
index 8b1378917..000000000
--- a/packages/shared-python/shared/services/retrieval/agentic/evidence/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/packages/shared-python/shared/services/retrieval/agentic/evidence/builder.py b/packages/shared-python/shared/services/retrieval/agentic/evidence/builder.py
deleted file mode 100644
index 80a94954e..000000000
--- a/packages/shared-python/shared/services/retrieval/agentic/evidence/builder.py
+++ /dev/null
@@ -1,301 +0,0 @@
-from __future__ import annotations
-
-from typing import Any
-
-from loguru import logger
-from sqlalchemy import select
-from sqlalchemy.ext.asyncio import AsyncSession
-
-from shared.models.database.document import RetrievalHitStat
-from shared.services.retrieval.agentic.core.budget import BudgetLedger
-from shared.services.retrieval.agentic.core.types import DocTreeNode
-from shared.services.retrieval.hydration.assets import (
- AssetUrlValue,
- build_retrieval_asset_url_map,
-)
-from shared.services.retrieval.stats.service import compute_importance_score
-from shared.utils.token_estimate import estimate_tokens
-
-
-def _collect_chunks_by_type(
- node: DocTreeNode,
- chunk_types: set[str],
-) -> list[dict[str, Any]]:
- collected_chunks: list[dict[str, Any]] = []
- for chunks in node.leaf_content.values():
- for chunk in chunks:
- chunk_type = (
- chunk.get("chunk_type") or chunk.get("type") or ""
- ).strip().lower()
- if chunk_type in chunk_types:
- collected_chunks.append(chunk)
- for child in node.children.values():
- collected_chunks.extend(_collect_chunks_by_type(child, chunk_types))
- return collected_chunks
-
-
-def collect_media_chunks(node: DocTreeNode) -> list[dict[str, Any]]:
- return _collect_chunks_by_type(node, {"image", "table", "page"})
-
-
-def collect_media_chunks_all(
- doc_trees: dict[str, DocTreeNode],
-) -> list[dict[str, Any]]:
- media: list[dict[str, Any]] = []
- for tree in doc_trees.values():
- media.extend(collect_media_chunks(tree))
- return media
-
-
-async def build_asset_url_map(
- media_chunks: list[dict[str, Any]],
-) -> dict[str, AssetUrlValue]:
- return await build_retrieval_asset_url_map(
- media_chunks,
- log_context="agentic evidence",
- )
-
-
-def _collect_all_leaf_paths(node: DocTreeNode) -> set[str]:
- paths = set(node.leaf_content.keys())
- for child in node.children.values():
- paths.update(_collect_all_leaf_paths(child))
- return paths
-
-
-def _collect_visible_paths(node: DocTreeNode) -> set[str]:
- paths = {item["path"] for item in node.outline_items if item.get("path")}
- for child in node.children.values():
- paths.update(_collect_visible_paths(child))
- return paths
-
-
-def _find_closest_ancestor(path: str, target_paths: set[str]) -> str | None:
- parts = path.split(" / ")
- for i in range(len(parts) - 1, 0, -1):
- ancestor = " / ".join(parts[:i])
- if ancestor in target_paths:
- return ancestor
- return None
-
-
-def reconcile_deferred_assets(
- tree: DocTreeNode,
- pending_assets: list[dict],
-) -> None:
- final_paths = _collect_all_leaf_paths(tree)
- visible_paths = _collect_visible_paths(tree)
- all_target_paths = final_paths | visible_paths
-
- if not all_target_paths:
- return
-
- existing_ids = {
- str(row.get("chunk_id") or "")
- for row in tree.flatten_chunk_rows()
- if row.get("chunk_id")
- }
-
- placed = 0
- ancestor_placed = 0
- for asset in pending_assets:
- chunk_id = str(asset.get("chunk_id") or "")
- if chunk_id and chunk_id in existing_ids:
- continue
-
- owner_path = asset.get("owner_section_path") or asset.get("section_path")
- if not owner_path:
- continue
-
- target_path = owner_path if owner_path in all_target_paths else None
- if target_path is None:
- target_path = _find_closest_ancestor(owner_path, all_target_paths)
- if target_path:
- ancestor_placed += 1
-
- if target_path is None:
- continue
-
- tree.add_leaf_chunks(target_path, [asset])
- if chunk_id:
- existing_ids.add(chunk_id)
- placed += 1
-
- if placed:
- tree.reparent_leaf_content()
- logger.info(
- f" deferred asset reconcile: {placed}/{len(pending_assets)} "
- f"assets placed into {len(final_paths)} leaf + {len(visible_paths)} visible paths "
- f"(ancestor_fallback={ancestor_placed})"
- )
-
-
-async def render_evidence(
- db: AsyncSession,
- doc_trees: dict[str, DocTreeNode],
- doc_id_to_name: dict[str, str],
-) -> str:
- del db
-
- from shared.services.retrieval.agentic.evidence.renderer import render_unified_doc_tree
-
- asset_url_map = await build_asset_url_map(collect_media_chunks_all(doc_trees))
-
- evidence_parts: list[str] = []
- for doc_id, doc_tree in doc_trees.items():
- # Only render if there is actual hydrated evidence (chunks collected
- # via COLLECT or discovery). Outline-only trees (e.g. navigation
- # FINISH with empty collect must not leak into evidence_text.
- if not doc_tree.has_leaf_content():
- continue
- doc_name = doc_id_to_name.get(doc_id, doc_id)
- rendered = render_unified_doc_tree(
- doc_tree,
- doc_name,
- asset_lookup=asset_url_map,
- )
- if rendered.strip():
- evidence_parts.append(rendered)
-
- return "\n\n".join(evidence_parts) if evidence_parts else ""
-
-
-def _iter_leaf_content(node: DocTreeNode):
- for path, chunks in node.leaf_content.items():
- yield path, chunks
- for child in node.children.values():
- yield from _iter_leaf_content(child)
-
-
-def _collect_confidences(node: DocTreeNode) -> dict[str, float]:
- values = dict(node.confidence)
- for child in node.children.values():
- for path, score in _collect_confidences(child).items():
- values[path] = max(values.get(path, 0.0), score)
- return values
-
-
-def _pop_leaf_path(node: DocTreeNode, path: str) -> bool:
- if path in node.leaf_content:
- node.leaf_content.pop(path)
- return True
- for child in node.children.values():
- if _pop_leaf_path(child, path):
- return True
- return False
-
-
-def _estimate_chunks_tokens(chunks: list[dict[str, Any]]) -> int:
- text = "\n".join(str(chunk.get("content") or "") for chunk in chunks)
- return estimate_tokens(text)
-
-
-async def _fetch_importance_norm_scores(
- db: AsyncSession,
- *,
- user_id: str,
- namespace: str,
- chunk_ids: list[str],
-) -> dict[str, float]:
- if not chunk_ids:
- return {}
- stmt = (
- select(
- RetrievalHitStat.chunk_id,
- RetrievalHitStat.hit_count,
- RetrievalHitStat.last_hit_at,
- RetrievalHitStat.created_at,
- )
- .where(RetrievalHitStat.user_id == user_id)
- .where(RetrievalHitStat.namespace == namespace)
- .where(RetrievalHitStat.hit_kind == "chunk")
- .where(RetrievalHitStat.chunk_id.in_(chunk_ids))
- )
- result = await db.execute(stmt)
- scores: dict[str, float] = {}
- for chunk_id, hit_count, last_hit_at, created_at in result.all():
- if chunk_id and last_hit_at and created_at:
- scores[str(chunk_id)] = compute_importance_score(
- hit_count,
- last_hit_at,
- created_at,
- )
- return scores
-
-
-async def trim_evidence_to_budget(
- db: AsyncSession,
- *,
- doc_trees: dict[str, DocTreeNode],
- doc_id_to_name: dict[str, str],
- context_remaining: int,
- user_id: str,
- namespace: str,
- ledger: BudgetLedger | None,
- safety_margin: float = 0.9,
-) -> str:
- full_text = await render_evidence(db, doc_trees, doc_id_to_name)
- target = int(max(context_remaining, 0) * safety_margin)
- if estimate_tokens(full_text) <= target:
- return full_text
-
- candidates: list[tuple[str, str, tuple[float, float, float], int]] = []
- for doc_id, tree in doc_trees.items():
- confidence = _collect_confidences(tree)
- for path, chunks in _iter_leaf_content(tree):
- chunk_ids = [
- str(chunk.get("chunk_id"))
- for chunk in chunks
- if chunk.get("chunk_id")
- ]
- importance = 0.0
- importance_scores = await _fetch_importance_norm_scores(
- db,
- user_id=user_id,
- namespace=namespace,
- chunk_ids=chunk_ids,
- )
- if importance_scores:
- importance = max(importance_scores.values())
- discovery_score = (
- float(chunks[0].get("discovery_score", 0.0) or 0.0)
- if chunks
- else 0.0
- )
- score = (
- float(confidence.get(path, 0.0) or 0.0),
- discovery_score,
- importance,
- )
- candidates.append((doc_id, path, score, _estimate_chunks_tokens(chunks)))
-
- current_estimate = estimate_tokens(full_text)
- removed: list[dict[str, Any]] = []
- for doc_id, path, score, token_estimate in sorted(
- candidates,
- key=lambda item: (item[2], -item[3]),
- ):
- if current_estimate <= target:
- break
- if _pop_leaf_path(doc_trees[doc_id], path):
- confidence_score, discovery_score, importance_score = score
- removed.append(
- {
- "document_id": doc_id,
- "document_name": doc_id_to_name.get(doc_id, doc_id),
- "path": path,
- "confidence_score": round(confidence_score, 4),
- "discovery_score": round(discovery_score, 4),
- "importance_score": round(importance_score, 4),
- "token_estimate": token_estimate,
- }
- )
- current_estimate = max(current_estimate - token_estimate, 0)
-
- if ledger is not None:
- ledger.trimmed_paths.extend(removed)
- logger.info(
- f" agentic.trim_evidence: removed={len(removed)} "
- f"est_tokens={current_estimate} target={target}"
- )
- return await render_evidence(db, doc_trees, doc_id_to_name)
diff --git a/packages/shared-python/shared/services/retrieval/agentic/evidence/renderer.py b/packages/shared-python/shared/services/retrieval/agentic/evidence/renderer.py
deleted file mode 100644
index 3b8bd9c73..000000000
--- a/packages/shared-python/shared/services/retrieval/agentic/evidence/renderer.py
+++ /dev/null
@@ -1,448 +0,0 @@
-"""Render agentic document trees into evidence text."""
-from __future__ import annotations
-
-from typing import Any, cast
-
-from shared.services.retrieval.agentic.core.types import DocTreeNode
-
-AssetLookupValue = str
-
-
-def render_unified_doc_tree(
- node: DocTreeNode,
- doc_name: str,
- depth: int = 0,
- asset_lookup: dict[str, AssetLookupValue] | None = None,
-) -> str:
- """Render a DocTreeNode as one coherent hierarchy."""
- parts: list[str] = []
- indent = " " * depth
-
- if depth == 0:
- parts.append(f"[Document] {doc_name}\n")
-
- child_prefixes = set(node.children.keys())
-
- def min_sort(path: str) -> float:
- chunks = node.leaf_content.get(path, [])
- return min((chunk.get("sort_order") or float("inf") for chunk in chunks), default=float("inf"))
-
- render_queue: list[tuple[float, str, dict | str]] = []
- outline_paths: set[str] = set()
- outline_position = 0.0
-
- for item in node.outline_items:
- path = item.get("path", "")
- if any(path.startswith(child_prefix + " / ") for child_prefix in child_prefixes):
- continue
- outline_paths.add(path)
-
- if path in node.leaf_content or path in node.children:
- sort_key = min_sort(path) if path in node.leaf_content else outline_position
- else:
- sort_key = outline_position
- outline_position = max(outline_position, sort_key) + 0.001
-
- render_queue.append((sort_key, "outline", item))
-
- for path in node.leaf_content:
- if path not in outline_paths:
- render_queue.append((min_sort(path), "orphan_leaf", path))
-
- for path in node.children:
- if path not in outline_paths and path not in node.leaf_content:
- child_sort = _infer_child_sort_order(node.children[path])
- render_queue.append((child_sort, "orphan_child", path))
-
- render_queue.sort(key=lambda item: item[0])
-
- for _sort_key, render_type, data in render_queue:
- if render_type == "outline":
- item = cast(dict, data)
- path = item.get("path", "")
- title = item.get("title", "")
- is_leaf = item.get("is_leaf", False)
- level = item.get("level", 1)
- leaf_tag = " [Leaf]" if is_leaf else ""
-
- level_tag = f"[L{level}] " if level else ""
- # Indent based on the section's own level relative to the tree depth,
- # so L2 items are indented even when they sit in the root node.
- item_indent = indent + " " * max(level - 1, 0)
- if level <= 1:
- parts.append(f"{item_indent}▸ {level_tag}{title}{leaf_tag}")
- else:
- parts.append(f"{item_indent}└ {level_tag}{title}{leaf_tag}")
-
- sub_indent = item_indent + " "
- if path in node.children:
- child = node.children[path]
- if path in node.leaf_content:
- # Parent has children: suppress page summary to avoid
- # redundant parent/child overlap (§ SAME-AS pattern).
- render_leaf_chunks(
- parts, node.leaf_content[path], sub_indent,
- asset_lookup=asset_lookup, suppress_page_summary=True,
- )
- child_text = render_unified_doc_tree(child, doc_name, depth + 1, asset_lookup=asset_lookup)
- if child_text.strip():
- parts.append(child_text)
- elif path in node.leaf_content:
- render_leaf_chunks(parts, node.leaf_content[path], sub_indent, asset_lookup=asset_lookup)
-
- elif render_type == "orphan_leaf":
- path = cast(str, data)
- title = path.rsplit(" / ", 1)[-1] if " / " in path else path
- level = _infer_level_from_path(path)
- item_indent = indent + " " * max(level - 1, 0)
- sub_indent = item_indent + " "
- level_tag = f"[L{level}] " if level else ""
- if path in node.children:
- # Non-leaf node with own content: render heading, then
- # self chunks, then child subtree (merged rendering).
- parts.append(f"{item_indent}▸ {level_tag}{title}")
- render_leaf_chunks(
- parts, node.leaf_content[path], sub_indent,
- asset_lookup=asset_lookup, suppress_page_summary=True,
- )
- child_text = render_unified_doc_tree(node.children[path], doc_name, depth + 1, asset_lookup=asset_lookup)
- if child_text.strip():
- parts.append(child_text)
- else:
- parts.append(f"{item_indent}▸ {level_tag}{title} [Leaf]")
- render_leaf_chunks(parts, node.leaf_content[path], sub_indent, asset_lookup=asset_lookup)
-
- elif render_type == "orphan_child":
- path = cast(str, data)
- title = path.rsplit(" / ", 1)[-1] if " / " in path else path
- level = _infer_level_from_path(path)
- item_indent = indent + " " * max(level - 1, 0)
- level_tag = f"[L{level}] " if level else ""
- child_text = render_unified_doc_tree(node.children[path], doc_name, depth + 1, asset_lookup=asset_lookup)
- # Only render the orphan heading if the child has content.
- # Prevents empty orphan nodes from polluting evidence_text.
- if child_text.strip():
- parts.append(f"{item_indent}▸ {level_tag}{title}")
- parts.append(child_text)
-
- return "\n".join(parts)
-
-
-def render_leaf_chunks(
- parts: list[str],
- chunks: list[dict[str, Any]],
- indent: str,
- asset_lookup: dict[str, AssetLookupValue] | None = None,
- suppress_page_summary: bool = False,
-) -> None:
- chunk_by_id = {
- chunk.get("chunk_id", ""): chunk
- for chunk in chunks
- if chunk.get("chunk_id")
- }
- rendered_ids: set[str] = set()
-
- for chunk in chunks:
- chunk_id = chunk.get("chunk_id", "")
- if chunk_id and chunk_id in rendered_ids:
- continue
-
- chunk_type = (chunk.get("chunk_type") or chunk.get("type") or "text").strip().lower()
- if chunk_type in ("image", "table"):
- continue
-
- if chunk_id:
- rendered_ids.add(chunk_id)
-
- if chunk_type == "page":
- render_page_chunk_lines(
- parts, chunk, indent,
- asset_lookup=asset_lookup,
- suppress_summary=suppress_page_summary,
- )
- continue
-
- content = str(chunk.get("content", "")).strip()
- for connection in (chunk.get("chunk_metadata") or {}).get("connect_to") or []:
- target = chunk_by_id.get(connection.get("target", ""))
- if not target:
- continue
- target_id = target.get("chunk_id", "")
- target_type = (target.get("chunk_type") or target.get("type") or "").strip().lower()
- ref_str = connection.get("ref", "")
- if not ref_str or ref_str not in content:
- continue
-
- if target_id:
- rendered_ids.add(target_id)
-
- if target_type == "table":
- file_path = target.get("file_path") or ""
- asset_url = _lookup_asset_url(asset_lookup, target_id)
- display_ref = asset_url or file_path
- table_lines = render_table_chunk_lines(
- target,
- display_ref=display_ref,
- chunk_by_id=chunk_by_id,
- asset_lookup=asset_lookup,
- rendered_ids=rendered_ids,
- )
- content = content.replace(ref_str, "\n" + "\n".join(table_lines) + "\n")
- elif target_type == "image":
- file_path = target.get("file_path") or ""
- image_description = str(target.get("content", "")).strip()
- if ref_str in image_description:
- image_description = image_description.replace(ref_str, "").strip()
- asset_url = _lookup_asset_url(asset_lookup, target_id)
- display_ref = asset_url or file_path
- if display_ref:
- content = content.replace(ref_str, f"\n[Image: {display_ref}]\n{image_description}\n")
- elif image_description:
- content = content.replace(ref_str, f"\n[Image description]\n{image_description}\n")
-
- for line in content.split("\n"):
- if line.strip():
- parts.append(f"{indent}┈ {line}")
-
- for chunk in chunks:
- chunk_id = chunk.get("chunk_id", "")
- if chunk_id and chunk_id in rendered_ids:
- continue
- if chunk_id:
- rendered_ids.add(chunk_id)
-
- chunk_type = (chunk.get("chunk_type") or chunk.get("type") or "").strip().lower()
- if chunk_type == "image":
- file_path = chunk.get("file_path") or ""
- image_description = str(chunk.get("content", "")).strip()
- asset_url = _lookup_asset_url(asset_lookup, chunk_id)
- display_ref = asset_url or file_path
- if display_ref:
- parts.append(f"{indent}┈ [Image: {display_ref}]")
- if image_description:
- for line in image_description.split("\n"):
- if line.strip():
- parts.append(f"{indent}┈ {line}")
- elif chunk_type == "table":
- file_path = chunk.get("file_path") or ""
- asset_url = _lookup_asset_url(asset_lookup, chunk_id)
- display_ref = asset_url or file_path
- for line in render_table_chunk_lines(
- chunk,
- display_ref=display_ref,
- chunk_by_id=chunk_by_id,
- asset_lookup=asset_lookup,
- rendered_ids=rendered_ids,
- ):
- if line.strip():
- parts.append(f"{indent}┈ {line}")
-
-
-def render_page_chunk_lines(
- parts: list[str],
- chunk: dict[str, Any],
- indent: str,
- asset_lookup: dict[str, AssetLookupValue] | None = None,
- suppress_summary: bool = False,
-) -> None:
- metadata = chunk.get("chunk_metadata") or chunk.get("metadata") or {}
- summary = str(metadata.get("summary") or chunk.get("summary") or "").strip()
- page_nums = _coerce_page_nums(
- metadata.get("page_nums") or chunk.get("page_nums")
- )
- if page_nums:
- parts.append(f"{indent}┈ {_format_page_range(page_nums).capitalize()}")
- if summary and not suppress_summary:
- for line in summary.split("\n"):
- if line.strip():
- parts.append(f"{indent}┈ {line}")
- elif not page_nums:
- parts.append(f"{indent}┈ [Page]")
-
- url = _asset_url_for_chunk(chunk, asset_lookup=asset_lookup)
- if url:
- parts.append(f"{indent}┈ [Page PDF ({_format_page_range(page_nums)}): {url}]")
-
-
-def render_table_chunk_lines(
- chunk: dict[str, Any],
- *,
- display_ref: str,
- chunk_by_id: dict[str, dict[str, Any]] | None = None,
- asset_lookup: dict[str, AssetLookupValue] | None = None,
- rendered_ids: set[str] | None = None,
-) -> list[str]:
- header = f"[Table: {display_ref}]" if display_ref else "[Table]"
- lines = [header]
- table_path = chunk.get("source_chunk_path") or chunk.get("section_path")
- if table_path:
- lines.append(f"Table path: {table_path}")
- file_path = chunk.get("file_path")
- if file_path:
- lines.append(f"Table asset: {file_path}")
-
- metadata = chunk.get("chunk_metadata") or chunk.get("metadata") or {}
- summary = metadata.get("summary") if isinstance(metadata, dict) else ""
- if summary:
- lines.append("Table summary:")
- lines.extend(str(summary).split("\n"))
-
- keywords = metadata.get("keywords") if isinstance(metadata, dict) else []
- if isinstance(keywords, list) and keywords:
- keyword_text = ";".join(str(keyword) for keyword in keywords if str(keyword).strip())
- if keyword_text:
- lines.append("Main columns:")
- lines.append(keyword_text)
- elif isinstance(keywords, str) and keywords.strip():
- lines.append("Main columns:")
- lines.append(keywords.strip())
-
- caption = metadata.get("caption") if isinstance(metadata, dict) else ""
- if caption:
- lines.append("Caption:")
- lines.append(str(caption).strip())
-
- lines.extend(
- _render_table_embedded_image_lines(
- chunk,
- chunk_by_id=chunk_by_id or {},
- asset_lookup=asset_lookup,
- rendered_ids=rendered_ids,
- )
- )
- return lines
-
-
-def _render_table_embedded_image_lines(
- table_chunk: dict[str, Any],
- *,
- chunk_by_id: dict[str, dict[str, Any]],
- asset_lookup: dict[str, AssetLookupValue] | None,
- rendered_ids: set[str] | None,
-) -> list[str]:
- metadata = table_chunk.get("chunk_metadata") or table_chunk.get("metadata") or {}
- if not isinstance(metadata, dict):
- return []
-
- lines: list[str] = []
- for connection in metadata.get("connect_to") or []:
- if not isinstance(connection, dict):
- continue
- if str(connection.get("relation") or "").strip() != "embeds":
- continue
- target_id = str(connection.get("target") or "").strip()
- if not target_id:
- continue
- target = chunk_by_id.get(target_id)
- if not target:
- continue
- target_type = (
- target.get("chunk_type") or target.get("type") or ""
- ).strip().lower()
- if target_type != "image":
- continue
- if rendered_ids is not None:
- rendered_ids.add(target_id)
-
- file_path = target.get("file_path") or ""
- asset_url = _lookup_asset_url(asset_lookup, target_id)
- display_ref = asset_url or file_path
- image_description = str(target.get("content") or "").strip()
- ref_str = str(connection.get("ref") or "").strip()
- if ref_str and ref_str in image_description:
- image_description = image_description.replace(ref_str, "").strip()
-
- if display_ref:
- lines.append(f"[Image: {display_ref}]")
- elif image_description:
- lines.append("[Image description]")
- if image_description:
- lines.extend(
- line for line in image_description.split("\n") if line.strip()
- )
- return lines
-
-
-def _asset_url_for_chunk(
- chunk: dict[str, Any],
- *,
- asset_lookup: dict[str, AssetLookupValue] | None,
-) -> str:
- chunk_id = str(chunk.get("chunk_id") or "").strip()
- value = (asset_lookup or {}).get(chunk_id, "") if chunk_id else ""
- return str(value or "").strip()
-
-
-def _lookup_asset_url(
- asset_lookup: dict[str, AssetLookupValue] | None,
- chunk_id: str,
-) -> str:
- if not chunk_id:
- return ""
- return str((asset_lookup or {}).get(chunk_id, "") or "").strip()
-
-
-def _format_page_range(page_nums: list[int]) -> str:
- pages = sorted(set(page_nums))
- if not pages:
- return "pages unknown"
- if len(pages) == 1:
- return f"page {pages[0]}"
- ranges: list[str] = []
- start = pages[0]
- prev = pages[0]
- for page in pages[1:]:
- if page == prev + 1:
- prev = page
- continue
- ranges.append(str(start) if start == prev else f"{start}-{prev}")
- start = prev = page
- ranges.append(str(start) if start == prev else f"{start}-{prev}")
- return f"pages {', '.join(ranges)}"
-
-
-def _coerce_page_nums(value: object) -> list[int]:
- if isinstance(value, list):
- raw_values = value
- elif value is None:
- raw_values = []
- else:
- raw_values = str(value).split(",")
-
- pages: list[int] = []
- for item in raw_values:
- try:
- pages.append(int(str(item).strip()))
- except (TypeError, ValueError):
- continue
- return pages
-
-
-def _infer_level_from_path(path: str) -> int:
- """Infer the section level for an orphan path from its segment count.
-
- Section paths use ``" / "`` as separator with one segment per hierarchy
- level (e.g. ``"安全类 / SJSYJ-SC103 / ... / 表3 ..."`` → level 5).
- This aligns with ``doc_nav.json`` level numbering.
- """
- parts = [p for p in path.split(" / ") if p.strip()]
- return max(len(parts), 1)
-
-
-def _infer_child_sort_order(child: DocTreeNode) -> float:
- """Infer sort position from the child's earliest chunk sort_order.
-
- When an orphan child node has no outline entry, we fall back to the
- minimum ``sort_order`` across all its hydrated chunks so that orphans
- render in document order instead of being appended at the end.
- """
- min_order = float("inf")
- for chunks in child.leaf_content.values():
- for chunk in chunks:
- order = chunk.get("sort_order")
- if order is not None and order < min_order:
- min_order = float(order)
- for grandchild in child.children.values():
- grandchild_order = _infer_child_sort_order(grandchild)
- min_order = min(min_order, grandchild_order)
- return min_order
diff --git a/packages/shared-python/shared/services/retrieval/agentic/navigation/__init__.py b/packages/shared-python/shared/services/retrieval/agentic/navigation/__init__.py
deleted file mode 100644
index 8b1378917..000000000
--- a/packages/shared-python/shared/services/retrieval/agentic/navigation/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/packages/shared-python/shared/services/retrieval/agentic/navigation/actions.py b/packages/shared-python/shared/services/retrieval/agentic/navigation/actions.py
deleted file mode 100644
index 8858056f4..000000000
--- a/packages/shared-python/shared/services/retrieval/agentic/navigation/actions.py
+++ /dev/null
@@ -1,713 +0,0 @@
-"""Legal action projection for agentic document navigation."""
-from __future__ import annotations
-
-from dataclasses import dataclass, field
-from typing import Any, Literal
-
-from shared.services.retrieval.agentic.core.budget import budget_status_from_snapshot
-from shared.services.retrieval.agentic.navigation.path_ledger import PathLedger
-from shared.services.retrieval.agentic.navigation.state import (
- RejectionRecord,
-)
-from shared.services.retrieval.search.lexical_text import normalize_section_path
-from shared.utils.text_utils import truncate_content_preview
-
-
-ActionKind = Literal[
- "EXPAND",
- "COLLECT",
- "BACK",
- "SEARCH_IMAGES",
- "SEARCH_TABLES",
- "FINISH",
-]
-
-
-@dataclass(frozen=True)
-class LegalAction:
- id: str
- action: ActionKind
- path: str | None = None
- target_scope: str | None = None
- asset_type: str | None = None
- note: str | None = None
- source: str = "tree"
- score: float = 0.0
- critical_expand: bool = False
-
-
-@dataclass
-class LegalActionSet:
- by_id: dict[str, LegalAction] = field(default_factory=dict)
- expand: list[LegalAction] = field(default_factory=list)
- collect: list[LegalAction] = field(default_factory=list)
- back: list[LegalAction] = field(default_factory=list)
- search: list[LegalAction] = field(default_factory=list)
- finish: LegalAction | None = None
-
- def add(self, action: LegalAction) -> None:
- self.by_id[action.id] = action
- if action.action == "EXPAND":
- self.expand.append(action)
- elif action.action == "COLLECT":
- self.collect.append(action)
- elif action.action == "BACK":
- self.back.append(action)
- elif action.action in ("SEARCH_IMAGES", "SEARCH_TABLES"):
- self.search.append(action)
- elif action.action == "FINISH":
- self.finish = action
-
- def get(self, action_id: str | None) -> LegalAction | None:
- if not action_id:
- return None
- return self.by_id.get(action_id)
-
-
-def build_legal_actions(
- *,
- items: list[dict[str, Any]],
- current_scope: str | None,
- collected_paths: list[dict[str, Any]],
- expanded_scopes: set[str],
- discovery_hints: list[dict[str, Any]] | None = None,
- rejected: dict[str, RejectionRecord] | None = None,
- total_images: int,
- total_tables: int,
- disabled_asset_types: set[str] | None = None,
- budget_snapshot: dict[str, Any] | None = None,
-) -> LegalActionSet:
- action_set = LegalActionSet()
- covered_paths = _covered_paths(collected_paths)
- outline_paths = _outline_paths(collected_paths)
- budget_mode = budget_status_from_snapshot(budget_snapshot)
- rejection_ledger = rejected or {}
- tool_adjudicated_paths = {
- path for path, record in rejection_ledger.items()
- if record.reason == "tool_adjudicated"
- }
- navigational_abandoned_paths = {
- path for path, record in rejection_ledger.items()
- if record.reason == "navigational_abandon"
- }
- discovery_scores = _discovery_scores_by_path(discovery_hints or [])
- scored_items = _score_items(items, discovery_scores)
- ranked_items = _rank_items(scored_items)
- expand_allowlist = _expand_allowlist(
- ranked_items,
- budget_mode=budget_mode,
- limit=3,
- )
-
- expand_index = 1
- collect_index = 1
- for item in scored_items:
- path = str(item.get("path") or "").strip()
- if not path or path == "Root":
- continue
- normalized_path = PathLedger.normalize(path)
- if PathLedger.is_covered(normalized_path, covered_paths):
- continue
- # tool_adjudicated rejections are content-level negative and are not
- # revived this round (TODO: future strong-signal revival).
- if PathLedger.is_covered(normalized_path, tool_adjudicated_paths):
- continue
-
- action_set.add(LegalAction(
- id=f"C{collect_index}",
- action="COLLECT",
- path=path,
- target_scope=path,
- note=(
- "upgrade outline to full evidence"
- if normalized_path in outline_paths
- else _item_note(item)
- ),
- score=float(item.get("relevance_score") or 0.0),
- ))
- collect_index += 1
-
- critical_expand = False
- if budget_mode == "EXHAUSTED":
- continue
- if budget_mode == "CRITICAL":
- if action_set.collect:
- continue
- critical_expand = True
- if item.get("is_leaf"):
- continue
- if normalized_path == current_scope:
- continue
- if current_scope and PathLedger.is_ancestor(normalized_path, current_scope):
- continue
- if normalized_path in expanded_scopes:
- continue
- if (
- budget_mode == "TIGHT"
- and normalized_path not in expand_allowlist
- ):
- continue
- # EXPAND suppression: navigational_abandon (weak) suppresses unless a
- # discovery / lexical signal revives the path.
- if normalized_path in navigational_abandoned_paths and not _has_discovery_signal(
- normalized_path, discovery_scores
- ):
- continue
-
- action_set.add(LegalAction(
- id=f"E{expand_index}",
- action="EXPAND",
- path=path,
- target_scope=path,
- note=_item_note(item),
- score=float(item.get("relevance_score") or 0.0),
- critical_expand=critical_expand,
- ))
- expand_index += 1
-
- discovery_index = 1
- seen_discovery_paths: set[str] = set()
- for hint in sorted(
- discovery_hints or [],
- key=lambda item: float(item.get("discovery_score") or 0.0),
- reverse=True,
- ):
- path = normalize_section_path(str(hint.get("section_path") or ""))
- if not path or path in seen_discovery_paths:
- continue
- seen_discovery_paths.add(path)
- if PathLedger.is_covered(path, covered_paths):
- continue
- # tool_adjudicated rejections are not revived by discovery this round.
- # TODO: allow tool-specific LLM adjudicators to revive rejected
- # collects when validity cannot be determined structurally.
- if PathLedger.is_covered(path, tool_adjudicated_paths):
- continue
- if any(action.path == path for action in action_set.collect):
- continue
- action_set.add(LegalAction(
- id=f"D{discovery_index}",
- action="COLLECT",
- path=path,
- target_scope=path,
- note=_discovery_note(hint),
- source="discovery",
- score=float(hint.get("discovery_score") or 0.0),
- ))
- discovery_index += 1
-
- search_allowed = budget_mode not in ("CRITICAL", "EXHAUSTED")
- disabled_assets = {item.lower() for item in disabled_asset_types or set()}
- if (
- search_allowed
- and "image" not in disabled_assets
- and total_images > 0
- and _asset_search_worthwhile(ranked_items, "image")
- ):
- action_set.add(LegalAction(
- id="S1",
- action="SEARCH_IMAGES",
- asset_type="image",
- note=f"{total_images} images available in current scope",
- ))
- if (
- search_allowed
- and "table" not in disabled_assets
- and total_tables > 0
- and _asset_search_worthwhile(ranked_items, "table")
- ):
- action_set.add(LegalAction(
- id="S2",
- action="SEARCH_TABLES",
- asset_type="table",
- note=f"{total_tables} tables available in current scope",
- ))
-
- if current_scope and budget_mode != "EXHAUSTED":
- back_index = 1
- for target in PathLedger.back_targets(current_scope):
- label = target if target is not None else "root"
- action_set.add(LegalAction(
- id=f"B{back_index}",
- action="BACK",
- path=target,
- target_scope=target,
- note=f"return to {label}",
- ))
- back_index += 1
-
- action_set.add(LegalAction(
- id="F1",
- action="FINISH",
- note="finish this document",
- ))
- return action_set
-
-
-def format_agent_state_block(
- *,
- current_scope: str | None,
- query_intent: str,
- expanded_scopes: set[str],
- rejected: dict[str, RejectionRecord],
- collected_paths: list[dict[str, Any]],
- prior_tool_result: dict[str, Any] | None,
- search_context: str,
- budget_snapshot: dict[str, Any] | None,
-) -> str:
- lines = [
- "=== Agent State ===",
- f"Current scope: {current_scope or 'root'}",
- f"Advisory query intent: {query_intent or 'UNKNOWN'}",
- _format_budget_state(budget_snapshot),
- ]
- budget_mode = budget_status_from_snapshot(budget_snapshot)
- if budget_mode == "CRITICAL":
- lines.append(
- "Budget policy: exploration actions are closed; collect the best visible "
- "evidence before FINISH."
- )
- elif budget_mode == "EXHAUSTED":
- lines.append(
- "Budget policy: planning budget is exhausted or in overdraft. Do not "
- "explore or search again. Use the current observation and tool results "
- "to decide FINISH, or collect only indispensable visible evidence."
- )
- if expanded_scopes:
- lines.append("Expanded scopes:")
- for path in sorted(expanded_scopes):
- lines.append(f' - "{path}"')
- else:
- lines.append("Expanded scopes: none")
-
- navigational_abandoned = sorted(
- path for path, record in rejected.items()
- if record.reason == "navigational_abandon"
- )
- tool_adjudicated = sorted(
- path for path, record in rejected.items()
- if record.reason == "tool_adjudicated"
- )
- if navigational_abandoned:
- lines.append(
- "Scopes avoided (soft; revived by discovery):"
- )
- for path in navigational_abandoned:
- lines.append(f' - "{path}"')
- if tool_adjudicated:
- lines.append(
- "Collects rejected by tool reconciliation (content-level; not revived):"
- )
- for path in tool_adjudicated:
- lines.append(f' - "{path}"')
-
- full_paths, outline_paths = _dedupe_collection_modes(collected_paths)
- if full_paths or outline_paths:
- if full_paths:
- lines.append(f"Full evidence collected: {len(full_paths)} item(s)")
- for path in full_paths:
- lines.append(f' - "{path}"')
- else:
- lines.append("Full evidence collected: none")
- if outline_paths:
- lines.append(
- f"Outline-only evidence: {len(outline_paths)} item(s) "
- "(structure only; not hydrated as full chunks)"
- )
- for path in outline_paths:
- lines.append(f' - "{path}"')
- else:
- lines.append("Collected evidence: none")
-
- if prior_tool_result:
- lines.append(f"Last tool result: {_compact_dict(prior_tool_result)}")
- if search_context:
- lines.append("Tool observation:")
- lines.append(search_context.strip())
- lines.append("=== End Agent State ===")
- return "\n".join(lines)
-
-
-def format_actionable_observation(
- *,
- items: list[dict[str, Any]],
- action_set: LegalActionSet,
- max_chars: int = 20000,
-) -> tuple[str, bool]:
- """Render visible document state and legal action affordances once."""
- if not items:
- return "(no visible sections)", False
-
- full_text = _render_actionable_items(
- items=items,
- action_set=action_set,
- include_summary=True,
- )
- if len(full_text) <= max_chars:
- return full_text, False
-
- slim_text = _render_actionable_items(
- items=items,
- action_set=action_set,
- include_summary=False,
- )
- return slim_text[:max_chars], True
-
-
-def _render_actionable_items(
- *,
- items: list[dict[str, Any]],
- action_set: LegalActionSet,
- include_summary: bool,
-) -> str:
- collect_by_path = {
- action.path: action
- for action in action_set.collect
- if action.path
- }
- expand_by_path = {
- action.path: action
- for action in action_set.expand
- if action.path
- }
- lines = [
- "=== Actionable Observation ===",
- "Each visible section appears once. Choose action IDs attached to the relevant line.",
- ]
- for item in items:
- lines.extend(_render_actionable_item(
- item=item,
- collect_action=collect_by_path.get(str(item.get("path") or "")),
- expand_action=expand_by_path.get(str(item.get("path") or "")),
- include_summary=include_summary,
- ))
-
- discovery_lines = _format_discovery_actions(action_set)
- if discovery_lines:
- lines.append("")
- lines.append("Discovery hints:")
- lines.extend(discovery_lines)
-
- global_actions = _format_global_actions(action_set)
- if global_actions:
- lines.append("")
- lines.append("Global actions:")
- lines.extend(global_actions)
- lines.append("=== End Actionable Observation ===")
- return "\n".join(lines)
-
-
-def _render_actionable_item(
- *,
- item: dict[str, Any],
- collect_action: LegalAction | None,
- expand_action: LegalAction | None,
- include_summary: bool,
-) -> list[str]:
- level = int(item.get("level", 1) or 1)
- show_summary = bool(item.get("show_summary", True))
- has_actions = collect_action is not None or expand_action is not None
- show_details = show_summary or has_actions
- path = str(item.get("path") or "")
- summary = str(item.get("summary") or "")
- is_leaf = bool(item.get("is_leaf", False))
- indent = " " * max(level - 1, 0)
- prefix = "▸" if level == 1 else "└"
- level_tag = f"depth={level}"
- counts = _format_counts(item) if show_details else ""
- tokens = _format_token_estimate(item) if show_details else ""
- leaf = " [Leaf]" if is_leaf else ""
- actions = _format_node_actions(
- collect_action=collect_action,
- expand_action=expand_action,
- )
-
- lines = [
- f'{indent}{prefix} {level_tag} path="{path}"{counts}{tokens}{leaf} actions: {actions}'
- ]
- if include_summary and show_details and summary:
- display_summary = _enrich_section_covers_summary(summary)
- clipped = truncate_content_preview(display_summary, head=120, tail=0)
- lines.append(f"{indent} summary: {clipped}")
- return lines
-
-
-def _format_node_actions(
- *,
- collect_action: LegalAction | None,
- expand_action: LegalAction | None,
-) -> str:
- actions: list[str] = []
- if collect_action:
- collect_name = (
- "collect_full"
- if collect_action.note == "upgrade outline to full evidence"
- else "collect"
- )
- actions.append(f"{collect_name}={collect_action.id}")
- if expand_action:
- actions.append(f"expand={expand_action.id}")
- return ", ".join(actions) if actions else "none"
-
-
-def _format_discovery_actions(action_set: LegalActionSet) -> list[str]:
- lines: list[str] = []
- for action in action_set.collect:
- if action.source != "discovery" or not action.path:
- continue
- note = f" | {action.note}" if action.note else ""
- lines.append(f' {action.id} -> "{action.path}"{note}')
- return lines
-
-
-def _format_global_actions(action_set: LegalActionSet) -> list[str]:
- lines: list[str] = []
- for action in action_set.search:
- if action.action == "SEARCH_IMAGES":
- lines.append(f" search_images={action.id} ({action.note})")
- elif action.action == "SEARCH_TABLES":
- lines.append(f" search_tables={action.id} ({action.note})")
- for action in action_set.back:
- target = action.target_scope or "root"
- lines.append(f" back={action.id} -> {target}")
- if action_set.finish:
- lines.append(f" finish={action_set.finish.id}")
- return lines
-
-
-def _format_counts(item: dict[str, Any]) -> str:
- parts: list[str] = []
- chunk_count = int(item.get("chunk_count") or 0)
- image_count = int(item.get("image_count") or 0)
- table_count = int(item.get("table_count") or 0)
- if chunk_count:
- parts.append(f"text={chunk_count}")
- if image_count:
- parts.append(f"image={image_count}")
- if table_count:
- parts.append(f"table={table_count}")
- return f' [{" ".join(parts)}]' if parts else ""
-
-
-def _format_token_estimate(item: dict[str, Any]) -> str:
- total_chars = int(item.get("total_chars") or 0)
- if total_chars <= 0:
- return ""
- tokens = total_chars / 2
- if tokens >= 1000:
- return f" ~{tokens / 1000:.1f}k tokens"
- return f" ~{int(tokens)} tokens"
-
-
-def _enrich_section_covers_summary(summary: str) -> str:
- prefix = "This section covers: "
- if not summary.startswith(prefix):
- return summary
- body = summary[len(prefix):]
- sub_sections = [s.strip() for s in body.split(", ") if s.strip()]
- return f"This section covers {len(sub_sections)} sub-sections: {body}"
-
-
-def _covered_paths(collected_paths: list[dict[str, Any]]) -> set[str]:
- return {
- PathLedger.normalize(str(item.get("path") or ""))
- for item in collected_paths
- if item.get("path") and item.get("hydrate_mode") != "outline"
- }
-
-
-def _outline_paths(collected_paths: list[dict[str, Any]]) -> set[str]:
- return {
- str(item.get("path") or "")
- for item in collected_paths
- if item.get("path") and item.get("hydrate_mode") == "outline"
- }
-
-
-def _dedupe_collection_modes(
- collected_paths: list[dict[str, Any]],
-) -> tuple[list[str], list[str]]:
- full: set[str] = set()
- outline: set[str] = set()
- for item in collected_paths:
- path = str(item.get("path") or "")
- if not path:
- continue
- if item.get("hydrate_mode") == "outline":
- outline.add(path)
- else:
- full.add(path)
- outline -= full
- return sorted(full), sorted(outline)
-
-
-def _discovery_note(hint: dict[str, Any]) -> str | None:
- summary = str(hint.get("summary") or "").strip()
- score = float(hint.get("discovery_score") or 0.0)
- score_note = f"score={score:.2f}" if score > 0 else ""
- if summary:
- clipped = truncate_content_preview(summary, head=120, tail=0)
- return f"{clipped} {score_note}".strip()
- chunk_type = str(hint.get("chunk_type") or "").strip()
- if chunk_type:
- return f"bottom-discovery hit type={chunk_type} {score_note}".strip()
- return f"bottom-discovery hit {score_note}".strip()
-
-
-def _format_budget_state(snapshot: dict[str, Any] | None) -> str:
- if not isinstance(snapshot, dict):
- return "Budget mode: UNKNOWN"
- planning = snapshot.get("planning")
- if not isinstance(planning, dict):
- return "Budget mode: UNKNOWN"
- status = str(planning.get("status") or "UNKNOWN")
- used_pct = planning.get("used_pct")
- remaining = planning.get("remaining")
- capacity = planning.get("capacity")
- overdraft = int(planning.get("overdraft") or 0)
- overdraft_note = f", overdraft={overdraft}" if overdraft > 0 else ""
- if used_pct is None:
- return f"Budget mode: {status}"
- if remaining is not None and capacity:
- return (
- f"Budget mode: {status} ({used_pct}% used, "
- f"{remaining}/{capacity} tokens remaining{overdraft_note})"
- )
- return f"Budget mode: {status} ({used_pct}% used{overdraft_note})"
-
-
-def _item_note(item: dict[str, Any]) -> str | None:
- parts: list[str] = []
- chunk_count = int(item.get("chunk_count") or 0)
- image_count = int(item.get("image_count") or 0)
- table_count = int(item.get("table_count") or 0)
- if chunk_count:
- parts.append(f"text={chunk_count}")
- if image_count:
- parts.append(f"image={image_count}")
- if table_count:
- parts.append(f"table={table_count}")
- score = float(item.get("relevance_score") or 0.0)
- if score > 0:
- parts.append(f"relevance={score:.2f}")
- if item.get("is_leaf"):
- parts.append("leaf")
- return " ".join(parts) if parts else None
-
-
-def _discovery_scores_by_path(
- discovery_hints: list[dict[str, Any]],
-) -> dict[str, float]:
- scores: dict[str, float] = {}
- for hint in discovery_hints:
- path = normalize_section_path(str(hint.get("section_path") or ""))
- if not path:
- continue
- score = float(hint.get("discovery_score") or 0.0)
- scores[path] = max(scores.get(path, 0.0), score)
- return scores
-
-
-def _score_items(
- items: list[dict[str, Any]],
- discovery_scores: dict[str, float],
-) -> list[dict[str, Any]]:
- scored: list[dict[str, Any]] = []
- for index, item in enumerate(items):
- copied = dict(item)
- copied["_original_index"] = index
- copied["relevance_score"] = _score_item(copied, discovery_scores)
- scored.append(copied)
- return scored
-
-
-def _rank_items(scored_items: list[dict[str, Any]]) -> list[dict[str, Any]]:
- return sorted(
- scored_items,
- key=lambda item: (
- float(item.get("relevance_score") or 0.0),
- int(item.get("chunk_count") or 0),
- int(item.get("table_count") or 0) + int(item.get("image_count") or 0),
- -int(item.get("_original_index") or 0),
- ),
- reverse=True,
- )
-
-
-def _score_item(
- item: dict[str, Any],
- discovery_scores: dict[str, float],
-) -> float:
- path = normalize_section_path(str(item.get("path") or ""))
- if not path:
- return 0.0
- score = discovery_scores.get(path, 0.0)
- for hint_path, hint_score in discovery_scores.items():
- if PathLedger.is_ancestor(path, hint_path):
- score = max(score, float(hint_score) * 0.9)
- elif PathLedger.is_ancestor(hint_path, path):
- score = max(score, float(hint_score) * 0.65)
- return min(score, 1.0)
-
-
-def _expand_allowlist(
- ranked_items: list[dict[str, Any]],
- *,
- budget_mode: str,
- limit: int,
-) -> set[str]:
- if budget_mode != "TIGHT":
- return {
- normalize_section_path(str(item.get("path") or ""))
- for item in ranked_items
- if item.get("path")
- }
- candidates = [
- normalize_section_path(str(item.get("path") or ""))
- for item in ranked_items
- if item.get("path")
- and not item.get("is_leaf")
- ]
- return set(candidates[:limit])
-
-
-def _has_discovery_signal(
- path: str,
- discovery_scores: dict[str, float],
-) -> bool:
- """A path has a discovery signal if it, an ancestor, or a descendant appears in discovery."""
- return any(
- candidate == path
- or PathLedger.is_ancestor(path, candidate)
- or PathLedger.is_ancestor(candidate, path)
- for candidate in discovery_scores
- )
-
-
-def _asset_search_worthwhile(
- ranked_items: list[dict[str, Any]],
- asset_kind: Literal["image", "table"],
-) -> bool:
- count_key = "image_count" if asset_kind == "image" else "table_count"
- return any(int(item.get(count_key) or 0) > 0 for item in ranked_items[:5])
-
-
-def _compact_dict(value: dict[str, Any]) -> str:
- bits: list[str] = []
- for key in ("tool", "status", "matched", "candidate_count", "status_detail"):
- if key in value:
- bits.append(f"{key}={value[key]}")
- budget = value.get("budget")
- if isinstance(budget, dict):
- delta = budget.get("delta")
- after = budget.get("after")
- if isinstance(delta, dict):
- bits.append(
- "budget_delta="
- f"used:{delta.get('used', 0)}, "
- f"used_pct:{delta.get('used_pct', 0)}, "
- f"overdraft:{delta.get('overdraft', 0)}"
- )
- if isinstance(after, dict) and int(after.get("overdraft") or 0) > 0:
- bits.append(f"budget_overdraft={after.get('overdraft')}")
- return ", ".join(bits) if bits else str(value)
diff --git a/packages/shared-python/shared/services/retrieval/agentic/navigation/assets.py b/packages/shared-python/shared/services/retrieval/agentic/navigation/assets.py
deleted file mode 100644
index 038f70001..000000000
--- a/packages/shared-python/shared/services/retrieval/agentic/navigation/assets.py
+++ /dev/null
@@ -1,818 +0,0 @@
-from __future__ import annotations
-
-import time
-from typing import Any
-
-from loguru import logger
-from sqlalchemy import func as sa_func
-from sqlalchemy import or_, select
-from sqlalchemy.ext.asyncio import AsyncSession
-
-from shared.models.database.document import DocumentChunk, DocumentSection
-from shared.models.database.job_result import JobResult
-from shared.services.retrieval.agentic.core.budget import BudgetExceeded
-from shared.services.retrieval.hydration.assets import build_retrieval_asset_url_map
-from shared.services.retrieval.llm_adapter import LLMFn
-from shared.utils.token_estimate import estimate_tokens
-
-
-def build_connected_owner_map(text_chunks: list[dict[str, Any]]) -> dict[str, str]:
- owner_map: dict[str, str] = {}
- for chunk in text_chunks:
- if (chunk.get("chunk_type") or "text") != "text":
- continue
- section_path = chunk.get("section_path") or ""
- if not section_path:
- continue
- metadata = chunk.get("chunk_metadata") or {}
- if not isinstance(metadata, dict):
- continue
- for conn in metadata.get("connect_to") or []:
- if not isinstance(conn, dict):
- continue
- target_id = str(conn.get("target") or "").strip()
- if target_id and target_id not in owner_map:
- owner_map[target_id] = section_path
- return owner_map
-
-
-async def _load_scope_sections(
- db: AsyncSession,
- *,
- document_id: str,
- job_result_id: str,
- scope_paths: list[str],
-) -> list[tuple[str, str]]:
- section_stmt = (
- select(DocumentSection.section_id, DocumentSection.section_path)
- .where(DocumentSection.document_id == document_id)
- .where(DocumentSection.job_result_id == job_result_id)
- )
- if scope_paths:
- scope_filters = []
- for scope in scope_paths:
- scope_filters.append(DocumentSection.section_path == scope)
- scope_filters.append(DocumentSection.section_path.like(f"{scope} / %"))
- section_stmt = section_stmt.where(or_(*scope_filters))
- rows = (await db.execute(section_stmt)).all()
- return [(section_id, section_path or "") for section_id, section_path in rows]
-
-
-async def count_assets_under_scope(
- db: AsyncSession,
- *,
- document_id: str,
- job_result_id: str,
- scope_paths: list[str],
-) -> tuple[int, int]:
- section_rows = await _load_scope_sections(
- db,
- document_id=document_id,
- job_result_id=job_result_id,
- scope_paths=scope_paths,
- )
- all_section_ids = [section_id for section_id, _section_path in section_rows]
-
- if not all_section_ids:
- return 0, 0
-
- count_stmt = (
- select(
- DocumentChunk.chunk_type,
- sa_func.count(DocumentChunk.id),
- )
- .where(DocumentChunk.document_id == document_id)
- .where(DocumentChunk.job_result_id == job_result_id)
- .where(DocumentChunk.section_id.in_(all_section_ids))
- .where(DocumentChunk.chunk_type.in_(["image", "table"]))
- .group_by(DocumentChunk.chunk_type)
- )
- count_result = await db.execute(count_stmt)
-
- total_images = 0
- total_tables = 0
- for chunk_type, count in count_result.all():
- if chunk_type == "image":
- total_images = count
- elif chunk_type == "table":
- total_tables = count
- return total_images, total_tables
-
-
-async def resolve_root_asset_owners(
- db: AsyncSession,
- *,
- document_id: str,
- job_result_id: str,
- chunks: list[dict[str, Any]],
-) -> dict[str, str]:
- root_asset_ids = [
- str(chunk.get("chunk_id") or "")
- for chunk in chunks
- if not chunk.get("owner_section_path")
- and (chunk.get("section_path") or "") == "Root"
- and (chunk.get("chunk_type") or "").lower() in ("image", "table")
- and chunk.get("chunk_id")
- ]
- if not root_asset_ids:
- return {}
-
- root_asset_set = set(root_asset_ids)
- text_stmt = (
- select(
- DocumentChunk.chunk_metadata,
- DocumentSection.section_path,
- )
- .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_type == "text")
- )
- result = await db.execute(text_stmt)
-
- owner_map: dict[str, str] = {}
- for metadata, section_path in result.all():
- if not isinstance(metadata, dict) or not section_path:
- continue
- for conn in metadata.get("connect_to") or []:
- if not isinstance(conn, dict):
- continue
- target_id = str(conn.get("target") or "").strip()
- if target_id in root_asset_set and target_id not in owner_map:
- owner_map[target_id] = section_path
-
- if owner_map:
- logger.info(
- f" resolve_root_asset_owners: resolved {len(owner_map)}/{len(root_asset_ids)} "
- f"Root assets to their owner sections"
- )
- return owner_map
-
-
-async def asset_filter_step(
- db: AsyncSession,
- *,
- document_id: str,
- job_result_id: str,
- scope_path: str | list[str] | None,
- asset_type: str,
-) -> list[dict[str, Any]]:
- t0 = time.monotonic()
- try:
- scope_list = (
- scope_path
- if isinstance(scope_path, list)
- else [scope_path]
- if scope_path
- else []
- )
-
- section_rows = await _load_scope_sections(
- db,
- document_id=document_id,
- job_result_id=job_result_id,
- scope_paths=scope_list,
- )
- section_ids = {row[0] for row in section_rows}
-
- if not section_ids:
- logger.info(f" asset_filter_step: no sections found under scope={scope_path}")
- return []
-
- section_path_by_id = {
- section_id: section_path for section_id, section_path in section_rows
- }
- asset_rows = (
- await db.execute(
- select(
- DocumentChunk.chunk_id,
- DocumentChunk.chunk_type,
- DocumentChunk.content,
- DocumentChunk.file_path,
- DocumentChunk.section_id,
- DocumentChunk.source_chunk_path,
- DocumentChunk.chunk_metadata,
- DocumentChunk.sort_order,
- DocumentChunk.job_result_id,
- )
- .where(DocumentChunk.document_id == document_id)
- .where(DocumentChunk.job_result_id == job_result_id)
- .where(DocumentChunk.section_id.in_(list(section_ids)))
- .where(DocumentChunk.chunk_type == asset_type)
- .order_by(DocumentChunk.sort_order)
- )
- ).all()
-
- text_rows = (
- await db.execute(
- select(
- DocumentChunk.section_id,
- DocumentChunk.chunk_type,
- DocumentChunk.chunk_metadata,
- DocumentChunk.source_chunk_path,
- )
- .where(DocumentChunk.document_id == document_id)
- .where(DocumentChunk.job_result_id == job_result_id)
- .where(DocumentChunk.section_id.in_(list(section_ids)))
- .where(DocumentChunk.chunk_type == "text")
- )
- ).all()
- text_row_dicts = [
- {
- "chunk_type": chunk_type,
- "chunk_metadata": metadata or {},
- "section_id": section_id,
- "section_path": section_path_by_id.get(section_id, ""),
- "source_chunk_path": source_chunk_path,
- }
- for section_id, chunk_type, metadata, source_chunk_path in text_rows
- ]
- owner_by_target_id = build_connected_owner_map(text_row_dicts)
-
- connected_target_ids: set[str] = set(owner_by_target_id.keys())
- if connected_target_ids:
- connected_rows = (
- await db.execute(
- select(
- DocumentChunk.chunk_id,
- DocumentChunk.chunk_type,
- DocumentChunk.content,
- DocumentChunk.file_path,
- DocumentChunk.section_id,
- DocumentChunk.source_chunk_path,
- DocumentChunk.chunk_metadata,
- DocumentChunk.sort_order,
- DocumentChunk.job_result_id,
- )
- .where(DocumentChunk.document_id == document_id)
- .where(DocumentChunk.job_result_id == job_result_id)
- .where(DocumentChunk.chunk_id.in_(list(connected_target_ids)))
- .where(DocumentChunk.chunk_type == asset_type)
- .order_by(DocumentChunk.sort_order)
- )
- ).all()
- else:
- connected_rows = []
-
- job_id = (
- await db.execute(select(JobResult.job_id).where(JobResult.id == job_result_id))
- ).scalar() or ""
- seen_ids: set[str] = set()
- chunks: list[dict[str, Any]] = []
- for row in list(asset_rows) + list(connected_rows):
- chunk_id = row[0]
- if chunk_id in seen_ids:
- continue
- seen_ids.add(chunk_id)
-
- owner_section_path = owner_by_target_id.get(chunk_id)
- if not owner_section_path:
- own_section_path = section_path_by_id.get(row[4])
- if own_section_path and own_section_path == "Root":
- logger.warning(
- " asset_filter_step: rejecting root-level owner fallback "
- f"chunk_id={chunk_id} section_path={own_section_path}"
- )
- own_section_path = None
- owner_section_path = own_section_path
-
- if not owner_section_path:
- logger.warning(
- f" asset_filter_step unresolved owner: chunk_id={chunk_id} "
- f"file_path={row[3]} scope={scope_path or 'root'}"
- )
- continue
-
- chunks.append(
- {
- "document_id": document_id,
- "chunk_id": chunk_id,
- "chunk_type": row[1],
- "content": row[2],
- "file_path": row[3],
- "section_id": row[4],
- "section_path": owner_section_path,
- "owner_section_path": owner_section_path,
- "source_chunk_path": row[5],
- "chunk_metadata": row[6] or {},
- "sort_order": row[7],
- "job_result_id": job_result_id,
- "job_id": job_id,
- }
- )
-
- latency = int((time.monotonic() - t0) * 1000)
- logger.info(
- f" asset_filter_step scope={scope_path or 'root'} "
- f"type={asset_type}: {len(chunks)} chunks found, {latency}ms"
- )
- return chunks
-
- except Exception as exc:
- logger.error(f" asset_filter_step failed: {exc}")
- return []
-
-
-async def search_assets_step(
- db: AsyncSession,
- *,
- document_id: str,
- job_result_id: str,
- scope_path: str | list[str] | None,
- asset_type: str,
- query: str,
- llm_fn: LLMFn,
- vlm_fn: LLMFn | None = None,
-) -> dict[str, Any]:
- """LLM-filtered asset search.
-
- For **tables**: uses text LLM with summary descriptions (unchanged).
- For **images**: generates presigned S3 URLs via
- ``build_retrieval_asset_url_map`` and sends them to the VLM
- (``vlm_fn``) for visual relevance judgment.
- """
- t0 = time.monotonic()
-
- all_assets = await asset_filter_step(
- db,
- document_id=document_id,
- job_result_id=job_result_id,
- scope_path=scope_path,
- asset_type=asset_type,
- )
- if not all_assets:
- logger.info(f" search_assets_step: no {asset_type} assets under scope={scope_path}")
- return {
- "status": "empty",
- "matched_assets": [],
- "verdicts": [],
- "candidate_count": 0,
- }
-
- # Build lookup by chunk_id
- asset_by_id: dict[str, dict[str, Any]] = {}
- for asset in all_assets:
- chunk_id = str(asset.get("chunk_id") or "")
- if chunk_id:
- asset_by_id[chunk_id] = asset
-
- if not asset_by_id:
- return {
- "status": "empty",
- "matched_assets": [],
- "verdicts": [],
- "candidate_count": 0,
- }
-
- status_detail = ""
- status = "empty"
-
- # ── Route by asset type ──────────────────────────────────────────
- if asset_type == "image":
- if vlm_fn is None:
- logger.info(" search_assets_step: VLM unavailable for image search")
- selected_ids = await _search_assets_via_text_llm(
- query=query,
- asset_type=asset_type,
- assets=list(asset_by_id.values()),
- llm_fn=llm_fn,
- )
- status = "fallback_matched" if selected_ids else "fallback_empty"
- status_detail = "vlm_unavailable_text_fallback"
- else:
- selected_ids, vlm_error = await _search_images_via_vlm(
- query=query,
- assets=list(asset_by_id.values()),
- vlm_fn=vlm_fn,
- )
- if vlm_error:
- logger.info(
- " search_assets_step: VLM image search fell back to text "
- f"filter, reason={vlm_error}"
- )
- selected_ids = await _search_assets_via_text_llm(
- query=query,
- asset_type=asset_type,
- assets=list(asset_by_id.values()),
- llm_fn=llm_fn,
- )
- status = "fallback_matched" if selected_ids else "fallback_empty"
- status_detail = "vlm_failed_text_fallback"
- else:
- status = "matched" if selected_ids else "empty"
- else:
- selected_ids = await _search_assets_via_text_llm(
- query=query,
- asset_type=asset_type,
- assets=list(asset_by_id.values()),
- llm_fn=llm_fn,
- )
- status = "matched" if selected_ids else "empty"
-
- selected_id_set = {str(cid) for cid in selected_ids}
- matched_assets = [asset_by_id[cid] for cid in selected_ids if cid in asset_by_id]
- verdicts = [
- _asset_verdict(
- asset,
- relevant=str(asset.get("chunk_id") or "") in selected_id_set,
- reason=(
- _selected_reason(status)
- if str(asset.get("chunk_id") or "") in selected_id_set
- else _not_selected_reason(status)
- ),
- )
- for asset in asset_by_id.values()
- ]
-
- latency = int((time.monotonic() - t0) * 1000)
- logger.info(
- f" search_assets_step query=\"{query}\" type={asset_type}: "
- f"{len(matched_assets)}/{len(all_assets)} assets matched, {latency}ms"
- )
- return {
- "status": status,
- "status_detail": status_detail,
- "matched_assets": matched_assets,
- "verdicts": verdicts,
- "candidate_count": len(asset_by_id),
- "latency_ms": latency,
- }
-
-
-def _asset_verdict(
- asset: dict[str, Any],
- *,
- relevant: bool,
- reason: str,
-) -> dict[str, Any]:
- metadata = asset.get("chunk_metadata") or {}
- summary = metadata.get("summary", "")
- return {
- "chunk_id": asset.get("chunk_id", ""),
- "file_path": asset.get("file_path", ""),
- "section_path": asset.get("owner_section_path") or asset.get("section_path", ""),
- "summary": summary,
- "relevant": relevant,
- "reason": reason,
- }
-
-
-def _selected_reason(status: str) -> str:
- if status.startswith("fallback_"):
- return "selected_by_text_fallback"
- return "selected_by_asset_inspector"
-
-
-def _not_selected_reason(status: str) -> str:
- if status.startswith("fallback_"):
- return "not_selected_by_text_fallback"
- return "not_selected_by_asset_inspector"
-
-
-async def _search_assets_via_text_llm(
- *,
- query: str,
- asset_type: str,
- assets: list[dict[str, Any]],
- llm_fn: LLMFn,
-) -> list[str]:
- """Text-based LLM filtering for table assets."""
- candidates_for_llm, valid_ids, id_to_chunk_id = _project_assets_for_text_filter(
- query=query,
- asset_type=asset_type,
- assets=assets,
- )
-
- prompt = _format_asset_filter_prompt(query, asset_type, candidates_for_llm)
- try:
- response = await llm_fn(prompt)
- selected_ids = _parse_asset_filter_response(response, valid_ids)
- return [
- id_to_chunk_id[row_id]
- for row_id in selected_ids
- if row_id in id_to_chunk_id
- ]
- except BudgetExceeded:
- raise
- except Exception as exc:
- logger.warning(f" _search_assets_via_text_llm failed: {exc}")
- return []
-
-
-def _project_assets_for_text_filter(
- *,
- query: str,
- asset_type: str,
- assets: list[dict[str, Any]],
-) -> tuple[list[dict[str, str]], set[str], dict[str, str]]:
- """Project assets into a prompt-sized text view.
-
- Stable row identifiers are shown to the model. Owner paths stay internal:
- reconciliation and hydration use the original asset rows, not prompt text.
- Descriptive text is reduced only when the complete prompt would exceed the
- navigation planning budget envelope.
- """
- projected: list[dict[str, str]] = []
- valid_ids: set[str] = set()
- id_to_chunk_id: dict[str, str] = {}
- for index, asset in enumerate(assets, start=1):
- chunk_id = str(asset.get("chunk_id") or "")
- if not chunk_id:
- continue
- row_id = f"I{index}" if asset_type == "image" else f"T{index}"
- metadata = asset.get("chunk_metadata") or {}
- summary = str(metadata.get("summary") or "").strip()
- file_path = str(asset.get("file_path") or "")
- content = str(asset.get("content") or "").strip()
- description = summary or (content if asset_type == "table" else "")
- projected.append({
- "id": row_id,
- "file": file_path,
- "desc": description,
- })
- valid_ids.add(row_id)
- id_to_chunk_id[row_id] = chunk_id
-
- if not projected:
- return projected, valid_ids, id_to_chunk_id
-
- prompt = _format_asset_filter_prompt(query, asset_type, projected)
- prompt_budget = _asset_filter_prompt_budget()
- if estimate_tokens(prompt) <= prompt_budget:
- return projected, valid_ids, id_to_chunk_id
-
- structural_prompt = _format_asset_filter_prompt(
- query,
- asset_type,
- [
- {
- "id": item["id"],
- "file": item["file"],
- "desc": "",
- }
- for item in projected
- ],
- )
- structural_tokens = estimate_tokens(structural_prompt)
- desc_budget = max(prompt_budget - structural_tokens, len(projected))
- per_item_desc_tokens = max(desc_budget // len(projected), 1)
- compacted = [
- {
- "id": item["id"],
- "file": item["file"],
- "desc": _fit_text_to_token_budget(item["desc"], per_item_desc_tokens),
- }
- for item in projected
- ]
- return compacted, valid_ids, id_to_chunk_id
-
-
-def _asset_filter_prompt_budget() -> int:
- from shared.services.retrieval.agentic.core.runtime import build_config_from_env
-
- config = build_config_from_env()
- planning_capacity = int(
- max(config.token_budget_total - config.bootstrap_budget, 0)
- * config.planning_ratio
- )
- return max(planning_capacity, 1)
-
-
-def _fit_text_to_token_budget(text: str, token_budget: int) -> str:
- text = text.strip()
- if not text or estimate_tokens(text) <= token_budget:
- return text
- words = text.split()
- if len(words) > 1:
- kept: list[str] = []
- for word in words:
- candidate = " ".join([*kept, word])
- if estimate_tokens(candidate) > token_budget:
- break
- kept.append(word)
- return " ".join(kept).strip()
-
- lo = 0
- hi = len(text)
- best = ""
- while lo <= hi:
- mid = (lo + hi) // 2
- candidate = text[:mid].strip()
- if estimate_tokens(candidate) <= token_budget:
- best = candidate
- lo = mid + 1
- else:
- hi = mid - 1
- return best
-
-
-async def _search_images_via_vlm(
- *,
- query: str,
- assets: list[dict[str, Any]],
- vlm_fn: LLMFn,
-) -> tuple[list[str], str | None]:
- """VLM-based image search with presigned S3 URLs.
-
- Generates presigned URLs for each image asset, builds a multimodal
- prompt with image_url blocks, and asks the VLM to select relevant ones.
- """
- url_map = await build_retrieval_asset_url_map(
- assets, log_context="search_images_vlm",
- )
-
- # Only include images that have valid URLs.
- candidates: list[tuple[str, str, str]] = [] # (row_id, file_path, url)
- valid_ids: set[str] = set()
- id_to_chunk_id: dict[str, str] = {}
- for index, asset in enumerate(assets, start=1):
- chunk_id = str(asset.get("chunk_id") or "")
- url = url_map.get(chunk_id)
- if not url or isinstance(url, list):
- continue
- row_id = f"I{index}"
- file_path = asset.get("file_path") or ""
- candidates.append((row_id, file_path, url))
- valid_ids.add(row_id)
- id_to_chunk_id[row_id] = chunk_id
-
- if not candidates:
- logger.info(" _search_images_via_vlm: no presigned URLs available, skipping")
- return [], "no_presigned_urls"
-
- messages = _format_vlm_image_filter_messages(query, candidates)
- try:
- response = await vlm_fn(messages)
- selected_ids = _parse_asset_filter_response(response, valid_ids)
- return [
- id_to_chunk_id[row_id]
- for row_id in selected_ids
- if row_id in id_to_chunk_id
- ], None
- except BudgetExceeded:
- raise
- except Exception as exc:
- logger.warning(f" _search_images_via_vlm failed: {exc}")
- return [], str(exc)
-
-
-def _format_asset_filter_prompt(
- query: str,
- asset_type: str,
- candidates: list[dict[str, str]],
-) -> str:
- """Build the text LLM prompt for table asset filtering."""
- type_label = "images" if asset_type == "image" else "tables"
- items_text = _format_asset_candidates_table(candidates)
- example_id = candidates[0]["id"] if candidates else ("I1" if asset_type == "image" else "T1")
- return (
- f"You are an asset relevance filter.\n\n"
- f"Original user query: {query}\n\n"
- f"Below are {len(candidates)} {type_label} from a document. "
- f"Select ONLY assets that directly satisfy the user's query.\n\n"
- f"Selection policy:\n"
- f"- Match the requested asset type and the requested subject. "
- f"Being an image/chart/table is not enough.\n"
- f"- Do not select assets only because they belong to the same broad "
- f"domain as the query.\n"
- f"- Do not broaden specific market, instrument, company, metric, or "
- f"entity terms. Neighboring topics are not matches unless the candidate "
- f"explicitly connects them to the requested subject.\n"
- f"- Treat words like \"all\" as all relevant assets, not all visible "
- f"candidates.\n"
- f"- If the file name, summary, or content signal does not "
- f"directly support relevance, leave it out.\n"
- f"- If uncertain, do not select the asset.\n\n"
- f"=== Candidate {type_label.title()} ===\n{items_text}\n=== End ===\n\n"
- f"Return ONLY a JSON array of matching row IDs, e.g.: "
- f'["{example_id}"]\n'
- f"If none are relevant, return an empty array: []\n"
- f"Do not include any explanation."
- )
-
-
-def _format_asset_candidates_table(candidates: list[dict[str, str]]) -> str:
- lines = [
- "| ID | File | Summary / content signal |",
- "|---|---|---|",
- ]
- for candidate in candidates:
- lines.append(
- "| "
- + " | ".join([
- _markdown_cell(candidate.get("id", "")),
- _markdown_cell(candidate.get("file", "")),
- _markdown_cell(candidate.get("desc", "")),
- ])
- + " |"
- )
- return "\n".join(lines)
-
-
-def _markdown_cell(value: str) -> str:
- return (
- str(value or "")
- .replace("\n", " ")
- .replace("\r", " ")
- .replace("|", "\\|")
- .strip()
- )
-
-
-def _format_vlm_image_filter_messages(
- query: str,
- candidates: list[tuple[str, str, str]],
-) -> list[dict[str, Any]]:
- """Build multimodal VLM messages with inline image URLs.
-
- Each candidate is (row_id, file_path, presigned_url).
- The VLM sees the actual images and decides relevance.
- """
- content_parts: list[dict[str, Any]] = [
- {
- "type": "text",
- "text": (
- f"You are an image relevance filter.\n\n"
- f"Original user query: {query}\n\n"
- f"Below are {len(candidates)} images from a document. "
- f"Look at each image and select ONLY images that directly "
- f"satisfy the user's query.\n\n"
- f"Selection policy:\n"
- f"- Match both the requested visual type and requested subject.\n"
- f"- Do not select images only because they are charts or from "
- f"the same broad domain.\n"
- f"- Do not broaden specific market, instrument, company, metric, "
- f"or entity terms. Neighboring topics are not matches unless "
- f"the image explicitly connects them to the requested subject.\n"
- f"- Treat words like \"all\" as all relevant images, not all "
- f"visible candidates.\n"
- f"- If uncertain, do not select the image.\n\n"
- ),
- },
- ]
-
- for row_id, file_path, url in candidates:
- content_parts.append({
- "type": "text",
- "text": f'Image {row_id} file="{file_path}":',
- })
- content_parts.append({
- "type": "image_url",
- "image_url": {"url": url},
- })
-
- content_parts.append({
- "type": "text",
- "text": (
- f"\n\nReturn ONLY a JSON array of matching image row IDs, e.g.: "
- f'["{candidates[0][0]}"]\n'
- f"If none are relevant, return an empty array: []\n"
- f"Do not include any explanation."
- ),
- })
-
- return [{"role": "user", "content": content_parts}]
-
-
-def _parse_asset_filter_response(
- text: str,
- valid_ids: set[str],
-) -> list[str]:
- """Parse LLM response for asset filter and keep valid row IDs."""
- import json
- import re
-
- text = text.strip()
-
- # Try direct JSON parse
- try:
- result = json.loads(text)
- if isinstance(result, list):
- return [str(item) for item in result if str(item) in valid_ids]
- except (ValueError, json.JSONDecodeError):
- pass
-
- # Try extracting from code fence
- fence_match = re.search(r"```(?:json)?\s*\n?(.*?)\n?```", text, re.DOTALL)
- if fence_match:
- try:
- result = json.loads(fence_match.group(1).strip())
- if isinstance(result, list):
- return [str(item) for item in result if str(item) in valid_ids]
- except (ValueError, json.JSONDecodeError):
- pass
-
- # Try finding any JSON array
- bracket_match = re.search(r"\[.*?\]", text, re.DOTALL)
- if bracket_match:
- try:
- result = json.loads(bracket_match.group())
- if isinstance(result, list):
- return [str(item) for item in result if str(item) in valid_ids]
- except (ValueError, json.JSONDecodeError):
- pass
-
- return []
diff --git a/packages/shared-python/shared/services/retrieval/agentic/navigation/document.py b/packages/shared-python/shared/services/retrieval/agentic/navigation/document.py
deleted file mode 100644
index 22d9c2736..000000000
--- a/packages/shared-python/shared/services/retrieval/agentic/navigation/document.py
+++ /dev/null
@@ -1,1337 +0,0 @@
-"""Per-document navigation for agentic retrieval.
-
-Collector Agent architecture
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-The navigation loop uses an observe-act Collector Agent model where each step
-produces one main action and optional collection side effects:
-
-1. **collect**: paths to add to the evidence collection
-2. **action**: EXPAND/BACK/SEARCH_IMAGES/SEARCH_TABLES/FINISH
-
-The ``collected_paths`` list accumulates across all steps. After
-navigation completes (or is interrupted), a single batch hydration
-pass loads content for all collected paths.
-
-Asset collection (images/tables) still runs during navigation so LLM
-tool requests are honoured, but assets are reconciled after hydration.
-"""
-from __future__ import annotations
-
-from typing import Any, cast
-
-from loguru import logger
-from sqlalchemy.ext.asyncio import AsyncSession
-
-from shared.services.retrieval.agentic import tools
-from shared.services.retrieval.agentic.core.budget import BudgetExceeded
-from shared.services.retrieval.agentic.evidence.builder import reconcile_deferred_assets
-from shared.services.retrieval.agentic.core.runtime import AgentLlmBudget
-from shared.services.retrieval.agentic.core.trace import TraceRecorder
-from shared.services.retrieval.agentic.core.types import (
- AgentRunConfig,
- AgentState,
- CandidateDoc,
- DecisionTraceStep,
- DocTreeNode,
- NavigateStepResult,
- ToolResult,
-)
-from shared.services.retrieval.agentic.navigation.selection_hydration import (
- hydrate_path_selections_into_node,
-)
-from shared.services.retrieval.agentic.navigation.path_ledger import PathLedger
-from shared.services.retrieval.agentic.navigation.state import NavigationState
-from shared.services.retrieval.agentic.prompts import (
- QUERY_INTENT_PROMPT,
- parse_query_intent_response,
-)
-from shared.services.retrieval.llm_adapter import LLMFn
-
-
-class DocumentNavigationRunner:
- def __init__(
- self,
- *,
- db: AsyncSession,
- state: AgentState,
- trace: TraceRecorder,
- trace_enabled: bool,
- user_id: str,
- namespace: str,
- query: str,
- config: AgentRunConfig,
- discovery_by_doc: dict[str, list[dict[str, Any]]],
- llm_fn: LLMFn | None,
- llm_budget: AgentLlmBudget,
- disabled_asset_types: set[str] | None = None,
- ) -> None:
- self._db = db
- self._state = state
- self._trace = trace
- self._trace_enabled = trace_enabled
- self._user_id = user_id
- self._namespace = namespace
- self._query = query
- self._config = config
- self._discovery_by_doc = discovery_by_doc
- self._llm_fn = llm_fn
- self._llm_budget = llm_budget
- self._disabled_asset_types = disabled_asset_types or set()
- self._decision_steps: list[dict[str, Any]] = []
-
- @property
- def decision_steps(self) -> list[dict[str, Any]]:
- return list(self._decision_steps)
-
- async def navigate_selected_documents(self) -> None:
- logger.info(
- f" agentic: Phase 2 — navigating {len(self._state.selected_docs)} documents"
- )
- query_intent = await self._classify_query_intent()
- for doc in self._state.selected_docs:
- if self._state.elapsed_ms >= self._config.latency_budget_ms:
- logger.info(" agentic: latency budget hit during Phase 2, stopping")
- break
- await self._navigate_document(doc, query_intent=query_intent)
-
- async def _navigate_document(
- self,
- doc: CandidateDoc,
- *,
- query_intent: str,
- ) -> None:
- job_result_id = self._state.doc_job_map.get(doc.document_id, "")
- if not job_result_id:
- logger.info(f" agentic: skipping doc {doc.document_id} — no job_result_id")
- self._state.ever_explored_doc_ids.add(doc.document_id)
- return
-
- doc_name = doc.source_file_name or self._state.doc_id_to_name.get(doc.document_id, "")
- root = DocTreeNode(scope_path=None)
- doc_pending_assets: list[dict[str, Any]] = []
- from shared.services.retrieval.agentic.navigation.section_tree import (
- load_document_section_rows,
- )
- section_rows = await load_document_section_rows(
- self._db,
- document_id=doc.document_id,
- job_result_id=job_result_id,
- )
-
- # Phase 2A: Collector Agent navigation (summary-only, no content hydration)
- doc_pending_assets, collected_paths = await self._navigate_collector(
- doc=doc,
- root=root,
- doc_name=doc_name,
- job_result_id=job_result_id,
- section_rows=section_rows,
- query_intent=query_intent,
- )
-
- # Phase 2B: Batch hydrate all collected paths
- if collected_paths:
- await self._hydrate_collected(
- doc=doc,
- root=root,
- job_result_id=job_result_id,
- collected_paths=collected_paths,
- section_rows=section_rows,
- )
-
- # Phase 2C: Reconcile assets into hydrated tree
- if doc_pending_assets:
- self._reconcile_pending_assets(
- doc=doc,
- root=root,
- doc_name=doc_name,
- doc_pending_assets=doc_pending_assets,
- )
-
- if doc.document_id in self._state.doc_trees:
- self._state.doc_trees[doc.document_id].merge(root)
- else:
- self._state.doc_trees[doc.document_id] = root
- self._state.ever_explored_doc_ids.add(doc.document_id)
- if self._state.ledger is not None:
- self._state.ledger.mark_explored(docs=1)
-
- async def _navigate_collector(
- self,
- *,
- doc: CandidateDoc,
- root: DocTreeNode,
- doc_name: str,
- job_result_id: str,
- section_rows: list,
- query_intent: str,
- ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
- """Collector Agent navigation loop.
-
- Returns (doc_pending_assets, collected_paths).
- """
- doc_discovery_hints = self._discovery_by_doc.get(doc.document_id, [])
- doc_pending_assets: list[dict[str, Any]] = []
- nav_state = NavigationState(
- document_id=doc.document_id,
- document_name=doc_name,
- job_result_id=job_result_id,
- )
- # Context from SEARCH tools — injected into next navigate prompt
- search_context: str = ""
- prior_tool_result: dict[str, Any] | None = None
-
- exit_reason = "unknown"
- budget_failure: dict[str, Any] | None = None
-
- while nav_state.step_count < self._config.max_nav_steps:
- if self._state.elapsed_ms >= self._config.latency_budget_ms:
- exit_reason = "latency"
- break
- has_tool_context = bool(prior_tool_result or search_context)
- if (
- self._state.ledger
- and self._state.ledger.status("planning") == "EXHAUSTED"
- and not has_tool_context
- ):
- logger.info(" agentic: planning budget exhausted, ending navigation for current doc")
- exit_reason = "budget"
- break
-
- nav_state.step_count += 1
- before_scope = nav_state.current_scope
- expanded_before = set(nav_state.expanded_scopes)
- rejected_before = dict(nav_state.rejected)
- collected_before_count = len(nav_state.collected_paths)
-
- doc_llm_fn = self._llm_budget.for_document(
- cast(LLMFn, self._llm_fn),
- doc_id=doc.document_id,
- step=nav_state.step_count,
- allow_overdraft=has_tool_context,
- overdraft_reason=(
- "report_tool_result_to_main_agent"
- if has_tool_context else ""
- ),
- )
- try:
- nav_result = await tools.navigate_step(
- self._db,
- document_id=doc.document_id,
- job_result_id=job_result_id,
- query=self._query,
- llm_fn=doc_llm_fn,
- user_id=self._user_id,
- namespace=self._namespace,
- doc_name=doc_name,
- scope_path=nav_state.current_scope,
- budget_snapshot=self._state.ledger.snapshot() if self._state.ledger else None,
- nav_trace=nav_state.nav_trace if nav_state.nav_trace else None,
- collected_paths=nav_state.collected_paths,
- expanded_scopes=nav_state.expanded_scopes,
- rejected=nav_state.rejected,
- disabled_asset_types=self._disabled_asset_types | nav_state.blocked_asset_types_for_scope(
- nav_state.current_scope
- ),
- discovery_hints=doc_discovery_hints,
- section_rows=section_rows,
- query_intent=query_intent,
- search_context=search_context,
- prior_tool_result=prior_tool_result,
- )
- except BudgetExceeded as exc:
- budget_failure = getattr(exc, "details", {}) or {}
- logger.info(
- " agentic: planning budget exhausted during navigation "
- f"details={budget_failure}"
- )
- if self._trace_enabled:
- self._trace.record_budget_stop("planning_exhausted")
- exit_reason = "budget"
- break
- self._state.step_count += 1
-
- # Clear previous tool context (consumed by this step's prompt)
- search_context = ""
- prior_tool_result = None
-
- # ── Execute asset tools (SEARCH) ─────────────────────────────
- asset_tool_result = await self._execute_asset_tools(
- doc=doc,
- job_result_id=job_result_id,
- scope=nav_state.current_scope,
- nav_result=nav_result,
- pending_assets=doc_pending_assets,
- parent_step_index=len(self._decision_steps),
- )
- search_context = asset_tool_result.get("context", "")
- prior_tool_result = asset_tool_result.get("summary")
- if (
- prior_tool_result is not None
- and self._should_block_asset_search(
- prior_tool_result, nav_state.current_scope,
- )
- ):
- nav_state.block_asset_search(
- nav_state.current_scope,
- str(prior_tool_result.get("asset_type") or ""),
- )
-
- # Merge outline + confidence into root tree
- _merge_step_node(root, nav_result.node)
-
- requested_collects = list(nav_result.collect)
- collect_reconcile = self._reconcile_collects_after_tool(
- nav_result=nav_result,
- asset_tool_result=asset_tool_result,
- )
- rejected_collects = collect_reconcile["rejected_collects"]
- if rejected_collects:
- nav_result.collect = collect_reconcile["accepted_collects"]
- for path in rejected_collects:
- nav_state.mark_rejected_collect(
- path,
- step=nav_state.step_count,
- detail=collect_reconcile.get("reason", ""),
- )
- logger.info(
- " agentic: tool reconciliation rejected collects: "
- f"{rejected_collects}"
- )
-
- # ── Process COLLECT ──────────────────────────────────────────
- collected_in_step: list[str] = []
- for coll_item in nav_result.collect:
- path = coll_item["path"]
- nav_state.add_collected(
- coll_item,
- step=nav_state.step_count,
- scope_context=nav_state.current_scope,
- )
- collected_in_step.append(path)
- # Outline collections keep children visible — the intent is
- # "see structure, then drill deeper for full content".
- # Coverage / action filtering now derives from collected_paths
- # via the navigation state ledger (no physical exclusion).
-
- # ── Process navigation action ────────────────────────────────
- should_break = False
- if (
- nav_result.action == "EXPAND"
- and nav_result.result_status == "ok"
- and nav_result.drill_into
- ):
- drill_path = nav_result.drill_into
- # Create child node in tree for the drill target
- target_parent = _find_target_node(root, drill_path)
- target_parent.children.setdefault(drill_path, DocTreeNode(scope_path=drill_path))
- nav_state.mark_expanded(drill_path)
- nav_state.current_scope = drill_path
-
- elif nav_result.action == "BACK" and nav_result.result_status == "ok":
- if nav_state.current_scope is None:
- logger.info(" agentic: BACK at root scope, staying at root")
- nav_result.result_status = "invalid_back"
- nav_result.result_note = "already_at_root"
- else:
- back_target = nav_result.back_to # None = root
- if PathLedger.valid_back_target(nav_state.current_scope, back_target):
- nav_state.mark_rejected_if_unproductive(
- nav_state.current_scope,
- step=nav_state.step_count,
- detail="back_from_unproductive_scope",
- )
- nav_state.current_scope = back_target
- else:
- logger.warning(
- f" agentic: invalid back_to='{back_target}' "
- f"from scope='{nav_state.current_scope}'"
- )
- nav_result.result_status = "invalid_back"
- nav_result.result_note = f"invalid_back_target: {back_target}"
-
- elif nav_result.action == "ERROR":
- logger.warning(
- f" agentic: navigation ERROR for doc={doc.document_id}: "
- f"{nav_result.error_reason or nav_result.reason}"
- )
- exit_reason = "error"
- should_break = True
-
- elif nav_result.action == "FINISH" and nav_result.result_status == "ok":
- exit_reason = "llm_finish"
- should_break = True
-
- # ── Build trace entry ────────────────────────────────────────
- state_delta = nav_state.snapshot_delta(
- before_scope=before_scope,
- expanded_before=expanded_before,
- rejected_before=rejected_before,
- collected_before_count=collected_before_count,
- )
- trace_entry: dict[str, Any] = {
- "step": nav_state.step_count,
- "scope": before_scope or "root",
- "action": nav_result.action,
- "drill_into": nav_result.drill_into,
- "back_to": nav_result.back_to,
- "collected": collected_in_step,
- "tools_used": nav_result.tools,
- "reason": nav_result.reason,
- "result_status": nav_result.result_status,
- "state_delta": state_delta,
- }
- if rejected_collects:
- trace_entry["requested_collects"] = [
- item.get("path", "")
- for item in requested_collects
- if item.get("path")
- ]
- trace_entry["rejected_collects"] = rejected_collects
- trace_entry["tool_reconciliation"] = collect_reconcile["reason"]
- # Record tool usage & results so future steps can see search history
- if prior_tool_result:
- trace_entry["tool_results"] = prior_tool_result
- nav_state.nav_trace.append(trace_entry)
-
- # ── Record decision step ─────────────────────────────────────
- main_step_index = self._record_navigation_step(
- doc=doc,
- scope=before_scope,
- step_num=nav_state.step_count,
- nav_result=nav_result,
- collected_in_step=collected_in_step,
- asset_summary=asset_tool_result.get("summary"),
- rejected_collects=rejected_collects,
- state_delta=state_delta,
- )
- asset_trace = asset_tool_result.get("asset_trace")
- if asset_trace:
- asset_trace["parent_step_index"] = main_step_index
- self._append_decision_trace_step(DecisionTraceStep(**asset_trace))
-
- if should_break:
- break
- else:
- # while loop exhausted — max_nav_steps reached
- exit_reason = "max_steps"
-
- # Hard guard: if navigation is forcefully interrupted and collected
- # nothing, collect visible leaf children under the last explored
- # scope. Voluntary FINISH/BACK with empty collection is respected.
- # budget – planning pool EXHAUSTED (pre-check or exception)
- # latency – elapsed time exceeded latency budget
- # max_steps – navigation step count limit reached
- # error – unexpected exception during navigate_step
- forced_exits = ("budget", "latency", "max_steps", "error")
- guard_triggered = False
- if not nav_state.collected_paths and nav_state.step_count > 0 and exit_reason in forced_exits:
- guard_triggered = True
- guard_scope = nav_state.current_scope
- logger.info(
- f" agentic: forced exit ({exit_reason}) with 0 collected paths, "
- f"auto-collecting leaves under scope={guard_scope or 'root'}"
- )
- from shared.services.retrieval.agentic.navigation.section_tree import (
- load_child_sections,
- )
- guard_items = await load_child_sections(
- self._db,
- doc.document_id,
- job_result_id,
- guard_scope,
- section_rows=section_rows,
- )
- for item in guard_items:
- if not item.get("show_summary", True):
- continue
- if item.get("is_leaf"):
- path = item["path"]
- nav_state.collected_paths.append({
- "path": path,
- "confidence": 0.4,
- "hydrate_mode": "chunks",
- "collected_at_step": nav_state.step_count,
- "scope_context": guard_scope or "root",
- "guard_reason": f"forced_exit_{exit_reason}",
- })
-
- self._append_decision_trace_step(DecisionTraceStep(
- step_index=len(self._decision_steps),
- agent="system",
- phase="system_guard",
- document_id=doc.document_id,
- document=doc.source_file_name or "",
- scope=guard_scope or "root",
- observation={
- "exit_reason": exit_reason,
- "collected_count": 0,
- },
- decision={
- "action": "auto_collect_visible_leaves",
- "args": {"scope": guard_scope or "root"},
- "reason": "hard navigation constraint stopped the loop before evidence collection",
- },
- result={
- "status": "guard_auto_collect",
- "collected": [
- {
- "path": p["path"],
- "confidence": p.get("confidence", 0.0),
- }
- for p in nav_state.collected_paths
- ],
- "note": f"forced_exit_{exit_reason}",
- },
- budget=self._state.ledger.snapshot() if self._state.ledger else None,
- elapsed_ms=self._state.elapsed_ms,
- ))
-
- # ── Navigate summary — record exit reason and final state ─────
- doc_name = doc.source_file_name or self._state.doc_id_to_name.get(doc.document_id, "")
- self._append_decision_trace_step(DecisionTraceStep(
- step_index=len(self._decision_steps),
- agent="navigator",
- phase="navigate_summary",
- document_id=doc.document_id,
- document=doc_name,
- scope=nav_state.current_scope or "root",
- observation={
- "total_steps": nav_state.step_count,
- "collected_count": len(nav_state.collected_paths),
- "guard_triggered": guard_triggered,
- "query_intent": query_intent,
- "budget_failure": budget_failure,
- },
- decision={
- "action": "summarize_navigation",
- "args": {},
- "reason": f"Navigation ended with exit_reason={exit_reason}",
- },
- result={
- "status": exit_reason,
- "final_scope": nav_state.current_scope or "root",
- "collected": [
- {
- "path": p.get("path", ""),
- "confidence": p.get("confidence", 0.0),
- }
- for p in nav_state.collected_paths
- ],
- },
- budget=self._state.ledger.snapshot() if self._state.ledger else None,
- elapsed_ms=self._state.elapsed_ms,
- ))
-
- return doc_pending_assets, nav_state.collected_paths
-
- async def _classify_query_intent(
- self,
- ) -> str:
- """Classify query intent as advisory state. Fail-open to UNKNOWN."""
- if self._llm_fn is None:
- return "UNKNOWN"
- prompt = QUERY_INTENT_PROMPT.format(
- query=self._query,
- )
- try:
- response = await self._llm_budget.call(
- cast(LLMFn, self._llm_fn),
- prompt,
- pool="planning",
- priority="low",
- )
- except Exception as exc:
- logger.info(
- f" agentic: query intent classifier failed-open: {exc}"
- )
- return "UNKNOWN"
- return parse_query_intent_response(response)
-
- async def _hydrate_collected(
- self,
- *,
- doc: CandidateDoc,
- root: DocTreeNode,
- job_result_id: str,
- collected_paths: list[dict[str, Any]],
- section_rows: list,
- ) -> None:
- """Batch-hydrate all collected paths after navigation completes."""
- if not collected_paths:
- return
-
- # Deduplicate: keep the most complete evidence mode per path. A later
- # full collect is an upgrade over an earlier outline collect even when
- # the outline confidence was higher.
- deduped: dict[str, dict[str, Any]] = {}
- for item in collected_paths:
- path = item["path"]
- if path not in deduped or _collect_rank(item) > _collect_rank(deduped[path]):
- deduped[path] = item
- unique_selections = list(deduped.values())
-
- # Ensure child nodes exist for each collected path so reparent can
- # correctly route descendant chunks into the right subtree.
- for item in unique_selections:
- path = item["path"]
- _ensure_child_node(root, path)
-
- await hydrate_path_selections_into_node(
- self._db,
- node=root,
- path_selections=unique_selections,
- user_id=self._user_id,
- namespace=self._namespace,
- document_id=doc.document_id,
- job_result_id=job_result_id,
- )
-
- # Single reparent pass — tree structure is final.
- root.reparent_leaf_content()
-
- # Load section tree outline for each collected child node, then
- # build a proper sub-tree so the renderer can nest L3 under L2 etc.
- from shared.services.retrieval.agentic.navigation.section_tree import load_child_sections
- for item in unique_selections:
- path = item["path"]
- child_node = root.children.get(path)
- if child_node is None or child_node.outline_items:
- continue # Skip if no child or already has outline
- try:
- section_items = await load_child_sections(
- self._db, doc.document_id, job_result_id, path,
- limit_depth=False,
- section_rows=section_rows,
- )
- if section_items:
- # Filter out the scope node itself AND ancestor/sibling
- # items. load_child_sections returns ancestor context
- # for navigation prompts, but for evidence rendering the
- # child node only needs its own descendants.
- child_node.outline_items = [
- si for si in section_items
- if si.get("path") != path
- and si.get("path", "").startswith(path + " / ")
- ]
- # Build sub-tree from outline hierarchy and re-reparent
- # so chunks are correctly nested (e.g. L3 under L2).
- _build_outline_subtree(child_node)
- except Exception as exc:
- logger.warning(f" hydrate_collected: failed to load outline for '{path}': {exc}")
-
- # Also organize any discovery chunks sitting in root.leaf_content.
- # Same path-based tree building; pre-existing children are protected.
- if root.leaf_content:
- _build_outline_subtree(root)
-
- # Accurate budget accounting — count only actually-hydrated chunks.
- if self._state.ledger is not None:
- total_chunks = len(root.flatten_chunk_rows())
- self._state.ledger.mark_explored(chunks=total_chunks)
- logger.info(
- f" agentic: hydrate_collected doc={doc.document_id} "
- f"collected={len(unique_selections)} hydrated_chunks={total_chunks}"
- )
-
- async def _execute_asset_tools(
- self,
- *,
- doc: CandidateDoc,
- job_result_id: str,
- scope: str | None,
- nav_result: NavigateStepResult,
- pending_assets: list[dict[str, Any]],
- parent_step_index: int,
- ) -> dict[str, Any]:
- """Execute SEARCH_* and return the observation for the next loop."""
- empty_result: dict[str, Any] = {
- "context": "",
- "summary": None,
- "asset_trace": None,
- }
- if not nav_result.tools:
- return empty_result
-
- if not nav_result.search_assets_params:
- return empty_result
-
- params = nav_result.search_assets_params
- search_query = params["query"]
- asset_type = params["asset_type"]
- scope_paths = params.get("scope_paths")
- tool_name = "SEARCH_IMAGES" if asset_type == "image" else "SEARCH_TABLES"
- budget_before = self._state.ledger.snapshot() if self._state.ledger else None
-
- search_llm_fn = self._llm_budget.for_document(
- cast(LLMFn, self._llm_fn),
- doc_id=doc.document_id,
- step=self._state.step_count,
- allow_overdraft=True,
- overdraft_reason=f"{tool_name}_asset_inspector",
- ) if self._llm_fn else None
- if search_llm_fn is None:
- summary = {
- "tool": tool_name,
- "asset_type": asset_type,
- "query": search_query,
- "matched": 0,
- "status": "unavailable",
- "scope_paths": scope_paths if scope_paths is not None else ([scope] if scope else []),
- "matched_paths": [],
- "sub_agent_assessment": "LLM unavailable for asset inspection",
- }
- return {
- "context": self._format_asset_context(
- tool_name,
- asset_type,
- search_query,
- [],
- status="unavailable",
- ),
- "summary": summary,
- "asset_trace": self._build_asset_trace_payload(
- doc=doc,
- scope=scope,
- parent_step_index=parent_step_index,
- asset_type=asset_type,
- query=search_query,
- candidates=[],
- result={"status": "unavailable", "verdicts": [], "matched_assets": []},
- ),
- }
-
- vlm_fn: LLMFn | None = None
- if asset_type == "image":
- from shared.services.retrieval.llm_adapter import create_retrieval_vlm_fn
- raw_vlm_fn = create_retrieval_vlm_fn()
- if raw_vlm_fn is not None:
- vlm_fn = self._llm_budget.for_document(
- raw_vlm_fn,
- doc_id=doc.document_id,
- step=self._state.step_count,
- allow_overdraft=True,
- overdraft_reason=f"{tool_name}_vlm_asset_inspector",
- )
-
- try:
- asset_result = await tools.search_assets_step(
- self._db,
- document_id=doc.document_id,
- job_result_id=job_result_id,
- scope_path=scope_paths if scope_paths is not None else scope,
- asset_type=asset_type,
- query=search_query,
- llm_fn=search_llm_fn,
- vlm_fn=vlm_fn,
- )
- except BudgetExceeded as exc:
- failure = getattr(exc, "details", {}) or {}
- logger.info(
- f" agentic: {tool_name} skipped — planning budget exhausted "
- f"details={failure}"
- )
- asset_result = {
- "status": "budget_exceeded",
- "status_detail": "budget_reserve_failed",
- "budget_failure": failure,
- "matched_assets": [],
- "verdicts": [],
- "candidate_count": 0,
- }
-
- budget_after = self._state.ledger.snapshot() if self._state.ledger else None
- budget_delta = _budget_delta(budget_before, budget_after)
- matched_assets = asset_result.get("matched_assets") or []
- verdicts = asset_result.get("verdicts") or []
- candidate_count = int(asset_result.get("candidate_count") or 0)
- if matched_assets:
- pending_assets.extend(matched_assets)
-
- summary = {
- "tool": tool_name,
- "asset_type": asset_type,
- "query": search_query,
- "matched": len(matched_assets),
- "candidate_count": candidate_count,
- "status": asset_result.get("status", "empty"),
- "status_detail": asset_result.get("status_detail", ""),
- "budget": {
- "before": _compact_budget_snapshot(budget_before),
- "after": _compact_budget_snapshot(budget_after),
- "delta": budget_delta,
- },
- "scope_paths": scope_paths if scope_paths is not None else ([scope] if scope else []),
- "matched_paths": [
- asset.get("file_path", "")
- for asset in matched_assets
- if asset.get("file_path")
- ],
- "matched_owner_paths": [
- asset.get("owner_section_path") or asset.get("section_path") or ""
- for asset in matched_assets
- if asset.get("owner_section_path") or asset.get("section_path")
- ],
- "sub_agent_assessment": (
- f"asset inspector matched {len(matched_assets)} "
- f"of {candidate_count} {asset_type} candidates "
- f"(status={asset_result.get('status', 'empty')})"
- ),
- }
- logger.info(
- f" agentic step {self._state.step_count}: {tool_name} "
- f'doc="{doc.source_file_name}" scope={scope or "root"} '
- f'search_scope={scope_paths if scope_paths is not None else scope or "root"} '
- f'query="{search_query}" matched={len(matched_assets)}'
- )
- return {
- "context": self._format_asset_context(
- tool_name,
- asset_type,
- search_query,
- matched_assets,
- status=str(asset_result.get("status", "empty")),
- status_detail=str(asset_result.get("status_detail", "")),
- ),
- "summary": summary,
- "matched_assets": matched_assets,
- "candidate_count": candidate_count,
- "asset_trace": self._build_asset_trace_payload(
- doc=doc,
- scope=scope,
- parent_step_index=parent_step_index,
- asset_type=asset_type,
- query=search_query,
- candidates=verdicts,
- result=asset_result,
- budget_before=budget_before,
- budget_after=budget_after,
- budget_delta=budget_delta,
- ),
- }
-
- @staticmethod
- def _should_block_asset_search(
- summary: dict[str, Any] | None,
- current_scope: str | None,
- ) -> bool:
- if not summary:
- return False
- if int(summary.get("matched") or 0) > 0:
- return False
- if not _tool_searched_current_scope(summary, current_scope):
- return False
- return str(summary.get("status") or "").lower() in {
- "empty",
- "fallback_empty",
- "unavailable",
- "error",
- "budget_exceeded",
- }
-
- @staticmethod
- def _reconcile_collects_after_tool(
- *,
- nav_result: NavigateStepResult,
- asset_tool_result: dict[str, Any],
- ) -> dict[str, Any]:
- accepted_collects = list(nav_result.collect)
- empty = {
- "accepted_collects": accepted_collects,
- "rejected_collects": [],
- "reason": "",
- }
- if not accepted_collects or not nav_result.tools:
- return empty
- summary = asset_tool_result.get("summary")
- if not isinstance(summary, dict):
- return empty
- tool_name = str(summary.get("tool") or "")
- if tool_name not in {"SEARCH_IMAGES", "SEARCH_TABLES"}:
- return empty
- status = str(summary.get("status") or "").lower()
- if status not in {"empty", "fallback_empty", "matched", "fallback_matched"}:
- return empty
-
- matched_assets = asset_tool_result.get("matched_assets") or []
- matched_owner_paths = [
- str(asset.get("owner_section_path") or asset.get("section_path") or "")
- for asset in matched_assets
- if asset.get("owner_section_path") or asset.get("section_path")
- ]
- still_accepted: list[dict[str, Any]] = []
- rejected_paths: list[str] = []
- for item in accepted_collects:
- path = str(item.get("path") or "")
- if not path:
- continue
- has_matching_asset = any(
- PathLedger.is_same_or_descendant(owner_path, path)
- for owner_path in matched_owner_paths
- )
- if has_matching_asset:
- still_accepted.append(item)
- else:
- rejected_paths.append(path)
-
- if not rejected_paths:
- return empty
- reason = (
- f"{tool_name} returned no valid matching assets under rejected "
- f"collect paths; status={status}, matched={len(matched_assets)}"
- )
- return {
- "accepted_collects": still_accepted,
- "rejected_collects": rejected_paths,
- "reason": reason,
- }
-
- def _reconcile_pending_assets(
- self,
- *,
- doc: CandidateDoc,
- root: DocTreeNode,
- doc_name: str,
- doc_pending_assets: list[dict[str, Any]],
- ) -> None:
- if doc_name and not root.children and not any(
- item.get("path") == doc_name for item in root.outline_items
- ):
- root.outline_items.insert(0, {"path": doc_name, "level": 0})
- reconcile_deferred_assets(root, doc_pending_assets)
- if self._trace_enabled:
- self._trace.record_step(
- "deferred_asset_reconcile",
- ToolResult(
- status="reconciled",
- payload={
- "document_id": doc.document_id,
- "pending_count": len(doc_pending_assets),
- "placed_count": sum(
- 1 for asset in doc_pending_assets
- if str(asset.get("chunk_id") or "") in {
- str(row.get("chunk_id") or "")
- for row in root.flatten_chunk_rows()
- }
- ),
- },
- ),
- decision_reason=f"deferred_reconcile_{doc.source_file_name}",
- )
-
- @staticmethod
- def _format_asset_context(
- tool_name: str,
- asset_type: str,
- search_query: str,
- matched_assets: list[dict[str, Any]],
- *,
- status: str = "empty",
- status_detail: str = "",
- ) -> str:
- if not matched_assets:
- detail = f" Status detail: {status_detail}." if status_detail else ""
- return (
- f"=== {tool_name} Results ===\n"
- f"No matching {asset_type}s found for \"{search_query}\" "
- f"(status={status}).{detail}\n"
- f"=== End {tool_name} Results ==="
- )
- lines = [
- f"=== {tool_name} Results ===",
- f'Found {len(matched_assets)} matching {asset_type}s for "{search_query}".',
- "Matched assets are available as asset evidence.",
- ]
- for i, asset in enumerate(matched_assets):
- file_path = asset.get("file_path", "")
- lines.append(f" {i + 1}. {file_path}")
- owner_paths = _unique_asset_owner_paths(matched_assets)
- if owner_paths:
- lines.append("Owner sections with matching assets:")
- for owner_path in owner_paths:
- lines.append(f' - "{owner_path}"')
- lines.append(
- "Use these asset results and owner sections to decide collect, "
- "finish, back, or further navigation."
- )
- lines.append(f"=== End {tool_name} Results ===")
- return "\n".join(lines)
-
- def _build_asset_trace_payload(
- self,
- *,
- doc: CandidateDoc,
- scope: str | None,
- parent_step_index: int,
- asset_type: str,
- query: str,
- candidates: list[dict[str, Any]],
- result: dict[str, Any],
- budget_before: dict[str, Any] | None = None,
- budget_after: dict[str, Any] | None = None,
- budget_delta: dict[str, Any] | None = None,
- ) -> dict[str, Any]:
- matched_assets = result.get("matched_assets") or []
- matched = [
- {
- "chunk_id": asset.get("chunk_id", ""),
- "file_path": asset.get("file_path", ""),
- "section_path": asset.get("owner_section_path")
- or asset.get("section_path", ""),
- }
- for asset in matched_assets
- ]
- return {
- "step_index": len(self._decision_steps),
- "agent": "asset_inspector",
- "parent_step_index": parent_step_index,
- "phase": "asset_inspect",
- "document_id": doc.document_id,
- "document": doc.source_file_name or "",
- "scope": scope or "root",
- "observation": {
- "asset_type": asset_type,
- "query": query,
- "candidates": candidates,
- },
- "decision": {
- "action": "inspect_assets",
- "args": {"asset_type": asset_type, "query": query},
- "reason": "judged each candidate against the requested evidence",
- },
- "result": {
- "status": result.get("status", "empty"),
- "status_detail": result.get("status_detail", ""),
- "verdicts": result.get("verdicts") or [],
- "matched": matched,
- "budget_failure": result.get("budget_failure"),
- "budget": {
- "before": _compact_budget_snapshot(budget_before),
- "after": _compact_budget_snapshot(budget_after),
- "delta": budget_delta or {},
- },
- },
- "budget": self._state.ledger.snapshot() if self._state.ledger else None,
- "elapsed_ms": self._state.elapsed_ms,
- }
-
- def _append_decision_trace_step(self, step: DecisionTraceStep) -> int:
- step.step_index = len(self._decision_steps)
- self._decision_steps.append(step.to_dict())
- if self._trace_enabled:
- self._trace.record_decision_trace_step(step)
- return step.step_index
-
- def _record_navigation_step(
- self,
- *,
- doc: CandidateDoc,
- scope: str | None,
- step_num: int,
- nav_result: NavigateStepResult,
- collected_in_step: list[str],
- asset_summary: dict[str, Any] | None = None,
- rejected_collects: list[str] | None = None,
- state_delta: dict[str, Any] | None = None,
- ) -> int:
- action = nav_result.action
- reason = nav_result.reason
- drill_into = nav_result.drill_into
- doc_name = doc.source_file_name or self._state.doc_id_to_name.get(doc.document_id, "")
- collected = [
- {
- "path": item.get("path", ""),
- "confidence": item.get("confidence", 0.0),
- "hydrate_mode": item.get("hydrate_mode", "chunks"),
- }
- for item in nav_result.collect
- ]
- decision_args: dict[str, Any] = {}
- if drill_into:
- decision_args["target"] = drill_into
- if action == "BACK":
- decision_args["target"] = nav_result.back_to
- if nav_result.search_assets_params:
- decision_args["query"] = nav_result.search_assets_params.get("query", "")
- decision_args["asset_type"] = nav_result.search_assets_params.get("asset_type", "")
- projected_scope = scope or "root"
- if action == "EXPAND" and drill_into:
- projected_scope = drill_into
- elif action == "BACK" and nav_result.result_status == "ok":
- projected_scope = nav_result.back_to or "root"
-
- result_payload: dict[str, Any] = {
- "status": nav_result.result_status,
- "collected": collected,
- "new_scope": projected_scope,
- "note": nav_result.result_note,
- }
- if state_delta is not None:
- result_payload["state_delta"] = state_delta
- if rejected_collects:
- result_payload["rejected_collects"] = rejected_collects
- if nav_result.error_reason:
- result_payload["error"] = nav_result.error_reason
- if asset_summary:
- result_payload["matched_assets"] = asset_summary.get("matched", 0)
- result_payload["tool_status"] = asset_summary.get("status")
- result_payload["tool_budget"] = asset_summary.get("budget")
- result_payload["sub_agent_assessment"] = asset_summary.get(
- "sub_agent_assessment"
- )
-
- trace_step = DecisionTraceStep(
- step_index=len(self._decision_steps),
- agent="navigator",
- phase="navigate",
- document=doc_name,
- document_id=doc.document_id,
- scope=scope or "root",
- observation=nav_result.observation,
- decision={
- "action": action,
- "args": decision_args,
- "reason": reason,
- },
- result=result_payload,
- budget=self._state.ledger.snapshot() if self._state.ledger else None,
- elapsed_ms=self._state.elapsed_ms,
- )
- step_index = self._append_decision_trace_step(trace_step)
-
- status_tag = (
- f" status={nav_result.result_status}"
- if nav_result.result_status != "ok"
- else ""
- )
- scope_log = scope or "root"
- logger.info(
- f" agentic step {self._state.step_count}: navigate_step "
- f'doc="{doc.source_file_name}" scope={scope_log} '
- f"step={step_num} action={action} tools={nav_result.tools} "
- f'reason="{reason[:80]}" '
- f"collected={len(collected_in_step)} "
- f"drill_into={drill_into} "
- f"outline={len(nav_result.node.outline_items)}"
- f"{status_tag}"
- )
- return step_index
-
-
-def _tool_searched_current_scope(
- summary: dict[str, Any],
- current_scope: str | None,
-) -> bool:
- current = PathLedger.normalize(current_scope) or "root"
- scope_paths = summary.get("scope_paths")
- if not isinstance(scope_paths, list) or not scope_paths:
- return current == "root"
- searched = [
- PathLedger.normalize(str(path or ""))
- for path in scope_paths
- if PathLedger.normalize(str(path or ""))
- ]
- if current == "root":
- return not searched
- return searched == [current]
-
-
-def _compact_budget_snapshot(snapshot: dict[str, Any] | None) -> dict[str, Any]:
- if not isinstance(snapshot, dict):
- return {}
- planning = snapshot.get("planning")
- if not isinstance(planning, dict):
- return {}
- compact = {
- "status": planning.get("status"),
- "used_pct": planning.get("used_pct"),
- "remaining": planning.get("remaining"),
- "capacity": planning.get("capacity"),
- "overdraft": planning.get("overdraft", 0),
- }
- overdraft_events = snapshot.get("overdraft_events")
- if isinstance(overdraft_events, list) and overdraft_events:
- compact["overdraft_events"] = overdraft_events[-3:]
- return compact
-
-
-def _budget_delta(
- before: dict[str, Any] | None,
- after: dict[str, Any] | None,
-) -> dict[str, Any]:
- before_planning = (
- before.get("planning") if isinstance(before, dict) else None
- )
- after_planning = (
- after.get("planning") if isinstance(after, dict) else None
- )
- if not isinstance(before_planning, dict) or not isinstance(after_planning, dict):
- return {}
- return {
- "used": int(after_planning.get("used") or 0)
- - int(before_planning.get("used") or 0),
- "used_pct": int(after_planning.get("used_pct") or 0)
- - int(before_planning.get("used_pct") or 0),
- "remaining": int(after_planning.get("remaining") or 0)
- - int(before_planning.get("remaining") or 0),
- "overdraft": int(after_planning.get("overdraft") or 0)
- - int(before_planning.get("overdraft") or 0),
- }
-
-
-def _unique_asset_owner_paths(matched_assets: list[dict[str, Any]]) -> list[str]:
- seen: set[str] = set()
- owner_paths: list[str] = []
- for asset in matched_assets:
- owner_path = str(asset.get("owner_section_path") or asset.get("section_path") or "")
- if not owner_path or owner_path in seen:
- continue
- seen.add(owner_path)
- owner_paths.append(owner_path)
- return owner_paths
-
-
-def _find_target_node(node: DocTreeNode, path: str) -> DocTreeNode:
- """Walk the tree to find the deepest existing node that owns *path*.
-
- Only recurse when *path* is a true descendant of a child (prefix match).
- An exact match means the item belongs to the section itself, which is
- managed by the *parent* node — the renderer already handles the case
- where a path appears in both ``children`` and ``leaf_content``.
- """
- for child_path, child in node.children.items():
- if PathLedger.is_ancestor(child_path, path):
- return _find_target_node(child, path)
- return node
-
-
-def _merge_step_node(root: DocTreeNode, step_node: DocTreeNode) -> None:
- """Route outline items and confidence from *step_node* to correct tree positions."""
- for item in step_node.outline_items:
- path = item.get("path", "")
- target = _find_target_node(root, path)
- existing = {i.get("path") for i in target.outline_items}
- if path not in existing:
- target.outline_items.append(item)
-
- for path, conf in step_node.confidence.items():
- target = _find_target_node(root, path)
- target.confidence[path] = max(target.confidence.get(path, 0), conf)
-
-
-def _collect_rank(item: dict[str, Any]) -> tuple[int, float]:
- mode = str(item.get("hydrate_mode") or "chunks")
- mode_rank = 0 if mode == "outline" else 1
- confidence = float(item.get("confidence") or 0.0)
- return (mode_rank, confidence)
-
-
-def _ensure_child_node(root: DocTreeNode, path: str) -> None:
- """Create an intermediate child node for *path* if it doesn't exist.
-
- When a non-leaf section is COLLECTed (e.g. "五、施工安全保证措施"),
- hydration loads all descendant chunks (e.g. "五、... / 3.监控量测措施 / 3.1...").
- These chunks are initially placed in root.leaf_content.
- ``reparent_leaf_content`` then moves them into the correct child subtree —
- but only if a child node exists for the collected path.
-
- This function creates that child node so reparent can work correctly.
- """
- # Don't create a child for root-level or if already exists
- if not path:
- return
-
- # Walk to find the deepest existing ancestor node
- target = root
- for child_path, child in root.children.items():
- if PathLedger.is_ancestor(child_path, path):
- target = child
- break
- if path == child_path:
- return # Already exists
-
- # Create the child node on the target
- if path not in target.children:
- target.children[path] = DocTreeNode(scope_path=path)
-
-
-def _build_outline_subtree(node: DocTreeNode) -> None:
- """Recursively build child nodes from outline_items + leaf_content paths.
-
- Every section_path is ``" / "``-separated; parent-child is prefix match.
- This function creates children one level at a time, reparents chunks
- into them, then recurses so the next level is handled correctly.
- """
- if not node.outline_items and not node.leaf_content:
- return
-
- # All known paths: outline metadata + actual chunk paths.
- all_paths = (
- {item["path"] for item in node.outline_items}
- | set(node.leaf_content.keys())
- )
-
- # Find paths that have at least one descendant.
- parent_paths: set[str] = set()
- for path in all_paths:
- for other in all_paths:
- if other != path and PathLedger.is_ancestor(path, other):
- parent_paths.add(path)
- break
-
- if not parent_paths:
- return # All items are leaves — nothing to nest.
-
- # KEY FIX: only keep top-level parents. If "A / B" and "A / B / C"
- # are both parents, only create "A / B" as a child NOW. "A / B / C"
- # will be created when the function recurses into "A / B".
- parent_paths = {
- pp for pp in parent_paths
- if not any(pp != other and PathLedger.is_ancestor(other, pp) for other in parent_paths)
- }
-
- # Track children that already exist (e.g. from collected hydration).
- # Their outline_items are already populated — don't push duplicates.
- pre_existing = set(node.children.keys())
-
- # Create child nodes for top-level parent paths only.
- for path in parent_paths:
- if path not in node.children:
- node.children[path] = DocTreeNode(scope_path=path)
-
- # Reparent existing children that are descendants of newly-created
- # parents. E.g. if node.children already has "A / B" and we just
- # created "A", move "A / B" under "A". This prevents "A / B" from
- # appearing as an orphan_child at the wrong tree depth.
- for parent_path in parent_paths:
- if parent_path in pre_existing:
- continue # Don't reparent into pre-existing nodes
- parent_node = node.children[parent_path]
- to_move = [
- cp for cp in list(node.children.keys())
- if cp != parent_path
- and PathLedger.is_ancestor(parent_path, cp)
- ]
- for cp in to_move:
- parent_node.children[cp] = node.children.pop(cp)
-
- # Split outline_items: keep items at this level, move descendants
- # into newly-created children only (skip pre-existing ones).
- kept: list[dict] = []
- for item in node.outline_items:
- item_path = item["path"]
- best_parent: str | None = None
- for pp in parent_paths:
- if PathLedger.is_ancestor(pp, item_path):
- if best_parent is None or len(pp) > len(best_parent):
- best_parent = pp
- if best_parent and best_parent not in pre_existing:
- node.children[best_parent].outline_items.append(item)
- else:
- kept.append(item)
- node.outline_items = kept
-
- # Reparent FIRST so children receive their leaf_content,
- # THEN recurse so deeper levels can be built from that content.
- node.reparent_leaf_content()
- for child in node.children.values():
- _build_outline_subtree(child)
diff --git a/packages/shared-python/shared/services/retrieval/agentic/navigation/knowledge_map.py b/packages/shared-python/shared/services/retrieval/agentic/navigation/knowledge_map.py
deleted file mode 100644
index c0196be66..000000000
--- a/packages/shared-python/shared/services/retrieval/agentic/navigation/knowledge_map.py
+++ /dev/null
@@ -1,93 +0,0 @@
-"""Knowledge-map overview for agentic document selection."""
-from __future__ import annotations
-
-from sqlalchemy import func, select
-from sqlalchemy.ext.asyncio import AsyncSession
-
-from shared.models.database.document import Document, DocumentChunk, GraphNode
-
-
-_MAX_OVERVIEW_FILES = 50
-
-
-async def build_knowledge_map_overview(
- db: AsyncSession,
- *,
- user_id: str,
- namespace: str,
-) -> tuple[str, dict[str, str]]:
- """Build a file-level knowledge map overview for LLM file selection."""
- doc_stmt = (
- select(Document)
- .where(Document.user_id == user_id)
- .where(Document.namespace == namespace)
- .where(Document.status == "active")
- .where(Document.current_job_result_id.is_not(None))
- .order_by(Document.updated_at.desc())
- .limit(_MAX_OVERVIEW_FILES)
- )
- doc_result = await db.execute(doc_stmt)
- documents = list(doc_result.scalars())
-
- if not documents:
- return "(empty)", {}
-
- doc_ids = [document.document_id for document in documents]
- doc_id_to_name = {
- document.document_id: (document.source_file_name or document.document_id)
- for document in documents
- }
-
- chunk_stats_stmt = (
- select(
- DocumentChunk.document_id,
- func.count(DocumentChunk.id).label("chunk_count"),
- func.count(func.nullif(DocumentChunk.chunk_type, "text")).label("media_count"),
- )
- .join(
- Document,
- (Document.document_id == DocumentChunk.document_id)
- & (Document.current_job_result_id == DocumentChunk.job_result_id),
- )
- .where(DocumentChunk.document_id.in_(doc_ids))
- .group_by(DocumentChunk.document_id)
- )
- chunk_stats_result = await db.execute(chunk_stats_stmt)
- chunk_stats: dict[str, dict[str, int]] = {}
- for document_id, chunk_count, media_count in chunk_stats_result.all():
- chunk_stats[document_id] = {"total": chunk_count, "media": media_count}
-
- graph_summary_stmt = (
- select(GraphNode.owner_document_id, GraphNode.properties)
- .where(GraphNode.owner_document_id.in_(doc_ids))
- .where(GraphNode.node_kind == "document")
- )
- graph_summary_result = await db.execute(graph_summary_stmt)
- doc_top_summaries: dict[str, str] = {}
- for document_id, properties in graph_summary_result.all():
- if not isinstance(properties, dict):
- continue
- top_summary = str(properties.get("top_summary") or "").strip()
- if top_summary:
- doc_top_summaries[document_id] = top_summary
-
- lines: list[str] = []
- for document in documents:
- document_id = document.document_id
- name = doc_id_to_name[document_id]
- stats = chunk_stats.get(document_id, {"total": 0, "media": 0})
- top_summary = doc_top_summaries.get(document_id, "")
-
- line = f'- [{document_id}] {name} chunks={stats["total"]}'
- if stats["media"] > 0:
- line += f' media={stats["media"]}'
- if top_summary:
- line += f"\n top_summary:\n{indent_block(top_summary, 4)}"
- lines.append(line)
-
- return "\n".join(lines), doc_id_to_name
-
-
-def indent_block(text: str, spaces: int) -> str:
- prefix = " " * spaces
- return "\n".join(f"{prefix}{line}" for line in str(text or "").splitlines())
diff --git a/packages/shared-python/shared/services/retrieval/agentic/navigation/path_ledger.py b/packages/shared-python/shared/services/retrieval/agentic/navigation/path_ledger.py
deleted file mode 100644
index bd051eb5a..000000000
--- a/packages/shared-python/shared/services/retrieval/agentic/navigation/path_ledger.py
+++ /dev/null
@@ -1,62 +0,0 @@
-"""Path relationship helpers for document navigation state."""
-from __future__ import annotations
-
-from collections.abc import Iterable
-
-from shared.services.retrieval.search.lexical_text import normalize_section_path
-
-
-class PathLedger:
- """Small, authoritative wrapper for section path relationships."""
-
- @staticmethod
- def normalize(path: str | None) -> str:
- return normalize_section_path(str(path or "").strip())
-
- @classmethod
- def is_ancestor(cls, ancestor: str | None, descendant: str | None) -> bool:
- ancestor_path = cls.normalize(ancestor)
- descendant_path = cls.normalize(descendant)
- if not ancestor_path or not descendant_path:
- return False
- return descendant_path.startswith(ancestor_path + " / ")
-
- @classmethod
- def is_same_or_descendant(cls, path: str | None, scope: str | None) -> bool:
- candidate = cls.normalize(path)
- scope_path = cls.normalize(scope)
- if not candidate or not scope_path:
- return False
- return candidate == scope_path or candidate.startswith(scope_path + " / ")
-
- @classmethod
- def is_covered(cls, path: str | None, covered_paths: Iterable[str]) -> bool:
- candidate = cls.normalize(path)
- if not candidate:
- return False
- return any(
- candidate == covered
- or candidate.startswith(covered + " / ")
- for covered in (cls.normalize(item) for item in covered_paths)
- if covered
- )
-
- @classmethod
- def back_targets(cls, current_scope: str | None) -> list[str | None]:
- scope = cls.normalize(current_scope)
- if not scope:
- return []
- parts = [part for part in scope.split(" / ") if part]
- targets: list[str | None] = [
- " / ".join(parts[:index])
- for index in range(len(parts) - 1, 0, -1)
- ]
- targets.append(None)
- return targets
-
- @classmethod
- def valid_back_target(cls, current_scope: str | None, target: str | None) -> bool:
- scope = cls.normalize(current_scope)
- if not scope:
- return False
- return target is None or cls.is_ancestor(target, scope)
diff --git a/packages/shared-python/shared/services/retrieval/agentic/navigation/section_counts.py b/packages/shared-python/shared/services/retrieval/agentic/navigation/section_counts.py
deleted file mode 100644
index 2e7ae2373..000000000
--- a/packages/shared-python/shared/services/retrieval/agentic/navigation/section_counts.py
+++ /dev/null
@@ -1,192 +0,0 @@
-"""Section count aggregation for agentic navigation."""
-from __future__ import annotations
-
-from sqlalchemy import case, func, literal_column, select
-from sqlalchemy.ext.asyncio import AsyncSession
-
-from shared.models.database.document import DocumentChunk
-
-
-async def attach_section_counts(
- db: AsyncSession,
- *,
- document_id: str,
- job_result_id: str,
- all_sections: dict[str, dict],
- items_by_path: dict[str, dict],
-) -> None:
- """Attach direct chunk and connected asset counts to visible section items."""
- scope_item_sids = {
- item["section_id"]
- for item in items_by_path.values()
- if item["show_summary"]
- }
- all_section_ids = [meta["section_id"] for meta in all_sections.values()]
- if not all_section_ids or not scope_item_sids:
- return
-
- section_id_counts = await _load_direct_chunk_counts(
- db,
- document_id=document_id,
- job_result_id=job_result_id,
- all_section_ids=all_section_ids,
- )
-
- sid_to_path = {meta["section_id"]: path for path, meta in all_sections.items()}
- for section_id, (text_count, image_count, table_count, total_chars) in section_id_counts.items():
- chunk_path = sid_to_path.get(section_id, "")
- if not chunk_path:
- continue
-
- for item_path, item in items_by_path.items():
- if not item["show_summary"]:
- continue
- if _chunk_belongs_to_item(chunk_path, item_path):
- item["chunk_count"] += text_count
- item["image_count"] += image_count
- item["table_count"] += table_count
- item["total_chars"] += total_chars
-
- await _attach_connected_asset_counts(
- db,
- document_id=document_id,
- job_result_id=job_result_id,
- items_by_path=items_by_path,
- sid_to_path=sid_to_path,
- )
-
- # Root is a virtual navigation container. Media availability for the
- # whole document is exposed through global SEARCH actions, not as Root
- # node-local images/tables.
- root_item = items_by_path.get("Root")
- if root_item:
- root_item["image_count"] = 0
- root_item["table_count"] = 0
-
-
-async def _load_direct_chunk_counts(
- db: AsyncSession,
- *,
- document_id: str,
- job_result_id: str,
- all_section_ids: list[str],
-) -> dict[str, tuple[int, int, int, int]]:
- chunk_stmt = (
- select(
- DocumentChunk.section_id,
- func.count(
- case(
- (DocumentChunk.chunk_type.notin_(["image", "table"]), literal_column("1")),
- )
- ).label("text_count"),
- func.count(
- case(
- (DocumentChunk.chunk_type == "image", literal_column("1")),
- )
- ).label("image_count"),
- func.count(
- case(
- (DocumentChunk.chunk_type == "table", literal_column("1")),
- )
- ).label("table_count"),
- func.coalesce(
- func.sum(func.length(DocumentChunk.content)), 0
- ).label("total_chars"),
- )
- .where(DocumentChunk.document_id == document_id)
- .where(DocumentChunk.job_result_id == job_result_id)
- .where(DocumentChunk.section_id.in_(all_section_ids))
- .group_by(DocumentChunk.section_id)
- )
- chunk_rows = (await db.execute(chunk_stmt)).all()
- return {
- section_id: (int(text_count), int(image_count), int(table_count), int(total_chars))
- for section_id, text_count, image_count, table_count, total_chars in chunk_rows
- }
-
-
-async def _attach_connected_asset_counts(
- db: AsyncSession,
- *,
- document_id: str,
- job_result_id: str,
- items_by_path: dict[str, dict],
- sid_to_path: dict[str, str],
-) -> None:
- scope_items_with_zero_assets = [
- item
- for item in items_by_path.values()
- if item["show_summary"] and item["image_count"] == 0 and item["table_count"] == 0
- ]
- if not scope_items_with_zero_assets:
- return
-
- scope_section_ids = {
- item["section_id"]
- for item in items_by_path.values()
- if item.get("section_id")
- }
- if not scope_section_ids:
- return
-
- connect_stmt = (
- select(
- DocumentChunk.section_id,
- DocumentChunk.chunk_metadata,
- )
- .where(DocumentChunk.document_id == document_id)
- .where(DocumentChunk.job_result_id == job_result_id)
- .where(DocumentChunk.section_id.in_(list(scope_section_ids)))
- .where(DocumentChunk.chunk_type == "text")
- )
- connect_result = (await db.execute(connect_stmt)).all()
-
- section_target_ids: dict[str, set[str]] = {}
- for section_id, metadata in connect_result:
- if not isinstance(metadata, dict):
- continue
- for connection in metadata.get("connect_to") or []:
- target_id = connection.get("target", "")
- if target_id:
- section_target_ids.setdefault(section_id, set()).add(target_id)
-
- if not section_target_ids:
- return
-
- all_target_ids: set[str] = set()
- for target_ids in section_target_ids.values():
- all_target_ids.update(target_ids)
-
- target_type_stmt = (
- select(
- DocumentChunk.chunk_id,
- DocumentChunk.chunk_type,
- )
- .where(DocumentChunk.document_id == document_id)
- .where(DocumentChunk.job_result_id == job_result_id)
- .where(DocumentChunk.chunk_id.in_(list(all_target_ids)))
- .where(DocumentChunk.chunk_type.in_(["image", "table"]))
- )
- target_type_result = (await db.execute(target_type_stmt)).all()
- target_types = {chunk_id: chunk_type for chunk_id, chunk_type in target_type_result}
-
- for section_id, target_ids in section_target_ids.items():
- ref_path = sid_to_path.get(section_id, "")
- if not ref_path:
- continue
- referenced_images = sum(1 for target_id in target_ids if target_types.get(target_id) == "image")
- referenced_tables = sum(1 for target_id in target_ids if target_types.get(target_id) == "table")
- if referenced_images == 0 and referenced_tables == 0:
- continue
- for item_path, item in items_by_path.items():
- if not item["show_summary"]:
- continue
- if _chunk_belongs_to_item(ref_path, item_path):
- item["image_count"] += referenced_images
- item["table_count"] += referenced_tables
-
-
-def _chunk_belongs_to_item(chunk_path: str, item_path: str) -> bool:
- if item_path == "Root":
- return chunk_path == item_path
- return chunk_path == item_path or chunk_path.startswith(item_path + " / ")
diff --git a/packages/shared-python/shared/services/retrieval/agentic/navigation/section_prompt_projection.py b/packages/shared-python/shared/services/retrieval/agentic/navigation/section_prompt_projection.py
deleted file mode 100644
index 83cb39fa7..000000000
--- a/packages/shared-python/shared/services/retrieval/agentic/navigation/section_prompt_projection.py
+++ /dev/null
@@ -1,66 +0,0 @@
-"""Prompt projection for agentic section navigation (Collector Agent model)."""
-from __future__ import annotations
-
-from typing import Any
-
-
-def format_nav_trace(
- nav_trace: list[dict[str, Any]],
-) -> str:
- """Render the unified navigation trace block.
-
- Includes compact navigation history. Current collection state is rendered
- separately by the Agent State block.
- """
- if not nav_trace:
- return ""
-
- lines = ["=== Navigation Trace ==="]
- for entry in nav_trace:
- step = entry.get("step", "?")
- scope = entry.get("scope", "root")
- action = entry.get("action", "?")
- reason = entry.get("reason", "")
- action_display = action
- drill_into = entry.get("drill_into")
- if action == "EXPAND" and drill_into:
- action_display = f'EXPAND "{drill_into}"'
- elif action == "BACK":
- back_to = entry.get("back_to")
- target = f'"{back_to}"' if back_to else "root"
- action_display = f"BACK to {target}"
-
- lines.append(f"Step {step}: scope={scope} → {action_display}")
-
- # Show tool usage and results so LLM can avoid repeating searches
- tool_results = entry.get("tool_results", {})
- if tool_results:
- tool_name = tool_results.get("tool", "")
- tool_query = tool_results.get("query", "")
- matched = int(tool_results.get("matched") or 0)
- tool_status = str(tool_results.get("status") or "")
- status = (
- f"found {matched} match(es)"
- if matched
- else f"no matches ({tool_status})"
- if tool_status
- else "no matches"
- )
- lines.append(f' 🔧 {tool_name}("{tool_query}") → {status}')
-
- # Show what was collected in this step
- step_collected = entry.get("collected", [])
- if step_collected:
- paths_display = ", ".join(f'"{c}"' for c in step_collected)
- lines.append(f" collected: {paths_display}")
-
- result_status = entry.get("result_status")
- if result_status and result_status != "ok":
- lines.append(f" result_status: {result_status}")
-
- if reason:
- lines.append(f" reason: {reason}")
- lines.append("")
-
- lines.append("=== End Trace ===")
- return "\n".join(lines)
diff --git a/packages/shared-python/shared/services/retrieval/agentic/navigation/section_tree.py b/packages/shared-python/shared/services/retrieval/agentic/navigation/section_tree.py
deleted file mode 100644
index 92592d4f5..000000000
--- a/packages/shared-python/shared/services/retrieval/agentic/navigation/section_tree.py
+++ /dev/null
@@ -1,231 +0,0 @@
-"""Section-tree loading and prompt projection for agentic navigation."""
-from __future__ import annotations
-
-from loguru import logger
-from sqlalchemy import select
-from sqlalchemy.ext.asyncio import AsyncSession
-
-from shared.models.database.document import DocumentSection
-from shared.services.retrieval.agentic.navigation.section_counts import attach_section_counts
-from shared.services.retrieval.search.lexical_text import normalize_section_path, split_section_path
-
-
-async def load_document_section_rows(
- db: AsyncSession,
- document_id: str,
- job_result_id: str,
-) -> list:
- stmt = (
- select(
- DocumentSection.section_id,
- DocumentSection.section_title,
- DocumentSection.section_path,
- DocumentSection.summary,
- DocumentSection.sort_order,
- )
- .where(DocumentSection.document_id == document_id)
- .where(DocumentSection.job_result_id == job_result_id)
- .order_by(DocumentSection.sort_order)
- )
- return list((await db.execute(stmt)).all())
-
-
-async def load_child_sections(
- db: AsyncSession,
- document_id: str,
- job_result_id: str,
- scope_path: str | list[str] | None = None,
- exclude_paths: set[str] | None = None,
- limit_depth: bool = True,
- section_rows: list | None = None,
-) -> list[dict]:
- """Load the continuous context tree for a navigation scope."""
- if section_rows is None:
- section_rows = await load_document_section_rows(
- db,
- document_id=document_id,
- job_result_id=job_result_id,
- )
- if not section_rows:
- return []
-
- if isinstance(scope_path, list):
- scope_list = [normalize_section_path(path) for path in scope_path]
- elif scope_path:
- scope_list = [normalize_section_path(scope_path)]
- else:
- scope_list = []
-
- scope_depth = len(split_section_path(scope_list[0])) if scope_list else 0
- excluded_paths = exclude_paths or set()
-
- logger.debug(
- f" load_child_sections: scopes={scope_list or ['root']} "
- f"scope_depth={scope_depth} exclude_paths={excluded_paths if excluded_paths else 'none'} "
- f"total_sections={len(section_rows)}"
- )
-
- all_sections: dict[str, dict] = {}
- for section_id, title, path, summary, sort_order in section_rows:
- if not path:
- continue
- normalized_path = normalize_section_path(path)
- parts = split_section_path(normalized_path)
- # Treat Root as a virtual L1 node so it appears in the
- # navigation tree and the LLM can decide whether to drill in.
- if normalized_path == "Root" and not parts:
- parts = ["Root"]
- all_sections[normalized_path] = {
- "title": title or parts[-1] if parts else normalized_path,
- "summary": summary or "",
- "sort_order": int(sort_order or 0),
- "section_id": section_id,
- "parts": parts,
- "depth": len(parts),
- }
-
- ancestor_prefixes: set[str] = set()
- for scope in scope_list:
- scope_parts = split_section_path(scope)
- for index in range(1, len(scope_parts) + 1):
- ancestor_prefixes.add(" / ".join(scope_parts[:index]))
-
- items_by_path = _select_scope_items(
- all_sections,
- scope_list=scope_list,
- ancestor_prefixes=ancestor_prefixes,
- exclude_paths=excluded_paths,
- )
- if not items_by_path:
- return []
-
- if limit_depth:
- allowed_set = _resolve_allowed_depths(items_by_path, scope_list)
- if allowed_set:
- to_remove = [
- path
- for path, item in items_by_path.items()
- if item["show_summary"] and item["level"] not in allowed_set
- ]
- for path in to_remove:
- del items_by_path[path]
-
- if not items_by_path:
- return []
-
- await attach_section_counts(
- db,
- document_id=document_id,
- job_result_id=job_result_id,
- all_sections=all_sections,
- items_by_path=items_by_path,
- )
-
- sorted_items = sorted(items_by_path.values(), key=lambda item: item["sort_order"])
- for item in sorted_items:
- item.pop("sort_order", None)
- item.pop("section_id", None)
-
- # Mark leaf status (no descendants in full section list)
- all_section_paths = set(all_sections.keys())
- for item in sorted_items:
- item_path = item["path"]
- item["is_leaf"] = not any(
- path != item_path and path.startswith(item_path + " / ")
- for path in all_section_paths
- )
- return sorted_items
-
-
-def _select_scope_items(
- all_sections: dict[str, dict],
- *,
- scope_list: list[str],
- ancestor_prefixes: set[str],
- exclude_paths: set[str],
-) -> dict[str, dict]:
- items_by_path: dict[str, dict] = {}
-
- def is_excluded(path: str) -> bool:
- return bool(
- exclude_paths
- and any(path == excluded or path.startswith(excluded + " / ") for excluded in exclude_paths)
- )
-
- for path, meta in all_sections.items():
- parts = meta["parts"]
- depth = meta["depth"]
-
- if not scope_list:
- if depth < 1 or is_excluded(path):
- continue
- items_by_path[path] = _make_item(path, meta, show_summary=True)
- continue
-
- matched_scope = _find_matched_scope(parts, depth=depth, scope_list=scope_list)
- if matched_scope:
- if is_excluded(path):
- continue
- items_by_path[path] = _make_item(path, meta, show_summary=True)
- continue
-
- max_scope_depth = max(len(split_section_path(scope)) for scope in scope_list)
- if depth <= max_scope_depth:
- if depth == 1 and path in ancestor_prefixes:
- items_by_path.setdefault(path, _make_item(path, meta, show_summary=False))
- elif depth > 1:
- parent_prefix = " / ".join(parts[:-1])
- if parent_prefix in ancestor_prefixes:
- items_by_path.setdefault(path, _make_item(path, meta, show_summary=False))
-
- return items_by_path
-
-
-def _make_item(path: str, meta: dict, show_summary: bool) -> dict:
- return {
- "path": path,
- "title": meta["title"],
- "summary": meta["summary"],
- "level": meta["depth"],
- "sort_order": meta["sort_order"],
- "chunk_count": 0,
- "image_count": 0,
- "table_count": 0,
- "total_chars": 0,
- "section_id": meta["section_id"],
- "show_summary": show_summary,
- }
-
-
-def _find_matched_scope(parts: list[str], *, depth: int, scope_list: list[str]) -> str | None:
- for scope in scope_list:
- scope_parts = split_section_path(scope)
- scope_depth = len(scope_parts)
- if depth > scope_depth and parts[:scope_depth] == scope_parts:
- return scope
- return None
-
-
-def _resolve_allowed_depths(items_by_path: dict[str, dict], scope_list: list[str]) -> set[int]:
- if not scope_list:
- depths = {
- item["level"]
- for item in items_by_path.values()
- if item.get("show_summary", True)
- }
- return set(sorted(depths)[:2])
-
- allowed_set: set[int] = set()
- for scope in scope_list:
- scope_parts = split_section_path(scope)
- scope_depth = len(scope_parts)
- child_depths = {
- item["level"]
- for item in items_by_path.values()
- if item.get("show_summary", True)
- and item["level"] > scope_depth
- and split_section_path(item["path"])[:scope_depth] == scope_parts
- }
- if child_depths:
- allowed_set.update(sorted(child_depths)[:2])
- return allowed_set
diff --git a/packages/shared-python/shared/services/retrieval/agentic/navigation/selection_hydration.py b/packages/shared-python/shared/services/retrieval/agentic/navigation/selection_hydration.py
deleted file mode 100644
index 45ef16939..000000000
--- a/packages/shared-python/shared/services/retrieval/agentic/navigation/selection_hydration.py
+++ /dev/null
@@ -1,137 +0,0 @@
-from __future__ import annotations
-
-from typing import Any
-
-from sqlalchemy.ext.asyncio import AsyncSession
-
-from shared.services.retrieval.agentic.core.types import DocTreeNode
-from shared.services.retrieval.agentic.navigation import assets as asset_tools
-from shared.services.retrieval.hydration.connected import hydrate_connected_target_rows
-from shared.services.retrieval.hydration.path import hydrate_paths_to_rows
-from shared.services.retrieval.hydration.reference import hydrate_referenced_chunk_rows
-
-
-async def hydrate_path_selections_into_node(
- db: AsyncSession,
- *,
- node: DocTreeNode,
- path_selections: list[dict[str, Any]],
- user_id: str,
- namespace: str,
- document_id: str,
- job_result_id: str | None = None,
-) -> None:
- chunks = await hydrate_paths_to_rows(
- db,
- path_selections=path_selections,
- user_id=user_id,
- namespace=namespace,
- document_id=document_id,
- )
- if not chunks:
- return
-
- chunks = await _append_connected_asset_targets(db, chunks)
- resolved_job_result_id = job_result_id or _find_job_result_id(chunks)
- if resolved_job_result_id:
- await _attach_root_asset_owners(
- db,
- document_id=document_id,
- job_result_id=resolved_job_result_id,
- chunks=chunks,
- )
-
- add_chunks_to_node(node, chunks)
-
-
-async def hydrate_chunk_refs_into_node(
- db: AsyncSession,
- *,
- node: DocTreeNode,
- refs: list[dict[str, Any]],
- user_id: str,
- namespace: str,
- document_id: str,
- job_result_id: str | None = None,
-) -> None:
- chunks = await hydrate_referenced_chunk_rows(
- db=db,
- user_id=user_id,
- namespace=namespace,
- refs=refs,
- )
- if not chunks:
- return
-
- chunks = await _append_connected_asset_targets(db, chunks)
- resolved_job_result_id = job_result_id or _find_job_result_id(chunks)
- if resolved_job_result_id:
- await _attach_root_asset_owners(
- db,
- document_id=document_id,
- job_result_id=resolved_job_result_id,
- chunks=chunks,
- )
-
- add_chunks_to_node(node, chunks)
-
-
-async def _append_connected_asset_targets(
- db: AsyncSession, chunks: list[dict[str, Any]]
-) -> list[dict[str, Any]]:
- connected = await hydrate_connected_target_rows(
- db=db,
- rows=chunks,
- exclude_document_ids=[],
- exclude_sections=[],
- )
- if not connected:
- return chunks
-
- owner_map = asset_tools.build_connected_owner_map(chunks)
- for chunk in connected:
- if not chunk.get("owner_section_path"):
- chunk["owner_section_path"] = owner_map.get(str(chunk.get("chunk_id") or ""))
- return [*chunks, *connected]
-
-
-async def _attach_root_asset_owners(
- db: AsyncSession,
- *,
- document_id: str,
- job_result_id: str,
- chunks: list[dict[str, Any]],
-) -> None:
- root_map = await asset_tools.resolve_root_asset_owners(
- db,
- document_id=document_id,
- job_result_id=job_result_id,
- chunks=chunks,
- )
- if not root_map:
- return
-
- for chunk in chunks:
- if chunk.get("owner_section_path"):
- continue
- chunk_id = str(chunk.get("chunk_id") or "")
- if chunk_id in root_map:
- chunk["owner_section_path"] = root_map[chunk_id]
-
-
-def _find_job_result_id(chunks: list[dict[str, Any]]) -> str | None:
- return next(
- (str(chunk["job_result_id"]) for chunk in chunks if chunk.get("job_result_id")),
- None,
- )
-
-
-def add_chunks_to_node(node: DocTreeNode, chunks: list[dict[str, Any]]) -> None:
- for chunk in chunks:
- real_path = (
- chunk.get("owner_section_path")
- or chunk.get("section_path")
- or chunk.get("source_chunk_path")
- )
- if real_path:
- node.add_leaf_chunks(str(real_path), [chunk])
diff --git a/packages/shared-python/shared/services/retrieval/agentic/navigation/state.py b/packages/shared-python/shared/services/retrieval/agentic/navigation/state.py
deleted file mode 100644
index 83b6ac57f..000000000
--- a/packages/shared-python/shared/services/retrieval/agentic/navigation/state.py
+++ /dev/null
@@ -1,221 +0,0 @@
-"""Per-document navigation state for the collector runtime."""
-from __future__ import annotations
-
-from dataclasses import dataclass, field
-from typing import Any, Literal
-
-from shared.services.retrieval.agentic.navigation.path_ledger import PathLedger
-
-RejectReason = Literal["tool_adjudicated", "navigational_abandon"]
-
-# Strength ordering: a stronger reason overrides a weaker one so we keep the
-# most informative record for a given path.
-_REASON_STRENGTH: dict[RejectReason, int] = {
- "tool_adjudicated": 2,
- "navigational_abandon": 1,
-}
-
-
-@dataclass
-class RejectionRecord:
- """A single rejection entry in the navigation state ledger.
-
- Two reasons are distinguished:
-
- - ``tool_adjudicated``: a SEARCH_* tool reconciliation proved the path has
- no matching assets. Strong (content-level) negative signal. Not revived
- this round; future strong-signal revival is a TODO.
- - ``navigational_abandon``: BACK left an unproductive scope. Weak (soft)
- signal; any discovery / lexical hit revives the path.
- """
-
- path: str
- reason: RejectReason
- step: int
- detail: str = ""
-
-
-@dataclass
-class NavigationState:
- """Mutable state for one document navigation loop.
-
- Path state is tracked in two orthogonal dimensions:
-
- - **Coverage** (positive "already taken as evidence"): derived from
- ``collected_paths`` via :meth:`covered_paths` / :meth:`outline_paths`.
- - **Rejection** (negative "evaluated, not taken"): a single labelled
- ledger in :attr:`rejected` keyed by normalized path. Replaces the
- former ``rejected_paths`` / ``rejected_collect_paths`` dual sets.
- """
-
- document_id: str
- document_name: str
- job_result_id: str
- current_scope: str | None = None
- expanded_scopes: set[str] = field(default_factory=set)
- rejected: dict[str, RejectionRecord] = field(default_factory=dict)
- collected_paths: list[dict[str, Any]] = field(default_factory=list)
- nav_trace: list[dict[str, Any]] = field(default_factory=list)
- tool_history: list[dict[str, Any]] = field(default_factory=list)
- blocked_asset_searches: set[str] = field(default_factory=set)
- step_count: int = 0
-
- # ── Coverage helpers (single source: collected_paths) ────────────────
-
- def covered_paths(self) -> set[str]:
- """Full-evidence paths (hydrate_mode != 'outline')."""
- return {
- PathLedger.normalize(str(item.get("path") or ""))
- for item in self.collected_paths
- if item.get("path") and item.get("hydrate_mode") != "outline"
- }
-
- def outline_paths(self) -> set[str]:
- """Outline-only paths; excludes any path also collected as full."""
- full = self.covered_paths()
- return {
- PathLedger.normalize(str(item.get("path") or ""))
- for item in self.collected_paths
- if item.get("path")
- and item.get("hydrate_mode") == "outline"
- and PathLedger.normalize(str(item.get("path") or "")) not in full
- }
-
- # ── Snapshot / delta for replayable traces ──────────────────────────
-
- def snapshot_delta(
- self,
- *,
- before_scope: str | None,
- expanded_before: set[str],
- rejected_before: dict[str, RejectionRecord],
- collected_before_count: int,
- ) -> dict[str, Any]:
- rejected_added: list[dict[str, Any]] = []
- for path, record in self.rejected.items():
- if path in rejected_before:
- continue
- rejected_added.append({
- "path": record.path,
- "reason": record.reason,
- "step": record.step,
- "detail": record.detail,
- })
- rejected_added.sort(key=lambda item: (item["step"], item["path"]))
- return {
- "current_scope_before": before_scope or "root",
- "current_scope_after": self.current_scope or "root",
- "expanded_added": sorted(self.expanded_scopes - expanded_before),
- "rejected_added": rejected_added,
- "collected_added": [
- item.get("path", "")
- for item in self.collected_paths[collected_before_count:]
- if item.get("path")
- ],
- }
-
- # ── Mutation helpers ────────────────────────────────────────────────
-
- def add_collected(
- self,
- item: dict[str, Any],
- *,
- step: int,
- scope_context: str | None,
- ) -> dict[str, Any]:
- enriched = dict(item)
- enriched["collected_at_step"] = step
- enriched["scope_context"] = scope_context or "root"
- self.collected_paths.append(enriched)
- return enriched
-
- def mark_expanded(self, path: str | None) -> None:
- normalized = PathLedger.normalize(path)
- if normalized:
- self.expanded_scopes.add(normalized)
-
- def mark_rejected_collect(
- self,
- path: str | None,
- *,
- step: int,
- detail: str = "",
- ) -> None:
- """Record a tool-adjudicated rejection (strong, content-level)."""
- normalized = PathLedger.normalize(path)
- if not normalized:
- return
- self._upsert_rejection(
- normalized,
- reason="tool_adjudicated",
- step=step,
- detail=detail,
- )
-
- def mark_rejected_if_unproductive(
- self,
- path: str | None,
- *,
- step: int,
- detail: str = "",
- ) -> None:
- """Record a soft navigational abandon when leaving an unproductive scope.
-
- Only written when no stronger record exists for the path.
- """
- normalized = PathLedger.normalize(path)
- if not normalized:
- return
- has_full_collect = any(
- item.get("hydrate_mode") != "outline"
- and PathLedger.is_same_or_descendant(item.get("path"), normalized)
- for item in self.collected_paths
- )
- if has_full_collect:
- return
- self._upsert_rejection(
- normalized,
- reason="navigational_abandon",
- step=step,
- detail=detail,
- )
-
- def _upsert_rejection(
- self,
- normalized_path: str,
- *,
- reason: RejectReason,
- step: int,
- detail: str,
- ) -> None:
- existing = self.rejected.get(normalized_path)
- if existing is not None and _REASON_STRENGTH[existing.reason] >= _REASON_STRENGTH[reason]:
- # Keep the stronger prior record.
- return
- self.rejected[normalized_path] = RejectionRecord(
- path=normalized_path,
- reason=reason,
- step=step,
- detail=detail,
- )
-
- def rejected_paths_with_reason(self, reason: RejectReason) -> set[str]:
- """All paths rejected with a specific reason label."""
- return {
- path for path, record in self.rejected.items()
- if record.reason == reason
- }
-
- def blocked_asset_types_for_scope(self, scope: str | None) -> set[str]:
- prefix = f"{PathLedger.normalize(scope) or 'root'}:"
- return {
- key.split(":", 1)[1]
- for key in self.blocked_asset_searches
- if key.startswith(prefix)
- }
-
- def block_asset_search(self, scope: str | None, asset_type: str) -> None:
- normalized_scope = PathLedger.normalize(scope) or "root"
- normalized_type = asset_type.strip().lower()
- if normalized_type:
- self.blocked_asset_searches.add(f"{normalized_scope}:{normalized_type}")
diff --git a/packages/shared-python/shared/services/retrieval/agentic/navigation/tools.py b/packages/shared-python/shared/services/retrieval/agentic/navigation/tools.py
deleted file mode 100644
index 066fa1302..000000000
--- a/packages/shared-python/shared/services/retrieval/agentic/navigation/tools.py
+++ /dev/null
@@ -1,364 +0,0 @@
-"""Agentic retrieval navigation tools — observe-act collector model."""
-from __future__ import annotations
-
-from typing import Any
-
-from loguru import logger
-from sqlalchemy.ext.asyncio import AsyncSession
-
-from shared.services.retrieval.agentic.navigation.assets import (
- count_assets_under_scope,
-)
-from shared.services.retrieval.agentic.core.budget import (
- BudgetExceeded,
- budget_status_from_snapshot,
-)
-from shared.services.retrieval.agentic.prompts import (
- COLLECTOR_PROMPT,
- adjust_budget_snapshot,
- parse_collector_response,
-)
-from shared.services.retrieval.agentic.navigation.section_prompt_projection import (
- format_nav_trace,
-)
-from shared.services.retrieval.agentic.navigation.actions import (
- build_legal_actions,
- format_actionable_observation,
- format_agent_state_block,
-)
-from shared.services.retrieval.agentic.navigation.section_tree import load_child_sections
-from shared.services.retrieval.agentic.navigation.state import RejectionRecord
-from shared.services.retrieval.agentic.core.types import DocTreeNode, NavigateStepResult
-from shared.services.retrieval.llm_adapter import LLMFn
-
-
-
-async def navigate_step(
- db: AsyncSession,
- *,
- document_id: str,
- job_result_id: str,
- query: str,
- llm_fn: LLMFn,
- user_id: str,
- namespace: str,
- doc_name: str = "",
- scope_path: str | None = None,
- exclude_paths: set[str] | None = None,
- budget_snapshot: dict | None = None,
- nav_trace: list[dict[str, Any]] | None = None,
- collected_paths: list[dict[str, Any]] | None = None,
- expanded_scopes: set[str] | None = None,
- rejected: dict[str, RejectionRecord] | None = None,
- disabled_asset_types: set[str] | None = None,
- discovery_hints: list[dict[str, Any]] | None = None,
- section_rows: list | None = None,
- query_intent: str = "UNKNOWN",
- search_context: str = "",
- prior_tool_result: dict[str, Any] | None = None,
-) -> NavigateStepResult:
- """Navigate one document scope with a single observe-act decision."""
- scope_paths = [scope_path] if scope_path else []
-
- try:
- items = await load_child_sections(
- db,
- document_id,
- job_result_id,
- scope_path,
- exclude_paths=exclude_paths,
- section_rows=section_rows,
- )
- if not items:
- return NavigateStepResult.stop(
- scope_paths[0] if scope_paths else None,
- reason="No visible sections in the current scope.",
- )
-
- budget_status = budget_status_from_snapshot(budget_snapshot)
- if budget_status in {"CRITICAL", "EXHAUSTED"}:
- total_images, total_tables = 0, 0
- else:
- total_images, total_tables = await count_assets_under_scope(
- db,
- document_id=document_id,
- job_result_id=job_result_id,
- scope_paths=scope_paths,
- )
-
- expanded_path_set = set(expanded_scopes or _expanded_paths_from_trace(nav_trace or []))
- if scope_path:
- expanded_path_set.add(scope_path)
- rejection_ledger = rejected or {}
- provisional_action_set = build_legal_actions(
- items=items,
- current_scope=scope_path,
- collected_paths=collected_paths or [],
- expanded_scopes=expanded_path_set,
- discovery_hints=discovery_hints,
- rejected=rejection_ledger,
- total_images=total_images,
- total_tables=total_tables,
- disabled_asset_types=disabled_asset_types or set(),
- budget_snapshot=budget_snapshot,
- )
- provisional_observation_text, provisional_overflowed = (
- format_actionable_observation(
- items=items,
- action_set=provisional_action_set,
- )
- )
-
- trace_block = format_nav_trace(nav_trace or [])
-
- # Estimate this call's prompt token cost and adjust the budget
- # snapshot so the LLM sees post-call budget, not pre-call.
- # This prevents the LLM from seeing misleadingly low percentages
- # (e.g. 63% when it will actually be 89% after this call).
- prompt_tokens_est = (
- len(provisional_observation_text)
- + len(trace_block)
- + 800
- ) // 2 # rough chars-to-tokens ratio
- adjusted_snapshot = adjust_budget_snapshot(
- budget_snapshot, prompt_tokens_est,
- )
- if (
- budget_status_from_snapshot(adjusted_snapshot)
- == budget_status_from_snapshot(budget_snapshot)
- ):
- action_set = provisional_action_set
- actionable_observation = provisional_observation_text
- overflowed = provisional_overflowed
- else:
- action_set = build_legal_actions(
- items=items,
- current_scope=scope_path,
- collected_paths=collected_paths or [],
- expanded_scopes=expanded_path_set,
- discovery_hints=discovery_hints,
- rejected=rejection_ledger,
- total_images=total_images,
- total_tables=total_tables,
- disabled_asset_types=disabled_asset_types or set(),
- budget_snapshot=adjusted_snapshot,
- )
- actionable_observation, overflowed = format_actionable_observation(
- items=items,
- action_set=action_set,
- )
- observation = {
- "visible_sections": [
- item.get("path", "")
- for item in items
- if item.get("path")
- ][:50],
- "available_images": total_images,
- "available_tables": total_tables,
- "prior_tool_result": prior_tool_result,
- "current_scope": scope_path or "root",
- "query_intent": query_intent,
- "legal_actions": {
- "expand": [item.id for item in action_set.expand],
- "collect": [item.id for item in action_set.collect],
- "back": [item.id for item in action_set.back],
- "search": [item.id for item in action_set.search],
- "finish": [action_set.finish.id] if action_set.finish else [],
- },
- "rejected": {
- path: {"reason": record.reason, "step": record.step}
- for path, record in rejection_ledger.items()
- },
- }
- agent_state_block = format_agent_state_block(
- current_scope=scope_path,
- query_intent=query_intent,
- expanded_scopes=expanded_path_set,
- rejected=rejection_ledger,
- collected_paths=collected_paths or [],
- prior_tool_result=prior_tool_result,
- search_context=search_context,
- budget_snapshot=adjusted_snapshot,
- )
-
- prompt = COLLECTOR_PROMPT.format(
- doc_name=doc_name or document_id,
- doc_id=document_id,
- agent_state_block=agent_state_block,
- trace_block=trace_block,
- query=query,
- actionable_observation=actionable_observation,
- )
-
- response = await llm_fn(prompt)
- parsed = parse_collector_response(response)
- requested_action = parsed["action"]
- selected_tools = parsed["tools"]
- tool_params = parsed.get("tool_params", {})
- reason = parsed.get("reason", "")
- raw_collect = parsed.get("collect", [])
- action_id = parsed.get("action_id")
- legal_main = action_set.get(action_id)
- action = (
- legal_main.action
- if legal_main and legal_main.action != "COLLECT"
- else requested_action
- )
- if action != requested_action:
- reason = (
- f"Action field '{requested_action}' did not match legal action "
- f"ID '{action_id}'; executing ID-defined action '{action}'. "
- + reason
- ).strip()[:500]
- selected_tools = [action] if action in ("SEARCH_IMAGES", "SEARCH_TABLES") else []
-
- scope_label = scope_path or "root"
- logger.info(
- f" navigate_step scope={scope_label}: "
- f"action={action} collect={len(raw_collect)} "
- f"action_id={action_id} tools={selected_tools} "
- f"tool_params={tool_params} "
- f"overflowed={overflowed}"
- )
-
- node = DocTreeNode(scope_path=scope_paths[0] if scope_paths else None)
- node.outline_items = [item for item in items if item.get("show_summary", True)]
-
- # Resolve COLLECT side effects from legal action IDs.
- valid_collect: list[dict[str, Any]] = []
- invalid_collect: list[str] = []
- existing_collect_modes = _existing_collect_modes(collected_paths or [])
- for item in raw_collect:
- collect_id = item.get("id")
- legal_collect = action_set.get(collect_id)
- if legal_collect and legal_collect.action == "COLLECT" and legal_collect.path:
- confidence = item.get("confidence", 0.7)
- outline = bool(item.get("outline", False)) and (
- budget_status_from_snapshot(adjusted_snapshot) != "CRITICAL"
- or query_intent in {"MACRO_SUMMARY", "STRUCTURE_OVERVIEW"}
- )
- if (
- legal_main
- and legal_main.action == "EXPAND"
- and legal_main.path == legal_collect.path
- ):
- outline = True
- hydrate_mode = "outline" if outline else "chunks"
- existing_mode = existing_collect_modes.get(legal_collect.path)
- if existing_mode == "chunks":
- continue
- if existing_mode == "outline" and hydrate_mode == "outline":
- continue
- node.confidence[legal_collect.path] = confidence
- valid_collect.append({
- "path": legal_collect.path,
- "confidence": confidence,
- "hydrate_mode": hydrate_mode,
- })
- elif collect_id:
- invalid_collect.append(str(collect_id))
-
- valid_drill: list[dict[str, Any]] = []
- result_status = "ok"
- result_note: str | None = None
- drill_into: str | None = None
- back_to: str | None = None
- if requested_action == "ERROR":
- result_status = "invalid_response"
- result_note = reason or "invalid model response"
- elif action == "EXPAND":
- if legal_main and legal_main.action == "EXPAND" and legal_main.path:
- drill_into = legal_main.path
- valid_drill.append({
- "path": drill_into,
- "confidence": 0.8,
- })
- else:
- result_status = "invalid_action_id"
- result_note = f"invalid_expand_id: {action_id}"
- elif action == "BACK":
- if legal_main and legal_main.action == "BACK":
- back_to = legal_main.target_scope
- else:
- result_status = "invalid_action_id"
- result_note = f"invalid_back_id: {action_id}"
- elif action in ("SEARCH_IMAGES", "SEARCH_TABLES"):
- if legal_main is None or legal_main.action != action:
- result_status = "invalid_action_id"
- result_note = f"invalid_search_id: {action_id}"
- elif action == "FINISH":
- if legal_main is None or legal_main.action != "FINISH":
- result_status = "invalid_action_id"
- result_note = f"invalid_finish_id: {action_id}"
-
- if invalid_collect and result_status == "ok":
- result_status = "invalid_collect"
- result_note = "invalid_collect_ids: " + ", ".join(invalid_collect[:5])
-
- # Parse tool parameters for SEARCH
- search_assets_params: dict[str, Any] | None = None
-
- if action in ("SEARCH_IMAGES", "SEARCH_TABLES") and result_status == "ok":
- asset_type = "image" if action == "SEARCH_IMAGES" else "table"
- collected_scope_paths = [
- str(item.get("path") or "")
- for item in valid_collect
- if item.get("path")
- ]
- search_assets_params = {
- "query": query.strip(),
- "asset_type": asset_type,
- "scope_paths": collected_scope_paths or scope_paths,
- }
- elif action in ("SEARCH_IMAGES", "SEARCH_TABLES"):
- selected_tools = []
-
- return NavigateStepResult(
- action=action,
- collect=valid_collect,
- drill=valid_drill,
- back_to=back_to,
- tools=selected_tools,
- node=node,
- reason=reason,
- search_assets_params=search_assets_params,
- observation=observation,
- result_status=result_status,
- result_note=result_note,
- )
-
- except BudgetExceeded:
- raise
- except Exception as exc:
- logger.error(f" navigate_step failed for doc={document_id}: {exc}")
- return NavigateStepResult.error(
- scope_paths[0] if scope_paths else None,
- reason=str(exc),
- )
-
-
-def _expanded_paths_from_trace(nav_trace: list[dict[str, Any]]) -> set[str]:
- expanded: set[str] = set()
- for entry in nav_trace:
- if entry.get("action") != "EXPAND":
- continue
- if entry.get("result_status", "ok") != "ok":
- continue
- drill_into = entry.get("drill_into")
- if isinstance(drill_into, str) and drill_into:
- expanded.add(drill_into)
- return expanded
-
-
-def _existing_collect_modes(collected_paths: list[dict[str, Any]]) -> dict[str, str]:
- modes: dict[str, str] = {}
- for item in collected_paths:
- path = str(item.get("path") or "")
- if not path:
- continue
- hydrate_mode = str(item.get("hydrate_mode") or "chunks")
- if hydrate_mode != "outline":
- modes[path] = "chunks"
- elif modes.get(path) != "chunks":
- modes[path] = "outline"
- return modes
diff --git a/packages/shared-python/shared/services/retrieval/agentic/orchestrator.py b/packages/shared-python/shared/services/retrieval/agentic/orchestrator.py
deleted file mode 100644
index 32a604966..000000000
--- a/packages/shared-python/shared/services/retrieval/agentic/orchestrator.py
+++ /dev/null
@@ -1,448 +0,0 @@
-"""Retrieval Agent orchestrator — evidence-only navigation loop.
-
-Flow:
- Phase 1: Document selection (bottom_discovery + kg_document_select)
- Phase 2: Per-document navigation (iterative BFS via navigate_step)
- Phase 3: Render evidence text for downstream agents
-
-The orchestrator drives navigation through a per-document observe-act loop.
-Each navigate_step is a single LLM call that chooses one main action
-(EXPAND/BACK/SEARCH_IMAGES/SEARCH_TABLES/FINISH) plus optional collection
-side effects. FINISH explicitly terminates navigation for that document.
-
-KNOWHERE does not generate final answers. Downstream agents decide whether the
-returned evidence is sufficient for their task and may call retrieval again.
-"""
-from __future__ import annotations
-
-import os
-from typing import Any
-
-from loguru import logger
-from sqlalchemy.ext.asyncio import AsyncSession
-
-from shared.services.retrieval.agentic.core.budget import BudgetLedger
-from shared.services.retrieval.agentic.discovery.phase import (
- run_initial_discovery,
-)
-from shared.services.retrieval.agentic.navigation.document import DocumentNavigationRunner
-from shared.services.retrieval.agentic.evidence.builder import (
- trim_evidence_to_budget as _trim_evidence_to_budget,
-)
-from shared.services.retrieval.agentic.core.runtime import (
- AgentLlmBudget,
- build_config_from_env as _build_config_from_env,
- load_budget_inventory as _load_budget_inventory,
-)
-from shared.services.retrieval.agentic.core.trace import TraceRecorder
-from shared.services.retrieval.agentic.core.types import (
- AgentRunConfig,
- AgentState,
- AgenticResult,
- DecisionTraceStep,
-)
-from shared.services.retrieval.llm_adapter import LLMFn
-from shared.services.retrieval.settings import DEFAULT_TOP_K, resolve_disabled_asset_types
-
-
-class RetrievalAgent:
- """Agentic retrieval orchestrator — navigate and return evidence.
-
- Usage::
-
- agent = RetrievalAgent()
- result = await agent.run(
- db, user_id=..., namespace=..., query=..., llm_fn=..., ...
- )
- # result.evidence_text — hierarchical context for downstream agents
- # result.answer_text — deprecated, always empty
- # result.referenced_chunks — chunk IDs for hit stats / frontend
-
- The agent requires a valid ``llm_fn`` for LLM-driven navigation.
- If ``llm_fn`` is None, the run returns discovery-only results.
- """
-
- async def run(
- self,
- db: AsyncSession,
- *,
- user_id: str,
- namespace: str,
- query: str,
- top_k: int = DEFAULT_TOP_K,
- llm_fn: LLMFn | None = None,
- exclude_document_ids: list[str] | None = None,
- exclude_sections: list[dict[str, str]] | None = None,
- chunk_types: set[str] | None = None,
- signal_paths: list[str] | None = None,
- filter_mode: str = 'delete',
- channels: list[str] | None = None,
- channel_weights: dict[str, float] | None = None,
- internal_recall_k: int | None = None,
- config: AgentRunConfig | None = None,
- ledger: BudgetLedger | None = None,
- parent_run_id: str | None = None,
- workflow_step_id: str | None = None,
- ) -> AgenticResult:
- """Run the agentic retrieval pipeline.
-
- Returns an ``AgenticResult`` containing rendered evidence text
- and referenced chunk IDs. Never raises — errors are captured in
- trace and the best available evidence is returned.
- """
- config = config or _build_config_from_env()
- exclude_document_ids = exclude_document_ids or []
- exclude_sections = exclude_sections or []
-
- state = AgentState()
- state.ledger = ledger or BudgetLedger(
- total=config.token_budget_total,
- planning_ratio=config.planning_ratio,
- bootstrap=config.bootstrap_budget,
- per_doc_min_share=config.per_doc_min_share,
- )
- total_chunks, total_docs, chunks_count_by_doc = await _load_budget_inventory(
- db,
- user_id=user_id,
- namespace=namespace,
- exclude_document_ids=exclude_document_ids,
- )
- state.kg_total_chunks = total_chunks
- state.kg_total_docs = total_docs
- state.ledger.total_chunks = total_chunks
- state.ledger.total_docs = total_docs
- trace = TraceRecorder(
- db, user_id=user_id, namespace=namespace, query=query,
- config=config, top_k=top_k, chunk_types=chunk_types,
- filters={
- 'exclude_document_ids': exclude_document_ids,
- 'exclude_sections': exclude_sections,
- 'signal_paths': signal_paths,
- 'internal_recall_k': internal_recall_k,
- },
- parent_run_id=parent_run_id,
- workflow_step_id=workflow_step_id,
- )
-
- trace_enabled = os.environ.get('RETRIEVAL_AGENTIC_TRACE_ENABLED', 'true') == 'true'
- if trace_enabled:
- await trace.create_run()
-
- logger.info(
- f'agentic retrieval START: query="{query[:60]}..." '
- f'top_k={top_k} latency_budget={config.latency_budget_ms}ms '
- f'token_budget={config.token_budget_total}'
- )
-
- if llm_fn is None:
- logger.warning('agentic: no llm_fn provided — running discovery-only mode')
-
- bootstrap_llm_fn: LLMFn | None = None
- llm_budget = AgentLlmBudget(state)
- if llm_fn is not None:
- bootstrap_llm_fn = llm_budget.for_pool(llm_fn, pool='bootstrap')
-
- discovery_rows = await run_initial_discovery(
- db,
- state=state,
- trace=trace,
- trace_enabled=trace_enabled,
- 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,
- filter_mode=filter_mode,
- channels=channels,
- channel_weights=channel_weights,
- internal_recall_k=internal_recall_k,
- bootstrap_llm_fn=bootstrap_llm_fn,
- )
-
- # If no LLM or no docs selected, return discovery rows directly
- if not state.selected_docs:
- logger.info('agentic: no documents selected — returning discovery results')
- no_docs_trace: list[dict[str, Any]] = []
- kg_select_step = DecisionTraceStep(
- step_index=0,
- agent='doc_selector',
- parent_step_index=None,
- phase='kg_select',
- document_id=None,
- document=None,
- scope='corpus',
- observation={
- 'query': query,
- 'candidate_documents': len(state.doc_id_to_name),
- },
- decision={
- 'action': 'select_documents',
- 'args': {},
- 'reason': 'No documents selected for navigation',
- },
- result={
- 'status': 'empty',
- 'collected': [],
- },
- budget=state.ledger.snapshot() if state.ledger else None,
- elapsed_ms=state.elapsed_ms,
- )
- no_docs_trace.append(kg_select_step.to_dict())
- if trace_enabled:
- trace.record_decision_trace_step(kg_select_step)
- discovery_refs = [
- {
- 'chunk_id': r.get('chunk_id', ''),
- 'document_id': r.get('document_id', ''),
- 'chunk_type': r.get('chunk_type', ''),
- 'section_path': (
- r.get('source_file_name', '')
- if r.get('section_path') == 'Root'
- else r.get('section_path', '')
- ),
- 'file_path': r.get('file_path', ''),
- 'job_id': r.get('job_id', ''),
- }
- for r in discovery_rows[:top_k]
- if r.get('chunk_id')
- ]
- terminal_step = DecisionTraceStep(
- step_index=len(no_docs_trace),
- agent='retrieval_agent',
- parent_step_index=None,
- phase='terminal',
- document_id=None,
- document=None,
- scope='retrieve_step',
- observation={
- 'router_used': 'agentic_discovery_only',
- 'referenced_chunks': len(discovery_refs),
- 'evidence_chars': 0,
- },
- decision={
- 'action': 'complete',
- 'args': {},
- 'reason': 'no_documents_selected',
- },
- result={
- 'status': 'ok',
- 'stop_reason': 'no_documents_selected',
- 'failure_reason': '',
- },
- budget=state.ledger.snapshot() if state.ledger else None,
- elapsed_ms=state.elapsed_ms,
- )
- no_docs_trace.append(terminal_step.to_dict())
- if trace_enabled:
- trace.record_decision_trace_step(terminal_step)
- if trace_enabled:
- await trace.complete(
- discovery_rows,
- 'agentic_discovery_only',
- budget_snapshot=state.ledger.snapshot() if state.ledger else None,
- )
- return AgenticResult(
- evidence_text='',
- answer_text='',
- referenced_chunks=discovery_refs,
- router_used='agentic_discovery_only',
- budget_snapshot=state.ledger.snapshot() if state.ledger else None,
- stop_reason='no_documents_selected',
- decision_trace=no_docs_trace,
- )
-
- discovery_by_doc: dict[str, list[dict[str, Any]]] = {}
- for row in discovery_rows:
- doc_id = row.get('document_id', '')
- # Root chunks are navigable via the section tree; exclude them
- # from discovery hints where the bare "Root" label gives the LLM
- # no actionable information.
- section_path = str(row.get('section_path', '') or '').strip()
- if not doc_id or section_path == 'Root':
- continue
- discovery_by_doc.setdefault(doc_id, []).append(row)
-
-
- if state.ledger is not None:
- await state.ledger.allocate_doc_caps({
- doc.document_id: chunks_count_by_doc.get(doc.document_id, 1)
- for doc in state.selected_docs
- })
-
- # Phase 2 + 3: navigate once, then render evidence for downstream agents.
- evidence_text = ''
- stop_reason = 'evidence_only'
- failure_reason = ''
- decision_trace: list[dict[str, Any]] = []
-
- # Record KG document selection as the first decision trace entry
- if state.selected_docs:
- kg_select_step = DecisionTraceStep(
- step_index=0,
- agent='doc_selector',
- parent_step_index=None,
- phase='kg_select',
- document_id=None,
- document=None,
- scope='corpus',
- observation={
- 'query': query,
- 'candidate_documents': len(state.doc_id_to_name),
- },
- decision={
- 'action': 'select_documents',
- 'args': {},
- 'reason': f'{len(state.selected_docs)} document(s) selected for navigation',
- },
- result={
- 'status': 'ok',
- 'collected': [
- {
- 'document': doc.source_file_name,
- 'document_id': doc.document_id,
- 'confidence': doc.confidence,
- 'reason': doc.reason,
- 'source': doc.source,
- }
- for doc in state.selected_docs
- ],
- },
- budget=state.ledger.snapshot() if state.ledger else None,
- elapsed_ms=state.elapsed_ms,
- )
- decision_trace.append(kg_select_step.to_dict())
- if trace_enabled:
- trace.record_decision_trace_step(kg_select_step)
-
- if state.elapsed_ms >= config.latency_budget_ms:
- stop_reason = 'latency_budget'
- else:
- navigation_runner = DocumentNavigationRunner(
- db=db,
- state=state,
- trace=trace,
- trace_enabled=trace_enabled,
- user_id=user_id,
- namespace=namespace,
- query=query,
- config=config,
- discovery_by_doc=discovery_by_doc,
- llm_fn=llm_fn,
- llm_budget=llm_budget,
- disabled_asset_types=resolve_disabled_asset_types(chunk_types),
- )
- await navigation_runner.navigate_selected_documents()
- decision_trace.extend(
- _offset_decision_trace(
- navigation_runner.decision_steps,
- offset=len(decision_trace),
- )
- )
-
- context_remaining = state.ledger.remaining('context') if state.ledger else config.token_budget_total
- evidence_text = await _trim_evidence_to_budget(
- db,
- doc_trees=state.doc_trees,
- doc_id_to_name=state.doc_id_to_name,
- context_remaining=context_remaining,
- user_id=user_id,
- namespace=namespace,
- ledger=state.ledger,
- )
-
- # ══════════════════════════════════════════════════════════════════
- # Final Assembly
- # ══════════════════════════════════════════════════════════════════
- router_used = (
- 'agentic_llm' if any(t.has_content() for t in state.doc_trees.values())
- else 'agentic_discovery_only'
- )
-
- # Collect referenced chunk IDs from all doc trees
- all_refs: list[dict[str, Any]] = []
- seen_ref_ids: set[str] = set()
- for doc_id, doc_tree in state.doc_trees.items():
- doc_name = state.doc_id_to_name.get(doc_id, doc_id)
- for ref in doc_tree.collect_referenced_ids(document_name=doc_name):
- cid = ref.get('chunk_id', '')
- if cid and cid not in seen_ref_ids:
- seen_ref_ids.add(cid)
- all_refs.append(ref)
-
- terminal_step = DecisionTraceStep(
- step_index=len(decision_trace),
- agent='retrieval_agent',
- parent_step_index=None,
- phase='terminal',
- document_id=None,
- document=None,
- scope='retrieve_step',
- observation={
- 'router_used': router_used,
- 'referenced_chunks': len(all_refs),
- 'evidence_chars': len(evidence_text),
- },
- decision={
- 'action': 'complete',
- 'args': {},
- 'reason': stop_reason or failure_reason or 'retrieval_complete',
- },
- result={
- 'status': 'error' if failure_reason else 'ok',
- 'stop_reason': stop_reason,
- 'failure_reason': failure_reason,
- },
- budget=state.ledger.snapshot() if state.ledger else None,
- elapsed_ms=state.elapsed_ms,
- )
- decision_trace.append(terminal_step.to_dict())
- if trace_enabled:
- trace.record_decision_trace_step(terminal_step)
-
- result = AgenticResult(
- evidence_text=evidence_text,
- answer_text='',
- referenced_chunks=all_refs,
- router_used=router_used,
- budget_snapshot=state.ledger.snapshot() if state.ledger else None,
- stop_reason=stop_reason,
- failure_reason=failure_reason,
- decision_trace=decision_trace,
- )
-
- logger.info(
- f'agentic retrieval DONE: {len(all_refs)} referenced chunks, '
- f'evidence_text={len(evidence_text)} chars, '
- f'router={router_used}, steps={state.step_count}, '
- f'stop_reason={stop_reason}, '
- f'{state.elapsed_ms}ms'
- )
-
- if trace_enabled:
- await trace.complete(
- all_refs,
- router_used,
- budget_snapshot=state.ledger.snapshot() if state.ledger else None,
- )
-
- return result
-
-
-def _offset_decision_trace(
- steps: list[dict[str, Any]],
- *,
- offset: int,
-) -> list[dict[str, Any]]:
- adjusted: list[dict[str, Any]] = []
- for step in steps:
- copied = dict(step)
- old_index = int(copied.get('step_index') or 0)
- copied['step_index'] = old_index + offset
- parent = copied.get('parent_step_index')
- if parent is not None:
- copied['parent_step_index'] = int(parent) + offset
- adjusted.append(copied)
- return adjusted
diff --git a/packages/shared-python/shared/services/retrieval/agentic/prompts.py b/packages/shared-python/shared/services/retrieval/agentic/prompts.py
deleted file mode 100644
index 3a7c5b2cc..000000000
--- a/packages/shared-python/shared/services/retrieval/agentic/prompts.py
+++ /dev/null
@@ -1,316 +0,0 @@
-"""Prompt templates and response parsers for agentic retrieval."""
-from __future__ import annotations
-
-import json
-import re
-from typing import Any
-
-from shared.services.retrieval.agentic.core.budget import project_budget_snapshot
-
-
-FILE_SELECT_PROMPT = """\
-You are a document routing assistant.
-
-{budget_block}
-Below is a document corpus overview showing all available documents,
-their navigation summaries, chunk counts, and media counts.
-Some documents may show "🔍 Discovery hints" — these are preliminary keyword
-matches from bottom-up search. Consider them as additional signals but make
-your own judgment on document relevance.
-
-=== Document Corpus Overview ===
-{overview}
-=== End Overview ===
-
-User query: {query}
-Based on the query, select documents that may contain relevant information.
-If NO document in the corpus is relevant to the query, return an EMPTY array [].
-Return ONLY a JSON array of document IDs, e.g.: ["doc_abc123", "doc_def456"]
-Do not include any explanation.
-"""
-
-
-COLLECTOR_PROMPT = """\
-You are a document navigation agent running an observe-act loop.
-
-Document: "{doc_name}" (id: {doc_id})
-
-{agent_state_block}
-
-{trace_block}
-
-User query: {query}
-
-{actionable_observation}
-
-=== Rules ===
-
-Each step chooses exactly ONE main action, plus optional COLLECT side effects.
-
-Action semantics:
- - EXPAND observes a listed section's children in the next step.
- - COLLECT adds a listed section and all descendant content to evidence.
- - BACK only changes current scope; it does not collect evidence.
- - SEARCH_IMAGES and SEARCH_TABLES inspect assets in the current scope.
- Use only the listed SEARCH action ID. The asset inspector receives the
- user's original query directly.
- After a SEARCH result returns matches, use the matched assets and owner
- sections to decide whether more owner context is needed; avoid repeating
- the same asset search unless the current scope has changed and the prior
- result is insufficient for the query.
- - FINISH ends navigation for this document.
-
-COLLECT side effect:
- - COLLECT includes the section AND ALL its descendant content.
- - Set "outline": true to collect only structure (titles + summaries),
- keeping children available for further EXPAND or COLLECT.
- - If you COLLECT the same section you EXPAND as the main action, use
- "outline": true so the section remains open for child exploration.
- - If the advisory query intent is MACRO_SUMMARY or STRUCTURE_OVERVIEW
- (document overview, chapter map, high-level summary), prefer outline
- collection; outline evidence can be sufficient final evidence.
- - If the advisory query intent is FACTUAL_DETAIL, NUMERIC_DETAIL, or
- ASSET_LOOKUP, prefer full evidence collection ("outline": false), or
- SEARCH_IMAGES/SEARCH_TABLES when visual/table evidence is central.
- - If the advisory query intent is UNKNOWN, decide from the user's wording:
- broad summaries can use outline, specific facts/numbers/assets need full
- evidence.
- - FINISH only when the collected evidence is sufficient for the user's query.
- The system will not infer missing evidence for you.
- - In CRITICAL budget mode, exploration is closed. Prefer the smallest
- sufficient COLLECT side effects, then FINISH.
- - In EXHAUSTED or overdraft budget mode, do not explore or search again.
- Use current observations/tool results to FINISH, or collect only
- indispensable visible evidence before FINISH.
- - For [Leaf] nodes or small sections, prefer COLLECT over EXPAND.
-
-=== End Rules ===
-
-Return ONLY a JSON object:
-{{"collect": [{{"id": "C1", "confidence": , "outline": false}}],
- "action": "",
- "action_args": {{"id": ""}},
- "reason": "..."}}
-Do not include any explanation outside the JSON.
-
-IMPORTANT:
-1. All agent-generated text (e.g., "reason" and other free-text fields) MUST be written in English.
-2. Document content and section paths MUST remain in their original language.
-3. Use only action IDs from Actionable Observation. Never invent IDs or write raw section paths as action targets.
-4. The action value MUST match the chosen ID group: E*=EXPAND, B*=BACK, S*=SEARCH, F*=FINISH.
-5. When Budget mode is CRITICAL or EXHAUSTED, choose the best sufficient COLLECT side effects and then FINISH.
-"""
-
-
-QUERY_INTENT_PROMPT = """\
-Classify the user's retrieval query for document navigation.
-
-Return ONLY a JSON object: {{"intent": "