diff --git a/.agents/skills/quantmind-dev/SKILL.md b/.agents/skills/quantmind-dev/SKILL.md index a05338c..4b0cda8 100644 --- a/.agents/skills/quantmind-dev/SKILL.md +++ b/.agents/skills/quantmind-dev/SKILL.md @@ -11,7 +11,9 @@ Development workflow for contributing to the QuantMind codebase. 1. Read the repository root `AGENTS.md` or `CLAUDE.md` for the stable architecture constraints and the module map. -2. Pick exactly one workflow reference below; do not load the others. +2. Read `docs/README.md` when the task adds, changes, or uses a public + operation or public-network source. +3. Pick exactly one workflow reference below; do not load the others. ## Select Workflow @@ -24,8 +26,11 @@ A feature task usually chains all three: develop → commit → pull request. ## Rules -- `bash scripts/verify.sh` is the single "shippable" gate. CI runs the same - script; run it before every push and before marking a PR ready. +- `bash scripts/verify.sh` is the deterministic offline golden gate. Run it + before every push and before marking a PR ready. +- Public-network integrations have separate bounded live component gates. + Run every applicable gate listed in `docs/README.md` when changing that + component and before publishing. - Never bypass pre-commit / pre-push hooks (`--no-verify`) unless the user explicitly authorizes it. - New features ship with a unit test and a focused example (see diff --git a/.agents/skills/quantmind-dev/references/develop-components.md b/.agents/skills/quantmind-dev/references/develop-components.md index b04a647..88ddf66 100644 --- a/.agents/skills/quantmind-dev/references/develop-components.md +++ b/.agents/skills/quantmind-dev/references/develop-components.md @@ -18,10 +18,12 @@ apply throughout. or a side effect beyond the call it wraps). No premature abstractions — extract shared code when the second real caller appears, not before. 4. **Add the unit test and the example** (sections below). -5. **Update the public surface** if needed: module `__init__.py` exports - and user-facing docs (`README.md`, `docs/`) for user-visible features. -6. **Verify**: targeted `pytest tests//` while iterating; - `bash scripts/verify.sh` before handoff. +5. **Update the public surface** if needed: package exports, the relevant + design or guide, and the catalog in `docs/README.md`. Update the root + README only when its overview or quick start changes. +6. **Verify**: run targeted tests while iterating, the offline golden gate + before handoff, and every applicable live component gate for changed + public-network integrations. ## Module Routing @@ -46,9 +48,9 @@ apply throughout. objects are `TreeKnowledge` even when a flatten card exists alongside (e.g. `Paper` vs `PaperKnowledgeCard`). -### `quantmind/configs/` — flow cfg + typed inputs +### `quantmind/configs/` — operation cfg + typed inputs -- Extend `BaseFlowCfg`; inputs are discriminated-union Pydantic types. +- Extend `BaseFlowCfg`; inputs are Pydantic models or discriminated unions. - Never `Dict[str, Any]` in signatures — model it. ### `quantmind/preprocess/` — deterministic data prep @@ -60,10 +62,11 @@ apply throughout. ### `quantmind/flows/` and `quantmind/magic.py` — apex layer -- Flows are pure `async def` functions, not classes; state passes as - arguments; side effects go through explicit hooks. -- Use the OpenAI Agents SDK directly (`Agent`, `@function_tool`, - `output_type=`); never wrap `from agents import ...` in a facade. +- Public operations are `async def` functions, not classes; state passes + as arguments and side effects are explicit. +- Semantic operations use the OpenAI Agents SDK directly (`Agent`, + `@function_tool`, `output_type=`); deterministic operations do not add an + LLM. Never wrap `from agents import ...` in a facade. - Fan-out goes through `batch_run` (bounded concurrency, error policy); `batch_run` rejects `memory=` at the signature layer by design. @@ -79,6 +82,35 @@ apply throughout. - Logger only. New general-purpose helpers need maintainer sign-off via an issue first; the default answer is "put it in the module that uses it". +## Public Operation Checklist + +A public operation is complete only when all of these agree: + +1. A stage and name consistent with `docs/design/en/operations.md`. +2. Typed input and config models, exported from `quantmind.configs`. +3. One intent-oriented `async def` operation exported from `quantmind.flows`, + with its result contract exported from the canonical owning layer. +4. Offline success and failure tests plus a magic-introspection test when the + operation follows the `(input, *, cfg)` convention. +5. One runnable common-path example under `examples//`. +6. A relevant design or guide and one row in `docs/README.md`. + +Do not add a registry solely for discovery; package exports and the component +catalog are the discovery surfaces. + +## Public-Network Source Checklist + +When adding a source to an existing operation: + +1. Update the typed source selection and the operation's direct dispatch. +2. Keep acquisition policy internal. Add a shared provider abstraction only + after a second implementation demonstrates shared behavior. +3. Add offline mocked tests for parsing, boundaries, continuation after item + failures, and completeness semantics. +4. Update the source table and design under `docs/`. +5. Add or update a bounded live verifier and GitHub workflow. Keep live + network work out of `scripts/verify.sh`. + ## Tests - Location mirrors the module: `tests//test_.py`. @@ -101,5 +133,6 @@ apply throughout. - Docstrings: English, Google style, required on public functions and models. -- User-visible behavior changes update `README.md` (usage section) and - `docs/` where applicable. +- Public behavior changes update the relevant design or guide and the catalog + in `docs/README.md`. Update the root README only for top-level positioning + or quick-start changes. diff --git a/.agents/skills/quantmind-dev/references/pull-request.md b/.agents/skills/quantmind-dev/references/pull-request.md index 8f64552..9c79ac1 100644 --- a/.agents/skills/quantmind-dev/references/pull-request.md +++ b/.agents/skills/quantmind-dev/references/pull-request.md @@ -7,14 +7,16 @@ How to open and maintain a pull request against QuantMind. 1. Branch from `master`; keep the PR small and focused — one PR equals one reviewable change (see [Google eng practices on small CLs](https://google.github.io/eng-practices/review/developer/small-cls.html)). -2. Run the canonical gate and make sure it is green: +2. Run the deterministic offline golden gate: ```bash bash scripts/verify.sh ``` - CI runs the exact same script; do not open (or mark ready) a PR with a - red local run. +3. If the change affects a public-network integration, run every applicable + live component gate listed in `docs/README.md`. + +Do not open or mark ready a PR with a red offline or applicable live gate. ## Title @@ -37,9 +39,10 @@ and make sure the body covers: if one exists. 2. **Related issue** — reference it when one exists (`Closes #NN` / `Part of #NN`). -3. **Verification performed** — state what you ran (e.g. - `bash scripts/verify.sh` green; targeted `pytest tests//`; - manual example run) so the reviewer does not have to guess. +3. **Verification performed** — state the exact offline and applicable live + commands you ran (e.g. `bash scripts/verify.sh`; targeted + `pytest tests//`; a component verifier; manual example run) so the + reviewer does not have to guess. Keep the template checklist and remove items that do not apply. diff --git a/.claude/skills/quantmind-dev/SKILL.md b/.claude/skills/quantmind-dev/SKILL.md index a05338c..4b0cda8 100644 --- a/.claude/skills/quantmind-dev/SKILL.md +++ b/.claude/skills/quantmind-dev/SKILL.md @@ -11,7 +11,9 @@ Development workflow for contributing to the QuantMind codebase. 1. Read the repository root `AGENTS.md` or `CLAUDE.md` for the stable architecture constraints and the module map. -2. Pick exactly one workflow reference below; do not load the others. +2. Read `docs/README.md` when the task adds, changes, or uses a public + operation or public-network source. +3. Pick exactly one workflow reference below; do not load the others. ## Select Workflow @@ -24,8 +26,11 @@ A feature task usually chains all three: develop → commit → pull request. ## Rules -- `bash scripts/verify.sh` is the single "shippable" gate. CI runs the same - script; run it before every push and before marking a PR ready. +- `bash scripts/verify.sh` is the deterministic offline golden gate. Run it + before every push and before marking a PR ready. +- Public-network integrations have separate bounded live component gates. + Run every applicable gate listed in `docs/README.md` when changing that + component and before publishing. - Never bypass pre-commit / pre-push hooks (`--no-verify`) unless the user explicitly authorizes it. - New features ship with a unit test and a focused example (see diff --git a/.claude/skills/quantmind-dev/references/develop-components.md b/.claude/skills/quantmind-dev/references/develop-components.md index b04a647..88ddf66 100644 --- a/.claude/skills/quantmind-dev/references/develop-components.md +++ b/.claude/skills/quantmind-dev/references/develop-components.md @@ -18,10 +18,12 @@ apply throughout. or a side effect beyond the call it wraps). No premature abstractions — extract shared code when the second real caller appears, not before. 4. **Add the unit test and the example** (sections below). -5. **Update the public surface** if needed: module `__init__.py` exports - and user-facing docs (`README.md`, `docs/`) for user-visible features. -6. **Verify**: targeted `pytest tests//` while iterating; - `bash scripts/verify.sh` before handoff. +5. **Update the public surface** if needed: package exports, the relevant + design or guide, and the catalog in `docs/README.md`. Update the root + README only when its overview or quick start changes. +6. **Verify**: run targeted tests while iterating, the offline golden gate + before handoff, and every applicable live component gate for changed + public-network integrations. ## Module Routing @@ -46,9 +48,9 @@ apply throughout. objects are `TreeKnowledge` even when a flatten card exists alongside (e.g. `Paper` vs `PaperKnowledgeCard`). -### `quantmind/configs/` — flow cfg + typed inputs +### `quantmind/configs/` — operation cfg + typed inputs -- Extend `BaseFlowCfg`; inputs are discriminated-union Pydantic types. +- Extend `BaseFlowCfg`; inputs are Pydantic models or discriminated unions. - Never `Dict[str, Any]` in signatures — model it. ### `quantmind/preprocess/` — deterministic data prep @@ -60,10 +62,11 @@ apply throughout. ### `quantmind/flows/` and `quantmind/magic.py` — apex layer -- Flows are pure `async def` functions, not classes; state passes as - arguments; side effects go through explicit hooks. -- Use the OpenAI Agents SDK directly (`Agent`, `@function_tool`, - `output_type=`); never wrap `from agents import ...` in a facade. +- Public operations are `async def` functions, not classes; state passes + as arguments and side effects are explicit. +- Semantic operations use the OpenAI Agents SDK directly (`Agent`, + `@function_tool`, `output_type=`); deterministic operations do not add an + LLM. Never wrap `from agents import ...` in a facade. - Fan-out goes through `batch_run` (bounded concurrency, error policy); `batch_run` rejects `memory=` at the signature layer by design. @@ -79,6 +82,35 @@ apply throughout. - Logger only. New general-purpose helpers need maintainer sign-off via an issue first; the default answer is "put it in the module that uses it". +## Public Operation Checklist + +A public operation is complete only when all of these agree: + +1. A stage and name consistent with `docs/design/en/operations.md`. +2. Typed input and config models, exported from `quantmind.configs`. +3. One intent-oriented `async def` operation exported from `quantmind.flows`, + with its result contract exported from the canonical owning layer. +4. Offline success and failure tests plus a magic-introspection test when the + operation follows the `(input, *, cfg)` convention. +5. One runnable common-path example under `examples//`. +6. A relevant design or guide and one row in `docs/README.md`. + +Do not add a registry solely for discovery; package exports and the component +catalog are the discovery surfaces. + +## Public-Network Source Checklist + +When adding a source to an existing operation: + +1. Update the typed source selection and the operation's direct dispatch. +2. Keep acquisition policy internal. Add a shared provider abstraction only + after a second implementation demonstrates shared behavior. +3. Add offline mocked tests for parsing, boundaries, continuation after item + failures, and completeness semantics. +4. Update the source table and design under `docs/`. +5. Add or update a bounded live verifier and GitHub workflow. Keep live + network work out of `scripts/verify.sh`. + ## Tests - Location mirrors the module: `tests//test_.py`. @@ -101,5 +133,6 @@ apply throughout. - Docstrings: English, Google style, required on public functions and models. -- User-visible behavior changes update `README.md` (usage section) and - `docs/` where applicable. +- Public behavior changes update the relevant design or guide and the catalog + in `docs/README.md`. Update the root README only for top-level positioning + or quick-start changes. diff --git a/.claude/skills/quantmind-dev/references/pull-request.md b/.claude/skills/quantmind-dev/references/pull-request.md index 8f64552..9c79ac1 100644 --- a/.claude/skills/quantmind-dev/references/pull-request.md +++ b/.claude/skills/quantmind-dev/references/pull-request.md @@ -7,14 +7,16 @@ How to open and maintain a pull request against QuantMind. 1. Branch from `master`; keep the PR small and focused — one PR equals one reviewable change (see [Google eng practices on small CLs](https://google.github.io/eng-practices/review/developer/small-cls.html)). -2. Run the canonical gate and make sure it is green: +2. Run the deterministic offline golden gate: ```bash bash scripts/verify.sh ``` - CI runs the exact same script; do not open (or mark ready) a PR with a - red local run. +3. If the change affects a public-network integration, run every applicable + live component gate listed in `docs/README.md`. + +Do not open or mark ready a PR with a red offline or applicable live gate. ## Title @@ -37,9 +39,10 @@ and make sure the body covers: if one exists. 2. **Related issue** — reference it when one exists (`Closes #NN` / `Part of #NN`). -3. **Verification performed** — state what you ran (e.g. - `bash scripts/verify.sh` green; targeted `pytest tests//`; - manual example run) so the reviewer does not have to guess. +3. **Verification performed** — state the exact offline and applicable live + commands you ran (e.g. `bash scripts/verify.sh`; targeted + `pytest tests//`; a component verifier; manual example run) so the + reviewer does not have to guess. Keep the template checklist and remove items that do not apply. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index b21e545..a6e8f58 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,14 +1,20 @@ -## Description +## Summary - + -## Checklist +## Related Issue + + + +## Verification -Please feel free to remove inapplicable items for your PR. + + +## Checklist -- [ ] The PR title starts with `$CATEGORY(xx): xxx` (such as `feat(tool): xxx`, `fix(source): xxx`, `docs(README): xxx`) -- [ ] Related issue is referred in this PR -- [ ] The markdown and latex are rendered correctly. -- [ ] The code in PR is well-documented. -- [ ] The PR is complete and small, read the [Google eng practice (CL equals to PR)](https://google.github.io/eng-practices/review/developer/small-cls.html) to understand more about small PR. +- [ ] The title uses English Conventional Commit format: `type(scope): summary`. +- [ ] The related issue or design discussion is linked when applicable. +- [ ] `bash scripts/verify.sh` passes. +- [ ] Every applicable live component gate passes, or this PR states why none applies. +- [ ] Public behavior has focused tests, an example, and documentation where applicable. +- [ ] The PR is complete, small, and contains no unrelated changes. diff --git a/.github/workflows/news-e2e.yml b/.github/workflows/news-e2e.yml new file mode 100644 index 0000000..567db07 --- /dev/null +++ b/.github/workflows/news-e2e.yml @@ -0,0 +1,44 @@ +name: news-e2e + +on: + pull_request: + branches: [master] + schedule: + - cron: "17 3 * * *" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + news-e2e: + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Install uv + uses: astral-sh/setup-uv@v3 + with: + enable-cache: true + cache-dependency-glob: pyproject.toml + + - name: Create virtual environment + run: uv venv + + - name: Install project runtime dependencies + run: uv pip install --python .venv/bin/python -e . + + - name: Run live news E2E + run: .venv/bin/python scripts/verify_news_e2e.py diff --git a/AGENTS.md b/AGENTS.md index 4a7e302..d1b0d79 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,9 +15,9 @@ handoff all come from `openai-agents`. | Module | Role | |--------|------| | `quantmind/knowledge/` | Pydantic data standard (`FlattenKnowledge` / `TreeKnowledge` / `GraphKnowledge`) — dependency leaf | -| `quantmind/configs/` | Flow cfg + typed inputs (`BaseFlowCfg`, discriminated unions) — depends only on `knowledge` | +| `quantmind/configs/` | Operation cfg + typed input models or unions (`BaseFlowCfg`, `NewsWindow`, `PaperInput`) — depends only on `knowledge` | | `quantmind/preprocess/` | Deterministic fetch / format / clean / time utilities — depends only on `utils` | -| `quantmind/flows/` | Apex layer: end-to-end pipeline functions (`paper_flow`, `batch_run`) | +| `quantmind/flows/` | Apex layer: public library operations (`paper_flow`, `collect_news`, `batch_run`) | | `quantmind/magic.py` | `resolve_magic_input`: natural language → `(input, cfg)` | | `quantmind/mind/` | Cognitive layer (memory protocol); landing via the Agents SDK migration (#71) | | `quantmind/utils/` | Logger only — keep it that way | @@ -31,14 +31,17 @@ resurrect it into master. ```bash uv venv && source .venv/bin/activate uv pip install -e ".[dev]" -bash scripts/verify.sh # canonical "is this branch shippable" check +bash scripts/verify.sh # deterministic offline golden gate +python scripts/verify_news_e2e.py # live PR Newswire component gate ``` `scripts/verify.sh` runs five fast-fail steps (`ruff format --check`, -`ruff check`, `basedpyright`, `lint-imports`, `pytest --cov`). CI runs the -exact same script, so a green local run means a green PR. Do not bypass -pre-commit / pre-push hooks unless the user explicitly authorizes it — fix -the underlying issue instead. +`ruff check`, `basedpyright`, `lint-imports`, `pytest --cov`) and must remain +network-free. Public-network integrations have separate bounded live gates; +run each applicable gate when changing that component and before publishing. +The current component catalog and commands live in `docs/README.md`. Do not +bypass pre-commit / pre-push hooks unless the user explicitly authorizes it — +fix the underlying issue instead. ## Architecture Constraints (stable) @@ -46,15 +49,19 @@ the underlying issue instead. no plugin registries, no hook discovery, no CLI. 2. **Do not rebuild the agent runtime** — use `openai-agents` directly; no QuantMind-side facades over `from agents import ...`. -3. **Pydantic at boundaries, frozen dataclass internally** — Pydantic - (`frozen=True`, `extra="forbid"`) for anything exposed to an LLM or a - user; frozen dataclasses for internal value types. +3. **Schema models vs runtime evidence** — user/LLM inputs and configs use + extra-forbid Pydantic models; knowledge adds `frozen=True`; deterministic + fetch, preprocessing, and collection values use frozen dataclasses when + they do not need validation or JSON Schema (`Fetched`, `NewsBatch`). 4. **Import boundaries are contracts** — `import-linter` (configured in `pyproject.toml`) pins the dependency graph; never work around a failing contract. 5. **Absolute imports** across module boundaries. 6. **No meaningless wrappers** — a method must add logic, abstraction, or a side effect beyond the call it wraps; otherwise inline it. +7. **Name public operations by intent** — follow + `docs/design/en/operations.md`; use stage verbs, and reserve `pipeline` for + deliberate multi-stage composition. ## Tests and Examples @@ -63,6 +70,8 @@ A new feature ships with a unit test **and** a focused example: - Tests: `tests//`, subclass `unittest.TestCase`, mock external services, cover success and failure paths. - Examples: `examples//`, one simple usage per file. +- Public operations and sources: update the catalog in `docs/README.md` and + follow the `quantmind-dev` component checklist. ## Communication diff --git a/CLAUDE.md b/CLAUDE.md index 477b577..b5c66d2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,9 +16,9 @@ handoff all come from `openai-agents`. | Module | Role | |--------|------| | `quantmind/knowledge/` | Pydantic data standard (`FlattenKnowledge` / `TreeKnowledge` / `GraphKnowledge`) — dependency leaf | -| `quantmind/configs/` | Flow cfg + typed inputs (`BaseFlowCfg`, discriminated unions) — depends only on `knowledge` | +| `quantmind/configs/` | Operation cfg + typed input models or unions (`BaseFlowCfg`, `NewsWindow`, `PaperInput`) — depends only on `knowledge` | | `quantmind/preprocess/` | Deterministic fetch / format / clean / time utilities — depends only on `utils` | -| `quantmind/flows/` | Apex layer: end-to-end pipeline functions (`paper_flow`, `batch_run`) | +| `quantmind/flows/` | Apex layer: public library operations (`paper_flow`, `collect_news`, `batch_run`) | | `quantmind/magic.py` | `resolve_magic_input`: natural language → `(input, cfg)` | | `quantmind/mind/` | Cognitive layer (memory protocol); landing via the Agents SDK migration (#71) | | `quantmind/utils/` | Logger only — keep it that way | @@ -32,14 +32,17 @@ resurrect it into master. ```bash uv venv && source .venv/bin/activate uv pip install -e ".[dev]" -bash scripts/verify.sh # canonical "is this branch shippable" check +bash scripts/verify.sh # deterministic offline golden gate +python scripts/verify_news_e2e.py # live PR Newswire component gate ``` `scripts/verify.sh` runs five fast-fail steps (`ruff format --check`, -`ruff check`, `basedpyright`, `lint-imports`, `pytest --cov`). CI runs the -exact same script, so a green local run means a green PR. Do not bypass -pre-commit / pre-push hooks unless the user explicitly authorizes it — fix -the underlying issue instead. +`ruff check`, `basedpyright`, `lint-imports`, `pytest --cov`) and must remain +network-free. Public-network integrations have separate bounded live gates; +run each applicable gate when changing that component and before publishing. +The current component catalog and commands live in `docs/README.md`. Do not +bypass pre-commit / pre-push hooks unless the user explicitly authorizes it — +fix the underlying issue instead. ## Architecture Constraints (stable) @@ -47,15 +50,19 @@ the underlying issue instead. no plugin registries, no hook discovery, no CLI. 2. **Do not rebuild the agent runtime** — use `openai-agents` directly; no QuantMind-side facades over `from agents import ...`. -3. **Pydantic at boundaries, frozen dataclass internally** — Pydantic - (`frozen=True`, `extra="forbid"`) for anything exposed to an LLM or a - user; frozen dataclasses for internal value types. +3. **Schema models vs runtime evidence** — user/LLM inputs and configs use + extra-forbid Pydantic models; knowledge adds `frozen=True`; deterministic + fetch, preprocessing, and collection values use frozen dataclasses when + they do not need validation or JSON Schema (`Fetched`, `NewsBatch`). 4. **Import boundaries are contracts** — `import-linter` (configured in `pyproject.toml`) pins the dependency graph; never work around a failing contract. 5. **Absolute imports** across module boundaries. 6. **No meaningless wrappers** — a method must add logic, abstraction, or a side effect beyond the call it wraps; otherwise inline it. +7. **Name public operations by intent** — follow + `docs/design/en/operations.md`; use stage verbs, and reserve `pipeline` for + deliberate multi-stage composition. ## Tests and Examples @@ -64,6 +71,8 @@ A new feature ships with a unit test **and** a focused example: - Tests: `tests//`, subclass `unittest.TestCase`, mock external services, cover success and failure paths. - Examples: `examples//`, one simple usage per file. +- Public operations and sources: update the catalog in `docs/README.md` and + follow the `quantmind-dev` component checklist. ## Communication diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e5eb4d7..c3e787a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,9 +26,7 @@ will pick these up automatically. ## ✅ Verification -`scripts/verify.sh` is the single source of truth for "is this branch -shippable". CI runs the exact same script, so a green local run means a -green PR: +`scripts/verify.sh` is the deterministic offline golden gate for every PR: ```bash bash scripts/verify.sh @@ -36,7 +34,16 @@ bash scripts/verify.sh It runs five fast-fail steps: `ruff format --check`, `ruff check`, `basedpyright`, `lint-imports`, `pytest --cov` (with a branch-coverage -floor configured in `pyproject.toml`). +floor configured in `pyproject.toml`). It stays network-free. + +Public-network integrations have separate bounded live component gates. +Run every applicable gate when changing that component and before publishing; +the current commands are listed in [`docs/README.md`](docs/README.md). For +example, PR Newswire uses: + +```bash +python scripts/verify_news_e2e.py +``` **Hooks**: the pre-commit stage runs formatting/lint and file hygiene checks; the pre-push stage runs the full `scripts/verify.sh`. If a hook @@ -58,8 +65,9 @@ pytest tests// constraints and the module map. - **Style**: Google-style docstrings, English comments, 80-char lines (enforced by ruff). -- **Types**: Pydantic models at boundaries, frozen dataclasses internally; - comprehensive type hints (`basedpyright` runs in standard mode). +- **Types**: Pydantic for inputs, configs, and knowledge schemas; frozen + dataclasses for deterministic runtime evidence; comprehensive type hints + (`basedpyright` runs in standard mode). - **Dependency boundaries**: enforced by `import-linter` (`pyproject.toml`); don't work around a failing contract. - **Tests**: required under `tests//` (mirror the module @@ -67,12 +75,15 @@ pytest tests// and failure paths. - **Examples**: one focused example under `examples//` for each new feature. +- **Public operations and sources**: update the component catalog in + `docs/README.md`; public-network sources also require a bounded live check. ## 🔄 Pull Request Process 1. **Create a feature branch** from `master`. 2. **Follow Conventional Commits**: `type(scope): description`, in English. -3. **Verify before submitting**: `bash scripts/verify.sh` must be green. +3. **Verify before submitting**: the offline golden gate and every applicable + live component gate must be green. 4. **Submit the PR** using the template — English body, reference the related issue, and state the verification you performed. 5. Keep PRs small and focused diff --git a/docs/NEWS_PIPELINE.md b/docs/NEWS_PIPELINE.md deleted file mode 100644 index 2302489..0000000 --- a/docs/NEWS_PIPELINE.md +++ /dev/null @@ -1,247 +0,0 @@ -# News Pipeline - -QuantMind provides deterministic building blocks for fetching, parsing, and -normalizing public financial news. The current wire-provider MVP supports PR -Newswire. The provider contract is extensible, but other wire services are not -built-in or verified yet. - -This guide describes the public OSS behavior implemented today. It does not -assume private feeds, credentials, persistence infrastructure, or internal -services. - -## What the Pipeline Can Do - -| Stage | Public API | Result | -| --- | --- | --- | -| HTTP acquisition | `fetch_url`, `HttpFetcher`, `FetchPolicy` | Bounded response bytes and fetch metadata | -| Feed parsing | `fetch_rss_feed`, `parse_feed` | Typed RSS 2.x or Atom items | -| Article acquisition | `feed_item_to_news_document` | Explicit feed-body or linked-article text | -| Normalization | `preprocess_news_document`, `preprocess_feed_item` | Deterministic `NewsCandidate` | -| Wire ingestion | `fetch_wire_documents` | PR Newswire documents plus non-fatal failures | - -The main wire path is: - -```text -Public RSS/Atom feed - -> shared HTTP fetcher - -> typed feed items - -> provider mapping - -> linked article fetch - -> deterministic news normalization - -> WireDocument + optional WireFetchFailure records -``` - -### HTTP robustness - -`FetchPolicy` can opt a caller into: - -- bounded retries for transport errors, HTTP 408, HTTP 429, and HTTP 5xx; -- exponential backoff with configurable jitter; -- `Retry-After` handling for HTTP 429 and HTTP 503; -- per-host concurrency limits and minimum request spacing. - -Calling `fetch_url` without a policy preserves its one-shot behavior. Reuse one -`HttpFetcher` when several requests should share a connection pool and the same -per-host rate state. - -### RSS and Atom parsing - -`fetch_rss_feed` and `parse_feed` support RSS 2.x and Atom. Each `FeedItem` -retains normalized fields, selected source metadata, and the raw XML entry. - -### Explicit body acquisition - -News feed items use an explicit body-source decision: - -- `body_source="feed"` trusts the content included in the feed and performs no - article request; -- `body_source="article"` always follows the item URL, even when the feed - contains a non-empty teaser. - -The default is `feed`. The built-in PR Newswire adapter uses `article` because -its public feed descriptions may be teasers. - -### Deterministic normalization - -`NewsCandidate` normalization currently includes: - -- Unicode, whitespace, and duplicate-line cleanup; -- canonical source URLs with common tracking parameters removed; -- UTC publication timestamps; -- stable content hashes and source deduplication keys; -- exchange-qualified ticker hints such as `NASDAQ: NVDA` or `NYSE: IBM`. - -Ticker extraction only produces hints. Instrument resolution and validation -remain downstream responsibilities. - -### Replayable wire documents - -Each successful `WireDocument` contains: - -- the raw feed entry; -- raw article HTML when an article request was used; -- source and fetch metadata; -- cleaned Markdown; -- provider identity, payload ID, canonical URL, title, publisher, publication - time, content hash, stable identity, and ticker hints. - -Individual feed or article failures do not fail the whole call. They are -returned as lightweight `WireFetchFailure` values alongside successful -documents. - -## Examples - -### Normalize an existing news document - -This path performs no network request and is useful when the caller already -has the article text. - -```python -from datetime import datetime, timezone - -from quantmind.preprocess import RawNewsDocument, preprocess_news_document - -raw = RawNewsDocument( - title="Example Corp Reports Results", - body_text=( - "Example Corp (NASDAQ: EXMPL) reported fictional quarterly revenue." - ), - source_url="https://example.com/releases/results?utm_source=rss", - publisher="Example Publisher", - published_at=datetime(2026, 7, 12, 12, 0, tzinfo=timezone.utc), - payload_id="release-123", -) - -candidate = preprocess_news_document(raw) - -print(candidate.source_url) -print(candidate.dedup_key) -print([hint.symbol for hint in candidate.ticker_hints]) -``` - -A runnable version lives at -[`examples/preprocess/01_news_pr_wire.py`](../examples/preprocess/01_news_pr_wire.py). - -### Fetch and normalize a custom RSS or Atom feed - -Choose the body source based on the feed contract. Use `article` when entries -only contain teasers. - -```python -import asyncio - -from quantmind.preprocess import ( - FetchPolicy, - HttpFetcher, - fetch_rss_feed, - preprocess_feed_item, -) - - -async def main() -> None: - policy = FetchPolicy( - max_attempts=3, - max_concurrency_per_host=2, - min_interval_seconds=0.25, - ) - async with HttpFetcher(policy=policy) as fetcher: - feed = await fetch_rss_feed( - "https://example.com/news.xml", - fetcher=fetcher, - ) - candidates = [ - await preprocess_feed_item( - item, - publisher="Example Publisher", - body_source="article", - fetcher=fetcher, - ) - for item in feed.items - ] - - print(f"candidates={len(candidates)}") - - -asyncio.run(main()) -``` - -### Fetch the public PR Newswire feed - -`fetch_wire_documents` uses one shared fetcher for the feed and article -requests. The result contains successful documents and recorded failures. - -```python -import asyncio - -from quantmind.preprocess import ( - PR_NEWSWIRE, - FetchPolicy, - WireFeedConfig, - fetch_wire_documents, -) - - -async def main() -> None: - result = await fetch_wire_documents( - WireFeedConfig( - provider=PR_NEWSWIRE, - feed_urls=( - "https://www.prnewswire.com/rss/news-releases-list.rss", - ), - fetch_policy=FetchPolicy(min_interval_seconds=0.25), - ) - ) - - print(f"documents={result.success_count}") - print(f"failures={result.failure_count}") - - if result.documents: - document = result.documents[0] - print(document.identity) - print(document.cleaned_markdown[:200]) - print(document.raw_feed_entry.content_hash) - if document.raw_article is not None: - print(document.raw_article.content_hash) - - for failure in result.failures: - print(failure.stage, failure.error_type, failure.source_url) - - -asyncio.run(main()) -``` - -A focused runnable version lives at -[`examples/preprocess/02_wire_ingestion.py`](../examples/preprocess/02_wire_ingestion.py). - -## Verification - -The normal test suite is deterministic and does not require public network -access: - -```bash -python -m pytest --no-cov tests/preprocess -bash scripts/verify.sh -``` - -The live smoke test is separate and performs real PR Newswire feed and article -requests: - -```bash -python scripts/smoke_wire.py -``` - -The smoke checklist verifies that the current public feed produces documents, -raw feed entries, raw articles, cleaned Markdown, unique identities, and no -recorded failures. - -## Current Non-Goals - -The current pipeline does not provide: - -- built-in GlobeNewswire or Business Wire adapters; -- persistent cursors, watermarks, or time-window pagination; -- scheduling or durable storage; -- a shared batch-operation base class, hooks, monitoring, or metrics; -- authenticated or private provider endpoints; -- automatic company or instrument resolution; -- downstream news-card generation. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..757ab4b --- /dev/null +++ b/docs/README.md @@ -0,0 +1,52 @@ +# QuantMind Component Catalog + +This is the discovery index for QuantMind's public operations, supported +sources, examples, design documents, and verification commands. Source-specific +acquisition mechanics remain internal unless they are intentionally documented +as a public preprocessing primitive. + +Public callable names follow the +[operation naming contract](design/en/operations.md). The runtime API serves +Python callers; coding-agent guidance lives in the repository development +harness. + +## Public Operations + +| Operation | Import | Input and config | Result | Example | Design or guide | +|---|---|---|---|---|---| +| Paper extraction | `quantmind.flows.paper_flow` | `PaperInput`, `PaperFlowCfg` | `Paper` | [README usage](../README.md#-usage-examples) | [Papers](papers.md) | +| News collection | `quantmind.flows.collect_news` | `NewsWindow`, `NewsCollectionCfg` | `NewsBatch` from `quantmind.preprocess` | [Collect news](../examples/flows/collect_news.py) | [News collection design](design/en/news.md) | +| Bounded fan-out | `quantmind.flows.batch_run` | Operation inputs and shared config | `BatchResult` | [README usage](../README.md#-usage-examples) | API docstrings | + +Import public inputs and configs from `quantmind.configs` and current public +operations from `quantmind.flows`. Import result contracts from the canonical +layer shown in the catalog. + +## Public-Network Sources + +| Source | Source selection | Operation | Live component gate | +|---|---|---|---| +| PR Newswire | `NewsWindow(source="pr-newswire", ...)` | `collect_news` | `python scripts/verify_news_e2e.py` | + +The PR Newswire gate checks the public RSS feed and a complete preceding +24-hour listing window without fetching article pages. Its GitHub workflow +runs on pull requests, daily, and on manual dispatch. + +## Verification + +Run the deterministic offline golden gate for every change: + +```bash +bash scripts/verify.sh +``` + +It covers formatting, linting, typing, import boundaries, unit tests, and +coverage, and must remain network-free. When a change affects a public-network +component, also run every applicable live gate listed above. + +## Adding a Public Operation or Source + +Use the `quantmind-dev` component workflow. A public operation is not complete +until its typed contract, package exports, offline tests, focused example, +design or guide, and catalog row agree. A public-network source additionally +needs mocked source tests plus a bounded live verifier and CI workflow. diff --git a/docs/design/en/news.md b/docs/design/en/news.md new file mode 100644 index 0000000..269a967 --- /dev/null +++ b/docs/design/en/news.md @@ -0,0 +1,252 @@ +# News Collection Design + +## Status and Scope + +This document defines the OSS contract for collecting public company news in +QuantMind. The MVP supports PR Newswire only. It is deliberately small: one +intent-oriented collection operation, one time-window input, deterministic +preprocessing, and explicit partial-failure reporting. + +Its public name follows the +[operation naming contract](operations.md): collection returns source-faithful +evidence and remains separate from semantic knowledge extraction. + +The primary requirement is that any caller can request a complete, one-shot +collection of a past time window. A daily poll is therefore not a separate +operation; it is simply a short window evaluated on a schedule. + +## Design Principles + +1. **Express intent, not acquisition mechanics.** Callers ask for news from a + source and time window. They do not select RSS, listing pages, pagination, + or article-body rules. +2. **Return honest observations.** QuantMind does not silently deduplicate + source rows. Repeated source observations remain repeated output records, + and may share the same stable identity. +3. **Make partial work inspectable.** Item failures are values in the returned + batch. Invalid inputs still raise before network work starts. +4. **Keep source policy internal.** PR Newswire discovery can change from + listing pages to another public mechanism without changing the collection + contract. +5. **Make the supported set explicit.** The MVP uses exhaustive source + dispatch. It does not expose a provider protocol or a provider registry. +6. **Separate collection from production policy.** Persistence, deduplication, + rule-based pruning, target schemas, and scheduling belong to the consuming + data pipeline. + +## Public API Contract + +Callers use one entry point: + +```python +from datetime import datetime, timezone + +from quantmind.configs import NewsCollectionCfg, NewsWindow +from quantmind.flows import collect_news + +batch = await collect_news( + NewsWindow( + source="pr-newswire", + start=datetime(2026, 7, 13, tzinfo=timezone.utc), + end=datetime(2026, 7, 14, tzinfo=timezone.utc), + ), + cfg=NewsCollectionCfg(retain_raw_html=False), +) +``` + +`NewsWindow` uses timezone-aware timestamps and the half-open interval +`[start, end)`. Both regular collection and historical backfill use this same +call. There are no separate `poll_*`, `backfill_*`, or `fetch_wire_*` public +entry points. + +`NewsCollectionCfg` retains the repository's shared `BaseFlowCfg` fields so it +works with the common typed-operation and magic-input tooling. Its only +collection-specific field is `retain_raw_html`, which controls whether fetched +article HTML bytes remain in the result. It defaults to `False`, which is the +conservative and storage-efficient behavior. The article is still fetched, +hashed, parsed, and represented by metadata; only its byte payload is +discarded. The deterministic collector does not otherwise consume the shared +model, tracing, or SDK-run fields. + +### Collection records are not knowledge + +QuantMind keeps source evidence and semantic extraction as separate contracts: + +| Operation | Result | Canonical layer | +|-----------|--------|-----------------| +| `collect_news` | Source-faithful documents, artifacts, failures, and coverage | `quantmind.preprocess` | +| future `extract_news_knowledge` | Extracted financial events | `quantmind.knowledge.News` | + +`NewsDocument` is therefore not a `KnowledgeItem`. It carries HTTP evidence, +raw bytes, parsing output, and collection status that remain useful before any +LLM or business schema is chosen. `knowledge.News` is a compact semantic event +with entities, sentiment, materiality, provenance, and an embedding view. The +two may later be composed, but neither substitutes for the other. + +## Returned Data + +`collect_news` returns a `NewsBatch` with four concepts. The collection +contracts are publicly imported from `quantmind.preprocess`: + +- `documents`: successfully collected `NewsDocument` observations; +- `failures`: lightweight `NewsFailure` records for work that could not be + completed; +- `observed_count`: the number of source rows successfully normalized into + in-window observations, before article processing; +- `complete`: whether discovery proved coverage of the requested window. + +A `NewsDocument` contains the source name, stable identity, canonical URL, +title, publisher, publication time, cleaned Markdown, content hash, ticker +hints, and two evidence artifacts: + +- a small discovery artifact representing the public source row; +- an article artifact containing fetch metadata and a content hash. Its + `bytes` field is `None` unless `retain_raw_html=True`. + +`NewsArtifact` is the common evidence shape. It records the content hash, +content type, source and resolved URLs, status, headers, and fetch time, with +an optional byte payload. + +Repeated listing rows are not removed. If two rows point to the same release, +the batch contains two observations with the same stable identity. The +consumer can use that identity for an idempotent upsert without losing what +QuantMind actually observed. + +## Collection Pipeline + +```text +NewsWindow + -> source dispatch + -> newest-to-oldest public discovery pages + -> observations inside [start, end) + -> linked article fetch + -> deterministic HTML-to-Markdown normalization + -> NewsDocument or NewsFailure + -> NewsBatch +``` + +PR Newswire discovery is based on its public news-release listing rather than +the latest-items RSS snapshot. Pages are read newest to oldest until an +observation strictly older than the requested start is seen. The strict +boundary matters because several rows with the same minute-level timestamp may +span two pages. This makes a past-day request independently replayable instead +of dependent on a previously saved cursor. + +PR Newswire exposes listing timestamps at minute precision. Scheduled callers +should therefore use minute-aligned window bounds, as the live E2E does. + +RSS remains a lower-level parser and a live component check. It is not a +high-level `NewsInput`, because feed selection is a source implementation +detail and a bounded feed snapshot cannot prove complete time-window coverage. + +The HTTP layer owns bounded retries, backoff, `Retry-After` handling, and +per-host rate limits. PR Newswire-specific URL construction and listing HTML +parsing stay in the PR Newswire source module. + +Supported source names form a closed set in `NewsWindow.source`. +`collect_news` dispatches them exhaustively, so widening the input schema +without implementing the corresponding collector fails static verification. + +## Failure and Completeness Semantics + +Configuration errors raise immediately. Examples include a naive timestamp, +an empty source, an unsupported source, or `end <= start`. + +After collection begins, recoverable failures are recorded and independent +items continue. Each `NewsFailure` identifies the source, processing stage, +URL, optional item identity, error category, and message. + +`complete=False` when any of the following is true: + +- a discovery page could not be fetched or parsed; +- discovery stopped before crossing the window start; + +Article failures remain explicit in `failures` but do not change discovery +completeness. A caller can therefore distinguish "the source window was fully +enumerated" from "every observed article was processed." It can safely persist +successful records while separately monitoring and replaying the failed +portion. An empty batch is not considered complete unless discovery has +positively crossed the requested start. + +## Responsibility Boundary + +QuantMind owns: + +- public-source discovery and article acquisition; +- deterministic normalization; +- stable identities and evidence hashes; +- honest batch counts, failure records, and completeness. + +The consuming production pipeline owns: + +- its schedule and GitHub Action invocation; +- durable storage, watermarks, and idempotent upserts; +- deduplication policy; +- rule-based company-news pruning; +- downstream schemas, enrichment, and database writes; +- shared batch hooks, metrics, and monitoring infrastructure. + +This boundary lets a separate ingestion job request the last day in one call, +apply its own rules, and write its own schema without coupling those policies +to this OSS library. + +## Verification Contract + +Verification has two intentionally separate layers. + +### Offline verification + +`bash scripts/verify.sh` runs deterministic unit tests, linting, typing, and +coverage. News tests use saved HTML/RSS fixtures and mocked HTTP responses. +They cover window validation, time boundaries, pagination, duplicate +observations, raw-byte retention, retries, partial failures, and completeness. + +### Live news E2E + +`python scripts/verify_news_e2e.py` is the canonical public-network component +check. It performs two bounded checks: + +1. fetch and parse the official PR Newswire RSS feed; +2. discover PR Newswire listing observations for the preceding 24 hours and + prove that discovery crossed the window start. + +The E2E check never fetches article pages. It prints component-level PASS/FAIL +records and a compact observation/page/failure summary, then exits non-zero if +RSS is empty or invalid, listing discovery is empty or incomplete, or a +component raises. This keeps the check lightweight while detecting public +source or parser drift. + +GitHub Actions runs this check on every pull request, once daily, and on manual +dispatch. The ordinary offline verification workflow remains network-free so +local development and unit tests stay deterministic. + +## Non-Goals and Extension Rule + +The MVP does not include GlobeNewswire, Business Wire, authenticated feeds, +continuous cursors, storage, deduplication, business materiality scoring, or a +generic batch-operation base class. + +When a second source is implemented, compare its real behavior with PR +Newswire first. Extract a shared provider interface only for behavior the two +implementations genuinely share. The public +`collect_news(NewsWindow, *, cfg)` contract should remain unchanged. + +## Adding a Public News Source + +A coding agent adding a second source follows this closed checklist: + +1. Add the source name to `NewsWindow.source`. +2. Add one private `quantmind/preprocess/.py` collector. +3. Add one explicit branch to `collect_news`; the exhaustive type check must + remain green. +4. Add fixture-based success, boundary, duplicate-observation, + partial-failure, and completeness tests for the source. +5. Add a routing test proving only the selected collector is called. +6. Update the supported-source table in `docs/README.md`, this design, and the + focused example if its common path changes. +7. Add or extend a bounded live component verifier and GitHub workflow when + the integration depends on a public network endpoint. + +Only after two real collectors expose shared behavior should a common +`Protocol` be considered. A new source must never be added only to the input +Literal: static verification is expected to reject that incomplete change. diff --git a/docs/design/en/operations.md b/docs/design/en/operations.md new file mode 100644 index 0000000..c1866b0 --- /dev/null +++ b/docs/design/en/operations.md @@ -0,0 +1,62 @@ +# Public Operation Naming + +## Scope + +This document defines how public QuantMind callables communicate intent. It is +a naming contract, not a package-layout migration. Existing names may be +changed in focused compatibility work; they are not silently renamed here. + +The runtime library API is designed for Python callers. Repository guidance +for coding agents belongs to the development harness (`AGENTS.md`, skills, +docs, fixtures, and verification), not in the runtime API description. + +## Operation Stages + +Public names use a stage verb plus the domain or result: + +| Stage | Name pattern | Transformation | +|-------|--------------|----------------| +| Collection | `collect_` | External sources to source-faithful documents and evidence | +| Knowledge extraction | `extract__knowledge` | Documents to typed `quantmind.knowledge` values | +| Index construction | `build__index` | Documents or knowledge to a retrieval index | +| Analysis | `analyze_` | Domain inputs to an analytical result | +| Generic execution | `batch_run` and similar combinators | Apply an operation without changing its domain meaning | +| Composed recipe | `__pipeline` | Compose multiple named stages into a reusable pipeline | + +`flow` is not a domain verb. Do not add new public `*_flow` names merely +because a callable performs several steps. Use a precise operation name, or a +`*_pipeline` name only when the callable deliberately composes multiple public +stages as a reusable recipe. + +## Type Names + +- Input types describe caller intent, such as `NewsWindow` or `PaperInput`. +- Config types name the domain and stage, such as `NewsCollectionCfg` or a + future `PaperExtractionCfg`. +- Result types describe returned data, such as `NewsBatch`, `Paper`, or + `PageIndex`. +- Provider names stay out of public operation names unless provider-specific + behavior is itself the public contract. + +## Current API + +- `collect_news` is a collection operation and follows this contract. +- `batch_run` is a generic execution combinator, not a news or paper stage. +- `paper_flow` is an existing semantic-extraction API with legacy naming. It + is not the naming precedent for new operations; any rename belongs in a + separate compatibility change. + +The current `quantmind.flows` package remains the apex implementation namespace +for this release. Whether it should become `operations` or be split from a +future `pipelines` package is deliberately outside this document. + +## Review Checklist + +Before adding a public callable, state: + +1. Its operation stage. +2. Its input and result contracts. +3. Whether it is one operation or a genuine multi-stage pipeline. +4. Why its verb matches the observable result. + +If those answers are unclear, settle the contract before adding a public name. diff --git a/examples/flows/collect_news.py b/examples/flows/collect_news.py new file mode 100644 index 0000000..1b02a42 --- /dev/null +++ b/examples/flows/collect_news.py @@ -0,0 +1,28 @@ +"""Collect one replayable day of PR Newswire observations.""" + +import asyncio +from datetime import datetime, timedelta, timezone + +from quantmind.configs import NewsCollectionCfg, NewsWindow +from quantmind.flows import collect_news + + +async def main() -> None: + """Collect and summarize the preceding 24-hour window.""" + end = datetime.now(timezone.utc) + batch = await collect_news( + NewsWindow( + source="pr-newswire", + start=end - timedelta(days=1), + end=end, + ), + cfg=NewsCollectionCfg(retain_raw_html=False), + ) + print( + f"observed={batch.observed_count} documents={batch.success_count} " + f"failures={batch.failure_count} complete={batch.complete}" + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/preprocess/01_news_pr_wire.py b/examples/preprocess/01_news_pr_wire.py index ec211c9..ea66e33 100644 --- a/examples/preprocess/01_news_pr_wire.py +++ b/examples/preprocess/01_news_pr_wire.py @@ -19,6 +19,6 @@ candidate = preprocess_news_document(raw) -print(candidate.dedup_key) +print(candidate.identity) print(candidate.content_hash) print([hint.symbol for hint in candidate.ticker_hints]) diff --git a/examples/preprocess/02_wire_ingestion.py b/examples/preprocess/02_wire_ingestion.py deleted file mode 100644 index dd6058c..0000000 --- a/examples/preprocess/02_wire_ingestion.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Fetch the public PR Newswire feed and print a compact summary.""" - -import asyncio - -from quantmind.preprocess import ( - PR_NEWSWIRE, - FetchPolicy, - WireFeedConfig, - fetch_wire_documents, -) - -_PR_NEWSWIRE_FEED = "https://www.prnewswire.com/rss/news-releases-list.rss" - - -async def main() -> None: - """Fetch PR Newswire and print a compact result summary.""" - result = await fetch_wire_documents( - WireFeedConfig( - provider=PR_NEWSWIRE, - feed_urls=(_PR_NEWSWIRE_FEED,), - fetch_policy=FetchPolicy(min_interval_seconds=0.25), - ) - ) - print(f"documents={result.success_count} failures={result.failure_count}") - if result.documents: - first = result.documents[0] - print(first.title) - print(first.identity) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/quantmind/configs/__init__.py b/quantmind/configs/__init__.py index 271be37..2e9843d 100644 --- a/quantmind/configs/__init__.py +++ b/quantmind/configs/__init__.py @@ -1,7 +1,7 @@ -"""quantmind.configs — flow configuration + input types. +"""quantmind.configs — public operation configuration + input types. -Each flow has a `FlowCfg` (extends `BaseFlowCfg`) and a `Input` -discriminated-union type. All cfg / input classes live here so that: +Public operations use a ``BaseFlowCfg`` subclass and a typed input model or +discriminated union. All cfg / input classes live here so that: - YAML / CLI users see a single import surface, - JSON schemas can be exported uniformly (for IDE autocomplete), @@ -10,7 +10,7 @@ from quantmind.configs.base import BaseFlowCfg, BaseInput from quantmind.configs.earnings import EarningsFlowCfg, EarningsInput -from quantmind.configs.news import NewsFlowCfg, NewsInput +from quantmind.configs.news import NewsCollectionCfg, NewsWindow from quantmind.configs.paper import PaperFlowCfg, PaperInput __all__ = [ @@ -18,8 +18,8 @@ "BaseInput", "EarningsFlowCfg", "EarningsInput", - "NewsFlowCfg", - "NewsInput", + "NewsCollectionCfg", + "NewsWindow", "PaperFlowCfg", "PaperInput", ] diff --git a/quantmind/configs/news.py b/quantmind/configs/news.py index 3421c7d..ea71cc7 100644 --- a/quantmind/configs/news.py +++ b/quantmind/configs/news.py @@ -1,41 +1,37 @@ -"""News-flow configuration + input discriminated union.""" +"""Typed input and configuration for deterministic news collection.""" -from typing import Annotated, Literal, Union +from datetime import timezone +from typing import Literal -from pydantic import Field +from pydantic import AwareDatetime, field_validator, model_validator +from typing_extensions import Self from quantmind.configs.base import BaseFlowCfg, BaseInput -class RssFeed(BaseInput): - """RSS/Atom feed URL to be polled for items.""" +class NewsWindow(BaseInput): + """A replayable source window using the half-open interval [start, end).""" - type: Literal["rss"] = "rss" - url: str + type: Literal["window"] = "window" + source: Literal["pr-newswire"] + start: AwareDatetime + end: AwareDatetime + @field_validator("start", "end") + @classmethod + def normalize_to_utc(cls, value: AwareDatetime) -> AwareDatetime: + """Normalize aware timestamps so collectors receive one timezone.""" + return value.astimezone(timezone.utc) -class HttpUrl(BaseInput): - """Single news article URL.""" + @model_validator(mode="after") + def validate_order(self) -> Self: + """Reject empty or reversed collection windows.""" + if self.end <= self.start: + raise ValueError("NewsWindow requires end to be after start") + return self - type: Literal["http"] = "http" - url: str +class NewsCollectionCfg(BaseFlowCfg): + """Collection behavior that changes the returned evidence.""" -class Headline(BaseInput): - """Inline headline text (no body fetching).""" - - type: Literal["headline"] = "headline" - text: str - - -NewsInput = Annotated[ - Union[RssFeed, HttpUrl, Headline], - Field(discriminator="type"), -] - - -class NewsFlowCfg(BaseFlowCfg): - """Knobs specific to news_flow.""" - - materiality_threshold: Literal["low", "medium", "high"] = "medium" - entities_hint: list[str] = Field(default_factory=list) + retain_raw_html: bool = False diff --git a/quantmind/flows/__init__.py b/quantmind/flows/__init__.py index 78dd9bf..6290275 100644 --- a/quantmind/flows/__init__.py +++ b/quantmind/flows/__init__.py @@ -1,8 +1,8 @@ """Apex layer — composes configs / knowledge / preprocess on the SDK. -Each flow function (``paper_flow``, future ``news_flow`` / ``earnings_flow``) -takes a typed input and a ``FlowCfg`` and returns a knowledge item. -Cross-flow utilities live alongside: +Semantic flows such as ``paper_flow`` return ``quantmind.knowledge`` values. +Deterministic operations such as ``collect_news`` return source-faithful +``quantmind.preprocess`` values. Cross-flow utilities live alongside: - ``batch_run`` runs any flow over a list of inputs with bounded concurrency and aggregated results. @@ -12,11 +12,13 @@ """ from quantmind.flows.batch import BatchResult, batch_run +from quantmind.flows.news import collect_news from quantmind.flows.paper import UnsupportedContentTypeError, paper_flow __all__ = [ "BatchResult", "UnsupportedContentTypeError", "batch_run", + "collect_news", "paper_flow", ] diff --git a/quantmind/flows/news.py b/quantmind/flows/news.py new file mode 100644 index 0000000..d8af079 --- /dev/null +++ b/quantmind/flows/news.py @@ -0,0 +1,37 @@ +"""Intent-oriented collection operation for public company news.""" + +from typing_extensions import assert_never + +from quantmind.configs import NewsCollectionCfg, NewsWindow +from quantmind.preprocess import NewsBatch +from quantmind.preprocess.pr_newswire import _collect_pr_newswire + +__all__ = ["collect_news"] + + +async def collect_news( + input: NewsWindow, + *, + cfg: NewsCollectionCfg | None = None, +) -> NewsBatch: + """Collect one replayable window without exposing source mechanics. + + Args: + input: Source and half-open time window to collect. + cfg: Options that change the returned collection evidence. + + Returns: + Successfully collected documents, recoverable failures, and whether + source discovery proved complete coverage of the requested window. + Article failures do not make discovery incomplete. + """ + cfg = cfg or NewsCollectionCfg() + source = input.source + if source == "pr-newswire": + return await _collect_pr_newswire( + start=input.start, + end=input.end, + retain_raw_html=cfg.retain_raw_html, + ) + + assert_never(source) diff --git a/quantmind/knowledge/news.py b/quantmind/knowledge/news.py index 6604daa..2bf2de9 100644 --- a/quantmind/knowledge/news.py +++ b/quantmind/knowledge/news.py @@ -1,4 +1,4 @@ -"""News knowledge schema (output of news_flow).""" +"""Semantic news-event schema for agent and LLM extraction.""" from datetime import datetime from typing import Literal @@ -9,7 +9,7 @@ class News(FlattenKnowledge): - """A single news event extraction.""" + """A single extracted news event, distinct from collection evidence.""" item_type: Literal["news"] = "news" diff --git a/quantmind/magic.py b/quantmind/magic.py index 71e522a..819b96c 100644 --- a/quantmind/magic.py +++ b/quantmind/magic.py @@ -17,7 +17,15 @@ import json import types from collections.abc import Awaitable, Callable -from typing import Any, Generic, TypeVar, Union, get_args, get_origin +from typing import ( + Any, + Generic, + TypeVar, + Union, + get_args, + get_origin, + get_type_hints, +) from agents import Agent, Runner from pydantic import BaseModel @@ -120,10 +128,10 @@ def _introspect_flow_signature( ) -> tuple[Any, type[BaseFlowCfg]]: """Return ``(input_annotation, cfg_type)`` for a flow function. - ``input_annotation`` is returned as-is — it may be a discriminated- - union alias such as ``Annotated[Union[...], Field(discriminator=...)]``. - Pydantic accepts both plain ``BaseModel`` subclasses and discriminated - aliases as generic parameters. + Annotations are resolved before inspection, including postponed string + annotations. ``input_annotation`` may be a discriminated-union alias such + as ``Annotated[Union[...], Field(discriminator=...)]``; its extra metadata + is preserved for Pydantic. ``cfg_type`` strips an outer ``T | None`` so the resolver instantiates the concrete cfg subclass. The result must be a ``BaseFlowCfg`` @@ -138,8 +146,9 @@ def _introspect_flow_signature( raise TypeError( f"Flow {flow_fn.__name__!r} must accept a `cfg` keyword parameter" ) - input_anno = sig.parameters["input"].annotation - cfg_anno = sig.parameters["cfg"].annotation + annotations = get_type_hints(flow_fn, include_extras=True) + input_anno = annotations.get("input", sig.parameters["input"].annotation) + cfg_anno = annotations.get("cfg", sig.parameters["cfg"].annotation) cfg_type = _strip_optional(cfg_anno) if not (isinstance(cfg_type, type) and issubclass(cfg_type, BaseFlowCfg)): raise TypeError( diff --git a/quantmind/preprocess/__init__.py b/quantmind/preprocess/__init__.py index f62878b..97ca7ed 100644 --- a/quantmind/preprocess/__init__.py +++ b/quantmind/preprocess/__init__.py @@ -1,5 +1,13 @@ """Preprocess layer for fetching, formatting, and source normalization.""" +from quantmind.preprocess._news_types import ( + NewsArtifact, + NewsBatch, + NewsDocument, + NewsFailure, + NewsFailureStage, + NewsTickerHint, +) from quantmind.preprocess.clean import ( collapse_whitespace, dedupe_lines, @@ -30,10 +38,9 @@ from quantmind.preprocess.news import ( BodySource, NewsCandidate, - NewsTickerHint, RawNewsDocument, - build_news_dedup_key, - build_sec_news_dedup_key, + build_news_identity, + build_sec_news_identity, canonicalize_source_url, extract_exchange_ticker_hints, feed_item_to_news_document, @@ -51,18 +58,6 @@ parse_news_datetime, to_utc, ) -from quantmind.preprocess.wire import ( - PR_NEWSWIRE, - RawWireArtifact, - WireDocument, - WireFeedConfig, - WireFetchFailure, - WireFetchResult, - WireItemMapping, - WireProvider, - build_wire_identity, - fetch_wire_documents, -) __all__ = [ "ArxivIdParseError", @@ -73,23 +68,19 @@ "FetchPolicy", "Fetched", "HttpFetcher", + "NewsArtifact", + "NewsBatch", "NewsCandidate", + "NewsDocument", + "NewsFailure", + "NewsFailureStage", "NewsTickerHint", - "PR_NEWSWIRE", "PdfParseError", "RawFeed", "RawNewsDocument", "RawPaper", - "RawWireArtifact", - "WireDocument", - "WireFeedConfig", - "WireFetchFailure", - "WireFetchResult", - "WireItemMapping", - "WireProvider", - "build_news_dedup_key", - "build_sec_news_dedup_key", - "build_wire_identity", + "build_news_identity", + "build_sec_news_identity", "business_days_between", "canonicalize_source_url", "collapse_whitespace", @@ -100,7 +91,6 @@ "fetch_news_document", "fetch_rss_feed", "fetch_url", - "fetch_wire_documents", "html_to_markdown", "news_content_hash", "news_document_from_fetched", diff --git a/quantmind/preprocess/_news_types.py b/quantmind/preprocess/_news_types.py new file mode 100644 index 0000000..d41f6c8 --- /dev/null +++ b/quantmind/preprocess/_news_types.py @@ -0,0 +1,91 @@ +"""Source-faithful news collection values, exported by ``preprocess``. + +These records carry acquisition evidence and status. They are intentionally +separate from semantic ``quantmind.knowledge.News`` values. +""" + +from dataclasses import dataclass, field +from datetime import datetime +from typing import Literal + +NewsFailureStage = Literal[ + "discovery_fetch", + "discovery_parse", + "article_fetch", + "article_parse", +] + + +@dataclass(frozen=True, slots=True) +class NewsTickerHint: + """Ticker hint extracted before instrument resolution.""" + + symbol: str + exchange: str | None = None + source: str = "exchange_code" + confidence: float = 1.0 + raw: str | None = None + + +@dataclass(frozen=True, slots=True) +class NewsArtifact: + """Raw evidence and fetch metadata retained for replay or auditing.""" + + bytes: bytes | None + content_hash: str + content_type: str + source_url: str | None + resolved_url: str | None + status_code: int | None + headers: dict[str, str] = field(default_factory=dict) + fetched_at: datetime | None = None + + +@dataclass(frozen=True, slots=True) +class NewsDocument: + """One source observation with cleaned text and its raw evidence.""" + + source: str + identity: str + cleaned_markdown: str + content_hash: str + discovery_artifact: NewsArtifact + article_artifact: NewsArtifact + payload_id: str | None = None + canonical_url: str | None = None + title: str | None = None + publisher: str | None = None + published_at: datetime | None = None + ticker_hints: tuple[NewsTickerHint, ...] = () + + +@dataclass(frozen=True, slots=True) +class NewsFailure: + """Lightweight record for one recoverable collection failure.""" + + source: str + stage: NewsFailureStage + source_url: str + item_id: str | None + error_type: str + message: str + + +@dataclass(frozen=True, slots=True) +class NewsBatch: + """Observed documents, failures, and discovery-coverage status.""" + + documents: tuple[NewsDocument, ...] = () + failures: tuple[NewsFailure, ...] = () + observed_count: int = 0 + complete: bool = False + + @property + def success_count(self) -> int: + """Number of successfully collected observations.""" + return len(self.documents) + + @property + def failure_count(self) -> int: + """Number of recorded recoverable failures.""" + return len(self.failures) diff --git a/quantmind/preprocess/news.py b/quantmind/preprocess/news.py index 352df6d..ac36346 100644 --- a/quantmind/preprocess/news.py +++ b/quantmind/preprocess/news.py @@ -7,6 +7,7 @@ from typing import Literal from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit +from quantmind.preprocess._news_types import NewsTickerHint from quantmind.preprocess.clean import ( collapse_whitespace, dedupe_lines, @@ -68,17 +69,6 @@ } -@dataclass(frozen=True, slots=True) -class NewsTickerHint: - """Ticker hint extracted before instrument resolution.""" - - symbol: str - exchange: str | None = None - source: str = "exchange_code" - confidence: float = 1.0 - raw: str | None = None - - @dataclass(frozen=True, slots=True) class RawNewsDocument: """Raw news document ready for source-agnostic preprocessing.""" @@ -100,7 +90,7 @@ class NewsCandidate: body_text: str content_hash: str source_type: NewsSourceType - dedup_key: str + identity: str source_url: str | None = None title: str | None = None publisher: str | None = None @@ -319,7 +309,7 @@ def preprocess_news_document(raw: RawNewsDocument) -> NewsCandidate: body_text=body_text, content_hash=news_content_hash(body_text), source_type=raw.source_type, - dedup_key=build_news_dedup_key( + identity=build_news_identity( source_type=raw.source_type, source_url=source_url, payload_id=raw.payload_id, @@ -345,13 +335,13 @@ def news_content_hash(text: str) -> str: return hashlib.sha256(text.encode("utf-8")).hexdigest() -def build_news_dedup_key( +def build_news_identity( *, source_type: NewsSourceType, source_url: str | None = None, payload_id: str | None = None, ) -> str: - """Build a deterministic source-document dedup key. + """Build a deterministic source-document identity. Press-release/wire rows use the payload id when available and otherwise fall back to the canonical source URL. The identity is hashed so callers do @@ -361,27 +351,27 @@ def build_news_dedup_key( if not identity and source_url: identity = canonicalize_source_url(source_url) if not identity: - raise ValueError("news dedup key requires payload_id or source_url") + raise ValueError("news identity requires payload_id or source_url") prefix = _SOURCE_PREFIX[source_type] digest = hashlib.sha256(identity.encode("utf-8")).hexdigest() return f"{prefix}:{digest}" -def build_sec_news_dedup_key( +def build_sec_news_identity( *, accession_number: str, section_key: str, ) -> str: - """Build a stable 8-K EX-99.x news dedup key.""" + """Build a stable 8-K EX-99.x news identity.""" accession = accession_number.strip() section = section_key.strip().lower() if not accession or not section: - raise ValueError("SEC news dedup key requires accession and section") + raise ValueError("SEC news identity requires accession and section") return f"sec:{accession}:{section}" def canonicalize_source_url(url: str) -> str: - """Normalise source URLs for stable dedup identity.""" + """Normalise source URLs for stable source identity.""" parsed = urlsplit(url.strip()) query = [ (key, value) diff --git a/quantmind/preprocess/pr_newswire.py b/quantmind/preprocess/pr_newswire.py new file mode 100644 index 0000000..8f0e122 --- /dev/null +++ b/quantmind/preprocess/pr_newswire.py @@ -0,0 +1,733 @@ +"""Internal PR Newswire source implementation for ``collect_news``.""" + +import asyncio +import hashlib +import re +import time +from dataclasses import dataclass +from datetime import date, datetime, timedelta, timezone +from html import unescape +from html.parser import HTMLParser +from urllib.parse import urlencode, urljoin +from zoneinfo import ZoneInfo + +import httpx + +from quantmind.preprocess._news_types import ( + NewsArtifact, + NewsBatch, + NewsDocument, + NewsFailure, + NewsFailureStage, +) +from quantmind.preprocess.fetch._types import Fetched +from quantmind.preprocess.fetch.http import ( + FetchAttemptsExhausted, + FetchPolicy, + HttpFetcher, +) +from quantmind.preprocess.news import ( + canonicalize_source_url, + news_document_from_fetched, + preprocess_news_document, +) + +_SOURCE = "pr-newswire" +_PUBLISHER = "PR Newswire" +_BASE_URL = "https://www.prnewswire.com" +_LISTING_URL = f"{_BASE_URL}/news-releases/news-releases-list/" +_EASTERN = ZoneInfo("America/New_York") +_PAGE_SIZE = 100 +_MAX_PAGES = 20 +_CACHE_BUST_ATTEMPTS = 8 +_ARTICLE_MAX_BYTES = 10_000_000 +_LISTING_MAX_BYTES = 5_000_000 +_DEFAULT_FETCH_POLICY = FetchPolicy( + max_attempts=3, + backoff_base_seconds=0.5, + backoff_max_seconds=5.0, + jitter_seconds=0.1, + max_concurrency_per_host=2, + min_interval_seconds=0.1, +) + +_SHORT_TIME_RE = re.compile( + r"^(?P\d{1,2}):(?P\d{2})\s+E(?:S|D)?T$", + re.IGNORECASE, +) +_FULL_TIME_RE = re.compile( + r"^(?P[A-Za-z]{3})\s+(?P\d{1,2}),\s+" + r"(?P\d{4}),\s+(?P\d{1,2}):" + r"(?P\d{2})\s+E(?:S|D)?T$", + re.IGNORECASE, +) +_MONTHS = { + month: number + for number, month in enumerate( + ( + "jan", + "feb", + "mar", + "apr", + "may", + "jun", + "jul", + "aug", + "sep", + "oct", + "nov", + "dec", + ), + start=1, + ) +} +_PAYLOAD_ID_RE = re.compile(r"-(?P\d+)\.html$") + + +@dataclass(frozen=True, slots=True) +class PRNewswireObservation: + """One public listing row inside the requested time window.""" + + identity: str + payload_id: str | None + canonical_url: str + title: str + published_at: datetime + discovery_artifact: NewsArtifact + + +@dataclass(frozen=True, slots=True) +class PRNewswireDiscovery: + """Listing observations and evidence that window discovery completed.""" + + observations: tuple[PRNewswireObservation, ...] = () + failures: tuple[NewsFailure, ...] = () + page_count: int = 0 + complete: bool = False + + @property + def observed_count(self) -> int: + """Number of listing rows retained inside the requested window.""" + return len(self.observations) + + +@dataclass(frozen=True, slots=True) +class _ParsedListingRow: + href: str | None + title: str + timestamp: str + raw_html: bytes + + +@dataclass(frozen=True, slots=True) +class _ParsedListingPage: + page_date: date + rows: tuple[_ParsedListingRow, ...] + + +class _ListingParser(HTMLParser): + """Extract only PR Newswire listing cards with no optional dependency.""" + + def __init__(self) -> None: + super().__init__(convert_charrefs=False) + self.page_date_text: str | None = None + self.rows: list[_ParsedListingRow] = [] + self._row_div_depth = 0 + self._raw_parts: list[str] = [] + self._href: str | None = None + self._title_parts: list[str] = [] + self._timestamp_parts: list[str] = [] + self._in_heading = False + self._in_timestamp = False + + def handle_starttag( + self, + tag: str, + attrs: list[tuple[str, str | None]], + ) -> None: + attributes = dict(attrs) + classes = set((attributes.get("class") or "").split()) + if ( + not self._row_div_depth + and tag == "div" + and {"row", "newsCards"}.issubset(classes) + ): + self._start_row() + + if not self._row_div_depth: + if tag == "input" and attributes.get("id") == "date": + self.page_date_text = attributes.get("value") + return + + start_text = self.get_starttag_text() or f"<{tag}>" + self._raw_parts.append(start_text) + if tag == "div" and len(self._raw_parts) > 1: + self._row_div_depth += 1 + if tag == "a" and "newsreleaseconsolidatelink" in classes: + self._href = self._href or attributes.get("href") + if tag == "h3": + self._in_heading = True + if tag == "small" and self._in_heading: + self._in_timestamp = True + + def handle_startendtag( + self, + tag: str, + attrs: list[tuple[str, str | None]], + ) -> None: + attributes = dict(attrs) + if not self._row_div_depth: + if tag == "input" and attributes.get("id") == "date": + self.page_date_text = attributes.get("value") + return + self._raw_parts.append(self.get_starttag_text() or f"<{tag}/>") + + def handle_endtag(self, tag: str) -> None: + if not self._row_div_depth: + return + self._raw_parts.append(f"") + if tag == "small": + self._in_timestamp = False + if tag == "h3": + self._in_heading = False + self._in_timestamp = False + if tag == "div": + self._row_div_depth -= 1 + if not self._row_div_depth: + self._finish_row() + + def handle_data(self, data: str) -> None: + if not self._row_div_depth: + return + self._raw_parts.append(data) + self._append_heading_text(data) + + def handle_entityref(self, name: str) -> None: + self._handle_reference(f"&{name};") + + def handle_charref(self, name: str) -> None: + self._handle_reference(f"&#{name};") + + def handle_comment(self, data: str) -> None: + if self._row_div_depth: + self._raw_parts.append(f"") + + def _start_row(self) -> None: + self._row_div_depth = 1 + self._raw_parts = [] + self._href = None + self._title_parts = [] + self._timestamp_parts = [] + self._in_heading = False + self._in_timestamp = False + + def _finish_row(self) -> None: + self.rows.append( + _ParsedListingRow( + href=self._href, + title=_normalize_space(unescape("".join(self._title_parts))), + timestamp=_normalize_space( + unescape("".join(self._timestamp_parts)) + ), + raw_html="".join(self._raw_parts).encode("utf-8"), + ) + ) + self._raw_parts = [] + + def _handle_reference(self, reference: str) -> None: + if not self._row_div_depth: + return + self._raw_parts.append(reference) + self._append_heading_text(reference) + + def _append_heading_text(self, text: str) -> None: + if not self._in_heading: + return + if self._in_timestamp: + self._timestamp_parts.append(text) + else: + self._title_parts.append(text) + + +async def _discover_pr_newswire( + *, + start: datetime, + end: datetime, +) -> PRNewswireDiscovery: + """Discover every public PR Newswire listing row in ``[start, end)``.""" + start, end = _validate_window(start, end) + async with HttpFetcher(policy=_DEFAULT_FETCH_POLICY) as fetcher: + return await _discover_with_fetcher( + start=start, + end=end, + fetcher=fetcher, + ) + + +async def _collect_pr_newswire( + *, + start: datetime, + end: datetime, + retain_raw_html: bool, +) -> NewsBatch: + """Discover and collect PR Newswire articles for ``[start, end)``.""" + start, end = _validate_window(start, end) + async with HttpFetcher(policy=_DEFAULT_FETCH_POLICY) as fetcher: + discovery = await _discover_with_fetcher( + start=start, + end=end, + fetcher=fetcher, + ) + outcomes = await asyncio.gather( + *( + _collect_observation( + observation, + fetcher=fetcher, + retain_raw_html=retain_raw_html, + ) + for observation in discovery.observations + ) + ) + + documents = tuple( + outcome for outcome in outcomes if isinstance(outcome, NewsDocument) + ) + article_failures = tuple( + outcome for outcome in outcomes if isinstance(outcome, NewsFailure) + ) + return NewsBatch( + documents=documents, + failures=discovery.failures + article_failures, + observed_count=discovery.observed_count, + complete=discovery.complete, + ) + + +async def _discover_with_fetcher( + *, + start: datetime, + end: datetime, + fetcher: HttpFetcher, +) -> PRNewswireDiscovery: + observations: list[PRNewswireObservation] = [] + failures: list[NewsFailure] = [] + page_count = 0 + crossed_start = False + anchor = _ceil_to_hour(end).astimezone(_EASTERN) + prior_page: tuple[tuple[str | None, str], ...] | None = None + last_published_at: datetime | None = None + + for page_number in range(1, _MAX_PAGES + 1): + try: + fetched = await _fetch_listing_page( + fetcher, + anchor=anchor, + page_number=page_number, + ) + except Exception as exc: + failures.append( + _failure( + stage="discovery_fetch", + source_url=_listing_url( + anchor=anchor, + page_number=page_number, + ), + item_id=None, + error=exc, + ) + ) + break + + page_count += 1 + try: + parsed = _parse_listing_page(fetched, fallback_date=anchor.date()) + except Exception as exc: + failures.append( + _failure( + stage="discovery_parse", + source_url=( + fetched.resolved_url + or fetched.source_url + or _LISTING_URL + ), + item_id=None, + error=exc, + ) + ) + break + + fingerprint = tuple((row.href, row.timestamp) for row in parsed.rows) + if not fingerprint: + failures.append( + _failure( + stage="discovery_parse", + source_url=( + fetched.resolved_url + or fetched.source_url + or _LISTING_URL + ), + item_id=None, + error=ValueError("PR Newswire listing contained no rows"), + ) + ) + break + if fingerprint == prior_page: + failures.append( + _failure( + stage="discovery_parse", + source_url=( + fetched.resolved_url + or fetched.source_url + or _LISTING_URL + ), + item_id=None, + error=ValueError("PR Newswire pagination did not advance"), + ) + ) + break + prior_page = fingerprint + + for row in parsed.rows: + try: + published_at = _parse_listing_timestamp( + row.timestamp, + page_date=parsed.page_date, + previous=last_published_at, + ) + last_published_at = published_at + observation = _observation_from_row( + row, + published_at=published_at, + fetched_page=fetched, + ) + except Exception as exc: + failures.append( + _failure( + stage="discovery_parse", + source_url=( + fetched.resolved_url + or fetched.source_url + or _LISTING_URL + ), + item_id=row.href, + error=exc, + ) + ) + continue + + if start <= published_at < end: + observations.append(observation) + if published_at < start: + crossed_start = True + + if crossed_start: + break + else: + failures.append( + _failure( + stage="discovery_parse", + source_url=_LISTING_URL, + item_id=None, + error=ValueError( + f"PR Newswire discovery exceeded {_MAX_PAGES} pages" + ), + ) + ) + + return PRNewswireDiscovery( + observations=tuple(observations), + failures=tuple(failures), + page_count=page_count, + complete=crossed_start and not failures, + ) + + +async def _fetch_listing_page( + fetcher: HttpFetcher, + *, + anchor: datetime, + page_number: int, +) -> Fetched: + last_error: Exception | None = None + for _ in range(_CACHE_BUST_ATTEMPTS): + url = _listing_url( + anchor=anchor, + page_number=page_number, + cache_bust=str(time.time_ns()), + ) + try: + return await fetcher.fetch_url(url, max_bytes=_LISTING_MAX_BYTES) + except httpx.HTTPStatusError as exc: + last_error = exc + if exc.response.status_code != 404: + raise + assert last_error is not None + raise last_error + + +async def _collect_observation( + observation: PRNewswireObservation, + *, + fetcher: HttpFetcher, + retain_raw_html: bool, +) -> NewsDocument | NewsFailure: + try: + fetched = await fetcher.fetch_url( + observation.canonical_url, + max_bytes=_ARTICLE_MAX_BYTES, + ) + except Exception as exc: + return _failure( + stage="article_fetch", + source_url=observation.canonical_url, + item_id=observation.payload_id, + error=exc, + ) + + try: + raw = await news_document_from_fetched( + fetched, + title=observation.title, + publisher=_PUBLISHER, + published_at=observation.published_at, + payload_id=observation.payload_id, + ) + candidate = preprocess_news_document(raw) + except Exception as exc: + return _failure( + stage="article_parse", + source_url=observation.canonical_url, + item_id=observation.payload_id, + error=exc, + ) + + return NewsDocument( + source=_SOURCE, + identity=observation.identity, + cleaned_markdown=candidate.body_text, + content_hash=candidate.content_hash, + discovery_artifact=observation.discovery_artifact, + article_artifact=_artifact_from_fetched( + fetched, + retain_bytes=retain_raw_html, + ), + payload_id=observation.payload_id, + canonical_url=observation.canonical_url, + title=candidate.title, + publisher=candidate.publisher, + published_at=candidate.published_at, + ticker_hints=candidate.ticker_hints, + ) + + +def _parse_listing_page( + fetched: Fetched, + *, + fallback_date: date, +) -> _ParsedListingPage: + parser = _ListingParser() + try: + parser.feed(fetched.bytes.decode("utf-8")) + parser.close() + except (UnicodeDecodeError, ValueError) as exc: + raise ValueError(f"invalid PR Newswire listing HTML: {exc}") from exc + + page_date = fallback_date + if parser.page_date_text: + try: + page_date = datetime.strptime( + parser.page_date_text, + "%m/%d/%Y", + ).date() + except ValueError as exc: + raise ValueError( + f"invalid PR Newswire page date: {parser.page_date_text!r}" + ) from exc + return _ParsedListingPage(page_date=page_date, rows=tuple(parser.rows)) + + +def _observation_from_row( + row: _ParsedListingRow, + *, + published_at: datetime, + fetched_page: Fetched, +) -> PRNewswireObservation: + if not row.href: + raise ValueError("PR Newswire listing row has no release URL") + if not row.title: + raise ValueError("PR Newswire listing row has no title") + + canonical_url = canonicalize_source_url(urljoin(_BASE_URL, row.href)) + payload_match = _PAYLOAD_ID_RE.search(canonical_url) + payload_id = payload_match.group("id") if payload_match else None + identity = _build_identity( + payload_id=payload_id, + canonical_url=canonical_url, + ) + return PRNewswireObservation( + identity=identity, + payload_id=payload_id, + canonical_url=canonical_url, + title=row.title, + published_at=published_at, + discovery_artifact=NewsArtifact( + bytes=row.raw_html, + content_hash=hashlib.sha256(row.raw_html).hexdigest(), + content_type="text/html", + source_url=fetched_page.source_url, + resolved_url=fetched_page.resolved_url, + status_code=fetched_page.status_code, + headers=dict(fetched_page.headers), + fetched_at=fetched_page.fetched_at, + ), + ) + + +def _parse_listing_timestamp( + value: str, + *, + page_date: date, + previous: datetime | None, +) -> datetime: + previous_local = previous.astimezone(_EASTERN) if previous else None + short_match = _SHORT_TIME_RE.fullmatch(value) + if short_match: + local_date = previous_local.date() if previous_local else page_date + hour = int(short_match.group("hour")) + minute = int(short_match.group("minute")) + else: + full_match = _FULL_TIME_RE.fullmatch(value) + if not full_match: + raise ValueError(f"invalid PR Newswire timestamp: {value!r}") + month_name = full_match.group("month").lower() + try: + month = _MONTHS[month_name] + except KeyError as exc: + raise ValueError( + f"invalid PR Newswire month: {month_name!r}" + ) from exc + local_date = date( + int(full_match.group("year")), + month, + int(full_match.group("day")), + ) + hour = int(full_match.group("hour")) + minute = int(full_match.group("minute")) + + local = datetime.combine( + local_date, + datetime.min.time(), + tzinfo=_EASTERN, + ).replace(hour=hour, minute=minute) + if ( + short_match + and previous_local is not None + and (hour, minute) > (previous_local.hour, previous_local.minute) + ): + local -= timedelta(days=1) + return local.astimezone(timezone.utc) + + +def _listing_url( + *, + anchor: datetime, + page_number: int, + cache_bust: str | None = None, +) -> str: + params = { + "page": str(page_number), + "pagesize": str(_PAGE_SIZE), + "month": str(anchor.month), + "day": str(anchor.day), + "year": str(anchor.year), + "hour": f"{anchor.hour:02d}", + } + if cache_bust is not None: + params["_"] = cache_bust + return f"{_LISTING_URL}?{urlencode(params)}" + + +def _ceil_to_hour(value: datetime) -> datetime: + value = value.astimezone(timezone.utc) + if value.minute or value.second or value.microsecond: + return value.replace(minute=0, second=0, microsecond=0) + timedelta( + hours=1 + ) + return value + + +def _validate_window( + start: datetime, + end: datetime, +) -> tuple[datetime, datetime]: + if start.tzinfo is None or start.utcoffset() is None: + raise ValueError("PR Newswire discovery requires an aware start") + if end.tzinfo is None or end.utcoffset() is None: + raise ValueError("PR Newswire discovery requires an aware end") + start = start.astimezone(timezone.utc) + end = end.astimezone(timezone.utc) + if end <= start: + raise ValueError("PR Newswire discovery requires end after start") + return start, end + + +def _artifact_from_fetched( + fetched: Fetched, + *, + retain_bytes: bool, +) -> NewsArtifact: + return NewsArtifact( + bytes=fetched.bytes if retain_bytes else None, + content_hash=hashlib.sha256(fetched.bytes).hexdigest(), + content_type=fetched.content_type, + source_url=fetched.source_url, + resolved_url=fetched.resolved_url, + status_code=fetched.status_code, + headers=dict(fetched.headers), + fetched_at=fetched.fetched_at, + ) + + +def _build_identity( + *, + payload_id: str | None, + canonical_url: str, +) -> str: + raw = "\x1f".join((_SOURCE, payload_id or canonical_url)) + digest = hashlib.sha256(raw.encode("utf-8")).hexdigest() + return f"news:{_SOURCE}:{digest}" + + +def _failure( + *, + stage: NewsFailureStage, + source_url: str, + item_id: str | None, + error: Exception, +) -> NewsFailure: + return NewsFailure( + source=_SOURCE, + stage=stage, + source_url=source_url, + item_id=item_id, + error_type=_error_type(error), + message=str(error), + ) + + +def _error_type(error: Exception) -> str: + if isinstance(error, FetchAttemptsExhausted): + return "retry_exhausted" + if isinstance(error, httpx.TimeoutException): + return "timeout" + if isinstance(error, httpx.TransportError): + return "network" + if isinstance(error, httpx.HTTPStatusError): + return "http_status" + if isinstance(error, ValueError): + return "invalid_content" + return "unexpected" + + +def _normalize_space(value: str) -> str: + return " ".join(value.split()) diff --git a/quantmind/preprocess/wire.py b/quantmind/preprocess/wire.py deleted file mode 100644 index 1a32d50..0000000 --- a/quantmind/preprocess/wire.py +++ /dev/null @@ -1,431 +0,0 @@ -"""Provider-pluggable ingestion for public press-release wire feeds.""" - -import hashlib -from dataclasses import dataclass, field -from datetime import datetime -from typing import Literal, Protocol - -import httpx - -from quantmind.preprocess.fetch._types import Fetched -from quantmind.preprocess.fetch.http import ( - FetchAttemptsExhausted, - FetchPolicy, - HttpFetcher, -) -from quantmind.preprocess.fetch.rss import FeedItem, parse_feed -from quantmind.preprocess.news import ( - BodySource, - NewsTickerHint, - canonicalize_source_url, - feed_item_to_news_document, - news_document_from_fetched, - preprocess_news_document, -) - -WireFailureStage = Literal[ - "feed_fetch", - "feed_parse", - "article_fetch", - "article_parse", - "document_build", -] - - -@dataclass(frozen=True, slots=True) -class WireItemMapping: - """Provider-normalized identity and metadata for one feed item.""" - - payload_id: str | None - canonical_url: str | None - title: str - published_at: datetime | None - - -class WireProvider(Protocol): - """Adapter contract for provider-specific feed item behavior.""" - - @property - def name(self) -> str: - """Stable provider identifier.""" - ... - - @property - def publisher(self) -> str: - """Human-readable publisher name.""" - ... - - @property - def body_source(self) -> BodySource: - """Whether cleaned body text comes from feed or article HTML.""" - ... - - def map_item(self, item: FeedItem) -> WireItemMapping: - """Map one provider feed item into common identity fields.""" - ... - - -@dataclass(frozen=True, slots=True) -class _StandardWireProvider: - name: str - publisher: str - body_source: BodySource - - def map_item(self, item: FeedItem) -> WireItemMapping: - url = canonicalize_source_url(item.url) if item.url else None - return WireItemMapping( - payload_id=(item.id or "").strip() or None, - canonical_url=url, - title=item.title, - published_at=item.published_at, - ) - - -PR_NEWSWIRE: WireProvider = _StandardWireProvider( - name="pr-newswire", - publisher="PR Newswire", - body_source="article", -) - - -@dataclass(frozen=True, slots=True) -class WireFeedConfig: - """Public feed URLs, provider adapter, and shared HTTP policy.""" - - provider: WireProvider - feed_urls: tuple[str, ...] - fetch_policy: FetchPolicy = field(default_factory=FetchPolicy) - - def __post_init__(self) -> None: - """Reject incomplete configs before network calls begin.""" - if not self.feed_urls: - raise ValueError("WireFeedConfig requires at least one feed URL") - if any(not url.strip() for url in self.feed_urls): - raise ValueError("feed URLs must not be empty") - - -@dataclass(frozen=True, slots=True) -class RawWireArtifact: - """Raw bytes and fetch metadata retained for replay and re-cleaning.""" - - bytes: bytes - content_hash: str - content_type: str - source_url: str | None - resolved_url: str | None - status_code: int | None - headers: dict[str, str] = field(default_factory=dict) - fetched_at: datetime | None = None - - -@dataclass(frozen=True, slots=True) -class WireDocument: - """Replayable raw wire evidence paired with cleaned markdown.""" - - provider: str - identity: str - cleaned_markdown: str - content_hash: str - raw_feed_entry: RawWireArtifact - raw_article: RawWireArtifact | None = None - payload_id: str | None = None - canonical_url: str | None = None - title: str | None = None - publisher: str | None = None - published_at: datetime | None = None - ticker_hints: tuple[NewsTickerHint, ...] = () - - -@dataclass(frozen=True, slots=True) -class WireFetchFailure: - """Lightweight record for one feed or item that could not be processed.""" - - provider: str - stage: WireFailureStage - source_url: str - item_id: str | None - error_type: str - message: str - - -@dataclass(frozen=True, slots=True) -class WireFetchResult: - """Successful wire documents plus non-fatal per-item failures.""" - - documents: tuple[WireDocument, ...] = () - failures: tuple[WireFetchFailure, ...] = () - - @property - def success_count(self) -> int: - """Number of documents produced by this call.""" - return len(self.documents) - - @property - def failure_count(self) -> int: - """Number of feed or item failures recorded by this call.""" - return len(self.failures) - - -async def fetch_wire_documents(config: WireFeedConfig) -> WireFetchResult: - """Fetch current feed items and convert them into wire documents. - - Independent feed and item failures are recorded while remaining inputs - continue. Configuration errors still raise before any network request. - """ - documents: list[WireDocument] = [] - failures: list[WireFetchFailure] = [] - seen: set[str] = set() - async with HttpFetcher(policy=config.fetch_policy) as fetcher: - for feed_url in config.feed_urls: - try: - fetched_feed = await fetcher.fetch_url( - feed_url, - max_bytes=5_000_000, - ) - except Exception as exc: - failures.append( - _failure( - config.provider, - "feed_fetch", - feed_url, - None, - exc, - ) - ) - continue - - try: - feed = parse_feed( - fetched_feed.bytes, - feed_url=feed_url, - content_type=fetched_feed.content_type, - headers=fetched_feed.headers, - fetched=fetched_feed, - ) - except Exception as exc: - failures.append( - _failure( - config.provider, - "feed_parse", - feed_url, - None, - exc, - ) - ) - continue - - for item in feed.items: - try: - mapping = config.provider.map_item(item) - identity = build_wire_identity( - provider=config.provider.name, - payload_id=mapping.payload_id, - canonical_url=mapping.canonical_url, - ) - except Exception as exc: - failures.append( - _failure( - config.provider, - "document_build", - item.url or feed_url, - item.id, - exc, - ) - ) - continue - - if identity in seen: - continue - seen.add(identity) - - document = await _build_document( - provider=config.provider, - item=item, - mapping=mapping, - identity=identity, - fetched_feed=fetched_feed, - fetcher=fetcher, - failures=failures, - ) - if document is not None: - documents.append(document) - - return WireFetchResult( - documents=tuple(documents), - failures=tuple(failures), - ) - - -def build_wire_identity( - *, - provider: str, - payload_id: str | None, - canonical_url: str | None, -) -> str: - """Build stable identity from provider, payload ID, and canonical URL.""" - normalized_provider = provider.strip().lower() - normalized_payload = (payload_id or "").strip() - normalized_url = ( - canonicalize_source_url(canonical_url) if canonical_url else "" - ) - if not normalized_provider: - raise ValueError("wire identity requires a provider") - if not normalized_payload and not normalized_url: - raise ValueError("wire identity requires a payload ID or URL") - raw_identity = "\x1f".join( - (normalized_provider, normalized_payload, normalized_url) - ) - digest = hashlib.sha256(raw_identity.encode("utf-8")).hexdigest() - return f"wire:{normalized_provider}:{digest}" - - -async def _build_document( - *, - provider: WireProvider, - item: FeedItem, - mapping: WireItemMapping, - identity: str, - fetched_feed: Fetched, - fetcher: HttpFetcher, - failures: list[WireFetchFailure], -) -> WireDocument | None: - raw_article: Fetched | None = None - if provider.body_source == "article": - article_url = mapping.canonical_url - if not article_url: - failures.append( - _failure( - provider, - "article_fetch", - item.source_feed_url or "", - item.id, - ValueError("article body source requires an item URL"), - ) - ) - return None - try: - raw_article = await fetcher.fetch_url( - article_url, - max_bytes=10_000_000, - ) - except Exception as exc: - failures.append( - _failure( - provider, - "article_fetch", - article_url, - item.id, - exc, - ) - ) - return None - - try: - if raw_article is None: - raw_news = await feed_item_to_news_document( - item, - publisher=provider.publisher, - body_source="feed", - ) - else: - raw_news = await news_document_from_fetched( - raw_article, - title=mapping.title, - publisher=provider.publisher, - published_at=mapping.published_at, - payload_id=mapping.payload_id, - ) - candidate = preprocess_news_document(raw_news) - except Exception as exc: - failures.append( - _failure( - provider, - "article_parse" - if raw_article is not None - else "document_build", - item.url or item.source_feed_url or "", - item.id, - exc, - ) - ) - return None - - return WireDocument( - provider=provider.name, - identity=identity, - cleaned_markdown=candidate.body_text, - content_hash=candidate.content_hash, - raw_feed_entry=_feed_entry_artifact(item, fetched_feed), - raw_article=( - _artifact_from_fetched(raw_article) - if raw_article is not None - else None - ), - payload_id=mapping.payload_id, - canonical_url=mapping.canonical_url, - title=candidate.title, - publisher=candidate.publisher, - published_at=candidate.published_at, - ticker_hints=candidate.ticker_hints, - ) - - -def _feed_entry_artifact( - item: FeedItem, - fetched_feed: Fetched, -) -> RawWireArtifact: - raw_xml = item.raw_xml - return RawWireArtifact( - bytes=raw_xml, - content_hash=hashlib.sha256(raw_xml).hexdigest(), - content_type="application/xml", - source_url=fetched_feed.source_url, - resolved_url=fetched_feed.resolved_url, - status_code=fetched_feed.status_code, - headers=dict(fetched_feed.headers), - fetched_at=fetched_feed.fetched_at, - ) - - -def _artifact_from_fetched(fetched: Fetched) -> RawWireArtifact: - return RawWireArtifact( - bytes=fetched.bytes, - content_hash=hashlib.sha256(fetched.bytes).hexdigest(), - content_type=fetched.content_type, - source_url=fetched.source_url, - resolved_url=fetched.resolved_url, - status_code=fetched.status_code, - headers=dict(fetched.headers), - fetched_at=fetched.fetched_at, - ) - - -def _failure( - provider: WireProvider, - stage: WireFailureStage, - source_url: str, - item_id: str | None, - error: Exception, -) -> WireFetchFailure: - return WireFetchFailure( - provider=provider.name, - stage=stage, - source_url=source_url, - item_id=item_id, - error_type=_error_type(error), - message=str(error), - ) - - -def _error_type(error: Exception) -> str: - if isinstance(error, FetchAttemptsExhausted): - return "retry_exhausted" - if isinstance(error, httpx.TimeoutException): - return "timeout" - if isinstance(error, httpx.TransportError): - return "network" - if isinstance(error, httpx.HTTPStatusError): - return "http_status" - if isinstance(error, ValueError): - return "invalid_content" - return "unexpected" diff --git a/scripts/smoke_wire.py b/scripts/smoke_wire.py deleted file mode 100644 index 92197f4..0000000 --- a/scripts/smoke_wire.py +++ /dev/null @@ -1,73 +0,0 @@ -#!/usr/bin/env python3 -"""Run the manual live-network checklist for PR Newswire.""" - -import asyncio - -from quantmind.preprocess import ( - PR_NEWSWIRE, - FetchPolicy, - WireFeedConfig, - fetch_wire_documents, -) - -_PUBLIC_FEED = "https://www.prnewswire.com/rss/news-releases-list.rss" - - -async def _check_pr_newswire() -> bool: - provider_name = PR_NEWSWIRE.name - try: - result = await asyncio.wait_for( - fetch_wire_documents( - WireFeedConfig( - provider=PR_NEWSWIRE, - feed_urls=(_PUBLIC_FEED,), - fetch_policy=FetchPolicy( - max_attempts=2, - min_interval_seconds=0.25, - ), - ) - ), - timeout=120, - ) - except Exception as exc: - print(f"[FAIL] {provider_name}: {type(exc).__name__}: {exc}") - return False - - documents = result.documents - checks = { - "documents": bool(documents), - "no_failures": not result.failures, - "raw_feed_entry": bool(documents) - and all(document.raw_feed_entry.bytes for document in documents), - "raw_article": bool(documents) - and all( - document.raw_article is not None - and bool(document.raw_article.bytes) - for document in documents - ), - "cleaned_markdown": bool(documents) - and all(document.cleaned_markdown.strip() for document in documents), - "unique_identity": len({document.identity for document in documents}) - == len(documents), - } - for check, passed in checks.items(): - state = "PASS" if passed else "FAIL" - print(f"[{state}] {provider_name}: {check}") - print( - f" documents={result.success_count} " - f"failures={result.failure_count}" - ) - for failure in result.failures[:3]: - print( - f" {failure.stage} {failure.error_type} {failure.source_url}" - ) - return all(checks.values()) - - -async def main() -> int: - """Run the PR Newswire checklist and return a process exit code.""" - return 0 if await _check_pr_newswire() else 1 - - -if __name__ == "__main__": - raise SystemExit(asyncio.run(main())) diff --git a/scripts/verify_news_e2e.py b/scripts/verify_news_e2e.py new file mode 100644 index 0000000..76a27e7 --- /dev/null +++ b/scripts/verify_news_e2e.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""Run the bounded live-network checks for public news collection.""" + +import asyncio +from datetime import datetime, timedelta, timezone + +from quantmind.preprocess.fetch.http import FetchPolicy, HttpFetcher +from quantmind.preprocess.fetch.rss import fetch_rss_feed +from quantmind.preprocess.pr_newswire import _discover_pr_newswire + +_PUBLIC_RSS_URL = "https://www.prnewswire.com/rss/news-releases-list.rss" +_RSS_TIMEOUT_SECONDS = 60 +_DISCOVERY_TIMEOUT_SECONDS = 300 + + +async def _check_rss() -> bool: + try: + async with HttpFetcher( + policy=FetchPolicy( + max_attempts=3, + backoff_base_seconds=0.5, + backoff_max_seconds=5.0, + jitter_seconds=0.2, + max_concurrency_per_host=1, + min_interval_seconds=0.25, + ), + timeout=30.0, + max_bytes=5_000_000, + ) as fetcher: + feed = await asyncio.wait_for( + fetch_rss_feed(_PUBLIC_RSS_URL, fetcher=fetcher), + timeout=_RSS_TIMEOUT_SECONDS, + ) + except Exception as exc: + print(f"[FAIL] rss: {type(exc).__name__}: {exc}") + return False + + usable_count = sum( + bool(item.title.strip() and (item.url or "").strip()) + for item in feed.items + ) + passed = bool(feed.items) and usable_count > 0 + state = "PASS" if passed else "FAIL" + print( + f"[{state}] rss: items={len(feed.items)} usable={usable_count} " + f"url={_PUBLIC_RSS_URL}" + ) + return passed + + +async def _check_discovery(start: datetime, end: datetime) -> bool: + try: + result = await asyncio.wait_for( + _discover_pr_newswire(start=start, end=end), + timeout=_DISCOVERY_TIMEOUT_SECONDS, + ) + except Exception as exc: + print(f"[FAIL] pr-newswire-discovery: {type(exc).__name__}: {exc}") + return False + + urls = [observation.canonical_url for observation in result.observations] + duplicate_count = len(urls) - len(set(urls)) + in_window_count = sum( + start <= observation.published_at < end + for observation in result.observations + ) + passed = ( + bool(result.observations) + and in_window_count == len(result.observations) + and result.complete + and not result.failures + ) + state = "PASS" if passed else "FAIL" + print( + f"[{state}] pr-newswire-discovery: " + f"observed={len(result.observations)} " + f"in_window={in_window_count} " + f"duplicates={duplicate_count} pages={result.page_count} " + f"failures={len(result.failures)} complete={result.complete}" + ) + for failure in result.failures[:3]: + print( + f" {failure.stage} {failure.error_type} {failure.source_url}" + ) + return passed + + +async def main(*, now: datetime | None = None) -> int: + """Run all live checks and return a process exit code.""" + end = (now or datetime.now(timezone.utc)).astimezone(timezone.utc) + end = end.replace(second=0, microsecond=0) + start = end - timedelta(days=1) + print(f"news window: [{start.isoformat()}, {end.isoformat()})") + + rss_passed = await _check_rss() + discovery_passed = await _check_discovery(start, end) + return 0 if rss_passed and discovery_passed else 1 + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(main())) diff --git a/tests/configs/test_base.py b/tests/configs/test_base.py index 395604c..164e237 100644 --- a/tests/configs/test_base.py +++ b/tests/configs/test_base.py @@ -50,12 +50,12 @@ def test_top_level_imports(self): ) from quantmind.configs import ( EarningsFlowCfg, - NewsFlowCfg, + NewsCollectionCfg, PaperFlowCfg, ) self.assertTrue(issubclass(PaperFlowCfg, BaseFlowCfgExport)) - self.assertTrue(issubclass(NewsFlowCfg, BaseFlowCfgExport)) + self.assertTrue(issubclass(NewsCollectionCfg, BaseFlowCfgExport)) self.assertTrue(issubclass(EarningsFlowCfg, BaseFlowCfgExport)) self.assertEqual(BaseInputExport.__name__, "BaseInput") diff --git a/tests/configs/test_news.py b/tests/configs/test_news.py index 33a2318..d2436b9 100644 --- a/tests/configs/test_news.py +++ b/tests/configs/test_news.py @@ -1,50 +1,69 @@ -"""Tests for configs.news.""" +"""Tests for the intent-oriented news configuration.""" import unittest +from datetime import datetime, timedelta, timezone -from pydantic import TypeAdapter, ValidationError +from pydantic import ValidationError -from quantmind.configs.news import ( - Headline, - HttpUrl, - NewsFlowCfg, - NewsInput, - RssFeed, -) +from quantmind.configs import NewsCollectionCfg, NewsWindow -class NewsFlowCfgTests(unittest.TestCase): - def test_defaults(self): - cfg = NewsFlowCfg() - self.assertEqual(cfg.model, "gpt-4o") - self.assertEqual(cfg.materiality_threshold, "medium") +class NewsCollectionCfgTests(unittest.TestCase): + def test_raw_html_is_not_retained_by_default(self) -> None: + self.assertFalse(NewsCollectionCfg().retain_raw_html) -class NewsInputTests(unittest.TestCase): - def setUp(self): - self.adapter = TypeAdapter(NewsInput) - - def test_rss(self): - v = self.adapter.validate_python( - {"type": "rss", "url": "https://feeds.example.com/markets"} +class NewsWindowTests(unittest.TestCase): + def test_normalizes_aware_timestamps_to_utc(self) -> None: + window = NewsWindow( + source="pr-newswire", + start=datetime( + 2026, + 7, + 13, + 8, + tzinfo=timezone(timedelta(hours=8)), + ), + end=datetime( + 2026, + 7, + 14, + 8, + tzinfo=timezone(timedelta(hours=8)), + ), ) - self.assertIsInstance(v, RssFeed) - def test_http(self): - v = self.adapter.validate_python( - {"type": "http", "url": "https://news.example.com/a"} - ) - self.assertIsInstance(v, HttpUrl) + self.assertEqual(window.start.tzinfo, timezone.utc) + self.assertEqual(window.start.hour, 0) + self.assertEqual(window.end.tzinfo, timezone.utc) - def test_headline(self): - v = self.adapter.validate_python( - {"type": "headline", "text": "Fed holds rates"} - ) - self.assertIsInstance(v, Headline) + def test_rejects_naive_timestamps(self) -> None: + with self.assertRaises(ValidationError): + NewsWindow( + source="pr-newswire", + start=datetime(2026, 7, 13), + end=datetime(2026, 7, 14, tzinfo=timezone.utc), + ) + + def test_rejects_empty_or_reversed_window(self) -> None: + timestamp = datetime(2026, 7, 14, tzinfo=timezone.utc) + + with self.assertRaisesRegex(ValidationError, "end to be after start"): + NewsWindow( + source="pr-newswire", + start=timestamp, + end=timestamp, + ) - def test_unknown_rejected(self): + def test_rejects_unsupported_source(self) -> None: with self.assertRaises(ValidationError): - self.adapter.validate_python({"type": "podcast", "url": "x"}) + NewsWindow.model_validate( + { + "source": "business-wire", + "start": datetime(2026, 7, 13, tzinfo=timezone.utc), + "end": datetime(2026, 7, 14, tzinfo=timezone.utc), + } + ) if __name__ == "__main__": diff --git a/tests/flows/test_news.py b/tests/flows/test_news.py new file mode 100644 index 0000000..314994f --- /dev/null +++ b/tests/flows/test_news.py @@ -0,0 +1,63 @@ +"""Tests for the agent-facing news collection operation.""" + +import unittest +from datetime import datetime, timezone +from unittest.mock import AsyncMock, patch + +from quantmind.configs import NewsCollectionCfg, NewsWindow +from quantmind.flows import collect_news +from quantmind.magic import _introspect_flow_signature +from quantmind.preprocess import NewsBatch + + +class CollectNewsTests(unittest.IsolatedAsyncioTestCase): + async def test_dispatches_pr_newswire_window( + self, + ) -> None: + window = NewsWindow( + source="pr-newswire", + start=datetime(2026, 7, 13, tzinfo=timezone.utc), + end=datetime(2026, 7, 14, tzinfo=timezone.utc), + ) + expected = NewsBatch(observed_count=3, complete=True) + + with patch( + "quantmind.flows.news._collect_pr_newswire", + new=AsyncMock(return_value=expected), + ) as collect: + result = await collect_news( + window, + cfg=NewsCollectionCfg(retain_raw_html=True), + ) + + self.assertIs(result, expected) + collect.assert_awaited_once_with( + start=window.start, + end=window.end, + retain_raw_html=True, + ) + + async def test_uses_agent_safe_retention_default(self) -> None: + window = NewsWindow( + source="pr-newswire", + start=datetime(2026, 7, 13, tzinfo=timezone.utc), + end=datetime(2026, 7, 14, tzinfo=timezone.utc), + ) + + with patch( + "quantmind.flows.news._collect_pr_newswire", + new=AsyncMock(return_value=NewsBatch(complete=True)), + ) as collect: + await collect_news(window) + + self.assertFalse(collect.await_args.kwargs["retain_raw_html"]) + + def test_signature_is_magic_input_compatible(self) -> None: + input_type, cfg_type = _introspect_flow_signature(collect_news) + + self.assertIs(input_type, NewsWindow) + self.assertIs(cfg_type, NewsCollectionCfg) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/preprocess/fixtures/pr_newswire/article.html b/tests/preprocess/fixtures/pr_newswire/article.html new file mode 100644 index 0000000..51464c5 --- /dev/null +++ b/tests/preprocess/fixtures/pr_newswire/article.html @@ -0,0 +1,10 @@ + + + +
+

