codi-api-agent 0.3.1__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.
@@ -0,0 +1,431 @@
1
+ """Turn a prose API **reference document** into a draft OpenAPI spec (doc support).
2
+
3
+ This is the LLM analog of `spec_convert.convert_to_openapi` (which shells out to npx for
4
+ Postman/RAML/API-Blueprint): here an LLM extracts the endpoints a human wrote in prose. The output
5
+ is a normal OpenAPI dict that flows through `openapi_loader.build_catalog` and the rest of the
6
+ pipeline unchanged.
7
+
8
+ TRUST MODEL: extraction is a *draft*. The caller (the UI review gate) shows the endpoints to a
9
+ person who approves/prunes before anything becomes callable — so the agent's "never invent
10
+ endpoints" guarantee holds even though an LLM did the parsing. Each op carries `x-source` (the doc
11
+ snippet it came from) so the reviewer can check provenance.
12
+
13
+ Phase 2: large docs are split into heading-bounded chunks, extracted per chunk, and merged/deduped
14
+ by (method, path) — so a 200 KB reference doesn't have to fit one context window. A doc URL is
15
+ fetched (HTML stripped to text) before extraction.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import html as _html
20
+ import re
21
+ from urllib.parse import urlparse
22
+
23
+ import requests
24
+
25
+ from .llm import extract_json
26
+
27
+ _HTTP_METHODS = {"get", "post", "put", "delete", "patch", "head", "options"}
28
+ _UA = {"User-Agent": "api-agent/doc-extract"}
29
+
30
+
31
+ DOC_EXTRACT_SYSTEM = """You convert an API REFERENCE DOCUMENT (prose, markdown, curl examples,
32
+ parameter tables) into a strict JSON description of its HTTP endpoints.
33
+
34
+ Rules — follow EXACTLY:
35
+ - Extract ONLY endpoints that the document explicitly documents with a concrete HTTP METHOD and a
36
+ concrete PATH (e.g. "GET /api/v1/orders/{id}", "DELETE app/iotool/comments/comment", or a curl to
37
+ that path). Paths may be RELATIVE (e.g. "app/iotool/orders/order") — copy them exactly as written.
38
+ If a method+path is not written in the document, DO NOT output it. Never guess or invent. Omitting
39
+ a real endpoint is far better than inventing one.
40
+ - BE EXHAUSTIVE. A chunk may document MANY endpoints (a dozen or more) — extract EVERY one you find,
41
+ do not stop after the first few.
42
+ - SKIP GraphQL operations. A GraphQL query/mutation/subscription (a `query {…}`/`mutation {…}` sent
43
+ to a single GraphQL endpoint, or client hook names like `useGetTasksQuery`, `loginMutation`) is
44
+ NOT a REST endpoint and has no method+path — do not output it (a separate loader handles GraphQL).
45
+ Only output real REST HTTP endpoints (METHOD + URL path).
46
+ - For each endpoint capture: method, path (with {braces} for path params), a short summary, and its
47
+ parameters. For each parameter give: name; `in` (one of "query", "path", "header"); type
48
+ ("string"/"integer"/"number"/"boolean"/"array"); required (true/false); enum (array of allowed
49
+ values IF the doc lists them, else omit); a short description; and an example value if shown.
50
+ - If the endpoint takes a request body, put a short description or a small example in "body".
51
+ - Capture the document's base URL (the server, e.g. "https://api.example.com") in "base_url" if
52
+ stated, else "". Capture the auth scheme in "auth": {scheme: "bearer"|"apiKey"|"basic"|"none",
53
+ header: "<header name if any>", format: "<e.g. 'Bearer <token>'>"} — only if the doc states it.
54
+ - "source": copy the SHORT exact doc snippet (a line or two) the endpoint came from, so a human can
55
+ verify it. This is required for every endpoint.
56
+ - The text may be ONE CHUNK of a larger document — just extract whatever endpoints appear in it.
57
+
58
+ Return ONLY this JSON (no prose):
59
+ {
60
+ "title": "<api name if stated, else ''>",
61
+ "base_url": "<server url or ''>",
62
+ "auth": {"scheme": "bearer|apiKey|basic|none", "header": "", "format": ""},
63
+ "endpoints": [
64
+ {"method": "GET", "path": "/api/v1/orders/{id}", "summary": "...",
65
+ "params": [{"name": "id", "in": "path", "type": "integer", "required": true,
66
+ "description": "...", "example": 1}],
67
+ "body": "", "source": "GET /api/v1/orders/{id} — ..."}
68
+ ]
69
+ }"""
70
+
71
+
72
+ # --------------------------------------------------------------------------- #
73
+ # Chunking + extraction + merge (map-reduce)
74
+ # --------------------------------------------------------------------------- #
75
+ def _chunk_doc(text: str, max_chars: int) -> list[str]:
76
+ """Split a doc into <=max_chars chunks WITHOUT cutting through a section: break on markdown
77
+ headings, then greedily pack whole sections; a single oversized section is hard-split."""
78
+ if len(text) <= max_chars:
79
+ return [text]
80
+ sections: list[str] = []
81
+ cur: list[str] = []
82
+ for line in text.split("\n"):
83
+ if re.match(r"^#{1,6}\s", line) and cur: # a heading opens a new section
84
+ sections.append("\n".join(cur))
85
+ cur = [line]
86
+ else:
87
+ cur.append(line)
88
+ if cur:
89
+ sections.append("\n".join(cur))
90
+
91
+ chunks: list[str] = []
92
+ buf = ""
93
+ for sec in sections:
94
+ if len(sec) > max_chars: # a giant section: flush, then hard-split it
95
+ if buf:
96
+ chunks.append(buf)
97
+ buf = ""
98
+ chunks.extend(sec[i:i + max_chars] for i in range(0, len(sec), max_chars))
99
+ continue
100
+ if buf and len(buf) + len(sec) + 1 > max_chars:
101
+ chunks.append(buf)
102
+ buf = sec
103
+ else:
104
+ buf = (buf + "\n" + sec) if buf else sec
105
+ if buf:
106
+ chunks.append(buf)
107
+ return chunks or [text[:max_chars]]
108
+
109
+
110
+ def _extract_data(chunk: str, llm, model: str) -> dict:
111
+ """One extractor LLM call → the raw JSON dict. Best-effort: any failure yields no endpoints so a
112
+ single bad chunk never aborts the whole document."""
113
+ try:
114
+ resp = llm.complete(
115
+ [{"role": "system", "content": DOC_EXTRACT_SYSTEM},
116
+ {"role": "user", "content": f"API REFERENCE DOCUMENT (chunk):\n\n{chunk}"}],
117
+ model=model, json_mode=True,
118
+ )
119
+ return extract_json(resp.choices[0].message.content) or {}
120
+ except Exception:
121
+ return {}
122
+
123
+
124
+ def _merge_data(datas: list[dict]) -> dict:
125
+ """Merge per-chunk extractions: union endpoints deduped by (method, path) — keeping the RICHER
126
+ duplicate (more params) — and take the first non-empty title / base_url / auth."""
127
+ merged: dict = {"title": "", "base_url": "", "auth": {}, "endpoints": []}
128
+ index: dict[tuple[str, str], int] = {}
129
+ for d in datas:
130
+ if not isinstance(d, dict):
131
+ continue
132
+ if not merged["title"] and d.get("title"):
133
+ merged["title"] = d["title"]
134
+ if not merged["base_url"] and (d.get("base_url") or "").strip():
135
+ merged["base_url"] = d["base_url"]
136
+ auth = d.get("auth") or {}
137
+ if not merged["auth"] and auth.get("scheme") and auth.get("scheme") != "none":
138
+ merged["auth"] = auth
139
+ for ep in d.get("endpoints") or []:
140
+ if not isinstance(ep, dict):
141
+ continue
142
+ key = (str(ep.get("method", "")).lower(), str(ep.get("path", "")))
143
+ if key in index:
144
+ prev = merged["endpoints"][index[key]]
145
+ if len(ep.get("params") or []) > len(prev.get("params") or []):
146
+ merged["endpoints"][index[key]] = ep # keep the more detailed one
147
+ else:
148
+ index[key] = len(merged["endpoints"])
149
+ merged["endpoints"].append(ep)
150
+ return merged
151
+
152
+
153
+ def looks_graphql_heavy(text: str) -> tuple[bool, int]:
154
+ """Heuristic count of GraphQL-operation markers (useXxxQuery/Mutation hooks, `query {`/`mutation {`
155
+ blocks). Returns (is_heavy, count). Used to warn that GraphQL ops need the GraphQL loader, not
156
+ prose→OpenAPI extraction (they have no REST method+path so they can't be captured here)."""
157
+ t = text or ""
158
+ ops = set(re.findall(r"\buse[A-Z][A-Za-z0-9]*(?:Query|Mutation)\b", t))
159
+ blocks = len(re.findall(r"(?m)^\s*(?:query|mutation|subscription)\b[\s\w()]*\{", t))
160
+ n = len(ops) + blocks
161
+ return (n >= 10, n)
162
+
163
+
164
+ def _llm_extract_data(text: str, llm, model: str, max_chars: int = 16000, progress=None) -> dict:
165
+ """Chunk → per-chunk LLM extract → merge, returning the raw endpoint `data` (pre-assembly)."""
166
+ chunks = _chunk_doc(text or "", max_chars)
167
+ datas: list[dict] = []
168
+ for i, chunk in enumerate(chunks):
169
+ if progress:
170
+ try:
171
+ progress(i + 1, len(chunks))
172
+ except Exception:
173
+ pass
174
+ datas.append(_extract_data(chunk, llm, model))
175
+ return _merge_data(datas)
176
+
177
+
178
+ def extract_openapi_from_doc(text: str, llm, model: str, max_chars: int = 16000,
179
+ progress=None) -> dict:
180
+ """LLM-ONLY extraction path (kept for callers/tests that specifically want it). Prefer
181
+ `build_doc_openapi` which is deterministic-first."""
182
+ return _assemble_openapi(_llm_extract_data(text, llm, model, max_chars, progress))
183
+
184
+
185
+ # --------------------------------------------------------------------------- #
186
+ # Structural (regex) extraction — the DEFAULT. Free, exhaustive, no hallucinated endpoints.
187
+ # --------------------------------------------------------------------------- #
188
+ # `METHOD <path>` anywhere: curl blocks, method-path headings, or a table cell. Quotes/backticks/
189
+ # pipes are treated as separators (so `GET "https://…"` yields the URL without the quote).
190
+ _METHOD_PATH_RE = re.compile(r"""\b(GET|POST|PUT|DELETE|PATCH|HEAD)\b[\s|`"']+([^\s`()<>|"']+)""", re.I)
191
+ _HEADING_RE = re.compile(r"^#{1,6}\s+(.*)")
192
+
193
+
194
+ def _normalize_path(raw: str) -> str:
195
+ """Clean a captured path token into an absolute OpenAPI path, or '' if it isn't one."""
196
+ p = raw.strip().strip("`\"'").rstrip(".,;:)\"'")
197
+ if "://" in p: # absolute URL (any host/placeholder) → keep the path
198
+ p = urlparse(p).path
199
+ else:
200
+ p = re.sub(r"^\{\{[^}]+\}\}", "", p) # {{baseUrl}}/{{host}} placeholder prefix
201
+ p = re.sub(r"^\$\w+", "", p) # $HOST shell-var prefix
202
+ p = p.replace("${", "{") # JS template literal `${id}` → `{id}`
203
+ p = re.sub(r":(\w+)", r"{\1}", p) # :id → {id}
204
+ if "/" not in p or p.count("{") != p.count("}"):
205
+ return "" # not a path, or a token cut mid-param (`{dynamic`)
206
+ if not p.startswith("/"):
207
+ p = "/" + p
208
+ p = re.sub(r"/{2,}", "/", p)
209
+ return p.rstrip("/") or "/"
210
+
211
+
212
+ def _nearest_heading(lines: list[str], idx: int) -> str:
213
+ for j in range(idx, max(-1, idx - 60), -1):
214
+ m = _HEADING_RE.match(lines[j])
215
+ if m:
216
+ return re.split(r"\s[—–-]\s", m.group(1))[0].strip()[:120] # drop a "— opName" suffix
217
+ return ""
218
+
219
+
220
+ def _find_base_url(text: str) -> str:
221
+ m = re.search(r"(?i)base\s*url[^\n]*?(https?://[^\s`\"'<>]+)", text)
222
+ if not m:
223
+ return ""
224
+ url = m.group(1).rstrip("/`.,")
225
+ # A base URL with a placeholder (`https://{host}`, `{{baseUrl}}`) isn't a real server — return ""
226
+ # so the loader doesn't store it (the user supplies the real host in the UI). Otherwise the
227
+ # executor would strip the placeholder and call `https:///…`.
228
+ return "" if ("{" in url or "}" in url) else url
229
+
230
+
231
+ def _find_auth(text: str) -> dict:
232
+ if re.search(r"(?i)authorization:\s*bearer", text):
233
+ return {"scheme": "bearer", "header": "Authorization", "format": "Bearer <token>"}
234
+ if re.search(r"(?i)\bcookie:\s*\S", text):
235
+ return {"scheme": "apiKey", "header": "Cookie", "format": "<cookie>"}
236
+ if re.search(r"(?i)x-api-key", text):
237
+ return {"scheme": "apiKey", "header": "X-API-Key", "format": "<key>"}
238
+ return {}
239
+
240
+
241
+ def _find_title(text: str) -> str:
242
+ for line in (text or "").split("\n"):
243
+ m = _HEADING_RE.match(line)
244
+ if m:
245
+ return m.group(1).strip()[:80]
246
+ return ""
247
+
248
+
249
+ def structural_extract(text: str) -> dict:
250
+ """Deterministic endpoint discovery: scan for literal `METHOD path` tokens, deriving path params
251
+ from `{braces}` and query params from `?a=b` example URLs. Free, no LLM — and it CANNOT invent an
252
+ endpoint: every one is a literal string copied from the doc. Returns the same `data` shape the
253
+ LLM path does, so it flows through `_assemble_openapi`."""
254
+ lines = (text or "").split("\n")
255
+ found: dict[tuple[str, str], dict] = {}
256
+ for i, line in enumerate(lines):
257
+ for m in _METHOD_PATH_RE.finditer(line):
258
+ method = m.group(1).upper()
259
+ raw_path, _, query = m.group(2).partition("?")
260
+ path = _normalize_path(raw_path)
261
+ if not path:
262
+ continue
263
+ key = (method, path)
264
+ ep = found.get(key)
265
+ if ep is None:
266
+ ep = found[key] = {"method": method, "path": path,
267
+ "summary": _nearest_heading(lines, i),
268
+ "params": {}, "source": line.strip()[:200]}
269
+ for pp in re.findall(r"\{(\w+)\}", path):
270
+ ep["params"].setdefault(pp, {"name": pp, "in": "path", "required": True})
271
+ for qp in re.findall(r"[?&]([\w\[\]]+)=", "?" + query):
272
+ ep["params"].setdefault(qp, {"name": qp, "in": "query", "required": False})
273
+ endpoints = _dedupe_concrete_ids(
274
+ [{**ep, "params": list(ep["params"].values())} for ep in found.values()])
275
+ return {"title": _find_title(text), "base_url": _find_base_url(text),
276
+ "auth": _find_auth(text), "endpoints": endpoints}
277
+
278
+
279
+ def _dedupe_concrete_ids(endpoints: list[dict]) -> list[dict]:
280
+ """Docs often show an endpoint BOTH templated (`/x/{id}`) and as a concrete example (`/x/4821`).
281
+ Drop the concrete twin when a templated sibling exists — keeping the useful `{id}` form. A slot
282
+ only counts as an id if the concrete segment contains a digit, so real sub-resources like
283
+ `/x/templates` are NOT mistaken for an id value."""
284
+ templated = [e for e in endpoints if "{" in e["path"]]
285
+
286
+ def is_instance(concrete: str, template: str) -> bool:
287
+ cs, ts = concrete.split("/"), template.split("/")
288
+ if len(cs) != len(ts):
289
+ return False
290
+ for c, t in zip(cs, ts):
291
+ if t.startswith("{") and t.endswith("}"):
292
+ if not re.search(r"\d", c): # the {param} slot must look like an id (has a digit)
293
+ return False
294
+ elif c != t:
295
+ return False
296
+ return True
297
+
298
+ out = []
299
+ for e in endpoints:
300
+ if "{" not in e["path"] and any(
301
+ e["method"] == t["method"] and is_instance(e["path"], t["path"]) for t in templated):
302
+ continue # concrete instance of a templated sibling → drop
303
+ out.append(e)
304
+ return out
305
+
306
+
307
+ def build_doc_openapi(text: str, llm=None, model: str | None = None, use_llm: bool = False,
308
+ max_chars: int = 16000, progress=None) -> dict:
309
+ """PRIMARY entry for doc support. Deterministic **structural** extraction is the default (free,
310
+ exhaustive, cannot hallucinate an endpoint). With `use_llm=True` the LLM ALSO runs and its output
311
+ is merged in — enriching params on the structurally-found endpoints, and filling in a prose-only
312
+ doc where regex found nothing. With `use_llm=False` the result is 100% from the doc's literal
313
+ method+path lines."""
314
+ datas = [structural_extract(text)]
315
+ if use_llm and llm is not None:
316
+ datas.append(_llm_extract_data(text, llm, model, max_chars, progress))
317
+ # structural first → its endpoints are authoritative; merge keeps the richer params per (method,path)
318
+ return _assemble_openapi(_merge_data(datas))
319
+
320
+
321
+ # --------------------------------------------------------------------------- #
322
+ # Assemble the extractor JSON into an OpenAPI dict build_catalog understands
323
+ # --------------------------------------------------------------------------- #
324
+ def _assemble_openapi(data: dict) -> dict:
325
+ """Assemble the extractor's JSON into an OpenAPI 3 dict `build_catalog` understands. Skips any
326
+ endpoint without a concrete method + `/path`. Enum values fold into the param description (the
327
+ tool schema only carries type+description). `x-source`/`x-auth` are informational extensions."""
328
+ spec: dict = {
329
+ "openapi": "3.0.0",
330
+ "info": {"title": (data.get("title") or "Extracted API").strip(), "version": "1.0"},
331
+ "paths": {},
332
+ }
333
+ base = (data.get("base_url") or "").strip()
334
+ if base:
335
+ spec["servers"] = [{"url": base}]
336
+ auth = data.get("auth") or {}
337
+ if auth.get("scheme") and auth.get("scheme") != "none":
338
+ spec["x-auth"] = {"scheme": auth.get("scheme"), "header": auth.get("header", ""),
339
+ "format": auth.get("format", "")}
340
+
341
+ for ep in data.get("endpoints") or []:
342
+ if not isinstance(ep, dict):
343
+ continue
344
+ method = str(ep.get("method", "")).strip().lower()
345
+ path = str(ep.get("path", "")).strip()
346
+ if method not in _HTTP_METHODS or not path:
347
+ continue
348
+ # Docs often write RELATIVE paths ("app/iotool/orders/order") — make them absolute so they
349
+ # aren't dropped. A slashless single token (e.g. a GraphQL op name "loginMutation") is NOT a
350
+ # REST path → skip it. Strip any query string the model left on the path.
351
+ path = path.split("?", 1)[0].strip()
352
+ if not path.startswith("/"):
353
+ if "/" in path:
354
+ path = "/" + path
355
+ else:
356
+ continue
357
+ params = []
358
+ for p in ep.get("params") or []:
359
+ if not isinstance(p, dict):
360
+ continue
361
+ name, loc = p.get("name"), p.get("in")
362
+ if not name or loc not in ("query", "path", "header"):
363
+ continue
364
+ desc = (p.get("description") or "").strip()
365
+ enum = p.get("enum")
366
+ if isinstance(enum, list) and enum:
367
+ desc = (desc + f" (one of: {', '.join(str(e) for e in enum)})").strip()
368
+ ptype = p.get("type") if p.get("type") in ("string", "integer", "number", "boolean", "array") else "string"
369
+ param = {"name": name, "in": loc,
370
+ "required": bool(p.get("required")) or loc == "path",
371
+ "schema": {"type": ptype}, "description": desc}
372
+ if p.get("example") not in (None, ""):
373
+ param["example"] = p["example"]
374
+ params.append(param)
375
+ op = {
376
+ "summary": (ep.get("summary") or "").strip(),
377
+ "parameters": params,
378
+ "x-source": str(ep.get("source") or "")[:500],
379
+ }
380
+ if ep.get("body"):
381
+ op["requestBody"] = {"description": str(ep["body"])[:300],
382
+ "content": {"application/json": {"schema": {"type": "object"}}}}
383
+ spec["paths"].setdefault(path, {})[method] = op
384
+ return spec
385
+
386
+
387
+ def endpoint_rows(spec: dict) -> list[tuple[str, str, dict]]:
388
+ """(method, path, op) rows for the review UI, in a stable order."""
389
+ rows = []
390
+ for path, methods in (spec.get("paths") or {}).items():
391
+ for method, op in methods.items():
392
+ if isinstance(op, dict) and method.lower() in _HTTP_METHODS:
393
+ rows.append((method.lower(), path, op))
394
+ return rows
395
+
396
+
397
+ def prune_openapi(spec: dict, keep: set[tuple[str, str]]) -> dict:
398
+ """Return a copy of `spec` keeping only the (method, path) operations the reviewer approved."""
399
+ out = {k: v for k, v in spec.items() if k != "paths"}
400
+ out["paths"] = {}
401
+ for path, methods in (spec.get("paths") or {}).items():
402
+ kept = {m: op for m, op in methods.items() if (m.lower(), path) in keep}
403
+ if kept:
404
+ out["paths"][path] = kept
405
+ return out
406
+
407
+
408
+ # --------------------------------------------------------------------------- #
409
+ # Doc fetching (URL → text)
410
+ # --------------------------------------------------------------------------- #
411
+ def _html_to_text(html_text: str) -> str:
412
+ """Cheap HTML → text (no extra dependency): drop script/style, turn block tags into newlines,
413
+ strip remaining tags, unescape entities, collapse whitespace. Good enough for LLM extraction."""
414
+ t = re.sub(r"(?is)<(script|style|noscript)\b.*?</\1>", " ", html_text)
415
+ t = re.sub(r"(?i)<br\s*/?>", "\n", t)
416
+ t = re.sub(r"(?i)</(p|div|li|tr|h[1-6]|section|article|pre)>", "\n", t)
417
+ t = re.sub(r"<[^>]+>", " ", t)
418
+ t = _html.unescape(t)
419
+ t = re.sub(r"[ \t]+", " ", t)
420
+ return re.sub(r"\n{3,}", "\n\n", t).strip()
421
+
422
+
423
+ def fetch_doc_text(source: str, timeout: int = 20) -> str:
424
+ """Load a reference doc's text from a URL (HTML stripped to text) or a local file path."""
425
+ if source.startswith(("http://", "https://")):
426
+ resp = requests.get(source, timeout=timeout, headers=_UA)
427
+ resp.raise_for_status()
428
+ ctype = resp.headers.get("content-type", "").lower()
429
+ return _html_to_text(resp.text) if "html" in ctype else resp.text
430
+ with open(source, encoding="utf-8") as f:
431
+ return f.read()