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_VITALS = re.compile(r"^/api/v1/vitals/([^/]+)/latest$")
|
||||||
_PATH_BFLD_SCAN = re.compile(r"^/api/v1/bfld/([^/]+)/last_scan$")
|
_PATH_BFLD_SCAN = re.compile(r"^/api/v1/bfld/([^/]+)/last_scan$")
|
||||||
_PATH_BFLD_SUBSCRIBE = re.compile(r"^/api/v1/bfld/([^/]+)/subscribe$")
|
_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):
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
|
@ -183,6 +232,17 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
self._json(200, r)
|
self._json(200, r)
|
||||||
return
|
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})
|
self._json(404, {"error": "not found", "path": path})
|
||||||
|
|
||||||
def do_POST(self) -> None:
|
def do_POST(self) -> None:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue