feat(adr-125 tier1+2 iter 4): semantic-events MCP endpoint per §2.1.d
GET /api/v1/semantic-events/<node_id>/latest exposes the three
ADR-125 §2.1.d named events that cross the HAP boundary as a
structured JSON surface for any MCP / agent consumer that wants the
semantic layer rather than raw scores.
Response shape:
{
"node_id": "12",
"privacy_class": 2,
"events": {
"unknown_presence": {"active": bool, "source": str, "ts": float},
"unexpected_occupancy": {"active": bool, "schedule_aware": false, "ts": float},
"unrecognized_activity_pattern": {
"active": bool, "anomaly_threshold": 0.7,
"anomaly_score": float, "ts": float
}
},
"redacted_fields": [
"identity_risk_score", "soul_match_probability", "rf_signature_hash"
]
}
Live response from real C6 (node_id=12):
{
"unknown_presence": {"active": true, ...},
"unexpected_occupancy": {"active": true, "schedule_aware": false, ...},
"unrecognized_activity_pattern": {"active": false, "anomaly_score": 0.0, ...}
}
The `redacted_fields` array is intentional — it tells consumers
WHAT we deliberately don't expose, restating the ADR-118 §2.5 /
ADR-125 §2.1.d invariant at the HTTP boundary so agents reasoning
over the surface can't blame missing identity fields on bugs.
`unexpected_occupancy.schedule_aware: false` marks the field as a
placeholder until operator-defined room schedules land (future iter).
Agents that branch on this can fall back to raw occupancy until then.
Refs ADR-125 §2.1.d (semantic-events naming contract).
Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
parent
63b77f7602
commit
3d30261e74
|
|
@ -99,6 +99,55 @@ def bfld_scan_for(node_id: str) -> dict | None:
|
|||
_PATH_VITALS = re.compile(r"^/api/v1/vitals/([^/]+)/latest$")
|
||||
_PATH_BFLD_SCAN = re.compile(r"^/api/v1/bfld/([^/]+)/last_scan$")
|
||||
_PATH_BFLD_SUBSCRIBE = re.compile(r"^/api/v1/bfld/([^/]+)/subscribe$")
|
||||
_PATH_SEMANTIC = re.compile(r"^/api/v1/semantic-events/([^/]+)/latest$")
|
||||
|
||||
|
||||
def semantic_events_for(node_id: str) -> dict | None:
|
||||
"""ADR-125 §2.1.d semantic-event surface.
|
||||
|
||||
The three named events that cross the HAP boundary. Each one is a
|
||||
boolean + last-fire timestamp. Agents subscribe to this endpoint
|
||||
rather than reasoning over raw scores — the naming is the contract.
|
||||
"""
|
||||
f = _load_feature()
|
||||
if f is None or f.get("node_id") != node_id:
|
||||
return None
|
||||
presence = bool(f.get("presence", False))
|
||||
anomaly = float(f.get("anomaly_score") or 0.0)
|
||||
return {
|
||||
"node_id": f["node_id"],
|
||||
"privacy_class": int(f.get("privacy_class", 2)),
|
||||
"events": {
|
||||
"unknown_presence": {
|
||||
"active": presence,
|
||||
"source": "BFLD presence_score (rolling 3s avg ≥ 0.30)",
|
||||
"ts": f["ts"],
|
||||
},
|
||||
"unexpected_occupancy": {
|
||||
# Placeholder: schedule-aware gating is future work.
|
||||
# For now we surface raw occupancy and mark the gate
|
||||
# as `schedule_aware=False` so agents know not to
|
||||
# equate this with the full §2.1.d intent yet.
|
||||
"active": presence,
|
||||
"schedule_aware": False,
|
||||
"ts": f["ts"],
|
||||
},
|
||||
"unrecognized_activity_pattern": {
|
||||
"active": anomaly >= 0.7,
|
||||
"anomaly_threshold": 0.7,
|
||||
"anomaly_score": anomaly,
|
||||
"ts": f["ts"],
|
||||
},
|
||||
},
|
||||
# ADR-125 §2.1.d invariant restated at the HTTP boundary:
|
||||
# identity_risk_score, soul_match_probability, and rf_signature_hash
|
||||
# are NEVER published from this endpoint.
|
||||
"redacted_fields": [
|
||||
"identity_risk_score",
|
||||
"soul_match_probability",
|
||||
"rf_signature_hash",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
|
|
@ -183,6 +232,17 @@ class Handler(BaseHTTPRequestHandler):
|
|||
self._json(200, r)
|
||||
return
|
||||
|
||||
m = _PATH_SEMANTIC.match(path)
|
||||
if m:
|
||||
node_id = m.group(1)
|
||||
r = semantic_events_for(node_id)
|
||||
if r is None:
|
||||
self._json(503, {"error": f"no recent semantic events for {node_id}",
|
||||
"hint": "watcher running? node_id correct?"})
|
||||
return
|
||||
self._json(200, r)
|
||||
return
|
||||
|
||||
self._json(404, {"error": "not found", "path": path})
|
||||
|
||||
def do_POST(self) -> None:
|
||||
|
|
|
|||
Loading…
Reference in New Issue