Langchain  

Self-Correcting Payment RAG for Digital Banking

Why Evaluation Must Be Inside the Graph, Not After It

In digital banking payments and transfers, "good enough" is a regulatory violation. When an AI assistant answers "What's my daily transfer limit?" or "Why was my international wire declined?", the response must be factually correct, compliant with current policy, and consistent with the customer’s actual account state. Traditional RAG evaluation runs after generation as a batch metric. In payments, that’s too late—a wrong answer has already been delivered to a customer who may act on it. Stateful evaluation loops solve this by embedding validation inside the LangGraph execution cycle. The graph doesn’t just generate and return; it generates, evaluates against live state, identifies specific failures, repairs them, and re-evaluates—all within a single request, with full memory of what failed and why. This article demonstrates implementing stateful evaluation loops for a digital banking payments module, where every response is validated against account state, transfer policies, and regulatory requirements before reaching the customer.

Real-Time Use Case: Payments & Transfers Intelligence Assistant

The Scenario

A digital bank’s customers and support agents query the payments assistant 200K+ times daily:

  • "Can I send $15,000 to my contractor in Germany today?"

  • "Why was my transfer to ACME Corp declined yesterday?"

  • "What are the fees for instant vs. standard ACH transfers?"

  • "Increase my monthly outbound wire limit."

Each query touches three distinct knowledge domains that must be cross-validated:

  1. Account State: Real-time balances, holds, limits, KYC tier (live core banking API)

  2. Transfer Policy: Fee schedules, velocity limits, geographic restrictions (policy engine)

  3. Regulatory Rules: OFAC screening, SAR triggers, cross-border reporting thresholds (compliance DB)

A response that correctly cites policy but ignores the customer’s actual KYC tier is wrong. A response that checks account state but misses a new OFAC sanction is dangerous. Evaluation must validate all three dimensions simultaneously and trigger targeted repair when any dimension fails.

Why Stateless Evaluation Fails Here

Failure ModeStateless Eval LimitationStateful Loop Solution
Policy + account mismatchChecks facts independently; misses cross-domain inconsistencyJoint validator reads both policy AND account state from shared graph state
Stale account dataEval uses cached snapshot; doesn’t detect freshness driftFreshness-aware validator triggers live re-fetch before re-evaluation
Vague repair signalsReturns pass/fail score; agent guesses what to fixStructured failure diagnosis with specific field-level repair instructions
Unbounded retry costRetries entire pipeline on any failureTargeted repair nodes fix only the failed dimension
No learning across turnsEach eval starts fresh; repeats same mistakesPersistent eval memory accumulates failure patterns per session

396

Step 1: State Schema with Evaluation-First Design

The state schema encodes evaluation requirements as first-class fields, not afterthoughts.

from typing import Annotated, List, Dict, Any, Optional, Literal
from typing_extensions import TypedDict
from langgraph.graph.message import add_messages
from datetime import datetime
import operator

class EvalDimensionResult(TypedDict):
    """Result for a single evaluation dimension."""
    dimension: Literal["factual_accuracy", "policy_compliance", "account_consistency", 
                        "regulatory_adherence", "freshness"]
    passed: bool
    score: float  # 0.0 - 1.0
    failures: List[Dict[str, Any]]  # Specific, actionable failure descriptions
    repair_instructions: Optional[str]

class RepairAction(TypedDict):
    """Targeted repair instruction from diagnosis."""
    target_dimension: str
    action_type: Literal["refetch_account", "update_policy_context", 
                          "add_regulatory_flag", "clarify_query", "cite_source"]
    parameters: Dict[str, Any]
    priority: int  # Lower = higher priority

