feat: integrate Google Generative AI as a fallback for OpenRouter in translation and chat services
This commit is contained in:
@@ -51,3 +51,8 @@ HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
|
||||
|
||||
# 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"]
|
||||
|
||||
# ... (الإعدادات السابقة)
|
||||
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,
|
||||
get_openrouter_settings,
|
||||
)
|
||||
from app.services import google_ai_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -33,11 +34,35 @@ def chat_about_flowchart(message: str, flow_data: dict | None = None) -> dict:
|
||||
"""
|
||||
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:
|
||||
return {
|
||||
"reply": _fallback_response(message, flow_data),
|
||||
"updated_flow": None,
|
||||
}
|
||||
if g_settings and g_settings.api_key:
|
||||
context = ""
|
||||
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
|
||||
context = ""
|
||||
@@ -97,12 +122,30 @@ def chat_about_flowchart(message: str, flow_data: dict | None = None) -> dict:
|
||||
|
||||
except requests.exceptions.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 {
|
||||
"reply": "The AI service is taking too long. Please try again.",
|
||||
"updated_flow": None,
|
||||
}
|
||||
except Exception as 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 {
|
||||
"reply": _fallback_response(message, flow_data),
|
||||
"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,
|
||||
get_openrouter_settings,
|
||||
)
|
||||
from app.services import google_ai_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -373,6 +374,58 @@ def _split_translation_chunks(
|
||||
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(
|
||||
chunk: str, target_language: str, source_language: str | None = None
|
||||
) -> 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}"
|
||||
reply = _call_openrouter(
|
||||
system_prompt, user_msg, max_tokens=800, tool_name="pdf_chat"
|
||||
)
|
||||
try:
|
||||
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 ")
|
||||
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}"
|
||||
summary = _call_openrouter(
|
||||
system_prompt, user_msg, max_tokens=1000, tool_name="pdf_summarize"
|
||||
)
|
||||
try:
|
||||
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 ")
|
||||
return {"summary": summary, "pages_analyzed": page_count}
|
||||
|
||||
@@ -76,3 +76,7 @@ pytest-mock>=3.11.0
|
||||
requests-mock>=1.11.0
|
||||
fakeredis>=2.18.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