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
« prev ^ index » next coverage.py v7.13.5, created at 2026-06-18 05:34 +0000
1"""Scope synthesis route — Phase 6 (spec §synthesize)."""
3from __future__ import annotations
5from datetime import UTC, datetime
6from typing import Annotated, Any
8from fastapi import APIRouter, Depends, HTTPException, Query
10from ..auth import Identity, resolve_identity
11from ..db import db
12from ..fact_visibility import caller_read_scope
13from ..models.constants import VALID_SCOPES
15router = APIRouter(prefix="/v1/scopes", tags=["synthesis"])
17_SYS_PREFIX = "stigmem:"
18_URI_PREFIX = "stigmem://"
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 )
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)
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).
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]
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
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
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 }
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).
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}")
116 now = datetime.now(UTC).isoformat()
118 params = _build_synthesize_params(scope, identity.tenant_id, include_expired, limit, now)
120 with db() as conn:
121 rows = conn.execute(_SYNTHESIZE_SQL, params).fetchall()
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"])]
129 # Count occurrences per (entity, relation) among non-system facts to detect contradictions
130 seen = _count_pair_occurrences(rows)
132 facts_out: list[dict[str, Any]] = []
133 contradiction_count = 0
134 expired_count = 0
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
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
149 age_seconds = _row_age_seconds(r["timestamp"])
151 facts_out.append(_build_synthesized_fact(r, is_expired, age_seconds, contradicted))
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]
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 }