crewforge 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 (61) hide show
  1. crewforge/__init__.py +3 -0
  2. crewforge/__main__.py +4 -0
  3. crewforge/cli.py +128 -0
  4. crewforge/cli_support.py +109 -0
  5. crewforge/core/__init__.py +54 -0
  6. crewforge/core/agent.py +118 -0
  7. crewforge/core/client.py +253 -0
  8. crewforge/core/config.py +181 -0
  9. crewforge/core/crew.py +144 -0
  10. crewforge/core/errors.py +106 -0
  11. crewforge/core/parsing.py +110 -0
  12. crewforge/core/providers.py +131 -0
  13. crewforge/core/usage.py +163 -0
  14. crewforge/py.typed +0 -0
  15. crewforge/recipes/__init__.py +1 -0
  16. crewforge/recipes/api/__init__.py +38 -0
  17. crewforge/recipes/api/agents.py +523 -0
  18. crewforge/recipes/api/analytics.py +99 -0
  19. crewforge/recipes/api/appcheck.py +73 -0
  20. crewforge/recipes/api/cli.py +127 -0
  21. crewforge/recipes/api/formatters.py +32 -0
  22. crewforge/recipes/api/generator.py +37 -0
  23. crewforge/recipes/api/pipeline.py +86 -0
  24. crewforge/recipes/api/scaffold.py +68 -0
  25. crewforge/recipes/api/templates/docker-compose.j2 +9 -0
  26. crewforge/recipes/api/templates/dockerfile.j2 +7 -0
  27. crewforge/recipes/api/templates/env.example.j2 +2 -0
  28. crewforge/recipes/api/templates/fastapi_main.j2 +10 -0
  29. crewforge/recipes/api/templates/model.j2 +4 -0
  30. crewforge/recipes/api/templates/requirements.j2 +6 -0
  31. crewforge/recipes/api/templates/route.j2 +4 -0
  32. crewforge/recipes/api/templates/test.j2 +3 -0
  33. crewforge/recipes/api/utils.py +134 -0
  34. crewforge/recipes/content/__init__.py +20 -0
  35. crewforge/recipes/content/agents.py +735 -0
  36. crewforge/recipes/content/cli.py +128 -0
  37. crewforge/recipes/content/export.py +84 -0
  38. crewforge/recipes/content/pipeline.py +222 -0
  39. crewforge/recipes/content/settings.py +63 -0
  40. crewforge/recipes/content/text.py +57 -0
  41. crewforge/recipes/docs/__init__.py +48 -0
  42. crewforge/recipes/docs/agents.py +426 -0
  43. crewforge/recipes/docs/cli.py +156 -0
  44. crewforge/recipes/docs/export.py +118 -0
  45. crewforge/recipes/docs/index.py +117 -0
  46. crewforge/recipes/docs/ingest.py +212 -0
  47. crewforge/recipes/docs/pipeline.py +223 -0
  48. crewforge/recipes/docs/retrieve.py +70 -0
  49. crewforge/recipes/docs/settings.py +54 -0
  50. crewforge/recipes/review/__init__.py +1 -0
  51. crewforge/recipes/review/agents.py +331 -0
  52. crewforge/recipes/review/cli.py +144 -0
  53. crewforge/recipes/review/pipeline.py +125 -0
  54. crewforge/recipes/review/report.py +103 -0
  55. crewforge/recipes/review/sources.py +89 -0
  56. crewforge-0.1.0.dist-info/METADATA +372 -0
  57. crewforge-0.1.0.dist-info/RECORD +61 -0
  58. crewforge-0.1.0.dist-info/WHEEL +5 -0
  59. crewforge-0.1.0.dist-info/entry_points.txt +2 -0
  60. crewforge-0.1.0.dist-info/licenses/LICENSE +21 -0
  61. crewforge-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,523 @@
