feat(broadcast): generate grounded role insights
This commit is contained in:
+392
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+98
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user