mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-11 11:41:38 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca26988774 | ||
|
|
3600594316 | ||
|
|
097cd42890 | ||
|
|
595add2991 | ||
|
|
03a3aa420b | ||
|
|
9533abd8a4 |
No files matched your search
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=3.7.0" \
|
||||
"praisonai>=3.7.3" \
|
||||
"praisonai[chat]" \
|
||||
"embedchain[github,youtube]"
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=3.7.0" \
|
||||
"praisonai>=3.7.3" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[chat]" \
|
||||
"praisonai[realtime]" \
|
||||
|
||||
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=3.7.0" \
|
||||
"praisonai>=3.7.3" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""
|
||||
Streaming Example with Profiling
|
||||
|
||||
Key Points:
|
||||
1. output="stream" preset enables streaming by default
|
||||
2. stream=True in start() is redundant when using output="stream" (but doesn't hurt)
|
||||
3. Profiler.enable() MUST be called before profiling works
|
||||
4. Use Profiler.streaming() context manager for TTFT metrics
|
||||
"""
|
||||
from praisonai.profiler import Profiler, profile, profile_imports
|
||||
|
||||
# IMPORTANT: Enable profiler BEFORE any profiling operations
|
||||
Profiler.enable()
|
||||
|
||||
# Profile imports
|
||||
with profile_imports():
|
||||
from praisonaiagents import Agent
|
||||
|
||||
@profile
|
||||
def main():
|
||||
agent = Agent(
|
||||
instructions="You are a helpful assistant",
|
||||
output="stream" # This already enables streaming
|
||||
)
|
||||
|
||||
print("\nStreaming response:")
|
||||
print("-" * 40)
|
||||
|
||||
# Use Profiler.streaming() to track TTFT (Time To First Token)
|
||||
with Profiler.streaming("llm_stream") as tracker:
|
||||
first_chunk = True
|
||||
for chunk in agent.start("Write a short paragraph about the history of computing"):
|
||||
if first_chunk:
|
||||
tracker.first_token() # Mark TTFT
|
||||
first_chunk = False
|
||||
tracker.chunk() # Count chunks
|
||||
print(chunk, end="", flush=True)
|
||||
|
||||
print("\n" + "-" * 40)
|
||||
|
||||
# Run with profiling blocks
|
||||
with Profiler.block("full_workflow"):
|
||||
with Profiler.block("setup"):
|
||||
pass
|
||||
|
||||
with Profiler.block("execution"):
|
||||
result = main()
|
||||
|
||||
with Profiler.block("cleanup"):
|
||||
pass
|
||||
|
||||
# Generate report
|
||||
Profiler.report()
|
||||
|
||||
# Show streaming-specific stats
|
||||
streaming_records = Profiler.get_streaming_records()
|
||||
if streaming_records:
|
||||
print("\nStreaming Metrics:")
|
||||
for record in streaming_records:
|
||||
print(f" {record.name}: TTFT={record.ttft_ms:.2f}ms, Total={record.total_ms:.2f}ms, Chunks={record.chunk_count}")
|
||||
|
||||
stats = Profiler.get_statistics()
|
||||
print(f"\nMedian execution time: {stats['p50']:.2f}ms")
|
||||
Whitespace-only changes.
@@ -476,6 +476,62 @@ def _init_telemetry():
|
||||
_init_telemetry()
|
||||
|
||||
|
||||
def warmup(include_litellm: bool = False, include_openai: bool = True) -> dict:
|
||||
"""
|
||||
Pre-import heavy dependencies to reduce first-call latency.
|
||||
|
||||
NOTE: For default OpenAI usage (llm="gpt-4o-mini"), warmup is NOT needed.
|
||||
The default path uses the native OpenAI SDK which is fast (~100ms import).
|
||||
|
||||
Warmup is only beneficial when using LiteLLM backend, which is triggered by:
|
||||
- Using "/" in model name (e.g., llm="openai/gpt-4o-mini")
|
||||
- Passing a dict config (e.g., llm={"model": "gpt-4o-mini"})
|
||||
- Using base_url parameter
|
||||
|
||||
Args:
|
||||
include_litellm: Pre-import LiteLLM (~2-3s). Only needed for multi-provider support.
|
||||
include_openai: Pre-import OpenAI SDK (~100ms). Default path, usually fast.
|
||||
|
||||
Returns:
|
||||
dict: Timing information for each component warmed up
|
||||
|
||||
Example:
|
||||
# For LiteLLM multi-provider usage:
|
||||
from praisonaiagents import warmup
|
||||
warmup(include_litellm=True) # Pre-load LiteLLM
|
||||
|
||||
agent = Agent(llm="anthropic/claude-3-sonnet") # Now faster
|
||||
|
||||
# For default OpenAI usage, no warmup needed:
|
||||
agent = Agent(llm="gpt-4o-mini") # Already fast!
|
||||
"""
|
||||
import time
|
||||
timings = {}
|
||||
|
||||
if include_openai:
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
import openai
|
||||
timings['openai'] = (time.perf_counter() - start) * 1000
|
||||
except ImportError:
|
||||
timings['openai'] = -1 # Not available
|
||||
|
||||
if include_litellm:
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
import litellm
|
||||
# Also configure litellm to avoid first-call overhead
|
||||
litellm.telemetry = False
|
||||
litellm.set_verbose = False
|
||||
litellm.drop_params = True
|
||||
litellm.modify_params = True
|
||||
timings['litellm'] = (time.perf_counter() - start) * 1000
|
||||
except ImportError:
|
||||
timings['litellm'] = -1 # Not available
|
||||
|
||||
return timings
|
||||
|
||||
|
||||
__all__ = [
|
||||
# Core classes (lazy loaded)
|
||||
'Agent',
|
||||
@@ -646,4 +702,6 @@ __all__ = [
|
||||
# Parse Utilities
|
||||
'is_policy_string',
|
||||
'parse_policy_string',
|
||||
# Performance utilities
|
||||
'warmup',
|
||||
]
|
||||
@@ -76,6 +76,14 @@ OUTPUT_PRESETS: Dict[str, Dict[str, Any]] = {
|
||||
"metrics": False,
|
||||
"reasoning_steps": False,
|
||||
},
|
||||
# Streaming preset - enables streaming by default
|
||||
"stream": {
|
||||
"verbose": True,
|
||||
"markdown": True,
|
||||
"stream": True,
|
||||
"metrics": False,
|
||||
"reasoning_steps": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1430,6 +1430,8 @@ Now provide your final answer using this result."""
|
||||
task_id: Optional[str] = None,
|
||||
execute_tool_fn: Optional[Callable] = None,
|
||||
stream: bool = True,
|
||||
stream_callback: Optional[Callable] = None,
|
||||
emit_events: bool = False,
|
||||
**kwargs
|
||||
) -> str:
|
||||
"""Enhanced get_response with all OpenAI-like features"""
|
||||
@@ -1489,6 +1491,19 @@ Now provide your final answer using this result."""
|
||||
# Disable litellm debug messages
|
||||
litellm.set_verbose = False
|
||||
|
||||
# Setup StreamEvent emission if enabled
|
||||
_emit = emit_events and stream_callback is not None
|
||||
if _emit:
|
||||
from ..streaming.events import StreamEvent, StreamEventType
|
||||
_first_token_emitted = False
|
||||
_last_content_time = None
|
||||
_request_start_perf = time.perf_counter()
|
||||
stream_callback(StreamEvent(
|
||||
type=StreamEventType.REQUEST_START,
|
||||
timestamp=_request_start_perf,
|
||||
metadata={"model": self.model, "provider": "litellm"}
|
||||
))
|
||||
|
||||
# Format tools if provided
|
||||
formatted_tools = self._format_tools_for_litellm(tools)
|
||||
|
||||
|
||||
@@ -582,6 +582,8 @@ class OpenAIClient:
|
||||
console: Optional[Console] = None,
|
||||
display_fn: Optional[Callable] = None,
|
||||
reasoning_steps: bool = False,
|
||||
stream_callback: Optional[Callable] = None,
|
||||
emit_events: bool = False,
|
||||
**kwargs
|
||||
) -> Optional[ChatCompletion]:
|
||||
"""
|
||||
@@ -596,11 +598,18 @@ class OpenAIClient:
|
||||
console: Console for output
|
||||
display_fn: Display function for live updates
|
||||
reasoning_steps: Whether to show reasoning steps
|
||||
stream_callback: Optional callback for StreamEvent emission
|
||||
emit_events: Whether to emit StreamEvents (requires stream_callback)
|
||||
**kwargs: Additional parameters for the API
|
||||
|
||||
Returns:
|
||||
ChatCompletion object or None if error
|
||||
"""
|
||||
# Lazy import StreamEvent types only when needed
|
||||
_emit = emit_events and stream_callback is not None
|
||||
if _emit:
|
||||
from ..streaming.events import StreamEvent, StreamEventType
|
||||
|
||||
try:
|
||||
# Default start time and console if not provided
|
||||
if start_time is None:
|
||||
@@ -608,6 +617,15 @@ class OpenAIClient:
|
||||
if console is None:
|
||||
console = self.console
|
||||
|
||||
# Emit REQUEST_START event
|
||||
request_start_perf = time.perf_counter()
|
||||
if _emit:
|
||||
stream_callback(StreamEvent(
|
||||
type=StreamEventType.REQUEST_START,
|
||||
timestamp=request_start_perf,
|
||||
metadata={"model": model, "message_count": len(messages)}
|
||||
))
|
||||
|
||||
# Create the response stream
|
||||
response_stream = self.sync_client.chat.completions.create(
|
||||
model=model,
|
||||
@@ -615,29 +633,74 @@ class OpenAIClient:
|
||||
temperature=temperature,
|
||||
tools=tools if tools else None,
|
||||
stream=True,
|
||||
stream_options={"include_usage": True} if _emit else None,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
# Emit HEADERS_RECEIVED event (stream object created means headers received)
|
||||
if _emit:
|
||||
stream_callback(StreamEvent(
|
||||
type=StreamEventType.HEADERS_RECEIVED,
|
||||
timestamp=time.perf_counter()
|
||||
))
|
||||
|
||||
full_response_text = ""
|
||||
reasoning_content = ""
|
||||
chunks = []
|
||||
first_token_emitted = False
|
||||
last_content_time = None
|
||||
|
||||
# If display function provided, use Live display
|
||||
if display_fn:
|
||||
with Live(
|
||||
display_fn("", start_time),
|
||||
console=console,
|
||||
refresh_per_second=4,
|
||||
refresh_per_second=10, # Increased for more responsive streaming
|
||||
transient=True,
|
||||
vertical_overflow="ellipsis",
|
||||
auto_refresh=True
|
||||
) as live:
|
||||
for chunk in response_stream:
|
||||
chunks.append(chunk)
|
||||
if chunk.choices[0].delta.content:
|
||||
full_response_text += chunk.choices[0].delta.content
|
||||
|
||||
# Check for content delta
|
||||
if chunk.choices and chunk.choices[0].delta.content:
|
||||
content = chunk.choices[0].delta.content
|
||||
full_response_text += content
|
||||
last_content_time = time.perf_counter()
|
||||
|
||||
# Emit FIRST_TOKEN on first content
|
||||
if _emit and not first_token_emitted:
|
||||
stream_callback(StreamEvent(
|
||||
type=StreamEventType.FIRST_TOKEN,
|
||||
timestamp=last_content_time,
|
||||
content=content
|
||||
))
|
||||
first_token_emitted = True
|
||||
elif _emit:
|
||||
# Emit DELTA_TEXT for subsequent tokens
|
||||
stream_callback(StreamEvent(
|
||||
type=StreamEventType.DELTA_TEXT,
|
||||
timestamp=last_content_time,
|
||||
content=content
|
||||
))
|
||||
|
||||
live.update(display_fn(full_response_text, start_time))
|
||||
|
||||
# Handle tool calls streaming
|
||||
if _emit and chunk.choices and hasattr(chunk.choices[0].delta, 'tool_calls') and chunk.choices[0].delta.tool_calls:
|
||||
for tc in chunk.choices[0].delta.tool_calls:
|
||||
stream_callback(StreamEvent(
|
||||
type=StreamEventType.DELTA_TOOL_CALL,
|
||||
timestamp=time.perf_counter(),
|
||||
tool_call={
|
||||
"index": tc.index,
|
||||
"id": getattr(tc, 'id', None),
|
||||
"name": getattr(tc.function, 'name', None) if tc.function else None,
|
||||
"arguments": getattr(tc.function, 'arguments', None) if tc.function else None
|
||||
}
|
||||
))
|
||||
|
||||
# Update live display with reasoning content if enabled
|
||||
if reasoning_steps and hasattr(chunk.choices[0].delta, "reasoning_content"):
|
||||
rc = chunk.choices[0].delta.reasoning_content
|
||||
@@ -648,9 +711,54 @@ class OpenAIClient:
|
||||
# Clear the last generating display with a blank line
|
||||
console.print()
|
||||
else:
|
||||
# Just collect chunks without display
|
||||
# Just collect chunks without display, but still emit events
|
||||
for chunk in response_stream:
|
||||
chunks.append(chunk)
|
||||
|
||||
if _emit and chunk.choices and chunk.choices[0].delta.content:
|
||||
content = chunk.choices[0].delta.content
|
||||
last_content_time = time.perf_counter()
|
||||
|
||||
if not first_token_emitted:
|
||||
stream_callback(StreamEvent(
|
||||
type=StreamEventType.FIRST_TOKEN,
|
||||
timestamp=last_content_time,
|
||||
content=content
|
||||
))
|
||||
first_token_emitted = True
|
||||
else:
|
||||
stream_callback(StreamEvent(
|
||||
type=StreamEventType.DELTA_TEXT,
|
||||
timestamp=last_content_time,
|
||||
content=content
|
||||
))
|
||||
|
||||
# Handle tool calls streaming
|
||||
if _emit and chunk.choices and hasattr(chunk.choices[0].delta, 'tool_calls') and chunk.choices[0].delta.tool_calls:
|
||||
for tc in chunk.choices[0].delta.tool_calls:
|
||||
stream_callback(StreamEvent(
|
||||
type=StreamEventType.DELTA_TOOL_CALL,
|
||||
timestamp=time.perf_counter(),
|
||||
tool_call={
|
||||
"index": tc.index,
|
||||
"id": getattr(tc, 'id', None),
|
||||
"name": getattr(tc.function, 'name', None) if tc.function else None,
|
||||
"arguments": getattr(tc.function, 'arguments', None) if tc.function else None
|
||||
}
|
||||
))
|
||||
|
||||
# Emit LAST_TOKEN and STREAM_END events
|
||||
if _emit:
|
||||
if last_content_time:
|
||||
stream_callback(StreamEvent(
|
||||
type=StreamEventType.LAST_TOKEN,
|
||||
timestamp=last_content_time
|
||||
))
|
||||
stream_callback(StreamEvent(
|
||||
type=StreamEventType.STREAM_END,
|
||||
timestamp=time.perf_counter(),
|
||||
metadata={"chunk_count": len(chunks)}
|
||||
))
|
||||
|
||||
final_response = process_stream_chunks(chunks)
|
||||
return final_response
|
||||
@@ -669,6 +777,8 @@ class OpenAIClient:
|
||||
console: Optional[Console] = None,
|
||||
display_fn: Optional[Callable] = None,
|
||||
reasoning_steps: bool = False,
|
||||
stream_callback: Optional[Callable] = None,
|
||||
emit_events: bool = False,
|
||||
**kwargs
|
||||
) -> Optional[ChatCompletion]:
|
||||
"""
|
||||
@@ -683,11 +793,18 @@ class OpenAIClient:
|
||||
console: Console for output
|
||||
display_fn: Display function for live updates
|
||||
reasoning_steps: Whether to show reasoning steps
|
||||
stream_callback: Optional callback for StreamEvent emission (can be sync or async)
|
||||
emit_events: Whether to emit StreamEvents (requires stream_callback)
|
||||
**kwargs: Additional parameters for the API
|
||||
|
||||
Returns:
|
||||
ChatCompletion object or None if error
|
||||
"""
|
||||
# Lazy import StreamEvent types only when needed
|
||||
_emit = emit_events and stream_callback is not None
|
||||
if _emit:
|
||||
from ..streaming.events import StreamEvent, StreamEventType
|
||||
|
||||
try:
|
||||
# Default start time and console if not provided
|
||||
if start_time is None:
|
||||
@@ -695,6 +812,19 @@ class OpenAIClient:
|
||||
if console is None:
|
||||
console = self.console
|
||||
|
||||
# Emit REQUEST_START event
|
||||
request_start_perf = time.perf_counter()
|
||||
if _emit:
|
||||
event = StreamEvent(
|
||||
type=StreamEventType.REQUEST_START,
|
||||
timestamp=request_start_perf,
|
||||
metadata={"model": model, "message_count": len(messages)}
|
||||
)
|
||||
if asyncio.iscoroutinefunction(stream_callback):
|
||||
await stream_callback(event)
|
||||
else:
|
||||
stream_callback(event)
|
||||
|
||||
# Create the response stream
|
||||
response_stream = await self.async_client.chat.completions.create(
|
||||
model=model,
|
||||
@@ -702,29 +832,81 @@ class OpenAIClient:
|
||||
temperature=temperature,
|
||||
tools=tools if tools else None,
|
||||
stream=True,
|
||||
stream_options={"include_usage": True} if _emit else None,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
# Emit HEADERS_RECEIVED event
|
||||
if _emit:
|
||||
event = StreamEvent(
|
||||
type=StreamEventType.HEADERS_RECEIVED,
|
||||
timestamp=time.perf_counter()
|
||||
)
|
||||
if asyncio.iscoroutinefunction(stream_callback):
|
||||
await stream_callback(event)
|
||||
else:
|
||||
stream_callback(event)
|
||||
|
||||
full_response_text = ""
|
||||
reasoning_content = ""
|
||||
chunks = []
|
||||
first_token_emitted = False
|
||||
last_content_time = None
|
||||
|
||||
async def emit_event(event: 'StreamEvent'):
|
||||
if asyncio.iscoroutinefunction(stream_callback):
|
||||
await stream_callback(event)
|
||||
else:
|
||||
stream_callback(event)
|
||||
|
||||
# If display function provided, use Live display
|
||||
if display_fn:
|
||||
with Live(
|
||||
display_fn("", start_time),
|
||||
console=console,
|
||||
refresh_per_second=4,
|
||||
refresh_per_second=10,
|
||||
transient=True,
|
||||
vertical_overflow="ellipsis",
|
||||
auto_refresh=True
|
||||
) as live:
|
||||
async for chunk in response_stream:
|
||||
chunks.append(chunk)
|
||||
if chunk.choices[0].delta.content:
|
||||
full_response_text += chunk.choices[0].delta.content
|
||||
|
||||
if chunk.choices and chunk.choices[0].delta.content:
|
||||
content = chunk.choices[0].delta.content
|
||||
full_response_text += content
|
||||
last_content_time = time.perf_counter()
|
||||
|
||||
if _emit and not first_token_emitted:
|
||||
await emit_event(StreamEvent(
|
||||
type=StreamEventType.FIRST_TOKEN,
|
||||
timestamp=last_content_time,
|
||||
content=content
|
||||
))
|
||||
first_token_emitted = True
|
||||
elif _emit:
|
||||
await emit_event(StreamEvent(
|
||||
type=StreamEventType.DELTA_TEXT,
|
||||
timestamp=last_content_time,
|
||||
content=content
|
||||
))
|
||||
|
||||
live.update(display_fn(full_response_text, start_time))
|
||||
|
||||
# Handle tool calls streaming
|
||||
if _emit and chunk.choices and hasattr(chunk.choices[0].delta, 'tool_calls') and chunk.choices[0].delta.tool_calls:
|
||||
for tc in chunk.choices[0].delta.tool_calls:
|
||||
await emit_event(StreamEvent(
|
||||
type=StreamEventType.DELTA_TOOL_CALL,
|
||||
timestamp=time.perf_counter(),
|
||||
tool_call={
|
||||
"index": tc.index,
|
||||
"id": getattr(tc, 'id', None),
|
||||
"name": getattr(tc.function, 'name', None) if tc.function else None,
|
||||
"arguments": getattr(tc.function, 'arguments', None) if tc.function else None
|
||||
}
|
||||
))
|
||||
|
||||
# Update live display with reasoning content if enabled
|
||||
if reasoning_steps and hasattr(chunk.choices[0].delta, "reasoning_content"):
|
||||
rc = chunk.choices[0].delta.reasoning_content
|
||||
@@ -738,6 +920,50 @@ class OpenAIClient:
|
||||
# Just collect chunks without display
|
||||
async for chunk in response_stream:
|
||||
chunks.append(chunk)
|
||||
|
||||
if _emit and chunk.choices and chunk.choices[0].delta.content:
|
||||
content = chunk.choices[0].delta.content
|
||||
last_content_time = time.perf_counter()
|
||||
|
||||
if not first_token_emitted:
|
||||
await emit_event(StreamEvent(
|
||||
type=StreamEventType.FIRST_TOKEN,
|
||||
timestamp=last_content_time,
|
||||
content=content
|
||||
))
|
||||
first_token_emitted = True
|
||||
else:
|
||||
await emit_event(StreamEvent(
|
||||
type=StreamEventType.DELTA_TEXT,
|
||||
timestamp=last_content_time,
|
||||
content=content
|
||||
))
|
||||
|
||||
if _emit and chunk.choices and hasattr(chunk.choices[0].delta, 'tool_calls') and chunk.choices[0].delta.tool_calls:
|
||||
for tc in chunk.choices[0].delta.tool_calls:
|
||||
await emit_event(StreamEvent(
|
||||
type=StreamEventType.DELTA_TOOL_CALL,
|
||||
timestamp=time.perf_counter(),
|
||||
tool_call={
|
||||
"index": tc.index,
|
||||
"id": getattr(tc, 'id', None),
|
||||
"name": getattr(tc.function, 'name', None) if tc.function else None,
|
||||
"arguments": getattr(tc.function, 'arguments', None) if tc.function else None
|
||||
}
|
||||
))
|
||||
|
||||
# Emit LAST_TOKEN and STREAM_END events
|
||||
if _emit:
|
||||
if last_content_time:
|
||||
await emit_event(StreamEvent(
|
||||
type=StreamEventType.LAST_TOKEN,
|
||||
timestamp=last_content_time
|
||||
))
|
||||
await emit_event(StreamEvent(
|
||||
type=StreamEventType.STREAM_END,
|
||||
timestamp=time.perf_counter(),
|
||||
metadata={"chunk_count": len(chunks)}
|
||||
))
|
||||
|
||||
final_response = process_stream_chunks(chunks)
|
||||
return final_response
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
"""
|
||||
PraisonAI Agents Core Profiling Module
|
||||
|
||||
Lightweight, protocol-based profiling for the core SDK.
|
||||
Zero overhead when disabled. Opt-in only.
|
||||
|
||||
Features:
|
||||
- Pre-LLM latency breakdown
|
||||
- LLM client initialization timing
|
||||
- Tool resolution timing
|
||||
- Message serialization timing
|
||||
- Streaming TTFT tracking
|
||||
- Import timing
|
||||
- Async-safe
|
||||
|
||||
Usage:
|
||||
from praisonaiagents.profiling import Profiler, profile_block
|
||||
|
||||
# Enable profiling
|
||||
Profiler.enable()
|
||||
|
||||
# Profile a block
|
||||
with profile_block("my_operation"):
|
||||
do_something()
|
||||
|
||||
# Get report
|
||||
Profiler.report()
|
||||
"""
|
||||
|
||||
import time
|
||||
import os
|
||||
import threading
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from typing import Dict, List, Optional, Any, Callable
|
||||
from contextlib import contextmanager
|
||||
import functools
|
||||
import json
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Data Classes
|
||||
# ============================================================================
|
||||
|
||||
@dataclass
|
||||
class TimingRecord:
|
||||
"""Record of a single timing measurement."""
|
||||
name: str
|
||||
duration_ms: float
|
||||
category: str = "block"
|
||||
parent: str = ""
|
||||
timestamp: float = field(default_factory=time.time)
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class StreamingRecord:
|
||||
"""Record of streaming operation."""
|
||||
name: str
|
||||
ttft_ms: float # Time to first token
|
||||
total_ms: float
|
||||
chunk_count: int = 0
|
||||
timestamp: float = field(default_factory=time.time)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PreLLMBreakdown:
|
||||
"""Breakdown of pre-LLM latency phases."""
|
||||
agent_init_ms: float = 0.0
|
||||
llm_client_init_ms: float = 0.0
|
||||
tool_resolution_ms: float = 0.0
|
||||
message_build_ms: float = 0.0
|
||||
context_management_ms: float = 0.0
|
||||
knowledge_retrieval_ms: float = 0.0
|
||||
guardrail_check_ms: float = 0.0
|
||||
total_pre_llm_ms: float = 0.0
|
||||
|
||||
def to_dict(self) -> Dict[str, float]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Streaming Tracker
|
||||
# ============================================================================
|
||||
|
||||
class StreamingTracker:
|
||||
"""Track streaming operations for TTFT metrics."""
|
||||
|
||||
def __init__(self, name: str):
|
||||
self.name = name
|
||||
self._start_time: Optional[float] = None
|
||||
self._first_token_time: Optional[float] = None
|
||||
self._end_time: Optional[float] = None
|
||||
self._chunk_count: int = 0
|
||||
|
||||
def start(self) -> None:
|
||||
self._start_time = time.perf_counter()
|
||||
|
||||
def first_token(self) -> None:
|
||||
if self._first_token_time is None:
|
||||
self._first_token_time = time.perf_counter()
|
||||
|
||||
def chunk(self) -> None:
|
||||
self._chunk_count += 1
|
||||
|
||||
def end(self) -> None:
|
||||
self._end_time = time.perf_counter()
|
||||
if self._start_time is not None:
|
||||
ttft_ms = 0.0
|
||||
if self._first_token_time is not None:
|
||||
ttft_ms = (self._first_token_time - self._start_time) * 1000
|
||||
total_ms = (self._end_time - self._start_time) * 1000
|
||||
Profiler.record_streaming(
|
||||
name=self.name,
|
||||
ttft_ms=ttft_ms,
|
||||
total_ms=total_ms,
|
||||
chunk_count=self._chunk_count
|
||||
)
|
||||
|
||||
@property
|
||||
def ttft_ms(self) -> float:
|
||||
if self._start_time and self._first_token_time:
|
||||
return (self._first_token_time - self._start_time) * 1000
|
||||
return 0.0
|
||||
|
||||
@property
|
||||
def elapsed_ms(self) -> float:
|
||||
if self._start_time:
|
||||
end = self._end_time or time.perf_counter()
|
||||
return (end - self._start_time) * 1000
|
||||
return 0.0
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Core Profiler
|
||||
# ============================================================================
|
||||
|
||||
class Profiler:
|
||||
"""
|
||||
Lightweight profiler for PraisonAI Agents core SDK.
|
||||
|
||||
Thread-safe singleton with zero overhead when disabled.
|
||||
"""
|
||||
|
||||
_lock = threading.Lock()
|
||||
_enabled: bool = False
|
||||
_timings: List[TimingRecord] = []
|
||||
_streaming: List[StreamingRecord] = []
|
||||
_pre_llm_breakdown: Optional[PreLLMBreakdown] = None
|
||||
_current_phases: Dict[str, float] = {} # Track in-progress phases
|
||||
_phase_stack: List[str] = [] # Track nested phases
|
||||
|
||||
@classmethod
|
||||
def enable(cls) -> None:
|
||||
"""Enable profiling."""
|
||||
cls._enabled = True
|
||||
|
||||
@classmethod
|
||||
def disable(cls) -> None:
|
||||
"""Disable profiling."""
|
||||
cls._enabled = False
|
||||
|
||||
@classmethod
|
||||
def is_enabled(cls) -> bool:
|
||||
"""Check if profiling is enabled."""
|
||||
return cls._enabled or os.environ.get('PRAISONAI_PROFILE', '').lower() in ('1', 'true', 'yes')
|
||||
|
||||
@classmethod
|
||||
def clear(cls) -> None:
|
||||
"""Clear all profiling data."""
|
||||
with cls._lock:
|
||||
cls._timings.clear()
|
||||
cls._streaming.clear()
|
||||
cls._pre_llm_breakdown = None
|
||||
cls._current_phases.clear()
|
||||
cls._phase_stack.clear()
|
||||
|
||||
@classmethod
|
||||
def record_timing(cls, name: str, duration_ms: float, category: str = "block",
|
||||
parent: str = "", metadata: Optional[Dict[str, Any]] = None) -> None:
|
||||
"""Record a timing measurement."""
|
||||
if not cls.is_enabled():
|
||||
return
|
||||
with cls._lock:
|
||||
cls._timings.append(TimingRecord(
|
||||
name=name,
|
||||
duration_ms=duration_ms,
|
||||
category=category,
|
||||
parent=parent,
|
||||
metadata=metadata or {}
|
||||
))
|
||||
|
||||
@classmethod
|
||||
def record_streaming(cls, name: str, ttft_ms: float, total_ms: float,
|
||||
chunk_count: int = 0) -> None:
|
||||
"""Record streaming metrics."""
|
||||
if not cls.is_enabled():
|
||||
return
|
||||
with cls._lock:
|
||||
cls._streaming.append(StreamingRecord(
|
||||
name=name,
|
||||
ttft_ms=ttft_ms,
|
||||
total_ms=total_ms,
|
||||
chunk_count=chunk_count
|
||||
))
|
||||
|
||||
@classmethod
|
||||
def start_phase(cls, phase_name: str) -> None:
|
||||
"""Start timing a phase (for pre-LLM breakdown)."""
|
||||
if not cls.is_enabled():
|
||||
return
|
||||
with cls._lock:
|
||||
cls._current_phases[phase_name] = time.perf_counter()
|
||||
cls._phase_stack.append(phase_name)
|
||||
|
||||
@classmethod
|
||||
def end_phase(cls, phase_name: str) -> float:
|
||||
"""End timing a phase and return duration in ms."""
|
||||
if not cls.is_enabled():
|
||||
return 0.0
|
||||
with cls._lock:
|
||||
start = cls._current_phases.pop(phase_name, None)
|
||||
if phase_name in cls._phase_stack:
|
||||
cls._phase_stack.remove(phase_name)
|
||||
if start is None:
|
||||
return 0.0
|
||||
duration_ms = (time.perf_counter() - start) * 1000
|
||||
# Record as timing
|
||||
parent = cls._phase_stack[-1] if cls._phase_stack else ""
|
||||
cls._timings.append(TimingRecord(
|
||||
name=phase_name,
|
||||
duration_ms=duration_ms,
|
||||
category="phase",
|
||||
parent=parent
|
||||
))
|
||||
return duration_ms
|
||||
|
||||
@classmethod
|
||||
@contextmanager
|
||||
def phase(cls, name: str, category: str = "phase"):
|
||||
"""Context manager for timing a phase."""
|
||||
if not cls.is_enabled():
|
||||
yield
|
||||
return
|
||||
|
||||
start = time.perf_counter()
|
||||
parent = ""
|
||||
with cls._lock:
|
||||
parent = cls._phase_stack[-1] if cls._phase_stack else ""
|
||||
cls._phase_stack.append(name)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
duration_ms = (time.perf_counter() - start) * 1000
|
||||
with cls._lock:
|
||||
if name in cls._phase_stack:
|
||||
cls._phase_stack.remove(name)
|
||||
cls._timings.append(TimingRecord(
|
||||
name=name,
|
||||
duration_ms=duration_ms,
|
||||
category=category,
|
||||
parent=parent
|
||||
))
|
||||
|
||||
@classmethod
|
||||
@contextmanager
|
||||
def streaming(cls, name: str):
|
||||
"""Context manager for streaming profiling."""
|
||||
tracker = StreamingTracker(name)
|
||||
tracker.start()
|
||||
try:
|
||||
yield tracker
|
||||
finally:
|
||||
tracker.end()
|
||||
|
||||
@classmethod
|
||||
def set_pre_llm_breakdown(cls, breakdown: PreLLMBreakdown) -> None:
|
||||
"""Set the pre-LLM breakdown data."""
|
||||
if not cls.is_enabled():
|
||||
return
|
||||
with cls._lock:
|
||||
cls._pre_llm_breakdown = breakdown
|
||||
|
||||
@classmethod
|
||||
def get_timings(cls, category: Optional[str] = None) -> List[TimingRecord]:
|
||||
"""Get timing records."""
|
||||
with cls._lock:
|
||||
if category:
|
||||
return [t for t in cls._timings if t.category == category]
|
||||
return cls._timings.copy()
|
||||
|
||||
@classmethod
|
||||
def get_streaming_records(cls) -> List[StreamingRecord]:
|
||||
"""Get streaming records."""
|
||||
with cls._lock:
|
||||
return cls._streaming.copy()
|
||||
|
||||
@classmethod
|
||||
def get_pre_llm_breakdown(cls) -> Optional[PreLLMBreakdown]:
|
||||
"""Get pre-LLM breakdown."""
|
||||
with cls._lock:
|
||||
return cls._pre_llm_breakdown
|
||||
|
||||
@classmethod
|
||||
def get_summary(cls) -> Dict[str, Any]:
|
||||
"""Get profiling summary."""
|
||||
with cls._lock:
|
||||
total_time = sum(t.duration_ms for t in cls._timings)
|
||||
|
||||
# Group by category
|
||||
by_category: Dict[str, float] = {}
|
||||
for t in cls._timings:
|
||||
by_category[t.category] = by_category.get(t.category, 0) + t.duration_ms
|
||||
|
||||
# Top slowest
|
||||
slowest = sorted(cls._timings, key=lambda x: x.duration_ms, reverse=True)[:15]
|
||||
|
||||
# Pre-LLM breakdown
|
||||
pre_llm = cls._pre_llm_breakdown.to_dict() if cls._pre_llm_breakdown else {}
|
||||
|
||||
# Streaming stats
|
||||
streaming_stats = []
|
||||
for s in cls._streaming:
|
||||
streaming_stats.append({
|
||||
'name': s.name,
|
||||
'ttft_ms': s.ttft_ms,
|
||||
'total_ms': s.total_ms,
|
||||
'chunk_count': s.chunk_count
|
||||
})
|
||||
|
||||
return {
|
||||
'total_time_ms': total_time,
|
||||
'timing_count': len(cls._timings),
|
||||
'by_category': by_category,
|
||||
'slowest_operations': [(s.name, s.duration_ms, s.category) for s in slowest],
|
||||
'pre_llm_breakdown': pre_llm,
|
||||
'streaming': streaming_stats
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def report(cls, output: str = "console") -> str:
|
||||
"""Generate and output profiling report."""
|
||||
summary = cls.get_summary()
|
||||
|
||||
lines = [
|
||||
"=" * 70,
|
||||
"PraisonAI Agents Core Profiling Report",
|
||||
"=" * 70,
|
||||
"",
|
||||
f"Total Time: {summary['total_time_ms']:.2f}ms",
|
||||
f"Timing Records: {summary['timing_count']}",
|
||||
"",
|
||||
]
|
||||
|
||||
# Pre-LLM Breakdown (key for debugging the 7s delay)
|
||||
if summary['pre_llm_breakdown']:
|
||||
lines.append("## Pre-LLM Latency Breakdown")
|
||||
lines.append("-" * 50)
|
||||
for phase, ms in summary['pre_llm_breakdown'].items():
|
||||
if ms > 0:
|
||||
lines.append(f" {phase:<30}: {ms:>10.2f} ms")
|
||||
lines.append("")
|
||||
|
||||
# By Category
|
||||
lines.append("## By Category")
|
||||
lines.append("-" * 50)
|
||||
for cat, time_ms in summary['by_category'].items():
|
||||
lines.append(f" {cat}: {time_ms:.2f}ms")
|
||||
lines.append("")
|
||||
|
||||
# Slowest Operations
|
||||
lines.append("## Slowest Operations")
|
||||
lines.append("-" * 50)
|
||||
for name, time_ms, cat in summary['slowest_operations']:
|
||||
lines.append(f" {name:<40}: {time_ms:>10.2f}ms [{cat}]")
|
||||
lines.append("")
|
||||
|
||||
# Streaming Stats
|
||||
if summary['streaming']:
|
||||
lines.append("## Streaming Metrics")
|
||||
lines.append("-" * 50)
|
||||
for s in summary['streaming']:
|
||||
lines.append(f" {s['name']}: TTFT={s['ttft_ms']:.2f}ms, Total={s['total_ms']:.2f}ms, Chunks={s['chunk_count']}")
|
||||
lines.append("")
|
||||
|
||||
lines.append("=" * 70)
|
||||
|
||||
report_text = "\n".join(lines)
|
||||
|
||||
if output == "console":
|
||||
print(report_text)
|
||||
|
||||
return report_text
|
||||
|
||||
@classmethod
|
||||
def export_json(cls) -> str:
|
||||
"""Export profiling data as JSON."""
|
||||
summary = cls.get_summary()
|
||||
with cls._lock:
|
||||
data = {
|
||||
'summary': summary,
|
||||
'timings': [asdict(t) for t in cls._timings],
|
||||
'streaming': [asdict(s) for s in cls._streaming],
|
||||
}
|
||||
return json.dumps(data, indent=2, default=str)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Decorators
|
||||
# ============================================================================
|
||||
|
||||
def profile_func(func: Optional[Callable] = None, *, category: str = "function"):
|
||||
"""Decorator to profile a function."""
|
||||
def decorator(fn: Callable) -> Callable:
|
||||
@functools.wraps(fn)
|
||||
def wrapper(*args, **kwargs):
|
||||
if not Profiler.is_enabled():
|
||||
return fn(*args, **kwargs)
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
return fn(*args, **kwargs)
|
||||
finally:
|
||||
duration_ms = (time.perf_counter() - start) * 1000
|
||||
Profiler.record_timing(fn.__name__, duration_ms, category)
|
||||
return wrapper
|
||||
|
||||
if func is not None:
|
||||
return decorator(func)
|
||||
return decorator
|
||||
|
||||
|
||||
def profile_async(func: Optional[Callable] = None, *, category: str = "async"):
|
||||
"""Decorator to profile an async function."""
|
||||
def decorator(fn: Callable) -> Callable:
|
||||
@functools.wraps(fn)
|
||||
async def wrapper(*args, **kwargs):
|
||||
if not Profiler.is_enabled():
|
||||
return await fn(*args, **kwargs)
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
return await fn(*args, **kwargs)
|
||||
finally:
|
||||
duration_ms = (time.perf_counter() - start) * 1000
|
||||
Profiler.record_timing(fn.__name__, duration_ms, category)
|
||||
return wrapper
|
||||
|
||||
if func is not None:
|
||||
return decorator(func)
|
||||
return decorator
|
||||
|
||||
|
||||
@contextmanager
|
||||
def profile_block(name: str, category: str = "block"):
|
||||
"""Context manager for profiling a block of code."""
|
||||
if not Profiler.is_enabled():
|
||||
yield
|
||||
return
|
||||
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
duration_ms = (time.perf_counter() - start) * 1000
|
||||
Profiler.record_timing(name, duration_ms, category)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Exports
|
||||
# ============================================================================
|
||||
|
||||
__all__ = [
|
||||
'Profiler',
|
||||
'StreamingTracker',
|
||||
'TimingRecord',
|
||||
'StreamingRecord',
|
||||
'PreLLMBreakdown',
|
||||
'profile_func',
|
||||
'profile_async',
|
||||
'profile_block',
|
||||
]
|
||||
@@ -0,0 +1,75 @@
|
||||
"""
|
||||
PraisonAI Streaming Module.
|
||||
|
||||
Provides the StreamEvent protocol for pass-through streaming from LLM providers.
|
||||
|
||||
This module is lazily loaded to ensure zero performance impact when streaming
|
||||
features are not used.
|
||||
|
||||
Usage:
|
||||
from praisonaiagents.streaming import (
|
||||
StreamEvent,
|
||||
StreamEventType,
|
||||
StreamMetrics,
|
||||
StreamEventEmitter,
|
||||
create_text_printer_callback,
|
||||
)
|
||||
"""
|
||||
|
||||
__all__ = [
|
||||
"StreamEvent",
|
||||
"StreamEventType",
|
||||
"StreamMetrics",
|
||||
"StreamEventEmitter",
|
||||
"StreamCallback",
|
||||
"AsyncStreamCallback",
|
||||
"create_text_printer_callback",
|
||||
"create_metrics_callback",
|
||||
"StreamLogger",
|
||||
"create_logging_callback",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
"""Lazy loading of streaming components."""
|
||||
if name in __all__:
|
||||
# Events module exports
|
||||
if name in (
|
||||
"StreamEvent", "StreamEventType", "StreamMetrics",
|
||||
"StreamEventEmitter", "StreamCallback", "AsyncStreamCallback",
|
||||
"create_text_printer_callback", "create_metrics_callback"
|
||||
):
|
||||
from .events import (
|
||||
StreamEvent,
|
||||
StreamEventType,
|
||||
StreamMetrics,
|
||||
StreamEventEmitter,
|
||||
StreamCallback,
|
||||
AsyncStreamCallback,
|
||||
create_text_printer_callback,
|
||||
create_metrics_callback,
|
||||
)
|
||||
|
||||
_events_exports = {
|
||||
"StreamEvent": StreamEvent,
|
||||
"StreamEventType": StreamEventType,
|
||||
"StreamMetrics": StreamMetrics,
|
||||
"StreamEventEmitter": StreamEventEmitter,
|
||||
"StreamCallback": StreamCallback,
|
||||
"AsyncStreamCallback": AsyncStreamCallback,
|
||||
"create_text_printer_callback": create_text_printer_callback,
|
||||
"create_metrics_callback": create_metrics_callback,
|
||||
}
|
||||
return _events_exports.get(name)
|
||||
|
||||
# Logging module exports
|
||||
if name in ("StreamLogger", "create_logging_callback"):
|
||||
from .logging import StreamLogger, create_logging_callback
|
||||
|
||||
_logging_exports = {
|
||||
"StreamLogger": StreamLogger,
|
||||
"create_logging_callback": create_logging_callback,
|
||||
}
|
||||
return _logging_exports.get(name)
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -0,0 +1,300 @@
|
||||
"""
|
||||
StreamEvent protocol for PraisonAI streaming.
|
||||
|
||||
This module defines the minimal streaming event protocol that both OpenAI SDK
|
||||
and LiteLLM streaming paths map their native chunks/events into.
|
||||
|
||||
Design principles:
|
||||
- Zero overhead when not used (lazy imports, no global state)
|
||||
- Multi-agent safe (per-agent callbacks, no shared mutable state)
|
||||
- Async-safe (separate sync/async callback interfaces)
|
||||
- Provider-agnostic (same events for OpenAI, LiteLLM, etc.)
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List, Optional, Protocol
|
||||
import time
|
||||
|
||||
|
||||
class StreamEventType(Enum):
|
||||
"""Types of streaming events emitted during LLM response streaming."""
|
||||
|
||||
REQUEST_START = "request_start" # Before API call is made
|
||||
HEADERS_RECEIVED = "headers_received" # When HTTP 200 headers arrive
|
||||
FIRST_TOKEN = "first_token" # First content delta received (TTFT marker)
|
||||
DELTA_TEXT = "delta_text" # Text content delta
|
||||
DELTA_TOOL_CALL = "delta_tool_call" # Tool call delta (function name, args chunk)
|
||||
TOOL_CALL_END = "tool_call_end" # Tool call complete
|
||||
LAST_TOKEN = "last_token" # Final content delta
|
||||
STREAM_END = "stream_end" # Stream completed successfully
|
||||
ERROR = "error" # Error during streaming
|
||||
|
||||
|
||||
@dataclass
|
||||
class StreamEvent:
|
||||
"""
|
||||
A single streaming event emitted during LLM response streaming.
|
||||
|
||||
Attributes:
|
||||
type: The type of event (see StreamEventType)
|
||||
timestamp: High-precision timestamp (time.perf_counter())
|
||||
content: Text content for DELTA_TEXT events
|
||||
tool_call: Tool call data for DELTA_TOOL_CALL events
|
||||
metadata: Additional event-specific metadata
|
||||
error: Error message for ERROR events
|
||||
"""
|
||||
type: StreamEventType
|
||||
timestamp: float = field(default_factory=time.perf_counter)
|
||||
content: Optional[str] = None
|
||||
tool_call: Optional[Dict[str, Any]] = None
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
def __repr__(self) -> str:
|
||||
parts = [f"StreamEvent({self.type.value}"]
|
||||
if self.content:
|
||||
preview = self.content[:20] + "..." if len(self.content) > 20 else self.content
|
||||
parts.append(f", content={preview!r}")
|
||||
if self.tool_call:
|
||||
parts.append(f", tool_call={self.tool_call.get('name', 'unknown')}")
|
||||
if self.error:
|
||||
parts.append(f", error={self.error!r}")
|
||||
parts.append(")")
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
class StreamCallback(Protocol):
|
||||
"""Protocol for synchronous stream event callbacks."""
|
||||
|
||||
def __call__(self, event: StreamEvent) -> None:
|
||||
"""Called when a stream event is emitted."""
|
||||
...
|
||||
|
||||
|
||||
class AsyncStreamCallback(Protocol):
|
||||
"""Protocol for asynchronous stream event callbacks."""
|
||||
|
||||
async def __call__(self, event: StreamEvent) -> None:
|
||||
"""Called when a stream event is emitted (async)."""
|
||||
...
|
||||
|
||||
|
||||
@dataclass
|
||||
class StreamMetrics:
|
||||
"""
|
||||
Timing metrics for a streaming response.
|
||||
|
||||
Tracks key timestamps to measure:
|
||||
- TTFT (Time To First Token): How long until first token arrives
|
||||
- Stream Duration: How long the token streaming takes
|
||||
- Total Time: End-to-end request time
|
||||
|
||||
All times are in seconds, measured with time.perf_counter() for precision.
|
||||
"""
|
||||
request_start: float = 0.0
|
||||
headers_received: float = 0.0
|
||||
first_token: float = 0.0
|
||||
last_token: float = 0.0
|
||||
stream_end: float = 0.0
|
||||
token_count: int = 0
|
||||
|
||||
@property
|
||||
def ttft(self) -> float:
|
||||
"""Time To First Token in seconds (first_token - request_start)."""
|
||||
if self.first_token and self.request_start:
|
||||
return self.first_token - self.request_start
|
||||
return 0.0
|
||||
|
||||
@property
|
||||
def stream_duration(self) -> float:
|
||||
"""Duration of token streaming in seconds (last_token - first_token)."""
|
||||
if self.last_token and self.first_token:
|
||||
return self.last_token - self.first_token
|
||||
return 0.0
|
||||
|
||||
@property
|
||||
def total_time(self) -> float:
|
||||
"""Total request time in seconds (stream_end - request_start)."""
|
||||
if self.stream_end and self.request_start:
|
||||
return self.stream_end - self.request_start
|
||||
return 0.0
|
||||
|
||||
@property
|
||||
def tokens_per_second(self) -> float:
|
||||
"""Token generation rate (tokens / stream_duration)."""
|
||||
if self.stream_duration > 0 and self.token_count > 0:
|
||||
return self.token_count / self.stream_duration
|
||||
return 0.0
|
||||
|
||||
def update_from_event(self, event: StreamEvent) -> None:
|
||||
"""Update metrics from a StreamEvent."""
|
||||
if event.type == StreamEventType.REQUEST_START:
|
||||
self.request_start = event.timestamp
|
||||
elif event.type == StreamEventType.HEADERS_RECEIVED:
|
||||
self.headers_received = event.timestamp
|
||||
elif event.type == StreamEventType.FIRST_TOKEN:
|
||||
self.first_token = event.timestamp
|
||||
self.token_count = 1
|
||||
elif event.type == StreamEventType.DELTA_TEXT:
|
||||
self.token_count += 1
|
||||
elif event.type == StreamEventType.LAST_TOKEN:
|
||||
self.last_token = event.timestamp
|
||||
elif event.type == StreamEventType.STREAM_END:
|
||||
self.stream_end = event.timestamp
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert metrics to dictionary for logging/display."""
|
||||
return {
|
||||
"ttft_ms": round(self.ttft * 1000, 1),
|
||||
"stream_duration_ms": round(self.stream_duration * 1000, 1),
|
||||
"total_time_ms": round(self.total_time * 1000, 1),
|
||||
"token_count": self.token_count,
|
||||
"tokens_per_second": round(self.tokens_per_second, 1),
|
||||
}
|
||||
|
||||
def format_summary(self) -> str:
|
||||
"""Format metrics as a human-readable summary string."""
|
||||
return (
|
||||
f"TTFT: {self.ttft*1000:.0f}ms | "
|
||||
f"Stream: {self.stream_duration*1000:.0f}ms | "
|
||||
f"Total: {self.total_time*1000:.0f}ms | "
|
||||
f"Tokens: {self.token_count} ({self.tokens_per_second:.1f}/s)"
|
||||
)
|
||||
|
||||
|
||||
class StreamEventEmitter:
|
||||
"""
|
||||
Manages stream event callbacks for an agent or streaming session.
|
||||
|
||||
Thread-safe and multi-agent safe - each agent has its own emitter instance.
|
||||
Supports both sync and async callbacks.
|
||||
|
||||
Usage:
|
||||
emitter = StreamEventEmitter()
|
||||
emitter.add_callback(my_callback)
|
||||
|
||||
# During streaming:
|
||||
emitter.emit(StreamEvent(type=StreamEventType.DELTA_TEXT, content="Hello"))
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._callbacks: List[StreamCallback] = []
|
||||
self._async_callbacks: List[AsyncStreamCallback] = []
|
||||
self._metrics: Optional[StreamMetrics] = None
|
||||
self._collect_metrics: bool = False
|
||||
|
||||
def add_callback(self, callback: StreamCallback) -> None:
|
||||
"""Add a synchronous callback to receive stream events."""
|
||||
self._callbacks.append(callback)
|
||||
|
||||
def remove_callback(self, callback: StreamCallback) -> None:
|
||||
"""Remove a synchronous callback."""
|
||||
if callback in self._callbacks:
|
||||
self._callbacks.remove(callback)
|
||||
|
||||
def add_async_callback(self, callback: AsyncStreamCallback) -> None:
|
||||
"""Add an asynchronous callback to receive stream events."""
|
||||
self._async_callbacks.append(callback)
|
||||
|
||||
def remove_async_callback(self, callback: AsyncStreamCallback) -> None:
|
||||
"""Remove an asynchronous callback."""
|
||||
if callback in self._async_callbacks:
|
||||
self._async_callbacks.remove(callback)
|
||||
|
||||
def enable_metrics(self) -> None:
|
||||
"""Enable metrics collection for this streaming session."""
|
||||
self._collect_metrics = True
|
||||
self._metrics = StreamMetrics()
|
||||
|
||||
def get_metrics(self) -> Optional[StreamMetrics]:
|
||||
"""Get collected metrics (None if metrics not enabled)."""
|
||||
return self._metrics
|
||||
|
||||
def reset_metrics(self) -> None:
|
||||
"""Reset metrics for a new streaming session."""
|
||||
if self._collect_metrics:
|
||||
self._metrics = StreamMetrics()
|
||||
|
||||
def emit(self, event: StreamEvent) -> None:
|
||||
"""
|
||||
Emit a stream event to all registered callbacks.
|
||||
|
||||
This is the synchronous emission path. For async streaming,
|
||||
use emit_async().
|
||||
"""
|
||||
# Update metrics if enabled
|
||||
if self._collect_metrics and self._metrics:
|
||||
self._metrics.update_from_event(event)
|
||||
|
||||
# Call all sync callbacks
|
||||
for callback in self._callbacks:
|
||||
try:
|
||||
callback(event)
|
||||
except Exception:
|
||||
# Don't let callback errors break streaming
|
||||
pass
|
||||
|
||||
async def emit_async(self, event: StreamEvent) -> None:
|
||||
"""
|
||||
Emit a stream event to all registered callbacks (async version).
|
||||
|
||||
Calls both sync and async callbacks.
|
||||
"""
|
||||
# Update metrics if enabled
|
||||
if self._collect_metrics and self._metrics:
|
||||
self._metrics.update_from_event(event)
|
||||
|
||||
# Call sync callbacks
|
||||
for callback in self._callbacks:
|
||||
try:
|
||||
callback(event)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Call async callbacks
|
||||
for callback in self._async_callbacks:
|
||||
try:
|
||||
await callback(event)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@property
|
||||
def has_callbacks(self) -> bool:
|
||||
"""Check if any callbacks are registered."""
|
||||
return bool(self._callbacks or self._async_callbacks)
|
||||
|
||||
|
||||
def create_text_printer_callback(flush: bool = True) -> StreamCallback:
|
||||
"""
|
||||
Create a simple callback that prints text deltas to stdout.
|
||||
|
||||
Args:
|
||||
flush: Whether to flush stdout after each print (default True)
|
||||
|
||||
Returns:
|
||||
A StreamCallback that prints DELTA_TEXT content
|
||||
"""
|
||||
def printer(event: StreamEvent) -> None:
|
||||
if event.type == StreamEventType.DELTA_TEXT and event.content:
|
||||
print(event.content, end="", flush=flush)
|
||||
elif event.type == StreamEventType.STREAM_END:
|
||||
print() # Newline at end
|
||||
|
||||
return printer
|
||||
|
||||
|
||||
def create_metrics_callback(metrics: StreamMetrics) -> StreamCallback:
|
||||
"""
|
||||
Create a callback that updates a StreamMetrics instance.
|
||||
|
||||
Args:
|
||||
metrics: The StreamMetrics instance to update
|
||||
|
||||
Returns:
|
||||
A StreamCallback that updates the metrics
|
||||
"""
|
||||
def updater(event: StreamEvent) -> None:
|
||||
metrics.update_from_event(event)
|
||||
|
||||
return updater
|
||||
@@ -0,0 +1,207 @@
|
||||
"""
|
||||
Streaming logging utilities for PraisonAI.
|
||||
|
||||
Provides guarded logging for streaming events with clear timestamps
|
||||
for request_start, headers_received, first_token, last_token, and stream_end.
|
||||
|
||||
Usage:
|
||||
from praisonaiagents.streaming.logging import StreamLogger
|
||||
|
||||
logger = StreamLogger(verbose=True, metrics=True)
|
||||
logger.log_request_start(model="gpt-4o-mini")
|
||||
# ... streaming ...
|
||||
logger.log_first_token()
|
||||
# ... more tokens ...
|
||||
logger.log_stream_end()
|
||||
print(logger.get_metrics_summary())
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from .events import StreamMetrics, StreamEvent, StreamEventType
|
||||
|
||||
|
||||
class StreamLogger:
|
||||
"""
|
||||
Guarded logging for streaming events with timing metrics.
|
||||
|
||||
Only logs when verbose=True or metrics=True to ensure zero overhead
|
||||
when logging is disabled.
|
||||
|
||||
Attributes:
|
||||
verbose: Whether to log detailed streaming events
|
||||
metrics: Whether to collect and display timing metrics
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
verbose: bool = False,
|
||||
metrics: bool = False,
|
||||
logger: Optional[logging.Logger] = None
|
||||
):
|
||||
self.verbose = verbose
|
||||
self.metrics = metrics
|
||||
self._logger = logger or logging.getLogger(__name__)
|
||||
self._metrics = StreamMetrics() if metrics else None
|
||||
self._enabled = verbose or metrics
|
||||
|
||||
def log_request_start(
|
||||
self,
|
||||
model: Optional[str] = None,
|
||||
message_count: Optional[int] = None
|
||||
) -> None:
|
||||
"""Log when the streaming request is initiated."""
|
||||
if not self._enabled:
|
||||
return
|
||||
|
||||
timestamp = time.perf_counter()
|
||||
if self._metrics:
|
||||
self._metrics.request_start = timestamp
|
||||
|
||||
if self.verbose:
|
||||
extra = f" model={model}" if model else ""
|
||||
extra += f" messages={message_count}" if message_count else ""
|
||||
self._logger.info(f"[STREAM] REQUEST_START{extra}")
|
||||
|
||||
def log_headers_received(self) -> None:
|
||||
"""Log when HTTP response headers are received."""
|
||||
if not self._enabled:
|
||||
return
|
||||
|
||||
timestamp = time.perf_counter()
|
||||
if self._metrics:
|
||||
self._metrics.headers_received = timestamp
|
||||
|
||||
if self.verbose:
|
||||
self._logger.info("[STREAM] HEADERS_RECEIVED")
|
||||
|
||||
def log_first_token(self, content: Optional[str] = None) -> None:
|
||||
"""Log when the first content token is received (TTFT marker)."""
|
||||
if not self._enabled:
|
||||
return
|
||||
|
||||
timestamp = time.perf_counter()
|
||||
if self._metrics:
|
||||
self._metrics.first_token = timestamp
|
||||
self._metrics.token_count = 1
|
||||
|
||||
if self.verbose:
|
||||
preview = content[:20] + "..." if content and len(content) > 20 else content
|
||||
self._logger.info(f"[STREAM] FIRST_TOKEN content={preview!r}")
|
||||
|
||||
def log_delta_text(self, content: Optional[str] = None) -> None:
|
||||
"""Log a text delta (only when verbose, not for every token)."""
|
||||
if not self._enabled:
|
||||
return
|
||||
|
||||
if self._metrics:
|
||||
self._metrics.token_count += 1
|
||||
|
||||
# Don't log every delta to avoid noise - just track metrics
|
||||
|
||||
def log_last_token(self) -> None:
|
||||
"""Log when the last content token is received."""
|
||||
if not self._enabled:
|
||||
return
|
||||
|
||||
timestamp = time.perf_counter()
|
||||
if self._metrics:
|
||||
self._metrics.last_token = timestamp
|
||||
|
||||
if self.verbose:
|
||||
self._logger.info("[STREAM] LAST_TOKEN")
|
||||
|
||||
def log_stream_end(self, chunk_count: Optional[int] = None) -> None:
|
||||
"""Log when the stream ends."""
|
||||
if not self._enabled:
|
||||
return
|
||||
|
||||
timestamp = time.perf_counter()
|
||||
if self._metrics:
|
||||
self._metrics.stream_end = timestamp
|
||||
|
||||
if self.verbose:
|
||||
extra = f" chunks={chunk_count}" if chunk_count else ""
|
||||
self._logger.info(f"[STREAM] STREAM_END{extra}")
|
||||
|
||||
def log_error(self, error: str) -> None:
|
||||
"""Log a streaming error."""
|
||||
if self.verbose:
|
||||
self._logger.error(f"[STREAM] ERROR: {error}")
|
||||
|
||||
def get_metrics(self) -> Optional[StreamMetrics]:
|
||||
"""Get the collected metrics (None if metrics not enabled)."""
|
||||
return self._metrics
|
||||
|
||||
def get_metrics_summary(self) -> str:
|
||||
"""Get a formatted summary of streaming metrics."""
|
||||
if not self._metrics:
|
||||
return "Metrics not enabled"
|
||||
return self._metrics.format_summary()
|
||||
|
||||
def get_metrics_dict(self) -> Optional[Dict[str, Any]]:
|
||||
"""Get metrics as a dictionary."""
|
||||
if not self._metrics:
|
||||
return None
|
||||
return self._metrics.to_dict()
|
||||
|
||||
def update_from_event(self, event: StreamEvent) -> None:
|
||||
"""Update logger state from a StreamEvent."""
|
||||
if not self._enabled:
|
||||
return
|
||||
|
||||
if self._metrics:
|
||||
self._metrics.update_from_event(event)
|
||||
|
||||
if self.verbose:
|
||||
if event.type == StreamEventType.REQUEST_START:
|
||||
self.log_request_start()
|
||||
elif event.type == StreamEventType.HEADERS_RECEIVED:
|
||||
self.log_headers_received()
|
||||
elif event.type == StreamEventType.FIRST_TOKEN:
|
||||
self.log_first_token(event.content)
|
||||
elif event.type == StreamEventType.LAST_TOKEN:
|
||||
self.log_last_token()
|
||||
elif event.type == StreamEventType.STREAM_END:
|
||||
self.log_stream_end()
|
||||
elif event.type == StreamEventType.ERROR:
|
||||
self.log_error(event.error or "Unknown error")
|
||||
|
||||
|
||||
def create_logging_callback(
|
||||
verbose: bool = False,
|
||||
metrics: bool = False,
|
||||
logger: Optional[logging.Logger] = None
|
||||
) -> tuple:
|
||||
"""
|
||||
Create a StreamLogger and a callback function for use with streaming.
|
||||
|
||||
Args:
|
||||
verbose: Whether to log detailed events
|
||||
metrics: Whether to collect timing metrics
|
||||
logger: Optional custom logger
|
||||
|
||||
Returns:
|
||||
Tuple of (StreamLogger, callback_function)
|
||||
|
||||
Usage:
|
||||
stream_logger, callback = create_logging_callback(verbose=True, metrics=True)
|
||||
|
||||
# Pass callback to streaming method
|
||||
response = client.process_stream_response(
|
||||
...,
|
||||
stream_callback=callback,
|
||||
emit_events=True
|
||||
)
|
||||
|
||||
# After streaming, get metrics
|
||||
print(stream_logger.get_metrics_summary())
|
||||
"""
|
||||
stream_logger = StreamLogger(verbose=verbose, metrics=metrics, logger=logger)
|
||||
|
||||
def callback(event: StreamEvent) -> None:
|
||||
stream_logger.update_from_event(event)
|
||||
|
||||
return stream_logger, callback
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "praisonaiagents"
|
||||
version = "0.11.0"
|
||||
version = "0.11.2"
|
||||
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"""
|
||||
Streaming Example with TTFT Metrics
|
||||
|
||||
This example demonstrates true pass-through streaming with timing metrics.
|
||||
When verbose output is enabled, tokens are displayed as they arrive from the provider.
|
||||
|
||||
Key timing metrics:
|
||||
- TTFT (Time To First Token): Time from request to first token received
|
||||
- Stream Duration: Time from first to last token
|
||||
- Total Time: End-to-end request time
|
||||
"""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.config import OutputConfig
|
||||
|
||||
# Create agent with verbose output (shows streaming progress)
|
||||
agent = Agent(
|
||||
instructions="You are a helpful assistant",
|
||||
llm="gpt-4o-mini",
|
||||
output=OutputConfig(verbose=True) # Show generation progress
|
||||
)
|
||||
|
||||
# Run the agent - verbose mode shows streaming progress via Rich Live display
|
||||
result = agent.start("Write a short paragraph about the history of computing")
|
||||
print(f"\n--- Response ---\n{result}")
|
||||
@@ -1,11 +0,0 @@
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(
|
||||
instructions="You are a helpful assistant",
|
||||
llm="gpt-4o-mini"
|
||||
)
|
||||
|
||||
# Use chat method with stream=True for streaming responses
|
||||
for chunk in agent.chat("Write a short paragraph about the history of computing", stream=True):
|
||||
print(chunk, end="", flush=True)
|
||||
print() # Final newline
|
||||
@@ -0,0 +1 @@
|
||||
"""Unit tests for streaming module."""
|
||||
@@ -0,0 +1,387 @@
|
||||
"""
|
||||
Unit tests for streaming events module.
|
||||
|
||||
Tests cover:
|
||||
- StreamEvent creation and representation
|
||||
- StreamEventType enum values
|
||||
- StreamMetrics calculations (TTFT, stream duration, total time)
|
||||
- StreamEventEmitter callback management
|
||||
- create_text_printer_callback and create_metrics_callback helpers
|
||||
"""
|
||||
|
||||
import time
|
||||
from praisonaiagents.streaming.events import (
|
||||
StreamEvent,
|
||||
StreamEventType,
|
||||
StreamMetrics,
|
||||
StreamEventEmitter,
|
||||
create_text_printer_callback,
|
||||
create_metrics_callback,
|
||||
)
|
||||
|
||||
|
||||
class TestStreamEventType:
|
||||
"""Tests for StreamEventType enum."""
|
||||
|
||||
def test_event_types_exist(self):
|
||||
"""All required event types should exist."""
|
||||
assert StreamEventType.REQUEST_START.value == "request_start"
|
||||
assert StreamEventType.HEADERS_RECEIVED.value == "headers_received"
|
||||
assert StreamEventType.FIRST_TOKEN.value == "first_token"
|
||||
assert StreamEventType.DELTA_TEXT.value == "delta_text"
|
||||
assert StreamEventType.DELTA_TOOL_CALL.value == "delta_tool_call"
|
||||
assert StreamEventType.TOOL_CALL_END.value == "tool_call_end"
|
||||
assert StreamEventType.LAST_TOKEN.value == "last_token"
|
||||
assert StreamEventType.STREAM_END.value == "stream_end"
|
||||
assert StreamEventType.ERROR.value == "error"
|
||||
|
||||
|
||||
class TestStreamEvent:
|
||||
"""Tests for StreamEvent dataclass."""
|
||||
|
||||
def test_create_basic_event(self):
|
||||
"""Create a basic event with type only."""
|
||||
event = StreamEvent(type=StreamEventType.REQUEST_START)
|
||||
assert event.type == StreamEventType.REQUEST_START
|
||||
assert event.timestamp > 0
|
||||
assert event.content is None
|
||||
assert event.tool_call is None
|
||||
assert event.metadata is None
|
||||
assert event.error is None
|
||||
|
||||
def test_create_event_with_content(self):
|
||||
"""Create an event with content."""
|
||||
event = StreamEvent(
|
||||
type=StreamEventType.DELTA_TEXT,
|
||||
content="Hello"
|
||||
)
|
||||
assert event.type == StreamEventType.DELTA_TEXT
|
||||
assert event.content == "Hello"
|
||||
|
||||
def test_create_event_with_tool_call(self):
|
||||
"""Create an event with tool call data."""
|
||||
tool_data = {"name": "calculator", "arguments": '{"x": 1}'}
|
||||
event = StreamEvent(
|
||||
type=StreamEventType.DELTA_TOOL_CALL,
|
||||
tool_call=tool_data
|
||||
)
|
||||
assert event.type == StreamEventType.DELTA_TOOL_CALL
|
||||
assert event.tool_call == tool_data
|
||||
|
||||
def test_create_event_with_metadata(self):
|
||||
"""Create an event with metadata."""
|
||||
event = StreamEvent(
|
||||
type=StreamEventType.REQUEST_START,
|
||||
metadata={"model": "gpt-4o-mini", "message_count": 2}
|
||||
)
|
||||
assert event.metadata["model"] == "gpt-4o-mini"
|
||||
assert event.metadata["message_count"] == 2
|
||||
|
||||
def test_create_error_event(self):
|
||||
"""Create an error event."""
|
||||
event = StreamEvent(
|
||||
type=StreamEventType.ERROR,
|
||||
error="Connection timeout"
|
||||
)
|
||||
assert event.type == StreamEventType.ERROR
|
||||
assert event.error == "Connection timeout"
|
||||
|
||||
def test_event_repr(self):
|
||||
"""Event repr should be informative."""
|
||||
event = StreamEvent(
|
||||
type=StreamEventType.DELTA_TEXT,
|
||||
content="Hello world"
|
||||
)
|
||||
repr_str = repr(event)
|
||||
assert "DELTA_TEXT" in repr_str or "delta_text" in repr_str
|
||||
assert "Hello world" in repr_str
|
||||
|
||||
|
||||
class TestStreamMetrics:
|
||||
"""Tests for StreamMetrics dataclass."""
|
||||
|
||||
def test_default_metrics(self):
|
||||
"""Default metrics should be zero."""
|
||||
metrics = StreamMetrics()
|
||||
assert metrics.request_start == 0.0
|
||||
assert metrics.headers_received == 0.0
|
||||
assert metrics.first_token == 0.0
|
||||
assert metrics.last_token == 0.0
|
||||
assert metrics.stream_end == 0.0
|
||||
assert metrics.token_count == 0
|
||||
|
||||
def test_ttft_calculation(self):
|
||||
"""TTFT should be first_token - request_start."""
|
||||
metrics = StreamMetrics()
|
||||
metrics.request_start = 100.0
|
||||
metrics.first_token = 100.5
|
||||
assert metrics.ttft == 0.5
|
||||
|
||||
def test_ttft_zero_when_no_first_token(self):
|
||||
"""TTFT should be 0 when first_token not set."""
|
||||
metrics = StreamMetrics()
|
||||
metrics.request_start = 100.0
|
||||
assert metrics.ttft == 0.0
|
||||
|
||||
def test_stream_duration_calculation(self):
|
||||
"""Stream duration should be last_token - first_token."""
|
||||
metrics = StreamMetrics()
|
||||
metrics.first_token = 100.0
|
||||
metrics.last_token = 102.0
|
||||
assert metrics.stream_duration == 2.0
|
||||
|
||||
def test_total_time_calculation(self):
|
||||
"""Total time should be stream_end - request_start."""
|
||||
metrics = StreamMetrics()
|
||||
metrics.request_start = 100.0
|
||||
metrics.stream_end = 103.0
|
||||
assert metrics.total_time == 3.0
|
||||
|
||||
def test_tokens_per_second(self):
|
||||
"""Tokens per second calculation."""
|
||||
metrics = StreamMetrics()
|
||||
metrics.first_token = 100.0
|
||||
metrics.last_token = 102.0 # 2 second duration
|
||||
metrics.token_count = 100
|
||||
assert metrics.tokens_per_second == 50.0
|
||||
|
||||
def test_tokens_per_second_zero_duration(self):
|
||||
"""Tokens per second should be 0 when duration is 0."""
|
||||
metrics = StreamMetrics()
|
||||
assert metrics.tokens_per_second == 0.0
|
||||
|
||||
def test_update_from_event(self):
|
||||
"""Metrics should update from events."""
|
||||
metrics = StreamMetrics()
|
||||
|
||||
# Request start
|
||||
event1 = StreamEvent(type=StreamEventType.REQUEST_START, timestamp=100.0)
|
||||
metrics.update_from_event(event1)
|
||||
assert metrics.request_start == 100.0
|
||||
|
||||
# Headers received
|
||||
event2 = StreamEvent(type=StreamEventType.HEADERS_RECEIVED, timestamp=100.2)
|
||||
metrics.update_from_event(event2)
|
||||
assert metrics.headers_received == 100.2
|
||||
|
||||
# First token
|
||||
event3 = StreamEvent(type=StreamEventType.FIRST_TOKEN, timestamp=100.5)
|
||||
metrics.update_from_event(event3)
|
||||
assert metrics.first_token == 100.5
|
||||
assert metrics.token_count == 1
|
||||
|
||||
# Delta text
|
||||
event4 = StreamEvent(type=StreamEventType.DELTA_TEXT, timestamp=100.6)
|
||||
metrics.update_from_event(event4)
|
||||
assert metrics.token_count == 2
|
||||
|
||||
# Last token
|
||||
event5 = StreamEvent(type=StreamEventType.LAST_TOKEN, timestamp=102.0)
|
||||
metrics.update_from_event(event5)
|
||||
assert metrics.last_token == 102.0
|
||||
|
||||
# Stream end
|
||||
event6 = StreamEvent(type=StreamEventType.STREAM_END, timestamp=102.1)
|
||||
metrics.update_from_event(event6)
|
||||
assert metrics.stream_end == 102.1
|
||||
|
||||
def test_to_dict(self):
|
||||
"""Metrics should convert to dict."""
|
||||
metrics = StreamMetrics()
|
||||
metrics.request_start = 100.0
|
||||
metrics.first_token = 100.5
|
||||
metrics.last_token = 102.0
|
||||
metrics.stream_end = 102.1
|
||||
metrics.token_count = 50
|
||||
|
||||
d = metrics.to_dict()
|
||||
assert "ttft_ms" in d
|
||||
assert "stream_duration_ms" in d
|
||||
assert "total_time_ms" in d
|
||||
assert "token_count" in d
|
||||
assert "tokens_per_second" in d
|
||||
|
||||
def test_format_summary(self):
|
||||
"""Metrics should format as summary string."""
|
||||
metrics = StreamMetrics()
|
||||
metrics.request_start = 100.0
|
||||
metrics.first_token = 100.5
|
||||
metrics.last_token = 102.0
|
||||
metrics.stream_end = 102.1
|
||||
metrics.token_count = 50
|
||||
|
||||
summary = metrics.format_summary()
|
||||
assert "TTFT" in summary
|
||||
assert "Stream" in summary
|
||||
assert "Total" in summary
|
||||
assert "Tokens" in summary
|
||||
|
||||
|
||||
class TestStreamEventEmitter:
|
||||
"""Tests for StreamEventEmitter."""
|
||||
|
||||
def test_create_emitter(self):
|
||||
"""Create an emitter."""
|
||||
emitter = StreamEventEmitter()
|
||||
assert not emitter.has_callbacks
|
||||
|
||||
def test_add_callback(self):
|
||||
"""Add a callback."""
|
||||
emitter = StreamEventEmitter()
|
||||
events = []
|
||||
|
||||
def callback(event):
|
||||
events.append(event)
|
||||
|
||||
emitter.add_callback(callback)
|
||||
assert emitter.has_callbacks
|
||||
|
||||
def test_remove_callback(self):
|
||||
"""Remove a callback."""
|
||||
emitter = StreamEventEmitter()
|
||||
events = []
|
||||
|
||||
def callback(event):
|
||||
events.append(event)
|
||||
|
||||
emitter.add_callback(callback)
|
||||
emitter.remove_callback(callback)
|
||||
assert not emitter.has_callbacks
|
||||
|
||||
def test_emit_calls_callbacks(self):
|
||||
"""Emit should call all callbacks."""
|
||||
emitter = StreamEventEmitter()
|
||||
events1 = []
|
||||
events2 = []
|
||||
|
||||
emitter.add_callback(lambda e: events1.append(e))
|
||||
emitter.add_callback(lambda e: events2.append(e))
|
||||
|
||||
event = StreamEvent(type=StreamEventType.DELTA_TEXT, content="Hello")
|
||||
emitter.emit(event)
|
||||
|
||||
assert len(events1) == 1
|
||||
assert len(events2) == 1
|
||||
assert events1[0].content == "Hello"
|
||||
|
||||
def test_emit_with_metrics(self):
|
||||
"""Emit should update metrics when enabled."""
|
||||
emitter = StreamEventEmitter()
|
||||
emitter.enable_metrics()
|
||||
|
||||
emitter.emit(StreamEvent(type=StreamEventType.REQUEST_START, timestamp=100.0))
|
||||
emitter.emit(StreamEvent(type=StreamEventType.FIRST_TOKEN, timestamp=100.5))
|
||||
emitter.emit(StreamEvent(type=StreamEventType.STREAM_END, timestamp=102.0))
|
||||
|
||||
metrics = emitter.get_metrics()
|
||||
assert metrics is not None
|
||||
assert metrics.request_start == 100.0
|
||||
assert metrics.first_token == 100.5
|
||||
assert metrics.stream_end == 102.0
|
||||
|
||||
def test_callback_error_does_not_break_emission(self):
|
||||
"""Callback errors should not break other callbacks."""
|
||||
emitter = StreamEventEmitter()
|
||||
events = []
|
||||
|
||||
def bad_callback(event):
|
||||
raise ValueError("Test error")
|
||||
|
||||
def good_callback(event):
|
||||
events.append(event)
|
||||
|
||||
emitter.add_callback(bad_callback)
|
||||
emitter.add_callback(good_callback)
|
||||
|
||||
event = StreamEvent(type=StreamEventType.DELTA_TEXT)
|
||||
emitter.emit(event) # Should not raise
|
||||
|
||||
assert len(events) == 1
|
||||
|
||||
def test_reset_metrics(self):
|
||||
"""Reset metrics should create new metrics."""
|
||||
emitter = StreamEventEmitter()
|
||||
emitter.enable_metrics()
|
||||
|
||||
emitter.emit(StreamEvent(type=StreamEventType.REQUEST_START, timestamp=100.0))
|
||||
emitter.reset_metrics()
|
||||
|
||||
metrics = emitter.get_metrics()
|
||||
assert metrics.request_start == 0.0
|
||||
|
||||
|
||||
class TestHelperFunctions:
|
||||
"""Tests for helper functions."""
|
||||
|
||||
def test_create_text_printer_callback(self, capsys):
|
||||
"""Text printer callback should print content."""
|
||||
callback = create_text_printer_callback(flush=True)
|
||||
|
||||
callback(StreamEvent(type=StreamEventType.DELTA_TEXT, content="Hello"))
|
||||
callback(StreamEvent(type=StreamEventType.DELTA_TEXT, content=" World"))
|
||||
callback(StreamEvent(type=StreamEventType.STREAM_END))
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Hello World" in captured.out
|
||||
|
||||
def test_create_metrics_callback(self):
|
||||
"""Metrics callback should update metrics."""
|
||||
metrics = StreamMetrics()
|
||||
callback = create_metrics_callback(metrics)
|
||||
|
||||
callback(StreamEvent(type=StreamEventType.REQUEST_START, timestamp=100.0))
|
||||
callback(StreamEvent(type=StreamEventType.FIRST_TOKEN, timestamp=100.5))
|
||||
|
||||
assert metrics.request_start == 100.0
|
||||
assert metrics.first_token == 100.5
|
||||
|
||||
|
||||
class TestStreamingEventOrdering:
|
||||
"""Tests for event ordering and pass-through behavior."""
|
||||
|
||||
def test_events_emitted_immediately(self):
|
||||
"""Events should be emitted immediately, not buffered."""
|
||||
emitter = StreamEventEmitter()
|
||||
timestamps = []
|
||||
|
||||
def callback(event):
|
||||
timestamps.append((event.type, time.perf_counter()))
|
||||
|
||||
emitter.add_callback(callback)
|
||||
|
||||
# Simulate streaming with small delays
|
||||
emitter.emit(StreamEvent(type=StreamEventType.REQUEST_START))
|
||||
time.sleep(0.01)
|
||||
emitter.emit(StreamEvent(type=StreamEventType.FIRST_TOKEN))
|
||||
time.sleep(0.01)
|
||||
emitter.emit(StreamEvent(type=StreamEventType.DELTA_TEXT))
|
||||
|
||||
# Verify events were captured in order
|
||||
assert len(timestamps) == 3
|
||||
assert timestamps[0][0] == StreamEventType.REQUEST_START
|
||||
assert timestamps[1][0] == StreamEventType.FIRST_TOKEN
|
||||
assert timestamps[2][0] == StreamEventType.DELTA_TEXT
|
||||
|
||||
# Verify timestamps are increasing (no buffering)
|
||||
assert timestamps[0][1] < timestamps[1][1] < timestamps[2][1]
|
||||
|
||||
def test_no_buffering_before_first_token(self):
|
||||
"""Events should not be buffered before first token."""
|
||||
emitter = StreamEventEmitter()
|
||||
emitter.enable_metrics()
|
||||
|
||||
# Emit request start
|
||||
t1 = time.perf_counter()
|
||||
emitter.emit(StreamEvent(type=StreamEventType.REQUEST_START, timestamp=t1))
|
||||
|
||||
# Small delay (simulating network)
|
||||
time.sleep(0.05)
|
||||
|
||||
# Emit first token
|
||||
t2 = time.perf_counter()
|
||||
emitter.emit(StreamEvent(type=StreamEventType.FIRST_TOKEN, timestamp=t2))
|
||||
|
||||
metrics = emitter.get_metrics()
|
||||
|
||||
# TTFT should reflect the actual delay
|
||||
assert metrics.ttft >= 0.04 # Allow some tolerance
|
||||
@@ -0,0 +1,371 @@
|
||||
"""
|
||||
Unit tests for the core SDK profiling module.
|
||||
|
||||
Tests cover:
|
||||
- Profiler enable/disable
|
||||
- Timing recording
|
||||
- Streaming tracking
|
||||
- Phase tracking
|
||||
- Report generation
|
||||
- Zero overhead when disabled
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
class TestProfilerBasics:
|
||||
"""Test basic Profiler functionality."""
|
||||
|
||||
def test_profiler_disabled_by_default(self):
|
||||
"""Profiler should be disabled by default."""
|
||||
from praisonaiagents.profiling import Profiler
|
||||
Profiler.disable() # Ensure clean state
|
||||
assert not Profiler.is_enabled()
|
||||
|
||||
def test_profiler_enable_disable(self):
|
||||
"""Test enable/disable functionality."""
|
||||
from praisonaiagents.profiling import Profiler
|
||||
|
||||
Profiler.disable()
|
||||
assert not Profiler.is_enabled()
|
||||
|
||||
Profiler.enable()
|
||||
assert Profiler.is_enabled()
|
||||
|
||||
Profiler.disable()
|
||||
assert not Profiler.is_enabled()
|
||||
|
||||
def test_profiler_env_var_enable(self):
|
||||
"""Test enabling via environment variable."""
|
||||
from praisonaiagents.profiling import Profiler
|
||||
|
||||
Profiler.disable()
|
||||
|
||||
with patch.dict('os.environ', {'PRAISONAI_PROFILE': '1'}):
|
||||
assert Profiler.is_enabled()
|
||||
|
||||
with patch.dict('os.environ', {'PRAISONAI_PROFILE': 'true'}):
|
||||
assert Profiler.is_enabled()
|
||||
|
||||
with patch.dict('os.environ', {'PRAISONAI_PROFILE': 'yes'}):
|
||||
assert Profiler.is_enabled()
|
||||
|
||||
def test_profiler_clear(self):
|
||||
"""Test clearing profiling data."""
|
||||
from praisonaiagents.profiling import Profiler
|
||||
|
||||
Profiler.enable()
|
||||
Profiler.record_timing("test", 100.0)
|
||||
assert len(Profiler.get_timings()) > 0
|
||||
|
||||
Profiler.clear()
|
||||
assert len(Profiler.get_timings()) == 0
|
||||
Profiler.disable()
|
||||
|
||||
|
||||
class TestTimingRecording:
|
||||
"""Test timing recording functionality."""
|
||||
|
||||
def test_record_timing_when_enabled(self):
|
||||
"""Timing should be recorded when profiler is enabled."""
|
||||
from praisonaiagents.profiling import Profiler
|
||||
|
||||
Profiler.enable()
|
||||
Profiler.clear()
|
||||
|
||||
Profiler.record_timing("test_op", 123.45, category="test")
|
||||
|
||||
timings = Profiler.get_timings()
|
||||
assert len(timings) == 1
|
||||
assert timings[0].name == "test_op"
|
||||
assert timings[0].duration_ms == 123.45
|
||||
assert timings[0].category == "test"
|
||||
|
||||
Profiler.disable()
|
||||
|
||||
def test_record_timing_when_disabled(self):
|
||||
"""Timing should NOT be recorded when profiler is disabled."""
|
||||
from praisonaiagents.profiling import Profiler
|
||||
|
||||
Profiler.disable()
|
||||
Profiler.clear()
|
||||
|
||||
Profiler.record_timing("test_op", 123.45)
|
||||
|
||||
timings = Profiler.get_timings()
|
||||
assert len(timings) == 0
|
||||
|
||||
def test_record_timing_with_metadata(self):
|
||||
"""Timing should include metadata."""
|
||||
from praisonaiagents.profiling import Profiler
|
||||
|
||||
Profiler.enable()
|
||||
Profiler.clear()
|
||||
|
||||
Profiler.record_timing("test_op", 100.0, metadata={"key": "value"})
|
||||
|
||||
timings = Profiler.get_timings()
|
||||
assert timings[0].metadata == {"key": "value"}
|
||||
|
||||
Profiler.disable()
|
||||
|
||||
|
||||
class TestPhaseTracking:
|
||||
"""Test phase tracking functionality."""
|
||||
|
||||
def test_phase_context_manager(self):
|
||||
"""Phase context manager should record timing."""
|
||||
from praisonaiagents.profiling import Profiler
|
||||
|
||||
Profiler.enable()
|
||||
Profiler.clear()
|
||||
|
||||
with Profiler.phase("test_phase"):
|
||||
time.sleep(0.01) # 10ms
|
||||
|
||||
timings = Profiler.get_timings(category="phase")
|
||||
assert len(timings) == 1
|
||||
assert timings[0].name == "test_phase"
|
||||
assert timings[0].duration_ms >= 10 # At least 10ms
|
||||
|
||||
Profiler.disable()
|
||||
|
||||
def test_nested_phases(self):
|
||||
"""Nested phases should track parent."""
|
||||
from praisonaiagents.profiling import Profiler
|
||||
|
||||
Profiler.enable()
|
||||
Profiler.clear()
|
||||
|
||||
with Profiler.phase("outer"):
|
||||
with Profiler.phase("inner"):
|
||||
time.sleep(0.005)
|
||||
|
||||
timings = Profiler.get_timings(category="phase")
|
||||
assert len(timings) == 2
|
||||
|
||||
inner = next(t for t in timings if t.name == "inner")
|
||||
assert inner.parent == "outer"
|
||||
|
||||
Profiler.disable()
|
||||
|
||||
|
||||
class TestStreamingTracker:
|
||||
"""Test streaming tracker functionality."""
|
||||
|
||||
def test_streaming_ttft(self):
|
||||
"""Streaming tracker should measure TTFT."""
|
||||
from praisonaiagents.profiling import Profiler, StreamingTracker
|
||||
|
||||
Profiler.enable()
|
||||
Profiler.clear()
|
||||
|
||||
tracker = StreamingTracker("test_stream")
|
||||
tracker.start()
|
||||
time.sleep(0.01) # 10ms to first token
|
||||
tracker.first_token()
|
||||
time.sleep(0.02) # 20ms more
|
||||
tracker.end()
|
||||
|
||||
assert tracker.ttft_ms >= 10
|
||||
assert tracker.elapsed_ms >= 30
|
||||
|
||||
records = Profiler.get_streaming_records()
|
||||
assert len(records) == 1
|
||||
assert records[0].name == "test_stream"
|
||||
assert records[0].ttft_ms >= 10
|
||||
|
||||
Profiler.disable()
|
||||
|
||||
def test_streaming_context_manager(self):
|
||||
"""Streaming context manager should work."""
|
||||
from praisonaiagents.profiling import Profiler
|
||||
|
||||
Profiler.enable()
|
||||
Profiler.clear()
|
||||
|
||||
with Profiler.streaming("test_stream") as tracker:
|
||||
time.sleep(0.01)
|
||||
tracker.first_token()
|
||||
tracker.chunk()
|
||||
tracker.chunk()
|
||||
|
||||
records = Profiler.get_streaming_records()
|
||||
assert len(records) == 1
|
||||
assert records[0].chunk_count == 2
|
||||
|
||||
Profiler.disable()
|
||||
|
||||
|
||||
class TestProfileDecorators:
|
||||
"""Test profiling decorators."""
|
||||
|
||||
def test_profile_func_decorator(self):
|
||||
"""profile_func decorator should record function timing."""
|
||||
from praisonaiagents.profiling import Profiler, profile_func
|
||||
|
||||
Profiler.enable()
|
||||
Profiler.clear()
|
||||
|
||||
@profile_func
|
||||
def slow_function():
|
||||
time.sleep(0.01)
|
||||
return "done"
|
||||
|
||||
result = slow_function()
|
||||
assert result == "done"
|
||||
|
||||
timings = Profiler.get_timings(category="function")
|
||||
assert len(timings) == 1
|
||||
assert timings[0].name == "slow_function"
|
||||
assert timings[0].duration_ms >= 10
|
||||
|
||||
Profiler.disable()
|
||||
|
||||
def test_profile_func_disabled(self):
|
||||
"""profile_func should have zero overhead when disabled."""
|
||||
from praisonaiagents.profiling import Profiler, profile_func
|
||||
|
||||
Profiler.disable()
|
||||
Profiler.clear()
|
||||
|
||||
@profile_func
|
||||
def fast_function():
|
||||
return "done"
|
||||
|
||||
result = fast_function()
|
||||
assert result == "done"
|
||||
|
||||
timings = Profiler.get_timings()
|
||||
assert len(timings) == 0
|
||||
|
||||
|
||||
class TestProfileBlock:
|
||||
"""Test profile_block context manager."""
|
||||
|
||||
def test_profile_block(self):
|
||||
"""profile_block should record block timing."""
|
||||
from praisonaiagents.profiling import Profiler, profile_block
|
||||
|
||||
Profiler.enable()
|
||||
Profiler.clear()
|
||||
|
||||
with profile_block("my_block", category="custom"):
|
||||
time.sleep(0.01)
|
||||
|
||||
timings = Profiler.get_timings(category="custom")
|
||||
assert len(timings) == 1
|
||||
assert timings[0].name == "my_block"
|
||||
|
||||
Profiler.disable()
|
||||
|
||||
def test_profile_block_disabled(self):
|
||||
"""profile_block should have zero overhead when disabled."""
|
||||
from praisonaiagents.profiling import Profiler, profile_block
|
||||
|
||||
Profiler.disable()
|
||||
Profiler.clear()
|
||||
|
||||
with profile_block("my_block"):
|
||||
pass
|
||||
|
||||
timings = Profiler.get_timings()
|
||||
assert len(timings) == 0
|
||||
|
||||
|
||||
class TestReporting:
|
||||
"""Test report generation."""
|
||||
|
||||
def test_get_summary(self):
|
||||
"""get_summary should return structured data."""
|
||||
from praisonaiagents.profiling import Profiler
|
||||
|
||||
Profiler.enable()
|
||||
Profiler.clear()
|
||||
|
||||
Profiler.record_timing("op1", 100.0, category="cat1")
|
||||
Profiler.record_timing("op2", 200.0, category="cat2")
|
||||
|
||||
summary = Profiler.get_summary()
|
||||
|
||||
assert summary['total_time_ms'] == 300.0
|
||||
assert summary['timing_count'] == 2
|
||||
assert 'cat1' in summary['by_category']
|
||||
assert 'cat2' in summary['by_category']
|
||||
assert len(summary['slowest_operations']) == 2
|
||||
|
||||
Profiler.disable()
|
||||
|
||||
def test_export_json(self):
|
||||
"""export_json should return valid JSON."""
|
||||
from praisonaiagents.profiling import Profiler
|
||||
import json
|
||||
|
||||
Profiler.enable()
|
||||
Profiler.clear()
|
||||
|
||||
Profiler.record_timing("test", 100.0)
|
||||
|
||||
json_str = Profiler.export_json()
|
||||
data = json.loads(json_str)
|
||||
|
||||
assert 'summary' in data
|
||||
assert 'timings' in data
|
||||
assert len(data['timings']) == 1
|
||||
|
||||
Profiler.disable()
|
||||
|
||||
|
||||
class TestWarmup:
|
||||
"""Test warmup function."""
|
||||
|
||||
def test_warmup_returns_timings(self):
|
||||
"""warmup should return timing information."""
|
||||
from praisonaiagents import warmup
|
||||
|
||||
result = warmup(include_litellm=False, include_openai=False)
|
||||
assert isinstance(result, dict)
|
||||
|
||||
def test_warmup_with_litellm(self):
|
||||
"""warmup with litellm should return litellm timing."""
|
||||
from praisonaiagents import warmup
|
||||
|
||||
result = warmup(include_litellm=True, include_openai=False)
|
||||
assert 'litellm' in result
|
||||
# Should be positive (imported) or -1 (not available)
|
||||
assert result['litellm'] > 0 or result['litellm'] == -1
|
||||
|
||||
|
||||
class TestZeroOverhead:
|
||||
"""Test that profiling has zero overhead when disabled."""
|
||||
|
||||
def test_record_timing_overhead(self):
|
||||
"""record_timing should be fast when disabled."""
|
||||
from praisonaiagents.profiling import Profiler
|
||||
|
||||
Profiler.disable()
|
||||
|
||||
start = time.perf_counter()
|
||||
for _ in range(10000):
|
||||
Profiler.record_timing("test", 100.0)
|
||||
elapsed = (time.perf_counter() - start) * 1000
|
||||
|
||||
# Should complete 10k calls in under 10ms when disabled
|
||||
assert elapsed < 10, f"record_timing took {elapsed}ms for 10k calls"
|
||||
|
||||
def test_phase_context_overhead(self):
|
||||
"""phase context manager should be fast when disabled."""
|
||||
from praisonaiagents.profiling import Profiler
|
||||
|
||||
Profiler.disable()
|
||||
|
||||
start = time.perf_counter()
|
||||
for _ in range(1000):
|
||||
with Profiler.phase("test"):
|
||||
pass
|
||||
elapsed = (time.perf_counter() - start) * 1000
|
||||
|
||||
# Should complete 1k calls in under 10ms when disabled
|
||||
assert elapsed < 10, f"phase took {elapsed}ms for 1k calls"
|
||||
@@ -0,0 +1,169 @@
|
||||
"""
|
||||
Tests for the 'stream' output preset and streaming behavior.
|
||||
|
||||
Verifies:
|
||||
1. output="stream" is a valid preset string
|
||||
2. Streaming path yields chunks without buffering
|
||||
3. LLM call count == 1 for simple streaming case
|
||||
4. Precedence: start(stream=...) overrides OutputConfig.stream
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import warnings
|
||||
|
||||
|
||||
class TestStreamPresetValidation:
|
||||
"""Test that 'stream' is a valid output preset."""
|
||||
|
||||
def test_stream_preset_exists_in_output_presets(self):
|
||||
"""'stream' should be a valid key in OUTPUT_PRESETS."""
|
||||
from praisonaiagents.config.presets import OUTPUT_PRESETS
|
||||
|
||||
assert "stream" in OUTPUT_PRESETS, "Missing 'stream' preset in OUTPUT_PRESETS"
|
||||
assert OUTPUT_PRESETS["stream"]["stream"] is True, "'stream' preset should have stream=True"
|
||||
|
||||
def test_stream_preset_resolves_correctly(self):
|
||||
"""output='stream' should resolve to OutputConfig with stream=True."""
|
||||
from praisonaiagents.config.param_resolver import resolve
|
||||
from praisonaiagents.config.presets import OUTPUT_PRESETS
|
||||
from praisonaiagents.config.feature_configs import OutputConfig
|
||||
|
||||
result = resolve(
|
||||
value="stream",
|
||||
param_name="output",
|
||||
config_class=OutputConfig,
|
||||
presets=OUTPUT_PRESETS,
|
||||
)
|
||||
|
||||
assert isinstance(result, OutputConfig)
|
||||
assert result.stream is True
|
||||
assert result.verbose is True # stream preset enables verbose
|
||||
|
||||
def test_all_output_presets_are_valid(self):
|
||||
"""All documented presets should be valid."""
|
||||
from praisonaiagents.config.presets import OUTPUT_PRESETS
|
||||
|
||||
expected_presets = {"minimal", "normal", "verbose", "debug", "silent", "stream"}
|
||||
actual_presets = set(OUTPUT_PRESETS.keys())
|
||||
|
||||
assert expected_presets.issubset(actual_presets), \
|
||||
f"Missing presets: {expected_presets - actual_presets}"
|
||||
|
||||
def test_invalid_preset_raises_helpful_error(self):
|
||||
"""Invalid preset should raise error listing valid options including 'stream'."""
|
||||
from praisonaiagents.config.param_resolver import resolve
|
||||
from praisonaiagents.config.presets import OUTPUT_PRESETS
|
||||
from praisonaiagents.config.feature_configs import OutputConfig
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
resolve(
|
||||
value="invalid_preset",
|
||||
param_name="output",
|
||||
config_class=OutputConfig,
|
||||
presets=OUTPUT_PRESETS,
|
||||
)
|
||||
|
||||
error_msg = str(exc_info.value).lower()
|
||||
assert "invalid_preset" in error_msg
|
||||
assert "stream" in error_msg, "Error should list 'stream' as valid preset"
|
||||
|
||||
|
||||
class TestStreamPresetWithOverrides:
|
||||
"""Test array form with stream preset and overrides."""
|
||||
|
||||
def test_stream_preset_with_override_to_disable(self):
|
||||
"""['stream', {'stream': False}] should override stream to False."""
|
||||
from praisonaiagents.config.param_resolver import resolve, ArrayMode
|
||||
from praisonaiagents.config.presets import OUTPUT_PRESETS
|
||||
from praisonaiagents.config.feature_configs import OutputConfig
|
||||
|
||||
result = resolve(
|
||||
value=["stream", {"stream": False}],
|
||||
param_name="output",
|
||||
config_class=OutputConfig,
|
||||
presets=OUTPUT_PRESETS,
|
||||
array_mode=ArrayMode.PRESET_OVERRIDE,
|
||||
)
|
||||
|
||||
assert isinstance(result, OutputConfig)
|
||||
assert result.stream is False # Override takes effect
|
||||
assert result.verbose is True # Base preset value preserved
|
||||
|
||||
def test_verbose_preset_with_stream_override(self):
|
||||
"""['verbose', {'stream': True}] should enable streaming."""
|
||||
from praisonaiagents.config.param_resolver import resolve, ArrayMode
|
||||
from praisonaiagents.config.presets import OUTPUT_PRESETS
|
||||
from praisonaiagents.config.feature_configs import OutputConfig
|
||||
|
||||
result = resolve(
|
||||
value=["verbose", {"stream": True}],
|
||||
param_name="output",
|
||||
config_class=OutputConfig,
|
||||
presets=OUTPUT_PRESETS,
|
||||
array_mode=ArrayMode.PRESET_OVERRIDE,
|
||||
)
|
||||
|
||||
assert isinstance(result, OutputConfig)
|
||||
assert result.stream is True
|
||||
assert result.metrics is True # verbose preset has metrics=True
|
||||
|
||||
|
||||
class TestStreamPrecedence:
|
||||
"""Test precedence: start(stream=...) should override OutputConfig.stream."""
|
||||
|
||||
def test_start_stream_kwarg_overrides_output_config(self):
|
||||
"""start(stream=True) should enable streaming even if output='silent'."""
|
||||
# This is a behavioral test - we verify the logic in start() method
|
||||
# The actual streaming behavior is tested in integration tests
|
||||
|
||||
# Verify the start method checks kwargs['stream'] first
|
||||
import inspect
|
||||
from praisonaiagents.agent.agent import Agent
|
||||
|
||||
source = inspect.getsource(Agent.start)
|
||||
|
||||
# start() should check kwargs.get('stream', ...) OR getattr(self, 'stream', ...)
|
||||
assert "kwargs.get('stream'" in source or "stream" in source, \
|
||||
"start() should check stream kwarg"
|
||||
|
||||
|
||||
class TestNoDeprecationWarnings:
|
||||
"""Ensure no deprecation warnings for stream preset."""
|
||||
|
||||
def test_stream_preset_no_warnings(self):
|
||||
"""Using output='stream' should not emit any warnings."""
|
||||
from praisonaiagents.config.param_resolver import resolve
|
||||
from praisonaiagents.config.presets import OUTPUT_PRESETS
|
||||
from praisonaiagents.config.feature_configs import OutputConfig
|
||||
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
warnings.simplefilter("always")
|
||||
|
||||
resolve(
|
||||
value="stream",
|
||||
param_name="output",
|
||||
config_class=OutputConfig,
|
||||
presets=OUTPUT_PRESETS,
|
||||
)
|
||||
|
||||
deprecation_warnings = [x for x in w if issubclass(x.category, DeprecationWarning)]
|
||||
assert len(deprecation_warnings) == 0, \
|
||||
f"Unexpected warnings: {[str(x.message) for x in deprecation_warnings]}"
|
||||
|
||||
|
||||
class TestOutputConfigStreamField:
|
||||
"""Test OutputConfig has stream field."""
|
||||
|
||||
def test_output_config_has_stream_field(self):
|
||||
"""OutputConfig should have a stream field."""
|
||||
from praisonaiagents.config.feature_configs import OutputConfig
|
||||
|
||||
config = OutputConfig()
|
||||
assert hasattr(config, 'stream'), "OutputConfig missing 'stream' field"
|
||||
|
||||
def test_output_config_stream_default_is_false(self):
|
||||
"""OutputConfig.stream should default to False."""
|
||||
from praisonaiagents.config.feature_configs import OutputConfig
|
||||
|
||||
config = OutputConfig()
|
||||
assert config.stream is False, "OutputConfig.stream should default to False"
|
||||
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
Generated
+1
-1
@@ -2422,7 +2422,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "0.11.0"
|
||||
version = "0.11.2"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
Guardrails - Advanced Example
|
||||
|
||||
Demonstrates advanced guardrails patterns including:
|
||||
- Multi-agent configuration
|
||||
- Custom callbacks/handlers
|
||||
- Error handling
|
||||
- All available options
|
||||
|
||||
Expected Output:
|
||||
Comprehensive demonstration of guardrails with multiple agents.
|
||||
"""
|
||||
from praisonaiagents import Agent, Task, PraisonAIAgents
|
||||
|
||||
# ============================================================
|
||||
# Section 1: Custom Configuration
|
||||
# ============================================================
|
||||
|
||||
# Define custom guardrails configuration
|
||||
# config = ...
|
||||
|
||||
# ============================================================
|
||||
# Section 2: Multi-Agent Setup
|
||||
# ============================================================
|
||||
|
||||
agent1 = Agent(
|
||||
name="Agent1",
|
||||
instructions="You are the first agent",
|
||||
# guardrails=config,
|
||||
)
|
||||
|
||||
agent2 = Agent(
|
||||
name="Agent2",
|
||||
instructions="You are the second agent",
|
||||
# guardrails=config,
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# Section 3: Task Definition
|
||||
# ============================================================
|
||||
|
||||
task = Task(
|
||||
description="Demonstrate guardrails with multiple agents",
|
||||
agent=agent1,
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# Section 4: Execution
|
||||
# ============================================================
|
||||
|
||||
agents = PraisonAIAgents(agents=[agent1, agent2], tasks=[task])
|
||||
result = agents.start()
|
||||
print(result)
|
||||
|
||||
# ============================================================
|
||||
# Section 5: All Options Reference
|
||||
# ============================================================
|
||||
"""
|
||||
Guardrails Options:
|
||||
──────────────────
|
||||
| Option | Type | Default | Description |
|
||||
|-----------------|----------|---------|----------------------|
|
||||
| option1 | str | None | Description here |
|
||||
| option2 | bool | False | Description here |
|
||||
"""
|
||||
@@ -0,0 +1,19 @@
|
||||
"""
|
||||
Guardrails - Basic Example
|
||||
|
||||
Demonstrates minimal usage of guardrails with a single agent.
|
||||
|
||||
Expected Output:
|
||||
Agent response demonstrating guardrails functionality.
|
||||
"""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
# Basic guardrails usage
|
||||
agent = Agent(
|
||||
instructions="You are a helpful assistant",
|
||||
# guardrails=... # Configure guardrails here
|
||||
)
|
||||
|
||||
# Run the agent
|
||||
result = agent.start("Hello! Demonstrate guardrails functionality.")
|
||||
print(result)
|
||||
@@ -3,8 +3,8 @@ class Praisonai < Formula
|
||||
|
||||
desc "AI tools for various AI applications"
|
||||
homepage "https://github.com/MervinPraison/PraisonAI"
|
||||
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v3.7.0.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v3.7.0.tar.gz | shasum -a 256`.split.first
|
||||
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v3.7.3.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v3.7.3.tar.gz | shasum -a 256`.split.first
|
||||
license "MIT"
|
||||
|
||||
depends_on "[email protected]"
|
||||
|
||||
@@ -266,6 +266,115 @@ def register_commands():
|
||||
app.add_typer(test_app, name="test", help="Run test suite with tier and provider options")
|
||||
app.add_typer(examples_app, name="examples", help="Run and manage example files")
|
||||
|
||||
# Register standardise command
|
||||
try:
|
||||
standardise_app = typer.Typer(name="standardise", help="Documentation and examples standardisation (FDEP)")
|
||||
|
||||
@standardise_app.command("check")
|
||||
def standardise_check(
|
||||
path: str = typer.Option(".", "--path", "-p", help="Project root path"),
|
||||
feature: str = typer.Option(None, "--feature", help="Specific feature slug"),
|
||||
scope: str = typer.Option("all", "--scope", help="Scope: all, docs, examples, sdk, cli"),
|
||||
ci: bool = typer.Option(False, "--ci", help="CI mode"),
|
||||
):
|
||||
"""Check for standardisation issues."""
|
||||
from .commands.standardise import _run_check
|
||||
import argparse
|
||||
args = argparse.Namespace(path=path, feature=feature, scope=scope, ci=ci, dry_run=True)
|
||||
_run_check(args)
|
||||
|
||||
@standardise_app.command("report")
|
||||
def standardise_report(
|
||||
path: str = typer.Option(".", "--path", "-p", help="Project root path"),
|
||||
format: str = typer.Option("text", "--format", "-f", help="Format: text, json, markdown"),
|
||||
output: str = typer.Option(None, "--output", "-o", help="Output file"),
|
||||
ci: bool = typer.Option(False, "--ci", help="CI mode"),
|
||||
):
|
||||
"""Generate detailed report."""
|
||||
from .commands.standardise import _run_report
|
||||
import argparse
|
||||
args = argparse.Namespace(path=path, format=format, output=output, ci=ci, feature=None, scope="all", dry_run=True)
|
||||
_run_report(args)
|
||||
|
||||
@standardise_app.command("fix")
|
||||
def standardise_fix(
|
||||
path: str = typer.Option(".", "--path", "-p", help="Project root path"),
|
||||
feature: str = typer.Option(None, "--feature", help="Specific feature slug"),
|
||||
apply: bool = typer.Option(False, "--apply", help="Actually apply changes"),
|
||||
no_backup: bool = typer.Option(False, "--no-backup", help="Don't create backups"),
|
||||
):
|
||||
"""Fix standardisation issues."""
|
||||
from .commands.standardise import _run_fix
|
||||
import argparse
|
||||
args = argparse.Namespace(path=path, feature=feature, apply=apply, no_backup=no_backup, scope="all", ci=False, dry_run=not apply)
|
||||
_run_fix(args)
|
||||
|
||||
@standardise_app.command("init")
|
||||
def standardise_init(
|
||||
feature: str = typer.Argument(..., help="Feature slug to initialise"),
|
||||
path: str = typer.Option(".", "--path", "-p", help="Project root path"),
|
||||
apply: bool = typer.Option(False, "--apply", help="Actually create files"),
|
||||
):
|
||||
"""Initialise a new feature with all required artifacts."""
|
||||
from .commands.standardise import _run_init
|
||||
import argparse
|
||||
args = argparse.Namespace(feature=feature, path=path, apply=apply, scope="all", ci=False, dry_run=not apply)
|
||||
_run_init(args)
|
||||
|
||||
@standardise_app.command("ai")
|
||||
def standardise_ai(
|
||||
feature: str = typer.Argument(..., help="Feature slug to generate content for"),
|
||||
gen_type: str = typer.Option("all", "--type", "-t", help="Type: docs, examples, all"),
|
||||
apply: bool = typer.Option(False, "--apply", help="Actually create files"),
|
||||
verify: bool = typer.Option(False, "--verify", help="Verify with AI"),
|
||||
model: str = typer.Option("gpt-4o-mini", "--model", help="LLM model"),
|
||||
path: str = typer.Option(".", "--path", "-p", help="Project root path"),
|
||||
):
|
||||
"""AI-powered generation of docs/examples."""
|
||||
from .commands.standardise import _run_ai
|
||||
import argparse
|
||||
args = argparse.Namespace(feature=feature, type=gen_type, apply=apply, verify=verify, model=model, path=path, scope="all", ci=False, dry_run=not apply)
|
||||
_run_ai(args)
|
||||
|
||||
@standardise_app.command("checkpoint")
|
||||
def standardise_checkpoint(
|
||||
message: str = typer.Option(None, "--message", "-m", help="Checkpoint message"),
|
||||
path: str = typer.Option(".", "--path", "-p", help="Repository path"),
|
||||
):
|
||||
"""Create an undo checkpoint."""
|
||||
from .commands.standardise import _run_checkpoint
|
||||
import argparse
|
||||
args = argparse.Namespace(message=message, path=path)
|
||||
_run_checkpoint(args)
|
||||
|
||||
@standardise_app.command("undo")
|
||||
def standardise_undo(
|
||||
checkpoint: str = typer.Option(None, "--checkpoint", help="Checkpoint ID"),
|
||||
list_checkpoints: bool = typer.Option(False, "--list", help="List checkpoints"),
|
||||
path: str = typer.Option(".", "--path", "-p", help="Repository path"),
|
||||
):
|
||||
"""Undo to a previous checkpoint."""
|
||||
from .commands.standardise import _run_undo
|
||||
import argparse
|
||||
args = argparse.Namespace(checkpoint=checkpoint, list=list_checkpoints, path=path)
|
||||
_run_undo(args)
|
||||
|
||||
@standardise_app.command("redo")
|
||||
def standardise_redo(
|
||||
path: str = typer.Option(".", "--path", "-p", help="Repository path"),
|
||||
):
|
||||
"""Redo after an undo."""
|
||||
from .commands.standardise import _run_redo
|
||||
import argparse
|
||||
args = argparse.Namespace(path=path)
|
||||
_run_redo(args)
|
||||
|
||||
app.add_typer(standardise_app, name="standardise", help="Documentation and examples standardisation (FDEP)")
|
||||
# Also register as 'standardize' for US spelling
|
||||
app.add_typer(standardise_app, name="standardize", help="Documentation and examples standardisation (FDEP)")
|
||||
except Exception:
|
||||
pass # Graceful degradation if standardise module not available
|
||||
|
||||
# Register TUI and queue commands
|
||||
tui_app = create_tui_debug_app()
|
||||
queue_app = create_queue_app()
|
||||
|
||||
@@ -126,6 +126,12 @@ def chat_main(
|
||||
),
|
||||
profile: bool = typer.Option(False, "--profile", help="Enable CLI profiling (timing breakdown)"),
|
||||
profile_deep: bool = typer.Option(False, "--profile-deep", help="Enable deep profiling (cProfile stats, higher overhead)"),
|
||||
# UI backend selection
|
||||
ui_backend: str = typer.Option("auto", "--ui-backend", help="UI backend: auto, plain, rich, mg (middle-ground)"),
|
||||
json_output: bool = typer.Option(False, "--json", help="Output JSON (forces plain backend)"),
|
||||
no_color: bool = typer.Option(False, "--no-color", help="Disable colors"),
|
||||
theme: str = typer.Option("default", "--theme", help="UI theme: default, dark, light, minimal"),
|
||||
compact: bool = typer.Option(False, "--compact", help="Compact output mode"),
|
||||
):
|
||||
"""
|
||||
Start terminal-native interactive chat mode.
|
||||
@@ -150,6 +156,18 @@ def chat_main(
|
||||
if workspace:
|
||||
os.environ["PRAISONAI_WORKSPACE"] = workspace
|
||||
|
||||
# Configure UI backend
|
||||
from praisonai.cli.ui import select_backend, UIConfig
|
||||
ui_config = UIConfig(
|
||||
ui_backend=ui_backend,
|
||||
json_output=json_output,
|
||||
no_color=no_color,
|
||||
theme=theme,
|
||||
compact=compact,
|
||||
verbose=verbose,
|
||||
)
|
||||
backend = select_backend(ui_config)
|
||||
|
||||
# Handle profiling for single prompt mode
|
||||
if prompt and (profile or profile_deep):
|
||||
_run_profiled_chat(
|
||||
@@ -187,17 +205,22 @@ def chat_main(
|
||||
core = InteractiveCore(config=config)
|
||||
|
||||
if prompt:
|
||||
# Single prompt mode
|
||||
# Single prompt mode - use backend for output
|
||||
from praisonai.cli.ui.events import UIEventType
|
||||
|
||||
async def run_prompt():
|
||||
if continue_session:
|
||||
core.continue_session()
|
||||
|
||||
backend.emit(UIEventType.MESSAGE_START, {})
|
||||
response = await core.prompt(prompt)
|
||||
print(response)
|
||||
backend.emit(UIEventType.MESSAGE_CHUNK, {'content': response})
|
||||
backend.emit(UIEventType.MESSAGE_END, {})
|
||||
|
||||
asyncio.run(run_prompt())
|
||||
else:
|
||||
# Interactive REPL mode
|
||||
frontend = RichFrontend(core=core, config=config)
|
||||
# Interactive REPL mode - pass ui_config to frontend
|
||||
frontend = RichFrontend(core=core, config=config, ui_config=ui_config)
|
||||
asyncio.run(frontend.run())
|
||||
|
||||
except ImportError:
|
||||
@@ -252,7 +275,6 @@ def _run_profiled_chat(
|
||||
"name": "ChatAgent",
|
||||
"role": "Assistant",
|
||||
"goal": "Help the user",
|
||||
"verbose": verbose,
|
||||
}
|
||||
if model:
|
||||
agent_config["llm"] = model
|
||||
|
||||
@@ -0,0 +1,694 @@
|
||||
"""
|
||||
CLI commands for the FDEP standardisation system.
|
||||
|
||||
Commands:
|
||||
- praisonai standardise check - Check for standardisation issues
|
||||
- praisonai standardise report - Generate detailed report
|
||||
- praisonai standardise fix - Fix issues (with --apply)
|
||||
- praisonai standardise init - Initialise a new feature
|
||||
- praisonai standardise ai - AI-powered generation
|
||||
- praisonai standardise checkpoint - Create undo checkpoint
|
||||
- praisonai standardise undo - Undo to previous checkpoint
|
||||
- praisonai standardise redo - Redo after undo
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _lazy_import_engine():
|
||||
"""Lazy import to avoid heavy imports at CLI startup."""
|
||||
from praisonai.standardise import StandardiseEngine, StandardiseConfig
|
||||
return StandardiseEngine, StandardiseConfig
|
||||
|
||||
|
||||
def standardise_command(args=None):
|
||||
"""Main entry point for standardise command."""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="praisonai standardise",
|
||||
description="Standardise documentation and examples (FDEP)",
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(dest="subcommand", help="Subcommands")
|
||||
|
||||
# check subcommand
|
||||
check_parser = subparsers.add_parser(
|
||||
"check",
|
||||
help="Check for standardisation issues (default)",
|
||||
)
|
||||
_add_common_args(check_parser)
|
||||
|
||||
# report subcommand
|
||||
report_parser = subparsers.add_parser(
|
||||
"report",
|
||||
help="Generate detailed report",
|
||||
)
|
||||
_add_common_args(report_parser)
|
||||
report_parser.add_argument(
|
||||
"--format", "-f",
|
||||
choices=["text", "json", "markdown"],
|
||||
default="text",
|
||||
help="Report format (default: text)",
|
||||
)
|
||||
report_parser.add_argument(
|
||||
"--output", "-o",
|
||||
type=str,
|
||||
help="Output file path (default: stdout)",
|
||||
)
|
||||
|
||||
# fix subcommand
|
||||
fix_parser = subparsers.add_parser(
|
||||
"fix",
|
||||
help="Fix standardisation issues",
|
||||
)
|
||||
_add_common_args(fix_parser)
|
||||
fix_parser.add_argument(
|
||||
"--apply",
|
||||
action="store_true",
|
||||
help="Actually apply changes (default: dry-run)",
|
||||
)
|
||||
fix_parser.add_argument(
|
||||
"--no-backup",
|
||||
action="store_true",
|
||||
help="Don't create backups before changes",
|
||||
)
|
||||
|
||||
# init subcommand
|
||||
init_parser = subparsers.add_parser(
|
||||
"init",
|
||||
help="Initialise a new feature with all required artifacts",
|
||||
)
|
||||
init_parser.add_argument(
|
||||
"feature",
|
||||
type=str,
|
||||
help="Feature slug to initialise",
|
||||
)
|
||||
_add_common_args(init_parser)
|
||||
init_parser.add_argument(
|
||||
"--apply",
|
||||
action="store_true",
|
||||
help="Actually create files (default: dry-run)",
|
||||
)
|
||||
|
||||
# ai subcommand
|
||||
ai_parser = subparsers.add_parser(
|
||||
"ai",
|
||||
help="AI-powered generation of docs/examples",
|
||||
)
|
||||
ai_parser.add_argument(
|
||||
"feature",
|
||||
type=str,
|
||||
help="Feature slug to generate content for",
|
||||
)
|
||||
ai_parser.add_argument(
|
||||
"--type", "-t",
|
||||
choices=["docs", "examples", "all"],
|
||||
default="all",
|
||||
help="Type of content to generate",
|
||||
)
|
||||
ai_parser.add_argument(
|
||||
"--apply",
|
||||
action="store_true",
|
||||
help="Actually create files (default: dry-run preview)",
|
||||
)
|
||||
ai_parser.add_argument(
|
||||
"--verify",
|
||||
action="store_true",
|
||||
help="Verify generated content with AI",
|
||||
)
|
||||
ai_parser.add_argument(
|
||||
"--model",
|
||||
type=str,
|
||||
default="gpt-4o-mini",
|
||||
help="LLM model to use (default: gpt-4o-mini)",
|
||||
)
|
||||
_add_common_args(ai_parser)
|
||||
|
||||
# checkpoint subcommand
|
||||
checkpoint_parser = subparsers.add_parser(
|
||||
"checkpoint",
|
||||
help="Create an undo checkpoint",
|
||||
)
|
||||
checkpoint_parser.add_argument(
|
||||
"--message", "-m",
|
||||
type=str,
|
||||
help="Checkpoint message",
|
||||
)
|
||||
checkpoint_parser.add_argument(
|
||||
"--path", "-p",
|
||||
type=str,
|
||||
default=".",
|
||||
help="Repository path",
|
||||
)
|
||||
|
||||
# undo subcommand
|
||||
undo_parser = subparsers.add_parser(
|
||||
"undo",
|
||||
help="Undo to a previous checkpoint",
|
||||
)
|
||||
undo_parser.add_argument(
|
||||
"--checkpoint",
|
||||
type=str,
|
||||
help="Specific checkpoint ID to restore",
|
||||
)
|
||||
undo_parser.add_argument(
|
||||
"--list",
|
||||
action="store_true",
|
||||
help="List available checkpoints",
|
||||
)
|
||||
undo_parser.add_argument(
|
||||
"--path", "-p",
|
||||
type=str,
|
||||
default=".",
|
||||
help="Repository path",
|
||||
)
|
||||
|
||||
# redo subcommand
|
||||
redo_parser = subparsers.add_parser(
|
||||
"redo",
|
||||
help="Redo after an undo",
|
||||
)
|
||||
redo_parser.add_argument(
|
||||
"--path", "-p",
|
||||
type=str,
|
||||
default=".",
|
||||
help="Repository path",
|
||||
)
|
||||
|
||||
# Parse args
|
||||
parsed = parser.parse_args(args)
|
||||
|
||||
# Default to check if no subcommand
|
||||
if not parsed.subcommand:
|
||||
parsed.subcommand = "check"
|
||||
parsed = parser.parse_args(["check"] + (args or []))
|
||||
|
||||
# Execute subcommand
|
||||
if parsed.subcommand == "check":
|
||||
return _run_check(parsed)
|
||||
elif parsed.subcommand == "report":
|
||||
return _run_report(parsed)
|
||||
elif parsed.subcommand == "fix":
|
||||
return _run_fix(parsed)
|
||||
elif parsed.subcommand == "init":
|
||||
return _run_init(parsed)
|
||||
elif parsed.subcommand == "ai":
|
||||
return _run_ai(parsed)
|
||||
elif parsed.subcommand == "checkpoint":
|
||||
return _run_checkpoint(parsed)
|
||||
elif parsed.subcommand == "undo":
|
||||
return _run_undo(parsed)
|
||||
elif parsed.subcommand == "redo":
|
||||
return _run_redo(parsed)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def _add_common_args(parser):
|
||||
"""Add common arguments to a parser."""
|
||||
parser.add_argument(
|
||||
"--path", "-p",
|
||||
type=str,
|
||||
default=".",
|
||||
help="Project root path (default: current directory)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--feature",
|
||||
type=str,
|
||||
help="Specific feature slug to check",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--scope",
|
||||
choices=["all", "docs", "examples", "sdk", "cli"],
|
||||
default="all",
|
||||
help="Scope of check (default: all)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ci",
|
||||
action="store_true",
|
||||
help="CI mode (no prompts, machine-readable output)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
default=True,
|
||||
help="Don't make changes (default: true)",
|
||||
)
|
||||
|
||||
|
||||
def _create_config(parsed):
|
||||
"""Create config from parsed arguments."""
|
||||
_, StandardiseConfig = _lazy_import_engine()
|
||||
|
||||
config = StandardiseConfig(
|
||||
project_root=Path(parsed.path).resolve() if hasattr(parsed, "path") else None,
|
||||
dry_run=getattr(parsed, "dry_run", True) and not getattr(parsed, "apply", False),
|
||||
backup=not getattr(parsed, "no_backup", False),
|
||||
scope=getattr(parsed, "scope", "all"),
|
||||
feature_filter=getattr(parsed, "feature", None),
|
||||
ci_mode=getattr(parsed, "ci", False),
|
||||
report_format=getattr(parsed, "format", "text"),
|
||||
)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def _run_check(parsed) -> int:
|
||||
"""Run the check subcommand."""
|
||||
StandardiseEngine, StandardiseConfig = _lazy_import_engine()
|
||||
|
||||
config = _create_config(parsed)
|
||||
engine = StandardiseEngine(config)
|
||||
|
||||
report = engine.check()
|
||||
|
||||
# Print report
|
||||
output = engine.reports.generate(report, "text")
|
||||
print(output)
|
||||
|
||||
# Return exit code
|
||||
if config.ci_mode:
|
||||
return engine.get_exit_code(report)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def _run_report(parsed) -> int:
|
||||
"""Run the report subcommand."""
|
||||
StandardiseEngine, StandardiseConfig = _lazy_import_engine()
|
||||
|
||||
config = _create_config(parsed)
|
||||
engine = StandardiseEngine(config)
|
||||
|
||||
report = engine.check()
|
||||
output = engine.reports.generate(report, parsed.format)
|
||||
|
||||
if parsed.output:
|
||||
Path(parsed.output).write_text(output, encoding="utf-8")
|
||||
print(f"Report saved to {parsed.output}")
|
||||
else:
|
||||
print(output)
|
||||
|
||||
if config.ci_mode:
|
||||
return engine.get_exit_code(report)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def _run_fix(parsed) -> int:
|
||||
"""Run the fix subcommand."""
|
||||
StandardiseEngine, StandardiseConfig = _lazy_import_engine()
|
||||
|
||||
config = _create_config(parsed)
|
||||
engine = StandardiseEngine(config)
|
||||
|
||||
actions = engine.fix(
|
||||
feature=parsed.feature,
|
||||
apply=parsed.apply,
|
||||
)
|
||||
|
||||
if not actions["planned"]:
|
||||
print("No fixes needed.")
|
||||
return 0
|
||||
|
||||
print(f"Planned actions ({len(actions['planned'])}):")
|
||||
for action in actions["planned"]:
|
||||
print(f" • {action}")
|
||||
|
||||
if parsed.apply:
|
||||
print(f"\nApplied actions ({len(actions['applied'])}):")
|
||||
for action in actions["applied"]:
|
||||
print(f" ✓ {action}")
|
||||
else:
|
||||
print("\nRun with --apply to execute these changes.")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def _run_init(parsed) -> int:
|
||||
"""Run the init subcommand."""
|
||||
StandardiseEngine, StandardiseConfig = _lazy_import_engine()
|
||||
|
||||
config = _create_config(parsed)
|
||||
engine = StandardiseEngine(config)
|
||||
|
||||
try:
|
||||
created = engine.init(parsed.feature)
|
||||
except ValueError as e:
|
||||
print(f"Error: {e}")
|
||||
return 2
|
||||
|
||||
if not created:
|
||||
print(f"Feature '{parsed.feature}' already has all required artifacts.")
|
||||
return 0
|
||||
|
||||
print(f"{'Would create' if config.dry_run else 'Created'} artifacts for '{parsed.feature}':")
|
||||
for artifact_type, path in created.items():
|
||||
status = "📝" if config.dry_run else "✓"
|
||||
print(f" {status} {artifact_type}: {path}")
|
||||
|
||||
if config.dry_run:
|
||||
print("\nRun with --apply to create these files.")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def _run_ai(parsed) -> int:
|
||||
"""Run the AI generation subcommand with ACP/LSP runtime support."""
|
||||
import asyncio
|
||||
from praisonai.standardise.ai_generator import AIGenerator
|
||||
from praisonai.standardise.models import ArtifactType, FeatureSlug
|
||||
|
||||
config = _create_config(parsed)
|
||||
|
||||
slug = FeatureSlug.from_string(parsed.feature)
|
||||
if not slug.is_valid:
|
||||
print(f"Error: Invalid feature slug: {slug.validation_error}")
|
||||
return 2
|
||||
|
||||
# Start ACP/LSP runtime for context gathering
|
||||
runtime = None
|
||||
try:
|
||||
from praisonai.cli.features.interactive_runtime import create_runtime
|
||||
runtime = create_runtime(
|
||||
workspace=str(config.sdk_root or config.project_root or "."),
|
||||
lsp=True,
|
||||
acp=False # ACP not needed for generation
|
||||
)
|
||||
# Start runtime in background
|
||||
asyncio.get_event_loop().run_until_complete(runtime.start())
|
||||
if runtime.lsp_ready:
|
||||
print("🔧 LSP server ready for code intelligence")
|
||||
except Exception:
|
||||
# Runtime is optional, continue without it
|
||||
pass
|
||||
|
||||
generator = AIGenerator(
|
||||
model=parsed.model,
|
||||
sdk_root=config.sdk_root,
|
||||
docs_root=config.docs_root,
|
||||
examples_root=config.examples_root,
|
||||
use_fast_context=True,
|
||||
)
|
||||
|
||||
# Determine which artifacts to generate
|
||||
artifact_types = []
|
||||
gen_type = getattr(parsed, "type", "all")
|
||||
|
||||
if gen_type in ["docs", "all"]:
|
||||
artifact_types.extend([
|
||||
ArtifactType.DOCS_CONCEPT,
|
||||
ArtifactType.DOCS_FEATURE,
|
||||
ArtifactType.DOCS_CLI,
|
||||
ArtifactType.DOCS_SDK,
|
||||
])
|
||||
|
||||
if gen_type in ["examples", "all"]:
|
||||
artifact_types.extend([
|
||||
ArtifactType.EXAMPLE_BASIC,
|
||||
ArtifactType.EXAMPLE_ADVANCED,
|
||||
])
|
||||
|
||||
print(f"🤖 AI Generation for '{slug.normalised}'")
|
||||
print("=" * 50)
|
||||
|
||||
results = []
|
||||
for artifact_type in artifact_types:
|
||||
print(f"\n📝 Generating {artifact_type.value}...")
|
||||
|
||||
try:
|
||||
# Generate with execution verification for examples
|
||||
content, output_path, verification_info = generator.generate(
|
||||
slug, artifact_type,
|
||||
dry_run=not parsed.apply,
|
||||
verify_execution=True,
|
||||
)
|
||||
|
||||
# Show verification results for examples
|
||||
if verification_info:
|
||||
if verification_info.get("execution_passed"):
|
||||
print(" ✅ Code verified: runs successfully")
|
||||
elif verification_info.get("requires_external"):
|
||||
libs = ", ".join(verification_info.get("missing_libraries", []))
|
||||
print(f" ⚠️ Requires external libraries: {libs}")
|
||||
elif not verification_info.get("syntax_valid"):
|
||||
print(f" ❌ Syntax error: {verification_info.get('error', 'Unknown')[:100]}")
|
||||
else:
|
||||
print(f" ❌ Execution failed after {verification_info.get('attempt', 1)} attempts")
|
||||
if verification_info.get("error"):
|
||||
print(f" Error: {verification_info['error'][:150]}")
|
||||
|
||||
# AI content verification (optional)
|
||||
if parsed.verify:
|
||||
print(" 🔍 AI content review...")
|
||||
is_valid, summary, issues = generator.verify(content, artifact_type, slug)
|
||||
if is_valid:
|
||||
print(f" ✅ {summary}")
|
||||
else:
|
||||
print(f" ⚠️ {summary}")
|
||||
for issue in issues[:3]:
|
||||
print(f" - {issue}")
|
||||
|
||||
# Check if we should write
|
||||
can_write = True
|
||||
if verification_info and not verification_info.get("success"):
|
||||
if not verification_info.get("requires_external"):
|
||||
can_write = False
|
||||
print(" ⛔ Not writing: code doesn't run")
|
||||
|
||||
if parsed.apply and output_path and can_write:
|
||||
print(f" ✓ Created: {output_path}")
|
||||
results.append((artifact_type.value, True, output_path))
|
||||
elif not parsed.apply:
|
||||
print(f" 📋 Preview ({len(content)} chars)")
|
||||
# Show first few lines
|
||||
preview_lines = content.split("\n")[:10]
|
||||
for line in preview_lines:
|
||||
print(f" {line[:60]}")
|
||||
if len(content.split("\n")) > 10:
|
||||
print(" ...")
|
||||
results.append((artifact_type.value, True, None))
|
||||
else:
|
||||
results.append((artifact_type.value, False, "Verification failed"))
|
||||
|
||||
except Exception as e:
|
||||
print(f" ❌ Error: {e}")
|
||||
results.append((artifact_type.value, False, str(e)))
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
print("Summary:")
|
||||
success_count = sum(1 for _, success, _ in results if success)
|
||||
print(f" Generated: {success_count}/{len(results)}")
|
||||
|
||||
if not parsed.apply:
|
||||
print("\nRun with --apply to create these files.")
|
||||
|
||||
# Cleanup runtime
|
||||
if runtime:
|
||||
try:
|
||||
asyncio.get_event_loop().run_until_complete(runtime.stop())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def _run_checkpoint(parsed) -> int:
|
||||
"""Run the checkpoint subcommand."""
|
||||
from praisonai.standardise.undo_redo import UndoRedoManager
|
||||
|
||||
manager = UndoRedoManager(Path(parsed.path).resolve())
|
||||
|
||||
success, result = manager.create_checkpoint(parsed.message)
|
||||
|
||||
if success:
|
||||
print(f"✓ Checkpoint created: {result}")
|
||||
else:
|
||||
print(f"❌ Failed: {result}")
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def _run_undo(parsed) -> int:
|
||||
"""Run the undo subcommand."""
|
||||
from praisonai.standardise.undo_redo import UndoRedoManager
|
||||
|
||||
manager = UndoRedoManager(Path(parsed.path).resolve())
|
||||
|
||||
# List checkpoints if requested
|
||||
if getattr(parsed, "list", False):
|
||||
checkpoints = manager.list_checkpoints()
|
||||
if not checkpoints:
|
||||
print("No checkpoints found.")
|
||||
return 0
|
||||
|
||||
print("Available checkpoints:")
|
||||
for cp_id, date, message in checkpoints:
|
||||
print(f" • {cp_id} ({date}): {message[:50]}")
|
||||
return 0
|
||||
|
||||
# Undo to checkpoint
|
||||
success, result = manager.undo(parsed.checkpoint)
|
||||
|
||||
if success:
|
||||
print(f"✓ {result}")
|
||||
else:
|
||||
print(f"❌ {result}")
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def _run_redo(parsed) -> int:
|
||||
"""Run the redo subcommand."""
|
||||
from praisonai.standardise.undo_redo import UndoRedoManager
|
||||
|
||||
manager = UndoRedoManager(Path(parsed.path).resolve())
|
||||
|
||||
success, result = manager.redo()
|
||||
|
||||
if success:
|
||||
print(f"✓ {result}")
|
||||
else:
|
||||
print(f"❌ {result}")
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
# Click-based alternative for integration with main CLI
|
||||
def register_click_commands(app):
|
||||
"""Register click commands with the main CLI app."""
|
||||
try:
|
||||
import click
|
||||
except ImportError:
|
||||
return
|
||||
|
||||
@app.group(name="standardise")
|
||||
def standardise_group():
|
||||
"""Standardise documentation and examples (FDEP)."""
|
||||
pass
|
||||
|
||||
@standardise_group.command(name="check")
|
||||
@click.option("--path", "-p", default=".", help="Project root path")
|
||||
@click.option("--feature", help="Specific feature slug to check")
|
||||
@click.option("--scope", type=click.Choice(["all", "docs", "examples", "sdk", "cli"]), default="all")
|
||||
@click.option("--ci", is_flag=True, help="CI mode")
|
||||
def check_cmd(path, feature, scope, ci):
|
||||
"""Check for standardisation issues."""
|
||||
StandardiseEngine, StandardiseConfig = _lazy_import_engine()
|
||||
|
||||
config = StandardiseConfig(
|
||||
project_root=Path(path).resolve(),
|
||||
feature_filter=feature,
|
||||
scope=scope,
|
||||
ci_mode=ci,
|
||||
)
|
||||
engine = StandardiseEngine(config)
|
||||
report = engine.check()
|
||||
|
||||
output = engine.reports.generate(report, "text")
|
||||
click.echo(output)
|
||||
|
||||
if ci:
|
||||
sys.exit(engine.get_exit_code(report))
|
||||
|
||||
@standardise_group.command(name="report")
|
||||
@click.option("--path", "-p", default=".", help="Project root path")
|
||||
@click.option("--format", "-f", type=click.Choice(["text", "json", "markdown"]), default="text")
|
||||
@click.option("--output", "-o", help="Output file path")
|
||||
@click.option("--ci", is_flag=True, help="CI mode")
|
||||
def report_cmd(path, format, output, ci):
|
||||
"""Generate detailed report."""
|
||||
StandardiseEngine, StandardiseConfig = _lazy_import_engine()
|
||||
|
||||
config = StandardiseConfig(
|
||||
project_root=Path(path).resolve(),
|
||||
ci_mode=ci,
|
||||
)
|
||||
engine = StandardiseEngine(config)
|
||||
report = engine.check()
|
||||
|
||||
content = engine.reports.generate(report, format)
|
||||
|
||||
if output:
|
||||
Path(output).write_text(content, encoding="utf-8")
|
||||
click.echo(f"Report saved to {output}")
|
||||
else:
|
||||
click.echo(content)
|
||||
|
||||
@standardise_group.command(name="fix")
|
||||
@click.option("--path", "-p", default=".", help="Project root path")
|
||||
@click.option("--feature", help="Specific feature slug to fix")
|
||||
@click.option("--apply", is_flag=True, help="Actually apply changes")
|
||||
@click.option("--no-backup", is_flag=True, help="Don't create backups")
|
||||
def fix_cmd(path, feature, apply, no_backup):
|
||||
"""Fix standardisation issues."""
|
||||
StandardiseEngine, StandardiseConfig = _lazy_import_engine()
|
||||
|
||||
config = StandardiseConfig(
|
||||
project_root=Path(path).resolve(),
|
||||
feature_filter=feature,
|
||||
dry_run=not apply,
|
||||
backup=not no_backup,
|
||||
)
|
||||
engine = StandardiseEngine(config)
|
||||
|
||||
actions = engine.fix(feature=feature, apply=apply)
|
||||
|
||||
if not actions["planned"]:
|
||||
click.echo("No fixes needed.")
|
||||
return
|
||||
|
||||
click.echo(f"Planned actions ({len(actions['planned'])}):")
|
||||
for action in actions["planned"]:
|
||||
click.echo(f" • {action}")
|
||||
|
||||
if apply:
|
||||
click.echo(f"\nApplied actions ({len(actions['applied'])}):")
|
||||
for action in actions["applied"]:
|
||||
click.echo(f" ✓ {action}")
|
||||
else:
|
||||
click.echo("\nRun with --apply to execute these changes.")
|
||||
|
||||
@standardise_group.command(name="init")
|
||||
@click.argument("feature")
|
||||
@click.option("--path", "-p", default=".", help="Project root path")
|
||||
@click.option("--apply", is_flag=True, help="Actually create files")
|
||||
def init_cmd(feature, path, apply):
|
||||
"""Initialise a new feature with all required artifacts."""
|
||||
StandardiseEngine, StandardiseConfig = _lazy_import_engine()
|
||||
|
||||
config = StandardiseConfig(
|
||||
project_root=Path(path).resolve(),
|
||||
dry_run=not apply,
|
||||
)
|
||||
engine = StandardiseEngine(config)
|
||||
|
||||
try:
|
||||
created = engine.init(feature)
|
||||
except ValueError as e:
|
||||
click.echo(f"Error: {e}", err=True)
|
||||
sys.exit(2)
|
||||
|
||||
if not created:
|
||||
click.echo(f"Feature '{feature}' already has all required artifacts.")
|
||||
return
|
||||
|
||||
action_word = "Would create" if config.dry_run else "Created"
|
||||
click.echo(f"{action_word} artifacts for '{feature}':")
|
||||
for artifact_type, artifact_path in created.items():
|
||||
status = "📝" if config.dry_run else "✓"
|
||||
click.echo(f" {status} {artifact_type}: {artifact_path}")
|
||||
|
||||
if config.dry_run:
|
||||
click.echo("\nRun with --apply to create these files.")
|
||||
|
||||
return standardise_group
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(standardise_command())
|
||||
@@ -31,14 +31,21 @@ class RichFrontend:
|
||||
- `praisonai chat`
|
||||
"""
|
||||
|
||||
def __init__(self, core: Optional[InteractiveCore] = None, config: Optional[InteractiveConfig] = None):
|
||||
def __init__(
|
||||
self,
|
||||
core: Optional[InteractiveCore] = None,
|
||||
config: Optional[InteractiveConfig] = None,
|
||||
ui_config: Optional['UIConfig'] = None,
|
||||
):
|
||||
"""Initialize the Rich frontend.
|
||||
|
||||
Args:
|
||||
core: InteractiveCore instance. If None, creates one.
|
||||
config: Configuration. Used if core is None.
|
||||
ui_config: UI backend configuration for rendering options.
|
||||
"""
|
||||
self.core = core or InteractiveCore(config=config)
|
||||
self._ui_config = ui_config
|
||||
self._running = False
|
||||
self._current_response = ""
|
||||
self._pending_approval: Optional[ApprovalRequest] = None
|
||||
|
||||
@@ -34,6 +34,8 @@ TYPER_COMMANDS = {
|
||||
'memory', 'workflow', 'tools', 'knowledge', 'deploy', 'agents', 'skills',
|
||||
'eval', 'templates', 'recipe', 'todo', 'docs', 'commit', 'hooks', 'rules',
|
||||
'registry', 'package', 'endpoints',
|
||||
# Standardisation commands
|
||||
'standardise', 'standardize',
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"""
|
||||
UI Backend system for PraisonAI CLI.
|
||||
|
||||
Provides multiple rendering backends for interactive mode:
|
||||
- PlainBackend: No dependencies, works everywhere
|
||||
- RichBackend: Rich-based rendering (current default)
|
||||
- MiddleGroundBackend: Enhanced Rich + prompt_toolkit (new default when deps available)
|
||||
|
||||
All backends implement the UIBackend protocol and are selected automatically
|
||||
based on environment, or explicitly via --ui flag.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
__all__ = [
|
||||
'UIConfig',
|
||||
'UIEventType',
|
||||
'select_backend',
|
||||
'PlainBackend',
|
||||
'RichBackend',
|
||||
'MiddleGroundBackend',
|
||||
]
|
||||
|
||||
_lazy_cache = {}
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
"""Lazy load UI components."""
|
||||
if name in _lazy_cache:
|
||||
return _lazy_cache[name]
|
||||
|
||||
if name == 'UIConfig':
|
||||
from .config import UIConfig
|
||||
_lazy_cache[name] = UIConfig
|
||||
return UIConfig
|
||||
|
||||
if name == 'UIEventType':
|
||||
from .events import UIEventType
|
||||
_lazy_cache[name] = UIEventType
|
||||
return UIEventType
|
||||
|
||||
if name == 'PlainBackend':
|
||||
from .plain import PlainBackend
|
||||
_lazy_cache[name] = PlainBackend
|
||||
return PlainBackend
|
||||
|
||||
if name == 'RichBackend':
|
||||
from .rich_backend import RichBackend
|
||||
_lazy_cache[name] = RichBackend
|
||||
return RichBackend
|
||||
|
||||
if name == 'MiddleGroundBackend':
|
||||
from .mg_backend import MiddleGroundBackend
|
||||
_lazy_cache[name] = MiddleGroundBackend
|
||||
return MiddleGroundBackend
|
||||
|
||||
if name == 'select_backend':
|
||||
from .selector import select_backend
|
||||
_lazy_cache[name] = select_backend
|
||||
return select_backend
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
|
||||
def select_backend(config: Optional['UIConfig'] = None):
|
||||
"""
|
||||
Select the appropriate UI backend based on config and environment.
|
||||
|
||||
Selection priority:
|
||||
1. Explicit config.ui_backend override
|
||||
2. PRAISONAI_UI_SAFE=1 → PlainBackend
|
||||
3. config.json_output=True → PlainBackend
|
||||
4. Non-TTY stdout → PlainBackend
|
||||
5. Auto-detect based on available deps:
|
||||
- MiddleGroundBackend if rich + prompt_toolkit available
|
||||
- RichBackend if rich available
|
||||
- PlainBackend otherwise
|
||||
|
||||
Args:
|
||||
config: UIConfig instance with settings
|
||||
|
||||
Returns:
|
||||
UIBackend instance
|
||||
"""
|
||||
from .config import UIConfig
|
||||
from .plain import PlainBackend
|
||||
|
||||
if config is None:
|
||||
config = UIConfig()
|
||||
|
||||
# Explicit override
|
||||
if config.ui_backend and config.ui_backend != 'auto':
|
||||
return _get_backend_by_name(config.ui_backend, config)
|
||||
|
||||
# Environment override for safe mode
|
||||
if os.environ.get('PRAISONAI_UI_SAFE') == '1':
|
||||
return PlainBackend(config)
|
||||
|
||||
# JSON output forces plain
|
||||
if config.json_output:
|
||||
return PlainBackend(config)
|
||||
|
||||
# Non-TTY forces plain
|
||||
if not sys.stdout.isatty():
|
||||
return PlainBackend(config)
|
||||
|
||||
# Auto-detect based on available deps
|
||||
return _auto_select_backend(config)
|
||||
|
||||
|
||||
def _get_backend_by_name(name: str, config: 'UIConfig'):
|
||||
"""Get backend by name."""
|
||||
from .plain import PlainBackend
|
||||
|
||||
if name == 'plain':
|
||||
return PlainBackend(config)
|
||||
|
||||
if name == 'rich':
|
||||
try:
|
||||
from .rich_backend import RichBackend
|
||||
return RichBackend(config)
|
||||
except ImportError:
|
||||
return PlainBackend(config)
|
||||
|
||||
if name == 'mg':
|
||||
try:
|
||||
from .mg_backend import MiddleGroundBackend
|
||||
return MiddleGroundBackend(config)
|
||||
except ImportError:
|
||||
# Fallback to rich, then plain
|
||||
try:
|
||||
from .rich_backend import RichBackend
|
||||
return RichBackend(config)
|
||||
except ImportError:
|
||||
return PlainBackend(config)
|
||||
|
||||
# Unknown backend, use plain
|
||||
return PlainBackend(config)
|
||||
|
||||
|
||||
def _auto_select_backend(config: 'UIConfig'):
|
||||
"""Auto-select best available backend."""
|
||||
from .plain import PlainBackend
|
||||
|
||||
# Try MiddleGroundBackend first (requires rich + prompt_toolkit)
|
||||
try:
|
||||
from .mg_backend import MiddleGroundBackend
|
||||
return MiddleGroundBackend(config)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Try RichBackend (requires rich)
|
||||
try:
|
||||
from .rich_backend import RichBackend
|
||||
return RichBackend(config)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Fallback to PlainBackend
|
||||
return PlainBackend(config)
|
||||
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
UI Configuration for PraisonAI CLI.
|
||||
|
||||
Provides unified configuration for all UI backends.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class UIConfig:
|
||||
"""Configuration for UI backends."""
|
||||
|
||||
# Backend selection
|
||||
ui_backend: str = 'auto' # auto, plain, rich, mg
|
||||
|
||||
# Output modes
|
||||
json_output: bool = False
|
||||
compact: bool = False
|
||||
verbose: bool = False
|
||||
|
||||
# Styling
|
||||
no_color: bool = False
|
||||
no_unicode: bool = False
|
||||
theme: str = 'default'
|
||||
|
||||
# Behavior
|
||||
no_fullscreen: bool = False
|
||||
|
||||
# Additional settings
|
||||
extra: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@classmethod
|
||||
def from_cli_args(cls, **kwargs) -> 'UIConfig':
|
||||
"""Create UIConfig from CLI arguments."""
|
||||
return cls(
|
||||
ui_backend=kwargs.get('ui', 'auto'),
|
||||
json_output=kwargs.get('json', False),
|
||||
compact=kwargs.get('compact', False),
|
||||
verbose=kwargs.get('verbose', False),
|
||||
no_color=kwargs.get('no_color', False),
|
||||
no_unicode=kwargs.get('no_unicode', False),
|
||||
theme=kwargs.get('theme', 'default'),
|
||||
no_fullscreen=kwargs.get('no_fullscreen', False),
|
||||
)
|
||||
@@ -0,0 +1,61 @@
|
||||
"""
|
||||
UI Event types for backend communication.
|
||||
|
||||
Events flow from InteractiveCore to UI backends for rendering.
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
class UIEventType(Enum):
|
||||
"""Event types emitted by InteractiveCore to UI backends."""
|
||||
|
||||
# Message lifecycle
|
||||
MESSAGE_START = "message.start"
|
||||
MESSAGE_CHUNK = "message.chunk"
|
||||
MESSAGE_END = "message.end"
|
||||
|
||||
# Tool lifecycle
|
||||
TOOL_START = "tool.start"
|
||||
TOOL_PROGRESS = "tool.progress"
|
||||
TOOL_END = "tool.end"
|
||||
|
||||
# Approval flow
|
||||
APPROVAL_REQUEST = "approval.request"
|
||||
APPROVAL_RESPONSE = "approval.response"
|
||||
|
||||
# Status updates
|
||||
STATUS_UPDATE = "status.update"
|
||||
STATUS_CLEAR = "status.clear"
|
||||
|
||||
# Errors
|
||||
ERROR = "error"
|
||||
WARNING = "warning"
|
||||
|
||||
# Session events
|
||||
SESSION_START = "session.start"
|
||||
SESSION_END = "session.end"
|
||||
|
||||
# Agent events (multi-agent)
|
||||
AGENT_SWITCH = "agent.switch"
|
||||
|
||||
# Input events
|
||||
INPUT_REQUEST = "input.request"
|
||||
INPUT_RECEIVED = "input.received"
|
||||
|
||||
|
||||
@dataclass
|
||||
class UIEvent:
|
||||
"""A UI event with type and data payload."""
|
||||
|
||||
event_type: UIEventType
|
||||
data: Dict[str, Any] = field(default_factory=dict)
|
||||
agent_name: Optional[str] = None
|
||||
timestamp: Optional[float] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.timestamp is None:
|
||||
import time
|
||||
self.timestamp = time.time()
|
||||
@@ -0,0 +1,342 @@
|
||||
"""
|
||||
MiddleGroundBackend - Enhanced Rich + prompt_toolkit UI backend.
|
||||
|
||||
The "middle ground" between plain text and full TUI:
|
||||
- Rich for formatted output (panels, markdown, syntax highlighting)
|
||||
- prompt_toolkit for advanced input (history, completion, key bindings)
|
||||
- Streaming markdown with no-flicker approach (stable lines + live window)
|
||||
- Terminal cleanup on exit/crash
|
||||
|
||||
Key design: Uses Aider-style streaming where stable lines go to console
|
||||
above a small Live window, preserving terminal scrollback.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
import atexit
|
||||
import signal
|
||||
from typing import Dict, Any, Optional, List, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .config import UIConfig
|
||||
|
||||
from .events import UIEventType
|
||||
|
||||
try:
|
||||
from rich.console import Console
|
||||
from rich.markdown import Markdown
|
||||
from rich.panel import Panel
|
||||
from rich.live import Live
|
||||
from rich.text import Text
|
||||
RICH_AVAILABLE = True
|
||||
except ImportError:
|
||||
RICH_AVAILABLE = False
|
||||
|
||||
try:
|
||||
from prompt_toolkit import PromptSession
|
||||
from prompt_toolkit.history import InMemoryHistory
|
||||
PROMPT_TOOLKIT_AVAILABLE = True
|
||||
except ImportError:
|
||||
PROMPT_TOOLKIT_AVAILABLE = False
|
||||
|
||||
|
||||
class MarkdownStreamer:
|
||||
"""Streaming markdown renderer with no-flicker approach.
|
||||
|
||||
Splits output into stable lines (printed above) and unstable lines
|
||||
(in Live window). This preserves terminal scrollback while allowing
|
||||
smooth streaming updates.
|
||||
"""
|
||||
|
||||
def __init__(self, console: 'Console', live_window: int = 6):
|
||||
"""Initialize streamer.
|
||||
|
||||
Args:
|
||||
console: Rich Console instance
|
||||
live_window: Number of lines to keep in live area
|
||||
"""
|
||||
self._console = console
|
||||
self._live_window = live_window
|
||||
self._live: Optional[Live] = None
|
||||
self._printed_lines: List[str] = []
|
||||
self._current_text = ""
|
||||
self._min_delay = 1.0 / 20 # 20 FPS max
|
||||
self._last_update = 0.0
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the streaming display."""
|
||||
self._live = Live(Text(""), console=self._console, refresh_per_second=20)
|
||||
self._live.start()
|
||||
self._printed_lines = []
|
||||
self._current_text = ""
|
||||
|
||||
def update(self, text: str, final: bool = False) -> None:
|
||||
"""Update the displayed content.
|
||||
|
||||
Args:
|
||||
text: Full markdown text so far
|
||||
final: If True, this is the final update
|
||||
"""
|
||||
if not self._live:
|
||||
return
|
||||
|
||||
now = time.time()
|
||||
if not final and now - self._last_update < self._min_delay:
|
||||
return
|
||||
self._last_update = now
|
||||
|
||||
self._current_text = text
|
||||
lines = text.splitlines(keepends=True)
|
||||
num_lines = len(lines)
|
||||
|
||||
# How many lines are stable (not in live window)?
|
||||
stable_count = num_lines - self._live_window if not final else num_lines
|
||||
|
||||
if stable_count > len(self._printed_lines):
|
||||
# Print new stable lines above live area
|
||||
new_lines = lines[len(self._printed_lines):stable_count]
|
||||
for line in new_lines:
|
||||
self._console.print(line.rstrip('\n'))
|
||||
self._printed_lines = lines[:stable_count]
|
||||
|
||||
if final:
|
||||
self._live.update(Text(""))
|
||||
self._live.stop()
|
||||
self._live = None
|
||||
else:
|
||||
# Update live window with remaining lines
|
||||
rest = lines[stable_count:]
|
||||
rest_text = "".join(rest)
|
||||
self._live.update(Text(rest_text))
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the streaming display."""
|
||||
if self._live:
|
||||
self._live.stop()
|
||||
self._live = None
|
||||
|
||||
|
||||
class MiddleGroundBackend:
|
||||
"""Enhanced Rich + prompt_toolkit UI backend."""
|
||||
|
||||
def __init__(self, config: Optional['UIConfig'] = None):
|
||||
"""Initialize MiddleGroundBackend.
|
||||
|
||||
Args:
|
||||
config: Optional UIConfig instance
|
||||
|
||||
Raises:
|
||||
ImportError: If required deps not installed
|
||||
"""
|
||||
if not RICH_AVAILABLE:
|
||||
raise ImportError("Rich is required. Install with: pip install rich")
|
||||
if not PROMPT_TOOLKIT_AVAILABLE:
|
||||
raise ImportError("prompt_toolkit is required. Install with: pip install prompt_toolkit")
|
||||
|
||||
self._config = config
|
||||
self._current_message = ""
|
||||
self._in_stream = False
|
||||
|
||||
# Configure console
|
||||
no_color = config.no_color if config else False
|
||||
self._console = Console(
|
||||
force_terminal=True,
|
||||
no_color=no_color,
|
||||
highlight=not no_color,
|
||||
)
|
||||
|
||||
# Markdown streamer for no-flicker streaming
|
||||
self._streamer: Optional[MarkdownStreamer] = None
|
||||
|
||||
# prompt_toolkit session
|
||||
self._prompt_session: Optional[PromptSession] = None
|
||||
self._history = InMemoryHistory()
|
||||
|
||||
# Register cleanup handlers
|
||||
self._register_cleanup()
|
||||
|
||||
def _register_cleanup(self) -> None:
|
||||
"""Register terminal cleanup handlers."""
|
||||
def cleanup():
|
||||
self._cleanup_terminal()
|
||||
|
||||
atexit.register(cleanup)
|
||||
|
||||
# Handle signals
|
||||
def signal_handler(signum, frame):
|
||||
self._cleanup_terminal()
|
||||
sys.exit(128 + signum)
|
||||
|
||||
try:
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
except (ValueError, OSError):
|
||||
# Can't set signal handlers in some contexts
|
||||
pass
|
||||
|
||||
def _cleanup_terminal(self) -> None:
|
||||
"""Restore terminal state."""
|
||||
try:
|
||||
# Stop any active streaming
|
||||
if self._streamer:
|
||||
self._streamer.stop()
|
||||
|
||||
# Restore cursor
|
||||
sys.stdout.write("\033[?25h") # Show cursor
|
||||
sys.stdout.write("\033[0m") # Reset colors
|
||||
sys.stdout.flush()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def emit(self, event_type: UIEventType, data: Dict[str, Any]) -> None:
|
||||
"""Emit an event to the UI.
|
||||
|
||||
Args:
|
||||
event_type: Type of event
|
||||
data: Event data payload
|
||||
"""
|
||||
if event_type == UIEventType.MESSAGE_START:
|
||||
self._in_stream = True
|
||||
self._current_message = ""
|
||||
agent_name = data.get('agent_name', '')
|
||||
if agent_name:
|
||||
self._console.print(f"[bold cyan][{agent_name}][/bold cyan]")
|
||||
|
||||
# Start streaming display
|
||||
self._streamer = MarkdownStreamer(self._console)
|
||||
self._streamer.start()
|
||||
|
||||
elif event_type == UIEventType.MESSAGE_CHUNK:
|
||||
content = data.get('content', '')
|
||||
self._current_message += content
|
||||
|
||||
if self._streamer:
|
||||
self._streamer.update(self._current_message)
|
||||
|
||||
elif event_type == UIEventType.MESSAGE_END:
|
||||
self._in_stream = False
|
||||
|
||||
if self._streamer:
|
||||
self._streamer.update(self._current_message, final=True)
|
||||
self._streamer = None
|
||||
|
||||
self._current_message = ""
|
||||
|
||||
elif event_type == UIEventType.TOOL_START:
|
||||
tool_name = data.get('tool_name', 'tool')
|
||||
args = data.get('args', '')
|
||||
self._console.print(f"\n[dim]▶ {tool_name}[/dim]", end="")
|
||||
if args and self._config and self._config.verbose:
|
||||
self._console.print(f" [dim]{args}[/dim]")
|
||||
else:
|
||||
self._console.print()
|
||||
|
||||
elif event_type == UIEventType.TOOL_END:
|
||||
result = data.get('result', '')
|
||||
success = data.get('success', True)
|
||||
if result:
|
||||
style = "green" if success else "red"
|
||||
# Truncate long results
|
||||
if len(result) > 200 and not (self._config and self._config.verbose):
|
||||
result = result[:200] + "..."
|
||||
self._console.print(f"[{style}]{result}[/{style}]")
|
||||
|
||||
elif event_type == UIEventType.ERROR:
|
||||
message = data.get('message', 'Unknown error')
|
||||
self._console.print(Panel(
|
||||
f"[bold red]{message}[/bold red]",
|
||||
title="Error",
|
||||
border_style="red",
|
||||
))
|
||||
|
||||
elif event_type == UIEventType.WARNING:
|
||||
message = data.get('message', '')
|
||||
self._console.print(f"[yellow]⚠ {message}[/yellow]")
|
||||
|
||||
elif event_type == UIEventType.STATUS_UPDATE:
|
||||
status = data.get('status', '')
|
||||
if status:
|
||||
# Use transient status (overwrite with \r)
|
||||
sys.stdout.write(f"\r[{status}]")
|
||||
sys.stdout.flush()
|
||||
|
||||
elif event_type == UIEventType.STATUS_CLEAR:
|
||||
sys.stdout.write("\r" + " " * 40 + "\r")
|
||||
sys.stdout.flush()
|
||||
|
||||
elif event_type == UIEventType.APPROVAL_REQUEST:
|
||||
prompt = data.get('prompt', 'Approve?')
|
||||
action = data.get('action', '')
|
||||
|
||||
self._console.print()
|
||||
self._console.print(Panel(
|
||||
f"{prompt}\n\n[dim]{action}[/dim]" if action else prompt,
|
||||
title="[bold yellow]Approval Required[/bold yellow]",
|
||||
border_style="yellow",
|
||||
))
|
||||
self._console.print("[bold][y][/bold]es / [bold][n][/bold]o / [bold][a][/bold]lways: ", end="")
|
||||
|
||||
def prompt(self, message: str = "") -> str:
|
||||
"""Get user input with prompt_toolkit.
|
||||
|
||||
Args:
|
||||
message: Optional prompt message
|
||||
|
||||
Returns:
|
||||
User input string
|
||||
"""
|
||||
if not self._prompt_session:
|
||||
self._prompt_session = PromptSession(
|
||||
history=self._history,
|
||||
enable_history_search=True,
|
||||
)
|
||||
|
||||
try:
|
||||
return self._prompt_session.prompt(message)
|
||||
except EOFError:
|
||||
return ""
|
||||
except KeyboardInterrupt:
|
||||
return ""
|
||||
|
||||
async def prompt_async(self, message: str = "") -> str:
|
||||
"""Get user input asynchronously.
|
||||
|
||||
Args:
|
||||
message: Optional prompt message
|
||||
|
||||
Returns:
|
||||
User input string
|
||||
"""
|
||||
if not self._prompt_session:
|
||||
self._prompt_session = PromptSession(
|
||||
history=self._history,
|
||||
enable_history_search=True,
|
||||
)
|
||||
|
||||
try:
|
||||
return await self._prompt_session.prompt_async(message)
|
||||
except EOFError:
|
||||
return ""
|
||||
except KeyboardInterrupt:
|
||||
return ""
|
||||
|
||||
def is_tty(self) -> bool:
|
||||
"""Check if running in a TTY.
|
||||
|
||||
Returns:
|
||||
True if stdout is a TTY
|
||||
"""
|
||||
return sys.stdout.isatty()
|
||||
|
||||
def render_markdown(self, text: str) -> None:
|
||||
"""Render markdown text.
|
||||
|
||||
Args:
|
||||
text: Markdown text to render
|
||||
"""
|
||||
md = Markdown(text)
|
||||
self._console.print(md)
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Cleanup resources."""
|
||||
self._cleanup_terminal()
|
||||
@@ -0,0 +1,133 @@
|
||||
"""
|
||||
PlainBackend - Zero-dependency UI backend.
|
||||
|
||||
Works in any environment: non-TTY, piped output, dumb terminals.
|
||||
No colors, no formatting, just plain text output.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from typing import Dict, Any, Optional, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .config import UIConfig
|
||||
|
||||
from .events import UIEventType
|
||||
|
||||
|
||||
class PlainBackend:
|
||||
"""Plain text UI backend with no dependencies."""
|
||||
|
||||
def __init__(self, config: Optional['UIConfig'] = None):
|
||||
"""Initialize PlainBackend.
|
||||
|
||||
Args:
|
||||
config: Optional UIConfig instance
|
||||
"""
|
||||
self._config = config
|
||||
self._current_message = ""
|
||||
self._in_stream = False
|
||||
|
||||
def emit(self, event_type: UIEventType, data: Dict[str, Any]) -> None:
|
||||
"""Emit an event to the UI.
|
||||
|
||||
Args:
|
||||
event_type: Type of event
|
||||
data: Event data payload
|
||||
"""
|
||||
if event_type == UIEventType.MESSAGE_START:
|
||||
self._in_stream = True
|
||||
self._current_message = ""
|
||||
agent_name = data.get('agent_name', '')
|
||||
if agent_name:
|
||||
sys.stdout.write(f"[{agent_name}] ")
|
||||
sys.stdout.flush()
|
||||
|
||||
elif event_type == UIEventType.MESSAGE_CHUNK:
|
||||
content = data.get('content', '')
|
||||
sys.stdout.write(content)
|
||||
sys.stdout.flush()
|
||||
self._current_message += content
|
||||
|
||||
elif event_type == UIEventType.MESSAGE_END:
|
||||
self._in_stream = False
|
||||
if not self._current_message.endswith('\n'):
|
||||
sys.stdout.write('\n')
|
||||
sys.stdout.flush()
|
||||
self._current_message = ""
|
||||
|
||||
elif event_type == UIEventType.TOOL_START:
|
||||
tool_name = data.get('tool_name', 'tool')
|
||||
sys.stdout.write(f"\n[Tool: {tool_name}]\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
elif event_type == UIEventType.TOOL_END:
|
||||
result = data.get('result', '')
|
||||
if result:
|
||||
sys.stdout.write(f"{result}\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
elif event_type == UIEventType.ERROR:
|
||||
message = data.get('message', 'Unknown error')
|
||||
sys.stderr.write(f"Error: {message}\n")
|
||||
sys.stderr.flush()
|
||||
|
||||
elif event_type == UIEventType.WARNING:
|
||||
message = data.get('message', '')
|
||||
sys.stderr.write(f"Warning: {message}\n")
|
||||
sys.stderr.flush()
|
||||
|
||||
elif event_type == UIEventType.STATUS_UPDATE:
|
||||
status = data.get('status', '')
|
||||
if status and self._config and self._config.verbose:
|
||||
sys.stderr.write(f"[{status}]\n")
|
||||
sys.stderr.flush()
|
||||
|
||||
elif event_type == UIEventType.APPROVAL_REQUEST:
|
||||
prompt = data.get('prompt', 'Approve?')
|
||||
sys.stdout.write(f"\n{prompt} [y/n]: ")
|
||||
sys.stdout.flush()
|
||||
|
||||
def prompt(self, message: str = "") -> str:
|
||||
"""Get user input.
|
||||
|
||||
Args:
|
||||
message: Optional prompt message
|
||||
|
||||
Returns:
|
||||
User input string
|
||||
"""
|
||||
if message:
|
||||
sys.stdout.write(message)
|
||||
sys.stdout.flush()
|
||||
|
||||
try:
|
||||
return input()
|
||||
except EOFError:
|
||||
return ""
|
||||
except KeyboardInterrupt:
|
||||
return ""
|
||||
|
||||
async def prompt_async(self, message: str = "") -> str:
|
||||
"""Get user input asynchronously.
|
||||
|
||||
For PlainBackend, this is just a sync call wrapped.
|
||||
|
||||
Args:
|
||||
message: Optional prompt message
|
||||
|
||||
Returns:
|
||||
User input string
|
||||
"""
|
||||
return self.prompt(message)
|
||||
|
||||
def is_tty(self) -> bool:
|
||||
"""Check if running in a TTY.
|
||||
|
||||
Returns:
|
||||
True if stdout is a TTY
|
||||
"""
|
||||
return sys.stdout.isatty()
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Cleanup resources. No-op for PlainBackend."""
|
||||
pass
|
||||
@@ -0,0 +1,162 @@
|
||||
"""
|
||||
RichBackend - Rich-based UI backend.
|
||||
|
||||
Uses Rich library for formatted terminal output with colors,
|
||||
panels, markdown rendering, and syntax highlighting.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from typing import Dict, Any, Optional, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .config import UIConfig
|
||||
|
||||
from .events import UIEventType
|
||||
|
||||
try:
|
||||
from rich.console import Console
|
||||
from rich.markdown import Markdown
|
||||
from rich.panel import Panel
|
||||
RICH_AVAILABLE = True
|
||||
except ImportError:
|
||||
RICH_AVAILABLE = False
|
||||
|
||||
|
||||
class RichBackend:
|
||||
"""Rich-based UI backend with formatted output."""
|
||||
|
||||
def __init__(self, config: Optional['UIConfig'] = None):
|
||||
"""Initialize RichBackend.
|
||||
|
||||
Args:
|
||||
config: Optional UIConfig instance
|
||||
|
||||
Raises:
|
||||
ImportError: If rich is not installed
|
||||
"""
|
||||
if not RICH_AVAILABLE:
|
||||
raise ImportError("Rich is required for RichBackend. Install with: pip install rich")
|
||||
|
||||
self._config = config
|
||||
self._current_message = ""
|
||||
self._in_stream = False
|
||||
|
||||
# Configure console
|
||||
no_color = config.no_color if config else False
|
||||
self._console = Console(
|
||||
force_terminal=True,
|
||||
no_color=no_color,
|
||||
highlight=not no_color,
|
||||
)
|
||||
|
||||
def emit(self, event_type: UIEventType, data: Dict[str, Any]) -> None:
|
||||
"""Emit an event to the UI.
|
||||
|
||||
Args:
|
||||
event_type: Type of event
|
||||
data: Event data payload
|
||||
"""
|
||||
if event_type == UIEventType.MESSAGE_START:
|
||||
self._in_stream = True
|
||||
self._current_message = ""
|
||||
agent_name = data.get('agent_name', '')
|
||||
if agent_name:
|
||||
self._console.print(f"[bold cyan][{agent_name}][/bold cyan] ", end="")
|
||||
|
||||
elif event_type == UIEventType.MESSAGE_CHUNK:
|
||||
content = data.get('content', '')
|
||||
# For streaming, use raw print to avoid Rich overhead
|
||||
sys.stdout.write(content)
|
||||
sys.stdout.flush()
|
||||
self._current_message += content
|
||||
|
||||
elif event_type == UIEventType.MESSAGE_END:
|
||||
self._in_stream = False
|
||||
if not self._current_message.endswith('\n'):
|
||||
sys.stdout.write('\n')
|
||||
sys.stdout.flush()
|
||||
self._current_message = ""
|
||||
|
||||
elif event_type == UIEventType.TOOL_START:
|
||||
tool_name = data.get('tool_name', 'tool')
|
||||
self._console.print(f"\n[dim]▶ {tool_name}[/dim]")
|
||||
|
||||
elif event_type == UIEventType.TOOL_END:
|
||||
result = data.get('result', '')
|
||||
success = data.get('success', True)
|
||||
if result:
|
||||
style = "green" if success else "red"
|
||||
self._console.print(f"[{style}]{result}[/{style}]")
|
||||
|
||||
elif event_type == UIEventType.ERROR:
|
||||
message = data.get('message', 'Unknown error')
|
||||
self._console.print(f"[bold red]Error:[/bold red] {message}")
|
||||
|
||||
elif event_type == UIEventType.WARNING:
|
||||
message = data.get('message', '')
|
||||
self._console.print(f"[yellow]Warning:[/yellow] {message}")
|
||||
|
||||
elif event_type == UIEventType.STATUS_UPDATE:
|
||||
status = data.get('status', '')
|
||||
if status and self._config and self._config.verbose:
|
||||
self._console.print(f"[dim][{status}][/dim]")
|
||||
|
||||
elif event_type == UIEventType.APPROVAL_REQUEST:
|
||||
prompt = data.get('prompt', 'Approve?')
|
||||
self._console.print(Panel(
|
||||
prompt,
|
||||
title="[bold yellow]Approval Required[/bold yellow]",
|
||||
border_style="yellow",
|
||||
))
|
||||
self._console.print("[y]es / [n]o / [a]lways: ", end="")
|
||||
|
||||
def prompt(self, message: str = "") -> str:
|
||||
"""Get user input.
|
||||
|
||||
Args:
|
||||
message: Optional prompt message
|
||||
|
||||
Returns:
|
||||
User input string
|
||||
"""
|
||||
if message:
|
||||
self._console.print(message, end="")
|
||||
|
||||
try:
|
||||
return input()
|
||||
except EOFError:
|
||||
return ""
|
||||
except KeyboardInterrupt:
|
||||
return ""
|
||||
|
||||
async def prompt_async(self, message: str = "") -> str:
|
||||
"""Get user input asynchronously.
|
||||
|
||||
Args:
|
||||
message: Optional prompt message
|
||||
|
||||
Returns:
|
||||
User input string
|
||||
"""
|
||||
return self.prompt(message)
|
||||
|
||||
def is_tty(self) -> bool:
|
||||
"""Check if running in a TTY.
|
||||
|
||||
Returns:
|
||||
True if stdout is a TTY
|
||||
"""
|
||||
return sys.stdout.isatty()
|
||||
|
||||
def render_markdown(self, text: str) -> None:
|
||||
"""Render markdown text.
|
||||
|
||||
Args:
|
||||
text: Markdown text to render
|
||||
"""
|
||||
md = Markdown(text)
|
||||
self._console.print(md)
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Cleanup resources."""
|
||||
pass
|
||||
@@ -57,7 +57,7 @@ class CloudDeployer:
|
||||
file.write("FROM python:3.11-slim\n")
|
||||
file.write("WORKDIR /app\n")
|
||||
file.write("COPY . .\n")
|
||||
file.write("RUN pip install flask praisonai==3.7.0 gunicorn markdown\n")
|
||||
file.write("RUN pip install flask praisonai==3.7.3 gunicorn markdown\n")
|
||||
file.write("EXPOSE 8080\n")
|
||||
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""
|
||||
PraisonAI Standardise Module
|
||||
|
||||
Feature Docs/Examples Protocol (FDEP) implementation for standardising
|
||||
documentation and examples across the PraisonAI ecosystem.
|
||||
|
||||
All imports are lazy-loaded for zero performance impact.
|
||||
"""
|
||||
|
||||
__all__ = [
|
||||
# Models
|
||||
"FeatureSlug",
|
||||
"FeatureManifest",
|
||||
"ArtifactStatus",
|
||||
"ValidationResult",
|
||||
"DuplicateCluster",
|
||||
"DriftReport",
|
||||
# Engine
|
||||
"StandardiseEngine",
|
||||
"FeatureDiscovery",
|
||||
"ArtifactValidator",
|
||||
"DedupeDetector",
|
||||
"DriftDetector",
|
||||
"TemplateGenerator",
|
||||
"ReportGenerator",
|
||||
# Config
|
||||
"StandardiseConfig",
|
||||
# AI Generation
|
||||
"AIGenerator",
|
||||
"ExampleVerifier",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
"""Lazy loading of standardise components."""
|
||||
if name in ("FeatureSlug", "FeatureManifest", "ArtifactStatus",
|
||||
"ValidationResult", "DuplicateCluster", "DriftReport"):
|
||||
from .models import (
|
||||
FeatureSlug, FeatureManifest, ArtifactStatus,
|
||||
ValidationResult, DuplicateCluster, DriftReport
|
||||
)
|
||||
return locals()[name]
|
||||
|
||||
if name == "StandardiseEngine":
|
||||
from .engine import StandardiseEngine
|
||||
return StandardiseEngine
|
||||
|
||||
if name == "FeatureDiscovery":
|
||||
from .discovery import FeatureDiscovery
|
||||
return FeatureDiscovery
|
||||
|
||||
if name == "ArtifactValidator":
|
||||
from .validator import ArtifactValidator
|
||||
return ArtifactValidator
|
||||
|
||||
if name == "DedupeDetector":
|
||||
from .dedupe import DedupeDetector
|
||||
return DedupeDetector
|
||||
|
||||
if name == "DriftDetector":
|
||||
from .drift import DriftDetector
|
||||
return DriftDetector
|
||||
|
||||
if name == "TemplateGenerator":
|
||||
from .templates import TemplateGenerator
|
||||
return TemplateGenerator
|
||||
|
||||
if name == "ReportGenerator":
|
||||
from .reports import ReportGenerator
|
||||
return ReportGenerator
|
||||
|
||||
if name == "StandardiseConfig":
|
||||
from .config import StandardiseConfig
|
||||
return StandardiseConfig
|
||||
|
||||
if name == "AIGenerator":
|
||||
from .ai_generator import AIGenerator
|
||||
return AIGenerator
|
||||
|
||||
if name == "ExampleVerifier":
|
||||
from .example_verifier import ExampleVerifier
|
||||
return ExampleVerifier
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,260 @@
|
||||
"""
|
||||
Configuration for the FDEP standardisation system.
|
||||
|
||||
Supports configuration via:
|
||||
1. CLI flags (highest priority)
|
||||
2. Config file (.praison/standardise.yaml)
|
||||
3. Environment variables
|
||||
4. Defaults (lowest priority)
|
||||
"""
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class StandardiseConfig:
|
||||
"""Configuration for the standardiser engine."""
|
||||
|
||||
# Root paths (auto-detected if not specified)
|
||||
project_root: Optional[Path] = None
|
||||
docs_root: Optional[Path] = None
|
||||
examples_root: Optional[Path] = None
|
||||
sdk_root: Optional[Path] = None
|
||||
cli_root: Optional[Path] = None
|
||||
|
||||
# Mintlify config
|
||||
mintlify_config: Optional[Path] = None
|
||||
|
||||
# Manifest storage
|
||||
manifest_dir: Optional[Path] = None
|
||||
|
||||
# Safety settings
|
||||
dry_run: bool = True
|
||||
backup: bool = True
|
||||
force: bool = False
|
||||
|
||||
# Scope settings
|
||||
scope: str = "all" # all, docs, examples, sdk, cli
|
||||
feature_filter: Optional[str] = None # Specific feature slug to check
|
||||
|
||||
# Dedupe settings
|
||||
dedupe_mode: str = "prompt" # prompt, auto, skip
|
||||
similarity_threshold: float = 0.8
|
||||
|
||||
# Report settings
|
||||
report_format: str = "text" # text, json, markdown
|
||||
|
||||
# CI settings
|
||||
ci_mode: bool = False
|
||||
|
||||
# Excluded paths (relative to project root)
|
||||
excluded_paths: List[str] = field(default_factory=lambda: [
|
||||
"node_modules",
|
||||
"__pycache__",
|
||||
".git",
|
||||
".venv",
|
||||
"venv",
|
||||
"dist",
|
||||
"build",
|
||||
])
|
||||
|
||||
# Required artifact types per feature
|
||||
required_artifacts: List[str] = field(default_factory=lambda: [
|
||||
"docs_concept",
|
||||
"docs_feature",
|
||||
"docs_cli",
|
||||
"docs_sdk",
|
||||
"example_basic",
|
||||
"example_advanced",
|
||||
])
|
||||
|
||||
def __post_init__(self):
|
||||
"""Auto-detect paths if not specified."""
|
||||
# Convert string paths to Path objects
|
||||
if isinstance(self.project_root, str):
|
||||
self.project_root = Path(self.project_root)
|
||||
if isinstance(self.docs_root, str):
|
||||
self.docs_root = Path(self.docs_root)
|
||||
if isinstance(self.examples_root, str):
|
||||
self.examples_root = Path(self.examples_root)
|
||||
if isinstance(self.sdk_root, str):
|
||||
self.sdk_root = Path(self.sdk_root)
|
||||
if isinstance(self.cli_root, str):
|
||||
self.cli_root = Path(self.cli_root)
|
||||
if isinstance(self.mintlify_config, str):
|
||||
self.mintlify_config = Path(self.mintlify_config)
|
||||
if isinstance(self.manifest_dir, str):
|
||||
self.manifest_dir = Path(self.manifest_dir)
|
||||
|
||||
if self.project_root is None:
|
||||
self.project_root = self._detect_project_root()
|
||||
|
||||
if self.project_root:
|
||||
self._auto_detect_paths()
|
||||
|
||||
def _detect_project_root(self) -> Optional[Path]:
|
||||
"""Detect project root by looking for common markers."""
|
||||
cwd = Path.cwd()
|
||||
|
||||
# Look for .praison directory or pyproject.toml
|
||||
for parent in [cwd] + list(cwd.parents):
|
||||
if (parent / ".praison").exists():
|
||||
return parent
|
||||
if (parent / "pyproject.toml").exists():
|
||||
return parent
|
||||
if (parent / "mint.json").exists():
|
||||
return parent
|
||||
|
||||
return cwd
|
||||
|
||||
def _auto_detect_paths(self):
|
||||
"""Auto-detect paths based on project root."""
|
||||
if not self.project_root:
|
||||
return
|
||||
|
||||
root = self.project_root
|
||||
|
||||
# Docs root detection - default: ~/PraisonAIDocs/docs
|
||||
if self.docs_root is None:
|
||||
# First check home directory for PraisonAIDocs
|
||||
home_docs = Path.home() / "PraisonAIDocs" / "docs"
|
||||
if home_docs.exists():
|
||||
self.docs_root = home_docs
|
||||
else:
|
||||
# Fallback to project-relative paths
|
||||
for candidate in ["docs", "PraisonAIDocs/docs"]:
|
||||
path = root / candidate
|
||||
if path.exists():
|
||||
self.docs_root = path
|
||||
break
|
||||
# Check parent for PraisonAIDocs
|
||||
parent_docs = root.parent / "PraisonAIDocs"
|
||||
if parent_docs.exists() and self.docs_root is None:
|
||||
self.docs_root = parent_docs / "docs"
|
||||
|
||||
# Examples root detection - default: ~/praisonai-package/examples
|
||||
if self.examples_root is None:
|
||||
# First check home directory for praisonai-package/examples
|
||||
home_examples = Path.home() / "praisonai-package" / "examples"
|
||||
if home_examples.exists():
|
||||
self.examples_root = home_examples
|
||||
else:
|
||||
# Fallback to project-relative paths
|
||||
for candidate in ["examples/python", "examples"]:
|
||||
path = root / candidate
|
||||
if path.exists():
|
||||
self.examples_root = path
|
||||
break
|
||||
|
||||
# SDK root detection
|
||||
if self.sdk_root is None:
|
||||
for candidate in [
|
||||
"src/praisonai-agents/praisonaiagents",
|
||||
"praisonaiagents",
|
||||
]:
|
||||
path = root / candidate
|
||||
if path.exists():
|
||||
self.sdk_root = path
|
||||
break
|
||||
|
||||
# CLI root detection
|
||||
if self.cli_root is None:
|
||||
for candidate in [
|
||||
"src/praisonai/praisonai/cli",
|
||||
"praisonai/cli",
|
||||
]:
|
||||
path = root / candidate
|
||||
if path.exists():
|
||||
self.cli_root = path
|
||||
break
|
||||
|
||||
# Mintlify config detection
|
||||
if self.mintlify_config is None:
|
||||
# Check docs root parent for mint.json
|
||||
if self.docs_root:
|
||||
mint_path = self.docs_root.parent / "mint.json"
|
||||
if mint_path.exists():
|
||||
self.mintlify_config = mint_path
|
||||
# Check project root
|
||||
if self.mintlify_config is None:
|
||||
for name in ["mint.json", "mintlify.json", "docs.json"]:
|
||||
path = root / name
|
||||
if path.exists():
|
||||
self.mintlify_config = path
|
||||
break
|
||||
|
||||
# Manifest directory
|
||||
if self.manifest_dir is None:
|
||||
self.manifest_dir = root / ".praison" / "features"
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, path: Path) -> "StandardiseConfig":
|
||||
"""Load configuration from a YAML file."""
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
# Fallback to basic parsing if PyYAML not available
|
||||
return cls()
|
||||
|
||||
if not path.exists():
|
||||
return cls()
|
||||
|
||||
with open(path, "r") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
|
||||
return cls(
|
||||
project_root=Path(data["project_root"]) if data.get("project_root") else None,
|
||||
docs_root=Path(data["docs_root"]) if data.get("docs_root") else None,
|
||||
examples_root=Path(data["examples_root"]) if data.get("examples_root") else None,
|
||||
sdk_root=Path(data["sdk_root"]) if data.get("sdk_root") else None,
|
||||
cli_root=Path(data["cli_root"]) if data.get("cli_root") else None,
|
||||
mintlify_config=Path(data["mintlify_config"]) if data.get("mintlify_config") else None,
|
||||
manifest_dir=Path(data["manifest_dir"]) if data.get("manifest_dir") else None,
|
||||
dry_run=data.get("dry_run", True),
|
||||
backup=data.get("backup", True),
|
||||
force=data.get("force", False),
|
||||
scope=data.get("scope", "all"),
|
||||
feature_filter=data.get("feature_filter"),
|
||||
dedupe_mode=data.get("dedupe_mode", "prompt"),
|
||||
similarity_threshold=data.get("similarity_threshold", 0.8),
|
||||
report_format=data.get("report_format", "text"),
|
||||
ci_mode=data.get("ci_mode", False),
|
||||
excluded_paths=data.get("excluded_paths", cls.__dataclass_fields__["excluded_paths"].default_factory()),
|
||||
required_artifacts=data.get("required_artifacts", cls.__dataclass_fields__["required_artifacts"].default_factory()),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "StandardiseConfig":
|
||||
"""Load configuration from environment variables."""
|
||||
return cls(
|
||||
project_root=Path(os.environ["PRAISON_PROJECT_ROOT"]) if os.environ.get("PRAISON_PROJECT_ROOT") else None,
|
||||
docs_root=Path(os.environ["PRAISON_DOCS_ROOT"]) if os.environ.get("PRAISON_DOCS_ROOT") else None,
|
||||
dry_run=os.environ.get("PRAISON_DRY_RUN", "true").lower() == "true",
|
||||
ci_mode=os.environ.get("CI", "false").lower() == "true",
|
||||
)
|
||||
|
||||
def to_dict(self) -> Dict:
|
||||
"""Convert to dictionary for serialisation."""
|
||||
return {
|
||||
"project_root": str(self.project_root) if self.project_root else None,
|
||||
"docs_root": str(self.docs_root) if self.docs_root else None,
|
||||
"examples_root": str(self.examples_root) if self.examples_root else None,
|
||||
"sdk_root": str(self.sdk_root) if self.sdk_root else None,
|
||||
"cli_root": str(self.cli_root) if self.cli_root else None,
|
||||
"mintlify_config": str(self.mintlify_config) if self.mintlify_config else None,
|
||||
"manifest_dir": str(self.manifest_dir) if self.manifest_dir else None,
|
||||
"dry_run": self.dry_run,
|
||||
"backup": self.backup,
|
||||
"force": self.force,
|
||||
"scope": self.scope,
|
||||
"feature_filter": self.feature_filter,
|
||||
"dedupe_mode": self.dedupe_mode,
|
||||
"similarity_threshold": self.similarity_threshold,
|
||||
"report_format": self.report_format,
|
||||
"ci_mode": self.ci_mode,
|
||||
"excluded_paths": self.excluded_paths,
|
||||
"required_artifacts": self.required_artifacts,
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
"""
|
||||
Duplicate detection for the FDEP standardisation system.
|
||||
|
||||
Detects duplicates via:
|
||||
- Same slug in multiple locations
|
||||
- High title similarity (>80%)
|
||||
- High code-block overlap (>40%)
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from .config import StandardiseConfig
|
||||
from .discovery import FeatureDiscovery
|
||||
from .models import DuplicateCluster, FeatureSlug
|
||||
|
||||
|
||||
class DedupeDetector:
|
||||
"""Detects duplicate and near-duplicate content."""
|
||||
|
||||
def __init__(self, config: StandardiseConfig, discovery: FeatureDiscovery):
|
||||
self.config = config
|
||||
self.discovery = discovery
|
||||
self._frontmatter_cache: Dict[Path, Dict] = {}
|
||||
|
||||
def detect_all(self) -> List[DuplicateCluster]:
|
||||
"""Detect all duplicate clusters."""
|
||||
clusters = []
|
||||
|
||||
# Detect same-slug duplicates
|
||||
clusters.extend(self._detect_same_slug_duplicates())
|
||||
|
||||
# Detect title similarity duplicates
|
||||
clusters.extend(self._detect_title_duplicates())
|
||||
|
||||
return clusters
|
||||
|
||||
def _detect_same_slug_duplicates(self) -> List[DuplicateCluster]:
|
||||
"""Detect pages with the same slug in different locations."""
|
||||
clusters = []
|
||||
|
||||
all_features = self.discovery.get_all_features()
|
||||
|
||||
for slug in all_features:
|
||||
pages = self.discovery.find_docs_pages(slug)
|
||||
|
||||
if len(pages) > 1:
|
||||
# Determine primary page (prefer features > concepts > cli > sdk)
|
||||
primary = self._select_primary_page(pages)
|
||||
|
||||
clusters.append(DuplicateCluster(
|
||||
slug=slug,
|
||||
pages=pages,
|
||||
similarity_score=1.0, # Exact slug match
|
||||
issue_type="same_slug",
|
||||
recommendation="merge" if len(pages) <= 4 else "review",
|
||||
primary_page=primary,
|
||||
))
|
||||
|
||||
return clusters
|
||||
|
||||
def _detect_title_duplicates(self) -> List[DuplicateCluster]:
|
||||
"""Detect pages with similar titles."""
|
||||
clusters = []
|
||||
|
||||
if not self.config.docs_root or not self.config.docs_root.exists():
|
||||
return clusters
|
||||
|
||||
# Collect all page titles
|
||||
title_to_pages: Dict[str, List[Tuple[Path, str]]] = {}
|
||||
|
||||
for mdx_file in self.config.docs_root.rglob("*.mdx"):
|
||||
# Skip node_modules and other excluded paths
|
||||
if any(excluded in str(mdx_file) for excluded in self.config.excluded_paths):
|
||||
continue
|
||||
|
||||
title = self._extract_title(mdx_file)
|
||||
if title:
|
||||
normalised_title = self._normalise_title(title)
|
||||
if normalised_title not in title_to_pages:
|
||||
title_to_pages[normalised_title] = []
|
||||
title_to_pages[normalised_title].append((mdx_file, title))
|
||||
|
||||
# Find duplicates
|
||||
for normalised_title, pages in title_to_pages.items():
|
||||
if len(pages) > 1:
|
||||
# Check if these are already captured by same-slug detection
|
||||
slugs = set()
|
||||
for page, _ in pages:
|
||||
slug = FeatureSlug.from_path(page, "docs")
|
||||
slugs.add(slug.normalised)
|
||||
|
||||
if len(slugs) > 1:
|
||||
# Different slugs but same title - this is a title duplicate
|
||||
slug = FeatureSlug.from_string(normalised_title)
|
||||
clusters.append(DuplicateCluster(
|
||||
slug=slug,
|
||||
pages=[p for p, _ in pages],
|
||||
similarity_score=0.9,
|
||||
issue_type="title_similarity",
|
||||
recommendation="review",
|
||||
primary_page=pages[0][0],
|
||||
))
|
||||
|
||||
return clusters
|
||||
|
||||
def _extract_title(self, mdx_file: Path) -> Optional[str]:
|
||||
"""Extract title from MDX frontmatter."""
|
||||
if mdx_file in self._frontmatter_cache:
|
||||
return self._frontmatter_cache[mdx_file].get("title")
|
||||
|
||||
try:
|
||||
content = mdx_file.read_text(encoding="utf-8")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
frontmatter = self._parse_frontmatter(content)
|
||||
self._frontmatter_cache[mdx_file] = frontmatter
|
||||
|
||||
return frontmatter.get("title")
|
||||
|
||||
def _parse_frontmatter(self, content: str) -> Dict:
|
||||
"""Parse YAML frontmatter from MDX content."""
|
||||
frontmatter = {}
|
||||
|
||||
# Check for frontmatter delimiters
|
||||
if not content.startswith("---"):
|
||||
return frontmatter
|
||||
|
||||
# Find end of frontmatter
|
||||
end_match = re.search(r"\n---\s*\n", content[3:])
|
||||
if not end_match:
|
||||
return frontmatter
|
||||
|
||||
frontmatter_text = content[3:end_match.start() + 3]
|
||||
|
||||
# Simple YAML parsing (avoid dependency on PyYAML)
|
||||
for line in frontmatter_text.split("\n"):
|
||||
line = line.strip()
|
||||
if ":" in line:
|
||||
key, value = line.split(":", 1)
|
||||
key = key.strip()
|
||||
value = value.strip().strip('"').strip("'")
|
||||
frontmatter[key] = value
|
||||
|
||||
return frontmatter
|
||||
|
||||
def _normalise_title(self, title: str) -> str:
|
||||
"""Normalise a title for comparison."""
|
||||
# Lowercase, remove special chars, collapse whitespace
|
||||
normalised = title.lower()
|
||||
normalised = re.sub(r"[^a-z0-9\s]", "", normalised)
|
||||
normalised = re.sub(r"\s+", " ", normalised).strip()
|
||||
return normalised
|
||||
|
||||
def _select_primary_page(self, pages: List[Path]) -> Optional[Path]:
|
||||
"""Select the primary page from a list of duplicates."""
|
||||
# Priority: features > concepts > cli > sdk
|
||||
priority_order = ["features", "concepts", "cli", "sdk"]
|
||||
|
||||
for priority_dir in priority_order:
|
||||
for page in pages:
|
||||
if f"/{priority_dir}/" in str(page) or f"\\{priority_dir}\\" in str(page):
|
||||
return page
|
||||
|
||||
# Default to first page
|
||||
return pages[0] if pages else None
|
||||
|
||||
def calculate_similarity(self, text1: str, text2: str) -> float:
|
||||
"""Calculate similarity between two texts using Levenshtein ratio."""
|
||||
if not text1 or not text2:
|
||||
return 0.0
|
||||
|
||||
# Simple character-level similarity
|
||||
len1, len2 = len(text1), len(text2)
|
||||
if len1 == 0 and len2 == 0:
|
||||
return 1.0
|
||||
if len1 == 0 or len2 == 0:
|
||||
return 0.0
|
||||
|
||||
# Use set-based similarity for efficiency
|
||||
set1 = set(text1.lower().split())
|
||||
set2 = set(text2.lower().split())
|
||||
|
||||
intersection = len(set1 & set2)
|
||||
union = len(set1 | set2)
|
||||
|
||||
if union == 0:
|
||||
return 0.0
|
||||
|
||||
return intersection / union
|
||||
|
||||
def get_naming_inconsistencies(self) -> List[Tuple[Path, Path, str]]:
|
||||
"""Find naming inconsistencies (e.g., handoff.mdx vs handoffs.mdx)."""
|
||||
inconsistencies = []
|
||||
|
||||
if not self.config.docs_root or not self.config.docs_root.exists():
|
||||
return inconsistencies
|
||||
|
||||
# Check each docs directory
|
||||
for doc_dir in ["concepts", "features", "cli"]:
|
||||
dir_path = self.config.docs_root / doc_dir
|
||||
if not dir_path.exists():
|
||||
continue
|
||||
|
||||
# Get all MDX files
|
||||
mdx_files = list(dir_path.glob("*.mdx"))
|
||||
file_stems = {f.stem.lower(): f for f in mdx_files}
|
||||
|
||||
# Check for singular/plural pairs
|
||||
from .models import SINGULAR_PLURAL_MAP
|
||||
for singular, plural in SINGULAR_PLURAL_MAP.items():
|
||||
if singular in file_stems and plural in file_stems:
|
||||
inconsistencies.append((
|
||||
file_stems[singular],
|
||||
file_stems[plural],
|
||||
f"Both {singular}.mdx and {plural}.mdx exist in {doc_dir}/",
|
||||
))
|
||||
|
||||
return inconsistencies
|
||||
@@ -0,0 +1,319 @@
|
||||
"""
|
||||
Feature discovery for the FDEP standardisation system.
|
||||
|
||||
Discovers features from:
|
||||
- SDK modules (praisonaiagents/*)
|
||||
- CLI features (praisonai/cli/features/*)
|
||||
- Docs pages (docs/**)
|
||||
- Examples (examples/python/**)
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Set
|
||||
|
||||
from .config import StandardiseConfig
|
||||
from .models import FeatureSlug
|
||||
|
||||
|
||||
class FeatureDiscovery:
|
||||
"""Discovers features from various sources in the codebase."""
|
||||
|
||||
# Directories to skip during discovery
|
||||
SKIP_DIRS = {
|
||||
"__pycache__", ".git", ".venv", "venv", "node_modules",
|
||||
"dist", "build", ".pytest_cache", ".mypy_cache",
|
||||
}
|
||||
|
||||
# Files to skip
|
||||
SKIP_FILES = {"__init__.py", "base.py", "utils.py", "helpers.py"}
|
||||
|
||||
def __init__(self, config: StandardiseConfig):
|
||||
self.config = config
|
||||
self._sdk_features: Optional[Set[FeatureSlug]] = None
|
||||
self._cli_features: Optional[Set[FeatureSlug]] = None
|
||||
self._docs_features: Optional[Set[FeatureSlug]] = None
|
||||
self._example_features: Optional[Set[FeatureSlug]] = None
|
||||
|
||||
def discover_all(self) -> Dict[str, Set[FeatureSlug]]:
|
||||
"""Discover features from all sources."""
|
||||
return {
|
||||
"sdk": self.discover_sdk_features(),
|
||||
"cli": self.discover_cli_features(),
|
||||
"docs": self.discover_docs_features(),
|
||||
"examples": self.discover_example_features(),
|
||||
}
|
||||
|
||||
def get_all_features(self) -> Set[FeatureSlug]:
|
||||
"""Get union of all discovered features."""
|
||||
all_sources = self.discover_all()
|
||||
result: Set[FeatureSlug] = set()
|
||||
for features in all_sources.values():
|
||||
result.update(features)
|
||||
return result
|
||||
|
||||
def discover_sdk_features(self) -> Set[FeatureSlug]:
|
||||
"""Discover features from SDK modules."""
|
||||
if self._sdk_features is not None:
|
||||
return self._sdk_features
|
||||
|
||||
self._sdk_features = set()
|
||||
|
||||
if not self.config.sdk_root or not self.config.sdk_root.exists():
|
||||
return self._sdk_features
|
||||
|
||||
# Each top-level directory in SDK is a feature module
|
||||
for item in self.config.sdk_root.iterdir():
|
||||
if not item.is_dir():
|
||||
continue
|
||||
if item.name in self.SKIP_DIRS:
|
||||
continue
|
||||
if item.name.startswith("."):
|
||||
continue
|
||||
|
||||
slug = FeatureSlug.from_path(item, "sdk")
|
||||
if slug.is_valid:
|
||||
self._sdk_features.add(slug)
|
||||
|
||||
return self._sdk_features
|
||||
|
||||
def discover_cli_features(self) -> Set[FeatureSlug]:
|
||||
"""Discover features from CLI feature files."""
|
||||
if self._cli_features is not None:
|
||||
return self._cli_features
|
||||
|
||||
self._cli_features = set()
|
||||
|
||||
if not self.config.cli_root or not self.config.cli_root.exists():
|
||||
return self._cli_features
|
||||
|
||||
# Check features directory
|
||||
features_dir = self.config.cli_root / "features"
|
||||
if features_dir.exists():
|
||||
for item in features_dir.iterdir():
|
||||
if not item.is_file():
|
||||
continue
|
||||
if item.suffix != ".py":
|
||||
continue
|
||||
if item.name in self.SKIP_FILES:
|
||||
continue
|
||||
|
||||
slug = FeatureSlug.from_path(item, "cli")
|
||||
if slug.is_valid:
|
||||
self._cli_features.add(slug)
|
||||
|
||||
# Check commands directory
|
||||
commands_dir = self.config.cli_root / "commands"
|
||||
if commands_dir.exists():
|
||||
for item in commands_dir.iterdir():
|
||||
if not item.is_file():
|
||||
continue
|
||||
if item.suffix != ".py":
|
||||
continue
|
||||
if item.name in self.SKIP_FILES:
|
||||
continue
|
||||
|
||||
slug = FeatureSlug.from_path(item, "cli")
|
||||
if slug.is_valid:
|
||||
self._cli_features.add(slug)
|
||||
|
||||
return self._cli_features
|
||||
|
||||
def discover_docs_features(self) -> Set[FeatureSlug]:
|
||||
"""Discover features from docs pages."""
|
||||
if self._docs_features is not None:
|
||||
return self._docs_features
|
||||
|
||||
self._docs_features = set()
|
||||
|
||||
if not self.config.docs_root or not self.config.docs_root.exists():
|
||||
return self._docs_features
|
||||
|
||||
# Scan key docs directories
|
||||
docs_dirs = ["concepts", "features", "cli", "sdk"]
|
||||
|
||||
for dir_name in docs_dirs:
|
||||
dir_path = self.config.docs_root / dir_name
|
||||
if not dir_path.exists():
|
||||
continue
|
||||
|
||||
self._scan_docs_directory(dir_path)
|
||||
|
||||
return self._docs_features
|
||||
|
||||
def _scan_docs_directory(self, directory: Path, depth: int = 0):
|
||||
"""Recursively scan a docs directory for feature slugs."""
|
||||
if depth > 3: # Limit recursion depth
|
||||
return
|
||||
|
||||
for item in directory.iterdir():
|
||||
if item.name in self.SKIP_DIRS:
|
||||
continue
|
||||
if item.name.startswith("."):
|
||||
continue
|
||||
|
||||
if item.is_file() and item.suffix == ".mdx":
|
||||
slug = FeatureSlug.from_path(item, "docs")
|
||||
if slug.is_valid:
|
||||
self._docs_features.add(slug)
|
||||
elif item.is_dir():
|
||||
# Directory name itself might be a feature
|
||||
slug = FeatureSlug.from_path(item, "docs")
|
||||
if slug.is_valid:
|
||||
self._docs_features.add(slug)
|
||||
# Recurse into subdirectory
|
||||
self._scan_docs_directory(item, depth + 1)
|
||||
|
||||
def discover_example_features(self) -> Set[FeatureSlug]:
|
||||
"""Discover features from examples."""
|
||||
if self._example_features is not None:
|
||||
return self._example_features
|
||||
|
||||
self._example_features = set()
|
||||
|
||||
if not self.config.examples_root or not self.config.examples_root.exists():
|
||||
return self._example_features
|
||||
|
||||
# Each top-level directory in examples is a feature
|
||||
for item in self.config.examples_root.iterdir():
|
||||
if not item.is_dir():
|
||||
continue
|
||||
if item.name in self.SKIP_DIRS:
|
||||
continue
|
||||
if item.name.startswith("."):
|
||||
continue
|
||||
|
||||
slug = FeatureSlug.from_path(item, "examples")
|
||||
if slug.is_valid:
|
||||
self._example_features.add(slug)
|
||||
|
||||
return self._example_features
|
||||
|
||||
def get_feature_sources(self, slug: FeatureSlug) -> Dict[str, bool]:
|
||||
"""Get which sources contain a given feature."""
|
||||
return {
|
||||
"sdk": slug in self.discover_sdk_features(),
|
||||
"cli": slug in self.discover_cli_features(),
|
||||
"docs": slug in self.discover_docs_features(),
|
||||
"examples": slug in self.discover_example_features(),
|
||||
}
|
||||
|
||||
def find_docs_pages(self, slug: FeatureSlug) -> List[Path]:
|
||||
"""Find all docs pages for a given feature slug."""
|
||||
pages = []
|
||||
|
||||
if not self.config.docs_root or not self.config.docs_root.exists():
|
||||
return pages
|
||||
|
||||
# Search in key directories
|
||||
search_dirs = [
|
||||
self.config.docs_root / "concepts",
|
||||
self.config.docs_root / "features",
|
||||
self.config.docs_root / "cli",
|
||||
]
|
||||
|
||||
# Also search SDK docs
|
||||
sdk_docs = self.config.docs_root / "sdk" / "praisonaiagents"
|
||||
if sdk_docs.exists():
|
||||
search_dirs.append(sdk_docs)
|
||||
|
||||
slug_str = slug.normalised
|
||||
slug_variants = {slug_str, slug_str.replace("-", "_")}
|
||||
|
||||
# Add singular/plural variants
|
||||
from .models import SINGULAR_PLURAL_MAP
|
||||
for singular, plural in SINGULAR_PLURAL_MAP.items():
|
||||
if slug_str == plural:
|
||||
slug_variants.add(singular)
|
||||
elif slug_str == singular:
|
||||
slug_variants.add(plural)
|
||||
|
||||
for search_dir in search_dirs:
|
||||
if not search_dir.exists():
|
||||
continue
|
||||
|
||||
self._find_pages_recursive(search_dir, slug_variants, pages)
|
||||
|
||||
return pages
|
||||
|
||||
def _find_pages_recursive(self, directory: Path, slug_variants: Set[str],
|
||||
pages: List[Path], depth: int = 0):
|
||||
"""Recursively find pages matching slug variants."""
|
||||
if depth > 4:
|
||||
return
|
||||
|
||||
for item in directory.iterdir():
|
||||
if item.name in self.SKIP_DIRS:
|
||||
continue
|
||||
|
||||
if item.is_file() and item.suffix == ".mdx":
|
||||
stem = item.stem.lower().replace("_", "-")
|
||||
if stem in slug_variants:
|
||||
pages.append(item)
|
||||
elif item.is_dir():
|
||||
dir_name = item.name.lower().replace("_", "-")
|
||||
if dir_name in slug_variants:
|
||||
# Check for index or main file in this directory
|
||||
for mdx_file in item.glob("*.mdx"):
|
||||
pages.append(mdx_file)
|
||||
else:
|
||||
self._find_pages_recursive(item, slug_variants, pages, depth + 1)
|
||||
|
||||
def find_examples(self, slug: FeatureSlug) -> List[Path]:
|
||||
"""Find all example files for a given feature slug."""
|
||||
examples = []
|
||||
|
||||
if not self.config.examples_root or not self.config.examples_root.exists():
|
||||
return examples
|
||||
|
||||
slug_str = slug.normalised
|
||||
slug_variants = {slug_str, slug_str.replace("-", "_")}
|
||||
|
||||
# Check for feature directory
|
||||
for variant in slug_variants:
|
||||
feature_dir = self.config.examples_root / variant
|
||||
if feature_dir.exists() and feature_dir.is_dir():
|
||||
for py_file in feature_dir.glob("*.py"):
|
||||
examples.append(py_file)
|
||||
|
||||
return examples
|
||||
|
||||
def get_sdk_module_path(self, slug: FeatureSlug) -> Optional[Path]:
|
||||
"""Get the SDK module path for a feature."""
|
||||
if not self.config.sdk_root or not self.config.sdk_root.exists():
|
||||
return None
|
||||
|
||||
slug_str = slug.normalised
|
||||
variants = [slug_str, slug_str.replace("-", "_")]
|
||||
|
||||
for variant in variants:
|
||||
module_path = self.config.sdk_root / variant
|
||||
if module_path.exists() and module_path.is_dir():
|
||||
return module_path
|
||||
|
||||
return None
|
||||
|
||||
def get_cli_feature_path(self, slug: FeatureSlug) -> Optional[Path]:
|
||||
"""Get the CLI feature file path for a feature."""
|
||||
if not self.config.cli_root or not self.config.cli_root.exists():
|
||||
return None
|
||||
|
||||
slug_str = slug.normalised
|
||||
variants = [slug_str, slug_str.replace("-", "_")]
|
||||
|
||||
# Check features directory
|
||||
features_dir = self.config.cli_root / "features"
|
||||
if features_dir.exists():
|
||||
for variant in variants:
|
||||
feature_path = features_dir / f"{variant}.py"
|
||||
if feature_path.exists():
|
||||
return feature_path
|
||||
|
||||
# Check commands directory
|
||||
commands_dir = self.config.cli_root / "commands"
|
||||
if commands_dir.exists():
|
||||
for variant in variants:
|
||||
command_path = commands_dir / f"{variant}.py"
|
||||
if command_path.exists():
|
||||
return command_path
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,263 @@
|
||||
"""
|
||||
Drift detection for the FDEP standardisation system.
|
||||
|
||||
Detects drift between:
|
||||
- SDK exports vs SDK docs
|
||||
- CLI help/flags vs CLI docs
|
||||
"""
|
||||
|
||||
import ast
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Set
|
||||
|
||||
from .config import StandardiseConfig
|
||||
from .discovery import FeatureDiscovery
|
||||
from .models import DriftItem, DriftReport, FeatureSlug
|
||||
|
||||
|
||||
class DriftDetector:
|
||||
"""Detects drift between code and documentation."""
|
||||
|
||||
def __init__(self, config: StandardiseConfig, discovery: FeatureDiscovery):
|
||||
self.config = config
|
||||
self.discovery = discovery
|
||||
|
||||
def detect_all(self) -> List[DriftReport]:
|
||||
"""Detect drift for all features."""
|
||||
reports = []
|
||||
|
||||
all_features = self.discovery.get_all_features()
|
||||
|
||||
for slug in all_features:
|
||||
report = self.detect_feature_drift(slug)
|
||||
if report.has_drift:
|
||||
reports.append(report)
|
||||
|
||||
return reports
|
||||
|
||||
def detect_feature_drift(self, slug: FeatureSlug) -> DriftReport:
|
||||
"""Detect drift for a single feature."""
|
||||
sdk_drift = self._detect_sdk_drift(slug)
|
||||
cli_drift = self._detect_cli_drift(slug)
|
||||
|
||||
return DriftReport(
|
||||
slug=slug,
|
||||
sdk_drift=sdk_drift,
|
||||
cli_drift=cli_drift,
|
||||
)
|
||||
|
||||
def _detect_sdk_drift(self, slug: FeatureSlug) -> List[DriftItem]:
|
||||
"""Detect drift between SDK module and SDK docs."""
|
||||
drift_items = []
|
||||
|
||||
# Get SDK module path
|
||||
sdk_module = self.discovery.get_sdk_module_path(slug)
|
||||
if not sdk_module or not sdk_module.exists():
|
||||
return drift_items
|
||||
|
||||
# Extract public exports from SDK module
|
||||
sdk_exports = self._extract_sdk_exports(sdk_module)
|
||||
|
||||
# Extract documented items from SDK docs
|
||||
sdk_docs = self._get_sdk_docs_path(slug)
|
||||
if sdk_docs:
|
||||
documented_items = self._extract_documented_items(sdk_docs)
|
||||
else:
|
||||
documented_items = set()
|
||||
|
||||
# Find items in SDK but not in docs
|
||||
for export in sdk_exports:
|
||||
if export not in documented_items:
|
||||
drift_items.append(DriftItem(
|
||||
name=export,
|
||||
source="sdk",
|
||||
missing_in="docs",
|
||||
item_type="class" if export[0].isupper() else "function",
|
||||
))
|
||||
|
||||
# Find items in docs but not in SDK
|
||||
for item in documented_items:
|
||||
if item not in sdk_exports:
|
||||
drift_items.append(DriftItem(
|
||||
name=item,
|
||||
source="docs",
|
||||
missing_in="sdk",
|
||||
item_type="class" if item[0].isupper() else "function",
|
||||
))
|
||||
|
||||
return drift_items
|
||||
|
||||
def _detect_cli_drift(self, slug: FeatureSlug) -> List[DriftItem]:
|
||||
"""Detect drift between CLI feature and CLI docs."""
|
||||
drift_items = []
|
||||
|
||||
# Get CLI feature path
|
||||
cli_feature = self.discovery.get_cli_feature_path(slug)
|
||||
if not cli_feature or not cli_feature.exists():
|
||||
return drift_items
|
||||
|
||||
# Extract CLI flags/commands from code
|
||||
cli_flags = self._extract_cli_flags(cli_feature)
|
||||
|
||||
# Extract documented flags from CLI docs
|
||||
cli_docs = self._get_cli_docs_path(slug)
|
||||
if cli_docs:
|
||||
documented_flags = self._extract_documented_flags(cli_docs)
|
||||
else:
|
||||
documented_flags = set()
|
||||
|
||||
# Find flags in code but not in docs
|
||||
for flag in cli_flags:
|
||||
if flag not in documented_flags:
|
||||
drift_items.append(DriftItem(
|
||||
name=flag,
|
||||
source="cli",
|
||||
missing_in="docs",
|
||||
item_type="flag",
|
||||
))
|
||||
|
||||
return drift_items
|
||||
|
||||
def _extract_sdk_exports(self, module_path: Path) -> Set[str]:
|
||||
"""Extract public exports from an SDK module."""
|
||||
exports = set()
|
||||
|
||||
# Check __init__.py for __all__
|
||||
init_file = module_path / "__init__.py"
|
||||
if init_file.exists():
|
||||
try:
|
||||
content = init_file.read_text(encoding="utf-8")
|
||||
# Look for __all__ = [...]
|
||||
all_match = re.search(r"__all__\s*=\s*\[(.*?)\]", content, re.DOTALL)
|
||||
if all_match:
|
||||
items = re.findall(r'["\'](\w+)["\']', all_match.group(1))
|
||||
exports.update(items)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Also scan for public classes and functions
|
||||
for py_file in module_path.glob("*.py"):
|
||||
if py_file.name.startswith("_"):
|
||||
continue
|
||||
|
||||
try:
|
||||
content = py_file.read_text(encoding="utf-8")
|
||||
tree = ast.parse(content)
|
||||
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.ClassDef):
|
||||
if not node.name.startswith("_"):
|
||||
exports.add(node.name)
|
||||
elif isinstance(node, ast.FunctionDef):
|
||||
if not node.name.startswith("_"):
|
||||
exports.add(node.name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return exports
|
||||
|
||||
def _extract_cli_flags(self, cli_file: Path) -> Set[str]:
|
||||
"""Extract CLI flags from a CLI feature file."""
|
||||
flags = set()
|
||||
|
||||
try:
|
||||
content = cli_file.read_text(encoding="utf-8")
|
||||
|
||||
# Look for click.option or argparse add_argument patterns
|
||||
# click: @click.option("--flag-name", ...)
|
||||
click_flags = re.findall(r'@click\.option\(["\'](-{1,2}[\w-]+)["\']', content)
|
||||
flags.update(click_flags)
|
||||
|
||||
# argparse: parser.add_argument("--flag-name", ...)
|
||||
argparse_flags = re.findall(r'add_argument\(["\'](-{1,2}[\w-]+)["\']', content)
|
||||
flags.update(argparse_flags)
|
||||
|
||||
# Also look for flag definitions in strings
|
||||
string_flags = re.findall(r'["\'](-{2}[\w-]+)["\']', content)
|
||||
flags.update(string_flags)
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return flags
|
||||
|
||||
def _extract_documented_items(self, docs_path: Path) -> Set[str]:
|
||||
"""Extract documented class/function names from docs."""
|
||||
items = set()
|
||||
|
||||
try:
|
||||
content = docs_path.read_text(encoding="utf-8")
|
||||
|
||||
# Look for class/function references in code blocks
|
||||
# Pattern: `ClassName` or `function_name`
|
||||
backtick_items = re.findall(r'`(\w+)`', content)
|
||||
items.update(backtick_items)
|
||||
|
||||
# Look for headings that might be class/function names
|
||||
heading_items = re.findall(r'^#{1,3}\s+`?(\w+)`?', content, re.MULTILINE)
|
||||
items.update(heading_items)
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return items
|
||||
|
||||
def _extract_documented_flags(self, docs_path: Path) -> Set[str]:
|
||||
"""Extract documented CLI flags from docs."""
|
||||
flags = set()
|
||||
|
||||
try:
|
||||
content = docs_path.read_text(encoding="utf-8")
|
||||
|
||||
# Look for flags in code blocks and inline code
|
||||
flag_patterns = re.findall(r'`(-{1,2}[\w-]+)`', content)
|
||||
flags.update(flag_patterns)
|
||||
|
||||
# Also look for flags in plain text
|
||||
text_flags = re.findall(r'\s(-{2}[\w-]+)\s', content)
|
||||
flags.update(text_flags)
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return flags
|
||||
|
||||
def _get_sdk_docs_path(self, slug: FeatureSlug) -> Optional[Path]:
|
||||
"""Get the SDK docs path for a feature."""
|
||||
if not self.config.docs_root:
|
||||
return None
|
||||
|
||||
sdk_docs = self.config.docs_root / "sdk" / "praisonaiagents"
|
||||
if not sdk_docs.exists():
|
||||
return None
|
||||
|
||||
slug_str = slug.normalised
|
||||
variants = [slug_str, slug_str.replace("-", "_")]
|
||||
|
||||
for variant in variants:
|
||||
module_dir = sdk_docs / variant
|
||||
if module_dir.exists():
|
||||
for mdx_file in module_dir.glob("*.mdx"):
|
||||
return mdx_file
|
||||
|
||||
return None
|
||||
|
||||
def _get_cli_docs_path(self, slug: FeatureSlug) -> Optional[Path]:
|
||||
"""Get the CLI docs path for a feature."""
|
||||
if not self.config.docs_root:
|
||||
return None
|
||||
|
||||
cli_docs = self.config.docs_root / "cli"
|
||||
if not cli_docs.exists():
|
||||
return None
|
||||
|
||||
slug_str = slug.normalised
|
||||
variants = [slug_str, slug_str.replace("-", "_")]
|
||||
|
||||
for variant in variants:
|
||||
path = cli_docs / f"{variant}.mdx"
|
||||
if path.exists():
|
||||
return path
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,270 @@
|
||||
"""
|
||||
Main standardisation engine for the FDEP system.
|
||||
|
||||
Orchestrates all standardisation operations:
|
||||
- Discovery
|
||||
- Validation
|
||||
- Dedupe detection
|
||||
- Drift detection
|
||||
- Report generation
|
||||
- Fix application
|
||||
"""
|
||||
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from .config import StandardiseConfig
|
||||
from .dedupe import DedupeDetector
|
||||
from .discovery import FeatureDiscovery
|
||||
from .drift import DriftDetector
|
||||
from .models import (
|
||||
ArtifactType,
|
||||
FeatureSlug,
|
||||
IssueType,
|
||||
StandardiseIssue,
|
||||
StandardiseReport,
|
||||
)
|
||||
from .reports import ReportGenerator
|
||||
from .templates import TemplateGenerator
|
||||
from .validator import ArtifactValidator
|
||||
|
||||
|
||||
class StandardiseEngine:
|
||||
"""Main engine for standardisation operations."""
|
||||
|
||||
def __init__(self, config: Optional[StandardiseConfig] = None):
|
||||
self.config = config or StandardiseConfig()
|
||||
self.discovery = FeatureDiscovery(self.config)
|
||||
self.validator = ArtifactValidator(self.config, self.discovery)
|
||||
self.dedupe = DedupeDetector(self.config, self.discovery)
|
||||
self.drift = DriftDetector(self.config, self.discovery)
|
||||
self.templates = TemplateGenerator()
|
||||
self.reports = ReportGenerator()
|
||||
|
||||
def check(self) -> StandardiseReport:
|
||||
"""Run a full standardisation check and return a report."""
|
||||
timestamp = datetime.now().isoformat()
|
||||
|
||||
# Discover features
|
||||
all_features = self.discovery.get_all_features()
|
||||
|
||||
# Count docs and examples
|
||||
docs_count = self._count_docs()
|
||||
examples_count = self._count_examples()
|
||||
|
||||
# Validate artifacts
|
||||
validation_results = self.validator.validate_all()
|
||||
missing_artifacts = self.validator.get_missing_artifacts()
|
||||
|
||||
# Detect duplicates
|
||||
duplicates = self.dedupe.detect_all()
|
||||
|
||||
# Detect naming inconsistencies
|
||||
naming_issues = self.dedupe.get_naming_inconsistencies()
|
||||
|
||||
# Detect drift
|
||||
drift_reports = self.drift.detect_all()
|
||||
|
||||
# Compile issues
|
||||
issues = []
|
||||
|
||||
# Add naming inconsistency issues
|
||||
for path1, path2, message in naming_issues:
|
||||
issues.append(StandardiseIssue(
|
||||
issue_type=IssueType.NAMING_INCONSISTENCY,
|
||||
slug=None,
|
||||
path=path1,
|
||||
message=message,
|
||||
severity="warning",
|
||||
auto_fixable=True,
|
||||
fix_action=f"rename {path1} or {path2}",
|
||||
))
|
||||
|
||||
# Add drift issues
|
||||
for drift_report in drift_reports:
|
||||
for item in drift_report.sdk_drift:
|
||||
issues.append(StandardiseIssue(
|
||||
issue_type=IssueType.DRIFT_SDK_DOCS,
|
||||
slug=drift_report.slug,
|
||||
path=None,
|
||||
message=f"{item.name} in {item.source} but missing in {item.missing_in}",
|
||||
severity="info",
|
||||
auto_fixable=False,
|
||||
))
|
||||
for item in drift_report.cli_drift:
|
||||
issues.append(StandardiseIssue(
|
||||
issue_type=IssueType.DRIFT_CLI_DOCS,
|
||||
slug=drift_report.slug,
|
||||
path=None,
|
||||
message=f"{item.name} in {item.source} but missing in {item.missing_in}",
|
||||
severity="info",
|
||||
auto_fixable=False,
|
||||
))
|
||||
|
||||
return StandardiseReport(
|
||||
timestamp=timestamp,
|
||||
features_scanned=len(all_features),
|
||||
docs_pages=docs_count,
|
||||
examples_count=examples_count,
|
||||
issues=issues,
|
||||
duplicates=duplicates,
|
||||
missing_artifacts=missing_artifacts,
|
||||
drift_reports=drift_reports,
|
||||
validation_results=list(validation_results.values()),
|
||||
)
|
||||
|
||||
def report(self, format: str = "text") -> str:
|
||||
"""Generate a report in the specified format."""
|
||||
report = self.check()
|
||||
return self.reports.generate(report, format)
|
||||
|
||||
def fix(self, feature: Optional[str] = None,
|
||||
apply: bool = False) -> Dict[str, List[str]]:
|
||||
"""
|
||||
Fix standardisation issues.
|
||||
|
||||
Args:
|
||||
feature: Specific feature slug to fix (None = all)
|
||||
apply: Actually apply changes (False = dry-run)
|
||||
|
||||
Returns:
|
||||
Dict with 'planned' and 'applied' actions
|
||||
"""
|
||||
actions = {"planned": [], "applied": []}
|
||||
|
||||
# Get current state
|
||||
report = self.check()
|
||||
|
||||
# Plan fixes for missing artifacts
|
||||
for slug_str, missing in report.missing_artifacts.items():
|
||||
if feature and slug_str != feature:
|
||||
continue
|
||||
|
||||
slug = FeatureSlug.from_string(slug_str)
|
||||
|
||||
for artifact_type in missing:
|
||||
path = self.templates.get_expected_path(
|
||||
slug, artifact_type,
|
||||
docs_root=self.config.docs_root,
|
||||
examples_root=self.config.examples_root,
|
||||
)
|
||||
|
||||
if path:
|
||||
action = f"CREATE {path}"
|
||||
actions["planned"].append(action)
|
||||
|
||||
if apply and not self.config.dry_run:
|
||||
self._create_artifact(slug, artifact_type, path)
|
||||
actions["applied"].append(action)
|
||||
|
||||
# Plan fixes for naming inconsistencies
|
||||
naming_issues = self.dedupe.get_naming_inconsistencies()
|
||||
for path1, path2, message in naming_issues:
|
||||
if feature:
|
||||
slug1 = FeatureSlug.from_path(path1, "docs")
|
||||
slug2 = FeatureSlug.from_path(path2, "docs")
|
||||
if slug1.normalised != feature and slug2.normalised != feature:
|
||||
continue
|
||||
|
||||
# Prefer plural form
|
||||
if "singular" in str(path1).lower() or len(path1.stem) < len(path2.stem):
|
||||
action = f"RENAME {path1} -> deprecated, keep {path2}"
|
||||
else:
|
||||
action = f"RENAME {path2} -> deprecated, keep {path1}"
|
||||
actions["planned"].append(action)
|
||||
|
||||
return actions
|
||||
|
||||
def init(self, feature: str) -> Dict[str, str]:
|
||||
"""
|
||||
Initialise a new feature with all required artifacts.
|
||||
|
||||
Args:
|
||||
feature: Feature slug to initialise
|
||||
|
||||
Returns:
|
||||
Dict mapping artifact type to created path
|
||||
"""
|
||||
created = {}
|
||||
slug = FeatureSlug.from_string(feature)
|
||||
|
||||
if not slug.is_valid:
|
||||
raise ValueError(f"Invalid feature slug: {slug.validation_error}")
|
||||
|
||||
for artifact_type in ArtifactType:
|
||||
if artifact_type == ArtifactType.MANIFEST:
|
||||
continue
|
||||
|
||||
path = self.templates.get_expected_path(
|
||||
slug, artifact_type,
|
||||
docs_root=self.config.docs_root,
|
||||
examples_root=self.config.examples_root,
|
||||
)
|
||||
|
||||
if path and not path.exists():
|
||||
if not self.config.dry_run:
|
||||
self._create_artifact(slug, artifact_type, path)
|
||||
created[artifact_type.value] = str(path)
|
||||
|
||||
return created
|
||||
|
||||
def _create_artifact(self, slug: FeatureSlug, artifact_type: ArtifactType,
|
||||
path: Path) -> None:
|
||||
"""Create an artifact file."""
|
||||
# Backup if exists
|
||||
if path.exists() and self.config.backup:
|
||||
self._backup_file(path)
|
||||
|
||||
# Create parent directories
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Generate content
|
||||
content = self.templates.generate(slug, artifact_type)
|
||||
|
||||
# Write file
|
||||
path.write_text(content, encoding="utf-8")
|
||||
|
||||
def _backup_file(self, path: Path) -> Path:
|
||||
"""Create a backup of a file."""
|
||||
if not self.config.project_root:
|
||||
return path
|
||||
|
||||
backup_dir = self.config.project_root / ".praison" / "backups"
|
||||
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
backup_name = f"{path.stem}_{timestamp}{path.suffix}"
|
||||
backup_path = backup_dir / backup_name
|
||||
|
||||
shutil.copy2(path, backup_path)
|
||||
return backup_path
|
||||
|
||||
def _count_docs(self) -> int:
|
||||
"""Count total docs pages."""
|
||||
if not self.config.docs_root or not self.config.docs_root.exists():
|
||||
return 0
|
||||
|
||||
count = 0
|
||||
for mdx_file in self.config.docs_root.rglob("*.mdx"):
|
||||
if not any(excluded in str(mdx_file) for excluded in self.config.excluded_paths):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
def _count_examples(self) -> int:
|
||||
"""Count total example files."""
|
||||
if not self.config.examples_root or not self.config.examples_root.exists():
|
||||
return 0
|
||||
|
||||
count = 0
|
||||
for py_file in self.config.examples_root.rglob("*.py"):
|
||||
if not any(excluded in str(py_file) for excluded in self.config.excluded_paths):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
def get_exit_code(self, report: StandardiseReport) -> int:
|
||||
"""Get CI exit code based on report."""
|
||||
if report.has_issues:
|
||||
return 1 # Issues found
|
||||
return 0 # No issues
|
||||
@@ -0,0 +1,674 @@
|
||||
"""
|
||||
Enhanced templates for the FDEP standardisation system.
|
||||
|
||||
Based on analysis of existing PraisonAI documentation best practices:
|
||||
- Progressive disclosure (simple first, advanced later)
|
||||
- Mermaid diagrams for concepts
|
||||
- CodeGroup/Tabs for multiple examples
|
||||
- Consistent structure across page types
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from .models import ArtifactType, FeatureSlug
|
||||
|
||||
|
||||
# Template constants based on existing doc analysis
|
||||
CONCEPT_TEMPLATE = '''---
|
||||
title: "{name}"
|
||||
sidebarTitle: "{name}"
|
||||
description: "{description}"
|
||||
icon: "{icon}"
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
{name} provides [brief description of what this feature does and why it exists].
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Input
|
||||
A[User Request]
|
||||
end
|
||||
|
||||
subgraph {name}
|
||||
B[Process] --> C[Validate]
|
||||
C --> D[Execute]
|
||||
end
|
||||
|
||||
subgraph Output
|
||||
E[Result]
|
||||
end
|
||||
|
||||
A --> B
|
||||
D --> E
|
||||
|
||||
style B fill:#189AB4,color:#fff
|
||||
style C fill:#2E8B57,color:#fff
|
||||
style D fill:#8B0000,color:#fff
|
||||
```
|
||||
|
||||
## Types of {name}
|
||||
|
||||
<CardGroup cols={{2}}>
|
||||
<Card title="Type 1" icon="code">
|
||||
Description of first type
|
||||
</Card>
|
||||
<Card title="Type 2" icon="brain">
|
||||
Description of second type
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## Quick Start
|
||||
|
||||
```python
|
||||
from praisonaiagents import Agent, Task
|
||||
|
||||
# Basic {slug} usage
|
||||
agent = Agent(
|
||||
instructions="You are a helpful assistant",
|
||||
# Configure {slug} here
|
||||
)
|
||||
|
||||
result = agent.start("Your prompt here")
|
||||
print(result)
|
||||
```
|
||||
|
||||
## When to Use
|
||||
|
||||
| Use Case | Recommendation |
|
||||
|----------|----------------|
|
||||
| Simple tasks | Use basic configuration |
|
||||
| Complex workflows | Use advanced patterns |
|
||||
| Production | Enable all safety features |
|
||||
|
||||
## Related Features
|
||||
|
||||
- [Related Feature 1](/docs/concepts/related1)
|
||||
- [Related Feature 2](/docs/concepts/related2)
|
||||
'''
|
||||
|
||||
FEATURE_TEMPLATE = '''---
|
||||
title: "{name}"
|
||||
description: "How to use {slug} in PraisonAI"
|
||||
icon: "code"
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
<CodeGroup>
|
||||
```python Basic Usage
|
||||
from praisonaiagents import Agent, Task
|
||||
|
||||
# Simple {slug} example
|
||||
agent = Agent(
|
||||
instructions="You are a helpful assistant",
|
||||
# {slug} configuration
|
||||
)
|
||||
|
||||
result = agent.start("Hello!")
|
||||
print(result)
|
||||
```
|
||||
|
||||
```python Advanced Usage
|
||||
from praisonaiagents import Agent, Task, Agents
|
||||
|
||||
# Advanced {slug} with multiple agents
|
||||
agent1 = Agent(name="Agent1", instructions="First agent")
|
||||
agent2 = Agent(name="Agent2", instructions="Second agent")
|
||||
|
||||
task = Task(
|
||||
description="Complex task using {slug}",
|
||||
agent=agent1,
|
||||
)
|
||||
|
||||
agents = Agents(agents=[agent1, agent2], tasks=[task])
|
||||
result = agents.start()
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
## Configuration Options
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Basic">
|
||||
```python
|
||||
# Minimal configuration
|
||||
agent = Agent(
|
||||
instructions="Your instructions",
|
||||
# {slug}=True, # Enable feature
|
||||
)
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Advanced">
|
||||
```python
|
||||
# Full configuration
|
||||
agent = Agent(
|
||||
instructions="Your instructions",
|
||||
# {slug}={{
|
||||
# "option1": "value1",
|
||||
# "option2": "value2",
|
||||
# }},
|
||||
)
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
## API Reference
|
||||
|
||||
### Parameters
|
||||
|
||||
<ParamField body="param1" type="string" required>
|
||||
Description of the first parameter.
|
||||
</ParamField>
|
||||
|
||||
<ParamField body="param2" type="boolean" default="false">
|
||||
Description of the second parameter.
|
||||
</ParamField>
|
||||
|
||||
<ParamField body="param3" type="object">
|
||||
Advanced configuration object.
|
||||
</ParamField>
|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1: Basic Usage
|
||||
|
||||
```python
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(instructions="You are helpful")
|
||||
result = agent.start("Hello")
|
||||
```
|
||||
|
||||
### Example 2: With Callbacks
|
||||
|
||||
```python
|
||||
from praisonaiagents import Agent
|
||||
|
||||
def on_complete(result):
|
||||
print(f"Completed: {{result}}")
|
||||
|
||||
agent = Agent(
|
||||
instructions="You are helpful",
|
||||
# callbacks=[on_complete],
|
||||
)
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
<Accordion title="Common Issue 1">
|
||||
**Problem**: Description of the issue
|
||||
|
||||
**Solution**: How to fix it
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Common Issue 2">
|
||||
**Problem**: Another common issue
|
||||
|
||||
**Solution**: The fix for this issue
|
||||
</Accordion>
|
||||
|
||||
## See Also
|
||||
|
||||
- [Concept Guide](/docs/concepts/{slug})
|
||||
- [CLI Reference](/docs/cli/{slug})
|
||||
- [API Reference](/docs/sdk/praisonaiagents/{slug}/{slug})
|
||||
'''
|
||||
|
||||
CLI_TEMPLATE = '''---
|
||||
title: "{name}"
|
||||
sidebarTitle: "{name}"
|
||||
description: "CLI commands for {slug}"
|
||||
icon: "terminal"
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
praisonai {slug} [OPTIONS]
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### Basic Command
|
||||
|
||||
```bash
|
||||
praisonai {slug}
|
||||
```
|
||||
|
||||
**Expected Output:**
|
||||
```
|
||||
[Output example here]
|
||||
```
|
||||
|
||||
### With Options
|
||||
|
||||
```bash
|
||||
praisonai {slug} --option value
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `--help` | flag | - | Show help message |
|
||||
| `--verbose` | flag | false | Enable verbose output |
|
||||
| `--config` | string | - | Path to config file |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `PRAISONAI_{slug_upper}` | Configure {slug} | None |
|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1: Basic
|
||||
|
||||
```bash
|
||||
praisonai {slug}
|
||||
```
|
||||
|
||||
### Example 2: With Config
|
||||
|
||||
```bash
|
||||
praisonai {slug} --config config.yaml
|
||||
```
|
||||
|
||||
### Example 3: Verbose Mode
|
||||
|
||||
```bash
|
||||
praisonai {slug} --verbose
|
||||
```
|
||||
|
||||
## Integration with Python
|
||||
|
||||
You can also use {slug} programmatically:
|
||||
|
||||
```python
|
||||
from praisonaiagents import Agent
|
||||
|
||||
# Equivalent to CLI command
|
||||
agent = Agent(instructions="Your instructions")
|
||||
result = agent.start("Your prompt")
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
<Accordion title="Command not found">
|
||||
Ensure PraisonAI is installed: `pip install praisonai`
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Permission denied">
|
||||
Check file permissions and try with `sudo` if needed.
|
||||
</Accordion>
|
||||
'''
|
||||
|
||||
SDK_TEMPLATE = '''---
|
||||
title: "{name} Module"
|
||||
description: "API reference for {slug}"
|
||||
icon: "code"
|
||||
---
|
||||
|
||||
# {name}
|
||||
|
||||
The {name} module provides [brief description].
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install praisonaiagents
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```python
|
||||
from praisonaiagents import {class_name}
|
||||
|
||||
# Basic usage
|
||||
instance = {class_name}()
|
||||
result = instance.execute()
|
||||
```
|
||||
|
||||
## Classes
|
||||
|
||||
### `{class_name}`
|
||||
|
||||
Main class for {slug} functionality.
|
||||
|
||||
#### Constructor
|
||||
|
||||
```python
|
||||
{class_name}(
|
||||
param1: str,
|
||||
param2: bool = False,
|
||||
**kwargs
|
||||
)
|
||||
```
|
||||
|
||||
#### Parameters
|
||||
|
||||
<ParamField body="param1" type="string" required>
|
||||
Primary configuration parameter.
|
||||
</ParamField>
|
||||
|
||||
<ParamField body="param2" type="boolean" default="false">
|
||||
Enable advanced features.
|
||||
</ParamField>
|
||||
|
||||
#### Methods
|
||||
|
||||
##### `execute()`
|
||||
|
||||
Execute the main functionality.
|
||||
|
||||
```python
|
||||
result = instance.execute(input_data)
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `input_data` (str): The input to process
|
||||
|
||||
**Returns:** `str` - The processed result
|
||||
|
||||
##### `configure()`
|
||||
|
||||
Update configuration.
|
||||
|
||||
```python
|
||||
instance.configure(option="value")
|
||||
```
|
||||
|
||||
## Type Definitions
|
||||
|
||||
```python
|
||||
from typing import TypedDict, Optional
|
||||
|
||||
class {class_name}Config(TypedDict):
|
||||
param1: str
|
||||
param2: Optional[bool]
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Basic Example
|
||||
|
||||
```python
|
||||
from praisonaiagents import {class_name}
|
||||
|
||||
instance = {class_name}(param1="value")
|
||||
result = instance.execute()
|
||||
print(result)
|
||||
```
|
||||
|
||||
### Advanced Example
|
||||
|
||||
```python
|
||||
from praisonaiagents import {class_name}
|
||||
|
||||
# With full configuration
|
||||
instance = {class_name}(
|
||||
param1="value",
|
||||
param2=True,
|
||||
)
|
||||
|
||||
# Custom processing
|
||||
result = instance.execute()
|
||||
```
|
||||
|
||||
## See Also
|
||||
|
||||
- [Concept Guide](/docs/concepts/{slug})
|
||||
- [Feature Guide](/docs/features/{slug})
|
||||
'''
|
||||
|
||||
EXAMPLE_BASIC_TEMPLATE = '''"""
|
||||
{name} - Basic Example
|
||||
|
||||
Demonstrates minimal usage of {slug} with a single agent.
|
||||
|
||||
Features:
|
||||
- Simple configuration
|
||||
- Single agent setup
|
||||
- Basic execution
|
||||
|
||||
Expected Output:
|
||||
Agent response demonstrating {slug} functionality.
|
||||
|
||||
Usage:
|
||||
python {slug}-basic.py
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent
|
||||
|
||||
# ============================================================
|
||||
# Basic {name} Configuration
|
||||
# ============================================================
|
||||
|
||||
# Create an agent with {slug} enabled
|
||||
agent = Agent(
|
||||
name="{name}Agent",
|
||||
role="{name} Specialist",
|
||||
goal="Demonstrate basic {slug} functionality",
|
||||
backstory="You are an expert in {slug} operations.",
|
||||
instructions="Provide clear, helpful responses demonstrating {slug}.",
|
||||
# {slug}=True, # Enable {slug} feature
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# Execute
|
||||
# ============================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Run the agent
|
||||
result = agent.start("Demonstrate {slug} functionality")
|
||||
print(result)
|
||||
'''
|
||||
|
||||
EXAMPLE_ADVANCED_TEMPLATE = '''"""
|
||||
{name} - Advanced Example
|
||||
|
||||
Demonstrates advanced {slug} patterns including:
|
||||
- Multi-agent configuration
|
||||
- Custom callbacks/handlers
|
||||
- Error handling
|
||||
- All available options
|
||||
|
||||
Features:
|
||||
- Multiple agents working together
|
||||
- Custom configuration
|
||||
- Production-ready patterns
|
||||
|
||||
Expected Output:
|
||||
Comprehensive demonstration of {slug} with multiple agents.
|
||||
|
||||
Usage:
|
||||
python {slug}-advanced.py
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent, Task, Agents
|
||||
from typing import Any
|
||||
|
||||
# ============================================================
|
||||
# Section 1: Custom Configuration
|
||||
# ============================================================
|
||||
|
||||
# Define custom {slug} configuration
|
||||
# config = {{
|
||||
# "option1": "value1",
|
||||
# "option2": True,
|
||||
# }}
|
||||
|
||||
# ============================================================
|
||||
# Section 2: Custom Callbacks
|
||||
# ============================================================
|
||||
|
||||
def on_task_complete(task_output: Any) -> None:
|
||||
"""Callback when a task completes."""
|
||||
print(f"Task completed: {{task_output}}")
|
||||
|
||||
def on_error(error: Exception) -> None:
|
||||
"""Callback when an error occurs."""
|
||||
print(f"Error occurred: {{error}}")
|
||||
|
||||
# ============================================================
|
||||
# Section 3: Multi-Agent Setup
|
||||
# ============================================================
|
||||
|
||||
# Primary agent
|
||||
primary_agent = Agent(
|
||||
name="Primary{name}Agent",
|
||||
role="Primary {name} Handler",
|
||||
goal="Handle primary {slug} operations",
|
||||
backstory="You are the primary handler for {slug} tasks.",
|
||||
instructions="Process {slug} requests thoroughly and accurately.",
|
||||
# {slug}=config,
|
||||
)
|
||||
|
||||
# Secondary agent
|
||||
secondary_agent = Agent(
|
||||
name="Secondary{name}Agent",
|
||||
role="Secondary {name} Handler",
|
||||
goal="Support and verify {slug} operations",
|
||||
backstory="You verify and enhance {slug} results.",
|
||||
instructions="Review and improve {slug} outputs.",
|
||||
# {slug}=config,
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# Section 4: Task Definition
|
||||
# ============================================================
|
||||
|
||||
primary_task = Task(
|
||||
description="Demonstrate advanced {slug} with primary processing",
|
||||
agent=primary_agent,
|
||||
expected_output="Detailed {slug} demonstration",
|
||||
)
|
||||
|
||||
verification_task = Task(
|
||||
description="Verify and enhance the {slug} results",
|
||||
agent=secondary_agent,
|
||||
expected_output="Verified and enhanced {slug} output",
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# Section 5: Execution
|
||||
# ============================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Create agents team
|
||||
agents = Agents(
|
||||
agents=[primary_agent, secondary_agent],
|
||||
tasks=[primary_task, verification_task],
|
||||
verbose=True,
|
||||
)
|
||||
|
||||
# Execute
|
||||
result = agents.start()
|
||||
print("\\n" + "=" * 60)
|
||||
print("FINAL RESULT:")
|
||||
print("=" * 60)
|
||||
print(result)
|
||||
|
||||
# ============================================================
|
||||
# Section 6: All Options Reference
|
||||
# ============================================================
|
||||
"""
|
||||
{name} Options:
|
||||
──────────────────────────────────────────────────────────────
|
||||
| Option | Type | Default | Description |
|
||||
|-----------------|----------|---------|----------------------|
|
||||
| option1 | str | None | Primary option |
|
||||
| option2 | bool | False | Enable advanced mode |
|
||||
| option3 | int | 10 | Iteration count |
|
||||
| option4 | list | [] | Additional handlers |
|
||||
──────────────────────────────────────────────────────────────
|
||||
"""
|
||||
'''
|
||||
|
||||
|
||||
class EnhancedTemplateGenerator:
|
||||
"""Enhanced template generator with best-practice templates."""
|
||||
|
||||
# Icon mapping for features
|
||||
ICON_MAP = {
|
||||
"guardrails": "shield-halved",
|
||||
"memory": "brain",
|
||||
"knowledge": "book",
|
||||
"tools": "wrench",
|
||||
"agents": "robot",
|
||||
"tasks": "list-check",
|
||||
"workflows": "diagram-project",
|
||||
"handoffs": "arrow-right-arrow-left",
|
||||
"sessions": "clock",
|
||||
"hooks": "plug",
|
||||
"callbacks": "phone",
|
||||
"telemetry": "chart-line",
|
||||
"default": "code",
|
||||
}
|
||||
|
||||
def get_icon(self, slug: str) -> str:
|
||||
"""Get appropriate icon for a feature."""
|
||||
return self.ICON_MAP.get(slug, self.ICON_MAP["default"])
|
||||
|
||||
def generate(self, slug: FeatureSlug, artifact_type: ArtifactType) -> str:
|
||||
"""Generate template content for an artifact type."""
|
||||
slug_str = slug.normalised
|
||||
name = slug_str.replace("-", " ").title()
|
||||
class_name = "".join(word.title() for word in slug_str.split("-"))
|
||||
icon = self.get_icon(slug_str)
|
||||
slug_upper = slug_str.upper().replace("-", "_")
|
||||
|
||||
# Select template
|
||||
if artifact_type == ArtifactType.DOCS_CONCEPT:
|
||||
template = CONCEPT_TEMPLATE
|
||||
elif artifact_type == ArtifactType.DOCS_FEATURE:
|
||||
template = FEATURE_TEMPLATE
|
||||
elif artifact_type == ArtifactType.DOCS_CLI:
|
||||
template = CLI_TEMPLATE
|
||||
elif artifact_type == ArtifactType.DOCS_SDK:
|
||||
template = SDK_TEMPLATE
|
||||
elif artifact_type == ArtifactType.EXAMPLE_BASIC:
|
||||
template = EXAMPLE_BASIC_TEMPLATE
|
||||
elif artifact_type == ArtifactType.EXAMPLE_ADVANCED:
|
||||
template = EXAMPLE_ADVANCED_TEMPLATE
|
||||
else:
|
||||
return ""
|
||||
|
||||
# Fill template
|
||||
return template.format(
|
||||
name=name,
|
||||
slug=slug_str,
|
||||
class_name=class_name,
|
||||
icon=icon,
|
||||
slug_upper=slug_upper,
|
||||
description=f"{name} functionality for PraisonAI agents",
|
||||
)
|
||||
|
||||
def get_expected_path(self, slug: FeatureSlug, artifact_type: ArtifactType,
|
||||
docs_root: Optional[Path] = None,
|
||||
examples_root: Optional[Path] = None) -> Optional[Path]:
|
||||
"""Get the expected path for an artifact."""
|
||||
slug_str = slug.normalised
|
||||
|
||||
if artifact_type == ArtifactType.EXAMPLE_BASIC:
|
||||
if examples_root:
|
||||
return examples_root / slug_str / f"{slug_str}-basic.py"
|
||||
elif artifact_type == ArtifactType.EXAMPLE_ADVANCED:
|
||||
if examples_root:
|
||||
return examples_root / slug_str / f"{slug_str}-advanced.py"
|
||||
elif artifact_type == ArtifactType.DOCS_CONCEPT:
|
||||
if docs_root:
|
||||
return docs_root / "concepts" / f"{slug_str}.mdx"
|
||||
elif artifact_type == ArtifactType.DOCS_FEATURE:
|
||||
if docs_root:
|
||||
return docs_root / "features" / f"{slug_str}.mdx"
|
||||
elif artifact_type == ArtifactType.DOCS_CLI:
|
||||
if docs_root:
|
||||
return docs_root / "cli" / f"{slug_str}.mdx"
|
||||
elif artifact_type == ArtifactType.DOCS_SDK:
|
||||
if docs_root:
|
||||
return docs_root / "sdk" / "praisonaiagents" / slug_str / f"{slug_str}.mdx"
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,229 @@
|
||||
"""
|
||||
Example verification module for the FDEP standardisation system.
|
||||
|
||||
Verifies that generated Python examples are:
|
||||
1. Syntactically correct (can be parsed)
|
||||
2. Runnable (can be executed without errors)
|
||||
3. Handle external library dependencies gracefully
|
||||
|
||||
Features:
|
||||
- Syntax validation via ast.parse
|
||||
- Execution in subprocess with timeout
|
||||
- Detection of missing external libraries
|
||||
- Detailed error reporting
|
||||
"""
|
||||
|
||||
import ast
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple
|
||||
|
||||
|
||||
@dataclass
|
||||
class VerificationResult:
|
||||
"""Result of example verification."""
|
||||
success: bool
|
||||
syntax_valid: bool
|
||||
execution_passed: bool
|
||||
output: str
|
||||
error: str
|
||||
missing_libraries: List[str]
|
||||
requires_external: bool
|
||||
|
||||
@property
|
||||
def can_write(self) -> bool:
|
||||
"""Whether the example is safe to write."""
|
||||
return self.syntax_valid and (self.execution_passed or self.requires_external)
|
||||
|
||||
|
||||
class ExampleVerifier:
|
||||
"""Verifies that generated examples are runnable."""
|
||||
|
||||
# Known external libraries that may not be installed
|
||||
EXTERNAL_LIBRARIES = {
|
||||
"requests", "httpx", "aiohttp", # HTTP
|
||||
"pandas", "numpy", "scipy", # Data science
|
||||
"torch", "tensorflow", "transformers", # ML
|
||||
"redis", "pymongo", "psycopg2", # Databases
|
||||
"boto3", "google-cloud", # Cloud
|
||||
"playwright", "selenium", # Browser automation
|
||||
"beautifulsoup4", "scrapy", # Web scraping
|
||||
}
|
||||
|
||||
# Imports that indicate external dependencies
|
||||
EXTERNAL_IMPORT_PATTERNS = [
|
||||
"import requests",
|
||||
"import httpx",
|
||||
"import pandas",
|
||||
"import numpy",
|
||||
"import torch",
|
||||
"import tensorflow",
|
||||
"from transformers",
|
||||
"import redis",
|
||||
"import pymongo",
|
||||
"import boto3",
|
||||
]
|
||||
|
||||
# Patterns that indicate LLM API calls (will timeout during verification)
|
||||
LLM_CALL_PATTERNS = [
|
||||
"agent.start(",
|
||||
"agents.start(",
|
||||
".chat(",
|
||||
".run(",
|
||||
]
|
||||
|
||||
def __init__(self, timeout: int = 30):
|
||||
self.timeout = timeout
|
||||
|
||||
def verify(self, code: str) -> VerificationResult:
|
||||
"""
|
||||
Verify that the code is valid and runnable.
|
||||
|
||||
Args:
|
||||
code: Python source code to verify
|
||||
|
||||
Returns:
|
||||
VerificationResult with details
|
||||
"""
|
||||
# Step 1: Syntax validation
|
||||
syntax_valid, syntax_error = self._check_syntax(code)
|
||||
if not syntax_valid:
|
||||
return VerificationResult(
|
||||
success=False,
|
||||
syntax_valid=False,
|
||||
execution_passed=False,
|
||||
output="",
|
||||
error=f"Syntax error: {syntax_error}",
|
||||
missing_libraries=[],
|
||||
requires_external=False,
|
||||
)
|
||||
|
||||
# Step 2: Check for external dependencies
|
||||
requires_external, external_libs = self._detect_external_deps(code)
|
||||
|
||||
# Step 3: Skip execution for examples with LLM calls (they will timeout)
|
||||
if "LLM_API_CALL" in external_libs:
|
||||
return VerificationResult(
|
||||
success=True,
|
||||
syntax_valid=True,
|
||||
execution_passed=False,
|
||||
output="",
|
||||
error="Skipped: Example makes LLM API calls",
|
||||
missing_libraries=["praisonaiagents (LLM API)"],
|
||||
requires_external=True,
|
||||
)
|
||||
|
||||
# Step 4: Execute the code
|
||||
execution_passed, output, error, missing_libs = self._execute(code)
|
||||
|
||||
# Combine missing libraries
|
||||
all_missing = list(set(missing_libs + external_libs))
|
||||
|
||||
return VerificationResult(
|
||||
success=execution_passed or requires_external,
|
||||
syntax_valid=True,
|
||||
execution_passed=execution_passed,
|
||||
output=output,
|
||||
error=error,
|
||||
missing_libraries=all_missing,
|
||||
requires_external=requires_external and not execution_passed,
|
||||
)
|
||||
|
||||
def _check_syntax(self, code: str) -> Tuple[bool, str]:
|
||||
"""Check if code is syntactically valid."""
|
||||
try:
|
||||
ast.parse(code)
|
||||
return True, ""
|
||||
except SyntaxError as e:
|
||||
return False, f"Line {e.lineno}: {e.msg}"
|
||||
|
||||
def _detect_external_deps(self, code: str) -> Tuple[bool, List[str]]:
|
||||
"""Detect if code requires external libraries or makes LLM calls."""
|
||||
external_libs = []
|
||||
|
||||
for pattern in self.EXTERNAL_IMPORT_PATTERNS:
|
||||
if pattern in code:
|
||||
lib_name = pattern.split()[-1]
|
||||
external_libs.append(lib_name)
|
||||
|
||||
# Check for LLM API calls that will timeout during verification
|
||||
for pattern in self.LLM_CALL_PATTERNS:
|
||||
if pattern in code:
|
||||
external_libs.append("LLM_API_CALL")
|
||||
break
|
||||
|
||||
return len(external_libs) > 0, external_libs
|
||||
|
||||
def _execute(self, code: str) -> Tuple[bool, str, str, List[str]]:
|
||||
"""Execute code in a subprocess."""
|
||||
missing_libs = []
|
||||
|
||||
# Write code to temp file
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".py", delete=False, encoding="utf-8"
|
||||
) as f:
|
||||
f.write(code)
|
||||
temp_path = f.name
|
||||
|
||||
try:
|
||||
# Run with timeout
|
||||
result = subprocess.run(
|
||||
[sys.executable, temp_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=self.timeout,
|
||||
env={**subprocess.os.environ, "PYTHONDONTWRITEBYTECODE": "1"},
|
||||
)
|
||||
|
||||
output = result.stdout
|
||||
error = result.stderr
|
||||
|
||||
# Check for import errors
|
||||
if result.returncode != 0:
|
||||
if "ModuleNotFoundError" in error or "ImportError" in error:
|
||||
# Extract module name
|
||||
for line in error.split("\n"):
|
||||
if "No module named" in line:
|
||||
# Extract module name from error
|
||||
parts = line.split("'")
|
||||
if len(parts) >= 2:
|
||||
missing_libs.append(parts[1].split(".")[0])
|
||||
|
||||
return False, output, error, missing_libs
|
||||
|
||||
return True, output, error, missing_libs
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, "", f"Execution timed out after {self.timeout}s", missing_libs
|
||||
except Exception as e:
|
||||
return False, "", str(e), missing_libs
|
||||
finally:
|
||||
# Clean up temp file
|
||||
Path(temp_path).unlink(missing_ok=True)
|
||||
|
||||
def format_result(self, result: VerificationResult) -> str:
|
||||
"""Format verification result for display."""
|
||||
lines = []
|
||||
|
||||
if result.success:
|
||||
lines.append("✅ Verification PASSED")
|
||||
else:
|
||||
lines.append("❌ Verification FAILED")
|
||||
|
||||
lines.append(f" Syntax valid: {'✓' if result.syntax_valid else '✗'}")
|
||||
lines.append(f" Execution passed: {'✓' if result.execution_passed else '✗'}")
|
||||
|
||||
if result.requires_external:
|
||||
lines.append(f" ⚠️ Requires external libraries: {', '.join(result.missing_libraries)}")
|
||||
lines.append(" (Will be written with warning)")
|
||||
|
||||
if result.error and not result.requires_external:
|
||||
lines.append(f" Error: {result.error[:200]}")
|
||||
|
||||
if result.output:
|
||||
lines.append(f" Output preview: {result.output[:100]}...")
|
||||
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,201 @@
|
||||
"""
|
||||
Manifest schema and storage for the FDEP standardisation system.
|
||||
|
||||
Manifests are stored in .praison/features/{slug}.yaml
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from .models import FeatureManifest, FeatureSlug
|
||||
|
||||
|
||||
class ManifestStorage:
|
||||
"""Manages feature manifest files."""
|
||||
|
||||
def __init__(self, manifest_dir: Optional[Path] = None):
|
||||
self.manifest_dir = manifest_dir or Path(".praison/features")
|
||||
|
||||
def get_manifest_path(self, slug: FeatureSlug) -> Path:
|
||||
"""Get the path to a manifest file."""
|
||||
return self.manifest_dir / f"{slug.normalised}.yaml"
|
||||
|
||||
def load(self, slug: FeatureSlug) -> Optional[FeatureManifest]:
|
||||
"""Load a manifest from disk."""
|
||||
path = self.get_manifest_path(slug)
|
||||
if not path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
return self._load_simple(path, slug)
|
||||
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f)
|
||||
return FeatureManifest.from_dict(data)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _load_simple(self, path: Path, slug: FeatureSlug) -> Optional[FeatureManifest]:
|
||||
"""Simple YAML loading without PyYAML dependency."""
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
data = {}
|
||||
|
||||
for line in content.split("\n"):
|
||||
line = line.strip()
|
||||
if ":" in line and not line.startswith("#"):
|
||||
key, value = line.split(":", 1)
|
||||
key = key.strip()
|
||||
value = value.strip().strip('"').strip("'")
|
||||
data[key] = value
|
||||
|
||||
return FeatureManifest.from_dict(data)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def save(self, manifest: FeatureManifest) -> Path:
|
||||
"""Save a manifest to disk."""
|
||||
path = self.get_manifest_path(manifest.slug)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
import yaml
|
||||
content = yaml.dump(manifest.to_dict(), default_flow_style=False, sort_keys=False)
|
||||
except ImportError:
|
||||
content = self._to_simple_yaml(manifest)
|
||||
|
||||
path.write_text(content, encoding="utf-8")
|
||||
return path
|
||||
|
||||
def _to_simple_yaml(self, manifest: FeatureManifest) -> str:
|
||||
"""Convert manifest to simple YAML without PyYAML."""
|
||||
lines = []
|
||||
data = manifest.to_dict()
|
||||
|
||||
for key, value in data.items():
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, list):
|
||||
if value:
|
||||
lines.append(f"{key}:")
|
||||
for item in value:
|
||||
lines.append(f" - {item}")
|
||||
elif isinstance(value, dict):
|
||||
if value:
|
||||
lines.append(f"{key}:")
|
||||
for k, v in value.items():
|
||||
lines.append(f" {k}: {v}")
|
||||
else:
|
||||
lines.append(f"{key}: {value}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def list_all(self) -> List[FeatureManifest]:
|
||||
"""List all manifests."""
|
||||
manifests = []
|
||||
|
||||
if not self.manifest_dir.exists():
|
||||
return manifests
|
||||
|
||||
for yaml_file in self.manifest_dir.glob("*.yaml"):
|
||||
slug = FeatureSlug.from_path(yaml_file, "manifest")
|
||||
manifest = self.load(slug)
|
||||
if manifest:
|
||||
manifests.append(manifest)
|
||||
|
||||
return manifests
|
||||
|
||||
def delete(self, slug: FeatureSlug) -> bool:
|
||||
"""Delete a manifest."""
|
||||
path = self.get_manifest_path(slug)
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
return True
|
||||
return False
|
||||
|
||||
def exists(self, slug: FeatureSlug) -> bool:
|
||||
"""Check if a manifest exists."""
|
||||
return self.get_manifest_path(slug).exists()
|
||||
|
||||
def create_default(self, slug: FeatureSlug,
|
||||
sdk_module: Optional[str] = None,
|
||||
cli_commands: Optional[List[str]] = None) -> FeatureManifest:
|
||||
"""Create a default manifest for a feature."""
|
||||
name = slug.normalised.replace("-", " ").title()
|
||||
|
||||
return FeatureManifest(
|
||||
slug=slug,
|
||||
name=name,
|
||||
description=f"{name} feature for PraisonAI",
|
||||
status="stable",
|
||||
sdk_module=sdk_module or slug.normalised,
|
||||
cli_commands=cli_commands or [],
|
||||
)
|
||||
|
||||
|
||||
# Schema for manifest validation
|
||||
MANIFEST_SCHEMA = {
|
||||
"type": "object",
|
||||
"required": ["slug", "name", "description"],
|
||||
"properties": {
|
||||
"slug": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9-]{0,62}[a-z0-9]$|^[a-z]$",
|
||||
"description": "Feature slug (lowercase alphanumeric + hyphens)",
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 100,
|
||||
"description": "Human-readable feature name",
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 500,
|
||||
"description": "Brief description of the feature",
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["stable", "beta", "experimental", "deprecated"],
|
||||
"default": "stable",
|
||||
},
|
||||
"min_version": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d+\\.\\d+\\.\\d+$",
|
||||
"description": "Minimum PraisonAI version required",
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Tags for categorisation",
|
||||
},
|
||||
"related_features": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Related feature slugs",
|
||||
},
|
||||
"sdk_module": {
|
||||
"type": "string",
|
||||
"description": "SDK module name (e.g., 'guardrails')",
|
||||
},
|
||||
"cli_commands": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "CLI commands for this feature",
|
||||
},
|
||||
"api_classes": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Public API classes",
|
||||
},
|
||||
"artifacts": {
|
||||
"type": "object",
|
||||
"description": "Paths to artifacts",
|
||||
"additionalProperties": {"type": "string"},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
"""
|
||||
Data models for the FDEP standardisation system.
|
||||
|
||||
All models use dataclasses for lightweight, stdlib-only implementation.
|
||||
"""
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
|
||||
class ArtifactType(Enum):
|
||||
"""Types of required artifacts per feature."""
|
||||
DOCS_CONCEPT = "docs_concept"
|
||||
DOCS_FEATURE = "docs_feature"
|
||||
DOCS_CLI = "docs_cli"
|
||||
DOCS_SDK = "docs_sdk"
|
||||
EXAMPLE_BASIC = "example_basic"
|
||||
EXAMPLE_ADVANCED = "example_advanced"
|
||||
MANIFEST = "manifest"
|
||||
|
||||
|
||||
class ArtifactStatus(Enum):
|
||||
"""Status of an artifact."""
|
||||
PRESENT = "present"
|
||||
MISSING = "missing"
|
||||
DUPLICATE = "duplicate"
|
||||
OUTDATED = "outdated"
|
||||
|
||||
|
||||
class IssueType(Enum):
|
||||
"""Types of standardisation issues."""
|
||||
MISSING_ARTIFACT = "missing_artifact"
|
||||
DUPLICATE_CONTENT = "duplicate_content"
|
||||
NAMING_INCONSISTENCY = "naming_inconsistency"
|
||||
DRIFT_SDK_DOCS = "drift_sdk_docs"
|
||||
DRIFT_CLI_DOCS = "drift_cli_docs"
|
||||
INVALID_FRONTMATTER = "invalid_frontmatter"
|
||||
|
||||
|
||||
# Slug validation regex: lowercase alphanumeric + hyphens, 2-64 chars
|
||||
SLUG_PATTERN = re.compile(r"^[a-z][a-z0-9-]{0,62}[a-z0-9]$|^[a-z]$")
|
||||
|
||||
# Singular to plural mappings for normalisation
|
||||
SINGULAR_PLURAL_MAP = {
|
||||
"agent": "agents",
|
||||
"task": "tasks",
|
||||
"tool": "tools",
|
||||
"guardrail": "guardrails",
|
||||
"handoff": "handoffs",
|
||||
"workflow": "workflows",
|
||||
"checkpoint": "checkpoints",
|
||||
"hook": "hooks",
|
||||
"skill": "skills",
|
||||
"plugin": "plugins",
|
||||
"session": "sessions",
|
||||
"process": "processes",
|
||||
}
|
||||
|
||||
# Legacy alias mappings
|
||||
LEGACY_ALIASES = {
|
||||
"rag": "knowledge",
|
||||
"retrieval": "knowledge",
|
||||
"llm": "models",
|
||||
"model": "models",
|
||||
"db": "databases",
|
||||
"database": "databases",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class FeatureSlug:
|
||||
"""
|
||||
Represents a normalised feature slug.
|
||||
|
||||
Slug rules:
|
||||
- Lowercase alphanumeric + hyphens only
|
||||
- 1-64 characters
|
||||
- Stable normalisation (singular/plural, legacy aliases)
|
||||
"""
|
||||
raw: str
|
||||
normalised: str
|
||||
is_valid: bool
|
||||
validation_error: Optional[str] = None
|
||||
|
||||
@classmethod
|
||||
def from_string(cls, value: str) -> "FeatureSlug":
|
||||
"""Create a FeatureSlug from a raw string."""
|
||||
raw = value.strip().lower()
|
||||
|
||||
# Replace underscores with hyphens
|
||||
normalised = raw.replace("_", "-")
|
||||
|
||||
# Remove .py, .mdx extensions if present
|
||||
for ext in (".py", ".mdx", ".md"):
|
||||
if normalised.endswith(ext):
|
||||
normalised = normalised[:-len(ext)]
|
||||
|
||||
# Apply legacy alias mapping
|
||||
if normalised in LEGACY_ALIASES:
|
||||
normalised = LEGACY_ALIASES[normalised]
|
||||
|
||||
# Apply singular to plural normalisation (prefer plural for collections)
|
||||
if normalised in SINGULAR_PLURAL_MAP:
|
||||
normalised = SINGULAR_PLURAL_MAP[normalised]
|
||||
|
||||
# Validate
|
||||
is_valid = bool(SLUG_PATTERN.match(normalised))
|
||||
validation_error = None
|
||||
|
||||
if not is_valid:
|
||||
if not normalised:
|
||||
validation_error = "Slug cannot be empty"
|
||||
elif not normalised[0].isalpha():
|
||||
validation_error = "Slug must start with a letter"
|
||||
elif len(normalised) > 64:
|
||||
validation_error = "Slug must be 64 characters or less"
|
||||
else:
|
||||
validation_error = "Slug must contain only lowercase letters, numbers, and hyphens"
|
||||
|
||||
return cls(raw=raw, normalised=normalised, is_valid=is_valid,
|
||||
validation_error=validation_error)
|
||||
|
||||
@classmethod
|
||||
def from_path(cls, path: Path, base_type: str = "unknown") -> "FeatureSlug":
|
||||
"""
|
||||
Derive a slug from a file or directory path.
|
||||
|
||||
Args:
|
||||
path: Path to derive slug from
|
||||
base_type: Type hint (sdk, cli, docs, examples)
|
||||
"""
|
||||
# Use the stem (filename without extension) or directory name
|
||||
name = path.stem if path.is_file() else path.name
|
||||
return cls.from_string(name)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.normalised
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash(self.normalised)
|
||||
|
||||
def __eq__(self, other) -> bool:
|
||||
if isinstance(other, FeatureSlug):
|
||||
return self.normalised == other.normalised
|
||||
if isinstance(other, str):
|
||||
return self.normalised == other.lower()
|
||||
return False
|
||||
|
||||
|
||||
@dataclass
|
||||
class ArtifactPath:
|
||||
"""Represents a path to a required artifact."""
|
||||
artifact_type: ArtifactType
|
||||
expected_path: Path
|
||||
actual_path: Optional[Path] = None
|
||||
status: ArtifactStatus = ArtifactStatus.MISSING
|
||||
|
||||
@property
|
||||
def exists(self) -> bool:
|
||||
return self.actual_path is not None and self.actual_path.exists()
|
||||
|
||||
|
||||
@dataclass
|
||||
class FeatureManifest:
|
||||
"""
|
||||
Manifest entry for a single feature.
|
||||
|
||||
This is the source of truth for what artifacts a feature should have.
|
||||
"""
|
||||
slug: FeatureSlug
|
||||
name: str
|
||||
description: str
|
||||
status: str = "stable" # stable, beta, experimental, deprecated
|
||||
min_version: Optional[str] = None
|
||||
tags: List[str] = field(default_factory=list)
|
||||
related_features: List[str] = field(default_factory=list)
|
||||
sdk_module: Optional[str] = None
|
||||
cli_commands: List[str] = field(default_factory=list)
|
||||
api_classes: List[str] = field(default_factory=list)
|
||||
artifacts: Dict[str, str] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for YAML serialisation."""
|
||||
return {
|
||||
"slug": self.slug.normalised,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"status": self.status,
|
||||
"min_version": self.min_version,
|
||||
"tags": self.tags,
|
||||
"related_features": self.related_features,
|
||||
"sdk_module": self.sdk_module,
|
||||
"cli_commands": self.cli_commands,
|
||||
"api_classes": self.api_classes,
|
||||
"artifacts": self.artifacts,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "FeatureManifest":
|
||||
"""Create from dictionary."""
|
||||
slug = FeatureSlug.from_string(data.get("slug", ""))
|
||||
return cls(
|
||||
slug=slug,
|
||||
name=data.get("name", slug.normalised.replace("-", " ").title()),
|
||||
description=data.get("description", ""),
|
||||
status=data.get("status", "stable"),
|
||||
min_version=data.get("min_version"),
|
||||
tags=data.get("tags", []),
|
||||
related_features=data.get("related_features", []),
|
||||
sdk_module=data.get("sdk_module"),
|
||||
cli_commands=data.get("cli_commands", []),
|
||||
api_classes=data.get("api_classes", []),
|
||||
artifacts=data.get("artifacts", {}),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidationResult:
|
||||
"""Result of validating a feature's artifacts."""
|
||||
slug: FeatureSlug
|
||||
is_valid: bool
|
||||
missing_artifacts: List[ArtifactType] = field(default_factory=list)
|
||||
present_artifacts: List[ArtifactPath] = field(default_factory=list)
|
||||
issues: List[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def missing_count(self) -> int:
|
||||
return len(self.missing_artifacts)
|
||||
|
||||
@property
|
||||
def present_count(self) -> int:
|
||||
return len(self.present_artifacts)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DuplicateCluster:
|
||||
"""A cluster of duplicate or near-duplicate content."""
|
||||
slug: FeatureSlug
|
||||
pages: List[Path]
|
||||
similarity_score: float # 0.0 to 1.0
|
||||
issue_type: str # "same_slug", "title_similarity", "content_overlap"
|
||||
recommendation: str # "merge", "keep_both", "review"
|
||||
primary_page: Optional[Path] = None # Recommended canonical page
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"DuplicateCluster({self.slug}, {len(self.pages)} pages, {self.issue_type})"
|
||||
|
||||
|
||||
@dataclass
|
||||
class DriftItem:
|
||||
"""A single drift item (SDK export or CLI flag not in docs, or vice versa)."""
|
||||
name: str
|
||||
source: str # "sdk", "cli", "docs"
|
||||
missing_in: str # Where it's missing
|
||||
item_type: str # "class", "function", "flag", "command"
|
||||
|
||||
|
||||
@dataclass
|
||||
class DriftReport:
|
||||
"""Report of drift between code and documentation."""
|
||||
slug: FeatureSlug
|
||||
sdk_drift: List[DriftItem] = field(default_factory=list)
|
||||
cli_drift: List[DriftItem] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def has_drift(self) -> bool:
|
||||
return bool(self.sdk_drift or self.cli_drift)
|
||||
|
||||
@property
|
||||
def total_drift_items(self) -> int:
|
||||
return len(self.sdk_drift) + len(self.cli_drift)
|
||||
|
||||
|
||||
@dataclass
|
||||
class StandardiseIssue:
|
||||
"""A single standardisation issue."""
|
||||
issue_type: IssueType
|
||||
slug: Optional[FeatureSlug]
|
||||
path: Optional[Path]
|
||||
message: str
|
||||
severity: str = "warning" # error, warning, info
|
||||
auto_fixable: bool = False
|
||||
fix_action: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class StandardiseReport:
|
||||
"""Complete standardisation report."""
|
||||
timestamp: str
|
||||
features_scanned: int
|
||||
docs_pages: int
|
||||
examples_count: int
|
||||
issues: List[StandardiseIssue] = field(default_factory=list)
|
||||
duplicates: List[DuplicateCluster] = field(default_factory=list)
|
||||
missing_artifacts: Dict[str, List[ArtifactType]] = field(default_factory=dict)
|
||||
drift_reports: List[DriftReport] = field(default_factory=list)
|
||||
validation_results: List[ValidationResult] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def total_issues(self) -> int:
|
||||
return len(self.issues)
|
||||
|
||||
@property
|
||||
def total_missing(self) -> int:
|
||||
return sum(len(v) for v in self.missing_artifacts.values())
|
||||
|
||||
@property
|
||||
def total_duplicates(self) -> int:
|
||||
return len(self.duplicates)
|
||||
|
||||
@property
|
||||
def has_issues(self) -> bool:
|
||||
return self.total_issues > 0 or self.total_missing > 0 or self.total_duplicates > 0
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for JSON serialisation."""
|
||||
return {
|
||||
"timestamp": self.timestamp,
|
||||
"summary": {
|
||||
"features_scanned": self.features_scanned,
|
||||
"docs_pages": self.docs_pages,
|
||||
"examples_count": self.examples_count,
|
||||
"total_issues": self.total_issues,
|
||||
"total_missing": self.total_missing,
|
||||
"total_duplicates": self.total_duplicates,
|
||||
},
|
||||
"issues": [
|
||||
{
|
||||
"type": i.issue_type.value,
|
||||
"slug": str(i.slug) if i.slug else None,
|
||||
"path": str(i.path) if i.path else None,
|
||||
"message": i.message,
|
||||
"severity": i.severity,
|
||||
"auto_fixable": i.auto_fixable,
|
||||
}
|
||||
for i in self.issues
|
||||
],
|
||||
"duplicates": [
|
||||
{
|
||||
"slug": str(d.slug),
|
||||
"pages": [str(p) for p in d.pages],
|
||||
"similarity": d.similarity_score,
|
||||
"type": d.issue_type,
|
||||
"recommendation": d.recommendation,
|
||||
}
|
||||
for d in self.duplicates
|
||||
],
|
||||
"missing_artifacts": {
|
||||
slug: [a.value for a in artifacts]
|
||||
for slug, artifacts in self.missing_artifacts.items()
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
"""
|
||||
Report generators for the FDEP standardisation system.
|
||||
|
||||
Generates reports in:
|
||||
- Text (terminal-friendly)
|
||||
- JSON (machine-readable)
|
||||
- Markdown (documentation-friendly)
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from .models import StandardiseReport
|
||||
|
||||
|
||||
class ReportGenerator:
|
||||
"""Generates standardisation reports in various formats."""
|
||||
|
||||
def generate(self, report: StandardiseReport, format: str = "text") -> str:
|
||||
"""Generate a report in the specified format."""
|
||||
if format == "text":
|
||||
return self._generate_text(report)
|
||||
elif format == "json":
|
||||
return self._generate_json(report)
|
||||
elif format == "markdown":
|
||||
return self._generate_markdown(report)
|
||||
else:
|
||||
raise ValueError(f"Unknown format: {format}")
|
||||
|
||||
def _generate_text(self, report: StandardiseReport) -> str:
|
||||
"""Generate a text report for terminal output."""
|
||||
lines = []
|
||||
|
||||
# Header
|
||||
lines.append("")
|
||||
lines.append("🔍 PraisonAI Standardisation Check")
|
||||
lines.append("══════════════════════════════════")
|
||||
lines.append("")
|
||||
|
||||
# Summary
|
||||
lines.append("📊 Summary")
|
||||
lines.append("──────────")
|
||||
lines.append(f"Features scanned: {report.features_scanned}")
|
||||
lines.append(f"Docs pages: {report.docs_pages}")
|
||||
lines.append(f"Examples: {report.examples_count}")
|
||||
lines.append("")
|
||||
|
||||
if not report.has_issues:
|
||||
lines.append("✅ No issues found!")
|
||||
return "\n".join(lines)
|
||||
|
||||
lines.append(f"⚠️ Issues Found: {report.total_issues + report.total_missing + report.total_duplicates}")
|
||||
lines.append("──────────────────────")
|
||||
lines.append("")
|
||||
|
||||
# Duplicates
|
||||
if report.duplicates:
|
||||
lines.append(f"🔴 DUPLICATES ({len(report.duplicates)} clusters)")
|
||||
for cluster in report.duplicates:
|
||||
pages_str = ", ".join(self._short_path(p) for p in cluster.pages[:3])
|
||||
if len(cluster.pages) > 3:
|
||||
pages_str += f" (+{len(cluster.pages) - 3} more)"
|
||||
lines.append(f" • {cluster.slug}: {len(cluster.pages)} pages ({cluster.issue_type})")
|
||||
lines.append("")
|
||||
|
||||
# Missing artifacts
|
||||
if report.missing_artifacts:
|
||||
lines.append(f"🟡 MISSING ARTIFACTS ({len(report.missing_artifacts)} features)")
|
||||
for slug, artifacts in list(report.missing_artifacts.items())[:10]:
|
||||
artifact_names = [a.value.replace("_", " ") for a in artifacts]
|
||||
lines.append(f" • {slug}: missing {', '.join(artifact_names[:3])}")
|
||||
if len(artifact_names) > 3:
|
||||
lines.append(f" (+{len(artifact_names) - 3} more)")
|
||||
if len(report.missing_artifacts) > 10:
|
||||
lines.append(f" ... and {len(report.missing_artifacts) - 10} more features")
|
||||
lines.append("")
|
||||
|
||||
# Other issues
|
||||
if report.issues:
|
||||
naming_issues = [i for i in report.issues if "naming" in i.message.lower()]
|
||||
if naming_issues:
|
||||
lines.append(f"🟢 NAMING ISSUES ({len(naming_issues)})")
|
||||
for issue in naming_issues[:5]:
|
||||
lines.append(f" • {issue.message}")
|
||||
lines.append("")
|
||||
|
||||
# Next actions
|
||||
lines.append("📋 Next Actions")
|
||||
lines.append("───────────────")
|
||||
lines.append("1. Run `praisonai standardise report --format markdown` for details")
|
||||
if report.duplicates:
|
||||
lines.append("2. Run `praisonai standardise fix --feature <slug>` to consolidate")
|
||||
if report.missing_artifacts:
|
||||
lines.append("3. Run `praisonai standardise init --feature <slug>` to create missing")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def _generate_json(self, report: StandardiseReport) -> str:
|
||||
"""Generate a JSON report."""
|
||||
return json.dumps(report.to_dict(), indent=2)
|
||||
|
||||
def _generate_markdown(self, report: StandardiseReport) -> str:
|
||||
"""Generate a Markdown report."""
|
||||
lines = []
|
||||
|
||||
# Header
|
||||
lines.append("# PraisonAI Standardisation Report")
|
||||
lines.append("")
|
||||
lines.append(f"Generated: {report.timestamp}")
|
||||
lines.append("")
|
||||
|
||||
# Summary table
|
||||
lines.append("## Summary")
|
||||
lines.append("")
|
||||
lines.append("| Metric | Value |")
|
||||
lines.append("|--------|-------|")
|
||||
lines.append(f"| Features scanned | {report.features_scanned} |")
|
||||
lines.append(f"| Docs pages | {report.docs_pages} |")
|
||||
lines.append(f"| Examples | {report.examples_count} |")
|
||||
lines.append(f"| Total issues | {report.total_issues} |")
|
||||
lines.append(f"| Missing artifacts | {report.total_missing} |")
|
||||
lines.append(f"| Duplicate clusters | {report.total_duplicates} |")
|
||||
lines.append("")
|
||||
|
||||
# Duplicates section
|
||||
if report.duplicates:
|
||||
lines.append("## Duplicate Clusters")
|
||||
lines.append("")
|
||||
for cluster in report.duplicates:
|
||||
lines.append(f"### {cluster.slug}")
|
||||
lines.append("")
|
||||
lines.append(f"- **Type**: {cluster.issue_type}")
|
||||
lines.append(f"- **Similarity**: {cluster.similarity_score:.0%}")
|
||||
lines.append(f"- **Recommendation**: {cluster.recommendation}")
|
||||
lines.append("")
|
||||
lines.append("**Pages:**")
|
||||
for page in cluster.pages:
|
||||
lines.append(f"- `{page}`")
|
||||
lines.append("")
|
||||
|
||||
# Missing artifacts section
|
||||
if report.missing_artifacts:
|
||||
lines.append("## Missing Artifacts")
|
||||
lines.append("")
|
||||
lines.append("| Feature | Missing |")
|
||||
lines.append("|---------|---------|")
|
||||
for slug, artifacts in report.missing_artifacts.items():
|
||||
artifact_names = ", ".join(a.value for a in artifacts)
|
||||
lines.append(f"| {slug} | {artifact_names} |")
|
||||
lines.append("")
|
||||
|
||||
# Issues section
|
||||
if report.issues:
|
||||
lines.append("## Other Issues")
|
||||
lines.append("")
|
||||
for issue in report.issues:
|
||||
severity_icon = {"error": "🔴", "warning": "🟡", "info": "🔵"}.get(issue.severity, "⚪")
|
||||
lines.append(f"- {severity_icon} **{issue.issue_type.value}**: {issue.message}")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def _short_path(self, path: Path) -> str:
|
||||
"""Get a shortened path for display."""
|
||||
parts = path.parts
|
||||
if len(parts) > 3:
|
||||
return "/".join(parts[-3:])
|
||||
return str(path)
|
||||
|
||||
def save(self, report: StandardiseReport, output_path: Path,
|
||||
format: str = "text") -> None:
|
||||
"""Save a report to a file."""
|
||||
content = self.generate(report, format)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_text(content, encoding="utf-8")
|
||||
@@ -0,0 +1,399 @@
|
||||
"""
|
||||
Template generators for the FDEP standardisation system.
|
||||
|
||||
Generates:
|
||||
- Example files (basic and advanced)
|
||||
- Docs pages (concept, feature, cli, sdk)
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from .models import ArtifactType, FeatureSlug
|
||||
|
||||
|
||||
class TemplateGenerator:
|
||||
"""Generates template files for features."""
|
||||
|
||||
def generate(self, slug: FeatureSlug, artifact_type: ArtifactType,
|
||||
output_path: Optional[Path] = None) -> str:
|
||||
"""Generate template content for an artifact type."""
|
||||
if artifact_type == ArtifactType.EXAMPLE_BASIC:
|
||||
return self._generate_basic_example(slug)
|
||||
elif artifact_type == ArtifactType.EXAMPLE_ADVANCED:
|
||||
return self._generate_advanced_example(slug)
|
||||
elif artifact_type == ArtifactType.DOCS_CONCEPT:
|
||||
return self._generate_concept_doc(slug)
|
||||
elif artifact_type == ArtifactType.DOCS_FEATURE:
|
||||
return self._generate_feature_doc(slug)
|
||||
elif artifact_type == ArtifactType.DOCS_CLI:
|
||||
return self._generate_cli_doc(slug)
|
||||
elif artifact_type == ArtifactType.DOCS_SDK:
|
||||
return self._generate_sdk_doc(slug)
|
||||
else:
|
||||
raise ValueError(f"Unknown artifact type: {artifact_type}")
|
||||
|
||||
def _generate_basic_example(self, slug: FeatureSlug) -> str:
|
||||
"""Generate a basic example template."""
|
||||
name = slug.normalised.replace("-", " ").title()
|
||||
slug_str = slug.normalised
|
||||
|
||||
return f'''"""
|
||||
{name} - Basic Example
|
||||
|
||||
Demonstrates minimal usage of {slug_str} with a single agent.
|
||||
|
||||
Expected Output:
|
||||
Agent response demonstrating {slug_str} functionality.
|
||||
"""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
# Basic {slug_str} usage
|
||||
agent = Agent(
|
||||
instructions="You are a helpful assistant",
|
||||
# {slug_str}=... # Configure {slug_str} here
|
||||
)
|
||||
|
||||
# Run the agent
|
||||
result = agent.start("Hello! Demonstrate {slug_str} functionality.")
|
||||
print(result)
|
||||
'''
|
||||
|
||||
def _generate_advanced_example(self, slug: FeatureSlug) -> str:
|
||||
"""Generate an advanced example template."""
|
||||
name = slug.normalised.replace("-", " ").title()
|
||||
slug_str = slug.normalised
|
||||
|
||||
return f'''"""
|
||||
{name} - Advanced Example
|
||||
|
||||
Demonstrates advanced {slug_str} patterns including:
|
||||
- Multi-agent configuration
|
||||
- Custom callbacks/handlers
|
||||
- Error handling
|
||||
- All available options
|
||||
|
||||
Expected Output:
|
||||
Comprehensive demonstration of {slug_str} with multiple agents.
|
||||
"""
|
||||
from praisonaiagents import Agent, Task, PraisonAIAgents
|
||||
|
||||
# ============================================================
|
||||
# Section 1: Custom Configuration
|
||||
# ============================================================
|
||||
|
||||
# Define custom {slug_str} configuration
|
||||
# config = ...
|
||||
|
||||
# ============================================================
|
||||
# Section 2: Multi-Agent Setup
|
||||
# ============================================================
|
||||
|
||||
agent1 = Agent(
|
||||
name="Agent1",
|
||||
instructions="You are the first agent",
|
||||
# {slug_str}=config,
|
||||
)
|
||||
|
||||
agent2 = Agent(
|
||||
name="Agent2",
|
||||
instructions="You are the second agent",
|
||||
# {slug_str}=config,
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# Section 3: Task Definition
|
||||
# ============================================================
|
||||
|
||||
task = Task(
|
||||
description="Demonstrate {slug_str} with multiple agents",
|
||||
agent=agent1,
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# Section 4: Execution
|
||||
# ============================================================
|
||||
|
||||
agents = PraisonAIAgents(agents=[agent1, agent2], tasks=[task])
|
||||
result = agents.start()
|
||||
print(result)
|
||||
|
||||
# ============================================================
|
||||
# Section 5: All Options Reference
|
||||
# ============================================================
|
||||
"""
|
||||
{name} Options:
|
||||
──────────────────
|
||||
| Option | Type | Default | Description |
|
||||
|-----------------|----------|---------|----------------------|
|
||||
| option1 | str | None | Description here |
|
||||
| option2 | bool | False | Description here |
|
||||
"""
|
||||
'''
|
||||
|
||||
def _generate_concept_doc(self, slug: FeatureSlug) -> str:
|
||||
"""Generate a concept documentation template."""
|
||||
name = slug.normalised.replace("-", " ").title()
|
||||
slug_str = slug.normalised
|
||||
|
||||
return f'''---
|
||||
title: "{name}"
|
||||
description: "Understanding {slug_str} in PraisonAI"
|
||||
icon: "lightbulb"
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
{name} provides [brief description of what this feature does and why it exists].
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A[Input] --> B[{name}]
|
||||
B --> C[Output]
|
||||
```
|
||||
|
||||
## When to Use
|
||||
|
||||
<CardGroup cols={{2}}>
|
||||
<Card title="Use When" icon="check">
|
||||
- Use case 1
|
||||
- Use case 2
|
||||
</Card>
|
||||
<Card title="Don't Use When" icon="xmark">
|
||||
- Anti-pattern 1
|
||||
- Anti-pattern 2
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## How It Works
|
||||
|
||||
[Brief explanation of the mechanism, 3-5 sentences max.]
|
||||
|
||||
## Related Features
|
||||
|
||||
- [Related Feature 1](/docs/concepts/related1)
|
||||
- [Related Feature 2](/docs/concepts/related2)
|
||||
'''
|
||||
|
||||
def _generate_feature_doc(self, slug: FeatureSlug) -> str:
|
||||
"""Generate a feature documentation template."""
|
||||
name = slug.normalised.replace("-", " ").title()
|
||||
slug_str = slug.normalised
|
||||
|
||||
return f'''---
|
||||
title: "{name}"
|
||||
description: "How to use {slug_str} in PraisonAI"
|
||||
icon: "code"
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
```python
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(
|
||||
instructions="You are a helpful assistant",
|
||||
# {slug_str}=...
|
||||
)
|
||||
result = agent.start("Hello!")
|
||||
```
|
||||
|
||||
## Basic Usage
|
||||
|
||||
<Steps>
|
||||
<Step title="Step 1: Configure">
|
||||
Configure {slug_str} for your agent.
|
||||
```python
|
||||
# Configuration code
|
||||
```
|
||||
</Step>
|
||||
<Step title="Step 2: Run">
|
||||
Run the agent with {slug_str} enabled.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Advanced Patterns
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Pattern 1">
|
||||
```python
|
||||
# Pattern 1 code
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Pattern 2">
|
||||
```python
|
||||
# Pattern 2 code
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
## All Options
|
||||
|
||||
<ParamField body="option1" type="string" required>
|
||||
Description of option1.
|
||||
</ParamField>
|
||||
|
||||
<ParamField body="option2" type="boolean" default="false">
|
||||
Description of option2.
|
||||
</ParamField>
|
||||
|
||||
## See Also
|
||||
|
||||
- [CLI Reference](/docs/cli/{slug_str})
|
||||
- [API Reference](/docs/sdk/praisonaiagents/{slug_str}/{slug_str})
|
||||
'''
|
||||
|
||||
def _generate_cli_doc(self, slug: FeatureSlug) -> str:
|
||||
"""Generate a CLI documentation template."""
|
||||
name = slug.normalised.replace("-", " ").title()
|
||||
slug_str = slug.normalised
|
||||
|
||||
return f'''---
|
||||
title: "{name}"
|
||||
description: "CLI commands for {slug_str}"
|
||||
icon: "terminal"
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
praisonai "Your prompt" --{slug_str}
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### `--{slug_str}`
|
||||
|
||||
Enable {slug_str} functionality.
|
||||
|
||||
```bash
|
||||
praisonai "Your prompt" --{slug_str}
|
||||
```
|
||||
|
||||
**Expected Output:**
|
||||
```
|
||||
[Example output here]
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `PRAISONAI_{slug_str.upper().replace("-", "_")}` | Configure {slug_str} | None |
|
||||
|
||||
## Examples
|
||||
|
||||
### Basic
|
||||
|
||||
```bash
|
||||
praisonai "Hello" --{slug_str}
|
||||
```
|
||||
|
||||
### With Other Flags
|
||||
|
||||
```bash
|
||||
praisonai "Hello" --{slug_str} --verbose
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
<Accordion title="Common Issue 1">
|
||||
Solution for common issue 1.
|
||||
</Accordion>
|
||||
'''
|
||||
|
||||
def _generate_sdk_doc(self, slug: FeatureSlug) -> str:
|
||||
"""Generate an SDK documentation template."""
|
||||
name = slug.normalised.replace("-", " ").title()
|
||||
slug_str = slug.normalised
|
||||
class_name = "".join(word.title() for word in slug_str.split("-"))
|
||||
|
||||
return f'''---
|
||||
title: "{name} Module"
|
||||
description: "API reference for {slug_str}"
|
||||
icon: "code"
|
||||
---
|
||||
|
||||
# {name}
|
||||
|
||||
{name} module provides [brief description].
|
||||
|
||||
## Quick Start
|
||||
|
||||
```python
|
||||
from praisonaiagents import {class_name}
|
||||
|
||||
# Basic usage
|
||||
instance = {class_name}()
|
||||
```
|
||||
|
||||
## Classes
|
||||
|
||||
### `{class_name}`
|
||||
|
||||
Main class for {slug_str} functionality.
|
||||
|
||||
#### Constructor
|
||||
|
||||
```python
|
||||
{class_name}(
|
||||
param1: str,
|
||||
param2: bool = False
|
||||
)
|
||||
```
|
||||
|
||||
#### Parameters
|
||||
|
||||
<ParamField body="param1" type="string" required>
|
||||
Description of param1.
|
||||
</ParamField>
|
||||
|
||||
<ParamField body="param2" type="boolean" default="false">
|
||||
Description of param2.
|
||||
</ParamField>
|
||||
|
||||
#### Methods
|
||||
|
||||
##### `method_name()`
|
||||
|
||||
Description of the method.
|
||||
|
||||
```python
|
||||
result = instance.method_name(args)
|
||||
```
|
||||
|
||||
**Returns:** `ReturnType` - Description of return value.
|
||||
|
||||
## Type Definitions
|
||||
|
||||
```python
|
||||
# Type aliases used in this module
|
||||
```
|
||||
'''
|
||||
|
||||
def get_expected_path(self, slug: FeatureSlug, artifact_type: ArtifactType,
|
||||
docs_root: Optional[Path] = None,
|
||||
examples_root: Optional[Path] = None) -> Optional[Path]:
|
||||
"""Get the expected path for an artifact."""
|
||||
slug_str = slug.normalised
|
||||
|
||||
if artifact_type == ArtifactType.EXAMPLE_BASIC:
|
||||
if examples_root:
|
||||
return examples_root / slug_str / f"{slug_str}-basic.py"
|
||||
elif artifact_type == ArtifactType.EXAMPLE_ADVANCED:
|
||||
if examples_root:
|
||||
return examples_root / slug_str / f"{slug_str}-advanced.py"
|
||||
elif artifact_type == ArtifactType.DOCS_CONCEPT:
|
||||
if docs_root:
|
||||
return docs_root / "concepts" / f"{slug_str}.mdx"
|
||||
elif artifact_type == ArtifactType.DOCS_FEATURE:
|
||||
if docs_root:
|
||||
return docs_root / "features" / f"{slug_str}.mdx"
|
||||
elif artifact_type == ArtifactType.DOCS_CLI:
|
||||
if docs_root:
|
||||
return docs_root / "cli" / f"{slug_str}.mdx"
|
||||
elif artifact_type == ArtifactType.DOCS_SDK:
|
||||
if docs_root:
|
||||
return docs_root / "sdk" / "praisonaiagents" / slug_str / f"{slug_str}.mdx"
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,232 @@
|
||||
"""
|
||||
Git-based undo/redo system for the FDEP standardisation system.
|
||||
|
||||
Provides:
|
||||
- Checkpoint creation before changes
|
||||
- Undo to previous checkpoint
|
||||
- Redo to next checkpoint
|
||||
- List checkpoints
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
|
||||
class UndoRedoManager:
|
||||
"""Git-based undo/redo for standardisation changes."""
|
||||
|
||||
CHECKPOINT_PREFIX = "standardise-checkpoint-"
|
||||
|
||||
def __init__(self, repo_path: Optional[Path] = None):
|
||||
self.repo_path = repo_path or Path.cwd()
|
||||
self._verify_git_repo()
|
||||
|
||||
def _verify_git_repo(self) -> bool:
|
||||
"""Verify we're in a git repository."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--git-dir"],
|
||||
cwd=self.repo_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return result.returncode == 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _run_git(self, *args) -> Tuple[bool, str]:
|
||||
"""Run a git command."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git"] + list(args),
|
||||
cwd=self.repo_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return result.returncode == 0, result.stdout + result.stderr
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
def create_checkpoint(self, message: Optional[str] = None) -> Tuple[bool, str]:
|
||||
"""
|
||||
Create a checkpoint before making changes.
|
||||
|
||||
Returns:
|
||||
Tuple of (success, checkpoint_id or error message)
|
||||
"""
|
||||
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
checkpoint_id = f"{self.CHECKPOINT_PREFIX}{timestamp}"
|
||||
|
||||
# Stage all changes
|
||||
success, output = self._run_git("add", "-A")
|
||||
if not success:
|
||||
return False, f"Failed to stage changes: {output}"
|
||||
|
||||
# Check if there are changes to commit
|
||||
success, output = self._run_git("diff", "--cached", "--quiet")
|
||||
if success:
|
||||
# No changes to commit, just create a tag at current HEAD
|
||||
pass
|
||||
else:
|
||||
# Commit changes
|
||||
commit_msg = message or f"Standardise checkpoint: {timestamp}"
|
||||
success, output = self._run_git("commit", "-m", commit_msg)
|
||||
if not success and "nothing to commit" not in output:
|
||||
return False, f"Failed to commit: {output}"
|
||||
|
||||
# Create tag
|
||||
tag_msg = message or f"Standardise checkpoint at {timestamp}"
|
||||
success, output = self._run_git("tag", "-a", checkpoint_id, "-m", tag_msg)
|
||||
if not success:
|
||||
return False, f"Failed to create tag: {output}"
|
||||
|
||||
return True, checkpoint_id
|
||||
|
||||
def undo(self, checkpoint_id: Optional[str] = None) -> Tuple[bool, str]:
|
||||
"""
|
||||
Undo to a previous checkpoint.
|
||||
|
||||
Args:
|
||||
checkpoint_id: Specific checkpoint to restore (default: previous)
|
||||
|
||||
Returns:
|
||||
Tuple of (success, message)
|
||||
"""
|
||||
if checkpoint_id is None:
|
||||
# Find the most recent checkpoint
|
||||
checkpoints = self.list_checkpoints()
|
||||
if len(checkpoints) < 2:
|
||||
return False, "No previous checkpoint to undo to"
|
||||
checkpoint_id = checkpoints[1][0] # Second most recent
|
||||
|
||||
# Verify checkpoint exists
|
||||
success, output = self._run_git("rev-parse", checkpoint_id)
|
||||
if not success:
|
||||
return False, f"Checkpoint not found: {checkpoint_id}"
|
||||
|
||||
# Create a safety checkpoint before undoing
|
||||
self.create_checkpoint("Auto-checkpoint before undo")
|
||||
|
||||
# Reset to checkpoint
|
||||
success, output = self._run_git("reset", "--hard", checkpoint_id)
|
||||
if not success:
|
||||
return False, f"Failed to reset: {output}"
|
||||
|
||||
return True, f"Restored to checkpoint: {checkpoint_id}"
|
||||
|
||||
def redo(self) -> Tuple[bool, str]:
|
||||
"""
|
||||
Redo to the next checkpoint (after an undo).
|
||||
|
||||
Returns:
|
||||
Tuple of (success, message)
|
||||
"""
|
||||
# Get reflog to find the previous HEAD
|
||||
success, output = self._run_git("reflog", "-n", "10", "--format=%H %s")
|
||||
if not success:
|
||||
return False, "Failed to read reflog"
|
||||
|
||||
# Find the commit before the last reset
|
||||
lines = output.strip().split("\n")
|
||||
for i, line in enumerate(lines):
|
||||
if "reset: moving to" in line and i > 0:
|
||||
# The previous line has the commit we want to redo to
|
||||
target_hash = lines[i - 1].split()[0]
|
||||
success, output = self._run_git("reset", "--hard", target_hash)
|
||||
if success:
|
||||
return True, "Redo successful"
|
||||
return False, f"Failed to redo: {output}"
|
||||
|
||||
return False, "No redo available"
|
||||
|
||||
def list_checkpoints(self) -> List[Tuple[str, str, str]]:
|
||||
"""
|
||||
List all standardise checkpoints.
|
||||
|
||||
Returns:
|
||||
List of (checkpoint_id, date, message) tuples
|
||||
"""
|
||||
success, output = self._run_git(
|
||||
"tag", "-l", f"{self.CHECKPOINT_PREFIX}*",
|
||||
"--format=%(refname:short)|%(creatordate:short)|%(subject)"
|
||||
)
|
||||
|
||||
if not success:
|
||||
return []
|
||||
|
||||
checkpoints = []
|
||||
for line in output.strip().split("\n"):
|
||||
if line and "|" in line:
|
||||
parts = line.split("|", 2)
|
||||
if len(parts) >= 3:
|
||||
checkpoints.append((parts[0], parts[1], parts[2]))
|
||||
elif len(parts) == 2:
|
||||
checkpoints.append((parts[0], parts[1], ""))
|
||||
|
||||
# Sort by date descending
|
||||
checkpoints.sort(key=lambda x: x[1], reverse=True)
|
||||
return checkpoints
|
||||
|
||||
def delete_checkpoint(self, checkpoint_id: str) -> Tuple[bool, str]:
|
||||
"""Delete a checkpoint."""
|
||||
success, output = self._run_git("tag", "-d", checkpoint_id)
|
||||
if success:
|
||||
return True, f"Deleted checkpoint: {checkpoint_id}"
|
||||
return False, f"Failed to delete: {output}"
|
||||
|
||||
def get_changes_since(self, checkpoint_id: str) -> List[str]:
|
||||
"""Get list of files changed since a checkpoint."""
|
||||
success, output = self._run_git(
|
||||
"diff", "--name-only", checkpoint_id, "HEAD"
|
||||
)
|
||||
|
||||
if not success:
|
||||
return []
|
||||
|
||||
return [f for f in output.strip().split("\n") if f]
|
||||
|
||||
def preview_undo(self, checkpoint_id: str) -> List[str]:
|
||||
"""Preview what files would change if we undo to a checkpoint."""
|
||||
return self.get_changes_since(checkpoint_id)
|
||||
|
||||
|
||||
class FileBackupManager:
|
||||
"""Simple file-based backup for non-git scenarios."""
|
||||
|
||||
def __init__(self, backup_dir: Optional[Path] = None):
|
||||
self.backup_dir = backup_dir or Path(".praison/backups")
|
||||
self.backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def backup_file(self, file_path: Path) -> Optional[Path]:
|
||||
"""Create a backup of a file."""
|
||||
if not file_path.exists():
|
||||
return None
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
backup_name = f"{file_path.stem}_{timestamp}{file_path.suffix}"
|
||||
backup_path = self.backup_dir / backup_name
|
||||
|
||||
import shutil
|
||||
shutil.copy2(file_path, backup_path)
|
||||
return backup_path
|
||||
|
||||
def restore_file(self, backup_path: Path, target_path: Path) -> bool:
|
||||
"""Restore a file from backup."""
|
||||
if not backup_path.exists():
|
||||
return False
|
||||
|
||||
import shutil
|
||||
shutil.copy2(backup_path, target_path)
|
||||
return True
|
||||
|
||||
def list_backups(self, file_stem: Optional[str] = None) -> List[Path]:
|
||||
"""List all backups, optionally filtered by file stem."""
|
||||
backups = list(self.backup_dir.glob("*"))
|
||||
|
||||
if file_stem:
|
||||
backups = [b for b in backups if b.stem.startswith(file_stem)]
|
||||
|
||||
backups.sort(key=lambda x: x.stat().st_mtime, reverse=True)
|
||||
return backups
|
||||
@@ -0,0 +1,283 @@
|
||||
"""
|
||||
Artifact validation for the FDEP standardisation system.
|
||||
|
||||
Validates that features have all required artifacts:
|
||||
- docs/concepts/{slug}.mdx
|
||||
- docs/features/{slug}.mdx
|
||||
- docs/cli/{slug}.mdx
|
||||
- docs/sdk/praisonaiagents/{module}/{slug}.mdx
|
||||
- examples/python/{slug}/{slug}-basic.py
|
||||
- examples/python/{slug}/{slug}-advanced.py
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from .config import StandardiseConfig
|
||||
from .discovery import FeatureDiscovery
|
||||
from .models import (
|
||||
ArtifactPath,
|
||||
ArtifactStatus,
|
||||
ArtifactType,
|
||||
FeatureSlug,
|
||||
ValidationResult,
|
||||
)
|
||||
|
||||
|
||||
class ArtifactValidator:
|
||||
"""Validates that features have all required artifacts."""
|
||||
|
||||
def __init__(self, config: StandardiseConfig, discovery: FeatureDiscovery):
|
||||
self.config = config
|
||||
self.discovery = discovery
|
||||
|
||||
def validate_feature(self, slug: FeatureSlug) -> ValidationResult:
|
||||
"""Validate all artifacts for a single feature."""
|
||||
missing = []
|
||||
present = []
|
||||
issues = []
|
||||
|
||||
# Check each required artifact type
|
||||
for artifact_type in ArtifactType:
|
||||
if artifact_type == ArtifactType.MANIFEST:
|
||||
continue # Manifest is optional
|
||||
|
||||
artifact_path = self._get_expected_path(slug, artifact_type)
|
||||
if artifact_path is None:
|
||||
continue
|
||||
|
||||
if artifact_path.exists:
|
||||
present.append(ArtifactPath(
|
||||
artifact_type=artifact_type,
|
||||
expected_path=artifact_path.expected_path,
|
||||
actual_path=artifact_path.actual_path,
|
||||
status=ArtifactStatus.PRESENT,
|
||||
))
|
||||
else:
|
||||
missing.append(artifact_type)
|
||||
present.append(ArtifactPath(
|
||||
artifact_type=artifact_type,
|
||||
expected_path=artifact_path.expected_path,
|
||||
actual_path=None,
|
||||
status=ArtifactStatus.MISSING,
|
||||
))
|
||||
|
||||
is_valid = len(missing) == 0
|
||||
|
||||
return ValidationResult(
|
||||
slug=slug,
|
||||
is_valid=is_valid,
|
||||
missing_artifacts=missing,
|
||||
present_artifacts=present,
|
||||
issues=issues,
|
||||
)
|
||||
|
||||
def validate_all(self) -> Dict[str, ValidationResult]:
|
||||
"""Validate all discovered features."""
|
||||
results = {}
|
||||
|
||||
all_features = self.discovery.get_all_features()
|
||||
|
||||
for slug in all_features:
|
||||
if self.config.feature_filter:
|
||||
if slug.normalised != self.config.feature_filter:
|
||||
continue
|
||||
|
||||
results[slug.normalised] = self.validate_feature(slug)
|
||||
|
||||
return results
|
||||
|
||||
def get_missing_artifacts(self) -> Dict[str, List[ArtifactType]]:
|
||||
"""Get all missing artifacts across all features."""
|
||||
missing = {}
|
||||
|
||||
results = self.validate_all()
|
||||
for slug_str, result in results.items():
|
||||
if result.missing_artifacts:
|
||||
missing[slug_str] = result.missing_artifacts
|
||||
|
||||
return missing
|
||||
|
||||
def _get_expected_path(self, slug: FeatureSlug,
|
||||
artifact_type: ArtifactType) -> Optional[ArtifactPath]:
|
||||
"""Get the expected path for an artifact."""
|
||||
slug_str = slug.normalised
|
||||
|
||||
if artifact_type == ArtifactType.DOCS_CONCEPT:
|
||||
if not self.config.docs_root:
|
||||
return None
|
||||
expected = self.config.docs_root / "concepts" / f"{slug_str}.mdx"
|
||||
actual = self._find_actual_path(expected, slug, "concepts")
|
||||
return ArtifactPath(
|
||||
artifact_type=artifact_type,
|
||||
expected_path=expected,
|
||||
actual_path=actual,
|
||||
status=ArtifactStatus.PRESENT if actual else ArtifactStatus.MISSING,
|
||||
)
|
||||
|
||||
elif artifact_type == ArtifactType.DOCS_FEATURE:
|
||||
if not self.config.docs_root:
|
||||
return None
|
||||
expected = self.config.docs_root / "features" / f"{slug_str}.mdx"
|
||||
actual = self._find_actual_path(expected, slug, "features")
|
||||
return ArtifactPath(
|
||||
artifact_type=artifact_type,
|
||||
expected_path=expected,
|
||||
actual_path=actual,
|
||||
status=ArtifactStatus.PRESENT if actual else ArtifactStatus.MISSING,
|
||||
)
|
||||
|
||||
elif artifact_type == ArtifactType.DOCS_CLI:
|
||||
if not self.config.docs_root:
|
||||
return None
|
||||
expected = self.config.docs_root / "cli" / f"{slug_str}.mdx"
|
||||
actual = self._find_actual_path(expected, slug, "cli")
|
||||
return ArtifactPath(
|
||||
artifact_type=artifact_type,
|
||||
expected_path=expected,
|
||||
actual_path=actual,
|
||||
status=ArtifactStatus.PRESENT if actual else ArtifactStatus.MISSING,
|
||||
)
|
||||
|
||||
elif artifact_type == ArtifactType.DOCS_SDK:
|
||||
if not self.config.docs_root:
|
||||
return None
|
||||
# SDK docs are in docs/sdk/praisonaiagents/{module}/
|
||||
sdk_docs = self.config.docs_root / "sdk" / "praisonaiagents"
|
||||
expected = sdk_docs / slug_str / f"{slug_str}.mdx"
|
||||
actual = self._find_sdk_docs_path(slug, sdk_docs)
|
||||
return ArtifactPath(
|
||||
artifact_type=artifact_type,
|
||||
expected_path=expected,
|
||||
actual_path=actual,
|
||||
status=ArtifactStatus.PRESENT if actual else ArtifactStatus.MISSING,
|
||||
)
|
||||
|
||||
elif artifact_type == ArtifactType.EXAMPLE_BASIC:
|
||||
if not self.config.examples_root:
|
||||
return None
|
||||
expected = self.config.examples_root / slug_str / f"{slug_str}-basic.py"
|
||||
actual = self._find_example_path(slug, "basic")
|
||||
return ArtifactPath(
|
||||
artifact_type=artifact_type,
|
||||
expected_path=expected,
|
||||
actual_path=actual,
|
||||
status=ArtifactStatus.PRESENT if actual else ArtifactStatus.MISSING,
|
||||
)
|
||||
|
||||
elif artifact_type == ArtifactType.EXAMPLE_ADVANCED:
|
||||
if not self.config.examples_root:
|
||||
return None
|
||||
expected = self.config.examples_root / slug_str / f"{slug_str}-advanced.py"
|
||||
actual = self._find_example_path(slug, "advanced")
|
||||
return ArtifactPath(
|
||||
artifact_type=artifact_type,
|
||||
expected_path=expected,
|
||||
actual_path=actual,
|
||||
status=ArtifactStatus.PRESENT if actual else ArtifactStatus.MISSING,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def _find_actual_path(self, expected: Path, slug: FeatureSlug,
|
||||
doc_type: str) -> Optional[Path]:
|
||||
"""Find the actual path, checking variants."""
|
||||
if expected.exists():
|
||||
return expected
|
||||
|
||||
if not self.config.docs_root:
|
||||
return None
|
||||
|
||||
# Check variants
|
||||
slug_str = slug.normalised
|
||||
variants = [
|
||||
slug_str,
|
||||
slug_str.replace("-", "_"),
|
||||
slug_str.replace("-", ""),
|
||||
]
|
||||
|
||||
# Add singular/plural variants
|
||||
from .models import SINGULAR_PLURAL_MAP
|
||||
for singular, plural in SINGULAR_PLURAL_MAP.items():
|
||||
if slug_str == plural:
|
||||
variants.append(singular)
|
||||
elif slug_str == singular:
|
||||
variants.append(plural)
|
||||
|
||||
doc_dir = self.config.docs_root / doc_type
|
||||
if not doc_dir.exists():
|
||||
return None
|
||||
|
||||
for variant in variants:
|
||||
path = doc_dir / f"{variant}.mdx"
|
||||
if path.exists():
|
||||
return path
|
||||
|
||||
return None
|
||||
|
||||
def _find_sdk_docs_path(self, slug: FeatureSlug,
|
||||
sdk_docs: Path) -> Optional[Path]:
|
||||
"""Find SDK docs path, checking module directories."""
|
||||
if not sdk_docs.exists():
|
||||
return None
|
||||
|
||||
slug_str = slug.normalised
|
||||
variants = [
|
||||
slug_str,
|
||||
slug_str.replace("-", "_"),
|
||||
]
|
||||
|
||||
# Check for module directory
|
||||
for variant in variants:
|
||||
module_dir = sdk_docs / variant
|
||||
if module_dir.exists() and module_dir.is_dir():
|
||||
# Look for any .mdx file in the module directory
|
||||
for mdx_file in module_dir.glob("*.mdx"):
|
||||
return mdx_file
|
||||
|
||||
return None
|
||||
|
||||
def _find_example_path(self, slug: FeatureSlug,
|
||||
example_type: str) -> Optional[Path]:
|
||||
"""Find example path, checking variants."""
|
||||
if not self.config.examples_root:
|
||||
return None
|
||||
|
||||
slug_str = slug.normalised
|
||||
variants = [
|
||||
slug_str,
|
||||
slug_str.replace("-", "_"),
|
||||
]
|
||||
|
||||
for variant in variants:
|
||||
example_dir = self.config.examples_root / variant
|
||||
if not example_dir.exists():
|
||||
continue
|
||||
|
||||
# Check for basic/advanced files with various naming patterns
|
||||
patterns = [
|
||||
f"{variant}-{example_type}.py",
|
||||
f"{variant}_{example_type}.py",
|
||||
f"{example_type}.py",
|
||||
f"{example_type}_{variant}.py",
|
||||
]
|
||||
|
||||
for pattern in patterns:
|
||||
path = example_dir / pattern
|
||||
if path.exists():
|
||||
return path
|
||||
|
||||
# If looking for basic, any simple example file counts
|
||||
if example_type == "basic":
|
||||
for py_file in example_dir.glob("*.py"):
|
||||
if "advanced" not in py_file.name.lower():
|
||||
return py_file
|
||||
|
||||
# If looking for advanced, any advanced/complex example counts
|
||||
if example_type == "advanced":
|
||||
for py_file in example_dir.glob("*.py"):
|
||||
name_lower = py_file.name.lower()
|
||||
if any(x in name_lower for x in ["advanced", "complex", "multi"]):
|
||||
return py_file
|
||||
|
||||
return None
|
||||
@@ -1 +1 @@
|
||||
__version__ = "3.7.0"
|
||||
__version__ = "3.7.3"
|
||||
@@ -12,7 +12,7 @@ dependencies = [
|
||||
"rich>=13.7",
|
||||
"markdown>=3.5",
|
||||
"pyparsing>=3.0.0",
|
||||
"praisonaiagents>=0.11.0",
|
||||
"praisonaiagents>=0.11.2",
|
||||
"python-dotenv>=0.19.0",
|
||||
"instructor>=1.3.3",
|
||||
"PyYAML>=6.0",
|
||||
|
||||
@@ -15,7 +15,8 @@ import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add the praisonai package to path
|
||||
repo_root = Path(__file__).parent.parent
|
||||
# Script is in src/praisonai/scripts/, so go up 3 levels to repo root
|
||||
repo_root = Path(__file__).parent.parent.parent.parent
|
||||
sys.path.insert(0, str(repo_root / "src" / "praisonai"))
|
||||
|
||||
from praisonai._dev.api_md import generate_api_md
|
||||
|
||||
Executable
+47
@@ -0,0 +1,47 @@
|
||||
#!/bin/bash
|
||||
# CI script for standardisation checks
|
||||
# Usage: ./scripts/standardise_check.sh [--strict]
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 - No issues found
|
||||
# 1 - Issues found
|
||||
# 2 - Error running check
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
# Check if strict mode
|
||||
STRICT_MODE=false
|
||||
if [[ "$1" == "--strict" ]]; then
|
||||
STRICT_MODE=true
|
||||
fi
|
||||
|
||||
echo "🔍 Running PraisonAI Standardisation Check..."
|
||||
echo ""
|
||||
|
||||
# Run the check
|
||||
cd "$PROJECT_ROOT"
|
||||
python -m praisonai.cli.commands.standardise check --ci --path "$PROJECT_ROOT"
|
||||
EXIT_CODE=$?
|
||||
|
||||
if [[ $EXIT_CODE -eq 0 ]]; then
|
||||
echo ""
|
||||
echo "✅ Standardisation check passed!"
|
||||
exit 0
|
||||
elif [[ $EXIT_CODE -eq 1 ]]; then
|
||||
echo ""
|
||||
echo "⚠️ Standardisation issues found."
|
||||
if [[ "$STRICT_MODE" == "true" ]]; then
|
||||
echo "Failing in strict mode."
|
||||
exit 1
|
||||
else
|
||||
echo "Run 'praisonai standardise report' for details."
|
||||
exit 0 # Non-strict mode: warn but don't fail
|
||||
fi
|
||||
else
|
||||
echo ""
|
||||
echo "❌ Error running standardisation check."
|
||||
exit 2
|
||||
fi
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for CLI UI backends."""
|
||||
@@ -0,0 +1,143 @@
|
||||
"""
|
||||
Tests for UI backend selection logic.
|
||||
|
||||
Verifies:
|
||||
1. Auto-selection picks correct backend based on environment
|
||||
2. Non-TTY forces PlainBackend
|
||||
3. --json forces PlainBackend
|
||||
4. PRAISONAI_UI_SAFE=1 forces PlainBackend
|
||||
5. Explicit --ui flag overrides auto-selection
|
||||
6. Fallback chain works when deps missing
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
class TestBackendSelection:
|
||||
"""Test suite for UI backend auto-selection."""
|
||||
|
||||
def test_select_backend_returns_plain_for_non_tty(self):
|
||||
"""Non-TTY stdout should select PlainBackend."""
|
||||
from praisonai.cli.ui import select_backend, UIConfig
|
||||
|
||||
with patch.object(sys.stdout, 'isatty', return_value=False):
|
||||
config = UIConfig()
|
||||
backend = select_backend(config)
|
||||
assert backend.__class__.__name__ == 'PlainBackend'
|
||||
|
||||
def test_select_backend_returns_plain_for_json_mode(self):
|
||||
"""--json flag should force PlainBackend."""
|
||||
from praisonai.cli.ui import select_backend, UIConfig
|
||||
|
||||
config = UIConfig(json_output=True)
|
||||
backend = select_backend(config)
|
||||
assert backend.__class__.__name__ == 'PlainBackend'
|
||||
|
||||
def test_select_backend_returns_plain_for_safe_mode(self):
|
||||
"""PRAISONAI_UI_SAFE=1 should force PlainBackend."""
|
||||
from praisonai.cli.ui import select_backend, UIConfig
|
||||
|
||||
with patch.dict(os.environ, {'PRAISONAI_UI_SAFE': '1'}):
|
||||
config = UIConfig()
|
||||
backend = select_backend(config)
|
||||
assert backend.__class__.__name__ == 'PlainBackend'
|
||||
|
||||
def test_select_backend_explicit_override(self):
|
||||
"""Explicit --ui flag should override auto-selection."""
|
||||
from praisonai.cli.ui import select_backend, UIConfig
|
||||
|
||||
config = UIConfig(ui_backend='plain')
|
||||
backend = select_backend(config)
|
||||
assert backend.__class__.__name__ == 'PlainBackend'
|
||||
|
||||
def test_select_backend_auto_with_deps(self):
|
||||
"""Auto-selection with deps available should select MiddleGroundBackend."""
|
||||
from praisonai.cli.ui import select_backend, UIConfig
|
||||
|
||||
with patch.object(sys.stdout, 'isatty', return_value=True):
|
||||
with patch.dict(os.environ, {}, clear=False):
|
||||
# Remove PRAISONAI_UI_SAFE if present
|
||||
os.environ.pop('PRAISONAI_UI_SAFE', None)
|
||||
config = UIConfig()
|
||||
backend = select_backend(config)
|
||||
# Should be MiddleGroundBackend or RichBackend depending on deps
|
||||
assert backend.__class__.__name__ in ('MiddleGroundBackend', 'RichBackend', 'PlainBackend')
|
||||
|
||||
def test_select_backend_fallback_chain(self):
|
||||
"""When deps missing, should fallback gracefully."""
|
||||
from praisonai.cli.ui import select_backend, UIConfig
|
||||
|
||||
with patch.object(sys.stdout, 'isatty', return_value=True):
|
||||
config = UIConfig(ui_backend='auto')
|
||||
backend = select_backend(config)
|
||||
# Should return some valid backend
|
||||
assert hasattr(backend, 'emit')
|
||||
assert hasattr(backend, 'prompt')
|
||||
|
||||
|
||||
class TestUIConfig:
|
||||
"""Test UIConfig dataclass."""
|
||||
|
||||
def test_ui_config_defaults(self):
|
||||
"""UIConfig should have sensible defaults."""
|
||||
from praisonai.cli.ui import UIConfig
|
||||
|
||||
config = UIConfig()
|
||||
assert config.ui_backend == 'auto'
|
||||
assert config.json_output is False
|
||||
assert config.no_color is False
|
||||
assert config.theme == 'default'
|
||||
|
||||
def test_ui_config_from_cli_args(self):
|
||||
"""UIConfig should be constructible from CLI args."""
|
||||
from praisonai.cli.ui import UIConfig
|
||||
|
||||
config = UIConfig(
|
||||
ui_backend='mg',
|
||||
json_output=True,
|
||||
no_color=True,
|
||||
theme='dark',
|
||||
compact=True,
|
||||
)
|
||||
assert config.ui_backend == 'mg'
|
||||
assert config.json_output is True
|
||||
assert config.no_color is True
|
||||
assert config.theme == 'dark'
|
||||
assert config.compact is True
|
||||
|
||||
|
||||
class TestBackendProtocol:
|
||||
"""Test that backends implement the UIBackend protocol."""
|
||||
|
||||
def test_plain_backend_has_required_methods(self):
|
||||
"""PlainBackend should implement UIBackend protocol."""
|
||||
from praisonai.cli.ui.plain import PlainBackend
|
||||
|
||||
backend = PlainBackend()
|
||||
assert hasattr(backend, 'emit')
|
||||
assert hasattr(backend, 'prompt')
|
||||
assert hasattr(backend, 'is_tty')
|
||||
assert callable(backend.emit)
|
||||
assert callable(backend.prompt)
|
||||
assert callable(backend.is_tty)
|
||||
|
||||
def test_plain_backend_is_tty_returns_false_for_non_tty(self):
|
||||
"""PlainBackend.is_tty() should return False for non-TTY."""
|
||||
from praisonai.cli.ui.plain import PlainBackend
|
||||
|
||||
with patch.object(sys.stdout, 'isatty', return_value=False):
|
||||
backend = PlainBackend()
|
||||
assert backend.is_tty() is False
|
||||
|
||||
def test_plain_backend_emit_writes_to_stdout(self, capsys):
|
||||
"""PlainBackend.emit() should write to stdout."""
|
||||
from praisonai.cli.ui.plain import PlainBackend
|
||||
from praisonai.cli.ui.events import UIEventType
|
||||
|
||||
backend = PlainBackend()
|
||||
backend.emit(UIEventType.MESSAGE_CHUNK, {'content': 'Hello'})
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert 'Hello' in captured.out
|
||||
@@ -0,0 +1,178 @@
|
||||
"""
|
||||
Tests for UI event routing.
|
||||
|
||||
Verifies:
|
||||
1. Events are properly routed from InteractiveCore to backends
|
||||
2. All event types are handled correctly
|
||||
3. Streaming events work properly
|
||||
4. Multi-agent events include agent name
|
||||
"""
|
||||
|
||||
from praisonai.cli.ui.events import UIEventType, UIEvent
|
||||
from praisonai.cli.ui.plain import PlainBackend
|
||||
|
||||
|
||||
class TestUIEventType:
|
||||
"""Test UIEventType enum."""
|
||||
|
||||
def test_message_events_exist(self):
|
||||
"""Message lifecycle events should exist."""
|
||||
assert UIEventType.MESSAGE_START
|
||||
assert UIEventType.MESSAGE_CHUNK
|
||||
assert UIEventType.MESSAGE_END
|
||||
|
||||
def test_tool_events_exist(self):
|
||||
"""Tool lifecycle events should exist."""
|
||||
assert UIEventType.TOOL_START
|
||||
assert UIEventType.TOOL_PROGRESS
|
||||
assert UIEventType.TOOL_END
|
||||
|
||||
def test_approval_events_exist(self):
|
||||
"""Approval flow events should exist."""
|
||||
assert UIEventType.APPROVAL_REQUEST
|
||||
assert UIEventType.APPROVAL_RESPONSE
|
||||
|
||||
def test_status_events_exist(self):
|
||||
"""Status events should exist."""
|
||||
assert UIEventType.STATUS_UPDATE
|
||||
assert UIEventType.STATUS_CLEAR
|
||||
|
||||
def test_error_events_exist(self):
|
||||
"""Error events should exist."""
|
||||
assert UIEventType.ERROR
|
||||
assert UIEventType.WARNING
|
||||
|
||||
|
||||
class TestUIEvent:
|
||||
"""Test UIEvent dataclass."""
|
||||
|
||||
def test_event_has_timestamp(self):
|
||||
"""UIEvent should auto-generate timestamp."""
|
||||
event = UIEvent(event_type=UIEventType.MESSAGE_START)
|
||||
assert event.timestamp is not None
|
||||
assert event.timestamp > 0
|
||||
|
||||
def test_event_with_data(self):
|
||||
"""UIEvent should accept data payload."""
|
||||
event = UIEvent(
|
||||
event_type=UIEventType.MESSAGE_CHUNK,
|
||||
data={'content': 'Hello'}
|
||||
)
|
||||
assert event.data['content'] == 'Hello'
|
||||
|
||||
def test_event_with_agent_name(self):
|
||||
"""UIEvent should accept agent name for multi-agent."""
|
||||
event = UIEvent(
|
||||
event_type=UIEventType.MESSAGE_START,
|
||||
agent_name='Researcher'
|
||||
)
|
||||
assert event.agent_name == 'Researcher'
|
||||
|
||||
|
||||
class TestPlainBackendEventRouting:
|
||||
"""Test PlainBackend event handling."""
|
||||
|
||||
def test_message_start_with_agent_name(self, capsys):
|
||||
"""MESSAGE_START with agent name should print agent prefix."""
|
||||
backend = PlainBackend()
|
||||
backend.emit(UIEventType.MESSAGE_START, {'agent_name': 'Coder'})
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert '[Coder]' in captured.out
|
||||
|
||||
def test_message_chunk_appends_content(self, capsys):
|
||||
"""MESSAGE_CHUNK should append content to output."""
|
||||
backend = PlainBackend()
|
||||
backend.emit(UIEventType.MESSAGE_CHUNK, {'content': 'Hello '})
|
||||
backend.emit(UIEventType.MESSAGE_CHUNK, {'content': 'World'})
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert 'Hello World' in captured.out
|
||||
|
||||
def test_message_end_adds_newline(self, capsys):
|
||||
"""MESSAGE_END should add newline if needed."""
|
||||
backend = PlainBackend()
|
||||
backend.emit(UIEventType.MESSAGE_START, {})
|
||||
backend.emit(UIEventType.MESSAGE_CHUNK, {'content': 'Test'})
|
||||
backend.emit(UIEventType.MESSAGE_END, {})
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out.endswith('\n')
|
||||
|
||||
def test_tool_start_shows_tool_name(self, capsys):
|
||||
"""TOOL_START should show tool name."""
|
||||
backend = PlainBackend()
|
||||
backend.emit(UIEventType.TOOL_START, {'tool_name': 'read_file'})
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert 'read_file' in captured.out
|
||||
|
||||
def test_tool_end_shows_result(self, capsys):
|
||||
"""TOOL_END should show result."""
|
||||
backend = PlainBackend()
|
||||
backend.emit(UIEventType.TOOL_END, {'result': 'File contents here'})
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert 'File contents here' in captured.out
|
||||
|
||||
def test_error_writes_to_stderr(self, capsys):
|
||||
"""ERROR should write to stderr."""
|
||||
backend = PlainBackend()
|
||||
backend.emit(UIEventType.ERROR, {'message': 'Something went wrong'})
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert 'Something went wrong' in captured.err
|
||||
assert 'Error' in captured.err
|
||||
|
||||
def test_warning_writes_to_stderr(self, capsys):
|
||||
"""WARNING should write to stderr."""
|
||||
backend = PlainBackend()
|
||||
backend.emit(UIEventType.WARNING, {'message': 'Be careful'})
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert 'Be careful' in captured.err
|
||||
assert 'Warning' in captured.err
|
||||
|
||||
def test_approval_request_shows_prompt(self, capsys):
|
||||
"""APPROVAL_REQUEST should show prompt."""
|
||||
backend = PlainBackend()
|
||||
backend.emit(UIEventType.APPROVAL_REQUEST, {'prompt': 'Allow file write?'})
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert 'Allow file write?' in captured.out
|
||||
assert '[y/n]' in captured.out
|
||||
|
||||
|
||||
class TestStreamingBehavior:
|
||||
"""Test streaming message behavior."""
|
||||
|
||||
def test_streaming_preserves_partial_content(self, capsys):
|
||||
"""Streaming should preserve partial content between chunks."""
|
||||
backend = PlainBackend()
|
||||
|
||||
backend.emit(UIEventType.MESSAGE_START, {})
|
||||
backend.emit(UIEventType.MESSAGE_CHUNK, {'content': 'Hello '})
|
||||
backend.emit(UIEventType.MESSAGE_CHUNK, {'content': 'World '})
|
||||
backend.emit(UIEventType.MESSAGE_CHUNK, {'content': '!'})
|
||||
backend.emit(UIEventType.MESSAGE_END, {})
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert 'Hello World !' in captured.out
|
||||
|
||||
def test_multiple_messages_separated(self, capsys):
|
||||
"""Multiple messages should be properly separated."""
|
||||
backend = PlainBackend()
|
||||
|
||||
# First message
|
||||
backend.emit(UIEventType.MESSAGE_START, {})
|
||||
backend.emit(UIEventType.MESSAGE_CHUNK, {'content': 'First'})
|
||||
backend.emit(UIEventType.MESSAGE_END, {})
|
||||
|
||||
# Second message
|
||||
backend.emit(UIEventType.MESSAGE_START, {})
|
||||
backend.emit(UIEventType.MESSAGE_CHUNK, {'content': 'Second'})
|
||||
backend.emit(UIEventType.MESSAGE_END, {})
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert 'First' in captured.out
|
||||
assert 'Second' in captured.out
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for the standardise module."""
|
||||
@@ -0,0 +1,221 @@
|
||||
"""Tests for standardise discovery."""
|
||||
|
||||
from praisonai.standardise.config import StandardiseConfig
|
||||
from praisonai.standardise.discovery import FeatureDiscovery
|
||||
from praisonai.standardise.models import FeatureSlug
|
||||
|
||||
|
||||
class TestFeatureDiscovery:
|
||||
"""Tests for FeatureDiscovery class."""
|
||||
|
||||
def test_discover_sdk_features(self, tmp_path):
|
||||
"""Test discovering features from SDK modules."""
|
||||
# Create mock SDK structure
|
||||
sdk_root = tmp_path / "praisonaiagents"
|
||||
sdk_root.mkdir()
|
||||
|
||||
(sdk_root / "guardrails").mkdir()
|
||||
(sdk_root / "memory").mkdir()
|
||||
(sdk_root / "knowledge").mkdir()
|
||||
(sdk_root / "__pycache__").mkdir() # Should be skipped
|
||||
|
||||
config = StandardiseConfig(sdk_root=sdk_root)
|
||||
discovery = FeatureDiscovery(config)
|
||||
|
||||
features = discovery.discover_sdk_features()
|
||||
|
||||
assert len(features) == 3
|
||||
slugs = {str(f) for f in features}
|
||||
assert "guardrails" in slugs
|
||||
assert "memory" in slugs
|
||||
assert "knowledge" in slugs
|
||||
|
||||
def test_discover_cli_features(self, tmp_path):
|
||||
"""Test discovering features from CLI files."""
|
||||
# Create mock CLI structure
|
||||
cli_root = tmp_path / "cli"
|
||||
cli_root.mkdir()
|
||||
features_dir = cli_root / "features"
|
||||
features_dir.mkdir()
|
||||
|
||||
(features_dir / "guardrail.py").touch()
|
||||
(features_dir / "memory.py").touch()
|
||||
(features_dir / "__init__.py").touch() # Should be skipped
|
||||
|
||||
config = StandardiseConfig(cli_root=cli_root)
|
||||
discovery = FeatureDiscovery(config)
|
||||
|
||||
features = discovery.discover_cli_features()
|
||||
|
||||
assert len(features) == 2
|
||||
slugs = {str(f) for f in features}
|
||||
assert "guardrails" in slugs # Normalised from guardrail
|
||||
assert "memory" in slugs
|
||||
|
||||
def test_discover_docs_features(self, tmp_path):
|
||||
"""Test discovering features from docs pages."""
|
||||
# Create mock docs structure
|
||||
docs_root = tmp_path / "docs"
|
||||
docs_root.mkdir()
|
||||
|
||||
concepts = docs_root / "concepts"
|
||||
concepts.mkdir()
|
||||
(concepts / "guardrails.mdx").touch()
|
||||
(concepts / "memory.mdx").touch()
|
||||
|
||||
features = docs_root / "features"
|
||||
features.mkdir()
|
||||
(features / "knowledge.mdx").touch()
|
||||
|
||||
config = StandardiseConfig(docs_root=docs_root)
|
||||
discovery = FeatureDiscovery(config)
|
||||
|
||||
features_found = discovery.discover_docs_features()
|
||||
|
||||
slugs = {str(f) for f in features_found}
|
||||
assert "guardrails" in slugs
|
||||
assert "memory" in slugs
|
||||
assert "knowledge" in slugs
|
||||
|
||||
def test_discover_example_features(self, tmp_path):
|
||||
"""Test discovering features from examples."""
|
||||
# Create mock examples structure
|
||||
examples_root = tmp_path / "examples"
|
||||
examples_root.mkdir()
|
||||
|
||||
(examples_root / "guardrails").mkdir()
|
||||
(examples_root / "memory").mkdir()
|
||||
(examples_root / "__pycache__").mkdir() # Should be skipped
|
||||
|
||||
config = StandardiseConfig(examples_root=examples_root)
|
||||
discovery = FeatureDiscovery(config)
|
||||
|
||||
features = discovery.discover_example_features()
|
||||
|
||||
assert len(features) == 2
|
||||
slugs = {str(f) for f in features}
|
||||
assert "guardrails" in slugs
|
||||
assert "memory" in slugs
|
||||
|
||||
def test_get_all_features(self, tmp_path):
|
||||
"""Test getting union of all features."""
|
||||
# Create mock structure with overlapping features
|
||||
sdk_root = tmp_path / "sdk"
|
||||
sdk_root.mkdir()
|
||||
(sdk_root / "guardrails").mkdir()
|
||||
(sdk_root / "memory").mkdir()
|
||||
|
||||
docs_root = tmp_path / "docs"
|
||||
docs_root.mkdir()
|
||||
concepts = docs_root / "concepts"
|
||||
concepts.mkdir()
|
||||
(concepts / "guardrails.mdx").touch()
|
||||
(concepts / "knowledge.mdx").touch()
|
||||
|
||||
config = StandardiseConfig(sdk_root=sdk_root, docs_root=docs_root)
|
||||
discovery = FeatureDiscovery(config)
|
||||
|
||||
all_features = discovery.get_all_features()
|
||||
|
||||
slugs = {str(f) for f in all_features}
|
||||
assert "guardrails" in slugs
|
||||
assert "memory" in slugs
|
||||
assert "knowledge" in slugs
|
||||
|
||||
def test_get_feature_sources(self, tmp_path):
|
||||
"""Test getting sources for a specific feature."""
|
||||
# Create mock structure with explicit cli_root that doesn't have guardrails
|
||||
sdk_root = tmp_path / "sdk"
|
||||
sdk_root.mkdir()
|
||||
(sdk_root / "guardrails").mkdir()
|
||||
|
||||
docs_root = tmp_path / "docs"
|
||||
docs_root.mkdir()
|
||||
concepts = docs_root / "concepts"
|
||||
concepts.mkdir()
|
||||
(concepts / "guardrails.mdx").touch()
|
||||
|
||||
# Create empty cli root to ensure no CLI features found
|
||||
cli_root = tmp_path / "cli"
|
||||
cli_root.mkdir()
|
||||
(cli_root / "features").mkdir()
|
||||
|
||||
# Create empty examples root to ensure no examples found
|
||||
examples_root = tmp_path / "examples"
|
||||
examples_root.mkdir()
|
||||
|
||||
config = StandardiseConfig(sdk_root=sdk_root, docs_root=docs_root, cli_root=cli_root, examples_root=examples_root)
|
||||
discovery = FeatureDiscovery(config)
|
||||
|
||||
slug = FeatureSlug.from_string("guardrails")
|
||||
sources = discovery.get_feature_sources(slug)
|
||||
|
||||
assert sources["sdk"] is True
|
||||
assert sources["docs"] is True
|
||||
assert sources["cli"] is False
|
||||
assert sources["examples"] is False
|
||||
|
||||
def test_find_docs_pages(self, tmp_path):
|
||||
"""Test finding all docs pages for a feature."""
|
||||
# Create mock docs with multiple pages for same feature
|
||||
docs_root = tmp_path / "docs"
|
||||
docs_root.mkdir()
|
||||
|
||||
concepts = docs_root / "concepts"
|
||||
concepts.mkdir()
|
||||
(concepts / "guardrails.mdx").touch()
|
||||
|
||||
features = docs_root / "features"
|
||||
features.mkdir()
|
||||
(features / "guardrails.mdx").touch()
|
||||
|
||||
cli = docs_root / "cli"
|
||||
cli.mkdir()
|
||||
(cli / "guardrail.mdx").touch() # Singular variant
|
||||
|
||||
config = StandardiseConfig(docs_root=docs_root)
|
||||
discovery = FeatureDiscovery(config)
|
||||
|
||||
slug = FeatureSlug.from_string("guardrails")
|
||||
pages = discovery.find_docs_pages(slug)
|
||||
|
||||
assert len(pages) >= 2 # At least concepts and features
|
||||
|
||||
def test_find_examples(self, tmp_path):
|
||||
"""Test finding example files for a feature."""
|
||||
# Create mock examples
|
||||
examples_root = tmp_path / "examples"
|
||||
examples_root.mkdir()
|
||||
|
||||
guardrails_dir = examples_root / "guardrails"
|
||||
guardrails_dir.mkdir()
|
||||
(guardrails_dir / "guardrails-basic.py").touch()
|
||||
(guardrails_dir / "guardrails-advanced.py").touch()
|
||||
|
||||
config = StandardiseConfig(examples_root=examples_root)
|
||||
discovery = FeatureDiscovery(config)
|
||||
|
||||
slug = FeatureSlug.from_string("guardrails")
|
||||
examples = discovery.find_examples(slug)
|
||||
|
||||
assert len(examples) == 2
|
||||
|
||||
def test_caching(self, tmp_path):
|
||||
"""Test that discovery results are cached."""
|
||||
sdk_root = tmp_path / "sdk"
|
||||
sdk_root.mkdir()
|
||||
(sdk_root / "guardrails").mkdir()
|
||||
|
||||
config = StandardiseConfig(sdk_root=sdk_root)
|
||||
discovery = FeatureDiscovery(config)
|
||||
|
||||
# First call
|
||||
features1 = discovery.discover_sdk_features()
|
||||
|
||||
# Add another directory
|
||||
(sdk_root / "memory").mkdir()
|
||||
|
||||
# Second call should return cached result
|
||||
features2 = discovery.discover_sdk_features()
|
||||
|
||||
assert features1 is features2 # Same object (cached)
|
||||
@@ -0,0 +1,210 @@
|
||||
"""Tests for standardise engine."""
|
||||
|
||||
from praisonai.standardise.config import StandardiseConfig
|
||||
from praisonai.standardise.engine import StandardiseEngine
|
||||
|
||||
|
||||
class TestStandardiseEngine:
|
||||
"""Tests for StandardiseEngine class."""
|
||||
|
||||
def test_check_returns_report(self, tmp_path):
|
||||
"""Test that check returns a valid report."""
|
||||
# Create minimal structure
|
||||
sdk_root = tmp_path / "sdk"
|
||||
sdk_root.mkdir()
|
||||
(sdk_root / "guardrails").mkdir()
|
||||
|
||||
config = StandardiseConfig(
|
||||
project_root=tmp_path,
|
||||
sdk_root=sdk_root,
|
||||
)
|
||||
engine = StandardiseEngine(config)
|
||||
|
||||
report = engine.check()
|
||||
|
||||
assert report.timestamp
|
||||
assert report.features_scanned >= 1
|
||||
|
||||
def test_report_text_format(self, tmp_path):
|
||||
"""Test text report generation."""
|
||||
config = StandardiseConfig(project_root=tmp_path)
|
||||
engine = StandardiseEngine(config)
|
||||
|
||||
output = engine.report(format="text")
|
||||
|
||||
assert "Standardisation" in output
|
||||
assert "Summary" in output
|
||||
|
||||
def test_report_json_format(self, tmp_path):
|
||||
"""Test JSON report generation."""
|
||||
config = StandardiseConfig(project_root=tmp_path)
|
||||
engine = StandardiseEngine(config)
|
||||
|
||||
output = engine.report(format="json")
|
||||
|
||||
import json
|
||||
data = json.loads(output)
|
||||
assert "timestamp" in data
|
||||
assert "summary" in data
|
||||
|
||||
def test_fix_dry_run(self, tmp_path):
|
||||
"""Test fix in dry-run mode."""
|
||||
sdk_root = tmp_path / "sdk"
|
||||
sdk_root.mkdir()
|
||||
(sdk_root / "guardrails").mkdir()
|
||||
|
||||
docs_root = tmp_path / "docs"
|
||||
docs_root.mkdir()
|
||||
|
||||
config = StandardiseConfig(
|
||||
project_root=tmp_path,
|
||||
sdk_root=sdk_root,
|
||||
docs_root=docs_root,
|
||||
dry_run=True,
|
||||
)
|
||||
engine = StandardiseEngine(config)
|
||||
|
||||
actions = engine.fix(apply=False)
|
||||
|
||||
# Should have planned actions but no applied
|
||||
assert len(actions["applied"]) == 0
|
||||
|
||||
def test_fix_apply(self, tmp_path):
|
||||
"""Test fix with apply."""
|
||||
sdk_root = tmp_path / "sdk"
|
||||
sdk_root.mkdir()
|
||||
(sdk_root / "guardrails").mkdir()
|
||||
|
||||
docs_root = tmp_path / "docs"
|
||||
docs_root.mkdir()
|
||||
(docs_root / "concepts").mkdir()
|
||||
(docs_root / "features").mkdir()
|
||||
(docs_root / "cli").mkdir()
|
||||
|
||||
examples_root = tmp_path / "examples"
|
||||
examples_root.mkdir()
|
||||
|
||||
config = StandardiseConfig(
|
||||
project_root=tmp_path,
|
||||
sdk_root=sdk_root,
|
||||
docs_root=docs_root,
|
||||
examples_root=examples_root,
|
||||
dry_run=False,
|
||||
backup=False,
|
||||
)
|
||||
engine = StandardiseEngine(config)
|
||||
|
||||
actions = engine.fix(feature="guardrails", apply=True)
|
||||
|
||||
# Should have applied some actions
|
||||
assert len(actions["applied"]) > 0
|
||||
|
||||
def test_init_feature(self, tmp_path):
|
||||
"""Test initialising a new feature."""
|
||||
docs_root = tmp_path / "docs"
|
||||
docs_root.mkdir()
|
||||
(docs_root / "concepts").mkdir()
|
||||
(docs_root / "features").mkdir()
|
||||
(docs_root / "cli").mkdir()
|
||||
|
||||
examples_root = tmp_path / "examples"
|
||||
examples_root.mkdir()
|
||||
|
||||
config = StandardiseConfig(
|
||||
project_root=tmp_path,
|
||||
docs_root=docs_root,
|
||||
examples_root=examples_root,
|
||||
dry_run=False,
|
||||
)
|
||||
engine = StandardiseEngine(config)
|
||||
|
||||
created = engine.init("my-new-feature")
|
||||
|
||||
# Should create multiple artifacts
|
||||
assert len(created) > 0
|
||||
assert "docs_concept" in created or "docs_feature" in created
|
||||
|
||||
def test_init_invalid_slug(self, tmp_path):
|
||||
"""Test init with invalid slug raises error."""
|
||||
config = StandardiseConfig(project_root=tmp_path)
|
||||
engine = StandardiseEngine(config)
|
||||
|
||||
try:
|
||||
engine.init("123invalid")
|
||||
assert False, "Should have raised ValueError"
|
||||
except ValueError as e:
|
||||
assert "letter" in str(e).lower()
|
||||
|
||||
def test_get_exit_code_no_issues(self, tmp_path):
|
||||
"""Test exit code when no issues."""
|
||||
config = StandardiseConfig(project_root=tmp_path)
|
||||
engine = StandardiseEngine(config)
|
||||
|
||||
report = engine.check()
|
||||
|
||||
# With empty project, might have no issues
|
||||
exit_code = engine.get_exit_code(report)
|
||||
assert exit_code in [0, 1] # Either no issues or issues found
|
||||
|
||||
def test_backup_creation(self, tmp_path):
|
||||
"""Test that backups are created."""
|
||||
docs_root = tmp_path / "docs"
|
||||
docs_root.mkdir()
|
||||
concepts = docs_root / "concepts"
|
||||
concepts.mkdir()
|
||||
|
||||
# Create existing file
|
||||
existing = concepts / "guardrails.mdx"
|
||||
existing.write_text("existing content")
|
||||
|
||||
config = StandardiseConfig(
|
||||
project_root=tmp_path,
|
||||
docs_root=docs_root,
|
||||
dry_run=False,
|
||||
backup=True,
|
||||
)
|
||||
engine = StandardiseEngine(config)
|
||||
|
||||
# Trigger backup
|
||||
backup_path = engine._backup_file(existing)
|
||||
|
||||
assert backup_path.exists()
|
||||
assert "guardrails" in backup_path.name
|
||||
|
||||
|
||||
class TestStandardiseConfig:
|
||||
"""Tests for StandardiseConfig class."""
|
||||
|
||||
def test_default_values(self):
|
||||
"""Test default configuration values."""
|
||||
config = StandardiseConfig()
|
||||
|
||||
assert config.dry_run is True
|
||||
assert config.backup is True
|
||||
assert config.force is False
|
||||
assert config.scope == "all"
|
||||
assert config.dedupe_mode == "prompt"
|
||||
|
||||
def test_auto_detect_paths(self, tmp_path):
|
||||
"""Test auto-detection of paths."""
|
||||
# Create structure
|
||||
(tmp_path / ".praison").mkdir()
|
||||
docs = tmp_path / "docs"
|
||||
docs.mkdir()
|
||||
examples = tmp_path / "examples" / "python"
|
||||
examples.mkdir(parents=True)
|
||||
|
||||
config = StandardiseConfig(project_root=tmp_path)
|
||||
|
||||
assert config.docs_root == docs
|
||||
assert config.examples_root == examples
|
||||
|
||||
def test_from_env(self, monkeypatch):
|
||||
"""Test loading config from environment."""
|
||||
monkeypatch.setenv("PRAISON_DRY_RUN", "false")
|
||||
monkeypatch.setenv("CI", "true")
|
||||
|
||||
config = StandardiseConfig.from_env()
|
||||
|
||||
assert config.dry_run is False
|
||||
assert config.ci_mode is True
|
||||
@@ -0,0 +1,121 @@
|
||||
"""
|
||||
Unit tests for the ExampleVerifier.
|
||||
"""
|
||||
|
||||
from praisonai.standardise.example_verifier import ExampleVerifier, VerificationResult
|
||||
|
||||
|
||||
class TestExampleVerifier:
|
||||
"""Tests for ExampleVerifier."""
|
||||
|
||||
def test_valid_simple_code(self):
|
||||
"""Test verification of valid simple code."""
|
||||
verifier = ExampleVerifier(timeout=10)
|
||||
code = '''
|
||||
print("Hello, World!")
|
||||
'''
|
||||
result = verifier.verify(code)
|
||||
|
||||
assert result.syntax_valid
|
||||
assert result.execution_passed
|
||||
assert result.success
|
||||
assert result.can_write
|
||||
|
||||
def test_syntax_error(self):
|
||||
"""Test detection of syntax errors."""
|
||||
verifier = ExampleVerifier(timeout=10)
|
||||
code = '''
|
||||
def broken(
|
||||
print("missing closing paren"
|
||||
'''
|
||||
result = verifier.verify(code)
|
||||
|
||||
assert not result.syntax_valid
|
||||
assert not result.success
|
||||
assert not result.can_write
|
||||
assert "Syntax error" in result.error
|
||||
|
||||
def test_runtime_error(self):
|
||||
"""Test detection of runtime errors."""
|
||||
verifier = ExampleVerifier(timeout=10)
|
||||
code = '''
|
||||
x = 1 / 0 # Division by zero
|
||||
'''
|
||||
result = verifier.verify(code)
|
||||
|
||||
assert result.syntax_valid
|
||||
assert not result.execution_passed
|
||||
assert not result.success
|
||||
assert "ZeroDivisionError" in result.error
|
||||
|
||||
def test_missing_import(self):
|
||||
"""Test detection of missing imports."""
|
||||
verifier = ExampleVerifier(timeout=10)
|
||||
code = '''
|
||||
import nonexistent_module_xyz
|
||||
'''
|
||||
result = verifier.verify(code)
|
||||
|
||||
assert result.syntax_valid
|
||||
assert not result.execution_passed
|
||||
assert "nonexistent_module_xyz" in result.missing_libraries
|
||||
|
||||
def test_external_library_detection(self):
|
||||
"""Test detection of external library requirements."""
|
||||
verifier = ExampleVerifier(timeout=10)
|
||||
code = '''
|
||||
import pandas
|
||||
df = pandas.DataFrame()
|
||||
'''
|
||||
result = verifier.verify(code)
|
||||
|
||||
assert result.syntax_valid
|
||||
# May or may not pass depending on if pandas is installed
|
||||
# But should detect pandas as external
|
||||
assert "pandas" in result.missing_libraries or result.execution_passed
|
||||
|
||||
def test_can_write_with_external_libs(self):
|
||||
"""Test that code requiring external libs can still be written."""
|
||||
# Create a result that simulates external lib requirement
|
||||
result = VerificationResult(
|
||||
success=True,
|
||||
syntax_valid=True,
|
||||
execution_passed=False,
|
||||
output="",
|
||||
error="ModuleNotFoundError: No module named 'requests'",
|
||||
missing_libraries=["requests"],
|
||||
requires_external=True,
|
||||
)
|
||||
|
||||
assert result.can_write # Should allow writing despite execution failure
|
||||
|
||||
def test_timeout_handling(self):
|
||||
"""Test that infinite loops are handled."""
|
||||
verifier = ExampleVerifier(timeout=2)
|
||||
code = '''
|
||||
import time
|
||||
time.sleep(10) # Will timeout
|
||||
'''
|
||||
result = verifier.verify(code)
|
||||
|
||||
assert result.syntax_valid
|
||||
assert not result.execution_passed
|
||||
assert "timed out" in result.error.lower()
|
||||
|
||||
def test_format_result(self):
|
||||
"""Test result formatting."""
|
||||
verifier = ExampleVerifier()
|
||||
|
||||
result = VerificationResult(
|
||||
success=True,
|
||||
syntax_valid=True,
|
||||
execution_passed=True,
|
||||
output="Hello",
|
||||
error="",
|
||||
missing_libraries=[],
|
||||
requires_external=False,
|
||||
)
|
||||
|
||||
formatted = verifier.format_result(result)
|
||||
assert "PASSED" in formatted
|
||||
assert "Syntax valid: ✓" in formatted
|
||||
@@ -0,0 +1,168 @@
|
||||
"""Tests for standardise models."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from praisonai.standardise.models import (
|
||||
FeatureSlug,
|
||||
ArtifactType,
|
||||
ArtifactStatus,
|
||||
SINGULAR_PLURAL_MAP,
|
||||
LEGACY_ALIASES,
|
||||
)
|
||||
|
||||
|
||||
class TestFeatureSlug:
|
||||
"""Tests for FeatureSlug class."""
|
||||
|
||||
def test_from_string_simple(self):
|
||||
"""Test simple slug creation."""
|
||||
slug = FeatureSlug.from_string("guardrails")
|
||||
assert slug.normalised == "guardrails"
|
||||
assert slug.is_valid
|
||||
assert slug.validation_error is None
|
||||
|
||||
def test_from_string_with_underscores(self):
|
||||
"""Test slug with underscores converts to hyphens."""
|
||||
slug = FeatureSlug.from_string("my_feature")
|
||||
assert slug.normalised == "my-feature"
|
||||
assert slug.is_valid
|
||||
|
||||
def test_from_string_uppercase(self):
|
||||
"""Test uppercase is converted to lowercase."""
|
||||
slug = FeatureSlug.from_string("MyFeature")
|
||||
assert slug.normalised == "myfeature"
|
||||
assert slug.is_valid
|
||||
|
||||
def test_from_string_with_extension(self):
|
||||
"""Test file extensions are stripped."""
|
||||
slug = FeatureSlug.from_string("guardrails.py")
|
||||
assert slug.normalised == "guardrails"
|
||||
|
||||
slug = FeatureSlug.from_string("guardrails.mdx")
|
||||
assert slug.normalised == "guardrails"
|
||||
|
||||
def test_singular_to_plural_normalisation(self):
|
||||
"""Test singular forms are normalised to plural."""
|
||||
slug = FeatureSlug.from_string("guardrail")
|
||||
assert slug.normalised == "guardrails"
|
||||
|
||||
slug = FeatureSlug.from_string("agent")
|
||||
assert slug.normalised == "agents"
|
||||
|
||||
slug = FeatureSlug.from_string("task")
|
||||
assert slug.normalised == "tasks"
|
||||
|
||||
def test_legacy_alias_normalisation(self):
|
||||
"""Test legacy aliases are normalised."""
|
||||
slug = FeatureSlug.from_string("rag")
|
||||
assert slug.normalised == "knowledge"
|
||||
|
||||
slug = FeatureSlug.from_string("retrieval")
|
||||
assert slug.normalised == "knowledge"
|
||||
|
||||
def test_invalid_slug_empty(self):
|
||||
"""Test empty slug is invalid."""
|
||||
slug = FeatureSlug.from_string("")
|
||||
assert not slug.is_valid
|
||||
assert "empty" in slug.validation_error.lower()
|
||||
|
||||
def test_invalid_slug_starts_with_number(self):
|
||||
"""Test slug starting with number is invalid."""
|
||||
slug = FeatureSlug.from_string("123feature")
|
||||
assert not slug.is_valid
|
||||
assert "letter" in slug.validation_error.lower()
|
||||
|
||||
def test_invalid_slug_special_chars(self):
|
||||
"""Test slug with special characters is invalid."""
|
||||
slug = FeatureSlug.from_string("my@feature")
|
||||
assert not slug.is_valid
|
||||
|
||||
def test_from_path_file(self):
|
||||
"""Test slug from file path."""
|
||||
path = Path("/some/path/guardrails.py")
|
||||
slug = FeatureSlug.from_path(path, "sdk")
|
||||
assert slug.normalised == "guardrails"
|
||||
|
||||
def test_from_path_directory(self):
|
||||
"""Test slug from directory path."""
|
||||
path = Path("/some/path/guardrails")
|
||||
slug = FeatureSlug.from_path(path, "sdk")
|
||||
assert slug.normalised == "guardrails"
|
||||
|
||||
def test_equality(self):
|
||||
"""Test slug equality."""
|
||||
slug1 = FeatureSlug.from_string("guardrails")
|
||||
slug2 = FeatureSlug.from_string("guardrail") # Normalises to guardrails
|
||||
assert slug1 == slug2
|
||||
|
||||
slug3 = FeatureSlug.from_string("memory")
|
||||
assert slug1 != slug3
|
||||
|
||||
def test_equality_with_string(self):
|
||||
"""Test slug equality with string."""
|
||||
slug = FeatureSlug.from_string("guardrails")
|
||||
assert slug == "guardrails"
|
||||
assert slug == "GUARDRAILS" # Case insensitive
|
||||
|
||||
def test_hash(self):
|
||||
"""Test slug hashing for use in sets/dicts."""
|
||||
slug1 = FeatureSlug.from_string("guardrails")
|
||||
slug2 = FeatureSlug.from_string("guardrail")
|
||||
|
||||
# Should have same hash since they normalise to same value
|
||||
assert hash(slug1) == hash(slug2)
|
||||
|
||||
# Can be used in sets
|
||||
slug_set = {slug1, slug2}
|
||||
assert len(slug_set) == 1
|
||||
|
||||
def test_str(self):
|
||||
"""Test string representation."""
|
||||
slug = FeatureSlug.from_string("guardrails")
|
||||
assert str(slug) == "guardrails"
|
||||
|
||||
|
||||
class TestArtifactType:
|
||||
"""Tests for ArtifactType enum."""
|
||||
|
||||
def test_all_types_exist(self):
|
||||
"""Test all required artifact types exist."""
|
||||
assert ArtifactType.DOCS_CONCEPT
|
||||
assert ArtifactType.DOCS_FEATURE
|
||||
assert ArtifactType.DOCS_CLI
|
||||
assert ArtifactType.DOCS_SDK
|
||||
assert ArtifactType.EXAMPLE_BASIC
|
||||
assert ArtifactType.EXAMPLE_ADVANCED
|
||||
assert ArtifactType.MANIFEST
|
||||
|
||||
def test_values(self):
|
||||
"""Test artifact type values."""
|
||||
assert ArtifactType.DOCS_CONCEPT.value == "docs_concept"
|
||||
assert ArtifactType.EXAMPLE_BASIC.value == "example_basic"
|
||||
|
||||
|
||||
class TestArtifactStatus:
|
||||
"""Tests for ArtifactStatus enum."""
|
||||
|
||||
def test_all_statuses_exist(self):
|
||||
"""Test all statuses exist."""
|
||||
assert ArtifactStatus.PRESENT
|
||||
assert ArtifactStatus.MISSING
|
||||
assert ArtifactStatus.DUPLICATE
|
||||
assert ArtifactStatus.OUTDATED
|
||||
|
||||
|
||||
class TestMappings:
|
||||
"""Tests for slug mapping constants."""
|
||||
|
||||
def test_singular_plural_map_has_common_terms(self):
|
||||
"""Test common terms are in the mapping."""
|
||||
assert "agent" in SINGULAR_PLURAL_MAP
|
||||
assert "task" in SINGULAR_PLURAL_MAP
|
||||
assert "guardrail" in SINGULAR_PLURAL_MAP
|
||||
assert "handoff" in SINGULAR_PLURAL_MAP
|
||||
|
||||
def test_legacy_aliases_has_common_aliases(self):
|
||||
"""Test common aliases are in the mapping."""
|
||||
assert "rag" in LEGACY_ALIASES
|
||||
assert "retrieval" in LEGACY_ALIASES
|
||||
@@ -0,0 +1,207 @@
|
||||
"""
|
||||
Unit tests for the undo/redo system.
|
||||
"""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from praisonai.standardise.undo_redo import UndoRedoManager, FileBackupManager
|
||||
|
||||
|
||||
class TestUndoRedoManager:
|
||||
"""Tests for UndoRedoManager."""
|
||||
|
||||
def test_init_with_path(self, tmp_path):
|
||||
"""Test initialization with a path."""
|
||||
manager = UndoRedoManager(tmp_path)
|
||||
assert manager.repo_path == tmp_path
|
||||
|
||||
def test_checkpoint_prefix(self):
|
||||
"""Test checkpoint prefix constant."""
|
||||
assert UndoRedoManager.CHECKPOINT_PREFIX == "standardise-checkpoint-"
|
||||
|
||||
@patch.object(UndoRedoManager, '_run_git')
|
||||
def test_create_checkpoint_success(self, mock_git, tmp_path):
|
||||
"""Test successful checkpoint creation."""
|
||||
mock_git.side_effect = [
|
||||
(True, ""), # git add
|
||||
(False, "changes"), # git diff (has changes)
|
||||
(True, ""), # git commit
|
||||
(True, ""), # git tag
|
||||
]
|
||||
|
||||
manager = UndoRedoManager(tmp_path)
|
||||
success, result = manager.create_checkpoint("Test checkpoint")
|
||||
|
||||
assert success
|
||||
assert result.startswith("standardise-checkpoint-")
|
||||
|
||||
@patch.object(UndoRedoManager, '_run_git')
|
||||
def test_create_checkpoint_no_changes(self, mock_git, tmp_path):
|
||||
"""Test checkpoint creation with no changes."""
|
||||
mock_git.side_effect = [
|
||||
(True, ""), # git add
|
||||
(True, ""), # git diff (no changes)
|
||||
(True, ""), # git tag
|
||||
]
|
||||
|
||||
manager = UndoRedoManager(tmp_path)
|
||||
success, result = manager.create_checkpoint()
|
||||
|
||||
assert success
|
||||
|
||||
@patch.object(UndoRedoManager, '_run_git')
|
||||
def test_list_checkpoints(self, mock_git, tmp_path):
|
||||
"""Test listing checkpoints."""
|
||||
mock_git.return_value = (True, "standardise-checkpoint-20240101|2024-01-01|Test message\n")
|
||||
|
||||
manager = UndoRedoManager(tmp_path)
|
||||
checkpoints = manager.list_checkpoints()
|
||||
|
||||
assert len(checkpoints) == 1
|
||||
assert checkpoints[0][0] == "standardise-checkpoint-20240101"
|
||||
assert checkpoints[0][1] == "2024-01-01"
|
||||
assert checkpoints[0][2] == "Test message"
|
||||
|
||||
@patch.object(UndoRedoManager, '_run_git')
|
||||
def test_list_checkpoints_empty(self, mock_git, tmp_path):
|
||||
"""Test listing checkpoints when none exist."""
|
||||
mock_git.return_value = (True, "")
|
||||
|
||||
manager = UndoRedoManager(tmp_path)
|
||||
checkpoints = manager.list_checkpoints()
|
||||
|
||||
assert checkpoints == []
|
||||
|
||||
@patch.object(UndoRedoManager, '_run_git')
|
||||
def test_undo_success(self, mock_git, tmp_path):
|
||||
"""Test successful undo."""
|
||||
mock_git.side_effect = [
|
||||
(True, ""), # rev-parse (checkpoint exists)
|
||||
(True, ""), # git add (safety checkpoint)
|
||||
(True, ""), # git diff
|
||||
(True, ""), # git tag
|
||||
(True, ""), # git reset
|
||||
]
|
||||
|
||||
manager = UndoRedoManager(tmp_path)
|
||||
success, result = manager.undo("standardise-checkpoint-test")
|
||||
|
||||
assert success
|
||||
assert "Restored" in result
|
||||
|
||||
@patch.object(UndoRedoManager, '_run_git')
|
||||
def test_undo_checkpoint_not_found(self, mock_git, tmp_path):
|
||||
"""Test undo with non-existent checkpoint."""
|
||||
mock_git.return_value = (False, "not found")
|
||||
|
||||
manager = UndoRedoManager(tmp_path)
|
||||
success, result = manager.undo("nonexistent")
|
||||
|
||||
assert not success
|
||||
assert "not found" in result
|
||||
|
||||
@patch.object(UndoRedoManager, '_run_git')
|
||||
def test_redo_no_redo_available(self, mock_git, tmp_path):
|
||||
"""Test redo when no redo is available."""
|
||||
mock_git.return_value = (True, "abc123 HEAD@{0}: commit: test\n")
|
||||
|
||||
manager = UndoRedoManager(tmp_path)
|
||||
success, result = manager.redo()
|
||||
|
||||
assert not success
|
||||
assert "No redo available" in result
|
||||
|
||||
@patch.object(UndoRedoManager, '_run_git')
|
||||
def test_delete_checkpoint(self, mock_git, tmp_path):
|
||||
"""Test deleting a checkpoint."""
|
||||
mock_git.return_value = (True, "")
|
||||
|
||||
manager = UndoRedoManager(tmp_path)
|
||||
success, result = manager.delete_checkpoint("test-checkpoint")
|
||||
|
||||
assert success
|
||||
assert "Deleted" in result
|
||||
|
||||
@patch.object(UndoRedoManager, '_run_git')
|
||||
def test_get_changes_since(self, mock_git, tmp_path):
|
||||
"""Test getting changes since a checkpoint."""
|
||||
mock_git.return_value = (True, "file1.py\nfile2.py\n")
|
||||
|
||||
manager = UndoRedoManager(tmp_path)
|
||||
changes = manager.get_changes_since("test-checkpoint")
|
||||
|
||||
assert len(changes) == 2
|
||||
assert "file1.py" in changes
|
||||
assert "file2.py" in changes
|
||||
|
||||
|
||||
class TestFileBackupManager:
|
||||
"""Tests for FileBackupManager."""
|
||||
|
||||
def test_init_creates_backup_dir(self, tmp_path):
|
||||
"""Test that init creates backup directory."""
|
||||
backup_dir = tmp_path / "backups"
|
||||
_ = FileBackupManager(backup_dir) # Creates dir on init
|
||||
|
||||
assert backup_dir.exists()
|
||||
|
||||
def test_backup_file(self, tmp_path):
|
||||
"""Test backing up a file."""
|
||||
# Create a test file
|
||||
test_file = tmp_path / "test.txt"
|
||||
test_file.write_text("test content")
|
||||
|
||||
backup_dir = tmp_path / "backups"
|
||||
manager = FileBackupManager(backup_dir)
|
||||
|
||||
backup_path = manager.backup_file(test_file)
|
||||
|
||||
assert backup_path is not None
|
||||
assert backup_path.exists()
|
||||
assert backup_path.read_text() == "test content"
|
||||
|
||||
def test_backup_nonexistent_file(self, tmp_path):
|
||||
"""Test backing up a non-existent file."""
|
||||
manager = FileBackupManager(tmp_path / "backups")
|
||||
|
||||
result = manager.backup_file(tmp_path / "nonexistent.txt")
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_restore_file(self, tmp_path):
|
||||
"""Test restoring a file from backup."""
|
||||
# Create backup
|
||||
backup_dir = tmp_path / "backups"
|
||||
backup_dir.mkdir()
|
||||
backup_file = backup_dir / "backup.txt"
|
||||
backup_file.write_text("backup content")
|
||||
|
||||
# Target file
|
||||
target_file = tmp_path / "restored.txt"
|
||||
|
||||
manager = FileBackupManager(backup_dir)
|
||||
result = manager.restore_file(backup_file, target_file)
|
||||
|
||||
assert result
|
||||
assert target_file.exists()
|
||||
assert target_file.read_text() == "backup content"
|
||||
|
||||
def test_list_backups(self, tmp_path):
|
||||
"""Test listing backups."""
|
||||
backup_dir = tmp_path / "backups"
|
||||
backup_dir.mkdir()
|
||||
|
||||
# Create some backup files
|
||||
(backup_dir / "file1_20240101.txt").write_text("1")
|
||||
(backup_dir / "file1_20240102.txt").write_text("2")
|
||||
(backup_dir / "file2_20240101.txt").write_text("3")
|
||||
|
||||
backup_manager = FileBackupManager(backup_dir)
|
||||
|
||||
# List all
|
||||
all_backups = backup_manager.list_backups()
|
||||
assert len(all_backups) == 3
|
||||
|
||||
# List filtered
|
||||
file1_backups = backup_manager.list_backups("file1")
|
||||
assert len(file1_backups) == 2
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Tests for standardise validator."""
|
||||
|
||||
from praisonai.standardise.config import StandardiseConfig
|
||||
from praisonai.standardise.discovery import FeatureDiscovery
|
||||
from praisonai.standardise.validator import ArtifactValidator
|
||||
from praisonai.standardise.models import ArtifactType, FeatureSlug
|
||||
|
||||
|
||||
class TestArtifactValidator:
|
||||
"""Tests for ArtifactValidator class."""
|
||||
|
||||
def test_validate_feature_all_present(self, tmp_path):
|
||||
"""Test validation when all artifacts are present."""
|
||||
# Create complete feature structure
|
||||
docs_root = tmp_path / "docs"
|
||||
docs_root.mkdir()
|
||||
|
||||
(docs_root / "concepts").mkdir()
|
||||
(docs_root / "concepts" / "guardrails.mdx").touch()
|
||||
|
||||
(docs_root / "features").mkdir()
|
||||
(docs_root / "features" / "guardrails.mdx").touch()
|
||||
|
||||
(docs_root / "cli").mkdir()
|
||||
(docs_root / "cli" / "guardrails.mdx").touch()
|
||||
|
||||
sdk_docs = docs_root / "sdk" / "praisonaiagents" / "guardrails"
|
||||
sdk_docs.mkdir(parents=True)
|
||||
(sdk_docs / "guardrails.mdx").touch()
|
||||
|
||||
examples_root = tmp_path / "examples"
|
||||
examples_root.mkdir()
|
||||
guardrails_examples = examples_root / "guardrails"
|
||||
guardrails_examples.mkdir()
|
||||
(guardrails_examples / "guardrails-basic.py").touch()
|
||||
(guardrails_examples / "guardrails-advanced.py").touch()
|
||||
|
||||
config = StandardiseConfig(docs_root=docs_root, examples_root=examples_root)
|
||||
discovery = FeatureDiscovery(config)
|
||||
validator = ArtifactValidator(config, discovery)
|
||||
|
||||
slug = FeatureSlug.from_string("guardrails")
|
||||
result = validator.validate_feature(slug)
|
||||
|
||||
assert result.is_valid
|
||||
assert len(result.missing_artifacts) == 0
|
||||
|
||||
def test_validate_feature_missing_artifacts(self, tmp_path):
|
||||
"""Test validation when some artifacts are missing."""
|
||||
# Create partial feature structure
|
||||
docs_root = tmp_path / "docs"
|
||||
docs_root.mkdir()
|
||||
|
||||
(docs_root / "concepts").mkdir()
|
||||
(docs_root / "concepts" / "guardrails.mdx").touch()
|
||||
|
||||
# Missing: features, cli, sdk docs, examples
|
||||
|
||||
config = StandardiseConfig(docs_root=docs_root)
|
||||
discovery = FeatureDiscovery(config)
|
||||
validator = ArtifactValidator(config, discovery)
|
||||
|
||||
slug = FeatureSlug.from_string("guardrails")
|
||||
result = validator.validate_feature(slug)
|
||||
|
||||
assert not result.is_valid
|
||||
assert len(result.missing_artifacts) > 0
|
||||
assert ArtifactType.DOCS_FEATURE in result.missing_artifacts
|
||||
|
||||
def test_validate_all(self, tmp_path):
|
||||
"""Test validating all discovered features."""
|
||||
# Create structure with multiple features
|
||||
sdk_root = tmp_path / "sdk"
|
||||
sdk_root.mkdir()
|
||||
(sdk_root / "guardrails").mkdir()
|
||||
(sdk_root / "memory").mkdir()
|
||||
|
||||
docs_root = tmp_path / "docs"
|
||||
docs_root.mkdir()
|
||||
(docs_root / "concepts").mkdir()
|
||||
(docs_root / "concepts" / "guardrails.mdx").touch()
|
||||
# memory has no docs
|
||||
|
||||
config = StandardiseConfig(sdk_root=sdk_root, docs_root=docs_root)
|
||||
discovery = FeatureDiscovery(config)
|
||||
validator = ArtifactValidator(config, discovery)
|
||||
|
||||
results = validator.validate_all()
|
||||
|
||||
assert "guardrails" in results
|
||||
assert "memory" in results
|
||||
|
||||
def test_get_missing_artifacts(self, tmp_path):
|
||||
"""Test getting all missing artifacts."""
|
||||
sdk_root = tmp_path / "sdk"
|
||||
sdk_root.mkdir()
|
||||
(sdk_root / "guardrails").mkdir()
|
||||
(sdk_root / "memory").mkdir()
|
||||
|
||||
config = StandardiseConfig(sdk_root=sdk_root)
|
||||
discovery = FeatureDiscovery(config)
|
||||
validator = ArtifactValidator(config, discovery)
|
||||
|
||||
missing = validator.get_missing_artifacts()
|
||||
|
||||
# Both features should have missing artifacts
|
||||
assert len(missing) >= 2
|
||||
|
||||
def test_finds_variant_paths(self, tmp_path):
|
||||
"""Test that validator finds files with variant naming."""
|
||||
docs_root = tmp_path / "docs"
|
||||
docs_root.mkdir()
|
||||
|
||||
(docs_root / "cli").mkdir()
|
||||
# Using singular form
|
||||
(docs_root / "cli" / "guardrail.mdx").touch()
|
||||
|
||||
config = StandardiseConfig(docs_root=docs_root)
|
||||
discovery = FeatureDiscovery(config)
|
||||
validator = ArtifactValidator(config, discovery)
|
||||
|
||||
slug = FeatureSlug.from_string("guardrails")
|
||||
artifact_path = validator._find_actual_path(
|
||||
docs_root / "cli" / "guardrails.mdx",
|
||||
slug,
|
||||
"cli"
|
||||
)
|
||||
|
||||
# Should find the singular variant
|
||||
assert artifact_path is not None
|
||||
assert artifact_path.exists()
|
||||
|
||||
def test_feature_filter(self, tmp_path):
|
||||
"""Test that feature filter limits validation."""
|
||||
sdk_root = tmp_path / "sdk"
|
||||
sdk_root.mkdir()
|
||||
(sdk_root / "guardrails").mkdir()
|
||||
(sdk_root / "memory").mkdir()
|
||||
(sdk_root / "knowledge").mkdir()
|
||||
|
||||
config = StandardiseConfig(sdk_root=sdk_root, feature_filter="guardrails")
|
||||
discovery = FeatureDiscovery(config)
|
||||
validator = ArtifactValidator(config, discovery)
|
||||
|
||||
results = validator.validate_all()
|
||||
|
||||
# Only guardrails should be validated
|
||||
assert len(results) == 1
|
||||
assert "guardrails" in results
|
||||
Generated
+4
-4
@@ -4276,7 +4276,7 @@ requires-dist = [
|
||||
{ name = "praisonai-tools", marker = "extra == 'autogen'", specifier = ">=0.1.0" },
|
||||
{ name = "praisonai-tools", marker = "extra == 'autogen-v4'", specifier = ">=0.1.0" },
|
||||
{ name = "praisonai-tools", marker = "extra == 'crewai'", specifier = ">=0.1.0" },
|
||||
{ name = "praisonaiagents", specifier = ">=0.11.0" },
|
||||
{ name = "praisonaiagents", specifier = ">=0.11.2" },
|
||||
{ name = "pyautogen", marker = "extra == 'autogen'", specifier = "==0.2.29" },
|
||||
{ name = "pydantic", marker = "extra == 'chat'", specifier = "<=2.10.1" },
|
||||
{ name = "pydantic", marker = "extra == 'code'", specifier = "<=2.10.1" },
|
||||
@@ -4324,7 +4324,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "0.11.0"
|
||||
version = "0.11.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
@@ -4333,9 +4333,9 @@ dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "rich" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ea/90/b4d4d7dbed2abae2ccb58c41d638884d792bde2108123400e6a8399df6bd/praisonaiagents-0.11.0.tar.gz", hash = "sha256:b6791d6ea92bd50d4a879931f3c3b6b786dd5447dbe25f875568e83dd3685bae", size = 964117, upload-time = "2026-01-10T13:03:11.999Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a4/d0/84583da326312957eb887e32d6df4549889494b736b2f3925f3b80fc7e70/praisonaiagents-0.11.2.tar.gz", hash = "sha256:a32e1063404d61cac71be4ee447f6888f7b9566a298ac63793aeaec41eeeeab1", size = 972927, upload-time = "2026-01-10T19:35:37.032Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/03/bb/7929c5451d640f2e1a68f4c715fe764aff7b37da3992312cd3d2d4137291/praisonaiagents-0.11.0-py3-none-any.whl", hash = "sha256:942bcc2ae5922091fa0a16cd1712f6fb4bb163d4a45ca2e0b16e66eb88c86dd8", size = 943080, upload-time = "2026-01-10T13:03:09.608Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/e8/3c13e6f5a3bcd4b01c0a373a2d3fdd797eeea3f46f673fc6a14ba081e1f2/praisonaiagents-0.11.2-py3-none-any.whl", hash = "sha256:e5d666d23d59e53b498beefc30edef3d28ca42fda7b0cd862d1859e59410067c", size = 954207, upload-time = "2026-01-10T19:35:35.034Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in new issue
Block a user