From 230dfb4dfcb01691e72ab110b612b2946b1967aa Mon Sep 17 00:00:00 2001 From: let5sne Date: Sat, 25 Jul 2026 15:18:58 +0800 Subject: [PATCH] feat(broadcast): finish multi-role insight experience --- .../AihrBroadcastAttachmentService.java | 30 +++-- .../AihrBroadcastInsightService.java | 121 +++++++++++++----- .../aihr/broadcast/AihrBroadcastService.java | 43 ++++++- .../AihrBroadcastAttachmentContractTest.java | 101 +++++++++++++++ .../AihrBroadcastInsightServiceTest.java | 49 +++++-- .../broadcast/AihrBroadcastServiceTest.java | 16 +++ docs/API_INTEGRATION.md | 2 +- docs/BRD_IMPLEMENTATION_AUDIT.md | 2 +- docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md | 4 +- frontend/src/api/aihr/broadcast.ts | 3 + .../broadcast-attachment-contract.test.ts | 6 + frontend/src/views/content/broadcast.vue | 36 +++++- .../src/pages/user/broadcast/detail.vue | 115 ++++++++++++++++- mobile-uni/src/pages/user/broadcast/index.vue | 4 +- mobile-uni/src/services/broadcast.ts | 36 +++++- mobile-uni/tests/broadcast-m1.test.mjs | 3 +- .../broadcast-multi-role-insight.test.mjs | 41 ++++++ scripts/demo-check.sh | 4 + scripts/release-preflight.sh | 16 ++- scripts/reset-dev-db.sh | 1 + 20 files changed, 552 insertions(+), 81 deletions(-) create mode 100644 mobile-uni/tests/broadcast-multi-role-insight.test.mjs diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/broadcast/AihrBroadcastAttachmentService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/broadcast/AihrBroadcastAttachmentService.java index 85178465..7a94033a 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/broadcast/AihrBroadcastAttachmentService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/broadcast/AihrBroadcastAttachmentService.java @@ -30,6 +30,7 @@ import java.sql.Statement; import java.util.List; import java.util.Locale; import java.util.Set; +import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; @@ -160,13 +161,13 @@ public class AihrBroadcastAttachmentService { """); jdbcTemplate.update(""" update aihr_broadcast_attachment - set insight_status = 'PENDING', update_time = now() + set insight_status = 'PENDING', insight_version = null, update_time = now() where status = 'READY' and insight_status = 'PROCESSING' and update_time < date_sub(now(), interval 2 hour) """); trigger(); } catch (RuntimeException error) { - log.warn("broadcast attachment recovery failed"); + log.warn("broadcast attachment recovery failed type={}", error.getClass().getSimpleName()); } } @@ -178,7 +179,7 @@ public class AihrBroadcastAttachmentService { .whenComplete((ignored, error) -> { workerRunning.set(false); if (error != null) { - log.warn("broadcast attachment worker failed"); + log.warn("broadcast attachment worker failed type={}", error.getClass().getSimpleName()); } if (hasQueued()) { trigger(); @@ -234,16 +235,18 @@ public class AihrBroadcastAttachmentService { limit 10 """, (rs, rowNum) -> new InsightWork( rs.getLong("id"), rs.getString("tenant_id"), rs.getString("file_name"), - rs.getString("extracted_text") + rs.getString("extracted_text"), null )); for (InsightWork row : rows) { + String claimToken = "claim-" + UUID.randomUUID().toString().replace("-", "").substring(0, 24); int claimed = jdbcTemplate.update(""" update aihr_broadcast_attachment - set insight_status = 'PROCESSING', update_time = now() + set insight_status = 'PROCESSING', insight_version = ?, update_time = now() where tenant_id = ? and id = ? and status = 'READY' and insight_status = 'PENDING' - """, row.tenantId(), row.id()); + """, claimToken, row.tenantId(), row.id()); if (claimed == 1) { - return row; + return new InsightWork( + row.id(), row.tenantId(), row.fileName(), row.extractedText(), claimToken); } } return null; @@ -294,9 +297,10 @@ public class AihrBroadcastAttachmentService { int updated = jdbcTemplate.update(""" update aihr_broadcast_attachment set insight_status = ?, insights_json = ?, insight_version = ?, update_time = now() - where tenant_id = ? and id = ? and status = 'READY' and insight_status = 'PROCESSING' + where tenant_id = ? and id = ? and status = 'READY' + and insight_status = 'PROCESSING' and insight_version = ? """, generation.status(), generation.json(), AihrBroadcastInsightService.VERSION, - work.tenantId(), work.id()); + work.tenantId(), work.id(), work.claimToken()); if (updated != 1) { log.info("broadcast attachment {} insights completed after its state changed", work.id()); } @@ -306,8 +310,9 @@ public class AihrBroadcastAttachmentService { update aihr_broadcast_attachment set insight_status = 'FAILED', insights_json = null, insight_version = ?, update_time = now() - where tenant_id = ? and id = ? and status = 'READY' and insight_status = 'PROCESSING' - """, AihrBroadcastInsightService.VERSION, work.tenantId(), work.id()); + where tenant_id = ? and id = ? and status = 'READY' + and insight_status = 'PROCESSING' and insight_version = ? + """, AihrBroadcastInsightService.VERSION, work.tenantId(), work.id(), work.claimToken()); } } @@ -404,7 +409,8 @@ public class AihrBroadcastAttachmentService { long id, String tenantId, String fileName, - String extractedText + String extractedText, + String claimToken ) { } } 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 index 9bc23f8a..9ade8286 100644 --- 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 @@ -3,6 +3,7 @@ package org.dromara.aihr.broadcast; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.dromara.aihr.broadcast.AihrBroadcastDto.BroadcastEvidence; import org.dromara.aihr.broadcast.AihrBroadcastDto.BroadcastPerspective; import org.dromara.aihr.domain.AihrModelDto.ChatRequest; @@ -23,6 +24,7 @@ import static java.util.Map.entry; @Service @RequiredArgsConstructor +@Slf4j public class AihrBroadcastInsightService { public static final String VERSION = "role-perspective-v1"; @@ -49,7 +51,8 @@ public class AihrBroadcastInsightService { private final AihrModelSeedService modelService; public Generation generate(String fileName, String content) { - String source = content == null ? "" : content.replace("\r\n", "\n").replace('\r', '\n').trim(); + String normalized = content == null ? "" : content.replace("\r\n", "\n").replace('\r', '\n').trim(); + String source = AihrSensitiveText.forModel(normalized); if (source.isEmpty()) { return new Generation("FAILED", null, List.of()); } @@ -61,12 +64,12 @@ public class AihrBroadcastInsightService { for (SourceChunk chunk : chunks) { try { ChatResponse response = modelService.chat(new ChatRequest( - AihrSensitiveText.forModel(prompt(fileName, chunk)), + prompt(fileName, chunk), null, AihrSensitiveText.forModel(""" 你负责解读物业公司内部文件。只返回 JSON,不要 markdown。 只允许使用给定原文;原文未提及的岗位、结论、行动或风险不要输出。 - evidence.quote 必须逐字复制原文,不能改写或拼接。 + 每个结论的 evidenceQuote 必须逐字复制原文,不能改写或拼接。 """) )); if (response == null || !"openai-compatible".equals(response.mode()) @@ -76,8 +79,9 @@ public class AihrBroadcastInsightService { for (BroadcastPerspective perspective : parseModelPerspectives(response.answer(), chunk, source)) { merged.computeIfAbsent(perspective.code(), PerspectiveAccumulator::new).add(perspective); } - } catch (RuntimeException ignored) { + } catch (RuntimeException error) { failedChunks++; + log.warn("broadcast insight chunk failed type={}", error.getClass().getSimpleName()); } } List perspectives = merged.values().stream() @@ -93,7 +97,8 @@ public class AihrBroadcastInsightService { return new Generation(status, objectMapper.writeValueAsString(new StoredInsights(VERSION, perspectives)), perspectives); - } catch (Exception ignored) { + } catch (Exception error) { + log.warn("broadcast insight serialization failed type={}", error.getClass().getSimpleName()); return new Generation("FAILED", null, List.of()); } } @@ -118,7 +123,8 @@ public class AihrBroadcastInsightService { } } return List.copyOf(perspectives); - } catch (Exception ignored) { + } catch (Exception error) { + log.warn("broadcast stored insights invalid type={}", error.getClass().getSimpleName()); return List.of(); } } @@ -193,37 +199,77 @@ public class AihrBroadcastInsightService { 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; - } - } - } + String summary = groundedText(row.path("summary"), 500, chunk, source, evidence); + List concerns = groundedItems(row.path("concerns"), chunk, source, evidence); + List impacts = groundedItems(row.path("impacts"), chunk, source, evidence); + List actions = groundedItems(row.path("actions"), chunk, source, evidence); + List risks = groundedItems(row.path("risks"), chunk, source, evidence); 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")), + summary, + concerns, + impacts, + actions, + risks, evidence ); } + private static List groundedItems( + JsonNode node, + SourceChunk chunk, + String source, + List evidence + ) { + if (!node.isArray()) { + return List.of(); + } + Set values = new LinkedHashSet<>(); + for (JsonNode item : node) { + String value = groundedText(item, 300, chunk, source, evidence); + if (!value.isEmpty()) { + values.add(value); + } + if (values.size() == MAX_ITEMS) { + break; + } + } + return List.copyOf(values); + } + + private static String groundedText( + JsonNode item, + int maximum, + SourceChunk chunk, + String source, + List evidence + ) { + if (!item.isObject()) { + return ""; + } + String text = limited(item.path("text").asText(), maximum); + String quote = limited(item.path("evidenceQuote").asText(), 200); + if (text.isEmpty() || quote.length() < 20) { + return ""; + } + int offset = source.indexOf(quote, chunk.startOffset()); + if (offset < chunk.startOffset() || offset + quote.length() > chunk.endOffset()) { + return ""; + } + boolean existing = evidence.stream().anyMatch(value -> value.quote().equals(quote)); + if (!existing && evidence.size() == MAX_EVIDENCE) { + return ""; + } + if (!existing) { + evidence.add(new BroadcastEvidence(paragraphIndex(source, offset), quote)); + } + return text; + } + private static BroadcastPerspective persistedPerspective(JsonNode row) { String code = clean(row.path("code").asText()); String label = ROLE_LABELS.get(code); @@ -274,13 +320,18 @@ public class AihrBroadcastInsightService { finance=财务;hr=人力;operations=业务运营;audit_risk=审计/风控;management=管理层。 返回格式: - {"perspectives":[{"code":"finance","summary":"...","concerns":["..."],"impacts":["..."], - "actions":["..."],"risks":["..."],"evidence":[{"paragraphIndex":1,"quote":"原文逐字短句"}]}]} + {"perspectives":[{"code":"finance", + "summary":{"text":"...","evidenceQuote":"原文逐字短句"}, + "concerns":[{"text":"...","evidenceQuote":"原文逐字短句"}], + "impacts":[{"text":"...","evidenceQuote":"原文逐字短句"}], + "actions":[{"text":"...","evidenceQuote":"原文逐字短句"}], + "risks":[{"text":"...","evidenceQuote":"原文逐字短句"}]}]} 规则: 1. 只输出本切片明确涉及的岗位;没有直接内容就不要输出该岗位。 - 2. 每类最多 5 条,evidence 最多 3 条且每条 20 到 200 字。 - 3. 不得使用常识补充原文没有写的责任、时限、处罚或流程。 + 2. 每个 summary 或列表项都必须携带自己的 evidenceQuote;没有原文证据就删除该项。 + 3. 每类最多 5 条,证据短句 20 到 200 字。 + 4. 不得使用常识补充原文没有写的责任、时限、处罚或流程。 文件名:%s 切片:%d @@ -366,6 +417,14 @@ public class AihrBroadcastInsightService { } private void add(BroadcastPerspective perspective) { + long newEvidence = perspective.evidence().stream() + .map(BroadcastEvidence::quote) + .filter(quote -> !evidence.containsKey(quote)) + .distinct() + .count(); + if (evidence.size() + newEvidence > MAX_EVIDENCE) { + return; + } if (summary.isEmpty()) summary = perspective.summary(); add(concerns, perspective.concerns(), MAX_ITEMS); add(impacts, perspective.impacts(), MAX_ITEMS); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/broadcast/AihrBroadcastService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/broadcast/AihrBroadcastService.java index 32277f07..335e4bcf 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/broadcast/AihrBroadcastService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/broadcast/AihrBroadcastService.java @@ -645,7 +645,7 @@ public class AihrBroadcastService { throw employeeOnly(); } Long matched = jdbcTemplate.queryForObject(""" - select count(*) + select count(distinct o.ext_party_id) from sys_user u join aihr_org_snapshot o on binary o.tenant_id = binary u.tenant_id @@ -655,9 +655,23 @@ public class AihrBroadcastService { and u.user_type = ? and u.status = '0' and u.del_flag = '0' + and char_length(u.phonenumber) = 11 + and u.phonenumber regexp '^[0-9]{11}$' and o.employment_status = 'active' + and o.ext_party_id is not null + and o.ext_party_id <> '' + and not exists ( + select 1 + from aihr_org_snapshot o2 + where binary o2.tenant_id = binary o.tenant_id + and binary o2.ext_party_id = binary o.ext_party_id + and o2.employment_status = 'active' + and o2.person_phone is not null + and o2.person_phone <> '' + and binary o2.person_phone <> binary u.phonenumber + ) """, Long.class, principal.tenantId(), principal.userId(), UserType.APP_USER.getUserType()); - if (matched == null || matched == 0) { + if (matched == null || matched != 1) { throw employeeOnly(); } } @@ -674,7 +688,32 @@ public class AihrBroadcastService { and u.user_type = ? and u.status = '0' and u.del_flag = '0' + and char_length(u.phonenumber) = 11 + and u.phonenumber regexp '^[0-9]{11}$' and o.employment_status = 'active' + and o.ext_party_id is not null + and o.ext_party_id <> '' + and o.ext_party_id = ( + select min(o2.ext_party_id) + from aihr_org_snapshot o2 + where binary o2.tenant_id = binary u.tenant_id + and binary o2.person_phone = binary u.phonenumber + and o2.employment_status = 'active' + and o2.ext_party_id is not null + and o2.ext_party_id <> '' + group by o2.person_phone + having count(distinct o2.ext_party_id) = 1 + ) + and not exists ( + select 1 + from aihr_org_snapshot o3 + where binary o3.tenant_id = binary o.tenant_id + and binary o3.ext_party_id = binary o.ext_party_id + and o3.employment_status = 'active' + and o3.person_phone is not null + and o3.person_phone <> '' + and binary o3.person_phone <> binary u.phonenumber + ) and o.position_name is not null and o.position_name <> '' order by o.position_name diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/broadcast/AihrBroadcastAttachmentContractTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/broadcast/AihrBroadcastAttachmentContractTest.java index d4c0082f..1dab3aed 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/broadcast/AihrBroadcastAttachmentContractTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/broadcast/AihrBroadcastAttachmentContractTest.java @@ -2,17 +2,33 @@ package org.dromara.aihr.broadcast; import org.dromara.aihr.broadcast.AihrBroadcastDto.BroadcastAttachmentResponse; import org.dromara.aihr.broadcast.AihrBroadcastDto.PublishRequest; +import org.dromara.aihr.knowledge.parse.KnowledgeDocumentParser; +import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver; +import org.dromara.aihr.service.AihrSopSeedService; +import org.dromara.system.service.ISysOssService; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; import java.lang.reflect.Method; +import java.lang.reflect.Proxy; import java.nio.file.Files; import java.nio.file.Path; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; import java.util.List; +import java.util.Map; +import java.util.concurrent.ScheduledExecutorService; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; 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 AihrBroadcastAttachmentContractTest { @@ -58,5 +74,90 @@ class AihrBroadcastAttachmentContractTest { assertTrue(source.contains("insight_status = 'PENDING'")); assertTrue(source.contains("insight_status = 'FAILED'")); assertTrue(source.contains("insight_status = 'PROCESSING'")); + assertTrue(source.contains("set insight_status = 'PENDING', insight_version = null")); + } + + @Test + void insightWorkerUsesItsClaimTokenWhenCompletingWithoutChangingFileReadiness() throws Exception { + InsightJdbcTemplate jdbcTemplate = new InsightJdbcTemplate(); + AihrBroadcastInsightService insightService = mock(AihrBroadcastInsightService.class); + when(insightService.generate(any(), any())).thenReturn( + new AihrBroadcastInsightService.Generation("FAILED", null, List.of())); + AihrBroadcastAttachmentService service = new AihrBroadcastAttachmentService( + jdbcTemplate, + mock(ISysOssService.class), + mock(KnowledgeDocumentParser.class), + mock(AihrSopSeedService.class), + insightService, + mock(AihrKnowledgePrincipalResolver.class), + mock(AihrBroadcastService.class), + mock(ScheduledExecutorService.class) + ); + + Method claim = AihrBroadcastAttachmentService.class.getDeclaredMethod("claimInsight"); + claim.setAccessible(true); + Object work = claim.invoke(service); + Method claimTokenAccessor = work.getClass().getDeclaredMethod("claimToken"); + claimTokenAccessor.setAccessible(true); + String claimToken = (String) claimTokenAccessor.invoke(work); + + Method process = AihrBroadcastAttachmentService.class.getDeclaredMethod("processInsight", work.getClass()); + process.setAccessible(true); + process.invoke(service, work); + + assertTrue(claimToken.startsWith("claim-")); + assertArrayEquals(new Object[]{claimToken, "tenant-a", 9L}, jdbcTemplate.updateArgs.get(0)); + assertTrue(jdbcTemplate.updateSql.get(1).contains("and insight_version = ?")); + assertFalse(jdbcTemplate.updateSql.get(1).contains("set status =")); + assertEquals(claimToken, jdbcTemplate.updateArgs.get(1)[5]); + } + + private static final class InsightJdbcTemplate extends JdbcTemplate { + private final List updateSql = new ArrayList<>(); + private final List updateArgs = new ArrayList<>(); + + @Override + public List query(String sql, RowMapper rowMapper, Object... args) { + return rows(rowMapper); + } + + @Override + public List query(String sql, RowMapper rowMapper) { + return rows(rowMapper); + } + + private static List rows(RowMapper rowMapper) { + try { + return List.of(rowMapper.mapRow(resultSet(Map.of( + "id", 9L, + "tenant_id", "tenant-a", + "file_name", "制度.txt", + "extracted_text", "制度原文" + )), 0)); + } catch (SQLException error) { + throw new AssertionError(error); + } + } + + @Override + public int update(String sql, Object... args) { + updateSql.add(sql); + updateArgs.add(args); + return 1; + } + + private static ResultSet resultSet(Map values) { + return (ResultSet) Proxy.newProxyInstance( + InsightJdbcTemplate.class.getClassLoader(), new Class[]{ResultSet.class}, (proxy, method, args) -> { + Object value = values.get(args[0]); + if ("getLong".equals(method.getName())) { + return ((Number) value).longValue(); + } + if ("getString".equals(method.getName())) { + return value == null ? null : value.toString(); + } + throw new UnsupportedOperationException(method.getName()); + }); + } } } 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 index 5d04d42f..a9f2aa51 100644 --- 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 @@ -39,22 +39,21 @@ class AihrBroadcastInsightServiceTest { } @Test - void fabricatedEvidenceIsRemovedBeforePersistence() { - String source = "财务部应在每月五日前完成费用复核并向项目负责人提交差异说明。"; + void everyConclusionRequiresItsOwnExactEvidenceAndUsesTheActualParagraph() { + String source = "本通知自发布之日起执行。\n\n财务部应在每月五日前完成费用复核并向项目负责人提交差异说明。"; 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": "模型编造但原文不存在的财务处罚规则"} - ] + "summary": {"text": "财务需要按月完成费用复核。", + "evidenceQuote": "财务部应在每月五日前完成费用复核并向项目负责人提交差异说明"}, + "concerns": [{"text": "逾期将被处罚", + "evidenceQuote": "模型编造但原文不存在的财务处罚规则"}], + "impacts": [{"text": "需形成差异说明", + "evidenceQuote": "财务部应在每月五日前完成费用复核并向项目负责人提交差异说明"}], + "actions": [], + "risks": [] }] } """)); @@ -65,19 +64,41 @@ class AihrBroadcastInsightServiceTest { assertEquals("READY", result.status()); assertEquals(1, result.perspectives().size()); assertEquals(1, result.perspectives().get(0).evidence().size()); + assertEquals(2, result.perspectives().get(0).evidence().get(0).paragraphIndex()); + assertTrue(result.perspectives().get(0).concerns().isEmpty()); + assertEquals(List.of("需形成差异说明"), result.perspectives().get(0).impacts()); assertFalse(result.json().contains("模型编造")); assertTrue(source.contains(result.perspectives().get(0).evidence().get(0).quote())); } + @Test + void evidenceIsCheckedAgainstTheSameRedactedTextSentToTheModel() { + String source = "财务复核遇到疑问时请联系13912345678,并在每月五日前提交书面差异说明。"; + AihrModelSeedService model = mock(AihrModelSeedService.class); + when(model.chat(any())).thenReturn(success(""" + {"perspectives":[{"code":"finance", + "summary":{"text":"按时提交差异说明", + "evidenceQuote":"财务复核遇到疑问时请联系1391****5678,并在每月五日前提交书面差异说明。"}, + "concerns":[],"impacts":[],"actions":[],"risks":[]}]} + """)); + AihrBroadcastInsightService service = new AihrBroadcastInsightService(new ObjectMapper(), model); + + AihrBroadcastInsightService.Generation result = service.generate("费用通知.txt", source); + + assertEquals("READY", result.status()); + assertTrue(result.json().contains("1391****5678")); + assertFalse(result.json().contains("13912345678")); + } + @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":"财务部需要复核本月项目费用并形成书面差异说明"}]}]} + {"perspectives":[{"code":"finance", + "summary":{"text":"复核费用","evidenceQuote":"财务部需要复核本月项目费用并形成书面差异说明"}, + "concerns":[],"impacts":[],"actions":[],"risks":[]}]} """)) .thenThrow(new IllegalStateException("provider timeout")); AihrBroadcastInsightService partial = new AihrBroadcastInsightService(new ObjectMapper(), partialModel); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/broadcast/AihrBroadcastServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/broadcast/AihrBroadcastServiceTest.java index 3813c9d1..541be075 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/broadcast/AihrBroadcastServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/broadcast/AihrBroadcastServiceTest.java @@ -70,6 +70,22 @@ class AihrBroadcastServiceTest { assertArrayEquals(new Object[]{"tenant-a", 7L, "app_user"}, jdbcTemplate.queryForObjectArgs); } + @Test + void employeeBroadcastEndpointsFailClosedWhenOnePhoneMatchesMultiplePeople() { + RecordingJdbcTemplate jdbcTemplate = new RecordingJdbcTemplate(); + jdbcTemplate.audienceCount = 2L; + AihrBroadcastService service = new AihrBroadcastService(jdbcTemplate, RESOLVER); + + ServiceException error = assertThrows(ServiceException.class, service::unreadCount); + + assertEquals(HttpStatus.FORBIDDEN, error.getCode()); + assertTrue(jdbcTemplate.queryForObjectSql.contains("count(distinct o.ext_party_id)")); + assertTrue(jdbcTemplate.queryForObjectSql.contains("not exists")); + assertTrue(jdbcTemplate.queryForObjectSql.contains("binary o2.person_phone <> binary u.phonenumber")); + assertTrue(jdbcTemplate.queryForObjectSql.contains("char_length(u.phonenumber) = 11")); + assertTrue(jdbcTemplate.queryForObjectSql.contains("u.phonenumber regexp '^[0-9]{11}$'")); + } + @Test void unavailableMessagesDoNotLeakAcrossTenantsOrAfterWithdrawal() { RecordingJdbcTemplate jdbcTemplate = new RecordingJdbcTemplate(); diff --git a/docs/API_INTEGRATION.md b/docs/API_INTEGRATION.md index 5339c553..0317b1fc 100644 --- a/docs/API_INTEGRATION.md +++ b/docs/API_INTEGRATION.md @@ -20,7 +20,7 @@ | SOP 知识底层服务 `/knowledge/sop` 与旧客户端兼容 | `POST /api/knowledge/query`、`POST /api/knowledge/query-media`、`GET /api/knowledge/resources/{attachmentId}/content`、`POST /api/knowledge/answer-feedback`、`GET /api/knowledge/position-sop`、`GET /api/aihr/mobile/onboard/tasks`、`POST /api/aihr/mobile/onboard/tasks/{id}/complete`、`GET /api/aihr/mobile/qualification`、`POST /api/knowledge/doc/upload` | Agent 按规划结果调用该层;管理端和旧客户端接口继续兼容。文字/现场附件查询走统一知识空间授权和 MySQL Fulltext + Qdrant 混合召回;内部登录端可选传 `conversationId/contextVersion`,服务端保留 30 分钟不活跃过期、最新 6 轮脱敏截断上下文,用现有 chat 模型或保守规则输出 `QA/FILE/VIDEO/DATA_TOOL` 和 `rewrittenQuery`,旧版本冲突返回 `409`。员工从银城大喇叭详情追问时只传 `broadcastMessageId`:服务端每轮按当前 APP 在职员工、当前租户和 `PUBLISHED` 状态重新取消息,响应仅回显 `{messageId,title,publishedAt}`,消息正文不写入客户端存储、会话 JSON 或查询审计;同一会话的消息来源不可改绑,撤回、跨租户或非在职员工均拒绝。该上下文只说明公司消息,不可据此捏造个人任务;上下文模式拒绝现场媒体和数据工具,外部无状态 API 不接受该参数。岗位 SOP 的项目范围复用当前 APP 已验证主体的 `projectCodes`,不得以管理端组织快照的模糊/别名查询作为后备。原文件/视频仅从当前命中且仍有授权的附件返回,内容接口每次重新计算租户、应用和主体权限;无资源时明确返回空列表,不生成假链接。搜索继续返回 `reviewId/promptVersion`,员工反馈保存评审批次;员工学习页按当前 APP 身份读取正式岗前/入职任务,资格证据无正式数据时明确返回 `NOT_CONFIGURED`。详细边界见 [专项设计](20260718/问师傅多轮会话与原始资料交付设计.md)。 | | 工作助手确认式统一采集 `/pages/user/sop/index`、`/pages/user/assistant/memories` | Agent 主入口返回 `actionDraft`,确认/忽略走 `/api/aihr/agent/actions/{draftId}/confirm|dismiss`;旧接口 `POST /api/knowledge/query`、`POST /api/knowledge/query-media`、`GET /api/aihr/personal-assistant/memory-candidates?status=DRAFT`、`POST /api/aihr/personal-assistant/memory-candidates/{id}/confirm|dismiss` 继续兼容;确认记录读写使用 `GET /api/aihr/personal-assistant/assistant-captures?limit=50`、`POST /assistant-captures/{id}/status`、`GET /assistant-captures/{id}/status-history`、`GET /assistant-captures/{id}/source`,旧项目记录继续使用 `/api/aihr/service-memories` | 文字、ASR 转写和现场媒体复用同一候选检测。项目工作必须从 `/api/aihr/mobile/me` 返回的项目名称列表选择当前项目,内部 `projectCode` 仍由服务端按当前登录主体授权收窄;个人笔记可以不绑定项目。显式“记一下”生成 `ASSISTANT_CAPTURE/DRAFT`,系统建议 `ATTENDANCE/INSPECTION/SERVICE_LEAD/RESIDENT_PROFILE/CASE/PERSONAL_NOTE/FOLLOW_UP/PROJECT_NOTE`。确认必须带 `expectedVersion + idempotencyKey + saveScope(PRIVATE|COMPANY)`;PRIVATE 仅本人可见,COMPANY 写入 PENDING 待流转。状态更新只允许 `RECORDED/PENDING/IN_PROGRESS/COMPLETED/VOID`,幂等键不得跨记录或目标状态复用。来源文件重新鉴权交付;正式外部接收端未配置前不得显示已送达。 | | 今日工作成果 `/pages/user/work-results/index`、`/pages/supervisor/work-results/index` | 员工 `POST /api/aihr/work-results/mine/generate?projectCode=...&workDate=YYYY-MM-DD`;主管 `GET /api/aihr/work-results/project?projectCode=...&workDate=YYYY-MM-DD` | 按员工 + 项目 + Asia/Shanghai 自然日确定性聚合已确认记录;两页默认当天并可按日期查看历史成果,客户端禁止选择未来日期。内容未变化时重复生成不增加版本,记录或状态变化后版本递增。主管只可查看本人在目标项目具备主管身份的团队结果;APP 登录身份同时兼容组织快照中的外部人员 ID 与手机号,普通员工或跨项目请求返回 403。外部接口未接通时只显示本地状态和 PENDING,不伪造派单或考勤同步成功 | -| 银城大喇叭 | 员工 `GET /api/aihr/broadcast/unread-count`、`GET /api/aihr/broadcast/messages?pageNum=&pageSize=`、`GET /api/aihr/broadcast/messages/{id}`、`POST /api/aihr/broadcast/messages/{id}/read`、`GET /api/aihr/broadcast/attachments/{id}/content`;员工详情可经统一 `POST /api/knowledge/query` 的 `broadcastMessageId` 发起文字追问;管理 `GET /api/aihr/broadcast/admin/messages`、`POST /api/aihr/broadcast/admin/attachments`、`GET /api/aihr/broadcast/admin/attachments/{id}`、`POST /api/aihr/broadcast/messages`、`POST /api/aihr/broadcast/messages/{id}/withdraw` | 发布支持全员或按部门/岗位/人员定向、必读、单个公司文件与异步提炼。文件支持 txt/md/PDF/Word/Excel/PPT,100MB 内;先受控写入 OSS,再异步解析并复用现有模型生成摘要,只有 `READY` 且未绑定、属于当前租户和上传人的文件才可随消息发布。员工详情只返回摘要和受控下载信息,不返回提取全文或原始 OSS 地址;下载和追问每次重新校验当前 APP 在职身份、租户和消息状态,追问上下文由服务端拼接消息正文与提取文本,客户端仍只传 `broadcastMessageId`。定向目标用于“与我有关”和必读提醒,不改变全租户公开频道的可见性。发布、阅读和撤回保持既有幂等与审计约束;管理接口只允许 `superadmin` 或 `hr_operator`。当前仍不包含消息修订、撤回后补推或短信/电话强触达。 | +| 银城大喇叭 | 员工 `GET /api/aihr/broadcast/unread-count`、`GET /api/aihr/broadcast/messages?pageNum=&pageSize=`、`GET /api/aihr/broadcast/messages/{id}`、`POST /api/aihr/broadcast/messages/{id}/read`、`GET /api/aihr/broadcast/attachments/{id}/content`;员工详情可经统一 `POST /api/knowledge/query` 的 `broadcastMessageId` 发起文字追问;管理 `GET /api/aihr/broadcast/admin/messages`、`POST /api/aihr/broadcast/admin/attachments`、`GET /api/aihr/broadcast/admin/attachments/{id}`、`POST /api/aihr/broadcast/messages`、`POST /api/aihr/broadcast/messages/{id}/withdraw` | 发布支持全员或按部门/岗位/人员定向、必读、单个公司文件与异步提炼。文件支持 txt/md/PDF/Word/Excel/PPT,100MB 内;先受控写入 OSS,再异步解析并复用现有模型生成摘要,只有 `READY` 且未绑定、属于当前租户和上传人的文件才可随消息发布。文件提炼完成后异步生成生活顾问、保洁、保安、工程维修、财务、人力、运营、审计风控、管理层中有原文依据的岗位解读;员工详情附件返回 `insightStatus/defaultPerspectiveCode/perspectiveLabels/perspectives`,每项依据仅含原文段号与已校验的短引用,不返回提取全文或原始 OSS 地址。`defaultPerspectiveCode` 由服务端按当前 APP 手机号精确匹配在职岗位,客户端不能指定;所有有权限员工仍可查看并切换全部已生成视角。`PENDING/PARTIAL/FAILED` 均不阻断摘要、原文件下载、消息发布或追问。下载和追问每次重新校验当前 APP 在职身份、租户和消息状态,追问上下文由服务端拼接消息正文与提取文本,客户端仍只传 `broadcastMessageId`。定向目标在界面称“定向提醒”,用于必读与范围提示,不改变全租户公开频道的可见性。发布、阅读和撤回保持既有幂等与审计约束;管理接口只允许 `superadmin` 或 `hr_operator`。当前仍不包含消息修订、撤回后补推或短信/电话强触达。 | | 员工直通车 `/pages/user/direct/index`、管理端 `/content/direct` | 员工 `GET /api/aihr/direct/channels`、`POST /api/aihr/direct/feedback`、`GET /api/aihr/direct/mine`、`GET /api/aihr/direct/mine/{id}`;处理端 `GET /api/aihr/direct/admin/feedback`、`POST /api/aihr/direct/admin/feedback/{id}/reply` | 员工可选择总裁、财务、人力、审计、运营并点对点提交;反馈内容支持语音转文字输入(复用 `/api/ai/asr`;文字为主路径,录音不可用或权限失败仅提示改文字输入)。总裁/审计默认匿名;匿名仅表示业务处理界面不显示提交人,系统仍保存内部账号和姓名快照供本人查询、幂等与审计。`direct_president/direct_finance/direct_hr/direct_audit/direct_operations` 仅处理各自频道,`superadmin` 可处理全部;列表、详情和回复均由服务端按角色收窄。一个反馈只允许一次正式回复,员工可在“我的反馈”查看状态和回复;当前不扩展为工单 SLA、转派或多轮聊天。 | | 成果投稿(当前页面名“工作上报”)`/pages/user/report/index` | `POST /api/aihr/work-report/organize`、`POST /attachment`、`POST /reports`、`GET /reports/mine`;主管/运营另有列表和审核接口 | 只承载 CASE/VIDEO/SOP/KNOWLEDGE 四类投稿。会话式页面、无状态整理、附件、幂等正式提交和历史状态已部署;审核通过不自动入知识库。当前整理服务不读取图片/视频内容,只把附件名称作为不可信元数据;不得与日常工作记录或“今日工作成果”混用 | | 资料处理 `/knowledge/processing` | `GET /api/knowledge/processing/overview`、`POST /api/knowledge/doc/upload-async`、`GET /api/knowledge/doc/upload-items`、`POST /api/knowledge/doc/upload-items/{id}/retry` | 已接入解析任务状态聚合;页面只保留“批量导入”,接口暂存+入队即秒回,后台 worker(并发 2)逐条解析/归类/向量化;ZIP 在 worker 内安全解压后把支持的子文件继续入同一批次队列,页面按批次轮询进度、失败可单文件重试;不提供浏览器目录选择或服务端目录导入入口 | diff --git a/docs/BRD_IMPLEMENTATION_AUDIT.md b/docs/BRD_IMPLEMENTATION_AUDIT.md index d1e9d659..cc16dc85 100644 --- a/docs/BRD_IMPLEMENTATION_AUDIT.md +++ b/docs/BRD_IMPLEMENTATION_AUDIT.md @@ -33,7 +33,7 @@ ### 2026-07-24 公司消息 R1—R3 发布边界 -当前公司消息能力包括全员公开频道、目标快照、必读、未读/已读、管理端发布/撤回、单文件异步提炼,以及以 `broadcastMessageId` 进入“问”的受控追问。服务端每轮重新校验租户、在职身份和消息状态,正文与附件内容均由服务端加载;消息撤回后续问和文件访问即被拒绝。 +当前公司消息能力包括全员公开频道、目标快照、必读、未读/已读、管理端发布/撤回、单文件异步提炼,以及以 `broadcastMessageId` 进入“问”的受控追问。开发分支已补充公司文件多岗位解读:AI 只保留能以原文短引用佐证的岗位视角,员工默认视角由认证手机号对应的在职岗位决定,同时允许切换查看全部已生成视角;生成失败不阻断摘要、原文件、发布或追问。服务端每轮重新校验租户、在职身份和消息状态,正文与附件内容均由服务端加载;消息撤回后续问和文件访问即被拒绝。该新增能力尚未部署或完成生产验收。 “问”页面将连续会话设为主体,消息/知识回答不再把通用培训或岗位资料伪造成当前员工待办。生产已验证全员文件消息发布、摘要、认证下载、文件内容追问和撤回;测试消息已撤回。直通车按高管、财务、人力、审计和运营五个通道分权,处理端只见“匿名员工”,系统保留内部身份审计,每条仅一次正式回复;生产已验证员工提交、审计处理和员工查看回复。定向人群正反例、正式处理人绑定、修订、补推、强触达和外部流程仍待补齐。 diff --git a/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md b/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md index 59daeb6e..f66e7789 100644 --- a/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md +++ b/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md @@ -110,7 +110,7 @@ mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aih mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/aihr_personal_knowledge_mysql8.sql ``` -顺序原因:场景补充依赖三张场景/Rubric 基础表;五维迁移依赖场景和 Rubric;五岗位题库之后的内容运营迁移为每个场景补齐唯一的初始 Rubric、五个稳定评分维度与训练时评分规则快照列,并只修正仍处于待训练状态的旧每日题场景编号,不重写已完成记录。紧随其后的成长目录迁移只在既有场景和会话表上增加岗位/层级/能力项/审核状态与会话快照,以及复盘会话专用的 `growth_confirmation_level/growth_confirmed_by/growth_confirmed_time`;它只允许记录主管对当前会话下一训练层级的确认,不改写原训练快照或人事结论,并补齐/修复派发请求的 `(tenant_id,request_key,ext_party_id)` 唯一键与最近内容过滤索引。它保留既有启停可用性和历史会话,不把迁移结果写成业务内容签字。生产环境不得设置 `AIHR_PRACTICE_RUNTIME_SCHEMA_BOOTSTRAP=true`:应用请求只读校验表、列和关键索引,任何缺失都必须先执行本节正式 SQL,不能由任一用户请求触发补列、补索引或状态回填。岗位/SOP/任务/资格迁移只补正式数据契约,不写业务行,也不代表岗位适用范围、任务规则或资格标准已经获得 HR 确认。住宅 SOP 和 Prompt 迁移只补内置内容,不代表内容已完成业务复核。知识会话迁移只新增短期上下文表,不生成业务对话。排序规则兼容迁移先对齐本批发布表,全量排序规则迁移再统一历史 `aihr_*` 表;两者都读取目标库 `aihr_knowledge_info.tenant_id` 的实际排序规则。服务记忆迁移创建候选、统一采集、兼容项目记录和版本留痕四张表;工作上报幂等迁移随后只补原表列与唯一索引;知识分类迁移新增空间内分类表及附件成员的可空 `category_id`,不迁移、删除或扩大任何空间授权与检索范围;银城大喇叭 M0 迁移创建消息、阅读和不可变 v1 快照三表,为已存在消息补一次快照,并补发布请求键/载荷哈希、撤回原因及 `(tenant_id,published_by,publish_request_key)` 唯一约束。紧接着 M1 定向迁移补充必读与目标载荷字段,以及规则快照、命中对象快照两表;它只决定提醒/必读对象,不改变全租户公开频道的可见性。直通车迁移创建点对点反馈表,并给正式租户 `000000` 幂等预置总裁、财务、人力、审计、运营五个处理角色;人员仍须由管理员按职责分配,业务匿名不抹除内部审计身份。公司文件迁移创建异步提炼表并给消息增加可空 `attachment_id`,不迁移或公开已有文件。随后,消息追问迁移只给短期会话增加可空 `broadcast_message_id`;它不保存消息正文,并用于约束同一会话不能切换公司消息。历史普通会话保持 `NULL`。Agent 迁移只新增最小运行审计表,不保存问题、答案、密钥或模型推理。个人资料迁移最后创建独立 `aihr_personal_*` 表,并从当前租户既有 MinIO 配置派生私有 `personal-minio` 配置;`ruoyi-personal` bucket 必须由受控运维流程创建和验证,不能在文档或日志中输出访问密钥。它们同样对齐目标排序规则,兼容历史生产库与全新 MySQL 8 数据库。 +顺序原因:场景补充依赖三张场景/Rubric 基础表;五维迁移依赖场景和 Rubric;五岗位题库之后的内容运营迁移为每个场景补齐唯一的初始 Rubric、五个稳定评分维度与训练时评分规则快照列,并只修正仍处于待训练状态的旧每日题场景编号,不重写已完成记录。紧随其后的成长目录迁移只在既有场景和会话表上增加岗位/层级/能力项/审核状态与会话快照,以及复盘会话专用的 `growth_confirmation_level/growth_confirmed_by/growth_confirmed_time`;它只允许记录主管对当前会话下一训练层级的确认,不改写原训练快照或人事结论,并补齐/修复派发请求的 `(tenant_id,request_key,ext_party_id)` 唯一键与最近内容过滤索引。它保留既有启停可用性和历史会话,不把迁移结果写成业务内容签字。生产环境不得设置 `AIHR_PRACTICE_RUNTIME_SCHEMA_BOOTSTRAP=true`:应用请求只读校验表、列和关键索引,任何缺失都必须先执行本节正式 SQL,不能由任一用户请求触发补列、补索引或状态回填。岗位/SOP/任务/资格迁移只补正式数据契约,不写业务行,也不代表岗位适用范围、任务规则或资格标准已经获得 HR 确认。住宅 SOP 和 Prompt 迁移只补内置内容,不代表内容已完成业务复核。知识会话迁移只新增短期上下文表,不生成业务对话。排序规则兼容迁移先对齐本批发布表,全量排序规则迁移再统一历史 `aihr_*` 表;两者都读取目标库 `aihr_knowledge_info.tenant_id` 的实际排序规则。服务记忆迁移创建候选、统一采集、兼容项目记录和版本留痕四张表;工作上报幂等迁移随后只补原表列与唯一索引;知识分类迁移新增空间内分类表及附件成员的可空 `category_id`,不迁移、删除或扩大任何空间授权与检索范围;银城大喇叭 M0 迁移创建消息、阅读和不可变 v1 快照三表,为已存在消息补一次快照,并补发布请求键/载荷哈希、撤回原因及 `(tenant_id,published_by,publish_request_key)` 唯一约束。紧接着 M1 定向迁移补充必读与目标载荷字段,以及规则快照、命中对象快照两表;它只决定提醒/必读对象,不改变全租户公开频道的可见性。直通车迁移创建点对点反馈表,并给正式租户 `000000` 幂等预置总裁、财务、人力、审计、运营五个处理角色;人员仍须由管理员按职责分配,业务匿名不抹除内部审计身份。公司文件迁移创建异步提炼表并给消息增加可空 `attachment_id`,不迁移或公开已有文件;紧随其后的多岗位解读迁移只在附件表增加生成状态、经引用校验的结构化结果和规则版本,并建立异步队列索引,不回填旧文件、不改变文件 READY 或消息发布状态。随后,消息追问迁移只给短期会话增加可空 `broadcast_message_id`;它不保存消息正文,并用于约束同一会话不能切换公司消息。历史普通会话保持 `NULL`。Agent 迁移只新增最小运行审计表,不保存问题、答案、密钥或模型推理。个人资料迁移最后创建独立 `aihr_personal_*` 表,并从当前租户既有 MinIO 配置派生私有 `personal-minio` 配置;`ruoyi-personal` bucket 必须由受控运维流程创建和验证,不能在文档或日志中输出访问密钥。它们同样对齐目标排序规则,兼容历史生产库与全新 MySQL 8 数据库。 ## 迁移后只读核验 @@ -242,7 +242,7 @@ GROUP BY d.dimension_code ORDER BY d.dimension_code; ``` -本批完整发布的预期是 `release-preflight` 的 `64/64` 必需表与列契约均存在,新增项为 `aihr_direct_feedback`、`aihr_broadcast_attachment`、`aihr_broadcast_message.attachment_id` 和 `aihr_agent_run`。此前 `57/57`、`62/62` 都只代表较窄的历史发布清单。后续线上只读复核已确认 M1 的目标表和必读字段,但当前线上不存在 `aihr_practice_help_event`,Agent 迁移也仍是本地发布候选,因此不得把完整清单写成已发布。Agent 审计字段应为 `14/14`,且表中不得出现问题、答案或附件列。工作上报幂等字段为 `2/2`,大喇叭发布/撤回审计字段为 `3/3`,其发布幂等索引为 `0 | tenant_id,published_by,publish_request_key | 0`,M1 必读/目标载荷字段契约为 `2/2`,两张目标表字段契约为 `16/16`,定向规则与对象快照唯一约束分别为 `4/4`、`3/3`,公司消息追问会话字段为 `1/1`,工作助手新增字段为 `8/8`,会话证据字段为 `8/8`,专项批量/内容快照字段为 `8/8`,候选资料审核字段为 `2/2`,面试复核字段为 `6/6`,字段查询覆盖所有列,内置 Prompt 数量为 `6`。私有资料配置只应返回 `ruoyi-personal`、私有策略和 `configured` 状态,不显示访问密钥。五个岗位各有 `20` 个场景,`daily/special` 各有 `100` 道题;其中 `14` 个未完成正式审核的高风险场景及其 `28` 道题保持禁用。岗位/SOP/任务/资格表及新记忆表为空是允许的;结构、索引和内置内容通过只证明 schema/seed 与发布产物一致,不证明员工直达反馈、文件提炼、员工确认记忆、完整个人知识库或严格试点已经完成业务验收。 +本批完整发布的预期是 `release-preflight` 的 `64/64` 必需表与列契约均存在,新增项为 `aihr_direct_feedback`、`aihr_broadcast_attachment`、`aihr_broadcast_message.attachment_id` 和 `aihr_agent_run`。此前 `57/57`、`62/62` 都只代表较窄的历史发布清单。后续线上只读复核已确认 M1 的目标表和必读字段,但当前线上不存在 `aihr_practice_help_event`,Agent 迁移也仍是本地发布候选,因此不得把完整清单写成已发布。Agent 审计字段应为 `14/14`,且表中不得出现问题、答案或附件列。工作上报幂等字段为 `2/2`,大喇叭发布/撤回审计字段为 `3/3`,其发布幂等索引为 `0 | tenant_id,published_by,publish_request_key | 0`,M1 必读/目标载荷字段契约为 `2/2`,两张目标表字段契约为 `16/16`,定向规则与对象快照唯一约束分别为 `4/4`、`3/3`,公司文件附件字段和消息绑定字段契约为 `15/15`,并应存在 `(insight_status,status,update_time)` 解读队列索引;公司消息追问会话字段为 `1/1`,工作助手新增字段为 `8/8`,会话证据字段为 `8/8`,专项批量/内容快照字段为 `8/8`,候选资料审核字段为 `2/2`,面试复核字段为 `6/6`,字段查询覆盖所有列,内置 Prompt 数量为 `6`。私有资料配置只应返回 `ruoyi-personal`、私有策略和 `configured` 状态,不显示访问密钥。五个岗位各有 `20` 个场景,`daily/special` 各有 `100` 道题;其中 `14` 个未完成正式审核的高风险场景及其 `28` 道题保持禁用。岗位/SOP/任务/资格表及新记忆表为空是允许的;结构、索引和内置内容通过只证明 schema/seed 与发布产物一致,不证明员工直达反馈、文件提炼、多岗位解读、员工确认记忆、完整个人知识库或严格试点已经完成业务验收。 ## 迁移后应用回归 diff --git a/frontend/src/api/aihr/broadcast.ts b/frontend/src/api/aihr/broadcast.ts index 1c36d18d..3ff2cf85 100644 --- a/frontend/src/api/aihr/broadcast.ts +++ b/frontend/src/api/aihr/broadcast.ts @@ -7,6 +7,7 @@ type ApiResult = { }; export type BroadcastStatus = 'PUBLISHED' | 'WITHDRAWN'; +export type BroadcastInsightStatus = 'NOT_PROCESSED' | 'PENDING' | 'READY' | 'PARTIAL' | 'FAILED'; export type BroadcastTargetRequest = { projectCodes: string[]; @@ -51,6 +52,8 @@ export type BroadcastAttachment = { summary?: string; errorMessage?: string; downloadUrl?: string; + insightStatus?: BroadcastInsightStatus; + perspectiveLabels?: string[]; }; export type BroadcastAdminPage = { diff --git a/frontend/src/utils/broadcast-attachment-contract.test.ts b/frontend/src/utils/broadcast-attachment-contract.test.ts index 305a7421..558a58b6 100644 --- a/frontend/src/utils/broadcast-attachment-contract.test.ts +++ b/frontend/src/utils/broadcast-attachment-contract.test.ts @@ -15,6 +15,12 @@ describe('公司文件提炼发布契约', () => { expect(page).toContain('上传并提炼文件'); expect(page).toContain('refreshAttachment'); expect(page).toContain("attachment.value?.status !== 'READY'"); + expect(api).toContain('insightStatus?: BroadcastInsightStatus'); + expect(api).toContain('perspectiveLabels?: string[]'); + expect(page).toContain('多岗位解读'); + expect(page).toContain("response.data.insightStatus === 'PENDING'"); + expect(page).toContain('attachment.perspectiveLabels?.length'); + expect(page).toContain('刷新状态'); expect(page).toContain('attachmentId: form.attachmentId'); expect(pending).toContain('attachmentId?: number'); }); diff --git a/frontend/src/views/content/broadcast.vue b/frontend/src/views/content/broadcast.vue index 4df7fe70..9debcb3d 100644 --- a/frontend/src/views/content/broadcast.vue +++ b/frontend/src/views/content/broadcast.vue @@ -166,6 +166,18 @@ show-icon :title="`提炼摘要:${attachment.summary}`" /> +
+ 多岗位解读:{{ insightStatusText(attachment.insightStatus) }} +
+ {{ label }} +
+ 刷新状态 +
{ if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`; return `${(bytes / 1024 / 1024).toFixed(1)} MB`; }; +const insightStatusText = (status?: BroadcastAttachment['insightStatus']) => ({ + NOT_PROCESSED: '未生成', + PENDING: '正在生成', + READY: '已完成', + PARTIAL: '部分完成', + FAILED: '生成失败,不影响发布' +}[status || 'NOT_PROCESSED']); const stopAttachmentPolling = () => { if (attachmentPollTimer !== undefined) window.clearTimeout(attachmentPollTimer); @@ -685,7 +704,9 @@ const refreshAttachment = async (id: number, attemptsLeft = 80) => { const response = await getBroadcastAttachment(id); attachment.value = response.data; form.attachmentId = response.data.id; - if ((response.data.status === 'QUEUED' || response.data.status === 'PROCESSING') && attemptsLeft > 0) { + if ((response.data.status === 'QUEUED' + || response.data.status === 'PROCESSING' + || response.data.insightStatus === 'PENDING') && attemptsLeft > 0) { attachmentPollTimer = window.setTimeout(() => void refreshAttachment(id, attemptsLeft - 1), 1500); } } catch { @@ -820,6 +841,19 @@ onUnmounted(stopAttachmentPolling); font-size: 12px; } +.insight-status-line, +.insight-labels { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.insight-status-line { + color: #606266; + font-size: 13px; +} + .table-card { padding: 18px; border-radius: 8px; diff --git a/mobile-uni/src/pages/user/broadcast/detail.vue b/mobile-uni/src/pages/user/broadcast/detail.vue index f1e6e983..b4f0a7e9 100644 --- a/mobile-uni/src/pages/user/broadcast/detail.vue +++ b/mobile-uni/src/pages/user/broadcast/detail.vue @@ -14,10 +14,14 @@ {{ item.title }} 必读消息 - 与我有关{{ item.targetReason ? ` · ${item.targetReason}` : '' }} + 定向提醒{{ item.targetReason ? ` · ${item.targetReason}` : '' }} 发布时间:{{ displayTime(item.publishedAt) }} {{ item.content }} + + + 将在“问”中围绕本条消息继续咨询 + @@ -30,19 +34,67 @@ - - - - 将在“问”中围绕本条消息继续咨询 + + + + + {{ selectedPerspective?.code === item.attachment.defaultPerspectiveCode ? '我的岗位视角' : '多岗位解读' }} + + {{ selectedPerspective?.label }} + + 部分完成 + + + + + + + {{ selectedPerspective.summary }} + + {{ section.label }} + · {{ entry }} + + + + + 第 {{ evidence.paragraphIndex }} 段 + “{{ evidence.quote }}” + + + + + AI 正在从不同岗位解读,不影响查看原文件 + + + + 多岗位解读暂不可用,可继续查看原文件或咨询 + + + 该文件暂未生成多岗位解读 +