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.
450 lines
15 KiB
Python
450 lines
15 KiB
Python
"""LLM providers — Spec 029 T1 (Ollama) + 034 KIMI + Groq free tier."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
from collections.abc import Iterator
|
|
|
|
import httpx
|
|
|
|
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://10.10.10.123:11434").rstrip("/")
|
|
AGENTIC_LLM_MODEL = os.getenv("AGENTIC_LLM_MODEL", "qwen2.5:7b-instruct")
|
|
AGENTIC_EMBED_MODEL = os.getenv("AGENTIC_EMBED_MODEL", "nomic-embed-text")
|
|
AGENTIC_LLM_ENABLED = os.getenv("AGENTIC_LLM_ENABLED", "false").lower() in ("1", "true", "yes")
|
|
KIMI_API_KEY = os.getenv("KIMI_API_KEY", "").strip()
|
|
KIMI_BASE_URL = os.getenv("KIMI_BASE_URL", "https://api.moonshot.ai/v1").strip().rstrip("/")
|
|
KIMI_MODEL = os.getenv("KIMI_MODEL", "kimi-k2.5").strip()
|
|
GROQ_API_KEY = os.getenv("GROQ_API_KEY", "").strip()
|
|
GROQ_BASE_URL = os.getenv("GROQ_BASE_URL", "https://api.groq.com/openai/v1").strip().rstrip("/")
|
|
GROQ_MODEL = os.getenv("GROQ_MODEL", "llama-3.1-8b-instant").strip()
|
|
AGENTIC_LLM_PROVIDER = os.getenv("AGENTIC_LLM_PROVIDER", "auto").lower().strip()
|
|
AGENTIC_LLM_FALLBACK = os.getenv("AGENTIC_LLM_FALLBACK", "ollama").lower().strip()
|
|
|
|
|
|
def _clean_env(value: str) -> str:
|
|
return re.split(r"\s+#", value or "", maxsplit=1)[0].strip()
|
|
|
|
|
|
def resolve_provider() -> str:
|
|
if not AGENTIC_LLM_ENABLED:
|
|
return "t0"
|
|
provider = _clean_env(AGENTIC_LLM_PROVIDER).lower() or "auto"
|
|
if provider == "auto":
|
|
if GROQ_API_KEY:
|
|
return "groq"
|
|
if KIMI_API_KEY:
|
|
return "kimi"
|
|
return "ollama"
|
|
return provider
|
|
|
|
|
|
def active_model() -> str:
|
|
p = resolve_provider()
|
|
if p == "groq":
|
|
return _clean_env(GROQ_MODEL) or "llama-3.1-8b-instant"
|
|
if p == "kimi":
|
|
return _clean_env(KIMI_MODEL) or "kimi-k2.5"
|
|
if p == "ollama":
|
|
return AGENTIC_LLM_MODEL
|
|
return "t0"
|
|
|
|
|
|
def llm_status() -> dict:
|
|
provider = resolve_provider()
|
|
return {
|
|
"tier": "t1" if AGENTIC_LLM_ENABLED else "t0",
|
|
"provider": provider,
|
|
"model": active_model(),
|
|
"groq_configured": bool(GROQ_API_KEY),
|
|
"groq_base_url": _clean_env(GROQ_BASE_URL),
|
|
"kimi_configured": bool(KIMI_API_KEY),
|
|
"kimi_base_url": _clean_env(KIMI_BASE_URL),
|
|
"ollama": ollama_available(),
|
|
"ollama_url": OLLAMA_BASE_URL,
|
|
"ollama_model": AGENTIC_LLM_MODEL,
|
|
"embed_model": AGENTIC_EMBED_MODEL,
|
|
"fallback": AGENTIC_LLM_FALLBACK,
|
|
}
|
|
|
|
|
|
def ollama_available() -> bool:
|
|
try:
|
|
with httpx.Client(timeout=3.0) as c:
|
|
return c.get(f"{OLLAMA_BASE_URL}/api/tags").status_code == 200
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _openai_compatible_chat(
|
|
*,
|
|
base_url: str,
|
|
api_key: str,
|
|
model: str,
|
|
messages: list[dict],
|
|
max_tokens: int,
|
|
timeout: float,
|
|
prefix: str,
|
|
) -> tuple[str, str]:
|
|
try:
|
|
with httpx.Client(timeout=timeout) as c:
|
|
r = c.post(
|
|
f"{base_url.rstrip('/')}/chat/completions",
|
|
headers={
|
|
"Authorization": f"Bearer {api_key}",
|
|
"Content-Type": "application/json",
|
|
},
|
|
json={
|
|
"model": model,
|
|
"messages": messages,
|
|
"temperature": 0.3,
|
|
"max_tokens": max_tokens,
|
|
},
|
|
)
|
|
if r.status_code == 200:
|
|
data = r.json()
|
|
choice = (data.get("choices") or [{}])[0]
|
|
msg = choice.get("message") or {}
|
|
txt = (msg.get("content") or "").strip()
|
|
if txt:
|
|
return txt, model
|
|
if r.status_code == 429:
|
|
return ("", f"{prefix}-quota")
|
|
return ("", f"{prefix}-error-{r.status_code}")
|
|
except Exception:
|
|
return ("", f"{prefix}-error")
|
|
|
|
|
|
def _groq_chat(messages: list[dict], *, max_tokens: int = 1200) -> tuple[str, str]:
|
|
if not GROQ_API_KEY:
|
|
return ("", "groq-unconfigured")
|
|
model = _clean_env(GROQ_MODEL) or "llama-3.1-8b-instant"
|
|
base = _clean_env(GROQ_BASE_URL) or "https://api.groq.com/openai/v1"
|
|
return _openai_compatible_chat(
|
|
base_url=base,
|
|
api_key=GROQ_API_KEY,
|
|
model=model,
|
|
messages=messages,
|
|
max_tokens=max_tokens,
|
|
timeout=30.0,
|
|
prefix="groq",
|
|
)
|
|
|
|
|
|
def _kimi_chat(messages: list[dict], *, max_tokens: int = 1200) -> tuple[str, str]:
|
|
if not KIMI_API_KEY:
|
|
return ("", "kimi-unconfigured")
|
|
model = _clean_env(KIMI_MODEL) or "kimi-k2.5"
|
|
base = _clean_env(KIMI_BASE_URL) or "https://api.moonshot.ai/v1"
|
|
return _openai_compatible_chat(
|
|
base_url=base,
|
|
api_key=KIMI_API_KEY,
|
|
model=model,
|
|
messages=messages,
|
|
max_tokens=max_tokens,
|
|
timeout=45.0,
|
|
prefix="kimi",
|
|
)
|
|
|
|
|
|
def _ollama_chat(messages: list[dict], *, max_tokens: int = 800) -> tuple[str, str]:
|
|
if not ollama_available():
|
|
return ("", "ollama-offline")
|
|
try:
|
|
with httpx.Client(timeout=120.0) as c:
|
|
r = c.post(
|
|
f"{OLLAMA_BASE_URL}/api/chat",
|
|
json={"model": AGENTIC_LLM_MODEL, "messages": messages, "stream": False},
|
|
)
|
|
if r.status_code == 200:
|
|
txt = (r.json().get("message") or {}).get("content", "").strip()
|
|
if txt:
|
|
return txt, AGENTIC_LLM_MODEL
|
|
except Exception:
|
|
pass
|
|
return ("", "ollama-error")
|
|
|
|
|
|
def _try_fallback(messages: list[dict], *, max_tokens: int, skip: str) -> tuple[str, str]:
|
|
order = []
|
|
if AGENTIC_LLM_FALLBACK == "ollama" and skip != "ollama":
|
|
order.append("ollama")
|
|
if skip != "groq" and GROQ_API_KEY:
|
|
order.append("groq")
|
|
if skip != "kimi" and KIMI_API_KEY:
|
|
order.append("kimi")
|
|
for fb in order:
|
|
if fb == "ollama":
|
|
txt, model = _ollama_chat(messages, max_tokens=max_tokens)
|
|
elif fb == "groq":
|
|
txt, model = _groq_chat(messages, max_tokens=max_tokens)
|
|
else:
|
|
txt, model = _kimi_chat(messages, max_tokens=max_tokens)
|
|
if txt:
|
|
return txt, f"{fb}:{model}"
|
|
return ("", "t0-fallback")
|
|
|
|
|
|
def _llm_chat(messages: list[dict], *, max_tokens: int = 2000) -> tuple[str, str]:
|
|
if not AGENTIC_LLM_ENABLED:
|
|
return ("", "t0")
|
|
provider = resolve_provider()
|
|
dispatch = {
|
|
"groq": _groq_chat,
|
|
"kimi": _kimi_chat,
|
|
"ollama": _ollama_chat,
|
|
}
|
|
fn = dispatch.get(provider)
|
|
if not fn:
|
|
return ("", "t0")
|
|
txt, model = fn(messages, max_tokens=max_tokens)
|
|
if txt:
|
|
return txt, f"{provider}:{model.split(':')[-1] if ':' in model else model}"
|
|
if model.endswith("-quota"):
|
|
return ("", model)
|
|
return _try_fallback(messages, max_tokens=max_tokens, skip=provider)
|
|
|
|
|
|
def _openai_compatible_stream(
|
|
*,
|
|
base_url: str,
|
|
api_key: str,
|
|
model: str,
|
|
messages: list[dict],
|
|
max_tokens: int,
|
|
timeout: float,
|
|
) -> Iterator[str]:
|
|
try:
|
|
with httpx.Client(timeout=timeout) as c:
|
|
with c.stream(
|
|
"POST",
|
|
f"{base_url.rstrip('/')}/chat/completions",
|
|
headers={
|
|
"Authorization": f"Bearer {api_key}",
|
|
"Content-Type": "application/json",
|
|
},
|
|
json={
|
|
"model": model,
|
|
"messages": messages,
|
|
"temperature": 0.5,
|
|
"max_tokens": max_tokens,
|
|
"stream": True,
|
|
},
|
|
) as r:
|
|
if r.status_code != 200:
|
|
return
|
|
for line in r.iter_lines():
|
|
if not line or not line.startswith("data: "):
|
|
continue
|
|
payload = line[6:].strip()
|
|
if payload == "[DONE]":
|
|
break
|
|
try:
|
|
data = json.loads(payload)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
delta = (data.get("choices") or [{}])[0].get("delta") or {}
|
|
txt = delta.get("content") or ""
|
|
if txt:
|
|
yield txt
|
|
except Exception:
|
|
return
|
|
|
|
|
|
def _groq_stream(messages: list[dict], *, max_tokens: int = 1200) -> Iterator[str]:
|
|
if not GROQ_API_KEY:
|
|
return
|
|
model = _clean_env(GROQ_MODEL) or "llama-3.1-8b-instant"
|
|
base = _clean_env(GROQ_BASE_URL) or "https://api.groq.com/openai/v1"
|
|
yield from _openai_compatible_stream(
|
|
base_url=base,
|
|
api_key=GROQ_API_KEY,
|
|
model=model,
|
|
messages=messages,
|
|
max_tokens=max_tokens,
|
|
timeout=60.0,
|
|
)
|
|
|
|
|
|
def _kimi_stream(messages: list[dict], *, max_tokens: int = 1200) -> Iterator[str]:
|
|
if not KIMI_API_KEY:
|
|
return
|
|
model = _clean_env(KIMI_MODEL) or "kimi-k2.5"
|
|
base = _clean_env(KIMI_BASE_URL) or "https://api.moonshot.ai/v1"
|
|
yield from _openai_compatible_stream(
|
|
base_url=base,
|
|
api_key=KIMI_API_KEY,
|
|
model=model,
|
|
messages=messages,
|
|
max_tokens=max_tokens,
|
|
timeout=60.0,
|
|
)
|
|
|
|
|
|
def _ollama_stream(messages: list[dict], *, max_tokens: int = 800) -> Iterator[str]:
|
|
if not ollama_available():
|
|
return
|
|
try:
|
|
with httpx.Client(timeout=120.0) as c:
|
|
with c.stream(
|
|
"POST",
|
|
f"{OLLAMA_BASE_URL}/api/chat",
|
|
json={"model": AGENTIC_LLM_MODEL, "messages": messages, "stream": True},
|
|
) as r:
|
|
for line in r.iter_lines():
|
|
if not line:
|
|
continue
|
|
try:
|
|
data = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
chunk = (data.get("message") or {}).get("content") or ""
|
|
if chunk:
|
|
yield chunk
|
|
if data.get("done"):
|
|
break
|
|
except Exception:
|
|
return
|
|
|
|
|
|
def stream_llm_chat(messages: list[dict], *, max_tokens: int = 2000) -> tuple[Iterator[str], str]:
|
|
"""Returns (token iterator, provider label). Falls back to burst if stream empty."""
|
|
if not AGENTIC_LLM_ENABLED:
|
|
return iter(()), "t0"
|
|
|
|
provider = resolve_provider()
|
|
dispatch = {"groq": _groq_stream, "kimi": _kimi_stream, "ollama": _ollama_stream}
|
|
fn = dispatch.get(provider)
|
|
if not fn:
|
|
return iter(()), "t0"
|
|
|
|
def _gen():
|
|
got = False
|
|
for tok in fn(messages, max_tokens=max_tokens):
|
|
got = True
|
|
yield tok
|
|
if not got:
|
|
txt, model = _llm_chat(messages, max_tokens=max_tokens)
|
|
if txt:
|
|
yield txt
|
|
|
|
label = f"{provider}:{active_model()}"
|
|
return _gen(), label
|
|
|
|
|
|
def build_chat_messages(
|
|
*,
|
|
question: str,
|
|
kb_snippets: list[str] | None = None,
|
|
findings_summary: str | None = None,
|
|
ops_context: str | None = None,
|
|
user_role: str = "technician",
|
|
target_agent: str = "A6",
|
|
agent_name: str = "Copiloto",
|
|
agent_role: str = "Assistência tickets e janela humana",
|
|
history_messages: list[dict] | None = None,
|
|
) -> list[dict]:
|
|
from app.agents.chat_context_builder import RESPONSE_FORMAT_INSTRUCTION
|
|
|
|
ctx_block = ops_context or ""
|
|
if not ctx_block:
|
|
ctx_parts = [f"Operador: {user_role} (Ligbox Desk)"]
|
|
if findings_summary:
|
|
ctx_parts.append(f"Findings abertos:\n{findings_summary[:2000]}")
|
|
if kb_snippets:
|
|
ctx_parts.append("Base de conhecimento relevante:\n" + "\n---\n".join(kb_snippets[:6])[:4000])
|
|
ctx_block = "\n\n".join(ctx_parts)
|
|
|
|
system = (
|
|
f"Você é **{agent_name}** ({target_agent}) — {agent_role}.\n"
|
|
"Actua como **investigador DevOps sénior** no ecossistema Ligbox, não como FAQ genérico.\n\n"
|
|
"## Missão\n"
|
|
"Analisar o contexto operacional real, formular hipóteses fundamentadas e propor "
|
|
"**acções concretas** que o operador possa executar agora.\n\n"
|
|
"## Dados operacionais injectados\n"
|
|
f"{ctx_block}\n\n"
|
|
f"{RESPONSE_FORMAT_INSTRUCTION}"
|
|
)
|
|
messages: list[dict] = [{"role": "system", "content": system}]
|
|
for hm in history_messages or []:
|
|
role = hm.get("role")
|
|
content = (hm.get("content") or "").strip()
|
|
if role in ("user", "assistant") and content:
|
|
messages.append({"role": role, "content": content})
|
|
messages.append({
|
|
"role": "user",
|
|
"content": (
|
|
f"Pergunta do operador ({user_role}):\n{question}\n\n"
|
|
"Responda no formato investigativo obrigatório. "
|
|
"Use os incidentes/findings/cenários do contexto quando relevantes."
|
|
),
|
|
})
|
|
return messages
|
|
|
|
|
|
def advise_human_action(
|
|
*, finding_title: str, finding_detail: str, kb_snippets: list[str] | None = None
|
|
) -> tuple[str, str]:
|
|
kb = "---".join(kb_snippets or [])[:2500] or "N/A"
|
|
messages = [
|
|
{
|
|
"role": "system",
|
|
"content": "Advisor Agentic Ops Ligbox. Português BR, máx 6 frases. O que fazer AGORA?",
|
|
},
|
|
{
|
|
"role": "user",
|
|
"content": f"Problema: {finding_title}\nDetalhe: {finding_detail}\nKB: {kb}",
|
|
},
|
|
]
|
|
txt, model = _llm_chat(messages, max_tokens=400)
|
|
if txt:
|
|
return txt, model
|
|
return (f"Investigar manualmente: {finding_title}", "t0")
|
|
|
|
|
|
def chat_context(
|
|
*,
|
|
question: str,
|
|
kb_snippets: list[str] | None = None,
|
|
findings_summary: str | None = None,
|
|
ops_context: str | None = None,
|
|
user_role: str = "technician",
|
|
target_agent: str = "A6",
|
|
agent_name: str = "Copiloto",
|
|
agent_role: str = "Assistência tickets e janela humana",
|
|
history: str | None = None,
|
|
history_messages: list[dict] | None = None,
|
|
) -> tuple[str, str]:
|
|
# Legacy string history → single user turn if no structured history
|
|
hist_msgs = list(history_messages or [])
|
|
if not hist_msgs and history:
|
|
hist_msgs = [{"role": "user", "content": f"[Histórico resumido]\n{history[:3000]}"}]
|
|
|
|
messages = build_chat_messages(
|
|
question=question,
|
|
kb_snippets=kb_snippets,
|
|
findings_summary=findings_summary,
|
|
ops_context=ops_context,
|
|
user_role=user_role,
|
|
target_agent=target_agent,
|
|
agent_name=agent_name,
|
|
agent_role=agent_role,
|
|
history_messages=hist_msgs,
|
|
)
|
|
txt, model = _llm_chat(messages, max_tokens=1200)
|
|
if txt:
|
|
return txt, model
|
|
if model == "groq-quota":
|
|
return (
|
|
"Quota Groq esgotada (free tier). Tente amanhã ou use fallback Ollama local.",
|
|
"groq-quota",
|
|
)
|
|
if model == "kimi-quota":
|
|
return (
|
|
"Conta KIMI sem saldo. Groq/Ollama disponíveis como alternativa.",
|
|
"kimi-quota",
|
|
)
|
|
return (
|
|
"Modo T0 activo — LLM indisponível. Consulte findings e audit log no painel Agentic Ops.",
|
|
"t0",
|
|
)
|