querdex 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. {querdex-0.1.0 → querdex-0.2.0}/.gitignore +1 -0
  2. {querdex-0.1.0 → querdex-0.2.0}/PKG-INFO +72 -2
  3. {querdex-0.1.0 → querdex-0.2.0}/README.md +70 -0
  4. {querdex-0.1.0 → querdex-0.2.0}/pyproject.toml +2 -2
  5. {querdex-0.1.0 → querdex-0.2.0}/scripts/run_release_gate.py +3 -3
  6. {querdex-0.1.0 → querdex-0.2.0}/src/querdex/__init__.py +1 -1
  7. {querdex-0.1.0 → querdex-0.2.0}/src/querdex/cli.py +36 -0
  8. querdex-0.2.0/src/querdex/extraction/__init__.py +29 -0
  9. querdex-0.2.0/src/querdex/extraction/aligner.py +81 -0
  10. querdex-0.2.0/src/querdex/extraction/chunker.py +108 -0
  11. querdex-0.2.0/src/querdex/extraction/extractor.py +291 -0
  12. querdex-0.2.0/src/querdex/extraction/models.py +89 -0
  13. querdex-0.2.0/src/querdex/extraction/visualize.py +175 -0
  14. {querdex-0.1.0 → querdex-0.2.0}/src/querdex/ingestion/parsers/sqlite_parser.py +8 -2
  15. {querdex-0.1.0 → querdex-0.2.0}/src/querdex/ingestion/parsers/url_parser.py +53 -4
  16. {querdex-0.1.0 → querdex-0.2.0}/src/querdex/services/engine.py +21 -0
  17. {querdex-0.1.0 → querdex-0.2.0}/src/querdex/storage/graph_store.py +16 -6
  18. {querdex-0.1.0 → querdex-0.2.0}/src/querdex/storage/sqlite_store.py +36 -1
  19. querdex-0.2.0/tests/test_extraction.py +245 -0
  20. {querdex-0.1.0 → querdex-0.2.0}/tests/test_ingestion_additional.py +5 -15
  21. {querdex-0.1.0 → querdex-0.2.0}/uv.lock +1 -1
  22. {querdex-0.1.0 → querdex-0.2.0}/.env.example +0 -0
  23. {querdex-0.1.0 → querdex-0.2.0}/.github/workflows/ci.yml +0 -0
  24. {querdex-0.1.0 → querdex-0.2.0}/.github/workflows/publish.yml +0 -0
  25. {querdex-0.1.0 → querdex-0.2.0}/Makefile +0 -0
  26. {querdex-0.1.0 → querdex-0.2.0}/infra/Dockerfile +0 -0
  27. {querdex-0.1.0 → querdex-0.2.0}/infra/ENVIRONMENTS.md +0 -0
  28. {querdex-0.1.0 → querdex-0.2.0}/infra/README.md +0 -0
  29. {querdex-0.1.0 → querdex-0.2.0}/infra/docker-compose.yml +0 -0
  30. {querdex-0.1.0 → querdex-0.2.0}/scripts/dev_index_and_query.sh +0 -0
  31. {querdex-0.1.0 → querdex-0.2.0}/src/querdex/adaptive/__init__.py +0 -0
  32. {querdex-0.1.0 → querdex-0.2.0}/src/querdex/adaptive/updater.py +0 -0
  33. {querdex-0.1.0 → querdex-0.2.0}/src/querdex/evaluation/__init__.py +0 -0
  34. {querdex-0.1.0 → querdex-0.2.0}/src/querdex/evaluation/harness.py +0 -0
  35. {querdex-0.1.0 → querdex-0.2.0}/src/querdex/evaluation/metrics.py +0 -0
  36. {querdex-0.1.0 → querdex-0.2.0}/src/querdex/indexing/__init__.py +0 -0
  37. {querdex-0.1.0 → querdex-0.2.0}/src/querdex/indexing/coordinator.py +0 -0
  38. {querdex-0.1.0 → querdex-0.2.0}/src/querdex/indexing/diff_reindex.py +0 -0
  39. {querdex-0.1.0 → querdex-0.2.0}/src/querdex/indexing/entity_extractor.py +0 -0
  40. {querdex-0.1.0 → querdex-0.2.0}/src/querdex/indexing/entity_map_updater.py +0 -0
  41. {querdex-0.1.0 → querdex-0.2.0}/src/querdex/indexing/graph_builder.py +0 -0
  42. {querdex-0.1.0 → querdex-0.2.0}/src/querdex/indexing/quality.py +0 -0
  43. {querdex-0.1.0 → querdex-0.2.0}/src/querdex/indexing/tree_builder.py +0 -0
  44. {querdex-0.1.0 → querdex-0.2.0}/src/querdex/ingestion/__init__.py +0 -0
  45. {querdex-0.1.0 → querdex-0.2.0}/src/querdex/ingestion/base.py +0 -0
  46. {querdex-0.1.0 → querdex-0.2.0}/src/querdex/ingestion/orchestrator.py +0 -0
  47. {querdex-0.1.0 → querdex-0.2.0}/src/querdex/ingestion/parsers/__init__.py +0 -0
  48. {querdex-0.1.0 → querdex-0.2.0}/src/querdex/ingestion/parsers/audio_video_parser.py +0 -0
  49. {querdex-0.1.0 → querdex-0.2.0}/src/querdex/ingestion/parsers/code_parser.py +0 -0
  50. {querdex-0.1.0 → querdex-0.2.0}/src/querdex/ingestion/parsers/csv_parser.py +0 -0
  51. {querdex-0.1.0 → querdex-0.2.0}/src/querdex/ingestion/parsers/docx_parser.py +0 -0
  52. {querdex-0.1.0 → querdex-0.2.0}/src/querdex/ingestion/parsers/html_parser.py +0 -0
  53. {querdex-0.1.0 → querdex-0.2.0}/src/querdex/ingestion/parsers/markdown_parser.py +0 -0
  54. {querdex-0.1.0 → querdex-0.2.0}/src/querdex/ingestion/parsers/ocr.py +0 -0
  55. {querdex-0.1.0 → querdex-0.2.0}/src/querdex/ingestion/parsers/pdf_parser.py +0 -0
  56. {querdex-0.1.0 → querdex-0.2.0}/src/querdex/ingestion/parsers/text_parser.py +0 -0
  57. {querdex-0.1.0 → querdex-0.2.0}/src/querdex/llm/__init__.py +0 -0
  58. {querdex-0.1.0 → querdex-0.2.0}/src/querdex/llm/anthropic_client.py +0 -0
  59. {querdex-0.1.0 → querdex-0.2.0}/src/querdex/llm/client.py +0 -0
  60. {querdex-0.1.0 → querdex-0.2.0}/src/querdex/llm/fake_client.py +0 -0
  61. {querdex-0.1.0 → querdex-0.2.0}/src/querdex/llm/openai_client.py +0 -0
  62. {querdex-0.1.0 → querdex-0.2.0}/src/querdex/ops/__init__.py +0 -0
  63. {querdex-0.1.0 → querdex-0.2.0}/src/querdex/ops/health.py +0 -0
  64. {querdex-0.1.0 → querdex-0.2.0}/src/querdex/ops/observability.py +0 -0
  65. {querdex-0.1.0 → querdex-0.2.0}/src/querdex/ops/retry.py +0 -0
  66. {querdex-0.1.0 → querdex-0.2.0}/src/querdex/query/__init__.py +0 -0
  67. {querdex-0.1.0 → querdex-0.2.0}/src/querdex/query/analyzer.py +0 -0
  68. {querdex-0.1.0 → querdex-0.2.0}/src/querdex/query/answering.py +0 -0
  69. {querdex-0.1.0 → querdex-0.2.0}/src/querdex/query/graph_walker.py +0 -0
  70. {querdex-0.1.0 → querdex-0.2.0}/src/querdex/query/multi_doc.py +0 -0
  71. {querdex-0.1.0 → querdex-0.2.0}/src/querdex/query/router.py +0 -0
  72. {querdex-0.1.0 → querdex-0.2.0}/src/querdex/query/tiered_search.py +0 -0
  73. {querdex-0.1.0 → querdex-0.2.0}/src/querdex/schemas/__init__.py +0 -0
  74. {querdex-0.1.0 → querdex-0.2.0}/src/querdex/schemas/models.py +0 -0
  75. {querdex-0.1.0 → querdex-0.2.0}/src/querdex/services/__init__.py +0 -0
  76. {querdex-0.1.0 → querdex-0.2.0}/src/querdex/services/interfaces.py +0 -0
  77. {querdex-0.1.0 → querdex-0.2.0}/src/querdex/storage/__init__.py +0 -0
  78. {querdex-0.1.0 → querdex-0.2.0}/src/querdex/utils/__init__.py +0 -0
  79. {querdex-0.1.0 → querdex-0.2.0}/src/querdex/utils/llm_validation.py +0 -0
  80. {querdex-0.1.0 → querdex-0.2.0}/src/querdex/utils/query_cluster.py +0 -0
  81. {querdex-0.1.0 → querdex-0.2.0}/src/querdex/utils/tree_ops.py +0 -0
  82. {querdex-0.1.0 → querdex-0.2.0}/tests/fixtures/eval/baseline_cases.json +0 -0
  83. {querdex-0.1.0 → querdex-0.2.0}/tests/fixtures/eval/kpi_baseline.json +0 -0
  84. {querdex-0.1.0 → querdex-0.2.0}/tests/fixtures/golden/parser_manifest.json +0 -0
  85. {querdex-0.1.0 → querdex-0.2.0}/tests/fixtures/golden/sample.csv +0 -0
  86. {querdex-0.1.0 → querdex-0.2.0}/tests/fixtures/golden/sample.html +0 -0
  87. {querdex-0.1.0 → querdex-0.2.0}/tests/fixtures/golden/sample.md +0 -0
  88. {querdex-0.1.0 → querdex-0.2.0}/tests/fixtures/golden/sample.py +0 -0
  89. {querdex-0.1.0 → querdex-0.2.0}/tests/fixtures/golden/sample.txt +0 -0
  90. {querdex-0.1.0 → querdex-0.2.0}/tests/test_adaptive_updater.py +0 -0
  91. {querdex-0.1.0 → querdex-0.2.0}/tests/test_diff_reindex.py +0 -0
  92. {querdex-0.1.0 → querdex-0.2.0}/tests/test_docx_integration.py +0 -0
  93. {querdex-0.1.0 → querdex-0.2.0}/tests/test_entity_map_updater.py +0 -0
  94. {querdex-0.1.0 → querdex-0.2.0}/tests/test_evaluation_harness.py +0 -0
  95. {querdex-0.1.0 → querdex-0.2.0}/tests/test_graph_builder.py +0 -0
  96. {querdex-0.1.0 → querdex-0.2.0}/tests/test_graph_store.py +0 -0
  97. {querdex-0.1.0 → querdex-0.2.0}/tests/test_ingestion.py +0 -0
  98. {querdex-0.1.0 → querdex-0.2.0}/tests/test_llm_integration.py +0 -0
  99. {querdex-0.1.0 → querdex-0.2.0}/tests/test_metrics_harness.py +0 -0
  100. {querdex-0.1.0 → querdex-0.2.0}/tests/test_ocr_provider.py +0 -0
  101. {querdex-0.1.0 → querdex-0.2.0}/tests/test_parser_golden.py +0 -0
  102. {querdex-0.1.0 → querdex-0.2.0}/tests/test_pdf_integration.py +0 -0
  103. {querdex-0.1.0 → querdex-0.2.0}/tests/test_pdf_parser.py +0 -0
  104. {querdex-0.1.0 → querdex-0.2.0}/tests/test_pipeline.py +0 -0
  105. {querdex-0.1.0 → querdex-0.2.0}/tests/test_query_analyzer.py +0 -0
  106. {querdex-0.1.0 → querdex-0.2.0}/tests/test_query_graph_multi.py +0 -0
  107. {querdex-0.1.0 → querdex-0.2.0}/tests/test_reindex.py +0 -0
  108. {querdex-0.1.0 → querdex-0.2.0}/tests/test_routing_suite.py +0 -0
  109. {querdex-0.1.0 → querdex-0.2.0}/tests/test_schemas.py +0 -0
  110. {querdex-0.1.0 → querdex-0.2.0}/tests/test_store.py +0 -0
  111. {querdex-0.1.0 → querdex-0.2.0}/tests/test_tree_builder.py +0 -0
  112. {querdex-0.1.0 → querdex-0.2.0}/tests/test_tree_quality.py +0 -0
