Skip to content

Add natural language search #3

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 21 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
29d61fe
Hack to demo natural language search
sunu May 19, 2025
c428150
feat: add config option to enable/disable semantic searh
vgeorge Jun 13, 2025
d6ae6b9
fix: do not display default search form when natural query is enabled
vgeorge Jun 13, 2025
fa3879c
feat: move natural language input to SearchFilter component
vgeorge Jun 18, 2025
ff0078f
feat: improve natural language search UI with better user guidance
vgeorge Jun 18, 2025
b2f91e0
feat: add natural language search with explanation display and loadin…
vgeorge Jun 18, 2025
1f66dfd
feat: populate temporal extent from natural language search response
vgeorge Jun 18, 2025
35a52cb
feat: display natural language search intersects polygon on map with …
vgeorge Jun 18, 2025
b03fd4d
feat: populate collections filter from natural language search response
vgeorge Jun 18, 2025
ae83557
feat: populate items per page from natural language search response
vgeorge Jun 18, 2025
6b83c8e
fix: do not query items from natural language API
vgeorge Jun 23, 2025
3fbba13
fix: restore setFilters logic
vgeorge Jun 23, 2025
f8022dc
refactor: change spatial extend from checkbox to radio group in prepa…
vgeorge Jun 23, 2025
7b2e020
feat: use natural language area as intersects param
vgeorge Jun 23, 2025
7b456c5
feat: add branch preview deployment workflow with semantic search sup…
vgeorge Jun 25, 2025
e1f5ea7
fix: update deployment url
vgeorge Jun 25, 2025
fe831dd
Add separator to clearly distinguish form-based and natural language …
AliceR Jun 26, 2025
9e868ee
Clarify instructions for natural language search
AliceR Jun 26, 2025
f3e15e9
Add separate button for direct nl search, making it the primary action
AliceR Jun 26, 2025
e21da43
Display spatial extend of natural search area
AliceR Jun 30, 2025
6be1a20
Merge pull request #7 from developmentseed/nls-demo-ux-improvements
vgeorge Jul 1, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 44 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ It's not officially supported, but you may also be able to use it for
certain _OGC API - Records_ and _OGC API - Features_ compliant servers.

**Please note that STAC Browser is currently with limited funding for both maintenance, bug fixes and improvements. This means issues and PRs may be addressed very slowly.
If you care about STAC Browser and have some funds to support the future of STAC Browser, please contact matthias@mohr.ws**
If you care about STAC Browser and have some funds to support the future of STAC Browser, please contact <matthias@mohr.ws>**

**Table of Contents:**

