Fase 0 complete: extract all business logic, auth, database, and 176 endpoints from monolithic app.py into dedicated modules. Structure: - app.py: FastAPI hub (CORS, routers, startup/shutdown) - models.py: 13 Pydantic request models - utils.py: shared utilities (embed status, upload validation, process registries) - config.py: constants, env vars, model catalogs - auth/: crypto, jwt_auth, oidc, rate_limit - database/: db(), init_db(), schema DDL - services/: genai, compliance, chat, terraform, embeddings, cis_reports, mcp - routes/: 13 APIRouter modules (auth, users, oci_config, oci_explorer, genai, mcp, adb, embeddings, reports, chat, terraform, settings, cis_engine) Also: README updated with OCIR auth instructions for manual docker run. 86 tests passing.
733 lines
35 KiB
Python
733 lines
35 KiB
Python
"""Embedding routes — preview, embed report, status, section, file, upload, upload-url, consult, list, delete, purge."""
|
|
import json
|
|
import uuid
|
|
import re
|
|
from pathlib import Path
|
|
|
|
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File, Form, Query, BackgroundTasks
|
|
|
|
from database import db
|
|
from auth.jwt_auth import current_user, require, _audit, _config_log, _verify_config_access, _verify_report_access
|
|
from config import REPORTS, log, _chat_executor
|
|
from models import ConsultQuery
|
|
from utils import validate_upload, set_embed_status, get_embed_status, update_embed_status
|
|
from services.genai import (
|
|
_call_genai,
|
|
_get_adb_connection,
|
|
_resolve_embed_config,
|
|
_embed_text,
|
|
_DIM_TO_MODEL,
|
|
_get_table_embedding_dim,
|
|
_vector_search_multi,
|
|
_relevant_tables,
|
|
_build_rag_context,
|
|
_get_active_adb_configs,
|
|
_get_tables_for_config,
|
|
RAG_CONTEXT_TEMPLATE,
|
|
CONSULT_SYSTEM_PROMPT,
|
|
)
|
|
from services.embeddings import (
|
|
_build_metadata_json,
|
|
_auto_register_table,
|
|
_ingest_documents_task,
|
|
_chunk_report_by_section,
|
|
_chunk_cis_pdf,
|
|
_chunk_text_file,
|
|
_get_adb_and_genai,
|
|
_chunk_summary_csv,
|
|
_purge_table_by_tenancy,
|
|
_resolve_table_for_csv,
|
|
_chunk_findings_csv,
|
|
)
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
# ── Module-local helpers ─────────────────────────────────────────────────────
|
|
|
|
def _extract_pdf_text(file_bytes: bytes) -> str:
|
|
"""Extract text from a PDF file using PyPDF2 or pdfplumber."""
|
|
import io
|
|
try:
|
|
import PyPDF2
|
|
reader = PyPDF2.PdfReader(io.BytesIO(file_bytes))
|
|
pages = []
|
|
for page in reader.pages:
|
|
text = page.extract_text()
|
|
if text:
|
|
pages.append(text.strip())
|
|
return "\n\n".join(pages)
|
|
except ImportError:
|
|
pass
|
|
try:
|
|
import pdfplumber
|
|
pages = []
|
|
with pdfplumber.open(io.BytesIO(file_bytes)) as pdf:
|
|
for page in pdf.pages:
|
|
text = page.extract_text()
|
|
if text:
|
|
pages.append(text.strip())
|
|
return "\n\n".join(pages)
|
|
except ImportError:
|
|
raise HTTPException(400, "PDF support requires PyPDF2 or pdfplumber. Install: pip install PyPDF2")
|
|
|
|
|
|
def _extract_text_from_html(html: str) -> str:
|
|
"""Extract readable text from HTML, stripping tags and scripts."""
|
|
import re as _re
|
|
text = _re.sub(r'<script[^>]*>[\s\S]*?</script>', ' ', html, flags=_re.IGNORECASE)
|
|
text = _re.sub(r'<style[^>]*>[\s\S]*?</style>', ' ', text, flags=_re.IGNORECASE)
|
|
text = _re.sub(r'<[^>]+>', ' ', text)
|
|
text = _re.sub(r'&[a-zA-Z]+;', ' ', text)
|
|
text = _re.sub(r'&#\d+;', ' ', text)
|
|
text = _re.sub(r'\s+', ' ', text).strip()
|
|
return text
|
|
|
|
|
|
def _classify_report_file(fname: str) -> str:
|
|
"""Classify a report file into a category based on its filename."""
|
|
fl = fname.lower()
|
|
if "summary_report" in fl: return "summary"
|
|
if "error_report" in fl or "error" in fl and fl.endswith(".csv"): return "error"
|
|
if fl.startswith("obp_") and "findings" in fl: return "obp_finding"
|
|
if fl.startswith("obp_") and "best_practices" in fl: return "obp_best_practice"
|
|
if fl.startswith("obp_"): return "obp_finding"
|
|
if fl.startswith("raw_data_"): return "raw_data"
|
|
if fl.startswith("cis_"): return "cis_finding"
|
|
if "consolidated_report" in fl: return "consolidated"
|
|
if fl.endswith(".png"): return "diagram"
|
|
return "other"
|
|
|
|
|
|
# ── Routes ───────────────────────────────────────────────────────────────────
|
|
|
|
@router.get("/api/embeddings/preview/{rid}")
|
|
async def preview_report_chunks(rid: str, u=Depends(current_user)):
|
|
"""Preview the chunks that will be generated from a CIS report before embedding."""
|
|
_verify_report_access(rid, u)
|
|
with db() as c:
|
|
r = c.execute("SELECT json_path,tenancy_name FROM reports WHERE id=? AND status='completed'", (rid,)).fetchone()
|
|
if not r: raise HTTPException(404, "Report not found or not completed")
|
|
json_path = r["json_path"]
|
|
if not json_path or not Path(json_path).exists():
|
|
raise HTTPException(400, "Report JSON file not found")
|
|
try:
|
|
report_data = json.loads(Path(json_path).read_text())
|
|
except Exception:
|
|
raise HTTPException(400, "Invalid report data")
|
|
documents = _chunk_report_by_section(report_data)
|
|
rd = report_data if isinstance(report_data, dict) else {}
|
|
return {"tenancy": rd.get("tenancy", "unknown"),
|
|
"regions": rd.get("regions", []),
|
|
"compartments": rd.get("compartments", []),
|
|
"total_chunks": len(documents),
|
|
"chunks": documents}
|
|
|
|
@router.post("/api/embeddings/report/{rid}")
|
|
async def embed_report(rid: str, req: dict, bg: BackgroundTasks, u=Depends(require("admin","user"))):
|
|
"""Auto-embed all CIS report CSVs into their mapped ADB vector tables."""
|
|
_verify_report_access(rid, u)
|
|
vid = req.get("adb_config_id")
|
|
if not vid: raise HTTPException(400, "adb_config_id is required")
|
|
_verify_config_access("adb", vid, u)
|
|
with db() as c:
|
|
r = c.execute("SELECT tenancy_name, config_id FROM reports WHERE id=? AND status='completed'", (rid,)).fetchone()
|
|
if not r: raise HTTPException(404, "Report not found or not completed")
|
|
|
|
rdir = REPORTS / rid
|
|
tenancy = r["tenancy_name"] or "unknown"
|
|
|
|
# Read extract_date from summary CSV
|
|
import csv as csvmod
|
|
summary_csv = rdir / "cis_summary_report.csv"
|
|
extract_date = ""
|
|
if summary_csv.exists():
|
|
with open(summary_csv, "r", encoding="utf-8") as f:
|
|
first = next(csvmod.DictReader(f), {})
|
|
extract_date = first.get("extract_date", "")
|
|
|
|
cfg, gc = _get_adb_and_genai(vid, oci_config_id=r["config_id"] if r["config_id"] else None, user_id=u["id"])
|
|
|
|
# Load registered tables for validation
|
|
with db() as c:
|
|
registered = {t["table_name"].lower() for t in
|
|
c.execute("SELECT table_name FROM adb_vector_tables WHERE adb_config_id=? AND is_active=1", (vid,)).fetchall()}
|
|
|
|
# Scan all CSVs and map to tables
|
|
task_id = str(uuid.uuid4())
|
|
table_docs: dict[str, list] = {}
|
|
missing_tables: list[str] = []
|
|
skipped_tables: list[str] = []
|
|
|
|
for csv_file in sorted(rdir.glob("cis_*.csv")):
|
|
table = _resolve_table_for_csv(csv_file.name)
|
|
if not table:
|
|
continue
|
|
if table.lower() not in registered:
|
|
if table not in skipped_tables:
|
|
skipped_tables.append(table)
|
|
continue
|
|
if csv_file.name == "cis_summary_report.csv":
|
|
docs = _chunk_summary_csv(str(csv_file), tenancy, extract_date)
|
|
else:
|
|
docs = _chunk_findings_csv(str(csv_file), tenancy, extract_date)
|
|
if docs:
|
|
table_docs.setdefault(table, []).extend(docs)
|
|
|
|
if not table_docs:
|
|
if skipped_tables:
|
|
raise HTTPException(400, f"Tabela(s) nao registrada(s) no ADB: {', '.join(skipped_tables)}. Registre em Configuracoes > ADB Vector.")
|
|
raise HTTPException(400, "No CSV files found to embed")
|
|
|
|
# Calculate totals for status tracking
|
|
total_docs = sum(len(d) for d in table_docs.values())
|
|
tables_used = list(table_docs.keys())
|
|
set_embed_status(task_id, {
|
|
"status": "running", "table": ", ".join(tables_used), "tenancy": tenancy,
|
|
"inserted": 0, "total": total_docs, "user_id": u["id"],
|
|
"message": f"Embedding {total_docs} documentos em {len(tables_used)} tabelas..."
|
|
})
|
|
|
|
# Build queue info: [(table, doc_count), ...]
|
|
table_queue = [(tbl, len(docs)) for tbl, docs in table_docs.items()]
|
|
|
|
def _bg_embed_all():
|
|
"""Background: embed documents into their respective tables sequentially."""
|
|
import array
|
|
default_model = cfg.get("embedding_model_id", "cohere.embed-v4.0")
|
|
inserted_total = 0
|
|
skipped_total = 0
|
|
processed_total = 0
|
|
errors = []
|
|
for tbl_idx, (tbl, docs) in enumerate(table_docs.items()):
|
|
remaining = table_queue[tbl_idx + 1:] if tbl_idx + 1 < len(table_queue) else []
|
|
_auto_register_table(cfg["id"], tbl)
|
|
purged = _purge_table_by_tenancy(cfg, tbl, tenancy, extract_date)
|
|
if purged:
|
|
update_embed_status(task_id, {"message": f"Purged {purged} old docs from {tbl}...",
|
|
"current_table": tbl, "current_inserted": 0, "current_total": len(docs),
|
|
"queue": [{"table": t, "docs": n} for t, n in remaining]})
|
|
emb_model = default_model
|
|
try:
|
|
actual_dim = _get_table_embedding_dim(cfg, tbl)
|
|
if actual_dim and actual_dim in _DIM_TO_MODEL:
|
|
emb_model = _DIM_TO_MODEL[actual_dim]
|
|
except Exception:
|
|
pass
|
|
current_inserted = 0
|
|
current_skipped = 0
|
|
try:
|
|
conn = _get_adb_connection(cfg)
|
|
cur = conn.cursor()
|
|
for doc in docs:
|
|
try:
|
|
content = doc.get("content", "")
|
|
if not content:
|
|
skipped_total += 1
|
|
current_skipped += 1
|
|
processed_total += 1
|
|
continue
|
|
embedding = _embed_text(content, gc, emb_model)
|
|
vec = array.array('f', [float(x) for x in embedding])
|
|
metadata = _build_metadata_json(
|
|
tenancy=doc.get("tenancy", tenancy),
|
|
section=doc.get("section", ""),
|
|
report_date=extract_date,
|
|
user_id=u["id"],
|
|
)
|
|
cur.execute(f'INSERT INTO "{tbl}" (ID, TEXT, EMBEDDING, METADATA) VALUES (HEXTORAW(:1), :2, :3, :4)',
|
|
[uuid.uuid4().hex.upper(), content, vec, metadata])
|
|
inserted_total += 1
|
|
current_inserted += 1
|
|
except Exception as e:
|
|
err_str = str(e)
|
|
if "message" in err_str:
|
|
import re as _re
|
|
m = _re.search(r'"message"\s*:\s*"(.*?)"', err_str)
|
|
log.warning(f"Embed skip in {tbl}: {m.group(1)[:200] if m else err_str[:200]}")
|
|
else:
|
|
log.warning(f"Embed skip in {tbl}: {err_str[:200]}")
|
|
skipped_total += 1
|
|
current_skipped += 1
|
|
processed_total += 1
|
|
update_embed_status(task_id, {
|
|
"inserted": inserted_total, "skipped": skipped_total, "processed": processed_total,
|
|
"current_table": tbl, "current_inserted": current_inserted, "current_skipped": current_skipped,
|
|
"current_total": len(docs),
|
|
"queue": [{"table": t, "docs": n} for t, n in remaining],
|
|
"message": f"{tbl}: {current_inserted}/{len(docs)} — global: {inserted_total}/{total_docs}"
|
|
})
|
|
conn.commit()
|
|
cur.close()
|
|
conn.close()
|
|
log.info(f"Embedded {current_inserted}/{len(docs)} docs into {tbl} (tenancy={tenancy})")
|
|
except Exception as e:
|
|
log.error(f"Failed to connect/embed to {tbl}: {e}")
|
|
errors.append(f"{tbl}: {str(e)[:100]}")
|
|
|
|
msg = f"{inserted_total}/{total_docs} documentos em {len(tables_used)} tabelas ({', '.join(tables_used)})"
|
|
if tenancy: msg += f" — tenancy: {tenancy}"
|
|
if errors: msg += f" | Erros: {'; '.join(errors)}"
|
|
update_embed_status(task_id, {
|
|
"status": "done" if not errors else "done",
|
|
"inserted": inserted_total, "message": msg
|
|
})
|
|
_audit(u["id"], u["username"], "embed_report_auto", rid, msg)
|
|
_config_log("adb", cfg["id"], cfg.get("config_name"),
|
|
"success" if not errors else "error", "ingest", msg, u["id"], u["username"])
|
|
|
|
_chat_executor.submit(_bg_embed_all)
|
|
msg = f"Embedding iniciado — {total_docs} documentos em {len(tables_used)} tabelas ({', '.join(tables_used)})"
|
|
if skipped_tables:
|
|
msg += f". Ignoradas (nao registradas): {', '.join(skipped_tables)}"
|
|
return {
|
|
"ok": True, "task_id": task_id, "message": msg,
|
|
"tables": tables_used, "skipped": skipped_tables, "total_documents": total_docs, "tenancy": tenancy
|
|
}
|
|
|
|
|
|
@router.get("/api/embeddings/status/{task_id}")
|
|
async def embedding_status(task_id: str, u=Depends(current_user)):
|
|
"""Check embedding task progress."""
|
|
st = get_embed_status(task_id)
|
|
if not st:
|
|
return {"status": "unknown", "message": "Task not found"}
|
|
if st.get("user_id") and st["user_id"] != u["id"] and u["role"] != "admin":
|
|
return {"status": "unknown", "message": "Task not found"}
|
|
return {k: v for k, v in st.items() if k != "user_id"}
|
|
|
|
|
|
@router.post("/api/embeddings/report/{rid}/section")
|
|
async def embed_report_section(rid: str, req: dict, bg: BackgroundTasks, u=Depends(require("admin","user"))):
|
|
"""Embed all CSV files from a specific report section into the mapped ADB table."""
|
|
_verify_report_access(rid, u)
|
|
import csv as csvmod
|
|
vid = req.get("adb_config_id")
|
|
file_names: list = req.get("file_names", [])
|
|
if not vid:
|
|
# Auto-detect first active ADB
|
|
with db() as c:
|
|
adb = c.execute("SELECT id FROM adb_vector_configs WHERE is_active=1 LIMIT 1").fetchone()
|
|
if not adb: raise HTTPException(400, "No active ADB config found")
|
|
vid = adb["id"]
|
|
if not file_names: raise HTTPException(400, "file_names is required")
|
|
|
|
with db() as c:
|
|
r = c.execute("SELECT tenancy_name, config_id FROM reports WHERE id=? AND status='completed'", (rid,)).fetchone()
|
|
if not r: raise HTTPException(404)
|
|
rdir = REPORTS / rid
|
|
tenancy = r["tenancy_name"] or "unknown"
|
|
|
|
# Read extract_date
|
|
summary = rdir / "cis_summary_report.csv"
|
|
extract_date = ""
|
|
if summary.exists():
|
|
with open(summary, "r", encoding="utf-8") as f:
|
|
first = next(csvmod.DictReader(f), {})
|
|
extract_date = first.get("extract_date", "")
|
|
|
|
cfg, gc = _get_adb_and_genai(vid, oci_config_id=r["config_id"] if r["config_id"] else None, user_id=u["id"])
|
|
|
|
# Load registered tables for validation
|
|
with db() as c:
|
|
registered = {t["table_name"].lower() for t in
|
|
c.execute("SELECT table_name FROM adb_vector_tables WHERE adb_config_id=? AND is_active=1", (vid,)).fetchall()}
|
|
|
|
# Group files by target table and validate
|
|
import array
|
|
table_docs: dict[str, list] = {}
|
|
missing_tables: list[str] = []
|
|
for fname in file_names:
|
|
csv_path = rdir / fname
|
|
if not csv_path.exists(): continue
|
|
table = _resolve_table_for_csv(fname)
|
|
if not table: continue
|
|
if table.lower() not in registered:
|
|
if table not in missing_tables:
|
|
missing_tables.append(table)
|
|
continue
|
|
if fname == "cis_summary_report.csv":
|
|
docs = _chunk_summary_csv(str(csv_path), tenancy, extract_date)
|
|
else:
|
|
docs = _chunk_findings_csv(str(csv_path), tenancy, extract_date)
|
|
if docs:
|
|
table_docs.setdefault(table, []).extend(docs)
|
|
|
|
if missing_tables and not table_docs:
|
|
raise HTTPException(400, f"Tabela(s) nao registrada(s) no ADB: {', '.join(missing_tables)}. Registre em Configuracoes > ADB Vector.")
|
|
if not table_docs:
|
|
raise HTTPException(400, "No embeddable content found in the selected files")
|
|
|
|
total_docs = sum(len(d) for d in table_docs.values())
|
|
tables_used = list(table_docs.keys())
|
|
task_id = str(uuid.uuid4())
|
|
set_embed_status(task_id, {
|
|
"status": "running", "table": ", ".join(tables_used), "tenancy": tenancy,
|
|
"inserted": 0, "total": total_docs, "user_id": u["id"], "message": f"Embedding {total_docs} docs em {', '.join(tables_used)}..."
|
|
})
|
|
|
|
def _bg():
|
|
default_model = cfg.get("embedding_model_id", "cohere.embed-v4.0")
|
|
inserted = 0
|
|
skipped = 0
|
|
processed = 0
|
|
for tbl, docs in table_docs.items():
|
|
purged = _purge_table_by_tenancy(cfg, tbl, tenancy, extract_date)
|
|
if purged:
|
|
update_embed_status(task_id, {"message": f"Purged {purged} old docs from {tbl}..."})
|
|
_auto_register_table(cfg["id"], tbl)
|
|
emb_model = default_model
|
|
try:
|
|
actual_dim = _get_table_embedding_dim(cfg, tbl)
|
|
if actual_dim and actual_dim in _DIM_TO_MODEL:
|
|
emb_model = _DIM_TO_MODEL[actual_dim]
|
|
except Exception:
|
|
pass
|
|
try:
|
|
conn = _get_adb_connection(cfg)
|
|
cur = conn.cursor()
|
|
for doc in docs:
|
|
try:
|
|
content = doc.get("content", "")
|
|
if not content:
|
|
skipped += 1
|
|
processed += 1
|
|
continue
|
|
embedding = _embed_text(content, gc, emb_model)
|
|
vec = array.array('f', [float(x) for x in embedding])
|
|
metadata = _build_metadata_json(tenancy=doc.get("tenancy", tenancy), section=doc.get("section", ""), report_date=extract_date, user_id=u["id"])
|
|
cur.execute(f'INSERT INTO "{tbl}" (ID, TEXT, EMBEDDING, METADATA) VALUES (HEXTORAW(:1), :2, :3, :4)',
|
|
[uuid.uuid4().hex.upper(), content, vec, metadata])
|
|
inserted += 1
|
|
except Exception as e:
|
|
skipped += 1
|
|
err_str = str(e)
|
|
if "message" in err_str:
|
|
import re as _re
|
|
m = _re.search(r'"message"\s*:\s*"(.*?)"', err_str)
|
|
log.warning(f"Embed skip in {tbl}: {m.group(1)[:200] if m else err_str[:200]}")
|
|
else:
|
|
log.warning(f"Embed skip in {tbl}: {err_str[:200]}")
|
|
processed += 1
|
|
update_embed_status(task_id, {
|
|
"inserted": inserted, "skipped": skipped, "processed": processed, "total": total_docs,
|
|
"message": f"{tbl}: {inserted} OK, {skipped} falhas ({processed}/{total_docs})"
|
|
})
|
|
conn.commit(); cur.close(); conn.close()
|
|
except Exception as e:
|
|
log.error(f"Embed connection error {tbl}: {e}")
|
|
msg = f"{inserted}/{total_docs} embeddings OK"
|
|
if skipped: msg += f", {skipped} falhas"
|
|
msg += f" em {', '.join(tables_used)} (tenancy: {tenancy})"
|
|
update_embed_status(task_id, {"status": "done", "inserted": inserted, "skipped": skipped, "processed": processed, "message": msg})
|
|
_audit(u["id"], u["username"], "embed_section", rid, msg)
|
|
|
|
_chat_executor.submit(_bg)
|
|
return {"ok": True, "task_id": task_id, "tables": tables_used, "total": total_docs,
|
|
"message": f"Embedding de {total_docs} docs iniciado ({', '.join(tables_used)})"}
|
|
|
|
|
|
@router.post("/api/embeddings/report/{rid}/file/{fid}")
|
|
async def embed_report_file(rid: str, fid: str, req: dict, bg: BackgroundTasks, u=Depends(require("admin","user"))):
|
|
"""Embed a specific report file (CSV, JSON, TXT, etc.) into ADB vector store."""
|
|
_verify_report_access(rid, u)
|
|
vid = req.get("adb_config_id")
|
|
if not vid: raise HTTPException(400, "adb_config_id is required")
|
|
_verify_config_access("adb", vid, u)
|
|
with db() as c:
|
|
r = c.execute("SELECT tenancy_name, config_id FROM reports WHERE id=? AND status='completed'", (rid,)).fetchone()
|
|
if not r: raise HTTPException(404, "Report not found or not completed")
|
|
f = c.execute("SELECT * FROM report_files WHERE id=? AND report_id=?", (fid, rid)).fetchone()
|
|
if not f: raise HTTPException(404, "File not found")
|
|
p = Path(f["file_path"])
|
|
if not p.exists(): raise HTTPException(404, "File not found on disk")
|
|
fname = f["file_name"].lower()
|
|
allowed = ('.txt', '.csv', '.json', '.md', '.pdf')
|
|
if not any(fname.endswith(ext) for ext in allowed):
|
|
raise HTTPException(400, f"Formatos aceitos para embedding: {', '.join(allowed)}")
|
|
raw = p.read_bytes()
|
|
if fname.endswith('.pdf'):
|
|
content = _extract_pdf_text(raw)
|
|
else:
|
|
content = raw.decode("utf-8", errors="replace")
|
|
if not content.strip(): raise HTTPException(400, "File is empty")
|
|
documents = _chunk_text_file(content, f["file_name"])
|
|
if not documents: raise HTTPException(400, "No content chunks found")
|
|
cfg, gc = _get_adb_and_genai(vid, oci_config_id=r["config_id"] if r["config_id"] else None, user_id=u["id"])
|
|
target_table = req.get("table_name") or None
|
|
tenancy = r["tenancy_name"] or "unknown"
|
|
task_id = str(uuid.uuid4())
|
|
bg.add_task(_ingest_documents_task, cfg, gc, documents, u["id"], u["username"],
|
|
table_name=target_table, tenancy=tenancy, task_id=task_id)
|
|
_audit(u["id"], u["username"], "embed_report_file", f"{rid}/{fid}", f"{f['file_name']}, {len(documents)} chunks, tenancy={tenancy}")
|
|
return {"ok": True, "task_id": task_id, "message": f"Embedding de {f['file_name']} iniciado ({len(documents)} chunks)", "chunks": len(documents)}
|
|
|
|
@router.post("/api/embeddings/upload")
|
|
async def embed_upload(adb_config_id: str = Form(...), table_name: str = Form(""), file: UploadFile = File(...), bg: BackgroundTasks = None, u=Depends(require("admin","user"))):
|
|
_verify_config_access("adb", adb_config_id, u)
|
|
fname = file.filename.lower()
|
|
allowed = ('.txt', '.pdf', '.csv', '.json', '.md')
|
|
if not any(fname.endswith(ext) for ext in allowed):
|
|
raise HTTPException(400, f"Formatos aceitos: {', '.join(allowed)}")
|
|
await validate_upload(file, allowed)
|
|
file.file.seek(0)
|
|
raw = await file.read()
|
|
if fname.endswith('.pdf'):
|
|
content = _extract_pdf_text(raw)
|
|
else:
|
|
content = raw.decode("utf-8", errors="replace")
|
|
if not content.strip(): raise HTTPException(400, "File is empty")
|
|
target_table = table_name.strip() or None
|
|
# Use CIS-specific chunking for cisrecom table (segments by recommendation number with overlap)
|
|
if target_table and 'cisrecom' in target_table.lower():
|
|
documents = _chunk_cis_pdf(content, file.filename)
|
|
if not documents:
|
|
documents = _chunk_text_file(content, file.filename) # fallback
|
|
else:
|
|
documents = _chunk_text_file(content, file.filename)
|
|
if not documents: raise HTTPException(400, "No content chunks found")
|
|
cfg, gc = _get_adb_and_genai(adb_config_id, user_id=u["id"])
|
|
bg.add_task(_ingest_documents_task, cfg, gc, documents, u["id"], u["username"], table_name=target_table)
|
|
_audit(u["id"], u["username"], "embed_upload", file.filename, f"{len(documents)} chunks")
|
|
return {"ok": True, "message": f"Embedding de {len(documents)} chunks iniciado", "chunks": len(documents), "filename": file.filename}
|
|
|
|
@router.post("/api/embeddings/upload-url")
|
|
async def embed_upload_url(
|
|
adb_config_id: str = Form(...),
|
|
table_name: str = Form(""),
|
|
url: str = Form(...),
|
|
bg: BackgroundTasks = None,
|
|
u=Depends(require("admin", "user"))
|
|
):
|
|
_verify_config_access("adb", adb_config_id, u)
|
|
import requests as req
|
|
url = url.strip()
|
|
if not url.startswith(("http://", "https://")):
|
|
raise HTTPException(400, "URL invalida — deve comecar com http:// ou https://")
|
|
try:
|
|
resp = req.get(url, timeout=30, headers={"User-Agent": "Mozilla/5.0 AI-Agent/1.0"})
|
|
resp.raise_for_status()
|
|
except Exception as e:
|
|
raise HTTPException(400, f"Erro ao acessar URL: {str(e)[:300]}")
|
|
ct = resp.headers.get("content-type", "")
|
|
if "pdf" in ct:
|
|
content = _extract_pdf_text(resp.content)
|
|
elif "html" in ct or "text" in ct:
|
|
content = _extract_text_from_html(resp.text)
|
|
else:
|
|
content = resp.text
|
|
if not content or not content.strip():
|
|
raise HTTPException(400, "Nenhum conteudo extraido da URL")
|
|
documents = _chunk_text_file(content, url)
|
|
if not documents:
|
|
raise HTTPException(400, "Nenhum chunk gerado do conteudo")
|
|
cfg, gc = _get_adb_and_genai(adb_config_id, user_id=u["id"])
|
|
target_table = table_name.strip() or None
|
|
bg.add_task(_ingest_documents_task, cfg, gc, documents, u["id"], u["username"], table_name=target_table)
|
|
_audit(u["id"], u["username"], "embed_url", url, f"{len(documents)} chunks")
|
|
return {"ok": True, "message": f"Embedding de {len(documents)} chunks iniciado", "chunks": len(documents), "url": url}
|
|
|
|
@router.post("/api/embeddings/consult")
|
|
async def consult_embeddings(req: ConsultQuery, u=Depends(current_user)):
|
|
"""Query embeddings via vector search + GenAI to get a formatted answer."""
|
|
if not req.query.strip():
|
|
raise HTTPException(400, "Query nao pode ser vazia")
|
|
adb_configs = _get_active_adb_configs(u["id"])
|
|
if not adb_configs:
|
|
raise HTTPException(400, "Nenhuma conexao ADB ativa configurada")
|
|
# Resolve tenancy for filtered search
|
|
rag_tenancy = None
|
|
if req.oci_config_id:
|
|
with db() as c:
|
|
oci_row = c.execute("SELECT tenancy_name FROM oci_configs WHERE id=?", (req.oci_config_id,)).fetchone()
|
|
if oci_row:
|
|
rag_tenancy = oci_row["tenancy_name"]
|
|
log.info(f"Consult: filtering by tenancy '{rag_tenancy}'")
|
|
|
|
# Detect CIS recommendation number in query for exact text filtering
|
|
import re as _re
|
|
cis_match = _re.search(r'(?:cis|recommendation)\s*(\d+\.\d+)', req.query, _re.IGNORECASE)
|
|
cis_text_filter = f"CIS Recommendation: {cis_match.group(1)}" if cis_match else None
|
|
if cis_text_filter:
|
|
log.info(f"Consult: detected CIS filter '{cis_text_filter}'")
|
|
|
|
# Collect results from all active ADB configs + tables
|
|
all_docs = []
|
|
rag_errors = []
|
|
for adb_cfg in adb_configs:
|
|
try:
|
|
emb_genai = _resolve_embed_config(oci_config_id=adb_cfg.get("oci_config_id"), user_id=u["id"])
|
|
except Exception as e:
|
|
log.warning(f"Consult: resolve config failed for {adb_cfg['id']}: {e}")
|
|
continue
|
|
tables = _get_tables_for_config(adb_cfg["id"], active_only=True)
|
|
if req.table_name:
|
|
tables = [t for t in tables if t["table_name"] == req.table_name]
|
|
all_table_names = [t["table_name"] for t in tables if t["table_name"]]
|
|
# Smart skip
|
|
relevant = _relevant_tables(req.query, all_table_names) if not req.table_name else all_table_names
|
|
skipped = set(all_table_names) - set(relevant)
|
|
if skipped:
|
|
log.info(f"Consult: skipped {', '.join(skipped)}")
|
|
# Auto-detect model
|
|
emb_model = adb_cfg.get("embedding_model_id", "cohere.embed-v4.0")
|
|
for tbl_name in relevant:
|
|
try:
|
|
dim = _get_table_embedding_dim(adb_cfg, tbl_name)
|
|
if dim and dim in _DIM_TO_MODEL:
|
|
emb_model = _DIM_TO_MODEL[dim]
|
|
break
|
|
except Exception:
|
|
pass
|
|
try:
|
|
query_embedding = _embed_text(req.query, emb_genai, emb_model)
|
|
tbl_top_k = 10 if cis_text_filter else 3
|
|
docs = _vector_search_multi(adb_cfg, query_embedding, relevant,
|
|
top_k_per_table=tbl_top_k, tenancy=rag_tenancy,
|
|
text_filter=cis_text_filter, user_id=u["id"])
|
|
all_docs.extend(docs)
|
|
if docs:
|
|
sources = {}
|
|
for d in docs:
|
|
sources[d["source"]] = sources.get(d["source"], 0) + 1
|
|
log.info(f"Consult: {len(docs)} docs — {', '.join(f'{k}:{v}' for k,v in sources.items())}")
|
|
except Exception as e:
|
|
err = str(e)[:150]
|
|
log.warning(f"Consult: search failed: {err}")
|
|
if "DPY-6001" in str(e) or "DPY-6005" in str(e) or "timeout" in str(e).lower():
|
|
rag_errors.append(f"ADB offline ou timeout ({adb_cfg.get('config_name','?')})")
|
|
if not all_docs:
|
|
if rag_errors:
|
|
return {"answer": "\u26a0\ufe0f " + "; ".join(set(rag_errors)) + ". A base de conhecimento nao esta disponivel no momento.", "documents": [], "total": 0}
|
|
return {"answer": "Nenhum resultado encontrado nas bases vetoriais.", "documents": [], "total": 0}
|
|
# Sort by distance and take top results
|
|
all_docs.sort(key=lambda d: d.get("distance", 999))
|
|
top_limit = 15 if cis_text_filter else 8
|
|
top_docs = all_docs[:top_limit]
|
|
# Build context with dates and sources
|
|
rag_context = _build_rag_context(top_docs, max_total_chars=16000 if cis_text_filter else 12000)
|
|
if rag_errors:
|
|
rag_context += "\n\n\u26a0\ufe0f Algumas bases nao puderam ser consultadas: " + "; ".join(set(rag_errors))
|
|
augmented = RAG_CONTEXT_TEMPLATE.format(context=rag_context, question=req.query)
|
|
# Get GenAI config for answering — try saved config first, then auto-resolve from OCI
|
|
gc = None
|
|
with db() as c:
|
|
gc_row = c.execute("SELECT * FROM genai_configs WHERE user_id=? AND is_default=1 ORDER BY created_at DESC", (u["id"],)).fetchone()
|
|
if not gc_row:
|
|
gc_row = c.execute("SELECT * FROM genai_configs WHERE user_id=? ORDER BY created_at DESC", (u["id"],)).fetchone()
|
|
if gc_row:
|
|
gc = dict(gc_row)
|
|
else:
|
|
# Auto-resolve: build GenAI config from OCI credentials + default model
|
|
try:
|
|
resolved = _resolve_embed_config(user_id=u["id"])
|
|
gc = {
|
|
"oci_config_id": resolved["oci_config_id"],
|
|
"endpoint": resolved.get("endpoint", f"https://inference.generativeai.{resolved.get('genai_region','us-ashburn-1')}.oci.oraclecloud.com"),
|
|
"compartment_id": resolved.get("compartment_id", ""),
|
|
"genai_region": resolved.get("genai_region", "us-ashburn-1"),
|
|
"model_id": "openai.gpt-5.2",
|
|
"model_ocid": "",
|
|
"serving_type": "ON_DEMAND",
|
|
"temperature": 0.3,
|
|
"max_tokens": 8000,
|
|
"top_p": 0.9,
|
|
"top_k": 1,
|
|
"frequency_penalty": 0,
|
|
"presence_penalty": 0,
|
|
}
|
|
log.info(f"Consult: auto-resolved GenAI config from OCI, model=openai.gpt-4.1")
|
|
except Exception as e:
|
|
log.warning(f"Consult: no GenAI config available: {e}")
|
|
doc_list = [{"content": d.get("content", "")[:500], "source": d.get("source", ""), "distance": round(d.get("distance", 0), 4), "metadata": d.get("metadata", "")} for d in top_docs]
|
|
parts = []
|
|
for i, d in enumerate(top_docs, 1):
|
|
content = d.get("content", "")
|
|
if len(content) > 800: content = content[:800] + "..."
|
|
parts.append(f"**Documento {i}** — `{d.get('source', '?')}` (distancia: {d.get('distance', 0):.4f})\n\n{content}")
|
|
return {"answer": "\n\n---\n\n".join(parts), "documents": doc_list, "total": len(all_docs)}
|
|
try:
|
|
gc["system_prompt"] = CONSULT_SYSTEM_PROMPT
|
|
answer, _, _ = _call_genai(gc, augmented)
|
|
except Exception as e:
|
|
log.error(f"Consult GenAI error: {e}")
|
|
answer = f"Erro ao consultar GenAI: {str(e)[:300]}"
|
|
doc_list = [{"content": d.get("content", "")[:500], "source": d.get("source", ""), "distance": round(d.get("distance", 0), 4), "metadata": d.get("metadata", "")} for d in top_docs]
|
|
return {"answer": answer, "documents": doc_list, "total": len(all_docs)}
|
|
|
|
@router.get("/api/embeddings/{vid}/list")
|
|
async def list_embeddings(vid: str, table_name: str = Query(""), limit: int = Query(50), offset: int = Query(0), u=Depends(current_user)):
|
|
_verify_config_access("adb", vid, u)
|
|
with db() as c:
|
|
cfg = c.execute("SELECT * FROM adb_vector_configs WHERE id=?", (vid,)).fetchone()
|
|
if not cfg: raise HTTPException(404)
|
|
try:
|
|
conn = _get_adb_connection(dict(cfg))
|
|
cur = conn.cursor()
|
|
table_name = table_name.strip() or cfg["table_name"]
|
|
if not table_name: raise HTTPException(400, "Nenhuma tabela selecionada")
|
|
cur.execute(f'SELECT COUNT(*) FROM "{table_name}"')
|
|
total = cur.fetchone()[0]
|
|
cur.execute(f"""
|
|
SELECT ID, METADATA FROM "{table_name}"
|
|
OFFSET :1 ROWS FETCH NEXT :2 ROWS ONLY
|
|
""", [offset, limit])
|
|
rows = []
|
|
for row in cur:
|
|
rid = row[0].hex() if isinstance(row[0], bytes) else str(row[0])
|
|
meta = row[1]
|
|
if hasattr(meta, 'read'): meta = meta.read()
|
|
rows.append({"id": rid, "metadata": meta})
|
|
cur.close(); conn.close()
|
|
return {"total": total, "offset": offset, "limit": limit, "documents": rows}
|
|
except Exception as e:
|
|
raise HTTPException(500, f"Erro ao listar embeddings: {str(e)[:500]}")
|
|
|
|
@router.delete("/api/embeddings/{vid}/{doc_id}")
|
|
async def delete_embedding(vid: str, doc_id: str, table_name: str = Query(""), u=Depends(require("admin","user"))):
|
|
_verify_config_access("adb", vid, u, require_owner=True)
|
|
with db() as c:
|
|
cfg = c.execute("SELECT * FROM adb_vector_configs WHERE id=?", (vid,)).fetchone()
|
|
if not cfg: raise HTTPException(404)
|
|
try:
|
|
conn = _get_adb_connection(dict(cfg))
|
|
cur = conn.cursor()
|
|
table_name = table_name.strip() or cfg["table_name"]
|
|
if not table_name: raise HTTPException(400, "Nenhuma tabela selecionada")
|
|
cur.execute(f'DELETE FROM "{table_name}" WHERE ID = :1', [doc_id])
|
|
conn.commit()
|
|
cur.close(); conn.close()
|
|
return {"ok": True}
|
|
except Exception as e:
|
|
raise HTTPException(500, f"Erro ao deletar: {str(e)[:500]}")
|
|
|
|
@router.post("/api/embeddings/{vid}/purge")
|
|
async def purge_embeddings(vid: str, req: dict, u=Depends(require("admin","user"))):
|
|
"""Delete old embeddings from a table, optionally filtered by tenancy."""
|
|
_verify_config_access("adb", vid, u, require_owner=True)
|
|
table_name = req.get("table_name", "").strip()
|
|
tenancy = req.get("tenancy", "").strip()
|
|
if not table_name: raise HTTPException(400, "table_name e obrigatorio")
|
|
with db() as c:
|
|
cfg = c.execute("SELECT * FROM adb_vector_configs WHERE id=?", (vid,)).fetchone()
|
|
if not cfg: raise HTTPException(404)
|
|
try:
|
|
conn = _get_adb_connection(dict(cfg))
|
|
cur = conn.cursor()
|
|
if tenancy:
|
|
cur.execute(f'SELECT COUNT(*) FROM "{table_name}" WHERE METADATA LIKE :1',
|
|
[f'%"tenancy":"{tenancy}"%'])
|
|
count = cur.fetchone()[0]
|
|
cur.execute(f'DELETE FROM "{table_name}" WHERE METADATA LIKE :1',
|
|
[f'%"tenancy":"{tenancy}"%'])
|
|
else:
|
|
cur.execute(f'SELECT COUNT(*) FROM "{table_name}"')
|
|
count = cur.fetchone()[0]
|
|
cur.execute(f'DELETE FROM "{table_name}"')
|
|
conn.commit()
|
|
cur.close(); conn.close()
|
|
_audit(u["id"], u["username"], "purge_embeddings", vid, f"table={table_name}, tenancy={tenancy or 'ALL'}, deleted={count}")
|
|
return {"ok": True, "deleted": count, "table": table_name, "tenancy": tenancy or "ALL"}
|
|
except Exception as e:
|
|
raise HTTPException(500, f"Erro ao limpar: {str(e)[:500]}")
|