diff --git a/.env.example b/.env.example index bb74538..51ab89c 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,10 @@ OPENROUTER_API_KEY= OPENROUTER_MODEL=nvidia/nemotron-3-super-120b-a12b:free 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) DEEPL_API_KEY= DEEPL_API_URL=https://api-free.deepl.com/v2/translate diff --git a/backend/Dockerfile b/backend/Dockerfile index 4ea40c1..049b00b 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -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 +# ... (باقي الإعدادات) \ No newline at end of file diff --git a/backend/app/services/ai_chat_service.py b/backend/app/services/ai_chat_service.py index 61b1914..a8bb748 100644 --- a/backend/app/services/ai_chat_service.py +++ b/backend/app/services/ai_chat_service.py @@ -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, diff --git a/backend/app/services/google_ai_service.py b/backend/app/services/google_ai_service.py new file mode 100644 index 0000000..2e0e742 --- /dev/null +++ b/backend/app/services/google_ai_service.py @@ -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) diff --git a/backend/app/services/pdf_ai_service.py b/backend/app/services/pdf_ai_service.py index a0891f6..a80d322 100644 --- a/backend/app/services/pdf_ai_service.py +++ b/backend/app/services/pdf_ai_service.py @@ -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} diff --git a/backend/requirements.txt b/backend/requirements.txt index f3af0bc..387f759 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 +