fix(aihr): sanitize outbound model text
This commit is contained in:
+1
-9
@@ -524,15 +524,7 @@ public class AihrCaseService {
|
||||
}
|
||||
|
||||
private static String maskSensitiveText(String text) {
|
||||
if (text == null || text.isBlank()) {
|
||||
return clean(text);
|
||||
}
|
||||
String masked = text;
|
||||
masked = masked.replaceAll("(?<!\\d)(1[3-9]\\d{2})\\d{3}(\\d{4})(?!\\d)", "$1****$2");
|
||||
masked = masked.replaceAll("(?<!\\d)(\\d{1,2})(栋|幢|号楼|单元)(\\d{3,4})(?!\\d)", "$1$2****");
|
||||
masked = masked.replaceAll("(?<!\\d)(\\d{1,2})[--](\\d{3,4})(?!\\d)", "$1-****");
|
||||
masked = masked.replaceAll("([\\u4e00-\\u9fa5])([\\u4e00-\\u9fa5])(先生|女士|经理)", "$1*$3");
|
||||
return clean(masked);
|
||||
return clean(AihrSensitiveText.forModel(text));
|
||||
}
|
||||
|
||||
private record CaseState(
|
||||
|
||||
+2
-2
@@ -369,7 +369,7 @@ public class AihrInterviewService {
|
||||
履历摘要:%s
|
||||
关注风险:%s
|
||||
岗位能力:%s
|
||||
""".formatted(candidate.name(), candidate.position(), candidate.project(), candidate.experience(), candidate.risk(), String.join("、", candidate.capabilities()));
|
||||
""".formatted(AihrSensitiveText.personName(candidate.name(), "候选人"), candidate.position(), candidate.project(), candidate.experience(), candidate.risk(), String.join("、", candidate.capabilities()));
|
||||
return modelService.tryChat(system, user, 0.25).flatMap(this::parseQuestions);
|
||||
}
|
||||
|
||||
@@ -388,7 +388,7 @@ public class AihrInterviewService {
|
||||
岗位能力:%s
|
||||
问答记录:
|
||||
%s
|
||||
""".formatted(session.candidate().name(), session.candidate().position(), String.join("、", session.candidate().capabilities()), renderAnswers(session));
|
||||
""".formatted(AihrSensitiveText.personName(session.candidate().name(), "候选人"), session.candidate().position(), String.join("、", session.candidate().capabilities()), renderAnswers(session));
|
||||
return modelService.tryChat(system, user, 0.0).flatMap(this::parseScore);
|
||||
}
|
||||
|
||||
|
||||
+4
-2
@@ -385,11 +385,13 @@ public class AihrModelSeedService {
|
||||
ArrayNode messages = body.putArray("messages");
|
||||
ObjectNode system = messages.addObject();
|
||||
system.put("role", "system");
|
||||
system.put("content", isBlank(systemPrompt) ? "你是物业 AI 人力资源系统助手,回答要适合物业一线使用,简洁、可执行。" : systemPrompt);
|
||||
system.put("content", AihrSensitiveText.forModel(isBlank(systemPrompt)
|
||||
? "你是物业 AI 人力资源系统助手,回答要适合物业一线使用,简洁、可执行。"
|
||||
: systemPrompt));
|
||||
|
||||
ObjectNode user = messages.addObject();
|
||||
user.put("role", "user");
|
||||
user.put("content", prompt);
|
||||
user.put("content", AihrSensitiveText.forModel(prompt));
|
||||
|
||||
HttpRequest.Builder builder = HttpRequest.newBuilder()
|
||||
.uri(URI.create(normalizeBaseUrl(runtime.baseUrl()) + "/chat/completions"))
|
||||
|
||||
+1
-9
@@ -2957,15 +2957,7 @@ public class AihrPracticeSeedService {
|
||||
}
|
||||
|
||||
private String maskSensitiveText(String text) {
|
||||
if (text == null || text.isBlank()) {
|
||||
return text;
|
||||
}
|
||||
String masked = text;
|
||||
masked = masked.replaceAll("(?<!\\d)(1[3-9]\\d{2})\\d{3}(\\d{4})(?!\\d)", "$1****$2");
|
||||
masked = masked.replaceAll("(?<!\\d)(\\d{1,2})(栋|幢|号楼|单元)(\\d{3,4})(?!\\d)", "$1$2****");
|
||||
masked = masked.replaceAll("(?<!\\d)(\\d{1,2})[--](\\d{3,4})(?!\\d)", "$1-****");
|
||||
masked = masked.replaceAll("([\\u4e00-\\u9fa5])([\\u4e00-\\u9fa5])(先生|女士|经理)", "$1*$3");
|
||||
return masked;
|
||||
return AihrSensitiveText.forModel(text);
|
||||
}
|
||||
|
||||
private Integer scoreValue(List<DimensionResponse> scores, String label) {
|
||||
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package org.dromara.aihr.service;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 发送到外部 AI 模型前的最小文本脱敏规则。
|
||||
*
|
||||
* <p>这不是正式的数据分类引擎;它只负责拦截当前一期已知的手机号、邮箱、房号和带称谓姓名,
|
||||
* 避免“入库已脱敏、出境仍是原文”的边界漏洞。正式试点仍需配套供应商留存、审计和删除合同。</p>
|
||||
*/
|
||||
final class AihrSensitiveText {
|
||||
|
||||
// 保持案例服务既有展示口径:手机号保留前 4 位和后 4 位,中间统一遮蔽。
|
||||
private static final Pattern PHONE = Pattern.compile("(?<!\\d)(1[3-9]\\d{2})\\d{3}(\\d{4})(?!\\d)");
|
||||
private static final Pattern EMAIL = Pattern.compile("(?i)(?<![\\w.+-])[\\w.+-]+@[\\w-]+(?:\\.[\\w-]+)+(?![\\w.-])");
|
||||
private static final Pattern ROOM = Pattern.compile("(?<!\\d)(\\d{1,2})(栋|幢|号楼|单元)(\\d{3,4})(?!\\d)");
|
||||
private static final Pattern ROOM_SHORT = Pattern.compile("(?<!\\d)(\\d{1,2})[--](\\d{3,4})(?!\\d)");
|
||||
private static final Pattern HONORIFIC_NAME = Pattern.compile("([\\u4e00-\\u9fa5])([\\u4e00-\\u9fa5])(先生|女士|经理)");
|
||||
|
||||
private AihrSensitiveText() {
|
||||
}
|
||||
|
||||
static String forModel(String text) {
|
||||
if (text == null || text.isBlank()) {
|
||||
return text;
|
||||
}
|
||||
String masked = PHONE.matcher(text).replaceAll("$1****$2");
|
||||
masked = EMAIL.matcher(masked).replaceAll("[邮箱已脱敏]");
|
||||
masked = ROOM.matcher(masked).replaceAll("$1$2****");
|
||||
masked = ROOM_SHORT.matcher(masked).replaceAll("$1-****");
|
||||
return HONORIFIC_NAME.matcher(masked).replaceAll("$1*$3");
|
||||
}
|
||||
|
||||
/** 已知的结构化姓名字段不应仅依赖称谓正则,直接用匿名角色替换。 */
|
||||
static String personName(String name, String roleLabel) {
|
||||
return name == null || name.isBlank() ? "" : "匿名" + roleLabel;
|
||||
}
|
||||
}
|
||||
+16
-16
@@ -246,8 +246,8 @@ public class AihrSopSeedService {
|
||||
update_time = VALUES(update_time)
|
||||
""",
|
||||
tenantId(),
|
||||
truncate(queryText, 1000),
|
||||
queryNorm,
|
||||
truncate(AihrSensitiveText.forModel(queryText), 1000),
|
||||
normalizeFeedbackQuery(AihrSensitiveText.forModel(queryText)),
|
||||
fragmentId,
|
||||
verdict,
|
||||
firstNonBlank(request.category(), "sop"),
|
||||
@@ -812,14 +812,14 @@ public class AihrSopSeedService {
|
||||
ArrayNode messages = body.putArray("messages");
|
||||
ObjectNode system = messages.addObject();
|
||||
system.put("role", "system");
|
||||
system.put("content", "你是物业资料管理员。只返回 JSON,不要 markdown。字段固定为 category, summary, tags, reason。tags 为 3 到 6 个短标签。");
|
||||
system.put("content", AihrSensitiveText.forModel("你是物业资料管理员。只返回 JSON,不要 markdown。字段固定为 category, summary, tags, reason。tags 为 3 到 6 个短标签。"));
|
||||
|
||||
String categoryRule = autoCategory
|
||||
? "根据资料内容选择最合适的已有分类;如果明显不属于已有分类,可给出一个不超过 12 个字的新分类。"
|
||||
: "分类必须保持为:" + manualCategory + "。只生成摘要、标签和归类理由。";
|
||||
ObjectNode user = messages.addObject();
|
||||
user.put("role", "user");
|
||||
user.put("content", """
|
||||
user.put("content", AihrSensitiveText.forModel("""
|
||||
已有分类:
|
||||
%s
|
||||
|
||||
@@ -828,7 +828,7 @@ public class AihrSopSeedService {
|
||||
文件名:%s
|
||||
正文摘录:
|
||||
%s
|
||||
""".formatted(categoryOptionsText(), categoryRule, fileName, truncate(content, 6000)));
|
||||
""".formatted(categoryOptionsText(), categoryRule, fileName, truncate(content, 6000))));
|
||||
|
||||
HttpRequest.Builder builder = HttpRequest.newBuilder()
|
||||
.uri(URI.create(normalizeBaseUrl(runtime.baseUrl()) + "/chat/completions"))
|
||||
@@ -1635,7 +1635,7 @@ public class AihrSopSeedService {
|
||||
ObjectNode body = objectMapper.createObjectNode();
|
||||
body.put("model", runtime.modelName());
|
||||
ArrayNode input = body.putArray("input");
|
||||
fragments.forEach(input::add);
|
||||
fragments.stream().map(AihrSensitiveText::forModel).forEach(input::add);
|
||||
|
||||
HttpRequest.Builder builder = HttpRequest.newBuilder()
|
||||
.uri(URI.create(normalizeBaseUrl(runtime.baseUrl()) + "/embeddings"))
|
||||
@@ -1751,11 +1751,11 @@ public class AihrSopSeedService {
|
||||
try {
|
||||
ObjectNode body = objectMapper.createObjectNode();
|
||||
body.put("model", runtime.get().modelName());
|
||||
body.put("query", queryText);
|
||||
body.put("query", AihrSensitiveText.forModel(queryText));
|
||||
body.put("top_n", hits.size());
|
||||
body.put("return_documents", false);
|
||||
ArrayNode documents = body.putArray("documents");
|
||||
hits.forEach(hit -> documents.add(truncate(hit.content(), 2000)));
|
||||
hits.forEach(hit -> documents.add(AihrSensitiveText.forModel(truncate(hit.content(), 2000))));
|
||||
|
||||
HttpRequest httpRequest = HttpRequest.newBuilder()
|
||||
.uri(URI.create(normalizeBaseUrl(runtime.get().baseUrl()) + "/rerank"))
|
||||
@@ -1820,7 +1820,7 @@ public class AihrSopSeedService {
|
||||
VALUES (?, ?, ?, ?, ?, '待补充', ?)
|
||||
""",
|
||||
tenantId(),
|
||||
queryText.trim(),
|
||||
AihrSensitiveText.forModel(queryText.trim()),
|
||||
firstNonBlank(category, "sop"),
|
||||
firstNonBlank(position, "生活顾问"),
|
||||
normalizeSearchSource(source),
|
||||
@@ -1864,10 +1864,10 @@ public class AihrSopSeedService {
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, '待评审', ?, ?)
|
||||
""", Statement.RETURN_GENERATED_KEYS);
|
||||
ps.setString(1, tenantId());
|
||||
ps.setString(2, truncate(response.queryText(), 1000));
|
||||
ps.setString(2, truncate(AihrSensitiveText.forModel(response.queryText()), 1000));
|
||||
ps.setString(3, firstNonBlank(response.category(), "sop"));
|
||||
ps.setString(4, truncate(response.answer(), 2000));
|
||||
ps.setString(5, truncate(response.reference(), 1000));
|
||||
ps.setString(4, truncate(AihrSensitiveText.forModel(response.answer()), 2000));
|
||||
ps.setString(5, truncate(AihrSensitiveText.forModel(response.reference()), 1000));
|
||||
ps.setInt(6, response.snippets() == null ? 0 : response.snippets().size());
|
||||
ps.setString(7, normalizeSearchSource(source));
|
||||
ps.setString(8, firstNonBlank(response.promptVersion(), "unknown"));
|
||||
@@ -2418,11 +2418,11 @@ public class AihrSopSeedService {
|
||||
ArrayNode messages = body.putArray("messages");
|
||||
ObjectNode system = messages.addObject();
|
||||
system.put("role", "system");
|
||||
system.put("content", prompt.systemPrompt());
|
||||
system.put("content", AihrSensitiveText.forModel(prompt.systemPrompt()));
|
||||
|
||||
ObjectNode user = messages.addObject();
|
||||
user.put("role", "user");
|
||||
user.put("content", renderPrompt(prompt.template(), queryText, context));
|
||||
user.put("content", AihrSensitiveText.forModel(renderPrompt(prompt.template(), queryText, context)));
|
||||
|
||||
HttpRequest.Builder builder = HttpRequest.newBuilder()
|
||||
.uri(URI.create(normalizeBaseUrl(runtime.baseUrl()) + "/chat/completions"))
|
||||
@@ -2494,12 +2494,12 @@ public class AihrSopSeedService {
|
||||
|
||||
ObjectNode user = messages.addObject();
|
||||
user.put("role", "user");
|
||||
user.put("content", """
|
||||
user.put("content", AihrSensitiveText.forModel("""
|
||||
员工的问题:%s
|
||||
|
||||
检索命中的知识片段:
|
||||
%s
|
||||
""".formatted(queryText, context));
|
||||
""".formatted(queryText, context)));
|
||||
|
||||
HttpRequest.Builder builder = HttpRequest.newBuilder()
|
||||
.uri(URI.create(normalizeBaseUrl(runtime.baseUrl()) + "/chat/completions"))
|
||||
|
||||
+1
-1
@@ -83,7 +83,7 @@ public class AihrSpeechService {
|
||||
try {
|
||||
ObjectNode body = objectMapper.createObjectNode();
|
||||
body.put("model", runtime.modelName());
|
||||
body.put("input", text.trim());
|
||||
body.put("input", AihrSensitiveText.forModel(text.trim()));
|
||||
body.put("voice", resolveVoice(runtime.modelName(), voice));
|
||||
body.put("response_format", "mp3");
|
||||
HttpRequest httpRequest = authorized(runtime, "/audio/speech")
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
package org.dromara.aihr.service;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
@Tag("dev")
|
||||
class AihrSensitiveTextTest {
|
||||
|
||||
@Test
|
||||
void masksKnownPiiBeforeExternalModelUse() {
|
||||
String raw = "请联系张三先生,电话13812345678,邮箱zhangsan@example.com,住3栋1201和2-302。";
|
||||
|
||||
String masked = AihrSensitiveText.forModel(raw);
|
||||
|
||||
assertFalse(masked.contains("13812345678"));
|
||||
assertFalse(masked.contains("zhangsan@example.com"));
|
||||
assertFalse(masked.contains("3栋1201"));
|
||||
assertFalse(masked.contains("2-302"));
|
||||
assertTrue(masked.contains("1381****5678"));
|
||||
assertTrue(masked.contains("[邮箱已脱敏]"));
|
||||
assertTrue(masked.contains("3栋****"));
|
||||
assertTrue(masked.contains("2-****"));
|
||||
assertTrue(masked.contains("张*先生"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksStructuredPersonNamesWithoutGuessingFreeTextNames() {
|
||||
assertTrue(AihrSensitiveText.personName("张三", "候选人").equals("匿名候选人"));
|
||||
assertTrue(AihrSensitiveText.personName("", "候选人").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void allTextModelBoundariesUseTheSanitizer() throws Exception {
|
||||
String modelSource = Files.readString(source("AihrModelSeedService.java"));
|
||||
String sopSource = Files.readString(source("AihrSopSeedService.java"));
|
||||
String interviewSource = Files.readString(source("AihrInterviewService.java"));
|
||||
String speechSource = Files.readString(source("AihrSpeechService.java"));
|
||||
|
||||
assertTrue(modelSource.contains("AihrSensitiveText.forModel(prompt)"));
|
||||
assertTrue(sopSource.contains("AihrSensitiveText.forModel(renderPrompt(prompt.template(), queryText, context))"));
|
||||
assertTrue(sopSource.contains("AihrSensitiveText.forModel(\"\"\""));
|
||||
assertTrue(interviewSource.contains("AihrSensitiveText.personName(candidate.name(), \"候选人\")"));
|
||||
assertTrue(speechSource.contains("AihrSensitiveText.forModel(text.trim())"));
|
||||
}
|
||||
|
||||
private static Path source(String fileName) {
|
||||
Path direct = Path.of("src/main/java/org/dromara/aihr/service", fileName);
|
||||
return Files.exists(direct)
|
||||
? direct
|
||||
: Path.of("ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service", fileName);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user