From b472e21acf72e8aacc4c8cd7609a35e2ab4d73b3 Mon Sep 17 00:00:00 2001 From: Chris Durbin Date: Tue, 31 Dec 2024 13:04:48 -0500 Subject: [PATCH] HARMONY-1876: Submit a harmony request from the AI search service, poll the job status until the request completes, and then load the resulting image in the browser. --- .../harmony/app/frontends/free-text-query.ts | 62 ++++++++- .../views/free-text-query/index.mustache.html | 129 ++++++++++++------ 2 files changed, 147 insertions(+), 44 deletions(-) diff --git a/services/harmony/app/frontends/free-text-query.ts b/services/harmony/app/frontends/free-text-query.ts index 9e1eae088..62c47365d 100644 --- a/services/harmony/app/frontends/free-text-query.ts +++ b/services/harmony/app/frontends/free-text-query.ts @@ -12,6 +12,7 @@ import HarmonyRequest from '../models/harmony-request'; import { parseNumber } from '../util/parameter-parsing-helpers'; import knexfile from '../../../../db/knexfile'; import { knex } from 'knex'; +import * as querystring from 'querystring'; /** * get GeoJSON for a given place @@ -65,6 +66,7 @@ interface GeneratedHarmonyRequestParameters { timeInterval: string | null; outputFormat: string | null; geoJson: string | null; + statusUrl: string; } const distanceUnits = { @@ -128,6 +130,51 @@ async function getEmbedding(input: string): Promise { return responseBody.embedding; } + +/** + * Create a token header for the given access token string + * + * @param token - The access token for the user + * @returns An object with an 'Authorization' key and 'Bearer token' as the value, + * or an empty object if the token is not set + */ +function _makeTokenHeader(token: string): object { + return { Authorization: `Bearer ${token}` }; +} + +interface HarmonyJobStatus { + links: Array<{ + href: string; + [key: string]: any; // allows additional fields in each link object + }>; + [key: string]: any; // allows additional top-level fields in the status object +} + +/** + * TODO + */ +async function submitHarmonyRequest(collection, variable, queryParams, token): Promise { + queryParams.forceAsync = true; + queryParams.maxResults = 1; + const encodedVariable = encodeURIComponent(variable); + const baseUrl = `http://localhost:3000/${collection}/ogc-api-coverages/1.0.0/collections/${encodedVariable}/coverage/rangeset`; + const querystr = querystring.stringify(queryParams); + const headers = { + ..._makeTokenHeader(token), + }; + + console.log(`Making request to ${baseUrl}?${querystr}`); + const response = await fetch(`${baseUrl}?${querystr}`, + { + method: 'GET', + headers, + }); + const data = await response.json(); + console.log(JSON.stringify(data)); + // return await response.json(); + return data; +} + /** * Endpoint to make requests using free text * @@ -198,7 +245,6 @@ export async function freeTextQueryPost( const sql = `SELECT collection_id, collection_name, variable_id, variable_name, variable_definition, 1 - (embedding <=> '[${embedding}]') AS similarity FROM umm_embeddings ORDER BY embedding <=> '[${embedding}]' LIMIT 50;`; // const sql = `SELECT collection_id, variable_id, (embedding <-> '[${embedding}]') AS similarity FROM umm_embeddings ORDER BY embedding <-> '[${embedding}]' DESC LIMIT 5;`; - const db = knex(knexfile); const dbResult = await db.raw(sql); @@ -209,10 +255,11 @@ export async function freeTextQueryPost( } const conceptIds = dbResult.rows.map(a => a.collection_id); + const conceptIdsSet = new Set(conceptIds); const temporalParam = modelOutput.timeInterval?.replace(/\+00:00/g, '').replace('/', ','); const collQuery: CmrQuery = { - concept_id: conceptIds, + concept_id: Array.from(conceptIdsSet) as unknown as any, geojson: url, page_size: 100, temporal: temporalParam, @@ -223,6 +270,7 @@ export async function freeTextQueryPost( const collsWithGranules = await queryCollectionUsingMultipartForm({}, collQuery, req.accessToken); // list of collection concept ids that has granule found with the spatial and temporal search + collsWithGranules.collections.map(c => console.log(`Collection ${c.id} has ${c.granule_count} granules.`)); const collConceptIds = collsWithGranules.collections.filter(c => c.granule_count > 0).map(c => c.id); console.log(`Collections with granules matching spatial and temporal search: ${JSON.stringify(collConceptIds)}`); @@ -245,6 +293,14 @@ export async function freeTextQueryPost( console.log('No matching collections are found'); } + // Submit the request off to harmony - TODO figure out shapefile and temporal + // const temporal = getTemporalQueryParam(temporalParam); + const queryParams = {} as unknown as any; + if (modelOutput.outputFormat) { + queryParams.outputFormat = modelOutput.outputFormat; + } + + const harmonyJob = await submitHarmonyRequest(collectionId, variableId, queryParams, req.accessToken); const harmonyParams: GeneratedHarmonyRequestParameters = { propertyOfInterest: modelOutput.propertyOfInterest, placeOfInterest: modelOutput.placeOfInterest, @@ -258,7 +314,9 @@ export async function freeTextQueryPost( timeInterval: modelOutput.timeInterval, outputFormat: modelOutput.outputFormat, geoJson, + statusUrl: harmonyJob.links[2].href, }; + res.send(harmonyParams); } catch (e) { diff --git a/services/harmony/app/views/free-text-query/index.mustache.html b/services/harmony/app/views/free-text-query/index.mustache.html index 586b82ca6..80c82e597 100644 --- a/services/harmony/app/views/free-text-query/index.mustache.html +++ b/services/harmony/app/views/free-text-query/index.mustache.html @@ -124,6 +124,10 @@
 

+
+

+ +
@@ -151,55 +155,96 @@ var map; var geoJsonLayer; - async function postForm(form) { - const spinner = document.getElementById('spinner'); - document.getElementById('model-output').innerHTML = ''; - if (geoJsonLayer) { - geoJsonLayer.clearLayers(); - } - spinner.style.display = 'block'; - const response = await fetch("./free-text", { - method: "POST", - body: JSON.stringify({ - query: form.query.value - }), - headers: { - "Content-type": "application/json; charset=UTF-8" + async function pollJobStatus(statusUrl) { + const statusElement = document.getElementById('job-status'); + const imageElement = document.getElementById('dynamic-image'); + + // Function to poll the job status + async function checkStatus() { + const response = await fetch(statusUrl, { + method: 'GET', + // headers: { + // 'Content-type': 'application/json; charset=UTF-8' + // } + }); + + const data = await response.json(); + + if (data.status === 'successful') { + // Update the image when the job is ready + imageUrl = data.links[1]?.href; + imageElement.src = imageUrl; + imageElement.style.display = 'block'; + statusElement.style.display = 'none'; + } else if (data.status !== 'running') { + statusElement.innerText = 'The request failed.'; + } else { + // Keep polling if the job is still in progress + statusElement.innerText = 'Harmony request in progress. Please wait...'; + setTimeout(checkStatus, 2000); // Poll every 2 seconds } - }); - - spinner.style.display = 'none'; - - const json = await response.json(); - const geoJson = json.geoJson; - const results = { - propertyOfInterest: json.propertyOfInterest, - placeOfInterest: json.placeOfInterest, - buffer: `${json.bufferNumber} ${json.bufferUnits}`, - collectionId: `${json.collection}`, - collectionName: `${json.collectionName}`, - variableId: `${json.variable}`, - variableName: `${json.variableName}`, - variableDefinition: `${json.variableDefinition}`, - temporalRange: `${json.timeInterval}`, - outputFormat: `${json.outputFormat}`, } - document.getElementById('model-output').innerHTML = JSON.stringify(results, null, 2); - if (!map) { - map = L.map('map').setView([51.505, -0.09], 1); + checkStatus(); // Start polling + } - const tiles = L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { - maxZoom: 19, - attribution: '© OpenStreetMap' - }).addTo(map); - } + async function postForm(form) { + const spinner = document.getElementById('spinner'); + const outputElement = document.getElementById('model-output'); + outputElement.innerHTML = ''; + spinner.style.display = 'block'; + try { + const response = await fetch("./free-text", { + method: "POST", + body: JSON.stringify({ query: form.query.value }), + headers: { + "Content-type": "application/json; charset=UTF-8" + } + }); + + if (!response.ok) { + throw new Error(`Error: ${response.statusText}`); + } - geoJsonLayer = L.geoJSON(geoJson).addTo(map); - map.fitBounds(geoJsonLayer.getBounds()); + const json = await response.json(); + const geoJson = json.geoJson; + + const results = { + propertyOfInterest: json.propertyOfInterest, + placeOfInterest: json.placeOfInterest, + buffer: `${json.bufferNumber} ${json.bufferUnits}`, + collectionId: `${json.collection}`, + collectionName: `${json.collectionName}`, + variableId: `${json.variable}`, + variableName: `${json.variableName}`, + variableDefinition: `${json.variableDefinition}`, + temporalRange: `${json.timeInterval}`, + outputFormat: `${json.outputFormat}`, + }; + outputElement.innerHTML = JSON.stringify(results, null, 2); + + if (!map) { + map = L.map('map').setView([51.505, -0.09], 1); + L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { + maxZoom: 19, + attribution: '© OpenStreetMap contributors' + }).addTo(map); + } - return false; + if (geoJsonLayer && map.hasLayer(geoJsonLayer)) { + map.removeLayer(geoJsonLayer); + } + + geoJsonLayer = L.geoJSON(geoJson).addTo(map); + map.fitBounds(geoJsonLayer.getBounds()); + pollJobStatus(json.statusUrl); + } catch (error) { + console.error(error); + outputElement.innerHTML = 'An error occurred. Please try again.'; + } finally { + spinner.style.display = 'none'; + } }