@@ -33,3 +33,4 @@ RUNBOOK.md
33
33
  .env
34
34
  .env.*
35
35
  !.env.example
36
+ test_api_key.py
@@ -1,8 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: querdex
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Reasoning-first document intelligence system
5
- Author-email: Your Name <you@example.com>
5
+ Author-email: Animay Tiwari <animaytiwari@outlook.com>
6
6
  License: MIT
7
7
  Keywords: document,indexing,llm,rag,retrieval
8
8
  Classifier: Development Status :: 3 - Alpha
@@ -32,6 +32,12 @@ Description-Content-Type: text/markdown
32
32
 
33
33
  # Querdex
34
34
 
35
+ [![PyPI version](https://img.shields.io/pypi/v/querdex)](https://pypi.org/project/querdex/)
36
+ [![Downloads](https://img.shields.io/pypi/dm/querdex)](https://pypistats.org/packages/querdex)
37
+ [![Python versions](https://img.shields.io/pypi/pyversions/querdex)](https://pypi.org/project/querdex/)
38
+ [![CI](https://github.com/its-animay/querdex/actions/workflows/ci.yml/badge.svg)](https://github.com/its-animay/querdex/actions/workflows/ci.yml)
39
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](https://opensource.org/licenses/MIT)
40
+
35
41
  **Reasoning-first document intelligence system.**
36
42
 
37
43
  Querdex indexes any document into a hierarchical tree, then uses a two-tier LLM search to answer questions with cited sources. It works without an LLM (keyword heuristics), and optionally plugs in Anthropic or OpenAI for higher-quality results.
@@ -44,6 +50,7 @@ Querdex indexes any document into a hierarchical tree, then uses a two-tier LLM
44
50
  - [Installation](#installation)
45
51
  - [Quick Start (CLI)](#quick-start-cli)
46
52
  - [LLM Setup](#llm-setup)
53
+ - [Structured Extraction](#structured-extraction)
47
54
  - [CLI Reference](#cli-reference)
48
55
  - [Python API](#python-api)
49
56
  - [Supported File Types](#supported-file-types)
@@ -214,6 +221,54 @@ export QUERDEX_LLM_TIER2_MODEL=gpt-4o # powerful
214
221
 
215
222
  ---
216
223
 
224
+ ## Structured Extraction
225
+
226
+ Pull structured facts out of any indexed document — with **source grounding**: every extraction carries the exact section, page, and character span it came from, so nothing is silently hallucinated.
227
+
228
+ The schema is defined **by example**, not by code. Describe what you want and (optionally) show one or two examples:
229
+
230
+ ```bash
231
+ querdex extract --doc-id demo \
232
+ --prompt "Extract revenue figures and executive names" \
233
+ --examples examples.json \
234
+ --html review.html
235
+ ```
236
+
237
+ `examples.json`:
238
+ ```json
239
+ [
240
+ {
241
+ "text": "Alice Chen reported revenue of $5M in Q1.",
242
+ "extractions": [
243
+ {"extraction_class": "metric", "extraction_text": "revenue of $5M", "attributes": {"period": "Q1"}},
244
+ {"extraction_class": "person", "extraction_text": "Alice Chen"}
245
+ ]
246
+ }
247
+ ]
248
+ ```
249
+
250
+ The classes and attribute keys in your examples define the output schema. Every result is aligned back to the source text (exact → fuzzy matching); model output that cannot be located is kept but flagged `unaligned` so you can review it instead of trusting it.
251
+
252
+ `--html` writes a self-contained review page: the full document with color-coded highlights per extraction class, toggleable legend, attribute tooltips, and a click-to-jump list of all extractions.
253
+
254
+ Long documents are chunked and processed in parallel; use `--passes 2` to trade extra LLM calls for higher recall. Without an LLM configured, extraction degrades to literal matching of your example texts.
255
+
256
+ Python API:
257
+
258
+ ```python
259
+ from querdex.extraction import ExtractionTask, ExtractionExample, ExampleExtraction
260
+
261
+ task = ExtractionTask(
262
+ description="Extract revenue figures and executive names",
263
+ examples=[...],
264
+ )
265
+ run = engine.extract_document("demo", task, passes=1)
266
+ for e in run.extractions:
267
+ print(e.extraction_class, repr(e.extraction_text), e.section_id, e.char_start, e.alignment)
268
+ ```
269
+
270
+ ---
271
+
217
272
  ## CLI Reference
218
273
 
219
274
  ```
@@ -224,6 +279,7 @@ querdex [--db PATH] <command> [options]
224
279
  |---------|-------------|
225
280
  | `index <file>` | Index a document. Auto-detects format from extension. |
226
281
  | `query` | Query an indexed document. |
282
+ | `extract` | Run schema-by-example structured extraction over an indexed document. |
227
283
  | `delete` | Remove a document and all its data from the store. |
228
284
 
229
285
  ### `index`
@@ -249,6 +305,20 @@ querdex query --doc-id ID --query TEXT [--session-id ID]
249
305
  | `--query` | required | Natural language question |
250
306
  | `--session-id` | none | Enables multi-turn context (pass same ID across turns) |
251
307
 
308
+ ### `extract`
309
+
310
+ ```
311
+ querdex extract --doc-id ID --prompt TEXT [--examples FILE] [--passes N] [--html FILE]
312
+ ```
313
+
314
+ | Argument | Default | Description |
315
+ |----------|---------|-------------|
316
+ | `--doc-id` | required | Document to extract from |
317
+ | `--prompt` | required | Natural language description of what to extract |
318
+ | `--examples` | none | JSON file with few-shot examples (defines the output schema) |
319
+ | `--passes` | `1` | Extraction passes; more passes improve recall |
320
+ | `--html` | none | Write an interactive HTML review page to this path |
321
+
252
322
  ### `delete`
253
323
 
254
324
  ```
@@ -1,5 +1,11 @@
1
1
  # Querdex
2
2
 
3
+ [![PyPI version](https://img.shields.io/pypi/v/querdex)](https://pypi.org/project/querdex/)
4
+ [![Downloads](https://img.shields.io/pypi/dm/querdex)](https://pypistats.org/packages/querdex)
5
+ [![Python versions](https://img.shields.io/pypi/pyversions/querdex)](https://pypi.org/project/querdex/)
6
+ [![CI](https://github.com/its-animay/querdex/actions/workflows/ci.yml/badge.svg)](https://github.com/its-animay/querdex/actions/workflows/ci.yml)
7
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](https://opensource.org/licenses/MIT)
8
+
3
9
  **Reasoning-first document intelligence system.**
4
10
 
5
11
  Querdex indexes any document into a hierarchical tree, then uses a two-tier LLM search to answer questions with cited sources. It works without an LLM (keyword heuristics), and optionally plugs in Anthropic or OpenAI for higher-quality results.
@@ -12,6 +18,7 @@ Querdex indexes any document into a hierarchical tree, then uses a two-tier LLM
12
18
  - [Installation](#installation)
13
19
  - [Quick Start (CLI)](#quick-start-cli)
14
20
  - [LLM Setup](#llm-setup)
21
+ - [Structured Extraction](#structured-extraction)
15
22
  - [CLI Reference](#cli-reference)
16
23
  - [Python API](#python-api)
17
24
  - [Supported File Types](#supported-file-types)
@@ -182,6 +189,54 @@ export QUERDEX_LLM_TIER2_MODEL=gpt-4o # powerful
182
189
 
183
190
  ---
184
191
 
192
+ ## Structured Extraction
193
+
194
+ Pull structured facts out of any indexed document — with **source grounding**: every extraction carries the exact section, page, and character span it came from, so nothing is silently hallucinated.
195
+
196
+ The schema is defined **by example**, not by code. Describe what you want and (optionally) show one or two examples:
197
+
198
+ ```bash
199
+ querdex extract --doc-id demo \
200
+ --prompt "Extract revenue figures and executive names" \
201
+ --examples examples.json \
202
+ --html review.html
203
+ ```
204
+
205
+ `examples.json`:
206
+ ```json
207
+ [
208
+ {
209
+ "text": "Alice Chen reported revenue of $5M in Q1.",
210
+ "extractions": [
211
+ {"extraction_class": "metric", "extraction_text": "revenue of $5M", "attributes": {"period": "Q1"}},
212
+ {"extraction_class": "person", "extraction_text": "Alice Chen"}
213
+ ]
214
+ }
215
+ ]
216
+ ```
217
+
218
+ The classes and attribute keys in your examples define the output schema. Every result is aligned back to the source text (exact → fuzzy matching); model output that cannot be located is kept but flagged `unaligned` so you can review it instead of trusting it.
219
+
220
+ `--html` writes a self-contained review page: the full document with color-coded highlights per extraction class, toggleable legend, attribute tooltips, and a click-to-jump list of all extractions.
221
+
222
+ Long documents are chunked and processed in parallel; use `--passes 2` to trade extra LLM calls for higher recall. Without an LLM configured, extraction degrades to literal matching of your example texts.
223
+
224
+ Python API:
225
+
226
+ ```python
227
+ from querdex.extraction import ExtractionTask, ExtractionExample, ExampleExtraction
228
+
229
+ task = ExtractionTask(
230
+ description="Extract revenue figures and executive names",
231
+ examples=[...],
232
+ )
233
+ run = engine.extract_document("demo", task, passes=1)
234
+ for e in run.extractions:
235
+ print(e.extraction_class, repr(e.extraction_text), e.section_id, e.char_start, e.alignment)
236
+ ```
237
+
238
+ ---
239
+
185
240
  ## CLI Reference
186
241
 
187
242
  ```
@@ -192,6 +247,7 @@ querdex [--db PATH] <command> [options]
192
247
  |---------|-------------|
193
248
  | `index <file>` | Index a document. Auto-detects format from extension. |
194
249
  | `query` | Query an indexed document. |
250
+ | `extract` | Run schema-by-example structured extraction over an indexed document. |
195
251
  | `delete` | Remove a document and all its data from the store. |
196
252
 
197
253
  ### `index`
@@ -217,6 +273,20 @@ querdex query --doc-id ID --query TEXT [--session-id ID]
217
273
  | `--query` | required | Natural language question |
218
274
  | `--session-id` | none | Enables multi-turn context (pass same ID across turns) |
219
275
 
276
+ ### `extract`
277
+
278
+ ```
279
+ querdex extract --doc-id ID --prompt TEXT [--examples FILE] [--passes N] [--html FILE]
280
+ ```
281
+
282
+ | Argument | Default | Description |
283
+ |----------|---------|-------------|
284
+ | `--doc-id` | required | Document to extract from |
285
+ | `--prompt` | required | Natural language description of what to extract |
286
+ | `--examples` | none | JSON file with few-shot examples (defines the output schema) |
287
+ | `--passes` | `1` | Extraction passes; more passes improve recall |
288
+ | `--html` | none | Write an interactive HTML review page to this path |
289
+
220
290
  ### `delete`
221
291
 
222
292
  ```
@@ -1,11 +1,11 @@
1
1
  [project]
2
2
  name = "querdex"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "Reasoning-first document intelligence system"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
7
7
  license = { text = "MIT" }
8
- authors = [{ name = "Your Name", email = "you@example.com" }]
8
+ authors = [{ name = "Animay Tiwari", email = "animaytiwari@outlook.com" }]
9
9
  keywords = ["document", "indexing", "retrieval", "llm", "rag"]
10
10
  classifiers = [
11
11
  "Development Status :: 3 - Alpha",
@@ -5,12 +5,12 @@ import asyncio
5
5
  import json
6
6
  import sys
7
7
 
8
- from hyperindex.evaluation import EvaluationHarness, KPIDashboardBuilder, RetrievalMetricsHarness
9
- from hyperindex.services import build_engine
8
+ from querdex.evaluation import EvaluationHarness, KPIDashboardBuilder, RetrievalMetricsHarness
9
+ from querdex.services import build_engine
10
10
 
11
11
 
12
12
  def _build_parser() -> argparse.ArgumentParser:
13
- parser = argparse.ArgumentParser(description="Run HyperIndex evaluation and release gate checks.")
13
+ parser = argparse.ArgumentParser(description="Run querdex evaluation and release gate checks.")
14
14
  parser.add_argument("--db", required=True, help="SQLite database path")
15
15
  parser.add_argument("--doc-path", required=True, help="Path to document to index for the benchmark run")
16
16
  parser.add_argument("--doc-id", required=True, help="Document id used for benchmark queries")
@@ -2,4 +2,4 @@
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.1.0"
5
+ __version__ = "0.2.0"
@@ -2,8 +2,10 @@ from __future__ import annotations
2
2
 
3
3
  import argparse
4
4
  import asyncio
5
+ import json
5
6
  from pathlib import Path
6
7
 
8
+ from querdex.extraction import ExtractionExample, ExtractionTask, render_extraction_html
7
9
  from querdex.services import build_engine
8
10
 
9
11
 
@@ -25,6 +27,13 @@ def _build_parser() -> argparse.ArgumentParser:
25
27
  delete_parser = subparsers.add_parser("delete", help="Delete an indexed document")
26
28
  delete_parser.add_argument("--doc-id", required=True, help="Document id to delete")
27
29
 
30
+ extract_parser = subparsers.add_parser("extract", help="Run structured extraction over an indexed document")
31
+ extract_parser.add_argument("--doc-id", required=True, help="Document id to extract from")
32
+ extract_parser.add_argument("--prompt", required=True, help="Description of what to extract")
33
+ extract_parser.add_argument("--examples", default=None, help="Path to JSON file with few-shot examples")
34
+ extract_parser.add_argument("--passes", type=int, default=1, help="Extraction passes (more passes, more recall)")
35
+ extract_parser.add_argument("--html", default=None, help="Write an interactive HTML review page to this path")
36
+
28
37
  return parser
29
38
 
30
39
 
@@ -52,6 +61,33 @@ def main() -> None:
52
61
  engine.store.delete_document(args.doc_id)
53
62
  print(f"Deleted doc_id={args.doc_id}")
54
63
 
64
+ elif args.command == "extract":
65
+ examples = []
66
+ if args.examples:
67
+ raw_examples = json.loads(Path(args.examples).read_text(encoding="utf-8"))
68
+ examples = [ExtractionExample.model_validate(item) for item in raw_examples]
69
+ task = ExtractionTask(description=args.prompt, examples=examples)
70
+ run = engine.extract_document(args.doc_id, task, passes=args.passes)
71
+ stats = run.stats
72
+ print(
73
+ f"Run {run.run_id}: {len(run.extractions)} extractions "
74
+ f"(exact={stats.exact_count} fuzzy={stats.fuzzy_count} unaligned={stats.unaligned_count}) "
75
+ f"in {stats.latency_ms}ms across {stats.chunk_count} chunks"
76
+ )
77
+ for extraction in run.extractions[:20]:
78
+ location = (
79
+ f"p{extraction.page_number} {extraction.section_id}"
80
+ if extraction.section_id
81
+ else "ungrounded"
82
+ )
83
+ print(f" [{extraction.extraction_class}] {extraction.extraction_text!r} ({location})")
84
+ if len(run.extractions) > 20:
85
+ print(f" ... and {len(run.extractions) - 20} more")
86
+ if args.html:
87
+ sections = engine.store.sections_for_doc(args.doc_id)
88
+ Path(args.html).write_text(render_extraction_html(run, sections), encoding="utf-8")
89
+ print(f"HTML review written to {args.html}")
90
+
55
91
  finally:
56
92
  engine.store.close()
57
93
 
@@ -0,0 +1,29 @@
1
+ from querdex.extraction.aligner import align
2
+ from querdex.extraction.chunker import ChunkPiece, TextChunk, chunk_sections
3
+ from querdex.extraction.extractor import StructuredExtractor
4
+ from querdex.extraction.models import (
5
+ AlignmentStatus,
6
+ ExampleExtraction,
7
+ Extraction,
8
+ ExtractionExample,
9
+ ExtractionRun,
10
+ ExtractionStats,
11
+ ExtractionTask,
12
+ )
13
+ from querdex.extraction.visualize import render_extraction_html
14
+
15
+ __all__ = [
16
+ "AlignmentStatus",
17
+ "ChunkPiece",
18
+ "ExampleExtraction",
19
+ "Extraction",
20
+ "ExtractionExample",
21
+ "ExtractionRun",
22
+ "ExtractionStats",
23
+ "ExtractionTask",
24
+ "StructuredExtractor",
25
+ "TextChunk",
26
+ "align",
27
+ "chunk_sections",
28
+ "render_extraction_html",
29
+ ]
@@ -0,0 +1,81 @@
1
+ from __future__ import annotations
2
+
3
+ from difflib import SequenceMatcher
4
+
5
+ from querdex.extraction.models import AlignmentStatus
6
+
7
+ _MIN_FUZZY_RATIO = 0.75
8
+
9
+
10
+ def _normalize(text: str) -> tuple[str, list[int]]:
11
+ """Lowercase and collapse whitespace, keeping a map back to original indices.
12
+
13
+ ``index_map[i]`` is the index in ``text`` of normalized character ``i``,
14
+ which lets fuzzy matches report offsets in the original string.
15
+ """
16
+ normalized_chars: list[str] = []
17
+ index_map: list[int] = []
18
+ prev_space = True # drop leading whitespace
19
+ for i, ch in enumerate(text):
20
+ if ch.isspace():
21
+ if prev_space:
22
+ continue
23
+ normalized_chars.append(" ")
24
+ index_map.append(i)
25
+ prev_space = True
26
+ else:
27
+ normalized_chars.append(ch.lower())
28
+ index_map.append(i)
29
+ prev_space = False
30
+ if normalized_chars and normalized_chars[-1] == " ":
31
+ normalized_chars.pop()
32
+ index_map.pop()
33
+ return "".join(normalized_chars), index_map
34
+
35
+
36
+ def align(needle: str, haystack: str) -> tuple[int, int, AlignmentStatus] | None:
37
+ """Locate needle inside haystack, returning (start, end, status) in haystack coordinates.
38
+
39
+ Tries progressively looser strategies: exact, case-insensitive,
40
+ whitespace-normalized, then fuzzy via difflib. Returns None when no
41
+ sufficiently similar region exists - callers should treat that
42
+ extraction as unverified model output.
43
+ """
44
+ needle = needle.strip()
45
+ if not needle:
46
+ return None
47
+
48
+ idx = haystack.find(needle)
49
+ if idx >= 0:
50
+ return idx, idx + len(needle), "exact"
51
+
52
+ idx = haystack.lower().find(needle.lower())
53
+ if idx >= 0:
54
+ return idx, idx + len(needle), "exact"
55
+
56
+ norm_haystack, index_map = _normalize(haystack)
57
+ norm_needle, _ = _normalize(needle)
58
+ if not norm_needle or not norm_haystack:
59
+ return None
60
+
61
+ idx = norm_haystack.find(norm_needle)
62
+ if idx >= 0:
63
+ start = index_map[idx]
64
+ end = index_map[idx + len(norm_needle) - 1] + 1
65
+ return start, end, "fuzzy"
66
+
67
+ matcher = SequenceMatcher(a=norm_haystack, b=norm_needle, autojunk=False)
68
+ match = matcher.find_longest_match(0, len(norm_haystack), 0, len(norm_needle))
69
+ if match.size == 0:
70
+ return None
71
+ window_start = max(0, match.a - match.b)
72
+ window_end = min(len(norm_haystack), window_start + len(norm_needle))
73
+ if window_end <= window_start:
74
+ return None
75
+ window = norm_haystack[window_start:window_end]
76
+ ratio = SequenceMatcher(a=window, b=norm_needle, autojunk=False).ratio()
77
+ if ratio < _MIN_FUZZY_RATIO:
78
+ return None
79
+ start = index_map[window_start]
80
+ end = index_map[window_end - 1] + 1
81
+ return start, end, "fuzzy"
@@ -0,0 +1,108 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass, field
5
+
6
+ from querdex.schemas import Section
7
+
8
+ _SENTENCE_END_RE = re.compile(r"[.!?]\s")
9
+ _PIECE_SEPARATOR = "\n\n"
10
+
11
+
12
+ @dataclass
13
+ class ChunkPiece:
14
+ """A contiguous region of a chunk mapped back to its source section."""
15
+
16
+ section_id: str
17
+ page_number: int
18
+ chunk_offset: int
19
+ section_offset: int
20
+ length: int
21
+
22
+
23
+ @dataclass
24
+ class TextChunk:
25
+ chunk_id: str
26
+ text: str
27
+ pieces: list[ChunkPiece] = field(default_factory=list)
28
+
29
+ def locate(self, char_start: int) -> ChunkPiece | None:
30
+ """Return the piece containing char_start (chunk coordinates)."""
31
+ for piece in self.pieces:
32
+ if piece.chunk_offset <= char_start < piece.chunk_offset + piece.length:
33
+ return piece
34
+ return None
35
+
36
+
37
+ def _split_long_text(text: str, max_chars: int) -> list[tuple[int, str]]:
38
+ """Split text into (section_offset, piece) tuples of at most max_chars.
39
+
40
+ Cuts prefer sentence boundaries, then word boundaries, so extractions
41
+ are unlikely to straddle a chunk edge.
42
+ """
43
+ pieces: list[tuple[int, str]] = []
44
+ start = 0
45
+ while start < len(text):
46
+ if len(text) - start <= max_chars:
47
+ pieces.append((start, text[start:]))
48
+ break
49
+ window = text[start : start + max_chars]
50
+ cut = max((m.end() for m in _SENTENCE_END_RE.finditer(window)), default=0)
51
+ if cut < max_chars // 2:
52
+ space = window.rfind(" ")
53
+ cut = space + 1 if space > max_chars // 2 else max_chars
54
+ pieces.append((start, text[start : start + cut]))
55
+ start += cut
56
+ return pieces
57
+
58
+
59
+ def chunk_sections(sections: list[Section], *, max_chars: int = 4000) -> list[TextChunk]:
60
+ """Group section contents into chunks of at most max_chars.
61
+
62
+ Consecutive small sections are packed together; oversized sections are
63
+ split at sentence boundaries. Every region of every chunk maps back to
64
+ its source section and offset so extractions can be grounded precisely.
65
+ """
66
+ chunks: list[TextChunk] = []
67
+ current_parts: list[str] = []
68
+ current_pieces: list[ChunkPiece] = []
69
+ current_len = 0
70
+
71
+ def flush() -> None:
72
+ nonlocal current_parts, current_pieces, current_len
73
+ if current_parts:
74
+ chunks.append(
75
+ TextChunk(
76
+ chunk_id=f"chunk_{len(chunks) + 1:04d}",
77
+ text="".join(current_parts),
78
+ pieces=current_pieces,
79
+ )
80
+ )
81
+ current_parts = []
82
+ current_pieces = []
83
+ current_len = 0
84
+
85
+ def add_piece(section: Section, section_offset: int, piece_text: str) -> None:
86
+ nonlocal current_len
87
+ if current_parts:
88
+ current_parts.append(_PIECE_SEPARATOR)
89
+ current_len += len(_PIECE_SEPARATOR)
90
+ current_pieces.append(
91
+ ChunkPiece(
92
+ section_id=section.section_id,
93
+ page_number=section.page_number,
94
+ chunk_offset=current_len,
95
+ section_offset=section_offset,
96
+ length=len(piece_text),
97
+ )
98
+ )
99
+ current_parts.append(piece_text)
100
+ current_len += len(piece_text)
101
+
102
+ for section in sections:
103
+ for section_offset, piece_text in _split_long_text(section.content, max_chars):
104
+ if current_len and current_len + len(_PIECE_SEPARATOR) + len(piece_text) > max_chars:
105
+ flush()
106
+ add_piece(section, section_offset, piece_text)
107
+ flush()
108
+ return chunks