fix: embedding reliability - chunking, truncation, progress bar, shared status
- Text chunking: max 8000 chars per document, large CSV rows split with context header - Text truncation: _embed_text truncates at 8000 chars before sending to model (prevents 400 errors) - Purge before re-embed: deletes old docs by tenancy+date before inserting new ones - Shared embedding status: file-based status (/data/.embed_status/) instead of in-memory dict (fixes multi-worker visibility) - Progress bar: shows green (OK) + red (failures) segments, percentage, processed/total count - Better error logging: extracts readable error message from OCI API errors
This commit is contained in:
202
backend/app.py
202
backend/app.py
@@ -41,7 +41,28 @@ for d in [DATA, OCI_DIR, REPORTS, MCP_DIR, WALLET_DIR]:
|
||||
|
||||
_running_reports: dict[str, asyncio.subprocess.Process] = {} # rid → subprocess
|
||||
_running_terraform: dict[str, asyncio.subprocess.Process] = {} # wid → subprocess
|
||||
_embedding_status: dict[str, dict] = {} # task_id → {status, message, table, tenancy, inserted, total}
|
||||
_EMBED_STATUS_DIR = DATA / ".embed_status"
|
||||
_EMBED_STATUS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _set_embed_status(task_id: str, data: dict):
|
||||
"""Write embedding status to shared file (visible across all workers)."""
|
||||
(_EMBED_STATUS_DIR / f"{task_id}.json").write_text(json.dumps(data), encoding="utf-8")
|
||||
|
||||
def _get_embed_status(task_id: str) -> dict | None:
|
||||
"""Read embedding status from shared file."""
|
||||
p = _EMBED_STATUS_DIR / f"{task_id}.json"
|
||||
if p.exists():
|
||||
try:
|
||||
return json.loads(p.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
def _update_embed_status(task_id: str, updates: dict):
|
||||
"""Update embedding status (read-modify-write)."""
|
||||
current = _get_embed_status(task_id) or {}
|
||||
current.update(updates)
|
||||
_set_embed_status(task_id, current)
|
||||
TERRAFORM_DIR = DATA / "terraform"
|
||||
TERRAFORM_DIR.mkdir(parents=True, exist_ok=True)
|
||||
_chat_executor = concurrent.futures.ThreadPoolExecutor(max_workers=10, thread_name_prefix="chat")
|
||||
@@ -2811,9 +2832,12 @@ def _resolve_embed_config(oci_config_id: str = None, genai_cfg: dict = None) ->
|
||||
}
|
||||
raise HTTPException(400, "Nenhuma credencial OCI configurada para gerar embeddings.")
|
||||
|
||||
def _embed_text(text: str, genai_cfg: dict, embedding_model_id: str) -> list:
|
||||
def _embed_text(text: str, genai_cfg: dict, embedding_model_id: str, max_chars: int = 8000) -> list:
|
||||
"""Generate embedding using OCI GenAI embed endpoint."""
|
||||
import oci
|
||||
# Truncate text to fit model token limit (~8192 tokens)
|
||||
if len(text) > max_chars:
|
||||
text = text[:max_chars]
|
||||
config_path = str(OCI_DIR / genai_cfg["oci_config_id"] / "config")
|
||||
config = oci.config.from_file(config_path, "DEFAULT")
|
||||
endpoint = genai_cfg.get("endpoint") or f"https://inference.generativeai.{genai_cfg.get('genai_region','us-ashburn-1')}.oci.oraclecloud.com"
|
||||
@@ -2828,7 +2852,7 @@ def _embed_text(text: str, genai_cfg: dict, embedding_model_id: str) -> list:
|
||||
emb_ref = emb_info.get("ocids", {}).get(region) or embedding_model_id
|
||||
embed_detail.serving_mode = oci.generative_ai_inference.models.OnDemandServingMode(model_id=emb_ref)
|
||||
embed_detail.compartment_id = genai_cfg.get("compartment_id", "")
|
||||
embed_detail.truncate = "NONE"
|
||||
embed_detail.truncate = "END"
|
||||
embed_detail.input_type = "SEARCH_QUERY"
|
||||
response = client.embed_text(embed_detail)
|
||||
return response.data.embeddings[0]
|
||||
@@ -3489,8 +3513,8 @@ def _ingest_documents_task(cfg: dict, genai_cfg: dict, documents: list, user_id:
|
||||
total = len(documents)
|
||||
# Track status
|
||||
if task_id:
|
||||
_embedding_status[task_id] = {"status": "running", "table": table_name, "tenancy": tenancy or "",
|
||||
"inserted": 0, "total": total, "message": "Iniciando embedding..."}
|
||||
_set_embed_status(task_id, {"status": "running", "table": table_name, "tenancy": tenancy or "",
|
||||
"inserted": 0, "total": total, "message": "Iniciando embedding..."})
|
||||
# Auto-register table so it appears in multi-table RAG search
|
||||
_auto_register_table(cfg["id"], table_name)
|
||||
conn = _get_adb_connection(cfg)
|
||||
@@ -3519,7 +3543,7 @@ def _ingest_documents_task(cfg: dict, genai_cfg: dict, documents: list, user_id:
|
||||
""", [uuid.uuid4().hex.upper(), content, vec, metadata])
|
||||
inserted += 1
|
||||
if task_id:
|
||||
_embedding_status[task_id].update({"inserted": inserted, "message": f"Embedding {inserted}/{total}..."})
|
||||
_update_embed_status(task_id, {"inserted": inserted, "message": f"Embedding {inserted}/{total}..."})
|
||||
except Exception as e:
|
||||
log.error(f"Failed to ingest document: {e}")
|
||||
conn.commit()
|
||||
@@ -3530,12 +3554,12 @@ def _ingest_documents_task(cfg: dict, genai_cfg: dict, documents: list, user_id:
|
||||
_audit(user_id, username, "ingest_documents", cfg["id"], f"{inserted} documents")
|
||||
_config_log("adb", cfg["id"], cfg.get("config_name"), "success", "ingest", msg, user_id, username)
|
||||
if task_id:
|
||||
_embedding_status[task_id].update({"status": "done", "inserted": inserted, "message": msg})
|
||||
_update_embed_status(task_id, {"status": "done", "inserted": inserted, "message": msg})
|
||||
except Exception as e:
|
||||
log.error(f"Ingestion task failed: {e}")
|
||||
_config_log("adb", cfg["id"], cfg.get("config_name"), "error", "ingest", str(e)[:500], user_id, username)
|
||||
if task_id:
|
||||
_embedding_status[task_id].update({"status": "error", "message": str(e)[:300]})
|
||||
_update_embed_status(task_id, {"status": "error", "message": str(e)[:300]})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -3693,6 +3717,31 @@ _CIS_TABLE_MAP = {
|
||||
"Asset_Management": "assetmanagement",
|
||||
}
|
||||
|
||||
def _purge_table_by_tenancy(cfg: dict, table_name: str, tenancy: str, extract_date: str = "") -> int:
|
||||
"""Delete existing embeddings from a table for a specific tenancy (and optionally extract_date).
|
||||
Returns number of rows deleted."""
|
||||
try:
|
||||
conn = _get_adb_connection(cfg)
|
||||
cur = conn.cursor()
|
||||
if extract_date:
|
||||
cur.execute(f"""DELETE FROM "{table_name}" WHERE
|
||||
JSON_VALUE(METADATA, '$.tenancy') = :1 AND
|
||||
JSON_VALUE(METADATA, '$.extract_date') = :2""", [tenancy, extract_date])
|
||||
else:
|
||||
cur.execute(f"""DELETE FROM "{table_name}" WHERE
|
||||
JSON_VALUE(METADATA, '$.tenancy') = :1""", [tenancy])
|
||||
deleted = cur.rowcount
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
if deleted:
|
||||
log.info(f"Purged {deleted} rows from {table_name} (tenancy={tenancy}, date={extract_date or 'all'})")
|
||||
return deleted
|
||||
except Exception as e:
|
||||
log.warning(f"Purge failed for {table_name}: {e}")
|
||||
return 0
|
||||
|
||||
|
||||
def _resolve_table_for_csv(filename: str) -> str | None:
|
||||
"""Map a CIS report CSV filename to its ADB vector table."""
|
||||
if filename == "cis_summary_report.csv":
|
||||
@@ -3703,9 +3752,11 @@ def _resolve_table_for_csv(filename: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _chunk_findings_csv(csv_path: str, tenancy: str, extract_date: str) -> list:
|
||||
"""Chunk a CIS findings CSV into documents. Each row becomes a document with structured content."""
|
||||
import csv as csvmod
|
||||
def _chunk_findings_csv(csv_path: str, tenancy: str, extract_date: str, max_chars: int = 8000) -> list:
|
||||
"""Chunk a CIS findings CSV into documents. Each row becomes one or more documents.
|
||||
If a row exceeds max_chars (~6000 tokens), it's split into smaller chunks with
|
||||
a context header (tenancy, resource name, ID) repeated in each part."""
|
||||
import csv as csvmod, re as _re
|
||||
p = Path(csv_path)
|
||||
if not p.exists():
|
||||
return []
|
||||
@@ -3716,26 +3767,58 @@ def _chunk_findings_csv(csv_path: str, tenancy: str, extract_date: str) -> list:
|
||||
return []
|
||||
skip_cols = {"extract_date", "deep_link", "domain_deeplink", "defined_tags",
|
||||
"freeform_tags", "system_tags", "external_identifier"}
|
||||
meta = json.dumps({"tenancy": tenancy, "extract_date": extract_date, "source": p.name})
|
||||
|
||||
for row in rows:
|
||||
parts = []
|
||||
parts.append(f"Tenancy: {tenancy}")
|
||||
parts.append(f"Extract Date: {extract_date}")
|
||||
# Build context header (always repeated in each chunk)
|
||||
header_parts = [f"Tenancy: {tenancy}", f"Extract Date: {extract_date}"]
|
||||
body_parts = []
|
||||
# Identify key fields for the header
|
||||
name = row.get("name") or row.get("display_name") or row.get("username") or ""
|
||||
rid = row.get("id", "")
|
||||
if name:
|
||||
header_parts.append(f"Resource: {name}")
|
||||
if rid:
|
||||
header_parts.append(f"ID: {rid}")
|
||||
|
||||
for col, val in row.items():
|
||||
if col.lower() in skip_cols or not val or not val.strip():
|
||||
continue
|
||||
if col.lower() in ("name", "display_name", "username", "id"):
|
||||
continue # already in header
|
||||
# Clean HYPERLINK formulas
|
||||
if val.startswith("=HYPERLINK"):
|
||||
import re
|
||||
m = re.search(r',\s*"([^"]+)"', val)
|
||||
m = _re.search(r',\s*"([^"]+)"', val)
|
||||
val = m.group(1) if m else val
|
||||
parts.append(f"{col}: {val}")
|
||||
content = "\n".join(parts)
|
||||
if len(content) > 50:
|
||||
documents.append({
|
||||
"content": content,
|
||||
"tenancy": tenancy,
|
||||
"metadata": json.dumps({"tenancy": tenancy, "extract_date": extract_date, "source": p.name})
|
||||
})
|
||||
body_parts.append(f"{col}: {val}")
|
||||
|
||||
header = "\n".join(header_parts)
|
||||
body = "\n".join(body_parts)
|
||||
full_content = header + "\n" + body
|
||||
|
||||
if len(full_content) <= max_chars:
|
||||
if len(full_content) > 50:
|
||||
documents.append({"content": full_content, "tenancy": tenancy, "metadata": meta})
|
||||
else:
|
||||
# Split body into chunks, each prefixed with context header
|
||||
chunk_size = max_chars - len(header) - 50 # reserve space for header + part label
|
||||
chunks = []
|
||||
current = ""
|
||||
for line in body_parts:
|
||||
if len(current) + len(line) + 2 > chunk_size and current:
|
||||
chunks.append(current)
|
||||
current = line
|
||||
else:
|
||||
current = current + "\n" + line if current else line
|
||||
if current:
|
||||
chunks.append(current)
|
||||
|
||||
for i, chunk in enumerate(chunks):
|
||||
part_label = f"(part {i + 1}/{len(chunks)})" if len(chunks) > 1 else ""
|
||||
content = f"{header}\n{part_label}\n{chunk}".strip()
|
||||
if len(content) > 50:
|
||||
documents.append({"content": content, "tenancy": tenancy, "metadata": meta})
|
||||
|
||||
return documents
|
||||
|
||||
|
||||
@@ -3796,11 +3879,11 @@ async def embed_report(rid: str, req: dict, bg: BackgroundTasks, u=Depends(requi
|
||||
# Calculate totals for status tracking
|
||||
total_docs = sum(len(d) for d in table_docs.values())
|
||||
tables_used = list(table_docs.keys())
|
||||
_embedding_status[task_id] = {
|
||||
_set_embed_status(task_id, {
|
||||
"status": "running", "table": ", ".join(tables_used), "tenancy": tenancy,
|
||||
"inserted": 0, "total": total_docs,
|
||||
"message": f"Embedding {total_docs} documentos em {len(tables_used)} tabelas..."
|
||||
}
|
||||
})
|
||||
|
||||
def _bg_embed_all():
|
||||
"""Background: embed documents into their respective tables sequentially."""
|
||||
@@ -3810,6 +3893,10 @@ async def embed_report(rid: str, req: dict, bg: BackgroundTasks, u=Depends(requi
|
||||
errors = []
|
||||
for tbl, docs in table_docs.items():
|
||||
_auto_register_table(cfg["id"], tbl)
|
||||
# Purge old data for this tenancy/date before inserting
|
||||
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-detect embedding model based on table dimension
|
||||
emb_model = default_model
|
||||
try:
|
||||
@@ -3823,9 +3910,12 @@ async def embed_report(rid: str, req: dict, bg: BackgroundTasks, u=Depends(requi
|
||||
conn = _get_adb_connection(cfg)
|
||||
cur = conn.cursor()
|
||||
for doc in docs:
|
||||
processed = 0
|
||||
try:
|
||||
content = doc.get("content", "")
|
||||
if not content: continue
|
||||
if not content:
|
||||
processed += 1
|
||||
continue
|
||||
embedding = _embed_text(content, gc, emb_model)
|
||||
vec = array.array('f', [float(x) for x in embedding])
|
||||
metadata = _build_metadata_json(
|
||||
@@ -3836,12 +3926,13 @@ async def embed_report(rid: str, req: dict, bg: BackgroundTasks, u=Depends(requi
|
||||
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
|
||||
_embedding_status[task_id].update({
|
||||
"inserted": inserted_total,
|
||||
"message": f"Embedding {inserted_total}/{total_docs} — tabela: {tbl}"
|
||||
})
|
||||
except Exception as e:
|
||||
log.error(f"Failed to embed doc in {tbl}: {e}")
|
||||
log.warning(f"Embed skip in {tbl}: {str(e)[:120]}")
|
||||
processed += 1
|
||||
_update_embed_status(task_id, {
|
||||
"inserted": inserted_total,
|
||||
"message": f"Embedding {inserted_total}/{processed} OK — {tbl}"
|
||||
})
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
@@ -3853,7 +3944,7 @@ async def embed_report(rid: str, req: dict, bg: BackgroundTasks, u=Depends(requi
|
||||
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)}"
|
||||
_embedding_status[task_id].update({
|
||||
_update_embed_status(task_id, {
|
||||
"status": "done" if not errors else "done",
|
||||
"inserted": inserted_total, "message": msg
|
||||
})
|
||||
@@ -3874,7 +3965,7 @@ async def embed_report(rid: str, req: dict, bg: BackgroundTasks, u=Depends(requi
|
||||
@app.get("/api/embeddings/status/{task_id}")
|
||||
async def embedding_status(task_id: str, u=Depends(current_user)):
|
||||
"""Check embedding task progress."""
|
||||
st = _embedding_status.get(task_id)
|
||||
st = _get_embed_status(task_id)
|
||||
if not st:
|
||||
return {"status": "unknown", "message": "Task not found"}
|
||||
return st
|
||||
@@ -3943,46 +4034,65 @@ async def embed_report_section(rid: str, req: dict, bg: BackgroundTasks, u=Depen
|
||||
total_docs = sum(len(d) for d in table_docs.values())
|
||||
tables_used = list(table_docs.keys())
|
||||
task_id = str(uuid.uuid4())
|
||||
_embedding_status[task_id] = {
|
||||
_set_embed_status(task_id, {
|
||||
"status": "running", "table": ", ".join(tables_used), "tenancy": tenancy,
|
||||
"inserted": 0, "total": total_docs, "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)
|
||||
# Auto-detect embedding model based on table dimension
|
||||
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]
|
||||
log.info(f"Table {tbl}: dim={actual_dim}, using model {emb_model}")
|
||||
except Exception as e:
|
||||
log.warning(f"Could not detect dim for {tbl}: {e}")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
conn = _get_adb_connection(cfg)
|
||||
cur = conn.cursor()
|
||||
for doc in docs:
|
||||
try:
|
||||
content = doc.get("content", "")
|
||||
if not content: continue
|
||||
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)
|
||||
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
|
||||
_embedding_status[task_id].update({"inserted": inserted, "message": f"Embedding {inserted}/{total_docs} — {tbl}"})
|
||||
except Exception as e:
|
||||
log.error(f"Section embed error in {tbl}: {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"Section embed connection error {tbl}: {e}")
|
||||
msg = f"{inserted}/{total_docs} docs em {', '.join(tables_used)} (tenancy: {tenancy})"
|
||||
_embedding_status[task_id].update({"status": "done", "inserted": inserted, "message": msg})
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user