From 56b46cde0ed4be66b095943e7006eca3ea94622c Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Wed, 16 Sep 2026 23:13:42 -0400 Subject: [PATCH] fix(ai): chaine de repli web_search (SearXNG -> DuckDuckGo -> Bing, BUG-051) --- .env.example | 7 + CHANGELOG.md | 18 +- README.fr.md | 6 +- README.md | 6 +- VERSION | 2 +- backend/tools/web.py | 285 ++++++++++++++---- desktop/Cargo.lock | 2 +- desktop/Cargo.toml | 2 +- desktop/tauri.conf.json | 2 +- docs/ISSUES_TODOLIST.md | 2 + docs/ROADMAP.md | 5 +- docs/features/ai-assistant-conversation-ux.md | 5 + docs/features/ai-tools-roadmap.md | 7 +- package.json | 2 +- tests/test_web_tools.py | 75 ++++- 15 files changed, 353 insertions(+), 73 deletions(-) diff --git a/.env.example b/.env.example index 7b1f286..c342f1d 100644 --- a/.env.example +++ b/.env.example @@ -67,3 +67,10 @@ DEEPSEEK_MODEL=deepseek-chat # Google Gemini # GEMINI_API_KEY=AIza... # GEMINI_MODEL=gemini-2.0-flash + +# ── Assistant IA — recherche web (outil web_search) ── +# Instance SearXNG auto-hébergée (aucune clé API requise) +# OBSIGATE_SEARXNG_URL=https://search.dracodev.net +# Chaîne de repli sans clé (DuckDuckGo puis Bing) si SearXNG ne remonte rien +# OBSIGATE_WEB_FALLBACK=1 +# OBSIGATE_WEB_TIMEOUT=10 diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b3c620..1151234 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). > **En cours de développement** : les changements à venir sont listés dans la section -> [Unreleased](#unreleased). La dernière version livrée est **2.7.0**. +> [Unreleased](#unreleased). La dernière version livrée est **2.7.1**. --- @@ -14,6 +14,22 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [2.7.1] — 2026-09-16 + +### Corrigé + +- **BUG-051 - Assistant IA : « je ne peux pas accéder à internet » malgré la recherche web** : + lorsque l'instance SearXNG auto-hébergée ne remontait aucun résultat (moteurs amont + suspendus/CAPTCHA), `web_search` renvoyait une liste vide et le modèle concluait à une + absence d'accès réseau. L'outil essaie désormais une **chaîne de repli sans clé** — + SearXNG, puis DuckDuckGo (endpoint HTML sans JS), puis Bing (page de résultats HTML) — + et ne s'arrête qu'au premier fournisseur qui renvoie des résultats (`provider` dans le + résultat, `OBSIGATE_WEB_FALLBACK=0` pour désactiver les replis). Le message d'avertissement + final nomme les fournisseurs essayés. Fichier : `backend/tools/web.py`. Tests : + `tests/test_web_tools.py` (+4). + +--- + ## [2.7.0] — 2026-09-16 ### Ajouté diff --git a/README.fr.md b/README.fr.md index 4a6bec1..e4bce73 100644 --- a/README.fr.md +++ b/README.fr.md @@ -4,7 +4,7 @@ **Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive. -[![Version](https://img.shields.io/badge/Version-2.7.0-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.7.1-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -916,8 +916,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l ## 📝 Changelog -Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.7.0). +Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.7.1). --- -*Projet : ObsiGate | Version : 2.7.0 | Dernière mise à jour : Juin 2026* +*Projet : ObsiGate | Version : 2.7.1 | Dernière mise à jour : Juin 2026* diff --git a/README.md b/README.md index 277289b..9894e44 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface. -[![Version](https://img.shields.io/badge/Version-2.7.0-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.7.1-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -1085,8 +1085,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) ## 📝 Changelog -See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.7.0). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.7.1). --- -*Project: ObsiGate | Version: 2.7.0 | Last updated: May 2026* +*Project: ObsiGate | Version: 2.7.1 | Last updated: May 2026* diff --git a/VERSION b/VERSION index 24ba9a3..860487c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.7.0 +2.7.1 diff --git a/backend/tools/web.py b/backend/tools/web.py index 0b07b4b..38eecec 100644 --- a/backend/tools/web.py +++ b/backend/tools/web.py @@ -2,27 +2,35 @@ Phase 1 of the documented web-toolset roadmap: -* ``web_search`` — query the self-hosted SearXNG instance (no API key). +* ``web_search`` — query the self-hosted SearXNG instance (no API key) and, + when it returns nothing, fall back to keyless HTML providers (DuckDuckGo, + then Bing) so a dead meta-search instance never leaves the assistant + answering « je n'ai pas accès à internet ». * ``fetch_url`` — retrieve a public web page and return readable text. -Both are READ-risk tools (no confirmation), rate-limited through the shared +All are READ-risk tools (no confirmation), rate-limited through the shared registry, SSRF-guarded (scheme + private-address rejection), and size-capped. Configuration (environment): * ``OBSIGATE_SEARXNG_URL`` — defaults to https://search.dracodev.net * ``OBSIGATE_WEB_TIMEOUT`` — seconds, default 10 +* ``OBSIGATE_WEB_FALLBACK`` — ``0``/``false`` disables the keyless HTML + fallbacks (SearXNG only), default enabled """ from __future__ import annotations +import base64 +import binascii import html as html_lib import ipaddress import logging import os import re import socket +from collections.abc import Callable from typing import Any -from urllib.parse import urlparse +from urllib.parse import parse_qs, urlparse import httpx @@ -34,7 +42,18 @@ logger = logging.getLogger("obsigate.tools.web") SEARXNG_URL = os.environ.get("OBSIGATE_SEARXNG_URL", "https://search.dracodev.net") WEB_TIMEOUT = float(os.environ.get("OBSIGATE_WEB_TIMEOUT", "10")) +WEB_FALLBACK_ENABLED = os.environ.get("OBSIGATE_WEB_FALLBACK", "1").strip().lower() not in { + "0", + "false", + "no", + "off", +} USER_AGENT = "ObsiGateAssistant/1.0 (+self-hosted vault AI)" +# Search engines reject non-browser agents on their public HTML endpoints. +BROWSER_UA = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" +) MAX_FETCH_BYTES = 1_500_000 MAX_TEXT_CHARS = 20_000 @@ -46,6 +65,18 @@ _BLOCK_SPLIT_RE = re.compile( r"]*>", re.IGNORECASE, ) +_DDG_RESULT_RE = re.compile( + r']*class="result__a"[^>]*href="([^"]+)"[^>]*>(.*?)', re.IGNORECASE | re.DOTALL +) +_DDG_SNIPPET_RE = re.compile( + r']*class="result__snippet"[^>]*>(.*?)', re.IGNORECASE | re.DOTALL +) +_BING_RESULT_RE = re.compile( + r']*>\s*]*href="([^"]+)"[^>]*>(.*?)', re.IGNORECASE | re.DOTALL +) +_BING_SNIPPET_RE = re.compile( + r'

