feat(assistant): unify confirmed memory capture
This commit is contained in:
+22
-13
@@ -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");
|
||||
|
||||
+10
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
+30
-2
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+344
-78
@@ -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(
|
||||
|
||||
+44
-3
@@ -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);
|
||||
|
||||
+90
-10
@@ -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;
|
||||
}
|
||||
|
||||
@@ -78,6 +78,39 @@ CREATE TABLE IF NOT EXISTS `aihr_service_memory_version` (
|
||||
KEY `idx_aihr_service_memory_version_scope` (`tenant_id`, `project_code`, `memory_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='项目服务记忆版本快照';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `aihr_assistant_capture` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '统一采集记录ID',
|
||||
`tenant_id` varchar(20) NOT NULL COMMENT '租户编号',
|
||||
`owner_user_id` bigint NOT NULL COMMENT '记录员工',
|
||||
`project_code` varchar(100) DEFAULT NULL COMMENT '经当前身份确认的项目编码',
|
||||
`save_scope` varchar(20) NOT NULL COMMENT 'PRIVATE/COMPANY',
|
||||
`suggested_type` varchar(30) NOT NULL COMMENT 'SERVICE_LEAD/RESIDENT_PROFILE/CASE/PERSONAL_NOTE/FOLLOW_UP/PROJECT_NOTE',
|
||||
`building_name` varchar(100) DEFAULT NULL COMMENT '可选楼栋',
|
||||
`unit_name` varchar(100) DEFAULT NULL COMMENT '可选单元',
|
||||
`room_no` varchar(100) DEFAULT NULL COMMENT '可选房号',
|
||||
`category` varchar(50) NOT NULL COMMENT '系统建议分类',
|
||||
`summary` varchar(1000) NOT NULL COMMENT '用户确认后的最小必要摘要',
|
||||
`detail` text COMMENT '用户确认后的必要补充',
|
||||
`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',
|
||||
`delivery_status` varchar(20) NOT NULL COMMENT 'NOT_REQUIRED/PENDING/DELIVERED/FAILED',
|
||||
`delivery_attempts` int NOT NULL DEFAULT 0 COMMENT '企业流转尝试次数',
|
||||
`next_delivery_at` datetime DEFAULT NULL COMMENT '下次流转时间',
|
||||
`external_record_id` varchar(100) DEFAULT NULL COMMENT '外部系统记录ID',
|
||||
`last_error` varchar(500) DEFAULT NULL COMMENT '最近一次公开错误',
|
||||
`confirm_idempotency_key` varchar(100) NOT NULL COMMENT '确认幂等键',
|
||||
`confirm_request_hash` char(64) 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`),
|
||||
UNIQUE KEY `uk_aihr_assistant_capture_confirm` (`tenant_id`, `owner_user_id`, `confirm_idempotency_key`),
|
||||
KEY `idx_aihr_assistant_capture_owner` (`tenant_id`, `owner_user_id`, `save_scope`, `create_time`),
|
||||
KEY `idx_aihr_assistant_capture_delivery` (`delivery_status`, `next_delivery_at`, `id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='工作助手确认式统一采集记录';
|
||||
|
||||
-- 生产旧库与 MySQL 8 新库的排序规则可能不同;以现有租户主表为事实源动态对齐。
|
||||
SET @aihr_memory_collation := (
|
||||
SELECT collation_name
|
||||
@@ -95,6 +128,8 @@ SET @aihr_memory_ddl := CONCAT('ALTER TABLE `aihr_service_memory` CONVERT TO CHA
|
||||
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 := CONCAT('ALTER TABLE `aihr_assistant_capture` 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;
|
||||
|
||||
@@ -78,6 +78,50 @@ CREATE TABLE IF NOT EXISTS `aihr_service_memory_version` (
|
||||
KEY `idx_aihr_service_memory_version_scope` (`tenant_id`, `project_code`, `memory_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='项目服务记忆版本快照';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `aihr_assistant_capture` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '统一采集记录ID',
|
||||
`tenant_id` varchar(20) NOT NULL COMMENT '租户编号',
|
||||
`owner_user_id` bigint NOT NULL COMMENT '记录员工',
|
||||
`project_code` varchar(100) DEFAULT NULL COMMENT '经当前身份确认的项目编码',
|
||||
`save_scope` varchar(20) NOT NULL COMMENT 'PRIVATE/COMPANY',
|
||||
`suggested_type` varchar(30) NOT NULL COMMENT '系统建议类型',
|
||||
`building_name` varchar(100) DEFAULT NULL COMMENT '可选楼栋',
|
||||
`unit_name` varchar(100) DEFAULT NULL COMMENT '可选单元',
|
||||
`room_no` varchar(100) DEFAULT NULL COMMENT '可选房号',
|
||||
`category` varchar(50) NOT NULL COMMENT '系统建议分类',
|
||||
`summary` varchar(1000) NOT NULL COMMENT '用户确认后的最小必要摘要',
|
||||
`detail` text COMMENT '用户确认后的必要补充',
|
||||
`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',
|
||||
`delivery_status` varchar(20) NOT NULL COMMENT 'NOT_REQUIRED/PENDING/DELIVERED/FAILED',
|
||||
`delivery_attempts` int NOT NULL DEFAULT 0 COMMENT '企业流转尝试次数',
|
||||
`next_delivery_at` datetime DEFAULT NULL COMMENT '下次流转时间',
|
||||
`external_record_id` varchar(100) DEFAULT NULL COMMENT '外部系统记录ID',
|
||||
`last_error` varchar(500) DEFAULT NULL COMMENT '最近一次公开错误',
|
||||
`confirm_idempotency_key` varchar(100) NOT NULL COMMENT '确认幂等键',
|
||||
`confirm_request_hash` char(64) 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`),
|
||||
UNIQUE KEY `uk_aihr_assistant_capture_confirm` (`tenant_id`, `owner_user_id`, `confirm_idempotency_key`),
|
||||
KEY `idx_aihr_assistant_capture_owner` (`tenant_id`, `owner_user_id`, `save_scope`, `create_time`),
|
||||
KEY `idx_aihr_assistant_capture_delivery` (`delivery_status`, `next_delivery_at`, `id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='工作助手确认式统一采集记录';
|
||||
|
||||
SET @aihr_memory_ddl := IF(
|
||||
EXISTS(
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = 'aihr_assistant_capture'
|
||||
AND column_name = 'follow_up_at'
|
||||
),
|
||||
'SELECT 1',
|
||||
'ALTER TABLE `aihr_assistant_capture` ADD COLUMN `follow_up_at` datetime DEFAULT NULL COMMENT ''用户确认的跟进时间'' AFTER `occurred_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
|
||||
@@ -123,6 +167,8 @@ SET @aihr_memory_ddl := CONCAT('ALTER TABLE `aihr_service_memory` CONVERT TO CHA
|
||||
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 := CONCAT('ALTER TABLE `aihr_assistant_capture` 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;
|
||||
|
||||
Reference in New Issue
Block a user