Skip to content
Merged
58 changes: 54 additions & 4 deletions src/praisonai/praisonai/cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,48 @@ def _setup_langfuse_observability(*, verbose: bool = False) -> None:
typer.echo(f"Warning: failed to initialize Langfuse observability: {e}", err=True)


def _setup_langextract_observability(*, verbose: bool = False) -> None:
"""Set up Langextract observability by wiring TraceSink to action emitter."""
try:
import importlib.util

# Explicitly check if langextract is available before attempting to use it
if importlib.util.find_spec('langextract') is None:
if verbose:
typer.echo("Warning: langextract is not installed. Install with: pip install 'praisonai[langextract]'", err=True)
return

from praisonai.observability.langextract import LangextractSink, LangextractSinkConfig
from praisonaiagents.trace.protocol import TraceEmitter, set_default_emitter
import os
import atexit

# Build LangextractSinkConfig from env vars
config = LangextractSinkConfig(
output_path=os.getenv("PRAISONAI_LANGEXTRACT_OUTPUT", "praisonai-trace.html"),
auto_open=os.getenv("PRAISONAI_LANGEXTRACT_AUTO_OPEN", "false").lower() == "true",
)

# Create LangextractSink
sink = LangextractSink(config=config)

# Ensure sink is closed on exit to write the trace file
atexit.register(sink.close)

# Set up action-level trace emitter
emitter = TraceEmitter(sink=sink, enabled=True)
set_default_emitter(emitter)
Comment on lines +51 to +68
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The LangextractSink requires an explicit close() call to write the trace file to disk. When using the global --observe langextract flag, there is currently no mechanism to ensure close() is called at the end of the process. Registering a shutdown hook via atexit will ensure the trace is generated when the CLI exits.

        import os
        import atexit
        
        # Build LangextractSinkConfig from env vars
        config = LangextractSinkConfig(
            output_path=os.getenv("PRAISONAI_LANGEXTRACT_OUTPUT", "praisonai-trace.html"),
            auto_open=os.getenv("PRAISONAI_LANGEXTRACT_AUTO_OPEN", "false").lower() == "true",
        )
        
        # Create LangextractSink
        sink = LangextractSink(config=config)
        
        # Ensure sink is closed on exit to write the trace file
        atexit.register(sink.close)
        
        # Set up action-level trace emitter
        emitter = TraceEmitter(sink=sink, enabled=True)
        set_default_emitter(emitter)


except ImportError:
# Gracefully degrade if langextract not installed
if verbose:
typer.echo("Warning: langextract is not installed. Install with: pip install 'praisonai[langextract]'", err=True)
except Exception as e:
Comment on lines +38 to +74
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_setup_langextract_observability()'s except ImportError branch won't trigger when langextract isn't installed because importing LangextractSink doesn't import the third-party langextract package. This results in observability appearing enabled but failing later on close() with only a logged warning. Consider explicitly checking importlib.util.find_spec('langextract') (or importing langextract here) and surfacing a clear CLI warning/error when it's missing.

Copilot uses AI. Check for mistakes.
# Avoid breaking CLI if observability setup fails
if verbose:
typer.echo(f"Warning: failed to initialize langextract observability: {e}", err=True)

Comment thread
coderabbitai[bot] marked this conversation as resolved.

