Coverage for node / src / stigmem_node / routes / synthesize.py: 95%

63 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-06-18 05:34 +0000

1"""Scope synthesis route — Phase 6 (spec §synthesize).""" 

2 

3from __future__ import annotations 

4 

5from datetime import UTC, datetime 

6from typing import Annotated, Any 

7 

8from fastapi import APIRouter, Depends, HTTPException, Query 

9 

10from ..auth import Identity, resolve_identity 

11from ..db import db 

12from ..fact_visibility import caller_read_scope 

13from ..models.constants import VALID_SCOPES 

14 

15router = APIRouter(prefix="/v1/scopes", tags=["synthesis"]) 

16 

17_SYS_PREFIX = "stigmem:" 

18_URI_PREFIX = "stigmem://" 

19 

20 

21def _is_system(entity: str, relation: str) -> bool: 

22 return (entity.startswith(_SYS_PREFIX) and not entity.startswith(_URI_PREFIX)) or ( 

23 relation.startswith(_SYS_PREFIX) and not relation.startswith(_URI_PREFIX) 

24 ) 

25 

26 

27_SYNTHESIZE_SQL = ( 

28 "SELECT f.*, " 

29 " COALESCE(fvo.valid_until, f.valid_until) AS projected_valid_until, " 

30 " COALESCE(fvo.confidence, f.confidence) AS projected_confidence, " 

31 " COALESCE(fgm.garden_id, f.garden_id) AS projected_garden_id " 

32 "FROM facts f " 

33 "LEFT JOIN fact_validity_overrides fvo ON fvo.fact_id = f.id " 

34 "LEFT JOIN fact_garden_membership fgm ON fgm.fact_id = f.id" 

35 " WHERE f.scope = ?" 

36 " AND f.tenant_id = ?" 

37 " AND (? = 1" 

38 " OR COALESCE(fvo.valid_until, f.valid_until) IS NULL" 

39 " OR COALESCE(fvo.valid_until, f.valid_until) > ?)" 

40 " ORDER BY COALESCE(fvo.confidence, f.confidence) DESC, f.timestamp DESC" 

41 " LIMIT ?" 

42) 

43 

44 

45def _build_synthesize_params( 

46 scope: str, tenant_id: str, include_expired: bool, limit: int, now: str 

47) -> list[Any]: 

48 """Return the bind values for ``_SYNTHESIZE_SQL`` (tenant-scoped, audit synthesize). 

49 

50 The SQL text is a module-level constant; this helper only computes 

51 bind values. Keeping the SQL string out of any function that 

52 accepts user input prevents CodeQL from interprocedurally tainting 

53 it — see issue #121 for why a function that takes user inputs and 

54 returns ``(sql, params)`` still trips ``py/sql-injection`` even 

55 when the returned SQL value is invariant. 

56 """ 

57 expired_flag = 1 if include_expired else 0 

58 return [scope, tenant_id, expired_flag, now, limit] 

59 

60 

61def _count_pair_occurrences(rows: list[Any]) -> dict[tuple[str, str], int]: 

62 """Count (entity, relation) occurrences for non-system facts.""" 

63 seen: dict[tuple[str, str], int] = {} 

64 for r in rows: 

65 if not _is_system(r["entity"], r["relation"]): 

66 key = (r["entity"], r["relation"]) 

67 seen[key] = seen.get(key, 0) + 1 

68 return seen 

69 

70 

71def _row_age_seconds(timestamp: str) -> float: 

72 """Return seconds elapsed since the row's ISO timestamp; 0.0 on parse error.""" 

73 try: 

74 ts = datetime.fromisoformat(timestamp.replace("Z", "+00:00")) 

75 return (datetime.now(UTC) - ts).total_seconds() 

76 except (ValueError, TypeError): 

77 return 0.0 

78 

79 

80def _build_synthesized_fact( 

81 r: Any, is_expired: bool, age_seconds: float, contradicted: bool 

82) -> dict[str, Any]: 

83 """Build the per-fact dict returned by synthesize_scope.""" 

84 return { 

85 "id": r["id"], 

86 "entity": r["entity"], 

87 "relation": r["relation"], 

88 "value": {"type": r["value_type"], "v": r["value_v"]}, 

89 "confidence": r["projected_confidence"], 

90 "timestamp": r["timestamp"], 

91 "valid_until": r["projected_valid_until"], 

92 "is_expired": is_expired, 

93 "age_seconds": age_seconds, 

94 "contradicted": contradicted, 

95 "source": r["source"], 

96 } 

97 

98 

99@router.get("/{scope}/synthesize") 

100def synthesize_scope( 

101 scope: str, 

102 identity: Annotated[Identity, Depends(resolve_identity)], 

103 include_expired: bool = Query(False), 

104 limit: int = Query(200, ge=1, le=1000), 

105) -> dict[str, Any]: 

106 """Confidence-weighted summary of all facts in a scope (Phase 6). 

107 

108 Returns facts sorted by confidence descending, with contradiction flags and 

109 freshness metadata for each fact, plus aggregate statistics. 

110 """ 

111 if not identity.can_read(): 111 ↛ 112line 111 didn't jump to line 112 because the condition on line 111 was never true

112 raise HTTPException(status_code=403, detail="read permission required") 

113 if scope not in VALID_SCOPES: 

114 raise HTTPException(status_code=400, detail=f"scope must be one of {VALID_SCOPES}") 

115 

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

117 

118 params = _build_synthesize_params(scope, identity.tenant_id, include_expired, limit, now) 

119 

120 with db() as conn: 

121 rows = conn.execute(_SYNTHESIZE_SQL, params).fetchall() 

122 

123 # Garden ACL (audit synthesize sibling of H1/M3): drop facts whose projected 

124 # garden the caller cannot see BEFORE aggregating, so the summary and stats 

125 # never expose restricted-garden content. Fail-closed + batched. 

126 read_scope = caller_read_scope(identity) 

127 rows = [r for r in rows if read_scope.garden_allows(r["projected_garden_id"])] 

128 

129 # Count occurrences per (entity, relation) among non-system facts to detect contradictions 

130 seen = _count_pair_occurrences(rows) 

131 

132 facts_out: list[dict[str, Any]] = [] 

133 contradiction_count = 0 

134 expired_count = 0 

135 

136 for r in rows: 

137 is_expired = ( 

138 r["projected_valid_until"] is not None and r["projected_valid_until"] <= now 

139 ) 

140 if is_expired: 

141 expired_count += 1 

142 

143 contradicted = False 

144 if not _is_system(r["entity"], r["relation"]): 

145 contradicted = seen.get((r["entity"], r["relation"]), 0) > 1 

146 if contradicted: 

147 contradiction_count += 1 

148 

149 age_seconds = _row_age_seconds(r["timestamp"]) 

150 

151 facts_out.append(_build_synthesized_fact(r, is_expired, age_seconds, contradicted)) 

152 

153 confidences = [f["confidence"] for f in facts_out] 

154 mean_confidence = sum(confidences) / len(confidences) if confidences else 0.0 

155 timestamps = [f["timestamp"] for f in facts_out] 

156 

157 return { 

158 "scope": scope, 

159 "fact_count": len(facts_out), 

160 "facts": facts_out, 

161 "contradiction_count": contradiction_count, 

162 "mean_confidence": mean_confidence, 

163 "freshest_timestamp": max(timestamps) if timestamps else None, 

164 "oldest_timestamp": min(timestamps) if timestamps else None, 

165 "expired_fact_count": expired_count, 

166 "synthesized_at": now, 

167 }