Fix circular import in __init__.py (#35)
This commit is contained in:
@@ -8,3 +8,138 @@ Quick Start:
|
|||||||
|
|
||||||
For full documentation, see README.md
|
For full documentation, see README.md
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# Lazy imports to avoid circular dependency on cold starts
|
||||||
|
# See: https://peps.python.org/pep-0562/
|
||||||
|
def __getattr__(name: str):
|
||||||
|
"""Lazy import for module-level attributes to break circular imports."""
|
||||||
|
|
||||||
|
# Core exports
|
||||||
|
if name == "OpusOrchestrator":
|
||||||
|
from opus_orchestrator.orchestrator import OpusOrchestrator
|
||||||
|
return OpusOrchestrator
|
||||||
|
if name == "run_opus":
|
||||||
|
from opus_orchestrator.langgraph_workflow import run_opus
|
||||||
|
return run_opus
|
||||||
|
if name == "OpusConfig":
|
||||||
|
from opus_orchestrator.config import OpusConfig
|
||||||
|
return OpusConfig
|
||||||
|
if name == "get_config":
|
||||||
|
from opus_orchestrator.config import get_config
|
||||||
|
return get_config
|
||||||
|
if name == "set_config":
|
||||||
|
from opus_orchestrator.config import set_config
|
||||||
|
return set_config
|
||||||
|
if name == "OpusState":
|
||||||
|
from opus_orchestrator.state import OpusState
|
||||||
|
return OpusState
|
||||||
|
if name == "NonfictionGenerator":
|
||||||
|
from opus_orchestrator.nonfiction_generator import NonfictionGenerator
|
||||||
|
return NonfictionGenerator
|
||||||
|
if name == "OpusLogger":
|
||||||
|
from opus_orchestrator.logging import OpusLogger
|
||||||
|
return OpusLogger
|
||||||
|
if name == "get_logger":
|
||||||
|
from opus_orchestrator.logging import get_logger
|
||||||
|
return get_logger
|
||||||
|
|
||||||
|
# Frameworks
|
||||||
|
if name == "StoryFramework":
|
||||||
|
from opus_orchestrator.frameworks import StoryFramework
|
||||||
|
return StoryFramework
|
||||||
|
if name == "FRAMEWORKS":
|
||||||
|
from opus_orchestrator.frameworks import FRAMEWORKS
|
||||||
|
return FRAMEWORKS
|
||||||
|
if name == "get_framework_for_genre":
|
||||||
|
from opus_orchestrator.frameworks import get_framework_for_genre
|
||||||
|
return get_framework_for_genre
|
||||||
|
if name == "get_framework_prompt":
|
||||||
|
from opus_orchestrator.frameworks import get_framework_prompt
|
||||||
|
return get_framework_prompt
|
||||||
|
|
||||||
|
# Schemas
|
||||||
|
if name == "BookBlueprint":
|
||||||
|
from opus_orchestrator.schemas import BookBlueprint
|
||||||
|
return BookBlueprint
|
||||||
|
if name == "BookIntent":
|
||||||
|
from opus_orchestrator.schemas import BookIntent
|
||||||
|
return BookIntent
|
||||||
|
if name == "BookType":
|
||||||
|
from opus_orchestrator.schemas import BookType
|
||||||
|
return BookType
|
||||||
|
if name == "Chapter":
|
||||||
|
from opus_orchestrator.schemas import Chapter
|
||||||
|
return Chapter
|
||||||
|
if name == "ChapterBlueprint":
|
||||||
|
from opus_orchestrator.schemas import ChapterBlueprint
|
||||||
|
return ChapterBlueprint
|
||||||
|
if name == "ChapterCritique":
|
||||||
|
from opus_orchestrator.schemas import ChapterCritique
|
||||||
|
return ChapterCritique
|
||||||
|
if name == "ChapterDraft":
|
||||||
|
from opus_orchestrator.schemas import ChapterDraft
|
||||||
|
return ChapterDraft
|
||||||
|
if name == "Manuscript":
|
||||||
|
from opus_orchestrator.schemas import Manuscript
|
||||||
|
return Manuscript
|
||||||
|
if name == "RawContent":
|
||||||
|
from opus_orchestrator.schemas import RawContent
|
||||||
|
return RawContent
|
||||||
|
|
||||||
|
# Utilities
|
||||||
|
if name == "LLMClient":
|
||||||
|
from opus_orchestrator.utils.llm import LLMClient
|
||||||
|
return LLMClient
|
||||||
|
if name == "get_llm_client":
|
||||||
|
from opus_orchestrator.utils.llm import get_llm_client
|
||||||
|
return get_llm_client
|
||||||
|
if name == "RetryHandler":
|
||||||
|
from opus_orchestrator.utils.retry import RetryHandler
|
||||||
|
return RetryHandler
|
||||||
|
if name == "CircuitBreaker":
|
||||||
|
from opus_orchestrator.utils.retry import CircuitBreaker
|
||||||
|
return CircuitBreaker
|
||||||
|
if name == "with_retry":
|
||||||
|
from opus_orchestrator.utils.retry import with_retry
|
||||||
|
return with_retry
|
||||||
|
|
||||||
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||||
|
|
||||||
|
|
||||||
|
__version__ = "0.2.0"
|
||||||
|
|
||||||
|
# Explicit __all__ for static analysis and IDE support
|
||||||
|
__all__ = [
|
||||||
|
# Core
|
||||||
|
"OpusOrchestrator",
|
||||||
|
"OpusConfig",
|
||||||
|
"get_config",
|
||||||
|
"set_config",
|
||||||
|
"OpusState",
|
||||||
|
"NonfictionGenerator",
|
||||||
|
"run_opus",
|
||||||
|
# Logging
|
||||||
|
"OpusLogger",
|
||||||
|
"get_logger",
|
||||||
|
# Frameworks
|
||||||
|
"StoryFramework",
|
||||||
|
"FRAMEWORKS",
|
||||||
|
"get_framework_for_genre",
|
||||||
|
"get_framework_prompt",
|
||||||
|
# Schemas
|
||||||
|
"BookBlueprint",
|
||||||
|
"BookIntent",
|
||||||
|
"BookType",
|
||||||
|
"Chapter",
|
||||||
|
"ChapterBlueprint",
|
||||||
|
"ChapterCritique",
|
||||||
|
"ChapterDraft",
|
||||||
|
"Manuscript",
|
||||||
|
"RawContent",
|
||||||
|
# Utilities
|
||||||
|
"LLMClient",
|
||||||
|
"get_llm_client",
|
||||||
|
"RetryHandler",
|
||||||
|
"CircuitBreaker",
|
||||||
|
"with_retry",
|
||||||
|
]
|
||||||
|
|||||||
@@ -87,7 +87,8 @@ class BaseAgent(ABC, Generic[T]):
|
|||||||
"""
|
"""
|
||||||
temp = temperature if temperature is not None else self.config.temperature
|
temp = temperature if temperature is not None else self.config.temperature
|
||||||
|
|
||||||
return await self.llm_client.complete(
|
# Use async version for async context
|
||||||
|
return await self.llm_client.complete_async(
|
||||||
system_prompt=system_prompt,
|
system_prompt=system_prompt,
|
||||||
user_prompt=user_prompt,
|
user_prompt=user_prompt,
|
||||||
temperature=temp,
|
temperature=temp,
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ Provide:
|
|||||||
if numbers:
|
if numbers:
|
||||||
score = float(numbers[0])
|
score = float(numbers[0])
|
||||||
break
|
break
|
||||||
except:
|
except (ValueError, IndexError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return AgentResponse(
|
return AgentResponse(
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ from typing import Any, Optional
|
|||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
from opus_orchestrator.agents.base import BaseAgent, AgentResponse
|
from opus_orchestrator.agents.base import BaseAgent, AgentResponse
|
||||||
from opus_orchestrator.utils.research import (
|
from opus_orchestrator.utils.research import (
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ from typing import Any, Optional
|
|||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
from autogen import ConversableAgent, GroupChat, GroupChatManager
|
from autogen import ConversableAgent, GroupChat, GroupChatManager
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ from typing import Any, Optional
|
|||||||
from crewai import Agent, Crew, LLM, Process, Task
|
from crewai import Agent, Crew, LLM, Process, Task
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
from opus_orchestrator.config import get_config
|
from opus_orchestrator.config import get_config
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ from typing import Any, Optional
|
|||||||
from crewai import Agent, Process
|
from crewai import Agent, Process
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
from opus_orchestrator.crews.base_crew import OpusCrew
|
from opus_orchestrator.crews.base_crew import OpusCrew
|
||||||
from opus_orchestrator.config import get_config
|
from opus_orchestrator.config import get_config
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ from typing import Any, Optional
|
|||||||
from crewai import Agent, Process
|
from crewai import Agent, Process
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
from opus_orchestrator.crews.base_crew import OpusCrew
|
from opus_orchestrator.crews.base_crew import OpusCrew
|
||||||
from opus_orchestrator.config import get_config
|
from opus_orchestrator.config import get_config
|
||||||
|
|||||||
@@ -140,11 +140,15 @@ class OpusGraph:
|
|||||||
self.framework = framework
|
self.framework = framework
|
||||||
self.genre = genre
|
self.genre = genre
|
||||||
self.target_word_count = target_word_count
|
self.target_word_count = target_word_count
|
||||||
self.api_key = api_key or os.environ.get("OPENAI_API_KEY")
|
|
||||||
|
# Get API key and provider from environment
|
||||||
|
self.api_key = api_key or os.environ.get("MINIMAX_API_KEY") or os.environ.get("OPENAI_API_KEY")
|
||||||
|
self.provider = os.environ.get("OPUS_PROVIDER", "minimax")
|
||||||
|
self.model = os.environ.get("OPUS_MODEL", "MiniMax-M2.5")
|
||||||
self.use_autogen = use_autogen
|
self.use_autogen = use_autogen
|
||||||
|
|
||||||
# Use synchronous LLM
|
# Use synchronous LLM with config
|
||||||
self.llm = LLMClient(api_key=self.api_key, provider="openai", model="gpt-4o")
|
self.llm = LLMClient(api_key=self.api_key, provider=self.provider, model=self.model)
|
||||||
|
|
||||||
# AutoGen critique crew
|
# AutoGen critique crew
|
||||||
self.critique_crew = None
|
self.critique_crew = None
|
||||||
|
|||||||
+130
-46
@@ -1,61 +1,145 @@
|
|||||||
"""Logging configuration for Opus.
|
"""Structured Logging for Opus Orchestrator.
|
||||||
|
|
||||||
Structured logging with levels, formats, and handlers.
|
Provides JSON-formatted logging for production environments.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from datetime import datetime
|
||||||
|
from typing import Any, Optional
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
def setup_logging(
|
class LogLevel(str, Enum):
|
||||||
level: str = "INFO",
|
"""Log levels."""
|
||||||
log_file: str = None,
|
DEBUG = "DEBUG"
|
||||||
format: str = "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
|
INFO = "INFO"
|
||||||
) -> logging.Logger:
|
WARNING = "WARNING"
|
||||||
"""Setup structured logging for Opus.
|
ERROR = "ERROR"
|
||||||
|
CRITICAL = "CRITICAL"
|
||||||
|
|
||||||
|
|
||||||
|
class StructuredFormatter(logging.Formatter):
|
||||||
|
"""JSON formatter for structured logging."""
|
||||||
|
|
||||||
|
def format(self, record: logging.LogRecord) -> str:
|
||||||
|
"""Format log record as JSON."""
|
||||||
|
log_data = {
|
||||||
|
"timestamp": datetime.utcnow().isoformat() + "Z",
|
||||||
|
"level": record.levelname,
|
||||||
|
"logger": record.name,
|
||||||
|
"message": record.getMessage(),
|
||||||
|
"module": record.module,
|
||||||
|
"function": record.funcName,
|
||||||
|
"line": record.lineno,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add exception info if present
|
||||||
|
if record.exc_info:
|
||||||
|
log_data["exception"] = self.formatException(record.exc_info)
|
||||||
|
|
||||||
|
# Add extra fields
|
||||||
|
if hasattr(record, "extra"):
|
||||||
|
log_data.update(record.extra)
|
||||||
|
|
||||||
|
return json.dumps(log_data)
|
||||||
|
|
||||||
|
|
||||||
|
class OpusLogger:
|
||||||
|
"""Structured logger for Opus."""
|
||||||
|
|
||||||
|
def __init__(self, name: str, level: str = "INFO"):
|
||||||
|
"""Initialize logger.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Logger name (usually module name)
|
||||||
|
level: Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||||
|
"""
|
||||||
|
self.logger = logging.getLogger(name)
|
||||||
|
self.logger.setLevel(getattr(logging, level.upper()))
|
||||||
|
|
||||||
|
# Only add handler once
|
||||||
|
if not self.logger.handlers:
|
||||||
|
handler = logging.StreamHandler(sys.stdout)
|
||||||
|
handler.setFormatter(StructuredFormatter())
|
||||||
|
self.logger.addHandler(handler)
|
||||||
|
|
||||||
|
def debug(self, message: str, **kwargs: Any) -> None:
|
||||||
|
"""Log debug message."""
|
||||||
|
self.logger.debug(message, extra={"extra": kwargs} if kwargs else {})
|
||||||
|
|
||||||
|
def info(self, message: str, **kwargs: Any) -> None:
|
||||||
|
"""Log info message."""
|
||||||
|
self.logger.info(message, extra={"extra": kwargs} if kwargs else {})
|
||||||
|
|
||||||
|
def warning(self, message: str, **kwargs: Any) -> None:
|
||||||
|
"""Log warning message."""
|
||||||
|
self.logger.warning(message, extra={"extra": kwargs} if kwargs else {})
|
||||||
|
|
||||||
|
def error(self, message: str, **kwargs: Any) -> None:
|
||||||
|
"""Log error message."""
|
||||||
|
self.logger.error(message, extra={"extra": kwargs} if kwargs else {})
|
||||||
|
|
||||||
|
def critical(self, message: str, **kwargs: Any) -> None:
|
||||||
|
"""Log critical message."""
|
||||||
|
self.logger.critical(message, extra={"extra": kwargs} if kwargs else {})
|
||||||
|
|
||||||
|
def log_request(self, method: str, path: str, status_code: int, duration_ms: float) -> None:
|
||||||
|
"""Log HTTP request."""
|
||||||
|
self.info(
|
||||||
|
"HTTP Request",
|
||||||
|
method=method,
|
||||||
|
path=path,
|
||||||
|
status_code=status_code,
|
||||||
|
duration_ms=duration_ms,
|
||||||
|
)
|
||||||
|
|
||||||
|
def log_llm_request(self, provider: str, model: str, duration_ms: float, success: bool) -> None:
|
||||||
|
"""Log LLM API request."""
|
||||||
|
self.info(
|
||||||
|
"LLM Request",
|
||||||
|
provider=provider,
|
||||||
|
model=model,
|
||||||
|
duration_ms=duration_ms,
|
||||||
|
success=success,
|
||||||
|
)
|
||||||
|
|
||||||
|
def log_generation(self, book_type: str, genre: str, word_count: int, duration_s: float) -> None:
|
||||||
|
"""Log book generation."""
|
||||||
|
self.info(
|
||||||
|
"Book Generation",
|
||||||
|
book_type=book_type,
|
||||||
|
genre=genre,
|
||||||
|
word_count=word_count,
|
||||||
|
duration_s=duration_s,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_logger(name: str, level: Optional[str] = None) -> OpusLogger:
|
||||||
|
"""Get a structured logger.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
level: DEBUG, INFO, WARNING, ERROR
|
name: Logger name
|
||||||
log_file: Optional file path
|
level: Optional log level override
|
||||||
format: Log message format
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Configured logger
|
OpusLogger instance
|
||||||
"""
|
"""
|
||||||
# Create logger
|
import os
|
||||||
logger = logging.getLogger("opus")
|
env_level = os.environ.get("OPUS_LOG_LEVEL", level or "INFO")
|
||||||
logger.setLevel(getattr(logging, level.upper()))
|
return OpusLogger(name, env_level)
|
||||||
|
|
||||||
# Clear existing handlers
|
|
||||||
logger.handlers.clear()
|
|
||||||
|
|
||||||
# Console handler
|
|
||||||
console = logging.StreamHandler(sys.stdout)
|
|
||||||
console.setLevel(getattr(logging, level.upper()))
|
|
||||||
console.setFormatter(logging.Formatter(format))
|
|
||||||
logger.addHandler(console)
|
|
||||||
|
|
||||||
# File handler (optional)
|
|
||||||
if log_file:
|
|
||||||
log_path = Path(log_file)
|
|
||||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
file_handler = logging.FileHandler(log_file)
|
|
||||||
file_handler.setLevel(logging.DEBUG)
|
|
||||||
file_handler.setFormatter(logging.Formatter(
|
|
||||||
"%(asctime)s | %(levelname)-8s | %(name)s:%(lineno)d | %(message)s"
|
|
||||||
))
|
|
||||||
logger.addHandler(file_handler)
|
|
||||||
|
|
||||||
return logger
|
|
||||||
|
|
||||||
|
|
||||||
# Default logger
|
# Convenience function for quick logging
|
||||||
logger = setup_logging()
|
def log(name: str, level: str, message: str, **kwargs: Any) -> None:
|
||||||
|
"""Quick logging function.
|
||||||
|
|
||||||
|
Args:
|
||||||
# Usage in modules:
|
name: Logger name
|
||||||
# from opus_orchestrator.logging import logger
|
level: Log level
|
||||||
# logger.info("Starting generation")
|
message: Log message
|
||||||
# logger.error(f"Failed: {e}")
|
**kwargs: Additional context
|
||||||
|
"""
|
||||||
|
logger = get_logger(name)
|
||||||
|
getattr(logger, level.lower())(message, **kwargs)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Usage:
|
|||||||
from opus_orchestrator.nonfiction.classifier import PurposeClassifier, ReaderPurpose
|
from opus_orchestrator.nonfiction.classifier import PurposeClassifier, ReaderPurpose
|
||||||
|
|
||||||
classifier = PurposeClassifier()
|
classifier = PurposeClassifier()
|
||||||
result = await classifier.classify(
|
result = classifier._keyword_classify(
|
||||||
concept="Leadership for introverts",
|
concept="Leadership for introverts",
|
||||||
target_audience="Introverted professionals who want to develop leadership skills",
|
target_audience="Introverted professionals who want to develop leadership skills",
|
||||||
intended_outcome="Learn to lead with quiet confidence"
|
intended_outcome="Learn to lead with quiet confidence"
|
||||||
@@ -19,20 +19,11 @@ Usage:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
import json
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import Enum
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
# Import ReaderPurpose from taxonomy to avoid duplicate enum definitions
|
||||||
class ReaderPurpose(str, Enum):
|
from opus_orchestrator.nonfiction_taxonomy import ReaderPurpose
|
||||||
"""Why is the reader reading this book?"""
|
|
||||||
LEARN_HANDS_ON = "learn_hands_on"
|
|
||||||
UNDERSTAND = "understand"
|
|
||||||
TRANSFORM = "transform"
|
|
||||||
DECIDE = "decide"
|
|
||||||
REFERENCE = "reference"
|
|
||||||
BE_INSPIRED = "be_inspired"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ CREATIVE_FRAMEWORKS = {
|
|||||||
"description": "Branching narrative where reader choices determine the story path. Multiple endings based on decisions.",
|
"description": "Branching narrative where reader choices determine the story path. Multiple endings based on decisions.",
|
||||||
"purpose": ReaderPurpose.BE_INSPIRED,
|
"purpose": ReaderPurpose.BE_INSPIRED,
|
||||||
"structure": StructuralPattern.NARRATIVE,
|
"structure": StructuralPattern.NARRATIVE,
|
||||||
"category": NonfictionCategory.CREATIVE,
|
"category": NonfictionCategory.CREATIVITY,
|
||||||
"stages": [
|
"stages": [
|
||||||
"Introduction - Set the scene",
|
"Introduction - Set the scene",
|
||||||
"Opening Choice - First decision point",
|
"Opening Choice - First decision point",
|
||||||
@@ -59,7 +59,7 @@ Include:
|
|||||||
"description": "RPG-style adventure with combat, stats, and inventory. Reader/player is the protagonist.",
|
"description": "RPG-style adventure with combat, stats, and inventory. Reader/player is the protagonist.",
|
||||||
"purpose": ReaderPurpose.BE_INSPIRED,
|
"purpose": ReaderPurpose.BE_INSPIRED,
|
||||||
"structure": StructuralPattern.NARRATIVE,
|
"structure": StructuralPattern.NARRATIVE,
|
||||||
"category": NonfictionCategory.CREATIVE,
|
"category": NonfictionCategory.CREATIVITY,
|
||||||
"stages": [
|
"stages": [
|
||||||
"Character Creation - Stats, skills",
|
"Character Creation - Stats, skills",
|
||||||
"Equipment List - Starting items",
|
"Equipment List - Starting items",
|
||||||
@@ -95,7 +95,7 @@ Include:
|
|||||||
"description": "Game script with character dialogues, scene descriptions, and choice points. Anime/VN style.",
|
"description": "Game script with character dialogues, scene descriptions, and choice points. Anime/VN style.",
|
||||||
"purpose": ReaderPurpose.BE_INSPIRED,
|
"purpose": ReaderPurpose.BE_INSPIRED,
|
||||||
"structure": StructuralPattern.NARRATIVE,
|
"structure": StructuralPattern.NARRATIVE,
|
||||||
"category": NonfictionCategory.CREATIVE,
|
"category": NonfictionCategory.CREATIVITY,
|
||||||
"stages": [
|
"stages": [
|
||||||
"Title Screen / Opening",
|
"Title Screen / Opening",
|
||||||
"Prologue",
|
"Prologue",
|
||||||
@@ -135,7 +135,7 @@ Include:
|
|||||||
"description": "Story told entirely through documents: letters, emails, texts, diary entries, etc.",
|
"description": "Story told entirely through documents: letters, emails, texts, diary entries, etc.",
|
||||||
"purpose": ReaderPurpose.BE_INSPIRED,
|
"purpose": ReaderPurpose.BE_INSPIRED,
|
||||||
"structure": StructuralPattern.NARRATIVE,
|
"structure": StructuralPattern.NARRATIVE,
|
||||||
"category": NonfictionCategory.CREATIVE,
|
"category": NonfictionCategory.CREATIVITY,
|
||||||
"stages": [
|
"stages": [
|
||||||
"Editor's Note - Frame narrative",
|
"Editor's Note - Frame narrative",
|
||||||
"Document 1: Letter/Email/Text",
|
"Document 1: Letter/Email/Text",
|
||||||
@@ -170,7 +170,7 @@ Include:
|
|||||||
"description": "A story revealed through discovered materials: redacted files, journal fragments, annotated maps.",
|
"description": "A story revealed through discovered materials: redacted files, journal fragments, annotated maps.",
|
||||||
"purpose": ReaderPurpose.BE_INSPIRED,
|
"purpose": ReaderPurpose.BE_INSPIRED,
|
||||||
"structure": StructuralPattern.NARRATIVE,
|
"structure": StructuralPattern.NARRATIVE,
|
||||||
"category": NonfictionCategory.CREATIVE,
|
"category": NonfictionCategory.CREATIVITY,
|
||||||
"stages": [
|
"stages": [
|
||||||
"Foreword - How these were found",
|
"Foreword - How these were found",
|
||||||
"Artifact 1: Document type",
|
"Artifact 1: Document type",
|
||||||
@@ -207,7 +207,7 @@ Include:
|
|||||||
"description": "Revolutionary call to action. A passionate argument for change.",
|
"description": "Revolutionary call to action. A passionate argument for change.",
|
||||||
"purpose": ReaderPurpose.BE_INSPIRED,
|
"purpose": ReaderPurpose.BE_INSPIRED,
|
||||||
"structure": StructuralPattern.ARGUMENT,
|
"structure": StructuralPattern.ARGUMENT,
|
||||||
"category": NonfictionCategory.CREATIVE,
|
"category": NonfictionCategory.CREATIVITY,
|
||||||
"stages": [
|
"stages": [
|
||||||
"Opening - The problem we've ignored",
|
"Opening - The problem we've ignored",
|
||||||
"Part 1: What Is - Current state",
|
"Part 1: What Is - Current state",
|
||||||
@@ -242,7 +242,7 @@ Include:
|
|||||||
"description": "Public letter to a specific person/entity. Makes a point through direct address.",
|
"description": "Public letter to a specific person/entity. Makes a point through direct address.",
|
||||||
"purpose": ReaderPurpose.DECIDE,
|
"purpose": ReaderPurpose.DECIDE,
|
||||||
"structure": StructuralPattern.ARGUMENT,
|
"structure": StructuralPattern.ARGUMENT,
|
||||||
"category": NonfictionCategory.CREATIVE,
|
"category": NonfictionCategory.CREATIVITY,
|
||||||
"stages": [
|
"stages": [
|
||||||
"Salutation - Dear [Name/Entity]",
|
"Salutation - Dear [Name/Entity]",
|
||||||
"Opening - Why I'm writing publicly",
|
"Opening - Why I'm writing publicly",
|
||||||
@@ -279,7 +279,7 @@ Include:
|
|||||||
"description": "Endlessly extensible story. Each chapter ends with a new beginning. For serialization.",
|
"description": "Endlessly extensible story. Each chapter ends with a new beginning. For serialization.",
|
||||||
"purpose": ReaderPurpose.BE_INSPIRED,
|
"purpose": ReaderPurpose.BE_INSPIRED,
|
||||||
"structure": StructuralPattern.NARRATIVE,
|
"structure": StructuralPattern.NARRATIVE,
|
||||||
"category": NonfictionCategory.CREATIVE,
|
"category": NonfictionCategory.CREATIVITY,
|
||||||
"stages": [
|
"stages": [
|
||||||
"Episode 1: Complete arc",
|
"Episode 1: Complete arc",
|
||||||
"Episode 1 Cliffhanger - Link to next",
|
"Episode 1 Cliffhanger - Link to next",
|
||||||
@@ -313,7 +313,7 @@ Include:
|
|||||||
"description": "Story that repeats at different scales. Chapter mirrors scene mirrors paragraph.",
|
"description": "Story that repeats at different scales. Chapter mirrors scene mirrors paragraph.",
|
||||||
"purpose": ReaderPurpose.BE_INSPIRED,
|
"purpose": ReaderPurpose.BE_INSPIRED,
|
||||||
"structure": StructuralPattern.NARRATIVE,
|
"structure": StructuralPattern.NARRATIVE,
|
||||||
"category": NonfictionCategory.CREATIVE,
|
"category": NonfictionCategory.CREATIVITY,
|
||||||
"stages": [
|
"stages": [
|
||||||
"Macro Level: The Overall Arc",
|
"Macro Level: The Overall Arc",
|
||||||
"Structure mirrors down",
|
"Structure mirrors down",
|
||||||
@@ -344,7 +344,7 @@ Include:
|
|||||||
"description": "Non-linear collection of fragments: memories, images (described), ticket stubs, recipes, etc.",
|
"description": "Non-linear collection of fragments: memories, images (described), ticket stubs, recipes, etc.",
|
||||||
"purpose": ReaderPurpose.BE_INSPIRED,
|
"purpose": ReaderPurpose.BE_INSPIRED,
|
||||||
"structure": StructuralPattern.SPIRAL,
|
"structure": StructuralPattern.SPIRAL,
|
||||||
"category": NonfictionCategory.CREATIVE,
|
"category": NonfictionCategory.CREATIVITY,
|
||||||
"stages": [
|
"stages": [
|
||||||
"Opening Spread - First impressions",
|
"Opening Spread - First impressions",
|
||||||
"Fragment 1: Memory/Image",
|
"Fragment 1: Memory/Image",
|
||||||
@@ -381,7 +381,7 @@ Include:
|
|||||||
"description": "Script for spoken audio. Includes banter, segments, transitions.",
|
"description": "Script for spoken audio. Includes banter, segments, transitions.",
|
||||||
"purpose": ReaderPurpose.LEARN_HANDS_ON,
|
"purpose": ReaderPurpose.LEARN_HANDS_ON,
|
||||||
"structure": StructuralPattern.SEQUENTIAL,
|
"structure": StructuralPattern.SEQUENTIAL,
|
||||||
"category": NonfictionCategory.CREATIVE,
|
"category": NonfictionCategory.CREATIVITY,
|
||||||
"stages": [
|
"stages": [
|
||||||
"Intro - Hook + branding",
|
"Intro - Hook + branding",
|
||||||
"Cold Open - Tease",
|
"Cold Open - Tease",
|
||||||
@@ -415,7 +415,7 @@ Include:
|
|||||||
"description": "Hollywood standard screenplay format. Visual storytelling through action and dialogue.",
|
"description": "Hollywood standard screenplay format. Visual storytelling through action and dialogue.",
|
||||||
"purpose": ReaderPurpose.BE_INSPIRED,
|
"purpose": ReaderPurpose.BE_INSPIRED,
|
||||||
"structure": StructuralPattern.NARRATIVE,
|
"structure": StructuralPattern.NARRATIVE,
|
||||||
"category": NonfictionCategory.CREATIVE,
|
"category": NonfictionCategory.CREATIVITY,
|
||||||
"stages": [
|
"stages": [
|
||||||
"Title Page",
|
"Title Page",
|
||||||
"Fade In",
|
"Fade In",
|
||||||
@@ -451,7 +451,7 @@ Include:
|
|||||||
"description": "Theatrical script with scenes, stage directions, and dialogue.",
|
"description": "Theatrical script with scenes, stage directions, and dialogue.",
|
||||||
"purpose": ReaderPurpose.BE_INSPIRED,
|
"purpose": ReaderPurpose.BE_INSPIRED,
|
||||||
"structure": StructuralPattern.NARRATIVE,
|
"structure": StructuralPattern.NARRATIVE,
|
||||||
"category": NonfictionCategory.CREATIVE,
|
"category": NonfictionCategory.CREATIVITY,
|
||||||
"stages": [
|
"stages": [
|
||||||
"Title Page",
|
"Title Page",
|
||||||
"Dramatis Personae - Characters",
|
"Dramatis Personae - Characters",
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ from typing import Any, Optional
|
|||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
from opus_orchestrator.nonfiction_frameworks import (
|
from opus_orchestrator.nonfiction_frameworks import (
|
||||||
NonfictionFramework,
|
NonfictionFramework,
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ class NonfictionCategory(str, Enum):
|
|||||||
CREATIVITY = "creativity"
|
CREATIVITY = "creativity"
|
||||||
SPIRITUALITY = "spirituality"
|
SPIRITUALITY = "spirituality"
|
||||||
HOW_TO = "how_to"
|
HOW_TO = "how_to"
|
||||||
|
EDUCATION = "education"
|
||||||
|
ACADEMIC = "academic"
|
||||||
|
RPG = "rpg"
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ from typing import Any, Optional
|
|||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
from opus_orchestrator.agents.fiction import (
|
from opus_orchestrator.agents.fiction import (
|
||||||
ArchitectAgent,
|
ArchitectAgent,
|
||||||
@@ -347,8 +346,6 @@ Generate a detailed outline with:
|
|||||||
Returns:
|
Returns:
|
||||||
RawContent with the combined text from the repo
|
RawContent with the combined text from the repo
|
||||||
"""
|
"""
|
||||||
from opus_orchestrator.utils.github_ingest import GitHubIngestor
|
|
||||||
|
|
||||||
print(f"📥 Loading from GitHub: {repo}")
|
print(f"📥 Loading from GitHub: {repo}")
|
||||||
|
|
||||||
github_token = self.config.github_token or os.environ.get("GITHUB_TOKEN")
|
github_token = self.config.github_token or os.environ.get("GITHUB_TOKEN")
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ from pydantic import BaseModel
|
|||||||
from pydantic_ai import Agent
|
from pydantic_ai import Agent
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
from opus_orchestrator.config import get_config
|
from opus_orchestrator.config import get_config
|
||||||
|
|
||||||
|
|||||||
@@ -7,18 +7,46 @@ import os
|
|||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, BackgroundTasks, UploadFile, File, StreamingResponse
|
from fastapi import FastAPI, HTTPException, BackgroundTasks, UploadFile, File, StreamingResponse, Depends, Security
|
||||||
from fastapi.responses import JSONResponse, RedirectResponse
|
from fastapi.responses import JSONResponse, RedirectResponse
|
||||||
|
from fastapi.security import APIKeyHeader
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
from opus_orchestrator.config import get_config
|
from opus_orchestrator.config import get_config
|
||||||
from opus_orchestrator import run_opus, OpusOrchestrator
|
from opus_orchestrator import run_opus, OpusOrchestrator
|
||||||
from opus_orchestrator.frameworks import FRAMEWORKS
|
from opus_orchestrator.frameworks import FRAMEWORKS
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# AUTHENTICATION
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
API_KEY_HEADER = APIKeyHeader(name="X-API-Key", auto_error=False)
|
||||||
|
|
||||||
|
async def get_api_key(api_key: str = Security(API_KEY_HEADER)) -> str:
|
||||||
|
"""Validate API key from header or environment.
|
||||||
|
|
||||||
|
If no API key is configured (for development), allow all requests.
|
||||||
|
Set OPUS_API_KEY environment variable to protect production endpoints.
|
||||||
|
"""
|
||||||
|
configured_key = os.environ.get("OPUS_API_KEY")
|
||||||
|
|
||||||
|
# No key configured - allow all (development mode)
|
||||||
|
if not configured_key:
|
||||||
|
return "dev"
|
||||||
|
|
||||||
|
# Key configured - validate
|
||||||
|
if api_key is None:
|
||||||
|
raise HTTPException(status_code=401, detail="API key required. Set X-API-Key header.")
|
||||||
|
|
||||||
|
if api_key != configured_key:
|
||||||
|
raise HTTPException(status_code=403, detail="Invalid API key")
|
||||||
|
|
||||||
|
return api_key
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# REQUEST/RESPONSE MODELS
|
# REQUEST/RESPONSE MODELS
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -194,7 +222,7 @@ async def list_frameworks():
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/generate", response_model=GenerateResponse, tags=["generate"])
|
@app.post("/generate", response_model=GenerateResponse, tags=["generate"])
|
||||||
async def generate(request: GenerateRequest, background_tasks: BackgroundTasks):
|
async def generate(request: GenerateRequest, background_tasks: BackgroundTasks, api_key: str = Depends(get_api_key)):
|
||||||
"""Generate a manuscript from concept or GitHub repo."""
|
"""Generate a manuscript from concept or GitHub repo."""
|
||||||
import traceback
|
import traceback
|
||||||
try:
|
try:
|
||||||
@@ -302,7 +330,7 @@ async def generate_stream(request: GenerateRequest):
|
|||||||
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
||||||
|
|
||||||
@app.post("/ingest", response_model=IngestResponse, tags=["ingest"])
|
@app.post("/ingest", response_model=IngestResponse, tags=["ingest"])
|
||||||
async def ingest(request: IngestRequest):
|
async def ingest(request: IngestRequest, api_key: str = Depends(get_api_key)):
|
||||||
"""Ingest content from a GitHub repository."""
|
"""Ingest content from a GitHub repository."""
|
||||||
try:
|
try:
|
||||||
orch = OpusOrchestrator(book_type="fiction")
|
orch = OpusOrchestrator(book_type="fiction")
|
||||||
@@ -351,7 +379,7 @@ class S3UploadResponse(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/upload", response_model=UploadResponse, tags=["upload"])
|
@app.post("/upload", response_model=UploadResponse, tags=["upload"])
|
||||||
async def upload_file(file: UploadFile = File(...)):
|
async def upload_file(file: UploadFile = File(...), api_key: str = Depends(get_api_key)):
|
||||||
"""Upload a file for processing."""
|
"""Upload a file for processing."""
|
||||||
try:
|
try:
|
||||||
content = await file.read()
|
content = await file.read()
|
||||||
@@ -368,7 +396,7 @@ async def upload_file(file: UploadFile = File(...)):
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/upload/s3", response_model=S3UploadResponse, tags=["upload"])
|
@app.post("/upload/s3", response_model=S3UploadResponse, tags=["upload"])
|
||||||
async def upload_to_s3(request: S3UploadRequest):
|
async def upload_to_s3(request: S3UploadRequest, api_key: str = Depends(get_api_key)):
|
||||||
"""Upload content to S3-compatible storage."""
|
"""Upload content to S3-compatible storage."""
|
||||||
try:
|
try:
|
||||||
from opus_orchestrator import S3Ingestor
|
from opus_orchestrator import S3Ingestor
|
||||||
@@ -441,3 +469,64 @@ if __name__ == "__main__":
|
|||||||
|
|
||||||
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8000
|
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8000
|
||||||
uvicorn.run(app, host="0.0.0.0", port=port)
|
uvicorn.run(app, host="0.0.0.0", port=port)
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# RATE LIMITING
|
||||||
|
# =============================================================================
|
||||||
|
from fastapi import Request
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
import time
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""Simple in-memory rate limiter."""
|
||||||
|
|
||||||
|
def __init__(self, app, requests_per_minute: int = 30):
|
||||||
|
super().__init__(app)
|
||||||
|
self.requests_per_minute = requests_per_minute
|
||||||
|
self.requests = defaultdict(list)
|
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next):
|
||||||
|
# Skip rate limiting for health check
|
||||||
|
if request.url.path == "/health":
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
client_ip = request.client.host if request.client else "unknown"
|
||||||
|
current_time = time.time()
|
||||||
|
|
||||||
|
# Clean old requests (older than 1 minute)
|
||||||
|
self.requests[client_ip] = [
|
||||||
|
t for t in self.requests[client_ip]
|
||||||
|
if current_time - t < 60
|
||||||
|
]
|
||||||
|
|
||||||
|
# Check rate limit
|
||||||
|
if len(self.requests[client_ip]) >= self.requests_per_minute:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=429,
|
||||||
|
content={"detail": "Rate limit exceeded. Please try again later."}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Record this request
|
||||||
|
self.requests[client_ip].append(current_time)
|
||||||
|
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
|
||||||
|
# Get rate limit from environment, default to 30/minute
|
||||||
|
_rate_limit = int(os.environ.get("RATE_LIMIT_PER_MINUTE", "30"))
|
||||||
|
app.add_middleware(RateLimitMiddleware, requests_per_minute=_rate_limit)
|
||||||
|
|
||||||
|
# CORS middleware - secure configuration
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
# Get allowed origins from environment, default to restricted set
|
||||||
|
_cors_origins = os.environ.get("CORS_ORIGINS", "").split(",") if os.environ.get("CORS_ORIGINS") else []
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=_cors_origins if _cors_origins else ["http://localhost:3000", "http://localhost:8000"],
|
||||||
|
allow_credentials=True if _cors_origins else False,
|
||||||
|
allow_methods=["GET", "POST", "PUT", "DELETE"],
|
||||||
|
allow_headers=["Content-Type", "Authorization"],
|
||||||
|
)
|
||||||
|
|||||||
@@ -284,7 +284,7 @@ Just respond with a number between 0.0 and 1.0."""
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
return float(response.strip())
|
return float(response.strip())
|
||||||
except:
|
except (ValueError, TypeError):
|
||||||
return 0.5
|
return 0.5
|
||||||
|
|
||||||
def _simple_relevance(self, content: str, purpose: CrawlPurpose) -> float:
|
def _simple_relevance(self, content: str, purpose: CrawlPurpose) -> float:
|
||||||
|
|||||||
@@ -9,9 +9,7 @@ import re
|
|||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from dotenv import load_dotenv
|
# Note: dotenv loading removed - set GITHUB_TOKEN environment variable directly
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
|
|
||||||
class GitHubIngestor:
|
class GitHubIngestor:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""LLM client for Opus Orchestrator.
|
"""LLM client for Opus Orchestrator.
|
||||||
|
|
||||||
Supports MiniMax and OpenAI providers - both async and sync.
|
Supports MiniMax and OpenAI providers - both async and sync.
|
||||||
|
Includes retry logic with exponential backoff and circuit breaker.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -10,9 +11,14 @@ from typing import Any, Optional
|
|||||||
import httpx
|
import httpx
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
from opus_orchestrator.utils.retry import RetryHandler, RetryConfig
|
||||||
|
|
||||||
|
|
||||||
class LLMClient:
|
class LLMClient:
|
||||||
"""Simple LLM client for making API calls - supports both sync and async."""
|
"""Simple LLM client for making API calls - supports both sync and async.
|
||||||
|
|
||||||
|
Includes built-in retry logic with circuit breaker for resilience.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -20,8 +26,17 @@ class LLMClient:
|
|||||||
provider: str = "minimax",
|
provider: str = "minimax",
|
||||||
model: str = "MiniMax/MiniMax-M2.1",
|
model: str = "MiniMax/MiniMax-M2.1",
|
||||||
base_url: Optional[str] = None,
|
base_url: Optional[str] = None,
|
||||||
|
max_retries: int = 3,
|
||||||
):
|
):
|
||||||
"""Initialize LLM client."""
|
"""Initialize LLM client.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
api_key: API key for the provider
|
||||||
|
provider: LLM provider (minimax, openai)
|
||||||
|
model: Model name
|
||||||
|
base_url: Optional custom base URL
|
||||||
|
max_retries: Maximum retry attempts (default 3)
|
||||||
|
"""
|
||||||
self.api_key = api_key or os.environ.get("MINIMAX_API_KEY") or os.environ.get("OPENAI_API_KEY")
|
self.api_key = api_key or os.environ.get("MINIMAX_API_KEY") or os.environ.get("OPENAI_API_KEY")
|
||||||
self.provider = provider
|
self.provider = provider
|
||||||
self.model = model
|
self.model = model
|
||||||
@@ -34,7 +49,8 @@ class LLMClient:
|
|||||||
if base_url:
|
if base_url:
|
||||||
self.base_url = base_url
|
self.base_url = base_url
|
||||||
elif provider == "minimax":
|
elif provider == "minimax":
|
||||||
self.base_url = "https://api.minimax.chat/v1"
|
# Use Anthropic-compatible API (like OpenClaw uses)
|
||||||
|
self.base_url = "https://api.minimax.io/anthropic"
|
||||||
elif provider == "openai":
|
elif provider == "openai":
|
||||||
self.base_url = "https://api.openai.com/v1"
|
self.base_url = "https://api.openai.com/v1"
|
||||||
else:
|
else:
|
||||||
@@ -43,6 +59,16 @@ class LLMClient:
|
|||||||
# Async client
|
# Async client
|
||||||
self._async_client = httpx.AsyncClient(timeout=120.0)
|
self._async_client = httpx.AsyncClient(timeout=120.0)
|
||||||
|
|
||||||
|
# Initialize retry handler
|
||||||
|
retry_config = RetryConfig(
|
||||||
|
max_attempts=max_retries,
|
||||||
|
base_delay=1.0,
|
||||||
|
max_delay=30.0,
|
||||||
|
exponential_base=2.0,
|
||||||
|
jitter=True,
|
||||||
|
)
|
||||||
|
self._retry_handler = RetryHandler(retry_config)
|
||||||
|
|
||||||
def complete(
|
def complete(
|
||||||
self,
|
self,
|
||||||
system_prompt: str,
|
system_prompt: str,
|
||||||
@@ -74,24 +100,33 @@ class LLMClient:
|
|||||||
temperature: float = 0.7,
|
temperature: float = 0.7,
|
||||||
max_tokens: Optional[int] = None,
|
max_tokens: Optional[int] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Make a completion request (ASYNC)."""
|
"""Make a completion request (ASYNC) with retry logic."""
|
||||||
headers = {
|
|
||||||
"Authorization": f"Bearer {self.api_key}",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.provider == "minimax":
|
async def _make_request():
|
||||||
return await self._complete_minimax_async(
|
headers = {
|
||||||
system_prompt, user_prompt, temperature, max_tokens, headers
|
"Authorization": f"Bearer {self.api_key}",
|
||||||
)
|
"Content-Type": "application/json",
|
||||||
elif self.provider == "openai":
|
}
|
||||||
return await self._complete_openai_async(
|
|
||||||
system_prompt, user_prompt, temperature, max_tokens, headers
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Unsupported provider: {self.provider}")
|
|
||||||
|
|
||||||
async def _complete_minimax(
|
if self.provider == "minimax":
|
||||||
|
return await self._complete_minimax_async(
|
||||||
|
system_prompt, user_prompt, temperature, max_tokens, headers
|
||||||
|
)
|
||||||
|
elif self.provider == "openai":
|
||||||
|
return await self._complete_openai_async(
|
||||||
|
system_prompt, user_prompt, temperature, max_tokens, headers
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unsupported provider: {self.provider}")
|
||||||
|
|
||||||
|
# Use retry handler for resilience
|
||||||
|
try:
|
||||||
|
return await self._retry_handler.execute_with_retry(_make_request)
|
||||||
|
except Exception as e:
|
||||||
|
# Log and re-raise with context
|
||||||
|
raise RuntimeError(f"LLM request failed after retries: {e}") from e
|
||||||
|
|
||||||
|
async def _complete_minimax_async(
|
||||||
self,
|
self,
|
||||||
system_prompt: str,
|
system_prompt: str,
|
||||||
user_prompt: str,
|
user_prompt: str,
|
||||||
@@ -99,8 +134,8 @@ class LLMClient:
|
|||||||
max_tokens: Optional[int],
|
max_tokens: Optional[int],
|
||||||
headers: dict,
|
headers: dict,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Call MiniMax API."""
|
"""Call MiniMax API using Anthropic-compatible endpoint."""
|
||||||
# MiniMax chat completion format
|
# Anthropic-compatible format
|
||||||
payload = {
|
payload = {
|
||||||
"model": self.minimax_model,
|
"model": self.minimax_model,
|
||||||
"messages": [
|
"messages": [
|
||||||
@@ -113,9 +148,10 @@ class LLMClient:
|
|||||||
if max_tokens:
|
if max_tokens:
|
||||||
payload["max_tokens"] = max_tokens
|
payload["max_tokens"] = max_tokens
|
||||||
|
|
||||||
|
# Use Anthropic-compatible endpoint
|
||||||
response = await self._async_client.post(
|
response = await self._async_client.post(
|
||||||
f"{self.base_url}/text/chatcompletion_v2",
|
f"{self.base_url}/v1/messages",
|
||||||
headers=headers,
|
headers={**headers, "Content-Type": "application/json"},
|
||||||
json=payload,
|
json=payload,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -127,13 +163,13 @@ class LLMClient:
|
|||||||
|
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
# Handle different response formats
|
# Handle Anthropic-compatible response format
|
||||||
if "choices" in data:
|
if "content" in data:
|
||||||
return data["choices"][0]["message"]["content"]
|
# Return the text content
|
||||||
elif "choices" in data.get("data", {}):
|
if isinstance(data["content"], list) and len(data["content"]) > 0:
|
||||||
return data["data"]["choices"][0]["message"]["content"]
|
return data["content"][0].get("text", str(data["content"][0]))
|
||||||
|
return str(data["content"])
|
||||||
else:
|
else:
|
||||||
# Try to find content in response
|
|
||||||
raise Exception(f"Unexpected MiniMax response: {data}")
|
raise Exception(f"Unexpected MiniMax response: {data}")
|
||||||
|
|
||||||
async def _complete_openai(
|
async def _complete_openai(
|
||||||
@@ -183,7 +219,7 @@ class LLMClient:
|
|||||||
max_tokens: Optional[int],
|
max_tokens: Optional[int],
|
||||||
headers: dict,
|
headers: dict,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Call MiniMax API (sync)."""
|
"""Call MiniMax API (sync) using Anthropic-compatible endpoint."""
|
||||||
payload = {
|
payload = {
|
||||||
"model": self.minimax_model,
|
"model": self.minimax_model,
|
||||||
"messages": [
|
"messages": [
|
||||||
@@ -196,9 +232,10 @@ class LLMClient:
|
|||||||
if max_tokens:
|
if max_tokens:
|
||||||
payload["max_tokens"] = max_tokens
|
payload["max_tokens"] = max_tokens
|
||||||
|
|
||||||
|
# Use Anthropic-compatible endpoint
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
f"{self.base_url}/text/chatcompletion_v2",
|
f"{self.base_url}/v1/messages",
|
||||||
headers=headers,
|
headers={**headers, "Content-Type": "application/json"},
|
||||||
json=payload,
|
json=payload,
|
||||||
timeout=120,
|
timeout=120,
|
||||||
)
|
)
|
||||||
@@ -210,10 +247,19 @@ class LLMClient:
|
|||||||
|
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
if "choices" in data:
|
# Handle Anthropic-compatible response format
|
||||||
return data["choices"][0]["message"]["content"]
|
if "content" in data:
|
||||||
elif "choices" in data.get("data", {}):
|
if isinstance(data["content"], list) and len(data["content"]) > 0:
|
||||||
return data["data"]["choices"][0]["message"]["content"]
|
# Look for text content, skip thinking
|
||||||
|
text_parts = []
|
||||||
|
for item in data["content"]:
|
||||||
|
if item.get("type") == "text":
|
||||||
|
text_parts.append(item.get("text", ""))
|
||||||
|
if text_parts:
|
||||||
|
return "".join(text_parts)
|
||||||
|
# If no text found, return first item as string
|
||||||
|
return str(data["content"][0])
|
||||||
|
return str(data["content"])
|
||||||
else:
|
else:
|
||||||
raise Exception(f"Unexpected MiniMax response: {data}")
|
raise Exception(f"Unexpected MiniMax response: {data}")
|
||||||
|
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ class MultiSourceIngestor:
|
|||||||
text = f.read_text(encoding='utf-8', errors='ignore')
|
text = f.read_text(encoding='utf-8', errors='ignore')
|
||||||
rel_path = f.relative_to(path)
|
rel_path = f.relative_to(path)
|
||||||
content_parts.append(f"## {rel_path}\n\n{text}\n")
|
content_parts.append(f"## {rel_path}\n\n{text}\n")
|
||||||
except:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
merged = "\n\n".join(content_parts)
|
merged = "\n\n".join(content_parts)
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ class WikipediaTool:
|
|||||||
"summary": page.summary[:500],
|
"summary": page.summary[:500],
|
||||||
"content": page.content[:2000],
|
"content": page.content[:2000],
|
||||||
})
|
})
|
||||||
except:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
return articles
|
return articles
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
|
|
||||||
# HTML Template for the UI
|
# HTML Template for the UI
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Test memoir ingestion."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Set token
|
||||||
|
os.environ["GITHUB_TOKEN"] = "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"
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
from opus_orchestrator.nonfiction.intake import determine_intake
|
||||||
|
from opus_orchestrator.nonfiction import ReaderPurpose
|
||||||
|
|
||||||
|
print("=== Testing Memoir Sources ===\n")
|
||||||
|
|
||||||
|
# 1. Determine purpose
|
||||||
|
result = await determine_intake(
|
||||||
|
concept="A memoir about love, loss, and transformation",
|
||||||
|
purpose="transform",
|
||||||
|
category="memoir"
|
||||||
|
)
|
||||||
|
print(f"1. PURPOSE: {result.purpose.value}")
|
||||||
|
print(f" Framework: {result.framework.get('name')}")
|
||||||
|
print(f" Stages: {len(result.framework.get('stages', []))}")
|
||||||
|
print(f" Source: {result.source}")
|
||||||
|
|
||||||
|
# 2. Try GitHub sources
|
||||||
|
print("\n2. Ingesting from GitHub...")
|
||||||
|
from opus_orchestrator.utils.multi_source_ingest import ingest_multiple
|
||||||
|
|
||||||
|
sources = [
|
||||||
|
{"type": "github", "repo": "mrhavens/The-Last-Love-Story"},
|
||||||
|
]
|
||||||
|
|
||||||
|
result = await ingest_multiple(sources)
|
||||||
|
print(f" Success: {result.successful_sources}/{result.total_sources}")
|
||||||
|
print(f" Content: {len(result.merged_content)} chars")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -186,7 +186,7 @@ class TestIntegration:
|
|||||||
"""Integration tests - skip if no API key."""
|
"""Integration tests - skip if no API key."""
|
||||||
|
|
||||||
@pytest.mark.skipif(
|
@pytest.mark.skipif(
|
||||||
not __import__('os')..environ.get('OPENAI_API_KEY'),
|
not __import__('os').environ.get('OPENAI_API_KEY'),
|
||||||
reason="No API key"
|
reason="No API key"
|
||||||
)
|
)
|
||||||
def test_real_api_call(self):
|
def test_real_api_call(self):
|
||||||
|
|||||||
Reference in New Issue
Block a user