Personal AI Research

OSINT Real-Time Tracker: Multilingual Conflict Monitoring, Built Solo with Gemini

A maps-based dashboard that turns open-source conflict reports into structured, geolocated, credibility-scored events. The interesting part wasn't getting Gemini to return clean JSON — it was everything built around it to make that output something you could actually trust.

July – October 2026 · Live at osint.davidirving.dev

What it does

The tracker ingests open-source reports — social posts, wire dispatches, radio transcripts — in whatever language they were written, and turns them into structured, geolocated, credibility-scored events on a live map. Built solo in Google AI Studio, React and Express on the frontend and backend, Gemini for the actual analysis, Firestore for persistence, deployed to Cloud Run.

One Gemini call, five structured outputs

The core route takes raw source text and makes a single Gemini call with a structured responseSchema — no separate translation step, no pipeline of smaller calls. The prompt is explicit about this: translate to English if needed, identify the source language, then extract title, summary, category, severity, geolocation, sentiment, and a five-parameter credibility grade, all in one response.

Geolocation resolution is the same call, not a separate one: the prompt instructs Gemini to normalize transliterations — Kyiv versus Kiev, Zaporizhzhia versus Zaporozhye — while preserving the original-script place name, and requires a written explanation of how any spelling ambiguity was resolved. That explanation is rendered verbatim in the app's location panel, not summarized or hidden — a real pass-through of the model's own reasoning, which is what "not a black box" actually has to mean if it's going to mean anything.

Sentiment is the most elaborate part of the schema: a continuous score, a 1–10 tension rating, a dominant emotion plus secondary tones, a six-way nuance classification, and a four-axis valence breakdown across hostility, fear, defiance, and calm. The prompt explicitly asks for "a detailed, dispassionate computational linguistics narrative" explaining which grammatical markers, idioms, or regional slang in the original language carry that emotion — which is exactly the native-language-nuance claim, and it's a real, required field in the schema, not marketing copy describing something the model happens to do sometimes.

Credibility is a fixed rubric, not a vibe: source reputation, cross-referencing, evidence level, contextual plausibility, and freshness, each worth up to twenty points with its own required justification string. The total isn't something Gemini asserts directly — the server sums the five sub-scores itself, so the headline number is always traceable back to five specific, visible judgments underneath it.

Raw source text any language One Gemini call structured responseSchema Title & Summary Geolocation Sentiment Credibility Category & Severity

One call, one schema, five structured judgments back — not five separate round-trips.

A scheduled harvester, and the distributed-systems bug it needed

A background scheduler runs the same pipeline unattended, aimed at named authoritative sources across six sectors — Ukraine frontline reporting, Red Sea maritime alerts, Sudan and the Sahel, East Asia, the Caucasus, and cyber incidents — using Gemini's search grounding rather than an open-ended crawl, with every harvest and its full audit trail written to Firestore.

Running on Cloud Run means the app can have more than one instance alive at once, each running its own independent copy of that scheduler. Without a lock, every instance's "has an hour passed since the last harvest?" check can pass at the same moment, since the state only gets saved after a harvest finishes — which means multiple instances can all decide to run a full, quota-burning harvest simultaneously. The fix is a Firestore transaction that atomically checks and claims the harvest slot before the slow Gemini call even starts, so only one instance ever wins the race. It's a small piece of code, and it's the kind of bug that's invisible in local development and only exists because of how the actual deployment target behaves.

No lock — every instance checks at once Instance A check passes → harvests Instance B check passes → harvests Instance C check passes → harvests Firestore transaction claims the slot first Instance A claims slot → harvests Instance B slot taken → skips Instance C slot taken → skips

The check itself was never the problem — it was that nothing stopped every instance from passing it at the same moment.

A real security bug, found before it became an incident

