- `app/services/http_client.py` : `async with shared_client(timeout=15) as client:` remplace les 49 créations `async with httpx.AsyncClient(` de 14 fichiers (gitea ×21, providers oidc/oauth ×11, calendar ×4, automations ×3…) — le pool de connexions est réutilisé au lieu d'être recréé à chaque appel. __aexit__ no-op (le client partagé ne se ferme pas à la sortie). - Cache par (boucle d'event, kwargs) en WeakKeyDictionary : un AsyncClient n'est JAMAIS partagé entre deux loops (piège des tests « Event loop is closed ») — une boucle par test = client propre collecté avec la boucle. Clé = kwargs triés, repr() pour les valeurs non hashables (`headers=` dict → TypeError rattrapé par la suite). - Laissés délibérément : github_adapter (transport MockTransport injecté), webhook_outbound (client « own_client » fermé par la fonction). - Tests : `test_http_client_shared_and_loop_scoped` (réutilisation mêmes kwargs / cloisonné kwargs / cloisonné loop) ; le stub des webhooks patche aussi la fabrique `http_client.httpx` + purge du cache (avant : webhook_outbound.httpx patché mais la fabrique partagée créait un vrai client → réseau réel dans les tests). suite **1091/1091** · ruff OK · docs à jour
592 lines
28 KiB
Python
592 lines
28 KiB
Python
"""FlowDeck — LLM client abstraction (v4.10.0).
|
|
|
|
Abstraction over multiple LLM providers so the agent never talks to the DB
|
|
directly — it emits *tool intentions* (function calls) that AgentEngine turns
|
|
into guarded internal actions.
|
|
|
|
Supported providers (OpenAI-compatible chat-completions JSON response):
|
|
openai, anthropic, mistral, cohere, google, groq, deepseek, openrouter,
|
|
nvidia, together, perplexity, xai, qwencloud, minimax, morph, fireworks,
|
|
cerebras, sambanova, chutes, xiaomi, sealion, sensenova,
|
|
ollama (local, no key). Anthropic, Google (`/v1beta/openai`) and Cohere
|
|
(`/compatibility/v1`) expose an OpenAI-compatible surface at their base URL.
|
|
|
|
When no API key is configured (or provider == "offline") the client falls back
|
|
to a deterministic, dependency-free *mock planner*. This keeps the whole agent
|
|
functional — and fully testable — with zero external calls, which is what the
|
|
local deployment and the test-suite rely on.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
|
|
import httpx
|
|
|
|
from app.config import settings
|
|
from app.services.http_client import shared_client
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Provider → default model + base URL when llm_model/api_base are empty.
|
|
# All entries speak the OpenAI-compatible chat-completions protocol (Anthropic,
|
|
# Google and Cohere expose an OpenAI-compatible surface at their given base).
|
|
PROVIDERS = {
|
|
"openai": ("https://api.openai.com/v1", "gpt-4o"),
|
|
"anthropic": ("https://api.anthropic.com/v1", "claude-opus-4-8"),
|
|
"mistral": ("https://api.mistral.ai/v1", "mistral-large-latest"),
|
|
"cohere": ("https://api.cohere.ai/compatibility/v1", "command-a-plus-05-2026"),
|
|
"google": ("https://generativelanguage.googleapis.com/v1beta/openai", "gemini-2.0-flash"),
|
|
"groq": ("https://api.groq.com/openai/v1", "llama-3.3-70b-versatile"),
|
|
"deepseek": ("https://api.deepseek.com/v1", "deepseek-chat"),
|
|
"openrouter": ("https://openrouter.ai/api/v1", "meta-llama/llama-3.3-70b-instruct"),
|
|
"nvidia": ("https://integrate.api.nvidia.com/v1", "nvidia/nemotron-3-super-120b-a12b"),
|
|
"together": ("https://api.together.xyz/v1", "meta-llama/Llama-3.3-70B-Instruct-Turbo"),
|
|
"perplexity": ("https://api.perplexity.ai", "sonar-pro"),
|
|
"xai": ("https://api.x.ai/v1", "grok-4.6"),
|
|
"qwencloud": ("https://dashscope-intl.aliyuncs.com/compatible-mode/v1", "qwen-max"),
|
|
"minimax": ("https://api.minimax.chat/v1", "MiniMax-Text-01"),
|
|
"morph": ("https://api.morphllm.com/v1", "morph-v3-large"),
|
|
"fireworks": ("https://api.fireworks.ai/inference/v1",
|
|
"accounts/fireworks/models/deepseek-v4-pro-0813"),
|
|
"cerebras": ("https://api.cerebras.ai/v1", "llama-3.3-70b"),
|
|
"sambanova": ("https://api.sambanova.ai/v1", "Meta-Llama-3.3-70B-Instruct"),
|
|
"chutes": ("https://llm.chutes.ai/v1", "deepseek-ai/DeepSeek-V3"),
|
|
"xiaomi": ("https://api.xiaomimimo.com/v1", "mimo-7b-rl"),
|
|
"sealion": ("https://api.sea-lion.ai/v1", "aisingapore/Llama-SEA-LION-v3-70B-IT"),
|
|
"sensenova": ("https://api.sensenova.cn/compatible-mode/v1", "SenseChat-5"),
|
|
"ollama": ("http://localhost:11434/v1", "llama3.1"),
|
|
"offline": (None, None),
|
|
}
|
|
|
|
# Friendly display names for the Settings / Agent UIs.
|
|
PROVIDER_LABELS: dict[str, str] = {
|
|
"openai": "OpenAI",
|
|
"anthropic": "Anthropic",
|
|
"mistral": "Mistral",
|
|
"cohere": "Cohere",
|
|
"google": "Google Gemini",
|
|
"groq": "Groq",
|
|
"deepseek": "DeepSeek",
|
|
"openrouter": "OpenRouter",
|
|
"nvidia": "NVIDIA NIM",
|
|
"together": "Together AI",
|
|
"perplexity": "Perplexity",
|
|
"xai": "xAI (Grok)",
|
|
"qwencloud": "DashScope (Alibaba)",
|
|
"minimax": "MiniMax",
|
|
"morph": "Morph",
|
|
"fireworks": "Fireworks AI",
|
|
"cerebras": "Cerebras",
|
|
"sambanova": "SambaNova",
|
|
"chutes": "Chutes AI",
|
|
"xiaomi": "Xiaomi (MiMo)",
|
|
"sealion": "SEA-LION",
|
|
"sensenova": "SenseNova",
|
|
"ollama": "Ollama (local)",
|
|
"offline": "Hors-ligne (mock)",
|
|
}
|
|
|
|
# Curated model presets surfaced by /api/agent/providers for the UI selectors.
|
|
PROVIDER_MODELS: dict[str, list[str]] = {
|
|
"openai": ["gpt-4o", "gpt-4o-mini", "gpt-4.1", "gpt-4.1-mini", "o3-mini", "gpt-4-turbo"],
|
|
"anthropic": ["claude-opus-4-8", "claude-sonnet-4-5", "claude-3-5-sonnet", "claude-haiku-4-5"],
|
|
"mistral": ["mistral-large-latest", "mistral-medium-latest", "mistral-small-latest",
|
|
"codestral-latest", "open-mistral-nemo", "pixtral-large-latest"],
|
|
"cohere": ["command-a-plus-05-2026", "command-r-plus", "command-r", "command-a-03-2025"],
|
|
"google": ["gemini-2.0-flash", "gemini-2.0-flash-lite", "gemini-1.5-pro", "gemini-1.5-flash"],
|
|
"groq": ["llama-3.3-70b-versatile", "llama-3.1-8b-instant",
|
|
"mixtral-8x7b-32768", "gemma2-9b-it"],
|
|
"deepseek": ["deepseek-chat", "deepseek-reasoner"],
|
|
"openrouter": ["meta-llama/llama-3.3-70b-instruct", "anthropic/claude-3.5-sonnet",
|
|
"openai/gpt-4o", "mistralai/mistral-large"],
|
|
"nvidia": ["nvidia/nemotron-3-super-120b-a12b", "nvidia/nemotron-3-nano-30b-a3b",
|
|
"meta/llama-3.1-70b-instruct", "nvidia/llama-3.3-nemotron-super-49b-v1.5",
|
|
"deepseek-ai/deepseek-v4-pro", "z-ai/glm-5.2"],
|
|
"together": ["meta-llama/Llama-3.3-70B-Instruct-Turbo",
|
|
"meta-llama/Meta-Llama-3.1-405B-Instruct-Turbo",
|
|
"Qwen/Qwen2.5-72B-Instruct-Turbo", "mistralai/Mixtral-8x7B-Instruct-v0.1"],
|
|
"perplexity": ["sonar-pro", "sonar", "sonar-reasoning", "sonar-deep-research"],
|
|
"xai": ["grok-4.6", "grok-4.5", "grok-4.3", "grok-4.20-0309-reasoning",
|
|
"grok-build-0.1"],
|
|
"qwencloud": ["qwen-max", "qwen-plus", "qwen-turbo", "qwen-long"],
|
|
"minimax": ["MiniMax-Text-01", "abab6.5s-chat", "abab6.5-chat"],
|
|
"morph": ["morph-v3-large", "morph-v3-fast"],
|
|
"fireworks": ["accounts/fireworks/models/deepseek-v4-pro-0813",
|
|
"accounts/fireworks/models/kimi-k2p6",
|
|
"accounts/fireworks/models/glm-5p2",
|
|
"accounts/fireworks/models/minimax-m3",
|
|
"accounts/fireworks/models/gpt-oss-120b",
|
|
"accounts/fireworks/models/qwen3-8b"],
|
|
"cerebras": ["llama-3.3-70b", "llama3.1-8b", "llama-3.1-70b"],
|
|
"sambanova": ["Meta-Llama-3.3-70B-Instruct", "Meta-Llama-3.1-405B-Instruct",
|
|
"Qwen2.5-72B-Instruct"],
|
|
"chutes": ["deepseek-ai/DeepSeek-V3", "deepseek-ai/DeepSeek-R1",
|
|
"Qwen/Qwen2.5-72B-Instruct"],
|
|
"xiaomi": ["mimo-7b-rl", "mimo-7b"],
|
|
"sealion": ["aisingapore/Llama-SEA-LION-v3-70B-IT",
|
|
"aisingapore/Gemma-SEA-LION-v3-9B-IT"],
|
|
"sensenova": ["SenseChat-5", "SenseChat-5-Cantonese", "SenseChat-Turbo"],
|
|
"ollama": ["llama3.1", "llama3", "mistral", "qwen2.5", "gemma2", "mixtral"],
|
|
"offline": [],
|
|
}
|
|
|
|
# Llama-style / ChatML tool markers used by the mock planner.
|
|
_CREATE_PATTERNS = [
|
|
(re.compile(r"cr[eéé]er\s+(?:une\s+)?collection[:\s]+[\"']?([A-Za-zÀ-ÿ0-9 _\-]+)"),
|
|
lambda m: ("create_collection", {"name": m.group(1).strip()})),
|
|
(re.compile(r"create\s+collection\s+[\"']?([A-Za-z0-9 _\-]+)"),
|
|
lambda m: ("create_collection", {"name": m.group(1).strip()})),
|
|
(re.compile(r"create\s+a\s+page\s+[\"']?([A-Za-z0-9 _\-]+)"),
|
|
lambda m: ("create_page", {"title": m.group(1).strip()})),
|
|
]
|
|
_SEARCH_PATTERNS = [
|
|
(re.compile(r"(?:recherche|search|trouve|find)\s+[\"']?([A-Za-z0-9 _\-]+)"),
|
|
lambda m: ("search_workspace", {"query": m.group(1).strip()})),
|
|
]
|
|
|
|
# Loose fallback: "collection <Name>" → create_collection (covers "crée une collection X",
|
|
# "créer la collection X", "create collection X", etc.)
|
|
_COLLECTION_LINE = re.compile(
|
|
r"\bcollection\b[:\s]+(?:nomm[ée]e\s+)?([A-Za-zÀ-ÿ0-9_][^,.\n()]*[A-Za-zÀ-ÿ0-9_])",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class LLMResponse:
|
|
"""Normalized completion: either a final text or one or more tool calls."""
|
|
text: str = ""
|
|
tool_calls: list[dict] = field(default_factory=list)
|
|
model: str = ""
|
|
usage: dict = field(default_factory=dict)
|
|
notice: str = ""
|
|
|
|
|
|
class LLMClient:
|
|
"""Multi-provider chat client with tool-calling support and offline mock."""
|
|
|
|
def __init__(self, provider: str | None = None, api_key: str | None = None,
|
|
api_base: str | None = None):
|
|
from .llm_config import get_llm_config # local import avoids a cycle
|
|
|
|
cfg = get_llm_config()
|
|
self.provider = (provider or cfg["provider"] or "offline").lower()
|
|
self.api_key = api_key if api_key is not None else cfg["api_key"]
|
|
self.api_base = api_base if api_base is not None else cfg["api_base"]
|
|
base, model = PROVIDERS.get(self.provider, (None, None))
|
|
self.api_base = self.api_base or base
|
|
# Le modèle global configuré n'est valable que pour le provider global :
|
|
# tester un autre provider (ex. nvidia alors que deepseek est actif) ne doit
|
|
# PAS lui envoyer le modèle du provider actif (sinon « model not found »).
|
|
cfg_provider = (cfg.get("provider") or "offline").lower()
|
|
global_model = (cfg.get("model") or "") if self.provider == cfg_provider else ""
|
|
self.default_model = global_model or model or "gpt-4o"
|
|
|
|
# ── Public API ──
|
|
|
|
async def complete(self, messages: list[dict], *, model: str | None = None,
|
|
tools: list[dict] | None = None,
|
|
stream: bool = False) -> LLMResponse:
|
|
"""Send a chat completion. Returns text and/or tool_calls."""
|
|
model = model or self.default_model
|
|
|
|
if self.provider == "offline" or not self._has_credentials():
|
|
return await self._mock_complete(messages, model, tools)
|
|
|
|
try:
|
|
return await asyncio.wait_for(
|
|
self._http_complete(messages, model, tools),
|
|
timeout=settings.agent_run_timeout_seconds,
|
|
)
|
|
except Exception as exc: # noqa: BLE001 — never mask a real-provider failure
|
|
# On NE retombe PAS silencieusement sur le mock quand un fournisseur
|
|
# réel est configuré : l'erreur doit remonter (SSE "error") pour que
|
|
# l'utilisateur voie pourquoi rien n'a été généré.
|
|
logger.warning("LLM provider '%s' failed (%s)", self.provider, exc)
|
|
raise
|
|
|
|
async def is_available(self) -> bool:
|
|
"""True when a real provider is configured."""
|
|
return self.provider != "offline" and self._has_credentials()
|
|
|
|
async def ping(self, *, model: str | None = None) -> LLMResponse:
|
|
"""Reach the provider without mock fallback (used by the "Test connection"
|
|
UI). Raises on any real error so the caller can surface it."""
|
|
model = model or self.default_model
|
|
if self.provider == "offline":
|
|
return LLMResponse(text="Mode hors-ligne (mock) — aucun appel réseau nécessaire.", model=model)
|
|
if not self._has_credentials():
|
|
raise PermissionError(f"Clé API manquante pour le provider « {self.provider} »")
|
|
return await asyncio.wait_for(
|
|
self._http_complete(
|
|
[{"role": "user", "content": "Réponds uniquement par le mot : PONG"}],
|
|
model,
|
|
None,
|
|
),
|
|
timeout=settings.agent_run_timeout_seconds,
|
|
)
|
|
|
|
# ── Helpers ──
|
|
|
|
def _has_credentials(self) -> bool:
|
|
if self.provider == "ollama":
|
|
return True # local, no key required
|
|
return bool(self.api_key)
|
|
|
|
def _endpoint(self) -> str:
|
|
return f"{self.api_base.rstrip('/')}/chat/completions"
|
|
|
|
@staticmethod
|
|
def _http_error_detail(exc: httpx.HTTPStatusError) -> str:
|
|
"""Human-readable HTTP error including the provider's response body.
|
|
|
|
Providers return actionable JSON on 4xx (e.g. « model not allowed »,
|
|
« country not supported »); surfacing it makes the Settings test
|
|
debuggable instead of a bare « 403 Forbidden ».
|
|
"""
|
|
resp = exc.response
|
|
try:
|
|
body = (resp.text or "").strip()
|
|
except Exception: # noqa: BLE001 — body already consumed / undecodable
|
|
body = ""
|
|
if len(body) > 500:
|
|
body = body[:500] + "…"
|
|
base = f"{resp.status_code} {resp.reason_phrase} ({resp.url})"
|
|
return f"{base}: {body}" if body else base
|
|
|
|
async def _http_complete(self, messages, model, tools, *, _noticer: str = "") -> LLMResponse:
|
|
payload: dict = {
|
|
"model": model,
|
|
"messages": messages,
|
|
"temperature": 0.2,
|
|
}
|
|
if tools:
|
|
payload["tools"] = [{"type": "function", "function": t} for t in tools]
|
|
payload["tool_choice"] = "auto"
|
|
headers = {"Content-Type": "application/json"}
|
|
if self.api_key:
|
|
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
try:
|
|
async with shared_client(timeout=settings.agent_run_timeout_seconds) as client:
|
|
resp = await client.post(self._endpoint(), json=payload, headers=headers)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
except httpx.HTTPStatusError as exc:
|
|
# Repli robuste : le modèle choisi a été retiré / n'existe plus
|
|
# (404 « model not found » / 410 « has reached its end of life »).
|
|
# Au lieu d'échouer, on retente UNE fois avec le modèle par défaut du
|
|
# provider et on signale le basculement — la liste validée peut avoir
|
|
# vieilli (modèle déprécié entre deux rafraîchissements).
|
|
if exc.response.status_code in (404, 410) \
|
|
and model and self.default_model and model != self.default_model:
|
|
logger.warning(
|
|
"Model '%s' unavailable (%s) on %s — retrying with default '%s'",
|
|
model, exc.response.status_code, self.provider, self.default_model,
|
|
)
|
|
return await self._http_complete(messages, self.default_model, tools, _noticer=(
|
|
f"Le modèle « {model} » n'est plus disponible ({exc.response.status_code}). "
|
|
f"Réponse générée avec « {self.default_model} » à la place."
|
|
))
|
|
# Surface the provider's own error body (403 « forbidden », 400 …).
|
|
raise RuntimeError(self._http_error_detail(exc)) from exc
|
|
|
|
response = self._parse_response(data, model)
|
|
response.notice = _noticer or ""
|
|
return response
|
|
|
|
def _parse_response(self, data: dict, model: str) -> LLMResponse:
|
|
choice = data["choices"][0]["message"]
|
|
text = choice.get("content") or ""
|
|
tool_calls = []
|
|
for tc in choice.get("tool_calls") or []:
|
|
fn = tc.get("function") or {}
|
|
try:
|
|
args = json.loads(fn.get("arguments") or "{}")
|
|
except json.JSONDecodeError:
|
|
args = {}
|
|
tool_calls.append({
|
|
"id": tc.get("id") or "",
|
|
"name": fn.get("name"),
|
|
"arguments": args,
|
|
"arguments_raw": fn.get("arguments") or "",
|
|
})
|
|
return LLMResponse(
|
|
text=text,
|
|
tool_calls=tool_calls,
|
|
model=model,
|
|
usage=data.get("usage", {}),
|
|
)
|
|
|
|
# ── Offline mock planner (deterministic, no network) ──
|
|
|
|
async def _mock_complete(self, messages, model, tools) -> LLMResponse:
|
|
user_content = self._last_user_content(messages)
|
|
sys_content = self._system_content(messages)
|
|
# Only the user's objective drives the planner. The engine appends the
|
|
# workspace/document snapshot under "# Contexte"; that text must never
|
|
# trigger keyword heuristics (a doc mentioning "recherche"/"collection"
|
|
# used to misroute content requests into tool calls).
|
|
objective = user_content.split("\n# Contexte")[0]
|
|
|
|
# Once tool results are already in the conversation, we have acted:
|
|
# stop issuing new tool calls and conclude.
|
|
if any(m.get("role") == "tool" for m in messages):
|
|
return LLMResponse(
|
|
text="Objectif traité — actions enregistrées dans le journal d'audit.",
|
|
model=model,
|
|
)
|
|
|
|
# Skill-driven: if the objective names a known skill, mirror its template.
|
|
skill_hint = self._extract_skill_hint(objective)
|
|
|
|
if skill_hint == "sprint":
|
|
return LLMResponse(
|
|
tool_calls=[
|
|
{"name": "read_gitea_issues", "arguments": {"owner": "bruno", "repo": "flowdeck", "state": "open"}},
|
|
{"name": "create_collection", "arguments": {"name": "Sprint"}},
|
|
{"name": "add_property", "arguments": {"collection_id": 0, "name": "Status", "prop_type": "select", "options": ["Todo", "In Progress", "Done"]}},
|
|
{"name": "create_view", "arguments": {"collection_id": 0, "view_type": "board"}},
|
|
],
|
|
text="Plan: analyze open issues, then build a sprint board.",
|
|
model=model,
|
|
)
|
|
|
|
# Inline content-generation ("Ask AI" / "AI meeting note") — answered
|
|
# before the tool-intent heuristics and scoped to the user objective
|
|
# only, so an injected "# Contexte" that happens to mention "collection"
|
|
# can't misroute a writing request into a create-collection action.
|
|
draft = self._draft_reply(objective)
|
|
if draft:
|
|
return LLMResponse(text=draft, model=model)
|
|
|
|
# Documents / espaces de travail (offline): unambiguous intents resolved
|
|
# from the objective — create a document (optionally in a named
|
|
# workspace) or list the accessible workspaces.
|
|
doc_args = self._document_create_args(objective)
|
|
if doc_args is not None:
|
|
return LLMResponse(
|
|
tool_calls=[{"name": "create_document", "arguments": doc_args}],
|
|
text="Plan: création d'un document.",
|
|
model=model,
|
|
)
|
|
if self._is_workspaces_request(objective):
|
|
return LLMResponse(
|
|
tool_calls=[{"name": "read_workspaces", "arguments": {}}],
|
|
text="Plan: lister les espaces de travail.",
|
|
model=model,
|
|
)
|
|
|
|
# Exact keyword → tool intent resolution (objective only).
|
|
for regex, builder in _CREATE_PATTERNS:
|
|
m = regex.search(objective)
|
|
if m:
|
|
return LLMResponse(
|
|
tool_calls=[dict(name=name, arguments=self._bind_placeholders(args, sys_content)) for name, args in [builder(m)]],
|
|
text=f"Plan: running {builder(m)[0]}.",
|
|
model=model,
|
|
)
|
|
|
|
for regex, builder in _SEARCH_PATTERNS:
|
|
m = regex.search(objective)
|
|
if m:
|
|
return LLMResponse(
|
|
tool_calls=[dict(name=name, arguments=args) for name, args in [builder(m)]],
|
|
text=f"Plan: searching '{m.group(1)}'.",
|
|
model=model,
|
|
)
|
|
|
|
# Loose "collection <X>" detection → treat as a create intent.
|
|
low = objective.lower()
|
|
if "collection" in low:
|
|
m = _COLLECTION_LINE.search(objective)
|
|
if m:
|
|
name = m.group(1).strip()
|
|
return LLMResponse(
|
|
tool_calls=[{"name": "create_collection",
|
|
"arguments": self._bind_placeholders({"name": name}, sys_content)}],
|
|
text=f"Plan: create collection '{name}'.",
|
|
model=model,
|
|
)
|
|
|
|
# Plain conversational objective → final answer (no tool).
|
|
return LLMResponse(
|
|
text=self._summarize(objective),
|
|
model=model,
|
|
)
|
|
|
|
def _bind_placeholders(self, args: dict, sys_content: str) -> dict:
|
|
"""Inject a collection id from the context when the planner left it as 0."""
|
|
args = dict(args)
|
|
if args.get("collection_id") == 0:
|
|
match = re.search(r"Collection IDs?:\s*([0-9,\s]+)", sys_content)
|
|
if match:
|
|
ids = [int(x) for x in re.split(r"[,\s]+", match.group(1).strip()) if x.isdigit()]
|
|
if ids:
|
|
args["collection_id"] = ids[0]
|
|
return args
|
|
|
|
@staticmethod
|
|
def _document_create_args(content: str) -> dict | None:
|
|
"""Deterministic `create_document` intent for the offline mock.
|
|
|
|
Only fires when the user clearly asks to *create* a document (a content
|
|
rewrite such as « résume / traduis ce document » is left to `_draft_reply`).
|
|
Returns None when the message is not a create-document intent.
|
|
"""
|
|
low = content.lower()
|
|
if "document" not in low:
|
|
return None
|
|
if not any(k in low for k in ("création", "créer", "crée", "crées", "create",
|
|
"nouveau document", "nouvelle page", "faire un")):
|
|
return None
|
|
quotes = re.findall(r'[«"]([^«»"]{1,80})[»"]', content)
|
|
title = quotes[0].strip() if quotes else None
|
|
if not title:
|
|
m = re.search(
|
|
r"\bdocument\b\s*(?:nomm[ée]e?\s+|intitul[ée]e?\s+|appel[ée]e?\s+)?"
|
|
r'[«"]?\s*([A-Za-zÀ-ÿ0-9][A-Za-zÀ-ÿ0-9_ \-]{1,60})',
|
|
content, re.IGNORECASE,
|
|
)
|
|
if m:
|
|
title = m.group(1).strip()
|
|
if not title:
|
|
return None
|
|
args = {"title": title}
|
|
if len(quotes) > 1 and re.search(r"\b(workspace|espace de travail)\b", low):
|
|
args["workspace_name"] = quotes[-1].strip()
|
|
return args
|
|
|
|
@staticmethod
|
|
def _is_workspaces_request(content: str) -> bool:
|
|
"""True when the user asks to list / locate the workspaces."""
|
|
low = content.lower()
|
|
has_ws = any(w in low for w in ("workspace", "espace de travail", "espaces de travail"))
|
|
has_verb = any(v in low for v in ("liste", "lister", "list", "quels", "montre",
|
|
"affiche", "mes espaces", "ou sont", "où sont"))
|
|
return has_ws and has_verb
|
|
|
|
@staticmethod
|
|
def _system_content(messages) -> str:
|
|
return "\n".join(m.get("content", "") for m in messages if m.get("role") == "system")
|
|
|
|
@staticmethod
|
|
def _last_user_content(messages) -> str:
|
|
for m in reversed(messages):
|
|
if m.get("role") == "user":
|
|
c = m.get("content", "")
|
|
if isinstance(c, list):
|
|
return " ".join(p.get("text", "") for p in c if isinstance(p, dict))
|
|
return str(c)
|
|
return ""
|
|
|
|
@staticmethod
|
|
def _extract_skill_hint(content: str) -> str | None:
|
|
low = content.lower()
|
|
if "sprint" in low or "préparation de sprint" in low:
|
|
return "sprint"
|
|
return None
|
|
|
|
@staticmethod
|
|
def _summarize(content: str) -> str:
|
|
"""Produce a terse final summary from a conversational objective."""
|
|
content = content.split("\n# Contexte")[0]
|
|
return (content[:600] + "…" if len(content) > 600 else content)
|
|
|
|
def _draft_reply(self, content: str) -> str | None:
|
|
"""Generate usable structured copy for content/meeting requests when no
|
|
real LLM is configured (offline mock). Returns None when the message is
|
|
not a clear content-generation intent so other paths keep their behavior.
|
|
"""
|
|
from datetime import date
|
|
|
|
low = content.lower()
|
|
title = None
|
|
m = re.search(r"intitul[ée]e\s*[«\"']([^»\"']+)[»\"']", content)
|
|
if m:
|
|
title = m.group(1).strip()
|
|
today = date.today().isoformat()
|
|
|
|
# ── AI meeting note template ──
|
|
if any(k in low for k in ("ai meeting note", "meeting note",
|
|
"compte-rendu", "compte rendu",
|
|
"notes de réunion", "réunion")):
|
|
return (
|
|
"📅 AI Meeting Note\n"
|
|
f"Date : {today} · Participants : (à renseigner)\n"
|
|
"\n"
|
|
"## Résumé\n"
|
|
"Point central de la discussion et contexte (à compléter).\n"
|
|
"\n"
|
|
"## Décisions\n"
|
|
"• Décision 1 — valider le périmètre et les responsables.\n"
|
|
"• Décision 2 — définir la prochaine échéance.\n"
|
|
"\n"
|
|
"## Action items\n"
|
|
"☐ Action 1 — responsable : …, échéance : …\n"
|
|
"☐ Action 2 — responsable : …, échéance : …\n"
|
|
"\n"
|
|
"## Prochaines étapes\n"
|
|
"• Planifier le suivi et archiver ce compte-rendu.\n"
|
|
)
|
|
|
|
# ── Traduction / analyse du document (hors-ligne) ──
|
|
if re.search(r"traduis|traduit|translate", low):
|
|
return (
|
|
"⚠️ **Traduction non disponible en mode hors-ligne** (aucun modèle d'IA "
|
|
"connecté).\n\n"
|
|
"Connectez un fournisseur dans **Paramètres → Agent & IA**, puis relancez "
|
|
"« Traduire cette page » : le document traduit apparaîtra ici, avec un aperçu "
|
|
"à approuver ou à rejeter avant application."
|
|
)
|
|
if re.search(r"r[ée]sum|am[ée]lior|sugg[èe]re des|propose des", low):
|
|
return (
|
|
"⚠️ **Cette action nécessite un modèle d'IA connecté** pour analyser le "
|
|
"document.\n\n"
|
|
"Configurez une clé API dans **Paramètres → Agent & IA**, puis relancez "
|
|
"l'action : l'agent générera la proposition ici, avec un aperçu à approuver "
|
|
"ou à rejeter avant application."
|
|
)
|
|
|
|
# ── Page / document draft (contextual "Ask AI") ──
|
|
if title:
|
|
t = title[:80]
|
|
return (
|
|
f"{t}\n"
|
|
"\n"
|
|
f"Présentation générale du sujet « {t} » : objectif, contexte et "
|
|
"public visé en quelques phrases. (Document généré hors-ligne — "
|
|
"connectez une clé API pour une rédaction complète.)\n"
|
|
"\n"
|
|
"## Objectif\n"
|
|
"• Clarifier le besoin couvert par ce document.\n"
|
|
"• Lister les livrables attendus.\n"
|
|
"\n"
|
|
"## Points clés\n"
|
|
"• Idée principale 1 avec les arguments associés.\n"
|
|
"• Idée principale 2 et les exemples concrets.\n"
|
|
"\n"
|
|
"## Prochaines étapes\n"
|
|
"• Relire, compléter et mettre en forme ce contenu.\n"
|
|
)
|
|
|
|
# ── Generic drafting verb, no title (typing directly in the chat) ──
|
|
if re.search(r"^(r[ée]dige|[ée]cris|[ée]crire|g[ée]n[èe]re|produis|d[ée]veloppe|"
|
|
r"[ée]cris\s+un|g[ée]n[èe]re\s+un|r[ée]dige\s+un)\b", low):
|
|
return (
|
|
"## Introduction\n"
|
|
"Contexte et objectif de ce texte, en une à deux phrases.\n"
|
|
"\n"
|
|
"## Développement\n"
|
|
"• Premier argument structuré avec un exemple.\n"
|
|
"• Deuxième argument appuyé par une donnée ou un fait.\n"
|
|
"\n"
|
|
"## Conclusion\n"
|
|
"Synthèse et prochaine étape recommandée.\n"
|
|
)
|
|
|
|
return None
|