@natlibfi/melinda-record-matching 5.0.1-alpha.1 → 5.0.2-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.
- package/dist/candidate-search/index.js +6 -2
- package/dist/candidate-search/index.js.map +2 -2
- package/dist/candidate-search/query-list/bib.js +10 -2
- package/dist/candidate-search/query-list/bib.js.map +3 -3
- package/dist/cli.js +1 -1
- package/dist/cli.js.map +2 -2
- package/dist/index.js +2 -2
- package/dist/index.js.map +2 -2
- package/dist/match-detection/features/bib/authors.js +2 -2
- package/dist/match-detection/features/bib/authors.js.map +2 -2
- package/dist/match-detection/features/bib/bibliographic-level.js +12 -1
- package/dist/match-detection/features/bib/bibliographic-level.js.map +2 -2
- package/dist/match-detection/features/bib/host-component.js +1 -1
- package/dist/match-detection/features/bib/host-component.js.map +2 -2
- package/dist/match-detection/features/bib/isbn.js +122 -15
- package/dist/match-detection/features/bib/isbn.js.map +2 -2
- package/dist/match-detection/features/bib/issn.js +62 -5
- package/dist/match-detection/features/bib/issn.js.map +2 -2
- package/dist/match-detection/features/bib/language.js +176 -59
- package/dist/match-detection/features/bib/language.js.map +3 -3
- package/dist/match-detection/features/bib/title-version-original.js +2 -2
- package/dist/match-detection/features/bib/title-version-original.js.map +2 -2
- package/dist/match-detection/features/bib/title.js +2 -2
- package/dist/match-detection/features/bib/title.js.map +2 -2
- package/dist/match-detection/index.js +5 -5
- package/dist/match-detection/index.js.map +2 -2
- package/package.json +9 -4
- package/src/candidate-search/index.js +7 -2
- package/src/candidate-search/query-list/bib.js +22 -15
- package/src/cli.js +1 -1
- package/src/index.js +3 -3
- package/src/match-detection/features/bib/authors.js +2 -2
- package/src/match-detection/features/bib/bibliographic-level.js +13 -1
- package/src/match-detection/features/bib/host-component.js +1 -1
- package/src/match-detection/features/bib/isbn.js +162 -14
- package/src/match-detection/features/bib/issn.js +83 -1
- package/src/match-detection/features/bib/language.js +261 -73
- package/src/match-detection/features/bib/title-version-original.js +2 -2
- package/src/match-detection/features/bib/title.js +5 -4
- package/src/match-detection/index.js +5 -5
|
@@ -1,7 +1,89 @@
|
|
|
1
|
+
/*
|
|
2
|
+
//import createInterface from './standard-identifier-factory.js';
|
|
1
3
|
|
|
2
|
-
import createInterface from './standard-identifier-factory.js';
|
|
3
4
|
|
|
4
5
|
export default () => {
|
|
5
6
|
const {extract, compare} = createInterface({pattern: /^022$/u, subfieldCodes: ['a', 'z', 'y']});
|
|
6
7
|
return {extract, compare, name: 'ISSN'};
|
|
7
8
|
};
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
import createDebugLogger from 'debug';
|
|
13
|
+
|
|
14
|
+
import {isComponentRecord} from '@natlibfi/melinda-commons';
|
|
15
|
+
|
|
16
|
+
const debug = createDebugLogger('@natlibfi/melinda-record-matching:match-detection:features:issn');
|
|
17
|
+
const debugData = debug.extend('data');
|
|
18
|
+
|
|
19
|
+
export default () => ({
|
|
20
|
+
name: 'ISSN',
|
|
21
|
+
extract: ({record/*, recordExternal*/}) => {
|
|
22
|
+
//const label = recordExternal && recordExternal.label ? recordExternal.label : 'record';
|
|
23
|
+
|
|
24
|
+
const isHost = !isComponentRecord(record, false, []);
|
|
25
|
+
|
|
26
|
+
// debug(`\t Is HOST: '${isHost}'`);
|
|
27
|
+
return getIssns();
|
|
28
|
+
|
|
29
|
+
function getIssns() {
|
|
30
|
+
const fields = getRelevantFields();
|
|
31
|
+
if (fields.length === 0) {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
debug(`\t ${fields.length} potential ISSN fields (${fields[0].tag})`);
|
|
35
|
+
const subfieldCodes = getRelevantSubfieldCodes();
|
|
36
|
+
//debug(`\t subfield codes: '${subfieldCodes.join("', '")}'`);
|
|
37
|
+
const subfieldValues = fields.flatMap(f => f.subfields.filter(sf => subfieldCodes.includes(sf.code)).map(sf => sf.value));
|
|
38
|
+
//debug(`\t cand values: '${subfieldValues.join("', '")}'`);
|
|
39
|
+
// Stripping punctuaction with substring here is pretty quick and dirty approach...
|
|
40
|
+
const validSubfieldValues = subfieldValues?.map(val => normalizeSubfieldValue(val)).filter(val => isValidIssn(val));
|
|
41
|
+
if (!validSubfieldValues) {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
return uniqArray(validSubfieldValues);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function getRelevantFields() {
|
|
48
|
+
if (isHost) {
|
|
49
|
+
return record.get(/^022$/u);
|
|
50
|
+
}
|
|
51
|
+
return record.get(/^[79]73$/u);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function normalizeSubfieldValue(val) {
|
|
55
|
+
return val.replace(/[., :;].*$/u, '');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isValidIssn(val) {
|
|
59
|
+
return /^[0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9X]$/u.test(val);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function getRelevantSubfieldCodes() {
|
|
63
|
+
if (isHost) {
|
|
64
|
+
return ['a', 'y', 'z'];
|
|
65
|
+
}
|
|
66
|
+
return ['x'];
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
// eslint-disable-next-line max-statements
|
|
70
|
+
compare: (aa, bb) => {
|
|
71
|
+
debugData(`Comparing ISSN sets ${JSON.stringify(aa)} and ${JSON.stringify(bb)}`);
|
|
72
|
+
if (aa.length === 0 || bb.length === 0) {
|
|
73
|
+
// No data for decision
|
|
74
|
+
return 0;
|
|
75
|
+
}
|
|
76
|
+
const firstSharedIssn = aa.find(val => bb.includes(val));
|
|
77
|
+
if (firstSharedIssn) {
|
|
78
|
+
debug(`\t Shared ISSN found: '${firstSharedIssn}'`);
|
|
79
|
+
// Maybe less for comps?
|
|
80
|
+
return 0.2;
|
|
81
|
+
}
|
|
82
|
+
return -0.2;
|
|
83
|
+
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
export function uniqArray(arr) {
|
|
88
|
+
return arr.filter((val, i) => arr.indexOf(val) === i);
|
|
89
|
+
}
|
|
@@ -1,111 +1,299 @@
|
|
|
1
1
|
|
|
2
2
|
import createDebugLogger from 'debug';
|
|
3
|
+
|
|
4
|
+
import {MarcRecord} from '@natlibfi/marc-record';
|
|
5
|
+
|
|
6
|
+
import {FixSami041, Remove041zxx} from '@natlibfi/marc-record-validators-melinda';
|
|
7
|
+
|
|
3
8
|
import {getMatchCounts} from '../../../matching-utils.js';
|
|
4
9
|
|
|
5
10
|
const debug = createDebugLogger('@natlibfi/melinda-record-matching:match-detection:features:language');
|
|
6
11
|
const debugData = debug.extend('data');
|
|
7
12
|
|
|
13
|
+
// NB! 2025-12-17: I changed the logic drastically. However, I tried to keep the scores as same as possible.
|
|
14
|
+
// 'extract' no longer extracts anything. Instead it clones the record, and does some preprocessing (using validators) instead.
|
|
15
|
+
// 'compare' had multiple changes
|
|
16
|
+
// - f041 ind1 differences ('0' vs '1') now result in a small penalty
|
|
17
|
+
// - Two sami languages related changes:
|
|
18
|
+
// -- validator adds a 'smi' subfield before a corresponding sma/sme/...subfield, if needed (national)
|
|
19
|
+
// -- 'smi' only vs 'smi'+'sma' does not cause penalty
|
|
20
|
+
// - 'mul' vs 'fin'+'swe' does not cause a penalty. However, 'mul' vs 'fin' alone triggers penalty.
|
|
21
|
+
// - 008/35-37 and f041$a/$d are calculated separately
|
|
22
|
+
// - a threshold is applied: return value is always between -1.0 and +0.1 (as it was before)
|
|
23
|
+
// - we try to handle 041 $2 ISO 639-2 and ISO 639-3 as well.
|
|
24
|
+
// - 'und' vs one other language does not cause a penalty in certain contexts. In other contexts it's treated as a normal "language code". (Tune/alleviate penalties later on.)
|
|
25
|
+
|
|
8
26
|
export default () => ({
|
|
9
27
|
name: 'Language',
|
|
10
28
|
extract: ({record, recordExternal}) => {
|
|
11
29
|
const label = recordExternal && recordExternal.label ? recordExternal.label : 'record';
|
|
30
|
+
const clonedRecord = new MarcRecord(record, record._validationOptions); // clone(record); // NB! This loses record.get()...
|
|
31
|
+
|
|
32
|
+
FixSami041().fix(clonedRecord); // Handle 'smi' adding if needed
|
|
33
|
+
Remove041zxx().fix(clonedRecord); // Remove 'zxx' from f041s
|
|
34
|
+
// Add other language code normalizations?
|
|
12
35
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
36
|
+
// Should we return all the fields, or just the relevant fields?
|
|
37
|
+
return [{leader: clonedRecord.leader, fields: clonedRecord.fields}, label];
|
|
38
|
+
},
|
|
39
|
+
// eslint-disable-next-line max-statements
|
|
40
|
+
compare: (aa, bb) => {
|
|
41
|
+
const [a, aLabel] = aa;
|
|
42
|
+
const [b, bLabel] = bb;
|
|
43
|
+
debugData(`Comparing language ${JSON.stringify(a)} and ${JSON.stringify(b)}`);
|
|
44
|
+
|
|
45
|
+
const score008 = compare008();
|
|
46
|
+
const score041 = compare041();
|
|
47
|
+
return applyLimits(score008 + score041);
|
|
16
48
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
49
|
+
function applyLimits(score) {
|
|
50
|
+
if (score > 0.1) { // Keeps the original max, we might have 0.10 (041) + 0.05 (008/35-37) = 0.15 now
|
|
51
|
+
return 0.1;
|
|
52
|
+
}
|
|
53
|
+
if (score < -1) {
|
|
54
|
+
return -1.0;
|
|
55
|
+
}
|
|
56
|
+
return score;
|
|
20
57
|
}
|
|
21
58
|
|
|
22
|
-
|
|
23
|
-
|
|
59
|
+
function compare008() {
|
|
60
|
+
const a008 = get008Value(a, aLabel);
|
|
61
|
+
const b008 = get008Value(b, bLabel);
|
|
62
|
+
// Something seriously wrong with 008:
|
|
63
|
+
if (a008 === undefined || b008 === undefined) {
|
|
64
|
+
return 0.0; // Punish for generic badness?
|
|
65
|
+
}
|
|
24
66
|
|
|
25
|
-
|
|
67
|
+
if (containsNoData(a008) || containsNoData(b008)){
|
|
68
|
+
// Nothing to compare
|
|
69
|
+
return 0.0;
|
|
70
|
+
}
|
|
26
71
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
72
|
+
if (a008 === b008) {
|
|
73
|
+
return 0.05;
|
|
74
|
+
}
|
|
30
75
|
|
|
31
|
-
if (
|
|
32
|
-
return
|
|
76
|
+
if (a008 === 'mul' || b008 === 'mul') {
|
|
77
|
+
return 0.0;
|
|
33
78
|
}
|
|
34
79
|
|
|
35
|
-
|
|
36
|
-
debugData(`${label}: 008 code: ${code}`);
|
|
37
|
-
return isLangCodeForALanguage(code) ? code : undefined;
|
|
80
|
+
return -0.2;
|
|
38
81
|
}
|
|
39
82
|
|
|
40
|
-
// Uses only f041s that have 2nd ind ' ', which means that the codes used are MARC 21 language codes
|
|
41
83
|
|
|
42
|
-
function get041Values() {
|
|
43
|
-
return record.get(/^041$/u)
|
|
44
|
-
.filter(({ind2}) => ind2 === ' ')
|
|
45
|
-
.map(({subfields}) => subfields)
|
|
46
|
-
.flat()
|
|
47
|
-
.filter(({code}) => code === 'a' || code === 'd')
|
|
48
|
-
.filter(({value}) => value && isLangCodeForALanguage(value))
|
|
49
|
-
.map(({value}) => value);
|
|
50
|
-
}
|
|
51
84
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
function
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
function compare041() {
|
|
88
|
+
const a041s = getFields041(a);
|
|
89
|
+
const b041s = getFields041(b);
|
|
90
|
+
if (a041s.length === 0 || b041s.length === 0) {
|
|
91
|
+
return 0.0; // Should we punish these for badness?
|
|
58
92
|
}
|
|
59
|
-
if (
|
|
60
|
-
|
|
61
|
-
|
|
93
|
+
if (a041s.length === 1 && b041s.length === 1) {
|
|
94
|
+
// The language codes are typically same between different sources. Incur a small penalty for differences though...
|
|
95
|
+
const sourcePenalty = getSourceOfLanguageCode(a041s[0]) === getSourceOfLanguageCode(b041s[0]) ? 0.0 : -0.05;
|
|
96
|
+
const mainPenalty = compareLanguageCodeLists(getFieldsLanguageCodes(a041s[0]), getFieldsLanguageCodes(b041s[0]));
|
|
97
|
+
const scoreInd1 = mainPenalty === 0.0 ? 0.0 : indicator1Penalty(a041s[0], b041s[0]);
|
|
98
|
+
return mainPenalty + sourcePenalty + scoreInd1;
|
|
62
99
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
100
|
+
// Things are already complicated (likely to pe penalized, so no nedd to worrty about language code sources, I daresay.
|
|
101
|
+
return compareLanguageCodeLists(getLanguageCodesFrom041Fields(a), getLanguageCodesFrom041Fields(b));
|
|
102
|
+
|
|
103
|
+
function getFields041(record) {
|
|
104
|
+
if (!record.fields) {
|
|
105
|
+
return [];
|
|
106
|
+
}
|
|
107
|
+
return record.fields.filter(f => f.tag === '041');
|
|
67
108
|
}
|
|
68
|
-
|
|
109
|
+
|
|
69
110
|
}
|
|
70
111
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
112
|
+
function getSourceOfLanguageCode(field) {
|
|
113
|
+
const sf2 = field.subfields.find(sf => sf.code === '2');
|
|
114
|
+
if (!sf2) {
|
|
115
|
+
return 'MARC';
|
|
116
|
+
}
|
|
117
|
+
// Normalize the two relevant language code names:
|
|
118
|
+
if (sf2.value.match(/639.2/u)) {
|
|
119
|
+
return 'ISO 639-2';
|
|
120
|
+
}
|
|
121
|
+
if (sf2.value.match(/639.3/u)) {
|
|
122
|
+
return 'ISO 639-3';
|
|
123
|
+
}
|
|
124
|
+
return sf2.value; // We don't actually have anything else in Melinda though
|
|
125
|
+
}
|
|
75
126
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
127
|
+
function indicator1Penalty(aField, bField) {
|
|
128
|
+
if (aField.ind1 === ' ' || bField.ind1 === ' '|| aField.ind1 === bField.ind1 ) {
|
|
129
|
+
return 0.0;
|
|
130
|
+
}
|
|
131
|
+
debug(`\t Indicator penalty: '${aField.ind1}' vs '${bField.ind1}'`);
|
|
132
|
+
return -0.1;
|
|
79
133
|
}
|
|
80
134
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
function containsNoData(languageCode) {
|
|
138
|
+
return [' ', '|||', 'und'].includes(languageCode);
|
|
84
139
|
}
|
|
85
140
|
|
|
86
|
-
|
|
141
|
+
function compareLanguageCodeLists(a, b) {
|
|
142
|
+
if (a.length === 0 || b.length === 0) {
|
|
143
|
+
debugData(`No language to compare`);
|
|
144
|
+
return 0;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// If either one of the sets has only the generic 'smi', remove the specific sami languages codes from the other as well:
|
|
148
|
+
const samiLanguageCodes = ['sma', 'sme', 'smj', 'smn', 'sms']; // NB! Don't put generic 'smi' here!
|
|
149
|
+
|
|
150
|
+
if (a.includes('smi') && b.includes('smi')) {
|
|
151
|
+
if (a.some(code => samiLanguageCodes.includes(code)) && !b.some(code => samiLanguageCodes.includes(code))) {
|
|
152
|
+
return compareLanguageCodeLists(a.filter(val => !samiLanguageCodes.includes(val)), b); // recurse
|
|
153
|
+
}
|
|
154
|
+
if (b.some(code => samiLanguageCodes.includes(code)) && !a.some(code => samiLanguageCodes.includes(code))) {
|
|
155
|
+
return compareLanguageCodeLists(a, b.filter(val => !samiLanguageCodes.includes(val))); // recurse
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
if (a.length === b.length && a.every((element, index) => element === b[index])) {
|
|
163
|
+
debugData(`All languages match`);
|
|
164
|
+
return 0.1;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Handle 'mul'. (Should we check whether the other array contains mul as well, probably not...)
|
|
168
|
+
// (If 'mul' does not appear alone, then it is very iffy... )
|
|
169
|
+
if (a.length === 1 && a[0] === 'mul' && b.length > 1) {
|
|
170
|
+
return 0;
|
|
171
|
+
}
|
|
172
|
+
if (b.length === 1 && b[0] === 'mul' && a.length > 1) {
|
|
173
|
+
return 0;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
// Damage control:
|
|
179
|
+
|
|
180
|
+
// Not using the generic solution here as eg. 'und' needs a special treatment
|
|
181
|
+
const sharedValues = getSharedValues(a, b);
|
|
182
|
+
const aOnly = a.filter(val => !sharedValues.includes(val));
|
|
183
|
+
const bOnly = b.filter(val => !sharedValues.includes(val));
|
|
184
|
+
const hasUnd = [...aOnly, ...bOnly].includes('und');
|
|
185
|
+
|
|
186
|
+
if (sharedValues.length < 1) {
|
|
187
|
+
console.info(`NV: ${sharedValues.join(", ")}`);
|
|
188
|
+
if (aOnly.length === 1 && bOnly.length === 1 && hasUnd) {
|
|
189
|
+
debug(`Both have languages, but none of these match. However, the benefit of doubt is given: '${aOnly[0]}' and '${bOnly[0]}' might mean the same}`);
|
|
190
|
+
return 0;
|
|
191
|
+
}
|
|
192
|
+
debug(`Both have languages, but none of these match.`); // includes 'mul' vs a single language code
|
|
193
|
+
return -1.0;
|
|
194
|
+
}
|
|
87
195
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
196
|
+
const {matchingValues, possibleMatchValues, maxValues} = getMatchCounts(a, b);
|
|
197
|
+
|
|
198
|
+
debug(`Both have languages, ${matchingValues}/${possibleMatchValues} valid languages match.`);
|
|
199
|
+
// ignore non-matches if there is mismatching amount of values
|
|
200
|
+
debug(`Possible matches: ${possibleMatchValues}/${maxValues}`);
|
|
201
|
+
// we give some kind of penalty for mismatching amount of values instead of simple divide?
|
|
202
|
+
const missingCount = maxValues - possibleMatchValues;
|
|
203
|
+
const misMatchCount = possibleMatchValues - matchingValues;
|
|
204
|
+
debug(`\t missing: ${missingCount}`);
|
|
205
|
+
debug(`\t mismatches: ${misMatchCount}`);
|
|
206
|
+
|
|
207
|
+
const penaltyForMissing = 0.02 * (maxValues - possibleMatchValues);
|
|
208
|
+
const penaltyForMisMatch = 0.05 * (possibleMatchValues - matchingValues);
|
|
209
|
+
debug(`\t points: penaltyForMissing: ${penaltyForMissing}`);
|
|
210
|
+
debug(`\t points: penaltyForMisMatch: ${penaltyForMisMatch}`);
|
|
211
|
+
|
|
212
|
+
const points = Number(Number(0.1 - penaltyForMisMatch - penaltyForMissing).toFixed(2));
|
|
213
|
+
debug(`Total points: ${points}`);
|
|
214
|
+
|
|
215
|
+
return points;
|
|
91
216
|
}
|
|
92
|
-
debug(`Both have languages, ${matchingValues}/${possibleMatchValues} valid languages match.`);
|
|
93
|
-
// ignore non-matches if there is mismatching amount of values
|
|
94
|
-
debug(`Possible matches: ${possibleMatchValues}/${maxValues}`);
|
|
95
|
-
//we give some kind of penalty for mismatching amount of values instead of simple divide?
|
|
96
|
-
const missingCount = maxValues - possibleMatchValues;
|
|
97
|
-
const misMatchCount = possibleMatchValues - matchingValues;
|
|
98
|
-
debug(`\t missing: ${missingCount}`);
|
|
99
|
-
debug(`\t mismatches: ${misMatchCount}`);
|
|
100
|
-
|
|
101
|
-
const penaltyForMissing = 0.02 * (maxValues - possibleMatchValues);
|
|
102
|
-
const penaltyForMisMatch = 0.05 * (possibleMatchValues - matchingValues);
|
|
103
|
-
debug(`\t points: penaltyForMissing: ${penaltyForMissing}`);
|
|
104
|
-
debug(`\t points: penaltyForMisMatch: ${penaltyForMisMatch}`);
|
|
105
|
-
|
|
106
|
-
const points = Number(Number(0.1 - penaltyForMisMatch - penaltyForMissing).toFixed(2));
|
|
107
|
-
debug(`Total points: ${points}`);
|
|
108
|
-
|
|
109
|
-
return points;
|
|
110
217
|
}
|
|
218
|
+
|
|
111
219
|
});
|
|
220
|
+
|
|
221
|
+
function get008Value(record, label = 'record') {
|
|
222
|
+
if (!record || !record.fields) {
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
const f008 = record.fields.find(f => f.tag === '008');
|
|
226
|
+
if (!f008) {
|
|
227
|
+
return undefined;
|
|
228
|
+
}
|
|
229
|
+
const value = f008.value || defaultValue;
|
|
230
|
+
|
|
231
|
+
if (!value) {
|
|
232
|
+
debugData(`${label}: Failed to extact 008/35-37 value from '${value}'`);
|
|
233
|
+
return defaultValue;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const code = value.slice(35, 38);
|
|
237
|
+
debugData(`${label}: 008 code: '${code}'`);
|
|
238
|
+
return code;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Check if a string is a possible, validly formed language code for a single language
|
|
242
|
+
// Currently accept also codes in capitals
|
|
243
|
+
function isLangCodeForALanguage(code, label = 'record', encoding = undefined) {
|
|
244
|
+
if (!code) {
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if ([undefined, 'ISO 639-2', 'ISO-639-3'].includes(encoding) && code.length !== 3) {
|
|
249
|
+
debugData(`${label}: Code ${code} is not correct length (3) for a language code.`);
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
if (!encoding) {
|
|
253
|
+
// 'mul' should be passed as 'mul' can match 'fin' + 'swe' etc.
|
|
254
|
+
// 'zxx' should be removed by a validator
|
|
255
|
+
// '^^^' is Aleph-specific corruption, not supporting it anymore
|
|
256
|
+
if (code === '|||' || code === ' ' ) { // || code === '^^^' || code === 'mul' || code === 'zxx') {
|
|
257
|
+
debugData(`${label}: Code ${code} is not code for a spesific language.`);
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
// Marc, ISO 639-2 and ISO 639-3 are all three-letters long. Not other language codes seen in Melinda:
|
|
262
|
+
const langCodePattern = /^[a-z][a-z][a-z]$/ui;
|
|
263
|
+
if (!langCodePattern.test(code)) {
|
|
264
|
+
debugData(`${label}: Code ${code} is not valid as a language code`);
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
function getLanguageCodesFrom041Fields(record) {
|
|
273
|
+
// NB! We brutally don't check $2 (language code source) as marc's language codes is practically a subset of ISO 639-2,
|
|
274
|
+
// and also ISO 639-2 and ISO 639-3 overlap to a degree. If we ever run into a trouble with some ISO 639-2 vs 639-3 mismatch, then we'll work it out.
|
|
275
|
+
// Also we could write a validator that converts ISO 639-2 to marc if applicable and maybe even partial support for ISO-639-3.
|
|
276
|
+
// Note that ISO 639-2-B values correspond with marc beteer that ISO 639-2-T. Eg. code for Chinese 'zho' should/code be normalized to 'chi'!
|
|
277
|
+
const values = record.fields.filter(f => f.tag === '041').flatMap(f => getFieldsLanguageCodes(f));
|
|
278
|
+
/*
|
|
279
|
+
// .filter(({ind2}) => ind2 === ' ')
|
|
280
|
+
.map(({subfields}) => subfields)
|
|
281
|
+
.flat()
|
|
282
|
+
.filter(({code}) => code === 'a' || code === 'd')
|
|
283
|
+
.filter(({value}) => value && isLangCodeForALanguage(value))
|
|
284
|
+
.map(({value}) => value);
|
|
285
|
+
*/
|
|
286
|
+
return [...new Set(values)].sort();
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function getFieldsLanguageCodes(field) {
|
|
290
|
+
const relevantSubfields = field.subfields.filter(sf => sf.code === 'a' || sf.code === 'd').filter(sf => isLangCodeForALanguage(sf.value, 'field'));
|
|
291
|
+
|
|
292
|
+
const values = relevantSubfields.map(sf => sf.value).flat();
|
|
293
|
+
|
|
294
|
+
return [...new Set(values)].sort();
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function getSharedValues(list1, list2) {
|
|
298
|
+
return list1.filter(val => list2.includes(val));
|
|
299
|
+
}
|
|
@@ -3,7 +3,7 @@ import createDebugLogger from 'debug';
|
|
|
3
3
|
import naturalPkg from 'natural';
|
|
4
4
|
const {LevenshteinDistance: leven} = naturalPkg;
|
|
5
5
|
|
|
6
|
-
export default ({
|
|
6
|
+
export default ({threshold = 10} = {}) => ({
|
|
7
7
|
name: 'titleVersionOriginal',
|
|
8
8
|
extract: ({record}) => {
|
|
9
9
|
const title = getTitle();
|
|
@@ -38,7 +38,7 @@ export default ({treshold = 10} = {}) => ({
|
|
|
38
38
|
|
|
39
39
|
debug(`'${a}' vs '${b}': Max length = ${maxLength}, distance = ${distance}, percentage = ${percentage}`);
|
|
40
40
|
|
|
41
|
-
if (percentage <=
|
|
41
|
+
if (percentage <= threshold) {
|
|
42
42
|
return 0.3;
|
|
43
43
|
}
|
|
44
44
|
|
|
@@ -7,7 +7,7 @@ const debug = createDebugLogger('@natlibfi/melinda-record-matching:match-detecti
|
|
|
7
7
|
const debugData = debug.extend('data');
|
|
8
8
|
|
|
9
9
|
|
|
10
|
-
export default ({
|
|
10
|
+
export default ({threshold = 10} = {}) => ({
|
|
11
11
|
name: 'Title',
|
|
12
12
|
extract: ({record, recordExternal}) => {
|
|
13
13
|
const label = recordExternal && recordExternal.label ? recordExternal.label : 'record';
|
|
@@ -19,8 +19,9 @@ export default ({treshold = 10} = {}) => ({
|
|
|
19
19
|
// decompose unicode diacritics
|
|
20
20
|
.normalize('NFD')
|
|
21
21
|
// strip non-letters/numbers
|
|
22
|
-
// - note: combined with decomposing unicode
|
|
23
|
-
// - we could precompose the
|
|
22
|
+
// - note: combined with decomposing unicode diacritics this normalizes both 'saa' and 'sää' as 'saa'
|
|
23
|
+
// - we could precompose the Finnish letters back to avoid this
|
|
24
|
+
// - see validator normalize-utf8-diacritics for details
|
|
24
25
|
.replace(/[^\p{Letter}\p{Number}]/gu, '')
|
|
25
26
|
.toLowerCase();
|
|
26
27
|
debug(`${label}: titleString: ${titleAsNormalizedString}`);
|
|
@@ -56,7 +57,7 @@ export default ({treshold = 10} = {}) => ({
|
|
|
56
57
|
|
|
57
58
|
debug(`'${a}' vs '${b}': Max length = ${maxLength}, distance = ${distance}, percentage = ${percentage}`);
|
|
58
59
|
|
|
59
|
-
if (percentage <=
|
|
60
|
+
if (percentage <= threshold) {
|
|
60
61
|
return 0.3;
|
|
61
62
|
}
|
|
62
63
|
|
|
@@ -4,13 +4,13 @@ import * as features from './features/index.js';
|
|
|
4
4
|
|
|
5
5
|
export {features};
|
|
6
6
|
|
|
7
|
-
export default ({strategy,
|
|
7
|
+
export default ({strategy, threshold = 0.8999}, returnStrategy = false, localSettings = {}) => ({recordA, recordB, recordAExternal = {}, recordBExternal = {}}) => {
|
|
8
8
|
const minProbabilityQuantifier = 0.5;
|
|
9
9
|
|
|
10
10
|
const debug = createDebugLogger('@natlibfi/melinda-record-matching:match-detection');
|
|
11
11
|
const debugData = debug.extend('data');
|
|
12
12
|
|
|
13
|
-
debugData(`Strategy: ${JSON.stringify(strategy)},
|
|
13
|
+
debugData(`Strategy: ${JSON.stringify(strategy)}, Threshold: ${JSON.stringify(threshold)}, ReturnStrategy: ${JSON.stringify(returnStrategy)}`);
|
|
14
14
|
debugData(`Records: A: ${recordA}\nB: ${recordB}`);
|
|
15
15
|
debug(`Externals: A: ${JSON.stringify(recordAExternal)}, B: ${JSON.stringify(recordBExternal)}`);
|
|
16
16
|
// We could add here labels for records if we didn't get external labels
|
|
@@ -43,8 +43,8 @@ export default ({strategy, treshold = 0.9}, returnStrategy = false, localSetting
|
|
|
43
43
|
|
|
44
44
|
if (similarityVector.some(v => v >= minProbabilityQuantifier)) {
|
|
45
45
|
const probability = calculateProbability(similarityVector);
|
|
46
|
-
debug(`probability: ${probability} (
|
|
47
|
-
return returnResult({match: probability >=
|
|
46
|
+
debug(`probability: ${probability} (Threshold: ${threshold})`);
|
|
47
|
+
return returnResult({match: probability >= threshold, probability});
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
debugData(`No feature yielded minimum probability amount of points (${minProbabilityQuantifier})`);
|
|
@@ -58,7 +58,7 @@ export default ({strategy, treshold = 0.9}, returnStrategy = false, localSetting
|
|
|
58
58
|
function returnResult(result) {
|
|
59
59
|
if (returnStrategy) {
|
|
60
60
|
debug(`Returning detection strategy with the result`);
|
|
61
|
-
const resultWithStrategy = {match: result.match, probability: result.probability, strategy: formatStrategy(strategy),
|
|
61
|
+
const resultWithStrategy = {match: result.match, probability: result.probability, strategy: formatStrategy(strategy), threshold};
|
|
62
62
|
debugData(`${JSON.stringify(resultWithStrategy)}`);
|
|
63
63
|
return resultWithStrategy;
|
|
64
64
|
}
|