Skip to content

Commit be51bf8

Browse files
feat: add Phase 1 raw observation store
1 parent f46f60c commit be51bf8

5 files changed

Lines changed: 961 additions & 0 deletions

File tree

app/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@
1414
from . import source_authority_hardening as _source_authority_hardening # noqa: F401,E402
1515
from . import configured_source_subclasses as _configured_source_subclasses # noqa: F401,E402
1616

17+
# Phase 1 captures configured-source provider observations before source-mode/relevance
18+
# filtering can erase them from downstream state. It is additive truth storage only;
19+
# existing delivery/filtering remains the compatibility authority during rollout.
20+
from . import raw_observation_runtime as _raw_observation_runtime # noqa: F401,E402
21+
1722
# Protect runnable legacy/private/webhook boundaries and stale durable state too:
1823
# external historical rows/queued items must not bypass the collector policy.
1924
from . import configured_source_runtime as _configured_source_runtime # noqa: F401,E402

app/raw_observation.py

Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
1+
from __future__ import annotations
2+
3+
import hashlib
4+
import json
5+
import sqlite3
6+
from dataclasses import asdict, dataclass
7+
from datetime import datetime, timezone
8+
from pathlib import Path
9+
from typing import Any
10+
11+
SCHEMA_VERSION = 1
12+
13+
14+
class RawObservationError(RuntimeError):
15+
"""Raw source truth could not be persisted safely."""
16+
17+
18+
def _utc_now() -> str:
19+
return datetime.now(timezone.utc).isoformat()
20+
21+
22+
def _canonical_json(value: Any) -> str:
23+
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str)
24+
25+
26+
def _sha256_text(value: str) -> str:
27+
return hashlib.sha256(value.encode("utf-8")).hexdigest()
28+
29+
30+
@dataclass(frozen=True, slots=True)
31+
class RawObservation:
32+
provider: str
33+
external_post_id: str
34+
source_handle: str
35+
source_mode: str
36+
created_at: str
37+
text: str = ""
38+
conversation_id: str = ""
39+
reply_to_id: str = ""
40+
quoted_id: str = ""
41+
quoted_text: str = ""
42+
quoted_author: str = ""
43+
lang: str = ""
44+
media_json: str = "[]"
45+
quoted_media_json: str = "[]"
46+
post_type: str = "post"
47+
is_retweet: bool = False
48+
is_reply: bool = False
49+
is_quote: bool = False
50+
is_media_only: bool = False
51+
provenance: str = ""
52+
retrieval_attempt_id: str = ""
53+
provider_payload_hash: str = ""
54+
observation_status: str = "converted"
55+
56+
def __post_init__(self) -> None:
57+
provider = str(self.provider or "").strip().casefold()
58+
external_post_id = str(self.external_post_id or "").strip()
59+
source_handle = str(self.source_handle or "").lstrip("@").strip().casefold()
60+
if not provider or not external_post_id or not source_handle:
61+
raise RawObservationError(
62+
"Raw observation requires provider, external_post_id and source_handle."
63+
)
64+
object.__setattr__(self, "provider", provider)
65+
object.__setattr__(self, "external_post_id", external_post_id)
66+
object.__setattr__(self, "source_handle", source_handle)
67+
68+
@property
69+
def observation_key(self) -> str:
70+
return _sha256_text(
71+
"\x1f".join((self.provider, self.source_handle, self.external_post_id))
72+
)
73+
74+
def snapshot_dict(self) -> dict[str, Any]:
75+
data = asdict(self)
76+
data["is_retweet"] = bool(self.is_retweet)
77+
data["is_reply"] = bool(self.is_reply)
78+
data["is_quote"] = bool(self.is_quote)
79+
data["is_media_only"] = bool(self.is_media_only)
80+
return data
81+
82+
@property
83+
def snapshot_hash(self) -> str:
84+
return _sha256_text(_canonical_json(self.snapshot_dict()))
85+
86+
87+
class RawObservationStore:
88+
"""Durable pre-filter source truth.
89+
90+
`raw_observations` keeps the latest canonical snapshot and observation counters.
91+
`raw_observation_versions` keeps each distinct provider-visible version once.
92+
Repeated five-minute scans therefore do not duplicate full payloads indefinitely,
93+
while edits/representation changes remain auditable.
94+
"""
95+
96+
def __init__(self, path: Path) -> None:
97+
self.path = Path(path)
98+
self.path.parent.mkdir(parents=True, exist_ok=True)
99+
try:
100+
self.conn = sqlite3.connect(self.path, timeout=15)
101+
self.conn.row_factory = sqlite3.Row
102+
self.conn.execute("PRAGMA journal_mode=WAL")
103+
self.conn.execute("PRAGMA foreign_keys=ON")
104+
self._init_schema()
105+
except sqlite3.Error as exc:
106+
raise RawObservationError(
107+
f"Could not initialize raw observation store: {type(exc).__name__}"
108+
) from exc
109+
110+
def _init_schema(self) -> None:
111+
try:
112+
self.conn.executescript(
113+
"""
114+
CREATE TABLE IF NOT EXISTS raw_observation_meta (
115+
key TEXT PRIMARY KEY,
116+
value TEXT NOT NULL
117+
);
118+
119+
CREATE TABLE IF NOT EXISTS raw_observations (
120+
observation_key TEXT PRIMARY KEY,
121+
provider TEXT NOT NULL,
122+
external_post_id TEXT NOT NULL,
123+
source_handle TEXT NOT NULL,
124+
source_mode TEXT NOT NULL DEFAULT '',
125+
created_at TEXT NOT NULL DEFAULT '',
126+
text TEXT NOT NULL DEFAULT '',
127+
conversation_id TEXT NOT NULL DEFAULT '',
128+
reply_to_id TEXT NOT NULL DEFAULT '',
129+
quoted_id TEXT NOT NULL DEFAULT '',
130+
quoted_text TEXT NOT NULL DEFAULT '',
131+
quoted_author TEXT NOT NULL DEFAULT '',
132+
lang TEXT NOT NULL DEFAULT '',
133+
media_json TEXT NOT NULL DEFAULT '[]',
134+
quoted_media_json TEXT NOT NULL DEFAULT '[]',
135+
post_type TEXT NOT NULL DEFAULT 'post',
136+
is_retweet INTEGER NOT NULL DEFAULT 0,
137+
is_reply INTEGER NOT NULL DEFAULT 0,
138+
is_quote INTEGER NOT NULL DEFAULT 0,
139+
is_media_only INTEGER NOT NULL DEFAULT 0,
140+
provenance TEXT NOT NULL DEFAULT '',
141+
retrieval_attempt_id TEXT NOT NULL DEFAULT '',
142+
provider_payload_hash TEXT NOT NULL DEFAULT '',
143+
observation_status TEXT NOT NULL DEFAULT 'converted',
144+
snapshot_hash TEXT NOT NULL,
145+
first_observed_at TEXT NOT NULL,
146+
last_observed_at TEXT NOT NULL,
147+
observation_count INTEGER NOT NULL DEFAULT 1,
148+
UNIQUE(provider, external_post_id, source_handle)
149+
);
150+
151+
CREATE INDEX IF NOT EXISTS raw_observations_source_time_idx
152+
ON raw_observations(source_handle, created_at, external_post_id);
153+
154+
CREATE INDEX IF NOT EXISTS raw_observations_status_idx
155+
ON raw_observations(observation_status, last_observed_at);
156+
157+
CREATE TABLE IF NOT EXISTS raw_observation_versions (
158+
observation_key TEXT NOT NULL,
159+
snapshot_hash TEXT NOT NULL,
160+
observed_at TEXT NOT NULL,
161+
retrieval_attempt_id TEXT NOT NULL DEFAULT '',
162+
provenance TEXT NOT NULL DEFAULT '',
163+
snapshot_json TEXT NOT NULL,
164+
PRIMARY KEY(observation_key, snapshot_hash),
165+
FOREIGN KEY(observation_key)
166+
REFERENCES raw_observations(observation_key)
167+
ON DELETE CASCADE
168+
);
169+
170+
CREATE INDEX IF NOT EXISTS raw_observation_versions_time_idx
171+
ON raw_observation_versions(observed_at);
172+
"""
173+
)
174+
self.conn.execute(
175+
"""
176+
INSERT INTO raw_observation_meta(key, value)
177+
VALUES('schema_version', ?)
178+
ON CONFLICT(key) DO UPDATE SET value=excluded.value
179+
""",
180+
(str(SCHEMA_VERSION),),
181+
)
182+
self.conn.commit()
183+
except sqlite3.Error as exc:
184+
raise RawObservationError(
185+
f"Could not create raw observation schema: {type(exc).__name__}"
186+
) from exc
187+
188+
def record(
189+
self,
190+
observation: RawObservation,
191+
*,
192+
observed_at: str | None = None,
193+
) -> None:
194+
now = str(observed_at or _utc_now())
195+
snapshot = observation.snapshot_dict()
196+
snapshot_json = _canonical_json(snapshot)
197+
snapshot_hash = observation.snapshot_hash
198+
key = observation.observation_key
199+
try:
200+
with self.conn:
201+
self.conn.execute(
202+
"""
203+
INSERT INTO raw_observations(
204+
observation_key, provider, external_post_id, source_handle,
205+
source_mode, created_at, text, conversation_id, reply_to_id,
206+
quoted_id, quoted_text, quoted_author, lang, media_json,
207+
quoted_media_json, post_type, is_retweet, is_reply, is_quote,
208+
is_media_only, provenance, retrieval_attempt_id,
209+
provider_payload_hash, observation_status, snapshot_hash,
210+
first_observed_at, last_observed_at, observation_count
211+
)
212+
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1)
213+
ON CONFLICT(observation_key) DO UPDATE SET
214+
source_mode=excluded.source_mode,
215+
created_at=excluded.created_at,
216+
text=excluded.text,
217+
conversation_id=excluded.conversation_id,
218+
reply_to_id=excluded.reply_to_id,
219+
quoted_id=excluded.quoted_id,
220+
quoted_text=excluded.quoted_text,
221+
quoted_author=excluded.quoted_author,
222+
lang=excluded.lang,
223+
media_json=excluded.media_json,
224+
quoted_media_json=excluded.quoted_media_json,
225+
post_type=excluded.post_type,
226+
is_retweet=excluded.is_retweet,
227+
is_reply=excluded.is_reply,
228+
is_quote=excluded.is_quote,
229+
is_media_only=excluded.is_media_only,
230+
provenance=excluded.provenance,
231+
retrieval_attempt_id=excluded.retrieval_attempt_id,
232+
provider_payload_hash=excluded.provider_payload_hash,
233+
observation_status=excluded.observation_status,
234+
snapshot_hash=excluded.snapshot_hash,
235+
last_observed_at=excluded.last_observed_at,
236+
observation_count=raw_observations.observation_count + 1
237+
""",
238+
(
239+
key,
240+
observation.provider,
241+
observation.external_post_id,
242+
observation.source_handle,
243+
observation.source_mode,
244+
observation.created_at,
245+
observation.text,
246+
observation.conversation_id,
247+
observation.reply_to_id,
248+
observation.quoted_id,
249+
observation.quoted_text,
250+
observation.quoted_author,
251+
observation.lang,
252+
observation.media_json,
253+
observation.quoted_media_json,
254+
observation.post_type,
255+
int(observation.is_retweet),
256+
int(observation.is_reply),
257+
int(observation.is_quote),
258+
int(observation.is_media_only),
259+
observation.provenance,
260+
observation.retrieval_attempt_id,
261+
observation.provider_payload_hash,
262+
observation.observation_status,
263+
snapshot_hash,
264+
now,
265+
now,
266+
),
267+
)
268+
self.conn.execute(
269+
"""
270+
INSERT OR IGNORE INTO raw_observation_versions(
271+
observation_key, snapshot_hash, observed_at,
272+
retrieval_attempt_id, provenance, snapshot_json
273+
) VALUES(?,?,?,?,?,?)
274+
""",
275+
(
276+
key,
277+
snapshot_hash,
278+
now,
279+
observation.retrieval_attempt_id,
280+
observation.provenance,
281+
snapshot_json,
282+
),
283+
)
284+
except sqlite3.Error as exc:
285+
raise RawObservationError(
286+
f"Could not persist raw observation: {type(exc).__name__}"
287+
) from exc
288+
289+
def get(
290+
self,
291+
*,
292+
provider: str,
293+
external_post_id: str,
294+
source_handle: str,
295+
) -> dict[str, Any] | None:
296+
key = _sha256_text(
297+
"\x1f".join(
298+
(
299+
str(provider).strip().casefold(),
300+
str(source_handle).lstrip("@").strip().casefold(),
301+
str(external_post_id).strip(),
302+
)
303+
)
304+
)
305+
row = self.conn.execute(
306+
"SELECT * FROM raw_observations WHERE observation_key=?",
307+
(key,),
308+
).fetchone()
309+
return dict(row) if row is not None else None
310+
311+
def count(self, *, source_handle: str = "") -> int:
312+
if source_handle:
313+
return int(
314+
self.conn.execute(
315+
"SELECT count(*) FROM raw_observations WHERE source_handle=?",
316+
(str(source_handle).lstrip("@").strip().casefold(),),
317+
).fetchone()[0]
318+
)
319+
return int(self.conn.execute("SELECT count(*) FROM raw_observations").fetchone()[0])
320+
321+
def version_count(self, observation_key: str) -> int:
322+
return int(
323+
self.conn.execute(
324+
"SELECT count(*) FROM raw_observation_versions WHERE observation_key=?",
325+
(str(observation_key),),
326+
).fetchone()[0]
327+
)
328+
329+
def close(self) -> None:
330+
self.conn.close()

0 commit comments

Comments
 (0)