Compare commits

...
2 Commits
Author SHA1 Message Date
bruno 62cff271d8 fix(ai): entetes navigateur pour le repli web_search (Bing/DDG, BUG-051)
CI / lint (push) Successful in 1m39s
CI / security (push) Successful in 1m0s
CI / test (push) Successful in 2m9s
CI / build (push) Successful in 56s
CI / e2e (push) Successful in 11m9s
2026-09-16 23:17:27 -04:00
bruno 56b46cde0e fix(ai): chaine de repli web_search (SearXNG -> DuckDuckGo -> Bing, BUG-051)
CI / lint (push) Successful in 1m32s
CI / security (push) Successful in 1m0s
CI / test (push) Successful in 3m8s
CI / build (push) Successful in 56s
CI / e2e (push) Successful in 10m44s
2026-09-16 23:13:44 -04:00
15 changed files with 380 additions and 73 deletions
+7
View File
@@ -67,3 +67,10 @@ DEEPSEEK_MODEL=deepseek-chat
# Google Gemini # Google Gemini
# GEMINI_API_KEY=AIza... # GEMINI_API_KEY=AIza...
# GEMINI_MODEL=gemini-2.0-flash # 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
+31 -1
View File
@@ -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). 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 > **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.2**.
--- ---
@@ -14,6 +14,36 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
--- ---
## [2.7.2] — 2026-09-16
### Corrigé
- **BUG-051 (complément) - Repli Bing : en-têtes de navigation navigateur** : un `User-Agent`
navigateur seul ne suffit pas — Bing renvoie des résultats factices (SERP sans rapport avec
la requête) aux appels dépourvus des en-têtes de navigation habituels. Les fournisseurs HTML
(DuckDuckGo, Bing) envoient désormais `Accept-Language`, `Sec-Fetch-*` et
`Upgrade-Insecure-Requests` (`BROWSER_HEADERS`). Vérifié en conteneur : recherche
« Canadien de Montréal 2026-2027 » → résultats NHL / RDS / Wikipédia pertinents
(`provider: bing`). Fichier : `backend/tools/web.py`.
---
## [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 ## [2.7.0] — 2026-09-16
### Ajouté ### Ajouté
+3 -3
View File
@@ -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. **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.2-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![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/) [![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/) [![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 ## 📝 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.2).
--- ---
*Projet : ObsiGate | Version : 2.7.0 | Dernière mise à jour : Juin 2026* *Projet : ObsiGate | Version : 2.7.2 | Dernière mise à jour : Juin 2026*
+3 -3
View File
@@ -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. **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.2-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![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/) [![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/) [![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 ## 📝 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.2).
--- ---
*Project: ObsiGate | Version: 2.7.0 | Last updated: May 2026* *Project: ObsiGate | Version: 2.7.2 | Last updated: May 2026*
+1 -1
View File
@@ -1 +1 @@
2.7.0 2.7.2
+243 -54
View File
@@ -2,27 +2,35 @@
Phase 1 of the documented web-toolset roadmap: 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. * ``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. registry, SSRF-guarded (scheme + private-address rejection), and size-capped.
Configuration (environment): Configuration (environment):
* ``OBSIGATE_SEARXNG_URL`` — defaults to https://search.dracodev.net * ``OBSIGATE_SEARXNG_URL`` — defaults to https://search.dracodev.net
* ``OBSIGATE_WEB_TIMEOUT`` — seconds, default 10 * ``OBSIGATE_WEB_TIMEOUT`` — seconds, default 10
* ``OBSIGATE_WEB_FALLBACK`` — ``0``/``false`` disables the keyless HTML
fallbacks (SearXNG only), default enabled
""" """
from __future__ import annotations from __future__ import annotations
import base64
import binascii
import html as html_lib import html as html_lib
import ipaddress import ipaddress
import logging import logging
import os import os
import re import re
import socket import socket
from collections.abc import Callable
from typing import Any from typing import Any
from urllib.parse import urlparse from urllib.parse import parse_qs, urlparse
import httpx import httpx
@@ -34,7 +42,30 @@ logger = logging.getLogger("obsigate.tools.web")
SEARXNG_URL = os.environ.get("OBSIGATE_SEARXNG_URL", "https://search.dracodev.net") SEARXNG_URL = os.environ.get("OBSIGATE_SEARXNG_URL", "https://search.dracodev.net")
WEB_TIMEOUT = float(os.environ.get("OBSIGATE_WEB_TIMEOUT", "10")) 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)" 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"
)
# A minimal UA is not enough: Bing serves decoy SERPs (unrelated results) to
# requests missing the usual browser navigation headers.
BROWSER_HEADERS = {
"User-Agent": BROWSER_UA,
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
"Accept-Language": "fr-CA,fr;q=0.9,en-US;q=0.8,en;q=0.7",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1",
"Upgrade-Insecure-Requests": "1",
}
MAX_FETCH_BYTES = 1_500_000 MAX_FETCH_BYTES = 1_500_000
MAX_TEXT_CHARS = 20_000 MAX_TEXT_CHARS = 20_000
@@ -46,6 +77,18 @@ _BLOCK_SPLIT_RE = re.compile(
r"</?(?:p|div|br|li|h[1-6]|tr|table|ul|ol|section|article|header|footer)\b[^>]*>", r"</?(?:p|div|br|li|h[1-6]|tr|table|ul|ol|section|article|header|footer)\b[^>]*>",
re.IGNORECASE, re.IGNORECASE,
) )
_DDG_RESULT_RE = re.compile(
r'<a[^>]*class="result__a"[^>]*href="([^"]+)"[^>]*>(.*?)</a>', re.IGNORECASE | re.DOTALL
)
_DDG_SNIPPET_RE = re.compile(
r'<a[^>]*class="result__snippet"[^>]*>(.*?)</a>', re.IGNORECASE | re.DOTALL
)
_BING_RESULT_RE = re.compile(
r'<h2[^>]*>\s*<a[^>]*href="([^"]+)"[^>]*>(.*?)</a>', re.IGNORECASE | re.DOTALL
)
_BING_SNIPPET_RE = re.compile(
r'<p class="b_lineclamp[^"]*">(.*?)</p>', re.IGNORECASE | re.DOTALL
)
class SSRFError(ToolError): class SSRFError(ToolError):
@@ -99,6 +142,160 @@ def _html_to_text(raw: str) -> str:
return text.strip() 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=<urlencoded target>``."""
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=BROWSER_HEADERS,
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<base64url target>``."""
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=BROWSER_HEADERS,
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( @tool(
name="web_search", name="web_search",
description=( description=(
@@ -111,67 +308,59 @@ def _html_to_text(raw: str) -> str:
scopes=(ToolScope.IN_APP,), scopes=(ToolScope.IN_APP,),
) )
def web_search(ctx, params: WebSearchInput) -> dict[str, Any]: 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() query = params.query.strip()
if not query: if not query:
raise ToolError("Requête vide", code="invalid_arguments") raise ToolError("Requête vide", code="invalid_arguments")
url = SEARXNG_URL.rstrip("/") + "/search"
try: attempts: list[str] = []
resp = httpx.get( unresponsive: list[str] = []
url, reachable = False
params={ last_error: Exception | None = None
"q": query,
"format": "json", for name, provider in _provider_chain():
"categories": params.category or "general", attempts.append(name)
"pageno": max(1, params.page), try:
**({"language": params.language} if params.language else {}), results, engines = provider(query, params)
"safesearch": "1", except (httpx.HTTPError, ValueError, AttributeError) as e:
}, logger.warning("web_search provider %s failed: %s", name, e)
headers={"User-Agent": USER_AGENT}, last_error = e
timeout=WEB_TIMEOUT, continue
follow_redirects=False, reachable = True
) if engines:
resp.raise_for_status() unresponsive = engines
data = resp.json() if results:
except httpx.HTTPError as e: payload: dict[str, Any] = {
logger.warning("web_search failed: %s", e) "query": query,
"provider": name,
"results": results,
"count": len(results),
}
if unresponsive:
payload["unresponsive_engines"] = unresponsive[:8]
return payload
if not reachable:
raise ToolError( raise ToolError(
"Le moteur de recherche web est momentanément indisponible.", "Le moteur de recherche web est momentanément indisponible.",
code="web_search_unavailable", code="web_search_unavailable",
) from e ) from last_error
results: list[dict[str, Any]] = []
for item in (data.get("results") or [])[: params.max_results]: # Every provider answered but returned nothing: tell the model explicitly
results.append( # so it stops retrying the same query until its tool quota burns out.
{ payload = {
"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] = {
"query": query, "query": query,
"engine": "searxng", "provider": attempts[-1],
"results": results, "results": [],
"count": len(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: if unresponsive:
payload["unresponsive_engines"] = unresponsive[:8] 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 return payload
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]] [[package]]
name = "obsigate-desktop" name = "obsigate-desktop"
version = "2.7.0" version = "2.7.2"
dependencies = [ dependencies = [
"chrono", "chrono",
"env_logger", "env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "obsigate-desktop" name = "obsigate-desktop"
version = "2.7.0" version = "2.7.2"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian" description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"] authors = ["Bruno Charest"]
edition = "2021" edition = "2021"
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json", "$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate", "productName": "ObsiGate",
"version": "2.7.0", "version": "2.7.2",
"identifier": "com.obsigate.desktop", "identifier": "com.obsigate.desktop",
"build": { "build": {
"frontendDist": "../frontend", "frontendDist": "../frontend",
+3
View File
@@ -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-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-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-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) ### TODOs techniques (améliorations / nouvelles tâches)
@@ -210,6 +211,8 @@ 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-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-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-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<base64url>`), 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) |
| 2026-09-16 | BUG-051 (complément) | Correction | `backend/tools/web.py`, `CHANGELOG.md` | **BUG-051** suite : un `User-Agent` navigateur seul ne suffit pas — Bing renvoie une SERP factice (résultats sans rapport, ex. « highest paying jobs » / « Sam Reid ») aux requêtes sans en-têtes de navigation. Ajout de `BROWSER_HEADERS` (`Accept-Language`, `Sec-Fetch-*`, `Upgrade-Insecure-Requests`) pour DuckDuckGo et Bing. Vérifié **en conteneur** (`obsigate-test`, v2.7.1) : `web_search('Canadien de Montreal horaire matchs 2026 2027')` → `provider: bing`, 5 résultats pertinents (nhl.com/fr/canadiens, rds.ca, fr.wikipedia.org). | 🟢 corrigé (en attente vérif utilisateur) |
--- ---
+3 -2
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap # ObsiGate — Roadmap
> **Version :** 2.7.0 | **Dernière mise à jour :** 2026-09-16 > **Version :** 2.7.2 | **Dernière mise à jour :** 2026-09-16
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact > **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
> vers les fonctionnalités livrées. > vers les fonctionnalités livrées.
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)** > - **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, [features/ai-tools-roadmap.md](./features/ai-tools-roadmap.md) (frameworks évalués,
bibliothèques par catégorie, transverse retry/cache/secrets/async). bibliothèques par catégorie, transverse retry/cache/secrets/async).
- **Sous-tâches :** - **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 - [ ] `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`) - [ ] Sources connectées : Gitea/GitHub (priorité haute) puis Google Drive / OneDrive (OAuth2 `authlib`)
- [ ] Production de documents : conversion, tableurs, PDF/Word (outils WRITE + confirmation) - [ ] Production de documents : conversion, tableurs, PDF/Word (outils WRITE + confirmation)
@@ -133,6 +133,11 @@
ne remonte aucun résultat (moteurs amont suspendus/CAPTCHA) : `warning` + ne remonte aucun résultat (moteurs amont suspendus/CAPTCHA) : `warning` +
`unresponsive_engines` dans le résultat — sans ce signal, l'assistant relançait la `unresponsive_engines` dans le résultat — sans ce signal, l'assistant relançait la
même recherche jusqu'au quota d'outils. 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) ### Outils restants — documentés pour le futur (hors #91)
La catégorie Notion « étapes » peut s'étendre ; chaque futur outil devra être un La catégorie Notion « étapes » peut s'étendre ; chaque futur outil devra être un
+5 -2
View File
@@ -37,8 +37,11 @@ réécriture de la boucle n'est nécessaire.
## 3. Phase 2 — catégories à implémenter ## 3. Phase 2 — catégories à implémenter
### 3.1 Recherche web étendue (`web_search`) ### 3.1 Recherche web étendue (`web_search`)
- **Fallback sans clé** : aujourd'hui SearXNG auto-hébergé (`OBSIGATE_SEARXNG_URL`). - **Fallback sans clé — ✅ livré (BUG-051)** : chaîne de fournisseurs dans
Prévoir une chaîne de repli si l'instance est indisponible (DuckDuckGo HTML). `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 - **Fournisseurs optionnels** (clé dans Infisical, jamais en dur) : Tavily
(résultats orientés agents), Brave Search API, SerpAPI (Google), Exa. (résultats orientés agents), Brave Search API, SerpAPI (Google), Exa.
Interface unifiée type `anysearch` pour un sélecteur de fournisseur unique. Interface unifiée type `anysearch` pour un sélecteur de fournisseur unique.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "obsigate", "name": "obsigate",
"version": "2.7.0", "version": "2.7.2",
"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.", "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", "main": "patch.js",
"directories": { "directories": {
+72 -3
View File
@@ -33,6 +33,40 @@ def _ctx() -> ToolContext:
return ToolContext(user={"username": "tester", "vaults": []}, mode=ToolMode.IN_APP) 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 = (
'<div class="result">'
'<a rel="nofollow" class="result__a" '
'href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com%2Fpage&amp;rut=1">'
"Example <b>Page</b></a>"
'<a class="result__snippet" href="#">A useful snippet</a>'
"</div>"
)
BING_HTML = (
'<h2 class=""><a target="_blank" '
'href="https://www.bing.com/ck/a?u=a1aHR0cHM6Ly9leGFtcGxlLmNvbS9iaW5n&amp;ntb=1">'
"Bing <strong>Result</strong></a></h2>"
'<p class="b_lineclamp2">Bing snippet here</p>'
)
class TestRegistration: class TestRegistration:
def test_tools_registered_read_only_in_app(self): def test_tools_registered_read_only_in_app(self):
for name in ("web_search", "fetch_url"): for name in ("web_search", "fetch_url"):
@@ -58,7 +92,7 @@ class TestWebSearch:
monkeypatch.setattr(web.httpx, "get", fake_get) monkeypatch.setattr(web.httpx, "get", fake_get)
out = web.web_search(_ctx(), web.WebSearchInput(query="pizza", max_results=3)) 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 out["count"] == 3
assert len(out["results"][0]["snippet"]) <= 600 assert len(out["results"][0]["snippet"]) <= 600
assert captured["params"]["q"] == "pizza" assert captured["params"]["q"] == "pizza"
@@ -70,11 +104,11 @@ class TestWebSearch:
def test_empty_result_set_warns_about_blocked_engines(self, monkeypatch): def test_empty_result_set_warns_about_blocked_engines(self, monkeypatch):
"""An all-blocked instance answers 200 with no results: the model must """An all-blocked instance answers 200 with no results: the model must
be told instead of retrying the same search until the quota burns.""" 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": [], "results": [],
"number_of_results": 0, "number_of_results": 0,
"unresponsive_engines": [["duckduckgo", "CAPTCHA"], ["google", "access denied"]], "unresponsive_engines": [["duckduckgo", "CAPTCHA"], ["google", "access denied"]],
})) })))
out = web.web_search(_ctx(), web.WebSearchInput(query="meteo montreal")) out = web.web_search(_ctx(), web.WebSearchInput(query="meteo montreal"))
assert out["count"] == 0 assert out["count"] == 0
assert out["unresponsive_engines"] == ["duckduckgo", "google"] assert out["unresponsive_engines"] == ["duckduckgo", "google"]
@@ -100,6 +134,41 @@ class TestWebSearch:
web.web_search(_ctx(), web.WebSearchInput(query="x")) web.web_search(_ctx(), web.WebSearchInput(query="x"))
assert ei.value.code == "web_search_unavailable" 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: class TestFetchUrl:
def test_html_converted_to_text(self, monkeypatch): def test_html_converted_to_text(self, monkeypatch):