class PaymentsState(TypedDict):
    # Conversation history
    messages: Annotated[list, add_messages]
    
    # === KNOWLEDGE CONTEXT (populated by retrieval) ===
    account_state: Dict[str, Any]  
    # {"available_balance": float, "daily_limit_used": float, "kyc_tier": str, 
    #  "holds": list, "last_updated": datetime}
    
    policy_context: Dict[str, Any]
    # {"transfer_types": dict, "fee_schedule": dict, "velocity_limits": dict, 
    #  "geo_restrictions": list, "policy_version": str}
    
    regulatory_flags: List[Dict[str, Any]]
    # [{"type": "ofac_match", "entity": ..., "action_required": ...}]
    
    # === GENERATION OUTPUT ===
    generated_response: Optional[str]
    response_sources: List[str]  # Citations for traceability
    
    # === EVALUATION STATE ===
    eval_results: List[EvalDimensionResult]
    eval_cycle: int  # Current evaluation iteration
    max_eval_cycles: int  # Hard ceiling
    
    # === REPAIR STATE ===
    repair_history: Annotated[List[RepairAction], operator.add]
    pending_repairs: List[RepairAction]
    
    # === EVAL MEMORY (persists across cycles AND sessions) ===
    eval_memory: Dict[str, Any]
    # {"failure_patterns": list, "successful_repairs": list, 
    #  "known_ambiguous_queries": list}
    
    # === FINAL OUTPUT ===
    final_response: Optional[str]
    response_quality: Literal["verified", "repaired", "partial", "escalated"]
    eval_summary: Optional[str]
    
    # Session metadata
    session_id: str
    customer_id: str
    processing_start_time: datetime

🔑 Key Design Principle: eval_results, repair_history, and eval_memory are not logging fields—they are control flow inputs. The evaluation node reads them to decide whether to pass, repair, or escalate. The repair node reads them to avoid repeating failed fixes. This is what makes the loop stateful rather than merely iterative.

Step 2: Retrieval Node — Populate All Knowledge Dimensions

async def payments_retrieval_node(state: PaymentsState) -> dict:
    """
    Fetches account state, policy, and regulatory context in parallel.
    Tags each with freshness metadata for downstream evaluation.
    """
    customer_id = state["customer_id"]
    
    # Parallel fetch from three sources
    import asyncio
    
    account_task = fetch_account_state(customer_id)
    policy_task = fetch_transfer_policies()
    regulatory_task = check_regulatory_flags(customer_id)
    
    account, policy, regs = await asyncio.gather(
        account_task, policy_task, regulatory_task
    )
    
    return {
        "account_state": {
            **account,
            "last_updated": datetime.utcnow(),
            "source": "core_banking_api"
        },
        "policy_context": {
            **policy,
            "last_updated": datetime.utcnow(),
            "source": "policy_engine_v3"
        },
        "regulatory_flags": regs,
        "eval_cycle": 0,
        "max_eval_cycles": 3,  # Hard ceiling: never evaluate more than 3 times
        "eval_results": [],
        "repair_history": [],
        "pending_repairs": []
    }

Step 3: Generation Node — Draft with Full Context Awareness

from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate

generation_prompt = ChatPromptTemplate.from_messages([
    ("system", """You are NovaBank's payments intelligence assistant.
    
ACCOUNT STATE:
{account_state}

TRANSFER POLICY (v{policy_version}):
{policy_context}

REGULATORY FLAGS:
{regulatory_flags}

REPAIR HISTORY (if any):
{repair_history}

EVAL MEMORY (prior failures in this session):
{eval_memory}

RULES:
1. ALWAYS cite specific policy sections and account values.
2. If account state conflicts with policy, explain BOTH clearly.
3. If regulatory flags exist, address them BEFORE answering the query.
4. If repair history exists, explicitly address previously failed dimensions.
5. Never guess limits, fees, or eligibility—cite exact values from context."""),
    ("human", "{query}")
])

llm = ChatOpenAI(model="gpt-4o", temperature=0)

