diff --git a/README.md b/README.md index 6e38889..61925bc 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,10 @@ api.available_regions() api.available_category() ``` +## Examples + +- [Generate a source-linked news briefing](examples/source_linked_briefing/README.md) from a live Search API response or a deterministic offline fixture. + ## Authentication All requests are authenticated using an `Authorization` header. Pass your API key when instantiating the client: diff --git a/examples/source_linked_briefing/README.md b/examples/source_linked_briefing/README.md new file mode 100644 index 0000000..9e4893e --- /dev/null +++ b/examples/source_linked_briefing/README.md @@ -0,0 +1,100 @@ +# Source-linked news briefing + +This example turns a Currents Search API response into two local files: + +- `briefing.md` for a person to read; +- `briefing.json` for a dashboard, queue, or application-owned agent workflow. + +Every item keeps its source URL and publication time. The script does not call a language model, summarize full publisher articles, or make decisions for the reader. + +The example accompanies [Build a Source-Linked News Briefing with Currents Search API](https://currentsapi.services/en/blog/build-source-linked-news-briefing-currents-search-api). + +## Run the checked-in fixture + +Install the SDK from this checkout: + +```bash +python -m pip install -e . +``` + +Generate deterministic output without a network request or API key: + +```bash +python examples/source_linked_briefing/briefing.py \ + --fixture examples/source_linked_briefing/fixtures/search_response.json \ + --output-dir briefing-output +``` + +The fixture contains fictional `example.com` articles. It contains no customer data, publisher article bodies, or credentials. Its `_fixture_generated_at` value keeps both output files identical across runs. + +For another saved Search API response, add `_fixture_generated_at` at the top level or pass `--generated-at` explicitly. + +## Run a live search + +Create a [free Currents API key](https://currentsapi.services/en/register), then export it: + +```bash +export CURRENTS_API_KEY="your-api-key" +``` + +Run a live search: + +```bash +python examples/source_linked_briefing/briefing.py \ + --keywords "energy storage" \ + --language en \ + --output-dir briefing-output +``` + +Live mode calls `CurrentsAPI.search()`. Fixture mode reads a saved Search API response. Both modes use the same validation, normalization, ordering, and rendering path. + +## Output + +Articles are ordered by publication time, newest first. Titles provide a stable tie-breaker. Missing optional fields become empty values rather than fabricated content. + +The Markdown output remains deliberately plain: + +```markdown +# Source-Linked News Briefing + +Generated at: 2026-07-25T00:00:00Z + +- Battery storage policy enters public consultation - + - Published: 2026-07-25T08:00:00Z + - A fictional example describing a public policy consultation. +``` + +The JSON output contains `generated_at` and a normalized `articles` list. Each normalized article contains: + +- `title` +- `description` +- `url` +- `published` +- `language` +- `category` + +## Where an agent fits + +Currents retrieves structured news results and preserves source context. Your application owns any later prompt, model call, summary, alert, embedding, storage policy, or human-review step. + +If you pass the JSON output to a model, instruct it to use only the supplied items, preserve every source URL, distinguish source facts from inference, and state when the retrieved context is insufficient. + +## Limitations + +- Search results reflect the index at request time. A saved briefing is not automatically refreshed. +- Network failures, invalid credentials, plan limits, and rate limits can stop live execution. +- Overlapping searches can return duplicate articles. This single-query example does not deduplicate across runs. +- Descriptions can be missing or truncated. Follow the publisher URL for the source context. +- Access through an API does not grant republication rights. Follow your Currents plan terms and publisher requirements. +- Do not treat generated or retrieved text as investment, legal, medical, or other high-stakes advice. +- Add retries, caching, incremental date windows, observability, and human review before scheduling production workloads. + +## Test + +Run the focused offline tests: + +```bash +python -m pytest tests/test_source_linked_briefing_example.py +``` + +The tests cover source preservation, publication times, deterministic ordering, malformed responses, the checked-in fixture, and the live SDK adapter. diff --git a/examples/source_linked_briefing/briefing.py b/examples/source_linked_briefing/briefing.py new file mode 100644 index 0000000..acef1eb --- /dev/null +++ b/examples/source_linked_briefing/briefing.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +"""Generate a source-linked news briefing from Currents Search API data.""" + +import argparse +import json +import os +from datetime import datetime, timezone +from pathlib import Path + + +def parse_args(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--fixture", type=Path, help="Read a saved Search API response.") + parser.add_argument( + "--keywords", + default="artificial intelligence", + help="Set the search terms for live mode.", + ) + parser.add_argument( + "--language", + default="en", + help="Set the article language for live mode.", + ) + parser.add_argument( + "--output-dir", + type=Path, + default=Path("briefing-output"), + help="Choose where to write briefing.md and briefing.json.", + ) + parser.add_argument( + "--generated-at", + help="Set the output timestamp for a custom fixture.", + ) + return parser.parse_args() + + +def normalize_article(article): + return { + "title": article.get("title") or "Untitled", + "description": article.get("description") or "", + "url": article.get("url") or "", + "published": article.get("published") or "", + "language": article.get("language") or "", + "category": article.get("category") or [], + } + + +def load_fixture(path): + return json.loads(path.read_text(encoding="utf-8")) + + +def load_live_response(keywords, language): + api_key = os.environ.get("CURRENTS_API_KEY") + if not api_key: + raise ValueError("CURRENTS_API_KEY is required for live mode") + + from currentsapi import CurrentsAPI + + return CurrentsAPI(api_key=api_key).search( + keywords=keywords, + language=language, + ) + + +def validate_response(response): + if not isinstance(response, dict): + raise ValueError("Search API response must be an object") + if response.get("status") != "ok": + raise ValueError("Search API response status must be 'ok'") + if not isinstance(response.get("news"), list): + raise ValueError("Search API response news must be a list") + if any(not isinstance(article, dict) for article in response["news"]): + raise ValueError("Every news item must be an object") + for article in response["news"]: + for field in ("title", "description", "url", "published", "language"): + value = article.get(field) + if value is not None and not isinstance(value, str): + raise ValueError("news item {} must be a string".format(field)) + category = article.get("category") + if category is not None and ( + not isinstance(category, list) + or any(not isinstance(item, str) for item in category) + ): + raise ValueError("news item category must be a list of strings") + + +def resolve_generated_at(args, response): + if args.generated_at: + return args.generated_at + if args.fixture: + fixture_time = response.get("_fixture_generated_at") + if not isinstance(fixture_time, str) or not fixture_time: + raise ValueError( + "Fixture must include _fixture_generated_at or use --generated-at" + ) + return fixture_time + return datetime.now(timezone.utc).isoformat() + + +def build_output(response, generated_at): + validate_response(response) + articles = [normalize_article(article) for article in response["news"]] + articles.sort(key=lambda article: article["title"].casefold()) + articles.sort(key=lambda article: article["published"], reverse=True) + lines = ["# Source-Linked News Briefing", "", "Generated at: {}".format(generated_at), ""] + for article in articles: + title = article["title"] + url = article["url"] + lines.append("- {} - <{}>".format(title, url) if url else "- {}".format(title)) + if article["published"]: + lines.append(" - Published: {}".format(article["published"])) + if article["description"]: + lines.append(" - {}".format(article["description"])) + return "\n".join(lines) + "\n", { + "generated_at": generated_at, + "articles": articles, + } + + +def main(): + args = parse_args() + + try: + response = ( + load_fixture(args.fixture) + if args.fixture + else load_live_response(args.keywords, args.language) + ) + validate_response(response) + generated_at = resolve_generated_at(args, response) + markdown, structured = build_output(response, generated_at) + except (OSError, ValueError, json.JSONDecodeError) as exc: + raise SystemExit("error: {}".format(exc)) + + args.output_dir.mkdir(parents=True, exist_ok=True) + (args.output_dir / "briefing.md").write_text(markdown, encoding="utf-8") + (args.output_dir / "briefing.json").write_text( + json.dumps(structured, indent=2) + "\n", + encoding="utf-8", + ) + print("Wrote {}".format(args.output_dir)) + + +if __name__ == "__main__": + main() diff --git a/examples/source_linked_briefing/fixtures/search_response.json b/examples/source_linked_briefing/fixtures/search_response.json new file mode 100644 index 0000000..10e2287 --- /dev/null +++ b/examples/source_linked_briefing/fixtures/search_response.json @@ -0,0 +1,40 @@ +{ + "_fixture_generated_at": "2026-07-25T00:00:00Z", + "status": "ok", + "news": [ + { + "id": "example-market-001", + "title": "Grid operator announces storage procurement", + "description": "A fictional example describing a new storage procurement round.", + "url": "https://example.com/energy/storage-procurement", + "published": "2026-07-23T14:15:00Z", + "language": "en", + "category": [ + "business" + ] + }, + { + "id": "example-policy-001", + "title": "Battery storage policy enters public consultation", + "description": "A fictional example describing a public policy consultation.", + "url": "https://example.com/energy/storage-policy", + "published": "2026-07-25T08:00:00Z", + "language": "en", + "category": [ + "regional" + ] + }, + { + "id": "example-project-001", + "title": "Utility-scale battery project reaches commissioning", + "description": "A fictional example describing project commissioning.", + "url": "https://example.com/energy/project-commissioning", + "published": "2026-07-24T10:30:00Z", + "language": "en", + "category": [ + "technology" + ] + } + ], + "page": 1 +} diff --git a/tests/test_source_linked_briefing_example.py b/tests/test_source_linked_briefing_example.py new file mode 100644 index 0000000..b4376b0 --- /dev/null +++ b/tests/test_source_linked_briefing_example.py @@ -0,0 +1,302 @@ +import json +import os +import subprocess +import sys +from pathlib import Path + + +EXAMPLE = Path("examples/source_linked_briefing/briefing.py") +CHECKED_IN_FIXTURE = Path( + "examples/source_linked_briefing/fixtures/search_response.json" +) + + +def test_fixture_mode_writes_source_linked_markdown_and_json(tmp_path): + fixture = tmp_path / "search-response.json" + fixture.write_text( + json.dumps( + { + "status": "ok", + "news": [ + { + "title": "Grid storage project reaches commissioning", + "description": "The operator started final commissioning.", + "url": "https://news.example/grid-storage", + "published": "2026-07-24T09:30:00Z", + "language": "en", + "category": ["business"], + } + ], + } + ), + encoding="utf-8", + ) + output_dir = tmp_path / "output" + + result = subprocess.run( + [ + sys.executable, + str(EXAMPLE), + "--fixture", + str(fixture), + "--output-dir", + str(output_dir), + "--generated-at", + "2026-07-25T00:00:00Z", + ], + capture_output=True, + check=False, + text=True, + ) + + assert result.returncode == 0, result.stderr + markdown = (output_dir / "briefing.md").read_text(encoding="utf-8") + structured = json.loads((output_dir / "briefing.json").read_text(encoding="utf-8")) + assert "https://news.example/grid-storage" in markdown + assert "2026-07-24T09:30:00Z" in markdown + assert structured["articles"][0]["url"] == "https://news.example/grid-storage" + assert structured["articles"][0]["published"] == "2026-07-24T09:30:00Z" + + +def test_fixture_mode_orders_newest_articles_first(tmp_path): + fixture = tmp_path / "search-response.json" + fixture.write_text( + json.dumps( + { + "status": "ok", + "news": [ + { + "title": "Earlier report", + "url": "https://news.example/earlier", + "published": "2026-07-23T09:30:00Z", + }, + { + "title": "Latest report", + "url": "https://news.example/latest", + "published": "2026-07-25T09:30:00Z", + }, + ], + } + ), + encoding="utf-8", + ) + output_dir = tmp_path / "output" + + result = subprocess.run( + [ + sys.executable, + str(EXAMPLE), + "--fixture", + str(fixture), + "--output-dir", + str(output_dir), + "--generated-at", + "2026-07-25T10:00:00Z", + ], + capture_output=True, + check=False, + text=True, + ) + + assert result.returncode == 0, result.stderr + structured = json.loads((output_dir / "briefing.json").read_text(encoding="utf-8")) + assert [article["title"] for article in structured["articles"]] == [ + "Latest report", + "Earlier report", + ] + + +def test_fixture_mode_rejects_malformed_search_response(tmp_path): + fixture = tmp_path / "search-response.json" + fixture.write_text( + json.dumps({"status": "ok", "news": {"unexpected": "object"}}), + encoding="utf-8", + ) + output_dir = tmp_path / "output" + + result = subprocess.run( + [ + sys.executable, + str(EXAMPLE), + "--fixture", + str(fixture), + "--output-dir", + str(output_dir), + ], + capture_output=True, + check=False, + text=True, + ) + + assert result.returncode != 0 + assert "news must be a list" in result.stderr + assert not output_dir.exists() + + +def test_fixture_mode_rejects_malformed_article_fields(tmp_path): + fixture = tmp_path / "search-response.json" + fixture.write_text( + json.dumps( + { + "status": "ok", + "news": [ + { + "title": ["not", "a", "string"], + "url": "https://news.example/invalid", + "published": "2026-07-25T12:00:00Z", + } + ], + } + ), + encoding="utf-8", + ) + output_dir = tmp_path / "output" + + result = subprocess.run( + [ + sys.executable, + str(EXAMPLE), + "--fixture", + str(fixture), + "--output-dir", + str(output_dir), + "--generated-at", + "2026-07-25T13:00:00Z", + ], + capture_output=True, + check=False, + text=True, + ) + + assert result.returncode != 0 + assert "news item title must be a string" in result.stderr + assert not output_dir.exists() + + +def test_live_mode_requires_api_key(tmp_path): + env = os.environ.copy() + env.pop("CURRENTS_API_KEY", None) + + result = subprocess.run( + [ + sys.executable, + str(EXAMPLE), + "--output-dir", + str(tmp_path / "output"), + ], + capture_output=True, + check=False, + env=env, + text=True, + ) + + assert result.returncode != 0 + assert "CURRENTS_API_KEY is required for live mode" in result.stderr + + +def test_checked_in_fixture_runs_without_network_or_credentials(tmp_path): + env = os.environ.copy() + env.pop("CURRENTS_API_KEY", None) + + result = subprocess.run( + [ + sys.executable, + str(EXAMPLE), + "--fixture", + str(CHECKED_IN_FIXTURE), + "--output-dir", + str(tmp_path / "output"), + "--generated-at", + "2026-07-25T00:00:00Z", + ], + capture_output=True, + check=False, + env=env, + text=True, + ) + + assert result.returncode == 0, result.stderr + markdown = (tmp_path / "output" / "briefing.md").read_text(encoding="utf-8") + assert "Battery storage policy enters public consultation" in markdown + assert "https://example.com/energy/storage-policy" in markdown + + +def test_checked_in_fixture_produces_identical_output_on_every_run(tmp_path): + first_output = tmp_path / "first" + second_output = tmp_path / "second" + + for output_dir in (first_output, second_output): + result = subprocess.run( + [ + sys.executable, + str(EXAMPLE), + "--fixture", + str(CHECKED_IN_FIXTURE), + "--output-dir", + str(output_dir), + ], + capture_output=True, + check=False, + text=True, + ) + assert result.returncode == 0, result.stderr + + assert (first_output / "briefing.md").read_bytes() == ( + second_output / "briefing.md" + ).read_bytes() + assert (first_output / "briefing.json").read_bytes() == ( + second_output / "briefing.json" + ).read_bytes() + + +def test_live_mode_uses_the_sdk_search_result(tmp_path): + fake_package = tmp_path / "fake-sdk" / "currentsapi" + fake_package.mkdir(parents=True) + (fake_package / "__init__.py").write_text( + """ +class CurrentsAPI: + def __init__(self, api_key): + assert api_key == "test-key" + + def search(self, keywords, language): + assert keywords == "energy storage" + assert language == "en" + return { + "status": "ok", + "news": [{ + "title": "Live search result", + "url": "https://news.example/live-result", + "published": "2026-07-25T12:00:00Z" + }] + } +""".lstrip(), + encoding="utf-8", + ) + env = os.environ.copy() + env["CURRENTS_API_KEY"] = "test-key" + env["PYTHONPATH"] = str(tmp_path / "fake-sdk") + output_dir = tmp_path / "output" + + result = subprocess.run( + [ + sys.executable, + str(EXAMPLE), + "--keywords", + "energy storage", + "--language", + "en", + "--output-dir", + str(output_dir), + "--generated-at", + "2026-07-25T13:00:00Z", + ], + capture_output=True, + check=False, + env=env, + text=True, + ) + + assert result.returncode == 0, result.stderr + markdown = (output_dir / "briefing.md").read_text(encoding="utf-8") + assert "Live search result" in markdown + assert "https://news.example/live-result" in markdown