Skip to content

Commit

Permalink
HARMONY-1876: Submit a harmony request from the AI search service, po…
Browse files Browse the repository at this point in the history
…ll the job status until the request completes, and then load the resulting image in the browser.
  • Loading branch information
chris-durbin committed Dec 31, 2024
1 parent 9028a08 commit b472e21
Show file tree
Hide file tree
Showing 2 changed files with 147 additions and 44 deletions.
62 changes: 60 additions & 2 deletions services/harmony/app/frontends/free-text-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -65,6 +66,7 @@ interface GeneratedHarmonyRequestParameters {
timeInterval: string | null;
outputFormat: string | null;
geoJson: string | null;
statusUrl: string;
}

const distanceUnits = {
Expand Down Expand Up @@ -128,6 +130,51 @@ async function getEmbedding(input: string): Promise<number[]> {
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<HarmonyJobStatus> {
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
*
Expand Down Expand Up @@ -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);
Expand All @@ -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,
Expand All @@ -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)}`);

Expand All @@ -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,
Expand All @@ -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) {
Expand Down
129 changes: 87 additions & 42 deletions services/harmony/app/views/free-text-query/index.mustache.html
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,10 @@
<pre id="model-output"> </pre>
<br/>
<div id="map" style="width: 600px; height: 400px;"></div>
<div id="image-container">
<p id="job-status"></p>
<img id="dynamic-image" src="" alt="Dynamic Image" style="display:none;" />
</div>
</div>
</body>

Expand Down Expand Up @@ -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: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
}).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: '&copy; 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';
}
}

</script>
Expand Down

0 comments on commit b472e21

Please sign in to comment.