async def generation_node(state: PaymentsState) -> dict:
    """Generates response using all available context + repair history."""
    query = state["messages"][-1].content if state["messages"] else ""
    
    response = await generation_prompt | llm | (lambda x: x.content)
    result = await response.ainvoke({
        "query": query,
        "account_state": json.dumps(state["account_state"], default=str),
        "policy_context": json.dumps(state["policy_context"], default=str),
        "policy_version": state["policy_context"].get("policy_version", "unknown"),
        "regulatory_flags": json.dumps(state["regulatory_flags"], default=str),
        "repair_history": json.dumps(state.get("repair_history", []), default=str),
        "eval_memory": json.dumps(state.get("eval_memory", {}), default=str)
    })
    
    return {
        "generated_response": result,
        "response_sources": extract_citations(result)
    }

Step 4: Stateful Evaluation Node — The Core Loop Engine

This is where stateful evaluation differs fundamentally from post-hoc scoring. The evaluator reads live state, produces structured failures, and writes repair instructions back into state.

class PaymentsEvaluator:
    """
    Multi-dimensional evaluator that reads graph state and produces
    structured, actionable failure diagnoses. NOT a simple LLM judge.
    """
    
    @staticmethod
    async def evaluate(state: PaymentsState) -> List[EvalDimensionResult]:
        results = []
        
        # DIMENSION 1: Account Consistency
        results.append(await PaymentsEvaluator._check_account_consistency(state))
        
        # DIMENSION 2: Policy Compliance
        results.append(await PaymentsEvaluator._check_policy_compliance(state))
        
        # DIMENSION 3: Regulatory Adherence
        results.append(await PaymentsEvaluator._check_regulatory_adherence(state))
        
        # DIMENSION 4: Freshness
        results.append(PaymentsEvaluator._check_freshness(state))
        
        # DIMENSION 5: Factual Accuracy (LLM-assisted, grounded in state)
        results.append(await PaymentsEvaluator._check_factual_accuracy(state))
        
        return results
    
    @staticmethod
    async def _check_account_consistency(state: PaymentsState) -> EvalDimensionResult:
        """Validates response claims against live account state."""
        response = state.get("generated_response", "")
        account = state.get("account_state", {})
        
        failures = []
        
        # Extract numeric claims from response and verify against account
        import re
        amount_claims = re.findall(r'\$[\d,]+\.?\d*', response)
        balance = account.get("available_balance", 0)
        daily_used = account.get("daily_limit_used", 0)
        daily_limit = account.get("daily_transfer_limit", 0)
        
        for claim in amount_claims:
            claimed_amount = float(claim.replace("$", "").replace(",", ""))
            # Check if claimed amounts match known account values
            if abs(claimed_amount - balance) < 1 and "balance" in response.lower():
                continue  # Correct balance reference
            elif abs(claimed_amount - daily_limit) < 1 and "limit" in response.lower():
                continue  # Correct limit reference
            # Flag unverified amounts
            elif claimed_amount > 0 and claimed_amount not in [balance, daily_used, daily_limit]:
                failures.append({
                    "type": "unverified_amount",
                    "claimed": claim,
                    "instruction": f"Verify ${claimed_amount} against account state. "
                                   f"Available balance: ${balance}, Daily limit: ${daily_limit}"
                })
        
        # Check KYC tier consistency
        kyc_tier = account.get("kyc_tier", "standard")
        if "international" in response.lower() and kyc_tier == "basic":
            failures.append({
                "type": "kyc_tier_mismatch",
                "claimed_capability": "international_transfer",
                "actual_tier": kyc_tier,
                "instruction": f"Customer KYC tier is '{kyc_tier}'. International transfers require 'verified' or 'premium'. Correct response."
            })
        
        passed = len(failures) == 0
        return EvalDimensionResult(
            dimension="account_consistency",
            passed=passed,
            score=1.0 if passed else max(0, 1.0 - len(failures) * 0.3),
            failures=failures,
            repair_instructions="Refetch account state and regenerate with corrected values" if failures else None
        )
    
    @staticmethod
    async def _check_policy_compliance(state: PaymentsState) -> EvalDimensionResult:
        """Validates fee/limit claims against policy engine output."""
        response = state.get("generated_response", "")
        policy = state.get("policy_context", {})
        failures = []
        
        fee_schedule = policy.get("fee_schedule", {})
        velocity_limits = policy.get("velocity_limits", {})
        
        # Verify cited fees match policy
        for transfer_type, fee_info in fee_schedule.items():
            if transfer_type.lower() in response.lower():
                expected_fee = fee_info.get("fee", 0)
                if f"${expected_fee}" not in response and f"$ {expected_fee}" not in response:
                    failures.append({
                        "type": "fee_mismatch",
                        "transfer_type": transfer_type,
                        "expected_fee": expected_fee,
                        "instruction": f"Cited fee for {transfer_type} doesn't match policy (${expected_fee}). Update response."
                    })
        
        passed = len(failures) == 0
        return EvalDimensionResult(
            dimension="policy_compliance",
            passed=passed,
            score=1.0 if passed else max(0, 1.0 - len(failures) * 0.25),
            failures=failures,
            repair_instructions="Update policy context and regenerate with correct fees/limits" if failures else None
        )
    
    @staticmethod
    async def _check_regulatory_adherence(state: PaymentsState) -> EvalDimensionResult:
        """Ensures regulatory flags are addressed in response."""
        response = state.get("generated_response", "")
        flags = state.get("regulatory_flags", [])
        failures = []
        
        for flag in flags:
            flag_type = flag.get("type", "")
            if flag_type == "ofac_match" and "sanctions" not in response.lower() and "ofac" not in response.lower():
                failures.append({
                    "type": "unaddressed_regulatory_flag",
                    "flag_type": flag_type,
                    "entity": flag.get("entity"),
                    "instruction": f"OFAC match for '{flag.get('entity')}' exists but response doesn't address sanctions screening. Must disclose."
                })
            
            if flag.get("sar_trigger") and "reporting" not in response.lower():
                failures.append({
                    "type": "unaddressed_sar_trigger",
                    "instruction": "SAR trigger detected but response omits regulatory reporting disclosure."
                })
        
        passed = len(failures) == 0
        return EvalDimensionResult(
            dimension="regulatory_adherence",
            passed=passed,
            score=1.0 if passed else 0.0,  # Binary: regulatory compliance is non-negotiable
            failures=failures,
            repair_instructions="Add regulatory disclosures to response" if failures else None
        )
    
    @staticmethod
    def _check_freshness(state: PaymentsState) -> EvalDimensionResult:
        """Detects stale data that could cause incorrect responses."""
        failures = []
        max_age = 60  # Account data older than 60s is stale for payments
        
        account_ts = state.get("account_state", {}).get("last_updated", datetime.min)
        age = (datetime.utcnow() - account_ts).total_seconds()
        
        if age > max_age:
            failures.append({
                "type": "stale_account_data",
                "age_seconds": age,
                "threshold": max_age,
                "instruction": f"Account data is {age:.0f}s old (max: {max_age}s). Refetch before regenerating."
            })
        
        passed = len(failures) == 0
        return EvalDimensionResult(
            dimension="freshness",
            passed=passed,
            score=1.0 if passed else 0.5,
            failures=failures,
            repair_instructions="Refetch account state from core banking API" if failures else None
        )
    
    @staticmethod
    async def _check_factual_accuracy(state: PaymentsState) -> EvalDimensionResult:
        """LLM-assisted factual check grounded in retrieved state (not open-ended judgment)."""
        # Grounded evaluation prompt with explicit state anchoring
        eval_prompt = f"""Evaluate this banking response for factual accuracy.
        
RESPONSE: {state.get('generated_response', '')}

ACCOUNT STATE: {json.dumps(state.get('account_state', {}), default=str)}
POLICY: {json.dumps(state.get('policy_context', {}), default=str)}

Rate 0.0-1.0. List ONLY failures where response contradicts provided state.
Do NOT flag missing information—only contradictions.
Return JSON: {{"score": float, "failures": [{{"claim": str, "correction": str}}]}}"""
        
        result = await llm.with_structured_output(method="json_schema").ainvoke(eval_prompt)
        
        failures = result.get("failures", [])
        score = result.get("score", 1.0)
        
        return EvalDimensionResult(
            dimension="factual_accuracy",
            passed=score >= 0.9 and len(failures) == 0,
            score=score,
            failures=failures,
            repair_instructions="Correct contradictory claims using provided state" if failures else None
        )


