Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion src/components/organisms/DataSearch/SearchCards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ const SearchCards = ({
{groups.flatMap((group) =>
group.options.map((option, index) => (
<button
key={`${group.text}:${index}`}
key={`${group.key}:${index}`}
type="button"
className={styles.card}
style={{ '--i': index } as React.CSSProperties}
Expand Down
2 changes: 1 addition & 1 deletion src/components/organisms/DataSearch/SearchSentence.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1909,7 +1909,7 @@ export const SearchSentenceHeading = ({
{groups.flatMap((group) =>
group.options.map((option, optionIndex) => (
<DropdownMenu.Item
key={`${group.text}:${optionIndex}`}
key={`${group.key}:${optionIndex}`}
asChild
onSelect={() => pick(option)}
>
Expand Down
39 changes: 28 additions & 11 deletions src/lib/search/parsedEdit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,25 +99,42 @@ export function resolveTypedEdit(
}

/** One ambiguous wording of an entity_ambiguous refusal and the options
* that settle it (`no_data.options` whose `mentions` hold the wording), in
* the order the refusal lists them. */
export type EntityChoiceGroup = { text: string; options: NoDataOption[] };

/** The refusal's options grouped by the wording each settles, wordings in
* the order their first option comes. An option settling two wordings
* stands in both groups. Empty for every reason but entity_ambiguous. */
* that settle it, in the order the refusal lists them. `key` is the
* options' `entity_choice` (the group's index in
* `no_data.ambiguous_entities`) when they carry one, else the wording. */
export type EntityChoiceGroup = { key: number | string; text: string; options: NoDataOption[] };

/** The refusal's options grouped by what each settles, groups in the order
* their first option comes. An option carrying `entity_choice` joins that
* `ambiguous_entities` group, so two groups written alike ("aid from Congo
* to Congo") stay apart. An option without it joins a group per wording in
* its `mentions`. Empty for every reason but entity_ambiguous. */
export function entityChoiceGroups(noData: NoData | null | undefined): EntityChoiceGroup[] {
if (noData?.reason !== 'entity_ambiguous') return [];

const groups = new Map<string, NoDataOption[]>();
const groups = new Map<number | string, EntityChoiceGroup>();
const join = (key: number | string, text: string, option: NoDataOption) => {
const group = groups.get(key) ?? { key, text, options: [] };

group.options.push(option);
groups.set(key, group);
};

for (const option of noData.options ?? []) {
for (const text of option.mentions) {
groups.set(text, [...(groups.get(text) ?? []), option]);
const choice = option.entity_choice;

if (typeof choice === 'number') {
join(
choice,
noData.ambiguous_entities?.[choice]?.text ?? option.mentions[0] ?? '',
option,
);
} else {
for (const text of option.mentions) join(text, text, option);
}
}

return [...groups].map(([text, options]) => ({ text, options }));
return [...groups.values()];
}

/** The part an entity choice posts: its option's one-part request,
Expand Down
67 changes: 66 additions & 1 deletion src/lib/search/test/fixtures/ambiguous-congo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
* restatement, so it carries none.
*/

import type { NoData, NoDataOption, ParsedPart } from '../../types.ts';
import type { NoData, NoDataOption, ParsedPart, SentenceToken } from '../../types.ts';

const congoOption = (dcid: string, label: string): NoDataOption => ({
mentions: ['Congo'],
Expand Down Expand Up @@ -75,3 +75,68 @@ export const odaToCongoRestatement: ParsedPart = {
variable_text: 'official development assistance',
entities: [{ text: 'Congo', role_text: 'to' }],
};

/** "aid from Congo to Congo": one wording asked for each end, so the refusal
* carries two `ambiguous_entities` groups written alike. Each option pins
* one end by its candidate's name and posts the other as "Congo", and names
* its group as `entity_choice`, as dfo-apis search/engine/conjoin.py
* `_entity_options` builds them. */
const congoEnds: NoDataOption[] = [0, 1].flatMap((entityChoice) =>
[
['country/COD', 'Congo [DRC]'],
['country/COG', 'Congo [Republic]'],
].map(([dcid, label]) => {
const pinned = { text: label, dcid };
const [origin, destination] =
entityChoice === 0 ? [pinned, { text: 'Congo' }] : [{ text: 'Congo' }, pinned];

return {
mentions: ['Congo'],
entity_choice: entityChoice,
label,
data_query: {
kind: 'parsed' as const,
parts: [
{
variable_text: 'aid',
entities: [
{ ...origin, role_text: 'from' },
{ ...destination, role_text: 'to' },
],
},
],
},
};
}),
);

const congoGroup = {
text: 'Congo',
candidates: [
{ ref: 'country/COD', label: 'Congo [DRC]', kind: 'country' },
{ ref: 'country/COG', label: 'Congo [Republic]', kind: 'country' },
],
};

export const congoToCongoNoData: NoData = {
reason: 'entity_ambiguous',
entity_terms: ['Congo', 'Congo'],
ambiguous_entities: [congoGroup, congoGroup],
options: congoEnds,
unbound_qualifiers: [],
};

export const congoToCongoRestatement: ParsedPart = {
variable_text: 'aid',
entities: [
{ text: 'Congo', role_text: 'from' },
{ text: 'Congo', role_text: 'to' },
],
};

/** The two wording slot tokens of its sentence plan, as dfo-apis
* search/render/no_data.py `_ambiguous_entity_plan` marks them. */
export const congoToCongoWordings: SentenceToken[] = [
{ kind: 'slot', text: 'Congo', role: 'origin', entity_choice: 0 },
{ kind: 'slot', text: 'Congo', axis: 'where', role: 'destination', entity_choice: 1 },
];
15 changes: 15 additions & 0 deletions src/lib/search/test/parsedEdit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
restatementTargetForToken,
} from '../parsedEdit.ts';
import {
congoToCongoNoData,
gdpOfCongoNoData,
gdpOfCongoRestatement,
odaToCongoNoData,
Expand Down Expand Up @@ -274,6 +275,20 @@ test('entityChoiceGroups lists a wording per mention, in the order the options c
assert.deepEqual(entityChoiceGroups(null), []);
});

test('entityChoiceGroups keeps one group per entity_choice, two written alike apart', () => {
assert.deepEqual(
entityChoiceGroups(congoToCongoNoData).map(({ key, text, options }) => [
key,
text,
options.map((option) => option.entity_choice),
]),
[
[0, 'Congo', [0, 0]],
[1, 'Congo', [1, 1]],
],
);
});

// ---------------------------------------------------------------------------
// offersAmbiguousPicker: SearchCards and studioHandoffParts' shared gate for
// whether a no_data part gets the "Choose a Match" picker rather than the
Expand Down
30 changes: 30 additions & 0 deletions src/lib/search/test/view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ import { tokenPart } from '../sentencePlan.ts';
import { definite, noData, readable, resolution } from './fixtures/resolution.ts';
import { fixtures } from './fixtures/index.ts';
import { noDataResponse, wireSpec } from './fixtures/no-data.ts';
import {
congoToCongoNoData,
congoToCongoRestatement,
congoToCongoWordings,
} from './fixtures/ambiguous-congo.ts';
import { servedAnswer } from './fixtures/served-answer.ts';
import { servedAid, servedHealth, servedHealthEvents } from './fixtures/served-options.ts';
import {
Expand Down Expand Up @@ -1732,6 +1737,24 @@ test('ambiguousWordingGroup refuses the variable slot and an entity read-back',
assert.equal(ambiguousWordingGroup({ ...token, text: 'IFC' }, refusal), null);
});

test('ambiguousWordingGroup finds each slot its own group when two wordings are written alike', () => {
const [from, to] = congoToCongoWordings;
const menu = (token: SentenceToken) =>
ambiguousWordingGroup(token, congoToCongoNoData)?.options.map((option) =>
option.data_query.parts[0]?.entities?.map((entity) => entity.dcid ?? null),
);

// The "from" slot pins the origin, the "to" slot the destination.
assert.deepEqual(menu(from), [
['country/COD', null],
['country/COG', null],
]);
assert.deepEqual(menu(to), [
[null, 'country/COD'],
[null, 'country/COG'],
]);
});

test("ambiguousEntityMenuTitle titles the menu by the ambiguous wording's role", () => {
const part = noData(resolution(fixtures['entity-ambiguous']).parts[0]);
const { restatement } = part;
Expand Down Expand Up @@ -1782,6 +1805,13 @@ test('ambiguousEntityMenuTitle names every ambiguous wording a shared menu offer
};

assert.equal(ambiguousEntityMenuTitle(bothAmbiguous), 'Select Provider or Recipient');
assert.equal(
ambiguousEntityMenuTitle({
no_data: congoToCongoNoData,
restatement: congoToCongoRestatement,
}),
'Select Provider or Recipient',
);
});

test('ambiguousEntityMenuTitle reads a restatement whose entities are null', () => {
Expand Down
17 changes: 14 additions & 3 deletions src/lib/search/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@ export type {
IdentityUnitRequest,
MeasureAxisValue,
NoDataDenominator,
NoDataOption,
ObservationSeries,
ObservationsOmission,
OpenConstraintAxisFact,
Expand All @@ -63,7 +62,6 @@ export type {
ParsedEntity as ParsedPartEntity,
RequestedOperation as Operation,
RoleAxis,
SentenceToken,
Shape,
Slot,
SlotOption,
Expand All @@ -76,6 +74,15 @@ export type {
Warning,
} from '@developmentfinance/client/wire';

// dfo-apis' NoDataOption.entity_choice (and SentenceToken's); the vendored client lacks it.
type EntityChoice = { entity_choice?: number | null };

/** One ready request a no_data refusal offers. */
export type NoDataOption = wire.NoDataOption & EntityChoice;

/** One token of a sentence plan. */
export type SentenceToken = wire.SentenceToken & EntityChoice;

/** The envelope's contract version. */
export type SchemaVersion = Present<wire.Resolution['schema_version']>;

Expand Down Expand Up @@ -211,10 +218,14 @@ export type SpecReading = Partial<Spec> | UnreadableSpec;
type CandidateSet = Omit<wire.CandidateSet, 'specs'> & { specs: PartSpec[] };

/** A no_data outcome with its nearest, flagged and alternate Specs flattened. */
export type NoData = Omit<wire.NoData, 'nearest_real' | 'flagged_match' | 'alternate_match'> & {
export type NoData = Omit<
wire.NoData,
'nearest_real' | 'flagged_match' | 'alternate_match' | 'options'
> & {
nearest_real?: PartSpec[] | null;
flagged_match?: PartSpec | null;
alternate_match?: PartSpec | null;
options?: NoDataOption[];
};

/** The members `searchClient.flattenPart` defaults when the wire omits them. */
Expand Down
51 changes: 32 additions & 19 deletions src/lib/search/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2571,16 +2571,23 @@ export const unresolvedPartText = (part: Part | null | undefined): string | null
return null;
};

/** What an ambiguous wording names, by the role its restatement entity
* plays. The engine echoes a directed role as "from" or "to" (dfo-apis
* search/engine/role_text.py), so "from" is a provider, "to" a recipient, and
* anything else a place. */
const ambiguousEntityNoun = (restatement: ParsedPart | null | undefined, text: string): string => {
const role = restatement?.entities
?.find((entity) => entity.text === text)
?.role_text?.toLowerCase();
/** What an ambiguous wording names, by the role each restatement entity
* written that way plays. The engine echoes a directed role as "from" or
* "to" (dfo-apis search/engine/role_text.py), so "from" is a provider, "to"
* a recipient, and anything else a place. A wording written at both ends
* ("aid from Congo to Congo") names both. */
const ambiguousEntityNouns = (
restatement: ParsedPart | null | undefined,
text: string,
): string[] => {
const roles = (restatement?.entities ?? [])
.filter((entity) => entity.text === text)
.map((entity) => entity.role_text?.toLowerCase());
const nouns = roles.map((role) =>
role === 'from' ? 'Provider' : role === 'to' ? 'Recipient' : 'Place',
);

return role === 'from' ? 'Provider' : role === 'to' ? 'Recipient' : 'Place';
return nouns.length > 0 ? nouns : ['Place'];
};

/**
Expand All @@ -2598,7 +2605,7 @@ export const ambiguousEntityMenuTitle = (
}

const nouns = new Set(
groups.map((group) => ambiguousEntityNoun(part?.restatement, group.text)),
groups.flatMap((group) => ambiguousEntityNouns(part?.restatement, group.text)),
);

return `Select ${[...nouns].join(' or ')}`;
Expand All @@ -2607,25 +2614,31 @@ export const ambiguousEntityMenuTitle = (
/**
* The ambiguous wording an entity_ambiguous part's slot token renders, with
* the options that settle it (entityChoiceGroups), or null for any other
* token. The engine marks each wording as a `slot` token with no
* `entity_refs` or `binding_ref`, the role the request gave it, and text
* equal to the wording (dfo-apis search/render/no_data.py). The part's own
* variable slot is never one.
* token. A token carrying `entity_choice` names its group in
* `no_data.ambiguous_entities`. Without it, the engine marks each wording as
* a `slot` token with no `entity_refs` or `binding_ref`, the role the request
* gave it, and text equal to the wording (dfo-apis search/render/no_data.py);
* the part's own variable slot is never one.
*/
export const ambiguousWordingGroup = (
token: SentenceToken,
noData: NoData | null | undefined,
): EntityChoiceGroup | null => {
const choice = token.entity_choice;

if (
token.kind !== 'slot' ||
token.axis === 'what' ||
token.binding_ref ||
token.entity_refs?.length
typeof choice !== 'number' &&
(token.kind !== 'slot' ||
token.axis === 'what' ||
token.binding_ref ||
token.entity_refs?.length)
) {
return null;
}

return entityChoiceGroups(noData).find((group) => group.text === token.text) ?? null;
const key = typeof choice === 'number' ? choice : token.text;

return entityChoiceGroups(noData).find((group) => group.key === key) ?? null;
};

// Values render exactly as the engine sent them — US-style separators,
Expand Down
Loading