feat: CIS number in embeddings, tenancy dropdown in consult, text filter for exact CIS search
- Chunk findings CSV: includes CIS Recommendation number, Section, Status in document header - Consult embeddings: tenancy dropdown (OCI config selector) for filtered search - CIS number detection: regex extracts "cis X.Y" from query → TEXT LIKE filter for exact match - Dynamic top_k: 10 per table when CIS filter active (vs 3 default), 15 global results - Vector search text_filter: combined vector similarity + TEXT LIKE for precise results - Purged 121174 legacy docs without tenancy metadata from all CIS tables - Re-embedded 4364 docs across 7 tables with full CIS metadata - GPT-5.2 for consult (was GPT-4.1), max_tokens 8000
This commit is contained in:
@@ -2915,9 +2915,10 @@ def _get_table_embedding_dim(cfg: dict, table_name: str) -> int:
|
|||||||
|
|
||||||
_GLOBAL_TABLES = {"cisrecom", "engineerknowledgebase"} # Tables without tenancy filter (generic knowledge)
|
_GLOBAL_TABLES = {"cisrecom", "engineerknowledgebase"} # Tables without tenancy filter (generic knowledge)
|
||||||
|
|
||||||
def _vector_search(cfg: dict, query_embedding: list, top_k: int = 5, table_name: str = None, tenancy: str = None) -> list:
|
def _vector_search(cfg: dict, query_embedding: list, top_k: int = 5, table_name: str = None, tenancy: str = None, text_filter: str = None) -> list:
|
||||||
"""Search ADB vector store using cosine similarity. Returns top-K documents.
|
"""Search ADB vector store using cosine similarity. Returns top-K documents.
|
||||||
If tenancy is provided and table is not global, filters by tenancy."""
|
If tenancy is provided and table is not global, filters by tenancy.
|
||||||
|
If text_filter is provided, also filters TEXT content with LIKE."""
|
||||||
import array
|
import array
|
||||||
table_name = table_name or cfg.get("table_name", "")
|
table_name = table_name or cfg.get("table_name", "")
|
||||||
conn = _get_adb_connection(cfg)
|
conn = _get_adb_connection(cfg)
|
||||||
@@ -2927,23 +2928,27 @@ def _vector_search(cfg: dict, query_embedding: list, top_k: int = 5, table_name:
|
|||||||
vec = array.array('f', query_embedding)
|
vec = array.array('f', query_embedding)
|
||||||
limit = int(top_k)
|
limit = int(top_k)
|
||||||
use_tenancy_filter = tenancy and table_name.lower() not in _GLOBAL_TABLES
|
use_tenancy_filter = tenancy and table_name.lower() not in _GLOBAL_TABLES
|
||||||
|
# Build WHERE clauses
|
||||||
|
conditions = []
|
||||||
|
params = [vec]
|
||||||
|
param_idx = 2
|
||||||
if use_tenancy_filter:
|
if use_tenancy_filter:
|
||||||
|
conditions.append(f"JSON_VALUE(METADATA, '$.tenancy') = :{param_idx}")
|
||||||
|
params.append(tenancy)
|
||||||
|
param_idx += 1
|
||||||
|
if text_filter:
|
||||||
|
conditions.append(f"TEXT LIKE :{param_idx}")
|
||||||
|
params.append(f"%{text_filter}%")
|
||||||
|
param_idx += 1
|
||||||
|
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
||||||
cur.execute(f"""
|
cur.execute(f"""
|
||||||
SELECT ID, TEXT, METADATA,
|
SELECT ID, TEXT, METADATA,
|
||||||
VECTOR_DISTANCE(EMBEDDING, :1, COSINE) AS distance
|
VECTOR_DISTANCE(EMBEDDING, :1, COSINE) AS distance
|
||||||
FROM "{table_name}"
|
FROM "{table_name}"
|
||||||
WHERE JSON_VALUE(METADATA, '$.tenancy') = :2
|
{where}
|
||||||
ORDER BY distance ASC
|
ORDER BY distance ASC
|
||||||
FETCH FIRST {limit} ROWS ONLY
|
FETCH FIRST {limit} ROWS ONLY
|
||||||
""", [vec, tenancy])
|
""", params)
|
||||||
else:
|
|
||||||
cur.execute(f"""
|
|
||||||
SELECT ID, TEXT, METADATA,
|
|
||||||
VECTOR_DISTANCE(EMBEDDING, :1, COSINE) AS distance
|
|
||||||
FROM "{table_name}"
|
|
||||||
ORDER BY distance ASC
|
|
||||||
FETCH FIRST {limit} ROWS ONLY
|
|
||||||
""", [vec])
|
|
||||||
results = []
|
results = []
|
||||||
for row in cur:
|
for row in cur:
|
||||||
content = row[1]
|
content = row[1]
|
||||||
@@ -3826,11 +3831,23 @@ def _chunk_findings_csv(csv_path: str, tenancy: str, extract_date: str, max_char
|
|||||||
return []
|
return []
|
||||||
skip_cols = {"extract_date", "deep_link", "domain_deeplink", "defined_tags",
|
skip_cols = {"extract_date", "deep_link", "domain_deeplink", "defined_tags",
|
||||||
"freeform_tags", "system_tags", "external_identifier"}
|
"freeform_tags", "system_tags", "external_identifier"}
|
||||||
meta = json.dumps({"tenancy": tenancy, "extract_date": extract_date, "source": p.name})
|
# Extract CIS recommendation number from filename (e.g., cis_Identity_and_Access_Management_1-1.csv → 1.1)
|
||||||
|
rec_match = _re.search(r'_(\d+)-(\d+(?:\.\d+)?)\.csv$', p.name)
|
||||||
|
cis_rec = f"{rec_match.group(1)}.{rec_match.group(2)}" if rec_match else ""
|
||||||
|
# Extract section name from filename
|
||||||
|
sec_match = _re.search(r'^cis_(.+?)_\d+-', p.name)
|
||||||
|
cis_section = sec_match.group(1).replace("_", " ") if sec_match else ""
|
||||||
|
|
||||||
|
meta = json.dumps({"tenancy": tenancy, "extract_date": extract_date, "source": p.name, "cis_recommendation": cis_rec})
|
||||||
|
|
||||||
for row in rows:
|
for row in rows:
|
||||||
# Build context header (always repeated in each chunk)
|
# Build context header (always repeated in each chunk)
|
||||||
header_parts = [f"Tenancy: {tenancy}", f"Extract Date: {extract_date}"]
|
header_parts = [f"Tenancy: {tenancy}", f"Extract Date: {extract_date}"]
|
||||||
|
if cis_rec:
|
||||||
|
header_parts.append(f"CIS Recommendation: {cis_rec}")
|
||||||
|
if cis_section:
|
||||||
|
header_parts.append(f"Section: {cis_section}")
|
||||||
|
header_parts.append(f"Status: Non-Compliant")
|
||||||
body_parts = []
|
body_parts = []
|
||||||
# Identify key fields for the header
|
# Identify key fields for the header
|
||||||
name = row.get("name") or row.get("display_name") or row.get("username") or ""
|
name = row.get("name") or row.get("display_name") or row.get("username") or ""
|
||||||
@@ -4311,6 +4328,7 @@ class ConsultQuery(BaseModel):
|
|||||||
query: str
|
query: str
|
||||||
table_name: str = ""
|
table_name: str = ""
|
||||||
top_k: int = 10
|
top_k: int = 10
|
||||||
|
oci_config_id: str = ""
|
||||||
|
|
||||||
@app.post("/api/embeddings/consult")
|
@app.post("/api/embeddings/consult")
|
||||||
async def consult_embeddings(req: ConsultQuery, u=Depends(current_user)):
|
async def consult_embeddings(req: ConsultQuery, u=Depends(current_user)):
|
||||||
@@ -4320,8 +4338,25 @@ async def consult_embeddings(req: ConsultQuery, u=Depends(current_user)):
|
|||||||
adb_configs = _get_active_adb_configs(u["id"])
|
adb_configs = _get_active_adb_configs(u["id"])
|
||||||
if not adb_configs:
|
if not adb_configs:
|
||||||
raise HTTPException(400, "Nenhuma conexão ADB ativa configurada")
|
raise HTTPException(400, "Nenhuma conexão 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
|
# Collect results from all active ADB configs + tables
|
||||||
all_docs = []
|
all_docs = []
|
||||||
|
rag_errors = []
|
||||||
for adb_cfg in adb_configs:
|
for adb_cfg in adb_configs:
|
||||||
try:
|
try:
|
||||||
emb_genai = _resolve_embed_config(oci_config_id=adb_cfg.get("oci_config_id"))
|
emb_genai = _resolve_embed_config(oci_config_id=adb_cfg.get("oci_config_id"))
|
||||||
@@ -4331,36 +4366,45 @@ async def consult_embeddings(req: ConsultQuery, u=Depends(current_user)):
|
|||||||
tables = _get_tables_for_config(adb_cfg["id"], active_only=True)
|
tables = _get_tables_for_config(adb_cfg["id"], active_only=True)
|
||||||
if req.table_name:
|
if req.table_name:
|
||||||
tables = [t for t in tables if t["table_name"] == req.table_name]
|
tables = [t for t in tables if t["table_name"] == req.table_name]
|
||||||
# Group tables by embedding dimension and generate one query embedding per dimension
|
embeddings_cache = {}
|
||||||
embeddings_cache = {} # dim -> query_embedding
|
|
||||||
for tbl in tables:
|
for tbl in tables:
|
||||||
tbl_name = tbl["table_name"]
|
tbl_name = tbl["table_name"]
|
||||||
try:
|
try:
|
||||||
dim = _get_table_embedding_dim(adb_cfg, tbl_name)
|
dim = _get_table_embedding_dim(adb_cfg, tbl_name)
|
||||||
if not dim:
|
if not dim:
|
||||||
log.warning(f"Consult: table {tbl_name} is empty or has no embeddings")
|
|
||||||
continue
|
continue
|
||||||
if dim not in embeddings_cache:
|
if dim not in embeddings_cache:
|
||||||
model = _DIM_TO_MODEL.get(dim, adb_cfg.get("embedding_model_id", ""))
|
model = _DIM_TO_MODEL.get(dim, adb_cfg.get("embedding_model_id", ""))
|
||||||
if not model:
|
if not model:
|
||||||
log.warning(f"Consult: no model for dim={dim} on {tbl_name}")
|
|
||||||
continue
|
continue
|
||||||
embeddings_cache[dim] = _embed_text(req.query, emb_genai, model)
|
embeddings_cache[dim] = _embed_text(req.query, emb_genai, model)
|
||||||
log.info(f"Consult: embedded query for dim={dim} using {model}")
|
|
||||||
query_embedding = embeddings_cache[dim]
|
query_embedding = embeddings_cache[dim]
|
||||||
docs = _vector_search(adb_cfg, query_embedding, top_k=req.top_k, table_name=tbl_name)
|
# Apply tenancy filter (skip for global tables) + CIS text filter
|
||||||
|
search_tenancy = rag_tenancy if tbl_name.lower() not in _GLOBAL_TABLES else None
|
||||||
|
tbl_top_k = 10 if cis_text_filter else 3
|
||||||
|
docs = _vector_search(adb_cfg, query_embedding, top_k=tbl_top_k, table_name=tbl_name, tenancy=search_tenancy, text_filter=cis_text_filter if tbl_name.lower() not in _GLOBAL_TABLES else None)
|
||||||
for d in docs:
|
for d in docs:
|
||||||
d["source"] = tbl_name
|
d["source"] = tbl_name
|
||||||
all_docs.extend(docs)
|
all_docs.extend(docs)
|
||||||
|
if docs:
|
||||||
|
log.info(f"Consult: {len(docs)} docs from {tbl_name}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.warning(f"Consult: failed on {tbl_name}: {type(e).__name__}: {str(e)[:200]}")
|
err = str(e)[:150]
|
||||||
|
log.warning(f"Consult: failed on {tbl_name}: {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 not all_docs:
|
||||||
|
if rag_errors:
|
||||||
|
return {"answer": "⚠️ " + "; ".join(set(rag_errors)) + ". A base de conhecimento não está disponível no momento.", "documents": [], "total": 0}
|
||||||
return {"answer": "Nenhum resultado encontrado nas bases vetoriais.", "documents": [], "total": 0}
|
return {"answer": "Nenhum resultado encontrado nas bases vetoriais.", "documents": [], "total": 0}
|
||||||
# Sort by distance and take top results
|
# Sort by distance and take top results
|
||||||
all_docs.sort(key=lambda d: d.get("distance", 999))
|
all_docs.sort(key=lambda d: d.get("distance", 999))
|
||||||
top_docs = all_docs[:req.top_k]
|
top_limit = 15 if cis_text_filter else 8
|
||||||
# Build context and call GenAI
|
top_docs = all_docs[:top_limit]
|
||||||
rag_context = _build_rag_context(top_docs)
|
# 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⚠️ Algumas bases não puderam ser consultadas: " + "; ".join(set(rag_errors))
|
||||||
augmented = RAG_CONTEXT_TEMPLATE.format(context=rag_context, question=req.query)
|
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
|
# Get GenAI config for answering — try saved config first, then auto-resolve from OCI
|
||||||
gc = None
|
gc = None
|
||||||
@@ -4379,11 +4423,11 @@ async def consult_embeddings(req: ConsultQuery, u=Depends(current_user)):
|
|||||||
"endpoint": resolved.get("endpoint", f"https://inference.generativeai.{resolved.get('genai_region','us-ashburn-1')}.oci.oraclecloud.com"),
|
"endpoint": resolved.get("endpoint", f"https://inference.generativeai.{resolved.get('genai_region','us-ashburn-1')}.oci.oraclecloud.com"),
|
||||||
"compartment_id": resolved.get("compartment_id", ""),
|
"compartment_id": resolved.get("compartment_id", ""),
|
||||||
"genai_region": resolved.get("genai_region", "us-ashburn-1"),
|
"genai_region": resolved.get("genai_region", "us-ashburn-1"),
|
||||||
"model_id": "openai.gpt-4.1",
|
"model_id": "openai.gpt-5.2",
|
||||||
"model_ocid": "",
|
"model_ocid": "",
|
||||||
"serving_type": "ON_DEMAND",
|
"serving_type": "ON_DEMAND",
|
||||||
"temperature": 0.3,
|
"temperature": 0.3,
|
||||||
"max_tokens": 4000,
|
"max_tokens": 8000,
|
||||||
"top_p": 0.9,
|
"top_p": 0.9,
|
||||||
"top_k": 1,
|
"top_k": 1,
|
||||||
"frequency_penalty": 0,
|
"frequency_penalty": 0,
|
||||||
|
|||||||
@@ -88,10 +88,11 @@ export const embeddingsApi = {
|
|||||||
}) as unknown as Promise<PurgeResult>,
|
}) as unknown as Promise<PurgeResult>,
|
||||||
|
|
||||||
/** Consult embeddings with a question */
|
/** Consult embeddings with a question */
|
||||||
consult: (query: string, tableName?: string, topK = 10) =>
|
consult: (query: string, tableName?: string, topK = 10, ociConfigId?: string) =>
|
||||||
client.post('/embeddings/consult', {
|
client.post('/embeddings/consult', {
|
||||||
query,
|
query,
|
||||||
table_name: tableName || '',
|
table_name: tableName || '',
|
||||||
top_k: topK,
|
top_k: topK,
|
||||||
|
oci_config_id: ociConfigId || '',
|
||||||
}) as unknown as Promise<ConsultResult>,
|
}) as unknown as Promise<ConsultResult>,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -39,10 +39,11 @@ function renderMarkdown(text: string): string {
|
|||||||
/* ── Main ── */
|
/* ── Main ── */
|
||||||
export default function EmbConsultPage() {
|
export default function EmbConsultPage() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { adbCfg } = useAppStore();
|
const { adbCfg, ociCfg } = useAppStore();
|
||||||
|
|
||||||
// Config selectors
|
// Config selectors
|
||||||
const [selTable, setSelTable] = useState('');
|
const [selTable, setSelTable] = useState('');
|
||||||
|
const [selOci, setSelOci] = useState(ociCfg.length > 0 ? ociCfg[0].id : '');
|
||||||
const [topK, setTopK] = useState(10);
|
const [topK, setTopK] = useState(10);
|
||||||
|
|
||||||
// Chat
|
// Chat
|
||||||
@@ -101,7 +102,7 @@ export default function EmbConsultPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const d = await embeddingsApi.consult(q, selTable || undefined, topK);
|
const d = await embeddingsApi.consult(q, selTable || undefined, topK, selOci || undefined);
|
||||||
let answer = d.answer || t('ec.noAnswer');
|
let answer = d.answer || t('ec.noAnswer');
|
||||||
|
|
||||||
const assistantMsg: ChatMessage = {
|
const assistantMsg: ChatMessage = {
|
||||||
@@ -162,6 +163,20 @@ export default function EmbConsultPage() {
|
|||||||
|
|
||||||
{/* Config bar */}
|
{/* Config bar */}
|
||||||
<div className="flex gap-3 flex-wrap flex-shrink-0">
|
<div className="flex gap-3 flex-wrap flex-shrink-0">
|
||||||
|
<div className="min-w-[160px]">
|
||||||
|
<label className="block text-[.68rem] font-semibold mb-1" style={{ color: 'var(--t4)' }}>Tenancy</label>
|
||||||
|
<select
|
||||||
|
value={selOci}
|
||||||
|
onChange={(e) => setSelOci(e.target.value)}
|
||||||
|
className="w-full px-3 py-1.5 rounded-lg text-[.76rem] outline-none"
|
||||||
|
style={{ background: 'var(--bg1)', border: '1px solid var(--bd)', color: 'var(--t1)' }}
|
||||||
|
>
|
||||||
|
<option value="">Todas</option>
|
||||||
|
{ociCfg.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>{c.tenancy_name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div className="flex-1 min-w-[180px]">
|
<div className="flex-1 min-w-[180px]">
|
||||||
<label className="block text-[.68rem] font-semibold mb-1" style={{ color: 'var(--t4)' }}>{t('ec.tableOptional')}</label>
|
<label className="block text-[.68rem] font-semibold mb-1" style={{ color: 'var(--t4)' }}>{t('ec.tableOptional')}</label>
|
||||||
<select
|
<select
|
||||||
|
|||||||
Reference in New Issue
Block a user