Coverage for node / src / stigmem_node / routes / intents.py: 77%
178 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-25 01:49 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-25 01:49 +0000
1"""Intent Envelope route — spec §4, §5.14 (v0.8).
3POST /v1/intents
4 Accept a structured IntentEnvelope, validate it, decompose it into atomic
5 facts in the fabric, and return a receipt with the generated intent ID and
6 all written fact IDs.
8GET /v1/intents/:intent_id
9 Reconstruct and return the envelope by querying its reified facts.
11Fact reification schema (all facts share the same intent_id entity, except
12sub-entities for constraints/preferences/deferences/artifacts):
14 (intent_id, "intent:from", ref, from_uri, scope)
15 (intent_id, "intent:goal", text, from_uri, scope)
16 (intent_id, "intent:to", ref, from_uri, scope) — one per target
17 (intent_id, "intent:escalation", string, from_uri, scope) — priority
18 (intent_id, "intent:escalate_to", ref, from_uri, scope)
19 (intent_id, "intent:escalation:channel", string, ...)
20 (intent_id, "intent:escalation:context", string "true"|"false", ...)
21 (intent_id, "intent:handoff_to", ref, from_uri, scope) — to[0] when handoff present
22 (intent_id, "intent:handoff_summary",text, from_uri, scope)
23 (intent_id, "intent:context_ref", ref, from_uri, scope) — one per fact_ref
24 (intent_id, "intent:continuation", text, from_uri, scope)
25 (intent_id, "intent:constraint", ref, from_uri, scope) — one per constraint sub-entity
26 (intent_id, "intent:preference", ref, from_uri, scope) — one per preference sub-entity
27 (intent_id, "intent:deference", ref, from_uri, scope) — one per deference sub-entity
28 (intent_id, "intent:artifact", ref, from_uri, scope) — one per artifact sub-entity
30Sub-entities use the pattern "{intent_id}:{kind}:{index}" for stable referencing.
31"""
33from __future__ import annotations
35import uuid
36from datetime import UTC, datetime
37from typing import Annotated, Any
39from fastapi import APIRouter, Depends, HTTPException, status
41from ..auth import Identity, resolve_identity
42from ..db import db
43from ..entity_normalizer import NormalizationError, normalize_entity_uri
44from ..hlc import node_hlc
45from ..models.facts import FactValue
46from ..models.intents import (
47 Constraint,
48 DeferenceRule,
49 EscalationPolicy,
50 HandoffArtifact,
51 HandoffPayload,
52 IntentEnvelopeRecord,
53 IntentEnvelopeRequest,
54 Preference,
55)
57router = APIRouter(prefix="/v1/intents", tags=["intents"])
59# Relations that identify a fact row as the root of an IntentEnvelope.
60_ROOT_RELATION = "intent:goal"
63# ---------------------------------------------------------------------------
64# Helpers
65# ---------------------------------------------------------------------------
68def _encode_v(vtype: str, v: Any) -> str:
69 if vtype == "null": 69 ↛ 70line 69 didn't jump to line 70 because the condition on line 69 was never true
70 return "null"
71 if vtype == "boolean": 71 ↛ 72line 71 didn't jump to line 72 because the condition on line 71 was never true
72 return "true" if v else "false"
73 return str(v)
76def _insert(
77 conn: Any,
78 entity: str,
79 relation: str,
80 vtype: str,
81 vraw: Any,
82 source: str,
83 scope: str,
84 valid_until: str | None,
85 now: str,
86) -> str:
87 fact_id = str(uuid.uuid4())
88 hlc = node_hlc.tick()
89 conn.execute(
90 """INSERT INTO facts
91 (id, entity, relation, value_type, value_v, source, timestamp,
92 valid_until, confidence, scope, hlc, received_from)
93 VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""",
94 (
95 fact_id,
96 entity,
97 relation,
98 vtype,
99 _encode_v(vtype, vraw),
100 source,
101 now,
102 valid_until,
103 1.0,
104 scope,
105 hlc,
106 None,
107 ),
108 )
109 return fact_id
112def _decompose(
113 conn: Any,
114 intent_id: str,
115 req: IntentEnvelopeRequest,
116 now: str,
117) -> list[str]:
118 """Write all atomic facts for an IntentEnvelope; return list of fact IDs."""
119 src = req.from_uri
120 scope = req.scope
121 exp = req.expires_at
122 ids: list[str] = []
124 def ins(entity: str, relation: str, vtype: str, vraw: Any) -> None:
125 ids.append(_insert(conn, entity, relation, vtype, vraw, src, scope, exp, now))
127 # Core facts on intent_id
128 ins(intent_id, "intent:from", "ref", req.from_uri)
129 ins(intent_id, "intent:goal", "text", req.goal)
130 for to_uri in req.to:
131 ins(intent_id, "intent:to", "ref", to_uri)
133 # Escalation
134 if req.escalation:
135 esc = req.escalation
136 ins(intent_id, "intent:escalation", "string", esc.priority)
137 ins(intent_id, "intent:escalate_to", "ref", esc.escalate_to)
138 ins(intent_id, "intent:escalation:channel", "string", esc.channel)
139 ins(
140 intent_id,
141 "intent:escalation:context",
142 "string",
143 "true" if esc.include_context else "false",
144 )
146 # Handoff
147 if req.handoff:
148 h = req.handoff
149 # Emit intent:handoff_to pointing at first `to` for adapter compat
150 if req.to: 150 ↛ 152line 150 didn't jump to line 152 because the condition on line 150 was always true
151 ins(intent_id, "intent:handoff_to", "ref", req.to[0])
152 ins(intent_id, "intent:handoff_summary", "text", h.summary)
153 for ref_uri in h.fact_refs:
154 ins(intent_id, "intent:context_ref", "ref", ref_uri)
155 if h.continuation: 155 ↛ 157line 155 didn't jump to line 157 because the condition on line 155 was always true
156 ins(intent_id, "intent:continuation", "text", h.continuation)
157 for i, artifact in enumerate(h.artifacts):
158 art_id = f"{intent_id}:artifact:{i}"
159 ins(art_id, "intent:artifact:name", "string", artifact.name)
160 ins(art_id, "intent:artifact:ref", "ref", artifact.ref)
161 ins(intent_id, "intent:artifact", "ref", art_id)
163 # Constraints
164 for i, c in enumerate(req.constraint):
165 sub = f"{intent_id}:constraint:{i}"
166 ins(sub, "intent:constraint:kind", "string", c.kind)
167 ins(sub, "intent:constraint:limit", c.limit.type, c.limit.v)
168 if c.unit is not None: 168 ↛ 170line 168 didn't jump to line 170 because the condition on line 168 was always true
169 ins(sub, "intent:constraint:unit", "string", c.unit)
170 ins(intent_id, "intent:constraint", "ref", sub)
172 # Preferences
173 for i, p in enumerate(req.preference):
174 sub = f"{intent_id}:preference:{i}"
175 ins(sub, "intent:preference:kind", "string", p.kind)
176 ins(sub, "intent:preference:value", p.value.type, p.value.v)
177 ins(sub, "intent:preference:weight", "number", p.weight)
178 ins(intent_id, "intent:preference", "ref", sub)
180 # Deferences
181 for i, d in enumerate(req.deference):
182 sub = f"{intent_id}:deference:{i}"
183 ins(sub, "intent:deference:condition", "text", d.condition)
184 ins(sub, "intent:deference:defer_to", "ref", d.defer_to)
185 if d.timeout_s is not None: 185 ↛ 187line 185 didn't jump to line 187 because the condition on line 185 was always true
186 ins(sub, "intent:deference:timeout_s", "number", d.timeout_s)
187 ins(intent_id, "intent:deference", "ref", sub)
189 return ids
192def _index_root_rows(root: list[Any]) -> tuple[dict[str, list[str]], str | None, str | None]:
193 """Group root rows by relation; surface earliest timestamp + last valid_until."""
194 by_rel: dict[str, list[str]] = {}
195 created_at: str | None = None
196 expires_at: str | None = None
197 for row in root:
198 by_rel.setdefault(row["relation"], []).append(row["value_v"])
199 if created_at is None or row["timestamp"] < created_at:
200 created_at = row["timestamp"]
201 if row["valid_until"] is not None: 201 ↛ 202line 201 didn't jump to line 202 because the condition on line 201 was never true
202 expires_at = row["valid_until"]
203 return by_rel, created_at, expires_at
206def _build_escalation(by_rel: dict[str, list[str]]) -> EscalationPolicy | None:
207 if "intent:escalation" not in by_rel:
208 return None
209 return EscalationPolicy(
210 escalate_to=(by_rel.get("intent:escalate_to") or [""])[0],
211 channel=(by_rel.get("intent:escalation:channel") or ["stigmem"])[0],
212 priority=by_rel["intent:escalation"][0],
213 include_context=(by_rel.get("intent:escalation:context") or ["true"])[0] == "true",
214 )
217def _build_handoff(
218 by_rel: dict[str, list[str]],
219 rows_by_entity: dict[str, list[Any]],
220) -> HandoffPayload | None:
221 if "intent:handoff_summary" not in by_rel: 221 ↛ 223line 221 didn't jump to line 223 because the condition on line 221 was always true
222 return None
223 artifacts: list[HandoffArtifact] = []
224 for art_id in by_rel.get("intent:artifact", []):
225 art_by_rel = {r["relation"]: r["value_v"] for r in rows_by_entity.get(art_id, [])}
226 artifacts.append(
227 HandoffArtifact(
228 name=art_by_rel.get("intent:artifact:name", ""),
229 ref=art_by_rel.get("intent:artifact:ref", ""),
230 )
231 )
232 cont = by_rel.get("intent:continuation")
233 return HandoffPayload(
234 summary=by_rel["intent:handoff_summary"][0],
235 fact_refs=by_rel.get("intent:context_ref", []),
236 continuation=cont[0] if cont else None,
237 artifacts=artifacts,
238 )
241def _build_constraints(
242 by_rel: dict[str, list[str]],
243 rows_by_entity: dict[str, list[Any]],
244) -> list[Constraint]:
245 out: list[Constraint] = []
246 for sub_id in by_rel.get("intent:constraint", []): 246 ↛ 247line 246 didn't jump to line 247 because the loop on line 246 never started
247 sub_rel = {r["relation"]: r for r in rows_by_entity.get(sub_id, [])}
248 kind_row = sub_rel.get("intent:constraint:kind")
249 limit_row = sub_rel.get("intent:constraint:limit")
250 if kind_row and limit_row:
251 unit_row = sub_rel.get("intent:constraint:unit")
252 out.append(
253 Constraint(
254 kind=kind_row["value_v"],
255 limit=FactValue(type=limit_row["value_type"], v=limit_row["value_v"]),
256 unit=unit_row["value_v"] if unit_row else None,
257 )
258 )
259 return out
262def _build_preferences(
263 by_rel: dict[str, list[str]],
264 rows_by_entity: dict[str, list[Any]],
265) -> list[Preference]:
266 out: list[Preference] = []
267 for sub_id in by_rel.get("intent:preference", []): 267 ↛ 268line 267 didn't jump to line 268 because the loop on line 267 never started
268 sub_rel = {r["relation"]: r for r in rows_by_entity.get(sub_id, [])}
269 kind_row = sub_rel.get("intent:preference:kind")
270 val_row = sub_rel.get("intent:preference:value")
271 if kind_row and val_row:
272 wt_row = sub_rel.get("intent:preference:weight")
273 out.append(
274 Preference(
275 kind=kind_row["value_v"],
276 value=FactValue(type=val_row["value_type"], v=val_row["value_v"]),
277 weight=float(wt_row["value_v"]) if wt_row else 1.0,
278 )
279 )
280 return out
283def _build_deferences(
284 by_rel: dict[str, list[str]],
285 rows_by_entity: dict[str, list[Any]],
286) -> list[DeferenceRule]:
287 out: list[DeferenceRule] = []
288 for sub_id in by_rel.get("intent:deference", []): 288 ↛ 289line 288 didn't jump to line 289 because the loop on line 288 never started
289 sub_rel = {r["relation"]: r for r in rows_by_entity.get(sub_id, [])}
290 cond_row = sub_rel.get("intent:deference:condition")
291 defer_row = sub_rel.get("intent:deference:defer_to")
292 if cond_row and defer_row:
293 timeout_row = sub_rel.get("intent:deference:timeout_s")
294 out.append(
295 DeferenceRule(
296 condition=cond_row["value_v"],
297 defer_to=defer_row["value_v"],
298 timeout_s=int(float(timeout_row["value_v"])) if timeout_row else None,
299 )
300 )
301 return out
304def _reconstruct(
305 intent_id: str, rows_by_entity: dict[str, list[Any]]
306) -> IntentEnvelopeRecord | None:
307 """Rebuild an IntentEnvelopeRecord from raw DB rows grouped by entity."""
308 root = rows_by_entity.get(intent_id, [])
309 if not root: 309 ↛ 310line 309 didn't jump to line 310 because the condition on line 309 was never true
310 return None
312 by_rel, created_at, expires_at = _index_root_rows(root)
313 if _ROOT_RELATION not in by_rel: 313 ↛ 314line 313 didn't jump to line 314 because the condition on line 313 was never true
314 return None
316 return IntentEnvelopeRecord(
317 id=intent_id,
318 **{"from": (by_rel.get("intent:from") or [""])[0]},
319 to=by_rel.get("intent:to", []),
320 goal=by_rel[_ROOT_RELATION][0],
321 scope=root[0]["scope"],
322 created_at=created_at or datetime.now(UTC).isoformat(),
323 expires_at=expires_at,
324 constraint=_build_constraints(by_rel, rows_by_entity),
325 preference=_build_preferences(by_rel, rows_by_entity),
326 deference=_build_deferences(by_rel, rows_by_entity),
327 escalation=_build_escalation(by_rel),
328 handoff=_build_handoff(by_rel, rows_by_entity),
329 fact_ids=[], # not returned on GET; fact_ids are a POST-only receipt
330 )
333# ---------------------------------------------------------------------------
334# Routes
335# ---------------------------------------------------------------------------
338@router.post(
339 "",
340 response_model=IntentEnvelopeRecord,
341 response_model_by_alias=True,
342 status_code=status.HTTP_201_CREATED,
343)
344def submit_intent(
345 req: IntentEnvelopeRequest,
346 identity: Annotated[Identity, Depends(resolve_identity)],
347) -> IntentEnvelopeRecord:
348 """Submit an IntentEnvelope (Spec-X8-Intent-Envelope).
350 Validates the envelope, normalizes URIs, decomposes it into atomic facts,
351 and returns a receipt with the generated intent ID and all written fact IDs.
352 Idempotent when the caller supplies a stable ``id``: if that intent_id already
353 has a ``intent:goal`` fact in the fabric, returns 409 Conflict.
354 """
355 if not identity.can_write():
356 raise HTTPException(
357 status_code=status.HTTP_403_FORBIDDEN, detail="write permission required"
358 )
360 # Normalize URIs
361 try:
362 from_uri = normalize_entity_uri(req.from_uri)
363 except NormalizationError as exc:
364 raise HTTPException(
365 status_code=status.HTTP_400_BAD_REQUEST,
366 detail=f"invalid_entity_uri: from — {exc}",
367 ) from exc
369 normalized_to: list[str] = []
370 for i, t in enumerate(req.to):
371 try:
372 normalized_to.append(normalize_entity_uri(t))
373 except NormalizationError as exc:
374 raise HTTPException(
375 status_code=status.HTTP_400_BAD_REQUEST,
376 detail=f"invalid_entity_uri: to[{i}] — {exc}",
377 ) from exc
379 # Resolve intent_id
380 intent_id = req.id if req.id else f"intent:{uuid.uuid4()}"
382 now = datetime.now(UTC).isoformat()
384 with db() as conn:
385 # Idempotency check: reject if intent_id already exists
386 existing = conn.execute(
387 "SELECT id FROM facts WHERE entity=? AND relation=? LIMIT 1",
388 (intent_id, _ROOT_RELATION),
389 ).fetchone()
390 if existing:
391 raise HTTPException(
392 status_code=status.HTTP_409_CONFLICT,
393 detail=(
394 f"intent {intent_id!r} already exists; supply a new id "
395 "or omit for auto-generation"
396 ),
397 )
399 # Build a normalised copy for decomposition
400 normalised_req = req.model_copy(update={"from_uri": from_uri, "to": normalized_to})
401 fact_ids = _decompose(conn, intent_id, normalised_req, now)
403 return IntentEnvelopeRecord(
404 id=intent_id,
405 **{"from": from_uri},
406 to=normalized_to,
407 goal=req.goal,
408 scope=req.scope,
409 created_at=now,
410 expires_at=req.expires_at,
411 constraint=req.constraint,
412 preference=req.preference,
413 deference=req.deference,
414 escalation=req.escalation,
415 handoff=req.handoff,
416 fact_ids=fact_ids,
417 )
420@router.get(
421 "/{intent_id:path}",
422 response_model=IntentEnvelopeRecord,
423 response_model_by_alias=True,
424)
425def get_intent(
426 intent_id: str,
427 identity: Annotated[Identity, Depends(resolve_identity)],
428) -> IntentEnvelopeRecord:
429 """Retrieve an IntentEnvelope by ID, reconstructed from its reified facts.
431 Covered by Spec-X8-Intent-Envelope.
432 """
433 if not identity.can_read(): 433 ↛ 434line 433 didn't jump to line 434 because the condition on line 433 was never true
434 raise HTTPException(
435 status_code=status.HTTP_403_FORBIDDEN, detail="read permission required"
436 )
438 now = datetime.now(UTC).isoformat()
439 prefix = f"{intent_id}:%"
441 with db() as conn:
442 rows = conn.execute(
443 """SELECT * FROM facts
444 WHERE (entity = ? OR entity LIKE ?)
445 AND confidence > 0.0
446 AND (valid_until IS NULL OR valid_until > ?)
447 ORDER BY entity, relation""",
448 (intent_id, prefix, now),
449 ).fetchall()
451 if not rows:
452 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="intent not found")
454 rows_by_entity: dict[str, list[Any]] = {}
455 for row in rows:
456 rows_by_entity.setdefault(row["entity"], []).append(row)
458 record = _reconstruct(intent_id, rows_by_entity)
459 if record is None: 459 ↛ 460line 459 didn't jump to line 460 because the condition on line 459 was never true
460 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="intent not found")
462 return record