@natlibfi/melinda-record-matching 2.0.1-alpha.1 → 2.0.7-alpha.1

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 (96) hide show
  1. package/.github/CODEOWNERS +2 -0
  2. package/.github/dependabot.yml +40 -0
  3. package/.github/workflows/melinda-node-tests.yml +70 -0
  4. package/README.md +1 -1
  5. package/dist/candidate-search/candidate-search-utils.js +3 -6
  6. package/dist/candidate-search/candidate-search-utils.js.map +1 -1
  7. package/dist/candidate-search/index.js +115 -50
  8. package/dist/candidate-search/index.js.map +1 -1
  9. package/dist/candidate-search/index.spec.js +41 -48
  10. package/dist/candidate-search/index.spec.js.map +1 -1
  11. package/dist/candidate-search/query-list/bib.js +154 -64
  12. package/dist/candidate-search/query-list/bib.js.map +1 -1
  13. package/dist/candidate-search/query-list/bib.spec.js +1 -10
  14. package/dist/candidate-search/query-list/bib.spec.js.map +1 -1
  15. package/dist/candidate-search/query-list/index.js +4 -10
  16. package/dist/candidate-search/query-list/index.js.map +1 -1
  17. package/dist/index.js +498 -50
  18. package/dist/index.js.map +1 -1
  19. package/dist/index.spec.js +60 -34
  20. package/dist/index.spec.js.map +1 -1
  21. package/dist/match-detection/features/bib/all-source-ids.js +16 -19
  22. package/dist/match-detection/features/bib/all-source-ids.js.map +1 -1
  23. package/dist/match-detection/features/bib/authors.js +31 -35
  24. package/dist/match-detection/features/bib/authors.js.map +1 -1
  25. package/dist/match-detection/features/bib/bibliographic-level.js +3 -3
  26. package/dist/match-detection/features/bib/bibliographic-level.js.map +1 -1
  27. package/dist/match-detection/features/bib/host-component.js +3 -3
  28. package/dist/match-detection/features/bib/host-component.js.map +1 -1
  29. package/dist/match-detection/features/bib/index.js +28 -13
  30. package/dist/match-detection/features/bib/index.js.map +1 -1
  31. package/dist/match-detection/features/bib/index.spec.js +9 -13
  32. package/dist/match-detection/features/bib/index.spec.js.map +1 -1
  33. package/dist/match-detection/features/bib/isbn.js +28 -6
  34. package/dist/match-detection/features/bib/isbn.js.map +1 -1
  35. package/dist/match-detection/features/bib/issn.js +0 -4
  36. package/dist/match-detection/features/bib/issn.js.map +1 -1
  37. package/dist/match-detection/features/bib/language.js +69 -41
  38. package/dist/match-detection/features/bib/language.js.map +1 -1
  39. package/dist/match-detection/features/bib/media-type.js +83 -0
  40. package/dist/match-detection/features/bib/media-type.js.map +1 -0
  41. package/dist/match-detection/features/bib/melinda-id.js +0 -4
  42. package/dist/match-detection/features/bib/melinda-id.js.map +1 -1
  43. package/dist/match-detection/features/bib/melinda-identifier-factory.js +6 -17
  44. package/dist/match-detection/features/bib/melinda-identifier-factory.js.map +1 -1
  45. package/dist/match-detection/features/bib/other-standard-identifier.js +1 -4
  46. package/dist/match-detection/features/bib/other-standard-identifier.js.map +1 -1
  47. package/dist/match-detection/features/bib/publication-time-allow-cons-years-multi.js +256 -0
  48. package/dist/match-detection/features/bib/publication-time-allow-cons-years-multi.js.map +1 -0
  49. package/dist/match-detection/features/bib/publication-time-allow-cons-years.js +71 -0
  50. package/dist/match-detection/features/bib/publication-time-allow-cons-years.js.map +1 -0
  51. package/dist/match-detection/features/bib/publication-time.js +9 -8
  52. package/dist/match-detection/features/bib/publication-time.js.map +1 -1
  53. package/dist/match-detection/features/bib/record-type.js +6 -3
  54. package/dist/match-detection/features/bib/record-type.js.map +1 -1
  55. package/dist/match-detection/features/bib/standard-identifier-factory.js +129 -39
  56. package/dist/match-detection/features/bib/standard-identifier-factory.js.map +1 -1
  57. package/dist/match-detection/features/bib/title-version-original.js +79 -0
  58. package/dist/match-detection/features/bib/title-version-original.js.map +1 -0
  59. package/dist/match-detection/features/bib/title.js +29 -22
  60. package/dist/match-detection/features/bib/title.js.map +1 -1
  61. package/dist/match-detection/features/index.js +0 -4
  62. package/dist/match-detection/features/index.js.map +1 -1
  63. package/dist/match-detection/index.js +94 -47
  64. package/dist/match-detection/index.js.map +1 -1
  65. package/dist/match-detection/index.spec.js +25 -15
  66. package/dist/match-detection/index.spec.js.map +1 -1
  67. package/dist/matching-utils.js +63 -15
  68. package/dist/matching-utils.js.map +1 -1
  69. package/package.json +27 -24
  70. package/src/candidate-search/index.js +93 -31
  71. package/src/candidate-search/index.spec.js +27 -24
  72. package/src/candidate-search/query-list/bib.js +149 -22
  73. package/src/candidate-search/query-list/index.js +1 -1
  74. package/src/index.js +310 -38
  75. package/src/index.spec.js +49 -21
  76. package/src/match-detection/features/bib/all-source-ids.js +3 -3
  77. package/src/match-detection/features/bib/authors.js +23 -16
  78. package/src/match-detection/features/bib/bibliographic-level.js +1 -1
  79. package/src/match-detection/features/bib/host-component.js +1 -1
  80. package/src/match-detection/features/bib/index.js +4 -0
  81. package/src/match-detection/features/bib/index.spec.js +10 -2
  82. package/src/match-detection/features/bib/isbn.js +21 -2
  83. package/src/match-detection/features/bib/language.js +63 -22
  84. package/src/match-detection/features/bib/media-type.js +77 -0
  85. package/src/match-detection/features/bib/melinda-identifier-factory.js +2 -3
  86. package/src/match-detection/features/bib/other-standard-identifier.js +2 -0
  87. package/src/match-detection/features/bib/publication-time-allow-cons-years-multi.js +268 -0
  88. package/src/match-detection/features/bib/publication-time-allow-cons-years.js +67 -0
  89. package/src/match-detection/features/bib/publication-time.js +9 -4
  90. package/src/match-detection/features/bib/record-type.js +5 -1
  91. package/src/match-detection/features/bib/standard-identifier-factory.js +107 -22
  92. package/src/match-detection/features/bib/title-version-original.js +78 -0
  93. package/src/match-detection/features/bib/title.js +27 -7
  94. package/src/match-detection/index.js +60 -22
  95. package/src/match-detection/index.spec.js +18 -4
  96. package/src/matching-utils.js +62 -5
