fix: side peek des bases repasse en vanilla JS + largeur 1100px standard (v7.49.0)
FlowDeck CI / test (push) Failing after 3h13m58s
FlowDeck CI / lint (push) Successful in 2m12s
FlowDeck CI / docker (push) Skipped

- Panneau peek: les bindings Alpine (x-data absent du conteneur) rendaient
  loovverture et le redimensionnement inoperants -> cblage direct sur le document.
- Helper unique window.fdWirePeekResize (app.js): pointer capture, 300px-90vw,
  clic=fermer, largeur persiste fd_peek_width partagee entre les 4 peeks.
- database-table-container margin:0 (tableau colle a gauche, marge Library).
- .lib-container remonte dans app.css (trash etait pleine largeur), .db-index 1100px.
- ObsiGate verifie sans code: creation .xlsx OK (openpyxl, #186).
This commit is contained in:
2026-10-05 22:47:36 -04:00
parent 894004a1f5
commit 1d1cdbd618
63 changed files with 9473 additions and 393 deletions
+6
View File
@@ -278,6 +278,12 @@ def _row_date(values: dict, prop_id: str, prop_name: str) -> str:
return str(raw or "")
# Public aliases — read-only helpers reused by the router layer (sidebar
# "Meeting" tab needs the date property + value of a collection row).
date_prop_id = _date_prop_id
row_date = _row_date
def _to_epoch(value: str | None) -> float:
if not value:
return 0.0
+222
View File
@@ -0,0 +1,222 @@
"""FlowDeck — cohérence collections ↔ workspace.
Une database (`collections`) peut être :
* **de workspace** — créée via `/db/api`, rattachée par `workspace_id` seul ;
* **de page** — page convertie en base (`collections.parent_page_id` +
`pages.collection_id`) ou base inline insérée dans une page.
Ce module centralise les deux sens de la relation :
* **lecture** — quelles collections sont encore *vivantes* dans un workspace
(leur page hôte n'est pas à la corbeille / n'a pas été supprimée) et
quelles lignes sont encore vivantes (leur page contenu n'est pas à la
corbeille). Sans ce filtre, ``/my-tasks`` continue d'afficher les tâches
d'un workspace dont tous les documents ont été supprimés ;
* **écriture** — la suppression en cascade d'une collection. L'ordre des
écritures est dicté par le schéma SQLite :
- ``pages.collection_id`` référence ``collections(id)`` en ``NO ACTION`` :
la page hôte doit être détachée **avant** la suppression ;
- ``collection_data_sources.source_collection_id`` référence également en
``NO ACTION`` : les vues liées qui pointent vers la collection doivent
être détachées ;
- ``collection_pages`` (lignes), ``collection_properties``,
``collection_views``, ``collection_permissions`` et les pages contenu
(``pages.collection_row_id``) partent en ``CASCADE``.
Sans ce détachement, ``DELETE FROM collections`` lève un ``IntegrityError``
dès qu'une page hôte (ou une vue liée) existe encore.
"""
from __future__ import annotations
import logging
logger = logging.getLogger(__name__)
# ── Lecture ──────────────────────────────────────────────────────────────
#: SQL du workspace **effectif** d'une collection.
#:
#: ``collections.workspace_id`` est renseigné pour les databases créées via
#: ``/db/api``, mais reste **NULL** pour une base portée par une page (page
#: convertie en base, base inline insérée dans un document) : son workspace
#: est alors celui de la page hôte. Sans ce repli, la requête ``c.workspace_id
#: = ?`` de `live_collection_ids` ne trouve jamais ces bases — et un JOIN
#: ``workspaces`` les exclut définitivement.
EFFECTIVE_WORKSPACE_SQL = (
"COALESCE({a}.workspace_id,"
" (SELECT p.workspace_id FROM pages p WHERE p.id = {a}.parent_page_id))"
)
def effective_workspace_sql(alias: str = "c") -> str:
"""Workspace effectif d'une collection (colonne ou ``NULL`` si orpheline)."""
return EFFECTIVE_WORKSPACE_SQL.format(a=alias)
def user_workspace_ids(conn, user_id: int) -> list[int]:
"""IDs des workspaces accessibles à l'utilisateur.
Propriétaire **ou** membre : c'est le même périmètre que la recherche
globale (`app/services/search._scope_where`), à la différence près qu'on
n'inclut pas les éléments sans workspace — une base non rattachable
n'appartient à personne et ne doit donc apparaître chez personne.
"""
rows = conn.execute(
"SELECT id FROM workspaces WHERE owner_id = ?"
" UNION SELECT workspace_id FROM workspace_members WHERE user_id = ?",
(user_id, user_id),
).fetchall()
return [r[0] for r in rows]
def live_collection_ids(conn, workspace_id: int) -> list[int]:
"""IDs des collections rattachées au workspace qui sont encore vivantes.
Une collection est exclue dès que sa page hôte a disparu :
* ``parent_page_id`` renseigné mais page absente ou à la corbeille ;
* une page ``content_format='collection'`` qui pointe encore vers elle
mais est à la corbeille.
Les collections sans page hôte (databases de workspace créées via
``/db/api``) restent toujours visibles.
Le rattachement passe par le workspace **effectif** : une base créée depuis
un document a ``workspace_id`` NULL et n'est repérée que via sa page hôte.
"""
rows = conn.execute(
f"""
SELECT c.id
FROM collections c
WHERE {effective_workspace_sql("c")} = ?
AND (c.parent_page_id IS NULL
OR EXISTS (SELECT 1 FROM pages p
WHERE p.id = c.parent_page_id AND p.deleted_at IS NULL))
AND NOT EXISTS (SELECT 1 FROM pages p
WHERE p.collection_id = c.id AND p.deleted_at IS NOT NULL)
ORDER BY c.name
""",
(workspace_id,),
).fetchall()
return [r["id"] for r in rows]
def live_row_ids(conn, collection_id: int) -> list[int]:
"""IDs des lignes racines d'une collection dont la page contenu vit.
La page contenu d'une ligne est créée paresseusement (``ensure_row_page``)
: son absence ne dit rien, mais son passage à la corbeille signifie que
l'utilisateur a supprimé le document → la ligne sort de My Tasks.
"""
rows = conn.execute(
"""
SELECT cp.id
FROM collection_pages cp
WHERE cp.collection_id = ?
AND cp.parent_id IS NULL
AND NOT EXISTS (SELECT 1 FROM pages p
WHERE p.collection_row_id = cp.id AND p.deleted_at IS NOT NULL)
ORDER BY cp.position
""",
(collection_id,),
).fetchall()
return [r["id"] for r in rows]
def live_document_count(conn, workspace_id: int) -> int:
"""Nombre de documents visibles du workspace (mêmes règles que l'arbre
de la sidebar : racines, non supprimés, hors pages contenu de ligne)."""
return conn.execute(
"SELECT COUNT(*) FROM pages "
"WHERE workspace_id = ? AND parent_id IS NULL "
"AND deleted_at IS NULL AND collection_row_id IS NULL",
(workspace_id,),
).fetchone()[0]
def collections_hosted_by_pages(conn, page_ids) -> list[int]:
"""IDs des collections dont l'une de ces pages est la page hôte.
Les deux relations sont couvertes : ``collections.parent_page_id`` (page
convertie en base, base inline) et ``pages.collection_id`` (page hôte).
Les pages contenu de ligne (``collection_row_id`` renseigné) n'hébergent
aucune collection et ne remontent donc rien.
À appeler **avant** de supprimer les pages si l'on veut aussi capturer la
relation ``pages.collection_id``.
"""
ids = [int(p) for p in page_ids if p is not None]
if not ids:
return []
qs = ",".join("?" * len(ids))
rows = conn.execute(
f"""
SELECT id FROM collections WHERE parent_page_id IN ({qs})
UNION
SELECT collection_id FROM pages
WHERE id IN ({qs}) AND collection_id IS NOT NULL
""",
[*ids, *ids],
).fetchall()
return [r["id"] for r in rows]
def collections_hosted_by_page(conn, page_id: int) -> list[int]:
"""``collections_hosted_by_pages`` pour une seule page."""
return collections_hosted_by_pages(conn, [page_id])
def workspace_collection_ids(conn, workspace_id: int) -> list[int]:
"""IDs de **toutes** les collections du workspace (sans filtre de
vivacité) — utilisé lors de la suppression du workspace."""
rows = conn.execute(
"SELECT id FROM collections WHERE workspace_id = ?", (workspace_id,)
).fetchall()
return [r["id"] for r in rows]
# ── Écriture ─────────────────────────────────────────────────────────────
def delete_collections(conn, collection_ids) -> int:
"""Supprime définitivement des collections et tout ce qu'elles portent.
Détache d'abord les références ``NO ACTION`` (pages hôtes, sources des
vues liées) puis supprime : le reste part en ``CASCADE``.
Ne fait **pas** de ``commit`` — l'appelant garde la main sur la
transaction. Retourne le nombre de collections supprimées.
"""
ids = [int(i) for i in collection_ids if i is not None]
if not ids:
return 0
qs = ",".join("?" * len(ids))
# 1. Pages hôtes : `pages.collection_id` est en NO ACTION. Une page dont le
# contenu EST la base redevient une page blocs normale.
conn.execute(
f"""UPDATE pages
SET collection_id = NULL,
content_format = CASE WHEN content_format = 'collection'
THEN 'blocks' ELSE content_format END
WHERE collection_id IN ({qs})""",
ids,
)
# 2. Vues liées : `source_collection_id` est en NO ACTION également. La vue
# liée perd sa source (elle n'a plus rien à afficher) mais survit.
conn.execute(
f"DELETE FROM collection_data_sources WHERE source_collection_id IN ({qs})",
ids,
)
# 3. Suppression : lignes, propriétés, vues, permissions et pages contenu
# suivent en CASCADE.
cur = conn.execute(f"DELETE FROM collections WHERE id IN ({qs})", ids)
return cur.rowcount or 0
def delete_collections_hosted_by_pages(conn, page_ids) -> int:
"""Supprime les databases portées par des pages définitivement supprimées
(corbeille purgée, suppression définitive, workspace effacé)."""
return delete_collections(conn, collections_hosted_by_pages(conn, page_ids))
+195
View File
@@ -109,6 +109,201 @@ GALLERY: dict[str, dict] = {
),
"allowed_tools": ["search_workspace", "read_document", "create_document", "write_blocks"],
},
# ── Vague 1 : skills 100 % locaux (aucun nouveau tool requis) ──────────
"veille-documentaire": {
"name": "Veille documentaire",
"icon": "🔎",
"description": "Repère les pages du workspace sans mise à jour récente et génère un digest d'entrée.",
"prompt_template": (
"Fais une veille documentaire du workspace :\n"
"1. Repère les documents dont le titre évoque une décision, un chantier ou un arbitrage "
"(search_workspace : « décision », « ADR », « roadmap », « spec », « bilan »).\n"
"2. Lis les plus pertinents (read_document) et note pour chacun : date de mise à jour, "
"statut (vivant / obsolète / contradictoire), propriétaire mentionné quand il existe.\n"
"3. Crée un document « Veille — <date> » (create_document) en 3 sections : "
"À valider (décisions non actées), À mettre à jour (docs dépassés par d'autres), "
"Cohérences (points de vue contradictoires entre documents).\n"
"4. Ne modifie aucun document : tu proposes, l'humain décide."
),
"allowed_tools": ["search_workspace", "read_document", "create_document", "write_blocks"],
},
"chasse-decisions": {
"name": "Chasse aux décisions",
"icon": "⚖️",
"description": " Rassemble en une page toutes les décisions prises dans le workspace, avec leur statut.",
"prompt_template": (
"Construis le registre des décisions du workspace :\n"
"1. Cherche les documents de décision (search_workspace : « décision », « arbitrage », "
"« choix de », « remplacement de »).\n"
"2. Lis-les (read_document) et extrais pour chacune : la décision, la date, la motivation, "
"les options écartées.\n"
"3. Crée un document « Registre des décisions » (create_document) en tableau : "
"Décision · Date · Motivation · Statut (actif / révoqué / jamais tranché).\n"
"4. Signale explicitement les sujets récurrents où aucune décision n'a été actée, "
"plutôt que de combler les trous."
),
"allowed_tools": ["search_workspace", "read_document", "create_document", "write_blocks"],
},
"onboarding-nouveau": {
"name": "Onboarding nouveau",
"icon": "🧭",
"description": "Génère un parcours de lecture ordonné pour une personne qui rejoint l'équipe.",
"prompt_template": (
"Prépare l'onboarding d'une nouvelle personne :\n"
"1. Repère les documents d'entrée : onboarding existant, README, architecture, process, "
"glossaire (search_workspace).\n"
"2. Lis-les (read_document) et classe-les par jour d'arrivée : Jour 1, Semaine 1, "
"Puis plus tard.\n"
"3. Crée un document « Onboarding — <prénom> » (create_document) : pour chaque étape, "
"le document à lire, ce qu'on doit en retenir, et 2 questions pour vérifier la compréhension.\n"
"4. Termine par les 5 questions qu'une personne doit savoir répondre à la fin de la semaine 1."
),
"allowed_tools": ["search_workspace", "read_document", "create_document", "write_blocks"],
},
"audit-base": {
"name": "Audit de base",
"icon": "🧹",
"description": "Audite une collection : colonnes vides, doublons, lignes sans propriétaire, propriétés inutilisées.",
"prompt_template": (
"Audite la collection demandée :\n"
"1. Lis son schéma et ses lignes (read_collection).\n"
"2. Détecte : propriétés jamais remplies, lignes sans propriétaire, doublons de titre, "
"valeurs incohérentes dans une même colonne, colonnes ajoutées puis abandonnées.\n"
"3. Crée un document « Audit — <collection> » (create_document) avec, par anomalie : "
"l'ampleur (nombre de lignes), des exemples concrets, et la correction proposée.\n"
"4. N'applique AUCUNE correction : liste d'abord, l'humain valide ensuite."
),
"allowed_tools": ["search_workspace", "read_collection", "read_page", "create_document", "write_blocks"],
},
"sprint-review": {
"name": "Revue de sprint",
"icon": "🏁",
"description": "Construit une revue de sprint depuis les issues Gitea : accompli, en retard, risques.",
"prompt_template": (
"Prépare la revue de sprint à partir des issues Gitea :\n"
"1. Lis les issues du dépôt (read_gitea_issues, state=all) et resynchronise si nécessaire (sync_gitea).\n"
"2. Classe-les : accompli (fermées), en retard (ouvertes et dépassées), à trier (sans label).\n"
"3. Croise avec les documents du workspace (search_workspace) pour voir si des décisions "
"changent la priorisation.\n"
"4. Crée un document « Revue de sprint — <période> » (create_document) : Accompli · En retard · "
"Risques · Prochaines priorités, avec le numéro d'issue pour chaque affirmation.\n"
"5. Ne ferme ni ne modifie aucune issue."
),
"allowed_tools": ["read_gitea_issues", "sync_gitea", "search_workspace",
"create_document", "write_blocks"],
},
# ── Vague 2 : skills web (web_search / fetch_url / search_code) ────────
"recherche-marche": {
"name": "Recherche marché",
"icon": "🌍",
"description": "Étudie un sujet de marché sur le web et produit une note sourcée avec alternatives.",
"prompt_template": (
"Produis une étude de marché sourcée sur « <sujet> » :\n"
"1. Cherche 3 requêtes distinctes (web_search) : le sujet lui-même, « <sujet> alternatives », "
"« <sujet> prix tarifs 2026 ».\n"
"2. Approfondis les 3 pages les plus prometteuses (fetch_url).\n"
"3. Crée un document « Marché — <sujet> » (create_document) : acteurs et positionnement, "
"grille tarifaire, forces/faiblesses, et 3 recommandations.\n"
"4. OBLIGATION : chaque affirmation porte sa source au format [titre — URL]. "
"Si une information n'est pas sourcée, écris « non vérifié » plutôt que d'inventer.\n"
"5. Indique la date de la recherche : ces données périment vite."
),
"allowed_tools": ["web_search", "fetch_url", "create_document", "write_blocks"],
},
"veille-techno": {
"name": "Veille technologique",
"icon": "🛰️",
"description": "Surveille l'actualité d'une techno : versions, ruptures, dépendances et advisories.",
"prompt_template": (
"Fais une veille technologique sur « <techno> » :\n"
"1. Web : dernières annonces et sorties de version (web_search).\n"
"2. GitHub : dépôts de référence et issues récentes résolues (search_code, kind=issues).\n"
"3. Approfondis les annonces importantes (fetch_url).\n"
"4. Crée un document « Veille — <techno> — <date> » (create_document) : "
"Nouveautés depuis le dernier point · Risques de rupture · Actions recommandées (mettre à jour / "
"surveiller / ignorer).\n"
"5. Une ligne par fait, avec [titre — URL]. Ne présente jamais une rumeur comme une release."
),
"allowed_tools": ["web_search", "fetch_url", "search_code",
"create_document", "write_blocks"],
},
"debug-web": {
"name": "Debug web",
"icon": "🐛",
"description": "Diagnostique un bug technique en croisant les docs, le code GitHub et les discussions.",
"prompt_template": (
"Diagnostique le problème suivant : <erreur / symptôme>.\n"
"1. Reformule le symptôme en une recherche web précise (web_search) : message d'erreur exact "
"+ bibliothèque + version.\n"
"2. Cherche si c'est un bug connu (search_code, kind=issues) et lis les discussions pertinentes (fetch_url).\n"
"3. Crée un document « Debug — <symptôme> » (create_document) : Cause probable (classée par "
"probabilité) · Vérifications à faire dans cet ordre · Correctif proposé.\n"
"4. Distingue clairement ce qui est vérifié par une source de ce qui est ton hypothèse."
),
"allowed_tools": ["web_search", "search_code", "fetch_url",
"create_document", "write_blocks"],
},
# ── Vague 3 : skills « pouvoir » ──────────────────────────────────────
"triage-incident": {
"name": "Triage d'incident",
"icon": "🚨",
"description": "Prend un incident décrit, ouvre un runbook pré-rempli et pose les premières questions de qualification.",
"prompt_template": (
"Traite l'incident suivant : <description>.\n"
"1. Qualifie avant de chercher : quel service, depuis quand, quel périmètre d'impact, "
"erreur observable. Les inconnues deviennent des questions, pas des suppositions.\n"
"2. Cherche les incidents similaires déjà résolus dans le workspace (search_workspace) "
"et les runbooks existants.\n"
"3. Crée un document « Incident — <titre court> » (create_document) : Symptôme · Impact · "
"Cause probable · Hypothèses classées · Étapes de résolution · Questions à poser à l'utilisateur.\n"
"4. Ne modifie aucun code ni aucune configuration : tu produis un runbook, pas un correctif.\n"
"5. Termine par les 3 informations qui feraient le plus progresser le diagnostic."
),
"allowed_tools": ["search_workspace", "read_document", "web_search",
"create_document", "write_blocks"],
},
"nettoyage-base": {
"name": "Nettoyage de base",
"icon": "🧽",
"description": "Prépare un plan de nettoyage d'une collection (doublons, valeurs vides) avec aperçu avant écriture.",
"prompt_template": (
"Prépare le nettoyage de la collection « <collection> » :\n"
"1. Lis son schéma et ses lignes (read_collection).\n"
"2. Identifie les corrections : lignes vides à supprimer, doublons à fusionner, "
"valeurs parasites à normaliser, propriétés obsolètes.\n"
"3. PRODUIRE UN APERÇU D'AVANT : pour chaque catégorie de correction, le nombre de lignes "
"concernées et 3 exemples concrets.\n"
"4. Règle absolue : n'applique AUCUNE modification avant validation explicite de l'utilisateur. "
"Le plan est le livrable.\n"
"5. Crée le document « Plan de nettoyage — <collection> » (create_document) avec le plan, "
"puis demande la validation."
),
"allowed_tools": ["search_workspace", "read_collection", "read_page",
"create_document", "write_blocks"],
},
"briefing-quotidien": {
"name": "Briefing quotidien",
"icon": "🌅",
"description": "Prépare le point du matin : activités de la veille, décisions en attente, blocages.",
"prompt_template": (
"Prépare le briefing du jour :\n"
"1. Activités : ce qui a bougé dans le workspace et le dépôt (read_gitea_issues, "
"search_workspace) — uniquement des faits datés.\n"
"2. Décisions en attente : documents de décision sans statut clair.\n"
"3. Blocages : tickets ouverts sans mouvement, sujets qui reviennent dans plusieurs documents.\n"
"4. Crée un document « Briefing — <date> » (create_document) en moins d'une page : "
"Hier en bref · À trancher aujourd'hui · Bloquants · Une chose à surveiller.\n"
"5. Si une section n'a aucun élément, écris « rien à signaler » plutôt que d'inventer."
),
"allowed_tools": ["search_workspace", "read_document", "read_gitea_issues",
"create_document", "write_blocks"],
},
}
+2 -1
View File
@@ -22,6 +22,7 @@ import logging
import time
from app.db import get_conn
from app.services.collection_lifecycle import delete_collections
logger = logging.getLogger(__name__)
@@ -381,7 +382,7 @@ class SyncEngine:
def _mut_collection_delete(self, user_id: int, payload: dict) -> dict:
cid = payload.get("collection_id")
with get_conn() as conn:
conn.execute("DELETE FROM collections WHERE id=?", (cid,))
delete_collections(conn, [cid])
conn.commit()
return {"collection_id": cid, "server_version": None}
+438
View File
@@ -0,0 +1,438 @@
"""FlowDeck — My Tasks : sources de tâches et normalisation.
Une base ne diffuse ses tâches dans My Tasks qu'après **conversion
explicite** (``collections.is_task``) et le mapping explicite de ses trois
propriétés requises :
=========================== ==========================================
Rôle Colonne sur ``collections``
=========================== ==========================================
``assignee`` (Assigné à) ``task_assignee_prop``
``status`` (Statut) ``task_status_prop``
``due`` (Échéance) ``task_due_prop``
=========================== ==========================================
Ce module est la lecture unique de ce mapping : My Tasks, les filtres, les
vues Kanban/calendrier et l'ajout rapide s'appuient tous sur
``collect_tasks`` — donc une même tâche ne peut pas être rendue
différemment selon l'appelant.
Périmètre : **tous les workspaces de l'utilisateur** (mode Notion *Home*),
pas seulement le workspace actif — le tableau de bord est un poste de
travail transverse.
"""
from __future__ import annotations
import json
import logging
import unicodedata
from datetime import UTC, date, datetime, timedelta
from app.services.collection_lifecycle import (
effective_workspace_sql,
live_collection_ids,
live_row_ids,
user_workspace_ids,
)
logger = logging.getLogger(__name__)
#: Notion refuse au-delà de 10 bases connectées au widget My Tasks.
MAX_TASK_SOURCES = 10
#: Rôles requis, dans l'ordre d'affichage de la modale de conversion.
TASK_ROLES = ("assignee", "status", "due")
#: Types acceptés pour chaque rôle (une base « bien formée » est refusée
#: sinon : une colonne Date n'a pas de sens comme Assigné à).
ROLE_TYPES = {
"assignee": ("person",),
"status": ("status", "select"),
"due": ("date",),
}
ROLE_LABELS = {
"assignee": "Assigné à",
"status": "Statut",
"due": "Date d'échéance",
}
#: Premier mot d'un statut valant « terminé », une fois normalisé (minuscules,
#: sans accents). On compare des formes normalisées et non des libellés exacts :
#: « Terminée », « Terminé », « Complete » ou « Closed » doivent tous être
#: reconnus, quelle que soit la casse et l'accentuation.
#:
#: On matche le **premier mot** et non un simple préfixe : « Clôture »
#: commence comme « clos » mais n'est pas un statut terminé.
DONE_WORDS = frozenset({
# anglais
"done", "complete", "completed", "closed", "closed?", "terminated", "finished",
# français (masculin / féminin, singulier / pluriel)
"clos", "close", "termine", "terminee", "termines", "terminees",
"annule", "annulee", "annules", "annulees", "cancelled", "canceled",
"resolu", "resolue", "resolus", "resolues", "acheve", "achevee",
})
def _normalize(value) -> str:
"""Minuscules, accents retirés, espaces compactés."""
text = unicodedata.normalize("NFKD", str(value or ""))
text = "".join(c for c in text if not unicodedata.combining(c))
return " ".join(text.lower().split())
def is_done(status: str) -> bool:
"""Le statut vaut-il « terminé » ?
Le premier mot suffit : « Terminée », « terminated », « Complete! » et
« done (closed) » sont reconnus ; « Clôture » ne l'est pas.
"""
norm = _normalize(status)
if not norm:
return False
first = norm.split()[0] if norm.split() else ""
return first.strip("!?.,:;()") in DONE_WORDS
def user_today(conn, user_id: int) -> date:
"""« Aujourd'hui » dans le fuseau de l'utilisateur.
Le projet stocke déjà ce fuseau (`users.timezone`, cf. migration v5.8.0) et
l'utilise déjà pour le calendrier des bases. My Tasks s'en sert aussi :
sans cela, une tâche due « aujourd'hui » bascule dans « demain » (ou
l'inverse) dès que l'utilisateur est loin d'UTC.
"""
row = conn.execute(
"SELECT timezone FROM users WHERE id=?", (user_id,)
).fetchone()
tz_name = (row["timezone"] if row and "timezone" in row.keys() else "") or ""
if tz_name:
try:
from zoneinfo import ZoneInfo
return datetime.now(ZoneInfo(tz_name)).date()
except Exception: # fuseau inconnu / base tz absente → UTC
logger.debug("timezone « %s » illisible, repli sur UTC", tz_name)
return datetime.now(UTC).date()
def task_source_columns() -> str:
"""Colonnes de ``collections`` lues par les requêtes de sources.
Les 3 colonnes de mapping n'existent que depuis la migration 30 : sur une
base antérieure elles vaudront NULL de toute façon.
Préfixées par ``c.`` car `list_task_sources` joint ``pages`` : sans
qualification, ``id`` est ambigu.
"""
return ("c.id, c.name, c.icon, c.workspace_id, c.is_task, "
"c.task_assignee_prop, c.task_status_prop, c.task_due_prop")
def list_task_sources(conn, user_id: int) -> list[dict]:
"""Bases de tâches configurées par l'utilisateur, tous workspaces
confondus. Renvoie aussi ``configured`` (les 3 rôles mappés) et
``missing_roles`` pour piloter l'invite de conversion.
Deux exclusions :
* une base dont la page hôte est à la corbeille (ou supprimée) est
**retirée** : sans ce filtre, une base portée par un document supprimé
continuerait d'alimenter My Tasks — le bug d'origine ;
* une base dont le workspace n'est pas accessible à l'utilisateur n'est
pas listée (une base sans workspace rattachable n'appartient à personne).
Le workspace retenu est l'**effectif** (`collections.workspace_id`, sinon
celui de la page hôte) : une base créée depuis un document a
``workspace_id`` NULL, et un ``JOIN workspaces`` la faisait disparaître de
My Tasks alors qu'elle venait d'être convertie.
"""
ws_ids = user_workspace_ids(conn, user_id)
if not ws_ids:
return []
qs = ",".join("?" * len(ws_ids))
ws_sql = effective_workspace_sql("c")
rows = conn.execute(
f"""SELECT {task_source_columns()}, {ws_sql} AS ws_effective,
(SELECT w.name FROM workspaces w WHERE w.id = {ws_sql})
AS workspace_name
FROM collections c
WHERE c.is_task = 1 AND {ws_sql} IN ({qs})
ORDER BY c.name""",
[*ws_ids],
).fetchall()
# Un workspace peut apparaître plusieurs fois : on ne recalcule le
# jeu de bases vivantes qu'une fois par workspace.
live_by_ws: dict[int, set[int]] = {}
sources = []
for r in rows:
d = dict(r)
ws = d.get("ws_effective")
if ws not in live_by_ws:
live_by_ws[ws] = set(live_collection_ids(conn, ws))
if d["id"] not in live_by_ws[ws]:
logger.debug("base %s exclue : sa page hôte n'est plus vivante", d["id"])
continue
missing = [role for role in TASK_ROLES if not d.get(f"task_{role}_prop")]
d["configured"] = not missing
d["missing_roles"] = missing
d["status_options"] = _status_options(conn, d.get("task_status_prop"))
sources.append(d)
return sources
def _status_options(conn, prop_id) -> list[str]:
"""Libellés du statut de la base source (colonnes du Kanban)."""
if not prop_id:
return []
row = conn.execute(
"SELECT options_json FROM collection_properties WHERE id=?", (prop_id,)
).fetchone()
if not row:
return []
try:
opts = json.loads(row["options_json"] or "[]")
except (json.JSONDecodeError, TypeError):
return []
return [o if isinstance(o, str) else (o or {}).get("name", "") for o in opts
if isinstance(o, str) or isinstance(o, dict)]
def source_state(conn, collection_id: int) -> dict:
"""État de conversion d'une base pour la modale « convertir en base de
tâches » : mapping courant + colonnes candidates pour chaque rôle."""
coll = conn.execute(
f"SELECT {task_source_columns()} FROM collections c WHERE c.id=?",
(collection_id,),
).fetchone()
if coll is None:
return {"exists": False}
d = dict(coll)
props = conn.execute(
"SELECT id, name, prop_type, options_json, position FROM collection_properties "
"WHERE collection_id=? ORDER BY position, id",
(collection_id,),
).fetchall()
missing = [role for role in TASK_ROLES if not d.get(f"task_{role}_prop")]
used = {d.get(f"task_{role}_prop") for role in TASK_ROLES if d.get(f"task_{role}_prop")}
return {
"exists": True,
"collection_id": collection_id,
"name": d.get("name"),
"is_task": bool(d.get("is_task")),
"mapping": {role: d.get(f"task_{role}_prop") for role in TASK_ROLES},
"missing_roles": missing,
"configured": not missing,
# Par rôle : les colonnes compatibles (une colonne déjà affectée à un
# autre rôle est exclue — Notion n'autorise pas le double emploi).
"candidates": {
role: [
{"id": p["id"], "name": p["name"], "prop_type": p["prop_type"]}
for p in props
if p["prop_type"] in ROLE_TYPES[role] and p["id"] not in used
]
for role in TASK_ROLES
},
"all_properties": [
{"id": p["id"], "name": p["name"], "prop_type": p["prop_type"]}
for p in props
],
"suggested_names": {role: ROLE_LABELS[role] for role in TASK_ROLES},
"max_sources": MAX_TASK_SOURCES,
"sources_count": _sources_count(conn, d.get("workspace_id"), collection_id),
}
def _sources_count(conn, workspace_id, exclude_id=None) -> int:
"""Nombre de bases déjà connectées (pour la limite de 10)."""
row = conn.execute(
"SELECT COUNT(*) FROM collections WHERE is_task = 1 AND workspace_id = ?",
(workspace_id,),
).fetchone()
n = row[0] if row else 0
if exclude_id is not None:
row = conn.execute(
"SELECT is_task FROM collections WHERE id=?", (exclude_id,)
).fetchone()
if row and row[0]:
n -= 1
return max(n, 0)
def _row_value(raw: dict, prop_id):
"""Valeur d'une propriété de ligne, indexée par ID (ou par nom, pour les
lignes historicisées écrites avant le passage aux clés indexées)."""
if prop_id is None:
return None
if prop_id in raw:
return raw[prop_id]
return raw.get(str(prop_id))
def _person_values(value) -> list:
"""Aplatit une valeur `person` en liste d'éléments (dicts `person` ou
chaînes), sans perdre l'id — c'est lui qui identifie l'assigné."""
if value is None:
return []
if isinstance(value, dict):
return [value]
if isinstance(value, list):
out: list = []
for v in value:
out.extend(_person_values(v))
return out
if isinstance(value, str) and value.strip():
return [value]
return []
def _person_label(v) -> str:
if isinstance(v, dict):
return str(v.get("login") or v.get("full_name") or "")
return str(v)
def _assigned_to_me(values: list, user_id: int, login: str) -> bool:
"""L'assigné est-il l'utilisateur courant ?
Une valeur `person` porte `{id, login, full_name}` : on compare l'id en
priorité (stable), puis le login en repli pour les lignes historicisées
qui ne stockent qu'un nom.
"""
if not login:
return False
for v in values:
if isinstance(v, dict):
if v.get("id") is not None and str(v["id"]) == str(user_id):
return True
if _person_label(v).strip().lower() == login.strip().lower():
return True
return False
def collect_tasks(conn, user_id: int, *, mine_only: bool = True,
login: str = "") -> list[dict]:
"""Tâches normalisées de toutes les bases connectées.
Chaque entrée porte sa source (``collection_id``/``collection_name``) et
les identifiants des propriétés à écrire pour une édition en direct
(``status_prop``/``due_prop``/``assignee_prop``) : le front n'a plus à
deviner quoi écrire.
"""
tasks: list[dict] = []
today = user_today(conn, user_id)
for src in list_task_sources(conn, user_id):
if not src["configured"]:
# Base connectée mais mapping incomplet : elle n'alimente pas
# My Tasks (l'UI le signale), plutôt que d'inventer des colonnes.
continue
status_prop = src["task_status_prop"]
due_prop = src["task_due_prop"]
assignee_prop = src["task_assignee_prop"]
for row_id in live_row_ids(conn, src["id"]):
row = conn.execute(
"SELECT id, title, icon, created_at, property_values_json "
"FROM collection_pages WHERE id=?",
(row_id,),
).fetchone()
if row is None:
continue
try:
raw = json.loads(row["property_values_json"] or "{}")
except (json.JSONDecodeError, TypeError):
raw = {}
if not isinstance(raw, dict):
raw = {}
persons = _person_values(_row_value(raw, assignee_prop))
assignees = [_person_label(p) for p in persons if _person_label(p)]
if mine_only and persons and not _assigned_to_me(persons, user_id, login):
continue
status = _row_value(raw, status_prop)
if isinstance(status, list):
status = status[0] if status else ""
status = str(status or "")
due_raw = _row_value(raw, due_prop)
due = str(due_raw or "")[:10]
due_state = ""
if due:
try:
d = date.fromisoformat(due)
except ValueError:
d = None
if d:
due_state = ("overdue" if d < today
else "today" if d == today
else "upcoming")
tasks.append({
"id": row["id"],
"title": row["title"] or "Untitled",
"icon": row["icon"] or "📄",
"created_at": row["created_at"],
"collection_id": src["id"],
"collection_name": src["name"],
"collection_icon": src["icon"] or "📋",
"workspace_id": src["workspace_id"],
"workspace_name": src["workspace_name"],
"assignees": assignees,
"status": status,
"status_options": src["status_options"],
"done": is_done(status),
"due": due,
"due_state": due_state,
"status_prop": status_prop,
"due_prop": due_prop,
"assignee_prop": assignee_prop,
})
return tasks
def filter_and_sort(tasks: list[dict], *, due: str = "all", hide_done: bool = False,
sort: str = "due", today: date | None = None) -> list[dict]:
"""Filtres et tri globaux de My Tasks (portés par l'URL).
``today`` est le jour de référence de l'utilisateur (fuseau compris) : il
doit être celui utilisé par `collect_tasks`, sinon « aujourd'hui » et
« cette semaine » ne cibleront pas le même jour que `due_state`.
"""
out = tasks
if hide_done:
out = [t for t in out if not t["done"]]
if due and due != "all":
if due == "today":
out = [t for t in out if t["due_state"] == "today"]
elif due == "overdue":
out = [t for t in out if t["due_state"] in ("today", "overdue")]
elif due == "week":
# « Cette semaine » est **borné** à J+7 : sans borne, une tâche
# datée dans six mois apparaîtrait sous « Cette semaine ».
ref = today or datetime.now(UTC).date()
limit = (ref + timedelta(days=7)).isoformat()
out = [t for t in out
if t["due"] and (t["due_state"] in ("today", "overdue")
or t["due"] <= limit)]
if sort == "status":
return sorted(out, key=lambda t: (t["done"], t["status"].lower(),
(t["due"] or "9999")))
if sort == "source":
return sorted(out, key=lambda t: (t["collection_name"].lower(),
(t["due"] or "9999"), t["title"].lower()))
if sort == "created":
return sorted(out, key=lambda t: str(t.get("created_at") or ""))
# "due" : les tâches datées d'abord (les plus urgentes en tête), puis
# les tâches sans date — comme une liste de rappels.
return sorted(out, key=lambda t: ((t["due"] or "9999-12-31"),
t["done"], t["title"].lower()))
+195 -1
View File
@@ -15,12 +15,14 @@ from __future__ import annotations
import json
import logging
import re
import sqlite3
from dataclasses import dataclass, field
from datetime import UTC
from typing import Any
from app.db import get_conn
from app.services.collection_lifecycle import delete_collections
logger = logging.getLogger(__name__)
@@ -785,7 +787,7 @@ class DeleteCollection(Tool):
coll = conn.execute("SELECT * FROM collections WHERE id=?", (cid,)).fetchone()
if not coll:
return ToolResult(status="error", tool=self.name, message=f"Collection #{cid} introuvable")
conn.execute("DELETE FROM collections WHERE id=?", (cid,))
delete_collections(conn, [cid])
conn.commit()
return ToolResult(
status="success", tool=self.name, target_type="collection", target_id=cid,
@@ -877,6 +879,197 @@ class CreateGiteaIssue(Tool):
)
# ══════════════════════════ Web tools ══════════════════════════
# Outils réseau : recherche web, lecture d'une page, recherche GitHub.
# Ils réutilisent les garde-fous SSRF déjà éprouvés par l'importeur URL
# (``importers.url_fetch`` / ``og_fetcher``) — aucune adresse privée,
# re-vérifiée à chaque saut de redirection.
class WebSearch(Tool):
name = "web_search"
description = (
"Recherche sur le web (actualités, docs, libs, comparatifs) et "
"renvoie titre + URL + extrait pour chaque résultat. Utilise-la AVANT de "
"répondre sur un fait récent ou technique, puis CITE les URL obtenues."
)
parameters = {
"type": "object",
"properties": {
"query": {"type": "string", "description": "requête de recherche"},
"num_results": {"type": "integer",
"description": "nombre de résultats (1-10, défaut 5)"},
},
"required": ["query"],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
from app.services.web_search import available_providers, search_web
query = str(args.get("query") or "").strip()
if not query:
return ToolResult(status="error", tool=self.name, message="query vide")
try:
num = max(1, min(int(args.get("num_results") or 5), 10))
except (TypeError, ValueError):
num = 5
try:
results, provider = await search_web(query, num)
except Exception as exc: # noqa: BLE001
return ToolResult(status="error", tool=self.name,
message=f"Recherche web indisponible: {exc}")
if not results:
hint = ""
if not available_providers().get(provider, True):
hint = " — configurez EXA_API_KEY dans les réglages FlowDeck"
return ToolResult(status="error", tool=self.name,
message=f"Aucun résultat pour « {query} » (provider: {provider}){hint}")
return ToolResult(
status="success", tool=self.name, target_type="web_search",
data={"query": query, "provider": provider, "results": results,
"count": len(results)},
message=f"{len(results)} résultat(s) via {provider}",
)
class FetchUrl(Tool):
name = "fetch_url"
description = (
"Télécharge une page web et la convertit en Markdown (titres, listes, "
"code, tableaux) pour pouvoir la lire et la citer. Utilise-la après "
"web_search pour approfondir un résultat. Les URLs internes / privées "
"sont refusées."
)
parameters = {
"type": "object",
"properties": {
"url": {"type": "string", "description": "URL http(s) de la page"},
"max_chars": {"type": "integer",
"description": "longueur max du markdown renvoyé (défaut 20000)"},
},
"required": ["url"],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
from app.config import settings
from app.services.http_client import shared_client
from app.services.importers.html_notes import _html_to_markdown
from app.services.importers.url_fetch import _validate_url
from app.services.og_fetcher import parse_og
raw = str(args.get("url") or "").strip()
if not raw:
return ToolResult(status="error", tool=self.name, message="url vide")
# « example.com/page » → https://example.com/page, mais un schéma
# explicite (file:, ftp:, …) est conservé pour être REJETÉ par le
# garde-fou, au lieu d'être transformé en requête réseau hasardeuse.
if not re.match(r"^[a-zA-Z][a-zA-Z0-9+.\-]*://", raw):
raw = "https://" + raw
try:
url = _validate_url(raw)
except ValueError as exc:
return ToolResult(status="error", tool=self.name, message=str(exc))
try:
max_chars = max(500, min(int(args.get("max_chars")
or settings.web_fetch_max_chars), 60000))
except (TypeError, ValueError):
max_chars = int(settings.web_fetch_max_chars or 20000)
current = url
resp = None
try:
async with shared_client(timeout=15.0, follow_redirects=False,
headers={"User-Agent": "FlowDeck-Agent/1.0 (fetch_url)"}) as client:
for _ in range(6):
resp = await client.get(current)
if resp.status_code in (301, 302, 303, 307, 308):
loc = resp.headers.get("location")
if not loc:
raise ValueError("redirection sans cible")
from urllib.parse import urljoin
current = urljoin(current, loc)
# re-validation : une URL publique peut rediriger vers 169.254.x
current = _validate_url(current)
continue
resp.raise_for_status()
break
else:
raise ValueError("trop de redirections")
except ValueError as exc:
return ToolResult(status="error", tool=self.name, message=str(exc))
except Exception as exc: # noqa: BLE001
return ToolResult(status="error", tool=self.name,
message=f"Page injoignable: {exc}")
if resp is None:
return ToolResult(status="error", tool=self.name, message="Aucune réponse")
ctype = (resp.headers.get("content-type") or "").lower()
body = resp.text
if "html" not in ctype and "xhtml" not in ctype:
# JSON / texte brut : on le renvoie tel quel, c'est souvent une API.
markdown = body
meta: dict = {}
else:
meta = parse_og(body, current)
markdown = _html_to_markdown(body)
truncated = len(markdown) > max_chars
if truncated:
markdown = markdown[:max_chars] + "\n\n[contenu tronqué]"
title = (meta.get("title") or "").strip() or current
return ToolResult(
status="success", tool=self.name, target_type="web_page", target_id=current,
data={"url": current, "title": title[:200], "markdown": markdown,
"truncated": truncated, "chars": len(markdown)},
message=f"Page lue : {title[:80]} ({len(markdown)} car.)",
)
class SearchCode(Tool):
name = "search_code"
description = (
"Recherche GitHub : dépôts, code source ou issues (uniquement GitHub "
"public, sans clé possible mais limitée à 10 requêtes/min). Sert à "
"trouver une implémentation de référence, une lib ou l'historique d'un bug."
)
parameters = {
"type": "object",
"properties": {
"query": {"type": "string", "description": "termes GitHub (lang:python, repo:, etc.)"},
"kind": {"type": "string",
"description": "repositories | code | issues (défaut repositories)"},
"limit": {"type": "integer", "description": "nombre de résultats (1-10, défaut 5)"},
},
"required": ["query"],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
from app.services.web_search import search_github
query = str(args.get("query") or "").strip()
if not query:
return ToolResult(status="error", tool=self.name, message="query vide")
kind = str(args.get("kind") or "repositories")
try:
limit = max(1, min(int(args.get("limit") or 5), 10))
except (TypeError, ValueError):
limit = 5
try:
results = await search_github(query, kind, limit)
except Exception as exc: # noqa: BLE001
return ToolResult(status="error", tool=self.name,
message=f"Recherche GitHub impossible: {exc}")
return ToolResult(
status="success", tool=self.name, target_type="github",
target_id=f"{kind}:{query}",
data={"query": query, "kind": kind, "results": results, "count": len(results)},
message=f"{len(results)} résultat(s) GitHub ({kind})",
)
# ══════════════════════════ Registry ══════════════════════════
TOOL_CLASSES = [
@@ -887,6 +1080,7 @@ TOOL_CLASSES = [
DeletePage, DeleteCollection,
ReadGiteaIssues, SyncGitea, CreateGiteaIssue,
DeleteDocument,
WebSearch, FetchUrl, SearchCode,
]
+8
View File
@@ -13,6 +13,10 @@ import re
from datetime import UTC, datetime, timedelta
from app.db import get_conn
from app.services.collection_lifecycle import (
collections_hosted_by_page,
delete_collections,
)
logger = logging.getLogger(__name__)
@@ -57,6 +61,9 @@ def purge_expired(days: int = 30) -> dict:
if dt is None or dt >= cutoff:
continue
page_id = row["id"]
# A purged host page takes its database with it, otherwise the
# workspace kept advertising tasks whose document is gone.
collections = collections_hosted_by_page(conn, page_id)
# Promote direct children to the deleted page's parent so no live
# sub-tree is orphaned (matches the permanent-delete semantics).
conn.execute(
@@ -65,6 +72,7 @@ def purge_expired(days: int = 30) -> dict:
(page_id, page_id),
)
conn.execute("DELETE FROM pages WHERE id=?", (page_id,))
delete_collections(conn, collections)
purged.append(page_id)
conn.commit()
if purged:
+295
View File
@@ -0,0 +1,295 @@
"""FlowDeck — Web search + GitHub search pour les tools agent (v7.46.0).
Alimente les deux tools réseau qui manquaient au registre : la recherche
**en ligne** (`web_search`) et la recherche **GitHub** (`search_code`).
Choix de conception :
* **Provider configurable** (``settings.web_search_provider``) : Exa
(recherche sémantique + snippets, clé requise) en priorité, DuckDuckGo en
repli sans compte. Le repli parse du HTML — c'est volontairement *dégradé* :
il n'est jamais utilisé si une clé Exa est configurée, et sert surtout à
garder le skill utilisable sur une instance sans compte externe.
* **Aucune dépendance ajoutée** : httpx déjà présent, ``shared_client`` pour le
pool de connexions (A42), et les garde-fous SSRF déjà éprouvés par
``app.services.importers.url_fetch`` / ``og_fetcher``.
* **Toujours des sources** : chaque résultat porte titre + URL + extrait, pour
que l'IA puisse citer au lieu d'inventer.
La couche réseau est injectable (``transport``) pour que les tests n'aient
jamais besoin du réseau.
"""
from __future__ import annotations
import html as htmlmod
import logging
import re
from typing import Any
from urllib.parse import parse_qs, urlparse
from app.config import settings
from app.services.http_client import shared_client
logger = logging.getLogger(__name__)
USER_AGENT = "FlowDeck-Agent/1.0 (+web_search)"
#: Domaines de repli interdits : réponses « sous conditions » / anti-bot qui
#: pollueraient les résultats (DuckDuckGo sert parfois une page de challenge).
_BLOCKED_RESULT_HOSTS = frozenset({
"duckduckgo.com", "lite.duckduckgo.com", "duckduckgo.com.yahoo.net",
})
EXA_ENDPOINT = "https://api.exa.ai/search"
DDG_ENDPOINT = "https://lite.duckduckgo.com/lite/"
GITHUB_API = "https://api.github.com"
MAX_RESULTS = 10
_SNIPPET_CHARS = 300
def _clean(text: str | None) -> str:
return re.sub(r"\s+", " ", (text or "")).strip()
def _clip(text: str, limit: int = _SNIPPET_CHARS) -> str:
text = _clean(text)
return text if len(text) <= limit else text[: limit - 1].rstrip() + "…"
def _is_resultable(url: str) -> bool:
"""Ignore les redirections vers le moteur lui-même et les URLs non http."""
parsed = urlparse(url or "")
if parsed.scheme not in ("http", "https") or not parsed.hostname:
return False
return parsed.hostname.lower() not in _BLOCKED_RESULT_HOSTS
# ══════════════════════ Exa ══════════════════════
async def _search_exa(query: str, num_results: int, transport=None) -> list[dict]:
key = (settings.exa_api_key or "").strip()
if not key:
return []
payload = {
"query": query,
"numResults": max(1, min(num_results, MAX_RESULTS)),
"contents": {"text": {"maxCharacters": 1200}},
}
kwargs: dict[str, Any] = {"timeout": 15.0}
if transport is not None:
kwargs["transport"] = transport
async with shared_client(**kwargs) as client:
resp = await client.post(
EXA_ENDPOINT,
json=payload,
headers={"x-api-key": key, "Content-Type": "application/json",
"User-Agent": USER_AGENT},
)
if resp.status_code != 200:
raise RuntimeError(f"Exa HTTP {resp.status_code}")
results = []
for item in (resp.json() or {}).get("results", []):
url = item.get("url") or ""
if not _is_resultable(url):
continue
results.append({
"title": _clip(item.get("title") or url, 160),
"url": url,
"snippet": _clip(item.get("text") or item.get("summary") or ""),
"published": (item.get("publishedDate") or "")[:10],
"source": "exa",
})
return results[:MAX_RESULTS]
# ══════════════════════ DuckDuckGo (repli sans compte) ══════════════════════
_DDG_ROW_RE = re.compile(
r"<a[^>]+class=\"[^\"]*result-link[^\"]*\"[^>]+href=\"(?P<href>[^\"]+)\"[^>]*>(?P<title>.*?)</a>",
re.I | re.S,
)
_DDG_SNIPPET_RE = re.compile(
r"class=\"[^\"]*result-snippet[^\"]*\"[^>]*>(?P<snippet>.*?)</td>", re.I | re.S
)
_TAG_RE = re.compile(r"<[^>]+>")
def _unwrap_ddg(href: str) -> str:
"""DuckDuckGo emballe les résultats dans ``/l/?uddg=<url encodé>``."""
if not href:
return ""
parsed = urlparse(htmlmod.unescape(href))
if "uddg" in (parsed.query or ""):
values = parse_qs(parsed.query).get("uddg")
if values:
return values[0]
return href
async def _search_duckduckgo(query: str, num_results: int, transport=None) -> list[dict]:
kwargs: dict[str, Any] = {"timeout": 12.0}
if transport is not None:
kwargs["transport"] = transport
async with shared_client(**kwargs) as client:
resp = await client.post(
DDG_ENDPOINT,
data={"q": query, "kl": "wt-wt"},
headers={"User-Agent": USER_AGENT,
"Content-Type": "application/x-www-form-urlencoded"},
)
if resp.status_code != 200:
raise RuntimeError(f"DuckDuckGo HTTP {resp.status_code}")
body = resp.text
links = list(_DDG_ROW_RE.finditer(body))
snippets = [_clean(_TAG_RE.sub("", m.group("snippet"))) for m in _DDG_SNIPPET_RE.finditer(body)]
results = []
for idx, match in enumerate(links):
url = _unwrap_ddg(match.group("href"))
if not _is_resultable(url):
continue
results.append({
"title": _clean(_TAG_RE.sub("", match.group("title"))),
"url": url,
"snippet": _clip(snippets[idx]) if idx < len(snippets) else "",
"published": "",
"source": "duckduckgo",
})
if len(results) >= min(num_results, MAX_RESULTS):
break
return results
# ══════════════════════ API publique ══════════════════════
async def search_web(query: str, num_results: int = 5,
transport=None) -> tuple[list[dict], str]:
"""Recherche web → ``(results, provider)``.
Essaie le provider configuré puis l'autre ; si aucun ne répond, renvoie
une liste vide — c'est au tool de traduire ça en message actionnable pour
le modèle (« configurez EXA_API_KEY ») plutôt qu'en erreur réseau opaque.
"""
q = (query or "").strip()
if not q:
return [], ""
provider = (settings.web_search_provider or "exa").strip().lower()
order = ("exa", "duckduckgo") if provider == "exa" else ("duckduckgo", "exa")
errors: list[str] = []
for name in order:
try:
if name == "exa":
results = await _search_exa(q, num_results, transport)
else:
results = await _search_duckduckgo(q, num_results, transport)
except Exception as exc: # noqa: BLE001 — un provider qui tombe ne doit pas tuer l'autre
logger.info("web_search provider %s failed: %s", name, exc)
errors.append(f"{name}: {exc}")
continue
if results:
return results, name
if errors:
logger.warning("web_search sans résultat — %s", "; ".join(errors))
return [], order[0]
return [], order[0]
def available_providers() -> dict[str, bool]:
"""Quel provider est réellement utilisable (affiché dans les settings)."""
return {
"exa": bool((settings.exa_api_key or "").strip()),
"duckduckgo": True,
"github": True, # sans PAT : 10 req/min suffisent pour un usage agent
}
# ══════════════════════ GitHub ══════════════════════
_GH_KINDS = {
"repositories": "repos",
"repos": "repos",
"code": "code",
"issues": "issues",
}
def _gh_headers() -> dict[str, str]:
headers = {"Accept": "application/vnd.github+json",
"User-Agent": USER_AGENT,
"X-GitHub-Api-Version": "2022-11-28"}
token = (settings.github_token or "").strip()
if token:
headers["Authorization"] = f"Bearer {token}"
return headers
def _gh_item_name(item: dict, kind: str) -> str:
if kind == "repos":
return item.get("full_name") or item.get("name") or ""
if kind == "code":
return item.get("path") or ""
return item.get("title") or f"#{item.get('number')}"
def _gh_item_url(item: dict, kind: str) -> str:
if kind == "code":
# L'API code search ne renvoie pas d'URL exploitable : on reconstruit.
repo = (item.get("repository") or {}).get("full_name", "")
return f"https://github.com/{repo}/blob/HEAD/{item.get('path', '')}" if repo else ""
return item.get("html_url") or ""
def _gh_item_snippet(item: dict, kind: str) -> str:
if kind == "code":
return ""
if kind == "repos":
return _clip(item.get("description") or "")
body = _clean(item.get("body") or "")
return _clip(body) if body else _clip(item.get("title") or "")
async def search_github(query: str, kind: str = "repositories", limit: int = 5,
transport=None) -> list[dict]:
"""Recherche GitHub (repos | code | issues) → liste de résultats sourcés.
Fonctionne sans PAT (limite 10 req/min) ; avec ``GITHUB_TOKEN``, 30 req/min.
"""
q = (query or "").strip()
if not q:
return []
k = _GH_KINDS.get((kind or "").strip().lower(), "repos")
per_page = max(1, min(int(limit or 5), MAX_RESULTS))
kwargs: dict[str, Any] = {"timeout": 15.0}
if transport is not None:
kwargs["transport"] = transport
async with shared_client(**kwargs) as client:
resp = await client.get(
f"{GITHUB_API}/search/{k}",
params={"q": q, "per_page": per_page},
headers=_gh_headers(),
)
if resp.status_code == 403:
raise RuntimeError("GitHub: quota dépassé (403) — configurez GITHUB_TOKEN")
if resp.status_code == 422:
raise RuntimeError("GitHub: requête de recherche invalide (422)")
if resp.status_code != 200:
raise RuntimeError(f"GitHub HTTP {resp.status_code}")
items = (resp.json() or {}).get("items", [])
results = []
for item in items[:per_page]:
results.append({
"title": _gh_item_name(item, k),
"url": _gh_item_url(item, k),
"snippet": _gh_item_snippet(item, k),
"stars": item.get("stargazers_count") if k == "repos" else None,
"state": item.get("state") if k == "issues" else "",
"source": f"github/{k}",
})
return results