File size: 9,270 Bytes
000e62a
 
 
6d20eab
2d521fd
 
 
 
 
6d20eab
 
 
 
 
 
 
 
 
 
 
 
2d521fd
000e62a
 
 
 
 
 
 
 
2d521fd
30d653d
000e62a
30d653d
000e62a
6d20eab
 
 
 
 
 
 
 
000e62a
 
 
 
 
 
 
 
 
 
 
 
 
6d20eab
 
 
 
 
2d521fd
 
 
6d20eab
2d521fd
 
 
6d20eab
 
 
2d521fd
 
 
 
 
 
 
 
 
6d20eab
2d521fd
 
 
 
000e62a
2d521fd
 
 
 
6d20eab
 
000e62a
 
 
2d521fd
6d20eab
 
 
 
 
 
 
 
 
000e62a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6d20eab
000e62a
6d20eab
000e62a
 
 
2d521fd
 
 
6d20eab
 
 
2d521fd
 
 
6d20eab
 
 
 
 
2d521fd
6d20eab
2d521fd
 
 
 
 
6d20eab
 
2d521fd
 
 
6d20eab
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2d521fd
 
 
 
 
6d20eab
 
 
 
 
2d521fd
6d20eab
 
 
 
2d521fd
 
 
 
6d20eab
 
 
 
000e62a
6d20eab
2d521fd
 
 
6d20eab
 
 
 
 
 
 
2d521fd
000e62a
 
 
 
 
 
 
 
 
 
 
 
 
 
6d20eab
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
"""Outcome recording with idempotency, no dummy fallbacks, and timezone-aware timestamps.
Also updates per‑skill reliability models when skill provenance is present (v4.3.1).
"""

import datetime
import logging
from typing import Optional, Dict, Any

from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError

from agentic_reliability_framework.core.governance.risk_engine import RiskEngine
from agentic_reliability_framework.core.governance.intents import (
    InfrastructureIntent,
    ProvisionResourceIntent,
    GrantAccessIntent,
    DeployConfigurationIntent,
)
from app.database.models_intents import IntentDB, OutcomeDB, BetaStateDB

logger = logging.getLogger(__name__)

# ── v4.3.1: optional skill registry integration ──────────────
try:
    from agentic_reliability_framework.core.governance.skill_registry import SkillRegistry
    SKILL_REGISTRY_AVAILABLE = True
except ImportError:
    SkillRegistry = None
    SKILL_REGISTRY_AVAILABLE = False


# ---------------------------------------------------------------------------
# Helper: persist the conjugate posterior state
# ---------------------------------------------------------------------------
def _persist_beta_state(db: Session, tenant_id: str, risk_engine: RiskEngine) -> None:
    """
    Write the current Beta posterior parameters to the beta_state table.
    This is called after every outcome update so that online learning
    survives restarts.
    """
    try:
        state = risk_engine.beta_store.get_state()
        for cat, (alpha, beta) in state.items():
            # Upsert on (tenant_id, category): merge() matches on primary key
            # only, and these rows are always constructed without an `id`,
            # so merge() would always attempt an INSERT and collide with the
            # unique constraint on the second write for the same pair.
            row = db.query(BetaStateDB).filter(
                BetaStateDB.tenant_id == tenant_id,
                BetaStateDB.category == cat.value,
            ).first()
            if row is not None:
                row.alpha = alpha
                row.beta = beta
            else:
                db.add(BetaStateDB(tenant_id=tenant_id, category=cat.value, alpha=alpha, beta=beta))
        db.commit()
        logger.debug("Persisted Beta posterior parameters to database.")
    except Exception as e:
        db.rollback()
        logger.error("Failed to persist beta state: %s", e)


class OutcomeConflictError(Exception):
    """Raised when an outcome already exists for the same intent with a different result."""
    pass


def reconstruct_oss_intent_from_json(
        oss_json: Dict[str, Any]) -> InfrastructureIntent:
    """Reconstruct OSS intent from stored JSON. Raises ValueError on failure."""
    intent_type = oss_json.get("intent_type")
    if intent_type == "provision_resource":
        return ProvisionResourceIntent(**oss_json)
    elif intent_type == "grant_access":
        return GrantAccessIntent(**oss_json)
    elif intent_type == "deploy_config":
        return DeployConfigurationIntent(**oss_json)
    else:
        raise ValueError(
            f"Cannot reconstruct intent from JSON: missing or unknown intent_type {intent_type}")


