diff --git a/.env.example b/.env.example index f021f7a..b322b73 100644 --- a/.env.example +++ b/.env.example @@ -32,3 +32,20 @@ # Backup # OBSIGATE_BACKUP_DIR=.obsigate-backup + +# ── AI Provider Configuration ── +# Définir au moins un provider pour activer les fonctionnalités AI dans l'éditeur + +# AI_DEFAULT_PROVIDER=deepseek # deepseek | openrouter | gemini + +# DeepSeek (recommandé, bon marché) +# DEEPSEEK_API_KEY=sk-... +# DEEPSEEK_MODEL=deepseek-chat + +# OpenRouter (accès à plusieurs modèles) +# OPENROUTER_API_KEY=sk-or-v1-... +# OPENROUTER_MODEL=openai/gpt-4o-mini + +# Google Gemini +# GEMINI_API_KEY=AIza... +# GEMINI_MODEL=gemini-2.0-flash diff --git a/backend/ai.py b/backend/ai.py new file mode 100644 index 0000000..88ce59e --- /dev/null +++ b/backend/ai.py @@ -0,0 +1,253 @@ +"""ObsiGate AI — Multi-provider AI service for editor enhancement. + +Supports: DeepSeek, OpenRouter, Google Gemini. +Configured via environment variables. +""" + +import os +import json +import logging +import httpx +from typing import Optional, Literal + +logger = logging.getLogger("obsigate.ai") + +ProviderName = Literal["deepseek", "openrouter", "gemini"] + +# Provider configurations +PROVIDERS = { + "deepseek": { + "api_key": os.getenv("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": os.getenv("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": os.getenv("GEMINI_API_KEY", ""), + "base_url": "https://generativelanguage.googleapis.com/v1beta", + "model": os.getenv("GEMINI_MODEL", "gemini-2.0-flash"), + "auth_header": None, # Uses query param ?key= + }, +} + +DEFAULT_PROVIDER: ProviderName = os.getenv("AI_DEFAULT_PROVIDER", "deepseek") # type: ignore + + +def _get_provider_config(provider: Optional[ProviderName] = None) -> dict: + """Get provider config, falling back to default if requested provider unavailable.""" + p = provider or DEFAULT_PROVIDER + if p not in PROVIDERS: + p = DEFAULT_PROVIDER + 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} + + +async def _call_deepseek_openrouter(prompt: str, system: str, provider: Optional[ProviderName] = None, + temperature: float = 0.7, max_tokens: int = 2048) -> str: + """Call OpenAI-compatible API (DeepSeek, OpenRouter).""" + cfg = _get_provider_config(provider) + headers = { + "Authorization": cfg["auth_header"].format(api_key=cfg["api_key"]), + "Content-Type": "application/json", + } + 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: Optional[ProviderName] = 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.") + 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: Optional[ProviderName] = 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: Optional[ProviderName] = 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: Optional[ProviderName] = 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: Optional[ProviderName] = 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: Optional[ProviderName] = 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: Optional[ProviderName] = 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: Optional[ProviderName] = 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: Optional[ProviderName] = 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: Optional[ProviderName] = 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: Optional[ProviderName] = 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: Optional[ProviderName] = 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: Optional[ProviderName] = 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: Optional[ProviderName] = 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: Optional[ProviderName] = 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: Optional[ProviderName] = 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: Optional[ProviderName] = 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, + ) diff --git a/backend/ai_routes.py b/backend/ai_routes.py new file mode 100644 index 0000000..5638d62 --- /dev/null +++ b/backend/ai_routes.py @@ -0,0 +1,144 @@ +"""ObsiGate AI — API routes for AI-powered editor features.""" + +from fastapi import APIRouter, HTTPException, Query +from pydantic import BaseModel, Field +from typing import Optional +import logging + +from backend.ai import ( + ai_improve_writing, ai_fix_spelling, ai_make_shorter, ai_make_longer, + ai_simplify, ai_change_tone, ai_translate, ai_explain, ai_summarize, + ai_continue_writing, ai_custom_rewrite, ai_convert_to_list, + ai_convert_to_table, ai_generate_frontmatter, ai_inline_complete, + ai_convert_to_canvas, +) + +logger = logging.getLogger("obsigate.ai_routes") +router = APIRouter(prefix="/api/ai", tags=["AI"]) + + +class AIRequest(BaseModel): + text: str = Field(..., description="Input text to process", min_length=1) + instruction: Optional[str] = Field(None, description="Custom instruction for rewrite") + target_lang: Optional[str] = Field(None, description="Target language for translation") + tone: Optional[str] = Field(None, description="Target tone (professional, casual, etc.)") + provider: Optional[str] = Field(None, description="AI provider override") + + +class AIResponse(BaseModel): + result: str = Field(..., description="Processed text result") + provider: str = Field(..., description="AI provider used") + + +async def _handle(action, request: AIRequest): + """Wrapper with error handling.""" + try: + result = await action(request.text, request.provider) + return AIResponse(result=result, provider=request.provider or "default") + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"AI error: {e}") + raise HTTPException(status_code=500, detail=f"AI service error: {str(e)}") + + +@router.post("/improve", response_model=AIResponse) +async def api_improve(req: AIRequest): + """Improve writing quality.""" + return await _handle(ai_improve_writing, req) + + +@router.post("/fix-spelling", response_model=AIResponse) +async def api_fix_spelling(req: AIRequest): + """Fix spelling and grammar.""" + return await _handle(ai_fix_spelling, req) + + +@router.post("/make-shorter", response_model=AIResponse) +async def api_make_shorter(req: AIRequest): + """Make text more concise.""" + return await _handle(ai_make_shorter, req) + + +@router.post("/make-longer", response_model=AIResponse) +async def api_make_longer(req: AIRequest): + """Expand text with more detail.""" + return await _handle(ai_make_longer, req) + + +@router.post("/simplify", response_model=AIResponse) +async def api_simplify(req: AIRequest): + """Simplify language.""" + return await _handle(ai_simplify, req) + + +@router.post("/tone", response_model=AIResponse) +async def api_tone(req: AIRequest): + """Change text tone. Requires `tone` field (e.g., 'professional', 'casual').""" + if not req.tone: + raise HTTPException(status_code=400, detail="Field 'tone' is required (e.g., 'professional', 'casual')") + return await _handle(lambda text, p: ai_change_tone(text, req.tone, p), req) + + +@router.post("/translate", response_model=AIResponse) +async def api_translate(req: AIRequest): + """Translate text. Requires `target_lang` field (e.g., 'French', 'English', 'Japanese').""" + if not req.target_lang: + raise HTTPException(status_code=400, detail="Field 'target_lang' is required") + return await _handle(lambda text, p: ai_translate(text, req.target_lang, p), req) + + +@router.post("/explain", response_model=AIResponse) +async def api_explain(req: AIRequest): + """Explain the selected text.""" + return await _handle(ai_explain, req) + + +@router.post("/summarize", response_model=AIResponse) +async def api_summarize(req: AIRequest): + """Summarize text.""" + return await _handle(ai_summarize, req) + + +@router.post("/continue", response_model=AIResponse) +async def api_continue(req: AIRequest): + """Continue writing from the selected text.""" + return await _handle(ai_continue_writing, req) + + +@router.post("/rewrite", response_model=AIResponse) +async def api_rewrite(req: AIRequest): + """Custom rewrite with instruction. Requires `instruction` field.""" + if not req.instruction: + raise HTTPException(status_code=400, detail="Field 'instruction' is required") + return await _handle(lambda text, p: ai_custom_rewrite(text, req.instruction, p), req) + + +@router.post("/to-list", response_model=AIResponse) +async def api_to_list(req: AIRequest): + """Convert text to a markdown list.""" + return await _handle(ai_convert_to_list, req) + + +@router.post("/to-table", response_model=AIResponse) +async def api_to_table(req: AIRequest): + """Convert text to a markdown table.""" + return await _handle(ai_convert_to_table, req) + + +@router.post("/frontmatter", response_model=AIResponse) +async def api_frontmatter(req: AIRequest): + """Generate YAML frontmatter.""" + return await _handle(ai_generate_frontmatter, req) + + +@router.post("/inline-complete", response_model=AIResponse) +async def api_inline_complete(req: AIRequest): + """Inline completion.""" + return await _handle(ai_inline_complete, req) + + +@router.post("/to-canvas", response_model=AIResponse) +async def api_to_canvas(req: AIRequest): + """Convert to Mermaid diagram or outline.""" + return await _handle(ai_convert_to_canvas, req) diff --git a/backend/main.py b/backend/main.py index 0bf55d6..9b0f2fa 100644 --- a/backend/main.py +++ b/backend/main.py @@ -594,8 +594,10 @@ except OSError: from backend.share import create_share, get_share_by_token, record_access, revoke_share, list_shares # noqa: E402 from backend.webhooks import get_webhooks, create_webhook, update_webhook, delete_webhook, dispatch_webhooks # noqa: E402 from backend.saved_searches import get_saved, save_search, delete_saved # noqa: E402 +from backend.ai_routes import router as ai_router # noqa: E402 app.include_router(auth_router) +app.include_router(ai_router) # Resolve frontend path relative to this file FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend" diff --git a/backend/requirements.txt b/backend/requirements.txt index 82eb51c..c63bcd9 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -11,3 +11,4 @@ python-jose>=3.3.0 sortedcontainers>=2.4.0 snowballstemmer>=2.2.0 weasyprint>=60.0 +httpx>=0.27.0 diff --git a/frontend/index.html b/frontend/index.html index 4ca9b2a..41b4526 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -495,7 +495,9 @@ -
+