baucli 1.0.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.
baucli/api.py ADDED
@@ -0,0 +1,914 @@
1
+ """BAUCLI — REST API client for BAU Server.
2
+
3
+ All communication with the BAU Server goes through this module.
4
+ Base URL: https://begbrokerdev.begcloud.com/ords/bauctx/api/v1
5
+ """
6
+
7
+ import json
8
+ from typing import Optional
9
+ import requests
10
+
11
+ from baucli import __version__, __product__
12
+
13
+
14
+ class BauApiError(Exception):
15
+ """BAU API error with status and message."""
16
+ def __init__(self, message: str, status_code: int = 0, response: dict = None):
17
+ super().__init__(message)
18
+ self.status_code = status_code
19
+ self.response = response or {}
20
+
21
+
22
+ class BauSessionError(BauApiError):
23
+ """Session token missing/invalid/expired/revoked. Caller should `baucli login`."""
24
+
25
+
26
+ SESSION_ERROR_TAGS = {
27
+ "session_missing", "session_invalid", "session_revoked",
28
+ "session_expired", "session_tenant_mismatch",
29
+ }
30
+
31
+
32
+ class BauApi:
33
+ """REST API client for BAU Server."""
34
+
35
+ def __init__(self, api_url: str, api_key: str = None,
36
+ session_token: str = None, timeout: int = 30):
37
+ self.api_url = api_url.rstrip("/")
38
+ self.api_key = api_key
39
+ self.session_token = session_token
40
+ self.timeout = timeout
41
+ self.session = requests.Session()
42
+ self.session.headers.update({
43
+ "User-Agent": f"{__product__}/{__version__}",
44
+ "Accept": "application/json",
45
+ })
46
+ if api_key:
47
+ self.session.headers["Authorization"] = f"Bearer {api_key}"
48
+ if session_token:
49
+ self.session.headers["X-Bau-Session"] = session_token
50
+
51
+ def _request(self, method: str, path: str, json_data: dict = None,
52
+ params: dict = None, auth_required: bool = True,
53
+ timeout: int = None) -> dict:
54
+ """Make an HTTP request to the BAU API.
55
+
56
+ timeout: per-call override in seconds. Falls back to self.timeout.
57
+ Long server-side endpoints pass a higher floor here (e.g. rag_index).
58
+ """
59
+ url = f"{self.api_url}/{path.lstrip('/')}"
60
+ eff_timeout = timeout or self.timeout
61
+
62
+ headers = {}
63
+ if not auth_required and self.api_key:
64
+ # Remove Bearer for public endpoints
65
+ headers["Authorization"] = ""
66
+
67
+ try:
68
+ if not auth_required:
69
+ # Use a clean session without Bearer for public endpoints
70
+ resp = requests.request(
71
+ method, url, json=json_data, params=params,
72
+ timeout=eff_timeout,
73
+ headers={"User-Agent": f"{__product__}/{__version__}",
74
+ "Accept": "application/json",
75
+ "Content-Type": "application/json"},
76
+ )
77
+ else:
78
+ resp = self.session.request(
79
+ method, url, json=json_data, params=params,
80
+ timeout=eff_timeout,
81
+ )
82
+ except requests.ConnectionError:
83
+ raise BauApiError(f"Cannot connect to BAU Server at {self.api_url}")
84
+ except requests.Timeout:
85
+ raise BauApiError(f"Request timeout ({eff_timeout}s) to {url}")
86
+
87
+ data = self._parse_body(resp)
88
+
89
+ if resp.status_code == 401:
90
+ msg = (data or {}).get("message", "Unauthorized") if isinstance(data, dict) else "Unauthorized"
91
+ if msg in SESSION_ERROR_TAGS:
92
+ raise BauSessionError(msg, 401, data or {})
93
+ raise BauApiError(msg, 401, data or {})
94
+
95
+ if data is None:
96
+ # Plain-text response (e.g. deploy DDL script).
97
+ return {"status": "ok", "text": resp.text}
98
+
99
+ if isinstance(data, dict) and data.get("status") == "error":
100
+ raise BauApiError(
101
+ data.get("message", "Unknown error"),
102
+ resp.status_code, data
103
+ )
104
+
105
+ return data
106
+
107
+ @staticmethod
108
+ def _parse_body(resp) -> Optional[dict]:
109
+ """Parse response body as JSON. Handles the ORDS plsql/block quirk
110
+ where OWA_UTIL.MIME_HEADER emits `Content-type: ...\\n\\n{json}` into
111
+ the body instead of an HTTP header. Returns None for plain-text bodies.
112
+ """
113
+ try:
114
+ return resp.json()
115
+ except (json.JSONDecodeError, ValueError):
116
+ pass
117
+ text = resp.text
118
+ if text.lstrip().lower().startswith("content-type:"):
119
+ sep = text.find("\n\n")
120
+ if sep > 0:
121
+ try:
122
+ return json.loads(text[sep + 2:])
123
+ except (json.JSONDecodeError, ValueError):
124
+ pass
125
+ return None
126
+
127
+ # ── Public endpoints (no auth) ──────────────────────────
128
+
129
+ def health(self) -> dict:
130
+ """GET /health — Server health check."""
131
+ return self._request("GET", "health", auth_required=False)
132
+
133
+ def license_activate(self, license_key: str) -> dict:
134
+ """POST /license/activate — Activate license, receive config."""
135
+ return self._request("POST", "license/activate",
136
+ json_data={"license_key": license_key},
137
+ auth_required=False)
138
+
139
+ def license_validate(self, license_key: str) -> dict:
140
+ """POST /license/validate — Validate license (heartbeat)."""
141
+ return self._request("POST", "license/validate",
142
+ json_data={"license_key": license_key},
143
+ auth_required=False)
144
+
145
+ # ── Protected endpoints (Bearer token) ──────────────────
146
+
147
+ def environments(self) -> dict:
148
+ """GET /environments — read-only tenant promotion ladder (no secrets).
149
+
150
+ Topology + connectivity (engine/host/service) + the SQLcl named
151
+ connection HINT per env. Lets a thin client discover the ladder without
152
+ a direct BAUCTX login; credentials stay local (wallet/keyring)."""
153
+ return self._request("GET", "environments")
154
+
155
+ # ── Agent verification (Phase 1A) — read-only fact-check ─
156
+ # Structural ground-truth from the catalog. Every response carries
157
+ # ok/source/confidence/stale/in_project + exists:false first-class.
158
+
159
+ def verify_object(self, schema: str, object: str) -> dict:
160
+ """GET /verify/object — does the table/view/PL-SQL/sequence exist? Type,
161
+ status_doc, purpose, source/confidence, in_project."""
162
+ return self._request("GET", "verify/object",
163
+ params={"schema": schema, "object": object})
164
+
165
+ def verify_column(self, schema: str, table: str, column: str) -> dict:
166
+ """GET /verify/column — a single column's ground-truth: data_type,
167
+ nullable, PK/FK/UK, fk_ref, semantic_domain, sensitive, label, default,
168
+ sample_values, description + source/confidence."""
169
+ return self._request("GET", "verify/column",
170
+ params={"schema": schema, "table": table, "column": column})
171
+
172
+ def agent_columns(self, schema: str, table: str) -> dict:
173
+ """GET /columns — compact list of every column of a table (name/type/
174
+ nullable/pk/fk/uk/domain/label/default)."""
175
+ return self._request("GET", "columns",
176
+ params={"schema": schema, "table": table})
177
+
178
+ def agent_fk(self, schema: str, table: str) -> dict:
179
+ """GET /fk — foreign keys: references (this→other) + referenced_by
180
+ (other→this). Kills invented joins."""
181
+ return self._request("GET", "fk",
182
+ params={"schema": schema, "table": table})
183
+
184
+ def agent_domain(self, schema: str, table: str, column: str) -> dict:
185
+ """GET /domain — closed enum of a column (CHECK values + PT/EN labels).
186
+ Kills invented enum values."""
187
+ return self._request("GET", "domain",
188
+ params={"schema": schema, "table": table, "column": column})
189
+
190
+ def agent_constraints(self, schema: str, table: str) -> dict:
191
+ """GET /constraints — PK/UK/FK/CHECK of a table (+ friendly DOCERRMSG
192
+ message per constraint)."""
193
+ return self._request("GET", "constraints",
194
+ params={"schema": schema, "table": table})
195
+
196
+ def conventions(self, schema: str) -> dict:
197
+ """GET /conventions — house style DERIVED from the catalog (PK/FK naming,
198
+ audit columns, active flag + values, top semantic domains) + the schema's
199
+ AI_CONTEXT prose (form/report standard). For generating new code that
200
+ matches the rest of the schema."""
201
+ return self._request("GET", "conventions", params={"schema": schema})
202
+
203
+ def task_brief(self, schema: str, task: str = None, tables: str = None,
204
+ scope: str = None, budget: int = 8000) -> dict:
205
+ """GET /task_brief — one curated context pack for a task: target tables
206
+ (resolved from `tables` CSV > `scope` code > keyword match on `task`),
207
+ each with compact columns/FKs/closed-domains, plus lean house conventions
208
+ and the business flows that touch them. Budget-aware (`budget` chars)."""
209
+ params = {"schema": schema, "budget": budget}
210
+ if task: params["task"] = task
211
+ if tables: params["tables"] = tables
212
+ if scope: params["scope"] = scope
213
+ return self._request("GET", "task_brief", params=params)
214
+
215
+ def validate_change(self, schema: str, intent: dict) -> dict:
216
+ """POST /validate-change — validate a structured change intent (write-loop)
217
+ against structure, conventions, impact and data profile. Returns
218
+ verdict PASS/WARN/FAIL + checks + a house-style suggested_ddl. Read-only.
219
+ intent: {action: ADD_COLUMN|MODIFY_NOT_NULL|ADD_UNIQUE|ADD_INDEX|ADD_FK,
220
+ table, column, data_type?, nullable?, references?, ref_column?}."""
221
+ return self._request("POST", "validate-change", json_data=intent,
222
+ params={"schema": schema})
223
+
224
+ def propose(self, schema: str, intent: dict) -> dict:
225
+ """POST /propose — validate + STORE a change proposal (status PROPOSED).
226
+ Returns {proposal_id, verdict, validation}."""
227
+ return self._request("POST", "propose", json_data=intent,
228
+ params={"schema": schema})
229
+
230
+ def proposals(self, schema: str = None, status: str = None) -> dict:
231
+ """GET /proposals — list change proposals (optionally by schema/status)."""
232
+ p = {}
233
+ if schema: p["schema"] = schema
234
+ if status: p["status"] = status
235
+ return self._request("GET", "proposals", params=p)
236
+
237
+ def proposal(self, proposal_id: int) -> dict:
238
+ """GET /proposals/{id} — full proposal detail (DDL + validation)."""
239
+ return self._request("GET", f"proposals/{proposal_id}")
240
+
241
+ def proposal_regen_purpose(self, proposal_id: int) -> dict:
242
+ """POST /proposals/{id}/regen-purpose — (re)generate the AI motive
243
+ (purpose) for a proposal. Returns {ok, id, purpose}."""
244
+ return self._request("POST", f"proposals/{proposal_id}/regen-purpose")
245
+
246
+ def proposal_status(self, proposal_id: int, status: str, notes: str = None) -> dict:
247
+ """POST /proposals/{id}/status — approve/reject/apply a proposal."""
248
+ body = {"status": status}
249
+ if notes: body["notes"] = notes
250
+ return self._request("POST", f"proposals/{proposal_id}/status", json_data=body)
251
+
252
+ def profile(self, schema: str, table: str) -> dict:
253
+ """GET /profile — a table's DATA PROFILE (per column: num_distinct,
254
+ null_pct, histogram, top_values) from optimizer stats. Grounds the
255
+ agent in the real data distribution."""
256
+ return self._request("GET", "profile", params={"schema": schema, "table": table})
257
+
258
+ def profile_ingest(self, body: dict) -> dict:
259
+ """POST /profile/ingest — upload a data profile collected locally from
260
+ the client DB optimizer stats. Body: {schema, tables:[{name, num_rows,
261
+ columns:[{name, num_distinct, num_nulls, null_pct, sample_size,
262
+ histogram, top_values}]}]}."""
263
+ return self._request("POST", "profile/ingest", json_data=body)
264
+
265
+ def golden(self, schema: str) -> dict:
266
+ """GET /golden — the schema's GOLDEN entities (canonical/source-of-truth
267
+ core tables, PL/SQL objects and flows), each with rationale + source
268
+ (AI/HEURISTIC/MANUAL). The authoritative map to build on."""
269
+ return self._request("GET", "golden", params={"schema": schema})
270
+
271
+ def quality(self, schema: str = None, check: str = None,
272
+ only_doc: bool = False) -> dict:
273
+ """GET /quality — open documentation-quality issues for the tenant
274
+ (optionally a schema). Each issue has check/severity/object/message.
275
+ only_doc=True narrows to the "uncertain documentation" checks
276
+ (DOCTAB.DOC_UNCERTAIN / DOCPL.DOC_UNCERTAIN)."""
277
+ params = {}
278
+ if schema:
279
+ params["schema"] = schema
280
+ if check:
281
+ params["check"] = check
282
+ if only_doc:
283
+ params["only_doc"] = "Y"
284
+ return self._request("GET", "quality", params=params)
285
+
286
+ def quality_scan(self, schema: str) -> dict:
287
+ """POST /quality/scan — (re)run the quality detectors for a schema and
288
+ return open-issue counts by check. Cheap; safe to run any time."""
289
+ return self._request("POST", "quality/scan", params={"schema": schema})
290
+
291
+ def golden_classify(self, schema: str, force: bool = False) -> dict:
292
+ """POST /golden/classify — enqueue the async GOLDEN classification job
293
+ (heuristic + AI). Server-gated: only runs when the schema is fully
294
+ documented AND golden is out of date (unless force). Returns
295
+ {queued, job_id} or {queued: false, message}."""
296
+ params = {"schema": schema}
297
+ if force:
298
+ params["force"] = "Y"
299
+ return self._request("POST", "golden/classify", params=params)
300
+
301
+ def golden_status(self, schema: str, timeout: int = None) -> dict:
302
+ """GET /golden/status — lightweight signal for the dev nudge:
303
+ {golden_needed, fully_documented, golden_last_at}."""
304
+ return self._request("GET", "golden/status", params={"schema": schema},
305
+ timeout=timeout)
306
+
307
+ # ---- Semantic layer (Phase 6): verified query library ----
308
+ def semantic_queries(self, schema: str, kind: str = None,
309
+ entity: str = None, verified_only: bool = False) -> dict:
310
+ """GET /semantic/queries — the schema's SEMANTIC query library
311
+ (metrics, golden queries, rule-checks). Each item: code, kind, name,
312
+ question, entity, verified, verified_at, rowcount. Use verified queries
313
+ instead of inventing SQL."""
314
+ params = {"schema": schema}
315
+ if kind:
316
+ params["kind"] = kind
317
+ if entity:
318
+ params["entity"] = entity
319
+ if verified_only:
320
+ params["verified"] = "Y"
321
+ return self._request("GET", "semantic/queries", params=params)
322
+
323
+ def semantic_query(self, schema: str, code: str) -> dict:
324
+ """GET /semantic/query — one library entry with the FULL SQL text and
325
+ last verified sample."""
326
+ return self._request("GET", "semantic/query",
327
+ params={"schema": schema, "code": code})
328
+
329
+ def semantic_search(self, schema: str, q: str, k: int = 3) -> dict:
330
+ """GET /semantic/search — resolve a query by CODE (exact) or by MEANING
331
+ (natural-language question, via RAG over kind='QUERY'). Returns
332
+ {match:'code', query} or {match:'semantic', results:[{code, question,
333
+ entity, verified, distance, sql}]} or {match:'none', note}.
334
+
335
+ NB: o param vai como `question` (NÃO `q`) — `q` é reservado pelo ORDS
336
+ (filtro ?q={JSON}) e derruba a request com 400 (ver server script 216)."""
337
+ return self._request("GET", "semantic/search",
338
+ params={"schema": schema, "question": q, "k": k})
339
+
340
+ def semantic_pending(self, schema: str) -> dict:
341
+ """GET /semantic/pending — library entries not yet verified by
342
+ execution. `semantic verify` fetches these, runs them read-only against
343
+ the local DB, and posts the result back."""
344
+ return self._request("GET", "semantic/pending", params={"schema": schema})
345
+
346
+ def semantic_register(self, body: dict) -> dict:
347
+ """POST /semantic/register — register/upsert a query by (schema, code).
348
+ Body: {schema, code, kind, name, question, description, grain, entity,
349
+ sql, result_kind}. Changing the SQL resets its verified flag."""
350
+ return self._request("POST", "semantic/register", json_data=body)
351
+
352
+ def semantic_suggest(self, schema: str, max_n: int = 15) -> dict:
353
+ """POST /semantic/suggest — ASSÍNCRONO: enfileira um job SEMANTIC_SEED em
354
+ que a IA propõe métricas/rule-checks das tabelas/CHECKs/colunas de
355
+ domínio DOCUMENTADAS e grava as NOVAS como não-verificadas (source=AI)
356
+ para curadoria. Retorna {ok, queued, job_id, message} na hora — a IA roda
357
+ no drain do servidor (a chamada síncrona estourava o timeout do gateway).
358
+ As sugestões aparecem quando o job concluir (App 601 › Consultas
359
+ Semânticas / baucli semantic list)."""
360
+ return self._request("POST", "semantic/suggest",
361
+ params={"schema": schema, "max": max_n})
362
+
363
+ def semantic_verify(self, schema: str, code: str, result: dict) -> dict:
364
+ """POST /semantic/verify — report the outcome of running a query
365
+ read-only. result: {source, rowcount, ms, sample, error}. error=None
366
+ marks it verified; a non-null error keeps it unverified."""
367
+ return self._request("POST", "semantic/verify",
368
+ params={"schema": schema, "code": code},
369
+ json_data=result)
370
+
371
+ def scope(self, code: str) -> dict:
372
+ """GET /scopes/{code} — a project scope set (BAU_SCOPE_SETS) as JSON:
373
+ the named object subset a project needs. Used by `sync --scope` to send
374
+ only the listed objects. Returns {"error": ...} when the code is unknown."""
375
+ return self._request("GET", f"scopes/{code}")
376
+
377
+ def scopes_list(self) -> dict:
378
+ """GET /scopes — all scope sets of the tenant."""
379
+ return self._request("GET", "scopes")
380
+
381
+ def scope_create(self, code: str, name: str, schema: str,
382
+ description: str = None) -> dict:
383
+ """POST /scopes — create a scope set (schema must be registered)."""
384
+ body = {"code": code, "name": name, "schema": schema}
385
+ if description:
386
+ body["description"] = description
387
+ return self._request("POST", "scopes", json_data=body)
388
+
389
+ def scope_add_item(self, code: str, otype: str, owner: str, name: str,
390
+ subprogram: str = None, include_kind: str = None,
391
+ note: str = None) -> dict:
392
+ """POST /scopes/{code}/items — add one object to a scope set."""
393
+ body = {"type": otype, "owner": owner, "name": name}
394
+ if subprogram:
395
+ body["subprogram"] = subprogram
396
+ if include_kind:
397
+ body["include_kind"] = include_kind
398
+ if note:
399
+ body["note"] = note
400
+ return self._request("POST", f"scopes/{code}/items", json_data=body)
401
+
402
+ def scope_remove_item(self, code: str, item_id: int) -> dict:
403
+ """DELETE /scopes/{code}/items/{id} — remove one scope item."""
404
+ return self._request("DELETE", f"scopes/{code}/items/{item_id}")
405
+
406
+ def scope_resolve(self, code: str) -> dict:
407
+ """POST /scopes/{code}/resolve — bind scope items to the synced catalog
408
+ rows (BAU_SCOPE_ITEMS.*_ID). Returns {ok,linked,total,unresolved}."""
409
+ return self._request("POST", f"scopes/{code}/resolve", json_data={})
410
+
411
+ # ---- DOCMIG (migração de legado -> APEX) --------------------------
412
+ def docmig_import(self, payload: dict) -> dict:
413
+ """POST /docmig/import — upsert the legacy-screen inventory (common
414
+ model: project/screens/blocks/fields/actions/reports). The server
415
+ resolves blocks/fields to the documented DOCTAB tables/columns."""
416
+ return self._request("POST", "docmig/import", json_data=payload)
417
+
418
+ def docmig_projects(self) -> dict:
419
+ """GET /docmig/projects — migration projects of the tenant."""
420
+ return self._request("GET", "docmig/projects")
421
+
422
+ def docmig_project(self, code: str) -> dict:
423
+ """GET /docmig/projects/{code} — project overview + screens/reports."""
424
+ return self._request("GET", f"docmig/projects/{code}")
425
+
426
+ def docmig_screen(self, screen_id: int) -> dict:
427
+ """GET /docmig/screens/{id} — full screen inventory with DOCTAB
428
+ bindings (labels, domains, form item types) — the AI blueprint."""
429
+ return self._request("GET", f"docmig/screens/{screen_id}")
430
+
431
+ def docmig_report(self, report_id: int) -> dict:
432
+ """GET /docmig/reports/{id} — legacy report + columns."""
433
+ return self._request("GET", f"docmig/reports/{report_id}")
434
+
435
+ # ---- DOCAGENT (Advisor de Agentes de IA) --------------------------
436
+ def agents_list(self) -> dict:
437
+ """GET /agents — agentes de IA sugeridos do tenant (DOCAGENT)."""
438
+ return self._request("GET", "agents")
439
+
440
+ def agent(self, code: str) -> dict:
441
+ """Alias of agent_get (kept for the bau_agents MCP tool)."""
442
+ return self.agent_get(code)
443
+
444
+ def agent_get(self, code: str) -> dict:
445
+ """GET /agents/{code} — um agente sugerido + refs de ancoragem."""
446
+ return self._request("GET", f"agents/{code}")
447
+
448
+ def agents_suggest(self, schema: str, max_agents: int = 6) -> dict:
449
+ """POST /agents/suggest — enfileira análise assíncrona do negócio de um schema."""
450
+ return self._request("POST", "agents/suggest",
451
+ json_data={"schema": schema, "max": max_agents})
452
+
453
+ def agent_approve(self, code: str) -> dict:
454
+ """POST /agents/{code}/approve — aprova um agente sugerido."""
455
+ return self._request("POST", f"agents/{code}/approve")
456
+
457
+ def impact(self, schema: str, obj: str, direction: str = "DOWN",
458
+ depth: int = 5, report: bool = False, lang: str = "pt_BR") -> dict:
459
+ """GET /impact/:schema/:object[/report] — dependency-graph impact analysis.
460
+
461
+ report=False → grafo JSON (conjunto impactado); report=True → laudo de
462
+ negócio gerado por IA (Markdown, dentro de {"report": ...}).
463
+ """
464
+ path = f"impact/{schema}/{obj}" + ("/report" if report else "")
465
+ params = {"dir": direction, "depth": depth}
466
+ if report:
467
+ params["lang"] = lang
468
+ # O laudo (/report) faz uma chamada de IA síncrona no servidor — pode
469
+ # passar dos 30s default. Piso maior só para o report; o grafo é rápido.
470
+ return self._request("GET", path, params=params,
471
+ timeout=(max(self.timeout, 180) if report else None))
472
+
473
+ def impact_report_queue(self, schema: str, obj: str, direction: str = "DOWN",
474
+ depth: int = 5, lang: str = "pt_BR") -> dict:
475
+ """POST /impact/:schema/:object/report/queue — enfileira um job
476
+ assíncrono que gera o laudo de impacto por IA e persiste em BAU_REPORTS
477
+ (IMPACT/HTML). Retorna {queued, job_id, message}. O resultado aparece em
478
+ App 601 › Análise de Impacto (p71) / Relatórios. Substitui a chamada
479
+ síncrona que estourava o timeout do gateway."""
480
+ return self._request("POST", f"impact/{schema}/{obj}/report/queue",
481
+ params={"dir": direction, "depth": depth, "lang": lang})
482
+
483
+ def audit_enqueue(self, schema: str, tables: str = None, schedule: str = None,
484
+ provider_id: int = None, reason: str = None) -> dict:
485
+ """POST /audit — enfileira o DA virtual (auditoria de modelagem por IA)
486
+ como job assíncrono e volta NA HORA. Audita o schema inteiro ou só
487
+ `tables` (CSV). Cada achado sério vira uma proposta no App 601 (validada
488
+ pelo write-loop). Retorna {ok, job_id, enqueued, status:'QUEUED',
489
+ custo_ia_est}. Acompanhe com audit_status / audit_findings."""
490
+ body = {"schema": schema}
491
+ if tables: body["tables"] = tables
492
+ if schedule: body["schedule"] = schedule
493
+ if provider_id: body["provider_id"] = provider_id
494
+ if reason: body["reason"] = reason
495
+ return self._request("POST", "audit", json_data=body)
496
+
497
+ def audit_status(self, job_id: int) -> dict:
498
+ """GET /audit/{job}/status — progresso do run (itens_total/feitos/falha,
499
+ propostas_geradas, iniciado/concluido_em, status QUEUED|RUNNING|DONE|FAILED)."""
500
+ return self._request("GET", f"audit/{int(job_id)}/status")
501
+
502
+ def audit_findings(self, job_id: int) -> dict:
503
+ """GET /audit/{job}/findings — as propostas geradas pelo run (App 601):
504
+ proposal_id, tabela, coluna, severidade, categoria, achado, action,
505
+ suggested_ddl, verdict."""
506
+ return self._request("GET", f"audit/{int(job_id)}/findings")
507
+
508
+ def flows_list(self, schema: str) -> dict:
509
+ """GET /flows/{schema} — index of structured business flows (DOCFLOW)."""
510
+ return self._request("GET", f"flows/{schema}")
511
+
512
+ def flow(self, schema: str, obj: str, subprogram: str = None,
513
+ include_draft: bool = False) -> dict:
514
+ """GET /flows/{schema}/{object} — structured business flow JSON
515
+ (steps / refs / rules). Only DOCUMENTED unless include_draft."""
516
+ params = {}
517
+ if subprogram:
518
+ params["subprogram"] = subprogram
519
+ if include_draft:
520
+ params["draft"] = "Y"
521
+ return self._request("GET", f"flows/{schema}/{obj}", params=params or None)
522
+
523
+ def rag_index(self, schema: str) -> dict:
524
+ """POST /rag/index/{schema} — refresh chunks + embed IN-DB (server-side,
525
+ via VECTOR_EMBEDDING). Sem cálculo na borda. Retorna {chunks, embedded,
526
+ pending, model, ok}. Long server-side embed: floor the timeout at 300s
527
+ so a low configured default doesn't kill the call."""
528
+ return self._request("POST", f"rag/index/{schema}", json_data={},
529
+ timeout=max(self.timeout, 300))
530
+
531
+ def rag_search(self, schema: str, query: str, top_k: int = 8) -> dict:
532
+ """POST /rag/search/{schema} — busca semântica por TEXTO; o servidor
533
+ embeda a pergunta IN-DB e devolve top-k chunks do catálogo aprovado."""
534
+ return self._request("POST", f"rag/search/{schema}",
535
+ json_data={"query": query, "top_k": top_k})
536
+
537
+ def docs_system(self, application_id: int, audience: str = "USER",
538
+ fmt: str = "HTML", language: str = "pt_BR") -> dict:
539
+ """POST /docs/system — generate the System Documentation for an APEX app
540
+ and stream it back. Returns {content: bytes, report_id, content_type}.
541
+
542
+ Deterministic composer (no AI): reuses the approved DOCFLOW/DOCTAB/DOCPL
543
+ catalog. audience USER=user manual, TECH=+field tables & data model.
544
+ format HTML or DOCX (Word-compatible)."""
545
+ url = f"{self.api_url}/docs/system"
546
+ try:
547
+ resp = self.session.request(
548
+ "POST", url,
549
+ json={"application_id": application_id, "audience": audience,
550
+ "format": fmt, "language": language},
551
+ timeout=max(self.timeout, 120),
552
+ )
553
+ except requests.ConnectionError:
554
+ raise BauApiError(f"Cannot connect to BAU Server at {self.api_url}")
555
+ except requests.Timeout:
556
+ raise BauApiError(f"Request timeout to {url}")
557
+ if resp.status_code == 401:
558
+ raise BauApiError("Unauthorized", 401)
559
+ if resp.status_code == 404:
560
+ raise BauApiError("Aplicação não encontrada no BAU (rode 'baucli apex sync' antes)", 404)
561
+ if resp.status_code >= 400:
562
+ raise BauApiError(f"Server error {resp.status_code}", resp.status_code)
563
+ return {
564
+ "content": resp.content,
565
+ "report_id": resp.headers.get("X-Bau-Report-Id"),
566
+ "content_type": resp.headers.get("Content-Type", ""),
567
+ }
568
+
569
+ def ai_limits(self) -> dict:
570
+ """GET /limits — per-analyst AI limits of the tenant + live month usage.
571
+
572
+ Read-only oversight (management is done in App 601). Returns
573
+ {"status":"ok","text": <json array>} or the parsed list."""
574
+ return self._request("GET", "limits")
575
+
576
+ def sync_status(self, schema: str) -> dict:
577
+ """GET /sync/status?schema=X — Get sync recommendation."""
578
+ return self._request("GET", "sync/status", params={"schema": schema})
579
+
580
+ def sync_batch(self, schema: str, db_engine: str, objects: list,
581
+ db_version: str = None,
582
+ overwrite_undocumented: bool = False,
583
+ dependencies: list = None,
584
+ sequences: list = None,
585
+ db_name: str = None) -> dict:
586
+ """POST /sync/batch — Send objects to BAU Server.
587
+
588
+ dependencies: array de deps do dicionario (grafo). Enviado numa chamada
589
+ dedicada (objects=[]); o servidor faz replace por schema em BAU_DEP_RAW.
590
+ db_name: nome do database registrado. Enviado para que um tenant com
591
+ MAIS DE UM database ativo resolva sem ambiguidade (senao o servidor
592
+ levanta ORA-20453 ao auto-selecionar).
593
+ """
594
+ payload = {
595
+ "schema": schema,
596
+ "db_engine": db_engine,
597
+ "objects": objects,
598
+ }
599
+ if db_version:
600
+ payload["db_version"] = db_version
601
+ if overwrite_undocumented:
602
+ payload["overwrite_undocumented"] = "Y"
603
+ if dependencies is not None:
604
+ payload["dependencies"] = dependencies
605
+ if sequences is not None:
606
+ payload["sequences"] = sequences
607
+ if db_name:
608
+ payload["db_name"] = db_name
609
+ return self._request("POST", "sync/batch", json_data=payload)
610
+
611
+ def source_upload(self, schema: str, name: str, obj_type: str,
612
+ source: str, db_engine: str = "ORACLE") -> dict:
613
+ """POST /source/upload — Upload single object source."""
614
+ return self._request("POST", "source/upload", json_data={
615
+ "schema": schema, "name": name, "type": obj_type,
616
+ "source": source, "db_engine": db_engine,
617
+ })
618
+
619
+ def get_source(self, schema: str, name: str) -> dict:
620
+ """GET /source/{schema}/{name} — Get current source."""
621
+ return self._request("GET", f"source/{schema}/{name}")
622
+
623
+ def get_source_hashes(self, schema: str) -> dict:
624
+ """GET /source/hashes/{schema} — Current source hash per object (drift)."""
625
+ return self._request("GET", f"source/hashes/{schema}")
626
+
627
+ def get_source_history(self, schema: str, name: str) -> dict:
628
+ """GET /source/history/{schema}/{name} — All source revisions for one object."""
629
+ return self._request("GET", f"source/history/{schema}/{name}")
630
+
631
+ def get_table_hashes(self, schema: str) -> dict:
632
+ """GET /tables/hashes/{schema} — Canonical column signature hash per table (drift)."""
633
+ return self._request("GET", f"tables/hashes/{schema}")
634
+
635
+ def get_table_columns_diff(self, schema: str, table: str) -> dict:
636
+ """GET /tables/columns/{schema}/{table} — Column list shaped for per-column diff."""
637
+ return self._request("GET", f"tables/columns/{schema}/{table}")
638
+
639
+ def get_columns_ui(self, schema: str, table: str) -> dict:
640
+ """GET /columns/{schema}/{table} — Rich UI metadata used by `apex-rebuild`.
641
+
642
+ Returns {"columns": [...]} where each entry carries label, align_form,
643
+ align_report, format_mask, text_case, hidden, read_only, show_in_forms,
644
+ show_in_reports, *_display_order, lov_display_order, description,
645
+ form_item_type (exact APEXlang token, e.g. "checkbox"/"selectList") and
646
+ domain_values ([{value,label}, ...] from the column's CHECK constraint)
647
+ — the last two let `apex-rebuild` retype an item and synthesize its
648
+ static LOV / single-checkbox checked&unchecked values from the domain.
649
+ """
650
+ return self._request("GET", f"columns/{schema}/{table}")
651
+
652
+ def objects_changed(self, days: int = 30, schema_name: str = None) -> dict:
653
+ """GET /objects/changed — Modified objects."""
654
+ params = {"days": days}
655
+ if schema_name:
656
+ params["schema_name"] = schema_name
657
+ return self._request("GET", "objects/changed", params=params)
658
+
659
+ def deploys_pending(self, schema: str = None, status: str = None) -> dict:
660
+ """GET /deploys/pending — Pending deploy queue."""
661
+ params = {}
662
+ if schema:
663
+ params["schema"] = schema
664
+ if status:
665
+ params["status"] = status
666
+ query = "&".join(f"{k}={v}" for k, v in params.items())
667
+ endpoint = "deploys/pending"
668
+ if query:
669
+ endpoint += "?" + query
670
+ return self._request("GET", endpoint)
671
+
672
+ def deploy_review(self, deploy_id: int, action: str,
673
+ reviewer: str = None, notes: str = None) -> dict:
674
+ """POST /deploys/{id}/review — Approve or reject."""
675
+ payload = {"action": action}
676
+ if reviewer:
677
+ payload["reviewer"] = reviewer
678
+ if notes:
679
+ payload["notes"] = notes
680
+ return self._request("POST", f"deploys/{deploy_id}/review", json_data=payload)
681
+
682
+ def deploy_script(self, deploy_id: int) -> dict:
683
+ """GET /deploys/{id}/script — Download DDL script."""
684
+ return self._request("GET", f"deploys/{deploy_id}/script")
685
+
686
+ def deploy_confirm(self, deploy_id: int, success: bool, error: str = None) -> dict:
687
+ """POST /deploys/{id}/confirm — Mark deploy APPLIED or FAILED after local execution."""
688
+ payload = {"success": "Y" if success else "N"}
689
+ if error:
690
+ payload["error"] = error[:4000]
691
+ return self._request("POST", f"deploys/{deploy_id}/confirm", json_data=payload)
692
+
693
+ # ── DOCTAB deploy queue (ANNOTATIONS / COMMENT ON per table) ──────────
694
+ def doctab_deploys_pending(self, schema: str = None, status: str = None,
695
+ limit: int = 100) -> dict:
696
+ """GET /doctab/deploys/pending — Pending DOCTAB deploys."""
697
+ params = {"p_limit": limit}
698
+ if schema:
699
+ params["schema"] = schema
700
+ if status:
701
+ params["status"] = status
702
+ return self._request("GET", "doctab/deploys/pending", params=params)
703
+
704
+ def doctab_deploy_script(self, deploy_id: int) -> dict:
705
+ """GET /doctab/deploys/{id}/script — Download DOCTAB DDL script."""
706
+ return self._request("GET", f"doctab/deploys/{deploy_id}/script")
707
+
708
+ def doctab_deploy_preview(self, deploy_id: int) -> dict:
709
+ """GET /doctab/deploys/{id}/preview — Preview DOCTAB deploy metadata."""
710
+ return self._request("GET", f"doctab/deploys/{deploy_id}/preview")
711
+
712
+ def doctab_deploy_confirm(self, deploy_id: int, success: bool,
713
+ error: str = None) -> dict:
714
+ """POST /doctab/deploys/{id}/confirm — Mark DOCTAB deploy APPLIED or FAILED."""
715
+ payload = {"success": "Y" if success else "N"}
716
+ if error:
717
+ payload["error"] = error[:4000]
718
+ return self._request("POST", f"doctab/deploys/{deploy_id}/confirm",
719
+ json_data=payload)
720
+
721
+ def deploy_preview(self, deploy_id: int) -> dict:
722
+ """GET /deploys/{id}/preview — Preview header and metadata."""
723
+ return self._request("GET", f"deploys/{deploy_id}/preview")
724
+
725
+ def doctab_get_script(self, schema_name: str) -> dict:
726
+ """GET /doctab/script/{schema_name} — Full COMMENT ON DDL script."""
727
+ return self._request("GET", f"doctab/script/{schema_name}")
728
+
729
+ def annotations_get_table(self, schema: str, table: str) -> dict:
730
+ """GET /annotations/{schema}/{table}/script — Oracle 23ai+ DDL for one table."""
731
+ return self._request("GET", f"annotations/{schema}/{table}/script")
732
+
733
+ def annotations_get_file(self, schema: str) -> dict:
734
+ """GET /annotations/{schema}/file — Oracle 23ai+ DDL for every table in the schema."""
735
+ return self._request("GET", f"annotations/{schema}/file")
736
+
737
+ def get_ui_kit(self, schema: str, engine: str = "ORACLE",
738
+ language: str = "EN", only: str = None) -> str:
739
+ """GET /ui-kit/{schema} — UI Kit bundle (domains + errors + APEX handler).
740
+
741
+ Returns the script as plain text. `only` accepts a CSV combo of
742
+ DOMAINS, ERRORS, HANDLER (None or empty = all three).
743
+ """
744
+ params = {"engine": engine, "lang": language}
745
+ if only:
746
+ params["only"] = only
747
+ result = self._request("GET", f"ui-kit/{schema}", params=params)
748
+ return result.get("text", "") if isinstance(result, dict) else str(result)
749
+
750
+ def context_languages(self, schema: str) -> dict:
751
+ """GET /context/{schema}/languages — JSON {primary, languages[]} for a schema.
752
+
753
+ Drives the bilingual decision in `bau context`: when len(languages) > 1
754
+ and the user didn't pin a single `--language`, the agent fetches one
755
+ context per language and writes a file per locale.
756
+ """
757
+ return self._request("GET", f"context/{schema}/languages")
758
+
759
+ def viewer_list(self, schema: str, status: str = None, obj_type: str = None) -> dict:
760
+ """GET /viewer/{schema} — List objects + tables for viewer."""
761
+ params = {}
762
+ if status:
763
+ params["status"] = status
764
+ if obj_type:
765
+ params["type"] = obj_type
766
+ query = "&".join(f"{k}={v}" for k, v in params.items())
767
+ endpoint = f"viewer/{schema}"
768
+ if query:
769
+ endpoint += "?" + query
770
+ return self._request("GET", endpoint)
771
+
772
+ def viewer_detail(self, category: str, item_id: int, language: str = None) -> dict:
773
+ """GET /viewer/detail/{OBJ|TBL}/{id} — Full documentation detail.
774
+
775
+ When `language` is set (e.g. 'pt_BR'), the server replaces EN canonical
776
+ text (purpose, description, prompt, business_domain) with the matching
777
+ translation from BAU_DOCTRANSLATE_VALUES. Falls back to EN when no
778
+ translation exists.
779
+ """
780
+ params = {"language": language} if language else None
781
+ return self._request("GET", f"viewer/detail/{category}/{item_id}", params=params)
782
+
783
+ def get_context(self, schema: str, language: str = "EN",
784
+ tables: str = None, objects: str = None,
785
+ scope: str = None) -> dict:
786
+ """GET /context/{schema} — AI context markdown.
787
+
788
+ `tables` and `objects` are optional comma-separated names that
789
+ narrow the markdown to just those tables / PL/SQL objects. When
790
+ both are NULL the full schema context is returned (legacy behavior).
791
+ """
792
+ params = {"language": language}
793
+ if tables:
794
+ params["tables"] = tables
795
+ if objects:
796
+ params["objects"] = objects
797
+ if scope:
798
+ params["scope"] = scope # 'preamble' = business context only
799
+ return self._request("GET", f"context/{schema}", params=params)
800
+
801
+ def database_register(self, db_name: str, db_engine: str,
802
+ db_version: str = None, host: str = None,
803
+ port: int = None, native_identifier: str = None) -> dict:
804
+ """POST /database/register — Register database UUID."""
805
+ payload = {"db_name": db_name, "db_engine": db_engine}
806
+ if db_version:
807
+ payload["db_version"] = db_version
808
+ if host:
809
+ payload["host"] = host
810
+ if port:
811
+ payload["port"] = port
812
+ if native_identifier:
813
+ payload["native_identifier"] = native_identifier
814
+ return self._request("POST", "database/register", json_data=payload)
815
+
816
+ def database_validate(self, bau_database_uuid: str) -> dict:
817
+ """POST /database/validate — Validate database fingerprint."""
818
+ return self._request("POST", "database/validate",
819
+ json_data={"bau_database_uuid": bau_database_uuid})
820
+
821
+ def get_quota(self) -> dict:
822
+ """GET /quota — Full quota status."""
823
+ return self._request("GET", "quota")
824
+
825
+ # ── DOCAPEX endpoints ──
826
+
827
+ def apex_sync(self, payload: dict) -> dict:
828
+ """POST /apex/sync — Send full APEX app snapshot to the server.
829
+
830
+ `payload` must follow the SYNC_APEX_APP contract: {schema, app, pages,
831
+ regions, items, proc, da}. Use OracleExtractor.extract_apex to build it.
832
+ """
833
+ return self._request("POST", "apex/sync", json_data=payload)
834
+
835
+ def apex_hashes(self, schema: str, application_id: int) -> dict:
836
+ """GET /apex/hashes/{schema}/{app_id} — Per-row HASH_CURRENT for drift."""
837
+ return self._request("GET", f"apex/hashes/{schema}/{application_id}")
838
+
839
+ def apex_screenshot_plan(self, schema: str, application_id: int) -> dict:
840
+ """GET /apex/screenshot-plan/{schema}/{app} — capture recipe per modal.
841
+
842
+ Returns {"status":"ok","modals":[{page_id, launch_page, launch_items,
843
+ launch_selector, dialog_selector, wait_ms}, ...]} documented by the
844
+ analyst in App 601 (p42). Used to open modal pages deterministically.
845
+ """
846
+ return self._request(
847
+ "GET", f"apex/screenshot-plan/{schema}/{application_id}")
848
+
849
+ def apex_upload_screenshot(self, application_id: int, page_no: int,
850
+ image_bytes: bytes,
851
+ mime: str = "image/png") -> dict:
852
+ """POST /apex/apps/{app}/pages/{page_no}/screenshot — raw image bytes.
853
+
854
+ Uploads a page screenshot by NATURAL KEY (APEX app id + page number);
855
+ the server resolves the BAU_DOCAPEX_PAGES row and routes to DB or the
856
+ OCI bucket per config. `image_bytes` is the raw PNG/JPEG/WEBP content.
857
+ """
858
+ url = (f"{self.api_url}/apex/apps/{application_id}"
859
+ f"/pages/{page_no}/screenshot")
860
+ try:
861
+ resp = self.session.request(
862
+ "POST", url, data=image_bytes,
863
+ headers={"Content-Type": mime},
864
+ timeout=max(self.timeout, 60),
865
+ )
866
+ except requests.ConnectionError:
867
+ raise BauApiError(f"Cannot connect to BAU Server at {self.api_url}")
868
+ except requests.Timeout:
869
+ raise BauApiError(f"Request timeout ({max(self.timeout, 60)}s) to {url}")
870
+
871
+ data = self._parse_body(resp)
872
+ if resp.status_code == 401:
873
+ msg = (data or {}).get("message", "Unauthorized") if isinstance(data, dict) else "Unauthorized"
874
+ if msg in SESSION_ERROR_TAGS:
875
+ raise BauSessionError(msg, 401, data or {})
876
+ raise BauApiError(msg, 401, data or {})
877
+ if isinstance(data, dict) and data.get("status") == "error":
878
+ raise BauApiError(data.get("message", "Unknown error"),
879
+ resp.status_code, data)
880
+ if data is None:
881
+ return {"status": "ok", "text": resp.text}
882
+ return data
883
+
884
+ def apex_ai_generate(self, level: str, row_id: int,
885
+ provider_id: int = None) -> dict:
886
+ """POST /apex/ai/generate/{level}/{id} — Trigger AI doc generation.
887
+
888
+ level: APP | PAGE | REGION | ITEM | PROC | DA
889
+ """
890
+ body = {}
891
+ if provider_id is not None:
892
+ body["provider_id"] = provider_id
893
+ return self._request("POST", f"apex/ai/generate/{level}/{row_id}",
894
+ json_data=body)
895
+
896
+ def apex_ai_approve(self, level: str, row_id: int,
897
+ approver: str = None) -> dict:
898
+ """POST /apex/ai/approve/{level}/{id} — Approve AI draft."""
899
+ body = {}
900
+ if approver:
901
+ body["approver"] = approver
902
+ return self._request("POST", f"apex/ai/approve/{level}/{row_id}",
903
+ json_data=body)
904
+
905
+ # ── Session auth (Bearer required, session NOT required) ──
906
+
907
+ def auth_login(self, email: str, password: str) -> dict:
908
+ """POST /auth/login — Exchange email+password for a session token."""
909
+ return self._request("POST", "auth/login",
910
+ json_data={"email": email, "password": password})
911
+
912
+ def auth_logout(self) -> dict:
913
+ """POST /auth/logout — Revoke the active session (X-Bau-Session header)."""
914
+ return self._request("POST", "auth/logout")