Skip to content
This repository was archived by the owner on Jul 20, 2026. It is now read-only.

DocumentSource-shaped index, SEO surface, staged R2 publish & Postgres document index - #88

Merged
damo-da merged 6 commits into
mainfrom
ngm-documentsource-format
Jun 26, 2026
Merged

DocumentSource-shaped index, SEO surface, staged R2 publish & Postgres document index#88
damo-da merged 6 commits into
mainfrom
ngm-documentsource-format

Conversation

@damo-da

@damo-da damo-da commented Jun 26, 2026

Copy link
Copy Markdown
Member

What & why

Makes every NGM document a first-class, publicly discoverable record and keeps an up-to-date queryable index of all of them. NGM only adopts the Jawafdehi API DocumentSource format — there is no API coupling here (a future import is a clean 1:1).

Changes

Index format (DocumentSource-compatible)

  • Manuscript carries links: [{link, role}], a stable document_id, and source_type, mirroring cases.models.SourceLinkRole/SourceType field-for-field. url/file_name stay as the RAW back-compat alias.
  • Builders consolidate to one manuscript per logical document: CIAA press releases group by press_id (PDF=RAW, other attachments=ALTERNATE, CIAA page=SOURCE_PAGE); court-order leaves collapse per case_number.

SEO surface (crawlable, streamed)

  • build_index emits a static HTML landing page per document under /d/ (canonical, Open Graph, JSON-LD CreativeWork, roled links, transcript placeholder), a sitemap index + per-dataset child sitemaps (split at the 50k-URL limit), and robots.txt.
  • The writer streams (bounded in-flight window), so peak memory stays flat at 1M+ documents.

Publish: local staging → R2 mirror

  • IndexBuilder splits read-root (uploads/) from write-root (output_root). main() builds the derived tree to a local /tmp staging dir, then publish.py bulk-uploads via boto3 with explicit Content-Type (.htmltext/html) and syncs deletions so the store is a clean mirror.
  • Delete scope is guarded (is_safe_to_delete) to the SEO surface + the current indices/<date>/. uploads/ (source) and older snapshots are never listed or deleted.
  • Local FILES_STORE builds in place (no staging/publish) — tests & serve_test_index unchanged.

Postgres index of all document sources

  • New document_sources table (DocumentSourceIndex, created by the existing init_db/create_all — no Alembic) + db_index.py that upserts one row per document and mirrors (deletes rows whose last_seen_build is stale). The whole archive is now SQL-queryable alongside the court tables. Skipped via NGM_SKIP_DB_INDEX.

Tests

