feat: integrate Google Generative AI as a fallback for OpenRouter in translation and chat services

This commit is contained in:
Your Name
2026-03-31 19:42:08 +02:00
parent e7fa0730c6
commit 42b1ad1250
6 changed files with 325 additions and 10 deletions

View File

@@ -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,

View 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)

View File

@@ -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}