def record_outcome(
    db: Session,
    tenant_id: str,
    deterministic_id: str,
    success: bool,
    recorded_by: Optional[str],
    notes: Optional[str],
    risk_engine: RiskEngine,
    idempotency_key: Optional[str] = None,
    skill_id: Optional[str] = None,               # v4.3.1
    skill_version: Optional[int] = None,           # v4.3.1
    skill_registry: Optional["SkillRegistry"] = None,  # v4.3.1
) -> OutcomeDB:
    """
    Record an outcome for a previously evaluated intent.

    Idempotent: calling twice with the same (deterministic_id, success) returns the same record.
    If the outcome already exists with a different success value, raises OutcomeConflictError.

    No dummy intents are created. If the OSS intent cannot be reconstructed, the risk engine
    is NOT updated – we log an error and still record the outcome.

    The intent lookup is scoped to `tenant_id` so a caller can only record outcomes for
    intents owned by their own tenant, even if they know or guess another tenant's
    deterministic_id.

    Parameters
    ----------
    db : Session
        SQLAlchemy session.
    tenant_id : str
        Tenant of the authenticated caller. Must match the intent's owning tenant.
    deterministic_id : str
        Unique identifier of the original intent.
    success : bool
        Whether the action succeeded (True) or failed (False).
    recorded_by : str or None
        Optional user or system identifier.
    notes : str or None
        Optional human-readable notes.
    risk_engine : RiskEngine
        ARF risk engine instance (may be updated).
    idempotency_key : str or None
        Optional caller-provided idempotency token.
    skill_id : str or None (v4.3.1)
        Identifier of the procedural skill that guided the action.
    skill_version : int or None (v4.3.1)
        Version number of that skill.
    skill_registry : SkillRegistry or None (v4.3.1)
        Optional skill registry instance to update per‑skill reliability.

    Returns
    -------
    OutcomeDB
        The recorded outcome object.

    Raises
    ------
    ValueError
        If intent not found or reconstruction fails fatally.
    OutcomeConflictError
        If a conflicting outcome already exists.
    """
    # 1. Fetch the original intent record, scoped to the caller's tenant
    intent = db.query(IntentDB).filter(
        IntentDB.deterministic_id == deterministic_id,
        IntentDB.tenant_id == tenant_id,
    ).one_or_none()
    if not intent:
        raise ValueError(f"Intent not found: {deterministic_id}")

    # 2. Idempotency / conflict check with database-level uniqueness
    existing_outcome = db.query(OutcomeDB).filter(
        OutcomeDB.intent_id == intent.id).one_or_none()
    if existing_outcome:
        if existing_outcome.success == success:
            return existing_outcome
        db.rollback()
        raise OutcomeConflictError(
            f"Outcome already recorded for intent {deterministic_id} with different result "
            f"(existing={existing_outcome.success}, new={success})"
        )

    # 3. Create outcome record
    outcome = OutcomeDB(
        intent_id=intent.id,
        success=bool(success),
        recorded_by=recorded_by,
        notes=notes,
        recorded_at=datetime.datetime.now(datetime.timezone.utc),
        idempotency_key=idempotency_key,
    )
    db.add(outcome)

    # 4. Attempt to commit; handle duplicate key errors for idempotency
    try:
        db.commit()
        db.refresh(outcome)
    except IntegrityError as e:
        db.rollback()
        if "idempotency_key" in str(e) and idempotency_key:
            existing = db.query(OutcomeDB).filter(
                OutcomeDB.idempotency_key == idempotency_key).first()
            if existing:
                logger.info(
                    "Idempotent request for key %s, returning existing outcome",
                    idempotency_key)
                return existing
        raise

    # 5. Update RiskEngine ONLY if we can reconstruct a valid OSS intent
    oss_intent = None
    if intent.oss_payload:
        try:
            oss_intent = reconstruct_oss_intent_from_json(intent.oss_payload)
        except Exception as e:
            logger.error(
                "Failed to reconstruct OSS intent for %s: %s. RiskEngine will NOT be updated.",
                deterministic_id,
                e,
                exc_info=True)
    else:
        logger.warning(
            "No oss_payload stored for intent %s – cannot update RiskEngine.",
            deterministic_id
        )

    if oss_intent is not None:
        try:
            risk_engine.update_outcome(oss_intent, success)

            # ----------------------------------------------------------------
            # PERSISTENCE: after updating the conjugate posterior, write it
            # ----------------------------------------------------------------
            _persist_beta_state(db, tenant_id, risk_engine)

        except Exception as e:
            logger.exception(
                "Failed to update RiskEngine after recording outcome for intent %s: %s",
                deterministic_id,
                e)
    else:
        logger.info(
            "Skipped RiskEngine update for intent %s (no valid OSS intent)",
            deterministic_id
        )

    # 6. v4.3.1: Update per‑skill reliability model if provenance is provided
    if SKILL_REGISTRY_AVAILABLE and skill_registry is not None and skill_id is not None and skill_version is not None:
        try:
            skill_registry.observe_outcome(skill_id, skill_version, success)
            logger.debug(
                "Skill reliability updated for '%s' v%d (success=%s)",
                skill_id, skill_version, success,
            )
        except Exception as e:
            logger.warning(
                "Failed to update skill reliability for '%s' v%d: %s",
                skill_id, skill_version, e, exc_info=True,
            )

    return outcome