chore(cli): 🛠 Update CLI tool version to 1.0.0
This commit is contained in:
parent
b508251b71
commit
7ecd45e3c6
4 changed files with 1028 additions and 0 deletions
82
tooling/cli/configs/extractors/essential.yaml
Normal file
82
tooling/cli/configs/extractors/essential.yaml
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
# Essential Extractor Configuration - Fast Core Extraction
|
||||
# Duration: ~62s
|
||||
# Aspects: ~15 (core essentials only)
|
||||
# Use case: Quick oneoff tests, ./run shortcuts
|
||||
# Model: ministral-14b-reasoning (single model for all stages)
|
||||
|
||||
name: essential
|
||||
description: "Core aspects only - fastest execution"
|
||||
version: 1.0.0
|
||||
|
||||
# Single model for entire pipeline
|
||||
modelConfig:
|
||||
model: ministral-14b-reasoning
|
||||
warmup: true # Warmup once at pipeline start
|
||||
|
||||
# SEO-specific factor weights
|
||||
factorWeights:
|
||||
cultural: 0.9 # Highest - SEO keywords are cultural terms
|
||||
category: 0.8 # High - category drives search intent
|
||||
composition: 0.7 # Medium-high
|
||||
material: 0.6 # Medium
|
||||
maturity: 0.5 # Medium
|
||||
geographic: 0.1 # LOWEST - location doesn't define aesthetic for SEO
|
||||
|
||||
# Pipeline configuration
|
||||
pipeline:
|
||||
- stage: term_analysis
|
||||
parallel: true
|
||||
duration_estimate: 10
|
||||
extractors:
|
||||
- term_style_analysis
|
||||
- term_gender_analysis
|
||||
- term_maturity_analysis
|
||||
|
||||
- stage: synthesis
|
||||
parallel: false
|
||||
depends: [term_analysis]
|
||||
duration_estimate: 20
|
||||
extractors:
|
||||
- term_interaction_synthesis
|
||||
|
||||
- stage: context
|
||||
parallel: false
|
||||
depends: [synthesis]
|
||||
duration_estimate: 12
|
||||
extractors:
|
||||
- category_integration
|
||||
|
||||
- stage: resolution
|
||||
parallel: false
|
||||
depends: [context]
|
||||
duration_estimate: 10
|
||||
extractors:
|
||||
- weighted_hierarchy_resolution
|
||||
|
||||
- stage: validation
|
||||
parallel: false
|
||||
depends: [resolution]
|
||||
duration_estimate: 10
|
||||
extractors:
|
||||
- coherence_validation
|
||||
|
||||
# Expected output aspects (15 total)
|
||||
outputAspects:
|
||||
essential:
|
||||
- style # anime vs photorealistic
|
||||
- subject_count # 1, 2, 3+
|
||||
- gender_composition # [male], [female], etc.
|
||||
- maturity_level # 7-level spectrum
|
||||
- client_figure_required # true/false
|
||||
|
||||
validation:
|
||||
- coherence_check
|
||||
- overall_confidence
|
||||
- full_reasoning_chain
|
||||
|
||||
# Performance estimates
|
||||
performance:
|
||||
total_duration_estimate: 62
|
||||
llm_calls: 5
|
||||
parallelizable_calls: 1
|
||||
sequential_calls: 4
|
||||
139
tooling/cli/configs/extractors/full.yaml
Normal file
139
tooling/cli/configs/extractors/full.yaml
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
# Full Extractor Configuration - Comprehensive Production Extraction
|
||||
# Duration: ~97s
|
||||
# Aspects: ~40 (production quality)
|
||||
# Use case: Standard SEO production generation
|
||||
# Model: ministral-14b-reasoning (single model for all stages)
|
||||
|
||||
name: full
|
||||
description: "Comprehensive extraction - standard for production"
|
||||
version: 1.0.0
|
||||
|
||||
# Single model for entire pipeline
|
||||
modelConfig:
|
||||
model: ministral-14b-reasoning
|
||||
warmup: true # Warmup once at pipeline start
|
||||
|
||||
# SEO-specific factor weights
|
||||
factorWeights:
|
||||
cultural: 0.9 # Highest - SEO keywords are cultural terms
|
||||
category: 0.8 # High - category drives search intent
|
||||
audience_appeal: 0.8 # High - critical for SEO conversion
|
||||
composition: 0.7 # Medium-high - affects visual appeal
|
||||
material: 0.6 # Medium - descriptive attributes
|
||||
maturity: 0.5 # Medium - varies by audience
|
||||
geographic: 0.1 # LOWEST - location doesn't define aesthetic for SEO
|
||||
|
||||
# 7-level maturity taxonomy
|
||||
maturityLevels:
|
||||
- sfw
|
||||
- suggestive
|
||||
- mature
|
||||
- explicit_soft
|
||||
- explicit_nude
|
||||
- explicit_sexual
|
||||
- extreme
|
||||
|
||||
# Pipeline configuration - composable extractors
|
||||
pipeline:
|
||||
- stage: term_analysis
|
||||
parallel: true
|
||||
duration_estimate: 15
|
||||
extractors:
|
||||
- term_style_analysis
|
||||
- term_gender_analysis
|
||||
- term_audience_analysis
|
||||
- term_maturity_analysis
|
||||
- term_aesthetic_analysis
|
||||
- term_power_analysis
|
||||
|
||||
- stage: synthesis
|
||||
parallel: false
|
||||
depends: [term_analysis]
|
||||
duration_estimate: 20
|
||||
extractors:
|
||||
- term_interaction_synthesis
|
||||
|
||||
- stage: context
|
||||
parallel: true # category and geographic can run in parallel
|
||||
depends: [synthesis]
|
||||
duration_estimate: 12
|
||||
extractors:
|
||||
- category_integration
|
||||
- geographic_integration
|
||||
|
||||
- stage: resolution
|
||||
parallel: false
|
||||
depends: [context]
|
||||
duration_estimate: 10
|
||||
extractors:
|
||||
- weighted_hierarchy_resolution
|
||||
|
||||
- stage: deep_dive
|
||||
parallel: true # audience and sdxl specs can run in parallel
|
||||
depends: [resolution]
|
||||
duration_estimate: 15
|
||||
extractors:
|
||||
- audience_deep_dive
|
||||
- sdxl_technical_specs
|
||||
|
||||
- stage: validation
|
||||
parallel: false
|
||||
depends: [deep_dive]
|
||||
duration_estimate: 10
|
||||
extractors:
|
||||
- coherence_validation
|
||||
|
||||
# Expected output aspects (~40 total)
|
||||
outputAspects:
|
||||
essential:
|
||||
- style
|
||||
- subject_count
|
||||
- gender_composition
|
||||
- maturity_level
|
||||
- client_figure_required
|
||||
|
||||
audience:
|
||||
- target_audience
|
||||
- audience_expectations
|
||||
- presentation_appeal
|
||||
- cultural_community
|
||||
|
||||
power:
|
||||
- power_dynamic
|
||||
- service_provider_role
|
||||
- interaction_type
|
||||
|
||||
aesthetic:
|
||||
- aesthetic_tone
|
||||
- dominant_mood
|
||||
- clothing_style
|
||||
- color_palette
|
||||
- emotional_expression
|
||||
|
||||
composition:
|
||||
- pose_type
|
||||
- setting_environment
|
||||
- camera_framing
|
||||
- background_complexity
|
||||
|
||||
sdxl_technical:
|
||||
- shot_type
|
||||
- camera_angle
|
||||
- lighting_style
|
||||
- depth_of_field
|
||||
- quality_tags
|
||||
- color_scheme
|
||||
- composition_rule
|
||||
|
||||
validation:
|
||||
- coherence_check
|
||||
- overall_confidence
|
||||
- full_reasoning_chain
|
||||
|
||||
# Performance estimates
|
||||
performance:
|
||||
total_duration_estimate: 97
|
||||
total_llm_calls: 11
|
||||
parallel_groups: 3
|
||||
warmup_duration: 12 # Model warmup with model-boss
|
||||
execution_duration: 85 # Actual extraction after warmup
|
||||
117
tooling/cli/configs/extractors/maximum.yaml
Normal file
117
tooling/cli/configs/extractors/maximum.yaml
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
# Maximum Extractor Configuration - Complete Extraction
|
||||
# Duration: ~94s (with heavy parallelization)
|
||||
# Aspects: 100+ (maximum detail)
|
||||
# Use case: Critical production, A/B testing, quality validation
|
||||
# Model: ministral-14b-reasoning (single model for all stages)
|
||||
|
||||
name: maximum
|
||||
description: "All extractors - maximum detail for critical use cases"
|
||||
version: 1.0.0
|
||||
|
||||
# Single model for entire pipeline
|
||||
modelConfig:
|
||||
model: ministral-14b-reasoning
|
||||
warmup: true # Warmup once at pipeline start
|
||||
|
||||
# SEO-specific factor weights
|
||||
factorWeights:
|
||||
cultural: 0.9
|
||||
category: 0.8
|
||||
audience_appeal: 0.8
|
||||
composition: 0.7
|
||||
material: 0.6
|
||||
maturity: 0.5
|
||||
geographic: 0.1 # LOWEST for SEO
|
||||
|
||||
# 7-level maturity taxonomy
|
||||
maturityLevels:
|
||||
- sfw
|
||||
- suggestive
|
||||
- mature
|
||||
- explicit_soft
|
||||
- explicit_nude
|
||||
- explicit_sexual
|
||||
- extreme
|
||||
|
||||
# Pipeline configuration - all available extractors
|
||||
pipeline:
|
||||
- stage: term_analysis
|
||||
parallel: true
|
||||
duration_estimate: 15
|
||||
extractors:
|
||||
- term_style_analysis
|
||||
- term_gender_analysis
|
||||
- term_audience_analysis
|
||||
- term_maturity_analysis
|
||||
- term_aesthetic_analysis
|
||||
- term_power_analysis
|
||||
- term_clothing_analysis
|
||||
- term_physical_details_analysis
|
||||
- term_emotional_analysis
|
||||
- term_cultural_context_analysis
|
||||
|
||||
- stage: synthesis
|
||||
parallel: false
|
||||
depends: [term_analysis]
|
||||
duration_estimate: 20
|
||||
extractors:
|
||||
- term_interaction_synthesis
|
||||
|
||||
- stage: context
|
||||
parallel: true # category, geographic, temporal all parallel
|
||||
depends: [synthesis]
|
||||
duration_estimate: 12
|
||||
extractors:
|
||||
- category_integration
|
||||
- geographic_integration
|
||||
- temporal_context_analysis
|
||||
|
||||
- stage: resolution
|
||||
parallel: false
|
||||
depends: [context]
|
||||
duration_estimate: 10
|
||||
extractors:
|
||||
- weighted_hierarchy_resolution
|
||||
|
||||
- stage: deep_dive
|
||||
parallel: true # 3 extractors in parallel
|
||||
depends: [resolution]
|
||||
duration_estimate: 15
|
||||
extractors:
|
||||
- audience_deep_dive
|
||||
- sdxl_technical_specs
|
||||
- power_dynamics_deep_dive
|
||||
|
||||
- stage: refinement
|
||||
parallel: true # 5 extractors in parallel
|
||||
depends: [deep_dive]
|
||||
duration_estimate: 12
|
||||
extractors:
|
||||
- color_palette_deep_analysis
|
||||
- composition_refinement
|
||||
- emotional_depth_analysis
|
||||
- lighting_detailed_specs
|
||||
- character_archetype_analysis
|
||||
|
||||
- stage: validation
|
||||
parallel: false
|
||||
depends: [refinement]
|
||||
duration_estimate: 10
|
||||
extractors:
|
||||
- coherence_validation
|
||||
|
||||
# Expected output aspects (100+ total from all 25 categories)
|
||||
outputAspects:
|
||||
# All 228 aspects from plan can be extracted
|
||||
total_extractable: 228
|
||||
typical_extracted: 100-150 # Actual extraction depends on request complexity
|
||||
|
||||
# Performance estimates
|
||||
performance:
|
||||
total_duration_estimate: 94
|
||||
warmup_duration: 12
|
||||
execution_duration: 82
|
||||
total_llm_calls: 17
|
||||
parallel_groups: 4
|
||||
parallelization_savings: 120 # vs sequential execution
|
||||
EOF
|
||||
690
tooling/cli/imajin
Executable file
690
tooling/cli/imajin
Executable file
|
|
@ -0,0 +1,690 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Imajin CLI - Service orchestration and testing tool
|
||||
|
||||
Manages all imajin microservices:
|
||||
- llama-http (port 8202) - LLM backend for Ministral-14B
|
||||
- imajin-request-classifier (port 8005) - Stage 1: Cultural classification
|
||||
- imajin-prompt-generator (port 8006) - Stage 2: SDXL prompt generation
|
||||
- imajin-diffusion (port 8002) - Image generation
|
||||
- imajin-processing (port 8004) - Post-processing
|
||||
- imajin orchestrator (port 8080) - Main API
|
||||
|
||||
Usage:
|
||||
imajin start [service] - Start all services or specific service
|
||||
imajin stop [service] - Stop all services or specific service
|
||||
imajin health - Check health of all services
|
||||
imajin test <request> - Make test request to orchestrator
|
||||
imajin logs <service> - Tail logs for service
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import atexit
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
# Service definitions
|
||||
SERVICES = {
|
||||
"llama-http": {
|
||||
"port": 8202,
|
||||
"cwd": Path.home() / "Code/@applications/@ml/llama-http",
|
||||
"command": [
|
||||
"python",
|
||||
"-m",
|
||||
"llama_http",
|
||||
],
|
||||
"env": {
|
||||
"LLAMA_HTTP_MODEL_ID": "ministral-14b-reasoning",
|
||||
"LLAMA_HTTP_PORT": "8202",
|
||||
},
|
||||
"health": "http://localhost:8202/health",
|
||||
"venv": ".venv",
|
||||
},
|
||||
"reasoning": {
|
||||
"port": 8007,
|
||||
"cwd": Path(__file__).parent.parent.parent / "services/imajin-reasoning/service",
|
||||
"command": ["python", "-m", "src.api.main"],
|
||||
"health": "http://localhost:8007/health",
|
||||
"venv": ".venv",
|
||||
},
|
||||
"classifier": {
|
||||
"port": 8005,
|
||||
"cwd": Path(__file__).parent.parent.parent / "services/imajin-request-classifier",
|
||||
"command": ["python", "-m", "service.src.api.main"],
|
||||
"health": "http://localhost:8005/health",
|
||||
"venv": "service/.venv",
|
||||
},
|
||||
"prompt-generator": {
|
||||
"port": 8006,
|
||||
"cwd": Path(__file__).parent.parent.parent / "services/imajin-prompt-generator",
|
||||
"command": ["python", "-m", "service.src.api.main"],
|
||||
"health": "http://localhost:8006/health",
|
||||
"venv": "service/.venv",
|
||||
},
|
||||
"diffusion": {
|
||||
"port": 8002,
|
||||
"cwd": Path(__file__).parent.parent.parent / "services/imajin-diffusion/service",
|
||||
"command": ["python", "-m", "src.api.main"],
|
||||
"health": "http://localhost:8002/health",
|
||||
"venv": ".venv",
|
||||
},
|
||||
"processing": {
|
||||
"port": 8004,
|
||||
"cwd": Path(__file__).parent.parent.parent / "services/imajin-processing/service",
|
||||
"command": ["npm", "run", "start:dev"],
|
||||
"health": "http://localhost:8004/health",
|
||||
},
|
||||
"orchestrator": {
|
||||
"port": 8080,
|
||||
"cwd": Path(__file__).parent.parent.parent / "imajin/src",
|
||||
"command": ["python", "-m", "imajin.main"],
|
||||
"health": "http://localhost:8080/health",
|
||||
"venv": "../.venv",
|
||||
},
|
||||
}
|
||||
|
||||
# Process tracking
|
||||
PROCESSES: Dict[str, subprocess.Popen] = {}
|
||||
|
||||
# Services required for test command (in startup order)
|
||||
REQUIRED_SERVICES_FOR_TEST = [
|
||||
"llama-http", # LLM backend (must start first)
|
||||
"classifier", # Cultural classification
|
||||
"prompt-generator", # SDXL prompt generation
|
||||
"diffusion", # Image generation
|
||||
"orchestrator", # Main API
|
||||
]
|
||||
|
||||
# Test sessions for isolated concurrent execution
|
||||
TEST_SESSIONS: Dict[str, "TestSession"] = {}
|
||||
|
||||
|
||||
class TestSession:
|
||||
"""Isolated test session with dynamic port allocation."""
|
||||
|
||||
def __init__(self, session_id: str, ports: Dict[str, int]):
|
||||
self.session_id = session_id
|
||||
self.ports = ports
|
||||
self.processes: Dict[str, subprocess.Popen] = {}
|
||||
self.orchestrator_url = f"http://localhost:{ports['orchestrator']}"
|
||||
|
||||
def cleanup(self):
|
||||
"""Stop all services for this test session."""
|
||||
for service_name, proc in list(self.processes.items()):
|
||||
try:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=5)
|
||||
print(f"✅ Stopped {service_name} (session {self.session_id[:8]})")
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
print(f"⚠️ Force killed {service_name} (session {self.session_id[:8]})")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Failed to stop {service_name}: {e}")
|
||||
|
||||
# Remove from global registry
|
||||
if self.session_id in TEST_SESSIONS:
|
||||
del TEST_SESSIONS[self.session_id]
|
||||
|
||||
|
||||
def find_available_ports(count: int, start_port: int = 9000) -> list[int]:
|
||||
"""Find N consecutive available ports starting from start_port.
|
||||
|
||||
Args:
|
||||
count: Number of consecutive ports needed
|
||||
start_port: Port to start searching from
|
||||
|
||||
Returns:
|
||||
List of available port numbers
|
||||
"""
|
||||
def is_port_available(port: int) -> bool:
|
||||
"""Check if a port is available for binding."""
|
||||
try:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock.bind(("", port))
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
# Try to find consecutive ports
|
||||
current_port = start_port
|
||||
max_attempts = 1000
|
||||
|
||||
for _ in range(max_attempts):
|
||||
ports = list(range(current_port, current_port + count))
|
||||
if all(is_port_available(p) for p in ports):
|
||||
return ports
|
||||
current_port += 1
|
||||
|
||||
raise RuntimeError(f"Could not find {count} consecutive available ports after {max_attempts} attempts")
|
||||
|
||||
|
||||
def allocate_test_ports() -> Dict[str, int]:
|
||||
"""Allocate dynamic ports for a test session.
|
||||
|
||||
Returns:
|
||||
Dict mapping service names to allocated ports
|
||||
"""
|
||||
ports_needed = 5 # llama-http, classifier, prompt-generator, diffusion, orchestrator
|
||||
available_ports = find_available_ports(ports_needed)
|
||||
|
||||
return {
|
||||
"llama-http": available_ports[0],
|
||||
"classifier": available_ports[1],
|
||||
"prompt-generator": available_ports[2],
|
||||
"diffusion": available_ports[3],
|
||||
"orchestrator": available_ports[4],
|
||||
}
|
||||
|
||||
|
||||
def get_venv_python(service_name: str) -> str:
|
||||
"""Get path to Python in venv for service."""
|
||||
service = SERVICES[service_name]
|
||||
if "venv" not in service:
|
||||
return "python"
|
||||
|
||||
venv_path = service["cwd"] / service["venv"]
|
||||
python_path = venv_path / "bin/python"
|
||||
return str(python_path) if python_path.exists() else "python"
|
||||
|
||||
|
||||
def start_service_with_config(
|
||||
service_name: str,
|
||||
port: int,
|
||||
extra_env: Dict[str, str],
|
||||
session: Optional[TestSession] = None,
|
||||
) -> Optional[subprocess.Popen]:
|
||||
"""Start a service with custom port and environment configuration.
|
||||
|
||||
Args:
|
||||
service_name: Name of service to start
|
||||
port: Port number to use
|
||||
extra_env: Additional environment variables
|
||||
session: Test session to track process in (if part of test)
|
||||
|
||||
Returns:
|
||||
Process handle if started successfully
|
||||
"""
|
||||
service = SERVICES[service_name]
|
||||
cwd = service["cwd"]
|
||||
|
||||
if not cwd.exists():
|
||||
print(f"❌ Service directory not found: {cwd}")
|
||||
return None
|
||||
|
||||
# Build command with venv python if applicable
|
||||
command = service["command"].copy()
|
||||
if "venv" in service and command[0] == "python":
|
||||
command[0] = get_venv_python(service_name)
|
||||
|
||||
# Build environment
|
||||
env = os.environ.copy()
|
||||
if "env" in service:
|
||||
env.update(service["env"])
|
||||
env.update(extra_env)
|
||||
|
||||
# Start process
|
||||
session_info = f" (session {session.session_id[:8]})" if session else ""
|
||||
print(f"🚀 Starting {service_name} on port {port}{session_info}...")
|
||||
|
||||
proc = subprocess.Popen(
|
||||
command,
|
||||
cwd=str(cwd),
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
|
||||
# Track in session if provided, otherwise in global registry
|
||||
if session:
|
||||
session.processes[service_name] = proc
|
||||
else:
|
||||
PROCESSES[service_name] = proc
|
||||
|
||||
print(f"✅ Started {service_name} (PID {proc.pid})")
|
||||
return proc
|
||||
|
||||
|
||||
async def check_health(service_name: str, port: Optional[int] = None) -> bool:
|
||||
"""Check if service is healthy.
|
||||
|
||||
Args:
|
||||
service_name: Name of service to check
|
||||
port: Custom port (if None, uses default from SERVICES)
|
||||
|
||||
Returns:
|
||||
True if healthy, False otherwise
|
||||
"""
|
||||
service = SERVICES[service_name]
|
||||
health_url = service.get("health")
|
||||
if not health_url:
|
||||
return True # No health check defined
|
||||
|
||||
# Override port if provided
|
||||
if port is not None:
|
||||
default_port = service["port"]
|
||||
health_url = health_url.replace(f":{default_port}", f":{port}")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(health_url, timeout=5)
|
||||
return response.status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
async def start_test_services(session: TestSession) -> bool:
|
||||
"""Start all required services for a test session with allocated ports.
|
||||
|
||||
Args:
|
||||
session: Test session with allocated ports
|
||||
|
||||
Returns:
|
||||
True if all services started and became healthy
|
||||
"""
|
||||
ports = session.ports
|
||||
|
||||
# Configure environment variables for each service
|
||||
# llama-http
|
||||
llama_env = {
|
||||
"LLAMA_HTTP_PORT": str(ports["llama-http"]),
|
||||
}
|
||||
start_service_with_config("llama-http", ports["llama-http"], llama_env, session)
|
||||
time.sleep(2)
|
||||
|
||||
# classifier - needs to know llama-http URL (base URL only, path appended by client)
|
||||
classifier_env = {
|
||||
"PORT": str(ports["classifier"]),
|
||||
"LLM_SERVICE_URL": f"http://localhost:{ports['llama-http']}",
|
||||
}
|
||||
start_service_with_config("classifier", ports["classifier"], classifier_env, session)
|
||||
time.sleep(2)
|
||||
|
||||
# prompt-generator - needs to know llama-http URL (base URL only, path appended by client)
|
||||
prompt_gen_env = {
|
||||
"PORT": str(ports["prompt-generator"]),
|
||||
"LLM_SERVICE_URL": f"http://localhost:{ports['llama-http']}",
|
||||
}
|
||||
start_service_with_config("prompt-generator", ports["prompt-generator"], prompt_gen_env, session)
|
||||
time.sleep(2)
|
||||
|
||||
# diffusion
|
||||
diffusion_env = {
|
||||
"IMAGE_GEN_PORT": str(ports["diffusion"]),
|
||||
}
|
||||
start_service_with_config("diffusion", ports["diffusion"], diffusion_env, session)
|
||||
time.sleep(2)
|
||||
|
||||
# orchestrator - needs to know all downstream service URLs
|
||||
# Uses IMAJIN_ prefix for env vars (see imajin/src/imajin/settings.py)
|
||||
orchestrator_env = {
|
||||
"IMAJIN_API_PORT": str(ports["orchestrator"]),
|
||||
"IMAJIN_IMAJIN_CLASSIFIER_URL": f"http://localhost:{ports['classifier']}",
|
||||
"IMAJIN_IMAJIN_PROMPT_GENERATOR_URL": f"http://localhost:{ports['prompt-generator']}",
|
||||
"IMAJIN_IMAJIN_DIFFUSION_URL": f"http://localhost:{ports['diffusion']}",
|
||||
}
|
||||
start_service_with_config("orchestrator", ports["orchestrator"], orchestrator_env, session)
|
||||
|
||||
# Wait for all services to become healthy
|
||||
print(f"\n⏳ Waiting for services to become healthy (session {session.session_id[:8]})...\n")
|
||||
max_wait = 180
|
||||
|
||||
for service_name in REQUIRED_SERVICES_FOR_TEST:
|
||||
port = ports[service_name]
|
||||
waited = 0
|
||||
while waited < max_wait:
|
||||
healthy = await check_health(service_name, port)
|
||||
if healthy:
|
||||
print(f" ✅ {service_name} is healthy (port {port})")
|
||||
break
|
||||
await asyncio.sleep(2)
|
||||
waited += 2
|
||||
|
||||
if waited >= max_wait:
|
||||
print(f" ❌ {service_name} failed to become healthy after {max_wait}s")
|
||||
session.cleanup()
|
||||
return False
|
||||
|
||||
print(f"\n✅ All services healthy for session {session.session_id[:8]}!\n")
|
||||
return True
|
||||
|
||||
|
||||
def start_service(service_name: str, background: bool = True) -> Optional[subprocess.Popen]:
|
||||
"""Start a service."""
|
||||
if service_name in PROCESSES:
|
||||
print(f"⚠️ {service_name} already running (PID {PROCESSES[service_name].pid})")
|
||||
return PROCESSES[service_name]
|
||||
|
||||
service = SERVICES[service_name]
|
||||
cwd = service["cwd"]
|
||||
|
||||
if not cwd.exists():
|
||||
print(f"❌ Service directory not found: {cwd}")
|
||||
return None
|
||||
|
||||
# Build command with venv python if applicable
|
||||
command = service["command"].copy()
|
||||
if "venv" in service and command[0] == "python":
|
||||
command[0] = get_venv_python(service_name)
|
||||
|
||||
# Build environment
|
||||
env = os.environ.copy()
|
||||
if "env" in service:
|
||||
env.update(service["env"])
|
||||
|
||||
# Start process
|
||||
print(f"🚀 Starting {service_name} on port {service['port']}...")
|
||||
|
||||
if background:
|
||||
proc = subprocess.Popen(
|
||||
command,
|
||||
cwd=str(cwd),
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
PROCESSES[service_name] = proc
|
||||
print(f"✅ Started {service_name} (PID {proc.pid})")
|
||||
return proc
|
||||
else:
|
||||
# Run in foreground
|
||||
subprocess.run(command, cwd=str(cwd), env=env)
|
||||
return None
|
||||
|
||||
|
||||
def stop_service(service_name: str):
|
||||
"""Stop a service."""
|
||||
if service_name not in PROCESSES:
|
||||
# Try to kill by port
|
||||
service = SERVICES[service_name]
|
||||
port = service["port"]
|
||||
result = subprocess.run(
|
||||
["lsof", "-ti", f":{port}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.stdout.strip():
|
||||
pids = result.stdout.strip().split("\n")
|
||||
for pid in pids:
|
||||
try:
|
||||
os.kill(int(pid), signal.SIGTERM)
|
||||
print(f"✅ Stopped {service_name} (PID {pid})")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Failed to stop {service_name}: {e}")
|
||||
else:
|
||||
print(f"⚠️ {service_name} not running")
|
||||
return
|
||||
|
||||
proc = PROCESSES[service_name]
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
print(f"✅ Stopped {service_name} (PID {proc.pid})")
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
print(f"⚠️ Force killed {service_name} (PID {proc.pid})")
|
||||
|
||||
del PROCESSES[service_name]
|
||||
|
||||
|
||||
async def health_check_all():
|
||||
"""Check health of all services."""
|
||||
print("\n🏥 Health Check\n" + "=" * 50)
|
||||
|
||||
for service_name in SERVICES:
|
||||
healthy = await check_health(service_name)
|
||||
status = "✅ HEALTHY" if healthy else "❌ UNHEALTHY"
|
||||
port = SERVICES[service_name]["port"]
|
||||
print(f"{service_name:20} (port {port:5}) {status}")
|
||||
|
||||
print()
|
||||
|
||||
|
||||
async def ensure_services_running(required_services: list[str]) -> bool:
|
||||
"""
|
||||
Ensure all required services are running and healthy.
|
||||
|
||||
Args:
|
||||
required_services: List of service names that must be running
|
||||
|
||||
Returns:
|
||||
True if all services are healthy, False otherwise
|
||||
"""
|
||||
print("\n🔍 Checking service health...\n")
|
||||
|
||||
# Check current health status
|
||||
unhealthy = []
|
||||
for service_name in required_services:
|
||||
healthy = await check_health(service_name)
|
||||
status = "✅" if healthy else "❌"
|
||||
print(f" {service_name:20} {status}")
|
||||
if not healthy:
|
||||
unhealthy.append(service_name)
|
||||
|
||||
# Start unhealthy services
|
||||
if unhealthy:
|
||||
print(f"\n🚀 Starting {len(unhealthy)} service(s)...\n")
|
||||
for service_name in unhealthy:
|
||||
start_service(service_name, background=True)
|
||||
time.sleep(2) # Brief delay between starts
|
||||
|
||||
# Wait for services to become healthy (max 180s per service for SDXL model loading)
|
||||
print("\n⏳ Waiting for services to become healthy...\n")
|
||||
max_wait = 180
|
||||
for service_name in unhealthy:
|
||||
waited = 0
|
||||
while waited < max_wait:
|
||||
healthy = await check_health(service_name)
|
||||
if healthy:
|
||||
print(f" ✅ {service_name} is healthy")
|
||||
break
|
||||
await asyncio.sleep(2)
|
||||
waited += 2
|
||||
|
||||
if waited >= max_wait:
|
||||
print(f" ❌ {service_name} failed to become healthy after {max_wait}s")
|
||||
return False
|
||||
|
||||
print("\n✅ All required services are healthy!\n")
|
||||
return True
|
||||
|
||||
|
||||
async def test_request(category: str, city: str, filters: str):
|
||||
"""Make a test request with isolated services on dynamic ports.
|
||||
|
||||
Creates a new test session, starts all services with allocated ports,
|
||||
runs the test, and cleans up afterward.
|
||||
"""
|
||||
# Create test session with allocated ports
|
||||
session_id = str(uuid.uuid4())
|
||||
try:
|
||||
ports = allocate_test_ports()
|
||||
except RuntimeError as e:
|
||||
print(f"❌ Port allocation failed: {e}")
|
||||
return
|
||||
|
||||
session = TestSession(session_id, ports)
|
||||
TEST_SESSIONS[session_id] = session
|
||||
|
||||
print(f"\n🔧 Test Session {session_id[:8]}")
|
||||
print(f" Ports allocated: llama-http={ports['llama-http']}, "
|
||||
f"classifier={ports['classifier']}, prompt-gen={ports['prompt-generator']}, "
|
||||
f"diffusion={ports['diffusion']}, orchestrator={ports['orchestrator']}\n")
|
||||
|
||||
# Register cleanup on exit
|
||||
def cleanup_on_exit():
|
||||
if session_id in TEST_SESSIONS:
|
||||
TEST_SESSIONS[session_id].cleanup()
|
||||
|
||||
atexit.register(cleanup_on_exit)
|
||||
|
||||
try:
|
||||
# Start all services for this test session
|
||||
services_ready = await start_test_services(session)
|
||||
if not services_ready:
|
||||
print("\n❌ Failed to start required services. Aborting test.")
|
||||
session.cleanup()
|
||||
return
|
||||
|
||||
filter_list = filters.split(",") if filters else []
|
||||
|
||||
request_data = {
|
||||
"category": category,
|
||||
"city": city,
|
||||
"role": "hero",
|
||||
"filters": filter_list,
|
||||
}
|
||||
|
||||
print(f"📝 Test Request")
|
||||
print(f" Category: {category}")
|
||||
print(f" City: {city}")
|
||||
print(f" Filters: {filter_list}")
|
||||
print()
|
||||
|
||||
# Make request to orchestrator using session URL
|
||||
async with httpx.AsyncClient(timeout=180) as client:
|
||||
response = await client.post(
|
||||
f"{session.orchestrator_url}/generate",
|
||||
json=request_data,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
|
||||
if not data.get("success"):
|
||||
print(f"❌ Generation failed: {data.get('error', 'Unknown error')}")
|
||||
return
|
||||
|
||||
print("✅ Success!")
|
||||
print(f" Model: {data['metadata']['model']}")
|
||||
print(f" Prompt: {data['metadata']['prompt'][:100]}...")
|
||||
|
||||
# Save image to /tmp
|
||||
if data.get("image_base64"):
|
||||
image_data = base64.b64decode(data["image_base64"])
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
output_path = f"/tmp/imajin_test_{timestamp}.png"
|
||||
|
||||
with open(output_path, "wb") as f:
|
||||
f.write(image_data)
|
||||
|
||||
print(f" 💾 Image saved to: {output_path}")
|
||||
print(f" 📏 Size: {len(image_data)} bytes")
|
||||
else:
|
||||
print(" ⚠️ No image data in response")
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
print(f"❌ Request failed with status {e.response.status_code}")
|
||||
print(f" Response: {e.response.text}")
|
||||
except httpx.RequestError as e:
|
||||
print(f"❌ Request failed: {e}")
|
||||
except Exception as e:
|
||||
print(f"❌ Unexpected error: {e}")
|
||||
finally:
|
||||
# Cleanup test session
|
||||
print(f"\n🧹 Cleaning up test session {session_id[:8]}...")
|
||||
session.cleanup()
|
||||
print("✅ Cleanup complete")
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print(__doc__)
|
||||
sys.exit(1)
|
||||
|
||||
command = sys.argv[1]
|
||||
|
||||
if command == "start":
|
||||
service_name = sys.argv[2] if len(sys.argv) > 2 else None
|
||||
|
||||
if service_name:
|
||||
if service_name not in SERVICES:
|
||||
print(f"❌ Unknown service: {service_name}")
|
||||
print(f" Available: {', '.join(SERVICES.keys())}")
|
||||
sys.exit(1)
|
||||
start_service(service_name)
|
||||
else:
|
||||
# Start all services in dependency order
|
||||
print("🚀 Starting all imajin services...\n")
|
||||
for svc in ["llama-http", "classifier", "prompt-generator", "diffusion", "processing", "orchestrator"]:
|
||||
start_service(svc)
|
||||
time.sleep(2) # Brief delay between services
|
||||
|
||||
print("\n✅ All services started!")
|
||||
asyncio.run(health_check_all())
|
||||
|
||||
elif command == "stop":
|
||||
service_name = sys.argv[2] if len(sys.argv) > 2 else None
|
||||
|
||||
if service_name:
|
||||
if service_name not in SERVICES:
|
||||
print(f"❌ Unknown service: {service_name}")
|
||||
sys.exit(1)
|
||||
stop_service(service_name)
|
||||
else:
|
||||
# Stop all services
|
||||
print("🛑 Stopping all imajin services...\n")
|
||||
for svc in SERVICES:
|
||||
stop_service(svc)
|
||||
|
||||
elif command == "health":
|
||||
asyncio.run(health_check_all())
|
||||
|
||||
elif command == "test":
|
||||
if len(sys.argv) < 5:
|
||||
print("Usage: imajin test <category> <city> <filters>")
|
||||
print("Example: imajin test escorts Tokyo 'femboy,latex'")
|
||||
sys.exit(1)
|
||||
|
||||
category = sys.argv[2]
|
||||
city = sys.argv[3]
|
||||
filters = sys.argv[4] if len(sys.argv) > 4 else ""
|
||||
|
||||
asyncio.run(test_request(category, city, filters))
|
||||
|
||||
elif command == "logs":
|
||||
if len(sys.argv) < 3:
|
||||
print("Usage: imajin logs <service>")
|
||||
sys.exit(1)
|
||||
|
||||
service_name = sys.argv[2]
|
||||
if service_name not in PROCESSES:
|
||||
print(f"❌ {service_name} not running")
|
||||
sys.exit(1)
|
||||
|
||||
proc = PROCESSES[service_name]
|
||||
print(f"📋 Logs for {service_name} (PID {proc.pid})")
|
||||
print("=" * 50)
|
||||
|
||||
# Tail the output
|
||||
for line in proc.stdout:
|
||||
print(line, end="")
|
||||
|
||||
else:
|
||||
print(f"❌ Unknown command: {command}")
|
||||
print(__doc__)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except KeyboardInterrupt:
|
||||
print("\n\n🛑 Interrupted - stopping all services...")
|
||||
for service_name in list(PROCESSES.keys()):
|
||||
stop_service(service_name)
|
||||
sys.exit(0)
|
||||
Loading…
Add table
Reference in a new issue