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

1"""Intent Envelope route — spec §4, §5.14 (v0.8). 

2 

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. 

7 

8GET /v1/intents/:intent_id 

9 Reconstruct and return the envelope by querying its reified facts. 

10 

11Fact reification schema (all facts share the same intent_id entity, except 

12sub-entities for constraints/preferences/deferences/artifacts): 

13 

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 

29 

30Sub-entities use the pattern "{intent_id}:{kind}:{index}" for stable referencing. 

31""" 

32 

33from __future__ import annotations 

34 

35import uuid 

36from datetime import UTC, datetime 

37from typing import Annotated, Any 

38 

39from fastapi import APIRouter, Depends, HTTPException, status 

40 

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) 

56 

57router = APIRouter(prefix="/v1/intents", tags=["intents"]) 

58 

59# Relations that identify a fact row as the root of an IntentEnvelope. 

60_ROOT_RELATION = "intent:goal" 

61 

62 

63# --------------------------------------------------------------------------- 

64# Helpers 

65# --------------------------------------------------------------------------- 

66 

67 

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) 

74 

75 

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 

110 

111 

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] = [] 

123 

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)) 

126 

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) 

132 

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 ) 

145 

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) 

162 

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) 

171 

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) 

179 

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) 

188 

189 return ids 

190 

191 

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 

204 

205 

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 ) 

215 

216 

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 ) 

239 

240 

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 

260 

261 

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 

281 

282 

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 

302 

303 

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 

311 

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 

315 

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 ) 

331 

332 

333# --------------------------------------------------------------------------- 

334# Routes 

335# --------------------------------------------------------------------------- 

336 

337 

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). 

349 

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 ) 

359 

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 

368 

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 

378 

379 # Resolve intent_id 

380 intent_id = req.id if req.id else f"intent:{uuid.uuid4()}" 

381 

382 now = datetime.now(UTC).isoformat() 

383 

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 ) 

398 

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) 

402 

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 ) 

418 

419 

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. 

430 

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 ) 

437 

438 now = datetime.now(UTC).isoformat() 

439 prefix = f"{intent_id}:%" 

440 

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() 

450 

451 if not rows: 

452 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="intent not found") 

453 

454 rows_by_entity: dict[str, list[Any]] = {} 

455 for row in rows: 

456 rows_by_entity.setdefault(row["entity"], []).append(row) 

457 

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") 

461 

462 return record