diff --git a/.github/workflows/llamaindex-example.yaml b/.github/workflows/llamaindex-example.yaml new file mode 100644 index 0000000..5eb979f --- /dev/null +++ b/.github/workflows/llamaindex-example.yaml @@ -0,0 +1,24 @@ +name: LlamaIndex Example + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + example: + runs-on: ubuntu-latest + defaults: + run: + working-directory: python/examples/llamaindex + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + - run: pip install -r requirements.txt pytest + - name: Verify published SDK and LlamaIndex workflow offline + run: | + python workflow.py + python -m pytest -q diff --git a/README.md b/README.md index 93800f7..d647739 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,9 @@ Use this application key for API requests. SDK account-management methods requir ## Languages +**LlamaIndex example:** [web search → extraction → cited retrieval](python/examples/llamaindex/README.md) +([中文](python/examples/llamaindex/README.zh-CN.md)), with a no-token offline demo and hosted MCP discovery. + | Language | Directory | Package | |----------|-----------|---------| | Python | [python](python/) | `acedatacloud` | diff --git a/python/README.md b/python/README.md index 844d0bf..3eeaf60 100644 --- a/python/README.md +++ b/python/README.md @@ -49,6 +49,10 @@ for chunk in client.openai.chat.completions.create( ## Async Usage +For a runnable LlamaIndex workflow, see [search, extraction and cited retrieval](examples/llamaindex/README.md) +([中文](examples/llamaindex/README.zh-CN.md)). It uses the published SDK, runs offline by default, +and includes a read-only hosted MCP discovery command. + ```python import asyncio from acedatacloud import AsyncAceDataCloud diff --git a/python/examples/llamaindex/README.md b/python/examples/llamaindex/README.md new file mode 100644 index 0000000..df80b29 --- /dev/null +++ b/python/examples/llamaindex/README.md @@ -0,0 +1,153 @@ +# LlamaIndex: web search → extraction → cited retrieval + +[中文](README.zh-CN.md) + +Use the published Ace Data Cloud Python SDK with LlamaIndex `FunctionTool`, +`Document`, and `SimpleKeywordTableIndex`. This example retrieves passages with +their source URLs. It does not generate an answer or require an LLM/embedding key. +It is an Ace-maintained example, not an official LlamaIndex listing or built-in provider. + +This is an auxiliary SDK search/extraction compatibility example, separate from +the six native LlamaIndex integrations released as GitHub **v0.1.0**: + +| Native integration | Versioned release | +|---|---| +| Ace Data Cloud LLM | [v0.1.0](https://github.com/AceDataCloud/AceDataCloudModelProviderLlamaIndex/releases/tag/v0.1.0) | +| Suno | [v0.1.0](https://github.com/AceDataCloud/SunoLlamaIndex/releases/tag/v0.1.0) | +| Seedance | [v0.1.0](https://github.com/AceDataCloud/SeedanceLlamaIndex/releases/tag/v0.1.0) | +| HappyHorse | [v0.1.0](https://github.com/AceDataCloud/HappyHorseLlamaIndex/releases/tag/v0.1.0) | +| GPT Image | [v0.1.0](https://github.com/AceDataCloud/GPTImageLlamaIndex/releases/tag/v0.1.0) | +| Nano Banana | [v0.1.0](https://github.com/AceDataCloud/NanoBananaLlamaIndex/releases/tag/v0.1.0) | + +Those are independently installable native packages with their own versioned wheel +and onboarding. This example does not replace them or imply official listing. +PyPI publication of those six new packages is still pending the shared project +creation quota; use their GitHub release instructions, not an assumed PyPI version. + +## Install and run without a token + +Python 3.10+ is required by LlamaIndex; Python 3.12 is used in CI. +From this directory: + +```bash +python -m venv .venv +source .venv/bin/activate +python -m pip install -r requirements.txt +python workflow.py +``` + +The default run uses **synthetic response fixtures** with the published SDK and +real LlamaIndex tools/index. API requests are intercepted, with unmatched requests +rejected. Output is marked `synthetic-offline`; the URLs identify example sources, +and the text is synthetic. No API token or paid request is used. + +The output contains retrieved `text`, `url`, `title`, `task_id`, and `trace_id`. +`task_id`/`trace_id` are synthetic in the default run. + +## Use your Ace account + +1. [Register or sign in](https://platform.acedata.cloud?utm_source=opensource&utm_medium=referral&utm_campaign=evergreen&utm_content=llamaindex_search_extraction_example_register) and open [Applications](https://platform.acedata.cloud/console/applications?utm_source=opensource&utm_medium=referral&utm_campaign=evergreen&utm_content=llamaindex_search_extraction_example_api_key). +2. Find **General application**. If absent, choose **Initialize General Application**. + Its key can use services your account is entitled to; a dedicated application's + key is service-limited. This workflow requires both Google Search and WebExtrator access. +3. Copy the application's **API Key**, or select **Manage Keys → Create** for a + dedicated `LlamaIndex search/extraction` key. + +![Real Ace console: copy the redacted API Key or open Manage Keys](https://raw.githubusercontent.com/AceDataCloud/SunoDify/8d3d03570e73324220f111f7fe343da0792a8071/_assets/tutorial/get-api-key-en.png) + +4. When creating a key, review **Expiration Time**, **Set Usage Limitation**, and + **Allowed APIs**. If restricting APIs, allow `/serp/google`, `/webextrator/extract` + and `/webextrator/tasks` for same-ID recovery. Create it, return to the key list, + and copy the raw token. Do not add `Bearer `, quotes, whitespace or the screenshot's + redacted characters; a `platform-...` management token cannot replace this key. + +![Real Ace console: create a separately named key with optional restrictions](https://raw.githubusercontent.com/AceDataCloud/SunoDify/8d3d03570e73324220f111f7fe343da0792a8071/_assets/tutorial/create-api-key-en.png) + +These are real, redacted Ace console screenshots from the linked fixed SunoDify +commit, reused and visually checked on **2026-10-19**; capture date/console version +is unknown. The historical `Dify tutorial` example name should be replaced by +`LlamaIndex search/extraction`. These show account setup, not this example running. +Never put a real token in a prompt, notebook output, screenshot or Git. + +5. Check your current permissions, quota, balance and prices before live calls: + [Google Search API](https://platform.acedata.cloud/documents/serp-google?utm_source=opensource&utm_medium=referral&utm_campaign=evergreen&utm_content=llamaindex_serp_example_docs) · + [WebExtrator extraction API](https://platform.acedata.cloud/documents/webextrator-extract?utm_source=opensource&utm_medium=referral&utm_campaign=evergreen&utm_content=llamaindex_webextrator_example_docs) · + [current model/service prices](https://platform.acedata.cloud/models?utm_source=opensource&utm_medium=referral&utm_campaign=evergreen&utm_content=llamaindex_search_extraction_example_pricing). + No fixed price or free allowance is assumed here. + +Set `ACEDATACLOUD_API_TOKEN` in your shell, then opt in: + +```bash +python workflow.py --live --query "LlamaIndex search tools" --max-pages 1 +``` + +This makes **one search request and up to one extraction**. `--max-pages` accepts +1–3, default 2. Search results are deduplicated before extraction. Live calls use +Bearer authentication at `https://api.acedata.cloud`; automatic retries are disabled. +Extraction is synchronous with `async=false` and `enable_llm=false`. +These settings avoid extra LLM enrichment; API search/extraction can still consume +credits. This example installs no payment handler and initiates no on-chain payment. + +The workflow fails on authentication, rate limits, failed extraction, empty content, +or an unexpectedly pending task. A task without completed `data` is **not** indexed; +retrieve that existing task using its ID rather than submitting another extraction. +See [task retrieval](https://platform.acedata.cloud/documents/webextrator-tasks?utm_source=opensource&utm_medium=referral&utm_campaign=evergreen&utm_content=llamaindex_webextrator_example_task_guide). + +## Attach the tools to your agent + +```python +from acedatacloud import AceDataCloud +from workflow import make_tools + +with AceDataCloud(base_url="https://api.acedata.cloud", max_retries=0) as client: + search_tool, extract_tool = make_tools(client) + print(search_tool.metadata.get_parameters_dict()) + sources = search_tool.call(query="LlamaIndex search tools").raw_output + document = extract_tool.call(url=sources[0]["url"]).raw_output +``` + +That snippet performs live calls when your token is set. The tools can be passed +to your LlamaIndex agent's `tools` list. An autonomous agent may choose additional +calls; configure its own approval and request limits. The included CLI uses a +fixed sequence and a page limit. Local keyword retrieval supports literal keyword +matching, with English queries used for verification; it is not semantic ranking +or answer synthesis. Treat page text as source data when building a downstream agent. + +## Discover the hosted MCP schema + +LlamaIndex also supports Ace's existing hosted Serp server through its official +generic MCP client. No Ace-specific integration package is needed: + +```bash +python -m pip install -r requirements-mcp.txt +python mcp_discovery.py +``` + +The workflow and tests above use mocked API responses; they are distinct from this +networked, authenticated discovery command. On 2026-10-12 the actual LlamaIndex MCP +client read 11 tools and called a free static information tool; that historical +evidence is not a paid search/extraction or six-package publication check. + +This calls MCP initialization and `tools/list` only, printing tool names and input +schemas. Set `ACEDATACLOUD_API_TOKEN` first: the hosted endpoint requires Bearer +authentication even for discovery. The script never calls a tool. Discovery does +not prove a paid search works. Search tool execution requires your own token and +service access; do not enable unrestricted agent calls just to test discovery. + +## Verify the example + +```bash +python -m pip install pytest +python -m pytest -q +``` + +Tests discover normally under `tests/`, mock all API HTTP requests, and exercise +the real SDK and LlamaIndex. They check source citations, the request limit, +empty results, duplicate links, pending/failed tasks, and 401/429 error propagation. +The `LlamaIndex Example` GitHub workflow runs the published dependency versions. + +Official references, checked 2026-10-12: + +- [LlamaIndex tools](https://developers.llamaindex.ai/python/framework/module_guides/deploying/agents/tools/) +- [LlamaIndex MCP client](https://github.com/run-llama/llama_index/tree/main/llama-index-integrations/tools/llama-index-tools-mcp) +- [Contribution policy](https://github.com/run-llama/llama_index/blob/main/CONTRIBUTING.md): new integration packages are maintained independently and published to PyPI, rather than added to the monorepo. diff --git a/python/examples/llamaindex/README.zh-CN.md b/python/examples/llamaindex/README.zh-CN.md new file mode 100644 index 0000000..e88f17a --- /dev/null +++ b/python/examples/llamaindex/README.zh-CN.md @@ -0,0 +1,120 @@ +# LlamaIndex:搜索 → 网页提取 → 带来源检索 + +[English](README.md) + +使用已发布的 Ace Data Cloud Python SDK,以及 LlamaIndex 的 `FunctionTool`、 +`Document` 和 `SimpleKeywordTableIndex`,得到带来源 URL 的检索片段。 +示例不生成回答,不需要 LLM 或 embedding 密钥。这是 Ace 维护的示例; +尚无 LlamaIndex 官方收录、默认内置或精选证明。 + +这是辅助性的 SDK 搜索/提取兼容示例,与已通过 GitHub 发布 **v0.1.0** 的六个原生 LlamaIndex 包分开: + +| 原生集成 | 版本化发行入口 | +|---|---| +| Ace Data Cloud LLM | [v0.1.0](https://github.com/AceDataCloud/AceDataCloudModelProviderLlamaIndex/releases/tag/v0.1.0) | +| Suno | [v0.1.0](https://github.com/AceDataCloud/SunoLlamaIndex/releases/tag/v0.1.0) | +| Seedance | [v0.1.0](https://github.com/AceDataCloud/SeedanceLlamaIndex/releases/tag/v0.1.0) | +| HappyHorse | [v0.1.0](https://github.com/AceDataCloud/HappyHorseLlamaIndex/releases/tag/v0.1.0) | +| GPT Image | [v0.1.0](https://github.com/AceDataCloud/GPTImageLlamaIndex/releases/tag/v0.1.0) | +| Nano Banana | [v0.1.0](https://github.com/AceDataCloud/NanoBananaLlamaIndex/releases/tag/v0.1.0) | + +六包各自有独立 wheel 和原生类/工具,本示例不代替它们,也不等于官方收录。 +六个新包的 PyPI 发布仍待共享创建项目配额,按 GitHub 正式发行说明安装,不猜造 PyPI 版本。 + +## 无 Token 安装与体验 + +需要 Python 3.10+,CI 使用 3.12。在本目录运行: + +```bash +python -m venv .venv +source .venv/bin/activate +python -m pip install -r requirements.txt +python workflow.py +``` + +默认模式使用**人工编写的模拟响应**,但 SDK、LlamaIndex 工具调用与索引都是真实组件。 +API 请求被拦截,未匹配请求会失败。输出标为 `synthetic-offline`, +正文和 task/trace ID 是模拟数据;来源 URL 仅用于演示引用。无需 Token,不发收费请求。 + +## 连接自己的账户 + +1. [注册或登录](https://platform.acedata.cloud?utm_source=opensource&utm_medium=referral&utm_campaign=evergreen&utm_content=llamaindex_search_extraction_example_register_zh),打开[应用控制台 Applications](https://platform.acedata.cloud/console/applications?utm_source=opensource&utm_medium=referral&utm_campaign=evergreen&utm_content=llamaindex_search_extraction_example_api_key_zh)。 +2. 找到 **General application**;没有时点 **Initialize General Application**。 + 通用应用 Key 仍受当前账户服务权限约束,专用应用仅限其服务;本流程同时需要 Google Search 与 WebExtrator。 +3. 复制应用的 **API Key**;或 **Manage Keys → Create** 新建 `LlamaIndex search/extraction` 专用 Key。 + +![真实 Ace 控制台:复制已遮挡 Key 或打开 Manage Keys](https://raw.githubusercontent.com/AceDataCloud/SunoDify/8d3d03570e73324220f111f7fe343da0792a8071/_assets/tutorial/get-api-key-en.png) + +4. 按需设置 **Expiration Time**、**Set Usage Limitation** 和 **Allowed APIs**。 + 若限制接口,需允许 `/serp/google`、`/webextrator/extract` 以及同 ID 恢复用的 `/webextrator/tasks`。 + 创建后回 Key 列表复制 Token 本体,不加 `Bearer `、引号、空格或截图遮挡字符。 + `platform-...` 管理令牌不能代替本流程的服务 Key;不要把真实 Key 放入 prompt、输出、截图或 Git。 + +![真实 Ace 控制台:创建独立 Key 和可选权限限制](https://raw.githubusercontent.com/AceDataCloud/SunoDify/8d3d03570e73324220f111f7fe343da0792a8071/_assets/tutorial/create-api-key-en.png) + +图片复用固定 SunoDify 提交的真实脱敏控制台截图,**2026-10-19 复用并目视核验**; +原拍摄日期与控制台版本未知。旧示例名 `Dify tutorial` 应改填 `LlamaIndex search/extraction`。 +这些图只展示账户步骤,不是本示例真实运行证据。 + +5. 开启真实调用前核当前权限、用量、余额与价格: + [Google Search API](https://platform.acedata.cloud/documents/serp-google?utm_source=opensource&utm_medium=referral&utm_campaign=evergreen&utm_content=llamaindex_serp_example_docs_zh) · + [WebExtrator 提取 API](https://platform.acedata.cloud/documents/webextrator-extract?utm_source=opensource&utm_medium=referral&utm_campaign=evergreen&utm_content=llamaindex_webextrator_example_docs_zh) · + [当前模型/服务价格](https://platform.acedata.cloud/models?utm_source=opensource&utm_medium=referral&utm_campaign=evergreen&utm_content=llamaindex_search_extraction_example_pricing_zh)。 + 本教程不假定固定单价或免费额度。 + +在 shell 中设置 `ACEDATACLOUD_API_TOKEN`,然后显式开启: + +```bash +python workflow.py --live --query "LlamaIndex search tools" --max-pages 1 +``` + +该命令发一次搜索及最多一次提取。`--max-pages` 允许 1–3,默认 2;提取前按 URL 去重。 +真实调用使用 `https://api.acedata.cloud` 的 Bearer 鉴权,禁用自动重试; +提取明确设置 `async=false`、`enable_llm=false`。不开 LLM 增强仍可能消耗搜索和提取 Credits。 +示例不安装付款 handler,不发链上付款。 + +鉴权、限流、提取失败、空正文或意外 pending 均会报错。 +只有完成的 `data` 会进入索引;若已得到 task ID 却没有正文, +按[任务查询指南](https://platform.acedata.cloud/documents/webextrator-tasks?utm_source=opensource&utm_medium=referral&utm_campaign=evergreen&utm_content=llamaindex_webextrator_example_task_guide_zh) +查询同一 ID,避免重新提交提取。结果保留 `text`、`url`、`title`、`task_id` 和 `trace_id`。 + +## 放入自己的 Agent + +`make_tools(client)` 返回 `ace_web_search` 与 `ace_web_extract` 两个 LlamaIndex 工具, +可加入 Agent 的 `tools`。参见英文 README 的完整代码。 +自主 Agent 可能追加调用,需单独设置审批和调用限制;示例 CLI 是有页数上限的固定流程。 +本地检索按字面关键词匹配,验证查询为英文,不提供语义排序或回答合成。 +在下游 Agent 中把网页正文作为来源数据处理。 + +## 只读 MCP 工具发现 + +使用 LlamaIndex 官方通用 MCP 客户端连接 Ace 现有托管 Serp MCP: + +```bash +python -m pip install -r requirements-mcp.txt +python mcp_discovery.py +``` + +上面的工作流与测试使用模拟 API,与这里的联网工具发现分开。2026-10-12 曾通过真实 +LlamaIndex MCP 客户端读回 11 个工具并调用免费静态信息工具;这是历史只读证据, +不等于收费搜索/提取或六个原生包发布验收。 + +先设置 `ACEDATACLOUD_API_TOKEN`:托管端点在工具发现阶段也需要 Bearer 鉴权。 +只初始化并执行 `tools/list`,打印工具名与输入 schema。 +不执行搜索工具。工具发现通过不能替代真实付费首调;执行搜索仍需自己的权限与额度。 + +## 离线验证 + +```bash +python -m pip install pytest +python -m pytest -q +``` + +测试自动发现 `tests/`,模拟所有 API 请求,验证引用、请求上限、空搜索、URL 去重、 +pending/失败提取以及 401/429 错误含义。专属 CI 使用公开发布的依赖版本。 + +官方依据(2026-10-12 复核): + +- [LlamaIndex 工具](https://developers.llamaindex.ai/python/framework/module_guides/deploying/agents/tools/) +- [官方 MCP 客户端](https://github.com/run-llama/llama_index/tree/main/llama-index-integrations/tools/llama-index-tools-mcp) +- [贡献政策](https://github.com/run-llama/llama_index/blob/main/CONTRIBUTING.md):新增 integration package 在独立仓维护并发布 PyPI,不进入 monorepo。 diff --git a/python/examples/llamaindex/mcp_discovery.py b/python/examples/llamaindex/mcp_discovery.py new file mode 100644 index 0000000..63ae314 --- /dev/null +++ b/python/examples/llamaindex/mcp_discovery.py @@ -0,0 +1,24 @@ +"""List hosted Serp tools with the official LlamaIndex MCP client; never call a tool.""" + +import asyncio +import json +import os + +from llama_index.tools.mcp import BasicMCPClient + + +async def main() -> None: + token = os.environ.get("ACEDATACLOUD_API_TOKEN") + if not token: + raise SystemExit("Set ACEDATACLOUD_API_TOKEN: hosted MCP discovery requires authentication.") + headers = {"Authorization": f"Bearer {token}"} + client = BasicMCPClient("https://serp.mcp.acedata.cloud/mcp", headers=headers, timeout=30) + try: + tools = await client.list_tools() + print(json.dumps([tool.model_dump(by_alias=True) for tool in tools.tools], indent=2)) + finally: + await client.http_client.aclose() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/examples/llamaindex/pytest.ini b/python/examples/llamaindex/pytest.ini new file mode 100644 index 0000000..5ee6477 --- /dev/null +++ b/python/examples/llamaindex/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +testpaths = tests diff --git a/python/examples/llamaindex/requirements-mcp.txt b/python/examples/llamaindex/requirements-mcp.txt new file mode 100644 index 0000000..262d21a --- /dev/null +++ b/python/examples/llamaindex/requirements-mcp.txt @@ -0,0 +1,3 @@ +-r requirements.txt +llama-index-tools-mcp==0.6.0 +mcp==2.3.0 diff --git a/python/examples/llamaindex/requirements.txt b/python/examples/llamaindex/requirements.txt new file mode 100644 index 0000000..c2d78c4 --- /dev/null +++ b/python/examples/llamaindex/requirements.txt @@ -0,0 +1,3 @@ +acedatacloud==2026.10.12.0 +llama-index-core==0.14.25 +respx==0.23.1 diff --git a/python/examples/llamaindex/tests/test_workflow.py b/python/examples/llamaindex/tests/test_workflow.py new file mode 100644 index 0000000..9951abc --- /dev/null +++ b/python/examples/llamaindex/tests/test_workflow.py @@ -0,0 +1,83 @@ +import json + +import httpx +import pytest +import respx +from workflow import DEMO_BASE, install_demo_routes, make_tools, run + +from acedatacloud import AceDataCloud, AuthenticationError, RateLimitError + + +def test_search_extract_retrieve_keeps_citations_and_request_bound(): + with respx.mock(assert_all_mocked=True, assert_all_called=True) as router: + install_demo_routes(router) + with AceDataCloud(api_token="offline-demo", base_url=DEMO_BASE, max_retries=0) as client: + passages = run(client, "LlamaIndex search tools", max_pages=1) + assert len(router.calls) == 2 + search_body = json.loads(router.calls[0].request.content) + assert search_body == {"query": "LlamaIndex search tools", "type": "search", "number": 10, "page": 1} + assert json.loads(router.calls[1].request.content)["async"] is False + assert json.loads(router.calls[1].request.content)["enable_llm"] is False + assert passages + assert passages[0]["url"].startswith("https://developers.llamaindex.ai/") + assert passages[0]["task_id"] == "synthetic-demo-task" + assert "source URLs" in passages[0]["text"] + + +def test_empty_search_makes_no_extraction_request(): + with respx.mock(assert_all_mocked=True) as router: + router.post(f"{DEMO_BASE}/serp/google").respond(200, json={"organic": []}) + with AceDataCloud(api_token="offline-demo", base_url=DEMO_BASE, max_retries=0) as client: + assert run(client, "nothing") == [] + assert len(router.calls) == 1 + + +def test_search_deduplicates_links_and_ignores_non_web_sources(): + with respx.mock(assert_all_mocked=True) as router: + router.post(f"{DEMO_BASE}/serp/google").respond( + 200, + json={ + "data": { + "organic": [ + {"link": "https://example.com/page#one"}, + {"link": "https://example.com/page#two"}, + {"link": "mailto:person@example.com"}, + ] + } + }, + ) + with AceDataCloud(api_token="offline-demo", base_url=DEMO_BASE, max_retries=0) as client: + search, _ = make_tools(client) + assert search.call(query="example").raw_output == [ + {"url": "https://example.com/page", "title": "https://example.com/page"} + ] + + +@pytest.mark.parametrize( + "response", + [ + {"success": True, "task_id": "existing-task"}, + {"success": True, "data": {"text": ""}}, + {"success": False, "data": {"text": "failed"}}, + ], +) +def test_pending_empty_or_failed_extraction_is_not_a_document(response): + with respx.mock(assert_all_mocked=True) as router: + router.post(f"{DEMO_BASE}/webextrator/extract").respond(200, json=response) + with AceDataCloud(api_token="offline-demo", base_url=DEMO_BASE, max_retries=0) as client: + _, extract = make_tools(client) + with pytest.raises(ValueError): + extract.call(url="https://example.com/page") + assert len(router.calls) == 1 + + +@pytest.mark.parametrize("status,error_type", [(401, AuthenticationError), (429, RateLimitError)]) +def test_api_errors_keep_their_meaning_and_do_not_retry(status, error_type): + with respx.mock(assert_all_mocked=True) as router: + router.post(f"{DEMO_BASE}/serp/google").mock( + return_value=httpx.Response(status, json={"error": {"message": "request rejected"}}) + ) + with AceDataCloud(api_token="offline-demo", base_url=DEMO_BASE, max_retries=0) as client: + with pytest.raises(error_type): + run(client, "example") + assert len(router.calls) == 1 diff --git a/python/examples/llamaindex/workflow.py b/python/examples/llamaindex/workflow.py new file mode 100644 index 0000000..d525e69 --- /dev/null +++ b/python/examples/llamaindex/workflow.py @@ -0,0 +1,164 @@ +"""Search, extract and retrieve cited passages with LlamaIndex; offline by default.""" + +from __future__ import annotations + +import argparse +import json +import os +from typing import Any +from urllib.parse import urldefrag, urlparse + +import httpx +import respx +from llama_index.core import Document, SimpleKeywordTableIndex +from llama_index.core.llms import MockLLM +from llama_index.core.node_parser import SentenceSplitter +from llama_index.core.tools import FunctionTool + +from acedatacloud import AceDataCloud + +API_BASE = "https://api.acedata.cloud" +DEMO_BASE = "https://offline.example.invalid" +DEMO_PAGES = { + "https://developers.llamaindex.ai/python/framework/community/integrations/": { + "title": "LlamaIndex integrations (synthetic demo)", + "text": "LlamaIndex tools connect search and extraction APIs to a retrieval workflow. " + "Documents preserve source URLs for citations.", + }, + "https://developers.llamaindex.ai/python/framework/module_guides/mcp/": { + "title": "LlamaIndex MCP (synthetic demo)", + "text": "MCP discovery lists tool schemas without executing a search. " + "A search tool call can use an authenticated API and consume account credits.", + }, +} + + +def source_url(value: Any) -> str: + if not isinstance(value, str): + raise ValueError("Expected an HTTP(S) source URL") + url = urldefrag(value)[0] + parsed = urlparse(url) + if parsed.scheme not in ("http", "https") or not parsed.hostname or parsed.username or parsed.password: + raise ValueError("Expected an HTTP(S) source URL without credentials") + return url + + +def make_tools(client: AceDataCloud) -> tuple[FunctionTool, FunctionTool]: + def search(query: str) -> list[dict[str, str]]: + """Search the web once; returns source URLs and titles. Consumes API credits in live mode.""" + if not query.strip(): + raise ValueError("Search query must not be empty") + response = client.search.google(query=query, type="search", number=10, page=1) + if response.get("success") is False: + raise ValueError("Search returned an unsuccessful response") + data = response.get("data", response) + if not isinstance(data, dict) or not isinstance(data.get("organic"), list): + raise ValueError("Search response must contain an organic result list") + sources: list[dict[str, str]] = [] + seen: set[str] = set() + for item in data["organic"]: + if not isinstance(item, dict): + continue + try: + url = source_url(item.get("link")) + except ValueError: + continue + if url not in seen: + sources.append({"url": url, "title": str(item.get("title", url))}) + seen.add(url) + return sources + + def extract(url: str) -> Document: + """Extract one page synchronously without LLM enrichment. Consumes API credits in live mode.""" + url = source_url(url) + response = client.webextrator.extract(url=url, async_=False, enable_llm=False, timeout=30) + if response.get("success") is False: + raise ValueError("Extraction returned an unsuccessful response") + data = response.get("data") + if not isinstance(data, dict): + task_id = response.get("task_id", "unknown") + raise ValueError(f"No completed extraction body; retrieve existing task {task_id}. Do not resubmit.") + text = data.get("markdown") or data.get("text") + if not isinstance(text, str) or not text.strip(): + raise ValueError("Extraction returned no page text") + metadata = { + "url": source_url(data.get("finalUrl") or data.get("url") or url), + "title": str(data.get("title") or url), + "task_id": str(response.get("task_id", "")), + "trace_id": str(response.get("trace_id", "")), + } + return Document( + text=text, + metadata=metadata, + excluded_llm_metadata_keys=list(metadata), + excluded_embed_metadata_keys=list(metadata), + ) + + return ( + FunctionTool.from_defaults(fn=search, name="ace_web_search"), + FunctionTool.from_defaults(fn=extract, name="ace_web_extract"), + ) + + +def run(client: AceDataCloud, query: str, max_pages: int = 2) -> list[dict[str, str]]: + if not 1 <= max_pages <= 3: + raise ValueError("max_pages must be between 1 and 3") + search, extract = make_tools(client) + sources = search.call(query=query).raw_output[:max_pages] + if not sources: + return [] + documents = [extract.call(url=source["url"]).raw_output for source in sources] + # Regex keyword retrieval needs neither a remote LLM nor embeddings. + index = SimpleKeywordTableIndex.from_documents( + documents, + llm=MockLLM(), + transformations=[SentenceSplitter(chunk_size=256, chunk_overlap=32, tokenizer=str.split)], + ) + nodes = index.as_retriever(retriever_mode="simple", llm=MockLLM(), num_chunks_per_query=3).retrieve(query) + return [{**node.node.metadata, "text": node.node.text} for node in nodes] + + +def install_demo_routes(router: respx.MockRouter) -> None: + router.post(f"{DEMO_BASE}/serp/google").respond( + 200, json={"organic": [{"link": url, "title": page["title"]} for url, page in DEMO_PAGES.items()]} + ) + + def extract_response(request: httpx.Request) -> httpx.Response: + url = json.loads(request.content)["url"] + return httpx.Response( + 200, + json={ + "success": True, + "task_id": "synthetic-demo-task", + "trace_id": "synthetic-demo-trace", + "data": {"url": url, **DEMO_PAGES[url]}, + }, + ) + + router.post(f"{DEMO_BASE}/webextrator/extract").mock(side_effect=extract_response) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--query", default="LlamaIndex search tools") + parser.add_argument("--max-pages", type=int, choices=(1, 2, 3), default=2) + parser.add_argument( + "--live", action="store_true", help="Use your token: 1 search + up to max-pages paid extractions" + ) + args = parser.parse_args() + if args.live: + token = os.environ.get("ACEDATACLOUD_API_TOKEN") + if not token: + parser.error("Set ACEDATACLOUD_API_TOKEN before --live") + with AceDataCloud(api_token=token, base_url=API_BASE, max_retries=0) as client: + passages = run(client, args.query, args.max_pages) + else: + with respx.mock(assert_all_mocked=True, assert_all_called=False) as router: + install_demo_routes(router) + with AceDataCloud(api_token="offline-demo", base_url=DEMO_BASE, max_retries=0) as client: + passages = run(client, args.query, args.max_pages) + print(json.dumps({"mode": "live" if args.live else "synthetic-offline", "passages": passages}, indent=2)) + + +if __name__ == "__main__": + main()