"""Agentic API — Spec 029.""" from __future__ import annotations import json from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import StreamingResponse from pydantic import BaseModel, Field from app import agent_bindings, auth from app.agents import llm_client, runner, store from app.agents import messages as agent_messages from app.agents.catalog import AGENT_CATALOG, roster_public from app.agents.chat_stream import chat_stream_generator, _thread_history_messages from app.agents.chat_context_builder import build_ops_context router = APIRouter(prefix="/api/v1/agents", tags=["agents"]) def _db(): conn = auth.db() try: yield conn finally: conn.close() def _ops_view(user, conn): if not agent_bindings.can_use_agentics_ui(conn, user.role): raise HTTPException(403, "insufficient permissions") def _require_agent_chat(user, conn, agent_id: str): _ops_view(user, conn) if not agent_bindings.can_chat_agent(conn, user.role, agent_id): raise HTTPException(403, f"sem atribuição Agentics para agente {agent_id}") class ChatRequest(BaseModel): question: str = Field(..., min_length=2, max_length=4000) include_findings: bool = True target_agent: str = Field(default="A6", description="Agente destino — default Copiloto") class ReplyRequest(BaseModel): body: str = Field(..., min_length=1, max_length=8000) target_agent: str | None = None @router.get("/health") def agents_health(): return {"status": "ok", **llm_client.llm_status()} @router.get("/overview") def agents_overview(user=Depends(auth.get_current_user), conn=Depends(_db)): _ops_view(user, conn) return store.get_overview(conn) @router.get("/incidents") def agents_incidents( user=Depends(auth.get_current_user), conn=Depends(_db), status: str = Query("open"), severity: str | None = None, agent_id: str | None = None, limit: int = Query(50, ge=1, le=200), ): _ops_view(user, conn) return {"incidents": store.list_incidents(conn, status=status, severity=severity, agent_id=agent_id, limit=limit)} @router.get("/incidents/{incident_id}") def agents_incident_detail(incident_id: int, user=Depends(auth.get_current_user), conn=Depends(_db)): _ops_view(user, conn) inc = store.get_incident(conn, incident_id) if not inc: raise HTTPException(404, "incident not found") return { "incident": inc, "recent_runs": store.recent_runs_for_scenario(conn, inc["scenario_id"]), "thread_id": inc.get("thread_id"), } @router.post("/incidents/{incident_id}/ack") def ack_incident(incident_id: int, user=Depends(auth.get_current_user), conn=Depends(_db)): _ops_view(user, conn) inc = store.ack_incident(conn, incident_id, user.username) if not inc: raise HTTPException(404, "incident not found") store.log_event(conn, event_type="incident.ack", message=f"#{incident_id}", payload={"by": user.username}) conn.commit() return {"ok": True, "incident_id": incident_id, "thread_id": inc.get("thread_id")} @router.get("/timeline") def agents_timeline(user=Depends(auth.get_current_user), conn=Depends(_db), limit: int = Query(24, ge=1, le=100)): _ops_view(user, conn) ticks = [ dict(r) for r in conn.execute( """SELECT ts, message, payload_json FROM agent_action_log WHERE event_type='tick.complete' ORDER BY id DESC LIMIT ?""", (limit,), ) ] out = [] for t in ticks: payload = {} try: payload = json.loads(t.get("payload_json") or "{}") except json.JSONDecodeError: pass runs = payload.get("runs") or [] findings = sum(r.get("findings_count", 0) for r in runs if isinstance(r, dict)) out.append({"at": t["ts"], "scenarios": payload.get("total", len(runs)), "findings": findings}) return {"ticks": out} @router.get("/roster") def agents_roster(user=Depends(auth.get_current_user), conn=Depends(_db)): _ops_view(user, conn) return {"agents": roster_public()} @router.get("/inbox") def agents_inbox(user=Depends(auth.get_current_user), conn=Depends(_db), limit: int = Query(50, ge=1, le=200)): _ops_view(user, conn) return {"messages": agent_messages.list_inbox(conn, role=user.role, limit=limit)} @router.get("/threads") def agents_threads(user=Depends(auth.get_current_user), conn=Depends(_db), limit: int = Query(40, ge=1, le=100)): _ops_view(user, conn) return {"threads": agent_messages.list_threads(conn, limit=limit)} @router.get("/threads/{thread_id}/messages") def thread_messages(thread_id: int, user=Depends(auth.get_current_user), conn=Depends(_db)): _ops_view(user, conn) if not conn.execute("SELECT id FROM agent_threads WHERE id=?", (thread_id,)).fetchone(): raise HTTPException(404, "thread not found") return {"thread_id": thread_id, "messages": agent_messages.thread_messages(conn, thread_id)} def _agent_profile(agent_id: str): return AGENT_CATALOG.get(agent_id, AGENT_CATALOG["A6"]) def _findings_summary(conn, include: bool) -> str: if not include: return "" open_f = store.list_findings(conn, limit=8, open_only=True) if not open_f: return "" return "\n".join( f"- [{f['severity']}] {f['title']}: {f.get('suggested_human_action') or ''}" for f in open_f ) def _thread_history(conn, thread_id: int, limit: int = 10) -> str: msgs = agent_messages.thread_messages(conn, thread_id)[-limit:] return "\n".join(f"{m.get('from_label') or m.get('from_id')}: {m.get('body', '')[:240]}" for m in msgs) def _chat_ops_context(conn, question: str, target_agent: str, include: bool) -> tuple[list[str], str]: kb = store.search_kb(conn, question) snippets = [k["snippet"] for k in kb] ctx = build_ops_context( conn, question, target_agent, include_findings=include, kb_snippets=snippets ) return snippets, ctx @router.post("/threads/{thread_id}/reply") def thread_reply( thread_id: int, body: ReplyRequest, user=Depends(auth.get_current_user), conn=Depends(_db), ): _ops_view(user, conn) row = conn.execute("SELECT * FROM agent_threads WHERE id=?", (thread_id,)).fetchone() if not row: raise HTTPException(404, "thread not found") target = body.target_agent or row["primary_agent"] mid = agent_messages.human_reply( conn, thread_id=thread_id, username=user.username, body=body.body, target_agent=target ) store.log_event( conn, event_type="human.reply", message=body.body[:120], agent_id=target, payload={"thread_id": thread_id, "user": user.username}, ) conn.commit() return {"ok": True, "message_id": mid} @router.post("/threads/{thread_id}/chat") def thread_chat( thread_id: int, body: ChatRequest, user=Depends(auth.get_current_user), conn=Depends(_db), ): """Continuar conversa com agente (LLM) numa thread existente.""" row = conn.execute("SELECT * FROM agent_threads WHERE id=?", (thread_id,)).fetchone() if not row: raise HTTPException(404, "thread not found") target = body.target_agent or row["primary_agent"] _require_agent_chat(user, conn, target) profile = _agent_profile(target) kb_snippets, ops_context = _chat_ops_context(conn, body.question, target, body.include_findings) answer, model = llm_client.chat_context( question=body.question, kb_snippets=kb_snippets, ops_context=ops_context, user_role=user.role, target_agent=target, agent_name=profile.name, agent_role=profile.role, history_messages=_thread_history_messages(conn, thread_id), ) agent_messages.post_message( conn, thread_id=thread_id, from_type="human", from_id=user.username, to_type="agent", to_id=target, body=body.question, ) agent_messages.post_message( conn, thread_id=thread_id, from_type="agent", from_id=target, to_type="human", to_id=user.username, body=answer, context={"model": model, "kb_hits": len(kb_snippets)}, ) store.log_event( conn, event_type="chat.thread", message=body.question[:120], agent_id=target, payload={"user": user.username, "model": model, "thread_id": thread_id}, ) conn.commit() return {"answer": answer, "model": model, "kb_hits": len(kb_snippets), "thread_id": thread_id} @router.post("/threads/{thread_id}/chat/stream") def thread_chat_stream( thread_id: int, body: ChatRequest, user=Depends(auth.get_current_user), ): """Chat com streaming SSE — resposta token a token.""" _ops_view(user, conn) conn = auth.db() try: row = conn.execute("SELECT * FROM agent_threads WHERE id=?", (thread_id,)).fetchone() if not row: raise HTTPException(404, "thread not found") target = body.target_agent or row["primary_agent"] profile = _agent_profile(target) finally: conn.close() return StreamingResponse( chat_stream_generator( username=user.username, user_role=user.role, question=body.question, target_agent=target, agent_name=profile.name, agent_role=profile.role, include_findings=body.include_findings, thread_id=thread_id, ), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, ) @router.post("/messages/{message_id}/ack") def ack_inbox_message(message_id: int, user=Depends(auth.get_current_user), conn=Depends(_db)): _ops_view(user, conn) if not agent_messages.ack_message(conn, message_id, user.username): raise HTTPException(404, "not found") conn.commit() return {"ok": True, "id": message_id} @router.get("/scenarios") def list_scenarios(user=Depends(auth.get_current_user), conn=Depends(_db)): _ops_view(user, conn) runner.sync_registry(conn) conn.commit() return {"scenarios": store.list_scenarios(conn)} @router.get("/findings") def list_findings( user=Depends(auth.get_current_user), conn=Depends(_db), severity: str | None = None, limit: int = Query(50, ge=1, le=200), open_only: bool = True, ): _ops_view(user, conn) return {"findings": store.list_findings(conn, severity=severity, limit=limit, open_only=open_only)} @router.post("/findings/{finding_id}/ack") def ack_finding(finding_id: int, user=Depends(auth.get_current_user), conn=Depends(_db)): _ops_view(user, conn) if not conn.execute("SELECT id FROM agent_findings WHERE id=?", (finding_id,)).fetchone(): raise HTTPException(404, "not found") now = datetime.now(timezone.utc).isoformat() conn.execute( "UPDATE agent_findings SET acknowledged_at=?, acknowledged_by=? WHERE id=?", (now, user.username, finding_id), ) store.log_event(conn, event_type="finding.ack", message=f"#{finding_id}", payload={"by": user.username}) conn.commit() return {"ok": True, "id": finding_id} @router.get("/action-log") def action_log(user=Depends(auth.get_current_user), conn=Depends(_db), limit: int = Query(100, ge=1, le=500)): _ops_view(user, conn) return {"events": store.list_action_log(conn, limit=limit)} @router.get("/kb/sources") def kb_sources(user=Depends(auth.get_current_user), conn=Depends(_db)): _ops_view(user, conn) rows = conn.execute( """SELECT source_path, COUNT(*) AS chunks, MAX(indexed_at) AS indexed_at FROM agent_kb_chunks GROUP BY source_path ORDER BY source_path""" ).fetchall() total = conn.execute("SELECT COUNT(*) c FROM agent_kb_chunks").fetchone()["c"] return {"sources": [dict(r) for r in rows], "total_chunks": total} @router.get("/kb/search") def kb_search( user=Depends(auth.get_current_user), conn=Depends(_db), q: str = Query(..., min_length=1, max_length=500), limit: int = Query(12, ge=1, le=50), ): _ops_view(user, conn) hits = store.search_kb(conn, q, limit=limit) return {"query": q, "hits": hits, "count": len(hits)} @router.post("/runs/{scenario_id}") def trigger_run(scenario_id: str, user=Depends(auth.get_current_user), conn=Depends(_db)): if not agent_bindings.can_trigger_runs(conn, user.role): raise HTTPException(403, "insufficient permissions") r = runner.run_scenario(conn, scenario_id, trigger=f"manual:{user.username}") conn.commit() return r @router.post("/chat") def agent_chat(body: ChatRequest, user=Depends(auth.get_current_user), conn=Depends(_db)): """Janela de contexto T1 — humano ↔ agente (default Copiloto A6).""" _require_agent_chat(user, conn, body.target_agent) profile = _agent_profile(body.target_agent) kb_snippets, ops_context = _chat_ops_context(conn, body.question, body.target_agent, body.include_findings) answer, model = llm_client.chat_context( question=body.question, kb_snippets=kb_snippets, ops_context=ops_context, user_role=user.role, target_agent=body.target_agent, agent_name=profile.name, agent_role=profile.role, ) thread_id = agent_messages.create_thread( conn, subject=f"Chat: {body.question[:60]}", primary_agent=body.target_agent, severity="info", ) agent_messages.post_message( conn, thread_id=thread_id, from_type="human", from_id=user.username, to_type="agent", to_id=body.target_agent, body=body.question, ) agent_messages.post_message( conn, thread_id=thread_id, from_type="agent", from_id=body.target_agent, to_type="human", to_id=user.username, body=answer, context={"model": model, "kb_hits": len(kb_snippets)}, ) store.log_event( conn, event_type="chat.query", message=body.question[:120], agent_id=body.target_agent, payload={"user": user.username, "model": model, "thread_id": thread_id}, ) conn.commit() return {"answer": answer, "model": model, "kb_hits": len(kb_snippets), "thread_id": thread_id} @router.post("/chat/stream") def agent_chat_stream(body: ChatRequest, user=Depends(auth.get_current_user), conn=Depends(_db)): """Nova conversa com streaming SSE.""" _require_agent_chat(user, conn, body.target_agent) profile = _agent_profile(body.target_agent) return StreamingResponse( chat_stream_generator( username=user.username, user_role=user.role, question=body.question, target_agent=body.target_agent, agent_name=profile.name, agent_role=profile.role, include_findings=body.include_findings, thread_id=None, ), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, ) @router.post("/internal/tick") def internal_tick(user=Depends(auth.require_internal_or_user), conn=Depends(_db)): kb = runner.index_specs_kb(conn) result = runner.run_all_enabled(conn, trigger="cron") store.log_event( conn, event_type="tick.complete", message=f"kb={kb} runs={result['total']}", agent_id="A0", payload={"kb": kb, **result}, ) conn.commit() return {"ok": True, "kb_indexed": kb, **result}