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.
Pilot, flag off: 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 ConfigCatxActivityStream(default off; missing SDK / outage / missing flag => off) — do not setX_ACTIVITY_STREAM_ENABLED=true. Allowlist is POIs taggedcamp:redorcol:x-api(union) with atwitter_id. Paid polling viapois.x_api_since_idstill runs and stayscol:x-apionly. Connection health is tracked inx_activity_stream_connections. See 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.