Spaces:
Sleeping
Sleeping
Download main.py from anil988/SentinelFin-AI-Backend: direct link, hf CLI and curl.
- Browser
- Download file 46.5 kB
-
https://huggingface.co/spaces/anil988/SentinelFin-AI-Backend/resolve/main/main.py
- Command line
-
hf download hf://spaces/anil988/SentinelFin-AI-Backend/main.py
-
curl -L -o main.py https://huggingface.co/spaces/anil988/SentinelFin-AI-Backend/resolve/main/main.py
46.5 kB
| """ | |
| SentinelFin FastAPI Backend β Real 8-Agent LangGraph Pipeline | |
| Each node calls the actual agents/<name>/agent.py class. | |
| Now includes: | |
| - UiPath Orchestrator job trigger (Maestro) | |
| - UiPath Action Center queue item for human BSA review | |
| - Server-Sent Events (SSE) streaming so the UI updates in real time | |
| - Document Upload endpoint: PDF, CSV, SWIFT MT103, JSON parsing | |
| """ | |
| import os | |
| import sys | |
| import json | |
| import asyncio | |
| import subprocess | |
| from typing import List, TypedDict, AsyncGenerator | |
| from fastapi import FastAPI, UploadFile, File, HTTPException | |
| from fastapi.staticfiles import StaticFiles | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from fastapi.responses import StreamingResponse, JSONResponse, HTMLResponse, RedirectResponse | |
| from pydantic import BaseModel | |
| from langgraph.graph import StateGraph, START, END | |
| from dotenv import load_dotenv | |
| from integrations.document_parser import parse_document, SAMPLE_SWIFT_MT103, SAMPLE_CSV, SAMPLE_JSON | |
| load_dotenv() | |
| # ββ make agent subdirs importable βββββββββββββββββββββββββββββββββββββββββββββ | |
| AGENTS_DIR = os.path.join(os.path.dirname(__file__), "agents") | |
| for agent_folder in os.listdir(AGENTS_DIR): | |
| agent_path = os.path.join(AGENTS_DIR, agent_folder) | |
| if os.path.isdir(agent_path): | |
| sys.path.insert(0, agent_path) | |
| from agents.agent_2_sanctions_screener.agent import SanctionsScreenerAgent | |
| from agents.agent_3_pattern_detection.agent import PatternDetectionAgent | |
| from agents.agent_4_network_investigation.agent import NetworkInvestigationAgent | |
| from agents.agent_5_regulatory_intelligence.agent import RegulatoryIntelligenceAgent | |
| from agents.agent_6_sar_narrative_writer.agent import SARNarrativeWriterAgent | |
| from agents.agent_7_sar_form_population.agent import SARFormPopulationAgent | |
| from agents.agent_8_submission_audit.agent import SubmissionAuditAgent | |
| # ββ Health check ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| app = FastAPI(title="SentinelFin β 8-Agent LangGraph + UiPath Maestro") | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], | |
| allow_credentials=True, | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| # ββ Broadcast System βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class StreamBroadcaster: | |
| def __init__(self): | |
| self.queues = set() | |
| def add_client(self) -> asyncio.Queue: | |
| q = asyncio.Queue() | |
| self.queues.add(q) | |
| return q | |
| def remove_client(self, q: asyncio.Queue): | |
| self.queues.discard(q) | |
| async def broadcast(self, event: str, data: dict): | |
| msg = f"event: {event}\ndata: {json.dumps(data)}\n\n" | |
| # Create a list of queues to avoid size changing during iteration | |
| for q in list(self.queues): | |
| await q.put(msg) | |
| broadcaster = StreamBroadcaster() | |
| # ββ Schemas ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class CaseRequest(BaseModel): | |
| case_id: str | |
| alert_type: str = "SUSPECTED_LAYERING" | |
| transactions: List[dict] = [] | |
| resilience_mode: bool = False # NEW: triggers exceptional-path demo | |
| STATE_CACHE = {} | |
| class StateRequest(BaseModel): | |
| case_id: str | |
| class AgentState(TypedDict): | |
| case_id: str | |
| alert_type: str | |
| transactions: List[dict] | |
| resilience_mode: bool | |
| case_data: dict | |
| risk_score: int | |
| sanctions_hits: bool | |
| sla_status: str | |
| days_remaining: int | |
| sar_narrative: str | |
| fincen_form_111: dict | |
| fincen_tracking_id: str | |
| entities: List[str] | |
| network_depth: int | |
| pattern_name: str | |
| action_center_item_id: str | |
| # ββ Agent nodes ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def node_triage(state: AgentState) -> AgentState: | |
| print("\n[NODE 1 β TRIAGE] Building case data from transactions...") | |
| txns = state["transactions"] | |
| entities = set() | |
| for t in txns: | |
| for side in ("originator", "beneficiary"): | |
| name = t.get(side, {}).get("name") | |
| if name: | |
| entities.add(name) | |
| state["case_data"] = { | |
| "case_id": state["case_id"], | |
| "alert_type": state["alert_type"], | |
| "transactions": txns, | |
| "entities": list(entities), | |
| "resilience_mode": state["resilience_mode"], | |
| } | |
| state["entities"] = list(entities) | |
| state["risk_score"] = 0 | |
| print(f"[NODE 1] {len(txns)} transactions, {len(entities)} entities extracted.") | |
| return state | |
| def node_sanctions(state: AgentState) -> AgentState: | |
| print("\n[NODE 2 β SANCTIONS] Screening entities against OFAC SDN list...") | |
| # resilience_mode triggers the OFAC HTTP-500 retry demo | |
| updated = SanctionsScreenerAgent.run( | |
| state["case_data"], | |
| force_api_failure=state.get("resilience_mode", False) | |
| ) | |
| state["case_data"] = updated | |
| hits = updated.get("sanctions_results", {}).get("hits", []) | |
| state["sanctions_hits"] = len(hits) > 0 | |
| print(f"[NODE 2] Sanctions hits: {len(hits)}") | |
| return state | |
| def node_pattern(state: AgentState) -> AgentState: | |
| print("\n[NODE 3 β PATTERN] Calling Llama 3.1 to generate custom detection logic...") | |
| result = PatternDetectionAgent.run(state["case_data"]) | |
| state["risk_score"] = result.get("risk_score", 85) | |
| state["pattern_name"] = result.get("pattern_name", state["alert_type"]) | |
| state["case_data"]["pattern_results"] = result | |
| state["case_data"]["risk_score"] = state["risk_score"] | |
| print(f"[NODE 3] Risk: {state['risk_score']}, pattern: {state['pattern_name']}") | |
| return state | |
| def node_network(state: AgentState) -> AgentState: | |
| print("\n[NODE 4 β NETWORK] BFS graph traversal (dynamic root detection)...") | |
| txns = state["case_data"].get("transactions", []) | |
| graph: dict = {} | |
| all_destinations: set = set() | |
| for t in txns: | |
| orig = t.get("originator", {}).get("name") or t.get("originator", {}).get("account", "Unknown") | |
| ben = t.get("beneficiary", {}).get("name") or t.get("beneficiary", {}).get("account", "Unknown") | |
| graph.setdefault(orig, []).append(ben) | |
| all_destinations.add(ben) | |
| root_candidates = set(graph.keys()) - all_destinations | |
| root_node = next(iter(root_candidates), next(iter(graph.keys()), None)) | |
| max_depth = 0 | |
| terminal_nodes: set = set() | |
| if root_node and root_node in graph: | |
| queue = [(root_node, 0)] | |
| visited: set = set() | |
| while queue: | |
| current, depth = queue.pop(0) | |
| if current not in visited: | |
| visited.add(current) | |
| max_depth = max(max_depth, depth) | |
| nbrs = graph.get(current, []) | |
| if not nbrs: | |
| terminal_nodes.add(current) | |
| else: | |
| for n in nbrs: | |
| queue.append((n, depth + 1)) | |
| state["case_data"]["network_results"] = { | |
| "max_depth": max_depth, | |
| "terminal_nodes": list(terminal_nodes), | |
| "network_risk_score": 80 if max_depth >= 2 else 40, | |
| "root_node": root_node, | |
| "status": "COMPLETED", | |
| } | |
| state["network_depth"] = max_depth | |
| print(f"[NODE 4] Depth: {max_depth}, terminals: {terminal_nodes}") | |
| return state | |
| def node_regulatory(state: AgentState) -> AgentState: | |
| print("\n[NODE 5 β REGULATORY] Computing FinCEN 30-day SLA deadline...") | |
| updated = RegulatoryIntelligenceAgent.run(state["case_data"]) | |
| state["case_data"] = updated | |
| reg = updated.get("regulatory_context", {}) | |
| state["sla_status"] = reg.get("sla_status", "UNKNOWN") | |
| state["days_remaining"] = reg.get("days_remaining", 0) | |
| print(f"[NODE 5] SLA: {state['sla_status']} ({state['days_remaining']}d)") | |
| return state | |
| def node_sar_writer(state: AgentState) -> AgentState: | |
| print("\n[NODE 6 β SAR WRITER] Llama 3.1 writing FinCEN-compliant SAR narrative...") | |
| findings = { | |
| "risk_score": state["risk_score"], | |
| "sanctions_hits": state.get("sanctions_hits", False), | |
| "network_depth": state.get("network_depth", 0), | |
| "sla_status": state.get("sla_status", "UNKNOWN"), | |
| "applicable_rules": state["case_data"].get("regulatory_context", {}).get("applicable_rules", []), | |
| "pattern": state.get("pattern_name", state["alert_type"]), | |
| } | |
| narrative = SARNarrativeWriterAgent.run(state["case_data"], findings) | |
| state["sar_narrative"] = narrative | |
| state["case_data"]["sar_narrative"] = narrative | |
| print(f"[NODE 6] SAR narrative: {len(narrative)} chars.") | |
| return state | |
| def node_form_populator(state: AgentState) -> AgentState: | |
| print("\n[NODE 7 β FORM] Mapping to FinCEN Form 111 schema...") | |
| updated = SARFormPopulationAgent.run(state["case_data"], state["sar_narrative"]) | |
| state["case_data"] = updated | |
| state["fincen_form_111"] = updated.get("fincen_form_111_payload", {}) | |
| print("[NODE 7] Form 111 payload created.") | |
| return state | |
| def node_submission_audit(state: AgentState) -> AgentState: | |
| print("\n[NODE 8 β AUDIT] Generating audit trail (Governance passed back to Maestro)...") | |
| updated = SubmissionAuditAgent.run(state["case_data"]) | |
| state["case_data"] = updated | |
| audit = updated.get("submission_audit", {}) | |
| state["fincen_tracking_id"] = audit.get("fincen_tracking_id", "UNKNOWN") | |
| # ββ GOVERNANCE FIX: Removed autonomous push to Action Center ββββββββββββ | |
| # The LangGraph pipeline no longer creates the Action Center task directly. | |
| # It simply returns the payload, and the Maestro Case will handle the UI routing. | |
| state["action_center_item_id"] = "PENDING_MAESTRO_ROUTING" | |
| print(f"[NODE 8] Tracking ID: {state['fincen_tracking_id']}, Action Center Routing: Delegated to Maestro") | |
| return state | |
| # ββ Build LangGraph ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| workflow = StateGraph(AgentState) | |
| workflow.add_node("triage", node_triage) | |
| workflow.add_node("sanctions", node_sanctions) | |
| workflow.add_node("pattern", node_pattern) | |
| workflow.add_node("network", node_network) | |
| workflow.add_node("regulatory", node_regulatory) | |
| workflow.add_node("writer", node_sar_writer) | |
| workflow.add_node("populator", node_form_populator) | |
| workflow.add_node("submission", node_submission_audit) | |
| workflow.add_edge(START, "triage") | |
| workflow.add_edge("triage", "sanctions") | |
| workflow.add_edge("sanctions", "pattern") | |
| workflow.add_edge("pattern", "network") | |
| workflow.add_edge("network", "regulatory") | |
| workflow.add_edge("regulatory", "writer") | |
| workflow.add_edge("writer", "populator") | |
| workflow.add_edge("populator", "submission") | |
| workflow.add_edge("submission", END) | |
| investigation_graph = workflow.compile() | |
| async def action_center_preview(title: str = "Action Center Review", data: str = "{}"): | |
| import json | |
| from fastapi.responses import HTMLResponse | |
| try: | |
| parsed_data = json.loads(data) | |
| except: | |
| parsed_data = {"raw": data} | |
| html = f""" | |
| <!DOCTYPE html> | |
| <html> | |
| <head> | |
| <title>{title}</title> | |
| <style> | |
| body {{ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; padding: 20px; background: #f4f6f8; color: #333; }} | |
| .container {{ background: white; padding: 30px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); max-width: 800px; margin: 0 auto; }} | |
| h2 {{ color: #fa4616; margin-top: 0; }} | |
| .field {{ margin-bottom: 15px; border-bottom: 1px solid #eee; padding-bottom: 10px; }} | |
| .label {{ font-weight: 600; color: #555; font-size: 0.9em; text-transform: uppercase; }} | |
| .value {{ margin-top: 5px; font-size: 1.1em; white-space: pre-wrap; }} | |
| .footer {{ margin-top: 30px; padding-top: 20px; border-top: 2px solid #eee; font-size: 0.9em; color: #888; text-align: center; }} | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <h2>{title}</h2> | |
| <p style="color: #666; margin-bottom: 30px;">Please review the data below. To approve, use the <b>Comments</b> panel on the right to leave notes, then click <b>Assign to self</b> and <b>Complete</b>.</p> | |
| """ | |
| for k, v in parsed_data.items(): | |
| html += f""" | |
| <div class="field"> | |
| <div class="label">{k.replace('_', ' ')}</div> | |
| <div class="value">{v}</div> | |
| </div> | |
| """ | |
| html += """ | |
| <div class="footer"> | |
| UiPath Action Center β Powered by SentinelFin AI | |
| </div> | |
| </div> | |
| </body> | |
| </html> | |
| """ | |
| return HTMLResponse(content=html) | |
| # ββ SSE streaming endpoint βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def monitor_stream(): | |
| """SSE endpoint that passively monitors global executions (triggered by Maestro).""" | |
| async def event_generator(): | |
| q = broadcaster.add_client() | |
| try: | |
| while True: | |
| msg = await q.get() | |
| yield msg | |
| except asyncio.CancelledError: | |
| broadcaster.remove_client(q) | |
| return StreamingResponse(event_generator(), media_type="text/event-stream") | |
| async def get_or_create_active_state() -> AgentState: | |
| if STATE_CACHE: | |
| return list(STATE_CACHE.values())[-1] | |
| from integrations.document_parser import SAMPLE_JSON | |
| import json | |
| mock_data = json.loads(SAMPLE_JSON) | |
| actual_transactions = mock_data.get("transactions", []) | |
| state: AgentState = { | |
| "case_id": "demo_case_fallback", | |
| "alert_type": "AML_TRANSACTION_MONITORING", | |
| "transactions": actual_transactions, | |
| "resilience_mode": "strict", | |
| "case_data": {}, | |
| "risk_score": 0, | |
| "sanctions_hits": False, | |
| "sla_status": "", | |
| "days_remaining": 0, | |
| "sar_narrative": "", | |
| "fincen_form_111": {}, | |
| "fincen_tracking_id": "", | |
| "entities": [], | |
| "network_depth": 0, | |
| "pattern_name": "", | |
| "action_center_item_id": "" | |
| } | |
| STATE_CACHE["demo_case_fallback"] = state | |
| return state | |
| async def execute_real_agent(agent_idx: int, agent_label: str, node_func): | |
| state = await get_or_create_active_state() | |
| await broadcaster.broadcast("agent_start", {"agent": agent_idx, "name": agent_label}) | |
| await broadcaster.broadcast("agent_log", {"msg": f"Running {agent_label} logic...", "type": "info"}) | |
| # Run the heavy agent logic safely without blocking the FastAPI event loop | |
| loop = asyncio.get_event_loop() | |
| try: | |
| updated_state = await loop.run_in_executor(None, node_func, state) | |
| if "case_id" in updated_state: | |
| STATE_CACHE[updated_state["case_id"]] = updated_state | |
| except Exception as e: | |
| print(f"Error in {agent_label}: {e}") | |
| await broadcaster.broadcast("agent_log", {"msg": f"Error: {e}", "type": "error"}) | |
| await broadcaster.broadcast("agent_done", {"agent": agent_idx, "name": agent_label, "detail": "Completed"}) | |
| return {"status": "success", "agent": agent_label, "risk_score": state.get("risk_score", 0)} | |
| async def agent_1_context(): | |
| return await execute_real_agent(1, "Triage Analyst", node_triage) | |
| async def agent_2_sanctions(): | |
| return await execute_real_agent(2, "Sanctions Screener", node_sanctions) | |
| async def agent_3_pattern(): | |
| return await execute_real_agent(3, "Pattern Detection (Llama 3.1)", node_pattern) | |
| async def agent_4_network(): | |
| return await execute_real_agent(4, "Network Investigator", node_network) | |
| async def agent_5_regulatory(): | |
| return await execute_real_agent(5, "Regulatory Intelligence", node_regulatory) | |
| async def agent_6_writer(): | |
| return await execute_real_agent(6, "SAR Narrative Writer (Llama 3.1)", node_sar_writer) | |
| async def agent_7_form(): | |
| return await execute_real_agent(7, "Form Populator", node_form_populator) | |
| async def agent_8_submission(): | |
| return await execute_real_agent(8, "Submission Audit (Data Service)", node_submission_audit) | |
| async def investigate_part1(req: CaseRequest): | |
| """ | |
| Maestro Sync Part 1: Runs Agents 1-4. | |
| Broadcasts real-time events to the React Dashboard. | |
| Returns the generated risk_score and massive JSON state to Maestro. | |
| """ | |
| print(f"\n[MAESTRO SYNC] Part 1: Processing Case {req.case_id}...") | |
| from integrations.document_parser import SAMPLE_JSON | |
| import json | |
| # If the user forgot to pass the full transactions array in UiPath, load the default! | |
| actual_transactions = req.transactions | |
| if not actual_transactions: | |
| print("[MAESTRO SYNC] Warning: No transactions provided! Auto-loading mock data.") | |
| mock_data = json.loads(SAMPLE_JSON) | |
| actual_transactions = mock_data.get("transactions", []) | |
| initial_state: AgentState = { | |
| "case_id": req.case_id, | |
| "alert_type": req.alert_type, | |
| "transactions": actual_transactions, | |
| "resilience_mode": req.resilience_mode, | |
| "case_data": {}, | |
| "risk_score": 0, | |
| "sanctions_hits": False, | |
| "sla_status": "", | |
| "days_remaining": 0, | |
| "sar_narrative": "", | |
| "fincen_form_111": {}, | |
| "fincen_tracking_id": "", | |
| "entities": [], | |
| "network_depth": 0, | |
| "pattern_name": "", | |
| "action_center_item_id": "" | |
| } | |
| loop = asyncio.get_event_loop() | |
| state = initial_state.copy() | |
| NODES = ["triage", "sanctions", "pattern", "network"] | |
| NODE_LABELS = ["Triage Analyst", "Sanctions Screener", "Pattern Detection (Llama 3.1)", "Network Investigator"] | |
| node_funcs = [node_triage, node_sanctions, node_pattern, node_network] | |
| await broadcaster.broadcast("agent_start", {"agent": 0, "name": "UiPath Maestro Orchestration"}) | |
| await asyncio.sleep(1) | |
| await broadcaster.broadcast("agent_done", {"agent": 0, "name": "UiPath Maestro Orchestration", "detail": "Case Context Initialized"}) | |
| for idx, (fn, label, node_id) in enumerate(zip(node_funcs, NODE_LABELS, NODES), start=1): | |
| await broadcaster.broadcast("agent_start", {"agent": idx, "name": label}) | |
| try: | |
| state = await loop.run_in_executor(None, fn, state) | |
| await broadcaster.broadcast("agent_done", {"agent": idx, "name": label, "job_key": "local_execution"}) | |
| except Exception as e: | |
| await broadcaster.broadcast("agent_error", {"agent": idx, "name": label, "error": str(e)}) | |
| # Trigger pause UI for Action Center | |
| await broadcaster.broadcast("agent_log", {"msg": "βΈοΈ Hitting Gate 1: Triage Analyst Review via Action Center API...", "type": "start"}) | |
| await broadcaster.broadcast("agent_pause", {"agent": 3, "name": "Gate 1: Triage Analyst Review", "job_key": "MAESTRO_NATIVE_FORM"}) | |
| # Cache state globally so Maestro doesn't have to pass it back | |
| STATE_CACHE[req.case_id] = state | |
| return JSONResponse(content={ | |
| "risk_score": state.get("risk_score"), | |
| "status": "PAUSED_FOR_ACTION_CENTER" | |
| }) | |
| async def investigate_part2(req: StateRequest): | |
| """ | |
| Maestro Sync Part 2: Runs Agents 5-8. | |
| Resumes execution after Maestro Native Action Center form is approved. | |
| """ | |
| print(f"\n[MAESTRO SYNC] Part 2: Resuming Case {req.case_id} after Action Center approval...") | |
| state = STATE_CACHE.get(req.case_id) | |
| if not state: | |
| raise HTTPException(status_code=404, detail="State not found for this case_id. Did Part 1 finish?") | |
| loop = asyncio.get_event_loop() | |
| await broadcaster.broadcast("agent_resume", {"agent": 3, "name": "Gate 1: Triage Analyst Review"}) | |
| NODES = ["regulatory", "writer", "populator", "submission"] | |
| NODE_LABELS = ["Regulatory Intel", "SAR Writer (Llama 3.1)", "Form 111 Populator", "Audit & Action Center"] | |
| node_funcs = [node_regulatory, node_sar_writer, node_form_populator, node_submission_audit] | |
| for idx, (fn, label, node_id) in enumerate(zip(node_funcs, NODE_LABELS, NODES), start=5): | |
| await broadcaster.broadcast("agent_start", {"agent": idx, "name": label}) | |
| try: | |
| state = await loop.run_in_executor(None, fn, state) | |
| await broadcaster.broadcast("agent_done", {"agent": idx, "name": label, "job_key": "local_execution"}) | |
| except Exception as e: | |
| await broadcaster.broadcast("agent_error", {"agent": idx, "name": label, "error": str(e)}) | |
| # Data Service sync | |
| await broadcaster.broadcast("agent_start", {"agent": 9, "name": "Data Service (Sync)"}) | |
| from integrations.uipath_api import UiPathAPI | |
| api_success = await loop.run_in_executor(None, UiPathAPI.push_to_data_service, state.get("case_data", {})) | |
| if api_success: | |
| await broadcaster.broadcast("agent_done", {"agent": 9, "name": "Data Service (Sync)", "detail": "Extracted Data Synced to UiPath Cloud"}) | |
| else: | |
| await broadcaster.broadcast("agent_done", {"agent": 9, "name": "Data Service (Sync)", "detail": "Data Service Push Simulated"}) | |
| # Pause for Gate 2 instead of finishing | |
| await broadcaster.broadcast("agent_log", {"msg": "βΈοΈ Hitting Gate 2: BSA Officer Review via Action Center API...", "type": "start"}) | |
| await broadcaster.broadcast("agent_pause", {"agent": 8, "name": "Gate 2: BSA Officer Review", "job_key": "MAESTRO_NATIVE_FORM"}) | |
| from fastapi.responses import PlainTextResponse | |
| return PlainTextResponse(content=str(state.get("sar_narrative", "SAR Report Generated."))) | |
| async def view_report(): | |
| """Generates a beautiful HTML page of the SAR report for the BSA Officer to review.""" | |
| from fastapi.responses import HTMLResponse | |
| if not STATE_CACHE: | |
| return HTMLResponse(content="<h2>No SAR generated yet. Run the investigation first!</h2>") | |
| # Get the latest case from the cache | |
| latest_state = list(STATE_CACHE.values())[-1] | |
| sar_text = latest_state.get("sar_narrative", "No SAR narrative found.") | |
| html_content = f""" | |
| <html> | |
| <head><title>FinCEN SAR Report</title></head> | |
| <body style="font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; padding: 40px; max-width: 900px; margin: auto; line-height: 1.6; background-color: #f8fafc;"> | |
| <div style="background: white; padding: 40px; border-radius: 12px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); border-top: 8px solid #1e40af;"> | |
| <h1 style="color: #1e40af; border-bottom: 2px solid #e2e8f0; padding-bottom: 10px;">Suspicious Activity Report (SAR)</h1> | |
| <p style="color: #64748b; font-weight: bold;">Tracking ID: {latest_state.get("fincen_tracking_id", "PENDING")} | Risk Score: {latest_state.get("risk_score", "N/A")}</p> | |
| <div style="margin-top: 30px; font-size: 16px; white-space: pre-wrap; color: #334155;"> | |
| {sar_text} | |
| </div> | |
| <div style="margin-top: 40px; text-align: center;"> | |
| <p style="color: #ef4444; font-weight: bold;">Please return to UiPath Action Center to Approve or Reject this report.</p> | |
| </div> | |
| </div> | |
| </body> | |
| </html> | |
| """ | |
| return HTMLResponse(content=html_content) | |
| async def investigate_part3(req: StateRequest): | |
| """Resumes after Gate 2 and finishes the dashboard.""" | |
| state = STATE_CACHE.get(req.case_id, {}) | |
| await broadcaster.broadcast("agent_resume", {"agent": 8, "name": "Gate 2: BSA Officer Review"}) | |
| await broadcaster.broadcast("agent_start", {"agent": 10, "name": "Integration Service"}) | |
| from integrations.slack_notifier import SlackNotifier | |
| slack_msg = f"π¨ *SentinelFin Alert* π¨\nA critical money laundering case ({state.get('fincen_tracking_id', 'FC-12345')}) has been approved.\nA FinCEN SAR has been generated." | |
| SlackNotifier.send_alert(slack_msg) | |
| await broadcaster.broadcast("agent_done", {"agent": 10, "name": "Integration Service", "detail": "Slack & Jira notified"}) | |
| await broadcaster.broadcast("result", { | |
| "fincen_tracking_id": state.get("fincen_tracking_id", "FC-12345"), | |
| "final_risk_score": state.get("risk_score", 0), | |
| "pattern_name": state.get("pattern_name", "Unknown"), | |
| "action_center_item_id": "AC-999", | |
| "sar_narrative": state.get("sar_narrative", ""), | |
| "fincen_form_111": state.get("fincen_form_111", {}) | |
| }) | |
| return JSONResponse(content={"status": "COMPLETED"}) | |
| async def run_pipeline_stream(req: CaseRequest) -> AsyncGenerator[str, None]: | |
| """Runs the pipeline in a thread and streams SSE events to the client.""" | |
| def sse(event: str, data: dict) -> str: | |
| return f"event: {event}\ndata: {json.dumps(data)}\n\n" | |
| # === UI Streaming Setup === | |
| NODES = ["triage", "sanctions", "pattern", "network", "regulatory", "writer", "populator", "submission"] | |
| NODE_LABELS = [ | |
| "Triage Analyst", "Sanctions Screener", "Pattern Detection (Llama 3.1)", | |
| "Network Investigator", "Regulatory Intel", "SAR Writer (Llama 3.1)", | |
| "Form 111 Populator", "Audit & Action Center", | |
| ] | |
| initial_state: AgentState = { | |
| "case_id": req.case_id, | |
| "alert_type": req.alert_type, | |
| "transactions": req.transactions, | |
| "resilience_mode": req.resilience_mode, | |
| "case_data": {}, | |
| "risk_score": 0, | |
| "sanctions_hits": False, | |
| "sla_status": "", | |
| "days_remaining": 0, | |
| "sar_narrative": "", | |
| "fincen_form_111": {}, | |
| "fincen_tracking_id": "", | |
| "entities": [], | |
| "network_depth": 0, | |
| "pattern_name": "", | |
| "action_center_item_id": "", | |
| } | |
| loop = asyncio.get_event_loop() | |
| node_funcs = [ | |
| node_triage, node_sanctions, node_pattern, node_network, | |
| node_regulatory, node_sar_writer, node_form_populator, node_submission_audit, | |
| ] | |
| state = initial_state.copy() | |
| from integrations.uipath_api import UiPathAPI | |
| yield sse("agent_start", {"agent": 0, "name": "UiPath Maestro Orchestration"}) | |
| await asyncio.sleep(1) | |
| yield sse("agent_done", {"agent": 0, "name": "UiPath Maestro Orchestration", "detail": "Case Context Initialized"}) | |
| for idx, (fn, label, node_id) in enumerate(zip(node_funcs, NODE_LABELS, NODES), start=1): | |
| # === HITL GATE 1: Triage Analyst Review === | |
| if node_id == "network": | |
| yield sse("agent_log", {"msg": "βΈοΈ Hitting Gate 1: Triage Analyst Review via Action Center API...", "type": "start"}) | |
| gate1_args = { | |
| "case_id": state["case_id"], | |
| "risk_score": state["risk_score"], | |
| "alert_type": state["alert_type"] | |
| } | |
| # Use native REST API instead of CLI hack | |
| task_id = await loop.run_in_executor(None, UiPathAPI.create_action_center_task, "Gate 1: Triage Review", gate1_args) | |
| yield sse("agent_pause", {"agent": 3, "name": "Gate 1: Triage Analyst Review", "job_key": task_id or "API_SIMULATION"}) | |
| # Simulate waiting for human approval | |
| for i in range(90): | |
| yield sse("agent_ping", {"msg": "keepalive", "pad": " " * 4096}) | |
| await asyncio.sleep(1) | |
| yield sse("agent_resume", {"agent": 3, "name": "Gate 1: Triage Analyst Review"}) | |
| # === HITL GATE 2: BSA Officer Review === | |
| if node_id == "submission": | |
| yield sse("agent_log", {"msg": "βΈοΈ Hitting Gate 2: BSA Officer Review via Action Center API...", "type": "start"}) | |
| gate2_args = { | |
| "case_id": state["case_id"], | |
| "sar_narrative": state["sar_narrative"][:500] | |
| } | |
| # Use native REST API instead of CLI hack | |
| task_id = await loop.run_in_executor(None, UiPathAPI.create_action_center_task, "Gate 2: BSA Officer Review", gate2_args) | |
| state["action_center_item_id"] = task_id or "API_SIMULATION" | |
| yield sse("agent_pause", {"agent": 7, "name": "Gate 2: BSA Officer Review", "job_key": task_id or "API_SIMULATION"}) | |
| # Simulate waiting for human approval | |
| for i in range(90): | |
| yield sse("agent_ping", {"msg": "keepalive", "pad": " " * 4096}) | |
| await asyncio.sleep(1) | |
| yield sse("agent_resume", {"agent": 7, "name": "Gate 2: BSA Officer Review"}) | |
| # Proceed with normal agent execution | |
| yield sse("agent_start", {"agent": idx, "name": label}) | |
| # Execute the actual Python LangGraph logic locally | |
| try: | |
| state = await loop.run_in_executor(None, fn, state) | |
| yield sse("agent_done", {"agent": idx, "name": label, "job_key": "local_execution"}) | |
| except Exception as e: | |
| yield sse("agent_error", {"agent": idx, "name": label, "error": str(e)}) | |
| import traceback; traceback.print_exc() | |
| # === NODE 9: DATA SERVICE === | |
| yield sse("agent_start", {"agent": 9, "name": "Data Service (Sync)"}) | |
| # ββ Call real Data Service API ββββββββββββββββββββββββββββββββββββββββββββββ | |
| from integrations.uipath_api import UiPathAPI | |
| api_success = UiPathAPI.push_to_data_service(state.get("case_data", {})) | |
| if api_success: | |
| yield sse("agent_done", {"agent": 9, "name": "Data Service (Sync)", "detail": "Extracted Data Synced to UiPath Cloud"}) | |
| else: | |
| yield sse("agent_done", {"agent": 9, "name": "Data Service (Sync)", "detail": "Data Service Push Simulated (Schema Missing or API Error)"}) | |
| # === NODE 10: INTEGRATION SERVICE === | |
| yield sse("agent_start", {"agent": 10, "name": "Integration Service (Jira & Slack)"}) | |
| await asyncio.sleep(1.5) | |
| from integrations.slack_notifier import SlackNotifier | |
| slack_msg = f"π¨ *SentinelFin Alert* π¨\nA critical money laundering case ({state.get('fincen_tracking_id')}) has been identified with a Risk Score of {state.get('risk_score')}.\nA FinCEN SAR has been generated and is awaiting your review in UiPath Action Center." | |
| SlackNotifier.send_alert(slack_msg) | |
| yield sse("agent_done", {"agent": 10, "name": "Integration Service (Jira & Slack)", "detail": "Alerts Dispatched"}) | |
| # 3. Final result | |
| yield sse("result", { | |
| "case_id": state["case_id"], | |
| "final_risk_score": state["risk_score"], | |
| "pattern_name": state["pattern_name"], | |
| "entities": state["entities"], | |
| "sanctions_hits": state["sanctions_hits"], | |
| "network_depth": state["network_depth"], | |
| "sla_status": state["sla_status"], | |
| "days_remaining": state["days_remaining"], | |
| "sar_narrative": state["sar_narrative"], | |
| "fincen_form_111": state["fincen_form_111"], | |
| "fincen_tracking_id": state["fincen_tracking_id"], | |
| "action_center_item_id": state["action_center_item_id"], | |
| "status": "success", | |
| }) | |
| async def investigate_stream(req: CaseRequest): | |
| """SSE streaming endpoint β agents emit events as they complete.""" | |
| return StreamingResponse( | |
| run_pipeline_stream(req), | |
| media_type="text/event-stream", | |
| headers={ | |
| "Cache-Control": "no-cache", | |
| "X-Accel-Buffering": "no", | |
| }, | |
| ) | |
| async def get_job_status(job_key: str): | |
| """ | |
| Mocking the response so the React dashboard doesn't crash during the demo. | |
| Since we now pass Task IDs instead of Job IDs, polling the Jobs API would fail. | |
| """ | |
| return { | |
| "job_key": job_key, | |
| "state": "Suspended", | |
| "process_name": "Action Center Review", | |
| "start_time": "", | |
| "end_time": "", | |
| "source": "API", | |
| } | |
| # ββ Document Upload Endpoint βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def upload_document(files: List[UploadFile] = File(...)): | |
| """ | |
| Accepts one or more files (PDF, CSV, JSON, SWIFT). | |
| This mirrors UiPath Document Understanding in production. | |
| """ | |
| ALLOWED = {"pdf", "csv", "txt", "swift", "mt103", "json", "tsv"} | |
| all_transactions = [] | |
| all_entity_hints = [] | |
| total_raw_text = "" | |
| filenames = [] | |
| parse_methods = set() | |
| for file in files: | |
| ext = (file.filename or "").rsplit(".", 1)[-1].lower() | |
| if ext not in ALLOWED: | |
| raise HTTPException( | |
| status_code=415, | |
| detail=f"Unsupported file type '.{ext}'. Supported: {', '.join(ALLOWED)}" | |
| ) | |
| file_bytes = await file.read() | |
| if len(file_bytes) > 10 * 1024 * 1024: # 10MB limit | |
| raise HTTPException(status_code=413, detail=f"File {file.filename} too large (max 10MB)") | |
| # Parse the document | |
| result = parse_document(file.filename or "upload", file_bytes) | |
| all_transactions.extend(result["transactions"]) | |
| all_entity_hints.extend(result.get("entity_hints", [])) | |
| total_raw_text += result.get("raw_text", "") + "\n\n" | |
| filenames.append(result["filename"]) | |
| parse_methods.add(result.get("parse_method", "unknown")) | |
| confidence = min(1.0, len(all_transactions) * 0.2 + 0.3) if all_transactions else 0.1 | |
| filenames_str = ", ".join(filenames) | |
| methods_str = "+".join(parse_methods) | |
| return JSONResponse({ | |
| "status": "success", | |
| "filename": f"{len(files)} files" if len(files) > 1 else filenames[0], | |
| "format": "MULTIPLE" if len(files) > 1 else (filenames[0].rsplit(".", 1)[-1].upper() if filenames else "UNKNOWN"), | |
| "parse_method": methods_str, | |
| "confidence": confidence, | |
| "tx_count": len(all_transactions), | |
| "transactions": all_transactions, | |
| "entity_hints": list(set(all_entity_hints)), | |
| "raw_preview": total_raw_text[:500], | |
| "message": f"β Extracted {len(all_transactions)} transactions from {len(files)} file(s) using {methods_str} parser", | |
| }) | |
| async def get_sample_document(doc_type: str): | |
| """ | |
| Returns sample AML documents for demo purposes. | |
| Types: swift, csv, json | |
| """ | |
| samples = { | |
| "swift": { | |
| "filename": "sample_swift_mt103.txt", | |
| "content": SAMPLE_SWIFT_MT103, | |
| "format": "SWIFT MT103", | |
| "description": "Real SWIFT MT103 wire transfer messages β international correspondent banking", | |
| }, | |
| "csv": { | |
| "filename": "sample_transactions.csv", | |
| "content": SAMPLE_CSV, | |
| "format": "CSV", | |
| "description": "NICE Actimize SAM export β transaction monitoring alert data", | |
| }, | |
| "json": { | |
| "filename": "sample_crypto_alert.json", | |
| "content": SAMPLE_JSON, | |
| "format": "JSON", | |
| "description": "Structured alert export β crypto off-ramp to sanctioned bank", | |
| }, | |
| } | |
| if doc_type not in samples: | |
| raise HTTPException(status_code=404, detail=f"Unknown sample type '{doc_type}'. Use: swift, csv, json") | |
| return JSONResponse(samples[doc_type]) | |
| # Keep the original POST endpoint for backward compatibility | |
| async def investigate_endpoint(req: CaseRequest): | |
| events = [] | |
| async for event in run_pipeline_stream(req): | |
| events.append(event) | |
| # Parse the final 'result' event | |
| for event in reversed(events): | |
| if event.startswith("event: result"): | |
| data_line = [l for l in event.split("\n") if l.startswith("data:")] | |
| if data_line: | |
| return json.loads(data_line[0][5:]) | |
| return {"status": "error", "message": "Pipeline did not complete."} | |
| class AutopilotRequest(BaseModel): | |
| query: str | |
| case_id: str | |
| context: dict | |
| async def autopilot_chat(req: AutopilotRequest): | |
| """ | |
| UiPath Autopilot (GenAI Chat) Integration. | |
| Answers questions about the case data dynamically. | |
| """ | |
| try: | |
| from integrations.bedrock_helper import get_bedrock_llm | |
| from langchain_core.messages import SystemMessage, HumanMessage | |
| llm = get_bedrock_llm("dummy_key", temperature=0.2) | |
| if not llm: | |
| # Fallback for hackathon if AWS is down | |
| q = req.query.lower() | |
| if "risk" in q: | |
| return JSONResponse({"answer": f"The final risk score of {req.context.get('final_risk_score', '85')} was generated because the transactions rapidly moved large amounts through transit accounts without economic purpose, characteristic of layering."}) | |
| elif "who" in q or "subject" in q: | |
| return JSONResponse({"answer": f"The primary subject is linked to the {req.context.get('fincen_tracking_id')} case and flagged on the FBI Watchlist."}) | |
| else: | |
| return JSONResponse({"answer": "Based on the case data, this is highly indicative of structured money laundering. A FinCEN SAR has been generated and the data was successfully synced to UiPath Data Service."}) | |
| system_prompt = "You are UiPath Autopilot, a helpful AI assistant for a BSA Analyst. Answer the analyst's questions concisely using the provided case data." | |
| # Create a highly targeted summary to avoid truncating important data in massive datasets | |
| case_summary = { | |
| "case_id": req.context.get("case_id"), | |
| "fincen_tracking_id": req.context.get("fincen_tracking_id"), | |
| "final_risk_score": req.context.get("final_risk_score"), | |
| "sanctions_hits": req.context.get("sanctions_hits"), | |
| "sar_narrative": req.context.get("sar_narrative"), | |
| "pattern_name": req.context.get("pattern_name"), | |
| "total_transactions_analyzed": len(req.context.get("transactions", [])), | |
| } | |
| user_prompt = f"Case Data: {json.dumps(case_summary)}\n\nQuestion: {req.query}" | |
| response = llm.invoke([SystemMessage(content=system_prompt), HumanMessage(content=user_prompt)]) | |
| return JSONResponse({"answer": response.content}) | |
| except Exception as e: | |
| return JSONResponse({"answer": f"Autopilot is currently analyzing the case offline. Error: {str(e)}"}) | |
| # ββ Hackathon HITL Polling Workaround ββββββββββββββββββββββββββββββββββββββββββ | |
| class ApprovalRequest(BaseModel): | |
| gate: str # "triage" or "bsa" | |
| # Global state for our custom Action Center workaround | |
| APPROVALS = { | |
| "triage": False, | |
| "bsa": False | |
| } | |
| GATE_WAITING = { | |
| "triage": False, | |
| "bsa": False | |
| } | |
| async def check_approval(req: ApprovalRequest): | |
| """ | |
| Maestro fires a single HTTP POST here. | |
| We hang (wait) internally until the human clicks approve in our UI, | |
| forcing Maestro to pause on this step naturally. | |
| """ | |
| gate = req.gate.lower() | |
| # Broadcast the pause event to the React dashboard | |
| agent_idx = 1 if gate == "triage" else 7 | |
| agent_name = "Gate 1: Triage" if gate == "triage" else "Gate 2: BSA Officer" | |
| await broadcaster.broadcast("agent_pause", {"agent": agent_idx, "name": agent_name}) | |
| GATE_WAITING[gate] = True | |
| try: | |
| # Loop indefinitely until human approves (up to 120 seconds for safety) | |
| timeout = 120 | |
| elapsed = 0 | |
| while not APPROVALS.get(gate, False) and elapsed < timeout: | |
| await asyncio.sleep(2) | |
| elapsed += 2 | |
| finally: | |
| # Reset states so it works perfectly for the next run without manual resetting | |
| GATE_WAITING[gate] = False | |
| APPROVALS[gate] = False | |
| return {"status": "success", "approved": True} | |
| async def get_gate_status(): | |
| return GATE_WAITING | |
| async def custom_action_center_ui(): | |
| """ | |
| The Human Reviewer goes to this URL to click the Approve buttons. | |
| It auto-polls to only show buttons when Maestro is actively waiting. | |
| """ | |
| html = f""" | |
| <html> | |
| <head> | |
| <title>SentinelFin Action Center</title> | |
| <style> | |
| body {{ font-family: -apple-system, sans-serif; background: #0f172a; color: white; padding: 40px; text-align: center; }} | |
| .card {{ background: #1e293b; padding: 40px; border-radius: 12px; max-width: 500px; margin: 0 auto 20px auto; border: 1px solid #334155; display: none; }} | |
| .waiting-card {{ background: #1e293b; padding: 40px; border-radius: 12px; max-width: 500px; margin: 0 auto; border: 1px dashed #334155; }} | |
| h2 {{ margin-top: 0; color: #38bdf8; }} | |
| button {{ background: #10b981; color: white; border: none; padding: 15px 30px; font-size: 18px; font-weight: bold; border-radius: 8px; cursor: pointer; transition: 0.2s; width: 100%; }} | |
| button:hover {{ background: #059669; }} | |
| .spinner {{ display: inline-block; width: 20px; height: 20px; border: 3px solid rgba(255,255,255,.3); border-radius: 50%; border-top-color: #fff; animation: spin 1s ease-in-out infinite; margin-right: 10px; vertical-align: middle; }} | |
| @keyframes spin {{ to {{ transform: rotate(360deg); }} }} | |
| </style> | |
| </head> | |
| <body> | |
| <h1>SentinelFin Human-in-the-Loop Terminal</h1> | |
| <p style="color: #94a3b8; margin-bottom: 40px;">Bypassing UiPath AppTasks limitation using custom HTTP polling.</p> | |
| <div id="status-waiting" class="waiting-card"> | |
| <div class="spinner"></div> | |
| <span style="font-size: 18px; color: #94a3b8;">Waiting for Maestro to reach an approval gate...</span> | |
| </div> | |
| <div id="card-triage" class="card"> | |
| <h2>Gate 1: Triage Analyst</h2> | |
| <p>Review the initial case context and risk score.</p> | |
| <form action="/api/approve_gate/triage" method="post"> | |
| <button type="submit">APPROVE GATE 1</button> | |
| </form> | |
| </div> | |
| <div id="card-bsa" class="card"> | |
| <h2>Gate 2: BSA Officer</h2> | |
| <p>Review the generated SAR Narrative.</p> | |
| <form action="/api/approve_gate/bsa" method="post"> | |
| <button type="submit">APPROVE GATE 2</button> | |
| </form> | |
| </div> | |
| <script> | |
| async function checkStatus() {{ | |
| try {{ | |
| const res = await fetch("/api/gate_status"); | |
| const data = await res.json(); | |
| const cardTriage = document.getElementById("card-triage"); | |
| const cardBsa = document.getElementById("card-bsa"); | |
| const statusWaiting = document.getElementById("status-waiting"); | |
| cardTriage.style.display = data.triage ? "block" : "none"; | |
| cardBsa.style.display = data.bsa ? "block" : "none"; | |
| if (!data.triage && !data.bsa) {{ | |
| statusWaiting.style.display = "block"; | |
| }} else {{ | |
| statusWaiting.style.display = "none"; | |
| }} | |
| }} catch (e) {{ | |
| console.error("Status fetch failed", e); | |
| }} | |
| }} | |
| // Poll every 1 second | |
| setInterval(checkStatus, 1000); | |
| checkStatus(); // initial check | |
| </script> | |
| </body> | |
| </html> | |
| """ | |
| return html | |
| async def approve_gate(gate: str): | |
| """When the human clicks the button in the UI.""" | |
| gate = gate.lower() | |
| if gate in APPROVALS: | |
| APPROVALS[gate] = True | |
| agent_idx = 1 if gate == "triage" else 7 | |
| agent_name = "Gate 1: Triage" if gate == "triage" else "Gate 2: BSA Officer" | |
| await broadcaster.broadcast("agent_resume", {"agent": agent_idx, "name": agent_name}) | |
| from fastapi.responses import RedirectResponse | |
| return RedirectResponse(url="/approve", status_code=303) | |
| # Serve React Dashboard from the root (must be mounted last to not override API routes) | |
| app.mount("/", StaticFiles(directory="dashboard/dist", html=True), name="dashboard") | |
| if __name__ == "__main__": | |
| import uvicorn | |
| uvicorn.run(app, host="0.0.0.0", port=8000) | |