157 passing — format consolidation & one-per-logical-doc, link validity (matches the API's validate_url_list), SEO output + sitemap pagination, read/write split, publish sync safety incl. a fake-S3 end-to-end (uploads + Content-Type + orphan pruning while uploads//old snapshots survive), and DB row collection. ruff + black clean.

Follow-ups (out of scope here)

  • The separate ngm-frontend repo consumes the index and must adapt to the consolidated manuscript shape (it grouped press releases by press_id client-side; court-order leaves no longer have one manuscript per file).
  • Markdown transcripts: pipeline is designed in docs/ngm/transcripts.md (meta repo) but not run.
  • jawafdehi-api import of these as real DocumentSource rows (needs a small external_ref field) — deferred.
  • Staging uses container /tmp (no infra change, by request); an emptyDir is the escape hatch if node ephemeral-disk pressure bites at full scale.
  • Docs (docs/ngm/index-structure.md, docs/ngm/transcripts.md) live in the meta repo (GitLab) and are committed separately.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added browseable document landing pages with cleaner document URLs.
    • Added sitemap and robots files to improve search engine discovery.
    • Added support for richer document links and stable document identifiers.
    • Enabled safer remote publishing with staged uploads and cleanup of outdated published files.
  • Bug Fixes

    • Improved handling of multi-file documents so related attachments are grouped into one logical item.
    • Preserved older output behavior when no separate staging location is configured.

…sh, Postgres index

Make every NGM document a first-class, publicly discoverable record and keep an
up-to-date queryable index of all of them.

Index format (DocumentSource-compatible):
- Manuscript carries a roled link list (links: [{link, role}]), a stable
  document_id, and a source_type, mirroring the Jawafdehi API DocumentSource
  shape field-for-field; url/file_name kept as the RAW back-compat alias.
- Builders consolidate to ONE manuscript per logical document: CIAA press
  releases group by press_id (PDF=RAW, others=ALTERNATE, ciaa page=SOURCE_PAGE),
  court-order leaves collapse per case_number.

SEO surface (crawlable, streamed):
- build_index emits a static HTML landing page per document under /d/ (canonical,
  Open Graph, JSON-LD CreativeWork, roled links, transcript placeholder), a
  sitemap index + per-dataset child sitemaps (split at 50k URLs), and robots.txt.
- Streaming writer bounds memory regardless of document count (1M+ safe).

Publish (local staging -> R2 mirror):
- IndexBuilder splits read-root (uploads/) from write-root (output_root); main()
  builds the derived tree to a local /tmp staging dir, then publish.py bulk-
  uploads via boto3 with explicit Content-Type and syncs deletions so the store
  is a clean mirror. Delete scope is guarded to the SEO surface + current
  indices/<date>/; uploads/ and older snapshots are never touched.

Postgres index:
- New document_sources table (created by init_db/create_all) + db_index.py
  upsert/mirror keyed on last_seen_build, so the whole archive is SQL-queryable
  alongside the court tables.

Tests: 157 passing (format consolidation, link validity, SEO output + pagination,
read/write split, publish sync safety incl. fake-S3 end-to-end, row collection).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@damo-da, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 43 minutes and 45 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 5ae15ef3-78d1-4909-b6ac-23b6c9b0e6bd

📥 Commits

Reviewing files that changed from the base of the PR and between b4e1e70 and 7ce270a.

📒 Files selected for processing (9)
  • ngm/index/build_index.py
  • ngm/index/db_index.py
  • ngm/index/models.py
  • ngm/index/publish.py
  • tests/test_court_order_listing.py
  • tests/test_document_format.py
  • tests/test_output_split.py
  • tests/test_publish.py
  • tests/test_seo_output.py
📝 Walkthrough

Walkthrough

Adds manuscript/source roles and stable document IDs, mirrors them into a new document_sources table, rewrites index building to stage outputs separately and generate SEO artifacts, and adds Postgres sync plus S3-compatible publish orchestration with tests for the new flows.

Changes

Document source indexing pipeline

Layer / File(s) Summary
Document contract and mirror schema
ngm/index/models.py, ngm/database/models.py, tests/test_document_format.py
Manuscript now serializes links, document_id, and source_type, and document_sources stores the mirrored fields plus JSONB metadata and a (dataset, source_type) index.
Logical manuscript builders
ngm/index/build_index.py, tests/test_document_format.py
Source-specific builders now emit slug-based logical document IDs, roled attachment links, source-page links, and single-manuscript outputs for press releases, annual reports, PPMO blacklist entries, and court orders.
Staged output root
ngm/index/build_index.py, tests/test_output_split.py
Derived index files now write to output_root, with cached directory creation, and tests verify read-root and write-root outputs stay separated.
SEO files
ngm/index/build_index.py, tests/test_seo_output.py
HTML landing pages, sitemap indexes, child sitemaps, and robots.txt are generated from the built tree, and tests assert the emitted URLs and pagination.
Postgres document mirror
ngm/index/db_index.py, tests/test_db_index.py
Built manuscripts are flattened into document_sources rows, upserted by document_id, and stale rows are removed by last_seen_build; tests check row mapping and HTML URL derivation.
Remote publish
ngm/index/publish.py, tests/test_publish.py
Staged keys are uploaded with content types from their extensions, managed remote prefixes are scanned, and only safe orphan objects are deleted; tests cover helper behavior and the end-to-end prune/upload flow.
Build orchestration
ngm/index/build_index.py
main() now stages remote builds locally, toggles Postgres mirroring, and only publishes when the target store URI is remote.

Sequence Diagram(s)

sequenceDiagram
  participant main
  participant IndexBuilder
  participant sync_document_sources
  participant publish
  participant S3CompatibleStore
  main->>IndexBuilder: build tree and write index + SEO files
  main->>sync_document_sources: mirror document_sources rows when enabled
  main->>publish: publish staged files for remote stores
  publish->>S3CompatibleStore: upload staged keys and delete managed orphans
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~90+ minutes

Possibly related PRs

  • Jawafdehi/ngm#31: Covers the same IndexBuilder output and Manuscript serialization paths that this PR expands.

Poem

I hop through links on moonlit tracks,
With sitemaps tucked into my packs.
My burrow hums, the pages bloom,
And mirrored rows keep tidy room.
🐇✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 42.73% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main changes: DocumentSource-shaped documents, SEO output, R2 staging/publish, and the Postgres document index.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ngm-documentsource-format

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a Postgres-backed document_sources index that mirrors the hierarchical index tree of the NGM archive, alongside an SEO-friendly surface consisting of crawlable HTML landing pages, sitemaps, and robots.txt. It updates the Manuscript model to support roled links and stable document IDs, implements a streaming publisher to sync staged files to Cloudflare R2, and adds database synchronization logic. The review feedback highlights a potential Cross-Site Scripting (XSS) vulnerability in the JSON-LD script generation, a potential KeyError when accessing the link dictionary directly, and a risk of primary key conflicts if press_id is missing from press release metadata.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread ngm/index/build_index.py Outdated
Comment thread ngm/index/build_index.py
Comment on lines +1306 to +1313
raw_link = next(
(
link["link"]
for link in ms.links
if link.get("role") == SourceLinkRole.RAW.value
),
"",
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using direct dictionary lookup link["link"] can raise a KeyError if the "link" key is missing from a legacy or malformed manuscript's links. Since link.get("link", "") is already used safely elsewhere in this method (e.g., on line 1279), we should use it here as well for consistency and safety.

        raw_link = next(\n            (\n                link.get("link", "")\n                for link in ms.links\n                if link.get("role") == SourceLinkRole.RAW.value\n            ),\n            "",\n        )

Comment thread ngm/index/build_index.py
return None

return manuscripts
press_id = metadata.get("press_id")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If press_id is missing or None in the metadata dictionary, document_id will be generated as ngm:ciaa-press-release:None. If multiple press releases have a missing press_id, they will all share the same document_id, causing primary key conflicts and overwrites in the database.\n\nFalling back to metadata_path.stem (which is the filename, e.g., "1234") ensures a unique and stable identifier even if press_id is missing from the JSON metadata.

Suggested change
press_id = metadata.get("press_id")
press_id = metadata.get("press_id") or metadata_path.stem

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@ngm/index/build_index.py`:
- Around line 508-516: The Manuscript construction in the CIAA press-release
path should reject missing or blank press_id before building document_id. In the
relevant builder that reads metadata and returns Manuscript, validate
metadata.get("press_id") is present and non-empty, and fail fast if it is not.
Keep the document_id format in place only after this check so the dedupe key and
document_sources.document_id stay unique and do not collapse bad metadata into
the same ngm:ciaa-press-release:* id.
- Around line 509-513: The Manuscript construction is mismatching the promoted
primary attachment because file_name still uses file_names[0] while url comes
from _primary_url(links). Update the logic in the Manuscript creation path so
file_name is derived from the same promoted RAW/PDF attachment selected by
_primary_url, keeping the two fields aligned; use the existing helpers around
primary_name, _primary_url, and the Manuscript initializer to make the change.
- Around line 1419-1466: Use a per-run build id for the DB mirror sweep instead
of the shared date string. In main(), generate a unique build identifier for
each execution and pass that into sync_document_sources() so last_seen_build
changes every run; keep date_str only for date-based paths like IndexBuilder.
Update the call site and any nearby logging/variables in build_index.py to
clearly distinguish the run id from the calendar date.
- Around line 1294-1342: The JSON-LD block in the HTML rendering is not escaped
before being embedded in the `<script>` tag, which can allow scraped metadata to
terminate the script early. Update the JSON serialization in the
`build_index.py` HTML template generation to use a script-safe JSON-LD string
(for example, by escaping `</` before insertion) in the same place where
`json.dumps(jsonld, ensure_ascii=False)` is currently used.

In `@ngm/index/db_index.py`:
- Around line 87-119: `sync_document_sources()` is using `build_id` as the
stale-row mirror marker, but the caller in `build_index` is passing `date_str`,
which is shared across same-day runs. Update the call site that invokes
`sync_document_sources` to pass a unique per-run marker (for example a UTC
timestamp or UUID) instead of `date_str`, and keep `date_str` only for
naming/SEO uses. Make sure the stale delete condition in `sync_document_sources`
continues to compare against that per-run marker so later reruns can remove rows
that disappeared in the new build.

In `@ngm/index/publish.py`:
- Around line 158-161: The stale-object cleanup in `publish.py` is assuming
every `client.delete_objects` call succeeds, but S3 can return per-key `Errors`
even when the HTTP response is 200. Update the delete flow in the batch loop to
capture the `delete_objects` response, inspect `Errors`, and surface or handle
any failures before continuing. Then make the final `deleted` count in the
publishing logic reflect only successfully deleted keys, using the
`delete_objects` result rather than blindly counting all items in `stale`.
- Around line 132-134: The upload scheduling in publish should not enqueue one
Future for every staged file up front. Update the ThreadPoolExecutor flow in the
publish logic around the put submission and as_completed handling so uploads are
submitted in a bounded batch/streamed queue, using the existing _UPLOAD_WORKERS
limit to cap pending futures. Keep the same failure handling and result draining
behavior, but avoid building a large futures map before any completions are
consumed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 84395501-a58f-41da-90bc-4ff4f15a1973

📥 Commits

Reviewing files that changed from the base of the PR and between 783a67f and b4e1e70.

📒 Files selected for processing (10)
  • ngm/database/models.py
  • ngm/index/build_index.py
  • ngm/index/db_index.py
  • ngm/index/models.py
  • ngm/index/publish.py
  • tests/test_db_index.py
  • tests/test_document_format.py
  • tests/test_output_split.py
  • tests/test_publish.py
  • tests/test_seo_output.py

Comment thread ngm/index/build_index.py
Comment thread ngm/index/build_index.py Outdated
Comment thread ngm/index/build_index.py Outdated
Comment thread ngm/index/build_index.py Outdated
Comment thread ngm/index/db_index.py
Comment thread ngm/index/publish.py Outdated
Comment thread ngm/index/publish.py Outdated
damo-da and others added 3 commits June 26, 2026 02:17
…efresh updated_at

- SECURITY: escape <, >, & in the JSON-LD <script> block so scraped text
  containing "</script>" can't break out of / inject into the landing page.
- SAFETY: abort the build when it produces 0 documents (a source-read failure),
  so an empty staging tree can't sync-delete every live page/sitemap or, next
  day, drop every document_sources row.
- CORRECTNESS: use a per-run timestamp (not just the date) as the DB build_id so
  a same-day rebuild still prunes documents that vanished since the earlier run.
- Refresh updated_at=now() on conflict in the document_sources upsert (and fix
  the stale comment).

Adds regression tests for the JSON-LD escaping and the empty-build abort. 159
tests passing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…DRY, bounded txns)

- press_id falls back to the metadata filename stem (files are {press_id}.json)
  so a document_id is never "ngm:ciaa-press-release:None".
- filename-derived ids (kanun-patrika, annual reports, PPMO) get a short
  deterministic hash suffix (_slug_with_hash) so distinct filenames that slugify
  identically never collide on a document_id / HTML page / DB PK.
- single source of truth for the HTML landing-page path: models.document_html_relpath,
  used by both the builder and the DB indexer (removes the duplicated helper).
- document_sources upsert commits per 1000-row batch (and the stale sweep runs in
  its own transaction) so a 1M-row sync is not one giant transaction.

Adds tests for slug-hash uniqueness and the press_id fallback. 161 tests passing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Still-valid findings from the automated reviewers on PR #88 (the JSON-LD escape
and per-run build_id were already fixed):

- build_index: raw_link extraction uses link.get("link","") (no KeyError on a
  malformed link dict).
- build_index: press_id falls back to the metadata stem when absent OR blank;
  url/file_name now both follow the promoted RAW attachment (PDF), so they no
  longer disagree for multi-attachment press releases.
- publish._upload: bounded in-flight window (~4x workers) instead of scheduling
  one Future per staged file up front (1M files -> 1M Futures).
- publish._delete: inspect delete_objects per-object "Errors" and raise, instead
  of silently leaving orphaned objects behind.

Adds tests for the delete-error path and the url/file_name RAW alignment. 162
tests passing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
damo-da and others added 2 commits June 26, 2026 03:15
…g, no writes

NGM_DRY_RUN=1 builds the full tree from the (cloud) read store to local staging
but makes NO writes back — no R2 publish, no DB sync — so a real rebuild can be
measured/inspected with read-only access to the store. The staging dir is kept
for inspection on a dry run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…5min -> ~15s)

The court-orders read phase dominated the nightly rebuild (~35 min) because the
builder enumerated ~23k files via cloudpathlib iterdir()+is_file(), and is_file()
on a cloud path costs a network round-trip each.

Replace it with a single recursive list_objects_v2 per court type (S3), or rglob
(local). The returned cloud paths are used only for .name / _build_url (pure path
math, no further I/O), so the rest of the tree-building is unchanged.

Measured on the real ngm bucket: court types 0.4s; list special (2,509) 1.8s;
list supreme (20,555) 10.6s; full supreme build (20,555 cases) 11.2s. The whole
court-orders read drops from ~35 min to ~15s (~150x). Together with local
staging (write phase), the rebuild no longer pays either the 35-min read or the
40-min per-file write.

Adds S3-branch tests with a fake boto3 client (CI has no R2). 165 tests passing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@damo-da
damo-da merged commit 8a6d4e1 into main Jun 26, 2026
4 checks passed
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant