diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalPromptSanitizer.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalPromptSanitizer.java index ea22e9a9..bbf1ba8e 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalPromptSanitizer.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalPromptSanitizer.java @@ -2,26 +2,36 @@ package org.dromara.aihr.personal.service; import java.text.Normalizer; import java.util.List; +import java.util.regex.Matcher; import java.util.regex.Pattern; public final class PersonalPromptSanitizer { + private static final String NUMBER_SEPARATOR = "[..\\s\\-—–·]"; + private static final Pattern NUMBER_CANDIDATE = Pattern.compile( + "(? RULES = List.of( - new Rule(Pattern.compile("(? RESIDUAL_PATTERNS = List.of( Pattern.compile("(?i)(?:护照(?:号)?|passport)[::\\s]*[A-Z0-9]{5,20}"), - Pattern.compile("(? pattern.matcher(normalized).find()); + if (RESIDUAL_PATTERNS.stream().anyMatch(pattern -> pattern.matcher(normalized).find())) { + return true; + } + Matcher matcher = NUMBER_CANDIDATE.matcher(normalized); + while (matcher.find()) { + if (classifyNumber(normalized, matcher) != null) { + return true; + } + } + return false; + } + + private static String maskNumberCandidates(String value) { + Matcher matcher = NUMBER_CANDIDATE.matcher(value); + StringBuffer output = new StringBuffer(); + while (matcher.find()) { + String replacement = classifyNumber(value, matcher); + matcher.appendReplacement(output, replacement == null + ? Matcher.quoteReplacement(matcher.group()) : Matcher.quoteReplacement(replacement)); + } + matcher.appendTail(output); + return output.toString(); + } + + private static String classifyNumber(String value, Matcher matcher) { + String compact = matcher.group().replaceAll(NUMBER_SEPARATOR, ""); + if (compact.startsWith("+")) { + compact = compact.substring(1); + } + String domestic = compact.startsWith("86") && compact.length() == 13 ? compact.substring(2) : compact; + String context = value.substring(Math.max(0, matcher.start() - 12), matcher.start()); + if (MOBILE.matcher(domestic).matches()) { + return "[手机号]"; + } + if (IDENTITY.matcher(compact).matches() + && (hasDirectContext(context, "身份证", "证件号", "身份号码") || validIdentityChecksum(compact))) { + return "[身份证号]"; + } + if (compact.chars().allMatch(Character::isDigit) && compact.length() >= 16 && compact.length() <= 19 + && (hasDirectContext(context, "银行卡", "银行卡号", "银行账号", "卡号") || validLuhn(compact))) { + return "[银行卡号]"; + } + if (compact.chars().allMatch(Character::isDigit) + && (FIXED_PHONE.matcher(compact).matches() + || hasDirectContext(context, "手机", "电话", "联系方式", "联系电话"))) { + return "[固定电话]"; + } + return null; + } + + private static boolean hasDirectContext(String context, String... labels) { + String trimmed = context.stripTrailing(); + if (trimmed.endsWith(":") || trimmed.endsWith(":")) { + trimmed = trimmed.substring(0, trimmed.length() - 1).stripTrailing(); + } + for (String label : labels) { + if (trimmed.endsWith(label)) { + return true; + } + } + return false; + } + + private static boolean validLuhn(String digits) { + int sum = 0; + boolean doubled = false; + for (int i = digits.length() - 1; i >= 0; i--) { + int digit = digits.charAt(i) - '0'; + if (doubled && (digit *= 2) > 9) { + digit -= 9; + } + sum += digit; + doubled = !doubled; + } + return sum % 10 == 0; + } + + private static boolean validIdentityChecksum(String identity) { + int[] weights = {7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2}; + char[] checks = {'1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2'}; + int sum = 0; + for (int i = 0; i < weights.length; i++) { + sum += (identity.charAt(i) - '0') * weights[i]; + } + return Character.toUpperCase(identity.charAt(17)) == checks[sum % 11]; } private static String normalize(String value) { diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalAnswerServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalAnswerServiceTest.java index 450fbba4..4c0bdc45 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalAnswerServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalAnswerServiceTest.java @@ -198,6 +198,38 @@ class PersonalAnswerServiceTest { assertEquals(sensitive, response.citations().get(0).excerpt()); } + @Test + void removesUnicodeSeparatedPiiFromFinalPromptAndPreservesPropertyBusinessMeaning() { + List prompts = new ArrayList<>(); + AtomicInteger modelCalls = new AtomicInteger(); + String query = "业主投诉漏水,联系人电话138.0000.0000,工单编号202607120001需跟进"; + String source = "客户反馈很好,南京市鼓楼区银城街12号张三家,备用手机138—0000—0000"; + PersonalAnswerService service = PersonalAnswerService.forTest( + (owner, request) -> List.of(personalHit("1", "正常物业记录", source)), + (owner, text, grant, limit) -> List.of(), + (system, user, temperature) -> { + modelCalls.incrementAndGet(); + prompts.add(user); + return result("答案"); + }, + new RecordingPersistence() + ); + + service.ask(OWNER, new AskRequest(null, query, List.of(SearchScope.PERSONAL), + null, null, List.of(), "ANSWER")); + + assertEquals(1, modelCalls.get()); + String prompt = prompts.get(0); + assertFalse(prompt.contains("138.0000.0000")); + assertFalse(prompt.contains("138—0000—0000")); + assertFalse(prompt.contains("南京市鼓楼区银城街12号张三家")); + assertTrue(prompt.contains("[手机号]")); + assertTrue(prompt.contains("[地址]")); + assertTrue(prompt.contains("业主投诉漏水")); + assertTrue(prompt.contains("客户反馈很好")); + assertTrue(prompt.contains("工单编号202607120001需跟进")); + } + @Test void residualSensitiveContentFailsClosedWithoutCallingExternalModel() { AtomicInteger modelCalls = new AtomicInteger(); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalPromptSanitizerTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalPromptSanitizerTest.java index b528f7a9..6776c696 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalPromptSanitizerTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalPromptSanitizerTest.java @@ -4,6 +4,7 @@ import org.dromara.aihr.personal.service.PersonalPromptSanitizer; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -32,12 +33,30 @@ class PersonalPromptSanitizerTest { @Test void normalizesUnicodeAndDoesNotMaskOrdinaryBusinessText() { assertTrue(PersonalPromptSanitizer.sanitize("手机138-0000-0000").contains("[手机号]")); - String ordinary = "本周完成2026年7月12日收费标准复核,工单编号A12345,计划覆盖3个项目。"; + assertEquals("手机[手机号]", PersonalPromptSanitizer.sanitize("手机138.0000.0000")); + assertEquals("手机[手机号]", PersonalPromptSanitizer.sanitize("手机138—0000—0000")); + for (String mobile : new String[]{"138.0000.0000", "138 0000 0000", "138-0000-0000", + "138–0000–0000", "138·0000·0000"}) { + assertEquals("手机[手机号]", PersonalPromptSanitizer.sanitize("手机" + mobile)); + } + String ordinary = "业主投诉漏水,客户反馈很好,联系人电话待补,工单编号202607120001需跟进。"; String sanitized = PersonalPromptSanitizer.sanitize(ordinary); - assertTrue(sanitized.contains("收费标准复核")); - assertTrue(sanitized.contains("工单编号A12345")); + assertTrue(sanitized.contains("业主投诉漏水")); + assertTrue(sanitized.contains("客户反馈很好")); + assertTrue(sanitized.contains("联系人电话")); + assertTrue(sanitized.contains("工单编号202607120001需跟进")); assertFalse(sanitized.contains("[")); assertFalse(PersonalPromptSanitizer.containsSensitive(ordinary)); assertTrue(PersonalPromptSanitizer.containsSensitive("护照号 E12345678")); } + + @Test + void masksUnlabelledChineseAddressWithoutTreatingBusinessNounsAsNames() { + String sanitized = PersonalPromptSanitizer.sanitize("南京市鼓楼区银城街12号张三家"); + + assertEquals("[地址]", sanitized); + assertFalse(sanitized.contains("张三")); + assertEquals("南京市鼓楼区收费标准已更新", + PersonalPromptSanitizer.sanitize("南京市鼓楼区收费标准已更新")); + } }