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.
Files changed (40) hide show
  1. api_shakedown/__init__.py +3 -0
  2. api_shakedown/__main__.py +3 -0
  3. api_shakedown/agents/__init__.py +1 -0
  4. api_shakedown/agents/_common.py +36 -0
  5. api_shakedown/agents/chaos.py +146 -0
  6. api_shakedown/agents/contracts.py +176 -0
  7. api_shakedown/agents/coordinator.py +211 -0
  8. api_shakedown/agents/reviewer.py +301 -0
  9. api_shakedown/agents/stubs.py +163 -0
  10. api_shakedown/agents/testdata.py +146 -0
  11. api_shakedown/chaos.py +451 -0
  12. api_shakedown/chaos_control.py +54 -0
  13. api_shakedown/cli.py +131 -0
  14. api_shakedown/config.py +19 -0
  15. api_shakedown/contracts.py +243 -0
  16. api_shakedown/harness/__init__.py +26 -0
  17. api_shakedown/mcp_server.py +218 -0
  18. api_shakedown/pipeline.py +92 -0
  19. api_shakedown/py.typed +0 -0
  20. api_shakedown/spec/__init__.py +17 -0
  21. api_shakedown/spec/chunking.py +32 -0
  22. api_shakedown/spec/edge_cases.py +149 -0
  23. api_shakedown/spec/loader.py +104 -0
  24. api_shakedown/spec/models.py +78 -0
  25. api_shakedown/spec/normalize.py +175 -0
  26. api_shakedown/spec/refs.py +59 -0
  27. api_shakedown/spec/samples.py +100 -0
  28. api_shakedown/spec/schema_check.py +31 -0
  29. api_shakedown/templates/__init__.py +1 -0
  30. api_shakedown/templates/chaos_cli.py +92 -0
  31. api_shakedown/templates/contract_runner.py +50 -0
  32. api_shakedown/templates/robustness_runner.py +37 -0
  33. api_shakedown/templates/shakedown_support.py +92 -0
  34. api_shakedown/testdata.py +215 -0
  35. api_shakedown/wiremock.py +273 -0
  36. api_shakedown-0.1.0.dist-info/METADATA +431 -0
  37. api_shakedown-0.1.0.dist-info/RECORD +40 -0
  38. api_shakedown-0.1.0.dist-info/WHEEL +4 -0
  39. api_shakedown-0.1.0.dist-info/entry_points.txt +3 -0
  40. 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