Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 8 additions & 23 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,6 @@ DataFrame is an internal detail. Below is the complete schema.
```text
~/.knowhere/{corpus_name}/
├── knowledge_graph.json # corpus-wide graph: file metadata + cross-doc edges
├── chunk_stats.json # Per-chunk retrieval hit analytics {chunk_id → stats}
├── {source_file_name}/ # One directory per ingested document
│ ├── chunks.json # All parsed chunks for this document
│ ├── doc_nav.json # Hierarchical navigation tree for agentic retrieval
Expand All @@ -264,7 +263,6 @@ DataFrame is an internal detail. Below is the complete schema.
"types": { "image": 13, "table": 1, "text": 141 },
"top_keywords": ["model", "security", "ai", "operations", "artificial_intelligence"],
"top_summary": "This document includes: Legal Notice, Foreword, 1. Overview, ...",
"importance": 0.3,
"created_at": "2026-05-09T09:14:12.422208+00:00"
}
},
Expand All @@ -276,25 +274,8 @@ DataFrame is an internal detail. Below is the complete schema.
|:---|:---|
| `files.{name}.top_keywords` | TF-IDF top keywords across all chunks (used for cross-doc edge scoring) |
| `files.{name}.top_summary` | Auto-generated outline of top-level headings (injected by `load_nav_top_summary()`) |
| `files.{name}.importance` | Base importance score (feeds `compute_importance_score()` in ranking) |
| `edges[]` | Cross-document edges with `{source, target, weight, shared_keywords}` when keyword overlap ≥ 0.8 |

### `chunk_stats.json` — Retrieval Hit Analytics

```json
{
"2e2beffc-90b2-5429-8ee7-3c49260a1204": {
"hit_count": 0,
"first_hit": null,
"last_hit": null,
"created_at": "2026-05-09T09:14:12.423011+00:00"
}
}
```

Keyed by `chunk_id`. `hit_count` and `last_hit` feed into `importance_norm_score`
for retrieval ranking boost.

### `chunks.json` — Per-Document Chunk Records

The core persisted data. Contains `{"chunks": [...]}` — an ordered array of
Expand Down Expand Up @@ -340,7 +321,10 @@ chunk objects. Three chunk types exist:
"page_nums": [],
"file_path": "images/image-1 ai_model.png",
"keywords": [],
"tokens": []
"tokens": [],
"connect_to": [
{ "target": "d88e4c47-3c48-5bdf-b849-693c00453021", "relation": "embedded_by" }
]
}
}
```
Expand Down Expand Up @@ -374,13 +358,14 @@ chunk objects. Three chunk types exist:
| `path` | `str` | Hierarchical path: `{kb}/{file}/{section1}/{section2}/...` for text; `images/...` or `tables/...` for assets |
| `metadata.length` | `int` | Character count of content |
| `metadata.summary` | `str` | LLM summary (images/tables: `"image-N\n{description}"`) |
| `metadata.tokens` | `list[str]` | Pre-tokenized Chinese terms for BM25 retrieval |
| `metadata.entities` | `list[{text, type}]` | Typed entities extracted from content or asset VLM/LLM analysis |
| `metadata.keywords` | `list[str]` | LLM-extracted keywords (semicolon-split from DataFrame) |
| `metadata.page_nums` | `list[int]` | Source page numbers (PDF only) |
| `metadata.file_path` | `str` | Relative asset path for images/tables |
| `metadata.connect_to` | `list[ConnectionValue]` | Cross-chunk references (text→image/table embeddings) |
| `metadata.connect_to` | `list[ConnectionValue]` | Cross-chunk relations (body↔image/table embeddings) |
| `metadata.connect_to[].target` | `str` | Target chunk_id |
| `metadata.connect_to[].relation` | `str` | `"embeds"` (inline asset) or `"related"` |
| `metadata.connect_to[].target_document_id` | `str` | Reserved for cross-document targets; omitted for same-document edges |
| `metadata.connect_to[].relation` | `str` | `"embeds"` (body → inline asset), `"embedded_by"` (asset → owning body), `"related"` (same-page body → asset); `"cross_doc_entity"` / `"intra_doc_ref"` are reserved and not generated yet |
| `metadata.connect_to[].ref` | `str` | Original reference string: `"[images/image-1.png]"` |
| `metadata.connect_to[].position` | `{start, end}` | Character offset of the reference in content |

