querdex 0.1.0__tar.gz → 0.2.1__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.1}/.gitignore +1 -0
  2. {querdex-0.1.0 → querdex-0.2.1}/PKG-INFO +74 -2
  3. {querdex-0.1.0 → querdex-0.2.1}/README.md +71 -0
  4. {querdex-0.1.0 → querdex-0.2.1}/pyproject.toml +5 -2
  5. {querdex-0.1.0 → querdex-0.2.1}/scripts/run_release_gate.py +3 -3
  6. {querdex-0.1.0 → querdex-0.2.1}/src/querdex/__init__.py +1 -1
  7. {querdex-0.1.0 → querdex-0.2.1}/src/querdex/cli.py +36 -0
  8. querdex-0.2.1/src/querdex/extraction/__init__.py +29 -0
  9. querdex-0.2.1/src/querdex/extraction/aligner.py +81 -0
  10. querdex-0.2.1/src/querdex/extraction/chunker.py +108 -0
  11. querdex-0.2.1/src/querdex/extraction/extractor.py +291 -0
  12. querdex-0.2.1/src/querdex/extraction/models.py +89 -0
  13. querdex-0.2.1/src/querdex/extraction/visualize.py +175 -0
  14. {querdex-0.1.0 → querdex-0.2.1}/src/querdex/ingestion/parsers/sqlite_parser.py +8 -2
  15. {querdex-0.1.0 → querdex-0.2.1}/src/querdex/ingestion/parsers/url_parser.py +53 -4
  16. {querdex-0.1.0 → querdex-0.2.1}/src/querdex/services/engine.py +21 -0
  17. {querdex-0.1.0 → querdex-0.2.1}/src/querdex/storage/graph_store.py +16 -6
  18. {querdex-0.1.0 → querdex-0.2.1}/src/querdex/storage/sqlite_store.py +36 -1
  19. querdex-0.2.1/tests/test_extraction.py +245 -0
  20. {querdex-0.1.0 → querdex-0.2.1}/tests/test_ingestion_additional.py +5 -15
  21. {querdex-0.1.0 → querdex-0.2.1}/uv.lock +1 -1
  22. {querdex-0.1.0 → querdex-0.2.1}/.env.example +0 -0
  23. {querdex-0.1.0 → querdex-0.2.1}/.github/workflows/ci.yml +0 -0
  24. {querdex-0.1.0 → querdex-0.2.1}/.github/workflows/publish.yml +0 -0
  25. {querdex-0.1.0 → querdex-0.2.1}/Makefile +0 -0
  26. {querdex-0.1.0 → querdex-0.2.1}/infra/Dockerfile +0 -0
  27. {querdex-0.1.0 → querdex-0.2.1}/infra/ENVIRONMENTS.md +0 -0
  28. {querdex-0.1.0 → querdex-0.2.1}/infra/README.md +0 -0
  29. {querdex-0.1.0 → querdex-0.2.1}/infra/docker-compose.yml +0 -0
  30. {querdex-0.1.0 → querdex-0.2.1}/scripts/dev_index_and_query.sh +0 -0
  31. {querdex-0.1.0 → querdex-0.2.1}/src/querdex/adaptive/__init__.py +0 -0
  32. {querdex-0.1.0 → querdex-0.2.1}/src/querdex/adaptive/updater.py +0 -0
  33. {querdex-0.1.0 → querdex-0.2.1}/src/querdex/evaluation/__init__.py +0 -0
  34. {querdex-0.1.0 → querdex-0.2.1}/src/querdex/evaluation/harness.py +0 -0
  35. {querdex-0.1.0 → querdex-0.2.1}/src/querdex/evaluation/metrics.py +0 -0
  36. {querdex-0.1.0 → querdex-0.2.1}/src/querdex/indexing/__init__.py +0 -0
  37. {querdex-0.1.0 → querdex-0.2.1}/src/querdex/indexing/coordinator.py +0 -0
  38. {querdex-0.1.0 → querdex-0.2.1}/src/querdex/indexing/diff_reindex.py +0 -0
  39. {querdex-0.1.0 → querdex-0.2.1}/src/querdex/indexing/entity_extractor.py +0 -0
  40. {querdex-0.1.0 → querdex-0.2.1}/src/querdex/indexing/entity_map_updater.py +0 -0
  41. {querdex-0.1.0 → querdex-0.2.1}/src/querdex/indexing/graph_builder.py +0 -0
  42. {querdex-0.1.0 → querdex-0.2.1}/src/querdex/indexing/quality.py +0 -0
  43. {querdex-0.1.0 → querdex-0.2.1}/src/querdex/indexing/tree_builder.py +0 -0
  44. {querdex-0.1.0 → querdex-0.2.1}/src/querdex/ingestion/__init__.py +0 -0
  45. {querdex-0.1.0 → querdex-0.2.1}/src/querdex/ingestion/base.py +0 -0
  46. {querdex-0.1.0 → querdex-0.2.1}/src/querdex/ingestion/orchestrator.py +0 -0
  47. {querdex-0.1.0 → querdex-0.2.1}/src/querdex/ingestion/parsers/__init__.py +0 -0
  48. {querdex-0.1.0 → querdex-0.2.1}/src/querdex/ingestion/parsers/audio_video_parser.py +0 -0
  49. {querdex-0.1.0 → querdex-0.2.1}/src/querdex/ingestion/parsers/code_parser.py +0 -0
  50. {querdex-0.1.0 → querdex-0.2.1}/src/querdex/ingestion/parsers/csv_parser.py +0 -0
  51. {querdex-0.1.0 → querdex-0.2.1}/src/querdex/ingestion/parsers/docx_parser.py +0 -0
  52. {querdex-0.1.0 → querdex-0.2.1}/src/querdex/ingestion/parsers/html_parser.py +0 -0
  53. {querdex-0.1.0 → querdex-0.2.1}/src/querdex/ingestion/parsers/markdown_parser.py +0 -0
  54. {querdex-0.1.0 → querdex-0.2.1}/src/querdex/ingestion/parsers/ocr.py +0 -0
  55. {querdex-0.1.0 → querdex-0.2.1}/src/querdex/ingestion/parsers/pdf_parser.py +0 -0
  56. {querdex-0.1.0 → querdex-0.2.1}/src/querdex/ingestion/parsers/text_parser.py +0 -0
  57. {querdex-0.1.0 → querdex-0.2.1}/src/querdex/llm/__init__.py +0 -0
  58. {querdex-0.1.0 → querdex-0.2.1}/src/querdex/llm/anthropic_client.py +0 -0
  59. {querdex-0.1.0 → querdex-0.2.1}/src/querdex/llm/client.py +0 -0
  60. {querdex-0.1.0 → querdex-0.2.1}/src/querdex/llm/fake_client.py +0 -0
  61. {querdex-0.1.0 → querdex-0.2.1}/src/querdex/llm/openai_client.py +0 -0
  62. {querdex-0.1.0 → querdex-0.2.1}/src/querdex/ops/__init__.py +0 -0
  63. {querdex-0.1.0 → querdex-0.2.1}/src/querdex/ops/health.py +0 -0
  64. {querdex-0.1.0 → querdex-0.2.1}/src/querdex/ops/observability.py +0 -0
  65. {querdex-0.1.0 → querdex-0.2.1}/src/querdex/ops/retry.py +0 -0
  66. {querdex-0.1.0 → querdex-0.2.1}/src/querdex/query/__init__.py +0 -0
  67. {querdex-0.1.0 → querdex-0.2.1}/src/querdex/query/analyzer.py +0 -0
  68. {querdex-0.1.0 → querdex-0.2.1}/src/querdex/query/answering.py +0 -0
  69. {querdex-0.1.0 → querdex-0.2.1}/src/querdex/query/graph_walker.py +0 -0
  70. {querdex-0.1.0 → querdex-0.2.1}/src/querdex/query/multi_doc.py +0 -0
  71. {querdex-0.1.0 → querdex-0.2.1}/src/querdex/query/router.py +0 -0
  72. {querdex-0.1.0 → querdex-0.2.1}/src/querdex/query/tiered_search.py +0 -0
  73. {querdex-0.1.0 → querdex-0.2.1}/src/querdex/schemas/__init__.py +0 -0
  74. {querdex-0.1.0 → querdex-0.2.1}/src/querdex/schemas/models.py +0 -0
  75. {querdex-0.1.0 → querdex-0.2.1}/src/querdex/services/__init__.py +0 -0
  76. {querdex-0.1.0 → querdex-0.2.1}/src/querdex/services/interfaces.py +0 -0
  77. {querdex-0.1.0 → querdex-0.2.1}/src/querdex/storage/__init__.py +0 -0
  78. {querdex-0.1.0 → querdex-0.2.1}/src/querdex/utils/__init__.py +0 -0
  79. {querdex-0.1.0 → querdex-0.2.1}/src/querdex/utils/llm_validation.py +0 -0
  80. {querdex-0.1.0 → querdex-0.2.1}/src/querdex/utils/query_cluster.py +0 -0
  81. {querdex-0.1.0 → querdex-0.2.1}/src/querdex/utils/tree_ops.py +0 -0
  82. {querdex-0.1.0 → querdex-0.2.1}/tests/fixtures/eval/baseline_cases.json +0 -0
  83. {querdex-0.1.0 → querdex-0.2.1}/tests/fixtures/eval/kpi_baseline.json +0 -0
  84. {querdex-0.1.0 → querdex-0.2.1}/tests/fixtures/golden/parser_manifest.json +0 -0
  85. {querdex-0.1.0 → querdex-0.2.1}/tests/fixtures/golden/sample.csv +0 -0
  86. {querdex-0.1.0 → querdex-0.2.1}/tests/fixtures/golden/sample.html +0 -0
  87. {querdex-0.1.0 → querdex-0.2.1}/tests/fixtures/golden/sample.md +0 -0
  88. {querdex-0.1.0 → querdex-0.2.1}/tests/fixtures/golden/sample.py +0 -0
  89. {querdex-0.1.0 → querdex-0.2.1}/tests/fixtures/golden/sample.txt +0 -0
  90. {querdex-0.1.0 → querdex-0.2.1}/tests/test_adaptive_updater.py +0 -0
  91. {querdex-0.1.0 → querdex-0.2.1}/tests/test_diff_reindex.py +0 -0
  92. {querdex-0.1.0 → querdex-0.2.1}/tests/test_docx_integration.py +0 -0
  93. {querdex-0.1.0 → querdex-0.2.1}/tests/test_entity_map_updater.py +0 -0
  94. {querdex-0.1.0 → querdex-0.2.1}/tests/test_evaluation_harness.py +0 -0
  95. {querdex-0.1.0 → querdex-0.2.1}/tests/test_graph_builder.py +0 -0
  96. {querdex-0.1.0 → querdex-0.2.1}/tests/test_graph_store.py +0 -0
  97. {querdex-0.1.0 → querdex-0.2.1}/tests/test_ingestion.py +0 -0
  98. {querdex-0.1.0 → querdex-0.2.1}/tests/test_llm_integration.py +0 -0
  99. {querdex-0.1.0 → querdex-0.2.1}/tests/test_metrics_harness.py +0 -0
  100. {querdex-0.1.0 → querdex-0.2.1}/tests/test_ocr_provider.py +0 -0
  101. {querdex-0.1.0 → querdex-0.2.1}/tests/test_parser_golden.py +0 -0
  102. {querdex-0.1.0 → querdex-0.2.1}/tests/test_pdf_integration.py +0 -0
  103. {querdex-0.1.0 → querdex-0.2.1}/tests/test_pdf_parser.py +0 -0
  104. {querdex-0.1.0 → querdex-0.2.1}/tests/test_pipeline.py +0 -0
  105. {querdex-0.1.0 → querdex-0.2.1}/tests/test_query_analyzer.py +0 -0
  106. {querdex-0.1.0 → querdex-0.2.1}/tests/test_query_graph_multi.py +0 -0
  107. {querdex-0.1.0 → querdex-0.2.1}/tests/test_reindex.py +0 -0
  108. {querdex-0.1.0 → querdex-0.2.1}/tests/test_routing_suite.py +0 -0
  109. {querdex-0.1.0 → querdex-0.2.1}/tests/test_schemas.py +0 -0
  110. {querdex-0.1.0 → querdex-0.2.1}/tests/test_store.py +0 -0
  111. {querdex-0.1.0 → querdex-0.2.1}/tests/test_tree_builder.py +0 -0
  112. {querdex-0.1.0 → querdex-0.2.1}/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,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: querdex
