Add API client mode to CLI
Now CLI works in both local and client/server mode: # Local mode (default) opus generate --concept ... # API client mode opus --api-url http://localhost:8000 generate --concept ... opus --api-url https://opus-api.example.com generate --repo owner/repo OpusAPIClient class for programmatic API access.
This commit is contained in:
+276
-82
@@ -8,12 +8,31 @@ Usage:
|
|||||||
opus generate --concept "Your story idea"
|
opus generate --concept "Your story idea"
|
||||||
opus serve --port 8000 # Start API server
|
opus serve --port 8000 # Start API server
|
||||||
opus docs # Show documentation
|
opus docs # Show documentation
|
||||||
"""
|
|
||||||
|
# Or use as API client:
|
||||||
|
opus --api-url http://localhost:8000 generate --concept "..."
|
||||||
|
|
||||||
|
Local mode (default): Runs generation locally using LangGraph/CrewAI
|
||||||
|
API mode: Sends requests to Opus API server
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
opus [GLOBAL_OPTIONS] <command> [OPTIONS]
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
# Local generation
|
||||||
|
opus generate --concept "A robot dreams of love"
|
||||||
|
|
||||||
|
# API client mode
|
||||||
|
opus --api-url http://localhost:8000 generate --concept "..."
|
||||||
|
opus --api-url https://opus-api.example.com generate --repo owner/repo
|
||||||
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
import json
|
||||||
|
import requests
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Add the project root to the path
|
# Add the project root to the path
|
||||||
@@ -26,6 +45,123 @@ env_path = Path(__file__).parent.parent / ".env"
|
|||||||
load_dotenv(env_path)
|
load_dotenv(env_path)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# API CLIENT
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class OpusAPIClient:
|
||||||
|
"""Client for Opus REST API."""
|
||||||
|
|
||||||
|
def __init__(self, base_url: str):
|
||||||
|
"""Initialize API client.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: Base URL of Opus API server
|
||||||
|
"""
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self.session = requests.Session()
|
||||||
|
self.session.headers.update({"Content-Type": "application/json"})
|
||||||
|
|
||||||
|
def health(self) -> dict:
|
||||||
|
"""Check API health."""
|
||||||
|
return self._get("/health")
|
||||||
|
|
||||||
|
def frameworks(self) -> dict:
|
||||||
|
"""List available frameworks."""
|
||||||
|
return self._get("/frameworks")
|
||||||
|
|
||||||
|
def generate(
|
||||||
|
self,
|
||||||
|
concept: str = None,
|
||||||
|
repo: str = None,
|
||||||
|
framework: str = "snowflake",
|
||||||
|
genre: str = "fiction",
|
||||||
|
book_type: str = "fiction",
|
||||||
|
target_word_count: int = 5000,
|
||||||
|
chapters: int = 3,
|
||||||
|
tone: str = "literary",
|
||||||
|
use_crewai: bool = False,
|
||||||
|
use_autogen: bool = True,
|
||||||
|
) -> dict:
|
||||||
|
"""Generate a manuscript.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
concept: Seed concept
|
||||||
|
repo: GitHub repo to ingest
|
||||||
|
framework: Story framework
|
||||||
|
genre: Genre
|
||||||
|
book_type: Book type
|
||||||
|
target_word_count: Target word count
|
||||||
|
chapters: Number of chapters
|
||||||
|
tone: Writing tone
|
||||||
|
use_crewai: Use CrewAI
|
||||||
|
use_autogen: Use AutoGen critique
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Generation result dict
|
||||||
|
"""
|
||||||
|
payload = {
|
||||||
|
"framework": framework,
|
||||||
|
"genre": genre,
|
||||||
|
"book_type": book_type,
|
||||||
|
"target_word_count": target_word_count,
|
||||||
|
"chapters": chapters,
|
||||||
|
"tone": tone,
|
||||||
|
"use_crewai": use_crewai,
|
||||||
|
"use_autogen": use_autogen,
|
||||||
|
}
|
||||||
|
|
||||||
|
if concept:
|
||||||
|
payload["concept"] = concept
|
||||||
|
if repo:
|
||||||
|
payload["repo"] = repo
|
||||||
|
|
||||||
|
return self._post("/generate", payload)
|
||||||
|
|
||||||
|
def ingest(self, repo: str, include_readme: bool = True) -> dict:
|
||||||
|
"""Ingest from GitHub.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo: GitHub repo (owner/repo)
|
||||||
|
include_readme: Include README files
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Ingested content
|
||||||
|
"""
|
||||||
|
return self._post("/ingest", {
|
||||||
|
"repo": repo,
|
||||||
|
"include_readme": include_readme,
|
||||||
|
})
|
||||||
|
|
||||||
|
def _get(self, endpoint: str) -> dict:
|
||||||
|
"""GET request."""
|
||||||
|
url = f"{self.base_url}{endpoint}"
|
||||||
|
response = self.session.get(url)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
def _post(self, endpoint: str, data: dict) -> dict:
|
||||||
|
"""POST request."""
|
||||||
|
url = f"{self.base_url}{endpoint}"
|
||||||
|
response = self.session.post(url, json=data)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def get_api_client(api_url: str = None) -> OpusAPIClient | None:
|
||||||
|
"""Get API client if URL provided.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
api_url: API base URL
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
OpusAPIClient or None
|
||||||
|
"""
|
||||||
|
if api_url:
|
||||||
|
return OpusAPIClient(api_url)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def setup_cli() -> argparse.ArgumentParser:
|
def setup_cli() -> argparse.ArgumentParser:
|
||||||
"""Set up the CLI argument parser."""
|
"""Set up the CLI argument parser."""
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
@@ -38,8 +174,7 @@ and PydanticAI for professional manuscript production.
|
|||||||
Examples:
|
Examples:
|
||||||
opus generate --concept "A robot dreams of love" --framework snowflake
|
opus generate --concept "A robot dreams of love" --framework snowflake
|
||||||
opus serve --port 8080
|
opus serve --port 8080
|
||||||
opus docs
|
opus --api-url http://localhost:8000 generate --concept "..."
|
||||||
opus ingest --repo mrhavens/my-book
|
|
||||||
""",
|
""",
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
)
|
)
|
||||||
@@ -50,6 +185,12 @@ Examples:
|
|||||||
version="Opus Orchestrator AI v0.2.0",
|
version="Opus Orchestrator AI v0.2.0",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Global option for API client mode
|
||||||
|
parser.add_argument(
|
||||||
|
"--api-url",
|
||||||
|
help="Use API server at this URL (client mode). Without this, runs locally.",
|
||||||
|
)
|
||||||
|
|
||||||
subparsers = parser.add_subparsers(dest="command", help="Command to run")
|
subparsers = parser.add_subparsers(dest="command", help="Command to run")
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
@@ -293,86 +434,121 @@ async def run_generate(args: argparse.Namespace) -> int:
|
|||||||
print("📚 OPUS ORCHESTRATOR AI")
|
print("📚 OPUS ORCHESTRATOR AI")
|
||||||
print(f"{'='*60}\n")
|
print(f"{'='*60}\n")
|
||||||
|
|
||||||
# Determine the seed concept
|
# Check for API client mode
|
||||||
seed_concept = args.concept
|
if args.api_url:
|
||||||
|
client = OpusAPIClient(args.api_url)
|
||||||
|
|
||||||
if args.repo:
|
print(f"🌐 API Client Mode")
|
||||||
# Ingest from GitHub - use FULL content
|
print(f" Server: {args.api_url}\n")
|
||||||
print(f"📥 Ingesting from GitHub: {args.repo}")
|
|
||||||
|
|
||||||
orch = OpusOrchestrator(
|
# Call API
|
||||||
book_type=args.book_type,
|
try:
|
||||||
genre=args.genre,
|
result = client.generate(
|
||||||
target_word_count=args.words,
|
concept=args.concept,
|
||||||
framework=args.framework,
|
repo=args.repo,
|
||||||
)
|
framework=args.framework,
|
||||||
|
|
||||||
content = orch.ingest_from_github(args.repo)
|
|
||||||
|
|
||||||
# Use full content as seed
|
|
||||||
full_text = content.text
|
|
||||||
print(f" ✅ Loaded {len(full_text):,} characters from {content.metadata['file_count']} files")
|
|
||||||
print(f" 📄 Files: {', '.join(content.metadata['files'])}\n")
|
|
||||||
|
|
||||||
seed_concept = full_text
|
|
||||||
|
|
||||||
if not seed_concept:
|
|
||||||
print("Error: Please provide --concept or --repo")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
# Show generation parameters
|
|
||||||
print(f"🎯 Generating {args.words:,} words ({args.chapters} chapters)")
|
|
||||||
print(f" Framework: {args.framework}")
|
|
||||||
print(f" Genre: {args.genre}")
|
|
||||||
print(f" Type: {args.book_type}")
|
|
||||||
print(f" Tone: {args.tone}")
|
|
||||||
print(f" CrewAI: {args.use_crewai}")
|
|
||||||
print(f" AutoGen: {not args.no_autogen}")
|
|
||||||
print()
|
|
||||||
|
|
||||||
use_autogen = not args.no_autogen
|
|
||||||
|
|
||||||
if args.use_crewai:
|
|
||||||
# Use CrewAI crews
|
|
||||||
print("🛠️ Using CrewAI crews...\n")
|
|
||||||
|
|
||||||
if args.book_type == "fiction":
|
|
||||||
crew = create_fiction_crew(
|
|
||||||
genre=args.genre,
|
genre=args.genre,
|
||||||
tone=args.tone,
|
book_type=args.book_type,
|
||||||
target_word_count=args.words // args.chapters,
|
|
||||||
)
|
|
||||||
|
|
||||||
story = crew.write_full_story(
|
|
||||||
story_outline=seed_concept[:10000], # Limit for crew context
|
|
||||||
character_sheets="",
|
|
||||||
style_guide=f"Tone: {args.tone}",
|
|
||||||
num_chapters=args.chapters,
|
|
||||||
)
|
|
||||||
|
|
||||||
manuscript = "\n\n---\n\n".join(story)
|
|
||||||
else:
|
|
||||||
crew = create_nonfiction_crew(
|
|
||||||
topic=args.genre,
|
|
||||||
tone=args.tone,
|
|
||||||
target_word_count=args.words,
|
target_word_count=args.words,
|
||||||
|
chapters=args.chapters,
|
||||||
|
tone=args.tone,
|
||||||
|
use_crewai=args.use_crewai,
|
||||||
|
use_autogen=not args.no_autogen,
|
||||||
)
|
)
|
||||||
|
|
||||||
manuscript = crew.write_section(
|
print(f"✅ Generation complete!")
|
||||||
section_outline=seed_concept[:10000],
|
print(f" Words: {result.get('word_count', 'N/A'):,}")
|
||||||
style_guide=f"Tone: {args.tone}",
|
print(f" Chapters: {result.get('chapters', 'N/A')}")
|
||||||
)
|
print(f" Framework: {result.get('framework', 'N/A')}\n")
|
||||||
|
|
||||||
|
manuscript = result.get("manuscript", "")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ API Error: {e}")
|
||||||
|
return 1
|
||||||
else:
|
else:
|
||||||
# Use LangGraph pipeline
|
# LOCAL MODE - run locally
|
||||||
result = await run_opus(
|
# Determine the seed concept
|
||||||
seed_concept=seed_concept,
|
seed_concept = args.concept
|
||||||
framework=args.framework,
|
|
||||||
genre=args.genre,
|
|
||||||
target_word_count=args.words,
|
|
||||||
use_autogen=use_autogen,
|
|
||||||
)
|
|
||||||
|
|
||||||
manuscript = result.get("manuscript", str(result))
|
if args.repo:
|
||||||
|
# Ingest from GitHub - use FULL content
|
||||||
|
print(f"📥 Ingesting from GitHub: {args.repo}")
|
||||||
|
|
||||||
|
orch = OpusOrchestrator(
|
||||||
|
book_type=args.book_type,
|
||||||
|
genre=args.genre,
|
||||||
|
target_word_count=args.words,
|
||||||
|
framework=args.framework,
|
||||||
|
)
|
||||||
|
|
||||||
|
content = orch.ingest_from_github(args.repo)
|
||||||
|
|
||||||
|
# Use full content as seed
|
||||||
|
full_text = content.text
|
||||||
|
print(f" ✅ Loaded {len(full_text):,} characters from {content.metadata['file_count']} files")
|
||||||
|
print(f" 📄 Files: {', '.join(content.metadata['files'])}\n")
|
||||||
|
|
||||||
|
seed_concept = full_text
|
||||||
|
|
||||||
|
if not seed_concept:
|
||||||
|
print("Error: Please provide --concept or --repo")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Show generation parameters
|
||||||
|
print(f"🏠 Local Mode")
|
||||||
|
print(f"🎯 Generating {args.words:,} words ({args.chapters} chapters)")
|
||||||
|
print(f" Framework: {args.framework}")
|
||||||
|
print(f" Genre: {args.genre}")
|
||||||
|
print(f" Type: {args.book_type}")
|
||||||
|
print(f" Tone: {args.tone}")
|
||||||
|
print(f" CrewAI: {args.use_crewai}")
|
||||||
|
print(f" AutoGen: {not args.no_autogen}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
use_autogen = not args.no_autogen
|
||||||
|
|
||||||
|
if args.use_crewai:
|
||||||
|
# Use CrewAI crews
|
||||||
|
print("🛠️ Using CrewAI crews...\n")
|
||||||
|
|
||||||
|
if args.book_type == "fiction":
|
||||||
|
crew = create_fiction_crew(
|
||||||
|
genre=args.genre,
|
||||||
|
tone=args.tone,
|
||||||
|
target_word_count=args.words // args.chapters,
|
||||||
|
)
|
||||||
|
|
||||||
|
story = crew.write_full_story(
|
||||||
|
story_outline=seed_concept[:10000], # Limit for crew context
|
||||||
|
character_sheets="",
|
||||||
|
style_guide=f"Tone: {args.tone}",
|
||||||
|
num_chapters=args.chapters,
|
||||||
|
)
|
||||||
|
|
||||||
|
manuscript = "\n\n---\n\n".join(story)
|
||||||
|
else:
|
||||||
|
crew = create_nonfiction_crew(
|
||||||
|
topic=args.genre,
|
||||||
|
tone=args.tone,
|
||||||
|
target_word_count=args.words,
|
||||||
|
)
|
||||||
|
|
||||||
|
manuscript = crew.write_section(
|
||||||
|
section_outline=seed_concept[:10000],
|
||||||
|
style_guide=f"Tone: {args.tone}",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Use LangGraph pipeline
|
||||||
|
result = await run_opus(
|
||||||
|
seed_concept=seed_concept,
|
||||||
|
framework=args.framework,
|
||||||
|
genre=args.genre,
|
||||||
|
target_word_count=args.words,
|
||||||
|
use_autogen=use_autogen,
|
||||||
|
)
|
||||||
|
|
||||||
|
manuscript = result.get("manuscript", str(result))
|
||||||
|
|
||||||
# Save output
|
# Save output
|
||||||
output_path = args.output
|
output_path = args.output
|
||||||
@@ -425,22 +601,40 @@ def run_ingest(args: argparse.Namespace) -> int:
|
|||||||
|
|
||||||
print(f"\n📥 Ingesting from GitHub: {args.repo}\n")
|
print(f"\n📥 Ingesting from GitHub: {args.repo}\n")
|
||||||
|
|
||||||
orch = OpusOrchestrator(book_type="fiction")
|
# Check for API client mode
|
||||||
content = orch.ingest_from_github(args.repo, include_readme=args.include_readme)
|
if args.api_url:
|
||||||
|
client = OpusAPIClient(args.api_url)
|
||||||
|
print(f"🌐 API Client Mode: {args.api_url}\n")
|
||||||
|
|
||||||
print(f"✅ Loaded {len(content.text):,} characters")
|
try:
|
||||||
print(f" Files: {content.metadata['file_count']}")
|
result = client.ingest(args.repo, include_readme=args.include_readme)
|
||||||
print(f" File list: {', '.join(content.metadata['files'])}\n")
|
content_text = result.get("content", "")
|
||||||
|
file_count = result.get("file_count", 0)
|
||||||
|
files = result.get("files", [])
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ API Error: {e}")
|
||||||
|
return 1
|
||||||
|
else:
|
||||||
|
# Local mode
|
||||||
|
orch = OpusOrchestrator(book_type="fiction")
|
||||||
|
content = orch.ingest_from_github(args.repo, include_readme=args.include_readme)
|
||||||
|
content_text = content.text
|
||||||
|
file_count = content.metadata["file_count"]
|
||||||
|
files = content.metadata["files"]
|
||||||
|
|
||||||
|
print(f"✅ Loaded {len(content_text):,} characters")
|
||||||
|
print(f" Files: {file_count}")
|
||||||
|
print(f" File list: {', '.join(files)}\n")
|
||||||
|
|
||||||
if args.preview:
|
if args.preview:
|
||||||
print("📄 PREVIEW (first 2000 chars):")
|
print("📄 PREVIEW (first 2000 chars):")
|
||||||
print("-" * 40)
|
print("-" * 40)
|
||||||
print(content.text[:2000])
|
print(content_text[:2000])
|
||||||
print("-" * 40)
|
print("-" * 40)
|
||||||
|
|
||||||
if args.output:
|
if args.output:
|
||||||
with open(args.output, "w") as f:
|
with open(args.output, "w") as f:
|
||||||
f.write(content.text)
|
f.write(content_text)
|
||||||
print(f"\n💾 Saved to: {args.output}")
|
print(f"\n💾 Saved to: {args.output}")
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
Reference in New Issue
Block a user