Example Company Shares an Operations Update

+

Example Company (NYSE: EXCO) supplied fictional operating details.

+

This full article is the source of the cleaned Markdown body.

+
+ + diff --git a/tests/preprocess/fixtures/pr_newswire/listing_full.html b/tests/preprocess/fixtures/pr_newswire/listing_full.html new file mode 100644 index 0000000..138dda8 --- /dev/null +++ b/tests/preprocess/fixtures/pr_newswire/listing_full.html @@ -0,0 +1,34 @@ + + + + + + + + + + diff --git a/tests/preprocess/fixtures/pr_newswire/listing_short_page_1.html b/tests/preprocess/fixtures/pr_newswire/listing_short_page_1.html new file mode 100644 index 0000000..3baa1f9 --- /dev/null +++ b/tests/preprocess/fixtures/pr_newswire/listing_short_page_1.html @@ -0,0 +1,30 @@ + + + + + + + + + diff --git a/tests/preprocess/fixtures/pr_newswire/listing_short_page_2.html b/tests/preprocess/fixtures/pr_newswire/listing_short_page_2.html new file mode 100644 index 0000000..27a10ac --- /dev/null +++ b/tests/preprocess/fixtures/pr_newswire/listing_short_page_2.html @@ -0,0 +1,22 @@ + + + + + + + + diff --git a/tests/preprocess/fixtures/wire/pr_news_wire.xml b/tests/preprocess/fixtures/wire/pr_news_wire.xml deleted file mode 100644 index b493c9f..0000000 --- a/tests/preprocess/fixtures/wire/pr_news_wire.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - Sanitized PR Newswire feed - - Example Energy Shares an Operations Update - https://example.test/prn/releases/operations-update - prn-example-001 - Wed, 17 Apr 2024 12:00:00 GMT - Example Energy shared a short operations teaser.

]]>
-
-
-
diff --git a/tests/preprocess/fixtures/wire/pr_news_wire_article.html b/tests/preprocess/fixtures/wire/pr_news_wire_article.html deleted file mode 100644 index e55dd02..0000000 --- a/tests/preprocess/fixtures/wire/pr_news_wire_article.html +++ /dev/null @@ -1,10 +0,0 @@ - - - -
-