1
+ """The api crew: parse a description, then build spec, routes, models, auth, tests and docs.
2
+
3
+ Deterministic agents (schema, test_gen, validator) never call the LLM. The LLM
4
+ agents return code only through a fenced block that must compile, and the
5
+ parser must return the documented JSON shape with at least one endpoint;
6
+ anything else fails the run naming the agent (apiforge used to fall back to
7
+ an empty description that a later agent filled with invented routes).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass, field
13
+ from typing import Any
14
+
15
+ from ...core.agent import Agent
16
+ from ...core.client import LLMClient
17
+ from ...core.errors import OutputParseError
18
+ from ...core.parsing import extract_code_block
19
+ from ...core.usage import UsageTracker
20
+
21
+ HTTP_METHODS = ("GET", "POST", "PUT", "PATCH", "DELETE")
22
+
23
+
24
+ @dataclass
25
+ class ParsedEndpoint:
26
+ path: str
27
+ method: str
28
+ description: str
29
+ parameters: list[dict[str, Any]] = field(default_factory=list)
30
+ request_body: dict[str, Any] | None = None
31
+ response_schema: dict[str, Any] = field(default_factory=dict)
32
+ tags: list[str] = field(default_factory=list)
33
+ auth_required: bool = True
34
+
35
+
36
+ @dataclass
37
+ class APIDescription:
38
+ title: str
39
+ description: str
40
+ version: str = "1.0.0"
41
+ base_path: str = "/api/v1"
42
+ endpoints: list[ParsedEndpoint] = field(default_factory=list)
43
+ auth_type: str = "bearer"
44
+ models: list[dict[str, Any]] = field(default_factory=list)
45
+
46
+
47
+ class ApiAgent(Agent):
48
+ """Base for the api agents (keeps apiforge's ``run(**kwargs)`` entry point)."""
49
+
50
+ def __init__(
51
+ self, client: LLMClient | None = None, tracker: UsageTracker | None = None, **kwargs: Any
52
+ ) -> None:
53
+ super().__init__(client, tracker, **kwargs) # type: ignore[arg-type]
54
+
55
+ async def run(self, **kwargs: Any) -> Any: # pragma: no cover - abstract
56
+ raise NotImplementedError
57
+
58
+ def code_from(self, content: str) -> str:
59
+ """The fenced Python block of a reply; it must exist and compile."""
60
+ code = extract_code_block(content, "python")
61
+ if code is None or not code.strip():
62
+ raise OutputParseError(self.name, "no ```python code block in the reply", content[:120])
63
+ try:
64
+ compile(code, f"<{self.name}>", "exec")
65
+ except SyntaxError as exc:
66
+ raise OutputParseError(
67
+ self.name, f"generated code does not compile: {exc.msg} (line {exc.lineno})"
68
+ ) from exc
69
+ return code.strip() + "\n"
70
+
71
+ async def review_code(self, code: str, instructions: str) -> str:
72
+ response = await self.ask(
73
+ f"{instructions}\nReturn the complete file in ONE ```python code block.\n"
74
+ f"```python\n{code}\n```"
75
+ )
76
+ return self.code_from(response.content)
77
+
78
+
79
+ # ------------------------------------------------------------------------- parser
80
+
81
+ _SCHEMA_HINT = """Reply with ONE JSON object and nothing else (no prose, no markdown).
82
+ Use exactly this shape:
83
+ {
84
+ "title": "string",
85
+ "description": "string",
86
+ "version": "1.0.0",
87
+ "base_path": "/api/v1",
88
+ "auth_type": "bearer" | "api_key" | "none",
89
+ "endpoints": [
90
+ {
91
+ "path": "/resource/{id}",
92
+ "method": "GET" | "POST" | "PUT" | "PATCH" | "DELETE",
93
+ "description": "string",
94
+ "parameters": [
95
+ {"name": "id", "in": "path" | "query",
96
+ "type": "string" | "integer" | "boolean", "required": true}
97
+ ],
98
+ "request_body": {"field": "type"} | null,
99
+ "response_schema": {"field": "type"},
100
+ "tags": ["string"],
101
+ "auth_required": true
102
+ }
103
+ ],
104
+ "models": [
105
+ {"name": "Resource", "fields": [{"name": "id", "type": "string", "required": true}]}
106
+ ]
107
+ }
108
+ Paths are relative to base_path. Include every endpoint the user describes."""
109
+
110
+
111
+ class ParserAgent(ApiAgent):
112
+ name = "parser"
113
+ description = "Turns a natural-language API description into a structured spec"
114
+ system_prompt = (
115
+ "You are the parser of an API scaffolding crew. Turn a natural-language API "
116
+ "description into a structured JSON spec.\n" + _SCHEMA_HINT
117
+ )
118
+ temperature = 0.0
119
+
120
+ def to_description(self, data: dict[str, Any]) -> APIDescription:
121
+ endpoints: list[ParsedEndpoint] = []
122
+ for i, ep in enumerate(data.get("endpoints") or []):
123
+ if not isinstance(ep, dict) or not ep.get("path"):
124
+ raise OutputParseError(self.name, f"endpoint #{i + 1} has no path")
125
+ method = str(ep.get("method", "GET")).upper()
126
+ if method not in HTTP_METHODS:
127
+ raise OutputParseError(self.name, f"endpoint {ep['path']} has method {method!r}")
128
+ path = "/" + str(ep["path"]).lstrip("/")
129
+ endpoints.append(
130
+ ParsedEndpoint(
131
+ path=path,
132
+ method=method,
133
+ description=str(ep.get("description") or f"{method} {path}"),
134
+ parameters=list(ep.get("parameters") or []),
135
+ request_body=ep.get("request_body") or None,
136
+ response_schema=ep.get("response_schema") or {},
137
+ tags=list(ep.get("tags") or []),
138
+ auth_required=bool(ep.get("auth_required", True)),
139
+ )
140
+ )
141
+ if not endpoints:
142
+ raise OutputParseError(self.name, "the spec has no endpoints")
143
+ return APIDescription(
144
+ title=str(data.get("title") or "API"),
145
+ description=str(data.get("description") or ""),
146
+ version=str(data.get("version") or "1.0.0"),
147
+ base_path="/" + str(data.get("base_path") or "/api/v1").strip("/"),
148
+ endpoints=endpoints,
149
+ auth_type=str(data.get("auth_type") or "bearer"),
150
+ models=[m for m in data.get("models") or [] if isinstance(m, dict) and m.get("name")],
151
+ )
152
+
153
+ async def parse(self, description: str) -> APIDescription:
154
+ data = await self.ask_json(
155
+ "Parse this API description into the JSON shape above:\n" + description
156
+ )
157
+ return self.to_description(data)
158
+
159
+ async def run(self, **kwargs: Any) -> APIDescription:
160
+ return await self.parse(kwargs["description"])
161
+
162
+
163
+ # --------------------------------------------------------------- deterministic
164
+
165
+
166
+ def _func_name(method: str, path: str) -> str:
167
+ stem = path.strip("/").replace("/", "_").replace("{", "").replace("}", "").replace("-", "_")
168
+ return f"{method.lower()}_{stem or 'root'}"
169
+
170
+
171
+ class SchemaAgent(ApiAgent):
172
+ """Builds the OpenAPI 3.1 document from the parsed description (no LLM call)."""
173
+
174
+ name = "schema"
175
+ description = "Builds the OpenAPI 3.1 spec (deterministic)"
176
+
177
+ def _build_paths(self, desc: APIDescription) -> dict[str, Any]:
178
+ paths: dict[str, Any] = {}
179
+ for ep in desc.endpoints:
180
+ op: dict[str, Any] = {
181
+ "summary": ep.description,
182
+ "operationId": _func_name(ep.method, ep.path),
183
+ "tags": ep.tags or ["default"],
184
+ "responses": {
185
+ "200": {
186
+ "description": "Success",
187
+ "content": {
188
+ "application/json": {"schema": ep.response_schema or {"type": "object"}}
189
+ },
190
+ }
191
+ },
192
+ }
193
+ if ep.parameters:
194
+ op["parameters"] = [
195
+ {
196
+ "name": p.get("name"),
197
+ "in": p.get("in", "query"),
198
+ "required": p.get("required", p.get("in") == "path"),
199
+ "schema": {"type": p.get("type", "string")},
200
+ }
201
+ for p in ep.parameters
202
+ ]
203
+ if ep.request_body and ep.method in ("POST", "PUT", "PATCH"):
204
+ op["requestBody"] = {
205
+ "required": True,
206
+ "content": {"application/json": {"schema": ep.request_body}},
207
+ }
208
+ if ep.auth_required and desc.auth_type != "none":
209
+ op["security"] = [{"bearerAuth": []}]
210
+ paths.setdefault(ep.path, {})[ep.method.lower()] = op
211
+ return paths
212
+
213
+ def _build_schemas(self, desc: APIDescription) -> dict[str, Any]:
214
+ schemas: dict[str, Any] = {}
215
+ for m in desc.models:
216
+ props, req = {}, []
217
+ for f in m.get("fields", []):
218
+ props[f["name"]] = {"type": f.get("type", "string")}
219
+ if f.get("required"):
220
+ req.append(f["name"])
221
+ schemas[m["name"]] = {"type": "object", "properties": props, "required": req}
222
+ return schemas
223
+
224
+ async def generate_spec(self, desc: APIDescription) -> dict[str, Any]:
225
+ return {
226
+ "openapi": "3.1.0",
227
+ "info": {"title": desc.title, "description": desc.description, "version": desc.version},
228
+ "servers": [{"url": desc.base_path}],
229
+ "paths": self._build_paths(desc),
230
+ "components": {
231
+ "schemas": self._build_schemas(desc),
232
+ "securitySchemes": {"bearerAuth": {"type": "http", "scheme": "bearer"}},
233
+ },
234
+ "security": [{"bearerAuth": []}],
235
+ }
236
+
237
+ async def run(self, **kwargs: Any) -> dict[str, Any]:
238
+ return await self.generate_spec(kwargs["description"])
239
+
240
+
241
+ class TestAgent(ApiAgent):
242
+ """Writes a pytest smoke test per endpoint (no LLM call)."""
243
+
244
+ __test__ = False # not a pytest test class despite the name
245
+ name = "test_gen"
246
+ description = "Writes pytest smoke tests for every endpoint (deterministic)"
247
+
248
+ @staticmethod
249
+ def _sample_path(desc: APIDescription, path: str) -> str:
250
+ full = desc.base_path.rstrip("/") + path
251
+ for part in [s for s in full.split("/") if s.startswith("{")]:
252
+ full = full.replace(part, "1")
253
+ return full
254
+
255
+ async def generate_tests(self, desc: APIDescription) -> str:
256
+ lines = ['"""Auto-generated smoke tests (crewforge)."""', ""]
257
+ for ep in desc.endpoints:
258
+ method = ep.method.lower()
259
+ url = self._sample_path(desc, ep.path)
260
+ lines.append(f"def test_{_func_name(ep.method, ep.path)}(client):")
261
+ lines.append(
262
+ f' """{ep.method} {ep.path} answers (auth or validation errors allowed)."""'
263
+ )
264
+ if ep.method == "GET" or ep.method == "DELETE":
265
+ lines.append(f' resp = client.{method}("{url}")')
266
+ else:
267
+ lines.append(f' resp = client.{method}("{url}", json={{}})')
268
+ allowed = "200, 201, 204, 401, 403, 422"
269
+ if "{" in ep.path:
270
+ # The sample id ("1") need not exist: "not found" is a correct answer.
271
+ allowed += ", 404"
272
+ lines.append(f" assert resp.status_code in ({allowed})")
273
+ lines.append("")
274
+ return "\n".join(lines)
275
+
276
+ async def run(self, **kwargs: Any) -> str:
277
+ return await self.generate_tests(kwargs["description"])
278
+
279
+
280
+ # --------------------------------------------------------------------- LLM code
281
+
282
+
283
+ class RouterAgent(ApiAgent):
284
+ name = "router"
285
+ description = "Writes the FastAPI routes"
286
+ system_prompt = (
287
+ "You write FastAPI route modules. Keep every route path, HTTP method and the "
288
+ "module-level `router` variable exactly as given; improve the bodies, typing and "
289
+ "docstrings only."
290
+ )
291
+
292
+ def _generate_code(self, desc: APIDescription) -> str:
293
+ lines = [
294
+ '"""Auto-generated FastAPI routes (crewforge)."""',
295
+ "from fastapi import APIRouter, Query",
296
+ "",
297
+ f'router = APIRouter(prefix="{desc.base_path}")',
298
+ "",
299
+ ]
300
+ for ep in desc.endpoints:
301
+ method = ep.method.lower()
302
+ params = []
303
+ for p in ep.parameters:
304
+ ptype = {"integer": "int", "string": "str", "boolean": "bool"}.get(
305
+ p.get("type", "string"), "str"
306
+ )
307
+ if p.get("in") == "path":
308
+ params.append(f"{p['name']}: {ptype}")
309
+ else:
310
+ params.append(f"{p['name']}: {ptype} | None = Query(None)")
311
+ if ep.request_body and method in ("post", "put", "patch"):
312
+ params.append("body: dict")
313
+ lines.append(f'@router.{method}("{ep.path}")')
314
+ lines.append(f"async def {_func_name(ep.method, ep.path)}({', '.join(params)}):")
315
+ lines.append(f' """{ep.description}"""')
316
+ lines.append(" return {}")
317
+ lines.append("")
318
+ return "\n".join(lines)
319
+
320
+ async def generate_routes(self, desc: APIDescription) -> str:
321
+ return await self.review_code(
322
+ self._generate_code(desc), "Review and improve this FastAPI routes module."
323
+ )
324
+
325
+ async def run(self, **kwargs: Any) -> str:
326
+ return await self.generate_routes(kwargs["description"])
327
+
328
+
329
+ class ModelAgent(ApiAgent):
330
+ name = "model"
331
+ description = "Writes the Pydantic v2 models"
332
+ system_prompt = "You write Pydantic v2 models. Keep every class and field name as given."
333
+ TYPE_MAP = {"string": "str", "integer": "int", "boolean": "bool", "number": "float"}
334
+
335
+ @classmethod
336
+ def _generate_models(cls, desc: APIDescription) -> str:
337
+ lines = ['"""Auto-generated models (crewforge)."""', "from pydantic import BaseModel", ""]
338
+ for m in desc.models:
339
+ lines.append(f"class {m['name']}(BaseModel):")
340
+ fields = m.get("fields") or []
341
+ for fld in fields:
342
+ ftype = cls.TYPE_MAP.get(fld.get("type", "string"), "str")
343
+ if not fld.get("required", True):
344
+ lines.append(f" {fld['name']}: {ftype} | None = None")
345
+ else:
346
+ lines.append(f" {fld['name']}: {ftype}")
347
+ if not fields:
348
+ lines.append(" pass")
349
+ lines.append("")
350
+ return "\n".join(lines)
351
+
352
+ async def generate_models(self, desc: APIDescription) -> str:
353
+ return await self.review_code(self._generate_models(desc), "Review these Pydantic models.")
354
+
355
+ async def run(self, **kwargs: Any) -> str:
356
+ return await self.generate_models(kwargs["description"])
357
+
358
+
359
+ _BEARER = '''"""JWT bearer auth."""
360
+ import os
361
+
362
+ import jwt
363
+ from fastapi import Depends, HTTPException
364
+ from fastapi.security import HTTPBearer
365
+
366
+ sec = HTTPBearer()
367
+ SECRET = os.environ["JWT_SECRET"]
368
+
369
+
370
+ async def get_user(creds=Depends(sec)):
371
+ try:
372
+ return jwt.decode(creds.credentials, SECRET, algorithms=["HS256"])
373
+ except jwt.InvalidTokenError:
374
+ raise HTTPException(401, "Invalid token") from None
375
+ '''
376
+
377
+ _API_KEY = '''"""API key auth."""
378
+ import os
379
+
380
+ from fastapi import HTTPException, Security
381
+ from fastapi.security import APIKeyHeader
382
+
383
+ header = APIKeyHeader(name="X-API-Key")
384
+ KEYS = {k for k in os.getenv("API_KEYS", "").split(",") if k}
385
+
386
+
387
+ async def verify(key: str = Security(header)):
388
+ if key not in KEYS:
389
+ raise HTTPException(403, "Invalid API key")
390
+ return key
391
+ '''
392
+
393
+
394
+ class AuthAgent(ApiAgent):
395
+ name = "auth"
396
+ description = "Writes the auth dependency (JWT bearer or API key)"
397
+ system_prompt = "You review FastAPI auth code for security problems and fix them."
398
+
399
+ async def generate_auth(self, auth_type: str = "bearer") -> str:
400
+ code = _API_KEY if auth_type == "api_key" else _BEARER
401
+ return await self.review_code(code, "Review this auth code.")
402
+
403
+ async def run(self, **kwargs: Any) -> str:
404
+ return await self.generate_auth(kwargs.get("auth_type", "bearer"))
405
+
406
+
407
+ class DocAgent(ApiAgent):
408
+ name = "doc_gen"
409
+ description = "Writes API.md"
410
+ system_prompt = (
411
+ "You write clear API documentation in Markdown. Document only the endpoints you "
412
+ "are given; do not invent any."
413
+ )
414
+
415
+ def _skeleton(self, desc: APIDescription) -> str:
416
+ lines = [
417
+ f"# {desc.title}",
418
+ "",
419
+ desc.description,
420
+ "",
421
+ "## Base URL",
422
+ f"`{desc.base_path}`",
423
+ "",
424
+ "## Authentication",
425
+ f"Type: {desc.auth_type}",
426
+ "",
427
+ "## Endpoints",
428
+ "",
429
+ ]
430
+ for ep in desc.endpoints:
431
+ lines.append(f"### `{ep.method} {ep.path}`")
432
+ lines.append(ep.description)
433
+ if ep.parameters:
434
+ lines.append("| Name | In | Type | Required |")
435
+ lines.append("|------|-----|------|----------|")
436
+ for p in ep.parameters:
437
+ lines.append(
438
+ f"| {p.get('name')} | {p.get('in', 'query')} | "
439
+ f"{p.get('type', 'string')} | {p.get('required', False)} |"
440
+ )
441
+ lines.append("")
442
+ return "\n".join(lines)
443
+
444
+ async def generate_docs(self, desc: APIDescription) -> str:
445
+ response = await self.ask("Improve this API documentation:\n\n" + self._skeleton(desc))
446
+ return response.content.strip() + "\n"
447
+
448
+ async def run(self, **kwargs: Any) -> str:
449
+ return await self.generate_docs(kwargs["description"])
450
+
451
+
452
+ # ---------------------------------------------------------------------- validate
453
+
454
+
455
+ @dataclass
456
+ class ValidationResult:
457
+ valid: bool
458
+ errors: list[str]
459
+ warnings: list[str]
460
+ score: int # 0-100
461
+ operations: int = 0 # how many operations were actually checked
462
+
463
+ def to_dict(self) -> dict[str, Any]:
464
+ return {
465
+ "valid": self.valid,
466
+ "score": self.score,
467
+ "operations_checked": self.operations,
468
+ "errors": self.errors,
469
+ "warnings": self.warnings,
470
+ }
471
+
472
+
473
+ class ValidateAgent:
474
+ """Checks an OpenAPI document offline (no LLM call, no key needed)."""
475
+
476
+ name = "validator"
477
+ description = "Checks an OpenAPI spec (deterministic)"
478
+
479
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
480
+ pass
481
+
482
+ async def validate(self, spec: dict[str, Any]) -> ValidationResult:
483
+ errors: list[str] = []
484
+ warnings: list[str] = []
485
+ if not isinstance(spec, dict):
486
+ return ValidationResult(False, ["Spec is not a JSON object"], [], 0)
487
+ if "openapi" not in spec:
488
+ errors.append("Missing openapi version")
489
+ if "info" not in spec:
490
+ errors.append("Missing info object")
491
+ paths = spec.get("paths")
492
+ if paths is None:
493
+ errors.append("Missing paths object")
494
+ elif not paths:
495
+ errors.append("No endpoints defined (paths is empty)")
496
+ info = spec.get("info") if isinstance(spec.get("info"), dict) else {}
497
+ if not info.get("title"):
498
+ errors.append("Missing API title")
499
+ if not info.get("version"):
500
+ errors.append("Missing API version")
501
+ operations = 0
502
+ for path, methods in (paths or {}).items() if isinstance(paths, dict) else []:
503
+ if not isinstance(methods, dict):
504
+ errors.append(f"Path {path} is not an object")
505
+ continue
506
+ for method, op in methods.items():
507
+ if method not in ("get", "post", "put", "delete", "patch"):
508
+ continue
509
+ operations += 1
510
+ if not isinstance(op, dict) or "responses" not in op:
511
+ errors.append(f"Missing responses for {method.upper()} {path}")
512
+ elif "summary" not in op and "operationId" not in op:
513
+ warnings.append(f"Missing summary for {method.upper()} {path}")
514
+ if paths and operations == 0:
515
+ # A validator that checked nothing must not hand out a score.
516
+ errors.append("No HTTP operations found under paths (validator checked 0 operations)")
517
+ score = 0 if operations == 0 else max(0, 100 - len(errors) * 10 - len(warnings) * 2)
518
+ return ValidationResult(
519
+ valid=not errors, errors=errors, warnings=warnings, score=score, operations=operations
520
+ )
521
+
522
+ async def run(self, **kwargs: Any) -> ValidationResult:
523
+ return await self.validate(kwargs["spec"])
@@ -0,0 +1,99 @@
1
+ """Aggregate metrics across several scaffold runs (from apiforge)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from collections import defaultdict
7
+ from dataclasses import dataclass, field
8
+ from typing import Any
9
+
10
+
11
+ @dataclass
12
+ class PipelineMetric:
13
+ """Single pipeline run metric."""
14
+
15
+ run_id: str
16
+ description_length: int
17
+ endpoints_generated: int
18
+ models_generated: int
19
+ validation_score: int
20
+ total_tokens: int
21
+ total_calls: int
22
+ duration_seconds: float
23
+ framework: str
24
+ auth_type: str
25
+ timestamp: float = field(default_factory=time.time)
26
+
27
+
28
+ class PipelineAnalytics:
29
+ """Track and analyze pipeline runs over time."""
30
+
31
+ def __init__(self) -> None:
32
+ self._metrics: list[PipelineMetric] = []
33
+
34
+ def record(self, metric: PipelineMetric) -> None:
35
+ """Record a pipeline run metric."""
36
+ self._metrics.append(metric)
37
+
38
+ @property
39
+ def total_runs(self) -> int:
40
+ return len(self._metrics)
41
+
42
+ def average_score(self) -> float:
43
+ """Average validation score across all runs."""
44
+ if not self._metrics:
45
+ return 0.0
46
+ return sum(m.validation_score for m in self._metrics) / len(self._metrics)
47
+
48
+ def average_tokens(self) -> float:
49
+ """Average tokens per run."""
50
+ if not self._metrics:
51
+ return 0.0
52
+ return sum(m.total_tokens for m in self._metrics) / len(self._metrics)
53
+
54
+ def average_duration(self) -> float:
55
+ """Average run duration in seconds."""
56
+ if not self._metrics:
57
+ return 0.0
58
+ return sum(m.duration_seconds for m in self._metrics) / len(self._metrics)
59
+
60
+ def total_tokens_consumed(self) -> int:
61
+ return sum(m.total_tokens for m in self._metrics)
62
+
63
+ def total_api_calls(self) -> int:
64
+ return sum(m.total_calls for m in self._metrics)
65
+
66
+ def framework_breakdown(self) -> dict[str, int]:
67
+ """Count of runs per framework."""
68
+ counts: dict[str, int] = defaultdict(int)
69
+ for m in self._metrics:
70
+ counts[m.framework] += 1
71
+ return dict(counts)
72
+
73
+ def auth_type_breakdown(self) -> dict[str, int]:
74
+ """Count of runs per auth type."""
75
+ counts: dict[str, int] = defaultdict(int)
76
+ for m in self._metrics:
77
+ counts[m.auth_type] += 1
78
+ return dict(counts)
79
+
80
+ def top_runs(self, n: int = 5) -> list[PipelineMetric]:
81
+ """Top N runs by validation score."""
82
+ return sorted(self._metrics, key=lambda m: m.validation_score, reverse=True)[:n]
83
+
84
+ def summary(self) -> dict[str, Any]:
85
+ """Full analytics summary."""
86
+ return {
87
+ "total_runs": self.total_runs,
88
+ "average_score": round(self.average_score(), 1),
89
+ "average_tokens": round(self.average_tokens(), 0),
90
+ "average_duration_seconds": round(self.average_duration(), 2),
91
+ "total_tokens_consumed": self.total_tokens_consumed(),
92
+ "total_api_calls": self.total_api_calls(),
93
+ "framework_breakdown": self.framework_breakdown(),
94
+ "auth_type_breakdown": self.auth_type_breakdown(),
95
+ "top_scores": [m.validation_score for m in self.top_runs()],
96
+ }
97
+
98
+ def reset(self) -> None:
99
+ self._metrics.clear()