3
- Version: 0.1.0
3
+ Version: 0.2.1
4
4
  Summary: Reasoning-first document intelligence system
5
- Author-email: Your Name <you@example.com>
5
+ Author: Animay Tiwari
6
+ Author-email: Animay Tiwari <animaytiwari@outlook.com>
6
7
  License: MIT
7
8
  Keywords: document,indexing,llm,rag,retrieval
8
9
  Classifier: Development Status :: 3 - Alpha
@@ -32,6 +33,13 @@ Description-Content-Type: text/markdown
32
33
 
33
34
  # Querdex
34
35
 
36
+ [![PyPI version](https://img.shields.io/pypi/v/querdex)](https://pypi.org/project/querdex/)
37
+ [![Total downloads](https://static.pepy.tech/badge/querdex)](https://pepy.tech/projects/querdex)
38
+ [![Downloads/month](https://img.shields.io/pypi/dm/querdex)](https://pypistats.org/packages/querdex)
39
+ [![Python versions](https://img.shields.io/pypi/pyversions/querdex)](https://pypi.org/project/querdex/)
40
+ [![CI](https://github.com/its-animay/querdex/actions/workflows/ci.yml/badge.svg)](https://github.com/its-animay/querdex/actions/workflows/ci.yml)
41
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](https://opensource.org/licenses/MIT)
42
+
35
43
  **Reasoning-first document intelligence system.**
36
44
 
37
45
  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 +52,7 @@ Querdex indexes any document into a hierarchical tree, then uses a two-tier LLM
44
52
  - [Installation](#installation)
45
53
  - [Quick Start (CLI)](#quick-start-cli)
46
54
  - [LLM Setup](#llm-setup)
55
+ - [Structured Extraction](#structured-extraction)
47
56
  - [CLI Reference](#cli-reference)
48
57
  - [Python API](#python-api)
49
58
  - [Supported File Types](#supported-file-types)
@@ -214,6 +223,54 @@ export QUERDEX_LLM_TIER2_MODEL=gpt-4o # powerful
214
223
 
215
224
  ---
216
225
 
226
+ ## Structured Extraction
227
+
228
+ 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.
229
+
230
+ The schema is defined **by example**, not by code. Describe what you want and (optionally) show one or two examples:
231
+
232
+ ```bash
233
+ querdex extract --doc-id demo \
234
+ --prompt "Extract revenue figures and executive names" \
235
+ --examples examples.json \
236
+ --html review.html
237
+ ```
238
+
239
+ `examples.json`:
240
+ ```json
241
+ [
242
+ {
243
+ "text": "Alice Chen reported revenue of $5M in Q1.",
244
+ "extractions": [
245
+ {"extraction_class": "metric", "extraction_text": "revenue of $5M", "attributes": {"period": "Q1"}},
246
+ {"extraction_class": "person", "extraction_text": "Alice Chen"}
247
+ ]
248
+ }
249
+ ]
250
+ ```
251
+
252
+ 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.
253
+
254
+ `--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.
255
+
256
+ 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.
257
+
258
+ Python API:
259
+
260
+ ```python
261
+ from querdex.extraction import ExtractionTask, ExtractionExample, ExampleExtraction
262
+
263
+ task = ExtractionTask(
264
+ description="Extract revenue figures and executive names",
265
+ examples=[...],
266
+ )
267
+ run = engine.extract_document("demo", task, passes=1)
268
+ for e in run.extractions:
269
+ print(e.extraction_class, repr(e.extraction_text), e.section_id, e.char_start, e.alignment)
270
+ ```
271
+
272
+ ---
273
+
217
274
  ## CLI Reference
218
275
 
219
276
  ```
@@ -224,6 +281,7 @@ querdex [--db PATH] <command> [options]
224
281
  |---------|-------------|
225
282
  | `index <file>` | Index a document. Auto-detects format from extension. |
226
283
  | `query` | Query an indexed document. |
284
+ | `extract` | Run schema-by-example structured extraction over an indexed document. |
227
285
  | `delete` | Remove a document and all its data from the store. |
228
286
 
229
287
  ### `index`
@@ -249,6 +307,20 @@ querdex query --doc-id ID --query TEXT [--session-id ID]
249
307
  | `--query` | required | Natural language question |
250
308
  | `--session-id` | none | Enables multi-turn context (pass same ID across turns) |
251
309
 
310
+ ### `extract`
311
+
312
+ ```
313
+ querdex extract --doc-id ID --prompt TEXT [--examples FILE] [--passes N] [--html FILE]
314
+ ```
315
+
316
+ | Argument | Default | Description |
317
+ |----------|---------|-------------|
318
+ | `--doc-id` | required | Document to extract from |
319
+ | `--prompt` | required | Natural language description of what to extract |
320
+ | `--examples` | none | JSON file with few-shot examples (defines the output schema) |
321
+ | `--passes` | `1` | Extraction passes; more passes improve recall |
322
+ | `--html` | none | Write an interactive HTML review page to this path |
323
+
252
324
  ### `delete`
253
325
 
254
326
  ```
@@ -1,5 +1,12 @@
1
1
  # Querdex
2
2
 
3
+ [![PyPI version](https://img.shields.io/pypi/v/querdex)](https://pypi.org/project/querdex/)
4
+ [![Total downloads](https://static.pepy.tech/badge/querdex)](https://pepy.tech/projects/querdex)
5
+ [![Downloads/month](https://img.shields.io/pypi/dm/querdex)](https://pypistats.org/packages/querdex)
6
+ [![Python versions](https://img.shields.io/pypi/pyversions/querdex)](https://pypi.org/project/querdex/)
7
+ [![CI](https://github.com/its-animay/querdex/actions/workflows/ci.yml/badge.svg)](https://github.com/its-animay/querdex/actions/workflows/ci.yml)
8
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](https://opensource.org/licenses/MIT)
9
+
3
10
  **Reasoning-first document intelligence system.**
4
11
 
5
12
  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 +19,7 @@ Querdex indexes any document into a hierarchical tree, then uses a two-tier LLM
12
19
  - [Installation](#installation)
13
20
  - [Quick Start (CLI)](#quick-start-cli)
14
21
  - [LLM Setup](#llm-setup)
22
+ - [Structured Extraction](#structured-extraction)
15
23
  - [CLI Reference](#cli-reference)
16
24
  - [Python API](#python-api)
17
25
  - [Supported File Types](#supported-file-types)
@@ -182,6 +190,54 @@ export QUERDEX_LLM_TIER2_MODEL=gpt-4o # powerful
182
190
 
183
191
  ---
184
192
 
193
+ ## Structured Extraction
194
+
195
+ 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.
196
+
197
+ The schema is defined **by example**, not by code. Describe what you want and (optionally) show one or two examples:
198
+
199
+ ```bash
200
+ querdex extract --doc-id demo \
201
+ --prompt "Extract revenue figures and executive names" \
202
+ --examples examples.json \
203
+ --html review.html
204
+ ```
205
+
206
+ `examples.json`:
207
+ ```json
208
+ [
209
+ {
210
+ "text": "Alice Chen reported revenue of $5M in Q1.",
211
+ "extractions": [
212
+ {"extraction_class": "metric", "extraction_text": "revenue of $5M", "attributes": {"period": "Q1"}},
213
+ {"extraction_class": "person", "extraction_text": "Alice Chen"}
214
+ ]
215
+ }
216
+ ]
217
+ ```
218
+
219
+ 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.
220
+
221
+ `--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.
222
+
223
+ 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.
224
+
225
+ Python API:
226
+
227
+ ```python
228
+ from querdex.extraction import ExtractionTask, ExtractionExample, ExampleExtraction
229
+
230
+ task = ExtractionTask(
231
+ description="Extract revenue figures and executive names",
232
+ examples=[...],
233
+ )
234
+ run = engine.extract_document("demo", task, passes=1)
235
+ for e in run.extractions:
236
+ print(e.extraction_class, repr(e.extraction_text), e.section_id, e.char_start, e.alignment)
237
+ ```
238
+
239
+ ---
240
+
185
241
  ## CLI Reference
186
242
 
187
243
  ```
@@ -192,6 +248,7 @@ querdex [--db PATH] <command> [options]
192
248
  |---------|-------------|
193
249
  | `index <file>` | Index a document. Auto-detects format from extension. |
194
250
  | `query` | Query an indexed document. |
251
+ | `extract` | Run schema-by-example structured extraction over an indexed document. |
195
252
  | `delete` | Remove a document and all its data from the store. |
196
253
 
197
254
  ### `index`
@@ -217,6 +274,20 @@ querdex query --doc-id ID --query TEXT [--session-id ID]
217
274
  | `--query` | required | Natural language question |
218
275
  | `--session-id` | none | Enables multi-turn context (pass same ID across turns) |
219
276
 
277
+ ### `extract`
278
+
279
+ ```
280
+ querdex extract --doc-id ID --prompt TEXT [--examples FILE] [--passes N] [--html FILE]
281
+ ```
282
+
283
+ | Argument | Default | Description |
284
+ |----------|---------|-------------|
285
+ | `--doc-id` | required | Document to extract from |
286
+ | `--prompt` | required | Natural language description of what to extract |
287
+ | `--examples` | none | JSON file with few-shot examples (defines the output schema) |
288
+ | `--passes` | `1` | Extraction passes; more passes improve recall |
289
+ | `--html` | none | Write an interactive HTML review page to this path |
290
+
220
291
  ### `delete`
221
292
 
222
293
  ```
@@ -1,11 +1,14 @@
1
1
  [project]
2
2
  name = "querdex"
3
- version = "0.1.0"
3
+ version = "0.2.1"
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 = [
9
+ { name = "Animay Tiwari" },
10
+ { name = "Animay Tiwari", email = "animaytiwari@outlook.com" },
11
+ ]
9
12
  keywords = ["document", "indexing", "retrieval", "llm", "rag"]
10
13
  classifiers = [
11
14
  "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.1"
@@ -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