Adds RBAC matrix API/UI behind feature flags, agent×role toggles with audit, and extends Agentic Ops with streaming chat, Kimi LLM support, and UI updates.
240 lines
9.3 KiB
Python
240 lines
9.3 KiB
Python
"""Contexto operacional rico para chat investigativo — Spec 030+."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import sqlite3
|
||
|
||
from app.agents.catalog import AGENT_CATALOG
|
||
from app.agents import store
|
||
|
||
|
||
AGENT_INVESTIGATION_LENS: dict[str, str] = {
|
||
"A0": "Visão global: correlacionar incidentes, delegar aos agentes certos, priorizar por severidade.",
|
||
"A1": "Investigar: VM112 wizard/Carbonio, Proxmox cluster, CPU/RAM, containers down, restarts.",
|
||
"A2": "Investigar: DNS Cloudflare, Traefik CT114, certificados LE, SNI, pfSense API/regras.",
|
||
"A3": "Investigar: SPF/DKIM/DMARC, entregabilidade mail, registos DNS por domínio tenant.",
|
||
"A4": "Investigar: filas mail, amavis/clamav, spam, quarentena, bloqueios de envio.",
|
||
"A5": "Investigar: alertas Wazuh VM104, correlação SIEM, timeline segurança, runbooks R0/R1.",
|
||
"A6": "Investigar: tickets Desk, onboarding stuck, findings abertos, contexto cliente, runbooks KB.",
|
||
"A7": "Investigar: runbooks aprovados pós-incidente, remediação R0–R3, aprovações pendentes.",
|
||
"sentinel": "Investigar: health checks T0 (desk, wizard, pfSense, proxmox, ollama, VM123 stack).",
|
||
}
|
||
|
||
|
||
def _fmt_incident(inc: dict) -> str:
|
||
action = (inc.get("suggested_human_action") or "Investigar manualmente.")[:200]
|
||
return (
|
||
f"- [{inc.get('severity', '?').upper()}] {inc.get('title')} "
|
||
f"(cenário `{inc.get('scenario_id')}`, agente {inc.get('agent_name', inc.get('primary_agent'))}, "
|
||
f"visto {inc.get('occurrence_count', 1)}×, última vez {inc.get('last_seen_at', '?')})\n"
|
||
f" → Acção sugerida: {action}"
|
||
)
|
||
|
||
|
||
def build_ops_context(
|
||
conn: sqlite3.Connection,
|
||
question: str,
|
||
target_agent: str,
|
||
*,
|
||
include_findings: bool = True,
|
||
kb_snippets: list[str] | None = None,
|
||
) -> str:
|
||
"""Monta dossiê operacional para respostas investigativas."""
|
||
sections: list[str] = []
|
||
ov = store.get_overview(conn)
|
||
open_inc = ov.get("incidents_open") or {}
|
||
|
||
sections.append(
|
||
"### Estado do ambiente (dados reais do Agentic Ops)\n"
|
||
f"- Provider LLM: {ov.get('provider')} / {ov.get('model')}\n"
|
||
f"- Último tick worker: {ov.get('last_tick_at') or 'desconhecido'} — status `{ov.get('last_tick_status', '?')}`\n"
|
||
f"- Cenários vigilância: **{ov.get('scenarios_ok', 0)}/{ov.get('scenarios_total', 0)} OK**\n"
|
||
f"- Incidentes abertos: {open_inc.get('critical', 0)} crít · "
|
||
f"{open_inc.get('high', 0)} alto · {open_inc.get('warn', 0)} aviso"
|
||
)
|
||
|
||
lens = AGENT_INVESTIGATION_LENS.get(target_agent, AGENT_INVESTIGATION_LENS["A6"])
|
||
profile = AGENT_CATALOG.get(target_agent, AGENT_CATALOG["A6"])
|
||
sections.append(
|
||
f"### Lente investigativa do agente {profile.name} ({target_agent})\n{lens}\n"
|
||
f"Competências: {', '.join(profile.reads[:4])}"
|
||
)
|
||
|
||
incidents = store.list_incidents(conn, status="open", limit=15)
|
||
if target_agent not in ("A0", "A6", "ALL"):
|
||
agent_first = [i for i in incidents if i.get("primary_agent") == target_agent]
|
||
other = [i for i in incidents if i.get("primary_agent") != target_agent][:5]
|
||
incidents = agent_first + other
|
||
if incidents:
|
||
sections.append(
|
||
"### Incidentes activos (deduplicados por cenário)\n"
|
||
+ "\n".join(_fmt_incident(i) for i in incidents[:8])
|
||
)
|
||
else:
|
||
sections.append("### Incidentes activos\nNenhum incidente aberto no momento.")
|
||
|
||
scenarios = store.list_scenarios(conn)
|
||
failing = [s for s in scenarios if s.get("last_run_status") not in (None, "ok")]
|
||
if failing:
|
||
fail_lines = []
|
||
for s in failing[:8]:
|
||
fail_lines.append(
|
||
f"- `{s['id']}` — {s.get('title', s['id'])}: status **{s.get('last_run_status')}** "
|
||
f"(último run {s.get('last_run_at', '?')})"
|
||
)
|
||
sections.append("### Cenários com falha recente\n" + "\n".join(fail_lines))
|
||
|
||
if include_findings:
|
||
findings = store.list_findings(conn, limit=10, open_only=True)
|
||
if findings:
|
||
flines = []
|
||
for f in findings[:8]:
|
||
detail = (f.get("detail_md") or f.get("title") or "")[:180]
|
||
flines.append(
|
||
f"- [{f.get('severity')}] **{f.get('title')}** — {detail}\n"
|
||
f" → Humano: {(f.get('suggested_human_action') or 'verificar')[:160]}"
|
||
)
|
||
sections.append("### Findings T0/T1 abertos\n" + "\n".join(flines))
|
||
|
||
if kb_snippets:
|
||
sections.append(
|
||
"### Base de conhecimento (RAG — specs/runbooks)\n"
|
||
+ "\n---\n".join(kb_snippets[:6])[:4500]
|
||
)
|
||
|
||
sections.append(
|
||
"### Infra de referência Ligbox\n"
|
||
"- Proxmox host · pfSense WAN/LAN · VM112 wizard/onboard · VM122 Desk/API · VM123 finance/Ollama\n"
|
||
"- API pfSense via Traefik: `firewall.itecnologys.com` · Desk: `desk.ligbox.com.br`"
|
||
)
|
||
|
||
return "\n\n".join(sections)
|
||
|
||
|
||
RESPONSE_FORMAT_INSTRUCTION = """
|
||
## Formato OBRIGATÓRIO da resposta (markdown)
|
||
|
||
Estruture SEMPRE assim — respostas planas ou genéricas são proibidas:
|
||
|
||
### 🔍 Diagnóstico
|
||
Sintetize o que os **dados do contexto** indicam sobre a pergunta. Cite incidentes/findings/cenários relevantes por nome.
|
||
|
||
### 🧪 Hipóteses (ordem de probabilidade)
|
||
1. **Hipótese mais provável** — porquê, o que corrobora
|
||
2. **Hipótese alternativa** — o que verificar para confirmar/descartar
|
||
3. (opcional) terceira hipótese se aplicável
|
||
|
||
### ✅ Plano de acção proposto
|
||
| # | Prioridade | Acção concreta | Onde / comando |
|
||
|---|------------|----------------|----------------|
|
||
| 1 | Alta/Média/Baixa | passo executável | `comando` ou painel/link |
|
||
| 2 | ... | ... | ... |
|
||
|
||
Inclua **comandos shell** ou **URLs/painéis** quando souber (sem inventar credenciais).
|
||
|
||
### ⚠️ Riscos e impacto
|
||
- O que pode piorar se ignorarmos
|
||
- Dependências (ex.: reiniciar X afecta Y)
|
||
|
||
### ▶️ Próximo passo imediato
|
||
Uma frase directa: **faça isto agora** — a acção única de maior valor.
|
||
|
||
---
|
||
Regras: português BR · baseie-se no contexto injectado · se faltar dado, diga **exactamente** o que o operador deve correr/verificar · não invente estados de serviços.
|
||
"""
|
||
|
||
|
||
def _question_relevance(question: str, *texts: str) -> int:
|
||
q = question.lower()
|
||
score = 0
|
||
for t in texts:
|
||
if not t:
|
||
continue
|
||
tl = str(t).lower()
|
||
for word in q.split():
|
||
if len(word) > 3 and word in tl:
|
||
score += 2
|
||
if tl in q or q in tl:
|
||
score += 3
|
||
return score
|
||
|
||
|
||
def build_suggested_actions(
|
||
conn: sqlite3.Connection,
|
||
question: str,
|
||
target_agent: str,
|
||
*,
|
||
limit: int = 6,
|
||
) -> list[dict]:
|
||
"""Acções executáveis no Desk — checks T0 e ack de incidentes."""
|
||
actions: list[dict] = []
|
||
seen_scenarios: set[str] = set()
|
||
seen_incidents: set[int] = set()
|
||
|
||
def add_run(scenario_id: str, label: str, severity: str = "info") -> None:
|
||
if scenario_id in seen_scenarios or len(actions) >= limit:
|
||
return
|
||
if not store.get_scenario(conn, scenario_id):
|
||
return
|
||
seen_scenarios.add(scenario_id)
|
||
actions.append({
|
||
"type": "run_scenario",
|
||
"scenario_id": scenario_id,
|
||
"label": label,
|
||
"severity": severity,
|
||
})
|
||
|
||
def add_ack(incident_id: int, label: str, severity: str, scenario_id: str) -> None:
|
||
if incident_id in seen_incidents or len(actions) >= limit:
|
||
return
|
||
seen_incidents.add(incident_id)
|
||
actions.append({
|
||
"type": "ack_incident",
|
||
"incident_id": incident_id,
|
||
"scenario_id": scenario_id,
|
||
"label": label,
|
||
"severity": severity,
|
||
})
|
||
|
||
incidents = store.list_incidents(conn, status="open", limit=15)
|
||
ranked_inc = sorted(
|
||
incidents,
|
||
key=lambda i: (
|
||
-{"critical": 4, "high": 3, "warn": 2, "info": 1}.get(i.get("severity", "info"), 0),
|
||
-_question_relevance(question, i.get("title", ""), i.get("scenario_id", "")),
|
||
),
|
||
)
|
||
for inc in ranked_inc[:5]:
|
||
sid = inc.get("scenario_id") or ""
|
||
title = inc.get("title") or sid
|
||
sev = inc.get("severity") or "warn"
|
||
rel = _question_relevance(question, title, sid)
|
||
if rel > 0 or len(actions) < 2:
|
||
add_run(sid, f"▶ Executar check: {title[:48]}", sev)
|
||
if rel > 0 and inc.get("id"):
|
||
add_ack(int(inc["id"]), f"✓ Ack #{inc['id']} · {title[:32]}", sev, sid)
|
||
|
||
for s in store.list_scenarios(conn):
|
||
if s.get("last_run_status") not in (None, "ok"):
|
||
add_run(
|
||
s["id"],
|
||
f"▶ Re-validar: {(s.get('title') or s['id'])[:42]}",
|
||
"warn" if s.get("last_run_status") == "fail" else "info",
|
||
)
|
||
|
||
profile = AGENT_CATALOG.get(target_agent)
|
||
if profile:
|
||
for sid in profile.scenarios:
|
||
sc = store.get_scenario(conn, sid)
|
||
if sc:
|
||
add_run(sid, f"▶ Check: {(sc.get('title') or sid)[:40]}", "info")
|
||
|
||
if len(actions) < 2 and target_agent in ("A6", "A0", "sentinel"):
|
||
for sid, lbl in (
|
||
("desk.api.health", "▶ Health Desk API"),
|
||
("proxmox.cluster", "▶ Health Proxmox"),
|
||
("ollama.vm123.health", "▶ Health Ollama VM123"),
|
||
):
|
||
add_run(sid, lbl, "info")
|
||
|
||
return actions[:limit]
|