feat(assistant): unify confirmed memory capture

This commit is contained in:
2026-07-20 02:30:06 +08:00
parent 46a6156d8c
commit 0a5fc9a463
32 changed files with 949 additions and 242 deletions
@@ -59,14 +59,14 @@ public class AihrKnowledgeQueryService {
if (hasText(routed.toolCode())) {
response = queryDataTool(principal, app, routed, request.queryText());
} else {
memoryRecall = "QA".equals(context.intent())
boolean candidateIntent = "QA".equals(context.intent())
&& memoryService.isProjectMemoryCandidateIntent(principal, request.queryText());
memoryRecall = "QA".equals(context.intent()) && !candidateIntent
? 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");
@@ -82,16 +82,19 @@ public class AihrKnowledgeQueryService {
spaceIds = Set.of();
}
if (response == null) {
response = queryDocuments(principal, app, spaceIds, routed, request.queryText());
response = candidateIntent
? emptyProjectResponse(request.queryText())
: queryDocuments(principal, app, spaceIds, routed, request.queryText());
finalizeAudit = candidateIntent;
}
if (!memoryRecall.items().isEmpty()) {
response = mergeServiceMemories(memoryRecall, response);
finalizeAudit = true;
}
}
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());
: memoryService.detectForQuery(principal, request.queryText(), context.conversationId(), response.requestId());
QueryResponse completed = complete(principal, app, spaceIds, request.queryText(), context, response);
QueryResponse result = completed.withMemoryCandidate(candidate);
if (finalizeAudit || candidate != null) {
List<String> sourceTypes = finalSourceTypes(result, memoryRecall, candidate);
@@ -230,12 +233,17 @@ 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()
)));
recall.items().forEach(item -> {
String domain = hasText(item.domain()) ? item.domain() : PROJECT_SERVICE;
String docPrefix = PROJECT_SERVICE.equals(domain) ? "service-memory:" : "assistant-capture:";
citations.add(new Citation(
domain, domain, docPrefix + item.id(), item.title(), item.summary(), null,
domain, item.status(), item.occurredAt(), item.updatedAt()
));
});
LinkedHashSet<String> usedScopes = new LinkedHashSet<>(response.usedSpaceCodes());
usedScopes.add(PROJECT_SERVICE);
recall.items().stream().map(item -> hasText(item.domain()) ? item.domain() : PROJECT_SERVICE)
.forEach(usedScopes::add);
String answer = response.noEvidence() || response.citations().isEmpty()
? recall.answer()
: response.answer() + "\n\n" + recall.answer();
@@ -258,12 +266,13 @@ public class AihrKnowledgeQueryService {
MemoryCandidateResponse candidate) {
LinkedHashSet<String> sourceTypes = new LinkedHashSet<>();
boolean hasDocument = response.citations().stream()
.anyMatch(citation -> !PROJECT_SERVICE.equals(citation.domain()));
.anyMatch(citation -> !Set.of(PROJECT_SERVICE, "PERSONAL", "COMPANY").contains(citation.domain()));
if (hasDocument) {
sourceTypes.add("DOCUMENT");
}
if (recall != null && !recall.items().isEmpty()) {
sourceTypes.add(PROJECT_SERVICE);
recall.items().stream().map(item -> hasText(item.domain()) ? item.domain() : PROJECT_SERVICE)
.forEach(sourceTypes::add);
}
if (candidate != null) {
sourceTypes.add("MEMORY_CANDIDATE");
@@ -5,6 +5,7 @@ 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.aihr.memory.AihrMemoryDto.AssistantCaptureResponse;
import org.dromara.common.core.domain.R;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -42,4 +43,13 @@ public class AihrMemoryController {
public R<MemoryCandidateResponse> dismiss(@PathVariable Long id) {
return R.ok(memoryService.dismiss(id));
}
@SaCheckLogin
@GetMapping("/assistant-captures")
public R<List<AssistantCaptureResponse>> captures(
@RequestParam(value = "saveScope", required = false) String saveScope,
@RequestParam(value = "limit", defaultValue = "50") Integer limit
) {
return R.ok(memoryService.assistantCaptures(saveScope, limit));
}
}
@@ -44,8 +44,13 @@ public final class AihrMemoryDto {
Long expectedVersion,
String idempotencyKey,
MemoryDraft draft,
Boolean enableReminder
Boolean enableReminder,
String saveScope
) {
public ConfirmMemoryRequest(Long expectedVersion, String idempotencyKey, MemoryDraft draft,
Boolean enableReminder) {
this(expectedVersion, idempotencyKey, draft, enableReminder, null);
}
}
public record ConfirmMemoryResponse(
@@ -55,6 +60,24 @@ public final class AihrMemoryDto {
) {
}
public record AssistantCaptureResponse(
Long id,
String saveScope,
String suggestedType,
String projectCode,
String buildingName,
String unitName,
String roomNo,
String category,
String summary,
String detail,
String occurredAt,
String followUpAt,
String deliveryStatus,
String createdAt
) {
}
public record ServiceMemoryResponse(
Long id,
String projectCode,
@@ -119,7 +142,12 @@ public final class AihrMemoryDto {
String summary,
String status,
String occurredAt,
String updatedAt
String updatedAt,
String domain
) {
public ServiceMemoryRecallItem(Long id, String projectCode, String title, String summary,
String status, String occurredAt, String updatedAt) {
this(id, projectCode, title, summary, status, occurredAt, updatedAt, "PROJECT_SERVICE");
}
}
}
@@ -8,6 +8,7 @@ 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.ConfirmMemoryResponse;
import org.dromara.aihr.memory.AihrMemoryDto.AssistantCaptureResponse;
import org.dromara.aihr.memory.AihrMemoryDto.MemoryCandidateResponse;
import org.dromara.aihr.memory.AihrMemoryDto.MemoryDraft;
import org.dromara.aihr.memory.AihrMemoryDto.ServiceMemoryRecall;
@@ -57,6 +58,8 @@ import java.util.regex.Pattern;
public class AihrMemoryService {
private static final String PROJECT_SERVICE = "PROJECT_SERVICE";
private static final String ASSISTANT_CAPTURE = "ASSISTANT_CAPTURE";
private static final Set<String> CAPTURE_SCOPES = Set.of("PRIVATE", "COMPANY");
private static final Set<String> CANDIDATE_STATUSES = Set.of("DRAFT", "NEEDS_INPUT");
private static final Set<String> MEMORY_STATUSES = Set.of("OPEN", "IN_PROGRESS", "RESOLVED", "VOID");
private static final Pattern IDEMPOTENCY_KEY = Pattern.compile("[A-Za-z0-9._:-]{8,100}");
@@ -64,6 +67,8 @@ public class AihrMemoryService {
private static final Pattern UNIT = Pattern.compile("([0-9一二三四五六七八九十]{1,4})单元");
private static final Pattern ROOM = Pattern.compile(
"(?:(?:房号|房间|室号)\\s*[::]?\\s*([A-Za-z0-9-]{2,12})|(?<![0-9])([0-9]{3,6})\\s*(?:室|户))");
private static final Pattern CONTEXTUAL_ROOM = Pattern.compile(
"(?:单元|栋|幢|号楼)\\s*([0-9]{3,6})(?![0-9])(?!\\s*(?:年|月|日|分钟|小时|元|块|人|次))");
private static final Pattern BUILDING_VALUE = Pattern.compile(
"[A-Za-z0-9一二三四五六七八九十-]{1,8}(?:栋|幢|号楼)");
private static final Pattern UNIT_VALUE = Pattern.compile(
@@ -79,7 +84,8 @@ public class AihrMemoryService {
+ "病史|病情|用药|过敏|诊断|确诊|患有|患者|疾病|住院|手术|慢性病|"
+ "行动不便|失能|半失能|卧床|轮椅|认知障碍");
private static final Set<String> RECALL_KEYWORDS = Set.of(
"维修", "投诉", "养老", "电梯", "保洁", "门禁", "漏水", "噪音", "停车", "装修", "回访", "跟进");
"维修", "投诉", "养老", "电梯", "保洁", "门禁", "漏水", "噪音", "停车", "装修", "回访", "跟进",
"家政", "助餐", "陪诊", "代购", "想法", "线索", "案例");
private static final TypeReference<List<String>> STRING_LIST = new TypeReference<>() { };
private static final TypeReference<Map<String, Object>> SNAPSHOT_MAP = new TypeReference<>() { };
@@ -104,6 +110,9 @@ public class AihrMemoryService {
}
return saveCandidate(principal, plan, conversationId, requestId);
} catch (RuntimeException ex) {
if (detectPlan(principal, queryText, LocalDateTime.now().withNano(0)) != null) {
throw new ServiceException("记忆功能暂不可用,请稍后重试", 503);
}
log.warn("service memory candidate unavailable; query answer remains usable");
return null;
}
@@ -153,13 +162,20 @@ public class AihrMemoryService {
normalized = copyWithProjectCode(normalized, candidate.projectCode());
}
boolean enableReminder = Boolean.TRUE.equals(request.enableReminder());
String saveScope = upper(request.saveScope());
if (ASSISTANT_CAPTURE.equals(candidate.targetDomain())) {
saveScope = saveScope.isEmpty() ? "COMPANY" : saveScope;
if (!CAPTURE_SCOPES.contains(saveScope)) {
throw badRequest("saveScope 仅支持 PRIVATE/COMPANY");
}
}
String requestHash = hash(new ConfirmFingerprint(
candidateId, request.expectedVersion(), normalized, enableReminder));
candidateId, request.expectedVersion(), normalized, enableReminder, saveScope));
if ("CONFIRMED".equals(candidate.status())) {
if (idempotencyKey.equals(candidate.confirmIdempotencyKey())
&& requestHash.equals(candidate.confirmRequestHash())) {
return new ConfirmMemoryResponse(PROJECT_SERVICE, candidate.targetId(), candidate.targetVersion());
return new ConfirmMemoryResponse(candidate.targetDomain(), candidate.targetId(), candidate.targetVersion());
}
throw conflict("幂等键或确认内容与原请求不一致");
}
@@ -174,6 +190,10 @@ public class AihrMemoryService {
if (candidate.version() != request.expectedVersion().intValue()) {
throw conflict("记忆候选已更新,请刷新后重试");
}
if (ASSISTANT_CAPTURE.equals(candidate.targetDomain())) {
return confirmAssistantCapture(principal, candidate, normalized, request, saveScope,
idempotencyKey, requestHash);
}
if (!PROJECT_SERVICE.equals(candidate.targetDomain())
|| !Set.of("RESIDENT_NEED", "FOLLOW_UP").contains(candidate.memoryType())) {
throw conflict("当前候选不属于项目服务记忆");
@@ -224,6 +244,41 @@ public class AihrMemoryService {
return new ConfirmMemoryResponse(PROJECT_SERVICE, memoryId, 1);
}
public List<AssistantCaptureResponse> assistantCaptures(String rawSaveScope, Integer requestedLimit) {
AihrKnowledgePrincipal principal = principalResolver.current();
String saveScope = upper(rawSaveScope);
if (!saveScope.isEmpty() && !CAPTURE_SCOPES.contains(saveScope)) {
throw badRequest("saveScope 仅支持 PRIVATE/COMPANY");
}
int limit = requestedLimit == null ? 50 : requestedLimit;
if (limit < 1 || limit > 100) {
throw badRequest("limit 仅支持 1-100");
}
String scopeClause = saveScope.isEmpty() ? "" : " and save_scope = ?";
List<Object> args = new ArrayList<>(List.of(principal.tenantId(), principal.userId()));
if (!saveScope.isEmpty()) args.add(saveScope);
args.add(limit);
try {
return jdbcTemplate.query("""
select id, save_scope, suggested_type, project_code, building_name, unit_name, room_no,
category, summary, detail, occurred_at, follow_up_at, delivery_status, create_time
from aihr_assistant_capture
where tenant_id = ? and owner_user_id = ?%s
order by create_time desc, id desc
limit ?
""".formatted(scopeClause), (rs, rowNum) -> new AssistantCaptureResponse(
rs.getLong("id"), rs.getString("save_scope"), rs.getString("suggested_type"),
rs.getString("project_code"), rs.getString("building_name"), rs.getString("unit_name"),
rs.getString("room_no"), rs.getString("category"), rs.getString("summary"),
rs.getString("detail"), format(rs.getTimestamp("occurred_at")),
format(rs.getTimestamp("follow_up_at")),
rs.getString("delivery_status"), format(rs.getTimestamp("create_time"))
), args.toArray());
} catch (DataAccessException ex) {
throw new ServiceException("统一采集数据库迁移未执行", 503);
}
}
@Transactional(rollbackFor = Exception.class)
public MemoryCandidateResponse dismiss(Long candidateId) {
AihrKnowledgePrincipal principal = principalResolver.current();
@@ -348,69 +403,126 @@ public class AihrMemoryService {
return copyWithVersions(row, versions);
}
/** Query-path recall is read-only and fails closed when project scope is absent. */
/** Query-path recall is read-only. Personal captures are owner-scoped; shared records are project-scoped. */
public ServiceMemoryRecall recall(AihrKnowledgePrincipal principal, String rawQuery, Integer requestedLimit) {
if (principal == null || principal.projectCodes().isEmpty() || !looksLikeMemoryQuery(rawQuery)) {
if (principal == null || !looksLikeMemoryQuery(rawQuery)) {
return ServiceMemoryRecall.empty();
}
int limit = Math.min(5, Math.max(1, requestedLimit == null ? 5 : requestedLimit));
List<String> terms = recallTerms(rawQuery);
List<ServiceMemoryRecallItem> items = new ArrayList<>();
try {
String projectCode = resolveProjectCode(principal.projectCodes(), rawQuery);
if (!hasText(projectCode)) {
return ServiceMemoryRecall.empty();
items.addAll(recallAssistantCaptures(principal, terms, limit));
} catch (DataAccessException ex) {
log.warn("assistant capture recall unavailable; project memory recall continues");
}
String projectCode = resolveProjectCode(principal.projectCodes(), rawQuery);
if (hasText(projectCode) && !terms.isEmpty()) {
try {
items.addAll(recallProjectMemories(principal, projectCode, terms, limit));
} catch (DataAccessException ex) {
log.warn("project memory recall unavailable; personal recall continues");
}
List<String> projectCodes = List.of(projectCode);
int limit = Math.min(5, Math.max(1, requestedLimit == null ? 5 : requestedLimit));
List<String> terms = recallTerms(rawQuery);
if (terms.isEmpty()) {
return ServiceMemoryRecall.empty();
}
StringBuilder sql = new StringBuilder("""
select id, project_code, title, summary, status, occurred_at, update_time
from aihr_service_memory
where tenant_id = ? and project_code in (%s) and status <> 'VOID'
""".formatted(placeholders(projectCodes.size())));
List<Object> args = new ArrayList<>();
args.add(principal.tenantId());
args.addAll(projectCodes);
// ponytail: bounded LIKE recall is enough for the first relational slice; add FTS/Qdrant after corpus growth.
}
items.sort(java.util.Comparator.comparing(
ServiceMemoryRecallItem::occurredAt,
java.util.Comparator.nullsLast(java.util.Comparator.reverseOrder())
));
if (items.size() > limit) {
items = new ArrayList<>(items.subList(0, limit));
}
return items.isEmpty() ? ServiceMemoryRecall.empty() : new ServiceMemoryRecall(items, recallAnswer(items));
}
private List<ServiceMemoryRecallItem> recallAssistantCaptures(AihrKnowledgePrincipal principal,
List<String> terms, int limit) {
StringBuilder sql = new StringBuilder("""
select id, project_code, save_scope, category, building_name, unit_name, room_no,
summary, delivery_status, occurred_at, update_time
from aihr_assistant_capture
where tenant_id = ? and owner_user_id = ?
""");
List<Object> args = new ArrayList<>(List.of(principal.tenantId(), principal.userId()));
appendRecallLike(sql, args, terms,
"concat_ws(' ', building_name, unit_name, room_no, category, summary, detail)");
sql.append(" order by occurred_at desc, id desc limit ?");
args.add(limit);
return jdbcTemplate.query(sql.toString(), (rs, rowNum) -> {
String location = clean(rs.getString("building_name")) + clean(rs.getString("unit_name"))
+ clean(rs.getString("room_no"));
String category = clean(rs.getString("category"));
String scope = rs.getString("save_scope");
return new ServiceMemoryRecallItem(
rs.getLong("id"), rs.getString("project_code"),
category + (location.isEmpty() ? "" : ":" + location), rs.getString("summary"),
rs.getString("delivery_status"), format(rs.getTimestamp("occurred_at")),
format(rs.getTimestamp("update_time")), "PRIVATE".equals(scope) ? "PERSONAL" : "COMPANY"
);
}, args.toArray());
}
private List<ServiceMemoryRecallItem> recallProjectMemories(AihrKnowledgePrincipal principal,
String projectCode, List<String> terms, int limit) {
StringBuilder sql = new StringBuilder("""
select id, project_code, title, summary, status, occurred_at, update_time
from aihr_service_memory
where tenant_id = ? and project_code = ? and status <> 'VOID'
""");
List<Object> args = new ArrayList<>(List.of(principal.tenantId(), projectCode));
appendRecallLike(sql, args, terms,
"concat_ws(' ', building_name, unit_name, room_no, category, title, summary, detail)");
sql.append(" order by occurred_at desc, id desc limit ?");
args.add(limit);
return jdbcTemplate.query(sql.toString(), (rs, rowNum) ->
new ServiceMemoryRecallItem(
rs.getLong("id"), rs.getString("project_code"), rs.getString("title"),
rs.getString("summary"), rs.getString("status"), format(rs.getTimestamp("occurred_at")),
format(rs.getTimestamp("update_time")), PROJECT_SERVICE
), args.toArray());
}
private static void appendRecallLike(StringBuilder sql, List<Object> args, List<String> terms,
String searchableExpression) {
if (terms.isEmpty()) {
return;
}
List<String> locations = terms.stream().filter(AihrMemoryService::isLocationRecallTerm).toList();
List<String> keywords = terms.stream().filter(term -> !isLocationRecallTerm(term)).toList();
for (String location : locations) {
sql.append(" and ").append(searchableExpression).append(" like ?");
args.add("%" + location + "%");
}
if (!keywords.isEmpty()) {
sql.append(" and (");
for (int i = 0; i < terms.size(); i++) {
if (i > 0) {
sql.append(" or ");
}
sql.append("concat_ws(' ', building_name, unit_name, room_no, category, title, summary, detail) like ?");
args.add("%" + terms.get(i) + "%");
for (int i = 0; i < keywords.size(); i++) {
if (i > 0) sql.append(" or ");
sql.append(searchableExpression).append(" like ?");
args.add("%" + keywords.get(i) + "%");
}
sql.append(")");
sql.append(" order by occurred_at desc, id desc limit ?");
args.add(limit);
List<ServiceMemoryRecallItem> items = jdbcTemplate.query(sql.toString(), (rs, rowNum) ->
new ServiceMemoryRecallItem(
rs.getLong("id"), rs.getString("project_code"), rs.getString("title"),
rs.getString("summary"), rs.getString("status"), format(rs.getTimestamp("occurred_at")),
format(rs.getTimestamp("update_time"))
), args.toArray());
return items.isEmpty() ? ServiceMemoryRecall.empty() : new ServiceMemoryRecall(items, recallAnswer(items));
} catch (DataAccessException ex) {
log.warn("service memory recall unavailable; enterprise knowledge query continues");
return ServiceMemoryRecall.empty();
}
}
private static boolean isLocationRecallTerm(String term) {
String value = clean(term);
return BUILDING_VALUE.matcher(value).matches()
|| UNIT_VALUE.matcher(value).matches()
|| ROOM_VALUE.matcher(value).matches();
}
static CandidatePlan detectPlan(AihrKnowledgePrincipal principal, String rawQuery, LocalDateTime now) {
String query = clean(rawQuery);
if (query.isEmpty() || query.length() > 1000) {
return null;
}
boolean explicit = containsAny(query, "记一下", "记录一下", "帮我记", "保存一下", "备忘");
boolean resident = containsAny(query, "住户", "业主", "房号") || (BUILDING.matcher(query).find() && UNIT.matcher(query).find());
boolean resident = containsAny(query, "住户", "业主", "房号") || BUILDING.matcher(query).find();
boolean actionable = containsAny(query, "需要", "希望", "想要", "要求", "咨询", "报修", "投诉", "跟进", "回访", "联系", "处理", "提醒");
if (!actionable || (!explicit && !resident) || (!explicit && looksLikeQuestion(query))) {
if ((!explicit && (!actionable || !resident)) || (!explicit && looksLikeQuestion(query))) {
return null;
}
String memoryType = containsAny(query, "跟进", "回访", "联系", "提醒", "后续处理")
? "FOLLOW_UP" : "RESIDENT_NEED";
String memoryType = suggestedType(query);
String projectCode = resolveProjectCode(principal == null ? Set.of() : principal.projectCodes(), query);
String building = match(BUILDING, query, 0);
String unit = match(UNIT, query, 0);
@@ -434,20 +546,20 @@ public class AihrMemoryService {
summary = summary.replace(location, "");
}
}
summary = truncate(redactMemoryText(summary).replaceAll("\\s+", " ").trim(), 1000);
summary = truncate(redactMemoryText(stripCaptureCommand(summary)).replaceAll("\\s+", " ").trim(), 1000);
if (summary.isEmpty()) {
missing.add("summary");
}
LocalDateTime occurredAt = (now == null ? LocalDateTime.now() : now).withNano(0);
String category = suggestedCategory(memoryType);
MemoryDraft draft = new MemoryDraft(
projectCode, building, unit, room,
"FOLLOW_UP".equals(memoryType) ? "待跟进" : "住户需求",
category,
defaultTitle("", memoryType, new MemoryDraft(projectCode, building, unit, room,
"", "", summary, "", "", format(occurredAt), null)),
category, "", summary, "", "", format(occurredAt), null)),
summary, "", "FOLLOW_UP".equals(memoryType) ? "IN_PROGRESS" : "OPEN",
format(occurredAt), null);
String status = missing.isEmpty() ? "DRAFT" : "NEEDS_INPUT";
return new CandidatePlan(projectCode, memoryType, status, draft, List.copyOf(missing), explicit ? 0.92 : 0.78);
return new CandidatePlan(projectCode, memoryType, "DRAFT", draft, List.copyOf(missing), explicit ? 0.92 : 0.78);
}
static CandidatePlan continuePlan(AihrKnowledgePrincipal principal, String memoryType, String existingProjectCode,
@@ -456,23 +568,33 @@ public class AihrMemoryService {
return null;
}
String query = clean(rawQuery);
if (looksLikeQuestion(query) || detectPlan(principal, query, LocalDateTime.now().withNano(0)) != null) {
if (looksLikeQuestion(query) || containsAny(query, "另外记", "再记一条", "新的一条", "另记一条")) {
return null;
}
CandidatePlan fresh = detectPlan(principal, query, LocalDateTime.now().withNano(0));
if (fresh != null && !sameCapture(memoryType, existing, fresh)) {
return null;
}
String resolvedProject = hasText(existingProjectCode) ? existingProjectCode
: resolveProjectCode(principal == null ? Set.of() : principal.projectCodes(), query);
String building = hasText(existing.buildingName()) ? existing.buildingName() : match(BUILDING, query, 0);
String unit = hasText(existing.unitName()) ? existing.unitName() : match(UNIT, query, 0);
String room = hasText(existing.roomNo()) ? existing.roomNo() : matchRoom(query);
String buildingFromQuery = match(BUILDING, query, 0);
String unitFromQuery = match(UNIT, query, 0);
String roomFromQuery = matchRoom(query);
String building = hasText(buildingFromQuery) ? buildingFromQuery : existing.buildingName();
String unit = hasText(unitFromQuery) ? unitFromQuery : existing.unitName();
String room = hasText(roomFromQuery) ? roomFromQuery : existing.roomNo();
String summary = clean(existing.summary());
if (summary.isEmpty() && containsAny(query, "需要", "希望", "想要", "要求", "咨询", "报修", "投诉", "跟进", "回访")) {
summary = truncate(redactMemoryText(query), 1000);
if (fresh != null && hasText(fresh.draft().summary())) {
summary = fresh.draft().summary();
} else if (summary.isEmpty() && containsAny(query, "需要", "希望", "想要", "要求", "咨询", "报修", "投诉", "跟进", "回访")) {
summary = truncate(redactMemoryText(stripCaptureCommand(query)), 1000);
}
boolean progressed = (!hasText(existingProjectCode) && hasText(resolvedProject))
|| (!hasText(existing.buildingName()) && hasText(building))
|| (!hasText(existing.unitName()) && hasText(unit))
|| (!hasText(existing.roomNo()) && hasText(room))
|| (!hasText(existing.summary()) && hasText(summary));
|| (!hasText(existing.summary()) && hasText(summary))
|| fresh != null;
if (!progressed) {
return null;
}
@@ -491,7 +613,7 @@ public class AihrMemoryService {
resolvedProject, building, unit, room, defaultCategory(existing.category(), memoryType), title,
summary, existing.detail(), initialStatus(existing.status(), memoryType),
existing.occurredAt(), existing.followUpAt());
return new CandidatePlan(resolvedProject, memoryType, missing.isEmpty() ? "DRAFT" : "NEEDS_INPUT",
return new CandidatePlan(resolvedProject, fresh == null ? memoryType : fresh.memoryType(), "DRAFT",
merged, List.copyOf(missing), 0.92);
}
@@ -505,7 +627,7 @@ public class AihrMemoryService {
select id, project_code, memory_type, draft_json, version, expires_at
from aihr_memory_candidate
where tenant_id = ? and owner_user_id = ? and source_conversation_id = ?
and status = 'NEEDS_INPUT' and expires_at > now()
and status in ('DRAFT', 'NEEDS_INPUT') and expires_at > now()
order by update_time desc, id desc
limit 1
""", (rs, rowNum) -> new ContinuationRow(
@@ -527,7 +649,7 @@ public class AihrMemoryService {
update aihr_memory_candidate
set project_code = ?, status = ?, draft_json = ?, missing_fields_json = ?,
candidate_hash = ?, version = version + 1, update_time = now()
where tenant_id = ? and owner_user_id = ? and id = ? and status = 'NEEDS_INPUT' and version = ?
where tenant_id = ? and owner_user_id = ? and id = ? and status in ('DRAFT', 'NEEDS_INPUT') and version = ?
""", hasText(merged.projectCode()) ? merged.projectCode() : null, merged.status(),
json(merged.draft()), json(merged.missingFields()), candidateHash,
principal.tenantId(), principal.userId(), row.id(), row.version());
@@ -535,7 +657,7 @@ public class AihrMemoryService {
return null;
}
return new MemoryCandidateResponse(
row.id(), row.version() + 1, merged.status(), merged.memoryType(), PROJECT_SERVICE,
row.id(), row.version() + 1, merged.status(), merged.memoryType(), ASSISTANT_CAPTURE,
merged.draft(), merged.missingFields(), format(row.expiresAt()));
}
@@ -553,7 +675,7 @@ public class AihrMemoryService {
(tenant_id, owner_user_id, source_conversation_id, source_request_id, candidate_hash,
project_code, memory_type, target_domain, status, draft_json, missing_fields_json,
confidence, version, expires_at, create_time, update_time)
values (?, ?, ?, ?, ?, ?, ?, 'PROJECT_SERVICE', ?, ?, ?, ?, 1, ?, now(), now())
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, now(), now())
""", Statement.RETURN_GENERATED_KEYS);
statement.setString(1, principal.tenantId());
statement.setLong(2, principal.userId());
@@ -566,11 +688,12 @@ public class AihrMemoryService {
statement.setNull(6, Types.VARCHAR);
}
statement.setString(7, plan.memoryType());
statement.setString(8, plan.status());
statement.setString(9, json(plan.draft()));
statement.setString(10, json(plan.missingFields()));
statement.setDouble(11, plan.confidence());
statement.setTimestamp(12, Timestamp.valueOf(expiresAt));
statement.setString(8, ASSISTANT_CAPTURE);
statement.setString(9, plan.status());
statement.setString(10, json(plan.draft()));
statement.setString(11, json(plan.missingFields()));
statement.setDouble(12, plan.confidence());
statement.setTimestamp(13, Timestamp.valueOf(expiresAt));
return statement;
}, key);
} catch (DuplicateKeyException duplicate) {
@@ -580,7 +703,7 @@ public class AihrMemoryService {
if (id == null) {
throw new ServiceException("创建记忆候选失败", 500);
}
return new MemoryCandidateResponse(id.longValue(), 1, plan.status(), plan.memoryType(), PROJECT_SERVICE,
return new MemoryCandidateResponse(id.longValue(), 1, plan.status(), plan.memoryType(), ASSISTANT_CAPTURE,
plan.draft(), plan.missingFields(), format(expiresAt));
}
@@ -614,6 +737,90 @@ public class AihrMemoryService {
return rows.get(0);
}
private ConfirmMemoryResponse confirmAssistantCapture(AihrKnowledgePrincipal principal, CandidateRow candidate,
MemoryDraft draft, ConfirmMemoryRequest request,
String saveScope, String idempotencyKey,
String requestHash) {
if (!hasText(draft.summary())) {
throw badRequest("summary 必填");
}
String projectCode = clean(draft.projectCode());
if (hasText(projectCode) && !principal.projectCodes().contains(projectCode)) {
throw new ServiceException("所属项目不存在或无权访问", HttpStatus.FORBIDDEN);
}
LocalDateTime occurredAt = parseDateTime(draft.occurredAt(), "occurredAt", false);
LocalDateTime followUpAt = Boolean.TRUE.equals(request.enableReminder())
? parseDateTime(draft.followUpAt(), "followUpAt", false)
: null;
String targetDomain = "PRIVATE".equals(saveScope) ? "PERSONAL" : "COMPANY";
String deliveryStatus = "PRIVATE".equals(saveScope) ? "NOT_REQUIRED" : "PENDING";
long captureId;
try {
captureId = insertAssistantCapture(principal, candidate, draft, projectCode, saveScope,
deliveryStatus, occurredAt, followUpAt, idempotencyKey, requestHash);
int updated = jdbcTemplate.update("""
update aihr_memory_candidate
set status = 'CONFIRMED', target_domain = ?, target_id = ?, target_version = 1,
version = version + 1, confirm_idempotency_key = ?, confirm_request_hash = ?,
confirmed_at = now(), draft_json = null, missing_fields_json = null, update_time = now()
where tenant_id = ? and owner_user_id = ? and id = ? and status = 'DRAFT'
and target_domain = 'ASSISTANT_CAPTURE' and version = ?
""", targetDomain, captureId, idempotencyKey, requestHash, principal.tenantId(),
principal.userId(), candidate.id(), request.expectedVersion());
if (updated != 1) throw conflict("确认卡已更新,请刷新后重试");
} catch (DuplicateKeyException ex) {
throw conflict("该 idempotencyKey 已用于其他确认请求");
} catch (DataAccessException ex) {
throw new ServiceException("统一采集数据库迁移未执行", 503);
}
return new ConfirmMemoryResponse(targetDomain, captureId, 1);
}
private long insertAssistantCapture(AihrKnowledgePrincipal principal, CandidateRow candidate, MemoryDraft draft,
String projectCode, String saveScope, String deliveryStatus,
LocalDateTime occurredAt, LocalDateTime followUpAt,
String idempotencyKey, String requestHash) {
KeyHolder key = new GeneratedKeyHolder();
jdbcTemplate.update(connection -> {
PreparedStatement statement = connection.prepareStatement("""
insert into aihr_assistant_capture
(tenant_id, owner_user_id, project_code, save_scope, suggested_type,
building_name, unit_name, room_no, category, summary, detail, occurred_at,
follow_up_at, source_conversation_id, source_request_id, delivery_status,
confirm_idempotency_key, confirm_request_hash, version, create_time, update_time)
select tenant_id, owner_user_id, ?, ?, memory_type, ?, ?, ?, ?, ?, ?, ?, ?,
source_conversation_id, source_request_id, ?, ?, ?, 1, now(), now()
from aihr_memory_candidate
where tenant_id = ? and owner_user_id = ? and id = ? and status = 'DRAFT'
""", Statement.RETURN_GENERATED_KEYS);
if (hasText(projectCode)) statement.setString(1, projectCode); else statement.setNull(1, Types.VARCHAR);
statement.setString(2, saveScope);
nullable(statement, 3, draft.buildingName());
nullable(statement, 4, draft.unitName());
nullable(statement, 5, draft.roomNo());
statement.setString(6, defaultCategory(draft.category(), candidate.memoryType()));
statement.setString(7, draft.summary());
nullable(statement, 8, draft.detail());
statement.setTimestamp(9, Timestamp.valueOf(occurredAt));
if (followUpAt == null) statement.setNull(10, Types.TIMESTAMP);
else statement.setTimestamp(10, Timestamp.valueOf(followUpAt));
statement.setString(11, deliveryStatus);
statement.setString(12, idempotencyKey);
statement.setString(13, requestHash);
statement.setString(14, principal.tenantId());
statement.setLong(15, principal.userId());
statement.setLong(16, candidate.id());
return statement;
}, key);
Number id = key.getKey();
if (id == null) throw conflict("确认卡状态已变化");
return id.longValue();
}
private static void nullable(PreparedStatement statement, int index, String value) throws SQLException {
if (hasText(value)) statement.setString(index, value); else statement.setNull(index, Types.VARCHAR);
}
private long insertServiceMemory(AihrKnowledgePrincipal principal, CandidateRow candidate, MemoryDraft draft,
LocalDateTime occurredAt, LocalDateTime followUpAt) {
KeyHolder key = new GeneratedKeyHolder();
@@ -776,7 +983,7 @@ public class AihrMemoryService {
}
private static String defaultCategory(String category, String memoryType) {
return hasText(category) ? category : ("FOLLOW_UP".equals(memoryType) ? "待跟进" : "住户需求");
return hasText(category) ? category : suggestedCategory(memoryType);
}
private static String defaultTitle(String title, String memoryType, MemoryDraft draft) {
@@ -784,10 +991,51 @@ public class AihrMemoryService {
return title;
}
String location = clean(draft.buildingName()) + clean(draft.unitName()) + clean(draft.roomNo());
return ("FOLLOW_UP".equals(memoryType) ? "跟进事项:" : "住户需求:")
return suggestedCategory(memoryType) + ":"
+ (location.isEmpty() ? "待补充位置" : location);
}
private static String suggestedType(String query) {
if (containsAny(query, "已解决", "处理完成", "已经恢复", "成功处理", "优秀案例", "经验案例")) {
return "CASE";
}
if (containsAny(query, "我的想法", "我有个想法", "灵感", "心得", "个人经验", "我认为")) {
return "PERSONAL_NOTE";
}
if (containsAny(query, "用户画像", "住户画像", "独居", "空巢", "高龄", "老奶奶", "老爷爷")
|| Pattern.compile("[0-9一二三四五六七八九十百]{1,3}岁").matcher(query).find()) {
return "RESIDENT_PROFILE";
}
if (containsAny(query, "养老", "助餐", "家政", "陪诊", "代购", "上门服务", "保洁服务")) {
return "SERVICE_LEAD";
}
if (containsAny(query, "跟进", "回访", "联系", "提醒", "后续处理")) {
return "FOLLOW_UP";
}
return "PROJECT_NOTE";
}
private static String suggestedCategory(String memoryType) {
return switch (memoryType) {
case "SERVICE_LEAD" -> "服务线索";
case "RESIDENT_PROFILE" -> "住户画像";
case "CASE" -> "优秀案例";
case "PERSONAL_NOTE" -> "个人想法";
case "FOLLOW_UP" -> "待跟进事项";
default -> "工作记录";
};
}
private static boolean sameCapture(String existingType, MemoryDraft existing, CandidatePlan fresh) {
if (!clean(existingType).equals(fresh.memoryType())) return false;
String oldBuilding = clean(existing.buildingName());
String newBuilding = clean(fresh.draft().buildingName());
if (!oldBuilding.isEmpty() && !newBuilding.isEmpty() && !oldBuilding.equals(newBuilding)) return false;
String oldRoom = clean(existing.roomNo());
String newRoom = clean(fresh.draft().roomNo());
return oldRoom.isEmpty() || newRoom.isEmpty() || oldRoom.equals(newRoom) || !hasText(existing.unitName());
}
/**
* The shared/searchable record never copies client free text. It is rebuilt from validated location fields and a
* closed service taxonomy, so an unrecognised name or health phrase cannot enter FULLTEXT even if a detector misses.
@@ -861,7 +1109,7 @@ public class AihrMemoryService {
return false;
}
boolean domainTerm = containsAny(value, "住户", "业主", "房号", "项目记录", "服务记录", "需求",
"跟进", "回访", "维修", "投诉", "养老", "漏水", "门禁", "停车")
"跟进", "回访", "维修", "投诉", "养老", "漏水", "门禁", "停车", "保洁", "想法", "线索", "案例")
|| BUILDING.matcher(value).find();
boolean completeLocation = BUILDING.matcher(value).find()
&& UNIT.matcher(value).find()
@@ -877,15 +1125,27 @@ public class AihrMemoryService {
}
private static String recallAnswer(List<ServiceMemoryRecallItem> items) {
StringBuilder answer = new StringBuilder("已找到当前项目权限内的相关记录:");
StringBuilder answer = new StringBuilder("已找到你有权限查看的相关记录:");
for (ServiceMemoryRecallItem item : items) {
answer.append("\n- [").append(statusLabel(item.status())).append("] ")
answer.append("\n- [").append(recallStatusLabel(item)).append("] ")
.append(item.title()).append(":").append(item.summary())
.append("(").append(displayDateTime(item.occurredAt())).append(")");
}
return answer.toString();
}
private static String recallStatusLabel(ServiceMemoryRecallItem item) {
return switch (clean(item.domain())) {
case "PERSONAL" -> "仅自己";
case "COMPANY" -> switch (clean(item.status())) {
case "DELIVERED" -> "公司已流转";
case "FAILED" -> "公司流转待重试";
default -> "公司待流转";
};
default -> statusLabel(item.status());
};
}
private static String statusLabel(String status) {
return switch (clean(status)) {
case "OPEN" -> "待处理";
@@ -1092,6 +1352,13 @@ public class AihrMemoryService {
return NAMED_RESIDENT.matcher(redacted).replaceAll("匿名住户");
}
private static String stripCaptureCommand(String value) {
return clean(value).replaceFirst(
"^(?:请|麻烦)?(?:帮我)?(?:记一下|记录一下|保存一下|备忘)(?:这件事)?[::,,、\\s]*",
""
);
}
private static String match(Pattern pattern, String text, int group) {
Matcher matcher = pattern.matcher(text);
return matcher.find() ? clean(matcher.group(group)) : "";
@@ -1099,10 +1366,9 @@ public class AihrMemoryService {
private static String matchRoom(String text) {
Matcher matcher = ROOM.matcher(text);
if (!matcher.find()) {
return "";
}
return clean(matcher.group(1) == null ? matcher.group(2) : matcher.group(1));
if (matcher.find()) return clean(matcher.group(1) == null ? matcher.group(2) : matcher.group(1));
Matcher contextual = CONTEXTUAL_ROOM.matcher(text);
return contextual.find() ? clean(contextual.group(1)) : "";
}
private static boolean containsAny(String value, String... needles) {
@@ -1184,7 +1450,7 @@ public class AihrMemoryService {
}
private record ConfirmFingerprint(Long candidateId, Long expectedVersion, MemoryDraft draft,
boolean enableReminder) {
boolean enableReminder, String saveScope) {
}
private record ContinuationRow(
@@ -252,7 +252,7 @@ class AihrKnowledgeQueryServiceTest {
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(),
31L, 1, "DRAFT", "SERVICE_LEAD", "ASSISTANT_CAPTURE", draft, List.of(),
"2026-07-20T08:30:00");
when(resolver.current()).thenReturn(principal);
when(appService.requireSessionApp("000000", "app")).thenReturn(app);
@@ -262,6 +262,8 @@ class AihrKnowledgeQueryServiceTest {
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.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, jdbc, mock(AihrKnowledgeDataToolService.class),
@@ -271,12 +273,15 @@ class AihrKnowledgeQueryServiceTest {
"记一下,3栋2单元1201室住户希望了解养老服务", List.of(), "sop", null, "mobile", 5, null));
assertEquals(candidate, result.memoryCandidate());
assertTrue(result.answer().isEmpty());
assertTrue(result.citations().isEmpty());
verify(sop, never()).searchAuthorized(any(), any(), any());
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"));
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
@@ -429,6 +434,42 @@ class AihrKnowledgeQueryServiceTest {
eq("service-memory-v1"));
}
@Test
void ownerScopedAssistantCaptureCanBeRecalledWithoutKnowledgeSpace() {
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(
601L, "P1", "服务线索:三栋3203", "需要保洁服务。", "NOT_REQUIRED",
"2026-07-20T01:13:00", "2026-07-20T01:13:00", "PERSONAL");
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(
"查一下之前三栋3203的保洁记录", List.of(), "sop", null, "mobile", 5, null));
assertFalse(result.noEvidence());
assertTrue(result.answer().contains("需要保洁服务"));
assertEquals("PERSONAL", result.citations().get(0).domain());
assertEquals("assistant-capture:601", result.citations().get(0).docId());
assertEquals(List.of("PERSONAL"), result.usedSpaceCodes());
verify(audit).recordFinal(eq(result.requestId()), eq(principal), eq(app), anyString(),
eq(List.of("PERSONAL")), eq(List.of("PERSONAL")), eq("SUCCESS"), anyLong(),
eq("service-memory-v1"));
}
@Test
void resourceDownloadRecomputesCurrentApplicationAndPrincipalScope() {
var resolver = mock(AihrKnowledgePrincipalResolver.class);
@@ -39,13 +39,13 @@ class AihrMemoryServiceTest {
private static final LocalDateTime NOW = LocalDateTime.of(2026, 7, 19, 8, 30);
@Test
void singleProjectCompleteResidentNeedBecomesDraftWithoutInventingIdentity() {
void singleProjectCompleteServiceLeadBecomesDraftWithoutInventingIdentity() {
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("SERVICE_LEAD", plan.memoryType());
assertEquals("P1", plan.projectCode());
assertEquals("3栋", plan.draft().buildingName());
assertEquals("2单元", plan.draft().unitName());
@@ -55,7 +55,7 @@ class AihrMemoryServiceTest {
}
@Test
void emptyOrAmbiguousProjectScopeFailsClosedAsNeedsInput() {
void missingProjectIsOptionalForUnifiedCapture() {
var noProject = AihrMemoryService.detectPlan(principal(Set.of()),
"记一下,3栋2单元1201室住户希望了解养老服务", NOW);
var ambiguous = AihrMemoryService.detectPlan(principal(Set.of("P1", "P2")),
@@ -65,8 +65,8 @@ class AihrMemoryServiceTest {
var overlappingCode = AihrMemoryService.detectPlan(principal(Set.of("P1", "P10")),
"记一下,P10项目3栋2单元1201室住户希望了解养老服务", NOW);
assertEquals("NEEDS_INPUT", noProject.status());
assertEquals("NEEDS_INPUT", ambiguous.status());
assertEquals("DRAFT", noProject.status());
assertEquals("DRAFT", ambiguous.status());
assertTrue(noProject.missingFields().contains("projectCode"));
assertTrue(ambiguous.missingFields().contains("projectCode"));
assertEquals("DRAFT", explicit.status());
@@ -75,6 +75,72 @@ class AihrMemoryServiceTest {
assertEquals("P10", overlappingCode.projectCode());
}
@Test
void explicitCaptureAcceptsBareRoomAndKeepsMissingLocationOptional() {
var compact = AihrMemoryService.detectPlan(principal(Set.of("P1")),
"帮我记一下三栋3203需要保洁服务。", NOW);
var complete = AihrMemoryService.detectPlan(principal(Set.of("P1")),
"记一下三栋二单元3103需要保洁服务。", NOW);
assertNotNull(compact);
assertEquals("DRAFT", compact.status());
assertEquals("SERVICE_LEAD", compact.memoryType());
assertEquals("三栋", compact.draft().buildingName());
assertEquals("3203", compact.draft().roomNo());
assertEquals("需要保洁服务。", compact.draft().summary());
assertTrue(compact.missingFields().contains("unitName"));
assertNotNull(complete);
assertEquals("DRAFT", complete.status());
assertEquals("二单元", complete.draft().unitName());
assertEquals("3103", complete.draft().roomNo());
assertFalse(complete.missingFields().contains("roomNo"));
}
@Test
void repeatedExplicitCaptureUpdatesOriginalInsteadOfCreatingAnotherCard() {
var first = AihrMemoryService.detectPlan(principal(Set.of("P1")),
"帮我记一下三栋3203需要保洁服务。", NOW);
var merged = AihrMemoryService.continuePlan(principal(Set.of("P1")), first.memoryType(),
first.projectCode(), first.draft(), "记一下三栋二单元3103需要保洁服务。");
assertNotNull(merged);
assertEquals("DRAFT", merged.status());
assertEquals("二单元", merged.draft().unitName());
assertEquals("3103", merged.draft().roomNo());
}
@Test
void unifiedCaptureCanBeSavedPrivatelyWithoutCompleteLocation() throws Exception {
ObjectMapper mapper = new ObjectMapper();
MemoryDraft draft = new MemoryDraft(
"P1", "三栋", "", "3203", "服务线索", "服务线索:三栋3203",
"需要保洁服务", "", "OPEN", "2026-07-19T08:30:00", null);
ConfirmJdbc jdbc = new ConfirmJdbc(mapper.writeValueAsString(draft), 1,
LocalDateTime.now().plusHours(12), "ASSISTANT_CAPTURE", "SERVICE_LEAD");
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, "capture-confirm-private-001", draft, false, "PRIVATE"));
assertEquals("PERSONAL", response.targetDomain());
assertEquals(501L, response.targetId());
assertEquals("CONFIRMED", jdbc.status);
assertEquals("PERSONAL", jdbc.targetDomain);
}
@Test
void contextualRoomParserDoesNotTreatYearDurationOrMoneyAsRoom() {
var plan = AihrMemoryService.detectPlan(principal(Set.of("P1")),
"记一下三栋2026年保洁预算300元,20分钟后再联系。", NOW);
assertNotNull(plan);
assertEquals("", plan.draft().roomNo());
assertTrue(plan.missingFields().contains("roomNo"));
}
@Test
void ordinaryQuestionDoesNotCreateCandidate() {
assertNull(AihrMemoryService.detectPlan(principal(Set.of("P1")), "住户有哪些跟进需求?", NOW));
@@ -349,6 +415,9 @@ class AihrMemoryServiceTest {
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(schema.contains("aihr_assistant_capture"));
assertTrue(schema.contains("idx_aihr_assistant_capture_delivery"));
assertTrue(migration.contains("aihr_assistant_capture"));
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'"));
@@ -399,6 +468,8 @@ class AihrMemoryServiceTest {
private int version;
private final LocalDateTime expiresAt;
private String status = "DRAFT";
private String targetDomain = "PROJECT_SERVICE";
private String memoryType = "RESIDENT_NEED";
private String confirmKey;
private String confirmHash;
private Long targetId;
@@ -421,6 +492,13 @@ class AihrMemoryServiceTest {
this.expiresAt = expiresAt;
}
private ConfirmJdbc(String draftJson, int version, LocalDateTime expiresAt,
String targetDomain, String memoryType) {
this(draftJson, version, expiresAt);
this.targetDomain = targetDomain;
this.memoryType = memoryType;
}
@Override
public <T> List<T> query(String sql, RowMapper<T> rowMapper, Object... args) {
if (sql.contains("from aihr_service_memory")) {
@@ -451,8 +529,8 @@ class AihrMemoryServiceTest {
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("memory_type")).thenAnswer(ignored -> memoryType);
when(rs.getString("target_domain")).thenAnswer(ignored -> targetDomain);
when(rs.getString("status")).thenReturn(status);
when(rs.getString("draft_json")).thenReturn(draftJson);
when(rs.getString("missing_fields_json")).thenReturn("[]");
@@ -495,10 +573,12 @@ class AihrMemoryServiceTest {
}
if (sql.contains("set status = 'CONFIRMED'")) {
status = "CONFIRMED";
targetId = ((Number) args[0]).longValue();
boolean genericCapture = sql.contains("target_domain = ?");
if (genericCapture) targetDomain = String.valueOf(args[0]);
targetId = ((Number) args[genericCapture ? 1 : 0]).longValue();
targetVersion = 1;
confirmKey = String.valueOf(args[1]);
confirmHash = String.valueOf(args[2]);
confirmKey = String.valueOf(args[genericCapture ? 2 : 1]);
confirmHash = String.valueOf(args[genericCapture ? 3 : 2]);
version++;
return 1;
}