agent-eval-planner 0.1.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.
- agent_eval_planner/__init__.py +50 -0
- agent_eval_planner/__main__.py +8 -0
- agent_eval_planner/cli.py +211 -0
- agent_eval_planner/contract_analyzer.py +195 -0
- agent_eval_planner/errors.py +13 -0
- agent_eval_planner/models.py +39 -0
- agent_eval_planner/plan_renderer.py +125 -0
- agent_eval_planner/planner.py +79 -0
- agent_eval_planner/remediation_renderer.py +138 -0
- agent_eval_planner/suite_renderer.py +56 -0
- agent_eval_planner/suite_validator.py +166 -0
- agent_eval_planner/vector_catalog.py +333 -0
- agent_eval_planner/version.py +3 -0
- agent_eval_planner-0.1.0.dist-info/METADATA +139 -0
- agent_eval_planner-0.1.0.dist-info/RECORD +19 -0
- agent_eval_planner-0.1.0.dist-info/WHEEL +5 -0
- agent_eval_planner-0.1.0.dist-info/entry_points.txt +2 -0
- agent_eval_planner-0.1.0.dist-info/licenses/LICENSE +20 -0
- agent_eval_planner-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""agent_eval_planner package.
|
|
2
|
+
|
|
3
|
+
Turns an agent contract into a guardrail evaluation plan, JSONL suite, and remediations.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from .errors import Error, InputError, ValidationError
|
|
9
|
+
from .models import PlannerResult, Vector
|
|
10
|
+
from .planner import Planner
|
|
11
|
+
from .suite_validator import SuiteValidator
|
|
12
|
+
from .version import __version__
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def generate(input_path: str | None = None, raw: str | None = None, **options) -> PlannerResult:
|
|
16
|
+
"""Generate plan, suite, and remediations from an agent contract."""
|
|
17
|
+
return Planner(input_path=input_path, raw=raw, **options).call()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def generate_plan(input_path: str | None = None, raw: str | None = None, **options) -> str:
|
|
21
|
+
"""Generate only the Markdown evaluation plan."""
|
|
22
|
+
return generate(input_path=input_path, raw=raw, **options).plan
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def validate_suite(
|
|
26
|
+
path: str,
|
|
27
|
+
known_tools: list[str] | None = None,
|
|
28
|
+
agent_has_no_tools: bool = False,
|
|
29
|
+
) -> list[str]:
|
|
30
|
+
"""Validate a suite.jsonl file. Returns a list of errors (empty if OK)."""
|
|
31
|
+
return SuiteValidator(
|
|
32
|
+
path=path,
|
|
33
|
+
known_tools=known_tools or [],
|
|
34
|
+
agent_has_no_tools=agent_has_no_tools,
|
|
35
|
+
).validate()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"__version__",
|
|
40
|
+
"generate",
|
|
41
|
+
"generate_plan",
|
|
42
|
+
"validate_suite",
|
|
43
|
+
"Planner",
|
|
44
|
+
"PlannerResult",
|
|
45
|
+
"Vector",
|
|
46
|
+
"SuiteValidator",
|
|
47
|
+
"Error",
|
|
48
|
+
"InputError",
|
|
49
|
+
"ValidationError",
|
|
50
|
+
]
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Command-line interface for agent-eval-planner."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Sequence
|
|
9
|
+
|
|
10
|
+
from . import generate, validate_suite
|
|
11
|
+
from .errors import Error
|
|
12
|
+
from .models import PlannerResult
|
|
13
|
+
from .version import __version__
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
17
|
+
"""Builds the argument parser for generate mode."""
|
|
18
|
+
parser = argparse.ArgumentParser(
|
|
19
|
+
prog="agent-eval-planner",
|
|
20
|
+
description="Gera plano de eval, suite JSONL e remediações a partir do contrato de um agente.",
|
|
21
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
22
|
+
epilog=(
|
|
23
|
+
"Usage:\n"
|
|
24
|
+
" agent-eval-planner [options] <contract.md|prompt.txt|agent.json>\n"
|
|
25
|
+
" agent-eval-planner validate [options] <suite.jsonl>"
|
|
26
|
+
),
|
|
27
|
+
)
|
|
28
|
+
parser.add_argument("input", nargs="?", help="Caminho do contrato do agente")
|
|
29
|
+
parser.add_argument("-t", "--team", help="Nome do time no título")
|
|
30
|
+
parser.add_argument("-a", "--agent", dest="agent_name", help="Nome do target_agent")
|
|
31
|
+
parser.add_argument("--tools", help="Tools conhecidas (vírgula)")
|
|
32
|
+
parser.add_argument("--scope", dest="declared_scope", help="Escopo declarado (resumo)")
|
|
33
|
+
parser.add_argument("--out-of-scope", dest="out_of_scope", help="Fora de escopo declarado")
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
"--harness",
|
|
36
|
+
default="generic",
|
|
37
|
+
help="Harness alvo (generic|marketing-copilot)",
|
|
38
|
+
)
|
|
39
|
+
parser.add_argument("-o", "--output", help="Arquivo .md/.jsonl ou diretório de saída")
|
|
40
|
+
parser.add_argument("--plan-only", action="store_true", help="Gerar apenas o plano")
|
|
41
|
+
parser.add_argument("--suite-only", action="store_true", help="Gerar apenas a suite JSONL")
|
|
42
|
+
parser.add_argument(
|
|
43
|
+
"--remediations-only",
|
|
44
|
+
action="store_true",
|
|
45
|
+
help="Gerar apenas remediações",
|
|
46
|
+
)
|
|
47
|
+
parser.add_argument(
|
|
48
|
+
"-v",
|
|
49
|
+
"--version",
|
|
50
|
+
action="version",
|
|
51
|
+
version=f"agent_eval_planner {__version__}",
|
|
52
|
+
)
|
|
53
|
+
return parser
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def build_validate_parser() -> argparse.ArgumentParser:
|
|
57
|
+
"""Builds the argument parser for validate mode."""
|
|
58
|
+
parser = argparse.ArgumentParser(
|
|
59
|
+
prog="agent-eval-planner validate",
|
|
60
|
+
description="Valida uma suite JSONL (hard-fail em forbidden_tools vazios / placeholders).",
|
|
61
|
+
)
|
|
62
|
+
parser.add_argument("suite", nargs="?", help="Caminho da suite.jsonl")
|
|
63
|
+
parser.add_argument(
|
|
64
|
+
"--known-tools",
|
|
65
|
+
help="Tools reais (vírgula)",
|
|
66
|
+
)
|
|
67
|
+
parser.add_argument(
|
|
68
|
+
"--agent-has-no-tools",
|
|
69
|
+
action="store_true",
|
|
70
|
+
help="Agente sem tools",
|
|
71
|
+
)
|
|
72
|
+
return parser
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
76
|
+
"""Main CLI execution method."""
|
|
77
|
+
args_list = list(argv) if argv is not None else sys.argv[1:]
|
|
78
|
+
if args_list and args_list[0] == "validate":
|
|
79
|
+
return _run_validate(args_list[1:])
|
|
80
|
+
return _run_generate(args_list)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _run_generate(argv: list[str]) -> int:
|
|
84
|
+
parser = build_parser()
|
|
85
|
+
args = parser.parse_args(argv)
|
|
86
|
+
if not args.input:
|
|
87
|
+
parser.print_help(sys.stderr)
|
|
88
|
+
return 1
|
|
89
|
+
|
|
90
|
+
tools = None
|
|
91
|
+
if args.tools:
|
|
92
|
+
tools = [part.strip() for part in args.tools.split(",") if part.strip()]
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
result = generate(
|
|
96
|
+
input_path=args.input,
|
|
97
|
+
team=args.team,
|
|
98
|
+
agent_name=args.agent_name,
|
|
99
|
+
tools=tools,
|
|
100
|
+
declared_scope=args.declared_scope,
|
|
101
|
+
out_of_scope=args.out_of_scope,
|
|
102
|
+
harness=args.harness,
|
|
103
|
+
)
|
|
104
|
+
_write_outputs(result, args)
|
|
105
|
+
return 0
|
|
106
|
+
except Error as exc:
|
|
107
|
+
print(f"Erro: {exc}", file=sys.stderr)
|
|
108
|
+
return 1
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _looks_like_directory(path: str) -> bool:
|
|
112
|
+
candidate = Path(path)
|
|
113
|
+
return candidate.is_dir() or path.endswith("/") or candidate.suffix == ""
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _select_single(result: PlannerResult, args: argparse.Namespace) -> str | None:
|
|
117
|
+
if args.suite_only:
|
|
118
|
+
return result.suite
|
|
119
|
+
if args.remediations_only:
|
|
120
|
+
return result.remediations
|
|
121
|
+
if args.plan_only:
|
|
122
|
+
return result.plan
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _write_bundle(directory: Path, result: PlannerResult, args: argparse.Namespace) -> None:
|
|
127
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
128
|
+
if not args.suite_only and not args.remediations_only:
|
|
129
|
+
plan_path = directory / "plano-de-acao-agent-eval.md"
|
|
130
|
+
plan_path.write_text(result.plan, encoding="utf-8")
|
|
131
|
+
print(f"Plano gerado em {plan_path}", file=sys.stderr)
|
|
132
|
+
if not args.plan_only and not args.remediations_only:
|
|
133
|
+
suite_path = directory / "suite.jsonl"
|
|
134
|
+
suite_path.write_text(result.suite, encoding="utf-8")
|
|
135
|
+
print(f"Suite gerada em {suite_path}", file=sys.stderr)
|
|
136
|
+
if args.plan_only or args.suite_only:
|
|
137
|
+
return
|
|
138
|
+
rem_path = directory / "remediacoes.md"
|
|
139
|
+
rem_path.write_text(result.remediations, encoding="utf-8")
|
|
140
|
+
print(f"Remediações geradas em {rem_path}", file=sys.stderr)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _emit_stdout(result: PlannerResult, args: argparse.Namespace) -> None:
|
|
144
|
+
if args.suite_only:
|
|
145
|
+
sys.stdout.write(result.suite)
|
|
146
|
+
elif args.remediations_only:
|
|
147
|
+
sys.stdout.write(result.remediations)
|
|
148
|
+
elif args.plan_only:
|
|
149
|
+
sys.stdout.write(result.plan)
|
|
150
|
+
else:
|
|
151
|
+
sys.stdout.write(result.plan)
|
|
152
|
+
print("\n# --- suite.jsonl ---\n", file=sys.stderr)
|
|
153
|
+
sys.stdout.write(result.suite)
|
|
154
|
+
print("\n# --- remediacoes.md ---\n", file=sys.stderr)
|
|
155
|
+
sys.stdout.write(result.remediations)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _write_outputs(result: PlannerResult, args: argparse.Namespace) -> None:
|
|
159
|
+
if not args.output:
|
|
160
|
+
_emit_stdout(result, args)
|
|
161
|
+
return
|
|
162
|
+
|
|
163
|
+
path = args.output
|
|
164
|
+
if _looks_like_directory(path):
|
|
165
|
+
_write_bundle(Path(path), result, args)
|
|
166
|
+
return
|
|
167
|
+
|
|
168
|
+
output = Path(path)
|
|
169
|
+
if args.suite_only or output.suffix == ".jsonl":
|
|
170
|
+
output.write_text(result.suite, encoding="utf-8")
|
|
171
|
+
print(f"Suite gerada em {path}", file=sys.stderr)
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
content = _select_single(result, args) or result.plan
|
|
175
|
+
output.write_text(content, encoding="utf-8")
|
|
176
|
+
print(f"Artefato gerado em {path}", file=sys.stderr)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _run_validate(argv: list[str]) -> int:
|
|
180
|
+
parser = build_validate_parser()
|
|
181
|
+
args = parser.parse_args(argv)
|
|
182
|
+
if not args.suite:
|
|
183
|
+
parser.print_help(sys.stderr)
|
|
184
|
+
return 1
|
|
185
|
+
|
|
186
|
+
known_tools: list[str] = []
|
|
187
|
+
if args.known_tools:
|
|
188
|
+
known_tools = [part.strip() for part in args.known_tools.split(",") if part.strip()]
|
|
189
|
+
|
|
190
|
+
try:
|
|
191
|
+
errors = validate_suite(
|
|
192
|
+
args.suite,
|
|
193
|
+
known_tools=known_tools,
|
|
194
|
+
agent_has_no_tools=args.agent_has_no_tools,
|
|
195
|
+
)
|
|
196
|
+
except Error as exc:
|
|
197
|
+
print(f"Erro: {exc}", file=sys.stderr)
|
|
198
|
+
return 1
|
|
199
|
+
|
|
200
|
+
if errors:
|
|
201
|
+
print(f"INVALID: {args.suite} ({len(errors)} error(s))", file=sys.stderr)
|
|
202
|
+
for error in errors:
|
|
203
|
+
print(f" - {error}", file=sys.stderr)
|
|
204
|
+
return 1
|
|
205
|
+
|
|
206
|
+
print(f"OK: {args.suite}")
|
|
207
|
+
return 0
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
if __name__ == "__main__":
|
|
211
|
+
sys.exit(main())
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Parses an agent contract (markdown/text/JSON/YAML) into a structured profile."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
from .errors import InputError
|
|
13
|
+
|
|
14
|
+
TOOL_LINE_RE = re.compile(
|
|
15
|
+
r"^\s*[-*]\s*`?([a-z][a-z0-9_]*)`?\s*$|"
|
|
16
|
+
r"^\s*[-*]\s*tool[s]?\s*:\s*`?([a-z][a-z0-9_]*)`?|"
|
|
17
|
+
r"^\s*`([a-z][a-z0-9_]*)`\s*[—(]\s*tool",
|
|
18
|
+
re.IGNORECASE | re.MULTILINE,
|
|
19
|
+
)
|
|
20
|
+
TOOLS_INLINE_RE = re.compile(r"tools?\s*[:=]\s*\[([^\]]+)\]", re.IGNORECASE)
|
|
21
|
+
NAME_RE = re.compile(
|
|
22
|
+
r"(?:agent(?:[_\s]name)?|specialist|target[_ ]?agent)\s*[:=]\s*[\"']?([A-Za-z0-9_\-./]+)[\"']?",
|
|
23
|
+
re.IGNORECASE,
|
|
24
|
+
)
|
|
25
|
+
AGENT_NAME_KEY_RE = re.compile(
|
|
26
|
+
r"(?:agent_name|target_agent)\s*[:=]\s*[\"']?([A-Za-z0-9_\-./]+)[\"']?",
|
|
27
|
+
re.IGNORECASE,
|
|
28
|
+
)
|
|
29
|
+
SCOPE_RE = re.compile(r"escopo(?:\s+declarado)?\s*[:=]\s*(.+)$", re.IGNORECASE | re.MULTILINE)
|
|
30
|
+
HEADING_RE = re.compile(r"^#+\s*(.+)$", re.MULTILINE)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ContractAnalyzer:
|
|
34
|
+
"""Parses an agent contract file into a structured profile."""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
source_path: str | None = None,
|
|
39
|
+
raw: str | None = None,
|
|
40
|
+
agent_name: str | None = None,
|
|
41
|
+
tools: list[str] | str | None = None,
|
|
42
|
+
declared_scope: str | None = None,
|
|
43
|
+
out_of_scope: str | None = None,
|
|
44
|
+
harness: str = "generic",
|
|
45
|
+
) -> None:
|
|
46
|
+
self.source_path = source_path
|
|
47
|
+
self.raw = raw if raw is not None else self._read_source(source_path)
|
|
48
|
+
self.agent_name = agent_name or self._extract_agent_name() or "default"
|
|
49
|
+
self.tools = self._normalize_tools(tools if tools is not None else self._extract_tools())
|
|
50
|
+
self.declared_scope = (
|
|
51
|
+
declared_scope or self._extract_scope_hint() or "Escopo declarado no contrato do agente"
|
|
52
|
+
)
|
|
53
|
+
self.out_of_scope = out_of_scope or (
|
|
54
|
+
"Pedidos off-topic, assistente geral, conteúdo fora do domínio"
|
|
55
|
+
)
|
|
56
|
+
self.harness = harness or "generic"
|
|
57
|
+
self.signals = self._detect_signals()
|
|
58
|
+
|
|
59
|
+
def has_tools(self) -> bool:
|
|
60
|
+
"""Returns True when the contract declares at least one tool."""
|
|
61
|
+
return bool(self.tools)
|
|
62
|
+
|
|
63
|
+
def multi_specialist(self) -> bool:
|
|
64
|
+
"""Returns True when the contract looks like a multi-agent setup."""
|
|
65
|
+
return bool(self.signals.get("multi_specialist"))
|
|
66
|
+
|
|
67
|
+
def analytics_domain(self) -> bool:
|
|
68
|
+
"""Returns True when the contract mentions analytics/metrics."""
|
|
69
|
+
return bool(self.signals.get("analytics"))
|
|
70
|
+
|
|
71
|
+
def introduction(self) -> str:
|
|
72
|
+
"""Short introduction used in the generated plan."""
|
|
73
|
+
parts = [f"Agente **{self.agent_name}**"]
|
|
74
|
+
if self.has_tools():
|
|
75
|
+
parts.append(f"com {len(self.tools)} tool(s) no contrato")
|
|
76
|
+
else:
|
|
77
|
+
parts.append("sem tools declaradas")
|
|
78
|
+
parts.append(f"(harness: {self.harness})")
|
|
79
|
+
return (
|
|
80
|
+
f"{' '.join(parts)}. Pipeline de eval de guardrails — paralelo do plano de "
|
|
81
|
+
"pentest de API, aplicado a comportamento de LLM."
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def _read_source(self, path: str | None) -> str:
|
|
85
|
+
if not path:
|
|
86
|
+
raise InputError("input_path é obrigatório quando raw não é informado.")
|
|
87
|
+
source = Path(path)
|
|
88
|
+
if not source.is_file():
|
|
89
|
+
raise InputError(f"Arquivo não encontrado: {path}")
|
|
90
|
+
return source.read_text(encoding="utf-8")
|
|
91
|
+
|
|
92
|
+
def _normalize_tools(self, lst: list[str] | str | None) -> list[str]:
|
|
93
|
+
if lst is None:
|
|
94
|
+
return []
|
|
95
|
+
items = lst if isinstance(lst, list) else [lst]
|
|
96
|
+
found: list[str] = []
|
|
97
|
+
for item in items:
|
|
98
|
+
found.extend(part.strip() for part in str(item).split(","))
|
|
99
|
+
unique: list[str] = []
|
|
100
|
+
seen: set[str] = set()
|
|
101
|
+
for name in found:
|
|
102
|
+
if name and name not in seen:
|
|
103
|
+
seen.add(name)
|
|
104
|
+
unique.append(name)
|
|
105
|
+
return unique
|
|
106
|
+
|
|
107
|
+
def _extract_tools(self) -> list[str]:
|
|
108
|
+
from_structured = self._tools_from_structured()
|
|
109
|
+
if from_structured:
|
|
110
|
+
return from_structured
|
|
111
|
+
|
|
112
|
+
found: list[str] = []
|
|
113
|
+
for match in TOOLS_INLINE_RE.finditer(self.raw):
|
|
114
|
+
found.extend(
|
|
115
|
+
part.strip().strip("\"'`") for part in re.split(r"[,\s]+", match.group(1))
|
|
116
|
+
)
|
|
117
|
+
for match in TOOL_LINE_RE.finditer(self.raw):
|
|
118
|
+
found.append(next(g for g in match.groups() if g))
|
|
119
|
+
return found
|
|
120
|
+
|
|
121
|
+
def _tools_from_structured(self) -> list[str]:
|
|
122
|
+
data = self._parse_structured()
|
|
123
|
+
if not isinstance(data, dict):
|
|
124
|
+
return []
|
|
125
|
+
agent = data.get("agent")
|
|
126
|
+
candidates = (
|
|
127
|
+
data.get("tools")
|
|
128
|
+
or data.get("agent_tools")
|
|
129
|
+
or (agent.get("tools") if isinstance(agent, dict) else None)
|
|
130
|
+
or []
|
|
131
|
+
)
|
|
132
|
+
if not isinstance(candidates, list):
|
|
133
|
+
candidates = [candidates]
|
|
134
|
+
return [str(item) for item in candidates]
|
|
135
|
+
|
|
136
|
+
def _parse_structured(self) -> dict[str, Any] | None:
|
|
137
|
+
if not self.raw or not self.raw.strip():
|
|
138
|
+
return None
|
|
139
|
+
path = self.source_path or ""
|
|
140
|
+
stripped = self.raw.strip()
|
|
141
|
+
try:
|
|
142
|
+
if path.endswith(".json") or stripped.startswith("{"):
|
|
143
|
+
parsed = json.loads(self.raw)
|
|
144
|
+
return parsed if isinstance(parsed, dict) else None
|
|
145
|
+
if re.search(r"\.(ya?ml)$", path, re.IGNORECASE):
|
|
146
|
+
parsed = yaml.safe_load(self.raw)
|
|
147
|
+
return parsed if isinstance(parsed, dict) else None
|
|
148
|
+
except Exception:
|
|
149
|
+
return None
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
def _extract_agent_name(self) -> str | None:
|
|
153
|
+
data = self._parse_structured()
|
|
154
|
+
if isinstance(data, dict):
|
|
155
|
+
agent = data.get("agent")
|
|
156
|
+
name = (
|
|
157
|
+
data.get("agent_name")
|
|
158
|
+
or data.get("target_agent")
|
|
159
|
+
or data.get("name")
|
|
160
|
+
or (agent.get("name") if isinstance(agent, dict) else None)
|
|
161
|
+
)
|
|
162
|
+
if name:
|
|
163
|
+
return str(name)
|
|
164
|
+
key_match = AGENT_NAME_KEY_RE.search(self.raw)
|
|
165
|
+
if key_match:
|
|
166
|
+
return key_match.group(1)
|
|
167
|
+
match = NAME_RE.search(self.raw)
|
|
168
|
+
return match.group(1) if match else None
|
|
169
|
+
|
|
170
|
+
def _extract_scope_hint(self) -> str | None:
|
|
171
|
+
match = SCOPE_RE.search(self.raw)
|
|
172
|
+
if match:
|
|
173
|
+
return match.group(1).strip()
|
|
174
|
+
heading = HEADING_RE.search(self.raw)
|
|
175
|
+
if heading:
|
|
176
|
+
return heading.group(1).strip()
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
def _detect_signals(self) -> dict[str, bool]:
|
|
180
|
+
down = self.raw.lower()
|
|
181
|
+
return {
|
|
182
|
+
"has_tools": self.has_tools(),
|
|
183
|
+
"multi_specialist": bool(
|
|
184
|
+
re.search(r"specialist|orchestrator|handoff|multi[- ]?agent", down)
|
|
185
|
+
),
|
|
186
|
+
"analytics": bool(
|
|
187
|
+
re.search(r"analytics|métrica|metrica|funnel|abertura|convers[aã]o|bigquery", down)
|
|
188
|
+
),
|
|
189
|
+
"docs": bool(re.search(r"docs?|help.?center|conhecimento|rag|retrieval", down)),
|
|
190
|
+
"mutable_tools": any(
|
|
191
|
+
re.search(r"ticket|delete|create|update|write|send|open_", tool, re.IGNORECASE)
|
|
192
|
+
for tool in self.tools
|
|
193
|
+
),
|
|
194
|
+
"pii_surface": bool(re.search(r"pii|cpf|email|tenant|cliente|customer", down)),
|
|
195
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Exceptions for agent_eval_planner."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Error(Exception):
|
|
5
|
+
"""Base exception for agent_eval_planner."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class InputError(Error):
|
|
9
|
+
"""Raised when input path or arguments are invalid."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ValidationError(Error):
|
|
13
|
+
"""Raised when a suite JSONL fails validation."""
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Data models for agent_eval_planner."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Vector:
|
|
11
|
+
"""A guardrail evaluation vector (attack prompt + expected behavior)."""
|
|
12
|
+
|
|
13
|
+
id: str
|
|
14
|
+
name: str
|
|
15
|
+
category: str
|
|
16
|
+
category_title: str
|
|
17
|
+
severity: str
|
|
18
|
+
objective: str
|
|
19
|
+
attack_prompt: str
|
|
20
|
+
expected_validation: str
|
|
21
|
+
failure_criteria: str
|
|
22
|
+
intent: str
|
|
23
|
+
tags: list[str] = field(default_factory=list)
|
|
24
|
+
refusal: bool = True
|
|
25
|
+
split: str = "smoke"
|
|
26
|
+
notes: str = ""
|
|
27
|
+
always: bool = False
|
|
28
|
+
triggers: list[str] = field(default_factory=list)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class PlannerResult:
|
|
33
|
+
"""Artifacts produced by a planner run."""
|
|
34
|
+
|
|
35
|
+
plan: str
|
|
36
|
+
suite: str
|
|
37
|
+
remediations: str
|
|
38
|
+
analyzer: Any
|
|
39
|
+
vectors: list[Vector]
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Renders the Markdown evaluation action plan."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .contract_analyzer import ContractAnalyzer
|
|
6
|
+
from .models import Vector
|
|
7
|
+
from .vector_catalog import CATEGORY_TITLES
|
|
8
|
+
|
|
9
|
+
CATEGORY_ORDER = ["SCOPE", "INJECT", "ROLE", "PII", "TOOL", "HALLUC", "EXFIL"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PlanRenderer:
|
|
13
|
+
"""Renders a Markdown eval action plan from analyzer + selected vectors."""
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
analyzer: ContractAnalyzer,
|
|
18
|
+
vectors: list[Vector],
|
|
19
|
+
team: str | None = None,
|
|
20
|
+
) -> None:
|
|
21
|
+
self.analyzer = analyzer
|
|
22
|
+
self.vectors = vectors
|
|
23
|
+
self.team = team or "TIME"
|
|
24
|
+
|
|
25
|
+
def render(self) -> str:
|
|
26
|
+
"""Returns the full Markdown plan."""
|
|
27
|
+
sections = [
|
|
28
|
+
self._header(),
|
|
29
|
+
self._introduction(),
|
|
30
|
+
self._eval_sections(),
|
|
31
|
+
self._summary_table(),
|
|
32
|
+
self._recommendations(),
|
|
33
|
+
self._out_of_scope(),
|
|
34
|
+
]
|
|
35
|
+
return "\n\n".join(section for section in sections if section) + "\n"
|
|
36
|
+
|
|
37
|
+
def _header(self) -> str:
|
|
38
|
+
return f"# {self.team} — Plano de Ação de Eval de Agente"
|
|
39
|
+
|
|
40
|
+
def _introduction(self) -> str:
|
|
41
|
+
tools = (
|
|
42
|
+
"_(nenhuma)_"
|
|
43
|
+
if not self.analyzer.tools
|
|
44
|
+
else ", ".join(f"`{tool}`" for tool in self.analyzer.tools)
|
|
45
|
+
)
|
|
46
|
+
source = self.analyzer.source_path or "inline"
|
|
47
|
+
return (
|
|
48
|
+
"## Introdução\n\n"
|
|
49
|
+
f"{self.analyzer.introduction()}\n\n"
|
|
50
|
+
f"**Agente(s) em escopo:** {self.analyzer.agent_name}\n\n"
|
|
51
|
+
f"**Contrato analisado:** `{source}`\n\n"
|
|
52
|
+
f"**Harness alvo:** {self.analyzer.harness}\n\n"
|
|
53
|
+
f"**Escopo declarado (resumo):** {self.analyzer.declared_scope}\n\n"
|
|
54
|
+
f"**Fora de escopo declarado:** {self.analyzer.out_of_scope}\n\n"
|
|
55
|
+
f"**Tools / capabilities:** {tools}"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def _eval_sections(self) -> str:
|
|
59
|
+
grouped: dict[str, list[Vector]] = {}
|
|
60
|
+
for vector in self.vectors:
|
|
61
|
+
grouped.setdefault(vector.category, []).append(vector)
|
|
62
|
+
|
|
63
|
+
blocks: list[str] = []
|
|
64
|
+
for category in CATEGORY_ORDER:
|
|
65
|
+
items = grouped.get(category)
|
|
66
|
+
if not items:
|
|
67
|
+
continue
|
|
68
|
+
title = CATEGORY_TITLES.get(category, category)
|
|
69
|
+
body = "\n\n".join(self._render_vector(vector) for vector in items)
|
|
70
|
+
blocks.append(f"## EVAL — {title}\n\n{body}")
|
|
71
|
+
return "\n\n---\n\n".join(blocks)
|
|
72
|
+
|
|
73
|
+
def _render_vector(self, vector: Vector) -> str:
|
|
74
|
+
notes = vector.notes if vector.notes else "—"
|
|
75
|
+
return (
|
|
76
|
+
f"#### {vector.id} — {vector.name}\n\n"
|
|
77
|
+
f"- **Severidade:** {vector.severity}\n"
|
|
78
|
+
f"- **Objetivo:** {vector.objective}\n"
|
|
79
|
+
"- **Prompt de ataque:**\n"
|
|
80
|
+
" ```text\n"
|
|
81
|
+
f" {vector.attack_prompt}\n"
|
|
82
|
+
" ```\n"
|
|
83
|
+
f"- **Validação esperada:** {vector.expected_validation}\n"
|
|
84
|
+
f"- **Critério de falha:** {vector.failure_criteria}\n"
|
|
85
|
+
f"- **Observação:** {notes}"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def _summary_table(self) -> str:
|
|
89
|
+
rows = [
|
|
90
|
+
f"| {v.id} | {v.category} | {v.severity} | {v.name} | {v.failure_criteria} |"
|
|
91
|
+
for v in self.vectors
|
|
92
|
+
]
|
|
93
|
+
return (
|
|
94
|
+
"## Tabela de resumo\n\n"
|
|
95
|
+
"| ID | Categoria | Severidade | Vetor | Falha se |\n"
|
|
96
|
+
"|----|-----------|------------|-------|----------|\n"
|
|
97
|
+
+ "\n".join(rows)
|
|
98
|
+
+ "\n\n"
|
|
99
|
+
"**Prioridade de execução:** SCOPE → INJECT → ROLE → PII → TOOL → HALLUC → EXFIL"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def _recommendations(self) -> str:
|
|
103
|
+
tips = [
|
|
104
|
+
"Rodar smoke pack (SCOPE-01, INJECT-01, ROLE-01) no harness antes de expandir a suite.",
|
|
105
|
+
"Preencher `forbidden_tools` com as tools reais do agente — suite com lista vazia em refusal é falso verde.",
|
|
106
|
+
"Preferir policy gate determinístico para off-topic previsível; o modelo sozinho não basta.",
|
|
107
|
+
]
|
|
108
|
+
if self.analyzer.multi_specialist():
|
|
109
|
+
tips.append(
|
|
110
|
+
"Validar handoff entre specialists (SCOPE-04) — orchestrator detectado no contrato."
|
|
111
|
+
)
|
|
112
|
+
if self.analyzer.analytics_domain():
|
|
113
|
+
tips.append(
|
|
114
|
+
"Cobrir grounding de métricas (HALLUC-01) com tool obrigatória ou recusa explícita."
|
|
115
|
+
)
|
|
116
|
+
body = "\n".join(f"{index}. {tip}" for index, tip in enumerate(tips, start=1))
|
|
117
|
+
return f"## Recomendações imediatas\n\n{body}"
|
|
118
|
+
|
|
119
|
+
def _out_of_scope(self) -> str:
|
|
120
|
+
return (
|
|
121
|
+
"## Fora do escopo deste plano\n\n"
|
|
122
|
+
"- Pentest de API/infra (usar `security_pentest_planner`)\n"
|
|
123
|
+
"- Journeys de UI / synthetics de browser\n"
|
|
124
|
+
"- Modelo de ameaça corporativo completo"
|
|
125
|
+
)
|