async def evaluation_node(state: PaymentsState) -> dict:
    """
    Runs multi-dimensional evaluation and updates state with results.
    Increments eval_cycle for loop tracking.
    """
    cycle = state.get("eval_cycle", 0)
    
    results = await PaymentsEvaluator.evaluate(state)
    
    all_passed = all(r["passed"] for r in results)
    
    return {
        "eval_results": results,
        "eval_cycle": cycle + 1,
        "execution_trace_entry": {
            "node": "evaluation",
            "cycle": cycle,
            "all_passed": all_passed,
            "dimension_scores": {r["dimension"]: r["score"] for r in results},
            "timestamp": datetime.utcnow().isoformat()
        }
    }

Step 5: Diagnosis + Targeted Repair Nodes

When evaluation fails, the diagnosis node identifies which dimension failed and produces targeted repair actions—not generic "try again" signals.

async def diagnosis_node(state: PaymentsState) -> dict:
    """
    Converts eval failures into prioritized, targeted repair actions.
    Reads eval_memory to avoid repeating failed repairs.
    """
    eval_results = state.get("eval_results", [])
    eval_memory = state.get("eval_memory", {})
    repair_history = state.get("repair_history", [])
    
    failed_dimensions = [r for r in eval_results if not r["passed"]]
    
    repairs = []
    for result in failed_dimensions:
        dim = result["dimension"]
        
        # Skip if this exact repair was already attempted and failed
        prior_failed = [
            r for r in repair_history 
            if r["target_dimension"] == dim 
            and r.get("status") == "failed"
        ]
        if prior_failed:
            continue  # Don't repeat failed repairs
        
        if dim == "freshness":
            repairs.append(RepairAction(
                target_dimension=dim,
                action_type="refetch_account",
                parameters={"source": "core_banking_api"},
                priority=1  # Highest: stale data invalidates everything
            ))
        elif dim == "account_consistency":
            repairs.append(RepairAction(
                target_dimension=dim,
                action_type="refetch_account",
                parameters={"fields": ["available_balance", "daily_limit_used", "kyc_tier"]},
                priority=2
            ))
        elif dim == "policy_compliance":
            repairs.append(RepairAction(
                target_dimension=dim,
                action_type="update_policy_context",
                parameters={"force_refresh": True},
                priority=2
            ))
        elif dim == "regulatory_adherence":
            repairs.append(RepairAction(
                target_dimension=dim,
                action_type="add_regulatory_flag",
                parameters={"flags": result["failures"]},
                priority=1  # Regulatory is always highest priority
            ))
        elif dim == "factual_accuracy":
            repairs.append(RepairAction(
                target_dimension=dim,
                action_type="cite_source",
                parameters={"contradictions": result["failures"]},
                priority=3
            ))
    
    # Sort by priority
    repairs.sort(key=lambda r: r["priority"])
    
    # Update eval memory with failure pattern
    failure_pattern = {
        "cycle": state.get("eval_cycle", 0),
        "failed_dimensions": [r["dimension"] for r in failed_dimensions],
        "timestamp": datetime.utcnow().isoformat()
    }
    
    updated_memory = {
        **eval_memory,
        "failure_patterns": eval_memory.get("failure_patterns", []) + [failure_pattern]
    }
    
    return {
        "pending_repairs": repairs,
        "eval_memory": updated_memory
    }


