Intel Data Platform Guide
Who this is for: analysts, researchers, and team members who want to use the data we collect — where it lives, how it gets there, when it refreshes, and how to query it. No code access required.
This file is the source of truth. The Markdown file
docs/data/data_guide.mdis the source; the online page atdatadocs.hml.stis generated from it automatically by CI on every push tomain. Edit here, open a PR — CI renders and publishes. See How this page is built.
Overview
Everything we collect — social media, TV, radio, online news — plus everything we derive from it — narratives, subjects, events, briefs — lives in two stores:
| Store | What it holds | How you query it |
|---|---|---|
Google BigQuery — intel-487218.datawarehouse |
Raw collected content (posts, articles, transcripts) and analytics time-series (daily mention/volume tables) | SQL, directly (this is the analyst-facing store) |
| PostgreSQL (Render-managed app DB) | Source-of-truth catalogs and definitions (POIs, narratives, subjects), analysis results surfaced in the app (events, briefs, alignment), plus auth & ops | Through the app UI / API; direct SQL is for engineers |
Rule of thumb: if you want to analyze content or trends, you want BigQuery. If you want the curated definitions and finished results the product shows, that's PostgreSQL — reachable via the app at hml.st or the API.
The data moves through four layers: Collection → Storage → Processing → Presentation. The full diagram is in docs/diagrams/data-flow.txt.
Architecture diagrams
Two diagrams live in docs/diagrams/: end-to-end data flow (Collection → Storage → Processing → Presentation, plus detailed Publishers/messaging-funnel and Events sub-pipelines) and deployment topology (Render vs GCP, schedulers, workflows, jobs, secrets, external APIs). Click either image to open the full-size SVG.
Refreshed 2026-08-13 to match the ScrapeCreators migration, the official X API /
col:5xfrequent-collection paths, and the Pixel pipeline. If this guide and the diagrams ever drift again, the tables below in this guide are the current source of truth — diagrams are refreshed on request, not on every change.
Ingestion (Collection)
Scheduled Cloud Run Jobs (region us-west1) pull from each source and write raw rows to BigQuery. POI handles to track come from Postgres (pois). No ingest job writes to Postgres — all raw data lands in BigQuery.
| Source | Collected via | Entrypoint | Cadence | Lands in (BQ) |
|---|---|---|---|---|
| Twitter / X | XPOZ API | backend/xpoz_etl.py (ingest-tw; no standalone scheduler — runs inside collection-analysis) |
daily 12:00 (+ Fri 05:00, within collection-analysis) |
twitter_* |
| Twitter / X (official API) | official X API | backend/x_api_etl.py (ingest-x-api, col:x-api-tagged POIs, incremental via since_id) |
daily 12:00 (+ Fri 05:00, within collection-analysis) |
twitter_tweets |
Twitter / X (col:5x, XPOZ) |
XPOZ API | backend/xpoz_etl.py (ingest-tw-5x, --tags col:5x --exclude-tags col:x-api; serialised with ingest-ig-5x, skips while ingest-tw runs) |
00:00, 08:00, 16:00, 20:00 (via collection workflow) |
twitter_tweets |
Twitter / X (col:5x) |
official X API | backend/x_api_etl.py (ingest-x-api-5x, --all-tags col:5x col:x-api) |
00:00, 08:00, 16:00, 20:00 (via collection workflow) |
twitter_tweets |
| XPOZ API | backend/ig_etl.py |
daily (after Twitter) | ig_* |
|
Instagram (col:5x) |
XPOZ API | backend/ig_etl.py (ingest-ig-5x, --tags col:5x; serialised with ingest-tw-5x) |
00:00, 08:00, 16:00, 20:00 (via collection workflow) |
ig_* |
| TikTok | ScrapeCreators | backend/tiktok_etl.py (ingest-tiktok; also downloads each video's audio and transcribes it to Hebrew — see the TikTok section below) |
daily | tiktok_* |
TikTok (col:5x) |
ScrapeCreators | backend/tiktok_etl.py (ingest-tiktok-5x, --tags col:5x --scrape-since-hours 16) |
00:00, 08:00, 16:00, 20:00 (via collection workflow) |
tiktok_* |
| ScrapeCreators | backend/fb_etl.py (ingest-fb + ingest-fb-publishers) |
daily | fb_pages, fb_profiles, fb_posts |
|
Facebook (col:5x) |
ScrapeCreators | backend/fb_etl.py (ingest-fb-5x, --tags col:5x) |
00:00, 08:00, 16:00, 20:00 (via collection workflow) |
fb_pages, fb_profiles, fb_posts |
| Pixel (disguised venues) | ScrapeCreators | backend/scripts/pixel_ingest.py (ingest-pixel) |
daily 06:00 | pixel_* (see below) |
| Telegram | web scraper | backend/telegram_etl.py |
daily | telegram_* |
Telegram (col:5x) |
web scraper | backend/telegram_etl.py (ingest-telegram-5x, --tags col:5x --refresh-days 0) |
00:00, 08:00, 16:00, 20:00 (via collection workflow) |
telegram_* |
| TV (Channel 14) | Google Drive | backend/tv_etl.py |
daily | tv_* |
| Radio (Galei Yisrael) | site scraper | backend/gly_etl.py |
daily | gly_* |
| Online papers | site scrapers | backend/online_paper_etl.py |
every 2h / daily | online_paper_articles |
| Mainstream papers | site scrapers | backend/mainstream_papers_etl.py (ingest-mainstream-papers; feeds the events pipeline, separate taxonomy from Online papers above) |
hourly (within events-pipeline workflow) |
mainstream_articles |
| Podcasts | RSS + Gemini | backend/podcast_etl.py (ingest-podcasts; downloads each new episode's MP3 and transcribes to Hebrew via Gemini) |
daily 12:00 (+ Fri 05:00, within collection-analysis) |
podcast_* |
| Knowledge Base | Google Drive | backend/kb_etl.py |
daily | kb_* |
| Mashlat analytics (incident reports) | Cloud SQL Postgres (cross-project, mashlat-c2) |
backend/mashlat_etl.py (ingest-mashlat; read-only analytics.* views, via the Cloud SQL Python Connector) |
hourly | mashlat_* |
Each pipeline follows the same pattern: raw content tables, plus <platform>_etl_runs / _etl_run_errors (run audit) and <platform>_*_fetches (delta-window tracking). Raw tables upsert through a transient <table>_staging twin.
Live path, flag on: X's Activity API (XAA) pushes new posts as they land instead of waiting for
ingest-x-api-5x. Thex-activity-streamreader holds the single socket and publishes each NDJSON event to Pub/Sub (x-activity-events); it does not MERGE and does not transcribe.x-activity-workerinstances MERGE into the sharedtwitter_tweetstable (ingestion_source=x_activity) and then publish the mapped tweet to Pub/Sub topictwitter-live-tweetsfor other teams. Videos reuse the existingtwitter-transcribetopic and service. Gated by Hamal ConfigCatxActivityStreamEnabled(default on; missing SDK / outage / missing flag => on) — dashboard Off is the kill-switch; do not setX_ACTIVITY_STREAM_ENABLED=true. Allowlist is POIs taggedcamp:redorcol:x-api(union) with atwitter_id. Hourly jobx-activity-subscription-synckeeps XAApost.createsubscriptions matched to that allowlist (ADR 0011). Paid polling viapois.x_api_since_idstill runs and stayscol:x-apionly. Connection health is tracked inx_activity_stream_connections. See ADR 0012 and ADR 0008.
Processing (Analysis)
Analysis jobs read from BigQuery, load their catalogs (narratives, subjects, POIs, domain knowledge) from Postgres at startup, and write outputs back to both stores.
| Pipeline | Entrypoint | Reads | Writes |
|---|---|---|---|
| Narratives | backend/analysis_narratives.py |
BQ raw posts | PG narratives (catalog) · BQ mention_daily, narrative_sources, emerging_terms, dashboard_summary |
| Subjects | backend/analysis_subjects.py |
BQ raw posts | PG subjects · BQ subjects_sources, subjects_daily |
| Events | backend/events_pipeline.py |
BQ mainstream_articles (clustering input) + online_paper_articles/social/TV counts (amplification) |
PG events, event_sources, event_feedback |
| Messaging-funnel alignment | backend/messaging_funnel_workflow.py |
BQ twitter_tweets, fb_posts |
PG post_alignment |
| Briefs (weekly / daily) | backend/weekly_brief_etl.py, daily brief ETL |
BQ aggregates + PG catalogs | PG weekly_briefs, daily_brief |
| Keyword monitors | keyword_monitor_runner.py (on-demand) |
BQ raw tables | PG keyword_monitors, keyword_narratives |
| D-Day Signs (סימנים מעידים) | dday-candidates → dday-judge → dday-events, + non-blocking dday-locations |
BQ raw tables + PG dday_signs/dday_sign_versions (Sign definitions) |
BQ dday_sign_candidates, dday_sign_detections, dday_detection_locations · PG dday_events, dday_event_detections, dday_event_places |
Most analysis uses Gemini for classification, extraction, embeddings, and Hebrew synthesis.
The D-Day Signs pipeline is a three-stage chain — candidate nomination (keyword match), judging (Gemini + grounding check), aggregation into scored events for the /dday/triage queue — documented in full in docs/dday_signs.md (content selection, the judge's grounding rule, event merge/corroboration, authority bands). This section and the tables below cover only where its data lives.
When the flows run
All schedules are Asia/Jerusalem; schedulers live in us-west1 (Cloud Scheduler → Workflows).
| Time (IL) | What runs |
|---|---|
| hourly | ingest-mashlat (Mashlat analytics — incident reports collection) |
| every 2h, 06:00–22:00 | events-pipeline (mainstream-papers → events analysis, 3h window) |
| daily 04:00 | events-pipeline-daily (7-day rescore window) |
| daily 06:00 | ingest-messaging-briefs (parse desk briefs); ingest-pixel (disguised-venue collection) |
| daily 07:30 | messaging-funnel-workflow (collection → FB publishers → alignment) |
| every 3h, on the hour | dday-signs-trigger: dday-candidates → dday-judge → dday-events (+ non-blocking dday-locations) — election-day cadence (every 5 min, dday-signs-trigger-dday) exists but is created paused |
| 00:00, 08:00, 16:00, 20:00 daily | collection workflow: ingest-x-api-5x + ingest-tw-5x + ingest-fb-5x + ingest-ig-5x + ingest-tiktok-5x + ingest-telegram-5x, for col:5x-tagged POIs only. With the 12:00 full sweep below, that is five passes a day for these POIs. |
| daily 12:00 (+ extra Fri 05:00) | collection-analysis: all ingest jobs (incl. ingest-tw Twitter) → narratives + subjects analysis → events → weekly & daily briefs |
To check the last successful run of any pipeline, query its <platform>_etl_runs table in BigQuery, or use /admin/jobs in the app.
Getting notified when collection finishes (Pub/Sub)
Don't want to poll BigQuery or /admin/jobs? Both collection-analysis and collection publish a Pub/Sub event — with per-source success/failure counts — right after their collection phase finishes (Twitter video transcripts for that run included), before analysis and briefs even start.
- Topic:
projects/intel-487218/topics/ingestion-completed - Fires: once per workflow run —
collection-analysisdaily 12:00 (+ Fri 05:00),collectionat 00:00, 08:00, 16:00 and 20:00, all Asia/Jerusalem (see When the flows run above) - Attributes (filter subscriptions on these without decoding the message body):
workflow(collection-analysis|collection),phase(alwayscollection),status(success|partial_failure)
Message body (JSON, base64-encoded in the Pub/Sub data field — any client library decodes this automatically):
{
"workflow": "collection-analysis",
"status": "partial_failure",
"timestamp": "2026-08-12T09:03:41Z",
"summary": {
"total_sources": 11,
"sources_succeeded": 10,
"sources_failed": 1,
"items_succeeded": 4231,
"items_failed": 7
},
"sources": {
"tw": { "status": "success", "succeeded": 812, "failed": 0 },
"kb": { "status": "failed", "succeeded": 0, "failed": 7 }
},
"failed_sources": ["kb"]
}
sources.<name>.succeeded/.failed are item-level counts (posts/documents processed vs. failed within that source's own run), not just whether the job process exited 0.
To subscribe, you need roles/pubsub.subscriber on the topic — ask a project developer to grant it to you or your service account (see For admins for the equivalent BigQuery grant pattern; same idea, roles/pubsub.subscriber instead). Once granted:
gcloud pubsub subscriptions create my-subscription \
--project=intel-487218 \
--topic=ingestion-completed \
--topic-project=intel-487218
gcloud pubsub subscriptions pull my-subscription --project=intel-487218 --auto-ack --limit=5
If you're on a different GCP project (same org or not), you don't need access to this repo or project console — ask a project developer to grant your service account subscriber rights on the topic (or set up a push subscription to your endpoint on your behalf); you only need the topic name above and your own gcloud/client-library access to your own project.
For server-side filtering (e.g. only failures, or only one workflow), the full cross-project setup (both "you subscribe" and "we push to your endpoint" patterns), and delivery/idempotency notes, see Subscribing to ingestion-completed — everything above is enough to get started even without access to that file.
Where things live — BigQuery vs PostgreSQL
The same concept often has a definition in Postgres and time-series data in BigQuery. This map tells you which side to query:
| Entity | PostgreSQL (source of truth) | BigQuery |
|---|---|---|
| Narratives | narratives |
mention_daily, narrative_sources |
| Meta-narratives | meta_narratives |
denormalized onto narratives.meta_narrative |
| Group narratives | narrative_groups + narratives |
same narratives / mention_daily / narrative_sources |
| Keyword narratives | keyword_monitors, keyword_narratives |
(reads raw tables; no dedicated BQ output) |
| Subjects | subjects |
subjects_sources, subjects_daily |
| Events | events, event_sources, event_feedback |
(reads online_paper_articles + counts; no BQ output) |
| Weekly / daily briefs | weekly_briefs, daily_brief |
— |
| POIs / domain knowledge | pois, domain_knowledge |
— |
| Raw social/news/TV/radio/podcast content | — | twitter_*, tiktok_*, fb_*, ig_*, telegram_*, tv_*, gly_*, podcast_*, online_paper_articles, kb_* |
| Mashlat analytics (incident reports) | — | mashlat_signals, mashlat_events, mashlat_cases, mashlat_tasks, mashlat_regions, mashlat_teams, mashlat_event_categories, mashlat_signals_files, mashlat_events_files, mashlat_signals_events |
| Pixel (disguised-venue investigation) | — | pixel_venues, pixel_posts, pixel_venue_snapshots, pixel_lean_daily, pixel_flip_state, pixel_coordination, pixel_ig_* (IG intake) |
| SocialAgents KPI (external push) | socialagents_kpi_snapshot |
— |
| D-Day Signs (סימנים מעידים) | dday_signs, dday_sign_versions, dday_sign_stat, dday_events, dday_event_detections, dday_event_places, dday_event_lineage |
dday_sign_candidates, dday_sign_detections, dday_detection_locations |
BigQuery table catalog
Grouped by source. Key columns are a starting point — the full schema is visible in the BigQuery console.
🐦 Twitter / X
| Table | Contents | Key columns |
|---|---|---|
twitter_users |
X profiles | username, name, description, location, followers_count, verified, gist (AI role summary) |
twitter_tweets |
Tweets (partitioned by created_at day) |
id, text, author_username, created_at, created_at_date, like_count, retweet_count, reply_count, quote_count, hashtags, reply_settings |
twitter_followers |
follower→followee edges | follower_id, followee_id |
twitter_user_geocodes |
geocoded user locations | normalized_location, latitude, longitude |
reply_settingsis only populated for the official-X-API rows (col:x-api-tagged POIs — see the Ingestion table above). It'sNULLfor the bulk oftwitter_tweets, which comes via XPOZ: verified live against XPOZ's API that it silently drops unsupported field names from a fields request rather than erroring, andreplySettings/conversationControlare not among the fields it returns — this isn't a config gap, XPOZ just doesn't expose who-can-reply data.
🎵 TikTok
| Table | Contents | Key columns |
|---|---|---|
tiktok_users |
accounts | username, nickname, follower_count, like_count, post_count, is_verified |
tiktok_posts |
videos | id, username, description, created_at_date, play_count, like_count, comment_count, share_count, transcript |
Videos are transcribed. Every collection pass pulls the audio track off each new video (yt-dlp → ffmpeg → Gemini, same path as podcasts and FB/X video) and writes three columns: transcript (the text), transcript_language (he-IL), and transcribed_at. This is spoken audio, which is a different thing from description — the caption the author typed. For "what did this person actually say", search transcript; for "how did they frame the post", read description.
Transcription is gated on engagement — share_count×3 + comment_count×2 + like_count, the same weighting the warehouse uses to rank TikTok posts for subjects and narratives, with a default threshold of 250. Low-reach videos are deliberately left untranscribed. This matches how Facebook, X and Instagram already work.
transcript IS NULL therefore means one of three things, and they are worth telling apart: the video was below the engagement gate, or we tried and got nothing back (no speech in the clip), or it hasn't been attempted yet. transcribed_at is the discriminator — it is stamped on every finished attempt, successful or not, so transcribed_at IS NOT NULL AND transcript IS NULL is precisely "we tried, there was nothing to transcribe".
Historical coverage — collected before the gate existed, when everything was transcribed — was ~79% of videos with any text but only ~61% with 100+ characters, median ~430 chars. The gap skews by account type: politicians' own accounts ran 90–96%, short-form news desks much lower (mako.israel 61%, n12news 71%), because so much of their output is captioned b-roll over music with no speech in it. Expect lower volume and a similar hit rate going forward — the gate cuts spend, it does not improve the ratio, because engagement barely predicts whether a clip contains speech.
Use video_duration_ms, not video_duration. The old video_duration column mixed units by collector — milliseconds from ScrapeCreators, seconds from XPOZ and Apify (measured on the same videos: 100.75 where ScrapeCreators said 100750) — so any query over it was wrong for part of the table. video_duration_ms is milliseconds for every row, backfilled from the collector recorded on each row rather than guessed from magnitude. Divide by 1000 for seconds.
⚠️
video_durationis left in place, unit-mixed and no longer written, purely as a rollback path. Rows collected from 2026-08-20 have it NULL. Do not query it.
The same convention holds for fb_posts and twitter_tweets: transcribed_at records the attempt, transcript holds text or NULL. Both carried [empty]/[failed] marker strings for a few days from 2026-08-13; if you are querying historical rows and see those values, treat them as NULL.
| Table | Contents | Key columns |
|---|---|---|
fb_pages |
pages | username, name, category, followers_count, page_url |
fb_profiles |
personal profiles | username, name, bio, friends_count, location |
fb_posts |
posts (pages + profiles + groups) | id, source_type, username, text, permalink, created_at_date, reactions_total, comments_count, shares_count |
| Table | Contents | Key columns |
|---|---|---|
ig_users |
accounts | username, full_name, biography, follower_count, media_count |
ig_posts |
posts / reels | id, username, post_type, caption, like_count, comment_count, video_play_count, transcript, created_at_date |
✈️ Telegram
| Table | Contents | Key columns |
|---|---|---|
telegram_channels |
channels | username, title, description, member_count |
telegram_messages |
messages | id, channel_username, text, created_at_date, views, forwards, forwarded_from_channel, link_domain |
📺 TV (Channel 14) & 📻 Radio (Galei Yisrael)
| Table | Contents | Key columns |
|---|---|---|
tv_documents |
parsed broadcast transcripts | broadcast_date, doc_type, content_markdown, word_count, speakers |
tv_segments |
per-speaker transcript segments | file_id, speaker, text, timestamp_str |
gly_segments |
radio program segment headlines | program_name, headline, subtitle, broadcast_date, mp3_url |
gly_programs |
program registry | program_id, title, description_html |
🎙️ Podcasts
| Table | Contents | Key columns |
|---|---|---|
podcast_episodes |
episode registry (per show, from RSS) | episode_id, show_slug, title, audio_url, published_date, duration_seconds, transcript_status |
podcast_documents |
one parsed-episode document (mirrors tv_documents) |
episode_id, show_slug, published_date, content_markdown, word_count, speakers |
podcast_segments |
per-speaker transcript segments | podcast_segment_id, episode_id, show_slug, segment_index, speaker, text |
📰 Online papers & 📚 Knowledge Base
| Table | Contents | Key columns |
|---|---|---|
online_paper_articles |
news articles | outlet_slug, title, author, url, published_at, summary, content_text, ai_summary |
mainstream_articles |
events-pipeline's news source (separate taxonomy from online_paper_articles) |
outlet_slug, title, url, published_at, content_text, gdelt_gkg_id (GDELT's GKGRECORDID, when sourced via GDELT) |
kb_documents |
internal Drive documents | file_id, content_markdown, word_count, page_count |
🚨 Mashlat analytics (incident reports)
Mirrors Mashlat's read-only analytics.* Postgres views (a separate GCP project, mashlat-c2) for D-Day incident response data — signals from the field, triaged events, cases, and tasks, plus dimension lookups. signals/events/cases/tasks sync incrementally (hourly, via a last_modified watermark); the rest are small dimension/junction tables, fully re-extracted every run. reporter_name/reporter_phone (PII on mashlat_signals) are intentionally not mirrored.
| Table | Contents | Key columns |
|---|---|---|
mashlat_signals |
raw field reports (app, phone, WhatsApp, patrol, simulation) | id, external_id, received_at, source, status, severity_raw, body, region_id, event_id (deprecated — see below), location (GEOGRAPHY), last_modified |
mashlat_events |
triaged incidents | id, title, status, severity, category_id, region_id, case_id, due_at, resolved_at, location (GEOGRAPHY), last_modified |
mashlat_cases |
case files grouping related events | id, title, summary, status, severity, region_id, last_modified |
mashlat_tasks |
work items on an event | id, title, status, priority, event_id, assigned_team_id, due_at, completed_at, last_modified |
mashlat_regions |
geographic/org regions (dimension) | id, name, code |
mashlat_teams |
response teams (dimension) | id, name, type, region_id, phone |
mashlat_event_categories |
incident category lookup (dimension) | id, name, default_severity, sla_minutes |
mashlat_signals_files |
signal↔file attachment links (junction) | id, signal_id, file_id, sort |
mashlat_events_files |
event↔file attachment links (junction) | id, event_id, file_id, sort |
mashlat_signals_events |
signal↔event links, many-to-many (junction) | id, signal_id, event_id, sort |
locationcolumns areGEOGRAPHY(point), decoded server-side from the source's PostGIS geometry.As of 2026-08-11, a signal can link to more than one event (
signals.eventwas retired for a many-to-many relation).mashlat_signals.event_idstill exists but now only reflects the earliest-linked event, kept for backward compat — joinmashlat_signals_eventsfor the full picture.
📈 Analysis time-series
| Table | Contents | Key columns |
|---|---|---|
narratives |
frozen 2026-03 — now in PG | narrative_id, name_he, meta_narrative, escalation_stage, political_camp |
mention_daily |
daily narrative metrics | narrative_id, source_platform, mention_date, volume, engagement, poi_volume |
narrative_sources |
per-post narrative mentions | narrative_id, source_item_id, source_platform, mention_date, engagement, raw_engagement, is_poi |
subjects_daily |
daily subject metrics | subject_id, mention_date, source_platform, camp, volume, engagement, top_actors, top_posts |
subjects_sources |
per-post subject mentions | subject_id, source_platform, author_id, camp, mention_date, engagement |
emerging_terms |
trending terms | term, growth_pct, first_seen_date, run_date |
dashboard_summary |
per-run KPIs | run_date, total_posts_7d, active_narratives_count |
engagementvsraw_engagement(HML-155). Every per-item post query wraps its weighted-interaction formula inLN(1 + ...)— a log-compressed score meant only for ranking one post against another (it stops one viral post dominating a narrative's top-posts list).narrative_sources.engagementholds that ranking score;is_poiordering and top-posts selection should keep using it.narrative_sources.raw_engagementholds the true, unlogged interaction count and is whatmention_daily.engagementsums — summing the log-compressed score instead (as this table did before HML-155) makesmention_daily.engagementtrackmention_daily.volumealmost 1:1, which is what the weekly-brief chart bug looked like.tv/podcast/online_paperhave no native engagement signal, so theirraw_engagementis always0(theirengagementstays a constant ranking proxy, not a measurement).
🕵️ Pixel (disguised-venue detection)
Kept deliberately separate from fb_pages/fb_posts: these are investigation targets (suspected sleeper pages), not tracked POIs.
| Table | Contents | Key columns |
|---|---|---|
pixel_venues |
candidate venues + A1 provenance | handle, name, page_url, admin_country, former_names, runs_political_ads |
pixel_posts |
venue posts | post_id, venue_handle, text, reactions_total, is_political, stance_json |
pixel_venue_snapshots |
daily follower/like/post-count snapshots | handle, snapshot_date, followers_count, likes_count, post_count |
pixel_lean_daily |
daily pro-Likud lean per venue (flip-watch timeline) | handle, day, lean, political_posts |
pixel_flip_state |
latest flip verdict per venue (dedup for alerting) | handle, flipped, changepoint_day, lean_before, lean_after |
pixel_coordination |
cross-venue co-shared URL/media clusters | cluster_id, kind, venues, coordinated, span_minutes, evidence |
pixel_ig_profiles |
Instagram venue intake — profiles | username, full_name, biography, follower_count, fb_handle (linked FB venue, if known) |
pixel_ig_posts |
Instagram venue intake — posts | post_id, username, post_type, caption, like_count, comment_count, is_political, stance_json |
pixel_ig_comments |
comments on pixel_ig_posts (commenter-overlap analysis) |
comment_id, post_id, post_username, commenter_username, text, created_at_date |
pixel_ig_following |
who each suspect IG venue follows | username, following_username, following_full_name, is_verified |
pixel_ig_followers |
who follows each suspect IG venue (ranked, oldest first) | username, follower_username, rank, first_seen_at, last_seen_at |
🚨 D-Day Signs (סימנים מעידים)
The Sign detection pipeline's audit trail — see docs/dday_signs.md for what a "Sign" is and how a candidate becomes a detection. Sign definitions (dday_signs, dday_sign_versions) and finished events (dday_events, dday_event_detections) are in Postgres — these BQ tables are the append-only trail behind them.
| Table | Contents | Key columns |
|---|---|---|
dday_sign_candidates |
stage 01 output — every keyword hit nominated for judging | candidate_id, sign_id, sign_slug, platform, item_id, occurred_on, text, matched_keywords |
dday_sign_detections |
stage 02 output — every judged (Sign, item) pair, matched or not | detection_id, candidate_id, sign_id, matched, grounded, evidence_quote, confidence, severity (HML-222 phase 3 stopped writing the old urgency column; it is left in place holding history), answered |
dday_detection_locations |
place names extracted/geocoded from matched+grounded detections | location_id, content_hash, platform, item_id, place_text, lat, lng, country_code |
A candidate is nominated at roughly 50:1 versus detections that actually match — most of
dday_sign_candidatesis the judge saying no. A row'smatched AND groundedis what makes it a real detection; see the file above for why an ungrounded quote is demoted rather than dropped.Ignore operational tables ending in
_etl_runs,_run_errors,_fetches, and_staging— they are internal ETL bookkeeping, not analysis data.
Full BigQuery table inventory
The sections above are the analyst-facing curated view. For completeness, this is every table any backend code defines or writes to in intel-487218.datawarehouse, grouped by the source file that owns its schema. 🗃️ = content/analysis table (covered above); ⚙️ = operational/internal (audit, delta-tracking, cache) — safe to ignore for analysis.
| Table | Owning file | Kind |
|---|---|---|
twitter_users |
xpoz_etl_bigquery.py / xpoz_etl.py |
🗃️ |
twitter_tweets |
xpoz_etl_bigquery.py / xpoz_etl.py |
🗃️ |
twitter_followers |
xpoz_etl_bigquery.py / xpoz_etl.py |
🗃️ |
twitter_user_geocodes |
xpoz_etl.py |
🗃️ |
tweet_context_cache |
xpoz_etl.py |
⚙️ |
twitter_tweets_fetches |
xpoz_etl.py |
⚙️ |
twitter_tweet_interactions_fetches |
xpoz_etl.py |
⚙️ |
twitter_followers_fetches |
xpoz_etl.py |
⚙️ |
twitter_following_fetches |
xpoz_etl.py |
⚙️ |
etl_runs, etl_run_errors |
xpoz_etl.py (shared Twitter/IG audit) |
⚙️ |
ig_users, ig_posts |
ig_etl_bigquery.py |
🗃️ |
ig_posts_fetches |
ig_etl_bigquery.py |
⚙️ |
ig_etl_runs, ig_etl_run_errors |
ig_etl_bigquery.py |
⚙️ |
ig_enrichment_thresholds |
ig_etl_bigquery.py |
⚙️ |
tiktok_users, tiktok_posts |
tiktok_etl_bigquery.py |
🗃️ |
tiktok_posts_fetches |
tiktok_etl_bigquery.py |
⚙️ |
tiktok_etl_runs, tiktok_etl_run_errors |
tiktok_etl_bigquery.py |
⚙️ |
fb_pages, fb_profiles, fb_posts |
fb_etl_bigquery.py |
🗃️ |
fb_posts_fetches, fb_entities_fetches |
fb_etl_bigquery.py |
⚙️ |
fb_etl_runs, fb_etl_run_errors |
fb_etl_bigquery.py |
⚙️ |
telegram_channels, telegram_messages |
telegram_etl_bigquery.py |
🗃️ |
telegram_messages_fetches |
telegram_etl_bigquery.py |
⚙️ |
telegram_etl_runs, telegram_etl_run_errors |
telegram_etl_bigquery.py |
⚙️ |
tv_documents, tv_segments |
tv_etl_bigquery.py |
🗃️ |
tv_files, tv_file_scans |
tv_etl_bigquery.py |
⚙️ |
tv_etl_runs, tv_etl_run_errors |
tv_etl_bigquery.py |
⚙️ |
gly_segments, gly_programs |
gly_etl_bigquery.py |
🗃️ |
gly_etl_runs |
gly_etl_bigquery.py |
⚙️ |
online_paper_articles |
online_paper_etl_bigquery.py |
🗃️ |
online_paper_etl_runs, online_paper_etl_run_errors |
online_paper_etl_bigquery.py |
⚙️ |
mainstream_articles |
mainstream_papers_etl_bigquery.py (events-pipeline's news source — separate taxonomy from online_paper_articles) |
🗃️ |
mainstream_etl_runs, mainstream_etl_run_errors |
mainstream_papers_etl_bigquery.py |
⚙️ |
podcast_episodes, podcast_documents, podcast_segments |
podcast_etl_bigquery.py |
🗃️ |
podcast_etl_runs, podcast_etl_run_errors, podcast_feed_scans |
podcast_etl_bigquery.py |
⚙️ |
kb_documents |
kb_etl_bigquery.py |
🗃️ |
kb_files, kb_file_scans |
kb_etl_bigquery.py |
⚙️ |
kb_etl_runs, kb_etl_run_errors |
kb_etl_bigquery.py |
⚙️ |
mashlat_signals, mashlat_events, mashlat_cases, mashlat_tasks, mashlat_regions, mashlat_teams, mashlat_event_categories, mashlat_signals_files, mashlat_events_files, mashlat_signals_events |
mashlat_etl_bigquery.py |
🗃️ |
mashlat_etl_runs, mashlat_etl_run_errors |
mashlat_etl_bigquery.py |
⚙️ |
pixel_venues, pixel_posts, pixel_venue_snapshots, pixel_lean_daily, pixel_flip_state, pixel_coordination |
pixel_etl_bigquery.py |
🗃️ |
pixel_ig_profiles, pixel_ig_posts, pixel_ig_comments, pixel_ig_following, pixel_ig_followers |
pixel_etl_bigquery.py (Instagram intake, added alongside the FB-only Pixel tables above) |
🗃️ |
pixel_ig_posts_fetches |
pixel_etl_bigquery.py |
⚙️ |
mention_daily, narrative_sources, emerging_terms, dashboard_summary |
analysis_narratives_bigquery.py |
🗃️ |
camp_hourly_cdf |
analysis_narratives_bigquery.py (posting-time model for the events amplification baseline) |
🗃️ |
tweet_media_enrichment |
analysis_narratives_bigquery.py (OCR/vision cache for tweet images) |
⚙️ |
post_entities |
analysis_narratives_bigquery.py (per-post NER cache for the entity-mentions report) |
⚙️ |
entity_mention_daily |
analysis_narratives_bigquery.py (per-audience daily entity-mention counts) |
🗃️ |
analysis_narratives_runs, analysis_narratives_run_errors |
analysis_narratives_bigquery.py |
⚙️ |
subjects_sources, subjects_daily |
analysis_subjects_bigquery.py |
🗃️ |
analysis_subjects_runs, analysis_subjects_run_errors |
analysis_subjects_bigquery.py |
⚙️ |
brief_correlation, post_correlation |
report_correlation.py (cosine-similarity scores linking messaging briefs/items to publisher posts) |
🗃️ |
narrative_centroids, subject_centroids |
embedding_utils.py (embedding-centroid cache used by the events pipeline to correlate articles to subjects/narratives) |
⚙️ |
dday_sign_candidates |
dday_candidates_bigquery.py |
🗃️ |
dday_sign_detections |
dday_detections_bigquery.py |
🗃️ |
dday_detection_locations |
dday_detection_locations.py |
🗃️ |
x_activity_stream_connections |
x_activity_stream_bigquery.py (XAA reader connection health; workers MERGE posts into twitter_tweets, flag off) |
⚙️ |
Query these through the app / API rather than direct SQL. Grouped by role (representative, not exhaustive):
| Group | Tables |
|---|---|
| Catalogs & definitions | pois, narratives, meta_narratives, narrative_groups, subjects, subject_narratives, keyword_monitors, keyword_narratives, domain_knowledge, message_narrative_catalog |
| Analysis results | events, event_sources, event_feedback, post_alignment, weekly_briefs, daily_brief, channel_metrics_daily, kpi_daily_snapshots, socialagents_kpi_snapshot (external webhook push, not a scheduled ETL — POST /webhooks/socialagents), network_analysis_results, network_cluster_summaries |
| D-Day Signs | dday_signs, dday_sign_versions, dday_sign_stat (Sign definitions + calibration), dday_events, dday_event_detections, dday_event_places, dday_event_lineage (triage queue at /dday/triage) |
| Messaging & audiences | audiences, audience_tags, audience_reports, message_brief, message_narrative, message_subtitle, message_item, messaging_item, messaging_item_metric, publisher, publisher_user, publisher_channel, intelligence_report |
| Product surfaces | cc_sessions, cc_messages, cc_config, situation_rooms, situation_room_stories, recommended_items, item_distributions |
| Auth & ops | users, roles, permissions, role_permissions, user_roles, oauth_clients, oauth_codes, oauth_refresh_tokens, api_tokens, operation_costs, pipeline_runs |
Accessing the data
BigQuery (analysts)
Your identity is your @hamal-elections.co.il Google account. To query, you need these IAM roles on project intel-487218:
| Role | Why |
|---|---|
roles/bigquery.dataViewer |
read the tables |
roles/bigquery.jobUser |
run queries |
roles/bigquery.studioUser + roles/bigquery.readSessionUser |
(optional) BigQuery Studio / notebooks |
Ask one of the project developers to grant these — see For admins.
Three ways to query:
- BigQuery Studio (no install): open https://console.cloud.google.com/bigquery?project=intel-487218, expand
intel-487218 → datawarehouse, write SQL, Run. bqCLI:bash gcloud auth login gcloud config set project intel-487218 bq query --use_legacy_sql=false \ 'SELECT username, followers_count FROM `intel-487218.datawarehouse.twitter_users` ORDER BY followers_count DESC LIMIT 10'- Python: ```python from google.cloud import bigquery
client = bigquery.Client(project="intel-487218")
for r in client.query(
"SELECT username, followers_count "
"FROM intel-487218.datawarehouse.twitter_users "
"ORDER BY followers_count DESC LIMIT 10"
).result():
print(r.username, r.followers_count)
```
PostgreSQL
The Postgres DB is the application's operational store. For almost everyone, the right way to read it is through the product:
- The app UI at hml.st (narratives, subjects, events, briefs, sources).
- The read API under
/api/v1/...and the Intel MCP server atapi.hml.st/mcp(for AI clients).
Direct SQL access to Postgres is reserved for engineers via the Render dashboard.
Example queries (BigQuery)
Top tweets by likes in the last 7 days (filter on the partition column to keep it cheap):
SELECT author_username, text, like_count, retweet_count
FROM `intel-487218.datawarehouse.twitter_tweets`
WHERE created_at_date >= DATE_SUB(CURRENT_DATE(), INTERVAL 7 DAY)
ORDER BY like_count DESC
LIMIT 10;
Daily mention volume of a narrative:
SELECT mention_date, SUM(volume) AS volume, SUM(engagement) AS engagement
FROM `intel-487218.datawarehouse.mention_daily`
WHERE narrative_id = 'NARRATIVE_ID_HERE'
GROUP BY mention_date
ORDER BY mention_date;
Search Telegram messages for a term:
SELECT channel_username, created_at_date, views, text
FROM `intel-487218.datawarehouse.telegram_messages`
WHERE text LIKE '%TERM%'
AND created_at_date >= DATE_SUB(CURRENT_DATE(), INTERVAL 30 DAY)
ORDER BY views DESC
LIMIT 50;
Top subjects on TikTok yesterday:
SELECT subject_id, SUM(volume) AS volume
FROM `intel-487218.datawarehouse.subjects_daily`
WHERE mention_date = DATE_SUB(CURRENT_DATE(), INTERVAL 1 DAY)
AND source_platform = 'tiktok'
GROUP BY subject_id
ORDER BY volume DESC
LIMIT 20;
Things worth knowing (gotchas)
- Filter by date to save money.
twitter_tweetsand the post tables are partitioned bycreated_at_date. Always addWHERE created_at_date >= ...— otherwise the query scans the whole table and costs more. - Skip operational tables (
_etl_runs,_run_errors,_fetches,_staging). camp/political_campmarks the political camp — useful for slicing subjects/narratives.- Hebrew text: use
LIKE '%...%'for exact matching, orREGEXP_CONTAINSfor smarter matching. - Data is not real-time. Most sources refresh daily (Twitter ~12:00 IL, papers every 2h). Check freshness via the
_etl_runstables or/admin/jobs. twitter_tweets.reply_settingsisNULLon most rows. It's only populated for the small official-X-API subset; XPOZ (the source of the bulk oftwitter_tweets) doesn't expose who-can-reply data at all — confirmed by requesting it directly against XPOZ's API and observing it's silently omitted from the response.mashlat_signalsexcludes PII. Reporter name/phone from the source system are intentionally not mirrored into BigQuery.- The catalog can drift. This page is regenerated from the codebase by the
/update-datadocsmaintenance command — if a table here looks wrong, re-run it (see below).
For admins: granting BigQuery access
Give a new analyst read-only access with:
gcloud projects add-iam-policy-binding intel-487218 \
--member="user:NEW_ANALYST@hamal-elections.co.il" \
--role="roles/bigquery.dataViewer" --condition=None
gcloud projects add-iam-policy-binding intel-487218 \
--member="user:NEW_ANALYST@hamal-elections.co.il" \
--role="roles/bigquery.jobUser" --condition=None
# optional — BigQuery Studio / notebooks
gcloud projects add-iam-policy-binding intel-487218 \
--member="user:NEW_ANALYST@hamal-elections.co.il" \
--role="roles/bigquery.studioUser" --condition=None
gcloud projects add-iam-policy-binding intel-487218 \
--member="user:NEW_ANALYST@hamal-elections.co.il" \
--role="roles/bigquery.readSessionUser" --condition=None
Developers get a broader custom role (
intelDev, includes write) viamake gcloud-grant-dev-access. Do not give that to analysts — read-only is enough.
How this page is built and published
docs/data/render.pyrenders this Markdown into a styled, self-containeddocs/data/site/index.html(table of contents, dark/light, copy-to-clipboard on code)..github/workflows/publish-datadocs.ymlruns the renderer and deploys to Cloudflare Pages on every push tomaintouchingdocs/data/**.- Authentication is enforced by Cloudflare Access — only
@hamal-elections.co.ilGoogle logins reachdatadocs.hml.st.
Preview locally:
uv run --with markdown python docs/data/render.py
open docs/data/site/index.html
Keep it current: run the /update-datadocs command — it re-scans the codebase for schema, source, and schedule changes and updates this file. One-time CI/Access setup is documented in docs/data/README.md.