Fix circular import in __init__.py (#35)

This commit is contained in:
2026-03-14 09:24:31 +00:00
parent 1b116108a6
commit 0f62267806
25 changed files with 517 additions and 137 deletions
+135
View File
@@ -8,3 +8,138 @@ Quick Start:
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",
]
+2 -1
View File
@@ -87,7 +87,8 @@ class BaseAgent(ABC, Generic[T]):
"""
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,
user_prompt=user_prompt,
temperature=temp,
+1 -1
View File
@@ -182,7 +182,7 @@ Provide:
if numbers:
score = float(numbers[0])
break
except:
except (ValueError, IndexError):
pass
return AgentResponse(
-1
View File
@@ -8,7 +8,6 @@ from typing import Any, Optional
from dotenv import load_dotenv
load_dotenv()
from opus_orchestrator.agents.base import BaseAgent, AgentResponse
from opus_orchestrator.utils.research import (
-1
View File
@@ -9,7 +9,6 @@ from typing import Any, Optional
from dotenv import load_dotenv
load_dotenv()
from autogen import ConversableAgent, GroupChat, GroupChatManager
-1
View File
@@ -9,7 +9,6 @@ from typing import Any, Optional
from crewai import Agent, Crew, LLM, Process, Task
from dotenv import load_dotenv
load_dotenv()
from opus_orchestrator.config import get_config
-1
View File
@@ -8,7 +8,6 @@ from typing import Any, Optional
from crewai import Agent, Process
from dotenv import load_dotenv
load_dotenv()
from opus_orchestrator.crews.base_crew import OpusCrew
from opus_orchestrator.config import get_config
@@ -8,7 +8,6 @@ from typing import Any, Optional
from crewai import Agent, Process
from dotenv import load_dotenv
load_dotenv()
from opus_orchestrator.crews.base_crew import OpusCrew
from opus_orchestrator.config import get_config
+7 -3
View File
@@ -140,11 +140,15 @@ class OpusGraph:
self.framework = framework
self.genre = genre
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
# Use synchronous LLM
self.llm = LLMClient(api_key=self.api_key, provider="openai", model="gpt-4o")
# Use synchronous LLM with config
self.llm = LLMClient(api_key=self.api_key, provider=self.provider, model=self.model)
# AutoGen critique crew
self.critique_crew = None
+131 -47
View File
@@ -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 sys
from pathlib import Path
from datetime import datetime
from typing import Any, Optional
from enum import Enum
def setup_logging(
level: str = "INFO",
log_file: str = None,
format: str = "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
) -> logging.Logger:
"""Setup structured logging for Opus.
class LogLevel(str, Enum):
"""Log levels."""
DEBUG = "DEBUG"
INFO = "INFO"
WARNING = "WARNING"
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:
level: DEBUG, INFO, WARNING, ERROR
log_file: Optional file path
format: Log message format
name: Logger name
level: Optional log level override
Returns:
Configured logger
OpusLogger instance
"""
# Create logger
logger = logging.getLogger("opus")
logger.setLevel(getattr(logging, level.upper()))
# 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
import os
env_level = os.environ.get("OPUS_LOG_LEVEL", level or "INFO")
return OpusLogger(name, env_level)
# Default logger
logger = setup_logging()
# Usage in modules:
# from opus_orchestrator.logging import logger
# logger.info("Starting generation")
# logger.error(f"Failed: {e}")
# Convenience function for quick logging
def log(name: str, level: str, message: str, **kwargs: Any) -> None:
"""Quick logging function.
Args:
name: Logger name
level: Log level
message: Log message
**kwargs: Additional context
"""
logger = get_logger(name)
getattr(logger, level.lower())(message, **kwargs)
+3 -12
View File
@@ -7,7 +7,7 @@ Usage:
from opus_orchestrator.nonfiction.classifier import PurposeClassifier, ReaderPurpose
classifier = PurposeClassifier()
result = await classifier.classify(
result = classifier._keyword_classify(
concept="Leadership for introverts",
target_audience="Introverted professionals who want to develop leadership skills",
intended_outcome="Learn to lead with quiet confidence"
@@ -19,20 +19,11 @@ Usage:
"""
import re
import json
from dataclasses import dataclass
from enum import Enum
from typing import Optional
class ReaderPurpose(str, Enum):
"""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"
# Import ReaderPurpose from taxonomy to avoid duplicate enum definitions
from opus_orchestrator.nonfiction_taxonomy import ReaderPurpose
@dataclass
@@ -22,7 +22,7 @@ CREATIVE_FRAMEWORKS = {
"description": "Branching narrative where reader choices determine the story path. Multiple endings based on decisions.",
"purpose": ReaderPurpose.BE_INSPIRED,
"structure": StructuralPattern.NARRATIVE,
"category": NonfictionCategory.CREATIVE,
"category": NonfictionCategory.CREATIVITY,
"stages": [
"Introduction - Set the scene",
"Opening Choice - First decision point",
@@ -59,7 +59,7 @@ Include:
"description": "RPG-style adventure with combat, stats, and inventory. Reader/player is the protagonist.",
"purpose": ReaderPurpose.BE_INSPIRED,
"structure": StructuralPattern.NARRATIVE,
"category": NonfictionCategory.CREATIVE,
"category": NonfictionCategory.CREATIVITY,
"stages": [
"Character Creation - Stats, skills",
"Equipment List - Starting items",
@@ -95,7 +95,7 @@ Include:
"description": "Game script with character dialogues, scene descriptions, and choice points. Anime/VN style.",
"purpose": ReaderPurpose.BE_INSPIRED,
"structure": StructuralPattern.NARRATIVE,
"category": NonfictionCategory.CREATIVE,
"category": NonfictionCategory.CREATIVITY,
"stages": [
"Title Screen / Opening",
"Prologue",
@@ -135,7 +135,7 @@ Include:
"description": "Story told entirely through documents: letters, emails, texts, diary entries, etc.",
"purpose": ReaderPurpose.BE_INSPIRED,
"structure": StructuralPattern.NARRATIVE,
"category": NonfictionCategory.CREATIVE,
"category": NonfictionCategory.CREATIVITY,
"stages": [
"Editor's Note - Frame narrative",
"Document 1: Letter/Email/Text",
@@ -170,7 +170,7 @@ Include:
"description": "A story revealed through discovered materials: redacted files, journal fragments, annotated maps.",
"purpose": ReaderPurpose.BE_INSPIRED,
"structure": StructuralPattern.NARRATIVE,
"category": NonfictionCategory.CREATIVE,
"category": NonfictionCategory.CREATIVITY,
"stages": [
"Foreword - How these were found",
"Artifact 1: Document type",
@@ -207,7 +207,7 @@ Include:
"description": "Revolutionary call to action. A passionate argument for change.",
"purpose": ReaderPurpose.BE_INSPIRED,
"structure": StructuralPattern.ARGUMENT,
"category": NonfictionCategory.CREATIVE,
"category": NonfictionCategory.CREATIVITY,
"stages": [
"Opening - The problem we've ignored",
"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.",
"purpose": ReaderPurpose.DECIDE,
"structure": StructuralPattern.ARGUMENT,
"category": NonfictionCategory.CREATIVE,
"category": NonfictionCategory.CREATIVITY,
"stages": [
"Salutation - Dear [Name/Entity]",
"Opening - Why I'm writing publicly",
@@ -279,7 +279,7 @@ Include:
"description": "Endlessly extensible story. Each chapter ends with a new beginning. For serialization.",
"purpose": ReaderPurpose.BE_INSPIRED,
"structure": StructuralPattern.NARRATIVE,
"category": NonfictionCategory.CREATIVE,
"category": NonfictionCategory.CREATIVITY,
"stages": [
"Episode 1: Complete arc",
"Episode 1 Cliffhanger - Link to next",
@@ -313,7 +313,7 @@ Include:
"description": "Story that repeats at different scales. Chapter mirrors scene mirrors paragraph.",
"purpose": ReaderPurpose.BE_INSPIRED,
"structure": StructuralPattern.NARRATIVE,
"category": NonfictionCategory.CREATIVE,
"category": NonfictionCategory.CREATIVITY,
"stages": [
"Macro Level: The Overall Arc",
"Structure mirrors down",
@@ -344,7 +344,7 @@ Include:
"description": "Non-linear collection of fragments: memories, images (described), ticket stubs, recipes, etc.",
"purpose": ReaderPurpose.BE_INSPIRED,
"structure": StructuralPattern.SPIRAL,
"category": NonfictionCategory.CREATIVE,
"category": NonfictionCategory.CREATIVITY,
"stages": [
"Opening Spread - First impressions",
"Fragment 1: Memory/Image",
@@ -381,7 +381,7 @@ Include:
"description": "Script for spoken audio. Includes banter, segments, transitions.",
"purpose": ReaderPurpose.LEARN_HANDS_ON,
"structure": StructuralPattern.SEQUENTIAL,
"category": NonfictionCategory.CREATIVE,
"category": NonfictionCategory.CREATIVITY,
"stages": [
"Intro - Hook + branding",
"Cold Open - Tease",
@@ -415,7 +415,7 @@ Include:
"description": "Hollywood standard screenplay format. Visual storytelling through action and dialogue.",
"purpose": ReaderPurpose.BE_INSPIRED,
"structure": StructuralPattern.NARRATIVE,
"category": NonfictionCategory.CREATIVE,
"category": NonfictionCategory.CREATIVITY,
"stages": [
"Title Page",
"Fade In",
@@ -451,7 +451,7 @@ Include:
"description": "Theatrical script with scenes, stage directions, and dialogue.",
"purpose": ReaderPurpose.BE_INSPIRED,
"structure": StructuralPattern.NARRATIVE,
"category": NonfictionCategory.CREATIVE,
"category": NonfictionCategory.CREATIVITY,
"stages": [
"Title Page",
"Dramatis Personae - Characters",
@@ -8,7 +8,6 @@ from typing import Any, Optional
from dotenv import load_dotenv
load_dotenv()
from opus_orchestrator.nonfiction_frameworks import (
NonfictionFramework,
+3
View File
@@ -58,6 +58,9 @@ class NonfictionCategory(str, Enum):
CREATIVITY = "creativity"
SPIRITUALITY = "spirituality"
HOW_TO = "how_to"
EDUCATION = "education"
ACADEMIC = "academic"
RPG = "rpg"
# ============================================================================
-3
View File
@@ -10,7 +10,6 @@ from typing import Any, Optional
from dotenv import load_dotenv
load_dotenv()
from opus_orchestrator.agents.fiction import (
ArchitectAgent,
@@ -347,8 +346,6 @@ Generate a detailed outline with:
Returns:
RawContent with the combined text from the repo
"""
from opus_orchestrator.utils.github_ingest import GitHubIngestor
print(f"📥 Loading from GitHub: {repo}")
github_token = self.config.github_token or os.environ.get("GITHUB_TOKEN")
-1
View File
@@ -10,7 +10,6 @@ from pydantic import BaseModel
from pydantic_ai import Agent
from dotenv import load_dotenv
load_dotenv()
from opus_orchestrator.config import get_config
+95 -6
View File
@@ -7,18 +7,46 @@ import os
from typing import Any, Optional
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.security import APIKeyHeader
from pydantic import BaseModel, Field
from dotenv import load_dotenv
load_dotenv()
from opus_orchestrator.config import get_config
from opus_orchestrator import run_opus, OpusOrchestrator
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
# =============================================================================
@@ -194,7 +222,7 @@ async def list_frameworks():
@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."""
import traceback
try:
@@ -302,7 +330,7 @@ async def generate_stream(request: GenerateRequest):
return StreamingResponse(event_generator(), media_type="text/event-stream")
@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."""
try:
orch = OpusOrchestrator(book_type="fiction")
@@ -351,7 +379,7 @@ class S3UploadResponse(BaseModel):
@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."""
try:
content = await file.read()
@@ -368,7 +396,7 @@ async def upload_file(file: UploadFile = File(...)):
@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."""
try:
from opus_orchestrator import S3Ingestor
@@ -441,3 +469,64 @@ if __name__ == "__main__":
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8000
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"],
)
+1 -1
View File
@@ -284,7 +284,7 @@ Just respond with a number between 0.0 and 1.0."""
try:
return float(response.strip())
except:
except (ValueError, TypeError):
return 0.5
def _simple_relevance(self, content: str, purpose: CrawlPurpose) -> float:
+1 -3
View File
@@ -9,9 +9,7 @@ import re
from typing import Any, Optional
import requests
from dotenv import load_dotenv
load_dotenv()
# Note: dotenv loading removed - set GITHUB_TOKEN environment variable directly
class GitHubIngestor:
+82 -36
View File
@@ -1,6 +1,7 @@
"""LLM client for Opus Orchestrator.
Supports MiniMax and OpenAI providers - both async and sync.
Includes retry logic with exponential backoff and circuit breaker.
"""
import os
@@ -10,9 +11,14 @@ from typing import Any, Optional
import httpx
import requests
from opus_orchestrator.utils.retry import RetryHandler, RetryConfig
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__(
self,
@@ -20,8 +26,17 @@ class LLMClient:
provider: str = "minimax",
model: str = "MiniMax/MiniMax-M2.1",
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.provider = provider
self.model = model
@@ -34,7 +49,8 @@ class LLMClient:
if base_url:
self.base_url = base_url
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":
self.base_url = "https://api.openai.com/v1"
else:
@@ -42,6 +58,16 @@ class LLMClient:
# Async client
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(
self,
@@ -74,24 +100,33 @@ class LLMClient:
temperature: float = 0.7,
max_tokens: Optional[int] = None,
) -> str:
"""Make a completion request (ASYNC)."""
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
"""Make a completion request (ASYNC) with retry logic."""
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}")
async def _make_request():
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
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 def _complete_minimax_async(
self,
system_prompt: str,
user_prompt: str,
@@ -99,8 +134,8 @@ class LLMClient:
max_tokens: Optional[int],
headers: dict,
) -> str:
"""Call MiniMax API."""
# MiniMax chat completion format
"""Call MiniMax API using Anthropic-compatible endpoint."""
# Anthropic-compatible format
payload = {
"model": self.minimax_model,
"messages": [
@@ -113,9 +148,10 @@ class LLMClient:
if max_tokens:
payload["max_tokens"] = max_tokens
# Use Anthropic-compatible endpoint
response = await self._async_client.post(
f"{self.base_url}/text/chatcompletion_v2",
headers=headers,
f"{self.base_url}/v1/messages",
headers={**headers, "Content-Type": "application/json"},
json=payload,
)
@@ -127,13 +163,13 @@ class LLMClient:
data = response.json()
# Handle different response formats
if "choices" in data:
return data["choices"][0]["message"]["content"]
elif "choices" in data.get("data", {}):
return data["data"]["choices"][0]["message"]["content"]
# Handle Anthropic-compatible response format
if "content" in data:
# Return the text content
if isinstance(data["content"], list) and len(data["content"]) > 0:
return data["content"][0].get("text", str(data["content"][0]))
return str(data["content"])
else:
# Try to find content in response
raise Exception(f"Unexpected MiniMax response: {data}")
async def _complete_openai(
@@ -183,7 +219,7 @@ class LLMClient:
max_tokens: Optional[int],
headers: dict,
) -> str:
"""Call MiniMax API (sync)."""
"""Call MiniMax API (sync) using Anthropic-compatible endpoint."""
payload = {
"model": self.minimax_model,
"messages": [
@@ -196,9 +232,10 @@ class LLMClient:
if max_tokens:
payload["max_tokens"] = max_tokens
# Use Anthropic-compatible endpoint
response = requests.post(
f"{self.base_url}/text/chatcompletion_v2",
headers=headers,
f"{self.base_url}/v1/messages",
headers={**headers, "Content-Type": "application/json"},
json=payload,
timeout=120,
)
@@ -210,10 +247,19 @@ class LLMClient:
data = response.json()
if "choices" in data:
return data["choices"][0]["message"]["content"]
elif "choices" in data.get("data", {}):
return data["data"]["choices"][0]["message"]["content"]
# Handle Anthropic-compatible response format
if "content" in data:
if isinstance(data["content"], list) and len(data["content"]) > 0:
# 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:
raise Exception(f"Unexpected MiniMax response: {data}")
@@ -193,7 +193,7 @@ class MultiSourceIngestor:
text = f.read_text(encoding='utf-8', errors='ignore')
rel_path = f.relative_to(path)
content_parts.append(f"## {rel_path}\n\n{text}\n")
except:
except OSError:
pass
merged = "\n\n".join(content_parts)
+1 -1
View File
@@ -178,7 +178,7 @@ class WikipediaTool:
"summary": page.summary[:500],
"content": page.content[:2000],
})
except:
except Exception:
continue
return articles
except Exception as e:
-1
View File
@@ -14,7 +14,6 @@ from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from dotenv import load_dotenv
load_dotenv()
# HTML Template for the UI
+40
View File
@@ -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())
+1 -1
View File
@@ -186,7 +186,7 @@ class TestIntegration:
"""Integration tests - skip if no API key."""
@pytest.mark.skipif(
not __import__('os')..environ.get('OPENAI_API_KEY'),
not __import__('os').environ.get('OPENAI_API_KEY'),
reason="No API key"
)
def test_real_api_call(self):