diff --git a/autotests/dfm-search-tests/tst_chinese_nlp.cpp b/autotests/dfm-search-tests/tst_chinese_nlp.cpp index dd3aef45..5b9e59a9 100644 --- a/autotests/dfm-search-tests/tst_chinese_nlp.cpp +++ b/autotests/dfm-search-tests/tst_chinese_nlp.cpp @@ -136,6 +136,8 @@ private Q_SLOTS: void timeCustom_month(); void timeCustom_yearMonth(); void timeCustom_yearMonth_separators(); + void timeCustom_yearMonth_chineseYear(); + void timeCustom_yearMonth_chineseYear_noResidualKeyword(); void timeCustom_date(); void timeCustom_dateSpoken(); void timeCustom_fullDate(); @@ -1045,6 +1047,96 @@ void tst_ChineseNLP::timeCustom_yearMonth_separators() QCOMPARE(intent3.timeConstraint().customStart().date().month(), 12); } +// BUG-372165: Chinese-numeral year ("二五年"/"二零二五年"/"二〇二五年") must be +// recognised by the year rules, not leaked into keywords / bound to the current year. +void tst_ChineseNLP::timeCustom_yearMonth_chineseYear() +{ + // "二五年十二月" — 2-digit Chinese year + Chinese month → 2025-12 + ParsedIntent intent1; + m_parser->parse(QStringLiteral("二五年十二月的文档"), intent1); + QCOMPARE(intent1.timeConstraint().kind(), TimeConstraintKind::Custom); + QCOMPARE(intent1.timeConstraint().customStart().date().year(), 2025); + QCOMPARE(intent1.timeConstraint().customStart().date().month(), 12); + QCOMPARE(intent1.timeConstraint().customStart().date().day(), 1); + QCOMPARE(intent1.timeConstraint().customEnd().date().year(), 2025); + QCOMPARE(intent1.timeConstraint().customEnd().date().month(), 12); + + // "二五年12月" — 2-digit Chinese year + Arabic month → 2025-12 + ParsedIntent intent2; + m_parser->parse(QStringLiteral("二五年12月的文档"), intent2); + QCOMPARE(intent2.timeConstraint().kind(), TimeConstraintKind::Custom); + QCOMPARE(intent2.timeConstraint().customStart().date().year(), 2025); + QCOMPARE(intent2.timeConstraint().customStart().date().month(), 12); + + // "二零二五年十二月" — 4-digit Chinese year (with 零) + Chinese month → 2025-12 + ParsedIntent intent3; + m_parser->parse(QStringLiteral("二零二五年十二月的文档"), intent3); + QCOMPARE(intent3.timeConstraint().kind(), TimeConstraintKind::Custom); + QCOMPARE(intent3.timeConstraint().customStart().date().year(), 2025); + QCOMPARE(intent3.timeConstraint().customStart().date().month(), 12); + + // "二〇二五年十二月" — 4-digit Chinese year (with 〇) + Chinese month → 2025-12 + ParsedIntent intent4; + m_parser->parse(QStringLiteral("二〇二五年十二月的文档"), intent4); + QCOMPARE(intent4.timeConstraint().kind(), TimeConstraintKind::Custom); + QCOMPARE(intent4.timeConstraint().customStart().date().year(), 2025); + QCOMPARE(intent4.timeConstraint().customStart().date().month(), 12); + + // Regression: a bare Chinese-numeral year ("二零二五年") must be parsed as a year, + // spanning the whole year. + ParsedIntent intent5; + m_parser->parse(QStringLiteral("二零二五年的文档"), intent5); + QCOMPARE(intent5.timeConstraint().kind(), TimeConstraintKind::Custom); + QCOMPARE(intent5.timeConstraint().customStart().date().year(), 2025); + QCOMPARE(intent5.timeConstraint().customStart().date().month(), 1); + QCOMPARE(intent5.timeConstraint().customEnd().date().year(), 2025); + QCOMPARE(intent5.timeConstraint().customEnd().date().month(), 12); + + // Regression: "十二月的文件" must NOT be mis-parsed as a year-month; it stays a + // month of the current year (the year capture group requires >= 2 Chinese digits + // and cannot start with 十). + ParsedIntent intent6; + m_parser->parse(QStringLiteral("十二月的文件"), intent6); + QCOMPARE(intent6.timeConstraint().kind(), TimeConstraintKind::Custom); + QCOMPARE(intent6.timeConstraint().customStart().date().month(), 12); + QCOMPARE(intent6.timeConstraint().customStart().date().year(), QDate::currentDate().year()); +} + +// BUG-372165 scenario: the full叠加 query "二五年十二月创建的表格" must produce a +// correct time constraint, the spreadsheet filetype, and NO residual keyword. +void tst_ChineseNLP::timeCustom_yearMonth_chineseYear_noResidualKeyword() +{ + ParsedIntent intent; + m_parser->parse(QStringLiteral("二五年十二月创建的表格"), intent); + + // Correct time constraint: the whole month of 2025-12, not the current year. + QCOMPARE(intent.timeConstraint().kind(), TimeConstraintKind::Custom); + QCOMPARE(intent.timeConstraint().customStart().date().year(), 2025); + QCOMPARE(intent.timeConstraint().customStart().date().month(), 12); + QCOMPARE(intent.timeConstraint().customEnd().date().year(), 2025); + QCOMPARE(intent.timeConstraint().customEnd().date().month(), 12); + + // Spreadsheet filetype consumed. + QVERIFY(intent.fileExtensions().contains("xls")); + QVERIFY(intent.fileExtensions().contains("xlsx")); + QVERIFY(intent.fileExtensions().contains("csv")); + + // The Chinese-numeral year must be fully consumed by a time rule, not leaked + // into keywords (which was the root cause of empty search results). + QVERIFY2(intent.keywords().isEmpty(), + "Chinese-numeral year must not leak into keywords"); + + // The consumed span must be the year-month rule, covering the whole "二五年十二月". + bool hasYearMonth = false; + for (const MatchSpan &span : intent.consumedSpans()) { + if (span.ruleId() == QLatin1String("time_exact_year_month")) { + hasYearMonth = true; + break; + } + } + QVERIFY(hasYearMonth); +} + void tst_ChineseNLP::timeCustom_date() { // "12月5日" → this year, Dec 5 diff --git a/src/dfm-search/dfm-search-lib/semantic/extractors/timeextractor.cpp b/src/dfm-search/dfm-search-lib/semantic/extractors/timeextractor.cpp index 53130344..6291402e 100644 --- a/src/dfm-search/dfm-search-lib/semantic/extractors/timeextractor.cpp +++ b/src/dfm-search/dfm-search-lib/semantic/extractors/timeextractor.cpp @@ -397,6 +397,27 @@ int TimeExtractor::localeAwareToInt(const QString &input, } } + // Digit-by-digit Chinese numeral string (e.g., "二五" = 25, "二零二五" = 2025, + // "二〇二五" = 2025). Used for year written in Chinese numerals position-by-position. + // Each character must map to a single digit 0-9 — this excludes "十" (10) and "两", + // which are not used in digit-by-digit year notation. This branch runs after the + // "十"-based branches above, so "十一"/"二十"/"十二" are already handled. + bool allSingleDigits = true; + for (const QChar &ch : input) { + auto it = digitMap.constFind(ch); + if (it == digitMap.constEnd() || it.value() > 9) { + allSingleDigits = false; + break; + } + } + if (allSingleDigits && input.size() >= 2) { + int value = 0; + for (const QChar &ch : input) { + value = value * 10 + digitMap.value(ch); + } + return value; + } + return -1; } diff --git a/src/dfm-search/dfm-search-lib/semantic/rules/zh_CN/time_rules.json b/src/dfm-search/dfm-search-lib/semantic/rules/zh_CN/time_rules.json index 02fa21f1..81f7cd2d 100644 --- a/src/dfm-search/dfm-search-lib/semantic/rules/zh_CN/time_rules.json +++ b/src/dfm-search/dfm-search-lib/semantic/rules/zh_CN/time_rules.json @@ -249,8 +249,8 @@ }, { "id": "time_exact_year", - "pattern": "(?\\d{2,4})年", - "description": "Exact year (e.g. 2025年, 25年)", + "pattern": "(?\\d{2,4}|[零〇一二三四五六七八九]{2,4})年", + "description": "Exact year (e.g. 2025年, 25年, 二五年, 二零二五年)", "enabled": true, "priority": 120, "metadata": { @@ -258,6 +258,7 @@ "format": "year", "digit_map": { "零": 0, + "〇": 0, "一": 1, "二": 2, "三": 3, @@ -299,8 +300,8 @@ }, { "id": "time_exact_year_month", - "pattern": "(?\\d{2,4})[年\\./\\-](?(?:\\d{1,2}|[零一二两三四五六七八九十]{1,3}))(?:月份?)?", - "description": "Exact year-month (e.g. 2025年12月, 2025-12)", + "pattern": "(?\\d{2,4}|[零〇一二三四五六七八九]{2,4})[年\\./\\-](?(?:\\d{1,2}|[零一二两三四五六七八九十]{1,3}))(?:月份?)?", + "description": "Exact year-month (e.g. 2025年12月, 25年12月, 二五年十二月, 二零二五年12月)", "enabled": true, "priority": 140, "metadata": { @@ -308,6 +309,7 @@ "format": "year_month", "digit_map": { "零": 0, + "〇": 0, "一": 1, "二": 2, "三": 3, @@ -349,8 +351,8 @@ }, { "id": "time_exact_full_date", - "pattern": "(?\\d{2,4})[年\\./\\-](?(?:\\d{1,2}|[零一二两三四五六七八九十]{1,3}))[月\\./\\-](?(?:\\d{1,2}|[零一二两三四五六七八九十]{1,3}))[日号]?", - "description": "Exact full date (e.g. 2025年12月5日, 2025-12-05)", + "pattern": "(?\\d{2,4}|[零〇一二三四五六七八九]{2,4})[年\\./\\-](?(?:\\d{1,2}|[零一二两三四五六七八九十]{1,3}))[月\\./\\-](?(?:\\d{1,2}|[零一二两三四五六七八九十]{1,3}))[日号]?", + "description": "Exact full date (e.g. 2025年12月5日, 2025-12-05, 二零二五年十二月五日)", "enabled": true, "priority": 160, "metadata": { @@ -358,6 +360,7 @@ "format": "full_date", "digit_map": { "零": 0, + "〇": 0, "一": 1, "二": 2, "三": 3,