sanity-plugin-taxonomy-manager 4.7.2 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +13 -108
- package/{lib → dist}/index.d.ts +66 -48
- package/dist/index.js +106 -0
- package/dist/index.js.map +1 -0
- package/package.json +40 -46
- package/src/components/Children.tsx +4 -3
- package/src/components/Concepts.tsx +2 -2
- package/src/components/Hierarchy.test.tsx +92 -0
- package/src/components/Hierarchy.tsx +65 -30
- package/src/components/TopConcepts.tsx +2 -2
- package/src/components/TreeStructure.tsx +3 -3
- package/src/components/inputs/ArrayHierarchyInput.test.tsx +102 -0
- package/src/components/inputs/ArrayHierarchyInput.tsx +23 -24
- package/src/components/inputs/Identifier.tsx +1 -1
- package/src/components/inputs/InputHierarchy.test.tsx +38 -0
- package/src/components/inputs/InputHierarchy.tsx +20 -68
- package/src/components/inputs/ReferenceHierarchyInput.test.tsx +109 -0
- package/src/components/inputs/ReferenceHierarchyInput.tsx +24 -25
- package/src/components/interactions/ConceptDetailLink.tsx +0 -1
- package/src/components/interactions/ConceptEditAction.tsx +1 -1
- package/src/components/interactions/ConceptSelectLink.test.tsx +49 -0
- package/src/components/interactions/ConceptSelectLink.tsx +10 -6
- package/src/context.ts +34 -14
- package/src/core/__snapshots__/queries.test.ts.snap +386 -0
- package/src/core/__snapshots__/semanticRecommendations.test.ts.snap +10 -0
- package/src/core/createId.test.ts +35 -0
- package/src/core/filters.test.ts +64 -0
- package/src/core/filters.ts +68 -0
- package/src/core/ids.test.ts +68 -0
- package/src/core/ids.ts +61 -0
- package/src/core/mutations.test.ts +114 -0
- package/src/core/mutations.ts +105 -0
- package/src/core/ports.ts +59 -0
- package/src/core/queries.test.ts +50 -0
- package/src/{queries.ts → core/queries.ts} +7 -6
- package/src/core/semanticRecommendations.test.ts +160 -0
- package/src/core/semanticRecommendations.ts +127 -0
- package/src/core/tree/annotateRecommendations.test.ts +43 -0
- package/src/core/tree/annotateRecommendations.ts +30 -0
- package/src/core/tree/pruneConcepts.test.ts +77 -0
- package/src/core/tree/pruneConcepts.ts +72 -0
- package/src/core/validation.test.ts +42 -0
- package/src/core/validation.ts +42 -0
- package/src/helpers/baseIriField.ts +22 -0
- package/src/helpers/branchFilter.test.ts +48 -0
- package/src/helpers/branchFilter.ts +17 -26
- package/src/helpers/schemeFilter.test.ts +51 -0
- package/src/helpers/schemeFilter.ts +11 -23
- package/src/hooks/index.ts +1 -1
- package/src/hooks/useCreateConcept.tsx +33 -110
- package/src/hooks/useRemoveConcept.tsx +24 -36
- package/src/hooks/useSemanticRecommendations.tsx +75 -0
- package/src/index.test.ts +31 -0
- package/src/index.ts +2 -6
- package/src/seams/StudioDataAdapter.ts +81 -0
- package/src/seams/TaxonomyPortContext.tsx +33 -0
- package/src/skosConcept.tsx +23 -234
- package/src/skosConceptScheme.tsx +2 -4
- package/src/structure.tsx +60 -0
- package/src/test/FakeDataPort.test.ts +62 -0
- package/src/test/FakeDataPort.ts +69 -0
- package/src/test/inputHarness.tsx +71 -0
- package/src/test/renderWithUi.test.tsx +14 -0
- package/src/test/renderWithUi.tsx +24 -0
- package/src/test-setup.ts +35 -0
- package/src/types.tsx +30 -11
- package/src/views/ConceptUseView.tsx +4 -10
- package/lib/index.esm.d.mts +0 -492
- package/lib/index.esm.esm.js +0 -182
- package/lib/index.esm.esm.js.map +0 -1
- package/lib/index.esm.mjs +0 -182
- package/lib/index.esm.mjs.map +0 -1
- package/lib/index.js +0 -182
- package/lib/index.js.map +0 -1
- package/sanity.json +0 -8
- package/src/config.ts +0 -16
- package/src/helpers/baseIriField.tsx +0 -42
- package/src/hooks/useEmbeddingsRecs.tsx +0 -75
- package/src/skosConcept.module.css +0 -10
- package/src/structure.ts +0 -40
- package/v2-incompatible.js +0 -11
- /package/src/{helpers → core}/createId.ts +0 -0
package/src/core/ids.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DocumentId,
|
|
3
|
+
getDraftId,
|
|
4
|
+
getVersionId,
|
|
5
|
+
getVersionNameFromId,
|
|
6
|
+
isPublishedId,
|
|
7
|
+
isVersionId,
|
|
8
|
+
type VersionId,
|
|
9
|
+
} from '@sanity/id-utils'
|
|
10
|
+
|
|
11
|
+
/** The `_strengthenOnPublish` template applied to weak skosConcept references. */
|
|
12
|
+
export const SKOS_CONCEPT_STRENGTHEN = {type: 'skosConcept', template: {id: 'skosConcept'}} as const
|
|
13
|
+
|
|
14
|
+
export type SchemeMutationTarget = {
|
|
15
|
+
isInRelease: boolean
|
|
16
|
+
releaseName: string | undefined
|
|
17
|
+
schemeId: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Resolve the document id a scheme mutation should write to, from the displayed
|
|
22
|
+
* scheme id: the matching version id when the scheme is being edited inside a
|
|
23
|
+
* release, otherwise its draft id. Shared by concept create/remove.
|
|
24
|
+
*/
|
|
25
|
+
export function deriveSchemeMutationTarget(displayedId: string): SchemeMutationTarget {
|
|
26
|
+
const docId = DocumentId(displayedId)
|
|
27
|
+
const isInRelease = isVersionId(docId)
|
|
28
|
+
const releaseName = isInRelease ? getVersionNameFromId(docId as VersionId) : undefined
|
|
29
|
+
const schemeId = isInRelease ? getVersionId(docId, releaseName as string) : getDraftId(docId)
|
|
30
|
+
return {isInRelease, releaseName, schemeId}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The id for a newly created concept, in the same release/draft context as its
|
|
35
|
+
* scheme. `uuid` is supplied by the caller (kept pure/deterministic here).
|
|
36
|
+
*/
|
|
37
|
+
export function deriveNewConceptId(
|
|
38
|
+
uuid: string,
|
|
39
|
+
context: {isInRelease: boolean; releaseName: string | undefined},
|
|
40
|
+
): string {
|
|
41
|
+
const docId = DocumentId(uuid)
|
|
42
|
+
return context.isInRelease
|
|
43
|
+
? getVersionId(docId, context.releaseName as string)
|
|
44
|
+
: getDraftId(docId)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type ReferenceStrength = {
|
|
48
|
+
_weak: boolean
|
|
49
|
+
_strengthenOnPublish?: typeof SKOS_CONCEPT_STRENGTHEN
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Weak-reference flags for a reference to a concept that may not be published
|
|
54
|
+
* yet. When the referenced concept is a draft or version (not published), the
|
|
55
|
+
* reference must be weak and strengthened on publish so it survives until the
|
|
56
|
+
* target is published.
|
|
57
|
+
*/
|
|
58
|
+
export function conceptReferenceStrength(referencedOriginalId: string): ReferenceStrength {
|
|
59
|
+
const published = isPublishedId(DocumentId(referencedOriginalId))
|
|
60
|
+
return published ? {_weak: false} : {_weak: true, _strengthenOnPublish: SKOS_CONCEPT_STRENGTHEN}
|
|
61
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import {describe, expect, it} from 'vitest'
|
|
2
|
+
|
|
3
|
+
import type {ConceptSchemeDocument} from '../types'
|
|
4
|
+
|
|
5
|
+
import {SKOS_CONCEPT_STRENGTHEN} from './ids'
|
|
6
|
+
import {planCreateConcept, planRemoveConcept} from './mutations'
|
|
7
|
+
|
|
8
|
+
const scheme = (id: string): ConceptSchemeDocument['displayed'] =>
|
|
9
|
+
({_id: id, _type: 'skosConceptScheme', title: 'Test Scheme'}) as ConceptSchemeDocument['displayed']
|
|
10
|
+
|
|
11
|
+
describe('planCreateConcept', () => {
|
|
12
|
+
it('plans a top concept on a published scheme (draft target, no broader)', () => {
|
|
13
|
+
expect(
|
|
14
|
+
planCreateConcept({
|
|
15
|
+
scheme: scheme('scheme-1'),
|
|
16
|
+
conceptType: 'topConcept',
|
|
17
|
+
newConceptUuid: 'new-1',
|
|
18
|
+
conceptId: 'abc123',
|
|
19
|
+
schemeBaseIri: 'https://example.com/',
|
|
20
|
+
newConceptKey: 'ref-key',
|
|
21
|
+
broaderKey: 'broader-key',
|
|
22
|
+
}),
|
|
23
|
+
).toEqual({
|
|
24
|
+
kind: 'create',
|
|
25
|
+
schemeId: 'drafts.scheme-1',
|
|
26
|
+
newConceptId: 'drafts.new-1',
|
|
27
|
+
createIfNotExists: {_id: 'drafts.scheme-1', _type: 'skosConceptScheme', title: 'Test Scheme'},
|
|
28
|
+
create: {
|
|
29
|
+
_id: 'drafts.new-1',
|
|
30
|
+
_type: 'skosConcept',
|
|
31
|
+
conceptId: 'abc123',
|
|
32
|
+
prefLabel: '',
|
|
33
|
+
baseIri: 'https://example.com/',
|
|
34
|
+
broader: [],
|
|
35
|
+
related: [],
|
|
36
|
+
},
|
|
37
|
+
appendField: 'topConcepts',
|
|
38
|
+
reference: {
|
|
39
|
+
_ref: 'new-1',
|
|
40
|
+
_type: 'reference',
|
|
41
|
+
_key: 'ref-key',
|
|
42
|
+
_strengthenOnPublish: SKOS_CONCEPT_STRENGTHEN,
|
|
43
|
+
_weak: true,
|
|
44
|
+
},
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('plans a child concept on a draft scheme with a published broader (strong broader ref)', () => {
|
|
49
|
+
const plan = planCreateConcept({
|
|
50
|
+
scheme: scheme('drafts.scheme-1'),
|
|
51
|
+
conceptType: 'concept',
|
|
52
|
+
broaderConcept: {id: 'broader-1', _originalId: 'broader-1'},
|
|
53
|
+
newConceptUuid: 'new-2',
|
|
54
|
+
conceptId: 'def456',
|
|
55
|
+
schemeBaseIri: undefined,
|
|
56
|
+
newConceptKey: 'ref-key',
|
|
57
|
+
broaderKey: 'broader-key',
|
|
58
|
+
})
|
|
59
|
+
expect(plan.schemeId).toBe('drafts.scheme-1')
|
|
60
|
+
expect(plan.newConceptId).toBe('drafts.new-2')
|
|
61
|
+
expect(plan.appendField).toBe('concepts')
|
|
62
|
+
expect(plan.create.broader).toEqual([
|
|
63
|
+
{_key: 'broader-key', _ref: 'broader-1', _type: 'reference', _weak: false},
|
|
64
|
+
])
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('plans a child concept in a release with a draft broader (weak broader ref)', () => {
|
|
68
|
+
const plan = planCreateConcept({
|
|
69
|
+
scheme: scheme('versions.rel1.scheme-1'),
|
|
70
|
+
conceptType: 'concept',
|
|
71
|
+
broaderConcept: {id: 'broader-2', _originalId: 'drafts.broader-2'},
|
|
72
|
+
newConceptUuid: 'new-3',
|
|
73
|
+
conceptId: 'ghi789',
|
|
74
|
+
schemeBaseIri: 'https://example.com/',
|
|
75
|
+
newConceptKey: 'ref-key',
|
|
76
|
+
broaderKey: 'broader-key',
|
|
77
|
+
})
|
|
78
|
+
expect(plan.schemeId).toBe('versions.rel1.scheme-1')
|
|
79
|
+
expect(plan.newConceptId).toBe('versions.rel1.new-3')
|
|
80
|
+
expect(plan.reference._ref).toBe('new-3')
|
|
81
|
+
expect(plan.create.broader).toEqual([
|
|
82
|
+
{
|
|
83
|
+
_key: 'broader-key',
|
|
84
|
+
_ref: 'broader-2',
|
|
85
|
+
_type: 'reference',
|
|
86
|
+
_weak: true,
|
|
87
|
+
_strengthenOnPublish: SKOS_CONCEPT_STRENGTHEN,
|
|
88
|
+
},
|
|
89
|
+
])
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
describe('planRemoveConcept', () => {
|
|
94
|
+
it('plans removal of a top concept from a published scheme (draft target)', () => {
|
|
95
|
+
expect(
|
|
96
|
+
planRemoveConcept({scheme: scheme('scheme-1'), conceptRef: 'concept-9', conceptType: 'topConcept'}),
|
|
97
|
+
).toEqual({
|
|
98
|
+
kind: 'remove',
|
|
99
|
+
schemeId: 'drafts.scheme-1',
|
|
100
|
+
createIfNotExists: {_id: 'drafts.scheme-1', _type: 'skosConceptScheme', title: 'Test Scheme'},
|
|
101
|
+
unsetPaths: ['topConcepts[_ref=="concept-9"]'],
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('plans removal of a concept from a scheme in a release (version target)', () => {
|
|
106
|
+
const plan = planRemoveConcept({
|
|
107
|
+
scheme: scheme('versions.rel1.scheme-1'),
|
|
108
|
+
conceptRef: 'concept-9',
|
|
109
|
+
conceptType: 'concept',
|
|
110
|
+
})
|
|
111
|
+
expect(plan.schemeId).toBe('versions.rel1.scheme-1')
|
|
112
|
+
expect(plan.unsetPaths).toEqual(['concepts[_ref=="concept-9"]'])
|
|
113
|
+
})
|
|
114
|
+
})
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import {DocumentId, getPublishedId} from '@sanity/id-utils'
|
|
2
|
+
|
|
3
|
+
import type {ConceptSchemeDocument, SkosConceptDocument, SkosConceptReference} from '../types'
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
conceptReferenceStrength,
|
|
7
|
+
deriveNewConceptId,
|
|
8
|
+
deriveSchemeMutationTarget,
|
|
9
|
+
SKOS_CONCEPT_STRENGTHEN,
|
|
10
|
+
} from './ids'
|
|
11
|
+
|
|
12
|
+
type SchemeDisplayed = ConceptSchemeDocument['displayed']
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* "Describe, don't execute": the create/remove planners below compute a pure
|
|
16
|
+
* description of the transaction a concept mutation should run. The hooks stay
|
|
17
|
+
* thin — they generate the random ids/keys, pass them in, and replay the plan
|
|
18
|
+
* onto client.transaction(). This makes the release/version-aware mutation logic
|
|
19
|
+
* exhaustively unit-testable without a Studio.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export type CreateConceptPlan = {
|
|
23
|
+
kind: 'create'
|
|
24
|
+
schemeId: string
|
|
25
|
+
newConceptId: string
|
|
26
|
+
createIfNotExists: SchemeDisplayed & {_id: string}
|
|
27
|
+
create: SkosConceptDocument
|
|
28
|
+
appendField: 'topConcepts' | 'concepts'
|
|
29
|
+
reference: SkosConceptReference
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function planCreateConcept(input: {
|
|
33
|
+
scheme: SchemeDisplayed
|
|
34
|
+
conceptType: 'topConcept' | 'concept'
|
|
35
|
+
broaderConcept?: {id: string; _originalId: string}
|
|
36
|
+
newConceptUuid: string
|
|
37
|
+
conceptId: string
|
|
38
|
+
schemeBaseIri: string | undefined
|
|
39
|
+
newConceptKey: string
|
|
40
|
+
broaderKey: string
|
|
41
|
+
}): CreateConceptPlan {
|
|
42
|
+
const target = deriveSchemeMutationTarget(input.scheme._id)
|
|
43
|
+
const newConceptId = deriveNewConceptId(input.newConceptUuid, target)
|
|
44
|
+
|
|
45
|
+
const create: SkosConceptDocument = {
|
|
46
|
+
_id: newConceptId,
|
|
47
|
+
_type: 'skosConcept',
|
|
48
|
+
conceptId: input.conceptId,
|
|
49
|
+
prefLabel: '',
|
|
50
|
+
baseIri: input.schemeBaseIri,
|
|
51
|
+
broader: [],
|
|
52
|
+
related: [],
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (input.broaderConcept) {
|
|
56
|
+
create.broader = [
|
|
57
|
+
{
|
|
58
|
+
_key: input.broaderKey,
|
|
59
|
+
_ref: getPublishedId(DocumentId(input.broaderConcept.id)),
|
|
60
|
+
_type: 'reference',
|
|
61
|
+
...conceptReferenceStrength(input.broaderConcept._originalId),
|
|
62
|
+
},
|
|
63
|
+
]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const reference: SkosConceptReference = {
|
|
67
|
+
_ref: getPublishedId(DocumentId(newConceptId)),
|
|
68
|
+
_type: 'reference',
|
|
69
|
+
_key: input.newConceptKey,
|
|
70
|
+
_strengthenOnPublish: SKOS_CONCEPT_STRENGTHEN,
|
|
71
|
+
_weak: true,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
kind: 'create',
|
|
76
|
+
schemeId: target.schemeId,
|
|
77
|
+
newConceptId,
|
|
78
|
+
createIfNotExists: {...input.scheme, _id: target.schemeId},
|
|
79
|
+
create,
|
|
80
|
+
appendField: input.conceptType === 'topConcept' ? 'topConcepts' : 'concepts',
|
|
81
|
+
reference,
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type RemoveConceptPlan = {
|
|
86
|
+
kind: 'remove'
|
|
87
|
+
schemeId: string
|
|
88
|
+
createIfNotExists: SchemeDisplayed & {_id: string}
|
|
89
|
+
unsetPaths: string[]
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function planRemoveConcept(input: {
|
|
93
|
+
scheme: SchemeDisplayed
|
|
94
|
+
conceptRef: string
|
|
95
|
+
conceptType: string
|
|
96
|
+
}): RemoveConceptPlan {
|
|
97
|
+
const field = input.conceptType === 'topConcept' ? 'topConcepts' : 'concepts'
|
|
98
|
+
const {schemeId} = deriveSchemeMutationTarget(input.scheme._id)
|
|
99
|
+
return {
|
|
100
|
+
kind: 'remove',
|
|
101
|
+
schemeId,
|
|
102
|
+
createIfNotExists: {...input.scheme, _id: schemeId},
|
|
103
|
+
unsetPaths: [`${field}[_ref=="${input.conceptRef}"]`],
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type {CreateConceptPlan, RemoveConceptPlan} from './mutations'
|
|
2
|
+
import type {ConceptRecommendation, DocumentConcepts, SemanticSearchConfig} from '../types'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* #### Taxonomy Data Port
|
|
6
|
+
* The single internal seam for everything that touches Sanity Studio or the
|
|
7
|
+
* network: the live concept-tree query, concept-mutation transactions, and
|
|
8
|
+
* semantic term recommendations. Components and mutation hooks talk to this
|
|
9
|
+
* interface instead of `useListeningQuery`/`useClient` directly, so the render
|
|
10
|
+
* tree can be exercised against an in-memory fake (`test/FakeDataPort`) and the
|
|
11
|
+
* implementation can be swapped later (e.g. the App SDK) without touching
|
|
12
|
+
* components.
|
|
13
|
+
*
|
|
14
|
+
* Members are hooks (`use*`): the tree seam wraps Studio's
|
|
15
|
+
* `documentStore.listenQuery` and the mutation/embeddings seams read the Studio
|
|
16
|
+
* client from React context, so they obey the rules of hooks. They are reached
|
|
17
|
+
* via `useTaxonomyDataPort()` — default is the real `StudioDataAdapter`, and
|
|
18
|
+
* tests inject a fake through `TaxonomyPortProvider`.
|
|
19
|
+
*/
|
|
20
|
+
export interface TaxonomyDataPort {
|
|
21
|
+
/** Live concept tree for a scheme document (`trunk`) or a branch within it (`input`). */
|
|
22
|
+
useWatchTree(params: ConceptTreeParams): WatchResult<DocumentConcepts>
|
|
23
|
+
/** Replay a pure create/remove plan (`core/mutations`) onto a Studio transaction. */
|
|
24
|
+
useApplyConceptPlan(): (plan: ConceptPlan) => Promise<void>
|
|
25
|
+
/** Semantic term recommendations for the input components (GROQ `text::semanticSimilarity()`). */
|
|
26
|
+
useSemanticRecommendations(config?: SemanticSearchConfig): SemanticRecommendationsResult
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Perspective intent, mirroring `ReleaseContext` (`selectedPerspectiveName` from
|
|
31
|
+
* `usePerspective()`): `undefined` ⇒ drafts, otherwise the named perspective.
|
|
32
|
+
*/
|
|
33
|
+
export type TaxonomyPerspective = string | undefined
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Which tree to watch. `trunk` is the full scheme hierarchy (the structure Tree
|
|
37
|
+
* View); `input` is a (possibly branch-scoped) tree for the field input dialog.
|
|
38
|
+
*/
|
|
39
|
+
export type ConceptTreeParams =
|
|
40
|
+
| {mode: 'trunk'; documentId: string; perspective: TaxonomyPerspective}
|
|
41
|
+
| {mode: 'input'; documentId: string; branchId: string | null; perspective: TaxonomyPerspective}
|
|
42
|
+
|
|
43
|
+
/** Result shape of a live query: mirrors `useListeningQuery`, with `error` normalized to `Error | null`. */
|
|
44
|
+
export interface WatchResult<T> {
|
|
45
|
+
data: T | null
|
|
46
|
+
loading: boolean
|
|
47
|
+
error: Error | null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A create or remove transaction plan, discriminated by `kind` (see `core/mutations`). */
|
|
51
|
+
export type ConceptPlan = CreateConceptPlan | RemoveConceptPlan
|
|
52
|
+
|
|
53
|
+
/** Return shape of the semantic-recommendations seam. */
|
|
54
|
+
export interface SemanticRecommendationsResult {
|
|
55
|
+
conceptRecs: ConceptRecommendation[]
|
|
56
|
+
recsError: string | null
|
|
57
|
+
/** Run the search, scoping recommendations to the given scheme; a no-op without a `schemeId`. */
|
|
58
|
+
triggerSearch: (schemeId?: string) => void
|
|
59
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import {describe, expect, it} from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {inputBuilder, trunkBuilder} from './queries'
|
|
4
|
+
|
|
5
|
+
// Characterization tests: pin the exact GROQ the builders emit today so the
|
|
6
|
+
// Stage 3 extraction into core/ (and making the depth cap configurable) cannot
|
|
7
|
+
// silently change behavior. The full strings are snapshotted; the structural
|
|
8
|
+
// assertions document the load-bearing parts.
|
|
9
|
+
|
|
10
|
+
describe('trunkBuilder', () => {
|
|
11
|
+
const query = trunkBuilder()
|
|
12
|
+
|
|
13
|
+
it('matches the characterized GROQ output', () => {
|
|
14
|
+
expect(query).toMatchSnapshot()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('selects the scheme by $id and projects topConcepts + concepts, ordered by prefLabel', () => {
|
|
18
|
+
expect(query).toContain('*[_id == $id][0]')
|
|
19
|
+
expect(query).toContain('"topConcepts": topConcepts[]->|order(prefLabel)')
|
|
20
|
+
expect(query).toContain('"concepts": concepts[]->|order(prefLabel)')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('computes isOrphan via array::intersects of broader refs against the scheme refs', () => {
|
|
24
|
+
expect(query).toContain('"isOrphan":')
|
|
25
|
+
expect(query).toContain('array::intersects')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('recurses childConcepts to a depth cap of 6 levels', () => {
|
|
29
|
+
expect(query).toContain('"level": 6')
|
|
30
|
+
expect(query).not.toContain('"level": 7')
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
describe('inputBuilder', () => {
|
|
35
|
+
const query = inputBuilder()
|
|
36
|
+
|
|
37
|
+
it('matches the characterized GROQ output', () => {
|
|
38
|
+
expect(query).toMatchSnapshot()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('branches on the presence of $branchId via select()', () => {
|
|
42
|
+
expect(query).toContain('select($branchId != null =>')
|
|
43
|
+
expect(query).toContain('$branchId in @->broader[]->.conceptId')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('recurses childConcepts to a depth cap of 6 levels', () => {
|
|
47
|
+
expect(query).toContain('"level": 6')
|
|
48
|
+
expect(query).not.toContain('"level": 7')
|
|
49
|
+
})
|
|
50
|
+
})
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Maximum hierarchy depth the builders recurse to. This is the single knob for
|
|
3
|
+
* the depth cap — raising it later (if performance permits and use cases
|
|
4
|
+
* warrant) is a one-line change. Kept at its long-standing value of 6.
|
|
5
5
|
*/
|
|
6
|
+
const MAX_TREE_DEPTH = 6
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* #### Branch Builder
|
|
9
|
-
* Recursive
|
|
10
|
-
*
|
|
10
|
+
* Recursive builder for successive branches of the concept hierarchy, down to
|
|
11
|
+
* `MAX_TREE_DEPTH` levels deep.
|
|
11
12
|
*/
|
|
12
13
|
const branchBuilder = (level = 1): string | void => {
|
|
13
14
|
let reference = '^.^.concepts[]._ref'
|
|
@@ -16,7 +17,7 @@ const branchBuilder = (level = 1): string | void => {
|
|
|
16
17
|
reference = `^.${reference}`
|
|
17
18
|
i++
|
|
18
19
|
}
|
|
19
|
-
if (level >
|
|
20
|
+
if (level > MAX_TREE_DEPTH) {
|
|
20
21
|
return ''
|
|
21
22
|
}
|
|
22
23
|
return `"childConcepts": *[_id in ${reference} && ^._id in broader[]._ref]|order(prefLabel)
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import {describe, expect, it} from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
assembleQueryText,
|
|
5
|
+
recommendationsErrorMessage,
|
|
6
|
+
recommendationsQuery,
|
|
7
|
+
recommendedConceptIds,
|
|
8
|
+
toConceptRecommendations,
|
|
9
|
+
} from './semanticRecommendations'
|
|
10
|
+
|
|
11
|
+
describe('recommendationsQuery', () => {
|
|
12
|
+
const query = recommendationsQuery()
|
|
13
|
+
|
|
14
|
+
it('matches the characterized GROQ output', () => {
|
|
15
|
+
expect(query).toMatchSnapshot()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('scores skosConcepts by semanticSimilarity and returns the top $maxResults', () => {
|
|
19
|
+
expect(query).toContain('_type == "skosConcept"')
|
|
20
|
+
expect(query).toContain('score(text::semanticSimilarity($searchQuery))')
|
|
21
|
+
expect(query).toContain('order(_score desc)')
|
|
22
|
+
expect(query).toContain('[0...$maxResults]')
|
|
23
|
+
expect(query).toContain('"conceptId": _id')
|
|
24
|
+
expect(query).toContain('"score": _score')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it("scopes candidates to the field's scheme via $schemeId membership", () => {
|
|
28
|
+
expect(query).toContain('_type == "skosConceptScheme" && schemeId == $schemeId')
|
|
29
|
+
expect(query).toContain('topConcepts[]._ref')
|
|
30
|
+
expect(query).toContain('concepts[]._ref')
|
|
31
|
+
expect(query).toContain('_id in')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('uses no part of the deprecated Embeddings Index API', () => {
|
|
35
|
+
expect(query).not.toContain('text::embedding')
|
|
36
|
+
expect(query).not.toContain('embeddings-index')
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('assembleQueryText', () => {
|
|
41
|
+
it('joins the non-empty field values with a space, in order', () => {
|
|
42
|
+
const text = assembleQueryText([
|
|
43
|
+
{name: 'title', value: 'Summer'},
|
|
44
|
+
{name: 'description', value: 'reading list'},
|
|
45
|
+
])
|
|
46
|
+
expect(text).toBe('Summer reading list')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('throws naming the single empty field', () => {
|
|
50
|
+
expect(() =>
|
|
51
|
+
assembleQueryText([
|
|
52
|
+
{name: 'title', value: 'Summer'},
|
|
53
|
+
{name: 'description', value: ''},
|
|
54
|
+
])
|
|
55
|
+
).toThrowError('Please fill out the description field to enable match scores.')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('throws listing every empty field when more than one is empty', () => {
|
|
59
|
+
expect(() =>
|
|
60
|
+
assembleQueryText([
|
|
61
|
+
{name: 'title', value: ''},
|
|
62
|
+
{name: 'description', value: ' '},
|
|
63
|
+
])
|
|
64
|
+
).toThrowError(
|
|
65
|
+
'The following fields must be filled out to enable match scores: title, description'
|
|
66
|
+
)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('treats whitespace-only and non-string values as empty', () => {
|
|
70
|
+
expect(() =>
|
|
71
|
+
assembleQueryText([
|
|
72
|
+
{name: 'title', value: ' '},
|
|
73
|
+
{name: 'count', value: 42},
|
|
74
|
+
])
|
|
75
|
+
).toThrowError(
|
|
76
|
+
'The following fields must be filled out to enable match scores: title, count'
|
|
77
|
+
)
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
describe('toConceptRecommendations', () => {
|
|
82
|
+
it('maps rows to {conceptId, score}', () => {
|
|
83
|
+
const recs = toConceptRecommendations([
|
|
84
|
+
{conceptId: 'concept-a', score: 0.9},
|
|
85
|
+
{conceptId: 'concept-b', score: 0.4},
|
|
86
|
+
])
|
|
87
|
+
expect(recs).toEqual([
|
|
88
|
+
{conceptId: 'concept-a', score: 0.9},
|
|
89
|
+
{conceptId: 'concept-b', score: 0.4},
|
|
90
|
+
])
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('normalizes draft and version ids to their published form', () => {
|
|
94
|
+
const recs = toConceptRecommendations([
|
|
95
|
+
{conceptId: 'drafts.concept-a', score: 0.9},
|
|
96
|
+
{conceptId: 'versions.rABC.concept-b', score: 0.4},
|
|
97
|
+
])
|
|
98
|
+
expect(recs).toEqual([
|
|
99
|
+
{conceptId: 'concept-a', score: 0.9},
|
|
100
|
+
{conceptId: 'concept-b', score: 0.4},
|
|
101
|
+
])
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('drops rows missing a string id or numeric score', () => {
|
|
105
|
+
const recs = toConceptRecommendations([
|
|
106
|
+
{conceptId: 'concept-a', score: 0.9},
|
|
107
|
+
{conceptId: '', score: 0.5},
|
|
108
|
+
{conceptId: 'concept-c', score: null as unknown as number},
|
|
109
|
+
null as unknown as {conceptId: string; score: number},
|
|
110
|
+
])
|
|
111
|
+
expect(recs).toEqual([{conceptId: 'concept-a', score: 0.9}])
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('returns an empty array for null or undefined input', () => {
|
|
115
|
+
expect(toConceptRecommendations(null)).toEqual([])
|
|
116
|
+
expect(toConceptRecommendations(undefined)).toEqual([])
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
describe('recommendedConceptIds', () => {
|
|
121
|
+
it('collects the recommended concept ids as a set', () => {
|
|
122
|
+
const ids = recommendedConceptIds([
|
|
123
|
+
{conceptId: 'a', score: 0.9},
|
|
124
|
+
{conceptId: 'b', score: 0.4},
|
|
125
|
+
])
|
|
126
|
+
expect(ids).toEqual(new Set(['a', 'b']))
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('de-duplicates repeated concept ids', () => {
|
|
130
|
+
const ids = recommendedConceptIds([
|
|
131
|
+
{conceptId: 'a', score: 0.9},
|
|
132
|
+
{conceptId: 'a', score: 0.3},
|
|
133
|
+
{conceptId: 'b', score: 0.5},
|
|
134
|
+
])
|
|
135
|
+
expect(ids).toEqual(new Set(['a', 'b']))
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('returns an empty set for no recommendations', () => {
|
|
139
|
+
expect(recommendedConceptIds([]).size).toBe(0)
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
describe('recommendationsErrorMessage', () => {
|
|
144
|
+
it('flags an embeddings-related error as the dataset not being enabled', () => {
|
|
145
|
+
const message = recommendationsErrorMessage(
|
|
146
|
+
new Error('text::semanticSimilarity requires embeddings to be enabled')
|
|
147
|
+
)
|
|
148
|
+
expect(message).toBe("Semantic recommendations aren't enabled for this dataset.")
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('falls back to a generic message for any other failure', () => {
|
|
152
|
+
expect(recommendationsErrorMessage(new Error('Network request failed'))).toBe(
|
|
153
|
+
'Unable to load semantic recommendations.'
|
|
154
|
+
)
|
|
155
|
+
expect(recommendationsErrorMessage('socket hang up')).toBe(
|
|
156
|
+
'Unable to load semantic recommendations.'
|
|
157
|
+
)
|
|
158
|
+
expect(recommendationsErrorMessage(undefined)).toBe('Unable to load semantic recommendations.')
|
|
159
|
+
})
|
|
160
|
+
})
|