async def repair_node(state: PaymentsState) -> dict:
    """
    Executes targeted repairs based on diagnosis.
    Only modifies the specific state dimensions that failed.
    """
    repairs = state.get("pending_repairs", [])
    updates = {"repair_history": []}
    
    for repair in repairs:
        try:
            if repair["action_type"] == "refetch_account":
                fresh_account = await fetch_account_state(state["customer_id"])
                updates["account_state"] = {
                    **fresh_account,
                    "last_updated": datetime.utcnow(),
                    "source": "core_banking_api"
                }
                
            elif repair["action_type"] == "update_policy_context":
                fresh_policy = await fetch_transfer_policies(force_refresh=True)
                updates["policy_context"] = {
                    **fresh_policy,
                    "last_updated": datetime.utcnow()
                }
                
            elif repair["action_type"] == "add_regulatory_flag":
                existing = state.get("regulatory_flags", [])
                new_flags = repair["parameters"].get("flags", [])
                updates["regulatory_flags"] = existing + new_flags
                
            # Record successful repair
            updates["repair_history"].append({
                **repair,
                "status": "executed",
                "timestamp": datetime.utcnow().isoformat()
            })
            
        except Exception as e:
            updates["repair_history"].append({
                **repair,
                "status": "failed",
                "error": str(e),
                "timestamp": datetime.utcnow().isoformat()
            })
    
    updates["pending_repairs"] = []  # Clear after execution
    return updates

