Skip to content

Commit

Permalink
feat: explain search result (#1156)
Browse files Browse the repository at this point in the history
* feat: first cut of explaining query result

* feat: explainQueryResult tests

* docs: update hubSearch guide with details about explainQueryResult

* docs: add comment on export

* docs: add docs for types

* refactor: add async to failing test
  • Loading branch information
dbouwman authored Aug 11, 2023
1 parent 084c0d8 commit d469907
Show file tree
Hide file tree
Showing 13 changed files with 1,412 additions and 27 deletions.
116 changes: 116 additions & 0 deletions docs/src/guides/functional/hub-search.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,119 @@ const opts: IHubSearchOptions = {
- In all other cases, we recommend specifying `arcgis` as that will use the Portal search API, and can return non-public content.

_Note_ If you are searching for specific [Hub Entities](./hub-entities), the entity "manager" modules also have simplified search functions return fully populated entity objects.


## Explaining Query Results

Sometimes we need a means to descibe why a particular item is included in a search result - in particular when users are managing the content catalog.
To that end, the `explainQueryResult(result:GenericResult, query:IQuery, requestOptions)` function is built to help with this.

To use this function, we pass in the `IQuery` that was send into `hubSearch(...)`, and you'll get a response that looks like this:

```json
{
// Copy of the Result object
"result": {
"id": "92ca9c12ee604b958303a52f3e0bbb6b",
"group": [
"9985c3ce1f0c4c39b065fc40a4548780",
"9fef828e929d4f8e9fb5e5e3e174e9f6"
]
},
// Copy of the Query
"query": {
"targetEntity": "item",
"filters": [
{
"predicates": [
{
"group": [
"9fef828e929d4f8e9fb5e5e3e174e9f6",
"9985c3ce1f0c4c39b065fc40a4548780"
]
}
]
}
]
},
// Did the Result match the Query's criteria?
"matched": true,
// Array of reasons the Query matched or did not
"reasons": [
{
"filter": {
"predicates": [
{
"group": {
"any": [
"9fef828e929d4f8e9fb5e5e3e174e9f6",
"9985c3ce1f0c4c39b065fc40a4548780"
]
}
}
]
},
"matched": true,
"reasons": [
{
"predicate": {
"group": {
"any": [
"9fef828e929d4f8e9fb5e5e3e174e9f6",
"9985c3ce1f0c4c39b065fc40a4548780"
]
}
},
"matched": true,
"reasons": [
{
"attribute": "group",
"values": "9985c3ce1f0c4c39b065fc40a4548780,9fef828e929d4f8e9fb5e5e3e174e9f6",
"condition": "IN",
"matched": true,
"requirement": "9fef828e929d4f8e9fb5e5e3e174e9f6,9985c3ce1f0c4c39b065fc40a4548780",
"message": "Value(s) 9985c3ce1f0c4c39b065fc40a4548780,9fef828e929d4f8e9fb5e5e3e174e9f6 contained at least one of value from [9fef828e929d4f8e9fb5e5e3e174e9f6,9985c3ce1f0c4c39b065fc40a4548780]",
"meta": {
"groups": [
{
"id": "9985c3ce1f0c4c39b065fc40a4548780",
"title": "Explain Group 2"
},
{
"id": "9fef828e929d4f8e9fb5e5e3e174e9f6",
"title": "Explain Group 1"
}
]
}
}
]
}
]
}
],
"summary": [
{
"attribute": "group",
"values": "9985c3ce1f0c4c39b065fc40a4548780,9fef828e929d4f8e9fb5e5e3e174e9f6",
"condition": "IN",
"matched": true,
"requirement": "9fef828e929d4f8e9fb5e5e3e174e9f6,9985c3ce1f0c4c39b065fc40a4548780",
"message": "Value(s) 9985c3ce1f0c4c39b065fc40a4548780,9fef828e929d4f8e9fb5e5e3e174e9f6 contained at least one of value from [9fef828e929d4f8e9fb5e5e3e174e9f6,9985c3ce1f0c4c39b065fc40a4548780]",
// Meta information that could be used when constructing a translated string
"meta": {
"groups": [
{
"id": "9985c3ce1f0c4c39b065fc40a4548780",
"title": "Explain Group 2"
},
{
"id": "9fef828e929d4f8e9fb5e5e3e174e9f6",
"title": "Explain Group 1"
}
]
}
}
]
}

```
121 changes: 121 additions & 0 deletions packages/common/e2e/explain-query.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { IQuery, cloneObject, explainQueryResult } from "../src";
import { hubSearch } from "../src/search";
import Artifactory from "./helpers/Artifactory";
import config from "./helpers/config";

describe("Explain Query", () => {
let factory: Artifactory;
beforeAll(() => {
factory = new Artifactory(config);
jasmine.DEFAULT_TIMEOUT_INTERVAL = 200000;
});
it("item in two groups", async () => {
const ctxMgr = await factory.getContextManager("hubBasic", "admin");

const query: IQuery = {
targetEntity: "item",
filters: [
{
predicates: [
{
group: [
"9fef828e929d4f8e9fb5e5e3e174e9f6", // Explain Group 1
"9985c3ce1f0c4c39b065fc40a4548780", // Explain Group 2
],
},
],
},
],
};
const result = {
id: "92ca9c12ee604b958303a52f3e0bbb6b",
};
const explanation = await explainQueryResult(
result,
query,
ctxMgr.context.requestOptions
);
});
it("item in two groups: catalog exclude one", async () => {
const ctxMgr = await factory.getContextManager("hubBasic", "admin");

const query: IQuery = {
targetEntity: "item",
filters: [
{
predicates: [
{
group: {
all: [
"9fef828e929d4f8e9fb5e5e3e174e9f6", // Explain Group 1
],
not: [
"9985c3ce1f0c4c39b065fc40a4548780", // Explain Group 2
],
},
},
],
},
],
};
const result = {
id: "92ca9c12ee604b958303a52f3e0bbb6b",
};
const explanation = await explainQueryResult(
result,
query,
ctxMgr.context.requestOptions
);
// debugger;
});
it("two filters: item in two groups", async () => {
const ctxMgr = await factory.getContextManager("hubBasic", "admin");

const query: IQuery = {
targetEntity: "item",
// Filters are ANDed together
filters: [
{
predicates: [
{
group: [
"9fef828e929d4f8e9fb5e5e3e174e9f6", // Explain Group 1
],
},
],
},
{
predicates: [
{
group: [
"9985c3ce1f0c4c39b065fc40a4548780", // Explain Group 2
],
},
],
},
],
};
// Add a term filter to get the specific result we want
const searchQuery = cloneObject(query);
searchQuery.filters.push({
predicates: [{ term: "Oak" }],
});
// execute query
const results = await hubSearch(query, {
requestOptions: ctxMgr.context.hubRequestOptions,
});

expect(results.results.length).toBe(1);

const result = results.results[0];

expect(result.id).toBe("92ca9c12ee604b958303a52f3e0bbb6b");
// now get the explanation
const explanation = await explainQueryResult(
result,
query,
ctxMgr.context.requestOptions
);
// debugger;
});
});
3 changes: 3 additions & 0 deletions packages/common/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ export * from "./core/updateHubEntity";
// but if it's exported there, random tests start failing
export * from "./urls/getCardModelUrl";
export * from "./core/EntityEditor";
// Unclear _why_ this needs to be here vs. in search/index.ts
// but if it's exported there, it's not actually exporeted
export * from "./search/explainQueryResult";

import OperationStack from "./OperationStack";
import OperationError from "./OperationError";
Expand Down
55 changes: 37 additions & 18 deletions packages/common/src/search/_internal/expandPredicate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,39 @@ import { cloneObject } from "../../util";
import { IPredicate } from "../types";
import { relativeDateToDateRange, valueToMatchOptions } from "../utils";

/**
* @internal
* Predicate properties that are treated as dates
*/
export const PREDICATE_DATE_PROPS = ["created", "modified", "lastlogin"];

/**
* @internal
* Predicate properties that are just copied forward
*/
export const PREDICATE_COPY_PROPS = [
"bbox",
"categoriesAsParam",
"categoryFilter",
"filterType",
"isopendata",
"isviewonly",
"memberType",
"name",
"searchUserAccess",
"searchUserName",
"term",
];

/**
* @internal
* Predicate properties that are not treated as match options
*/
export const PREDICATE_NON_MATCH_OPTIONS_PROPS = [
...PREDICATE_DATE_PROPS,
...PREDICATE_COPY_PROPS,
];

/**
* @private
* Expand a predicate
Expand All @@ -11,29 +44,15 @@ import { relativeDateToDateRange, valueToMatchOptions } from "../utils";
*/
export function expandPredicate(predicate: IPredicate): IPredicate {
const result: IPredicate = {};
const dateProps = ["created", "modified", "lastlogin"];
const copyProps = [
"bbox",
"categoriesAsParam",
"categoryFilter",
"filterType",
"isopendata",
"isviewonly",
"memberType",
"name",
"searchUserAccess",
"searchUserName",
"term",
];
const nonMatchOptionsFields = [...dateProps, ...copyProps];

// Do the conversion
Object.entries(predicate).forEach(([key, value]) => {
// Handle MatchOptions fields
if (!nonMatchOptionsFields.includes(key)) {
if (!PREDICATE_NON_MATCH_OPTIONS_PROPS.includes(key)) {
setProp(key, valueToMatchOptions(value), result);
}
// Handle Date fields
if (dateProps.includes(key)) {
if (PREDICATE_DATE_PROPS.includes(key)) {
const dateFieldValue = cloneObject(getProp(predicate, key));
if (getProp(predicate, `${key}.type`) === "relative-date") {
setProp(key, relativeDateToDateRange(dateFieldValue), result);
Expand All @@ -42,7 +61,7 @@ export function expandPredicate(predicate: IPredicate): IPredicate {
}
}
// Handle fields that are just copied forward
if (copyProps.includes(key) && predicate.hasOwnProperty(key)) {
if (PREDICATE_COPY_PROPS.includes(key) && predicate.hasOwnProperty(key)) {
setProp(key, value, result);
}
});
Expand Down
42 changes: 42 additions & 0 deletions packages/common/src/search/_internal/explainFilter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { IRequestOptions } from "@esri/arcgis-rest-request";
import { GenericResult, IFilterExplanation } from "../explainQueryResult";
import { IFilter } from "../types/IHubCatalog";
import { cloneObject } from "../../util";
import { explainPredicate } from "./explainPredicate";

/**
* Generate an explanation if a specific result passes the filter's criteria
* @param filter
* @param result
* @param requestOptions
* @returns
*/

export async function explainFilter(
filter: IFilter,
result: GenericResult,
requestOptions: IRequestOptions
): Promise<IFilterExplanation> {
// setup return value
const explanation: IFilterExplanation = {
filter: cloneObject(filter),
matched: false,
reasons: [],
};
// for each predicate, explain the match and return the explanation
for (const predicate of filter.predicates) {
const r = await explainPredicate(predicate, result, requestOptions);
explanation.reasons.push(r);
}
// depending on the operation, we combine the predicate results differently
if (filter.operation === "OR") {
// if any of the predicates match, then the filter matches
explanation.matched = explanation.reasons.some((r) => r.matched);
} else {
// filter.operation defaults to AND
explanation.matched = explanation.reasons.every((r) => r.matched);
}

// return
return explanation;
}
Loading

0 comments on commit d469907

Please sign in to comment.