10xgraph-api 0.7.0__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.
- 10xgraph_api-0.7.0.dist-info/METADATA +618 -0
- 10xgraph_api-0.7.0.dist-info/RECORD +203 -0
- 10xgraph_api-0.7.0.dist-info/WHEEL +5 -0
- 10xgraph_api-0.7.0.dist-info/entry_points.txt +3 -0
- 10xgraph_api-0.7.0.dist-info/licenses/LICENSE +21 -0
- 10xgraph_api-0.7.0.dist-info/top_level.txt +2 -0
- agentflow_cli/__init__.py +69 -0
- tenxgraph_api/__init__.py +23 -0
- tenxgraph_api/cli/__init__.py +6 -0
- tenxgraph_api/cli/capabilities.py +127 -0
- tenxgraph_api/cli/commands/__init__.py +49 -0
- tenxgraph_api/cli/commands/api.py +262 -0
- tenxgraph_api/cli/commands/audit.py +213 -0
- tenxgraph_api/cli/commands/build.py +305 -0
- tenxgraph_api/cli/commands/config.py +71 -0
- tenxgraph_api/cli/commands/demo.py +93 -0
- tenxgraph_api/cli/commands/eval.py +909 -0
- tenxgraph_api/cli/commands/init.py +531 -0
- tenxgraph_api/cli/commands/skills.py +532 -0
- tenxgraph_api/cli/commands/test.py +113 -0
- tenxgraph_api/cli/commands/version.py +47 -0
- tenxgraph_api/cli/config_editor/__init__.py +15 -0
- tenxgraph_api/cli/config_editor/schema.py +566 -0
- tenxgraph_api/cli/config_editor/server.py +240 -0
- tenxgraph_api/cli/config_editor/static/app.css +2 -0
- tenxgraph_api/cli/config_editor/static/app.js +3 -0
- tenxgraph_api/cli/config_editor/static/favicon.svg +11 -0
- tenxgraph_api/cli/config_editor/static/index.html +20 -0
- tenxgraph_api/cli/config_editor/store.py +120 -0
- tenxgraph_api/cli/config_editor/validation.py +377 -0
- tenxgraph_api/cli/constants.py +112 -0
- tenxgraph_api/cli/context.py +46 -0
- tenxgraph_api/cli/core/__init__.py +0 -0
- tenxgraph_api/cli/core/animations.py +550 -0
- tenxgraph_api/cli/core/config.py +279 -0
- tenxgraph_api/cli/core/output.py +625 -0
- tenxgraph_api/cli/core/prompts.py +220 -0
- tenxgraph_api/cli/core/screen.py +287 -0
- tenxgraph_api/cli/core/steps.py +621 -0
- tenxgraph_api/cli/core/theme.py +242 -0
- tenxgraph_api/cli/core/validation.py +251 -0
- tenxgraph_api/cli/exceptions.py +129 -0
- tenxgraph_api/cli/logger.py +84 -0
- tenxgraph_api/cli/main.py +936 -0
- tenxgraph_api/cli/templates/__init__.py +0 -0
- tenxgraph_api/cli/templates/defaults.py +844 -0
- tenxgraph_api/cli/templates/dev/.env.example +1 -0
- tenxgraph_api/cli/templates/dev/10xgraph.json +16 -0
- tenxgraph_api/cli/templates/dev/graph/__init__.py +0 -0
- tenxgraph_api/cli/templates/dev/graph/agent.py +63 -0
- tenxgraph_api/cli/templates/prod/.env.example +75 -0
- tenxgraph_api/cli/templates/prod/.pre-commit-config.yaml +50 -0
- tenxgraph_api/cli/templates/prod/.python-version +1 -0
- tenxgraph_api/cli/templates/prod/10xgraph.json +42 -0
- tenxgraph_api/cli/templates/prod/auth/__init__.py +0 -0
- tenxgraph_api/cli/templates/prod/auth/agent_auth.py +46 -0
- tenxgraph_api/cli/templates/prod/evals/__init__.py +0 -0
- tenxgraph_api/cli/templates/prod/evals/user_simulator_eval.py +76 -0
- tenxgraph_api/cli/templates/prod/evals/weather_agents_eval.py +53 -0
- tenxgraph_api/cli/templates/prod/graph/__init__.py +0 -0
- tenxgraph_api/cli/templates/prod/graph/agent.py +50 -0
- tenxgraph_api/cli/templates/prod/graph/state.py +5 -0
- tenxgraph_api/cli/templates/prod/graph/thread_name_generator.py +7 -0
- tenxgraph_api/cli/templates/prod/graph/tools/__init__.py +0 -0
- tenxgraph_api/cli/templates/prod/graph/tools/weather_tool.py +43 -0
- tenxgraph_api/cli/templates/prod/graph/validators/__init__.py +0 -0
- tenxgraph_api/cli/templates/prod/graph/validators/lifecyle.py +107 -0
- tenxgraph_api/cli/templates/prod/graph/validators/manager.py +9 -0
- tenxgraph_api/cli/templates/prod/graph/validators/validators.py +21 -0
- tenxgraph_api/cli/templates/prod/pyproject.toml +79 -0
- tenxgraph_api/cli/templates/prod/tests/__init__.py +0 -0
- tenxgraph_api/cli/templates/prod/tests/conftest.py +6 -0
- tenxgraph_api/cli/templates/prod/tests/test_agent_eval.py +78 -0
- tenxgraph_api/cli/templates/prod/tests/test_catalog_tools.py +47 -0
- tenxgraph_api/cli/templates/prod/tests/test_graph_nodes.py +57 -0
- tenxgraph_api/cli/templates/skills/10xgraph/SKILL.md +92 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/agents-and-tools.md +389 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/api-client.md +61 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/api-configuration.md +75 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/api-settings-and-middleware.md +89 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/architecture.md +91 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/auth-and-authorization.md +149 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/callbacks-and-command.md +283 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/checkpointing-and-threads.md +63 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/cli-commands.md +73 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/client-auth-and-errors.md +94 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/client-messages-invoke-stream.md +115 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/client-threads-memory-files.md +80 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/context-id-background.md +80 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/dependency-injection.md +58 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/evaluation.md +500 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/id-and-thread-name-generators.md +69 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/media-and-files.md +66 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/memory-and-store.md +70 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/prebuilt-agents-and-tools.md +394 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/production-runtime.md +56 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/providers-and-adapters.md +89 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/publishers-and-runtime-protocols.md +84 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/rate-limiting.md +194 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/realtime.md +172 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/remote-tools.md +55 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/rest-api-and-errors.md +97 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/security-and-validators.md +86 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/state-and-messages.md +68 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/state-graph.md +232 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/stream-emitter.md +144 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/streaming.md +53 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/testing-and-evaluation.md +64 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/unit-testing.md +202 -0
- tenxgraph_api/cli/templates/skills/copilot/10xgraph.instructions.md +108 -0
- tenxgraph_api/py.typed +0 -0
- tenxgraph_api/src/__init__.py +0 -0
- tenxgraph_api/src/app/__init__.py +13 -0
- tenxgraph_api/src/app/core/__init__.py +15 -0
- tenxgraph_api/src/app/core/auth/__init__.py +0 -0
- tenxgraph_api/src/app/core/auth/auth_backend.py +38 -0
- tenxgraph_api/src/app/core/auth/authorization.py +379 -0
- tenxgraph_api/src/app/core/auth/base_auth.py +27 -0
- tenxgraph_api/src/app/core/auth/jwt_auth.py +122 -0
- tenxgraph_api/src/app/core/auth/ownership_resolver.py +170 -0
- tenxgraph_api/src/app/core/auth/permissions.py +354 -0
- tenxgraph_api/src/app/core/auth/request_config.py +130 -0
- tenxgraph_api/src/app/core/auth/route_guard.py +105 -0
- tenxgraph_api/src/app/core/config/__init__.py +0 -0
- tenxgraph_api/src/app/core/config/graph_config.py +532 -0
- tenxgraph_api/src/app/core/config/media_settings.py +72 -0
- tenxgraph_api/src/app/core/config/sentry_config.py +60 -0
- tenxgraph_api/src/app/core/config/settings.py +202 -0
- tenxgraph_api/src/app/core/config/setup_logs.py +65 -0
- tenxgraph_api/src/app/core/config/setup_middleware.py +483 -0
- tenxgraph_api/src/app/core/config/worker_middleware.py +99 -0
- tenxgraph_api/src/app/core/exceptions/__init__.py +17 -0
- tenxgraph_api/src/app/core/exceptions/general_exception.py +58 -0
- tenxgraph_api/src/app/core/exceptions/handle_errors.py +363 -0
- tenxgraph_api/src/app/core/exceptions/resources_exceptions.py +72 -0
- tenxgraph_api/src/app/core/exceptions/user_exception.py +58 -0
- tenxgraph_api/src/app/core/middleware/__init__.py +25 -0
- tenxgraph_api/src/app/core/middleware/rate_limit/__init__.py +15 -0
- tenxgraph_api/src/app/core/middleware/rate_limit/base.py +27 -0
- tenxgraph_api/src/app/core/middleware/rate_limit/factory.py +98 -0
- tenxgraph_api/src/app/core/middleware/rate_limit/keying.py +133 -0
- tenxgraph_api/src/app/core/middleware/rate_limit/memory.py +98 -0
- tenxgraph_api/src/app/core/middleware/rate_limit/middleware.py +90 -0
- tenxgraph_api/src/app/core/middleware/rate_limit/redis.py +115 -0
- tenxgraph_api/src/app/core/middleware/request_limits.py +154 -0
- tenxgraph_api/src/app/core/middleware/security_headers.py +239 -0
- tenxgraph_api/src/app/core/utils/__init__.py +14 -0
- tenxgraph_api/src/app/core/utils/log_sanitizer.py +216 -0
- tenxgraph_api/src/app/loader.py +516 -0
- tenxgraph_api/src/app/main.py +180 -0
- tenxgraph_api/src/app/routers/__init__.py +4 -0
- tenxgraph_api/src/app/routers/ag_ui/__init__.py +10 -0
- tenxgraph_api/src/app/routers/ag_ui/converter.py +195 -0
- tenxgraph_api/src/app/routers/ag_ui/event_mapper.py +353 -0
- tenxgraph_api/src/app/routers/ag_ui/router.py +90 -0
- tenxgraph_api/src/app/routers/ag_ui/service.py +193 -0
- tenxgraph_api/src/app/routers/checkpointer/__init__.py +1 -0
- tenxgraph_api/src/app/routers/checkpointer/router.py +482 -0
- tenxgraph_api/src/app/routers/checkpointer/schemas/__init__.py +1 -0
- tenxgraph_api/src/app/routers/checkpointer/schemas/checkpointer_schemas.py +121 -0
- tenxgraph_api/src/app/routers/checkpointer/services/__init__.py +1 -0
- tenxgraph_api/src/app/routers/checkpointer/services/checkpointer_service.py +300 -0
- tenxgraph_api/src/app/routers/evals/__init__.py +6 -0
- tenxgraph_api/src/app/routers/evals/router.py +38 -0
- tenxgraph_api/src/app/routers/evals/services/__init__.py +1 -0
- tenxgraph_api/src/app/routers/evals/services/eval_report_service.py +354 -0
- tenxgraph_api/src/app/routers/graph/__init__.py +6 -0
- tenxgraph_api/src/app/routers/graph/realtime_guard.py +161 -0
- tenxgraph_api/src/app/routers/graph/router.py +821 -0
- tenxgraph_api/src/app/routers/graph/schemas/__init__.py +1 -0
- tenxgraph_api/src/app/routers/graph/schemas/graph_schemas.py +386 -0
- tenxgraph_api/src/app/routers/graph/services/__init__.py +1 -0
- tenxgraph_api/src/app/routers/graph/services/graph_service.py +1219 -0
- tenxgraph_api/src/app/routers/graph/services/multimodal_preprocessor.py +164 -0
- tenxgraph_api/src/app/routers/media/__init__.py +506 -0
- tenxgraph_api/src/app/routers/media/router.py +242 -0
- tenxgraph_api/src/app/routers/media/schemas.py +39 -0
- tenxgraph_api/src/app/routers/ping/__init__.py +4 -0
- tenxgraph_api/src/app/routers/ping/router.py +28 -0
- tenxgraph_api/src/app/routers/setup_router.py +60 -0
- tenxgraph_api/src/app/routers/store/__init__.py +4 -0
- tenxgraph_api/src/app/routers/store/router.py +200 -0
- tenxgraph_api/src/app/routers/store/schemas/__init__.py +28 -0
- tenxgraph_api/src/app/routers/store/schemas/store_schemas.py +200 -0
- tenxgraph_api/src/app/routers/store/services/__init__.py +4 -0
- tenxgraph_api/src/app/routers/store/services/store_service.py +203 -0
- tenxgraph_api/src/app/tasks/__init__.py +0 -0
- tenxgraph_api/src/app/tasks/user_tasks.py +38 -0
- tenxgraph_api/src/app/utils/__init__.py +12 -0
- tenxgraph_api/src/app/utils/callable_helper.py +26 -0
- tenxgraph_api/src/app/utils/media/__init__.py +15 -0
- tenxgraph_api/src/app/utils/media/extractor.py +172 -0
- tenxgraph_api/src/app/utils/media/pipeline.py +83 -0
- tenxgraph_api/src/app/utils/parse_output.py +17 -0
- tenxgraph_api/src/app/utils/response_helper.py +129 -0
- tenxgraph_api/src/app/utils/schemas/__init__.py +11 -0
- tenxgraph_api/src/app/utils/schemas/output_schemas.py +28 -0
- tenxgraph_api/src/app/utils/schemas/user_schemas.py +13 -0
- tenxgraph_api/src/app/utils/snowflake_id_generator.py +90 -0
- tenxgraph_api/src/app/utils/swagger_helper.py +160 -0
- tenxgraph_api/src/app/utils/telemetry_store.py +123 -0
- tenxgraph_api/src/app/utils/thread_name_generator.py +304 -0
- tenxgraph_api/src/app/worker.py +91 -0
|
@@ -0,0 +1,909 @@
|
|
|
1
|
+
"""Eval command — discover and run 10xGraph evaluations, always generating reports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import importlib
|
|
7
|
+
import importlib.util
|
|
8
|
+
import inspect
|
|
9
|
+
import sys
|
|
10
|
+
import typing
|
|
11
|
+
import webbrowser
|
|
12
|
+
from collections import defaultdict
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import TYPE_CHECKING, Any
|
|
16
|
+
|
|
17
|
+
from injectq.testing import override_dependency
|
|
18
|
+
from tenxgraph.qa.evaluation import CriteriaConfig, CriterionConfig, EvalConfig
|
|
19
|
+
from tenxgraph.qa.evaluation.collectors.trajectory_collector import (
|
|
20
|
+
TrajectoryCollector,
|
|
21
|
+
make_trajectory_callback,
|
|
22
|
+
)
|
|
23
|
+
from tenxgraph.qa.evaluation.config.eval_config import ReporterConfig
|
|
24
|
+
from tenxgraph.qa.evaluation.eval_result import EvalCaseResult
|
|
25
|
+
from tenxgraph.qa.evaluation.eval_result import EvalReport as ER
|
|
26
|
+
from tenxgraph.qa.evaluation.evaluator import AgentEvaluator
|
|
27
|
+
from tenxgraph.qa.evaluation.reporters.manager import ReporterManager
|
|
28
|
+
from tenxgraph.utils.callbacks import CallbackManager
|
|
29
|
+
|
|
30
|
+
from tenxgraph_api.cli.commands import BaseCommand
|
|
31
|
+
from tenxgraph_api.cli.core.config import ConfigManager
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from tenxgraph.qa.evaluation.eval_result import EvalReport
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class _PendingCase:
|
|
40
|
+
case: Any # EvalCase
|
|
41
|
+
evaluator: AgentEvaluator
|
|
42
|
+
config: Any # EvalConfig — the resolved config for this case
|
|
43
|
+
file_name: str
|
|
44
|
+
eval_set_id: str
|
|
45
|
+
eval_set_name: str
|
|
46
|
+
config_source: str = "" # where config came from: per-file / confeval.py / defaults
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class _PendingSimulation:
|
|
51
|
+
scenario: Any # ConversationScenario
|
|
52
|
+
graph: Any
|
|
53
|
+
simulator: Any # UserSimulator
|
|
54
|
+
file_name: str
|
|
55
|
+
eval_set_id: str
|
|
56
|
+
eval_set_name: str
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _reset_inject_proxy(service_type: type) -> None:
|
|
60
|
+
"""Reset the lazy Inject[T] proxy cached on Node.execute's default parameters.
|
|
61
|
+
|
|
62
|
+
Inject[T] proxies cache their resolved value in _injected_value on first use.
|
|
63
|
+
container.override() clears the singleton scope, but the proxy's own cache is
|
|
64
|
+
independent — it must be cleared so the proxy re-resolves from the container
|
|
65
|
+
on next invocation instead of returning the stale value.
|
|
66
|
+
"""
|
|
67
|
+
try:
|
|
68
|
+
from tenxgraph.core.graph.node import Node as _GraphNode
|
|
69
|
+
|
|
70
|
+
defaults = getattr(_GraphNode.execute, "__defaults__", None) or ()
|
|
71
|
+
for d in defaults:
|
|
72
|
+
if (
|
|
73
|
+
hasattr(d, "_injected_value")
|
|
74
|
+
and hasattr(d, "service_type")
|
|
75
|
+
and d.service_type is service_type
|
|
76
|
+
):
|
|
77
|
+
d._injected_value = None
|
|
78
|
+
except Exception: # nosec B110 # noqa: S110
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
DEFAULT_CONCURRENCY = 4
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class EvalCommand(BaseCommand):
|
|
86
|
+
"""Discover and run agent evaluations; always write HTML + JSON reports."""
|
|
87
|
+
|
|
88
|
+
# ------------------------------------------------------------------
|
|
89
|
+
# Discovery
|
|
90
|
+
# ------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
def _discover(self, target: Path) -> list[Path]:
|
|
93
|
+
"""Return eval files under target. If target is a file, return it directly."""
|
|
94
|
+
if target.is_file():
|
|
95
|
+
return [target]
|
|
96
|
+
|
|
97
|
+
seen: dict[Path, None] = {}
|
|
98
|
+
for pattern in ("*_eval.py", "eval_*.py"):
|
|
99
|
+
for p in sorted(target.rglob(pattern)):
|
|
100
|
+
seen[p] = None
|
|
101
|
+
return list(seen)
|
|
102
|
+
|
|
103
|
+
# ------------------------------------------------------------------
|
|
104
|
+
# Module loading
|
|
105
|
+
# ------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
def _load_module(self, path: Path) -> Any:
|
|
108
|
+
project_root = str(Path.cwd())
|
|
109
|
+
if project_root not in sys.path:
|
|
110
|
+
sys.path.insert(0, project_root)
|
|
111
|
+
|
|
112
|
+
spec = importlib.util.spec_from_file_location("_tenxgraph_eval", path)
|
|
113
|
+
mod = importlib.util.module_from_spec(spec) # type: ignore[arg-type]
|
|
114
|
+
spec.loader.exec_module(mod) # type: ignore[union-attr]
|
|
115
|
+
return mod
|
|
116
|
+
|
|
117
|
+
# ------------------------------------------------------------------
|
|
118
|
+
# Agent loading from 10xgraph.json
|
|
119
|
+
# ------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
def _load_agent_from_config(self) -> Any:
|
|
122
|
+
config_manager = ConfigManager()
|
|
123
|
+
discovered = config_manager.auto_discover_config()
|
|
124
|
+
if not discovered:
|
|
125
|
+
raise RuntimeError("No 10xgraph.json found — cannot auto-load agent.")
|
|
126
|
+
config_manager.load_config(str(discovered))
|
|
127
|
+
agent_spec: str = config_manager.get_config_value("agent", default="")
|
|
128
|
+
if not agent_spec or ":" not in agent_spec:
|
|
129
|
+
raise RuntimeError(f"Invalid 'agent' field in 10xgraph.json: {agent_spec!r}")
|
|
130
|
+
module_path, attr = agent_spec.rsplit(":", 1)
|
|
131
|
+
mod = importlib.import_module(module_path)
|
|
132
|
+
return getattr(mod, attr)
|
|
133
|
+
|
|
134
|
+
# ------------------------------------------------------------------
|
|
135
|
+
# Default config
|
|
136
|
+
# ------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
def _default_config(self) -> EvalConfig:
|
|
139
|
+
return EvalConfig(
|
|
140
|
+
criteria=CriteriaConfig(
|
|
141
|
+
tool_name_match=CriterionConfig.tool_name_match(threshold=1.0),
|
|
142
|
+
# response_match=CriterionConfig.response_match(threshold=0.8),
|
|
143
|
+
# hallucinations=CriterionConfig.hallucination(threshold=0.8),
|
|
144
|
+
rouge_match=CriterionConfig.rouge_match(threshold=0.5),
|
|
145
|
+
node_order=CriterionConfig.node_order(threshold=0.8),
|
|
146
|
+
)
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# ------------------------------------------------------------------
|
|
150
|
+
# confeval.py loading
|
|
151
|
+
# ------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
def _load_confeval(self, evals_dir: Path) -> EvalConfig | None:
|
|
154
|
+
"""Find and load confeval.py from evals_dir, returning its EvalConfig.
|
|
155
|
+
|
|
156
|
+
Tries get_eval_config() first, then falls back to the EVAL_CONFIG
|
|
157
|
+
constant. Returns None if no confeval.py is found so callers can
|
|
158
|
+
distinguish "no confeval" from "confeval with empty criteria".
|
|
159
|
+
"""
|
|
160
|
+
confeval_path = evals_dir / "confeval.py"
|
|
161
|
+
if not confeval_path.exists():
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
mod = self._load_module(confeval_path)
|
|
166
|
+
except Exception as exc:
|
|
167
|
+
self.logger.warning("Failed to load confeval.py: %s", exc)
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
if hasattr(mod, "get_eval_config"):
|
|
171
|
+
try:
|
|
172
|
+
return mod.get_eval_config()
|
|
173
|
+
except Exception as exc:
|
|
174
|
+
self.logger.warning("get_eval_config() in confeval.py failed: %s", exc)
|
|
175
|
+
|
|
176
|
+
if hasattr(mod, "EVAL_CONFIG"):
|
|
177
|
+
return mod.EVAL_CONFIG
|
|
178
|
+
|
|
179
|
+
self.logger.warning(
|
|
180
|
+
"confeval.py found but has no get_eval_config() or EVAL_CONFIG — ignoring"
|
|
181
|
+
)
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
def _confeval_search_dirs(self, target_path: Path, evals_dir: Path) -> list[Path]:
|
|
185
|
+
"""Directories to search for a global confeval.py, nearest-first.
|
|
186
|
+
|
|
187
|
+
confeval.py is the GLOBAL criteria config, so its discovery must not
|
|
188
|
+
depend on what the user targets. Walk up from the target (its parent
|
|
189
|
+
when the target is a file) toward the current working directory, then
|
|
190
|
+
append the configured evals directory. This way evals/confeval.py is
|
|
191
|
+
found whether the user runs the whole evals/ dir, a subfolder, or a
|
|
192
|
+
single file inside it.
|
|
193
|
+
"""
|
|
194
|
+
start = target_path.parent if target_path.is_file() else target_path
|
|
195
|
+
try:
|
|
196
|
+
cur = start.resolve()
|
|
197
|
+
cwd = Path.cwd().resolve()
|
|
198
|
+
except OSError:
|
|
199
|
+
return [evals_dir]
|
|
200
|
+
|
|
201
|
+
dirs: list[Path] = []
|
|
202
|
+
while True:
|
|
203
|
+
dirs.append(cur)
|
|
204
|
+
if cur in (cwd, cur.parent):
|
|
205
|
+
break
|
|
206
|
+
cur = cur.parent
|
|
207
|
+
|
|
208
|
+
ev = evals_dir.resolve()
|
|
209
|
+
if ev not in dirs:
|
|
210
|
+
dirs.append(ev)
|
|
211
|
+
return dirs
|
|
212
|
+
|
|
213
|
+
def _resolve_confeval(
|
|
214
|
+
self, target_path: Path, evals_dir: Path
|
|
215
|
+
) -> tuple[EvalConfig | None, Path | None]:
|
|
216
|
+
"""Find and load the nearest global confeval.py.
|
|
217
|
+
|
|
218
|
+
Returns (config, path_to_confeval) for the first directory that yields a
|
|
219
|
+
usable confeval.py, or (None, None) when none is found so callers fall
|
|
220
|
+
back to per-file config and then the built-in defaults.
|
|
221
|
+
"""
|
|
222
|
+
for d in self._confeval_search_dirs(target_path, evals_dir):
|
|
223
|
+
cfg = self._load_confeval(d)
|
|
224
|
+
if cfg is not None:
|
|
225
|
+
return cfg, d / "confeval.py"
|
|
226
|
+
return None, None
|
|
227
|
+
|
|
228
|
+
def _collect_eval_functions(self, mod: Any) -> tuple[list[tuple[str, Any]], Any]:
|
|
229
|
+
"""Pytest-style discovery: functions annotated -> EvalSet are evals, ->
|
|
230
|
+
EvalConfig is config."""
|
|
231
|
+
from tenxgraph.qa.evaluation import EvalConfig, EvalSet
|
|
232
|
+
|
|
233
|
+
eval_pairs: list[tuple[str, Any]] = []
|
|
234
|
+
config: Any = None
|
|
235
|
+
|
|
236
|
+
for name, obj in inspect.getmembers(mod, inspect.isfunction):
|
|
237
|
+
if name.startswith("_"):
|
|
238
|
+
continue
|
|
239
|
+
if getattr(obj, "__module__", None) != getattr(mod, "__name__", None):
|
|
240
|
+
continue
|
|
241
|
+
hints: dict[str, Any] = {}
|
|
242
|
+
try:
|
|
243
|
+
hints = typing.get_type_hints(obj)
|
|
244
|
+
except Exception:
|
|
245
|
+
hints = getattr(obj, "__annotations__", {})
|
|
246
|
+
|
|
247
|
+
ret = hints.get("return")
|
|
248
|
+
if ret is None:
|
|
249
|
+
continue
|
|
250
|
+
|
|
251
|
+
try:
|
|
252
|
+
if inspect.isclass(ret) and issubclass(ret, EvalSet):
|
|
253
|
+
eval_pairs.append((name, obj()))
|
|
254
|
+
continue
|
|
255
|
+
if inspect.isclass(ret) and issubclass(ret, EvalConfig) and config is None:
|
|
256
|
+
config = obj()
|
|
257
|
+
except Exception as exc:
|
|
258
|
+
self.logger.warning("Could not call %s(): %s", name, exc)
|
|
259
|
+
|
|
260
|
+
return eval_pairs, config
|
|
261
|
+
|
|
262
|
+
# ------------------------------------------------------------------
|
|
263
|
+
# Flat pool: collect all pending cases from a file
|
|
264
|
+
# ------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
def _collect_from_file(
|
|
267
|
+
self, path: Path, global_config: EvalConfig | None
|
|
268
|
+
) -> list[_PendingCase | _PendingSimulation]:
|
|
269
|
+
"""Load a module and return pending work for every eval case or simulation scenario.
|
|
270
|
+
|
|
271
|
+
Config priority chain (highest → lowest):
|
|
272
|
+
1. Per-file — EVAL_CONFIG / get_eval_config() inside the file
|
|
273
|
+
2. confeval.py — global_config (fallback when no per-file config)
|
|
274
|
+
3. Built-in defaults — _default_config()
|
|
275
|
+
|
|
276
|
+
Returns _PendingSimulation items when the file exposes get_scenarios() or SCENARIOS.
|
|
277
|
+
Returns _PendingCase items for the standard get_eval_set() / pytest-style protocols.
|
|
278
|
+
"""
|
|
279
|
+
mod = self._load_module(path)
|
|
280
|
+
file_name = path.name
|
|
281
|
+
|
|
282
|
+
# Simulator protocol — get_scenarios() or SCENARIOS constant
|
|
283
|
+
scenarios = None
|
|
284
|
+
if hasattr(mod, "get_scenarios"):
|
|
285
|
+
try:
|
|
286
|
+
scenarios = mod.get_scenarios()
|
|
287
|
+
except Exception as exc:
|
|
288
|
+
self.logger.warning("Could not call get_scenarios() in %s: %s", file_name, exc)
|
|
289
|
+
elif hasattr(mod, "SCENARIOS"):
|
|
290
|
+
scenarios = mod.SCENARIOS
|
|
291
|
+
|
|
292
|
+
if scenarios is not None:
|
|
293
|
+
return self._collect_simulations(mod, scenarios, file_name)
|
|
294
|
+
|
|
295
|
+
# get_eval_set() protocol
|
|
296
|
+
if hasattr(mod, "get_eval_set"):
|
|
297
|
+
file_config: EvalConfig | None = None
|
|
298
|
+
if hasattr(mod, "get_eval_config"):
|
|
299
|
+
file_config = mod.get_eval_config()
|
|
300
|
+
elif hasattr(mod, "EVAL_CONFIG"):
|
|
301
|
+
file_config = mod.EVAL_CONFIG
|
|
302
|
+
config, source = self._resolve_case_config(file_config, global_config)
|
|
303
|
+
return self._make_pending(mod, mod.get_eval_set(), config, file_name, source)
|
|
304
|
+
|
|
305
|
+
# pytest-style discovery
|
|
306
|
+
eval_pairs, discovered_config = self._collect_eval_functions(mod)
|
|
307
|
+
if eval_pairs:
|
|
308
|
+
file_config = discovered_config or (
|
|
309
|
+
mod.get_eval_config()
|
|
310
|
+
if hasattr(mod, "get_eval_config")
|
|
311
|
+
else mod.EVAL_CONFIG
|
|
312
|
+
if hasattr(mod, "EVAL_CONFIG")
|
|
313
|
+
else None
|
|
314
|
+
)
|
|
315
|
+
config, source = self._resolve_case_config(file_config, global_config)
|
|
316
|
+
pending: list[_PendingCase] = []
|
|
317
|
+
for _, es in eval_pairs:
|
|
318
|
+
pending.extend(self._make_pending(mod, es, config, file_name, source))
|
|
319
|
+
return pending
|
|
320
|
+
|
|
321
|
+
self.output.warning(f"Skipping {file_name} — no eval entry point found.")
|
|
322
|
+
return []
|
|
323
|
+
|
|
324
|
+
def _resolve_case_config(
|
|
325
|
+
self, file_config: EvalConfig | None, global_config: EvalConfig | None
|
|
326
|
+
) -> tuple[EvalConfig, str]:
|
|
327
|
+
"""Resolve the config for a file and label its source.
|
|
328
|
+
|
|
329
|
+
Priority (highest first): per-file get_eval_config()/EVAL_CONFIG, then the
|
|
330
|
+
global confeval.py, then the built-in defaults. The returned label is shown
|
|
331
|
+
in the criteria block so users can see which level each file resolved to.
|
|
332
|
+
"""
|
|
333
|
+
if file_config is not None:
|
|
334
|
+
return file_config, "per-file"
|
|
335
|
+
if global_config is not None:
|
|
336
|
+
return global_config, "confeval.py"
|
|
337
|
+
return self._default_config(), "built-in defaults"
|
|
338
|
+
|
|
339
|
+
def _collect_simulations(
|
|
340
|
+
self, mod: Any, scenarios: list[Any], file_name: str
|
|
341
|
+
) -> list[_PendingSimulation]:
|
|
342
|
+
"""Build _PendingSimulation items for each scenario in the file."""
|
|
343
|
+
from tenxgraph.qa.evaluation import (
|
|
344
|
+
CriterionConfig,
|
|
345
|
+
SimulationGoalsCriterion,
|
|
346
|
+
UserSimulator,
|
|
347
|
+
UserSimulatorConfig,
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
graph = getattr(mod, "app", None) or self._load_agent_from_config()
|
|
351
|
+
|
|
352
|
+
# Per-file simulator config via SIMULATOR_CONFIG constant or default
|
|
353
|
+
sim_cfg: UserSimulatorConfig | None = getattr(mod, "SIMULATOR_CONFIG", None)
|
|
354
|
+
goal_threshold: float = 0.7
|
|
355
|
+
if sim_cfg is not None and hasattr(sim_cfg, "goal_threshold"):
|
|
356
|
+
goal_threshold = sim_cfg.goal_threshold # type: ignore[attr-defined]
|
|
357
|
+
|
|
358
|
+
judge = SimulationGoalsCriterion(
|
|
359
|
+
config=CriterionConfig(threshold=goal_threshold, num_samples=1)
|
|
360
|
+
)
|
|
361
|
+
simulator = UserSimulator(
|
|
362
|
+
config=sim_cfg or UserSimulatorConfig(),
|
|
363
|
+
criteria=[judge],
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
eval_set_id = f"{Path(file_name).stem}_simulations"
|
|
367
|
+
eval_set_name = f"{Path(file_name).stem} (user simulator)"
|
|
368
|
+
|
|
369
|
+
return [
|
|
370
|
+
_PendingSimulation(
|
|
371
|
+
scenario=sc,
|
|
372
|
+
graph=graph,
|
|
373
|
+
simulator=simulator,
|
|
374
|
+
file_name=file_name,
|
|
375
|
+
eval_set_id=eval_set_id,
|
|
376
|
+
eval_set_name=eval_set_name,
|
|
377
|
+
)
|
|
378
|
+
for sc in scenarios
|
|
379
|
+
]
|
|
380
|
+
|
|
381
|
+
def _make_pending(
|
|
382
|
+
self,
|
|
383
|
+
mod: Any,
|
|
384
|
+
eval_set: Any,
|
|
385
|
+
config: EvalConfig,
|
|
386
|
+
file_name: str,
|
|
387
|
+
config_source: str = "",
|
|
388
|
+
) -> list[_PendingCase]:
|
|
389
|
+
graph = getattr(mod, "app", None) or self._load_agent_from_config()
|
|
390
|
+
collector = TrajectoryCollector(capture_all_events=True)
|
|
391
|
+
evaluator = AgentEvaluator(graph, collector, config=config)
|
|
392
|
+
return [
|
|
393
|
+
_PendingCase(
|
|
394
|
+
case=c,
|
|
395
|
+
evaluator=evaluator,
|
|
396
|
+
config=config,
|
|
397
|
+
file_name=file_name,
|
|
398
|
+
eval_set_id=eval_set.eval_set_id,
|
|
399
|
+
eval_set_name=eval_set.name,
|
|
400
|
+
config_source=config_source,
|
|
401
|
+
)
|
|
402
|
+
for c in eval_set.eval_cases
|
|
403
|
+
]
|
|
404
|
+
|
|
405
|
+
# ------------------------------------------------------------------
|
|
406
|
+
# Criteria display
|
|
407
|
+
# ------------------------------------------------------------------
|
|
408
|
+
|
|
409
|
+
def _print_criteria_block(
|
|
410
|
+
self,
|
|
411
|
+
confeval_config: EvalConfig | None,
|
|
412
|
+
confeval_path: Path | None,
|
|
413
|
+
) -> None:
|
|
414
|
+
"""Print the active global criteria and their source before any case runs.
|
|
415
|
+
|
|
416
|
+
This is the global fallback config (confeval.py, else built-in defaults)
|
|
417
|
+
applied to files without their own get_eval_config()/EVAL_CONFIG. Files
|
|
418
|
+
that define a per-file config override this for their own cases.
|
|
419
|
+
"""
|
|
420
|
+
if confeval_config is not None:
|
|
421
|
+
source = str(confeval_path) if confeval_path else "confeval.py"
|
|
422
|
+
criteria = confeval_config.criteria
|
|
423
|
+
else:
|
|
424
|
+
source = "built-in defaults"
|
|
425
|
+
criteria = self._default_config().criteria
|
|
426
|
+
|
|
427
|
+
lines = [f"Criteria source: {source}"]
|
|
428
|
+
for name in criteria.model_fields:
|
|
429
|
+
cfg = getattr(criteria, name)
|
|
430
|
+
if cfg is None:
|
|
431
|
+
continue
|
|
432
|
+
|
|
433
|
+
parts = [f"threshold={cfg.threshold}"]
|
|
434
|
+
parts.append(cfg.match_type.value)
|
|
435
|
+
if cfg.judge_model:
|
|
436
|
+
parts.append(f"judge={cfg.judge_model}")
|
|
437
|
+
if cfg.num_samples and cfg.num_samples != 1:
|
|
438
|
+
parts.append(f"samples={cfg.num_samples}")
|
|
439
|
+
lines.append(f" {name:<40} {' '.join(parts)}")
|
|
440
|
+
self.output.info("\n".join(lines), emoji=False)
|
|
441
|
+
|
|
442
|
+
def _criteria_rows(self, config: EvalConfig) -> list[tuple[str, str, dict[str, Any]]]:
|
|
443
|
+
"""Return (name, human_summary, machine_dict) for each active criterion."""
|
|
444
|
+
criteria = config.criteria
|
|
445
|
+
rows: list[tuple[str, str, dict[str, Any]]] = []
|
|
446
|
+
for name in type(criteria).model_fields:
|
|
447
|
+
cfg = getattr(criteria, name)
|
|
448
|
+
if cfg is None:
|
|
449
|
+
continue
|
|
450
|
+
match_type = getattr(getattr(cfg, "match_type", None), "value", None)
|
|
451
|
+
parts = [f"threshold={cfg.threshold}"]
|
|
452
|
+
detail: dict[str, Any] = {"threshold": cfg.threshold}
|
|
453
|
+
if match_type:
|
|
454
|
+
parts.append(match_type)
|
|
455
|
+
detail["match_type"] = match_type
|
|
456
|
+
if cfg.judge_model:
|
|
457
|
+
parts.append(f"judge={cfg.judge_model}")
|
|
458
|
+
detail["judge_model"] = cfg.judge_model
|
|
459
|
+
if cfg.num_samples and cfg.num_samples != 1:
|
|
460
|
+
parts.append(f"samples={cfg.num_samples}")
|
|
461
|
+
detail["num_samples"] = cfg.num_samples
|
|
462
|
+
rows.append((name, " ".join(parts), detail))
|
|
463
|
+
return rows
|
|
464
|
+
|
|
465
|
+
def _criteria_by_file(
|
|
466
|
+
self, pending: list[_PendingCase | _PendingSimulation]
|
|
467
|
+
) -> dict[str, dict[str, Any]]:
|
|
468
|
+
"""Map each file to its resolved criteria source and active criteria.
|
|
469
|
+
|
|
470
|
+
Used both for the per-file console block and the merged report metadata so
|
|
471
|
+
a single combined run still records which criteria applied to which file.
|
|
472
|
+
"""
|
|
473
|
+
out: dict[str, dict[str, Any]] = {}
|
|
474
|
+
for pc in pending:
|
|
475
|
+
if pc.file_name in out:
|
|
476
|
+
continue
|
|
477
|
+
if isinstance(pc, _PendingCase):
|
|
478
|
+
rows = self._criteria_rows(pc.config)
|
|
479
|
+
out[pc.file_name] = {
|
|
480
|
+
"source": pc.config_source,
|
|
481
|
+
"criteria": {name: detail for name, _, detail in rows},
|
|
482
|
+
}
|
|
483
|
+
elif isinstance(pc, _PendingSimulation):
|
|
484
|
+
out[pc.file_name] = {"source": "user-simulator goals", "criteria": {}}
|
|
485
|
+
return out
|
|
486
|
+
|
|
487
|
+
def _print_criteria_per_file(
|
|
488
|
+
self,
|
|
489
|
+
pending: list[_PendingCase | _PendingSimulation],
|
|
490
|
+
confeval_path: Path | None,
|
|
491
|
+
) -> None:
|
|
492
|
+
"""Print the criteria each file resolved to, before any case runs.
|
|
493
|
+
|
|
494
|
+
Unlike the single global block, this shows per-file criteria so a combined
|
|
495
|
+
run makes clear that each file is evaluated against its own config
|
|
496
|
+
(per-file > confeval.py > built-in defaults).
|
|
497
|
+
"""
|
|
498
|
+
seen: set[str] = set()
|
|
499
|
+
blocks: list[str] = []
|
|
500
|
+
for pc in pending:
|
|
501
|
+
if pc.file_name in seen:
|
|
502
|
+
continue
|
|
503
|
+
seen.add(pc.file_name)
|
|
504
|
+
|
|
505
|
+
if isinstance(pc, _PendingSimulation):
|
|
506
|
+
blocks.append(f"Criteria {pc.file_name} (source: user-simulator goals)")
|
|
507
|
+
continue
|
|
508
|
+
|
|
509
|
+
source = pc.config_source
|
|
510
|
+
if source == "confeval.py" and confeval_path:
|
|
511
|
+
source = str(confeval_path)
|
|
512
|
+
lines = [f"Criteria {pc.file_name} (source: {source})"]
|
|
513
|
+
rows = self._criteria_rows(pc.config)
|
|
514
|
+
if not rows:
|
|
515
|
+
lines.append(" (no active criteria)")
|
|
516
|
+
for name, summary, _ in rows:
|
|
517
|
+
lines.append(f" {name:<40} {summary}")
|
|
518
|
+
blocks.append("\n".join(lines))
|
|
519
|
+
if blocks:
|
|
520
|
+
self.output.info("\n\n".join(blocks), emoji=False)
|
|
521
|
+
|
|
522
|
+
# ------------------------------------------------------------------
|
|
523
|
+
# Progress printing
|
|
524
|
+
# ------------------------------------------------------------------
|
|
525
|
+
|
|
526
|
+
@staticmethod
|
|
527
|
+
def _case_status(result: EvalCaseResult) -> str:
|
|
528
|
+
if result.passed:
|
|
529
|
+
return "passed"
|
|
530
|
+
return "error" if result.is_error else "failed"
|
|
531
|
+
|
|
532
|
+
@staticmethod
|
|
533
|
+
def _case_detail(result: EvalCaseResult) -> str:
|
|
534
|
+
detail = f"{result.duration_seconds:.2f}s"
|
|
535
|
+
tok = getattr(result, "token_usage", None)
|
|
536
|
+
if tok and (tok.input_tokens or tok.output_tokens):
|
|
537
|
+
detail += f" in={tok.input_tokens} out={tok.output_tokens}"
|
|
538
|
+
return detail
|
|
539
|
+
|
|
540
|
+
# ------------------------------------------------------------------
|
|
541
|
+
# Flat pool execution — single asyncio event loop for all cases
|
|
542
|
+
# ------------------------------------------------------------------
|
|
543
|
+
|
|
544
|
+
async def _run_flat_pool(
|
|
545
|
+
self,
|
|
546
|
+
pending: list[_PendingCase | _PendingSimulation],
|
|
547
|
+
max_concurrency: int,
|
|
548
|
+
parallel: bool,
|
|
549
|
+
) -> list[tuple[str, str, str, EvalCaseResult]]:
|
|
550
|
+
"""Run all cases and simulations under a single event loop.
|
|
551
|
+
|
|
552
|
+
Returns list of (file_name, eval_set_id, eval_set_name, EvalCaseResult).
|
|
553
|
+
"""
|
|
554
|
+
total = len(pending)
|
|
555
|
+
|
|
556
|
+
async def _run_case(pc: _PendingCase) -> tuple[str, str, str, EvalCaseResult]:
|
|
557
|
+
local_collector = TrajectoryCollector(
|
|
558
|
+
capture_all_events=pc.evaluator.collector.capture_all_events,
|
|
559
|
+
)
|
|
560
|
+
_, local_mgr = make_trajectory_callback(local_collector)
|
|
561
|
+
container = pc.evaluator.graph._state_graph._container
|
|
562
|
+
# The Inject[CallbackManager] proxy on Node.execute caches its resolved
|
|
563
|
+
# value independently of the container — reset it so it re-resolves
|
|
564
|
+
# inside the override context instead of returning the stale instance.
|
|
565
|
+
_reset_inject_proxy(CallbackManager)
|
|
566
|
+
with override_dependency(CallbackManager, local_mgr, container=container):
|
|
567
|
+
try:
|
|
568
|
+
result = await pc.evaluator._evaluate_case(
|
|
569
|
+
pc.case, collector_override=local_collector
|
|
570
|
+
)
|
|
571
|
+
except Exception as exc:
|
|
572
|
+
result = EvalCaseResult.failure(
|
|
573
|
+
eval_id=pc.case.eval_id,
|
|
574
|
+
error=str(exc),
|
|
575
|
+
name=pc.case.name,
|
|
576
|
+
)
|
|
577
|
+
return (pc.file_name, pc.eval_set_id, pc.eval_set_name, result)
|
|
578
|
+
|
|
579
|
+
async def _run_simulation(ps: _PendingSimulation) -> tuple[str, str, str, EvalCaseResult]:
|
|
580
|
+
from tenxgraph.qa.evaluation.eval_result import CriterionResult
|
|
581
|
+
from tenxgraph.qa.evaluation.token_usage import TokenUsage
|
|
582
|
+
|
|
583
|
+
try:
|
|
584
|
+
sim_result = await ps.simulator.run(ps.graph, ps.scenario)
|
|
585
|
+
# Use the full CriterionResult objects (with per-criterion token_usage)
|
|
586
|
+
# if available; fall back to rebuilding from scores dict.
|
|
587
|
+
criterion_results = sim_result.criterion_results or [
|
|
588
|
+
CriterionResult.success(
|
|
589
|
+
criterion=name,
|
|
590
|
+
score=score,
|
|
591
|
+
threshold=ps.simulator.criteria[0].threshold
|
|
592
|
+
if ps.simulator.criteria
|
|
593
|
+
else 0.7,
|
|
594
|
+
details=sim_result.criterion_details.get(name, {}),
|
|
595
|
+
)
|
|
596
|
+
for name, score in sim_result.criterion_scores.items()
|
|
597
|
+
]
|
|
598
|
+
# If no criterion ran (no goals defined), fall back to completion flag
|
|
599
|
+
if not criterion_results:
|
|
600
|
+
score = 1.0 if sim_result.completed else 0.0
|
|
601
|
+
criterion_results = [
|
|
602
|
+
CriterionResult.success(
|
|
603
|
+
criterion="simulation_completed",
|
|
604
|
+
score=score,
|
|
605
|
+
threshold=0.5,
|
|
606
|
+
)
|
|
607
|
+
]
|
|
608
|
+
conversation_text = "\n".join(
|
|
609
|
+
f"{m['role'].upper()}: {m['content']}" for m in sim_result.conversation
|
|
610
|
+
)
|
|
611
|
+
criteria_token_usage = sum(
|
|
612
|
+
(cr.token_usage for cr in criterion_results), TokenUsage()
|
|
613
|
+
)
|
|
614
|
+
total_token_usage = sim_result.simulator_token_usage + criteria_token_usage
|
|
615
|
+
result = EvalCaseResult.success(
|
|
616
|
+
eval_id=ps.scenario.scenario_id,
|
|
617
|
+
name=ps.scenario.description or ps.scenario.scenario_id,
|
|
618
|
+
criterion_results=criterion_results,
|
|
619
|
+
actual_response=conversation_text,
|
|
620
|
+
token_usage=total_token_usage,
|
|
621
|
+
agent_token_usage=sim_result.simulator_token_usage,
|
|
622
|
+
metadata={
|
|
623
|
+
"turns": sim_result.turns,
|
|
624
|
+
"goals_achieved": sim_result.goals_achieved,
|
|
625
|
+
"completed": sim_result.completed,
|
|
626
|
+
},
|
|
627
|
+
)
|
|
628
|
+
except Exception as exc:
|
|
629
|
+
result = EvalCaseResult.failure(
|
|
630
|
+
eval_id=ps.scenario.scenario_id,
|
|
631
|
+
error=str(exc),
|
|
632
|
+
name=ps.scenario.description or ps.scenario.scenario_id,
|
|
633
|
+
)
|
|
634
|
+
return (ps.file_name, ps.eval_set_id, ps.eval_set_name, result)
|
|
635
|
+
|
|
636
|
+
async def _dispatch(
|
|
637
|
+
item: _PendingCase | _PendingSimulation,
|
|
638
|
+
) -> tuple[str, str, str, EvalCaseResult]:
|
|
639
|
+
if isinstance(item, _PendingSimulation):
|
|
640
|
+
return await _run_simulation(item)
|
|
641
|
+
return await _run_case(item)
|
|
642
|
+
|
|
643
|
+
def _record(progress: Any, quad: tuple[str, str, str, EvalCaseResult]) -> None:
|
|
644
|
+
file_name, _, _, result = quad
|
|
645
|
+
progress.record(
|
|
646
|
+
f"{file_name}::{result.name or result.eval_id}",
|
|
647
|
+
status=self._case_status(result),
|
|
648
|
+
detail=self._case_detail(result),
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
title = "Running evaluation cases" + (f" ({max_concurrency} at a time)" if parallel else "")
|
|
652
|
+
|
|
653
|
+
if not parallel:
|
|
654
|
+
results: list[tuple[str, str, str, EvalCaseResult]] = []
|
|
655
|
+
with self.output.progress_run(title, total=total) as progress:
|
|
656
|
+
for item in pending:
|
|
657
|
+
quad = await _dispatch(item)
|
|
658
|
+
_record(progress, quad)
|
|
659
|
+
results.append(quad)
|
|
660
|
+
return results
|
|
661
|
+
|
|
662
|
+
semaphore = asyncio.Semaphore(max_concurrency)
|
|
663
|
+
|
|
664
|
+
async def _run_one(
|
|
665
|
+
item: _PendingCase | _PendingSimulation,
|
|
666
|
+
) -> tuple[str, str, str, EvalCaseResult]:
|
|
667
|
+
async with semaphore:
|
|
668
|
+
return await _dispatch(item)
|
|
669
|
+
|
|
670
|
+
output_results: list[tuple[str, str, str, EvalCaseResult]] = []
|
|
671
|
+
with self.output.progress_run(title, total=total) as progress:
|
|
672
|
+
tasks = [asyncio.create_task(_run_one(item)) for item in pending]
|
|
673
|
+
for coro in asyncio.as_completed(tasks):
|
|
674
|
+
quad = await coro
|
|
675
|
+
_record(progress, quad)
|
|
676
|
+
output_results.append(quad)
|
|
677
|
+
|
|
678
|
+
return output_results
|
|
679
|
+
|
|
680
|
+
# ------------------------------------------------------------------
|
|
681
|
+
# Report merging
|
|
682
|
+
# ------------------------------------------------------------------
|
|
683
|
+
|
|
684
|
+
def _merge_reports(self, reports: list[EvalReport], base_config: Any = None) -> EvalReport:
|
|
685
|
+
if len(reports) == 1:
|
|
686
|
+
return reports[0]
|
|
687
|
+
|
|
688
|
+
all_results = []
|
|
689
|
+
for r in reports:
|
|
690
|
+
all_results.extend(r.results)
|
|
691
|
+
return ER.create(
|
|
692
|
+
eval_set_id="combined_eval",
|
|
693
|
+
eval_set_name="Combined Evaluation",
|
|
694
|
+
results=all_results,
|
|
695
|
+
config_used=base_config.model_dump() if base_config else {},
|
|
696
|
+
)
|
|
697
|
+
|
|
698
|
+
# ------------------------------------------------------------------
|
|
699
|
+
# Eval directory from 10xgraph.json
|
|
700
|
+
# ------------------------------------------------------------------
|
|
701
|
+
|
|
702
|
+
def _resolve_eval_dir(self) -> Path:
|
|
703
|
+
config_manager = ConfigManager()
|
|
704
|
+
discovered = config_manager.auto_discover_config()
|
|
705
|
+
directory = "evals"
|
|
706
|
+
if discovered:
|
|
707
|
+
try:
|
|
708
|
+
config_manager.load_config(str(discovered))
|
|
709
|
+
eval_cfg = config_manager.get_evaluation_config()
|
|
710
|
+
directory = eval_cfg.get("directory", "evals")
|
|
711
|
+
except Exception:
|
|
712
|
+
self.logger.warning(
|
|
713
|
+
"Failed to load eval directory from config; using default 'evals/'"
|
|
714
|
+
)
|
|
715
|
+
return Path.cwd() / directory
|
|
716
|
+
|
|
717
|
+
# ------------------------------------------------------------------
|
|
718
|
+
# Main entry point
|
|
719
|
+
# ------------------------------------------------------------------
|
|
720
|
+
|
|
721
|
+
def execute( # noqa: PLR0912, PLR0915
|
|
722
|
+
self,
|
|
723
|
+
target: str | None = None,
|
|
724
|
+
output_dir: str = "eval_reports",
|
|
725
|
+
no_report: bool = False,
|
|
726
|
+
threshold: float | None = None,
|
|
727
|
+
open_report: bool = False,
|
|
728
|
+
parallel: bool = False,
|
|
729
|
+
max_concurrency: int = 4,
|
|
730
|
+
verbose: bool = False,
|
|
731
|
+
quiet: bool = False,
|
|
732
|
+
**kwargs: Any,
|
|
733
|
+
) -> int:
|
|
734
|
+
# 1. Load infra settings from 10xgraph.json — no criteria here.
|
|
735
|
+
# Criteria come exclusively from confeval.py (step 4) or per-file config.
|
|
736
|
+
config_manager = ConfigManager()
|
|
737
|
+
discovered = config_manager.auto_discover_config()
|
|
738
|
+
json_directory = "evals"
|
|
739
|
+
effective_parallel = parallel
|
|
740
|
+
effective_concurrency = max_concurrency
|
|
741
|
+
if discovered:
|
|
742
|
+
try:
|
|
743
|
+
config_manager.load_config(str(discovered))
|
|
744
|
+
global_eval_cfg = config_manager.get_evaluation_config()
|
|
745
|
+
if output_dir == "eval_reports":
|
|
746
|
+
output_dir = global_eval_cfg.get("output_dir", output_dir)
|
|
747
|
+
if threshold is None:
|
|
748
|
+
threshold = global_eval_cfg.get("threshold")
|
|
749
|
+
if not parallel:
|
|
750
|
+
effective_parallel = global_eval_cfg.get("parallel", False)
|
|
751
|
+
if max_concurrency == DEFAULT_CONCURRENCY:
|
|
752
|
+
effective_concurrency = global_eval_cfg.get(
|
|
753
|
+
"max_concurrency", DEFAULT_CONCURRENCY
|
|
754
|
+
)
|
|
755
|
+
json_directory = global_eval_cfg.get("directory", json_directory)
|
|
756
|
+
except Exception:
|
|
757
|
+
self.logger.warning("Failed to load eval config from 10xgraph.json; using defaults")
|
|
758
|
+
|
|
759
|
+
# 2. CLI flags already captured as function parameters (highest priority).
|
|
760
|
+
|
|
761
|
+
# 3. Resolve target path
|
|
762
|
+
if target:
|
|
763
|
+
target_path = Path(target)
|
|
764
|
+
if not target_path.exists():
|
|
765
|
+
self.output.error(f"Path not found: {target}")
|
|
766
|
+
return 1
|
|
767
|
+
else:
|
|
768
|
+
target_path = Path.cwd() / json_directory
|
|
769
|
+
if not target_path.exists():
|
|
770
|
+
self.output.error(
|
|
771
|
+
f"Eval directory '{target_path}' not found. "
|
|
772
|
+
"Create an evals/ directory or pass a file/folder path."
|
|
773
|
+
)
|
|
774
|
+
return 1
|
|
775
|
+
|
|
776
|
+
# 4. Load confeval.py → global criteria config (None if not found).
|
|
777
|
+
# confeval.py is the GLOBAL config: discover it independently of the
|
|
778
|
+
# target so it applies whether the user evaluates the whole evals/
|
|
779
|
+
# directory, a subfolder, or a single file inside it.
|
|
780
|
+
evals_dir = Path.cwd() / json_directory
|
|
781
|
+
confeval_config, confeval_path = self._resolve_confeval(target_path, evals_dir)
|
|
782
|
+
|
|
783
|
+
# 5. Discover files
|
|
784
|
+
files = self._discover(target_path)
|
|
785
|
+
if not files:
|
|
786
|
+
self.output.error(f"No eval files found in {target_path}")
|
|
787
|
+
return 1
|
|
788
|
+
|
|
789
|
+
# 6. Collect all pending cases across every file
|
|
790
|
+
pending: list[_PendingCase | _PendingSimulation] = []
|
|
791
|
+
for f in files:
|
|
792
|
+
try:
|
|
793
|
+
cases = self._collect_from_file(f, confeval_config)
|
|
794
|
+
pending.extend(cases)
|
|
795
|
+
except Exception as exc:
|
|
796
|
+
self.output.error(f"Error loading {f.name}: {exc}")
|
|
797
|
+
self.logger.exception("Failed to load eval file: %s", f)
|
|
798
|
+
|
|
799
|
+
if not pending:
|
|
800
|
+
self.output.error(
|
|
801
|
+
"No eval cases found. Ensure eval files expose get_eval_set() "
|
|
802
|
+
"or functions annotated with -> EvalSet."
|
|
803
|
+
)
|
|
804
|
+
return 1
|
|
805
|
+
|
|
806
|
+
n_files = len({pc.file_name for pc in pending})
|
|
807
|
+
n_sims = sum(1 for pc in pending if isinstance(pc, _PendingSimulation))
|
|
808
|
+
n_cases = len(pending) - n_sims
|
|
809
|
+
parts = []
|
|
810
|
+
if n_cases:
|
|
811
|
+
parts.append(f"{n_cases} eval case(s)")
|
|
812
|
+
if n_sims:
|
|
813
|
+
parts.append(f"{n_sims} simulation scenario(s)")
|
|
814
|
+
self.output.command_header(
|
|
815
|
+
"eval",
|
|
816
|
+
f"Found {', '.join(parts)} across {n_files} file(s) in {target_path}",
|
|
817
|
+
)
|
|
818
|
+
|
|
819
|
+
# 7. Print per-file criteria so users see which config each file resolved to
|
|
820
|
+
self._print_criteria_per_file(pending, confeval_path)
|
|
821
|
+
|
|
822
|
+
# 8. Run all cases under a single asyncio event loop
|
|
823
|
+
quads = asyncio.run(self._run_flat_pool(pending, effective_concurrency, effective_parallel))
|
|
824
|
+
|
|
825
|
+
if not quads:
|
|
826
|
+
self.output.error("No results produced.")
|
|
827
|
+
return 1
|
|
828
|
+
|
|
829
|
+
# Build per-eval-set config map from pending before results are consumed
|
|
830
|
+
group_configs: dict[str, Any] = {
|
|
831
|
+
pc.eval_set_id: pc.config for pc in pending if isinstance(pc, _PendingCase)
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
# 7. Group by eval_set_id → one EvalReport per set
|
|
835
|
+
groups: dict[str, tuple[str, list[EvalCaseResult]]] = defaultdict(lambda: ("", []))
|
|
836
|
+
for _, eval_set_id, eval_set_name, result in quads:
|
|
837
|
+
name, results_list = groups[eval_set_id]
|
|
838
|
+
groups[eval_set_id] = (eval_set_name or name, [*results_list, result])
|
|
839
|
+
|
|
840
|
+
reports: list[EvalReport] = []
|
|
841
|
+
for eval_set_id, (eval_set_name, results) in groups.items():
|
|
842
|
+
group_cfg = group_configs.get(eval_set_id) or confeval_config or self._default_config()
|
|
843
|
+
reports.append(
|
|
844
|
+
ER.create(
|
|
845
|
+
eval_set_id=eval_set_id,
|
|
846
|
+
eval_set_name=eval_set_name,
|
|
847
|
+
results=results,
|
|
848
|
+
config_used=group_cfg.model_dump(),
|
|
849
|
+
)
|
|
850
|
+
)
|
|
851
|
+
|
|
852
|
+
# 8. Merge into a single report
|
|
853
|
+
merged = self._merge_reports(reports, base_config=confeval_config or self._default_config())
|
|
854
|
+
|
|
855
|
+
# Record per-file criteria in the merged report so a single combined run still
|
|
856
|
+
# captures which criteria applied to which file (the merged config_used alone
|
|
857
|
+
# collapses to one config and loses this).
|
|
858
|
+
if isinstance(getattr(merged, "metadata", None), dict):
|
|
859
|
+
merged.metadata["criteria_by_file"] = self._criteria_by_file(pending)
|
|
860
|
+
|
|
861
|
+
# 9. Determine exit code
|
|
862
|
+
if threshold is not None and merged.summary.pass_rate < threshold:
|
|
863
|
+
self.output.error(
|
|
864
|
+
f"Pass rate {merged.summary.pass_rate:.1%} is below threshold {threshold:.1%}"
|
|
865
|
+
)
|
|
866
|
+
return_code = 1
|
|
867
|
+
else:
|
|
868
|
+
return_code = 0 if merged.summary.pass_rate == 1.0 else 1
|
|
869
|
+
|
|
870
|
+
# 10. Generate reports
|
|
871
|
+
if not no_report:
|
|
872
|
+
manager = ReporterManager(
|
|
873
|
+
ReporterConfig(
|
|
874
|
+
output_dir=output_dir,
|
|
875
|
+
html=True,
|
|
876
|
+
json_report=True,
|
|
877
|
+
console=False,
|
|
878
|
+
timestamp_files=True,
|
|
879
|
+
)
|
|
880
|
+
)
|
|
881
|
+
report_result = manager.run_all(merged)
|
|
882
|
+
|
|
883
|
+
if report_result.html_path:
|
|
884
|
+
self.output.success(f"HTML report: {report_result.html_path}")
|
|
885
|
+
if report_result.json_path:
|
|
886
|
+
self.output.info(f"JSON report: {report_result.json_path}", emoji=False)
|
|
887
|
+
if report_result.has_errors:
|
|
888
|
+
for name, err in report_result.errors:
|
|
889
|
+
self.output.warning(f"Reporter error [{name}]: {err}")
|
|
890
|
+
|
|
891
|
+
if open_report and report_result.html_path:
|
|
892
|
+
webbrowser.open(Path(report_result.html_path).resolve().as_uri())
|
|
893
|
+
|
|
894
|
+
summary = merged.summary
|
|
895
|
+
self.output.info(
|
|
896
|
+
f"Results: {summary.passed_cases}/{summary.total_cases} passed "
|
|
897
|
+
f"({summary.pass_rate:.1%})",
|
|
898
|
+
emoji=False,
|
|
899
|
+
)
|
|
900
|
+
tok = summary.total_token_usage
|
|
901
|
+
if tok.total_tokens:
|
|
902
|
+
cache_part = f" cache_read: {tok.cache_read_tokens:,}" if tok.cache_read_tokens else ""
|
|
903
|
+
self.output.info(
|
|
904
|
+
f"Tokens — input: {tok.input_tokens:,} output: {tok.output_tokens:,}"
|
|
905
|
+
f"{cache_part} total: {tok.total_tokens:,}",
|
|
906
|
+
emoji=False,
|
|
907
|
+
)
|
|
908
|
+
|
|
909
|
+
return return_code
|