feat: multimodal chat, region-specific MCP scanning, and resilience improvements

Add image/PDF/text file upload in chat via new /api/chat/upload endpoint
with OCI GenAI ImageContent and DocumentContent support. Frontend shows
attach button, file preview, and handles multipart upload seamlessly.

Add optional regions parameter to all CIS MCP scan tools for targeting
specific OCI regions instead of full tenancy. Session cache scoped by
config+regions for concurrent multi-region scans.

Add orphaned report auto-detection on progress poll (marks as failed if
process not found). Increase nginx proxy timeout to 15 minutes. Improve
$api error handling for non-JSON responses (504 HTML pages).

Update README to v1.9.
This commit is contained in:
nogueiraguh
2026-03-05 13:27:17 -03:00
parent 1d52234b1a
commit ef43eaa7ba
5 changed files with 221 additions and 47 deletions

View File

@@ -1013,7 +1013,8 @@ def _compact_history(session_id: str, user_id: str, genai_cfg: dict, history: li
def _call_genai(gc: dict, message: str, history: list = None, tools: list = None,
tool_results_cohere: list = None,
extra_messages: list = None) -> tuple:
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.
@@ -1127,12 +1128,29 @@ def _call_genai(gc: dict, message: str, history: list = None, tools: list = None
msg.content = [content]
messages.append(msg)
# Current user message
content = oci.generative_ai_inference.models.TextContent()
content.text = message
# 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]
user_message.content = content_parts
messages.append(user_message)
# Append accumulated tool use loop messages (assistant+tool_calls → tool_results → ...)
@@ -2096,6 +2114,12 @@ 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")
@@ -2170,8 +2194,10 @@ async def report_dl(rid, fmt: str = Query("json"), u=Depends(current_user)):
return FileResponse(p, filename=f"cis_{r['tenancy_name']}_{rid[:8]}.{fmt}")
# ── Chat Agent ────────────────────────────────────────────────────────────────
@app.post("/api/chat")
async def chat(msg: ChatMsg, u=Depends(current_user)):
# (endpoints defined below _chat_core, after _agent_respond)
async def _chat_core_impl(msg: ChatMsg, u, attachments: list = None):
"""Internal chat implementation — called by both /api/chat and /api/chat/upload."""
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) VALUES (?,?,?,?,?,?)",
@@ -2284,7 +2310,7 @@ async def chat(msg: ChatMsg, u=Depends(current_user)):
log.info(f"Post-compaction: {len(history)} msgs, ~{_estimate_history_tokens(history)} est tokens")
hist = history[:-1] if len(history) > 1 else None
resp_text, tool_calls, tool_calls_raw = _call_genai(cfg_dict, augmented_message, hist, tools=tool_defs)
resp_text, tool_calls, tool_calls_raw = _call_genai(cfg_dict, augmented_message, hist, tools=tool_defs, attachments=attachments)
# Tool use loop (max 5 iterations)
# Accumulate extra_messages so the model sees the full tool use conversation
@@ -2360,6 +2386,90 @@ async def chat(msg: ChatMsg, u=Depends(current_user)):
result["tools_used"] = [{"name": tr["name"], "result_preview": tr["content"][:200]} for tr in all_tool_results]
return result
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(
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")
# Process uploaded files into attachments
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:
# Text-based files: read content and append to message
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)
# Build a synthetic ChatMsg and delegate to the chat logic
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,
)
# Store attachments in request state for _call_genai
result = await _chat_core_impl(msg, u, attachments=attachments if attachments else None)
if file_names:
# Save file reference in user message
with db() as c:
c.execute("UPDATE chat_messages SET content = content || ? WHERE session_id=? AND role='user' ORDER BY created_at DESC LIMIT 1",
(f"\n[📎 {', '.join(file_names)}]", result["session_id"]))
return result
@app.post("/api/chat")
async def chat(msg: ChatMsg, u=Depends(current_user)):
return await _chat_core_impl(msg, u)
def _agent_respond(msg, user):
m = msg.lower().strip()
if any(k in m for k in ["status","health","como está","saúde"]):