fix(personal): align assistant citations and UI state

This commit is contained in:
2026-07-12 12:07:28 +08:00
parent c8ef15ee9e
commit 36b7bf28bf
14 changed files with 181 additions and 61 deletions
@@ -27,8 +27,13 @@ public final class PersonalAssistantDto {
LocalDate dateTo, List<Long> 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<CitationResponse> citations, String model,
@@ -53,8 +58,12 @@ public final class PersonalAssistantDto {
LocalDate dateTo, List<Long> 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<SearchHitResponse> hits) {
@@ -69,4 +78,14 @@ public final class PersonalAssistantDto {
public record ChatMessageResponse(long id, String role, String content, List<CitationResponse> 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;
}
}
}
@@ -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<CitationResponse> 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() {
@@ -95,7 +95,7 @@ public class PersonalRetrievalService {
private List<SearchHitResponse> 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)
@@ -296,12 +296,26 @@ public class PersonalSpaceService {
private List<CitationResponse> citations(String value) {
if (value == null || value.isBlank()) return List.of();
try {
return objectMapper.readValue(value, new TypeReference<>() { });
List<CitationResponse> 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<String> allowed, String error) {
if (value == null || value.isBlank()) return null;
String normalized = value.trim().toUpperCase(java.util.Locale.ROOT);
@@ -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<org.dromara.aihr.personal.domain.PersonalAssistantDto.CitationResponse> 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);
}
@@ -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());
@@ -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<CitationResponse> current = (List<CitationResponse>) 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<CitationResponse> legacy = (List<CitationResponse>) 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);