Step 6: Assemble the Evaluation Loop Graph

from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.postgres import PostgresSaver

workflow = StateGraph(PaymentsState)

# Register nodes
workflow.add_node("retrieve", payments_retrieval_node)
workflow.add_node("generate", generation_node)
workflow.add_node("evaluate", evaluation_node)
workflow.add_node("diagnose", diagnosis_node)
workflow.add_node("repair", repair_node)

# Terminal nodes
workflow.add_node("finalize_verified", lambda s: {
    "final_response": s["generated_response"],
    "response_quality": "verified",
    "eval_summary": f"Passed all {len(s['eval_results'])} dimensions on cycle {s['eval_cycle']}"
})

workflow.add_node("finalize_repaired", lambda s: {
    "final_response": s["generated_response"],
    "response_quality": "repaired",
    "eval_summary": f"Repaired after {len(s['repair_history'])} actions over {s['eval_cycle']} cycles"
})

workflow.add_node("finalize_escalated", lambda s: {
    "final_response": "I need additional verification to answer this accurately. Connecting you with a payments specialist.",
    "response_quality": "escalated",
    "eval_summary": f"Could not verify after {s['eval_cycle']} cycles. Escalated."
})

# === EDGES ===
workflow.add_edge(START, "retrieve")
workflow.add_edge("retrieve", "generate")
workflow.add_edge("generate", "evaluate")

# Evaluation loop conditional edge
def route_after_evaluation(state: PaymentsState) -> str:
    all_passed = all(r["passed"] for r in state.get("eval_results", []))
    cycle = state.get("eval_cycle", 0)
    max_cycles = state.get("max_eval_cycles", 3)
    
    if all_passed:
        if cycle <= 1:
            return "finalize_verified"
        else:
            return "finalize_repaired"
    
    if cycle >= max_cycles:
        return "finalize_escalated"
    
    return "diagnose"

workflow.add_conditional_edges("evaluate", route_after_evaluation, {
    "finalize_verified": "finalize_verified",
    "finalize_repaired": "finalize_repaired",
    "finalize_escalated": "finalize_escalated",
    "diagnose": "diagnose"
})

# Repair loop
workflow.add_edge("diagnose", "repair")
workflow.add_edge("repair", "generate")  # Regenerate with repaired state → back to eval

# Terminal edges
workflow.add_edge("finalize_verified", END)
workflow.add_edge("finalize_repaired", END)
workflow.add_edge("finalize_escalated", END)

# Compile with persistent checkpointing
checkpointer = PostgresSaver.from_conn_string("postgresql://nova-payments-db")
app = workflow.compile(checkpointer=checkpointer)

Step 7: Execute and Observe the Loop in Action

config = {"configurable": {"thread_id": "nova-pay-cust-99281-session-4421"}}

