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.
- api_agent/__init__.py +60 -0
- api_agent/__main__.py +5 -0
- api_agent/_version.py +2 -0
- api_agent/agent.py +6901 -0
- api_agent/catalog.py +147 -0
- api_agent/chart.py +144 -0
- api_agent/cli.py +31 -0
- api_agent/config.py +296 -0
- api_agent/doc_extract.py +431 -0
- api_agent/graphql_loader.py +309 -0
- api_agent/llm.py +470 -0
- api_agent/log.py +138 -0
- api_agent/metrics.py +1030 -0
- api_agent/openapi_loader.py +560 -0
- api_agent/prompts/__init__.py +59 -0
- api_agent/prompts/advisory.py +52 -0
- api_agent/prompts/executor.py +175 -0
- api_agent/prompts/judges.py +207 -0
- api_agent/prompts/support.py +59 -0
- api_agent/prompts/synthesis.py +350 -0
- api_agent/router.py +294 -0
- api_agent/schemas.py +250 -0
- api_agent/spec_convert.py +86 -0
- api_agent/sql_loader.py +1254 -0
- api_agent/supervisor.py +178 -0
- api_agent/ui.py +918 -0
- codi_api_agent-0.3.1.dist-info/METADATA +260 -0
- codi_api_agent-0.3.1.dist-info/RECORD +31 -0
- codi_api_agent-0.3.1.dist-info/WHEEL +5 -0
- codi_api_agent-0.3.1.dist-info/entry_points.txt +2 -0
- codi_api_agent-0.3.1.dist-info/top_level.txt +1 -0
api_agent/doc_extract.py
ADDED
|
@@ -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()
|