langchain-nestdaddy 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_nestdaddy-0.1.0/.github/workflows/tests.yml +32 -0
- langchain_nestdaddy-0.1.0/.gitignore +8 -0
- langchain_nestdaddy-0.1.0/LICENSE +21 -0
- langchain_nestdaddy-0.1.0/PKG-INFO +129 -0
- langchain_nestdaddy-0.1.0/README.md +100 -0
- langchain_nestdaddy-0.1.0/langchain_nestdaddy/__init__.py +26 -0
- langchain_nestdaddy-0.1.0/langchain_nestdaddy/_client.py +89 -0
- langchain_nestdaddy-0.1.0/langchain_nestdaddy/retrievers.py +108 -0
- langchain_nestdaddy-0.1.0/langchain_nestdaddy/tools.py +221 -0
- langchain_nestdaddy-0.1.0/pyproject.toml +48 -0
- langchain_nestdaddy-0.1.0/tests/__init__.py +0 -0
- langchain_nestdaddy-0.1.0/tests/integration_tests/__init__.py +0 -0
- langchain_nestdaddy-0.1.0/tests/integration_tests/test_live.py +32 -0
- langchain_nestdaddy-0.1.0/tests/unit_tests/__init__.py +0 -0
- langchain_nestdaddy-0.1.0/tests/unit_tests/test_behaviour.py +138 -0
- langchain_nestdaddy-0.1.0/tests/unit_tests/test_standard.py +53 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
name: tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
pull_request:
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
unit:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
strategy:
|
|
11
|
+
matrix:
|
|
12
|
+
python-version: ["3.9", "3.11", "3.13"]
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v4
|
|
15
|
+
- uses: actions/setup-python@v5
|
|
16
|
+
with:
|
|
17
|
+
python-version: ${{ matrix.python-version }}
|
|
18
|
+
- run: pip install -e ".[test]"
|
|
19
|
+
- run: pytest tests/unit_tests --disable-socket --allow-unix-socket -q
|
|
20
|
+
|
|
21
|
+
integration:
|
|
22
|
+
runs-on: ubuntu-latest
|
|
23
|
+
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
|
24
|
+
steps:
|
|
25
|
+
- uses: actions/checkout@v4
|
|
26
|
+
- uses: actions/setup-python@v5
|
|
27
|
+
with:
|
|
28
|
+
python-version: "3.12"
|
|
29
|
+
- run: pip install -e ".[test]"
|
|
30
|
+
- run: pytest -m integration tests/integration_tests -q
|
|
31
|
+
env:
|
|
32
|
+
NESTDADDY_API_KEY: ${{ secrets.NESTDADDY_API_KEY }}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 NestDaddy
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: langchain-nestdaddy
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: LangChain integration for NestDaddy: give your AI today's internet. Live web search, real-time news and page reading.
|
|
5
|
+
Project-URL: Homepage, https://nestdaddy.com/mcp
|
|
6
|
+
Project-URL: Documentation, https://github.com/nestdaddy/langchain-nestdaddy#readme
|
|
7
|
+
Project-URL: Source, https://github.com/nestdaddy/langchain-nestdaddy
|
|
8
|
+
Project-URL: API reference, https://nestdaddy.com/apis
|
|
9
|
+
Author-email: NestDaddy <hello@nestdaddy.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: agents,langchain,nestdaddy,news,rag,search,web search
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Indexing/Search
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
19
|
+
Requires-Python: >=3.9
|
|
20
|
+
Requires-Dist: httpx<1.0.0,>=0.25.0
|
|
21
|
+
Requires-Dist: langchain-core<2.0.0,>=0.3.15
|
|
22
|
+
Requires-Dist: pydantic<3.0,>=2.7
|
|
23
|
+
Provides-Extra: test
|
|
24
|
+
Requires-Dist: langchain-tests>=0.3.0; extra == 'test'
|
|
25
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'test'
|
|
26
|
+
Requires-Dist: pytest-socket>=0.7; extra == 'test'
|
|
27
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# langchain-nestdaddy
|
|
31
|
+
|
|
32
|
+
**Give your AI today's internet.** Live web search, real-time breaking news from around the world and full page
|
|
33
|
+
reading for LangChain agents and RAG pipelines, from [NestDaddy](https://nestdaddy.com)'s own index.
|
|
34
|
+
|
|
35
|
+
[](https://pypi.org/project/langchain-nestdaddy/)
|
|
36
|
+
[](https://github.com/nestdaddy/langchain-nestdaddy/actions/workflows/tests.yml)
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install -U langchain-nestdaddy
|
|
40
|
+
export NESTDADDY_API_KEY="your-key" # free trial key: https://nestdaddy.com/mcp
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
| Class | What it does |
|
|
44
|
+
|---|---|
|
|
45
|
+
| `NestDaddySearch` | Web search on NestDaddy's own index (title, url, snippet, domain) |
|
|
46
|
+
| `NestDaddyNewsSearch` | Real-time news by key words, optional country and time window |
|
|
47
|
+
| `NestDaddyReadPage` | Clean main text of any page or article, no menus or ads |
|
|
48
|
+
| `NestDaddyNewsRetriever` | News as LangChain `Document`s for RAG, optionally with the full article text |
|
|
49
|
+
|
|
50
|
+
## Give an agent live search and news
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from langchain.agents import create_agent
|
|
54
|
+
from langchain_nestdaddy import NestDaddyNewsSearch, NestDaddyReadPage, NestDaddySearch
|
|
55
|
+
|
|
56
|
+
agent = create_agent(
|
|
57
|
+
"anthropic:claude-sonnet-5-5",
|
|
58
|
+
tools=[NestDaddySearch(max_results=5), NestDaddyNewsSearch(days=7), NestDaddyReadPage()],
|
|
59
|
+
system_prompt="Use news search for anything recent, web search for everything else. "
|
|
60
|
+
"Read a page in full before you explain its details.",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
result = agent.invoke({"messages": [{"role": "user", "content": "What did the Fed decide this week?"}]})
|
|
64
|
+
print(result["messages"][-1].content)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Any chat model that supports tool calling works the same way.
|
|
68
|
+
|
|
69
|
+
## Use the tools directly
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from langchain_nestdaddy import NestDaddyNewsSearch, NestDaddySearch, NestDaddyReadPage
|
|
73
|
+
|
|
74
|
+
NestDaddySearch(max_results=3).invoke({"query": "model context protocol"})
|
|
75
|
+
# {'query': 'model context protocol', 'count': 3, 'results': [{'title': ..., 'url': ..., 'snippet': ...}]}
|
|
76
|
+
|
|
77
|
+
NestDaddyNewsSearch().invoke({"query": "interest rates", "country": "US", "days": 7})
|
|
78
|
+
# {'query': 'interest rates', 'count': 10, 'total': ..., 'results': [{'title': ..., 'source_domain': ...,
|
|
79
|
+
# 'published_timestamp': ..., 'summary': ...}]}
|
|
80
|
+
|
|
81
|
+
NestDaddyReadPage(max_chars=8000).invoke({"url": "https://example.com/some-article"})
|
|
82
|
+
# {'title': ..., 'final_url': ..., 'text': ...}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Local news works in the local language: `NestDaddyNewsSearch().invoke({"query": "banjir Jakarta", "country": "ID"})`.
|
|
86
|
+
|
|
87
|
+
## RAG over today's news
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
from langchain_nestdaddy import NestDaddyNewsRetriever
|
|
91
|
+
|
|
92
|
+
retriever = NestDaddyNewsRetriever(k=5, days=3, full_text=True)
|
|
93
|
+
docs = retriever.invoke("chip export rules")
|
|
94
|
+
|
|
95
|
+
for d in docs:
|
|
96
|
+
print(d.metadata["title"], d.metadata["url"], d.metadata["published_timestamp"])
|
|
97
|
+
print(d.page_content[:300])
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
With `full_text=True` each article is read in full (one extra API call per article). If an article cannot be
|
|
101
|
+
read, its summary is kept so you still get `k` documents.
|
|
102
|
+
|
|
103
|
+
## Async
|
|
104
|
+
|
|
105
|
+
Every tool and the retriever support `ainvoke`; the retriever reads full articles in parallel.
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
docs = await NestDaddyNewsRetriever(k=5, full_text=True).ainvoke("AI regulation")
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Errors
|
|
112
|
+
|
|
113
|
+
API errors raise `NestDaddyAPIError` with the HTTP status and a hint (a bad key points to the dashboard, a plan
|
|
114
|
+
limit to the plans page). Your key is stored as a `SecretStr` and never printed.
|
|
115
|
+
|
|
116
|
+
## Prefer MCP?
|
|
117
|
+
|
|
118
|
+
The same tools are available as a remote MCP server for Claude, Cursor, VS Code and n8n:
|
|
119
|
+
`https://mcp.nestdaddy.com/mcp`. Setup: [nestdaddy.com/mcp](https://nestdaddy.com/mcp).
|
|
120
|
+
|
|
121
|
+
## Development
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
pip install -e ".[test]"
|
|
125
|
+
pytest tests/unit_tests --disable-socket --allow-unix-socket # offline
|
|
126
|
+
NESTDADDY_API_KEY=... pytest -m integration tests/integration_tests
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
MIT licensed.
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# langchain-nestdaddy
|
|
2
|
+
|
|
3
|
+
**Give your AI today's internet.** Live web search, real-time breaking news from around the world and full page
|
|
4
|
+
reading for LangChain agents and RAG pipelines, from [NestDaddy](https://nestdaddy.com)'s own index.
|
|
5
|
+
|
|
6
|
+
[](https://pypi.org/project/langchain-nestdaddy/)
|
|
7
|
+
[](https://github.com/nestdaddy/langchain-nestdaddy/actions/workflows/tests.yml)
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install -U langchain-nestdaddy
|
|
11
|
+
export NESTDADDY_API_KEY="your-key" # free trial key: https://nestdaddy.com/mcp
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
| Class | What it does |
|
|
15
|
+
|---|---|
|
|
16
|
+
| `NestDaddySearch` | Web search on NestDaddy's own index (title, url, snippet, domain) |
|
|
17
|
+
| `NestDaddyNewsSearch` | Real-time news by key words, optional country and time window |
|
|
18
|
+
| `NestDaddyReadPage` | Clean main text of any page or article, no menus or ads |
|
|
19
|
+
| `NestDaddyNewsRetriever` | News as LangChain `Document`s for RAG, optionally with the full article text |
|
|
20
|
+
|
|
21
|
+
## Give an agent live search and news
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from langchain.agents import create_agent
|
|
25
|
+
from langchain_nestdaddy import NestDaddyNewsSearch, NestDaddyReadPage, NestDaddySearch
|
|
26
|
+
|
|
27
|
+
agent = create_agent(
|
|
28
|
+
"anthropic:claude-sonnet-5-5",
|
|
29
|
+
tools=[NestDaddySearch(max_results=5), NestDaddyNewsSearch(days=7), NestDaddyReadPage()],
|
|
30
|
+
system_prompt="Use news search for anything recent, web search for everything else. "
|
|
31
|
+
"Read a page in full before you explain its details.",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
result = agent.invoke({"messages": [{"role": "user", "content": "What did the Fed decide this week?"}]})
|
|
35
|
+
print(result["messages"][-1].content)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Any chat model that supports tool calling works the same way.
|
|
39
|
+
|
|
40
|
+
## Use the tools directly
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from langchain_nestdaddy import NestDaddyNewsSearch, NestDaddySearch, NestDaddyReadPage
|
|
44
|
+
|
|
45
|
+
NestDaddySearch(max_results=3).invoke({"query": "model context protocol"})
|
|
46
|
+
# {'query': 'model context protocol', 'count': 3, 'results': [{'title': ..., 'url': ..., 'snippet': ...}]}
|
|
47
|
+
|
|
48
|
+
NestDaddyNewsSearch().invoke({"query": "interest rates", "country": "US", "days": 7})
|
|
49
|
+
# {'query': 'interest rates', 'count': 10, 'total': ..., 'results': [{'title': ..., 'source_domain': ...,
|
|
50
|
+
# 'published_timestamp': ..., 'summary': ...}]}
|
|
51
|
+
|
|
52
|
+
NestDaddyReadPage(max_chars=8000).invoke({"url": "https://example.com/some-article"})
|
|
53
|
+
# {'title': ..., 'final_url': ..., 'text': ...}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Local news works in the local language: `NestDaddyNewsSearch().invoke({"query": "banjir Jakarta", "country": "ID"})`.
|
|
57
|
+
|
|
58
|
+
## RAG over today's news
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from langchain_nestdaddy import NestDaddyNewsRetriever
|
|
62
|
+
|
|
63
|
+
retriever = NestDaddyNewsRetriever(k=5, days=3, full_text=True)
|
|
64
|
+
docs = retriever.invoke("chip export rules")
|
|
65
|
+
|
|
66
|
+
for d in docs:
|
|
67
|
+
print(d.metadata["title"], d.metadata["url"], d.metadata["published_timestamp"])
|
|
68
|
+
print(d.page_content[:300])
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
With `full_text=True` each article is read in full (one extra API call per article). If an article cannot be
|
|
72
|
+
read, its summary is kept so you still get `k` documents.
|
|
73
|
+
|
|
74
|
+
## Async
|
|
75
|
+
|
|
76
|
+
Every tool and the retriever support `ainvoke`; the retriever reads full articles in parallel.
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
docs = await NestDaddyNewsRetriever(k=5, full_text=True).ainvoke("AI regulation")
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Errors
|
|
83
|
+
|
|
84
|
+
API errors raise `NestDaddyAPIError` with the HTTP status and a hint (a bad key points to the dashboard, a plan
|
|
85
|
+
limit to the plans page). Your key is stored as a `SecretStr` and never printed.
|
|
86
|
+
|
|
87
|
+
## Prefer MCP?
|
|
88
|
+
|
|
89
|
+
The same tools are available as a remote MCP server for Claude, Cursor, VS Code and n8n:
|
|
90
|
+
`https://mcp.nestdaddy.com/mcp`. Setup: [nestdaddy.com/mcp](https://nestdaddy.com/mcp).
|
|
91
|
+
|
|
92
|
+
## Development
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
pip install -e ".[test]"
|
|
96
|
+
pytest tests/unit_tests --disable-socket --allow-unix-socket # offline
|
|
97
|
+
NESTDADDY_API_KEY=... pytest -m integration tests/integration_tests
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
MIT licensed.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""LangChain integration for NestDaddy: give your AI today's internet.
|
|
2
|
+
|
|
3
|
+
Live web search, real-time breaking news from around the world and full page reading, from NestDaddy's own
|
|
4
|
+
index. Get a key at https://nestdaddy.com/mcp and set ``NESTDADDY_API_KEY``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from importlib import metadata
|
|
8
|
+
|
|
9
|
+
from langchain_nestdaddy._client import NestDaddyAPIError, NestDaddyAPIWrapper
|
|
10
|
+
from langchain_nestdaddy.retrievers import NestDaddyNewsRetriever
|
|
11
|
+
from langchain_nestdaddy.tools import NestDaddyNewsSearch, NestDaddyReadPage, NestDaddySearch
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
__version__ = metadata.version(__package__ or "langchain_nestdaddy")
|
|
15
|
+
except metadata.PackageNotFoundError:
|
|
16
|
+
__version__ = ""
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"NestDaddyAPIError",
|
|
20
|
+
"NestDaddyAPIWrapper",
|
|
21
|
+
"NestDaddyNewsRetriever",
|
|
22
|
+
"NestDaddyNewsSearch",
|
|
23
|
+
"NestDaddyReadPage",
|
|
24
|
+
"NestDaddySearch",
|
|
25
|
+
"__version__",
|
|
26
|
+
]
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Small HTTP client for the NestDaddy REST API, shared by the tools and the retriever."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any, Dict, Optional
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
from pydantic import BaseModel, ConfigDict, Field, SecretStr, model_validator
|
|
10
|
+
|
|
11
|
+
DEFAULT_BASE_URL = "https://nestdaddy.com"
|
|
12
|
+
USER_AGENT = "langchain-nestdaddy/0.1.0"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class NestDaddyAPIError(RuntimeError):
|
|
16
|
+
"""Raised when the NestDaddy API answers with an error (bad key, plan limit, bad input)."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, status_code: int, error: str, message: str) -> None:
|
|
19
|
+
self.status_code = status_code
|
|
20
|
+
self.error = error
|
|
21
|
+
hint = ""
|
|
22
|
+
if status_code in (401, 403):
|
|
23
|
+
hint = " Check your key at https://nestdaddy.com/dashboard"
|
|
24
|
+
elif status_code == 429:
|
|
25
|
+
hint = " You reached your plan's limit: https://nestdaddy.com/mcp"
|
|
26
|
+
super().__init__(f"NestDaddy API error {status_code} ({error}): {message}.{hint}")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class NestDaddyAPIWrapper(BaseModel):
|
|
30
|
+
"""Calls the NestDaddy API with your key.
|
|
31
|
+
|
|
32
|
+
The key is read from ``api_key`` or the ``NESTDADDY_API_KEY`` environment variable.
|
|
33
|
+
Get one at https://nestdaddy.com/mcp (free trial).
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
model_config = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
|
|
37
|
+
|
|
38
|
+
api_key: Optional[SecretStr] = Field(default=None)
|
|
39
|
+
base_url: str = DEFAULT_BASE_URL
|
|
40
|
+
timeout: float = 30.0
|
|
41
|
+
transport: Optional[Any] = Field(default=None, exclude=True, repr=False)
|
|
42
|
+
"""Optional httpx transport (used by tests). Leave unset in normal use."""
|
|
43
|
+
|
|
44
|
+
@model_validator(mode="after")
|
|
45
|
+
def _resolve_key(self) -> "NestDaddyAPIWrapper":
|
|
46
|
+
if self.api_key is None:
|
|
47
|
+
env = os.environ.get("NESTDADDY_API_KEY", "").strip()
|
|
48
|
+
if env:
|
|
49
|
+
self.api_key = SecretStr(env)
|
|
50
|
+
if self.api_key is None or not self.api_key.get_secret_value():
|
|
51
|
+
raise ValueError(
|
|
52
|
+
"NestDaddy API key not found. Pass api_key=... or set NESTDADDY_API_KEY. "
|
|
53
|
+
"Get a key at https://nestdaddy.com/mcp"
|
|
54
|
+
)
|
|
55
|
+
return self
|
|
56
|
+
|
|
57
|
+
# ------------------------------------------------------------------ internals
|
|
58
|
+
def _headers(self) -> Dict[str, str]:
|
|
59
|
+
assert self.api_key is not None
|
|
60
|
+
return {
|
|
61
|
+
"X-API-Key": self.api_key.get_secret_value(),
|
|
62
|
+
"Accept": "application/json",
|
|
63
|
+
"User-Agent": USER_AGENT,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
@staticmethod
|
|
67
|
+
def _clean(params: Dict[str, Any]) -> Dict[str, Any]:
|
|
68
|
+
return {k: v for k, v in params.items() if v not in (None, "")}
|
|
69
|
+
|
|
70
|
+
@staticmethod
|
|
71
|
+
def _parse(response: httpx.Response) -> Dict[str, Any]:
|
|
72
|
+
try:
|
|
73
|
+
data = response.json()
|
|
74
|
+
except ValueError:
|
|
75
|
+
raise NestDaddyAPIError(response.status_code, "invalid_response",
|
|
76
|
+
"the API did not return JSON") from None
|
|
77
|
+
if response.status_code != 200:
|
|
78
|
+
raise NestDaddyAPIError(response.status_code, str(data.get("error") or "error"),
|
|
79
|
+
str(data.get("message") or data.get("error") or response.reason_phrase))
|
|
80
|
+
return data
|
|
81
|
+
|
|
82
|
+
def get(self, path: str, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
83
|
+
with httpx.Client(base_url=self.base_url, timeout=self.timeout, transport=self.transport) as client:
|
|
84
|
+
return self._parse(client.get(path, params=self._clean(params), headers=self._headers()))
|
|
85
|
+
|
|
86
|
+
async def aget(self, path: str, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
87
|
+
async with httpx.AsyncClient(base_url=self.base_url, timeout=self.timeout,
|
|
88
|
+
transport=self.transport) as client:
|
|
89
|
+
return self._parse(await client.get(path, params=self._clean(params), headers=self._headers()))
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""NestDaddy news retriever: real-time news as LangChain Documents, optionally with the full article text."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from typing import Any, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
from langchain_core.callbacks import AsyncCallbackManagerForRetrieverRun, CallbackManagerForRetrieverRun
|
|
9
|
+
from langchain_core.documents import Document
|
|
10
|
+
from langchain_core.retrievers import BaseRetriever
|
|
11
|
+
from pydantic import Field, model_validator
|
|
12
|
+
|
|
13
|
+
from langchain_nestdaddy._client import NestDaddyAPIError, NestDaddyAPIWrapper
|
|
14
|
+
|
|
15
|
+
META_FIELDS = ("title", "url", "source_domain", "published_timestamp", "country")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class NestDaddyNewsRetriever(BaseRetriever):
|
|
19
|
+
"""Retrieve real-time news from around the world for a query.
|
|
20
|
+
|
|
21
|
+
Each article becomes a ``Document``: the summary (or, with ``full_text=True``, the article text) is the
|
|
22
|
+
``page_content``; title, url, source, publish time and country are in ``metadata``.
|
|
23
|
+
|
|
24
|
+
Setup:
|
|
25
|
+
.. code-block:: bash
|
|
26
|
+
|
|
27
|
+
pip install -U langchain-nestdaddy
|
|
28
|
+
export NESTDADDY_API_KEY="your-key"
|
|
29
|
+
|
|
30
|
+
Instantiate:
|
|
31
|
+
.. code-block:: python
|
|
32
|
+
|
|
33
|
+
from langchain_nestdaddy import NestDaddyNewsRetriever
|
|
34
|
+
|
|
35
|
+
retriever = NestDaddyNewsRetriever(k=5, days=7, full_text=True)
|
|
36
|
+
|
|
37
|
+
Usage:
|
|
38
|
+
.. code-block:: python
|
|
39
|
+
|
|
40
|
+
docs = retriever.invoke("chip export rules")
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
api_wrapper: NestDaddyAPIWrapper
|
|
44
|
+
k: int = Field(default=5, ge=1, le=50)
|
|
45
|
+
"""How many articles to return."""
|
|
46
|
+
days: int = Field(default=30, ge=1, le=120)
|
|
47
|
+
"""Only news from the last N days."""
|
|
48
|
+
country: Optional[str] = None
|
|
49
|
+
"""Optional two-letter country code, e.g. US, GB, IN, SG, ID."""
|
|
50
|
+
full_text: bool = False
|
|
51
|
+
"""Read each article in full (one extra API call per article) instead of using its summary."""
|
|
52
|
+
max_chars: int = Field(default=6000, ge=500, le=30000)
|
|
53
|
+
"""Characters kept from each full article."""
|
|
54
|
+
|
|
55
|
+
@model_validator(mode="before")
|
|
56
|
+
@classmethod
|
|
57
|
+
def _build_wrapper(cls, values: Any) -> Any:
|
|
58
|
+
if isinstance(values, dict) and "api_wrapper" not in values:
|
|
59
|
+
kwargs = {k: values.pop(k) for k in ("api_key", "base_url") if k in values}
|
|
60
|
+
values["api_wrapper"] = NestDaddyAPIWrapper(**kwargs)
|
|
61
|
+
return values
|
|
62
|
+
|
|
63
|
+
# ------------------------------------------------------------------ helpers
|
|
64
|
+
def _params(self, query: str) -> Dict[str, Any]:
|
|
65
|
+
return {"q": query, "days": self.days, "limit": self.k,
|
|
66
|
+
"country": self.country.upper() if self.country else None}
|
|
67
|
+
|
|
68
|
+
@staticmethod
|
|
69
|
+
def _to_doc(item: Dict[str, Any], text: Optional[str] = None) -> Document:
|
|
70
|
+
content = text or item.get("summary") or item.get("description") or item.get("title") or ""
|
|
71
|
+
meta = {f: item[f] for f in META_FIELDS if item.get(f) not in (None, "")}
|
|
72
|
+
meta["full_text"] = bool(text)
|
|
73
|
+
return Document(page_content=content, metadata=meta)
|
|
74
|
+
|
|
75
|
+
def _items(self, data: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
76
|
+
return [r for r in data.get("results") or [] if isinstance(r, dict) and r.get("url")][: self.k]
|
|
77
|
+
|
|
78
|
+
def _read(self, url: str) -> Optional[str]:
|
|
79
|
+
try:
|
|
80
|
+
page = self.api_wrapper.get("/api/v1/extract", {"url": url, "max_chars": self.max_chars})
|
|
81
|
+
except NestDaddyAPIError:
|
|
82
|
+
return None # keep the summary when one article cannot be read
|
|
83
|
+
text = page.get("text") or ""
|
|
84
|
+
return text if len(text) > 200 else None
|
|
85
|
+
|
|
86
|
+
async def _aread(self, url: str) -> Optional[str]:
|
|
87
|
+
try:
|
|
88
|
+
page = await self.api_wrapper.aget("/api/v1/extract", {"url": url, "max_chars": self.max_chars})
|
|
89
|
+
except NestDaddyAPIError:
|
|
90
|
+
return None
|
|
91
|
+
text = page.get("text") or ""
|
|
92
|
+
return text if len(text) > 200 else None
|
|
93
|
+
|
|
94
|
+
# ------------------------------------------------------------------ BaseRetriever
|
|
95
|
+
def _get_relevant_documents(self, query: str, *,
|
|
96
|
+
run_manager: CallbackManagerForRetrieverRun) -> List[Document]:
|
|
97
|
+
items = self._items(self.api_wrapper.get("/api/v1/news/search", self._params(query)))
|
|
98
|
+
texts = [self._read(i["url"]) if self.full_text else None for i in items]
|
|
99
|
+
return [self._to_doc(i, t) for i, t in zip(items, texts)]
|
|
100
|
+
|
|
101
|
+
async def _aget_relevant_documents(self, query: str, *,
|
|
102
|
+
run_manager: AsyncCallbackManagerForRetrieverRun) -> List[Document]:
|
|
103
|
+
items = self._items(await self.api_wrapper.aget("/api/v1/news/search", self._params(query)))
|
|
104
|
+
if self.full_text:
|
|
105
|
+
texts = await asyncio.gather(*(self._aread(i["url"]) for i in items))
|
|
106
|
+
else:
|
|
107
|
+
texts = [None] * len(items)
|
|
108
|
+
return [self._to_doc(i, t) for i, t in zip(items, texts)]
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""NestDaddy tools for LangChain agents: web search, real-time news search and full page reading."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, List, Optional, Tuple, Type
|
|
6
|
+
|
|
7
|
+
from langchain_core.callbacks import AsyncCallbackManagerForToolRun, CallbackManagerForToolRun
|
|
8
|
+
from langchain_core.tools import BaseTool
|
|
9
|
+
from pydantic import BaseModel, Field, model_validator
|
|
10
|
+
|
|
11
|
+
from langchain_nestdaddy._client import NestDaddyAPIWrapper
|
|
12
|
+
|
|
13
|
+
SNIPPET_CHARS = 400
|
|
14
|
+
NEWS_FIELDS = ("title", "url", "source_domain", "published_timestamp", "summary", "description", "country")
|
|
15
|
+
WEB_FIELDS = ("title", "url", "snippet", "domain", "type")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _pick(item: Dict[str, Any], fields: Tuple[str, ...]) -> Dict[str, Any]:
|
|
19
|
+
out: Dict[str, Any] = {}
|
|
20
|
+
for f in fields:
|
|
21
|
+
v = item.get(f)
|
|
22
|
+
if v in (None, "", [], {}):
|
|
23
|
+
continue
|
|
24
|
+
if isinstance(v, str) and len(v) > SNIPPET_CHARS:
|
|
25
|
+
v = v[:SNIPPET_CHARS].rstrip() + "..."
|
|
26
|
+
out[f] = v
|
|
27
|
+
return out
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class _NestDaddyTool(BaseTool):
|
|
31
|
+
"""Shared setup: every tool owns an API wrapper, built from ``api_key`` or ``NESTDADDY_API_KEY``."""
|
|
32
|
+
|
|
33
|
+
api_wrapper: NestDaddyAPIWrapper
|
|
34
|
+
|
|
35
|
+
@model_validator(mode="before")
|
|
36
|
+
@classmethod
|
|
37
|
+
def _build_wrapper(cls, values: Any) -> Any:
|
|
38
|
+
if isinstance(values, dict) and "api_wrapper" not in values:
|
|
39
|
+
kwargs = {k: values.pop(k) for k in ("api_key", "base_url") if k in values}
|
|
40
|
+
values["api_wrapper"] = NestDaddyAPIWrapper(**kwargs)
|
|
41
|
+
return values
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------------------------- web search
|
|
45
|
+
class NestDaddySearchInput(BaseModel):
|
|
46
|
+
query: str = Field(description="Short key words to search the web for, not a whole sentence.")
|
|
47
|
+
max_results: Optional[int] = Field(default=None, ge=1, le=50,
|
|
48
|
+
description="How many results to return (1-50).")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class NestDaddySearch(_NestDaddyTool):
|
|
52
|
+
"""Search the web with NestDaddy's own index.
|
|
53
|
+
|
|
54
|
+
Setup:
|
|
55
|
+
.. code-block:: bash
|
|
56
|
+
|
|
57
|
+
pip install -U langchain-nestdaddy
|
|
58
|
+
export NESTDADDY_API_KEY="your-key"
|
|
59
|
+
|
|
60
|
+
Instantiate:
|
|
61
|
+
.. code-block:: python
|
|
62
|
+
|
|
63
|
+
from langchain_nestdaddy import NestDaddySearch
|
|
64
|
+
|
|
65
|
+
tool = NestDaddySearch(max_results=5)
|
|
66
|
+
|
|
67
|
+
Invoke:
|
|
68
|
+
.. code-block:: python
|
|
69
|
+
|
|
70
|
+
tool.invoke({"query": "model context protocol"})
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
name: str = "nestdaddy_search"
|
|
74
|
+
description: str = (
|
|
75
|
+
"Search the web with NestDaddy's own index. Returns the title, url, snippet and domain of each page. "
|
|
76
|
+
"For time-sensitive questions fresh news articles are mixed in. Use short key words. "
|
|
77
|
+
"Call nestdaddy_read_page on a url when the snippet is not enough."
|
|
78
|
+
)
|
|
79
|
+
args_schema: Type[BaseModel] = NestDaddySearchInput
|
|
80
|
+
max_results: int = 10
|
|
81
|
+
|
|
82
|
+
def _params(self, query: str, max_results: Optional[int]) -> Dict[str, Any]:
|
|
83
|
+
return {"q": query, "limit": max_results or self.max_results}
|
|
84
|
+
|
|
85
|
+
@staticmethod
|
|
86
|
+
def _format(data: Dict[str, Any]) -> Dict[str, Any]:
|
|
87
|
+
results = [_pick(r, WEB_FIELDS) for r in data.get("results") or [] if isinstance(r, dict)]
|
|
88
|
+
return {"query": data.get("query"), "count": len(results), "results": results}
|
|
89
|
+
|
|
90
|
+
def _run(self, query: str, max_results: Optional[int] = None,
|
|
91
|
+
run_manager: Optional[CallbackManagerForToolRun] = None) -> Dict[str, Any]:
|
|
92
|
+
return self._format(self.api_wrapper.get("/api/v1/search", self._params(query, max_results)))
|
|
93
|
+
|
|
94
|
+
async def _arun(self, query: str, max_results: Optional[int] = None,
|
|
95
|
+
run_manager: Optional[AsyncCallbackManagerForToolRun] = None) -> Dict[str, Any]:
|
|
96
|
+
return self._format(await self.api_wrapper.aget("/api/v1/search", self._params(query, max_results)))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ---------------------------------------------------------------------------------------------- news search
|
|
100
|
+
class NestDaddyNewsSearchInput(BaseModel):
|
|
101
|
+
query: str = Field(description="Key words a headline would use, in the local language for local news.")
|
|
102
|
+
country: Optional[str] = Field(default=None, min_length=2, max_length=2,
|
|
103
|
+
description="Optional two-letter country code, e.g. US, GB, IN, SG, ID.")
|
|
104
|
+
days: Optional[int] = Field(default=None, ge=1, le=120, description="Only news from the last N days (1-120).")
|
|
105
|
+
max_results: Optional[int] = Field(default=None, ge=1, le=50,
|
|
106
|
+
description="How many articles to return (1-50).")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class NestDaddyNewsSearch(_NestDaddyTool):
|
|
110
|
+
"""Search real-time news from around the world by key words, newest and best match first.
|
|
111
|
+
|
|
112
|
+
Setup:
|
|
113
|
+
.. code-block:: bash
|
|
114
|
+
|
|
115
|
+
pip install -U langchain-nestdaddy
|
|
116
|
+
export NESTDADDY_API_KEY="your-key"
|
|
117
|
+
|
|
118
|
+
Instantiate:
|
|
119
|
+
.. code-block:: python
|
|
120
|
+
|
|
121
|
+
from langchain_nestdaddy import NestDaddyNewsSearch
|
|
122
|
+
|
|
123
|
+
tool = NestDaddyNewsSearch(days=7, max_results=5)
|
|
124
|
+
|
|
125
|
+
Invoke:
|
|
126
|
+
.. code-block:: python
|
|
127
|
+
|
|
128
|
+
tool.invoke({"query": "interest rates", "country": "US"})
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
name: str = "nestdaddy_news_search"
|
|
132
|
+
description: str = (
|
|
133
|
+
"Search real-time breaking news from around the world by key words: title, url, source, publish time and "
|
|
134
|
+
"summary of each article, newest and best match first. The best tool for questions about recent events, "
|
|
135
|
+
"people, companies and places. Optional country (two-letter code) and days (1-120)."
|
|
136
|
+
)
|
|
137
|
+
args_schema: Type[BaseModel] = NestDaddyNewsSearchInput
|
|
138
|
+
max_results: int = 10
|
|
139
|
+
days: int = 30
|
|
140
|
+
|
|
141
|
+
def _params(self, query: str, country: Optional[str], days: Optional[int],
|
|
142
|
+
max_results: Optional[int]) -> Dict[str, Any]:
|
|
143
|
+
return {"q": query, "country": country.upper() if country else None, "days": days or self.days,
|
|
144
|
+
"limit": max_results or self.max_results}
|
|
145
|
+
|
|
146
|
+
@staticmethod
|
|
147
|
+
def _format(data: Dict[str, Any]) -> Dict[str, Any]:
|
|
148
|
+
results = [_pick(r, NEWS_FIELDS) for r in data.get("results") or [] if isinstance(r, dict)]
|
|
149
|
+
return {"query": data.get("query"), "count": len(results), "total": data.get("total"), "results": results}
|
|
150
|
+
|
|
151
|
+
def _run(self, query: str, country: Optional[str] = None, days: Optional[int] = None,
|
|
152
|
+
max_results: Optional[int] = None,
|
|
153
|
+
run_manager: Optional[CallbackManagerForToolRun] = None) -> Dict[str, Any]:
|
|
154
|
+
return self._format(self.api_wrapper.get("/api/v1/news/search",
|
|
155
|
+
self._params(query, country, days, max_results)))
|
|
156
|
+
|
|
157
|
+
async def _arun(self, query: str, country: Optional[str] = None, days: Optional[int] = None,
|
|
158
|
+
max_results: Optional[int] = None,
|
|
159
|
+
run_manager: Optional[AsyncCallbackManagerForToolRun] = None) -> Dict[str, Any]:
|
|
160
|
+
return self._format(await self.api_wrapper.aget("/api/v1/news/search",
|
|
161
|
+
self._params(query, country, days, max_results)))
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# ---------------------------------------------------------------------------------------------- read page
|
|
165
|
+
class NestDaddyReadPageInput(BaseModel):
|
|
166
|
+
url: str = Field(description="The full http(s) url of the page or article to read.")
|
|
167
|
+
max_chars: Optional[int] = Field(default=None, ge=500, le=30000,
|
|
168
|
+
description="Maximum characters of text to return (500-30000).")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class NestDaddyReadPage(_NestDaddyTool):
|
|
172
|
+
"""Read the clean main text of any web page or news article (no menus, no ads).
|
|
173
|
+
|
|
174
|
+
Setup:
|
|
175
|
+
.. code-block:: bash
|
|
176
|
+
|
|
177
|
+
pip install -U langchain-nestdaddy
|
|
178
|
+
export NESTDADDY_API_KEY="your-key"
|
|
179
|
+
|
|
180
|
+
Instantiate:
|
|
181
|
+
.. code-block:: python
|
|
182
|
+
|
|
183
|
+
from langchain_nestdaddy import NestDaddyReadPage
|
|
184
|
+
|
|
185
|
+
tool = NestDaddyReadPage(max_chars=8000)
|
|
186
|
+
|
|
187
|
+
Invoke:
|
|
188
|
+
.. code-block:: python
|
|
189
|
+
|
|
190
|
+
tool.invoke({"url": "https://en.wikipedia.org/wiki/Model_Context_Protocol"})
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
name: str = "nestdaddy_read_page"
|
|
194
|
+
description: str = (
|
|
195
|
+
"Read the full readable text of one web page or news article: title, author, date and main text, "
|
|
196
|
+
"without menus or ads. Use it after a search when you need the details behind a result."
|
|
197
|
+
)
|
|
198
|
+
args_schema: Type[BaseModel] = NestDaddyReadPageInput
|
|
199
|
+
max_chars: int = 8000
|
|
200
|
+
|
|
201
|
+
@staticmethod
|
|
202
|
+
def _format(data: Dict[str, Any]) -> Dict[str, Any]:
|
|
203
|
+
keep = ("title", "final_url", "author", "published", "text", "truncated", "length")
|
|
204
|
+
return {k: data[k] for k in keep if data.get(k) not in (None, "")}
|
|
205
|
+
|
|
206
|
+
def _run(self, url: str, max_chars: Optional[int] = None,
|
|
207
|
+
run_manager: Optional[CallbackManagerForToolRun] = None) -> Dict[str, Any]:
|
|
208
|
+
return self._format(self.api_wrapper.get("/api/v1/extract",
|
|
209
|
+
{"url": url, "max_chars": max_chars or self.max_chars}))
|
|
210
|
+
|
|
211
|
+
async def _arun(self, url: str, max_chars: Optional[int] = None,
|
|
212
|
+
run_manager: Optional[AsyncCallbackManagerForToolRun] = None) -> Dict[str, Any]:
|
|
213
|
+
return self._format(await self.api_wrapper.aget("/api/v1/extract",
|
|
214
|
+
{"url": url, "max_chars": max_chars or self.max_chars}))
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
__all__: List[str] = [
|
|
218
|
+
"NestDaddySearch", "NestDaddySearchInput",
|
|
219
|
+
"NestDaddyNewsSearch", "NestDaddyNewsSearchInput",
|
|
220
|
+
"NestDaddyReadPage", "NestDaddyReadPageInput",
|
|
221
|
+
]
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.24"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "langchain-nestdaddy"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "LangChain integration for NestDaddy: give your AI today's internet. Live web search, real-time news and page reading."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [{ name = "NestDaddy", email = "hello@nestdaddy.com" }]
|
|
13
|
+
keywords = ["langchain", "search", "news", "web search", "rag", "agents", "nestdaddy"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Topic :: Internet :: WWW/HTTP :: Indexing/Search",
|
|
20
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
21
|
+
]
|
|
22
|
+
dependencies = [
|
|
23
|
+
"langchain-core>=0.3.15,<2.0.0",
|
|
24
|
+
"httpx>=0.25.0,<1.0.0",
|
|
25
|
+
"pydantic>=2.7,<3.0",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
test = [
|
|
30
|
+
"pytest>=8",
|
|
31
|
+
"pytest-asyncio>=0.23",
|
|
32
|
+
"pytest-socket>=0.7",
|
|
33
|
+
"langchain-tests>=0.3.0",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Homepage = "https://nestdaddy.com/mcp"
|
|
38
|
+
Documentation = "https://github.com/nestdaddy/langchain-nestdaddy#readme"
|
|
39
|
+
Source = "https://github.com/nestdaddy/langchain-nestdaddy"
|
|
40
|
+
"API reference" = "https://nestdaddy.com/apis"
|
|
41
|
+
|
|
42
|
+
[tool.hatch.build.targets.wheel]
|
|
43
|
+
packages = ["langchain_nestdaddy"]
|
|
44
|
+
|
|
45
|
+
[tool.pytest.ini_options]
|
|
46
|
+
asyncio_mode = "auto"
|
|
47
|
+
markers = ["integration: calls the live NestDaddy API (needs NESTDADDY_API_KEY)"]
|
|
48
|
+
addopts = "-m 'not integration'"
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Live tests against the real NestDaddy API. Run with: NESTDADDY_API_KEY=... pytest -m integration"""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from langchain_nestdaddy import NestDaddyNewsRetriever, NestDaddyNewsSearch, NestDaddyReadPage, NestDaddySearch
|
|
8
|
+
|
|
9
|
+
pytestmark = [
|
|
10
|
+
pytest.mark.integration,
|
|
11
|
+
pytest.mark.skipif(not os.environ.get("NESTDADDY_API_KEY"), reason="NESTDADDY_API_KEY not set"),
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_live_web_search() -> None:
|
|
16
|
+
out = NestDaddySearch(max_results=3).invoke({"query": "model context protocol"})
|
|
17
|
+
assert out["count"] >= 1 and out["results"][0]["url"].startswith("http")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_live_news_search() -> None:
|
|
21
|
+
out = NestDaddyNewsSearch(max_results=3, days=7).invoke({"query": "AI"})
|
|
22
|
+
assert out["count"] >= 1 and out["results"][0]["title"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_live_read_page() -> None:
|
|
26
|
+
out = NestDaddyReadPage(max_chars=1000).invoke({"url": "https://en.wikipedia.org/wiki/Web_search_engine"})
|
|
27
|
+
assert len(out.get("text", "")) > 200
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_live_retriever() -> None:
|
|
31
|
+
docs = NestDaddyNewsRetriever(k=2, days=7).invoke("AI")
|
|
32
|
+
assert docs and docs[0].metadata["url"].startswith("http")
|
|
File without changes
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Behaviour tests with a fake NestDaddy API (httpx.MockTransport), sync and async."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any, Dict, List
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from langchain_nestdaddy import (
|
|
10
|
+
NestDaddyAPIError,
|
|
11
|
+
NestDaddyAPIWrapper,
|
|
12
|
+
NestDaddyNewsRetriever,
|
|
13
|
+
NestDaddyNewsSearch,
|
|
14
|
+
NestDaddyReadPage,
|
|
15
|
+
NestDaddySearch,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
LONG = "Full article text. " * 40
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def fake_api(seen: List[httpx.Request]) -> httpx.MockTransport:
|
|
22
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
23
|
+
seen.append(request)
|
|
24
|
+
if request.headers.get("X-API-Key") != "test-key":
|
|
25
|
+
return httpx.Response(401, json={"error": "Invalid API key", "message": "The key is not valid"})
|
|
26
|
+
path, q = request.url.path, dict(request.url.params)
|
|
27
|
+
if path == "/api/v1/search":
|
|
28
|
+
return httpx.Response(200, json={"query": q["q"], "results": [
|
|
29
|
+
{"title": "MCP spec", "url": "https://modelcontextprotocol.io", "snippet": "x" * 900,
|
|
30
|
+
"domain": "modelcontextprotocol.io", "type": "web", "internal_score": 9}]})
|
|
31
|
+
if path == "/api/v1/news/search":
|
|
32
|
+
return httpx.Response(200, json={"query": q["q"], "total": 2, "results": [
|
|
33
|
+
{"title": "Fed holds rates", "url": "https://news.example/a", "source_domain": "news.example",
|
|
34
|
+
"published_timestamp": "2026-11-13T10:00:00Z", "summary": "The Fed held rates.", "country": "US"},
|
|
35
|
+
{"title": "Markets rise", "url": "https://news.example/b", "source_domain": "news.example",
|
|
36
|
+
"published_timestamp": "2026-11-13T09:00:00Z", "summary": "Stocks rose.", "country": "US"}]})
|
|
37
|
+
if path == "/api/v1/extract":
|
|
38
|
+
if q["url"].endswith("/b"):
|
|
39
|
+
return httpx.Response(422, json={"error": "Unreadable", "message": "No article text"})
|
|
40
|
+
return httpx.Response(200, json={"title": "Fed holds rates", "final_url": q["url"],
|
|
41
|
+
"text": LONG, "length": len(LONG), "truncated": False})
|
|
42
|
+
return httpx.Response(404, json={"error": "Not found", "message": path})
|
|
43
|
+
return httpx.MockTransport(handler)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def wrapper(seen: List[httpx.Request], key: str = "test-key") -> NestDaddyAPIWrapper:
|
|
47
|
+
return NestDaddyAPIWrapper(api_key=key, transport=fake_api(seen))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_missing_key_is_a_clear_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
51
|
+
monkeypatch.delenv("NESTDADDY_API_KEY", raising=False)
|
|
52
|
+
with pytest.raises(ValueError, match="NESTDADDY_API_KEY"):
|
|
53
|
+
NestDaddySearch()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_key_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
57
|
+
monkeypatch.setenv("NESTDADDY_API_KEY", "env-key")
|
|
58
|
+
assert NestDaddySearch().api_wrapper.api_key.get_secret_value() == "env-key"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_key_is_not_printed() -> None:
|
|
62
|
+
w = NestDaddyAPIWrapper(api_key="secret-value")
|
|
63
|
+
assert "secret-value" not in repr(w) and "secret-value" not in str(w.model_dump())
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_web_search_trims_and_picks_fields() -> None:
|
|
67
|
+
seen: List[httpx.Request] = []
|
|
68
|
+
out = NestDaddySearch(api_wrapper=wrapper(seen), max_results=3).invoke({"query": "mcp"})
|
|
69
|
+
assert seen[0].url.params["limit"] == "3" and seen[0].url.params["q"] == "mcp"
|
|
70
|
+
r = out["results"][0]
|
|
71
|
+
assert set(r) == {"title", "url", "snippet", "domain", "type"} # internal fields dropped
|
|
72
|
+
assert len(r["snippet"]) <= 403
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_news_search_params_and_output() -> None:
|
|
76
|
+
seen: List[httpx.Request] = []
|
|
77
|
+
out = NestDaddyNewsSearch(api_wrapper=wrapper(seen), days=7).invoke(
|
|
78
|
+
{"query": "fed", "country": "us", "max_results": 2})
|
|
79
|
+
p = seen[0].url.params
|
|
80
|
+
assert (p["country"], p["days"], p["limit"]) == ("US", "7", "2")
|
|
81
|
+
assert out["count"] == 2 and out["total"] == 2 and out["results"][0]["source_domain"] == "news.example"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_optional_params_are_not_sent() -> None:
|
|
85
|
+
seen: List[httpx.Request] = []
|
|
86
|
+
NestDaddyNewsSearch(api_wrapper=wrapper(seen)).invoke({"query": "fed"})
|
|
87
|
+
assert "country" not in seen[0].url.params
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_read_page() -> None:
|
|
91
|
+
seen: List[httpx.Request] = []
|
|
92
|
+
out = NestDaddyReadPage(api_wrapper=wrapper(seen), max_chars=2000).invoke({"url": "https://news.example/a"})
|
|
93
|
+
assert seen[0].url.params["max_chars"] == "2000" and out["text"].startswith("Full article text")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_api_error_is_raised_with_hint() -> None:
|
|
97
|
+
seen: List[httpx.Request] = []
|
|
98
|
+
with pytest.raises(NestDaddyAPIError, match="dashboard") as e:
|
|
99
|
+
wrapper(seen, key="wrong").get("/api/v1/search", {"q": "x"})
|
|
100
|
+
assert e.value.status_code == 401
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_tool_as_agent_tool_message() -> None:
|
|
104
|
+
"""Tools called by an agent return a ToolMessage whose content is the JSON result."""
|
|
105
|
+
seen: List[httpx.Request] = []
|
|
106
|
+
tool = NestDaddyNewsSearch(api_wrapper=wrapper(seen))
|
|
107
|
+
msg = tool.invoke({"type": "tool_call", "id": "call_1", "name": tool.name, "args": {"query": "fed"}})
|
|
108
|
+
assert msg.tool_call_id == "call_1" and "Fed holds rates" in msg.content
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_retriever_summaries() -> None:
|
|
112
|
+
seen: List[httpx.Request] = []
|
|
113
|
+
docs = NestDaddyNewsRetriever(api_wrapper=wrapper(seen), k=2).invoke("fed")
|
|
114
|
+
assert [d.page_content for d in docs] == ["The Fed held rates.", "Stocks rose."]
|
|
115
|
+
assert docs[0].metadata["url"] == "https://news.example/a" and docs[0].metadata["full_text"] is False
|
|
116
|
+
assert len(seen) == 1
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_retriever_full_text_keeps_summary_when_unreadable() -> None:
|
|
120
|
+
seen: List[httpx.Request] = []
|
|
121
|
+
docs = NestDaddyNewsRetriever(api_wrapper=wrapper(seen), k=2, full_text=True).invoke("fed")
|
|
122
|
+
assert docs[0].page_content.startswith("Full article text") and docs[0].metadata["full_text"] is True
|
|
123
|
+
assert docs[1].page_content == "Stocks rose." and docs[1].metadata["full_text"] is False
|
|
124
|
+
assert len(seen) == 3
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
async def test_async_tool_and_retriever() -> None:
|
|
128
|
+
seen: List[httpx.Request] = []
|
|
129
|
+
out = await NestDaddySearch(api_wrapper=wrapper(seen)).ainvoke({"query": "mcp"})
|
|
130
|
+
assert out["count"] == 1
|
|
131
|
+
docs = await NestDaddyNewsRetriever(api_wrapper=wrapper(seen), k=2, full_text=True).ainvoke("fed")
|
|
132
|
+
assert docs[0].metadata["full_text"] is True and len(docs) == 2
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_output_is_json_serialisable() -> None:
|
|
136
|
+
seen: List[httpx.Request] = []
|
|
137
|
+
out: Dict[str, Any] = NestDaddyNewsSearch(api_wrapper=wrapper(seen)).invoke({"query": "fed"})
|
|
138
|
+
json.dumps(out)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""LangChain standard unit tests for the NestDaddy tools (no network)."""
|
|
2
|
+
|
|
3
|
+
from typing import Type
|
|
4
|
+
|
|
5
|
+
from langchain_tests.unit_tests import ToolsUnitTests
|
|
6
|
+
|
|
7
|
+
from langchain_nestdaddy import NestDaddyNewsSearch, NestDaddyReadPage, NestDaddySearch
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TestNestDaddySearchUnit(ToolsUnitTests):
|
|
11
|
+
@property
|
|
12
|
+
def tool_constructor(self) -> Type[NestDaddySearch]:
|
|
13
|
+
return NestDaddySearch
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
def tool_constructor_params(self) -> dict:
|
|
17
|
+
return {"api_key": "test-key", "max_results": 3}
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def tool_invoke_params_example(self) -> dict:
|
|
21
|
+
return {"query": "model context protocol"}
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def init_from_env_params(self) -> tuple:
|
|
25
|
+
return ({"NESTDADDY_API_KEY": "env-key"}, {}, {})
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TestNestDaddyNewsSearchUnit(ToolsUnitTests):
|
|
29
|
+
@property
|
|
30
|
+
def tool_constructor(self) -> Type[NestDaddyNewsSearch]:
|
|
31
|
+
return NestDaddyNewsSearch
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def tool_constructor_params(self) -> dict:
|
|
35
|
+
return {"api_key": "test-key"}
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def tool_invoke_params_example(self) -> dict:
|
|
39
|
+
return {"query": "interest rates", "country": "US", "days": 7}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class TestNestDaddyReadPageUnit(ToolsUnitTests):
|
|
43
|
+
@property
|
|
44
|
+
def tool_constructor(self) -> Type[NestDaddyReadPage]:
|
|
45
|
+
return NestDaddyReadPage
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def tool_constructor_params(self) -> dict:
|
|
49
|
+
return {"api_key": "test-key"}
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def tool_invoke_params_example(self) -> dict:
|
|
53
|
+
return {"url": "https://example.com/article"}
|