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
This commit is contained in:
ajaysi
2026-03-22 10:45:05 +05:30
parent d412275748
commit d557bd4918
13 changed files with 232 additions and 1179 deletions

View File

@@ -49,12 +49,8 @@ load_dotenv(project_root / '.env') # root .env (fallback)
load_dotenv() # CWD .env (fallback) load_dotenv() # CWD .env (fallback)
# Set up clean logging for end users # Set up clean logging for end users
from logging_config import configure_logging from logging_config import setup_clean_logging
<<<<<<< HEAD setup_clean_logging()
configure_logging(bootstrap_source="asgi-import")
=======
configure_logging(mode="default", app_name="ALwrity")
>>>>>>> pr-422
# Import middleware # Import middleware
from middleware.auth_middleware import get_current_user from middleware.auth_middleware import get_current_user

View File

@@ -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 logging
import os import os
import sys import sys
from typing import Dict, Optional, Tuple
from loguru import logger 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() logger.remove()
if not verbose_mode: if not verbose_mode:
# Suppress verbose logging for end users - be more aggressive # Suppress verbose logging for end users - be more aggressive
logging.getLogger('sqlalchemy.engine').setLevel(logging.CRITICAL) 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( logger.add(
sys.stdout.write, sys.stdout.write,
level="WARNING", 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 filter=warning_only_filter
) )
# Add a focused sink to surface Story Video Generation INFO logs in console # 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 "[video_gen]" in msg
or service == "video_generation_service" or service == "video_generation_service"
or "services.llm_providers.main_video_generation" in name 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 (
"<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | "
"<level>{level: <8}</level> | "
"<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> | "
"rid={extra[request_id]} jid={extra[job_id]} uid={extra[user_id]} | "
"{message}"
)
return (
"<green>{time:HH:mm:ss}</green> | "
"<level>{level: <8}</level> | "
"<cyan>{name}</cyan>:<cyan>{line}</cyan> | "
"{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( logger.add(
sys.stdout, sys.stdout.write,
level="INFO", level="INFO",
<<<<<<< HEAD format="{time:HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}\n",
format=console_format,
filter=_video_generation_filter,
enqueue=True,
=======
format=f"{app_name} | {{time:HH:mm:ss}} | {{level: <8}} | {{name}}:{{function}}:{{line}} - {{message}}\n",
filter=video_generation_filter filter=video_generation_filter
>>>>>>> pr-422
) )
else:
log_file = os.getenv("ALWRITY_LOG_FILE", "").strip() # In verbose mode, show all log levels with detailed formatting
if log_file:
logger.add( 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, sys.stdout.write,
level="DEBUG", 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 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(): def get_uvicorn_log_level():
"""Get appropriate uvicorn log level based on verbose mode.""" """Get appropriate uvicorn log level based on verbose mode."""
verbose_mode = os.getenv("ALWRITY_VERBOSE", "false").lower() == "true" verbose_mode = os.getenv("ALWRITY_VERBOSE", "false").lower() == "true"
>>>>>>> pr-422
return "debug" if verbose_mode else "warning" return "debug" if verbose_mode else "warning"

View File

@@ -49,12 +49,8 @@ load_dotenv(project_root / '.env') # root .env (fallback)
load_dotenv() # CWD .env (fallback) load_dotenv() # CWD .env (fallback)
# Set up clean logging for end users # Set up clean logging for end users
from logging_config import configure_logging from logging_config import setup_clean_logging
<<<<<<< HEAD setup_clean_logging()
configure_logging(bootstrap_source="asgi-import")
=======
configure_logging(mode="default", app_name="ALwrity")
>>>>>>> pr-422
# Import middleware # Import middleware
from middleware.auth_middleware import get_current_user from middleware.auth_middleware import get_current_user

View File

@@ -15,7 +15,6 @@ from loguru import logger
from .txtai_service import TxtaiIntelligenceService, TXTAI_AVAILABLE from .txtai_service import TxtaiIntelligenceService, TXTAI_AVAILABLE
from services.intelligence.agents.core_agent_framework import BaseALwrityAgent from services.intelligence.agents.core_agent_framework import BaseALwrityAgent
from services.llm_providers.main_text_generation import llm_text_gen 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) # Optional txtai imports (align with core agent framework)
try: try:
@@ -35,16 +34,7 @@ class SharedLLMWrapper:
try: try:
# We ignore kwargs like 'max_tokens' as llm_text_gen handles defaults, # We ignore kwargs like 'max_tokens' as llm_text_gen handles defaults,
# but we could map them if needed. # but we could map them if needed.
return llm_text_gen( return llm_text_gen(prompt, user_id=self.user_id)
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
)
except Exception as e: except Exception as e:
logger.error(f"SharedLLMWrapper failed to generate text: {e}") logger.error(f"SharedLLMWrapper failed to generate text: {e}")
return f"[ERROR: Shared LLM generation failed for user {self.user_id}]" return f"[ERROR: Shared LLM generation failed for user {self.user_id}]"
@@ -54,17 +44,6 @@ class SharedLLMWrapper:
_local_llm_cache = {} _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 = [ LOCAL_LLM_FALLBACKS = [
"Qwen/Qwen2.5-1.5B-Instruct", "Qwen/Qwen2.5-1.5B-Instruct",
"Qwen/Qwen2.5-0.5B-Instruct", "Qwen/Qwen2.5-0.5B-Instruct",
@@ -191,8 +170,8 @@ class SIFBaseAgent(BaseALwrityAgent):
def _create_txtai_agent(self): def _create_txtai_agent(self):
""" """
Expose a txtai Agent interface with flat-file context tools. SIF agents primarily use the intelligence service directly, but we can expose
Tools are scoped to the current user workspace via AgentFlatContextStore. capabilities via a standard agent interface if available.
""" """
if not TXTAI_AVAILABLE or Agent is None: if not TXTAI_AVAILABLE or Agent is None:
raise RuntimeError(f"[{self.__class__.__name__}] txtai Agent not available") raise RuntimeError(f"[{self.__class__.__name__}] txtai Agent not available")
@@ -201,103 +180,11 @@ class SIFBaseAgent(BaseALwrityAgent):
_llm_for_agent = self.llm _llm_for_agent = self.llm
for _ in range(3): for _ in range(3):
_llm_for_agent = getattr(_llm_for_agent, "llm", _llm_for_agent) _llm_for_agent = getattr(_llm_for_agent, "llm", _llm_for_agent)
return Agent(llm=_llm_for_agent, tools=[])
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,
},
],
)
except Exception as e: except Exception as e:
logger.error(f"[{self.__class__.__name__}] Failed to create txtai Agent: {e}") logger.error(f"[{self.__class__.__name__}] Failed to create txtai Agent: {e}")
raise 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): class StrategyArchitectAgent(SIFBaseAgent):
"""Agent for discovering content pillars and identifying strategic gaps.""" """Agent for discovering content pillars and identifying strategic gaps."""
@@ -799,25 +686,7 @@ class ContentGuardianAgent(SIFBaseAgent):
if not text: if not text:
return {"compliance_score": 0.0, "issues": ["No text provided"]} return {"compliance_score": 0.0, "issues": ["No text provided"]}
guidelines_source = "provided" if style_guidelines else "none" # 1. Fetch Style Guidelines from SIF if not provided
# 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}")
if not style_guidelines and self.sif_service: if not style_guidelines and self.sif_service:
try: try:
# Search for website analysis to get brand voice/style # Search for website analysis to get brand voice/style
@@ -828,7 +697,7 @@ class ContentGuardianAgent(SIFBaseAgent):
res = results[0] res = results[0]
metadata_str = res.get('object') metadata_str = res.get('object')
metadata = json.loads(metadata_str) if isinstance(metadata_str, str) else (metadata_str or res) metadata = json.loads(metadata_str) if isinstance(metadata_str, str) else (metadata_str or res)
if metadata.get('type') == 'website_analysis': if metadata.get('type') == 'website_analysis':
report = metadata.get('full_report', {}) report = metadata.get('full_report', {})
style_guidelines = { style_guidelines = {
@@ -836,7 +705,6 @@ class ContentGuardianAgent(SIFBaseAgent):
"style_patterns": report.get('style_patterns', {}), "style_patterns": report.get('style_patterns', {}),
"writing_style": report.get('writing_style', {}) "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')}") logger.info(f"[{self.__class__.__name__}] Retrieved style guidelines from SIF: {style_guidelines.get('tone')}")
except Exception as e: except Exception as e:
logger.warning(f"[{self.__class__.__name__}] Failed to retrieve style guidelines from SIF: {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), "compliance_score": max(0.0, score),
"issues": issues, "issues": issues,
"is_compliant": score > 0.8, "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: except Exception as e:

View File

@@ -10,8 +10,6 @@ Key Features:
- Comprehensive error handling and logging - Comprehensive error handling and logging
- Automatic API key management - Automatic API key management
- Support for various Hugging Face models via Inference Providers - Support for various Hugging Face models via Inference Providers
- Explicit fallback model sequences
- Client caching for performance
Best Practices: Best Practices:
1. Use structured output for complex, multi-field responses 1. Use structured output for complex, multi-field responses
@@ -49,24 +47,35 @@ Last Updated: January 2025
""" """
import os import os
import sys
from pathlib import Path
import json import json
import re import re
from functools import lru_cache from typing import Optional, Dict, Any
from typing import Optional, Dict, Any, List
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 loguru import logger
from utils.logger_utils import get_service_logger, emit_routing_event from utils.logger_utils import get_service_logger
<<<<<<< HEAD
from .routing_policy import PREMIUM_DEFAULT_MODEL, SIF_LOW_COST_MODEL_DEFAULTS
=======
>>>>>>> pr-421
# Use service-specific logger to avoid conflicts # Use service-specific logger to avoid conflicts
logger = get_service_logger("huggingface_provider") logger = get_service_logger("huggingface_provider")
from tenacity import ( from tenacity import (
retry, retry,
retry_if_exception,
stop_after_attempt, stop_after_attempt,
wait_random_exponential, wait_random_exponential,
) )
@@ -81,57 +90,13 @@ except ImportError:
logger.warn("OpenAI library not available. Install with: pip install openai") logger.warn("OpenAI library not available. Install with: pip install openai")
HF_FALLBACK_MODELS = [ HF_FALLBACK_MODELS = [
PREMIUM_DEFAULT_MODEL, "openai/gpt-oss-120b:groq",
"moonshotai/Kimi-K2-Instruct-0905:groq", "moonshotai/Kimi-K2-Instruct-0905:groq",
"meta-llama/Llama-3.1-8B-Instruct: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): def _candidate_model_variants(model: str):
"""Yield model ids to try for a single logical model preference.""" """Yield model ids to try for a single logical model preference."""
if not model: if not model:
@@ -147,9 +112,8 @@ def _candidate_model_variants(model: str):
yield base_model yield base_model
def _fallback_model_sequence(model: str, fallback_models: Optional[List[str]] = None): def _fallback_model_sequence(model: str):
"""Generate a sequence of models to try as fallbacks.""" sequence = [model] + HF_FALLBACK_MODELS
sequence = [model] + (fallback_models or HF_FALLBACK_MODELS)
seen = set() seen = set()
for preferred_model in sequence: for preferred_model in sequence:
for candidate in _candidate_model_variants(preferred_model): 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) seen.add(candidate)
yield candidate yield candidate
def get_huggingface_api_key() -> str:
def get_huggingface_api_key(explicit_api_key: Optional[str] = None) -> str:
"""Get Hugging Face API key with proper error handling.""" """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: if not api_key:
error_msg = "HF_TOKEN environment variable is not set. Please set it in your .env file." error_msg = "HF_TOKEN environment variable is not set. Please set it in your .env file."
logger.error(error_msg) logger.error(error_msg)
@@ -174,32 +137,14 @@ def get_huggingface_api_key(explicit_api_key: Optional[str] = None) -> str:
return api_key return api_key
@retry(wait=wait_random_exponential(min=1, max=60), stop=stop_after_attempt(6))
@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),
)
def huggingface_text_response( def huggingface_text_response(
prompt: str, prompt: str,
model: str = PREMIUM_DEFAULT_MODEL, model: str = "openai/gpt-oss-120b:groq",
fallback_models: Optional[List[str]] = None,
temperature: float = 0.7, temperature: float = 0.7,
max_tokens: int = 2048, max_tokens: int = 2048,
top_p: float = 0.9, top_p: float = 0.9,
system_prompt: Optional[str] = None, 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
) -> str: ) -> str:
""" """
Generate text response using Hugging Face Inference Providers API. Generate text response using Hugging Face Inference Providers API.
@@ -209,13 +154,11 @@ def huggingface_text_response(
Args: Args:
prompt (str): The input prompt for the AI model prompt (str): The input prompt for the AI model
model (str): Hugging Face model identifier (default: PREMIUM_DEFAULT_MODEL) model (str): Hugging Face model identifier (default: "openai/gpt-oss-120b:groq")
fallback_models (list, optional): Custom fallback models to try
temperature (float): Controls randomness (0.0-1.0) temperature (float): Controls randomness (0.0-1.0)
max_tokens (int): Maximum tokens in response max_tokens (int): Maximum tokens in response
top_p (float): Nucleus sampling parameter (0.0-1.0) top_p (float): Nucleus sampling parameter (0.0-1.0)
system_prompt (str, optional): System instruction for the model system_prompt (str, optional): System instruction for the model
api_key (str, optional): Explicit API key override
Returns: Returns:
str: Generated text response str: Generated text response
@@ -228,17 +171,32 @@ def huggingface_text_response(
- Set max_tokens based on expected response length - Set max_tokens based on expected response length
- Use system_prompt to guide model behavior - Use system_prompt to guide model behavior
- Handle errors gracefully in calling functions - 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: try:
if not OPENAI_AVAILABLE: if not OPENAI_AVAILABLE:
raise ImportError("OpenAI library not available. Install with: pip install openai") raise ImportError("OpenAI library not available. Install with: pip install openai")
# Get API key with proper error handling # Get API key with proper error handling
hf_api_key = get_huggingface_api_key(api_key) api_key = get_huggingface_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})") 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 # 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") logger.info("✅ Hugging Face client initialized for text response")
# Prepare input for the API # Prepare input for the API
@@ -269,41 +227,13 @@ def huggingface_text_response(
logger.info("🚀 Making Hugging Face API call (chat completion)...") 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 response = None
last_error = 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): 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: try:
response = client.chat.completions.create( response = client.chat.completions.create(
model=candidate_model, model=candidate_model,
@@ -313,67 +243,41 @@ def huggingface_text_response(
max_tokens=max_tokens max_tokens=max_tokens
) )
if candidate_model != model: 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 break
except NotFoundError as nf_err: except NotFoundError as nf_err:
last_error = 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) logger.warning("HF model not found: {}. Trying fallback model.", candidate_model)
>>>>>>> pr-421
continue continue
if response is None: 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 # Extract text from response
generated_text = response.choices[0].message.content or "" generated_text = response.choices[0].message.content
# Clean up the response # Clean up the response
generated_text = re.sub(r'```[a-zA-Z]*\n?', '', generated_text) if generated_text:
generated_text = re.sub(r'```\n?', '', generated_text) # Remove any markdown formatting if present
generated_text = generated_text.strip() 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)})") logger.info(f"✅ Hugging Face text response generated successfully (length: {len(generated_text)})")
return generated_text return generated_text
except Exception as exc: except Exception as e:
details = _error_details(exc) logger.error(f"❌ Hugging Face text generation failed: {str(e)}")
logger.error( raise Exception(f"Hugging Face text generation failed: {str(e)}")
"❌ 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
@retry(wait=wait_random_exponential(min=1, max=60), stop=stop_after_attempt(6))
@retry(
retry=retry_if_exception(_should_retry_hf_error),
wait=wait_random_exponential(min=1, max=60),
stop=stop_after_attempt(6),
)
def huggingface_structured_json_response( def huggingface_structured_json_response(
prompt: str, prompt: str,
schema: Dict[str, Any], schema: Dict[str, Any],
model: str = PREMIUM_DEFAULT_MODEL, model: str = "openai/gpt-oss-120b:groq",
fallback_models: Optional[List[str]] = None,
temperature: float = 0.7, temperature: float = 0.7,
max_tokens: int = 8192, max_tokens: int = 8192,
system_prompt: Optional[str] = None, 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
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Generate structured JSON response using Hugging Face Inference Providers API. Generate structured JSON response using Hugging Face Inference Providers API.
@@ -384,12 +288,10 @@ def huggingface_structured_json_response(
Args: Args:
prompt (str): The input prompt for the AI model prompt (str): The input prompt for the AI model
schema (dict): JSON schema defining the expected output structure schema (dict): JSON schema defining the expected output structure
model (str): Hugging Face model identifier (default: PREMIUM_DEFAULT_MODEL) model (str): Hugging Face model identifier (default: "openai/gpt-oss-120b:groq")
fallback_models (list, optional): Custom fallback models to try
temperature (float): Controls randomness (0.0-1.0). Use 0.1-0.3 for structured output 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 max_tokens (int): Maximum tokens in response. Use 8192 for complex outputs
system_prompt (str, optional): System instruction for the model system_prompt (str, optional): System instruction for the model
api_key (str, optional): Explicit API key override
Returns: Returns:
dict: Parsed JSON response matching the provided schema 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 - Set max_tokens to 8192 for complex multi-field responses
- Avoid deeply nested schemas with many required fields - Avoid deeply nested schemas with many required fields
- Test with smaller outputs first, then scale up - 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: try:
if not OPENAI_AVAILABLE: if not OPENAI_AVAILABLE:
raise ImportError("OpenAI library not available. Install with: pip install openai") raise ImportError("OpenAI library not available. Install with: pip install openai")
# Get API key with proper error handling # Get API key with proper error handling
hf_api_key = get_huggingface_api_key(api_key) api_key = get_huggingface_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})") 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 # 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") logger.info("✅ Hugging Face client initialized for structured JSON response")
# Prepare input for the API # Prepare input for the API
@@ -427,6 +354,7 @@ def huggingface_structured_json_response(
}) })
# Add user prompt with JSON instruction # 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." json_instruction = "Please respond with valid JSON that matches the provided schema."
messages.append({ messages.append({
"role": "user", "role": "user",
@@ -445,14 +373,13 @@ def huggingface_structured_json_response(
logger.info("🚀 Making Hugging Face structured API call...") 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 # Add JSON schema to prompt for guidance
json_schema_str = json.dumps(schema, indent=2) json_schema_str = json.dumps(schema, indent=2)
messages[-1]["content"] += f"\n\nJSON Schema:\n{json_schema_str}" 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 # Add rate limiting to prevent expensive API calls
import time import time
time.sleep(1) # 1 second delay between API calls time.sleep(1) # 1 second delay between API calls
@@ -460,23 +387,7 @@ def huggingface_structured_json_response(
try: try:
response = None response = None
last_error = None last_error = None
fallback_models_tried = []
fallback_count = 0
for candidate_model in _fallback_model_sequence(model): 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: try:
response = client.chat.completions.create( response = client.chat.completions.create(
model=candidate_model, model=candidate_model,
@@ -490,45 +401,23 @@ def huggingface_structured_json_response(
break break
except NotFoundError as nf_err: except NotFoundError as nf_err:
last_error = nf_err last_error = nf_err
fallback_count += 1
logger.warning("HF structured model not found: {}. Trying fallback model.", candidate_model) logger.warning("HF structured model not found: {}. Trying fallback model.", candidate_model)
continue continue
>>>>>>> pr-421
for candidate_model in _fallback_model_sequence(model, fallback_models): if response is None:
# Emit routing event for each model attempt raise last_error or Exception("Hugging Face structured generation failed: all fallback models failed")
route_intent = "primary" if candidate_model == model else "fallback"
emit_routing_event( response_text = response.choices[0].message.content
logger,
flow_type="huggingface_structured", # Clean up response text if needed
route_intent=route_intent, response_text = response_text.strip()
provider_selected="huggingface", if response_text.startswith("```json"):
model_selected=candidate_model, response_text = response_text[7:]
tenant_user_id=tenant_user_id, if response_text.endswith("```"):
extra={"original_model": model, "api_call": True, "response_format": "json_object"} response_text = response_text[:-3]
) response_text = response_text.strip()
try: 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) parsed_json = json.loads(response_text)
logger.info("✅ Hugging Face structured JSON response parsed successfully") logger.info("✅ Hugging Face structured JSON response parsed successfully")
return parsed_json return parsed_json
@@ -556,75 +445,43 @@ def huggingface_structured_json_response(
response = None response = None
last_error = None last_error = None
for candidate_model in _fallback_model_sequence(model): 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: try:
response = client.chat.completions.create( response = client.chat.completions.create(
model=candidate_model, model=candidate_model,
messages=messages, messages=messages,
temperature=temperature, temperature=temperature,
max_tokens=max_tokens, max_tokens=max_tokens
) )
if candidate_model != model: 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 break
<<<<<<< HEAD
except Exception as second_err:
last_error = second_err
=======
except NotFoundError as nf_err: except NotFoundError as nf_err:
last_error = nf_err last_error = nf_err
fallback_count += 1
logger.warning("HF structured model not found (no response_format path): {}", candidate_model) logger.warning("HF structured model not found (no response_format path): {}", candidate_model)
>>>>>>> pr-421
continue continue
if response is None: if response is None:
raise last_error or RuntimeError("All fallback models failed") 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() except Exception as e:
error_msg = str(e) if str(e) else repr(e)
# Clean up response text if needed error_type = type(e).__name__
if response_text.startswith("```json"): logger.error(f"❌ Hugging Face structured JSON generation failed: {error_type}: {error_msg}")
response_text = response_text[7:] logger.error(f"❌ Full exception details: {repr(e)}")
if response_text.endswith("```"): import traceback
response_text = response_text[:-3] logger.error(f"❌ Traceback: {traceback.format_exc()}")
response_text = response_text.strip() raise Exception(f"Hugging Face structured JSON generation failed: {error_type}: {error_msg}")
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
def get_available_models() -> list: def get_available_models() -> list:
""" """
@@ -634,15 +491,14 @@ def get_available_models() -> list:
list: List of available model identifiers list: List of available model identifiers
""" """
return [ return [
PREMIUM_DEFAULT_MODEL, "openai/gpt-oss-120b:groq",
"moonshotai/Kimi-K2-Instruct-0905:groq", "moonshotai/Kimi-K2-Instruct-0905:groq",
"Qwen/Qwen2.5-VL-7B-Instruct", "Qwen/Qwen2.5-VL-7B-Instruct",
"meta-llama/Llama-3.1-8B-Instruct:groq", "meta-llama/Llama-3.1-8B-Instruct:groq",
"microsoft/Phi-3-medium-4k-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: def validate_model(model: str) -> bool:
""" """
Validate if a model identifier is supported. Validate if a model identifier is supported.

View File

@@ -2,8 +2,6 @@
This service provides the main LLM text generation functionality, This service provides the main LLM text generation functionality,
migrated from the legacy lib/gpt_providers/text_generation/main_text_generation.py 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 import os
@@ -13,47 +11,9 @@ from datetime import datetime
from loguru import logger from loguru import logger
from fastapi import HTTPException 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 .gemini_provider import gemini_text_response, gemini_structured_json_response
from .huggingface_provider import huggingface_text_response, huggingface_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 .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( def llm_text_gen(
@@ -93,14 +53,17 @@ def llm_text_gen(
frequency_penalty = 0.0 frequency_penalty = 0.0
presence_penalty = 0.0 presence_penalty = 0.0
# Check for GPT_PROVIDER environment variable provider_cfg = tenant_provider_config_resolver.resolve(
env_provider = os.getenv('GPT_PROVIDER', '').lower() modality="text",
if env_provider in ['gemini', 'google']: user_id=user_id,
)
selected_provider = (provider_cfg.selected_providers or [None])[0]
if selected_provider in ["gemini", "google"]:
gpt_provider = "google" gpt_provider = "google"
model = "gemini-2.0-flash-001" model = provider_cfg.model_policy.get("default_model") or "gemini-2.0-flash-001"
elif env_provider in ['hf_response_api', 'huggingface', 'hf']: elif selected_provider == "huggingface":
gpt_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 # Default blog characteristics
blog_tone = "Professional" blog_tone = "Professional"
@@ -110,64 +73,32 @@ def llm_text_gen(
blog_output_format = "markdown" blog_output_format = "markdown"
blog_length = 2000 blog_length = 2000
# Check which providers have API keys available using APIKeyManager
api_key_manager = APIKeyManager()
available_providers = [] available_providers = []
if api_key_manager.get_api_key("gemini"): for provider in ("google", "huggingface"):
available_providers.append("google") if get_api_key(provider, user_id=user_id):
if api_key_manager.get_api_key("hf_token"): available_providers.append(provider)
available_providers.append("huggingface")
preferred_provider = env_provider or None if gpt_provider not in available_providers:
flow_type = "text_generation" logger.warning(f"[llm_text_gen] Provider {gpt_provider} unavailable for user {user_id}, falling back.")
route_intent = "primary" if available_providers:
fallback_count = 0 gpt_provider = available_providers[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"
else: else:
logger.error("[llm_text_gen] No API keys found for supported providers.") 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.") raise RuntimeError("No LLM API keys configured for tenant or environment defaults.")
else:
# Environment variable was set, validate it's supported # Ensure downstream provider clients (currently env-based) receive resolved key
if gpt_provider not in available_providers: resolved_key = get_api_key(gpt_provider, user_id=user_id)
logger.warning(f"[llm_text_gen] Provider {gpt_provider} not available, falling back to available providers") if gpt_provider == "google" and resolved_key:
if "google" in available_providers: os.environ["GEMINI_API_KEY"] = resolved_key
gpt_provider = "google" os.environ.setdefault("GOOGLE_API_KEY", resolved_key)
model = "gemini-2.0-flash-001" elif gpt_provider == "huggingface" and resolved_key:
elif "huggingface" in available_providers: os.environ["HF_TOKEN"] = resolved_key
gpt_provider = "huggingface"
model = "mistralai/Mistral-7B-Instruct-v0.3:groq"
else:
raise RuntimeError("No supported providers available.")
if gpt_provider == "huggingface" and preferred_hf_models: if gpt_provider == "huggingface" and preferred_hf_models:
model = preferred_hf_models[0] model = preferred_hf_models[0]
logger.info(f"[llm_text_gen] Using preferred low-cost HF model: {model}") 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}") 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) # Map provider name to APIProvider enum (define at function scope for usage tracking)
from models.subscription_models import APIProvider from models.subscription_models import APIProvider
@@ -311,8 +242,7 @@ def llm_text_gen(
model=model, model=model,
temperature=temperature, temperature=temperature,
max_tokens=max_tokens, max_tokens=max_tokens,
system_prompt=system_instructions, system_prompt=system_instructions
tenant_user_id=user_id
) )
else: else:
response_text = huggingface_text_response( response_text = huggingface_text_response(
@@ -321,8 +251,7 @@ def llm_text_gen(
temperature=temperature, temperature=temperature,
max_tokens=max_tokens, max_tokens=max_tokens,
top_p=top_p, top_p=top_p,
system_prompt=system_instructions, system_prompt=system_instructions
tenant_user_id=user_id
) )
else: else:
logger.error(f"[llm_text_gen] Unknown provider: {gpt_provider}") logger.error(f"[llm_text_gen] Unknown provider: {gpt_provider}")
@@ -366,34 +295,17 @@ def llm_text_gen(
try: try:
logger.info(f"[llm_text_gen] Trying SINGLE fallback provider: {fallback_provider}") logger.info(f"[llm_text_gen] Trying SINGLE fallback provider: {fallback_provider}")
actual_provider_used = fallback_provider actual_provider_used = fallback_provider
fallback_count += 1
route_intent = "fallback"
# Update provider enum for fallback # Update provider enum for fallback
if fallback_provider == "google": if fallback_provider == "google":
provider_enum = APIProvider.GEMINI provider_enum = APIProvider.GEMINI
actual_provider_name = "gemini" actual_provider_name = "gemini"
fallback_model = "gemini-2.0-flash-lite" fallback_model = "gemini-2.0-flash-lite"
fallback_models_tried.append(fallback_model)
elif fallback_provider == "huggingface": elif fallback_provider == "huggingface":
provider_enum = APIProvider.MISTRAL provider_enum = APIProvider.MISTRAL
actual_provider_name = "huggingface" actual_provider_name = "huggingface"
fallback_model = "mistralai/Mistral-7B-Instruct-v0.3:groq" 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 fallback_provider == "google":
if json_struct: if json_struct:
response_text = gemini_structured_json_response( response_text = gemini_structured_json_response(
@@ -422,8 +334,7 @@ def llm_text_gen(
model="mistralai/Mistral-7B-Instruct-v0.3:groq", model="mistralai/Mistral-7B-Instruct-v0.3:groq",
temperature=temperature, temperature=temperature,
max_tokens=max_tokens, max_tokens=max_tokens,
system_prompt=system_instructions, system_prompt=system_instructions
tenant_user_id=user_id
) )
else: else:
response_text = huggingface_text_response( response_text = huggingface_text_response(
@@ -432,8 +343,7 @@ def llm_text_gen(
temperature=temperature, temperature=temperature,
max_tokens=max_tokens, max_tokens=max_tokens,
top_p=top_p, top_p=top_p,
system_prompt=system_instructions, system_prompt=system_instructions
tenant_user_id=user_id
) )
# TRACK USAGE after successful fallback call # TRACK USAGE after successful fallback call
@@ -472,18 +382,16 @@ def check_gpt_provider(gpt_provider: str) -> bool:
supported_providers = ["google", "huggingface"] supported_providers = ["google", "huggingface"]
return gpt_provider in supported_providers 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.""" """Get API key for the specified provider."""
try: try:
api_key_manager = APIKeyManager()
provider_mapping = { provider_mapping = {
"google": "gemini", "google": "gemini",
"huggingface": "hf_token" "huggingface": "huggingface"
} }
mapped_provider = provider_mapping.get(gpt_provider, gpt_provider) 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: except Exception as e:
logger.error(f"[get_api_key] Error getting API key for {gpt_provider}: {str(e)}") logger.error(f"[get_api_key] Error getting API key for {gpt_provider}: {str(e)}")
return None return None
>>>>>>> pr-421

View File

@@ -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 from __future__ import annotations
import os import os
@@ -251,4 +166,3 @@ class TenantProviderConfigResolver:
tenant_provider_config_resolver = TenantProviderConfigResolver() tenant_provider_config_resolver = TenantProviderConfigResolver()
>>>>>>> pr-420

View File

@@ -6,24 +6,10 @@ Extracts ALL onboarding data and provides personalized defaults for forms and re
from typing import Dict, Any, Optional, List from typing import Dict, Any, Optional, List
from loguru import logger 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 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: class PersonalizationService:
""" """
Service for extracting user preferences from onboarding data Service for extracting user preferences from onboarding data
@@ -34,14 +20,6 @@ class PersonalizationService:
"""Initialize Personalization Service.""" """Initialize Personalization Service."""
self.logger = logger self.logger = logger
logger.info("[Personalization Service] Initialized") 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]: def get_user_preferences(self, user_id: str) -> Dict[str, Any]:
""" """
@@ -58,50 +36,20 @@ class PersonalizationService:
- templates: Recommended templates for user's industry - templates: Recommended templates for user's industry
- channels: Recommended channels based on platform personas - channels: Recommended channels based on platform personas
""" """
db = None db = SessionLocal()
try: 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() integration_service = OnboardingDataIntegrationService()
<<<<<<< HEAD
integrated_data = integration_service.get_integrated_data_sync(user_id, db) 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', {}) 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 # Map strictly from Canonical Profile
preferences = { preferences = {
"industry": canonical_profile.get("industry"), "industry": canonical_profile.get("industry"),
<<<<<<< HEAD "target_audience": canonical_profile.get("target_audience", {}),
"target_audience": self._as_dict(canonical_profile.get("target_audience", {})), "platform_preferences": canonical_profile.get("platform_preferences", []),
"platform_preferences": self._as_list(canonical_profile.get("platform_preferences", [])), "content_preferences": canonical_profile.get("content_types", []),
"content_preferences": self._as_list(canonical_profile.get("content_types", [])), "style_preferences": canonical_profile.get("visual_style", {}),
"style_preferences": self._as_dict(canonical_profile.get("visual_style", {})), "brand_colors": canonical_profile.get("brand_colors", []),
"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
"recommended_templates": [], "recommended_templates": [],
"recommended_channels": [], "recommended_channels": [],
"writing_style": { "writing_style": {
@@ -110,11 +58,7 @@ class PersonalizationService:
"complexity": canonical_profile.get("writing_complexity", "intermediate"), "complexity": canonical_profile.get("writing_complexity", "intermediate"),
"engagement_level": canonical_profile.get("writing_engagement", "moderate"), "engagement_level": canonical_profile.get("writing_engagement", "moderate"),
}, },
<<<<<<< HEAD "brand_values": canonical_profile.get("brand_values", []),
"brand_values": self._as_list(canonical_profile.get("brand_values", [])),
=======
"brand_values": _ensure_list(canonical_profile.get("brand_values")),
>>>>>>> pr-416
} }
# Ensure target_audience structure # Ensure target_audience structure
@@ -150,7 +94,7 @@ class PersonalizationService:
if not preferences["recommended_channels"]: if not preferences["recommended_channels"]:
preferences["recommended_channels"] = self._get_recommended_channels( preferences["recommended_channels"] = self._get_recommended_channels(
preferences.get("industry"), 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')}") 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) logger.error(f"[Personalization] Error getting user preferences: {str(e)}", exc_info=True)
return self._get_default_preferences() return self._get_default_preferences()
finally: finally:
if db: db.close()
db.close()
def get_personalized_defaults( def get_personalized_defaults(
self, self,

View File

@@ -11,7 +11,12 @@ from pathlib import Path
from loguru import logger from loguru import logger
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy.orm import Session 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: class StoryAudioGenerationService:
@@ -29,7 +34,7 @@ class StoryAudioGenerationService:
self.output_dir = Path(output_dir) self.output_dir = Path(output_dir)
self.output_dir.mkdir(parents=True, exist_ok=True) self.output_dir.mkdir(parents=True, exist_ok=True)
else: 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}") 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: 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. Falls back to default output_dir if workspace not found.
""" """
try: 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: except Exception as e:
logger.warning(f"[StoryAudioGeneration] Failed to resolve user workspace path for {user_id}: {e}") logger.warning(f"[StoryAudioGeneration] Failed to resolve user workspace path for {user_id}: {e}")
return self.output_dir return self.output_dir

View File

@@ -15,11 +15,16 @@ from sqlalchemy.orm import Session
from services.llm_providers.main_image_generation import generate_image from services.llm_providers.main_image_generation import generate_image
from services.llm_providers.image_generation import ImageGenerationResult from services.llm_providers.image_generation import ImageGenerationResult
from utils.logger_utils import get_service_logger 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") 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: class StoryImageGenerationService:
"""Service for generating images for story scenes.""" """Service for generating images for story scenes."""
@@ -35,7 +40,7 @@ class StoryImageGenerationService:
self.output_dir = Path(output_dir) self.output_dir = Path(output_dir)
self.output_dir.mkdir(parents=True, exist_ok=True) self.output_dir.mkdir(parents=True, exist_ok=True)
else: 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}") 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: 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. Falls back to default output_dir if workspace not found.
""" """
try: 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: except Exception as e:
logger.warning(f"[StoryImageGeneration] Failed to resolve user workspace path for {user_id}: {e}") logger.warning(f"[StoryImageGeneration] Failed to resolve user workspace path for {user_id}: {e}")
return self.output_dir return self.output_dir

View File

@@ -11,7 +11,12 @@ from pathlib import Path
from loguru import logger from loguru import logger
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy.orm import Session 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: class StoryVideoGenerationService:
@@ -29,7 +34,7 @@ class StoryVideoGenerationService:
self.output_dir = Path(output_dir) self.output_dir = Path(output_dir)
self.output_dir.mkdir(parents=True, exist_ok=True) self.output_dir.mkdir(parents=True, exist_ok=True)
else: 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}") 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: 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. Falls back to default output_dir if workspace not found.
""" """
try: 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: except Exception as e:
logger.warning(f"[StoryVideoGeneration] Failed to resolve user workspace path for {user_id}: {e}") logger.warning(f"[StoryVideoGeneration] Failed to resolve user workspace path for {user_id}: {e}")
return self.output_dir return self.output_dir

View File

@@ -216,7 +216,7 @@ def start_backend(enable_reload=False, production_mode=False):
print("=" * 50) print("=" * 50)
# Set up clean logging for end users # 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) # Video stack preflight (diagnostics + version assert)
try: try:
from services.story_writer.video_preflight import ( 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 log_video_stack_diagnostics = None
assert_supported_moviepy = None assert_supported_moviepy = None
<<<<<<< HEAD verbose_mode = setup_clean_logging()
verbose_mode = configure_logging(verbose_mode=verbose_mode, bootstrap_source="start_alwrity_backend")
=======
verbose_mode = configure_logging(mode="default", app_name="ALwrity")
>>>>>>> pr-422
uvicorn_log_level = get_uvicorn_log_level() uvicorn_log_level = get_uvicorn_log_level()
# Log diagnostics and assert versions (fail fast if misconfigured) # Log diagnostics and assert versions (fail fast if misconfigured)

View File

@@ -2,17 +2,8 @@
Logger utilities to prevent conflicts between different logging configurations. Logger utilities to prevent conflicts between different logging configurations.
""" """
import hashlib
import json
from loguru import logger from loguru import logger
import sys 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"): 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) safe_logger_config(format_string)
return logger.bind(service=service_name) 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