Expand Down
16 changes: 8 additions & 8 deletions apps/worker/app/services/page_memory/node_assembler.py
Original file line number Diff line number Diff line change
Expand Up @@ -630,14 +630,14 @@ def _attach_asset_connections(
row = rows_by_path.get(leaf.section_path)
if row is None:
continue
connection: dict[str, Any] = {
"target": uri,
"relation": "related",
"ref": ref,
}
if owner_leaf is not None:
connection["same_as_owner"] = owner_leaf.section_path
_append_connect_to(row, connection)
_append_connect_to(
row,
{
"target": uri,
"relation": "related",
"ref": ref,
},
)


def _append_connect_to(row: dict[str, Any], connection: dict[str, Any]) -> None:
Expand Down
17 changes: 13 additions & 4 deletions apps/worker/app/services/page_memory/page_assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from shared.services.ai.prompt_service import build_prompt

_GRID_SIZE = 1000
_ASSET_CROP_MARGIN_PX = 4
_VALID_KINDS = {"table", "figure"}
_ASSET_ANNOTATE_DIR = "asset_annotate"

Expand Down Expand Up @@ -194,7 +195,7 @@ def crop_page_asset(
asset: PageAsset,
page_image_path: str,
output_dir: str,
margin_px: int = 4,
margin_px: int = _ASSET_CROP_MARGIN_PX,
) -> PageAsset:
image_dir = Path(output_dir) / "images"
image_dir.mkdir(parents=True, exist_ok=True)
Expand Down Expand Up @@ -543,10 +544,18 @@ def build_asset_rows(


def _asset_extra_metadata(asset: PageAsset) -> dict[str, Any]:
metadata: dict[str, Any] = {
"image_uri": asset.image_uri,
# bbox is the detected (VLM) box on the rendered page image, not the cropped box.
return {
"asset_region": {
"page_num": asset.page_index,
"bbox": list(asset.bbox_px),
"coord_origin": "top_left",
"coord_system": "page_image_px",
"page_width": asset.width_px,
"page_height": asset.height_px,
"crop_margin_px": _ASSET_CROP_MARGIN_PX,
}
}
return metadata


def asset_reference(asset: PageAsset) -> str:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -421,14 +421,24 @@ def test_build_node_rows_prepends_asset_rows_and_links_page_nodes() -> None:
assert "asset_kind" not in rows[0]["extra_metadata"]
assert "title" not in rows[0]["extra_metadata"]
assert "html_uri" not in rows[0]["extra_metadata"]
assert "image_uri" not in rows[0]["extra_metadata"]
assert rows[0]["extra_metadata"]["asset_region"] == {
"page_num": 231,
"bbox": [10, 20, 200, 120],
"coord_origin": "top_left",
"coord_system": "page_image_px",
"page_width": 1000,
"page_height": 1400,
"crop_margin_px": 4,
}

by_path = {row["path"]: row for row in rows}
owner = by_path["demo.pdf/3 基本规定/3.1 职责"]
shared = by_path["demo.pdf/3 基本规定/3.2 管理规定"]
assert '"relation": "embeds"' in owner["connectto"]
assert '"target": "tables/table_page_231_1.html"' in owner["connectto"]
assert '"relation": "related"' in shared["connectto"]
assert '"same_as_owner": "demo.pdf/3 基本规定/3.1 职责"' in shared["connectto"]
assert "same_as_owner" not in shared["connectto"]


def test_page_connectto_normalizes_to_asset_chunk_id() -> None:
Expand Down Expand Up @@ -481,3 +491,6 @@ def test_page_connectto_normalizes_to_asset_chunk_id() -> None:
"ref": "[tables/table_page_1_1.html]",
}
]
assert asset_chunk["metadata"]["connect_to"] == [
{"target": "page_1", "relation": "embedded_by"}
]
132 changes: 132 additions & 0 deletions apps/worker/tests/contract/test_page_memory_output_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,143 @@
os.environ.setdefault("S3_SECRET_ACCESS_KEY", "test")
os.environ.setdefault("S3_TEMP_PATH", "/tmp")

from app.services.document_parser.support.parser_rows import PARSER_ROW_COLUMNS
from app.services.page_memory import memory_service
from app.services.page_memory.page_assets import PageAsset, build_asset_rows
from shared.services.chunks.dataframe_chunk_converter import dataframe_to_chunks
from shared.services.storage.zip_chunk_schema import ZipChunkSchemaBuilder


def _page_asset(*, kind: str, asset_index: int, **uris: str) -> PageAsset:
return PageAsset(
asset_id=f"asset_{kind}_{asset_index}",
page_index=5,
asset_index=asset_index,
kind=kind,
bbox_px=[10, 20, 200, 120],
width_px=1000,
height_px=1400,
width_pt=500,
height_pt=700,
title=f"{kind} title",
summary=f"{kind} summary",
keywords=["k1", "k2"],
entities=[{"text": "Toolformer", "type": "model"}],
**uris,
)


def test_asset_region_and_metadata_match_between_chunks_and_zip() -> None:
figure = _page_asset(
kind="figure",
asset_index=1,
image_uri="images/image_page_5_figure_1.png",
)
table = _page_asset(
kind="table",
asset_index=2,
image_uri="images/image_page_5_table_2.png",
html_uri="tables/table_page_5_2.html",
)
rows = build_asset_rows({5: [figure, table]})
df = pd.DataFrame(rows, columns=pd.Index([*PARSER_ROW_COLUMNS, "extra_metadata"]))

chunks = dataframe_to_chunks(df)
formatted = ZipChunkSchemaBuilder().format_chunks(
chunks,
image_files_map={
"asset_figure_1": {"file_path": "images/image_page_5_figure_1.png"}
},
table_files_map={"asset_table_2": {"file_path": "tables/table_page_5_2.html"}},
)

chunk_by_id = {chunk["chunk_id"]: chunk["metadata"] for chunk in chunks}
zip_by_id = {chunk["chunk_id"]: chunk["metadata"] for chunk in formatted}
expected_region = {
"page_num": 5,
"bbox": [10, 20, 200, 120],
"coord_origin": "top_left",
"coord_system": "page_image_px",
"page_width": 1000,
"page_height": 1400,
"crop_margin_px": 4,
}
for asset_id in ("asset_figure_1", "asset_table_2"):
chunk_metadata = chunk_by_id[asset_id]
zip_metadata = zip_by_id[asset_id]
assert chunk_metadata["asset_region"] == expected_region
assert "image_uri" not in chunk_metadata
assert set(zip_metadata) <= set(chunk_metadata)
for key in (
"asset_region",
"entities",
"asset_title",
"keywords",
"summary",
"page_nums",
"length",
):
assert zip_metadata[key] == chunk_metadata[key], key
assert zip_by_id["asset_figure_1"]["original_name"] == (
chunk_by_id["asset_figure_1"]["original_name"]
)
assert zip_by_id["asset_figure_1"]["entities"] == [
{"text": "Toolformer", "type": "model"}
]


def test_zip_chunk_schema_omits_malformed_asset_region() -> None:
chunks = [
{
"chunk_id": "asset_figure_1",
"type": "image",
"content": "figure",
"path": "images/a.png",
"metadata": {
"file_path": "images/a.png",
"asset_region": {"page_num": 5, "bbox": [1, 2, 3]},
},
}
]

formatted = ZipChunkSchemaBuilder().format_chunks(
chunks,
image_files_map={"asset_figure_1": {"file_path": "images/a.png"}},
table_files_map={},
)

assert "asset_region" not in formatted[0]["metadata"]


def test_zip_text_omits_legacy_tokens_field() -> None:
chunks = dataframe_to_chunks(
pd.DataFrame(
[
{
"content": "body",
"path": "demo.pdf/Root",
"type": "PTXT",
"length": 4,
"keywords": "",
"summary": "",
"know_id": "text-1",
"tokens": "",
"connectto": "",
"addtime": "2026-06-11 00:00:00",
"page_nums": "1",
}
]
)
)

formatted = ZipChunkSchemaBuilder().format_chunks(
chunks, image_files_map={}, table_files_map={}
)

assert "tokens" not in chunks[0]["metadata"]
assert "tokens" not in formatted[0]["metadata"]


def test_dataframe_converter_accepts_page_chunks_with_extra_metadata() -> None:
df = pd.DataFrame(
[
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,13 @@ def test_table_embedded_images_are_extracted_rewritten_and_linked(
for item in table_connect
)

text_id = str(by_type["text"]["chunk_id"])
image_connect = by_type["image"]["metadata"]["connect_to"]
assert len(image_connect) == 2
assert all(item["relation"] == "embedded_by" for item in image_connect)
assert {item["target"] for item in image_connect} == {text_id, table_id}
assert {"target": text_id, "relation": "embedded_by"} in table_connect

def test_table_embedded_images_skip_below_img_min_size(tmp_path: Path) -> None:
output_dir = tmp_path / "doc"
images_dir = output_dir / "images"
Expand Down
Loading
Loading