Async chat processing eliminates 504 timeouts - POST returns immediately, backend processes in background, frontend polls for results with timestamps. Scale to ~12 simultaneous chats via 8 uvicorn workers + 16-thread executor. Parallelized MCP data collection, 2h session cache, 5min tool timeout. Full dead code cleanup across backend, frontend, MCP, docker, and nginx.
2723 lines
146 KiB
Python
2723 lines
146 KiB
Python
"""
|
||
OCI CIS AI Agent - Backend API v1.1
|
||
FastAPI with JWT auth, TOTP MFA, RBAC, OCI GenAI (exact SDK pattern),
|
||
OCI Account Explorer, MCP Server registry with VectorDB tool integration,
|
||
Autonomous DB vector storage, CIS reports, chat agent, audit log.
|
||
"""
|
||
import os, json, uuid, hashlib, hmac, time, base64, struct, secrets, subprocess
|
||
import shutil, asyncio, sqlite3, logging, re, concurrent.futures
|
||
from datetime import datetime, timedelta
|
||
from pathlib import Path
|
||
from typing import Optional, List, Dict, Any
|
||
from contextlib import contextmanager
|
||
from functools import partial
|
||
|
||
from fastapi import (
|
||
FastAPI, HTTPException, Depends, Request, UploadFile, File, Form,
|
||
Query, BackgroundTasks
|
||
)
|
||
from fastapi.middleware.cors import CORSMiddleware
|
||
from fastapi.responses import JSONResponse, FileResponse
|
||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||
from pydantic import BaseModel
|
||
import jwt as pyjwt
|
||
|
||
# ── Config ────────────────────────────────────────────────────────────────────
|
||
APP_SECRET = os.environ.get("APP_SECRET", secrets.token_hex(32))
|
||
JWT_ALG = "HS256"
|
||
JWT_EXP_H = int(os.environ.get("JWT_EXPIRY_HOURS", "12"))
|
||
DATA = Path(os.environ.get("DATA_DIR", "/data"))
|
||
DB_PATH = DATA / "agent.db"
|
||
OCI_DIR = DATA / "oci_configs"
|
||
REPORTS = DATA / "reports"
|
||
MCP_DIR = DATA / "mcp_servers"
|
||
WALLET_DIR = DATA / "wallets"
|
||
VERSION = "1.1"
|
||
|
||
for d in [DATA, OCI_DIR, REPORTS, MCP_DIR, WALLET_DIR]:
|
||
d.mkdir(parents=True, exist_ok=True)
|
||
|
||
_running_reports: dict[str, asyncio.subprocess.Process] = {} # rid → subprocess
|
||
_chat_executor = concurrent.futures.ThreadPoolExecutor(max_workers=16, thread_name_prefix="chat")
|
||
|
||
# ── Chat Memory Compaction Settings ──
|
||
COMPACT_TOKEN_THRESHOLD = 8000 # estimated tokens before triggering compaction
|
||
COMPACT_KEEP_RECENT = 6 # recent messages to keep uncompacted
|
||
COMPACT_SUMMARY_MAX_TOKENS = 1000
|
||
COMPACT_MIN_MESSAGES = 6
|
||
|
||
logging.basicConfig(level=logging.INFO)
|
||
log = logging.getLogger("agent")
|
||
|
||
app = FastAPI(title="OCI CIS AI Agent", version=VERSION)
|
||
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True,
|
||
allow_methods=["*"], allow_headers=["*"])
|
||
security = HTTPBearer()
|
||
|
||
# ── OCI GenAI Models Catalog ──────────────────────────────────────────────────
|
||
# OCIDs are region-specific; "ocids" maps genai_region → OCID.
|
||
# _call_genai resolves the OCID for the configured region at runtime.
|
||
GENAI_MODELS = {
|
||
# ── Meta ──
|
||
"meta.llama-4-maverick-17b-128e-instruct-fp8": {"provider":"meta","name":"Meta Llama 4 Maverick","api_format":"GENERIC",
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceyah6tjdejjashngznsylutuhhvufukzb2g2ls54g2flsfq"}},
|
||
"meta.llama-4-scout-17b-16e-instruct": {"provider":"meta","name":"Meta Llama 4 Scout","api_format":"GENERIC",
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceyaw23hfc7mtvv5wef3gwvvaguyzqmhb5lx4r5s3y2xzc4a"}},
|
||
"meta.llama-guard-4-12b": {"provider":"meta","name":"Meta Llama Guard 4 (12B)","api_format":"GENERIC",
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceyab5ggfxf4zs33lb5skxemyudnfxangjl4557toy3yapea"}},
|
||
# ── Google ──
|
||
"google.gemini-2.5-pro": {"provider":"google","name":"Google Gemini 2.5 Pro","api_format":"GENERIC",
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceyargceyuaysrjzo2metq2rinavayxqmpu7tkm6mmfojcvq"}},
|
||
"google.gemini-2.5-flash": {"provider":"google","name":"Google Gemini 2.5 Flash","api_format":"GENERIC",
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceyaeo4ehrn25guuats5s45hnvswlhxo6riop275l2bkr2vq"}},
|
||
"google.gemini-2.5-flash-lite": {"provider":"google","name":"Google Gemini 2.5 Flash-Lite","api_format":"GENERIC",
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceyaqk3p6ljepyguyo4ff5dwjw3ecij3x4l2j32e3gz66wtq"}},
|
||
# ── OpenAI ──
|
||
"openai.gpt-5.3-codex": {"provider":"openai","name":"OpenAI GPT-5.3 Codex","api_format":"GENERIC",
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceyayatqadc4zh74l6mh7sb3sb5olk7jnhq62bfnxgjwfb5a"}},
|
||
"openai.gpt-5.2-codex": {"provider":"openai","name":"OpenAI GPT-5.2 Codex","api_format":"GENERIC",
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceyarqt3ngs42jvevvgunlvkb2ksxlnotqymbm4duy3phy4q"}},
|
||
"openai.gpt-5.2": {"provider":"openai","name":"OpenAI GPT-5.2","api_format":"GENERIC",
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceya4fw3p5fddnexbfcurnz7spgkqb4mq4a6y5ubyv7777sa"}},
|
||
"openai.gpt-5.2-chat-latest": {"provider":"openai","name":"OpenAI GPT-5.2 Chat Latest","api_format":"GENERIC",
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceyafvkojauwkg3b6nps2rogu5lmigedrkel65j6qtk7l2uq"}},
|
||
"openai.gpt-5.1-codex-max": {"provider":"openai","name":"OpenAI GPT-5.1 Codex Max","api_format":"GENERIC",
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceyaqy3hrasco26ocvumkr5canmnzkvkhgoyw6ntyvyeamrq"}},
|
||
"openai.gpt-5.1-codex": {"provider":"openai","name":"OpenAI GPT-5.1 Codex","api_format":"GENERIC",
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceyar76rnxb66b4bkhlpn62jdffjedmeijbbh3h3v4e6xrxa"}},
|
||
"openai.gpt-5.1-codex-mini": {"provider":"openai","name":"OpenAI GPT-5.1 Codex Mini","api_format":"GENERIC",
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceyashim6rmq4irtdxw5osv4flw6ueggq5sppyzmv3qw7tha"}},
|
||
"openai.gpt-5.1": {"provider":"openai","name":"OpenAI GPT-5.1","api_format":"GENERIC",
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceya3darth2ozqcfssb2bats5jitpgigllccajasdyqljnkq"}},
|
||
"openai.gpt-5.1-chat-latest": {"provider":"openai","name":"OpenAI GPT-5.1 Chat Latest","api_format":"GENERIC",
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceyavtwm6mlbmpmkkvejoprbt5cpflx2bq66lgy6yr2hxsba"}},
|
||
"openai.gpt-5-codex": {"provider":"openai","name":"OpenAI GPT-5 Codex","api_format":"GENERIC",
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceyasddbvtsh44glecj4bbyghxdbjabmvb76pvrkjefeqgpa"}},
|
||
"openai.gpt-5": {"provider":"openai","name":"OpenAI GPT-5","api_format":"GENERIC",
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceyathaswupt2vqykuxzinzbu776zpydos343fokoddyywma"}},
|
||
"openai.gpt-5-mini": {"provider":"openai","name":"OpenAI GPT-5 Mini","api_format":"GENERIC",
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceya3eain4n6v3edm4ryjvze5hnjouujd4vralxntfalwjaq"}},
|
||
"openai.gpt-5-nano": {"provider":"openai","name":"OpenAI GPT-5 Nano","api_format":"GENERIC",
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceyactz4ikvxbjchjlkdw4jtyftsopfwk6prltzarznudqxq"}},
|
||
"openai.gpt-4.1": {"provider":"openai","name":"OpenAI GPT-4.1","api_format":"GENERIC",
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceyaa6g75r2qqmtzjaooqtlxv4lxkpcqp2jdd6plpq7yq7ea"}},
|
||
"openai.gpt-4.1-mini": {"provider":"openai","name":"OpenAI GPT-4.1 Mini","api_format":"GENERIC",
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceya6pk3sxishpiexm2rb5sf4ytb5tsbz4to2g3g23smidaa"}},
|
||
"openai.gpt-4.1-nano": {"provider":"openai","name":"OpenAI GPT-4.1 Nano","api_format":"GENERIC",
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceyacxqiaijwxalbynhst6oyg4wttzagz3dai4y2rnwh6wrq"}},
|
||
"openai.gpt-4o": {"provider":"openai","name":"OpenAI GPT-4o","api_format":"GENERIC",
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceyah7slrtboxdbfdy5cdspsfts62yumoclpdgwydopse7za"}},
|
||
"openai.gpt-4o-mini": {"provider":"openai","name":"OpenAI GPT-4o Mini","api_format":"GENERIC",
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceya5jba2auifbnsmrfjqmigut4aq6gei4kxyfqmwsyjo54a"}},
|
||
"openai.gpt-4o-mini-search-preview": {"provider":"openai","name":"OpenAI GPT-4o Mini Search Preview","api_format":"GENERIC",
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceyauin4xlovatfjhxp67e3pxfmjp26sxide2yobsnxn5xyq"}},
|
||
"openai.gpt-4o-search-preview": {"provider":"openai","name":"OpenAI GPT-4o Search Preview","api_format":"GENERIC",
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceyar2iwiwsqlrrdw5wmppchuz56nmkrp2soyohx3boc37mq"}},
|
||
"openai.gpt-image-1.5": {"provider":"openai","name":"OpenAI GPT Image 1.5","api_format":"GENERIC",
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceyauvxywwfyxnqouoltmp2oe5srzspxnnflmxpkznbwgaea"}},
|
||
"openai.gpt-image-1": {"provider":"openai","name":"OpenAI GPT Image 1","api_format":"GENERIC",
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceyaykmvlptihcu4iumplkemkp3u3mcw7atypidcpfpminoq"}},
|
||
"openai.gpt-audio": {"provider":"openai","name":"OpenAI GPT Audio","api_format":"GENERIC",
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceyabnxjhfcolefqhio7twnnqvkxbkphpy6f5h2mo4xqkdvq"}},
|
||
"openai.o4-mini": {"provider":"openai","name":"OpenAI o4-mini","api_format":"GENERIC",
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceya5ivfqp4fxlajoeg2cahlqtuuswagiv6a7dggpigy23bq"}},
|
||
"openai.o3": {"provider":"openai","name":"OpenAI o3","api_format":"GENERIC",
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceyalgnrukpjk6wm5zsf4jzkoneahgswhrk7kukkoagwnzma"}},
|
||
"openai.o3-mini": {"provider":"openai","name":"OpenAI o3-mini","api_format":"GENERIC",
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceyayrbuyw3robmloay76lh7j2l3mk65aoy64mhcgmwzn5yq"}},
|
||
"openai.o1": {"provider":"openai","name":"OpenAI o1","api_format":"GENERIC",
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceya674efirmykrx77pde5ftnuihvpn45vyhn2ecj6dnl5ca"}},
|
||
"openai.gpt-oss-120b": {"provider":"openai","name":"OpenAI GPT-oss (120B)","api_format":"GENERIC",
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceyachl5cngrvo4tna3bj3yqqtfvgtjarbgidmy76z7ojy6a"}},
|
||
"openai.gpt-oss-20b": {"provider":"openai","name":"OpenAI GPT-oss (20B)","api_format":"GENERIC",
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceyaillcx47jg3axfefkaruzmcvmse66t4nmoxuwpaqbf4kq"}},
|
||
# ── xAI ──
|
||
"xai.grok-4": {"provider":"xai","name":"xAI Grok 4","api_format":"GENERIC",
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceyaldmhg25is4nouena4oa2pj4nvwgfeempo4syiaazukia"}},
|
||
"xai.grok-4-1-fast-reasoning": {"provider":"xai","name":"xAI Grok 4.1 Fast Reasoning","api_format":"GENERIC",
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceyaocp3cooe7jkje7irb7dsrspgobjtkxakutz76gjo3k3a"}},
|
||
"xai.grok-4-1-fast-non-reasoning": {"provider":"xai","name":"xAI Grok 4.1 Fast Non-Reasoning","api_format":"GENERIC",
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceyaklmfkhxw5lwo5ifhqo2dxu5ymbeyzdr4nnpv3paigsoq"}},
|
||
"xai.grok-4-fast-reasoning": {"provider":"xai","name":"xAI Grok 4 Fast Reasoning","api_format":"GENERIC",
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceyamqkpviarwzvo3wwuhnhqloa224fneaukac5megifuaba"}},
|
||
"xai.grok-4-fast-non-reasoning": {"provider":"xai","name":"xAI Grok 4 Fast Non-Reasoning","api_format":"GENERIC",
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceyaj2m67qex5dzgi7gi5blfdztnvbwggvibaxi6ac3jthha"}},
|
||
"xai.grok-3": {"provider":"xai","name":"xAI Grok 3","api_format":"GENERIC",
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceyag3w2xk76vlahjujj2gdfeuzhflt25gbo3bxidlsqfjla"}},
|
||
"xai.grok-3-mini": {"provider":"xai","name":"xAI Grok 3 Mini","api_format":"GENERIC",
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceyar77g5dtex3loxuluziukznryr7kowvncea33ml7vrlda"}},
|
||
"xai.grok-3-fast": {"provider":"xai","name":"xAI Grok 3 Fast","api_format":"GENERIC",
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceyazdu5a5ruzfds34kwb2zwc65p6fes5uwddmkq7dyisvxq"}},
|
||
"xai.grok-3-mini-fast": {"provider":"xai","name":"xAI Grok 3 Mini Fast","api_format":"GENERIC",
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceyauvjoll2repj5pbtkk7pinwj57ex3lkehzpxd6v6rxscq"}},
|
||
"xai.grok-code-fast-1": {"provider":"xai","name":"xAI Grok Code Fast 1","api_format":"GENERIC",
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceyaayp5ccahfbpht56x7meyc26gyvojowmrbrr4xj626enq"}},
|
||
# ── ProtectAI ──
|
||
"protectai.deberta-v3-base-prompt-injection-v2": {"provider":"protectai","name":"ProtectAI DeBERTa Prompt Injection v2","api_format":"GENERIC",
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceyasj4kxlosqvmyaxu53lrc7iddmo2owc6bizoa2y5w47oq"}},
|
||
}
|
||
|
||
EMBEDDING_MODELS = {
|
||
"cohere.embed-v4.0": {"name":"Cohere Embed v4.0 (Multimodal)","dims":1536,
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceyahw4vlsxm7newcqtlgmristnwxlrxox3h7bcnlomjpgwa"}},
|
||
"openai.text-embedding-3-large": {"name":"OpenAI Text Embedding 3 Large","dims":3072,
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceya3i6o2p5h2mij6unya5bsyc46aqey5hwy3icncawo3vcq"}},
|
||
"openai.text-embedding-3-small": {"name":"OpenAI Text Embedding 3 Small","dims":1536,
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceyarjpjyniixp4tf7kyhr5bfajxmky4sjmbki2hp55ns2pq"}},
|
||
}
|
||
|
||
GENAI_REGIONS = [
|
||
"us-chicago-1","us-ashburn-1","us-phoenix-1","uk-london-1",
|
||
"eu-frankfurt-1","ap-tokyo-1","ap-osaka-1","sa-saopaulo-1",
|
||
"ca-toronto-1","ap-melbourne-1","ap-mumbai-1","eu-amsterdam-1",
|
||
"me-jeddah-1","ap-singapore-1","ap-seoul-1","sa-vinhedo-1",
|
||
]
|
||
|
||
OCI_REGIONS = {
|
||
"Americas": [
|
||
"us-ashburn-1","us-phoenix-1","us-chicago-1","us-sanjose-1","us-saltlake-2",
|
||
"ca-toronto-1","ca-montreal-1",
|
||
"sa-saopaulo-1","sa-vinhedo-1","sa-bogota-1","sa-santiago-1","sa-valparaiso-1",
|
||
"mx-queretaro-1","mx-monterrey-1",
|
||
],
|
||
"Europe": [
|
||
"eu-frankfurt-1","eu-amsterdam-1","eu-zurich-1","eu-madrid-1","eu-marseille-1",
|
||
"eu-milan-1","eu-paris-1","eu-stockholm-1","eu-jovanovac-1","eu-dcc-rome-1",
|
||
"uk-london-1","uk-cardiff-1",
|
||
"il-jerusalem-1",
|
||
],
|
||
"Asia Pacific": [
|
||
"ap-tokyo-1","ap-osaka-1","ap-seoul-1","ap-chuncheon-1",
|
||
"ap-mumbai-1","ap-hyderabad-1",
|
||
"ap-melbourne-1","ap-sydney-1",
|
||
"ap-singapore-1","ap-singapore-2",
|
||
],
|
||
"Middle East & Africa": [
|
||
"me-jeddah-1","me-abudhabi-1","me-dubai-1","me-riyadh-1",
|
||
"af-johannesburg-1",
|
||
],
|
||
}
|
||
|
||
# ── Database ──────────────────────────────────────────────────────────────────
|
||
@contextmanager
|
||
def db():
|
||
conn = sqlite3.connect(str(DB_PATH), timeout=30, check_same_thread=False)
|
||
conn.row_factory = sqlite3.Row
|
||
conn.execute("PRAGMA journal_mode=WAL")
|
||
conn.execute("PRAGMA busy_timeout=30000")
|
||
conn.execute("PRAGMA foreign_keys=ON")
|
||
try:
|
||
yield conn
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
|
||
def init_db():
|
||
with db() as c:
|
||
c.executescript("""
|
||
CREATE TABLE IF NOT EXISTS users (
|
||
id TEXT PRIMARY KEY,
|
||
first_name TEXT NOT NULL,
|
||
last_name TEXT NOT NULL,
|
||
username TEXT UNIQUE NOT NULL,
|
||
email TEXT,
|
||
password_hash TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'viewer',
|
||
mfa_secret TEXT, mfa_enabled INTEGER DEFAULT 0,
|
||
is_active INTEGER DEFAULT 1,
|
||
created_at TEXT DEFAULT (datetime('now')),
|
||
last_login TEXT
|
||
);
|
||
CREATE TABLE IF NOT EXISTS sessions (
|
||
id TEXT PRIMARY KEY, user_id TEXT NOT NULL,
|
||
created_at TEXT DEFAULT (datetime('now')),
|
||
expires_at TEXT NOT NULL, is_active INTEGER DEFAULT 1
|
||
);
|
||
CREATE TABLE IF NOT EXISTS oci_configs (
|
||
id TEXT PRIMARY KEY, user_id TEXT NOT NULL,
|
||
tenancy_name TEXT NOT NULL, tenancy_ocid TEXT NOT NULL,
|
||
user_ocid TEXT NOT NULL, fingerprint TEXT NOT NULL,
|
||
region TEXT NOT NULL, key_file_path TEXT NOT NULL,
|
||
compartment_id TEXT,
|
||
created_at TEXT DEFAULT (datetime('now'))
|
||
);
|
||
CREATE TABLE IF NOT EXISTS genai_configs (
|
||
id TEXT PRIMARY KEY, user_id TEXT NOT NULL,
|
||
name TEXT NOT NULL DEFAULT 'default',
|
||
oci_config_id TEXT NOT NULL,
|
||
model_id TEXT NOT NULL,
|
||
model_ocid TEXT,
|
||
compartment_id TEXT NOT NULL,
|
||
genai_region TEXT NOT NULL,
|
||
endpoint TEXT NOT NULL,
|
||
serving_type TEXT DEFAULT 'ON_DEMAND',
|
||
dedicated_endpoint_id TEXT,
|
||
temperature REAL DEFAULT 1,
|
||
max_tokens INTEGER DEFAULT 6000,
|
||
top_p REAL DEFAULT 0.95,
|
||
top_k INTEGER DEFAULT 1,
|
||
frequency_penalty REAL DEFAULT 0,
|
||
presence_penalty REAL DEFAULT 0,
|
||
is_default INTEGER DEFAULT 0,
|
||
system_prompt TEXT DEFAULT '',
|
||
created_at TEXT DEFAULT (datetime('now')),
|
||
FOREIGN KEY (oci_config_id) REFERENCES oci_configs(id)
|
||
);
|
||
CREATE TABLE IF NOT EXISTS reports (
|
||
id TEXT PRIMARY KEY, user_id TEXT NOT NULL,
|
||
tenancy_name TEXT NOT NULL, config_id TEXT,
|
||
level INTEGER DEFAULT 2,
|
||
obp_checks INTEGER DEFAULT 0,
|
||
raw_data INTEGER DEFAULT 0,
|
||
redact_output INTEGER DEFAULT 0,
|
||
status TEXT DEFAULT 'pending', progress TEXT DEFAULT '',
|
||
html_path TEXT, json_path TEXT,
|
||
created_at TEXT DEFAULT (datetime('now')),
|
||
completed_at TEXT, error_msg TEXT
|
||
);
|
||
CREATE TABLE IF NOT EXISTS report_files (
|
||
id TEXT PRIMARY KEY,
|
||
report_id TEXT NOT NULL,
|
||
file_name TEXT NOT NULL,
|
||
file_path TEXT NOT NULL,
|
||
file_type TEXT NOT NULL,
|
||
file_category TEXT NOT NULL,
|
||
file_size INTEGER DEFAULT 0
|
||
);
|
||
CREATE TABLE IF NOT EXISTS adb_vector_configs (
|
||
id TEXT PRIMARY KEY, user_id TEXT NOT NULL,
|
||
config_name TEXT NOT NULL,
|
||
dsn TEXT NOT NULL,
|
||
username TEXT NOT NULL,
|
||
password_enc TEXT NOT NULL,
|
||
wallet_dir TEXT,
|
||
wallet_password_enc TEXT,
|
||
table_name TEXT DEFAULT 'CIS_EMBEDDINGS',
|
||
use_mtls INTEGER DEFAULT 1,
|
||
is_active INTEGER DEFAULT 1,
|
||
created_at TEXT DEFAULT (datetime('now'))
|
||
);
|
||
CREATE TABLE IF NOT EXISTS adb_vector_tables (
|
||
id TEXT PRIMARY KEY,
|
||
adb_config_id TEXT NOT NULL,
|
||
table_name TEXT NOT NULL,
|
||
description TEXT DEFAULT '',
|
||
is_active INTEGER DEFAULT 1,
|
||
created_at TEXT DEFAULT (datetime('now')),
|
||
FOREIGN KEY (adb_config_id) REFERENCES adb_vector_configs(id) ON DELETE CASCADE,
|
||
UNIQUE(adb_config_id, table_name)
|
||
);
|
||
CREATE TABLE IF NOT EXISTS mcp_servers (
|
||
id TEXT PRIMARY KEY, user_id TEXT NOT NULL,
|
||
name TEXT NOT NULL, description TEXT,
|
||
server_type TEXT NOT NULL DEFAULT 'stdio',
|
||
command TEXT, args TEXT, env_vars TEXT,
|
||
url TEXT, module_path TEXT,
|
||
tools TEXT,
|
||
linked_adb_id TEXT,
|
||
is_active INTEGER DEFAULT 1,
|
||
created_at TEXT DEFAULT (datetime('now'))
|
||
);
|
||
CREATE TABLE IF NOT EXISTS chat_messages (
|
||
id TEXT PRIMARY KEY, session_id TEXT NOT NULL,
|
||
user_id TEXT NOT NULL, role TEXT NOT NULL,
|
||
content TEXT NOT NULL,
|
||
model_id TEXT,
|
||
created_at TEXT DEFAULT (datetime('now'))
|
||
);
|
||
CREATE TABLE IF NOT EXISTS chat_summaries (
|
||
id TEXT PRIMARY KEY,
|
||
session_id TEXT NOT NULL,
|
||
user_id TEXT NOT NULL,
|
||
summary TEXT NOT NULL,
|
||
messages_compacted INTEGER NOT NULL,
|
||
up_to_message_id TEXT NOT NULL,
|
||
created_at TEXT DEFAULT (datetime('now'))
|
||
);
|
||
CREATE TABLE IF NOT EXISTS audit_log (
|
||
id TEXT PRIMARY KEY, user_id TEXT, username TEXT,
|
||
action TEXT NOT NULL, resource TEXT, details TEXT,
|
||
ip TEXT, created_at TEXT DEFAULT (datetime('now'))
|
||
);
|
||
CREATE TABLE IF NOT EXISTS config_logs (
|
||
id TEXT PRIMARY KEY,
|
||
config_type TEXT NOT NULL,
|
||
config_id TEXT NOT NULL,
|
||
config_name TEXT,
|
||
severity TEXT NOT NULL,
|
||
action TEXT NOT NULL,
|
||
message TEXT NOT NULL,
|
||
user_id TEXT,
|
||
username TEXT,
|
||
created_at TEXT DEFAULT (datetime('now'))
|
||
);
|
||
CREATE TABLE IF NOT EXISTS app_settings (
|
||
key TEXT PRIMARY KEY,
|
||
value TEXT NOT NULL,
|
||
updated_at TEXT DEFAULT (datetime('now'))
|
||
);
|
||
CREATE TABLE IF NOT EXISTS system_prompts (
|
||
id TEXT PRIMARY KEY,
|
||
name TEXT NOT NULL,
|
||
agent TEXT NOT NULL DEFAULT 'chat',
|
||
content TEXT NOT NULL,
|
||
is_active INTEGER DEFAULT 0,
|
||
created_at TEXT DEFAULT (datetime('now'))
|
||
);
|
||
""")
|
||
c.execute("DELETE FROM config_logs WHERE created_at < datetime('now', '-30 days')")
|
||
# ── Migrations ──
|
||
for col in ["system_prompt TEXT DEFAULT ''"]:
|
||
try:
|
||
c.execute(f"ALTER TABLE genai_configs ADD COLUMN {col}")
|
||
except sqlite3.OperationalError:
|
||
pass
|
||
for col in ["genai_config_id TEXT", "embedding_model_id TEXT DEFAULT 'cohere.embed-v4.0'"]:
|
||
try:
|
||
c.execute(f"ALTER TABLE adb_vector_configs ADD COLUMN {col}")
|
||
except sqlite3.OperationalError:
|
||
pass
|
||
for col in ["progress TEXT DEFAULT ''", "level INTEGER DEFAULT 2", "obp_checks INTEGER DEFAULT 0",
|
||
"raw_data INTEGER DEFAULT 0", "redact_output INTEGER DEFAULT 0"]:
|
||
try:
|
||
c.execute(f"ALTER TABLE reports ADD COLUMN {col}")
|
||
except sqlite3.OperationalError:
|
||
pass
|
||
for col in ["status TEXT DEFAULT 'done'"]:
|
||
try:
|
||
c.execute(f"ALTER TABLE chat_messages ADD COLUMN {col}")
|
||
except sqlite3.OperationalError:
|
||
pass
|
||
# Migrate legacy table_name from adb_vector_configs into adb_vector_tables
|
||
try:
|
||
for cfg_row in c.execute("SELECT id, table_name FROM adb_vector_configs WHERE table_name IS NOT NULL AND table_name != ''").fetchall():
|
||
if not c.execute("SELECT 1 FROM adb_vector_tables WHERE adb_config_id=? AND table_name=?", (cfg_row["id"], cfg_row["table_name"])).fetchone():
|
||
c.execute("INSERT INTO adb_vector_tables (id, adb_config_id, table_name, description) VALUES (?,?,?,?)",
|
||
(str(uuid.uuid4()), cfg_row["id"], cfg_row["table_name"], "Migrado automaticamente"))
|
||
except Exception:
|
||
pass
|
||
# Seed default system prompt if none exists for chat agent
|
||
if not c.execute("SELECT 1 FROM system_prompts WHERE agent='chat'").fetchone():
|
||
c.execute("INSERT INTO system_prompts (id,name,agent,content,is_active) VALUES (?,?,?,?,?)",
|
||
(str(uuid.uuid4()), "OCI CIS RAG Agent", "chat", RAG_DEFAULT_SYSTEM_PROMPT, 1))
|
||
adm = c.execute("SELECT id FROM users WHERE username='admin'").fetchone()
|
||
if not adm:
|
||
c.execute(
|
||
"INSERT INTO users (id,first_name,last_name,username,email,password_hash,role) VALUES (?,?,?,?,?,?,?)",
|
||
(str(uuid.uuid4()), "Admin", "Sistema", "admin", "admin@local", _hash_pw("admin123"), "admin")
|
||
)
|
||
log.info("Default admin created: admin / admin123")
|
||
|
||
# ── Crypto helpers ────────────────────────────────────────────────────────────
|
||
def _hash_pw(pw): salt=secrets.token_hex(16); h=hashlib.pbkdf2_hmac("sha256",pw.encode(),salt.encode(),100_000); return f"{salt}:{h.hex()}"
|
||
def _verify_pw(pw,stored): salt,h=stored.split(":"); return hmac.compare_digest(hashlib.pbkdf2_hmac("sha256",pw.encode(),salt.encode(),100_000).hex(),h)
|
||
def _totp_secret(): return base64.b32encode(secrets.token_bytes(20)).decode()
|
||
def _totp_verify(secret,code,window=1):
|
||
key=base64.b32decode(secret); now=int(time.time())//30
|
||
for off in range(-window,window+1):
|
||
msg=struct.pack(">Q",now+off); h=hmac.new(key,msg,hashlib.sha1).digest(); o=h[-1]&0x0F
|
||
c=str((struct.unpack(">I",h[o:o+4])[0]&0x7FFFFFFF)%1_000_000).zfill(6)
|
||
if hmac.compare_digest(c,code): return True
|
||
return False
|
||
def _totp_uri(secret,user): return f"otpauth://totp/OCI-CIS-Agent:{user}?secret={secret}&issuer=OCI-CIS-Agent"
|
||
def _make_token(uid,role,sid):
|
||
return pyjwt.encode({"sub":uid,"role":role,"sid":sid,"exp":datetime.utcnow()+timedelta(hours=JWT_EXP_H),"iat":datetime.utcnow()},APP_SECRET,algorithm=JWT_ALG)
|
||
def _enc(v): return base64.b64encode(v.encode()).decode()
|
||
def _dec(v): return base64.b64decode(v.encode()).decode()
|
||
def _mask(v, show=6):
|
||
"""Mask a sensitive value, showing only the last `show` characters."""
|
||
if not v or len(v) <= show:
|
||
return "•" * 8
|
||
return "•" * min(len(v) - show, 20) + v[-show:]
|
||
def _safe_dec(v):
|
||
"""Decrypt a value, returning as-is if not encrypted (migration compat)."""
|
||
if not v:
|
||
return v
|
||
try:
|
||
return _dec(v)
|
||
except Exception:
|
||
return v
|
||
|
||
# ── OCI SDK Client Helper ─────────────────────────────────────────────────────
|
||
def _get_oci_config(oci_config_id: str) -> dict:
|
||
import oci
|
||
config_path = str(OCI_DIR / oci_config_id / "config")
|
||
return oci.config.from_file(config_path, "DEFAULT")
|
||
|
||
# ── Auth deps ─────────────────────────────────────────────────────────────────
|
||
async def current_user(cred: HTTPAuthorizationCredentials = Depends(security)):
|
||
try: p = pyjwt.decode(cred.credentials, APP_SECRET, algorithms=[JWT_ALG])
|
||
except pyjwt.ExpiredSignatureError: raise HTTPException(401, "Token expirado")
|
||
except pyjwt.InvalidTokenError: raise HTTPException(401, "Token inválido")
|
||
with db() as c:
|
||
u = c.execute("SELECT * FROM users WHERE id=? AND is_active=1", (p["sub"],)).fetchone()
|
||
s = c.execute("SELECT * FROM sessions WHERE id=? AND is_active=1", (p["sid"],)).fetchone()
|
||
if not u or not s: raise HTTPException(401, "Sessão inválida")
|
||
return dict(u)
|
||
|
||
def require(*roles):
|
||
async def dep(u=Depends(current_user)):
|
||
if u["role"] not in roles: raise HTTPException(403, f"Requer role: {', '.join(roles)}")
|
||
return u
|
||
return dep
|
||
|
||
def _audit(uid, uname, action, resource=None, details=None, ip=None):
|
||
with db() as c:
|
||
c.execute("INSERT INTO audit_log (id,user_id,username,action,resource,details,ip) VALUES (?,?,?,?,?,?,?)",
|
||
(str(uuid.uuid4()), uid, uname, action, resource, details, ip))
|
||
|
||
def _config_log(config_type, config_id, config_name, severity, action, message, uid=None, uname=None):
|
||
with db() as c:
|
||
c.execute("INSERT INTO config_logs (id,config_type,config_id,config_name,severity,action,message,user_id,username) VALUES (?,?,?,?,?,?,?,?,?)",
|
||
(str(uuid.uuid4()), config_type, config_id, config_name or "", severity, action, str(message)[:2000], uid, uname))
|
||
|
||
# ── Models ────────────────────────────────────────────────────────────────────
|
||
class LoginReq(BaseModel):
|
||
username: str; password: str; totp_code: Optional[str] = None
|
||
class RegisterReq(BaseModel):
|
||
first_name: str; last_name: str; username: str; email: str; password: str; role: str = "viewer"
|
||
class TOTPVerify(BaseModel):
|
||
totp_code: str
|
||
class ChangePwReq(BaseModel):
|
||
current_password: str; new_password: str
|
||
class UserUpdateReq(BaseModel):
|
||
email: Optional[str] = None; role: Optional[str] = None; is_active: Optional[bool] = None
|
||
class ChatMsg(BaseModel):
|
||
message: str; session_id: Optional[str] = None
|
||
# Option A: saved preset
|
||
genai_config_id: Optional[str] = None
|
||
# Option B: direct model selection (inline)
|
||
model_id: Optional[str] = None; oci_config_id: Optional[str] = None
|
||
genai_region: Optional[str] = None; compartment_id: Optional[str] = None
|
||
temperature: Optional[float] = None; max_tokens: Optional[int] = None
|
||
top_p: Optional[float] = None; top_k: Optional[int] = None
|
||
frequency_penalty: Optional[float] = None; presence_penalty: Optional[float] = None
|
||
use_tools: Optional[bool] = True
|
||
class RunReportReq(BaseModel):
|
||
config_id: str; regions: Optional[List[str]] = None
|
||
level: int = 2; obp: bool = False; raw: bool = False; redact_output: bool = False
|
||
class GenAIConfigReq(BaseModel):
|
||
name: str = "default"
|
||
oci_config_id: str; model_id: str; model_ocid: Optional[str] = None
|
||
compartment_id: str; genai_region: str
|
||
endpoint: Optional[str] = None
|
||
serving_type: str = "ON_DEMAND"; dedicated_endpoint_id: Optional[str] = None
|
||
temperature: float = 1; max_tokens: int = 6000; top_p: float = 0.95
|
||
top_k: int = 1; frequency_penalty: float = 0; presence_penalty: float = 0
|
||
is_default: bool = False
|
||
class IngestDocReq(BaseModel):
|
||
adb_config_id: str; documents: List[Dict[str, Any]]; table_name: Optional[str] = None
|
||
class MCPServerReq(BaseModel):
|
||
name: str; description: Optional[str] = None; server_type: str = "stdio"
|
||
command: Optional[str] = None; args: Optional[List[str]] = None
|
||
env_vars: Optional[Dict[str,str]] = None; url: Optional[str] = None
|
||
module_path: Optional[str] = None; tools: Optional[List[dict]] = None
|
||
linked_adb_id: Optional[str] = None
|
||
|
||
# ── Auth endpoints ────────────────────────────────────────────────────────────
|
||
@app.post("/api/auth/login")
|
||
async def login(req: LoginReq, request: Request):
|
||
with db() as c:
|
||
u = c.execute("SELECT * FROM users WHERE username=? AND is_active=1", (req.username,)).fetchone()
|
||
if not u or not _verify_pw(req.password, u["password_hash"]): raise HTTPException(401, "Credenciais inválidas")
|
||
u = dict(u)
|
||
if u["mfa_enabled"]:
|
||
if not req.totp_code: return {"mfa_required": True, "message": "Código MFA necessário"}
|
||
if not _totp_verify(u["mfa_secret"], req.totp_code): raise HTTPException(401, "Código MFA inválido")
|
||
sid = str(uuid.uuid4()); exp = (datetime.utcnow()+timedelta(hours=JWT_EXP_H)).isoformat()
|
||
with db() as c:
|
||
c.execute("INSERT INTO sessions (id,user_id,expires_at) VALUES (?,?,?)", (sid, u["id"], exp))
|
||
c.execute("UPDATE users SET last_login=datetime('now') WHERE id=?", (u["id"],))
|
||
_audit(u["id"], u["username"], "login", ip=request.client.host if request.client else None)
|
||
return {"token":_make_token(u["id"],u["role"],sid),
|
||
"user":{"id":u["id"],"first_name":u["first_name"],"last_name":u["last_name"],"username":u["username"],"email":u["email"],"role":u["role"],"mfa_enabled":bool(u["mfa_enabled"])}}
|
||
|
||
@app.post("/api/auth/logout")
|
||
async def logout_ep(u=Depends(current_user)):
|
||
with db() as c: c.execute("UPDATE sessions SET is_active=0 WHERE user_id=?", (u["id"],))
|
||
return {"ok": True}
|
||
|
||
@app.post("/api/auth/register")
|
||
async def register(req: RegisterReq, adm=Depends(require("admin"))):
|
||
if req.role not in ("admin","user","viewer"): raise HTTPException(400, "Role inválida")
|
||
uid = str(uuid.uuid4())
|
||
with db() as c:
|
||
if c.execute("SELECT 1 FROM users WHERE username=?", (req.username,)).fetchone(): raise HTTPException(400, "Usuário já existe")
|
||
c.execute("INSERT INTO users (id,first_name,last_name,username,email,password_hash,role) VALUES (?,?,?,?,?,?,?)",
|
||
(uid, req.first_name, req.last_name, req.username, req.email, _hash_pw(req.password), req.role))
|
||
_audit(adm["id"], adm["username"], "create_user", uid, f"user={req.username} role={req.role}")
|
||
return {"id": uid, "username": req.username, "role": req.role}
|
||
|
||
@app.post("/api/auth/change-password")
|
||
async def change_pw(req: ChangePwReq, u=Depends(current_user)):
|
||
if not _verify_pw(req.current_password, u["password_hash"]): raise HTTPException(400, "Senha atual incorreta")
|
||
with db() as c: c.execute("UPDATE users SET password_hash=? WHERE id=?", (_hash_pw(req.new_password), u["id"]))
|
||
return {"ok": True}
|
||
|
||
# ── MFA ───────────────────────────────────────────────────────────────────────
|
||
@app.post("/api/mfa/setup")
|
||
async def mfa_setup(u=Depends(current_user)):
|
||
sec = _totp_secret()
|
||
with db() as c: c.execute("UPDATE users SET mfa_secret=? WHERE id=?", (sec, u["id"]))
|
||
return {"secret": sec, "uri": _totp_uri(sec, u["username"])}
|
||
|
||
@app.post("/api/mfa/verify")
|
||
async def mfa_verify(req: TOTPVerify, u=Depends(current_user)):
|
||
if not u.get("mfa_secret"): raise HTTPException(400, "Chame /api/mfa/setup primeiro")
|
||
if not _totp_verify(u["mfa_secret"], req.totp_code): raise HTTPException(400, "Código inválido")
|
||
with db() as c: c.execute("UPDATE users SET mfa_enabled=1 WHERE id=?", (u["id"],))
|
||
return {"ok": True, "message": "MFA ativado"}
|
||
|
||
@app.post("/api/mfa/disable/{user_id}")
|
||
async def mfa_disable(user_id: str, adm=Depends(require("admin"))):
|
||
with db() as c: c.execute("UPDATE users SET mfa_enabled=0,mfa_secret=NULL WHERE id=?", (user_id,))
|
||
_audit(adm["id"], adm["username"], "disable_mfa", user_id)
|
||
return {"ok": True}
|
||
|
||
# ── Users ─────────────────────────────────────────────────────────────────────
|
||
@app.get("/api/users")
|
||
async def list_users(u=Depends(require("admin"))):
|
||
with db() as c: rows = c.execute("SELECT id,first_name,last_name,username,email,role,mfa_enabled,is_active,created_at,last_login FROM users").fetchall()
|
||
return [dict(r) for r in rows]
|
||
|
||
@app.get("/api/users/me")
|
||
async def me(u=Depends(current_user)):
|
||
return {k: u[k] for k in ("id","first_name","last_name","username","email","role","mfa_enabled")}
|
||
|
||
@app.put("/api/users/{uid}")
|
||
async def update_user(uid: str, req: UserUpdateReq, adm=Depends(require("admin"))):
|
||
sets, vals = [], []
|
||
if req.email is not None: sets.append("email=?"); vals.append(req.email)
|
||
if req.role is not None:
|
||
if req.role not in ("admin","user","viewer"): raise HTTPException(400, "Role inválida")
|
||
sets.append("role=?"); vals.append(req.role)
|
||
if req.is_active is not None: sets.append("is_active=?"); vals.append(int(req.is_active))
|
||
if sets:
|
||
vals.append(uid)
|
||
with db() as c: c.execute(f"UPDATE users SET {','.join(sets)} WHERE id=?", vals)
|
||
_audit(adm["id"], adm["username"], "update_user", uid)
|
||
return {"ok": True}
|
||
|
||
@app.delete("/api/users/{uid}")
|
||
async def del_user(uid: str, adm=Depends(require("admin"))):
|
||
if uid == adm["id"]: raise HTTPException(400, "Não pode desativar a si mesmo")
|
||
with db() as c: c.execute("UPDATE users SET is_active=0 WHERE id=?", (uid,))
|
||
_audit(adm["id"], adm["username"], "deactivate_user", uid)
|
||
return {"ok": True}
|
||
|
||
# ── OCI Config ────────────────────────────────────────────────────────────────
|
||
@app.post("/api/oci/config")
|
||
async def save_oci(
|
||
tenancy_name: str = Form(...), tenancy_ocid: str = Form(...),
|
||
user_ocid: str = Form(...), fingerprint: str = Form(...),
|
||
region: str = Form(...), compartment_id: str = Form(""),
|
||
key_passphrase: str = Form(""),
|
||
private_key: UploadFile = File(...), public_key: Optional[UploadFile] = File(None),
|
||
u = Depends(require("admin","user"))
|
||
):
|
||
cid = str(uuid.uuid4()); cdir = OCI_DIR / cid; cdir.mkdir(parents=True)
|
||
kp = cdir / "oci_api_key.pem"
|
||
key_bytes = await private_key.read()
|
||
kp.write_bytes(key_bytes); kp.chmod(0o600)
|
||
# Detect encrypted keys that require passphrase
|
||
key_text = key_bytes.decode("utf-8", errors="ignore")
|
||
key_is_encrypted = "ENCRYPTED" in key_text or "Proc-Type: 4,ENCRYPTED" in key_text
|
||
if key_is_encrypted and not key_passphrase:
|
||
# Clean up and warn
|
||
shutil.rmtree(cdir, ignore_errors=True)
|
||
raise HTTPException(400, "A chave privada está criptografada (ENCRYPTED). Informe a Key Passphrase.")
|
||
if public_key: (cdir / "oci_api_key_public.pem").write_bytes(await public_key.read())
|
||
cfg_file = cdir / "config"
|
||
cfg_content = (f"[DEFAULT]\nuser={user_ocid}\nfingerprint={fingerprint}\n"
|
||
f"tenancy={tenancy_ocid}\nregion={region}\nkey_file={kp}\n")
|
||
if key_passphrase:
|
||
cfg_content += f"pass_phrase={key_passphrase}\n"
|
||
cfg_file.write_text(cfg_content)
|
||
cfg_file.chmod(0o600)
|
||
with db() as c:
|
||
c.execute("INSERT INTO oci_configs (id,user_id,tenancy_name,tenancy_ocid,user_ocid,fingerprint,region,key_file_path,compartment_id) VALUES (?,?,?,?,?,?,?,?,?)",
|
||
(cid, u["id"], tenancy_name, _enc(tenancy_ocid), _enc(user_ocid), _enc(fingerprint), region, str(kp), _enc(compartment_id) if compartment_id else None))
|
||
_audit(u["id"], u["username"], "save_oci_config", cid, f"tenancy={tenancy_name}")
|
||
_config_log("oci", cid, tenancy_name, "success", "save", f"Credencial salva: {tenancy_name} ({region})", u["id"], u["username"])
|
||
return {"id": cid, "tenancy_name": tenancy_name, "region": region}
|
||
|
||
@app.get("/api/oci/configs")
|
||
async def list_oci(u=Depends(current_user)):
|
||
with db() as c:
|
||
cols = "id,user_id,tenancy_name,tenancy_ocid,user_ocid,fingerprint,region,compartment_id,created_at"
|
||
if u["role"]=="admin": rows=c.execute(f"SELECT {cols} FROM oci_configs").fetchall()
|
||
else: rows=c.execute(f"SELECT {cols} FROM oci_configs WHERE user_id=?",(u["id"],)).fetchall()
|
||
result = []
|
||
for r in rows:
|
||
d = dict(r)
|
||
# Decrypt and mask sensitive fields for display
|
||
try:
|
||
d["tenancy_ocid"] = _mask(_dec(d["tenancy_ocid"]))
|
||
except Exception:
|
||
d["tenancy_ocid"] = _mask(d["tenancy_ocid"])
|
||
try:
|
||
d["user_ocid"] = _mask(_dec(d["user_ocid"]))
|
||
except Exception:
|
||
d["user_ocid"] = _mask(d["user_ocid"])
|
||
d["fingerprint"] = "•" * 20 # fingerprint fully masked
|
||
if d.get("compartment_id"):
|
||
try:
|
||
d["compartment_id"] = _mask(_dec(d["compartment_id"]))
|
||
except Exception:
|
||
d["compartment_id"] = _mask(d["compartment_id"])
|
||
result.append(d)
|
||
return result
|
||
|
||
@app.delete("/api/oci/configs/{cid}")
|
||
async def del_oci(cid: str, u=Depends(require("admin","user"))):
|
||
with db() as c:
|
||
cfg=c.execute("SELECT * FROM oci_configs WHERE id=?",(cid,)).fetchone()
|
||
if not cfg: raise HTTPException(404)
|
||
if u["role"]!="admin" and cfg["user_id"]!=u["id"]: raise HTTPException(403)
|
||
c.execute("DELETE FROM oci_configs WHERE id=?",(cid,))
|
||
d=OCI_DIR/cid
|
||
if d.exists(): shutil.rmtree(d)
|
||
return {"ok": True}
|
||
|
||
@app.put("/api/oci/configs/{cid}")
|
||
async def update_oci(
|
||
cid: str,
|
||
tenancy_name: str = Form(...), tenancy_ocid: str = Form(""),
|
||
user_ocid: str = Form(""), fingerprint: str = Form(""),
|
||
region: str = Form(...), compartment_id: str = Form(""),
|
||
key_passphrase: str = Form(""),
|
||
private_key: Optional[UploadFile] = File(None), public_key: Optional[UploadFile] = File(None),
|
||
u = Depends(require("admin","user"))
|
||
):
|
||
with db() as c:
|
||
existing = c.execute("SELECT * FROM oci_configs WHERE id=?", (cid,)).fetchone()
|
||
if not existing: raise HTTPException(404)
|
||
if u["role"] != "admin" and existing["user_id"] != u["id"]: raise HTTPException(403)
|
||
# Keep existing encrypted values if not provided (empty = keep current)
|
||
tenancy_ocid = tenancy_ocid or _safe_dec(existing["tenancy_ocid"])
|
||
user_ocid = user_ocid or _safe_dec(existing["user_ocid"])
|
||
fingerprint = fingerprint or _safe_dec(existing["fingerprint"])
|
||
compartment_id = compartment_id or _safe_dec(existing["compartment_id"]) or ""
|
||
cdir = OCI_DIR / cid; cdir.mkdir(parents=True, exist_ok=True)
|
||
kp = cdir / "oci_api_key.pem"
|
||
if private_key and private_key.filename:
|
||
key_bytes = await private_key.read()
|
||
kp.write_bytes(key_bytes); kp.chmod(0o600)
|
||
key_text = key_bytes.decode("utf-8", errors="ignore")
|
||
if ("ENCRYPTED" in key_text or "Proc-Type: 4,ENCRYPTED" in key_text) and not key_passphrase:
|
||
raise HTTPException(400, "A chave privada está criptografada (ENCRYPTED). Informe a Key Passphrase.")
|
||
if public_key and public_key.filename:
|
||
(cdir / "oci_api_key_public.pem").write_bytes(await public_key.read())
|
||
cfg_file = cdir / "config"
|
||
cfg_content = (f"[DEFAULT]\nuser={user_ocid}\nfingerprint={fingerprint}\n"
|
||
f"tenancy={tenancy_ocid}\nregion={region}\nkey_file={kp}\n")
|
||
if key_passphrase:
|
||
cfg_content += f"pass_phrase={key_passphrase}\n"
|
||
cfg_file.write_text(cfg_content); cfg_file.chmod(0o600)
|
||
with db() as c:
|
||
c.execute("UPDATE oci_configs SET tenancy_name=?,tenancy_ocid=?,user_ocid=?,fingerprint=?,region=?,compartment_id=? WHERE id=?",
|
||
(tenancy_name, _enc(tenancy_ocid), _enc(user_ocid), _enc(fingerprint), region, _enc(compartment_id) if compartment_id else None, cid))
|
||
_audit(u["id"], u["username"], "update_oci_config", cid, f"tenancy={tenancy_name}")
|
||
_config_log("oci", cid, tenancy_name, "success", "save", f"Credencial atualizada: {tenancy_name} ({region})", u["id"], u["username"])
|
||
return {"id": cid, "tenancy_name": tenancy_name, "region": region}
|
||
|
||
@app.post("/api/oci/test/{cid}")
|
||
async def test_oci(cid: str, u=Depends(require("admin","user"))):
|
||
cp = OCI_DIR / cid / "config"
|
||
cname = None
|
||
with db() as c:
|
||
row = c.execute("SELECT tenancy_name FROM oci_configs WHERE id=?", (cid,)).fetchone()
|
||
if row: cname = row["tenancy_name"]
|
||
if not cp.exists():
|
||
_config_log("oci", cid, cname, "error", "test", "Config não encontrada", u["id"], u["username"])
|
||
return {"status":"error","message":"Config não encontrada"}
|
||
try:
|
||
r = subprocess.run(["oci","iam","region","list","--config-file",str(cp),"--output","json"],
|
||
capture_output=True, text=True, timeout=30, stdin=subprocess.DEVNULL)
|
||
if r.returncode == 0:
|
||
_config_log("oci", cid, cname, "success", "test", "Conexão OK", u["id"], u["username"])
|
||
return {"status":"success","message":"Conexão OK","data":json.loads(r.stdout)}
|
||
msg = r.stderr[:500]
|
||
if "passphrase" in msg.lower() or "getpass" in msg.lower():
|
||
msg = "A chave privada requer passphrase. Recadastre a credencial informando a Key Passphrase."
|
||
_config_log("oci", cid, cname, "error", "test", msg, u["id"], u["username"])
|
||
return {"status":"error","message":msg}
|
||
except FileNotFoundError:
|
||
_config_log("oci", cid, cname, "error", "test", "OCI CLI não disponível no container.", u["id"], u["username"])
|
||
return {"status":"error","message":"OCI CLI não disponível no container."}
|
||
except subprocess.TimeoutExpired:
|
||
_config_log("oci", cid, cname, "error", "test", "Timeout na conexão", u["id"], u["username"])
|
||
return {"status":"error","message":"Timeout na conexão"}
|
||
except Exception as e:
|
||
_config_log("oci", cid, cname, "error", "test", str(e)[:500], u["id"], u["username"])
|
||
return {"status":"error","message":str(e)[:500]}
|
||
|
||
# ── OCI Account Explorer ──────────────────────────────────────────────────────
|
||
@app.get("/api/oci/explore/{cid}/compartments")
|
||
async def explore_compartments(cid: str, u=Depends(current_user)):
|
||
try:
|
||
import oci
|
||
config = _get_oci_config(cid)
|
||
identity = oci.identity.IdentityClient(config)
|
||
with db() as c:
|
||
cfg = c.execute("SELECT tenancy_ocid,compartment_id FROM oci_configs WHERE id=?", (cid,)).fetchone()
|
||
root = _safe_dec(cfg["compartment_id"]) or _safe_dec(cfg["tenancy_ocid"])
|
||
comps = identity.list_compartments(root, compartment_id_in_subtree=True).data
|
||
return [{"id":cp.id,"name":cp.name,"lifecycle_state":cp.lifecycle_state,"description":cp.description or ""} for cp in comps if cp.lifecycle_state == "ACTIVE"]
|
||
except Exception as e:
|
||
return {"error": str(e)[:500]}
|
||
|
||
@app.get("/api/oci/explore/{cid}/regions")
|
||
async def explore_regions(cid: str, u=Depends(current_user)):
|
||
try:
|
||
import oci
|
||
config = _get_oci_config(cid)
|
||
identity = oci.identity.IdentityClient(config)
|
||
with db() as c:
|
||
cfg = c.execute("SELECT tenancy_ocid FROM oci_configs WHERE id=?", (cid,)).fetchone()
|
||
regions = identity.list_region_subscriptions(_safe_dec(cfg["tenancy_ocid"])).data
|
||
return [{"name":r.region_name,"key":r.region_key,"status":r.status,"is_home":r.is_home_region} for r in regions]
|
||
except Exception as e:
|
||
return {"error": str(e)[:500]}
|
||
|
||
@app.get("/api/oci/explore/{cid}/vcns")
|
||
async def explore_vcns(cid: str, compartment_id: str = Query(None), u=Depends(current_user)):
|
||
try:
|
||
import oci
|
||
config = _get_oci_config(cid)
|
||
vn = oci.core.VirtualNetworkClient(config)
|
||
with db() as c:
|
||
cfg = c.execute("SELECT tenancy_ocid,compartment_id FROM oci_configs WHERE id=?", (cid,)).fetchone()
|
||
comp = compartment_id or _safe_dec(cfg["compartment_id"]) or _safe_dec(cfg["tenancy_ocid"])
|
||
vcns = vn.list_vcns(comp).data
|
||
return [{"id":v.id,"display_name":v.display_name,"cidr_blocks":v.cidr_blocks,"lifecycle_state":v.lifecycle_state} for v in vcns]
|
||
except Exception as e:
|
||
return {"error": str(e)[:500]}
|
||
|
||
@app.get("/api/oci/explore/{cid}/instances")
|
||
async def explore_instances(cid: str, compartment_id: str = Query(None), u=Depends(current_user)):
|
||
try:
|
||
import oci
|
||
config = _get_oci_config(cid)
|
||
compute = oci.core.ComputeClient(config)
|
||
with db() as c:
|
||
cfg = c.execute("SELECT tenancy_ocid,compartment_id FROM oci_configs WHERE id=?", (cid,)).fetchone()
|
||
comp = compartment_id or _safe_dec(cfg["compartment_id"]) or _safe_dec(cfg["tenancy_ocid"])
|
||
insts = compute.list_instances(comp).data
|
||
return [{"id":i.id,"display_name":i.display_name,"shape":i.shape,"lifecycle_state":i.lifecycle_state,"region":i.region,"time_created":str(i.time_created)} for i in insts]
|
||
except Exception as e:
|
||
return {"error": str(e)[:500]}
|
||
|
||
@app.get("/api/oci/explore/{cid}/databases")
|
||
async def explore_databases(cid: str, compartment_id: str = Query(None), u=Depends(current_user)):
|
||
try:
|
||
import oci
|
||
config = _get_oci_config(cid)
|
||
db_client = oci.database.DatabaseClient(config)
|
||
with db() as c:
|
||
cfg = c.execute("SELECT tenancy_ocid,compartment_id FROM oci_configs WHERE id=?", (cid,)).fetchone()
|
||
comp = compartment_id or _safe_dec(cfg["compartment_id"]) or _safe_dec(cfg["tenancy_ocid"])
|
||
adbs = db_client.list_autonomous_databases(comp).data
|
||
return [{"id":a.id,"display_name":a.display_name,"db_name":a.db_name,"lifecycle_state":a.lifecycle_state,
|
||
"cpu_core_count":a.cpu_core_count,"data_storage_size_in_tbs":a.data_storage_size_in_tbs,
|
||
"is_free_tier":a.is_free_tier} for a in adbs]
|
||
except Exception as e:
|
||
return {"error": str(e)[:500]}
|
||
|
||
@app.get("/api/oci/explore/{cid}/buckets")
|
||
async def explore_buckets(cid: str, compartment_id: str = Query(None), u=Depends(current_user)):
|
||
try:
|
||
import oci
|
||
config = _get_oci_config(cid)
|
||
os_client = oci.object_storage.ObjectStorageClient(config)
|
||
namespace = os_client.get_namespace().data
|
||
with db() as c:
|
||
cfg = c.execute("SELECT tenancy_ocid,compartment_id FROM oci_configs WHERE id=?", (cid,)).fetchone()
|
||
comp = compartment_id or _safe_dec(cfg["compartment_id"]) or _safe_dec(cfg["tenancy_ocid"])
|
||
buckets = os_client.list_buckets(namespace, comp).data
|
||
return [{"name":b.name,"namespace":b.namespace,"time_created":str(b.time_created)} for b in buckets]
|
||
except Exception as e:
|
||
return {"error": str(e)[:500]}
|
||
|
||
# ── OCI GenAI Config & Chat ───────────────────────────────────────────────────
|
||
@app.get("/api/genai/models")
|
||
async def list_genai_models(u=Depends(current_user)):
|
||
return {"models": GENAI_MODELS, "regions": GENAI_REGIONS, "embedding_models": EMBEDDING_MODELS, "oci_regions": OCI_REGIONS}
|
||
|
||
@app.post("/api/genai/config")
|
||
async def save_genai(req: GenAIConfigReq, u=Depends(require("admin","user"))):
|
||
gid = str(uuid.uuid4())
|
||
ep = req.endpoint or f"https://inference.generativeai.{req.genai_region}.oci.oraclecloud.com"
|
||
with db() as c:
|
||
if req.is_default:
|
||
c.execute("UPDATE genai_configs SET is_default=0 WHERE user_id=?", (u["id"],))
|
||
c.execute(
|
||
"""INSERT INTO genai_configs (id,user_id,name,oci_config_id,model_id,model_ocid,compartment_id,
|
||
genai_region,endpoint,serving_type,dedicated_endpoint_id,temperature,max_tokens,top_p,top_k,
|
||
frequency_penalty,presence_penalty,is_default) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||
(gid, u["id"], req.name, req.oci_config_id, req.model_id, req.model_ocid,
|
||
req.compartment_id, req.genai_region, ep, req.serving_type, req.dedicated_endpoint_id,
|
||
req.temperature, req.max_tokens, req.top_p, req.top_k,
|
||
req.frequency_penalty, req.presence_penalty, int(req.is_default)))
|
||
_audit(u["id"], u["username"], "save_genai_config", gid, req.model_id)
|
||
_config_log("genai", gid, req.name, "success", "save", f"Modelo salvo: {req.model_id} ({req.genai_region})", u["id"], u["username"])
|
||
return {"id": gid, "model_id": req.model_id, "endpoint": ep}
|
||
|
||
@app.get("/api/genai/configs")
|
||
async def list_genai(u=Depends(current_user)):
|
||
with db() as c:
|
||
if u["role"]=="admin": rows=c.execute("SELECT * FROM genai_configs").fetchall()
|
||
else: rows=c.execute("SELECT * FROM genai_configs WHERE user_id=?",(u["id"],)).fetchall()
|
||
return [dict(r) for r in rows]
|
||
|
||
@app.delete("/api/genai/configs/{gid}")
|
||
async def del_genai(gid: str, u=Depends(require("admin","user"))):
|
||
with db() as c: c.execute("DELETE FROM genai_configs WHERE id=?", (gid,))
|
||
return {"ok": True}
|
||
|
||
@app.put("/api/genai/configs/{gid}")
|
||
async def update_genai(gid: str, req: GenAIConfigReq, u=Depends(require("admin","user"))):
|
||
with db() as c:
|
||
existing = c.execute("SELECT * FROM genai_configs WHERE id=?", (gid,)).fetchone()
|
||
if not existing: raise HTTPException(404)
|
||
if u["role"] != "admin" and existing["user_id"] != u["id"]: raise HTTPException(403)
|
||
ep = req.endpoint or f"https://inference.generativeai.{req.genai_region}.oci.oraclecloud.com"
|
||
with db() as c:
|
||
if req.is_default:
|
||
c.execute("UPDATE genai_configs SET is_default=0 WHERE user_id=?", (u["id"],))
|
||
c.execute(
|
||
"""UPDATE genai_configs SET name=?,oci_config_id=?,model_id=?,model_ocid=?,compartment_id=?,
|
||
genai_region=?,endpoint=?,serving_type=?,dedicated_endpoint_id=?,temperature=?,max_tokens=?,
|
||
top_p=?,top_k=?,frequency_penalty=?,presence_penalty=?,is_default=? WHERE id=?""",
|
||
(req.name, req.oci_config_id, req.model_id, req.model_ocid,
|
||
req.compartment_id, req.genai_region, ep, req.serving_type, req.dedicated_endpoint_id,
|
||
req.temperature, req.max_tokens, req.top_p, req.top_k,
|
||
req.frequency_penalty, req.presence_penalty, int(req.is_default), gid))
|
||
_audit(u["id"], u["username"], "update_genai_config", gid, req.model_id)
|
||
_config_log("genai", gid, req.name, "success", "save", f"Modelo atualizado: {req.model_id} ({req.genai_region})", u["id"], u["username"])
|
||
return {"id": gid, "model_id": req.model_id, "endpoint": ep}
|
||
|
||
@app.post("/api/genai/test/{gid}")
|
||
async def test_genai(gid: str, u=Depends(require("admin","user"))):
|
||
with db() as c:
|
||
gc = c.execute("SELECT * FROM genai_configs WHERE id=?",(gid,)).fetchone()
|
||
if not gc: raise HTTPException(404)
|
||
gname = gc["name"]
|
||
try:
|
||
resp, _, _ = _call_genai(dict(gc), "Say 'connection successful' in one short sentence.")
|
||
_config_log("genai", gid, gname, "success", "test", f"GenAI OK: {resp[:200]}", u["id"], u["username"])
|
||
return {"status":"success","message":"GenAI OK","response":resp[:300]}
|
||
except Exception as e:
|
||
msg = str(e)[:500]
|
||
_config_log("genai", gid, gname, "error", "test", msg, u["id"], u["username"])
|
||
return {"status":"error","message":msg}
|
||
|
||
# ── Chat Memory Compaction ─────────────────────────────────────────────────────
|
||
|
||
def _estimate_tokens(text: str) -> int:
|
||
return len(text) // 4
|
||
|
||
def _estimate_history_tokens(history: list) -> int:
|
||
return sum(_estimate_tokens(h["content"]) for h in history)
|
||
|
||
def _should_compact(history: list) -> bool:
|
||
if len(history) < COMPACT_MIN_MESSAGES:
|
||
return False
|
||
return _estimate_history_tokens(history) > COMPACT_TOKEN_THRESHOLD
|
||
|
||
def _generate_summary(genai_cfg: dict, messages_to_summarize: list) -> str:
|
||
"""Use the same GenAI model to summarize older conversation messages."""
|
||
conversation_text = ""
|
||
for m in messages_to_summarize:
|
||
role_label = "Usuário" if m["role"] == "user" else "Assistente"
|
||
# Truncate very long messages in the summary input
|
||
content = m["content"][:3000] if len(m["content"]) > 3000 else m["content"]
|
||
conversation_text += f"{role_label}: {content}\n\n"
|
||
|
||
summary_prompt = (
|
||
"Resuma a conversa abaixo em um parágrafo conciso que capture:\n"
|
||
"- Tópicos principais discutidos\n"
|
||
"- Decisões tomadas e conclusões\n"
|
||
"- Resultados de ferramentas/tools executadas\n"
|
||
"- Contexto OCI/CIS relevante para perguntas futuras\n\n"
|
||
"Seja conciso mas preserve toda informação acionável.\n\n"
|
||
"CONVERSA:\n" + conversation_text
|
||
)
|
||
|
||
summary_cfg = dict(genai_cfg)
|
||
summary_cfg["system_prompt"] = "Você é um sumarizador. Responda apenas com o resumo."
|
||
summary_cfg["max_tokens"] = COMPACT_SUMMARY_MAX_TOKENS
|
||
|
||
try:
|
||
text, _, _ = _call_genai(summary_cfg, summary_prompt, history=None, tools=None)
|
||
return text.strip()
|
||
except Exception as e:
|
||
log.warning(f"Summary generation failed: {e}")
|
||
return ""
|
||
|
||
def _compact_history(session_id: str, user_id: str, genai_cfg: dict, history: list) -> list:
|
||
"""Compact conversation history by summarizing older messages."""
|
||
if len(history) <= COMPACT_KEEP_RECENT:
|
||
return history
|
||
|
||
messages_to_summarize = history[:-COMPACT_KEEP_RECENT]
|
||
recent_messages = history[-COMPACT_KEEP_RECENT:]
|
||
|
||
# Check for existing summary
|
||
with db() as c:
|
||
row = c.execute(
|
||
"SELECT summary, messages_compacted FROM chat_summaries "
|
||
"WHERE session_id=? ORDER BY created_at DESC LIMIT 1",
|
||
(session_id,)
|
||
).fetchone()
|
||
if row:
|
||
existing_summary = row["summary"]
|
||
prev_compacted = row["messages_compacted"]
|
||
# Reuse if no new messages to summarize
|
||
if len(messages_to_summarize) <= prev_compacted:
|
||
return [{"role": "assistant", "content": f"[Resumo da conversa anterior: {existing_summary}]"}] + recent_messages
|
||
else:
|
||
existing_summary = None
|
||
|
||
# Include existing summary as prefix for incremental compaction
|
||
if existing_summary:
|
||
messages_to_summarize = [{"role": "assistant", "content": f"[Resumo anterior: {existing_summary}]"}] + messages_to_summarize
|
||
|
||
summary_text = _generate_summary(genai_cfg, messages_to_summarize)
|
||
|
||
if not summary_text:
|
||
# Fallback: truncate keeping recent messages that fit in budget
|
||
truncated = []
|
||
budget = 6000
|
||
for m in reversed(history):
|
||
t = _estimate_tokens(m["content"])
|
||
if budget - t < 0:
|
||
break
|
||
truncated.insert(0, m)
|
||
budget -= t
|
||
return truncated
|
||
|
||
# Save summary to DB
|
||
actual_count = len(messages_to_summarize) - (1 if existing_summary else 0)
|
||
with db() as c:
|
||
last_msg = c.execute(
|
||
"SELECT id FROM chat_messages WHERE session_id=? AND role IN ('user','assistant') "
|
||
"ORDER BY created_at ASC LIMIT 1 OFFSET ?",
|
||
(session_id, max(0, actual_count - 1))
|
||
).fetchone()
|
||
last_id = last_msg["id"] if last_msg else "unknown"
|
||
c.execute(
|
||
"INSERT INTO chat_summaries (id,session_id,user_id,summary,messages_compacted,up_to_message_id) VALUES (?,?,?,?,?,?)",
|
||
(str(uuid.uuid4()), session_id, user_id, summary_text, actual_count, last_id)
|
||
)
|
||
|
||
log.info(f"Generated summary for session {session_id}: {len(summary_text)} chars, {actual_count} msgs compacted")
|
||
return [{"role": "assistant", "content": f"[Resumo da conversa anterior: {summary_text}]"}] + recent_messages
|
||
|
||
# ── GenAI Call ─────────────────────────────────────────────────────────────────
|
||
|
||
def _call_genai(gc: dict, message: str, history: list = None, tools: list = None,
|
||
tool_results_cohere: list = None,
|
||
extra_messages: list = None,
|
||
attachments: list = None) -> tuple:
|
||
"""
|
||
Call OCI Generative AI with optional tool use support.
|
||
Returns (text, tool_calls, tool_calls_raw) tuple.
|
||
tool_calls is a list of dicts or None. tool_calls_raw is the raw OCI SDK objects (for Generic format continuations).
|
||
extra_messages: list of raw OCI SDK message objects to append after the user message (for tool use loop accumulation).
|
||
"""
|
||
import oci
|
||
|
||
# Load OCI config from stored credentials (same as ~/.oci/config)
|
||
config_path = str(OCI_DIR / gc["oci_config_id"] / "config")
|
||
config = oci.config.from_file(config_path, "DEFAULT")
|
||
|
||
# Service endpoint - built from region
|
||
endpoint = gc["endpoint"]
|
||
|
||
# Create inference client with retry strategy and timeout
|
||
generative_ai_inference_client = oci.generative_ai_inference.GenerativeAiInferenceClient(
|
||
config=config,
|
||
service_endpoint=endpoint,
|
||
retry_strategy=oci.retry.NoneRetryStrategy(),
|
||
timeout=(10, 600)
|
||
)
|
||
|
||
# System prompt
|
||
system_prompt = gc.get("system_prompt", "")
|
||
|
||
# Build ChatDetails
|
||
chat_detail = oci.generative_ai_inference.models.ChatDetails()
|
||
|
||
# Determine API format from model catalog
|
||
model_info = GENAI_MODELS.get(gc["model_id"], {})
|
||
api_format = model_info.get("api_format", "GENERIC")
|
||
|
||
if api_format == "COHERE":
|
||
# ── Cohere models (CohereChatRequest) ──
|
||
chat_request = oci.generative_ai_inference.models.CohereChatRequest()
|
||
chat_request.api_format = oci.generative_ai_inference.models.BaseChatRequest.API_FORMAT_COHERE
|
||
if system_prompt:
|
||
chat_request.preamble_override = system_prompt
|
||
chat_request.message = message
|
||
chat_request.max_tokens = int(gc.get("max_tokens", 6000))
|
||
chat_request.temperature = float(gc.get("temperature", 1))
|
||
chat_request.frequency_penalty = float(gc.get("frequency_penalty", 0))
|
||
chat_request.presence_penalty = float(gc.get("presence_penalty", 0))
|
||
chat_request.top_p = float(gc.get("top_p", 0.95))
|
||
chat_request.top_k = int(gc.get("top_k", 1))
|
||
if history:
|
||
chat_history = []
|
||
for h in history:
|
||
entry = oci.generative_ai_inference.models.CohereMessage()
|
||
entry.role = "USER" if h["role"] == "user" else "CHATBOT"
|
||
entry.message = h["content"]
|
||
chat_history.append(entry)
|
||
chat_request.chat_history = chat_history
|
||
# Tool use support for Cohere
|
||
if tools:
|
||
cohere_tools = []
|
||
for t in tools:
|
||
props = t.get("input_schema", {}).get("properties", {})
|
||
required = t.get("input_schema", {}).get("required", [])
|
||
param_defs = {}
|
||
for k, v in props.items():
|
||
pd = oci.generative_ai_inference.models.CohereParameterDefinition()
|
||
pd.type = v.get("type", "str")
|
||
pd.description = v.get("description", "")
|
||
pd.is_required = k in required
|
||
param_defs[k] = pd
|
||
ct = oci.generative_ai_inference.models.CohereTool()
|
||
ct.name = t["name"]
|
||
ct.description = t.get("description", "")
|
||
ct.parameter_definitions = param_defs if param_defs else None
|
||
cohere_tools.append(ct)
|
||
chat_request.tools = cohere_tools
|
||
if tool_results_cohere:
|
||
chat_request.tool_results = tool_results_cohere
|
||
else:
|
||
# ── Generic format (Meta Llama, Google, xAI, OpenAI) ──
|
||
chat_request = oci.generative_ai_inference.models.GenericChatRequest()
|
||
chat_request.api_format = oci.generative_ai_inference.models.BaseChatRequest.API_FORMAT_GENERIC
|
||
provider = model_info.get("provider", "")
|
||
is_openai = provider == "openai"
|
||
is_xai = provider == "xai"
|
||
# OpenAI models use max_completion_tokens instead of max_tokens
|
||
# and do not support top_k
|
||
if is_openai:
|
||
chat_request.max_completion_tokens = int(gc.get("max_tokens", 6000))
|
||
else:
|
||
chat_request.max_tokens = int(gc.get("max_tokens", 6000))
|
||
if not is_xai:
|
||
chat_request.top_k = int(gc.get("top_k", 1))
|
||
chat_request.temperature = float(gc.get("temperature", 1))
|
||
if not is_xai:
|
||
chat_request.frequency_penalty = float(gc.get("frequency_penalty", 0))
|
||
chat_request.presence_penalty = float(gc.get("presence_penalty", 0))
|
||
chat_request.top_p = float(gc.get("top_p", 0.95))
|
||
|
||
messages = []
|
||
if system_prompt:
|
||
sys_content = oci.generative_ai_inference.models.TextContent()
|
||
sys_content.text = system_prompt
|
||
sys_msg = oci.generative_ai_inference.models.Message()
|
||
sys_msg.role = "SYSTEM"
|
||
sys_msg.content = [sys_content]
|
||
messages.append(sys_msg)
|
||
if history:
|
||
for h in history:
|
||
content = oci.generative_ai_inference.models.TextContent()
|
||
content.text = h["content"]
|
||
msg = oci.generative_ai_inference.models.Message()
|
||
msg.role = "USER" if h["role"] == "user" else "ASSISTANT"
|
||
msg.content = [content]
|
||
messages.append(msg)
|
||
|
||
# Current user message (with optional multimodal attachments)
|
||
content_parts = []
|
||
if attachments:
|
||
for att in attachments:
|
||
if att["type"] == "image":
|
||
img_url = oci.generative_ai_inference.models.ImageUrl()
|
||
img_url.url = att["data_uri"]
|
||
img_url.detail = "AUTO"
|
||
img_content = oci.generative_ai_inference.models.ImageContent()
|
||
img_content.image_url = img_url
|
||
content_parts.append(img_content)
|
||
elif att["type"] == "document":
|
||
doc_url = oci.generative_ai_inference.models.DocumentUrl()
|
||
doc_url.url = att["data_uri"]
|
||
doc_content = oci.generative_ai_inference.models.DocumentContent()
|
||
doc_content.document_url = doc_url
|
||
content_parts.append(doc_content)
|
||
text_content = oci.generative_ai_inference.models.TextContent()
|
||
text_content.text = message
|
||
content_parts.append(text_content)
|
||
user_message = oci.generative_ai_inference.models.Message()
|
||
user_message.role = "USER"
|
||
user_message.content = content_parts
|
||
messages.append(user_message)
|
||
|
||
# Append accumulated tool use loop messages (assistant+tool_calls → tool_results → ...)
|
||
if extra_messages:
|
||
messages.extend(extra_messages)
|
||
|
||
chat_request.messages = messages
|
||
|
||
# Tool definitions for Generic format
|
||
if tools:
|
||
generic_tools = []
|
||
for t in tools:
|
||
fd = oci.generative_ai_inference.models.FunctionDefinition()
|
||
fd.name = t["name"]
|
||
fd.description = t.get("description", "")
|
||
fd.parameters = t.get("input_schema")
|
||
generic_tools.append(fd)
|
||
chat_request.tools = generic_tools
|
||
|
||
# Serving mode - resolve OCID: explicit model_ocid > catalog ocid for region > short model_id
|
||
model_ref = gc.get("model_ocid") or ""
|
||
if not model_ref:
|
||
region = gc.get("genai_region", "")
|
||
ocids = model_info.get("ocids", {})
|
||
model_ref = ocids.get(region) or gc["model_id"]
|
||
log.info(f"GenAI call: model_id={gc.get('model_id')}, model_ref={model_ref[:60]}...")
|
||
if gc.get("serving_type") == "DEDICATED" and gc.get("dedicated_endpoint_id"):
|
||
chat_detail.serving_mode = oci.generative_ai_inference.models.DedicatedServingMode(
|
||
endpoint_id=gc["dedicated_endpoint_id"]
|
||
)
|
||
else:
|
||
chat_detail.serving_mode = oci.generative_ai_inference.models.OnDemandServingMode(
|
||
model_id=model_ref
|
||
)
|
||
|
||
chat_detail.chat_request = chat_request
|
||
chat_detail.compartment_id = gc["compartment_id"]
|
||
|
||
# Execute
|
||
chat_response = generative_ai_inference_client.chat(chat_detail)
|
||
|
||
# Extract text and tool_calls from response
|
||
resp = chat_response.data.chat_response
|
||
if api_format == "COHERE":
|
||
text = resp.text if hasattr(resp, 'text') else ""
|
||
tool_calls = None
|
||
if hasattr(resp, 'tool_calls') and resp.tool_calls:
|
||
tool_calls = []
|
||
for tc in resp.tool_calls:
|
||
tool_calls.append({
|
||
"id": tc.name, # Cohere uses name as identifier
|
||
"name": tc.name,
|
||
"arguments": tc.parameters if isinstance(tc.parameters, dict) else {}
|
||
})
|
||
return (text or "", tool_calls, None)
|
||
else:
|
||
text = ""
|
||
tool_calls = None
|
||
tool_calls_raw = None # raw content list from assistant message for continuation
|
||
if hasattr(resp, 'choices') and resp.choices:
|
||
choice = resp.choices[0]
|
||
if hasattr(choice, 'message') and choice.message:
|
||
# Check for tool calls
|
||
if hasattr(choice.message, 'tool_calls') and choice.message.tool_calls:
|
||
tool_calls = []
|
||
for tc in choice.message.tool_calls:
|
||
args = {}
|
||
if hasattr(tc, 'arguments') and tc.arguments:
|
||
try: args = json.loads(tc.arguments) if isinstance(tc.arguments, str) else tc.arguments
|
||
except: args = {}
|
||
tool_calls.append({
|
||
"id": tc.id if hasattr(tc, 'id') else tc.name,
|
||
"name": tc.name if hasattr(tc, 'name') else "",
|
||
"arguments": args
|
||
})
|
||
# Preserve raw tool_calls for building assistant message in next iteration
|
||
tool_calls_raw = choice.message.tool_calls
|
||
# Extract text
|
||
if hasattr(choice.message, 'content') and choice.message.content:
|
||
contents = choice.message.content
|
||
if contents and len(contents) > 0:
|
||
text = contents[0].text if hasattr(contents[0], 'text') else ""
|
||
return (text or "", tool_calls, tool_calls_raw)
|
||
|
||
# ── RAG Helpers ───────────────────────────────────────────────────────────────
|
||
RAG_CONTEXT_TEMPLATE = """--- CONTEXTO RECUPERADO (Vector Search) ---
|
||
{context}
|
||
--- FIM DO CONTEXTO ---
|
||
|
||
Pergunta do usuário: {question}"""
|
||
|
||
RAG_DEFAULT_SYSTEM_PROMPT = """Você é um assistente RAG especializado em Oracle Cloud Infrastructure (OCI).
|
||
|
||
### Escopo e restrições
|
||
- Responda **SOMENTE** perguntas relacionadas a Oracle Cloud (OCI), CIS Benchmark, CIS Report e itens de inventário OCI (IAM/AssetManagement/Networking/StorageBlock/FileStorageService/Objectstore/Compute/LoggingandMonitoring).
|
||
- Se a pergunta não for desse escopo, recuse educadamente e peça uma pergunta dentro do tema.
|
||
|
||
### Bases vetorizadas disponíveis (contexto recuperado automaticamente)
|
||
- **CIS_REPORT**: report coletado na tenancy (compliance, findings, critérios/auditoria, evidências).
|
||
- **CIS_RECOMMENDATIONS**: recomendações práticas de correção/remediação para itens não compliant.
|
||
- **Inventários OCI** (itens não compliance): inventidentityandaccess, inventassetmanagement, inventnetworking, inventstorageblock, inventfilestorageservice, inventobjectstorage, inventcomputeinstances, inventloggingandmonitoring.
|
||
|
||
### Resolução de divergências
|
||
- Para **critérios, exigência e auditoria**, priorize documentos do **CIS_REPORT**.
|
||
- Para **passos de remediação (como corrigir)**, priorize documentos do **CIS_RECOMMENDATIONS**.
|
||
- Se houver conflito, declare: "Há divergência entre bases; para auditoria usei CIS_REPORT e para correção usei CIS_RECOMMENDATIONS", citando evidências.
|
||
|
||
### Regras de fidelidade
|
||
- Responda **somente** com informações suportadas por evidências do contexto recuperado.
|
||
- Se não houver evidência suficiente: **"Não encontrei nas minhas bases"** e peça dados para refinar (Recommendation #, seção, recurso, OCID, etc.).
|
||
- **Não invente** páginas, comandos, políticas, valores ou números.
|
||
- Use somente **1–3 linhas** de evidência por item para manter respostas compactas.
|
||
|
||
### Formato de resposta (compacto, porém completo)
|
||
- **Tenancy:** <tenancy>
|
||
- **Recomendação <número> — <título>**
|
||
- **Seção/Capítulo:** …
|
||
- **Nível/Tipo:** Manual/Automated (se disponível)
|
||
- **O que isso exige (CIS):** …
|
||
- **Como auditar (critério):** …
|
||
- **Como corrigir (remediação):** (somente se solicitado ou relevante)
|
||
- Passos (Console/OCI CLI **apenas se estiverem nas evidências**)
|
||
- Observações/alertas
|
||
- **Evidência (citação curta):** "…"
|
||
- **Fontes consultadas:**
|
||
- CIS_REPORT: <metadata essenciais>
|
||
- CIS_RECOMMENDATIONS: <metadata essenciais> (se usado)"""
|
||
|
||
def _get_adb_connection(cfg: dict):
|
||
"""Create an oracledb connection from an adb_vector_configs row."""
|
||
import oracledb
|
||
params = {"user": cfg["username"], "password": _dec(cfg["password_enc"]), "dsn": cfg["dsn"]}
|
||
if cfg["use_mtls"] and cfg.get("wallet_dir"):
|
||
params["config_dir"] = cfg["wallet_dir"]
|
||
params["wallet_location"] = cfg["wallet_dir"]
|
||
if cfg.get("wallet_password_enc"):
|
||
params["wallet_password"] = _dec(cfg["wallet_password_enc"])
|
||
return oracledb.connect(**params)
|
||
|
||
def _embed_text(text: str, genai_cfg: dict, embedding_model_id: str) -> list:
|
||
"""Generate embedding using OCI GenAI embed endpoint."""
|
||
import oci
|
||
config_path = str(OCI_DIR / genai_cfg["oci_config_id"] / "config")
|
||
config = oci.config.from_file(config_path, "DEFAULT")
|
||
endpoint = genai_cfg["endpoint"]
|
||
client = oci.generative_ai_inference.GenerativeAiInferenceClient(
|
||
config=config, service_endpoint=endpoint,
|
||
retry_strategy=oci.retry.NoneRetryStrategy(), timeout=(10, 120)
|
||
)
|
||
embed_detail = oci.generative_ai_inference.models.EmbedTextDetails()
|
||
embed_detail.inputs = [text]
|
||
# Resolve OCID for embedding model by region
|
||
emb_info = EMBEDDING_MODELS.get(embedding_model_id, {})
|
||
region = genai_cfg.get("genai_region", "")
|
||
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["compartment_id"]
|
||
embed_detail.truncate = "NONE"
|
||
embed_detail.input_type = "SEARCH_QUERY"
|
||
response = client.embed_text(embed_detail)
|
||
return response.data.embeddings[0]
|
||
|
||
def _vector_search(cfg: dict, query_embedding: list, top_k: int = 5, table_name: str = None) -> list:
|
||
"""Search ADB vector store using cosine similarity. Returns top-K documents."""
|
||
import array
|
||
table_name = table_name or cfg.get("table_name", "CIS_EMBEDDINGS")
|
||
conn = _get_adb_connection(cfg)
|
||
try:
|
||
cur = conn.cursor()
|
||
vec = array.array('d', query_embedding)
|
||
cur.execute(f"""
|
||
SELECT ID, CONTENT, METADATA, SOURCE,
|
||
VECTOR_DISTANCE(EMBEDDING, :1, COSINE) AS distance
|
||
FROM {table_name}
|
||
ORDER BY distance ASC
|
||
FETCH FIRST :2 ROWS ONLY
|
||
""", [vec, top_k])
|
||
results = []
|
||
for row in cur:
|
||
content = row[1]
|
||
if hasattr(content, 'read'):
|
||
content = content.read()
|
||
results.append({
|
||
"id": row[0], "content": content or "",
|
||
"metadata": row[2], "source": row[3], "distance": float(row[4])
|
||
})
|
||
cur.close()
|
||
return results
|
||
finally:
|
||
conn.close()
|
||
|
||
def _build_rag_context(documents: list) -> str:
|
||
"""Format retrieved documents into a context string for the LLM prompt."""
|
||
if not documents:
|
||
return ""
|
||
parts = []
|
||
for i, doc in enumerate(documents, 1):
|
||
source = doc.get("source", "unknown")
|
||
content = doc.get("content", "")
|
||
if len(content) > 2000:
|
||
content = content[:2000] + "..."
|
||
parts.append(f"[Document {i} | Source: {source}]\n{content}")
|
||
return "\n\n---\n\n".join(parts)
|
||
|
||
def _get_active_adb_configs(user_id: str) -> list[dict]:
|
||
"""Get all active ADB vector configs with a linked GenAI config."""
|
||
with db() as c:
|
||
rows = c.execute(
|
||
"SELECT * FROM adb_vector_configs WHERE user_id=? AND is_active=1 AND genai_config_id IS NOT NULL ORDER BY created_at DESC",
|
||
(user_id,)
|
||
).fetchall()
|
||
if not rows:
|
||
rows = c.execute(
|
||
"SELECT * FROM adb_vector_configs WHERE is_active=1 AND genai_config_id IS NOT NULL ORDER BY created_at DESC"
|
||
).fetchall()
|
||
return [dict(r) for r in rows]
|
||
|
||
def _get_tables_for_config(adb_config_id: str, active_only: bool = True) -> list[dict]:
|
||
"""Get all registered vector tables for an ADB config."""
|
||
with db() as c:
|
||
sql = "SELECT * FROM adb_vector_tables WHERE adb_config_id=?"
|
||
if active_only:
|
||
sql += " AND is_active=1"
|
||
sql += " ORDER BY created_at ASC"
|
||
rows = c.execute(sql, (adb_config_id,)).fetchall()
|
||
return [dict(r) for r in rows]
|
||
|
||
# ── MCP Servers ───────────────────────────────────────────────────────────────
|
||
@app.post("/api/mcp/servers")
|
||
async def register_mcp(req: MCPServerReq, u=Depends(require("admin","user"))):
|
||
mid = str(uuid.uuid4())
|
||
with db() as c:
|
||
c.execute(
|
||
"INSERT INTO mcp_servers (id,user_id,name,description,server_type,command,args,env_vars,url,module_path,tools,linked_adb_id) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
|
||
(mid, u["id"], req.name, req.description, req.server_type,
|
||
req.command, json.dumps(req.args) if req.args else None,
|
||
json.dumps(req.env_vars) if req.env_vars else None, req.url, req.module_path,
|
||
json.dumps(req.tools) if req.tools else None, req.linked_adb_id))
|
||
_audit(u["id"], u["username"], "register_mcp", mid, req.name)
|
||
_config_log("mcp", mid, req.name, "success", "save", f"MCP registrado: {req.name} ({req.server_type})", u["id"], u["username"])
|
||
return {"id": mid, "name": req.name, "server_type": req.server_type}
|
||
|
||
@app.get("/api/mcp/servers")
|
||
async def list_mcp(u=Depends(current_user)):
|
||
with db() as c:
|
||
if u["role"]=="admin": rows=c.execute("SELECT * FROM mcp_servers ORDER BY created_at DESC").fetchall()
|
||
else: rows=c.execute("SELECT * FROM mcp_servers WHERE user_id=? ORDER BY created_at DESC",(u["id"],)).fetchall()
|
||
res = []
|
||
for r in rows:
|
||
d = dict(r)
|
||
for k in ("args","env_vars","tools"):
|
||
if d.get(k):
|
||
try: d[k] = json.loads(d[k])
|
||
except: pass
|
||
res.append(d)
|
||
return res
|
||
|
||
@app.delete("/api/mcp/servers/{mid}")
|
||
async def del_mcp(mid: str, u=Depends(require("admin","user"))):
|
||
with db() as c: c.execute("DELETE FROM mcp_servers WHERE id=?", (mid,))
|
||
return {"ok": True}
|
||
|
||
@app.put("/api/mcp/servers/{mid}")
|
||
async def update_mcp(mid: str, req: MCPServerReq, u=Depends(require("admin","user"))):
|
||
with db() as c:
|
||
existing = c.execute("SELECT * FROM mcp_servers WHERE id=?", (mid,)).fetchone()
|
||
if not existing: raise HTTPException(404)
|
||
if u["role"] != "admin" and existing["user_id"] != u["id"]: raise HTTPException(403)
|
||
with db() as c:
|
||
c.execute(
|
||
"UPDATE mcp_servers SET name=?,description=?,server_type=?,command=?,args=?,env_vars=?,url=?,tools=?,linked_adb_id=? WHERE id=?",
|
||
(req.name, req.description, req.server_type, req.command,
|
||
json.dumps(req.args) if req.args else None,
|
||
json.dumps(req.env_vars) if req.env_vars else None, req.url,
|
||
json.dumps(req.tools) if req.tools else None, req.linked_adb_id, mid))
|
||
_audit(u["id"], u["username"], "update_mcp", mid, req.name)
|
||
_config_log("mcp", mid, req.name, "success", "save", f"MCP atualizado: {req.name} ({req.server_type})", u["id"], u["username"])
|
||
return {"id": mid, "name": req.name, "server_type": req.server_type}
|
||
|
||
@app.put("/api/mcp/servers/{mid}/toggle")
|
||
async def toggle_mcp(mid: str, u=Depends(require("admin","user"))):
|
||
with db() as c:
|
||
cur = c.execute("SELECT is_active FROM mcp_servers WHERE id=?",(mid,)).fetchone()
|
||
if not cur: raise HTTPException(404)
|
||
c.execute("UPDATE mcp_servers SET is_active=? WHERE id=?",(0 if cur["is_active"] else 1, mid))
|
||
return {"ok": True, "is_active": not cur["is_active"]}
|
||
|
||
@app.post("/api/mcp/servers/{mid}/upload")
|
||
async def upload_mcp_file(mid: str, file: UploadFile = File(...), u=Depends(require("admin","user"))):
|
||
sdir = MCP_DIR / mid; sdir.mkdir(parents=True, exist_ok=True)
|
||
fp = sdir / file.filename
|
||
fp.write_bytes(await file.read())
|
||
with db() as c: c.execute("UPDATE mcp_servers SET module_path=? WHERE id=?", (str(fp), mid))
|
||
return {"ok": True, "path": str(fp)}
|
||
|
||
@app.put("/api/mcp/servers/{mid}/link-adb")
|
||
async def link_mcp_adb(mid: str, adb_id: str = Query(...), u=Depends(require("admin","user"))):
|
||
with db() as c: c.execute("UPDATE mcp_servers SET linked_adb_id=? WHERE id=?", (adb_id, mid))
|
||
return {"ok": True, "mcp_id": mid, "linked_adb_id": adb_id}
|
||
|
||
@app.post("/api/mcp/servers/{mid}/discover-tools")
|
||
async def discover_mcp_tools(mid: str, u=Depends(require("admin","user"))):
|
||
"""Connect to MCP server and discover available tools."""
|
||
with db() as c:
|
||
row = c.execute("SELECT * FROM mcp_servers WHERE id=?", (mid,)).fetchone()
|
||
if not row: raise HTTPException(404)
|
||
mcp_srv = dict(row)
|
||
try:
|
||
discovered = await _discover_mcp_tools(mcp_srv)
|
||
except Exception as e:
|
||
raise HTTPException(500, f"Erro ao descobrir tools: {str(e)[:500]}")
|
||
# Merge with existing tools (keep manually added ones)
|
||
existing = []
|
||
if mcp_srv.get("tools"):
|
||
try: existing = json.loads(mcp_srv["tools"]) if isinstance(mcp_srv["tools"], str) else mcp_srv["tools"]
|
||
except: pass
|
||
existing_names = {t["name"] for t in existing if isinstance(t, dict)}
|
||
for dt in discovered:
|
||
if dt["name"] not in existing_names:
|
||
existing.append(dt)
|
||
else:
|
||
# Update existing tool with discovered schema
|
||
for i, et in enumerate(existing):
|
||
if isinstance(et, dict) and et["name"] == dt["name"]:
|
||
existing[i] = dt
|
||
break
|
||
with db() as c:
|
||
c.execute("UPDATE mcp_servers SET tools=? WHERE id=?", (json.dumps(existing), mid))
|
||
_audit(u["id"], u["username"], "discover_mcp_tools", mid, f"{len(discovered)} tools found")
|
||
return {"ok": True, "discovered": len(discovered), "total": len(existing), "tools": existing}
|
||
|
||
@app.put("/api/mcp/servers/{mid}/tools")
|
||
async def update_mcp_tools(mid: str, req: dict, u=Depends(require("admin","user"))):
|
||
"""Manually update tools for an MCP server."""
|
||
with db() as c:
|
||
row = c.execute("SELECT id FROM mcp_servers WHERE id=?", (mid,)).fetchone()
|
||
if not row: raise HTTPException(404)
|
||
tools = req.get("tools", [])
|
||
if not isinstance(tools, list): raise HTTPException(400, "tools must be a list")
|
||
for t in tools:
|
||
if not isinstance(t, dict) or "name" not in t:
|
||
raise HTTPException(400, "Each tool must have a 'name' field")
|
||
with db() as c:
|
||
c.execute("UPDATE mcp_servers SET tools=? WHERE id=?", (json.dumps(tools), mid))
|
||
return {"ok": True, "tools": tools}
|
||
|
||
async def _discover_mcp_tools(mcp_srv: dict) -> list[dict]:
|
||
"""Connect to MCP server and list available tools."""
|
||
from mcp import ClientSession
|
||
if mcp_srv["server_type"] in ("stdio", "module"):
|
||
from mcp.client.stdio import stdio_client, StdioServerParameters
|
||
cmd = mcp_srv.get("command") or "python3"
|
||
args_raw = mcp_srv.get("args")
|
||
args = json.loads(args_raw) if isinstance(args_raw, str) else (args_raw or [])
|
||
env_raw = mcp_srv.get("env_vars")
|
||
env = json.loads(env_raw) if isinstance(env_raw, str) else (env_raw or None)
|
||
params = StdioServerParameters(command=cmd, args=args, env=env)
|
||
async with stdio_client(params) as streams:
|
||
async with ClientSession(*streams) as session:
|
||
await session.initialize()
|
||
result = await session.list_tools()
|
||
return [{"name": t.name, "description": t.description or "",
|
||
"input_schema": t.inputSchema if hasattr(t, 'inputSchema') else {}} for t in result.tools]
|
||
elif mcp_srv["server_type"] == "sse":
|
||
from mcp.client.sse import sse_client
|
||
async with sse_client(mcp_srv["url"]) as streams:
|
||
async with ClientSession(*streams) as session:
|
||
await session.initialize()
|
||
result = await session.list_tools()
|
||
return [{"name": t.name, "description": t.description or "",
|
||
"input_schema": t.inputSchema if hasattr(t, 'inputSchema') else {}} for t in result.tools]
|
||
return []
|
||
|
||
async def _execute_mcp_tool(mcp_srv: dict, tool_name: str, arguments: dict) -> str:
|
||
"""Connect to MCP server and execute a specific tool."""
|
||
from mcp import ClientSession
|
||
if mcp_srv["server_type"] in ("stdio", "module"):
|
||
from mcp.client.stdio import stdio_client, StdioServerParameters
|
||
cmd = mcp_srv.get("command") or "python3"
|
||
args_raw = mcp_srv.get("args")
|
||
args = json.loads(args_raw) if isinstance(args_raw, str) else (args_raw or [])
|
||
env_raw = mcp_srv.get("env_vars")
|
||
env = json.loads(env_raw) if isinstance(env_raw, str) else (env_raw or None)
|
||
params = StdioServerParameters(command=cmd, args=args, env=env)
|
||
async with stdio_client(params) as streams:
|
||
async with ClientSession(*streams) as session:
|
||
await session.initialize()
|
||
result = await session.call_tool(tool_name, arguments)
|
||
parts = []
|
||
for c in result.content:
|
||
if hasattr(c, 'text'):
|
||
parts.append(c.text)
|
||
elif hasattr(c, 'data'):
|
||
parts.append(str(c.data))
|
||
return "\n".join(parts) if parts else "Tool executed successfully (no output)"
|
||
elif mcp_srv["server_type"] == "sse":
|
||
from mcp.client.sse import sse_client
|
||
async with sse_client(mcp_srv["url"]) as streams:
|
||
async with ClientSession(*streams) as session:
|
||
await session.initialize()
|
||
result = await session.call_tool(tool_name, arguments)
|
||
parts = []
|
||
for c in result.content:
|
||
if hasattr(c, 'text'):
|
||
parts.append(c.text)
|
||
elif hasattr(c, 'data'):
|
||
parts.append(str(c.data))
|
||
return "\n".join(parts) if parts else "Tool executed successfully (no output)"
|
||
raise ValueError(f"Unsupported MCP server type: {mcp_srv['server_type']}")
|
||
|
||
def _get_active_mcp_tools(user_id: str) -> list[dict]:
|
||
"""Get all tools from active MCP servers for a user, with server reference."""
|
||
with db() as c:
|
||
rows = c.execute(
|
||
"SELECT * FROM mcp_servers WHERE is_active=1 AND (user_id=? OR EXISTS (SELECT 1 FROM users WHERE id=? AND role='admin'))",
|
||
(user_id, user_id)
|
||
).fetchall()
|
||
result = []
|
||
for r in rows:
|
||
srv = dict(r)
|
||
tools_raw = srv.get("tools")
|
||
if not tools_raw:
|
||
continue
|
||
try:
|
||
tools = json.loads(tools_raw) if isinstance(tools_raw, str) else tools_raw
|
||
except:
|
||
continue
|
||
for t in tools:
|
||
if isinstance(t, dict) and t.get("name"):
|
||
result.append({"server": srv, "tool": t})
|
||
return result
|
||
|
||
# ── ADB Vector DB Config ─────────────────────────────────────────────────────
|
||
@app.post("/api/adb/parse-wallet")
|
||
async def parse_wallet(wallet: UploadFile = File(...), u=Depends(require("admin","user"))):
|
||
"""Extract DSN names from tnsnames.ora inside wallet ZIP (temporary parse, no save)."""
|
||
import zipfile, tempfile
|
||
try:
|
||
data = await wallet.read()
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
zp = Path(tmp) / "wallet.zip"
|
||
zp.write_bytes(data)
|
||
with zipfile.ZipFile(str(zp), 'r') as z:
|
||
z.extractall(tmp)
|
||
tns = Path(tmp) / "tnsnames.ora"
|
||
if not tns.exists():
|
||
raise HTTPException(400, "Wallet ZIP não contém tnsnames.ora")
|
||
tns_text = tns.read_text(errors="ignore")
|
||
dsn_names = re.findall(r'^(\w[\w\-.]*)\s*=', tns_text, re.MULTILINE)
|
||
if not dsn_names:
|
||
raise HTTPException(400, "Nenhum DSN encontrado no tnsnames.ora")
|
||
return {"dsn_names": dsn_names, "files": [n for n in os.listdir(tmp) if n != "wallet.zip"]}
|
||
except zipfile.BadZipFile:
|
||
raise HTTPException(400, "Arquivo não é um ZIP válido")
|
||
|
||
@app.post("/api/adb/config")
|
||
async def save_adb(
|
||
config_name: str = Form(...), dsn: str = Form(...), username: str = Form(...),
|
||
password: str = Form(...), wallet_password: str = Form(""),
|
||
table_name: str = Form("CIS_EMBEDDINGS"), use_mtls: str = Form("true"),
|
||
genai_config_id: str = Form(""), embedding_model_id: str = Form("cohere.embed-v4.0"),
|
||
wallet: Optional[UploadFile] = File(None),
|
||
u=Depends(require("admin","user"))
|
||
):
|
||
vid = str(uuid.uuid4())
|
||
use_mtls_bool = use_mtls.lower() in ("true", "1", "yes")
|
||
with db() as c:
|
||
c.execute(
|
||
"INSERT INTO adb_vector_configs (id,user_id,config_name,dsn,username,password_enc,wallet_password_enc,table_name,use_mtls,genai_config_id,embedding_model_id) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
|
||
(vid, u["id"], config_name, dsn, username, _enc(password),
|
||
_enc(wallet_password) if wallet_password else None, table_name, int(use_mtls_bool),
|
||
genai_config_id or None, embedding_model_id))
|
||
# Auto-save wallet if provided
|
||
if wallet and wallet.filename:
|
||
import zipfile
|
||
wdir = WALLET_DIR / vid; wdir.mkdir(parents=True, exist_ok=True)
|
||
zp = wdir / "wallet.zip"
|
||
zp.write_bytes(await wallet.read())
|
||
with zipfile.ZipFile(str(zp), 'r') as z: z.extractall(str(wdir))
|
||
with db() as c: c.execute("UPDATE adb_vector_configs SET wallet_dir=? WHERE id=?", (str(wdir), vid))
|
||
_config_log("adb", vid, config_name, "success", "upload", f"Wallet enviada com a conexão", u["id"], u["username"])
|
||
_audit(u["id"], u["username"], "save_adb_config", vid, config_name)
|
||
_config_log("adb", vid, config_name, "success", "save", f"Conexão salva: {config_name} ({dsn})", u["id"], u["username"])
|
||
return {"id": vid, "config_name": config_name}
|
||
|
||
@app.post("/api/adb/{vid}/upload-wallet")
|
||
async def upload_wallet(vid: str, wallet: UploadFile = File(...), u=Depends(require("admin","user"))):
|
||
cname = None
|
||
with db() as c:
|
||
row = c.execute("SELECT config_name FROM adb_vector_configs WHERE id=?", (vid,)).fetchone()
|
||
if row: cname = row["config_name"]
|
||
try:
|
||
wdir = WALLET_DIR / vid; wdir.mkdir(parents=True, exist_ok=True)
|
||
zp = wdir / "wallet.zip"
|
||
zp.write_bytes(await wallet.read())
|
||
import zipfile
|
||
with zipfile.ZipFile(str(zp), 'r') as z: z.extractall(str(wdir))
|
||
with db() as c: c.execute("UPDATE adb_vector_configs SET wallet_dir=? WHERE id=?", (str(wdir), vid))
|
||
files = os.listdir(str(wdir))
|
||
_config_log("adb", vid, cname, "success", "upload", f"Wallet enviada: {', '.join(files)}", u["id"], u["username"])
|
||
return {"ok": True, "wallet_dir": str(wdir), "files": files}
|
||
except Exception as e:
|
||
_config_log("adb", vid, cname, "error", "upload", str(e)[:500], u["id"], u["username"])
|
||
raise HTTPException(500, str(e)[:500])
|
||
|
||
@app.get("/api/adb/configs")
|
||
async def list_adb(u=Depends(current_user)):
|
||
with db() as c:
|
||
cols = "id,config_name,dsn,username,table_name,use_mtls,is_active,wallet_dir,genai_config_id,embedding_model_id,created_at"
|
||
if u["role"]=="admin": rows=c.execute(f"SELECT {cols} FROM adb_vector_configs").fetchall()
|
||
else: rows=c.execute(f"SELECT {cols} FROM adb_vector_configs WHERE user_id=?",(u["id"],)).fetchall()
|
||
result = []
|
||
for r in rows:
|
||
d = dict(r)
|
||
d["tables"] = _get_tables_for_config(d["id"], active_only=False)
|
||
result.append(d)
|
||
return result
|
||
|
||
@app.post("/api/adb/test/{vid}")
|
||
async def test_adb(vid: str, u=Depends(require("admin","user"))):
|
||
with db() as c:
|
||
cfg = c.execute("SELECT * FROM adb_vector_configs WHERE id=?",(vid,)).fetchone()
|
||
if not cfg: raise HTTPException(404)
|
||
cname = cfg["config_name"]
|
||
try:
|
||
conn = _get_adb_connection(dict(cfg))
|
||
cur = conn.cursor(); cur.execute("SELECT 1 FROM DUAL"); cur.close(); conn.close()
|
||
_config_log("adb", vid, cname, "success", "test", "Conexão Autonomous DB OK!", u["id"], u["username"])
|
||
return {"status":"success","message":"Conexão Autonomous DB OK!"}
|
||
except ImportError:
|
||
_config_log("adb", vid, cname, "error", "test", "python-oracledb não disponível no container.", u["id"], u["username"])
|
||
return {"status":"error","message":"python-oracledb não disponível no container."}
|
||
except Exception as e:
|
||
msg = str(e)[:500]
|
||
_config_log("adb", vid, cname, "error", "test", msg, u["id"], u["username"])
|
||
return {"status":"error","message":msg}
|
||
|
||
@app.delete("/api/adb/configs/{vid}")
|
||
async def del_adb(vid: str, u=Depends(require("admin","user"))):
|
||
with db() as c: c.execute("DELETE FROM adb_vector_configs WHERE id=?", (vid,))
|
||
d = WALLET_DIR / vid
|
||
if d.exists(): shutil.rmtree(d)
|
||
return {"ok": True}
|
||
|
||
@app.put("/api/adb/configs/{vid}")
|
||
async def update_adb(
|
||
vid: str,
|
||
config_name: str = Form(...), dsn: str = Form(...), username: str = Form(...),
|
||
password: str = Form(""), wallet_password: str = Form(""),
|
||
table_name: str = Form("CIS_EMBEDDINGS"), use_mtls: str = Form("true"),
|
||
genai_config_id: str = Form(""), embedding_model_id: str = Form("cohere.embed-v4.0"),
|
||
wallet: Optional[UploadFile] = File(None),
|
||
u=Depends(require("admin","user"))
|
||
):
|
||
with db() as c:
|
||
existing = c.execute("SELECT * FROM adb_vector_configs WHERE id=?", (vid,)).fetchone()
|
||
if not existing: raise HTTPException(404)
|
||
if u["role"] != "admin" and existing["user_id"] != u["id"]: raise HTTPException(403)
|
||
use_mtls_bool = use_mtls.lower() in ("true", "1", "yes")
|
||
sets = "config_name=?,dsn=?,username=?,table_name=?,use_mtls=?,genai_config_id=?,embedding_model_id=?"
|
||
vals = [config_name, dsn, username, table_name, int(use_mtls_bool), genai_config_id or None, embedding_model_id]
|
||
if password:
|
||
sets += ",password_enc=?"
|
||
vals.append(_enc(password))
|
||
if wallet_password:
|
||
sets += ",wallet_password_enc=?"
|
||
vals.append(_enc(wallet_password))
|
||
vals.append(vid)
|
||
with db() as c:
|
||
c.execute(f"UPDATE adb_vector_configs SET {sets} WHERE id=?", vals)
|
||
if wallet and wallet.filename:
|
||
import zipfile
|
||
wdir = WALLET_DIR / vid; wdir.mkdir(parents=True, exist_ok=True)
|
||
zp = wdir / "wallet.zip"
|
||
zp.write_bytes(await wallet.read())
|
||
with zipfile.ZipFile(str(zp), 'r') as z: z.extractall(str(wdir))
|
||
with db() as c: c.execute("UPDATE adb_vector_configs SET wallet_dir=? WHERE id=?", (str(wdir), vid))
|
||
_config_log("adb", vid, config_name, "success", "upload", "Wallet atualizado", u["id"], u["username"])
|
||
_audit(u["id"], u["username"], "update_adb_config", vid, config_name)
|
||
_config_log("adb", vid, config_name, "success", "save", f"Conexão atualizada: {config_name} ({dsn})", u["id"], u["username"])
|
||
return {"id": vid, "config_name": config_name}
|
||
|
||
_TABLE_NAME_RE = re.compile(r'^[A-Z][A-Z0-9_]{0,127}$')
|
||
|
||
@app.get("/api/adb/{vid}/tables")
|
||
async def list_adb_tables(vid: str, u=Depends(current_user)):
|
||
with db() as c:
|
||
cfg = c.execute("SELECT id FROM adb_vector_configs WHERE id=?", (vid,)).fetchone()
|
||
if not cfg: raise HTTPException(404)
|
||
return _get_tables_for_config(vid, active_only=False)
|
||
|
||
@app.post("/api/adb/{vid}/tables")
|
||
async def add_adb_table(vid: str, req: dict, u=Depends(require("admin","user"))):
|
||
table_name = req.get("table_name", "").strip().upper()
|
||
description = req.get("description", "").strip()
|
||
if not table_name: raise HTTPException(400, "table_name é obrigatório")
|
||
if not _TABLE_NAME_RE.match(table_name): raise HTTPException(400, "Nome da tabela inválido. Use letras maiúsculas, números e underscores.")
|
||
with db() as c:
|
||
cfg = c.execute("SELECT * FROM adb_vector_configs WHERE id=?", (vid,)).fetchone()
|
||
if not cfg: raise HTTPException(404, "ADB config not found")
|
||
tid = str(uuid.uuid4())
|
||
try:
|
||
with db() as c:
|
||
c.execute("INSERT INTO adb_vector_tables (id, adb_config_id, table_name, description) VALUES (?,?,?,?)",
|
||
(tid, vid, table_name, description))
|
||
except sqlite3.IntegrityError:
|
||
raise HTTPException(409, f"Tabela '{table_name}' já registrada nesta conexão")
|
||
_config_log("adb", vid, cfg["config_name"], "success", "add_table", f"Tabela '{table_name}' registrada", u["id"], u["username"])
|
||
return {"id": tid, "table_name": table_name}
|
||
|
||
@app.delete("/api/adb/{vid}/tables/{tid}")
|
||
async def remove_adb_table(vid: str, tid: str, u=Depends(require("admin","user"))):
|
||
with db() as c:
|
||
row = c.execute("SELECT * FROM adb_vector_tables WHERE id=? AND adb_config_id=?", (tid, vid)).fetchone()
|
||
if not row: raise HTTPException(404, "Table entry not found")
|
||
with db() as c:
|
||
c.execute("DELETE FROM adb_vector_tables WHERE id=?", (tid,))
|
||
return {"ok": True}
|
||
|
||
@app.put("/api/adb/{vid}/tables/{tid}")
|
||
async def update_adb_table(vid: str, tid: str, req: dict, u=Depends(require("admin","user"))):
|
||
with db() as c:
|
||
row = c.execute("SELECT * FROM adb_vector_tables WHERE id=? AND adb_config_id=?", (tid, vid)).fetchone()
|
||
if not row: raise HTTPException(404, "Table entry not found")
|
||
sets, vals = [], []
|
||
if "table_name" in req:
|
||
tn = req["table_name"].strip().upper()
|
||
if not tn: raise HTTPException(400, "table_name é obrigatório")
|
||
if not _TABLE_NAME_RE.match(tn): raise HTTPException(400, "Nome da tabela inválido")
|
||
sets.append("table_name=?"); vals.append(tn)
|
||
if "description" in req:
|
||
sets.append("description=?"); vals.append(req["description"])
|
||
if "is_active" in req:
|
||
sets.append("is_active=?"); vals.append(int(req["is_active"]))
|
||
if not sets: return {"ok": True}
|
||
vals.append(tid)
|
||
try:
|
||
with db() as c:
|
||
c.execute(f"UPDATE adb_vector_tables SET {','.join(sets)} WHERE id=?", vals)
|
||
except sqlite3.IntegrityError:
|
||
raise HTTPException(409, "Tabela com este nome já existe nesta conexão")
|
||
return {"ok": True}
|
||
|
||
@app.post("/api/adb/{vid}/ingest")
|
||
async def ingest_documents(vid: str, req: IngestDocReq, bg: BackgroundTasks, u=Depends(require("admin","user"))):
|
||
with db() as c:
|
||
cfg = c.execute("SELECT * FROM adb_vector_configs WHERE id=?", (vid,)).fetchone()
|
||
if not cfg: raise HTTPException(404, "ADB config not found")
|
||
cfg = dict(cfg)
|
||
if not cfg.get("genai_config_id"):
|
||
raise HTTPException(400, "GenAI config not linked to this ADB connection")
|
||
with db() as c:
|
||
gc = c.execute("SELECT * FROM genai_configs WHERE id=?", (cfg["genai_config_id"],)).fetchone()
|
||
if not gc: raise HTTPException(400, "Linked GenAI config not found")
|
||
bg.add_task(_ingest_documents_task, cfg, dict(gc), req.documents, u["id"], u["username"], table_name=req.table_name)
|
||
return {"ok": True, "message": f"Ingestão iniciada para {len(req.documents)} documentos", "config_id": vid}
|
||
|
||
def _ingest_documents_task(cfg: dict, genai_cfg: dict, documents: list, user_id: str, username: str, table_name: str = None):
|
||
"""Background task: embed and insert documents into ADB via OCI GenAI."""
|
||
import array
|
||
emb_model = cfg.get("embedding_model_id", "cohere.embed-v4.0")
|
||
table_name = table_name or cfg.get("table_name", "CIS_EMBEDDINGS")
|
||
conn = _get_adb_connection(cfg)
|
||
try:
|
||
cur = conn.cursor()
|
||
inserted = 0
|
||
for doc in documents:
|
||
try:
|
||
content = doc.get("content", "")
|
||
if not content: continue
|
||
embedding = _embed_text(content, genai_cfg, emb_model)
|
||
vec = array.array('d', embedding)
|
||
cur.execute(f"""
|
||
INSERT INTO {table_name} (ID, CONTENT, EMBEDDING, METADATA, SOURCE)
|
||
VALUES (:1, :2, :3, :4, :5)
|
||
""", [str(uuid.uuid4()), content, vec, doc.get("metadata", ""), doc.get("source", "manual_upload")])
|
||
inserted += 1
|
||
except Exception as e:
|
||
log.error(f"Failed to ingest document: {e}")
|
||
conn.commit()
|
||
cur.close()
|
||
log.info(f"Ingested {inserted}/{len(documents)} documents into {table_name}")
|
||
_audit(user_id, username, "ingest_documents", cfg["id"], f"{inserted} documents")
|
||
_config_log("adb", cfg["id"], cfg.get("config_name"), "success", "ingest", f"{inserted}/{len(documents)} documentos ingeridos em {table_name}", user_id, username)
|
||
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)
|
||
finally:
|
||
conn.close()
|
||
|
||
# ── Embeddings ────────────────────────────────────────────────────────────────
|
||
def _chunk_report_by_section(report_data: dict) -> list:
|
||
"""Chunk a CIS report into documents grouped by section."""
|
||
if isinstance(report_data, str):
|
||
report_data = json.loads(report_data)
|
||
findings = report_data.get("findings", {})
|
||
tenancy = report_data.get("tenancy", "unknown")
|
||
generated_at = report_data.get("generated_at", "")
|
||
regions = report_data.get("regions", [])
|
||
compartments = report_data.get("compartments", [])
|
||
# Build context header for each chunk
|
||
ctx_parts = [f"Tenancy: {tenancy}"]
|
||
if regions:
|
||
ctx_parts.append(f"Regions: {', '.join(regions)}")
|
||
if compartments:
|
||
ctx_parts.append(f"Compartments: {', '.join(compartments[:50])}")
|
||
ctx_header = "\n".join(ctx_parts)
|
||
sections = {}
|
||
for cid, check in findings.items():
|
||
sec = check.get("section", "Other")
|
||
sections.setdefault(sec, [])
|
||
sections[sec].append(check)
|
||
documents = []
|
||
for section_name, checks in sections.items():
|
||
passed = sum(1 for c in checks if c.get("status") == "PASS")
|
||
failed = sum(1 for c in checks if c.get("status") == "FAIL")
|
||
review = sum(1 for c in checks if c.get("status") == "REVIEW")
|
||
lines = [ctx_header, "", f"Section: {section_name}", f"Total checks: {len(checks)}, Passed: {passed}, Failed: {failed}, Review: {review}", ""]
|
||
for c in checks:
|
||
status = c.get("status", "REVIEW")
|
||
lines.append(f"- [{c.get('id', '')}] {c.get('title', '')} — Status: {status}")
|
||
if c.get("findings"):
|
||
for f in c["findings"]:
|
||
lines.append(f" Finding: {f}")
|
||
documents.append({
|
||
"content": "\n".join(lines),
|
||
"source": f"CIS Report - {tenancy} - {generated_at}",
|
||
"metadata": f"tenancy: {tenancy}, section: {section_name}, total: {len(checks)}, passed: {passed}, failed: {failed}, review: {review}"
|
||
})
|
||
return documents
|
||
|
||
def _chunk_text_file(text: str, filename: str, chunk_size: int = 1000) -> list:
|
||
"""Split text into chunks by paragraphs or fixed size."""
|
||
paragraphs = [p.strip() for p in text.split("\n\n") if p.strip()]
|
||
documents = []
|
||
current_chunk = ""
|
||
chunk_num = 1
|
||
for para in paragraphs:
|
||
if len(current_chunk) + len(para) + 2 > chunk_size and current_chunk:
|
||
documents.append({"content": current_chunk, "source": filename, "metadata": f"chunk: {chunk_num}"})
|
||
chunk_num += 1
|
||
current_chunk = para
|
||
else:
|
||
current_chunk = current_chunk + "\n\n" + para if current_chunk else para
|
||
if current_chunk:
|
||
documents.append({"content": current_chunk, "source": filename, "metadata": f"chunk: {chunk_num}"})
|
||
return documents
|
||
|
||
def _get_adb_and_genai(vid: str):
|
||
"""Load ADB config and its linked GenAI config. Returns (adb_cfg, genai_cfg) or raises."""
|
||
with db() as c:
|
||
cfg = c.execute("SELECT * FROM adb_vector_configs WHERE id=?", (vid,)).fetchone()
|
||
if not cfg: raise HTTPException(404, "ADB config not found")
|
||
cfg = dict(cfg)
|
||
if not cfg.get("genai_config_id"):
|
||
raise HTTPException(400, "GenAI config not linked to this ADB connection")
|
||
with db() as c:
|
||
gc = c.execute("SELECT * FROM genai_configs WHERE id=?", (cfg["genai_config_id"],)).fetchone()
|
||
if not gc: raise HTTPException(400, "Linked GenAI config not found")
|
||
return cfg, dict(gc)
|
||
|
||
@app.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."""
|
||
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)
|
||
return {"tenancy": report_data.get("tenancy", "unknown"),
|
||
"regions": report_data.get("regions", []),
|
||
"compartments": report_data.get("compartments", []),
|
||
"total_chunks": len(documents),
|
||
"chunks": documents}
|
||
|
||
@app.post("/api/embeddings/report/{rid}")
|
||
async def embed_report(rid: str, req: dict, bg: BackgroundTasks, u=Depends(require("admin","user"))):
|
||
vid = req.get("adb_config_id")
|
||
if not vid: raise HTTPException(400, "adb_config_id is required")
|
||
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)
|
||
if not documents: raise HTTPException(400, "No sections found in report")
|
||
cfg, gc = _get_adb_and_genai(vid)
|
||
target_table = req.get("table_name") 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_report", rid, f"{len(documents)} sections")
|
||
return {"ok": True, "message": f"Embedding de {len(documents)} seções iniciado", "sections": len(documents)}
|
||
|
||
@app.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"))):
|
||
if not file.filename.lower().endswith(".txt"):
|
||
raise HTTPException(400, "Only .txt files are supported")
|
||
content = (await file.read()).decode("utf-8", errors="replace")
|
||
if not content.strip(): raise HTTPException(400, "File is empty")
|
||
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)
|
||
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_upload", file.filename, f"{len(documents)} chunks")
|
||
return {"ok": True, "message": f"Embedding de {len(documents)} chunks iniciado", "chunks": len(documents), "filename": file.filename}
|
||
|
||
@app.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)):
|
||
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"] or "CIS_EMBEDDINGS"
|
||
cur.execute(f"SELECT COUNT(*) FROM {table_name}")
|
||
total = cur.fetchone()[0]
|
||
cur.execute(f"""
|
||
SELECT ID, SOURCE, METADATA, CREATED_AT FROM {table_name}
|
||
ORDER BY CREATED_AT DESC
|
||
OFFSET :1 ROWS FETCH NEXT :2 ROWS ONLY
|
||
""", [offset, limit])
|
||
rows = []
|
||
for row in cur:
|
||
rows.append({"id": row[0], "source": row[1], "metadata": row[2],
|
||
"created_at": str(row[3]) if row[3] else None})
|
||
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]}")
|
||
|
||
@app.delete("/api/embeddings/{vid}/{doc_id}")
|
||
async def delete_embedding(vid: str, doc_id: str, table_name: str = Query(""), u=Depends(require("admin","user"))):
|
||
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"] or "CIS_EMBEDDINGS"
|
||
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]}")
|
||
|
||
# ── Reports ───────────────────────────────────────────────────────────────────
|
||
|
||
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"
|
||
|
||
@app.post("/api/reports/run")
|
||
async def run_report(req: RunReportReq, bg: BackgroundTasks, u=Depends(require("admin","user"))):
|
||
if req.level not in (1, 2): raise HTTPException(400, "Level must be 1 or 2")
|
||
with db() as c:
|
||
cfg = c.execute("SELECT * FROM oci_configs WHERE id=?",(req.config_id,)).fetchone()
|
||
if not cfg: raise HTTPException(404, "Config não encontrada")
|
||
rid = str(uuid.uuid4())
|
||
c.execute("INSERT INTO reports (id,user_id,tenancy_name,config_id,level,obp_checks,raw_data,redact_output,status) VALUES (?,?,?,?,?,?,?,?,?)",
|
||
(rid, u["id"], cfg["tenancy_name"], req.config_id, req.level, int(req.obp), int(req.raw), int(req.redact_output), "running"))
|
||
bg.add_task(_exec_report, rid, dict(cfg), req.regions, req.level, req.obp, req.raw, req.redact_output)
|
||
_audit(u["id"], u["username"], "run_report", rid)
|
||
return {"report_id": rid, "status": "running"}
|
||
|
||
async def _exec_report(rid, cfg, regions, level=2, obp=False, raw=False, redact_output=False):
|
||
rdir = REPORTS / rid; rdir.mkdir(parents=True, exist_ok=True)
|
||
config_path = str(OCI_DIR / cfg["id"] / "config")
|
||
try:
|
||
cmd = ["python3", "-u", "/app/cis_reports.py",
|
||
"-c", config_path,
|
||
"--report-directory", str(rdir),
|
||
"--level", str(level),
|
||
"--print-to-screen", "True",
|
||
"--report-summary-json"]
|
||
if regions: cmd += ["--regions", ",".join(regions)]
|
||
if obp: cmd.append("--obp")
|
||
if raw: cmd.append("--raw")
|
||
if redact_output: cmd.append("--redact-output")
|
||
proc = await asyncio.create_subprocess_exec(*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
|
||
_running_reports[rid] = proc
|
||
progress_lines = []
|
||
try:
|
||
while True:
|
||
line = await proc.stdout.readline()
|
||
if not line:
|
||
break
|
||
text = line.decode(errors="replace").strip()
|
||
if text:
|
||
progress_lines.append(text)
|
||
with db() as c:
|
||
c.execute("UPDATE reports SET progress=? WHERE id=?",
|
||
("\n".join(progress_lines[-50:]), rid))
|
||
await proc.wait()
|
||
finally:
|
||
_running_reports.pop(rid, None)
|
||
stderr_data = await proc.stderr.read()
|
||
# Check if cancelled
|
||
with db() as c:
|
||
cur_status = c.execute("SELECT status FROM reports WHERE id=?", (rid,)).fetchone()
|
||
if cur_status and cur_status["status"] == "cancelled":
|
||
return
|
||
if proc.returncode == 0:
|
||
# Scan output directory for all generated files
|
||
html_path = None; json_path = None
|
||
with db() as c:
|
||
for fpath in rdir.iterdir():
|
||
if fpath.is_file():
|
||
fname = fpath.name
|
||
ftype = fpath.suffix.lstrip(".")
|
||
category = _classify_report_file(fname)
|
||
fsize = fpath.stat().st_size
|
||
c.execute("INSERT INTO report_files (id,report_id,file_name,file_path,file_type,file_category,file_size) VALUES (?,?,?,?,?,?,?)",
|
||
(str(uuid.uuid4()), rid, fname, str(fpath), ftype, category, fsize))
|
||
if "summary_report" in fname and fname.endswith(".html"):
|
||
html_path = str(fpath)
|
||
elif "summary_report" in fname and fname.endswith(".json"):
|
||
json_path = str(fpath)
|
||
c.execute("UPDATE reports SET status='completed',progress=?,html_path=?,json_path=?,completed_at=datetime('now') WHERE id=?",
|
||
("\n".join(progress_lines), html_path, json_path, rid))
|
||
_config_log("oci", cfg["id"], cfg["tenancy_name"], "success", "report", f"Report completed: {rid}")
|
||
else:
|
||
err = (stderr_data.decode(errors="replace") if stderr_data else "Unknown")[:2000]
|
||
with db() as c:
|
||
c.execute("UPDATE reports SET status='failed',progress=?,error_msg=?,completed_at=datetime('now') WHERE id=?",
|
||
("\n".join(progress_lines), err, rid))
|
||
_config_log("oci", cfg["id"], cfg["tenancy_name"], "error", "report", err)
|
||
except Exception as e:
|
||
_running_reports.pop(rid, None)
|
||
with db() as c:
|
||
c.execute("UPDATE reports SET status='failed',error_msg=?,completed_at=datetime('now') WHERE id=?", (str(e)[:2000], rid))
|
||
_config_log("oci", cfg["id"], cfg["tenancy_name"], "error", "report", str(e)[:2000])
|
||
|
||
@app.get("/api/reports")
|
||
async def list_reports(u=Depends(current_user)):
|
||
with db() as c:
|
||
q = "SELECT id,user_id,tenancy_name,status,progress,level,obp_checks,raw_data,redact_output,created_at,completed_at,error_msg FROM reports"
|
||
rows = c.execute(q+" ORDER BY created_at DESC").fetchall() if u["role"]=="admin" \
|
||
else c.execute(q+" WHERE user_id=? ORDER BY created_at DESC",(u["id"],)).fetchall()
|
||
return [dict(r) for r in rows]
|
||
|
||
@app.get("/api/reports/{rid}/progress")
|
||
async def get_report_progress(rid: str, u=Depends(current_user)):
|
||
with db() as c:
|
||
r = c.execute("SELECT status,progress,created_at,completed_at,error_msg FROM reports WHERE id=?", (rid,)).fetchone()
|
||
if not r: raise HTTPException(404)
|
||
# Detect orphaned "running" reports: status is running but no live process
|
||
if r["status"] == "running" and rid not in _running_reports:
|
||
with db() as c:
|
||
c.execute("UPDATE reports SET status='failed', error_msg='Interrompido: processo não encontrado', completed_at=datetime('now') WHERE id=? AND status='running'", (rid,))
|
||
log.warning(f"Report {rid} marked as failed: no live process found")
|
||
return {**dict(r), "status": "failed", "error_msg": "Interrompido: processo não encontrado", "completed_at": datetime.utcnow().isoformat()}
|
||
return dict(r)
|
||
|
||
@app.post("/api/reports/{rid}/cancel")
|
||
async def cancel_report(rid: str, u=Depends(require("admin", "user"))):
|
||
with db() as c:
|
||
r = c.execute("SELECT status FROM reports WHERE id=?", (rid,)).fetchone()
|
||
if not r: raise HTTPException(404)
|
||
if r["status"] != "running":
|
||
raise HTTPException(400, "Report is not running")
|
||
proc = _running_reports.get(rid)
|
||
if proc:
|
||
try:
|
||
proc.terminate()
|
||
try:
|
||
await asyncio.wait_for(proc.wait(), timeout=5)
|
||
except asyncio.TimeoutError:
|
||
proc.kill()
|
||
except ProcessLookupError:
|
||
pass
|
||
_running_reports.pop(rid, None)
|
||
with db() as c:
|
||
c.execute("UPDATE reports SET status='cancelled',error_msg='Cancelled by user',completed_at=datetime('now') WHERE id=?", (rid,))
|
||
_audit(u["id"], u["username"], "cancel_report", rid)
|
||
return {"ok": True, "status": "cancelled"}
|
||
|
||
@app.get("/api/reports/{rid}")
|
||
async def get_report(rid, u=Depends(current_user)):
|
||
with db() as c: r=c.execute("SELECT * FROM reports WHERE id=?",(rid,)).fetchone()
|
||
if not r: raise HTTPException(404)
|
||
if u["role"]!="admin" and r["user_id"]!=u["id"]: raise HTTPException(403)
|
||
return dict(r)
|
||
|
||
@app.get("/api/reports/{rid}/files")
|
||
async def list_report_files(rid: str, u=Depends(current_user)):
|
||
with db() as c:
|
||
r = c.execute("SELECT user_id FROM reports WHERE id=?", (rid,)).fetchone()
|
||
if not r: raise HTTPException(404)
|
||
if u["role"] != "admin" and r["user_id"] != u["id"]: raise HTTPException(403)
|
||
files = c.execute(
|
||
"SELECT id,file_name,file_type,file_category,file_size FROM report_files WHERE report_id=? ORDER BY file_category,file_name",
|
||
(rid,)
|
||
).fetchall()
|
||
return [dict(f) for f in files]
|
||
|
||
@app.get("/api/reports/{rid}/files/{fid}/download")
|
||
async def download_report_file(rid: str, fid: str, u=Depends(current_user)):
|
||
with db() as c:
|
||
r = c.execute("SELECT user_id FROM reports WHERE id=?", (rid,)).fetchone()
|
||
if not r: raise HTTPException(404)
|
||
if u["role"] != "admin" and r["user_id"] != u["id"]: raise HTTPException(403)
|
||
f = c.execute("SELECT * FROM report_files WHERE id=? AND report_id=?", (fid, rid)).fetchone()
|
||
if not f: raise HTTPException(404)
|
||
p = Path(f["file_path"])
|
||
if not p.exists(): raise HTTPException(404, "File not found on disk")
|
||
return FileResponse(p, filename=f["file_name"])
|
||
|
||
@app.get("/api/reports/{rid}/html")
|
||
async def report_html(rid):
|
||
with db() as c: r=c.execute("SELECT html_path FROM reports WHERE id=?",(rid,)).fetchone()
|
||
if not r or not r["html_path"] or not Path(r["html_path"]).exists(): raise HTTPException(404)
|
||
return FileResponse(r["html_path"], media_type="text/html")
|
||
|
||
@app.get("/api/reports/{rid}/download")
|
||
async def report_dl(rid, fmt: str = Query("json"), u=Depends(current_user)):
|
||
with db() as c: r=c.execute("SELECT * FROM reports WHERE id=?",(rid,)).fetchone()
|
||
if not r: raise HTTPException(404)
|
||
p = r["json_path"] if fmt=="json" else r["html_path"]
|
||
if not p or not Path(p).exists(): raise HTTPException(404)
|
||
return FileResponse(p, filename=f"cis_{r['tenancy_name']}_{rid[:8]}.{fmt}")
|
||
|
||
# ── Chat Agent ────────────────────────────────────────────────────────────────
|
||
# (endpoints defined below _chat_core, after _agent_respond)
|
||
|
||
def _chat_start(msg: ChatMsg, u, attachments: list = None):
|
||
"""Start a chat: save user msg, resolve config, return (sid, mid, genai_cfg) or immediate response.
|
||
If genai_cfg is None, returns immediate fallback response in mid field as dict."""
|
||
sid = msg.session_id or str(uuid.uuid4())
|
||
with db() as c:
|
||
c.execute("INSERT INTO chat_messages (id,session_id,user_id,role,content,model_id,status) VALUES (?,?,?,?,?,?,?)",
|
||
(str(uuid.uuid4()), sid, u["id"], "user", msg.message, None, "done"))
|
||
|
||
genai_cfg = None
|
||
if msg.genai_config_id:
|
||
with db() as c:
|
||
row = c.execute("SELECT * FROM genai_configs WHERE id=?", (msg.genai_config_id,)).fetchone()
|
||
if row:
|
||
genai_cfg = dict(row)
|
||
if msg.temperature is not None: genai_cfg["temperature"] = msg.temperature
|
||
if msg.max_tokens is not None: genai_cfg["max_tokens"] = msg.max_tokens
|
||
if msg.top_p is not None: genai_cfg["top_p"] = msg.top_p
|
||
if msg.top_k is not None: genai_cfg["top_k"] = msg.top_k
|
||
if msg.frequency_penalty is not None: genai_cfg["frequency_penalty"] = msg.frequency_penalty
|
||
if msg.presence_penalty is not None: genai_cfg["presence_penalty"] = msg.presence_penalty
|
||
elif msg.model_id and msg.oci_config_id:
|
||
with db() as c:
|
||
oci_row = c.execute("SELECT * FROM oci_configs WHERE id=?", (msg.oci_config_id,)).fetchone()
|
||
if not oci_row:
|
||
raise HTTPException(400, "OCI config not found")
|
||
region = msg.genai_region or oci_row["region"]
|
||
compartment = _safe_dec(oci_row["compartment_id"]) if oci_row["compartment_id"] else ""
|
||
if not compartment:
|
||
raise HTTPException(400, "compartment_id required")
|
||
genai_cfg = {
|
||
"oci_config_id": msg.oci_config_id,
|
||
"model_id": msg.model_id,
|
||
"model_ocid": None,
|
||
"compartment_id": compartment,
|
||
"genai_region": region,
|
||
"endpoint": f"https://inference.generativeai.{region}.oci.oraclecloud.com",
|
||
"serving_type": "ON_DEMAND",
|
||
"dedicated_endpoint_id": None,
|
||
"temperature": msg.temperature if msg.temperature is not None else 1.0,
|
||
"max_tokens": msg.max_tokens if msg.max_tokens is not None else 6000,
|
||
"top_p": msg.top_p if msg.top_p is not None else 0.95,
|
||
"top_k": msg.top_k if msg.top_k is not None else 1,
|
||
"frequency_penalty": msg.frequency_penalty if msg.frequency_penalty is not None else 0.0,
|
||
"presence_penalty": msg.presence_penalty if msg.presence_penalty is not None else 0.0,
|
||
}
|
||
|
||
if not genai_cfg:
|
||
# No GenAI config — return immediate fallback
|
||
resp = _agent_respond(msg.message, u)
|
||
with db() as c:
|
||
c.execute("INSERT INTO chat_messages (id,session_id,user_id,role,content,model_id,status) VALUES (?,?,?,?,?,?,?)",
|
||
(str(uuid.uuid4()), sid, u["id"], "assistant", resp, None, "done"))
|
||
return sid, {"session_id": sid, "response": resp, "model_id": None, "status": "done"}, None
|
||
|
||
# Create placeholder assistant message for background processing
|
||
mid = str(uuid.uuid4())
|
||
with db() as c:
|
||
c.execute("INSERT INTO chat_messages (id,session_id,user_id,role,content,model_id,status) VALUES (?,?,?,?,?,?,?)",
|
||
(mid, sid, u["id"], "assistant", "", genai_cfg.get("model_id"), "processing"))
|
||
return sid, mid, genai_cfg
|
||
|
||
|
||
async def _chat_background(mid: str, sid: str, msg: ChatMsg, user: dict, genai_cfg: dict, attachments: list = None):
|
||
"""Background worker — processes GenAI chat, updates DB when done."""
|
||
log.info(f"Chat background started: mid={mid}, sid={sid}")
|
||
try:
|
||
history = []
|
||
with db() as c:
|
||
prev = c.execute("SELECT role,content FROM chat_messages WHERE session_id=? AND role IN ('user','assistant') AND status='done' ORDER BY created_at ASC", (sid,)).fetchall()
|
||
history = [{"role":r["role"],"content":r["content"]} for r in prev]
|
||
|
||
# ── RAG: augment with vector context from ALL active ADB configs ──
|
||
rag_context = ""
|
||
adb_cfgs = _get_active_adb_configs(user["id"])
|
||
if adb_cfgs:
|
||
all_documents = []
|
||
for adb_cfg in adb_cfgs:
|
||
try:
|
||
with db() as c:
|
||
emb_genai = c.execute("SELECT * FROM genai_configs WHERE id=?", (adb_cfg["genai_config_id"],)).fetchone()
|
||
if emb_genai:
|
||
emb_model = adb_cfg.get("embedding_model_id", "cohere.embed-v4.0")
|
||
query_embedding = _embed_text(msg.message, dict(emb_genai), emb_model)
|
||
tables = _get_tables_for_config(adb_cfg["id"], active_only=True)
|
||
if not tables:
|
||
tables = [{"table_name": adb_cfg.get("table_name", "CIS_EMBEDDINGS")}]
|
||
for tbl in tables:
|
||
try:
|
||
documents = _vector_search(adb_cfg, query_embedding, top_k=5, table_name=tbl["table_name"])
|
||
if documents:
|
||
for doc in documents:
|
||
doc["source"] = f"{doc.get('source', 'unknown')} [{tbl['table_name']}]"
|
||
all_documents.extend(documents)
|
||
log.info(f"RAG: Retrieved {len(documents)} docs from {tbl['table_name']}")
|
||
except Exception as te:
|
||
log.warning(f"RAG search failed for table {tbl['table_name']}: {te}")
|
||
except Exception as e:
|
||
log.warning(f"RAG retrieval failed for {adb_cfg.get('config_name','?')} (non-fatal): {e}")
|
||
if all_documents:
|
||
all_documents.sort(key=lambda d: d["distance"])
|
||
rag_context = _build_rag_context(all_documents[:10])
|
||
|
||
cfg_dict = dict(genai_cfg)
|
||
with db() as c:
|
||
sp_row = c.execute("SELECT content FROM system_prompts WHERE agent='chat' AND is_active=1 LIMIT 1").fetchone()
|
||
global_prompt = sp_row["content"] if sp_row and sp_row["content"] else ""
|
||
if rag_context:
|
||
augmented_message = RAG_CONTEXT_TEMPLATE.format(context=rag_context, question=msg.message)
|
||
cfg_dict["system_prompt"] = global_prompt or RAG_DEFAULT_SYSTEM_PROMPT
|
||
else:
|
||
augmented_message = msg.message
|
||
if global_prompt:
|
||
cfg_dict["system_prompt"] = global_prompt
|
||
|
||
mcp_tools = []
|
||
tool_defs = None
|
||
if msg.use_tools:
|
||
mcp_tools = _get_active_mcp_tools(user["id"])
|
||
if mcp_tools:
|
||
tool_defs = [t["tool"] for t in mcp_tools]
|
||
log.info(f"Chat with {len(tool_defs)} MCP tools available")
|
||
|
||
if history and _should_compact(history):
|
||
log.info(f"Compaction triggered: {len(history)} msgs, ~{_estimate_history_tokens(history)} est tokens")
|
||
history = _compact_history(sid, user["id"], cfg_dict, history)
|
||
log.info(f"Post-compaction: {len(history)} msgs, ~{_estimate_history_tokens(history)} est tokens")
|
||
|
||
hist = history[:-1] if len(history) > 1 else None
|
||
loop = asyncio.get_event_loop()
|
||
resp_text, tool_calls, tool_calls_raw = await loop.run_in_executor(
|
||
_chat_executor, partial(_call_genai, cfg_dict, augmented_message, hist,
|
||
tool_defs, None, None, attachments))
|
||
|
||
all_tool_results = []
|
||
accumulated_msgs = []
|
||
iterations = 0
|
||
api_format = GENAI_MODELS.get(genai_cfg.get("model_id", ""), {}).get("api_format", "GENERIC")
|
||
while tool_calls and iterations < 5:
|
||
iterations += 1
|
||
log.info(f"Tool use iteration {iterations}: {len(tool_calls)} tool call(s)")
|
||
iteration_results = []
|
||
for tc in tool_calls:
|
||
mcp_match = next((m for m in mcp_tools if m["tool"]["name"] == tc["name"]), None)
|
||
if mcp_match:
|
||
try:
|
||
result = await _execute_mcp_tool(mcp_match["server"], tc["name"], tc["arguments"])
|
||
log.info(f"Tool {tc['name']} executed successfully ({len(result)} chars)")
|
||
except Exception as te:
|
||
result = f"Erro ao executar tool {tc['name']}: {str(te)[:300]}"
|
||
log.warning(f"Tool {tc['name']} failed: {te}")
|
||
else:
|
||
result = f"Tool {tc['name']} não encontrada nos MCP servers ativos"
|
||
iteration_results.append({"tool_call_id": tc["id"], "name": tc["name"], "content": result})
|
||
all_tool_results.extend(iteration_results)
|
||
|
||
if api_format == "COHERE":
|
||
import oci
|
||
cohere_results = []
|
||
for tr in iteration_results:
|
||
tc_obj = oci.generative_ai_inference.models.CohereToolCall()
|
||
tc_obj.name = tr["name"]
|
||
tc_obj.parameters = {}
|
||
tr_obj = oci.generative_ai_inference.models.CohereToolResult()
|
||
tr_obj.call = tc_obj
|
||
tr_obj.outputs = [{"result": tr["content"]}]
|
||
cohere_results.append(tr_obj)
|
||
resp_text, tool_calls, tool_calls_raw = await loop.run_in_executor(
|
||
_chat_executor, partial(_call_genai, cfg_dict, augmented_message, hist,
|
||
tool_defs, cohere_results))
|
||
else:
|
||
import oci
|
||
assistant_msg = oci.generative_ai_inference.models.AssistantMessage()
|
||
assistant_msg.tool_calls = tool_calls_raw
|
||
accumulated_msgs.append(assistant_msg)
|
||
for tr in iteration_results:
|
||
tool_msg = oci.generative_ai_inference.models.ToolMessage()
|
||
tool_msg.tool_call_id = tr["tool_call_id"]
|
||
tool_content = oci.generative_ai_inference.models.TextContent()
|
||
tool_content.text = tr["content"]
|
||
tool_msg.content = [tool_content]
|
||
accumulated_msgs.append(tool_msg)
|
||
resp_text, tool_calls, tool_calls_raw = await loop.run_in_executor(
|
||
_chat_executor, partial(_call_genai, cfg_dict, augmented_message, hist,
|
||
tool_defs, None, accumulated_msgs))
|
||
|
||
resp = resp_text
|
||
if all_tool_results:
|
||
tools_info = '\n\n🔧 **Tools utilizadas:** ' + ', '.join(tr["name"] for tr in all_tool_results)
|
||
resp += tools_info
|
||
|
||
with db() as c:
|
||
c.execute("UPDATE chat_messages SET content=?, status='done' WHERE id=?", (resp, mid))
|
||
log.info(f"Chat {mid} completed successfully")
|
||
except Exception as e:
|
||
log.error(f"Chat {mid} failed: {e}")
|
||
with db() as c:
|
||
c.execute("UPDATE chat_messages SET content=?, status='failed' WHERE id=?",
|
||
(f"❌ Erro GenAI: {str(e)[:400]}", mid))
|
||
|
||
MAX_UPLOAD_FILES = 5
|
||
MAX_UPLOAD_SIZE = 10 * 1024 * 1024 # 10MB
|
||
IMAGE_MIMES = {"image/png", "image/jpeg", "image/gif", "image/webp", "image/bmp"}
|
||
DOC_MIMES = {"application/pdf"}
|
||
|
||
|
||
@app.post("/api/chat/upload")
|
||
async def chat_with_files(
|
||
bg: BackgroundTasks,
|
||
message: str = Form(""),
|
||
session_id: Optional[str] = Form(None),
|
||
genai_config_id: Optional[str] = Form(None),
|
||
model_id: Optional[str] = Form(None),
|
||
oci_config_id: Optional[str] = Form(None),
|
||
genai_region: Optional[str] = Form(None),
|
||
compartment_id: Optional[str] = Form(None),
|
||
use_tools: Optional[bool] = Form(True),
|
||
temperature: Optional[float] = Form(None),
|
||
max_tokens: Optional[int] = Form(None),
|
||
top_p: Optional[float] = Form(None),
|
||
top_k: Optional[int] = Form(None),
|
||
frequency_penalty: Optional[float] = Form(None),
|
||
presence_penalty: Optional[float] = Form(None),
|
||
files: List[UploadFile] = File(default=[]),
|
||
u=Depends(current_user),
|
||
):
|
||
if len(files) > MAX_UPLOAD_FILES:
|
||
raise HTTPException(400, f"Máximo {MAX_UPLOAD_FILES} arquivos por mensagem")
|
||
|
||
attachments = []
|
||
file_names = []
|
||
for f in files:
|
||
data = await f.read()
|
||
if len(data) > MAX_UPLOAD_SIZE:
|
||
raise HTTPException(400, f"Arquivo {f.filename} excede 10MB")
|
||
mime = f.content_type or "application/octet-stream"
|
||
b64 = base64.b64encode(data).decode()
|
||
data_uri = f"data:{mime};base64,{b64}"
|
||
if mime in IMAGE_MIMES:
|
||
attachments.append({"type": "image", "mime": mime, "data_uri": data_uri})
|
||
elif mime in DOC_MIMES:
|
||
attachments.append({"type": "document", "mime": mime, "data_uri": data_uri})
|
||
else:
|
||
try:
|
||
text = data.decode("utf-8", errors="replace")
|
||
except Exception:
|
||
text = data.decode("latin-1", errors="replace")
|
||
message = f"{message}\n\n--- Conteúdo de {f.filename} ---\n{text[:50000]}"
|
||
file_names.append(f.filename)
|
||
|
||
msg = ChatMsg(
|
||
message=message or "Analise os arquivos anexados.",
|
||
session_id=session_id,
|
||
genai_config_id=genai_config_id,
|
||
model_id=model_id,
|
||
oci_config_id=oci_config_id,
|
||
genai_region=genai_region,
|
||
compartment_id=compartment_id,
|
||
use_tools=use_tools if use_tools is not None else True,
|
||
temperature=temperature,
|
||
max_tokens=max_tokens,
|
||
top_p=top_p,
|
||
top_k=top_k,
|
||
frequency_penalty=frequency_penalty,
|
||
presence_penalty=presence_penalty,
|
||
)
|
||
|
||
sid, mid_or_result, genai_cfg = _chat_start(msg, u, attachments=attachments if attachments else None)
|
||
if genai_cfg is None:
|
||
return mid_or_result # immediate fallback response
|
||
|
||
if file_names:
|
||
with db() as c:
|
||
c.execute("UPDATE chat_messages SET content = content || ? WHERE session_id=? AND role='user' AND status='done' ORDER BY created_at DESC LIMIT 1",
|
||
(f"\n[📎 {', '.join(file_names)}]", sid))
|
||
|
||
bg.add_task(_chat_background, mid_or_result, sid, msg, dict(u), genai_cfg, attachments if attachments else None)
|
||
return {"message_id": mid_or_result, "session_id": sid, "status": "processing"}
|
||
|
||
|
||
@app.post("/api/chat")
|
||
async def chat(msg: ChatMsg, bg: BackgroundTasks, u=Depends(current_user)):
|
||
sid, mid_or_result, genai_cfg = _chat_start(msg, u)
|
||
if genai_cfg is None:
|
||
return mid_or_result # immediate fallback response
|
||
bg.add_task(_chat_background, mid_or_result, sid, msg, dict(u), genai_cfg)
|
||
return {"message_id": mid_or_result, "session_id": sid, "status": "processing"}
|
||
|
||
|
||
@app.get("/api/chat/{mid}/status")
|
||
async def chat_message_status(mid: str, u=Depends(current_user)):
|
||
with db() as c:
|
||
r = c.execute("SELECT id, session_id, role, content, model_id, status FROM chat_messages WHERE id=?", (mid,)).fetchone()
|
||
if not r:
|
||
raise HTTPException(404, "Message not found")
|
||
return dict(r)
|
||
|
||
|
||
def _agent_respond(msg, user):
|
||
m = msg.lower().strip()
|
||
if any(k in m for k in ["status","health","como está","saúde"]):
|
||
cli = "✅ Instalado" if shutil.which("oci") else "❌ Não instalado"
|
||
with db() as c:
|
||
nc=c.execute("SELECT COUNT(*) n FROM oci_configs").fetchone()["n"]
|
||
nr=c.execute("SELECT COUNT(*) n FROM reports").fetchone()["n"]
|
||
nu=c.execute("SELECT COUNT(*) n FROM users WHERE is_active=1").fetchone()["n"]
|
||
nm=c.execute("SELECT COUNT(*) n FROM mcp_servers WHERE is_active=1").fetchone()["n"]
|
||
ng=c.execute("SELECT COUNT(*) n FROM genai_configs").fetchone()["n"]
|
||
return (f"📊 **Status do Sistema**\n\n• OCI CLI: {cli}\n• Configs OCI: {nc}\n• GenAI Models: {ng}\n• MCP Servers: {nm}\n• Relatórios: {nr}\n• Usuários ativos: {nu}\n• Servidor: ✅ Online\n• Versão: v{VERSION}")
|
||
if any(k in m for k in ["ajuda","help","comandos"]):
|
||
return ("🤖 **Comandos disponíveis:**\n\n• `status` — Status do sistema\n• `listar configs` — Configurações OCI\n• `verificar cis` — Checks CIS 3.0\n• `modelos` — Modelos GenAI disponíveis\n• `ajuda` — Esta mensagem\n\n💡 Configure um modelo GenAI para chat com IA.")
|
||
if any(k in m for k in ["modelo","modelos","genai"]):
|
||
lines = ["🧠 **Modelos OCI Generative AI disponíveis:**\n"]
|
||
for mid, info in GENAI_MODELS.items():
|
||
lines.append(f"• `{mid}` — {info['name']} ({info['provider']})")
|
||
lines.append("\nConfigure em **GenAI Config** para usar no chat.")
|
||
return "\n".join(lines)
|
||
if any(k in m for k in ["cis","benchmark","checks","verificar"]):
|
||
return ("🔒 **CIS OCI Foundations Benchmark 3.0**\n\n54 controles em 8 domínios:\n• IAM: 17 controles\n• Networking: 8 controles\n• Compute: 3 controles\n• Logging & Monitoring: 18 controles\n• Storage: 6 controles\n• Asset Management: 2 controles\n\nConfigure OCI e execute um relatório na aba **Report**.")
|
||
return ("Sou o **OCI CIS AI Agent v" + VERSION + "**. Sem modelo GenAI configurado, uso respostas locais.\n\n"
|
||
"Para chat com IA:\n1. Configure **OCI Credentials**\n2. Configure **GenAI** com modelo e região\n3. Selecione o modelo no chat\n\nDigite **ajuda** para ver os comandos.")
|
||
|
||
@app.delete("/api/chat/{sid}")
|
||
async def clear_chat(sid, u=Depends(current_user)):
|
||
with db() as c: c.execute("DELETE FROM chat_messages WHERE session_id=? AND user_id=?", (sid, u["id"]))
|
||
return {"ok": True}
|
||
|
||
# ── Audit ─────────────────────────────────────────────────────────────────────
|
||
@app.get("/api/audit-log")
|
||
async def audit_log(limit:int=Query(100,le=500), u=Depends(require("admin"))):
|
||
with db() as c: rows=c.execute("SELECT * FROM audit_log ORDER BY created_at DESC LIMIT ?",(limit,)).fetchall()
|
||
return [dict(r) for r in rows]
|
||
|
||
# ── Config Logs ───────────────────────────────────────────────────────────────
|
||
@app.get("/api/config-logs")
|
||
async def get_config_logs(
|
||
config_type: str = Query(None), config_id: str = Query(None),
|
||
severity: str = Query(None), limit: int = Query(50, le=200),
|
||
u=Depends(current_user)
|
||
):
|
||
query = "SELECT * FROM config_logs WHERE 1=1"
|
||
params = []
|
||
if config_type: query += " AND config_type=?"; params.append(config_type)
|
||
if config_id: query += " AND config_id=?"; params.append(config_id)
|
||
if severity: query += " AND severity=?"; params.append(severity)
|
||
if u["role"] != "admin": query += " AND user_id=?"; params.append(u["id"])
|
||
query += " ORDER BY created_at DESC LIMIT ?"
|
||
params.append(limit)
|
||
with db() as c: rows = c.execute(query, params).fetchall()
|
||
return [dict(r) for r in rows]
|
||
|
||
# ── App Settings ──────────────────────────────────────────────────────────────
|
||
@app.get("/api/settings/{key}")
|
||
async def get_setting(key: str, u=Depends(current_user)):
|
||
with db() as c:
|
||
row = c.execute("SELECT value FROM app_settings WHERE key=?", (key,)).fetchone()
|
||
return {"key": key, "value": row["value"] if row else ""}
|
||
|
||
@app.put("/api/settings/{key}")
|
||
async def put_setting(key: str, body: dict, u=Depends(require("admin"))):
|
||
value = body.get("value", "")
|
||
with db() as c:
|
||
c.execute("INSERT INTO app_settings (key,value,updated_at) VALUES (?,?,datetime('now')) ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at", (key, value))
|
||
return {"key": key, "value": value}
|
||
|
||
# ── System Prompts ────────────────────────────────────────────────────────────
|
||
@app.get("/api/prompts/{agent}")
|
||
async def list_prompts(agent: str, u=Depends(current_user)):
|
||
with db() as c:
|
||
rows = c.execute("SELECT * FROM system_prompts WHERE agent=? ORDER BY is_active DESC, created_at DESC", (agent,)).fetchall()
|
||
return [dict(r) for r in rows]
|
||
|
||
@app.post("/api/prompts")
|
||
async def save_prompt(body: dict, u=Depends(require("admin"))):
|
||
agent = body.get("agent", "chat")
|
||
with db() as c:
|
||
count = c.execute("SELECT COUNT(*) FROM system_prompts WHERE agent=?", (agent,)).fetchone()[0]
|
||
if count >= 10:
|
||
raise HTTPException(400, "Limite de 10 prompts atingido. Exclua um antes de criar outro.")
|
||
pid = str(uuid.uuid4())
|
||
name = body.get("name", "Sem nome")
|
||
content = body.get("content", "")
|
||
is_active = body.get("is_active", False)
|
||
with db() as c:
|
||
if is_active:
|
||
c.execute("UPDATE system_prompts SET is_active=0 WHERE agent=?", (agent,))
|
||
c.execute("INSERT INTO system_prompts (id,name,agent,content,is_active) VALUES (?,?,?,?,?)",
|
||
(pid, name, agent, content, int(is_active)))
|
||
return {"id": pid}
|
||
|
||
@app.put("/api/prompts/{pid}")
|
||
async def update_prompt(pid: str, body: dict, u=Depends(require("admin"))):
|
||
with db() as c:
|
||
existing = c.execute("SELECT * FROM system_prompts WHERE id=?", (pid,)).fetchone()
|
||
if not existing:
|
||
raise HTTPException(404)
|
||
name = body.get("name", existing["name"])
|
||
content = body.get("content", existing["content"])
|
||
is_active = body.get("is_active", existing["is_active"])
|
||
with db() as c:
|
||
if is_active:
|
||
c.execute("UPDATE system_prompts SET is_active=0 WHERE agent=?", (existing["agent"],))
|
||
c.execute("UPDATE system_prompts SET name=?,content=?,is_active=? WHERE id=?",
|
||
(name, content, int(is_active), pid))
|
||
return {"id": pid}
|
||
|
||
@app.delete("/api/prompts/{pid}")
|
||
async def delete_prompt(pid: str, u=Depends(require("admin"))):
|
||
with db() as c:
|
||
c.execute("DELETE FROM system_prompts WHERE id=?", (pid,))
|
||
return {"ok": True}
|
||
|
||
# ── CIS Engine Version Management ─────────────────────────────────────────────
|
||
CIS_REPORTS_PATH = Path("/app/cis_reports.py")
|
||
CIS_GITHUB_RAW = "https://raw.githubusercontent.com/oci-landing-zones/oci-cis-landingzone-quickstart/main/scripts/cis_reports.py"
|
||
CIS_PATCHES = [
|
||
{"name": "region_none_check_1",
|
||
"original": "elif region.region_name in self.__regions_to_run_in or self.__run_in_all_regions:",
|
||
"patched": "elif self.__run_in_all_regions or (self.__regions_to_run_in and region.region_name in self.__regions_to_run_in):"},
|
||
{"name": "region_none_check_2",
|
||
"original": "if self.__home_region not in self.__regions_to_run_in:",
|
||
"patched": "if not self.__run_in_all_regions and self.__regions_to_run_in and self.__home_region not in self.__regions_to_run_in:"},
|
||
]
|
||
|
||
def _read_cis_version(content: str = None) -> dict:
|
||
if content is None:
|
||
if not CIS_REPORTS_PATH.exists(): return {"version": "unknown", "date": "unknown"}
|
||
content = CIS_REPORTS_PATH.read_text(encoding="utf-8", errors="replace")
|
||
ver = re.search(r'RELEASE_VERSION\s*=\s*["\']([^"\']+)["\']', content)
|
||
dt = re.search(r'UPDATED_DATE\s*=\s*["\']([^"\']+)["\']', content)
|
||
return {"version": ver.group(1) if ver else "unknown", "date": dt.group(1) if dt else "unknown"}
|
||
|
||
@app.get("/api/cis-engine/version")
|
||
async def cis_engine_version(u=Depends(current_user)):
|
||
info = _read_cis_version()
|
||
return {"version": info["version"], "updated_date": info["date"], "file_path": str(CIS_REPORTS_PATH)}
|
||
|
||
@app.get("/api/cis-engine/check-update")
|
||
async def cis_engine_check_update(u=Depends(require("admin"))):
|
||
import requests as req
|
||
local = _read_cis_version()
|
||
try:
|
||
resp = req.get(CIS_GITHUB_RAW, timeout=30)
|
||
resp.raise_for_status()
|
||
remote = _read_cis_version(resp.text)
|
||
except Exception as e:
|
||
raise HTTPException(502, f"Failed to check GitHub: {str(e)[:300]}")
|
||
return {
|
||
"local_version": local["version"], "local_date": local["date"],
|
||
"remote_version": remote["version"], "remote_date": remote["date"],
|
||
"update_available": remote["version"] != local["version"]
|
||
}
|
||
|
||
@app.post("/api/cis-engine/update")
|
||
async def cis_engine_update(u=Depends(require("admin"))):
|
||
import requests as req
|
||
old = _read_cis_version()
|
||
try:
|
||
resp = req.get(CIS_GITHUB_RAW, timeout=60)
|
||
resp.raise_for_status()
|
||
content = resp.text
|
||
except Exception as e:
|
||
raise HTTPException(502, f"Failed to download from GitHub: {str(e)[:300]}")
|
||
new = _read_cis_version(content)
|
||
# Apply patches
|
||
patches_applied = []
|
||
for p in CIS_PATCHES:
|
||
if p["original"] in content:
|
||
content = content.replace(p["original"], p["patched"])
|
||
patches_applied.append(p["name"])
|
||
CIS_REPORTS_PATH.write_text(content, encoding="utf-8")
|
||
# Save version info
|
||
with db() as c:
|
||
c.execute("INSERT INTO app_settings (key,value,updated_at) VALUES ('cis_engine_version',?,datetime('now')) ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at",
|
||
(new["version"],))
|
||
log.info(f"CIS engine updated: {old['version']} → {new['version']}, patches: {patches_applied}")
|
||
_audit(u["id"], u["username"], "cis_engine_update", None, f"{old['version']} → {new['version']}")
|
||
return {"ok": True, "old_version": old["version"], "new_version": new["version"], "patches_applied": patches_applied}
|
||
|
||
@app.get("/api/health")
|
||
async def health():
|
||
return {"status":"ok","ts":datetime.utcnow().isoformat(),"version":VERSION}
|
||
|
||
@app.on_event("startup")
|
||
async def startup():
|
||
init_db()
|
||
# Mark orphaned "running" reports as failed (e.g. after container restart)
|
||
with db() as c:
|
||
orphaned = c.execute("UPDATE reports SET status='failed', error_msg='Interrompido: container reiniciado' WHERE status='running'").rowcount
|
||
if orphaned:
|
||
log.warning(f"Marked {orphaned} orphaned running report(s) as failed")
|
||
# Auto-register CIS Compliance Scanner MCP server if not present
|
||
with db() as c:
|
||
existing = c.execute("SELECT id FROM mcp_servers WHERE name='CIS Compliance Scanner'").fetchone()
|
||
if not existing:
|
||
mid = str(uuid.uuid4())
|
||
c.execute(
|
||
"INSERT INTO mcp_servers (id, user_id, name, description, server_type, command, args) VALUES (?,?,?,?,?,?,?)",
|
||
(mid, "system", "CIS Compliance Scanner",
|
||
"OCI CIS Foundations Benchmark 3.0 - 48 CIS checks + 11 OBP checks via MCP",
|
||
"stdio", "python3", json.dumps(["/app/mcp_cis_server.py"]))
|
||
)
|
||
log.info(f"Auto-registered CIS Compliance Scanner MCP server (id={mid})")
|
||
|
||
# Save CIS engine version to app_settings
|
||
try:
|
||
vi = _read_cis_version()
|
||
with db() as c:
|
||
c.execute("INSERT INTO app_settings (key,value,updated_at) VALUES ('cis_engine_version',?,datetime('now')) ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at",
|
||
(vi["version"],))
|
||
log.info(f"CIS engine version: {vi['version']} ({vi['date']})")
|
||
except Exception as e:
|
||
log.warning(f"Could not read CIS engine version: {e}")
|
||
|
||
log.info(f"OCI CIS AI Agent v{VERSION} API started")
|
||
|
||
if __name__ == "__main__":
|
||
import uvicorn
|
||
uvicorn.run(app, host="0.0.0.0", port=8000)
|