2025-04-06 21:49:16 +08:00
|
|
|
import argparse
|
2025-08-26 21:27:40 +08:00
|
|
|
import os
|
|
|
|
|
import json
|
2026-04-06 22:51:04 +08:00
|
|
|
from pageindex.index.page_index import *
|
fix: address xhigh code-review findings on 2d46d68..8f536cb
Verified 12 findings from an xhigh-effort review of the prior review-fix
batch; all confirmed real. Most trace back to one root cause: the
build_index() text-stripping fix (8f536cb) correctly stopped Markdown from
leaking full text by default, but broke every path that assumed text could
be re-read later.
Correctness:
- LocalBackend._fill_node_text (get_document(include_text=True)) only handled
PDF's start_index/end_index convention; Markdown nodes use line_num and got
silently empty text. Now handles both.
- get_page_content's Markdown fallback (triggered when a StorageEngine
legitimately returns None from get_pages()) read from the now-text-stripped
structure. It now re-derives from the source file, mirroring the PDF
fallback, so it no longer depends on structure text at all.
- add_document's PDF-only text-stripping branch (with the stale "markdown
needs text in structure for fallback retrieval" comment) is now dead/wrong
since build_index() already applies if_add_node_text uniformly — removed.
- _validate_llm_provider's keyless-provider allowlist was missing several
local LiteLLM providers (xinference, llamafile, triton, oobabooga,
openai_like, docker_model_runner, custom, custom_openai, petals) that need
no API key just like ollama/lm_studio; expanded.
- The three agent-tool closures (get_document, get_document_structure,
get_page_content) had three different not-found patterns; two bypassed the
backend's DocumentNotFoundError entirely. Extracted LocalBackend.
_require_document as the single existence check every method/tool now uses.
- examples/agentic_vectorless_rag_demo.py's hand-rolled Agent() didn't apply
the litellm/ prefix normalization the SDK does internally, so its own
documented "any LiteLLM provider" claim broke for non-openai models.
- cloud delete_collection's cache eviction removed the "folders unavailable"
None sentinel too, forcing a wasted re-fetch; now only pops on a real id.
Cleanup / altitude:
- build_index() skips the remove_structure_text walk entirely when text was
never added (content_based + if_add_node_summary=False + if_add_node_text=
False) instead of a guaranteed no-op tree walk.
- page_index()'s locals()-capture-as-kwargs (fragile by construction) replaced
with an explicit dict of the named parameters.
- run_pageindex.py's _cli_bool and the page_index_md.py legacy shim's
_coerce_bool were duplicate, diverging implementations; both now bind
directly to the canonical pageindex.index.page_index_md._coerce_bool.
- retrieve.py's _get_md_page_content delegated its own traversal instead of
calling the canonical get_md_page_content; now a one-line delegation.
- FileTypeError's docstring now calls out the except-ordering gotcha from
also subclassing ValueError.
17 new regression tests (tests/test_review_fixes_2.py) plus 2 updated in
tests/test_legacy_shims.py for the simplified md_to_tree shim. Full suite:
210 passed, 2 skipped.
Claude-Session: https://claude.ai/code/session_01Kx5DgKbhK1N8autqXH8SmS
2026-07-08 21:56:41 +08:00
|
|
|
# Reuse the canonical yes/no coercion (as _cli_bool) instead of a second copy —
|
|
|
|
|
# a bare ``--flag`` (no value) resolves to True via argparse's ``const``; an
|
|
|
|
|
# explicit value keeps the legacy yes/no style working, so ``--flag no`` turns
|
|
|
|
|
# it off. argparse only ever passes a str here (const/default bypass type=).
|
|
|
|
|
from pageindex.index.page_index_md import md_to_tree, _coerce_bool as _cli_bool
|
2026-04-06 22:51:04 +08:00
|
|
|
from pageindex.config import IndexConfig
|
2025-04-06 21:49:16 +08:00
|
|
|
|
2026-07-08 19:11:52 +08:00
|
|
|
|
2025-04-06 21:49:16 +08:00
|
|
|
if __name__ == "__main__":
|
|
|
|
|
# Set up argument parser
|
2025-08-26 21:27:40 +08:00
|
|
|
parser = argparse.ArgumentParser(description='Process PDF or Markdown document and generate structure')
|
2025-08-28 12:45:39 +08:00
|
|
|
parser.add_argument('--pdf_path', type=str, help='Path to the PDF file')
|
|
|
|
|
parser.add_argument('--md_path', type=str, help='Path to the Markdown file')
|
|
|
|
|
|
2026-04-06 22:51:04 +08:00
|
|
|
parser.add_argument('--model', type=str, default=None, help='Model to use')
|
2025-08-28 12:45:39 +08:00
|
|
|
|
2026-03-20 18:47:07 +08:00
|
|
|
parser.add_argument('--toc-check-pages', type=int, default=None,
|
2025-08-26 21:27:40 +08:00
|
|
|
help='Number of pages to check for table of contents (PDF only)')
|
2026-03-20 18:47:07 +08:00
|
|
|
parser.add_argument('--max-pages-per-node', type=int, default=None,
|
2025-08-26 21:27:40 +08:00
|
|
|
help='Maximum number of pages per node (PDF only)')
|
2026-03-20 18:47:07 +08:00
|
|
|
parser.add_argument('--max-tokens-per-node', type=int, default=None,
|
2025-08-26 21:27:40 +08:00
|
|
|
help='Maximum number of tokens per node (PDF only)')
|
2025-08-28 12:45:39 +08:00
|
|
|
|
2026-07-08 19:11:52 +08:00
|
|
|
# Bare flag (e.g. --if-add-node-id) turns the option on; an explicit value
|
|
|
|
|
# keeps the legacy yes/no style, so --if-add-node-id no turns it off.
|
|
|
|
|
parser.add_argument('--if-add-node-id', nargs='?', const=True, type=_cli_bool, default=None,
|
|
|
|
|
help='Add node IDs (on by default). Bare flag or yes/no, e.g. --if-add-node-id no')
|
|
|
|
|
parser.add_argument('--if-add-node-summary', nargs='?', const=True, type=_cli_bool, default=None,
|
|
|
|
|
help='Add node summaries (on by default). Bare flag or yes/no')
|
|
|
|
|
parser.add_argument('--if-add-doc-description', nargs='?', const=True, type=_cli_bool, default=None,
|
|
|
|
|
help='Add a document description (on by default). Bare flag or yes/no')
|
|
|
|
|
parser.add_argument('--if-add-node-text', nargs='?', const=True, type=_cli_bool, default=None,
|
|
|
|
|
help='Add raw text to nodes (off by default). Bare flag or yes/no')
|
2026-04-06 22:51:04 +08:00
|
|
|
|
2025-08-26 21:27:40 +08:00
|
|
|
# Markdown specific arguments
|
fix: CMYK image drop, empty-doc crash, page_index shadowing, sqlite hardening, flaky tests
Addresses items 9-13 and a/b/c/f from the max-effort review of PR #272.
- pdf.py: image colorspace check was `pix.n > 4`, which treats CMYK-without-
alpha (n==4, same as RGBA) as not needing RGB conversion; pix.save() as .png
then raises "unsupported colorspace", silently dropped by the surrounding
except. Fixed to `pix.n - pix.alpha >= 4` (correctly converts CMYK, leaves
RGBA untouched).
- pipeline.py: detect_strategy([]) (an empty/whitespace-only source file)
returned "content_based", routing into the PDF-oriented TOC-detection
pipeline -- wasting a real LLM call before raising IndexingError. Empty
node lists now route to level_based, whose build_tree_from_levels([])
returns an empty structure instantly with zero LLM calls.
- page_index.py (shim): pageindex/__init__.py binds the canonical `page_index`
function as the package attribute, but this file is ALSO a real submodule
of the same name -- importing it anywhere (import machinery, unconditional)
overwrites that attribute with the module object, breaking
`from pageindex import page_index; page_index(x)` for the rest of the
process. Made the shim module itself callable (delegates to the real
function via a ModuleType subclass), so whichever object ends up in that
slot is callable regardless of import order.
- storage/sqlite.py: create_collection let a raw sqlite3.IntegrityError escape
on a duplicate name (new CollectionAlreadyExistsError); the collections
table's CHECK constraint only validated the name's first character (GLOB
'*' is a wildcard, not a regex quantifier over the preceding class) --
fixed to validate the whole string, and SQLiteStorage now also validates in
Python (it's a public StorageEngine usable directly, bypassing
LocalBackend's own check).
- tests/test_review_fixes_2.py: two tests used a ContentNode with no `level`
set, so build_index took the content_based path and made real (retried,
slow, and -- with a valid key -- billable) LLM calls instead of testing the
text-stripping logic they claimed to. Mocked out _content_based_pipeline.
- retrieve.py: _parse_pages/_get_pdf_page_content were independent copies of
the canonical parse_pages/get_pdf_page_content that had already drifted
(missing the p>=1 filter and 1000-page DoS cap) -- delegate to canonical
now, so the legacy pageindex.get_page_content path can't silently regress
again.
- parser/markdown.py: a leading UTF-8 BOM broke first-header detection
(not whitespace, .strip() doesn't remove it) -- decode utf-8-sig. Only
backtick fences were recognized as code blocks, so a '#'-prefixed line
inside a ~~~-fenced block (valid CommonMark) was misparsed as a heading --
recognize both fence styles.
- run_pageindex.py: --if-thinning wasn't migrated to the bare-flag +
legacy-yes/no convention the other four --if-add-* flags got; bare usage
raised an argparse error and it never went through the shared coercion.
- types.py: DocumentDetail's `structure` field was inside the class's
total=False body, so TypedDict rules made it optional even though every
backend always populates it. Split into a required base class.
Adds regression tests for all of the above. Full suite: 244 passed, 2 skipped
(one pre-existing, unrelated flaky cloud-streaming test).
Claude-Session: https://claude.ai/code/session_01Kx5DgKbhK1N8autqXH8SmS
2026-07-09 11:58:59 +08:00
|
|
|
parser.add_argument('--if-thinning', nargs='?', const=True, type=_cli_bool, default=None,
|
|
|
|
|
help='Apply tree thinning (off by default, markdown only). Bare flag or yes/no')
|
2025-08-26 21:27:40 +08:00
|
|
|
parser.add_argument('--thinning-threshold', type=int, default=5000,
|
|
|
|
|
help='Minimum token threshold for thinning (markdown only)')
|
|
|
|
|
parser.add_argument('--summary-token-threshold', type=int, default=200,
|
|
|
|
|
help='Token threshold for generating summaries (markdown only)')
|
2025-04-06 21:49:16 +08:00
|
|
|
args = parser.parse_args()
|
2026-04-06 22:51:04 +08:00
|
|
|
|
2025-08-28 12:45:39 +08:00
|
|
|
# Validate that exactly one file type is specified
|
|
|
|
|
if not args.pdf_path and not args.md_path:
|
|
|
|
|
raise ValueError("Either --pdf_path or --md_path must be specified")
|
|
|
|
|
if args.pdf_path and args.md_path:
|
|
|
|
|
raise ValueError("Only one of --pdf_path or --md_path can be specified")
|
2026-04-06 22:51:04 +08:00
|
|
|
|
|
|
|
|
# Build IndexConfig from CLI args (None values use defaults)
|
|
|
|
|
config_overrides = {
|
|
|
|
|
k: v for k, v in {
|
|
|
|
|
"model": args.model,
|
|
|
|
|
"toc_check_page_num": args.toc_check_pages,
|
|
|
|
|
"max_page_num_each_node": args.max_pages_per_node,
|
|
|
|
|
"max_token_num_each_node": args.max_tokens_per_node,
|
|
|
|
|
"if_add_node_id": args.if_add_node_id,
|
|
|
|
|
"if_add_node_summary": args.if_add_node_summary,
|
|
|
|
|
"if_add_doc_description": args.if_add_doc_description,
|
|
|
|
|
"if_add_node_text": args.if_add_node_text,
|
|
|
|
|
}.items() if v is not None
|
|
|
|
|
}
|
|
|
|
|
opt = IndexConfig(**config_overrides)
|
|
|
|
|
|
2025-08-28 12:45:39 +08:00
|
|
|
if args.pdf_path:
|
|
|
|
|
# Validate PDF file
|
|
|
|
|
if not args.pdf_path.lower().endswith('.pdf'):
|
|
|
|
|
raise ValueError("PDF file must have .pdf extension")
|
|
|
|
|
if not os.path.isfile(args.pdf_path):
|
|
|
|
|
raise ValueError(f"PDF file not found: {args.pdf_path}")
|
2025-08-26 21:27:40 +08:00
|
|
|
|
|
|
|
|
# Process the PDF
|
2025-08-28 12:45:39 +08:00
|
|
|
toc_with_page_number = page_index_main(args.pdf_path, opt)
|
2025-08-26 21:27:40 +08:00
|
|
|
print('Parsing done, saving to file...')
|
2026-04-06 22:51:04 +08:00
|
|
|
|
2025-08-26 21:27:40 +08:00
|
|
|
# Save results
|
2026-04-06 22:51:04 +08:00
|
|
|
pdf_name = os.path.splitext(os.path.basename(args.pdf_path))[0]
|
2025-09-02 20:22:17 +08:00
|
|
|
output_dir = './results'
|
|
|
|
|
output_file = f'{output_dir}/{pdf_name}_structure.json'
|
|
|
|
|
os.makedirs(output_dir, exist_ok=True)
|
2026-04-06 22:51:04 +08:00
|
|
|
|
2025-09-02 20:22:17 +08:00
|
|
|
with open(output_file, 'w', encoding='utf-8') as f:
|
2025-08-26 21:27:40 +08:00
|
|
|
json.dump(toc_with_page_number, f, indent=2)
|
2026-04-06 22:51:04 +08:00
|
|
|
|
2025-09-02 20:22:17 +08:00
|
|
|
print(f'Tree structure saved to: {output_file}')
|
2026-04-06 22:51:04 +08:00
|
|
|
|
2025-08-28 12:45:39 +08:00
|
|
|
elif args.md_path:
|
|
|
|
|
# Validate Markdown file
|
|
|
|
|
if not args.md_path.lower().endswith(('.md', '.markdown')):
|
|
|
|
|
raise ValueError("Markdown file must have .md or .markdown extension")
|
|
|
|
|
if not os.path.isfile(args.md_path):
|
|
|
|
|
raise ValueError(f"Markdown file not found: {args.md_path}")
|
2026-04-06 22:51:04 +08:00
|
|
|
|
2025-08-26 21:27:40 +08:00
|
|
|
# Process markdown file
|
|
|
|
|
print('Processing markdown file...')
|
|
|
|
|
import asyncio
|
2026-04-06 22:51:04 +08:00
|
|
|
|
2025-08-26 21:27:40 +08:00
|
|
|
toc_with_page_number = asyncio.run(md_to_tree(
|
2025-08-28 12:45:39 +08:00
|
|
|
md_path=args.md_path,
|
fix: CMYK image drop, empty-doc crash, page_index shadowing, sqlite hardening, flaky tests
Addresses items 9-13 and a/b/c/f from the max-effort review of PR #272.
- pdf.py: image colorspace check was `pix.n > 4`, which treats CMYK-without-
alpha (n==4, same as RGBA) as not needing RGB conversion; pix.save() as .png
then raises "unsupported colorspace", silently dropped by the surrounding
except. Fixed to `pix.n - pix.alpha >= 4` (correctly converts CMYK, leaves
RGBA untouched).
- pipeline.py: detect_strategy([]) (an empty/whitespace-only source file)
returned "content_based", routing into the PDF-oriented TOC-detection
pipeline -- wasting a real LLM call before raising IndexingError. Empty
node lists now route to level_based, whose build_tree_from_levels([])
returns an empty structure instantly with zero LLM calls.
- page_index.py (shim): pageindex/__init__.py binds the canonical `page_index`
function as the package attribute, but this file is ALSO a real submodule
of the same name -- importing it anywhere (import machinery, unconditional)
overwrites that attribute with the module object, breaking
`from pageindex import page_index; page_index(x)` for the rest of the
process. Made the shim module itself callable (delegates to the real
function via a ModuleType subclass), so whichever object ends up in that
slot is callable regardless of import order.
- storage/sqlite.py: create_collection let a raw sqlite3.IntegrityError escape
on a duplicate name (new CollectionAlreadyExistsError); the collections
table's CHECK constraint only validated the name's first character (GLOB
'*' is a wildcard, not a regex quantifier over the preceding class) --
fixed to validate the whole string, and SQLiteStorage now also validates in
Python (it's a public StorageEngine usable directly, bypassing
LocalBackend's own check).
- tests/test_review_fixes_2.py: two tests used a ContentNode with no `level`
set, so build_index took the content_based path and made real (retried,
slow, and -- with a valid key -- billable) LLM calls instead of testing the
text-stripping logic they claimed to. Mocked out _content_based_pipeline.
- retrieve.py: _parse_pages/_get_pdf_page_content were independent copies of
the canonical parse_pages/get_pdf_page_content that had already drifted
(missing the p>=1 filter and 1000-page DoS cap) -- delegate to canonical
now, so the legacy pageindex.get_page_content path can't silently regress
again.
- parser/markdown.py: a leading UTF-8 BOM broke first-header detection
(not whitespace, .strip() doesn't remove it) -- decode utf-8-sig. Only
backtick fences were recognized as code blocks, so a '#'-prefixed line
inside a ~~~-fenced block (valid CommonMark) was misparsed as a heading --
recognize both fence styles.
- run_pageindex.py: --if-thinning wasn't migrated to the bare-flag +
legacy-yes/no convention the other four --if-add-* flags got; bare usage
raised an argparse error and it never went through the shared coercion.
- types.py: DocumentDetail's `structure` field was inside the class's
total=False body, so TypedDict rules made it optional even though every
backend always populates it. Split into a required base class.
Adds regression tests for all of the above. Full suite: 244 passed, 2 skipped
(one pre-existing, unrelated flaky cloud-streaming test).
Claude-Session: https://claude.ai/code/session_01Kx5DgKbhK1N8autqXH8SmS
2026-07-09 11:58:59 +08:00
|
|
|
if_thinning=bool(args.if_thinning),
|
2025-08-26 21:27:40 +08:00
|
|
|
min_token_threshold=args.thinning_threshold,
|
2025-08-28 12:45:39 +08:00
|
|
|
if_add_node_summary=opt.if_add_node_summary,
|
2025-08-26 21:27:40 +08:00
|
|
|
summary_token_threshold=args.summary_token_threshold,
|
2025-08-28 12:45:39 +08:00
|
|
|
model=opt.model,
|
|
|
|
|
if_add_doc_description=opt.if_add_doc_description,
|
|
|
|
|
if_add_node_text=opt.if_add_node_text,
|
|
|
|
|
if_add_node_id=opt.if_add_node_id
|
2025-08-26 21:27:40 +08:00
|
|
|
))
|
2026-04-06 22:51:04 +08:00
|
|
|
|
2025-08-26 21:27:40 +08:00
|
|
|
print('Parsing done, saving to file...')
|
2026-04-06 22:51:04 +08:00
|
|
|
|
2025-08-26 21:27:40 +08:00
|
|
|
# Save results
|
2026-04-06 22:51:04 +08:00
|
|
|
md_name = os.path.splitext(os.path.basename(args.md_path))[0]
|
2025-09-02 20:22:17 +08:00
|
|
|
output_dir = './results'
|
|
|
|
|
output_file = f'{output_dir}/{md_name}_structure.json'
|
|
|
|
|
os.makedirs(output_dir, exist_ok=True)
|
2026-04-06 22:51:04 +08:00
|
|
|
|
2025-09-02 20:22:17 +08:00
|
|
|
with open(output_file, 'w', encoding='utf-8') as f:
|
|
|
|
|
json.dump(toc_with_page_number, f, indent=2, ensure_ascii=False)
|
2026-04-06 22:51:04 +08:00
|
|
|
|
|
|
|
|
print(f'Tree structure saved to: {output_file}')
|