feat(broadcast): generate grounded role insights

This commit is contained in:
2026-07-25 14:24:08 +08:00
parent c35fd0741b
commit 91ebe41259
2 changed files with 490 additions and 0 deletions
@@ -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<String, String> 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<SourceChunk> chunks = chunks(source);
boolean truncated = chunks.size() == MAX_CHUNKS
&& chunks.get(chunks.size() - 1).endOffset() < source.length();
Map<String, PerspectiveAccumulator> 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<BroadcastPerspective> 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<BroadcastPerspective> 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<BroadcastPerspective> 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<String> 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<SourceChunk> chunks(String rawContent) {
String content = rawContent == null ? "" : rawContent.replace("\r\n", "\n").replace('\r', '\n').trim();
if (content.isEmpty()) {
return List.of();
}
List<SourceChunk> 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<BroadcastPerspective> 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<BroadcastPerspective> 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<BroadcastEvidence> 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<BroadcastEvidence> 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<String> items(JsonNode node) {
if (!node.isArray()) {
return List.of();
}
Set<String> 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<BroadcastPerspective> 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<BroadcastPerspective> perspectives) {
}
private static final class PerspectiveAccumulator {
private final String code;
private String summary = "";
private final Set<String> concerns = new LinkedHashSet<>();
private final Set<String> impacts = new LinkedHashSet<>();
private final Set<String> actions = new LinkedHashSet<>();
private final Set<String> risks = new LinkedHashSet<>();
private final Map<String, BroadcastEvidence> 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<String> target, List<String> values, int maximum) {
for (String value : values) {
if (target.size() == maximum) break;
target.add(value);
}
}
}
}
@@ -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<AihrBroadcastInsightService.SourceChunk> 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);
}
}