Compare commits

...
3 Commits
Author SHA1 Message Date
bruno e3434d19ea fix(ai): garantir une reponse finale quand la boucle d'agent epuise son budget (BUG-052)
CI / lint (push) Successful in 1m31s
CI / security (push) Successful in 1m2s
CI / test (push) Successful in 3m25s
CI / build (push) Successful in 56s
CI / e2e (push) Successful in 10m41s
2026-09-16 23:28:56 -04:00
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
17 changed files with 522 additions and 90 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
+47 -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.3**.
--- ---
@@ -14,6 +14,52 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
--- ---
## [2.7.3] — 2026-09-16
### Corrigé
- **BUG-052 - Assistant IA : recherche web sans réponse finale (étapes et sources affichées, aucun texte)** :
quand le budget d'itérations (`DEFAULT_MAX_ITERATIONS = 10`) ou le quota d'appels d'outils
était épuisé pendant que le modèle enchaînait encore des recherches/lectures, la boucle
d'agent renvoyait un contenu vide → la conversation n'affichait que les étapes et les
sources. La boucle effectue désormais un **dernier appel sans outil** qui demande au modèle
de synthétiser les informations recueillies (`_finalize_answer`), avec un repli déterministe
listant les sources si cet appel échoue ou reste vide. Les appels d'outils non atteints du
lot en cours de quota reçoivent un résultat `deferred` pour garder la conversation valide.
Fichier : `backend/agent/loop.py`. Tests : `tests/test_agent_loop.py` (+2).
---
## [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.3-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.3).
--- ---
*Projet : ObsiGate | Version : 2.7.0 | Dernière mise à jour : Juin 2026* *Projet : ObsiGate | Version : 2.7.3 | 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.3-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.3).
--- ---
*Project: ObsiGate | Version: 2.7.0 | Last updated: May 2026* *Project: ObsiGate | Version: 2.7.3 | Last updated: May 2026*
+1 -1
View File
@@ -1 +1 @@
2.7.0 2.7.3
+85 -17
View File
@@ -40,6 +40,15 @@ MAX_TOOL_RESULT_CHARS = 100_000
# Quota: maximum tool calls executed per agent run (``BOOKSLM_MAX_TOOL_CALLS``). # Quota: maximum tool calls executed per agent run (``BOOKSLM_MAX_TOOL_CALLS``).
DEFAULT_MAX_TOOL_CALLS = int(os.environ.get("BOOKSLM_MAX_TOOL_CALLS", "25")) DEFAULT_MAX_TOOL_CALLS = int(os.environ.get("BOOKSLM_MAX_TOOL_CALLS", "25"))
# Sent as a last user turn when the loop stopped before the model produced an
# answer (iteration/quota budget exhausted while it was still calling tools).
_FINALIZE_INSTRUCTION = (
"N'appelle plus aucun outil. Réponds maintenant directement à l'utilisateur, "
"en français, à partir des informations déjà recueillies ci-dessus. "
"Structure la réponse en Markdown, cite les liens sources utiles, et si les "
"informations sont insuffisantes, dis-le explicitement."
)
# Stopping reasons # Stopping reasons
STOP_DONE = "done" STOP_DONE = "done"
STOP_MAX_ITERATIONS = "max_iterations" STOP_MAX_ITERATIONS = "max_iterations"
@@ -109,8 +118,8 @@ def _assistant_tool_message(content: str | None, tool_calls: list[Any]) -> dict[
} }
def _deferred_tool_message(call: Any) -> dict[str, Any]: def _deferred_tool_message(call: Any, reason: str | None = None) -> dict[str, Any]:
"""Answer a tool call that was not reached because the run paused. """Answer a tool call that was not reached because the run stopped early.
A single LLM response may carry several tool calls. When one of them is A single LLM response may carry several tool calls. When one of them is
mutating and pauses the run for confirmation, the assistant message already mutating and pauses the run for confirmation, the assistant message already
@@ -125,7 +134,7 @@ def _deferred_tool_message(call: Any) -> dict[str, Any]:
"name": call.name, "name": call.name,
"content": json.dumps({ "content": json.dumps({
"status": "deferred", "status": "deferred",
"reason": ( "reason": reason or (
"Not executed: the run paused to confirm an earlier tool call. " "Not executed: the run paused to confirm an earlier tool call. "
"Re-issue this call if it is still needed." "Re-issue this call if it is still needed."
), ),
@@ -133,6 +142,69 @@ def _deferred_tool_message(call: Any) -> dict[str, Any]:
} }
def _fallback_summary(executed: list[ToolCallRecord]) -> str:
"""Deterministic non-empty answer built from the gathered tool results.
Used only if the final synthesis call fails or returns nothing, so a turn
never ends on an empty message (BUG-052).
"""
lines: list[str] = []
for record in executed:
data = record.result
if not isinstance(data, dict):
continue
for item in (data.get("results") or [])[:5]:
if not isinstance(item, dict):
continue
title = item.get("title") or item.get("url") or ""
url = item.get("url") or ""
lines.append(f"- [{title}]({url})" if url else f"- {title}")
if data.get("url") and data.get("text"):
title = data.get("title") or data["url"]
lines.append(f"- [{title}]({data['url']})")
if not lines:
return "Je n'ai pas pu produire de réponse à partir des résultats obtenus."
unique = list(dict.fromkeys(lines))
return "Voici les sources pertinentes trouvées :\n" + "\n".join(unique)
async def _finalize_answer(
llm: Callable[..., Any],
convo: list[dict[str, Any]],
executed: list[ToolCallRecord],
steps: list[dict[str, Any]],
iterations: int,
stopped: str,
) -> AgentResult:
"""Guarantee a textual answer when the loop stopped before producing one.
Web research often exhausts the iteration budget while the model is still
calling tools; returning ``content=""`` left the conversation with steps and
sources but no answer. One final tool-less call asks the model to synthesize
the gathered results, and a deterministic source list is used as a last
resort (BUG-052).
"""
content = ""
if executed:
try:
response = await llm(
[*convo, {"role": "user", "content": _FINALIZE_INSTRUCTION}], []
)
content = (response.content or "").strip()
except Exception as e:
logger.warning(f"Agent final synthesis failed: {e}")
if not content:
content = _fallback_summary(executed)
return AgentResult(
content=content,
messages=convo,
tool_calls=executed,
steps=steps,
iterations=iterations,
stopped=stopped,
)
def _execute_confirmed( def _execute_confirmed(
ctx: ToolContext, ctx: ToolContext,
confirm_pending: dict[str, Any], confirm_pending: dict[str, Any],
@@ -275,13 +347,14 @@ async def run_agent(
for index, call in enumerate(response.tool_calls): for index, call in enumerate(response.tool_calls):
if quota is not None and len(executed) >= quota: if quota is not None and len(executed) >= quota:
logger.warning(f"Agent reached the tool-call quota ({quota})") logger.warning(f"Agent reached the tool-call quota ({quota})")
return AgentResult( # Keep the conversation valid for the synthesis call: the
content=response.content or "", # assistant message announced every tool call of the batch.
messages=convo, for skipped in response.tool_calls[index:]:
tool_calls=executed, convo.append(_deferred_tool_message(
steps=steps, skipped, "Not executed: the tool-call quota was reached."
iterations=iteration, ))
stopped=STOP_QUOTA_EXCEEDED, return await _finalize_answer(
llm, convo, executed, steps, iteration, STOP_QUOTA_EXCEEDED
) )
try: try:
result = call_tool(call.name, ctx, call.arguments) result = call_tool(call.name, ctx, call.arguments)
@@ -327,11 +400,6 @@ async def run_agent(
}) })
logger.warning(f"Agent reached max iterations ({max_iterations})") logger.warning(f"Agent reached max iterations ({max_iterations})")
return AgentResult( return await _finalize_answer(
content="", llm, convo, executed, steps, max_iterations, STOP_MAX_ITERATIONS
messages=convo,
tool_calls=executed,
steps=steps,
iterations=max_iterations,
stopped=STOP_MAX_ITERATIONS,
) )
+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.3"
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.3"
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.3",
"identifier": "com.obsigate.desktop", "identifier": "com.obsigate.desktop",
"build": { "build": {
"frontendDist": "../frontend", "frontendDist": "../frontend",
+5
View File
@@ -160,6 +160,8 @@ 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) |
| *BUG-052* | [🟡 IMPORTANT] Assistant IA : recherche web sans réponse finale (10 étapes + sources affichées, aucun texte dans la conversation) | 🟢 corrigé | P1 | ⚙️ backend + 🤖 ia | IA | `backend/agent/loop.py`, `tests/test_agent_loop.py` | Assistant (mode agent) : recherche web qui enchaîne 10 étapes puis n'affiche aucune réponse | `backend/agent/loop.py` : `_finalize_answer` — dernier appel LLM sans outil (instruction de synthèse) quand le budget d'itérations/quota est épuisé, repli déterministe `_fallback_summary` (liste des sources), résultats `deferred` pour les appels non atteints du lot en quota | Cause : `content=""` renvoyé sur `STOP_MAX_ITERATIONS`/`STOP_QUOTA_EXCEEDED` alors que le modèle appelait encore des outils. Tests : `tests/test_agent_loop.py` (+2) |
| | | | | | | | | | | | | | | | | | | | | | | |
### TODOs techniques (améliorations / nouvelles tâches) ### TODOs techniques (améliorations / nouvelles tâches)
@@ -210,6 +212,9 @@ 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) |
| 2026-09-16 | BUG-052 | Correction | `backend/agent/loop.py`, `tests/test_agent_loop.py`, `CHANGELOG.md` | **BUG-052** : la boucle d'agent ne rendait plus jamais de réponse vide. `_finalize_answer` : à l'épuisement du budget d'itérations (`STOP_MAX_ITERATIONS`) ou du quota d'appels (`STOP_QUOTA_EXCEEDED`), un dernier appel LLM **sans outil** reçoit une instruction de synthèse (« N'appelle plus aucun outil. Réponds maintenant… ») et son texte devient la réponse ; si l'appel échoue ou reste vide, `_fallback_summary` compose une liste déterministe des sources (`web_search`/`fetch_url`) pour ne jamais renvoyer un tour vide. Les `tool_calls` non atteints lors d'un arrêt sur quota reçoivent un résultat `deferred` (conversation valide pour la synthèse). Vérifié : `tests/test_agent_loop.py` 16/16 (+2 : synthèse finale, repli sources), suite complète 1084 passed / 6 skipped (14 erreurs MCP préexistantes), ruff 0 (backend), mypy 0 (`backend/agent/loop.py`). | 🟢 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.3 | **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
+10 -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.
@@ -77,6 +80,11 @@ audit) et **jamais** avec un token en dur :
- Livré : la note intermédiaire du modèle devient une étape visible. - Livré : la note intermédiaire du modèle devient une étape visible.
- Extension possible : exposer les itérations de la boucle (`iterations`) comme - Extension possible : exposer les itérations de la boucle (`iterations`) comme
étapes de planification quand un outil de plan est ajouté. étapes de planification quand un outil de plan est ajouté.
- **Garantie de réponse finale — ✅ livré (BUG-052)** : à l'épuisement du budget
d'itérations ou du quota d'appels d'outils, `_finalize_answer` déclenche un
dernier appel LLM **sans outil** (instruction de synthèse) ; un repli
déterministe liste les sources si cet appel échoue. Une recherche web ne peut
plus se terminer sur une conversation sans texte.
## 4. Transverse — à faire avec la phase 2 ## 4. Transverse — à faire avec la phase 2
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "obsigate", "name": "obsigate",
"version": "2.7.0", "version": "2.7.3",
"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": {
+34
View File
@@ -182,6 +182,40 @@ class TestConfirmationAndLimits:
assert result.iterations == 2 assert result.iterations == 2
assert len(result.tool_calls) == 2 assert len(result.tool_calls) == 2
@pytest.mark.asyncio
async def test_max_iterations_synthesizes_final_answer(self, monkeypatch):
"""BUG-052: exhausting the budget must still produce an answer."""
_register(monkeypatch, "_echo", lambda ctx, params: {"value": 1})
llm = ScriptedLLM([
LLMResponse(tool_calls=[ToolCall(id="1", name="_echo", arguments={})]),
LLMResponse(tool_calls=[ToolCall(id="2", name="_echo", arguments={})]),
LLMResponse(content="synthèse finale"),
])
result = await run_agent(
[{"role": "user", "content": "loop"}], ctx=_ctx(), llm=llm, max_iterations=2
)
assert result.stopped == STOP_MAX_ITERATIONS
assert result.content == "synthèse finale"
# The last call is tool-less and carries the synthesis instruction.
assert llm.calls[-1]["tools"] == []
assert "N'appelle plus aucun outil" in llm.calls[-1]["messages"][-1]["content"]
@pytest.mark.asyncio
async def test_max_iterations_falls_back_to_sources(self, monkeypatch):
"""An empty/failed synthesis still returns the gathered sources."""
_register(monkeypatch, "_search", lambda ctx, params: {
"results": [{"title": "T", "url": "https://ex.dev/a"}]
})
llm = ScriptedLLM([
LLMResponse(tool_calls=[ToolCall(id="1", name="_search", arguments={})]),
LLMResponse(content=""),
])
result = await run_agent(
[{"role": "user", "content": "loop"}], ctx=_ctx(), llm=llm, max_iterations=1
)
assert result.stopped == STOP_MAX_ITERATIONS
assert "https://ex.dev/a" in result.content
# ═══════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════
# Permissions (index-backed) # Permissions (index-backed)
+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):