class OutputFormat(str, Enum):
"""Output format options."""
text = "text"
Expand Down Expand Up @@ -125,7 +167,7 @@ def main_callback(
None,
"--observe",
"-O",
help="Enable observability (langfuse, langsmith, etc.)",
help="Enable observability (langfuse, langextract)",
envvar="PRAISONAI_OBSERVE",
),
):
Expand All @@ -148,9 +190,15 @@ def main_callback(

# Validate and set up observability if requested
if observe:
if observe != "langfuse":
raise typer.BadParameter(f"Unsupported observe provider: {observe}")
_setup_langfuse_observability(verbose=verbose)
if observe == "langfuse":
_setup_langfuse_observability(verbose=verbose)
elif observe == "langextract":
_setup_langextract_observability(verbose=verbose)
else:
raise typer.BadParameter(
f"Unsupported observe provider: {observe}. "
"Choose one of: langfuse, langextract."
)

# Determine output mode
if state.quiet:
Expand Down Expand Up @@ -278,6 +326,7 @@ def register_commands():
from .commands.flow import app as flow_app
from .commands.unified import app as unified_app
from .commands.langfuse import app as langfuse_app
from .commands.langextract import app as langextract_app
from .commands.port import app as port_app
from .commands.managed import app as managed_app
from .commands.up import app as up_app
Expand Down Expand Up @@ -465,6 +514,7 @@ def app_cmd(
app.add_typer(flow_app, name="flow", help="Visual workflow builder (Langflow)")
app.add_typer(unified_app, name="dashboard", help="🌟 Unified Dashboard (Flow + Claw + UI)")
app.add_typer(langfuse_app, name="langfuse", help="🔍 Langfuse observability platform")
app.add_typer(langextract_app, name="langextract", help="🧠 Langextract visual trace layer")
app.add_typer(port_app, name="port", help="🔌 Manage port usage and resolve conflicts")
app.add_typer(up_app, name="up", help="🚀 Start unified PraisonAI stack (Langfuse + Langflow)")

Expand Down
4 changes: 4 additions & 0 deletions src/praisonai/praisonai/cli/commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
'examples_app',
'replay_app',
'github_app',
'langextract_app',
]


Expand Down Expand Up @@ -92,4 +93,7 @@ def __getattr__(name: str):
elif name == 'github_app':
from .github import app as github_app
return github_app
elif name == 'langextract_app':
from .langextract import app as langextract_app
return langextract_app
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
106 changes: 106 additions & 0 deletions src/praisonai/praisonai/cli/commands/langextract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
"""
PraisonAI Langextract Commands.

CLI commands for rendering PraisonAI traces with langextract:
- `praisonai langextract view` - render existing JSONL to HTML
- `praisonai langextract render` - run workflow with langextract observability
"""

import typer
import webbrowser
from pathlib import Path
from typing import Optional

app = typer.Typer(name="langextract", help="Render PraisonAI traces with langextract.")


@app.command(name="view")
def view(
jsonl_path: Path = typer.Argument(..., help="Path to annotated-documents JSONL"),
output_html: Path = typer.Option("trace.html", "--output", "-o", help="Output HTML file path"),
no_open: bool = typer.Option(False, "--no-open", help="Don't open HTML in browser"),
):
"""Render an existing annotated-documents JSONL to an interactive HTML."""
try:
import langextract as lx # type: ignore
except ImportError:
typer.echo("Error: langextract is not installed. Install with: pip install 'praisonai[langextract]'", err=True)
raise typer.Exit(1)

if not jsonl_path.exists():
typer.echo(f"Error: JSONL file not found: {jsonl_path}", err=True)
raise typer.Exit(1)

try:
html = lx.visualize(str(jsonl_path))
html_text = html.data if hasattr(html, "data") else html
output_html.write_text(html_text, encoding="utf-8")
typer.echo(f"✅ Wrote {output_html}")

if not no_open:
webbrowser.open(f"file://{output_html.resolve()}")
except Exception as e:
typer.echo(f"Error: Failed to render HTML: {e}", err=True)
raise typer.Exit(1)


@app.command(name="render")
def render(
yaml_path: Path = typer.Argument(..., help="PraisonAI YAML workflow"),
output_html: Path = typer.Option("workflow.html", "--output", "-o", help="Output HTML file path"),
no_open: bool = typer.Option(False, "--no-open", help="Don't open HTML in browser"),
api_url: Optional[str] = typer.Option(None, "--api-url", help="API URL (if using remote API)"),
):
"""Run a workflow end-to-end with LangextractSink attached, then open the HTML."""
try:
import langextract # noqa: F401 — probe optional dep early for clear error
from praisonai.observability import LangextractSink, LangextractSinkConfig
from praisonaiagents.trace.protocol import TraceEmitter, set_default_emitter
from praisonai import PraisonAI
except ImportError as e:
typer.echo(
f"Error: Missing dependencies: {e}. "
"Install langextract with: pip install 'praisonai[langextract]'",
err=True,
)
raise typer.Exit(1) from e

if not yaml_path.exists():
typer.echo(f"Error: YAML file not found: {yaml_path}", err=True)
raise typer.Exit(1)

# Set up langextract observability
config = LangextractSinkConfig(
output_path=str(output_html),
auto_open=not no_open,
)
sink = LangextractSink(config=config)

# Set up trace emitter for the duration of the run
emitter = TraceEmitter(sink=sink, enabled=True)
set_default_emitter(emitter)

Comment on lines +55 to +82
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

render() wires up the action trace system (TraceEmitter/set_default_emitter), but PraisonAI.main() (legacy YAML runner) emits context trace events via ContextTraceEmitter/set_context_emitter (see praisonai/cli/main.py). As a result, this command is unlikely to capture the workflow execution at all. To make praisonai langextract render work, hook into the context tracing pipeline (or add a ContextTraceSink adapter) rather than the action trace emitter.

Copilot uses AI. Check for mistakes.
try:
# Run the workflow
praison = PraisonAI(agent_file=str(yaml_path))
if api_url:
praison.api_url = api_url.rstrip("/")

result = praison.main()
typer.echo(result)

except Exception as e:
typer.echo(f"Error: Workflow failed: {e}", err=True)
raise typer.Exit(1) from e
finally:
# Ensure sink is closed even if workflow fails
sink.close()

if output_html.exists():
typer.echo(f"✅ Trace rendered: {output_html}")
else:
typer.echo(
f"Error: Trace was not rendered to {output_html} (see logs for details)",
err=True,
)
raise typer.Exit(1)
7 changes: 7 additions & 0 deletions src/praisonai/praisonai/observability/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

if TYPE_CHECKING:
from .langfuse import LangfuseSink, LangfuseSinkConfig
from .langextract import LangextractSink, LangextractSinkConfig

__all__ = []

Expand All @@ -20,5 +21,11 @@ def __getattr__(name: str):
elif name == "LangfuseSinkConfig":
from .langfuse import LangfuseSinkConfig
return LangfuseSinkConfig
elif name == "LangextractSink":
from .langextract import LangextractSink
return LangextractSink
elif name == "LangextractSinkConfig":
from .langextract import LangextractSinkConfig
return LangextractSinkConfig
else:
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
Loading