Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
100 changes: 100 additions & 0 deletions examples/source_linked_briefing/README.md
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.
145 changes: 145 additions & 0 deletions examples/source_linked_briefing/briefing.py
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Parse publication timestamps before sorting

When responses contain valid timestamps with different UTC offsets, lexicographic ordering is not chronological; for example, 2026-01-01T00:30:00+01:00 sorts ahead of the newer 2025-12-31T23:45:00Z. Parse and normalize nonempty timestamps before sorting so the documented newest-first order remains correct.

Useful? React with 👍 / 👎.

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"]))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Escape article text before rendering Markdown

When a live article description contains Markdown or a newline such as summary\n- Fabricated headline - <https://example.invalid>, this interpolation creates an additional rendered briefing item that appears independently source-linked. Because descriptions originate outside the application, escape Markdown control characters and indent or replace embedded line breaks before writing the human-readable briefing.

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()
40 changes: 40 additions & 0 deletions examples/source_linked_briefing/fixtures/search_response.json
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
}
Loading
Loading