feat(personal-assistant): add confirmed project memory
This commit is contained in:
+28
-3
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+21
@@ -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);
|
||||
|
||||
+94
-5
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
+45
@@ -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));
|
||||
}
|
||||
}
|
||||
+125
@@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
+1199
File diff suppressed because it is too large
Load Diff
+43
@@ -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));
|
||||
}
|
||||
}
|
||||
+209
-7
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
+553
@@ -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;
|
||||
@@ -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`,不得因为记录由本人创建而绕过项目权限。
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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,演示不中断。
|
||||
|
||||
@@ -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
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }));
|
||||
};
|
||||
@@ -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/);
|
||||
});
|
||||
Executable
+62
@@ -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
|
||||
@@ -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)"
|
||||
|
||||
@@ -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';"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user