gather-engine 2.1.0__tar.gz → 2.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.
- {gather_engine-2.1.0 → gather_engine-2.2.0}/PKG-INFO +7 -5
- {gather_engine-2.1.0 → gather_engine-2.2.0}/README.md +6 -4
- {gather_engine-2.1.0 → gather_engine-2.2.0}/pyproject.toml +1 -1
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/__init__.py +1 -1
- gather_engine-2.2.0/src/gather/citecheck.py +119 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/cli.py +4 -18
- gather_engine-2.2.0/src/gather/cli_ext.py +19 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/client_mcp.py +6 -7
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/commands.py +20 -11
- gather_engine-2.2.0/src/gather/docs_tool.py +93 -0
- gather_engine-2.2.0/src/gather/filter_ledger.py +111 -0
- gather_engine-2.2.0/src/gather/grant_checks.py +52 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/grants.py +3 -36
- gather_engine-2.2.0/src/gather/http_call.py +56 -0
- gather_engine-2.2.0/src/gather/ledger_cmd.py +39 -0
- gather_engine-2.2.0/src/gather/ledger_verify.py +86 -0
- gather_engine-2.2.0/src/gather/local_model.py +83 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/mcp.py +17 -45
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/method.py +2 -0
- gather_engine-2.2.0/src/gather/reddit.py +203 -0
- gather_engine-2.2.0/src/gather/reddit_cmd.py +34 -0
- gather_engine-2.2.0/src/gather/reddit_items.py +64 -0
- gather_engine-2.2.0/src/gather/report.py +72 -0
- gather_engine-2.2.0/src/gather/report_cmd.py +81 -0
- gather_engine-2.2.0/src/gather/route.py +78 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/run_config.py +36 -4
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/source.py +16 -9
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/video_cmd.py +3 -3
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/video_source.py +5 -0
- gather_engine-2.2.0/src/gather/youtube_api.py +129 -0
- gather_engine-2.2.0/src/gather/youtube_cmd.py +90 -0
- gather_engine-2.2.0/src/gather/youtube_probe.py +92 -0
- gather_engine-2.2.0/src/gather/youtube_route.py +166 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather_engine.egg-info/PKG-INFO +7 -5
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather_engine.egg-info/SOURCES.txt +33 -0
- gather_engine-2.2.0/tests/test_citecheck.py +72 -0
- gather_engine-2.2.0/tests/test_client_disclosure.py +80 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_client_package.py +41 -1
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_client_setup.py +17 -0
- gather_engine-2.2.0/tests/test_client_vendored.py +144 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_docs.py +1 -1
- gather_engine-2.2.0/tests/test_filter_ledger.py +117 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_grants.py +1 -1
- gather_engine-2.2.0/tests/test_http_call.py +59 -0
- gather_engine-2.2.0/tests/test_mcp_annotations.py +38 -0
- gather_engine-2.2.0/tests/test_reddit.py +180 -0
- gather_engine-2.2.0/tests/test_reddit_grants.py +29 -0
- gather_engine-2.2.0/tests/test_report.py +99 -0
- gather_engine-2.2.0/tests/test_route.py +40 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_video_source.py +3 -2
- gather_engine-2.2.0/tests/test_youtube_api.py +63 -0
- gather_engine-2.2.0/tests/test_youtube_grants.py +30 -0
- gather_engine-2.2.0/tests/test_youtube_probe.py +88 -0
- gather_engine-2.2.0/tests/test_youtube_route.py +188 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/LICENSE +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/setup.cfg +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/__main__.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/_vendor/__init__.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/_vendor/safe_spawn.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/api.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/arxiv.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/availability.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/backends.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/backends_browser.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/benchmark.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/browser.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/browser_evidence.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/cache.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/captions.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/channel.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/channel_ledger.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/client_network.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/context.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/corpus_cmd.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/crawl.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/credentials.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/derive.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/digest.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/docs.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/dom.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/export.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/extract.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/fastparse.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/federation.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/federation_cmd.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/federation_policy.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/federation_receipt.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/feed.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/fetch.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/flagship.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/interop.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/item.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/localpath.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/model.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/monitor.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/net.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/ocr.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/pacing.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/payloads.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/pdf.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/pilot.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/pilot_bundle.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/pilot_commands.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/pilot_manifest.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/pilot_report.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/pilot_sources.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/provenance.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/recall.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/run.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/schema_extract.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/scholar.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/scope.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/search.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/spawn.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/store.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/stream.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/track.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/transcribe.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/video.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/web.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/web_commands.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather/ytdlp.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather_engine.egg-info/dependency_links.txt +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather_engine.egg-info/entry_points.txt +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather_engine.egg-info/requires.txt +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/src/gather_engine.egg-info/top_level.txt +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_api.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_arxiv.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_availability.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_backends.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_backends_browser.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_backends_card.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_benchmark.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_browser_evidence.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_cache.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_captions.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_channel.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_channel_ledger.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_cli.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_cli_web.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_client_network.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_client_profile.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_context.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_context_mountinfo.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_crawl.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_credentials.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_demo.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_derive.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_device_names.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_digest.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_dom.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_export.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_extract.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_fastparse.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_federation.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_federation_cli.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_federation_receipt.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_feed.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_fetch.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_flagship_cli.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_hard_sources.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_interop.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_item.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_localpath.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_mcp.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_method.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_model.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_monitor.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_net.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_nonlocal_paths.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_pacing.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_package.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_pdf.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_pilot.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_pilot_bundle.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_pilot_cli.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_pilot_manifest.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_pilot_refresh.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_pilot_report.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_pilot_sample.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_pilot_showcase.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_pilot_sources.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_provenance.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_recall.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_release_workflow.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_repo_art.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_repo_card.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_run.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_schema_extract.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_scholar.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_scope.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_search.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_spawn_children.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_spawn_working_folder.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_store.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_stream.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_track.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_vendored.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_verify_pilot_cli.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_video.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_video_spawn.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_web.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_web_refine.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_xml_safety.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_ytdlp.py +0 -0
- {gather_engine-2.1.0 → gather_engine-2.2.0}/tests/test_ytdlp_real.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gather-engine
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.2.0
|
|
4
4
|
Summary: Accountable research intake for difficult sources, with provenance receipts and witnessed digests.
|
|
5
5
|
Author: Zain Dana Harper
|
|
6
6
|
License-Expression: LicenseRef-FSL-1.1-MIT
|
|
@@ -34,7 +34,7 @@ Dynamic: license-file
|
|
|
34
34
|
[](https://pypi.org/project/gather-engine/)
|
|
35
35
|

|
|
36
36
|

|
|
37
|
-

|
|
38
38
|
|
|
39
39
|
gather pulls research out of the places most tools break on: arXiv papers, authenticated JSON APIs, JavaScript-rendered pages via a real headless browser, scanned images through OCR, and audio through transcription, alongside video, web, feeds, and local docs. The core runs with zero third-party runtime dependencies, and the same engine is reachable from the CLI, MCP tools, and plain Python. Every run writes a receipt you can re-check.
|
|
40
40
|
|
|
@@ -42,7 +42,7 @@ gather pulls research out of the places most tools break on: arXiv papers, authe
|
|
|
42
42
|
|
|
43
43
|
## Current status
|
|
44
44
|
|
|
45
|
-
`gather-engine 2.
|
|
45
|
+
`gather-engine 2.2.0` is the current source version. Core intake,
|
|
46
46
|
content-addressed corpus storage, exact UTF-8 source-byte receipts, readable
|
|
47
47
|
context selection, descriptor handoff, pilot monitoring, CLI, Python API, and MCP
|
|
48
48
|
surfaces are present in this checkout. Browser rendering, OCR, audio, and fast
|
|
@@ -82,7 +82,9 @@ fabricated field cannot reach the corpus by being plausible.
|
|
|
82
82
|
- **Track elements across redesigns.** Fingerprint a scraped element once, then relocate it in any later version of the page and get a typed MATCH, RELOCATED, DRIFT, or GONE verdict (`gather.track`, Python API).
|
|
83
83
|
- **Structured extraction with a hallucination check.** `gather.schema_extract` binds schema fields to source nodes, and `verify_record` rejects any LLM-proposed field value not grounded in the fetched content.
|
|
84
84
|
- **Streaming extraction.** `gather.stream` parses an HTML stream chunk by chunk and emits partial-update commits as blocks complete, each folded into a hash chain, so a streamed extraction is replayable.
|
|
85
|
-
- **Hard-source adapters behind one shape.** Video with captions and comments (`yt-dlp`), static web, RSS/Atom feeds, local docs, arXiv, PDFs (`pdftotext`), authenticated JSON APIs (token from env, never logged), JS-rendered pages (headless Chromium), scanned images (`tesseract`), and audio (`whisper`). Each external tool is optional and only needed for its own adapter.
|
|
85
|
+
- **Hard-source adapters behind one shape.** Video with captions and comments (`yt-dlp` first, with the official YouTube Data API as a fallback when you supply your own key; each item records which path served it and how fast), Reddit through its official Data API with your own app credentials, static web, RSS/Atom feeds, local docs, arXiv, PDFs (`pdftotext`), authenticated JSON APIs (token from env, never logged), JS-rendered pages (headless Chromium), scanned images (`tesseract`), and audio (`whisper`). Each external tool is optional and only needed for its own adapter.
|
|
86
|
+
- **Cited reports, checked in code.** `gather report` asks a model on your own machine to answer from fixed excerpts with exact quotes, then checks every quote against the excerpt it cites and marks any it cannot verify.
|
|
87
|
+
- **Filter ledgers.** `--scope TERMS --ledger DIR` writes one row per dropped item with its reason code, plus the input, so `gather ledger verify` can recompute every count.
|
|
86
88
|
- **Channel and playlist intake.** `gather channel <url> --store DIR` lists a channel's videos, shorts, and streams tabs (or one playlist) and gathers every entry with bounded concurrency, resumable from a per-pass ledger. Metadata and comments run as one pass and captions as another (`--no-captions`, `--captions-only`), with polite pacing, bounded backoff on HTTP 429 and session rate limits, a stop at the first bot check (never retried or answered), one caption track per video, and a run summary that counts every outcome by reason.
|
|
87
89
|
- **Scholarly-graph federation.** `gather scholar` queries OpenAlex, Semantic Scholar, and Crossref in one call, dedupes results by normalized DOI (never a fuzzy title match), and can capture citation edges as first-class records with `--edges`.
|
|
88
90
|
- **A durable local corpus.** Any fetch command takes `--store DIR`: bodies are content-addressed and deduped by hash, new writes preserve exact UTF-8 source bytes, and `gather corpus list|verify|digest|runs|search|stats|prune|availability|context` inspects, re-checks, and queries what you stored.
|
|
@@ -250,7 +252,7 @@ Keep the README, package metadata, and examples aligned with current behavior be
|
|
|
250
252
|
|
|
251
253
|
---
|
|
252
254
|
|
|
253
|
-
**[
|
|
255
|
+
Built by **[Zain Dana Harper](https://harperz9.github.io)** in Seattle: evidence-first tools that leave a re-checkable artifact behind. The full workbench is at [Project Telos](https://harperz9.github.io).
|
|
254
256
|
|
|
255
257
|
## Local client packages
|
|
256
258
|
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
[](https://pypi.org/project/gather-engine/)
|
|
9
9
|

|
|
10
10
|

|
|
11
|
-

|
|
12
12
|
|
|
13
13
|
gather pulls research out of the places most tools break on: arXiv papers, authenticated JSON APIs, JavaScript-rendered pages via a real headless browser, scanned images through OCR, and audio through transcription, alongside video, web, feeds, and local docs. The core runs with zero third-party runtime dependencies, and the same engine is reachable from the CLI, MCP tools, and plain Python. Every run writes a receipt you can re-check.
|
|
14
14
|
|
|
@@ -16,7 +16,7 @@ gather pulls research out of the places most tools break on: arXiv papers, authe
|
|
|
16
16
|
|
|
17
17
|
## Current status
|
|
18
18
|
|
|
19
|
-
`gather-engine 2.
|
|
19
|
+
`gather-engine 2.2.0` is the current source version. Core intake,
|
|
20
20
|
content-addressed corpus storage, exact UTF-8 source-byte receipts, readable
|
|
21
21
|
context selection, descriptor handoff, pilot monitoring, CLI, Python API, and MCP
|
|
22
22
|
surfaces are present in this checkout. Browser rendering, OCR, audio, and fast
|
|
@@ -56,7 +56,9 @@ fabricated field cannot reach the corpus by being plausible.
|
|
|
56
56
|
- **Track elements across redesigns.** Fingerprint a scraped element once, then relocate it in any later version of the page and get a typed MATCH, RELOCATED, DRIFT, or GONE verdict (`gather.track`, Python API).
|
|
57
57
|
- **Structured extraction with a hallucination check.** `gather.schema_extract` binds schema fields to source nodes, and `verify_record` rejects any LLM-proposed field value not grounded in the fetched content.
|
|
58
58
|
- **Streaming extraction.** `gather.stream` parses an HTML stream chunk by chunk and emits partial-update commits as blocks complete, each folded into a hash chain, so a streamed extraction is replayable.
|
|
59
|
-
- **Hard-source adapters behind one shape.** Video with captions and comments (`yt-dlp`), static web, RSS/Atom feeds, local docs, arXiv, PDFs (`pdftotext`), authenticated JSON APIs (token from env, never logged), JS-rendered pages (headless Chromium), scanned images (`tesseract`), and audio (`whisper`). Each external tool is optional and only needed for its own adapter.
|
|
59
|
+
- **Hard-source adapters behind one shape.** Video with captions and comments (`yt-dlp` first, with the official YouTube Data API as a fallback when you supply your own key; each item records which path served it and how fast), Reddit through its official Data API with your own app credentials, static web, RSS/Atom feeds, local docs, arXiv, PDFs (`pdftotext`), authenticated JSON APIs (token from env, never logged), JS-rendered pages (headless Chromium), scanned images (`tesseract`), and audio (`whisper`). Each external tool is optional and only needed for its own adapter.
|
|
60
|
+
- **Cited reports, checked in code.** `gather report` asks a model on your own machine to answer from fixed excerpts with exact quotes, then checks every quote against the excerpt it cites and marks any it cannot verify.
|
|
61
|
+
- **Filter ledgers.** `--scope TERMS --ledger DIR` writes one row per dropped item with its reason code, plus the input, so `gather ledger verify` can recompute every count.
|
|
60
62
|
- **Channel and playlist intake.** `gather channel <url> --store DIR` lists a channel's videos, shorts, and streams tabs (or one playlist) and gathers every entry with bounded concurrency, resumable from a per-pass ledger. Metadata and comments run as one pass and captions as another (`--no-captions`, `--captions-only`), with polite pacing, bounded backoff on HTTP 429 and session rate limits, a stop at the first bot check (never retried or answered), one caption track per video, and a run summary that counts every outcome by reason.
|
|
61
63
|
- **Scholarly-graph federation.** `gather scholar` queries OpenAlex, Semantic Scholar, and Crossref in one call, dedupes results by normalized DOI (never a fuzzy title match), and can capture citation edges as first-class records with `--edges`.
|
|
62
64
|
- **A durable local corpus.** Any fetch command takes `--store DIR`: bodies are content-addressed and deduped by hash, new writes preserve exact UTF-8 source bytes, and `gather corpus list|verify|digest|runs|search|stats|prune|availability|context` inspects, re-checks, and queries what you stored.
|
|
@@ -224,7 +226,7 @@ Keep the README, package metadata, and examples aligned with current behavior be
|
|
|
224
226
|
|
|
225
227
|
---
|
|
226
228
|
|
|
227
|
-
**[
|
|
229
|
+
Built by **[Zain Dana Harper](https://harperz9.github.io)** in Seattle: evidence-first tools that leave a re-checkable artifact behind. The full workbench is at [Project Telos](https://harperz9.github.io).
|
|
228
230
|
|
|
229
231
|
## Local client packages
|
|
230
232
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "gather-engine"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.2.0"
|
|
8
8
|
description = "Accountable research intake for difficult sources, with provenance receipts and witnessed digests."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -28,7 +28,7 @@ from gather.scope import filter_scope, in_scope
|
|
|
28
28
|
from gather.source import Catalog, Source
|
|
29
29
|
from gather.store import Corpus
|
|
30
30
|
|
|
31
|
-
__version__ = "2.
|
|
31
|
+
__version__ = "2.2.0"
|
|
32
32
|
|
|
33
33
|
__all__ = [
|
|
34
34
|
"Catalog", "Corpus", "CorpusRootDescriptor", "CorpusRootIdentity", "Digest", "Item", "NullProvenanceProvider", "NullSynthesizer",
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""A code check on every citation in a report. Pure: no network, no model.
|
|
2
|
+
|
|
3
|
+
A report cites an excerpt by quoting it and naming its number: ``"the exact words" [2]``. For
|
|
4
|
+
each citation the check decides, by string comparison alone:
|
|
5
|
+
|
|
6
|
+
- ``verified``: the quoted span appears in excerpt 2 (after Unicode, quote-mark, dash and
|
|
7
|
+
whitespace normalisation, and case folding);
|
|
8
|
+
- ``not-in-source``: excerpt 2 exists and does not contain the span;
|
|
9
|
+
- ``unknown-source``: there is no excerpt 2;
|
|
10
|
+
- ``too-short``: the quote has fewer than ``MIN_QUOTE_WORDS`` words, so a match would prove
|
|
11
|
+
little; it is not counted as verified;
|
|
12
|
+
- ``unchecked``: a bracketed number with no quote before it, so there is nothing to compare.
|
|
13
|
+
|
|
14
|
+
Sentences with no citation at all are listed as uncited. Nothing is hidden: every citation and
|
|
15
|
+
every uncited sentence is in the result. Precision is verified citations over all citations.
|
|
16
|
+
|
|
17
|
+
What this does not check: whether the sentence around a verified quote says what the excerpt
|
|
18
|
+
means. A quote can be exact and the claim around it can still stretch it.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import re
|
|
24
|
+
import unicodedata
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
|
|
27
|
+
CHECK_SCHEMA = "gather.citation-check/1"
|
|
28
|
+
MIN_QUOTE_WORDS = 4
|
|
29
|
+
STATUSES = ("verified", "not-in-source", "unknown-source", "too-short", "unchecked")
|
|
30
|
+
|
|
31
|
+
_MARKS = str.maketrans({"“": '"', "”": '"', "„": '"', "«": '"', "»": '"',
|
|
32
|
+
"‘": "'", "’": "'", "–": "-", "—": "-", "−": "-",
|
|
33
|
+
" ": " "})
|
|
34
|
+
# a double-quoted span, optional space, then [n]
|
|
35
|
+
_QUOTE_CITE = re.compile(r'"([^"]{1,2000})"\s*\[(\d{1,4})\]')
|
|
36
|
+
_BARE_CITE = re.compile(r"\[(\d{1,4})\]")
|
|
37
|
+
_LIST_MARK = re.compile(r"^(?:[-*+]|\d{1,3}[.)])\s+")
|
|
38
|
+
_SENTENCE = re.compile(r'(?<=[.!?])["\')\]]*\s+(?=["(\[A-Z0-9])')
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def normalize(text: str) -> str:
|
|
42
|
+
"""NFKC, straight quote marks and hyphens, collapsed whitespace, case folded."""
|
|
43
|
+
text = unicodedata.normalize("NFKC", text).translate(_MARKS)
|
|
44
|
+
return re.sub(r"\s+", " ", text).strip().casefold()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _strip_edges(quote: str) -> str:
|
|
48
|
+
return normalize(quote).strip(" .,;:!?'")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True, slots=True)
|
|
52
|
+
class Citation:
|
|
53
|
+
sentence: int
|
|
54
|
+
source: int
|
|
55
|
+
quote: str | None
|
|
56
|
+
status: str
|
|
57
|
+
|
|
58
|
+
def to_dict(self) -> dict:
|
|
59
|
+
return {"sentence": self.sentence, "source": self.source, "quote": self.quote, "status": self.status}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _split_line(line: str) -> list[str]:
|
|
63
|
+
"""Split one line at sentence ends that fall outside a double-quoted span."""
|
|
64
|
+
parts, start = [], 0
|
|
65
|
+
for m in _SENTENCE.finditer(line):
|
|
66
|
+
if line[:m.start()].count('"') % 2 == 0:
|
|
67
|
+
parts.append(line[start:m.start() + len(m.group(0).rstrip())].strip())
|
|
68
|
+
start = m.end()
|
|
69
|
+
parts.append(line[start:].strip())
|
|
70
|
+
return [p for p in parts if p]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def sentences(report: str) -> list[str]:
|
|
74
|
+
"""The report's sentences: lines split at sentence ends outside quotes, list markers removed,
|
|
75
|
+
headings and blank lines dropped."""
|
|
76
|
+
out: list[str] = []
|
|
77
|
+
for raw in report.splitlines():
|
|
78
|
+
line = raw.strip()
|
|
79
|
+
if not line or line.startswith("#"):
|
|
80
|
+
continue
|
|
81
|
+
out.extend(_split_line(_LIST_MARK.sub("", line.translate(_MARKS))))
|
|
82
|
+
return out
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _status(quote: str, source: int, excerpts: list[str]) -> str:
|
|
86
|
+
if not 1 <= source <= len(excerpts):
|
|
87
|
+
return "unknown-source"
|
|
88
|
+
span = _strip_edges(quote)
|
|
89
|
+
if len(span.split()) < MIN_QUOTE_WORDS:
|
|
90
|
+
return "too-short"
|
|
91
|
+
return "verified" if span in normalize(excerpts[source - 1]) else "not-in-source"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _sentence_citations(n: int, sentence: str, excerpts: list[str]) -> list[Citation]:
|
|
95
|
+
found = [Citation(n, int(m.group(2)), m.group(1), _status(m.group(1), int(m.group(2)), excerpts))
|
|
96
|
+
for m in _QUOTE_CITE.finditer(sentence.translate(_MARKS))]
|
|
97
|
+
bare_text = _QUOTE_CITE.sub("", sentence.translate(_MARKS))
|
|
98
|
+
found += [Citation(n, int(m.group(1)), None, "unchecked") for m in _BARE_CITE.finditer(bare_text)]
|
|
99
|
+
return found
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def check_report(report: str, excerpts: list[str]) -> dict:
|
|
103
|
+
"""Check every citation in ``report`` against ``excerpts`` (excerpt 1 is ``excerpts[0]``)."""
|
|
104
|
+
cites: list[Citation] = []
|
|
105
|
+
uncited: list[str] = []
|
|
106
|
+
for n, sentence in enumerate(sentences(report)):
|
|
107
|
+
found = _sentence_citations(n, sentence, excerpts)
|
|
108
|
+
cites.extend(found)
|
|
109
|
+
if not found:
|
|
110
|
+
uncited.append(sentence)
|
|
111
|
+
counts = {s: sum(1 for c in cites if c.status == s) for s in STATUSES}
|
|
112
|
+
total = len(cites)
|
|
113
|
+
return {
|
|
114
|
+
"schema": CHECK_SCHEMA, "excerpts": len(excerpts), "citations": [c.to_dict() for c in cites],
|
|
115
|
+
"counts": counts, "total": total,
|
|
116
|
+
"precision": round(counts["verified"] / total, 4) if total else None,
|
|
117
|
+
"uncited_sentences": uncited,
|
|
118
|
+
"does_not_prove": "that the claim around a verified quote matches the excerpt's meaning",
|
|
119
|
+
}
|
|
@@ -4,6 +4,7 @@ import argparse
|
|
|
4
4
|
import sys
|
|
5
5
|
|
|
6
6
|
from gather import __version__
|
|
7
|
+
from gather.cli_ext import add_extension_parsers
|
|
7
8
|
from gather.commands import (
|
|
8
9
|
cmd_api,
|
|
9
10
|
cmd_arxiv,
|
|
@@ -11,12 +12,10 @@ from gather.commands import (
|
|
|
11
12
|
cmd_docs,
|
|
12
13
|
cmd_feed,
|
|
13
14
|
cmd_ocr,
|
|
14
|
-
cmd_parse,
|
|
15
15
|
cmd_pdf,
|
|
16
16
|
cmd_run,
|
|
17
17
|
cmd_scholar,
|
|
18
18
|
cmd_transcribe,
|
|
19
|
-
cmd_video,
|
|
20
19
|
cmd_web,
|
|
21
20
|
)
|
|
22
21
|
from gather.corpus_cmd import cmd_corpus
|
|
@@ -29,7 +28,6 @@ from gather.pilot_commands import (
|
|
|
29
28
|
cmd_pilot_run,
|
|
30
29
|
cmd_pilot_verify,
|
|
31
30
|
)
|
|
32
|
-
from gather.video_cmd import add_channel_parser, add_ytdlp_options
|
|
33
31
|
from gather.web_commands import (
|
|
34
32
|
cmd_caps,
|
|
35
33
|
cmd_crawl,
|
|
@@ -50,6 +48,8 @@ def _add_common(p: argparse.ArgumentParser) -> None:
|
|
|
50
48
|
p.add_argument("--scope", default=None, help="comma-separated scope terms; keep items mentioning any")
|
|
51
49
|
p.add_argument("--json", action="store_true", help="emit the catalog and digest as JSON")
|
|
52
50
|
p.add_argument("--store", default=None, metavar="DIR", help="persist gathered items into a corpus at DIR")
|
|
51
|
+
p.add_argument("--ledger", default=None, metavar="DIR",
|
|
52
|
+
help="write a filter ledger (ledger.json, input.jsonl) of what --scope dropped into DIR")
|
|
53
53
|
|
|
54
54
|
|
|
55
55
|
def _add_flagship_commands(sub) -> None:
|
|
@@ -124,21 +124,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
124
124
|
sub = parser.add_subparsers(dest="command")
|
|
125
125
|
_add_flagship_commands(sub)
|
|
126
126
|
|
|
127
|
-
|
|
128
|
-
parse.add_argument("info", help="path to a yt-dlp info.json")
|
|
129
|
-
parse.add_argument("--vtt", default=None, help="path to a .vtt captions file")
|
|
130
|
-
parse.add_argument("--auto-captions", action="store_true",
|
|
131
|
-
help="captions are machine-generated: collapse rolling-window growth, stamp auto-caption")
|
|
132
|
-
_add_common(parse)
|
|
133
|
-
parse.set_defaults(func=cmd_parse)
|
|
134
|
-
|
|
135
|
-
video = sub.add_parser("video", help="fetch a video via yt-dlp (needs yt-dlp on PATH and network)")
|
|
136
|
-
video.add_argument("url")
|
|
137
|
-
video.add_argument("--comments", action="store_true", help="also gather comments")
|
|
138
|
-
add_ytdlp_options(video)
|
|
139
|
-
_add_common(video)
|
|
140
|
-
video.set_defaults(func=cmd_video)
|
|
141
|
-
add_channel_parser(sub)
|
|
127
|
+
add_extension_parsers(sub, _add_common)
|
|
142
128
|
|
|
143
129
|
web = sub.add_parser("web", help="fetch a static web page via http(s) and extract readable text")
|
|
144
130
|
web.add_argument("url")
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""The subcommands that live in their own modules, registered in one place for ``gather.cli``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def add_extension_parsers(sub, add_common: Callable) -> None:
|
|
9
|
+
from gather.ledger_cmd import add_ledger_parser
|
|
10
|
+
from gather.reddit_cmd import add_reddit_parser
|
|
11
|
+
from gather.report_cmd import add_report_parsers
|
|
12
|
+
from gather.video_cmd import add_channel_parser
|
|
13
|
+
from gather.youtube_cmd import add_video_parsers
|
|
14
|
+
|
|
15
|
+
add_video_parsers(sub, add_common)
|
|
16
|
+
add_channel_parser(sub)
|
|
17
|
+
add_reddit_parser(sub, add_common)
|
|
18
|
+
add_report_parsers(sub, add_common)
|
|
19
|
+
add_ledger_parser(sub)
|
|
@@ -156,23 +156,22 @@ def main(argv=None):
|
|
|
156
156
|
return 0
|
|
157
157
|
|
|
158
158
|
def definitions(network=None):
|
|
159
|
-
from gather.
|
|
160
|
-
tools = [
|
|
159
|
+
from gather.docs_tool import annotate, docs_tool
|
|
160
|
+
tools = [annotate(docs_tool())]
|
|
161
161
|
if network is not None and network.origins:
|
|
162
|
-
tools.append({"name": "gather.fetch", "description": "Read an allowed origin; return untrusted source text and a byte receipt. No redirects or credentials.",
|
|
163
|
-
"inputSchema": {"type": "object", "properties": {"url": {"type": "string"}}, "required": ["url"], "additionalProperties": False}})
|
|
162
|
+
tools.append(annotate({"name": "gather.fetch", "description": "Read an allowed origin; return untrusted source text and a byte receipt. No redirects or credentials.",
|
|
163
|
+
"inputSchema": {"type": "object", "properties": {"url": {"type": "string"}}, "required": ["url"], "additionalProperties": False}}))
|
|
164
164
|
return tools
|
|
165
165
|
|
|
166
166
|
|
|
167
167
|
def invoke(name, args, root):
|
|
168
|
-
from gather.
|
|
169
|
-
from gather.mcp import call_tool
|
|
168
|
+
from gather.docs_tool import call_docs
|
|
170
169
|
# Single files avoid a directory walk following a file link outside the grant.
|
|
171
170
|
path = confined(root, args["path"])
|
|
172
171
|
if not path.is_file():
|
|
173
172
|
raise ClientRefusal("local client intake requires one text file per call")
|
|
174
173
|
args["path"] = str(path)
|
|
175
|
-
return
|
|
174
|
+
return call_docs(args)
|
|
176
175
|
|
|
177
176
|
|
|
178
177
|
if __name__ == "__main__":
|
|
@@ -13,12 +13,22 @@ def _scope(args) -> list[str]:
|
|
|
13
13
|
return _split(args.scope)
|
|
14
14
|
|
|
15
15
|
|
|
16
|
-
def
|
|
16
|
+
def _scoped(items, scope, ledger=None):
|
|
17
|
+
"""The scope filter; with ``ledger``, every drop is also written to a ledger beside its input."""
|
|
18
|
+
if not ledger:
|
|
19
|
+
from gather.scope import filter_scope
|
|
20
|
+
return filter_scope(items, scope)
|
|
21
|
+
from gather.filter_ledger import scope_with_ledger, write_ledger
|
|
22
|
+
kept, record = scope_with_ledger(items, scope)
|
|
23
|
+
write_ledger(ledger, record, items)
|
|
24
|
+
return kept, record["dropped"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _emit(items, scope, as_json, store=None, ledger=None) -> int:
|
|
17
28
|
from gather.digest import digest, verify_digest
|
|
18
29
|
from gather.payloads import catalog_digest_payload
|
|
19
|
-
from gather.scope import filter_scope
|
|
20
30
|
|
|
21
|
-
kept, dropped =
|
|
31
|
+
kept, dropped = _scoped(items, scope, ledger)
|
|
22
32
|
d = digest(kept)
|
|
23
33
|
stored = None
|
|
24
34
|
if store:
|
|
@@ -48,7 +58,7 @@ def _fetch_and_emit(fetch, args, fail: str = "fetch failed") -> int:
|
|
|
48
58
|
except Exception as exc:
|
|
49
59
|
print(f"{fail}: {exc}", file=sys.stderr)
|
|
50
60
|
return 1
|
|
51
|
-
return _emit(items, _scope(args), args.json, store=args.store)
|
|
61
|
+
return _emit(items, _scope(args), args.json, store=args.store, ledger=getattr(args, "ledger", None))
|
|
52
62
|
|
|
53
63
|
|
|
54
64
|
def cmd_parse(args) -> int:
|
|
@@ -61,13 +71,13 @@ def cmd_parse(args) -> int:
|
|
|
61
71
|
with open(args.vtt, encoding="utf-8") as f:
|
|
62
72
|
vtt = f.read()
|
|
63
73
|
items = parse_video(info_json, vtt, fetched_at=time.time(), auto_captions=args.auto_captions)
|
|
64
|
-
return _emit(items, _scope(args), args.json, store=args.store)
|
|
74
|
+
return _emit(items, _scope(args), args.json, store=args.store, ledger=args.ledger)
|
|
65
75
|
|
|
66
76
|
|
|
67
77
|
def cmd_video(args) -> int:
|
|
68
|
-
from gather.
|
|
78
|
+
from gather.youtube_cmd import route_from_args
|
|
69
79
|
try:
|
|
70
|
-
src =
|
|
80
|
+
src = route_from_args(args, with_comments=args.comments)
|
|
71
81
|
except ValueError as exc:
|
|
72
82
|
print(f"fetch failed: {exc}", file=sys.stderr)
|
|
73
83
|
return 2
|
|
@@ -115,17 +125,16 @@ def cmd_scholar(args) -> int:
|
|
|
115
125
|
except Exception as exc:
|
|
116
126
|
print(f"scholar failed: {exc}", file=sys.stderr)
|
|
117
127
|
return 1
|
|
118
|
-
return _emit_with_edges(items, edges, _scope(args), args.json, store=args.store)
|
|
128
|
+
return _emit_with_edges(items, edges, _scope(args), args.json, store=args.store, ledger=args.ledger)
|
|
119
129
|
|
|
120
130
|
|
|
121
|
-
def _emit_with_edges(items, edges, scope, as_json, store=None) -> int:
|
|
131
|
+
def _emit_with_edges(items, edges, scope, as_json, store=None, ledger=None) -> int:
|
|
122
132
|
"""Emit scholar items plus citation-edge receipts, folding the edges into the same witnessed
|
|
123
133
|
digest as the papers, so the seal covers the graph (nodes and links), not just the nodes."""
|
|
124
134
|
from gather.digest import digest_of_receipts, verify_digest
|
|
125
|
-
from gather.scope import filter_scope
|
|
126
135
|
from gather.store import Corpus
|
|
127
136
|
|
|
128
|
-
kept, dropped =
|
|
137
|
+
kept, dropped = _scoped(items, scope, ledger)
|
|
129
138
|
item_receipts = [
|
|
130
139
|
{"kind": i.kind, "id": i.id, "title": i.title, "source": i.provenance.source,
|
|
131
140
|
"ref": i.provenance.ref, "method": i.provenance.method, "sha256": i.provenance.sha256,
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""The gather.docs tool and the MCP tool annotations.
|
|
2
|
+
|
|
3
|
+
Shared by the full MCP server and the local client profile, so the client can
|
|
4
|
+
serve gather.docs without importing the full tool surface.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
|
|
10
|
+
from gather.localpath import require_local
|
|
11
|
+
from gather.payloads import catalog_digest_payload
|
|
12
|
+
from gather.scope import filter_scope
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def scope_terms(raw: object) -> list[str]:
|
|
16
|
+
if raw is None:
|
|
17
|
+
return []
|
|
18
|
+
if isinstance(raw, str):
|
|
19
|
+
return [term.strip() for term in raw.split(",") if term.strip()]
|
|
20
|
+
if isinstance(raw, list) and all(isinstance(term, str) for term in raw):
|
|
21
|
+
return [term.strip() for term in raw if term.strip()]
|
|
22
|
+
raise ValueError("scope must be a comma-separated string or a list of strings")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def payload_from_items(items, scope: list[str]) -> dict:
|
|
26
|
+
kept, dropped = filter_scope(items, scope)
|
|
27
|
+
return catalog_digest_payload(kept, dropped=dropped)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _hints(title: str, *, read_only: bool = True, idempotent: bool = True,
|
|
31
|
+
open_world: bool = False) -> dict:
|
|
32
|
+
return {"title": title, "readOnlyHint": read_only, "destructiveHint": False,
|
|
33
|
+
"idempotentHint": idempotent, "openWorldHint": open_world}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# MCP tool annotations. A hint describes the tool to the client and grants
|
|
37
|
+
# nothing; launch grants still refuse network sources, commands and credentials.
|
|
38
|
+
TOOL_ANNOTATIONS = {
|
|
39
|
+
"gather.status": _hints("Gather status"),
|
|
40
|
+
"gather.doctor": _hints("Gather readiness check"),
|
|
41
|
+
"gather.docs": _hints("Read local documents with receipts"),
|
|
42
|
+
"gather.arxiv": _hints("Fetch arXiv metadata", idempotent=False, open_world=True),
|
|
43
|
+
"gather.federation": _hints("Validate or plan a source registry"),
|
|
44
|
+
"gather.run": _hints("Run a gather config", read_only=False, idempotent=False,
|
|
45
|
+
open_world=True),
|
|
46
|
+
"gather.context": _hints("Select verified corpus context"),
|
|
47
|
+
"gather.pilot": _hints("Run or verify a gather pilot", read_only=False, idempotent=False,
|
|
48
|
+
open_world=True),
|
|
49
|
+
"gather.fetch": _hints("Read an allowed web origin", idempotent=False, open_world=True),
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def annotate(tool: dict) -> dict:
|
|
54
|
+
notes = dict(TOOL_ANNOTATIONS[tool["name"]])
|
|
55
|
+
return {**tool, "title": notes["title"], "annotations": notes}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def docs_tool() -> dict:
|
|
59
|
+
"""The raw gather.docs definition, before annotation."""
|
|
60
|
+
return {
|
|
61
|
+
"name": "gather.docs",
|
|
62
|
+
"description": "Read a local text file or directory and return catalog rows plus digest receipts.",
|
|
63
|
+
"inputSchema": {
|
|
64
|
+
"type": "object",
|
|
65
|
+
"properties": {
|
|
66
|
+
"path": {"type": "string", "description": "local file or directory to read; a network "
|
|
67
|
+
"or device path returns NON_LOCAL_PATH"},
|
|
68
|
+
"scope": {
|
|
69
|
+
"description": (
|
|
70
|
+
"optional post-fetch content filter: keep items whose title or body "
|
|
71
|
+
"contains any term as a case-insensitive substring; omit for unfiltered capture"
|
|
72
|
+
),
|
|
73
|
+
"oneOf": [
|
|
74
|
+
{"type": "string"},
|
|
75
|
+
{"type": "array", "items": {"type": "string"}},
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
"required": ["path"],
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def call_docs(args: dict) -> str:
|
|
85
|
+
"""Run gather.docs: read one local path and return catalog rows plus digest receipts."""
|
|
86
|
+
from gather.docs import DocsSource
|
|
87
|
+
|
|
88
|
+
path = args.get("path")
|
|
89
|
+
if not isinstance(path, str) or not path:
|
|
90
|
+
raise ValueError("gather.docs requires a non-empty path")
|
|
91
|
+
require_local(path, label="path")
|
|
92
|
+
payload = payload_from_items(DocsSource().fetch(path), scope_terms(args.get("scope")))
|
|
93
|
+
return json.dumps(payload, indent=2, ensure_ascii=False, sort_keys=True)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Filter ledgers: what a filter dropped, item by item, with one reason code each.
|
|
2
|
+
|
|
3
|
+
A filter that keeps some items and drops others makes a judgment a reader cannot see in the kept
|
|
4
|
+
set alone. ``apply_filters`` runs named filters in order; the first filter that rejects an item
|
|
5
|
+
gives its reason code, so every dropped item has exactly one. The ledger records the input
|
|
6
|
+
(count and a digest over the items' content hashes, in order), one row per dropped item (index,
|
|
7
|
+
id, content hash, reason), counts by reason, and the parameters of each filter.
|
|
8
|
+
|
|
9
|
+
``write_ledger`` saves the ledger and the input items beside it (``ledger.json`` and
|
|
10
|
+
``input.jsonl``), so a second person can recompute every count. ``verify_ledger`` does that
|
|
11
|
+
recomputation: it rebuilds the input digest, checks every row against the input, checks that
|
|
12
|
+
kept plus dropped equals the input and that the counts by reason match the rows, and, for the
|
|
13
|
+
built-in scope filter, runs the filter again and compares.
|
|
14
|
+
|
|
15
|
+
A ledger makes the filtering inspectable. It does not show that the filtering was fair, and a
|
|
16
|
+
reason code can still carry a judgment inside its label.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import hashlib
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
from collections.abc import Callable, Sequence
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from gather.item import Item, make_item
|
|
29
|
+
from gather.scope import in_scope
|
|
30
|
+
|
|
31
|
+
LEDGER_SCHEMA = "gather.filter-ledger/1"
|
|
32
|
+
SCOPE_REASON = "out-of-scope"
|
|
33
|
+
DOES_NOT_PROVE = "that the filtering was fair; it makes the filtering inspectable"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True, slots=True)
|
|
37
|
+
class Filter:
|
|
38
|
+
"""One named filter: ``keep(item)`` returns True to keep; a drop is recorded as ``reason``."""
|
|
39
|
+
|
|
40
|
+
reason: str
|
|
41
|
+
keep: Callable[[Item], bool]
|
|
42
|
+
params: dict[str, Any] = field(default_factory=dict)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def scope_filter(terms: Sequence[str]) -> Filter:
|
|
46
|
+
term_list = [t for t in terms if t]
|
|
47
|
+
return Filter(SCOPE_REASON, lambda it: in_scope(it, term_list), {"filter": "scope", "terms": term_list})
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def input_digest(hashes: Sequence[str]) -> str:
|
|
51
|
+
return hashlib.sha256("\n".join(hashes).encode("utf-8")).hexdigest()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def apply_filters(items: Sequence[Item], filters: Sequence[Filter]) -> tuple[list[Item], dict]:
|
|
55
|
+
"""The kept items, in order, and the ledger of the dropped."""
|
|
56
|
+
kept: list[Item] = []
|
|
57
|
+
rows: list[dict] = []
|
|
58
|
+
for index, it in enumerate(items):
|
|
59
|
+
reason = next((f.reason for f in filters if not f.keep(it)), None)
|
|
60
|
+
if reason is None:
|
|
61
|
+
kept.append(it)
|
|
62
|
+
else:
|
|
63
|
+
rows.append({"index": index, "id": it.id, "sha256": it.provenance.sha256, "reason": reason})
|
|
64
|
+
by_reason = {f.reason: sum(1 for r in rows if r["reason"] == f.reason) for f in filters}
|
|
65
|
+
ledger = {
|
|
66
|
+
"schema": LEDGER_SCHEMA,
|
|
67
|
+
"filters": [{"reason": f.reason, **f.params} for f in filters],
|
|
68
|
+
"input": {"count": len(items), "digest": input_digest([i.provenance.sha256 for i in items])},
|
|
69
|
+
"kept": len(kept), "dropped": len(rows), "by_reason": by_reason, "rows": rows,
|
|
70
|
+
"does_not_prove": DOES_NOT_PROVE,
|
|
71
|
+
}
|
|
72
|
+
return kept, ledger
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def scope_with_ledger(items: Sequence[Item], terms: Sequence[str]) -> tuple[list[Item], dict]:
|
|
76
|
+
"""The scope filter with its ledger. No terms keeps everything and drops nothing."""
|
|
77
|
+
return apply_filters(items, [scope_filter(terms)] if [t for t in terms if t] else [])
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _item_row(it: Item) -> dict:
|
|
81
|
+
p = it.provenance
|
|
82
|
+
return {"kind": it.kind, "id": it.id, "title": it.title, "text": it.text, "source": p.source,
|
|
83
|
+
"ref": p.ref, "method": p.method, "fetched_at": p.fetched_at, "sha256": p.sha256}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def write_ledger(directory: str, ledger: dict, items: Sequence[Item]) -> dict[str, str]:
|
|
87
|
+
"""Write ``ledger.json`` and ``input.jsonl`` into ``directory`` (created if absent)."""
|
|
88
|
+
os.makedirs(directory, exist_ok=True)
|
|
89
|
+
paths = {"ledger": os.path.join(directory, "ledger.json"), "input": os.path.join(directory, "input.jsonl")}
|
|
90
|
+
with open(paths["input"], "w", encoding="utf-8") as f:
|
|
91
|
+
for it in items:
|
|
92
|
+
f.write(json.dumps(_item_row(it), ensure_ascii=False, sort_keys=True) + "\n")
|
|
93
|
+
with open(paths["ledger"], "w", encoding="utf-8") as f:
|
|
94
|
+
json.dump(ledger, f, indent=2, ensure_ascii=False)
|
|
95
|
+
return paths
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def read_input(path: str) -> list[Item]:
|
|
99
|
+
"""Items from an ``input.jsonl``. A row whose text no longer matches its hash is an error."""
|
|
100
|
+
items = []
|
|
101
|
+
with open(path, encoding="utf-8") as f:
|
|
102
|
+
for n, line in enumerate(f, 1):
|
|
103
|
+
if not line.strip():
|
|
104
|
+
continue
|
|
105
|
+
r = json.loads(line)
|
|
106
|
+
it = make_item(kind=r["kind"], id=r["id"], title=r["title"], text=r["text"], source=r["source"],
|
|
107
|
+
ref=r["ref"], method=r["method"], fetched_at=r["fetched_at"])
|
|
108
|
+
if it.provenance.sha256 != r["sha256"]:
|
|
109
|
+
raise ValueError(f"input line {n}: text does not match its sha256")
|
|
110
|
+
items.append(it)
|
|
111
|
+
return items
|