From 0e374bccb82a4f47bcd1fd7c4cc2591fee7b6a7b Mon Sep 17 00:00:00 2001 From: chengke <404835780@qq.com> Date: Wed, 2 Sep 2026 12:18:53 +0800 Subject: [PATCH] refactor(retrieval): remove legacy evidence rendering and implement new evidence text rendering Deleted the legacy evidence rendering functionality and tests. Introduced a new method for rendering evidence text that groups documents and sections more effectively. Updated related components to utilize the new rendering approach. --- .../test_evidence_renderer_contract.py | 45 +++++ .../test_legacy_evidence_renderer_contract.py | 43 ----- .../test_retrieval_lazy_tree_contract.py | 4 +- .../test_page_memory_retrieval_contract.py | 2 + .../services/retrieval/execution/routes.py | 30 +++- .../retrieval/hydration/asset_inline.py | 85 ++++++++++ .../retrieval/hydration/evidence_text.py | 45 +++++ .../retrieval/hydration/legacy_evidence.py | 55 ------- .../retrieval/hydration/result_assembly.py | 47 ++++-- .../services/retrieval/hydration/row_utils.py | 10 -- .../services/retrieval/nav/nav_compose.py | 46 ++---- .../services/retrieval/nav/nav_hierarchy.py | 69 +++++++- .../shared/testing/contract_runtime.py | 7 + .../shared/tests/test_asset_inline.py | 154 ++++++++++++++++++ 14 files changed, 475 insertions(+), 167 deletions(-) create mode 100644 apps/api/tests/contract/test_evidence_renderer_contract.py delete mode 100644 apps/api/tests/contract/test_legacy_evidence_renderer_contract.py create mode 100644 packages/shared-python/shared/services/retrieval/hydration/asset_inline.py create mode 100644 packages/shared-python/shared/services/retrieval/hydration/evidence_text.py delete mode 100644 packages/shared-python/shared/services/retrieval/hydration/legacy_evidence.py create mode 100644 packages/shared-python/shared/tests/test_asset_inline.py diff --git a/apps/api/tests/contract/test_evidence_renderer_contract.py b/apps/api/tests/contract/test_evidence_renderer_contract.py new file mode 100644 index 000000000..8188408b9 --- /dev/null +++ b/apps/api/tests/contract/test_evidence_renderer_contract.py @@ -0,0 +1,45 @@ +from shared.services.retrieval.execution.routes import _render_rows_evidence + + +def test_render_rows_evidence_should_group_by_traceable_path() -> None: + rows = [ + { + "chunk_id": "c2", + "content": "second section content", + "sort_order": 2, + "source": { + "source_file_name": "alpha.pdf", + "section_path": "Alpha / Two", + }, + }, + { + "chunk_id": "c1", + "content": "first section content\nwith more detail", + "sort_order": 1, + "source": { + "source_file_name": "alpha.pdf", + "section_path": "Alpha / One", + }, + }, + { + "chunk_id": "c3", + "content": "
metric
", + "source_file_name": "beta.pdf", + "section_path": "Beta / Table", + }, + ] + + evidence_text = _render_rows_evidence(rows) + + assert "[E1]" in evidence_text + assert "[E2]" in evidence_text + assert "[E3]" in evidence_text + assert "[§ alpha.pdf / Alpha / One]" in evidence_text + assert "[§ alpha.pdf / Alpha / Two]" in evidence_text + assert "[§ beta.pdf / Beta / Table]" in evidence_text + assert "first section content" in evidence_text + assert "second section content" in evidence_text + assert "
metric
" in evidence_text + assert "[Document]" not in evidence_text + assert "▸" not in evidence_text + assert "┈" not in evidence_text diff --git a/apps/api/tests/contract/test_legacy_evidence_renderer_contract.py b/apps/api/tests/contract/test_legacy_evidence_renderer_contract.py deleted file mode 100644 index dcf4c086f..000000000 --- a/apps/api/tests/contract/test_legacy_evidence_renderer_contract.py +++ /dev/null @@ -1,43 +0,0 @@ -from shared.services.retrieval.hydration.legacy_evidence import render_legacy_evidence_text - - -def test_render_legacy_evidence_text_should_group_documents_and_sections() -> None: - rows = [ - { - "chunk_id": "c2", - "content": "second section content", - "sort_order": 2, - "source": { - "source_file_name": "alpha.pdf", - "section_path": "Alpha / Two", - }, - }, - { - "chunk_id": "c1", - "content": "first section content\nwith more detail", - "sort_order": 1, - "source": { - "source_file_name": "alpha.pdf", - "section_path": "Alpha / One", - }, - }, - { - "chunk_id": "c3", - "content": "
metric
", - "source_file_name": "beta.pdf", - "section_path": "Beta / Table", - }, - ] - - evidence_text = render_legacy_evidence_text(rows) - - assert "[Document] alpha.pdf" in evidence_text - assert "[Document] beta.pdf" in evidence_text - assert "▸ Alpha / One" in evidence_text - assert "▸ Alpha / Two" in evidence_text - assert " ┈ first section content" in evidence_text - assert " ┈ with more detail" in evidence_text - assert " ┈
metric
" in evidence_text - assert "\u3010\u6587\u6863\u3011" not in evidence_text - assert "[\u8868\u683c\u5185\u5bb9]" not in evidence_text - assert "[\u56fe\u7247" not in evidence_text diff --git a/apps/api/tests/contract/test_retrieval_lazy_tree_contract.py b/apps/api/tests/contract/test_retrieval_lazy_tree_contract.py index a920bb396..9571f151f 100644 --- a/apps/api/tests/contract/test_retrieval_lazy_tree_contract.py +++ b/apps/api/tests/contract/test_retrieval_lazy_tree_contract.py @@ -70,7 +70,7 @@ def node_meta(self, section_id: str) -> NodeMeta: budget_chars=100, ) - assert result.evidence_text == "[E1]\n[§ Child]\nevidence" + assert result.evidence_text == "[E1]\n[§ Root / Child]\nevidence" assert provider.metadata_calls == 0 @@ -102,4 +102,4 @@ def test_evidence_pack_identifies_header_owners_from_parent_chain() -> None: ) assert result.kept_chunks == [chunks[1]] - assert result.evidence_text == "[E1]\n[§ Child]\nchild evidence" + assert result.evidence_text == "[E1]\n[§ Root / Parent / Child]\nchild evidence" diff --git a/apps/worker/tests/contract/test_page_memory_retrieval_contract.py b/apps/worker/tests/contract/test_page_memory_retrieval_contract.py index 922aff4c5..1319db3fb 100644 --- a/apps/worker/tests/contract/test_page_memory_retrieval_contract.py +++ b/apps/worker/tests/contract/test_page_memory_retrieval_contract.py @@ -144,6 +144,8 @@ async def test_table_result_assembly_uses_summary_not_html() -> None: assert "企业名称;统一社会信用代码" in content assert "SHOULD NOT LEAK" not in content assert " AbstractAsyncContextManager[AsyncSession]: return get_db_context() +def _evidence_path_header(row: dict) -> str: + source = row.get("source") + if not isinstance(source, dict): + source = row + file_name = str(source.get("source_file_name") or "").strip() + section_path = str(source.get("section_path") or "").strip() + if file_name and section_path: + return f"{file_name} / {section_path}" + return file_name or section_path + + +def _render_rows_evidence(rows: list[dict]) -> str: + groups: dict[str, list[str]] = {} + for row in rows: + header = _evidence_path_header(row) + content = str(row.get("content") or "").strip() + if not content: + continue + groups.setdefault(header, []).append(content) + return render_evidence_blocks(list(groups.items())) + + async def run_retrieval_route( context: RetrievalRouteContext, ) -> RetrievalRouteOutcome: @@ -102,7 +122,7 @@ async def _try_run_small_corpus_route( "namespace": context.namespace, "query": context.query, "router_used": "small_corpus_all", - "evidence_text": render_legacy_evidence_text(results), + "evidence_text": _render_rows_evidence(results), "answer_text": "", "results": results, } @@ -157,7 +177,7 @@ async def _run_classic_topk_route( "namespace": context.namespace, "query": context.query, "router_used": "classic_topk", - "evidence_text": render_legacy_evidence_text(results), + "evidence_text": _render_rows_evidence(results), "answer_text": "", "results": results, } diff --git a/packages/shared-python/shared/services/retrieval/hydration/asset_inline.py b/packages/shared-python/shared/services/retrieval/hydration/asset_inline.py new file mode 100644 index 000000000..4c49f00d8 --- /dev/null +++ b/packages/shared-python/shared/services/retrieval/hydration/asset_inline.py @@ -0,0 +1,85 @@ +"""Insert connected image/table bodies at text placeholders. + +Replaces ``[images/...]`` / ``[tables/...]`` (or ``connect_to.ref``) with the +asset display body, with a newline before and after. Targets not found at a +placeholder are appended once. Leftover path placeholders are stripped. +""" + +from __future__ import annotations + +import re +from collections.abc import Mapping, Sequence +from typing import Any + +_PATH_REF_RE = re.compile(r"\[(?:images|tables)/[^\]\n]+\]") +_SAME_AS_RE = re.compile(r"\[SAME-AS [^\]]+\]") + + +def strip_path_placeholders(content: str) -> str: + text = _PATH_REF_RE.sub("", content) + text = _SAME_AS_RE.sub("", text) + return text.strip() + + +def inline_assets_at_placeholders( + host_text: str, + *, + connections: Sequence[Mapping[str, Any]] | Sequence[Any], + display_by_target: Mapping[str, str], +) -> tuple[str, set[str]]: + """Return (body, embedded_target_ids). + + ``display_by_target`` maps chunk_id → display body. Only targets present in + that map are inserted. Each target is inserted at most once. + """ + text = str(host_text or "") + embedded: set[str] = set() + pending_append: list[tuple[str, str]] = [] + + for item in connections or (): + if not isinstance(item, Mapping): + continue + target_id = str(item.get("target") or "").strip() + if not target_id or target_id in embedded: + continue + body = str(display_by_target.get(target_id) or "").strip() + if not body: + continue + + ref = str(item.get("ref") or "").strip() + placed = False + for candidate in _ref_candidates(ref): + if candidate and candidate in text: + text = text.replace(candidate, f"\n{body}\n", 1) + embedded.add(target_id) + placed = True + break + if not placed: + pending_append.append((target_id, body)) + + for target_id, body in pending_append: + if target_id in embedded: + continue + if text.strip(): + text = f"{text.rstrip()}\n\n{body}" + else: + text = body + embedded.add(target_id) + + return strip_path_placeholders(text), embedded + + +def _ref_candidates(ref: str) -> list[str]: + raw = str(ref or "").strip() + if not raw: + return [] + out: list[str] = [raw] + if raw.startswith("[") and raw.endswith("]"): + inner = raw[1:-1].strip() + if inner and inner not in out: + out.append(inner) + else: + bracketed = f"[{raw}]" + if bracketed not in out: + out.append(bracketed) + return out diff --git a/packages/shared-python/shared/services/retrieval/hydration/evidence_text.py b/packages/shared-python/shared/services/retrieval/hydration/evidence_text.py new file mode 100644 index 000000000..a868302e7 --- /dev/null +++ b/packages/shared-python/shared/services/retrieval/hydration/evidence_text.py @@ -0,0 +1,45 @@ +"""Shared evidence_text rendering: one ``[E#]`` block per group. + +Each block is ``[E#]`` + ``[§ path]`` (full traceable path) + body lines. +Groups are caller-provided; bodies in one group stay in that group. +""" + +from __future__ import annotations + +from typing import Sequence + + +def render_evidence_blocks( + groups: Sequence[tuple[str, Sequence[str]]], + *, + start_index: int = 1, +) -> str: + """Render (path, bodies) groups into evidence_text. + + ``path`` is the full traceable header (e.g. file / section chain). + Bodies are joined with newlines; multiple bodies in one group are indented. + ``start_index`` sets the first ``[E#]`` number (default 1). + """ + parts: list[str] = [] + index = max(1, int(start_index or 1)) + for path, bodies in groups: + texts = [str(t or "").strip() for t in bodies] + texts = [t for t in texts if t] + if not texts: + continue + block: list[str] = [f"[E{index + len(parts)}]"] + header = str(path or "").strip() + if header: + block.append(f"[§ {header}]") + indent = len(texts) >= 2 + for text in texts: + if indent: + block.append( + "\n".join( + (" " + ln if ln.strip() else ln) for ln in text.splitlines() + ) + ) + else: + block.append(text) + parts.append("\n".join(block).strip()) + return "\n\n".join(parts) diff --git a/packages/shared-python/shared/services/retrieval/hydration/legacy_evidence.py b/packages/shared-python/shared/services/retrieval/hydration/legacy_evidence.py deleted file mode 100644 index 63b4ea358..000000000 --- a/packages/shared-python/shared/services/retrieval/hydration/legacy_evidence.py +++ /dev/null @@ -1,55 +0,0 @@ -from __future__ import annotations - -from collections import defaultdict -from typing import Any - - -def render_legacy_evidence_text(rows: list[dict[str, Any]]) -> str: - """Render assembled retrieval rows into evidence-only context.""" - grouped_rows: dict[str, list[dict[str, Any]]] = defaultdict(list) - for row in rows: - doc_name = _source_value(row, "source_file_name") or "Unknown document" - grouped_rows[doc_name].append(row) - - parts: list[str] = [] - for doc_name in sorted(grouped_rows): - parts.append(f"[Document] {doc_name}") - last_section = object() - for row in sorted(grouped_rows[doc_name], key=_row_sort_key): - section_path = _source_value(row, "section_path") or doc_name - if section_path != last_section: - parts.append(f"▸ {section_path}") - last_section = section_path - _append_content_lines(parts, row.get("content")) - - return "\n".join(parts) - - -def _source_value(row: dict[str, Any], key: str) -> str: - source = row.get("source") - if isinstance(source, dict): - value = source.get(key) - if value: - return str(value) - value = row.get(key) - return str(value) if value else "" - - -def _row_sort_key(row: dict[str, Any]) -> tuple[str, int, str]: - section_path = _source_value(row, "section_path") - try: - sort_order = int(row.get("sort_order") or 0) - except (TypeError, ValueError): - sort_order = 0 - chunk_id = str(row.get("chunk_id") or "") - return section_path, sort_order, chunk_id - - -def _append_content_lines(parts: list[str], content: object) -> None: - text = str(content or "").strip() - if not text: - return - for line in text.splitlines(): - stripped = line.strip() - if stripped: - parts.append(f" ┈ {stripped}") diff --git a/packages/shared-python/shared/services/retrieval/hydration/result_assembly.py b/packages/shared-python/shared/services/retrieval/hydration/result_assembly.py index 863bfadba..2f6aa54ad 100644 --- a/packages/shared-python/shared/services/retrieval/hydration/result_assembly.py +++ b/packages/shared-python/shared/services/retrieval/hydration/result_assembly.py @@ -5,9 +5,12 @@ from sqlalchemy.ext.asyncio import AsyncSession +from shared.services.retrieval.hydration.asset_inline import ( + inline_assets_at_placeholders, + strip_path_placeholders, +) from shared.services.retrieval.hydration.connected import hydrate_connected_target_rows from shared.services.retrieval.hydration.row_utils import ( - clean_content, extract_page_nums, filter_excluded_rows, iter_connected_target_ids, @@ -70,18 +73,12 @@ async def assemble_retrieval_results( assembled_row['content'] = _compose_table_content(row, rows_by_chunk_id) assembled_row['content_source'] = 'summary' elif chunk_type == 'text': - related_parts = _connected_media_parts(row, rows_by_chunk_id) - if base_content and related_parts: - assembled_row['content'] = '\n\n'.join([base_content, *related_parts]) - elif related_parts: - assembled_row['content'] = '\n\n'.join(related_parts) - else: - assembled_row['content'] = base_content + assembled_row['content'] = _compose_text_content(row, rows_by_chunk_id) assembled_row['content_source'] = 'content' else: assembled_row['content'] = base_content assembled_row['content_source'] = 'content' - assembled_row['content'] = clean_content(assembled_row['content']) + assembled_row['content'] = strip_path_placeholders(assembled_row['content']) assembled.append(assembled_row) return assembled @@ -93,6 +90,26 @@ def _page_summary(row: dict[str, Any]) -> str: return str(metadata.get('summary') or '').strip() +def _compose_text_content( + row: dict[str, Any], + rows_by_chunk_id: dict[str, dict[str, Any]], +) -> str: + base_content = str(row.get('content') or '') + display_by_target = _connected_display_by_target(row, rows_by_chunk_id) + if not display_by_target: + return base_content + metadata = row.get('chunk_metadata') or row.get('metadata') or {} + connections = ( + metadata.get('connect_to') if isinstance(metadata, dict) else None + ) or [] + content, _embedded = inline_assets_at_placeholders( + base_content, + connections=connections if isinstance(connections, list) else [], + display_by_target=display_by_target, + ) + return content + + def _compose_table_content( row: dict[str, Any], rows_by_chunk_id: dict[str, dict[str, Any]], @@ -102,11 +119,11 @@ def _compose_table_content( return '\n\n'.join(part for part in parts if part) -def _connected_media_parts( +def _connected_display_by_target( row: dict[str, Any], rows_by_chunk_id: dict[str, dict[str, Any]], -) -> list[str]: - connected_targets: list[tuple[int, str]] = [] +) -> dict[str, str]: + display: dict[str, str] = {} for target_id in iter_connected_target_ids(row): target_row = rows_by_chunk_id.get(target_id) if not target_row: @@ -119,10 +136,8 @@ def _connected_media_parts( else: continue if target_content: - sort_key = int(target_row.get('sort_order', 0) or 0) - connected_targets.append((sort_key, target_content)) - connected_targets.sort(key=lambda item: item[0]) - return [content for _, content in connected_targets] + display[target_id] = target_content + return display def _connected_image_parts( diff --git a/packages/shared-python/shared/services/retrieval/hydration/row_utils.py b/packages/shared-python/shared/services/retrieval/hydration/row_utils.py index 32bcb2ba2..74fa382e9 100644 --- a/packages/shared-python/shared/services/retrieval/hydration/row_utils.py +++ b/packages/shared-python/shared/services/retrieval/hydration/row_utils.py @@ -1,6 +1,5 @@ from __future__ import annotations -import re from typing import Any from shared.services.retrieval.search.section_filters import is_excluded_section @@ -22,15 +21,6 @@ ReferenceLookupKey = tuple[str, str, str, str] -_PATH_REF_RE = re.compile(r'\[(?:images|tables)/[^\]\n]+\]') -_SAME_AS_RE = re.compile(r'\[SAME-AS [^\]]+\]') - - -def clean_content(content: str) -> str: - text = _PATH_REF_RE.sub('', content) - text = _SAME_AS_RE.sub('', text) - return text.strip() - def normalize_chunk_type(raw: object) -> str: return str(raw or '').strip().split('\n', 1)[0].lower() diff --git a/packages/shared-python/shared/services/retrieval/nav/nav_compose.py b/packages/shared-python/shared/services/retrieval/nav/nav_compose.py index 3951a70c4..e23a5f825 100644 --- a/packages/shared-python/shared/services/retrieval/nav/nav_compose.py +++ b/packages/shared-python/shared/services/retrieval/nav/nav_compose.py @@ -12,6 +12,8 @@ from dataclasses import dataclass from typing import Any, Dict, List, Optional, Sequence, Set, Tuple +from shared.services.retrieval.hydration.evidence_text import render_evidence_blocks + from ._compat import Chunk from ._compat import line_node_id from ._compat import ToolSpace @@ -100,17 +102,11 @@ def direct_parent_id(ts: ToolSpace, section_id: str, doc_id: str) -> Optional[st return line_node_id(resolved, b.lines[p].line_id) -def _section_title(ts: ToolSpace, section_id: str, doc_id: str, *, max_chars: int = 40) -> str: +def _section_title(ts: ToolSpace, section_id: str, doc_id: str) -> str: sid = str(section_id or "").strip() if not sid: return "" - def _clip(text: str) -> str: - t = (text or "").strip() - if len(t) > max_chars: - return t[:max_chars].rstrip() - return t - # ``path_titles`` is a lightweight title lookup. Prefer it over # ``get_structure`` because the latter may calculate the complete subtree # chunk count, which is unnecessary while rendering evidence headers. @@ -121,7 +117,7 @@ def _clip(text: str) -> str: except TypeError: path = str(path_titles(sid) or "").strip() if path: - return _clip(path.rsplit(" / ", 1)[-1]) + return path # Prefer structure title (Knowhere / ProviderToolSpace); never parse ids. try: @@ -131,7 +127,7 @@ def _clip(text: str) -> str: if isinstance(st, dict): raw = st.get("preview") or st.get("title") or "" if isinstance(raw, str) and raw.strip(): - return _clip(raw.strip()) + return raw.strip() resolved = _section_doc_id(ts, sid, doc_id) idx = getattr(ts, "_idx", None) @@ -145,13 +141,13 @@ def _clip(text: str) -> str: bb = getattr(idx, "_bundles", {}).get(resolved) if bb and bb.lines: title = (bb.lines[0].content or "").strip() - return _clip(title) if title else sid + return title if title else sid return sid _, j = loc if j < 0 or j >= len(b.lines): return sid title = (b.lines[j].content or "").strip() - return _clip(title) if title else sid + return title if title else sid def _chunk_body(chunk: Chunk) -> str: @@ -302,7 +298,7 @@ def _build_groups( if parent_id is None: parent_id = owner if parent_id not in groups: - title = _section_title(ts, parent_id, owner_doc, max_chars=40) + title = _section_title(ts, parent_id, owner_doc) groups[parent_id] = _ParentGroup( parent_id=parent_id, parent_title=title, @@ -325,24 +321,13 @@ def _render_group( selected: Sequence[_ChildItem], *, evidence_index: int, - indent: bool, ) -> str: - """Render one evidence block (full text only).""" - parts: List[str] = [f"[E{evidence_index}]"] - if group.parent_title: - parts.append(f"[§ {group.parent_title}]") - for child in selected: - body = _chunk_body(child.chunk) - if not body: - continue - if indent: - indented = "\n".join( - (" " + ln if ln.strip() else ln) for ln in body.splitlines() - ) - parts.append(indented) - else: - parts.append(body) - return "\n".join(parts).strip() + """Render one evidence block via the shared evidence renderer.""" + bodies = [_chunk_body(child.chunk) for child in selected] + return render_evidence_blocks( + [(group.parent_title or "", bodies)], + start_index=evidence_index, + ) def _scored_flat(groups: Sequence[_ParentGroup]) -> List[Tuple[Chunk, float]]: @@ -399,9 +384,8 @@ def _render_kept( ] if not entries: continue - indent = len(entries) >= 2 block = _render_group( - g, entries, evidence_index=len(parts) + 1, indent=indent + g, entries, evidence_index=len(parts) + 1 ) add = len(block) + (len(sep) if parts else 0) if used + add <= budget_chars: diff --git a/packages/shared-python/shared/services/retrieval/nav/nav_hierarchy.py b/packages/shared-python/shared/services/retrieval/nav/nav_hierarchy.py index 80b9cde01..ead3dfc6e 100644 --- a/packages/shared-python/shared/services/retrieval/nav/nav_hierarchy.py +++ b/packages/shared-python/shared/services/retrieval/nav/nav_hierarchy.py @@ -36,6 +36,10 @@ runtime_checkable, ) +from shared.services.retrieval.hydration.asset_inline import ( + inline_assets_at_placeholders, +) + if TYPE_CHECKING: from .knowhere_hybrid import PersistedScoreCorpus @@ -179,17 +183,72 @@ def parent_id(self, section_id: str) -> Optional[str]: return str(parent) if parent else None def _node_unit_span(self, section_id: str) -> Tuple[str, int, int]: - """(joined text, first sort_order, unit count) for one node's own units.""" + """(joined text, first sort_order, unit count) for one node's own units. + + Evidence display only: text units insert connected assets at placeholders. + Scoring still uses ``materialize_self_only_chunks`` / raw ``unit_text``. + """ self_units = getattr(self._provider, "self_units", None) unit_text = getattr(self._provider, "unit_text", None) if not callable(self_units) or not callable(unit_text): return "", 0, 0 units = list(self_units(section_id) or ()) - texts = [t for t in (str(unit_text(u) or "").strip() for u in units) if t] - if not texts: - return "", 0, len(units) + if not units: + return "", 0, 0 first_order = int(getattr(units[0], "sort_order", 0) or 0) - return "\n".join(texts), first_order, len(units) + + asset_types = {"image", "table"} + text_units: List[Any] = [] + asset_by_id: Dict[str, str] = {} + for unit in units: + chunk_type = str(getattr(unit, "chunk_type", "") or "").strip().lower() + chunk_id = str(getattr(unit, "chunk_id", "") or "").strip() + body = str(unit_text(unit) or "").strip() + if chunk_type in asset_types: + if chunk_id and body: + asset_by_id[chunk_id] = body + continue + text_units.append(unit) + + if not text_units: + texts = [body for body in asset_by_id.values() if body] + return "\n".join(texts), first_order, len(units) + + parts: List[str] = [] + used_assets: Set[str] = set() + for unit in text_units: + content = str(unit_text(unit) or "").strip() + meta = getattr(unit, "metadata", None) or {} + connections = ( + meta.get("connect_to") if isinstance(meta, dict) else None + ) or [] + if not isinstance(connections, list): + connections = [] + wanted = { + str(item.get("target") or "").strip() + for item in connections + if isinstance(item, dict) + } + display = { + target_id: asset_by_id[target_id] + for target_id in wanted + if target_id in asset_by_id + } + content, embedded = inline_assets_at_placeholders( + content, + connections=connections, + display_by_target=display, + ) + used_assets.update(embedded) + if content: + parts.append(content) + + for target_id, body in asset_by_id.items(): + if target_id in used_assets or not body: + continue + parts.append(body) + + return "\n".join(parts), first_order, len(units) def _make_chunk( self, node_id: str, doc_id: str, text: str, order: int, section_id: str diff --git a/packages/shared-python/shared/testing/contract_runtime.py b/packages/shared-python/shared/testing/contract_runtime.py index 27a3c7de1..f5fbbd5f4 100644 --- a/packages/shared-python/shared/testing/contract_runtime.py +++ b/packages/shared-python/shared/testing/contract_runtime.py @@ -350,6 +350,13 @@ def configure_contract_environment( "QSTASH_CURRENT_SIGNING_KEY": "qstash-current-test-key", "QSTASH_NEXT_SIGNING_KEY": "qstash-next-test-key", "QSTASH_CALLBACK_BASE_URL": "http://localhost:5005/api/v1", + # Pin product defaults so a developer's local apps/api/.env cannot + # change contract expectations (credits seed, upload allow-list). + "FREE_PLAN_INITIAL_CREDITS": "5", + "SUPPORTED_EXTENSIONS": ( + ".doc,.docx,.pdf,.txt,.xls,.xlsx,.csv,.pptx," + ".jpg,.jpeg,.png,.md,.html,.htm" + ), } if "BILLING_ENABLED" not in os.environ: diff --git a/packages/shared-python/shared/tests/test_asset_inline.py b/packages/shared-python/shared/tests/test_asset_inline.py new file mode 100644 index 000000000..a59652f09 --- /dev/null +++ b/packages/shared-python/shared/tests/test_asset_inline.py @@ -0,0 +1,154 @@ +"""Unit tests for placeholder-based asset inlining.""" + +from __future__ import annotations + +import pytest + +from shared.services.retrieval.hydration.asset_inline import ( + inline_assets_at_placeholders, +) +from shared.services.retrieval.hydration.result_assembly import ( + assemble_retrieval_results, +) +from shared.services.retrieval.nav.nav_hierarchy import ProviderToolSpace +from shared.services.retrieval.nav.nav_knowhere import ( + KnowhereProvider, + SectionRow, + UnitRow, +) + + +def test_inline_replaces_placeholder_with_newlines() -> None: + body, embedded = inline_assets_at_placeholders( + "see [images/a.png] here", + connections=[ + { + "target": "img-1", + "relation": "embeds", + "ref": "[images/a.png]", + } + ], + display_by_target={"img-1": "[Image: images/a.png]\nsummary"}, + ) + assert body == "see \n[Image: images/a.png]\nsummary\n here" + assert embedded == {"img-1"} + assert "[images/" not in body + + +def test_inline_appends_when_placeholder_missing() -> None: + body, embedded = inline_assets_at_placeholders( + "plain text", + connections=[{"target": "img-1", "ref": "[images/a.png]"}], + display_by_target={"img-1": "[Image: images/a.png]"}, + ) + assert body == "plain text\n\n[Image: images/a.png]" + assert embedded == {"img-1"} + + +def test_inline_does_not_duplicate_target() -> None: + body, embedded = inline_assets_at_placeholders( + "x [images/a.png] y", + connections=[ + {"target": "img-1", "ref": "[images/a.png]"}, + {"target": "img-1", "ref": "[images/a.png]"}, + ], + display_by_target={"img-1": "[Image: images/a.png]"}, + ) + assert body.count("[Image: images/a.png]") == 1 + assert embedded == {"img-1"} + + +@pytest.mark.asyncio +async def test_assemble_inserts_table_at_placeholder() -> None: + rows = [ + { + "chunk_id": "text-1", + "chunk_type": "text", + "content": "见表 [tables/table-1.html] 结束", + "chunk_metadata": { + "connect_to": [ + { + "target": "table-1", + "relation": "embeds", + "ref": "[tables/table-1.html]", + } + ] + }, + }, + { + "chunk_id": "table-1", + "chunk_type": "table", + "content": "
SHOULD NOT LEAK
", + "file_path": "tables/table-1.html", + "asset_url": "https://assets.example.com/job-1/tables/table-1.html", + "chunk_metadata": { + "summary": "企业入驻信息登记模板", + "keywords": ["企业名称"], + }, + }, + ] + assembled = await assemble_retrieval_results( + rows=rows, + exclude_document_ids=[], + exclude_sections=[], + ) + assert len(assembled) == 1 + content = assembled[0]["content"] + assert "[tables/" not in content + assert content.index("见表") < content.index("[Table:") + assert content.index("[Table:") < content.index("结束") + assert "企业入驻信息登记模板" in content + assert "SHOULD NOT LEAK" not in content + + +def test_node_unit_span_inlines_section_assets() -> None: + provider = KnowhereProvider( + doc_id="doc-1", + sections=[ + SectionRow( + section_id="sec-1", + parent_section_id=None, + section_path="One", + section_title="One", + section_level=1, + summary="", + sort_order=0, + ) + ], + units=[ + UnitRow( + chunk_id="text-1", + section_id="sec-1", + chunk_type="text", + content="see [images/a.png] end", + sort_order=0, + metadata={ + "connect_to": [ + { + "target": "img-1", + "relation": "embeds", + "ref": "[images/a.png]", + } + ] + }, + ), + UnitRow( + chunk_id="img-1", + section_id="sec-1", + chunk_type="image", + content="images/a.png", + sort_order=1, + file_path="images/a.png", + metadata={"summary": "chart summary"}, + ), + ], + ) + ts = ProviderToolSpace(provider) + text, _order, count = ts._node_unit_span("sec-1") + assert count == 2 + assert "[images/" not in text + assert text.index("see") < text.index("[Image:") + assert text.index("[Image:") < text.index("end") + assert "chart summary" in text + # Asset must not also appear as a trailing standalone copy. + assert text.count("[Image:") == 1