Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions server/api/src/services/lintEngine/rules/conflict.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,36 @@ describe("extractFacts", () => {
expect(a[0]?.value).toBe(b[0]?.value);
});

it("空白区切りの thousand-grouping を正規化する / normalises whitespace-separated numbers", () => {
// `1 000 円` も `1,000円` / `1000円` と同じ事実として扱う。
// `1 000 円` must collapse to the same canonical value as other variants.
const a = extractFacts("月額料金は 1 000 円 です。");
const b = extractFacts("月額料金は 1,000円 です。");
expect(a[0]?.value).toBe("1000円");
expect(a[0]?.value).toBe(b[0]?.value);
});

it("百万単位の空白区切りを正規化する / normalises million-scale grouped numbers", () => {
const facts = extractFacts("年間売上は 1 000 000 円 です。");
expect(facts[0]?.value).toBe("1000000円");
});

it("末尾の小数ゼロを正規化する / normalises trailing decimal zeros", () => {
// `1000.0円` と `1000円` は同じ数値を指す。parseFloat で末尾ゼロを畳む。
// `1000.0円` and `1000円` denote the same number; parseFloat canonicalises.
const a = extractFacts("月額料金は 1000.0円 です。");
const b = extractFacts("月額料金は 1000円 です。");
expect(a[0]?.value).toBe("1000円");
expect(a[0]?.value).toBe(b[0]?.value);
});

it("小数を含む数値を正規化する / normalises decimal numbers", () => {
const a = extractFacts("富士山の標高は 3.776km である。");
const b = extractFacts("富士山の標高は 3.7760km である。");
expect(a[0]?.value).toBe("3.776km");
expect(a[0]?.value).toBe(b[0]?.value);
});

it("日付の format 違いを同一値として正規化する / normalises date format variants", () => {
const a = extractFacts("リリース日は 2026-04-19 です。");
const b = extractFacts("リリース日は 2026/4/19 です。");
Expand Down
45 changes: 36 additions & 9 deletions server/api/src/services/lintEngine/rules/conflict.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,20 @@ import type { LintRuleResult, LintFindingCandidate } from "../types.js";
* Regex patterns for extracting numeric/date claims from content.
*/
const DATE_PATTERN = /(\d{4})[年/-](\d{1,2})[月/-](\d{1,2})[日]?/g;
// `\d[\d,.]*` (not `+`) so single-digit claims like `3人` / `7km` / `5年` are
// matched. `+` would require at least two numeric characters.
// `+` だと 2 文字以上必要になり 1 桁の主張 (3人 等) を取りこぼすため `*` を使う。
const NUMBER_PATTERN = /(\d[\d,.]*)\s*(km|m|kg|g|人|円|ドル|年|歳|万|億)/g;
// 数値パターン:
// - `\d[\d,.]*` は `1`, `1,000`, `3.14` 等を吸収。`*` (not `+`) なので 1 桁
// の主張 `3人` / `7km` / `5年` も取りこぼさない。
// - `(?: \d{3})*` で「半角スペース + 3 桁」の繰り返しを許容し、thousand-
// grouping の別表記 `1 000 円` / `1 000 000 円` を吸収する。
// ASCII スペース以外は受けない(`100 3人` のような偽マッチを避けるため、
// 3 桁ずつでないと拾わない)。
// Numeric pattern:
// - `\d[\d,.]*` matches `1`, `1,000`, `3.14`, etc. Using `*` (not `+`) keeps
// single-digit claims (`3人` / `7km` / `5年`) from being dropped.
// - `(?: \d{3})*` accepts space-grouped thousands (`1 000 円` /
// `1 000 000 円`). Requiring exactly 3 digits after each space prevents
// false grouping like `100 3人` → `1003人`.
const NUMBER_PATTERN = /(\d[\d,.]*(?: \d{3})*)\s*(km|m|kg|g|人|円|ドル|年|歳|万|億)/g;

/**
* 日付値を `YYYY-M-D` (パディングなし) に正規化する。`2026-04-19` と
Expand All @@ -32,14 +42,31 @@ function normalizeDateValue(raw: string): string {
}

/**
* 数値値から区切り文字(カンマ・空白)を取り除き正規化する。
* `1,000円` と `1000円`、`1 000 円` を同値として扱う。
* 数値値を canonical な `<number><unit>` 形式に正規化する。
* - 区切り文字(カンマ・空白)を除去
* - 単位を末尾から切り出して `parseFloat` で数値化し、末尾ゼロや `.0` を畳む
* (`1,000円` / `1000円` / `1 000 円` / `1000.0円` → すべて `1000円`)
*
* Normalize a matched number+unit string so separator-only variants
* (`1,000円` vs `1000円` vs `1 000 円`) collapse to the same value.
* Normalize a matched number+unit string to a canonical `<number><unit>` form.
* Thousands separators (comma/space) are stripped and the numeric portion is
* run through `parseFloat` so format-only variants — separators as well as
* trailing decimal zeros (`1000` vs `1000.0`) — all collapse to one value.
*
* NOTE: 入力は常に日本語 / 英語圏の表記 (`.` = 小数点、`,` = 千単位) を前提
* とする。ロケール別のカンマ=小数点は対象外(`lintEngine` は現状日本語
* コンテンツ向けのため)。
*/
function normalizeNumberValue(raw: string): string {
return raw.replace(/[,\s]+/g, "").toLowerCase();
const stripped = raw.replace(/[,\s]+/g, "");
const unitMatch = stripped.match(/(km|m|kg|g|人|円|ドル|年|歳|万|億)$/);
if (!unitMatch) return stripped.toLowerCase();
const unit = unitMatch[0];
const numeric = stripped.slice(0, -unit.length);
const parsed = Number.parseFloat(numeric);
if (!Number.isFinite(parsed)) return stripped.toLowerCase();
// `String(Number)` は末尾ゼロを自動で落とす(例: `1000.0` → `1000`)。
// `String(Number)` naturally drops trailing decimal zeros.
return `${parsed}${unit.toLowerCase()}`;
}

/**
Expand Down
14 changes: 12 additions & 2 deletions server/api/src/services/lintEngine/rules/titleSimilar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,15 +83,25 @@ export async function runTitleSimilarRule(ownerId: string, db: Database): Promis
if (seen.has(key)) continue;
seen.add(key);

// 完全一致 (distance=0) はリンクの曖昧さを生む深刻な重複なので
// `warn` に上げ、近似類似 (distance>0) は `info` のまま残す。
// Exact-title duplicates (distance=0) create link ambiguity and are
// more serious than near-matches, so escalate severity to `warn`.
const severity = dist === 0 ? "warn" : "info";
const suggestion =
dist === 0
? "タイトルが完全に一致しています。統合またはリネームを検討してください / Titles are identical. Consider merging or renaming."
: "タイトルが類似しています。統合を検討してください / Titles are similar. Consider merging.";

findings.push({
rule: "title_similar",
severity: "info",
severity,
pageIds: [a.id, b.id],
detail: {
titleA: a.title,
titleB: b.title,
distance: dist,
suggestion: `タイトルが類似しています。統合を検討してください / Titles are similar. Consider merging.`,
suggestion,
},
});
}
Expand Down
Loading