From ada2e1ee01340ab59f30a0b88d14207079d8804b Mon Sep 17 00:00:00 2001 From: Bradford Levy <5950006+bradfordlynch@users.noreply.github.com> Date: Mon, 3 Aug 2026 08:44:28 -0700 Subject: [PATCH 1/2] Add the earnings-call fact extraction behind each event's disclosure Every realized EARNINGS_RELEASE event carries a disclosure item with kind=facts / source=earnings_call whose content is ten sentences distilled from the call. Participants build on those facts without any account of how they are produced -- data/README called the sample "a stand-in for exposition" and schemas.py did not model the disclosure at all. examples/summary.py is an exact semantic port of the production summarizer, the same way scoring.py ports the scorer: the system prompt, user template, per-model thinking configuration (opus-4-5 manual budget; 4-6/4-7/4-8 adaptive at effort=high), the request builders, format_transcript, and both the strict and lenient fact parsers. A request built here is the request the pipeline sends. Two things the port makes visible. The prompt is the entire structure mechanism -- no tools, no JSON schema, no response_format, no temperature -- so the ten-fact shape is requested in prose and validated after the fact. And the transcript renderer prepends only the call's own header title: no ticker, date, fiscal period, estimates or market data, and no truncation or chunking. The summarizer sees the call and nothing else. Extraction can fail. recover_facts applies only non-corrupting repairs -- it never fabricates, splits, or pads to reach ten -- so an artifact can carry facts: null with a parse_note explaining why. Notebook 02 walks that vocabulary explicitly; agents should handle absent facts. Also adds lenient Disclosure/DisclosureItem models, an optional ANTHROPIC_API_KEY path (not a competition credential; only the notebook's last section uses it, and it self-skips without one), and synthetic sample transcript/artifact files -- real transcripts are licensed and are not distributed here. Verified against production by differential: every constant, the prompts, build_request for all four models, format_transcript in both modes, and 420 parser inputs (20 fixed plus 400 seeded random mutations covering fences, truncation, over/under-count, empty facts and mangled tails) match exactly. Note this cannot regenerate a competition disclosure -- different transcript, and thinking makes the call nondeterministic. SUMMARY_MODEL tracks the pipeline's model constant and will need updating when production switches. --- .env.example | 7 + README.md | 9 +- data/README.md | 10 + data/sample/summary_sample.json | 38 +++ data/sample/transcript_sample.json | 81 +++++ notebooks/02_earnings_call_facts.ipynb | 371 +++++++++++++++++++++++ pyproject.toml | 1 + src/examples/__init__.py | 1 + src/examples/config.py | 34 +++ src/examples/schemas.py | 31 ++ src/examples/summary.py | 392 +++++++++++++++++++++++++ tests/conftest.py | 2 + tests/test_summary.py | 362 +++++++++++++++++++++++ uv.lock | 134 +++++++++ 14 files changed, 1471 insertions(+), 2 deletions(-) create mode 100644 data/sample/summary_sample.json create mode 100644 data/sample/transcript_sample.json create mode 100644 notebooks/02_earnings_call_facts.ipynb create mode 100644 src/examples/summary.py create mode 100644 tests/test_summary.py diff --git a/.env.example b/.env.example index 40c9377..8690a30 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,13 @@ # data so you can still run them top-to-bottom offline. EM_API_KEY= +# ── Optional ── only for the summarizer demo ────────────────────────────── +# notebooks/02_earnings_call_facts.ipynb can run the real fact-extraction call +# against Anthropic's API to show the mechanism end to end. This is NOT a +# competition credential and nothing else in this repo needs it — leave it blank +# and that one section self-skips. Get a key from console.anthropic.com. +ANTHROPIC_API_KEY= + # ── Optional ── sensible default; uncomment only to override ────────────── # Which API stage to hit. Defaults to beta, the stage open to invite-code holders. # Production: https://api.explainingmarkets.ai/v1 diff --git a/README.md b/README.md index ec2e109..b58806e 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,9 @@ notebooks stay short and readable. |---|---| | [`notebooks/00_api_quickstart.ipynb`](notebooks/00_api_quickstart.ipynb) | The read endpoints — events calendar (table + chart), submission health, archive manifest, webhook self-test | | [`notebooks/01_historical_archive.ipynb`](notebooks/01_historical_archive.ipynb) | Download, cache, and load the historical archive, then reproduce the baseline scoring regressions (Koijen & Levy WP, Table 3) with the competition's exact scoring transform | +| [`notebooks/02_earnings_call_facts.ipynb`](notebooks/02_earnings_call_facts.ipynb) | Where each event's ten-fact `disclosure` comes from — the exact prompt, model config, and parsing behind it, and how extraction failures surface | -Both run top-to-bottom. With an API key they hit the live API; **without one they +All three run top-to-bottom. With an API key they hit the live API; **without one they fall back to a small bundled sample** (`data/sample/`), so you can run them — and CI can execute them — with no credentials. @@ -39,6 +40,10 @@ uv run jupyter lab No key yet? Skip the `.env` step and everything still runs in sample mode. +`.env.example` also has an optional `ANTHROPIC_API_KEY`. It is **not** a competition +credential — only the last section of notebook 02 uses it, to run the real fact-extraction +call so you can watch it happen. Leave it blank and that section self-skips. + ## Using the helpers outside a notebook ```python @@ -55,7 +60,7 @@ events_frame(events).head() ```text notebooks/ the numbered, narrative examples -src/examples/ helper package: config · client · schemas · frames · plotting · archive · scoring +src/examples/ helper package: config · client · schemas · frames · plotting · archive · scoring · summary scripts/ small runnable scripts (e.g. headless archive download) tests/ offline unit tests (mocked HTTP + bundled sample data) data/ diff --git a/data/README.md b/data/README.md index d64354e..f13f538 100644 --- a/data/README.md +++ b/data/README.md @@ -16,6 +16,16 @@ Just enough data for the notebooks to run top-to-bottom **without an API key** documented event payload with an inlined disclosure (`facts`). It is a **stand-in for exposition** — the real archive is served by the API and its lines may include additional fields. Treat the API as the source of truth. +- `transcript_sample.json` — a **synthetic** earnings-call transcript for a + fictional company (Northwind Logistics). Real transcripts are licensed and are + not distributed with this repo; this one exists so + `notebooks/02_earnings_call_facts.ipynb` and `examples.summary.format_transcript` + have something to run on offline. The shape mirrors the raw record the + production summarizer renders. +- `summary_sample.json` — a **synthetic** example of the artifact the summarizer + publishes per event: `event_id`, `response.facts` (the ten sentences that become + the disclosure item), `response.parse_note`, and `metadata`. The facts were + written by hand from `transcript_sample.json`; they are not model output. Keep anything here small. Never commit bulk data. diff --git a/data/sample/summary_sample.json b/data/sample/summary_sample.json new file mode 100644 index 0000000..a0cd71f --- /dev/null +++ b/data/sample/summary_sample.json @@ -0,0 +1,38 @@ +{ + "_comment": "SYNTHETIC. The facts below were written by hand from data/sample/transcript_sample.json (fictional company Northwind Logistics) — they are not model output and this event never happened. Shape mirrors the artifact the production summarizer publishes, whose `response.facts` becomes the disclosure item with kind=facts / source=earnings_call.", + "event_id": "ea_NWLG_Q2_2026", + "response": { + "facts": [ + "Total revenue was $842 million, up 14% year over year and above the high end of the $810-825 million guidance range.", + "Managed Freight revenue grew 31% year over year and rose to 38% of total revenue from 33% a year ago.", + "Enterprise shippers on the platform reached 4,180 after net additions of 240 in the quarter.", + "Net revenue retention improved to 118% from 112% in the prior-year quarter.", + "Gross margin expanded 310 basis points year over year to 27.4%, driven primarily by automated load matching rising to 71% of volume from 54%.", + "Adjusted EBITDA was $118 million, a 14.0% margin, versus $71 million and a 9.6% margin a year ago.", + "Adjusted earnings per share was $0.64, up from $0.35 in the prior-year quarter.", + "Free cash flow was $61 million on $96 million of operating cash flow, after $35 million of capital expenditures tied mainly to two new cross-dock facilities.", + "The company repurchased 1.9 million shares for $58 million and the board authorized an incremental $300 million buyback against a net cash position of $160 million.", + "Full-year revenue guidance was raised to $3.38-3.42 billion from $3.28-3.34 billion and adjusted EBITDA guidance to $472-488 million from $430-450 million." + ], + "parse_note": "strict" + }, + "metadata": { + "ticker": "NWLG", + "date_event": "2026-07-28", + "key_dev_id": 1000000000, + "transcript_id": "0000000", + "event_id": "ea_NWLG_Q2_2026", + "event_id_source": "benzinga:ea-past", + "type": "ea-summary-anthropic", + "msg_postprocess": { + "ticker": "NWLG", + "keyDevId": 1000000000, + "id_transcript": "0000000", + "eventDateTime": "2026-07-28T21:00:00Z", + "eventType": "Earnings Calls", + "ticker_period_year": "NWLG_Q2_2026", + "fiscal_period": "Q2", + "fiscal_year": 2026 + } + } +} diff --git a/data/sample/transcript_sample.json b/data/sample/transcript_sample.json new file mode 100644 index 0000000..079258d --- /dev/null +++ b/data/sample/transcript_sample.json @@ -0,0 +1,81 @@ +{ + "_comment": "SYNTHETIC. Northwind Logistics is a fictional company and this call never happened. Real earnings-call transcripts are licensed and are not distributed with this repo; this file exists so format_transcript() and the summarization demo have something to run on offline. Shape mirrors the raw transcript record the production pipeline renders.", + "header": { + "title": "Northwind Logistics, Inc., Q2 2026 Earnings Call, Jul 28, 2026", + "transcriptId": "0000000", + "companyName": "Northwind Logistics, Inc.", + "ticker": "NWLG" + }, + "components": [ + { + "speakerName": "Operator", + "speakerType": "Operator", + "text": "Good afternoon, and welcome to the Northwind Logistics second quarter fiscal 2026 earnings conference call. All participants are in a listen-only mode. After today's presentation, there will be an opportunity to ask questions. I would now like to turn the call over to Dana Whitfield, Head of Investor Relations." + }, + { + "speakerName": "Dana Whitfield", + "speakerType": "Executives", + "text": "Thank you, operator, and good afternoon everyone. Joining me today are Marcus Reyes, our Chief Executive Officer, and Priya Anand, our Chief Financial Officer. Before we begin, I'd remind you that today's call contains forward-looking statements subject to risks and uncertainties, and that we will reference non-GAAP measures reconciled in today's press release." + }, + { + "speakerName": "Marcus Reyes", + "speakerType": "Executives", + "text": "Thanks, Dana. We delivered a strong second quarter. Total revenue was $842 million, up 14% year over year, ahead of the high end of our guidance range of $810 to $825 million. Growth was broad-based, but the standout was our Managed Freight platform, which grew 31% year over year and now represents 38% of total revenue, up from 33% a year ago. We ended the quarter with 4,180 enterprise shippers on the platform, a net addition of 240 in the quarter, and our net revenue retention improved to 118% from 112% last year." + }, + { + "speakerName": "Marcus Reyes", + "speakerType": "Executives", + "text": "Operationally, we continued to see the benefits of the network densification work we started eighteen months ago. Load matching automation now handles 71% of volume without human intervention, up from 54% a year ago, and that is the single biggest driver of the gross margin expansion Priya will walk through. We also opened two new cross-dock facilities in Memphis and Reno, which we expect to contribute meaningfully to capacity in the back half." + }, + { + "speakerName": "Priya Anand", + "speakerType": "Executives", + "text": "Thank you, Marcus. Gross margin was 27.4%, expanding 310 basis points year over year, driven primarily by the automation mix shift Marcus described and, to a lesser extent, favorable purchased transportation rates. Adjusted EBITDA was $118 million, or a 14.0% margin, compared with $71 million and a 9.6% margin in the prior-year quarter. Adjusted earnings per share was $0.64 versus $0.35 a year ago. Operating cash flow was $96 million and free cash flow was $61 million after $35 million of capital expenditures, the majority of which related to the two new facilities." + }, + { + "speakerName": "Priya Anand", + "speakerType": "Executives", + "text": "On the balance sheet, we ended the quarter with $410 million in cash and $250 million drawn on our revolver, for net debt of a negative $160 million. During the quarter we repurchased 1.9 million shares for $58 million, and the board has authorized an incremental $300 million buyback. Turning to guidance: for the third quarter we expect revenue of $865 to $885 million and adjusted EBITDA of $120 to $128 million. For the full year we are raising our revenue outlook to $3.38 to $3.42 billion from $3.28 to $3.34 billion previously, and raising adjusted EBITDA to $472 to $488 million from $430 to $450 million." + }, + { + "speakerName": "Ellis Nakamura", + "speakerType": "Analysts", + "text": "Congrats on the quarter. Can you talk about the durability of the gross margin expansion? How much more runway is there on the automation rate, and should we think about 27.4% as a new baseline or is there seasonality we should be modeling?" + }, + { + "speakerName": "Priya Anand", + "speakerType": "Executives", + "text": "Good question. We think there is more room — our internal target is 80% automated load matching by the end of fiscal 2027, and each incremental ten points has historically been worth roughly 80 to 100 basis points of gross margin. That said, Q2 does benefit from seasonal mix; I'd model the second half closer to 26.5% to 27% rather than extrapolating 27.4% straight across. We are not changing our long-term gross margin target of 30%." + }, + { + "speakerName": "Sofia Brennan", + "speakerType": "Analysts", + "text": "On the enterprise shipper adds — 240 in the quarter is a step up. Is that a pull-forward, and what are you seeing competitively on pricing?" + }, + { + "speakerName": "Marcus Reyes", + "speakerType": "Executives", + "text": "We don't view it as a pull-forward. The pipeline actually grew sequentially, and our win rate on competitive bids improved to 44% from 38%. On pricing, the environment is rational. We took a low-single-digit price increase across the managed book effective in May and saw effectively no elevated churn — gross churn was 4.1% on a trailing twelve month basis, which is the lowest it has been since we went public." + }, + { + "speakerName": "Tom Okafor", + "speakerType": "Analysts", + "text": "One more on capital allocation. With net cash and the raised buyback, how are you weighing repurchases against M&A?" + }, + { + "speakerName": "Priya Anand", + "speakerType": "Executives", + "text": "Our first priority remains organic investment, and the Memphis and Reno facilities are the clearest example. Beyond that we are comfortable being opportunistic on buybacks at current levels. On M&A, we are looking at tuck-ins in final-mile capability, but nothing we would characterize as large or imminent. We are committed to remaining at or below one turn of net leverage." + }, + { + "speakerName": "Operator", + "speakerType": "Operator", + "text": "That concludes today's question-and-answer session. I would like to turn the call back to Marcus Reyes for closing remarks." + }, + { + "speakerName": "Marcus Reyes", + "speakerType": "Executives", + "text": "Thank you all for joining. We're pleased with the quarter, we're raising the year, and we look forward to seeing many of you at our investor day in November. Have a good evening." + } + ] +} diff --git a/notebooks/02_earnings_call_facts.ipynb b/notebooks/02_earnings_call_facts.ipynb new file mode 100644 index 0000000..2842a45 --- /dev/null +++ b/notebooks/02_earnings_call_facts.ipynb @@ -0,0 +1,371 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "cell-01", + "metadata": {}, + "source": [ + "# 02 \u2014 Earnings-call facts: where the disclosure comes from\n", + "\n", + "Every realized `EARNINGS_RELEASE` event carries a `disclosure` block, and inside it an\n", + "item with `kind=\"facts\"` / `source=\"earnings_call\"` whose `content` is **ten sentences**\n", + "distilled from the company's earnings call. Those ten sentences are the substantive\n", + "input most agents build on, so it is worth knowing exactly how they are produced.\n", + "\n", + "This notebook walks the whole path:\n", + "\n", + "1. What you receive \u2014 pull the facts out of an archive record\n", + "2. The artifact behind the pointer\n", + "3. What the model actually saw \u2014 the rendered transcript\n", + "4. The exact prompt, printed in full\n", + "5. The request surface \u2014 and what is deliberately *not* in it\n", + "6. How extraction failures surface (`parse_note`)\n", + "7. Optionally, run the real call yourself\n", + "\n", + "`examples.summary` is an exact semantic port of the production summarizer, so the\n", + "request built here is the request the pipeline sends.\n", + "\n", + "> **You cannot regenerate a competition disclosure with this.** The production input is\n", + "> a licensed full-length transcript that is not distributed with this repo, and extended\n", + "> thinking makes the call nondeterministic even on identical input. Everything below runs\n", + "> on a small **synthetic** transcript for a fictional company." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-02", + "metadata": {}, + "outputs": [], + "source": [ + "import json\n", + "from pathlib import Path\n", + "\n", + "import pandas as pd\n", + "from IPython.display import display\n", + "\n", + "from examples import load_config\n", + "from examples.archive import read_jsonl_gz\n", + "from examples.summary import (\n", + " MODEL_CONFIGS,\n", + " N_FACTS,\n", + " SUMMARY_MODEL,\n", + " build_request,\n", + " facts_from_disclosure,\n", + " format_transcript,\n", + " recover_facts,\n", + " summarize_transcript,\n", + ")\n", + "\n", + "REPO = next(p for p in [Path.cwd(), *Path.cwd().parents] if (p / \"data\" / \"sample\").is_dir())\n", + "SAMPLE_DIR = REPO / \"data\" / \"sample\"\n", + "\n", + "config = load_config()\n", + "print(\"Summarizer demo:\", \"LIVE\" if config.has_anthropic else \"OFF (no ANTHROPIC_API_KEY)\")\n", + "print(\"Facts per summary:\", N_FACTS)\n", + "print(\"Production model:\", SUMMARY_MODEL)" + ] + }, + { + "cell_type": "markdown", + "id": "cell-03", + "metadata": {}, + "source": [ + "## 1. What you receive\n", + "\n", + "Start from the delivered side. Each archive line is an event payload; the facts live at\n", + "`disclosure.items[]` under the entry whose `kind` is `facts` and whose `source` is\n", + "`earnings_call`. `facts_from_disclosure` does that lookup and returns `[]` when an event\n", + "has no such item \u2014 a scheduled-but-not-yet-occurred event legitimately has none, and so\n", + "does a call whose extraction failed.\n", + "\n", + "(The bundled sample is illustrative and carries fewer than ten facts; real archive lines\n", + "carry ten.)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-04", + "metadata": {}, + "outputs": [], + "source": [ + "records = list(read_jsonl_gz(SAMPLE_DIR / \"archive_EARNINGS_RELEASE_2025Q3.jsonl.gz\"))\n", + "\n", + "rows = []\n", + "for record in records:\n", + " ticker = record[\"focal_assets\"][0][\"identifier_value\"]\n", + " for i, fact in enumerate(facts_from_disclosure(record), start=1):\n", + " rows.append({\"ticker\": ticker, \"n\": i, \"fact\": fact})\n", + "\n", + "pd.set_option(\"display.max_colwidth\", 100)\n", + "display(pd.DataFrame(rows).set_index([\"ticker\", \"n\"]))" + ] + }, + { + "cell_type": "markdown", + "id": "cell-05", + "metadata": {}, + "source": [ + "## 2. The artifact behind the pointer\n", + "\n", + "Internally the pipeline publishes a small JSON artifact per event and the competition\n", + "dereferences it. Its shape is worth seeing, because it carries one field the disclosure\n", + "does not: `parse_note`, which records how cleanly the model's output parsed.\n", + "\n", + "`facts` can legitimately be `null` \u2014 see section 6." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-06", + "metadata": {}, + "outputs": [], + "source": [ + "artifact = json.loads((SAMPLE_DIR / \"summary_sample.json\").read_text())\n", + "\n", + "print(\"event_id :\", artifact[\"event_id\"])\n", + "print(\"parse_note:\", artifact[\"response\"][\"parse_note\"])\n", + "print(\"ticker :\", artifact[\"metadata\"][\"ticker\"])\n", + "print(\"model meta:\", artifact[\"metadata\"][\"type\"])\n", + "print()\n", + "for i, fact in enumerate(artifact[\"response\"][\"facts\"], start=1):\n", + " print(f\"{i:2d}. {fact}\")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-07", + "metadata": {}, + "source": [ + "## 3. What the model actually saw\n", + "\n", + "This is the part that most shapes what the facts can possibly contain.\n", + "\n", + "`format_transcript` renders the raw transcript to markdown, and the **only** metadata it\n", + "prepends is the transcript's own header title. No ticker, no event date, no fiscal\n", + "period, no consensus estimate, and no market data are injected separately. There is also\n", + "**no truncation, chunking, or token budgeting** \u2014 the entire call goes into a single user\n", + "message.\n", + "\n", + "So the summarizer knows what was said on the call, plus whatever the title reveals, and\n", + "nothing else." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-08", + "metadata": {}, + "outputs": [], + "source": [ + "transcript = json.loads((SAMPLE_DIR / \"transcript_sample.json\").read_text())\n", + "transcript_md = format_transcript(transcript)\n", + "\n", + "print(f\"{len(transcript['components'])} components -> {len(transcript_md):,} characters\\n\")\n", + "print(transcript_md[:900] + \"\\n...\")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-09", + "metadata": {}, + "source": [ + "## 4. The exact prompt\n", + "\n", + "Printed in full \u2014 this is the literal text the model receives.\n", + "\n", + "Note what the instructions ask for: single-sentence, **quantified**, investor-relevant,\n", + "drawn only from the transcript, and explicitly *not* verbatim quotation. The framing\n", + "target is anticipating the stock's reaction, which is why the facts skew toward numbers,\n", + "guidance, and surprises rather than narrative summary." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-10", + "metadata": {}, + "outputs": [], + "source": [ + "params = build_request(transcript_md)\n", + "\n", + "print(\"=\" * 78)\n", + "print(\"SYSTEM\")\n", + "print(\"=\" * 78)\n", + "print(params[\"system\"])\n", + "print()\n", + "print(\"=\" * 78)\n", + "print(\"USER (transcript body elided)\")\n", + "print(\"=\" * 78)\n", + "user = params[\"messages\"][0][\"content\"]\n", + "head, _, tail = user.partition(\"\")\n", + "print(head + \"\")\n", + "print(f\" ... {len(transcript_md):,} characters of rendered transcript ...\")\n", + "print(\"\")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-11", + "metadata": {}, + "source": [ + "## 5. The request surface\n", + "\n", + "Two things stand out.\n", + "\n", + "**Extended thinking is on.** The model reasons before answering, and on current models\n", + "that is *adaptive* thinking at `effort=\"high\"` \u2014 no fixed token budget. Thinking is a\n", + "large part of why the extraction is nondeterministic.\n", + "\n", + "**There is no structured-output machinery.** No `tools`, no JSON schema, no\n", + "`response_format`, no `temperature`, no stop sequences. The ten-fact JSON shape is\n", + "requested in prose and then *validated after the fact*. That is a deliberate design\n", + "choice with a visible consequence \u2014 section 6." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-12", + "metadata": {}, + "outputs": [], + "source": [ + "display(pd.DataFrame(MODEL_CONFIGS).T.rename_axis(\"model\"))\n", + "\n", + "surface = {k: v for k, v in params.items() if k != \"messages\"}\n", + "surface[\"messages\"] = f\"[1 user message, {len(params['messages'][0]['content']):,} chars]\"\n", + "print(json.dumps(surface, indent=2))" + ] + }, + { + "cell_type": "markdown", + "id": "cell-13", + "metadata": {}, + "source": [ + "## 6. How extraction failures surface\n", + "\n", + "Because structure is enforced by prompt text rather than by the API, the response can\n", + "deviate. The pipeline applies only **safe, non-corrupting** repairs \u2014 it never fabricates\n", + "a fact, never splits one, and never pads to reach ten. Anything it cannot recover safely\n", + "becomes a failure, and the published artifact carries `\"facts\": null` rather than a\n", + "partial list.\n", + "\n", + "The `parse_note` vocabulary below is exactly what you may observe." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-14", + "metadata": {}, + "outputs": [], + "source": [ + "ten = [f\"Fact {i} with revenue of ${i}00 million.\" for i in range(1, 11)]\n", + "payload = json.dumps({\"facts\": ten})\n", + "\n", + "scenarios = {\n", + " \"clean output\": payload,\n", + " \"wrapped in fences\": \"```json\\n\" + payload + \"\\n```\",\n", + " \"trailing commentary\": payload + \"\\n\\nHope that helps!\",\n", + " \"missing closing ]\": '{\"facts\": [' + \", \".join(json.dumps(f) for f in ten) + \"}\",\n", + " \"returned 12 facts\": json.dumps({\"facts\": [*ten, \"Extra A.\", \"Extra B.\"]}),\n", + " \"returned 8 facts\": json.dumps({\"facts\": ten[:8]}),\n", + " \"one empty fact\": json.dumps({\"facts\": [\"\", *ten[1:]]}),\n", + " \"truncated mid-sentence\": payload[:-40],\n", + " \"refusal / prose only\": \"I'm not able to summarize that transcript.\",\n", + "}\n", + "\n", + "rows = []\n", + "for label, raw in scenarios.items():\n", + " facts, note = recover_facts(raw)\n", + " rows.append(\n", + " {\n", + " \"scenario\": label,\n", + " \"parse_note\": note,\n", + " \"facts recovered\": \"\u2014\" if facts is None else len(facts),\n", + " \"published\": \"facts\" if facts is not None else \"null\",\n", + " }\n", + " )\n", + "\n", + "display(pd.DataFrame(rows).set_index(\"scenario\"))" + ] + }, + { + "cell_type": "markdown", + "id": "cell-15", + "metadata": {}, + "source": [ + "## 7. Run it yourself (optional, needs an Anthropic key)\n", + "\n", + "This section calls the real API on the synthetic transcript above. It is **not** a\n", + "competition credential and nothing else in this repo needs it \u2014 set `ANTHROPIC_API_KEY`\n", + "in `.env` only if you want to watch the extraction happen.\n", + "\n", + "Skipped automatically when the key is absent, so the notebook still runs top-to-bottom." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-16", + "metadata": {}, + "outputs": [], + "source": [ + "if not config.has_anthropic:\n", + " print(\"Sample mode: no ANTHROPIC_API_KEY set. Skipping the live summarization call.\")\n", + "else:\n", + " result = summarize_transcript(transcript_md, config.anthropic_client())\n", + "\n", + " print(f\"parse_note : {result.parse_note}\")\n", + " print(f\"stop_reason: {result.stop_reason}\")\n", + " print(f\"tokens : {result.input_tokens:,} in / {result.output_tokens:,} out\")\n", + " print()\n", + " if result.ok:\n", + " for i, fact in enumerate(result.facts, start=1):\n", + " print(f\"{i:2d}. {fact}\")\n", + " else:\n", + " print(\"Extraction failed; production would publish facts: null here.\")\n", + " print(result.raw_response[:500])\n", + "\n", + " if result.thinking_summary:\n", + " print(\"\\n--- summarized thinking ---\")\n", + " print(result.thinking_summary[:800])" + ] + }, + { + "cell_type": "markdown", + "id": "cell-17", + "metadata": {}, + "source": [ + "## What to take away\n", + "\n", + "- The facts are a **quantified, investor-framed distillation of the call and nothing\n", + " else** \u2014 no market data, no estimates, no cross-company context. Anything beyond the\n", + " call is yours to add.\n", + "- They are ordered by the model's judgment of relevance, which is why over-length\n", + " responses are trimmed from the tail.\n", + "- Extraction can fail, and a failure is visible rather than silently patched. Build for\n", + " the case where an event's facts are absent.\n", + "- Running this yourself will **not** reproduce a competition disclosure: different\n", + " transcript, and a nondeterministic call.\n", + "\n", + "Next: [`01_historical_archive.ipynb`](01_historical_archive.ipynb) turns these events into\n", + "a scored analysis frame with the competition's exact scoring transform." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/pyproject.toml b/pyproject.toml index ba9f34d..cb2b35f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,7 @@ readme = "README.md" requires-python = ">=3.11" license = { text = "MIT" } dependencies = [ + "anthropic>=0.40", "httpx>=0.27", "pandas>=2.2", "pyarrow>=16", diff --git a/src/examples/__init__.py b/src/examples/__init__.py index 2da7b5f..052a377 100644 --- a/src/examples/__init__.py +++ b/src/examples/__init__.py @@ -12,6 +12,7 @@ plotting — a colorblind-safe event-calendar chart archive — download, cache, and load the historical event archive scoring — the competition's exact percentile scoring transform + summary — the exact 10-fact extraction behind each event's disclosure """ from __future__ import annotations diff --git a/src/examples/config.py b/src/examples/config.py index b44b1b1..9d4034c 100644 --- a/src/examples/config.py +++ b/src/examples/config.py @@ -7,12 +7,14 @@ Environment variables: EM_API_KEY your submission's API key; sent as the ``X-API-Key`` header EM_API_BASE_URL API base URL (default: beta stage) + ANTHROPIC_API_KEY optional; only :mod:`examples.summary`'s live path uses it """ from __future__ import annotations import os from dataclasses import dataclass +from typing import Any from dotenv import load_dotenv @@ -25,16 +27,25 @@ class Config: ``api_key`` is ``None`` when ``EM_API_KEY`` is unset — callers use :meth:`is_live` to decide between live calls and sample-data fallback. + ``anthropic_api_key`` is separate and entirely optional: nothing in the + competition workflow needs it, and it is used only to *demonstrate* the + summarization call in :mod:`examples.summary`. """ api_key: str | None api_base_url: str + anthropic_api_key: str | None = None @property def is_live(self) -> bool: """True when an API key is present, so live calls can be made.""" return bool(self.api_key) + @property + def has_anthropic(self) -> bool: + """True when an Anthropic key is present, so the summarizer demo can run.""" + return bool(self.anthropic_api_key) + def require_api_key(self) -> str: """Return the API key or raise a clear error if it is missing.""" if not self.api_key: @@ -45,6 +56,28 @@ def require_api_key(self) -> str: ) return self.api_key + def require_anthropic_key(self) -> str: + """Return the Anthropic API key or raise a clear error if it is missing.""" + if not self.anthropic_api_key: + raise RuntimeError( + "ANTHROPIC_API_KEY is not set. This is only needed to run the " + "summarization demo in notebooks/02_earnings_call_facts.ipynb against " + "the real API — it is NOT a competition credential and nothing else in " + "this repo requires it. Get one from console.anthropic.com, add it to " + ".env (see .env.example), then restart the kernel." + ) + return self.anthropic_api_key + + def anthropic_client(self) -> Any: + """Build an ``anthropic.Anthropic`` client from :attr:`anthropic_api_key`. + + Mirrors :meth:`examples.client.Client.from_env`. Raises if the key is + missing, so notebooks should gate on :attr:`has_anthropic` first. + """ + import anthropic + + return anthropic.Anthropic(api_key=self.require_anthropic_key()) + def load_config() -> Config: """Load configuration, reading a local ``.env`` file if one is present.""" @@ -52,4 +85,5 @@ def load_config() -> Config: return Config( api_key=os.environ.get("EM_API_KEY") or None, api_base_url=os.environ.get("EM_API_BASE_URL", DEFAULT_API_BASE_URL).rstrip("/"), + anthropic_api_key=os.environ.get("ANTHROPIC_API_KEY") or None, ) diff --git a/src/examples/schemas.py b/src/examples/schemas.py index a95bfa0..622f96e 100644 --- a/src/examples/schemas.py +++ b/src/examples/schemas.py @@ -42,6 +42,37 @@ class CalendarEvent(_Model): focal_assets: list[AssetIdentifier] = [] +class DisclosureItem(_Model): + """One piece of content attached to a realized event. + + For earnings calls the item to look for is ``kind="facts"`` with + ``source="earnings_call"``: its ``content`` is the ten-sentence summary whose + provenance is documented in :mod:`examples.summary`. Both fields are open + string sets, like ``event_type`` — new kinds and sources can appear without + breaking parsing, so match on them rather than assuming a single item. + + Larger or binary artifacts are referenced rather than inlined, via + ``media_type`` / ``url`` / ``bytes`` / ``sha256``. + """ + + id: str | None = None + kind: str + source: str | None = None + content: list[str] = [] + media_type: str | None = None + url: str | None = None + bytes: int | None = None + sha256: str | None = None + + +class Disclosure(_Model): + """The ``disclosure`` block on a realized event — a bundle of content items.""" + + schema_version: str | None = None + generated_at: str | None = None + items: list[DisclosureItem] = [] + + class ArchiveFile(_Model): """One quarter-by-event-type file in the historical archive manifest. diff --git a/src/examples/summary.py b/src/examples/summary.py new file mode 100644 index 0000000..c5f2bce --- /dev/null +++ b/src/examples/summary.py @@ -0,0 +1,392 @@ +"""Exact port of the Explaining Markets earnings-call fact-extraction summarizer. + +Every ``EARNINGS_RELEASE`` event you receive carries a ``disclosure`` block whose +``items`` include one with ``kind="facts"`` and ``source="earnings_call"``. Its +``content`` is a list of exactly ten sentences. This module is the call that +produces them, so you can see what the extraction optimizes for rather than +treating the facts as an opaque input. + +The production request is deliberately plain: a ``system`` string, one ``user`` +message, and extended thinking. There is **no tool use, no JSON schema, no +``response_format``, and no ``temperature``** — the ten-fact JSON structure is +enforced by the prompt text alone and then validated after the fact by +:func:`parse_facts` / :func:`recover_facts`. That is worth knowing: the model is +free-forming its output, and the ``parse_note`` you may see in an artifact +records how cleanly it did so. + +:data:`SUMMARY_SYSTEM_PROMPT`, :data:`SUMMARY_USER_TEMPLATE`, +:data:`MODEL_CONFIGS`, :func:`parse_facts` and :func:`recover_facts` are exact +semantic ports of the production summarizer — fence stripping, bracket repair, +trim-to-ten and every failure note included — so a request you build here is +byte-identical to the one the pipeline sends. :func:`format_transcript` is the +same renderer the pipeline feeds it. + +**You will not reproduce the competition's facts by running this.** Two reasons, +both unavoidable: the production input is a licensed full-length earnings-call +transcript that is not distributed with this repo, and extended thinking makes +the call nondeterministic even on identical input. Treat a local run as an +illustration of the mechanism, not a way to regenerate or predict a disclosure. + +Typical use:: + + from examples.summary import build_request, format_transcript + + md = format_transcript(transcript) # what the model actually sees + params = build_request(md) # the exact Messages API kwargs + print(params["system"]) + print(params["messages"][0]["content"]) +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Any + +# The number of facts the prompt demands and the parsers enforce. Not a +# soft target: a response with nine or eleven facts is a parse failure. +N_FACTS = 10 + +# The model the live pipeline runs. This tracks the production constant +# (``ANTHROPIC_SUMMARY_MODELS`` in the pipeline's ``app.py``) and is updated when +# production switches models, so a disclosure you received months ago may have +# been produced by an earlier entry in MODEL_CONFIGS. The ``model`` that produced +# a given artifact is recorded on the event's source metadata — trust that over +# this default when you are reasoning about a specific past event. +SUMMARY_MODEL = "claude-opus-4-8" + +# Per-model request configuration. The thinking API differs by model generation: +# - opus-4-5: manual extended thinking (enabled + budget_tokens); adaptive +# unsupported. +# - opus-4-6 / opus-4-7 / opus-4-8: adaptive thinking (no fixed budget). +# opus-4-7+ *requires* it (enabled is rejected) and defaults +# display="omitted", so display="summarized" is set explicitly to keep +# capturing the thinking summary. effort="high" guarantees thinking on this +# extraction task (medium skips it on 4-7). max_tokens is a hard cap on +# thinking+text, not a charge; it is set generously on adaptive models to +# avoid truncation. +SUMMARY_THINKING_BUDGET = 1024 +SUMMARY_MAX_TOKENS = SUMMARY_THINKING_BUDGET + 2000 + +MODEL_CONFIGS: dict[str, dict[str, Any]] = { + "claude-opus-4-5": { + "max_tokens": SUMMARY_MAX_TOKENS, + "thinking": {"type": "enabled", "budget_tokens": SUMMARY_THINKING_BUDGET}, + "output_config": None, + }, + "claude-opus-4-6": { + "max_tokens": 16000, + "thinking": {"type": "adaptive", "display": "summarized"}, + "output_config": {"effort": "high"}, + }, + "claude-opus-4-7": { + "max_tokens": 16000, + "thinking": {"type": "adaptive", "display": "summarized"}, + "output_config": {"effort": "high"}, + }, + "claude-opus-4-8": { + "max_tokens": 16000, + "thinking": {"type": "adaptive", "display": "summarized"}, + "output_config": {"effort": "high"}, + }, +} + +# ----- The prompt (verbatim) ------------------------------------------------- + +SUMMARY_SYSTEM_PROMPT = ( + "You are a financial analyst. You extract the most investor-relevant facts from " + "earnings call transcripts to help investors anticipate the stock's reaction to the call." +) + +SUMMARY_USER_TEMPLATE = """Analyze the following earnings call transcript and extract the {n_facts} most investor-relevant facts. + +Each fact must: +- Be drawn only from the transcript (do not use any outside information). +- Be a single sentence capturing a specific, quantified insight relevant to investors. +- Avoid regurgitating the transcript verbatim; instead, synthesize and distill the information into investor-relevant facts. + +Return only a JSON object with this exact structure (no markdown fences, no commentary): +{{"facts": ["", "", ..., ""]}} + +The list must contain exactly {n_facts} facts. + +Transcript: + +{transcript} +""" # noqa: E501 + + +def build_summary_user_prompt(transcript_md: str) -> str: + """Render the user message for one transcript. + + ``transcript_md`` is the markdown from :func:`format_transcript`. It is + interpolated whole — see that function for what does and does not reach the + model. + """ + return SUMMARY_USER_TEMPLATE.format(n_facts=N_FACTS, transcript=transcript_md) + + +# ----- The request ----------------------------------------------------------- + + +def build_request(transcript_md: str, model: str = SUMMARY_MODEL) -> dict[str, Any]: + """Build the exact Anthropic Messages API kwargs the pipeline sends. + + Raises ``ValueError`` for a model with no entry in :data:`MODEL_CONFIGS`, + rather than guessing a thinking configuration that generation may reject. + + The returned dict is complete — pass it straight to + ``anthropic.Anthropic().messages.create(**params)``. Note what is *absent*: + no ``tools``, no ``response_format``, no ``temperature``, no stop sequences. + """ + if model not in MODEL_CONFIGS: + raise ValueError(f"No config for model {model!r}; known: {list(MODEL_CONFIGS)}") + cfg = MODEL_CONFIGS[model] + params: dict[str, Any] = { + "model": model, + "max_tokens": cfg["max_tokens"], + "thinking": cfg["thinking"], + "system": SUMMARY_SYSTEM_PROMPT, + "messages": [{"role": "user", "content": build_summary_user_prompt(transcript_md)}], + } + if cfg.get("output_config"): + params["output_config"] = cfg["output_config"] + return params + + +def build_batch_request(custom_id: str, transcript_md: str, model: str = SUMMARY_MODEL) -> dict: + """Wrap :func:`build_request` as one Anthropic Batch API request. + + Production summarizes historical quarters through the Batch API, keying each + request by the transcript's id so results can be joined back. + """ + return {"custom_id": str(custom_id), "params": build_request(transcript_md, model)} + + +# ----- The input the model sees ---------------------------------------------- + + +def format_transcript(transcript: dict, *, markdown: bool = True) -> str: + """Render a raw transcript record to the string that goes into the prompt. + + Worth reading closely, because it bounds what the summarizer can know. The + *only* metadata prepended is the transcript's own header title (e.g. + ``"Adeia Inc., Q1 2026 Earnings Call, May 04, 2026"``). No ticker, no event + date, no fiscal period, and no market data are injected separately — and + there is **no truncation, chunking, or token budgeting**: the full call goes + into a single user message. + + ``markdown=False`` reproduces the plain component join, which production uses + only for internal diagnostics — never for the prompt. + """ + if not markdown: + return "\n\n".join([comp["text"] for comp in transcript["components"]]) + + transcript_md = "" + transcript_md += f"# Transcript of {transcript['header']['title']}\n\n" + for component in transcript["components"]: + speaker, speaker_type, text = ( + component["speakerName"], + component["speakerType"], + component["text"], + ) + transcript_md += f"**{speaker} ({speaker_type}):** {text}\n\n" + + return transcript_md + + +# ----- Parsing --------------------------------------------------------------- + + +def parse_facts(response_text: str) -> list[str]: + """Parse a model response into exactly :data:`N_FACTS` non-empty facts. + + Strict: raises ``ValueError`` on any deviation (bad JSON, missing key, wrong + count, empty fact). Production uses this on its offline path; + :func:`recover_facts` is what the live pipeline runs. + """ + text = response_text.strip() + # Strip markdown code fences if the model added them despite instructions. + if text.startswith("```"): + text = text.split("```", 2)[1] + if text.startswith("json"): + text = text[len("json") :] + text = text.strip() + # Fall back to extracting the first {...} block if there is leading/trailing prose. + if not text.startswith("{"): + start = text.find("{") + end = text.rfind("}") + if start == -1 or end == -1 or end <= start: + raise ValueError(f"No JSON object found in response: {response_text[:200]!r}") + text = text[start : end + 1] + + obj = json.loads(text) + if "facts" not in obj: + raise ValueError(f"Response JSON missing 'facts' key: {list(obj.keys())}") + facts = obj["facts"] + if not isinstance(facts, list): + raise ValueError(f"'facts' is not a list: {type(facts).__name__}") + if len(facts) != N_FACTS: + raise ValueError(f"Expected {N_FACTS} facts, got {len(facts)}") + facts = [str(f).strip() for f in facts] + if any(not f for f in facts): + raise ValueError("One or more facts are empty") + return facts + + +def recover_facts(response_text: str) -> tuple[list[str] | None, str]: + """Lenient recovery of ten facts from a possibly malformed response. + + This is the function the live pipeline runs, and its second return value is + the ``parse_note`` recorded on the published artifact. It applies ONLY safe, + non-corrupting repairs and never fabricates or splits a fact: + + - strips markdown fences / surrounding prose + - if the model omitted the array's closing ``]`` (output ends ``"}`` rather + than ``"]}``), inserts it + - if the model returned MORE than ten facts, trims to the first ten (the + prompt orders them by relevance) + + Returns ``(facts, note)``. On anything it cannot recover safely it returns + ``(None, reason)`` so the call can be re-run rather than emitting wrong + output — which is why a published artifact can legitimately carry + ``"facts": null``. + + Note values: ``"strict"``, ``"repaired_close"``, ``"trimmed_from_{n}"`` (or a + ``"+"``-join), or a failure reason: ``"unparseable"``, ``"no_facts_list"``, + ``"empty_fact"``, ``"too_few:{n}"``. + """ + t = response_text.strip() + if t.startswith("```"): + t = t.split("```", 2)[1] + if t.startswith("json"): + t = t[len("json") :] + t = t.strip() + s = t.find("{") + if s == -1: + return None, "unparseable" + t = t[s:] + + # Candidate 1: first '{' to last '}' (handles trailing prose, the normal case). + # Candidate 2: normalize a broken tail to canonical '"]}'. Strip any trailing run of + # structural chars/whitespace (']', '}', spaces) back to the last closing quote of the + # final fact, then append '"]}'. This is non-corrupting: it only touches structural + # characters after the last string, and is only attempted when the last fact string is + # properly closed (body ends with '"'), so truncated-mid-string outputs are rejected. + candidates = [] + e = t.rfind("}") + if e != -1: + candidates.append(("strict", t[: e + 1])) + body = t.rstrip("}] \t\r\n") + if body.endswith('"'): + candidates.append(("repaired_close", body + "]}")) + + note: list[str] = [] + obj = None + for name, cand in candidates: + try: + obj = json.loads(cand) + if name != "strict": + note.append(name) + break + except json.JSONDecodeError: + continue + if obj is None: + return None, "unparseable" + + facts = obj.get("facts") + if not isinstance(facts, list): + return None, "no_facts_list" + facts = [str(f).strip() for f in facts] + if any(not f for f in facts): + return None, "empty_fact" + if len(facts) < N_FACTS: + return None, f"too_few:{len(facts)}" + if len(facts) > N_FACTS: + note.append(f"trimmed_from_{len(facts)}") + facts = facts[:N_FACTS] + return facts, "+".join(note) if note else "strict" + + +# ----- Running the call ------------------------------------------------------ + + +@dataclass(frozen=True) +class SummaryResult: + """One summarization call's outcome. + + ``facts`` is ``None`` when :func:`recover_facts` could not safely recover ten + facts; ``parse_note`` then carries the reason. ``thinking_summary`` is the + model's summarized extended thinking, present only because the request sets + ``display="summarized"``. ``output_tokens`` includes thinking tokens. + """ + + facts: list[str] | None + parse_note: str + raw_response: str + thinking_summary: str | None = None + input_tokens: int | None = None + output_tokens: int | None = None + stop_reason: str | None = None + + @property + def ok(self) -> bool: + """True when ten facts were recovered.""" + return self.facts is not None + + +def summarize_transcript( + transcript_md: str, + client: Any, + model: str = SUMMARY_MODEL, +) -> SummaryResult: + """Run one summarization call and parse the result. + + ``client`` is an ``anthropic.Anthropic`` instance, passed in rather than + constructed here so it can be reused across calls (see + :meth:`examples.config.Config.anthropic_client`). + + API errors propagate; a *parse* failure does not — it comes back as a + :class:`SummaryResult` with ``facts=None`` and an explanatory ``parse_note``, + matching how production records failures without dropping the response. + """ + resp = client.messages.create(**build_request(transcript_md, model)) + + raw = "".join(b.text for b in resp.content if getattr(b, "type", None) == "text") + thinking_summary = ( + "".join(b.thinking for b in resp.content if getattr(b, "type", None) == "thinking") or None + ) + facts, parse_note = recover_facts(raw) + return SummaryResult( + facts=facts, + parse_note=parse_note, + raw_response=raw, + thinking_summary=thinking_summary, + input_tokens=getattr(resp.usage, "input_tokens", None), + output_tokens=getattr(resp.usage, "output_tokens", None), + stop_reason=getattr(resp, "stop_reason", None), + ) + + +# ----- Reading what the API delivers ----------------------------------------- + +FACTS_KIND = "facts" +EARNINGS_CALL_SOURCE = "earnings_call" + + +def facts_from_disclosure(record: dict) -> list[str]: + """Pull the earnings-call facts out of an event or archive record. + + Looks for the first ``disclosure.items[]`` entry with ``kind="facts"`` and + ``source="earnings_call"`` and returns its ``content``. Returns ``[]`` when + the record carries no such item — a scheduled-but-not-yet-occurred event, or + a call whose summarization failed, legitimately has none. + + ``record`` is a raw archive line (as yielded by + :func:`examples.archive.read_jsonl_gz`) or an equivalent event payload. + """ + items = (record.get("disclosure") or {}).get("items") or [] + for item in items: + if item.get("kind") == FACTS_KIND and item.get("source") == EARNINGS_CALL_SOURCE: + return list(item.get("content") or []) + return [] diff --git a/tests/conftest.py b/tests/conftest.py index 6e8cc7c..343db81 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,6 +13,8 @@ SAMPLE_DIR = REPO_ROOT / "data" / "sample" SAMPLE_EVENTS = SAMPLE_DIR / "events_sample.json" SAMPLE_ARCHIVE = SAMPLE_DIR / "archive_EARNINGS_RELEASE_2025Q3.jsonl.gz" +SAMPLE_TRANSCRIPT = SAMPLE_DIR / "transcript_sample.json" +SAMPLE_SUMMARY = SAMPLE_DIR / "summary_sample.json" TEST_BASE_URL = "https://api.test/v1" diff --git a/tests/test_summary.py b/tests/test_summary.py new file mode 100644 index 0000000..4956ad7 --- /dev/null +++ b/tests/test_summary.py @@ -0,0 +1,362 @@ +"""The summarization port — prompt shape, request construction, and fact parsing. + +The parser cases mirror the production test suite one-for-one; if one of these +fails, the port has drifted from the pipeline. +""" + +from __future__ import annotations + +import json +from typing import Any + +import pytest + +from examples.archive import read_jsonl_gz +from examples.summary import ( + MODEL_CONFIGS, + N_FACTS, + SUMMARY_MODEL, + SUMMARY_SYSTEM_PROMPT, + build_batch_request, + build_request, + build_summary_user_prompt, + facts_from_disclosure, + format_transcript, + parse_facts, + recover_facts, + summarize_transcript, +) +from tests.conftest import SAMPLE_ARCHIVE, SAMPLE_SUMMARY, SAMPLE_TRANSCRIPT + + +def _facts(n: int) -> list[str]: + return [f"Fact number {i} with revenue of {i} million." for i in range(1, n + 1)] + + +def _payload(facts: list[str]) -> str: + return json.dumps({"facts": facts}) + + +# ----- parse_facts (strict) -------------------------------------------------- + + +def test_parse_facts_exactly_ten() -> None: + facts = _facts(N_FACTS) + assert parse_facts(_payload(facts)) == facts + + +def test_parse_facts_strips_markdown_fences() -> None: + facts = _facts(N_FACTS) + fenced = "```json\n" + _payload(facts) + "\n```" + assert parse_facts(fenced) == facts + + +def test_parse_facts_extracts_object_from_surrounding_prose() -> None: + facts = _facts(N_FACTS) + text = "Here you go:\n" + _payload(facts) + "\nHope that helps!" + assert parse_facts(text) == facts + + +def test_parse_facts_wrong_count_raises() -> None: + with pytest.raises(ValueError): + parse_facts(_payload(_facts(9))) + + +def test_parse_facts_missing_key_raises() -> None: + with pytest.raises(ValueError): + parse_facts(json.dumps({"items": _facts(N_FACTS)})) + + +def test_parse_facts_empty_fact_raises() -> None: + facts = _facts(N_FACTS) + facts[3] = " " + with pytest.raises(ValueError): + parse_facts(_payload(facts)) + + +def test_parse_facts_garbage_raises() -> None: + with pytest.raises(ValueError): + parse_facts("not json at all") + + +# ----- recover_facts (lenient — this is what the live pipeline runs) --------- + + +def test_recover_facts_strict_passthrough() -> None: + facts = _facts(N_FACTS) + got, note = recover_facts(_payload(facts)) + assert got == facts + assert note == "strict" + + +def test_recover_facts_trims_when_too_many() -> None: + facts = _facts(12) + got, note = recover_facts(_payload(facts)) + assert got == facts[:N_FACTS] + assert "trimmed_from_12" in note + + +def test_recover_facts_repairs_missing_closing_bracket() -> None: + # Model emitted the array without its closing ']' (ends '..."}' not '..."]}'). + facts = _facts(N_FACTS) + broken = '{"facts": [' + ", ".join(json.dumps(f) for f in facts) + "}" + got, note = recover_facts(broken) + assert got == facts + assert "repaired_close" in note + + +def test_recover_facts_too_few_is_unrecoverable() -> None: + got, note = recover_facts(_payload(_facts(8))) + assert got is None + assert note == "too_few:8" + + +def test_recover_facts_empty_fact_is_unrecoverable() -> None: + facts = _facts(N_FACTS) + facts[0] = "" + got, note = recover_facts(_payload(facts)) + assert got is None + assert note == "empty_fact" + + +def test_recover_facts_no_json_is_unrecoverable() -> None: + got, note = recover_facts("the model refused to answer") + assert got is None + assert note == "unparseable" + + +def test_recover_facts_truncated_mid_string_is_rejected() -> None: + # The bracket repair must not "fix" an output that was cut off inside a fact, + # which would silently publish a mangled final sentence. + facts = _facts(N_FACTS) + truncated = '{"facts": [' + ", ".join(json.dumps(f) for f in facts)[:-8] + got, note = recover_facts(truncated) + assert got is None + assert note == "unparseable" + + +def test_recover_facts_facts_not_a_list() -> None: + got, note = recover_facts(json.dumps({"facts": "ten facts"})) + assert got is None + assert note == "no_facts_list" + + +# ----- The prompt ------------------------------------------------------------ + + +def test_system_prompt_is_verbatim() -> None: + assert SUMMARY_SYSTEM_PROMPT == ( + "You are a financial analyst. You extract the most investor-relevant facts from " + "earnings call transcripts to help investors anticipate the stock's reaction to the call." + ) + + +def test_user_prompt_embeds_transcript_in_tags() -> None: + prompt = build_summary_user_prompt("# Transcript of Example\n\n**A (Executives):** hi\n") + assert "\n# Transcript of Example" in prompt + assert prompt.rstrip().endswith("") + + +def test_user_prompt_states_the_ten_fact_contract() -> None: + prompt = build_summary_user_prompt("body") + assert "extract the 10 most investor-relevant facts" in prompt + assert "The list must contain exactly 10 facts." in prompt + + +def test_user_prompt_emits_literal_json_braces() -> None: + # Guards the str.format escaping: the template's {{...}} must survive as a + # literal JSON example rather than being interpolated away. + prompt = build_summary_user_prompt("body") + assert '{"facts": ["", "", ..., ""]}' in prompt + + +# ----- The request ----------------------------------------------------------- + + +def test_build_request_default_model_is_adaptive_with_high_effort() -> None: + params = build_request("body") + assert params["model"] == SUMMARY_MODEL + assert params["thinking"] == {"type": "adaptive", "display": "summarized"} + assert params["output_config"] == {"effort": "high"} + assert params["max_tokens"] == 16000 + + +def test_build_request_opus_4_5_uses_manual_thinking_and_no_output_config() -> None: + params = build_request("body", "claude-opus-4-5") + assert params["thinking"] == {"type": "enabled", "budget_tokens": 1024} + assert "output_config" not in params + + +def test_build_request_has_no_structured_output_controls() -> None: + # Structure is enforced by the prompt text and the parsers, not by the API. + for model in MODEL_CONFIGS: + params = build_request("body", model) + assert set(params) <= { + "model", + "max_tokens", + "thinking", + "system", + "messages", + "output_config", + } + + +def test_build_request_unknown_model_raises() -> None: + with pytest.raises(ValueError, match="No config for model"): + build_request("body", "claude-opus-9-9") + + +def test_build_batch_request_wraps_params_with_custom_id() -> None: + batch = build_batch_request("12345", "body") + assert batch["custom_id"] == "12345" + assert batch["params"] == build_request("body") + + +# ----- The transcript renderer ---------------------------------------------- + +_TRANSCRIPT = { + "header": {"title": "Example Corp., Q2 2026 Earnings Call, Jul 28, 2026"}, + "components": [ + {"speakerName": "Operator", "speakerType": "Operator", "text": "Welcome."}, + {"speakerName": "Jane Doe", "speakerType": "Executives", "text": "Revenue was $1B."}, + ], +} + + +def test_format_transcript_markdown_prepends_only_the_header_title() -> None: + md = format_transcript(_TRANSCRIPT) + assert md.startswith("# Transcript of Example Corp., Q2 2026 Earnings Call, Jul 28, 2026\n\n") + assert "**Operator (Operator):** Welcome.\n\n" in md + assert "**Jane Doe (Executives):** Revenue was $1B.\n\n" in md + # No ticker/date/fiscal metadata is injected beyond what the title carries. + assert "EXMP" not in md + + +def test_format_transcript_plain_joins_component_text_only() -> None: + assert format_transcript(_TRANSCRIPT, markdown=False) == "Welcome.\n\nRevenue was $1B." + + +# ----- Running the call (stubbed client) ------------------------------------ + + +class _Block: + def __init__(self, type_: str, **kw: Any) -> None: + self.type = type_ + for k, v in kw.items(): + setattr(self, k, v) + + +class _Usage: + input_tokens = 1234 + output_tokens = 567 + + +class _Response: + stop_reason = "end_turn" + + def __init__(self, text: str, thinking: str | None = None) -> None: + self.content = [_Block("text", text=text)] + if thinking is not None: + self.content.insert(0, _Block("thinking", thinking=thinking)) + self.usage = _Usage() + + +class _Messages: + def __init__(self, response: _Response) -> None: + self._response = response + self.last_kwargs: dict[str, Any] | None = None + + def create(self, **kwargs: Any) -> _Response: + self.last_kwargs = kwargs + return self._response + + +class _StubClient: + def __init__(self, response: _Response) -> None: + self.messages = _Messages(response) + + +def test_summarize_transcript_success() -> None: + facts = _facts(N_FACTS) + client = _StubClient(_Response(_payload(facts), thinking="Weighing the guidance raise.")) + result = summarize_transcript("# Transcript of X\n\n", client) + + assert result.ok + assert result.facts == facts + assert result.parse_note == "strict" + assert result.thinking_summary == "Weighing the guidance raise." + assert (result.input_tokens, result.output_tokens) == (1234, 567) + assert result.stop_reason == "end_turn" + # The call went out with exactly the ported request. + assert client.messages.last_kwargs == build_request("# Transcript of X\n\n") + + +def test_summarize_transcript_parse_failure_does_not_raise() -> None: + client = _StubClient(_Response(_payload(_facts(8)))) + result = summarize_transcript("body", client) + + assert not result.ok + assert result.facts is None + assert result.parse_note == "too_few:8" + assert result.raw_response # the response is kept for inspection + + +def test_summarize_transcript_without_thinking_blocks() -> None: + client = _StubClient(_Response(_payload(_facts(N_FACTS)))) + assert summarize_transcript("body", client).thinking_summary is None + + +# ----- Reading what the API delivers ---------------------------------------- + + +def test_facts_from_disclosure_reads_the_sample_archive() -> None: + records = list(read_jsonl_gz(SAMPLE_ARCHIVE)) + assert records + for record in records: + facts = facts_from_disclosure(record) + assert facts + assert all(isinstance(f, str) and f for f in facts) + + +def test_facts_from_disclosure_ignores_other_kinds_and_sources() -> None: + record = { + "disclosure": { + "items": [ + {"kind": "facts", "source": "press_release", "content": ["nope"]}, + {"kind": "transcript", "source": "earnings_call", "content": ["nope"]}, + {"kind": "facts", "source": "earnings_call", "content": ["yes"]}, + ] + } + } + assert facts_from_disclosure(record) == ["yes"] + + +def test_facts_from_disclosure_missing_is_empty() -> None: + assert facts_from_disclosure({}) == [] + assert facts_from_disclosure({"disclosure": None}) == [] + assert facts_from_disclosure({"disclosure": {"items": []}}) == [] + + +# ----- The bundled samples the notebook runs on ----------------------------- + + +def test_sample_transcript_renders_and_is_prompt_ready() -> None: + transcript = json.loads(SAMPLE_TRANSCRIPT.read_text()) + md = format_transcript(transcript) + assert md.startswith("# Transcript of Northwind Logistics") + assert "**Priya Anand (Executives):**" in md + # The whole rendered call must fit in one user message — no chunking exists. + assert build_summary_user_prompt(md).count("") == 1 + + +def test_sample_summary_matches_the_published_artifact_shape() -> None: + artifact = json.loads(SAMPLE_SUMMARY.read_text()) + assert artifact["event_id"] == artifact["metadata"]["event_id"] + assert artifact["response"]["parse_note"] == "strict" + facts = artifact["response"]["facts"] + assert len(facts) == N_FACTS + assert all(isinstance(f, str) and f.strip() for f in facts) + # A published artifact must survive the same parser the pipeline applies. + recovered, note = recover_facts(json.dumps({"facts": facts})) + assert recovered == facts + assert note == "strict" diff --git a/uv.lock b/uv.lock index e3520d1..b82bb1b 100644 --- a/uv.lock +++ b/uv.lock @@ -25,6 +25,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] +[[package]] +name = "anthropic" +version = "0.120.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "docstring-parser" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d7/10/4ca013cb166f226bd89e0aeb0fcaff94f45ddf716d4925ce89475d3c587b/anthropic-0.120.2.tar.gz", hash = "sha256:9722efc10c27a30a69f5338ddacdb35bc6a64297a4e4ba729bf83af873d5fb3a", size = 1008421, upload-time = "2026-07-28T17:38:26.986Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/af/0f5db57b9397a0f3b7fc204cbef143401a7cadaf982330f97f1ce3d39f34/anthropic-0.120.2-py3-none-any.whl", hash = "sha256:0f0bc2b381dc0eb41c8d886b815d79c2041cd2374f83aed36f574b6dc9c579c1", size = 1022851, upload-time = "2026-07-28T17:38:25.466Z" }, +] + [[package]] name = "anyio" version = "4.14.1" @@ -549,11 +568,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" }, ] +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + +[[package]] +name = "docstring-parser" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, +] + [[package]] name = "examples" version = "0.1.0b1" source = { editable = "." } dependencies = [ + { name = "anthropic" }, { name = "httpx" }, { name = "jupyterlab" }, { name = "matplotlib" }, @@ -579,6 +617,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "anthropic", specifier = ">=0.40" }, { name = "httpx", specifier = ">=0.27" }, { name = "jupyterlab", specifier = ">=4.2" }, { name = "matplotlib", specifier = ">=3.8" }, @@ -846,6 +885,92 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "jiter" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/1f/10936e16d8860c70698a1aa939a46aa0224813b782bce4e000e637da0b2d/jiter-0.16.0.tar.gz", hash = "sha256:7b24c3492c5f4f84a37946ad9cf504910cf6a782d6a4e0689b6673c5894b4a1c", size = 176431, upload-time = "2026-06-29T13:05:13.657Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/3f/fae6cc967d120ec89e31c5418a51176d8278b3087fbb384a9176754f353c/jiter-0.16.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:67fddeda1688f0cce2d2ae83ccf8a80f79936f2d2997d6cc2261f82fdb54a4d3", size = 309289, upload-time = "2026-06-29T13:02:52.301Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e3/97c6c3562c077f6247d6e6ce5c82562500b6316c0d928e97e106b7a1321a/jiter-0.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c90c0f63df322be920eda6ce622e3083d8906ba267f8220fe7873213b8b4430e", size = 315181, upload-time = "2026-06-29T13:02:53.964Z" }, + { url = "https://files.pythonhosted.org/packages/7b/89/d8d073f8aa2667e46c6c0873f86fe4a512bba4293cc730f626a076211a62/jiter-0.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64c0203212098470032aabcde9356fc168f377aade3e43def61dfe17e92f2037", size = 340939, upload-time = "2026-06-29T13:02:55.412Z" }, + { url = "https://files.pythonhosted.org/packages/87/c9/db4fda3ed73fb864139305e935e5b8b38a5a24692a5a9dd356c22f1b9c8d/jiter-0.16.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12288303c9844e61e1651d02a9a6f6633e47d39f897d6991d1427161ce6b746e", size = 364932, upload-time = "2026-06-29T13:02:57.28Z" }, + { url = "https://files.pythonhosted.org/packages/a2/74/52b5e86241057f52ddd7c9a580f90effb51f9d06239f6fc612279b91a838/jiter-0.16.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cf109d010b4b05a105afb3d43be36a21322d345ad3111e13d15f680afef0e5b", size = 461132, upload-time = "2026-06-29T13:02:58.994Z" }, + { url = "https://files.pythonhosted.org/packages/a9/87/544a700f7447c1f31c5d7833821a4daa5683165c2d5a094fbf5b5800c3dc/jiter-0.16.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:62c1b7fe1f77925acf5af68b6140b8810fa87dfd4dc0a9c8568ec2fa2a10429c", size = 374857, upload-time = "2026-06-29T13:03:00.455Z" }, + { url = "https://files.pythonhosted.org/packages/40/cd/0fcc3f7d39183674d5bfa9ec640faaeb506c60be7c8f94625dfba366e37c/jiter-0.16.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8597d23c87f59294f83bcb6229b9ed1fccee13dbba967b46930d2f1759466fee", size = 347053, upload-time = "2026-06-29T13:03:02.045Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ae/c7e64e7932ad597fa395b61440b249ada6366716e25c6e08dd2afbd021e6/jiter-0.16.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:3126a5dbad56401989ac769aca0cb56005bfb3e2366eea0ca99d1a91c3c1ee03", size = 356153, upload-time = "2026-06-29T13:03:03.706Z" }, + { url = "https://files.pythonhosted.org/packages/d4/1c/1c719044f14da814e1a060191ab19b96f3e99207bc5b4bfc6d6be34b3f80/jiter-0.16.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c4b4717bdb35ae456f831a6b08d01880fff399887a6bbc526a583a406e484eea", size = 393956, upload-time = "2026-06-29T13:03:05.165Z" }, + { url = "https://files.pythonhosted.org/packages/3b/dc/7b2f303a2847207e265503853a2d964a55354cffd62a5f2936c155486798/jiter-0.16.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:adff21bc78edfe086c15eb495b900306076de378dc2337c132401fc39bd79c91", size = 521081, upload-time = "2026-06-29T13:03:06.886Z" }, + { url = "https://files.pythonhosted.org/packages/c2/5f/501cf6e1e09caeb420195179ffc6f62aca603f1220ec53fd80d0d70b3e56/jiter-0.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:dab907db06fc593645e73109acf4581ba5b548897d28b9348dc41ddc8343b2d3", size = 552085, upload-time = "2026-06-29T13:03:08.339Z" }, + { url = "https://files.pythonhosted.org/packages/79/54/aa5be86520113b79455c3877f3d1f07a348098df4083ba3688e9537e52dd/jiter-0.16.0-cp311-cp311-win32.whl", hash = "sha256:560b2cf3fb03240cd34f27409a238547488708f05b7c3924f571a60422251ec7", size = 206755, upload-time = "2026-06-29T13:03:09.653Z" }, + { url = "https://files.pythonhosted.org/packages/64/ec/2feb893eb330bd69b413866f4d5daada33c3962f1c6f270c91ca2d87fdf9/jiter-0.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:e431cfc9caf44c1d5459ff77d4e64cbf85fddb6a35dad836a15c6a9ec23087c1", size = 199155, upload-time = "2026-06-29T13:03:10.979Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9c/ca040d94415048a3666fc237774df8151c96f8d2b661cbe3b184acc95876/jiter-0.16.0-cp311-cp311-win_arm64.whl", hash = "sha256:2a8e9e39cf083016137aa5cadafe3188adc2ba6ba1fbf1e5d18889ad3e9ad056", size = 194403, upload-time = "2026-06-29T13:03:12.341Z" }, + { url = "https://files.pythonhosted.org/packages/83/2b/52ace16ed031354f0539749a49e4bf33797d82bea5137910835fa4b09793/jiter-0.16.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:67c3bc1760f8c99d805dcab4e644027142a53b1d5d861f18780ebdbd5d40b72a", size = 306943, upload-time = "2026-06-29T13:03:14.035Z" }, + { url = "https://files.pythonhosted.org/packages/94/2e/34957c2c1b661c252ba9bcc60ae0bddc27e0f7202c6073326a13c5390eec/jiter-0.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5af7780e4a26bd7d0d989592bf9ef12ebf806b74ab709223ecca37c749872ea9", size = 307779, upload-time = "2026-06-29T13:03:15.418Z" }, + { url = "https://files.pythonhosted.org/packages/88/6c/59bd309cab4460c54cf1079f3eb7fe7af6a4c895c5c957a53378693bad2b/jiter-0.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5bf78d0e05e45cfdd66558893938d59afe3d1b1a824a202039b20e607d25a72", size = 335826, upload-time = "2026-06-29T13:03:17.11Z" }, + { url = "https://files.pythonhosted.org/packages/3b/8c/f5ef7b65f0df47afa16596969defb281ebb86e96df346d62be6fd853d620/jiter-0.16.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f4444a83f946605990c98f625cdd3d2725bfb818158760c5748c653170a20e0e", size = 362573, upload-time = "2026-06-29T13:03:18.781Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0b/ace4354da061ee38844a0c27dc2c21eecd27aea119e8da324bea987522d0/jiter-0.16.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3a23f0e4f957e1be65752d2dfac9a5a06b1917af8dc85deb639c3b9d02e31290", size = 457979, upload-time = "2026-06-29T13:03:20.293Z" }, + { url = "https://files.pythonhosted.org/packages/55/40/c0253d3772eb9dcd8e6606ee9b2d53ec8e5b814589c47f140aa585f21eaa/jiter-0.16.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c22a488f7b9218e245a0025a9ba6b100e2e54700831cf4cf16833a27fba3ad01", size = 372302, upload-time = "2026-06-29T13:03:21.739Z" }, + { url = "https://files.pythonhosted.org/packages/a8/d2/4839422241aa12860ce597b20068727094ba0bc480723c74924ca5bad483/jiter-0.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46add52f4ad47a08bfb1219f3e673da972191489a33016edefdb5ea55bfa8c48", size = 343805, upload-time = "2026-06-29T13:03:23.384Z" }, + { url = "https://files.pythonhosted.org/packages/e2/59/e196888a05befdda7dbe299b722d56f2f6eec65402bc34c0a3306d595feb/jiter-0.16.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:9c8a956fd72c2cf1e730d01ea080341f13aa0a97a4a33b51abebe725b7ae9ca9", size = 351107, upload-time = "2026-06-29T13:03:24.815Z" }, + { url = "https://files.pythonhosted.org/packages/ec/74/4cd9e0fca65232136400354b630fbfcd2de634e22ccbb96567725981b548/jiter-0.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:561926e0573ffe4a32498420a76d64b16c513e1ab413b9d28158a8764ac701e5", size = 388441, upload-time = "2026-06-29T13:03:26.266Z" }, + { url = "https://files.pythonhosted.org/packages/d9/8c/554691e48bc711299c0a293dd8a6179e24b2d66a54dc295421fcf64569c0/jiter-0.16.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:44d019fa8cdaf89bf29c71b39e3712143fdd0ac76725c6ef954f9957a5ea8730", size = 516354, upload-time = "2026-06-29T13:03:28.02Z" }, + { url = "https://files.pythonhosted.org/packages/a4/cb/01e9d69dc2cc6759d4f91e230b34489c4fdb2518992650633f9e20bece89/jiter-0.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:0df91907609837f33341b8e6fe73b95991fdaa57caf1a0fbd343dffe826f386f", size = 547880, upload-time = "2026-06-29T13:03:29.534Z" }, + { url = "https://files.pythonhosted.org/packages/79/70/2953195f1c6ad00f49fa67e13df7e60acb3dd4f387101bc15abccddd905e/jiter-0.16.0-cp312-cp312-win32.whl", hash = "sha256:51d7b836acb0108d7c77df1742332cac2a1fa04a74d6dacec46e7091f0e91274", size = 203473, upload-time = "2026-06-29T13:03:31.025Z" }, + { url = "https://files.pythonhosted.org/packages/2d/05/2909a8b10699a4d560f8c502b6b2c5f3991b682b1922c1eedda242b225bd/jiter-0.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:1878349266f8ee36ecb1375cc5ba2f115f35fd9f0a1a4119e725e379126647f7", size = 196905, upload-time = "2026-06-29T13:03:32.472Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a9/6b82bb1c8d7790d602489b967b982a909e5d092875a6c2ade96444c8dfc5/jiter-0.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:2ed5738ae4af18271a51a528b8811b0cbfa4a1858de9d83359e4169855d6a331", size = 190618, upload-time = "2026-06-29T13:03:34.672Z" }, + { url = "https://files.pythonhosted.org/packages/91/c0/555fc60473d30d66894ba825e63615e3be7524fac23858356afa7a38906c/jiter-0.16.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:41977aa5654023948c2dae2a81cbf9c43343954bef1cd59a154dd15a4d84c195", size = 306203, upload-time = "2026-06-29T13:03:36.243Z" }, + { url = "https://files.pythonhosted.org/packages/d0/2b/c3eaf16f5d7c9bad66ea32f40a95bd169b29a91217fcc7f081375157e99c/jiter-0.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d28bb3c26762358dadf3e5bf0bccd29ae987d65e6988d2e6f49829c76b003c09", size = 306489, upload-time = "2026-06-29T13:03:37.846Z" }, + { url = "https://files.pythonhosted.org/packages/96/3f/02fdfc6705cad96127d883af5c34e4867f554f29ec7705ec1a46156400a9/jiter-0.16.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0542a7189c26920778658fc8fcf2af8bae05bae9924577f71804acef37996536", size = 335453, upload-time = "2026-06-29T13:03:39.221Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a6/e4bda5920d4b0d7c5dfb7174ce4a6b2e4d3e11c9162c452ef0eab4cdbdbd/jiter-0.16.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8fb8de1e23a0cb2a7f53c335049c7b72b6db41aa6227cdcc0972a1de5cb39450", size = 361625, upload-time = "2026-06-29T13:03:40.597Z" }, + { url = "https://files.pythonhosted.org/packages/b7/97/4e6b59b2c6e55cbb3e183595f81ad65dcfb21c915fee5e19e335df21bc55/jiter-0.16.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b72d0b2990ca754a9102779ac98d8597b7cb31678958562214a007f909eab78e", size = 456958, upload-time = "2026-06-29T13:03:42.074Z" }, + { url = "https://files.pythonhosted.org/packages/15/e0/97e9557686d2f94f4b93786eccb7eed28e9228ad132ea8237f44727314a7/jiter-0.16.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5f91b1c27fc22a57993d5a5cb8a627cb8ed4b10502716fac1ffbfe1d19d84e8", size = 372017, upload-time = "2026-06-29T13:03:43.658Z" }, + { url = "https://files.pythonhosted.org/packages/0f/94/db768b6938e0df35c86beeba3dfbbb025c9ee5c19e1aa271f2396e50864d/jiter-0.16.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c682bea068a90b764577bdb78a60a4c1d1606daf9cd4c893832a37c7cc9d9026", size = 343320, upload-time = "2026-06-29T13:03:45.226Z" }, + { url = "https://files.pythonhosted.org/packages/c1/d6/5a59d938244a30735fe62d9433fd325f9021ea29d89780ea4596ea93bc89/jiter-0.16.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:8d031aabecc4f1b6276adfb42e3aabb77c89d468bf616600e8d3a11328929053", size = 350520, upload-time = "2026-06-29T13:03:46.671Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/c4a857f49c9af125f6bbcac7e3eee7f7978ed89682833062e2dbf62576b1/jiter-0.16.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eab2cd170150e70153de16896a1774e3a1dca80154c56b54d7a812c479a7165e", size = 387550, upload-time = "2026-06-29T13:03:48.361Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d6/5fbc2f7d6b67b754caa61a993a2e626e815dec47ffc2f9e35f01adfebec7/jiter-0.16.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:6edb63a46e65a82c26800a868e49b2cac30dd5a4218b88d74bc2c848c8ad60bb", size = 515424, upload-time = "2026-06-29T13:03:49.881Z" }, + { url = "https://files.pythonhosted.org/packages/ed/54/284f0164b64a5fed915fea6ba7e9ba9b3d8d37c67d59cf2e3bb99d45cdfe/jiter-0.16.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:659039cc50b5addcc35fcc87ae2c1833b7c0a8e5326ef631a75e4478447bcf84", size = 546981, upload-time = "2026-06-29T13:03:51.363Z" }, + { url = "https://files.pythonhosted.org/packages/13/c5/2a467585a576594384e1d2c43e1224deaafc085f24e243529cf98beef8e1/jiter-0.16.0-cp313-cp313-win32.whl", hash = "sha256:c9c53be232c2e206ef9cdbad81a48bfa74c3d3f08bcf8124630a8a748aad993e", size = 202853, upload-time = "2026-06-29T13:03:53.015Z" }, + { url = "https://files.pythonhosted.org/packages/88/6a/de61d04b9eec69c71719968d2f716532a3bc121170c44a39e14979c6be81/jiter-0.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:baad945ed47f163ad833314f8e3288c396118934f94e7bbb9e243ce4b341a4fd", size = 196160, upload-time = "2026-06-29T13:03:54.447Z" }, + { url = "https://files.pythonhosted.org/packages/19/4b/b390ed59bafb3f31d008d1218578f10327714484b334439947f7e5b11e7f/jiter-0.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:3c1fd2dbe1b0af19e987f03fe66c5f5bd105a2229c1aff4ab14890b24f41d21a", size = 189862, upload-time = "2026-06-29T13:03:55.754Z" }, + { url = "https://files.pythonhosted.org/packages/a7/89/bc4f1b57d5da938fd344a466396541e586d161320d70bffd929aaafcd8f4/jiter-0.16.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b2c61484666ad42726029af0c00ef4541f0f3b5cdc550221f56c2343208018ee", size = 308239, upload-time = "2026-06-29T13:03:57.205Z" }, + { url = "https://files.pythonhosted.org/packages/65/7a/c415453e5213001bf3b411ff65dec3d303b0e76a4a2cfea9768cd4960994/jiter-0.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:63efadc657488f45db1c676d81e704cac2abf3fdb892def1faea61db053127e2", size = 308928, upload-time = "2026-06-29T13:03:58.643Z" }, + { url = "https://files.pythonhosted.org/packages/11/fc/1f4fb7ebf9a724c7741994f4aae18fba1e2f3133df14521a79194952c34a/jiter-0.16.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf0d73f50e7b6935677854f6e8e31d499ca7064dd24734f703e060f5b237d883", size = 336998, upload-time = "2026-06-29T13:04:00.071Z" }, + { url = "https://files.pythonhosted.org/packages/a0/8d/72cadaac05ccfa7cc3a0a2232862e6c72443ca40cf300ba8b57f9f18b69b/jiter-0.16.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3ea07d9bc8e7d03a9fbc051295462e6dbc295b894fd72457c3136e3e43d898", size = 362112, upload-time = "2026-06-29T13:04:01.52Z" }, + { url = "https://files.pythonhosted.org/packages/58/4a/c4b0d5f651fda90a24ffce9f8d56cde462a2e09d31ae3de3c68cef34c04e/jiter-0.16.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:26798522707abb47d767db536e4148ceac1b14446bf028ee85e579a2e043cfe5", size = 459807, upload-time = "2026-06-29T13:04:03.214Z" }, + { url = "https://files.pythonhosted.org/packages/80/58/ef77879ea9aa56b50824edc5a445e226422c7a8d211f3fd2a56bcb9493cf/jiter-0.16.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bc837c1b9631be10abfe0191537fe8009838204cec7e44827401ace390ddb567", size = 373181, upload-time = "2026-06-29T13:04:04.629Z" }, + { url = "https://files.pythonhosted.org/packages/49/2e/ffbc3f254e4d8a66da3062c624a7df4b7c2b2cf9e1fe43cf394b3e104041/jiter-0.16.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49060fd70737fad59d33ba9dcc0d83247dc9e77187de26053a19c16c9f32bd69", size = 344927, upload-time = "2026-06-29T13:04:06.067Z" }, + { url = "https://files.pythonhosted.org/packages/9a/f6/0be5dc6d64a89f80aa8fec984f94dedb2973e251edcae55841d60786d578/jiter-0.16.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:adbb8edeadd431bc4477879d5d371ece7cb1334486584e0f252656dd7ffada29", size = 352754, upload-time = "2026-06-29T13:04:07.477Z" }, + { url = "https://files.pythonhosted.org/packages/da/6e/7d31243b3b91cd261dd19e9d3557fc3251a80883d3d8049c86174e7ab7af/jiter-0.16.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:31aaee5b80f672c1dc21272bcfb9cbdcfc1ea04ff50f00ed5af500b80c44fa93", size = 390553, upload-time = "2026-06-29T13:04:08.92Z" }, + { url = "https://files.pythonhosted.org/packages/25/33/51ae371fde3c88897520f62b4d5f8b27ad7103e2bb10812ff52195609853/jiter-0.16.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:6722bcef4ffc86c835574b1b2fac6b33b9fb4a889c781e67950e891591f3c55a", size = 516900, upload-time = "2026-06-29T13:04:10.407Z" }, + { url = "https://files.pythonhosted.org/packages/a0/45/6449b3d123ea439ba79507c657288f461d55049e7bcbdc2cf8eb8210f491/jiter-0.16.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:5ab4f50ff971b611d656554ea10b75f80097392c827bc32923c6eeb6386c8b00", size = 548754, upload-time = "2026-06-29T13:04:12.046Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e7/fd2fb11ae3e2649333da3aa170d04d7b3000bbdc3b270f6513382fdf4e04/jiter-0.16.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:710cc51d4ebdcd3c1f70b232c1db1ea1344a075770422bbd4bede5708335acbe", size = 122381, upload-time = "2026-06-29T13:04:13.413Z" }, + { url = "https://files.pythonhosted.org/packages/26/80/f0b147a62c315a164ed2168908286ca302310824c218d3aae52b06c0c9a9/jiter-0.16.0-cp314-cp314-win32.whl", hash = "sha256:57b37fc887a32d44798e4d8ebfa7c9683ff3da1d5bf38f08d1bb3573ccb39106", size = 204578, upload-time = "2026-06-29T13:04:14.813Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e6/4758a14304b4523a6f5adb2419340086aa3593bd4327c2b25b5948a90548/jiter-0.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:cbd18dd5e2df96b580487b5745adf57ef64ad89ba2d9662fc3c19386acce7db8", size = 198154, upload-time = "2026-06-29T13:04:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/26/be/41fa54a2e7ea41d6c99f1dc5b1f0fd4cb474680304b5d268dd518e81da3a/jiter-0.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:a32d2027a9fa67f109ff245a3252ece3ccc32cc56703e1deab6cc846a59e0585", size = 191458, upload-time = "2026-06-29T13:04:17.707Z" }, + { url = "https://files.pythonhosted.org/packages/81/6b/59127338b86d9fe4d99418f5a15118bea778103ee0fe9d9dd7e0af174e95/jiter-0.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2577196f4474ef3fc4779a088a23b0897bbf86f9ea3679c372d45b8383b43207", size = 316739, upload-time = "2026-06-29T13:04:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/2d/95/49461034d5388196d3dabf98748935f017b7785d8f3f5349f834bcc4ed0d/jiter-0.16.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:616e89e008a93c01104161c75b4988e58716b01d62307ebfe161e52a56d2a818", size = 340911, upload-time = "2026-06-29T13:04:21.257Z" }, + { url = "https://files.pythonhosted.org/packages/cd/97/a4369f2fb82cb3dda13b98622f31249b2e014b223fe64ee534413ad72294/jiter-0.16.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e2e9efbe042210df657bade597f66d6d75723e3d8f45a12ea6d8167ff8bbce3", size = 361747, upload-time = "2026-06-29T13:04:22.677Z" }, + { url = "https://files.pythonhosted.org/packages/28/51/49b6ed456261646e1906016a6760367a28aacd3c24805e4e5fe64116c1db/jiter-0.16.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f4d9e473a5ce7d27fef8b848df4dc16e283893d3f53b4a585e72c9595f3c284", size = 460225, upload-time = "2026-06-29T13:04:24.441Z" }, + { url = "https://files.pythonhosted.org/packages/33/b5/5689aff4f66c5b60be63106e591dbfcba2190df97d2c9c7cf052361ddb98/jiter-0.16.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d30a4a1c87713060c8d1cc59a7b6c8fb6b8ef0a6900368014c76c87922a2929", size = 373169, upload-time = "2026-06-29T13:04:25.884Z" }, + { url = "https://files.pythonhosted.org/packages/a2/96/3ae1b85ee0d6d6cab254fb7f8da018272b932bbf2d69b07e98aa2a96c746/jiter-0.16.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bae96332410f866e5900d809298b1ed82735932986c672495f9701daacd80620", size = 350332, upload-time = "2026-06-29T13:04:27.302Z" }, + { url = "https://files.pythonhosted.org/packages/15/32/c99d7bafd78986556c95bf60ce84c6cc98786eac56066c12d7f828bb6747/jiter-0.16.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:da3d7ec75dc83bb18bca888b5edfae0656a26849056c59e05a7728badd17e7af", size = 353377, upload-time = "2026-06-29T13:04:28.731Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4b/f99a8e571287c3dec766bcc18528bbe8e8fb5365522ab5e6d64c93e87066/jiter-0.16.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ee6162b77d49a9939229df666dfa8af3e656b6701b54c4c84966d740e189264e", size = 387746, upload-time = "2026-06-29T13:04:30.319Z" }, + { url = "https://files.pythonhosted.org/packages/75/69/c78a5b3f71040e34eb5917df26fb7ae9a2174cad1ccbf277512507c53a6e/jiter-0.16.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:63ffdbdae7d4499f4cda14eadc12ddcabef0fc0c081191bdc2247489cb698077", size = 517292, upload-time = "2026-06-29T13:04:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f7/095b38eda4c70d03651c403f29a5590f16d12ddc5d544aac9f9cddf72277/jiter-0.16.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a111256a7193bea0759267b10385e5870949c239ed7b6ddbaaf57573edb38734", size = 549259, upload-time = "2026-06-29T13:04:33.721Z" }, + { url = "https://files.pythonhosted.org/packages/2e/c5/6a0207d90e5f656d95af98ebd0934f382d37674416f215aeda2ff8063e51/jiter-0.16.0-cp314-cp314t-win32.whl", hash = "sha256:de5ba8763e56b793561f43bed197c9ea55776daa5e9a6b91eed68a909bc9cdbf", size = 206523, upload-time = "2026-06-29T13:04:35.068Z" }, + { url = "https://files.pythonhosted.org/packages/a5/31/c757d5f30a8980fd945ce7b98be10be9e4ff59c7c42f5fd86804c2e87db8/jiter-0.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b8a3f9a6008048fe9def7bf465180564a6e458047d2ce499149cfbe73c3ae9db", size = 200366, upload-time = "2026-06-29T13:04:36.61Z" }, + { url = "https://files.pythonhosted.org/packages/7c/a2/d88de6d313d734a544a7901353ad5db67cb38dcfcd91713b7979dafc345d/jiter-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0fa25b09b13075c46f5bc174f2690525a925a4fc2f7c82969a2bbabff22386ce", size = 190516, upload-time = "2026-06-29T13:04:38.004Z" }, + { url = "https://files.pythonhosted.org/packages/06/d3/8e278946d43eeca2585b4dd0834a887cd71136329b837f3a16ed86a8b4b0/jiter-0.16.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:850ccb1d7eedb4200f4014b1c0e8a577de114fc3cd88faad646dcc9bc4bb12ad", size = 304518, upload-time = "2026-06-29T13:05:00.172Z" }, + { url = "https://files.pythonhosted.org/packages/72/43/28d4ef495028bf0506a413d4db3f4eb3e7288a382e0f065f306a17bbeb5e/jiter-0.16.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:e34e97bda77eb63242a410243c071e28ac7e0d8c0948c5ee658498690a4b2f2f", size = 310207, upload-time = "2026-06-29T13:05:02.123Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ca/c366b1012da1d640de975d9683acd44e4d150d9068845d0ca2610435253f/jiter-0.16.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b7dc85ea77d4abbae8bad0d3538678aedee75bceec4e2f6c8dfb1c74772e5aa5", size = 342771, upload-time = "2026-06-29T13:05:03.55Z" }, + { url = "https://files.pythonhosted.org/packages/16/52/50cc4056fc1ae02e7154704e7ecc89df0afb8300222cfe8a52d3f67e4730/jiter-0.16.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17ca7fae79f6d99cd9a042b75f917eaada7b895cfc7dd2ee3a16089dcaec7a85", size = 346468, upload-time = "2026-06-29T13:05:05.452Z" }, + { url = "https://files.pythonhosted.org/packages/98/ab/664fd8c4be028b2bedd3d2ff08769c4ede23d0dbc87a77c62384a0515b5d/jiter-0.16.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:f17d61a28b4b3e0e3e2ba98490c70501403b4d196f78732439160e7fd3678127", size = 303106, upload-time = "2026-06-29T13:05:07.118Z" }, + { url = "https://files.pythonhosted.org/packages/1a/07/421f1d5b65493a76e16027b848aba6a7d28073ae75944fa4289cc914d39f/jiter-0.16.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:96e38eea538c8ddf853a35727c7be0741c76c13f04148ac5c116222f50ece3b3", size = 304658, upload-time = "2026-06-29T13:05:08.708Z" }, + { url = "https://files.pythonhosted.org/packages/0a/db/bba1155f01a01c3c37a89425d571da751bbedf5c54247b831a04cb971798/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d284fb8d94d5855d60c44fefcab4bf966f1da6fada73992b01f6f0c9bc0c6702", size = 339719, upload-time = "2026-06-29T13:05:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/78/f7/18a1afcd64f35314b68c1f23afcd9994d0bc13e65cc77517afff4e83986d/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64d613743df53199b1aa256a7d328340da6d7078aac7705a7db9d7a791e9cfd2", size = 343885, upload-time = "2026-06-29T13:05:12.087Z" }, +] + [[package]] name = "json5" version = "0.15.0" @@ -2835,6 +2960,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + [[package]] name = "soupsieve" version = "2.8.4" From f8369f9db722579897ff63bc1ce0cacea21b6cc3 Mon Sep 17 00:00:00 2001 From: Bradford Levy <5950006+bradfordlynch@users.noreply.github.com> Date: Mon, 3 Aug 2026 08:44:41 -0700 Subject: [PATCH 2/2] Match the pre-commit ruff rev to the one CI lints with pre-commit pinned ruff v0.9.6 while CI runs `uv run ruff` (0.15.20 via uv.lock), so the two disagreed about what passes. The stale hook failed on archive.py with UP038, a rule newer ruff has since removed: the PEP 604 form it suggests for isinstance is slightly slower, isn't recommended practice, and wrongly implies other typing syntaxes work there. Applying it would have made the code worse to satisfy retracted advice, so archive.py is untouched and the rev moves instead. Also switches the hook id from `ruff` to `ruff-check`; `ruff` is now a legacy alias and the run warns about it. --- .pre-commit-config.yaml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7f04afd..970336c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,10 +1,13 @@ # Notebook hygiene + Python lint/format. Install once with: # uv run pre-commit install repos: + # Keep this rev in step with the `ruff` version resolved in uv.lock — CI lints + # with `uv run ruff`, so a drifting rev means pre-commit and CI disagree about + # what passes, and can enforce rules newer ruff has since retracted. - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.9.6 + rev: v0.15.20 hooks: - - id: ruff + - id: ruff-check args: ["--fix"] - id: ruff-format