nooa-cli 0.0.7__py3-none-any.whl
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.
- nooa_cli/AGENTS.md +162 -0
- nooa_cli/__init__.py +70 -0
- nooa_cli/__main__.py +8 -0
- nooa_cli/_common.py +45 -0
- nooa_cli/commands/__init__.py +106 -0
- nooa_cli/commands/_otlp_helpers.py +122 -0
- nooa_cli/commands/_template.py +89 -0
- nooa_cli/commands/config.py +356 -0
- nooa_cli/commands/delete_traces.py +63 -0
- nooa_cli/commands/eval.py +68 -0
- nooa_cli/commands/import_harbor.py +512 -0
- nooa_cli/commands/import_traces.py +189 -0
- nooa_cli/commands/start_dev.py +145 -0
- nooa_cli/commands/traces.py +316 -0
- nooa_cli/completion.py +253 -0
- nooa_cli/tools/__init__.py +0 -0
- nooa_cli/tools/_tree_sitter_backend.py +351 -0
- nooa_cli/tools/pyp/__init__.py +71 -0
- nooa_cli/tools/pyp/errors.py +92 -0
- nooa_cli/tools/pyp/sources.py +458 -0
- nooa_cli/tools/pyp/stream.py +527 -0
- nooa_cli/tools/pyp/tests/__init__.py +0 -0
- nooa_cli/tools/pyp/tests/test_pyp.py +394 -0
- nooa_cli/tools/repo_tools.py +902 -0
- nooa_cli-0.0.7.dist-info/METADATA +52 -0
- nooa_cli-0.0.7.dist-info/RECORD +28 -0
- nooa_cli-0.0.7.dist-info/WHEEL +4 -0
- nooa_cli-0.0.7.dist-info/entry_points.txt +5 -0
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Import NVIDIA OO Agents OTLP traces from a Harbor job directory into the viewer.
|
|
4
|
+
|
|
5
|
+
Walks a Harbor job directory (or any directory containing one), finds all
|
|
6
|
+
traces under ``artifacts/traces/*.jsonl``, enriches them with Harbor metadata
|
|
7
|
+
(trial name, task name, reward score, experiment grouping), and posts them to
|
|
8
|
+
the viewer.
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
nooa import-harbor ./jobs/my-job/
|
|
12
|
+
nooa import-harbor ./workspaces/ --endpoint http://host:5001
|
|
13
|
+
nooa import-harbor ./jobs/ --experiment my-eval --batch-id run-42
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import time
|
|
18
|
+
import urllib.parse
|
|
19
|
+
import urllib.request
|
|
20
|
+
import uuid
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
import click
|
|
24
|
+
|
|
25
|
+
from ._otlp_helpers import (
|
|
26
|
+
check_endpoint_reachable,
|
|
27
|
+
inject_resource_attrs,
|
|
28
|
+
post_traces_batch,
|
|
29
|
+
session_exists,
|
|
30
|
+
validate_endpoint,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
NAME = "import-harbor"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _find_harbor_traces(root: Path) -> list[Path]:
|
|
37
|
+
"""Find all OTLP trace files nested under Harbor artifact directories.
|
|
38
|
+
|
|
39
|
+
Harbor copies the container's ``/logs/artifacts/`` to ``trial_dir/artifacts/``
|
|
40
|
+
on the host. The agent decides the layout within that directory — a common
|
|
41
|
+
convention is ``artifacts/traces/*.jsonl``, but we search the full subtree
|
|
42
|
+
to be robust to other layouts.
|
|
43
|
+
"""
|
|
44
|
+
return sorted(root.rglob("artifacts/**/*.jsonl"))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _read_json(path: Path) -> dict:
|
|
48
|
+
"""Read a JSON file, returning an empty dict on any failure."""
|
|
49
|
+
try:
|
|
50
|
+
return json.loads(path.read_text())
|
|
51
|
+
except Exception:
|
|
52
|
+
return {}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _coerce_float(value: object) -> float | None:
|
|
56
|
+
"""Coerce a value to float, returning None if it cannot be coerced."""
|
|
57
|
+
try:
|
|
58
|
+
return float(value) # type: ignore[arg-type]
|
|
59
|
+
except (TypeError, ValueError, OverflowError):
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _read_score(trial_dir: Path, trial_result: dict) -> float | None:
|
|
64
|
+
"""Read the trial reward score, trying current Harbor result shapes in order.
|
|
65
|
+
|
|
66
|
+
Different Harbor/BinPool versions write the scalar reward in different places.
|
|
67
|
+
Fallback order (first coercible float wins):
|
|
68
|
+
|
|
69
|
+
1. ``verifier/reward.json["score"]``
|
|
70
|
+
2. ``verifier/reward.json["reward"]``
|
|
71
|
+
3. ``result.json["verifier_result"]["rewards"]["score"]``
|
|
72
|
+
4. ``result.json["verifier_result"]["rewards"]["reward"]``
|
|
73
|
+
5. ``verifier/reward.txt`` (plain float string)
|
|
74
|
+
|
|
75
|
+
Explicit ``None`` checks (not truthiness) ensure a valid ``0.0`` is returned.
|
|
76
|
+
"""
|
|
77
|
+
reward_json = _read_json(trial_dir / "verifier" / "reward.json")
|
|
78
|
+
for key in ("score", "reward"):
|
|
79
|
+
score = _coerce_float(reward_json.get(key))
|
|
80
|
+
if score is not None:
|
|
81
|
+
return score
|
|
82
|
+
|
|
83
|
+
verifier_result = trial_result.get("verifier_result")
|
|
84
|
+
rewards = verifier_result.get("rewards") if isinstance(verifier_result, dict) else None
|
|
85
|
+
if isinstance(rewards, dict):
|
|
86
|
+
for key in ("score", "reward"):
|
|
87
|
+
score = _coerce_float(rewards.get(key))
|
|
88
|
+
if score is not None:
|
|
89
|
+
return score
|
|
90
|
+
|
|
91
|
+
reward_txt = trial_dir / "verifier" / "reward.txt"
|
|
92
|
+
if reward_txt.exists():
|
|
93
|
+
return _coerce_float(reward_txt.read_text().strip())
|
|
94
|
+
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _trial_meta(jsonl_path: Path) -> dict:
|
|
99
|
+
"""Extract Harbor metadata for a trace file from its surrounding directory structure.
|
|
100
|
+
|
|
101
|
+
Expected layout::
|
|
102
|
+
|
|
103
|
+
<job_dir>/
|
|
104
|
+
result.json ← job-level (stats.evals for experiment name)
|
|
105
|
+
<trial_name>/
|
|
106
|
+
result.json ← trial_name, task_name, agent_info
|
|
107
|
+
verifier/
|
|
108
|
+
reward.json ← {"reward"|"score": <float>} (or reward.txt)
|
|
109
|
+
artifacts/ ← copy of /logs/artifacts/ from container
|
|
110
|
+
[traces/] ← agent-defined layout; traces can be here
|
|
111
|
+
<file>.jsonl ← this file
|
|
112
|
+
"""
|
|
113
|
+
# Walk up from the JSONL file to find the 'artifacts' directory;
|
|
114
|
+
# trial_dir is its parent (works regardless of depth under artifacts/).
|
|
115
|
+
trial_dir = jsonl_path.parent
|
|
116
|
+
for parent in jsonl_path.parents:
|
|
117
|
+
if parent.name == "artifacts":
|
|
118
|
+
trial_dir = parent.parent
|
|
119
|
+
break
|
|
120
|
+
job_dir = trial_dir.parent
|
|
121
|
+
|
|
122
|
+
trial_result = _read_json(trial_dir / "result.json")
|
|
123
|
+
job_result = _read_json(job_dir / "result.json")
|
|
124
|
+
|
|
125
|
+
trial_name = trial_result.get("trial_name") or trial_dir.name
|
|
126
|
+
task_name = trial_result.get("task_name", "")
|
|
127
|
+
config = trial_result.get("config") if isinstance(trial_result.get("config"), dict) else {}
|
|
128
|
+
agent_config = config.get("agent") if isinstance(config.get("agent"), dict) else {}
|
|
129
|
+
task_config = config.get("task") if isinstance(config.get("task"), dict) else {}
|
|
130
|
+
agent_name = (trial_result.get("agent_info") or {}).get("name", "") or agent_config.get(
|
|
131
|
+
"name", ""
|
|
132
|
+
)
|
|
133
|
+
model_name = agent_config.get("model_name", "")
|
|
134
|
+
agent_type = ""
|
|
135
|
+
kwargs = agent_config.get("kwargs") if isinstance(agent_config.get("kwargs"), dict) else {}
|
|
136
|
+
if kwargs:
|
|
137
|
+
agent_type = str(kwargs.get("agent_type") or "")
|
|
138
|
+
source = trial_result.get("source") or task_config.get("source") or ""
|
|
139
|
+
|
|
140
|
+
# Reward scalar lives in different places across Harbor versions; see _read_score.
|
|
141
|
+
score = _read_score(trial_dir, trial_result)
|
|
142
|
+
|
|
143
|
+
# Keep the Harbor eval key as metadata, but group viewer Evaluations by
|
|
144
|
+
# Harbor job by default. The eval key is usually broad (for example,
|
|
145
|
+
# "nemo-oo-agents__swebench_all") and otherwise collapses separate model
|
|
146
|
+
# jobs into one row.
|
|
147
|
+
harbor_eval = ""
|
|
148
|
+
evals = (job_result.get("stats") or {}).get("evals") or {}
|
|
149
|
+
if evals:
|
|
150
|
+
harbor_eval = next(iter(evals))
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
"trial_name": trial_name,
|
|
154
|
+
"task_name": task_name,
|
|
155
|
+
"agent_name": agent_name,
|
|
156
|
+
"agent_type": agent_type,
|
|
157
|
+
"model_name": model_name,
|
|
158
|
+
"source": source,
|
|
159
|
+
"started_at": trial_result.get("started_at", ""),
|
|
160
|
+
"finished_at": trial_result.get("finished_at", ""),
|
|
161
|
+
"score": score,
|
|
162
|
+
"harbor_eval": harbor_eval,
|
|
163
|
+
"experiment": job_dir.name or harbor_eval or "harbor",
|
|
164
|
+
"job_name": job_dir.name,
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _harbor_resource_attrs(meta: dict, experiment: str, batch_id: str) -> dict[str, str | bool]:
|
|
169
|
+
"""Build viewer resource attrs that make a Harbor trial appear as an eval row."""
|
|
170
|
+
attrs: dict[str, str | bool] = {
|
|
171
|
+
"session.id": meta["trial_name"],
|
|
172
|
+
"experiment": experiment,
|
|
173
|
+
"batch_id": batch_id,
|
|
174
|
+
"eval.test_id": meta["task_name"] or meta["trial_name"],
|
|
175
|
+
"eval.test_name": meta["task_name"] or meta["trial_name"],
|
|
176
|
+
"eval.display_name": meta["task_name"] or meta["trial_name"],
|
|
177
|
+
"eval.method": "harbor",
|
|
178
|
+
"eval.harbor_trial_name": meta["trial_name"],
|
|
179
|
+
}
|
|
180
|
+
if meta.get("model_name"):
|
|
181
|
+
attrs["eval.model"] = str(meta["model_name"])
|
|
182
|
+
if meta.get("agent_type"):
|
|
183
|
+
attrs["eval.agent_class"] = str(meta["agent_type"])
|
|
184
|
+
elif meta.get("agent_name"):
|
|
185
|
+
attrs["eval.agent_class"] = str(meta["agent_name"])
|
|
186
|
+
if meta.get("agent_name"):
|
|
187
|
+
attrs["eval.agent_name"] = str(meta["agent_name"])
|
|
188
|
+
if meta.get("source"):
|
|
189
|
+
attrs["eval.suite_name"] = str(meta["source"])
|
|
190
|
+
if meta.get("harbor_eval"):
|
|
191
|
+
attrs["eval.harbor_eval"] = str(meta["harbor_eval"])
|
|
192
|
+
if meta.get("score") is not None:
|
|
193
|
+
score = meta["score"]
|
|
194
|
+
attrs["eval.score"] = str(score)
|
|
195
|
+
attrs["eval.weighted_score"] = str(score)
|
|
196
|
+
attrs["eval.passed"] = score >= 1.0
|
|
197
|
+
return attrs
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _find_matching_live_session(endpoint: str, meta: dict, experiment: str) -> str | None:
|
|
201
|
+
"""Ask the viewer for the live-streamed session matching this Harbor trial."""
|
|
202
|
+
task_name = meta.get("task_name")
|
|
203
|
+
if not task_name:
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
def request_match(search_experiment: str) -> str | None:
|
|
207
|
+
query = {
|
|
208
|
+
"task_name": task_name,
|
|
209
|
+
"model": meta.get("model_name") or "",
|
|
210
|
+
"started_at": meta.get("started_at") or "",
|
|
211
|
+
"finished_at": meta.get("finished_at") or "",
|
|
212
|
+
"experiment": search_experiment,
|
|
213
|
+
}
|
|
214
|
+
url = f"{endpoint.rstrip('/')}/api/eval/match-session?{urllib.parse.urlencode(query)}"
|
|
215
|
+
try:
|
|
216
|
+
req = urllib.request.Request(url, method="GET")
|
|
217
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
218
|
+
if resp.status >= 300:
|
|
219
|
+
return None
|
|
220
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
221
|
+
except Exception:
|
|
222
|
+
return None
|
|
223
|
+
match = data.get("match") if isinstance(data, dict) else None
|
|
224
|
+
if not isinstance(match, dict):
|
|
225
|
+
return None
|
|
226
|
+
session_id = match.get("session_id")
|
|
227
|
+
return str(session_id) if session_id else None
|
|
228
|
+
|
|
229
|
+
return request_match("default") or request_match(experiment)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _build_eval_only_body(resource_attrs: dict[str, str | bool]) -> dict:
|
|
233
|
+
"""Build a minimal OTLP payload that enriches/creates one viewer eval session."""
|
|
234
|
+
now_ns = time.time_ns()
|
|
235
|
+
passed = bool(resource_attrs.get("eval.passed", False))
|
|
236
|
+
score = resource_attrs.get("eval.score")
|
|
237
|
+
span_attrs: list[dict] = [{"key": "eval.passed", "value": {"boolValue": passed}}]
|
|
238
|
+
if score is not None:
|
|
239
|
+
try:
|
|
240
|
+
span_attrs.append({"key": "eval.score", "value": {"doubleValue": float(score)}})
|
|
241
|
+
span_attrs.append(
|
|
242
|
+
{"key": "eval.weighted_score", "value": {"doubleValue": float(score)}}
|
|
243
|
+
)
|
|
244
|
+
except (TypeError, ValueError):
|
|
245
|
+
span_attrs.append({"key": "eval.score", "value": {"stringValue": str(score)}})
|
|
246
|
+
|
|
247
|
+
def value(v: str | bool) -> dict:
|
|
248
|
+
if isinstance(v, bool):
|
|
249
|
+
return {"boolValue": v}
|
|
250
|
+
return {"stringValue": str(v)}
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
"resourceSpans": [
|
|
254
|
+
{
|
|
255
|
+
"resource": {
|
|
256
|
+
"attributes": [
|
|
257
|
+
{"key": key, "value": value(val)} for key, val in resource_attrs.items()
|
|
258
|
+
]
|
|
259
|
+
},
|
|
260
|
+
"scopeSpans": [
|
|
261
|
+
{
|
|
262
|
+
"scope": {"name": "harbor-import"},
|
|
263
|
+
"spans": [
|
|
264
|
+
{
|
|
265
|
+
"traceId": uuid.uuid4().hex,
|
|
266
|
+
"spanId": uuid.uuid4().hex[:16],
|
|
267
|
+
"name": "eval",
|
|
268
|
+
"kind": 1,
|
|
269
|
+
"startTimeUnixNano": str(now_ns),
|
|
270
|
+
"endTimeUnixNano": str(now_ns),
|
|
271
|
+
"attributes": span_attrs,
|
|
272
|
+
"status": {"code": 1 if passed else 2, "message": ""},
|
|
273
|
+
}
|
|
274
|
+
],
|
|
275
|
+
}
|
|
276
|
+
],
|
|
277
|
+
}
|
|
278
|
+
]
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _trial_dirs(root: Path) -> list[Path]:
|
|
283
|
+
"""Find Harbor trial directories under a job dir or parent dir."""
|
|
284
|
+
roots = [root]
|
|
285
|
+
roots.extend(p.parent for p in root.rglob("result.json") if p.parent != root)
|
|
286
|
+
out: list[Path] = []
|
|
287
|
+
seen: set[Path] = set()
|
|
288
|
+
for candidate in roots:
|
|
289
|
+
if candidate in seen:
|
|
290
|
+
continue
|
|
291
|
+
seen.add(candidate)
|
|
292
|
+
result = candidate / "result.json"
|
|
293
|
+
if not result.exists():
|
|
294
|
+
continue
|
|
295
|
+
data = _read_json(result)
|
|
296
|
+
if data.get("trial_name") or (candidate / "config.json").exists():
|
|
297
|
+
out.append(candidate)
|
|
298
|
+
return sorted(out)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _trial_meta_from_dir(trial_dir: Path) -> dict:
|
|
302
|
+
"""Extract Harbor metadata when only a trial directory is available."""
|
|
303
|
+
return _trial_meta(trial_dir / "artifacts" / "traces" / "_synthetic.jsonl")
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _import_trace_file(
|
|
307
|
+
endpoint: str,
|
|
308
|
+
jsonl_path: Path,
|
|
309
|
+
resource_attrs: dict[str, str | bool | int],
|
|
310
|
+
batch_lines: int,
|
|
311
|
+
batch_bytes: int,
|
|
312
|
+
) -> tuple[bool, list[str]]:
|
|
313
|
+
"""Import one OTLP JSONL file, posting its lines in batches.
|
|
314
|
+
|
|
315
|
+
Accumulates OTLP bodies and flushes them in batches: many ``resourceSpans``
|
|
316
|
+
envelopes are merged into one POST, avoiding one HTTP request per line. A flush
|
|
317
|
+
is triggered when the batch reaches ``batch_lines`` envelopes or ``batch_bytes``
|
|
318
|
+
of raw input (an approximation of the eventual POST size). Returns
|
|
319
|
+
``(file_imported, errors)`` where ``file_imported`` is True if any flush
|
|
320
|
+
succeeded (preserving the previous any-success semantics).
|
|
321
|
+
"""
|
|
322
|
+
file_imported = False
|
|
323
|
+
errors: list[str] = []
|
|
324
|
+
batch: list[dict] = []
|
|
325
|
+
batch_input_bytes = 0
|
|
326
|
+
flush_count = 0
|
|
327
|
+
|
|
328
|
+
def flush() -> None:
|
|
329
|
+
nonlocal file_imported, batch, batch_input_bytes, flush_count
|
|
330
|
+
if not batch:
|
|
331
|
+
return
|
|
332
|
+
flush_count += 1
|
|
333
|
+
if post_traces_batch(endpoint, batch):
|
|
334
|
+
file_imported = True
|
|
335
|
+
else:
|
|
336
|
+
errors.append(f"{jsonl_path.name}: batch #{flush_count} failed to post")
|
|
337
|
+
batch = []
|
|
338
|
+
batch_input_bytes = 0
|
|
339
|
+
|
|
340
|
+
with open(jsonl_path) as f:
|
|
341
|
+
for raw_line in f:
|
|
342
|
+
raw_line = raw_line.strip()
|
|
343
|
+
if not raw_line:
|
|
344
|
+
continue
|
|
345
|
+
try:
|
|
346
|
+
body = json.loads(raw_line)
|
|
347
|
+
except json.JSONDecodeError:
|
|
348
|
+
continue
|
|
349
|
+
if "resourceSpans" not in body:
|
|
350
|
+
continue
|
|
351
|
+
|
|
352
|
+
inject_resource_attrs(body, resource_attrs)
|
|
353
|
+
batch.append(body)
|
|
354
|
+
# Approximation: raw line length before injection; the re-serialized
|
|
355
|
+
# POST body (with injected resource attrs) is slightly larger.
|
|
356
|
+
batch_input_bytes += len(raw_line)
|
|
357
|
+
|
|
358
|
+
if len(batch) >= batch_lines or batch_input_bytes >= batch_bytes:
|
|
359
|
+
flush()
|
|
360
|
+
|
|
361
|
+
flush()
|
|
362
|
+
|
|
363
|
+
return file_imported, errors
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
@click.command()
|
|
367
|
+
@click.argument("path", type=click.Path(exists=True))
|
|
368
|
+
@click.option(
|
|
369
|
+
"--endpoint",
|
|
370
|
+
default="http://localhost:5001",
|
|
371
|
+
show_default=True,
|
|
372
|
+
help="Viewer API endpoint.",
|
|
373
|
+
)
|
|
374
|
+
@click.option(
|
|
375
|
+
"--experiment",
|
|
376
|
+
default=None,
|
|
377
|
+
help="Override experiment name (default: Harbor job directory name).",
|
|
378
|
+
)
|
|
379
|
+
@click.option(
|
|
380
|
+
"--batch-id",
|
|
381
|
+
default=None,
|
|
382
|
+
help="Batch ID for this import (default: job directory name).",
|
|
383
|
+
)
|
|
384
|
+
@click.option(
|
|
385
|
+
"--batch-lines",
|
|
386
|
+
default=1000,
|
|
387
|
+
show_default=True,
|
|
388
|
+
help="Max OTLP lines combined into a single POST (per trace file).",
|
|
389
|
+
)
|
|
390
|
+
@click.option(
|
|
391
|
+
"--batch-bytes",
|
|
392
|
+
default=4_000_000,
|
|
393
|
+
show_default=True,
|
|
394
|
+
help="Max raw input bytes accumulated before flushing a POST (per trace file).",
|
|
395
|
+
)
|
|
396
|
+
@click.option(
|
|
397
|
+
"--eval-only",
|
|
398
|
+
is_flag=True,
|
|
399
|
+
help="Post Harbor result metadata as eval spans without importing trace JSONL files.",
|
|
400
|
+
)
|
|
401
|
+
def command(
|
|
402
|
+
path: str,
|
|
403
|
+
endpoint: str,
|
|
404
|
+
experiment: str | None,
|
|
405
|
+
batch_id: str | None,
|
|
406
|
+
batch_lines: int,
|
|
407
|
+
batch_bytes: int,
|
|
408
|
+
eval_only: bool,
|
|
409
|
+
):
|
|
410
|
+
"""Import NVIDIA OO Agents OTLP traces from a Harbor job directory.
|
|
411
|
+
|
|
412
|
+
\b
|
|
413
|
+
PATH can be:
|
|
414
|
+
- A Harbor job directory (contains result.json + trial subdirs)
|
|
415
|
+
- Any parent directory — traces are discovered recursively
|
|
416
|
+
|
|
417
|
+
\b
|
|
418
|
+
Examples:
|
|
419
|
+
nooa import-harbor ./jobs/my-job/
|
|
420
|
+
nooa import-harbor ./workspaces/ --endpoint http://host:5001
|
|
421
|
+
nooa import-harbor ./jobs/ --experiment my-eval
|
|
422
|
+
nooa import-harbor ./jobs/ --batch-lines 2000 --batch-bytes 8000000
|
|
423
|
+
|
|
424
|
+
OTLP lines are posted in batches (combining many resourceSpans into one
|
|
425
|
+
request) to keep large imports fast; tune with --batch-lines/--batch-bytes.
|
|
426
|
+
"""
|
|
427
|
+
root = Path(path)
|
|
428
|
+
files = _find_harbor_traces(root)
|
|
429
|
+
|
|
430
|
+
validate_endpoint(endpoint)
|
|
431
|
+
|
|
432
|
+
if not check_endpoint_reachable(endpoint):
|
|
433
|
+
click.echo(f"Cannot reach viewer at {endpoint}. Is it running?")
|
|
434
|
+
raise SystemExit(1)
|
|
435
|
+
|
|
436
|
+
imported = 0
|
|
437
|
+
skipped = 0
|
|
438
|
+
already_exist = 0
|
|
439
|
+
errors = []
|
|
440
|
+
|
|
441
|
+
if eval_only:
|
|
442
|
+
trial_dirs = _trial_dirs(root)
|
|
443
|
+
if not trial_dirs:
|
|
444
|
+
click.echo(f"No Harbor trial result directories found under {path}")
|
|
445
|
+
raise SystemExit(1)
|
|
446
|
+
click.echo(f"Found {len(trial_dirs)} Harbor trial result(s)...")
|
|
447
|
+
for trial_dir in trial_dirs:
|
|
448
|
+
meta = _trial_meta_from_dir(trial_dir)
|
|
449
|
+
session_id = meta["trial_name"]
|
|
450
|
+
exp = experiment or meta["experiment"]
|
|
451
|
+
bid = batch_id or meta["job_name"]
|
|
452
|
+
resource_attrs = _harbor_resource_attrs(meta, exp, bid)
|
|
453
|
+
matched_session_id = _find_matching_live_session(endpoint, meta, exp)
|
|
454
|
+
if matched_session_id:
|
|
455
|
+
resource_attrs["session.id"] = matched_session_id
|
|
456
|
+
body = _build_eval_only_body(resource_attrs)
|
|
457
|
+
if post_traces_batch(endpoint, [body]):
|
|
458
|
+
imported += 1
|
|
459
|
+
score_str = f"{meta['score']:.3f}" if meta["score"] is not None else "n/a"
|
|
460
|
+
match_str = f" -> {matched_session_id}" if matched_session_id else ""
|
|
461
|
+
click.echo(
|
|
462
|
+
f" + {session_id}{match_str} score={score_str} task={meta['task_name']}"
|
|
463
|
+
)
|
|
464
|
+
else:
|
|
465
|
+
skipped += 1
|
|
466
|
+
errors.append(f"{session_id}: failed to post eval metadata")
|
|
467
|
+
else:
|
|
468
|
+
if not files:
|
|
469
|
+
click.echo(f"No Harbor trace files found under {path}")
|
|
470
|
+
click.echo("Expected: <job>/<trial>/artifacts/traces/*.jsonl")
|
|
471
|
+
click.echo("Tip: use --eval-only to group Harbor result metadata without trace files")
|
|
472
|
+
raise SystemExit(1)
|
|
473
|
+
|
|
474
|
+
click.echo(f"Found {len(files)} trace file(s)...")
|
|
475
|
+
|
|
476
|
+
for jsonl_path in files:
|
|
477
|
+
meta = _trial_meta(jsonl_path)
|
|
478
|
+
session_id = meta["trial_name"]
|
|
479
|
+
exp = experiment or meta["experiment"]
|
|
480
|
+
bid = batch_id or meta["job_name"]
|
|
481
|
+
|
|
482
|
+
if session_exists(endpoint, session_id):
|
|
483
|
+
click.echo(f" ! {session_id}: already exists, skipping")
|
|
484
|
+
already_exist += 1
|
|
485
|
+
continue
|
|
486
|
+
|
|
487
|
+
resource_attrs = _harbor_resource_attrs(meta, exp, bid)
|
|
488
|
+
|
|
489
|
+
file_imported, file_errors = _import_trace_file(
|
|
490
|
+
endpoint, jsonl_path, resource_attrs, batch_lines, batch_bytes
|
|
491
|
+
)
|
|
492
|
+
errors.extend(file_errors)
|
|
493
|
+
|
|
494
|
+
if file_imported:
|
|
495
|
+
imported += 1
|
|
496
|
+
score_str = f"{meta['score']:.3f}" if meta["score"] is not None else "n/a"
|
|
497
|
+
click.echo(f" + {session_id} score={score_str} task={meta['task_name']}")
|
|
498
|
+
else:
|
|
499
|
+
skipped += 1
|
|
500
|
+
|
|
501
|
+
click.echo(f"\n{imported} imported, {skipped} skipped, {already_exist} already existed")
|
|
502
|
+
if errors:
|
|
503
|
+
for err in errors[:10]:
|
|
504
|
+
click.echo(f" ! {err}")
|
|
505
|
+
if len(errors) > 10:
|
|
506
|
+
click.echo(f" ... and {len(errors) - 10} more errors")
|
|
507
|
+
|
|
508
|
+
if imported:
|
|
509
|
+
encoded_batch = urllib.parse.quote(bid or "", safe="")
|
|
510
|
+
encoded_exp = urllib.parse.quote(exp or "", safe="")
|
|
511
|
+
click.echo(f"\nView at: {endpoint}/traces?batch_id={encoded_batch}")
|
|
512
|
+
click.echo(f"Evaluations: {endpoint}/evaluations/{encoded_exp}")
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Import OTLP trace .jsonl files into the viewer.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
nooa import-traces ./traces/
|
|
7
|
+
nooa import-traces my_trace.jsonl --endpoint http://host:5001
|
|
8
|
+
nooa import-traces ./experiment/ --batch-id my-experiment-v2
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import urllib.parse
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from uuid import uuid4
|
|
16
|
+
|
|
17
|
+
import click
|
|
18
|
+
|
|
19
|
+
from ._otlp_helpers import (
|
|
20
|
+
check_endpoint_reachable,
|
|
21
|
+
inject_resource_attrs,
|
|
22
|
+
post_annotations,
|
|
23
|
+
post_trace,
|
|
24
|
+
session_exists,
|
|
25
|
+
validate_endpoint,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
NAME = "import-traces"
|
|
29
|
+
|
|
30
|
+
TRACE_EXTENSIONS = {".jsonl"}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _find_trace_files(path: Path) -> list[Path]:
|
|
34
|
+
"""Find all trace JSONL files in a file or directory."""
|
|
35
|
+
if path.is_file():
|
|
36
|
+
return [path]
|
|
37
|
+
if not path.is_dir():
|
|
38
|
+
return []
|
|
39
|
+
files = []
|
|
40
|
+
for ext in TRACE_EXTENSIONS:
|
|
41
|
+
files.extend(path.rglob(f"*{ext}"))
|
|
42
|
+
seen = set()
|
|
43
|
+
unique = []
|
|
44
|
+
for f in sorted(files):
|
|
45
|
+
if f.resolve() not in seen:
|
|
46
|
+
seen.add(f.resolve())
|
|
47
|
+
unique.append(f)
|
|
48
|
+
return unique
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _detect_format(line: dict) -> str:
|
|
52
|
+
"""Detect whether a parsed JSON line is OTLP or legacy format."""
|
|
53
|
+
if "resourceSpans" in line:
|
|
54
|
+
return "otlp"
|
|
55
|
+
if "span_id" in line or "trace_id" in line:
|
|
56
|
+
return "legacy"
|
|
57
|
+
return "unknown"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _session_id_from_filename(path: Path) -> str:
|
|
61
|
+
"""Derive a session ID from the trace file's basename."""
|
|
62
|
+
name = path.name
|
|
63
|
+
for ext in sorted(TRACE_EXTENSIONS, key=len, reverse=True):
|
|
64
|
+
if name.endswith(ext):
|
|
65
|
+
return name[: -len(ext)]
|
|
66
|
+
return path.stem
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@click.command()
|
|
70
|
+
@click.argument("path", type=click.Path(exists=True))
|
|
71
|
+
@click.option(
|
|
72
|
+
"--endpoint",
|
|
73
|
+
default="http://localhost:5001",
|
|
74
|
+
show_default=True,
|
|
75
|
+
help="Viewer API endpoint.",
|
|
76
|
+
)
|
|
77
|
+
@click.option(
|
|
78
|
+
"--batch-id",
|
|
79
|
+
default=None,
|
|
80
|
+
help="Batch ID for this import (default: auto-generated).",
|
|
81
|
+
)
|
|
82
|
+
def command(path: str, endpoint: str, batch_id: str | None):
|
|
83
|
+
"""Import OTLP trace .jsonl files into the viewer."""
|
|
84
|
+
target = Path(path)
|
|
85
|
+
files = _find_trace_files(target)
|
|
86
|
+
|
|
87
|
+
if not files:
|
|
88
|
+
click.echo(f"No trace files found in {path}")
|
|
89
|
+
raise SystemExit(1)
|
|
90
|
+
|
|
91
|
+
validate_endpoint(endpoint)
|
|
92
|
+
|
|
93
|
+
if batch_id is None:
|
|
94
|
+
batch_id = f"import_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid4().hex[:6]}"
|
|
95
|
+
|
|
96
|
+
# Verify endpoint is reachable
|
|
97
|
+
if not check_endpoint_reachable(endpoint):
|
|
98
|
+
click.echo(f"Cannot reach viewer at {endpoint}. Is it running?")
|
|
99
|
+
raise SystemExit(1)
|
|
100
|
+
|
|
101
|
+
click.echo(f"Importing {len(files)} trace file(s) (batch_id={batch_id})...")
|
|
102
|
+
|
|
103
|
+
imported = 0
|
|
104
|
+
skipped = 0
|
|
105
|
+
already_exist = 0
|
|
106
|
+
annotations_imported = 0
|
|
107
|
+
errors = []
|
|
108
|
+
|
|
109
|
+
for file in files:
|
|
110
|
+
session_id = _session_id_from_filename(file)
|
|
111
|
+
|
|
112
|
+
# Check for existing session before importing
|
|
113
|
+
if session_exists(endpoint, session_id):
|
|
114
|
+
click.echo(
|
|
115
|
+
f" ! {file.name}: session '{session_id}' already exists, skipping "
|
|
116
|
+
f"(delete it first or rename the file to import as a new session)"
|
|
117
|
+
)
|
|
118
|
+
already_exist += 1
|
|
119
|
+
continue
|
|
120
|
+
|
|
121
|
+
inject_attrs = {"batch_id": batch_id}
|
|
122
|
+
|
|
123
|
+
file_has_session = False
|
|
124
|
+
file_imported = False
|
|
125
|
+
is_legacy = False
|
|
126
|
+
deferred_annotations: list[dict] = []
|
|
127
|
+
|
|
128
|
+
with open(file) as f:
|
|
129
|
+
for line_num, raw_line in enumerate(f, 1):
|
|
130
|
+
raw_line = raw_line.strip()
|
|
131
|
+
if not raw_line:
|
|
132
|
+
continue
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
body = json.loads(raw_line)
|
|
136
|
+
except json.JSONDecodeError:
|
|
137
|
+
continue
|
|
138
|
+
|
|
139
|
+
# Handle annotation lines from exported traces
|
|
140
|
+
if "annotations" in body and "resourceSpans" not in body:
|
|
141
|
+
anns = body["annotations"]
|
|
142
|
+
if isinstance(anns, list):
|
|
143
|
+
deferred_annotations.extend(anns)
|
|
144
|
+
continue
|
|
145
|
+
|
|
146
|
+
fmt = _detect_format(body)
|
|
147
|
+
|
|
148
|
+
if fmt == "legacy":
|
|
149
|
+
if not is_legacy:
|
|
150
|
+
errors.append(f"{file.name}: legacy format not supported, skipping")
|
|
151
|
+
is_legacy = True
|
|
152
|
+
continue
|
|
153
|
+
|
|
154
|
+
if fmt != "otlp":
|
|
155
|
+
continue
|
|
156
|
+
|
|
157
|
+
if not file_has_session:
|
|
158
|
+
inject_attrs["session.id"] = session_id
|
|
159
|
+
file_has_session = True
|
|
160
|
+
|
|
161
|
+
inject_resource_attrs(body, inject_attrs)
|
|
162
|
+
|
|
163
|
+
if post_trace(endpoint, body):
|
|
164
|
+
file_imported = True
|
|
165
|
+
else:
|
|
166
|
+
errors.append(f"{file.name}:{line_num}: failed to post")
|
|
167
|
+
|
|
168
|
+
if file_imported:
|
|
169
|
+
imported += 1
|
|
170
|
+
# Import annotations after spans so the session exists
|
|
171
|
+
if deferred_annotations:
|
|
172
|
+
count = post_annotations(endpoint, deferred_annotations)
|
|
173
|
+
annotations_imported += count
|
|
174
|
+
elif not is_legacy:
|
|
175
|
+
skipped += 1
|
|
176
|
+
|
|
177
|
+
click.echo(f" {imported} imported, {skipped} skipped")
|
|
178
|
+
if already_exist:
|
|
179
|
+
click.echo(f" {already_exist} skipped (already exist)")
|
|
180
|
+
if annotations_imported:
|
|
181
|
+
click.echo(f" {annotations_imported} annotation(s) imported")
|
|
182
|
+
if errors:
|
|
183
|
+
for err in errors[:10]:
|
|
184
|
+
click.echo(f" ! {err}")
|
|
185
|
+
if len(errors) > 10:
|
|
186
|
+
click.echo(f" ... and {len(errors) - 10} more errors")
|
|
187
|
+
|
|
188
|
+
encoded_batch = urllib.parse.quote(batch_id, safe="")
|
|
189
|
+
click.echo(f"\nView at: {endpoint}/traces?batch_id={encoded_batch}")
|