-
-
Notifications
You must be signed in to change notification settings - Fork 1.1k
feat: add langextract as local visual trace layer (fixes #1412) #1413
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
73bde5a
8dff04a
5067300
b543c7f
65df1eb
75d4c81
5d6641f
3160937
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
|
|
||
| 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
|
||
| # Avoid breaking CLI if observability setup fails | ||
| if verbose: | ||
| typer.echo(f"Warning: failed to initialize langextract observability: {e}", err=True) | ||
|
|
||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| class OutputFormat(str, Enum): | ||
| """Output format options.""" | ||
| text = "text" | ||
|
|
@@ -125,7 +167,7 @@ def main_callback( | |
| None, | ||
| "--observe", | ||
| "-O", | ||
| help="Enable observability (langfuse, langsmith, etc.)", | ||
| help="Enable observability (langfuse, langextract)", | ||
| envvar="PRAISONAI_OBSERVE", | ||
| ), | ||
| ): | ||
|
|
@@ -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: | ||
|
|
@@ -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 | ||
|
|
@@ -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)") | ||
|
|
||
|
|
||
| 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
|
||
| 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) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
LangextractSinkrequires an explicitclose()call to write the trace file to disk. When using the global--observe langextractflag, there is currently no mechanism to ensureclose()is called at the end of the process. Registering a shutdown hook viaatexitwill ensure the trace is generated when the CLI exits.