feat(web-search): tool DuckDuckGo abierta en /web-search para watsonx Orchestrate
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) <noreply@anthropic.com>
This commit is contained in:
@@ -6,3 +6,11 @@ BASE_URL=https://taller-wox.fitlabs.dev
|
|||||||
MATERIAL_DIR=./material
|
MATERIAL_DIR=./material
|
||||||
DB_PATH=./leads.db
|
DB_PATH=./leads.db
|
||||||
REPORTS_OUTPUT_DIR=./app/data/reports_output
|
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
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
from app import admin, benefits_api, frontend, reports_api
|
from app import admin, benefits_api, frontend, reports_api
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.db import init_db
|
from app.db import init_db
|
||||||
|
from app.web_search import web_search_app
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
@@ -36,6 +37,8 @@ app.mount(
|
|||||||
name="reports_output",
|
name="reports_output",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
app.mount("/web-search", web_search_app)
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
def on_startup() -> None:
|
def on_startup() -> None:
|
||||||
|
|||||||
3
app/web_search/__init__.py
Normal file
3
app/web_search/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from app.web_search.api import web_search_app
|
||||||
|
|
||||||
|
__all__ = ["web_search_app"]
|
||||||
154
app/web_search/api.py
Normal file
154
app/web_search/api.py
Normal file
@@ -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]
|
||||||
36
app/web_search/config.py
Normal file
36
app/web_search/config.py
Normal file
@@ -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),
|
||||||
|
)
|
||||||
65
app/web_search/search.py
Normal file
65
app/web_search/search.py
Normal file
@@ -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
|
||||||
@@ -23,6 +23,10 @@ services:
|
|||||||
MATERIAL_DIR: /app/material
|
MATERIAL_DIR: /app/material
|
||||||
DB_PATH: /app/db/leads.db
|
DB_PATH: /app/db/leads.db
|
||||||
REPORTS_OUTPUT_DIR: /app/app/data/reports_output
|
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:
|
volumes:
|
||||||
- taller-wox-db:/app/db
|
- taller-wox-db:/app/db
|
||||||
- taller-wox-reports:/app/app/data/reports_output
|
- taller-wox-reports:/app/app/data/reports_output
|
||||||
|
|||||||
@@ -10,3 +10,4 @@ pandas==2.2.3
|
|||||||
plotly==5.24.1
|
plotly==5.24.1
|
||||||
pytest==8.3.3
|
pytest==8.3.3
|
||||||
httpx==0.27.2
|
httpx==0.27.2
|
||||||
|
ddgs>=9,<10
|
||||||
|
|||||||
Reference in New Issue
Block a user