package/src/index.js CHANGED
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * Melinda record matching modules for Javascript
6
6
  *
7
- * Copyright (C) 2020 University Of Helsinki (The National Library Of Finland)
7
+ * Copyright (C) 2020-2022 University Of Helsinki (The National Library Of Finland)
8
8
  *
9
9
  * This file is part of melinda-record-matching-js
10
10
  *
@@ -29,68 +29,340 @@
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}) => {
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
- const detect = createDetectionInterface(detectionOptions);
38
+ const debugData = debug.extend('data');
38
39
 
39
- return record => {
40
- const search = createSearchInterface({...searchOptions, record});
41
- return iterate();
40
+ debugData(`DetectionOptions: ${JSON.stringify(detectionOptions)}`);
41
+ debugData(`SearchOptions: ${JSON.stringify(searchOptions)}`);
42
+ debugData(`MaxMatches: ${JSON.stringify(maxMatches)}`);
43
+ debugData(`MaxCandidates: ${JSON.stringify(maxCandidates)}`);
44
+ debugData(`ReturnStrategy: ${JSON.stringify(returnStrategy)}`);
45
+ debugData(`ReturnQuery: ${JSON.stringify(returnQuery)}`);
46
+ debugData(`ReturnNonMatches: ${JSON.stringify(returnNonMatches)}`);
47
+ debugData(`ReturnFailures: ${JSON.stringify(returnFailures)}`);
42
48
 
43
- // eslint-disable-next-line max-statements
44
- async function iterate(initialState = {}, matches = [], candidateCount = 0) {
45
- const {records, ...state} = await search(initialState);
46
49
 
47
- if (records.length > 0 || state.queriesLeft > 0) {
48
- debug(`Checking ${records.length} candidates for matches`);
50
+ const detect = createDetectionInterface(detectionOptions, returnStrategy);
49
51
 
50
- const matchResult = iterateRecords(records);
52
+ return ({record, recordExternal = {recordSource: 'incomingRecord', label: 'ic'}}) => {
51
53
 
52
- if (matchResult) {
53
- const newMatches = matches.concat(matchResult);
54
+ const search = createSearchInterface({...searchOptions, record, maxCandidates, recordExternal});
55
+ return iterate({});
54
56
 
55
- if (newMatches.length === maxMatches) {
56
- return newMatches;
57
- }
57
+ // candidateCount : amount of candidate records retrived from SRU for matching, NOT including current record set
58
+ // matches : candidates that have been detected as matches by current matcher job
59
+ // nonMatches : candidates that have been detected as non-matches by current matcher job (only if returnNonMatches is 'true')
60
+ // duplicateCount : amount of candidate records that were retrieved from the SRU but not handled further because they were already found in the matches/nonMatches
61
+
62
+ // state.totalRecords : amount of candidate records available to the current query (undefined, if there was no queries left)
63
+ // state.query : current query (undefined if there was no queries left)
64
+ // state.searchCounter : sequence for current search for current query (undefined, if there we no queries left)
65
+ // state.queryCandidateCounter: amount of candidate records retrieved from SRU for matching for current query, including the current record set (undefined if there were no queries left)
66
+ // state.queriesLeft : amount of queries left
67
+ // state.queryCounter : sequence for current query
68
+ // state.maxedQueries : queries that resulted in more than serverMaxResults hits
69
+
70
+ async function iterate({initialState = {}, matches = [], candidateCount = 0, nonMatches = [], duplicateCount = 0, nonMatchCount = 0, conversionFailures = [], matchErrors = []}) {
71
+ debugData(`Starting next matcher iteration.`);
72
+ const {records, failures, ...state} = await search(initialState);
73
+
74
+ debugData(`Current state: ${JSON.stringify(state)}, matches: ${matches.length}, candidateCount: ${candidateCount}, nonMatches: ${nonMatches.length}, nonMatchCount: ${nonMatchCount}, conversionFailures: ${conversionFailures}, matchErrors: ${matchErrors.length}`);
75
+ const recordSetSize = records.length;
76
+ const failureSetSize = failures.length;
77
+ const newCandidateCount = candidateCount + recordSetSize + failureSetSize;
78
+
79
+ const newConversionFailures = conversionFailures.concat(failures);
80
+ debugData(`Failures: ${failures.length}, ConversionFailures: ${conversionFailures.length}, NewConversionFailures: ${newConversionFailures.length}`);
81
+
82
+ if (recordSetSize > 0) {
83
+ return handleRecordSet();
84
+ }
85
+
86
+ if (state.queriesLeft > 0) {
87
+ debug(`Empty record set ${state.searchCounter} for ${state.query}, but there are ${state.queriesLeft} queries left`);
88
+ return iterate({initialState: state, matches, candidateCount: newCandidateCount, nonMatches, nonMatchCount, duplicateCount, conversionFailures: newConversionFailures, matchErrors});
89
+ }
90
+
91
+ debug(`No (more) candidate records to check, no more queries left, matches: ${matches.length}`);
92
+ return returnResult({matches, state, stopReason: '', nonMatches, nonMatchCount, candidateCount: newCandidateCount, duplicateCount, conversionFailures: newConversionFailures, matchErrors});
93
+
94
+ function handleRecordSet() {
95
+ debug(`Checking record set of ${recordSetSize} candidate records for possible matches, found by ${state.searchCounter} search for ${state.query}`);
96
+
97
+ const matchResult = iterateRecords({records, recordSetSize, maxMatches, matches, nonMatches, nonMatchCount});
98
+
99
+ const newDuplicateCount = duplicateCount + matchResult.duplicateCount;
100
+ const newNonMatchCount = nonMatchCount + matchResult.nonMatchCount;
101
+ const {newMatches, newNonMatches, newMatchErrors} = handleMatchResult(matchResult, matches, nonMatches, matchErrors);
102
+
103
+ if (maxMatchesFound({matches: newMatches, maxMatches})) {
104
+ return returnResult({matches: newMatches, state, stopReason: 'maxMatches', nonMatches: newNonMatches, duplicateCount: newDuplicateCount, candidateCount: newCandidateCount, nonMatchCount: newNonMatchCount, conversionFailures: newConversionFailures, matchErrors: newMatchErrors});
105
+ }
106
+
107
+ if (maxCandidatesRetrieved(newCandidateCount, maxCandidates)) {
108
+ return returnResult({matches: newMatches, state, stopReason: 'maxCandidates', nonMatches: newNonMatches, duplicateCount: newDuplicateCount, candidateCount: newCandidateCount, nonMatchCount: newNonMatchCount, conversionFailures: newConversionFailures, matchErrors: newMatchErrors});
109
+ }
110
+
111
+ return iterate({initialState: state, matches: newMatches, candidateCount: newCandidateCount, nonMatches: newNonMatches, duplicateCount: newDuplicateCount, nonMatchCount: newNonMatchCount, conversionFailures: newConversionFailures, matchErrors: newMatchErrors});
112
+ }
58
113
 
59
- return maxCandidatesRetrieved() ? newMatches : iterate(state, newMatches, candidateCount + records.length);
114
+ function handleMatchResult(matchResult, matches, nonMatches, matchErrors) {
115
+ debugData(`- Amount of new matches from record set: ${matchResult.matches.length}`);
116
+ // eslint-disable-next-line functional/no-conditional-statements
117
+ if (returnNonMatches) {
118
+ debugData(`- Amount of new nonMatches from record set: ${matchResult.nonMatches.length}`);
60
119
  }
61
120
 
62
- return maxCandidatesRetrieved() ? matches : iterate(state, matches, candidateCount + records.length);
121
+ const newMatches = matches.concat(returnQuery ? addQuery(matchResult.matches) : matchResult.matches);
122
+ const newNonMatches = returnNonMatches ? nonMatches.concat(returnQuery ? addQuery(matchResult.nonMatches) : matchResult.nonMatches) : [];
123
+ const newMatchErrors = matchErrors.concat(matchResult.matchErrors);
124
+
125
+ debugData(`- Total amount of matches: ${newMatches.length}`);
126
+ // eslint-disable-next-line functional/no-conditional-statements
127
+ if (returnNonMatches) {
128
+ debugData(`- Total amount of nonMatches: ${newNonMatches.length}`);
129
+ }
130
+
131
+ debugData(`MatchResult: ${JSON.stringify(matchResult)}`);
132
+ debugData(`Old matchErrors: ${JSON.stringify(matchErrors)}, matchErrors from matchResult: ${JSON.stringify(matchResult.matchErrors)}, New matchErrors: ${JSON.stringify(newMatchErrors)}`);
133
+
134
+ debugData(`- Total amount of matchErrors: ${newMatchErrors.length}`);
135
+
136
+ return {newMatches, newNonMatches, newMatchErrors};
63
137
  }
64
138
 
65
- debug(`No (more) candidate records to check, matches: ${matches.length}`);
66
- return matches;
139
+ function addQuery(matches) {
140
+ debugData(`Adding query ${state.query} to matches`);
141
+ return matches.map((match) => ({...match, matchQuery: state.query}));
142
+ }
67
143
 
68
- function maxCandidatesRetrieved() {
69
- if (candidateCount + records.length > maxCandidates) {
70
- debug(`Stopped searching because maximum number of candidates have been retrieved`);
144
+ function maxCandidatesRetrieved(candidateCount, maxCandidates) {
145
+ debugData(`Total amount of candidate records retrieved: ${newCandidateCount} (max: ${maxCandidates})`);
146
+ if (maxCandidates && candidateCount >= maxCandidates) {
147
+ debug(`Stopped matching because maximum number of candidate records ${candidateCount} / ${maxCandidates} have been retrieved`);
71
148
  return true;
72
149
  }
73
150
  }
151
+ }
152
+
153
+ // matches : array of matching candidate records
154
+ // nonMatches : array of nonMatching candidate records (if returnNonMatches option is true, otherwise empty array)
155
+ // - candidate.id
156
+ // - candidate.record
157
+ // - probability
158
+ // - strategy (if returnStrategy option is true)
159
+ // - treshold (if returnStrategy option is true)
160
+ // - matchQuery (if returnQuery option is true)
161
+ // failures: array of conversionFailures from candidate-search and matchErrors from matchDetection in error format {status, payload: {message, id}} if returnFailures is true
162
+
163
+ // we could have here also returnRecords/returnMatchRecords/returnNonMatchRecord options that could be turned false for not to return actual record data
164
+
165
+ // matchStatus.status: boolean, true if matcher retrieved and handled all found candidate records, false if it did not
166
+ // matchStatus.stopReason: string ('maxMatches','maxCandidates','maxedQueries','conversionFailures', empty string/undefined), reason for stopping retrieving or handling the candidate records
167
+ // - only one stopReason is returned (if there would be several possible stopReasons, stopReason is picked in the above order)
168
+ // - 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
169
+
170
+ function returnResult({matches, state, stopReason, nonMatches, duplicateCount, candidateCount, nonMatchCount, conversionFailures, matchErrors}) {
171
+ const conversionFailureCount = conversionFailures.length;
172
+ const matchErrorCount = matchErrors.length;
173
+ checkCounts({matches, nonMatches, candidateCount, duplicateCount, nonMatchCount, conversionFailureCount, matchErrorCount});
174
+ const matchStatus = getMatchState(state, stopReason, conversionFailureCount, matchErrorCount);
175
+ // add nonMatches to result only if returnNonMatches is 'true', otherwise nonMatches have not been gathered
176
+ const matchesResult = returnNonMatches ? {matches, matchStatus, nonMatches, candidateCount} : {matches, matchStatus, candidateCount};
177
+ const failures = [...conversionFailures, ...matchErrors];
178
+ const result = returnFailures ? {...matchesResult, conversionFailures: failures} : matchesResult;
179
+ debugData(`ReturnFailures ${returnFailures}`);
180
+ debugData(`${JSON.stringify(result)}`);
181
+ return result;
182
+
183
+ // note that in cases where the matching has been stopped because of maxMatches checkCounts won't (in most cases) match
184
+
185
+ function checkCounts({matches, nonMatches, candidateCount, duplicateCount, nonMatchCount, conversionFailureCount, matchErrorCount}) {
186
+ const matchCount = matches.length;
187
+ debugData(`Return nonMatches: ${returnNonMatches}`);
188
+ const chosenNonMatchCount = returnNonMatches ? nonMatches.length : nonMatchCount;
189
+ const totalHandled = matchCount + chosenNonMatchCount + duplicateCount;
190
+ debug(`candidateCount: ${candidateCount}, matches: ${matchCount}, nonMatches: ${chosenNonMatchCount}, duplicateCount: ${duplicateCount}, conversionFailureCount: ${conversionFailureCount}, matchErrorCount: ${matchErrorCount}`);
191
+ debug(`We got result for ${totalHandled} / ${candidateCount} retrieved candidates`);
192
+ if (totalHandled !== candidateCount) {
193
+ debug(`WARNING: Missing results for ${candidateCount - totalHandled} candidates`);
194
+ return;
195
+ }
196
+ return;
197
+ }
198
+
199
+ // eslint-disable-next-line max-statements
200
+ function getMatchState(state, stopReason, conversionFailuresCount, matchErrorCount) {
201
+ debugData(`${JSON.stringify(state)}`);
202
+ debug(`We had ${conversionFailuresCount} retrieved candidates that could not be converted.`);
203
+ debug(`We had ${matchErrorCount} retrieved candidates that errored in matchDetection.`);
204
+ 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}`);
205
+
206
+ debugData(`StopReason: <${stopReason}>`);
207
+
208
+ const searchesLeft = state.resultSetOffset && state.resultSetOffset <= state.totalRecords;
209
+ const nonRetrieved = searchesLeft ? state.totalRecords - state.queryCandidateCounter : 0;
210
+ debugData(`nonRetrieved: ${nonRetrieved}`);
211
+
212
+ // matchStatus.stopReason: string ('maxMatches','maxCandidates','maxedQueries','conversionFailures', empty string/undefined), reason for stopping retrieving or handling the candidate records
213
+ // 'maxMatches' and 'maxCandidates' are in stopReason, 'maxedQueries', 'conversionFailures' and 'matchErrors' are created here
214
+
215
+ if (state.queriesLeft > 0 || nonRetrieved > 0 || state.maxedQueries.length > 0 || conversionFailureCount > 0 || matchErrorCount > 0) {
216
+ const maxedQueriesStopReason = state.maxedQueries.length > 0 ? 'maxedQueries' : undefined;
217
+ const conversionFailuresStopReason = conversionFailureCount > 0 ? 'conversionFailures' : undefined;
218
+ const matchErrorsStopReason = matchErrorCount > 0 ? 'matchErrors' : undefined;
219
+ const newStopReason = stopReason === '' || stopReason === undefined ? maxedQueriesStopReason || conversionFailuresStopReason || matchErrorsStopReason : stopReason;
220
+ debugData(`MaxedQueriesStopReason: <${maxedQueriesStopReason}>`);
221
+ debugData(`ConversionFailureStopReason <${conversionFailuresStopReason}>`);
222
+ debugData(`MatchErrorsStopReason <${matchErrorsStopReason}>`);
223
+ debugData(`NewStopReason: <${newStopReason}>`);
224
+ debug(`Match status: false`);
225
+ return {status: false, stopReason: newStopReason};
226
+ }
227
+
228
+ debug(`Match status: true`);
229
+ return {status: true, stopReason};
230
+ }
231
+ }
232
+
233
+ // NOTES:
234
+ // - we could optimize by creating the featureSet for the incoming record once and using it for all database/candidateRecords
235
+ // - if creating the featureSet for the incoming record fails we have an unprocessable entity
236
+ // - if creating the featureSet for a candidate record fails we could skip that candidate - but list the case as a detectionFailure, same as conversionFailures
237
+
238
+ function iterateRecords({records, recordSetSize, maxMatches, matches = [], nonMatches = [], recordMatches = [], recordNonMatches = [], recordCount = 0, recordDuplicateCount = 0, recordNonMatchCount = 0, recordMatchErrors = []}) {
239
+
240
+ // recordSetSize : total amount of records in the current record set
241
+ // recordCount : amount of records from the current record set that have been handled
242
+ // maxMatches : setting for maximum amount found by current matcher job before the matcher job is stopped
243
+ // recordDuplicateCount : amount of records from the current record set that are already included in matches/nonMatches results
244
+ // recordNonMatchCount: amount of records from the current record set that are nonMatches (only is returnNonMatches setting is false)
245
+
246
+ // records : non-handled records in the current record set
247
+ // matches : found matches in the current matcher job
248
+ // recordMatches : found matches in the current record set
249
+ // recordNonMatches : found nonMatches in the current record set (only if returnNonMatches setting is true)
250
+ // recordMatchErrors: errored matchDetection in the current record set
251
+
252
+ const [candidate] = records;
253
+ const newRecordCount = candidate ? recordCount + 1 : recordCount;
74
254
 
75
- function iterateRecords(records) {
76
- const [candidate] = records;
255
+ // The matcher uses same matchDetection strategy for candidates from all candidate-searches -> matchDetection result for the same candidate is always same
256
+ // Exceptions would happen if the candidate would have been updated in the database between candidate searches
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
258
+ // different candidate search queries. Same candidate search query won't have duplicate records.
77
259
 
78
- if (candidate) {
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
+ debugData(`${JSON.stringify(result)}`);
266
+ */
267
+
268
+ if (candidate) {
269
+
270
+ // eslint-disable-next-line functional/no-conditional-statements
271
+ if (candidateNotInMatches(matches.concat(nonMatches), candidate)) {
79
272
  const {record: candidateRecord, id: candidateId} = candidate;
80
- const {match, probability} = detect(record, candidateRecord);
81
-
82
- if (match) {
83
- return {
84
- probability,
85
- candidate: {
86
- id: candidateId,
87
- record: candidateRecord
88
- }
89
- };
273
+ const recordBExternal = {id: candidate.id, recordSource: 'databaseRecord', label: `db-${candidate.id}`};
274
+ try {
275
+ debug(`Running matchDetection for record ${candidateId} (${newRecordCount}/${recordSetSize})`);
276
+ // we should handle errors from detection somehow - ie. cases where either record or candidateRecord errors
277
+ const detectionResult = detect({recordA: record, recordB: candidateRecord, recordAExternal: recordExternal, recordBExternal});
278
+
279
+ return handleDetectionResult(detectionResult, candidateId, candidateRecord);
280
+ } catch (error) {
281
+ debug(`MatchDetection errored: database record ${candidateId}: ${error}`);
282
+
283
+ const matchError = {status: 422, payload: {message: `Matching errored for database record ${candidateId}. ${error.message}.`, id: candidateId}};
284
+ const newRecordMatchErrors = recordMatchErrors.concat(matchError);
285
+ return iterateRecords({records: records.slice(1), recordSetSize, maxMatches, matches, recordMatches, recordCount: newRecordCount, recordNonMatches, recordDuplicateCount, recordNonMatchCount, recordMatchErrors: newRecordMatchErrors});
90
286
  }
287
+ }
288
+
289
+ return iterateRecords({records: records.slice(1), recordSetSize, maxMatches, matches, recordMatches, recordCount: newRecordCount, recordNonMatches, recordDuplicateCount: recordDuplicateCount + 1, recordNonMatchCount, recordMatchErrors});
290
+ }
291
+
292
+ debug(`No more candidates, record set (${recordCount}/${recordSetSize}) done, ${recordMatches.length} matches found, ${recordDuplicateCount} candidates already handled, ${returnNonMatches ? `${recordNonMatches.length}` : `${recordNonMatchCount}`} nonMatches found.`);
293
+ return {matches: recordMatches, nonMatches: returnNonMatches ? recordNonMatches : [], duplicateCount: recordDuplicateCount, nonMatchCount: recordNonMatchCount, matchErrors: recordMatchErrors};
294
+
295
+ function handleDetectionResult(detectionResult, candidateId, candidateRecord) {
296
+ debugData(`MatchDetection results for ${candidateId} (${newRecordCount}/${recordSetSize}): ${JSON.stringify(detectionResult)}`);
91
297
 
92
- return iterateRecords(records.slice(1));
298
+ if (detectionResult.match || returnNonMatches) {
299
+ debug(`${detectionResult.match ? `Record ${candidateId} (${newRecordCount}/${recordSetSize}) is a match!` : `Record ${candidateId} (${newRecordCount}/${recordSetSize}) is NOT a match!`}`);
300
+ debugData(`Strategy: ${JSON.stringify(detectionResult.strategy)}, Treshold: ${JSON.stringify(detectionResult.treshold)}`);
301
+
302
+ const matchResult = {
303
+ probability: detectionResult.probability,
304
+ candidate: {
305
+ id: candidateId,
306
+ record: candidateRecord
307
+ }
308
+ };
309
+ const strategyResult = {
310
+ strategy: detectionResult.strategy,
311
+ treshold: detectionResult.treshold
312
+ };
313
+ const newMatch = returnStrategy ? {...matchResult, ...strategyResult} : {...matchResult};
314
+
315
+ debugData(`${JSON.stringify(newMatch)}`);
316
+
317
+ return handleRecordMatch(detectionResult.match, newMatch);
318
+ }
319
+
320
+ const newRecordNonMatchCount = recordNonMatchCount + 1;
321
+ debugData(`- Total nonMatches after this detection: ${newRecordNonMatchCount}`);
322
+
323
+ return iterateRecords({records: records.slice(1), recordSetSize, maxMatches, matches, recordMatches, recordCount: newRecordCount, recordNonMatches, recordDuplicateCount, recordNonMatchCount: newRecordNonMatchCount, recordMatchErrors});
324
+ }
325
+
326
+ function handleRecordMatch(isMatch, newMatch) {
327
+ const newRecordMatches = isMatch ? recordMatches.concat(newMatch) : recordMatches;
328
+ const newRecordNonMatches = isMatch ? recordNonMatches : recordNonMatches.concat(newMatch);
329
+ const newRecordNonMatchCount = isMatch ? recordNonMatchCount : recordNonMatchCount + 1;
330
+
331
+ debugData(`- Total matches after this detection: ${matches.concat(newRecordMatches).length} (max: ${maxMatches})`);
332
+
333
+ // eslint-disable-next-line functional/no-conditional-statements
334
+ if (returnNonMatches) {
335
+ debugData(`- Total nonMatches after this detection: ${nonMatches.concat(newRecordNonMatches).length}`);
93
336
  }
337
+ debugData(`- Total nonMatchCount after this detection: ${recordNonMatchCount}`);
338
+
339
+ if (maxMatchesFound({matches: matches.concat(newRecordMatches), maxMatches})) {
340
+ debug(`MaxMatches (${maxMatches}) reached, handled candidates in record set: ${newRecordCount} non-handled candidates in record set ${recordSetSize - newRecordCount}`);
341
+ return {matches: newRecordMatches, nonMatches: returnNonMatches ? newRecordNonMatches : [], duplicateCount: recordDuplicateCount, nonMatchCount: newRecordNonMatchCount, matchErrors: recordMatchErrors};
342
+ }
343
+
344
+ return iterateRecords({records: records.slice(1), recordSetSize, maxMatches, matches, recordMatches: newRecordMatches, recordCount: newRecordCount, recordNonMatches: returnNonMatches ? newRecordNonMatches : [], duplicateCount: recordDuplicateCount, recordNonMatchCount: newRecordNonMatchCount, matchErrors: recordMatchErrors});
345
+ }
346
+
347
+ function candidateNotInMatches(matches, candidate) {
348
+ debug(`Checking that record ${candidate.id} is not already included in ${matches.length} matches/nonMatches`);
349
+ const newCandidateId = candidate.id;
350
+ debugData(`newCandidateId: ${newCandidateId}`);
351
+ const result = matches.find(({candidate}) => candidate.id === newCandidateId);
352
+ debugData(`Result: ${result}`);
353
+ if (result) {
354
+ debug(`${candidate.id} was already handled.`);
355
+ return false;
356
+ }
357
+ debug(`${candidate.id} not found in matches/nonMatches`);
358
+ return true;
359
+ }
360
+ }
361
+
362
+ function maxMatchesFound({matches, maxMatches}) {
363
+ if (maxMatches && matches.length >= maxMatches) {
364
+ debug(`Stopping recordSet iteration: maxMatches (${maxMatches}) for matcher job found.`);
365
+ return true;
94
366
  }
95
367
  }
96
368
  };
package/src/index.spec.js CHANGED
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * Melinda record matching modules for Javascript
6
6
  *
7
- * Copyright (C) 2020 University Of Helsinki (The National Library Of Finland)
7
+ * Copyright (C) 2020-2023 University Of Helsinki (The National Library Of Finland)
8
8
  *
9
9
  * This file is part of melinda-record-matching-js
10
10
  *
@@ -31,6 +31,10 @@ import {READERS} from '@natlibfi/fixura';
31
31
  import generateTests from '@natlibfi/fixugen-http-client';
32
32
  import {MarcRecord} from '@natlibfi/marc-record';
33
33
  import createMatchInterface, {matchDetection} from '.';
34
+ import createDebugLogger from 'debug';
35
+
36
+ const debug = createDebugLogger('@natlibfi/melinda-record-matching:index:test');
37
+ const debugData = debug.extend('data');
34
38
 
35
39
  describe('INDEX', () => {
36
40
  generateTests({
@@ -42,19 +46,32 @@ describe('INDEX', () => {
42
46
  }
43
47
  });
44
48
 
45
- async function callback({getFixture, options, enabled = true}) {
46
-
47
- if (!enabled) {
48
- return;
49
- }
49
+ // eslint-disable-next-line max-statements
50
+ async function callback({getFixture, options, expectedMatchStatus, expectedStopReason, expectedFailures, expectedCandidateCount}) {
50
51
 
51
52
  const record = new MarcRecord(getFixture('inputRecord.json'), {subfieldValues: false});
52
53
  const expectedMatches = getFixture('expectedMatches.json');
54
+ const expectedNonMatches = getFixture('expectedNonMatches.json') || [];
55
+
53
56
 
54
57
  const match = createMatchInterface(formatOptions());
55
- const matches = await match(record);
58
+ const {matches, matchStatus, nonMatches, conversionFailures, candidateCount} = await match({record});
59
+ debugData(`Matches: ${matches.length}, Status: ${matchStatus.status}/${matchStatus.stopReason}, NonMatches: ${nonMatches ? nonMatches.length : 'not returned'}, ConversionFailures: ${conversionFailures ? conversionFailures.length : 'not returned'}`);
60
+
61
+ expect(matchStatus.status).to.eql(expectedMatchStatus);
62
+ expect(matchStatus.stopReason).to.eql(expectedStopReason);
63
+ expect(candidateCount).to.eql(expectedCandidateCount);
56
64
 
57
- expect(formatResults()).to.eql(expectedMatches);
65
+ const formattedMatchResult = formatRecordResults(matches);
66
+ expect(formattedMatchResult).to.eql(expectedMatches);
67
+
68
+ const formattedNonMatchResult = formatRecordResults(nonMatches);
69
+ expect(formattedNonMatchResult).to.eql(expectedNonMatches);
70
+
71
+ // eslint-disable-next-line functional/no-conditional-statements
72
+ if (expectedFailures) {
73
+ expect(conversionFailures).to.eql(expectedFailures);
74
+ }
58
75
 
59
76
  function formatOptions() {
60
77
  const contextFeatures = matchDetection.features[options.detection.strategy.type];
@@ -62,26 +79,37 @@ describe('INDEX', () => {
62
79
  return {
63
80
  ...options,
64
81
  search: {
65
- ...options.search,
66
- maxRecordsPerRequest: 1
82
+ ...options.search
67
83
  },
68
84
  detection: {
69
85
  ...options.detect,
70
86
  strategy: options.detection.strategy.features.map(v => contextFeatures[v]())
71
- },
72
- maxMatches: 2,
73
- maxCandidates: 1
87
+ }
74
88
  };
75
89
  }
76
90
 
77
- function formatResults() {
78
- return matches.map(({candidate, probability}) => ({
79
- probability,
80
- candidate: {
81
- id: candidate.id,
82
- record: candidate.record.toObject()
83
- }
84
- }));
91
+ function formatRecordResults(matches) {
92
+ if (matches) {
93
+ debugData(JSON.stringify(matches));
94
+ return matches.map((match) => ({
95
+ ...match,
96
+ candidate: formatCandidate(match.candidate)
97
+ }));
98
+ }
99
+ return [];
85
100
  }
101
+
102
+ // Format candidate to remove validationOptions from record
103
+ function formatCandidate({id, record}) {
104
+ const newId = id;
105
+ const newRecord = record;
106
+ return {
107
+ id: newId,
108
+ record: newRecord.toObject()
109
+ };
110
+ }
111
+
112
+
86
113
  }
114
+
87
115
  });
@@ -1,4 +1,3 @@
1
- /* eslint-disable max-statements */
2
1
  /**
3
2
  *
4
3
  * @licstart The following is the entire license notice for the JavaScript code in this file.
@@ -56,8 +55,9 @@ Records with matching local ids for matching local db are a very good match.
56
55
 
57
56
  export default () => ({
58
57
  name: 'All source IDs',
59
- extract: record => {
60
- debug(`Creating match detection features for all local source id's`);
58
+ extract: ({record, recordExternal}) => {
59
+ const label = recordExternal && recordExternal.label ? recordExternal.label : 'record';
60
+ debug(`Creating match detection features for all local source id's for ${label}`);
61
61
 
62
62
  const fSids = record.get('SID');
63
63
  debugData(`SID-fields (${fSids.length}): ${JSON.stringify(fSids)}`);
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * Melinda record matching modules for Javascript
6
6
  *
7
- * Copyright (C) 2020 University Of Helsinki (The National Library Of Finland)
7
+ * Copyright (C) 2020-2022 University Of Helsinki (The National Library Of Finland)
8
8
  *
9
9
  * This file is part of melinda-record-matching-js
10
10
  *
@@ -27,25 +27,32 @@
27
27
  */
28
28
 
29
29
  import {LevenshteinDistance as leven} from 'natural';
30
+ import {testStringOrNumber} from '../../../matching-utils';
31
+
32
+ // We should extract also organisational authors (110/710)
30
33
 
31
34
  export default ({nameTreshold = 10} = {}) => ({
32
35
  name: 'Authors',
33
- extract: record => record.get(/^(?<def>100|700)$/u)
34
- .map(({subfields}) => {
35
- return subfields
36
- .filter(({code, value}) => code && value)
37
- .filter(({code}) => ['a', '0'].includes(code))
38
- .map(toObj)
39
- .reduce((acc, v) => ({...acc, ...v}), {});
40
-
41
- function toObj({code, value}) {
42
- if (code === 'a') {
43
- return {name: value.replace(/[^\p{Letter}\p{Number}]/gu, '').toLowerCase()};
44
- }
36
+ extract: ({record}) => {
37
+ //const label = recordExternal && recordExternal.label ? recordExternal.label : 'record';
38
+ const authors = record.get(/^(?<def>100|700)$/u)
39
+ .map(({subfields}) => {
40
+ return subfields
41
+ .filter(({code, value}) => code && value)
42
+ .filter(({code}) => ['a', '0'].includes(code))
43
+ .map(toObj)
44
+ .reduce((acc, v) => ({...acc, ...v}), {});
45
+
46
+ function toObj({code, value}) {
47
+ if (code === 'a') {
48
+ return {name: testStringOrNumber(value) ? String(value).replace(/[^\p{Letter}\p{Number}]/gu, '').toLowerCase() : ''};
49
+ }
45
50
 
46
- return {id: value};
47
- }
48
- }),
51
+ return {id: value};
52
+ }
53
+ });
54
+ return authors;
55
+ },
49
56
  compare: (a, b) => {
50
57
  const maxAuthors = a.length > b.length ? a.length : b.length;
51
58
  const matchingIds = findMatchingIds();
@@ -28,6 +28,6 @@
28
28
 
29
29
  export default () => ({
30
30
  name: 'Bibliographic level',
31
- extract: r => r.leader[7] ? [r.leader[7]] : [],
31
+ extract: ({record}) => record.leader[7] ? [record.leader[7]] : [],
32
32
  compare: (a, b) => a[0] === b[0] ? 0.1 : -0.2
33
33
  });
@@ -28,6 +28,6 @@
28
28
 
29
29
  export default () => ({
30
30
  name: 'Host/Component record',
31
- extract: r => r.get(/^773$/u).length > 0 ? ['component'] : ['host'],
31
+ extract: ({record}) => record.get(/^773$/u).length > 0 ? ['component'] : ['host'],
32
32
  compare: (a, b) => a[0] === b[0] ? 0.0 : -1.0
33
33
  });
@@ -31,10 +31,14 @@ export {default as isbn} from './isbn';
31
31
  export {default as issn} from './issn';
32
32
  export {default as otherStandardIdentifier} from './other-standard-identifier';
33
33
  export {default as title} from './title';
34
+ export {default as titleVersionOriginal} from './title-version-original';
34
35
  export {default as authors} from './authors';
35
36
  export {default as recordType} from './record-type';
36
37
  export {default as publicationTime} from './publication-time';
38
+ export {default as publicationTimeAllowConsYears} from './publication-time-allow-cons-years';
39
+ export {default as publicationTimeAllowConsYearsMulti} from './publication-time-allow-cons-years-multi';
37
40
  export {default as language} from './language';
38
41
  export {default as bibliographicLevel} from './bibliographic-level';
39
42
  export {default as melindaId} from './melinda-id';
40
43
  export {default as allSourceIds} from './all-source-ids';
44
+ export {default as mediaType} from './media-type';
@@ -31,6 +31,11 @@ import {READERS} from '@natlibfi/fixura';
31
31
  import {expect} from 'chai';
32
32
  import {MarcRecord} from '@natlibfi/marc-record';
33
33
  import * as features from '.';
34
+ import createDebugLogger from 'debug';
35
+
36
+
37
+ const debug = createDebugLogger('@natlibfi/melinda-record-matching:match-detection:features/bib:test');
38
+ const debugData = debug.extend('data');
34
39
 
35
40
 
36
41
  describe('match-detection/features/bib/', () => {
@@ -40,19 +45,22 @@ describe('match-detection/features/bib/', () => {
40
45
  fixura: {
41
46
  reader: READERS.JSON
42
47
  },
43
- // eslint-disable-next-line max-statements
48
+
44
49
  callback: ({enabled = true, feature, options, type, ...expectations}) => {
45
50
 
46
51
  if (!enabled) {
47
52
  return;
48
53
  }
49
54
 
55
+ debug(`Testing: ${feature} ${type}`);
56
+
50
57
  if (type === 'extract') {
51
58
  const {expectedFeatures, inputRecord} = expectations;
52
59
  const record = new MarcRecord(inputRecord, {subfieldValues: false});
60
+ debugData(`Record: ${record}`);
53
61
  const {extract} = features[feature](options);
54
62
 
55
- expect(extract(record)).to.eql(expectedFeatures);
63
+ expect(extract({record})).to.eql(expectedFeatures);
56
64
  return;
57
65
  }
58
66