diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/domain/PersonalAssistantDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/domain/PersonalAssistantDto.java index b55adf24..e131cedb 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/domain/PersonalAssistantDto.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/domain/PersonalAssistantDto.java @@ -27,8 +27,13 @@ public final class PersonalAssistantDto { LocalDate dateTo, List itemIds, String outputFormat) { } - public record CitationResponse(String domain, String sourceId, String title, String excerpt, - LocalDateTime capturedAt) { + public record CitationResponse(String domain, String sourceId, Long itemId, Long fragmentId, String sourceType, + String title, String excerpt, LocalDateTime capturedAt) { + /** Reads legacy persisted citations whose sourceId represented the fragment id. */ + public CitationResponse(String domain, String sourceId, String title, String excerpt, + LocalDateTime capturedAt) { + this(domain, sourceId, null, legacyId(sourceId), null, title, excerpt, capturedAt); + } } public record AskResponse(long sessionId, String answer, List citations, String model, @@ -53,8 +58,12 @@ public final class PersonalAssistantDto { LocalDate dateTo, List itemIds, Integer limit) { } - public record SearchHitResponse(String domain, String sourceId, String title, String excerpt, - LocalDateTime capturedAt, double score) { + public record SearchHitResponse(String domain, String sourceId, Long itemId, Long fragmentId, String sourceType, + String title, String excerpt, LocalDateTime capturedAt, double score) { + public SearchHitResponse(String domain, String sourceId, String title, String excerpt, + LocalDateTime capturedAt, double score) { + this(domain, sourceId, null, legacyId(sourceId), null, title, excerpt, capturedAt, score); + } } public record PersonalSearchResponse(String queryText, List hits) { @@ -69,4 +78,14 @@ public final class PersonalAssistantDto { public record ChatMessageResponse(long id, String role, String content, List citations, LocalDateTime createdAt) { } + + private static Long legacyId(String value) { + if (value == null || value.isBlank()) return null; + try { + long id = Long.parseLong(value); + return id > 0 ? id : null; + } catch (NumberFormatException ex) { + return null; + } + } } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalAnswerService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalAnswerService.java index af7aa9e0..0acb241b 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalAnswerService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalAnswerService.java @@ -202,13 +202,15 @@ public class PersonalAnswerService { PersonalSearchRequest search = new PersonalSearchRequest(request.query(), List.of(SearchScope.PERSONAL), request.dateFrom(), request.dateTo(), request.itemIds(), PER_DOMAIN_LIMIT); personal = personalRetriever.search(owner, search).stream() - .map(hit -> citation("PERSONAL", hit.sourceId(), hit.title(), hit.excerpt(), hit.capturedAt())) + .map(hit -> citation("PERSONAL", hit.sourceId(), hit.itemId(), hit.fragmentId(), hit.sourceType(), + hit.title(), hit.excerpt(), hit.capturedAt())) .toList(); } if (request.scopes().contains(SearchScope.ENTERPRISE)) { enterprise = enterpriseRetriever.search(owner, request.query(), enterpriseGrant.orElseThrow(), PER_DOMAIN_LIMIT).stream() - .map(hit -> citation("ENTERPRISE", Long.toString(hit.fragmentId()), hit.title(), hit.content(), null)) + .map(hit -> citation("ENTERPRISE", Long.toString(hit.fragmentId()), null, hit.fragmentId(), + "ENTERPRISE", hit.title(), hit.content(), null)) .toList(); } List ordered = new ArrayList<>(); @@ -237,10 +239,11 @@ public class PersonalAnswerService { target.addAll(unique.values()); } - private static CitationResponse citation(String domain, String sourceId, String title, String excerpt, + private static CitationResponse citation(String domain, String sourceId, Long itemId, Long fragmentId, + String sourceType, String title, String excerpt, LocalDateTime capturedAt) { - return new CitationResponse(domain, sourceId, truncate(clean(title), MAX_TITLE_LENGTH), - truncate(clean(excerpt), MAX_EXCERPT_LENGTH), capturedAt); + return new CitationResponse(domain, sourceId, itemId, fragmentId, sourceType, + truncate(clean(title), MAX_TITLE_LENGTH), truncate(clean(excerpt), MAX_EXCERPT_LENGTH), capturedAt); } private static String systemPrompt() { diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalRetrievalService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalRetrievalService.java index 0e1c0367..2fe6c505 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalRetrievalService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalRetrievalService.java @@ -95,7 +95,7 @@ public class PersonalRetrievalService { private List fulltext(PersonalOwner owner, ValidatedRequest request) { StringBuilder sql = new StringBuilder(""" - select f.id as fragment_id, i.title, f.content, i.captured_at, + select f.id as fragment_id, i.id as item_id, i.source_type, i.title, f.content, i.captured_at, match(f.content) against (? in natural language mode) as relevance from aihr_personal_fragment f join aihr_personal_item i @@ -130,7 +130,8 @@ public class PersonalRetrievalService { return List.of(); } StringBuilder sql = new StringBuilder(""" - select f.id as fragment_id, i.title, f.content, i.captured_at, 0 as relevance + select f.id as fragment_id, i.id as item_id, i.source_type, i.title, f.content, i.captured_at, + 0 as relevance from aihr_personal_fragment f join aihr_personal_item i on i.id = f.item_id and i.tenant_id = f.tenant_id and i.owner_user_id = f.owner_user_id @@ -160,8 +161,8 @@ public class PersonalRetrievalService { for (PersonalVectorStore.VectorMatch match : matches) { SearchHitResponse hit = byId.get(Long.toString(match.fragmentId())); if (hit != null) { - ordered.add(new SearchHitResponse(hit.domain(), hit.sourceId(), hit.title(), hit.excerpt(), - hit.capturedAt(), match.score())); + ordered.add(new SearchHitResponse(hit.domain(), hit.sourceId(), hit.itemId(), hit.fragmentId(), + hit.sourceType(), hit.title(), hit.excerpt(), hit.capturedAt(), match.score())); } } return ordered; @@ -171,6 +172,9 @@ public class PersonalRetrievalService { return (rs, rowNum) -> new SearchHitResponse( "PERSONAL", Long.toString(rs.getLong("fragment_id")), + rs.getLong("item_id"), + rs.getLong("fragment_id"), + rs.getString("source_type"), rs.getString("title"), excerpt(rs.getString("content")), rs.getObject("captured_at", LocalDateTime.class), @@ -184,8 +188,9 @@ public class PersonalRetrievalService { addRanking(lexical, hits, scores); addRanking(vector, hits, scores); return hits.values().stream() - .map(hit -> new SearchHitResponse(hit.domain(), hit.sourceId(), hit.title(), hit.excerpt(), - hit.capturedAt(), scores.getOrDefault(hit.sourceId(), 0D))) + .map(hit -> new SearchHitResponse(hit.domain(), hit.sourceId(), hit.itemId(), hit.fragmentId(), + hit.sourceType(), hit.title(), hit.excerpt(), hit.capturedAt(), + scores.getOrDefault(hit.sourceId(), 0D))) .sorted(Comparator.comparingDouble(SearchHitResponse::score).reversed() .thenComparing(SearchHitResponse::sourceId)) .limit(limit) diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalSpaceService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalSpaceService.java index a9e55259..36baccca 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalSpaceService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalSpaceService.java @@ -296,12 +296,26 @@ public class PersonalSpaceService { private List citations(String value) { if (value == null || value.isBlank()) return List.of(); try { - return objectMapper.readValue(value, new TypeReference<>() { }); + List parsed = objectMapper.readValue(value, new TypeReference<>() { }); + return parsed.stream().map(this::normalizeLegacyCitation).toList(); } catch (Exception ex) { throw new ServiceException("PERSONAL_SESSION_DATA_INVALID"); } } + private CitationResponse normalizeLegacyCitation(CitationResponse citation) { + if (citation == null || citation.fragmentId() != null) return citation; + Long fragmentId = null; + try { + long parsed = Long.parseLong(citation.sourceId()); + if (parsed > 0) fragmentId = parsed; + } catch (NumberFormatException | NullPointerException ignored) { + // Legacy non-numeric source ids stay nullable instead of inventing an identity. + } + return new CitationResponse(citation.domain(), citation.sourceId(), citation.itemId(), fragmentId, + citation.sourceType(), citation.title(), citation.excerpt(), citation.capturedAt()); + } + private static String normalized(String value, Set allowed, String error) { if (value == null || value.isBlank()) return null; String normalized = value.trim().toUpperCase(java.util.Locale.ROOT); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalAnswerServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalAnswerServiceTest.java index 4c0bdc45..1158e27f 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalAnswerServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalAnswerServiceTest.java @@ -56,6 +56,11 @@ class PersonalAnswerServiceTest { assertEquals(List.of("PERSONAL", "ENTERPRISE"), response.citations().stream().map(citation -> citation.domain()).toList()); assertEquals(2, response.citations().size()); + assertEquals(9L, response.citations().get(0).itemId()); + assertEquals(109L, response.citations().get(0).fragmentId()); + assertEquals("URL", response.citations().get(0).sourceType()); + assertEquals(null, response.citations().get(1).itemId()); + assertEquals(7L, response.citations().get(1).fragmentId()); assertEquals(500L, response.sessionId()); assertEquals(List.of(SearchScope.PERSONAL, SearchScope.ENTERPRISE), persistence.scope); assertEquals(response.citations(), persistence.citations); @@ -343,7 +348,8 @@ class PersonalAnswerServiceTest { jdbc, transaction, new ObjectMapper().findAndRegisterModules()); List citations = List.of( new org.dromara.aihr.personal.domain.PersonalAssistantDto.CitationResponse( - "PERSONAL", "8", "标题", "摘录", LocalDateTime.of(2026, 7, 12, 9, 0))); + "PERSONAL", "8", 80L, 8L, "FILE", "标题", "摘录", + LocalDateTime.of(2026, 7, 12, 9, 0))); assertTrue(persistence.sessionAccessible(OWNER, 88L)); assertEquals(88L, persistence.save(OWNER, 88L, "问题", "答案", @@ -375,6 +381,13 @@ class PersonalAnswerServiceTest { .filter(args -> "user".equals(args[4])) .findFirst().orElseThrow(); assertEquals("provider-model", assistantArgs[8]); + String citationsJson = String.valueOf(assistantArgs[7]); + assertTrue(citationsJson.contains("\"itemId\"")); + assertTrue(citationsJson.contains("\"fragmentId\"")); + assertTrue(citationsJson.contains("\"sourceType\"")); + assertTrue(citationsJson.contains("\"itemId\":80")); + assertTrue(citationsJson.contains("\"fragmentId\":8")); + assertTrue(citationsJson.contains("\"sourceType\":\"FILE\"")); assertEquals(17, assistantArgs[10]); assertEquals(8, assistantArgs[11]); assertTrue(((Long) userArgs[0]) < ((Long) assistantArgs[0])); @@ -443,7 +456,7 @@ class PersonalAnswerServiceTest { } private static SearchHitResponse personalHit(String id, String title, String excerpt) { - return new SearchHitResponse("PERSONAL", id, title, excerpt, + return new SearchHitResponse("PERSONAL", id, Long.parseLong(id), 100L + Long.parseLong(id), "URL", title, excerpt, LocalDateTime.of(2026, 7, 12, 9, 0), 1D); } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalRetrievalServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalRetrievalServiceTest.java index bcc29b92..0e45224c 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalRetrievalServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalRetrievalServiceTest.java @@ -44,6 +44,8 @@ class PersonalRetrievalServiceTest { assertTrue(normalized.contains("i.id = f.item_id and i.tenant_id = f.tenant_id and i.owner_user_id = f.owner_user_id")); assertTrue(normalized.contains("f.tenant_id = ? and f.owner_user_id = ?")); assertTrue(normalized.contains("i.status = 'READY'")); + assertTrue(normalized.contains("i.id as item_id")); + assertTrue(normalized.contains("i.source_type")); assertTrue(normalized.contains("match(f.content) against (? in natural language mode)")); assertTrue(normalized.contains("i.id in (?,?)")); assertFalse(sql.getValue().contains("3,5")); @@ -169,6 +171,15 @@ class PersonalRetrievalServiceTest { return new SearchHitResponse("PERSONAL", id, title, title + " excerpt", LocalDateTime.of(2026, 1, 1, 0, 0), 1); } + @Test + void personalHitCarriesItemFragmentAndSourceTypeSeparately() { + SearchHitResponse hit = new SearchHitResponse("PERSONAL", "44", 12L, 44L, "URL", "网页", "正文", + LocalDateTime.of(2026, 1, 1, 0, 0), 1D); + assertEquals(12L, hit.itemId()); + assertEquals(44L, hit.fragmentId()); + assertEquals("URL", hit.sourceType()); + } + private void assertDateInvalid(org.junit.jupiter.api.function.Executable executable) { IllegalArgumentException error = assertThrows(IllegalArgumentException.class, executable); assertEquals("PERSONAL_SEARCH_DATE_INVALID", error.getMessage()); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalSpaceServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalSpaceServiceTest.java index 1f34d254..372ea011 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalSpaceServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalSpaceServiceTest.java @@ -4,6 +4,7 @@ import org.dromara.aihr.personal.config.PersonalSchedulingConfig; import org.dromara.aihr.personal.service.PersonalIngestionService; import org.dromara.aihr.personal.service.PersonalIngestionWorker; import org.dromara.aihr.personal.service.PersonalSpaceService; +import org.dromara.aihr.personal.domain.PersonalAssistantDto.CitationResponse; import org.dromara.aihr.personal.support.PersonalKnowledgeProperties; import org.dromara.aihr.personal.support.PersonalOwner; import org.dromara.common.core.exception.ServiceException; @@ -46,6 +47,24 @@ import static org.mockito.Mockito.when; @Tag("dev") class PersonalSpaceServiceTest { + @Test + @SuppressWarnings("unchecked") + void citationPersistenceReadsNewIdentityAndLegacySourceId() { + PersonalSpaceService service = new PersonalSpaceService(mock(JdbcTemplate.class), properties()); + List current = (List) ReflectionTestUtils.invokeMethod(service, + "citations", "[{\"domain\":\"PERSONAL\",\"sourceId\":\"44\",\"itemId\":12," + + "\"fragmentId\":44,\"sourceType\":\"URL\",\"title\":\"网页\",\"excerpt\":\"摘要\"}]"); + assertEquals(12L, current.get(0).itemId()); + assertEquals(44L, current.get(0).fragmentId()); + assertEquals("URL", current.get(0).sourceType()); + + List legacy = (List) ReflectionTestUtils.invokeMethod(service, + "citations", "[{\"domain\":\"PERSONAL\",\"sourceId\":\"45\",\"title\":\"旧记录\"," + + "\"excerpt\":\"摘要\"}]"); + assertEquals(null, legacy.get(0).itemId()); + assertEquals(45L, legacy.get(0).fragmentId()); + } + @Test void itemListAndSessionsAreAlwaysOwnerScoped() { JdbcTemplate jdbc = mock(JdbcTemplate.class); diff --git a/mobile-uni/src/pages/user/assistant/index.vue b/mobile-uni/src/pages/user/assistant/index.vue index daefd6a1..76321df3 100644 --- a/mobile-uni/src/pages/user/assistant/index.vue +++ b/mobile-uni/src/pages/user/assistant/index.vue @@ -51,7 +51,7 @@ {{ answer.answer }} 引用依据 -