(.*?)

', re.IGNORECASE | re.DOTALL +) class SSRFError(ToolError): @@ -99,6 +130,160 @@ def _html_to_text(raw: str) -> str: return text.strip() +def _response_text(resp: httpx.Response) -> str: + """Decode a response body without relying on ``resp.text`` (easier to mock).""" + return resp.content.decode(resp.encoding or "utf-8", errors="replace") + + +def _clean_fragment(fragment: str) -> str: + return html_lib.unescape(_TAG_RE.sub("", fragment)).strip() + + +def _result( + title: str, url: str, snippet: str, published: Any = None, score: Any = None +) -> dict[str, Any]: + return { + "title": (title or "")[:300], + "url": url or "", + "snippet": (snippet or "")[:600], + "published": published, + "score": score, + } + + +def _search_searxng( + query: str, params: WebSearchInput +) -> tuple[list[dict[str, Any]], list[str]]: + """Query the self-hosted SearXNG instance (JSON API).""" + url = SEARXNG_URL.rstrip("/") + "/search" + resp = httpx.get( + url, + params={ + "q": query, + "format": "json", + "categories": params.category or "general", + "pageno": max(1, params.page), + **({"language": params.language} if params.language else {}), + "safesearch": "1", + }, + headers={"User-Agent": USER_AGENT}, + timeout=WEB_TIMEOUT, + follow_redirects=False, + ) + resp.raise_for_status() + data = resp.json() + results = [ + _result( + item.get("title") or "", + item.get("url") or "", + item.get("content") or "", + item.get("publishedDate"), + item.get("score"), + ) + for item in (data.get("results") or [])[: params.max_results] + ] + unresponsive = [ + name for entry in (data.get("unresponsive_engines") or []) + for name in ([entry[0]] if isinstance(entry, (list, tuple)) and entry else [entry]) + if isinstance(name, str) + ] + return results, unresponsive + + +def _unwrap_duckduckgo_url(href: str) -> str: + """DuckDuckGo HTML wraps hits in ``/l/?uddg=``.""" + href = html_lib.unescape(href) + if href.startswith("//"): + href = "https:" + href + if "uddg=" in href: + values = parse_qs(urlparse(href).query).get("uddg") + if values: + return values[0] + return href + + +def _search_duckduckgo( + query: str, params: WebSearchInput +) -> tuple[list[dict[str, Any]], list[str]]: + """Keyless fallback: scrape the DuckDuckGo no-JS HTML endpoint.""" + resp = httpx.get( + "https://html.duckduckgo.com/html/", + params={"q": query, **({"kl": params.language} if params.language else {})}, + headers={"User-Agent": BROWSER_UA, "Accept": "text/html,application/xhtml+xml"}, + timeout=WEB_TIMEOUT, + follow_redirects=False, + ) + resp.raise_for_status() + body = _response_text(resp) + snippets = [_clean_fragment(m.group(1)) for m in _DDG_SNIPPET_RE.finditer(body)] + results: list[dict[str, Any]] = [] + for index, match in enumerate(_DDG_RESULT_RE.finditer(body)): + results.append( + _result( + _clean_fragment(match.group(2)), + _unwrap_duckduckgo_url(match.group(1)), + snippets[index] if index < len(snippets) else "", + ) + ) + if len(results) >= params.max_results: + break + return results, [] + + +def _unwrap_bing_url(href: str) -> str: + """Bing wraps hits in ``/ck/a?...&u=a1``.""" + href = html_lib.unescape(href) + match = re.search(r"[?&]u=a1([A-Za-z0-9_\-]+)", href) + if not match: + return href + token = match.group(1).replace("-", "+").replace("_", "/") + token += "=" * (-len(token) % 4) + try: + return base64.b64decode(token).decode("utf-8", errors="replace") + except (ValueError, binascii.Error): + return href + + +def _search_bing( + query: str, params: WebSearchInput +) -> tuple[list[dict[str, Any]], list[str]]: + """Last-resort keyless fallback: scrape Bing's result page.""" + resp = httpx.get( + "https://www.bing.com/search", + params={"q": query, **({"setlang": params.language} if params.language else {})}, + headers={"User-Agent": BROWSER_UA, "Accept": "text/html,application/xhtml+xml"}, + timeout=WEB_TIMEOUT, + follow_redirects=False, + ) + resp.raise_for_status() + body = _response_text(resp) + snippets = [_clean_fragment(m.group(1)) for m in _BING_SNIPPET_RE.finditer(body)] + results: list[dict[str, Any]] = [] + for index, match in enumerate(_BING_RESULT_RE.finditer(body)): + results.append( + _result( + _clean_fragment(match.group(2)), + _unwrap_bing_url(match.group(1)), + snippets[index] if index < len(snippets) else "", + ) + ) + if len(results) >= params.max_results: + break + return results, [] + + +_Provider = Callable[[str, WebSearchInput], "tuple[list[dict[str, Any]], list[str]]"] + + +def _provider_chain() -> list[tuple[str, _Provider]]: + """Ordered providers: self-hosted meta-search first, then keyless fallbacks.""" + chain: list[tuple[str, _Provider]] = [("searxng", _search_searxng)] + if WEB_FALLBACK_ENABLED: + chain.append(("duckduckgo", _search_duckduckgo)) + chain.append(("bing", _search_bing)) + return chain + + @tool( name="web_search", description=( @@ -111,67 +296,59 @@ def _html_to_text(raw: str) -> str: scopes=(ToolScope.IN_APP,), ) def web_search(ctx, params: WebSearchInput) -> dict[str, Any]: - """Query the self-hosted SearXNG instance and return trimmed results.""" + """Try each configured provider and return the first non-empty result set.""" query = params.query.strip() if not query: raise ToolError("Requête vide", code="invalid_arguments") - url = SEARXNG_URL.rstrip("/") + "/search" - try: - resp = httpx.get( - url, - params={ - "q": query, - "format": "json", - "categories": params.category or "general", - "pageno": max(1, params.page), - **({"language": params.language} if params.language else {}), - "safesearch": "1", - }, - headers={"User-Agent": USER_AGENT}, - timeout=WEB_TIMEOUT, - follow_redirects=False, - ) - resp.raise_for_status() - data = resp.json() - except httpx.HTTPError as e: - logger.warning("web_search failed: %s", e) + + attempts: list[str] = [] + unresponsive: list[str] = [] + reachable = False + last_error: Exception | None = None + + for name, provider in _provider_chain(): + attempts.append(name) + try: + results, engines = provider(query, params) + except (httpx.HTTPError, ValueError, AttributeError) as e: + logger.warning("web_search provider %s failed: %s", name, e) + last_error = e + continue + reachable = True + if engines: + unresponsive = engines + if results: + payload: dict[str, Any] = { + "query": query, + "provider": name, + "results": results, + "count": len(results), + } + if unresponsive: + payload["unresponsive_engines"] = unresponsive[:8] + return payload + + if not reachable: raise ToolError( "Le moteur de recherche web est momentanément indisponible.", code="web_search_unavailable", - ) from e - results: list[dict[str, Any]] = [] - for item in (data.get("results") or [])[: params.max_results]: - results.append( - { - "title": (item.get("title") or "")[:300], - "url": item.get("url") or "", - "snippet": (item.get("content") or "")[:600], - "published": item.get("publishedDate"), - "score": item.get("score"), - } - ) - unresponsive = [ - name for entry in (data.get("unresponsive_engines") or []) - for name in ([entry[0]] if isinstance(entry, (list, tuple)) and entry else [entry]) - if isinstance(name, str) - ] - payload: dict[str, Any] = { + ) from last_error + + # Every provider answered but returned nothing: tell the model explicitly + # so it stops retrying the same query until its tool quota burns out. + payload = { "query": query, - "engine": "searxng", - "results": results, - "count": len(results), + "provider": attempts[-1], + "results": [], + "count": 0, + "warning": ( + "Aucun résultat : les fournisseurs de recherche web sont " + f"indisponibles ({', '.join(attempts)}). " + "Ne relance pas la même recherche — dis-le à l'utilisateur." + ), } if unresponsive: payload["unresponsive_engines"] = unresponsive[:8] - if not results: - # An instance whose upstream engines are all blocked (CAPTCHA / rate - # limit) answers 200 with an empty list. Without an explicit hint the - # model retries the same search until it burns its tool quota. - payload["warning"] = ( - "Aucun résultat : les moteurs de recherche de l'instance SearXNG sont " - f"indisponibles ({', '.join(unresponsive[:5]) or 'inconnus'}). " - "Ne relance pas la même recherche — dis-le à l'utilisateur." - ) return payload diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index 74be315..7d74516 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.7.0" +version = "2.7.1" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 7e2415b..fc00cf0 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "obsigate-desktop" -version = "2.7.0" +version = "2.7.1" description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian" authors = ["Bruno Charest"] edition = "2021" diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json index 037318f..32a03a3 100644 --- a/desktop/tauri.conf.json +++ b/desktop/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json", "productName": "ObsiGate", - "version": "2.7.0", + "version": "2.7.1", "identifier": "com.obsigate.desktop", "build": { "frontendDist": "../frontend", diff --git a/docs/ISSUES_TODOLIST.md b/docs/ISSUES_TODOLIST.md index 73ebced..c7f2b8b 100644 --- a/docs/ISSUES_TODOLIST.md +++ b/docs/ISSUES_TODOLIST.md @@ -160,6 +160,7 @@ Avant de corriger quoi que ce soit, un agent IA doit : | *BUG-048* | [🟡 IMPORTANT] Assistant IA : les entrées « Contextes » et « Skills » du menu « + » n'ouvraient pas leur menu (`@` / `/`) | 🟢 corrigé | P2 | 📱 frontend | IA | `frontend/js/bookslm.js` | Menu « + » de l'assistant → cliquer « Contextes » ou « Skills » | `e.stopPropagation()` sur les entrées du panneau `.bookslm-ext-menu` (le clic remontait au gestionnaire du panneau qui annulait le rendu asynchrone) | Journal 2026-09-16. Tests : `tests/frontend/ai.test.mjs` (+3) | | *BUG-049* | [🔵 MINEUR] Assistant IA : icône du bouton « + » invisible (largeur SVG nulle) | 🟢 corrigé | P3 | 📱 frontend | IA | `frontend/style.css` | Ouvrir l'assistant et observer le bouton « + » | Sélecteur porté à `.bookslm-input-area button.bookslm-btn-plus` (la règle générique `padding: 8px 16px` sur un bouton 32 px annulait la largeur de contenu) | Vérifié navigateur : SVG 0 px → 18 px. Journal 2026-09-16 | | *BUG-050* | [🟡 IMPORTANT] Assistant IA : échec de la création d'un sous-dossier contenant un fichier (appels d'outils parallèles + confirmation) | 🟢 corrigé | P1 | ⚙️ backend + 🤖 ia | IA | `backend/agent/loop.py`, `backend/services/mutations.py`, `backend/tools/service.py`, `backend/bookslm.py` | Mode Agent : « crée le dossier X et un fichier Y dedans » puis Appliquer | `backend/agent/loop.py` : résultats « deferred » (`_deferred_tool_message`) pour les `tool_calls` non atteints lors d'une pause de confirmation ; `backend/services/mutations.py` : `create_directory(..., exist_ok=True)` ; `backend/tools/service.py` + `backend/bookslm.py` : consignes `create_file` (parents auto-créés, chemin imbriqué unique) | Cause : le message assistant listait plusieurs `tool_calls` mais la pause n'ajoutait le résultat que du seul appel confirmé → conversation invalide (tool_call_id sans réponse) au resume. Tests : `tests/test_agent_loop.py` (+1), `tests/test_tools_mutations.py` (+1), `tests/test_api_main.py` (+1) | +| *BUG-051* | [🟡 IMPORTANT] Assistant IA : la recherche web répond toujours « je ne peux pas accéder à internet » (mode agent ou non) | 🟢 corrigé | P1 | ⚙️ backend + 🤖 ia | IA | `backend/tools/web.py`, `tests/test_web_tools.py` | Assistant : « fais une recherche sur l'horaire du Canadien de Montréal 2026-2027 » | `backend/tools/web.py` : chaîne de repli sans clé — SearXNG puis DuckDuckGo (HTML sans JS) puis Bing (HTML), premier fournisseur non vide retenu (`provider`), replis désactivables via `OBSIGATE_WEB_FALLBACK=0` | Cause : l'instance SearXNG par défaut (`search.dracodev.net`) remonte 0 résultat (moteurs amont suspendus/CAPTCHA) → le modèle en déduisait une absence d'accès réseau. Tests : `tests/test_web_tools.py` (+4) | | | | | | | | | | | | | ### TODOs techniques (améliorations / nouvelles tâches) @@ -210,6 +211,7 @@ Avant de corriger quoi que ce soit, un agent IA doit : | 2026-09-16 | BUG-048, #98 | Correction + feature | `frontend/js/bookslm.js`, `frontend/js/config.js`, `frontend/js/sidebar.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `.gitea/workflows/ci.yml`, `tests/frontend/ai.test.mjs`, `tests/frontend/ai-sidebar.test.mjs` (nouveau), `docs/features/ai-assistant-history.md`, `docs/ROADMAP.md`, `CHANGELOG.md` | **BUG-048** : les entrées « Contextes » et « Skills » du menu « + » ouvraient bien leur menu (`@` / `/`), mais le clic remontait au gestionnaire du panneau qui annulait le rendu asynchrone → menu jamais affiché ; correction par `e.stopPropagation()` sur les entrées du panneau `.bookslm-ext-menu`. **#98** : la barre de filtrage de la sidebar agit désormais sur l'onglet « Historique IA » — `filterAIHistory()` (config.js) filtre par titre, aperçu, répertoire, contexte ou libellé de mode, insensible casse/accents (`_aiNorm`), cache sessions `_aiSessionsCache`, message « aucune correspondance » (`bookslm.history_no_match`) dans la liste et placeholder dédié (`sidebar.filter_ai`) ; `initSidebarFilter` (sidebar.js) route saisie/touche casse/bouton « × » vers `filterAIHistory` quand l'onglet IA est actif ; chaque entrée du panneau « + » porte l'icône Lucide `plus`. Vérifié : tests frontend IA 87/87 (+3), nouvelle suite `ai-sidebar` 6/6, unit 9/9, 9 suites JSDOM vertes, validate-imports 38 modules, pytest / ruff / mypy inchangés (aucune modification backend). | 🟢 corrigé (en attente vérif utilisateur) — CI Gitea verte (lint, test, security, build, e2e) pour v2.5.0 (run #1511) | | 2026-09-16 | BUG-049, #99, #100 | Correction + feature | `frontend/style.css`, `frontend/js/config.js`, `frontend/js/viewer.js`, `frontend/js/sidebar.js`, `frontend/js/bookslm.js`, `frontend/locales/{fr,en}.json`, `.gitea/workflows/ci.yml`, `tests/frontend/ai.test.mjs`, `tests/frontend/sidebar-filters.test.mjs` (nouveau), `docs/features/sidebar-filters.md` (nouvelle), `docs/features/ai-assistant-history.md`, `docs/ROADMAP.md`, `CHANGELOG.md` | **BUG-049** : icône du bouton « + » de l'assistant invisible — la règle générique `.bookslm-input-area button` (spécificité supérieure) imposait `padding: 8px 16px` sur un bouton `width: 32px` ⇒ largeur de contenu nulle ⇒ SVG `width: 0px` ; sélecteur porté à `.bookslm-input-area button.bookslm-btn-plus` (+ `:hover`), vérifié en navigateur (Playwright : SVG 0 px → 18 px). **#99** : la barre de filtrage de la sidebar agit désormais sur les vues **Récents** (`filterRecentFiles`, titre/chemin/vault/aperçu/tags) et **Sauvegardes** (`filterSavedSearches`, cumulable avec les pills type), insensible casse/accents (`_sidebarNorm`/`_savedNorm`), message d'absence de résultat (`sidebar.no_results`) et placeholders dédiés (`sidebar.filter_recent`, `sidebar.filter_saved`) ; `initSidebarFilter` refactoré en `routeFilter`/`routeClear` couvrant les 5 onglets. **#100** : « Deep Research » ajoute une **pastille** `.bookslm-chip-deep-research` (au lieu d'injecter la directive dans le composeur), active le mode Agent et injecte la directive au moment de l'envoi. Vérifié : tests frontend IA 88/88 (+1), `sidebar-filters` 8/8 (nouveau), `ai-sidebar` 6/6, unit 9/9, 9 suites JSDOM vertes, validate-imports 38 modules, vérification navigateur du bouton « + » ; backend inchangé (pytest / ruff / mypy valides). | 🟢 corrigé (en attente vérif utilisateur) | | 2026-09-16 | BUG-050 | Correction | `backend/agent/loop.py`, `backend/services/mutations.py`, `backend/tools/service.py`, `backend/bookslm.py`, `tests/test_agent_loop.py`, `tests/test_tools_mutations.py`, `tests/test_api_main.py`, `docs/features/ai-tools-mcp.md`, `CHANGELOG.md` | **BUG-050** : création d'un sous-dossier contenant un fichier en mode Agent. (1) La boucle d'agent renvoyait la conversation sans réponse pour les `tool_calls` non atteints lorsqu'un appel mutateur déclenchait une confirmation → le provider rejetait le tour de reprise (« tool_call_id » orphelin) ; les appels restants reçoivent désormais un résultat `deferred` explicite (`_deferred_tool_message`) que le modèle réémet après confirmation. (2) `create_directory` est idempotent côté outil IA (`exist_ok=True`, succès si le dossier existe), le REST restant strict (409). (3) Consignes renforcées : `create_file` crée les dossiers parents, un seul appel avec chemin imbriqué suffit (`backend/bookslm.py`, descriptions d'outils). Vérifié : pytest 1091 passed / 6 skipped, ruff 0 (backend), mypy 0 (71 fichiers), tests frontend validate-imports 38 modules + unit 9/9. | 🟢 corrigé (en attente vérif utilisateur) | +| 2026-09-16 | BUG-051 | Correction | `backend/tools/web.py`, `tests/test_web_tools.py`, `docs/features/ai-tools-roadmap.md`, `docs/features/ai-assistant-conversation-ux.md`, `docs/ROADMAP.md`, `CHANGELOG.md` | **BUG-051** : `web_search` ne dépend plus d'une seule instance SearXNG. Nouvelle chaîne de fournisseurs (`_provider_chain`) : SearXNG (auto-hébergé, JSON) → DuckDuckGo (`html.duckduckgo.com/html/`, extraction `result__a`/`result__snippet`, décodage du lien `uddg=`) → Bing (`www.bing.com/search`, extraction `h2 > a` + `p.b_lineclamp*`, décodage de la redirection `u=a1`), UA navigateur, premier fournisseur non vide retenu et exposé (`provider`). Le champ `warning` final liste les fournisseurs essayés ; replis désactivables via `OBSIGATE_WEB_FALLBACK=0` ; erreur `web_search_unavailable` uniquement si tous les fournisseurs sont injoignables. Vérifié : pytest 1082 passed / 6 skipped (14 erreurs MCP préexistantes, sans lien), ruff 0 (backend), mypy 0 (`backend/tools/web.py`), `tests/test_web_tools.py` 13/13, recherche live Bing (horaire Canadiens) sur l'hôte. | 🟢 corrigé (en attente vérif utilisateur) | --- diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 2be111c..93425a6 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # ObsiGate — Roadmap -> **Version :** 2.7.0 | **Dernière mise à jour :** 2026-09-16 +> **Version :** 2.7.1 | **Dernière mise à jour :** 2026-09-16 > **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact > vers les fonctionnalités livrées. > - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)** @@ -87,7 +87,8 @@ [features/ai-tools-roadmap.md](./features/ai-tools-roadmap.md) (frameworks évalués, bibliothèques par catégorie, transverse retry/cache/secrets/async). - **Sous-tâches :** - - [ ] `web_search` : chaîne de repli sans clé (DuckDuckGo) + fournisseurs optionnels (Tavily, Brave, SerpAPI, Exa) + - [x] `web_search` : chaîne de repli sans clé (SearXNG → DuckDuckGo → Bing, `OBSIGATE_WEB_FALLBACK`) — BUG-051 + - [ ] `web_search` : fournisseurs optionnels à clé (Tavily, Brave, SerpAPI, Exa) - [ ] `fetch_url` : pages dynamiques via Playwright (worker isolé) ; crawl multi-pages Scrapy en tâche de fond - [ ] Sources connectées : Gitea/GitHub (priorité haute) puis Google Drive / OneDrive (OAuth2 `authlib`) - [ ] Production de documents : conversion, tableurs, PDF/Word (outils WRITE + confirmation) diff --git a/docs/features/ai-assistant-conversation-ux.md b/docs/features/ai-assistant-conversation-ux.md index 498d160..c8e27b1 100644 --- a/docs/features/ai-assistant-conversation-ux.md +++ b/docs/features/ai-assistant-conversation-ux.md @@ -133,6 +133,11 @@ ne remonte aucun résultat (moteurs amont suspendus/CAPTCHA) : `warning` + `unresponsive_engines` dans le résultat — sans ce signal, l'assistant relançait la même recherche jusqu'au quota d'outils. + - [x] **G8.** **Chaîne de repli web (BUG-051)** : `web_search` interroge successivement + SearXNG, puis DuckDuckGo (HTML sans JS) puis Bing (HTML), et retient le premier + fournisseur non vide (`provider`) ; les replis se désactivent via + `OBSIGATE_WEB_FALLBACK=0`. Évite que l'assistant conclue « pas d'accès à internet » + quand l'instance SearXNG est bloquée par ses moteurs amont. ### Outils restants — documentés pour le futur (hors #91) La catégorie Notion « étapes » peut s'étendre ; chaque futur outil devra être un diff --git a/docs/features/ai-tools-roadmap.md b/docs/features/ai-tools-roadmap.md index 092a9d2..1f9f8f4 100644 --- a/docs/features/ai-tools-roadmap.md +++ b/docs/features/ai-tools-roadmap.md @@ -37,8 +37,11 @@ réécriture de la boucle n'est nécessaire. ## 3. Phase 2 — catégories à implémenter ### 3.1 Recherche web étendue (`web_search`) -- **Fallback sans clé** : aujourd'hui SearXNG auto-hébergé (`OBSIGATE_SEARXNG_URL`). - Prévoir une chaîne de repli si l'instance est indisponible (DuckDuckGo HTML). +- **Fallback sans clé — ✅ livré (BUG-051)** : chaîne de fournisseurs dans + `backend/tools/web.py` — SearXNG auto-hébergé (`OBSIGATE_SEARXNG_URL`) puis, si + aucun résultat, DuckDuckGo (`html.duckduckgo.com/html/`) puis Bing + (`www.bing.com/search`). Le premier fournisseur non vide est retenu et exposé + (`provider`) ; replis désactivables via `OBSIGATE_WEB_FALLBACK=0`. - **Fournisseurs optionnels** (clé dans Infisical, jamais en dur) : Tavily (résultats orientés agents), Brave Search API, SerpAPI (Google), Exa. Interface unifiée type `anysearch` pour un sélecteur de fournisseur unique. diff --git a/package.json b/package.json index 7fcb6f2..4d86ac2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsigate", - "version": "2.7.0", + "version": "2.7.1", "description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.", "main": "patch.js", "directories": { diff --git a/tests/test_web_tools.py b/tests/test_web_tools.py index 81d5ad5..71411c6 100644 --- a/tests/test_web_tools.py +++ b/tests/test_web_tools.py @@ -33,6 +33,40 @@ def _ctx() -> ToolContext: return ToolContext(user={"username": "tester", "vaults": []}, mode=ToolMode.IN_APP) +def _html_get(routes: dict[str, str], searxng: Any = None): + """Dispatch httpx.get by URL marker: searxng JSON, HTML providers, else error.""" + + def fake_get(url, params=None, **kw): + url = str(url) + if "search.dracodev.net" in url: + if searxng is not None: + return searxng + raise web.httpx.ConnectError("searxng down") + for marker, body in routes.items(): + if marker in url: + return FakeResponse(content=body.encode("utf-8"), url=url) + raise web.httpx.ConnectError(f"no route: {url}") + + return fake_get + + +DDG_HTML = ( + '" +) + +BING_HTML = ( + '

' + "Bing Result

" + '

Bing snippet here

' +) + + class TestRegistration: def test_tools_registered_read_only_in_app(self): for name in ("web_search", "fetch_url"): @@ -58,7 +92,7 @@ class TestWebSearch: monkeypatch.setattr(web.httpx, "get", fake_get) out = web.web_search(_ctx(), web.WebSearchInput(query="pizza", max_results=3)) - assert out["engine"] == "searxng" + assert out["provider"] == "searxng" assert out["count"] == 3 assert len(out["results"][0]["snippet"]) <= 600 assert captured["params"]["q"] == "pizza" @@ -70,11 +104,11 @@ class TestWebSearch: def test_empty_result_set_warns_about_blocked_engines(self, monkeypatch): """An all-blocked instance answers 200 with no results: the model must be told instead of retrying the same search until the quota burns.""" - monkeypatch.setattr(web.httpx, "get", lambda *a, **kw: FakeResponse(json_data={ + monkeypatch.setattr(web.httpx, "get", _html_get({}, searxng=FakeResponse(json_data={ "results": [], "number_of_results": 0, "unresponsive_engines": [["duckduckgo", "CAPTCHA"], ["google", "access denied"]], - })) + }))) out = web.web_search(_ctx(), web.WebSearchInput(query="meteo montreal")) assert out["count"] == 0 assert out["unresponsive_engines"] == ["duckduckgo", "google"] @@ -100,6 +134,41 @@ class TestWebSearch: web.web_search(_ctx(), web.WebSearchInput(query="x")) assert ei.value.code == "web_search_unavailable" + def test_falls_back_to_duckduckgo_when_searxng_empty(self, monkeypatch): + monkeypatch.setattr(web.httpx, "get", _html_get( + {"html.duckduckgo.com": DDG_HTML}, + searxng=FakeResponse(json_data={"results": []}), + )) + out = web.web_search(_ctx(), web.WebSearchInput(query="python release")) + assert out["provider"] == "duckduckgo" + assert out["count"] == 1 + assert out["results"][0]["title"] == "Example Page" + assert out["results"][0]["url"] == "https://example.com/page" + assert out["results"][0]["snippet"] == "A useful snippet" + + def test_falls_back_to_bing_when_searxng_unreachable(self, monkeypatch): + monkeypatch.setattr(web.httpx, "get", _html_get( + {"bing.com": BING_HTML}, + searxng=None, + )) + out = web.web_search(_ctx(), web.WebSearchInput(query="python release")) + assert out["provider"] == "bing" + assert out["count"] == 1 + assert out["results"][0]["title"] == "Bing Result" + assert out["results"][0]["url"] == "https://example.com/bing" + assert out["results"][0]["snippet"] == "Bing snippet here" + + def test_fallback_can_be_disabled(self, monkeypatch): + monkeypatch.setattr(web, "WEB_FALLBACK_ENABLED", False) + monkeypatch.setattr(web.httpx, "get", _html_get( + {"html.duckduckgo.com": DDG_HTML, "bing.com": BING_HTML}, + searxng=FakeResponse(json_data={"results": []}), + )) + out = web.web_search(_ctx(), web.WebSearchInput(query="python release")) + assert out["count"] == 0 + assert out["provider"] == "searxng" + assert "warning" in out + class TestFetchUrl: def test_html_converted_to_text(self, monkeypatch):