From 385c448c1fec2abdc8704f6b0dda6404bb7c89ba Mon Sep 17 00:00:00 2001 From: farentsen Date: Thu, 28 May 2026 03:37:55 +0000 Subject: [PATCH] feat(web-search): tool DuckDuckGo abierta en /web-search para watsonx Orchestrate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sub-app FastAPI montada en /web-search con búsqueda vía ddgs. OpenAPI 3.0.3 + servers absoluto + operationId webSearch listo para importar como external tool en wxO. Sin auth (uso de taller/demo). Co-Authored-By: Claude Opus 4.7 (1M context) --- .env.example | 8 ++ app/main.py | 3 + app/web_search/__init__.py | 3 + app/web_search/api.py | 154 +++++++++++++++++++++++++++++++++++++ app/web_search/config.py | 36 +++++++++ app/web_search/search.py | 65 ++++++++++++++++ docker-compose.yml | 4 + requirements.txt | 1 + 8 files changed, 274 insertions(+) create mode 100644 app/web_search/__init__.py create mode 100644 app/web_search/api.py create mode 100644 app/web_search/config.py create mode 100644 app/web_search/search.py diff --git a/.env.example b/.env.example index 799f28d..2e54db9 100644 --- a/.env.example +++ b/.env.example @@ -6,3 +6,11 @@ BASE_URL=https://taller-wox.fitlabs.dev MATERIAL_DIR=./material DB_PATH=./leads.db REPORTS_OUTPUT_DIR=./app/data/reports_output + +# --- Web Search Tool para watsonx Orchestrate (montada en /web-search) --- +# Endpoint abierto (sin auth) — apto para taller/demo, no para producción. +# Por defecto se calcula como ${BASE_URL}/web-search. Override sólo si hace falta. +# WEB_SEARCH_PUBLIC_BASE_URL=https://taller-wox.fitlabs.dev/web-search +WEB_SEARCH_DEFAULT_MAX_RESULTS=5 +WEB_SEARCH_DEFAULT_REGION=wt-wt +WEB_SEARCH_REQUEST_TIMEOUT=12 diff --git a/app/main.py b/app/main.py index f921fe1..2030109 100644 --- a/app/main.py +++ b/app/main.py @@ -7,6 +7,7 @@ from fastapi.staticfiles import StaticFiles from app import admin, benefits_api, frontend, reports_api from app.config import get_settings from app.db import init_db +from app.web_search import web_search_app settings = get_settings() @@ -36,6 +37,8 @@ app.mount( name="reports_output", ) +app.mount("/web-search", web_search_app) + @app.on_event("startup") def on_startup() -> None: diff --git a/app/web_search/__init__.py b/app/web_search/__init__.py new file mode 100644 index 0000000..88f0517 --- /dev/null +++ b/app/web_search/__init__.py @@ -0,0 +1,3 @@ +from app.web_search.api import web_search_app + +__all__ = ["web_search_app"] diff --git a/app/web_search/api.py b/app/web_search/api.py new file mode 100644 index 0000000..383b08a --- /dev/null +++ b/app/web_search/api.py @@ -0,0 +1,154 @@ +"""Sub-app FastAPI con la web-search tool para watsonx Orchestrate. + +Se monta en la app principal bajo `/web-search`, por lo que: +- `GET /web-search/search` → endpoint de búsqueda (abierto, sin auth) +- `GET /web-search/health` → health check (fuera del schema) +- `GET /web-search/openapi.json`→ spec OpenAPI 3.0.3 listo para importar en wxO +- `GET /web-search/docs` → Swagger UI para probar +""" +from __future__ import annotations + +from typing import Annotated + +from fastapi import Depends, FastAPI, HTTPException, Query, status +from fastapi.openapi.utils import get_openapi +from pydantic import BaseModel, Field + +from app.web_search.config import WebSearchSettings, get_web_search_settings +from app.web_search.search import SearchProviderError, search_web + + +OPERATION_DESCRIPTION = ( + "Busca en la web y devuelve los resultados más relevantes (título, URL y " + "resumen). Úsala cuando necesites información actual o reciente, noticias, " + "datos que puedan haber cambiado, o cualquier información que no esté en tu " + "conocimiento base." +) + + +class SearchResultModel(BaseModel): + title: str = Field(..., description="Título del resultado de búsqueda.") + url: str = Field(..., description="URL del resultado.") + snippet: str = Field( + ..., + description="Fragmento de texto / resumen del resultado (texto plano, sin HTML, máx. 300 caracteres).", + ) + + +class SearchResponse(BaseModel): + query: str = Field(..., description="Consulta original recibida.") + count: int = Field(..., description="Cantidad de resultados devueltos.") + results: list[SearchResultModel] = Field( + ..., description="Lista de resultados, ordenados de mayor a menor relevancia." + ) + + +web_search_app = FastAPI( + title="FIT Web Search Tool", + version="1.0.0", + description=( + "Herramienta de búsqueda web para agentes de watsonx Orchestrate. " + "Devuelve resultados normalizados desde DuckDuckGo." + ), + docs_url="/docs", + redoc_url=None, + openapi_url="/openapi.json", +) + + +@web_search_app.get( + "/health", + include_in_schema=False, +) +def health() -> dict[str, str]: + return {"status": "ok"} + + +@web_search_app.get( + "/search", + operation_id="webSearch", + summary="Buscar en la web", + description=OPERATION_DESCRIPTION, + response_model=SearchResponse, + responses={ + 422: {"description": "Parámetros de entrada inválidos."}, + 502: {"description": "Error en el proveedor de búsqueda."}, + }, +) +def search( + settings: Annotated[WebSearchSettings, Depends(get_web_search_settings)], + query: Annotated[ + str, + Query( + min_length=1, + max_length=400, + description="Consulta de búsqueda en lenguaje natural.", + examples=["últimas noticias IBM watsonx orchestrate"], + ), + ], + max_results: Annotated[ + int, + Query( + ge=1, + le=10, + description="Cantidad de resultados a devolver (1–10). Por defecto 5.", + ), + ] = 5, + region: Annotated[ + str, + Query( + min_length=2, + max_length=10, + description=( + "Región/idioma del motor de búsqueda. Ejemplos: 'wt-wt' (global), " + "'cl-es' (Chile/español), 'mx-es' (México/español), 'us-en' (EEUU/inglés)." + ), + examples=["wt-wt", "cl-es", "mx-es", "us-en"], + ), + ] = "wt-wt", +) -> SearchResponse: + try: + results = search_web( + query=query, + max_results=max_results, + region=region, + timeout=settings.request_timeout, + ) + except SearchProviderError: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="Error en el proveedor de búsqueda", + ) + + return SearchResponse( + query=query, + count=len(results), + results=[ + SearchResultModel(title=r.title, url=r.url, snippet=r.snippet) for r in results + ], + ) + + +def _custom_openapi() -> dict: + if web_search_app.openapi_schema: + return web_search_app.openapi_schema + + settings = get_web_search_settings() + schema = get_openapi( + title=web_search_app.title, + version=web_search_app.version, + description=web_search_app.description, + routes=web_search_app.routes, + ) + + # wxO prefiere 3.0.x (FastAPI emite 3.1.0 por defecto) + schema["openapi"] = "3.0.3" + + # Bloque servers absoluto — obligatorio para wxO + schema["servers"] = [{"url": settings.public_base_url}] + + web_search_app.openapi_schema = schema + return schema + + +web_search_app.openapi = _custom_openapi # type: ignore[assignment] diff --git a/app/web_search/config.py b/app/web_search/config.py new file mode 100644 index 0000000..266eea3 --- /dev/null +++ b/app/web_search/config.py @@ -0,0 +1,36 @@ +import os +from functools import lru_cache +from dataclasses import dataclass + + +@dataclass(frozen=True) +class WebSearchSettings: + public_base_url: str + default_max_results: int + default_region: str + request_timeout: int + + +def _int_env(name: str, default: int) -> int: + raw = os.environ.get(name) + if raw is None or raw == "": + return default + try: + return int(raw) + except ValueError: + return default + + +@lru_cache +def get_web_search_settings() -> WebSearchSettings: + base_url = os.environ.get("BASE_URL", "https://taller-wox.fitlabs.dev").rstrip("/") + public_base_url = os.environ.get( + "WEB_SEARCH_PUBLIC_BASE_URL", + f"{base_url}/web-search", + ).rstrip("/") + return WebSearchSettings( + public_base_url=public_base_url, + default_max_results=_int_env("WEB_SEARCH_DEFAULT_MAX_RESULTS", 5), + default_region=os.environ.get("WEB_SEARCH_DEFAULT_REGION", "wt-wt"), + request_timeout=_int_env("WEB_SEARCH_REQUEST_TIMEOUT", 12), + ) diff --git a/app/web_search/search.py b/app/web_search/search.py new file mode 100644 index 0000000..de0d6fa --- /dev/null +++ b/app/web_search/search.py @@ -0,0 +1,65 @@ +"""Único punto del servicio que toca ddgs. + +Si se cambia el backend (Brave, Google CSE, etc.) sólo se reescribe este módulo; +el contrato `search_web(...)` -> list[SearchResult] no cambia. +""" +from __future__ import annotations + +import html +import re +from dataclasses import dataclass + +from ddgs import DDGS + + +SNIPPET_MAX_LEN = 300 +_TAG_RE = re.compile(r"<[^>]+>") +_WS_RE = re.compile(r"\s+") + + +class SearchProviderError(RuntimeError): + """Error opaco del proveedor de búsqueda. main.py lo traduce a HTTP 502.""" + + +@dataclass(frozen=True) +class SearchResult: + title: str + url: str + snippet: str + + +def _clean_snippet(raw: str | None) -> str: + if not raw: + return "" + text = _TAG_RE.sub("", raw) + text = html.unescape(text) + text = _WS_RE.sub(" ", text).strip() + if len(text) > SNIPPET_MAX_LEN: + text = text[: SNIPPET_MAX_LEN - 1].rstrip() + "…" + return text + + +def search_web(query: str, max_results: int, region: str, timeout: int) -> list[SearchResult]: + try: + raw = DDGS(timeout=timeout).text( + query, + region=region, + max_results=max_results, + ) + except Exception as exc: # ddgs lanza varias excepciones distintas; las unificamos + raise SearchProviderError(str(exc)) from exc + + results: list[SearchResult] = [] + for item in raw or []: + title = (item.get("title") or "").strip() + url = (item.get("href") or item.get("url") or "").strip() + if not url: + continue + results.append( + SearchResult( + title=title or url, + url=url, + snippet=_clean_snippet(item.get("body") or item.get("snippet")), + ) + ) + return results diff --git a/docker-compose.yml b/docker-compose.yml index 4d4fb13..66d79ed 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,6 +23,10 @@ services: MATERIAL_DIR: /app/material DB_PATH: /app/db/leads.db REPORTS_OUTPUT_DIR: /app/app/data/reports_output + WEB_SEARCH_PUBLIC_BASE_URL: ${WEB_SEARCH_PUBLIC_BASE_URL:-https://taller-wox.fitlabs.dev/web-search} + WEB_SEARCH_DEFAULT_MAX_RESULTS: ${WEB_SEARCH_DEFAULT_MAX_RESULTS:-5} + WEB_SEARCH_DEFAULT_REGION: ${WEB_SEARCH_DEFAULT_REGION:-wt-wt} + WEB_SEARCH_REQUEST_TIMEOUT: ${WEB_SEARCH_REQUEST_TIMEOUT:-12} volumes: - taller-wox-db:/app/db - taller-wox-reports:/app/app/data/reports_output diff --git a/requirements.txt b/requirements.txt index 007ff3d..7102846 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,3 +10,4 @@ pandas==2.2.3 plotly==5.24.1 pytest==8.3.3 httpx==0.27.2 +ddgs>=9,<10