"""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'', ' ', html, flags=_re.IGNORECASE)
text = _re.sub(r'', ' ', 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]}")