diff --git a/README.md b/README.md index 0d764e1..27c2fd7 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,15 @@

- OCI CIS AI Agent + AI Agent - Infrastructure & Security Engineer

-

OCI CIS AI Agent

+

AI Agent — Infrastructure & Security Engineer

Oracle Cloud Infrastructure — CIS Foundations Benchmark 3.0 — AI-Powered Compliance Platform

- Version + Version Python FastAPI OCI @@ -21,7 +21,7 @@ ## Overview -OCI CIS AI Agent is a self-hosted web application that automates **CIS Oracle Cloud Infrastructure Foundations Benchmark 3.0** compliance checks, powered by **OCI Generative AI** for intelligent analysis and an **MCP (Model Context Protocol)** server architecture for extensible task execution. +AI Agent — Infrastructure & Security Engineer is a self-hosted web application that automates **CIS Oracle Cloud Infrastructure Foundations Benchmark 3.0** compliance checks, powered by **OCI Generative AI** for intelligent analysis and an **MCP (Model Context Protocol)** server architecture for extensible task execution. The platform combines security compliance scanning, AI-powered chat with **RAG (Retrieval-Augmented Generation)**, infrastructure exploration, and vector-based knowledge storage into a single, containerized solution with an **Oracle Dark Premium** theme (light/dark modes), **KPI dashboard** with compliance gauge, and **Chart.js** visualizations. @@ -697,6 +697,7 @@ All models include OCID mapping for `us-ashburn-1`. For other regions, use the " | Version | Date | Changes | |---------|------|---------| +| **v2.6** | 2026-03 | **Rename**: app renamed to "AI Agent — Infrastructure & Security Engineer". **Session Token Auth**: OCI session token authentication support (type selector API Key/Session Token, conditional form fields, collapsible credential form, type tags in credentials table, `SecurityTokenSigner` backend). **Terraform Workspaces submenu**: sidebar sub-tab listing all workspaces grouped by date with accordion UI, filtered by existing chat sessions (INNER JOIN), actions (plan/apply/destroy/delete/open). **Retry on failure**: "Reenviar" button on failed messages across Chat Agent, Terraform Agent, and Prompt Generator. **Terraform fixes**: `f2` undefined variable in dedup logic, `oci_core_services` false positive validation (data sources excluded), `S.tfCode` reconstruction from `S.tfFiles` for Re-plan, auto-fix button visible without pre-selected model, DRG attachment rules in system prompt. **Resilience**: stuck workspace recovery on container restart (planning/applying/destroying → reset + lock file cleanup). | | **v2.5** | 2026-03 | **Explorer Expansion**: KPI stats bar with per-category resource counts (cached in SQLite `explorer_counts_cache`, background refresh on compartment access), enhanced views for VCNs/Subnets/Load Balancers/Buckets with expandable cards, start/stop for DB Systems + MySQL + Container Instances (5 resource types total), dead state filtering (`TERMINATED`/`DELETED`/`DELETING` hidden across all 36 endpoints), wider compartment tree (280px), IAM excluded from compartment totals. **Prompt Generator Intelligence**: same knowledge pipeline as Terraform Agent — auto-detects resource types from user message, fetches official docs (Example Usage + Arguments) from GitHub cached in SQLite, uses compact categorized resource reference. **Network Firewall fix**: `NetworkFirewallPolicySummaryCollection` not iterable — added `.items` accessor for collection objects. | | **v2.4** | 2026-03 | **Terraform Safety & UX**: region safety guard (plan blocked if model omits `variable "region"` — prevents wrong-region provisioning), rollback button on apply failure (manual `terraform destroy` with "ROLLBACK" confirmation), SSH public key field in OCI configs (auto-injected into `terraform.tfvars` for compute instances), reasoning_effort uppercase fix for OCI SDK, split-panel layout (terminal right 40%, files/plan/resources below chat 60%), plan button persists after destroy. **Terraform tfvars**: expanded `oci_var_map` with `ssh_public_key`/`ssh_authorized_keys` auto-mapping from OCI config | | **v2.3** | 2026-03 | **Consult Embeddings**: dedicated sub-menu with chat-like interface for natural language Q&A against vector store — queries all active tables via cosine similarity, returns contextual answers with source citations (works with or without GenAI config). **Knowledge Base & RAG Enhancements**: Knowledge Base (Base de Conhecimento) with file upload + URL import to `engineerknowledgebase` vector table, CIS Recommendations upload to `cisrecom` table, URL content extraction (HTML tag/script stripping, remote PDF parsing), auto-resolve embedding config from OCI credentials (no separate GenAI config required), `report_date` metadata tracking for embedding freshness, purge & re-embed flow for updated reports. **ADB Vector Improvements**: case-insensitive table matching with quoted identifiers for Oracle ADB, table validation endpoint against `user_tables`, tables section moved inside edit connection card, case-preserving table registration (removed forced uppercase). **Vector search fix**: FLOAT32 compatibility for VECTOR_DISTANCE queries, base64-decoded compartment_id for auto-resolved OCI configs. **Embeddings tab cleanup**: removed delete buttons, simplified to read-only view, updated descriptions. **Dependencies**: added PyPDF2 for PDF text extraction | diff --git a/backend/app.py b/backend/app.py index 92e3860..29d16cc 100644 --- a/backend/app.py +++ b/backend/app.py @@ -1,5 +1,5 @@ """ -OCI CIS AI Agent - Backend API v1.1 +AI Agent - Infrastructure & Security Engineer - 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. @@ -52,7 +52,7 @@ COMPACT_MIN_MESSAGES = 8 logging.basicConfig(level=logging.INFO) log = logging.getLogger("agent") -app = FastAPI(title="OCI CIS AI Agent", version=VERSION) +app = FastAPI(title="AI Agent - Infrastructure & Security Engineer", version=VERSION) app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"]) security = HTTPBearer() @@ -390,7 +390,7 @@ def init_db(): c.execute(f"ALTER TABLE terraform_workspaces ADD COLUMN {col}") except sqlite3.OperationalError: pass - for col in ["ssh_public_key TEXT"]: + for col in ["ssh_public_key TEXT", "auth_type TEXT DEFAULT 'api_key'"]: try: c.execute(f"ALTER TABLE oci_configs ADD COLUMN {col}") except sqlite3.OperationalError: @@ -427,6 +427,16 @@ def init_db(): else: c.execute("INSERT INTO system_prompts (id,name,agent,content,is_active) VALUES (?,?,?,?,?)", (str(uuid.uuid4()), "OCI Terraform Agent", "terraform", TF_DEFAULT_SYSTEM_PROMPT, 1)) + # Recover stuck terraform workspaces (interrupted by container restart) + stuck = c.execute("SELECT id, status FROM terraform_workspaces WHERE status IN ('planning','applying','destroying')").fetchall() + for ws in stuck: + prev = 'applied' if ws["status"] == 'destroying' else 'failed' + c.execute("UPDATE terraform_workspaces SET status=?, error='Interrompido por restart do container', updated_at=datetime('now') WHERE id=?", (prev, ws["id"])) + # Remove stale lock files + lock_file = TERRAFORM_DIR / ws["id"] / ".terraform.tfstate.lock.info" + if lock_file.exists(): + lock_file.unlink() + log.info(f"Recovered stuck workspace {ws['id']}: {ws['status']} -> {prev}") adm = c.execute("SELECT id FROM users WHERE username='admin'").fetchone() if not adm: c.execute( @@ -446,7 +456,7 @@ def _totp_verify(secret,code,window=1): 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 _totp_uri(secret,user): return f"otpauth://totp/AI-Agent:{user}?secret={secret}&issuer=AI-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() @@ -469,7 +479,30 @@ def _safe_dec(v): 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") + config = oci.config.from_file(config_path, "DEFAULT") + return config + + +def _get_oci_signer(oci_config_id: str): + """Return (config, signer) tuple. For session_token auth, returns SecurityTokenSigner; for api_key returns None (use default).""" + import oci + config = _get_oci_config(oci_config_id) + if config.get("security_token_file"): + from oci.auth.signers import SecurityTokenSigner + token_path = config["security_token_file"] + with open(token_path) as f: + token = f.read().strip() + signer = SecurityTokenSigner(token, config["key_file"]) + return config, signer + return config, None + + +def _oci_client(client_class, oci_config_id: str, **kwargs): + """Create an OCI client with correct auth (api_key or session_token).""" + config, signer = _get_oci_signer(oci_config_id) + if signer: + return client_class(config=config, signer=signer, **kwargs) + return client_class(config, **kwargs) # ── Auth deps ───────────────────────────────────────────────────────────────── async def current_user(cred: HTTPAuthorizationCredentials = Depends(security)): @@ -653,43 +686,79 @@ async def del_user(uid: str, adm=Depends(require("admin"))): @app.post("/api/oci/config") async def save_oci( tenancy_name: str = Form(...), tenancy_ocid: str = Form(...), - user_ocid: str = Form(...), fingerprint: str = Form(...), + user_ocid: str = Form(""), fingerprint: str = Form(""), region: str = Form(...), compartment_id: str = Form(""), key_passphrase: str = Form(""), ssh_public_key: str = Form(""), - private_key: UploadFile = File(...), public_key: Optional[UploadFile] = File(None), + auth_type: str = Form("api_key"), + security_token: str = Form(""), + private_key: Optional[UploadFile] = File(None), public_key: Optional[UploadFile] = File(None), u = Depends(require("admin","user")) ): + if auth_type not in ("api_key", "session_token"): + raise HTTPException(400, "auth_type deve ser 'api_key' ou 'session_token'") 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) + + if auth_type == "session_token": + # Session token auth: requires tenancy_ocid, region, token, and session key + if not security_token.strip(): + shutil.rmtree(cdir, ignore_errors=True) + raise HTTPException(400, "Session Token é obrigatório para autenticação por token.") + if not private_key or not private_key.filename: + shutil.rmtree(cdir, ignore_errors=True) + raise HTTPException(400, "A chave de sessão (.pem) é obrigatória para autenticação por token.") + key_bytes = await private_key.read() + kp.write_bytes(key_bytes); kp.chmod(0o600) + # Save security token file + token_file = cdir / "token" + token_file.write_text(security_token.strip()) + token_file.chmod(0o600) + # Generate config + cfg_content = (f"[DEFAULT]\ntenancy={tenancy_ocid}\nregion={region}\n" + f"key_file={kp}\nsecurity_token_file={token_file}\n") + if user_ocid: + cfg_content = f"[DEFAULT]\nuser={user_ocid}\n" + cfg_content.replace("[DEFAULT]\n", "") + cfg_file = cdir / "config" + cfg_file.write_text(cfg_content); cfg_file.chmod(0o600) + # For session_token, fingerprint/user may not be provided — use placeholders + user_ocid = user_ocid or "session_token_user" + fingerprint = fingerprint or "session_token" + else: + # API Key auth (original flow) + if not user_ocid or not fingerprint: + shutil.rmtree(cdir, ignore_errors=True) + raise HTTPException(400, "OCID User e Fingerprint são obrigatórios para API Key.") + if not private_key or not private_key.filename: + shutil.rmtree(cdir, ignore_errors=True) + raise HTTPException(400, "Selecione a chave privada (.pem).") + key_bytes = await private_key.read() + kp.write_bytes(key_bytes); kp.chmod(0o600) + 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: + shutil.rmtree(cdir, ignore_errors=True) + 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("INSERT INTO oci_configs (id,user_id,tenancy_name,tenancy_ocid,user_ocid,fingerprint,region,key_file_path,compartment_id,ssh_public_key) 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, ssh_public_key.strip() if ssh_public_key.strip() 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"]) + c.execute("INSERT INTO oci_configs (id,user_id,tenancy_name,tenancy_ocid,user_ocid,fingerprint,region,key_file_path,compartment_id,ssh_public_key,auth_type) 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, ssh_public_key.strip() if ssh_public_key.strip() else None, auth_type)) + _audit(u["id"], u["username"], "save_oci_config", cid, f"tenancy={tenancy_name} auth={auth_type}") + _config_log("oci", cid, tenancy_name, "success", "save", f"Credencial salva: {tenancy_name} ({region}) [{auth_type}]", 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,ssh_public_key,created_at" + cols = "id,user_id,tenancy_name,tenancy_ocid,user_ocid,fingerprint,region,compartment_id,ssh_public_key,auth_type,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 = [] @@ -736,6 +805,8 @@ async def update_oci( region: str = Form(...), compartment_id: str = Form(""), key_passphrase: str = Form(""), ssh_public_key: str = Form(""), + auth_type: str = Form(""), + security_token: str = Form(""), private_key: Optional[UploadFile] = File(None), public_key: Optional[UploadFile] = File(None), u = Depends(require("admin","user")) ): @@ -748,6 +819,11 @@ async def update_oci( 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 "" + try: + existing_auth = existing["auth_type"] or "api_key" + except (IndexError, KeyError): + existing_auth = "api_key" + auth_type = auth_type or existing_auth try: existing_ssh = existing["ssh_public_key"] or "" except (IndexError, KeyError): @@ -759,21 +835,32 @@ async def update_oci( 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: + if auth_type == "api_key" and ("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()) + # Update session token file if provided + if security_token.strip(): + token_file = cdir / "token" + token_file.write_text(security_token.strip()); token_file.chmod(0o600) 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" + if auth_type == "session_token": + token_path = cdir / "token" + cfg_content = f"[DEFAULT]\ntenancy={tenancy_ocid}\nregion={region}\nkey_file={kp}\n" + if user_ocid and user_ocid != "session_token_user": + cfg_content = f"[DEFAULT]\nuser={user_ocid}\ntenancy={tenancy_ocid}\nregion={region}\nkey_file={kp}\n" + cfg_content += f"security_token_file={token_path}\n" + else: + 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=?,ssh_public_key=? WHERE id=?", - (tenancy_name, _enc(tenancy_ocid), _enc(user_ocid), _enc(fingerprint), region, _enc(compartment_id) if compartment_id else None, ssh_public_key or 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"]) + c.execute("UPDATE oci_configs SET tenancy_name=?,tenancy_ocid=?,user_ocid=?,fingerprint=?,region=?,compartment_id=?,ssh_public_key=?,auth_type=? WHERE id=?", + (tenancy_name, _enc(tenancy_ocid), _enc(user_ocid), _enc(fingerprint), region, _enc(compartment_id) if compartment_id else None, ssh_public_key or None, auth_type, cid)) + _audit(u["id"], u["username"], "update_oci_config", cid, f"tenancy={tenancy_name} auth={auth_type}") + _config_log("oci", cid, tenancy_name, "success", "save", f"Credencial atualizada: {tenancy_name} ({region}) [{auth_type}]", u["id"], u["username"]) return {"id": cid, "tenancy_name": tenancy_name, "region": region} @app.post("/api/oci/test/{cid}") @@ -2354,6 +2441,7 @@ Se pedirem AWS, Azure, GCP ou outro provider, recuse educadamente. - Remote peering: APENAS UM LADO do par define `peer_id` e `peer_region_name`. O outro lado é criado SEM `peer_id`. Nunca gere dois RPCs apontando um para o outro (causa Cycle error). Exemplo: RPC_mad1 (sem peer_id) e RPC_mad3 (com peer_id = RPC_mad1.id, peer_region_name = var.region). - Associação de route table com subnet deve ser via `route_table_id` na subnet ou `oci_core_route_table_attachment`. - `oci_network_firewall_network_firewall` exige `network_firewall_policy_id`. +- DRG attachments: use `oci_core_drg_attachment` para VCN attachments (tipo padrão). Use `oci_core_drg_attachment_management` SOMENTE para REMOTE_PEERING_CONNECTION (único tipo suportado para management). Nunca duplique resource type+name entre arquivos (ex: dois `oci_core_drg_attachment.vcn_app_x`). Sempre associe `drg_route_table_id` nos VCN attachments para garantir roteamento correto. ### Proibições absolutas — módulos e variáveis - NUNCA use `module` blocks. Este workspace é flat (um único diretório). Não existem subdiretórios `modules/`. Toda a infraestrutura deve ser definida diretamente com `resource` e `data` blocks. @@ -2426,9 +2514,9 @@ def _validate_tf_resource_types(tf_code: str) -> list: Returns list of dicts with invalid types and suggestions.""" import re as _re from difflib import get_close_matches - # Extract all resource and data type declarations + # Extract only resource type declarations (data sources are read-only and may not be in the schema) used_types = set() - for m in _re.finditer(r'(?:resource|data)\s+"(\w+)"', tf_code): + for m in _re.finditer(r'resource\s+"(\w+)"', tf_code): used_types.add(m.group(1)) if not used_types: return [] @@ -3549,7 +3637,7 @@ async def embed_upload_url( if not url.startswith(("http://", "https://")): raise HTTPException(400, "URL inválida — deve começar com http:// ou https://") try: - resp = req.get(url, timeout=30, headers={"User-Agent": "Mozilla/5.0 OCI-CIS-Agent/1.0"}) + resp = req.get(url, timeout=30, headers={"User-Agent": "Mozilla/5.0 AI-Agent/1.0"}) resp.raise_for_status() except Exception as e: raise HTTPException(400, f"Erro ao acessar URL: {str(e)[:300]}") @@ -4483,7 +4571,7 @@ def _agent_respond(msg, user): 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" + return ("Sou o **AI Agent v" + VERSION + "** — Infrastructure & Security Engineer. 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.get("/api/chat/sessions") @@ -4835,7 +4923,7 @@ class TfPromptReq(BaseModel): session_id: Optional[str] = None @app.post("/api/terraform/generate-prompt") -async def tf_generate_prompt(req: TfPromptReq, u=Depends(current_user)): +async def tf_generate_prompt(req: TfPromptReq, bg: BackgroundTasks, u=Depends(current_user)): gc = None if req.genai_config: if req.genai_config.get("genai_config_id"): @@ -4911,16 +4999,23 @@ async def tf_generate_prompt(req: TfPromptReq, u=Depends(current_user)): if req.history: hist = [{"role": "user" if m.get("role","").upper() in ("USER","user") else "assistant", "content": m.get("content", "")} for m in req.history] - try: - answer, _, _ = _call_genai(gc, req.message, history=hist) - # Save assistant response - 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", answer, gc.get("model_id"), "done")) - return {"prompt": answer, "session_id": sid} - except Exception as e: - log.error(f"tf_generate_prompt error: {e}") - raise HTTPException(500, str(e)[:500]) + # Async background processing — same pattern as Chat/Terraform agents + 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", "", gc.get("model_id"), "processing")) + def _tfp_background(): + try: + answer, _, _ = _call_genai(gc, req.message, history=hist) + with db() as c: + c.execute("UPDATE chat_messages SET content=?, status='done' WHERE id=?", (answer, mid)) + log.info(f"TF Prompt Generator completed: mid={mid} len={len(answer)}") + except Exception as e: + log.error(f"tf_generate_prompt error: {e}") + with db() as c: + c.execute("UPDATE chat_messages SET content=?, status='failed' WHERE id=?", (str(e)[:500], mid)) + bg.add_task(_tfp_background) + return {"message_id": mid, "session_id": sid, "status": "processing"} @app.post("/api/terraform/chat") async def terraform_chat(msg: ChatMsg, bg: BackgroundTasks, u=Depends(current_user)): @@ -4953,7 +5048,12 @@ async def tf_create_workspace(req: TfWorkspaceReq, u=Depends(current_user)): async def tf_list_workspaces(u=Depends(current_user)): with db() as c: rows = c.execute( - "SELECT id,name,session_id,oci_config_id,status,created_at,updated_at FROM terraform_workspaces WHERE user_id=? ORDER BY created_at DESC", + """SELECT tw.id,tw.name,tw.session_id,tw.oci_config_id,tw.compartment_id,tw.status, + tw.plan_output,tw.apply_output,tw.destroy_output,tw.error,tw.created_at,tw.updated_at, + cs.title as session_title + FROM terraform_workspaces tw + INNER JOIN chat_sessions cs ON cs.id = tw.session_id AND cs.user_id = tw.user_id + WHERE tw.user_id=? ORDER BY tw.updated_at DESC""", (u["id"],)).fetchall() return [dict(r) for r in rows] @@ -5253,7 +5353,7 @@ def _write_tf_files(wdir: Path, tf_code: str): # Remove monolithic files that are fully duplicated for fname in dupes_in: if all( - any(f2 != fname and f2 in [fl for fl in resource_locations.get(key, []) if fl != fname]) + any(f2 != fname for f2 in resource_locations.get(key, [])) for key, flist in resource_locations.items() if fname in flist ) and len(files) - 1 >= 2: @@ -5881,7 +5981,7 @@ async def startup(): except Exception as e: log.warning(f"Could not populate tf_valid_types: {e}") - log.info(f"OCI CIS AI Agent v{VERSION} API started") + log.info(f"AI Agent v{VERSION} API started") if __name__ == "__main__": import uvicorn diff --git a/frontend/index.html b/frontend/index.html index b374e73..0148a8c 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,7 +3,7 @@ -OCI CIS AI Agent +AI Agent - Infrastructure & Security Engineer