Expand All @@ -37,6 +37,7 @@ If you care about STAC Browser and have some funds to support the future of STAC
- [Translation](#translation)
- [Customize through root catalog](#customize-through-root-catalog)
- [Custom extensions](#custom-extensions)
- [Natural Language Search](#natural-language-search)
- [Docker](#docker)
- [Contributing](#contributing)
- [Adding a new language](#adding-a-new-language)
Expand Down Expand Up @@ -269,6 +270,48 @@ STAC Browser supports some non-standardized extensions to the STAC specification
Add a `name` field and it will be used as title in the tab header, the same applies for the core Asset Object.
3. A link with relation type `icon` and a Browser-supported media type in any STAC entity will show an icon in the header and the lists of Catalogs, Collections and Items.

### Natural Language Search

STAC Browser supports natural language search functionality that allows users to search for STAC items using descriptive queries in natural language (e.g., "satellite images of forests in California from 2023").

To enable natural language search:

1. **Configure the API URL**: Set the `semanticSearchApiUrl` option in your configuration file:

```javascript
// config.js
module.exports = {
// ... other config options
semanticSearchApiUrl: "https://your-semantic-search-api.com",
// ... other config options
};
```

2. **API Requirements**: Your semantic search API should accept POST requests to `/items/search` with the following format:

```json
{
"query": "your natural language query",
"limit": 10
}
```

And return results in this format:

```json
{
"results": {
"items": [
// Array of STAC items
]
}
}
```

3. **User Interface**: Once configured, users will see the "Natural Language Query" search interface where they can enter descriptive queries.

**Note**: Natural language search is disabled by default. The feature will only appear in the interface when `semanticSearchApiUrl` is configured with a valid API URL.

## Docker

You can use the Docker to work with STAC Browser. Please read [Docker documentation](docs/docker.md) for more details.
Expand Down
3 changes: 2 additions & 1 deletion config.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,5 +48,6 @@ module.exports = {
requestQueryParameters: {},
socialSharing: ['email', 'bsky', 'mastodon', 'x'],
preprocessSTAC: null,
authConfig: null
authConfig: null,
semanticSearchApiUrl: false,
};
3 changes: 3 additions & 0 deletions src/components/ApiCapabilitiesMixin.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ export default {
canFilterFreeText() {
return this.supportsConformance(this.conformances.FreeText);
},
canSupportNaturalLanguage() {
return true;
},
cql() {
if (!this.supportsConformance(this.conformances.CqlFilters)) {
return null;
Expand Down
14 changes: 14 additions & 0 deletions src/components/Map.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
<l-tile-layer v-for="xyz of xyzLinks" ref="xyzOverlays" :key="xyz.url" layerType="overlay" v-bind="xyz" />
<LWMSTileLayer v-for="wms of wmsLinks" ref="wmsOverlays" :key="wms.url" layerType="overlay" v-bind="wms" />
<l-geo-json v-if="geojson" ref="geojson" :geojson="geojson" :options="{onEachFeature: showPopup}" :optionsStyle="{color: secondaryColor, weight: secondaryWeight}" />
<l-geo-json v-if="intersectsPolygon" ref="intersectsLayer" :geojson="intersectsPolygon" :options="{onEachFeature: showIntersectsPopup}" :optionsStyle="{color: '#3B82F6', weight: 2, fillColor: '#3B82F6', fillOpacity: 0.15, opacity: 0.8, dashArray: '5, 5'}" />
</l-map>
<b-popover
v-if="popover && selectedItem" placement="left" triggers="manual" :show="selectedItem !== null"
Expand Down Expand Up @@ -180,6 +181,15 @@ export default {
}
}
return wmsLinks;
},
naturalLanguageDescription() {
return this.naturalLanguageExplanation;
},
intersectsPolygon() {
if (this.stacLayerData && this.stacLayerData.intersects) {
return this.stacLayerData.intersects;
}
return null;
}
},
watch: {
Expand Down Expand Up @@ -452,6 +462,10 @@ export default {
}
layer.bindPopup(html);
},
showIntersectsPopup(feature, layer) {
const html = `<h3>${this.$t('search.naturalLanguageSearchArea')}</h3><p>${this.$t('search.naturalLanguageSearchAreaDescription')}</p>`;
layer.bindPopup(html);
},
addBoundsSelector() {
this.areaSelect = L.areaSelect({ // eslint-disable-line
width: 300,
Expand Down
151 changes: 146 additions & 5 deletions src/components/SearchFilter.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,32 @@

<b-card-title v-if="title" :title="title" />

<!-- Natural Language Search -->
<b-form-group v-if="canSupportNaturalLanguage" class="natural-language-search" :label="$t('search.naturalLanguageQuery')" :label-for="ids.naturalLanguage" :description="naturalLanguageDescription">
<div class="alert alert-info mb-3">
{{ $t('search.naturalLanguageInfo') }}
</div>
<div class="d-flex">
<b-form-input
:id="ids.naturalLanguage"
v-model="naturalLanguageQuery"
type="text"
:placeholder="$t('search.enterNaturalLanguageQuery')"
@keyup.enter="applyNaturalLanguageQuery"
@keydown.enter.prevent
class="flex-grow-1 mr-2"
/>
<b-button variant="primary" @click="applyNaturalLanguageQuery" :disabled="naturalLanguageLoading">
<span v-if="naturalLanguageLoading">
{{ $t('search.processing') }}
</span>
<span v-else>
{{ $t('search.applyNaturalLanguageQuery') }}
</span>
</b-button>
</div>
</b-form-group>

<b-form-group v-if="canFilterFreeText" class="filter-freetext" :label="$t('search.freeText')" :label-for="ids.q" :description="$t('search.freeTextDescription')">
<multiselect
:id="ids.q" :value="query.q" @input="setSearchTerms"
Expand Down Expand Up @@ -116,7 +142,7 @@
</template>

<script>
import { BBadge, BDropdown, BDropdownItem, BForm, BFormGroup, BFormInput, BFormCheckbox, BFormRadioGroup } from 'bootstrap-vue';
import { BBadge, BDropdown, BDropdownItem, BForm, BFormGroup, BFormInput, BFormCheckbox, BFormRadioGroup, BButton } from 'bootstrap-vue';
import Multiselect from 'vue-multiselect';
import { mapGetters, mapState } from "vuex";
import refParser from '@apidevtools/json-schema-ref-parser';
Expand Down Expand Up @@ -144,7 +170,8 @@ function getQueryDefaults() {
ids: [],
collections: [],
sortby: null,
filters: null
filters: null,
naturalLanguageQuery: ''
};
}

Expand All @@ -156,7 +183,10 @@ function getDefaults() {
query: getQueryDefaults(),
filtersAndOr: 'and',
filters: [],
selectedCollections: []
selectedCollections: [],
naturalLanguageQuery: '',
naturalLanguageExplanation: '',
naturalLanguageLoading: false
};
}

Expand All @@ -173,6 +203,7 @@ export default {
BFormInput,
BFormCheckbox,
BFormRadioGroup,
BButton,
QueryableInput: () => import('./QueryableInput.vue'),
Loading,
Map: () => import('./Map.vue'),
Expand Down Expand Up @@ -209,12 +240,16 @@ export default {
hasAllCollections: false,
collections: [],
collectionsLoadingTimer: null,
additionalCollectionCount: 0
additionalCollectionCount: 0,
naturalLanguageLoading: false
}, getDefaults());
},
computed: {
...mapState(['itemsPerPage', 'maxItemsPerPage', 'uiLanguage']),
...mapGetters(['canSearchCollections', 'supportsConformance']),
canSupportNaturalLanguage() {
return Boolean(this.$store.state.semanticSearchApiUrl);
},
collectionSelectOptions() {
let taggable = !this.hasAllCollections;
let isResult = this.collections.length > 0 && !this.hasAllCollections;
Expand Down Expand Up @@ -245,7 +280,7 @@ export default {
},
ids() {
let obj = {};
['q', 'datetime', 'bbox', 'collections', 'ids', 'sort', 'limit']
['q', 'datetime', 'bbox', 'collections', 'ids', 'sort', 'limit', 'naturalLanguage']
.forEach(field => obj[field] = field + formId);
return obj;
},
Expand Down Expand Up @@ -289,6 +324,9 @@ export default {
set(val) {
this.query.datetime = Array.isArray(val) ? val.map(d => Utils.dateToUTC(d)) : null;
}
},
naturalLanguageDescription() {
return this.naturalLanguageExplanation;
}
},
watch: {
Expand Down Expand Up @@ -349,6 +387,100 @@ export default {
Promise.all(promises).finally(() => this.loaded = true);
},
methods: {
async applyNaturalLanguageQuery(event) {
// Prevent form submission
if (event) {
event.preventDefault();
event.stopPropagation();
}

if (this.naturalLanguageQuery) {
this.naturalLanguageLoading = true;
try {
const SEMANTIC_SEARCH_API_URL = this.$store.state.semanticSearchApiUrl;
const response = await fetch(`${SEMANTIC_SEARCH_API_URL}/items/search`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
query: this.naturalLanguageQuery,
limit: 10
})
});

if (!response.ok) {
throw new Error(`API request failed with status ${response.status}`);
}

const responseData = await response.json();

// Store the explanation if available in the response
if (responseData.explanation) {
this.naturalLanguageExplanation = responseData.explanation;
} else if (responseData.results && responseData.results.explanation) {
this.naturalLanguageExplanation = responseData.results.explanation;
}

// Extract datetime from search_params if available
if (responseData.results && responseData.results.search_params && responseData.results.search_params.datetime) {
const datetimeString = responseData.results.search_params.datetime;
// Parse datetime string in format '2021-01-01/2022-12-31'
const dates = datetimeString.split('/');
if (dates.length === 2) {
const startDate = dates[0] === '..' ? null : new Date(dates[0]);
const endDate = dates[1] === '..' ? null : new Date(dates[1]);

// Set the datetime in the query
this.query.datetime = [startDate, endDate];
}
}

// Extract collections from search_params if available
if (responseData.results && responseData.results.search_params && responseData.results.search_params.collections) {
const collectionIds = responseData.results.search_params.collections;
if (Array.isArray(collectionIds) && collectionIds.length > 0) {
// Remove duplicates and set collections in the query
const uniqueCollectionIds = [...new Set(collectionIds)];
this.$set(this.query, 'collections', uniqueCollectionIds);

// Update selectedCollections to match the query collections
this.selectedCollections = uniqueCollectionIds.map(id => {
// Try to find existing collection in the collections array
let existingCollection = this.collections.find(c => c.value === id);
if (existingCollection) {
return existingCollection;
}
// If not found, create a new collection option
return this.collectionToMultiSelect({id});
});
}
}

// Extract max_items from search_params if available
if (responseData.results && responseData.results.search_params && responseData.results.search_params.max_items) {
const maxItems = parseInt(responseData.results.search_params.max_items, 10);
if (!isNaN(maxItems) && maxItems > 0) {
// Ensure the value doesn't exceed the maximum allowed
const limitedMaxItems = Math.min(maxItems, this.maxItems);
this.$set(this.query, 'limit', limitedMaxItems);
}
}

// Emit the natural language search results
this.$emit('natural-language-results', {
features: responseData.results.items,
type: 'Feature',
intersects: responseData.results.search_params?.intersects || null
});
} catch (error) {
console.error('Error in semantic search:', error);
this.$emit('natural-language-error', error.message);
} finally {
this.naturalLanguageLoading = false;
}
}
},
resetSearchCollection() {
clearTimeout(this.collectionsLoadingTimer);
this.collectionsLoadingTimer = null;
Expand Down Expand Up @@ -502,10 +634,14 @@ export default {
}
let filters = this.buildFilter();
this.$set(this.query, 'filters', filters);
this.$set(this.query, 'naturalLanguageQuery', this.naturalLanguageQuery);
this.$emit('input', this.query, false);
},
async onReset() {
Object.assign(this, getDefaults());
this.naturalLanguageQuery = '';
this.naturalLanguageExplanation = '';
this.naturalLanguageLoading = false;
this.$emit('input', {}, true);
},
setLimit(limit) {
Expand Down Expand Up @@ -610,6 +746,11 @@ $primary-color: map-get($theme-colors, "primary");
> label {
font-weight: 600;
}

// Add styling for fieldset > legend
legend {
font-weight: 600;
}
}
}
</style>
7 changes: 7 additions & 0 deletions src/locales/en/texts.json
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,13 @@
"enterCollections": "Enter one or more Collection IDs...",
"enterItemIds": "Enter one or more Item IDs...",
"enterSearchTerms": "Enter one or more search terms...",
"enterNaturalLanguageQuery": "e.g., 'satellite images of California from 2023'",
"naturalLanguageQuery": "AI Search",
"naturalLanguageInfo": "Enter a natural language query to search. This will replace any existing filter selections and return AI-powered search results.",
"applyNaturalLanguageQuery": "Search",
"naturalLanguageSearchArea": "Natural Language Search Area",
"naturalLanguageSearchAreaDescription": "This area represents the spatial extent used in your natural language search query.",
"processing": "Processing...",
"equalTo": "equal to",
"filterBySpatialExtent": "Filter by spatial extent",
"filterCollection": "Filter Collection",
Expand Down
Loading