diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 729a3b8..13b1081 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,7 @@ jobs: - run: bun run typecheck - run: bun run check:boundaries - run: bun run test + - run: bun run check:examples # What a release of this tree would put on npm: internal ranges that name # the versions built here, nothing depending on an unpublished package, # and the packed tarballs installing together and starting the CLI. The diff --git a/.github/workqueue/CLI-REVIEW-01.md b/.github/workqueue/CLI-REVIEW-01.md new file mode 100644 index 0000000..c480cea --- /dev/null +++ b/.github/workqueue/CLI-REVIEW-01.md @@ -0,0 +1,63 @@ +--- +id: CLI-REVIEW-01 +status: done +priority: high +dependencies: [] +packages: ["evidence-graph", "ingestion", "persistence", "cli"] +layer: L1 +effort: large +--- + +# Saved source review + +## What + +Make repeated review of known MediaWiki sources a complete CLI workflow: observe +since the last acknowledged revision, inspect full text and provenance, then +acknowledge the saved report without fetching newer edits. + +## Why + +A revision stream alone leaves consumers to maintain review baselines, recover +interrupted runs, add source context, and distinguish empty output from missing +history. Passage status must describe the latest observed revision. + +## Implementation + +- Atomic JSON state and reports with separate reviewed and fetched cursors. +- Watchlists, source-scoped entry identities, resumable pending inputs, and stale-report checks. +- Markdown/JSON reports, mechanical view filters, full text, source links, and offline sample. +- Parent and content-availability metadata, bounded cache selection, and captured explorer inputs. +- Shared passage status implementation and matching uncertainty in CLI/MCP. +- Documentation and a real-user fit assessment protocol. + +## Invariants + +- Deterministic observation; interpretation stays with the consumer. +- Fetching never acknowledges revisions. +- No text comparisons across known missing parents or unavailable text. +- Native event identity is preserved; review entry identity includes the source. +- Incomplete reports cannot advance a reviewed cursor. + +## Acceptance + +- [x] Review, resume, filtering, acknowledgment, and subsequent edits pass offline and compiled CLI checks. +- [x] Latest passage absence and uncertainty pass regression checks through CLI/MCP. +- [x] Explorer preserves captured ranges and exposes coverage. +- [x] Captured examples regenerate identically. +- [x] Core build, Biome, typecheck, boundary, tests, and packed-consumer gates pass. + +Real-user demand and time savings are not established by these checks. The guide +provides a repeat-review protocol for assessing fit with actual users. + +## Validation + +- Core build, Biome, typecheck, architecture boundary, and packed-consumer gates passed. +- 148 suites passed with 1,109 passing tests; live suites are skipped in the default gate. +- Separate live MediaWiki contract run passed 9 tests. +- Compiled CLI watchlist tests cover filtering, acknowledgment without fetching, and later edits. +- A Node consumer installed from six local package tarballs completed the offline review, acknowledgment, and quiet next review. +- SQLite migration was checked against an actual database with the prior schema. +- All three example capture scripts passed byte-comparison checks; 99 relative documentation links and anchors passed. + +The source changes have not been published. Real-user fit remains unmeasured. diff --git a/CHANGELOG.md b/CHANGELOG.md index b588970..742e5cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ ## Unreleased +### Added + +- `refract review`: source watchlists, saved fetched/reviewed revision cursors, resumable history fetching, and explicit acknowledgment of an inspected report. +- Markdown and JSON review reports with source identity, changed text, revision links, mechanical filters, saved inputs, and coverage. `review --sample` demonstrates the complete workflow offline. +- Optional revision parent and source-text availability metadata. Coverage reports missing inputs and avoids text comparisons across known gaps. + +### Fixed + +- Latest passage disappearance now reports absence from the latest fetched revision. Partial matches expose inspectable candidate text and uncertainty; unavailable latest text returns `unknown` through the CLI and MCP. +- Bounded analysis no longer mixes unrelated cached revisions into the selected range. Interactive analysis serves its captured inputs; the explorer accepts explicit bounds and shows coverage. + +## Unreleased + ### Fixed - **`refract verify --html` marked every event's proof "Merkle Root Match"**, including in a bundle that had failed verification. Each row now shows the verifier's result for that event's hash and for its proof, the page lists the checks that failed, and it states what `verify` checks and what it does not. The receipt and the command's output no longer say "cryptographically verified" or "integrity verified": nothing in a bundle is signed. Revision IDs read from the bundle are HTML-escaped like the other fields. - **`refract mcp` answered notifications with "Method not found"**. A message with no `id`, such as the `notifications/initialized` every MCP client sends after `initialize`, got a -32601 error reply, which JSON-RPC 2.0 forbids. Both `refract mcp` and `@refract-org/mcp` now ignore notifications, including unknown ones, without writing anything back. An unknown request, one that has an `id`, still gets -32601; that now includes a request named `initialized`, which used to get no reply at all. diff --git a/README.md b/README.md index 8bc35cd..62cd394 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ **Refract gives mutable knowledge a memory.** -Temporal observability for knowledge: structured, deterministic observations of changes across available revision histories. +See what changed in the sources you rely on since your last review, with text and revision links you can inspect. [![CI](https://github.com/refract-org/refract/actions/workflows/ci.yml/badge.svg)](https://github.com/refract-org/refract/actions/workflows/ci.yml) [![Version](https://img.shields.io/github/v/release/refract-org/refract)](https://github.com/refract-org/refract/releases) @@ -14,83 +14,147 @@ Temporal observability for knowledge: structured, deterministic observations of ---- - ## What does it do? -Given a Wikipedia article or compatible MediaWiki instance, Refract analyzes revisions and emits a deterministic event stream showing **what changed, when, and how**: changes to sentences, citations, sections, and document structure. +Refract compares revisions and produces events describing detected changes to wording, citations, and document structure. It provides temporal observability for knowledge: a history your tools can query alongside the current text. + +Two observations captured from the Bitcoin article: + +| Date | Before | After | Inspect the source | +|------|--------|-------|--------------------| +| 2010-03-18 | “The longest proof-of-work chain…” | “The longest proof-of-work block chain…” | [Prior revision](https://en.wikipedia.org/w/index.php?oldid=345930425), [next revision](https://en.wikipedia.org/w/index.php?oldid=350653695) | +| 2010-06-21 | A reference to the Bitcoin FAQ | Reference removed | [Prior revision](https://en.wikipedia.org/w/index.php?oldid=369361755), [next revision](https://en.wikipedia.org/w/index.php?oldid=369393212) | + +The wording above is excerpted; the [captured events](./docs/examples/bitcoin-events.ndjson) contain the full matched text and removed reference. The observations describe these revision pairs. Their significance is for the reader or downstream application to assess. + +[Reproduce the Bitcoin example](./docs/example-output.md) · [Inspect saved inputs](./docs/examples/bitcoin-revisions.json) + +## Try an offline example + +The bundled **fictional** Rivergate Library history demonstrates changed opening hours, a removed reference, and text that disappears and returns. The events are computed by the same analyzers used for wiki revisions. + +![Three observations from the fictional Rivergate Library sample: changed wording, removed reference, and returned text](./docs/examples/onboarding.svg) + +[Read the captured walkthrough](./docs/examples/onboarding.txt) · [Download sample NDJSON](./docs/examples/sample-events.ndjson) · [Follow the offline recipe](./docs/recipes.md#try-the-offline-walkthrough) + +To run the offline walkthrough from this checkout: ```bash -npx @refract-org/cli analyze "Semaglutide" --depth brief +git clone https://github.com/refract-org/refract.git +cd refract +bun install --frozen-lockfile +bun run build +node packages/cli/dist/src/cli.js init ``` -```txt -Analyzing "Semaglutide" at depth: brief... -Fetching revisions from Wikipedia... -Fetched 20 revisions. - -──────────────────── - Analysis Results -──────────────────── - Page: Semaglutide - Events: 203 - - ○ section changed (rev 714793761→714794084) [(lead)] - ● sentence introduced (rev 714794084→740186843) [(lead)] - ● sentence removed (rev 714794084→740186843) [(lead)] - ◆ citation added (rev 740186843→781025475) [body] -``` +The new offline `init` and `init --json` behavior requires this source build until it is included in an npm release. You can inspect the captured artifacts above without installing anything. -No LLM is called in the observation pipeline. The same captured inputs, analyzer versions, and configuration produce byte-reproducible event streams. A [hash-pinned ground-truth corpus](BENCHMARK.md#ground-truth-corpus) of 15,926 events across ten benchmark pages ships as a [release asset](https://github.com/refract-org/refract/releases/tag/benchmark-corpus-2026-08-27). +## Review sources you already depend on ---- +`refract review` keeps source changes pending until you acknowledge an inspected +report. It saves fetched revisions, resumes longer histories, and shows the +range it could observe. Each entry contains changed text and source context. -## Quick Start +From the source build above, try the complete workflow offline: -### Prerequisites +```bash +node packages/cli/dist/src/cli.js review --sample --from 1 --state-dir ./review-state +``` -- Node.js 20+ or Bun 1.3+ -- A Wikipedia or MediaWiki article title (pass `--api ` for non-English/private wikis) +Inspect the report, then use its printed acknowledgment command. Run it again +to see `no_changes`. The sample is fictional and makes no wiki requests. The new +review command requires this source build until it ships in an npm release. -### 1. One-shot Analysis +For live sources, create a page-title watchlist and establish an inspected +baseline with `review --pages-file pages.txt`. Subsequent runs compare against +the last acknowledged revision. Your application decides which changes matter. -```bash -# Run directly via npx -npx @refract-org/cli analyze "Artificial intelligence" --depth brief +[Complete review workflow](./docs/review.md) · [Captured review](./docs/examples/review.md) · [Report JSON with saved inputs](./docs/examples/review.json) + +## Quick start -# Or install globally +For live Wikipedia analysis, install the CLI with Node.js 20+: + +```bash npm install -g @refract-org/cli -refract analyze "Quantum computing" --depth forensic +refract analyze "Bitcoin" --depth brief --json ``` -### 2. Python SDK +This reads the latest 20 revisions on an uncached run and prints metadata as NDJSON. It requires network access. `brief` omits changed text; use `detailed` with a bounded revision range when you want wording: ```bash -pip install git+https://github.com/refract-org/refract-py.git +refract analyze "Bitcoin" --from 345930425 --to 350653695 --depth detailed --json ``` -```python -from refract import Refract +For a browser timeline: -r = Refract() -events = r.analyze("Bitcoin", depth="brief") -for event in events: - print(event.eventType, event.timestamp) +```bash +refract explore "Bitcoin" --from 345930425 --to 350653695 ``` -### 3. Model Context Protocol (MCP) +This fetches the specified pair, starts a local server at `localhost:8899`, and opens your browser. Keep the terminal running; Ctrl+C stops the server. Large histories can take longer to fetch and analyze. -Run the bundled MCP server for Cursor, Claude Desktop, or Antigravity: +Use a **page title**, such as `Bitcoin`, rather than a page URL. For another MediaWiki instance, pass its `api.php` endpoint with `--api`. -```bash -refract mcp -``` +[Choose a history range and output mode](./docs/usage.md) · [Install compatibility](./COMPATIBILITY.md) + +## Three workflows + +| Your question | What you receive | Start here | +|---------------|------------------|------------| +| “What changed in my sources since my last review?” | Pending changes with full text, source links, coverage, and explicit acknowledgment | [Review a watchlist](./docs/review.md) | +| “What did the record say at an earlier date?” | A text preview and revision ID at or before the requested date | [Inspect an earlier record](./docs/recipes.md#inspect-an-earlier-record) | +| “My application needs changes as data.” | NDJSON events to filter, store, or pass to your review process | [Build a review feed](./docs/recipes.md#build-a-review-feed) | + +Start with a small set of sources you already revisit. The [review guide](./docs/review.md#check-fit-with-a-real-review-process) provides a way to assess the workflow with your own review criteria. For a data integration, export events or consume the saved review report, select `citation_removed`, and inspect its source context. + +## Core concepts + +The central primitive is an **observed transition, anchored to source revisions**: -See [docs/mcp.md](./docs/mcp.md) for client configuration snippets. +`available revision history → deterministic observations → queryable event history` ---- +The same captured inputs, analyzer versions, and configuration produce the same event observations. Coverage is bounded by the analyzed history and matching rules; export timestamps can differ between runs. -## Monorepo Architecture +Claim IDs describe text in page and section context. Matching rules associate wording across revisions. State labels and hashes have specific limits: see [claim-state timelines](./docs/concepts/claim-state-timelines.md) and [replay and verification](./ARCHITECTURE.md#replay-and-verification). + +## What Refract captures + +The event schema defines changes to: + +| Part of the record | Examples | +|--------------------|----------| +| Sentences | First observed, modified, removed, reintroduced | +| Citations | Added, removed, replaced | +| Document structure | Section changes, lead/body changes, page moves | +| Links, categories, templates | Added, removed, template parameters changed | +| Revision activity | Revert patterns and edit clusters | +| Talk pages and access controls | Nearby talk-page activity and protection changes | + +Events can carry deterministic enrichment fields such as change magnitude, extracted terms, and certainty-marker counts. [Event reference](./docs/events.md) explains the types and rules. + +## Where it fits + +The CLI fetches **MediaWiki revision histories**, including Wikipedia and compatible public or user-controlled wikis. Library consumers can supply versioned text through the common `Revision` model. An arbitrary web URL alone does not provide a revision history. + +Refract derives observations under deterministic rules. Your application decides which changes matter and what to do with them. It does not establish that a statement is supported merely because it has a citation, or that editors disputed it merely because its wording changed. + +[Repository boundary](./docs/repository-boundary.md) · [Architecture](./ARCHITECTURE.md) · [Refract and NextConsensus](./docs/refract-and-nextconsensus.md) + +## Use it in your application + +Choose the interface that matches your existing workflow: + +| Interface | Use it when | Example | +|-----------|-------------|---------| +| CLI | You want to inspect or export a page's history | `refract export "Bitcoin" --from 369361755 --to 369393212 --format ndjson` | +| Libraries | You already fetch revisions or own versioned text inputs | `buildRevisionEvents(revisions, { depth: "detailed" })` | +| MCP | An agent needs to request histories and receive structured observations | `refract mcp` | +| Python | You work in Python and want events as dataclasses or DataFrames | `pip install git+https://github.com/refract-org/refract-py.git` | + +[Library adapter and data integrations](./docs/integrations.md) · [MCP setup](./docs/mcp.md) · [Private wiki access](./docs/usage.md#other-mediawiki-instances) + +## Monorepo architecture ``` refract/ @@ -105,66 +169,76 @@ refract/ └── docs/ # Architecture specs, recipes & boundary definitions ``` ---- - -## Development & Testing - -Build and test from source: +## Development and testing ```bash git clone https://github.com/refract-org/refract.git cd refract bun install bun run build -bun test +bun run test bun run lint ``` -Run CLI directly from source: - -```bash -node packages/cli/dist/src/cli.js analyze "Machine learning" --depth brief -``` - ---- - ## Packages | Package | Role | -| :--- | :--- | +|---------|------| | `@refract-org/evidence-graph` | Types, schemas, identity and verification primitives | | `@refract-org/ingestion` | MediaWiki access, pagination, retries, and authentication | | `@refract-org/analyzers` | Deterministic revision comparisons and enrichment | | `@refract-org/cli` | Commands and the local browser explorer | -| `@refract-org/mcp` | MCP library; executable is `refract mcp` from CLI | +| `@refract-org/mcp` | MCP library; the executable is `refract mcp` from the CLI | | `@refract-org/persistence` | Local SQLite persistence; not published | | `@refract-org/eval` | Evaluation harness and independently sourced labels | ---- +[Runnable examples](./examples/README.md) · [Benchmark corpus](./BENCHMARK.md) + +## License -## Position in the Revisable Delegation Loop +AGPL-3.0. See [LICENSE](./LICENSE). -Refract holds the **detect mismatch** step in the [STD-07 Revisable Delegation Record](https://ethotechnics.org/standards/std-07-revisable-delegation-record) loop. It identifies what changed without deciding whether a change matters: downstream consumers supply the predicate and domain interpretation. +If you modify this software and deploy it as a network service, you must release +your modifications. -- **Wired to**: [NextConsensus](https://nextconsensus.com) and [Ambit](https://github.com/zz-plant/ambit) via [`docs/refract-and-nextconsensus.md`](./docs/refract-and-nextconsensus.md) and [`docs/repository-boundary.md`](./docs/repository-boundary.md). -- **Siblings**: [Ambit](https://github.com/zz-plant/ambit) (capability and authorization), [Whether](https://github.com/zz-plant/whether) (act), [Ethotechnics](https://ethotechnics.org) (record shape and vocabulary). +**Commercial use:** NextConsensus offers commercial licenses for proprietary +integration without AGPL obligations. See [nextconsensus.com](https://nextconsensus.com). ---- +## Community -## Documentation & Recipes +- [Contributing](./CONTRIBUTING.md) — how to get started +- [Good first tasks](./ROADMAP.md) — ready-to-pick-up work items +- [Discussions](https://github.com/refract-org/refract/discussions) — questions, ideas +- [Code of Conduct](./.github/CODE_OF_CONDUCT.md) +- [Security](./.github/SECURITY.md) +- [Changelog](./CHANGELOG.md) +- [Cite this software](./CITATION.cff) -- [docs/README.md](./docs/README.md): Documentation hub -- [docs/recipes.md](./docs/recipes.md): Step-by-step recipes (inspecting historical records, building review feeds) -- [docs/events.md](./docs/events.md): Schema specifications for the 26 event types -- [docs/repository-boundary.md](./docs/repository-boundary.md): System boundaries and non-goals -- [ARCHITECTURE.md](./ARCHITECTURE.md): Verification scope, hashing, and Merkle proofs +## Ecosystem ---- +These repos extend the core engine: -## License +| Repo | Purpose | +|------|---------| +| [refract-docs](https://github.com/refract-org/refract-docs) | Public documentation site — [integrations](https://refract-org.github.io/refract-docs/integrations/), quickstart, CLI, SDK, tutorials | +| [refract-ui](https://github.com/refract-org/refract-ui) | Standalone visualization — load JSONL, render timelines, diffs, citations | +| [Demo data](./demo-data) | Local datasets; the [offline walkthrough](./docs/recipes.md#try-the-offline-walkthrough) uses a fictional library history | +| [refract-py](https://github.com/refract-org/refract-py) | Python adapter — calls the CLI and returns dataclasses or DataFrames | + +The core owns event contracts, CLI behavior, and reproducible examples. The other +repos publish guides or consume those interfaces. See the [repository map and +consolidation proposal](./docs/repositories.md) for ownership and migration steps. -**AGPL-3.0**. See [LICENSE](./LICENSE). +### Position in the revisable-delegation loop + +Refract holds the **detect mismatch** step in the [STD-07 Revisable Delegation Record](https://ethotechnics.org/standards/std-07-revisable-delegation-record) loop, emitting structured `EvidenceEvent` records over source revisions. It identifies what changed without deciding whether a change matters: downstream consumers supply the predicate and the context. + +- **Wired to**: [NextConsensus](https://nextconsensus.com) and [Ambit](https://github.com/zz-plant/ambit) via [`docs/refract-and-nextconsensus.md`](./docs/refract-and-nextconsensus.md) and [`docs/repository-boundary.md`](./docs/repository-boundary.md). +- **Siblings**: [Ambit](https://github.com/zz-plant/ambit) (capability and authorization), [Whether](https://github.com/zz-plant/whether) (act), [Ethotechnics](https://ethotechnics.org) (the record shape and vocabulary). -If you modify this software and deploy it as a network service, you must release your modifications under AGPL-3.0. +### Related projects -**Commercial use:** NextConsensus offers commercial licenses for proprietary integration without AGPL obligations. See [nextconsensus.com](https://nextconsensus.com). +- [**Stims**](https://github.com/zz-plant/stims) — Browser music visualizer inspired by MilkDrop +- [**sabnzbd-mcp**](https://github.com/zz-plant/sabnzbd-mcp) — MCP server for SABnzbd (zero deps) +- [**neckass**](https://github.com/zz-plant/neckass) — Privacy-first headline generator +- [**ethotechnics.org**](https://github.com/zz-plant/ethotechnics.org) — Open framework for the operational accountability of high-stakes AI systems; publishes the record shape Refract's siblings share (see below) diff --git a/ROADMAP.md b/ROADMAP.md index a4663eb..d9ce7b6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,19 @@ # Roadmap -Refract v0.2.1 — all 20 Phase 1 workqueue items shipped. Maintenance mode: bug fixes and polish. +Core revision analyzers are available. Current product work focuses on repeatable source review with inspectable observations. + +## Source review workflow + +Implemented in the source checkout; awaiting release: + +- `review`: watchlists, separate fetched/reviewed cursors, resumable inputs, and acknowledgment of saved reports. +- Markdown and JSON reports with full changed text, source links, filters, and coverage. +- Shared captured analysis in the explorer and consistent bounded cache selection. +- Latest-revision passage status, candidate text, and explicit matching uncertainty through CLI and MCP. + +[Workflow and fit assessment](./docs/review.md) · [Acceptance record](./.github/workqueue/CLI-REVIEW-01.md) + +Real-user fit remains to be assessed with repeated reviews of sources people already depend on. The offline fixture and automated checks demonstrate implementation behavior. ## Agent Workstream Mapping @@ -9,7 +22,7 @@ Refract v0.2.1 — all 20 Phase 1 workqueue items shipped. Maintenance mode: bug | `evidence-graph` | Agent F | Event schema, 29 event types, deterministic hashing, replay manifests | | `ingestion` | Agent A | MediaWiki REST client, rate limiter, XML dumps, Wikidata entity mapping | | `analyzers` | Agents B/C/D/E | Section differ, citation tracker, revert detector, template tracker, claim differ, wikilink/category extractors, talk correlator, talk thread parser, edit cluster detector, talk activity spike detector, protection tracker, page move detector, heuristic classifier, observation differ | -| `cli` | Agent G | 10 commands: analyze, claim, cron, diff, eval, explore, export, mcp, visualize, watch | +| `cli` | Agent G | Commands for source review, analysis, comparison, export, and agent access | | `persistence` | Agent G | SQLite storage (bun:sqlite) | | `eval` | — | Evaluation harness: L3 ground truth validation (public) | | `observable` | — | Observable Framework data loader (internal use) | diff --git a/docs/README.md b/docs/README.md index ecd5180..8c25e0f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,12 +1,15 @@ # Refract Documentation -Refract gives mutable knowledge a memory: temporal observability for knowledge -through structured observations of available revision histories. +Refract gives mutable knowledge a memory. See how Wikipedia text and citations +changed since your last review, with text and revision links to inspect. ## Getting Started +- [Review your sources](./review.md) — watchlists, saved baselines, coverage, and explicit acknowledgment + - [Quick Start](../README.md#quick-start) — first commands to try -- [init command](../README.md) — guided onboarding +- [Offline walkthrough](./recipes.md#try-the-offline-walkthrough) — changed wording, a removed reference, and returned text +- [History range and output guide](./usage.md) — what gets fetched and where results appear - [Core Concepts](../README.md#core-concepts) — observed transitions, identity, and replay - [Claim-State Timelines](./concepts/claim-state-timelines.md) — observed transitions, identity, matching, and coverage limits @@ -20,8 +23,9 @@ through structured observations of available revision histories. ## Guides -- [Example Output](./example-output.md) — what a full analysis looks like +- [Bitcoin walkthrough](./example-output.md) — two captured observations with source revision links - [Recipes](./recipes.md) — common workflows and patterns +- [Data and library integrations](./integrations.md) — adapters, event queries, and review feeds - [MCP Server](./mcp.md) — using Refract as an MCP tool provider - [Security](./security.md) — reporting vulnerabilities @@ -29,4 +33,5 @@ through structured observations of available revision histories. - [Contributing](../CONTRIBUTING.md) — how to get started - [Roadmap](../ROADMAP.md) — planned work and priorities +- [Repository map and consolidation proposal](./repositories.md) — ownership, shared contracts, and staged migration - [Refract vs. NextConsensus](./refract-and-nextconsensus.md) — how the two relate diff --git a/docs/example-output.md b/docs/example-output.md index 129be55..b9ace08 100644 --- a/docs/example-output.md +++ b/docs/example-output.md @@ -1,72 +1,80 @@ -# Example: Analyzing "Bitcoin" +# Walkthrough: wording and reference changes -This page shows the output of `refract analyze "Bitcoin" --depth brief` against -Wikipedia's live API (revision range: 2009–2010, 20 revisions). This is what Refract -produces: observations of changes detected within the captured revision range. +This example uses two real, adjacent revision pairs from Wikipedia's Bitcoin +article. Refract detects a wording change in one pair and a removed reference +in the other. The pairs are analyzed separately; no event is inferred across +the intervening months. -> **Note:** The full output file linked below was generated with Refract v0.3.x and -> uses legacy event type names (e.g., `claim_reworded` instead of `sentence_modified`, -> `section_changed` instead of `section_reorganized`). The inline examples below use -> the current event type names. This is an archived example, not a promise that -> current analyzer versions reproduce the legacy event counts or classifications. +## What you can inspect -Full CLI output: [bitcoin-quick-analysis.txt](./bitcoin-quick-analysis.txt) (330 events). - -## Sample Event Timeline +```text +2010-03-18T20:03:27Z sentence_modified + Revisions: 345930425 → 350653695 + Before (excerpt): The longest proof-of-work chain… + After (excerpt): The longest proof-of-work block chain… +2010-06-21T17:29:07Z citation_removed + Revisions: 369361755 → 369393212 + Removed: [http://www.bitcoin.org/faq Bitcoin FAQ] ``` -Analysis of "Bitcoin" at depth brief found 330 events across 20 revisions. -[2009-03-08T16:41:44Z] wikilink_added (rev 275832581→275832690) - Section: body - • target: cryptography +The sentence excerpts omit the unchanged remainder. The +[captured events](./examples/bitcoin-events.ndjson) include the complete matched +text, removed reference, timestamps, and revision IDs. They were computed from +[saved inputs](./examples/bitcoin-revisions.json) with the current checkout's +analyzers and event schema. The capture script also derives event IDs. -[2009-03-08T16:41:44Z] section_reorganized (rev 275832581→275832690) - Section: (lead) - • change: modified +| Observation | Prior revision | Next revision | +|-------------|----------------|---------------| +| Wording changed | [345930425](https://en.wikipedia.org/w/index.php?oldid=345930425) | [350653695](https://en.wikipedia.org/w/index.php?oldid=350653695) | +| FAQ reference removed | [369361755](https://en.wikipedia.org/w/index.php?oldid=369361755) | [369393212](https://en.wikipedia.org/w/index.php?oldid=369393212) | -[2009-03-08T16:41:44Z] sentence_modified (rev 275832581→275832690) - Section: (lead) - • old_length=28 new_length=166 +The article content is attributed to Wikipedia's Bitcoin contributors; the +revision links identify each captured version. A reference disappearing does +not establish why it was removed or whether it supported the passage. -[2009-08-05T23:50:52Z] wikilink_added (rev 275850009→306304462) - Section: body - • target: proof-of-work +## Reproduce with the CLI -[2009-08-05T23:50:52Z] wikilink_added (rev 275850009→306304462) - Section: body - • target: hashcash +These commands require network access and print NDJSON: -[2009-08-05T23:50:52Z] section_reorganized (rev 275850009→306304462) - Section: Proof-of-work - • change: added +```bash +refract analyze "Bitcoin" --from 345930425 --to 350653695 --depth detailed --json > wording-events.ndjson +refract export "Bitcoin" --from 369361755 --to 369393212 --format ndjson > reference-events.ndjson +``` -[2009-12-10T14:15:09Z] citation_added (rev 308164432→308164529) - Section: (lead) - • ref: href=http://sourceforge.net/projects/bitcoin/ +Each command can emit additional events for the pair. Select the observations +shown above by their type and text: -[2009-12-12T00:18:49Z] template_added (rev 308164529→308180771) - Section: body - • template: primarysources type=added +```bash +jq -c 'select(.eventType == "sentence_modified" and (.before | startswith("The longest proof-of-work chain")))' wording-events.ndjson +jq -c 'select(.eventType == "citation_removed" and (.before | contains("Bitcoin FAQ")))' reference-events.ndjson ``` -## Key Observations +## Reproduce without the wiki + +From a source checkout with dependencies installed and packages built: + +```bash +bun scripts/capture-bitcoin-example.ts --check +``` + +This reruns the analyzers against the saved revision pairs and checks the two +captured events. It makes no network calls. To regenerate the output, omit +`--check`. To fetch those pinned pairs again, add `--refresh`. + +The [capture script](../scripts/capture-bitcoin-example.ts) specifies the pairs, +selection rules, and event-ID derivation. Preserved article inputs make this +example reproducible even if current page content or supplemental metadata +changes. -- **Sentence lifecycle:** Lead section claims were reworded steadily across the first - 5 revisions — the page's elevator pitch evolved along with the project -- **Wikilinks added:** `cryptography`, `proof-of-work`, `hashcash` — the article - linked into Wikipedia's broader topic network as it matured -- **Citation added:** A reference to `sourceforge.net/projects/bitcoin/` - was detected in Dec 2009 within the analyzed range -- **Template changes:** Stub templates were replaced with more specific ones - (`bank-stub` → `web-software-stub`) as the article grew +## Start with a fictional sample -## What This Shows +For a walkthrough that also includes text disappearing and returning, use +[the offline recipe](./recipes.md#try-the-offline-walkthrough). Its inputs and +reference URL are fictional and marked as such in the exported events. -The event observations are **deterministic**: the same captured inputs, analyzer -versions, and configuration produce the same observations. The revision range -bounds what was observed; it does not establish the page's complete history. -Export timestamps can differ between runs. No model is called by the observation -pipeline; downstream systems interpret the observations. +## Archived output -> **v0.5.0+**: Events now include 6 enrichment fields (`editMagnitude`, `contentChange`, `keyTerms`, `certaintyProfile`, `directionSignal`, `quantitativeFindings`). These are computed deterministically during the analyze pipeline. Example output below shows the base event format; enrichment fields are present on all events when using v0.5.0+. +[bitcoin-quick-analysis.txt](./bitcoin-quick-analysis.txt) preserves a historical +v0.3.x run with 330 events across 20 revisions. It uses legacy event names and +classification behavior. It is not the expected output of the commands above. diff --git a/docs/examples/bitcoin-events.ndjson b/docs/examples/bitcoin-events.ndjson new file mode 100644 index 0000000..a357a41 --- /dev/null +++ b/docs/examples/bitcoin-events.ndjson @@ -0,0 +1,2 @@ +{"eventType":"sentence_modified","fromRevisionId":345930425,"toRevisionId":350653695,"section":"Proof-of-work","before":"The longest proof-of-work chain is acknowledged to be the oldest and most reliable account of the online transactions","after":"The longest proof-of-work block chain is acknowledged to be the oldest and most reliable account of the online transactions","deterministicFacts":[{"fact":"sentence_modified","detail":"sentence_length=123"}],"layer":"observed","timestamp":"2010-03-18T20:03:27Z","schemaVersion":"0.5.0","editMagnitude":"moderate","contentChange":"refinement","keyTerms":["longest","acknowledged","oldest","reliable","account","online","transactions"],"certaintyProfile":{"high":0,"medium":0,"low":0,"hedging":0},"directionSignal":"neutral","quantitativeFindings":[],"eventId":"a8ace51c6f1e015e"} +{"eventType":"citation_removed","fromRevisionId":369361755,"toRevisionId":369393212,"section":"body","before":"[http://www.bitcoin.org/faq Bitcoin FAQ]","after":"","deterministicFacts":[{"fact":"citation_changed","detail":"type=removed"}],"layer":"observed","timestamp":"2010-06-21T17:29:07Z","schemaVersion":"0.5.0","editMagnitude":"minor","contentChange":"compression","keyTerms":["bitcoin"],"certaintyProfile":{"high":0,"medium":0,"low":0,"hedging":0},"directionSignal":"neutral","quantitativeFindings":[],"eventId":"491cfdbed29f1407"} diff --git a/docs/examples/bitcoin-revisions.json b/docs/examples/bitcoin-revisions.json new file mode 100644 index 0000000..3f17f79 --- /dev/null +++ b/docs/examples/bitcoin-revisions.json @@ -0,0 +1,54 @@ +[ + { + "from": 345930425, + "to": 350653695, + "revisions": [ + { + "revId": 345930425, + "pageId": 28249265, + "pageTitle": "Bitcoin", + "timestamp": "2010-02-23T19:20:59Z", + "comment": "Fixing [[Wikipedia:Disambiguation pages with links|links to disambiguation pages]] using [[Project:AWB|AWB]]", + "content": "{{orphan|date=February 2010}}\n\n'''Bitcoin''' is an open source [[peer-to-peer]] electronic cash system developed by Satoshi Nakamoto that's completely decentralized, with no central server or trusted parties. Users hold the [[cryptography|crypto]] keys to their own money and transact directly with each other, with the help of the network to check for double-spending.\n\n==Proof-of-work==\n\nTo preserve the integrity of BitCoin, users must not be allowed to double-spend their tokens. To prevent this, all transactions are broadcasted over the network.\n\nTo keep this information reliable, it is protected against errors caused by failures of communication or deliberate sabotage. To resolve possible conflicts in the record, it is necessary to determine what data was produced first, and what data was produced later.\n\nBitCoin solves this problem (without a trusted timestamp authority) by using a [[proof-of-work system]], similar to that used by [[HashCash]]. When data is received through a node, the node performs a proof-of-work and adds it to the data. Other nodes receive the data, perform a proof-of-work, and add it to the data, ad infinitum. The longest proof-of-work chain is acknowledged to be the oldest and most reliable account of the online transactions.\n\nThis mechanism is claimed to be virtually tamper-proof. For an attacker to manipulate the record, he must outpace all of the other nodes on the network to produce the longest proof-of-work. This becomes exponentially more difficult as time passes. \n\nBitCoin is a completely peer-to-peer network, and every node is able to enter or leave the network at will. When a node joins the network, the longest proof-of-work is automatically accepted as the most reliable one.\n\n==See also==\n*[[Anonymous internet banking]]\n*[[eCache]]\n*[[Pecunix]]\n*[[Ripple monetary system]]\n*[[Yodelbank]]\n\n==External links==\n*[http://www.bitcoin.org/ Official website]\n*[http://newlibertystandard.wetpaint.com/ A Bitcoin Currency Exchange]\n\n\n{{bank-stub}}\n{{web-software-stub}}", + "size": 2032, + "minor": true + }, + { + "revId": 350653695, + "pageId": 28249265, + "pageTitle": "Bitcoin", + "timestamp": "2010-03-18T20:03:27Z", + "comment": "Fleshed out and clarified technical description", + "content": "{{orphan|date=February 2010}}\n\n'''Bitcoin''' is an open source [[peer-to-peer]] electronic cash system developed by Satoshi Nakamoto that's completely decentralized, with no central server or trusted parties. Users hold the [[cryptography|crypto]] keys to their own money and transact directly with each other, with the help of the network to check for double-spending.\n\n==Proof-of-work==\n\nTo preserve the integrity of BitCoin, users must not be allowed to double-spend their tokens. To prevent this, all transactions are broadcasted over the network.\n\nTo keep this information reliable, it is protected against errors caused by failures of communication or deliberate sabotage. To resolve possible conflicts in the record, it is necessary to determine what data was produced first, and what data was produced later.\n\nBitCoin solves this problem (without a trusted timestamp authority) by using a [[proof-of-work system]], similar to that used by [[HashCash]]. Whenever a BitCoin user makes a transaction, their node broadcasts the transaction to the network of nodes. When transaction data is received through a node, the node begins a proof-of-work calculation in an attempt to create a block containing the transaction. All nodes essentially race to create a block, as the first one to create a block gets BitCoins as a reward. Once a node successfully creates a block, it broadcasts the block to the network. Other nodes receive the block, perform a proof-of-work check, and add it to their chain if it is valid. As more transactions occur, blocks are created and added ad infinitum. The longest proof-of-work block chain is acknowledged to be the oldest and most reliable account of the online transactions.\n\nThis mechanism is claimed to be virtually tamper-proof. For an attacker to manipulate the record, he must outpace all of the other nodes on the network to produce the longest proof-of-work. This becomes exponentially more difficult as time passes. \n\nBitCoin is a completely peer-to-peer network, and every node is able to enter or leave the network at will. When a node joins the network, the longest proof-of-work is automatically accepted as the most reliable one.\n\n==See also==\n*[[Anonymous internet banking]]\n*[[eCache]]\n*[[Pecunix]]\n*[[Ripple monetary system]]\n*[[Yodelbank]]\n\n==External links==\n*[http://www.bitcoin.org/ Official website]\n*[http://www.bitcoin.org/sites/default/files/bitcoin.pdf BitCoin Whitepaper]\n*[http://newlibertystandard.wetpaint.com/ A Bitcoin Currency Exchange]\n\n\n{{bank-stub}}\n{{web-software-stub}}", + "size": 2550, + "minor": false + } + ] + }, + { + "from": 369361755, + "to": 369393212, + "revisions": [ + { + "revId": 369361755, + "pageId": 28249265, + "pageTitle": "Bitcoin", + "timestamp": "2010-06-21T14:13:25Z", + "comment": "", + "content": "'''Bitcoin''' is an open source [[peer-to-peer]] electronic cash system developed by Satoshi Nakamoto that's completely decentralized, with no central server or trusted parties. Users hold the [[cryptography|crypto]] keys to their own money and transact directly with each other, with the help of the network to check for double-spending.\n\n==Proof-of-work==\n\nTo preserve the integrity of Bitcoin, users must not be allowed to double-spend their tokens. To prevent this, all transactions are broadcasted over the network.\n\nTo keep this information reliable, it is protected against errors caused by failures of communication or deliberate sabotage. To resolve possible conflicts in the record, it is necessary to determine what data was produced first, and what data was produced later.\n\nBitcoin solves this problem (without a trusted timestamp authority) by using a [[proof-of-work system]], similar to that used by [[HashCash]]. Whenever a Bitcoin user makes a transaction, their node broadcasts the transaction to the network of nodes. When transaction data is received through a node, the node begins a proof-of-work calculation in an attempt to create a block containing the transaction. All nodes essentially race to create a block, as the first one to create a block gets Bitcoins as a reward. Once a node successfully creates a block, it broadcasts the block to the network. Other nodes receive the block, perform a proof-of-work check, and add it to their chain if it is valid. As more transactions occur, blocks are created and added ad infinitum. The longest proof-of-work block chain is acknowledged to be the oldest and most reliable account of the online transactions.\n\nThis mechanism is claimed to be virtually tamper-proof. For an attacker to manipulate the record, he must outpace all of the other nodes on the network to produce the longest proof-of-work. This becomes exponentially more difficult as time passes. \n\nBitcoin is a completely peer-to-peer network, and every node is able to enter or leave the network at will. When a node joins the network, the longest proof-of-work is automatically accepted as the most reliable one. \n\n==Basis of Value==\nLike other monetized commodity currencies, Bitcoin proponents argue that Bitcoin tokens derive their exchange value from two sources: \n\n# A base valuation derived from the commodity value of the energy required in CPU cycles to generate Bitcoin tokens. \n# A monetary valuation arising from the demand for use of Bitcoin tokens as a trade intermediary (a money). Due to its electronic nature, Bitcoin shares many features with traditional precious metal currencies in being suitable as a means of indirect exchange. Namely, ease of transport and storage, relative scarcity, divisibility, and fungibility. Proponents argue that the monetary value of Bitcoin tokens will rise as their use in trade and thus their potential marketability increases. [http://www.bitcoin.org/faq Bitcoin FAQ]\n\nNew Bitcoin tokens are created at a constant average rate set by the network. These new tokens are divided amongst the Bitcoin generating nodes by the CPU power they use to help maintain the network. The author argues that the competition for Bitcoin creation will drive the cost of electricity needed for generating a Bitcoin close to the market exchange value of a Bitcoin.\n\n==Monetary and Financial Benefits==\nGiven the decentralized nature of Bitcoin and the hard coding of monetary rules within the software, Bitcoin proponents suggest various monetary and financial benefits to potential users[http://www.bitcoin.org/faq Bitcoin FAQ]:\n\n* Direct transfer of monetary value via the internet without a trusted middleman or financial processor.\n* Third parties cannot prevent or control transactions.\n* Transactions are practically free.\n* Bitcoin value is unconnected to possible instability caused by fractional reserve banking and poor central bank policy (see [[Austrian business cycle theory]]). \n* Corrupted transactions created by hacked or modified clients are rejected by honest clients.\n* The limited inflation of the Bitcoin system’s money supply is distributed evenly by CPU power throughout the network and programmatically created at a rate known to all parties in advance. Inflation cannot therefore be centrally manipulated to effect redistribution of Bitcoin value from general users. \n* Given the predetermined rate of Bitcoin creation, the system is further protected against wild swings in supply due to externalities sometimes seen with traditional commodity currencies (see [[History_of_the_rupee#The_fall_of_the_Rupee|Fall of the Rupee]] due to large discoveries of silver reserves in the New World).\n* Bitcoins are potentially divisible to eight decimal points. There are therefore no practical limitations to downward price adjustments in a deflationary environment.\n\nAccording to the author, the design also supports a variety of possible transaction types that have yet to be implemented within the currently available client. These include [[escrow]] transactions, [[surety bond]] contracts, third party [[arbitration]], and multi-party signatures.[http://www.bitcoin.org/smf/index.php?topic=195.msg1611#msg1611]\n\n==Notable sites that accept Bitcoin==\n\n{{As of|2010|4}} there are roughly 12 companies accepting Bitcoin, most notably the largest being [https://www.link2voip.com Link2VoIP.com][http://www.bitcoin.org/smf/index.php?topic=30.msg733#msg733 Link2VoIP (www.link2voip.com) accepts Bitcoin as payment.] who offer automated Bitcoin payments[http://www.bitcoin.org/smf/index.php?topic=102.msg887;topicseen#msg887 Link2VoIP now has full automation.], as well as [http://newlibertystandard.wetpaint.com/ NewLibertyStandard] a Bitcoin Currency Exchange[http://www.bitcoin.org/smf/index.php?topic=30.msg146#msg146 NewLibertyStandard Bitcoin Currency Exchange.], [https://www.bitcoinmarket.com/ BITCOIN MARKET][http://www.bitcoin.org/smf/index.php?topic=20.msg747#msg747 Bitcoin Trading System.], and others.\n\n==See also==\n*[[Anonymous internet banking]]\n*[[eCache]]\n*[[Pecunix]]\n*[[Ripple monetary system]]\n*[[Yodelbank]]\n\n==External links==\n*[http://www.bitcoin.org/ Official website]\n*[http://www.bitcoin.org/sites/default/files/bitcoin.pdf Bitcoin Whitepaper]\n*[http://newlibertystandard.wetpaint.com/ A Bitcoin Currency Exchange]\n\n==References==\n{{reflist|1}}\n\n\n{{bank-stub}}\n{{web-software-stub}}", + "size": 6487, + "minor": false + }, + { + "revId": 369393212, + "pageId": 28249265, + "pageTitle": "Bitcoin", + "timestamp": "2010-06-21T17:29:07Z", + "comment": "fix duplicate ref", + "content": "'''Bitcoin''' is an open source [[peer-to-peer]] electronic cash system developed by Satoshi Nakamoto that's completely decentralized, with no central server or trusted parties. Users hold the [[cryptography|crypto]] keys to their own money and transact directly with each other, with the help of the network to check for double-spending.\n\n==Proof-of-work==\n\nTo preserve the integrity of Bitcoin, users must not be allowed to double-spend their tokens. To prevent this, all transactions are broadcasted over the network.\n\nTo keep this information reliable, it is protected against errors caused by failures of communication or deliberate sabotage. To resolve possible conflicts in the record, it is necessary to determine what data was produced first, and what data was produced later.\n\nBitcoin solves this problem (without a trusted timestamp authority) by using a [[proof-of-work system]], similar to that used by [[HashCash]]. Whenever a Bitcoin user makes a transaction, their node broadcasts the transaction to the network of nodes. When transaction data is received through a node, the node begins a proof-of-work calculation in an attempt to create a block containing the transaction. All nodes essentially race to create a block, as the first one to create a block gets Bitcoins as a reward. Once a node successfully creates a block, it broadcasts the block to the network. Other nodes receive the block, perform a proof-of-work check, and add it to their chain if it is valid. As more transactions occur, blocks are created and added ad infinitum. The longest proof-of-work block chain is acknowledged to be the oldest and most reliable account of the online transactions.\n\nThis mechanism is claimed to be virtually tamper-proof. For an attacker to manipulate the record, he must outpace all of the other nodes on the network to produce the longest proof-of-work. This becomes exponentially more difficult as time passes. \n\nBitcoin is a completely peer-to-peer network, and every node is able to enter or leave the network at will. When a node joins the network, the longest proof-of-work is automatically accepted as the most reliable one. \n\n==Basis of Value==\nLike other monetized commodity currencies, Bitcoin proponents argue that Bitcoin tokens derive their exchange value from two sources: \n\n# A base valuation derived from the commodity value of the energy required in CPU cycles to generate Bitcoin tokens. \n# A monetary valuation arising from the demand for use of Bitcoin tokens as a trade intermediary (a money). Due to its electronic nature, Bitcoin shares many features with traditional precious metal currencies in being suitable as a means of indirect exchange. Namely, ease of transport and storage, relative scarcity, divisibility, and fungibility. Proponents argue that the monetary value of Bitcoin tokens will rise as their use in trade and thus their potential marketability increases. [http://www.bitcoin.org/faq Bitcoin FAQ]\n\nNew Bitcoin tokens are created at a constant average rate set by the network. These new tokens are divided amongst the Bitcoin generating nodes by the CPU power they use to help maintain the network. The author argues that the competition for Bitcoin creation will drive the cost of electricity needed for generating a Bitcoin close to the market exchange value of a Bitcoin.\n\n==Monetary and Financial Benefits==\nGiven the decentralized nature of Bitcoin and the hard coding of monetary rules within the software, Bitcoin proponents suggest various monetary and financial benefits to potential users:\n\n* Direct transfer of monetary value via the internet without a trusted middleman or financial processor.\n* Third parties cannot prevent or control transactions.\n* Transactions are practically free.\n* Bitcoin value is unconnected to possible instability caused by fractional reserve banking and poor central bank policy (see [[Austrian business cycle theory]]). \n* Corrupted transactions created by hacked or modified clients are rejected by honest clients.\n* The limited inflation of the Bitcoin system’s money supply is distributed evenly by CPU power throughout the network and programmatically created at a rate known to all parties in advance. Inflation cannot therefore be centrally manipulated to effect redistribution of Bitcoin value from general users. \n* Given the predetermined rate of Bitcoin creation, the system is further protected against wild swings in supply due to externalities sometimes seen with traditional commodity currencies (see [[History_of_the_rupee#The_fall_of_the_Rupee|Fall of the Rupee]] due to large discoveries of silver reserves in the New World).\n* Bitcoins are potentially divisible to eight decimal points. There are therefore no practical limitations to downward price adjustments in a deflationary environment.\n\nAccording to the author, the design also supports a variety of possible transaction types that have yet to be implemented within the currently available client. These include [[escrow]] transactions, [[surety bond]] contracts, third party [[arbitration]], and multi-party signatures.[http://www.bitcoin.org/smf/index.php?topic=195.msg1611#msg1611]\n\n==Notable sites that accept Bitcoin==\n\n{{As of|2010|4}} there are roughly 12 companies accepting Bitcoin, most notably the largest being [https://www.link2voip.com Link2VoIP.com][http://www.bitcoin.org/smf/index.php?topic=30.msg733#msg733 Link2VoIP (www.link2voip.com) accepts Bitcoin as payment.] who offer automated Bitcoin payments[http://www.bitcoin.org/smf/index.php?topic=102.msg887;topicseen#msg887 Link2VoIP now has full automation.], as well as [http://newlibertystandard.wetpaint.com/ NewLibertyStandard] a Bitcoin Currency Exchange[http://www.bitcoin.org/smf/index.php?topic=30.msg146#msg146 NewLibertyStandard Bitcoin Currency Exchange.], [https://www.bitcoinmarket.com/ BITCOIN MARKET][http://www.bitcoin.org/smf/index.php?topic=20.msg747#msg747 Bitcoin Trading System.], and others.\n\n==See also==\n*[[Anonymous internet banking]]\n*[[eCache]]\n*[[Pecunix]]\n*[[Ripple monetary system]]\n*[[Yodelbank]]\n\n==External links==\n*[http://www.bitcoin.org/ Official website]\n*[http://www.bitcoin.org/sites/default/files/bitcoin.pdf Bitcoin Whitepaper]\n*[http://newlibertystandard.wetpaint.com/ A Bitcoin Currency Exchange]\n\n==References==\n{{reflist|1}}\n\n\n{{bank-stub}}\n{{web-software-stub}}", + "size": 6461, + "minor": true + } + ] + } +] diff --git a/docs/examples/onboarding.svg b/docs/examples/onboarding.svg new file mode 100644 index 0000000..ad8ac7e --- /dev/null +++ b/docs/examples/onboarding.svg @@ -0,0 +1,25 @@ + + A fictional library history made visible + A sentence changes from six to eight, a reference disappears, and text about free printing returns. These are computed observations from five fictional revisions. + + + What a history lets you see + Rivergate Library · fictional offline sample · computed by Refract's analyzers + + Wording changed + 2025-01-02 · sample 1 → 2 + Before: Rivergate Library is open until six every weekday throughout the year + After: Rivergate Library is open until eight every weekday throughout the year + + Reference removed + 2025-01-03 · sample 2 → 3 + https://example.org/rivergate/hours + The observation records removal; your application decides what it means. + + Text returned + 2025-01-05 · sample 4 → 5 + The library provides free printing for all registered members + Previously observed wording is present again in the sample history. + Source inputs and complete event data are linked in the offline walkthrough. + + diff --git a/docs/examples/review-citations.md b/docs/examples/review-citations.md new file mode 100644 index 0000000..478da9d --- /dev/null +++ b/docs/examples/review-citations.md @@ -0,0 +1,32 @@ +# Source change review + +Report: f4c82e34c4e0f44a8810b059563e209c0af045a9c0a63204fc4ea358ffda382b +Analyzer: 0.5.17; scope: article revisions only. + +## Rivergate Library (fictional) + +Status: pending. Reviewed revision: none; fetched: 5; source head: 5. + +Coverage: complete; 5 revisions (1 → 5). + +Fictional sample: dates, revision IDs, and URLs are illustrative. Inspect inputRevisions in the saved JSON report. + +1 shown; 8 hidden by filters. Acknowledgment covers all revisions and events in the unfiltered saved report. + +### citation_removed — body + +2025-01-03T12:00:00Z · fictional revisions 2 → 3 + +Before: + +```text +{{cite web|url=https://example.org/rivergate/hours|title=Rivergate opening hours}} +``` + +After: + +```text +(empty) +``` + +After inspecting the saved report: refract review --state-dir './review-state' --ack f4c82e34c4e0f44a8810b059563e209c0af045a9c0a63204fc4ea358ffda382b diff --git a/docs/examples/review.json b/docs/examples/review.json new file mode 100644 index 0000000..7b6f8f8 --- /dev/null +++ b/docs/examples/review.json @@ -0,0 +1,547 @@ +{ + "format": "refract-review/v1", + "analyzerVersion": "0.5.17", + "depth": "detailed", + "scope": "article_revisions", + "pages": [ + { + "source": { + "kind": "fictional_sample", + "key": "9f3e6db9080df0422d20f9e2dc269d082aa2bc9a22aa8fa56067ed6e015bd943", + "apiUrl": "https://example.org/w/api.php", + "pageTitle": "Rivergate Library (fictional)", + "pageId": 1 + }, + "reviewedRevisionId": null, + "baselineRevisionId": 1, + "fetchedRevisionId": 5, + "latestRevisionId": 5, + "status": "pending", + "coverage": { + "requested": { + "fromRevisionId": 1, + "toRevisionId": 5 + }, + "observed": { + "fromRevisionId": 1, + "toRevisionId": 5, + "revisionCount": 5 + }, + "status": "complete", + "missingRevisionIds": [], + "unavailableRevisionIds": [], + "truncated": false, + "notes": [] + }, + "inputRevisions": [ + { + "revId": 1, + "pageId": 1, + "pageTitle": "Rivergate Library (fictional)", + "timestamp": "2025-01-01T12:00:00Z", + "comment": "Fictional onboarding revision", + "content": "Rivergate Library is open until six every weekday throughout the year.{{cite web|url=https://example.org/rivergate/hours|title=Rivergate opening hours}}\n\nThe library provides free printing for all registered members.\n\nThe reading room has space for thirty seated visitors.", + "size": 283, + "minor": false, + "parentId": 0, + "contentAvailable": true + }, + { + "revId": 2, + "pageId": 1, + "pageTitle": "Rivergate Library (fictional)", + "timestamp": "2025-01-02T12:00:00Z", + "comment": "Fictional onboarding revision", + "content": "Rivergate Library is open until eight every weekday throughout the year.{{cite web|url=https://example.org/rivergate/hours|title=Rivergate opening hours}}\n\nThe library provides free printing for all registered members.\n\nThe reading room has space for thirty seated visitors.", + "size": 285, + "minor": false, + "parentId": 1, + "contentAvailable": true + }, + { + "revId": 3, + "pageId": 1, + "pageTitle": "Rivergate Library (fictional)", + "timestamp": "2025-01-03T12:00:00Z", + "comment": "Fictional onboarding revision", + "content": "Rivergate Library is open until eight every weekday throughout the year.\n\nThe library provides free printing for all registered members.\n\nThe reading room has space for thirty seated visitors.", + "size": 192, + "minor": false, + "parentId": 2, + "contentAvailable": true + }, + { + "revId": 4, + "pageId": 1, + "pageTitle": "Rivergate Library (fictional)", + "timestamp": "2025-01-04T12:00:00Z", + "comment": "Fictional onboarding revision", + "content": "Rivergate Library is open until eight every weekday throughout the year.\n\nThe reading room has space for thirty seated visitors.", + "size": 128, + "minor": false, + "parentId": 3, + "contentAvailable": true + }, + { + "revId": 5, + "pageId": 1, + "pageTitle": "Rivergate Library (fictional)", + "timestamp": "2025-01-05T12:00:00Z", + "comment": "Fictional onboarding revision", + "content": "Rivergate Library is open until eight every weekday throughout the year.\n\nThe library provides free printing for all registered members.\n\nThe reading room has space for thirty seated visitors.", + "size": 192, + "minor": false, + "parentId": 4, + "contentAvailable": true + } + ], + "entries": [ + { + "eventType": "section_reorganized", + "fromRevisionId": 1, + "toRevisionId": 2, + "section": "(lead)", + "before": "Rivergate Library is open until six every weekday throughout the year.{{cite web|url=https://example.org/rivergate/hours|title=Rivergate opening hours}}\n\nThe library provides free printing for all registered members.\n\nThe reading room has space for thirty seated visitors.", + "after": "Rivergate Library is open until eight every weekday throughout the year.{{cite web|url=https://example.org/rivergate/hours|title=Rivergate opening hours}}\n\nThe library provides free printing for all registered members.\n\nThe reading room has space for thirty seated visitors.", + "deterministicFacts": [ + { + "fact": "section_changed", + "detail": "change=modified" + } + ], + "layer": "observed", + "timestamp": "2025-01-02T12:00:00Z", + "schemaVersion": "0.5.0", + "editMagnitude": "major", + "contentChange": "refinement", + "keyTerms": [ + "rivergate", + "library", + "weekday", + "example", + "opening", + "provides", + "printing", + "registered", + "members", + "reading", + "thirty", + "seated", + "visitors" + ], + "certaintyProfile": { + "high": 0, + "medium": 0, + "low": 0, + "hedging": 0 + }, + "directionSignal": "neutral", + "quantitativeFindings": [], + "eventId": "82e47fbe8522f61f", + "entryId": "d90e323424e651d344be30fa13d4d1f74b353580acd8bd693566b5755edc8182", + "source": { + "kind": "fictional_sample", + "key": "9f3e6db9080df0422d20f9e2dc269d082aa2bc9a22aa8fa56067ed6e015bd943", + "apiUrl": "https://example.org/w/api.php", + "pageTitle": "Rivergate Library (fictional)", + "pageId": 1 + }, + "revisionLinks": { + "before": "https://example.org/w/index.php?oldid=1", + "after": "https://example.org/w/index.php?oldid=2", + "diff": "https://example.org/w/index.php?oldid=1&diff=2" + } + }, + { + "eventType": "sentence_modified", + "fromRevisionId": 1, + "toRevisionId": 2, + "section": "(lead)", + "before": "Rivergate Library is open until six every weekday throughout the year", + "after": "Rivergate Library is open until eight every weekday throughout the year", + "deterministicFacts": [ + { + "fact": "sentence_modified", + "detail": "sentence_length=71" + } + ], + "layer": "observed", + "timestamp": "2025-01-02T12:00:00Z", + "schemaVersion": "0.5.0", + "editMagnitude": "minor", + "contentChange": "refinement", + "keyTerms": [ + "rivergate", + "library", + "weekday" + ], + "certaintyProfile": { + "high": 0, + "medium": 0, + "low": 0, + "hedging": 0 + }, + "directionSignal": "neutral", + "quantitativeFindings": [], + "eventId": "313b9599834017ea", + "entryId": "55c8838f5eeda1d523983eaeacf35db5f54d81ae83685935ecc5c9495123f7a3", + "source": { + "kind": "fictional_sample", + "key": "9f3e6db9080df0422d20f9e2dc269d082aa2bc9a22aa8fa56067ed6e015bd943", + "apiUrl": "https://example.org/w/api.php", + "pageTitle": "Rivergate Library (fictional)", + "pageId": 1 + }, + "revisionLinks": { + "before": "https://example.org/w/index.php?oldid=1", + "after": "https://example.org/w/index.php?oldid=2", + "diff": "https://example.org/w/index.php?oldid=1&diff=2" + } + }, + { + "eventType": "citation_removed", + "fromRevisionId": 2, + "toRevisionId": 3, + "section": "body", + "before": "{{cite web|url=https://example.org/rivergate/hours|title=Rivergate opening hours}}", + "after": "", + "deterministicFacts": [ + { + "fact": "citation_changed", + "detail": "type=removed" + } + ], + "layer": "observed", + "timestamp": "2025-01-03T12:00:00Z", + "schemaVersion": "0.5.0", + "editMagnitude": "moderate", + "contentChange": "compression", + "keyTerms": [ + "example", + "rivergate", + "opening" + ], + "certaintyProfile": { + "high": 0, + "medium": 0, + "low": 0, + "hedging": 0 + }, + "directionSignal": "neutral", + "quantitativeFindings": [], + "eventId": "79512edc32a862cf", + "entryId": "17a87de09276cce09f35c095b3f52f77c8ffcf46a1e2842ee23612def8b0c48f", + "source": { + "kind": "fictional_sample", + "key": "9f3e6db9080df0422d20f9e2dc269d082aa2bc9a22aa8fa56067ed6e015bd943", + "apiUrl": "https://example.org/w/api.php", + "pageTitle": "Rivergate Library (fictional)", + "pageId": 1 + }, + "revisionLinks": { + "before": "https://example.org/w/index.php?oldid=2", + "after": "https://example.org/w/index.php?oldid=3", + "diff": "https://example.org/w/index.php?oldid=2&diff=3" + } + }, + { + "eventType": "section_reorganized", + "fromRevisionId": 2, + "toRevisionId": 3, + "section": "(lead)", + "before": "Rivergate Library is open until eight every weekday throughout the year.{{cite web|url=https://example.org/rivergate/hours|title=Rivergate opening hours}}\n\nThe library provides free printing for all registered members.\n\nThe reading room has space for thirty seated visitors.", + "after": "Rivergate Library is open until eight every weekday throughout the year.\n\nThe library provides free printing for all registered members.\n\nThe reading room has space for thirty seated visitors.", + "deterministicFacts": [ + { + "fact": "section_changed", + "detail": "change=modified" + } + ], + "layer": "observed", + "timestamp": "2025-01-03T12:00:00Z", + "schemaVersion": "0.5.0", + "editMagnitude": "major", + "contentChange": "refinement", + "keyTerms": [ + "rivergate", + "library", + "weekday", + "provides", + "printing", + "registered", + "members", + "reading", + "thirty", + "seated", + "visitors" + ], + "certaintyProfile": { + "high": 0, + "medium": 0, + "low": 0, + "hedging": 0 + }, + "directionSignal": "neutral", + "quantitativeFindings": [], + "eventId": "6d9139167662df1b", + "entryId": "070f582db01121978639795f5ba53448a30d4c4f443cda16e4447dc1e0586375", + "source": { + "kind": "fictional_sample", + "key": "9f3e6db9080df0422d20f9e2dc269d082aa2bc9a22aa8fa56067ed6e015bd943", + "apiUrl": "https://example.org/w/api.php", + "pageTitle": "Rivergate Library (fictional)", + "pageId": 1 + }, + "revisionLinks": { + "before": "https://example.org/w/index.php?oldid=2", + "after": "https://example.org/w/index.php?oldid=3", + "diff": "https://example.org/w/index.php?oldid=2&diff=3" + } + }, + { + "eventType": "template_removed", + "fromRevisionId": 2, + "toRevisionId": 3, + "section": "body", + "before": "", + "after": "cite web", + "deterministicFacts": [ + { + "fact": "template_changed", + "detail": "name=cite web type=removed" + } + ], + "layer": "observed", + "timestamp": "2025-01-03T12:00:00Z", + "schemaVersion": "0.5.0", + "editMagnitude": "minor", + "contentChange": "expansion", + "keyTerms": [], + "certaintyProfile": { + "high": 0, + "medium": 0, + "low": 0, + "hedging": 0 + }, + "directionSignal": "neutral", + "quantitativeFindings": [], + "eventId": "ef3d1f14aafa414e", + "entryId": "664ce949a35a465f0b8d9d830195734ef2da3051f58fb4ff28519a509f141595", + "source": { + "kind": "fictional_sample", + "key": "9f3e6db9080df0422d20f9e2dc269d082aa2bc9a22aa8fa56067ed6e015bd943", + "apiUrl": "https://example.org/w/api.php", + "pageTitle": "Rivergate Library (fictional)", + "pageId": 1 + }, + "revisionLinks": { + "before": "https://example.org/w/index.php?oldid=2", + "after": "https://example.org/w/index.php?oldid=3", + "diff": "https://example.org/w/index.php?oldid=2&diff=3" + } + }, + { + "eventType": "section_reorganized", + "fromRevisionId": 3, + "toRevisionId": 4, + "section": "(lead)", + "before": "Rivergate Library is open until eight every weekday throughout the year.\n\nThe library provides free printing for all registered members.\n\nThe reading room has space for thirty seated visitors.", + "after": "Rivergate Library is open until eight every weekday throughout the year.\n\nThe reading room has space for thirty seated visitors.", + "deterministicFacts": [ + { + "fact": "section_changed", + "detail": "change=modified" + } + ], + "layer": "observed", + "timestamp": "2025-01-04T12:00:00Z", + "schemaVersion": "0.5.0", + "editMagnitude": "moderate", + "contentChange": "refinement", + "keyTerms": [ + "rivergate", + "library", + "weekday", + "reading", + "thirty", + "seated", + "visitors" + ], + "certaintyProfile": { + "high": 0, + "medium": 0, + "low": 0, + "hedging": 0 + }, + "directionSignal": "neutral", + "quantitativeFindings": [], + "eventId": "4e639d38b9156103", + "entryId": "ce0432bb0b65079065c21a4dfc0cdda22af86539338ae57faa818d0f5440233e", + "source": { + "kind": "fictional_sample", + "key": "9f3e6db9080df0422d20f9e2dc269d082aa2bc9a22aa8fa56067ed6e015bd943", + "apiUrl": "https://example.org/w/api.php", + "pageTitle": "Rivergate Library (fictional)", + "pageId": 1 + }, + "revisionLinks": { + "before": "https://example.org/w/index.php?oldid=3", + "after": "https://example.org/w/index.php?oldid=4", + "diff": "https://example.org/w/index.php?oldid=3&diff=4" + } + }, + { + "eventType": "sentence_removed", + "fromRevisionId": 3, + "toRevisionId": 4, + "section": "(lead)", + "before": "The library provides free printing for all registered members", + "after": "", + "deterministicFacts": [ + { + "fact": "sentence_removed", + "detail": "sentence_length=61" + } + ], + "layer": "observed", + "timestamp": "2025-01-04T12:00:00Z", + "schemaVersion": "0.5.0", + "editMagnitude": "minor", + "contentChange": "removal", + "keyTerms": [ + "library", + "provides", + "printing", + "registered", + "members" + ], + "certaintyProfile": { + "high": 0, + "medium": 0, + "low": 0, + "hedging": 0 + }, + "directionSignal": "neutral", + "quantitativeFindings": [], + "eventId": "108f0e28a68f8e7d", + "entryId": "da5866766a22c036d801479c1dc3858af60eb9f02a556e1c618296cdc6d719ce", + "source": { + "kind": "fictional_sample", + "key": "9f3e6db9080df0422d20f9e2dc269d082aa2bc9a22aa8fa56067ed6e015bd943", + "apiUrl": "https://example.org/w/api.php", + "pageTitle": "Rivergate Library (fictional)", + "pageId": 1 + }, + "revisionLinks": { + "before": "https://example.org/w/index.php?oldid=3", + "after": "https://example.org/w/index.php?oldid=4", + "diff": "https://example.org/w/index.php?oldid=3&diff=4" + } + }, + { + "eventType": "section_reorganized", + "fromRevisionId": 4, + "toRevisionId": 5, + "section": "(lead)", + "before": "Rivergate Library is open until eight every weekday throughout the year.\n\nThe reading room has space for thirty seated visitors.", + "after": "Rivergate Library is open until eight every weekday throughout the year.\n\nThe library provides free printing for all registered members.\n\nThe reading room has space for thirty seated visitors.", + "deterministicFacts": [ + { + "fact": "section_changed", + "detail": "change=modified" + } + ], + "layer": "observed", + "timestamp": "2025-01-05T12:00:00Z", + "schemaVersion": "0.5.0", + "editMagnitude": "moderate", + "contentChange": "refinement", + "keyTerms": [ + "rivergate", + "library", + "weekday", + "provides", + "printing", + "registered", + "members", + "reading", + "thirty", + "seated", + "visitors" + ], + "certaintyProfile": { + "high": 0, + "medium": 0, + "low": 0, + "hedging": 0 + }, + "directionSignal": "neutral", + "quantitativeFindings": [], + "eventId": "5097c38395b5cc95", + "entryId": "f8918cfe850ecd84e553d7fc06310fe66b5c463670f1ccfdc3a6459221a95639", + "source": { + "kind": "fictional_sample", + "key": "9f3e6db9080df0422d20f9e2dc269d082aa2bc9a22aa8fa56067ed6e015bd943", + "apiUrl": "https://example.org/w/api.php", + "pageTitle": "Rivergate Library (fictional)", + "pageId": 1 + }, + "revisionLinks": { + "before": "https://example.org/w/index.php?oldid=4", + "after": "https://example.org/w/index.php?oldid=5", + "diff": "https://example.org/w/index.php?oldid=4&diff=5" + } + }, + { + "eventType": "sentence_reintroduced", + "fromRevisionId": 4, + "toRevisionId": 5, + "section": "(lead)", + "before": "", + "after": "The library provides free printing for all registered members", + "deterministicFacts": [ + { + "fact": "claim_detected", + "detail": "sentence_length=61" + } + ], + "layer": "observed", + "timestamp": "2025-01-05T12:00:00Z", + "schemaVersion": "0.5.0", + "editMagnitude": "minor", + "contentChange": "expansion", + "keyTerms": [ + "library", + "provides", + "printing", + "registered", + "members" + ], + "certaintyProfile": { + "high": 0, + "medium": 0, + "low": 0, + "hedging": 0 + }, + "directionSignal": "neutral", + "quantitativeFindings": [], + "eventId": "053be8b56f89a860", + "entryId": "f10861c8a80a30fdc40c3eb8744481177b0802240c60a9002a4f5daf277e2154", + "source": { + "kind": "fictional_sample", + "key": "9f3e6db9080df0422d20f9e2dc269d082aa2bc9a22aa8fa56067ed6e015bd943", + "apiUrl": "https://example.org/w/api.php", + "pageTitle": "Rivergate Library (fictional)", + "pageId": 1 + }, + "revisionLinks": { + "before": "https://example.org/w/index.php?oldid=4", + "after": "https://example.org/w/index.php?oldid=5", + "diff": "https://example.org/w/index.php?oldid=4&diff=5" + } + } + ] + } + ], + "reportId": "f4c82e34c4e0f44a8810b059563e209c0af045a9c0a63204fc4ea358ffda382b" +} diff --git a/docs/examples/review.md b/docs/examples/review.md new file mode 100644 index 0000000..5fb5540 --- /dev/null +++ b/docs/examples/review.md @@ -0,0 +1,188 @@ +# Source change review + +Report: f4c82e34c4e0f44a8810b059563e209c0af045a9c0a63204fc4ea358ffda382b +Analyzer: 0.5.17; scope: article revisions only. + +## Rivergate Library (fictional) + +Status: pending. Reviewed revision: none; fetched: 5; source head: 5. + +Coverage: complete; 5 revisions (1 → 5). + +Fictional sample: dates, revision IDs, and URLs are illustrative. Inspect inputRevisions in the saved JSON report. + +9 shown; 0 hidden by filters. Acknowledgment covers all revisions and events in the unfiltered saved report. + +### section_reorganized — (lead) + +2025-01-02T12:00:00Z · fictional revisions 1 → 2 + +Before: + +```text +Rivergate Library is open until six every weekday throughout the year.{{cite web|url=https://example.org/rivergate/hours|title=Rivergate opening hours}} + +The library provides free printing for all registered members. + +The reading room has space for thirty seated visitors. +``` + +After: + +```text +Rivergate Library is open until eight every weekday throughout the year.{{cite web|url=https://example.org/rivergate/hours|title=Rivergate opening hours}} + +The library provides free printing for all registered members. + +The reading room has space for thirty seated visitors. +``` + +### sentence_modified — (lead) + +2025-01-02T12:00:00Z · fictional revisions 1 → 2 + +Before: + +```text +Rivergate Library is open until six every weekday throughout the year +``` + +After: + +```text +Rivergate Library is open until eight every weekday throughout the year +``` + +### citation_removed — body + +2025-01-03T12:00:00Z · fictional revisions 2 → 3 + +Before: + +```text +{{cite web|url=https://example.org/rivergate/hours|title=Rivergate opening hours}} +``` + +After: + +```text +(empty) +``` + +### section_reorganized — (lead) + +2025-01-03T12:00:00Z · fictional revisions 2 → 3 + +Before: + +```text +Rivergate Library is open until eight every weekday throughout the year.{{cite web|url=https://example.org/rivergate/hours|title=Rivergate opening hours}} + +The library provides free printing for all registered members. + +The reading room has space for thirty seated visitors. +``` + +After: + +```text +Rivergate Library is open until eight every weekday throughout the year. + +The library provides free printing for all registered members. + +The reading room has space for thirty seated visitors. +``` + +### template_removed — body + +2025-01-03T12:00:00Z · fictional revisions 2 → 3 + +Before: + +```text +(empty) +``` + +After: + +```text +cite web +``` + +### section_reorganized — (lead) + +2025-01-04T12:00:00Z · fictional revisions 3 → 4 + +Before: + +```text +Rivergate Library is open until eight every weekday throughout the year. + +The library provides free printing for all registered members. + +The reading room has space for thirty seated visitors. +``` + +After: + +```text +Rivergate Library is open until eight every weekday throughout the year. + +The reading room has space for thirty seated visitors. +``` + +### sentence_removed — (lead) + +2025-01-04T12:00:00Z · fictional revisions 3 → 4 + +Before: + +```text +The library provides free printing for all registered members +``` + +After: + +```text +(empty) +``` + +### section_reorganized — (lead) + +2025-01-05T12:00:00Z · fictional revisions 4 → 5 + +Before: + +```text +Rivergate Library is open until eight every weekday throughout the year. + +The reading room has space for thirty seated visitors. +``` + +After: + +```text +Rivergate Library is open until eight every weekday throughout the year. + +The library provides free printing for all registered members. + +The reading room has space for thirty seated visitors. +``` + +### sentence_reintroduced — (lead) + +2025-01-05T12:00:00Z · fictional revisions 4 → 5 + +Before: + +```text +(empty) +``` + +After: + +```text +The library provides free printing for all registered members +``` + +After inspecting the saved report: refract review --state-dir './review-state' --ack f4c82e34c4e0f44a8810b059563e209c0af045a9c0a63204fc4ea358ffda382b diff --git a/docs/integrations.md b/docs/integrations.md new file mode 100644 index 0000000..4877283 --- /dev/null +++ b/docs/integrations.md @@ -0,0 +1,126 @@ +# Use observations in your application + +Start with the artifact you need: a local file, a library result, or an agent +tool response. The [review-feed recipe](./recipes.md#build-a-review-feed) shows +an end-to-end NDJSON workflow with captured output. + +## Use a single library adapter + +Install the packages your adapter needs: + +```bash +bun add @refract-org/evidence-graph @refract-org/ingestion @refract-org/analyzers +``` + +Keep Refract imports in one file so consumers have one place to change during an +upgrade: + +```typescript +// refract-adapter.ts +import { annotateEvents, buildRevisionEvents } from "@refract-org/analyzers"; +import type { EvidenceEvent, Revision } from "@refract-org/evidence-graph"; +import { MediaWikiClient } from "@refract-org/ingestion"; + +export { annotateEvents, buildRevisionEvents, MediaWikiClient }; +export type { EvidenceEvent, Revision }; +``` + +Your application supplies the revision inputs and receives observations: + +```typescript +import { annotateEvents, buildRevisionEvents } from "./refract-adapter.js"; +import type { Revision } from "./refract-adapter.js"; + +// Populate this from your versioned source, in chronological order. +export function observationsFor(revisions: Revision[]) { + return annotateEvents(buildRevisionEvents(revisions, { depth: "detailed" })); +} +``` + +`buildRevisionEvents` compares supplied revisions; it does not fetch data. +`annotateEvents` adds the event schema version and deterministic enrichment +fields. The CLI separately fetches talk-page and page-log metadata. Library +consumers must supply or process that metadata when they need those observations. + +See the [saved Bitcoin revision pairs](./examples/bitcoin-revisions.json) for +complete input objects and the [capture script](../scripts/capture-bitcoin-example.ts) +for an executable example. Analyze its pairs separately so the gap between them +does not become an invented transition. + +## Query an exported event file + +Native NDJSON uses camelCase fields such as `eventType` and `fromRevisionId`. +Flattened CSV uses names such as `event_type` and `from_revision_id`; choose one +format and inspect its fields before writing queries. + +For a downloaded copy of the [fictional sample](./examples/sample-events.ndjson), +this Python snippet selects reference removals: + +```python +import json + +with open("sample-events.ndjson") as source: + removals = [ + event for line in source if line.strip() + if (event := json.loads(line))["eventType"] == "citation_removed" + ] +for event in removals: + print(event["timestamp"], event["before"]) +``` + +A SQL engine with a JSON reader can query the same file. For DuckDB, use the +native field name: + +```sql +SELECT eventType, count(*) AS events +FROM read_json_auto('sample-events.ndjson', format='newline_delimited') +GROUP BY eventType; +``` + +CSV without `--flatten` truncates the before/after text to 200 characters. Use +NDJSON when your application needs the complete exported text and nested facts. + +## Add history to retrieval or a review queue + +Keep the current text alongside its captured revision ID. Store observations +with their revision range and extraction settings. When your application finds +a relevant change, link the reader to the input revisions and show the changed +text or removed reference. + +Your application supplies claim linking, review criteria, and any ranking or +notification rules. A changed sentence or removed reference is an observation; +it does not by itself establish that a retrieved passage should be rejected. + +## Scheduled observation + +`refract cron` performs a re-observation run over a page list and lookback +window. Use your scheduler to run it repeatedly. Delivery to a queue, storage +system, or review service belongs in your integration. Observation is bounded +by polling and source availability; this is not a continuous stream of every +external action. + +## Agent tools + +Use the [MCP setup](./mcp.md) to let an agent request page histories. Ask it to +report the revision range, observed text, and source revision IDs alongside its +explanation. Keep the tool observations and the agent's interpretation distinct. + +## Reproduction + +Preserve revision inputs, supporting metadata, analyzer versions, and +configuration when you need to reconstruct a result. A verification bundle +checks its internal hash and proof consistency; it does not fetch the source or +rerun the analyzers. [Verification scope](../ARCHITECTURE.md#replay-and-verification) +describes what the checks establish. + +## Consume saved source reviews + +Use `review --pages-file pages.txt --format json` when your application needs a +persistent review baseline. The report includes source identity, full changed +text, revision links, coverage, inputs, and analyzer version. Deduplicate entries +by `entryId`; native `eventId` remains the analyzer's observation hash. Filters +produce a separate view while the saved report retains every entry. + +Fetching leaves revisions pending. Acknowledgment by saved `reportId` advances +only the inspected endpoint, so later edits remain for the next review. See the +[review contract and examples](./review.md#inspect-changes-with-context). diff --git a/docs/mcp.md b/docs/mcp.md index 3a41cf3..733137f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -51,7 +51,8 @@ With a source checkout instead of npm, point the command at the build: ### `analyze` -Analyze a MediaWiki page's full edit history. +Analyze a MediaWiki page's revision history. Use `from` and `to` for a pinned +range; the default brief run reads a recent window. See [range and depth rules](./usage.md). | Parameter | Type | Required | Description | |---|---|---|---| @@ -64,7 +65,8 @@ Analyze a MediaWiki page's full edit history. ### `claim` -Track a specific claim's provenance across revisions. +Track text matches across revisions. The result is bounded by the fetched +history and [text-matching rules](./concepts/claim-state-timelines.md#identity-and-matching). | Parameter | Type | Required | Description | |---|---|---|---| @@ -93,7 +95,10 @@ Re-observe pages — for scheduled monitoring. ### `get_statement_history` -Track the history of a specific statement across revisions. Returns when the statement — or a semantic neighbor — appeared, disappeared, or changed, with revision IDs, timestamps, an overall status, and a short change summary. +Track normalized text or partial word matches across revisions. Returns matched +revision IDs, timestamps, an overall status, and a short change summary. A +partial match is not a guarantee of equivalent meaning; state labels summarize +the matching rules. See [state labels](./concepts/claim-state-timelines.md#state-labels). | Parameter | Type | Required | Description | |---|---|---|---| @@ -126,3 +131,15 @@ For private wikis, pass `apiKey`, `apiUser`, and `apiPassword` as parameters on } } ``` + +### Passage matching and latest state + +`get_statement_history` now includes `latest_revision_id`, `latest_match`, and +`caveats`. Detailed results retain candidate text and each variant's match kind. +`latest_match` distinguishes exact text, a sentence word-overlap candidate, no +match, and unavailable source text. Status `unknown` covers partial latest +matches or withheld latest text. Disappearance is measured against the latest +fetched revision, not the last historical match. + +Use [saved source reviews](./review.md) through the CLI when you need an explicit +review baseline; fetching a statement history does not acknowledge a review. diff --git a/docs/naming.md b/docs/naming.md index c6afc2e..de85abe 100644 --- a/docs/naming.md +++ b/docs/naming.md @@ -36,7 +36,7 @@ import type { EvidenceEvent } from "@refract-org/evidence-graph"; ## In shell ```bash -refract --version # 0.5.6 +refract --version # prints the installed CLI version which refract # points to @refract-org/cli binary ``` diff --git a/docs/recipes.md b/docs/recipes.md index bc2c658..713cf86 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -1,161 +1,175 @@ # Recipes -Task-first guide. Find what you need by what you want to do, not by what -Refract calls it. +Start with a question and finish with an artifact you can inspect. The offline +workflow uses a fictional history; the Wikipedia examples name real revision +pairs. Commands below use `refract` as the CLI command. ---- +## Try the offline walkthrough -## Track a Specific Claim - -**I want to know when a sentence first appeared, was removed, or reintroduced.** +The offline `init` behavior is available in this checkout. Until it ships in an +npm release, build from source and define a shell function while working in the +repository root: ```bash -refract claim "Bitcoin" --text "cryptocurrency" +bun install --frozen-lockfile +bun run build +refract() { node packages/cli/dist/src/cli.js "$@"; } +refract init ``` -Returns timeline of the claim across revisions. Example output: +The fictional Rivergate Library sample shows a sentence changing from closing at +six to closing at eight, a reference being removed, and wording about free +printing disappearing and returning. The reference URL, dates, and revision IDs +are fictional too. No network access is needed after dependencies are installed. -``` -[2009-03-08] sentence_first_seen: "Bitcoin is a cryptocurrency" - Section: lead — rev 275832581→275832690 -[2017-06-15] sentence_reintroduced: "Bitcoin is a cryptocurrency" - Section: lead — rev 456789012→456789013 -``` +![Computed observations from the fictional sample](./examples/onboarding.svg) + +Inspect the [source revisions](../packages/cli/src/onboarding-sample.ts), +[captured terminal output](./examples/onboarding.txt), and +[complete events](./examples/sample-events.ndjson). + +## Review sources since your last check -## Cross-Wiki Comparison +Use [the review workflow](./review.md) for a watchlist with saved revision +baselines, resumable fetching, and acknowledgment of an inspected report. Its +[offline sample](./review.md#try-the-complete-flow-offline) exercises the whole +process without fetching a wiki. -**I want to compare how Wikipedia and Fandom cover the same topic.** +## Inspect changes to a passage + +**“What changed in this passage between two revisions?”** ```bash -refract diff "Star Wars" \ - --wiki-a https://en.wikipedia.org/w/api.php \ - --wiki-b https://starwars.fandom.com/api.php +refract analyze "Bitcoin" --from 345930425 --to 350653695 --depth detailed --json > passage-events.ndjson +jq -c 'select(.eventType == "sentence_modified") | {timestamp, before, after, fromRevisionId, toRevisionId}' passage-events.ndjson ``` -Example output: +`detailed` includes matched text. `--json` prints one JSON event per line and +avoids launching the browser. The range is pinned so an unrelated current edit +does not change the example's article inputs. -``` -Cross-Wiki Diff: "Star Wars" - Wiki A: https://en.wikipedia.org/w/api.php - Wiki B: https://starwars.fandom.com/api.php - -── Overview ── - Total events 340 217 - Sections 12 8 - Citations 87 34 - Reverts 23 5 +The [Bitcoin walkthrough](./example-output.md) includes the captured wording +change and links to both source revisions. A similarity match is a rule-based +association of text, not a guarantee that its meaning remained the same. + +For a phrase in a page's recent history: + +```bash +refract claim "Bitcoin" --text "decentralized" --limit 50 ``` ---- +This inspects the latest 50 revisions. The command prints candidate text, +match kind, latest revision, and status. `unknown` identifies a partial match or +unavailable latest text; `absent` identifies no match in available latest text. +The first match is relative to the fetched window. A partial candidate is found +by sentence word overlap; see [matching rules](./concepts/claim-state-timelines.md#identity-and-matching). -## Private Wiki Access +## Inspect an earlier record -**I want to analyze a corporate wiki behind authentication.** +**“What did the page say at this date?”** ```bash -# Bearer token -refract analyze "Internal Policy" --api https://corp.example.com/w/api.php --api-key "sk-..." +refract snapshot "Bitcoin" --at 2024-01-15 +``` -# Basic auth -refract analyze "Internal Policy" --api https://corp.example.com/w/api.php --api-user "user" --api-password "pass" +The result names the closest fetched revision at or before the requested date +and prints a text preview, limited to 2,000 characters. Inspect the complete +revision by inserting its ID in: -# Client-credential headers (sent to whatever --api names, so set them only for that wiki) -REFRACT_OAUTH_CLIENT_ID="..." REFRACT_OAUTH_CLIENT_SECRET="..." \ - refract analyze "Internal Policy" --api https://corp.example.com/w/api.php +```text +https://en.wikipedia.org/w/index.php?oldid=REVISION_ID ``` ---- +A snapshot is one captured state. Use a bounded `analyze` call to compare it with +another revision. -## MCP: AI Agent Integration +## Build a review feed -**I want Claude/Cursor/Copilot to call Refract deterministically.** +**“My application needs reference removals as data.”** -Add to your MCP client config: +This offline workflow requires `jq` and the source-build `refract` function above: -```json -{ - "mcpServers": { - "sequent": { - "command": "bunx", - "args": ["refract", "mcp"] - } - } -} +```bash +refract init --json > sample-events.ndjson +jq -c 'select(.eventType == "citation_removed")' sample-events.ndjson > review-events.ndjson +jq '{timestamp, removedReference: .before, fromRevisionId, toRevisionId}' review-events.ndjson ``` -Then ask: "When did the Bitcoin article first call it a cryptocurrency?" +The selected event says that a reference was removed from fictional sample +revision 2 to 3. It preserves the removed reference and links the observation to +the input revision IDs. Compare your file with the +[captured review event](./examples/review-events.ndjson). ---- +`review-events.ndjson` is the artifact your application can ingest into a review +queue. Your application supplies the routing rule and any judgment about the +reference's significance. This recipe writes a file locally; it does not send a +notification or create a review item in an external service. -## Equivalent to WikiWho - -**I used WikiWho for token authorship. How do I get claim provenance?** +For real Wikipedia revisions, export a pinned range and attach source links: ```bash -# WikiWho: who wrote which token -# Refract: when did a claim appear, change, disappear -refract analyze "Bitcoin" --depth detailed | grep claim +refract export "Bitcoin" --from 369361755 --to 369393212 --format ndjson > wiki-events.ndjson +jq -c 'select(.eventType == "citation_removed") | { + eventId, timestamp, removedReference: .before, + priorRevision: ("https://en.wikipedia.org/w/index.php?oldid=" + (.fromRevisionId | tostring)), + nextRevision: ("https://en.wikipedia.org/w/index.php?oldid=" + (.toRevisionId | tostring)) +}' wiki-events.ndjson > wiki-review-items.ndjson ``` ---- +The links above assume English Wikipedia. For another MediaWiki instance, use +that wiki's revision URL. Events are bounded by the captured range; they do not +establish why a reference was removed or whether it supported the passage. -## Observable Framework Data Loader +## Compare two wikis -**I want to load Refract events into an Observable notebook.** +```bash +refract diff "Star Wars" --wiki-a https://en.wikipedia.org/w/api.php --wiki-b https://starwars.fandom.com/api.php +``` -```typescript -// data-loader.ts — Observable Framework data loader -import { readFileSync } from "node:fs"; +This produces a cross-wiki comparison. Live results depend on both histories; +counts are not fixed example output. Compare detected differences in text and +structure before assigning meaning to divergence. -export interface RefractLoaderOptions { - path: string; - format?: "json" | "sqlite"; -} +## Let an agent request history -export class RefractLoader { - private path: string; - private format: "json" | "sqlite"; +Use the CLI package that supplies the MCP executable: - constructor(options: RefractLoaderOptions) { - this.path = options.path; - this.format = options.format ?? (options.path.endsWith(".db") ? "sqlite" : "json"); - } - - async load(): Promise> { - if (this.format === "json") { - const raw = readFileSync(this.path, "utf-8"); - return JSON.parse(raw) as Record; +```json +{ + "mcpServers": { + "refract": { + "command": "npx", + "args": ["-y", "@refract-org/cli", "mcp"] } - - const { Database } = await import("bun:sqlite"); - const db = new Database(this.path, { readonly: true }); - const events = db.query("SELECT * FROM evidence_events").all(); - const revisions = db.query("SELECT * FROM revisions").all(); - db.close(); - return { events, revisions }; } } - -export function refractLoader(options: RefractLoaderOptions): RefractLoader { - return new RefractLoader(options); -} ``` -Usage in Observable: - -```js -import { refractLoader } from "./data-loader.ts"; -const data = refractLoader({ path: "./bitcoin-analysis.json" }); -``` +A useful first request is: “Analyze Bitcoin revisions 345930425 through +350653695 with text included. Show the observed wording changes and source +revision IDs. State the analyzed range.” ---- +The agent requests observations from Refract and explains them to the reader. +Its explanation is separate from Refract's deterministic event stream. +[MCP reference](./mcp.md) lists the tools and arguments. -## Equivalent to ORES +## Reproduce the saved examples -**I used ORES for edit quality scores. How do I get deterministic classification?** +After `bun run build`, these commands regenerate the checked-in artifacts: ```bash -# ORES: ML score for "likely damaging" -# Refract: deterministic revert detection + policy signal classification -refract analyze "Bitcoin" --depth detailed | grep -E "revert|template" +bun scripts/capture-onboarding.ts +bun scripts/capture-bitcoin-example.ts ``` + +Both use local inputs. Add `--check` to compare the saved artifacts with current +output without rewriting them. To fetch the two pinned Bitcoin revision pairs +again, run `bun scripts/capture-bitcoin-example.ts --refresh`; that step requires +network access. + +## Next steps + +- [History range and output guide](./usage.md) +- [Library adapter and data integrations](./integrations.md) +- [Other MediaWiki instances](./usage.md#other-mediawiki-instances) +- [Identity and verification limits](./concepts/claim-state-timelines.md) diff --git a/docs/repositories.md b/docs/repositories.md new file mode 100644 index 0000000..4cb7072 --- /dev/null +++ b/docs/repositories.md @@ -0,0 +1,112 @@ +# Repository map and consolidation proposal + +Refract has one observation engine and three supporting projects. A user can +analyze, review, and export sources with the core CLI alone. The viewer and Python +adapter provide optional ways to consume its output. + +## Current ownership + +| Repository | Owns | Consumes | Local checks | +|---|---|---|---| +| [refract](https://github.com/refract-org/refract) | Event schemas, analyzers, ingestion, CLI, explorer, review state, captured examples | Source revision histories | `bun run ci` and `bun run check:examples` | +| [refract-docs](https://github.com/refract-org/refract-docs) | Public site, navigation, tutorials, Pages deployment | Documented CLI and schema contracts | `npm ci`, `npm run lint`, `npm test`, `npm run build` | +| [refract-ui](https://github.com/refract-org/refract-ui) | Browser-only file viewer | NDJSON events; local types mirror the engine | `bun install --frozen-lockfile`, `bun run lint`, `bun run test`, `bun run build` | +| [refract-py](https://github.com/refract-org/refract-py) | Python subprocess adapter and notebook integrations | CLI flags and NDJSON output | `hatch test`, `hatch build` | + +This inventory was checked on 2026-10-12. The organization has no current +`refract-labs` or `refract-demo-data` repository. Datasets live in `demo-data/`; +runnable onboarding and review examples live in `docs/examples/`. + +## Fix drift before moving files + +Engine behavior and file formats belong in the core. The public site should link +to the [review contract](./review.md) and explain how to use it, rather than maintain +a second specification. Distinguish published CLI behavior from source-build +features on every entry page. + +Keep sample revisions and their computed output together in the core. Regenerate +with the capture scripts; `bun run check:examples` checks that saved output still +matches the implementation without fetching live histories. Viewer screenshots +remain viewer assets; a screenshot is not an event fixture. + +The viewer currently accepts individual events as NDJSON, not saved +`refract-review/v1` reports. The Python adapter currently wraps `analyze` and +`export`, not review acknowledgment. Use the CLI for the review loop. Any future +review consumer must preserve page identity and coverage alongside events. + +Checking a downstream project's own unit tests cannot establish compatibility +with a changed engine contract. A meaningful compatibility check must pass +engine-produced output into the downstream parser, including empty and partial +results. See [COMPATIBILITY.md](../COMPATIBILITY.md). + +## Proposed destination: one repository + +Consolidate development into `refract`, keeping independent build and release +boundaries: + +```text +refract/ + packages/ # existing TypeScript packages and CLI + docs/ # canonical engine and file-format guides + docs/examples/ # captured revision inputs and outputs + apps/docs/ # site builder, navigation, assets, tutorial content + apps/viewer/ # browser-only file viewer, its build and tests + clients/python/ # Python package, Hatch config and tests + demo-data/ # existing datasets +``` + +This lets a flag or event change update its implementation, public instructions, +viewer contract, and Python adapter in one PR. The browser viewer remains a +separate application: the CLI explorer fetches a page history, while the viewer +opens a previously exported file without fetching that source. + +Keep the npm workspace list explicit. Moving a private application into the +repository must not add it to the published package set. Python packaging stays +under Hatch and retains the `refract` import and existing subprocess behavior. +The site may retain its npm lockfile until a separate package-manager change is +tested; repository consolidation does not require a simultaneous toolchain move. + +## Migration sequence + +1. **Move the docs site first.** Import its history under `apps/docs/`, preserving + its CC-BY-4.0 license. Map reference pages to canonical `docs/` sources; retain + tutorial and asset paths. Build and preview the complete site before changing + deployment. Preserve `/refract-docs/` URLs through an explicit redirect or + deployment arrangement, including deep links, search, sitemap, and images. +2. **Move the viewer.** Import its history under `apps/viewer/`, preserve its + AGPL-3.0 license, and keep its browser-only operation. Replace independently + maintained event lists with a checked schema fixture or type-only adapter; + do not bundle ingestion, a CLI, or source credentials into the browser. +3. **Move the Python adapter.** Import its history under `clients/python/`, + preserve its AGPL-3.0 license, and build/install the wheel in a fresh + environment. Verify Python 3.10 and the CI runtime. Document a working install + target before retiring the current Git-based installation URL. +4. **Retire old repos after the cutovers.** Add relocation notices with exact + destinations, preserve tags and history, and transfer open issues. Archive + only after installs, deployments, and links have been verified. Preserve any + required redirect-only Pages deployment. + +Each move should be a separate PR with before/after build evidence. The current +PRs propose this structure and repair existing drift; they do not migrate or +archive repositories. + +## Checks in the consolidated repository + +Use separate jobs for the core, docs, viewer, and Python adapter. Path filters +should run affected jobs; schema, CLI, and fixture changes must also run consumer +contract checks. Keep the core release gate independent of site or Python builds. + +At minimum, the contract checks should cover: + +- Every current event type can be imported by the viewer, rendered, and exported + with its original revision identifiers and text. +- The Python adapter reads captured core NDJSON and preserves event and analyzer + identity. Its CLI argument tests run against the built CLI for fixed inputs. +- Published commands in the public quickstart work with the documented release. + Source-only workflows use the built CLI and the offline sample. +- Saved review reports retain source identity, coverage, and acknowledgment + boundaries if a consumer adds review support. + +Licenses remain attached to the imported subprojects. Relocation alone does not +relicense their content. Release names, public import names, and event identifiers +remain consumer contracts throughout the migration. diff --git a/docs/review.md b/docs/review.md new file mode 100644 index 0000000..ac3edf6 --- /dev/null +++ b/docs/review.md @@ -0,0 +1,177 @@ +# Review the sources you rely on + +**What changed since I last reviewed these pages, and where can I inspect it?** + +`refract review` saves the revisions it fetched and produces a review report. +Checking for updates keeps changes pending. After inspecting a saved report, +you acknowledge its ID to move the reviewed baseline to its captured endpoint. +Edits that arrive afterward remain for the next review. + +The workflow fits a researcher or developer who already knows which MediaWiki +pages their work depends on. It observes changes to article text, citations, +and structure. You decide which changes matter. The review command's scope is +article revisions; it does not include talk-page or page-move log observations. +Use `analyze` when you need those additional observations. + +## Try the complete flow offline + +The new command requires a source build until it ships in an npm release: + +```bash +bun install --frozen-lockfile +bun run build +refract() { node packages/cli/dist/src/cli.js "$@"; } + +refract review --sample --from 1 --state-dir ./review-state --format json > review.json +jq -r '.pages[] | .entries[] | [.eventType, .before, .after] | @tsv' review.json +refract review --sample --state-dir ./review-state --event-type citation_removed +``` + +The bundled library history is **fictional**. Its dates, IDs, and URLs are +illustrative. The terminal report shows changed text; the JSON report also +retains all input revisions. Sample revision links are illustrative and are +not rendered as live source links. + +Inspect the unfiltered report before acknowledging it: + +```bash +review_report_id=$(jq -r '.reportId' review.json) +refract review --state-dir ./review-state --ack "$review_report_id" +refract review --sample --state-dir ./review-state +``` + +The final command reports `no_changes`. No wiki requests are made by `--sample` +or `--ack`. [Read the captured review](./examples/review.md), +[inspect its JSON and input revisions](./examples/review.json), or compare the +[citation-only view](./examples/review-citations.md). + +## Start a live watchlist + +Create a plain-text file containing one page title per line. Blank lines, +comments beginning with `#`, and duplicate titles are ignored: + +```text +# Sources I depend on +Bitcoin +Earth +``` + +```bash +refract review --pages-file pages.txt --state-dir ./source-review --format json > baseline.json +``` + +On the first run, each page has status `baseline_required`. This records its +current revision for inspection. It makes no observation about earlier history. +Inspect the linked source revisions, then establish that baseline: + +```bash +baseline_report_id=$(jq -r '.reportId' baseline.json) +refract review --state-dir ./source-review --ack "$baseline_report_id" +``` + +On later runs: + +```bash +refract review --pages-file pages.txt --state-dir ./source-review > changes.md +refract review --pages-file pages.txt --state-dir ./source-review --format json > changes.json +``` + +Read the report, then acknowledge its ID using the same `--state-dir`. A repeat +run retains pending changes until acknowledgment; it does not append duplicate +entries to the report. For a single page whose last reviewed revision you know, +use `review "Bitcoin" --from REVISION_ID` on its first run. `--from` chooses a +comparison baseline; it does not mark those revisions reviewed. It cannot +replace a baseline that has already been acknowledged. + +Use `--api` and the existing authentication options for another MediaWiki +instance. Source keys include the API endpoint and page title, so matching +revision IDs on different wikis do not share a review cursor. + +## Inspect changes with context + +Every entry includes the source, section, date, mechanical event type, full +before/after text, and links to both source revisions and their diff. + +```bash +refract review --pages-file pages.txt --event-type citation_removed,sentence_modified +refract review "Bitcoin" --section "History" +refract review "Bitcoin" --text "proof-of-work" +``` + +Filters affect the displayed view. The report shows how many entries are hidden. +**Acknowledgment covers the whole captured revision range**, including entries +hidden by filters. Review the unfiltered report before acknowledging it. +JSON keeps the full report in `pages` and the filtered entries in `view.pages`. + +For an application, consume the report directly: + +```bash +jq -c '.pages[] | .entries[] | select(.eventType == "citation_removed") | {entryId, source, timestamp, before, revisionLinks}' changes.json +``` + +`eventId` retains the analyzer's observation identity. `entryId` combines that +identity with the source key; use it for deduplication across multiple wikis. +`reportId` hashes the saved report, including its inputs, scope, and analyzer +version. This detects changes to that local artifact; it does not authenticate +the external source. + +## Understand coverage and recovery + +| Report field or status | Meaning | +|------------------------|---------| +| `reviewedRevisionId` | Endpoint of the last acknowledged report | +| `baselineRevisionId` | First input used for this comparison | +| `fetchedRevisionId` | Last revision captured so far | +| `latestRevisionId` | Source head observed at the start of this run | +| `pending` | Complete comparison with emitted events awaiting review | +| `no_changes` | No events emitted for the captured comparison, or no newer revision; coverage explains which | +| `incomplete` | Missing endpoints, missing parents, withheld text, unknown continuity, or a limited fetch | +| `error` | A source could not be fetched; its prior checkpoint is retained | + +The report lists requested and observed ranges, missing parent revision IDs, +unavailable text, and truncation. Refract avoids text comparisons across known +gaps or unavailable text. An incomplete report cannot be acknowledged. + +By default, a run fetches up to 200 new revisions per page. For longer pending +histories, repeat the command to resume from the saved fetched cursor. The +boundary revision is retained so its next transition is included. You can also +raise `--limit`. Missing or withheld source revisions may require restoring +source access; increasing the limit does not fill those gaps. + +A complete saved report stays available even if a later fetch fails. `--ack` +reads that artifact, never refetches, and advances only to its recorded end. +It is idempotent for that endpoint and refuses stale reports that would replace +a newer acknowledged baseline. A watchlist report containing an incomplete +page cannot be acknowledged as a whole; rerun after resolving that page, or +review a complete page separately. + +Exit status is `0` for a complete observation or acknowledgment, `2` for an +incomplete or failed page, and `1` for invalid options or state errors. Pending +changes alone do not cause a nonzero exit status. + +State defaults to `~/.refract/review`. Each state directory contains a cursor +file and saved reports with their source text. Choose a separate directory for +each independent review process. A lock prevents overlapping writers. After an +interrupted process, remove the indicated `.lock` directory only when no review +process is running, then rerun; the saved checkpoints remain available. + +## Check fit with a real review process + +The offline example and tests demonstrate behavior. They do not establish demand +or savings. Before relying on this workflow, try it with a small set of pages +you already revisit: + +1. Record what you need to notice and your prior review method. +2. Review an unfiltered report. Check whether its text and links let you inspect + a relevant change without reconstructing the revision range yourself. +3. Acknowledge it, return after more edits, and check that the next report starts + where you left off. +4. Record review time, relevant changes found, distracting entries, missing + coverage, and any custom integration code required. + +Use those observations to decide whether the workflow fits your work and which +filters or integrations would help. Refract supplies observations; your review +criteria remain yours. + +Regenerate the captured sample with `bun scripts/capture-review.ts`. Check it +without rewriting files using `bun scripts/capture-review.ts --check`. diff --git a/docs/security.md b/docs/security.md index 7466959..0966e23 100644 --- a/docs/security.md +++ b/docs/security.md @@ -4,16 +4,13 @@ CLI flags containing credentials (`--api-key`, `--api-password`) are visible in process listings. -**Recommendation:** Use environment variables instead: - -```bash -# Instead of this (visible in ps aux): -refract analyze "Page" --api-key "secret" - -# Do this: -export WIKI_API_KEY="secret" -refract analyze "Page" --api-key "$WIKI_API_KEY" -``` +Expanding an environment variable into `--api-key "$WIKI_API_KEY"` still puts +the expanded value in the process arguments. It does not prevent this exposure. +The bearer-token and basic-auth CLI options currently receive values through +arguments. For a gateway that supports the client-header method, Refract reads +`REFRACT_OAUTH_CLIENT_ID` and `REFRACT_OAUTH_CLIENT_SECRET` directly from the +environment. That method requires a compatible gateway; it is not a substitute +for bearer authentication on an arbitrary wiki. ## Local storage @@ -27,7 +24,10 @@ Refract makes outbound HTTPS requests to: - The configured MediaWiki API (`--api`) - Slack/email/webhook endpoints when notifications are configured (`--notify-*`) -All requests use HTTPS. Authentication tokens are sent as `Authorization` headers or `x-api-key` headers per provider conventions. +API transport follows the configured URL. Use an HTTPS endpoint for private +wiki credentials. Bearer and basic-auth credentials use `Authorization`; +gateway client credentials use the `X-OAuth-Client-Id` and +`X-OAuth-Client-Secret` headers. See [instance access](./usage.md#other-mediawiki-instances). ## Reporting vulnerabilities diff --git a/docs/usage.md b/docs/usage.md new file mode 100644 index 0000000..4926de7 --- /dev/null +++ b/docs/usage.md @@ -0,0 +1,119 @@ +# Choose a history range and output + +Use a page title such as `Bitcoin`. A page URL is treated as a title, not parsed. +English Wikipedia is the default source. Range selection applies to cached and +fresh runs. A fixed range is reused only when both endpoints and parent +continuity are present. Open ranges check the source; incomplete cache entries +do not widen or shorten the requested selection. Custom `--api` sources bypass +the shared SQLite cache to avoid mixing wiki identities. + +## What gets analyzed? + +| Command or option | History used | +|-------------------|--------------| +| `analyze --depth brief` with no bounds | Latest 20 revisions; change the count with `--brief-limit` | +| `analyze --depth detailed` or `forensic` with no bounds | Available history, paginated from the beginning | +| `analyze --from ID --to ID` | Revisions between the specified bounds, including the endpoints | +| `analyze --since TIMESTAMP` | Revisions from that timestamp onward | +| `claim --text TEXT` | Latest 50 revisions; change the count with `--limit` | +| `snapshot --at DATE` | Fetches revisions at or before the date and selects the closest one | +| `explore PAGE` | Available history at detailed depth; accepts `--from`, `--to`, `--since`, `--depth`, and `--cache` | +| `review PAGE` or `review --pages-file FILE` | Revisions since the last acknowledged report; the first run establishes a baseline for inspection | + +Specifying bounds on `brief` disables its default latest-20 selection; `brief` +still controls how much text appears in the events. Large histories can take +longer to fetch and analyze. Start with a pinned pair or a small brief window. + +Interactive `analyze` serves the same captured result in the explorer, including +its revision bounds and coverage. It does not refetch the history. A direct +`explore` command can use the same bounds. + +`--since` also maintains prior observation files for re-observation comparisons +under `~/.wikihistory/observations`, or the supplied `--cache-dir`. It is not a +complete capture of the execution inputs. Retain those separately if you need +independent reconstruction. + +## How much detail do you need? + +| Depth | Content events carry | Useful for | +|-------|----------------------|------------| +| `brief` | Metadata; `before` and `after` text omitted | A small recent-change overview | +| `detailed` | Matched or changed text | Inspecting wording and references | +| `forensic` | Changed text plus full before/after wikitext in deterministic facts | Examining source markup | + +The flags select extraction detail, not the importance of a change. + +## Where do results appear? + +| Command | Result | +|---------|--------| +| `init` | Readable fictional walkthrough in the terminal; no wiki access | +| `init --json` | Fictional sample events as NDJSON; no wiki access | +| `analyze PAGE` in an interactive terminal, with events | Opens the local browser explorer and keeps its server running | +| `analyze PAGE --json` | One event per stdout line; progress messages go to stderr | +| `analyze PAGE --report` | One observation report JSON object | +| `export PAGE --format ndjson` | One exported event per stdout line; pipe it to a file | +| `explore PAGE` | Browser explorer at `localhost:8899`; Ctrl+C stops the server | +| `snapshot PAGE --at DATE` | Terminal text preview and revision metadata | +| `review PAGE` | Markdown review report with saved inputs and explicit acknowledgment | +| `review PAGE --format json` | Complete saved report plus filtered view; coverage is structured | + +The new offline `init` behavior requires a source build until it ships in an npm +release. [The recipe](./recipes.md#try-the-offline-walkthrough) shows how to run it. + +When stdout is redirected, `analyze` without `--json` can print a human-readable +summary. Use `--json` for data pipelines. `explore --no-open` starts the server +without opening a browser automatically. + +## What does an empty result mean? + +Coverage reports the requested and observed range, revision count, missing +parents or endpoints, unavailable text, and limits. `analyze --report` includes +structured `coverage`; NDJSON keeps native event records and prints coverage to +stderr. The explorer displays coverage and exposes it at `/api/coverage`. + +No emitted events means the enabled rules emitted none for the captured inputs. +`insufficient` means fewer than two revisions were captured; `partial` identifies +missing inputs, and `unknown` identifies unverified parent continuity. Known gaps +and unavailable text are not compared as adjacent revisions. Check coverage +before concluding there were no changes. + +The [review workflow](./review.md) distinguishes fetched and reviewed revisions +and prevents acknowledgment of incomplete histories. + +“Claim not found” means the requested text did not match within the fetched +revisions and matching rules. Try another phrase or a wider `--limit`; it does +not establish that the statement never appeared. + +## Other MediaWiki instances + +Use the target wiki's API endpoint: + +```bash +refract analyze "Internal Policy" --api https://wiki.example.org/w/api.php --depth brief --json +``` + +For an instance that expects authentication, add the appropriate credentials: + +| Method | Setting | +|--------|---------| +| Bearer token | `--api-key TOKEN` | +| Basic auth | `--api-user USER --api-password PASSWORD` | +| Gateway client headers | `REFRACT_OAUTH_CLIENT_ID` and `REFRACT_OAUTH_CLIENT_SECRET` environment variables | + +The client-header option sends custom headers; it does not perform an OAuth +login flow. Set those variables only for the wiki that expects them. CLI +credentials can appear in process listings; see [security considerations](./security.md). + +## Versioned text you already own + +The CLI does not recover a history from an arbitrary web URL. Library consumers +can supply text snapshots as `Revision[]` and use the deterministic analyzers. +Each revision needs an ID, page identity, timestamp, content, edit summary, +size, and minor-edit flag. Optional `parentId` records its source parent, and +`contentAvailable: false` records withheld source text. If your snapshots have gaps, events describe changes +between the captured states, not every intervening edit. + +[Library integration](./integrations.md#use-a-single-library-adapter) shows the +import boundary. The [revision type](../packages/evidence-graph/src/schemas/revision.ts) +is the input contract. diff --git a/package.json b/package.json index 86bd983..9553035 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "test": "vitest run", "ci": "bun run build && biome ci packages/ && bun run typecheck && bun run check:boundaries && bun run test && bun run check:release -- --pack", "check:boundaries": "bun scripts/check-boundaries.ts", + "check:examples": "bun scripts/capture-onboarding.ts --check && bun scripts/capture-review.ts --check && bun scripts/capture-bitcoin-example.ts --check", "check:release": "bun scripts/check-release.ts", "clean": "rm -rf packages/*/dist", "postinstall": "mkdir -p node_modules/@refract-org && for pkg in packages/*/; do name=$(basename $pkg); ln -sfn ../../packages/$name node_modules/@refract-org/$name 2>/dev/null; done", diff --git a/packages/cli/src/__tests__/analysis-coverage.test.ts b/packages/cli/src/__tests__/analysis-coverage.test.ts new file mode 100644 index 0000000..ec7249d --- /dev/null +++ b/packages/cli/src/__tests__/analysis-coverage.test.ts @@ -0,0 +1,115 @@ +import type { Revision } from "@refract-org/evidence-graph"; +import { MediaWikiClient } from "@refract-org/ingestion"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../commands/cache.js", () => ({ + loadCachedRevisions: vi.fn(async () => [rev(1), rev(2)]), + loadLatestCachedTimestamp: vi.fn(async () => "2025-01-02T00:00:00Z"), + saveRevisions: vi.fn(async () => {}), +})); + +import { fetchRevisionsWithCache } from "../commands/analyze-helpers.js"; + +function rev(id: number): Revision { + return { + revId: id, + parentId: id - 1, + pageId: 1, + pageTitle: "Page", + timestamp: `2025-01-0${id}T00:00:00Z`, + content: "Text", + comment: "", + size: 4, + minor: false, + }; +} +const fetchRange = (cache = false, from?: number, to?: number) => + fetchRevisionsWithCache("Page", "detailed", from, to, undefined, cache, undefined, undefined, undefined); +describe("analysis coverage and range selection", () => { + afterEach(() => vi.restoreAllMocks()); + it("rejects invalid bounds before fetching an unintended history", async () => { + const fetch = vi.spyOn(MediaWikiClient.prototype, "fetchRevisions").mockResolvedValue([]); + await expect(fetchRange(false, Number.NaN, 3)).rejects.toThrow(/revision/i); + await expect(fetchRange(false, 4, 3)).rejects.toThrow(/before/i); + await expect( + fetchRevisionsWithCache( + "Page", + "detailed", + undefined, + undefined, + "not-a-date", + false, + undefined, + undefined, + undefined, + ), + ).rejects.toThrow(/timestamp/i); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("uses the requested range even when older revisions are cached", async () => { + const fetch = vi.spyOn(MediaWikiClient.prototype, "fetchRevisions").mockResolvedValue([rev(3), rev(4)]); + const result = await fetchRange(true, 3, 4); + expect(result.revisions.map((r) => r.revId)).toEqual([3, 4]); + expect(fetch).toHaveBeenCalledWith("Page", expect.objectContaining({ startRevId: 3, endRevId: 4 })); + expect(result.coverage.status).toBe("complete"); + }); + it("retains a single revision and reports insufficient inputs", async () => { + vi.spyOn(MediaWikiClient.prototype, "fetchRevisions").mockResolvedValue([rev(3)]); + const result = await fetchRange(); + expect(result.revisions).toHaveLength(1); + expect(result.coverage.status).toBe("insufficient"); + expect(result.coverage.observed.fromRevisionId).toBe(3); + }); + it("reports missing bounds and parent gaps", async () => { + vi.spyOn(MediaWikiClient.prototype, "fetchRevisions").mockResolvedValue([rev(3), rev(5)]); + const result = await fetchRange(false, 2, 5); + expect(result.coverage.status).toBe("partial"); + expect(result.coverage.missingRevisionIds).toEqual([2, 4]); + }); + it("preserves MediaWiki parent IDs and distinguishes withheld content from empty text", async () => { + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response( + JSON.stringify({ + query: { + pages: [ + { + pageid: 1, + title: "Page", + revisions: [ + { + revid: 3, + parentid: 2, + timestamp: "2025-01-03T00:00:00Z", + size: 0, + slots: { main: { content: "" } }, + }, + { + revid: 4, + parentid: 3, + timestamp: "2025-01-04T00:00:00Z", + size: 4, + texthidden: true, + slots: { main: {} }, + }, + ], + }, + ], + }, + }), + ), + ), + ); + try { + const client = new MediaWikiClient({ apiUrl: "https://wiki.example/api.php" }); + const revisions = await client.fetchRevisions("Page"); + expect(revisions[0]).toMatchObject({ parentId: 2, contentAvailable: true }); + expect(revisions[1]).toMatchObject({ parentId: 3, contentAvailable: false }); + } finally { + vi.unstubAllGlobals(); + } + }); +}); diff --git a/packages/cli/src/__tests__/claim-history.test.ts b/packages/cli/src/__tests__/claim-history.test.ts index db16401..342ef28 100644 --- a/packages/cli/src/__tests__/claim-history.test.ts +++ b/packages/cli/src/__tests__/claim-history.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { runClaimHistory } from "../commands/claim.js"; +import { runClaim, runClaimHistory } from "../commands/claim.js"; const API = "https://wiki.example/w/api.php"; const CLAIM = "The bridge opened to traffic in 2031."; @@ -33,6 +33,87 @@ function pageHistory(url: URL): Response { describe("claim history reads the latest revisions", () => { afterEach(() => { vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("reports absent when the latest revision removes the passage", async () => { + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response( + JSON.stringify({ + query: { + pages: [ + { + pageid: 1, + title: "Bridge", + revisions: [ + revision(4, "A completely unrelated description of the landscape."), + revision(3, CLAIM), + ], + }, + ], + }, + }), + ), + ), + ); + const result = await runClaimHistory("Bridge", CLAIM, false, API); + expect(result.status).toBe("absent"); + expect(result.latestRevisionId).toBe(4); + expect(result.latestMatch).toBe("none"); + expect(result.historySummary).toContain("Last present"); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + await runClaim("Bridge", CLAIM, false, API); + expect(log.mock.calls.flat().join("\n")).toContain("Status: absent"); + }); + + it("marks word overlap as uncertain and retains inspectable candidate text", async () => { + const candidate = "The bridge opened to traffic in 2032."; + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response( + JSON.stringify({ + query: { pages: [{ pageid: 1, title: "Bridge", revisions: [revision(4, candidate)] }] }, + }), + ), + ), + ); + const result = await runClaimHistory("Bridge", CLAIM, false, API); + expect(result.latestMatch).toBe("partial"); + expect(result.revisions[0].match).toBe("partial"); + expect(result.revisions[0].text).toBe(candidate); + expect(result.caveats.join(" ")).toContain("word overlap"); + expect(result.historySummary).not.toContain("Present continuously"); + }); + + it("does not infer absence from withheld text", async () => { + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response( + JSON.stringify({ + query: { + pages: [ + { + pageid: 1, + title: "Bridge", + revisions: [{ ...revision(4, ""), texthidden: true }, revision(3, CLAIM)], + }, + ], + }, + }), + ), + ), + ); + const result = await runClaimHistory("Bridge", CLAIM, false, API); + expect(result.status).toBe("unknown"); + expect(result.latestMatch).toBe("unavailable"); + expect(result.historySummary).not.toContain("then removed"); }); it("finds a claim present in the current text", async () => { diff --git a/packages/cli/src/__tests__/explore-range.test.ts b/packages/cli/src/__tests__/explore-range.test.ts new file mode 100644 index 0000000..500bcf9 --- /dev/null +++ b/packages/cli/src/__tests__/explore-range.test.ts @@ -0,0 +1,70 @@ +import type { Server } from "node:http"; +import type { Revision } from "@refract-org/evidence-graph"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../commands/analyze.js", () => ({ runAnalyze: vi.fn(async () => ({ events: [], revisions: [] })) })); + +import { runAnalyze } from "../commands/analyze.js"; +import { runExplore } from "../commands/explore.js"; +import { describeCoverage } from "../coverage.js"; + +let server: Server | undefined; +afterEach(async () => { + await new Promise((resolve) => (server ? server.close(() => resolve()) : resolve())); + vi.restoreAllMocks(); +}); +describe("explorer range", () => { + it("serves the exact captured analysis without fetching it again", async () => { + const revisions: Revision[] = [ + { + revId: 7, + pageId: 1, + pageTitle: "Page", + content: "Example", + timestamp: "2025-01-01T00:00:00Z", + comment: "", + size: 7, + minor: false, + }, + ]; + const coverage = describeCoverage(revisions, { fromRevisionId: 7, toRevisionId: 7 }); + const analysis = { events: [], revisions, coverage }; + server = await runExplore("Page", 0, true, undefined, undefined, undefined, false, "brief", undefined, { + analysis, + }); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("missing port"); + const root = `http://127.0.0.1:${address.port}`; + expect(await (await fetch(`${root}/api/revisions`)).json()).toEqual(revisions); + expect(await (await fetch(`${root}/api/coverage`)).json()).toEqual(coverage); + expect(await (await fetch(root)).text()).toContain("Coverage: insufficient"); + expect(runAnalyze).not.toHaveBeenCalled(); + }); + it("forwards direct explorer bounds and cache settings", async () => { + server = await runExplore( + "Page", + 0, + true, + "https://example.org/api.php", + undefined, + undefined, + true, + "detailed", + undefined, + { fromRevId: 2, toRevId: 3, cacheDir: "/tmp/example-cache" }, + ); + expect(runAnalyze).toHaveBeenCalledWith( + "Page", + "detailed", + 2, + 3, + undefined, + true, + "https://example.org/api.php", + undefined, + "/tmp/example-cache", + undefined, + undefined, + ); + }); +}); diff --git a/packages/cli/src/__tests__/mcp.test.ts b/packages/cli/src/__tests__/mcp.test.ts index 9bdfa87..89b049a 100644 --- a/packages/cli/src/__tests__/mcp.test.ts +++ b/packages/cli/src/__tests__/mcp.test.ts @@ -10,6 +10,31 @@ import { runClaimHistory } from "../commands/claim.js"; import { handleToolCall, runMcpServer, TOOLS } from "../commands/mcp.js"; describe("mcp server", () => { + it("exposes latest presence and matching uncertainty to agent consumers", async () => { + vi.mocked(runClaimHistory).mockResolvedValue({ + statement: "A passage", + page: "Page", + status: "present", + historySummary: "Matched candidate.", + latestRevisionId: 7, + latestMatch: "partial", + caveats: ["Sentence word overlap only"], + revisions: [ + { revisionId: 7, timestamp: "2025-01-01T00:00:00Z", text: "Candidate text", section: "Lead", match: "partial" }, + ], + }); + const result = await handleToolCall("get_statement_history", { + statement: "A passage", + page: "Page", + depth: "detailed", + }); + const response = JSON.parse(result.content[0].text); + expect(response.latest_match).toBe("partial"); + expect(response.latest_revision_id).toBe(7); + expect(response.caveats).toEqual(["Sentence word overlap only"]); + expect(response.revisions[0]).toMatchObject({ text: "Candidate text", match: "partial" }); + }); + it("registers get_statement_history in tools list", () => { const tool = TOOLS.find((t) => t.name === "get_statement_history"); expect(tool).toBeDefined(); diff --git a/packages/cli/src/__tests__/review-cli.test.ts b/packages/cli/src/__tests__/review-cli.test.ts new file mode 100644 index 0000000..ab5d41d --- /dev/null +++ b/packages/cli/src/__tests__/review-cli.test.ts @@ -0,0 +1,93 @@ +import { execFile } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { promisify } from "node:util"; +import { expect, it } from "vitest"; + +const execute = promisify(execFile); + +it("runs the watchlist review, filter, acknowledgment, and subsequent delta through the compiled CLI", async () => { + const dir = mkdtempSync(join(tmpdir(), "refract-review-cli-")); + const content = "The library provides free printing to every member throughout the year."; + const revisions = [1, 2].map((id) => ({ + revid: id, + parentid: id - 1, + timestamp: `2025-01-0${id}T00:00:00Z`, + size: content.length, + slots: { main: { content: id === 1 ? content : content.replace("free", "paid") } }, + })); + let requests = 0; + const server = createServer((req, res) => { + requests++; + const url = new URL(req.url ?? "/", "http://localhost"); + let selected = revisions.filter( + (r) => + r.revid >= Number(url.searchParams.get("rvstartid") || 0) && + r.revid <= Number(url.searchParams.get("rvendid") || Infinity), + ); + if (url.searchParams.get("rvdir") === "older") selected = [...selected].reverse(); + selected = selected.slice(0, Number(url.searchParams.get("rvlimit"))); + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ query: { pages: [{ pageid: 1, title: "Library", revisions: selected }] } })); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + try { + const addr = server.address(); + if (!addr || typeof addr === "string") throw new Error("port unavailable"); + const api = `http://127.0.0.1:${addr.port}/w/api.php`; + const cli = (...args: string[]) => + execute(process.execPath, [resolve("packages/cli/dist/src/cli.js"), ...args], { + env: { ...process.env, REFRACT_OAUTH_CLIENT_ID: "", REFRACT_OAUTH_CLIENT_SECRET: "" }, + }); + const watchlist = join(dir, "pages.txt"); + writeFileSync(watchlist, "# Sources\nLibrary\nLibrary\n"); + const first = JSON.parse( + ( + await cli( + "review", + "--pages-file", + watchlist, + "--api", + api, + "--state-dir", + dir, + "--from", + "1", + "--format", + "json", + "--event-type", + "sentence_modified", + ) + ).stdout, + ); + expect(first.pages).toHaveLength(1); + expect(first.view.pages[0].entries).toHaveLength(1); + expect(first.view.pages[0].entries[0].before).toBe(content); + expect(first.pages[0].coverage.status).toBe("complete"); + const count = requests; + await cli("review", "--state-dir", dir, "--ack", first.reportId); + expect(requests).toBe(count); + const quiet = JSON.parse( + (await cli("review", "Library", "--api", api, "--state-dir", dir, "--format", "json")).stdout, + ); + expect(quiet.pages[0].status).toBe("no_changes"); + revisions.push({ + ...revisions[1], + revid: 3, + parentid: 2, + timestamp: "2025-01-03T00:00:00Z", + slots: { main: { content: "The library offers a reading room with large windows." } }, + }); + const next = JSON.parse( + (await cli("review", "Library", "--api", api, "--state-dir", dir, "--format", "json")).stdout, + ); + expect(next.pages[0].reviewedRevisionId).toBe(2); + expect(next.pages[0].entries.every((e: { fromRevisionId: number }) => e.fromRevisionId === 2)).toBe(true); + await expect(cli("review", "Library", "--state-dir", dir, "--limit", "0")).rejects.toThrow(/positive integer/); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/cli/src/__tests__/review.test.ts b/packages/cli/src/__tests__/review.test.ts new file mode 100644 index 0000000..d4914c4 --- /dev/null +++ b/packages/cli/src/__tests__/review.test.ts @@ -0,0 +1,180 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { Revision } from "@refract-org/evidence-graph"; +import { MediaWikiClient } from "@refract-org/ingestion"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { acknowledgeReview, observeReview, renderReview } from "../commands/review.js"; + +const API = "https://wiki.example/w/api.php"; +const text = "Rivergate Library is open until six every weekday throughout the year."; +function rev(id: number, content = text): Revision { + return { + revId: id, + parentId: id - 1, + contentAvailable: true, + pageId: 1, + pageTitle: "Library", + timestamp: `2025-01-0${id}T00:00:00Z`, + content, + comment: "", + size: content.length, + minor: false, + }; +} +let dir: string; +let history: Revision[]; +let fetch: ReturnType; +const options = () => ({ stateDir: dir, apiUrl: API }); +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "refract-review-")); + history = [rev(1), rev(2, text.replace("six", "eight"))]; + fetch = vi.spyOn(MediaWikiClient.prototype, "fetchRevisions").mockImplementation(async (_title, opts) => { + const selected = history.filter( + (r) => (!opts?.startRevId || r.revId >= opts.startRevId) && (!opts?.endRevId || r.revId <= opts.endRevId), + ); + if (opts?.direction === "older") selected.reverse(); + return selected.slice(0, opts?.limit); + }); +}); +afterEach(() => { + vi.restoreAllMocks(); + rmSync(dir, { recursive: true, force: true }); +}); +const observe = (extra = {}) => observeReview(["Library"], { ...options(), ...extra }); +const state = () => JSON.parse(readFileSync(join(dir, "state.json"), "utf8")); + +describe("source review", () => { + it("supports an offline review and acknowledgment using the fictional history", async () => { + fetch.mockRejectedValue(new Error("offline")); + const report = await observeReview([], { stateDir: dir, sample: true, from: 1 }); + expect(report.pages[0].source.kind).toBe("fictional_sample"); + expect(report.pages[0].entries.some((e) => e.eventType === "citation_removed")).toBe(true); + expect(renderReview(report)).toContain("Fictional sample"); + expect(renderReview(report)).toContain("```text\n"); + expect(renderReview(report)).not.toContain("[Diff]("); + await acknowledgeReview(report.reportId, dir); + expect((await observeReview([], { stateDir: dir, sample: true })).pages[0].status).toBe("no_changes"); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("requires explicit acknowledgment to establish the first reviewed baseline", async () => { + const report = await observe(); + expect(report.pages[0].status).toBe("baseline_required"); + expect(Object.values(state().sources)[0]).toMatchObject({ reviewedRevisionId: null, fetchedRevisionId: 2 }); + fetch.mockClear(); + await acknowledgeReview(report.reportId, dir); + expect(fetch).not.toHaveBeenCalled(); + expect(Object.values(state().sources)[0]).toMatchObject({ reviewedRevisionId: 2 }); + }); + it("retains pending changes across repeated observations until the report is acknowledged", async () => { + const report = await observe({ from: 1 }); + const again = await observe(); + expect(again.reportId).toBe(report.reportId); + const ids = again.pages[0].entries.map((e) => e.eventId); + expect(new Set(ids).size).toBe(ids.length); + expect(again.pages[0].entries.some((e) => e.eventType === "sentence_modified")).toBe(true); + await acknowledgeReview(report.reportId, dir); + expect((await observe()).pages[0].status).toBe("no_changes"); + }); + it("acknowledges only the saved end, leaving later edits pending", async () => { + const report = await observe({ from: 1 }); + history.push(rev(3, "Rivergate Library is closed for renovation throughout the year.")); + await acknowledgeReview(report.reportId, dir); + const next = await observe(); + expect(next.pages[0].reviewedRevisionId).toBe(2); + expect(next.pages[0].entries.every((e) => e.fromRevisionId >= 2)).toBe(true); + }); + it("blocks concurrent state writers and retains the prior checkpoint", async () => { + await observe({ from: 1 }); + const before = readFileSync(join(dir, "state.json"), "utf8"); + mkdirSync(join(dir, ".lock")); + await expect(observe()).rejects.toThrow(/locked/i); + expect(readFileSync(join(dir, "state.json"), "utf8")).toBe(before); + }); + it("does not acknowledge history whose parent continuity is unverified", async () => { + delete history[1].parentId; + const report = await observe({ from: 1 }); + expect(report.pages[0].status).toBe("incomplete"); + await expect(acknowledgeReview(report.reportId, dir)).rejects.toThrow(/incomplete/i); + }); + it("keeps page checkpoints when another page in the watchlist fails", async () => { + fetch.mockImplementation(async (title, opts) => { + if (title === "Missing") throw new Error("Unavailable source"); + return opts?.direction === "older" ? history.slice(-1) : history; + }); + const first = await observeReview(["Library", "Missing"], { ...options(), from: 1 }); + expect(first.pages.map((p) => p.status)).toEqual(["pending", "error"]); + await expect(acknowledgeReview(first.reportId, dir)).rejects.toThrow(/incomplete/i); + expect((await observe()).pages[0].entries).toEqual(first.pages[0].entries); + }); + + it("reports gaps and refuses to acknowledge incomplete history", async () => { + history = [rev(1), rev(3)]; + const report = await observe({ from: 1 }); + expect(report.pages[0].coverage.missingRevisionIds).toContain(2); + expect(report.pages[0].entries).toEqual([]); + await expect(acknowledgeReview(report.reportId, dir)).rejects.toThrow(/incomplete/i); + }); + it("resumes a limited fetch without losing transitions at the cursor", async () => { + history.push(rev(3, text.replace("six", "nine")), rev(4, text.replace("six", "ten"))); + const first = await observe({ from: 1, limit: 1 }); + expect(first.pages[0].coverage.truncated).toBe(true); + const second = await observe({ limit: 1 }); + expect(second.pages[0].fetchedRevisionId).toBe(3); + const third = await observe({ limit: 1 }); + expect(third.pages[0].coverage.status).toBe("complete"); + expect(third.pages[0].entries.some((e) => e.fromRevisionId === 2 && e.toRevisionId === 3)).toBe(true); + await acknowledgeReview(third.reportId, dir); + }); + it("includes review context and source links with full changed text", async () => { + const report = await observe({ from: 1 }); + const rendered = renderReview(report, { eventTypes: ["sentence_modified"] }); + expect(rendered).toContain("Before"); + expect(rendered).toContain(text); + expect(rendered).toContain("oldid=1"); + expect(rendered).toContain("diff=2"); + expect(rendered).toContain("hidden by filters"); + expect(report.pages[0].entries[0]).toMatchObject({ source: { apiUrl: API, pageTitle: "Library", pageId: 1 } }); + }); + it("does not overwrite good pending inputs when a fetch fails", async () => { + const report = await observe({ from: 1 }); + fetch.mockRejectedValueOnce(new Error("network unavailable")); + const failed = await observe(); + expect(failed.pages[0].status).toBe("error"); + await expect(acknowledgeReview(failed.reportId, dir)).rejects.toThrow(/incomplete/i); + await acknowledgeReview(report.reportId, dir); + expect((await observe()).pages[0].reviewedRevisionId).toBe(2); + }); + it("isolates the same title and revision IDs on different wikis", async () => { + const first = await observe({ from: 1 }); + const other = await observe({ apiUrl: "https://other.example/api.php", from: 1 }); + expect(first.pages[0].source.key).not.toBe(other.pages[0].source.key); + expect(first.pages[0].entries[0].entryId).not.toBe(other.pages[0].entries[0].entryId); + expect(first.pages[0].entries[0].eventId).toBe(other.pages[0].entries[0].eventId); + expect(Object.keys(state().sources)).toHaveLength(2); + }); + it("refuses stale acknowledgments instead of rewinding the reviewed cursor", async () => { + const old = await observe({ from: 1 }); + history.push(rev(3, text.replace("six", "nine"))); + const latest = await observe(); + await acknowledgeReview(latest.reportId, dir); + await expect(acknowledgeReview(old.reportId, dir)).rejects.toThrow(/stale/i); + await acknowledgeReview(latest.reportId, dir); // idempotent + }); + it("does not silently accept a modified report file", async () => { + const report = await observe({ from: 1 }); + const path = join(dir, "reports", `${report.reportId}.json`); + const changed = JSON.parse(readFileSync(path, "utf8")); + changed.pages[0].fetchedRevisionId = 900; + writeFileSync(path, JSON.stringify(changed)); + await expect(acknowledgeReview(report.reportId, dir)).rejects.toThrow(/hash/i); + }); + it("reports unavailable content without treating it as a text removal", async () => { + history[1].contentAvailable = false; + history[1].content = ""; + const report = await observe({ from: 1 }); + expect(report.pages[0].status).toBe("incomplete"); + expect(report.pages[0].entries).toEqual([]); + }); +}); diff --git a/packages/cli/src/commands/analyze-helpers.ts b/packages/cli/src/commands/analyze-helpers.ts index 5d35fd5..839630a 100644 --- a/packages/cli/src/commands/analyze-helpers.ts +++ b/packages/cli/src/commands/analyze-helpers.ts @@ -5,12 +5,14 @@ import { annotateEvents, correlateTalkRevisions, diffObservations } from "@refra import type { EvidenceEvent, Revision } from "@refract-org/evidence-graph"; import type { AuthConfig, RevisionOptions } from "@refract-org/ingestion"; import { MediaWikiClient } from "@refract-org/ingestion"; - -import { loadCachedRevisions, loadLatestCachedTimestamp, saveRevisions } from "./cache.js"; +import type { HistoryCoverage } from "../coverage.js"; +import { describeCoverage, formatCoverage } from "../coverage.js"; +import { loadCachedRevisions, saveRevisions } from "./cache.js"; export interface FetchResult { client: MediaWikiClient; revisions: Revision[]; + coverage: HistoryCoverage; } /** Create a MediaWikiClient and fetch revisions with delta-update caching. */ @@ -26,70 +28,47 @@ export async function fetchRevisionsWithCache( auth: AuthConfig | undefined, briefRevisionLimit?: number, ): Promise { + for (const id of [fromRevId, toRevId]) { + if (id !== undefined && (!Number.isSafeInteger(id) || id < 1)) + throw new Error("Revision bounds must be positive integer IDs."); + } + if (fromRevId !== undefined && toRevId !== undefined && fromRevId > toRevId) + throw new Error("The start revision must be before the end revision."); + if (fromTimestamp && !Number.isFinite(new Date(fromTimestamp).getTime())) + throw new Error("Use a valid ISO timestamp for --since."); + if (briefRevisionLimit !== undefined && (!Number.isSafeInteger(briefRevisionLimit) || briefRevisionLimit < 1)) + throw new Error("--brief-limit must be a positive integer."); const client = new MediaWikiClient(apiUrl ? { apiUrl, auth } : auth ? { auth } : undefined); console.error(`Analyzing "${pageTitle}" at depth: ${depth}...`); let revisions: Revision[] = []; - if (useCache) { - const cached = await loadCachedRevisions(pageTitle, 500, cacheDir); - if (cached.length > 0) { - console.error(`Loaded ${cached.length} revisions from cache.`); - revisions = cached; - - const latestTs = await loadLatestCachedTimestamp(pageTitle, cacheDir); - if (latestTs && !fromTimestamp && revisions.length < 500) { - const deltaOpts: RevisionOptions = { direction: "newer", start: new Date(latestTs) }; - if (toRevId) deltaOpts.endRevId = toRevId; - const newRevisions = await client.fetchRevisions(pageTitle, deltaOpts); - const uniqueNew = newRevisions.filter((r) => !revisions.some((cr) => cr.revId === r.revId)); - if (uniqueNew.length > 0) { - console.error(`Fetched ${uniqueNew.length} new revisions since ${latestTs}.`); - revisions = [...revisions, ...uniqueNew]; - await saveRevisions(uniqueNew, cacheDir); - } else { - console.error("Cache is up to date."); - } - } - } + const options: RevisionOptions = { direction: "newer" }; + if (fromTimestamp) options.start = new Date(fromTimestamp); + else if (fromRevId) options.startRevId = fromRevId; + if (toRevId) options.endRevId = toRevId; + if (depth === "brief" && !fromTimestamp && !fromRevId && !toRevId) { + options.direction = "older"; + options.limit = briefRevisionLimit ?? 20; } - - if (revisions.length === 0) { - console.error(`Fetching revisions from Wikipedia...`); - const options: RevisionOptions = { direction: "newer" }; - if (fromTimestamp) { - options.start = new Date(fromTimestamp); - console.error(`Fetching revisions since ${fromTimestamp}...`); - } else if (fromRevId) { - options.startRevId = fromRevId; - } - if (toRevId) { - options.endRevId = toRevId; - } - if (depth === "brief" && !fromTimestamp && !fromRevId && !toRevId) { - // The latest N, newest first (sorted oldest-first below). With "newer" - // this read the page's first N edits, so a brief look at a live page - // reported its history from the year it was created. - options.direction = "older"; - options.limit = briefRevisionLimit ?? 20; - } - revisions = await client.fetchRevisions(pageTitle, options); - console.error(`Fetched ${revisions.length} revisions.`); - - if (useCache && revisions.length > 0) { - await saveRevisions(revisions, cacheDir); - console.error(`Cached ${revisions.length} revisions.`); - } + const request = { fromRevisionId: fromRevId, toRevisionId: toRevId, since: fromTimestamp, limit: options.limit }; + // A fixed, continuous range can be reused. Open ranges always check the source. + if (useCache && !apiUrl && fromRevId && toRevId && !fromTimestamp) { + const cached = (await loadCachedRevisions(pageTitle, 500, cacheDir)) + .filter((r) => r.revId >= fromRevId && r.revId <= toRevId) + .sort((a, b) => a.revId - b.revId); + if (describeCoverage(cached, request).status === "complete") revisions = cached; } - - if (revisions.length < 2) { - console.error("Need at least 2 revisions to analyze."); - return { client, revisions: [] }; + if (!revisions.length) { + revisions = await client.fetchRevisions(pageTitle, options); + if (useCache && !apiUrl && revisions.length) await saveRevisions(revisions, cacheDir); } - const withTs = revisions.map((r) => ({ r, ts: new Date(r.timestamp).getTime() })); withTs.sort((a, b) => a.ts - b.ts); - return { client, revisions: withTs.map((x) => x.r) }; + const sorted = withTs.map((x) => x.r); + const coverage = describeCoverage(sorted, request); + console.error(formatCoverage(coverage)); + return { client, revisions: sorted, coverage }; } /** Correlate talk page revisions with article revisions. */ export function correlateTalkPages(sortedRevs: Revision[], talkRevs: Revision[]): EvidenceEvent[] { diff --git a/packages/cli/src/commands/analyze.ts b/packages/cli/src/commands/analyze.ts index 3235076..7e19cb0 100644 --- a/packages/cli/src/commands/analyze.ts +++ b/packages/cli/src/commands/analyze.ts @@ -18,6 +18,8 @@ import { } from "@refract-org/evidence-graph"; import type { AuthConfig } from "@refract-org/ingestion"; +import type { HistoryCoverage } from "../coverage.js"; +import { contiguousRevisionGroups } from "../coverage.js"; import { REFRACT_VERSION } from "../version.js"; import { correlateTalkPages, fetchRevisionsWithCache, finalizeEvents } from "./analyze-helpers.js"; @@ -170,12 +172,16 @@ export async function runAnalyze( cacheDir?: string, auth?: AuthConfig, config?: AnalyzerConfig, -): Promise<{ events: EvidenceEvent[]; revisions: Revision[] }> { +): Promise<{ events: EvidenceEvent[]; revisions: Revision[]; coverage?: HistoryCoverage }> { if (pagesFile) { return runBatch(pagesFile, depth, fromRevId, toRevId, fromTimestamp, useCache, apiUrl, cacheDir, auth, config); } - const { client, revisions: sortedRevs } = await fetchRevisionsWithCache( + const { + client, + revisions: sortedRevs, + coverage, + } = await fetchRevisionsWithCache( pageTitle, depth, fromRevId, @@ -187,7 +193,7 @@ export async function runAnalyze( auth, config?.briefRevisionLimit, ); - if (sortedRevs.length === 0) return { events: [], revisions: [] }; + if (sortedRevs.length < 2) return { events: [], revisions: sortedRevs, coverage }; const events: EvidenceEvent[] = []; @@ -215,17 +221,19 @@ export async function runAnalyze( // Any other --depth value has always run as detailed. const eventDepth: RevisionEventDepth = depth === "brief" || depth === "forensic" ? depth : "detailed"; events.push( - ...buildRevisionEvents(sortedRevs, { - depth: eventDepth, - similarityThreshold: config?.section?.similarityThreshold ?? 0.8, - protectionLogs, - }), + ...contiguousRevisionGroups(sortedRevs).flatMap((group) => + buildRevisionEvents(group, { + depth: eventDepth, + similarityThreshold: config?.section?.similarityThreshold ?? 0.8, + protectionLogs, + }), + ), ); events.push(...correlateTalkPages(sortedRevs, talkRevs)); finalizeEvents(pageTitle, events, sortedRevs, fromTimestamp, cacheDir); - return { events, revisions: sortedRevs }; + return { events, revisions: sortedRevs, coverage }; } async function runBatch( diff --git a/packages/cli/src/commands/claim.ts b/packages/cli/src/commands/claim.ts index 2556349..9d91866 100644 --- a/packages/cli/src/commands/claim.ts +++ b/packages/cli/src/commands/claim.ts @@ -1,23 +1,28 @@ import { findSectionForText, stripWikitext } from "@refract-org/analyzers"; -import type { ClaimState, Revision } from "@refract-org/evidence-graph"; +import type { ClaimState } from "@refract-org/evidence-graph"; import { createClaimIdentity } from "@refract-org/evidence-graph"; -import type { AuthConfig, RevisionOptions } from "@refract-org/ingestion"; +import type { AuthConfig } from "@refract-org/ingestion"; import { MediaWikiClient } from "@refract-org/ingestion"; -import { loadCachedRevisions, loadLatestCachedTimestamp, saveRevisions } from "./cache.js"; +import { saveRevisions } from "./cache.js"; export interface ClaimHistoryVariant { revisionId: number; timestamp: string; text: string; section: string; + match: "exact" | "partial"; } export interface ClaimHistoryResult { statement: string; page: string; revisions: ClaimHistoryVariant[]; - status: "present" | "absent" | "modified" | "contested"; + status: "present" | "absent" | "modified" | "contested" | "unknown"; + pageId?: number; historySummary: string; + latestRevisionId?: number; + latestMatch?: "exact" | "partial" | "none" | "unavailable"; + caveats?: string[]; } export async function runClaimHistory( @@ -29,37 +34,12 @@ export async function runClaimHistory( auth?: AuthConfig, revisionLimit = 50, ): Promise { + if (!claimText.trim()) throw new Error("Supply nonempty passage text."); + if (!Number.isSafeInteger(revisionLimit) || revisionLimit < 1) throw new Error("--limit must be a positive integer."); const client = new MediaWikiClient(apiUrl ? { apiUrl, auth } : auth ? { auth } : undefined); - let revisions: Revision[] = []; - - if (useCache) { - const cached = await loadCachedRevisions(pageTitle, 500, cacheDir); - if (cached.length > 0) { - revisions = cached; - - const latestTs = await loadLatestCachedTimestamp(pageTitle, cacheDir); - if (latestTs) { - const deltaOpts: RevisionOptions = { direction: "newer", start: new Date(latestTs) }; - const newRevisions = await client.fetchRevisions(pageTitle, deltaOpts); - const uniqueNew = newRevisions.filter((r) => !revisions.some((cr) => cr.revId === r.revId)); - if (uniqueNew.length > 0) { - revisions = [...revisions, ...uniqueNew]; - await saveRevisions(uniqueNew, cacheDir); - } - } - } - } - - if (revisions.length === 0) { - // Newest first, then sorted below. "newer" with a limit and no start returns - // the page's first revisions, so a claim in the current text read as absent. - revisions = await client.fetchRevisions(pageTitle, { limit: revisionLimit, direction: "older" }); - - if (useCache && revisions.length > 0) { - await saveRevisions(revisions, cacheDir); - } - } + const revisions = await client.fetchRevisions(pageTitle, { limit: revisionLimit, direction: "older" }); + if (useCache && !apiUrl && revisions.length > 0) await saveRevisions(revisions, cacheDir); if (revisions.length === 0) { return { @@ -82,13 +62,25 @@ export async function runClaimHistory( let textChanges = 0; let removals = 0; let reintroductions = 0; + let latestMatch: "exact" | "partial" | "none" | "unavailable" = "none"; + const caveats = [ + `Matching covers ${revs.length} fetched revisions, not the page's entire history.`, + "Partial matches use sentence word overlap; they do not establish that two passages express the same statement.", + "The legacy contested label records repeated wording changes or disappearance and return, not an observed discussion dispute.", + ]; for (const rev of revs) { + if (rev.contentAvailable === false) { + latestMatch = "unavailable"; + continue; + } const plainText = stripWikitext(rev.content); - const foundText = fuzzyFindText(claimText, plainText); + const match = findPassageMatch(claimText, plainText); + const foundText = match.text; + latestMatch = match.kind; if (foundText) { - if (currentState === "absent" || currentState === "deleted" || currentState === "receding") { + if (currentState === "absent" || currentState === "receding") { if (currentState !== "absent") { reintroductions++; transitions.push(`reintroduced at ${rev.timestamp}`); @@ -97,13 +89,9 @@ export async function runClaimHistory( } currentState = "emerging"; } else if (foundText !== lastKnownText && lastKnownText !== "") { - const oldLen = lastKnownText.length; - const newLen = foundText.length; - if (Math.abs(newLen - oldLen) > oldLen * 0.2) { - currentState = "contested"; - textChanges++; - transitions.push(`text changed at ${rev.timestamp}`); - } + currentState = "contested"; + textChanges++; + transitions.push(`matched text changed at ${rev.timestamp}`); } lastKnownText = foundText; @@ -113,10 +101,11 @@ export async function runClaimHistory( timestamp: rev.timestamp, text: foundText, section, + match: match.kind as "exact" | "partial", }); } else { - if (currentState !== "absent" && currentState !== "deleted") { - currentState = lastKnownText === "" ? "deleted" : "receding"; + if (lastKnownText !== "") { + currentState = "receding"; removals++; transitions.push(`removed at ${rev.timestamp}`); lastKnownText = ""; @@ -124,20 +113,26 @@ export async function runClaimHistory( } } - const isPresentInLatest = variants.length > 0 && variants[variants.length - 1].text !== ""; + const isPresentInLatest = latestMatch !== "none"; if (variants.length === 0) { return { statement: claimText, page: pageTitle, revisions: [], - status: "absent", + status: latestMatch === "unavailable" ? "unknown" : "absent", + pageId: revs[0].pageId, + latestRevisionId: revs.at(-1)?.revId, + latestMatch, + caveats, historySummary: `Statement not found in any revision of "${pageTitle}".`, }; } let status: ClaimHistoryResult["status"]; - if (!isPresentInLatest) { + if (latestMatch === "unavailable" || latestMatch === "partial") { + status = "unknown"; + } else if (!isPresentInLatest) { status = "absent"; } else if (removals > 0 || reintroductions > 0 || textChanges > 1) { status = "contested"; @@ -151,16 +146,18 @@ export async function runClaimHistory( const latest = variants[variants.length - 1]; const parts: string[] = [ `Tracked "${claimText}" across ${revs.length} revisions.`, - `First seen at ${first.timestamp} (rev ${first.revisionId}).`, + `First matched within this selection at ${first.timestamp} (rev ${first.revisionId}).`, ]; - if (status === "absent") { + if (status === "unknown") { + parts.push("Latest passage presence is uncertain: text is unavailable or only a word-overlap candidate matched."); + } else if (status === "absent") { parts.push(`Last present at ${latest.timestamp} (rev ${latest.revisionId}), then removed.`); } else if (status === "modified") { parts.push(`Latest version at ${latest.timestamp} (rev ${latest.revisionId}) has modified wording.`); } else if (status === "contested") { parts.push(`Multiple changes detected; latest version at ${latest.timestamp} (rev ${latest.revisionId}).`); } else { - parts.push(`Present continuously from ${first.timestamp} to ${latest.timestamp}.`); + parts.push(`Matching text found in the latest fetched revision (rev ${revs.at(-1)?.revId}).`); } if (transitions.length > 0) { parts.push(transitions.join("; ")); @@ -171,7 +168,11 @@ export async function runClaimHistory( page: pageTitle, revisions: variants, status, + pageId: revs[0].pageId, historySummary: parts.join(" "), + latestRevisionId: revs.at(-1)?.revId, + latestMatch, + caveats, }; } @@ -184,128 +185,53 @@ export async function runClaim( auth?: AuthConfig, revisionLimit = 50, ): Promise { - const client = new MediaWikiClient(apiUrl ? { apiUrl, auth } : auth ? { auth } : undefined); - console.log(`Tracking claim in "${pageTitle}"...`); + const result = await runClaimHistory(pageTitle, claimText, useCache, apiUrl, cacheDir, auth, revisionLimit); + console.log(`Tracking passage in "${pageTitle}"...`); console.log(`Claim text: "${claimText}"\n`); - - let revisions: Revision[] = []; - - if (useCache) { - const cached = await loadCachedRevisions(pageTitle, 500, cacheDir); - if (cached.length > 0) { - console.log(`Loaded ${cached.length} revisions from cache.`); - revisions = cached; - - const latestTs = await loadLatestCachedTimestamp(pageTitle, cacheDir); - if (latestTs) { - const deltaOpts: RevisionOptions = { direction: "newer", start: new Date(latestTs) }; - const newRevisions = await client.fetchRevisions(pageTitle, deltaOpts); - const uniqueNew = newRevisions.filter((r) => !revisions.some((cr) => cr.revId === r.revId)); - if (uniqueNew.length > 0) { - console.log(`Fetched ${uniqueNew.length} new revisions since ${latestTs}.`); - revisions = [...revisions, ...uniqueNew]; - await saveRevisions(uniqueNew, cacheDir); - } else { - console.log("Cache is up to date."); - } - } - } - } - - if (revisions.length === 0) { - // Newest first, then sorted below. "newer" with a limit and no start returns - // the page's first revisions, so a claim in the current text read as absent. - revisions = await client.fetchRevisions(pageTitle, { limit: revisionLimit, direction: "older" }); - console.log(`Fetched ${revisions.length} revisions.\n`); - - if (useCache && revisions.length > 0) { - await saveRevisions(revisions, cacheDir); - console.log(`Cached ${revisions.length} revisions.\n`); - } + for (const variant of result.revisions) { + console.log(`[${variant.timestamp}] ${variant.match.toUpperCase()} MATCH (rev ${variant.revisionId})`); + console.log(` Matched text: "${variant.text}"`); } + const identity = createClaimIdentity({ text: claimText, section: "", pageTitle, pageId: result.pageId ?? 0 }); + console.log(`\n=== Passage History Summary ===`); + console.log(`Claim ID: ${identity.claimId}`); + console.log(`Page: ${pageTitle}`); + console.log(`Status: ${result.status}`); + console.log(`Latest revision: ${result.latestRevisionId ?? "none"}`); + console.log(`Latest match: ${result.latestMatch ?? "none"}`); + console.log(result.historySummary); + for (const caveat of result.caveats ?? []) console.log(`Note: ${caveat}`); +} - if (revisions.length === 0) { - console.log("No revisions found."); - return; - } - - const withTs = [...revisions].map((r) => ({ r, ts: new Date(r.timestamp).getTime() })); - withTs.sort((a, b) => a.ts - b.ts); - const revs = withTs.map((x) => x.r); - - const identity = createClaimIdentity({ - text: claimText, - section: "", - pageTitle, - pageId: revs[0].pageId, - }); - - const variants: Array<{ revisionId: number; text: string; section: string; observedAt: string }> = []; - let lastKnownText = ""; - let currentState: ClaimState = "absent"; - - for (const rev of revs) { - const plainText = stripWikitext(rev.content); - const foundText = fuzzyFindText(claimText, plainText); - - if (foundText) { - if (currentState === "absent") { - currentState = "emerging"; - console.log(`[${rev.timestamp}] FIRST SEEN (rev ${rev.revId})`); - console.log(` State: absent → emerging`); - console.log(` Matched text: "${foundText.slice(0, 200)}"`); - } else if (foundText !== lastKnownText && lastKnownText !== "") { - const oldLen = lastKnownText.length; - const newLen = foundText.length; - if (Math.abs(newLen - oldLen) > oldLen * 0.2) { - currentState = "contested"; - console.log(`[${rev.timestamp}] TEXT CHANGED (rev ${rev.revId})`); - console.log(` State: → contested`); - console.log(` Previous: "${lastKnownText.slice(0, 150)}"`); - console.log(` Current: "${foundText.slice(0, 150)}"`); - } - } - - lastKnownText = foundText; - const section = findSectionForText(rev.content, foundText); - variants.push({ - revisionId: rev.revId, - text: foundText, - section, - observedAt: rev.timestamp, - }); - } else { - if (currentState !== "absent" && currentState !== "deleted") { - currentState = lastKnownText === "" ? "deleted" : "receding"; - console.log(`[${rev.timestamp}] REMOVED (rev ${rev.revId})`); - console.log(` State: → ${currentState}`); - lastKnownText = ""; - } +export function findPassageMatch( + claimText: string, + plainText: string, +): { + kind: "exact" | "partial" | "none"; + text: string; +} { + const normalize = (text: string) => text.toLowerCase().replace(/\s+/g, " ").trim(); + const query = normalize(claimText); + if (!query) return { kind: "none", text: "" }; + const candidates = plainText.split(/(?<=[.!?])\s+|\n+/).filter(Boolean); + const exact = candidates.find((text) => normalize(text).includes(query)); + if (exact) return { kind: "exact", text: exact.trim() }; + // An exact passage can cross sentence boundaries. + if (normalize(plainText).includes(query)) return { kind: "exact", text: plainText.trim() }; + const words = [...new Set(query.split(/[^\p{L}\p{N}]+/u).filter((word) => word.length > 3))]; + let best = ""; + let overlap = 0; + for (const candidate of candidates) { + const tokens = new Set(normalize(candidate).split(/[^\p{L}\p{N}]+/u)); + const count = words.filter((word) => tokens.has(word)).length; + if (count > overlap) { + overlap = count; + best = candidate.trim(); } } - - if (variants.length === 0) { - console.log(`\nClaim "${claimText}" not found in any revision of "${pageTitle}".`); - return; - } - - if (currentState !== "absent" && currentState !== "deleted") { - currentState = "stabilizing"; - } - - console.log(`\n=== Claim Lineage Summary ===`); - console.log(`Claim ID: ${identity.claimId}`); - console.log(`Page: ${pageTitle}`); - console.log(`Variants: ${variants.length}`); - console.log(`State: ${currentState}`); - console.log(`First seen: ${variants[0].observedAt} (rev ${variants[0].revisionId})`); - console.log( - `Last seen: ${variants[variants.length - 1].observedAt} (rev ${variants[variants.length - 1].revisionId})`, - ); - console.log(); - console.log(`The Claim ID is a deterministic hash — cite it to make your finding reproducible.`); - console.log(`Next: refract analyze "${pageTitle}" --depth detailed (full event stream)`); - console.log(` refract explore "${pageTitle}" (browser UI)`); + return words.length >= 3 && overlap >= words.length * 0.7 + ? { kind: "partial", text: best } + : { kind: "none", text: "" }; } export function fuzzyFindText(claimText: string, plainText: string, preNormalized?: string): string { diff --git a/packages/cli/src/commands/explore.ts b/packages/cli/src/commands/explore.ts index 517c08a..cc92510 100644 --- a/packages/cli/src/commands/explore.ts +++ b/packages/cli/src/commands/explore.ts @@ -1,6 +1,8 @@ +import type { Server } from "node:http"; import { createServer } from "node:http"; import type { AnalyzerConfig } from "@refract-org/evidence-graph"; import type { AuthConfig } from "@refract-org/ingestion"; +import { formatCoverage } from "../coverage.js"; import { renderHtmlReport } from "../html-renderer.js"; import { runAnalyze } from "./analyze.js"; @@ -23,23 +25,36 @@ export async function runExplore( useCache?: boolean, depth?: string, since?: string, -): Promise { - const { events, revisions } = await runAnalyze( - pageTitle, - depth ?? "detailed", - undefined, - undefined, - since, - useCache ?? false, - apiUrl, - undefined, - undefined, - auth, - config, - ); + options: { + fromRevId?: number; + toRevId?: number; + cacheDir?: string; + analysis?: Awaited>; + } = {}, +): Promise { + const { events, revisions, coverage } = + options.analysis ?? + (await runAnalyze( + pageTitle, + depth ?? "detailed", + options.fromRevId, + options.toRevId, + since, + useCache ?? false, + apiUrl, + undefined, + options.cacheDir, + auth, + config, + )); const server = createServer((req, res) => { const url = new URL(req.url ?? "/", `http://localhost:${port}`); + if (url.pathname === "/api/coverage") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(coverage ?? null)); + return; + } if (url.pathname === "/api/events") { res.writeHead(200, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }); res.end(JSON.stringify(events)); @@ -50,19 +65,25 @@ export async function runExplore( res.end(JSON.stringify(revisions)); return; } - const html = renderHtmlReport(pageTitle, events, revisions, true); + const html = renderHtmlReport(pageTitle, events, revisions, true, coverage); res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); res.end(html); }); - server.listen(port, () => { - const addr = server.address(); - const actualPort = typeof addr === "object" && addr ? addr.port : port; - const url = `http://localhost:${actualPort}`; - console.log(`\n Refract Explorer running at ${url}`); - console.log(` Page: ${pageTitle}`); - console.log(` Events: ${events.length} | Revisions: ${revisions.length}`); - console.log(` Press Ctrl+C to stop\n`); - if (!noOpen) openBrowser(url); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, "127.0.0.1", () => { + const addr = server.address(); + const actualPort = typeof addr === "object" && addr ? addr.port : port; + const url = `http://localhost:${actualPort}`; + console.log(`\n Refract Explorer running at ${url}`); + console.log(` Page: ${pageTitle}`); + console.log(` Events: ${events.length} | Revisions: ${revisions.length}`); + console.log(` Press Ctrl+C to stop\n`); + if (coverage) console.log(` ${formatCoverage(coverage)}`); + if (!noOpen) openBrowser(url); + resolve(); + }); }); + return server; } diff --git a/packages/cli/src/commands/mcp.ts b/packages/cli/src/commands/mcp.ts index fbbb0e8..5df8938 100644 --- a/packages/cli/src/commands/mcp.ts +++ b/packages/cli/src/commands/mcp.ts @@ -314,6 +314,8 @@ export async function handleToolCall( revisionId: r.revisionId, timestamp: r.timestamp, section: r.section, + text: r.text, + match: r.match, })); const response = { @@ -323,6 +325,9 @@ export async function handleToolCall( revisions: trimmedRevisions, status: history.status, history_summary: history.historySummary, + latest_revision_id: history.latestRevisionId, + latest_match: history.latestMatch, + caveats: history.caveats, }; return { content: [{ type: "text", text: JSON.stringify(response, null, 2) }] }; } diff --git a/packages/cli/src/commands/review.ts b/packages/cli/src/commands/review.ts new file mode 100644 index 0000000..fc1d83d --- /dev/null +++ b/packages/cli/src/commands/review.ts @@ -0,0 +1,383 @@ +import { createHash, randomUUID } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { annotateEvents, buildRevisionEvents } from "@refract-org/analyzers"; +import type { EvidenceEvent, Revision } from "@refract-org/evidence-graph"; +import { createEventIdentity } from "@refract-org/evidence-graph"; +import type { AuthConfig } from "@refract-org/ingestion"; +import { MediaWikiClient } from "@refract-org/ingestion"; +import type { HistoryCoverage } from "../coverage.js"; +import { contiguousRevisionGroups, describeCoverage, formatCoverage } from "../coverage.js"; +import { onboardingPageTitle, onboardingRevisions } from "../onboarding-sample.js"; +import { REFRACT_VERSION } from "../version.js"; + +export interface ReviewSource { + kind: "mediawiki" | "fictional_sample"; + key: string; + apiUrl: string; + pageTitle: string; + pageId: number; +} +export interface ReviewEntry extends EvidenceEvent { + source: ReviewSource; + entryId: string; + revisionLinks: { before: string; after: string; diff: string }; +} +export interface ReviewPage { + source: ReviewSource; + reviewedRevisionId: number | null; + baselineRevisionId: number | null; + fetchedRevisionId: number | null; + latestRevisionId: number | null; + status: "baseline_required" | "pending" | "no_changes" | "incomplete" | "error"; + coverage: HistoryCoverage; + inputRevisions: Revision[]; + entries: ReviewEntry[]; + error?: string; +} +export interface ReviewReport { + format: "refract-review/v1"; + analyzerVersion: string; + depth: "detailed"; + scope: "article_revisions"; + reportId: string; + pages: ReviewPage[]; +} +interface SourceState { + reviewedRevisionId: number | null; + fetchedRevisionId: number | null; + baselineRevisionId: number | null; + pendingRevisions: Revision[]; +} +interface ReviewState { + format: "refract-review-state/v1"; + sources: Record; +} +export interface ReviewOptions { + stateDir?: string; + apiUrl?: string; + auth?: AuthConfig; + from?: number; + limit?: number; + sample?: boolean; +} +export interface ReviewFilters { + eventTypes?: string[]; + section?: string; + text?: string; +} +const DEFAULT_STATE_DIR = join(homedir(), ".refract", "review"); +const hash = (value: unknown) => createHash("sha256").update(JSON.stringify(value)).digest("hex"); + +function atomicWrite(path: string, value: unknown): void { + const temp = `${path}.${randomUUID()}.tmp`; + try { + writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600, flag: "wx" }); + renameSync(temp, path); + } finally { + rmSync(temp, { force: true }); + } +} +async function withReviewLock(dir: string, action: () => Promise): Promise { + mkdirSync(dir, { recursive: true, mode: 0o700 }); + const lock = join(dir, ".lock"); + try { + mkdirSync(lock); + } catch { + throw new Error( + `Review state is locked: ${lock}. Wait for the other run; after an interrupted run, remove this directory when no review process is running.`, + ); + } + try { + return await action(); + } finally { + rmSync(lock, { recursive: true, force: true }); + } +} +function readState(dir: string): ReviewState { + const path = join(dir, "state.json"); + if (!existsSync(path)) return { format: "refract-review-state/v1", sources: {} }; + const state = JSON.parse(readFileSync(path, "utf8")) as ReviewState; + if (state.format !== "refract-review-state/v1" || !state.sources) throw new Error("Unsupported review state format."); + return state; +} +function sourceFor(title: string, apiUrl?: string): ReviewSource { + const url = new URL(apiUrl ?? "https://en.wikipedia.org/w/api.php"); + if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash) { + throw new Error("Use an HTTP(S) API endpoint without credentials, query parameters, or a fragment."); + } + const canonical = url.href; + return { kind: "mediawiki", key: hash([canonical, title]), apiUrl: canonical, pageTitle: title, pageId: 0 }; +} +export function revisionLinks(apiUrl: string, from: number, to: number): ReviewEntry["revisionLinks"] { + const make = (params: Record) => { + const url = new URL(apiUrl); + url.pathname = url.pathname.replace(/[^/]+$/, "index.php"); + url.search = new URLSearchParams(params).toString(); + return url.href; + }; + return { + before: make({ oldid: String(from) }), + after: make({ oldid: String(to) }), + diff: make({ oldid: String(from), diff: String(to) }), + }; +} + +export async function observeReview(titles: string[], options: ReviewOptions = {}): Promise { + const dir = options.stateDir ?? DEFAULT_STATE_DIR; + const limit = options.limit ?? 200; + if (!Number.isSafeInteger(limit) || limit < 1) throw new Error("--limit must be a positive integer."); + if (options.from !== undefined && (!Number.isSafeInteger(options.from) || options.from < 1)) + throw new Error("--from must be a positive revision ID."); + if (options.sample && (titles.length || options.apiUrl || options.auth)) + throw new Error("Use --sample without page titles, --api, or authentication."); + const unique = options.sample ? [onboardingPageTitle] : [...new Set(titles.map((t) => t.trim()).filter(Boolean))]; + if (!unique.length) throw new Error("Supply a page or a nonempty --pages-file."); + return withReviewLock(dir, async () => { + const state = readState(dir); + const pages: ReviewPage[] = []; + for (const title of unique) { + const source = sourceFor(title, options.sample ? "https://example.org/w/api.php" : options.apiUrl); + if (options.sample) { + source.kind = "fictional_sample"; + source.key = hash([source.apiUrl, title, source.kind]); + } + const prior = state.sources[source.key]; + const reviewed = prior?.reviewedRevisionId ?? null; + const baseline = reviewed ?? options.from ?? prior?.baselineRevisionId ?? null; + if (reviewed !== null && options.from !== undefined && options.from !== reviewed) { + throw new Error( + "--from cannot replace an acknowledged review baseline. Use a separate --state-dir to inspect another range.", + ); + } + const page: ReviewPage = { + source, + reviewedRevisionId: reviewed, + baselineRevisionId: baseline, + fetchedRevisionId: prior?.fetchedRevisionId ?? null, + latestRevisionId: null, + status: "error", + coverage: describeCoverage([]), + inputRevisions: [], + entries: [], + }; + try { + const client = new MediaWikiClient({ apiUrl: source.apiUrl, auth: options.auth }); + const sample = onboardingRevisions.map((r) => ({ ...r, parentId: r.revId - 1, contentAvailable: true })); + const heads = options.sample + ? [sample.at(-1)] + : await client.fetchRevisions(title, { direction: "older", limit: 1 }); + const head = heads[0]; + if (!head) throw new Error("No visible revisions found for this page."); + source.pageId = head.pageId; + page.latestRevisionId = head.revId; + let revisions: Revision[]; + if (baseline === null) revisions = [head]; + else { + const pending = prior?.baselineRevisionId === baseline ? prior.pendingRevisions : []; + const pendingEnd = pending.at(-1); + const reusable = + !!pendingEnd && + pending.length && + pending[0].revId === baseline && + pending.every((r) => r.contentAvailable !== false) && + describeCoverage(pending).missingRevisionIds.length === 0 && + pending.slice(1).every((r) => r.parentId !== undefined) && + pendingEnd.revId <= head.revId; + const start = reusable && pendingEnd ? pendingEnd.revId : baseline; + const delta = + start === head.revId + ? [head] + : options.sample + ? sample.filter((r) => r.revId >= start && r.revId <= head.revId).slice(0, limit + 1) + : await client.fetchRevisions(title, { + direction: "newer", + startRevId: start, + endRevId: head.revId, + limit: limit + 1, + }); + const byId = new Map(); + if (reusable) for (const r of pending) byId.set(r.revId, r); + for (const r of delta) byId.set(r.revId, r); + revisions = [...byId.values()].sort((a, b) => a.revId - b.revId); + } + const last = revisions.at(-1); + page.inputRevisions = revisions; + page.fetchedRevisionId = last?.revId ?? null; + const truncated = !!last && last.revId !== head.revId; + page.coverage = describeCoverage( + revisions, + { fromRevisionId: baseline ?? head.revId, toRevisionId: head.revId }, + truncated, + ); + const unknown = revisions.slice(1).some((r) => r.parentId === undefined); + if (baseline === null && head.contentAvailable !== false) { + page.coverage.status = "complete"; + page.coverage.notes = ["Initial baseline only. Earlier history has not been reviewed."]; + page.status = "baseline_required"; + } else if (page.coverage.status === "partial" || unknown || !last) page.status = "incomplete"; + else if (revisions.length === 1 && revisions[0].revId === baseline && baseline === head.revId) { + page.coverage.status = "complete"; + page.coverage.notes = ["The source is still at the selected baseline; there are no newer revisions."]; + page.status = "no_changes"; + } else page.status = "pending"; + // All filters are a view over a saved, unfiltered report. + const events = annotateEvents( + contiguousRevisionGroups(revisions).flatMap((group) => buildRevisionEvents(group, { depth: "detailed" })), + ); + const entries = new Map(); + for (const event of events) { + const observationId = event.eventId ?? createEventIdentity(event); + const entryId = hash([source.key, observationId]); + entries.set(entryId, { + ...event, + eventId: observationId, + entryId, + source, + revisionLinks: revisionLinks(source.apiUrl, event.fromRevisionId, event.toRevisionId), + }); + } + page.entries = [...entries.values()]; + if (page.status === "pending" && !page.entries.length) page.status = "no_changes"; + state.sources[source.key] = { + reviewedRevisionId: reviewed, + fetchedRevisionId: page.fetchedRevisionId, + baselineRevisionId: baseline, + pendingRevisions: revisions, + }; + atomicWrite(join(dir, "state.json"), state); + } catch (error) { + page.status = "error"; + page.error = (error as Error).message; + } + pages.push(page); + } + const data = { + format: "refract-review/v1" as const, + analyzerVersion: REFRACT_VERSION, + depth: "detailed" as const, + scope: "article_revisions" as const, + pages, + }; + const report: ReviewReport = { ...data, reportId: hash(data) }; + mkdirSync(join(dir, "reports"), { recursive: true, mode: 0o700 }); + atomicWrite(join(dir, "reports", `${report.reportId}.json`), report); + return report; + }); +} + +export async function acknowledgeReview(reportId: string, stateDir = DEFAULT_STATE_DIR): Promise { + if (!/^[a-f0-9]{64}$/.test(reportId)) throw new Error("Supply the saved report's 64-character report ID."); + return withReviewLock(stateDir, async () => { + const report = JSON.parse(readFileSync(join(stateDir, "reports", `${reportId}.json`), "utf8")) as ReviewReport; + const { reportId: storedId, ...data } = report; + if (report.format !== "refract-review/v1" || storedId !== reportId || hash(data) !== reportId) + throw new Error("Review report hash mismatch."); + if ( + !report.pages.length || + report.pages.some( + (p) => + p.coverage.status !== "complete" || + p.status === "error" || + p.status === "incomplete" || + p.fetchedRevisionId === null, + ) + ) { + throw new Error("Cannot acknowledge an incomplete report. Resume the review and inspect a complete report."); + } + const state = readState(stateDir); + for (const page of report.pages) { + const current = state.sources[page.source.key]; + if (!current) throw new Error("Review source state is missing."); + if (current.reviewedRevisionId === page.fetchedRevisionId) continue; + if (current.reviewedRevisionId !== page.reviewedRevisionId) + throw new Error("This report is stale; the reviewed baseline has changed."); + const end = page.fetchedRevisionId; + if (end === null) throw new Error("Incomplete revision endpoint."); + const newerPending = current.pendingRevisions.filter((r) => r.revId >= end); + const savedEnd = page.inputRevisions.find((r) => r.revId === end); + current.reviewedRevisionId = end; + current.baselineRevisionId = end; + current.pendingRevisions = + newerPending.length && newerPending[0].revId === end ? newerPending : savedEnd ? [savedEnd] : []; + } + atomicWrite(join(stateDir, "state.json"), state); + }); +} + +export function filterReviewEntries(entries: ReviewEntry[], filters: ReviewFilters = {}): ReviewEntry[] { + return entries.filter( + (e) => + (!filters.eventTypes?.length || filters.eventTypes.includes(e.eventType)) && + (!filters.section || e.section === filters.section) && + (!filters.text || `${e.before}\n${e.after}`.toLowerCase().includes(filters.text.toLowerCase())), + ); +} +export function renderReview(report: ReviewReport, filters: ReviewFilters = {}, stateDir?: string): string { + const lines = [ + "# Source change review", + "", + `Report: ${report.reportId}`, + `Analyzer: ${report.analyzerVersion}; scope: article revisions only.`, + "", + ]; + const quote = (value: string) => { + const backticks = value.match(/`+/g) ?? []; + const fence = "`".repeat(Math.max(3, ...backticks.map((run) => run.length + 1))); + return `${fence}text\n${value}\n${fence}`; + }; + for (const page of report.pages) { + const entries = filterReviewEntries(page.entries, filters); + lines.push( + `## ${page.source.pageTitle}`, + "", + `Status: ${page.status}. Reviewed revision: ${page.reviewedRevisionId ?? "none"}; fetched: ${page.fetchedRevisionId ?? "none"}; source head: ${page.latestRevisionId ?? "unknown"}.`, + "", + formatCoverage(page.coverage), + "", + ); + if (page.error) lines.push(`Fetch failed: ${page.error}`, ""); + if (page.source.kind === "fictional_sample") + lines.push( + "Fictional sample: dates, revision IDs, and URLs are illustrative. Inspect inputRevisions in the saved JSON report.", + "", + ); + if (page.latestRevisionId && page.source.kind !== "fictional_sample") + lines.push( + `[Inspect source head](${revisionLinks(page.source.apiUrl, page.latestRevisionId, page.latestRevisionId).after})`, + "", + ); + lines.push( + `${entries.length} shown; ${page.entries.length - entries.length} hidden by filters. Acknowledgment covers all revisions and events in the unfiltered saved report.`, + "", + ); + for (const entry of entries) { + lines.push( + `### ${entry.eventType} — ${entry.section || "Lead"}`, + "", + page.source.kind === "fictional_sample" + ? `${entry.timestamp} · fictional revisions ${entry.fromRevisionId} → ${entry.toRevisionId}` + : `${entry.timestamp} · [Before](${entry.revisionLinks.before}) · [After](${entry.revisionLinks.after}) · [Diff](${entry.revisionLinks.diff})`, + "", + "Before:", + "", + quote(entry.before || "(empty)"), + "", + "After:", + "", + quote(entry.after || "(empty)"), + "", + ); + } + if (!page.entries.length && page.status === "no_changes") + lines.push("No events emitted by the enabled rules for this captured range.", ""); + } + if (report.pages.every((p) => p.coverage.status === "complete")) + lines.push( + `After inspecting the saved report: refract review${stateDir ? ` --state-dir '${stateDir.replaceAll("'", "'\"'\"'")}'` : ""} --ack ${report.reportId}`, + "", + ); + else lines.push("Review is incomplete. Run review again to resume; acknowledgment is unavailable.", ""); + return lines.join("\n"); +} diff --git a/packages/cli/src/coverage.ts b/packages/cli/src/coverage.ts new file mode 100644 index 0000000..f560324 --- /dev/null +++ b/packages/cli/src/coverage.ts @@ -0,0 +1,87 @@ +import type { Revision } from "@refract-org/evidence-graph"; + +export interface HistoryRequest { + fromRevisionId?: number; + toRevisionId?: number; + since?: string; + limit?: number; +} +export interface HistoryCoverage { + requested: HistoryRequest; + observed: { fromRevisionId: number | null; toRevisionId: number | null; revisionCount: number }; + status: "complete" | "partial" | "insufficient" | "unknown"; + missingRevisionIds: number[]; + unavailableRevisionIds: number[]; + truncated: boolean; + notes: string[]; +} + +export function describeCoverage( + revisions: Revision[], + requested: HistoryRequest = {}, + truncated = false, +): HistoryCoverage { + const ids = new Set(revisions.map((r) => r.revId)); + const missing = new Set(); + for (const id of [requested.fromRevisionId, requested.toRevisionId]) { + if (id !== undefined && !ids.has(id)) missing.add(id); + } + for (let i = 1; i < revisions.length; i++) { + const parent = revisions[i].parentId; + if (parent !== undefined && parent !== revisions[i - 1].revId) missing.add(parent); + } + const unavailable = revisions.filter((r) => r.contentAvailable === false).map((r) => r.revId); + const unknown = revisions.slice(1).some((r) => r.parentId === undefined); + const notes: string[] = []; + if (requested.limit) + notes.push(`Selection is limited to ${requested.limit} revisions; it is not the entire history.`); + if (requested.since) notes.push("Transitions from before the requested timestamp are outside this selection."); + if (missing.size) notes.push("Requested endpoints or intervening parent revisions are missing."); + if (unavailable.length) notes.push("Some source text is unavailable; those transitions were not analyzed."); + if (unknown) notes.push("Parent links are unavailable for some inputs; continuity is unverified."); + if (truncated) notes.push("The fetch limit was reached before the requested end. Increase --limit and resume."); + if (revisions.length < 2) notes.push("At least two revisions are needed to compare text."); + return { + requested, + observed: { + fromRevisionId: revisions[0]?.revId ?? null, + toRevisionId: revisions.at(-1)?.revId ?? null, + revisionCount: revisions.length, + }, + status: + missing.size || unavailable.length || truncated + ? "partial" + : revisions.length < 2 + ? "insufficient" + : unknown + ? "unknown" + : "complete", + missingRevisionIds: [...missing], + unavailableRevisionIds: unavailable, + truncated, + notes, + }; +} + +export function formatCoverage(coverage: HistoryCoverage): string { + const { observed } = coverage; + return `Coverage: ${coverage.status}; ${observed.revisionCount} revisions (${observed.fromRevisionId ?? "none"} → ${observed.toRevisionId ?? "none"}).${coverage.notes.length ? ` ${coverage.notes.join(" ")}` : ""}`; +} + +/** Never compare across a known missing parent or unavailable content. */ +export function contiguousRevisionGroups(revisions: Revision[]): Revision[][] { + const groups: Revision[][] = []; + let current: Revision[] = []; + for (const revision of revisions) { + if (revision.contentAvailable === false) { + current = []; + continue; + } + if (!current.length || (revision.parentId !== undefined && revision.parentId !== current.at(-1)?.revId)) { + current = []; + groups.push(current); + } + current.push(revision); + } + return groups; +} diff --git a/packages/cli/src/html-renderer.ts b/packages/cli/src/html-renderer.ts index 272b593..3a4b6d0 100644 --- a/packages/cli/src/html-renderer.ts +++ b/packages/cli/src/html-renderer.ts @@ -5,6 +5,9 @@ import type { VerificationBundleResult, } from "@refract-org/evidence-graph"; +import type { HistoryCoverage } from "./coverage.js"; +import { formatCoverage } from "./coverage.js"; + const EVENT_COLORS: Record = { sentence_first_seen: "#4caf50", sentence_reintroduced: "#8bc34a", @@ -113,6 +116,7 @@ export function renderHtmlReport( events: EvidenceEvent[], revisions: Revision[], showJsonDownload?: boolean, + coverage?: HistoryCoverage, ): string { const sortedRevs = [...revisions].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()); const eventCounts = countBy(events, (e) => e.eventType); @@ -200,6 +204,7 @@ main { padding: 24px; max-width: 1400px; margin: 0 auto; }

${escapeHtml(pageTitle)}

${revisions.length} revisions · ${events.length} events · ${sortedRevs.length > 0 ? new Date(sortedRevs[sortedRevs.length - 1].timestamp).toLocaleDateString() : ""} +${coverage ? `

${escapeHtml(formatCoverage(coverage))}

` : ""}