The app's AI-Studio-generated starting point had Firestore rules wide open — allow read, write: if true — with the React frontend writing directly to Firestore for core functionality. That meant anyone with the Firebase client config, which is necessarily public in any browser-based app, could read or write data directly, bypassing the backend and its logic entirely. One endpoint made this worse in a specific way: the reset function didn't touch Firestore from the server at all — the client was doing the actual purge-and-reseed, with no authentication check anywhere in that path.

The fix moved the backend onto the Firebase Admin SDK — real service credentials that don't depend on client-side rules at all — locked the Firestore rules to block direct client writes on every sensitive collection, and gated the reset and system-log endpoints behind real Firebase ID-token verification. It was found by recognizing the same class of bug in a sibling project and checking for it here deliberately, rather than from an incident or an external report — the more useful way to find this kind of thing, if you can manage it.

What's public, what needs an account, and what happens when the model is down

Since October the default page is a public live map that needs no account. It shows each event's title, place, time, category, severity and source link, with filters for time, region and category. The detail behind each event, the summary, the five-part credibility score, the sentiment analysis and the satellite check, is for registered users, who can switch to the full analysis view. Submitting text for analysis and starting a harvest also need an account, because each one is a paid model call.

If Gemini is unavailable, the app shows the data it already has and logs the real reason. It does not generate anything to fill the gap. That was not always true, which is what the next section is about.

The public live map on a desktop: a feed of recent events on the left with time, region and category filters, a map of Sudan and the Red Sea with event markers, and an event card asking visitors to log in for the full analysis. The public live map on a phone: the map with event markers on top, filters and the event feed below.

The public live map, on desktop and phone. Anyone can browse it; the analysis behind each event needs an account.

The week I found out it had never harvested anything

In early August I found that the production API key had been the literal text "Not found." since the first deploy. The app looked healthy the whole time, because a fallback path quietly filled the feed with invented events: a made-up social-media post, made-up credibility scores, dated to today. It was tagged as seed data, but the content was never tied to a real incident. I had described the app as harvesting real data. That was wrong.

The fix was to remove the invented data entirely, not to label it better, and then to make every event prove where it came from. Each harvest now pulls real candidate articles from GDELT and ReliefWeb first, and an event is kept only if its source URL matches one of those articles or one of Gemini's own search citations. Anything else is dropped, not stored. A fuzzy duplicate check catches the same event reported with different wording, which exact-title matching never did.

The gate then rejected every event for four days. The cause: Gemini drops its search citations entirely when a request combines the search tool with structured JSON output, so the gate had nothing to match against. Search and extraction are now two separate calls, and extraction may only use sources the search call actually returned. Two more quiet failures turned up along the way: a free news source throttling harder than documented, and, in September, scale-to-zero hosting killing the hourly timer between requests.

October: closing the data, paying for it, and links that last

Building the public map showed that the detail I described as gated had never been gated. The database rules let anyone read every event, and the API returned full records without a login. Both now need a registered account. The public page reads only a fixed list of fields, so a field added later stays private until it is added to that list on purpose.

The same week, the prepaid Gemini credit ran out and the feed stopped. The app now records the cost of every model call and spreads a monthly cap evenly across the month, refusing calls once the day's share is spent. Harvests run every three hours, started by an external scheduler, because a timer inside the app stalled whenever the host throttled its CPU between requests.

Most event sources turned out to be Google search redirect links, which expire within days. The harvester now stores the publisher's own address instead, and 502 of the 541 stored links could still be repaired. The other 39 had already expired.

What actually mattered

Getting a model to return clean structured JSON isn't the hard part anymore — modern models do that reliably, most of the time. What actually took the work was everything built around that call: retry logic with exponential backoff and a model-fallback cascade for when Gemini itself is flaky or rate-limited, a distributed lock so an autoscaled deployment doesn't quietly burn its own quota racing itself, and treating "the AI-Studio default has wide-open database rules" as a real problem to go fix, rather than something to leave alone because it's a side project and nobody's watching.

None of that shows up in a screenshot of the map view. It's the difference between a demo that happens to work and a system that keeps working once it's actually running unattended.

Personal AI Research Gemini API NLP / Geolocation Application Security