Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3434d19ea | ||
|
|
62cff271d8 | ||
|
|
56b46cde0e |
@@ -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
@@ -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
@@ -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.
|
||||||
|
|
||||||
[]()
|
[]()
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](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*
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
[]()
|
[]()
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](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*
|
||||||
|
|||||||
+85
-17
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Generated
+1
-1
@@ -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
@@ -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,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",
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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": {
|
||||||
|
|||||||
@@ -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
@@ -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&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&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):
|
||||||
|
|||||||
Reference in New Issue
Block a user