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/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/cache_service.py b/packages/shared-python/shared/services/retrieval/cache_service.py index f58415f52..da76068d1 100644 --- a/packages/shared-python/shared/services/retrieval/cache_service.py +++ b/packages/shared-python/shared/services/retrieval/cache_service.py @@ -45,7 +45,8 @@ def _cache_shape_digest( threshold: float = 0.0, internal_recall_k: int | None = None, use_agentic: bool | None = None, - decomposition_enabled: bool | None = None, + llm_text_model: str | None = None, + llm_vision_model: str | None = None, ) -> str: normalized_excludes = sorted(exclude_document_ids) normalized_sections = _normalize_exclude_sections(exclude_sections) @@ -61,7 +62,8 @@ def _cache_shape_digest( str(threshold), str(internal_recall_k), str(use_agentic), - str(decomposition_enabled), + str(llm_text_model or ""), + str(llm_vision_model or ""), ] ) payload = f"{query}|{top_k}|{'|'.join(normalized_excludes)}|{'|'.join(normalized_sections)}|{extra}" diff --git a/packages/shared-python/shared/services/retrieval/execution/query_request.py b/packages/shared-python/shared/services/retrieval/execution/query_request.py index c226a9f6d..53363d185 100644 --- a/packages/shared-python/shared/services/retrieval/execution/query_request.py +++ b/packages/shared-python/shared/services/retrieval/execution/query_request.py @@ -93,7 +93,6 @@ def build_cache_extra(self) -> dict[str, Any]: "threshold": self.threshold, "internal_recall_k": self.internal_recall_k, "use_agentic": self.use_agentic, - "decomposition_enabled": True, "llm_text_model": text_model, "llm_vision_model": vision_model, } diff --git a/packages/shared-python/shared/services/retrieval/execution/response_projection.py b/packages/shared-python/shared/services/retrieval/execution/response_projection.py index b39dba68e..313eb8ece 100644 --- a/packages/shared-python/shared/services/retrieval/execution/response_projection.py +++ b/packages/shared-python/shared/services/retrieval/execution/response_projection.py @@ -26,7 +26,7 @@ def to_public_source(row: dict[str, Any]) -> dict[str, Any]: async def enrich_referenced_chunks_with_asset_url(refs: list[dict[str, Any]]) -> list[dict[str, Any]]: return await enrich_rows_with_retrieval_asset_url( refs, - log_context='agentic referenced chunk', + log_context='mapnav referenced chunk', ) diff --git a/packages/shared-python/shared/services/retrieval/llm_adapter.py b/packages/shared-python/shared/services/retrieval/llm_adapter.py deleted file mode 100644 index f99f35520..000000000 --- a/packages/shared-python/shared/services/retrieval/llm_adapter.py +++ /dev/null @@ -1,167 +0,0 @@ -"""Async LLM helpers retained for non-mapnav call sites. - -Map-nav uses ``nav_llm_backend`` + ``OpenAICompatibleClientSync`` instead. -""" -from __future__ import annotations - -import asyncio -import math -from contextvars import ContextVar -from typing import Any, Callable, Coroutine, Union, Sequence, cast - -from loguru import logger - -from shared.core.config import settings -from shared.services.ai.llm_overrides import get_current_llm_overrides - -# LLMFn accepts either a plain string or a list of ChatCompletionMessageParam -LLMFnInput = Union[str, Sequence[dict[str, Any]]] -LLMFn = Callable[[LLMFnInput], Coroutine[Any, Any, str]] -LLMUsage = dict[str, int] -current_llm_usage: ContextVar[LLMUsage | None] = ContextVar( - 'current_llm_usage', - default=None, -) - -_RETRIEVAL_LLM_TEMPERATURE = 0.1 -_RETRIEVAL_LLM_MAX_TOKENS = 2048 - - -def _has_llm_credentials() -> bool: - """Check whether at least one LLM provider is configured.""" - if get_current_llm_overrides() is not None: - return True - if getattr(settings, 'LLM_MOCK_ENABLED', False): - return True - if getattr(settings, 'DS_KEY', ''): - return True - if getattr(settings, 'ALI_API_KEYS', ''): - return True - if getattr(settings, 'GLM_API_KEY', ''): - return True - if getattr(settings, 'GPT_API_KEY', ''): - return True - return False - - -def _resolve_default_model() -> str: - """Pick a model name that matches the configured LLM provider.""" - overrides = get_current_llm_overrides() - if overrides is not None: - provider = overrides.text_effective() - if provider is not None: - return provider.model - if getattr(settings, 'DS_KEY', ''): - return 'deepseek-v4-flash' - if getattr(settings, 'ALI_API_KEYS', ''): - return 'qwen-plus' - if getattr(settings, 'GLM_API_KEY', ''): - return 'glm-4-flash' - if getattr(settings, 'GPT_API_KEY', ''): - return getattr(settings, 'NORMOL_MODEL', None) or 'gpt-4o-mini' - return getattr(settings, 'NORMOL_MODEL', None) or 'deepseek-v4-flash' - - -def _resolve_vlm_model(model: str | None = None) -> str: - overrides = get_current_llm_overrides() - if overrides is not None: - provider = overrides.vision_effective() - if provider is not None: - return provider.model - return model or getattr(settings, 'IMAGE_MODEL', '') or 'qwen3.6-flash' - - -def _build_client_for_channel(*, channel: str, model: str): - """Build an OpenAI-compatible client, honoring active BYOK overrides.""" - from shared.services.ai.openai_compatible_client_sync import get_openai_client - from shared.services.ai.llm_overrides import resolve_text, resolve_vision - - resolve = resolve_vision if channel == 'vision' else resolve_text - effective_model, api_key, api_url = resolve(model) - return get_openai_client( - model=effective_model, - api_key=api_key, - api_url=api_url, - ), effective_model - - -def create_retrieval_llm_fn( - *, - model: str | None = None, - temperature: float = _RETRIEVAL_LLM_TEMPERATURE, - max_tokens: int = _RETRIEVAL_LLM_MAX_TOKENS, -) -> LLMFn | None: - """Create an async LLM callable (legacy helper; map-nav uses nav_llm_backend). - - Returns None when no LLM provider is configured. - """ - if not _has_llm_credentials(): - logger.debug('retrieval: no LLM credentials configured') - return None - - effective_model = model or _resolve_default_model() - - async def llm_fn(prompt: LLMFnInput) -> str: - client, resolved_model = _build_client_for_channel( - channel='text', - model=effective_model, - ) - current_llm_usage.set(None) - - result, usage = await asyncio.to_thread( - client.chat_completion_with_usage, - cast(Any, prompt), - model=resolved_model, - temperature=temperature, - max_tokens=max_tokens, - ) - current_llm_usage.set(usage) - return result - - return llm_fn - - -def _coerce_provider_timeout_seconds(timeout_seconds: float | None) -> int | None: - if timeout_seconds is None or not math.isfinite(timeout_seconds): - return None - return max(1, math.ceil(timeout_seconds)) - - -def create_retrieval_vlm_fn( - *, - model: str | None = None, - temperature: float = _RETRIEVAL_LLM_TEMPERATURE, - max_tokens: int = 4096, -) -> LLMFn | None: - """Create an async VLM callable for image-aware answer generation. - - Uses the IMAGE_MODEL (e.g. qwen3.6-flash) for multimodal input. - Returns None when the image model is not configured. - - The returned function accepts the same ``LLMFnInput`` type as - ``create_retrieval_llm_fn`` — callers pass either a plain string - or a list of ChatCompletionMessageParam (including image_url parts). - """ - effective_model = _resolve_vlm_model(model) - - if not _has_llm_credentials(): - logger.debug('retrieval: no LLM credentials for VLM, image-aware answering disabled') - return None - - async def vlm_fn(prompt: LLMFnInput) -> str: - client, resolved_model = _build_client_for_channel( - channel='vision', - model=effective_model, - ) - current_llm_usage.set(None) - result, usage = await asyncio.to_thread( - client.chat_completion_with_usage, - cast(Any, prompt), - model=resolved_model, - temperature=temperature, - max_tokens=max_tokens, - ) - current_llm_usage.set(usage) - return result - - return vlm_fn diff --git a/packages/shared-python/shared/services/retrieval/nav/nav_agent.py b/packages/shared-python/shared/services/retrieval/nav/nav_agent.py index e78b2ccb8..f4d0d12db 100644 --- a/packages/shared-python/shared/services/retrieval/nav/nav_agent.py +++ b/packages/shared-python/shared/services/retrieval/nav/nav_agent.py @@ -32,7 +32,6 @@ LegalAction, NavConfig, NavState, - map_mode_enabled, ) # Back-compat aliases for tests / callers. @@ -362,10 +361,8 @@ def _run_nav_episode_body( load_llm_env() require_llm_env(context="Nav Agent") cfg = config or NavConfig(policy="llm") - if map_mode_enabled(None): - cfg.map_mode = True - if cfg.llm_max_tokens < 256: - cfg.llm_max_tokens = 256 + if cfg.map_mode and cfg.llm_max_tokens < 256: + cfg.llm_max_tokens = 256 nav_policy = (policy or cfg.policy or "llm").strip().lower() if nav_policy != "llm": raise ValueError( diff --git a/packages/shared-python/shared/services/retrieval/nav/nav_llm.py b/packages/shared-python/shared/services/retrieval/nav/nav_llm.py index 7cb0724b6..af75dcf9a 100644 --- a/packages/shared-python/shared/services/retrieval/nav/nav_llm.py +++ b/packages/shared-python/shared/services/retrieval/nav/nav_llm.py @@ -3,7 +3,7 @@ Nav modules call ``nav_chat`` / ``resolve_nav_model`` only — they must not read ``DS_KEY`` / ``OPENAI_API_KEY`` themselves. Credentials are resolved here via ``resolve_chat_credentials`` (deepseek-* → ``DS_KEY``/``DS_URL``, else -``OPENAI_*``), matching Knowhere's split at ``create_retrieval_llm_fn``. +``OPENAI_*``), matching Knowhere's chat credential split (deepseek → DS_*, else OPENAI_*). Thinking policy (DeepSeek V4 defaults thinking ON if omitted): diff --git a/packages/shared-python/shared/services/retrieval/nav/nav_projection.py b/packages/shared-python/shared/services/retrieval/nav/nav_projection.py index adce0bf56..452c3fdbd 100644 --- a/packages/shared-python/shared/services/retrieval/nav/nav_projection.py +++ b/packages/shared-python/shared/services/retrieval/nav/nav_projection.py @@ -8,12 +8,40 @@ from .nav_types import NavConfig, Projection, SectionView, map_mode_enabled try: - from section_summary_store import get_summary + from section_summary_store import get_summary as _store_get_summary except Exception: # pragma: no cover - def get_summary(section_id: str, *, doc_id: str = "") -> Optional[str]: # type: ignore + def _store_get_summary(section_id: str, *, doc_id: str = "") -> Optional[str]: # type: ignore return None +def _section_summary_for_map( + ts: Any, + section_id: str, + *, + doc_id: str = "", +) -> str: + """Inline map summary: store first, then provider structure (KH production).""" + sid = str(section_id or "").strip() + if not sid: + return "" + try: + text = str(_store_get_summary(sid, doc_id=str(doc_id or "")) or "").strip() + if text: + return text + except Exception: + pass + if ts is None: + return "" + try: + st = ts.get_structure(sid) or {} + text = str(st.get("summary") or "").strip() + if text: + return text + return str(st.get("preview") or "").strip() + except Exception: + return "" + + def _tokens(text: str) -> set[str]: return set(re.findall(r"[\w\u4e00-\u9fff]+", (text or "").lower())) @@ -437,8 +465,11 @@ def render(node: _MapNode) -> None: summary = "" if inline_summary: summary = _clip_summary( - get_summary(node.section_id, doc_id=_summary_doc_for(node.section_id)) - or "" + _section_summary_for_map( + ts, + node.section_id, + doc_id=_summary_doc_for(node.section_id), + ) ) if summary: lines.append(f"{indent} summary: {summary}") diff --git a/packages/shared-python/shared/services/retrieval/nav/nav_types.py b/packages/shared-python/shared/services/retrieval/nav/nav_types.py index bdf0c3c87..8f3366c0d 100644 --- a/packages/shared-python/shared/services/retrieval/nav/nav_types.py +++ b/packages/shared-python/shared/services/retrieval/nav/nav_types.py @@ -15,9 +15,14 @@ class ActionKind(str, Enum): def map_mode_enabled(config: "NavConfig | None" = None) -> bool: - """True when map-first observation/actions are active.""" - if config is not None and bool(getattr(config, "map_mode", False)): - return True + """True when map-first observation/actions are active. + + When ``config`` is provided it is authoritative (Knowhere production binds + ``map_mode`` on ``NavConfig``). Env ``NAV_MAP_MODE`` is only for EXP scripts + that call without a config. + """ + if config is not None: + return bool(getattr(config, "map_mode", False)) return os.environ.get("NAV_MAP_MODE", "0").strip().lower() not in { "0", "false", @@ -158,8 +163,6 @@ def from_dict(cls, data: Dict[str, Any]) -> "NavConfig": flat["mode"] = "checklist" if raw_mode == "checklist" else "navigate" allowed = {f.name for f in cls.__dataclass_fields__.values()} cfg = cls(**{k: v for k, v in flat.items() if k in allowed}) - if map_mode_enabled(None) and not cfg.map_mode: - cfg.map_mode = True if cfg.map_mode and cfg.llm_max_tokens < 256: cfg.llm_max_tokens = 256 return cfg diff --git a/packages/shared-python/shared/services/retrieval/trace/recorder.py b/packages/shared-python/shared/services/retrieval/trace/recorder.py index ff1c61b0d..79d5cea76 100644 --- a/packages/shared-python/shared/services/retrieval/trace/recorder.py +++ b/packages/shared-python/shared/services/retrieval/trace/recorder.py @@ -105,34 +105,6 @@ async def create_run(self) -> None: f"{self._run_id}: {rollback_error}" ) - def record_step( - self, - action_type: str, - result: Any, - *, - decision_reason: str = "", - ) -> None: - """Buffer a legacy ToolResult-shaped step. Prefer record_decision_trace_step.""" - payload = getattr(result, "payload", None) or {} - self._steps.append( - { - "step_index": len(self._steps), - "action_type": action_type, - "action_input": ( - {"decision_reason": decision_reason} if decision_reason else {} - ), - "observation_status": getattr(result, "status", "unknown"), - "observation_payload_keys": list(payload.keys()) if payload else [], - "latency_ms": int(getattr(result, "latency_ms", 0) or 0), - "error": getattr(result, "error", None), - "tokens_used": int(getattr(result, "tokens_used", 0) or 0), - "selected_paths": None, - "selected_doc_ids": None, - "model_name": None, - "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") diff --git a/packages/shared-python/shared/tests/test_nav_projection_prod.py b/packages/shared-python/shared/tests/test_nav_projection_prod.py new file mode 100644 index 000000000..9d74127bc --- /dev/null +++ b/packages/shared-python/shared/tests/test_nav_projection_prod.py @@ -0,0 +1,109 @@ +"""Knowhere production projection / config authority tests.""" + +from __future__ import annotations + +import os +from typing import Any + +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 shared.services.retrieval.nav.nav_hierarchy import ProviderToolSpace +from shared.services.retrieval.nav.nav_knowhere import SectionRow, UnitRow +from shared.services.retrieval.nav.nav_projection import ( + _section_summary_for_map, + build_map, +) +from shared.services.retrieval.nav.nav_types import NavConfig, map_mode_enabled +from shared.services.retrieval.nav_config import build_nav_config +from shared.services.retrieval.nav_snapshot import build_nav_snapshot + + +def test_map_mode_enabled_trusts_config_over_env(monkeypatch: Any) -> None: + monkeypatch.setenv("NAV_MAP_MODE", "0") + cfg_on = NavConfig(map_mode=True) + cfg_off = NavConfig(map_mode=False) + assert map_mode_enabled(cfg_on) is True + assert map_mode_enabled(cfg_off) is False + monkeypatch.setenv("NAV_MAP_MODE", "1") + assert map_mode_enabled(cfg_off) is False + assert map_mode_enabled(None) is True + + +def test_build_nav_config_authoritative_for_production() -> None: + cfg = build_nav_config() + assert cfg.map_mode is True + assert cfg.mode == "checklist" + assert map_mode_enabled(cfg) is True + + +def test_section_summary_falls_back_to_provider_structure() -> None: + class _FakeTs: + def get_structure(self, section_id: str) -> dict[str, Any]: + assert section_id == "sec_host" + return {"summary": "provider hosted summary", "preview": "preview"} + + text = _section_summary_for_map(_FakeTs(), "sec_host", doc_id="doc_a") + assert text == "provider hosted summary" + + +def test_build_map_inline_summary_uses_provider_when_store_missing() -> None: + root = SectionRow( + section_id="sec_root", + parent_section_id=None, + section_path="Root", + section_title="Root", + section_level=0, + summary="root summary", + sort_order=0, + ) + host = SectionRow( + section_id="sec_host", + parent_section_id="sec_root", + section_path="Chapter 1", + section_title="Chapter 1", + section_level=1, + summary="chapter one summary from db", + sort_order=1, + ) + unit = UnitRow( + chunk_id="chk_text", + section_id="sec_host", + chunk_type="text", + content="body", + sort_order=0, + ) + snap = build_nav_snapshot( + document_titles={"doc_a": "Doc A"}, + sections_by_doc={"doc_a": [root, host]}, + units_by_doc={"doc_a": [unit]}, + chunk_ref_index={ + "chk_text": { + "document_id": "doc_a", + "section_path": "Chapter 1", + "chunk_type": "text", + "file_path": None, + "job_id": "job_1", + } + }, + ) + ts = ProviderToolSpace(snap.provider) + cfg = build_nav_config() + # Force scoped inline-summary path (small actionable map under the limit). + cfg.scope_inline_summary_char_limit = 50_000 + projection = build_map( + ts, + doc_id="doc_a", + query="chapter", + scope="sec_host", + config=cfg, + map_scores={"sec_host": 1.0}, + ) + assert "summary: chapter one summary from db" in projection.text + host_views = [v for v in projection.tree_sections if v.section_id == "sec_host"] + assert host_views + assert "chapter one summary from db" in host_views[0].summary