From d557bd4918f48fd1877b01fb04f013c407239f5b Mon Sep 17 00:00:00 2001 From: ajaysi Date: Sun, 22 Mar 2026 10:45:05 +0530 Subject: [PATCH] Fix merge conflicts and resolve circular import issues - Resolve conflict markers in logging_config.py, main.py, app.py - Fix circular imports in story_writer services (image/audio/video generation) by using lazy imports for get_story_media_write_dir - Restore clean versions of: - sif_agents.py - tenant_provider_config.py - personalization_service.py - huggingface_provider.py - main_text_generation.py - logger_utils.py - Use setup_clean_logging() consistently across app.py and main.py - Restore verbose_mode handling in start_alwrity_backend.py --- backend/app.py | 8 +- backend/logging_config.py | 367 +--------------- backend/main.py | 8 +- backend/services/intelligence/sif_agents.py | 146 +----- .../llm_providers/huggingface_provider.py | 414 ++++++------------ .../llm_providers/main_text_generation.py | 158 ++----- .../llm_providers/tenant_provider_config.py | 86 ---- .../personalization_service.py | 77 +--- .../story_writer/audio_generation_service.py | 11 +- .../story_writer/image_generation_service.py | 11 +- .../story_writer/video_generation_service.py | 11 +- backend/start_alwrity_backend.py | 8 +- backend/utils/logger_utils.py | 106 ----- 13 files changed, 232 insertions(+), 1179 deletions(-) diff --git a/backend/app.py b/backend/app.py index 73312996..0b190fb9 100644 --- a/backend/app.py +++ b/backend/app.py @@ -49,12 +49,8 @@ load_dotenv(project_root / '.env') # root .env (fallback) load_dotenv() # CWD .env (fallback) # Set up clean logging for end users -from logging_config import configure_logging -<<<<<<< HEAD -configure_logging(bootstrap_source="asgi-import") -======= -configure_logging(mode="default", app_name="ALwrity") ->>>>>>> pr-422 +from logging_config import setup_clean_logging +setup_clean_logging() # Import middleware from middleware.auth_middleware import get_current_user diff --git a/backend/logging_config.py b/backend/logging_config.py index 193bf542..539db62e 100644 --- a/backend/logging_config.py +++ b/backend/logging_config.py @@ -1,146 +1,21 @@ -"""Centralized, production-ready logging configuration for the ALwrity backend.""" +""" +Logging configuration for ALwrity backend. +Provides clean logging setup for end users vs developers. +""" -from __future__ import annotations - -import asyncio -import json import logging import os import sys -from typing import Dict, Optional, Tuple - from loguru import logger -_LOGGING_CONFIGURED = False - -<<<<<<< HEAD -DEFAULT_LOG_OVERRIDES: Dict[str, str] = { - "sqlalchemy": "ERROR", - "sqlalchemy.engine": "ERROR", - "sqlalchemy.pool": "ERROR", - "uvicorn.access": "WARNING", - "watchfiles": "WARNING", - "httpx": "WARNING", - "urllib3": "WARNING", - "apscheduler": "INFO", -} - -VIDEO_SERVICE_NAMES = { - "video_generation_service", - "services.story_writer.video_generation_service", - "services.llm_providers.main_video_generation", -} - - -class InterceptHandler(logging.Handler): - """Forward standard-library logging records into Loguru sinks.""" - - def emit(self, record: logging.LogRecord) -> None: - try: - level = logger.level(record.levelname).name - except ValueError: - level = record.levelno - - frame, depth = logging.currentframe(), 2 - while frame and frame.f_code.co_filename == logging.__file__: - frame = frame.f_back - depth += 1 - - stdlib_extra = { - key: value - for key, value in record.__dict__.items() - if key - not in { - "name", "msg", "args", "levelname", "levelno", "pathname", "filename", - "module", "exc_info", "exc_text", "stack_info", "lineno", "funcName", - "created", "msecs", "relativeCreated", "thread", "threadName", "processName", - "process", "message", "asctime" - } - } - - log = logger.bind(stdlib_logger=record.name, **stdlib_extra) - log.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage()) - - -def _env_bool(name: str, default: bool = False) -> bool: - value = os.getenv(name) - if value is None: - return default - return value.strip().lower() in {"1", "true", "yes", "on"} - - -def _parse_level_overrides() -> Dict[str, str]: - overrides = dict(DEFAULT_LOG_OVERRIDES) - raw_overrides = os.getenv("ALWRITY_LOG_LEVEL_OVERRIDES", "").strip() - if not raw_overrides: - return overrides - - for pair in raw_overrides.split(","): - pair = pair.strip() - if not pair or "=" not in pair: - continue - logger_name, level = pair.split("=", 1) - logger_name = logger_name.strip() - level = level.strip().upper() - if logger_name and level: - overrides[logger_name] = level - - return overrides - - -def _resolve_log_level(level_name: str, default: int = logging.INFO) -> Tuple[int, bool]: - try: - return logging._checkLevel(level_name), True - except (TypeError, ValueError): - return default, False - - -def _apply_logger_overrides(verbose_mode: bool) -> None: - root_level = logging.DEBUG if verbose_mode else logging.INFO - logging.getLogger().setLevel(root_level) - - for logger_name, level_name in _parse_level_overrides().items(): - level_no, valid = _resolve_log_level(level_name) - if not valid: - logger.warning( - "Invalid log level override '{}' for logger '{}'; defaulting to INFO", - level_name, - logger_name, -======= -_LOGGING_CONFIGURED = False - - -class LoguruInterceptHandler(logging.Handler): - """Forward stdlib logging records to Loguru.""" - - def emit(self, record: logging.LogRecord) -> None: - try: - level = logger.level(record.levelname).name - except ValueError: - level = record.levelno - - frame, depth = logging.currentframe(), 2 - while frame and frame.f_code.co_filename == logging.__file__: - frame = frame.f_back - depth += 1 - - logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage()) - - -def configure_logging(mode: str = "default", verbose: bool | None = None, app_name: str = "alwrity") -> bool: - """Configure Loguru and stdlib logging into one shared pipeline.""" - global _LOGGING_CONFIGURED - - if verbose is None: - verbose_mode = mode == "verbose" or os.getenv("ALWRITY_VERBOSE", "false").lower() == "true" - else: - verbose_mode = verbose - - if _LOGGING_CONFIGURED: - return verbose_mode +def setup_clean_logging(): + """Set up clean logging for end users.""" + verbose_mode = os.getenv("ALWRITY_VERBOSE", "false").lower() == "true" + + # Always remove all existing handlers first to prevent conflicts logger.remove() - + if not verbose_mode: # Suppress verbose logging for end users - be more aggressive logging.getLogger('sqlalchemy.engine').setLevel(logging.CRITICAL) @@ -215,7 +90,7 @@ def configure_logging(mode: str = "default", verbose: bool | None = None, app_na logger.add( sys.stdout.write, level="WARNING", - format=f"{app_name} | {{time:HH:mm:ss}} | {{level: <8}} | {{name}}:{{function}}:{{line}} - {{message}}\n", + format="{time:HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}\n", filter=warning_only_filter ) # Add a focused sink to surface Story Video Generation INFO logs in console @@ -229,233 +104,25 @@ def configure_logging(mode: str = "default", verbose: bool | None = None, app_na or "[video_gen]" in msg or service == "video_generation_service" or "services.llm_providers.main_video_generation" in name ->>>>>>> pr-422 ) - logging.getLogger(logger_name).setLevel(level_no) - - -def _serialize_record(record: Dict) -> str: - payload = { - "time": record["time"].isoformat(), - "level": record["level"].name, - "name": record["name"], - "function": record["function"], - "line": record["line"], - "message": record["message"], - "extra": record.get("extra", {}), - } - if record.get("exception"): - payload["exception"] = str(record["exception"]) - return json.dumps(payload, default=str) - - -def _base_log_format(verbose_mode: bool) -> str: - if verbose_mode: - return ( - "{time:YYYY-MM-DD HH:mm:ss.SSS} | " - "{level: <8} | " - "{name}:{function}:{line} | " - "rid={extra[request_id]} jid={extra[job_id]} uid={extra[user_id]} | " - "{message}" - ) - - return ( - "{time:HH:mm:ss} | " - "{level: <8} | " - "{name}:{line} | " - "{message}" - ) - - -def _patch_record(record: Dict) -> Dict: - extra = record.setdefault("extra", {}) - extra.setdefault("request_id", "-") - extra.setdefault("job_id", "-") - extra.setdefault("user_id", "-") - return record - - -def _video_generation_filter(record: Dict) -> bool: - message = record.get("message", "") - name = record.get("name", "") - service_name = record.get("extra", {}).get("service") - return ( - "[StoryVideoGeneration]" in message - or "[video_gen]" in message - or service_name in VIDEO_SERVICE_NAMES - or any(service in name for service in VIDEO_SERVICE_NAMES) - ) - - -def _configure_loguru_sinks(verbose_mode: bool) -> None: - logger.remove() - - logger.configure(patcher=_patch_record) - - log_json = _env_bool("ALWRITY_LOG_JSON", default=False) - console_format = _serialize_record if log_json else _base_log_format(verbose_mode) - - logger.add( - sys.stdout, - level="DEBUG" if verbose_mode else "WARNING", - format=console_format, - backtrace=True, - diagnose=verbose_mode, - enqueue=True, - ) - - enable_video_focus = _env_bool("ALWRITY_ENABLE_VIDEO_LOG_FOCUS", default=not verbose_mode) - if enable_video_focus and not verbose_mode: logger.add( - sys.stdout, + sys.stdout.write, level="INFO", -<<<<<<< HEAD - format=console_format, - filter=_video_generation_filter, - enqueue=True, -======= - format=f"{app_name} | {{time:HH:mm:ss}} | {{level: <8}} | {{name}}:{{function}}:{{line}} - {{message}}\n", + format="{time:HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}\n", filter=video_generation_filter ->>>>>>> pr-422 ) - - log_file = os.getenv("ALWRITY_LOG_FILE", "").strip() - if log_file: + else: + # In verbose mode, show all log levels with detailed formatting logger.add( -<<<<<<< HEAD - log_file, - level="DEBUG" if verbose_mode else "INFO", - format=console_format, - rotation=os.getenv("ALWRITY_LOG_ROTATION", "50 MB"), - retention=os.getenv("ALWRITY_LOG_RETENTION", "14 days"), - enqueue=True, - backtrace=True, - diagnose=verbose_mode, - ) - - -def _configure_stdlib_intercept(verbose_mode: bool) -> None: - intercept_handler = InterceptHandler() - root_logger = logging.getLogger() - root_logger.handlers = [intercept_handler] - root_logger.setLevel(logging.DEBUG if verbose_mode else logging.INFO) - - for name in ("uvicorn", "uvicorn.error", "uvicorn.access", "gunicorn", "gunicorn.error"): - target_logger = logging.getLogger(name) - target_logger.handlers = [intercept_handler] - target_logger.propagate = False - - logging.captureWarnings(True) - - -def _register_exception_hooks() -> None: - def _excepthook(exc_type, exc_value, exc_traceback): - if issubclass(exc_type, KeyboardInterrupt): - sys.__excepthook__(exc_type, exc_value, exc_traceback) - return - logger.opt(exception=(exc_type, exc_value, exc_traceback)).critical("Uncaught exception") - - def _async_exception_handler(loop, context): - exc = context.get("exception") - if exc: - logger.opt(exception=exc).error("Unhandled asyncio exception") - else: - logger.error("Unhandled asyncio exception: {}", context.get("message", context)) - - sys.excepthook = _excepthook - - try: - loop = asyncio.get_running_loop() - loop.set_exception_handler(_async_exception_handler) - except RuntimeError: - pass - - -def configure_logging(*, verbose_mode: Optional[bool] = None, force: bool = False, bootstrap_source: str = "unknown") -> bool: - """Configure Loguru + stdlib logging in one place. - - Environment variables: - - ALWRITY_VERBOSE=true|false - - ALWRITY_LOG_LEVEL_OVERRIDES="sqlalchemy=ERROR,uvicorn.access=WARNING" - - ALWRITY_ENABLE_VIDEO_LOG_FOCUS=true|false - - ALWRITY_LOG_JSON=true|false - - ALWRITY_LOG_FILE=/path/to/backend.log - - ALWRITY_LOG_ROTATION=50 MB - - ALWRITY_LOG_RETENTION=14 days - """ - global _LOGGING_CONFIGURED - - if _LOGGING_CONFIGURED and not force: - return os.getenv("ALWRITY_VERBOSE", "false").lower() == "true" - - if verbose_mode is None: - verbose_mode = _env_bool("ALWRITY_VERBOSE", default=False) - - os.environ["ALWRITY_VERBOSE"] = "true" if verbose_mode else "false" - - _configure_loguru_sinks(verbose_mode) - _configure_stdlib_intercept(verbose_mode) - _apply_logger_overrides(verbose_mode) - _register_exception_hooks() - - logger.bind(source=bootstrap_source).info( - "Logging configured (verbose={}, source={})", - verbose_mode, - bootstrap_source, - ) -======= sys.stdout.write, level="DEBUG", - format=f"{app_name} | {{time:HH:mm:ss}} | {{level: <8}} | {{name}}:{{function}}:{{line}} - {{message}}\n" + format="{time:HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}\n" ) - - intercept_handler = LoguruInterceptHandler() - root_logger = logging.getLogger() - root_logger.handlers = [intercept_handler] - root_logger.setLevel(logging.DEBUG if verbose_mode else logging.WARNING) - - logging.captureWarnings(True) - warnings_logger = logging.getLogger("py.warnings") - warnings_logger.handlers = [intercept_handler] - warnings_logger.propagate = True - - for existing_logger in logging.root.manager.loggerDict.values(): - if isinstance(existing_logger, logging.Logger): - existing_logger.handlers = [] - existing_logger.propagate = True ->>>>>>> pr-422 - - _LOGGING_CONFIGURED = True + return verbose_mode -<<<<<<< HEAD - -def bind_logger_context(*, request_id: Optional[str] = None, job_id: Optional[str] = None, user_id: Optional[str] = None): - """Return a context-bound logger for request/job/user correlation.""" - return logger.bind( - request_id=request_id or "-", - job_id=job_id or "-", - user_id=user_id or "-", - ) - - -def setup_clean_logging() -> bool: - """Backward-compatible wrapper for existing imports.""" - return configure_logging(bootstrap_source="setup_clean_logging") - - -def get_uvicorn_log_level() -> str: - """Get uvicorn log level based on verbose mode.""" - verbose_mode = _env_bool("ALWRITY_VERBOSE", default=False) -======= -def setup_clean_logging(): - """Backward-compatible wrapper for existing startup files.""" - return configure_logging(mode="default") - - def get_uvicorn_log_level(): """Get appropriate uvicorn log level based on verbose mode.""" verbose_mode = os.getenv("ALWRITY_VERBOSE", "false").lower() == "true" ->>>>>>> pr-422 return "debug" if verbose_mode else "warning" diff --git a/backend/main.py b/backend/main.py index 7ba10ae3..7f5ee43f 100644 --- a/backend/main.py +++ b/backend/main.py @@ -49,12 +49,8 @@ load_dotenv(project_root / '.env') # root .env (fallback) load_dotenv() # CWD .env (fallback) # Set up clean logging for end users -from logging_config import configure_logging -<<<<<<< HEAD -configure_logging(bootstrap_source="asgi-import") -======= -configure_logging(mode="default", app_name="ALwrity") ->>>>>>> pr-422 +from logging_config import setup_clean_logging +setup_clean_logging() # Import middleware from middleware.auth_middleware import get_current_user diff --git a/backend/services/intelligence/sif_agents.py b/backend/services/intelligence/sif_agents.py index 5fd2f22e..a81bd0cf 100644 --- a/backend/services/intelligence/sif_agents.py +++ b/backend/services/intelligence/sif_agents.py @@ -15,7 +15,6 @@ from loguru import logger from .txtai_service import TxtaiIntelligenceService, TXTAI_AVAILABLE from services.intelligence.agents.core_agent_framework import BaseALwrityAgent from services.llm_providers.main_text_generation import llm_text_gen -from services.intelligence.agent_flat_context import AgentFlatContextStore # Optional txtai imports (align with core agent framework) try: @@ -35,16 +34,7 @@ class SharedLLMWrapper: try: # We ignore kwargs like 'max_tokens' as llm_text_gen handles defaults, # but we could map them if needed. - return llm_text_gen( - prompt, - user_id=self.user_id, -<<<<<<< HEAD - preferred_hf_models=LOW_COST_SHARED_REMOTE_MODELS, - flow_type="sif_agent", -======= - preferred_hf_models=REMOTE_LOW_COST_HF_MODELS, ->>>>>>> pr-418 - ) + return llm_text_gen(prompt, user_id=self.user_id) except Exception as e: logger.error(f"SharedLLMWrapper failed to generate text: {e}") return f"[ERROR: Shared LLM generation failed for user {self.user_id}]" @@ -54,17 +44,6 @@ class SharedLLMWrapper: _local_llm_cache = {} -<<<<<<< HEAD -LOW_COST_SHARED_REMOTE_MODELS = [ -======= - -REMOTE_LOW_COST_HF_MODELS = [ ->>>>>>> pr-418 - "Qwen/Qwen2.5-1.5B-Instruct", - "Qwen/Qwen2.5-0.5B-Instruct", - "TinyLlama/TinyLlama-1.1B-Chat-v1.0", -] - LOCAL_LLM_FALLBACKS = [ "Qwen/Qwen2.5-1.5B-Instruct", "Qwen/Qwen2.5-0.5B-Instruct", @@ -191,8 +170,8 @@ class SIFBaseAgent(BaseALwrityAgent): def _create_txtai_agent(self): """ - Expose a txtai Agent interface with flat-file context tools. - Tools are scoped to the current user workspace via AgentFlatContextStore. + SIF agents primarily use the intelligence service directly, but we can expose + capabilities via a standard agent interface if available. """ if not TXTAI_AVAILABLE or Agent is None: raise RuntimeError(f"[{self.__class__.__name__}] txtai Agent not available") @@ -201,103 +180,11 @@ class SIFBaseAgent(BaseALwrityAgent): _llm_for_agent = self.llm for _ in range(3): _llm_for_agent = getattr(_llm_for_agent, "llm", _llm_for_agent) - - return Agent( - llm=_llm_for_agent, - tools=[ - { - "name": "flat_context_manifest", - "description": "Returns manifest of available onboarding flat-context documents for this user", - "target": self._tool_flat_context_manifest, - }, - { - "name": "flat_context_read", - "description": "Read a flat-context document by logical name: step2|step3|step4|step5|manifest", - "target": self._tool_flat_context_read, - }, - { - "name": "flat_context_write_note", - "description": "Write lightweight agent notes/updates to a specific flat-context document", - "target": self._tool_flat_context_write_note, - }, - ], - ) + return Agent(llm=_llm_for_agent, tools=[]) except Exception as e: logger.error(f"[{self.__class__.__name__}] Failed to create txtai Agent: {e}") raise - def _tool_flat_context_manifest(self, context: Dict[str, Any]) -> Dict[str, Any]: - """Tool: list available flat-context docs and links.""" - try: - store = AgentFlatContextStore(self.user_id) - manifest = store.load_context_manifest() or {"documents": []} - return {"ok": True, "manifest": manifest} - except Exception as e: - return {"ok": False, "error": str(e)} - - def _tool_flat_context_read(self, context: Dict[str, Any]) -> Dict[str, Any]: - """Tool: read one user-scoped context doc.""" - try: - key = str((context or {}).get("document") or "").strip().lower() - store = AgentFlatContextStore(self.user_id) - mapping = { - "step2": store.load_step2_context_document, - "step3": store.load_step3_context_document, - "step4": store.load_step4_context_document, - "step5": store.load_step5_context_document, - "manifest": store.load_context_manifest, - } - if key not in mapping: - return {"ok": False, "error": "Invalid document. Use step2|step3|step4|step5|manifest"} - data = mapping[key]() - return {"ok": True, "document": key, "data": data or {}} - except Exception as e: - return {"ok": False, "error": str(e)} - - def _tool_flat_context_write_note(self, context: Dict[str, Any]) -> Dict[str, Any]: - """Tool: append agent note/update to step context by re-saving payload.""" - try: - key = str((context or {}).get("document") or "").strip().lower() - note = str((context or {}).get("note") or "").strip() - if not note: - return {"ok": False, "error": "note is required"} - - store = AgentFlatContextStore(self.user_id) - if key == "step2": - doc = store.load_step2_context_document() or {} - payload = doc.get("data") if isinstance(doc.get("data"), dict) else {} - notes = payload.get("agent_notes") if isinstance(payload.get("agent_notes"), list) else [] - notes.append({"note": note, "agent": self.agent_type, "ts": datetime.utcnow().isoformat()}) - payload["agent_notes"] = notes[-50:] - ok = store.save_step2_website_analysis(payload, source="agent_note") - elif key == "step3": - doc = store.load_step3_context_document() or {} - payload = doc.get("data") if isinstance(doc.get("data"), dict) else {} - notes = payload.get("agent_notes") if isinstance(payload.get("agent_notes"), list) else [] - notes.append({"note": note, "agent": self.agent_type, "ts": datetime.utcnow().isoformat()}) - payload["agent_notes"] = notes[-50:] - ok = store.save_step3_research_preferences(payload, source="agent_note") - elif key == "step4": - doc = store.load_step4_context_document() or {} - payload = doc.get("data") if isinstance(doc.get("data"), dict) else {} - notes = payload.get("agent_notes") if isinstance(payload.get("agent_notes"), list) else [] - notes.append({"note": note, "agent": self.agent_type, "ts": datetime.utcnow().isoformat()}) - payload["agent_notes"] = notes[-50:] - ok = store.save_step4_persona_data(payload, source="agent_note") - elif key == "step5": - doc = store.load_step5_context_document() or {} - payload = doc.get("data") if isinstance(doc.get("data"), dict) else {} - notes = payload.get("agent_notes") if isinstance(payload.get("agent_notes"), list) else [] - notes.append({"note": note, "agent": self.agent_type, "ts": datetime.utcnow().isoformat()}) - payload["agent_notes"] = notes[-50:] - ok = store.save_step5_integrations(payload, source="agent_note") - else: - return {"ok": False, "error": "Invalid document. Use step2|step3|step4|step5"} - - return {"ok": bool(ok), "document": key} - except Exception as e: - return {"ok": False, "error": str(e)} - class StrategyArchitectAgent(SIFBaseAgent): """Agent for discovering content pillars and identifying strategic gaps.""" @@ -799,25 +686,7 @@ class ContentGuardianAgent(SIFBaseAgent): if not text: return {"compliance_score": 0.0, "issues": ["No text provided"]} - guidelines_source = "provided" if style_guidelines else "none" - - # 1. Fetch Style Guidelines from flat-file context first, then SIF fallback - if not style_guidelines: - try: - flat_doc = AgentFlatContextStore(self.user_id).load_step2_context_document() - flat_data = (flat_doc or {}).get("data") if isinstance(flat_doc, dict) else None - if isinstance(flat_data, dict): - style_guidelines = { - "tone": (flat_data.get("brand_analysis") or {}).get("brand_voice", "neutral"), - "style_patterns": flat_data.get("style_patterns", {}), - "writing_style": flat_data.get("writing_style", {}), - "style_guidelines": flat_data.get("style_guidelines", {}), - } - guidelines_source = "flat_file" - logger.info(f"[{self.__class__.__name__}] Retrieved style guidelines from flat context") - except Exception as e: - logger.warning(f"[{self.__class__.__name__}] Failed to retrieve style guidelines from flat context: {e}") - + # 1. Fetch Style Guidelines from SIF if not provided if not style_guidelines and self.sif_service: try: # Search for website analysis to get brand voice/style @@ -828,7 +697,7 @@ class ContentGuardianAgent(SIFBaseAgent): res = results[0] metadata_str = res.get('object') metadata = json.loads(metadata_str) if isinstance(metadata_str, str) else (metadata_str or res) - + if metadata.get('type') == 'website_analysis': report = metadata.get('full_report', {}) style_guidelines = { @@ -836,7 +705,6 @@ class ContentGuardianAgent(SIFBaseAgent): "style_patterns": report.get('style_patterns', {}), "writing_style": report.get('writing_style', {}) } - guidelines_source = "sif_index" logger.info(f"[{self.__class__.__name__}] Retrieved style guidelines from SIF: {style_guidelines.get('tone')}") except Exception as e: logger.warning(f"[{self.__class__.__name__}] Failed to retrieve style guidelines from SIF: {e}") @@ -867,7 +735,7 @@ class ContentGuardianAgent(SIFBaseAgent): "compliance_score": max(0.0, score), "issues": issues, "is_compliant": score > 0.8, - "guidelines_source": guidelines_source + "guidelines_source": "sif_index" if not style_guidelines and self.sif_service else "provided" } except Exception as e: diff --git a/backend/services/llm_providers/huggingface_provider.py b/backend/services/llm_providers/huggingface_provider.py index 5b23e47d..e1b3c762 100644 --- a/backend/services/llm_providers/huggingface_provider.py +++ b/backend/services/llm_providers/huggingface_provider.py @@ -10,8 +10,6 @@ Key Features: - Comprehensive error handling and logging - Automatic API key management - Support for various Hugging Face models via Inference Providers -- Explicit fallback model sequences -- Client caching for performance Best Practices: 1. Use structured output for complex, multi-field responses @@ -49,24 +47,35 @@ Last Updated: January 2025 """ import os +import sys +from pathlib import Path import json import re -from functools import lru_cache -from typing import Optional, Dict, Any, List +from typing import Optional, Dict, Any + +from dotenv import load_dotenv + +# Fix the environment loading path - load from backend directory +current_dir = Path(__file__).parent.parent # services directory +backend_dir = current_dir.parent # backend directory +env_path = backend_dir / '.env' + +if env_path.exists(): + load_dotenv(env_path) + print(f"Loaded .env from: {env_path}") +else: + # Fallback to current directory + load_dotenv() + print(f"No .env found at {env_path}, using current directory") from loguru import logger -from utils.logger_utils import get_service_logger, emit_routing_event -<<<<<<< HEAD -from .routing_policy import PREMIUM_DEFAULT_MODEL, SIF_LOW_COST_MODEL_DEFAULTS -======= ->>>>>>> pr-421 +from utils.logger_utils import get_service_logger # Use service-specific logger to avoid conflicts logger = get_service_logger("huggingface_provider") from tenacity import ( retry, - retry_if_exception, stop_after_attempt, wait_random_exponential, ) @@ -81,57 +90,13 @@ except ImportError: logger.warn("OpenAI library not available. Install with: pip install openai") HF_FALLBACK_MODELS = [ - PREMIUM_DEFAULT_MODEL, + "openai/gpt-oss-120b:groq", "moonshotai/Kimi-K2-Instruct-0905:groq", "meta-llama/Llama-3.1-8B-Instruct:groq", - SIF_LOW_COST_MODEL_DEFAULTS[0], + "mistralai/Mistral-7B-Instruct-v0.3:groq", ] -def _should_retry_hf_error(exc: Exception) -> bool: - """Determine if an error should trigger a retry based on error type and message.""" - if isinstance(exc, NotFoundError): - return False # Don't retry model not found errors - - msg = str(exc).lower() - # Don't retry authentication errors - if any(keyword in msg for keyword in ["unauthorized", "forbidden", "401", "403", "invalid api key"]): - return False - # Don't retry billing/quota errors - if any(keyword in msg for keyword in ["insufficient", "quota", "billing", "payment", "credits", "balance"]): - return False - # Retry rate limiting and server errors - if any(keyword in msg for keyword in ["rate limit", "429", "500", "502", "503", "504", "timeout"]): - return True - # Default to retry for unknown errors - return True - - -def _classify_hf_error(exc: Exception) -> str: - """Classify Hugging Face errors for better error reporting.""" - msg = str(exc).lower() - if any(keyword in msg for keyword in ["insufficient", "quota", "billing", "payment", "credits", "balance"]): - return "billing_or_quota" - if any(keyword in msg for keyword in ["unauthorized", "forbidden", "401", "403"]): - return "auth_or_permission" - if "not found" in msg or "404" in msg: - return "model_not_found" - if any(keyword in msg for keyword in ["rate limit", "429"]): - return "rate_limit" - if any(keyword in msg for keyword in ["timeout", "500", "502", "503", "504"]): - return "server_error" - return "unknown" - - -def _error_details(exc: Exception) -> Dict[str, str]: - """Extract error details for logging.""" - return { - "type": type(exc).__name__, - "message": str(exc), - "repr": repr(exc), - } - - def _candidate_model_variants(model: str): """Yield model ids to try for a single logical model preference.""" if not model: @@ -147,9 +112,8 @@ def _candidate_model_variants(model: str): yield base_model -def _fallback_model_sequence(model: str, fallback_models: Optional[List[str]] = None): - """Generate a sequence of models to try as fallbacks.""" - sequence = [model] + (fallback_models or HF_FALLBACK_MODELS) +def _fallback_model_sequence(model: str): + sequence = [model] + HF_FALLBACK_MODELS seen = set() for preferred_model in sequence: for candidate in _candidate_model_variants(preferred_model): @@ -157,10 +121,9 @@ def _fallback_model_sequence(model: str, fallback_models: Optional[List[str]] = seen.add(candidate) yield candidate - -def get_huggingface_api_key(explicit_api_key: Optional[str] = None) -> str: +def get_huggingface_api_key() -> str: """Get Hugging Face API key with proper error handling.""" - api_key = explicit_api_key or os.getenv('HF_TOKEN') + api_key = os.getenv('HF_TOKEN') if not api_key: error_msg = "HF_TOKEN environment variable is not set. Please set it in your .env file." logger.error(error_msg) @@ -174,32 +137,14 @@ def get_huggingface_api_key(explicit_api_key: Optional[str] = None) -> str: return api_key - -@lru_cache(maxsize=16) -def _get_hf_client(api_key: str): - """Get cached Hugging Face client for better performance.""" - return OpenAI(base_url="https://router.huggingface.co/v1", api_key=api_key) - - -@retry( - retry=retry_if_exception(_should_retry_hf_error), - wait=wait_random_exponential(min=1, max=60), - stop=stop_after_attempt(6), -) +@retry(wait=wait_random_exponential(min=1, max=60), stop=stop_after_attempt(6)) def huggingface_text_response( prompt: str, - model: str = PREMIUM_DEFAULT_MODEL, - fallback_models: Optional[List[str]] = None, + model: str = "openai/gpt-oss-120b:groq", temperature: float = 0.7, max_tokens: int = 2048, top_p: float = 0.9, - system_prompt: Optional[str] = None, -<<<<<<< HEAD - api_key: Optional[str] = None, - tenant_user_id: Optional[str] = None, -======= - tenant_user_id: Optional[str] = None ->>>>>>> pr-421 + system_prompt: Optional[str] = None ) -> str: """ Generate text response using Hugging Face Inference Providers API. @@ -209,13 +154,11 @@ def huggingface_text_response( Args: prompt (str): The input prompt for the AI model - model (str): Hugging Face model identifier (default: PREMIUM_DEFAULT_MODEL) - fallback_models (list, optional): Custom fallback models to try + model (str): Hugging Face model identifier (default: "openai/gpt-oss-120b:groq") temperature (float): Controls randomness (0.0-1.0) max_tokens (int): Maximum tokens in response top_p (float): Nucleus sampling parameter (0.0-1.0) system_prompt (str, optional): System instruction for the model - api_key (str, optional): Explicit API key override Returns: str: Generated text response @@ -228,17 +171,32 @@ def huggingface_text_response( - Set max_tokens based on expected response length - Use system_prompt to guide model behavior - Handle errors gracefully in calling functions + + Example: + result = huggingface_text_response( + prompt="Write a blog post about AI", + model="openai/gpt-oss-120b:groq", + temperature=0.7, + max_tokens=2048, + system_prompt="You are a professional content writer." + ) """ try: if not OPENAI_AVAILABLE: raise ImportError("OpenAI library not available. Install with: pip install openai") # Get API key with proper error handling - hf_api_key = get_huggingface_api_key(api_key) - logger.info(f"🔑 Hugging Face API key loaded: {bool(hf_api_key)} (length: {len(hf_api_key) if hf_api_key else 0})") + api_key = get_huggingface_api_key() + logger.info(f"🔑 Hugging Face API key loaded: {bool(api_key)} (length: {len(api_key) if api_key else 0})") + if not api_key: + raise Exception("HF_TOKEN not found in environment variables") + # Initialize Hugging Face client - client = _get_hf_client(hf_api_key) + client = OpenAI( + base_url=f"https://router.huggingface.co/hf/v1", + api_key=api_key, + ) logger.info("✅ Hugging Face client initialized for text response") # Prepare input for the API @@ -269,41 +227,13 @@ def huggingface_text_response( logger.info("🚀 Making Hugging Face API call (chat completion)...") + # Add rate limiting to prevent expensive API calls + import time + time.sleep(1) # 1 second delay between API calls + response = None last_error = None -<<<<<<< HEAD - for candidate_model in _fallback_model_sequence(model, fallback_models): - # Emit routing event for each model attempt - route_intent = "primary" if candidate_model == model else "fallback" - emit_routing_event( - logger, - flow_type="huggingface_text", - route_intent=route_intent, - provider_selected="huggingface", - model_selected=candidate_model, - tenant_user_id=tenant_user_id, - extra={"original_model": model, "api_call": True} - ) - -======= - fallback_models_tried = [] - fallback_count = 0 for candidate_model in _fallback_model_sequence(model): - fallback_models_tried.append(candidate_model) - route_intent = "primary" if fallback_count == 0 else "fallback" - emit_routing_event( - logger, - flow_type="text_generation", - route_intent=route_intent, - provider_selected="huggingface", - model_selected=candidate_model, - preferred_provider="huggingface", - fallback_count=fallback_count, - fallback_models_tried=fallback_models_tried, - tenant_user_id=tenant_user_id, - extra={"hf_request_type": "text"}, - ) ->>>>>>> pr-421 try: response = client.chat.completions.create( model=candidate_model, @@ -313,67 +243,41 @@ def huggingface_text_response( max_tokens=max_tokens ) if candidate_model != model: - logger.warning("HF text fallback model used: {}", candidate_model) + logger.warning("HF text generation switched to fallback model: {}", candidate_model) break except NotFoundError as nf_err: last_error = nf_err -<<<<<<< HEAD - logger.warning("HF text model not found: {}", candidate_model) - continue - except Exception as call_err: - last_error = call_err - logger.warning("HF text call failed for model {}: {}", candidate_model, _error_details(call_err)) -======= - fallback_count += 1 logger.warning("HF model not found: {}. Trying fallback model.", candidate_model) ->>>>>>> pr-421 continue if response is None: - raise last_error or RuntimeError("All fallback models failed") + raise last_error or Exception("Hugging Face text generation failed: all fallback models failed") # Extract text from response - generated_text = response.choices[0].message.content or "" + generated_text = response.choices[0].message.content # Clean up the response - generated_text = re.sub(r'```[a-zA-Z]*\n?', '', generated_text) - generated_text = re.sub(r'```\n?', '', generated_text) - generated_text = generated_text.strip() + if generated_text: + # Remove any markdown formatting if present + generated_text = re.sub(r'```[a-zA-Z]*\n?', '', generated_text) + generated_text = re.sub(r'```\n?', '', generated_text) + generated_text = generated_text.strip() logger.info(f"✅ Hugging Face text response generated successfully (length: {len(generated_text)})") return generated_text - except Exception as exc: - details = _error_details(exc) - logger.error( - "❌ Hugging Face text generation failed | error_class={} | type={} | message={} | repr={}", - _classify_hf_error(exc), - details["type"], - details["message"], - details["repr"], - ) - raise Exception(f"Hugging Face text generation failed: {exc}") from exc + except Exception as e: + logger.error(f"❌ Hugging Face text generation failed: {str(e)}") + raise Exception(f"Hugging Face text generation failed: {str(e)}") - -@retry( - retry=retry_if_exception(_should_retry_hf_error), - wait=wait_random_exponential(min=1, max=60), - stop=stop_after_attempt(6), -) +@retry(wait=wait_random_exponential(min=1, max=60), stop=stop_after_attempt(6)) def huggingface_structured_json_response( prompt: str, schema: Dict[str, Any], - model: str = PREMIUM_DEFAULT_MODEL, - fallback_models: Optional[List[str]] = None, + model: str = "openai/gpt-oss-120b:groq", temperature: float = 0.7, max_tokens: int = 8192, - system_prompt: Optional[str] = None, -<<<<<<< HEAD - api_key: Optional[str] = None, - tenant_user_id: Optional[str] = None, -======= - tenant_user_id: Optional[str] = None ->>>>>>> pr-421 + system_prompt: Optional[str] = None ) -> Dict[str, Any]: """ Generate structured JSON response using Hugging Face Inference Providers API. @@ -384,12 +288,10 @@ def huggingface_structured_json_response( Args: prompt (str): The input prompt for the AI model schema (dict): JSON schema defining the expected output structure - model (str): Hugging Face model identifier (default: PREMIUM_DEFAULT_MODEL) - fallback_models (list, optional): Custom fallback models to try + model (str): Hugging Face model identifier (default: "openai/gpt-oss-120b:groq") temperature (float): Controls randomness (0.0-1.0). Use 0.1-0.3 for structured output max_tokens (int): Maximum tokens in response. Use 8192 for complex outputs system_prompt (str, optional): System instruction for the model - api_key (str, optional): Explicit API key override Returns: dict: Parsed JSON response matching the provided schema @@ -403,17 +305,42 @@ def huggingface_structured_json_response( - Set max_tokens to 8192 for complex multi-field responses - Avoid deeply nested schemas with many required fields - Test with smaller outputs first, then scale up + + Example: + schema = { + "type": "object", + "properties": { + "tasks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "title": {"type": "string"}, + "description": {"type": "string"} + } + } + } + } + } + result = huggingface_structured_json_response(prompt, schema, temperature=0.2, max_tokens=8192) """ try: if not OPENAI_AVAILABLE: raise ImportError("OpenAI library not available. Install with: pip install openai") # Get API key with proper error handling - hf_api_key = get_huggingface_api_key(api_key) - logger.info(f"🔑 Hugging Face API key loaded: {bool(hf_api_key)} (length: {len(hf_api_key) if hf_api_key else 0})") + api_key = get_huggingface_api_key() + logger.info(f"🔑 Hugging Face API key loaded: {bool(api_key)} (length: {len(api_key) if api_key else 0})") + if not api_key: + raise Exception("HF_TOKEN not found in environment variables") + # Initialize OpenAI client with Hugging Face base URL - client = _get_hf_client(hf_api_key) + # Use standard Inference API endpoint + client = OpenAI( + base_url=f"https://router.huggingface.co/hf/v1", + api_key=api_key, + ) logger.info("✅ Hugging Face client initialized for structured JSON response") # Prepare input for the API @@ -427,6 +354,7 @@ def huggingface_structured_json_response( }) # Add user prompt with JSON instruction + # For HF models, explicit JSON instruction in prompt is often better than response_format json_instruction = "Please respond with valid JSON that matches the provided schema." messages.append({ "role": "user", @@ -445,14 +373,13 @@ def huggingface_structured_json_response( logger.info("🚀 Making Hugging Face structured API call...") + # Make the API call using standard Chat Completions + logger.info("🚀 Making Hugging Face API call (chat completion)...") + # Add JSON schema to prompt for guidance json_schema_str = json.dumps(schema, indent=2) messages[-1]["content"] += f"\n\nJSON Schema:\n{json_schema_str}" -<<<<<<< HEAD - response = None - last_error = None -======= # Add rate limiting to prevent expensive API calls import time time.sleep(1) # 1 second delay between API calls @@ -460,23 +387,7 @@ def huggingface_structured_json_response( try: response = None last_error = None - fallback_models_tried = [] - fallback_count = 0 for candidate_model in _fallback_model_sequence(model): - fallback_models_tried.append(candidate_model) - route_intent = "primary" if fallback_count == 0 else "fallback" - emit_routing_event( - logger, - flow_type="text_generation", - route_intent=route_intent, - provider_selected="huggingface", - model_selected=candidate_model, - preferred_provider="huggingface", - fallback_count=fallback_count, - fallback_models_tried=fallback_models_tried, - tenant_user_id=tenant_user_id, - extra={"hf_request_type": "structured_json"}, - ) try: response = client.chat.completions.create( model=candidate_model, @@ -490,45 +401,23 @@ def huggingface_structured_json_response( break except NotFoundError as nf_err: last_error = nf_err - fallback_count += 1 logger.warning("HF structured model not found: {}. Trying fallback model.", candidate_model) continue ->>>>>>> pr-421 - for candidate_model in _fallback_model_sequence(model, fallback_models): - # Emit routing event for each model attempt - route_intent = "primary" if candidate_model == model else "fallback" - emit_routing_event( - logger, - flow_type="huggingface_structured", - route_intent=route_intent, - provider_selected="huggingface", - model_selected=candidate_model, - tenant_user_id=tenant_user_id, - extra={"original_model": model, "api_call": True, "response_format": "json_object"} - ) + if response is None: + raise last_error or Exception("Hugging Face structured generation failed: all fallback models failed") + + response_text = response.choices[0].message.content + + # Clean up response text if needed + response_text = response_text.strip() + if response_text.startswith("```json"): + response_text = response_text[7:] + if response_text.endswith("```"): + response_text = response_text[:-3] + response_text = response_text.strip() try: -<<<<<<< HEAD - response = client.chat.completions.create( - model=candidate_model, - messages=messages, - temperature=temperature, - max_tokens=max_tokens, - response_format={"type": "json_object"} - ) - if candidate_model != model: - logger.warning("HF structured fallback model used: {}", candidate_model) - break - except Exception as err: - last_error = err - if isinstance(err, NotFoundError): - logger.warning("HF structured model not found: {}", candidate_model) - continue - - msg = str(err).lower() - if "422" in msg or "not supported" in msg: -======= parsed_json = json.loads(response_text) logger.info("✅ Hugging Face structured JSON response parsed successfully") return parsed_json @@ -556,75 +445,43 @@ def huggingface_structured_json_response( response = None last_error = None for candidate_model in _fallback_model_sequence(model): - fallback_models_tried.append(candidate_model) - route_intent = "primary" if fallback_count == 0 else "fallback" - emit_routing_event( - logger, - flow_type="text_generation", - route_intent=route_intent, - provider_selected="huggingface", - model_selected=candidate_model, - preferred_provider="huggingface", - fallback_count=fallback_count, - fallback_models_tried=fallback_models_tried, - tenant_user_id=tenant_user_id, - extra={"hf_request_type": "structured_json_no_response_format"}, - ) ->>>>>>> pr-421 try: response = client.chat.completions.create( model=candidate_model, messages=messages, temperature=temperature, - max_tokens=max_tokens, + max_tokens=max_tokens ) if candidate_model != model: - logger.warning("HF structured fallback(no response_format) model: {}", candidate_model) + logger.warning("HF structured no-response_format fallback model: {}", candidate_model) break -<<<<<<< HEAD - except Exception as second_err: - last_error = second_err -======= except NotFoundError as nf_err: last_error = nf_err - fallback_count += 1 logger.warning("HF structured model not found (no response_format path): {}", candidate_model) ->>>>>>> pr-421 continue - if response is None: - raise last_error or RuntimeError("All fallback models failed") + if response is None: + raise last_error or e + response_text = response.choices[0].message.content + # ... (same parsing logic would apply, simplified here for brevity) + try: + return json.loads(response_text) + except: + # Regex fallback + json_match = re.search(r'\{.*\}', response_text, re.DOTALL) + if json_match: + return json.loads(json_match.group()) + return {"error": "Failed to parse JSON response", "raw_response": response_text} + raise e - response_text = (response.choices[0].message.content or "").strip() - - # Clean up response text if needed - if response_text.startswith("```json"): - response_text = response_text[7:] - if response_text.endswith("```"): - response_text = response_text[:-3] - response_text = response_text.strip() - - try: - parsed_json = json.loads(response_text) - logger.info("✅ Hugging Face structured JSON response parsed successfully") - return parsed_json - except json.JSONDecodeError: - json_match = re.search(r"\{.*\}", response_text, re.DOTALL) - if json_match: - return json.loads(json_match.group()) - return {"error": "Failed to parse JSON response", "raw_response": response_text} - - except Exception as exc: - details = _error_details(exc) - logger.error( - "❌ Hugging Face structured JSON generation failed | error_class={} | type={} | message={} | repr={}", - _classify_hf_error(exc), - details["type"], - details["message"], - details["repr"], - ) - raise Exception(f"Hugging Face structured JSON generation failed: {exc}") from exc - + except Exception as e: + error_msg = str(e) if str(e) else repr(e) + error_type = type(e).__name__ + logger.error(f"❌ Hugging Face structured JSON generation failed: {error_type}: {error_msg}") + logger.error(f"❌ Full exception details: {repr(e)}") + import traceback + logger.error(f"❌ Traceback: {traceback.format_exc()}") + raise Exception(f"Hugging Face structured JSON generation failed: {error_type}: {error_msg}") def get_available_models() -> list: """ @@ -634,15 +491,14 @@ def get_available_models() -> list: list: List of available model identifiers """ return [ - PREMIUM_DEFAULT_MODEL, + "openai/gpt-oss-120b:groq", "moonshotai/Kimi-K2-Instruct-0905:groq", "Qwen/Qwen2.5-VL-7B-Instruct", "meta-llama/Llama-3.1-8B-Instruct:groq", "microsoft/Phi-3-medium-4k-instruct:groq", - SIF_LOW_COST_MODEL_DEFAULTS[0] + "mistralai/Mistral-7B-Instruct-v0.3:groq" ] - def validate_model(model: str) -> bool: """ Validate if a model identifier is supported. diff --git a/backend/services/llm_providers/main_text_generation.py b/backend/services/llm_providers/main_text_generation.py index f6731ebb..2e766d29 100644 --- a/backend/services/llm_providers/main_text_generation.py +++ b/backend/services/llm_providers/main_text_generation.py @@ -2,8 +2,6 @@ This service provides the main LLM text generation functionality, migrated from the legacy lib/gpt_providers/text_generation/main_text_generation.py - -This is a clean version that imports from modular components to avoid merge conflicts. """ import os @@ -13,47 +11,9 @@ from datetime import datetime from loguru import logger from fastapi import HTTPException -# Import all functionality from our modular textgen_utils package -from .textgen_utils import ( - llm_text_gen, - check_gpt_provider, - get_api_key, - _normalize_provider, - _parse_csv_env, - _resolve_provider_sequence, - _map_logical_model_to_provider_model, - _resolve_model_sequence, -) - -# Re-export all the main functions for backward compatibility -__all__ = [ - "llm_text_gen", - "check_gpt_provider", - "get_api_key", - "_normalize_provider", - "_parse_csv_env", - "_resolve_provider_sequence", - "_map_logical_model_to_provider_model", - "_resolve_model_sequence", -] - -# Maintain any additional constants or configurations that might be needed -PREMIUM_HF_MINIMAL_FALLBACK_MODELS = [ - "openai/gpt-oss-120b:groq", -] - -# Legacy compatibility - any imports that other modules might expect from .gemini_provider import gemini_text_response, gemini_structured_json_response from .huggingface_provider import huggingface_text_response, huggingface_structured_json_response -<<<<<<< HEAD from .tenant_provider_config import tenant_provider_config_resolver -from .routing_policy import ( - PREMIUM_DEFAULT_MODEL, - SIF_LOW_COST_MODEL_DEFAULTS, - resolve_text_provider_alias, -) -======= -from ...utils.logger_utils import emit_routing_event def llm_text_gen( @@ -93,14 +53,17 @@ def llm_text_gen( frequency_penalty = 0.0 presence_penalty = 0.0 - # Check for GPT_PROVIDER environment variable - env_provider = os.getenv('GPT_PROVIDER', '').lower() - if env_provider in ['gemini', 'google']: + provider_cfg = tenant_provider_config_resolver.resolve( + modality="text", + user_id=user_id, + ) + selected_provider = (provider_cfg.selected_providers or [None])[0] + if selected_provider in ["gemini", "google"]: gpt_provider = "google" - model = "gemini-2.0-flash-001" - elif env_provider in ['hf_response_api', 'huggingface', 'hf']: + model = provider_cfg.model_policy.get("default_model") or "gemini-2.0-flash-001" + elif selected_provider == "huggingface": gpt_provider = "huggingface" - model = "mistralai/Mistral-7B-Instruct-v0.3:groq" + model = provider_cfg.model_policy.get("default_model") or "mistralai/Mistral-7B-Instruct-v0.3:groq" # Default blog characteristics blog_tone = "Professional" @@ -110,64 +73,32 @@ def llm_text_gen( blog_output_format = "markdown" blog_length = 2000 - # Check which providers have API keys available using APIKeyManager - api_key_manager = APIKeyManager() available_providers = [] - if api_key_manager.get_api_key("gemini"): - available_providers.append("google") - if api_key_manager.get_api_key("hf_token"): - available_providers.append("huggingface") + for provider in ("google", "huggingface"): + if get_api_key(provider, user_id=user_id): + available_providers.append(provider) - preferred_provider = env_provider or None - flow_type = "text_generation" - route_intent = "primary" - fallback_count = 0 - fallback_models_tried = [] - - # If no environment variable set, auto-detect based on available keys - if not env_provider: - # Prefer Google Gemini if available, otherwise use Hugging Face - if "google" in available_providers: - gpt_provider = "google" - model = "gemini-2.0-flash-001" - elif "huggingface" in available_providers: - gpt_provider = "huggingface" - model = "mistralai/Mistral-7B-Instruct-v0.3:groq" + if gpt_provider not in available_providers: + logger.warning(f"[llm_text_gen] Provider {gpt_provider} unavailable for user {user_id}, falling back.") + if available_providers: + gpt_provider = available_providers[0] else: logger.error("[llm_text_gen] No API keys found for supported providers.") - raise RuntimeError("No LLM API keys configured. Configure GEMINI_API_KEY or HF_TOKEN to enable AI responses.") - else: - # Environment variable was set, validate it's supported - if gpt_provider not in available_providers: - logger.warning(f"[llm_text_gen] Provider {gpt_provider} not available, falling back to available providers") - if "google" in available_providers: - gpt_provider = "google" - model = "gemini-2.0-flash-001" - elif "huggingface" in available_providers: - gpt_provider = "huggingface" - model = "mistralai/Mistral-7B-Instruct-v0.3:groq" - else: - raise RuntimeError("No supported providers available.") + raise RuntimeError("No LLM API keys configured for tenant or environment defaults.") + + # Ensure downstream provider clients (currently env-based) receive resolved key + resolved_key = get_api_key(gpt_provider, user_id=user_id) + if gpt_provider == "google" and resolved_key: + os.environ["GEMINI_API_KEY"] = resolved_key + os.environ.setdefault("GOOGLE_API_KEY", resolved_key) + elif gpt_provider == "huggingface" and resolved_key: + os.environ["HF_TOKEN"] = resolved_key if gpt_provider == "huggingface" and preferred_hf_models: model = preferred_hf_models[0] logger.info(f"[llm_text_gen] Using preferred low-cost HF model: {model}") - - fallback_models_tried.append(model) logger.debug(f"[llm_text_gen] Using provider: {gpt_provider}, model: {model}") - emit_routing_event( - logger, - flow_type=flow_type, - route_intent=route_intent, - provider_selected=gpt_provider, - model_selected=model, - preferred_provider=preferred_provider, - fallback_count=fallback_count, - fallback_models_tried=fallback_models_tried, - tenant_user_id=user_id, - extra={"available_providers": available_providers}, - ) # Map provider name to APIProvider enum (define at function scope for usage tracking) from models.subscription_models import APIProvider @@ -311,8 +242,7 @@ def llm_text_gen( model=model, temperature=temperature, max_tokens=max_tokens, - system_prompt=system_instructions, - tenant_user_id=user_id + system_prompt=system_instructions ) else: response_text = huggingface_text_response( @@ -321,8 +251,7 @@ def llm_text_gen( temperature=temperature, max_tokens=max_tokens, top_p=top_p, - system_prompt=system_instructions, - tenant_user_id=user_id + system_prompt=system_instructions ) else: logger.error(f"[llm_text_gen] Unknown provider: {gpt_provider}") @@ -366,34 +295,17 @@ def llm_text_gen( try: logger.info(f"[llm_text_gen] Trying SINGLE fallback provider: {fallback_provider}") actual_provider_used = fallback_provider - fallback_count += 1 - route_intent = "fallback" # Update provider enum for fallback if fallback_provider == "google": provider_enum = APIProvider.GEMINI actual_provider_name = "gemini" fallback_model = "gemini-2.0-flash-lite" - fallback_models_tried.append(fallback_model) elif fallback_provider == "huggingface": provider_enum = APIProvider.MISTRAL actual_provider_name = "huggingface" fallback_model = "mistralai/Mistral-7B-Instruct-v0.3:groq" - fallback_models_tried.append(fallback_model) - emit_routing_event( - logger, - flow_type=flow_type, - route_intent=route_intent, - provider_selected=fallback_provider, - model_selected=fallback_model, - preferred_provider=preferred_provider, - fallback_count=fallback_count, - fallback_models_tried=fallback_models_tried, - tenant_user_id=user_id, - extra={"available_providers": available_providers}, - ) - if fallback_provider == "google": if json_struct: response_text = gemini_structured_json_response( @@ -422,8 +334,7 @@ def llm_text_gen( model="mistralai/Mistral-7B-Instruct-v0.3:groq", temperature=temperature, max_tokens=max_tokens, - system_prompt=system_instructions, - tenant_user_id=user_id + system_prompt=system_instructions ) else: response_text = huggingface_text_response( @@ -432,8 +343,7 @@ def llm_text_gen( temperature=temperature, max_tokens=max_tokens, top_p=top_p, - system_prompt=system_instructions, - tenant_user_id=user_id + system_prompt=system_instructions ) # TRACK USAGE after successful fallback call @@ -472,18 +382,16 @@ def check_gpt_provider(gpt_provider: str) -> bool: supported_providers = ["google", "huggingface"] return gpt_provider in supported_providers -def get_api_key(gpt_provider: str) -> Optional[str]: +def get_api_key(gpt_provider: str, user_id: Optional[str] = None) -> Optional[str]: """Get API key for the specified provider.""" try: - api_key_manager = APIKeyManager() provider_mapping = { "google": "gemini", - "huggingface": "hf_token" + "huggingface": "huggingface" } - mapped_provider = provider_mapping.get(gpt_provider, gpt_provider) - return api_key_manager.get_api_key(mapped_provider) + key, _source = tenant_provider_config_resolver.resolve_provider_key(mapped_provider, user_id=user_id) + return key except Exception as e: logger.error(f"[get_api_key] Error getting API key for {gpt_provider}: {str(e)}") return None ->>>>>>> pr-421 diff --git a/backend/services/llm_providers/tenant_provider_config.py b/backend/services/llm_providers/tenant_provider_config.py index 53c99744..c9cf5f3a 100644 --- a/backend/services/llm_providers/tenant_provider_config.py +++ b/backend/services/llm_providers/tenant_provider_config.py @@ -1,88 +1,3 @@ -<<<<<<< HEAD -"""Tenant-aware provider configuration and API key resolution for LLM providers.""" - -from __future__ import annotations - -import os -import time -from typing import Dict, Optional - -from loguru import logger - -from services.database import get_session_for_user -from models.onboarding import APIKey, OnboardingSession - -_PROVIDER_KEY_MAP = { - "google": "gemini", - "gemini": "gemini", - "huggingface": "hf_token", - "hf": "hf_token", - "hf_response_api": "hf_token", -} - -_PROVIDER_ENV_MAP = { - "gemini": "GEMINI_API_KEY", - "hf_token": "HF_TOKEN", -} - -_CACHE_TTL_SECONDS = int(os.getenv("TENANT_PROVIDER_CACHE_TTL", "60")) -_cache: Dict[str, tuple[float, Optional[str]]] = {} - - -def _cache_key(user_id: Optional[str], provider_key: str) -> str: - return f"{user_id or 'global'}::{provider_key}" - - -def _normalize_provider(provider: str) -> str: - return _PROVIDER_KEY_MAP.get((provider or "").lower(), (provider or "").lower()) - - -def get_tenant_api_key(user_id: Optional[str], provider: str) -> Optional[str]: - provider_key = _normalize_provider(provider) - ck = _cache_key(user_id, provider_key) - cached = _cache.get(ck) - now = time.time() - if cached and (now - cached[0]) < _CACHE_TTL_SECONDS: - return cached[1] - - key: Optional[str] = None - if user_id: - db = None - try: - db = get_session_for_user(user_id) - if db: - record = ( - db.query(APIKey.key) - .join(OnboardingSession, APIKey.session_id == OnboardingSession.id) - .filter(OnboardingSession.user_id == user_id, APIKey.provider == provider_key) - .order_by(APIKey.updated_at.desc()) - .first() - ) - if record and record[0]: - key = record[0] - except Exception as exc: - logger.debug("tenant api-key lookup failed for user={}, provider={}: {}", user_id, provider_key, exc) - finally: - if db: - db.close() - - if not key: - env_var = _PROVIDER_ENV_MAP.get(provider_key) - if env_var: - key = os.getenv(env_var) - - _cache[ck] = (now, key) - return key - - -def get_available_text_providers(user_id: Optional[str]) -> list[str]: - providers = [] - if get_tenant_api_key(user_id, "gemini"): - providers.append("google") - if get_tenant_api_key(user_id, "huggingface"): - providers.append("huggingface") - return providers -======= from __future__ import annotations import os @@ -251,4 +166,3 @@ class TenantProviderConfigResolver: tenant_provider_config_resolver = TenantProviderConfigResolver() ->>>>>>> pr-420 diff --git a/backend/services/product_marketing/personalization_service.py b/backend/services/product_marketing/personalization_service.py index c1107ea6..19776d67 100644 --- a/backend/services/product_marketing/personalization_service.py +++ b/backend/services/product_marketing/personalization_service.py @@ -6,24 +6,10 @@ Extracts ALL onboarding data and provides personalized defaults for forms and re from typing import Dict, Any, Optional, List from loguru import logger -from services.database import get_session_for_user +from services.database import SessionLocal from api.content_planning.services.content_strategy.onboarding import OnboardingDataIntegrationService -def _ensure_dict(value: Any) -> Dict[str, Any]: - """Safely coerce arbitrary payload shape into a dictionary.""" - return value if isinstance(value, dict) else {} - - -def _ensure_list(value: Any) -> List[Any]: - """Safely coerce arbitrary payload shape into a list.""" - if isinstance(value, list): - return value - if value is None: - return [] - return [value] - - class PersonalizationService: """ Service for extracting user preferences from onboarding data @@ -34,14 +20,6 @@ class PersonalizationService: """Initialize Personalization Service.""" self.logger = logger logger.info("[Personalization Service] Initialized") - - @staticmethod - def _as_dict(value: Any) -> Dict[str, Any]: - return value if isinstance(value, dict) else {} - - @staticmethod - def _as_list(value: Any) -> List[Any]: - return value if isinstance(value, list) else [] def get_user_preferences(self, user_id: str) -> Dict[str, Any]: """ @@ -58,50 +36,20 @@ class PersonalizationService: - templates: Recommended templates for user's industry - channels: Recommended channels based on platform personas """ - db = None + db = SessionLocal() try: - db = get_session_for_user(user_id) - if not db: - logger.warning(f"[Personalization] No DB session available for user {user_id}; using default preferences") - return self._get_default_preferences() - integration_service = OnboardingDataIntegrationService() -<<<<<<< HEAD integrated_data = integration_service.get_integrated_data_sync(user_id, db) - if not isinstance(integrated_data, dict): - logger.warning( - f"[Personalization] Integrated onboarding payload is non-dict for user {user_id}; using defaults" - ) - integrated_data = {} - canonical_profile = integrated_data.get('canonical_profile', {}) - if not isinstance(canonical_profile, dict): - logger.warning( - f"[Personalization] Canonical profile is non-dict for user {user_id}; using defaults" - ) - canonical_profile = {} -======= - integrated_data_raw = integration_service.get_integrated_data_sync(user_id, db) - integrated_data = _ensure_dict(integrated_data_raw) - canonical_profile = _ensure_dict(integrated_data.get('canonical_profile')) ->>>>>>> pr-416 # Map strictly from Canonical Profile preferences = { "industry": canonical_profile.get("industry"), -<<<<<<< HEAD - "target_audience": self._as_dict(canonical_profile.get("target_audience", {})), - "platform_preferences": self._as_list(canonical_profile.get("platform_preferences", [])), - "content_preferences": self._as_list(canonical_profile.get("content_types", [])), - "style_preferences": self._as_dict(canonical_profile.get("visual_style", {})), - "brand_colors": self._as_list(canonical_profile.get("brand_colors", [])), -======= - "target_audience": _ensure_dict(canonical_profile.get("target_audience")), - "platform_preferences": _ensure_list(canonical_profile.get("platform_preferences")), - "content_preferences": _ensure_list(canonical_profile.get("content_types")), - "style_preferences": _ensure_dict(canonical_profile.get("visual_style")), - "brand_colors": _ensure_list(canonical_profile.get("brand_colors")), ->>>>>>> pr-416 + "target_audience": canonical_profile.get("target_audience", {}), + "platform_preferences": canonical_profile.get("platform_preferences", []), + "content_preferences": canonical_profile.get("content_types", []), + "style_preferences": canonical_profile.get("visual_style", {}), + "brand_colors": canonical_profile.get("brand_colors", []), "recommended_templates": [], "recommended_channels": [], "writing_style": { @@ -110,11 +58,7 @@ class PersonalizationService: "complexity": canonical_profile.get("writing_complexity", "intermediate"), "engagement_level": canonical_profile.get("writing_engagement", "moderate"), }, -<<<<<<< HEAD - "brand_values": self._as_list(canonical_profile.get("brand_values", [])), -======= - "brand_values": _ensure_list(canonical_profile.get("brand_values")), ->>>>>>> pr-416 + "brand_values": canonical_profile.get("brand_values", []), } # Ensure target_audience structure @@ -150,7 +94,7 @@ class PersonalizationService: if not preferences["recommended_channels"]: preferences["recommended_channels"] = self._get_recommended_channels( preferences.get("industry"), - _ensure_list(_ensure_dict(preferences.get("target_audience")).get("demographics")) + preferences.get("target_audience", {}).get("demographics", []) ) logger.info(f"[Personalization] Extracted preferences for user {user_id}: industry={preferences.get('industry')}") @@ -160,8 +104,7 @@ class PersonalizationService: logger.error(f"[Personalization] Error getting user preferences: {str(e)}", exc_info=True) return self._get_default_preferences() finally: - if db: - db.close() + db.close() def get_personalized_defaults( self, diff --git a/backend/services/story_writer/audio_generation_service.py b/backend/services/story_writer/audio_generation_service.py index 88c7b866..b07fc470 100644 --- a/backend/services/story_writer/audio_generation_service.py +++ b/backend/services/story_writer/audio_generation_service.py @@ -11,7 +11,12 @@ from pathlib import Path from loguru import logger from fastapi import HTTPException from sqlalchemy.orm import Session -from api.story_writer.utils.media_utils import get_story_media_write_dir + + +def _get_story_media_write_dir(media_type: str, user_id: Optional[str] = None, db: Optional[Session] = None) -> Path: + """Lazy import wrapper to avoid circular imports.""" + from api.story_writer.utils.media_utils import get_story_media_write_dir + return get_story_media_write_dir(media_type, user_id=user_id, db=db) class StoryAudioGenerationService: @@ -29,7 +34,7 @@ class StoryAudioGenerationService: self.output_dir = Path(output_dir) self.output_dir.mkdir(parents=True, exist_ok=True) else: - self.output_dir = get_story_media_write_dir("audio") + self.output_dir = _get_story_media_write_dir("audio") logger.info(f"[StoryAudioGeneration] Initialized with output directory: {self.output_dir}") def _get_user_audio_dir(self, user_id: str, db: Optional[Session] = None) -> Path: @@ -38,7 +43,7 @@ class StoryAudioGenerationService: Falls back to default output_dir if workspace not found. """ try: - return get_story_media_write_dir("audio", user_id=user_id, db=db) + return _get_story_media_write_dir("audio", user_id=user_id, db=db) except Exception as e: logger.warning(f"[StoryAudioGeneration] Failed to resolve user workspace path for {user_id}: {e}") return self.output_dir diff --git a/backend/services/story_writer/image_generation_service.py b/backend/services/story_writer/image_generation_service.py index 88ba129d..ecb71b6a 100644 --- a/backend/services/story_writer/image_generation_service.py +++ b/backend/services/story_writer/image_generation_service.py @@ -15,11 +15,16 @@ from sqlalchemy.orm import Session from services.llm_providers.main_image_generation import generate_image from services.llm_providers.image_generation import ImageGenerationResult from utils.logger_utils import get_service_logger -from api.story_writer.utils.media_utils import get_story_media_write_dir logger = get_service_logger("story_writer.image_generation") +def _get_story_media_write_dir(media_type: str, user_id: Optional[str] = None, db: Optional[Session] = None) -> Path: + """Lazy import wrapper to avoid circular imports.""" + from api.story_writer.utils.media_utils import get_story_media_write_dir + return get_story_media_write_dir(media_type, user_id=user_id, db=db) + + class StoryImageGenerationService: """Service for generating images for story scenes.""" @@ -35,7 +40,7 @@ class StoryImageGenerationService: self.output_dir = Path(output_dir) self.output_dir.mkdir(parents=True, exist_ok=True) else: - self.output_dir = get_story_media_write_dir("image") + self.output_dir = _get_story_media_write_dir("image") logger.info(f"[StoryImageGeneration] Initialized with output directory: {self.output_dir}") def _get_user_image_dir(self, user_id: str, db: Optional[Session] = None) -> Path: @@ -44,7 +49,7 @@ class StoryImageGenerationService: Falls back to default output_dir if workspace not found. """ try: - return get_story_media_write_dir("image", user_id=user_id, db=db) + return _get_story_media_write_dir("image", user_id=user_id, db=db) except Exception as e: logger.warning(f"[StoryImageGeneration] Failed to resolve user workspace path for {user_id}: {e}") return self.output_dir diff --git a/backend/services/story_writer/video_generation_service.py b/backend/services/story_writer/video_generation_service.py index 1e48b259..e2bb6a9f 100644 --- a/backend/services/story_writer/video_generation_service.py +++ b/backend/services/story_writer/video_generation_service.py @@ -11,7 +11,12 @@ from pathlib import Path from loguru import logger from fastapi import HTTPException from sqlalchemy.orm import Session -from api.story_writer.utils.media_utils import get_story_media_write_dir + + +def _get_story_media_write_dir(media_type: str, user_id: Optional[str] = None, db: Optional[Session] = None) -> Path: + """Lazy import wrapper to avoid circular imports.""" + from api.story_writer.utils.media_utils import get_story_media_write_dir + return get_story_media_write_dir(media_type, user_id=user_id, db=db) class StoryVideoGenerationService: @@ -29,7 +34,7 @@ class StoryVideoGenerationService: self.output_dir = Path(output_dir) self.output_dir.mkdir(parents=True, exist_ok=True) else: - self.output_dir = get_story_media_write_dir("video") + self.output_dir = _get_story_media_write_dir("video") logger.info(f"[StoryVideoGeneration] Initialized with output directory: {self.output_dir}") def _get_user_video_dir(self, user_id: str, db: Optional[Session] = None) -> Path: @@ -38,7 +43,7 @@ class StoryVideoGenerationService: Falls back to default output_dir if workspace not found. """ try: - return get_story_media_write_dir("video", user_id=user_id, db=db) + return _get_story_media_write_dir("video", user_id=user_id, db=db) except Exception as e: logger.warning(f"[StoryVideoGeneration] Failed to resolve user workspace path for {user_id}: {e}") return self.output_dir diff --git a/backend/start_alwrity_backend.py b/backend/start_alwrity_backend.py index 6d2076e4..58286dd6 100644 --- a/backend/start_alwrity_backend.py +++ b/backend/start_alwrity_backend.py @@ -216,7 +216,7 @@ def start_backend(enable_reload=False, production_mode=False): print("=" * 50) # Set up clean logging for end users - from logging_config import configure_logging, get_uvicorn_log_level + from logging_config import setup_clean_logging, get_uvicorn_log_level # Video stack preflight (diagnostics + version assert) try: from services.story_writer.video_preflight import ( @@ -228,11 +228,7 @@ def start_backend(enable_reload=False, production_mode=False): log_video_stack_diagnostics = None assert_supported_moviepy = None -<<<<<<< HEAD - verbose_mode = configure_logging(verbose_mode=verbose_mode, bootstrap_source="start_alwrity_backend") -======= - verbose_mode = configure_logging(mode="default", app_name="ALwrity") ->>>>>>> pr-422 + verbose_mode = setup_clean_logging() uvicorn_log_level = get_uvicorn_log_level() # Log diagnostics and assert versions (fail fast if misconfigured) diff --git a/backend/utils/logger_utils.py b/backend/utils/logger_utils.py index d569fa27..04752820 100644 --- a/backend/utils/logger_utils.py +++ b/backend/utils/logger_utils.py @@ -2,17 +2,8 @@ Logger utilities to prevent conflicts between different logging configurations. """ -import hashlib -import json from loguru import logger import sys -<<<<<<< HEAD -import hashlib -import json -from typing import Any, Dict, Optional -======= -from typing import Any, Dict, List, Optional ->>>>>>> pr-421 def safe_logger_config(format_string: str, level: str = "INFO"): @@ -60,100 +51,3 @@ def get_service_logger(service_name: str, format_string: str = None): safe_logger_config(format_string) return logger.bind(service=service_name) - - -<<<<<<< HEAD -def _mask_user_id(user_id: Optional[str]) -> str: - """Mask user ID for privacy in logs.""" - if not user_id: - return "anonymous" - return hashlib.sha256(str(user_id).encode("utf-8")).hexdigest()[:12] - - -def emit_routing_event( - logger_instance, - flow_type: str, - *, - route_intent: str = "primary", - provider_selected: str, - model_selected: str, - preferred_provider: Optional[str] = None, - fallback_count: int = 0, - fallback_models_tried: Optional[list] = None, - tenant_user_id: Optional[str] = None, - extra: Optional[Dict[str, Any]] = None, - level: str = "info" -) -> None: - """ - Emit structured routing event for LLM provider selection. - - Args: - logger_instance: Logger instance to use - flow_type: Type of flow (e.g., "sif_agent", "premium_tool") - route_intent: Route intent ("primary" or "fallback") - provider_selected: Selected provider name - model_selected: Selected model name - preferred_provider: Preferred provider (if any) - fallback_count: Number of fallback attempts made - fallback_models_tried: List of models tried as fallbacks - tenant_user_id: Tenant user ID (will be hashed) - extra: Additional fields to include - level: Log level to use - """ - payload: Dict[str, Any] = { - "flow_type": flow_type, - "route_intent": route_intent, -======= -def _mask_tenant_user_id(tenant_user_id: Optional[str]) -> Optional[str]: - """Return a stable hash for a tenant user id so logs avoid exposing raw IDs.""" - if not tenant_user_id: - return None - return hashlib.sha256(tenant_user_id.encode("utf-8")).hexdigest()[:12] - - -def emit_routing_event( - service_logger, - *, - flow_type: str, - route_intent: str, - provider_selected: Optional[str], - model_selected: Optional[str], - preferred_provider: Optional[str], - fallback_count: int = 0, - fallback_models_tried: Optional[List[str]] = None, - tenant_user_id: Optional[str] = None, - event_name: str = "llm_routing_event", - level: str = "INFO", - extra: Optional[Dict[str, Any]] = None, -) -> Dict[str, Any]: - """Emit a standardized structured model-routing event for AI facades.""" - payload: Dict[str, Any] = { - "event_name": event_name, - "flow_type": flow_type, - "route_intent": route_intent, - "flow_type/route_intent": f"{flow_type}/{route_intent}", ->>>>>>> pr-421 - "provider_selected": provider_selected, - "model_selected": model_selected, - "preferred_provider": preferred_provider, - "fallback_count": fallback_count, - "fallback_models_tried": fallback_models_tried or [], -<<<<<<< HEAD - "tenant": _mask_user_id(tenant_user_id), - } - - if extra: - payload.update(extra) - - log_method = getattr(logger_instance, level.lower(), logger_instance.info) - log_method("[llm_routing] {}", json.dumps(payload, sort_keys=True, default=str)) -======= - "tenant_user_id": _mask_tenant_user_id(tenant_user_id), - } - if extra: - payload.update(extra) - - log_method = getattr(service_logger, level.lower(), service_logger.info) - log_method("{}", json.dumps(payload, sort_keys=True)) - return payload ->>>>>>> pr-421