Example Energy Shares an Operations Update

-

Example Energy (NYSE: EXEN) supplied fictional operating details for a sanitized provider fixture.

-

This full article is the source of the cleaned markdown body.

-
- - diff --git a/tests/preprocess/test_news.py b/tests/preprocess/test_news.py index a2755a6..6c04a8b 100644 --- a/tests/preprocess/test_news.py +++ b/tests/preprocess/test_news.py @@ -10,8 +10,8 @@ from quantmind.preprocess.fetch.rss import FeedItem from quantmind.preprocess.news import ( RawNewsDocument, - build_news_dedup_key, - build_sec_news_dedup_key, + build_news_identity, + build_sec_news_identity, canonicalize_source_url, extract_exchange_ticker_hints, feed_item_to_news_document, @@ -84,18 +84,18 @@ def test_exchange_ticker_hints_are_deduped(self): self.assertEqual(hints[0].exchange, "NASDAQ") self.assertEqual(hints[1].exchange, "NYSE") - def test_build_sec_news_dedup_key(self): + def test_build_sec_news_identity(self): self.assertEqual( - build_sec_news_dedup_key( + build_sec_news_identity( accession_number="0001045810-26-000123", section_key="EX99.1", ), "sec:0001045810-26-000123:ex99.1", ) - def test_build_news_dedup_key_requires_identity(self): + def test_build_news_identity_requires_source_reference(self): with self.assertRaises(ValueError): - build_news_dedup_key(source_type="press_release") + build_news_identity(source_type="press_release") def test_preprocess_news_document_builds_candidate_contract(self): published = datetime( @@ -116,7 +116,7 @@ def test_preprocess_news_document_builds_candidate_contract(self): candidate = preprocess_news_document(raw) self.assertEqual(candidate.source_type, "press_release") - self.assertTrue(candidate.dedup_key.startswith("wire:")) + self.assertTrue(candidate.identity.startswith("wire:")) self.assertEqual( candidate.source_url, "https://example.com/pr/nvidia-results" ) diff --git a/tests/preprocess/test_news_types.py b/tests/preprocess/test_news_types.py new file mode 100644 index 0000000..cfa889e --- /dev/null +++ b/tests/preprocess/test_news_types.py @@ -0,0 +1,43 @@ +"""Tests for generic news collection result contracts.""" + +import unittest +from dataclasses import FrozenInstanceError + +from quantmind.preprocess import NewsArtifact, NewsBatch, NewsFailure + + +class NewsResultContractTests(unittest.TestCase): + def test_batch_counts_documents_and_failures(self) -> None: + failure = NewsFailure( + source="pr-newswire", + stage="article_fetch", + source_url="https://example.test/release", + item_id="release-1", + error_type="timeout", + message="request timed out", + ) + batch = NewsBatch(failures=(failure,), observed_count=1, complete=True) + + self.assertEqual(batch.success_count, 0) + self.assertEqual(batch.failure_count, 1) + self.assertEqual(batch.observed_count, 1) + self.assertTrue(batch.complete) + + def test_artifact_can_retain_hash_without_bytes(self) -> None: + artifact = NewsArtifact( + bytes=None, + content_hash="abc123", + content_type="text/html", + source_url="https://example.test/release", + resolved_url="https://example.test/release", + status_code=200, + ) + + self.assertIsNone(artifact.bytes) + self.assertEqual(artifact.content_hash, "abc123") + with self.assertRaises(FrozenInstanceError): + setattr(artifact, "bytes", b"changed") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/preprocess/test_pr_newswire.py b/tests/preprocess/test_pr_newswire.py new file mode 100644 index 0000000..856b5f7 --- /dev/null +++ b/tests/preprocess/test_pr_newswire.py @@ -0,0 +1,437 @@ +import re +import unittest +from datetime import datetime, timezone +from pathlib import Path +from unittest.mock import patch + +import httpx +import respx + +from quantmind.preprocess.fetch.http import FetchPolicy +from quantmind.preprocess.pr_newswire import ( + _collect_pr_newswire, + _discover_pr_newswire, +) + +_FIXTURES = Path(__file__).parent / "fixtures" / "pr_newswire" +_LISTING_RE = re.compile( + r"https://www\.prnewswire\.com/news-releases/" + r"news-releases-list/\?.*" +) +_TEST_POLICY = FetchPolicy( + max_attempts=1, + backoff_base_seconds=0.0, + backoff_max_seconds=0.0, + jitter_seconds=0.0, + max_concurrency_per_host=2, + min_interval_seconds=0.0, +) + + +def _fixture(name: str) -> bytes: + return (_FIXTURES / name).read_bytes() + + +def _listing_response(content: bytes, request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + request=request, + headers={"Content-Type": "text/html"}, + content=content, + ) + + +def _short_listing(*rows: tuple[str, str]) -> bytes: + cards = "".join( + ( + '" + ) + for timestamp, payload_id in rows + ) + return ( + '' + f"{cards}" + ).encode() + + +class DiscoverPRNewswireTests(unittest.IsolatedAsyncioTestCase): + async def test_preserves_duplicates_and_rolls_short_times_across_pages( + self, + ) -> None: + pages = { + 1: _fixture("listing_short_page_1.html"), + 2: _fixture("listing_short_page_2.html"), + } + + def respond(request: httpx.Request) -> httpx.Response: + page = int(request.url.params["page"]) + return _listing_response(pages[page], request) + + with ( + patch( + "quantmind.preprocess.pr_newswire._DEFAULT_FETCH_POLICY", + _TEST_POLICY, + ), + respx.mock(assert_all_called=True) as router, + ): + route = router.get(url__regex=_LISTING_RE).mock(side_effect=respond) + result = await _discover_pr_newswire( + start=datetime(2026, 7, 14, 3, 58, tzinfo=timezone.utc), + end=datetime(2026, 7, 14, 4, 30, tzinfo=timezone.utc), + ) + + self.assertTrue(result.complete) + self.assertEqual(result.page_count, 2) + self.assertEqual(result.observed_count, 4) + self.assertEqual(route.call_count, 2) + self.assertEqual( + [item.payload_id for item in result.observations], + ["302900111", "302900111", "302900112", "302900113"], + ) + self.assertEqual( + result.observations[-1].published_at, + datetime(2026, 7, 14, 3, 59, tzinfo=timezone.utc), + ) + self.assertEqual( + result.observations[0].identity, + result.observations[1].identity, + ) + self.assertNotEqual( + result.observations[0].canonical_url, + result.observations[1].canonical_url, + ) + self.assertEqual( + len({item.identity for item in result.observations}), 3 + ) + artifact = result.observations[0].discovery_artifact + self.assertIn( + b"Repeated", result.observations[1].discovery_artifact.bytes + ) + self.assertIn(b"Example Alpha", artifact.bytes) + self.assertEqual(artifact.content_type, "text/html") + self.assertEqual(artifact.status_code, 200) + request = route.calls[0].request + self.assertEqual(request.url.params["hour"], "01") + + async def test_full_timestamps_use_half_open_window_boundaries( + self, + ) -> None: + with ( + patch( + "quantmind.preprocess.pr_newswire._DEFAULT_FETCH_POLICY", + _TEST_POLICY, + ), + respx.mock(assert_all_called=True) as router, + ): + router.get(url__regex=_LISTING_RE).mock( + side_effect=lambda request: _listing_response( + _fixture("listing_full.html"), request + ) + ) + result = await _discover_pr_newswire( + start=datetime(2026, 7, 14, 3, 58, tzinfo=timezone.utc), + end=datetime(2026, 7, 14, 4, 30, tzinfo=timezone.utc), + ) + + self.assertTrue(result.complete) + self.assertEqual( + [item.payload_id for item in result.observations], + ["302900202", "302900203"], + ) + self.assertEqual( + result.observations[-1].published_at, + datetime(2026, 7, 14, 3, 58, tzinfo=timezone.utc), + ) + + async def test_equal_start_cluster_continues_onto_the_next_page( + self, + ) -> None: + pages = { + 1: _short_listing( + ("00:10 ET", "302900401"), + ("00:00 ET", "302900402"), + ), + 2: _short_listing( + ("00:00 ET", "302900403"), + ("23:59 ET", "302900404"), + ), + } + + def respond(request: httpx.Request) -> httpx.Response: + return _listing_response( + pages[int(request.url.params["page"])], + request, + ) + + with ( + patch( + "quantmind.preprocess.pr_newswire._DEFAULT_FETCH_POLICY", + _TEST_POLICY, + ), + respx.mock(assert_all_called=True) as router, + ): + router.get(url__regex=_LISTING_RE).mock(side_effect=respond) + result = await _discover_pr_newswire( + start=datetime(2026, 7, 14, 4, 0, tzinfo=timezone.utc), + end=datetime(2026, 7, 14, 4, 30, tzinfo=timezone.utc), + ) + + self.assertTrue(result.complete) + self.assertEqual(result.page_count, 2) + self.assertEqual( + [item.payload_id for item in result.observations], + ["302900401", "302900402", "302900403"], + ) + + async def test_row_parse_failure_keeps_good_rows_but_is_incomplete( + self, + ) -> None: + listing = b""" + + + + + + """ + with ( + patch( + "quantmind.preprocess.pr_newswire._DEFAULT_FETCH_POLICY", + _TEST_POLICY, + ), + respx.mock(assert_all_called=True) as router, + ): + router.get(url__regex=_LISTING_RE).mock( + side_effect=lambda request: _listing_response(listing, request) + ) + result = await _discover_pr_newswire( + start=datetime(2026, 7, 14, 3, 58, tzinfo=timezone.utc), + end=datetime(2026, 7, 14, 4, 30, tzinfo=timezone.utc), + ) + + self.assertFalse(result.complete) + self.assertEqual(result.observed_count, 1) + self.assertEqual(result.failures[0].stage, "discovery_parse") + self.assertEqual(result.failures[0].error_type, "invalid_content") + + async def test_fetch_failure_is_recorded(self) -> None: + with ( + patch( + "quantmind.preprocess.pr_newswire._DEFAULT_FETCH_POLICY", + _TEST_POLICY, + ), + respx.mock(assert_all_called=True) as router, + ): + router.get(url__regex=_LISTING_RE).mock( + return_value=httpx.Response(400) + ) + result = await _discover_pr_newswire( + start=datetime(2026, 7, 14, 3, 58, tzinfo=timezone.utc), + end=datetime(2026, 7, 14, 4, 30, tzinfo=timezone.utc), + ) + + self.assertFalse(result.complete) + self.assertEqual(result.page_count, 0) + self.assertEqual(result.failures[0].stage, "discovery_fetch") + self.assertEqual(result.failures[0].error_type, "http_status") + + async def test_page_limit_is_an_explicit_incomplete_failure(self) -> None: + pages = { + 1: _fixture("listing_short_page_1.html"), + 2: _fixture("listing_short_page_2.html"), + } + + def respond(request: httpx.Request) -> httpx.Response: + return _listing_response( + pages[int(request.url.params["page"])], + request, + ) + + with ( + patch( + "quantmind.preprocess.pr_newswire._DEFAULT_FETCH_POLICY", + _TEST_POLICY, + ), + patch("quantmind.preprocess.pr_newswire._MAX_PAGES", 2), + respx.mock(assert_all_called=True) as router, + ): + router.get(url__regex=_LISTING_RE).mock(side_effect=respond) + result = await _discover_pr_newswire( + start=datetime(2026, 7, 12, tzinfo=timezone.utc), + end=datetime(2026, 7, 14, 4, 30, tzinfo=timezone.utc), + ) + + self.assertFalse(result.complete) + self.assertEqual(result.page_count, 2) + self.assertIn("exceeded 2 pages", result.failures[0].message) + + async def test_cache_bust_retries_listing_404(self) -> None: + with ( + patch( + "quantmind.preprocess.pr_newswire._DEFAULT_FETCH_POLICY", + _TEST_POLICY, + ), + respx.mock(assert_all_called=True) as router, + ): + route = router.get(url__regex=_LISTING_RE).mock( + side_effect=[ + httpx.Response(404), + httpx.Response( + 200, + headers={"Content-Type": "text/html"}, + content=_fixture("listing_full.html"), + ), + ] + ) + result = await _discover_pr_newswire( + start=datetime(2026, 7, 14, 4, 29, tzinfo=timezone.utc), + end=datetime(2026, 7, 14, 4, 30, tzinfo=timezone.utc), + ) + + self.assertTrue(result.complete) + self.assertEqual(route.call_count, 2) + self.assertNotEqual( + route.calls[0].request.url.params["_"], + route.calls[1].request.url.params["_"], + ) + + async def test_rejects_invalid_windows_before_network_work(self) -> None: + with self.assertRaisesRegex(ValueError, "aware start"): + await _discover_pr_newswire( + start=datetime(2026, 7, 14, 3, 58), + end=datetime(2026, 7, 14, 4, 30, tzinfo=timezone.utc), + ) + with self.assertRaisesRegex(ValueError, "end after start"): + await _discover_pr_newswire( + start=datetime(2026, 7, 14, 4, 30, tzinfo=timezone.utc), + end=datetime(2026, 7, 14, 4, 30, tzinfo=timezone.utc), + ) + + +class CollectPRNewswireTests(unittest.IsolatedAsyncioTestCase): + async def test_collects_article_and_discards_raw_html_by_default( + self, + ) -> None: + article_url = ( + "https://www.prnewswire.com/news-releases/in-window-302900202.html" + ) + with ( + patch( + "quantmind.preprocess.pr_newswire._DEFAULT_FETCH_POLICY", + _TEST_POLICY, + ), + respx.mock(assert_all_called=True) as router, + ): + router.get(url__regex=_LISTING_RE).mock( + side_effect=lambda request: _listing_response( + _fixture("listing_full.html"), request + ) + ) + router.get(article_url).mock( + return_value=httpx.Response( + 200, + headers={"Content-Type": "text/html"}, + content=_fixture("article.html"), + ) + ) + result = await _collect_pr_newswire( + start=datetime(2026, 7, 14, 4, 29, tzinfo=timezone.utc), + end=datetime(2026, 7, 14, 4, 30, tzinfo=timezone.utc), + retain_raw_html=False, + ) + + self.assertTrue(result.complete) + self.assertEqual(result.observed_count, 1) + self.assertEqual(result.success_count, 1) + self.assertEqual(result.failure_count, 0) + document = result.documents[0] + self.assertIn("fictional operating details", document.cleaned_markdown) + self.assertIsNone(document.article_artifact.bytes) + self.assertTrue(document.article_artifact.content_hash) + self.assertTrue(document.discovery_artifact.bytes) + self.assertEqual( + document.identity.split(":", 2)[:2], ["news", "pr-newswire"] + ) + self.assertEqual(document.ticker_hints[0].symbol, "EXCO") + + async def test_retain_raw_html_and_record_independent_article_failure( + self, + ) -> None: + article_url = ( + "https://www.prnewswire.com/news-releases/in-window-302900202.html" + ) + with ( + patch( + "quantmind.preprocess.pr_newswire._DEFAULT_FETCH_POLICY", + _TEST_POLICY, + ), + respx.mock(assert_all_called=True) as router, + ): + router.get(url__regex=_LISTING_RE).mock( + side_effect=lambda request: _listing_response( + _fixture("listing_full.html"), request + ) + ) + router.get(article_url).mock(return_value=httpx.Response(404)) + failed = await _collect_pr_newswire( + start=datetime(2026, 7, 14, 4, 29, tzinfo=timezone.utc), + end=datetime(2026, 7, 14, 4, 30, tzinfo=timezone.utc), + retain_raw_html=True, + ) + + self.assertTrue(failed.complete) + self.assertEqual(failed.observed_count, 1) + self.assertEqual(failed.success_count, 0) + self.assertEqual(failed.failures[0].stage, "article_fetch") + + with ( + patch( + "quantmind.preprocess.pr_newswire._DEFAULT_FETCH_POLICY", + _TEST_POLICY, + ), + respx.mock(assert_all_called=True) as router, + ): + router.get(url__regex=_LISTING_RE).mock( + side_effect=lambda request: _listing_response( + _fixture("listing_full.html"), request + ) + ) + router.get(article_url).mock( + return_value=httpx.Response( + 200, + headers={"Content-Type": "text/html"}, + content=_fixture("article.html"), + ) + ) + retained = await _collect_pr_newswire( + start=datetime(2026, 7, 14, 4, 29, tzinfo=timezone.utc), + end=datetime(2026, 7, 14, 4, 30, tzinfo=timezone.utc), + retain_raw_html=True, + ) + + self.assertEqual( + retained.documents[0].article_artifact.bytes, + _fixture("article.html"), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/preprocess/test_wire.py b/tests/preprocess/test_wire.py deleted file mode 100644 index 44563c2..0000000 --- a/tests/preprocess/test_wire.py +++ /dev/null @@ -1,276 +0,0 @@ -"""Tests for provider-pluggable wire ingestion.""" - -import unittest -from pathlib import Path - -import httpx -import respx - -from quantmind.preprocess.fetch.http import FetchPolicy -from quantmind.preprocess.wire import ( - PR_NEWSWIRE, - WireFeedConfig, - WireItemMapping, - build_wire_identity, - fetch_wire_documents, -) - -_FIXTURES = Path(__file__).parent / "fixtures" / "wire" - - -def _fixture(name: str) -> bytes: - return (_FIXTURES / name).read_bytes() - - -class _FeedBodyProvider: - name = "full-content-test-wire" - publisher = "Full Content Test Wire" - body_source = "feed" - - def map_item(self, item): - return WireItemMapping( - payload_id=item.id, - canonical_url=item.url, - title=item.title, - published_at=item.published_at, - ) - - -class WireProviderConfigTests(unittest.TestCase): - def test_builtin_body_sources_are_explicit(self): - self.assertEqual(PR_NEWSWIRE.body_source, "article") - - def test_config_requires_a_feed_url(self): - with self.assertRaises(ValueError): - WireFeedConfig(provider=PR_NEWSWIRE, feed_urls=()) - - def test_identity_uses_all_available_components(self): - base = build_wire_identity( - provider="provider-a", - payload_id="item-1", - canonical_url="https://example.test/release", - ) - - self.assertNotEqual( - base, - build_wire_identity( - provider="provider-b", - payload_id="item-1", - canonical_url="https://example.test/release", - ), - ) - self.assertNotEqual( - base, - build_wire_identity( - provider="provider-a", - payload_id="item-2", - canonical_url="https://example.test/release", - ), - ) - self.assertNotEqual( - base, - build_wire_identity( - provider="provider-a", - payload_id="item-1", - canonical_url="https://example.test/other", - ), - ) - - -class FetchWireDocumentsTests(unittest.IsolatedAsyncioTestCase): - async def test_feed_body_mode_remains_available_for_custom_provider(self): - feed_url = "https://example.test/custom/rss" - feed = b""" - - Example quarterly results - https://example.test/releases/results - custom-example-001 - Example Corp reported fictional quarterly revenue.

-

NASDAQ: EXMPL

- ]]>
-
- """ - with respx.mock(assert_all_called=True) as router: - router.get(feed_url).mock( - return_value=httpx.Response( - 200, - headers={"Content-Type": "application/rss+xml"}, - content=feed, - ) - ) - result = await fetch_wire_documents( - WireFeedConfig( - provider=_FeedBodyProvider(), - feed_urls=(feed_url,), - ) - ) - - self.assertEqual(result.success_count, 1) - self.assertEqual(result.failure_count, 0) - document = result.documents[0] - self.assertIn("fictional quarterly revenue", document.cleaned_markdown) - self.assertIsNone(document.raw_article) - self.assertIn(b"custom-example-001", document.raw_feed_entry.bytes) - self.assertEqual(document.raw_feed_entry.status_code, 200) - self.assertIsNotNone(document.raw_feed_entry.fetched_at) - self.assertEqual(document.ticker_hints[0].symbol, "EXMPL") - - async def test_pr_newswire_returns_both_raw_artifacts(self): - feed_url = "https://example.test/prn/rss" - article_url = "https://example.test/prn/releases/operations-update" - article = _fixture("pr_news_wire_article.html") - with respx.mock(assert_all_called=True) as router: - router.get(feed_url).mock( - return_value=httpx.Response( - 200, - headers={"Content-Type": "application/rss+xml"}, - content=_fixture("pr_news_wire.xml"), - ) - ) - router.get(article_url).mock( - return_value=httpx.Response( - 200, - headers={"Content-Type": "text/html"}, - content=article, - ) - ) - result = await fetch_wire_documents( - WireFeedConfig( - provider=PR_NEWSWIRE, - feed_urls=(feed_url,), - ) - ) - - self.assertEqual(result.success_count, 1) - self.assertEqual(result.failure_count, 0) - document = result.documents[0] - self.assertIn("full article", document.cleaned_markdown) - self.assertIsNotNone(document.raw_article) - assert document.raw_article is not None - self.assertEqual(document.raw_article.bytes, article) - self.assertTrue(document.raw_feed_entry.bytes) - - async def test_duplicate_identity_is_processed_once(self): - feed_url = "https://example.test/duplicate/rss" - item = """ - - Duplicate release - https://example.test/releases/duplicate - duplicate-1 - A complete duplicate body.

]]>
-
- """ - feed = f"{item}{item}".encode() - with respx.mock(assert_all_called=True) as router: - router.get(feed_url).mock( - return_value=httpx.Response( - 200, - headers={"Content-Type": "application/rss+xml"}, - content=feed, - ) - ) - result = await fetch_wire_documents( - WireFeedConfig( - provider=_FeedBodyProvider(), - feed_urls=(feed_url,), - ) - ) - - self.assertEqual(result.success_count, 1) - self.assertEqual(result.failure_count, 0) - - async def test_extraction_failure_is_recorded_and_batch_continues(self): - feed_url = "https://example.test/partial/rss" - bad_url = "https://example.test/releases/bad" - good_url = "https://example.test/releases/good" - feed = f""" - - - Bad release{bad_url} - bad-1Short teaser - - - Good release{good_url} - good-1Short teaser - - - """.encode() - good_article = b""" -
-

Good release

The complete good article body.

-
- """ - with respx.mock(assert_all_called=True) as router: - router.get(feed_url).mock( - return_value=httpx.Response( - 200, - headers={"Content-Type": "application/rss+xml"}, - content=feed, - ) - ) - router.get(bad_url).mock( - return_value=httpx.Response( - 200, - headers={"Content-Type": "application/pdf"}, - content=b"not html", - ) - ) - router.get(good_url).mock( - return_value=httpx.Response( - 200, - headers={"Content-Type": "text/html"}, - content=good_article, - ) - ) - result = await fetch_wire_documents( - WireFeedConfig( - provider=PR_NEWSWIRE, - feed_urls=(feed_url,), - fetch_policy=FetchPolicy(max_attempts=1), - ) - ) - - self.assertEqual(result.success_count, 1) - self.assertEqual(result.failure_count, 1) - self.assertEqual(result.documents[0].payload_id, "good-1") - failure = result.failures[0] - self.assertEqual(failure.item_id, "bad-1") - self.assertEqual(failure.stage, "article_parse") - self.assertEqual(failure.error_type, "invalid_content") - - async def test_feed_failure_does_not_discard_other_feed(self): - bad_feed = "https://example.test/feed/bad" - good_feed = "https://example.test/feed/good" - with respx.mock(assert_all_called=True) as router: - router.get(bad_feed).mock(return_value=httpx.Response(404)) - router.get(good_feed).mock( - return_value=httpx.Response( - 200, - headers={"Content-Type": "application/rss+xml"}, - content=b""" - - Good feed item - https://example.test/releases/good-feed - good-feed-1 - A complete feed body. - - """, - ) - ) - result = await fetch_wire_documents( - WireFeedConfig( - provider=_FeedBodyProvider(), - feed_urls=(bad_feed, good_feed), - fetch_policy=FetchPolicy(max_attempts=1), - ) - ) - - self.assertEqual(result.success_count, 1) - self.assertEqual(result.failure_count, 1) - self.assertEqual(result.failures[0].stage, "feed_fetch") - self.assertEqual(result.failures[0].error_type, "http_status") - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_magic.py b/tests/test_magic.py index 426f6cd..80eba39 100644 --- a/tests/test_magic.py +++ b/tests/test_magic.py @@ -44,6 +44,19 @@ def test_paper_flow_returns_paper_input_and_cfg(self) -> None: # PaperInput is the Annotated[Union[...]] alias; pass through. self.assertEqual(input_type, PaperInput) + def test_resolves_postponed_annotations(self) -> None: + async def postponed_flow( + input: "ArxivIdentifier", + *, + cfg: "PaperFlowCfg | None" = None, + ) -> None: + return None + + input_type, cfg_type = _introspect_flow_signature(postponed_flow) + + self.assertIs(input_type, ArxivIdentifier) + self.assertIs(cfg_type, PaperFlowCfg) + def test_missing_input_param_raises(self) -> None: async def bad(*, cfg: PaperFlowCfg | None = None) -> None: return None diff --git a/tests/test_verify_news_e2e.py b/tests/test_verify_news_e2e.py new file mode 100644 index 0000000..ca5d684 --- /dev/null +++ b/tests/test_verify_news_e2e.py @@ -0,0 +1,142 @@ +import io +import unittest +from contextlib import redirect_stdout +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +from scripts import verify_news_e2e + +_PUBLISHED_AT = datetime(2026, 7, 14, tzinfo=timezone.utc) + + +def _feed_item(*, title: str = "Release", url: str | None = "https://x"): + return SimpleNamespace(title=title, url=url) + + +def _discovery_result( + *, + urls: tuple[str, ...] = ("https://example.test/release",), + failures: tuple[object, ...] = (), + complete: bool = True, + published_at: datetime = _PUBLISHED_AT, +): + return SimpleNamespace( + observations=tuple( + SimpleNamespace( + canonical_url=url, + published_at=published_at, + ) + for url in urls + ), + failures=failures, + complete=complete, + page_count=2, + ) + + +class VerifyNewsE2ETests(unittest.IsolatedAsyncioTestCase): + async def test_main_reports_duplicates_and_passes_both_components(self): + now = datetime(2026, 7, 14, 12, 30, tzinfo=timezone.utc) + feed = SimpleNamespace(items=(_feed_item(), _feed_item(url=None))) + discovery = _discovery_result( + urls=( + "https://example.test/releases/one", + "https://example.test/releases/one", + "https://example.test/releases/two", + ) + ) + + with ( + patch.object( + verify_news_e2e, + "fetch_rss_feed", + new=AsyncMock(return_value=feed), + ), + patch.object( + verify_news_e2e, + "_discover_pr_newswire", + new=AsyncMock(return_value=discovery), + ) as discover, + redirect_stdout(io.StringIO()) as output, + ): + exit_code = await verify_news_e2e.main(now=now) + + self.assertEqual(exit_code, 0) + self.assertIn("[PASS] rss", output.getvalue()) + self.assertIn("duplicates=1", output.getvalue()) + discover.assert_awaited_once_with( + start=now - timedelta(days=1), + end=now, + ) + + async def test_rss_failure_does_not_skip_discovery(self): + discovery = _discovery_result() + with ( + patch.object( + verify_news_e2e, + "fetch_rss_feed", + new=AsyncMock(side_effect=ValueError("bad XML")), + ), + patch.object( + verify_news_e2e, + "_discover_pr_newswire", + new=AsyncMock(return_value=discovery), + ) as discover, + redirect_stdout(io.StringIO()) as output, + ): + exit_code = await verify_news_e2e.main() + + self.assertEqual(exit_code, 1) + self.assertIn("[FAIL] rss: ValueError: bad XML", output.getvalue()) + discover.assert_awaited_once() + + async def test_rss_rejects_empty_or_wholly_unusable_items(self): + feeds = ( + SimpleNamespace(items=()), + SimpleNamespace(items=(_feed_item(url=None),)), + ) + for feed in feeds: + with self.subTest(feed=feed): + with ( + patch.object( + verify_news_e2e, + "fetch_rss_feed", + new=AsyncMock(return_value=feed), + ), + redirect_stdout(io.StringIO()), + ): + passed = await verify_news_e2e._check_rss() + self.assertFalse(passed) + + async def test_discovery_rejects_invalid_results(self): + start = datetime(2026, 7, 13, tzinfo=timezone.utc) + end = start + timedelta(days=1) + failure = SimpleNamespace( + stage="discovery_fetch", + error_type="http_status", + source_url="https://example.test/list", + ) + results = ( + _discovery_result(urls=()), + _discovery_result(complete=False), + _discovery_result(failures=(failure,)), + _discovery_result(published_at=end), + ) + + for result in results: + with self.subTest(result=result): + with ( + patch.object( + verify_news_e2e, + "_discover_pr_newswire", + new=AsyncMock(return_value=result), + ), + redirect_stdout(io.StringIO()), + ): + passed = await verify_news_e2e._check_discovery(start, end) + self.assertFalse(passed) + + +if __name__ == "__main__": + unittest.main()