feat: integrate Google Generative AI as a fallback for OpenRouter in translation and chat services
This commit is contained in:
@@ -20,6 +20,10 @@ OPENROUTER_API_KEY=
|
|||||||
OPENROUTER_MODEL=nvidia/nemotron-3-super-120b-a12b:free
|
OPENROUTER_MODEL=nvidia/nemotron-3-super-120b-a12b:free
|
||||||
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1/chat/completions
|
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1/chat/completions
|
||||||
|
|
||||||
|
# Google Generative AI (fallback provider)
|
||||||
|
GOOGLE_API_KEY=AIzaSyAKUp_qGpJPFMaDxIe6x3PjV4ghRTQuZ3Q
|
||||||
|
GOOGLE_MODEL=chat-bison-001
|
||||||
|
|
||||||
# Premium document translation (recommended for Translate PDF)
|
# Premium document translation (recommended for Translate PDF)
|
||||||
DEEPL_API_KEY=
|
DEEPL_API_KEY=
|
||||||
DEEPL_API_URL=https://api-free.deepl.com/v2/translate
|
DEEPL_API_URL=https://api-free.deepl.com/v2/translate
|
||||||
|
|||||||
@@ -51,3 +51,8 @@ HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
|
|||||||
|
|
||||||
# Run with Gunicorn (--preload ensures DB tables are created once before forking workers)
|
# Run with Gunicorn (--preload ensures DB tables are created once before forking workers)
|
||||||
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "4", "--timeout", "120", "--preload", "wsgi:app"]
|
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "4", "--timeout", "120", "--preload", "wsgi:app"]
|
||||||
|
|
||||||
|
# ... (الإعدادات السابقة)
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
# ... (باقي الإعدادات)
|
||||||
@@ -7,6 +7,7 @@ from app.services.openrouter_config_service import (
|
|||||||
extract_openrouter_text,
|
extract_openrouter_text,
|
||||||
get_openrouter_settings,
|
get_openrouter_settings,
|
||||||
)
|
)
|
||||||
|
from app.services import google_ai_service
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -33,11 +34,35 @@ def chat_about_flowchart(message: str, flow_data: dict | None = None) -> dict:
|
|||||||
"""
|
"""
|
||||||
settings = get_openrouter_settings()
|
settings = get_openrouter_settings()
|
||||||
|
|
||||||
|
try:
|
||||||
|
g_settings = google_ai_service.get_google_settings()
|
||||||
|
except Exception:
|
||||||
|
g_settings = None
|
||||||
|
|
||||||
|
# If OpenRouter is not configured, try Google as a fallback.
|
||||||
if not settings.api_key:
|
if not settings.api_key:
|
||||||
return {
|
if g_settings and g_settings.api_key:
|
||||||
"reply": _fallback_response(message, flow_data),
|
context = ""
|
||||||
"updated_flow": None,
|
if flow_data:
|
||||||
}
|
steps_summary = []
|
||||||
|
for s in flow_data.get("steps", []):
|
||||||
|
steps_summary.append(
|
||||||
|
f"- [{s.get('type', 'process')}] {s.get('title', '')}"
|
||||||
|
)
|
||||||
|
context = (
|
||||||
|
f"\nCurrent flowchart: {flow_data.get('title', 'Untitled')}\n"
|
||||||
|
f"Steps:\n" + "\n".join(steps_summary)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
reply = google_ai_service.call_google_text(
|
||||||
|
SYSTEM_PROMPT, f"{message}{context}", max_tokens=500, tool_name="flowchart_chat"
|
||||||
|
)
|
||||||
|
return {"reply": reply, "updated_flow": None}
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Google AI fallback failed: %s", e)
|
||||||
|
return {"reply": _fallback_response(message, flow_data), "updated_flow": None}
|
||||||
|
|
||||||
|
return {"reply": _fallback_response(message, flow_data), "updated_flow": None}
|
||||||
|
|
||||||
# Build context
|
# Build context
|
||||||
context = ""
|
context = ""
|
||||||
@@ -97,12 +122,30 @@ def chat_about_flowchart(message: str, flow_data: dict | None = None) -> dict:
|
|||||||
|
|
||||||
except requests.exceptions.Timeout:
|
except requests.exceptions.Timeout:
|
||||||
logger.warning("OpenRouter API timeout")
|
logger.warning("OpenRouter API timeout")
|
||||||
|
# Try Google fallback on timeout
|
||||||
|
if g_settings and g_settings.api_key:
|
||||||
|
try:
|
||||||
|
reply = google_ai_service.call_google_text(
|
||||||
|
SYSTEM_PROMPT, f"{message}{context}", max_tokens=500, tool_name="flowchart_chat"
|
||||||
|
)
|
||||||
|
return {"reply": reply, "updated_flow": None}
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Google fallback failed after OpenRouter timeout: %s", e)
|
||||||
return {
|
return {
|
||||||
"reply": "The AI service is taking too long. Please try again.",
|
"reply": "The AI service is taking too long. Please try again.",
|
||||||
"updated_flow": None,
|
"updated_flow": None,
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"OpenRouter API error: {e}")
|
logger.error(f"OpenRouter API error: {e}")
|
||||||
|
# Try Google as a fallback on generic errors
|
||||||
|
if g_settings and g_settings.api_key:
|
||||||
|
try:
|
||||||
|
reply = google_ai_service.call_google_text(
|
||||||
|
SYSTEM_PROMPT, f"{message}{context}", max_tokens=500, tool_name="flowchart_chat"
|
||||||
|
)
|
||||||
|
return {"reply": reply, "updated_flow": None}
|
||||||
|
except Exception as ge:
|
||||||
|
logger.exception("Google fallback failed: %s", ge)
|
||||||
return {
|
return {
|
||||||
"reply": _fallback_response(message, flow_data),
|
"reply": _fallback_response(message, flow_data),
|
||||||
"updated_flow": None,
|
"updated_flow": None,
|
||||||
|
|||||||
172
backend/app/services/google_ai_service.py
Normal file
172
backend/app/services/google_ai_service.py
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
"""Google Generative AI integration wrapper.
|
||||||
|
|
||||||
|
This module provides a thin wrapper that attempts to use the installed
|
||||||
|
`google-generativeai` package if available and falls back to a best-effort
|
||||||
|
REST call to the Generative Language endpoints. The wrapper keeps a simple
|
||||||
|
`call_google_text` interface that returns a generated string or raises an
|
||||||
|
exception on failure.
|
||||||
|
"""
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class GoogleSettings:
|
||||||
|
api_key: str
|
||||||
|
model: str
|
||||||
|
|
||||||
|
|
||||||
|
def get_google_settings() -> GoogleSettings:
|
||||||
|
api_key = str(os.getenv("GOOGLE_API_KEY", "")).strip()
|
||||||
|
model = str(os.getenv("GOOGLE_MODEL", "chat-bison-001")).strip() or "chat-bison-001"
|
||||||
|
return GoogleSettings(api_key=api_key, model=model)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_text_from_google_response(resp: Any) -> str:
|
||||||
|
try:
|
||||||
|
if resp is None:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# dict-style responses
|
||||||
|
if isinstance(resp, dict):
|
||||||
|
# common shape: {"candidates": [{"output": "..."}]}
|
||||||
|
candidates = resp.get("candidates") or resp.get("choices")
|
||||||
|
if isinstance(candidates, list) and candidates:
|
||||||
|
first = candidates[0]
|
||||||
|
if isinstance(first, dict):
|
||||||
|
for key in ("output", "text", "message", "content"):
|
||||||
|
val = first.get(key)
|
||||||
|
if isinstance(val, str) and val.strip():
|
||||||
|
return val.strip()
|
||||||
|
# nested content
|
||||||
|
content = first.get("content")
|
||||||
|
if isinstance(content, dict):
|
||||||
|
for k in ("text", "parts", "output"):
|
||||||
|
v = content.get(k)
|
||||||
|
if isinstance(v, str) and v.strip():
|
||||||
|
return v.strip()
|
||||||
|
|
||||||
|
# look for top-level text-like fields
|
||||||
|
for key in ("output", "text", "response", "message"):
|
||||||
|
val = resp.get(key)
|
||||||
|
if isinstance(val, str) and val.strip():
|
||||||
|
return val.strip()
|
||||||
|
|
||||||
|
return str(resp)
|
||||||
|
|
||||||
|
# object-like responses (SDK objects)
|
||||||
|
if hasattr(resp, "candidates"):
|
||||||
|
try:
|
||||||
|
c0 = resp.candidates[0]
|
||||||
|
if hasattr(c0, "output"):
|
||||||
|
return getattr(c0, "output", "") or ""
|
||||||
|
if hasattr(c0, "content"):
|
||||||
|
return getattr(c0, "content", "") or ""
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if hasattr(resp, "output"):
|
||||||
|
return getattr(resp, "output", "") or ""
|
||||||
|
|
||||||
|
return str(resp)
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _call_google_via_rest(settings: GoogleSettings, system_prompt: str, user_message: str, max_tokens: int) -> str:
|
||||||
|
# Try a few likely endpoint patterns for the Google Generative Language API.
|
||||||
|
candidate_urls = [
|
||||||
|
f"https://generativelanguage.googleapis.com/v1/models/{settings.model}:generateText",
|
||||||
|
f"https://generativelanguage.googleapis.com/v1beta2/models/{settings.model}:generateText",
|
||||||
|
f"https://generativelanguage.googleapis.com/v1/models/{settings.model}:generateMessage",
|
||||||
|
f"https://generativelanguage.googleapis.com/v1beta2/models/{settings.model}:generateMessage",
|
||||||
|
]
|
||||||
|
|
||||||
|
payload_variants = [
|
||||||
|
{
|
||||||
|
"prompt": {"messages": [{"author": "system", "content": system_prompt}, {"author": "user", "content": user_message}]},
|
||||||
|
"temperature": 0.5,
|
||||||
|
"maxOutputTokens": max_tokens,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input": f"{system_prompt}\n\n{user_message}",
|
||||||
|
"temperature": 0.5,
|
||||||
|
"maxOutputTokens": max_tokens,
|
||||||
|
},
|
||||||
|
{"prompt": f"{system_prompt}\n\n{user_message}", "temperature": 0.5, "maxOutputTokens": max_tokens},
|
||||||
|
]
|
||||||
|
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
for url in candidate_urls:
|
||||||
|
for payload in payload_variants:
|
||||||
|
try:
|
||||||
|
resp = requests.post(url, params={"key": settings.api_key}, json=payload, headers=headers, timeout=60)
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logger.debug("Google REST call to %s failed: %s", url, e)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if resp.status_code == 200:
|
||||||
|
try:
|
||||||
|
data = resp.json()
|
||||||
|
text = _extract_text_from_google_response(data)
|
||||||
|
if text:
|
||||||
|
return text
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if resp.status_code == 401:
|
||||||
|
raise RuntimeError("GOOGLE_UNAUTHORIZED")
|
||||||
|
|
||||||
|
if resp.status_code in (429, 502, 503, 504):
|
||||||
|
# transient problem
|
||||||
|
raise RuntimeError("GOOGLE_RATE_LIMIT_OR_SERVER_ERROR")
|
||||||
|
|
||||||
|
raise RuntimeError("GOOGLE_REQUEST_FAILED")
|
||||||
|
|
||||||
|
|
||||||
|
def call_google_text(system_prompt: str, user_message: str, max_tokens: int = 1000, tool_name: str = "pdf_ai") -> str:
|
||||||
|
settings = get_google_settings()
|
||||||
|
if not settings.api_key:
|
||||||
|
raise RuntimeError("GOOGLE_MISSING_API_KEY")
|
||||||
|
|
||||||
|
try:
|
||||||
|
import google.generativeai as genai
|
||||||
|
|
||||||
|
# configure SDK if it exposes configure
|
||||||
|
try:
|
||||||
|
if hasattr(genai, "configure"):
|
||||||
|
genai.configure(api_key=settings.api_key)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("google.generativeai.configure failed or not available")
|
||||||
|
|
||||||
|
messages = [{"role": "system", "content": system_prompt}, {"role": "user", "content": user_message}]
|
||||||
|
|
||||||
|
# Try chat-style API if available
|
||||||
|
if hasattr(genai, "chat") and hasattr(genai.chat, "create"):
|
||||||
|
resp = genai.chat.create(model=settings.model, messages=messages, temperature=0.5, max_output_tokens=max_tokens)
|
||||||
|
text = _extract_text_from_google_response(resp)
|
||||||
|
if not text:
|
||||||
|
raise RuntimeError("GOOGLE_EMPTY_RESPONSE")
|
||||||
|
return text
|
||||||
|
|
||||||
|
# Try generate_text if present
|
||||||
|
if hasattr(genai, "generate_text"):
|
||||||
|
prompt = f"{system_prompt}\n\n{user_message}"
|
||||||
|
resp = genai.generate_text(model=settings.model, prompt=prompt, max_output_tokens=max_tokens, temperature=0.5)
|
||||||
|
text = _extract_text_from_google_response(resp)
|
||||||
|
if not text:
|
||||||
|
raise RuntimeError("GOOGLE_EMPTY_RESPONSE")
|
||||||
|
return text
|
||||||
|
|
||||||
|
except Exception as e: # fall back to REST approach if SDK call fails
|
||||||
|
logger.debug("google.generativeai SDK not usable or raised: %s", e)
|
||||||
|
|
||||||
|
# Last-resort: try REST endpoints
|
||||||
|
return _call_google_via_rest(settings, system_prompt, user_message, max_tokens)
|
||||||
@@ -13,6 +13,7 @@ from app.services.openrouter_config_service import (
|
|||||||
extract_openrouter_text,
|
extract_openrouter_text,
|
||||||
get_openrouter_settings,
|
get_openrouter_settings,
|
||||||
)
|
)
|
||||||
|
from app.services import google_ai_service
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -373,6 +374,58 @@ def _split_translation_chunks(
|
|||||||
return chunks or [text]
|
return chunks or [text]
|
||||||
|
|
||||||
|
|
||||||
|
def _call_openrouter_translate(
|
||||||
|
chunk: str, target_language: str, source_language: str | None = None
|
||||||
|
) -> dict:
|
||||||
|
"""Attempt translation via OpenRouter, fall back to Google Generative AI if configured."""
|
||||||
|
source_hint = "auto-detect the source language"
|
||||||
|
if source_language and _normalize_language_code(source_language) != "auto":
|
||||||
|
source_hint = f"treat {_language_label(source_language)} as the source language"
|
||||||
|
|
||||||
|
system_prompt = (
|
||||||
|
"You are a professional document translator. "
|
||||||
|
f"Translate the provided PDF content into {_language_label(target_language)}. "
|
||||||
|
f"Please {source_hint}. Preserve headings, lists, tables, and page markers. "
|
||||||
|
"Return only the translated text."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Try OpenRouter first
|
||||||
|
try:
|
||||||
|
translation = _call_openrouter(
|
||||||
|
system_prompt,
|
||||||
|
chunk,
|
||||||
|
max_tokens=2200,
|
||||||
|
tool_name="pdf_translate_fallback",
|
||||||
|
)
|
||||||
|
provider = "openrouter"
|
||||||
|
except (RetryableTranslationError, PdfAiError) as open_err:
|
||||||
|
# If Google is configured, try as a fallback
|
||||||
|
try:
|
||||||
|
g_settings = google_ai_service.get_google_settings()
|
||||||
|
except Exception:
|
||||||
|
g_settings = None
|
||||||
|
|
||||||
|
if g_settings and g_settings.api_key:
|
||||||
|
try:
|
||||||
|
translation = google_ai_service.call_google_text(
|
||||||
|
system_prompt, chunk, max_tokens=2200, tool_name="pdf_translate_fallback"
|
||||||
|
)
|
||||||
|
provider = "google"
|
||||||
|
except Exception as google_err:
|
||||||
|
logger.exception("Google fallback for translation failed: %s", google_err)
|
||||||
|
raise open_err
|
||||||
|
else:
|
||||||
|
raise open_err
|
||||||
|
|
||||||
|
return {
|
||||||
|
"translation": translation,
|
||||||
|
"provider": provider,
|
||||||
|
"detected_source_language": _normalize_language_code(
|
||||||
|
source_language, default=""
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _call_deepl_translate(
|
def _call_deepl_translate(
|
||||||
chunk: str, target_language: str, source_language: str | None = None
|
chunk: str, target_language: str, source_language: str | None = None
|
||||||
) -> dict:
|
) -> dict:
|
||||||
@@ -593,9 +646,26 @@ def chat_with_pdf(input_path: str, question: str) -> dict:
|
|||||||
)
|
)
|
||||||
|
|
||||||
user_msg = f"Document content:\n{truncated}\n\nQuestion: {question}"
|
user_msg = f"Document content:\n{truncated}\n\nQuestion: {question}"
|
||||||
reply = _call_openrouter(
|
try:
|
||||||
system_prompt, user_msg, max_tokens=800, tool_name="pdf_chat"
|
reply = _call_openrouter(
|
||||||
)
|
system_prompt, user_msg, max_tokens=800, tool_name="pdf_chat"
|
||||||
|
)
|
||||||
|
except (RetryableTranslationError, PdfAiError) as open_err:
|
||||||
|
try:
|
||||||
|
g_settings = google_ai_service.get_google_settings()
|
||||||
|
except Exception:
|
||||||
|
g_settings = None
|
||||||
|
|
||||||
|
if g_settings and g_settings.api_key:
|
||||||
|
try:
|
||||||
|
reply = google_ai_service.call_google_text(
|
||||||
|
system_prompt, user_msg, max_tokens=800, tool_name="pdf_chat"
|
||||||
|
)
|
||||||
|
except Exception as google_err:
|
||||||
|
logger.exception("Google fallback for pdf chat failed: %s", google_err)
|
||||||
|
raise open_err
|
||||||
|
else:
|
||||||
|
raise open_err
|
||||||
|
|
||||||
page_count = text.count("[Page ")
|
page_count = text.count("[Page ")
|
||||||
return {"reply": reply, "pages_analyzed": page_count}
|
return {"reply": reply, "pages_analyzed": page_count}
|
||||||
@@ -637,9 +707,26 @@ def summarize_pdf(input_path: str, length: str = "medium") -> dict:
|
|||||||
)
|
)
|
||||||
|
|
||||||
user_msg = f"{length_instruction}\n\nDocument content:\n{truncated}"
|
user_msg = f"{length_instruction}\n\nDocument content:\n{truncated}"
|
||||||
summary = _call_openrouter(
|
try:
|
||||||
system_prompt, user_msg, max_tokens=1000, tool_name="pdf_summarize"
|
summary = _call_openrouter(
|
||||||
)
|
system_prompt, user_msg, max_tokens=1000, tool_name="pdf_summarize"
|
||||||
|
)
|
||||||
|
except (RetryableTranslationError, PdfAiError) as open_err:
|
||||||
|
try:
|
||||||
|
g_settings = google_ai_service.get_google_settings()
|
||||||
|
except Exception:
|
||||||
|
g_settings = None
|
||||||
|
|
||||||
|
if g_settings and g_settings.api_key:
|
||||||
|
try:
|
||||||
|
summary = google_ai_service.call_google_text(
|
||||||
|
system_prompt, user_msg, max_tokens=1000, tool_name="pdf_summarize"
|
||||||
|
)
|
||||||
|
except Exception as google_err:
|
||||||
|
logger.exception("Google fallback for pdf summarize failed: %s", google_err)
|
||||||
|
raise open_err
|
||||||
|
else:
|
||||||
|
raise open_err
|
||||||
|
|
||||||
page_count = text.count("[Page ")
|
page_count = text.count("[Page ")
|
||||||
return {"summary": summary, "pages_analyzed": page_count}
|
return {"summary": summary, "pages_analyzed": page_count}
|
||||||
|
|||||||
@@ -76,3 +76,7 @@ pytest-mock>=3.11.0
|
|||||||
requests-mock>=1.11.0
|
requests-mock>=1.11.0
|
||||||
fakeredis>=2.18.0
|
fakeredis>=2.18.0
|
||||||
httpx>=0.24.0
|
httpx>=0.24.0
|
||||||
|
|
||||||
|
# AI Integration google-generativeai>=0.1,<1.0
|
||||||
|
google-generativeai>=0.1,<1.0
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user