humanbound-cli 0.4.0__tar.gz → 0.4.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/PKG-INFO +1 -1
- {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/client.py +41 -2
- humanbound_cli-0.4.1/humanbound_cli/commands/logs.py +479 -0
- {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/report.py +172 -48
- {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli.egg-info/PKG-INFO +1 -1
- {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli.egg-info/top_level.txt +0 -1
- {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/pyproject.toml +1 -1
- humanbound_cli-0.4.0/humanbound_cli/commands/logs.py +0 -240
- {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/LICENSE +0 -0
- {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/README.md +0 -0
- {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/__init__.py +0 -0
- {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/commands/__init__.py +0 -0
- {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/commands/api_keys.py +0 -0
- {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/commands/auth.py +0 -0
- {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/commands/campaigns.py +0 -0
- {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/commands/completion.py +0 -0
- {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/commands/connectors.py +0 -0
- {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/commands/coverage.py +0 -0
- {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/commands/discover.py +0 -0
- {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/commands/docs.py +0 -0
- {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/commands/experiments.py +0 -0
- {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/commands/findings.py +0 -0
- {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/commands/guardrails.py +0 -0
- {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/commands/init.py +0 -0
- {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/commands/inventory.py +0 -0
- {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/commands/members.py +0 -0
- {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/commands/orgs.py +0 -0
- {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/commands/posture.py +0 -0
- {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/commands/projects.py +0 -0
- {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/commands/providers.py +0 -0
- {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/commands/scan.py +0 -0
- {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/commands/sentinel.py +0 -0
- {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/commands/test.py +0 -0
- {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/commands/upload_logs.py +0 -0
- {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/config.py +0 -0
- {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/connectors/__init__.py +0 -0
- {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/connectors/microsoft.py +0 -0
- {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/exceptions.py +0 -0
- {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/extractors/__init__.py +0 -0
- {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/extractors/openapi.py +0 -0
- {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/extractors/repo.py +0 -0
- {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/main.py +0 -0
- {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/pytest_plugin/__init__.py +0 -0
- {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/pytest_plugin/fixtures.py +0 -0
- {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/pytest_plugin/report.py +0 -0
- {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/report_builder.py +0 -0
- {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/serve/__init__.py +0 -0
- {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/serve/config_builder.py +0 -0
- {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/serve/local_server.py +0 -0
- {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/serve/runtime_detector.py +0 -0
- {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli/serve/tunnel_client.py +0 -0
- {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli.egg-info/SOURCES.txt +0 -0
- {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli.egg-info/dependency_links.txt +0 -0
- {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli.egg-info/entry_points.txt +0 -0
- {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/humanbound_cli.egg-info/requires.txt +0 -0
- {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/relay/relay.py +0 -0
- {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/setup.cfg +0 -0
- {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/tests/__init__.py +0 -0
- {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/tests/cli_integration_test.py +0 -0
- {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/tests/conftest.py +0 -0
- {humanbound_cli-0.4.0 → humanbound_cli-0.4.1}/tests/test_cli_commands.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: humanbound-cli
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.1
|
|
4
4
|
Summary: Humanbound CLI - command line interface for AI agent security testing.
|
|
5
5
|
Author-email: Kostas Siabanis <hello@humanbound.ai>, Demetris Gerogiannis <hello@humanbound.ai>
|
|
6
6
|
License: Apache-2.0
|
|
@@ -6,13 +6,11 @@ import time
|
|
|
6
6
|
import webbrowser
|
|
7
7
|
import http.server
|
|
8
8
|
import socketserver
|
|
9
|
-
import threading
|
|
10
9
|
import secrets
|
|
11
10
|
import hashlib
|
|
12
11
|
import base64
|
|
13
12
|
import urllib.parse
|
|
14
13
|
from typing import Optional, Dict, Any, List
|
|
15
|
-
from pathlib import Path
|
|
16
14
|
|
|
17
15
|
import requests
|
|
18
16
|
|
|
@@ -906,6 +904,47 @@ class HumanboundClient:
|
|
|
906
904
|
include_project=True,
|
|
907
905
|
)
|
|
908
906
|
|
|
907
|
+
def get_project_logs(
|
|
908
|
+
self,
|
|
909
|
+
page: int = 1,
|
|
910
|
+
size: int = 50,
|
|
911
|
+
result: Optional[str] = None,
|
|
912
|
+
from_date: Optional[str] = None,
|
|
913
|
+
until_date: Optional[str] = None,
|
|
914
|
+
test_category: Optional[str] = None,
|
|
915
|
+
last: Optional[int] = None,
|
|
916
|
+
) -> dict:
|
|
917
|
+
"""Get logs for the current project with optional filters.
|
|
918
|
+
|
|
919
|
+
Args:
|
|
920
|
+
page: Page number.
|
|
921
|
+
size: Items per page.
|
|
922
|
+
result: Filter by result (pass/fail).
|
|
923
|
+
from_date: Start date (ISO 8601).
|
|
924
|
+
until_date: End date (ISO 8601).
|
|
925
|
+
test_category: Filter by test category (substring match).
|
|
926
|
+
last: Limit to last N experiments.
|
|
927
|
+
|
|
928
|
+
Returns:
|
|
929
|
+
Paginated response with logs.
|
|
930
|
+
"""
|
|
931
|
+
if not self._project_id:
|
|
932
|
+
raise ValidationError("No project selected. Use set_project() first.")
|
|
933
|
+
|
|
934
|
+
params: Dict[str, Any] = {"page": page, "size": size}
|
|
935
|
+
if result:
|
|
936
|
+
params["result"] = result
|
|
937
|
+
if from_date:
|
|
938
|
+
params["from"] = from_date
|
|
939
|
+
if until_date:
|
|
940
|
+
params["until"] = until_date
|
|
941
|
+
if test_category:
|
|
942
|
+
params["test_category"] = test_category
|
|
943
|
+
if last:
|
|
944
|
+
params["last"] = last
|
|
945
|
+
|
|
946
|
+
return self.get("logs", params=params, include_project=True)
|
|
947
|
+
|
|
909
948
|
# -------------------------------------------------------------------------
|
|
910
949
|
# Provider Methods
|
|
911
950
|
# -------------------------------------------------------------------------
|
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
"""Logs command for retrieving and exporting experiment results."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.table import Table
|
|
6
|
+
import json
|
|
7
|
+
from datetime import datetime, timedelta
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from ..client import HumanboundClient
|
|
11
|
+
from ..exceptions import NotAuthenticatedError, APIError
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
console_err = Console(stderr=True)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@click.command("logs")
|
|
18
|
+
@click.argument("experiment_id", required=False)
|
|
19
|
+
@click.option(
|
|
20
|
+
"--format", "-f", "output_format",
|
|
21
|
+
type=click.Choice(["table", "json", "html"]),
|
|
22
|
+
default="table",
|
|
23
|
+
help="Output format"
|
|
24
|
+
)
|
|
25
|
+
@click.option(
|
|
26
|
+
"--output", "-o",
|
|
27
|
+
type=click.Path(),
|
|
28
|
+
help="Output file path (prints to stdout if not specified)"
|
|
29
|
+
)
|
|
30
|
+
@click.option(
|
|
31
|
+
"--verdict", "-v",
|
|
32
|
+
type=click.Choice(["pass", "fail", "all"]),
|
|
33
|
+
default="all",
|
|
34
|
+
help="Filter by verdict"
|
|
35
|
+
)
|
|
36
|
+
@click.option(
|
|
37
|
+
"--page", default=1, help="Page number (for table format)"
|
|
38
|
+
)
|
|
39
|
+
@click.option(
|
|
40
|
+
"--size", default=50, help="Items per page (for table format)"
|
|
41
|
+
)
|
|
42
|
+
@click.option(
|
|
43
|
+
"--all", "fetch_all", is_flag=True, help="Fetch all logs (for json format)"
|
|
44
|
+
)
|
|
45
|
+
@click.option(
|
|
46
|
+
"--last", "last_n", type=int, help="Logs from last N experiments"
|
|
47
|
+
)
|
|
48
|
+
@click.option(
|
|
49
|
+
"--category", "test_category", help="Filter by test category (substring match)"
|
|
50
|
+
)
|
|
51
|
+
@click.option(
|
|
52
|
+
"--from", "from_date", help="Start date (ISO 8601, e.g. 2026-01-01)"
|
|
53
|
+
)
|
|
54
|
+
@click.option(
|
|
55
|
+
"--until", "until_date", help="End date (ISO 8601)"
|
|
56
|
+
)
|
|
57
|
+
@click.option(
|
|
58
|
+
"--days", type=int, help="Last N days (shortcut for --from)"
|
|
59
|
+
)
|
|
60
|
+
def logs_command(experiment_id, output_format, output, verdict, page, size, fetch_all, last_n, test_category, from_date, until_date, days):
|
|
61
|
+
"""Get logs from an experiment or across a project.
|
|
62
|
+
|
|
63
|
+
If no experiment_id or scope flags are provided, uses the most recent experiment.
|
|
64
|
+
Use scope flags (--last, --category, --from, --until, --days) for project-wide logs.
|
|
65
|
+
|
|
66
|
+
\b
|
|
67
|
+
Examples:
|
|
68
|
+
hb logs # Latest experiment logs
|
|
69
|
+
hb logs abc123 # Specific experiment
|
|
70
|
+
hb logs --last 5 # Last 5 experiments
|
|
71
|
+
hb logs --last 3 --verdict fail # Failed logs from last 3
|
|
72
|
+
hb logs --category owasp_multi_turn # All multi-turn logs
|
|
73
|
+
hb logs --days 7 --format json -o week.json
|
|
74
|
+
hb logs --from 2026-01-01 --until 2026-02-01 --format html -o jan.html
|
|
75
|
+
"""
|
|
76
|
+
client = HumanboundClient()
|
|
77
|
+
|
|
78
|
+
if not client.is_authenticated():
|
|
79
|
+
console_err.print("[red]Not authenticated.[/red] Run 'hb login' first.")
|
|
80
|
+
raise SystemExit(1)
|
|
81
|
+
|
|
82
|
+
if not client.project_id:
|
|
83
|
+
console_err.print("[yellow]No project selected.[/yellow]")
|
|
84
|
+
console_err.print("Use 'hb projects use <id>' to select a project first.")
|
|
85
|
+
raise SystemExit(1)
|
|
86
|
+
|
|
87
|
+
# Validation
|
|
88
|
+
scope_flags = any([last_n, test_category, from_date, until_date, days])
|
|
89
|
+
if experiment_id and scope_flags:
|
|
90
|
+
console_err.print("[red]Cannot combine experiment ID with scope flags.[/red]")
|
|
91
|
+
console_err.print("Use either an experiment ID OR scope flags (--last, --category, --from, --until, --days).")
|
|
92
|
+
raise SystemExit(1)
|
|
93
|
+
if days and from_date:
|
|
94
|
+
console_err.print("[red]Cannot combine --days with --from.[/red]")
|
|
95
|
+
raise SystemExit(1)
|
|
96
|
+
|
|
97
|
+
# --days → --from
|
|
98
|
+
if days:
|
|
99
|
+
from_date = (datetime.utcnow() - timedelta(days=days)).strftime("%Y-%m-%dT00:00:00")
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
if scope_flags:
|
|
103
|
+
_project_level_logs(
|
|
104
|
+
client, output_format, output, verdict, page, size, fetch_all,
|
|
105
|
+
last_n, test_category, from_date, until_date,
|
|
106
|
+
)
|
|
107
|
+
elif experiment_id:
|
|
108
|
+
# Resolve partial experiment ID
|
|
109
|
+
experiment_id = _resolve_experiment_id(client, experiment_id)
|
|
110
|
+
|
|
111
|
+
if output_format == "html":
|
|
112
|
+
_export_html(client, experiment_id, output)
|
|
113
|
+
elif output_format == "json":
|
|
114
|
+
_export_json(client, experiment_id, output, verdict, fetch_all, page, size)
|
|
115
|
+
else:
|
|
116
|
+
_show_table(client, experiment_id, verdict, page, size)
|
|
117
|
+
else:
|
|
118
|
+
# No args → most recent experiment (existing behavior)
|
|
119
|
+
response = client.list_experiments(page=1, size=1)
|
|
120
|
+
exps = response.get("data", [])
|
|
121
|
+
if not exps:
|
|
122
|
+
console_err.print("[yellow]No experiments found.[/yellow]")
|
|
123
|
+
raise SystemExit(1)
|
|
124
|
+
experiment_id = exps[0].get("id")
|
|
125
|
+
console_err.print(f"[dim]Using most recent experiment: {experiment_id}[/dim]")
|
|
126
|
+
|
|
127
|
+
if output_format == "html":
|
|
128
|
+
_export_html(client, experiment_id, output)
|
|
129
|
+
elif output_format == "json":
|
|
130
|
+
_export_json(client, experiment_id, output, verdict, fetch_all, page, size)
|
|
131
|
+
else:
|
|
132
|
+
_show_table(client, experiment_id, verdict, page, size)
|
|
133
|
+
|
|
134
|
+
except NotAuthenticatedError:
|
|
135
|
+
console_err.print("[red]Not authenticated.[/red] Run 'hb login' first.")
|
|
136
|
+
raise SystemExit(1)
|
|
137
|
+
except APIError as e:
|
|
138
|
+
console_err.print(f"[red]Error:[/red] {e}")
|
|
139
|
+
raise SystemExit(1)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
# Project-level logs
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
def _build_experiment_lookup(client):
|
|
147
|
+
"""Fetch all experiments and build {id: {name, test_category}} lookup."""
|
|
148
|
+
lookup = {}
|
|
149
|
+
current_page = 1
|
|
150
|
+
while True:
|
|
151
|
+
response = client.list_experiments(page=current_page, size=100)
|
|
152
|
+
for exp in response.get("data", []):
|
|
153
|
+
lookup[exp.get("id")] = {
|
|
154
|
+
"name": exp.get("name", ""),
|
|
155
|
+
"test_category": exp.get("test_category", ""),
|
|
156
|
+
}
|
|
157
|
+
if not response.get("has_next_page"):
|
|
158
|
+
break
|
|
159
|
+
current_page += 1
|
|
160
|
+
return lookup
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _enrich_log(log, exp_lookup):
|
|
164
|
+
"""Add experiment_name and test_category to a log entry from lookup."""
|
|
165
|
+
exp_id = log.get("experiment_id", "")
|
|
166
|
+
info = exp_lookup.get(exp_id, {})
|
|
167
|
+
log["experiment_name"] = info.get("name", "")
|
|
168
|
+
log["test_category"] = info.get("test_category", "")
|
|
169
|
+
return log
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _project_level_logs(client, output_format, output, verdict, page, size, fetch_all,
|
|
173
|
+
last_n, test_category, from_date, until_date):
|
|
174
|
+
"""Fetch and display project-level logs with scope filters."""
|
|
175
|
+
result_filter = None if verdict == "all" else verdict
|
|
176
|
+
|
|
177
|
+
# Build experiment lookup for enriching logs
|
|
178
|
+
exp_lookup = _build_experiment_lookup(client)
|
|
179
|
+
|
|
180
|
+
if output_format == "html":
|
|
181
|
+
_project_export_html(client, output, result_filter, last_n, test_category, from_date, until_date, exp_lookup)
|
|
182
|
+
elif output_format == "json":
|
|
183
|
+
_project_export_json(client, output, result_filter, fetch_all, page, size, last_n, test_category, from_date, until_date, exp_lookup)
|
|
184
|
+
else:
|
|
185
|
+
_project_show_table(client, result_filter, page, size, last_n, test_category, from_date, until_date, exp_lookup)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _project_show_table(client, result_filter, page, size, last_n, test_category, from_date, until_date, exp_lookup):
|
|
189
|
+
"""Show project-level logs in table format."""
|
|
190
|
+
response = client.get_project_logs(
|
|
191
|
+
page=page, size=size, result=result_filter,
|
|
192
|
+
from_date=from_date, until_date=until_date,
|
|
193
|
+
test_category=test_category, last=last_n,
|
|
194
|
+
)
|
|
195
|
+
logs = response.get("data", [])
|
|
196
|
+
|
|
197
|
+
if not logs:
|
|
198
|
+
console.print("[yellow]No logs found.[/yellow]")
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
table = Table(title=f"Project Logs (page {page})")
|
|
202
|
+
table.add_column("Experiment", width=20)
|
|
203
|
+
table.add_column("Test Category", width=20)
|
|
204
|
+
table.add_column("Verdict", width=6)
|
|
205
|
+
table.add_column("Severity", width=8)
|
|
206
|
+
table.add_column("Category", width=15)
|
|
207
|
+
table.add_column("Prompt", max_width=40)
|
|
208
|
+
|
|
209
|
+
for log in logs:
|
|
210
|
+
_enrich_log(log, exp_lookup)
|
|
211
|
+
|
|
212
|
+
result_val = log.get("result", "")
|
|
213
|
+
result_style = "[green]pass[/green]" if result_val == "pass" else "[red]fail[/red]"
|
|
214
|
+
|
|
215
|
+
severity = log.get("severity", "")
|
|
216
|
+
severity_style = {
|
|
217
|
+
"critical": "[red bold]critical[/red bold]",
|
|
218
|
+
"high": "[red]high[/red]",
|
|
219
|
+
"medium": "[yellow]medium[/yellow]",
|
|
220
|
+
"low": "[blue]low[/blue]",
|
|
221
|
+
}.get(str(severity).lower(), str(severity))
|
|
222
|
+
|
|
223
|
+
# Shorten test_category for display
|
|
224
|
+
tc = log.get("test_category", "")
|
|
225
|
+
tc_short = tc.split("/")[-1] if "/" in tc else tc
|
|
226
|
+
|
|
227
|
+
table.add_row(
|
|
228
|
+
(log.get("experiment_name", "") or "")[:20],
|
|
229
|
+
tc_short[:20],
|
|
230
|
+
result_style,
|
|
231
|
+
severity_style if result_val == "fail" else "",
|
|
232
|
+
log.get("fail_category") or log.get("gen_category") or "",
|
|
233
|
+
(log.get("prompt", "") or "")[:40],
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
console.print(table)
|
|
237
|
+
|
|
238
|
+
if response.get("has_next_page"):
|
|
239
|
+
console.print(f"\n[dim]Showing {len(logs)} logs. Use --page to see more.[/dim]")
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _project_export_json(client, output, result_filter, fetch_all, page, size, last_n, test_category, from_date, until_date, exp_lookup):
|
|
243
|
+
"""Export project-level logs as JSON."""
|
|
244
|
+
all_logs = []
|
|
245
|
+
|
|
246
|
+
if fetch_all:
|
|
247
|
+
current_page = 1
|
|
248
|
+
while True:
|
|
249
|
+
response = client.get_project_logs(
|
|
250
|
+
page=current_page, size=100, result=result_filter,
|
|
251
|
+
from_date=from_date, until_date=until_date,
|
|
252
|
+
test_category=test_category, last=last_n,
|
|
253
|
+
)
|
|
254
|
+
logs = response.get("data", [])
|
|
255
|
+
all_logs.extend(logs)
|
|
256
|
+
if not response.get("has_next_page"):
|
|
257
|
+
break
|
|
258
|
+
current_page += 1
|
|
259
|
+
else:
|
|
260
|
+
response = client.get_project_logs(
|
|
261
|
+
page=page, size=size, result=result_filter,
|
|
262
|
+
from_date=from_date, until_date=until_date,
|
|
263
|
+
test_category=test_category, last=last_n,
|
|
264
|
+
)
|
|
265
|
+
all_logs = response.get("data", [])
|
|
266
|
+
|
|
267
|
+
# Enrich each log with experiment name and test_category
|
|
268
|
+
for log in all_logs:
|
|
269
|
+
_enrich_log(log, exp_lookup)
|
|
270
|
+
|
|
271
|
+
export_data = {
|
|
272
|
+
"project_id": client.project_id,
|
|
273
|
+
"filters": {
|
|
274
|
+
"last": last_n,
|
|
275
|
+
"test_category": test_category,
|
|
276
|
+
"from": from_date,
|
|
277
|
+
"until": until_date,
|
|
278
|
+
"result": result_filter,
|
|
279
|
+
},
|
|
280
|
+
"logs": all_logs,
|
|
281
|
+
"total_logs": len(all_logs),
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
json_output = json.dumps(export_data, indent=2, default=str)
|
|
285
|
+
|
|
286
|
+
if output:
|
|
287
|
+
Path(output).write_text(json_output)
|
|
288
|
+
console.print(f"[green]JSON exported to:[/green] {output}")
|
|
289
|
+
else:
|
|
290
|
+
print(json_output)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _project_export_html(client, output, result_filter, last_n, test_category, from_date, until_date, exp_lookup):
|
|
294
|
+
"""Export project-level logs as HTML report."""
|
|
295
|
+
with console.status("Generating HTML report...", spinner="dots"):
|
|
296
|
+
# Fetch all matching logs
|
|
297
|
+
all_logs = []
|
|
298
|
+
current_page = 1
|
|
299
|
+
while True:
|
|
300
|
+
response = client.get_project_logs(
|
|
301
|
+
page=current_page, size=100, result=result_filter,
|
|
302
|
+
from_date=from_date, until_date=until_date,
|
|
303
|
+
test_category=test_category, last=last_n,
|
|
304
|
+
)
|
|
305
|
+
all_logs.extend(response.get("data", []))
|
|
306
|
+
if not response.get("has_next_page"):
|
|
307
|
+
break
|
|
308
|
+
current_page += 1
|
|
309
|
+
|
|
310
|
+
# Enrich each log with experiment name and test_category
|
|
311
|
+
for log in all_logs:
|
|
312
|
+
_enrich_log(log, exp_lookup)
|
|
313
|
+
|
|
314
|
+
# Build pseudo-experiment for the report template
|
|
315
|
+
pseudo_experiment = {
|
|
316
|
+
"id": f"project-{client.project_id[:8]}",
|
|
317
|
+
"name": "Project Logs",
|
|
318
|
+
"test_category": test_category or "Project-wide",
|
|
319
|
+
"testing_level": "",
|
|
320
|
+
"status": "completed",
|
|
321
|
+
"results": {},
|
|
322
|
+
"created_at": from_date or "",
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
from ..report import generate_html_report
|
|
326
|
+
report_html = generate_html_report(pseudo_experiment, all_logs)
|
|
327
|
+
|
|
328
|
+
filename = output or f"project_{client.project_id[:8]}_logs.html"
|
|
329
|
+
Path(filename).write_text(report_html)
|
|
330
|
+
console.print(f"[green]HTML report exported to:[/green] {filename}")
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
# ---------------------------------------------------------------------------
|
|
334
|
+
# Experiment-level helpers (unchanged)
|
|
335
|
+
# ---------------------------------------------------------------------------
|
|
336
|
+
|
|
337
|
+
def _resolve_experiment_id(client: HumanboundClient, partial_id: str) -> str:
|
|
338
|
+
"""Resolve a partial experiment ID to full ID."""
|
|
339
|
+
if len(partial_id) >= 32:
|
|
340
|
+
return partial_id
|
|
341
|
+
|
|
342
|
+
# Search recent experiments for match
|
|
343
|
+
response = client.list_experiments(page=1, size=50)
|
|
344
|
+
for exp in response.get("data", []):
|
|
345
|
+
if exp.get("id", "").startswith(partial_id):
|
|
346
|
+
return exp.get("id")
|
|
347
|
+
|
|
348
|
+
# Not found, return as-is and let API handle error
|
|
349
|
+
return partial_id
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _show_table(client: HumanboundClient, experiment_id: str, verdict: str, page: int, size: int):
|
|
353
|
+
"""Show logs in table format."""
|
|
354
|
+
result_filter = None if verdict == "all" else verdict
|
|
355
|
+
|
|
356
|
+
response = client.get_experiment_logs(
|
|
357
|
+
experiment_id,
|
|
358
|
+
page=page,
|
|
359
|
+
size=size,
|
|
360
|
+
result=result_filter,
|
|
361
|
+
)
|
|
362
|
+
logs = response.get("data", [])
|
|
363
|
+
|
|
364
|
+
if not logs:
|
|
365
|
+
console.print("[yellow]No logs found.[/yellow]")
|
|
366
|
+
return
|
|
367
|
+
|
|
368
|
+
table = Table(title=f"Experiment Logs (page {page})")
|
|
369
|
+
table.add_column("ID", style="dim")
|
|
370
|
+
table.add_column("Verdict", width=6)
|
|
371
|
+
table.add_column("Severity", width=8)
|
|
372
|
+
table.add_column("Category", width=15)
|
|
373
|
+
table.add_column("Prompt", max_width=50)
|
|
374
|
+
|
|
375
|
+
for log in logs:
|
|
376
|
+
result_val = log.get("result", "")
|
|
377
|
+
result_style = "[green]pass[/green]" if result_val == "pass" else "[red]fail[/red]"
|
|
378
|
+
|
|
379
|
+
severity = log.get("severity", "")
|
|
380
|
+
severity_style = {
|
|
381
|
+
"critical": "[red bold]critical[/red bold]",
|
|
382
|
+
"high": "[red]high[/red]",
|
|
383
|
+
"medium": "[yellow]medium[/yellow]",
|
|
384
|
+
"low": "[blue]low[/blue]",
|
|
385
|
+
}.get(str(severity).lower(), str(severity))
|
|
386
|
+
|
|
387
|
+
table.add_row(
|
|
388
|
+
log.get("id", ""),
|
|
389
|
+
result_style,
|
|
390
|
+
severity_style if result_val == "fail" else "",
|
|
391
|
+
log.get("fail_category") or log.get("gen_category") or "",
|
|
392
|
+
(log.get("prompt", "") or "")[:50],
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
console.print(table)
|
|
396
|
+
|
|
397
|
+
total = response.get("total", 0)
|
|
398
|
+
if response.get("has_next_page"):
|
|
399
|
+
console.print(f"\n[dim]Showing {len(logs)} of {total}. Use --page to see more.[/dim]")
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _export_json(client: HumanboundClient, experiment_id: str, output: str, verdict: str, fetch_all: bool, page: int, size: int):
|
|
403
|
+
"""Export logs as JSON."""
|
|
404
|
+
result_filter = None if verdict == "all" else verdict
|
|
405
|
+
|
|
406
|
+
all_logs = []
|
|
407
|
+
|
|
408
|
+
if fetch_all:
|
|
409
|
+
# Fetch all pages
|
|
410
|
+
current_page = 1
|
|
411
|
+
while True:
|
|
412
|
+
response = client.get_experiment_logs(
|
|
413
|
+
experiment_id,
|
|
414
|
+
page=current_page,
|
|
415
|
+
size=100,
|
|
416
|
+
result=result_filter,
|
|
417
|
+
)
|
|
418
|
+
logs = response.get("data", [])
|
|
419
|
+
all_logs.extend(logs)
|
|
420
|
+
|
|
421
|
+
if not response.get("has_next_page"):
|
|
422
|
+
break
|
|
423
|
+
current_page += 1
|
|
424
|
+
else:
|
|
425
|
+
response = client.get_experiment_logs(
|
|
426
|
+
experiment_id,
|
|
427
|
+
page=page,
|
|
428
|
+
size=size,
|
|
429
|
+
result=result_filter,
|
|
430
|
+
)
|
|
431
|
+
all_logs = response.get("data", [])
|
|
432
|
+
|
|
433
|
+
# Get experiment info for context
|
|
434
|
+
experiment = client.get_experiment(experiment_id)
|
|
435
|
+
|
|
436
|
+
export_data = {
|
|
437
|
+
"experiment": {
|
|
438
|
+
"id": experiment.get("id"),
|
|
439
|
+
"name": experiment.get("name"),
|
|
440
|
+
"status": experiment.get("status"),
|
|
441
|
+
"test_category": experiment.get("test_category"),
|
|
442
|
+
"testing_level": experiment.get("testing_level"),
|
|
443
|
+
"created_at": experiment.get("created_at"),
|
|
444
|
+
},
|
|
445
|
+
"results": experiment.get("results", {}),
|
|
446
|
+
"logs": all_logs,
|
|
447
|
+
"total_logs": len(all_logs),
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
json_output = json.dumps(export_data, indent=2, default=str)
|
|
451
|
+
|
|
452
|
+
if output:
|
|
453
|
+
Path(output).write_text(json_output)
|
|
454
|
+
console.print(f"[green]JSON exported to:[/green] {output}")
|
|
455
|
+
else:
|
|
456
|
+
print(json_output)
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _export_html(client: HumanboundClient, experiment_id: str, output: str):
|
|
460
|
+
"""Export logs as HTML report."""
|
|
461
|
+
with console.status("Generating HTML report...", spinner="dots"):
|
|
462
|
+
experiment = client.get_experiment(experiment_id)
|
|
463
|
+
|
|
464
|
+
# Fetch all logs
|
|
465
|
+
all_logs = []
|
|
466
|
+
page = 1
|
|
467
|
+
while True:
|
|
468
|
+
resp = client.get_experiment_logs(experiment_id, page=page, size=100)
|
|
469
|
+
all_logs.extend(resp.get("data", []))
|
|
470
|
+
if not resp.get("has_next_page"):
|
|
471
|
+
break
|
|
472
|
+
page += 1
|
|
473
|
+
|
|
474
|
+
from ..report import generate_html_report
|
|
475
|
+
report_html = generate_html_report(experiment, all_logs)
|
|
476
|
+
|
|
477
|
+
filename = output or f"experiment_{experiment_id[:8]}_report.html"
|
|
478
|
+
Path(filename).write_text(report_html)
|
|
479
|
+
console.print(f"[green]HTML report exported to:[/green] {filename}")
|