api-shakedown 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.
- api_shakedown/__init__.py +3 -0
- api_shakedown/__main__.py +3 -0
- api_shakedown/agents/__init__.py +1 -0
- api_shakedown/agents/_common.py +36 -0
- api_shakedown/agents/chaos.py +146 -0
- api_shakedown/agents/contracts.py +176 -0
- api_shakedown/agents/coordinator.py +211 -0
- api_shakedown/agents/reviewer.py +301 -0
- api_shakedown/agents/stubs.py +163 -0
- api_shakedown/agents/testdata.py +146 -0
- api_shakedown/chaos.py +451 -0
- api_shakedown/chaos_control.py +54 -0
- api_shakedown/cli.py +131 -0
- api_shakedown/config.py +19 -0
- api_shakedown/contracts.py +243 -0
- api_shakedown/harness/__init__.py +26 -0
- api_shakedown/mcp_server.py +218 -0
- api_shakedown/pipeline.py +92 -0
- api_shakedown/py.typed +0 -0
- api_shakedown/spec/__init__.py +17 -0
- api_shakedown/spec/chunking.py +32 -0
- api_shakedown/spec/edge_cases.py +149 -0
- api_shakedown/spec/loader.py +104 -0
- api_shakedown/spec/models.py +78 -0
- api_shakedown/spec/normalize.py +175 -0
- api_shakedown/spec/refs.py +59 -0
- api_shakedown/spec/samples.py +100 -0
- api_shakedown/spec/schema_check.py +31 -0
- api_shakedown/templates/__init__.py +1 -0
- api_shakedown/templates/chaos_cli.py +92 -0
- api_shakedown/templates/contract_runner.py +50 -0
- api_shakedown/templates/robustness_runner.py +37 -0
- api_shakedown/templates/shakedown_support.py +92 -0
- api_shakedown/testdata.py +215 -0
- api_shakedown/wiremock.py +273 -0
- api_shakedown-0.1.0.dist-info/METADATA +431 -0
- api_shakedown-0.1.0.dist-info/RECORD +40 -0
- api_shakedown-0.1.0.dist-info/WHEEL +4 -0
- api_shakedown-0.1.0.dist-info/entry_points.txt +3 -0
- api_shakedown-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""Agent that reviews the other specialists' output.
|
|
2
|
+
|
|
3
|
+
Code already guarantees validity (schemas, routing, ids); the reviewer judges what code
|
|
4
|
+
cannot: realism, coverage, and whether expectations make sense. Findings are either
|
|
5
|
+
blockers (reject: the author revises) or suggestions (approve with comments), and every
|
|
6
|
+
finding must cite a real item in the artifact, so hallucinated complaints are fed back
|
|
7
|
+
and dropped rather than triggering pointless revisions.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any, Literal
|
|
14
|
+
|
|
15
|
+
from agentpanel import AgentContext, Artifact, Verdict
|
|
16
|
+
from langchain_core.language_models import BaseChatModel
|
|
17
|
+
from pydantic import BaseModel, Field
|
|
18
|
+
|
|
19
|
+
from api_shakedown.agents._common import generate_checked
|
|
20
|
+
from api_shakedown.chaos import ChaosSuite
|
|
21
|
+
from api_shakedown.contracts import ContractSuite
|
|
22
|
+
from api_shakedown.spec import ApiSpec, Operation
|
|
23
|
+
from api_shakedown.testdata import Dataset
|
|
24
|
+
from api_shakedown.wiremock import RECORD_PRIORITY, StubSet
|
|
25
|
+
|
|
26
|
+
GENERAL = "general"
|
|
27
|
+
MAX_CHARS = 16_000
|
|
28
|
+
MAX_VALUE_CHARS = 300
|
|
29
|
+
|
|
30
|
+
INSTRUCTIONS = """\
|
|
31
|
+
You review artifacts generated for an API test harness. Validity (schemas, routing, \
|
|
32
|
+
ids) is already checked by code; you judge quality.
|
|
33
|
+
|
|
34
|
+
Report only real problems, each about one specific item, with a concrete fix:
|
|
35
|
+
- "blocker": the artifact would be wrong or misleading for its purpose; the author \
|
|
36
|
+
must fix it.
|
|
37
|
+
- "suggestion": an improvement that is not required.
|
|
38
|
+
If the artifact is good, return an empty findings list. Do not invent problems.
|
|
39
|
+
|
|
40
|
+
OpenAPI note: "default" and ranges such as "4XX" are valid response keys. "default" \
|
|
41
|
+
means any status the spec does not list explicitly, so a case that expects "default" \
|
|
42
|
+
passes for any such status (400, 500, ...)."""
|
|
43
|
+
|
|
44
|
+
CHECKLISTS = {
|
|
45
|
+
"stubs": """\
|
|
46
|
+
Checklist for WireMock stubs:
|
|
47
|
+
- Bodies look like real data: no placeholders such as "string", "foo", or "test".
|
|
48
|
+
- Ids and names are consistent across operations.
|
|
49
|
+
- Error bodies match their status and plausibly describe the failure.
|
|
50
|
+
- Response headers have plausible values.
|
|
51
|
+
Request matching (paths, priorities, which stub answers which request) is generated by \
|
|
52
|
+
code and is correct; judge only the response content.""",
|
|
53
|
+
"contracts": """\
|
|
54
|
+
Checklist for contract test cases:
|
|
55
|
+
- Each operation has a happy path, its documented error statuses where feasible, and \
|
|
56
|
+
boundary cases for constrained inputs.
|
|
57
|
+
- Each case's inputs would actually produce its expected status (a 400 case must send \
|
|
58
|
+
invalid input; a 404 case an unknown id).
|
|
59
|
+
- Inputs are realistic; there are no near-duplicate cases.""",
|
|
60
|
+
"chaos": """\
|
|
61
|
+
Checklist for chaos scenarios:
|
|
62
|
+
- Scenarios are realistic and valuable for this API (writes, most-used reads, \
|
|
63
|
+
retries, partial failure).
|
|
64
|
+
- Each expectation is concrete and matches its effect (e.g. retry advice only for \
|
|
65
|
+
transient failures).
|
|
66
|
+
- No two scenarios test the same thing.
|
|
67
|
+
Baseline scenarios and probes are generated by code and cannot be changed by the \
|
|
68
|
+
author: only suggest about them, never block.""",
|
|
69
|
+
"dataset": """\
|
|
70
|
+
Checklist for test data:
|
|
71
|
+
- Records are realistic and varied: no placeholders, enum values spread across records.
|
|
72
|
+
- Data is internally consistent (names, dates, amounts, references).""",
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class Finding(BaseModel):
|
|
77
|
+
severity: Literal["blocker", "suggestion"]
|
|
78
|
+
item: str = Field(description='Id of the stub/case/scenario/record, or "general".')
|
|
79
|
+
problem: str
|
|
80
|
+
fix: str = Field(description="The concrete change the author should make.")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class ReviewFindings(BaseModel):
|
|
84
|
+
findings: list[Finding] = Field(default_factory=list)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class ArtifactReviewer:
|
|
89
|
+
model: BaseChatModel
|
|
90
|
+
name: str = "reviewer"
|
|
91
|
+
max_findings: int = 8
|
|
92
|
+
max_attempts: int = 3
|
|
93
|
+
|
|
94
|
+
async def review(self, artifact: Artifact, ctx: AgentContext) -> Verdict:
|
|
95
|
+
spec: ApiSpec = ctx.inputs["spec"]
|
|
96
|
+
reviewable = describe(artifact.content)
|
|
97
|
+
if reviewable is None:
|
|
98
|
+
return Verdict(approved=True, comments=["Not reviewed: unknown artifact type."])
|
|
99
|
+
kind, rendering, items, blockable = reviewable
|
|
100
|
+
notes: list[str] = []
|
|
101
|
+
output = await generate_checked(
|
|
102
|
+
self.model,
|
|
103
|
+
f"{INSTRUCTIONS}\n\n{CHECKLISTS[kind]}",
|
|
104
|
+
_prompt(spec, artifact, rendering, items),
|
|
105
|
+
ReviewFindings,
|
|
106
|
+
check=lambda out: _problems(out, items),
|
|
107
|
+
max_attempts=self.max_attempts,
|
|
108
|
+
notes=notes,
|
|
109
|
+
)
|
|
110
|
+
if output is None:
|
|
111
|
+
return Verdict(approved=True, comments=[f"Review unavailable: {' '.join(notes)}"])
|
|
112
|
+
findings = [
|
|
113
|
+
_downgrade(f, blockable) for f in output.findings if not _finding_problems(f, items)
|
|
114
|
+
]
|
|
115
|
+
findings.sort(key=lambda f: f.severity != "blocker")
|
|
116
|
+
findings = findings[: self.max_findings]
|
|
117
|
+
return Verdict(
|
|
118
|
+
approved=not any(f.severity == "blocker" for f in findings),
|
|
119
|
+
comments=[f"[{f.severity}] {f.item}: {f.problem} Fix: {f.fix}" for f in findings],
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
Rendered = tuple[list[str], set[str], set[str]] # lines, citable ids, blockable ids
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def describe(content: Any) -> tuple[str, str, set[str], set[str]] | None:
|
|
127
|
+
"""(checklist kind, rendering, citable ids, ids the author can fix) for an artifact."""
|
|
128
|
+
renderers: list[tuple[type, str, Callable[[Any], Rendered]]] = [
|
|
129
|
+
(StubSet, "stubs", _stubs),
|
|
130
|
+
(ContractSuite, "contracts", _contracts),
|
|
131
|
+
(ChaosSuite, "chaos", _chaos),
|
|
132
|
+
(Dataset, "dataset", _dataset),
|
|
133
|
+
]
|
|
134
|
+
for model, kind, render in renderers:
|
|
135
|
+
if isinstance(content, model):
|
|
136
|
+
lines, items, blockable = render(content)
|
|
137
|
+
return kind, _fit(lines), items, blockable
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _stubs(stubs: StubSet) -> Rendered:
|
|
142
|
+
lines = [
|
|
143
|
+
"(Which stub answers which request is decided by code and is correct; the bracket "
|
|
144
|
+
"says when each one is used. The X-Shakedown-Status header is a deliberate test "
|
|
145
|
+
"hook, not part of the API: it lets tests request each documented response on "
|
|
146
|
+
"demand. Never report it as a problem.)"
|
|
147
|
+
]
|
|
148
|
+
for m in stubs.mappings:
|
|
149
|
+
request, response = m["request"], m["response"]
|
|
150
|
+
meta = m["metadata"]["shakedown"]
|
|
151
|
+
body = response.get("jsonBody", response.get("body"))
|
|
152
|
+
lines.append(
|
|
153
|
+
f"- {m['name']}: {request['method']} [{_when(m, meta)}] "
|
|
154
|
+
f"-> {response.get('status')} {_short(body)}"
|
|
155
|
+
)
|
|
156
|
+
items = {m["name"] for m in stubs.mappings}
|
|
157
|
+
items |= {m["metadata"]["shakedown"]["operationId"] for m in stubs.mappings}
|
|
158
|
+
# Bodies taken from the test dataset are not the stub author's to change.
|
|
159
|
+
from_dataset = {
|
|
160
|
+
m["name"] for m in stubs.mappings if m["metadata"]["shakedown"].get("source") == "dataset"
|
|
161
|
+
}
|
|
162
|
+
return lines, items, items - from_dataset
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _when(mapping: dict[str, Any], meta: dict[str, Any]) -> str:
|
|
166
|
+
if meta.get("selectedBy"):
|
|
167
|
+
header, value = next(iter(meta["selectedBy"].items()))
|
|
168
|
+
meaning = " (the spec's 'default' response)" if value == "default" else ""
|
|
169
|
+
return f"only when the client sends {header}: {value}{meaning}"
|
|
170
|
+
if mapping["priority"] == RECORD_PRIORITY:
|
|
171
|
+
return f"only for {mapping['name'].split(' ', 2)[2]} (a record in the test data)"
|
|
172
|
+
if mapping["name"].endswith("(unknown id)"):
|
|
173
|
+
return "for ids not in the test data"
|
|
174
|
+
return "normal response"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _contracts(suite: ContractSuite) -> Rendered:
|
|
178
|
+
lines = [
|
|
179
|
+
f"- {c.id}: {c.method.upper()} {c.path} path={_short(c.path_params)} "
|
|
180
|
+
f"query={_short(c.query)} body={_short(c.body)} -> expect {_status(c.expected_status)}"
|
|
181
|
+
for c in suite.cases
|
|
182
|
+
]
|
|
183
|
+
items = {c.id for c in suite.cases} | {c.operation_id for c in suite.cases}
|
|
184
|
+
return lines, items, items
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _status(label: str) -> str:
|
|
188
|
+
if label == "default":
|
|
189
|
+
return "default (any status the spec does not list explicitly)"
|
|
190
|
+
if not label.isdigit():
|
|
191
|
+
return f"{label} (any {label[0]}xx)"
|
|
192
|
+
return label
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _chaos(suite: ChaosSuite) -> Rendered:
|
|
196
|
+
lines = ["Scenarios:"] + [
|
|
197
|
+
f"- {s.id} [{s.category}, {'baseline, fixed' if s.source == 'baseline' else 'llm'}] "
|
|
198
|
+
f"ops={s.operations or 'all'} "
|
|
199
|
+
f"effect={_short(s.effect.model_dump(exclude_none=True))} times={s.times} "
|
|
200
|
+
f"| expect: {s.expectation}"
|
|
201
|
+
for s in suite.scenarios
|
|
202
|
+
]
|
|
203
|
+
per_op: dict[str, list[str]] = {}
|
|
204
|
+
for probe in suite.probes:
|
|
205
|
+
per_op.setdefault(probe.operation_id, []).append(probe.description)
|
|
206
|
+
lines.append("Robustness probes (malformed requests that must get a 4xx):")
|
|
207
|
+
lines += [f"- {op}: {len(d)} probes, e.g. {'; '.join(d[:4])}" for op, d in per_op.items()]
|
|
208
|
+
items = {s.id for s in suite.scenarios} | {p.id for p in suite.probes} | set(per_op)
|
|
209
|
+
blockable = {s.id for s in suite.scenarios if s.source == "llm"} | set(per_op)
|
|
210
|
+
return lines, items, blockable
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _dataset(dataset: Dataset) -> Rendered:
|
|
214
|
+
lines: list[str] = []
|
|
215
|
+
items: set[str] = set()
|
|
216
|
+
for entity in dataset.entities.values():
|
|
217
|
+
items.add(entity.name)
|
|
218
|
+
for record in entity.records:
|
|
219
|
+
ref = f"{entity.name}#{record.get(entity.id_field or '', '?')}"
|
|
220
|
+
items.add(ref)
|
|
221
|
+
lines.append(f"- {ref}: {_short(record)}")
|
|
222
|
+
return lines, items, items
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _prompt(spec: ApiSpec, artifact: Artifact, rendering: str, items: set[str]) -> str:
|
|
226
|
+
operations = "\n".join(_operation_line(op) for op in spec.operations)
|
|
227
|
+
return (
|
|
228
|
+
f"API: {spec.title} {spec.version}\nOperations:\n{operations}\n\n"
|
|
229
|
+
f"Review this {artifact.kind} produced by '{artifact.author}':\n"
|
|
230
|
+
f"<artifact>\n{rendering}\n</artifact>\n\n"
|
|
231
|
+
'Each finding\'s "item" must be one of the ids shown above (the text before the '
|
|
232
|
+
'colon on a line, or an operation id), or "general" for a suggestion.'
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _operation_line(op: Operation) -> str:
|
|
237
|
+
"""One line per operation, with the inputs a reviewer needs to judge cases."""
|
|
238
|
+
params = ", ".join(
|
|
239
|
+
f"{p.name} ({p.location}{', required' if p.required else ''}"
|
|
240
|
+
+ (f", {_short(_constraints(p.schema_))}" if _constraints(p.schema_) else "")
|
|
241
|
+
+ ")"
|
|
242
|
+
for p in op.parameters
|
|
243
|
+
)
|
|
244
|
+
body = ""
|
|
245
|
+
if op.request_body is not None:
|
|
246
|
+
body = f" body: {op.request_body.content_type}"
|
|
247
|
+
body += " (required)" if op.request_body.required else " (optional)"
|
|
248
|
+
return (
|
|
249
|
+
f"- {op.id}: {op.method.upper()} {op.path}"
|
|
250
|
+
+ (f" ({op.summary})" if op.summary else "")
|
|
251
|
+
+ (f" params: {params};" if params else " no params;")
|
|
252
|
+
+ (body or " no request body;")
|
|
253
|
+
+ f" statuses {[r.status for r in op.responses]}"
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _constraints(schema: dict[str, Any] | None) -> dict[str, Any]:
|
|
258
|
+
keys = ("type", "enum", "minimum", "maximum", "minLength", "maxLength", "format")
|
|
259
|
+
return {k: v for k, v in (schema or {}).items() if k in keys}
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _downgrade(finding: Finding, blockable: set[str]) -> Finding:
|
|
263
|
+
"""A blocker on something the author cannot change becomes a suggestion."""
|
|
264
|
+
if finding.severity == "blocker" and finding.item not in blockable:
|
|
265
|
+
return finding.model_copy(update={"severity": "suggestion"})
|
|
266
|
+
return finding
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _problems(output: ReviewFindings, items: set[str]) -> list[str]:
|
|
270
|
+
return [
|
|
271
|
+
f"finding about {finding.item!r}: {problem}"
|
|
272
|
+
for finding in output.findings
|
|
273
|
+
for problem in _finding_problems(finding, items)
|
|
274
|
+
]
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _finding_problems(finding: Finding, items: set[str]) -> list[str]:
|
|
278
|
+
if finding.item == GENERAL:
|
|
279
|
+
if finding.severity == "blocker":
|
|
280
|
+
return ["a blocker must name a specific item, not 'general'."]
|
|
281
|
+
return []
|
|
282
|
+
if finding.item not in items:
|
|
283
|
+
return ["item is not in the artifact; cite an id exactly as shown."]
|
|
284
|
+
return []
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _short(value: Any) -> str:
|
|
288
|
+
text = json.dumps(value, default=str, ensure_ascii=False)
|
|
289
|
+
return text if len(text) <= MAX_VALUE_CHARS else text[:MAX_VALUE_CHARS] + "...(truncated)"
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _fit(lines: list[str]) -> str:
|
|
293
|
+
kept: list[str] = []
|
|
294
|
+
size = 0
|
|
295
|
+
for line in lines:
|
|
296
|
+
if size + len(line) > MAX_CHARS:
|
|
297
|
+
kept.append(f"... ({len(lines) - len(kept)} more lines omitted)")
|
|
298
|
+
break
|
|
299
|
+
kept.append(line)
|
|
300
|
+
size += len(line) + 1
|
|
301
|
+
return "\n".join(kept)
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Agent that generates WireMock stub mappings from the spec.
|
|
2
|
+
|
|
3
|
+
Request matching comes from code (`api_shakedown.wiremock`). The LLM writes realistic
|
|
4
|
+
response bodies, which are validated against the spec's response schemas; problems are
|
|
5
|
+
fed back for another attempt, and anything still invalid falls back to a schema sample.
|
|
6
|
+
Without a model, the agent is fully deterministic.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from agentpanel import AgentContext
|
|
13
|
+
from agentpanel.llm_agents import render_feedback
|
|
14
|
+
from langchain_core.language_models import BaseChatModel
|
|
15
|
+
from pydantic import BaseModel, Field
|
|
16
|
+
|
|
17
|
+
from api_shakedown.agents._common import generate_checked
|
|
18
|
+
from api_shakedown.spec import ApiSpec, Operation, chunk_operations
|
|
19
|
+
from api_shakedown.spec.chunking import DEFAULT_MAX_CHARS
|
|
20
|
+
from api_shakedown.spec.models import Response
|
|
21
|
+
from api_shakedown.spec.schema_check import schema_errors
|
|
22
|
+
from api_shakedown.testdata import Dataset
|
|
23
|
+
from api_shakedown.wiremock import (
|
|
24
|
+
ResponseContent,
|
|
25
|
+
ResponseKey,
|
|
26
|
+
StubSet,
|
|
27
|
+
build_stub_set,
|
|
28
|
+
dataset_content,
|
|
29
|
+
expected_responses,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
INSTRUCTIONS = """\
|
|
33
|
+
You generate realistic mock HTTP response bodies for WireMock stubs, from an OpenAPI \
|
|
34
|
+
description of each operation.
|
|
35
|
+
|
|
36
|
+
Rules:
|
|
37
|
+
- Every body must strictly conform to its response schema: required fields, types, \
|
|
38
|
+
enums, formats, and min/max constraints.
|
|
39
|
+
- Use realistic, internally consistent data: reuse the same ids and names across \
|
|
40
|
+
related operations, and avoid placeholders like "string" or "foo".
|
|
41
|
+
- Error bodies should read like real errors for their status code.
|
|
42
|
+
- Give plausible values for documented response headers.
|
|
43
|
+
- Use null as the body when a response has no content."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class GeneratedResponse(BaseModel):
|
|
47
|
+
operation_id: str
|
|
48
|
+
status: str
|
|
49
|
+
body: Any = Field(default=None, description="JSON value matching the response schema.")
|
|
50
|
+
headers: dict[str, str] = Field(default_factory=dict)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class GeneratedResponses(BaseModel):
|
|
54
|
+
responses: list[GeneratedResponse]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class StubAgent:
|
|
59
|
+
model: BaseChatModel | None = None
|
|
60
|
+
name: str = "stubs"
|
|
61
|
+
kind: str = "WireMock stub mappings"
|
|
62
|
+
max_chars: int = DEFAULT_MAX_CHARS
|
|
63
|
+
max_attempts: int = 3
|
|
64
|
+
|
|
65
|
+
async def produce(self, ctx: AgentContext) -> StubSet:
|
|
66
|
+
spec: ApiSpec = ctx.inputs["spec"]
|
|
67
|
+
dataset: Dataset | None = ctx.inputs.get("dataset")
|
|
68
|
+
contents: dict[ResponseKey, ResponseContent] = {}
|
|
69
|
+
notes: list[str] = []
|
|
70
|
+
if self.model is not None:
|
|
71
|
+
feedback = ctx.feedback_for(self.name)
|
|
72
|
+
for chunk in chunk_operations(spec.operations, self.max_chars):
|
|
73
|
+
expected = _needs_llm(chunk, dataset)
|
|
74
|
+
if expected:
|
|
75
|
+
contents |= await self._generate(
|
|
76
|
+
self.model, spec, chunk, expected, render_feedback(feedback), notes
|
|
77
|
+
)
|
|
78
|
+
notes += [
|
|
79
|
+
f"{op.id} {response.status}: used a schema-generated sample body."
|
|
80
|
+
for op, response in expected_responses(spec.operations)
|
|
81
|
+
if (op.id, response.status) not in contents
|
|
82
|
+
and dataset_content(response, dataset) is None
|
|
83
|
+
]
|
|
84
|
+
return build_stub_set(spec, contents, notes, dataset)
|
|
85
|
+
|
|
86
|
+
async def _generate(
|
|
87
|
+
self,
|
|
88
|
+
model: BaseChatModel,
|
|
89
|
+
spec: ApiSpec,
|
|
90
|
+
chunk: list[Operation],
|
|
91
|
+
expected: dict[ResponseKey, Response],
|
|
92
|
+
feedback: str,
|
|
93
|
+
notes: list[str],
|
|
94
|
+
) -> dict[ResponseKey, ResponseContent]:
|
|
95
|
+
output = await generate_checked(
|
|
96
|
+
model,
|
|
97
|
+
INSTRUCTIONS,
|
|
98
|
+
_prompt(spec, chunk, expected, feedback),
|
|
99
|
+
GeneratedResponses,
|
|
100
|
+
check=lambda out: _problems(spec, expected, out),
|
|
101
|
+
max_attempts=self.max_attempts,
|
|
102
|
+
notes=notes,
|
|
103
|
+
)
|
|
104
|
+
usable: dict[ResponseKey, ResponseContent] = {}
|
|
105
|
+
for item in output.responses if output else []:
|
|
106
|
+
key = (item.operation_id, item.status)
|
|
107
|
+
response = expected.get(key)
|
|
108
|
+
if response is None or (response.body is not None and item.body is None):
|
|
109
|
+
continue # unknown entry, or a null body where the sample is better
|
|
110
|
+
if not _item_problems(spec, response, item):
|
|
111
|
+
usable[key] = ResponseContent(body=item.body, headers=item.headers, source="llm")
|
|
112
|
+
return usable
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _needs_llm(chunk: list[Operation], dataset: Dataset | None) -> dict[ResponseKey, Response]:
|
|
116
|
+
"""Responses the LLM should write: everything the dataset does not already cover."""
|
|
117
|
+
return {
|
|
118
|
+
(op.id, r.status): r
|
|
119
|
+
for op, r in expected_responses(chunk)
|
|
120
|
+
if dataset_content(r, dataset) is None
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _prompt(
|
|
125
|
+
spec: ApiSpec, chunk: list[Operation], expected: dict[ResponseKey, Response], feedback: str
|
|
126
|
+
) -> str:
|
|
127
|
+
entries = []
|
|
128
|
+
for (op_id, status), response in expected.items():
|
|
129
|
+
body = response.body
|
|
130
|
+
what = f"{body.content_type}" if body else "no body: use null"
|
|
131
|
+
entries.append(f"- operation_id={op_id} status={status} ({what})")
|
|
132
|
+
prompt = (
|
|
133
|
+
f"API: {spec.title} {spec.version}\n\n"
|
|
134
|
+
"<operations>\n" + "\n".join(op.to_prompt() for op in chunk) + "\n</operations>\n\n"
|
|
135
|
+
"Produce exactly one entry for each of these responses:\n" + "\n".join(entries)
|
|
136
|
+
)
|
|
137
|
+
if feedback:
|
|
138
|
+
prompt += f"\n\nReviewers rejected the previous stubs. Address this feedback:\n{feedback}"
|
|
139
|
+
return prompt
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _problems(
|
|
143
|
+
spec: ApiSpec, expected: dict[ResponseKey, Response], output: GeneratedResponses
|
|
144
|
+
) -> list[str]:
|
|
145
|
+
produced = {(item.operation_id, item.status): item for item in output.responses}
|
|
146
|
+
problems = [
|
|
147
|
+
f"Missing entry for operation_id={op_id} status={status}."
|
|
148
|
+
for op_id, status in expected
|
|
149
|
+
if (op_id, status) not in produced
|
|
150
|
+
]
|
|
151
|
+
for key, item in produced.items():
|
|
152
|
+
response = expected.get(key)
|
|
153
|
+
if response is not None:
|
|
154
|
+
problems += [f"{key[0]} {key[1]}: {p}" for p in _item_problems(spec, response, item)]
|
|
155
|
+
return problems
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _item_problems(spec: ApiSpec, response: Response, item: GeneratedResponse) -> list[str]:
|
|
159
|
+
if response.body is None or response.body.schema_ is None:
|
|
160
|
+
return []
|
|
161
|
+
if item.body is None:
|
|
162
|
+
return ["body is null but the response has a schema."]
|
|
163
|
+
return schema_errors(response.body.schema_, item.body, spec.openapi_version)
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Agent that builds the shared test dataset.
|
|
2
|
+
|
|
3
|
+
The LLM writes realistic, varied records for each entity the API serves; code validates
|
|
4
|
+
every record against its schema, enforces unique ids, and falls back to deterministic
|
|
5
|
+
records where needed. Edge-case values come from code alone. The dataset is meant to run
|
|
6
|
+
first and be passed to the other agents as `inputs["dataset"]`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from agentpanel import AgentContext
|
|
14
|
+
from agentpanel.llm_agents import render_feedback
|
|
15
|
+
from langchain_core.language_models import BaseChatModel
|
|
16
|
+
from pydantic import BaseModel, Field
|
|
17
|
+
|
|
18
|
+
from api_shakedown.agents._common import generate_checked
|
|
19
|
+
from api_shakedown.spec import ApiSpec
|
|
20
|
+
from api_shakedown.spec.chunking import DEFAULT_MAX_CHARS
|
|
21
|
+
from api_shakedown.testdata import (
|
|
22
|
+
DEFAULT_RECORDS,
|
|
23
|
+
Dataset,
|
|
24
|
+
Entity,
|
|
25
|
+
build_dataset,
|
|
26
|
+
find_entities,
|
|
27
|
+
record_problems,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
MAX_RECORDS = 10
|
|
31
|
+
|
|
32
|
+
INSTRUCTIONS = """\
|
|
33
|
+
You create realistic test data for an API: records for each entity, matching its JSON \
|
|
34
|
+
schema exactly.
|
|
35
|
+
|
|
36
|
+
Rules:
|
|
37
|
+
- Every record must satisfy the schema: required fields, types, enums, formats, and \
|
|
38
|
+
min/max constraints.
|
|
39
|
+
- Records must have unique ids.
|
|
40
|
+
- Make records realistic and varied: different enum values, plausible names, dates, \
|
|
41
|
+
and amounts. Avoid placeholders like "string", "foo", or "test".
|
|
42
|
+
- Keep data consistent across entities that refer to each other."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class EntityRecords(BaseModel):
|
|
46
|
+
entity: str
|
|
47
|
+
records: list[dict[str, Any]]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class GeneratedDataset(BaseModel):
|
|
51
|
+
entities: list[EntityRecords] = Field(description="One entry per requested entity.")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class DatasetAgent:
|
|
56
|
+
model: BaseChatModel | None = None
|
|
57
|
+
name: str = "testdata"
|
|
58
|
+
kind: str = "test dataset"
|
|
59
|
+
records_per_entity: int = DEFAULT_RECORDS
|
|
60
|
+
max_chars: int = DEFAULT_MAX_CHARS
|
|
61
|
+
max_attempts: int = 3
|
|
62
|
+
|
|
63
|
+
async def produce(self, ctx: AgentContext) -> Dataset:
|
|
64
|
+
spec: ApiSpec = ctx.inputs["spec"]
|
|
65
|
+
entities = find_entities(spec)
|
|
66
|
+
notes: list[str] = []
|
|
67
|
+
if self.model is not None and entities:
|
|
68
|
+
feedback = render_feedback(ctx.feedback_for(self.name))
|
|
69
|
+
for group in _groups(list(entities.values()), self.max_chars):
|
|
70
|
+
await self._generate(self.model, spec, group, feedback, notes)
|
|
71
|
+
notes += [
|
|
72
|
+
f"{e.name}: used deterministic sample records."
|
|
73
|
+
for e in entities.values()
|
|
74
|
+
if not e.records
|
|
75
|
+
]
|
|
76
|
+
return build_dataset(spec, entities, notes)
|
|
77
|
+
|
|
78
|
+
async def _generate(
|
|
79
|
+
self,
|
|
80
|
+
model: BaseChatModel,
|
|
81
|
+
spec: ApiSpec,
|
|
82
|
+
group: list[Entity],
|
|
83
|
+
feedback: str,
|
|
84
|
+
notes: list[str],
|
|
85
|
+
) -> None:
|
|
86
|
+
count = max(1, min(self.records_per_entity, MAX_RECORDS))
|
|
87
|
+
by_name = {e.name: e for e in group}
|
|
88
|
+
output = await generate_checked(
|
|
89
|
+
model,
|
|
90
|
+
INSTRUCTIONS,
|
|
91
|
+
_prompt(spec, group, count, feedback),
|
|
92
|
+
GeneratedDataset,
|
|
93
|
+
check=lambda out: _problems(spec, by_name, out),
|
|
94
|
+
max_attempts=self.max_attempts,
|
|
95
|
+
notes=notes,
|
|
96
|
+
)
|
|
97
|
+
for item in output.entities if output else []:
|
|
98
|
+
entity = by_name.get(item.entity)
|
|
99
|
+
if entity is None:
|
|
100
|
+
continue
|
|
101
|
+
valid: list[dict[str, Any]] = []
|
|
102
|
+
for record in item.records[:MAX_RECORDS]:
|
|
103
|
+
if not record_problems(entity, [*valid, record], spec.openapi_version):
|
|
104
|
+
valid.append(record)
|
|
105
|
+
entity.records = valid
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _groups(entities: list[Entity], max_chars: int) -> list[list[Entity]]:
|
|
109
|
+
groups: list[list[Entity]] = []
|
|
110
|
+
size = 0
|
|
111
|
+
for entity in entities:
|
|
112
|
+
entity_size = len(json.dumps(entity.schema_))
|
|
113
|
+
if not groups or size + entity_size > max_chars:
|
|
114
|
+
groups.append([])
|
|
115
|
+
size = 0
|
|
116
|
+
groups[-1].append(entity)
|
|
117
|
+
size += entity_size
|
|
118
|
+
return groups
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _prompt(spec: ApiSpec, group: list[Entity], count: int, feedback: str) -> str:
|
|
122
|
+
schemas = "\n".join(
|
|
123
|
+
f'<entity name="{e.name}" id_field="{e.id_field}">\n{json.dumps(e.schema_)}\n</entity>'
|
|
124
|
+
for e in group
|
|
125
|
+
)
|
|
126
|
+
prompt = (
|
|
127
|
+
f"API: {spec.title} {spec.version}\n\n{schemas}\n\n"
|
|
128
|
+
f"Create {count} records for each of: {', '.join(e.name for e in group)}."
|
|
129
|
+
)
|
|
130
|
+
if feedback:
|
|
131
|
+
prompt += f"\n\nReviewers rejected the previous dataset. Address this feedback:\n{feedback}"
|
|
132
|
+
return prompt
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _problems(spec: ApiSpec, entities: dict[str, Entity], output: GeneratedDataset) -> list[str]:
|
|
136
|
+
produced = {item.entity: item for item in output.entities}
|
|
137
|
+
problems = [f"Missing records for entity {name}." for name in entities if name not in produced]
|
|
138
|
+
for name, item in produced.items():
|
|
139
|
+
entity = entities.get(name)
|
|
140
|
+
if entity is None:
|
|
141
|
+
problems.append(f"Unknown entity {name!r}; use one of {sorted(entities)}.")
|
|
142
|
+
elif not item.records:
|
|
143
|
+
problems.append(f"No records for {name}.")
|
|
144
|
+
else:
|
|
145
|
+
problems += record_problems(entity, item.records, spec.openapi_version)
|
|
146
|
+
return problems
|