@natlibfi/melinda-record-matching 2.1.0 → 2.2.0-alpha.3
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/dist/candidate-search/index.js +40 -10
- package/dist/candidate-search/index.js.map +1 -1
- package/dist/candidate-search/index.spec.js +16 -2
- package/dist/candidate-search/index.spec.js.map +1 -1
- package/dist/candidate-search/query-list/bib.js +2 -2
- package/dist/candidate-search/query-list/bib.js.map +1 -1
- package/dist/index.js +139 -39
- package/dist/index.js.map +1 -1
- package/dist/index.spec.js +12 -6
- package/dist/index.spec.js.map +1 -1
- package/dist/match-detection/features/bib/authors.js +1 -1
- package/dist/match-detection/features/bib/authors.js.map +1 -1
- package/dist/match-detection/features/bib/standard-identifier-factory.js +3 -2
- package/dist/match-detection/features/bib/standard-identifier-factory.js.map +1 -1
- package/dist/match-detection/index.js +36 -30
- package/dist/match-detection/index.js.map +1 -1
- package/dist/match-detection/index.spec.js +20 -2
- package/dist/match-detection/index.spec.js.map +1 -1
- package/dist/matching-utils.js +1 -1
- package/dist/matching-utils.js.map +1 -1
- package/package.json +16 -15
- package/src/candidate-search/index.js +36 -14
- package/src/candidate-search/index.spec.js +13 -3
- package/src/candidate-search/query-list/bib.js +2 -2
- package/src/index.js +94 -35
- package/src/index.spec.js +10 -6
- package/src/match-detection/features/bib/authors.js +1 -1
- package/src/match-detection/features/bib/standard-identifier-factory.js +2 -1
- package/src/match-detection/index.js +29 -19
- package/src/match-detection/index.spec.js +17 -3
- package/src/matching-utils.js +1 -1
|
@@ -30,6 +30,7 @@ import {expect} from 'chai';
|
|
|
30
30
|
import {READERS} from '@natlibfi/fixura';
|
|
31
31
|
import generateTests from '@natlibfi/fixugen-http-client';
|
|
32
32
|
import {MarcRecord} from '@natlibfi/marc-record';
|
|
33
|
+
import {Error as MatchingError} from '@natlibfi/melinda-commons';
|
|
33
34
|
import createSearchInterface, {CandidateSearchError} from '.';
|
|
34
35
|
import createDebugLogger from 'debug';
|
|
35
36
|
|
|
@@ -77,7 +78,7 @@ describe('candidate-search', () => {
|
|
|
77
78
|
}
|
|
78
79
|
|
|
79
80
|
// eslint-disable-next-line max-statements
|
|
80
|
-
async function iterate({searchOptions, expectedSearchError, count = 1}) {
|
|
81
|
+
async function iterate({searchOptions, expectedSearchError, expectedErrorStatus, count = 1}) {
|
|
81
82
|
const expectedResults = getFixture(`expectedResults${count}.json`);
|
|
82
83
|
|
|
83
84
|
if (expectedSearchError) { // eslint-disable-line functional/no-conditional-statement
|
|
@@ -85,8 +86,17 @@ describe('candidate-search', () => {
|
|
|
85
86
|
await search(searchOptions);
|
|
86
87
|
throw new Error('Expected an error');
|
|
87
88
|
} catch (err) {
|
|
89
|
+
debug(`Got an error: ${err}`);
|
|
88
90
|
expect(err).to.be.an('error');
|
|
89
|
-
|
|
91
|
+
const errorMessage = err instanceof MatchingError ? err.payload.message : err.message;
|
|
92
|
+
const errorStatus = err instanceof MatchingError ? err.status : undefined;
|
|
93
|
+
debug(`errorMessage: ${errorMessage}, errorStatus: ${errorStatus}`);
|
|
94
|
+
expect(errorMessage).to.match(new RegExp(expectedSearchError, 'u'));
|
|
95
|
+
|
|
96
|
+
if (expectedErrorStatus) {
|
|
97
|
+
expect(errorStatus).to.be(expectedErrorStatus);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
90
100
|
return;
|
|
91
101
|
}
|
|
92
102
|
}
|
|
@@ -98,7 +108,7 @@ describe('candidate-search', () => {
|
|
|
98
108
|
}
|
|
99
109
|
|
|
100
110
|
function formatResults(results) {
|
|
101
|
-
|
|
111
|
+
debug(results); //eslint-disable-line
|
|
102
112
|
return {
|
|
103
113
|
...results,
|
|
104
114
|
records: results.records.map(({record, id}) => ({id, record: record.toObject()}))
|
|
@@ -108,7 +108,7 @@ export function bibSourceIds(record) {
|
|
|
108
108
|
|
|
109
109
|
function removeSourcePrefix(subfieldValue) {
|
|
110
110
|
const sourcePrefixRegex = (/^(?<sourcePrefix>\([A-Za-z0-9-]+\))(?<id>.+)$/u);
|
|
111
|
-
const normalizedValue = subfieldValue.replace(sourcePrefixRegex, '$<id>');
|
|
111
|
+
const normalizedValue = subfieldValue ? subfieldValue.replace(sourcePrefixRegex, '$<id>') : '';
|
|
112
112
|
debugData(`Normalized ${subfieldValue} to ${normalizedValue}`);
|
|
113
113
|
return normalizedValue;
|
|
114
114
|
}
|
|
@@ -116,7 +116,7 @@ export function bibSourceIds(record) {
|
|
|
116
116
|
function normalizeSidSubfieldValue(subfieldValue) {
|
|
117
117
|
debugData(`Normalizing ${subfieldValue}`);
|
|
118
118
|
const normalizeAwayRegex = (/[- ]/u);
|
|
119
|
-
return subfieldValue.replace(normalizeAwayRegex, '');
|
|
119
|
+
return subfieldValue ? subfieldValue.replace(normalizeAwayRegex, '') : '';
|
|
120
120
|
}
|
|
121
121
|
|
|
122
122
|
}
|
package/src/index.js
CHANGED
|
@@ -29,10 +29,11 @@
|
|
|
29
29
|
import createDebugLogger from 'debug';
|
|
30
30
|
import createSearchInterface, * as candidateSearch from './candidate-search';
|
|
31
31
|
import createDetectionInterface, * as matchDetection from './match-detection';
|
|
32
|
+
//import inspect from 'util';
|
|
32
33
|
|
|
33
34
|
export {candidateSearch, matchDetection};
|
|
34
35
|
|
|
35
|
-
export default ({detection: detectionOptions, search: searchOptions, maxMatches = 1, maxCandidates = 25, returnStrategy = false, returnQuery = false, returnNonMatches = false}) => {
|
|
36
|
+
export default ({detection: detectionOptions, search: searchOptions, maxMatches = 1, maxCandidates = 25, returnStrategy = false, returnQuery = false, returnNonMatches = false, returnFailures = false}) => {
|
|
36
37
|
const debug = createDebugLogger('@natlibfi/melinda-record-matching:index');
|
|
37
38
|
const debugData = debug.extend('data');
|
|
38
39
|
|
|
@@ -43,6 +44,8 @@ export default ({detection: detectionOptions, search: searchOptions, maxMatches
|
|
|
43
44
|
debugData(`ReturnStrategy: ${JSON.stringify(returnStrategy)}`);
|
|
44
45
|
debugData(`ReturnQuery: ${JSON.stringify(returnQuery)}`);
|
|
45
46
|
debugData(`ReturnNonMatches: ${JSON.stringify(returnNonMatches)}`);
|
|
47
|
+
debugData(`ReturnFailures: ${JSON.stringify(returnFailures)}`);
|
|
48
|
+
|
|
46
49
|
|
|
47
50
|
const detect = createDetectionInterface(detectionOptions, returnStrategy);
|
|
48
51
|
|
|
@@ -63,13 +66,17 @@ export default ({detection: detectionOptions, search: searchOptions, maxMatches
|
|
|
63
66
|
// state.queryCounter : sequence for current query
|
|
64
67
|
// state.maxedQueries : queries that resulted in more than serverMaxResults hits
|
|
65
68
|
|
|
66
|
-
async function iterate({initialState = {}, matches = [], candidateCount = 0, nonMatches = [], duplicateCount = 0, nonMatchCount = 0}) {
|
|
69
|
+
async function iterate({initialState = {}, matches = [], candidateCount = 0, nonMatches = [], duplicateCount = 0, nonMatchCount = 0, conversionFailures = [], matchErrors = []}) {
|
|
67
70
|
debugData(`Starting next matcher iteration.`);
|
|
68
|
-
const {records, ...state} = await search(initialState);
|
|
71
|
+
const {records, failures, ...state} = await search(initialState);
|
|
69
72
|
|
|
70
|
-
debugData(`Current state: ${JSON.stringify(state)}, matches: ${matches.length}, candidateCount: ${candidateCount}, nonMatches: ${nonMatches.length}`);
|
|
73
|
+
debugData(`Current state: ${JSON.stringify(state)}, matches: ${matches.length}, candidateCount: ${candidateCount}, nonMatches: ${nonMatches.length}, nonMatchCount: ${nonMatchCount}, conversionFailures: ${conversionFailures}, matchErrors: ${matchErrors.length}`);
|
|
71
74
|
const recordSetSize = records.length;
|
|
72
|
-
const
|
|
75
|
+
const failureSetSize = failures.length;
|
|
76
|
+
const newCandidateCount = candidateCount + recordSetSize + failureSetSize;
|
|
77
|
+
|
|
78
|
+
const newConversionFailures = conversionFailures.concat(failures);
|
|
79
|
+
debugData(`Failures: ${failures.length}, ConversionFailures: ${conversionFailures.length}, NewConversionFailures: ${newConversionFailures.length}`);
|
|
73
80
|
|
|
74
81
|
if (recordSetSize > 0) {
|
|
75
82
|
return handleRecordSet();
|
|
@@ -77,32 +84,33 @@ export default ({detection: detectionOptions, search: searchOptions, maxMatches
|
|
|
77
84
|
|
|
78
85
|
if (state.queriesLeft > 0) {
|
|
79
86
|
debug(`Empty record set ${state.searchCounter} for ${state.query}, but there are ${state.queriesLeft} queries left`);
|
|
80
|
-
return iterate({initialState: state, matches, candidateCount: newCandidateCount, nonMatches, duplicateCount});
|
|
87
|
+
return iterate({initialState: state, matches, candidateCount: newCandidateCount, nonMatches, nonMatchCount, duplicateCount, conversionFailures: newConversionFailures, matchErrors});
|
|
81
88
|
}
|
|
82
89
|
|
|
83
90
|
debug(`No (more) candidate records to check, no more queries left, matches: ${matches.length}`);
|
|
84
|
-
return returnResult({matches, state, stopReason: '', nonMatches, candidateCount: newCandidateCount, duplicateCount});
|
|
91
|
+
return returnResult({matches, state, stopReason: '', nonMatches, nonMatchCount, candidateCount: newCandidateCount, duplicateCount, conversionFailures: newConversionFailures, matchErrors});
|
|
85
92
|
|
|
86
93
|
function handleRecordSet() {
|
|
87
94
|
debug(`Checking record set of ${recordSetSize} candidate records for possible matches, found by ${state.searchCounter} search for ${state.query}`);
|
|
88
95
|
|
|
89
|
-
const matchResult = iterateRecords({records, recordSetSize, maxMatches, matches, nonMatches});
|
|
96
|
+
const matchResult = iterateRecords({records, recordSetSize, maxMatches, matches, nonMatches, nonMatchCount});
|
|
97
|
+
|
|
90
98
|
const newDuplicateCount = duplicateCount + matchResult.duplicateCount;
|
|
91
99
|
const newNonMatchCount = nonMatchCount + matchResult.nonMatchCount;
|
|
92
|
-
const {newMatches, newNonMatches} = handleMatchResult(matchResult, matches, nonMatches);
|
|
100
|
+
const {newMatches, newNonMatches, newMatchErrors} = handleMatchResult(matchResult, matches, nonMatches, matchErrors);
|
|
93
101
|
|
|
94
102
|
if (maxMatchesFound({matches: newMatches, maxMatches})) {
|
|
95
|
-
return returnResult({matches: newMatches, state, stopReason: 'maxMatches', nonMatches: newNonMatches, duplicateCount: newDuplicateCount, candidateCount: newCandidateCount, nonMatchCount: newNonMatchCount});
|
|
103
|
+
return returnResult({matches: newMatches, state, stopReason: 'maxMatches', nonMatches: newNonMatches, duplicateCount: newDuplicateCount, candidateCount: newCandidateCount, nonMatchCount: newNonMatchCount, conversionFailures: newConversionFailures, matchErrors: newMatchErrors});
|
|
96
104
|
}
|
|
97
105
|
|
|
98
106
|
if (maxCandidatesRetrieved(newCandidateCount, maxCandidates)) {
|
|
99
|
-
return returnResult({matches: newMatches, state, stopReason: 'maxCandidates', nonMatches: newNonMatches, duplicateCount: newDuplicateCount, candidateCount: newCandidateCount, nonMatchCount: newNonMatchCount});
|
|
107
|
+
return returnResult({matches: newMatches, state, stopReason: 'maxCandidates', nonMatches: newNonMatches, duplicateCount: newDuplicateCount, candidateCount: newCandidateCount, nonMatchCount: newNonMatchCount, conversionFailures: newConversionFailures, matchErrors: newMatchErrors});
|
|
100
108
|
}
|
|
101
109
|
|
|
102
|
-
return iterate({initialState: state, matches: newMatches, candidateCount: newCandidateCount, nonMatches: newNonMatches, duplicateCount: newDuplicateCount, nonMatchCount: newNonMatchCount});
|
|
110
|
+
return iterate({initialState: state, matches: newMatches, candidateCount: newCandidateCount, nonMatches: newNonMatches, duplicateCount: newDuplicateCount, nonMatchCount: newNonMatchCount, conversionFailures: newConversionFailures, matchErrors: newMatchErrors});
|
|
103
111
|
}
|
|
104
112
|
|
|
105
|
-
function handleMatchResult(matchResult, matches, nonMatches) {
|
|
113
|
+
function handleMatchResult(matchResult, matches, nonMatches, matchErrors) {
|
|
106
114
|
debugData(`- Amount of new matches from record set: ${matchResult.matches.length}`);
|
|
107
115
|
// eslint-disable-next-line functional/no-conditional-statement
|
|
108
116
|
if (returnNonMatches) {
|
|
@@ -111,6 +119,7 @@ export default ({detection: detectionOptions, search: searchOptions, maxMatches
|
|
|
111
119
|
|
|
112
120
|
const newMatches = matches.concat(returnQuery ? addQuery(matchResult.matches) : matchResult.matches);
|
|
113
121
|
const newNonMatches = returnNonMatches ? nonMatches.concat(returnQuery ? addQuery(matchResult.nonMatches) : matchResult.nonMatches) : [];
|
|
122
|
+
const newMatchErrors = matchErrors.concat(matchResult.matchErrors);
|
|
114
123
|
|
|
115
124
|
debugData(`- Total amount of matches: ${newMatches.length}`);
|
|
116
125
|
// eslint-disable-next-line functional/no-conditional-statement
|
|
@@ -118,7 +127,12 @@ export default ({detection: detectionOptions, search: searchOptions, maxMatches
|
|
|
118
127
|
debugData(`- Total amount of nonMatches: ${newNonMatches.length}`);
|
|
119
128
|
}
|
|
120
129
|
|
|
121
|
-
|
|
130
|
+
debugData(`MatchResult: ${JSON.stringify(matchResult)}`);
|
|
131
|
+
debugData(`Old matchErrors: ${JSON.stringify(matchErrors)}, matchErrors from matchResult: ${JSON.stringify(matchResult.matchErrors)}, New matchErrors: ${JSON.stringify(newMatchErrors)}`);
|
|
132
|
+
|
|
133
|
+
debugData(`- Total amount of matchErrors: ${newMatchErrors.length}`);
|
|
134
|
+
|
|
135
|
+
return {newMatches, newNonMatches, newMatchErrors};
|
|
122
136
|
}
|
|
123
137
|
|
|
124
138
|
function addQuery(matches) {
|
|
@@ -143,30 +157,36 @@ export default ({detection: detectionOptions, search: searchOptions, maxMatches
|
|
|
143
157
|
// - strategy (if returnStrategy option is true)
|
|
144
158
|
// - treshold (if returnStrategy option is true)
|
|
145
159
|
// - matchQuery (if returnQuery option is true)
|
|
160
|
+
// failures: array of conversionFailures from candidate-search and matchErrors from matchDetection in error format {status, payload: {message, id}} if returnFailures is true
|
|
146
161
|
|
|
147
162
|
// we could have here also returnRecords/returnMatchRecords/returnNonMatchRecord options that could be turned false for not to return actual record data
|
|
148
163
|
|
|
149
164
|
// matchStatus.status: boolean, true if matcher retrieved and handled all found candidate records, false if it did not
|
|
150
|
-
// matchStatus.stopReason: string ('maxMatches','maxCandidates','maxedQueries',empty string/undefined), reason for stopping retrieving or handling the candidate records
|
|
165
|
+
// matchStatus.stopReason: string ('maxMatches','maxCandidates','maxedQueries','conversionFailures', empty string/undefined), reason for stopping retrieving or handling the candidate records
|
|
151
166
|
// - only one stopReason is returned (if there would be several possible stopReasons, stopReason is picked in the above order)
|
|
152
167
|
// - currently stopReason can be non-empty also in cases where status is true, if matcher hit the stop reason when handling the last available candidate record
|
|
153
168
|
|
|
154
|
-
function returnResult({matches, state, stopReason, nonMatches, duplicateCount, candidateCount, nonMatchCount}) {
|
|
155
|
-
|
|
156
|
-
const
|
|
169
|
+
function returnResult({matches, state, stopReason, nonMatches, duplicateCount, candidateCount, nonMatchCount, conversionFailures, matchErrors}) {
|
|
170
|
+
const conversionFailureCount = conversionFailures.length;
|
|
171
|
+
const matchErrorCount = matchErrors.length;
|
|
172
|
+
checkCounts({matches, nonMatches, candidateCount, duplicateCount, nonMatchCount, conversionFailureCount, matchErrorCount});
|
|
173
|
+
const matchStatus = getMatchState(state, stopReason, conversionFailureCount, matchErrorCount);
|
|
157
174
|
// add nonMatches to result only if returnNonMatches is 'true', otherwise nonMatches have not been gathered
|
|
158
|
-
const
|
|
175
|
+
const matchesResult = returnNonMatches ? {matches, matchStatus, nonMatches} : {matches, matchStatus};
|
|
176
|
+
const failures = [...conversionFailures, ...matchErrors];
|
|
177
|
+
const result = returnFailures ? {...matchesResult, conversionFailures: failures} : matchesResult;
|
|
178
|
+
debugData(`ReturnFailures ${returnFailures}`);
|
|
159
179
|
debugData(`${JSON.stringify(result)}`);
|
|
160
180
|
return result;
|
|
161
181
|
|
|
162
182
|
// note that in cases where the matching has been stopped because of maxMatches checkCounts won't (in most cases) match
|
|
163
183
|
|
|
164
|
-
function checkCounts({matches, nonMatches, candidateCount, duplicateCount, nonMatchCount}) {
|
|
184
|
+
function checkCounts({matches, nonMatches, candidateCount, duplicateCount, nonMatchCount, conversionFailureCount, matchErrorCount}) {
|
|
165
185
|
const matchCount = matches.length;
|
|
166
186
|
debugData(`Return nonMatches: ${returnNonMatches}`);
|
|
167
187
|
const chosenNonMatchCount = returnNonMatches ? nonMatches.length : nonMatchCount;
|
|
168
|
-
const totalHandled = matchCount +
|
|
169
|
-
debug(`candidateCount: ${candidateCount}, matches: ${matchCount}, nonMatches: ${chosenNonMatchCount}, duplicateCount: ${duplicateCount}`);
|
|
188
|
+
const totalHandled = matchCount + chosenNonMatchCount + duplicateCount;
|
|
189
|
+
debug(`candidateCount: ${candidateCount}, matches: ${matchCount}, nonMatches: ${chosenNonMatchCount}, duplicateCount: ${duplicateCount}, conversionFailureCount: ${conversionFailureCount}, matchErrorCount: ${matchErrorCount}`);
|
|
170
190
|
debug(`We got result for ${totalHandled} / ${candidateCount} retrieved candidates`);
|
|
171
191
|
if (totalHandled !== candidateCount) {
|
|
172
192
|
debug(`WARNING: Missing results for ${candidateCount - totalHandled} candidates`);
|
|
@@ -175,8 +195,11 @@ export default ({detection: detectionOptions, search: searchOptions, maxMatches
|
|
|
175
195
|
return;
|
|
176
196
|
}
|
|
177
197
|
|
|
178
|
-
|
|
198
|
+
// eslint-disable-next-line max-statements
|
|
199
|
+
function getMatchState(state, stopReason, conversionFailuresCount, matchErrorCount) {
|
|
179
200
|
debugData(`${JSON.stringify(state)}`);
|
|
201
|
+
debug(`We had ${conversionFailuresCount} retrieved candidates that could not be converted.`);
|
|
202
|
+
debug(`We had ${matchErrorCount} retrieved candidates that errored in matchDetection.`);
|
|
180
203
|
debug(`Queries left ${state.queriesLeft}, Searches for current query left: ${state.resultSetOffset && state.resultSetOffset <= state.totalRecords}, non-retrieved records: ${state.totalRecords - state.queryCandidateCounter}, maxedQueries (${state.maxedQueries.length}): ${state.maxedQueries}`);
|
|
181
204
|
|
|
182
205
|
debugData(`StopReason: <${stopReason}>`);
|
|
@@ -185,10 +208,17 @@ export default ({detection: detectionOptions, search: searchOptions, maxMatches
|
|
|
185
208
|
const nonRetrieved = searchesLeft ? state.totalRecords - state.queryCandidateCounter : 0;
|
|
186
209
|
debugData(`nonRetrieved: ${nonRetrieved}`);
|
|
187
210
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
211
|
+
// matchStatus.stopReason: string ('maxMatches','maxCandidates','maxedQueries','conversionFailures', empty string/undefined), reason for stopping retrieving or handling the candidate records
|
|
212
|
+
// 'maxMatches' and 'maxCandidates' are in stopReason, 'maxedQueries', 'conversionFailures' and 'matchErrors' are created here
|
|
213
|
+
|
|
214
|
+
if (state.queriesLeft > 0 || nonRetrieved > 0 || state.maxedQueries.length > 0 || conversionFailureCount > 0 || matchErrorCount > 0) {
|
|
215
|
+
const maxedQueriesStopReason = state.maxedQueries.length > 0 ? 'maxedQueries' : undefined;
|
|
216
|
+
const conversionFailuresStopReason = conversionFailureCount > 0 ? 'conversionFailures' : undefined;
|
|
217
|
+
const matchErrorsStopReason = matchErrorCount > 0 ? 'matchErrors' : undefined;
|
|
218
|
+
const newStopReason = stopReason === '' || stopReason === undefined ? maxedQueriesStopReason || conversionFailuresStopReason || matchErrorsStopReason : stopReason;
|
|
191
219
|
debugData(`MaxedQueriesStopReason: <${maxedQueriesStopReason}>`);
|
|
220
|
+
debugData(`ConversionFailureStopReason <${conversionFailuresStopReason}>`);
|
|
221
|
+
debugData(`MatchErrorsStopReason <${matchErrorsStopReason}>`);
|
|
192
222
|
debugData(`NewStopReason: <${newStopReason}>`);
|
|
193
223
|
debug(`Match status: false`);
|
|
194
224
|
return {status: false, stopReason: newStopReason};
|
|
@@ -199,7 +229,13 @@ export default ({detection: detectionOptions, search: searchOptions, maxMatches
|
|
|
199
229
|
}
|
|
200
230
|
}
|
|
201
231
|
|
|
202
|
-
|
|
232
|
+
// NOTES:
|
|
233
|
+
// - we could optimize by creating the featureSet for the incoming record once and using it for all database/candidateRecords
|
|
234
|
+
// - if creating the featureSet for the incoming record fails we have an unprocessable entity
|
|
235
|
+
// - if creating the featureSet for a candidate record fails we could skip that candidate - but list the case as a detectionFailure, same as conversionFailures
|
|
236
|
+
|
|
237
|
+
// eslint-disable-next-line max-statements
|
|
238
|
+
function iterateRecords({records, recordSetSize, maxMatches, matches = [], nonMatches = [], recordMatches = [], recordNonMatches = [], recordCount = 0, recordDuplicateCount = 0, recordNonMatchCount = 0, recordMatchErrors = []}) {
|
|
203
239
|
|
|
204
240
|
// recordSetSize : total amount of records in the current record set
|
|
205
241
|
// recordCount : amount of records from the current record set that have been handled
|
|
@@ -211,6 +247,7 @@ export default ({detection: detectionOptions, search: searchOptions, maxMatches
|
|
|
211
247
|
// matches : found matches in the current matcher job
|
|
212
248
|
// recordMatches : found matches in the current record set
|
|
213
249
|
// recordNonMatches : found nonMatches in the current record set (only if returnNonMatches setting is true)
|
|
250
|
+
// recordMatchErrors: errored matchDetection in the current record set
|
|
214
251
|
|
|
215
252
|
const [candidate] = records;
|
|
216
253
|
const newRecordCount = candidate ? recordCount + 1 : recordCount;
|
|
@@ -220,19 +257,39 @@ export default ({detection: detectionOptions, search: searchOptions, maxMatches
|
|
|
220
257
|
// Note that if returnNonMatches is false, matcher won't remember candidates that didn't match, so they will be matched again everytime they are retrieved by
|
|
221
258
|
// different candidate search queries. Same candidate search query won't have duplicate records.
|
|
222
259
|
|
|
260
|
+
/* We could optimize and detect all retrieved candidates at once
|
|
261
|
+
const candidateRecords = records.map(record => record.record);
|
|
262
|
+
const recordsIsArray = Array.isArray(candidateRecords);
|
|
263
|
+
debug(`records is an array: ${recordsIsArray}`);
|
|
264
|
+
const result = detect(record, candidateRecords);
|
|
265
|
+
debug(`${JSON.stringify(result)}`);
|
|
266
|
+
*/
|
|
267
|
+
|
|
223
268
|
if (candidate) {
|
|
224
269
|
|
|
270
|
+
// eslint-disable-next-line functional/no-conditional-statement
|
|
225
271
|
if (candidateNotInMatches(matches.concat(nonMatches), candidate)) {
|
|
226
272
|
const {record: candidateRecord, id: candidateId} = candidate;
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
273
|
+
try {
|
|
274
|
+
debug(`Running matchDetection for record ${candidateId} (${newRecordCount}/${recordSetSize})`);
|
|
275
|
+
// we should handle errors from detection somehow - ie. cases where either record or candidateRecord errors
|
|
276
|
+
const detectionResult = detect(record, candidateRecord);
|
|
277
|
+
|
|
278
|
+
return handleDetectionResult(detectionResult, candidateId, candidateRecord);
|
|
279
|
+
} catch (error) {
|
|
280
|
+
debug(`MatchDetection errored: database record ${candidateId}: ${error}`);
|
|
281
|
+
|
|
282
|
+
const matchError = {status: 422, payload: {message: `Matching errored for database record ${candidateId}. ${error.message}.`, id: candidateId}};
|
|
283
|
+
const newRecordMatchErrors = recordMatchErrors.concat(matchError);
|
|
284
|
+
return iterateRecords({records: records.slice(1), recordSetSize, maxMatches, matches, recordMatches, recordCount: newRecordCount, recordNonMatches, recordDuplicateCount, recordNonMatchCount, recordMatchErrors: newRecordMatchErrors});
|
|
285
|
+
}
|
|
230
286
|
}
|
|
231
|
-
|
|
287
|
+
|
|
288
|
+
return iterateRecords({records: records.slice(1), recordSetSize, maxMatches, matches, recordMatches, recordCount: newRecordCount, recordNonMatches, recordDuplicateCount: recordDuplicateCount + 1, recordNonMatchCount, recordMatchErrors});
|
|
232
289
|
}
|
|
233
290
|
|
|
234
291
|
debug(`No more candidates, record set (${recordCount}/${recordSetSize}) done, ${recordMatches.length} matches found, ${recordDuplicateCount} candidates already handled, ${returnNonMatches ? `${recordNonMatches.length}` : `${recordNonMatchCount}`} nonMatches found.`);
|
|
235
|
-
return {matches: recordMatches, nonMatches: returnNonMatches ? recordNonMatches : [], duplicateCount: recordDuplicateCount, nonMatchCount: recordNonMatchCount};
|
|
292
|
+
return {matches: recordMatches, nonMatches: returnNonMatches ? recordNonMatches : [], duplicateCount: recordDuplicateCount, nonMatchCount: recordNonMatchCount, matchErrors: recordMatchErrors};
|
|
236
293
|
|
|
237
294
|
function handleDetectionResult(detectionResult, candidateId, candidateRecord) {
|
|
238
295
|
debugData(`MatchDetection results for ${candidateId} (${newRecordCount}/${recordSetSize}): ${JSON.stringify(detectionResult)}`);
|
|
@@ -262,12 +319,13 @@ export default ({detection: detectionOptions, search: searchOptions, maxMatches
|
|
|
262
319
|
const newRecordNonMatchCount = recordNonMatchCount + 1;
|
|
263
320
|
debugData(`- Total nonMatches after this detection: ${newRecordNonMatchCount}`);
|
|
264
321
|
|
|
265
|
-
return iterateRecords({records: records.slice(1), recordSetSize, maxMatches, matches, recordMatches, recordCount: newRecordCount, recordNonMatches, recordDuplicateCount, recordNonMatchCount: newRecordNonMatchCount});
|
|
322
|
+
return iterateRecords({records: records.slice(1), recordSetSize, maxMatches, matches, recordMatches, recordCount: newRecordCount, recordNonMatches, recordDuplicateCount, recordNonMatchCount: newRecordNonMatchCount, recordMatchErrors});
|
|
266
323
|
}
|
|
267
324
|
|
|
268
325
|
function handleRecordMatch(isMatch, newMatch) {
|
|
269
326
|
const newRecordMatches = isMatch ? recordMatches.concat(newMatch) : recordMatches;
|
|
270
327
|
const newRecordNonMatches = isMatch ? recordNonMatches : recordNonMatches.concat(newMatch);
|
|
328
|
+
const newRecordNonMatchCount = isMatch ? recordNonMatchCount : recordNonMatchCount + 1;
|
|
271
329
|
|
|
272
330
|
debugData(`- Total matches after this detection: ${matches.concat(newRecordMatches).length} (max: ${maxMatches})`);
|
|
273
331
|
|
|
@@ -275,13 +333,14 @@ export default ({detection: detectionOptions, search: searchOptions, maxMatches
|
|
|
275
333
|
if (returnNonMatches) {
|
|
276
334
|
debugData(`- Total nonMatches after this detection: ${nonMatches.concat(newRecordNonMatches).length}`);
|
|
277
335
|
}
|
|
336
|
+
debugData(`- Total nonMatchCount after this detection: ${recordNonMatchCount}`);
|
|
278
337
|
|
|
279
338
|
if (maxMatchesFound({matches: matches.concat(newRecordMatches), maxMatches})) {
|
|
280
339
|
debug(`MaxMatches (${maxMatches}) reached, handled candidates in record set: ${newRecordCount} non-handled candidates in record set ${recordSetSize - newRecordCount}`);
|
|
281
|
-
return {matches: newRecordMatches, nonMatches: returnNonMatches ? newRecordNonMatches : [], duplicateCount: recordDuplicateCount, nonMatchCount:
|
|
340
|
+
return {matches: newRecordMatches, nonMatches: returnNonMatches ? newRecordNonMatches : [], duplicateCount: recordDuplicateCount, nonMatchCount: newRecordNonMatchCount, matchErrors: recordMatchErrors};
|
|
282
341
|
}
|
|
283
342
|
|
|
284
|
-
return iterateRecords({records: records.slice(1), recordSetSize, maxMatches, matches, recordMatches: newRecordMatches, recordCount: newRecordCount, recordNonMatches: returnNonMatches ? newRecordNonMatches : [], duplicateCount: recordDuplicateCount, recordNonMatchCount});
|
|
343
|
+
return iterateRecords({records: records.slice(1), recordSetSize, maxMatches, matches, recordMatches: newRecordMatches, recordCount: newRecordCount, recordNonMatches: returnNonMatches ? newRecordNonMatches : [], duplicateCount: recordDuplicateCount, recordNonMatchCount: newRecordNonMatchCount, matchErrors: recordMatchErrors});
|
|
285
344
|
}
|
|
286
345
|
|
|
287
346
|
function candidateNotInMatches(matches, candidate) {
|
package/src/index.spec.js
CHANGED
|
@@ -46,7 +46,7 @@ describe('INDEX', () => {
|
|
|
46
46
|
}
|
|
47
47
|
});
|
|
48
48
|
|
|
49
|
-
async function callback({getFixture, options, enabled = true, expectedMatchStatus, expectedStopReason}) {
|
|
49
|
+
async function callback({getFixture, options, enabled = true, expectedMatchStatus, expectedStopReason, expectedFailures}) {
|
|
50
50
|
|
|
51
51
|
if (!enabled) {
|
|
52
52
|
debug(`Disabled test!`);
|
|
@@ -59,8 +59,11 @@ describe('INDEX', () => {
|
|
|
59
59
|
|
|
60
60
|
|
|
61
61
|
const match = createMatchInterface(formatOptions());
|
|
62
|
-
const {matches, matchStatus, nonMatches} = await match(record);
|
|
63
|
-
debugData(
|
|
62
|
+
const {matches, matchStatus, nonMatches, conversionFailures} = await match(record);
|
|
63
|
+
debugData(`Matches: ${matches.length}, Status: ${matchStatus.status}/${matchStatus.stopReason}, NonMatches: ${nonMatches ? nonMatches.length : 'not returned'}, ConversionFailures: ${conversionFailures ? conversionFailures.length : 'not returned'}`);
|
|
64
|
+
|
|
65
|
+
expect(matchStatus.status).to.eql(expectedMatchStatus);
|
|
66
|
+
expect(matchStatus.stopReason).to.eql(expectedStopReason);
|
|
64
67
|
|
|
65
68
|
const formattedMatchResult = formatRecordResults(matches);
|
|
66
69
|
expect(formattedMatchResult).to.eql(expectedMatches);
|
|
@@ -68,9 +71,10 @@ describe('INDEX', () => {
|
|
|
68
71
|
const formattedNonMatchResult = formatRecordResults(nonMatches);
|
|
69
72
|
expect(formattedNonMatchResult).to.eql(expectedNonMatches);
|
|
70
73
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
+
// eslint-disable-next-line functional/no-conditional-statement
|
|
75
|
+
if (expectedFailures) {
|
|
76
|
+
expect(conversionFailures).to.eql(expectedFailures);
|
|
77
|
+
}
|
|
74
78
|
|
|
75
79
|
function formatOptions() {
|
|
76
80
|
const contextFeatures = matchDetection.features[options.detection.strategy.type];
|
|
@@ -40,7 +40,7 @@ export default ({nameTreshold = 10} = {}) => ({
|
|
|
40
40
|
|
|
41
41
|
function toObj({code, value}) {
|
|
42
42
|
if (code === 'a') {
|
|
43
|
-
return {name: value.replace(/[^\p{Letter}\p{Number}]/gu, '').toLowerCase()};
|
|
43
|
+
return {name: value ? value.replace(/[^\p{Letter}\p{Number}]/gu, '').toLowerCase() : ''};
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
return {id: value};
|
|
@@ -40,7 +40,8 @@ export default ({pattern, subfieldCodes}) => {
|
|
|
40
40
|
if (field) {
|
|
41
41
|
return field.subfields
|
|
42
42
|
.filter(({code}) => subfieldCodes.includes(code))
|
|
43
|
-
|
|
43
|
+
// .map(({code, value}) => ({code, value: value.replace(/-/ug, '')}));
|
|
44
|
+
.map(({code, value}) => ({code, value: value ? value.replace(/-/ug, '') : ''}));
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
return [];
|
|
@@ -32,7 +32,6 @@ import * as features from './features';
|
|
|
32
32
|
|
|
33
33
|
export {features};
|
|
34
34
|
|
|
35
|
-
// eslint-disable-next-line max-statements
|
|
36
35
|
export default ({strategy, treshold = 0.9}, returnStrategy = false) => (recordA, recordB) => {
|
|
37
36
|
const minProbabilityQuantifier = 0.5;
|
|
38
37
|
|
|
@@ -44,22 +43,36 @@ export default ({strategy, treshold = 0.9}, returnStrategy = false) => (recordA,
|
|
|
44
43
|
debugData(`ReturnStrategy: ${JSON.stringify(returnStrategy)}`);
|
|
45
44
|
|
|
46
45
|
const featuresA = extractFeatures(recordA);
|
|
47
|
-
const featuresB = extractFeatures(recordB);
|
|
48
46
|
|
|
49
|
-
|
|
50
|
-
|
|
47
|
+
debug(`We got an array of records: ${Array.isArray(recordB)}`);
|
|
48
|
+
const recordsB = Array.isArray(recordB) ? recordB : [recordB];
|
|
49
|
+
const detectionResults = recordsB.map(record => actualDetection(featuresA, record));
|
|
51
50
|
|
|
52
|
-
|
|
53
|
-
const similarityVector = generateSimilarityVector();
|
|
51
|
+
return Array.isArray(recordB) ? detectionResults : detectionResults[0];
|
|
54
52
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
53
|
+
function actualDetection(featuresA, record) {
|
|
54
|
+
|
|
55
|
+
const featuresB = extractFeatures(record);
|
|
56
|
+
|
|
57
|
+
debugData(`Features (a): ${JSON.stringify(featuresA)}`);
|
|
58
|
+
debugData(`Features (b): ${JSON.stringify(featuresB)}`);
|
|
59
|
+
|
|
60
|
+
const featurePairs = generateFeaturePairs(featuresA, featuresB);
|
|
61
|
+
const similarityVector = generateSimilarityVector(featurePairs);
|
|
62
|
+
|
|
63
|
+
if (similarityVector.some(v => v >= minProbabilityQuantifier)) {
|
|
64
|
+
const probability = calculateprobability(similarityVector);
|
|
65
|
+
debug(`probability: ${probability} (Treshold: ${treshold})`);
|
|
66
|
+
return returnResult({match: probability >= treshold, probability});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
debugData(`No feature yielded minimum probability amount of points (${minProbabilityQuantifier})`);
|
|
70
|
+
return returnResult({match: false, probability: 0.0});
|
|
59
71
|
}
|
|
60
72
|
|
|
61
|
-
|
|
62
|
-
|
|
73
|
+
function extractFeatures(record) {
|
|
74
|
+
return strategy.reduce((acc, {name, extract}) => acc.concat({name, value: extract(record)}), []);
|
|
75
|
+
}
|
|
63
76
|
|
|
64
77
|
function returnResult(result) {
|
|
65
78
|
if (returnStrategy) {
|
|
@@ -76,16 +89,12 @@ export default ({strategy, treshold = 0.9}, returnStrategy = false) => (recordA,
|
|
|
76
89
|
return strategyNames || [];
|
|
77
90
|
}
|
|
78
91
|
|
|
79
|
-
function calculateprobability() {
|
|
92
|
+
function calculateprobability(similarityVector) {
|
|
80
93
|
const probability = similarityVector.reduce((acc, v) => acc + v, 0.0);
|
|
81
94
|
return probability > 1.0 ? 1.0 : probability;
|
|
82
95
|
}
|
|
83
96
|
|
|
84
|
-
function
|
|
85
|
-
return strategy.reduce((acc, {name, extract}) => acc.concat({name, value: extract(record)}), []);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function generateSimilarityVector() {
|
|
97
|
+
function generateSimilarityVector(featurePairs) {
|
|
89
98
|
const compared = featurePairs.map(({name, a, b}) => {
|
|
90
99
|
const {compare} = strategy.find(({name: featureName}) => name === featureName);
|
|
91
100
|
const points = compare(a, b);
|
|
@@ -96,7 +105,7 @@ export default ({strategy, treshold = 0.9}, returnStrategy = false) => (recordA,
|
|
|
96
105
|
return compared.map(({points}) => points);
|
|
97
106
|
}
|
|
98
107
|
|
|
99
|
-
function generateFeaturePairs() {
|
|
108
|
+
function generateFeaturePairs(featuresA, featuresB) {
|
|
100
109
|
const pairs = generatePairs();
|
|
101
110
|
const missingFeatures = findMissing();
|
|
102
111
|
|
|
@@ -125,4 +134,5 @@ export default ({strategy, treshold = 0.9}, returnStrategy = false) => (recordA,
|
|
|
125
134
|
.filter(v => pairs.some(({name}) => name === v) === false);
|
|
126
135
|
}
|
|
127
136
|
}
|
|
137
|
+
|
|
128
138
|
};
|
|
@@ -32,6 +32,11 @@ import {expect} from 'chai';
|
|
|
32
32
|
import {MarcRecord} from '@natlibfi/marc-record';
|
|
33
33
|
import * as features from './features';
|
|
34
34
|
import createDetectionInterface from '.';
|
|
35
|
+
import {inspect} from 'util';
|
|
36
|
+
import createDebugLogger from 'debug';
|
|
37
|
+
|
|
38
|
+
const debug = createDebugLogger('@natlibfi/melinda-record-matching:match-detection:test');
|
|
39
|
+
const debugData = debug.extend('data');
|
|
35
40
|
|
|
36
41
|
describe('match-detection', () => {
|
|
37
42
|
generateTests({
|
|
@@ -41,16 +46,25 @@ describe('match-detection', () => {
|
|
|
41
46
|
fixura: {
|
|
42
47
|
reader: READERS.JSON
|
|
43
48
|
},
|
|
44
|
-
callback: ({getFixture, options, expectedResults, enabled = true}) => {
|
|
49
|
+
callback: ({getFixture, options, expectedResults, array, enabled = true}) => {
|
|
45
50
|
|
|
46
51
|
if (!enabled) {
|
|
52
|
+
debug(`*** DISABLED TEST! ***`);
|
|
47
53
|
return;
|
|
48
54
|
}
|
|
49
55
|
|
|
50
56
|
const detect = createDetectionInterface(formatOptions());
|
|
51
|
-
const recordA = new MarcRecord(getFixture('recordA.json'));
|
|
52
|
-
|
|
57
|
+
const recordA = new MarcRecord(getFixture('recordA.json'), {subfieldValues: false});
|
|
58
|
+
debugData(inspect(recordA));
|
|
59
|
+
|
|
60
|
+
debug(`Our recordB is an array of records: ${array}`);
|
|
61
|
+
const recordB = array
|
|
62
|
+
? getFixture('recordB.json').map(recordJson => new MarcRecord(recordJson, {subfieldValues: false}))
|
|
63
|
+
: new MarcRecord(getFixture('recordB.json'), {subfieldValues: false});
|
|
64
|
+
debugData(inspect(recordB));
|
|
65
|
+
|
|
53
66
|
const results = detect(recordA, recordB);
|
|
67
|
+
debugData(`${JSON.stringify(results)}`);
|
|
54
68
|
|
|
55
69
|
expect(results).to.eql(expectedResults);
|
|
56
70
|
|
package/src/matching-utils.js
CHANGED
|
@@ -59,7 +59,7 @@ export function getMelindaIdsF035(record) {
|
|
|
59
59
|
return subfields
|
|
60
60
|
.filter(sub => ['a', 'z'].includes(sub.code))
|
|
61
61
|
.filter(sub => melindaIdRegExp.test(sub.value))
|
|
62
|
-
.map(({value}) => value.replace(melindaIdRegExp, '$<id>'));
|
|
62
|
+
.map(({value}) => value ? value.replace(melindaIdRegExp, '$<id>') : '');
|
|
63
63
|
|
|
64
64
|
}
|
|
65
65
|
}
|