-
Notifications
You must be signed in to change notification settings - Fork 1
Add source-linked briefing example #6
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 - <https://example.com/energy/storage-policy> | ||
| - 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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"])) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a live article description contains Markdown or a newline such as Useful? React with 👍 / 👎. |
||
| 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() | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When responses contain valid timestamps with different UTC offsets, lexicographic ordering is not chronological; for example,
2026-01-01T00:30:00+01:00sorts ahead of the newer2025-12-31T23:45:00Z. Parse and normalize nonempty timestamps before sorting so the documented newest-first order remains correct.Useful? React with 👍 / 👎.