feat: AI Editor — toolbar avec menus dropdown, multi-provider (DeepSeek/OpenRouter/Gemini)
This commit is contained in:
+253
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
|
||||
@@ -11,3 +11,4 @@ python-jose>=3.3.0
|
||||
sortedcontainers>=2.4.0
|
||||
snowballstemmer>=2.2.0
|
||||
weasyprint>=60.0
|
||||
httpx>=0.27.0
|
||||
|
||||
Reference in New Issue
Block a user