Processing your Factory 2026 experience, then learning how to architect production-quality AI-powered Earth Observation applications.
Take 2 minutes to think quietly. Then share one thing with the person next to you.
Regardless of industry, investors and judges tend to ask the same core questions. Did you hear any of these?
After receiving feedback, every team faces a decision: do we change course, or push forward? Both can be correct. The key is knowing which signals warrant a pivot.
Every AI-powered Earth Observation application can be decomposed into three conceptual layers. This is a simplified form of layered architecture[1], adapted for the specific needs of geospatial AI applications. Research on engineering AI-based systems confirms that separating data, model, and interface concerns is critical for maintainability[7].
The Model-View-Controller (MVC) pattern, first conceived by Trygve Reenskaug at Xerox PARC in 1979[2] and later formalized for Smalltalk-80 by Krasner and Pope[3], maps naturally to AI-EO applications. Here is how the classic triad translates:
Instead of writing one massive script, break your application into small, focused modules. Each component has a single responsibility and communicates through well-defined interfaces[1]. A multivocal literature review identified over 70 design patterns for AI-based systems, many of which emphasize modular decomposition[10].
Each file handles one concern. You can work on the chat UI without touching the map code.
Your AI service wrapper works in any project that calls the same API. Write it once, use it everywhere.
You can test the AI service by mocking API responses, without needing a running map.
Two teammates can work on ai-service.js and map-service.js simultaneously without merge conflicts.
app.js file that handles everything is the number one cause of project chaos in student teams. Avoid it.
Here is a recommended file structure for your AI-EO project. You do not need a build system or framework; plain HTML, CSS, and JS with ES modules works perfectly.
my-eo-app/ ├── index.html ← entry point, loads all scripts ├── css/ │ ├── style.css ← global layout, themes, typography │ └── components.css ← chat bubbles, panels, controls ├── js/ │ ├── app.js ← main controller, bootstraps everything │ ├── ai-service.js ← AI API wrapper (Gemini/Groq) │ ├── map-service.js ← Leaflet map init, layers, drawing │ ├── ui-controller.js ← DOM updates, chat rendering │ └── config.js ← API keys, endpoints, settings ├── assets/ │ └── images/ ← logos, icons, screenshots └── data/ └── sample-data.geojson ← test datasets for development
Separation of concerns (SoC) means that each module in your application should address a single, well-defined aspect of the system[1]. For AI-EO apps, this principle is especially important because of the number of moving parts. Sculley et al. warn that ML systems easily accumulate "hidden technical debt" when model code, data pipelines, and UI logic are entangled[8].
// Everything mixed together async function handleChat(msg) { const resp = await fetch('gemini-api...'); const geojson = parseGeoJSON(resp); L.geoJSON(geojson).addTo(map); document.getElementById('chat') .innerHTML += `<div>...</div>`; }
// Each concern in its own module const resp = await aiService.chat(msg); const actions = aiService.parse(resp); if (actions.addLayer) mapService.addGeoJSON(actions.data); uiController.renderReply(resp.text);
ai-service.js. The map and UI code remain untouched.
ai-service.js)?State is the complete set of data that describes what your application is "doing" at any given moment. If you could freeze your app, the state would tell you everything needed to resume it exactly as it was.
The conversation history is the most critical piece of state in an AI-EO app. It provides the context window that allows the AI to give coherent, multi-turn responses[4].
{ role: "user", content: "..." } to the history array{ role: "model", content: "..." } to the history// Building context-aware conversation const history = [ { role: "user", content: "What satellite data covers France?" }, { role: "model", content: "Sentinel-2 provides 10m resolution..." }, { role: "user", content: "Show me the latest image for Strasbourg" } // The AI now knows "Strasbourg" is in France // and "image" refers to satellite imagery ];
When you enable Function Calling (Tools), Gemini does not just respond with text. Sometimes it returns a text explanation, one or more function calls, or both at once. A naive implementation that checks only if (text) ... else if (functionCall) will fail to show the text to the user, or fail to execute the tool.
To support rich, context-aware conversations, your controller needs an orchestration loop that checks and processes each part of the model's response:
functionCall, execute it, append the result to the history, and call Gemini again so it can explain what it did.
async function sendMessage(userText) { // 1. Push user message to history history.push({ role: "user", parts: [{ text: userText }] }); // 2. Call Gemini API let response = await callGeminiAPI(history); let parts = response.candidates[0].content.parts; history.push({ role: "model", parts: parts }); // 3. Multi-part iteration loop for (const part of parts) { if (part.text) { appendChatBubble("assistant", part.text); } if (part.functionCall) { // Execute local Leaflet JS action let res = await runLocalTool(part.functionCall); // Send tool results back to Gemini let toolPart = { functionResponse: { name: part.functionCall.name, response: { result: res } } }; history.push({ role: "user", parts: [toolPart] }); let secondRes = await callGeminiAPI(history); let secondParts = secondRes.candidates[0].content.parts; history.push({ role: "model", parts: secondParts }); // Parse model explanation for (const p of secondParts) { if (p.text) appendChatBubble("assistant", p.text); } } } }
Your Leaflet map has its own state: center, zoom level, active layers, drawn features, and basemap. This state needs to be synchronized with the AI so the model can reference "the current view."
map.getBounds()
// Capturing map state function getMapState() { return { center: map.getCenter(), zoom: map.getZoom(), bounds: map.getBounds(), activeLayers: [ ...this.mapLayers.keys() ] }; } // Pass to AI for spatial context const context = `User is viewing: ${state.center}, zoom ${state.zoom}`;
You do not need React, Vue, or any framework. A simple JavaScript class can manage all your application state cleanly. Here is a complete, production-ready pattern:
class AppState { constructor() { this.conversationHistory = []; this.mapLayers = new Map(); this.currentView = { center: [48.57, 7.75], zoom: 12 }; this.userData = {}; } // Add a message to conversation history addMessage(role, content) { this.conversationHistory.push({ role, content, timestamp: Date.now() }); } // Format history for Gemini API getConversationForAPI() { return this.conversationHistory.map(m => ({ role: m.role, parts: [{ text: m.content }] })); } // Save to localStorage for persistence save() { localStorage.setItem('appState', JSON.stringify({ conversationHistory: this.conversationHistory, currentView: this.currentView, userData: this.userData })); } // Restore from localStorage static load() { const saved = localStorage.getItem('appState'); if (!saved) return new AppState(); const state = new AppState(); Object.assign(state, JSON.parse(saved)); return state; } }
const state = AppState.load();
Every API has rate limits. The Gemini free tier typically allows between 5 and 30 requests per minute (RPM), depending on the model, plus daily caps of 100 to 1,500 requests per day (RPD). These limits change frequently, so always check the current quotas in Google AI Studio. If your user sends rapid messages, you will hit a 429 Too Many Requests error. The solution: exponential backoff[5].
async function fetchWithRetry(url, options, maxRetries = 3) { for (let i = 0; i <= maxRetries; i++) { try { const resp = await fetch(url, options); if (resp.status === 429) { const delay = Math.pow(2, i) * 1000; console.warn(`Rate limited. Retry in ${delay}ms`); await new Promise(r => setTimeout(r, delay)); continue; } return await resp.json(); } catch (err) { if (i === maxRetries) throw err; } } }
+ Math.random() * 500) to the delay to prevent "thundering herd" issues when multiple clients retry simultaneously.
If a user asks the same question twice, do not waste an API call. Use a simple in-memory cache or localStorage.
const cache = new Map(); async function cachedQuery(prompt) { const key = prompt.trim().toLowerCase(); if (cache.has(key)) return cache.get(key); const result = await queryAI(prompt); cache.set(key, result); return result; }
Never show raw error messages. Instead, display friendly, actionable feedback:
Error: 429 Too Many Requests at fetch.js:42
"I'm thinking extra hard about this one! Please wait a moment and I'll try again." (auto-retry in background)
"The satellite data service is temporarily unavailable. Here's what I know from cached data..." (fallback to localStorage)
Combine all the patterns into a single, reusable service class. This is the ai-service.js module your team can rely on throughout the project.
class AIService { constructor(apiKey, model = 'gemini-2.5-flash') { this.apiKey = apiKey; this.model = model; this.baseUrl = `https://generativelanguage.googleapis.com/v1beta/models/${model}`; this.cache = new Map(); } async chat(history, systemPrompt, tools = []) { const body = { system_instruction: { parts: [{ text: systemPrompt }] }, contents: history, ...(tools.length && { tools: [{ function_declarations: tools }] }) }; try { const data = await this.fetchWithRetry( `${this.baseUrl}:generateContent?key=${this.apiKey}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) } ); return this.parseResponse(data); } catch (err) { return { text: 'Sorry, I had trouble processing that. Please try again.', error: true }; } } parseResponse(data) { const part = data.candidates?.[0]?.content?.parts?.[0]; if (part?.functionCall) return { type: 'function', ...part.functionCall }; return { type: 'text', text: part?.text || 'No response' }; } async fetchWithRetry(url, opts, retries = 3) { for (let i = 0; i <= retries; i++) { const r = await fetch(url, opts); if (r.ok) return await r.json(); if (r.status === 429 && i < retries) await new Promise(r => setTimeout(r, 2 ** i * 1000)); else throw new Error(`API error: ${r.status}`); } } }
Developing APIs for production environments involves dealing with complex scenarios. Recent discussions from the Terra project highlight several crucial lessons for deploying and testing your API architectures.
Moving your code from development to a live server should not be a manual process. The Terra team emphasizes deploying docs on command and utilizing automatic deployments coupled with security constraints.
This includes running code quality scans before any pipeline moves code into the production environment.
When working with a component-based architecture, you must maintain a clear registry of where all your components live and ensure endpoints are available to serve them. Splitting components into different folders requires careful path management.
When downloading packages or triggering automated workflows, security constraints often block execution. You must be prepared to adjust workflow permissions (e.g. making certain workflows public temporarily) or properly authenticate package downloads to prevent failed pipeline builds.
Your API must pass through rigorous testing pipelines. This involves resolving code quality findings, anticipating expected results from data sampling, and making sure that security scans are resolved.
You have 5 working days to go from architecture to a working demo. Here is a realistic, structured plan:
| Day | Focus | Deliverable |
|---|---|---|
| Day 5 (Today) | Architecture + Setup | File structure created, config ready, base HTML/CSS layout |
| Day 6 | Core Feature | Map working, AI chat connected, one key feature functional |
| Day 7 | Data Integration | Real satellite data flowing, function calling working |
| Day 8 | Polish + Secondary Features | Error handling, loading states, UI refinement |
| Day 9 | Demo Prep | Rehearsed demo, README, edge cases handled |
A user story is a simple statement that captures a feature from the user's perspective. It follows the format:
The ONE feature that makes your project compelling. Build this first and make it bulletproof.
Additional features that enhance the demo. Only build these if the core feature is solid.
Software Engineer
She led the MIT team that developed the onboard flight software for the Apollo space program, coining the term 'software engineering'.
Earth Observation provides a macroscopic view of environmental trends, but its true power lies in downscaling this data to affect local policy and design, such as urban planning and sustainable workplaces.
As you build your architecture, you must consider the human element. How does your software enable meaningful connection in a remote or hybrid setting?
What was your biggest takeaway from this session, and how does it apply to the TERRA project? Write your response below. Your instructor will review this to track your progress.