@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.
Files changed (31) hide show
  1. package/dist/candidate-search/index.js +40 -10
  2. package/dist/candidate-search/index.js.map +1 -1
  3. package/dist/candidate-search/index.spec.js +16 -2
  4. package/dist/candidate-search/index.spec.js.map +1 -1
  5. package/dist/candidate-search/query-list/bib.js +2 -2
  6. package/dist/candidate-search/query-list/bib.js.map +1 -1
  7. package/dist/index.js +139 -39
  8. package/dist/index.js.map +1 -1
  9. package/dist/index.spec.js +12 -6
  10. package/dist/index.spec.js.map +1 -1
  11. package/dist/match-detection/features/bib/authors.js +1 -1
  12. package/dist/match-detection/features/bib/authors.js.map +1 -1
  13. package/dist/match-detection/features/bib/standard-identifier-factory.js +3 -2
  14. package/dist/match-detection/features/bib/standard-identifier-factory.js.map +1 -1
  15. package/dist/match-detection/index.js +36 -30
  16. package/dist/match-detection/index.js.map +1 -1
  17. package/dist/match-detection/index.spec.js +20 -2
  18. package/dist/match-detection/index.spec.js.map +1 -1
  19. package/dist/matching-utils.js +1 -1
  20. package/dist/matching-utils.js.map +1 -1
  21. package/package.json +16 -15
  22. package/src/candidate-search/index.js +36 -14
  23. package/src/candidate-search/index.spec.js +13 -3
  24. package/src/candidate-search/query-list/bib.js +2 -2
  25. package/src/index.js +94 -35
  26. package/src/index.spec.js +10 -6
  27. package/src/match-detection/features/bib/authors.js +1 -1
  28. package/src/match-detection/features/bib/standard-identifier-factory.js +2 -1
  29. package/src/match-detection/index.js +29 -19
  30. package/src/match-detection/index.spec.js +17 -3
  31. 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
- expect(err.message).to.match(new RegExp(expectedSearchError, 'u'));
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
- // console.log(results); //eslint-disable-line
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 newCandidateCount = candidateCount + recordSetSize;
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
- return {newMatches, newNonMatches};
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
- checkCounts({matches, nonMatches, candidateCount, duplicateCount, nonMatchCount});
156
- const matchStatus = getMatchState(state, stopReason);
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 result = returnNonMatches ? {matches, matchStatus, nonMatches} : {matches, matchStatus};
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 + nonMatchCount + duplicateCount;
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
- function getMatchState(state, stopReason) {
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
- if (state.queriesLeft > 0 || nonRetrieved > 0 || state.maxedQueries.length > 0) {
189
- const maxedQueriesStopReason = state.maxedQueries.length > 0 ? 'maxedQueries' : '';
190
- const newStopReason = stopReason === '' ? maxedQueriesStopReason : stopReason;
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
- function iterateRecords({records, recordSetSize, maxMatches, matches = [], nonMatches = [], recordMatches = [], recordNonMatches = [], recordCount = 0, recordDuplicateCount = 0, recordNonMatchCount = 0}) {
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
- debug(`Running matchDetection for record ${candidateId} (${newRecordCount}/${recordSetSize})`);
228
- const detectionResult = detect(record, candidateRecord);
229
- return handleDetectionResult(detectionResult, candidateId, candidateRecord);
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
- return iterateRecords({records: records.slice(1), recordSetSize, maxMatches, matches, recordMatches, recordCount: newRecordCount, recordNonMatches, recordDuplicateCount: recordDuplicateCount + 1, recordNonMatchCount});
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: recordNonMatchCount};
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(`${matches.length}, ${matchStatus.status}/${matchStatus.stopReason}, ${nonMatches ? nonMatches.length : nonMatches}`);
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
- expect(matchStatus.status).to.eql(expectedMatchStatus);
72
- expect(matchStatus.stopReason).to.eql(expectedStopReason);
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
- .map(({code, value}) => ({code, value: value.replace(/-/ug, '')}));
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
- debugData(`Features (a): ${JSON.stringify(featuresA)}`);
50
- debugData(`Features (b): ${JSON.stringify(featuresB)}`);
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
- const featurePairs = generateFeaturePairs();
53
- const similarityVector = generateSimilarityVector();
51
+ return Array.isArray(recordB) ? detectionResults : detectionResults[0];
54
52
 
55
- if (similarityVector.some(v => v >= minProbabilityQuantifier)) {
56
- const probability = calculateprobability();
57
- debug(`probability: ${probability} (Treshold: ${treshold})`);
58
- return returnResult({match: probability >= treshold, probability});
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
- debugData(`No feature yielded minimum probability amount of points (${minProbabilityQuantifier})`);
62
- return returnResult({match: false, probability: 0.0});
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 extractFeatures(record) {
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
- const recordB = new MarcRecord(getFixture('recordB.json'));
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
 
@@ -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
  }