langchain-econdash 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- langchain_econdash-0.1.0/.gitignore +11 -0
- langchain_econdash-0.1.0/PKG-INFO +59 -0
- langchain_econdash-0.1.0/README.md +43 -0
- langchain_econdash-0.1.0/examples/langchain_agent.ipynb +72 -0
- langchain_econdash-0.1.0/pyproject.toml +33 -0
- langchain_econdash-0.1.0/src/langchain_econdash/__init__.py +10 -0
- langchain_econdash-0.1.0/src/langchain_econdash/_format.py +90 -0
- langchain_econdash-0.1.0/src/langchain_econdash/py.typed +1 -0
- langchain_econdash-0.1.0/src/langchain_econdash/toolkit.py +39 -0
- langchain_econdash-0.1.0/src/langchain_econdash/tools.py +254 -0
- langchain_econdash-0.1.0/tests/test_tools.py +167 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: langchain-econdash
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: LangChain tools for EconDash macroeconomic data. Same free trial, API key and pay-per-call rules as the econdash client.
|
|
5
|
+
Project-URL: Homepage, https://econdash.org
|
|
6
|
+
Project-URL: Repository, https://github.com/econdash/econdash-python
|
|
7
|
+
Project-URL: Documentation, https://econdash.org/docs/python
|
|
8
|
+
Author-email: EconDash <noderguru@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Keywords: econdash,economics,langchain,tools,x402
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Requires-Dist: econdash>=0.2.1
|
|
13
|
+
Requires-Dist: langchain-core>=0.3
|
|
14
|
+
Requires-Dist: pydantic>=2.5
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# langchain-econdash
|
|
18
|
+
|
|
19
|
+
LangChain tools for [EconDash](https://econdash.org). They call the `econdash` client, so they follow the same access rules. They do not open a free back door to the archive.
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install langchain-econdash
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Releases are tags `langchain-vX.Y.Z` on `econdash-python` (the `X.Y.Z` matches this
|
|
26
|
+
package's version). A `v*` tag publishes the `econdash` client, not this package.
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from langchain_econdash import EconDashToolkit
|
|
30
|
+
|
|
31
|
+
tools = EconDashToolkit().get_tools() # free trial
|
|
32
|
+
# tools = EconDashToolkit(api_key="ed_...").get_tools()
|
|
33
|
+
# from eth_account import Account
|
|
34
|
+
# tools = EconDashToolkit(
|
|
35
|
+
# x402_signer=Account.from_key("0x..."), max_usd_per_call=0.05
|
|
36
|
+
# ).get_tools()
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
| Tool | What the model gets |
|
|
40
|
+
|---|---|
|
|
41
|
+
| `econdash_search_indicators` | Slug, name, unit, source, frequency. No numbers. |
|
|
42
|
+
| `econdash_get_series` | One country. Last 20 points, unit, freshness, a citation and the chart link. `full=true` for the whole reply. |
|
|
43
|
+
| `econdash_get_metadata` | Description, source, licence, freshness, a sample of countries. |
|
|
44
|
+
| `econdash_compare_countries` | Latest point for up to 8 ISO3 codes. |
|
|
45
|
+
|
|
46
|
+
## What is free
|
|
47
|
+
|
|
48
|
+
Without a key and without a wallet the tools only receive the free trial: 10 countries (USA, CHN, DEU, JPN, GBR, FRA, IND, BRA, RUS, KOR), 50 indicators, 300 requests a day and 30 a minute per IP.
|
|
49
|
+
|
|
50
|
+
A country or indicator outside that trial makes the API answer 402. The tool turns that into a sentence for the model (`Set ECONDASH_API_KEY` or enable x402/MPP) and does **not** include the figures. Nothing is charged.
|
|
51
|
+
|
|
52
|
+
The indicator catalog (names and slugs) is metadata and is readable inside the trial quota. The time series is not.
|
|
53
|
+
|
|
54
|
+
## How to pay
|
|
55
|
+
|
|
56
|
+
- **API key** from an EconDash subscription (`api_key=` or `ECONDASH_API_KEY`): full archive, no per-call charge, 10,000 requests a month.
|
|
57
|
+
- **Wallet:** pass `x402_signer` (Base), `tempo_account` (Tempo) or `solana_keypair` (Solana) **and** `max_usd_per_call`. The client pays one call only when the price is within that cap. If you omit the cap, it refuses to sign. Several wallets are tried in that order.
|
|
58
|
+
|
|
59
|
+
A wallet on the toolkit means an agent can spend on every series outside the trial, up to the cap. Leave the wallet off unless that is what you want.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# langchain-econdash
|
|
2
|
+
|
|
3
|
+
LangChain tools for [EconDash](https://econdash.org). They call the `econdash` client, so they follow the same access rules. They do not open a free back door to the archive.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install langchain-econdash
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Releases are tags `langchain-vX.Y.Z` on `econdash-python` (the `X.Y.Z` matches this
|
|
10
|
+
package's version). A `v*` tag publishes the `econdash` client, not this package.
|
|
11
|
+
|
|
12
|
+
```python
|
|
13
|
+
from langchain_econdash import EconDashToolkit
|
|
14
|
+
|
|
15
|
+
tools = EconDashToolkit().get_tools() # free trial
|
|
16
|
+
# tools = EconDashToolkit(api_key="ed_...").get_tools()
|
|
17
|
+
# from eth_account import Account
|
|
18
|
+
# tools = EconDashToolkit(
|
|
19
|
+
# x402_signer=Account.from_key("0x..."), max_usd_per_call=0.05
|
|
20
|
+
# ).get_tools()
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
| Tool | What the model gets |
|
|
24
|
+
|---|---|
|
|
25
|
+
| `econdash_search_indicators` | Slug, name, unit, source, frequency. No numbers. |
|
|
26
|
+
| `econdash_get_series` | One country. Last 20 points, unit, freshness, a citation and the chart link. `full=true` for the whole reply. |
|
|
27
|
+
| `econdash_get_metadata` | Description, source, licence, freshness, a sample of countries. |
|
|
28
|
+
| `econdash_compare_countries` | Latest point for up to 8 ISO3 codes. |
|
|
29
|
+
|
|
30
|
+
## What is free
|
|
31
|
+
|
|
32
|
+
Without a key and without a wallet the tools only receive the free trial: 10 countries (USA, CHN, DEU, JPN, GBR, FRA, IND, BRA, RUS, KOR), 50 indicators, 300 requests a day and 30 a minute per IP.
|
|
33
|
+
|
|
34
|
+
A country or indicator outside that trial makes the API answer 402. The tool turns that into a sentence for the model (`Set ECONDASH_API_KEY` or enable x402/MPP) and does **not** include the figures. Nothing is charged.
|
|
35
|
+
|
|
36
|
+
The indicator catalog (names and slugs) is metadata and is readable inside the trial quota. The time series is not.
|
|
37
|
+
|
|
38
|
+
## How to pay
|
|
39
|
+
|
|
40
|
+
- **API key** from an EconDash subscription (`api_key=` or `ECONDASH_API_KEY`): full archive, no per-call charge, 10,000 requests a month.
|
|
41
|
+
- **Wallet:** pass `x402_signer` (Base), `tempo_account` (Tempo) or `solana_keypair` (Solana) **and** `max_usd_per_call`. The client pays one call only when the price is within that cap. If you omit the cap, it refuses to sign. Several wallets are tried in that order.
|
|
42
|
+
|
|
43
|
+
A wallet on the toolkit means an agent can spend on every series outside the trial, up to the cap. Leave the wallet off unless that is what you want.
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
{
|
|
2
|
+
"cells": [
|
|
3
|
+
{
|
|
4
|
+
"cell_type": "markdown",
|
|
5
|
+
"id": "7fb27b941602401d91542211134fc71a",
|
|
6
|
+
"metadata": {},
|
|
7
|
+
"source": [
|
|
8
|
+
"# EconDash tools in a LangChain agent\n",
|
|
9
|
+
"\n",
|
|
10
|
+
"The tools use the free trial unless you pass an API key or a wallet.\n",
|
|
11
|
+
"A series outside the trial comes back as a payment message, not as numbers.\n"
|
|
12
|
+
]
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"cell_type": "code",
|
|
16
|
+
"execution_count": null,
|
|
17
|
+
"id": "acae54e37e7d407bbb7b55eff062a284",
|
|
18
|
+
"metadata": {},
|
|
19
|
+
"outputs": [],
|
|
20
|
+
"source": [
|
|
21
|
+
"from langchain_econdash import EconDashToolkit\n",
|
|
22
|
+
"\n",
|
|
23
|
+
"tools = EconDashToolkit().get_tools()\n",
|
|
24
|
+
"by_name = {tool.name: tool for tool in tools}\n",
|
|
25
|
+
"\n",
|
|
26
|
+
"print(by_name[\"econdash_search_indicators\"].invoke({\"query\": \"inflation\", \"limit\": 3}))\n",
|
|
27
|
+
"print(\n",
|
|
28
|
+
" by_name[\"econdash_get_series\"].invoke(\n",
|
|
29
|
+
" {\"indicator\": \"gdp-nominal\", \"country\": \"USA\", \"start\": \"2020\"}\n",
|
|
30
|
+
" )\n",
|
|
31
|
+
")\n",
|
|
32
|
+
"print(\n",
|
|
33
|
+
" by_name[\"econdash_compare_countries\"].invoke(\n",
|
|
34
|
+
" {\"indicator\": \"cpi-inflation\", \"countries\": [\"USA\", \"DEU\", \"JPN\"], \"year\": 2023}\n",
|
|
35
|
+
" )\n",
|
|
36
|
+
")\n",
|
|
37
|
+
"# Zimbabwe is outside the trial: payment required, no figure.\n",
|
|
38
|
+
"print(by_name[\"econdash_get_series\"].invoke({\"indicator\": \"gdp-nominal\", \"country\": \"ZWE\"}))"
|
|
39
|
+
]
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"cell_type": "markdown",
|
|
43
|
+
"id": "9a63283cbaf04dbcab1f6479b197f3a8",
|
|
44
|
+
"metadata": {},
|
|
45
|
+
"source": [
|
|
46
|
+
"To let a model call the tools, pass `tools` to `create_agent` together with any chat model.\n",
|
|
47
|
+
"Keep the toolkit without a wallet if the agent must not spend.\n",
|
|
48
|
+
"\n",
|
|
49
|
+
"```python\n",
|
|
50
|
+
"from langchain.agents import create_agent\n",
|
|
51
|
+
"\n",
|
|
52
|
+
"agent = create_agent(model, tools=EconDashToolkit().get_tools())\n",
|
|
53
|
+
"agent.invoke({\n",
|
|
54
|
+
" \"messages\": [{\n",
|
|
55
|
+
" \"role\": \"user\",\n",
|
|
56
|
+
" \"content\": \"Compare inflation in the US, Germany and Japan since 2020. Cite econdash.org.\",\n",
|
|
57
|
+
" }]\n",
|
|
58
|
+
"})\n",
|
|
59
|
+
"```\n"
|
|
60
|
+
]
|
|
61
|
+
}
|
|
62
|
+
],
|
|
63
|
+
"metadata": {
|
|
64
|
+
"kernelspec": {
|
|
65
|
+
"display_name": "Python 3",
|
|
66
|
+
"language": "python",
|
|
67
|
+
"name": "python3"
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
"nbformat": 4,
|
|
71
|
+
"nbformat_minor": 5
|
|
72
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "langchain-econdash"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "LangChain tools for EconDash macroeconomic data. Same free trial, API key and pay-per-call rules as the econdash client."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [{ name = "EconDash", email = "noderguru@gmail.com" }]
|
|
13
|
+
keywords = ["langchain", "econdash", "economics", "tools", "x402"]
|
|
14
|
+
dependencies = [
|
|
15
|
+
"econdash>=0.2.1",
|
|
16
|
+
"langchain-core>=0.3",
|
|
17
|
+
"pydantic>=2.5",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Homepage = "https://econdash.org"
|
|
22
|
+
Repository = "https://github.com/econdash/econdash-python"
|
|
23
|
+
Documentation = "https://econdash.org/docs/python"
|
|
24
|
+
|
|
25
|
+
[tool.hatch.build.targets.wheel]
|
|
26
|
+
packages = ["src/langchain_econdash"]
|
|
27
|
+
|
|
28
|
+
[tool.hatch.build.targets.sdist]
|
|
29
|
+
include = ["src", "tests", "README.md", "examples"]
|
|
30
|
+
|
|
31
|
+
[tool.pytest.ini_options]
|
|
32
|
+
testpaths = ["tests"]
|
|
33
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""LangChain tools for the EconDash API.
|
|
2
|
+
|
|
3
|
+
Data access is the econdash client's access. Nothing here bypasses the trial,
|
|
4
|
+
an API key, or pay-per-call.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from langchain_econdash.toolkit import EconDashToolkit
|
|
8
|
+
|
|
9
|
+
__version__ = "0.1.0"
|
|
10
|
+
__all__ = ["EconDashToolkit", "__version__"]
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Turn API errors and series into short text a model can read."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from econdash import (
|
|
6
|
+
EconDashAuthError,
|
|
7
|
+
EconDashError,
|
|
8
|
+
EconDashNotFound,
|
|
9
|
+
EconDashPaymentRequired,
|
|
10
|
+
EconDashRateLimitError,
|
|
11
|
+
Indicator,
|
|
12
|
+
Series,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
PAYMENT_HINT = (
|
|
16
|
+
"Payment required. No figures were returned and nothing was charged. "
|
|
17
|
+
"The free trial is 10 countries (USA, CHN, DEU, JPN, GBR, FRA, IND, BRA, RUS, KOR), "
|
|
18
|
+
"50 indicators, 300 requests per day and 30 per minute. "
|
|
19
|
+
"Set ECONDASH_API_KEY for a subscription, or pass a wallet and max_usd_per_call "
|
|
20
|
+
"to EconDashToolkit to pay per call with x402 or MPP. "
|
|
21
|
+
"A wallet pays only when it is set and the price is within the cap."
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
_ISO = "Use a 3-letter ISO3 code such as USA, DEU or JPN, not a country name."
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def country_error(code: str) -> str | None:
|
|
28
|
+
if len(code) == 3 and code.isalpha():
|
|
29
|
+
return None
|
|
30
|
+
return f"Invalid country code {code!r}. {_ISO}"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def explain(exc: Exception) -> str:
|
|
34
|
+
if isinstance(exc, EconDashPaymentRequired):
|
|
35
|
+
return PAYMENT_HINT
|
|
36
|
+
if isinstance(exc, EconDashRateLimitError):
|
|
37
|
+
wait = f" Retry after {exc.retry_after} seconds." if exc.retry_after else ""
|
|
38
|
+
return f"Rate limit reached.{wait} No data in this call."
|
|
39
|
+
if isinstance(exc, EconDashNotFound):
|
|
40
|
+
return f"Not found ({exc}). Search with econdash_search_indicators. {_ISO}"
|
|
41
|
+
if isinstance(exc, EconDashAuthError):
|
|
42
|
+
return f"API key was rejected ({exc}). Check ECONDASH_API_KEY."
|
|
43
|
+
if isinstance(exc, EconDashError):
|
|
44
|
+
return f"EconDash error: {exc}"
|
|
45
|
+
return f"EconDash error: {exc}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def search_text(rows: list[Indicator]) -> str:
|
|
49
|
+
if not rows:
|
|
50
|
+
return "No indicators matched. Try a shorter query such as gdp or inflation."
|
|
51
|
+
lines = ["slug | name | unit | source | frequency"]
|
|
52
|
+
for ind in rows:
|
|
53
|
+
lines.append(
|
|
54
|
+
" | ".join(
|
|
55
|
+
[
|
|
56
|
+
ind.slug or ind.code,
|
|
57
|
+
ind.name,
|
|
58
|
+
ind.unit or "",
|
|
59
|
+
ind.source or "",
|
|
60
|
+
ind.frequency or "",
|
|
61
|
+
]
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
return "\n".join(lines)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def series_text(series: Series, *, slug: str, full: bool) -> str:
|
|
68
|
+
points = series.data
|
|
69
|
+
shown = points if full else points[-20:]
|
|
70
|
+
name = series.indicator_name_en or series.indicator_name_ru
|
|
71
|
+
country = shown[-1].country if shown else ""
|
|
72
|
+
country_name = shown[-1].country_name if shown else country
|
|
73
|
+
last = shown[-1] if shown else None
|
|
74
|
+
chart = f"https://econdash.org/chart/{slug}/{country}" if slug and country else ""
|
|
75
|
+
head = [
|
|
76
|
+
f"{name} — {country_name or country}",
|
|
77
|
+
f"unit: {series.unit or 'n/a'}",
|
|
78
|
+
f"frequency: {series.frequency or 'n/a'}",
|
|
79
|
+
f"data_changed_at: {series.data_changed_at or 'n/a'}",
|
|
80
|
+
f"points: {len(points)} total, showing {len(shown)}",
|
|
81
|
+
]
|
|
82
|
+
if last is not None:
|
|
83
|
+
head.append(
|
|
84
|
+
f"citation: {country_name or country} {name} was {last.value} {series.unit or ''} "
|
|
85
|
+
f"on {last.date.isoformat()}. {chart}".rstrip()
|
|
86
|
+
)
|
|
87
|
+
body = [f"{p.date.isoformat()} {p.value}" for p in shown]
|
|
88
|
+
if not full and len(points) > len(shown):
|
|
89
|
+
body.append("Set full=true only when the user asked for the whole history.")
|
|
90
|
+
return "\n".join(head + body)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Build the four tools around one EconDash client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from langchain_core.tools import BaseTool
|
|
8
|
+
|
|
9
|
+
from econdash import AsyncEconDash, EconDash
|
|
10
|
+
from langchain_econdash.tools import CompareCountries, GetMetadata, GetSeries, SearchIndicators
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class EconDashToolkit:
|
|
14
|
+
"""Tools that use the same access as `EconDash`.
|
|
15
|
+
|
|
16
|
+
No key and no wallet: the free trial only. A 402 becomes a sentence for the model.
|
|
17
|
+
Pass `api_key=` for a subscription. Pass a wallet plus `max_usd_per_call` to pay
|
|
18
|
+
per call; without `max_usd_per_call` the client refuses to sign anything.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, api_key: str | None = None, **client_kwargs: Any) -> None:
|
|
22
|
+
self.client = EconDash(api_key, **client_kwargs)
|
|
23
|
+
self.async_client = AsyncEconDash(api_key, **client_kwargs)
|
|
24
|
+
|
|
25
|
+
def get_tools(self) -> list[BaseTool]:
|
|
26
|
+
shared = {"client": self.client, "async_client": self.async_client}
|
|
27
|
+
return [
|
|
28
|
+
SearchIndicators(**shared),
|
|
29
|
+
GetSeries(**shared),
|
|
30
|
+
GetMetadata(**shared),
|
|
31
|
+
CompareCountries(**shared),
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
def close(self) -> None:
|
|
35
|
+
self.client.close()
|
|
36
|
+
|
|
37
|
+
async def aclose(self) -> None:
|
|
38
|
+
self.client.close()
|
|
39
|
+
await self.async_client.aclose()
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""Four tools. Each one calls the econdash client and returns text, including on 402."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from langchain_core.tools import BaseTool
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
from econdash import EconDashError, Indicator, Series
|
|
11
|
+
from langchain_econdash._format import country_error, explain, search_text, series_text
|
|
12
|
+
|
|
13
|
+
_TRIAL = (
|
|
14
|
+
"Free trial only: 10 countries (USA, CHN, DEU, JPN, GBR, FRA, IND, BRA, RUS, KOR) "
|
|
15
|
+
"and 50 indicators, 300 requests/day. Anything else comes back as a payment message, "
|
|
16
|
+
"not as numbers. Find the slug with econdash_search_indicators first."
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _ClientTool(BaseTool):
|
|
21
|
+
client: Any = Field(exclude=True)
|
|
22
|
+
async_client: Any = Field(exclude=True)
|
|
23
|
+
|
|
24
|
+
model_config = {"arbitrary_types_allowed": True}
|
|
25
|
+
|
|
26
|
+
def _fail(self, exc: Exception) -> str:
|
|
27
|
+
return explain(exc)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SearchInput(BaseModel):
|
|
31
|
+
query: str = Field(description="Words to match against indicator names, slugs and sources.")
|
|
32
|
+
limit: int = Field(default=10, ge=1, le=20, description="How many matches to return.")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SearchIndicators(_ClientTool):
|
|
36
|
+
name: str = "econdash_search_indicators"
|
|
37
|
+
description: str = (
|
|
38
|
+
"Search the EconDash indicator catalog. Returns slug, name, unit, source and frequency. "
|
|
39
|
+
"Call this before a series. The catalog is metadata; it has no numbers. " + _TRIAL
|
|
40
|
+
)
|
|
41
|
+
args_schema: type[BaseModel] = SearchInput
|
|
42
|
+
|
|
43
|
+
def _run(self, query: str, limit: int = 10, **_: Any) -> str:
|
|
44
|
+
try:
|
|
45
|
+
return search_text(self.client.search_indicators(query, limit=limit))
|
|
46
|
+
except EconDashError as exc:
|
|
47
|
+
return self._fail(exc)
|
|
48
|
+
|
|
49
|
+
async def _arun(self, query: str, limit: int = 10, **_: Any) -> str:
|
|
50
|
+
try:
|
|
51
|
+
return search_text(await self.async_client.search_indicators(query, limit=limit))
|
|
52
|
+
except EconDashError as exc:
|
|
53
|
+
return self._fail(exc)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class SeriesInput(BaseModel):
|
|
57
|
+
indicator: str = Field(description="Slug from econdash_search_indicators, e.g. gdp-nominal.")
|
|
58
|
+
country: str = Field(description="ISO3 code, e.g. USA. Not a country name.")
|
|
59
|
+
start: str | None = Field(default=None, description="Start year or date, e.g. 2000.")
|
|
60
|
+
end: str | None = Field(default=None, description="End year or date.")
|
|
61
|
+
full: bool = Field(
|
|
62
|
+
default=False,
|
|
63
|
+
description="Return the whole series the API sent. Default is the last 20 points.",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class GetSeries(_ClientTool):
|
|
68
|
+
name: str = "econdash_get_series"
|
|
69
|
+
description: str = (
|
|
70
|
+
"Load one indicator for one country: unit, when the data last changed, a citation "
|
|
71
|
+
"with the econdash.org chart link, and the last 20 observations. " + _TRIAL
|
|
72
|
+
)
|
|
73
|
+
args_schema: type[BaseModel] = SeriesInput
|
|
74
|
+
|
|
75
|
+
def _text(self, series: Series, indicator: str, full: bool) -> str:
|
|
76
|
+
slug = indicator if "__" not in indicator else ""
|
|
77
|
+
if not slug:
|
|
78
|
+
try:
|
|
79
|
+
slug = self.client.get_indicator(series.indicator_code).slug or ""
|
|
80
|
+
except EconDashError:
|
|
81
|
+
slug = ""
|
|
82
|
+
return series_text(series, slug=slug, full=full)
|
|
83
|
+
|
|
84
|
+
def _run(
|
|
85
|
+
self,
|
|
86
|
+
indicator: str,
|
|
87
|
+
country: str,
|
|
88
|
+
start: str | None = None,
|
|
89
|
+
end: str | None = None,
|
|
90
|
+
full: bool = False,
|
|
91
|
+
**_: Any,
|
|
92
|
+
) -> str:
|
|
93
|
+
if message := country_error(country):
|
|
94
|
+
return message
|
|
95
|
+
try:
|
|
96
|
+
series = self.client.get_series(indicator, country, start=start, end=end)
|
|
97
|
+
except EconDashError as exc:
|
|
98
|
+
return self._fail(exc)
|
|
99
|
+
return self._text(series, indicator, full)
|
|
100
|
+
|
|
101
|
+
async def _arun(
|
|
102
|
+
self,
|
|
103
|
+
indicator: str,
|
|
104
|
+
country: str,
|
|
105
|
+
start: str | None = None,
|
|
106
|
+
end: str | None = None,
|
|
107
|
+
full: bool = False,
|
|
108
|
+
**_: Any,
|
|
109
|
+
) -> str:
|
|
110
|
+
if message := country_error(country):
|
|
111
|
+
return message
|
|
112
|
+
try:
|
|
113
|
+
series = await self.async_client.get_series(indicator, country, start=start, end=end)
|
|
114
|
+
except EconDashError as exc:
|
|
115
|
+
return self._fail(exc)
|
|
116
|
+
slug = indicator if "__" not in indicator else ""
|
|
117
|
+
return series_text(series, slug=slug, full=full)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class MetadataInput(BaseModel):
|
|
121
|
+
indicator: str = Field(description="Indicator slug or code.")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class GetMetadata(_ClientTool):
|
|
125
|
+
name: str = "econdash_get_metadata"
|
|
126
|
+
description: str = (
|
|
127
|
+
"Describe one indicator: source, licence, freshness and some countries. "
|
|
128
|
+
"Does not return the time series. " + _TRIAL
|
|
129
|
+
)
|
|
130
|
+
args_schema: type[BaseModel] = MetadataInput
|
|
131
|
+
|
|
132
|
+
def _text(self, ind: Indicator, countries: list[str] | None, country_note: str) -> str:
|
|
133
|
+
sample = ", ".join(countries[:12]) if countries else "n/a"
|
|
134
|
+
extra = f" ({len(countries)} countries)" if countries else ""
|
|
135
|
+
lines = [
|
|
136
|
+
f"{ind.slug or ind.code}: {ind.name}",
|
|
137
|
+
f"code: {ind.code}",
|
|
138
|
+
f"description: {ind.description or 'n/a'}",
|
|
139
|
+
f"source: {ind.source or 'n/a'}",
|
|
140
|
+
f"source_url: {ind.source_url or 'n/a'}",
|
|
141
|
+
f"license: {ind.license_name or 'n/a'} {ind.license_url or ''}".rstrip(),
|
|
142
|
+
f"attribution: {ind.attribution_text or 'n/a'}",
|
|
143
|
+
f"frequency: {ind.frequency or 'n/a'}",
|
|
144
|
+
f"data_changed_at: {ind.data_changed_at or 'n/a'}",
|
|
145
|
+
f"data_checked_at: {ind.data_checked_at or 'n/a'}",
|
|
146
|
+
f"observations: {ind.first_observation or '?'} .. {ind.last_observation or '?'}",
|
|
147
|
+
f"chart: {ind.url or 'n/a'}",
|
|
148
|
+
f"countries{extra}: {sample}",
|
|
149
|
+
]
|
|
150
|
+
if country_note:
|
|
151
|
+
lines.append(country_note)
|
|
152
|
+
return "\n".join(lines)
|
|
153
|
+
|
|
154
|
+
def _run(self, indicator: str, **_: Any) -> str:
|
|
155
|
+
try:
|
|
156
|
+
ind = self.client.get_indicator(indicator)
|
|
157
|
+
except EconDashError as exc:
|
|
158
|
+
return self._fail(exc)
|
|
159
|
+
countries: list[str] | None = None
|
|
160
|
+
note = ""
|
|
161
|
+
try:
|
|
162
|
+
raw = self.client._get(f"{self.client.base_url}/indicators/{ind.code}/countries")
|
|
163
|
+
countries = [str(code) for code in raw]
|
|
164
|
+
except EconDashError as exc:
|
|
165
|
+
note = explain(exc)
|
|
166
|
+
return self._text(ind, countries, note)
|
|
167
|
+
|
|
168
|
+
async def _arun(self, indicator: str, **_: Any) -> str:
|
|
169
|
+
try:
|
|
170
|
+
ind = await self.async_client.get_indicator(indicator)
|
|
171
|
+
except EconDashError as exc:
|
|
172
|
+
return self._fail(exc)
|
|
173
|
+
countries: list[str] | None = None
|
|
174
|
+
note = ""
|
|
175
|
+
try:
|
|
176
|
+
raw = await self.async_client._get(
|
|
177
|
+
f"{self.async_client.base_url}/indicators/{ind.code}/countries"
|
|
178
|
+
)
|
|
179
|
+
countries = [str(code) for code in raw]
|
|
180
|
+
except EconDashError as exc:
|
|
181
|
+
note = explain(exc)
|
|
182
|
+
return self._text(ind, countries, note)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class CompareInput(BaseModel):
|
|
186
|
+
indicator: str = Field(description="Indicator slug, e.g. gdp-nominal.")
|
|
187
|
+
countries: list[str] = Field(description="Up to 8 ISO3 codes, e.g. ['USA', 'DEU', 'JPN'].")
|
|
188
|
+
year: int | None = Field(default=None, description="Calendar year. Omit for the latest point.")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class CompareCountries(_ClientTool):
|
|
192
|
+
name: str = "econdash_compare_countries"
|
|
193
|
+
description: str = (
|
|
194
|
+
"Latest value of one indicator for a few countries, each requested on its own. "
|
|
195
|
+
"A country outside the trial is a payment message on that line; the other countries "
|
|
196
|
+
"are not unlocked by it. " + _TRIAL
|
|
197
|
+
)
|
|
198
|
+
args_schema: type[BaseModel] = CompareInput
|
|
199
|
+
|
|
200
|
+
def _one(self, fetch: Any, indicator: str, country: str, year: int | None) -> str:
|
|
201
|
+
if message := country_error(country):
|
|
202
|
+
return f"{country}: {message}"
|
|
203
|
+
try:
|
|
204
|
+
if year is None:
|
|
205
|
+
series = fetch(indicator, country)
|
|
206
|
+
else:
|
|
207
|
+
series = fetch(indicator, country, start=year, end=year)
|
|
208
|
+
except EconDashError as exc:
|
|
209
|
+
return f"{country}: {explain(exc)}"
|
|
210
|
+
if not series.data:
|
|
211
|
+
return f"{country}: no observations."
|
|
212
|
+
last = series.data[-1]
|
|
213
|
+
return f"{country}: {last.value} {series.unit or ''} on {last.date.isoformat()}"
|
|
214
|
+
|
|
215
|
+
def _run(self, indicator: str, countries: list[str], year: int | None = None, **_: Any) -> str:
|
|
216
|
+
if len(countries) > 8:
|
|
217
|
+
return "Pass at most 8 countries per call."
|
|
218
|
+
lines = [
|
|
219
|
+
self._one(
|
|
220
|
+
lambda ind, ctry, **kw: self.client.get_series(ind, ctry, **kw),
|
|
221
|
+
indicator,
|
|
222
|
+
country,
|
|
223
|
+
year,
|
|
224
|
+
)
|
|
225
|
+
for country in countries
|
|
226
|
+
]
|
|
227
|
+
return "\n".join(lines)
|
|
228
|
+
|
|
229
|
+
async def _arun(
|
|
230
|
+
self, indicator: str, countries: list[str], year: int | None = None, **_: Any
|
|
231
|
+
) -> str:
|
|
232
|
+
if len(countries) > 8:
|
|
233
|
+
return "Pass at most 8 countries per call."
|
|
234
|
+
lines = []
|
|
235
|
+
for country in countries:
|
|
236
|
+
if message := country_error(country):
|
|
237
|
+
lines.append(f"{country}: {message}")
|
|
238
|
+
continue
|
|
239
|
+
try:
|
|
240
|
+
if year is None:
|
|
241
|
+
series = await self.async_client.get_series(indicator, country)
|
|
242
|
+
else:
|
|
243
|
+
series = await self.async_client.get_series(
|
|
244
|
+
indicator, country, start=year, end=year
|
|
245
|
+
)
|
|
246
|
+
except EconDashError as exc:
|
|
247
|
+
lines.append(f"{country}: {explain(exc)}")
|
|
248
|
+
continue
|
|
249
|
+
if not series.data:
|
|
250
|
+
lines.append(f"{country}: no observations.")
|
|
251
|
+
continue
|
|
252
|
+
last = series.data[-1]
|
|
253
|
+
lines.append(f"{country}: {last.value} {series.unit or ''} on {last.date.isoformat()}")
|
|
254
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Tools talk to a fake client: trial data comes back, paid data comes back as a hint."""
|
|
2
|
+
|
|
3
|
+
from datetime import date
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from langchain_econdash.tools import CompareCountries, GetMetadata, GetSeries, SearchIndicators
|
|
7
|
+
|
|
8
|
+
from econdash import EconDashNotFound, EconDashPaymentRequired, Indicator, Point, Series
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _indicator() -> Indicator:
|
|
12
|
+
return Indicator(
|
|
13
|
+
code="world_bank__gdp_nominal",
|
|
14
|
+
slug="gdp-nominal",
|
|
15
|
+
name_ru="ВВП",
|
|
16
|
+
name_en="GDP",
|
|
17
|
+
unit="USD",
|
|
18
|
+
source="World Bank",
|
|
19
|
+
frequency="annual",
|
|
20
|
+
category=None,
|
|
21
|
+
fetch_method=None,
|
|
22
|
+
source_url="https://data.worldbank.org/indicator/NY.GDP.MKTP.CD",
|
|
23
|
+
license_name="CC BY 4.0",
|
|
24
|
+
description="Nominal GDP",
|
|
25
|
+
data_changed_at="2026-01-01",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _series(country: str) -> Series:
|
|
30
|
+
points = [Point(date=date(2000 + i, 1, 1), value=float(i), country=country) for i in range(25)]
|
|
31
|
+
return Series(
|
|
32
|
+
indicator_code="world_bank__gdp_nominal",
|
|
33
|
+
indicator_name_ru="ВВП",
|
|
34
|
+
indicator_name_en="GDP",
|
|
35
|
+
unit="USD",
|
|
36
|
+
data_changed_at="2026-01-01",
|
|
37
|
+
data=points,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Fake:
|
|
42
|
+
def __init__(self) -> None:
|
|
43
|
+
self.calls: list[tuple] = []
|
|
44
|
+
self.base_url = "https://econdash.org/api/v1"
|
|
45
|
+
|
|
46
|
+
def search_indicators(self, query: str, limit: int = 10) -> list[Indicator]:
|
|
47
|
+
self.calls.append(("search", query, limit))
|
|
48
|
+
return [_indicator()] if query else []
|
|
49
|
+
|
|
50
|
+
def get_series(self, indicator: str, country: str, start=None, end=None) -> Series:
|
|
51
|
+
self.calls.append(("series", indicator, country.upper(), start, end))
|
|
52
|
+
if country.upper() == "ZWE":
|
|
53
|
+
raise EconDashPaymentRequired("outside trial", status_code=402)
|
|
54
|
+
return _series(country.upper())
|
|
55
|
+
|
|
56
|
+
def get_indicator(self, indicator: str) -> Indicator:
|
|
57
|
+
self.calls.append(("meta", indicator))
|
|
58
|
+
if indicator == "missing":
|
|
59
|
+
raise EconDashNotFound("missing", status_code=404)
|
|
60
|
+
return _indicator()
|
|
61
|
+
|
|
62
|
+
def _get(self, url: str) -> list[str]:
|
|
63
|
+
self.calls.append(("countries", url))
|
|
64
|
+
return ["USA", "DEU"]
|
|
65
|
+
|
|
66
|
+
async def search_indicators_async(self, query: str, limit: int = 10) -> list[Indicator]:
|
|
67
|
+
return self.search_indicators(query, limit)
|
|
68
|
+
|
|
69
|
+
async def get_series_async(self, indicator: str, country: str, start=None, end=None) -> Series:
|
|
70
|
+
return self.get_series(indicator, country, start, end)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class AsyncFake(Fake):
|
|
74
|
+
async def search_indicators(self, query: str, limit: int = 10) -> list[Indicator]:
|
|
75
|
+
return super().search_indicators(query, limit)
|
|
76
|
+
|
|
77
|
+
async def get_series(self, indicator: str, country: str, start=None, end=None) -> Series:
|
|
78
|
+
return super().get_series(indicator, country, start, end)
|
|
79
|
+
|
|
80
|
+
async def get_indicator(self, indicator: str) -> Indicator:
|
|
81
|
+
return super().get_indicator(indicator)
|
|
82
|
+
|
|
83
|
+
async def _get(self, url: str) -> list[str]:
|
|
84
|
+
return super()._get(url)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_search_returns_slug_and_not_values() -> None:
|
|
88
|
+
tool = SearchIndicators(client=Fake(), async_client=Fake())
|
|
89
|
+
text = tool.invoke({"query": "gdp", "limit": 5})
|
|
90
|
+
assert "gdp-nominal" in text
|
|
91
|
+
assert "World Bank" in text
|
|
92
|
+
assert "2000-01-01" not in text
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_series_is_last_20_and_cites_the_chart() -> None:
|
|
96
|
+
fake = Fake()
|
|
97
|
+
tool = GetSeries(client=fake, async_client=fake)
|
|
98
|
+
text = tool.invoke({"indicator": "gdp-nominal", "country": "usa"})
|
|
99
|
+
assert "2000-01-01" not in text
|
|
100
|
+
assert "2005-01-01" in text
|
|
101
|
+
assert "2024-01-01 24.0" in text
|
|
102
|
+
assert "https://econdash.org/chart/gdp-nominal/USA" in text
|
|
103
|
+
assert "data_changed_at: 2026-01-01" in text
|
|
104
|
+
assert fake.calls[0][2] == "USA"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_full_series_includes_the_start() -> None:
|
|
108
|
+
tool = GetSeries(client=Fake(), async_client=Fake())
|
|
109
|
+
text = tool.invoke({"indicator": "gdp-nominal", "country": "USA", "full": True})
|
|
110
|
+
assert "2000-01-01 0.0" in text
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_outside_trial_is_a_payment_hint_without_figures() -> None:
|
|
114
|
+
fake = Fake()
|
|
115
|
+
tool = GetSeries(client=fake, async_client=fake)
|
|
116
|
+
text = tool.invoke({"indicator": "gdp-nominal", "country": "ZWE"})
|
|
117
|
+
assert "Payment required" in text
|
|
118
|
+
assert "ECONDASH_API_KEY" in text
|
|
119
|
+
assert "x402" in text
|
|
120
|
+
assert "24.0" not in text
|
|
121
|
+
assert len(fake.calls) == 1
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_bad_country_code_never_calls_the_api() -> None:
|
|
125
|
+
fake = Fake()
|
|
126
|
+
tool = GetSeries(client=fake, async_client=fake)
|
|
127
|
+
text = tool.invoke({"indicator": "gdp-nominal", "country": "Germany"})
|
|
128
|
+
assert "Invalid country code" in text
|
|
129
|
+
assert fake.calls == []
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_unknown_indicator_tells_the_model_to_search() -> None:
|
|
133
|
+
tool = GetMetadata(client=Fake(), async_client=Fake())
|
|
134
|
+
text = tool.invoke({"indicator": "missing"})
|
|
135
|
+
assert "Not found" in text
|
|
136
|
+
assert "econdash_search_indicators" in text
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_metadata_includes_licence_and_countries() -> None:
|
|
140
|
+
tool = GetMetadata(client=Fake(), async_client=Fake())
|
|
141
|
+
text = tool.invoke({"indicator": "gdp-nominal"})
|
|
142
|
+
assert "CC BY 4.0" in text
|
|
143
|
+
assert "USA, DEU" in text
|
|
144
|
+
assert "source_url:" in text
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_compare_keeps_paid_countries_out_of_the_answer() -> None:
|
|
148
|
+
tool = CompareCountries(client=Fake(), async_client=Fake())
|
|
149
|
+
text = tool.invoke({"indicator": "gdp-nominal", "countries": ["USA", "ZWE"]})
|
|
150
|
+
assert "USA: 24.0" in text
|
|
151
|
+
zwe = next(line for line in text.splitlines() if line.startswith("ZWE"))
|
|
152
|
+
assert "Payment required" in zwe
|
|
153
|
+
assert "24.0" not in zwe
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_compare_rejects_a_long_list() -> None:
|
|
157
|
+
tool = CompareCountries(client=Fake(), async_client=Fake())
|
|
158
|
+
text = tool.invoke({"indicator": "gdp-nominal", "countries": [f"A{i:02d}" for i in range(9)]})
|
|
159
|
+
assert "at most 8" in text
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@pytest.mark.asyncio
|
|
163
|
+
async def test_async_series_uses_the_async_client() -> None:
|
|
164
|
+
tool = GetSeries(client=Fake(), async_client=AsyncFake())
|
|
165
|
+
text = await tool.ainvoke({"indicator": "gdp-nominal", "country": "DEU"})
|
|
166
|
+
assert "DEU" in text
|
|
167
|
+
assert "Payment required" not in text
|