diff --git a/README.md b/README.md index c12b3ee7..254bb020 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Open-source toolkit for **Pipefy** developers: a Model Context Protocol (MCP) se | Component | Package / path | Purpose | |-----------|----------------|---------| -| **MCP server** | `pipefy-mcp-server` | Exposes **172** tools to MCP clients (Cursor, Claude Desktop, Claude Code, and others). | +| **MCP server** | `pipefy-mcp-server` | Exposes **176** tools to MCP clients (Cursor, Claude Desktop, Claude Code, and others). | | **CLI** | `pipefy-cli` | Terminal commands aligned with MCP capabilities; see [`docs/parity.md`](docs/parity.md). | | **SDK** | `pipefy` | Vendor GraphQL client, services, and models shared by MCP and CLI. | | **Skills** | [`skills/`](skills/) | Markdown playbooks (Anthropic Skills format) for common Pipefy workflows. | @@ -141,7 +141,7 @@ Deprecation and semver (post-1.0): [`docs/DEPRECATION.md`](docs/DEPRECATION.md). ## MCP server -The server registers **172 tools** across thirteen domains. Canonical names: `PIPEFY_TOOL_NAMES` in [`packages/mcp/src/pipefy_mcp/tools/registry.py`](packages/mcp/src/pipefy_mcp/tools/registry.py). +The server registers **176 tools** across thirteen domains. Canonical names: `PIPEFY_TOOL_NAMES` in [`packages/mcp/src/pipefy_mcp/tools/registry.py`](packages/mcp/src/pipefy_mcp/tools/registry.py). Tool descriptions and `Args:` blocks come from Python docstrings (what MCP clients show to models). Per-area reference docs cover parameters, edge cases, and cross-cutting behavior. @@ -155,7 +155,7 @@ Tool descriptions and `Args:` blocks come from Python docstrings (what MCP clien | **Reports** | 17 | Pipe and organization reports, async exports. | [docs](docs/mcp/tools/reports.md) | | **Automations & AI** | 23 | Automations, AI automations, AI agents, validators. | [docs](docs/mcp/tools/automations-and-ai.md) | | **LLM providers** | 5 | Discovery reads for the organization's LLM providers (custom + Pipefy-managed), vendor model lists, owner defaults, dependencies, and a read-access probe. | [docs](docs/mcp/tools/llm-providers.md) | -| **Knowledge bases** | 10 | Pipe-scoped AI knowledge bases: list all items, plain text and document CRUD (one-shot PDF upload), and a read-access probe. Attach sources to agents/behaviors via `dataSourceIds`. | [docs](docs/mcp/tools/knowledge-bases.md) | +| **Knowledge bases** | 14 | Pipe-scoped AI knowledge bases: list all items, plain text / document (one-shot PDF upload) / data lookup CRUD, and a read-access probe. Attach sources to agents/behaviors via `dataSourceIds`. | [docs](docs/mcp/tools/knowledge-bases.md) | | **iPaaS** | 4 | Lazy discovery, invocation, and app-connection setup for a pipe's iPaaS (Advanced Automations) workspace (`get_ipaas_tools`, `call_ipaas_tool`, plus the connection meta-tools). | [docs](docs/mcp/tools/ipaas.md) | | **Observability** | 11 | Logs, usage, credits, execution metrics, job exports. | [docs](docs/mcp/tools/observability.md) | | **Members, email & webhooks** | 11 | Membership, inbox email, webhooks. | [docs](docs/mcp/tools/members-email-webhooks.md) | diff --git a/docs/mcp/README.md b/docs/mcp/README.md index 99c01c7b..1e83ae63 100644 --- a/docs/mcp/README.md +++ b/docs/mcp/README.md @@ -13,7 +13,7 @@ Material in this tree describes **`pipefy-mcp-server`**: the MCP process, tool b | [`tools/reports.md`](tools/reports.md) | Pipe and organization reports, async exports | | [`tools/automations-and-ai.md`](tools/automations-and-ai.md) | Traditional automations, AI automations, AI agents, validators | | [`tools/llm-providers.md`](tools/llm-providers.md) | LLM provider discovery: custom + system providers, vendor models, defaults, dependencies, access probe | -| [`tools/knowledge-bases.md`](tools/knowledge-bases.md) | Pipe-scoped AI knowledge bases: list, plain text and document (PDF) CRUD, read-access probe | +| [`tools/knowledge-bases.md`](tools/knowledge-bases.md) | Pipe-scoped AI knowledge bases: list, plain text, document (PDF), and data lookup CRUD, read-access probe | | [`tools/observability.md`](tools/observability.md) | Logs, usage, credits, execution metrics, job exports | | [`tools/members-email-webhooks.md`](tools/members-email-webhooks.md) | Membership, inbox email, webhooks | | [`tools/organization.md`](tools/organization.md) | Organization metadata | diff --git a/docs/mcp/tools/knowledge-bases.md b/docs/mcp/tools/knowledge-bases.md index 4f6c4470..88454d30 100644 --- a/docs/mcp/tools/knowledge-bases.md +++ b/docs/mcp/tools/knowledge-bases.md @@ -1,6 +1,6 @@ # Knowledge bases -Pipe-scoped AI knowledge bases: list every item on a pipe, full CRUD for plain-text and document (PDF) sources, and a read-access probe. **10 tools.** +Pipe-scoped AI knowledge bases: list every item on a pipe, full CRUD for plain-text, document (PDF), and data lookup sources, and a read-access probe. **14 tools.** Knowledge bases are the data sources an AI agent draws on. Each item's `id` is what you attach to an agent or behavior via `dataSourceIds` (see [Automations & AI](automations-and-ai.md)): use `get_ai_knowledge_bases` to discover the IDs, then `validate_ai_agent_behaviors(data_source_ids=[...])` to check membership before writing the agent. @@ -8,7 +8,7 @@ Knowledge bases are the data sources an AI agent draws on. Each item's `id` is w | Tool | Read-only | Role | |------|-----------|------| -| `get_ai_knowledge_bases` | Yes | Lists every knowledge base item on a pipe (plain texts, documents, data lookups) as one flat list — no pagination. Each item carries `id`, `type` (e.g. `knowledge_base_plain_texts`), `name`, `description`, `updatedAt`. | +| `get_ai_knowledge_bases` | Yes | Lists every knowledge base item on a pipe (plain texts, documents, data lookups) as one flat list — no pagination. Each item carries `id`, `type` (`knowledge_base_plain_texts`, `knowledge_base_documents`, or `data_lookups` — the data lookup discriminator is not prefixed), `name`, `description`, `updatedAt`. | | `get_ai_knowledge_base_plain_text` | Yes | Fetches one plain text by `id`, including its full `content`. | | `create_ai_knowledge_base_plain_text` | No | Creates a plain text. `name`, `content` (1-3500 chars), and `description` (1-900 chars) are all required. | | `update_ai_knowledge_base_plain_text` | No | Partial update by `plain_text_id`: pass any of `name` / `content` / `description` (at least one); omitted fields keep their stored value. | @@ -17,11 +17,15 @@ Knowledge bases are the data sources an AI agent draws on. Each item's `id` is w | `create_ai_knowledge_base_document` | No | Creates a document from a local PDF in one shot (presigned URL, S3 PUT, create mutation). `name`, `file_path`, and `description` (1-900 chars) are required. | | `update_ai_knowledge_base_document` | No | Metadata-only partial update by `document_id`: pass `name` and/or `description` (at least one). The PDF file cannot be replaced. | | `delete_ai_knowledge_base_document` | No | Deletes a document permanently. Two-step: preview with `confirm=false` (default), execute with `confirm=true`. | +| `get_ai_knowledge_base_data_lookup` | Yes | Fetches one data lookup by `id`. The payload never includes `conditions` (see below). | +| `create_ai_knowledge_base_data_lookup` | No | Creates a data lookup: a data source that searches cards in a source pipe by conditions and returns selected fields. `name`, `description` (1-900 chars), `source_repo_id`, `output_fields`, and `conditions` are required. | +| `update_ai_knowledge_base_data_lookup` | No | Replaces a data lookup's full definition: `source_repo_id`, `output_fields`, and `conditions` are required on every call; omitting `search_query` clears it. Only `name`/`description` are partial. | +| `delete_ai_knowledge_base_data_lookup` | No | Deletes a data lookup permanently. Two-step: preview with `confirm=false` (default), execute with `confirm=true`. | | `validate_knowledge_base_access` | Yes | Probes whether the current credential can read a pipe's knowledge bases, classifying failures into structured problems instead of opaque errors. | ## Identifiers: pipe UUID, not numeric ID -Every knowledge base operation is scoped by the pipe **UUID** (`pipe_uuid`), not the numeric pipe ID — this follows the Pipefy GraphQL API. `get_pipe` returns the `uuid` field; `get_ai_knowledge_bases` returns each item's `id` (a data-source UUID) for the plain-text-by-id operations and for `dataSourceIds`. +Every knowledge base operation is scoped by the pipe **UUID** (`pipe_uuid`), not the numeric pipe ID — this follows the Pipefy GraphQL API. `get_pipe` returns the `uuid` field; `get_ai_knowledge_bases` returns each item's `id` (a data-source UUID) for the by-id operations and for `dataSourceIds`. The one deliberate exception is a data lookup's `source_repo_id`, which is the **numeric** ID of the source pipe (see below). ## Plain-text limits (enforced client-side) @@ -49,11 +53,30 @@ Failures are tagged with the step that failed — `file_read`, `presigned_url`, **Update is metadata-only.** `update_ai_knowledge_base_document` changes `name` / `description`; it cannot replace the PDF. `content` returned by `get_ai_knowledge_base_document` is the stored document URL, not the extracted text. +## Data lookups (typed conditions, full-replacement updates) + +A data lookup lets an agent search cards in a **source pipe** by conditions and return the values of selected fields. Its definition has four parts: `source_repo_id` (the source pipe), `output_fields` (which field values matching records return), `conditions` (how records are filtered), and an optional `search_query`. + +The toolkit validates the definition client-side because the API accepts shapes that only fail later, when an agent actually runs the lookup: + +- **`source_repo_id` must be the numeric pipe ID**, not a UUID. The API accepts a UUID at create time, but the lookup then breaks when an agent runs it — long after the write succeeded. +- **`output_fields`**: 1-30 field IDs — start-form/phase field slugs (e.g. `customer_email`) plus static fields such as `id`, `title`, `current_phase`, `assignees`, `created_by`, `created_at`. +- **`conditions`**: at least one object, each with `field` (a field ID as above) and `operator` (an opaque backend string, e.g. `"eq"`, `"contains"`). A condition is one of two shapes: + - **Static** — `value` is required and must be a string (the backend silently drops non-scalar values and the runtime rejects non-strings): `{"field": "title", "operator": "contains", "value": "urgent"}`. + - **AI-filled** — `usingFillWithAi: true` with `inputName`, `inputType` (opaque, e.g. `"text"`, `"number"`), and `inputDescription`; `value` must be omitted (the AI asks the user for it at runtime): `{"field": "customer_email", "operator": "eq", "usingFillWithAi": true, "inputName": "Customer email", "inputType": "text", "inputDescription": "The customer's email address"}`. + + Condition keys accept camelCase (as above) or snake_case; unknown keys are rejected so a typo cannot silently drop a constraint. A condition may also carry the optional passthrough keys `attribute` and `fieldUuid`, used by backend-specific search modes (`field` is still required — the backend rejects a condition without one). +- **`search_query`** is a backend-defined search mode marker, not free text; leave it unset unless you know the backend value you need. + +**Reads never return `conditions`.** The API stores them but does not expose them on `get_ai_knowledge_base_data_lookup` (or on the create/update responses), so keep your lookup definition — including conditions — as the client-side source of truth. + +**Updates replace the whole definition.** The backend rewrites the stored definition with exactly what the update carries: `source_repo_id`, `output_fields`, and `conditions` are required on every `update_ai_knowledge_base_data_lookup` call (pass the complete condition set, not a delta), and omitting `search_query` clears it. Only `name`/`description` behave partially (omitted keeps the stored value). + ## Probe semantics and the write gate -- A green `validate_knowledge_base_access` proves **read access only** (`read_ai_agents` on the pipe) — never the `manage_ai_agents` entitlement that plain-text and document create / update / delete require. +- A green `validate_knowledge_base_access` proves **read access only** (`read_ai_agents` on the pipe) — never the `manage_ai_agents` entitlement that every knowledge base create / update / delete (plain text, document, data lookup) requires. - An **empty knowledge base list** is a valid green result (`knowledge_base_count: 0`), not a failure. -- The **CLI gates writes** on the probe: `pipefy kb plain-text create` / `update` and `pipefy kb document create` / `update` run the read-access probe first and fail with the classified problem if it is denied, before attempting the mutation. +- The **CLI gates writes** on the probe: `pipefy kb plain-text create` / `update`, `pipefy kb document create` / `update`, and `pipefy kb data-lookup create` / `update` run the read-access probe first and fail with the classified problem if it is denied, before attempting the mutation. - The **MCP tools stay explicit-validate-first**: create / update do not auto-probe. Call `validate_knowledge_base_access` yourself before writing. - **Deletes require confirmation**: the MCP tool needs `confirm=true`; the CLI needs `--yes` (or an interactive prompt). diff --git a/docs/parity.md b/docs/parity.md index 52a8d302..175be65d 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -2,7 +2,7 @@ This matrix is the source of truth for **MCP tool ↔ `pipefy` CLI** coverage. Update it whenever MCP tools or CLI commands are added, renamed, or removed. -**Registry source:** `PIPEFY_TOOL_NAMES` in `packages/mcp/src/pipefy_mcp/tools/registry.py` (must stay in sync with this table: **172** tools). +**Registry source:** `PIPEFY_TOOL_NAMES` in `packages/mcp/src/pipefy_mcp/tools/registry.py` (must stay in sync with this table: **176** tools). **Later CLI coverage:** areas such as attachments, field conditions, email, audit export, traditional automations, exports/usage, introspection, and raw GraphQL appear as **shipped** below when the matching Typer commands exist in `packages/cli`. @@ -30,6 +30,7 @@ For **database records**, `find_records` result nodes may use **`fields`** while | `clone_pipe` | `pipefy pipe clone` | shipped | optional `--org`. | | `create_ai_agent` | `pipefy agent create` | shipped | AI Agents domain; post-v0.1 CLI unless explicitly rescoped. | | `create_ai_automation` | `pipefy ai-automation create` | shipped | AI Automations domain; post-v0.1 CLI unless explicitly rescoped. | +| `create_ai_knowledge_base_data_lookup` | `pipefy kb data-lookup create` | shipped | Knowledge bases; pipe-scoped (`--pipe-uuid`, `--name`, `--description` <=900, `--source-repo-id` numeric pipe ID, `--output-fields` JSON array 1-30, `--conditions` JSON array, optional `--search-query`). Conditions are typed client-side (static needs a string value; AI-filled needs the input trio). CLI gates on the read-access probe. | | `create_ai_knowledge_base_document` | `pipefy kb document create` | shipped | Knowledge bases; one-shot PDF upload (presigned URL, S3 PUT, create mutation). Pipe-scoped (`--pipe-uuid`, `--file`, `--name`, `--description` <=900, all required). `.pdf` and 20 MiB cap enforced client-side; step-tagged errors (`file_read`/`presigned_url`/`s3_upload`/`kb_create`). CLI gates on the read-access probe. Indexing is asynchronous. | | `create_ai_knowledge_base_plain_text` | `pipefy kb plain-text create` | shipped | Knowledge bases; pipe-scoped (`--pipe-uuid`, `--name`, `--content` <=3500, `--description` <=900, all required). CLI gates on the read-access probe; limits fail fast client-side. | | `create_automation` | `pipefy automation create` | shipped | (`--pipe`, `--name`, `--trigger-id`, `--action-id`, optional `--extra` JSON). | @@ -55,6 +56,7 @@ For **database records**, `find_records` result nodes may use **`fields`** while | `create_webhook` | `pipefy webhook create` | shipped | — | | `delete_ai_agent` | `pipefy agent delete` | shipped | AI Agents domain. | | `delete_ai_automation` | `pipefy ai-automation delete` | shipped | AI Automations domain. | +| `delete_ai_knowledge_base_data_lookup` | `pipefy kb data-lookup delete` | shipped | Knowledge bases; pipe-scoped (`--id`, `--pipe-uuid`). MCP requires `confirm=true`; CLI requires `--yes` (or interactive prompt). | | `delete_ai_knowledge_base_document` | `pipefy kb document delete` | shipped | Knowledge bases; pipe-scoped (`--id`, `--pipe-uuid`). MCP requires `confirm=true`; CLI requires `--yes` (or interactive prompt). | | `delete_ai_knowledge_base_plain_text` | `pipefy kb plain-text delete` | shipped | Knowledge bases; pipe-scoped (`--id`, `--pipe-uuid`). MCP requires `confirm=true`; CLI requires `--yes` (or interactive prompt). | | `delete_automation` | `pipefy automation delete` | shipped | destructive: `--yes` or confirm. | @@ -95,6 +97,7 @@ For **database records**, `find_records` result nodes may use **`fields`** while | `get_ai_automation` | `pipefy ai-automation get` | shipped | AI Automations domain. | | `get_ai_automations` | `pipefy ai-automation list` | shipped | AI Automations domain. | | `get_ai_credit_usage` | `pipefy usage credits` | shipped | (`--organization`, `--period`). | +| `get_ai_knowledge_base_data_lookup` | `pipefy kb data-lookup get` | shipped | Knowledge bases; pipe-scoped data lookup (`--id`, `--pipe-uuid`); the payload never includes `conditions` (the API does not expose them on reads). | | `get_ai_knowledge_base_document` | `pipefy kb document get` | shipped | Knowledge bases; pipe-scoped document metadata (`--id`, `--pipe-uuid`); `content` is the stored document URL, not the extracted text. | | `get_ai_knowledge_base_plain_text` | `pipefy kb plain-text get` | shipped | Knowledge bases; pipe-scoped plain text with content (`--id`, `--pipe-uuid`). | | `get_ai_knowledge_bases` | `pipefy kb list` | shipped | Knowledge bases; all items on a pipe (plain text, documents, data lookups) with `id` for `dataSourceIds` (`--pipe-uuid`; no pagination). | @@ -168,6 +171,7 @@ For **database records**, `find_records` result nodes may use **`fields`** while | `unpublish_sub_portal` | `pipefy portal sub-portal unpublish` | shipped | internal_api `updateSubPortalElement(subPortalUuid: null)`; sets `subPortals[].published` to false. | | `update_ai_agent` | `pipefy agent update` | shipped | AI Agents domain. | | `update_ai_automation` | `pipefy ai-automation update` | shipped | AI Automations domain. | +| `update_ai_knowledge_base_data_lookup` | `pipefy kb data-lookup update` | shipped | Knowledge bases; full-replacement update (`--id`, `--pipe-uuid`, required `--source-repo-id`/`--output-fields`/`--conditions` every call; omitted `--search-query` clears it; only `--name`/`--description` are partial). CLI gates on the read-access probe. | | `update_ai_knowledge_base_document` | `pipefy kb document update` | shipped | Knowledge bases; metadata-only partial update (`--id`, `--pipe-uuid`, optional `--name`/`--description`, at least one); no file replacement. CLI gates on the read-access probe. | | `update_ai_knowledge_base_plain_text` | `pipefy kb plain-text update` | shipped | Knowledge bases; partial update (`--id`, `--pipe-uuid`, optional `--name`/`--content`/`--description`, at least one). CLI gates on the read-access probe; limits fail fast client-side. | | `update_automation` | `pipefy automation update` | shipped | (`--extra` JSON). | @@ -209,6 +213,6 @@ for n in m.body: print(len(v.args[0].elts))" ``` -Expect **172** tool names in `PIPEFY_TOOL_NAMES` and **172** data rows in the parity table (excluding the header rows). +Expect **176** tool names in `PIPEFY_TOOL_NAMES` and **176** data rows in the parity table (excluding the header rows). When adding or removing an MCP tool, update **this file** and `PIPEFY_TOOL_NAMES` in the same change set. diff --git a/packages/cli/src/pipefy_cli/commands/knowledge_base.py b/packages/cli/src/pipefy_cli/commands/knowledge_base.py index 2c6f3303..0e2bb0b9 100644 --- a/packages/cli/src/pipefy_cli/commands/knowledge_base.py +++ b/packages/cli/src/pipefy_cli/commands/knowledge_base.py @@ -1,19 +1,24 @@ -"""AI knowledge bases (pipe-scoped): list, plain text/document CRUD, and access probe.""" +"""AI knowledge bases (pipe-scoped): list, plain text/document/data lookup CRUD, and access probe.""" from __future__ import annotations from pathlib import Path +from typing import Any import typer from pipefy_sdk import KnowledgeBaseDocumentUploadError, PipefyClient from pipefy_cli.commands._common import ( confirm_destructive, + parse_json_value, run_cli_command, ) kb_app = typer.Typer( - help="AI knowledge bases (pipe-scoped: list, plain text/document CRUD, access probe).", + help=( + "AI knowledge bases (pipe-scoped: list, plain text/document/data " + "lookup CRUD, access probe)." + ), no_args_is_help=True, ) plain_text_app = typer.Typer(help="Knowledge base plain texts.", no_args_is_help=True) @@ -22,6 +27,11 @@ help="Knowledge base documents (one-shot PDF upload).", no_args_is_help=True ) kb_app.add_typer(document_app, name="document") +data_lookup_app = typer.Typer( + help="Knowledge base data lookups (search cards in a source pipe).", + no_args_is_help=True, +) +kb_app.add_typer(data_lookup_app, name="data-lookup") _PIPE_UUID_HELP = "Pipe UUID (not the numeric ID; `pipefy pipe get` shows the uuid)." @@ -351,6 +361,195 @@ async def factory(client: PipefyClient): run_cli_command(ctx, json_out, factory, exit_1_on_unsuccessful=True) +_SOURCE_REPO_HELP = "Numeric ID of the source pipe to look up data from." +_OUTPUT_FIELDS_HELP = ( + "JSON array of field IDs whose values matching records return (1-30)." +) +_CONDITIONS_HELP = ( + "JSON array of condition objects. Each needs field + operator, and either " + 'a string value (static) or "usingFillWithAi": true with ' + "inputName/inputType/inputDescription (AI-filled)." +) +_SEARCH_QUERY_HELP = ( + "Backend-defined search mode marker; leave unset unless you know the " + "backend value you need." +) + + +def _parse_data_lookup_options( + output_fields: str, conditions: str +) -> tuple[list[str], list[dict[str, Any]]]: + """Parse the JSON-array options shared by data lookup create/update.""" + parsed_fields = parse_json_value(output_fields, "--output-fields") + if not isinstance(parsed_fields, list) or not all( + isinstance(f, str) for f in parsed_fields + ): + raise typer.BadParameter("--output-fields must be a JSON array of strings") + parsed_conditions = parse_json_value(conditions, "--conditions") + if not isinstance(parsed_conditions, list) or not all( + isinstance(c, dict) for c in parsed_conditions + ): + raise typer.BadParameter("--conditions must be a JSON array of objects") + return parsed_fields, parsed_conditions + + +@data_lookup_app.command("get") +def kb_data_lookup_get( + ctx: typer.Context, + data_lookup_id: str = typer.Option( + ..., "--id", help="Knowledge base data lookup ID (from `pipefy kb list`)." + ), + pipe_uuid: str = typer.Option(..., "--pipe-uuid", help=_PIPE_UUID_HELP), + json_out: bool = typer.Option( + False, "--json", "-j", help="Print machine-readable JSON to stdout." + ), +) -> None: + """Fetch one knowledge base data lookup (``get_ai_knowledge_base_data_lookup``). + + The payload never includes ``conditions`` (the API does not expose them on + reads); keep the lookup definition client-side. Exits 1 with ``success: + false`` when the API resolves no data lookup for the id (mirrors the MCP + tool's not-found handling). + """ + + async def factory(client: PipefyClient): + data_lookup = await client.get_ai_knowledge_base_data_lookup( + data_lookup_id, pipe_uuid + ) + if not data_lookup: + return _kb_not_found("Knowledge base data lookup", data_lookup_id) + return {"success": True, "knowledge_base_data_lookup": data_lookup} + + run_cli_command(ctx, json_out, factory, exit_1_on_unsuccessful=True) + + +@data_lookup_app.command("create") +def kb_data_lookup_create( + ctx: typer.Context, + pipe_uuid: str = typer.Option(..., "--pipe-uuid", help=_PIPE_UUID_HELP), + name: str = typer.Option(..., "--name", help="Display name (required)."), + description: str = typer.Option( + ..., "--description", help="Description (required, 1-900 characters)." + ), + source_repo_id: str = typer.Option(..., "--source-repo-id", help=_SOURCE_REPO_HELP), + output_fields: str = typer.Option(..., "--output-fields", help=_OUTPUT_FIELDS_HELP), + conditions: str = typer.Option(..., "--conditions", help=_CONDITIONS_HELP), + search_query: str | None = typer.Option( + None, "--search-query", help=_SEARCH_QUERY_HELP + ), + json_out: bool = typer.Option( + False, "--json", "-j", help="Print machine-readable JSON to stdout." + ), +) -> None: + """Create a knowledge base data lookup (``create_ai_knowledge_base_data_lookup``). + + Gated on the read-access probe. The definition is validated client-side: + ``--source-repo-id`` must be numeric, ``--output-fields`` takes 1-30 field + IDs, and every condition is typed (static conditions need a string + ``value``; AI-filled ones need the input trio). Reads never return + conditions, so keep the definition you send as the source of truth. + """ + fields, condition_list = _parse_data_lookup_options(output_fields, conditions) + + async def factory(client: PipefyClient): + gate = await _probe_gate(client, pipe_uuid) + if gate is not None: + return gate + data_lookup = await client.create_ai_knowledge_base_data_lookup( + pipe_uuid, + name=name, + description=description, + source_repo_id=source_repo_id, + output_fields=fields, + conditions=condition_list, + search_query=search_query, + ) + return {"success": True, "knowledge_base_data_lookup": data_lookup} + + run_cli_command(ctx, json_out, factory, exit_1_on_unsuccessful=True) + + +@data_lookup_app.command("update") +def kb_data_lookup_update( + ctx: typer.Context, + data_lookup_id: str = typer.Option( + ..., "--id", help="Knowledge base data lookup ID to update." + ), + pipe_uuid: str = typer.Option(..., "--pipe-uuid", help=_PIPE_UUID_HELP), + source_repo_id: str = typer.Option(..., "--source-repo-id", help=_SOURCE_REPO_HELP), + output_fields: str = typer.Option(..., "--output-fields", help=_OUTPUT_FIELDS_HELP), + conditions: str = typer.Option( + ..., + "--conditions", + help=_CONDITIONS_HELP + " Pass the complete set, not a delta.", + ), + search_query: str | None = typer.Option( + None, "--search-query", help=_SEARCH_QUERY_HELP + " Omitted means cleared." + ), + name: str | None = typer.Option(None, "--name", help="New name (non-blank)."), + description: str | None = typer.Option( + None, "--description", help="New description (1-900 characters)." + ), + json_out: bool = typer.Option( + False, "--json", "-j", help="Print machine-readable JSON to stdout." + ), +) -> None: + """Update a knowledge base data lookup by replacing its full definition (``update_ai_knowledge_base_data_lookup``). + + Every update rewrites the stored definition with exactly what this call + carries: ``--source-repo-id``, ``--output-fields``, and ``--conditions`` + are required every time, and omitting ``--search-query`` clears it. Only + ``--name``/``--description`` are partial. Gated on the read-access probe. + """ + fields, condition_list = _parse_data_lookup_options(output_fields, conditions) + + async def factory(client: PipefyClient): + gate = await _probe_gate(client, pipe_uuid) + if gate is not None: + return gate + data_lookup = await client.update_ai_knowledge_base_data_lookup( + data_lookup_id, + pipe_uuid, + source_repo_id=source_repo_id, + output_fields=fields, + conditions=condition_list, + search_query=search_query, + name=name, + description=description, + ) + return {"success": True, "knowledge_base_data_lookup": data_lookup} + + run_cli_command(ctx, json_out, factory, exit_1_on_unsuccessful=True) + + +@data_lookup_app.command("delete") +def kb_data_lookup_delete( + ctx: typer.Context, + data_lookup_id: str = typer.Option( + ..., "--id", help="Knowledge base data lookup ID to delete." + ), + pipe_uuid: str = typer.Option(..., "--pipe-uuid", help=_PIPE_UUID_HELP), + yes: bool = typer.Option( + False, "--yes", "-y", help="Skip the confirmation prompt." + ), + json_out: bool = typer.Option( + False, "--json", "-j", help="Print machine-readable JSON to stdout." + ), +) -> None: + """Delete a knowledge base data lookup permanently (``delete_ai_knowledge_base_data_lookup``).""" + confirm_destructive( + yes=yes, + description=f"knowledge base data lookup {data_lookup_id}", + ) + + async def factory(client: PipefyClient): + return await client.delete_ai_knowledge_base_data_lookup( + data_lookup_id, pipe_uuid + ) + + run_cli_command(ctx, json_out, factory, exit_1_on_unsuccessful=True) + + async def _probe_gate(client: PipefyClient, pipe_uuid: str) -> dict | None: """Gate a write on the read-access probe; return a failure dict or None. diff --git a/packages/cli/tests/fixtures/cli_help_golden.txt b/packages/cli/tests/fixtures/cli_help_golden.txt index 8fa96b8a..11f130a4 100644 --- a/packages/cli/tests/fixtures/cli_help_golden.txt +++ b/packages/cli/tests/fixtures/cli_help_golden.txt @@ -58,8 +58,8 @@ Usage: pipefy [OPTIONS] COMMAND [ARGS]... │ graphql Execute arbitrary GraphQL (mutations require --yes). See │ │ docs/cli/self-healing.md. │ │ introspect Discover GraphQL types and operations (JSON default). │ -│ kb AI knowledge bases (pipe-scoped: list, plain text/document │ -│ CRUD, access probe). │ +│ kb AI knowledge bases (pipe-scoped: list, plain │ +│ text/document/data lookup CRUD, access probe). │ │ export Bulk exports (automation jobs). │ │ org Organization operations. │ │ report-org Organization reports. │ @@ -1652,8 +1652,8 @@ Usage: pipefy introspect type [OPTIONS] NAME ### HELP kb Usage: pipefy kb [OPTIONS] COMMAND [ARGS]... - AI knowledge bases (pipe-scoped: list, plain text/document CRUD, access - probe). + AI knowledge bases (pipe-scoped: list, plain text/document/data lookup CRUD, + access probe). ╭─ Options ────────────────────────────────────────────────────────────────────╮ │ --help Show this message and exit. │ @@ -1665,6 +1665,144 @@ Usage: pipefy kb [OPTIONS] COMMAND [ARGS]... │ (``validate_knowledge_base_access``). │ │ plain-text Knowledge base plain texts. │ │ document Knowledge base documents (one-shot PDF upload). │ +│ data-lookup Knowledge base data lookups (search cards in a source │ +│ pipe). │ +╰──────────────────────────────────────────────────────────────────────────────╯ + +### HELP kb/data-lookup +Usage: pipefy kb data-lookup [OPTIONS] COMMAND [ARGS]... + + Knowledge base data lookups (search cards in a source pipe). + +╭─ Options ────────────────────────────────────────────────────────────────────╮ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────╯ +╭─ Commands ───────────────────────────────────────────────────────────────────╮ +│ get Fetch one knowledge base data lookup │ +│ (``get_ai_knowledge_base_data_lookup``). │ +│ create Create a knowledge base data lookup │ +│ (``create_ai_knowledge_base_data_lookup``). │ +│ update Update a knowledge base data lookup by replacing its full │ +│ definition (``update_ai_knowledge_base_data_lookup``). │ +│ delete Delete a knowledge base data lookup permanently │ +│ (``delete_ai_knowledge_base_data_lookup``). │ +╰──────────────────────────────────────────────────────────────────────────────╯ + +### HELP kb/data-lookup/create +Usage: pipefy kb data-lookup create [OPTIONS] + + Create a knowledge base data lookup + (``create_ai_knowledge_base_data_lookup``). + + Gated on the read-access probe. The definition is validated client-side: + ``--source-repo-id`` must be numeric, ``--output-fields`` takes 1-30 field + IDs, and every condition is typed (static conditions need a string ``value``; + AI-filled ones need the input trio). Reads never return conditions, so keep + the definition you send as the source of truth. + +╭─ Options ────────────────────────────────────────────────────────────────────╮ +│ * --pipe-uuid TEXT Pipe UUID (not the numeric ID; `pipefy │ +│ pipe get` shows the uuid). │ +│ [required] │ +│ * --name TEXT Display name (required). [required] │ +│ * --description TEXT Description (required, 1-900 characters). │ +│ [required] │ +│ * --source-repo-id TEXT Numeric ID of the source pipe to look up │ +│ data from. │ +│ [required] │ +│ * --output-fields TEXT JSON array of field IDs whose values │ +│ matching records return (1-30). │ +│ [required] │ +│ * --conditions TEXT JSON array of condition objects. Each │ +│ needs field + operator, and either a │ +│ string value (static) or │ +│ "usingFillWithAi": true with │ +│ inputName/inputType/inputDescription │ +│ (AI-filled). │ +│ [required] │ +│ --search-query TEXT Backend-defined search mode marker; leave │ +│ unset unless you know the backend value │ +│ you need. │ +│ --json -j Print machine-readable JSON to stdout. │ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────╯ + +### HELP kb/data-lookup/delete +Usage: pipefy kb data-lookup delete [OPTIONS] + + Delete a knowledge base data lookup permanently + (``delete_ai_knowledge_base_data_lookup``). + +╭─ Options ────────────────────────────────────────────────────────────────────╮ +│ * --id TEXT Knowledge base data lookup ID to delete. │ +│ [required] │ +│ * --pipe-uuid TEXT Pipe UUID (not the numeric ID; `pipefy pipe │ +│ get` shows the uuid). │ +│ [required] │ +│ --yes -y Skip the confirmation prompt. │ +│ --json -j Print machine-readable JSON to stdout. │ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────╯ + +### HELP kb/data-lookup/get +Usage: pipefy kb data-lookup get [OPTIONS] + + Fetch one knowledge base data lookup (``get_ai_knowledge_base_data_lookup``). + + The payload never includes ``conditions`` (the API does not expose them on + reads); keep the lookup definition client-side. Exits 1 with ``success: + false`` when the API resolves no data lookup for the id (mirrors the MCP + tool's not-found handling). + +╭─ Options ────────────────────────────────────────────────────────────────────╮ +│ * --id TEXT Knowledge base data lookup ID (from `pipefy kb │ +│ list`). │ +│ [required] │ +│ * --pipe-uuid TEXT Pipe UUID (not the numeric ID; `pipefy pipe │ +│ get` shows the uuid). │ +│ [required] │ +│ --json -j Print machine-readable JSON to stdout. │ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────╯ + +### HELP kb/data-lookup/update +Usage: pipefy kb data-lookup update [OPTIONS] + + Update a knowledge base data lookup by replacing its full definition + (``update_ai_knowledge_base_data_lookup``). + + Every update rewrites the stored definition with exactly what this call + carries: ``--source-repo-id``, ``--output-fields``, and ``--conditions`` are + required every time, and omitting ``--search-query`` clears it. Only + ``--name``/``--description`` are partial. Gated on the read-access probe. + +╭─ Options ────────────────────────────────────────────────────────────────────╮ +│ * --id TEXT Knowledge base data lookup ID to update. │ +│ [required] │ +│ * --pipe-uuid TEXT Pipe UUID (not the numeric ID; `pipefy │ +│ pipe get` shows the uuid). │ +│ [required] │ +│ * --source-repo-id TEXT Numeric ID of the source pipe to look up │ +│ data from. │ +│ [required] │ +│ * --output-fields TEXT JSON array of field IDs whose values │ +│ matching records return (1-30). │ +│ [required] │ +│ * --conditions TEXT JSON array of condition objects. Each │ +│ needs field + operator, and either a │ +│ string value (static) or │ +│ "usingFillWithAi": true with │ +│ inputName/inputType/inputDescription │ +│ (AI-filled). Pass the complete set, not a │ +│ delta. │ +│ [required] │ +│ --search-query TEXT Backend-defined search mode marker; leave │ +│ unset unless you know the backend value │ +│ you need. Omitted means cleared. │ +│ --name TEXT New name (non-blank). │ +│ --description TEXT New description (1-900 characters). │ +│ --json -j Print machine-readable JSON to stdout. │ +│ --help Show this message and exit. │ ╰──────────────────────────────────────────────────────────────────────────────╯ ### HELP kb/document diff --git a/packages/cli/tests/test_knowledge_base_commands.py b/packages/cli/tests/test_knowledge_base_commands.py index b554df97..de4efbc5 100644 --- a/packages/cli/tests/test_knowledge_base_commands.py +++ b/packages/cli/tests/test_knowledge_base_commands.py @@ -570,3 +570,294 @@ def test_kb_document_delete_with_yes(runner, clean_pipefy_env, saved_cwd, monkey mock_client.delete_ai_knowledge_base_document.assert_awaited_once_with( "kb-2", "pipe-uuid-1" ) + + +DATA_LOOKUP_FULL = { + "id": "kb-3", + "name": "Order lookup", + "description": "Find orders by customer email", + "sourceRepoId": "303088927", + "searchQuery": None, + "outputFields": ["title", "status"], + "updatedAt": "2026-07-18T00:00:00Z", +} + +_STATIC_CONDITION_JSON = '[{"field": "title", "operator": "contains", "value": "x"}]' + +_DATA_LOOKUP_CREATE_ARGS = [ + "kb", + "data-lookup", + "create", + "--pipe-uuid", + "pipe-uuid-1", + "--name", + "Order lookup", + "--description", + "Find orders", + "--source-repo-id", + "303088927", + "--output-fields", + '["title"]', + "--conditions", + _STATIC_CONDITION_JSON, +] + + +def test_kb_data_lookup_get_json(runner, clean_pipefy_env, saved_cwd, monkeypatch): + _env(monkeypatch) + mock_client = MagicMock() + mock_client.get_ai_knowledge_base_data_lookup = AsyncMock( + return_value=DATA_LOOKUP_FULL + ) + + with _client_patch(mock_client): + result = runner.invoke( + app, + [ + "kb", + "data-lookup", + "get", + "--id", + "kb-3", + "--pipe-uuid", + "pipe-uuid-1", + "--json", + ], + ) + assert result.exit_code == 0, result.stdout + (result.stderr or "") + data = json.loads(result.stdout) + assert data["success"] is True + assert data["knowledge_base_data_lookup"] == DATA_LOOKUP_FULL + mock_client.get_ai_knowledge_base_data_lookup.assert_awaited_once_with( + "kb-3", "pipe-uuid-1" + ) + + +def test_kb_data_lookup_get_not_found_exits_1( + runner, clean_pipefy_env, saved_cwd, monkeypatch +): + _env(monkeypatch) + mock_client = MagicMock() + mock_client.get_ai_knowledge_base_data_lookup = AsyncMock(return_value={}) + + with _client_patch(mock_client): + result = runner.invoke( + app, + [ + "kb", + "data-lookup", + "get", + "--id", + "kb-x", + "--pipe-uuid", + "pipe-uuid-1", + "--json", + ], + ) + assert result.exit_code == 1 + data = json.loads(result.stdout) + assert data["success"] is False + assert "pipefy kb list" in data["error"] + + +def test_kb_data_lookup_create_json(runner, clean_pipefy_env, saved_cwd, monkeypatch): + _env(monkeypatch) + mock_client = MagicMock() + mock_client.validate_knowledge_base_access = AsyncMock(return_value=GREEN_PROBE) + mock_client.create_ai_knowledge_base_data_lookup = AsyncMock( + return_value=DATA_LOOKUP_FULL + ) + + with _client_patch(mock_client): + result = runner.invoke(app, [*_DATA_LOOKUP_CREATE_ARGS, "--json"]) + assert result.exit_code == 0, result.stdout + (result.stderr or "") + data = json.loads(result.stdout) + assert data["success"] is True + assert data["knowledge_base_data_lookup"] == DATA_LOOKUP_FULL + mock_client.create_ai_knowledge_base_data_lookup.assert_awaited_once_with( + "pipe-uuid-1", + name="Order lookup", + description="Find orders", + source_repo_id="303088927", + output_fields=["title"], + conditions=[{"field": "title", "operator": "contains", "value": "x"}], + search_query=None, + ) + + +def test_kb_data_lookup_create_gated_on_denied_probe( + runner, clean_pipefy_env, saved_cwd, monkeypatch +): + _env(monkeypatch) + mock_client = MagicMock() + mock_client.validate_knowledge_base_access = AsyncMock(return_value=DENIED_PROBE) + mock_client.create_ai_knowledge_base_data_lookup = AsyncMock() + + with _client_patch(mock_client): + result = runner.invoke(app, [*_DATA_LOOKUP_CREATE_ARGS, "--json"]) + assert result.exit_code == 1 + assert json.loads(result.stdout)["success"] is False + mock_client.create_ai_knowledge_base_data_lookup.assert_not_awaited() + + +def test_kb_data_lookup_create_invalid_conditions_json_rejected( + runner, clean_pipefy_env, saved_cwd, monkeypatch +): + _env(monkeypatch) + mock_client = MagicMock() + mock_client.create_ai_knowledge_base_data_lookup = AsyncMock() + + args = list(_DATA_LOOKUP_CREATE_ARGS) + args[args.index(_STATIC_CONDITION_JSON)] = '{"not": "an array"}' + with _client_patch(mock_client): + result = runner.invoke(app, args) + assert result.exit_code != 0 + # Match the color-stable fragment: rich colorizes the ``--conditions`` + # option name in the error box, splitting it with ANSI codes. + assert "must be a JSON array of objects" in (result.stdout + (result.stderr or "")) + mock_client.create_ai_knowledge_base_data_lookup.assert_not_awaited() + + +def test_kb_data_lookup_create_invalid_output_fields_rejected( + runner, clean_pipefy_env, saved_cwd, monkeypatch +): + _env(monkeypatch) + mock_client = MagicMock() + mock_client.create_ai_knowledge_base_data_lookup = AsyncMock() + + args = list(_DATA_LOOKUP_CREATE_ARGS) + args[args.index('["title"]')] = "[1, 2]" + with _client_patch(mock_client): + result = runner.invoke(app, args) + assert result.exit_code != 0 + # Match the color-stable fragment (see the conditions test above). + assert "must be a JSON array of strings" in (result.stdout + (result.stderr or "")) + mock_client.create_ai_knowledge_base_data_lookup.assert_not_awaited() + + +def test_kb_data_lookup_update_sends_full_definition( + runner, clean_pipefy_env, saved_cwd, monkeypatch +): + _env(monkeypatch) + mock_client = MagicMock() + mock_client.validate_knowledge_base_access = AsyncMock(return_value=GREEN_PROBE) + mock_client.update_ai_knowledge_base_data_lookup = AsyncMock( + return_value=DATA_LOOKUP_FULL + ) + + with _client_patch(mock_client): + result = runner.invoke( + app, + [ + "kb", + "data-lookup", + "update", + "--id", + "kb-3", + "--pipe-uuid", + "pipe-uuid-1", + "--source-repo-id", + "303088927", + "--output-fields", + '["title"]', + "--conditions", + _STATIC_CONDITION_JSON, + "--name", + "Renamed", + "--json", + ], + ) + assert result.exit_code == 0, result.stdout + (result.stderr or "") + assert json.loads(result.stdout)["success"] is True + mock_client.update_ai_knowledge_base_data_lookup.assert_awaited_once_with( + "kb-3", + "pipe-uuid-1", + source_repo_id="303088927", + output_fields=["title"], + conditions=[{"field": "title", "operator": "contains", "value": "x"}], + search_query=None, + name="Renamed", + description=None, + ) + + +def test_kb_data_lookup_update_requires_definition_options( + runner, clean_pipefy_env, saved_cwd, monkeypatch +): + _env(monkeypatch) + mock_client = MagicMock() + mock_client.update_ai_knowledge_base_data_lookup = AsyncMock() + + with _client_patch(mock_client): + result = runner.invoke( + app, + [ + "kb", + "data-lookup", + "update", + "--id", + "kb-3", + "--pipe-uuid", + "pipe-uuid-1", + "--name", + "Renamed", + ], + ) + assert result.exit_code != 0 + mock_client.update_ai_knowledge_base_data_lookup.assert_not_awaited() + + +def test_kb_data_lookup_delete_requires_confirmation( + runner, clean_pipefy_env, saved_cwd, monkeypatch +): + _env(monkeypatch) + mock_client = MagicMock() + mock_client.delete_ai_knowledge_base_data_lookup = AsyncMock() + + with _client_patch(mock_client): + result = runner.invoke( + app, + [ + "kb", + "data-lookup", + "delete", + "--id", + "kb-3", + "--pipe-uuid", + "pipe-uuid-1", + ], + input="n\n", + ) + assert result.exit_code != 0 + mock_client.delete_ai_knowledge_base_data_lookup.assert_not_awaited() + + +def test_kb_data_lookup_delete_with_yes( + runner, clean_pipefy_env, saved_cwd, monkeypatch +): + _env(monkeypatch) + mock_client = MagicMock() + mock_client.delete_ai_knowledge_base_data_lookup = AsyncMock( + return_value={"success": True, "errors": []} + ) + + with _client_patch(mock_client): + result = runner.invoke( + app, + [ + "kb", + "data-lookup", + "delete", + "--id", + "kb-3", + "--pipe-uuid", + "pipe-uuid-1", + "--yes", + "--json", + ], + ) + assert result.exit_code == 0, result.stdout + (result.stderr or "") + assert json.loads(result.stdout)["success"] is True + mock_client.delete_ai_knowledge_base_data_lookup.assert_awaited_once_with( + "kb-3", "pipe-uuid-1" + ) diff --git a/packages/mcp/src/pipefy_mcp/tools/knowledge_base_tools.py b/packages/mcp/src/pipefy_mcp/tools/knowledge_base_tools.py index 3795b443..e531c232 100644 --- a/packages/mcp/src/pipefy_mcp/tools/knowledge_base_tools.py +++ b/packages/mcp/src/pipefy_mcp/tools/knowledge_base_tools.py @@ -1,4 +1,4 @@ -"""MCP tools for pipe-scoped AI knowledge bases (list, plain text CRUD, probe). +"""MCP tools for pipe-scoped AI knowledge bases (list, plain text/document/data lookup CRUD, probe). Reads and writes reach the API with the request-scoped credential and are fully governed by API permissions. Writes are gated on the caller running @@ -83,6 +83,16 @@ def _blank_error(value: str, field: str) -> dict[str, Any] | None: return None +def _kb_delete_failure(tool_name: str, result: dict[str, Any]) -> dict[str, Any]: + """Failure envelope for a KB delete that returned ``success=false``. + + Shared by every knowledge base kind so the error surface cannot drift. + """ + errs = result.get("errors") or [] + detail = "; ".join(str(e) for e in errs) if errs else "API returned success=false" + return tool_error(f"{tool_name} failed: {detail}") + + def _kb_success(data: dict[str, Any], *, message: str) -> dict[str, Any]: """Unified-envelope success (legacy flat payload when the flag is off).""" if is_unified_envelope_enabled(): @@ -105,8 +115,10 @@ async def get_ai_knowledge_bases( """List every knowledge base item on a pipe: plain texts, documents, and data lookups in one surface. Use this to discover the `dataSourceIds` values an AI agent or behavior can attach. Each item carries `id` (the data-source ID used in `dataSourceIds`), - `type` (e.g. `knowledge_base_plain_texts`), `name`, `description`, and - `updatedAt`. There is no pagination; the full list is returned. + `type`, `name`, `description`, and `updatedAt`. The `type` values are + `knowledge_base_plain_texts`, `knowledge_base_documents`, and + `data_lookups` (the data lookup discriminator is not prefixed). There + is no pagination; the full list is returned. Args: pipe_uuid: Pipe UUID (not the numeric ID; `get_pipe` returns the `uuid` field). @@ -293,15 +305,7 @@ async def delete_ai_knowledge_base_plain_text( except Exception as exc: # noqa: BLE001 return _kb_tool_error_from_exception(exc) if not result.get("success"): - errs = result.get("errors") or [] - detail = ( - "; ".join(str(e) for e in errs) - if errs - else "API returned success=false" - ) - return tool_error( - f"delete_ai_knowledge_base_plain_text failed: {detail}" - ) + return _kb_delete_failure("delete_ai_knowledge_base_plain_text", result) return _kb_success( {"deleted_id": plain_text_id.strip()}, message="Knowledge base plain text deleted.", @@ -485,24 +489,232 @@ async def delete_ai_knowledge_base_document( except Exception as exc: # noqa: BLE001 return _kb_tool_error_from_exception(exc) if not result.get("success"): - errs = result.get("errors") or [] - detail = ( - "; ".join(str(e) for e in errs) - if errs - else "API returned success=false" - ) - return tool_error(f"delete_ai_knowledge_base_document failed: {detail}") + return _kb_delete_failure("delete_ai_knowledge_base_document", result) return _kb_success( {"deleted_id": document_id.strip()}, message="Knowledge base document deleted.", ) + @mcp.tool(annotations=ToolAnnotations(readOnlyHint=True)) + async def get_ai_knowledge_base_data_lookup( + ctx: Context, + data_lookup_id: str, + pipe_uuid: str, + ) -> dict[str, Any]: + """Fetch one pipe-scoped knowledge base data lookup by ID. + + Returns `name`, `description`, `sourceRepoId`, `searchQuery`, and + `outputFields` — never `conditions`: the API stores them but does + not expose them on reads, so keep your lookup definition (including + conditions) as the client-side source of truth for updates. + + Args: + data_lookup_id: Knowledge base data lookup ID (from `get_ai_knowledge_bases`). + pipe_uuid: Pipe UUID (not the numeric ID). + """ + client = get_pipefy_client(ctx) + err = _blank_error(data_lookup_id, "data_lookup_id") or _blank_error( + pipe_uuid, "pipe_uuid" + ) + if err is not None: + return err + try: + data_lookup = await client.get_ai_knowledge_base_data_lookup( + data_lookup_id.strip(), pipe_uuid.strip() + ) + except Exception as exc: # noqa: BLE001 + return _kb_tool_error_from_exception(exc) + if not data_lookup: + return tool_error( + f"Knowledge base data lookup not found: {data_lookup_id.strip()}. " + f"{_KB_ID_DISCOVERY_HINT}" + ) + return _kb_success( + {"knowledge_base_data_lookup": data_lookup}, + message="Knowledge base data lookup retrieved.", + ) + + @mcp.tool( + annotations=ToolAnnotations(readOnlyHint=False, destructiveHint=False), + ) + async def create_ai_knowledge_base_data_lookup( + ctx: Context, + pipe_uuid: str, + name: str, + description: str, + source_repo_id: str, + output_fields: list[str], + conditions: list[dict[str, Any]], + search_query: str | None = None, + ) -> dict[str, Any]: + """Create a pipe-scoped knowledge base data lookup: an agent data source that searches cards in a source pipe by conditions and returns selected fields. Requires manage_ai_agents on the pipe; run `validate_knowledge_base_access` first. + + The definition is validated client-side because the API accepts + shapes that only break later, when an agent runs the lookup: + `source_repo_id` must be the numeric pipe ID (not a UUID); + `output_fields` takes 1-30 field IDs (field slugs plus static + fields like `id`, `title`, `created_at`); each condition needs + `field` and `operator` (an opaque backend string, e.g. `"eq"`, + `"contains"`) and is either static (string `value` required) or + AI-filled (`usingFillWithAi: true` with `inputName`, `inputType` + (e.g. `"text"`, `"number"`), and `inputDescription`; no `value`). + Keep the full definition client-side: reads never return + `conditions`. To attach the result to an agent, pass the returned + `id` in the agent's or behavior's `dataSourceIds`. + + Args: + pipe_uuid: Pipe UUID (not the numeric ID) that owns the lookup. + name: Display name (required, non-blank). + description: Description (required, 1-900 characters). + source_repo_id: Numeric ID of the source pipe to look up data from. + output_fields: Field IDs whose values matching records return (1-30). + conditions: Condition objects (at least one), e.g. + `{"field": "customer_email", "operator": "eq", "usingFillWithAi": true, "inputName": "Customer email", "inputType": "text", "inputDescription": "The customer's email address"}`. + search_query: Optional backend-defined search mode marker; leave unset unless you know the backend value you need. + """ + client = get_pipefy_client(ctx) + err = _blank_error(pipe_uuid, "pipe_uuid") + if err is not None: + return err + try: + data_lookup = await client.create_ai_knowledge_base_data_lookup( + pipe_uuid.strip(), + name=name, + description=description, + source_repo_id=source_repo_id, + output_fields=output_fields, + conditions=conditions, + search_query=search_query, + ) + except ValueError as exc: + return tool_error(str(exc)) + except Exception as exc: # noqa: BLE001 + return _kb_tool_error_from_exception(exc) + return _kb_success( + {"knowledge_base_data_lookup": data_lookup}, + message="Knowledge base data lookup created.", + ) + + @mcp.tool( + annotations=ToolAnnotations(readOnlyHint=False, destructiveHint=False), + ) + async def update_ai_knowledge_base_data_lookup( + ctx: Context, + data_lookup_id: str, + pipe_uuid: str, + source_repo_id: str, + output_fields: list[str], + conditions: list[dict[str, Any]], + search_query: str | None = None, + name: str | None = None, + description: str | None = None, + ) -> dict[str, Any]: + """Update a knowledge base data lookup by replacing its full definition. Requires manage_ai_agents; run `validate_knowledge_base_access` first. + + Every update rewrites the whole definition: the API replaces the + stored definition with exactly what this call carries, so + `source_repo_id`, `output_fields`, and `conditions` are required + every time, and omitting `search_query` clears it. Reads never + return `conditions`, so resend the definition from your own + client-side copy. Only `name`/`description` are partial (omitted + means keep the stored value). Field rules match + `create_ai_knowledge_base_data_lookup`. + + Args: + data_lookup_id: Data lookup ID to update (from `get_ai_knowledge_bases`). + pipe_uuid: Pipe UUID (not the numeric ID). + source_repo_id: Numeric ID of the source pipe (required; omitting it would strip the source from the stored definition). + output_fields: Field IDs whose values matching records return (1-30). + conditions: Condition objects (at least one) — the complete set, not a delta. + search_query: Optional backend-defined search mode marker; omitted means cleared. + name: New name (non-blank when given). + description: New description (1-900 characters when given). + """ + client = get_pipefy_client(ctx) + err = _blank_error(data_lookup_id, "data_lookup_id") or _blank_error( + pipe_uuid, "pipe_uuid" + ) + if err is not None: + return err + try: + data_lookup = await client.update_ai_knowledge_base_data_lookup( + data_lookup_id.strip(), + pipe_uuid.strip(), + source_repo_id=source_repo_id, + output_fields=output_fields, + conditions=conditions, + search_query=search_query, + name=name, + description=description, + ) + except ValueError as exc: + return tool_error(str(exc)) + except Exception as exc: # noqa: BLE001 + return _kb_tool_error_from_exception(exc) + return _kb_success( + {"knowledge_base_data_lookup": data_lookup}, + message="Knowledge base data lookup updated.", + ) + + @mcp.tool( + annotations=ToolAnnotations(readOnlyHint=False, destructiveHint=True), + ) + async def delete_ai_knowledge_base_data_lookup( + ctx: Context, + data_lookup_id: str, + pipe_uuid: str, + confirm: bool = False, + ) -> dict[str, Any]: + """Delete a pipe-scoped knowledge base data lookup permanently. This action is irreversible. + + Two-step operation: preview with `confirm=False` (default), then + execute with `confirm=True` after explicit human approval. + Elicitation does not authorize deletion (only `confirm=True` does). + Requires manage_ai_agents on the pipe. + + Args: + data_lookup_id: Data lookup ID to delete (from `get_ai_knowledge_bases`). + pipe_uuid: Pipe UUID (not the numeric ID). + confirm: Must be `True` to run the delete mutation. + """ + client = get_pipefy_client(ctx) + err = _blank_error(data_lookup_id, "data_lookup_id") or _blank_error( + pipe_uuid, "pipe_uuid" + ) + if err is not None: + return err + + guard = await check_destructive_confirmation( + ctx, + confirm=confirm, + resource_descriptor=( + f"knowledge base data lookup (ID: {data_lookup_id.strip()})" + ), + ) + if guard is not None: + return guard + + try: + result = await client.delete_ai_knowledge_base_data_lookup( + data_lookup_id.strip(), pipe_uuid.strip() + ) + except Exception as exc: # noqa: BLE001 + return _kb_tool_error_from_exception(exc) + if not result.get("success"): + return _kb_delete_failure( + "delete_ai_knowledge_base_data_lookup", result + ) + return _kb_success( + {"deleted_id": data_lookup_id.strip()}, + message="Knowledge base data lookup deleted.", + ) + @mcp.tool(annotations=ToolAnnotations(readOnlyHint=True)) async def validate_knowledge_base_access( ctx: Context, pipe_uuid: str, ) -> dict[str, Any]: - """Probe whether the current credential can read a pipe's knowledge bases. A green result proves READ access only (read_ai_agents), never the manage_ai_agents entitlement plain-text create/update/delete require. + """Probe whether the current credential can read a pipe's knowledge bases. A green result proves READ access only (read_ai_agents), never the manage_ai_agents entitlement knowledge base writes (plain text, document, data lookup) require. On success, reports how many knowledge base items are visible. On a classified failure, returns the structured problem (permission denied / diff --git a/packages/mcp/src/pipefy_mcp/tools/registry.py b/packages/mcp/src/pipefy_mcp/tools/registry.py index ff373087..3ab6e425 100644 --- a/packages/mcp/src/pipefy_mcp/tools/registry.py +++ b/packages/mcp/src/pipefy_mcp/tools/registry.py @@ -44,6 +44,7 @@ "create_automation", "create_card", "create_card_relation", + "create_ai_knowledge_base_data_lookup", "create_ai_knowledge_base_document", "create_ai_knowledge_base_plain_text", "create_field_condition", @@ -66,6 +67,7 @@ "create_webhook", "delete_ai_agent", "delete_ai_automation", + "delete_ai_knowledge_base_data_lookup", "delete_ai_knowledge_base_document", "delete_ai_knowledge_base_plain_text", "delete_automation", @@ -106,6 +108,7 @@ "get_ai_automation", "get_ai_automations", "get_ai_credit_usage", + "get_ai_knowledge_base_data_lookup", "get_ai_knowledge_base_document", "get_ai_knowledge_base_plain_text", "get_ai_knowledge_bases", @@ -179,6 +182,7 @@ "unpublish_sub_portal", "update_ai_agent", "update_ai_automation", + "update_ai_knowledge_base_data_lookup", "update_ai_knowledge_base_document", "update_ai_knowledge_base_plain_text", "update_automation", diff --git a/packages/mcp/tests/tools/test_knowledge_base_tools.py b/packages/mcp/tests/tools/test_knowledge_base_tools.py index 5ba95c9a..7fca6664 100644 --- a/packages/mcp/tests/tools/test_knowledge_base_tools.py +++ b/packages/mcp/tests/tools/test_knowledge_base_tools.py @@ -38,6 +38,18 @@ "updatedAt": "2026-07-16T00:00:00Z", } +DATA_LOOKUP_FULL = { + "id": "kb-3", + "name": "Order lookup", + "description": "Find orders by customer email", + "sourceRepoId": "303088927", + "searchQuery": None, + "outputFields": ["title", "status"], + "updatedAt": "2026-07-18T00:00:00Z", +} + +STATIC_CONDITION = {"field": "title", "operator": "contains", "value": "urgent"} + def permission_denied_error() -> TransportQueryError: return TransportQueryError( @@ -75,6 +87,10 @@ def mock_kb_client(): client.create_ai_knowledge_base_document = AsyncMock() client.update_ai_knowledge_base_document = AsyncMock() client.delete_ai_knowledge_base_document = AsyncMock() + client.get_ai_knowledge_base_data_lookup = AsyncMock() + client.create_ai_knowledge_base_data_lookup = AsyncMock() + client.update_ai_knowledge_base_data_lookup = AsyncMock() + client.delete_ai_knowledge_base_data_lookup = AsyncMock() client.validate_knowledge_base_access = AsyncMock() return client @@ -579,3 +595,214 @@ async def test_delete_document_with_confirm_executes( mock_kb_client.delete_ai_knowledge_base_document.assert_awaited_once_with( "kb-2", "pipe-uuid-1" ) + + +@pytest.mark.anyio +@pytest.mark.parametrize("kb_session", [None], indirect=True) +async def test_get_data_lookup_success( + kb_session, mock_kb_client, unified_envelope, extract_payload +): + mock_kb_client.get_ai_knowledge_base_data_lookup = AsyncMock( + return_value=DATA_LOOKUP_FULL + ) + async with kb_session as session: + result = await session.call_tool( + "get_ai_knowledge_base_data_lookup", + {"data_lookup_id": "kb-3", "pipe_uuid": "pipe-uuid-1"}, + ) + assert result.isError is False + mock_kb_client.get_ai_knowledge_base_data_lookup.assert_awaited_once_with( + "kb-3", "pipe-uuid-1" + ) + payload = extract_payload(result) + assert payload["success"] is True + assert payload["data"]["knowledge_base_data_lookup"] == DATA_LOOKUP_FULL + + +@pytest.mark.anyio +@pytest.mark.parametrize("kb_session", [None], indirect=True) +async def test_get_data_lookup_not_found(kb_session, mock_kb_client, extract_payload): + mock_kb_client.get_ai_knowledge_base_data_lookup = AsyncMock(return_value={}) + async with kb_session as session: + result = await session.call_tool( + "get_ai_knowledge_base_data_lookup", + {"data_lookup_id": "kb-x", "pipe_uuid": "pipe-uuid-1"}, + ) + payload = extract_payload(result) + assert payload["success"] is False + message = tool_error_message(payload) + assert "not found" in message.lower() + assert "get_ai_knowledge_bases" in message + + +@pytest.mark.anyio +@pytest.mark.parametrize("kb_session", [None], indirect=True) +async def test_create_data_lookup_success( + kb_session, mock_kb_client, unified_envelope, extract_payload +): + mock_kb_client.create_ai_knowledge_base_data_lookup = AsyncMock( + return_value=DATA_LOOKUP_FULL + ) + async with kb_session as session: + result = await session.call_tool( + "create_ai_knowledge_base_data_lookup", + { + "pipe_uuid": "pipe-uuid-1", + "name": "Order lookup", + "description": "Find orders", + "source_repo_id": "303088927", + "output_fields": ["title"], + "conditions": [STATIC_CONDITION], + }, + ) + assert result.isError is False + payload = extract_payload(result) + assert payload["success"] is True + assert payload["data"]["knowledge_base_data_lookup"] == DATA_LOOKUP_FULL + mock_kb_client.create_ai_knowledge_base_data_lookup.assert_awaited_once_with( + "pipe-uuid-1", + name="Order lookup", + description="Find orders", + source_repo_id="303088927", + output_fields=["title"], + conditions=[STATIC_CONDITION], + search_query=None, + ) + + +@pytest.mark.anyio +@pytest.mark.parametrize("kb_session", [None], indirect=True) +async def test_create_data_lookup_definition_value_error_mapped( + kb_session, mock_kb_client, extract_payload +): + mock_kb_client.create_ai_knowledge_base_data_lookup = AsyncMock( + side_effect=ValueError( + "source_repo_id must be the numeric pipe ID (a pipe UUID is " + "accepted by the API but breaks the lookup when an agent runs it)" + ) + ) + async with kb_session as session: + result = await session.call_tool( + "create_ai_knowledge_base_data_lookup", + { + "pipe_uuid": "pipe-uuid-1", + "name": "Order lookup", + "description": "Find orders", + "source_repo_id": "5f66417e-5adc-4c83-908f-0b888493c847", + "output_fields": ["title"], + "conditions": [STATIC_CONDITION], + }, + ) + payload = extract_payload(result) + assert payload["success"] is False + assert "numeric pipe ID" in tool_error_message(payload) + + +@pytest.mark.anyio +@pytest.mark.parametrize("kb_session", [None], indirect=True) +async def test_update_data_lookup_success( + kb_session, mock_kb_client, unified_envelope, extract_payload +): + mock_kb_client.update_ai_knowledge_base_data_lookup = AsyncMock( + return_value=DATA_LOOKUP_FULL + ) + async with kb_session as session: + result = await session.call_tool( + "update_ai_knowledge_base_data_lookup", + { + "data_lookup_id": "kb-3", + "pipe_uuid": "pipe-uuid-1", + "source_repo_id": "303088927", + "output_fields": ["title"], + "conditions": [STATIC_CONDITION], + "name": "Renamed", + }, + ) + assert result.isError is False + payload = extract_payload(result) + assert payload["success"] is True + mock_kb_client.update_ai_knowledge_base_data_lookup.assert_awaited_once_with( + "kb-3", + "pipe-uuid-1", + source_repo_id="303088927", + output_fields=["title"], + conditions=[STATIC_CONDITION], + search_query=None, + name="Renamed", + description=None, + ) + + +@pytest.mark.anyio +@pytest.mark.parametrize("kb_session", [None], indirect=True) +async def test_update_data_lookup_requires_full_definition_in_schema(kb_session): + """The tool schema itself enforces the full-definition contract.""" + async with kb_session as session: + tools = await session.list_tools() + tool = next( + t for t in tools.tools if t.name == "update_ai_knowledge_base_data_lookup" + ) + assert set(tool.inputSchema["required"]) >= { + "data_lookup_id", + "pipe_uuid", + "source_repo_id", + "output_fields", + "conditions", + } + + +@pytest.mark.anyio +@pytest.mark.parametrize("kb_session", [None], indirect=True) +async def test_delete_data_lookup_preview_without_confirm( + kb_session, mock_kb_client, extract_payload +): + async with kb_session as session: + result = await session.call_tool( + "delete_ai_knowledge_base_data_lookup", + {"data_lookup_id": "kb-3", "pipe_uuid": "pipe-uuid-1"}, + ) + payload = extract_payload(result) + assert payload["success"] is False + assert payload["requires_confirmation"] is True + mock_kb_client.delete_ai_knowledge_base_data_lookup.assert_not_awaited() + + +@pytest.mark.anyio +@pytest.mark.parametrize("kb_session", [None], indirect=True) +async def test_delete_data_lookup_with_confirm_executes( + kb_session, mock_kb_client, unified_envelope, extract_payload +): + mock_kb_client.delete_ai_knowledge_base_data_lookup = AsyncMock( + return_value={"success": True, "errors": []} + ) + async with kb_session as session: + result = await session.call_tool( + "delete_ai_knowledge_base_data_lookup", + {"data_lookup_id": "kb-3", "pipe_uuid": "pipe-uuid-1", "confirm": True}, + ) + assert result.isError is False + payload = extract_payload(result) + assert payload["success"] is True + mock_kb_client.delete_ai_knowledge_base_data_lookup.assert_awaited_once_with( + "kb-3", "pipe-uuid-1" + ) + + +@pytest.mark.anyio +@pytest.mark.parametrize("kb_session", [None], indirect=True) +async def test_get_data_lookup_permission_denied_classified( + kb_session, mock_kb_client, extract_payload +): + mock_kb_client.get_ai_knowledge_base_data_lookup = AsyncMock( + side_effect=permission_denied_error() + ) + async with kb_session as session: + result = await session.call_tool( + "get_ai_knowledge_base_data_lookup", + {"data_lookup_id": "kb-3", "pipe_uuid": "pipe-uuid-1"}, + ) + payload = extract_payload(result) + assert payload["success"] is False + error = payload.get("error") or {} + details = error.get("details") or {} + assert details.get("kind") == "permission_denied" diff --git a/packages/sdk/src/pipefy_sdk/__init__.py b/packages/sdk/src/pipefy_sdk/__init__.py index 7d4b2685..c3a03eb5 100644 --- a/packages/sdk/src/pipefy_sdk/__init__.py +++ b/packages/sdk/src/pipefy_sdk/__init__.py @@ -33,6 +33,7 @@ CreateAiAutomationInput, CreatePortalElementInput, CreateSendTaskAutomationInput, + DataLookupCondition, DeleteCommentInput, MemberInvite, NonBlankStr, @@ -71,6 +72,7 @@ AiAgentGraphPayload, CardSearch, KnowledgeBaseAccessProbeResult, + KnowledgeBaseDataLookupPayload, KnowledgeBaseDeleteResult, KnowledgeBaseDocumentPayload, KnowledgeBaseDocumentUploadError, @@ -115,10 +117,12 @@ "CreateAiAutomationInput", "CreatePortalElementInput", "CreateSendTaskAutomationInput", + "DataLookupCondition", "DeleteCommentInput", "GraphQLProblem", "GraphQLProblemKind", "KnowledgeBaseAccessProbeResult", + "KnowledgeBaseDataLookupPayload", "KnowledgeBaseDeleteResult", "KnowledgeBaseDocumentPayload", "KnowledgeBaseDocumentUploadError", diff --git a/packages/sdk/src/pipefy_sdk/client.py b/packages/sdk/src/pipefy_sdk/client.py index 2f26a16c..37d5ae8d 100644 --- a/packages/sdk/src/pipefy_sdk/client.py +++ b/packages/sdk/src/pipefy_sdk/client.py @@ -35,6 +35,7 @@ AttachmentTarget, AttachmentUploadResult, ) +from pipefy_sdk.models.knowledge_base import DataLookupCondition from pipefy_sdk.services.advanced_automations_service import AdvancedAutomationsService from pipefy_sdk.services.ai_agent_service import AiAgentService from pipefy_sdk.services.attachment_service import AttachmentService @@ -83,6 +84,7 @@ AutomationServiceResult, CardSearch, KnowledgeBaseAccessProbeResult, + KnowledgeBaseDataLookupPayload, KnowledgeBaseDeleteResult, KnowledgeBaseDocumentPayload, KnowledgeBasePayload, @@ -1073,6 +1075,68 @@ async def delete_ai_knowledge_base_document( document_id, pipe_uuid ) + async def get_ai_knowledge_base_data_lookup( + self, data_lookup_id: str, pipe_uuid: str + ) -> KnowledgeBaseDataLookupPayload: + """Fetch one pipe-scoped knowledge base data lookup by id (reads never include conditions).""" + return await self._knowledge_base_service.get_ai_knowledge_base_data_lookup( + data_lookup_id, pipe_uuid + ) + + async def create_ai_knowledge_base_data_lookup( + self, + pipe_uuid: str, + *, + name: str, + description: str, + source_repo_id: str, + output_fields: list[str], + conditions: list[dict[str, Any] | DataLookupCondition], + search_query: str | None = None, + ) -> KnowledgeBaseDataLookupPayload: + """Create a pipe-scoped knowledge base data lookup (definition validated client-side).""" + return await self._knowledge_base_service.create_ai_knowledge_base_data_lookup( + pipe_uuid, + name=name, + description=description, + source_repo_id=source_repo_id, + output_fields=output_fields, + conditions=conditions, + search_query=search_query, + ) + + async def update_ai_knowledge_base_data_lookup( + self, + data_lookup_id: str, + pipe_uuid: str, + *, + source_repo_id: str, + output_fields: list[str], + conditions: list[dict[str, Any] | DataLookupCondition], + search_query: str | None = None, + name: str | None = None, + description: str | None = None, + ) -> KnowledgeBaseDataLookupPayload: + """Update a knowledge base data lookup (full replacement of the definition).""" + return await self._knowledge_base_service.update_ai_knowledge_base_data_lookup( + data_lookup_id, + pipe_uuid, + source_repo_id=source_repo_id, + output_fields=output_fields, + conditions=conditions, + search_query=search_query, + name=name, + description=description, + ) + + async def delete_ai_knowledge_base_data_lookup( + self, data_lookup_id: str, pipe_uuid: str + ) -> KnowledgeBaseDeleteResult: + """Delete a pipe-scoped knowledge base data lookup (permanent).""" + return await self._knowledge_base_service.delete_ai_knowledge_base_data_lookup( + data_lookup_id, pipe_uuid + ) + async def validate_knowledge_base_access( self, pipe_uuid: str ) -> KnowledgeBaseAccessProbeResult: diff --git a/packages/sdk/src/pipefy_sdk/models/__init__.py b/packages/sdk/src/pipefy_sdk/models/__init__.py index 308ffe45..f354d0f0 100644 --- a/packages/sdk/src/pipefy_sdk/models/__init__.py +++ b/packages/sdk/src/pipefy_sdk/models/__init__.py @@ -35,6 +35,7 @@ DeleteCommentInput, UpdateCommentInput, ) +from pipefy_sdk.models.knowledge_base import DataLookupCondition from pipefy_sdk.models.member_invite import MemberInvite from pipefy_sdk.models.portal import ( CreatePortalElementInput, @@ -63,6 +64,7 @@ "CreateAiAutomationInput", "CreatePortalElementInput", "CreateSendTaskAutomationInput", + "DataLookupCondition", "DeleteCommentInput", "MemberInvite", "NonBlankStr", diff --git a/packages/sdk/src/pipefy_sdk/models/knowledge_base.py b/packages/sdk/src/pipefy_sdk/models/knowledge_base.py new file mode 100644 index 00000000..7565ba48 --- /dev/null +++ b/packages/sdk/src/pipefy_sdk/models/knowledge_base.py @@ -0,0 +1,104 @@ +"""Pydantic models for knowledge base data lookup inputs. + +A data lookup's conditions are validated here, at the boundary, because the +backend only checks ``field`` and ``operator`` at write time: a static +condition saved without a ``value`` (or with a non-string one) is accepted by +the API and then breaks the lookup when an agent runs it. Parsing into +:class:`DataLookupCondition` up front keeps definitions that cannot work from +ever being persisted. +""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from pipefy_sdk.models.validators import NonBlankStr + +# Attribute name → camelCase alias, so error messages name the field the same +# way the tool/docs teach it (``inputName``, not ``input_name``). +_FILL_WITH_AI_FIELDS = { + "input_name": "inputName", + "input_type": "inputType", + "input_description": "inputDescription", +} + + +class DataLookupCondition(BaseModel): + """One record-filter condition of a knowledge base data lookup. + + Accepts snake_case or camelCase keys (``usingFillWithAi``, ``inputName``, + …); unknown keys are rejected so a typo cannot silently drop a constraint. + Two mutually exclusive shapes exist: + + - **Static** (``using_fill_with_ai`` false, the default): ``value`` is + required and must be a non-blank string — the agent runtime compares + string values and rejects a condition saved without one. + - **AI-filled** (``using_fill_with_ai`` true): ``input_name``, + ``input_type``, and ``input_description`` are required (they describe + the input the AI asks for at runtime) and ``value`` must be omitted + (the runtime replaces it with the AI-provided input, so a static value + here would be silently ignored). + + ``operator`` is an opaque backend string (e.g. ``"eq"``, ``"contains"``); + ``input_type`` likewise (e.g. ``"text"``, ``"number"``). ``attribute`` and + ``field_uuid`` are optional passthrough keys used by backend-specific + search modes; ``field`` is required either way (the backend rejects a + condition without one). + """ + + model_config = ConfigDict(populate_by_name=True, extra="forbid") + + field: NonBlankStr + operator: NonBlankStr + value: NonBlankStr | None = None + using_fill_with_ai: bool = Field(default=False, alias="usingFillWithAi") + input_name: NonBlankStr | None = Field(default=None, alias="inputName") + input_type: NonBlankStr | None = Field(default=None, alias="inputType") + input_description: NonBlankStr | None = Field( + default=None, alias="inputDescription" + ) + attribute: NonBlankStr | None = None + field_uuid: NonBlankStr | None = Field(default=None, alias="fieldUuid") + + @model_validator(mode="after") + def _check_shape(self) -> DataLookupCondition: + if self.using_fill_with_ai: + missing = [ + alias + for name, alias in _FILL_WITH_AI_FIELDS.items() + if getattr(self, name) is None + ] + if missing: + raise ValueError( + "an AI-filled condition (usingFillWithAi=true) requires " + f"non-blank {', '.join(missing)}" + ) + if self.value is not None: + raise ValueError( + "an AI-filled condition (usingFillWithAi=true) must not set " + "'value'; the AI provides it at runtime" + ) + else: + if self.value is None: + raise ValueError( + "a static condition requires a non-blank string 'value' " + "(or set usingFillWithAi=true with inputName/inputType/" + "inputDescription)" + ) + extras = [ + alias + for name, alias in _FILL_WITH_AI_FIELDS.items() + if getattr(self, name) is not None + ] + if extras: + raise ValueError( + f"{', '.join(extras)} only apply to AI-filled conditions; " + "set usingFillWithAi=true or drop them" + ) + return self + + def to_input(self) -> dict[str, Any]: + """Serialize to the GraphQL condition input (camelCase, no nulls).""" + return self.model_dump(by_alias=True, exclude_none=True) diff --git a/packages/sdk/src/pipefy_sdk/models/validators.py b/packages/sdk/src/pipefy_sdk/models/validators.py index af186f2d..2599b2bf 100644 --- a/packages/sdk/src/pipefy_sdk/models/validators.py +++ b/packages/sdk/src/pipefy_sdk/models/validators.py @@ -6,9 +6,20 @@ from pydantic import BeforeValidator, Field + +def _strip_if_str(v: object) -> object: + """Strip strings; pass anything else through for the ``str`` core check. + + ``BeforeValidator(str.strip)`` would raise a raw ``TypeError`` on non-string + input, escaping pydantic's error collection; passing the value through lets + the field's ``str`` type reject it as a normal ``ValidationError``. + """ + return v.strip() if isinstance(v, str) else v + + NonBlankStr = Annotated[ str, - BeforeValidator(str.strip), + BeforeValidator(_strip_if_str), Field(min_length=1, description="Non-empty string after stripping whitespace"), ] diff --git a/packages/sdk/src/pipefy_sdk/queries/knowledge_base_queries.py b/packages/sdk/src/pipefy_sdk/queries/knowledge_base_queries.py index c0c540ab..d8d06dee 100644 --- a/packages/sdk/src/pipefy_sdk/queries/knowledge_base_queries.py +++ b/packages/sdk/src/pipefy_sdk/queries/knowledge_base_queries.py @@ -1,4 +1,4 @@ -"""GraphQL operations for pipe-scoped AI knowledge bases (list, plain text, documents). +"""GraphQL operations for pipe-scoped AI knowledge bases (list, plain text, documents, data lookups). All operations are pipe-scoped by ``pipeUuid`` (the pipe UUID, not the numeric id). The list query returns every knowledge base item on a pipe (plain text, @@ -177,15 +177,91 @@ """ ) +# Data lookup reads (and create/update payloads) never include ``conditions``: +# the backend stores them but the GraphQL type does not expose them, so callers +# must keep their lookup definition as the client-side source of truth. +GET_AI_KNOWLEDGE_BASE_DATA_LOOKUP_QUERY = gql( + """ + query aiKnowledgeBaseDataLookup($id: ID!, $pipeUuid: ID!) { + aiKnowledgeBaseDataLookup(id: $id, pipeUuid: $pipeUuid) { + id + name + description + sourceRepoId + searchQuery + outputFields + updatedAt + } + } + """ +) + +CREATE_AI_KNOWLEDGE_BASE_DATA_LOOKUP_MUTATION = gql( + """ + mutation createAiKnowledgeBaseDataLookup( + $input: CreateKnowledgeBaseDataLookupInput! + ) { + createAiKnowledgeBaseDataLookup(input: $input) { + knowledgeBaseDataLookup { + id + name + description + sourceRepoId + searchQuery + outputFields + updatedAt + } + } + } + """ +) + +UPDATE_AI_KNOWLEDGE_BASE_DATA_LOOKUP_MUTATION = gql( + """ + mutation updateAiKnowledgeBaseDataLookup( + $input: UpdateKnowledgeBaseDataLookupInput! + ) { + updateAiKnowledgeBaseDataLookup(input: $input) { + knowledgeBaseDataLookup { + id + name + description + sourceRepoId + searchQuery + outputFields + updatedAt + } + } + } + """ +) + +DELETE_AI_KNOWLEDGE_BASE_DATA_LOOKUP_MUTATION = gql( + """ + mutation deleteAiKnowledgeBaseDataLookup( + $input: DeleteKnowledgeBaseDataLookupInput! + ) { + deleteAiKnowledgeBaseDataLookup(input: $input) { + success + errors + } + } + """ +) + __all__ = [ + "CREATE_AI_KNOWLEDGE_BASE_DATA_LOOKUP_MUTATION", "CREATE_AI_KNOWLEDGE_BASE_DOCUMENT_MUTATION", "CREATE_AI_KNOWLEDGE_BASE_PLAIN_TEXT_MUTATION", + "DELETE_AI_KNOWLEDGE_BASE_DATA_LOOKUP_MUTATION", "DELETE_AI_KNOWLEDGE_BASE_DOCUMENT_MUTATION", "DELETE_AI_KNOWLEDGE_BASE_PLAIN_TEXT_MUTATION", "GET_AI_KNOWLEDGE_BASES_QUERY", + "GET_AI_KNOWLEDGE_BASE_DATA_LOOKUP_QUERY", "GET_AI_KNOWLEDGE_BASE_DOCUMENT_QUERY", "GET_AI_KNOWLEDGE_BASE_PLAIN_TEXT_QUERY", "GET_PIPE_ORGANIZATION_QUERY", + "UPDATE_AI_KNOWLEDGE_BASE_DATA_LOOKUP_MUTATION", "UPDATE_AI_KNOWLEDGE_BASE_DOCUMENT_MUTATION", "UPDATE_AI_KNOWLEDGE_BASE_PLAIN_TEXT_MUTATION", ] diff --git a/packages/sdk/src/pipefy_sdk/services/knowledge_base_service.py b/packages/sdk/src/pipefy_sdk/services/knowledge_base_service.py index 3d0123be..131da9df 100644 --- a/packages/sdk/src/pipefy_sdk/services/knowledge_base_service.py +++ b/packages/sdk/src/pipefy_sdk/services/knowledge_base_service.py @@ -1,11 +1,12 @@ -"""Service for pipe-scoped AI knowledge bases: list, plain text/document CRUD, probe. +"""Service for pipe-scoped AI knowledge bases: list, plain text/document/data lookup CRUD, probe. Client-side limits fail fast before the network call so callers get an actionable ``ValueError`` instead of a backend 422. The limits mirror the downstream ``DataSource``/``KnowledgeBasePlainText`` model validations: ``content`` and ``name`` are required and ``content`` is capped at 3500 chars; ``description`` is required (the GraphQL schema marks it optional, but the -backend rejects a blank one) and capped at 900 chars. Updates omit any field the +backend rejects a blank one) and capped at 900 chars — one shared rule for +every knowledge base kind. Plain-text/document updates omit any field the caller leaves unset, but validate every field they do pass. Documents are created one-shot from a local PDF: read the file (``.pdf`` @@ -15,6 +16,10 @@ Every stage raises :class:`KnowledgeBaseDocumentUploadError` tagged with the step that failed. The presign and S3 PUT primitives are shared with the attachment upload pipeline. + +Data lookups carry a full definition (source pipe, output fields, conditions) +that the API stores but never returns on reads, and every update replaces that +definition wholesale — see the data lookup methods for the resulting contract. """ from __future__ import annotations @@ -24,6 +29,7 @@ from typing import Any from pipefy_infra.filesystem import LocalFile, LocalFileError +from pydantic import ValidationError as PydanticValidationError from pipefy_sdk.graphql_executor import GraphQLExecutor from pipefy_sdk.graphql_problem import ( @@ -31,16 +37,21 @@ GraphQLProblemKind, classify_graphql_error_dicts, ) +from pipefy_sdk.models.knowledge_base import DataLookupCondition from pipefy_sdk.queries.attachment_queries import CREATE_PRESIGNED_URL_MUTATION from pipefy_sdk.queries.knowledge_base_queries import ( + CREATE_AI_KNOWLEDGE_BASE_DATA_LOOKUP_MUTATION, CREATE_AI_KNOWLEDGE_BASE_DOCUMENT_MUTATION, CREATE_AI_KNOWLEDGE_BASE_PLAIN_TEXT_MUTATION, + DELETE_AI_KNOWLEDGE_BASE_DATA_LOOKUP_MUTATION, DELETE_AI_KNOWLEDGE_BASE_DOCUMENT_MUTATION, DELETE_AI_KNOWLEDGE_BASE_PLAIN_TEXT_MUTATION, + GET_AI_KNOWLEDGE_BASE_DATA_LOOKUP_QUERY, GET_AI_KNOWLEDGE_BASE_DOCUMENT_QUERY, GET_AI_KNOWLEDGE_BASE_PLAIN_TEXT_QUERY, GET_AI_KNOWLEDGE_BASES_QUERY, GET_PIPE_ORGANIZATION_QUERY, + UPDATE_AI_KNOWLEDGE_BASE_DATA_LOOKUP_MUTATION, UPDATE_AI_KNOWLEDGE_BASE_DOCUMENT_MUTATION, UPDATE_AI_KNOWLEDGE_BASE_PLAIN_TEXT_MUTATION, ) @@ -51,6 +62,7 @@ ) from pipefy_sdk.services.types import ( KnowledgeBaseAccessProbeResult, + KnowledgeBaseDataLookupPayload, KnowledgeBaseDeleteResult, KnowledgeBaseDocumentPayload, KnowledgeBaseDocumentUploadError, @@ -59,20 +71,25 @@ ) MAX_PLAIN_TEXT_CONTENT_LENGTH = 3500 -MAX_PLAIN_TEXT_DESCRIPTION_LENGTH = 900 + +# One shared cap for every knowledge base kind (plain text, document, data +# lookup): the backend enforces it once, on the common ``DataSource`` model. +MAX_KB_DESCRIPTION_LENGTH = 900 # Document policy, enforced client-side because the backend skips PDF/size # validation when a document arrives as a URL (the only path these tools use). -# The description cap matches the shared ``DataSource`` rule (max 900). DOCUMENT_PDF_SUFFIX = ".pdf" MAX_DOCUMENT_SIZE_BYTES = 20 * 1024 * 1024 -MAX_DOCUMENT_DESCRIPTION_LENGTH = 900 DOCUMENT_CONTENT_TYPE = "application/pdf" +# Backend cap on a data lookup's output fields. +MAX_DATA_LOOKUP_OUTPUT_FIELDS = 30 + _PROBE_READ_ONLY_NOTE = ( "Read access confirmed. This proves knowledge-base list/read access only " - "(read_ai_agents on the pipe), never write entitlement; plain-text " - "create/update/delete require manage_ai_agents and may still be denied." + "(read_ai_agents on the pipe), never write entitlement; knowledge base " + "writes (plain text, document, data lookup) require manage_ai_agents and " + "may still be denied." ) @@ -106,7 +123,7 @@ def _problem_dict(problem: GraphQLProblem) -> dict[str, Any]: class KnowledgeBaseService: - """Pipe-scoped AI knowledge base reads, plain-text/document writes, and the probe.""" + """Pipe-scoped AI knowledge base reads, plain-text/document/data-lookup writes, and the probe.""" def __init__( self, @@ -185,13 +202,18 @@ async def create_ai_knowledge_base_plain_text( content, "content", MAX_PLAIN_TEXT_CONTENT_LENGTH ), "description": _require_bounded( - description, "description", MAX_PLAIN_TEXT_DESCRIPTION_LENGTH + description, "description", MAX_KB_DESCRIPTION_LENGTH ), } response = await self._executor.execute_query( CREATE_AI_KNOWLEDGE_BASE_PLAIN_TEXT_MUTATION, {"input": input_obj} ) - return _unwrap_plain_text(response, "createAiKnowledgeBasePlainText") + return _unwrap_kb_write( + response, + "createAiKnowledgeBasePlainText", + "knowledgeBasePlainText", + "plain text", + ) async def update_ai_knowledge_base_plain_text( self, @@ -233,12 +255,17 @@ async def update_ai_knowledge_base_plain_text( ) if description is not None: input_obj["description"] = _require_bounded( - description, "description", MAX_PLAIN_TEXT_DESCRIPTION_LENGTH + description, "description", MAX_KB_DESCRIPTION_LENGTH ) response = await self._executor.execute_query( UPDATE_AI_KNOWLEDGE_BASE_PLAIN_TEXT_MUTATION, {"input": input_obj} ) - return _unwrap_plain_text(response, "updateAiKnowledgeBasePlainText") + return _unwrap_kb_write( + response, + "updateAiKnowledgeBasePlainText", + "knowledgeBasePlainText", + "plain text", + ) async def delete_ai_knowledge_base_plain_text( self, plain_text_id: str, pipe_uuid: str @@ -259,13 +286,7 @@ async def delete_ai_knowledge_base_plain_text( response = await self._executor.execute_query( DELETE_AI_KNOWLEDGE_BASE_PLAIN_TEXT_MUTATION, {"input": input_obj} ) - payload = response.get("deleteAiKnowledgeBasePlainText") - payload = payload if isinstance(payload, dict) else {} - errors = payload.get("errors") - return { - "success": bool(payload.get("success")), - "errors": [str(e) for e in errors] if isinstance(errors, list) else [], - } + return _delete_result(response, "deleteAiKnowledgeBasePlainText") async def get_ai_knowledge_base_document( self, document_id: str, pipe_uuid: str @@ -325,7 +346,7 @@ async def create_ai_knowledge_base_document( pipe_uuid = _require_non_blank(pipe_uuid, "pipe_uuid") name = _require_non_blank(name, "name") description = _require_bounded( - description, "description", MAX_DOCUMENT_DESCRIPTION_LENGTH + description, "description", MAX_KB_DESCRIPTION_LENGTH ) file = await self._read_pdf(file_path) @@ -342,7 +363,12 @@ async def create_ai_knowledge_base_document( response = await self._executor.execute_query( CREATE_AI_KNOWLEDGE_BASE_DOCUMENT_MUTATION, {"input": input_obj} ) - return _unwrap_document(response, "createAiKnowledgeBaseDocument") + return _unwrap_kb_write( + response, + "createAiKnowledgeBaseDocument", + "knowledgeBaseDocument", + "document", + ) except Exception as exc: raise KnowledgeBaseDocumentUploadError( f"Document create failed: {exc}", step="kb_create" @@ -381,12 +407,17 @@ async def update_ai_knowledge_base_document( input_obj["name"] = _require_non_blank(name, "name") if description is not None: input_obj["description"] = _require_bounded( - description, "description", MAX_DOCUMENT_DESCRIPTION_LENGTH + description, "description", MAX_KB_DESCRIPTION_LENGTH ) response = await self._executor.execute_query( UPDATE_AI_KNOWLEDGE_BASE_DOCUMENT_MUTATION, {"input": input_obj} ) - return _unwrap_document(response, "updateAiKnowledgeBaseDocument") + return _unwrap_kb_write( + response, + "updateAiKnowledgeBaseDocument", + "knowledgeBaseDocument", + "document", + ) async def delete_ai_knowledge_base_document( self, document_id: str, pipe_uuid: str @@ -407,13 +438,163 @@ async def delete_ai_knowledge_base_document( response = await self._executor.execute_query( DELETE_AI_KNOWLEDGE_BASE_DOCUMENT_MUTATION, {"input": input_obj} ) - payload = response.get("deleteAiKnowledgeBaseDocument") - payload = payload if isinstance(payload, dict) else {} - errors = payload.get("errors") - return { - "success": bool(payload.get("success")), - "errors": [str(e) for e in errors] if isinstance(errors, list) else [], + return _delete_result(response, "deleteAiKnowledgeBaseDocument") + + async def get_ai_knowledge_base_data_lookup( + self, data_lookup_id: str, pipe_uuid: str + ) -> KnowledgeBaseDataLookupPayload: + """Fetch one pipe-scoped knowledge base data lookup by id. + + Args: + data_lookup_id: Knowledge base data lookup ID (from the list). + pipe_uuid: Pipe UUID (not the numeric id). + + Returns: + The data lookup dict; empty dict when the API resolves nothing. + ``conditions`` are never included — the API does not expose them + on reads, so keep the definition client-side. + """ + variables = { + "id": _require_non_blank(data_lookup_id, "data_lookup_id"), + "pipeUuid": _require_non_blank(pipe_uuid, "pipe_uuid"), + } + response = await self._executor.execute_query( + GET_AI_KNOWLEDGE_BASE_DATA_LOOKUP_QUERY, variables + ) + data_lookup = response.get("aiKnowledgeBaseDataLookup") + return data_lookup if isinstance(data_lookup, dict) else {} + + async def create_ai_knowledge_base_data_lookup( + self, + pipe_uuid: str, + *, + name: str, + description: str, + source_repo_id: str, + output_fields: list[str], + conditions: list[dict[str, Any] | DataLookupCondition], + search_query: str | None = None, + ) -> KnowledgeBaseDataLookupPayload: + """Create a pipe-scoped knowledge base data lookup. + + The definition is validated client-side (see + :func:`_parse_data_lookup_definition`) because the backend accepts + several shapes that only fail later, when an agent runs the lookup. + + Args: + pipe_uuid: Pipe UUID (not the numeric id) that owns the lookup. + name: Display name (required, non-blank). + description: Description (required by the backend, 1-900 chars). + source_repo_id: Numeric ID of the source pipe to look up data from. + output_fields: Field IDs returned from matching records (1-30). + conditions: Record-filter conditions (at least one); dicts are + parsed into :class:`DataLookupCondition`. + search_query: Optional backend-defined search mode marker; leave + unset unless you know the backend value you need. + + Returns: + The created data lookup dict (without ``conditions``). + """ + input_obj = { + "pipeUuid": _require_non_blank(pipe_uuid, "pipe_uuid"), + "name": _require_non_blank(name, "name"), + "description": _require_bounded( + description, "description", MAX_KB_DESCRIPTION_LENGTH + ), + **_parse_data_lookup_definition( + source_repo_id, output_fields, conditions, search_query + ), + } + response = await self._executor.execute_query( + CREATE_AI_KNOWLEDGE_BASE_DATA_LOOKUP_MUTATION, {"input": input_obj} + ) + return _unwrap_kb_write( + response, + "createAiKnowledgeBaseDataLookup", + "knowledgeBaseDataLookup", + "data lookup", + ) + + async def update_ai_knowledge_base_data_lookup( + self, + data_lookup_id: str, + pipe_uuid: str, + *, + source_repo_id: str, + output_fields: list[str], + conditions: list[dict[str, Any] | DataLookupCondition], + search_query: str | None = None, + name: str | None = None, + description: str | None = None, + ) -> KnowledgeBaseDataLookupPayload: + """Update a pipe-scoped knowledge base data lookup (full replacement). + + Every update rewrites the lookup's whole definition: the backend + replaces the stored definition with exactly what the mutation carries, + so the complete definition (``source_repo_id``, ``output_fields``, + ``conditions``) is required on every call and an omitted + ``search_query`` is cleared. Reads never return ``conditions``, so the + caller's own copy of the definition is the source of truth. Only + ``name``/``description`` may be omitted to keep their stored values. + + Args: + data_lookup_id: Data lookup ID to update. + pipe_uuid: Pipe UUID (not the numeric id). + source_repo_id: Numeric ID of the source pipe (required — omitting + it would silently strip the source from the stored definition). + output_fields: Field IDs returned from matching records (1-30). + conditions: Record-filter conditions (at least one). + search_query: Optional backend-defined search mode marker; omitted + means cleared. + name: New name (non-blank when given; unset keeps stored value). + description: New description (1-900 chars when given). + + Returns: + The updated data lookup dict (without ``conditions``). + """ + input_obj: dict[str, Any] = { + "pipeUuid": _require_non_blank(pipe_uuid, "pipe_uuid"), + "dataLookupId": _require_non_blank(data_lookup_id, "data_lookup_id"), + **_parse_data_lookup_definition( + source_repo_id, output_fields, conditions, search_query + ), + } + if name is not None: + input_obj["name"] = _require_non_blank(name, "name") + if description is not None: + input_obj["description"] = _require_bounded( + description, "description", MAX_KB_DESCRIPTION_LENGTH + ) + response = await self._executor.execute_query( + UPDATE_AI_KNOWLEDGE_BASE_DATA_LOOKUP_MUTATION, {"input": input_obj} + ) + return _unwrap_kb_write( + response, + "updateAiKnowledgeBaseDataLookup", + "knowledgeBaseDataLookup", + "data lookup", + ) + + async def delete_ai_knowledge_base_data_lookup( + self, data_lookup_id: str, pipe_uuid: str + ) -> KnowledgeBaseDeleteResult: + """Delete a pipe-scoped knowledge base data lookup (permanent). + + Args: + data_lookup_id: Data lookup ID to delete. + pipe_uuid: Pipe UUID (not the numeric id). + + Returns: + ``success`` and any backend ``errors``. + """ + input_obj = { + "pipeUuid": _require_non_blank(pipe_uuid, "pipe_uuid"), + "dataLookupId": _require_non_blank(data_lookup_id, "data_lookup_id"), } + response = await self._executor.execute_query( + DELETE_AI_KNOWLEDGE_BASE_DATA_LOOKUP_MUTATION, {"input": input_obj} + ) + return _delete_result(response, "deleteAiKnowledgeBaseDataLookup") async def _read_pdf(self, file_path: str | Path) -> LocalFile: """Read and validate the local PDF (``file_read`` step).""" @@ -522,7 +703,7 @@ async def validate_knowledge_base_access( Runs the list query through the partial-success executor and classifies any GraphQL errors instead of raising. A green result proves read access only (``read_ai_agents``), never the ``manage_ai_agents`` entitlement the - plain-text writes require — spelled out in ``note``. + knowledge base writes require — spelled out in ``note``. """ variables = {"pipeUuid": _require_non_blank(pipe_uuid, "pipe_uuid")} result = await self._executor.execute(GET_AI_KNOWLEDGE_BASES_QUERY, variables) @@ -555,39 +736,111 @@ async def validate_knowledge_base_access( return probe -def _unwrap_plain_text( - response: dict[str, Any], mutation_key: str -) -> KnowledgeBasePlainTextPayload: - """Unwrap a write mutation's plain text; a missing payload is a failure. +def _parse_data_lookup_definition( + source_repo_id: str, + output_fields: list[str], + conditions: list[dict[str, Any] | DataLookupCondition], + search_query: str | None, +) -> dict[str, Any]: + """Parse a data lookup definition into the mutation's input fields. + + Enforced client-side because the backend under-validates in ways that only + surface when an agent later runs the lookup: + + - ``source_repo_id`` must be the numeric pipe ID. The API would accept a + pipe UUID on create, but the lookup then breaks when an agent runs it — + long after the write succeeded. + - Static conditions must carry a string ``value`` (write-time validation + only checks ``field``/``operator``; the runtime hard-fails on a missing + or non-string value). AI-filled conditions need their input trio. Both + rules live on :class:`DataLookupCondition`. + - ``output_fields`` must be non-empty (backend-enforced) and at most 30 + (the backend ``DataLookup`` model cap), each entry a non-blank field ID. + + Raises: + ValueError: On any rule violation, with the offending index for + per-item failures. + """ + repo_id = _require_non_blank(source_repo_id, "source_repo_id") + # ASCII digits only: str.isdigit()/isdecimal() also accept Unicode digit + # characters (superscripts, other scripts) that are not a valid pipe ID. + if not (repo_id.isascii() and repo_id.isdigit()): + raise ValueError( + "source_repo_id must be the numeric pipe ID (a pipe UUID is " + "accepted by the API but breaks the lookup when an agent runs it)" + ) + if not output_fields: + raise ValueError("output_fields must contain at least one field ID") + if len(output_fields) > MAX_DATA_LOOKUP_OUTPUT_FIELDS: + raise ValueError( + f"output_fields must have at most {MAX_DATA_LOOKUP_OUTPUT_FIELDS} " + f"entries (got {len(output_fields)})" + ) + fields = [ + _require_non_blank(field, f"output_fields[{i}]") + for i, field in enumerate(output_fields) + ] + if not conditions: + raise ValueError("conditions must contain at least one condition") + parsed_conditions: list[DataLookupCondition] = [] + for i, condition in enumerate(conditions): + if isinstance(condition, DataLookupCondition): + parsed_conditions.append(condition) + continue + try: + parsed_conditions.append(DataLookupCondition.model_validate(condition)) + except PydanticValidationError as exc: + details = "; ".join(_condition_error_detail(err) for err in exc.errors()) + raise ValueError(f"conditions[{i}]: {details}") from exc + definition: dict[str, Any] = { + "sourceRepoId": repo_id, + "outputFields": fields, + "conditions": [c.to_input() for c in parsed_conditions], + } + if search_query is not None: + definition["searchQuery"] = _require_non_blank(search_query, "search_query") + return definition + + +def _condition_error_detail(error: dict[str, Any]) -> str: + """One human line per pydantic error, without the pydantic URL noise.""" + location = ".".join(str(part) for part in error.get("loc", ())) + message = error.get("msg", "invalid value") + return f"{location}: {message}" if location else message + - A write that returns no GraphQL errors but a null ``knowledgeBasePlainText`` - must not read as success — the caller cannot know whether it persisted. +def _unwrap_kb_write( + response: dict[str, Any], mutation_key: str, payload_key: str, noun: str +) -> dict[str, Any]: + """Unwrap a KB write mutation's payload; a missing payload is a failure. + + A write that returns no GraphQL errors but a null payload must not read as + success — the caller cannot know whether it persisted. Shared by every + knowledge base kind (plain text, document, data lookup) so the contract + cannot drift between them. """ payload = response.get(mutation_key) if isinstance(payload, dict): - plain_text = payload.get("knowledgeBasePlainText") - if isinstance(plain_text, dict) and plain_text: - return plain_text + node = payload.get(payload_key) + if isinstance(node, dict) and node: + return node raise ValueError( - f"{mutation_key} returned no plain text payload; " - "the write may not have persisted." + f"{mutation_key} returned no {noun} payload; the write may not have persisted." ) -def _unwrap_document( +def _delete_result( response: dict[str, Any], mutation_key: str -) -> KnowledgeBaseDocumentPayload: - """Unwrap a write mutation's document; a missing payload is a failure. +) -> KnowledgeBaseDeleteResult: + """Coerce a KB delete mutation's payload onto ``success``/``errors``. - A write that returns no GraphQL errors but a null ``knowledgeBaseDocument`` - must not read as success — the caller cannot know whether it persisted. + Shared by every knowledge base kind: a non-dict payload reads as failure, + non-list ``errors`` are dropped, entries are stringified. """ payload = response.get(mutation_key) - if isinstance(payload, dict): - document = payload.get("knowledgeBaseDocument") - if isinstance(document, dict) and document: - return document - raise ValueError( - f"{mutation_key} returned no document payload; " - "the write may not have persisted." - ) + payload = payload if isinstance(payload, dict) else {} + errors = payload.get("errors") + return { + "success": bool(payload.get("success")), + "errors": [str(e) for e in errors] if isinstance(errors, list) else [], + } diff --git a/packages/sdk/src/pipefy_sdk/services/types.py b/packages/sdk/src/pipefy_sdk/services/types.py index 3cb3d7ab..3ff55f44 100644 --- a/packages/sdk/src/pipefy_sdk/services/types.py +++ b/packages/sdk/src/pipefy_sdk/services/types.py @@ -183,6 +183,24 @@ class KnowledgeBaseDocumentPayload(TypedDict, total=False): updatedAt: str | None +class KnowledgeBaseDataLookupPayload(TypedDict, total=False): + """A single pipe-scoped knowledge base data lookup. + + Returned by the get query and by create/update. ``conditions`` are never + part of the payload: the API stores them but does not expose them on + reads, so callers must keep the lookup definition client-side. + ``sourceRepoId`` is the numeric ID of the source pipe. + """ + + id: str + name: str + description: str | None + sourceRepoId: str | None + searchQuery: str | None + outputFields: list[str] | None + updatedAt: str | None + + KnowledgeBaseDocumentUploadStep = Literal[ "file_read", "presigned_url", @@ -220,7 +238,7 @@ class KnowledgeBaseAccessProbeResult(TypedDict, total=False): ``ok`` is True when the list query succeeded (proves *read* access only — ``read_ai_agents`` on the pipe — never the ``manage_ai_agents`` entitlement - that plain-text create/update/delete require). ``knowledge_base_count`` is + that knowledge base create/update/delete require). ``knowledge_base_count`` is the number of items visible on the pipe. On failure, ``problem`` carries the classified GraphQL problem (kind/message/code/correlation_id). """ diff --git a/packages/sdk/tests/services/test_knowledge_base_service.py b/packages/sdk/tests/services/test_knowledge_base_service.py index 1a47f082..c13a3405 100644 --- a/packages/sdk/tests/services/test_knowledge_base_service.py +++ b/packages/sdk/tests/services/test_knowledge_base_service.py @@ -1,4 +1,4 @@ -"""Unit tests for KnowledgeBaseService (list, plain text CRUD, access probe).""" +"""Unit tests for KnowledgeBaseService (list, plain text/document/data lookup CRUD, probe).""" from __future__ import annotations @@ -8,9 +8,8 @@ from pipefy_sdk.graphql_executor import GraphQLResult from pipefy_sdk.services import knowledge_base_service as kb_module from pipefy_sdk.services.knowledge_base_service import ( - MAX_DOCUMENT_DESCRIPTION_LENGTH, + MAX_KB_DESCRIPTION_LENGTH, MAX_PLAIN_TEXT_CONTENT_LENGTH, - MAX_PLAIN_TEXT_DESCRIPTION_LENGTH, KnowledgeBaseService, ) from pipefy_sdk.services.types import KnowledgeBaseDocumentUploadError @@ -206,7 +205,7 @@ async def test_description_over_limit_rejected(self): "p", name="n", content="c", - description="y" * (MAX_PLAIN_TEXT_DESCRIPTION_LENGTH + 1), + description="y" * (MAX_KB_DESCRIPTION_LENGTH + 1), ) executor.execute_query.assert_not_awaited() @@ -567,7 +566,7 @@ async def test_description_over_limit_rejected(self, tmp_path): await service.create_ai_knowledge_base_document( "p", name="n", - description="y" * (MAX_DOCUMENT_DESCRIPTION_LENGTH + 1), + description="y" * (MAX_KB_DESCRIPTION_LENGTH + 1), file_path=pdf, ) executor.execute_query.assert_not_awaited() @@ -727,7 +726,7 @@ async def test_description_over_limit_rejected(self): with pytest.raises(ValueError, match="900"): await service.update_ai_knowledge_base_document( - "kb-2", "p", description="y" * (MAX_DOCUMENT_DESCRIPTION_LENGTH + 1) + "kb-2", "p", description="y" * (MAX_KB_DESCRIPTION_LENGTH + 1) ) executor.execute_query.assert_not_awaited() @@ -756,3 +755,452 @@ async def test_failure_surfaces_errors(self): result = await service.delete_ai_knowledge_base_document("kb-2", "p") assert result == {"success": False, "errors": ["nope"]} + + +DATA_LOOKUP_FULL = { + "id": "kb-3", + "name": "Order lookup", + "description": "Find orders by customer email", + "sourceRepoId": "303088927", + "searchQuery": None, + "outputFields": ["title", "status"], + "updatedAt": "2026-07-18T00:00:00Z", +} + +STATIC_CONDITION = {"field": "title", "operator": "contains", "value": "urgent"} +AI_CONDITION = { + "field": "customer_email", + "operator": "eq", + "usingFillWithAi": True, + "inputName": "Customer email", + "inputType": "text", + "inputDescription": "The customer's email address", +} + + +class TestGetDataLookup: + @pytest.mark.anyio + async def test_returns_data_lookup(self): + executor = mock_executor({"aiKnowledgeBaseDataLookup": DATA_LOOKUP_FULL}) + service = KnowledgeBaseService(executor=executor) + + result = await service.get_ai_knowledge_base_data_lookup("kb-3", "pipe-uuid-1") + + assert result == DATA_LOOKUP_FULL + _, variables = executor.execute_query.await_args.args + assert variables == {"id": "kb-3", "pipeUuid": "pipe-uuid-1"} + + @pytest.mark.anyio + async def test_null_yields_empty_dict(self): + executor = mock_executor({"aiKnowledgeBaseDataLookup": None}) + service = KnowledgeBaseService(executor=executor) + + assert await service.get_ai_knowledge_base_data_lookup("kb-x", "p") == {} + + @pytest.mark.anyio + async def test_blank_ids_rejected_before_wire(self): + executor = mock_executor({}) + service = KnowledgeBaseService(executor=executor) + + with pytest.raises(ValueError, match="data_lookup_id"): + await service.get_ai_knowledge_base_data_lookup(" ", "p") + executor.execute_query.assert_not_awaited() + + +def _data_lookup_create_executor(): + """Executor whose create mutation succeeds with ``DATA_LOOKUP_FULL``.""" + return mock_executor( + { + "createAiKnowledgeBaseDataLookup": { + "knowledgeBaseDataLookup": DATA_LOOKUP_FULL + } + } + ) + + +class TestCreateDataLookup: + def _service(self, executor): + return KnowledgeBaseService(executor=executor) + + async def _create(self, service, **overrides): + kwargs = { + "name": "Order lookup", + "description": "Find orders", + "source_repo_id": "303088927", + "output_fields": ["title"], + "conditions": [dict(STATIC_CONDITION)], + } + kwargs.update(overrides) + return await service.create_ai_knowledge_base_data_lookup("p", **kwargs) + + @pytest.mark.anyio + async def test_create_sends_serialized_definition(self): + executor = _data_lookup_create_executor() + service = self._service(executor) + + result = await self._create( + service, conditions=[dict(STATIC_CONDITION), dict(AI_CONDITION)] + ) + + assert result == DATA_LOOKUP_FULL + _, variables = executor.execute_query.await_args.args + assert variables == { + "input": { + "pipeUuid": "p", + "name": "Order lookup", + "description": "Find orders", + "sourceRepoId": "303088927", + "outputFields": ["title"], + "conditions": [ + { + "field": "title", + "operator": "contains", + "value": "urgent", + "usingFillWithAi": False, + }, + { + "field": "customer_email", + "operator": "eq", + "usingFillWithAi": True, + "inputName": "Customer email", + "inputType": "text", + "inputDescription": "The customer's email address", + }, + ], + } + } + + @pytest.mark.anyio + async def test_search_query_sent_only_when_given(self): + executor = _data_lookup_create_executor() + service = self._service(executor) + + await self._create(service, search_query="records") + + _, variables = executor.execute_query.await_args.args + assert variables["input"]["searchQuery"] == "records" + + @pytest.mark.anyio + async def test_snake_case_condition_keys_accepted(self): + executor = _data_lookup_create_executor() + service = self._service(executor) + + await self._create( + service, + conditions=[ + { + "field": "customer_email", + "operator": "eq", + "using_fill_with_ai": True, + "input_name": "Customer email", + "input_type": "text", + "input_description": "The customer's email address", + } + ], + ) + + _, variables = executor.execute_query.await_args.args + assert variables["input"]["conditions"][0]["inputName"] == "Customer email" + + @pytest.mark.anyio + async def test_typed_condition_instances_accepted(self): + from pipefy_sdk import DataLookupCondition + + executor = _data_lookup_create_executor() + service = self._service(executor) + + await self._create( + service, + conditions=[DataLookupCondition.model_validate(STATIC_CONDITION)], + ) + + _, variables = executor.execute_query.await_args.args + assert variables["input"]["conditions"][0]["value"] == "urgent" + + @pytest.mark.anyio + async def test_uuid_source_repo_id_rejected_before_wire(self): + executor = mock_executor({}) + service = self._service(executor) + + with pytest.raises(ValueError, match="numeric pipe ID"): + await self._create( + service, source_repo_id="5f66417e-5adc-4c83-908f-0b888493c847" + ) + executor.execute_query.assert_not_awaited() + + @pytest.mark.anyio + async def test_empty_output_fields_rejected(self): + service = self._service(mock_executor({})) + + with pytest.raises(ValueError, match="output_fields"): + await self._create(service, output_fields=[]) + + @pytest.mark.anyio + async def test_too_many_output_fields_rejected(self): + service = self._service(mock_executor({})) + + with pytest.raises(ValueError, match="at most 30"): + await self._create(service, output_fields=[f"f{i}" for i in range(31)]) + + @pytest.mark.anyio + async def test_blank_output_field_entry_rejected(self): + service = self._service(mock_executor({})) + + with pytest.raises(ValueError, match=r"output_fields\[1\]"): + await self._create(service, output_fields=["title", " "]) + + @pytest.mark.anyio + async def test_empty_conditions_rejected(self): + service = self._service(mock_executor({})) + + with pytest.raises(ValueError, match="conditions"): + await self._create(service, conditions=[]) + + @pytest.mark.anyio + async def test_static_condition_without_value_rejected(self): + service = self._service(mock_executor({})) + + with pytest.raises(ValueError, match=r"conditions\[0\].*static condition"): + await self._create( + service, conditions=[{"field": "title", "operator": "eq"}] + ) + + @pytest.mark.anyio + async def test_ai_condition_missing_trio_rejected(self): + service = self._service(mock_executor({})) + + with pytest.raises(ValueError, match="inputType, inputDescription"): + await self._create( + service, + conditions=[ + { + "field": "f", + "operator": "eq", + "usingFillWithAi": True, + "inputName": "n", + } + ], + ) + + @pytest.mark.anyio + async def test_ai_condition_with_value_rejected(self): + service = self._service(mock_executor({})) + + with pytest.raises(ValueError, match="must not set 'value'"): + await self._create( + service, conditions=[{**AI_CONDITION, "value": "static"}] + ) + + @pytest.mark.anyio + async def test_static_condition_with_input_fields_rejected(self): + service = self._service(mock_executor({})) + + with pytest.raises(ValueError, match="only apply to AI-filled"): + await self._create( + service, + conditions=[{**STATIC_CONDITION, "inputName": "n"}], + ) + + @pytest.mark.anyio + async def test_unknown_condition_key_rejected(self): + service = self._service(mock_executor({})) + + with pytest.raises(ValueError, match="extraKey"): + await self._create( + service, conditions=[{**STATIC_CONDITION, "extraKey": 1}] + ) + + @pytest.mark.anyio + async def test_over_limit_description_rejected(self): + service = self._service(mock_executor({})) + + with pytest.raises(ValueError, match="description"): + await self._create( + service, description="y" * (MAX_KB_DESCRIPTION_LENGTH + 1) + ) + + @pytest.mark.anyio + async def test_null_mutation_payload_raises(self): + executor = mock_executor( + {"createAiKnowledgeBaseDataLookup": {"knowledgeBaseDataLookup": None}} + ) + service = self._service(executor) + + with pytest.raises(ValueError, match="may not have persisted"): + await self._create(service) + + +class TestUpdateDataLookup: + def _executor(self): + return mock_executor( + { + "updateAiKnowledgeBaseDataLookup": { + "knowledgeBaseDataLookup": DATA_LOOKUP_FULL + } + } + ) + + @pytest.mark.anyio + async def test_full_definition_sent_without_optional_fields(self): + executor = self._executor() + service = KnowledgeBaseService(executor=executor) + + result = await service.update_ai_knowledge_base_data_lookup( + "kb-3", + "p", + source_repo_id="303088927", + output_fields=["title"], + conditions=[dict(STATIC_CONDITION)], + ) + + assert result == DATA_LOOKUP_FULL + _, variables = executor.execute_query.await_args.args + assert variables == { + "input": { + "pipeUuid": "p", + "dataLookupId": "kb-3", + "sourceRepoId": "303088927", + "outputFields": ["title"], + "conditions": [ + { + "field": "title", + "operator": "contains", + "value": "urgent", + "usingFillWithAi": False, + } + ], + } + } + + @pytest.mark.anyio + async def test_optional_fields_sent_when_given(self): + executor = self._executor() + service = KnowledgeBaseService(executor=executor) + + await service.update_ai_knowledge_base_data_lookup( + "kb-3", + "p", + source_repo_id="303088927", + output_fields=["title"], + conditions=[dict(STATIC_CONDITION)], + search_query="records", + name="Renamed", + description="New description", + ) + + _, variables = executor.execute_query.await_args.args + assert variables["input"]["searchQuery"] == "records" + assert variables["input"]["name"] == "Renamed" + assert variables["input"]["description"] == "New description" + + @pytest.mark.anyio + async def test_definition_rules_apply_on_update(self): + executor = mock_executor({}) + service = KnowledgeBaseService(executor=executor) + + with pytest.raises(ValueError, match="conditions"): + await service.update_ai_knowledge_base_data_lookup( + "kb-3", + "p", + source_repo_id="303088927", + output_fields=["title"], + conditions=[], + ) + executor.execute_query.assert_not_awaited() + + @pytest.mark.anyio + async def test_over_limit_description_rejected(self): + service = KnowledgeBaseService(executor=mock_executor({})) + + with pytest.raises(ValueError, match="description"): + await service.update_ai_knowledge_base_data_lookup( + "kb-3", + "p", + source_repo_id="303088927", + output_fields=["title"], + conditions=[dict(STATIC_CONDITION)], + description="y" * (MAX_KB_DESCRIPTION_LENGTH + 1), + ) + + +class TestDeleteDataLookup: + @pytest.mark.anyio + async def test_success(self): + executor = mock_executor( + {"deleteAiKnowledgeBaseDataLookup": {"success": True, "errors": []}} + ) + service = KnowledgeBaseService(executor=executor) + + result = await service.delete_ai_knowledge_base_data_lookup("kb-3", "p") + + assert result == {"success": True, "errors": []} + _, variables = executor.execute_query.await_args.args + assert variables == {"input": {"pipeUuid": "p", "dataLookupId": "kb-3"}} + + @pytest.mark.anyio + async def test_failure_surfaces_errors(self): + executor = mock_executor( + {"deleteAiKnowledgeBaseDataLookup": {"success": False, "errors": ["nope"]}} + ) + service = KnowledgeBaseService(executor=executor) + + result = await service.delete_ai_knowledge_base_data_lookup("kb-3", "p") + + assert result == {"success": False, "errors": ["nope"]} + + +class TestDataLookupConditionEdgeCases: + @pytest.mark.anyio + async def test_passthrough_condition_keys_accepted(self): + executor = _data_lookup_create_executor() + service = KnowledgeBaseService(executor=executor) + + await service.create_ai_knowledge_base_data_lookup( + "p", + name="n", + description="d", + source_repo_id="303088927", + output_fields=["title"], + conditions=[ + { + "field": "label", + "attribute": "label", + "fieldUuid": "f-uuid-1", + "operator": "eq", + "value": "urgent", + } + ], + ) + + _, variables = executor.execute_query.await_args.args + condition = variables["input"]["conditions"][0] + assert condition["attribute"] == "label" + assert condition["fieldUuid"] == "f-uuid-1" + + @pytest.mark.anyio + async def test_non_string_condition_field_is_clean_value_error(self): + service = KnowledgeBaseService(executor=mock_executor({})) + + with pytest.raises(ValueError, match=r"conditions\[0\]"): + await service.create_ai_knowledge_base_data_lookup( + "p", + name="n", + description="d", + source_repo_id="303088927", + output_fields=["title"], + conditions=[{"field": 123, "operator": "eq", "value": "x"}], + ) + + @pytest.mark.anyio + async def test_non_decimal_unicode_digit_repo_id_rejected(self): + service = KnowledgeBaseService(executor=mock_executor({})) + + with pytest.raises(ValueError, match="numeric pipe ID"): + await service.create_ai_knowledge_base_data_lookup( + "p", + name="n", + description="d", + source_repo_id="123²", + output_fields=["title"], + conditions=[dict(STATIC_CONDITION)], + ) diff --git a/skills/ai-agents/pipefy-ai-agents/SKILL.md b/skills/ai-agents/pipefy-ai-agents/SKILL.md index c00765ef..71c24b67 100644 --- a/skills/ai-agents/pipefy-ai-agents/SKILL.md +++ b/skills/ai-agents/pipefy-ai-agents/SKILL.md @@ -4,7 +4,7 @@ description: > Use this skill when the user wants to create, read, update, delete, or troubleshoot AI agents (conversational agents with behaviors). Covers 7 MCP tools including pre-flight validation, plus pipe-scoped - knowledge bases (list, plain text and document CRUD, access probe) attached via dataSourceIds. + knowledge bases (list, plain text/document/data lookup CRUD, access probe) attached via dataSourceIds. For traditional automations and AI automations, see skills/automations/. tags: [pipefy, ai-agents, behaviors, conversational] --- @@ -182,7 +182,7 @@ Knowledge bases are pipe-scoped data sources an agent draws on. Attach one by pu | Tool (MCP) | CLI | Read-only | Purpose | |------------|-----|-----------|---------| -| `get_ai_knowledge_bases` | `pipefy kb list` | Yes | List every item on a pipe (plain texts, documents, data lookups); each has an `id` for `dataSourceIds`. | +| `get_ai_knowledge_bases` | `pipefy kb list` | Yes | List every item on a pipe (plain texts, documents, data lookups); each has an `id` for `dataSourceIds` and a `type` (`knowledge_base_plain_texts`, `knowledge_base_documents`, or `data_lookups`). | | `get_ai_knowledge_base_plain_text` | `pipefy kb plain-text get` | Yes | Fetch one plain text with its content. | | `create_ai_knowledge_base_plain_text` | `pipefy kb plain-text create` | No | Create a plain text (`name`, `content` 1-3500, `description` 1-900 — all required). | | `update_ai_knowledge_base_plain_text` | `pipefy kb plain-text update` | No | Partial update; pass at least one of name/content/description. | @@ -191,6 +191,10 @@ Knowledge bases are pipe-scoped data sources an agent draws on. Attach one by pu | `create_ai_knowledge_base_document` | `pipefy kb document create` | No | Upload a local PDF in one shot (`file_path`/`--file`, `name`, `description` 1-900). `.pdf` + 20 MiB cap client-side; indexing is async. | | `update_ai_knowledge_base_document` | `pipefy kb document update` | No | Metadata-only update (name/description); no file replacement. | | `delete_ai_knowledge_base_document` | `pipefy kb document delete` | No | **(Two-step destructive)** MCP needs `confirm=true`; CLI needs `--yes`. | +| `get_ai_knowledge_base_data_lookup` | `pipefy kb data-lookup get` | Yes | Fetch one data lookup; the payload never includes `conditions` — keep the definition client-side. | +| `create_ai_knowledge_base_data_lookup` | `pipefy kb data-lookup create` | No | Create a data lookup (`name`, `description` 1-900, `source_repo_id` numeric pipe ID, `output_fields` 1-30, `conditions` — all required). | +| `update_ai_knowledge_base_data_lookup` | `pipefy kb data-lookup update` | No | Full replacement: resend `source_repo_id`/`output_fields`/`conditions` every call; omitted `search_query` clears it; only name/description are partial. | +| `delete_ai_knowledge_base_data_lookup` | `pipefy kb data-lookup delete` | No | **(Two-step destructive)** MCP needs `confirm=true`; CLI needs `--yes`. | | `validate_knowledge_base_access` | `pipefy kb validate-access` | Yes | Probe read access before writes. | ### Flow: validate-access → create plain text → attach @@ -201,6 +205,21 @@ Knowledge bases are pipe-scoped data sources an agent draws on. Attach one by pu For a **PDF document** instead of plain text, use `create_ai_knowledge_base_document(pipe_uuid, name, description, file_path)` (CLI: `pipefy kb document create --file …`) at step 2. It uploads the local PDF in one shot; `.pdf` and the 20 MiB cap are enforced client-side, and indexing is asynchronous (the document may not be searchable immediately). The rest of the flow is identical — keep the returned `id` and attach it. +### Data lookups: create with an AI-filled condition → attach → update (full replacement) + +A **data lookup** lets the agent search cards in a source pipe by conditions and return selected field values. Same flow as above at step 2, with three rules of its own: + +1. **Create** — `create_ai_knowledge_base_data_lookup(pipe_uuid, name, description, source_repo_id, output_fields, conditions)` (CLI: `pipefy kb data-lookup create --source-repo-id … --output-fields '[…]' --conditions '[…]'`). `source_repo_id` is the **numeric** ID of the source pipe (a UUID is accepted by the API but the lookup then breaks when the agent runs it). `output_fields` takes 1-30 field IDs (field slugs plus static fields like `id`, `title`, `created_at`). Each condition needs `field` + `operator` (opaque backend string, e.g. `"eq"`, `"contains"`) and is either **static** (string `value` required) or **AI-filled** — the AI asks the user for the value at runtime: + + ```json + [{"field": "customer_email", "operator": "eq", "usingFillWithAi": true, + "inputName": "Customer email", "inputType": "text", + "inputDescription": "The customer's email address"}] + ``` + +2. **Attach** — keep the returned `id` and add it to `dataSourceIds`, exactly as for the other kinds. **Also keep the definition you sent**: reads never return `conditions`, so your copy is the only complete record of the lookup. +3. **Update replaces everything** — `update_ai_knowledge_base_data_lookup` requires `source_repo_id`, `output_fields`, and `conditions` on every call (the complete condition set, not a delta), and omitting `search_query` clears it. Only `name`/`description` keep their stored values when omitted. + --- ## Token normalization & slug resolution