feat(personal-assistant): add confirmed project memory

This commit is contained in:
2026-07-19 08:19:24 +08:00
parent 151cc26caf
commit 24da833fa6
31 changed files with 4037 additions and 60 deletions
@@ -1,6 +1,7 @@
package org.dromara.aihr.knowledge.domain;
import org.dromara.aihr.domain.AihrSopDto.SearchResponse;
import org.dromara.aihr.memory.AihrMemoryDto.MemoryCandidateResponse;
import java.util.List;
@@ -32,8 +33,17 @@ public final class AihrKnowledgeQueryDto {
String docId,
String title,
String snippet,
Long fragmentId
Long fragmentId,
String domain,
String status,
String occurredAt,
String updatedAt
) {
public Citation(String spaceCode, String sourceType, String docId, String title, String snippet,
Long fragmentId) {
this(spaceCode, sourceType, docId, title, snippet, fragmentId,
"DOCUMENT".equals(sourceType) ? "ENTERPRISE" : sourceType, null, null, null);
}
}
public record Resource(
@@ -58,13 +68,28 @@ public final class AihrKnowledgeQueryDto {
Long contextVersion,
String intent,
String rewrittenQuery,
List<Resource> resources
List<Resource> resources,
MemoryCandidateResponse memoryCandidate
) {
public QueryResponse(String requestId, String queryText, String answer, List<Citation> citations,
List<String> usedSpaceCodes, boolean noEvidence, String promptVersion,
SearchResponse legacy, Object data, String conversationId, Long contextVersion,
String intent, String rewrittenQuery, List<Resource> resources) {
this(requestId, queryText, answer, citations, usedSpaceCodes, noEvidence, promptVersion,
legacy, data, conversationId, contextVersion, intent, rewrittenQuery, resources, null);
}
public QueryResponse(String requestId, String queryText, String answer, List<Citation> citations,
List<String> usedSpaceCodes, boolean noEvidence, String promptVersion,
SearchResponse legacy, Object data) {
this(requestId, queryText, answer, citations, usedSpaceCodes, noEvidence, promptVersion,
legacy, data, null, null, "QA", queryText, List.of());
legacy, data, null, null, "QA", queryText, List.of(), null);
}
public QueryResponse withMemoryCandidate(MemoryCandidateResponse candidate) {
return new QueryResponse(requestId, queryText, answer, citations, usedSpaceCodes, noEvidence,
promptVersion, legacy, data, conversationId, contextVersion, intent, rewrittenQuery,
resources, candidate);
}
}
}
@@ -37,6 +37,27 @@ public class AihrKnowledgeQueryAuditService {
}
}
/** Updates an earlier document audit or inserts the final fused-source audit when no document query ran. */
public void recordFinal(String requestId, AihrKnowledgePrincipal principal, AuthenticatedApp app, String question,
List<String> spaceCodes, List<String> sourceTypes, String status, long latencyMs,
String promptVersion) {
try {
int updated = jdbcTemplate.update("""
update aihr_knowledge_query_log
set used_space_codes_json = ?, source_types_json = ?, status = ?,
latency_ms = greatest(latency_ms, ?), prompt_version = ?
where request_id = ? and tenant_id = ? and app_id = ?
""", json(spaceCodes), json(sourceTypes), status, latencyMs, promptVersion,
requestId, app.tenantId(), app.id());
if (updated == 0) {
record(requestId, principal, app, question, spaceCodes, sourceTypes, status, latencyMs,
promptVersion);
}
} catch (DataAccessException ex) {
log.warn("knowledge query final audit write failed for request {}", requestId);
}
}
private String json(List<String> values) {
try {
return objectMapper.writeValueAsString(values == null ? List.of() : values);
@@ -12,6 +12,9 @@ import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryResponse;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.Resource;
import org.dromara.aihr.knowledge.service.AihrKnowledgeConversationService.ConversationContext;
import org.dromara.aihr.knowledge.service.AihrKnowledgeDataToolService.ToolResult;
import org.dromara.aihr.memory.AihrMemoryDto.MemoryCandidateResponse;
import org.dromara.aihr.memory.AihrMemoryDto.ServiceMemoryRecall;
import org.dromara.aihr.memory.AihrMemoryService;
import org.dromara.aihr.service.AihrSopSeedService;
import org.dromara.common.core.constant.HttpStatus;
import org.dromara.common.core.exception.ServiceException;
@@ -40,23 +43,64 @@ public class AihrKnowledgeQueryService {
private final JdbcTemplate jdbcTemplate;
private final AihrKnowledgeDataToolService dataToolService;
private final AihrKnowledgeConversationService conversationService;
private final AihrMemoryService memoryService;
public QueryResponse queryInternal(QueryRequest rawRequest) {
long queryStarted = System.nanoTime();
QueryRequest request = validate(rawRequest);
AihrKnowledgePrincipal principal = principalResolver.current();
AuthenticatedApp app = appService.requireSessionApp(principal.tenantId(), principal.clientKey());
ConversationContext context = conversationContext(principal, app, request);
QueryRequest routed = routedRequest(request, context);
Set<Long> spaceIds = Set.of();
QueryResponse response;
ServiceMemoryRecall memoryRecall = ServiceMemoryRecall.empty();
boolean finalizeAudit = false;
QueryResponse response = null;
if (hasText(routed.toolCode())) {
response = queryDataTool(principal, app, routed, request.queryText());
} else {
spaceIds = accessService.resolveInternalSpaceIds(
principal, app, request.spaceCodes(), "READ");
response = queryDocuments(principal, app, spaceIds, routed, request.queryText());
memoryRecall = "QA".equals(context.intent())
? memoryService.recall(principal, routed.queryText(), routed.limit())
: ServiceMemoryRecall.empty();
if (memoryRecall == null) {
memoryRecall = ServiceMemoryRecall.empty();
}
boolean candidateIntent = "QA".equals(context.intent())
&& memoryService.isProjectMemoryCandidateIntent(principal, request.queryText());
try {
spaceIds = accessService.resolveInternalSpaceIds(
principal, app, request.spaceCodes(), "READ");
} catch (ServiceException ex) {
if (ex.getCode() != null && ex.getCode() == HttpStatus.FORBIDDEN
&& request.spaceCodes().isEmpty()
&& (!memoryRecall.items().isEmpty() || candidateIntent)) {
response = emptyProjectResponse(request.queryText());
finalizeAudit = true;
} else {
throw ex;
}
spaceIds = Set.of();
}
if (response == null) {
response = queryDocuments(principal, app, spaceIds, routed, request.queryText());
}
if (!memoryRecall.items().isEmpty()) {
response = mergeServiceMemories(memoryRecall, response);
finalizeAudit = true;
}
}
return complete(principal, app, spaceIds, request.queryText(), context, response);
QueryResponse completed = complete(principal, app, spaceIds, request.queryText(), context, response);
MemoryCandidateResponse candidate = !"QA".equals(context.intent()) || hasText(routed.toolCode()) ? null
: memoryService.detectForQuery(principal, request.queryText(), context.conversationId(), completed.requestId());
QueryResponse result = completed.withMemoryCandidate(candidate);
if (finalizeAudit || candidate != null) {
List<String> sourceTypes = finalSourceTypes(result, memoryRecall, candidate);
auditService.recordFinal(
result.requestId(), principal, app, request.queryText(), result.usedSpaceCodes(), sourceTypes,
result.noEvidence() && candidate == null ? "NO_EVIDENCE" : "SUCCESS",
elapsedMillis(queryStarted), result.promptVersion());
}
return result;
}
public QueryResponse queryInternalMedia(QueryRequest rawRequest, MultipartFile file) {
@@ -184,6 +228,49 @@ public class AihrKnowledgeQueryService {
);
}
private QueryResponse mergeServiceMemories(ServiceMemoryRecall recall, QueryResponse response) {
List<Citation> citations = new ArrayList<>(response.citations());
recall.items().forEach(item -> citations.add(new Citation(
PROJECT_SERVICE, PROJECT_SERVICE, "service-memory:" + item.id(), item.title(), item.summary(), null,
PROJECT_SERVICE, item.status(), item.occurredAt(), item.updatedAt()
)));
LinkedHashSet<String> usedScopes = new LinkedHashSet<>(response.usedSpaceCodes());
usedScopes.add(PROJECT_SERVICE);
String answer = response.noEvidence() || response.citations().isEmpty()
? recall.answer()
: response.answer() + "\n\n" + recall.answer();
return new QueryResponse(
response.requestId(), response.queryText(), answer, List.copyOf(citations), List.copyOf(usedScopes),
false, response.promptVersion(), response.legacy(), response.data(), response.conversationId(),
response.contextVersion(), response.intent(), response.rewrittenQuery(), response.resources(),
response.memoryCandidate()
);
}
private static QueryResponse emptyProjectResponse(String queryText) {
return new QueryResponse(
UUID.randomUUID().toString().replace("-", ""), queryText, "", List.of(), List.of(),
true, "service-memory-v1", null, null
);
}
private static List<String> finalSourceTypes(QueryResponse response, ServiceMemoryRecall recall,
MemoryCandidateResponse candidate) {
LinkedHashSet<String> sourceTypes = new LinkedHashSet<>();
boolean hasDocument = response.citations().stream()
.anyMatch(citation -> !PROJECT_SERVICE.equals(citation.domain()));
if (hasDocument) {
sourceTypes.add("DOCUMENT");
}
if (recall != null && !recall.items().isEmpty()) {
sourceTypes.add(PROJECT_SERVICE);
}
if (candidate != null) {
sourceTypes.add("MEMORY_CANDIDATE");
}
return List.copyOf(sourceTypes);
}
private List<Resource> resources(String tenantId, Set<Long> spaceIds, List<Citation> citations, String intent) {
if (!isResourceIntent(intent) || spaceIds.isEmpty() || citations == null || citations.isEmpty()) {
return List.of();
@@ -337,6 +424,8 @@ public class AihrKnowledgeQueryService {
|| value.endsWith(".mkv") || value.endsWith(".webm") || value.endsWith(".m4v");
}
private static final String PROJECT_SERVICE = "PROJECT_SERVICE";
private record AttachmentResource(Long attachmentId, String title) {
}
}
@@ -0,0 +1,45 @@
package org.dromara.aihr.memory;
import cn.dev33.satoken.annotation.SaCheckLogin;
import lombok.RequiredArgsConstructor;
import org.dromara.aihr.memory.AihrMemoryDto.ConfirmMemoryRequest;
import org.dromara.aihr.memory.AihrMemoryDto.ConfirmMemoryResponse;
import org.dromara.aihr.memory.AihrMemoryDto.MemoryCandidateResponse;
import org.dromara.common.core.domain.R;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/aihr/personal-assistant")
public class AihrMemoryController {
private final AihrMemoryService memoryService;
@SaCheckLogin
@GetMapping("/memory-candidates")
public R<List<MemoryCandidateResponse>> candidates(
@RequestParam(value = "status", defaultValue = "DRAFT") String status
) {
return R.ok(memoryService.candidates(status));
}
@SaCheckLogin
@PostMapping("/memory-candidates/{id}/confirm")
public R<ConfirmMemoryResponse> confirm(@PathVariable Long id, @RequestBody ConfirmMemoryRequest request) {
return R.ok(memoryService.confirm(id, request));
}
@SaCheckLogin
@PostMapping("/memory-candidates/{id}/dismiss")
public R<MemoryCandidateResponse> dismiss(@PathVariable Long id) {
return R.ok(memoryService.dismiss(id));
}
}
@@ -0,0 +1,125 @@
package org.dromara.aihr.memory;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public final class AihrMemoryDto {
private AihrMemoryDto() {
}
public record MemoryDraft(
String projectCode,
String buildingName,
String unitName,
String roomNo,
String category,
String title,
String summary,
String detail,
String status,
String occurredAt,
String followUpAt
) {
}
public record MemoryCandidateResponse(
Long id,
Integer version,
String status,
String memoryType,
String targetDomain,
MemoryDraft draft,
List<String> missingFields,
String expiresAt
) {
public MemoryCandidateResponse {
missingFields = missingFields == null ? List.of() : List.copyOf(missingFields);
}
}
public record ConfirmMemoryRequest(
Long expectedVersion,
String idempotencyKey,
MemoryDraft draft,
Boolean enableReminder
) {
}
public record ConfirmMemoryResponse(
String targetDomain,
Long targetId,
Integer version
) {
}
public record ServiceMemoryResponse(
Long id,
String projectCode,
String memoryType,
String subjectType,
String buildingName,
String unitName,
String roomNo,
String category,
String title,
String summary,
String detail,
String status,
String occurredAt,
String followUpAt,
String sourceConversationId,
String sourceRequestId,
Long createdBy,
Long confirmedBy,
Integer version,
String createdAt,
String updatedAt,
List<ServiceMemoryVersionResponse> versions
) {
public ServiceMemoryResponse {
versions = versions == null ? List.of() : List.copyOf(versions);
}
}
public record ServiceMemoryVersionResponse(
Integer version,
Map<String, Object> snapshot,
String changeType,
String changeReason,
Long operatorUserId,
String createdAt
) {
public ServiceMemoryVersionResponse {
snapshot = snapshot == null ? Map.of()
: Collections.unmodifiableMap(new LinkedHashMap<>(snapshot));
}
}
public record ServiceMemoryRecall(
List<ServiceMemoryRecallItem> items,
String answer
) {
public ServiceMemoryRecall {
items = items == null ? List.of() : List.copyOf(items);
answer = answer == null ? "" : answer;
}
public static ServiceMemoryRecall empty() {
return new ServiceMemoryRecall(List.of(), "");
}
}
public record ServiceMemoryRecallItem(
Long id,
String projectCode,
String title,
String summary,
String status,
String occurredAt,
String updatedAt
) {
}
}
@@ -0,0 +1,43 @@
package org.dromara.aihr.memory;
import cn.dev33.satoken.annotation.SaCheckLogin;
import lombok.RequiredArgsConstructor;
import org.dromara.aihr.memory.AihrMemoryDto.ServiceMemoryResponse;
import org.dromara.common.core.domain.R;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/aihr/service-memories")
public class AihrServiceMemoryController {
private final AihrMemoryService memoryService;
@SaCheckLogin
@GetMapping
public R<List<ServiceMemoryResponse>> list(
@RequestParam(value = "projectCode", required = false) String projectCode,
@RequestParam(value = "status", required = false) String status,
@RequestParam(value = "dateFrom", required = false) String dateFrom,
@RequestParam(value = "dateTo", required = false) String dateTo,
@RequestParam(value = "buildingName", required = false) String buildingName,
@RequestParam(value = "unitName", required = false) String unitName,
@RequestParam(value = "roomNo", required = false) String roomNo,
@RequestParam(value = "limit", defaultValue = "50") Integer limit
) {
return R.ok(memoryService.serviceMemories(
projectCode, status, dateFrom, dateTo, buildingName, unitName, roomNo, limit));
}
@SaCheckLogin
@GetMapping("/{id}")
public R<ServiceMemoryResponse> detail(@PathVariable Long id) {
return R.ok(memoryService.serviceMemory(id));
}
}
@@ -14,6 +14,11 @@ import org.dromara.aihr.knowledge.service.AihrKnowledgeQueryService;
import org.dromara.aihr.knowledge.service.AihrKnowledgeDataToolService;
import org.dromara.aihr.knowledge.service.AihrKnowledgeConversationService;
import org.dromara.aihr.knowledge.service.AihrKnowledgeConversationService.ConversationContext;
import org.dromara.aihr.memory.AihrMemoryService;
import org.dromara.aihr.memory.AihrMemoryDto.MemoryCandidateResponse;
import org.dromara.aihr.memory.AihrMemoryDto.MemoryDraft;
import org.dromara.aihr.memory.AihrMemoryDto.ServiceMemoryRecall;
import org.dromara.aihr.memory.AihrMemoryDto.ServiceMemoryRecallItem;
import org.dromara.aihr.service.AihrSopSeedService;
import org.dromara.common.core.exception.ServiceException;
import org.junit.jupiter.api.Tag;
@@ -29,7 +34,10 @@ import java.sql.ResultSet;
import static org.junit.jupiter.api.Assertions.assertThrows;
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.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
@@ -108,7 +116,8 @@ class AihrKnowledgeQueryServiceTest {
List.of(), List.of(), List.of(), List.of(), "test-v1", null));
var service = new AihrKnowledgeQueryService(mock(AihrKnowledgePrincipalResolver.class),
mock(AihrKnowledgeAppService.class), access, sop, mock(AihrKnowledgeQueryAuditService.class), jdbc,
mock(AihrKnowledgeDataToolService.class), mock(AihrKnowledgeConversationService.class));
mock(AihrKnowledgeDataToolService.class), mock(AihrKnowledgeConversationService.class),
mock(AihrMemoryService.class));
var result = service.queryExternal(app,
new QueryRequest("公开服务", List.of("mt_customer_service"), null, null, "miniapp", 5, null));
@@ -129,7 +138,7 @@ class AihrKnowledgeQueryServiceTest {
when(tools.execute(principal, "MY_PRACTICE_SUMMARY")).thenThrow(new ServiceException("tool failed", 500));
var service = new AihrKnowledgeQueryService(resolver, appService, mock(AihrKnowledgeAccessService.class), sop,
mock(AihrKnowledgeQueryAuditService.class), mock(JdbcTemplate.class), tools,
mock(AihrKnowledgeConversationService.class));
mock(AihrKnowledgeConversationService.class), mock(AihrMemoryService.class));
assertThrows(ServiceException.class, () -> service.queryInternal(
new QueryRequest("我的训练", List.of(), null, null, "mobile", 5, "MY_PRACTICE_SUMMARY")));
@@ -157,7 +166,7 @@ class AihrKnowledgeQueryServiceTest {
List.of(), List.of(), List.of(), List.of(), "test-v1", null));
var service = new AihrKnowledgeQueryService(resolver, appService, access, sop,
mock(AihrKnowledgeQueryAuditService.class), jdbc, mock(AihrKnowledgeDataToolService.class),
mock(AihrKnowledgeConversationService.class));
mock(AihrKnowledgeConversationService.class), mock(AihrMemoryService.class));
var result = service.queryInternalMedia(
new QueryRequest("电梯这里该怎么办", List.of(), "sop", "保安", "mobile_uni_media", 5, null),
@@ -213,7 +222,8 @@ class AihrKnowledgeQueryServiceTest {
return List.of(mapper.mapRow(rs, 0));
});
var service = new AihrKnowledgeQueryService(resolver, appService, access, sop,
mock(AihrKnowledgeQueryAuditService.class), jdbc, mock(AihrKnowledgeDataToolService.class), conversations);
mock(AihrKnowledgeQueryAuditService.class), jdbc, mock(AihrKnowledgeDataToolService.class), conversations,
mock(AihrMemoryService.class));
var result = service.queryInternal(request);
@@ -227,6 +237,198 @@ class AihrKnowledgeQueryServiceTest {
assertEquals("/api/knowledge/resources/321/content", result.resources().get(0).contentUrl());
}
@Test
void unifiedQueryReturnsMemoryCandidateAtResponseRoot() throws Exception {
var resolver = mock(AihrKnowledgePrincipalResolver.class);
var appService = mock(AihrKnowledgeAppService.class);
var access = mock(AihrKnowledgeAccessService.class);
var sop = mock(AihrSopSeedService.class);
var jdbc = mock(JdbcTemplate.class);
var memory = mock(AihrMemoryService.class);
var audit = mock(AihrKnowledgeQueryAuditService.class);
var principal = new AihrKnowledgePrincipal("000000", 7L, "app_user", "employee-7",
Set.of("employee"), Set.of("P1"), "app");
var app = new AuthenticatedApp(1L, "000000", "yc_mobile", "员工端", "SESSION", 60, null);
var draft = new MemoryDraft("P1", "3栋", "2单元", "1201", "住户需求", "养老咨询",
"住户希望了解养老服务", "", "OPEN", "2026-07-19T08:30:00", null);
var candidate = new MemoryCandidateResponse(
31L, 1, "DRAFT", "RESIDENT_NEED", "PROJECT_SERVICE", draft, List.of(),
"2026-07-20T08:30:00");
when(resolver.current()).thenReturn(principal);
when(appService.requireSessionApp("000000", "app")).thenReturn(app);
when(access.resolveInternalSpaceIds(principal, app, List.of(), "READ")).thenReturn(Set.of(9L));
when(jdbc.queryForList(any(String.class), any(Class.class), any(Object[].class))).thenReturn(List.of("sop"));
when(sop.searchAuthorized(any(), any(), any())).thenReturn(
new SearchResponse("住户需求", "sop", "当前资料中没有足够依据", "", List.of(), List.of(),
List.of(), List.of(), List.of(), List.of(), "test-v1", null));
when(memory.recall(any(), anyString(), any())).thenReturn(ServiceMemoryRecall.empty());
when(memory.detectForQuery(eq(principal), anyString(), any(), anyString())).thenReturn(candidate);
var service = new AihrKnowledgeQueryService(resolver, appService, access, sop,
audit, jdbc, mock(AihrKnowledgeDataToolService.class),
mock(AihrKnowledgeConversationService.class), memory);
var result = service.queryInternal(new QueryRequest(
"记一下,3栋2单元1201室住户希望了解养老服务", List.of(), "sop", null, "mobile", 5, null));
assertEquals(candidate, result.memoryCandidate());
assertTrue(new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(result)
.contains("\"memoryCandidate\""));
verify(memory).detectForQuery(eq(principal), eq("记一下,3栋2单元1201室住户希望了解养老服务"),
any(), eq(result.requestId()));
verify(audit).recordFinal(eq(result.requestId()), eq(principal), eq(app), anyString(), eq(List.of("sop")),
eq(List.of("MEMORY_CANDIDATE")), eq("SUCCESS"), anyLong(), eq("test-v1"));
}
@Test
void firstCandidateCanBeCreatedWithoutEnterpriseKnowledgeSpace() {
var resolver = mock(AihrKnowledgePrincipalResolver.class);
var appService = mock(AihrKnowledgeAppService.class);
var access = mock(AihrKnowledgeAccessService.class);
var sop = mock(AihrSopSeedService.class);
var memory = mock(AihrMemoryService.class);
var audit = mock(AihrKnowledgeQueryAuditService.class);
var principal = new AihrKnowledgePrincipal("000000", 7L, "app_user", "employee-7",
Set.of("employee"), Set.of("P1"), "app");
var app = new AuthenticatedApp(1L, "000000", "yc_mobile", "员工端", "SESSION", 60, null);
var draft = new MemoryDraft("P1", "3栋", "2单元", "1201", "住户需求", "养老咨询",
"住户希望了解养老服务", "", "OPEN", "2026-07-19T08:30:00", null);
var candidate = new MemoryCandidateResponse(
31L, 1, "DRAFT", "RESIDENT_NEED", "PROJECT_SERVICE", draft, List.of(),
"2026-07-20T08:30:00");
when(resolver.current()).thenReturn(principal);
when(appService.requireSessionApp("000000", "app")).thenReturn(app);
when(access.resolveInternalSpaceIds(principal, app, List.of(), "READ"))
.thenThrow(new ServiceException("no scope", 403));
when(memory.recall(any(), anyString(), any())).thenReturn(ServiceMemoryRecall.empty());
when(memory.isProjectMemoryCandidateIntent(principal,
"记一下,3栋2单元1201室住户希望了解养老服务")).thenReturn(true);
when(memory.detectForQuery(eq(principal), anyString(), any(), anyString())).thenReturn(candidate);
var service = new AihrKnowledgeQueryService(resolver, appService, access, sop, audit,
mock(JdbcTemplate.class), mock(AihrKnowledgeDataToolService.class),
mock(AihrKnowledgeConversationService.class), memory);
var result = service.queryInternal(new QueryRequest(
"记一下,3栋2单元1201室住户希望了解养老服务", List.of(), "sop", null, "mobile", 5, null));
assertEquals(candidate, result.memoryCandidate());
verify(sop, never()).searchAuthorized(any(), any(), any());
verify(audit).recordFinal(eq(result.requestId()), eq(principal), eq(app), anyString(), eq(List.of()),
eq(List.of("MEMORY_CANDIDATE")), eq("SUCCESS"), anyLong(), eq("service-memory-v1"));
}
@Test
void explicitUnauthorizedKnowledgeSpaceStillRejectsCandidateIntent() {
var resolver = mock(AihrKnowledgePrincipalResolver.class);
var appService = mock(AihrKnowledgeAppService.class);
var access = mock(AihrKnowledgeAccessService.class);
var memory = mock(AihrMemoryService.class);
var principal = new AihrKnowledgePrincipal("000000", 7L, "app_user", "employee-7",
Set.of("employee"), Set.of("P1"), "app");
var app = new AuthenticatedApp(1L, "000000", "yc_mobile", "员工端", "SESSION", 60, null);
when(resolver.current()).thenReturn(principal);
when(appService.requireSessionApp("000000", "app")).thenReturn(app);
when(access.resolveInternalSpaceIds(principal, app, List.of("private-space"), "READ"))
.thenThrow(new ServiceException("no scope", 403));
when(memory.recall(any(), anyString(), any())).thenReturn(ServiceMemoryRecall.empty());
when(memory.isProjectMemoryCandidateIntent(any(), anyString())).thenReturn(true);
var service = new AihrKnowledgeQueryService(resolver, appService, access,
mock(AihrSopSeedService.class), mock(AihrKnowledgeQueryAuditService.class), mock(JdbcTemplate.class),
mock(AihrKnowledgeDataToolService.class), mock(AihrKnowledgeConversationService.class), memory);
assertThrows(ServiceException.class, () -> service.queryInternal(new QueryRequest(
"记一下,3栋2单元1201室住户希望了解养老服务", List.of("private-space"),
"sop", null, "mobile", 5, null)));
verify(memory, never()).detectForQuery(any(), anyString(), any(), anyString());
}
@Test
void confirmedProjectMemoryIsRecalledWithCurrentStatusAndProjectCitation() {
var resolver = mock(AihrKnowledgePrincipalResolver.class);
var appService = mock(AihrKnowledgeAppService.class);
var access = mock(AihrKnowledgeAccessService.class);
var sop = mock(AihrSopSeedService.class);
var jdbc = mock(JdbcTemplate.class);
var memory = mock(AihrMemoryService.class);
var audit = mock(AihrKnowledgeQueryAuditService.class);
var principal = new AihrKnowledgePrincipal("000000", 7L, "app_user", "employee-7",
Set.of("employee"), Set.of("P1"), "app");
var app = new AuthenticatedApp(1L, "000000", "yc_mobile", "员工端", "SESSION", 60, null);
when(resolver.current()).thenReturn(principal);
when(appService.requireSessionApp("000000", "app")).thenReturn(app);
when(access.resolveInternalSpaceIds(principal, app, List.of(), "READ")).thenReturn(Set.of(9L));
when(jdbc.queryForList(any(String.class), any(Class.class), any(Object[].class))).thenReturn(List.of("sop"));
when(sop.searchAuthorized(any(), any(), any())).thenReturn(
new SearchResponse("跟进", "sop", "按照养老咨询 SOP 进行回访", "", List.of(),
List.of(new SnippetResponse("养老咨询 SOP", "记录需求并安排回访", 88L)),
List.of(), List.of(), List.of(), List.of(), "test-v1", null));
when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class))).thenAnswer(invocation -> {
RowMapper<?> mapper = invocation.getArgument(1);
ResultSet rs = mock(ResultSet.class);
when(rs.getString("space_code")).thenReturn("sop");
when(rs.getString("doc_id")).thenReturn("elder-care-sop");
when(rs.getString("title")).thenReturn("养老咨询 SOP");
when(rs.getString("content")).thenReturn("记录需求并安排回访");
when(rs.getLong("fragment_id")).thenReturn(88L);
return List.of(mapper.mapRow(rs, 0));
});
var item = new ServiceMemoryRecallItem(
501L, "P1", "3栋2单元1201跟进", "明天回访养老服务需求", "IN_PROGRESS",
"2026-07-19T08:30:00", "2026-07-19T09:00:00");
when(memory.recall(eq(principal), anyString(), any())).thenReturn(
new ServiceMemoryRecall(List.of(item), "已找到当前项目权限内的相关记录"));
var service = new AihrKnowledgeQueryService(resolver, appService, access, sop,
audit, jdbc, mock(AihrKnowledgeDataToolService.class),
mock(AihrKnowledgeConversationService.class), memory);
var result = service.queryInternal(new QueryRequest(
"3栋有哪些住户跟进事项", List.of(), "sop", null, "mobile", 5, null));
assertFalse(result.noEvidence());
var projectCitation = result.citations().stream()
.filter(citation -> "PROJECT_SERVICE".equals(citation.domain()))
.findFirst().orElseThrow();
assertEquals("IN_PROGRESS", projectCitation.status());
assertEquals("service-memory:501", projectCitation.docId());
assertTrue(result.usedSpaceCodes().contains("PROJECT_SERVICE"));
verify(audit).recordFinal(eq(result.requestId()), eq(principal), eq(app), anyString(),
eq(List.of("sop", "PROJECT_SERVICE")), eq(List.of("DOCUMENT", "PROJECT_SERVICE")),
eq("SUCCESS"), anyLong(), eq("test-v1"));
}
@Test
void projectOnlyRecallWithoutKnowledgeSpaceStillWritesFinalAudit() {
var resolver = mock(AihrKnowledgePrincipalResolver.class);
var appService = mock(AihrKnowledgeAppService.class);
var access = mock(AihrKnowledgeAccessService.class);
var memory = mock(AihrMemoryService.class);
var audit = mock(AihrKnowledgeQueryAuditService.class);
var principal = new AihrKnowledgePrincipal("000000", 7L, "app_user", "employee-7",
Set.of("employee"), Set.of("P1"), "app");
var app = new AuthenticatedApp(1L, "000000", "yc_mobile", "员工端", "SESSION", 60, null);
var item = new ServiceMemoryRecallItem(
501L, "P1", "3栋2单元1201跟进", "明天回访养老服务需求", "IN_PROGRESS",
"2026-07-19T08:30:00", "2026-07-19T09:00:00");
when(resolver.current()).thenReturn(principal);
when(appService.requireSessionApp("000000", "app")).thenReturn(app);
when(access.resolveInternalSpaceIds(principal, app, List.of(), "READ"))
.thenThrow(new ServiceException("no scope", 403));
when(memory.recall(eq(principal), anyString(), any())).thenReturn(
new ServiceMemoryRecall(List.of(item), "已找到当前项目权限内的相关记录"));
var service = new AihrKnowledgeQueryService(resolver, appService, access,
mock(AihrSopSeedService.class), audit, mock(JdbcTemplate.class),
mock(AihrKnowledgeDataToolService.class), mock(AihrKnowledgeConversationService.class), memory);
var result = service.queryInternal(new QueryRequest(
"3栋有哪些住户跟进事项", List.of(), "sop", null, "mobile", 5, null));
assertFalse(result.noEvidence());
assertEquals(List.of("PROJECT_SERVICE"), result.usedSpaceCodes());
verify(audit).recordFinal(eq(result.requestId()), eq(principal), eq(app), anyString(),
eq(List.of("PROJECT_SERVICE")), eq(List.of("PROJECT_SERVICE")), eq("SUCCESS"), anyLong(),
eq("service-memory-v1"));
}
@Test
void resourceDownloadRecomputesCurrentApplicationAndPrincipalScope() {
var resolver = mock(AihrKnowledgePrincipalResolver.class);
@@ -242,7 +444,7 @@ class AihrKnowledgeQueryServiceTest {
when(jdbc.queryForList(anyString(), eq(Long.class), any(Object[].class))).thenReturn(List.of(777L));
var service = new AihrKnowledgeQueryService(resolver, appService, access, mock(AihrSopSeedService.class),
mock(AihrKnowledgeQueryAuditService.class), jdbc, mock(AihrKnowledgeDataToolService.class),
mock(AihrKnowledgeConversationService.class));
mock(AihrKnowledgeConversationService.class), mock(AihrMemoryService.class));
assertEquals(777L, service.authorizedResourceOssId(321L));
verify(access).resolveInternalSpaceIds(principal, app, List.of(), "READ");
@@ -262,7 +464,7 @@ class AihrKnowledgeQueryServiceTest {
when(access.resolveInternalSpaceIds(principal, app, List.of(), "READ")).thenReturn(Set.of());
var service = new AihrKnowledgeQueryService(resolver, appService, access, mock(AihrSopSeedService.class),
mock(AihrKnowledgeQueryAuditService.class), jdbc, mock(AihrKnowledgeDataToolService.class),
mock(AihrKnowledgeConversationService.class));
mock(AihrKnowledgeConversationService.class), mock(AihrMemoryService.class));
assertEquals(null, service.authorizedResourceOssId(321L));
verify(jdbc, never()).queryForList(anyString(), eq(Long.class), any(Object[].class));
@@ -276,6 +478,6 @@ class AihrKnowledgeQueryServiceTest {
) {
return new AihrKnowledgeQueryService(resolver, appService, access, sop,
mock(AihrKnowledgeQueryAuditService.class), mock(JdbcTemplate.class), mock(AihrKnowledgeDataToolService.class),
mock(AihrKnowledgeConversationService.class));
mock(AihrKnowledgeConversationService.class), mock(AihrMemoryService.class));
}
}
@@ -0,0 +1,553 @@
package org.dromara.aihr.memory;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver;
import org.dromara.aihr.memory.AihrMemoryDto.ConfirmMemoryRequest;
import org.dromara.aihr.memory.AihrMemoryDto.MemoryDraft;
import org.dromara.aihr.memory.AihrMemoryDto.ServiceMemoryVersionResponse;
import org.dromara.common.core.exception.ServiceException;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.PreparedStatementCreator;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.support.KeyHolder;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.ResultSet;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@Tag("dev")
class AihrMemoryServiceTest {
private static final LocalDateTime NOW = LocalDateTime.of(2026, 7, 19, 8, 30);
@Test
void singleProjectCompleteResidentNeedBecomesDraftWithoutInventingIdentity() {
var plan = AihrMemoryService.detectPlan(principal(Set.of("P1")),
"记一下,3栋2单元1201室住户希望了解养老服务,电话13812345678", NOW);
assertNotNull(plan);
assertEquals("DRAFT", plan.status());
assertEquals("RESIDENT_NEED", plan.memoryType());
assertEquals("P1", plan.projectCode());
assertEquals("3栋", plan.draft().buildingName());
assertEquals("2单元", plan.draft().unitName());
assertEquals("1201", plan.draft().roomNo());
assertFalse(plan.draft().summary().contains("13812345678"));
assertTrue(plan.missingFields().isEmpty());
}
@Test
void emptyOrAmbiguousProjectScopeFailsClosedAsNeedsInput() {
var noProject = AihrMemoryService.detectPlan(principal(Set.of()),
"记一下,3栋2单元1201室住户希望了解养老服务", NOW);
var ambiguous = AihrMemoryService.detectPlan(principal(Set.of("P1", "P2")),
"记一下,3栋2单元1201室住户希望了解养老服务", NOW);
var explicit = AihrMemoryService.detectPlan(principal(Set.of("P1", "P2")),
"记一下,P2项目3栋2单元1201室住户希望了解养老服务", NOW);
var overlappingCode = AihrMemoryService.detectPlan(principal(Set.of("P1", "P10")),
"记一下,P10项目3栋2单元1201室住户希望了解养老服务", NOW);
assertEquals("NEEDS_INPUT", noProject.status());
assertEquals("NEEDS_INPUT", ambiguous.status());
assertTrue(noProject.missingFields().contains("projectCode"));
assertTrue(ambiguous.missingFields().contains("projectCode"));
assertEquals("DRAFT", explicit.status());
assertEquals("P2", explicit.projectCode());
assertEquals("DRAFT", overlappingCode.status());
assertEquals("P10", overlappingCode.projectCode());
}
@Test
void ordinaryQuestionDoesNotCreateCandidate() {
assertNull(AihrMemoryService.detectPlan(principal(Set.of("P1")), "住户有哪些跟进需求?", NOW));
assertNull(AihrMemoryService.detectPlan(principal(Set.of("P1")), "电梯困人应该怎么处理?", NOW));
}
@Test
void sameConversationLocationFollowupUpgradesOriginalNeedsInputCandidate() throws Exception {
ObjectMapper mapper = new ObjectMapper();
MemoryDraft incomplete = new MemoryDraft(
"P1", "", "", "", "住户需求", "住户需求:待补充位置", "住户希望了解养老服务",
"", "OPEN", "2026-07-19T08:30:00", null);
ContinuationJdbc jdbc = new ContinuationJdbc(mapper.writeValueAsString(incomplete));
AihrKnowledgePrincipalResolver resolver = mock(AihrKnowledgePrincipalResolver.class);
when(resolver.current()).thenReturn(principal(Set.of("P1")));
AihrMemoryService service = new AihrMemoryService(jdbc, mapper, resolver);
var response = service.detectForQuery(
principal(Set.of("P1")), "3栋2单元1201室", "conversation_123", "request-next");
assertNotNull(response);
assertEquals(33L, response.id());
assertEquals(2, response.version());
assertEquals("DRAFT", response.status());
assertEquals("3栋", response.draft().buildingName());
assertEquals("2单元", response.draft().unitName());
assertEquals("1201", response.draft().roomNo());
assertEquals(1, jdbc.continuationUpdates);
assertEquals(0, jdbc.candidateInserts);
}
@Test
void continuationDoesNotConsumeQuestionsOrFreshMemoryIntents() {
MemoryDraft incomplete = new MemoryDraft(
"P1", "", "", "", "住户需求", "住户需求:待补充位置", "住户希望了解养老服务",
"", "OPEN", "2026-07-19T08:30:00", null);
AihrKnowledgePrincipal principal = principal(Set.of("P1"));
assertNull(AihrMemoryService.continuePlan(
principal, "RESIDENT_NEED", "P1", incomplete, "3栋电梯怎么处理?"));
assertNull(AihrMemoryService.continuePlan(
principal, "RESIDENT_NEED", "P1", incomplete, "记一下3栋2单元1201室住户报修漏水"));
assertNotNull(AihrMemoryService.continuePlan(
principal, "RESIDENT_NEED", "P1", incomplete, "3栋2单元1201室"));
}
@Test
void confirmLocksCandidateCreatesVersionOneAndReplaysSameKeyOnlyOnce() throws Exception {
ObjectMapper mapper = new ObjectMapper();
MemoryDraft draft = draft("住户希望了解养老服务");
MemoryDraft clientDraft = copyWithProjectCode(draft, null);
ConfirmJdbc jdbc = new ConfirmJdbc(mapper.writeValueAsString(draft), 1);
AihrKnowledgePrincipalResolver resolver = mock(AihrKnowledgePrincipalResolver.class);
when(resolver.current()).thenReturn(principal(Set.of("P1")));
AihrMemoryService service = new AihrMemoryService(jdbc, mapper, resolver);
ConfirmMemoryRequest request = new ConfirmMemoryRequest(1L, "memory-confirm-001", clientDraft, false);
String recallQuery = "查一下3栋2单元1201室住户养老需求";
assertTrue(service.recall(principal(Set.of("P1")), recallQuery, 5).items().isEmpty());
var first = service.confirm(7L, request);
var replay = service.confirm(7L, request);
var recalled = service.recall(principal(Set.of("P1")), recallQuery, 5);
assertEquals(1, recalled.items().size());
assertTrue(recalled.answer().contains("[待处理]"));
assertTrue(recalled.answer().contains("2026-07-19 08:30"));
assertFalse(recalled.answer().contains("[OPEN]"));
assertTrue(service.recall(principal(Set.of("P1", "P2")), recallQuery, 5).items().isEmpty());
assertEquals(1,
service.recall(principal(Set.of("P1", "P2")), "查一下P1项目3栋2单元1201室住户养老需求", 5)
.items().size());
assertEquals(first, replay);
assertEquals(501L, first.targetId());
assertEquals(1, first.version());
assertEquals(1, jdbc.memoryInsertCount);
assertEquals(1, jdbc.versionInsertCount);
assertTrue(jdbc.selectedForUpdate);
assertEquals("000000", jdbc.lastSelectArgs[0]);
assertEquals(7L, jdbc.lastSelectArgs[1]);
ConfirmMemoryRequest changed = new ConfirmMemoryRequest(
1L, "memory-confirm-001",
copyWithProjectCode(draft("住户希望了解养老和助餐服务"), null), false);
ServiceException conflict = assertThrows(ServiceException.class, () -> service.confirm(7L, changed));
assertEquals(409, conflict.getCode());
assertEquals(1, jdbc.memoryInsertCount);
}
@Test
void ordinarySopQuestionsNeverSweepRecentProjectMemories() {
ObjectMapper mapper = new ObjectMapper();
ConfirmJdbc jdbc = new ConfirmJdbc("{}", 1);
AihrKnowledgePrincipalResolver resolver = mock(AihrKnowledgePrincipalResolver.class);
AihrMemoryService service = new AihrMemoryService(jdbc, mapper, resolver);
AihrKnowledgePrincipal principal = principal(Set.of("P1"));
assertTrue(service.recall(principal, "住户需求如何处理?", 5).items().isEmpty());
assertTrue(service.recall(principal, "业主投诉怎么处理?", 5).items().isEmpty());
assertEquals(0, jdbc.recallQueryCount);
assertTrue(service.recall(principal, "3栋2单元1201室住户有什么需求?", 5).items().isEmpty());
assertEquals(1, jdbc.recallQueryCount);
}
@Test
void versionSnapshotPreservesNullableReminderWithoutAllowingMutation() {
Map<String, Object> snapshot = new LinkedHashMap<>();
snapshot.put("status", "OPEN");
snapshot.put("followUpAt", null);
ServiceMemoryVersionResponse response = new ServiceMemoryVersionResponse(
1, snapshot, "CONFIRM", null, 7L, "2026-07-19T08:30:00");
assertTrue(response.snapshot().containsKey("followUpAt"));
assertNull(response.snapshot().get("followUpAt"));
assertThrows(UnsupportedOperationException.class,
() -> response.snapshot().put("status", "RESOLVED"));
}
@Test
void staleExpectedVersionCannotOverwriteDraft() throws Exception {
ObjectMapper mapper = new ObjectMapper();
MemoryDraft draft = draft("住户希望了解养老服务");
ConfirmJdbc jdbc = new ConfirmJdbc(mapper.writeValueAsString(draft), 2);
AihrKnowledgePrincipalResolver resolver = mock(AihrKnowledgePrincipalResolver.class);
when(resolver.current()).thenReturn(principal(Set.of("P1")));
AihrMemoryService service = new AihrMemoryService(jdbc, mapper, resolver);
ServiceException error = assertThrows(ServiceException.class, () -> service.confirm(7L,
new ConfirmMemoryRequest(1L, "memory-confirm-002", draft, false)));
assertEquals(409, error.getCode());
assertEquals(0, jdbc.memoryInsertCount);
}
@Test
void expiredConfirmationRejectsWithoutPerformingARolledBackStatusUpdate() throws Exception {
ObjectMapper mapper = new ObjectMapper();
MemoryDraft draft = draft("住户希望了解养老服务");
ConfirmJdbc jdbc = new ConfirmJdbc(
mapper.writeValueAsString(draft), 1, LocalDateTime.now().minusMinutes(1));
AihrKnowledgePrincipalResolver resolver = mock(AihrKnowledgePrincipalResolver.class);
when(resolver.current()).thenReturn(principal(Set.of("P1")));
AihrMemoryService service = new AihrMemoryService(jdbc, mapper, resolver);
ServiceException error = assertThrows(ServiceException.class, () -> service.confirm(7L,
new ConfirmMemoryRequest(1L, "memory-confirm-expired", draft, false)));
assertEquals(409, error.getCode());
assertEquals("DRAFT", jdbc.status);
assertEquals(0, jdbc.expiredUpdateCount);
assertEquals(0, jdbc.memoryInsertCount);
}
@Test
void confirmationRejectsSensitiveValueDisguisedAsRoomNumber() throws Exception {
ObjectMapper mapper = new ObjectMapper();
MemoryDraft persistedDraft = draft("住户希望了解养老服务");
MemoryDraft unsafeDraft = new MemoryDraft(
"P1", "3栋", "2单元", "13812345678", persistedDraft.category(), persistedDraft.title(),
persistedDraft.summary(), persistedDraft.detail(), persistedDraft.status(),
persistedDraft.occurredAt(), persistedDraft.followUpAt());
ConfirmJdbc jdbc = new ConfirmJdbc(mapper.writeValueAsString(persistedDraft), 1);
AihrKnowledgePrincipalResolver resolver = mock(AihrKnowledgePrincipalResolver.class);
when(resolver.current()).thenReturn(principal(Set.of("P1")));
AihrMemoryService service = new AihrMemoryService(jdbc, mapper, resolver);
ServiceException error = assertThrows(ServiceException.class, () -> service.confirm(7L,
new ConfirmMemoryRequest(1L, "memory-confirm-unsafe", unsafeDraft, false)));
assertEquals(400, error.getCode());
assertEquals(0, jdbc.memoryInsertCount);
}
@Test
void confirmationRejectsResidentNameAndHealthInformationFromSearchableText() throws Exception {
ObjectMapper mapper = new ObjectMapper();
MemoryDraft persistedDraft = draft("住户希望了解养老服务");
ConfirmJdbc jdbc = new ConfirmJdbc(mapper.writeValueAsString(persistedDraft), 1);
AihrKnowledgePrincipalResolver resolver = mock(AihrKnowledgePrincipalResolver.class);
when(resolver.current()).thenReturn(principal(Set.of("P1")));
AihrMemoryService service = new AihrMemoryService(jdbc, mapper, resolver);
List<String> unsafeSummaries = List.of(
"住户姓名:张三,希望了解养老服务",
"住户患有高血压,希望了解养老服务"
);
for (int i = 0; i < unsafeSummaries.size(); i++) {
MemoryDraft unsafeDraft = new MemoryDraft(
"P1", persistedDraft.buildingName(), persistedDraft.unitName(), persistedDraft.roomNo(),
persistedDraft.category(), persistedDraft.title(), unsafeSummaries.get(i), persistedDraft.detail(),
persistedDraft.status(), persistedDraft.occurredAt(), persistedDraft.followUpAt());
int index = i;
ServiceException error = assertThrows(ServiceException.class, () -> service.confirm(7L,
new ConfirmMemoryRequest(1L, "memory-confirm-private-" + index, unsafeDraft, false)));
assertEquals(400, error.getCode());
}
assertEquals(0, jdbc.memoryInsertCount);
}
@Test
void normalServiceSummariesAreNotMistakenForResidentNames() throws Exception {
List<String> normalSummaries = List.of(
"业主家里漏水", "住户养老咨询", "业主报修漏水", "业主马桶需要维修");
for (int i = 0; i < normalSummaries.size(); i++) {
ObjectMapper mapper = new ObjectMapper();
MemoryDraft draft = draft(normalSummaries.get(i));
ConfirmJdbc jdbc = new ConfirmJdbc(mapper.writeValueAsString(draft), 1);
AihrKnowledgePrincipalResolver resolver = mock(AihrKnowledgePrincipalResolver.class);
when(resolver.current()).thenReturn(principal(Set.of("P1")));
AihrMemoryService service = new AihrMemoryService(jdbc, mapper, resolver);
var response = service.confirm(7L,
new ConfirmMemoryRequest(1L, "memory-confirm-normal-" + i, draft, false));
assertEquals(501L, response.targetId());
assertEquals(1, jdbc.memoryInsertCount);
}
}
@Test
void sharedSearchDraftNeverCopiesClientFreeText() {
MemoryDraft raw = new MemoryDraft(
"P1", "3栋", "2单元", "1201", "养老咨询", "张三高血压跟进",
"张三患有高血压,希望了解养老服务", "联系电话13812345678",
"OPEN", "2026-07-19T08:30:00", null);
MemoryDraft searchable = AihrMemoryService.projectSearchDraft("RESIDENT_NEED", raw);
assertEquals("养老服务", searchable.category());
assertEquals("3栋2单元1201养老服务", searchable.title());
assertEquals("住户提出养老服务需求", searchable.summary());
assertEquals("", searchable.detail());
assertFalse((searchable.title() + searchable.summary()).contains("张三"));
assertFalse((searchable.title() + searchable.summary()).contains("高血压"));
}
@Test
void dismissReturnsNoRetainedDraft() throws Exception {
ObjectMapper mapper = new ObjectMapper();
MemoryDraft draft = draft("住户希望了解养老服务");
ConfirmJdbc jdbc = new ConfirmJdbc(mapper.writeValueAsString(draft), 1);
AihrKnowledgePrincipalResolver resolver = mock(AihrKnowledgePrincipalResolver.class);
when(resolver.current()).thenReturn(principal(Set.of("P1")));
AihrMemoryService service = new AihrMemoryService(jdbc, mapper, resolver);
var response = service.dismiss(7L);
assertEquals("DISMISSED", response.status());
assertNull(response.draft());
assertTrue(response.missingFields().isEmpty());
assertTrue(jdbc.dismissClearedDraft);
}
@Test
void schemaAndControllersKeepSecurityAndLifecycleContracts() throws Exception {
String serviceSource = Files.readString(source("AihrMemoryService.java"));
String schema = Files.readString(repoPath("backend/script/sql/aihr_service_memory_mysql8.sql"));
String migration = Files.readString(repoPath(
"backend/script/sql/update/aihr_20260719_service_memory_mysql8.sql"));
assertTrue(serviceSource.contains("limit 1 for update"));
assertTrue(serviceSource.contains("plusHours(24)"));
assertTrue(serviceSource.contains("@Scheduled"));
assertTrue(serviceSource.contains("draft_json = null"));
assertTrue(serviceSource.contains("where tenant_id = ? and owner_user_id = ? and id = ?"));
assertTrue(serviceSource.contains("project_code in (%s)"));
assertFalse(serviceSource.contains("project_id"));
assertTrue(schema.contains("`project_code` varchar(100) NOT NULL"));
assertTrue(schema.contains("source_conversation_id"));
assertTrue(schema.contains("source_request_id"));
assertTrue(schema.contains("uk_aihr_memory_candidate_confirm_key"));
assertTrue(schema.contains("idx_aihr_memory_candidate_expiry"));
assertTrue(schema.contains("idx_aihr_memory_candidate_cleanup"));
assertTrue(migration.contains("table_name = 'aihr_knowledge_info'"));
assertTrue(migration.contains("index_name = 'idx_aihr_memory_candidate_expiry'"));
assertTrue(migration.contains("index_name = 'idx_aihr_memory_candidate_cleanup'"));
assertTrue(migration.contains("CONVERT TO CHARACTER SET utf8mb4 COLLATE"));
assertTrue(AihrMemoryController.class.getMethod("confirm", Long.class, ConfirmMemoryRequest.class)
.isAnnotationPresent(cn.dev33.satoken.annotation.SaCheckLogin.class));
assertTrue(AihrServiceMemoryController.class.getMethod("detail", Long.class)
.isAnnotationPresent(cn.dev33.satoken.annotation.SaCheckLogin.class));
}
private static MemoryDraft draft(String summary) {
return new MemoryDraft(
"P1", "3栋", "2单元", "1201", "养老咨询", "3栋2单元1201住户需求",
summary, "", "OPEN", "2026-07-19T08:30:00", null);
}
private static MemoryDraft copyWithProjectCode(MemoryDraft draft, String projectCode) {
return new MemoryDraft(
projectCode, draft.buildingName(), draft.unitName(), draft.roomNo(), draft.category(), draft.title(),
draft.summary(), draft.detail(), draft.status(), draft.occurredAt(), draft.followUpAt());
}
private static AihrKnowledgePrincipal principal(Set<String> projectCodes) {
return new AihrKnowledgePrincipal(
"000000", 7L, "app_user", "employee-7", Set.of("employee"), projectCodes, "app");
}
private static Path source(String fileName) {
Path direct = Path.of("src/main/java/org/dromara/aihr/memory", fileName);
return Files.exists(direct) ? direct
: repoPath("backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/memory/" + fileName);
}
private static Path repoPath(String value) {
Path cursor = Path.of("").toAbsolutePath();
for (int depth = 0; depth < 8 && cursor != null; depth++, cursor = cursor.getParent()) {
Path candidate = cursor.resolve(value);
if (Files.exists(candidate)) {
return candidate;
}
}
throw new IllegalStateException("repository file not found: " + value);
}
private static final class ConfirmJdbc extends JdbcTemplate {
private final String draftJson;
private int version;
private final LocalDateTime expiresAt;
private String status = "DRAFT";
private String confirmKey;
private String confirmHash;
private Long targetId;
private Integer targetVersion;
private boolean selectedForUpdate;
private Object[] lastSelectArgs;
private int memoryInsertCount;
private int versionInsertCount;
private int expiredUpdateCount;
private boolean dismissClearedDraft;
private int recallQueryCount;
private ConfirmJdbc(String draftJson, int version) {
this(draftJson, version, LocalDateTime.now().plusHours(12));
}
private ConfirmJdbc(String draftJson, int version, LocalDateTime expiresAt) {
this.draftJson = draftJson;
this.version = version;
this.expiresAt = expiresAt;
}
@Override
public <T> List<T> query(String sql, RowMapper<T> rowMapper, Object... args) {
if (sql.contains("from aihr_service_memory")) {
recallQueryCount++;
if (memoryInsertCount == 0) {
return List.of();
}
try {
ResultSet rs = mock(ResultSet.class);
when(rs.getLong("id")).thenReturn(501L);
when(rs.getString("project_code")).thenReturn("P1");
when(rs.getString("title")).thenReturn("3栋2单元1201住户需求");
when(rs.getString("summary")).thenReturn("住户希望了解养老服务");
when(rs.getString("status")).thenReturn("OPEN");
when(rs.getTimestamp("occurred_at")).thenReturn(Timestamp.valueOf(NOW));
when(rs.getTimestamp("update_time")).thenReturn(Timestamp.valueOf(NOW));
return List.of(rowMapper.mapRow(rs, 0));
} catch (Exception error) {
throw new IllegalStateException(error);
}
}
if (!sql.contains("from aihr_memory_candidate")) {
return List.of();
}
try {
selectedForUpdate = sql.contains("for update");
lastSelectArgs = args;
ResultSet rs = mock(ResultSet.class);
when(rs.getLong("id")).thenReturn(7L);
when(rs.getString("project_code")).thenReturn("P1");
when(rs.getString("memory_type")).thenReturn("RESIDENT_NEED");
when(rs.getString("target_domain")).thenReturn("PROJECT_SERVICE");
when(rs.getString("status")).thenReturn(status);
when(rs.getString("draft_json")).thenReturn(draftJson);
when(rs.getString("missing_fields_json")).thenReturn("[]");
when(rs.getObject("target_id")).thenReturn(targetId);
when(rs.getObject("target_version")).thenReturn(targetVersion);
when(rs.getInt("version")).thenReturn(version);
when(rs.getString("confirm_idempotency_key")).thenReturn(confirmKey);
when(rs.getString("confirm_request_hash")).thenReturn(confirmHash);
when(rs.getTimestamp("expires_at")).thenReturn(Timestamp.valueOf(expiresAt));
return List.of(rowMapper.mapRow(rs, 0));
} catch (Exception error) {
throw new IllegalStateException(error);
}
}
@Override
public int update(PreparedStatementCreator psc, KeyHolder generatedKeyHolder) {
memoryInsertCount++;
generatedKeyHolder.getKeyList().add(Map.of("GENERATED_KEY", 501L));
return 1;
}
@Override
public int update(String sql, Object... args) {
if (sql.contains("insert into aihr_service_memory_version")) {
versionInsertCount++;
return 1;
}
if (sql.contains("set status = 'EXPIRED'")) {
status = "EXPIRED";
version++;
expiredUpdateCount++;
return 1;
}
if (sql.contains("set status = 'DISMISSED'")) {
status = "DISMISSED";
version++;
dismissClearedDraft = sql.contains("draft_json = null");
return 1;
}
if (sql.contains("set status = 'CONFIRMED'")) {
status = "CONFIRMED";
targetId = ((Number) args[0]).longValue();
targetVersion = 1;
confirmKey = String.valueOf(args[1]);
confirmHash = String.valueOf(args[2]);
version++;
return 1;
}
return 1;
}
}
private static final class ContinuationJdbc extends JdbcTemplate {
private final String draftJson;
private int continuationUpdates;
private int candidateInserts;
private ContinuationJdbc(String draftJson) {
this.draftJson = draftJson;
}
@Override
public <T> List<T> query(String sql, RowMapper<T> rowMapper, Object... args) {
if (!sql.contains("source_conversation_id = ?")) {
return List.of();
}
try {
ResultSet rs = mock(ResultSet.class);
when(rs.getLong("id")).thenReturn(33L);
when(rs.getString("project_code")).thenReturn("P1");
when(rs.getString("memory_type")).thenReturn("RESIDENT_NEED");
when(rs.getString("draft_json")).thenReturn(draftJson);
when(rs.getInt("version")).thenReturn(1);
when(rs.getTimestamp("expires_at"))
.thenReturn(Timestamp.valueOf(LocalDateTime.now().plusHours(12)));
return List.of(rowMapper.mapRow(rs, 0));
} catch (Exception error) {
throw new IllegalStateException(error);
}
}
@Override
public int update(String sql, Object... args) {
if (sql.contains("update aihr_memory_candidate")) {
continuationUpdates++;
}
return 1;
}
@Override
public int update(PreparedStatementCreator psc, KeyHolder generatedKeyHolder) {
candidateInserts++;
return 1;
}
}
}
@@ -0,0 +1,100 @@
-- 个人助理项目服务记忆最小切片:候选确认、项目事实与版本快照。
CREATE TABLE IF NOT EXISTS `aihr_memory_candidate` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '候选ID',
`tenant_id` varchar(20) NOT NULL COMMENT '租户编号',
`owner_user_id` bigint NOT NULL COMMENT '候选所属用户',
`source_conversation_id` varchar(64) NOT NULL DEFAULT '' COMMENT '来源会话ID,无状态问答为空串',
`source_request_id` varchar(64) NOT NULL COMMENT '来源问答请求ID',
`candidate_hash` char(64) NOT NULL COMMENT '规范化候选SHA-256',
`project_code` varchar(100) DEFAULT NULL COMMENT '经当前身份解析的真实项目编码',
`memory_type` varchar(30) NOT NULL COMMENT 'RESIDENT_NEED/FOLLOW_UP',
`target_domain` varchar(30) NOT NULL DEFAULT 'PROJECT_SERVICE' COMMENT '目标记忆域',
`status` varchar(20) NOT NULL COMMENT 'NEEDS_INPUT/DRAFT/CONFIRMED/DISMISSED/EXPIRED',
`draft_json` json DEFAULT NULL COMMENT '可编辑的结构化草稿,不保存模型原始输出',
`missing_fields_json` json DEFAULT NULL COMMENT '待补充字段',
`confidence` decimal(5,4) NOT NULL DEFAULT 0 COMMENT '只用于展示建议',
`target_id` bigint DEFAULT NULL COMMENT '确认后项目记忆ID',
`target_version` int DEFAULT NULL COMMENT '确认结果版本',
`version` int NOT NULL DEFAULT 1 COMMENT '候选乐观锁版本',
`confirm_idempotency_key` varchar(100) DEFAULT NULL COMMENT '确认幂等键',
`confirm_request_hash` char(64) DEFAULT NULL COMMENT '确认请求规范化哈希',
`expires_at` datetime NOT NULL COMMENT '草稿过期时间',
`confirmed_at` datetime DEFAULT NULL COMMENT '确认时间',
`dismissed_at` datetime DEFAULT NULL COMMENT '忽略时间',
`create_time` datetime NOT NULL COMMENT '创建时间',
`update_time` datetime NOT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_aihr_memory_candidate_source` (`tenant_id`, `owner_user_id`, `source_request_id`, `candidate_hash`),
UNIQUE KEY `uk_aihr_memory_candidate_confirm_key` (`tenant_id`, `owner_user_id`, `confirm_idempotency_key`),
KEY `idx_aihr_memory_candidate_owner` (`tenant_id`, `owner_user_id`, `status`, `expires_at`),
KEY `idx_aihr_memory_candidate_expiry` (`status`, `expires_at`),
KEY `idx_aihr_memory_candidate_cleanup` (`status`, `update_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='个人助理长期记忆候选';
CREATE TABLE IF NOT EXISTS `aihr_service_memory` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '项目服务记忆ID',
`tenant_id` varchar(20) NOT NULL COMMENT '租户编号',
`project_code` varchar(100) NOT NULL COMMENT '真实项目编码',
`memory_type` varchar(30) NOT NULL COMMENT 'RESIDENT_NEED/FOLLOW_UP',
`subject_type` varchar(20) NOT NULL DEFAULT 'LOCATION' COMMENT '当前无权威住户主数据,仅定位到房屋',
`building_name` varchar(100) NOT NULL COMMENT '楼栋',
`unit_name` varchar(100) NOT NULL COMMENT '单元',
`room_no` varchar(100) NOT NULL COMMENT '房号',
`category` varchar(50) NOT NULL COMMENT '需求分类',
`title` varchar(500) NOT NULL COMMENT '标题',
`summary` varchar(1000) NOT NULL COMMENT '最小必要摘要',
`detail` text COMMENT '必要说明',
`status` varchar(20) NOT NULL DEFAULT 'OPEN' COMMENT 'OPEN/IN_PROGRESS/RESOLVED/VOID',
`occurred_at` datetime NOT NULL COMMENT '发生或获知时间',
`follow_up_at` datetime DEFAULT NULL COMMENT '用户单独确认的跟进时间',
`source_conversation_id` varchar(64) NOT NULL DEFAULT '' COMMENT '来源会话ID',
`source_request_id` varchar(64) NOT NULL COMMENT '来源问答请求ID',
`created_by` bigint NOT NULL COMMENT '记录人',
`confirmed_by` bigint NOT NULL COMMENT '确认人',
`version` int NOT NULL DEFAULT 1 COMMENT '当前版本',
`create_time` datetime NOT NULL COMMENT '创建时间',
`update_time` datetime NOT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_aihr_service_memory_scope` (`tenant_id`, `project_code`, `status`, `follow_up_at`),
KEY `idx_aihr_service_memory_location` (`tenant_id`, `project_code`, `building_name`, `unit_name`, `room_no`),
KEY `idx_aihr_service_memory_source` (`tenant_id`, `source_request_id`),
FULLTEXT KEY `ft_aihr_service_memory_text` (`title`, `summary`, `detail`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='项目共享服务记忆';
CREATE TABLE IF NOT EXISTS `aihr_service_memory_version` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '版本ID',
`tenant_id` varchar(20) NOT NULL COMMENT '租户编号',
`project_code` varchar(100) NOT NULL COMMENT '项目编码快照',
`memory_id` bigint NOT NULL COMMENT '项目服务记忆ID',
`version` int NOT NULL COMMENT '版本号',
`snapshot_json` json NOT NULL COMMENT '业务字段快照',
`change_type` varchar(30) NOT NULL COMMENT 'CONFIRM/UPDATE/STATUS/VOID',
`change_reason` varchar(500) DEFAULT NULL COMMENT '变更原因',
`operator_user_id` bigint NOT NULL COMMENT '操作人',
`create_time` datetime NOT NULL COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_aihr_service_memory_version` (`memory_id`, `version`),
KEY `idx_aihr_service_memory_version_scope` (`tenant_id`, `project_code`, `memory_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='项目服务记忆版本快照';
-- 生产旧库与 MySQL 8 新库的排序规则可能不同;以现有租户主表为事实源动态对齐。
SET @aihr_memory_collation := (
SELECT collation_name
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'aihr_knowledge_info'
AND column_name = 'tenant_id'
LIMIT 1
);
SET @aihr_memory_collation := COALESCE(@aihr_memory_collation, @@collation_database);
SET @aihr_memory_ddl := CONCAT('ALTER TABLE `aihr_memory_candidate` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_memory_collation);
PREPARE aihr_memory_stmt FROM @aihr_memory_ddl; EXECUTE aihr_memory_stmt; DEALLOCATE PREPARE aihr_memory_stmt;
SET @aihr_memory_ddl := CONCAT('ALTER TABLE `aihr_service_memory` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_memory_collation);
PREPARE aihr_memory_stmt FROM @aihr_memory_ddl; EXECUTE aihr_memory_stmt; DEALLOCATE PREPARE aihr_memory_stmt;
SET @aihr_memory_ddl := CONCAT('ALTER TABLE `aihr_service_memory_version` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_memory_collation);
PREPARE aihr_memory_stmt FROM @aihr_memory_ddl; EXECUTE aihr_memory_stmt; DEALLOCATE PREPARE aihr_memory_stmt;
SET @aihr_memory_ddl := NULL;
SET @aihr_memory_collation := NULL;
@@ -0,0 +1,128 @@
-- 2026-07-19 项目服务记忆增量迁移。与初始化脚本保持同构,可重复执行。
CREATE TABLE IF NOT EXISTS `aihr_memory_candidate` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '候选ID',
`tenant_id` varchar(20) NOT NULL COMMENT '租户编号',
`owner_user_id` bigint NOT NULL COMMENT '候选所属用户',
`source_conversation_id` varchar(64) NOT NULL DEFAULT '' COMMENT '来源会话ID,无状态问答为空串',
`source_request_id` varchar(64) NOT NULL COMMENT '来源问答请求ID',
`candidate_hash` char(64) NOT NULL COMMENT '规范化候选SHA-256',
`project_code` varchar(100) DEFAULT NULL COMMENT '经当前身份解析的真实项目编码',
`memory_type` varchar(30) NOT NULL COMMENT 'RESIDENT_NEED/FOLLOW_UP',
`target_domain` varchar(30) NOT NULL DEFAULT 'PROJECT_SERVICE' COMMENT '目标记忆域',
`status` varchar(20) NOT NULL COMMENT 'NEEDS_INPUT/DRAFT/CONFIRMED/DISMISSED/EXPIRED',
`draft_json` json DEFAULT NULL COMMENT '可编辑的结构化草稿,不保存模型原始输出',
`missing_fields_json` json DEFAULT NULL COMMENT '待补充字段',
`confidence` decimal(5,4) NOT NULL DEFAULT 0 COMMENT '只用于展示建议',
`target_id` bigint DEFAULT NULL COMMENT '确认后项目记忆ID',
`target_version` int DEFAULT NULL COMMENT '确认结果版本',
`version` int NOT NULL DEFAULT 1 COMMENT '候选乐观锁版本',
`confirm_idempotency_key` varchar(100) DEFAULT NULL COMMENT '确认幂等键',
`confirm_request_hash` char(64) DEFAULT NULL COMMENT '确认请求规范化哈希',
`expires_at` datetime NOT NULL COMMENT '草稿过期时间',
`confirmed_at` datetime DEFAULT NULL COMMENT '确认时间',
`dismissed_at` datetime DEFAULT NULL COMMENT '忽略时间',
`create_time` datetime NOT NULL COMMENT '创建时间',
`update_time` datetime NOT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_aihr_memory_candidate_source` (`tenant_id`, `owner_user_id`, `source_request_id`, `candidate_hash`),
UNIQUE KEY `uk_aihr_memory_candidate_confirm_key` (`tenant_id`, `owner_user_id`, `confirm_idempotency_key`),
KEY `idx_aihr_memory_candidate_owner` (`tenant_id`, `owner_user_id`, `status`, `expires_at`),
KEY `idx_aihr_memory_candidate_expiry` (`status`, `expires_at`),
KEY `idx_aihr_memory_candidate_cleanup` (`status`, `update_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='个人助理长期记忆候选';
CREATE TABLE IF NOT EXISTS `aihr_service_memory` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '项目服务记忆ID',
`tenant_id` varchar(20) NOT NULL COMMENT '租户编号',
`project_code` varchar(100) NOT NULL COMMENT '真实项目编码',
`memory_type` varchar(30) NOT NULL COMMENT 'RESIDENT_NEED/FOLLOW_UP',
`subject_type` varchar(20) NOT NULL DEFAULT 'LOCATION' COMMENT '当前无权威住户主数据,仅定位到房屋',
`building_name` varchar(100) NOT NULL COMMENT '楼栋',
`unit_name` varchar(100) NOT NULL COMMENT '单元',
`room_no` varchar(100) NOT NULL COMMENT '房号',
`category` varchar(50) NOT NULL COMMENT '需求分类',
`title` varchar(500) NOT NULL COMMENT '标题',
`summary` varchar(1000) NOT NULL COMMENT '最小必要摘要',
`detail` text COMMENT '必要说明',
`status` varchar(20) NOT NULL DEFAULT 'OPEN' COMMENT 'OPEN/IN_PROGRESS/RESOLVED/VOID',
`occurred_at` datetime NOT NULL COMMENT '发生或获知时间',
`follow_up_at` datetime DEFAULT NULL COMMENT '用户单独确认的跟进时间',
`source_conversation_id` varchar(64) NOT NULL DEFAULT '' COMMENT '来源会话ID',
`source_request_id` varchar(64) NOT NULL COMMENT '来源问答请求ID',
`created_by` bigint NOT NULL COMMENT '记录人',
`confirmed_by` bigint NOT NULL COMMENT '确认人',
`version` int NOT NULL DEFAULT 1 COMMENT '当前版本',
`create_time` datetime NOT NULL COMMENT '创建时间',
`update_time` datetime NOT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_aihr_service_memory_scope` (`tenant_id`, `project_code`, `status`, `follow_up_at`),
KEY `idx_aihr_service_memory_location` (`tenant_id`, `project_code`, `building_name`, `unit_name`, `room_no`),
KEY `idx_aihr_service_memory_source` (`tenant_id`, `source_request_id`),
FULLTEXT KEY `ft_aihr_service_memory_text` (`title`, `summary`, `detail`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='项目共享服务记忆';
CREATE TABLE IF NOT EXISTS `aihr_service_memory_version` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '版本ID',
`tenant_id` varchar(20) NOT NULL COMMENT '租户编号',
`project_code` varchar(100) NOT NULL COMMENT '项目编码快照',
`memory_id` bigint NOT NULL COMMENT '项目服务记忆ID',
`version` int NOT NULL COMMENT '版本号',
`snapshot_json` json NOT NULL COMMENT '业务字段快照',
`change_type` varchar(30) NOT NULL COMMENT 'CONFIRM/UPDATE/STATUS/VOID',
`change_reason` varchar(500) DEFAULT NULL COMMENT '变更原因',
`operator_user_id` bigint NOT NULL COMMENT '操作人',
`create_time` datetime NOT NULL COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_aihr_service_memory_version` (`memory_id`, `version`),
KEY `idx_aihr_service_memory_version_scope` (`tenant_id`, `project_code`, `memory_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='项目服务记忆版本快照';
SET @aihr_memory_ddl := IF(
EXISTS(
SELECT 1 FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'aihr_memory_candidate'
AND index_name = 'idx_aihr_memory_candidate_expiry'
),
'SELECT 1',
'ALTER TABLE `aihr_memory_candidate` ADD KEY `idx_aihr_memory_candidate_expiry` (`status`, `expires_at`)'
);
PREPARE aihr_memory_stmt FROM @aihr_memory_ddl; EXECUTE aihr_memory_stmt; DEALLOCATE PREPARE aihr_memory_stmt;
SET @aihr_memory_ddl := IF(
EXISTS(
SELECT 1 FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'aihr_memory_candidate'
AND index_name = 'idx_aihr_memory_candidate_cleanup'
),
'SELECT 1',
'ALTER TABLE `aihr_memory_candidate` ADD KEY `idx_aihr_memory_candidate_cleanup` (`status`, `update_time`)'
);
PREPARE aihr_memory_stmt FROM @aihr_memory_ddl; EXECUTE aihr_memory_stmt; DEALLOCATE PREPARE aihr_memory_stmt;
-- 清理早于本迁移产生的终态候选正文;幂等键与来源 ID 保留用于防重。
UPDATE `aihr_memory_candidate`
SET `draft_json` = NULL, `missing_fields_json` = NULL
WHERE `status` IN ('CONFIRMED', 'DISMISSED', 'EXPIRED')
AND `draft_json` IS NOT NULL
AND `update_time` <= DATE_SUB(NOW(), INTERVAL 24 HOUR);
SET @aihr_memory_collation := (
SELECT collation_name
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'aihr_knowledge_info'
AND column_name = 'tenant_id'
LIMIT 1
);
SET @aihr_memory_collation := COALESCE(@aihr_memory_collation, @@collation_database);
SET @aihr_memory_ddl := CONCAT('ALTER TABLE `aihr_memory_candidate` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_memory_collation);
PREPARE aihr_memory_stmt FROM @aihr_memory_ddl; EXECUTE aihr_memory_stmt; DEALLOCATE PREPARE aihr_memory_stmt;
SET @aihr_memory_ddl := CONCAT('ALTER TABLE `aihr_service_memory` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_memory_collation);
PREPARE aihr_memory_stmt FROM @aihr_memory_ddl; EXECUTE aihr_memory_stmt; DEALLOCATE PREPARE aihr_memory_stmt;
SET @aihr_memory_ddl := CONCAT('ALTER TABLE `aihr_service_memory_version` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_memory_collation);
PREPARE aihr_memory_stmt FROM @aihr_memory_ddl; EXECUTE aihr_memory_stmt; DEALLOCATE PREPARE aihr_memory_stmt;
SET @aihr_memory_ddl := NULL;
SET @aihr_memory_collation := NULL;
+53
View File
@@ -14,6 +14,7 @@
| 案例沉淀 `/knowledge/cases` | `GET /api/knowledge/case/capabilities`、`POST /api/knowledge/case/upload`、`/organize`、`/curate`、`GET /records`、`GET /records/{caseId}`、`POST /records/{caseId}/review`、`GET /records/{caseId}/media` | `/capabilities` 返回服务端判定的案例提交/查看能力,移动端不再向普通员工展示无权提交的素材表单;`/upload` 改为 multipart 真实语音上传并走 ASR,服务端只接受 MP3/WAV/M4A/WebM/OGG/AAC/FLAC,成功后原始音频写入 `sys_oss`,案例记录只保存 `mediaOssId` 供受保护媒体接口读取,不向客户端回传原始 `mediaUrl`;`/organize` 用真实转写调 chat 模型整理案例,未配置模型时按真实 transcript 本地结构化,并从背景外的真实摘要项提取学习点;APP 用户的项目范围从 `aihr_org_snapshot` 登录身份解析,上传、整理、入库、列表和详情均按项目范围校验,未完成正式组织映射时安全拒绝,不接受前端伪造项目范围;员工列表/详情只返回 `已入库` 案例,管理端系统用户保留全局运营视图;移动端和管理端案例详情通过受保护媒体接口回放原始音频,主管/项目负责人可提交脱敏点评;预渲染视频样片仍待正式媒体资产接入 |
| 案例媒体安全 | `GET /api/knowledge/case/records/{caseId}/media` | 案例详情只返回 `mediaOssId`,不返回原始 `sys_oss.url`;媒体下载会重复执行登录、后台角色或 APP 项目范围校验,再由服务端流式读取 OSS。管理端与 `mobile-uni` 通过鉴权 blob/temp 文件播放,关闭详情页时释放本地对象 URL |
| 问师傅与 SOP 知识库 `/pages/user/sop/index`、`/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` | 文字/现场附件查询走统一知识空间授权和 MySQL Fulltext + Qdrant 混合召回;内部登录端可选传 `conversationId/contextVersion`,服务端保留 30 分钟不活跃过期、最近 6 轮脱敏截断上下文,用现有 chat 模型或保守规则输出 `QA/FILE/VIDEO/DATA_TOOL` 和 `rewrittenQuery`,旧版本冲突返回 `409`。原文件/视频仅从当前命中且仍有授权的附件返回,内容接口每次重新计算租户、应用和主体权限;无资源时明确返回空列表,不生成假链接。旧客户端和外部 API 保持单轮无状态。搜索继续返回 `reviewId/promptVersion`,员工反馈保存评审批次;详细边界见 [专项设计](20260718/问师傅多轮会话与原始资料交付设计.md)。员工学习页按当前 APP 身份读取正式岗前/入职任务,资格证据无正式数据时明确返回 `NOT_CONFIGURED` |
| 工作助手确认式项目记忆 `/pages/user/sop/index`、`/pages/user/assistant/memories` | 既有 `POST /api/knowledge/query` 可选返回 `memoryCandidate`;`GET /api/aihr/personal-assistant/memory-candidates?status=DRAFT`、`POST /api/aihr/personal-assistant/memory-candidates/{id}/confirm`、`POST .../{id}/dismiss`、`GET /api/aihr/service-memories`、`GET /api/aihr/service-memories/{id}` | 2026-07-19 最小切片只识别 `RESIDENT_NEED/FOLLOW_UP` 并保存到 `PROJECT_SERVICE`。用户文字或 ASR 转写进入同一候选检测;候选草稿不是长期记忆,只有当前员工显式确认后才按服务端解析出的 `tenant_id + project_code` 写入项目服务记录和版本表。多项目无法唯一判断时返回 `NEEDS_INPUT`,不能显示确认按钮;确认携带 `expectedVersion + idempotencyKey`,同键同内容重放返回原结果,同键不同内容或旧版本返回冲突。提醒默认关闭;首批不创建个人知识库、Qdrant collection,也不把个人想法写入项目域 |
| 资料处理 `/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 内安全解压后把支持的子文件继续入同一批次队列,页面按批次轮询进度、失败可单文件重试;不提供浏览器目录选择或服务端目录导入入口 |
| 组织人员同步 | `POST /api/aihr/org/sync` | 从开放组织同步系统的 `/api/open/v1/sync/snapshot` 拉取 `company/department/employee` 快照,分页参数使用 `limit`;员工手机号只落 `person_phone` 用于移动端身份映射,不在组织列表响应暴露;岗位识别 `position/job_title/post/job_name/role/title` 等字段。`dryRun` 必须显式传入 `true`(预检)或 `false`(写入),省略或传 `null` 直接拒绝,避免空请求意外写库;默认 `replaceExisting=true`,写入前必须先用 `{"dryRun":true}`;dry-run 不访问本地快照表、不执行 DDL/写库,返回 `phoneLinked/maskedPhone/suspectText/warnings` 且 `syncedCount=0`。非 dry-run 覆盖写入遇到员工被跳过、手机号不完整、脱敏手机号或疑似乱码时默认拒绝,只有确认 dry-run 结果后显式传 `allowPartialReplace=true` 才允许覆盖;重复 `ext_party_id` 始终拒绝写入,因为数据库唯一键会折叠重复身份;`replaceExisting=false` 不触发不完整快照覆盖闸门,但仍拒绝重复身份。2026-07-15 线上已用 `/api/open/v1` 前缀完成配置、dry-run 和覆盖同步:开放平台返回公司 17、部门 963、员工 3417;生产 `aihr_org_snapshot` 为 3417 行,`phoneLinked=3392`、`maskedPhone=25`、`suspectText=0`,在职 2943、离职 474。当前剩余 25 人手机号不可用属于上游数据质量问题;功能发布不再被组织同步能力阻塞 |
| 移动端手机号登录 | `GET /resource/sms/code`、`POST /auth/mobile/sms-login` | 已复用 sms4j 阿里云配置 `config1` 和 RuoYi `sms` 授权策略;短信发送成功后才写 Redis 验证码;手机号不存在时自动注册 `app_user`;`aihr.sms.dev-fixed-code` 非空时不真发短信、验证码固定(dev 默认 `123456`)。prod 默认关闭,试点期只有同时设置 `AIHR_SMS_DEV_FIXED_CODE` 与 `AIHR_SMS_PROD_FIXED_CODE_ENABLED=true` 才启用固定码。 |
@@ -220,3 +221,55 @@ Content-Type: application/json
成功响应包含 `requestId`、`answer`、`citations`、`usedSpaceCodes`、`noEvidence` 和兼容 `legacy` 数据。引用携带 `spaceCode/sourceType/docId/title/snippet/fragmentId`;客户端不得把无引用回答包装成有知识依据的正式答案。
空间、授权和应用管理接口统一位于 `/api/knowledge/admin`:`spaces`、`spaces/{id}/grants`、`spaces/{id}/documents`、`apps`、`apps/{id}/spaces`、`apps/{id}/rotate-token`。`DELETE /spaces/{id}/documents/{attachId}` 只解绑当前空间成员;其他空间仍引用同一 OSS 时原文件不会删除。仅知识平台管理角色可调用,API_TOKEN 创建或轮换后只返回一次明文。完整初始化、令牌保管、legacy 迁移与回滚步骤见 [KNOWLEDGE_PLATFORM_RUNBOOK.md](KNOWLEDGE_PLATFORM_RUNBOOK.md)。
## 工作助手项目服务记忆最小切片
该切片复用上面的短会话请求,不新增另一套问答 session。登录员工说“记一下:3 栋 2 单元 1201 室住户想了解养老服务,十天后回访”时,`POST /api/knowledge/query` 在正常回答字段之外可返回:
```json
{
"memoryCandidate": {
"id": 301,
"version": 1,
"status": "DRAFT",
"memoryType": "RESIDENT_NEED",
"targetDomain": "PROJECT_SERVICE",
"draft": {
"buildingName": "3栋",
"unitName": "2单元",
"roomNo": "1201",
"category": "养老服务咨询",
"summary": "住户希望了解社区养老服务",
"occurredAt": "2026-07-19T14:00:00+08:00",
"followUpAt": null
},
"missingFields": [],
"expiresAt": "2026-07-20T14:00:00+08:00"
}
}
```
确认请求只提交用户在卡片上确认或修改的草稿;`projectCode` 即使传入也不能作为授权依据:
```http
POST /api/aihr/personal-assistant/memory-candidates/301/confirm
Authorization: Bearer <session-token>
Content-Type: application/json
{
"expectedVersion": 1,
"idempotencyKey": "memory-confirm-301-<stable-uuid>",
"enableReminder": false,
"draft": {
"buildingName": "3栋",
"unitName": "2单元",
"roomNo": "1201",
"category": "养老服务咨询",
"summary": "住户希望了解社区养老服务",
"occurredAt": "2026-07-19T14:00:00+08:00",
"followUpAt": null
}
}
```
服务端必须在事务中锁定候选,重新解析当前员工有效项目,写入 `aihr_service_memory` 与 `aihr_service_memory_version` 后再把候选改为 `CONFIRMED`。`DRAFT` 默认 24 小时过期;`NEEDS_INPUT` 只用于继续追问。列表、详情和后续问答召回必须同时过滤当前租户和授权 `project_code`,不得因为记录由本人创建而绕过项目权限。
+10 -4
View File
@@ -14,6 +14,8 @@
上述事实只证明本批版本和 schema 已发布。正式 OTP、真机能力、角色权限正反例和严格试点窗口仍需按发布后回归及 `DEMO_ACCEPTANCE.md` 验收。
2026-07-19 增量在上述基线上新增“项目服务记忆最小切片”:候选信息只有在当前员工明确确认后,才会写入其已授权项目的服务记录;本批不包含个人想法/经验的长期知识库、跨项目共享、自动提醒或向量化个人资料。迁移后结构门禁由 `35/35` 增至 `38/38`。
## 发布前置条件
1. 确认发布窗口、DB 备份和回滚负责人;迁移脚本包含 `ALTER TABLE`,不能在无备份状态执行。
@@ -48,9 +50,11 @@ mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aih
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260717_web_ai_question_reward_mysql8.sql
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260718_knowledge_conversation_mysql8.sql
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260718_release_collation_compat_mysql8.sql
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260718_unify_all_collations_mysql8.sql
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260719_service_memory_mysql8.sql
```
顺序原因:场景补充依赖三张场景/Rubric 基础表;五维迁移依赖场景和 Rubric;岗位/SOP/任务/资格迁移只补正式数据契约,不写业务行,也不代表岗位适用范围、任务规则或资格标准已经获得 HR 确认。住宅 SOP 和 Prompt 迁移只补内置内容,不代表内容已完成业务复核。知识会话迁移只新增短期上下文表,不生成业务对话。排序规则兼容迁移必须最后执行,它会读取目标库 `aihr_knowledge_info.tenant_id` 的实际排序规则,并将 19 张发布表统一到同一规则,兼容历史生产库与全新 MySQL 8 数据库。
顺序原因:场景补充依赖三张场景/Rubric 基础表;五维迁移依赖场景和 Rubric;岗位/SOP/任务/资格迁移只补正式数据契约,不写业务行,也不代表岗位适用范围、任务规则或资格标准已经获得 HR 确认。住宅 SOP 和 Prompt 迁移只补内置内容,不代表内容已完成业务复核。知识会话迁移只新增短期上下文表,不生成业务对话。排序规则兼容迁移先对齐本批发布表,全量排序规则迁移再统一历史 `aihr_*` 表;两者都读取目标库 `aihr_knowledge_info.tenant_id` 的实际排序规则。服务记忆迁移最后创建候选、正式记录和版本留痕三张表,同样对齐该规则,兼容历史生产库与全新 MySQL 8 数据库。
## 迁移后只读核验
@@ -74,7 +78,8 @@ WHERE table_schema = DATABASE()
'aihr_incentive_rule', 'aihr_points_ledger',
'aihr_candidate_material', 'aihr_interview_result', 'aihr_candidate_employee_link',
'aihr_position_responsibility', 'aihr_sop_applicability',
'aihr_onboard_task', 'aihr_qualification_gate'
'aihr_onboard_task', 'aihr_qualification_gate',
'aihr_memory_candidate', 'aihr_service_memory', 'aihr_service_memory_version'
)
ORDER BY table_name;
@@ -117,14 +122,15 @@ GROUP BY question_type
ORDER BY question_type;
```
预期是三张场景/Rubric 表、岗位题库、训练反馈表、五张考试表、两张全网检索表、问题榜/激励六张表、三张证据表、知识平台治理表、三张候选人/面试表和四张岗位/SOP/任务/资格契约表均存在;会话证据字段为 `8/8`,专项批量/内容快照字段为 `8/8`,候选资料审核字段为 `2/2`,面试复核字段为 `6/6`,字段查询覆盖所有列,内置 Prompt 数量为 `6`。五个岗位各有 `20` 个场景,`daily/special` 各有 `100` 道题;其中 `14` 个未完成正式审核的高风险场景及其 `28` 道题保持禁用。岗位/SOP/任务/资格表为空是允许的;这只证明 schema/seed 迁移完成,不证明正式岗位规则、资格 gating 或试点完成。
预期是三张场景/Rubric 表、岗位题库、训练反馈表、五张考试表、两张全网检索表、问题榜/激励六张表、三张证据表、知识平台治理表、三张候选人/面试表、四张岗位/SOP/任务/资格契约表,以及三张项目服务记忆表均存在;会话证据字段为 `8/8`,专项批量/内容快照字段为 `8/8`,候选资料审核字段为 `2/2`,面试复核字段为 `6/6`,字段查询覆盖所有列,内置 Prompt 数量为 `6`。五个岗位各有 `20` 个场景,`daily/special` 各有 `100` 道题;其中 `14` 个未完成正式审核的高风险场景及其 `28` 道题保持禁用。岗位/SOP/任务/资格表及新服务记忆表为空是允许的;这只证明 schema/seed 迁移完成,不证明已有员工确认过记忆、完整个人知识库或严格试点完成。
## 迁移后应用回归
1. 重启 `wygj-aihr.service`,确认状态为 `active`。
2. 检查根站、`/h5/`、租户接口和员工首页 API 的 HTTP 与业务码均为 `200`。
3. 用正式测试账号完成员工端训练、主管复盘和管理端场景/Prompt 只读检查;不要使用开发固定验证码验证生产安全策略。
4. 迁移完成后再按同一正式时间窗运行:
4. 用具备单一项目授权的正式测试账号,在“工作助手”说出一条包含房号、住户需求和跟进时间的测试信息;确认候选卡前数据库不得出现正式服务记录,确认后只能写入该账号已授权的 `project_code`。随后新开对话查询该事项,必须能召回;双击确认不得重复写入。测试结束保留审计证据,不直接删除生产业务记录。
5. 迁移完成后再按同一正式时间窗运行:
```bash
AIHR_PILOT_TENANT_ID=000000 \
+2
View File
@@ -21,6 +21,7 @@
| 5 | 案例沉淀 `/knowledge/cases` | 上传真实语音 → ASR 转写 → AI 整理 → 送审 → 入库 → 查看案例详情/回放原始音频 | 已完成语音转写、原始音频 OSS 留痕、整理、入库、学习点、主管点评和按项目权限查看持久化转写/摘要详情;预渲染视频样片仍未接入,不把音频回放当作“查看样片”完成证据 |
| 6 | SOP知识库 `/knowledge/sop` | 上传 txt/md/PDF/Word/Excel/PPT 文档或使用 seed 文档 → 检索 → 生成训练题 | 已完成闭环、引用 SOP 原文片段、训练题已生成 |
| 7 | 资料处理 `/knowledge/processing` | 打开页面 → 查看解析任务 → 查看处理链路 → 点“批量导入”选择少量文件或 ZIP | 可看到资料总量、等待/解析中/完成/失败、片段数、向量化状态、批次进度和事件列表;页面不出现“选择目录”“服务端导入” |
| 8 | 员工端工作助手 `/h5/#/pages/user/sop/index` | 语音或文字说“记一下:3 栋 2 单元 1201 室住户想了解养老服务,十天后回访” → 查看候选卡 → 先暂不保存,再重新生成并确认 → 打开“我的资料与记忆” → 新对话按房号查询 | 一级只显示“工作助手 / 查全网”;候选卡可修改、暂不保存、确认保存,提醒默认关闭;暂不保存不写正式记录,确认后只写当前授权项目且双击不重复;新对话能按项目权限召回并展示来源。该项只验收项目服务记忆最小切片,不代表完整个人资料库已交付 |
## AI陪练二期增量脚本
@@ -48,5 +49,6 @@
## 不演示
- 不演示 SSO、权限配置、知识图谱、数字人视频生成、绩效挂钩。
- 不把“项目服务记忆最小切片”演示成完整个人助理:个人文档/网页收藏、个人经验知识库、PPT 生成、自动提醒和项目服务记忆 Qdrant 向量化仍不在本批范围。
- 不承诺管理端 AI 面试、案例沉淀已写入正式业务数据库;三角色对练(管理端与移动端)记录写入 `aihr_practice_session`;候选人补充资料写 `sys_oss` + `aihr_candidate_material`,HR 可审核状态,但不做复杂审批流转;SOP 检索与文档上传使用 `aihr_knowledge_*` 本地表,失败时回退 seed。
- 大模型不作为演示硬依赖:模型管理已启用 chat 模型时,三角色对练为真实 LLM 生成与评分(asr/tts 配置后语音输入/播报可用);未配置或现场调用失败时全链路自动回退 seed,演示不中断。
+7
View File
@@ -219,6 +219,7 @@ RELEASE_REMOTE_URL=https://peilian.njzhmj.top RELEASE_VERIFY_REMOTE_MATCH=true R
- SOP知识库:进入 `/knowledge/sop`,可上传 txt/md/PDF/Word/Excel/PPT 文档入库;点击“检索” → “生成训练题”,应看到“已完成闭环”、命中数据库 SOP 原文片段和训练题;数据库不可用时页面回退 seed。
- 资料处理:进入 `/knowledge/processing`,应看到资料总量、解析任务表、处理链路、规则与风险;页面只保留“批量导入”和“刷新”,不应出现“选择目录”“服务端导入”或目录导入任务面板。**批量导入走异步队列**:提交即返回,页面出现“本次批量上传”进度面板(排队/加工中/完成/失败 + 单条重试),后台 worker 并发 2 逐条解析入库;支持多文件和 ZIP,暂存目录默认 `./.data/staging`(`aihr.upload.staging` 覆盖)。
- uni-app 员工端:进入 `https://wygj-mobile-uni.localhost/h5/#/pages/user/today/index`,手机号登录(dev 验证码固定 `123456`)。今日页进入问师傅、练习、案例素材和个人页;练习页应能完成开始练习、提交回应、结束评分、每日三题提交,个人页同步训练历史和成长证据包。每日三题正式按组织快照 `hire_date` 判断入职三个月窗口;本地 Demo 若快照尚无该字段,仅由 `dev/local` profile 且 `application-dev.yml` 的 `aihr.practice.allow-legacy-daily-drill-fallback=true` 启用训练次数回退,生产 profile 即使误传环境变量也强制关闭。
- 工作助手项目服务记忆:`/h5/#/pages/user/sop/index` 一级入口只保留“工作助手 / 查全网”,既有文字、ASR、媒体、数据工具和 30 分钟/最近 6 轮短会话链保持不变;`/h5/#/pages/user/assistant/memories` 展示待确认候选和当前项目可见的服务记录。首批只落 `aihr_memory_candidate`、`aihr_service_memory`、`aihr_service_memory_version` 三张关系表,不依赖新的 Qdrant collection。候选卡未确认前不得查询到正式记录,提醒默认关闭。
- 移动端登录后若组织快照接口返回 `401/403`,只降级为手动岗位确认,不应清除手机号登录态;岗位确认页仍需允许员工选择“生活顾问”后继续进入业务页。
- uni-app 主管端:进入 `https://wygj-mobile-uni.localhost/h5/#/pages/supervisor/index/index`,应看到团队概览、团队画像、团队预警、指派专项、待复盘和复盘详情;从团队画像派专项后,最近专项应立即回读新记录。
- uni-app 候选人端:进入 `https://wygj-mobile-uni.localhost/h5/#/pages/candidate/index/index`,手机号登录(dev 验证码固定 `123456`)→ “开始面试/面试练习”完成答题评分;“补充资料”选择 PDF/Word/图片后上传,应看到资料状态。Codex 内置浏览器不支持本地文件选择时,用真实 HTTP multipart smoke 代替浏览器文件选择。
@@ -228,6 +229,12 @@ RELEASE_REMOTE_URL=https://peilian.njzhmj.top RELEASE_VERIFY_REMOTE_MATCH=true R
```bash
./scripts/demo-check.sh
bash scripts/tests/aihr-schema-migrations.test.sh
node --test mobile-uni/tests/personal-assistant.test.mjs
API_BASE=https://wygj-api.localhost ./scripts/personal-assistant-smoke.sh
# 登录后只读核验:TOKEN=<mobile-access-token> API_BASE=https://wygj-api.localhost ./scripts/personal-assistant-smoke.sh
```
工作助手记忆本地人工回归至少覆盖:以有且只有一个 `project_code` 的员工登录;说出完整房屋定位、需求和时间后看到 `DRAFT` 卡;刷新“我的资料与记忆”仍能恢复;点击“暂不保存”不产生正式记录;重新生成后双击“确认保存”只写一条记录和一个初始版本;新开对话可按房号或事项召回。再用无项目、多个项目无法唯一判断及另一项目账号做反例,分别应为安全拒绝/`NEEDS_INPUT`/不可见。
完整演示脚本与录屏兜底见 [DEMO_ACCEPTANCE.md](DEMO_ACCEPTANCE.md)。
+20 -16
View File
@@ -70,7 +70,7 @@
| 全文检索 | 使用个人片段表 MySQL Fulltext | 查询必须包含 `tenant_id + owner_user_id` |
| 向量检索 | 新建 `aihr_personal_knowledge` collection | Qdrant filter 必须包含 `tenant_id + owner_user_id` |
| 企业知识检索 | 调用现有企业 RAG 服务 | 继续执行企业知识密级、项目和角色权限 |
| 项目业务记忆 | 独立 `aihr_service_memory` 关系表与同名 Qdrant collection | 查询必须包含 `tenant_id + project_id`,不得写入个人或企业知识表 |
| 项目业务记忆 | 独立 `aihr_service_memory` 关系表;关系库检索为 2026-07-19 最小切片,Qdrant 仅列为后续增强 | 查询必须包含 `tenant_id + project_code`,不得写入个人或企业知识表 |
禁止将个人资料写入:
@@ -87,7 +87,7 @@
→ AUTO: 意图路由只在三个内部授权域中选择;MANUAL: 校验用户指定的内部范围
→ 校验日期、itemIds、知识范围和配额
→ personal: 个人 Fulltext + 个人 Qdrant(强制 owner filter)
→ project_service: 项目业务记忆 Fulltext + 独立 Qdrant(强制 project filter)
→ project_service: 首批用关系库检索并强制 project_code filter;后续再叠加独立 Qdrant
→ enterprise: 企业 Fulltext + 企业 Qdrant(强制企业权限)
→ mixed: 所选域分别检索并授权后融合,不做跨域原始结果直连
→ Rerank
@@ -197,8 +197,8 @@ org.dromara.aihr.knowledge.parse
### 3.4 项目业务记忆权限
1. `project_id` 必须由当前登录员工的有效项目任职解析,客户端不得指定越权项目。
2. 项目业务记忆的列表、详情、修改和召回必须同时过滤 `tenant_id + project_id`,创建人不因“本人记录”获得跨项目访问权。
1. `project_code` 必须由当前登录员工的有效项目任职解析,沿用 `aihr_org_snapshot.project_code` 和现有知识授权口径;客户端不得指定越权项目。
2. 项目业务记忆的列表、详情、修改和召回必须同时过滤 `tenant_id + project_code`,创建人不因“本人记录”获得跨项目访问权。
3. 住户姓名、电话、身份证件和健康信息不作为自由文本检索字段;首批优先保存房屋定位、需求摘要、跟进状态和必要说明。
4. 发送外部模型前先由服务端提取并令牌化可识别信息;模型输出不得覆盖服务端保存的主体定位。
5. 员工修改已确认记录必须携带 `expectedVersion`,成功后新增版本快照,不原地抹除历史。
@@ -305,8 +305,8 @@ Message 保存:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint PK | 候选 ID |
| tenant_id/owner_user_id/project_id | varchar/bigint | 当前身份与候选项目范围 |
| source_session_id/source_message_id | bigint | 来源会话与消息 |
| tenant_id/owner_user_id/project_code | varchar | 当前身份与候选项目范围;项目键使用现有组织快照的 `project_code`,不是自造数值 ID |
| source_conversation_id/source_request_id | varchar(64) | 来源短会话与本次问答请求;当前「问」链没有持久化消息 ID |
| candidate_hash | varchar(64) | 来源 + 规范化草稿哈希,用于防重复 |
| memory_type | varchar(30) | PERSONAL_NOTE / WORK_IDEA / RESIDENT_NEED / FOLLOW_UP |
| target_domain | varchar(30) | PERSONAL / PROJECT_SERVICE |
@@ -314,11 +314,13 @@ Message 保存:
| draft_json | json | 可编辑结构化草稿;不得包含未脱敏模型原始响应 |
| missing_fields_json | json | 仍需追问的字段 |
| confidence | decimal(5,4) | 只决定是否建议展示,不决定自动保存 |
| version | int | 乐观锁版本,从 1 开始 |
| confirm_idempotency_key/confirm_request_hash | varchar | 确认写入的稳定幂等键及请求内容哈希 |
| target_id | bigint null | 确认后生成的个人 item 或业务记忆 ID |
| expires_at/confirmed_at | datetime null | 草稿过期与确认时间 |
| create_time/update_time | datetime | 审计时间 |
约束:`UNIQUE(tenant_id, owner_user_id, source_message_id, candidate_hash)`;DRAFT 默认 24 小时过期,DISMISSED/EXPIRED 的 `draft_json` 在 24 小时内物理清理。
约束:`UNIQUE(tenant_id, owner_user_id, source_request_id, candidate_hash)`;DRAFT 默认 24 小时过期,DISMISSED/EXPIRED 的 `draft_json` 在 24 小时内物理清理。
### 4.8 `aihr_service_memory`
@@ -327,7 +329,7 @@ Message 保存:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint PK | 业务记忆 ID |
| tenant_id/project_id | varchar/bigint | 租户与项目数据范围 |
| tenant_id/project_code | varchar | 租户与现有项目授权键 |
| subject_type | varchar(20) | RESIDENT / LOCATION |
| resident_external_id | varchar(100) null | 仅权威住户主数据接入后保存;不得自行生成 |
| building_name/unit_name/room_no | varchar | 员工确认的结构化房屋定位 |
@@ -336,12 +338,12 @@ Message 保存:
| status | varchar(20) | OPEN / IN_PROGRESS / RESOLVED / VOID |
| occurred_at | datetime | 事件发生或获知时间 |
| follow_up_at | datetime null | 用户单独确认的跟进时间;空值不产生提醒 |
| source_session_id/source_message_id | bigint | 可追溯来源 |
| source_conversation_id/source_request_id | varchar(64) | 可追溯来源 |
| created_by/confirmed_by | bigint | 记录人与确认人 |
| version | int | 乐观锁版本,从 1 开始 |
| version | int | 当前乐观锁版本,从 1 开始 |
| create_time/update_time | datetime | 审计时间 |
索引至少包含 `(tenant_id, project_id, status, follow_up_at)`、`(tenant_id, project_id, building_name, unit_name, room_no)`;全文检索只覆盖 `title/summary/detail`,向量 payload 强制包含 `tenant_id + project_id + memory_id + status`。
索引至少包含 `(tenant_id, project_code, status, follow_up_at)`、`(tenant_id, project_code, building_name, unit_name, room_no)`;首批关系库检索覆盖 `title/summary/detail`,后续若启用向量检索,payload 强制包含 `tenant_id + project_code + memory_id + status`。
### 4.9 `aihr_service_memory_version`
@@ -351,11 +353,12 @@ Message 保存:
### 5.1 Qdrant
- collections:个人资料使用 `aihr_personal_knowledge`;项目业务记忆使用 `aihr_service_memory`,两者不混 collection。
- 完整阶段目标:个人资料使用 `aihr_personal_knowledge` collection;项目业务记忆使用独立 `aihr_service_memory` collection,两者不混 collection。
- 2026-07-19 项目服务记忆最小切片只使用 MySQL 关系库,不创建新的 Qdrant collection;不得因未做向量化而回退到无权限过滤的企业 collection。
- 向量维度:跟随当前启用的 vector 模型;模型或维度变化使用独立重建任务。
- 个人 payload:`tenant_id`、`owner_user_id`、`space_id`、`item_id`、`fragment_id`、`source_type`、`captured_at`;必建 index 为 `tenant_id`、`owner_user_id`、`item_id`。
- 项目业务记忆 payload:`tenant_id`、`project_id`、`memory_id`、`status`、`occurred_at`、`updated_at`;必建 index 为 `tenant_id`、`project_id`、`memory_id`、`status`。
- 个人查询无 owner filter、项目业务查询无 project filter 时,检索服务直接拒绝执行并记录安全日志。
- 项目业务记忆后续 payload:`tenant_id`、`project_code`、`memory_id`、`status`、`occurred_at`、`updated_at`;必建 index 为 `tenant_id`、`project_code`、`memory_id`、`status`。
- 个人查询无 owner filter、项目业务查询无 project_code filter 时,检索服务直接拒绝执行并记录安全日志。
- `aihr_memory_candidate` 的任何状态都不写 Qdrant;只有关系库确认事务提交成功后才异步索引目标记录。
### 5.2 OSS
@@ -508,7 +511,7 @@ Message 保存:
"targetDomain": "PROJECT_SERVICE",
"memoryType": "RESIDENT_NEED",
"subject": {
"projectId": 88,
"projectCode": "YC-RESIDENTIAL-03",
"buildingName": "3栋",
"unitName": "2单元",
"roomNo": "1201"
@@ -520,7 +523,7 @@ Message 保存:
}
```
服务端必须忽略请求中的 `projectId` 作为授权依据,仅用它与当前登录态可访问项目做一致性校验。确认成功返回 `targetDomain/targetId/version`;同一 `idempotencyKey` 重放必须返回同一结果,不重复创建记录。
服务端必须忽略请求中的 `projectCode` 作为授权依据,仅用它与当前登录态可访问项目做一致性校验;客户端可省略该字段。确认成功返回 `targetDomain/targetId/version`;同一 `idempotencyKey` 重放必须返回同一结果,不重复创建记录,同一键配不同请求体必须拒绝。
## 7. 网页采集安全
@@ -759,6 +762,7 @@ src/services/personal-assistant.ts API 客户端
- `backend/script/sql/aihr_personal_knowledge_mysql8.sql`
- `backend/script/sql/aihr_service_memory_mysql8.sql`
- `backend/script/sql/update/aihr_20260719_service_memory_mysql8.sql`(项目服务记忆最小切片生产增量)
- `backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/**`
- `backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/memory/**`(候选识别、确认编排与项目业务记忆)
- `backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/**`
@@ -0,0 +1,518 @@
<template>
<view class="memory-card" :class="{ compact }">
<template v-if="candidate.status === 'NEEDS_INPUT'">
<view class="memory-head">
<text class="memory-title">还需要补充信息</text>
<text class="memory-tag muted">暂不保存</text>
</view>
<text class="needs-input-copy">{{ missingPrompt }}</text>
</template>
<template v-else-if="completed">
<view class="memory-head">
<text class="memory-title">已确认保存</text>
<text class="memory-tag success">{{ targetLabel }}</text>
</view>
<text class="saved-copy">这条事项已保存,后续工作助手可按权限查询。</text>
</template>
<template v-else-if="candidate.status === 'DRAFT'">
<view class="memory-head">
<text class="memory-title">发现一条可保存的信息</text>
<text class="memory-tag">{{ memoryTypeLabel }}</text>
</view>
<view v-if="editing" class="edit-form">
<view class="location-grid">
<input v-model.trim="draft.buildingName" class="field-input" maxlength="30" placeholder="楼栋" />
<input v-model.trim="draft.unitName" class="field-input" maxlength="30" placeholder="单元" />
<input v-model.trim="draft.roomNo" class="field-input" maxlength="30" placeholder="房号" />
</view>
<label class="field-row">
<text class="field-label">事项</text>
<input v-model.trim="draft.category" class="field-input wide" maxlength="60" placeholder="事项类别" />
</label>
<label class="field-row vertical">
<text class="field-label">摘要</text>
<textarea v-model.trim="draft.summary" class="field-textarea" maxlength="300" auto-height placeholder="简要说明需要记住的内容" />
</label>
<picker mode="date" :value="occurredDate" @change="changeOccurredDate">
<view class="field-row picker-row">
<text class="field-label">记录时间</text>
<text class="picker-value">{{ occurredDate || '请选择' }}</text>
</view>
</picker>
</view>
<view v-else class="memory-rows">
<view class="memory-row">
<text class="row-label">对象</text>
<text class="row-value">{{ subjectLabel || '待补充' }}</text>
</view>
<view class="memory-row">
<text class="row-label">事项</text>
<text class="row-value">{{ draft.category || memoryTypeLabel }}</text>
</view>
<view class="memory-row">
<text class="row-label">摘要</text>
<text class="row-value">{{ draft.summary || '待补充' }}</text>
</view>
<view class="memory-row">
<text class="row-label">保存到</text>
<text class="row-value">{{ targetLabel }}</text>
</view>
<view class="memory-row">
<text class="row-label">记录时间</text>
<text class="row-value">{{ displayDate(draft.occurredAt) }}</text>
</view>
</view>
<button
class="reminder-switch"
role="switch"
:aria-checked="reminderEnabled"
@click="reminderEnabled = !reminderEnabled"
>
<text class="switch-mark" :class="{ active: reminderEnabled }">{{ reminderEnabled ? '✓' : '' }}</text>
<text>加入跟进提醒(默认关闭)</text>
</button>
<picker v-if="reminderEnabled" mode="date" :value="followUpDate" @change="changeFollowUpDate">
<view class="followup-picker">
<text>跟进日期</text>
<text>{{ followUpDate || '请选择' }} ›</text>
</view>
</picker>
<text class="memory-note">确认后才会长期保存;暂不保存不会写入个人资料或项目记录。</text>
<text v-if="errorText" class="memory-error">{{ errorText }}</text>
<view class="memory-actions">
<button v-if="!editing" class="memory-action plain" :disabled="submitting" @click="editing = true">修改</button>
<button v-else class="memory-action plain" :disabled="submitting" @click="editing = false">完成修改</button>
<button class="memory-action outline" :disabled="submitting" @click="dismiss">暂不保存</button>
<button class="memory-action primary" :disabled="submitting" @click="confirm">确认保存</button>
</view>
</template>
<template v-else>
<view class="memory-head">
<text class="memory-title">{{ terminalStatusTitle }}</text>
<text class="memory-tag muted">{{ memoryTypeLabel }}</text>
</view>
<text class="saved-copy">这条确认卡已不可操作,请返回工作助手重新识别或刷新列表。</text>
</template>
</view>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import type { MemoryCandidate, MemoryCandidateDraft, MemoryConfirmResult } from '@/types/api';
import {
confirmMemoryCandidate,
createMemoryIdempotencyKey,
dismissMemoryCandidate
} from '@/services/personal-assistant';
import { currentAccountKey } from '@/services/request-scope';
const props = withDefaults(defineProps<{
candidate: MemoryCandidate;
compact?: boolean;
}>(), {
compact: false
});
const emit = defineEmits<{
confirmed: [result: MemoryConfirmResult];
dismissed: [candidateId: number];
refresh: [candidateId: number];
}>();
const cloneDraft = (value: MemoryCandidateDraft): MemoryCandidateDraft => ({
...value,
projectCode: value.projectCode || null,
buildingName: value.buildingName || '',
unitName: value.unitName || '',
roomNo: value.roomNo || '',
category: value.category || '',
title: value.title || '',
summary: value.summary || '',
detail: value.detail || '',
status: value.status || 'OPEN',
occurredAt: value.occurredAt || null,
followUpAt: value.followUpAt || null
});
const draft = ref(cloneDraft(props.candidate.draft));
const editing = ref(false);
const reminderEnabled = ref(false);
const submitting = ref(false);
const completed = ref(false);
const errorText = ref('');
const idempotencyKey = ref(createMemoryIdempotencyKey(props.candidate.id));
const occurredDate = computed(() => (draft.value.occurredAt || '').slice(0, 10));
const followUpDate = computed(() => (draft.value.followUpAt || '').slice(0, 10));
const subjectLabel = computed(() => [draft.value.buildingName, draft.value.unitName, draft.value.roomNo].filter(Boolean).join(''));
const missingProjectLocationLabels = computed(() => [
{ label: '楼栋', value: draft.value.buildingName },
{ label: '单元', value: draft.value.unitName },
{ label: '房号', value: draft.value.roomNo }
].filter((item) => !item.value?.trim()).map((item) => item.label));
const memoryTypeLabel = computed(() => ({
RESIDENT_NEED: '住户需求',
SERVICE_FACT: '服务事实',
FOLLOW_UP: '待跟进事项',
PERSONAL_NOTE: '个人事项'
}[props.candidate.memoryType] || '工作事项'));
const targetLabel = computed(() =>
props.candidate.targetDomain === 'PROJECT_SERVICE' ? '项目服务记录' : '我的资料与记忆'
);
const terminalStatusTitle = computed(() => ({
CONFIRMED: '这条事项已保存',
DISMISSED: '这条事项已暂不保存',
EXPIRED: '这条确认卡已过期'
}[props.candidate.status] || '这条确认卡已结束'));
const missingPrompt = computed(() => {
const labels: Record<string, string> = {
projectCode: '所属项目',
buildingName: '楼栋',
unitName: '单元',
roomNo: '房号',
category: '事项类别',
summary: '事项说明',
occurredAt: '发生时间'
};
const fields = (props.candidate.missingFields || []).map((item) => labels[item] || item);
return fields.length ? `请继续告诉我:${fields.join('、')}。信息补齐后再请你确认保存。` : '请继续补充对象或事项信息,信息补齐后再请你确认保存。';
});
const displayDate = (value: string | null) => value ? value.replace('T', ' ').slice(0, 16) : '未填写';
const dateFromEvent = (event: { detail?: { value?: string } }) => String(event.detail?.value || '');
const changeOccurredDate = (event: { detail?: { value?: string } }) => {
const value = dateFromEvent(event);
draft.value.occurredAt = value ? `${value}T00:00:00+08:00` : null;
};
const changeFollowUpDate = (event: { detail?: { value?: string } }) => {
const value = dateFromEvent(event);
draft.value.followUpAt = value ? `${value}T09:00:00+08:00` : null;
};
const confirm = async () => {
if (submitting.value || completed.value) return;
if (!draft.value.category || !draft.value.summary || !draft.value.occurredAt) {
errorText.value = '请先补全事项、摘要和记录时间';
editing.value = true;
return;
}
if (props.candidate.targetDomain === 'PROJECT_SERVICE' && missingProjectLocationLabels.value.length) {
errorText.value = `项目服务记录还需补充:${missingProjectLocationLabels.value.join('、')}`;
editing.value = true;
return;
}
if (reminderEnabled.value && !draft.value.followUpAt) {
errorText.value = '请先选择跟进日期';
return;
}
submitting.value = true;
errorText.value = '';
const requestAccountKey = currentAccountKey();
try {
const result = await confirmMemoryCandidate(props.candidate.id, {
expectedVersion: props.candidate.version,
idempotencyKey: idempotencyKey.value,
draft: {
...draft.value,
followUpAt: reminderEnabled.value ? draft.value.followUpAt : null
},
enableReminder: reminderEnabled.value
});
if (requestAccountKey !== currentAccountKey()) return;
completed.value = true;
emit('confirmed', result);
} catch (error) {
errorText.value = error instanceof Error ? error.message : '保存失败,请稍后重试';
if (/冲突|版本|更新/.test(errorText.value)) emit('refresh', props.candidate.id);
} finally {
submitting.value = false;
}
};
const dismiss = async () => {
if (submitting.value || completed.value) return;
submitting.value = true;
errorText.value = '';
const requestAccountKey = currentAccountKey();
try {
await dismissMemoryCandidate(props.candidate.id);
if (requestAccountKey !== currentAccountKey()) return;
emit('dismissed', props.candidate.id);
} catch (error) {
errorText.value = error instanceof Error ? error.message : '操作失败,请稍后重试';
} finally {
submitting.value = false;
}
};
watch(
() => [props.candidate.id, props.candidate.version] as const,
() => {
draft.value = cloneDraft(props.candidate.draft);
reminderEnabled.value = false;
editing.value = false;
completed.value = false;
errorText.value = '';
idempotencyKey.value = createMemoryIdempotencyKey(props.candidate.id);
}
);
</script>
<style scoped>
.memory-card {
box-sizing: border-box;
width: min(100%, 360px);
padding: 16px;
border: 1px solid #e7e9ed;
border-radius: 18px;
background: #ffffff;
box-shadow: 0 8px 24px rgba(28, 36, 48, 0.07);
}
.memory-card.compact {
width: 100%;
box-shadow: none;
}
.memory-head,
.memory-row,
.field-row,
.followup-picker {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.memory-title {
color: #18202b;
font-size: 17px;
font-weight: 800;
}
.memory-tag {
flex-shrink: 0;
padding: 4px 9px;
border-radius: 8px;
background: #fff0f2;
color: #d70016;
font-size: 12px;
}
.memory-tag.muted {
background: #f2f3f5;
color: #737c89;
}
.memory-tag.success {
background: #edf8f1;
color: #168243;
}
.memory-rows {
margin-top: 12px;
border-top: 1px solid #edf0f3;
}
.memory-row {
align-items: flex-start;
padding: 11px 0;
border-bottom: 1px solid #edf0f3;
}
.row-label,
.field-label {
flex: 0 0 64px;
color: #8a93a0;
font-size: 13px;
}
.row-value {
min-width: 0;
color: #29313c;
font-size: 14px;
line-height: 1.5;
text-align: right;
word-break: break-word;
}
.edit-form {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 14px;
}
.location-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.field-row.vertical {
align-items: flex-start;
}
.field-input,
.field-textarea {
box-sizing: border-box;
min-width: 0;
border: 1px solid #dde2e8;
border-radius: 10px;
background: #f8f9fb;
color: #29313c;
font-size: 14px;
}
.field-input {
height: 42px;
padding: 0 10px;
}
.field-input.wide,
.field-textarea {
flex: 1;
width: 100%;
}
.field-textarea {
min-height: 76px;
padding: 9px 10px;
line-height: 1.45;
}
.picker-row,
.followup-picker {
min-height: 44px;
padding: 0 10px;
border: 1px solid #dde2e8;
border-radius: 10px;
background: #f8f9fb;
}
.picker-value,
.followup-picker {
color: #596474;
font-size: 13px;
}
.reminder-switch {
display: flex;
align-items: center;
gap: 9px;
width: 100%;
min-height: 44px;
margin: 10px 0 0;
padding: 0;
border: none;
background: transparent;
color: #38424e;
font-size: 13px;
text-align: left;
}
.reminder-switch::after,
.memory-action::after {
border: none;
}
.switch-mark {
display: flex;
flex: 0 0 20px;
width: 20px;
height: 20px;
align-items: center;
justify-content: center;
border: 1px solid #c8ced6;
border-radius: 50%;
color: #ffffff;
font-size: 13px;
}
.switch-mark.active {
border-color: #e60012;
background: #e60012;
}
.memory-note,
.memory-error,
.needs-input-copy,
.saved-copy {
display: block;
margin-top: 10px;
font-size: 12px;
line-height: 1.55;
}
.memory-note {
color: #8a93a0;
}
.memory-error {
color: #c8001a;
}
.needs-input-copy,
.saved-copy {
color: #596474;
font-size: 14px;
}
.memory-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin-top: 14px;
}
.memory-action {
box-sizing: border-box;
min-width: 0;
min-height: 44px;
margin: 0;
padding: 0 4px;
border-radius: 12px;
font-size: 13px;
white-space: nowrap;
}
.memory-action.plain {
border: none;
background: transparent;
color: #29313c;
}
.memory-action.outline {
border: 1px solid #9ba5b3;
background: #ffffff;
color: #29313c;
}
.memory-action.primary {
grid-column: 1 / -1;
border: 1px solid #e60012;
background: #e60012;
color: #ffffff;
}
@media (max-width: 360px) {
.memory-card {
padding: 14px;
}
}
</style>
+8 -2
View File
@@ -22,7 +22,7 @@
{
"path": "pages/user/sop/index",
"style": {
"navigationBarTitleText": "问师傅"
"navigationBarTitleText": "工作助手"
}
},
{
@@ -52,7 +52,13 @@
{
"path": "pages/user/web-ai/index",
"style": {
"navigationBarTitleText": "问 AI(全网)"
"navigationBarTitleText": "查全网"
}
},
{
"path": "pages/user/assistant/memories",
"style": {
"navigationBarTitleText": "我的资料与记忆"
}
},
{
@@ -0,0 +1,342 @@
<template>
<view class="page employee-page-shell memories-page">
<view class="subpage-header">
<button class="subpage-back-button" aria-label="返回" @click="goBack">←</button>
<view class="subpage-heading">
<text class="employee-kicker">工作助手</text>
<text class="page-title">我的资料与记忆</text>
<text class="page-subtitle">首版仅展示待确认事项和项目跟进;完整个人资料库将在后续开放。</text>
</view>
</view>
<view v-if="!loggedIn" class="panel employee-card login-panel">
<text class="panel-title">登录后查看</text>
<text class="muted">这些内容只按你的账号和当前项目权限读取。</text>
<button class="primary-button" @click="goLogin">去登录</button>
</view>
<template v-else>
<text v-if="message" class="status-message">{{ message }}</text>
<view class="section-head">
<view>
<text class="section-title">待确认</text>
<text class="section-note">只有你点“确认保存”后才会成为长期记录</text>
</view>
<button class="refresh-button" :disabled="loading" @click="load">刷新</button>
</view>
<view v-if="candidates.length" class="candidate-list">
<MemoryCandidateCard
v-for="candidate in candidates"
:key="`${candidate.id}-${candidate.version}`"
:candidate="candidate"
compact
@confirmed="removeCandidate(candidate.id, '已确认保存')"
@dismissed="removeCandidate(candidate.id, '已暂不保存')"
@refresh="load"
/>
</view>
<view v-else class="empty-card employee-card">
<text class="empty-title">暂无待确认事项</text>
<text class="empty-copy">在工作助手里说“记一下”,识别到完整信息后会先让你确认。</text>
</view>
<view class="section-head followup-head">
<view>
<text class="section-title">项目跟进</text>
<text class="section-note">仅展示当前账号有权限查看的项目服务记录</text>
</view>
</view>
<view v-if="serviceMemories.length" class="service-list">
<view v-for="item in serviceMemories" :key="`${item.id}-${item.version}`" class="service-card employee-card">
<view class="service-head">
<text class="service-category">{{ item.category || memoryTypeLabel(item.memoryType) }}</text>
<text class="service-status" :class="statusClass(item.status)">{{ statusLabel(item.status) }}</text>
</view>
<text v-if="item.projectCode" class="service-project">项目:{{ item.projectCode }}</text>
<text class="service-subject">{{ serviceSubject(item) || '项目服务事项' }}</text>
<text class="service-summary">{{ item.summary }}</text>
<view class="service-meta">
<text>记录 {{ displayDate(item.occurredAt) }}</text>
<text v-if="item.followUpAt">跟进 {{ displayDate(item.followUpAt) }}</text>
<text v-if="item.updatedAt">更新 {{ displayDate(item.updatedAt) }}</text>
</view>
</view>
</view>
<view v-else class="empty-card employee-card">
<text class="empty-title">暂无项目跟进</text>
<text class="empty-copy">经你确认的住户需求、服务事实和待跟进事项会显示在这里。</text>
</view>
</template>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
import type { MemoryCandidate, ServiceMemory } from '@/types/api';
import MemoryCandidateCard from '@/components/chat/MemoryCandidateCard.vue';
import { getMemoryCandidates, getServiceMemories } from '@/services/personal-assistant';
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { ensureEmployeePosition } from '@/services/position';
import { resetPageScroll } from '@/services/navigation';
import { createPageRequestScope } from '@/services/request-scope';
const path = '/pages/user/assistant/memories';
const loggedIn = ref(false);
const loading = ref(false);
const message = ref('');
const candidates = ref<MemoryCandidate[]>([]);
const serviceMemories = ref<ServiceMemory[]>([]);
const requests = createPageRequestScope();
const goBack = () => uni.navigateBack({ delta: 1 });
const goLogin = () => {
rememberLoginRedirect(path);
uni.navigateTo({ url: '/pages/auth/login/index' });
};
const displayDate = (value?: string | null) => value ? value.replace('T', ' ').slice(0, 16) : '未填写';
const memoryTypeLabel = (type = '') => ({
RESIDENT_NEED: '住户需求',
SERVICE_FACT: '服务事实',
FOLLOW_UP: '待跟进事项'
}[type] || '工作事项');
const statusLabel = (status = '') => ({
OPEN: '待跟进',
IN_PROGRESS: '跟进中',
RESOLVED: '已完成',
VOID: '已作废'
}[status] || status || '已记录');
const statusClass = (status = '') => status === 'VOID' ? 'void' : status === 'RESOLVED' ? 'done' : '';
const serviceSubject = (item: ServiceMemory) => {
return [
item.buildingName,
item.unitName,
item.roomNo
].filter(Boolean).join('');
};
const removeCandidate = (candidateId: number, toast: string) => {
candidates.value = candidates.value.filter((item) => item.id !== candidateId);
uni.showToast({ title: toast, icon: 'none' });
void loadServiceMemories();
};
const loadServiceMemories = async () => {
const request = requests.begin('service-memories');
try {
const result = await getServiceMemories();
if (requests.isCurrent(request)) serviceMemories.value = result;
} catch (error) {
if (requests.isCurrent(request)) {
message.value = error instanceof Error ? error.message : '项目跟进读取失败';
}
}
};
const load = async () => {
loggedIn.value = isLoggedIn();
candidates.value = [];
serviceMemories.value = [];
message.value = '';
if (!loggedIn.value) return;
if (!(await ensureEmployeePosition(path))) return;
const request = requests.begin('memory-page');
loading.value = true;
const [candidateResult, serviceResult] = await Promise.allSettled([
getMemoryCandidates('DRAFT'),
getServiceMemories()
]);
if (!requests.isCurrent(request)) return;
if (candidateResult.status === 'fulfilled') candidates.value = candidateResult.value;
if (serviceResult.status === 'fulfilled') serviceMemories.value = serviceResult.value;
const errors = [candidateResult, serviceResult]
.filter((item): item is PromiseRejectedResult => item.status === 'rejected')
.map((item) => item.reason instanceof Error ? item.reason.message : '读取失败');
message.value = errors.join(';');
loading.value = false;
};
onShow(() => {
requests.activate();
resetPageScroll();
void load();
});
onHide(() => requests.invalidate());
onUnload(() => requests.invalidate());
</script>
<style scoped>
.memories-page {
max-width: var(--mobile-shell-max-width);
margin: 0 auto;
padding-bottom: calc(32px + env(safe-area-inset-bottom));
}
.status-message {
display: block;
margin-top: 14px;
color: #a9000d;
font-size: 13px;
line-height: 1.5;
}
.login-panel,
.empty-card {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 18px;
}
.login-panel .primary-button {
width: 100%;
margin-top: 4px;
}
.section-head,
.service-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.section-head {
margin-top: 22px;
}
.section-head.followup-head {
margin-top: 30px;
}
.section-title,
.section-note,
.empty-title,
.empty-copy,
.service-subject,
.service-summary {
display: block;
}
.section-title {
color: #18202b;
font-size: 20px;
font-weight: 900;
}
.section-note {
margin-top: 5px;
color: #8a93a0;
font-size: 12px;
line-height: 1.45;
}
.refresh-button {
flex-shrink: 0;
min-height: 38px;
margin: 0;
padding: 0 13px;
border: 1px solid #dde2e8;
border-radius: 999px;
background: #ffffff;
color: #596474;
font-size: 12px;
}
.candidate-list,
.service-list {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 12px;
}
.empty-card {
padding: 18px;
}
.empty-title {
color: #29313c;
font-weight: 800;
}
.empty-copy {
color: #8a93a0;
font-size: 13px;
line-height: 1.55;
}
.service-card {
padding: 17px;
border-radius: 18px;
}
.service-category {
color: #29313c;
font-size: 13px;
font-weight: 800;
}
.service-status {
padding: 4px 9px;
border-radius: 999px;
background: #fff0f2;
color: #d70016;
font-size: 11px;
}
.service-status.done {
background: #edf8f1;
color: #168243;
}
.service-status.void {
background: #f2f3f5;
color: #7d8590;
}
.service-subject {
margin-top: 12px;
color: #18202b;
font-size: 17px;
font-weight: 900;
}
.service-project {
display: block;
margin-top: 11px;
color: #727c89;
font-size: 12px;
line-height: 1.4;
}
.service-project + .service-subject {
margin-top: 5px;
}
.service-summary {
margin-top: 8px;
color: #4f5967;
font-size: 14px;
line-height: 1.6;
}
.service-meta {
display: flex;
flex-wrap: wrap;
gap: 6px 12px;
margin-top: 12px;
padding-top: 10px;
border-top: 1px solid #edf0f3;
color: #8a93a0;
font-size: 11px;
}
</style>
@@ -125,6 +125,14 @@
<view v-if="loggedIn" class="panel feature-entries">
<text class="panel-title">常用功能</text>
<button class="feature-entry" @click="openMemories">
<view class="feature-entry-icon memory">记</view>
<view class="feature-entry-copy">
<text class="feature-entry-title">我的资料与记忆</text>
<text class="feature-entry-desc">确认待保存事项 · 查看项目跟进</text>
</view>
<text class="points-entry-arrow">›</text>
</button>
<button class="feature-entry" @click="openLearning">
<view class="feature-entry-icon learn">学</view>
<view class="feature-entry-copy">
@@ -292,6 +300,10 @@ const openLearning = () => {
uni.navigateTo({ url: '/pages/user/learning/index' });
};
const openMemories = () => {
uni.navigateTo({ url: '/pages/user/assistant/memories' });
};
const openPracticeSummary = () => {
uni.setStorageSync('aihr_sop_pending_tool', 'MY_PRACTICE_SUMMARY');
uni.switchTab({ url: '/pages/user/sop/index' });
@@ -868,6 +880,10 @@ onShow(() => {
background: #1f5c9d;
}
.feature-entry-icon.memory {
background: #5d6878;
}
.feature-entry-icon.summary {
background: #e70014;
}
+126 -9
View File
@@ -9,8 +9,8 @@
</view>
</view>
<view class="source-switch chat-source-switch" aria-label="问答来源">
<button class="source-option active" aria-current="page">企业问师傅</button>
<button class="source-option" @click="openWebAi">问 AI(全网)</button>
<button class="source-option active" aria-current="page">工作助手</button>
<button class="source-option" @click="openWebAi">查全网</button>
</view>
</view>
@@ -19,7 +19,7 @@
<view class="bubble-row left">
<image class="avatar master-avatar" src="/static/mentor-female-avatar.png" mode="aspectFill" />
<view class="bubble master">
<text class="bubble-text">你好,我是老师傅。工作里的 SOP、话术、流程直接问我,答案都带引用依据;也可以按住下面的按钮说话。</text>
<text class="bubble-text">工作里的问题直接问我,我会查制度、SOP、项目记录和经你确认保存的事项;也可以按住下面的按钮说话。</text>
</view>
</view>
@@ -87,6 +87,7 @@
<button v-if="msg.toolCode === ''" class="bubble-action" :disabled="summaryLoading" @click="buildSummaryCard(msg)">
{{ summaryLoading ? '生成中' : '总结卡' }}
</button>
<button v-if="msg.result.noEvidence" class="bubble-action primary" @click="openWebAi">去查全网</button>
</view>
<view v-if="msg.toolCode === ''" class="bubble-actions feedback-row">
<button
@@ -112,8 +113,14 @@
<view v-if="msg.citationsOpen && msg.result.snippets?.length" class="bubble-citations">
<view v-for="(snippet, index) in msg.result.snippets" :key="`${snippet.docTitle}-${index}`" class="citation">
<text class="citation-domain" :class="{ project: snippet.domain === 'PROJECT_SERVICE' }">
{{ citationDomainLabel(snippet) }}
</text>
<text class="citation-title">{{ snippet.docTitle }}</text>
<text v-if="snippet.spaceCode" class="citation-meta">知识空间:{{ snippet.spaceCode }}</text>
<text v-if="snippet.domain === 'PROJECT_SERVICE'" class="citation-meta">
{{ serviceMemoryStatusLabel(snippet.status) }} · 发生 {{ displayCitationDate(snippet.occurredAt) }} · 更新 {{ displayCitationDate(snippet.updatedAt) }}
</text>
<text v-else-if="snippet.spaceCode" class="citation-meta">知识空间:{{ snippet.spaceCode }}</text>
<text class="citation-content">{{ snippetExcerpt(snippet.content) }}</text>
<text v-if="snippet.fragmentId" class="citation-meta">片段 #{{ snippet.fragmentId }}</text>
</view>
@@ -149,6 +156,13 @@
/>
</view>
</view>
<MemoryCandidateCard
v-if="msg.result.memoryCandidate"
:candidate="msg.result.memoryCandidate"
@dismissed="hideMemoryCandidate(msg)"
@refresh="refreshMemoryCandidate(msg, $event)"
/>
</view>
</view>
</template>
@@ -156,7 +170,7 @@
<view v-if="!loggedIn" class="bubble-row left">
<image class="avatar master-avatar" src="/static/mentor-female-avatar.png" mode="aspectFill" />
<view class="bubble master">
<text class="bubble-text">登录后才能查询 SOP、生成总结卡并反馈答案。</text>
<text class="bubble-text">登录后才能使用工作助手、确认保存事项并查询内部资料。</text>
<view class="bubble-actions">
<button class="bubble-action primary" @click="goLogin">去登录</button>
</view>
@@ -167,6 +181,8 @@
</view>
</scroll-view>
<view class="scope-note">资料范围:自动 · 仅查询当前有权限的内部资料</view>
<ChatComposer
ref="composerRef"
:disabled="!loggedIn"
@@ -278,6 +294,7 @@ import type {
SummaryCardResponse
} from '@/types/api';
import ChatComposer from '@/components/chat/ChatComposer.vue';
import MemoryCandidateCard from '@/components/chat/MemoryCandidateCard.vue';
import {
chooseKnowledgeMedia,
generateSummaryCard,
@@ -299,6 +316,8 @@ import { authenticatedFileUrl, releaseAuthenticatedFileUrl } from '@/services/ap
import { ensureEmployeePosition } from '@/services/position';
import { chooseSpeechAudio, createSpeechPlaybackController, measureAudioDuration, MENTOR_VOICE_PROFILE, transcribeSpeechBlob, transcribeSpeechFile } from '@/services/speech';
import { downloadSummaryCardImage } from '@/services/summary-card-image';
import { getMemoryCandidates } from '@/services/personal-assistant';
import { currentAccountKey } from '@/services/request-scope';
import { computed } from 'vue';
type ToolCode = '' | 'MY_PRACTICE_SUMMARY' | 'TEAM_PRACTICE_SUMMARY';
@@ -372,6 +391,9 @@ let voiceTranscriptionGeneration = 0;
let abortVoiceTranscription: (() => void) | null = null;
let knowledgeSearchGeneration = 0;
let activeMediaUploadTask: ReturnType<typeof uni.uploadFile> | null = null;
let candidateRefreshGeneration = 0;
let showGeneration = 0;
let visibleAccountKey = '';
const defaultToolCode = computed<'MY_PRACTICE_SUMMARY' | 'TEAM_PRACTICE_SUMMARY'>(() =>
currentRole.value === 'supervisor' && !supervisorLearner.value
@@ -418,6 +440,15 @@ const applyConversationState = (result: KnowledgeSearchResponse) => {
}
};
const retireEarlierMemoryCandidate = (candidateId?: number) => {
if (!candidateId) return;
messages.value.forEach((item) => {
if (item.role === 'master' && item.result.memoryCandidate?.id === candidateId) {
item.result.memoryCandidate = null;
}
});
};
const releaseMessageResources = () => {
messages.value.forEach((msg) => {
if (msg.role !== 'master') return;
@@ -430,6 +461,7 @@ const releaseMessageResources = () => {
const resetConversation = (notify: boolean) => {
cancelPendingMediaSearch();
candidateRefreshGeneration += 1;
releaseMessageResources();
speechPlayback.stop();
messages.value = [];
@@ -474,16 +506,21 @@ const sendQuestion = async (rawText: string, options: { voice?: boolean } = {})
uni.showToast({ title: '请先输入问题', icon: 'none' });
return;
}
const requestShowGeneration = showGeneration;
const requestAccountKey = currentAccountKey();
const isCurrentQuestion = () => requestShowGeneration === showGeneration
&& requestAccountKey === currentAccountKey();
refreshAuthState();
if (!loggedIn.value) {
uni.setStorageSync(pendingSopQueryKey, text);
goLogin();
return;
}
if (!(await ensureEmployeePosition('/pages/user/sop/index'))) {
if (!(await ensureEmployeePosition('/pages/user/sop/index')) || !isCurrentQuestion()) {
return;
}
refreshAuthState();
if (!isCurrentQuestion()) return;
pushMsg<UserMsg>({
role: 'user',
text,
@@ -503,6 +540,7 @@ const sendQuestion = async (rawText: string, options: { voice?: boolean } = {})
: await searchKnowledge(text, getSelectedPosition(), conversationState());
if (generation !== knowledgeSearchGeneration) return;
applyConversationState(nextResult);
retireEarlierMemoryCandidate(nextResult.memoryCandidate?.id);
const spoken = (nextResult.answer || '').trim();
replaceMsg(pending.id, {
id: pending.id,
@@ -518,7 +556,7 @@ const sendQuestion = async (rawText: string, options: { voice?: boolean } = {})
transcriptOpen: false
});
if (spoken) void prepareVoice(pending.id, spoken);
if (!nextResult.answer && !nextResult.snippets?.length) {
if (!nextResult.answer && !nextResult.snippets?.length && !nextResult.memoryCandidate) {
pushSystem('未检索到可引用内容,换个问法试试');
}
} catch (error) {
@@ -748,6 +786,22 @@ const cleanOrderedText = (text = '') =>
.replace(/^(?:[一二三四五六七八九十]+[、..]|[0-9]+[.)、..])\s*/, '')
.trim();
const citationDomainLabel = (snippet: KnowledgeSearchResponse['snippets'][number]) => {
if (snippet.domain === 'PROJECT_SERVICE') return '项目记录';
if (snippet.domain === 'PERSONAL') return '我的资料';
if (snippet.sourceType === 'DATA_TOOL') return '数据工具';
return '企业 SOP';
};
const serviceMemoryStatusLabel = (status?: string | null) => ({
OPEN: '待跟进',
IN_PROGRESS: '跟进中',
RESOLVED: '已完成',
VOID: '已作废'
}[status || ''] || '状态未知');
const displayCitationDate = (value?: string | null) => value ? value.replace('T', ' ').slice(0, 16) : '未记录';
const feedback = async (verdict: AnswerFeedbackVerdict, msg: MasterMsg) => {
if (msg.feedbackSubmitting || msg.feedbackVerdict) return;
const fragmentIds = Array.from(
@@ -858,30 +912,60 @@ const openWebAi = () => {
uni.navigateTo({ url: '/pages/user/web-ai/index' });
};
const hideMemoryCandidate = (msg: MasterMsg) => {
retireEarlierMemoryCandidate(msg.result.memoryCandidate?.id);
};
const refreshMemoryCandidate = async (msg: MasterMsg, candidateId: number) => {
const generation = ++candidateRefreshGeneration;
const accountKey = currentAccountKey();
try {
const candidates = await getMemoryCandidates('DRAFT');
if (generation !== candidateRefreshGeneration || accountKey !== currentAccountKey()) return;
msg.result.memoryCandidate = candidates.find((item) => item.id === candidateId) || null;
} catch (error) {
if (generation !== candidateRefreshGeneration || accountKey !== currentAccountKey()) return;
uni.showToast({ title: requestErrorText(error, '确认卡刷新失败'), icon: 'none' });
}
};
const handleShow = async () => {
const generation = ++showGeneration;
resetPageScroll();
const accountKey = currentAccountKey();
const isCurrentShow = () => generation === showGeneration && accountKey === currentAccountKey();
if (visibleAccountKey && visibleAccountKey !== accountKey) resetConversation(false);
visibleAccountKey = accountKey;
refreshAuthState();
if (!loggedIn.value && messages.value.length) {
resetConversation(false);
}
if (loggedIn.value && !(await ensureEmployeePosition('/pages/user/sop/index'))) {
return;
if (!isCurrentShow()) return;
if (loggedIn.value) {
const hasEmployeePosition = await ensureEmployeePosition('/pages/user/sop/index');
if (!isCurrentShow() || !hasEmployeePosition) return;
}
positionProfile.value = getSelectedPositionProfile();
scrollToBottom();
if (!isCurrentShow()) return;
const pending = uni.getStorageSync(pendingSopQueryKey);
if (!isCurrentShow()) return;
const pendingTool = uni.getStorageSync(pendingToolKey);
if (pendingTool === 'MY_PRACTICE_SUMMARY' || pendingTool === 'TEAM_PRACTICE_SUMMARY') {
if (!loggedIn.value) {
goLogin();
return;
}
if (!isCurrentShow()) return;
uni.removeStorageSync(pendingToolKey);
if (!isCurrentShow()) return;
void runDataTool(pendingTool);
return;
}
if (pending && loggedIn.value) {
if (!isCurrentShow()) return;
uni.removeStorageSync(pendingSopQueryKey);
if (!isCurrentShow()) return;
void sendQuestion(String(pending));
}
};
@@ -891,6 +975,8 @@ onShow(() => {
});
onHide(() => {
showGeneration += 1;
candidateRefreshGeneration += 1;
speechPlayback.stop();
composerRef.value?.teardown();
cancelVoiceTranscription();
@@ -898,6 +984,7 @@ onHide(() => {
});
onUnload(() => {
showGeneration += 1;
releaseMessageResources();
speechPlayback.destroy();
composerRef.value?.teardown();
@@ -2295,6 +2382,22 @@ onUnload(() => {
font-weight: 700;
}
.citation-domain {
display: inline-flex;
margin-bottom: 5px;
padding: 2px 7px;
border-radius: 999px;
background: #eef3f8;
color: #526176;
font-size: 12px;
font-weight: 700;
}
.citation-domain.project {
background: #fff0f2;
color: #c8001a;
}
.citation-meta {
display: block;
margin-top: 2px;
@@ -2423,4 +2526,18 @@ onUnload(() => {
color: #98a1ad;
font-size: 12px;
}
.scope-note {
flex-shrink: 0;
padding: 6px 14px;
border-top: 1px solid #eceef1;
background: #ffffff;
color: #8a93a0;
font-size: 12px;
line-height: 1.4;
}
.master-col :deep(.memory-card) {
max-width: 100%;
}
</style>
+5 -5
View File
@@ -3,14 +3,14 @@
<view class="chat-head">
<view class="chat-head-row">
<view class="chat-head-left">
<button class="chat-back" aria-label="返回问师傅" @click="goBack">←</button>
<text class="chat-title">问 AI(全网)</text>
<button class="chat-back" aria-label="返回工作助手" @click="goBack">←</button>
<text class="chat-title">查全网</text>
</view>
<text class="chat-context">互联网信息 · 独立来源</text>
</view>
<view class="source-switch chat-source-switch" aria-label="问答来源">
<button class="source-option" @click="goBack">企业问师傅</button>
<button class="source-option active" aria-current="page">问 AI(全网)</button>
<button class="source-option" @click="goBack">工作助手</button>
<button class="source-option active" aria-current="page">查全网</button>
</view>
</view>
@@ -19,7 +19,7 @@
<view class="bubble-row left">
<view class="avatar ai-avatar">AI</view>
<view class="bubble master">
<text class="bubble-text">我是全网 AI,检索公开网页并保留可核验引用,不与企业 SOP 混用。问题会先脱敏再发送到外部服务;答案来自互联网,不代表公司制度,涉及作业、消防、电梯或法律事项请以公司 SOP 为准。</text>
<text class="bubble-text">这里单独查询公开网页并保留可核验引用,不与工作助手的内部资料混用。问题会先脱敏再发送到外部服务;答案来自互联网,不代表公司制度,涉及作业、消防、电梯或法律事项请以公司 SOP 为准。</text>
<text class="bubble-subnote">不要输入业主姓名、手机号、房号、身份证、门禁或公司未公开资料。</text>
</view>
</view>
+33 -3
View File
@@ -54,6 +54,30 @@ const normalizeSnippet = (snippet: RawKnowledgeSnippet): KnowledgeSnippet => ({
content: snippet.content || snippet.text || ''
});
export const mergeKnowledgeSnippets = (...groups: RawKnowledgeSnippet[][]): KnowledgeSnippet[] => {
const rows: KnowledgeSnippet[] = [];
const indexes = new Map<string, number>();
groups.flat().forEach((item) => {
const snippet = normalizeSnippet(item);
const key = snippet.fragmentId
? `fragment:${snippet.fragmentId}`
: snippet.docId
? `${snippet.domain || snippet.sourceType || ''}:doc:${snippet.docId}`
: `${snippet.domain || snippet.sourceType || snippet.spaceCode || ''}|${snippet.docTitle}|${snippet.content}`;
const existingIndex = indexes.get(key);
if (existingIndex === undefined) {
indexes.set(key, rows.length);
rows.push(snippet);
return;
}
const present = Object.fromEntries(
Object.entries(snippet).filter(([, value]) => value !== undefined && value !== null && value !== '')
);
rows[existingIndex] = { ...rows[existingIndex], ...present };
});
return rows;
};
export const searchKnowledge = async (
queryText: string,
position = getSelectedPosition(),
@@ -136,9 +160,14 @@ const normalizeUnifiedResponse = (response: UnifiedKnowledgeResponse): Knowledge
const citationSnippets = (response.citations || []).map((item) => normalizeSnippet({
docTitle: item.title,
content: item.snippet,
docId: item.docId,
fragmentId: item.fragmentId,
spaceCode: item.spaceCode,
sourceType: item.sourceType
sourceType: item.sourceType,
domain: item.domain,
status: item.status,
occurredAt: item.occurredAt,
updatedAt: item.updatedAt
}));
return {
...legacy,
@@ -148,7 +177,7 @@ const normalizeUnifiedResponse = (response: UnifiedKnowledgeResponse): Knowledge
answer: response.answer,
reference: legacy.reference || (response.citations || []).map((item) => item.title).join('、'),
docs: legacy.docs || [],
snippets: (legacy.snippets?.length ? legacy.snippets : citationSnippets).map((item) => normalizeSnippet(item as RawKnowledgeSnippet)),
snippets: mergeKnowledgeSnippets(citationSnippets, legacy.snippets || []),
training: legacy.training || [],
records: legacy.records || [],
citations: response.citations || [],
@@ -160,7 +189,8 @@ const normalizeUnifiedResponse = (response: UnifiedKnowledgeResponse): Knowledge
contextVersion: response.contextVersion,
intent: response.intent,
rewrittenQuery: response.rewrittenQuery,
resources: response.resources || []
resources: response.resources || [],
memoryCandidate: response.memoryCandidate || legacy.memoryCandidate
};
};
@@ -0,0 +1,54 @@
import type {
MemoryCandidate,
MemoryConfirmRequest,
MemoryConfirmResult,
ServiceMemory
} from '@/types/api';
import { apiRequest } from './api';
const candidateBase = '/api/aihr/personal-assistant/memory-candidates';
type RawServiceMemory = Omit<ServiceMemory, 'version'> & { version?: number; currentVersion?: number };
const rowsOf = <T>(value: T[] | { rows?: T[]; records?: T[] } | null | undefined): T[] => {
if (Array.isArray(value)) return value;
return value?.rows || value?.records || [];
};
export const createMemoryIdempotencyKey = (candidateId: number) => {
const suffix = typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2, 12)}`;
return `memory-confirm-${candidateId}-${suffix}`;
};
export const getMemoryCandidates = async (status = 'DRAFT') =>
rowsOf(await apiRequest<MemoryCandidate[] | { rows?: MemoryCandidate[]; records?: MemoryCandidate[] }>({
url: `${candidateBase}?status=${encodeURIComponent(status)}`,
method: 'GET',
timeout: 15000
}));
export const confirmMemoryCandidate = (candidateId: number, request: MemoryConfirmRequest) =>
apiRequest<MemoryConfirmResult>({
url: `${candidateBase}/${candidateId}/confirm`,
method: 'POST',
data: request,
timeout: 15000
});
export const dismissMemoryCandidate = (candidateId: number) =>
apiRequest<{ id: number; status: string }>({
url: `${candidateBase}/${candidateId}/dismiss`,
method: 'POST',
timeout: 15000
});
export const getServiceMemories = async () => {
const rows = rowsOf(await apiRequest<RawServiceMemory[] | { rows?: RawServiceMemory[]; records?: RawServiceMemory[] }>({
url: '/api/aihr/service-memories',
method: 'GET',
timeout: 15000
}));
return rows.map((item) => ({ ...item, version: item.version ?? item.currentVersion ?? 0 }));
};
+74
View File
@@ -97,9 +97,14 @@ export interface KnowledgeSnippet {
docTitle: string;
content: string;
source?: string;
docId?: string | null;
fragmentId?: number | null;
spaceCode?: string | null;
sourceType?: 'DOCUMENT' | 'DATA_TOOL' | string;
domain?: 'PERSONAL' | 'PROJECT_SERVICE' | 'ENTERPRISE' | string | null;
status?: string | null;
occurredAt?: string | null;
updatedAt?: string | null;
}
export interface KnowledgeDoc {
@@ -131,6 +136,7 @@ export interface KnowledgeSearchResponse {
intent?: 'QA' | 'FILE' | 'VIDEO' | 'DATA_TOOL' | string;
rewrittenQuery?: string;
resources?: KnowledgeResource[];
memoryCandidate?: MemoryCandidate | null;
}
export interface KnowledgeMediaSelectedFile {
@@ -147,6 +153,10 @@ export interface KnowledgeCitation {
title: string;
snippet: string;
fragmentId?: number | null;
domain?: 'PERSONAL' | 'PROJECT_SERVICE' | 'ENTERPRISE' | string | null;
status?: string | null;
occurredAt?: string | null;
updatedAt?: string | null;
}
export interface KnowledgeResource {
@@ -178,6 +188,70 @@ export interface UnifiedKnowledgeResponse {
intent?: 'QA' | 'FILE' | 'VIDEO' | 'DATA_TOOL' | string;
rewrittenQuery?: string;
resources?: KnowledgeResource[];
memoryCandidate?: MemoryCandidate | null;
}
export type MemoryCandidateStatus = 'DRAFT' | 'NEEDS_INPUT' | 'CONFIRMED' | 'DISMISSED' | 'EXPIRED' | string;
export type MemoryTargetDomain = 'PERSONAL' | 'PROJECT_SERVICE' | string;
export interface MemoryCandidateDraft {
projectCode: string | null;
occurredAt: string | null;
followUpAt: string | null;
buildingName: string;
unitName: string;
roomNo: string;
category: string;
title: string;
summary: string;
detail: string;
status: string;
}
export interface MemoryCandidate {
id: number;
version: number;
status: MemoryCandidateStatus;
memoryType: string;
targetDomain: MemoryTargetDomain;
draft: MemoryCandidateDraft;
missingFields: string[];
expiresAt: string | null;
}
export interface MemoryConfirmRequest {
expectedVersion: number;
idempotencyKey: string;
draft: MemoryCandidateDraft;
enableReminder: boolean;
}
export interface MemoryConfirmResult {
targetDomain: MemoryTargetDomain;
targetId: number;
version: number;
}
export interface ServiceMemory {
id: number;
version: number;
currentVersion?: number;
projectCode?: string | null;
memoryType: string;
subjectType?: string;
category: string;
title?: string;
summary: string;
detail?: string;
status: string;
occurredAt: string | null;
followUpAt: string | null;
createdAt?: string | null;
updatedAt?: string | null;
buildingName?: string;
unitName?: string;
roomNo?: string;
}
export interface CaseCapabilities {
@@ -4,15 +4,15 @@ import test from 'node:test';
const read = (path) => readFile(new URL(path, import.meta.url), 'utf8');
test('全网问AI与企业问师傅分入口分接口并持续显示外部来源提示', async () => {
test('查全网与工作助手分入口分接口并持续显示外部来源提示', async () => {
const [sop, webAi, webService] = await Promise.all([
read('../src/pages/user/sop/index.vue'),
read('../src/pages/user/web-ai/index.vue'),
read('../src/services/web-ai.ts')
]);
assert.match(sop, /企业问师傅/);
assert.match(sop, /问 AI(全网)/);
assert.match(webAi, /不与企业 SOP 混用/);
assert.match(sop, /工作助手/);
assert.match(sop, /查全网/);
assert.match(webAi, /不与工作助手的内部资料混用/);
assert.match(webAi, /答案来自互联网,不代表公司制度/);
assert.match(webService, /\/api\/aihr\/web-ai\/query/);
assert.doesNotMatch(webService, /\/api\/knowledge\/query/);
@@ -0,0 +1,143 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test, { after } from 'node:test';
import { fileURLToPath } from 'node:url';
import { createServer } from 'vite';
const read = (path) => readFile(new URL(path, import.meta.url), 'utf8');
const vite = await createServer({
root: fileURLToPath(new URL('..', import.meta.url)),
configFile: false,
optimizeDeps: { noDiscovery: true },
server: { middlewareMode: true, hmr: false },
appType: 'custom'
});
after(async () => vite.close());
const { mergeKnowledgeSnippets } = await vite.ssrLoadModule('/src/services/knowledge.ts');
test('个人助理客户端使用确认式记忆接口且确认请求携带稳定幂等键', async () => {
const service = await read('../src/services/personal-assistant.ts');
const card = await read('../src/components/chat/MemoryCandidateCard.vue');
assert.match(service, /\/api\/aihr\/personal-assistant\/memory-candidates/);
assert.match(service, /\$\{candidateBase\}\/\$\{candidateId\}\/confirm/);
assert.match(service, /\$\{candidateBase\}\/\$\{candidateId\}\/dismiss/);
assert.match(service, /\/api\/aihr\/service-memories/);
assert.match(card, /const idempotencyKey = ref\(createMemoryIdempotencyKey\(props\.candidate\.id\)\)/);
assert.match(card, /if \(submitting\.value \|\| completed\.value\) return/);
assert.match(card, /expectedVersion: props\.candidate\.version/);
assert.match(card, /enableReminder: reminderEnabled\.value/);
assert.match(card, /\.\.\.value/);
for (const field of ['projectCode', 'title', 'detail', 'status']) assert.match(card, new RegExp(field));
});
test('候选确认卡区分待补充和草稿,提醒默认关闭并保留三个动作', async () => {
const card = await read('../src/components/chat/MemoryCandidateCard.vue');
assert.match(card, /candidate\.status === 'NEEDS_INPUT'/);
assert.match(card, /candidate\.status === 'DRAFT'/);
assert.match(card, /const reminderEnabled = ref\(false\)/);
assert.match(card, />修改</);
assert.match(card, />暂不保存</);
assert.match(card, />确认保存</);
assert.match(card, /v-if="reminderEnabled"/);
for (const field of ['buildingName', 'unitName', 'roomNo']) {
assert.match(card, new RegExp(`value: draft\\.value\\.${field}`));
}
assert.match(card, /filter\(\(item\) => !item\.value\?\.trim\(\)\)\.map/);
assert.match(card, /missingProjectLocationLabels\.value\.length/);
assert.match(card, /还需补充:\$\{missingProjectLocationLabels\.value\.join\('、'\)\}/);
});
test('工作助手页面展示任务过期后不会消费待执行查询或数据工具', async () => {
const page = await read('../src/pages/user/sop/index.vue');
assert.match(page, /const isCurrentShow = \(\) => generation === showGeneration && accountKey === currentAccountKey\(\)/);
assert.match(page, /if \(!isCurrentShow\(\)\) return;\s*const pending = uni\.getStorageSync\(pendingSopQueryKey\)/);
assert.match(page, /if \(!isCurrentShow\(\)\) return;\s*const pendingTool = uni\.getStorageSync\(pendingToolKey\)/);
assert.match(page, /if \(!isCurrentShow\(\)\) return;\s*uni\.removeStorageSync\(pendingToolKey\)/);
assert.match(page, /if \(!isCurrentShow\(\)\) return;\s*uni\.removeStorageSync\(pendingSopQueryKey\)/);
assert.match(page, /const requestShowGeneration = showGeneration/);
assert.match(page, /const requestAccountKey = currentAccountKey\(\)/);
assert.match(page, /ensureEmployeePosition\('\/pages\/user\/sop\/index'\)\) \|\| !isCurrentQuestion\(\)/);
});
test('工作助手保留原问答链并只提供显式查全网入口', async () => {
const [page, knowledge] = await Promise.all([
read('../src/pages/user/sop/index.vue'),
read('../src/services/knowledge.ts')
]);
assert.match(page, />工作助手<\/button>/);
assert.match(page, />查全网<\/button>/);
assert.match(page, /<MemoryCandidateCard/);
assert.match(page, /msg\.result\.noEvidence/);
assert.match(page, /@click="openWebAi">去查全网/);
assert.match(knowledge, /url: '\/api\/knowledge\/query'/);
assert.match(knowledge, /memoryCandidate: response\.memoryCandidate \|\| legacy\.memoryCandidate/);
assert.match(page, /visibleAccountKey !== accountKey/);
assert.match(page, /accountKey !== currentAccountKey\(\)/);
assert.match(page, /const generation = \+\+showGeneration/);
assert.match(page, /generation === showGeneration && accountKey === currentAccountKey\(\)/);
assert.match(page, /onHide\(\(\) => \{\s*showGeneration \+= 1/);
assert.match(page, /onUnload\(\(\) => \{\s*showGeneration \+= 1/);
assert.match(page, /项目记录/);
assert.match(page, /serviceMemoryStatusLabel/);
assert.match(page, /!nextResult\.answer && !nextResult\.snippets\?\.length && !nextResult\.memoryCandidate/);
assert.doesNotMatch(page, /setStorageSync\([^\n]*web[^\n]*query/i);
});
test('企业与项目引用合并去重并保留项目状态和时间', () => {
const snippets = mergeKnowledgeSnippets([
{
docTitle: '住户服务规范',
content: '先确认需求。',
fragmentId: 7,
sourceType: 'DOCUMENT',
domain: 'ENTERPRISE'
},
{
docTitle: '3栋2单元1201跟进记录',
content: '等待回访。',
docId: '901',
sourceType: 'PROJECT_SERVICE',
domain: 'PROJECT_SERVICE',
status: 'IN_PROGRESS',
occurredAt: '2026-07-18T14:00:00+08:00',
updatedAt: '2026-07-19T09:20:00+08:00'
}
], [
{
docTitle: '住户服务规范',
content: '先确认需求。',
fragmentId: 7,
spaceCode: 'enterprise-sop'
}
]);
assert.equal(snippets.length, 2);
assert.equal(snippets[0].domain, 'ENTERPRISE');
assert.equal(snippets[0].spaceCode, 'enterprise-sop');
assert.equal(snippets[1].domain, 'PROJECT_SERVICE');
assert.equal(snippets[1].status, 'IN_PROGRESS');
assert.equal(snippets[1].updatedAt, '2026-07-19T09:20:00+08:00');
});
test('我的页面和路由提供首版资料与记忆入口', async () => {
const [profile, memories, pages] = await Promise.all([
read('../src/pages/user/profile/index.vue'),
read('../src/pages/user/assistant/memories.vue'),
read('../src/pages.json')
]);
assert.match(profile, /我的资料与记忆/);
assert.match(profile, /openMemories/);
assert.match(memories, /待确认/);
assert.match(memories, /项目跟进/);
assert.match(memories, /首版仅展示/);
assert.match(memories, /项目:\{\{ item\.projectCode \}\}/);
assert.match(pages, /pages\/user\/assistant\/memories/);
});
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
set -euo pipefail
API_BASE="${API_BASE:-https://wygj-api.localhost}"
TOKEN="${TOKEN:-}"
CLIENT_ID="${CLIENT_ID:-428a8310cd442757ae699df5d894f051}"
fail() {
echo "personal-assistant-smoke: $*" >&2
exit 1
}
request() {
local path="$1"
local body_file
local status
body_file="$(mktemp)"
if [[ -n "$TOKEN" ]]; then
status="$(curl -ksS --max-time 15 -o "$body_file" -w '%{http_code}' \
-H "Authorization: Bearer $TOKEN" \
-H "clientid: $CLIENT_ID" \
"$API_BASE$path")" || {
rm -f "$body_file"
fail "request failed: $path"
}
else
status="$(curl -ksS --max-time 15 -o "$body_file" -w '%{http_code}' "$API_BASE$path")" || {
rm -f "$body_file"
fail "request failed: $path"
}
fi
RESPONSE_BODY="$(tr -d '\n' < "$body_file")"
RESPONSE_STATUS="$status"
rm -f "$body_file"
}
assert_protected_or_success() {
local label="$1"
local path="$2"
request "$path"
if [[ -z "$TOKEN" ]]; then
if [[ "$RESPONSE_STATUS" =~ ^(401|403)$ ]] || printf '%s' "$RESPONSE_BODY" | grep -Eq '"code"[[:space:]]*:[[:space:]]*(401|403)'; then
echo "$label=protected"
return
fi
fail "$label allowed anonymous access (HTTP $RESPONSE_STATUS): $RESPONSE_BODY"
fi
[[ "$RESPONSE_STATUS" == "200" ]] || fail "$label HTTP $RESPONSE_STATUS: $RESPONSE_BODY"
printf '%s' "$RESPONSE_BODY" | grep -Eq '"code"[[:space:]]*:[[:space:]]*200([[:space:]]*[,}])' \
|| fail "$label business response failed: $RESPONSE_BODY"
echo "$label=200"
}
assert_protected_or_success "memory_candidates" "/api/aihr/personal-assistant/memory-candidates?status=DRAFT"
assert_protected_or_success "service_memories" "/api/aihr/service-memories?limit=5"
if [[ -z "$TOKEN" ]]; then
echo "personal-assistant-smoke: authentication boundary passed; set TOKEN to add read-only authenticated checks"
else
echo "personal-assistant-smoke: authenticated read-only checks passed"
fi
+4 -1
View File
@@ -153,6 +153,9 @@ FROM (
UNION ALL SELECT 'aihr_sop_applicability'
UNION ALL SELECT 'aihr_onboard_task'
UNION ALL SELECT 'aihr_qualification_gate'
UNION ALL SELECT 'aihr_memory_candidate'
UNION ALL SELECT 'aihr_service_memory'
UNION ALL SELECT 'aihr_service_memory_version'
) required
LEFT JOIN information_schema.tables actual
ON actual.table_schema = DATABASE() AND actual.table_name = required.table_name
@@ -163,7 +166,7 @@ REMOTE
)" || fail "remote schema check failed: $remote_ssh:$remote_db"
[[ -z "$missing" ]] || fail "remote schema missing required tables: $(printf '%s' "$missing" | tr '\n' ', ' | sed 's/, $//')"
echo "remote_schema=35/35 $remote_ssh:$remote_db"
echo "remote_schema=38/38 $remote_ssh:$remote_db"
}
changed_files="$(git status --porcelain)"
+1
View File
@@ -24,6 +24,7 @@ docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_practice_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_interview_result_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_org_snapshot_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_service_memory_mysql8.sql"
docker exec wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue \
-e "select count(*) as tables_count from information_schema.tables where table_schema = 'ry-vue';"
+10 -1
View File
@@ -185,4 +185,13 @@ test "$candidate_interview_tables" = 'aihr_candidate_employee_link,aihr_candidat
candidate_interview_columns="$(mysql "$LEGACY_DB" -N -B -e "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='$LEGACY_DB' AND ((table_name = 'aihr_candidate_material' AND column_name IN ('reviewer','reviewed_time')) OR (table_name = 'aihr_interview_result' AND column_name IN ('questions_json','reviewed_score','review_status','reviewer','review_note','reviewed_time')));")"
test "$candidate_interview_columns" = '8'
echo "PASS: AIHR legacy schema migrations are idempotent, including practice evidence, knowledge feedback, candidate interview review, and position/SOP qualification contracts"
service_memory_migration="$ROOT_DIR/backend/script/sql/update/aihr_20260719_service_memory_mysql8.sql"
test -f "$service_memory_migration"
mysql "$DB" < "$service_memory_migration"
mysql "$DB" < "$service_memory_migration"
service_memory_tables="$(mysql "$DB" -N -B -e "SELECT GROUP_CONCAT(table_name ORDER BY table_name) FROM information_schema.tables WHERE table_schema='$DB' AND table_name IN ('aihr_memory_candidate','aihr_service_memory','aihr_service_memory_version');")"
test "$service_memory_tables" = 'aihr_memory_candidate,aihr_service_memory,aihr_service_memory_version'
service_memory_version_columns="$(mysql "$DB" -N -B -e "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='$DB' AND ((table_name = 'aihr_memory_candidate' AND column_name IN ('version','confirm_idempotency_key','confirm_request_hash','source_conversation_id','source_request_id')) OR (table_name = 'aihr_service_memory' AND column_name IN ('project_code','version')) OR (table_name = 'aihr_service_memory_version' AND column_name IN ('memory_id','version')));")"
test "$service_memory_version_columns" = '9'
echo "PASS: AIHR legacy schema migrations are idempotent, including practice evidence, knowledge feedback, candidate interview review, position/SOP qualification contracts, and the project service memory slice"