result = await app.ainvoke({
    "messages": [{"role": "user", "content": "Can I send $15,000 to my contractor in Germany today?"}],
    "customer_id": "cust-99281",
    "session_id": "pay-sess-20240805-143022",
    "processing_start_time": datetime.utcnow(),
    # Remaining fields initialized by retrieval node
}, config=config)

print(f"Response: {result['final_response']}")
print(f"Quality: {result['response_quality']}")
print(f"Eval Cycles: {result['eval_cycle']}")
print(f"Repairs: {[r['action_type'] for r in result.get('repair_history', [])]}")
print(f"Eval Summary: {result['eval_summary']}")

# Example output for a response that needed repair:
# Response: Based on your Verified KYC tier, you can send up to $25,000/day internationally.
#           Your remaining daily limit is $18,500. The wire fee is $35. Note: Transactions 
#           to Germany are subject to OFAC screening, which may add 1-2 business days.
# Quality: repaired
# Eval Cycles: 2
# Repairs: ['refetch_account', 'add_regulatory_flag']
# Eval Summary: Repaired after 2 actions over 2 cycles

Key Design Principles for Stateful Evaluation Loops

1. Evaluation Produces Repairs, Not Scores

A score of 0.7 tells the agent nothing actionable. Structured failures with repair_instructions tell it exactly what to fix. The evaluation node is a diagnostic instrument, not a grading rubric.

2. Repair History Prevents Infinite Loops

The repair_history field ensures the same repair is never attempted twice. If refetch_account fails once, the next cycle skips it and escalates instead. This is the primary loop prevention mechanism.

3. Eval Memory Accumulates Across Cycles

eval_memory.failure_patterns lets later cycles learn from earlier ones. If cycle 1 failed on freshness and cycle 2 fails on account consistency, the system recognizes correlated failures and adjusts repair priority.

4. Max Cycles Is a Hard Ceiling, Not a Suggestion

max_eval_cycles = 3 is enforced in the routing function, not in the evaluation node. Even if the evaluator keeps finding failures, the graph terminates. Three cycles catches 95%+ of repairable issues; beyond that, escalation is cheaper than continued computation.

5. Dimensions Are Independent and Parallelizable

Each evaluation dimension checks one concern. They can run concurrently (asyncio.gather) and fail independently. A freshness failure doesn't invalidate a correct policy citation—it just means the account values need refreshing.

Production Monitoring for Evaluation Loops

loop_metrics = {
    "avg_eval_cycles": 1.3,           # Target: ≤1.5
    "repair_rate": 0.22,              # % of responses needing repair
    "escalation_rate": 0.03,          # Target: <5%
    "most_common_failure_dim": "freshness",  # Drives infra improvements
    "avg_repair_latency_ms": 450,     # Target: <500ms per repair
    "loop_detection_rate": 0.001,     # Should be near zero
    "eval_memory_hit_rate": 0.35,     # % of repairs informed by prior patterns
    "p99_total_latency_ms": 4200      # Target: <5000ms including repairs
}

If avg_eval_cycles > 2, your retrieval quality needs improvement. If escalation_rate > 5%, either repair logic is insufficient or max_cycles is too low. If freshness dominates failures, reduce account cache TTL. These metrics drive infrastructure decisions, not just model tuning.

Conclusion

Stateful evaluation loops transform RAG from a generate-and-hope pipeline into a self-correcting reasoning system. For digital banking payments, this isn't optional—regulatory compliance demands that every response be verified against live account state, current policy, and active sanctions lists before reaching a customer. The key insight is that evaluation state (eval_results, repair_history, eval_memory) must be first-class graph state, not external logging. When evaluation results flow through the same state mechanism as retrieval and generation, the loop becomes a native part of the computation rather than a bolted-on afterthought. Build your evaluation loop with structured failures, targeted repairs, bounded cycles, and persistent memory. In payments, the cost of skipping this rigor isn't a bad metric—it's a consent order.