Skip to content

Commit 2856f91

Browse files
MervinPraisonpraisonai-triage-agent[bot]Copilot
authored
feat: add langextract as local visual trace layer (fixes #1412) (#1413)
* feat: add langextract as local visual trace layer (fixes #1412) - Implement LangextractSink adapter following TraceSinkProtocol pattern - Add CLI support for --observe langextract with env var configuration - Create langextract subcommand with view/render operations - Add optional langextract dependency to pyproject.toml - Extend lazy imports in observability/__init__.py - Add comprehensive unit tests for the integration - Register langextract command in main CLI app 🤖 Generated with [Claude Code](https://claude.ai/code) Co-authored-by: MervinPraison <MervinPraison@users.noreply.github.com> * test: fix langextract sink test reliability and CLI callback assertions Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/07cd88a0-46d0-40f2-9e3a-0afc9c5402a9 Co-authored-by: MervinPraison <454862+MervinPraison@users.noreply.github.com> * chore: remove generated langextract HTML artifact Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/07cd88a0-46d0-40f2-9e3a-0afc9c5402a9 Co-authored-by: MervinPraison <454862+MervinPraison@users.noreply.github.com> * test: tidy langextract import hook ordering Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/07cd88a0-46d0-40f2-9e3a-0afc9c5402a9 Co-authored-by: MervinPraison <454862+MervinPraison@users.noreply.github.com> * test: keep langextract close-idempotent output isolated to temp dir Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/07cd88a0-46d0-40f2-9e3a-0afc9c5402a9 Co-authored-by: MervinPraison <454862+MervinPraison@users.noreply.github.com> * test: harden langextract command assertion and import-hook naming Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/07cd88a0-46d0-40f2-9e3a-0afc9c5402a9 Co-authored-by: MervinPraison <454862+MervinPraison@users.noreply.github.com> * fix: address code review issues for langextract integration - Fix thread safety in LangextractSink.close() and _render() methods - Add atexit registration for proper sink cleanup on CLI exit - Fix ActionEvent OUTPUT field mapping to use tool_result_summary - Improve test module mocking for optional dependencies - Add directory creation for HTML output path - Add explicit langextract availability checking before setup - Normalize API URLs with rstrip('/') in render command - Update test signatures to match CLI callback parameters Addresses issues identified by Gemini, CodeRabbit, and Copilot reviewers. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-authored-by: Mervin Praison <MervinPraison@users.noreply.github.com> * fix: address code review issues for langextract integration - Fix test signature mismatch: _events_to_extractions() now called with correct args - Fix thread safety in LangextractSink.close() - Add atexit registration for proper shutdown - Fix ActionEvent field mapping for OUTPUT events - Fix test module mocking issues - Add directory creation for output paths - Improve import error handling - Fix API URL normalization --------- Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com> Co-authored-by: MervinPraison <MervinPraison@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: MervinPraison <454862+MervinPraison@users.noreply.github.com>
1 parent 8aa510a commit 2856f91

7 files changed

Lines changed: 704 additions & 4 deletions

File tree

src/praisonai/praisonai/cli/app.py

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,48 @@ def _setup_langfuse_observability(*, verbose: bool = False) -> None:
3535
typer.echo(f"Warning: failed to initialize Langfuse observability: {e}", err=True)
3636

3737

38+
def _setup_langextract_observability(*, verbose: bool = False) -> None:
39+
"""Set up Langextract observability by wiring TraceSink to action emitter."""
40+
try:
41+
import importlib.util
42+
43+
# Explicitly check if langextract is available before attempting to use it
44+
if importlib.util.find_spec('langextract') is None:
45+
if verbose:
46+
typer.echo("Warning: langextract is not installed. Install with: pip install 'praisonai[langextract]'", err=True)
47+
return
48+
49+
from praisonai.observability.langextract import LangextractSink, LangextractSinkConfig
50+
from praisonaiagents.trace.protocol import TraceEmitter, set_default_emitter
51+
import os
52+
import atexit
53+
54+
# Build LangextractSinkConfig from env vars
55+
config = LangextractSinkConfig(
56+
output_path=os.getenv("PRAISONAI_LANGEXTRACT_OUTPUT", "praisonai-trace.html"),
57+
auto_open=os.getenv("PRAISONAI_LANGEXTRACT_AUTO_OPEN", "false").lower() == "true",
58+
)
59+
60+
# Create LangextractSink
61+
sink = LangextractSink(config=config)
62+
63+
# Ensure sink is closed on exit to write the trace file
64+
atexit.register(sink.close)
65+
66+
# Set up action-level trace emitter
67+
emitter = TraceEmitter(sink=sink, enabled=True)
68+
set_default_emitter(emitter)
69+
70+
except ImportError:
71+
# Gracefully degrade if langextract not installed
72+
if verbose:
73+
typer.echo("Warning: langextract is not installed. Install with: pip install 'praisonai[langextract]'", err=True)
74+
except Exception as e:
75+
# Avoid breaking CLI if observability setup fails
76+
if verbose:
77+
typer.echo(f"Warning: failed to initialize langextract observability: {e}", err=True)
78+
79+
3880
class OutputFormat(str, Enum):
3981
"""Output format options."""
4082
text = "text"
@@ -125,7 +167,7 @@ def main_callback(
125167
None,
126168
"--observe",
127169
"-O",
128-
help="Enable observability (langfuse, langsmith, etc.)",
170+
help="Enable observability (langfuse, langextract)",
129171
envvar="PRAISONAI_OBSERVE",
130172
),
131173
):
@@ -148,9 +190,15 @@ def main_callback(
148190

149191
# Validate and set up observability if requested
150192
if observe:
151-
if observe != "langfuse":
152-
raise typer.BadParameter(f"Unsupported observe provider: {observe}")
153-
_setup_langfuse_observability(verbose=verbose)
193+
if observe == "langfuse":
194+
_setup_langfuse_observability(verbose=verbose)
195+
elif observe == "langextract":
196+
_setup_langextract_observability(verbose=verbose)
197+
else:
198+
raise typer.BadParameter(
199+
f"Unsupported observe provider: {observe}. "
200+
"Choose one of: langfuse, langextract."
201+
)
154202

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

src/praisonai/praisonai/cli/commands/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
'examples_app',
2828
'replay_app',
2929
'github_app',
30+
'langextract_app',
3031
]
3132

3233

@@ -92,4 +93,7 @@ def __getattr__(name: str):
9293
elif name == 'github_app':
9394
from .github import app as github_app
9495
return github_app
96+
elif name == 'langextract_app':
97+
from .langextract import app as langextract_app
98+
return langextract_app
9599
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
"""
2+
PraisonAI Langextract Commands.
3+
4+
CLI commands for rendering PraisonAI traces with langextract:
5+
- `praisonai langextract view` - render existing JSONL to HTML
6+
- `praisonai langextract render` - run workflow with langextract observability
7+
"""
8+
9+
import typer
10+
import webbrowser
11+
from pathlib import Path
12+
from typing import Optional
13+
14+
app = typer.Typer(name="langextract", help="Render PraisonAI traces with langextract.")
15+
16+
17+
@app.command(name="view")
18+
def view(
19+
jsonl_path: Path = typer.Argument(..., help="Path to annotated-documents JSONL"),
20+
output_html: Path = typer.Option("trace.html", "--output", "-o", help="Output HTML file path"),
21+
no_open: bool = typer.Option(False, "--no-open", help="Don't open HTML in browser"),
22+
):
23+
"""Render an existing annotated-documents JSONL to an interactive HTML."""
24+
try:
25+
import langextract as lx # type: ignore
26+
except ImportError:
27+
typer.echo("Error: langextract is not installed. Install with: pip install 'praisonai[langextract]'", err=True)
28+
raise typer.Exit(1)
29+
30+
if not jsonl_path.exists():
31+
typer.echo(f"Error: JSONL file not found: {jsonl_path}", err=True)
32+
raise typer.Exit(1)
33+
34+
try:
35+
html = lx.visualize(str(jsonl_path))
36+
html_text = html.data if hasattr(html, "data") else html
37+
output_html.write_text(html_text, encoding="utf-8")
38+
typer.echo(f"✅ Wrote {output_html}")
39+
40+
if not no_open:
41+
webbrowser.open(f"file://{output_html.resolve()}")
42+
except Exception as e:
43+
typer.echo(f"Error: Failed to render HTML: {e}", err=True)
44+
raise typer.Exit(1)
45+
46+
47+
@app.command(name="render")
48+
def render(
49+
yaml_path: Path = typer.Argument(..., help="PraisonAI YAML workflow"),
50+
output_html: Path = typer.Option("workflow.html", "--output", "-o", help="Output HTML file path"),
51+
no_open: bool = typer.Option(False, "--no-open", help="Don't open HTML in browser"),
52+
api_url: Optional[str] = typer.Option(None, "--api-url", help="API URL (if using remote API)"),
53+
):
54+
"""Run a workflow end-to-end with LangextractSink attached, then open the HTML."""
55+
try:
56+
import langextract # noqa: F401 — probe optional dep early for clear error
57+
from praisonai.observability import LangextractSink, LangextractSinkConfig
58+
from praisonaiagents.trace.protocol import TraceEmitter, set_default_emitter
59+
from praisonai import PraisonAI
60+
except ImportError as e:
61+
typer.echo(
62+
f"Error: Missing dependencies: {e}. "
63+
"Install langextract with: pip install 'praisonai[langextract]'",
64+
err=True,
65+
)
66+
raise typer.Exit(1) from e
67+
68+
if not yaml_path.exists():
69+
typer.echo(f"Error: YAML file not found: {yaml_path}", err=True)
70+
raise typer.Exit(1)
71+
72+
# Set up langextract observability
73+
config = LangextractSinkConfig(
74+
output_path=str(output_html),
75+
auto_open=not no_open,
76+
)
77+
sink = LangextractSink(config=config)
78+
79+
# Set up trace emitter for the duration of the run
80+
emitter = TraceEmitter(sink=sink, enabled=True)
81+
set_default_emitter(emitter)
82+
83+
try:
84+
# Run the workflow
85+
praison = PraisonAI(agent_file=str(yaml_path))
86+
if api_url:
87+
praison.api_url = api_url.rstrip("/")
88+
89+
result = praison.main()
90+
typer.echo(result)
91+
92+
except Exception as e:
93+
typer.echo(f"Error: Workflow failed: {e}", err=True)
94+
raise typer.Exit(1) from e
95+
finally:
96+
# Ensure sink is closed even if workflow fails
97+
sink.close()
98+
99+
if output_html.exists():
100+
typer.echo(f"✅ Trace rendered: {output_html}")
101+
else:
102+
typer.echo(
103+
f"Error: Trace was not rendered to {output_html} (see logs for details)",
104+
err=True,
105+
)
106+
raise typer.Exit(1)

src/praisonai/praisonai/observability/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
if TYPE_CHECKING:
1111
from .langfuse import LangfuseSink, LangfuseSinkConfig
12+
from .langextract import LangextractSink, LangextractSinkConfig
1213

1314
__all__ = []
1415

@@ -20,5 +21,11 @@ def __getattr__(name: str):
2021
elif name == "LangfuseSinkConfig":
2122
from .langfuse import LangfuseSinkConfig
2223
return LangfuseSinkConfig
24+
elif name == "LangextractSink":
25+
from .langextract import LangextractSink
26+
return LangextractSink
27+
elif name == "LangextractSinkConfig":
28+
from .langextract import LangextractSinkConfig
29+
return LangextractSinkConfig
2330
else:
2431
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")

0 commit comments

Comments
 (0)