diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/broadcast/AihrBroadcastInsightService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/broadcast/AihrBroadcastInsightService.java new file mode 100644 index 00000000..11d6f4f1 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/broadcast/AihrBroadcastInsightService.java @@ -0,0 +1,392 @@ +package org.dromara.aihr.broadcast; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.dromara.aihr.broadcast.AihrBroadcastDto.BroadcastEvidence; +import org.dromara.aihr.broadcast.AihrBroadcastDto.BroadcastPerspective; +import org.dromara.aihr.domain.AihrModelDto.ChatRequest; +import org.dromara.aihr.domain.AihrModelDto.ChatResponse; +import org.dromara.aihr.service.AihrModelSeedService; +import org.dromara.aihr.service.AihrSensitiveText; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +import static java.util.Map.entry; + +@Service +@RequiredArgsConstructor +public class AihrBroadcastInsightService { + + public static final String VERSION = "role-perspective-v1"; + private static final int CHUNK_SIZE = 4_000; + private static final int CHUNK_OVERLAP = 300; + private static final int MAX_CHUNKS = 80; + private static final int MAX_PERSPECTIVES = 9; + private static final int MAX_ITEMS = 5; + private static final int MAX_EVIDENCE = 3; + private static final Map ROLE_LABELS = Map.ofEntries( + entry("living_advisor", "生活顾问/客服"), + entry("cleaning", "保洁"), + entry("security", "保安"), + entry("engineering", "保修/工程"), + entry("finance", "财务"), + entry("hr", "人力"), + entry("operations", "业务运营"), + entry("audit_risk", "审计/风控"), + entry("management", "管理层") + ); + + private final ObjectMapper objectMapper; + private final AihrModelSeedService modelService; + + public Generation generate(String fileName, String content) { + String source = content == null ? "" : content.replace("\r\n", "\n").replace('\r', '\n').trim(); + if (source.isEmpty()) { + return new Generation("FAILED", null, List.of()); + } + List chunks = chunks(source); + boolean truncated = chunks.size() == MAX_CHUNKS + && chunks.get(chunks.size() - 1).endOffset() < source.length(); + Map merged = new LinkedHashMap<>(); + int failedChunks = 0; + for (SourceChunk chunk : chunks) { + try { + ChatResponse response = modelService.chat(new ChatRequest( + AihrSensitiveText.forModel(prompt(fileName, chunk)), + null, + AihrSensitiveText.forModel(""" + 你负责解读物业公司内部文件。只返回 JSON,不要 markdown。 + 只允许使用给定原文;原文未提及的岗位、结论、行动或风险不要输出。 + evidence.quote 必须逐字复制原文,不能改写或拼接。 + """) + )); + if (response == null || !"openai-compatible".equals(response.mode()) + || response.error() != null || response.answer() == null) { + throw new IllegalStateException("model did not return a usable structured answer"); + } + for (BroadcastPerspective perspective : parseModelPerspectives(response.answer(), chunk, source)) { + merged.computeIfAbsent(perspective.code(), PerspectiveAccumulator::new).add(perspective); + } + } catch (RuntimeException ignored) { + failedChunks++; + } + } + List perspectives = merged.values().stream() + .map(PerspectiveAccumulator::build) + .filter(perspective -> !perspective.evidence().isEmpty()) + .limit(MAX_PERSPECTIVES) + .toList(); + if (perspectives.isEmpty()) { + return new Generation("FAILED", null, List.of()); + } + String status = failedChunks > 0 || truncated ? "PARTIAL" : "READY"; + try { + return new Generation(status, + objectMapper.writeValueAsString(new StoredInsights(VERSION, perspectives)), + perspectives); + } catch (Exception ignored) { + return new Generation("FAILED", null, List.of()); + } + } + + public List readPerspectives(String storedJson) { + if (storedJson == null || storedJson.isBlank()) { + return List.of(); + } + try { + JsonNode rows = objectMapper.readTree(storedJson).path("perspectives"); + if (!rows.isArray()) { + return List.of(); + } + List perspectives = new ArrayList<>(); + for (JsonNode row : rows) { + BroadcastPerspective perspective = persistedPerspective(row); + if (perspective != null) { + perspectives.add(perspective); + } + if (perspectives.size() == MAX_PERSPECTIVES) { + break; + } + } + return List.copyOf(perspectives); + } catch (Exception ignored) { + return List.of(); + } + } + + public static String defaultPerspectiveCode(List positionNames) { + if (positionNames == null) { + return null; + } + for (String raw : positionNames) { + String name = clean(raw).toLowerCase(Locale.ROOT); + if (containsAny(name, "财务", "会计", "出纳")) return "finance"; + if (containsAny(name, "人力", "人事", "招聘")) return "hr"; + if (containsAny(name, "审计", "风控", "合规", "法务")) return "audit_risk"; + if (containsAny(name, "工程", "维修", "保修")) return "engineering"; + if (containsAny(name, "保洁", "清洁")) return "cleaning"; + if (containsAny(name, "保安", "秩序", "安保")) return "security"; + if (containsAny(name, "生活顾问", "管家", "客服", "客户服务")) return "living_advisor"; + if (containsAny(name, "运营", "业务")) return "operations"; + if (containsAny(name, "总经理", "项目经理", "总监", "负责人", "管理层")) return "management"; + } + return null; + } + + static List chunks(String rawContent) { + String content = rawContent == null ? "" : rawContent.replace("\r\n", "\n").replace('\r', '\n').trim(); + if (content.isEmpty()) { + return List.of(); + } + List chunks = new ArrayList<>(); + int start = 0; + while (start < content.length() && chunks.size() < MAX_CHUNKS) { + int desiredEnd = Math.min(start + CHUNK_SIZE, content.length()); + int end = desiredEnd == content.length() ? desiredEnd : naturalBoundary(content, start, desiredEnd); + if (end <= start) { + end = desiredEnd; + } + String text = content.substring(start, end).trim(); + if (!text.isEmpty()) { + chunks.add(new SourceChunk(chunks.size() + 1, paragraphIndex(content, start), start, end, text)); + } + if (end >= content.length()) { + break; + } + start = Math.max(start + 1, end - CHUNK_OVERLAP); + } + return List.copyOf(chunks); + } + + private List parseModelPerspectives(String answer, SourceChunk chunk, String source) { + try { + JsonNode rows = objectMapper.readTree(jsonObject(answer)).path("perspectives"); + if (!rows.isArray()) { + throw new IllegalArgumentException("perspectives must be an array"); + } + List perspectives = new ArrayList<>(); + for (JsonNode row : rows) { + BroadcastPerspective perspective = modelPerspective(row, chunk, source); + if (perspective != null) { + perspectives.add(perspective); + } + } + return perspectives; + } catch (Exception error) { + throw new IllegalArgumentException("invalid role insight JSON", error); + } + } + + private static BroadcastPerspective modelPerspective(JsonNode row, SourceChunk chunk, String source) { + String code = clean(row.path("code").asText()); + String label = ROLE_LABELS.get(code); + if (label == null) { + return null; + } + List evidence = new ArrayList<>(); + JsonNode evidenceRows = row.path("evidence"); + if (evidenceRows.isArray()) { + for (JsonNode item : evidenceRows) { + String quote = limited(item.path("quote").asText(), 200); + if (quote.length() < 20 || !chunk.text().contains(quote) || !source.contains(quote)) { + continue; + } + int paragraph = Math.max(chunk.paragraphIndex(), item.path("paragraphIndex").asInt(chunk.paragraphIndex())); + if (evidence.stream().noneMatch(existing -> existing.quote().equals(quote))) { + evidence.add(new BroadcastEvidence(paragraph, quote)); + } + if (evidence.size() == MAX_EVIDENCE) { + break; + } + } + } + if (evidence.isEmpty()) { + return null; + } + return new BroadcastPerspective( + code, + label, + limited(row.path("summary").asText(), 500), + items(row.path("concerns")), + items(row.path("impacts")), + items(row.path("actions")), + items(row.path("risks")), + evidence + ); + } + + private static BroadcastPerspective persistedPerspective(JsonNode row) { + String code = clean(row.path("code").asText()); + String label = ROLE_LABELS.get(code); + if (label == null) { + return null; + } + List evidence = new ArrayList<>(); + if (row.path("evidence").isArray()) { + for (JsonNode item : row.path("evidence")) { + String quote = limited(item.path("quote").asText(), 200); + if (quote.length() >= 20) { + evidence.add(new BroadcastEvidence(Math.max(1, item.path("paragraphIndex").asInt(1)), quote)); + } + if (evidence.size() == MAX_EVIDENCE) { + break; + } + } + } + if (evidence.isEmpty()) { + return null; + } + return new BroadcastPerspective(code, label, limited(row.path("summary").asText(), 500), + items(row.path("concerns")), items(row.path("impacts")), items(row.path("actions")), + items(row.path("risks")), evidence); + } + + private static List items(JsonNode node) { + if (!node.isArray()) { + return List.of(); + } + Set values = new LinkedHashSet<>(); + for (JsonNode item : node) { + String value = limited(item.asText(), 300); + if (!value.isEmpty()) { + values.add(value); + } + if (values.size() == MAX_ITEMS) { + break; + } + } + return List.copyOf(values); + } + + private static String prompt(String fileName, SourceChunk chunk) { + return """ + 岗位目录: + living_advisor=生活顾问/客服;cleaning=保洁;security=保安;engineering=保修/工程; + finance=财务;hr=人力;operations=业务运营;audit_risk=审计/风控;management=管理层。 + + 返回格式: + {"perspectives":[{"code":"finance","summary":"...","concerns":["..."],"impacts":["..."], + "actions":["..."],"risks":["..."],"evidence":[{"paragraphIndex":1,"quote":"原文逐字短句"}]}]} + + 规则: + 1. 只输出本切片明确涉及的岗位;没有直接内容就不要输出该岗位。 + 2. 每类最多 5 条,evidence 最多 3 条且每条 20 到 200 字。 + 3. 不得使用常识补充原文没有写的责任、时限、处罚或流程。 + + 文件名:%s + 切片:%d + 起始段落:%d + 原文: + %s + """.formatted(clean(fileName), chunk.index(), chunk.paragraphIndex(), chunk.text()); + } + + private static int naturalBoundary(String content, int start, int desiredEnd) { + int floor = start + CHUNK_SIZE / 2; + int blankLine = content.lastIndexOf("\n\n", desiredEnd); + if (blankLine >= floor) return blankLine; + int line = content.lastIndexOf('\n', desiredEnd); + if (line >= floor) return line; + for (int index = desiredEnd - 1; index >= floor; index--) { + if ("。!?;.!?;".indexOf(content.charAt(index)) >= 0) { + return index + 1; + } + } + return desiredEnd; + } + + private static int paragraphIndex(String content, int offset) { + int index = 1; + for (int cursor = 0; cursor < offset; cursor++) { + if (content.charAt(cursor) == '\n' && (cursor == 0 || content.charAt(cursor - 1) == '\n')) { + index++; + } + } + return index; + } + + private static String jsonObject(String value) { + String text = clean(value); + int start = text.indexOf('{'); + int end = text.lastIndexOf('}'); + if (start < 0 || end < start) { + throw new IllegalArgumentException("missing JSON object"); + } + return text.substring(start, end + 1); + } + + private static boolean containsAny(String value, String... candidates) { + for (String candidate : candidates) { + if (value.contains(candidate)) return true; + } + return false; + } + + private static String clean(String value) { + return value == null ? "" : value.trim(); + } + + private static String limited(String value, int maximum) { + String cleaned = clean(value); + return cleaned.length() <= maximum ? cleaned : cleaned.substring(0, maximum); + } + + public record Generation(String status, String json, List perspectives) { + public Generation { + perspectives = perspectives == null ? List.of() : List.copyOf(perspectives); + } + } + + record SourceChunk(int index, int paragraphIndex, int startOffset, int endOffset, String text) { + } + + private record StoredInsights(String version, List perspectives) { + } + + private static final class PerspectiveAccumulator { + private final String code; + private String summary = ""; + private final Set concerns = new LinkedHashSet<>(); + private final Set impacts = new LinkedHashSet<>(); + private final Set actions = new LinkedHashSet<>(); + private final Set risks = new LinkedHashSet<>(); + private final Map evidence = new LinkedHashMap<>(); + + private PerspectiveAccumulator(String code) { + this.code = code; + } + + private void add(BroadcastPerspective perspective) { + if (summary.isEmpty()) summary = perspective.summary(); + add(concerns, perspective.concerns(), MAX_ITEMS); + add(impacts, perspective.impacts(), MAX_ITEMS); + add(actions, perspective.actions(), MAX_ITEMS); + add(risks, perspective.risks(), MAX_ITEMS); + for (BroadcastEvidence item : perspective.evidence()) { + evidence.putIfAbsent(item.quote(), item); + if (evidence.size() == MAX_EVIDENCE) break; + } + } + + private BroadcastPerspective build() { + return new BroadcastPerspective(code, ROLE_LABELS.get(code), summary, + List.copyOf(concerns), List.copyOf(impacts), List.copyOf(actions), List.copyOf(risks), + List.copyOf(evidence.values())); + } + + private static void add(Set target, List values, int maximum) { + for (String value : values) { + if (target.size() == maximum) break; + target.add(value); + } + } + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/broadcast/AihrBroadcastInsightServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/broadcast/AihrBroadcastInsightServiceTest.java new file mode 100644 index 00000000..5d04d42f --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/broadcast/AihrBroadcastInsightServiceTest.java @@ -0,0 +1,98 @@ +package org.dromara.aihr.broadcast; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.dromara.aihr.domain.AihrModelDto.ChatResponse; +import org.dromara.aihr.service.AihrModelSeedService; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@Tag("dev") +class AihrBroadcastInsightServiceTest { + + @Test + void mapsOnlyKnownOrganizationPositions() { + assertEquals("finance", AihrBroadcastInsightService.defaultPerspectiveCode(List.of("财务经理"))); + assertEquals("living_advisor", AihrBroadcastInsightService.defaultPerspectiveCode(List.of("物业管家"))); + assertEquals("engineering", AihrBroadcastInsightService.defaultPerspectiveCode(List.of("工程维修主管"))); + assertNull(AihrBroadcastInsightService.defaultPerspectiveCode(List.of("未知岗位"))); + } + + @Test + void longContentIsSplitWithOverlapInsteadOfDroppingTheTail() { + String source = "第一段。" + "甲".repeat(4_200) + "\n\n第二段。" + "乙".repeat(4_200); + + List chunks = AihrBroadcastInsightService.chunks(source); + + assertTrue(chunks.size() >= 3); + assertTrue(chunks.stream().allMatch(chunk -> chunk.text().length() <= 4_000)); + assertTrue(chunks.get(chunks.size() - 1).text().contains("乙")); + } + + @Test + void fabricatedEvidenceIsRemovedBeforePersistence() { + String source = "财务部应在每月五日前完成费用复核并向项目负责人提交差异说明。"; + AihrModelSeedService model = mock(AihrModelSeedService.class); + when(model.chat(any())).thenReturn(success(""" + { + "perspectives": [{ + "code": "finance", + "summary": "财务需要按月完成费用复核。", + "concerns": ["费用复核时限"], + "impacts": ["需形成差异说明"], + "actions": ["每月五日前完成复核"], + "risks": ["逾期影响项目核算"], + "evidence": [ + {"paragraphIndex": 1, "quote": "财务部应在每月五日前完成费用复核并向项目负责人提交差异说明"}, + {"paragraphIndex": 1, "quote": "模型编造但原文不存在的财务处罚规则"} + ] + }] + } + """)); + AihrBroadcastInsightService service = new AihrBroadcastInsightService(new ObjectMapper(), model); + + AihrBroadcastInsightService.Generation result = service.generate("费用通知.txt", source); + + assertEquals("READY", result.status()); + assertEquals(1, result.perspectives().size()); + assertEquals(1, result.perspectives().get(0).evidence().size()); + assertFalse(result.json().contains("模型编造")); + assertTrue(source.contains(result.perspectives().get(0).evidence().get(0).quote())); + } + + @Test + void oneFailedChunkProducesPartialResultAndAllFailuresProduceFailed() { + String source = "财务部需要复核本月项目费用并形成书面差异说明。" + "甲".repeat(4_100); + AihrModelSeedService partialModel = mock(AihrModelSeedService.class); + when(partialModel.chat(any())) + .thenReturn(success(""" + {"perspectives":[{"code":"finance","summary":"复核费用","concerns":[],"impacts":[], + "actions":[],"risks":[],"evidence":[{"paragraphIndex":1, + "quote":"财务部需要复核本月项目费用并形成书面差异说明"}]}]} + """)) + .thenThrow(new IllegalStateException("provider timeout")); + AihrBroadcastInsightService partial = new AihrBroadcastInsightService(new ObjectMapper(), partialModel); + + assertEquals("PARTIAL", partial.generate("长文件.txt", source).status()); + + AihrModelSeedService failedModel = mock(AihrModelSeedService.class); + when(failedModel.chat(any())).thenThrow(new IllegalStateException("provider unavailable")); + AihrBroadcastInsightService failed = new AihrBroadcastInsightService(new ObjectMapper(), failedModel); + + assertEquals("FAILED", failed.generate("长文件.txt", source).status()); + } + + private static ChatResponse success(String answer) { + return new ChatResponse(true, "test", "test-model", answer, + "openai-compatible", null, List.of(), null); + } +}