Skip to content

Commit cd5bcd0

Browse files
authored
Create events.py
1 parent eb15479 commit cd5bcd0

1 file changed

Lines changed: 343 additions & 0 deletions

File tree

src/ohip_logging/events.py

Lines changed: 343 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,343 @@
1+
"""
2+
Structured event models for IX-HapticSight.
3+
4+
This module provides replay-safe, backend-agnostic event records for important
5+
runtime behavior such as:
6+
7+
- interaction request handling
8+
- consent decisions
9+
- safety decisions
10+
- planning outcomes
11+
- runtime faults
12+
- state transitions
13+
- execution milestones
14+
15+
The design goal is to preserve enough causal context for later audit and replay
16+
without depending on ad hoc console logs.
17+
"""
18+
19+
from __future__ import annotations
20+
21+
from dataclasses import dataclass, field
22+
from enum import Enum
23+
from time import time
24+
from typing import Any, Optional
25+
26+
from ohip.schemas import ContactPlan, SafetyLevel
27+
from ohip_runtime.requests import (
28+
ConsentAssessment,
29+
CoordinationDecision,
30+
DecisionStatus,
31+
InteractionRequest,
32+
PlanningOutcome,
33+
SafetyAssessment,
34+
)
35+
from ohip_runtime.state import (
36+
ExecutionState,
37+
InteractionSession,
38+
InteractionState,
39+
RuntimeFault,
40+
RuntimeHealth,
41+
)
42+
43+
44+
class EventKind(str, Enum):
45+
REQUEST_RECEIVED = "REQUEST_RECEIVED"
46+
CONSENT_EVALUATED = "CONSENT_EVALUATED"
47+
SAFETY_EVALUATED = "SAFETY_EVALUATED"
48+
PLAN_CREATED = "PLAN_CREATED"
49+
COORDINATION_DECIDED = "COORDINATION_DECIDED"
50+
STATE_TRANSITION = "STATE_TRANSITION"
51+
FAULT_APPLIED = "FAULT_APPLIED"
52+
EXECUTION_STATUS = "EXECUTION_STATUS"
53+
RETREAT_STATUS = "RETREAT_STATUS"
54+
SAFE_HOLD_STATUS = "SAFE_HOLD_STATUS"
55+
BENCHMARK_MARKER = "BENCHMARK_MARKER"
56+
REPLAY_MARKER = "REPLAY_MARKER"
57+
58+
59+
def _safe_plan_dict(plan: Optional[ContactPlan]) -> Optional[dict[str, Any]]:
60+
if plan is None:
61+
return None
62+
return plan.to_dict()
63+
64+
65+
@dataclass(frozen=True)
66+
class EventRecord:
67+
"""
68+
Canonical structured event record.
69+
70+
Notes:
71+
- `event_id` should be unique within a log or evidence bundle.
72+
- `reason_code` is intentionally short and machine-friendly.
73+
- `details` is optional extra context, not a dumping ground for raw payloads.
74+
"""
75+
76+
event_id: str
77+
kind: EventKind
78+
session_id: Optional[str]
79+
request_id: Optional[str]
80+
interaction_state: Optional[str]
81+
execution_state: Optional[str]
82+
runtime_health: Optional[str]
83+
reason_code: str
84+
created_at_utc_s: float = field(default_factory=time)
85+
details: dict[str, Any] = field(default_factory=dict)
86+
87+
def to_dict(self) -> dict[str, Any]:
88+
return {
89+
"event_id": self.event_id,
90+
"kind": self.kind.value,
91+
"session_id": self.session_id,
92+
"request_id": self.request_id,
93+
"interaction_state": self.interaction_state,
94+
"execution_state": self.execution_state,
95+
"runtime_health": self.runtime_health,
96+
"reason_code": self.reason_code,
97+
"created_at_utc_s": float(self.created_at_utc_s),
98+
"details": dict(self.details),
99+
}
100+
101+
@staticmethod
102+
def from_dict(data: dict[str, Any]) -> "EventRecord":
103+
return EventRecord(
104+
event_id=str(data["event_id"]),
105+
kind=EventKind(str(data["kind"])),
106+
session_id=data.get("session_id"),
107+
request_id=data.get("request_id"),
108+
interaction_state=data.get("interaction_state"),
109+
execution_state=data.get("execution_state"),
110+
runtime_health=data.get("runtime_health"),
111+
reason_code=str(data.get("reason_code", "")),
112+
created_at_utc_s=float(data.get("created_at_utc_s", time())),
113+
details=dict(data.get("details", {})),
114+
)
115+
116+
117+
def event_from_request(request: InteractionRequest) -> EventRecord:
118+
return EventRecord(
119+
event_id=f"{request.request_id}:request",
120+
kind=EventKind.REQUEST_RECEIVED,
121+
session_id=request.session_id,
122+
request_id=request.request_id,
123+
interaction_state=None,
124+
execution_state=None,
125+
runtime_health=None,
126+
reason_code="request_received",
127+
details={
128+
"interaction_kind": request.interaction_kind.value,
129+
"source": request.source.value,
130+
"target_name": request.target_name,
131+
"requested_scope": request.requested_scope,
132+
"requires_contact": bool(request.requires_contact),
133+
"requires_consent_freshness": bool(request.requires_consent_freshness),
134+
"subject_id": request.subject_id,
135+
"requested_at_utc_s": float(request.requested_at_utc_s),
136+
"notes": request.notes,
137+
},
138+
)
139+
140+
141+
def event_from_consent_assessment(
142+
session: InteractionSession,
143+
assessment: ConsentAssessment,
144+
) -> EventRecord:
145+
return EventRecord(
146+
event_id=f"{assessment.request_id}:consent",
147+
kind=EventKind.CONSENT_EVALUATED,
148+
session_id=session.session_id,
149+
request_id=assessment.request_id,
150+
interaction_state=session.interaction_state.value,
151+
execution_state=session.execution_state.value,
152+
runtime_health=session.runtime_health.value,
153+
reason_code=assessment.reason_code,
154+
details={
155+
"status": assessment.status.value,
156+
"consent_mode": assessment.consent_mode.value,
157+
"consent_valid": bool(assessment.consent_valid),
158+
"consent_fresh": bool(assessment.consent_fresh),
159+
"scope_allowed": bool(assessment.scope_allowed),
160+
"evaluated_at_utc_s": float(assessment.evaluated_at_utc_s),
161+
},
162+
)
163+
164+
165+
def event_from_safety_assessment(
166+
session: InteractionSession,
167+
assessment: SafetyAssessment,
168+
) -> EventRecord:
169+
return EventRecord(
170+
event_id=f"{assessment.request_id}:safety",
171+
kind=EventKind.SAFETY_EVALUATED,
172+
session_id=session.session_id,
173+
request_id=assessment.request_id,
174+
interaction_state=session.interaction_state.value,
175+
execution_state=session.execution_state.value,
176+
runtime_health=session.runtime_health.value,
177+
reason_code=assessment.reason_code,
178+
details={
179+
"status": assessment.status.value,
180+
"safety_level": assessment.safety_level.value,
181+
"may_approach": bool(assessment.may_approach),
182+
"may_contact": bool(assessment.may_contact),
183+
"requires_retreat": bool(assessment.requires_retreat),
184+
"requires_safe_hold": bool(assessment.requires_safe_hold),
185+
"evaluated_at_utc_s": float(assessment.evaluated_at_utc_s),
186+
},
187+
)
188+
189+
190+
def event_from_planning_outcome(
191+
session: InteractionSession,
192+
outcome: PlanningOutcome,
193+
) -> EventRecord:
194+
return EventRecord(
195+
event_id=f"{outcome.request_id}:plan",
196+
kind=EventKind.PLAN_CREATED,
197+
session_id=session.session_id,
198+
request_id=outcome.request_id,
199+
interaction_state=session.interaction_state.value,
200+
execution_state=session.execution_state.value,
201+
runtime_health=session.runtime_health.value,
202+
reason_code=outcome.reason_code,
203+
details={
204+
"status": outcome.status.value,
205+
"degraded": bool(outcome.degraded),
206+
"created_at_utc_s": float(outcome.created_at_utc_s),
207+
"has_plan": bool(outcome.has_plan),
208+
"plan": _safe_plan_dict(outcome.plan),
209+
},
210+
)
211+
212+
213+
def event_from_coordination_decision(
214+
session: InteractionSession,
215+
decision: CoordinationDecision,
216+
) -> EventRecord:
217+
return EventRecord(
218+
event_id=f"{decision.request_id}:decision",
219+
kind=EventKind.COORDINATION_DECIDED,
220+
session_id=session.session_id,
221+
request_id=decision.request_id,
222+
interaction_state=session.interaction_state.value,
223+
execution_state=session.execution_state.value,
224+
runtime_health=session.runtime_health.value,
225+
reason_code=decision.reason_code,
226+
details={
227+
"status": decision.status.value,
228+
"executable": bool(decision.executable),
229+
"created_at_utc_s": float(decision.created_at_utc_s),
230+
"consent_status": decision.consent.status.value,
231+
"safety_status": decision.safety.status.value,
232+
"planning_status": decision.planning.status.value if decision.planning else None,
233+
"has_plan": bool(decision.planning.has_plan) if decision.planning else False,
234+
},
235+
)
236+
237+
238+
def event_from_fault(
239+
session: InteractionSession,
240+
fault: RuntimeFault,
241+
) -> EventRecord:
242+
kind = EventKind.FAULT_APPLIED
243+
if fault.requires_retreat:
244+
kind = EventKind.RETREAT_STATUS
245+
elif fault.requires_safe_hold:
246+
kind = EventKind.SAFE_HOLD_STATUS
247+
248+
return EventRecord(
249+
event_id=f"{fault.fault_id}:fault",
250+
kind=kind,
251+
session_id=session.session_id,
252+
request_id=session.active_plan_id,
253+
interaction_state=session.interaction_state.value,
254+
execution_state=session.execution_state.value,
255+
runtime_health=session.runtime_health.value,
256+
reason_code=fault.reason_code,
257+
details={
258+
"source": fault.source,
259+
"severity": fault.severity.value,
260+
"disposition": fault.disposition.value,
261+
"latched": bool(fault.latched),
262+
"requires_abort": bool(fault.requires_abort),
263+
"requires_retreat": bool(fault.requires_retreat),
264+
"requires_safe_hold": bool(fault.requires_safe_hold),
265+
"created_at_utc_s": float(fault.created_at_utc_s),
266+
"details": fault.details,
267+
},
268+
)
269+
270+
271+
def state_transition_event(
272+
*,
273+
event_id: str,
274+
session_id: str,
275+
from_interaction_state: InteractionState,
276+
to_interaction_state: InteractionState,
277+
from_execution_state: ExecutionState,
278+
to_execution_state: ExecutionState,
279+
runtime_health: RuntimeHealth,
280+
reason_code: str,
281+
) -> EventRecord:
282+
return EventRecord(
283+
event_id=event_id,
284+
kind=EventKind.STATE_TRANSITION,
285+
session_id=session_id,
286+
request_id=None,
287+
interaction_state=to_interaction_state.value,
288+
execution_state=to_execution_state.value,
289+
runtime_health=runtime_health.value,
290+
reason_code=reason_code,
291+
details={
292+
"from_interaction_state": from_interaction_state.value,
293+
"to_interaction_state": to_interaction_state.value,
294+
"from_execution_state": from_execution_state.value,
295+
"to_execution_state": to_execution_state.value,
296+
},
297+
)
298+
299+
300+
def execution_status_event(
301+
*,
302+
event_id: str,
303+
session_id: str,
304+
request_id: Optional[str],
305+
interaction_state: InteractionState,
306+
execution_state: ExecutionState,
307+
runtime_health: RuntimeHealth,
308+
reason_code: str,
309+
safety_level: SafetyLevel,
310+
accepted: bool,
311+
backend_status: str = "",
312+
progress: float = 0.0,
313+
) -> EventRecord:
314+
return EventRecord(
315+
event_id=event_id,
316+
kind=EventKind.EXECUTION_STATUS,
317+
session_id=session_id,
318+
request_id=request_id,
319+
interaction_state=interaction_state.value,
320+
execution_state=execution_state.value,
321+
runtime_health=runtime_health.value,
322+
reason_code=reason_code,
323+
details={
324+
"safety_level": safety_level.value,
325+
"accepted": bool(accepted),
326+
"backend_status": backend_status,
327+
"progress": float(progress),
328+
},
329+
)
330+
331+
332+
__all__ = [
333+
"EventKind",
334+
"EventRecord",
335+
"event_from_request",
336+
"event_from_consent_assessment",
337+
"event_from_safety_assessment",
338+
"event_from_planning_outcome",
339+
"event_from_coordination_decision",
340+
"event_from_fault",
341+
"state_transition_event",
342+
"execution_status_event",
343+
]

0 commit comments

Comments
 (0)