chaffjs 0.27.1 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/dist/cli-text.d.ts.map +1 -1
- package/dist/cli-text.js +6 -4
- package/dist/cli-text.js.map +1 -1
- package/dist/derived/number-word-counts.d.ts +10 -0
- package/dist/derived/number-word-counts.d.ts.map +1 -0
- package/dist/derived/number-word-counts.js +21 -0
- package/dist/derived/number-word-counts.js.map +1 -0
- package/dist/detectors/amount-scale.d.ts +3 -0
- package/dist/detectors/amount-scale.d.ts.map +1 -0
- package/dist/detectors/amount-scale.js +50 -0
- package/dist/detectors/amount-scale.js.map +1 -0
- package/dist/detectors/change-rate.d.ts.map +1 -1
- package/dist/detectors/change-rate.js +12 -1
- package/dist/detectors/change-rate.js.map +1 -1
- package/dist/detectors/currency-notation.d.ts +3 -1
- package/dist/detectors/currency-notation.d.ts.map +1 -1
- package/dist/detectors/currency-notation.js +2 -2
- package/dist/detectors/currency-notation.js.map +1 -1
- package/dist/detectors/derived-numbers.d.ts.map +1 -1
- package/dist/detectors/derived-numbers.js +9 -1
- package/dist/detectors/derived-numbers.js.map +1 -1
- package/dist/detectors/fact-consistency.d.ts.map +1 -1
- package/dist/detectors/fact-consistency.js +14 -1
- package/dist/detectors/fact-consistency.js.map +1 -1
- package/dist/detectors/name-variant.d.ts.map +1 -1
- package/dist/detectors/name-variant.js +18 -10
- package/dist/detectors/name-variant.js.map +1 -1
- package/dist/detectors/registry/amount-scale.d.ts +3 -0
- package/dist/detectors/registry/amount-scale.d.ts.map +1 -0
- package/dist/detectors/registry/amount-scale.js +3 -0
- package/dist/detectors/registry/amount-scale.js.map +1 -0
- package/dist/facts/counted-facts.d.ts +16 -0
- package/dist/facts/counted-facts.d.ts.map +1 -0
- package/dist/facts/counted-facts.js +45 -0
- package/dist/facts/counted-facts.js.map +1 -0
- package/dist/name-variants.d.ts +19 -5
- package/dist/name-variants.d.ts.map +1 -1
- package/dist/name-variants.js +60 -6
- package/dist/name-variants.js.map +1 -1
- package/dist/structure/amount-scale.d.ts +29 -0
- package/dist/structure/amount-scale.d.ts.map +1 -0
- package/dist/structure/amount-scale.js +70 -0
- package/dist/structure/amount-scale.js.map +1 -0
- package/dist/structure/subject-change.d.ts +8 -0
- package/dist/structure/subject-change.d.ts.map +1 -0
- package/dist/structure/subject-change.js +31 -0
- package/dist/structure/subject-change.js.map +1 -0
- package/package.json +3 -3
- package/rules/amount-scale-consistency.yaml +74 -0
- package/rules/change-rate-mismatch.yaml +5 -4
- package/rules/elapsed-years-mismatch.yaml +2 -2
- package/rules/name-variant.yaml +5 -2
- package/rules/summary-fact-mismatch.yaml +2 -2
- package/src/cli-text.ts +6 -4
- package/src/derived/number-word-counts.ts +28 -0
- package/src/detectors/amount-scale.ts +63 -0
- package/src/detectors/change-rate.ts +15 -2
- package/src/detectors/currency-notation.ts +2 -2
- package/src/detectors/derived-numbers.ts +9 -2
- package/src/detectors/fact-consistency.ts +17 -1
- package/src/detectors/name-variant.ts +14 -4
- package/src/detectors/registry/amount-scale.ts +4 -0
- package/src/facts/counted-facts.ts +59 -0
- package/src/name-variants.ts +106 -11
- package/src/structure/amount-scale.ts +97 -0
- package/src/structure/subject-change.ts +35 -0
|
@@ -11,8 +11,8 @@ name:
|
|
|
11
11
|
en: A summary value that differs from the body
|
|
12
12
|
|
|
13
13
|
why:
|
|
14
|
-
ja:
|
|
15
|
-
en: 'The opening and the summary are read by people who never read the body. A value there that differs from the body sends them away with the wrong one. It happens when the body is corrected and the summary is not. Values written as "Item: value" or "The item is value" before the first heading, or in a section headed Summary, Abstract or Overview, are compared with the body''s values for the same item; it is pointed out only when the body gives one value throughout and the summary gives another.'
|
|
14
|
+
ja: 冒頭や「概要」の節は、本文を読まない人も読みます。そこに書いた値が本文と違うと、要約だけを読んだ人が間違った値を持ち帰ります。本文を直したあとに、要約を直し忘れると起きます。最初の見出しより前の文と、概要・要約・まとめの節の「項目:値」「項目は値です」を、本文の同じ項目の値と比べます。英語の文書では、名前の無い数も、数える動詞と数えるもの(We gained 52 new customers)が同じ本文の数と比べます。本文の値が一通りに揃っていて、要約の値がそれと違うときだけ指します。
|
|
15
|
+
en: 'The opening and the summary are read by people who never read the body. A value there that differs from the body sends them away with the wrong one. It happens when the body is corrected and the summary is not. Values written as "Item: value" or "The item is value" before the first heading, or in a section headed Summary, Abstract or Overview, are compared with the body''s values for the same item, and so is a count with no label, by the verb and the words of what it counts ("We gained 52 new customers"); it is pointed out only when the body gives one value throughout and the summary gives another.'
|
|
16
16
|
|
|
17
17
|
message:
|
|
18
18
|
ja: 冒頭・要約の「{label}」 {value} が、本文の {other} と違います
|
package/src/cli-text.ts
CHANGED
|
@@ -43,9 +43,10 @@ const USAGE_JA = `chaff — 文章の読みにくいところを見つけます
|
|
|
43
43
|
chaff facts <file> compare が照合する事実を、書き直す前の控えとして一覧にする
|
|
44
44
|
chaff outline <file> [<後>] 見出しの構成と形(見出しの数・節の平均の長さ・箇条書きの割合・太字)を測る。2 つなら前と後を並べる
|
|
45
45
|
chaff ai-score <file>... [--format json] AI らしさの簡易判定(低・中・高)。生成文に多い目印を、同じジャンルの人の文書と比べる(書いたのが AI かは判定しない)
|
|
46
|
-
chaff grade <items.jsonl> [--out <results.jsonl>] [--json]
|
|
46
|
+
chaff grade <items.jsonl> [--out <results.jsonl>] [--baseline <前の results.jsonl>] [--compact] [--json]
|
|
47
47
|
AI の出力を JSONL でまとめて採点する(指摘の率・事実・引用・合否。何も送らない)
|
|
48
|
-
chaff fix-plan <file> [--json]
|
|
48
|
+
chaff fix-plan <file> [--depth light|structure|register] [--json]
|
|
49
|
+
指摘をルールごとにまとめ、直す方向と確かめのコマンドを、書き直す人や AI 向けの計画にする(何も送らない)
|
|
49
50
|
chaff rules ルールの一覧を、グループごとに表で出す(いまの段階つき)
|
|
50
51
|
chaff rules --json いまの設定とルールの説明を JSON で出す(AI に渡す用)
|
|
51
52
|
chaff baseline <dir> いまある指摘を棚上げする(既存の repo に入れるとき)
|
|
@@ -90,9 +91,10 @@ const USAGE_EN = `chaff — finds what makes writing hard to read. It never rewr
|
|
|
90
91
|
chaff facts <file> list the facts compare checks, as an inventory to keep before a rewrite
|
|
91
92
|
chaff outline <file> [<after>] measure the outline and its shape (headings, average section length, text in lists, bold); two files side by side
|
|
92
93
|
chaff ai-score <file>... [--format json] quick AI-likeness score (low, medium, high): signs common in generated text against human documents of the genre (not a verdict on who wrote it)
|
|
93
|
-
chaff grade <items.jsonl> [--out <results.jsonl>] [--json]
|
|
94
|
+
chaff grade <items.jsonl> [--out <results.jsonl>] [--baseline <earlier results.jsonl>] [--compact] [--json]
|
|
94
95
|
grade a JSONL file of model outputs (finding rates, facts, quotations, pass or fail; sends nothing)
|
|
95
|
-
chaff fix-plan <file> [--json]
|
|
96
|
+
chaff fix-plan <file> [--depth light|structure|register] [--json]
|
|
97
|
+
a plan for whoever rewrites the file, a person or an AI: findings by rule, how to rewrite each, the checks to run after (sends nothing)
|
|
96
98
|
chaff rules the rules as a table, by group, with the level each runs at now
|
|
97
99
|
chaff rules --json the current settings and what each rule is, as JSON (to give to an AI)
|
|
98
100
|
chaff baseline <dir> shelve today's findings (when adding chaff to an existing repository)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Span } from "../plugin.ts";
|
|
2
|
+
import { escapeRegExp } from "../orthography.ts";
|
|
3
|
+
|
|
4
|
+
// A count written as a word before its unit ("twelve years", "Ten years"), which the tree does not read as a quantity.
|
|
5
|
+
// Pure: the number words come in order from one (the lexicon count-number, the position is the number).
|
|
6
|
+
|
|
7
|
+
export type WordCount = Span & { readonly amount: number };
|
|
8
|
+
|
|
9
|
+
const alternation = (words: readonly string[]): string =>
|
|
10
|
+
words
|
|
11
|
+
.filter((word) => word !== "")
|
|
12
|
+
.map(escapeRegExp)
|
|
13
|
+
.join("|");
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Each number word standing as a word of its own, followed by one of the units after a space. A word joined to letters,
|
|
17
|
+
* digits or a dash on either side is the tail of a larger number (二十年's 十, "twenty-one years", "fortyten").
|
|
18
|
+
*/
|
|
19
|
+
export const numberWordCounts = (source: string, numberWords: readonly string[], units: readonly string[]): WordCount[] => {
|
|
20
|
+
const [words, unitWords] = [alternation(numberWords), alternation(units)];
|
|
21
|
+
if (words === "" || unitWords === "") return [];
|
|
22
|
+
const pattern = new RegExp(`(?<![\\p{L}\\p{N}\\p{Pd}])(${words})\\s+(?:${unitWords})(?![\\p{L}\\p{N}])`, "giu");
|
|
23
|
+
const lowered = numberWords.map((word) => word.toLowerCase());
|
|
24
|
+
return [...source.matchAll(pattern)].flatMap((match) => {
|
|
25
|
+
const position = lowered.indexOf((match[1] ?? "").toLowerCase());
|
|
26
|
+
return position < 0 ? [] : [{ start: match.index, end: match.index + match[0].length, amount: position + 1 }];
|
|
27
|
+
});
|
|
28
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// amount-scale-consistency: the reading half. The amounts are currency-notation's (the lexicon currency-notation says
|
|
2
|
+
// how a currency is written); the words of scale and their values are the lexicon amount-multiplier (weight).
|
|
3
|
+
import type { Detector, Finding, ProseDocument } from "../plugin.ts";
|
|
4
|
+
import { escapeRegExp } from "../orthography.ts";
|
|
5
|
+
import { amountValue, scaleMixes, type ScaleWord, type ScaledAmount } from "../structure/amount-scale.ts";
|
|
6
|
+
import { AMOUNT, amountsIn, formsOf, type WrittenAmount } from "./currency-notation.ts";
|
|
7
|
+
import { quoteAround } from "./quote-around.ts";
|
|
8
|
+
|
|
9
|
+
/** How far back a larger part of one amount may start (1億2,000万円 is read from 2,000万円). */
|
|
10
|
+
const LEADING_REACH = 40;
|
|
11
|
+
|
|
12
|
+
const scaleWordsOf = (doc: ProseDocument): ScaleWord[] =>
|
|
13
|
+
(doc.lexicons["amount-multiplier"] ?? []).flatMap((entry) => (entry.weight === undefined ? [] : [{ word: entry.pattern, value: entry.weight }]));
|
|
14
|
+
|
|
15
|
+
/** The larger parts written right before an amount the currency notation read from its last part (1億 before 2,000万円). */
|
|
16
|
+
const leadingParts = (text: string, offset: number, words: readonly ScaleWord[]): string => {
|
|
17
|
+
if (words.length === 0) return "";
|
|
18
|
+
const scales = words.map((word) => escapeRegExp(word.word)).join("|");
|
|
19
|
+
const before = text.slice(Math.max(0, offset - LEADING_REACH), offset);
|
|
20
|
+
return new RegExp(`(?<![0-90-9.,,])(?:${AMOUNT}(?:${scales}))+$`, "u").exec(before)?.[0] ?? "";
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** The written amount without its currency mark, which is on the side its form says. */
|
|
24
|
+
const numberPart = (amount: WrittenAmount): string => {
|
|
25
|
+
const [position = "", ...mark] = amount.form.split(":");
|
|
26
|
+
const pattern = mark.join(":");
|
|
27
|
+
return position === "before" ? amount.written.trimStart().slice(pattern.length) : amount.written.trimEnd().slice(0, -pattern.length);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** The largest word of scale written in the number (万 in 1億2,000万 is smaller than 億), undefined when there is none. */
|
|
31
|
+
const unitOf = (number: string, words: readonly ScaleWord[]): number | undefined => {
|
|
32
|
+
const lower = number.toLowerCase();
|
|
33
|
+
const values = words.filter((word) => lower.includes(word.word.toLowerCase())).map((word) => word.value);
|
|
34
|
+
return values.length === 0 ? undefined : Math.max(...values);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const scaledAmountsOf = (text: string, amounts: readonly WrittenAmount[], words: readonly ScaleWord[]): ScaledAmount[] =>
|
|
38
|
+
amounts.flatMap((amount) => {
|
|
39
|
+
const leading = amount.form.startsWith("after:") ? leadingParts(text, amount.offset, words) : "";
|
|
40
|
+
const written = `${leading}${amount.written}`;
|
|
41
|
+
const number = `${leading}${numberPart(amount)}`;
|
|
42
|
+
const value = amountValue(number, words);
|
|
43
|
+
if (value === undefined) return [];
|
|
44
|
+
return [{ offset: amount.offset - leading.length, written, currency: amount.currency, value, unit: unitOf(number, words) }];
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
export const amountScale: Detector = (doc): Finding[] => {
|
|
48
|
+
const text = doc.prose ?? doc.source;
|
|
49
|
+
const words = scaleWordsOf(doc);
|
|
50
|
+
const amounts = amountsIn(
|
|
51
|
+
text,
|
|
52
|
+
formsOf(doc),
|
|
53
|
+
words.map((word) => word.word),
|
|
54
|
+
);
|
|
55
|
+
return scaleMixes(scaledAmountsOf(text, amounts, words)).map(({ odd, usual }) => ({
|
|
56
|
+
rule: "amount-scale-consistency",
|
|
57
|
+
severity: "info",
|
|
58
|
+
line: 0,
|
|
59
|
+
column: 0,
|
|
60
|
+
quote: quoteAround(text, odd.offset, odd.offset + odd.written.length),
|
|
61
|
+
values: { written: odd.written.trim(), usual: usual.written.trim(), offset: odd.offset },
|
|
62
|
+
}));
|
|
63
|
+
};
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// change-rate-mismatch: the reading half. Builds the figures, the rates and the words of structure/change-rate.ts from the
|
|
2
2
|
// tree's quantities, the numbers counted with a word ("1,200 companies"), the calendar years, and the lexicons change-direction,
|
|
3
3
|
// change-base, change-target, change-break, percent-unit and amount-multiplier.
|
|
4
|
-
import type { Detector, Finding, LexiconEntry, ProseDocument, Span, StructureNode } from "../plugin.ts";
|
|
4
|
+
import type { Detector, Finding, LexiconEntry, ProseDocument, Span, StructureNode, Token } from "../plugin.ts";
|
|
5
5
|
import { inDocumentOrder } from "../structure/issues.ts";
|
|
6
|
+
import { startsOtherSubject } from "../structure/subject-change.ts";
|
|
6
7
|
import { changeRateMismatches, type BaseMark, type Break, type Direction, type Figure, type Period, type Rate } from "../structure/change-rate.ts";
|
|
7
8
|
import { escapeRegExp } from "../orthography.ts";
|
|
8
9
|
import { quoteAt } from "./structure-tree.ts";
|
|
@@ -99,12 +100,24 @@ const directionsOf = (doc: ProseDocument): Direction[] =>
|
|
|
99
100
|
return sign === undefined ? [] : [{ start, end, sign }];
|
|
100
101
|
});
|
|
101
102
|
|
|
103
|
+
/** The sentence's tokens, and the index of the token that starts at offset (-1 when none does). */
|
|
104
|
+
const tokenAt = (doc: ProseDocument, offset: number): { readonly tokens: readonly Token[]; readonly index: number } => {
|
|
105
|
+
const tokens = doc.sentences.find((sentence) => sentence.span.start <= offset && offset < sentence.span.end)?.tokens ?? [];
|
|
106
|
+
return { tokens, index: tokens.findIndex((token) => token.span.start === offset) };
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/** A word that may join two values ("and") does not when another subject follows it ("and costs were"). */
|
|
110
|
+
const joinsTwoValues = (doc: ProseDocument, start: number): boolean => {
|
|
111
|
+
const { tokens, index } = tokenAt(doc, start);
|
|
112
|
+
return index < 0 || !startsOtherSubject(tokens, index);
|
|
113
|
+
};
|
|
114
|
+
|
|
102
115
|
/** Lexicon change-break (「は」, "and"), less the phrases of change-break-not (「上がり」 holds が but starts no subject). */
|
|
103
116
|
const breaksOf = (doc: ProseDocument): Break[] =>
|
|
104
117
|
spansWithout(doc, doc.lexicons["change-break"] ?? [], "change-break-not").map(({ start, end, entry }) => ({
|
|
105
118
|
start,
|
|
106
119
|
end,
|
|
107
|
-
beforeRateOnly: entry.group === "rate",
|
|
120
|
+
beforeRateOnly: entry.group === "rate" && joinsTwoValues(doc, start),
|
|
108
121
|
}));
|
|
109
122
|
|
|
110
123
|
/** A calendar year written alone or with a unit of calendar year (2025, 2025年, 2025年度), and not part of an amount ($2025). */
|
|
@@ -14,7 +14,7 @@ export type WrittenAmount = { readonly offset: number; readonly written: string;
|
|
|
14
14
|
/** 少ないほうの書き方の金額と、多いほうの書き方の例、その通貨の金額の数。 */
|
|
15
15
|
export type CurrencyMinority = { readonly odd: WrittenAmount; readonly usual: string; readonly count: number; readonly of: number };
|
|
16
16
|
|
|
17
|
-
const AMOUNT = "[0-90-9]+(?:[,,][0-90-9]{3})*(?:[..][0-90-9]+)?";
|
|
17
|
+
export const AMOUNT = "[0-90-9]+(?:[,,][0-90-9]{3})*(?:[..][0-90-9]+)?";
|
|
18
18
|
|
|
19
19
|
const alternation = (words: readonly string[]): string => words.map(escapeRegExp).join("|");
|
|
20
20
|
|
|
@@ -71,7 +71,7 @@ export const currencyMinorities = (amounts: readonly WrittenAmount[], limit: num
|
|
|
71
71
|
)
|
|
72
72
|
.toSorted((left, right) => left.odd.offset - right.odd.offset);
|
|
73
73
|
|
|
74
|
-
const formsOf = (doc: ProseDocument): CurrencyForm[] =>
|
|
74
|
+
export const formsOf = (doc: ProseDocument): CurrencyForm[] =>
|
|
75
75
|
(doc.lexicons["currency-notation"] ?? []).flatMap((entry) =>
|
|
76
76
|
entry.position === undefined || entry.group === undefined ? [] : [{ pattern: entry.pattern, position: entry.position, currency: entry.group }],
|
|
77
77
|
);
|
|
@@ -5,6 +5,7 @@ import { overlapsAny, spanIndex } from "../compare/spans.ts";
|
|
|
5
5
|
import { yearOf, type DurationUnit } from "../derived/date-arithmetic.ts";
|
|
6
6
|
import { durationMismatches, type DatedValue, type Duration } from "../derived/durations.ts";
|
|
7
7
|
import { elapsedMismatches, type Elapsed, type OriginWord, type Year } from "../derived/elapsed.ts";
|
|
8
|
+
import { numberWordCounts } from "../derived/number-word-counts.ts";
|
|
8
9
|
import { quoteAt } from "./structure-tree.ts";
|
|
9
10
|
|
|
10
11
|
/** 期間の単位の語彙表。木の数量の単位がどれかに入れば、その単位の期間。 */
|
|
@@ -122,8 +123,14 @@ const isAge = (doc: ProseDocument, quantity: Quantity): boolean =>
|
|
|
122
123
|
positioned(doc, "age-marker", "before").some((word) => textBefore(doc, quantity).endsWith(word)) ||
|
|
123
124
|
positioned(doc, "age-marker", "after").some((word) => textAfter(doc, quantity).startsWith(word));
|
|
124
125
|
|
|
125
|
-
|
|
126
|
-
|
|
126
|
+
/** 年数: 単位が年数の語の数量(10 years、創業10年)と、木が読まない語で書いた数(ten years)。 */
|
|
127
|
+
const elapsedOf = (doc: ProseDocument, quantities: readonly Quantity[]): Elapsed[] => {
|
|
128
|
+
const units = patternsOf(doc, "elapsed-unit");
|
|
129
|
+
const counted = quantities.filter((quantity) => !isAge(doc, quantity) && units.some((unit) => unit.normalize("NFKC") === quantity.unit));
|
|
130
|
+
const taken = spanIndex(quantities);
|
|
131
|
+
const worded = numberWordCounts(doc.source, patternsOf(doc, "count-number"), units).filter((count) => !overlapsAny(taken, count));
|
|
132
|
+
return [...counted, ...worded].toSorted((left, right) => left.start - right.start);
|
|
133
|
+
};
|
|
127
134
|
|
|
128
135
|
/** 「aged 45」の 45: 英語の木は単位の無い数を読まないので、年齢の印のすぐ後ろの数を読む。 */
|
|
129
136
|
const AGE_NUMBER = /^\s*(\d{1,3})(?!\d)/u;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Detector, Finding, ProseDocument, StructureNode } from "../plugin.ts";
|
|
2
|
+
import { countedFacts, countedPhraseAt, type CountedPhrase } from "../facts/counted-facts.ts";
|
|
2
3
|
import { nameSpans } from "../compare/proper-nouns.ts";
|
|
3
4
|
import { factValues, type FactValue } from "../facts/fact-values.ts";
|
|
4
5
|
import { labelledFacts, type AttributePhrase, type FactWords } from "../facts/labelled-facts.ts";
|
|
@@ -50,5 +51,20 @@ const findingOf =
|
|
|
50
51
|
/** 同じ節で、同じ名前に二通りの値(締切:10月5日 と 締切:10月7日)。 */
|
|
51
52
|
export const factConflict: Detector = (doc): Finding[] => scopeConflicts(factsOf(doc)).map(findingOf("fact-conflict", doc));
|
|
52
53
|
|
|
54
|
+
const countedPhrasesOf = (doc: ProseDocument): CountedPhrase[] =>
|
|
55
|
+
doc.sentences.flatMap((sentence) => {
|
|
56
|
+
const tokens = sentence.tokens ?? [];
|
|
57
|
+
return tokens.flatMap((token, index) => (token.pos === "NUM" ? countedPhraseAt(tokens, index) : []));
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
/** The values named by what they count, scoped like the labelled ones. Only the summary is compared with them. */
|
|
61
|
+
const countedFactsOf = (doc: ProseDocument): ScopedFact[] => {
|
|
62
|
+
const tree = doc.structure;
|
|
63
|
+
if (tree === undefined) return [];
|
|
64
|
+
const values = factValues(tree, doc.source, nameSpans(doc));
|
|
65
|
+
return scopedFacts(countedFacts(doc.source, values, countedPhrasesOf(doc)), tree, doc.source, patternsOf(doc, "summary-heading"));
|
|
66
|
+
};
|
|
67
|
+
|
|
53
68
|
/** 冒頭や要約の値が、本文の同じ名前の値と違う。 */
|
|
54
|
-
export const summaryFactMismatch: Detector = (doc): Finding[] =>
|
|
69
|
+
export const summaryFactMismatch: Detector = (doc): Finding[] =>
|
|
70
|
+
summaryConflicts([...factsOf(doc), ...countedFactsOf(doc)]).map(findingOf("summary-fact-mismatch", doc));
|
|
@@ -1,10 +1,19 @@
|
|
|
1
|
-
import type { Detector, Finding } from "../plugin.ts";
|
|
2
|
-
import { mentionsIn, nameVariants } from "../name-variants.ts";
|
|
1
|
+
import type { Detector, Finding, ProseDocument } from "../plugin.ts";
|
|
2
|
+
import { mentionsIn, nameVariants, suffixedNamesIn, type VariantChars } from "../name-variants.ts";
|
|
3
3
|
import { quoteAt } from "./structure-tree.ts";
|
|
4
4
|
|
|
5
|
+
// 人の名前と読ませる敬称(様、さん)は語彙表 person-suffix、字体の違う同じ字(斎・斉・齋)は name-variant-char が組(group)ごとに言う。
|
|
6
|
+
|
|
7
|
+
const variantCharsOf = (doc: ProseDocument): VariantChars =>
|
|
8
|
+
new Map((doc.lexicons["name-variant-char"] ?? []).flatMap((entry): [string, string][] => (entry.group === undefined ? [] : [[entry.pattern, entry.group]])));
|
|
9
|
+
|
|
5
10
|
/** 同じ名前を、文書の中で少しだけ違う形に書いた所(GitHub と Github、山田太郎 と 山田太朗)。少ないほうを指す。 */
|
|
6
|
-
export const nameVariant: Detector = (doc): Finding[] =>
|
|
7
|
-
|
|
11
|
+
export const nameVariant: Detector = (doc): Finding[] => {
|
|
12
|
+
const suffixes = (doc.lexicons["person-suffix"] ?? []).map((entry) => entry.pattern);
|
|
13
|
+
const chars = variantCharsOf(doc);
|
|
14
|
+
const tagged = doc.sentences.flatMap((sentence) => mentionsIn(sentence.tokens ?? [], doc.source, suffixes));
|
|
15
|
+
const mentions = [...tagged, ...suffixedNamesIn(doc.prose ?? doc.source, suffixes, chars, tagged)].toSorted((left, right) => left.offset - right.offset);
|
|
16
|
+
return nameVariants(mentions, chars).map(({ mention, usual, kind }) => ({
|
|
8
17
|
rule: "",
|
|
9
18
|
severity: "warning",
|
|
10
19
|
line: 0,
|
|
@@ -13,3 +22,4 @@ export const nameVariant: Detector = (doc): Finding[] =>
|
|
|
13
22
|
values: { name: mention.surface, usual, offset: mention.offset },
|
|
14
23
|
variant: kind,
|
|
15
24
|
}));
|
|
25
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Span, Token } from "../plugin.ts";
|
|
2
|
+
import type { FactValue } from "./fact-values.ts";
|
|
3
|
+
import type { Fact } from "./labelled-facts.ts";
|
|
4
|
+
|
|
5
|
+
// A value with no label, named by who counts, the verb that counts and what is counted ("We gained 52 new customers": we
|
|
6
|
+
// + gain + new customer). A summary that states such a value is compared with the body's value for the same three. Pure:
|
|
7
|
+
// reads the tagger's parts of speech and lemmas.
|
|
8
|
+
|
|
9
|
+
/** The words right after a number that say what it counts, and the key of who counts it with which verb. */
|
|
10
|
+
export type CountedPhrase = Span & { readonly label: string; readonly key: string };
|
|
11
|
+
|
|
12
|
+
/** How many words after a number may name what it counts ("new enterprise customers"). */
|
|
13
|
+
const MAX_COUNTED_WORDS = 3;
|
|
14
|
+
const COUNTED_WORD: ReadonlySet<string> = new Set(["ADJ", "NOUN"]);
|
|
15
|
+
const SUBJECT_WORD: ReadonlySet<string> = new Set(["NOUN", "PROPN", "PRON"]);
|
|
16
|
+
/** A number right after a preposition is part of something larger: the pool in "1 of 3 proposals". */
|
|
17
|
+
const NOT_A_COUNT_AFTER: ReadonlySet<string> = new Set(["ADP", "NUM"]);
|
|
18
|
+
|
|
19
|
+
/** The number is the end of a range ("between 10 and 12 customers") or of a pair ("1 of 3"). */
|
|
20
|
+
const isPartOfLarger = (tokens: readonly Token[], index: number): boolean => {
|
|
21
|
+
const [twoBefore, before] = [tokens[index - 2], tokens[index - 1]];
|
|
22
|
+
return NOT_A_COUNT_AFTER.has(before?.pos ?? "") || (before?.pos === "CCONJ" && twoBefore?.pos === "NUM");
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const wordOf = (token: Token): string => (token.lemma ?? token.surface).toLowerCase();
|
|
26
|
+
|
|
27
|
+
/** The adjectives and nouns right after the number at index, up to the last noun ("new customers"). */
|
|
28
|
+
const countedWords = (tokens: readonly Token[], index: number): Token[] => {
|
|
29
|
+
const after = tokens.slice(index + 1, index + 1 + MAX_COUNTED_WORDS);
|
|
30
|
+
const stop = after.findIndex((token) => !COUNTED_WORD.has(token.pos));
|
|
31
|
+
const run = stop < 0 ? after : after.slice(0, stop);
|
|
32
|
+
return run.slice(0, run.findLastIndex((token) => token.pos === "NOUN") + 1);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* What the number at index in a sentence's tokens counts: the words after it, the nearest verb before it, and the nearest
|
|
37
|
+
* noun or pronoun before that verb (who counts). None when one of the three is missing or the number is part of a larger one.
|
|
38
|
+
*/
|
|
39
|
+
export const countedPhraseAt = (tokens: readonly Token[], index: number): CountedPhrase[] => {
|
|
40
|
+
if (isPartOfLarger(tokens, index)) return [];
|
|
41
|
+
const words = countedWords(tokens, index);
|
|
42
|
+
const verbAt = tokens.slice(0, index).findLastIndex((token) => token.pos === "VERB");
|
|
43
|
+
const subject = tokens.slice(0, Math.max(0, verbAt)).findLast((token) => SUBJECT_WORD.has(token.pos));
|
|
44
|
+
const [first, last, verb] = [words[0], words.at(-1), tokens[verbAt]];
|
|
45
|
+
if (first === undefined || last === undefined || verb === undefined || subject === undefined) return [];
|
|
46
|
+
const key = ["counted", wordOf(subject), wordOf(verb), ...words.map(wordOf)].join("\u0000");
|
|
47
|
+
return [{ start: first.span.start, end: last.span.end, label: words.map((token) => token.surface).join(" "), key }];
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** A calendar year ("2026 revenue") is a point in time, not a count. */
|
|
51
|
+
const YEAR = /^(?:1[89]|2[01])\d{2}$/u;
|
|
52
|
+
|
|
53
|
+
/** The fact of each number with no unit that a counted phrase follows after one character (the space). */
|
|
54
|
+
export const countedFacts = (source: string, values: readonly FactValue[], phrases: readonly CountedPhrase[]): Fact[] =>
|
|
55
|
+
values.flatMap((value) => {
|
|
56
|
+
if (value.kind !== "quantity" || value.unit !== "" || YEAR.test(source.slice(value.start, value.end))) return [];
|
|
57
|
+
const phrase = phrases.find((candidate) => candidate.start === value.end + 1);
|
|
58
|
+
return phrase === undefined ? [] : [{ label: phrase.label, key: phrase.key, value }];
|
|
59
|
+
});
|
package/src/name-variants.ts
CHANGED
|
@@ -1,13 +1,26 @@
|
|
|
1
1
|
import type { Token } from "./plugin.ts";
|
|
2
|
+
import { escapeRegExp } from "./orthography.ts";
|
|
2
3
|
|
|
3
4
|
// 同じ名前(人・会社・製品)を、文書の中で少しだけ違う形に書いた所。どの形が正しいかは決めず、少ないほうを指す。
|
|
4
5
|
// 三通りで同じ名前と見る。書き方の違いだけ(GitHub と Github、Mac OS と macOS)、読みが同じ(山田太郎 と 山田太朗)、
|
|
5
6
|
// 英字の一字違い(Microsoft と Microsft)。一字違いは別の名前でもありうるので、多いほうが二度以上、少ないほうが一度だけのときに限る。
|
|
6
7
|
|
|
7
|
-
/**
|
|
8
|
-
|
|
8
|
+
/**
|
|
9
|
+
* words は名前の語(記号を除く)。読みが同じ二つの名前が、どの語で違うかを見るため。person は人の名前と読めるもの(解析器が
|
|
10
|
+
* 人名と読むか、敬称が付く)。
|
|
11
|
+
*/
|
|
12
|
+
export type NameMention = {
|
|
13
|
+
readonly surface: string;
|
|
14
|
+
readonly offset: number;
|
|
15
|
+
readonly reading: string | undefined;
|
|
16
|
+
readonly words: readonly string[];
|
|
17
|
+
readonly person?: boolean;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type NameVariant = { readonly mention: NameMention; readonly usual: string; readonly kind: "spelling" | "reading" | "near" | "character" };
|
|
9
21
|
|
|
10
|
-
|
|
22
|
+
/** 字体の違う同じ字(斎・斉・齋)。字から、その組の代表の字へ。 */
|
|
23
|
+
export type VariantChars = ReadonlyMap<string, string>;
|
|
11
24
|
|
|
12
25
|
const NAME = "PROPN";
|
|
13
26
|
/**
|
|
@@ -58,11 +71,18 @@ const withoutEdgeQuotes = (text: string): string => {
|
|
|
58
71
|
*/
|
|
59
72
|
const NOT_COMPARED = /@|(?:^|\s)\p{L}(?:\s|$)/u;
|
|
60
73
|
|
|
74
|
+
/** 人名と読む解析器の印(UD の NameType: 姓 Sur、名 Giv、どちらとも言えない人名 Prs)。 */
|
|
75
|
+
const PERSON_TYPES: ReadonlySet<string> = new Set(["Sur", "Giv", "Prs"]);
|
|
76
|
+
|
|
77
|
+
/** 人の名前か。解析器が人名と読む語を含むか、すぐ後ろに敬称(様、さん、氏)が付く。 */
|
|
78
|
+
const isPerson = (run: readonly Token[], next: Token | undefined, suffixes: readonly string[]): boolean =>
|
|
79
|
+
run.some((token) => PERSON_TYPES.has(token.features?.["NameType"] ?? "")) || (next !== undefined && suffixes.includes(next.surface));
|
|
80
|
+
|
|
61
81
|
/**
|
|
62
82
|
* 文の語から、名前の現れ。surface は source の上の書いたまま(折り返しの空白は一つにまとめる)。
|
|
63
83
|
* 大文字だけの名前(ACME INC、NASA)は外す。契約書の署名欄や略語で、ふつうの書き方の別の形ではない。小文字の英字だけの語も外す。
|
|
64
84
|
*/
|
|
65
|
-
export const mentionsIn = (tokens: readonly Token[], source: string): NameMention[] =>
|
|
85
|
+
export const mentionsIn = (tokens: readonly Token[], source: string, personSuffixes: readonly string[] = []): NameMention[] =>
|
|
66
86
|
runsOf(tokens).flatMap((run) => {
|
|
67
87
|
const first = run[0];
|
|
68
88
|
const last = run.at(-1);
|
|
@@ -73,9 +93,39 @@ export const mentionsIn = (tokens: readonly Token[], source: string): NameMentio
|
|
|
73
93
|
const words = run.filter((token) => !JOINERS.has(token.surface));
|
|
74
94
|
const readings = words.map((token) => token.reading);
|
|
75
95
|
const reading = readings.every((value) => value !== undefined && value !== "") ? readings.join("") : undefined;
|
|
76
|
-
|
|
96
|
+
const person = isPerson(run, tokens[tokens.indexOf(last) + 1], personSuffixes);
|
|
97
|
+
return [{ surface, offset: first.span.start, reading, words: words.map((token) => token.surface), person }];
|
|
77
98
|
});
|
|
78
99
|
|
|
100
|
+
/** 敬称の前の名前の字数(髙橋、髙橋太郎)。長い漢字の連なり(株式会社髙橋)は名前だけを切り出せない。 */
|
|
101
|
+
const MAX_SUFFIXED_NAME = 4;
|
|
102
|
+
const MIN_SUFFIXED_NAME = 2;
|
|
103
|
+
/** 敬称の前を読む範囲(UTF-16 の単位)。名前より長い漢字の連なりを、長いまま見るため。 */
|
|
104
|
+
const SUFFIX_LOOKBACK = 20;
|
|
105
|
+
const HAN = /^\p{Script=Han}$/u;
|
|
106
|
+
|
|
107
|
+
/** 文字列の終わりの漢字の連なり。 */
|
|
108
|
+
const trailingHan = (text: string): string => {
|
|
109
|
+
const letters = [...text];
|
|
110
|
+
return letters.slice(letters.findLastIndex((letter) => !HAN.test(letter)) + 1).join("");
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 解析器が名前と読めなかった、字体の違う字を含む人の名前(髙橋様、𠮷田様、﨑山様)。敬称のすぐ前の漢字の連なりで、語彙表の字を
|
|
115
|
+
* 含み、ほかの名前の現れと重ならないもの。
|
|
116
|
+
*/
|
|
117
|
+
export const suffixedNamesIn = (source: string, suffixes: readonly string[], chars: VariantChars, taken: readonly NameMention[]): NameMention[] =>
|
|
118
|
+
suffixes.flatMap((suffix) =>
|
|
119
|
+
[...source.matchAll(new RegExp(escapeRegExp(suffix), "gu"))].flatMap((match) => {
|
|
120
|
+
const run = trailingHan(source.slice(Math.max(0, match.index - SUFFIX_LOOKBACK), match.index));
|
|
121
|
+
const length = [...run].length;
|
|
122
|
+
if (length < MIN_SUFFIXED_NAME || length > MAX_SUFFIXED_NAME || ![...run].some((letter) => chars.has(letter))) return [];
|
|
123
|
+
const offset = match.index - run.length;
|
|
124
|
+
if (taken.some((mention) => mention.offset < match.index && offset < mention.offset + mention.surface.length)) return [];
|
|
125
|
+
return [{ surface: run, offset, reading: undefined, words: [run], person: true }];
|
|
126
|
+
}),
|
|
127
|
+
);
|
|
128
|
+
|
|
79
129
|
/** 比べる形。幅(AWS と AWS)、大文字小文字、空白と記号は名前を変えない。 */
|
|
80
130
|
export const nameKey = (surface: string): string =>
|
|
81
131
|
surface
|
|
@@ -83,14 +133,19 @@ export const nameKey = (surface: string): string =>
|
|
|
83
133
|
.toLowerCase()
|
|
84
134
|
.replaceAll(/[\s\p{P}\p{S}]/gu, "");
|
|
85
135
|
|
|
86
|
-
|
|
136
|
+
/** person: どれかの現れが人の名前と読める。 */
|
|
137
|
+
type Tally = { readonly surface: string; readonly first: NameMention; readonly count: number; readonly person: boolean };
|
|
87
138
|
|
|
88
139
|
const talliesOf = (mentions: readonly NameMention[]): Tally[] => {
|
|
89
|
-
const tallies = new Map<string, { surface: string; first: NameMention; count: number }>();
|
|
140
|
+
const tallies = new Map<string, { surface: string; first: NameMention; count: number; person: boolean }>();
|
|
90
141
|
mentions.forEach((mention) => {
|
|
91
142
|
const tally = tallies.get(mention.surface);
|
|
92
|
-
|
|
93
|
-
|
|
143
|
+
const person = mention.person === true;
|
|
144
|
+
if (tally === undefined) tallies.set(mention.surface, { surface: mention.surface, first: mention, count: 1, person });
|
|
145
|
+
else {
|
|
146
|
+
tally.count += 1;
|
|
147
|
+
tally.person ||= person;
|
|
148
|
+
}
|
|
94
149
|
});
|
|
95
150
|
return [...tallies.values()];
|
|
96
151
|
};
|
|
@@ -191,8 +246,46 @@ const isSlipOf = (tally: Tally, other: Tally): boolean => {
|
|
|
191
246
|
|
|
192
247
|
const nearVariants = (tallies: readonly Tally[]): NameVariant[] => variantsIn(groupBy(tallies, maskedKeys), "near", isSlipOf);
|
|
193
248
|
|
|
194
|
-
/**
|
|
195
|
-
|
|
249
|
+
/** 字体の違う字(斎と斉)を組の代表の字に寄せた形。 */
|
|
250
|
+
const characterKey = (tally: Tally, chars: VariantChars): string => [...tally.surface].map((letter) => chars.get(letter) ?? letter).join("");
|
|
251
|
+
|
|
252
|
+
/** 人の名前を、字体の違う同じ字(斎藤 と 斉藤、渡辺 と 渡邊)で書き分けた所。 */
|
|
253
|
+
const characterVariants = (tallies: readonly Tally[], chars: VariantChars): NameVariant[] =>
|
|
254
|
+
variantsIn(
|
|
255
|
+
groupBy(
|
|
256
|
+
tallies.filter((tally) => tally.person),
|
|
257
|
+
(tally) => [characterKey(tally, chars)],
|
|
258
|
+
),
|
|
259
|
+
"character",
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
/** 字数が同じで、一字だけが違う(松本 と 松元)。 */
|
|
263
|
+
const oneCharApart = (left: string, right: string): boolean => {
|
|
264
|
+
const [leftChars, rightChars] = [[...left], [...right]];
|
|
265
|
+
return leftChars.length === rightChars.length && leftChars.filter((char, index) => char !== rightChars[index]).length === 1;
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* 読みが同じ人の名前が、一字だけ違う(松本 と 松元)。読みの同じ別の姓(伊藤 と 伊東)もあるので、多いほうが二度以上、
|
|
270
|
+
* 少ないほうが一度だけのときに限る。
|
|
271
|
+
*/
|
|
272
|
+
const isPersonSlipOf = (tally: Tally, other: Tally): boolean => tally.count === 1 && other.count >= 2 && oneCharApart(tally.surface, other.surface);
|
|
273
|
+
|
|
274
|
+
const personReadingVariants = (tallies: readonly Tally[]): NameVariant[] =>
|
|
275
|
+
variantsIn(
|
|
276
|
+
groupBy(
|
|
277
|
+
tallies.filter((tally) => tally.person),
|
|
278
|
+
(tally) => (tally.first.reading === undefined ? [] : [tally.first.reading]),
|
|
279
|
+
),
|
|
280
|
+
"reading",
|
|
281
|
+
isPersonSlipOf,
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* 同じ名前の、少ないほうの書き方。書き方ごとに最初の現れを一つ。同じ現れを二つの見方が言えば、先の見方だけ。chars は字体の
|
|
286
|
+
* 違う同じ字の組(語彙表 name-variant-char)。
|
|
287
|
+
*/
|
|
288
|
+
export const nameVariants = (mentions: readonly NameMention[], chars: VariantChars = new Map()): NameVariant[] => {
|
|
196
289
|
const tallies = talliesOf(mentions);
|
|
197
290
|
const found = [
|
|
198
291
|
...variantsIn(
|
|
@@ -205,6 +298,8 @@ export const nameVariants = (mentions: readonly NameMention[]): NameVariant[] =>
|
|
|
205
298
|
oneWordApart,
|
|
206
299
|
),
|
|
207
300
|
...nearVariants(tallies),
|
|
301
|
+
...characterVariants(tallies, chars),
|
|
302
|
+
...personReadingVariants(tallies),
|
|
208
303
|
];
|
|
209
304
|
const reported = new Set<string>();
|
|
210
305
|
return found
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// An amount written in digits where the document writes amounts of that size with a word of scale, or the other way
|
|
2
|
+
// round (250,000円 beside 30万円; $2,500,000 beside $1.5 million). Small amounts in digits and large ones with 万 or
|
|
3
|
+
// million is one way of writing, not a mix, so only amounts of the same order of magnitude are compared. Pure.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* One amount: where it is, as written, its currency, its value, and the largest word of scale it was written with (unit:
|
|
7
|
+
* 10000 for 30万円, 1000000 for $1.5 million), undefined when it was written in digits only.
|
|
8
|
+
*/
|
|
9
|
+
export type ScaledAmount = {
|
|
10
|
+
readonly offset: number;
|
|
11
|
+
readonly written: string;
|
|
12
|
+
readonly currency: string;
|
|
13
|
+
readonly value: number;
|
|
14
|
+
readonly unit: number | undefined;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** The amount written the other way, and the first one of its size written the usual way. */
|
|
18
|
+
export type ScaleMix = { readonly odd: ScaledAmount; readonly usual: ScaledAmount };
|
|
19
|
+
|
|
20
|
+
/** A digit amount with more significant digits than this (1,234,567円) is a precise figure no word of scale would write. */
|
|
21
|
+
const MAX_SIGNIFICANT_DIGITS = 3;
|
|
22
|
+
|
|
23
|
+
const significantDigits = (value: number): number => {
|
|
24
|
+
const digits = [...String(value).replace(".", "")];
|
|
25
|
+
const first = digits.findIndex((digit) => digit !== "0");
|
|
26
|
+
return first < 0 ? 0 : digits.findLastIndex((digit) => digit !== "0") - first + 1;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const isScaled = (amount: ScaledAmount): boolean => amount.unit !== undefined;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Whether the amount could be written the other way. A digit one when it is round (250,000, not 250,500); a scaled one when
|
|
33
|
+
* it is at least one of its unit: "£0.2 million" is written to line up with larger amounts beside it, not to give a size.
|
|
34
|
+
*/
|
|
35
|
+
const comparable = (amount: ScaledAmount): boolean =>
|
|
36
|
+
amount.value > 0 && (amount.unit === undefined ? significantDigits(amount.value) <= MAX_SIGNIFICANT_DIGITS : amount.value >= amount.unit);
|
|
37
|
+
|
|
38
|
+
/** The order of magnitude, from the digits of the whole part (Math.log10 is not exact at every power of ten). */
|
|
39
|
+
const magnitudeOf = (value: number): number => String(Math.floor(value)).length - 1;
|
|
40
|
+
|
|
41
|
+
const countScaled = (amounts: readonly ScaledAmount[]): number => amounts.filter(isScaled).length;
|
|
42
|
+
|
|
43
|
+
/** Whether the scaled way is the odd one in a size: the fewer there, then the fewer at that size and above, then the later. */
|
|
44
|
+
const oddIsScaled = (band: readonly ScaledAmount[], larger: readonly ScaledAmount[]): boolean => {
|
|
45
|
+
const scaledInBand = countScaled(band);
|
|
46
|
+
if (scaledInBand * 2 !== band.length) return scaledInBand * 2 < band.length;
|
|
47
|
+
const scaledLarger = countScaled(larger);
|
|
48
|
+
if (scaledLarger * 2 !== larger.length) return scaledLarger * 2 < larger.length;
|
|
49
|
+
const firstScaled = band.findIndex(isScaled);
|
|
50
|
+
const firstDigits = band.findIndex((amount) => !isScaled(amount));
|
|
51
|
+
return firstScaled > firstDigits;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const mixesOfCurrency = (amounts: readonly ScaledAmount[]): ScaleMix[] => {
|
|
55
|
+
const magnitudes = [...new Set(amounts.map((amount) => magnitudeOf(amount.value)))];
|
|
56
|
+
return magnitudes.flatMap((magnitude) => {
|
|
57
|
+
const band = amounts.filter((amount) => magnitudeOf(amount.value) === magnitude);
|
|
58
|
+
const scaledIsOdd = oddIsScaled(
|
|
59
|
+
band,
|
|
60
|
+
amounts.filter((amount) => magnitudeOf(amount.value) >= magnitude),
|
|
61
|
+
);
|
|
62
|
+
const usual = band.find((amount) => isScaled(amount) !== scaledIsOdd);
|
|
63
|
+
if (usual === undefined) return [];
|
|
64
|
+
return band.filter((amount) => isScaled(amount) === scaledIsOdd).map((amount) => ({ odd: amount, usual }));
|
|
65
|
+
});
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** For each currency, the amounts written the other way from the rest of their order of magnitude, in document order. */
|
|
69
|
+
export const scaleMixes = (amounts: readonly ScaledAmount[]): ScaleMix[] => {
|
|
70
|
+
const ordered = amounts.filter(comparable).toSorted((left, right) => left.offset - right.offset);
|
|
71
|
+
return [...new Set(ordered.map((amount) => amount.currency))]
|
|
72
|
+
.flatMap((currency) => mixesOfCurrency(ordered.filter((amount) => amount.currency === currency)))
|
|
73
|
+
.toSorted((left, right) => left.odd.offset - right.odd.offset);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** A word of scale and the value it multiplies by (万 10000, million 1000000). */
|
|
77
|
+
export type ScaleWord = { readonly word: string; readonly value: number };
|
|
78
|
+
|
|
79
|
+
const NUMBER_THEN_WORD = /(\d+(?:,\d{3})*(?:\.\d+)?)\s?([^\s\d.,]*)/gu;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The value of an amount as written, without its currency: 1億2,000万 is 120000000, 1.5 million is 1500000. Undefined when a
|
|
83
|
+
* part is followed by a word that is not one of scale, or nothing is read.
|
|
84
|
+
*/
|
|
85
|
+
export const amountValue = (written: string, words: readonly ScaleWord[]): number | undefined => {
|
|
86
|
+
const parts = [...written.normalize("NFKC").matchAll(NUMBER_THEN_WORD)];
|
|
87
|
+
if (parts.length === 0) return undefined;
|
|
88
|
+
const values = parts.map((part) => {
|
|
89
|
+
const number = Number((part[1] ?? "").replaceAll(",", ""));
|
|
90
|
+
const word = (part[2] ?? "").toLowerCase();
|
|
91
|
+
if (word === "") return number;
|
|
92
|
+
const scale = words.find((entry) => entry.word.toLowerCase() === word);
|
|
93
|
+
return scale === undefined ? Number.NaN : number * scale.value;
|
|
94
|
+
});
|
|
95
|
+
const total = values.reduce((sum, value) => sum + value, 0);
|
|
96
|
+
return Number.isFinite(total) ? total : undefined;
|
|
97
|
+
};
|