CI / lint (push) Successful in 57s
CI / security (push) Successful in 39s
CI / test (push) Successful in 1m13s
CI / build (push) Successful in 36s
CI / e2e (push) Successful in 10m13s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
- backend/ai_chat.py: chat_completion provider-agnostique (OpenAI-compat tools/tool_calls + Gemini functionDeclarations/functionCall), retry sans tools si rejete - backend/agent/loop.py: run_agent multi-etapes (limite 10, truncation, confirmation two-step), LLM injectable - endpoint opt-in POST /api/ai/bookslm/agent (events SSE tool/message/confirmation), extraction _resolve_system_prompt - tests: test_ai_chat.py, test_agent_loop.py + 3 tests endpoint (728 passed au total) - ROADMAP B1/B2/B3/B7 livres ; B4/B5/B6 restants
369 lines
15 KiB
Python
369 lines
15 KiB
Python
"""ObsiGate AI — Multi-provider AI service for editor enhancement.
|
|
|
|
Supports: DeepSeek, OpenRouter, Google Gemini, Ollama, NVIDIA, QwenCloud, Xiaomi, Mistral.
|
|
Configured via environment variables.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
from pathlib import Path
|
|
from typing import Literal
|
|
|
|
import httpx
|
|
|
|
logger = logging.getLogger("obsigate.ai")
|
|
|
|
ProviderName = Literal["deepseek", "openrouter", "gemini", "ollama", "nvidia", "qwencloud", "xiaomi", "mistral"]
|
|
|
|
# Provider configurations — keys loaded from file or .env
|
|
AI_KEYS_FILE = Path("data/api_keys.json")
|
|
APP_CONFIG_FILE = Path(__file__).resolve().parent.parent / "data" / "config.json"
|
|
|
|
def _read_ai_keys() -> dict:
|
|
if not AI_KEYS_FILE.exists():
|
|
return {}
|
|
try:
|
|
return json.loads(AI_KEYS_FILE.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
return {}
|
|
|
|
|
|
def _read_app_config() -> dict:
|
|
"""Read the persisted application config (``data/config.json``)."""
|
|
if not APP_CONFIG_FILE.exists():
|
|
return {}
|
|
try:
|
|
return json.loads(APP_CONFIG_FILE.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
return {}
|
|
|
|
def get_ai_key(env_name: str) -> str:
|
|
"""Get AI key: stored file first, then .env fallback."""
|
|
keys = _read_ai_keys()
|
|
if keys.get(env_name):
|
|
return keys[env_name]
|
|
return os.getenv(env_name, "").strip()
|
|
|
|
def _load_provider_keys():
|
|
"""Load AI keys from stored file, falling back to .env."""
|
|
providers = {
|
|
"deepseek": {
|
|
"api_key": get_ai_key("DEEPSEEK_API_KEY"),
|
|
"base_url": "https://api.deepseek.com/v1",
|
|
"model": os.getenv("DEEPSEEK_MODEL", "deepseek-chat"),
|
|
"auth_header": "Bearer {api_key}",
|
|
},
|
|
"openrouter": {
|
|
"api_key": get_ai_key("OPENROUTER_API_KEY"),
|
|
"base_url": "https://openrouter.ai/api/v1",
|
|
"model": os.getenv("OPENROUTER_MODEL", "openai/gpt-4o-mini"),
|
|
"auth_header": "Bearer {api_key}",
|
|
},
|
|
"gemini": {
|
|
"api_key": get_ai_key("GEMINI_API_KEY"),
|
|
"base_url": "https://generativelanguage.googleapis.com/v1beta",
|
|
"model": os.getenv("GEMINI_MODEL", "gemini-2.0-flash"),
|
|
"auth_header": None,
|
|
},
|
|
"ollama": {
|
|
"api_key": "ollama",
|
|
"base_url": os.getenv("OLLAMA_BASE_URL", "http://ollama:11434/v1"),
|
|
"model": os.getenv("OLLAMA_MODEL", "qwen2.5-coder:1.5b"),
|
|
"auth_header": "Bearer {api_key}",
|
|
},
|
|
"nvidia": {
|
|
"api_key": get_ai_key("NVIDIA_API_KEY"),
|
|
"base_url": "https://integrate.api.nvidia.com/v1",
|
|
"model": os.getenv("NVIDIA_MODEL", "meta/llama-3.1-405b-instruct"),
|
|
"auth_header": "Bearer {api_key}",
|
|
},
|
|
"qwencloud": {
|
|
"api_key": get_ai_key("QWENCLOUD_API_KEY"),
|
|
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
|
"model": os.getenv("QWENCLOUD_MODEL", "qwen-max"),
|
|
"auth_header": "Bearer {api_key}",
|
|
},
|
|
"xiaomi": {
|
|
"api_key": get_ai_key("XIAOMI_API_KEY"),
|
|
"base_url": os.getenv("XIAOMI_BASE_URL", "https://api.xiaomimimo.com/v1"),
|
|
"model": os.getenv("XIAOMI_MODEL", "mimo-v2.5-pro"),
|
|
# Xiaomi MiMo uses a dedicated `api-key` header (NOT Authorization: Bearer).
|
|
# The `_call_deepseek_openrouter` helper substitutes {api_key} verbatim,
|
|
# so we just emit the raw key value here.
|
|
"auth_header": "{api_key}",
|
|
"auth_header_name": "api-key",
|
|
},
|
|
"mistral": {
|
|
"api_key": get_ai_key("MISTRAL_API_KEY"),
|
|
"base_url": "https://api.mistral.ai/v1",
|
|
"model": os.getenv("MISTRAL_MODEL", "mistral-large-latest"),
|
|
"auth_header": "Bearer {api_key}",
|
|
},
|
|
}
|
|
# Apply persisted per-provider model overrides (data/config.json).
|
|
overrides = _read_app_config().get("ai_default_models") or {}
|
|
if isinstance(overrides, dict):
|
|
for name, model in overrides.items():
|
|
if name in providers and isinstance(model, str) and model:
|
|
providers[name]["model"] = model
|
|
return providers
|
|
|
|
|
|
PROVIDERS = _load_provider_keys()
|
|
|
|
DEFAULT_PROVIDER: ProviderName = os.getenv("AI_DEFAULT_PROVIDER", "deepseek") # type: ignore
|
|
|
|
|
|
def get_default_provider() -> str:
|
|
"""Resolve the default provider: ``data/config.json`` > env > ``deepseek``."""
|
|
provider: str = str(_read_app_config().get("ai_default_provider") or os.getenv("AI_DEFAULT_PROVIDER", "deepseek"))
|
|
return provider if provider in PROVIDERS else "deepseek"
|
|
|
|
|
|
def reload_ai_config() -> str:
|
|
"""Reload provider keys and model overrides from disk into ``PROVIDERS`` in place.
|
|
|
|
Mutating ``PROVIDERS`` (rather than rebinding it) keeps references held by
|
|
other modules valid. Returns the resolved default provider.
|
|
"""
|
|
global DEFAULT_PROVIDER
|
|
PROVIDERS.clear()
|
|
PROVIDERS.update(_load_provider_keys())
|
|
DEFAULT_PROVIDER = get_default_provider() # type: ignore[assignment]
|
|
logger.info(f"AI config reloaded (default provider: {DEFAULT_PROVIDER})")
|
|
return DEFAULT_PROVIDER
|
|
|
|
|
|
def _get_provider_config(provider: ProviderName | None = None) -> dict:
|
|
"""Get provider config, falling back to default if requested provider unavailable."""
|
|
default = get_default_provider()
|
|
p = provider or default
|
|
if p not in PROVIDERS:
|
|
p = default
|
|
cfg = PROVIDERS[p]
|
|
if not cfg["api_key"]:
|
|
# Try next available provider
|
|
for alt in PROVIDERS:
|
|
if PROVIDERS[alt]["api_key"]:
|
|
p = alt
|
|
cfg = PROVIDERS[alt]
|
|
break
|
|
return {"name": p, **cfg}
|
|
|
|
|
|
def _build_headers(cfg: dict) -> dict:
|
|
"""Build HTTP headers for an OpenAI-compatible provider config.
|
|
|
|
Most providers use ``Authorization: Bearer KEY``. Some (Xiaomi MiMo) use a
|
|
dedicated header like ``api-key: KEY`` — supported via the
|
|
``auth_header_name`` key in PROVIDERS (defaults to ``Authorization``).
|
|
"""
|
|
header_name = cfg.get("auth_header_name") or "Authorization"
|
|
header_value = cfg["auth_header"].format(api_key=cfg["api_key"])
|
|
if header_name == "Authorization" and not header_value.lower().startswith("bearer "):
|
|
header_value = "Bearer " + header_value
|
|
return {
|
|
header_name: header_value,
|
|
"Content-Type": "application/json",
|
|
}
|
|
|
|
|
|
async def _call_deepseek_openrouter(prompt: str, system: str, provider: ProviderName | None = None,
|
|
temperature: float = 0.7, max_tokens: int = 2048) -> str:
|
|
"""Call OpenAI-compatible API (DeepSeek, OpenRouter, Xiaomi MiMo, etc.)."""
|
|
cfg = _get_provider_config(provider)
|
|
# Debug: log masked key to diagnose 401
|
|
key_preview = cfg["api_key"][:8] + "..." + cfg["api_key"][-4:] if len(cfg["api_key"]) > 12 else "***"
|
|
logger.info(f"AI call: provider={cfg['name']} model={cfg['model']} key={key_preview}")
|
|
headers = _build_headers(cfg)
|
|
payload = {
|
|
"model": cfg["model"],
|
|
"messages": [
|
|
{"role": "system", "content": system},
|
|
{"role": "user", "content": prompt},
|
|
],
|
|
"temperature": temperature,
|
|
"max_tokens": max_tokens,
|
|
}
|
|
async with httpx.AsyncClient(timeout=60.0) as client:
|
|
resp = await client.post(
|
|
f"{cfg['base_url']}/chat/completions",
|
|
headers=headers,
|
|
json=payload,
|
|
)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
return data["choices"][0]["message"]["content"].strip()
|
|
|
|
|
|
async def _call_gemini(prompt: str, system: str, temperature: float = 0.7, max_tokens: int = 2048) -> str:
|
|
"""Call Google Gemini API."""
|
|
cfg = PROVIDERS["gemini"]
|
|
url = f"{cfg['base_url']}/models/{cfg['model']}:generateContent?key={cfg['api_key']}"
|
|
payload = {
|
|
"system_instruction": {"parts": [{"text": system}]},
|
|
"contents": [{"parts": [{"text": prompt}]}],
|
|
"generationConfig": {
|
|
"temperature": temperature,
|
|
"maxOutputTokens": max_tokens,
|
|
},
|
|
}
|
|
async with httpx.AsyncClient(timeout=60.0) as client:
|
|
resp = await client.post(url, json=payload)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
return data["candidates"][0]["content"]["parts"][0]["text"].strip()
|
|
|
|
|
|
async def ai_complete(prompt: str, provider: ProviderName | None = None) -> str:
|
|
"""Generic AI completion. Routes to appropriate provider."""
|
|
cfg = _get_provider_config(provider)
|
|
if cfg["name"] == "gemini":
|
|
return await _call_gemini(prompt, "You are a helpful assistant.")
|
|
# All other providers use OpenAI-compatible format
|
|
return await _call_deepseek_openrouter(prompt, "You are a helpful assistant.", provider)
|
|
|
|
|
|
# ── Specialized AI actions ──
|
|
|
|
SYSTEM_PROMPT = """You are an AI assistant integrated into ObsiGate, a knowledge management tool.
|
|
Your responses should be direct and concise. When editing text, return ONLY the modified text,
|
|
no explanations or markdown fences."""
|
|
|
|
|
|
async def ai_improve_writing(text: str, provider: ProviderName | None = None) -> str:
|
|
"""Improve writing quality while preserving meaning."""
|
|
return await _call_deepseek_openrouter(
|
|
f"Improve the following text. Fix grammar, clarity, and flow. Preserve the original language and meaning.\n\n{text}",
|
|
SYSTEM_PROMPT, provider, temperature=0.3,
|
|
)
|
|
|
|
|
|
async def ai_fix_spelling(text: str, provider: ProviderName | None = None) -> str:
|
|
"""Fix spelling and grammar errors."""
|
|
return await _call_deepseek_openrouter(
|
|
f"Fix all spelling and grammar errors in this text. Return only the corrected text.\n\n{text}",
|
|
SYSTEM_PROMPT, provider, temperature=0.1,
|
|
)
|
|
|
|
|
|
async def ai_make_shorter(text: str, provider: ProviderName | None = None) -> str:
|
|
"""Make text more concise."""
|
|
return await _call_deepseek_openrouter(
|
|
f"Make this text shorter and more concise while preserving the key information.\n\n{text}",
|
|
SYSTEM_PROMPT, provider, temperature=0.3,
|
|
)
|
|
|
|
|
|
async def ai_make_longer(text: str, provider: ProviderName | None = None) -> str:
|
|
"""Expand text with more detail."""
|
|
return await _call_deepseek_openrouter(
|
|
f"Expand this text with more detail, examples, or explanation while keeping the same tone.\n\n{text}",
|
|
SYSTEM_PROMPT, provider, temperature=0.7, max_tokens=4096,
|
|
)
|
|
|
|
|
|
async def ai_simplify(text: str, provider: ProviderName | None = None) -> str:
|
|
"""Simplify language."""
|
|
return await _call_deepseek_openrouter(
|
|
f"Simplify this text. Use clearer, more straightforward language. Avoid jargon.\n\n{text}",
|
|
SYSTEM_PROMPT, provider, temperature=0.3,
|
|
)
|
|
|
|
|
|
async def ai_change_tone(text: str, tone: str, provider: ProviderName | None = None) -> str:
|
|
"""Change the tone of the text."""
|
|
return await _call_deepseek_openrouter(
|
|
f"Rewrite this text in a {tone} tone. Preserve the original meaning.\n\n{text}",
|
|
SYSTEM_PROMPT, provider, temperature=0.5,
|
|
)
|
|
|
|
|
|
async def ai_translate(text: str, target_lang: str, provider: ProviderName | None = None) -> str:
|
|
"""Translate text to target language."""
|
|
# Gemini is better at translation
|
|
if provider is None and PROVIDERS["gemini"]["api_key"]:
|
|
return await _call_gemini(
|
|
f"Translate the following text to {target_lang}. Return only the translation.\n\n{text}",
|
|
"You are a professional translator. Translate accurately and naturally.",
|
|
temperature=0.1,
|
|
)
|
|
return await _call_deepseek_openrouter(
|
|
f"Translate the following text to {target_lang}. Return only the translation.\n\n{text}",
|
|
"You are a professional translator. Translate accurately and naturally.",
|
|
provider, temperature=0.1,
|
|
)
|
|
|
|
|
|
async def ai_explain(text: str, provider: ProviderName | None = None) -> str:
|
|
"""Explain the selected text."""
|
|
return await _call_deepseek_openrouter(
|
|
f"Explain the following text clearly and concisely:\n\n{text}",
|
|
SYSTEM_PROMPT, provider, temperature=0.3,
|
|
)
|
|
|
|
|
|
async def ai_summarize(text: str, provider: ProviderName | None = None) -> str:
|
|
"""Summarize the selected text."""
|
|
return await _call_deepseek_openrouter(
|
|
f"Summarize the following text concisely:\n\n{text}",
|
|
SYSTEM_PROMPT, provider, temperature=0.3,
|
|
)
|
|
|
|
|
|
async def ai_continue_writing(text: str, provider: ProviderName | None = None) -> str:
|
|
"""Continue writing from the selected text."""
|
|
return await _call_deepseek_openrouter(
|
|
f"Continue writing from where this text leaves off. Match the style and tone:\n\n{text}",
|
|
SYSTEM_PROMPT, provider, temperature=0.7, max_tokens=4096,
|
|
)
|
|
|
|
|
|
async def ai_custom_rewrite(text: str, instruction: str, provider: ProviderName | None = None) -> str:
|
|
"""Rewrite text based on a custom instruction."""
|
|
return await _call_deepseek_openrouter(
|
|
f"Rewrite the following text according to this instruction: {instruction}\n\n{text}",
|
|
SYSTEM_PROMPT, provider, temperature=0.5,
|
|
)
|
|
|
|
|
|
async def ai_convert_to_list(text: str, provider: ProviderName | None = None) -> str:
|
|
"""Convert paragraph text to a markdown list."""
|
|
return await _call_deepseek_openrouter(
|
|
f"Convert this text into a well-organized markdown bullet list. Extract key points.\n\n{text}",
|
|
SYSTEM_PROMPT, provider, temperature=0.2,
|
|
)
|
|
|
|
|
|
async def ai_convert_to_table(text: str, provider: ProviderName | None = None) -> str:
|
|
"""Convert text to a markdown table."""
|
|
return await _call_deepseek_openrouter(
|
|
f"Convert this information into a markdown table. Choose appropriate columns.\n\n{text}",
|
|
SYSTEM_PROMPT, provider, temperature=0.2,
|
|
)
|
|
|
|
|
|
async def ai_generate_frontmatter(text: str, provider: ProviderName | None = None) -> str:
|
|
"""Generate YAML frontmatter for a markdown document."""
|
|
return await _call_deepseek_openrouter(
|
|
f"Generate YAML frontmatter for this markdown document. Include: titre, tags (as list), catégorie, statut, date. Return ONLY the YAML between --- markers.\n\n{text}",
|
|
SYSTEM_PROMPT, provider, temperature=0.3,
|
|
)
|
|
|
|
|
|
async def ai_inline_complete(text: str, provider: ProviderName | None = None) -> str:
|
|
"""Inline completion — suggest continuation."""
|
|
return await _call_deepseek_openrouter(
|
|
f"Complete this text naturally. Return only the completion (just the new text, no repetition):\n\n{text}",
|
|
SYSTEM_PROMPT, provider, temperature=0.3, max_tokens=512,
|
|
)
|
|
|
|
|
|
async def ai_convert_to_canvas(text: str, provider: ProviderName | None = None) -> str:
|
|
"""Convert text to a Mermaid diagram or canvas representation."""
|
|
return await _call_deepseek_openrouter(
|
|
f"Convert this content into a Mermaid.js diagram if applicable, or a structured outline. Choose the best format.\n\n{text}",
|
|
SYSTEM_PROMPT, provider, temperature=0.3, max_tokens=4096,
|
|
)
|