Coverage for node / src / stigmem_node / routes / intents.py: 77%
183 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-06-18 05:34 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-06-18 05:34 +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 ..fact_visibility import PROJECTED_GARDEN_JOIN, caller_read_scope, visible_facts_where
45from ..hlc import node_hlc
46from ..models.facts import FactValue
47from ..models.intents import (
48 Constraint,
49 DeferenceRule,
50 EscalationPolicy,
51 HandoffArtifact,
52 HandoffPayload,
53 IntentEnvelopeRecord,
54 IntentEnvelopeRequest,
55 Preference,
56)
58router = APIRouter(prefix="/v1/intents", tags=["intents"])
60# Relations that identify a fact row as the root of an IntentEnvelope.
61_ROOT_RELATION = "intent:goal"
64# ---------------------------------------------------------------------------
65# Helpers
66# ---------------------------------------------------------------------------
69def _encode_v(vtype: str, v: Any) -> str:
70 if vtype == "null": 70 ↛ 71line 70 didn't jump to line 71 because the condition on line 70 was never true
71 return "null"
72 if vtype == "boolean": 72 ↛ 73line 72 didn't jump to line 73 because the condition on line 72 was never true
73 return "true" if v else "false"
74 return str(v)
77def _insert(
78 conn: Any,
79 entity: str,
80 relation: str,
81 vtype: str,
82 vraw: Any,
83 source: str,
84 scope: str,
85 valid_until: str | None,
86 now: str,
87 tenant_id: str,
88) -> str:
89 fact_id = str(uuid.uuid4())
90 hlc = node_hlc.tick()
91 conn.execute(
92 """INSERT INTO facts
93 (id, entity, relation, value_type, value_v, source, timestamp,
94 valid_until, confidence, scope, hlc, received_from, tenant_id)
95 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
96 (
97 fact_id,
98 entity,
99 relation,
100 vtype,
101 _encode_v(vtype, vraw),
102 source,
103 now,
104 valid_until,
105 1.0,
106 scope,
107 hlc,
108 None,
109 tenant_id,
110 ),
111 )
112 return fact_id
115def _decompose(
116 conn: Any,
117 intent_id: str,
118 req: IntentEnvelopeRequest,
119 now: str,
120 tenant_id: str,
121) -> list[str]:
122 """Write all atomic facts for an IntentEnvelope; return list of fact IDs."""
123 src = req.from_uri
124 scope = req.scope
125 exp = req.expires_at
126 ids: list[str] = []
128 def ins(entity: str, relation: str, vtype: str, vraw: Any) -> None:
129 ids.append(_insert(conn, entity, relation, vtype, vraw, src, scope, exp, now, tenant_id))
131 # Core facts on intent_id
132 ins(intent_id, "intent:from", "ref", req.from_uri)
133 ins(intent_id, "intent:goal", "text", req.goal)
134 for to_uri in req.to:
135 ins(intent_id, "intent:to", "ref", to_uri)
137 # Escalation
138 if req.escalation:
139 esc = req.escalation
140 ins(intent_id, "intent:escalation", "string", esc.priority)
141 ins(intent_id, "intent:escalate_to", "ref", esc.escalate_to)
142 ins(intent_id, "intent:escalation:channel", "string", esc.channel)
143 ins(
144 intent_id,
145 "intent:escalation:context",
146 "string",
147 "true" if esc.include_context else "false",
148 )
150 # Handoff
151 if req.handoff:
152 h = req.handoff
153 # Emit intent:handoff_to pointing at first `to` for adapter compat
154 if req.to: 154 ↛ 156line 154 didn't jump to line 156 because the condition on line 154 was always true
155 ins(intent_id, "intent:handoff_to", "ref", req.to[0])
156 ins(intent_id, "intent:handoff_summary", "text", h.summary)
157 for ref_uri in h.fact_refs:
158 ins(intent_id, "intent:context_ref", "ref", ref_uri)
159 if h.continuation: 159 ↛ 161line 159 didn't jump to line 161 because the condition on line 159 was always true
160 ins(intent_id, "intent:continuation", "text", h.continuation)
161 for i, artifact in enumerate(h.artifacts):
162 art_id = f"{intent_id}:artifact:{i}"
163 ins(art_id, "intent:artifact:name", "string", artifact.name)
164 ins(art_id, "intent:artifact:ref", "ref", artifact.ref)
165 ins(intent_id, "intent:artifact", "ref", art_id)
167 # Constraints
168 for i, c in enumerate(req.constraint):
169 sub = f"{intent_id}:constraint:{i}"
170 ins(sub, "intent:constraint:kind", "string", c.kind)
171 ins(sub, "intent:constraint:limit", c.limit.type, c.limit.v)
172 if c.unit is not None: 172 ↛ 174line 172 didn't jump to line 174 because the condition on line 172 was always true
173 ins(sub, "intent:constraint:unit", "string", c.unit)
174 ins(intent_id, "intent:constraint", "ref", sub)
176 # Preferences
177 for i, p in enumerate(req.preference):
178 sub = f"{intent_id}:preference:{i}"
179 ins(sub, "intent:preference:kind", "string", p.kind)
180 ins(sub, "intent:preference:value", p.value.type, p.value.v)
181 ins(sub, "intent:preference:weight", "number", p.weight)
182 ins(intent_id, "intent:preference", "ref", sub)
184 # Deferences
185 for i, d in enumerate(req.deference):
186 sub = f"{intent_id}:deference:{i}"
187 ins(sub, "intent:deference:condition", "text", d.condition)
188 ins(sub, "intent:deference:defer_to", "ref", d.defer_to)
189 if d.timeout_s is not None: 189 ↛ 191line 189 didn't jump to line 191 because the condition on line 189 was always true
190 ins(sub, "intent:deference:timeout_s", "number", d.timeout_s)
191 ins(intent_id, "intent:deference", "ref", sub)
193 return ids
196def _index_root_rows(root: list[Any]) -> tuple[dict[str, list[str]], str | None, str | None]:
197 """Group root rows by relation; surface earliest timestamp + last valid_until."""
198 by_rel: dict[str, list[str]] = {}
199 created_at: str | None = None
200 expires_at: str | None = None
201 for row in root:
202 by_rel.setdefault(row["relation"], []).append(row["value_v"])
203 if created_at is None or row["timestamp"] < created_at:
204 created_at = row["timestamp"]
205 if row["valid_until"] is not None: 205 ↛ 206line 205 didn't jump to line 206 because the condition on line 205 was never true
206 expires_at = row["valid_until"]
207 return by_rel, created_at, expires_at
210def _build_escalation(by_rel: dict[str, list[str]]) -> EscalationPolicy | None:
211 if "intent:escalation" not in by_rel:
212 return None
213 return EscalationPolicy(
214 escalate_to=(by_rel.get("intent:escalate_to") or [""])[0],
215 channel=(by_rel.get("intent:escalation:channel") or ["stigmem"])[0],
216 priority=by_rel["intent:escalation"][0],
217 include_context=(by_rel.get("intent:escalation:context") or ["true"])[0] == "true",
218 )
221def _build_handoff(
222 by_rel: dict[str, list[str]],
223 rows_by_entity: dict[str, list[Any]],
224) -> HandoffPayload | None:
225 if "intent:handoff_summary" not in by_rel: 225 ↛ 227line 225 didn't jump to line 227 because the condition on line 225 was always true
226 return None
227 artifacts: list[HandoffArtifact] = []
228 for art_id in by_rel.get("intent:artifact", []):
229 art_by_rel = {r["relation"]: r["value_v"] for r in rows_by_entity.get(art_id, [])}
230 artifacts.append(
231 HandoffArtifact(
232 name=art_by_rel.get("intent:artifact:name", ""),
233 ref=art_by_rel.get("intent:artifact:ref", ""),
234 )
235 )
236 cont = by_rel.get("intent:continuation")
237 return HandoffPayload(
238 summary=by_rel["intent:handoff_summary"][0],
239 fact_refs=by_rel.get("intent:context_ref", []),
240 continuation=cont[0] if cont else None,
241 artifacts=artifacts,
242 )
245def _build_constraints(
246 by_rel: dict[str, list[str]],
247 rows_by_entity: dict[str, list[Any]],
248) -> list[Constraint]:
249 out: list[Constraint] = []
250 for sub_id in by_rel.get("intent:constraint", []): 250 ↛ 251line 250 didn't jump to line 251 because the loop on line 250 never started
251 sub_rel = {r["relation"]: r for r in rows_by_entity.get(sub_id, [])}
252 kind_row = sub_rel.get("intent:constraint:kind")
253 limit_row = sub_rel.get("intent:constraint:limit")
254 if kind_row and limit_row:
255 unit_row = sub_rel.get("intent:constraint:unit")
256 out.append(
257 Constraint(
258 kind=kind_row["value_v"],
259 limit=FactValue(type=limit_row["value_type"], v=limit_row["value_v"]),
260 unit=unit_row["value_v"] if unit_row else None,
261 )
262 )
263 return out
266def _build_preferences(
267 by_rel: dict[str, list[str]],
268 rows_by_entity: dict[str, list[Any]],
269) -> list[Preference]:
270 out: list[Preference] = []
271 for sub_id in by_rel.get("intent:preference", []): 271 ↛ 272line 271 didn't jump to line 272 because the loop on line 271 never started
272 sub_rel = {r["relation"]: r for r in rows_by_entity.get(sub_id, [])}
273 kind_row = sub_rel.get("intent:preference:kind")
274 val_row = sub_rel.get("intent:preference:value")
275 if kind_row and val_row:
276 wt_row = sub_rel.get("intent:preference:weight")
277 out.append(
278 Preference(
279 kind=kind_row["value_v"],
280 value=FactValue(type=val_row["value_type"], v=val_row["value_v"]),
281 weight=float(wt_row["value_v"]) if wt_row else 1.0,
282 )
283 )
284 return out
287def _build_deferences(
288 by_rel: dict[str, list[str]],
289 rows_by_entity: dict[str, list[Any]],
290) -> list[DeferenceRule]:
291 out: list[DeferenceRule] = []
292 for sub_id in by_rel.get("intent:deference", []): 292 ↛ 293line 292 didn't jump to line 293 because the loop on line 292 never started
293 sub_rel = {r["relation"]: r for r in rows_by_entity.get(sub_id, [])}
294 cond_row = sub_rel.get("intent:deference:condition")
295 defer_row = sub_rel.get("intent:deference:defer_to")
296 if cond_row and defer_row:
297 timeout_row = sub_rel.get("intent:deference:timeout_s")
298 out.append(
299 DeferenceRule(
300 condition=cond_row["value_v"],
301 defer_to=defer_row["value_v"],
302 timeout_s=int(float(timeout_row["value_v"])) if timeout_row else None,
303 )
304 )
305 return out
308def _reconstruct(
309 intent_id: str, rows_by_entity: dict[str, list[Any]]
310) -> IntentEnvelopeRecord | None:
311 """Rebuild an IntentEnvelopeRecord from raw DB rows grouped by entity."""
312 root = rows_by_entity.get(intent_id, [])
313 if not root: 313 ↛ 314line 313 didn't jump to line 314 because the condition on line 313 was never true
314 return None
316 by_rel, created_at, expires_at = _index_root_rows(root)
317 if _ROOT_RELATION not in by_rel: 317 ↛ 318line 317 didn't jump to line 318 because the condition on line 317 was never true
318 return None
320 return IntentEnvelopeRecord(
321 id=intent_id,
322 **{"from": (by_rel.get("intent:from") or [""])[0]},
323 to=by_rel.get("intent:to", []),
324 goal=by_rel[_ROOT_RELATION][0],
325 scope=root[0]["scope"],
326 created_at=created_at or datetime.now(UTC).isoformat(),
327 expires_at=expires_at,
328 constraint=_build_constraints(by_rel, rows_by_entity),
329 preference=_build_preferences(by_rel, rows_by_entity),
330 deference=_build_deferences(by_rel, rows_by_entity),
331 escalation=_build_escalation(by_rel),
332 handoff=_build_handoff(by_rel, rows_by_entity),
333 fact_ids=[], # not returned on GET; fact_ids are a POST-only receipt
334 )
337# ---------------------------------------------------------------------------
338# Routes
339# ---------------------------------------------------------------------------
342@router.post(
343 "",
344 response_model=IntentEnvelopeRecord,
345 response_model_by_alias=True,
346 status_code=status.HTTP_201_CREATED,
347)
348def submit_intent(
349 req: IntentEnvelopeRequest,
350 identity: Annotated[Identity, Depends(resolve_identity)],
351) -> IntentEnvelopeRecord:
352 """Submit an IntentEnvelope (Spec-X8-Intent-Envelope).
354 Validates the envelope, normalizes URIs, decomposes it into atomic facts,
355 and returns a receipt with the generated intent ID and all written fact IDs.
356 Idempotent when the caller supplies a stable ``id``: if that intent_id already
357 has a ``intent:goal`` fact in the fabric, returns 409 Conflict.
358 """
359 if not identity.can_write():
360 raise HTTPException(
361 status_code=status.HTTP_403_FORBIDDEN, detail="write permission required"
362 )
364 # Normalize URIs
365 try:
366 from_uri = normalize_entity_uri(req.from_uri)
367 except NormalizationError as exc:
368 raise HTTPException(
369 status_code=status.HTTP_400_BAD_REQUEST,
370 detail=f"invalid_entity_uri: from — {exc}",
371 ) from exc
373 normalized_to: list[str] = []
374 for i, t in enumerate(req.to):
375 try:
376 normalized_to.append(normalize_entity_uri(t))
377 except NormalizationError as exc:
378 raise HTTPException(
379 status_code=status.HTTP_400_BAD_REQUEST,
380 detail=f"invalid_entity_uri: to[{i}] — {exc}",
381 ) from exc
383 # Resolve intent_id
384 intent_id = req.id if req.id else f"intent:{uuid.uuid4()}"
386 now = datetime.now(UTC).isoformat()
388 with db() as conn:
389 # Idempotency check: reject if intent_id already exists in this tenant
390 # (tenant-scoped so it is not a cross-tenant existence oracle).
391 existing = conn.execute(
392 "SELECT id FROM facts WHERE entity=? AND relation=? AND tenant_id=? LIMIT 1",
393 (intent_id, _ROOT_RELATION, identity.tenant_id),
394 ).fetchone()
395 if existing:
396 raise HTTPException(
397 status_code=status.HTTP_409_CONFLICT,
398 detail=(
399 f"intent {intent_id!r} already exists; supply a new id "
400 "or omit for auto-generation"
401 ),
402 )
404 # Build a normalised copy for decomposition
405 normalised_req = req.model_copy(update={"from_uri": from_uri, "to": normalized_to})
406 fact_ids = _decompose(conn, intent_id, normalised_req, now, identity.tenant_id)
408 return IntentEnvelopeRecord(
409 id=intent_id,
410 **{"from": from_uri},
411 to=normalized_to,
412 goal=req.goal,
413 scope=req.scope,
414 created_at=now,
415 expires_at=req.expires_at,
416 constraint=req.constraint,
417 preference=req.preference,
418 deference=req.deference,
419 escalation=req.escalation,
420 handoff=req.handoff,
421 fact_ids=fact_ids,
422 )
425@router.get(
426 "/{intent_id:path}",
427 response_model=IntentEnvelopeRecord,
428 response_model_by_alias=True,
429)
430def get_intent(
431 intent_id: str,
432 identity: Annotated[Identity, Depends(resolve_identity)],
433) -> IntentEnvelopeRecord:
434 """Retrieve an IntentEnvelope by ID, reconstructed from its reified facts.
436 Covered by Spec-X8-Intent-Envelope.
437 """
438 if not identity.can_read(): 438 ↛ 439line 438 didn't jump to line 439 because the condition on line 438 was never true
439 raise HTTPException(
440 status_code=status.HTTP_403_FORBIDDEN, detail="read permission required"
441 )
443 now = datetime.now(UTC).isoformat()
444 # Escape LIKE metacharacters so a caller-supplied intent_id cannot inject
445 # wildcards into the prefix scan (audit intents sibling of F-10).
446 safe_prefix = intent_id.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
447 prefix = f"{safe_prefix}:%"
449 # Restrict to facts the caller may read — own tenant + visible garden (audit
450 # intents cross-tenant/garden sibling). visible_facts_where adds the
451 # tenant + projected-garden predicate; PROJECTED_GARDEN_JOIN supplies fgm.
452 read_scope = caller_read_scope(identity)
453 scope_sql, scope_params = visible_facts_where(read_scope)
454 sql = (
455 f"SELECT f.* FROM facts f {PROJECTED_GARDEN_JOIN}" # noqa: S608 # nosec B608
456 " WHERE (f.entity = ? OR f.entity LIKE ? ESCAPE '\\')"
457 " AND f.confidence > 0.0"
458 " AND (f.valid_until IS NULL OR f.valid_until > ?)"
459 f" {scope_sql}"
460 " ORDER BY f.entity, f.relation"
461 )
463 with db() as conn:
464 rows = conn.execute(sql, [intent_id, prefix, now, *scope_params]).fetchall()
466 if not rows:
467 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="intent not found")
469 rows_by_entity: dict[str, list[Any]] = {}
470 for row in rows:
471 rows_by_entity.setdefault(row["entity"], []).append(row)
473 record = _reconstruct(intent_id, rows_by_entity)
474 if record is None: 474 ↛ 475line 474 didn't jump to line 475 because the condition on line 474 was never true
475 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="intent not found")
477 return record