From ae0c23315d64ea2e886e26fc12ad496f011cc0f4 Mon Sep 17 00:00:00 2001 From: starboyate <2925776766@qq.com> Date: Thu, 16 Apr 2026 16:59:57 +0800 Subject: [PATCH] feat: redesign temporal memory item extraction --- .../ai/memory/core/data/MemoryItem.java | 47 +- .../item/LlmSelfVerificationStep.java | 41 +- .../core/extraction/item/MemoryItemLayer.java | 6 + .../strategy/LlmItemExtractionStrategy.java | 51 +- .../item/support/ExtractedMemoryEntry.java | 36 ++ .../item/support/ExtractedTemporal.java | 32 ++ .../support/MemoryItemExtractionResponse.java | 26 +- .../item/support/TemporalNormalizer.java | 434 ++++++++++++++++++ .../item/MemoryItemUnifiedPrompts.java | 105 +++-- .../item/SelfVerificationPrompts.java | 48 +- .../item/LlmSelfVerificationStepTest.java | 58 +++ .../item/MemoryItemLayerLanguageTest.java | 80 ++++ .../LlmItemExtractionStrategyTest.java | 69 +++ .../item/support/TemporalNormalizerTest.java | 126 +++++ .../item/MemoryItemUnifiedPromptsTest.java | 56 ++- .../item/SelfVerificationPromptsTest.java | 58 ++- .../internal/schema/StoreSchemaBootstrap.java | 152 ++++++ .../plugin/jdbc/mysql/MysqlMemoryStore.java | 35 +- .../postgresql/PostgresqlMemoryStore.java | 35 +- .../plugin/jdbc/sqlite/SqliteMemoryStore.java | 35 +- .../mysql/store/V4__item_temporal_fields.sql | 18 + .../store/V4__item_temporal_fields.sql | 18 + .../sqlite/store/V4__item_temporal_fields.sql | 17 + .../schema/StoreSchemaBootstrapTest.java | 23 +- .../jdbc/sqlite/SqliteMemoryStoreTest.java | 86 ++++ .../LlmToolCallItemExtractionStrategy.java | 3 + ...LlmToolCallItemExtractionStrategyTest.java | 23 + .../mybatis/converter/ItemConverter.java | 17 +- .../mybatis/dataobject/MemoryItemDO.java | 32 ++ .../store/mybatis/schema/DatabaseDialect.java | 9 +- .../store/mybatis/schema/MemoryStoreDdl.java | 58 +++ .../mysql/V5__item_temporal_fields.sql | 15 + .../postgresql/V5__item_temporal_fields.sql | 15 + .../sqlite/V5__item_temporal_fields.sql | 15 + ...tisPlusMemoryStoreBatchOperationsTest.java | 122 +++++ .../MemorySchemaAutoConfigurationTest.java | 3 +- .../mybatis/schema/MemoryStoreDdlTest.java | 9 +- 37 files changed, 1894 insertions(+), 119 deletions(-) create mode 100644 memind-core/src/main/java/com/openmemind/ai/memory/core/extraction/item/support/ExtractedTemporal.java create mode 100644 memind-core/src/main/java/com/openmemind/ai/memory/core/extraction/item/support/TemporalNormalizer.java create mode 100644 memind-core/src/test/java/com/openmemind/ai/memory/core/extraction/item/support/TemporalNormalizerTest.java create mode 100644 memind-plugins/memind-plugin-jdbc/src/main/resources/db/jdbc/mysql/store/V4__item_temporal_fields.sql create mode 100644 memind-plugins/memind-plugin-jdbc/src/main/resources/db/jdbc/postgresql/store/V4__item_temporal_fields.sql create mode 100644 memind-plugins/memind-plugin-jdbc/src/main/resources/db/jdbc/sqlite/store/V4__item_temporal_fields.sql create mode 100644 memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-mybatis-plus-starter/src/main/resources/db/migration/mysql/V5__item_temporal_fields.sql create mode 100644 memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-mybatis-plus-starter/src/main/resources/db/migration/postgresql/V5__item_temporal_fields.sql create mode 100644 memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-mybatis-plus-starter/src/main/resources/db/migration/sqlite/V5__item_temporal_fields.sql diff --git a/memind-core/src/main/java/com/openmemind/ai/memory/core/data/MemoryItem.java b/memind-core/src/main/java/com/openmemind/ai/memory/core/data/MemoryItem.java index e0a3ecd7..367b734d 100644 --- a/memind-core/src/main/java/com/openmemind/ai/memory/core/data/MemoryItem.java +++ b/memind-core/src/main/java/com/openmemind/ai/memory/core/data/MemoryItem.java @@ -50,6 +50,15 @@ public record MemoryItem( /* Semantic memory occurrence time (null for non-temporal items) */ Instant occurredAt, + /* Normalized semantic interval lower bound (null for non-temporal items) */ + Instant occurredStart, + + /* Normalized semantic interval exclusive upper bound (null for open/point items) */ + Instant occurredEnd, + + /* Normalized temporal granularity */ + String timeGranularity, + /* Source observation time from the original message/segment (null when unavailable) */ Instant observedAt, @@ -60,4 +69,40 @@ public record MemoryItem( Instant createdAt, /* Item type (FACT / FORESIGHT) */ - MemoryItemType type) {} + MemoryItemType type) { + + public MemoryItem( + Long id, + String memoryId, + String content, + MemoryScope scope, + MemoryCategory category, + String contentType, + String vectorId, + String rawDataId, + String contentHash, + Instant occurredAt, + Instant observedAt, + Map metadata, + Instant createdAt, + MemoryItemType type) { + this( + id, + memoryId, + content, + scope, + category, + contentType, + vectorId, + rawDataId, + contentHash, + occurredAt, + null, + null, + null, + observedAt, + metadata, + createdAt, + type); + } +} diff --git a/memind-core/src/main/java/com/openmemind/ai/memory/core/extraction/item/LlmSelfVerificationStep.java b/memind-core/src/main/java/com/openmemind/ai/memory/core/extraction/item/LlmSelfVerificationStep.java index 9db4bc0f..daf7d2ea 100644 --- a/memind-core/src/main/java/com/openmemind/ai/memory/core/extraction/item/LlmSelfVerificationStep.java +++ b/memind-core/src/main/java/com/openmemind/ai/memory/core/extraction/item/LlmSelfVerificationStep.java @@ -17,13 +17,17 @@ import com.openmemind.ai.memory.core.data.enums.MemoryCategory; import com.openmemind.ai.memory.core.data.enums.MemoryItemType; import com.openmemind.ai.memory.core.extraction.item.support.ExtractedMemoryEntry; +import com.openmemind.ai.memory.core.extraction.item.support.ExtractedTemporal; import com.openmemind.ai.memory.core.extraction.item.support.MemoryItemExtractionResponse; +import com.openmemind.ai.memory.core.extraction.item.support.TemporalNormalizer; import com.openmemind.ai.memory.core.llm.ChatMessages; import com.openmemind.ai.memory.core.llm.StructuredChatClient; import com.openmemind.ai.memory.core.prompt.PromptRegistry; import com.openmemind.ai.memory.core.prompt.extraction.item.SelfVerificationPrompts; import java.time.Instant; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Set; import org.slf4j.Logger; @@ -170,7 +174,7 @@ public Mono> verify( return structuredChatClient .call(messages, MemoryItemExtractionResponse.class) - .map(response -> toEntries(response, rawDataId, observedAt)) + .map(response -> toEntries(response, rawDataId, referenceTime, observedAt)) .switchIfEmpty(Mono.just(List.of())) .subscribeOn(Schedulers.boundedElastic()) .doOnNext( @@ -192,29 +196,54 @@ public Mono> verify( } private List toEntries( - MemoryItemExtractionResponse response, String rawDataId, Instant observedAt) { + MemoryItemExtractionResponse response, + String rawDataId, + Instant referenceTime, + Instant observedAt) { if (response == null || response.items() == null) { return List.of(); } - return response.items().stream().map(item -> toEntry(item, rawDataId, observedAt)).toList(); + return response.items().stream() + .map(item -> toEntry(item, rawDataId, referenceTime, observedAt)) + .toList(); } private static ExtractedMemoryEntry toEntry( - MemoryItemExtractionResponse.ExtractedItem item, String rawDataId, Instant observedAt) { + MemoryItemExtractionResponse.ExtractedItem item, + String rawDataId, + Instant referenceTime, + Instant observedAt) { + ExtractedTemporal temporal = + TemporalNormalizer.normalize(item.time(), item.occurredAt(), referenceTime); return new ExtractedMemoryEntry( item.content(), clamp(item.confidence()), - resolveOccurredAt(item.occurredAt()), + temporal.compatibilityOccurredAt(), + temporal.occurredStart(), + temporal.occurredEnd(), + temporal.granularity(), observedAt, rawDataId, null, item.insightTypes() != null ? item.insightTypes() : List.of(), - item.metadata(), + mergeMetadata(item, temporal), MemoryItemType.FACT, item.category()); } + private static Map mergeMetadata( + MemoryItemExtractionResponse.ExtractedItem item, ExtractedTemporal temporal) { + var merged = new LinkedHashMap(); + if (item.metadata() != null) { + merged.putAll(item.metadata()); + } + if (temporal.expression() != null && !temporal.expression().isBlank()) { + merged.put("timeExpression", temporal.expression()); + } + return merged.isEmpty() ? null : Map.copyOf(merged); + } + private static Instant resolveOccurredAt(String llmValue) { if (llmValue != null && !llmValue.isBlank()) { try { diff --git a/memind-core/src/main/java/com/openmemind/ai/memory/core/extraction/item/MemoryItemLayer.java b/memind-core/src/main/java/com/openmemind/ai/memory/core/extraction/item/MemoryItemLayer.java index 4b31f4ea..c29f3529 100644 --- a/memind-core/src/main/java/com/openmemind/ai/memory/core/extraction/item/MemoryItemLayer.java +++ b/memind-core/src/main/java/com/openmemind/ai/memory/core/extraction/item/MemoryItemLayer.java @@ -192,6 +192,9 @@ private ExtractedMemoryEntry entryWithNormalizedInsightTypes( entry.content(), entry.confidence(), entry.occurredAt(), + entry.occurredStart(), + entry.occurredEnd(), + entry.timeGranularity(), entry.observedAt(), entry.rawDataId(), entry.contentHash(), @@ -346,6 +349,9 @@ private MemoryItem toMemoryItem( entry.rawDataId(), entry.contentHash(), entry.occurredAt(), + entry.occurredStart(), + entry.occurredEnd(), + entry.timeGranularity(), entry.observedAt(), buildItemMetadata(entry), Instant.now(), diff --git a/memind-core/src/main/java/com/openmemind/ai/memory/core/extraction/item/strategy/LlmItemExtractionStrategy.java b/memind-core/src/main/java/com/openmemind/ai/memory/core/extraction/item/strategy/LlmItemExtractionStrategy.java index 0f720fcd..af6cc788 100644 --- a/memind-core/src/main/java/com/openmemind/ai/memory/core/extraction/item/strategy/LlmItemExtractionStrategy.java +++ b/memind-core/src/main/java/com/openmemind/ai/memory/core/extraction/item/strategy/LlmItemExtractionStrategy.java @@ -18,8 +18,10 @@ import com.openmemind.ai.memory.core.extraction.item.ItemExtractionConfig; import com.openmemind.ai.memory.core.extraction.item.ItemExtractionStrategy; import com.openmemind.ai.memory.core.extraction.item.support.ExtractedMemoryEntry; +import com.openmemind.ai.memory.core.extraction.item.support.ExtractedTemporal; import com.openmemind.ai.memory.core.extraction.item.support.ForesightExtractionResponse; import com.openmemind.ai.memory.core.extraction.item.support.MemoryItemExtractionResponse; +import com.openmemind.ai.memory.core.extraction.item.support.TemporalNormalizer; import com.openmemind.ai.memory.core.extraction.rawdata.ParsedSegment; import com.openmemind.ai.memory.core.llm.ChatMessages; import com.openmemind.ai.memory.core.llm.StructuredChatClient; @@ -141,24 +143,33 @@ private List toFactEntries( var observedAt = resolveObservedAt(segment); return response.items().stream() - .map( - item -> - new ExtractedMemoryEntry( - item.content(), - clamp(item.confidence()), - resolveOccurredAt(item.occurredAt()), - observedAt, - segment.rawDataId(), - null, - item.insightTypes() != null - ? item.insightTypes() - : List.of(), - mergeMetadata(segment, item), - MemoryItemType.FACT, - item.category())) + .map(item -> toFactEntry(item, observedAt, segment, referenceTime)) .toList(); } + private static ExtractedMemoryEntry toFactEntry( + MemoryItemExtractionResponse.ExtractedItem item, + Instant observedAt, + ParsedSegment segment, + Instant referenceTime) { + ExtractedTemporal temporal = + TemporalNormalizer.normalize(item.time(), item.occurredAt(), referenceTime); + return new ExtractedMemoryEntry( + item.content(), + clamp(item.confidence()), + temporal.compatibilityOccurredAt(), + temporal.occurredStart(), + temporal.occurredEnd(), + temporal.granularity(), + observedAt, + segment.rawDataId(), + null, + item.insightTypes() != null ? item.insightTypes() : List.of(), + mergeMetadata(segment, item, temporal), + MemoryItemType.FACT, + item.category()); + } + private Mono> extractForesight( ParsedSegment segment, String language) { return Mono.fromCallable( @@ -247,6 +258,13 @@ static Instant resolveObservedAt(ParsedSegment segment) { static Map mergeMetadata( ParsedSegment segment, MemoryItemExtractionResponse.ExtractedItem item) { + return mergeMetadata(segment, item, null); + } + + static Map mergeMetadata( + ParsedSegment segment, + MemoryItemExtractionResponse.ExtractedItem item, + ExtractedTemporal temporal) { var merged = new LinkedHashMap(); if (segment.metadata() != null) { merged.putAll(segment.metadata()); @@ -255,6 +273,9 @@ static Map mergeMetadata( if (item.metadata() != null) { merged.putAll(item.metadata()); } + if (temporal != null && temporal.expression() != null && !temporal.expression().isBlank()) { + merged.put("timeExpression", temporal.expression()); + } return merged.isEmpty() ? null : Map.copyOf(merged); } diff --git a/memind-core/src/main/java/com/openmemind/ai/memory/core/extraction/item/support/ExtractedMemoryEntry.java b/memind-core/src/main/java/com/openmemind/ai/memory/core/extraction/item/support/ExtractedMemoryEntry.java index 8787ceb5..a202a758 100644 --- a/memind-core/src/main/java/com/openmemind/ai/memory/core/extraction/item/support/ExtractedMemoryEntry.java +++ b/memind-core/src/main/java/com/openmemind/ai/memory/core/extraction/item/support/ExtractedMemoryEntry.java @@ -26,6 +26,9 @@ * @param content Memory content * @param confidence Confidence level (used only for filtering, not persisted) * @param occurredAt Semantic time when the memory occurred (null for non-temporal items) + * @param occurredStart Normalized semantic interval lower bound (null for non-temporal items) + * @param occurredEnd Normalized semantic interval exclusive upper bound + * @param timeGranularity Normalized temporal granularity * @param observedAt Source observation time from the original message/segment * @param rawDataId Source data ID * @param contentHash Content hash (filled after deduplication) @@ -36,6 +39,9 @@ public record ExtractedMemoryEntry( String content, float confidence, Instant occurredAt, + Instant occurredStart, + Instant occurredEnd, + String timeGranularity, Instant observedAt, String rawDataId, String contentHash, @@ -44,6 +50,33 @@ public record ExtractedMemoryEntry( MemoryItemType type, String category) { + public ExtractedMemoryEntry( + String content, + float confidence, + Instant occurredAt, + Instant observedAt, + String rawDataId, + String contentHash, + List insightTypes, + Map metadata, + MemoryItemType type, + String category) { + this( + content, + confidence, + occurredAt, + null, + null, + null, + observedAt, + rawDataId, + contentHash, + insightTypes, + metadata, + type, + category); + } + /** * Set contentHash */ @@ -52,6 +85,9 @@ public ExtractedMemoryEntry withContentHash(String contentHash) { content, confidence, occurredAt, + occurredStart, + occurredEnd, + timeGranularity, observedAt, rawDataId, contentHash, diff --git a/memind-core/src/main/java/com/openmemind/ai/memory/core/extraction/item/support/ExtractedTemporal.java b/memind-core/src/main/java/com/openmemind/ai/memory/core/extraction/item/support/ExtractedTemporal.java new file mode 100644 index 00000000..4187b03c --- /dev/null +++ b/memind-core/src/main/java/com/openmemind/ai/memory/core/extraction/item/support/ExtractedTemporal.java @@ -0,0 +1,32 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.memory.core.extraction.item.support; + +import java.time.Instant; + +/** + * Normalized temporal payload used by the extraction pipeline before persistence. + * + * @param occurredStart semantic lower bound in UTC + * @param occurredEnd semantic exclusive upper bound in UTC + * @param granularity normalized temporal granularity + * @param expression original temporal phrase from source text + * @param compatibilityOccurredAt compatibility anchor for legacy occurredAt + */ +public record ExtractedTemporal( + Instant occurredStart, + Instant occurredEnd, + String granularity, + String expression, + Instant compatibilityOccurredAt) {} diff --git a/memind-core/src/main/java/com/openmemind/ai/memory/core/extraction/item/support/MemoryItemExtractionResponse.java b/memind-core/src/main/java/com/openmemind/ai/memory/core/extraction/item/support/MemoryItemExtractionResponse.java index 569f57e5..5b1dc453 100644 --- a/memind-core/src/main/java/com/openmemind/ai/memory/core/extraction/item/support/MemoryItemExtractionResponse.java +++ b/memind-core/src/main/java/com/openmemind/ai/memory/core/extraction/item/support/MemoryItemExtractionResponse.java @@ -25,12 +25,24 @@ @JsonIgnoreProperties(ignoreUnknown = true) public record MemoryItemExtractionResponse(List items) { + /** + * Structured temporal extraction result. + * + * @param expression Original temporal phrase from source text + * @param start Normalized lower bound in ISO-8601 UTC + * @param end Normalized exclusive upper bound in ISO-8601 UTC + * @param granularity Temporal granularity + */ + @JsonIgnoreProperties(ignoreUnknown = true) + public record ExtractedTime(String expression, String start, String end, String granularity) {} + /** * Single extraction result * * @param content Memory content * @param confidence Confidence level (0.0-1.0) * @param occurredAt Memory occurrence time ISO-8601 (LLM parsed, may be null) + * @param time Structured temporal extraction result * @param insightTypes List of matched InsightType names (may be empty) * @param metadata Additional metadata (may be null; includes whenToUse, etc.) */ @@ -39,7 +51,19 @@ public record ExtractedItem( String content, float confidence, String occurredAt, + ExtractedTime time, List insightTypes, Map metadata, - String category) {} + String category) { + + public ExtractedItem( + String content, + float confidence, + String occurredAt, + List insightTypes, + Map metadata, + String category) { + this(content, confidence, occurredAt, null, insightTypes, metadata, category); + } + } } diff --git a/memind-core/src/main/java/com/openmemind/ai/memory/core/extraction/item/support/TemporalNormalizer.java b/memind-core/src/main/java/com/openmemind/ai/memory/core/extraction/item/support/TemporalNormalizer.java new file mode 100644 index 00000000..73702e6a --- /dev/null +++ b/memind-core/src/main/java/com/openmemind/ai/memory/core/extraction/item/support/TemporalNormalizer.java @@ -0,0 +1,434 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.memory.core.extraction.item.support; + +import java.time.DayOfWeek; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.Month; +import java.time.Year; +import java.time.YearMonth; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.time.temporal.TemporalAdjusters; +import java.util.Locale; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Deterministic temporal normalization for memory-item extraction. + */ +public final class TemporalNormalizer { + + private static final Set BUCKET_GRANULARITIES = Set.of("day", "week", "month", "year"); + private static final DateTimeFormatter ENGLISH_MONTH_FORMAT = + DateTimeFormatter.ofPattern("MMMM uuuu", Locale.ENGLISH); + private static final DateTimeFormatter ENGLISH_SHORT_MONTH_FORMAT = + DateTimeFormatter.ofPattern("MMM uuuu", Locale.ENGLISH); + private static final Pattern ISO_DATE_PATTERN = Pattern.compile("^(\\d{4})-(\\d{2})-(\\d{2})$"); + private static final Pattern ISO_MONTH_PATTERN = Pattern.compile("^(\\d{4})-(\\d{2})$"); + private static final Pattern ISO_YEAR_PATTERN = Pattern.compile("^(\\d{4})$"); + private static final Pattern CHINESE_YEAR_MONTH_PATTERN = + Pattern.compile("^(?:(今年)|(\\d{4})年)\\s*(\\d{1,2})月$"); + + private TemporalNormalizer() {} + + public static ExtractedTemporal normalize( + MemoryItemExtractionResponse.ExtractedTime time, + String legacyOccurredAt, + Instant referenceTime) { + if (hasStructuredTemporal(time)) { + return normalizeStructuredTime(time, referenceTime, ZoneId.systemDefault()); + } + + Instant occurredAt = parseInstant(legacyOccurredAt); + if (occurredAt != null) { + return new ExtractedTemporal(occurredAt, null, "unknown", null, occurredAt); + } + return empty(null, null); + } + + static ExtractedTemporal normalizeStructuredTime( + MemoryItemExtractionResponse.ExtractedTime time, + Instant referenceTime, + ZoneId systemZone) { + String expression = normalizeBlank(time.expression()); + String granularity = normalizeGranularity(time.granularity()); + Instant start = parseInstant(time.start()); + Instant end = parseInstant(time.end()); + + if (granularity == null && start != null && end != null) { + granularity = inferGranularityFromBoundaries(start, end, systemZone); + } + if (granularity == null && expression != null) { + granularity = inferGranularityFromExpression(expression); + } + + if (start != null && end != null) { + return normalizeStartEnd( + start, end, granularity, expression, referenceTime, systemZone); + } + if (start != null) { + return normalizeStartOnly(start, granularity, expression, referenceTime, systemZone); + } + if (expression != null) { + return parseExpression(expression, granularity, referenceTime, systemZone); + } + return empty(null, null); + } + + private static ExtractedTemporal normalizeStartEnd( + Instant start, + Instant end, + String granularity, + String expression, + Instant referenceTime, + ZoneId systemZone) { + if (!start.isBefore(end)) { + return empty(expression, granularity); + } + if ("point".equals(granularity)) { + return point(start, expression, "point"); + } + if ("range".equals(granularity)) { + return interval(start, end, expression, "range"); + } + if (BUCKET_GRANULARITIES.contains(granularity)) { + if (isCanonicalBucket(start, end, granularity, systemZone)) { + return interval(start, end, expression, granularity); + } + if (expression != null) { + ExtractedTemporal reparsed = + parseExpression(expression, granularity, referenceTime, systemZone); + if (reparsed.occurredStart() != null && reparsed.occurredEnd() != null) { + return reparsed; + } + } + return empty(expression, granularity); + } + return interval(start, end, expression, granularity != null ? granularity : "range"); + } + + private static ExtractedTemporal normalizeStartOnly( + Instant start, + String granularity, + String expression, + Instant referenceTime, + ZoneId systemZone) { + if ("point".equals(granularity) || granularity == null) { + return point(start, expression, granularity); + } + if (BUCKET_GRANULARITIES.contains(granularity)) { + if (isCanonicalBucketStart(start, granularity, systemZone)) { + return interval( + start, + advanceBucket(start, granularity, systemZone), + expression, + granularity); + } + if (expression != null) { + return parseExpression(expression, granularity, referenceTime, systemZone); + } + return empty(expression, granularity); + } + return point(start, expression, granularity); + } + + private static ExtractedTemporal parseExpression( + String expression, String granularity, Instant referenceTime, ZoneId systemZone) { + String normalizedExpression = expression.trim(); + + if (matchesAny(normalizedExpression, "昨天", "today", "yesterday", "tomorrow", "今天", "明天")) { + return resolveRelativeDay(normalizedExpression, referenceTime, systemZone); + } + if (matchesAny( + normalizedExpression, + "上周", + "这周", + "本周", + "下周", + "last week", + "this week", + "next week")) { + return resolveRelativeWeek(normalizedExpression, referenceTime, systemZone); + } + if (matchesAny( + normalizedExpression, + "上个月", + "这个月", + "本月", + "下个月", + "last month", + "this month", + "next month")) { + return resolveRelativeMonth(normalizedExpression, referenceTime, systemZone); + } + + Matcher isoDateMatcher = ISO_DATE_PATTERN.matcher(normalizedExpression); + if (isoDateMatcher.matches()) { + LocalDate localDate = LocalDate.parse(normalizedExpression); + return dateBucket(localDate, systemZone, "day", expression); + } + + Matcher isoMonthMatcher = ISO_MONTH_PATTERN.matcher(normalizedExpression); + if (isoMonthMatcher.matches()) { + YearMonth yearMonth = YearMonth.parse(normalizedExpression); + return monthBucket(yearMonth, systemZone, expression); + } + + Matcher chineseYearMonthMatcher = CHINESE_YEAR_MONTH_PATTERN.matcher(normalizedExpression); + if (chineseYearMonthMatcher.matches()) { + Integer year = null; + if (chineseYearMonthMatcher.group(1) != null) { + if (referenceTime == null) { + return empty(expression, granularity != null ? granularity : "month"); + } + year = referenceTime.atZone(systemZone).getYear(); + } + if (chineseYearMonthMatcher.group(2) != null) { + year = Integer.parseInt(chineseYearMonthMatcher.group(2)); + } + int month = Integer.parseInt(chineseYearMonthMatcher.group(3)); + return monthBucket(YearMonth.of(year, month), systemZone, expression); + } + + YearMonth parsedEnglishMonth = parseEnglishMonthYear(normalizedExpression); + if (parsedEnglishMonth != null) { + return monthBucket(parsedEnglishMonth, systemZone, expression); + } + + Matcher isoYearMatcher = ISO_YEAR_PATTERN.matcher(normalizedExpression); + if (isoYearMatcher.matches()) { + return yearBucket(Year.parse(normalizedExpression), systemZone, expression); + } + + return empty(expression, granularity); + } + + private static ExtractedTemporal resolveRelativeDay( + String expression, Instant referenceTime, ZoneId systemZone) { + if (referenceTime == null) { + return empty(expression, "day"); + } + LocalDate referenceDate = referenceTime.atZone(systemZone).toLocalDate(); + LocalDate targetDate = + switch (expression.toLowerCase(Locale.ROOT)) { + case "昨天", "yesterday" -> referenceDate.minusDays(1); + case "明天", "tomorrow" -> referenceDate.plusDays(1); + default -> referenceDate; + }; + return dateBucket(targetDate, systemZone, "day", expression); + } + + private static ExtractedTemporal resolveRelativeWeek( + String expression, Instant referenceTime, ZoneId systemZone) { + if (referenceTime == null) { + return empty(expression, "week"); + } + LocalDate referenceDate = referenceTime.atZone(systemZone).toLocalDate(); + LocalDate monday = referenceDate.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); + LocalDate targetMonday = + switch (expression.toLowerCase(Locale.ROOT)) { + case "上周", "last week" -> monday.minusWeeks(1); + case "下周", "next week" -> monday.plusWeeks(1); + default -> monday; + }; + + ZonedDateTime start = targetMonday.atStartOfDay(systemZone); + return interval(start.toInstant(), start.plusWeeks(1).toInstant(), expression, "week"); + } + + private static ExtractedTemporal resolveRelativeMonth( + String expression, Instant referenceTime, ZoneId systemZone) { + if (referenceTime == null) { + return empty(expression, "month"); + } + YearMonth referenceMonth = YearMonth.from(referenceTime.atZone(systemZone)); + YearMonth targetMonth = + switch (expression.toLowerCase(Locale.ROOT)) { + case "上个月", "last month" -> referenceMonth.minusMonths(1); + case "下个月", "next month" -> referenceMonth.plusMonths(1); + default -> referenceMonth; + }; + return monthBucket(targetMonth, systemZone, expression); + } + + private static ExtractedTemporal dateBucket( + LocalDate localDate, ZoneId systemZone, String granularity, String expression) { + ZonedDateTime start = localDate.atStartOfDay(systemZone); + return interval(start.toInstant(), start.plusDays(1).toInstant(), expression, granularity); + } + + private static ExtractedTemporal monthBucket( + YearMonth yearMonth, ZoneId systemZone, String expression) { + ZonedDateTime start = yearMonth.atDay(1).atStartOfDay(systemZone); + return interval(start.toInstant(), start.plusMonths(1).toInstant(), expression, "month"); + } + + private static ExtractedTemporal yearBucket(Year year, ZoneId systemZone, String expression) { + ZonedDateTime start = year.atDay(1).atStartOfDay(systemZone); + return interval(start.toInstant(), start.plusYears(1).toInstant(), expression, "year"); + } + + private static boolean isCanonicalBucket( + Instant start, Instant end, String granularity, ZoneId zone) { + if (!isCanonicalBucketStart(start, granularity, zone)) { + return false; + } + return advanceBucket(start, granularity, zone).equals(end); + } + + private static boolean isCanonicalBucketStart(Instant start, String granularity, ZoneId zone) { + ZonedDateTime zonedStart = start.atZone(zone); + if (!LocalTime.MIDNIGHT.equals(zonedStart.toLocalTime())) { + return false; + } + return switch (granularity) { + case "day" -> true; + case "week" -> zonedStart.getDayOfWeek() == DayOfWeek.MONDAY; + case "month" -> zonedStart.getDayOfMonth() == 1; + case "year" -> + zonedStart.getDayOfMonth() == 1 && zonedStart.getMonth() == Month.JANUARY; + default -> false; + }; + } + + private static Instant advanceBucket(Instant start, String granularity, ZoneId zone) { + ZonedDateTime zonedStart = start.atZone(zone); + return switch (granularity) { + case "day" -> zonedStart.plusDays(1).toInstant(); + case "week" -> zonedStart.plusWeeks(1).toInstant(); + case "month" -> zonedStart.plusMonths(1).toInstant(); + case "year" -> zonedStart.plusYears(1).toInstant(); + default -> start; + }; + } + + private static String inferGranularityFromBoundaries(Instant start, Instant end, ZoneId zone) { + for (String granularity : BUCKET_GRANULARITIES) { + if (isCanonicalBucket(start, end, granularity, zone)) { + return granularity; + } + } + return start.isBefore(end) ? "range" : null; + } + + private static String inferGranularityFromExpression(String expression) { + String normalized = expression.toLowerCase(Locale.ROOT); + if (normalized.contains("周") || normalized.contains("week")) { + return normalized.contains("到") || normalized.contains("to") ? "range" : "week"; + } + if (normalized.contains("月") + || normalized.contains("month") + || ISO_MONTH_PATTERN.matcher(normalized).matches()) { + return "month"; + } + if (normalized.contains("年") || ISO_YEAR_PATTERN.matcher(normalized).matches()) { + return "year"; + } + if (normalized.contains("天") + || normalized.contains("day") + || matchesAny(normalized, "昨天", "今天", "明天", "yesterday", "today", "tomorrow")) { + return "day"; + } + return null; + } + + private static YearMonth parseEnglishMonthYear(String expression) { + try { + return YearMonth.parse(expression, ENGLISH_MONTH_FORMAT); + } catch (DateTimeParseException ignored) { + } + try { + return YearMonth.parse(expression, ENGLISH_SHORT_MONTH_FORMAT); + } catch (DateTimeParseException ignored) { + return null; + } + } + + private static ExtractedTemporal interval( + Instant start, Instant end, String expression, String granularity) { + return new ExtractedTemporal( + start, end, granularity, expression, compatibilityOccurredAt(granularity, start)); + } + + private static ExtractedTemporal point( + Instant occurredAt, String expression, String granularity) { + return new ExtractedTemporal( + occurredAt, + null, + granularity != null ? granularity : "point", + expression, + compatibilityOccurredAt("point", occurredAt)); + } + + private static ExtractedTemporal empty(String expression, String granularity) { + return new ExtractedTemporal(null, null, granularity, expression, null); + } + + private static Instant compatibilityOccurredAt(String granularity, Instant start) { + if (start == null) { + return null; + } + return switch (granularity) { + case "point", "day", "unknown" -> start; + default -> null; + }; + } + + private static boolean hasStructuredTemporal(MemoryItemExtractionResponse.ExtractedTime time) { + return time != null + && (normalizeBlank(time.expression()) != null + || normalizeBlank(time.start()) != null + || normalizeBlank(time.end()) != null + || normalizeBlank(time.granularity()) != null); + } + + private static Instant parseInstant(String value) { + if (value == null || value.isBlank()) { + return null; + } + try { + return Instant.parse(value); + } catch (DateTimeParseException ignored) { + return null; + } + } + + private static String normalizeGranularity(String value) { + String normalized = normalizeBlank(value); + return normalized == null ? null : normalized.toLowerCase(Locale.ROOT); + } + + private static String normalizeBlank(String value) { + if (value == null || value.isBlank()) { + return null; + } + return value.trim(); + } + + private static boolean matchesAny(String candidate, String... values) { + String normalized = candidate.toLowerCase(Locale.ROOT); + for (String value : values) { + if (normalized.equals(value.toLowerCase(Locale.ROOT))) { + return true; + } + } + return false; + } +} diff --git a/memind-core/src/main/java/com/openmemind/ai/memory/core/prompt/extraction/item/MemoryItemUnifiedPrompts.java b/memind-core/src/main/java/com/openmemind/ai/memory/core/prompt/extraction/item/MemoryItemUnifiedPrompts.java index 4966cf81..53e66071 100644 --- a/memind-core/src/main/java/com/openmemind/ai/memory/core/prompt/extraction/item/MemoryItemUnifiedPrompts.java +++ b/memind-core/src/main/java/com/openmemind/ai/memory/core/prompt/extraction/item/MemoryItemUnifiedPrompts.java @@ -21,11 +21,10 @@ import com.openmemind.ai.memory.core.prompt.PromptTemplate; import com.openmemind.ai.memory.core.prompt.PromptType; import java.time.Instant; -import java.time.ZoneOffset; +import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.util.EnumSet; import java.util.List; -import java.util.Locale; import java.util.Set; import java.util.regex.Pattern; import java.util.stream.Collectors; @@ -38,9 +37,7 @@ */ public final class MemoryItemUnifiedPrompts { - static final DateTimeFormatter DATE_FMT = - DateTimeFormatter.ofPattern("yyyy-MM-dd (EEEE)", Locale.ENGLISH) - .withZone(ZoneOffset.UTC); + static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ISO_LOCAL_DATE; static final Pattern MESSAGE_TIMESTAMP_PATTERN = Pattern.compile("\\[\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}]"); @@ -48,9 +45,9 @@ public final class MemoryItemUnifiedPrompts { static final String RESOLVE_DATES_INSTRUCTION = """ Resolve relative expressions ("yesterday", "next month") to absolute dates. \ - Only populate `occurredAt` when the text itself provides semantic temporal evidence \ - for the memory. Do NOT infer `occurredAt` from the reference date or message \ - timestamp alone.\ + Use `time` as the primary temporal field. Set `time` to null when the text itself \ + does not provide semantic temporal evidence for the memory. Do NOT infer `time` \ + from the reference date or message timestamp alone.\ """; // ── System & User Prompt Templates ─────────────────────────────────────── @@ -130,30 +127,47 @@ Resolve relative expressions ("yesterday", "next month") to absolute dates. \ - 0.70-0.84: Reasonable inference - < 0.70: Do not extract - ## occurredAt - Temporal Content Embedding rules for time-specific memories: - - Time-specific memories: embed the resolved absolute date in the content AND \ - populate `occurredAt` with the ISO-8601 UTC timestamp only when the text itself \ - states or clearly implies that time. + ## time + Temporal extraction rules for time-specific memories: + - Use `time` as the primary temporal field. Set `time` to null when no semantic \ + temporal evidence exists in the source text. + - `time.expression`: the original temporal phrase from the source text, such as \ + "昨天", "last week", or "March 2026". + - `time.start`: the normalized lower bound in ISO-8601 UTC. + - `time.end`: the normalized exclusive upper bound in ISO-8601 UTC for ranges and \ + calendar buckets; use null for a single point in time. + - `time.granularity`: one of `point`, `day`, `week`, `month`, `year`, `range`, or \ + `unknown`. + - Time-specific memories: embed the resolved absolute date or range in the content \ + AND populate `time` only when the text itself states or clearly implies that time. - Profile, behavior, directive, playbook, resolution, and tool items should \ - normally set `occurredAt` to null. - - Event items should populate `occurredAt` only when the text itself contains \ - explicit temporal evidence such as a date, relative date phrase, or clear \ - start/end marker. - - Do NOT use message timestamps or conversation timestamps as `occurredAt` by \ - default. They are for resolving relative expressions, not for persistence defaults. + normally set `time` to null. + - Event items should populate `time` only when the text itself contains explicit \ + temporal evidence such as a date, relative date phrase, or clear start/end marker. + - Do NOT use message timestamps or conversation timestamps as default temporal \ + values. They are for resolving relative expressions, not persistence defaults. + - For `day`, `week`, `month`, and `year`, `time.start` and `time.end` must form a \ + canonical half-open bucket in the System Time Zone before converting to UTC. + - During rollout the parser still tolerates legacy `occurredAt`, but your response \ + should use `time`. """; private static final String OUTPUT = """ Return a JSON object ONLY. No extra text. + Use `"time": null` when no semantic temporal evidence exists. { "items":[ { "content": "Single, complete, self-contained sentence preserving ALL details", "confidence": 0.95, - "occurredAt": "2023-10-14T00:00:00Z", + "time": { + "expression": "on 2023-10-14 at 08:30 UTC", + "start": "2023-10-14T08:30:00Z", + "end": null, + "granularity": "point" + }, "insightTypes": ["Choose ONLY from the Available insightTypes listed under the assigned category"], "category_reason": "CRITICAL: Briefly explain WHY this category was chosen based on the rules. This field is for reasoning only and will NOT be stored.", "category": "" @@ -252,7 +266,7 @@ Resolve relative expressions ("yesterday", "next month") to absolute dates. \ { "content": "User is a backend engineer with 5 years of Python experience", "confidence": 1.0, - "occurredAt": null, + "time": null, "insightTypes": ["identity"], "category_reason": "Stable professional identity that remains true across projects.", "category": "profile" @@ -280,7 +294,7 @@ Resolve relative expressions ("yesterday", "next month") to absolute dates. \ { "content": "User reviews pull requests before morning standup every workday", "confidence": 1.0, - "occurredAt": null, + "time": null, "insightTypes": ["behavior"], "category_reason": "Recurring routine with explicit frequency evidence.", "category": "behavior" @@ -308,7 +322,7 @@ Resolve relative expressions ("yesterday", "next month") to absolute dates. \ { "content": "User's team uses Redis for caching with a 10-minute TTL", "confidence": 0.95, - "occurredAt": null, + "time": null, "insightTypes": ["experiences"], "category_reason": "Current team infrastructure setup, no specific time anchor.", "category": "event" @@ -336,7 +350,7 @@ Resolve relative expressions ("yesterday", "next month") to absolute dates. \ { "content": "User requires the agent to show the plan before substantial code changes", "confidence": 1.0, - "occurredAt": null, + "time": null, "insightTypes": ["directives"], "category_reason": "Durable collaboration rule for future interactions.", "category": "directive" @@ -363,7 +377,7 @@ Resolve relative expressions ("yesterday", "next month") to absolute dates. \ { "content": "For repository comparisons, first align memory scope, then compare taxonomy, extraction flow, and storage path", "confidence": 0.95, - "occurredAt": null, + "time": null, "insightTypes": ["playbooks"], "category_reason": "Reusable workflow for a recurring class of tasks.", "category": "playbook" @@ -390,7 +404,7 @@ Resolve relative expressions ("yesterday", "next month") to absolute dates. \ { "content": "Virtual threads caused HikariCP connection pool exhaustion because virtual thread count exceeded pool size; solved by setting maximumPoolSize to 10-20", "confidence": 0.95, - "occurredAt": null, + "time": null, "insightTypes": ["resolutions"], "category_reason": "Named problem plus usable fix with future reuse value.", "category": "resolution" @@ -598,34 +612,59 @@ static String buildSubjectClarityContext(String userName) { static String buildTimeContext(String segmentText, Instant referenceTime) { boolean hasTimestamps = segmentText != null && MESSAGE_TIMESTAMP_PATTERN.matcher(segmentText).find(); + ZoneId systemZone = ZoneId.systemDefault(); + String zoneLine = "System Time Zone: " + systemZone.getId() + "\n"; + String bucketRule = + "For `day`, `week`, `month`, and `year`, `time.start` and `time.end` must" + + " form canonical half-open bucket boundaries in this system time" + + " zone before converting to UTC.\n"; if (hasTimestamps) { String fallback = referenceTime != null - ? "\nFallback Reference Date: " + DATE_FMT.format(referenceTime) + ? "\nFallback Reference Date: " + + formatReferenceDate(referenceTime, systemZone) : ""; return "# Temporal Resolution\n" + + zoneLine + "Messages contain timestamps (e.g., [2023-05-25 13:17]). Use each message's" + " timestamp only as a reference anchor for resolving relative expressions" - + " within that message. Do NOT copy message timestamps into occurredAt unless" - + " the memory text itself makes that time semantically explicit." + + " within that message. Do NOT copy message timestamps into `time` or" + + " legacy `occurredAt` unless the memory text itself makes that time" + + " semantically explicit." + fallback + "\n\n" + + bucketRule + + "\n" + RESOLVE_DATES_INSTRUCTION + "\n"; } else if (referenceTime != null) { return "# Temporal Resolution\n" + + zoneLine + "Today's date: " - + DATE_FMT.format(referenceTime) + + formatReferenceDate(referenceTime, systemZone) + ". Use this only to resolve relative temporal references. Do NOT treat it" - + " as a default occurredAt.\n\n" + + " as a default temporal value.\n\n" + + bucketRule + + "\n" + RESOLVE_DATES_INSTRUCTION + "\n"; } else { return """ # Temporal Resolution - No absolute dates available. Do not resolve relative dates. - """; + System Time Zone:\ + """ + + systemZone.getId() + + """ + + No absolute dates available. Do not resolve relative dates. + For `day`, `week`, `month`, and `year`, `time.start` and `time.end` must form canonical \ + half-open bucket boundaries in this system time zone before converting to UTC. + """; } } + + private static String formatReferenceDate(Instant referenceTime, ZoneId systemZone) { + return referenceTime.atZone(systemZone).toLocalDate().format(DATE_FMT); + } } diff --git a/memind-core/src/main/java/com/openmemind/ai/memory/core/prompt/extraction/item/SelfVerificationPrompts.java b/memind-core/src/main/java/com/openmemind/ai/memory/core/prompt/extraction/item/SelfVerificationPrompts.java index 35bcc6dd..d97b2204 100644 --- a/memind-core/src/main/java/com/openmemind/ai/memory/core/prompt/extraction/item/SelfVerificationPrompts.java +++ b/memind-core/src/main/java/com/openmemind/ai/memory/core/prompt/extraction/item/SelfVerificationPrompts.java @@ -128,28 +128,44 @@ reusable method for handling that class of tasks. These become playbook items. """ # Scoring Guidelines - ## occurredAt - - Time-specific memories: embed the resolved absolute date in the content AND \ - populate `occurredAt` with the ISO-8601 UTC timestamp only when the text itself \ - states or clearly implies that time. + ## time + - Use `time` as the primary temporal field. Set `time` to null when no semantic \ + temporal evidence exists in the source text. + - `time.expression`: the original temporal phrase from the source text. + - `time.start`: the normalized lower bound in ISO-8601 UTC. + - `time.end`: the normalized exclusive upper bound in ISO-8601 UTC for ranges and \ + calendar buckets; use null for a single point in time. + - `time.granularity`: one of `point`, `day`, `week`, `month`, `year`, `range`, or \ + `unknown`. + - Time-specific memories: embed the resolved absolute date or range in the content \ + AND populate `time` only when the text itself states or clearly implies that time. - Profile, behavior, directive, playbook, resolution, and tool items should \ - normally set `occurredAt` to null. - - Event items should populate `occurredAt` only when the text itself contains \ - explicit temporal evidence such as a date, relative date phrase, or clear \ - start/end marker. - - Do NOT use message timestamps or conversation timestamps as `occurredAt` by \ - default. They are for resolving relative expressions, not for persistence defaults. + normally set `time` to null. + - Event items should populate `time` only when the text itself contains explicit \ + temporal evidence such as a date, relative date phrase, or clear start/end marker. + - Do NOT use message timestamps or conversation timestamps as default temporal \ + values. They are for resolving relative expressions, not persistence defaults. + - For `day`, `week`, `month`, and `year`, `time.start` and `time.end` must form a \ + canonical half-open bucket in the System Time Zone before converting to UTC. + - During rollout the parser still tolerates legacy `occurredAt`, but your response \ + should use `time`. """; private static final String OUTPUT = """ Return a JSON object ONLY. No extra text. No markdown fences. + Use `"time": null` when no semantic temporal evidence exists. { "items": [ { "content": "Single, complete, self-contained sentence preserving ALL details", - "occurredAt": "2026-03-18T00:00:00Z", + "time": { + "expression": "on 2026-03-18 at 10:00 UTC", + "start": "2026-03-18T10:00:00Z", + "end": null, + "granularity": "point" + }, "insightTypes": ["Choose ONLY from the Available insightTypes listed under the assigned category"], "category_reason": "CRITICAL: Briefly explain WHY this category was chosen AND why this item was missed by the first pass. This field is for reasoning only and will NOT be stored.", "category": "" @@ -178,14 +194,14 @@ reusable method for handling that class of tasks. These become playbook items. "items": [ { "content": "User's payment service migration uses Spring Boot 3.2", - "occurredAt": null, + "time": null, "insightTypes": ["experiences"], "category_reason": "Current project tech stack detail. Missed because the first pass captured the migration but not the specific framework version.", "category": "event" }, { "content": "Zhang San is responsible for frontend in User's team", - "occurredAt": null, + "time": null, "insightTypes": ["experiences"], "category_reason": "Team member role, current project context. Missed because the first pass focused on the migration fact and overlooked the team structure.", "category": "event" @@ -210,7 +226,7 @@ reusable method for handling that class of tasks. These become playbook items. "items": [ { "content": "Virtual threads caused HikariCP connection pool exhaustion because virtual thread count far exceeds pool size limit; solved by setting maximumPoolSize to 10-20", - "occurredAt": null, + "time": null, "insightTypes": ["resolutions"], "category_reason": "Resolution item: named problem, cause, and usable fix. The first pass only captured the symptom and missed the fix from the assistant response.", "category": "resolution" @@ -234,7 +250,7 @@ reusable method for handling that class of tasks. These become playbook items. "items": [ { "content": "User instructed the agent to respond in Chinese and keep answers concise", - "occurredAt": null, + "time": null, "insightTypes": ["directives"], "category_reason": "Directive item: durable rule for future interaction behavior. Commonly missed because it appears as a brief aside rather than the main task content.", "category": "directive" @@ -258,7 +274,7 @@ reusable method for handling that class of tasks. These become playbook items. "items": [ { "content": "For repository comparisons, first align memory scope, then compare taxonomy, extraction flow, and storage path", - "occurredAt": null, + "time": null, "insightTypes": ["playbooks"], "category_reason": "Playbook item: reusable handling workflow for a recurring class of tasks. The first pass captured the request but missed the reusable method.", "category": "playbook" diff --git a/memind-core/src/test/java/com/openmemind/ai/memory/core/extraction/item/LlmSelfVerificationStepTest.java b/memind-core/src/test/java/com/openmemind/ai/memory/core/extraction/item/LlmSelfVerificationStepTest.java index f3710afe..3cec765a 100644 --- a/memind-core/src/test/java/com/openmemind/ai/memory/core/extraction/item/LlmSelfVerificationStepTest.java +++ b/memind-core/src/test/java/com/openmemind/ai/memory/core/extraction/item/LlmSelfVerificationStepTest.java @@ -26,9 +26,13 @@ import com.openmemind.ai.memory.core.prompt.extraction.item.SelfVerificationPrompts; import java.time.Instant; import java.util.List; +import java.util.Map; +import java.util.TimeZone; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.ResourceLock; +import org.junit.jupiter.api.parallel.Resources; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; @@ -276,6 +280,60 @@ void shouldPreserveObservedAtFromTheSourceSegment() { }) .verifyComplete(); } + + @Test + @ResourceLock(Resources.SYSTEM_PROPERTIES) + @DisplayName("Should normalize structured time returned by self verification") + void shouldNormalizeStructuredTimeReturnedBySelfVerification() { + var originalTimeZone = TimeZone.getDefault(); + TimeZone.setDefault(TimeZone.getTimeZone("UTC")); + try { + var response = + new MemoryItemExtractionResponse( + List.of( + new MemoryItemExtractionResponse.ExtractedItem( + "User traveled to Hangzhou during the week of" + + " 2026-04-06 to 2026-04-12", + 0.95f, + null, + new MemoryItemExtractionResponse.ExtractedTime( + "上周", + "2026-04-06T00:00:00Z", + "2026-04-13T00:00:00Z", + "week"), + List.of("experiences"), + Map.of("reviewStage", "self_verification"), + "event"))); + var step = new LlmSelfVerificationStep(new FakeStructuredChatClient(response)); + + StepVerifier.create( + step.verify( + ORIGINAL_TEXT, + List.of(), + RAW_DATA_ID, + Instant.parse("2026-04-16T10:00:00Z"), + List.of(), + null, + null)) + .assertNext( + result -> { + assertThat(result).hasSize(1); + assertThat(result.getFirst().occurredStart()) + .isEqualTo(Instant.parse("2026-04-06T00:00:00Z")); + assertThat(result.getFirst().occurredEnd()) + .isEqualTo(Instant.parse("2026-04-13T00:00:00Z")); + assertThat(result.getFirst().timeGranularity()) + .isEqualTo("week"); + assertThat(result.getFirst().occurredAt()).isNull(); + assertThat(result.getFirst().metadata()) + .containsEntry("reviewStage", "self_verification") + .containsEntry("timeExpression", "上周"); + }) + .verifyComplete(); + } finally { + TimeZone.setDefault(originalTimeZone); + } + } } private static ExtractedMemoryEntry createEntry(String content) { diff --git a/memind-core/src/test/java/com/openmemind/ai/memory/core/extraction/item/MemoryItemLayerLanguageTest.java b/memind-core/src/test/java/com/openmemind/ai/memory/core/extraction/item/MemoryItemLayerLanguageTest.java index a132af91..10410941 100644 --- a/memind-core/src/test/java/com/openmemind/ai/memory/core/extraction/item/MemoryItemLayerLanguageTest.java +++ b/memind-core/src/test/java/com/openmemind/ai/memory/core/extraction/item/MemoryItemLayerLanguageTest.java @@ -22,6 +22,7 @@ import com.openmemind.ai.memory.core.data.DefaultInsightTypes; import com.openmemind.ai.memory.core.data.DefaultMemoryId; import com.openmemind.ai.memory.core.data.enums.MemoryCategory; +import com.openmemind.ai.memory.core.data.enums.MemoryItemType; import com.openmemind.ai.memory.core.data.enums.MemoryScope; import com.openmemind.ai.memory.core.extraction.item.dedup.DeduplicationResult; import com.openmemind.ai.memory.core.extraction.item.dedup.MemoryItemDeduplicator; @@ -34,10 +35,12 @@ import com.openmemind.ai.memory.core.llm.StructuredChatClient; import com.openmemind.ai.memory.core.store.MemoryStore; import com.openmemind.ai.memory.core.store.insight.InsightOperations; +import com.openmemind.ai.memory.core.store.item.ItemOperations; import com.openmemind.ai.memory.core.support.TestDocumentContent; import com.openmemind.ai.memory.core.vector.MemoryVector; import java.time.Instant; import java.util.List; +import java.util.Map; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -191,6 +194,83 @@ void entriesOutsideAllowedCategoriesAreDroppedBeforeDeduplication() { .containsExactly("profile"); } + @Test + @DisplayName("Extract should persist normalized temporal fields and legacy anchor") + void extractShouldPersistNormalizedTemporalFieldsAndLegacyAnchor() { + MemoryItemExtractor extractor = mock(MemoryItemExtractor.class); + MemoryItemDeduplicator deduplicator = mock(MemoryItemDeduplicator.class); + MemoryStore memoryStore = mock(MemoryStore.class); + InsightOperations insightOperations = mock(InsightOperations.class); + ItemOperations itemOperations = mock(ItemOperations.class); + MemoryVector vector = mock(MemoryVector.class); + var layer = new MemoryItemLayer(extractor, deduplicator, memoryStore, vector); + var memoryId = DefaultMemoryId.of("user1", "agent1"); + + var segment = + new ParsedSegment( + "user: 我上周去了杭州", + "caption", + 0, + 1, + "raw-1", + Map.of(), + new SegmentRuntimeContext( + Instant.parse("2026-04-16T10:00:00Z"), + Instant.parse("2026-04-16T10:00:00Z"), + "User")); + var temporalEntry = + new ExtractedMemoryEntry( + "User traveled to Hangzhou during the week of 2026-04-06 to" + + " 2026-04-12", + 0.95f, + null, + Instant.parse("2026-04-06T00:00:00Z"), + Instant.parse("2026-04-13T00:00:00Z"), + "week", + Instant.parse("2026-04-16T10:00:00Z"), + "raw-1", + "hash-1", + List.of("experiences"), + Map.of("timeExpression", "上周"), + MemoryItemType.FACT, + "event"); + var config = + new ItemExtractionConfig( + MemoryScope.USER, + ConversationContent.TYPE, + MemoryCategory.userCategories(), + false, + "English"); + + when(memoryStore.insightOperations()).thenReturn(insightOperations); + when(memoryStore.itemOperations()).thenReturn(itemOperations); + when(insightOperations.listInsightTypes()).thenReturn(DefaultInsightTypes.all()); + when(extractor.extract(eq(List.of(segment)), anyList(), eq(config))) + .thenReturn(Mono.just(List.of(temporalEntry))); + when(deduplicator.deduplicate(eq(memoryId), anyList())) + .thenReturn(Mono.just(new DeduplicationResult(List.of(temporalEntry), List.of()))); + when(deduplicator.spanName()).thenReturn("test"); + when(vector.storeBatch(eq(memoryId), anyList(), anyList())) + .thenReturn(Mono.just(List.of("vec-1"))); + + StepVerifier.create( + layer.extract( + memoryId, + new RawDataResult(List.of(), List.of(segment), false), + config)) + .assertNext( + result -> { + var item = result.newItems().getFirst(); + assertThat(item.occurredAt()).isNull(); + assertThat(item.occurredStart()) + .isEqualTo(Instant.parse("2026-04-06T00:00:00Z")); + assertThat(item.occurredEnd()) + .isEqualTo(Instant.parse("2026-04-13T00:00:00Z")); + assertThat(item.timeGranularity()).isEqualTo("week"); + }) + .verifyComplete(); + } + private static final class CapturingSelfVerificationStep extends LlmSelfVerificationStep { private String capturedLanguage; diff --git a/memind-core/src/test/java/com/openmemind/ai/memory/core/extraction/item/strategy/LlmItemExtractionStrategyTest.java b/memind-core/src/test/java/com/openmemind/ai/memory/core/extraction/item/strategy/LlmItemExtractionStrategyTest.java index b957b75c..0128d153 100644 --- a/memind-core/src/test/java/com/openmemind/ai/memory/core/extraction/item/strategy/LlmItemExtractionStrategyTest.java +++ b/memind-core/src/test/java/com/openmemind/ai/memory/core/extraction/item/strategy/LlmItemExtractionStrategyTest.java @@ -32,9 +32,12 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.TimeZone; import java.util.concurrent.CopyOnWriteArrayList; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.ResourceLock; +import org.junit.jupiter.api.parallel.Resources; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; @@ -237,6 +240,72 @@ void mergeMetadataShouldPreservePromptBudgetAuditFields() { .containsEntry("source", "llm"); } + @Test + @ResourceLock(Resources.SYSTEM_PROPERTIES) + @DisplayName("extract should normalize structured week time") + void extractShouldNormalizeStructuredWeekTime() { + var originalTimeZone = TimeZone.getDefault(); + TimeZone.setDefault(TimeZone.getTimeZone("UTC")); + try { + var client = + new FakeStructuredChatClient( + new MemoryItemExtractionResponse( + List.of( + new MemoryItemExtractionResponse.ExtractedItem( + "User traveled to Hangzhou during the week" + + " of 2026-04-06 to 2026-04-12", + 0.95f, + null, + new MemoryItemExtractionResponse.ExtractedTime( + "上周", + "2026-04-06T00:00:00Z", + "2026-04-13T00:00:00Z", + "week"), + List.of("experiences"), + Map.of(), + "event"))), + new ForesightExtractionResponse(List.of())); + var strategy = new LlmItemExtractionStrategy(client); + + StepVerifier.create( + strategy.extract( + List.of( + new ParsedSegment( + "user: 我上周去了杭州", + null, + 0, + 1, + "raw-structured-time", + Map.of(), + new SegmentRuntimeContext( + Instant.parse("2026-04-16T10:00:00Z"), + Instant.parse("2026-04-16T10:00:00Z"), + "Alice"))), + List.of(), + new ItemExtractionConfig( + MemoryScope.USER, + ConversationContent.TYPE, + MemoryCategory.userCategories(), + false, + "English"))) + .assertNext( + entries -> { + assertThat(entries).hasSize(1); + assertThat(entries.getFirst().occurredStart()) + .isEqualTo(Instant.parse("2026-04-06T00:00:00Z")); + assertThat(entries.getFirst().occurredEnd()) + .isEqualTo(Instant.parse("2026-04-13T00:00:00Z")); + assertThat(entries.getFirst().timeGranularity()).isEqualTo("week"); + assertThat(entries.getFirst().occurredAt()).isNull(); + assertThat(entries.getFirst().metadata()) + .containsEntry("timeExpression", "上周"); + }) + .verifyComplete(); + } finally { + TimeZone.setDefault(originalTimeZone); + } + } + private static ParsedSegment sampleSegment() { return new ParsedSegment( "user: I work on Spring Boot services.", diff --git a/memind-core/src/test/java/com/openmemind/ai/memory/core/extraction/item/support/TemporalNormalizerTest.java b/memind-core/src/test/java/com/openmemind/ai/memory/core/extraction/item/support/TemporalNormalizerTest.java new file mode 100644 index 00000000..eeb26559 --- /dev/null +++ b/memind-core/src/test/java/com/openmemind/ai/memory/core/extraction/item/support/TemporalNormalizerTest.java @@ -0,0 +1,126 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.memory.core.extraction.item.support; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import java.util.TimeZone; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.ResourceLock; +import org.junit.jupiter.api.parallel.Resources; + +@ResourceLock(Resources.SYSTEM_PROPERTIES) +class TemporalNormalizerTest { + + private TimeZone originalTimeZone; + + @BeforeEach + void setSystemZone() { + originalTimeZone = TimeZone.getDefault(); + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Shanghai")); + } + + @AfterEach + void restoreSystemZone() { + TimeZone.setDefault(originalTimeZone); + } + + @Test + void normalizeDayExpressionBuildsHalfOpenInterval() { + var time = + new MemoryItemExtractionResponse.ExtractedTime( + "昨天", "2026-04-14T16:00:00Z", null, "day"); + + var temporal = + TemporalNormalizer.normalize(time, null, Instant.parse("2026-04-16T10:00:00Z")); + + assertThat(temporal.occurredStart()).isEqualTo(Instant.parse("2026-04-14T16:00:00Z")); + assertThat(temporal.occurredEnd()).isEqualTo(Instant.parse("2026-04-15T16:00:00Z")); + assertThat(temporal.granularity()).isEqualTo("day"); + assertThat(temporal.compatibilityOccurredAt()) + .isEqualTo(Instant.parse("2026-04-14T16:00:00Z")); + } + + @Test + void normalizeLegacyOccurredAtKeepsUnknownGranularityAndNoSyntheticEnd() { + var temporal = + TemporalNormalizer.normalize( + null, "2026-04-15T10:15:00Z", Instant.parse("2026-04-16T10:00:00Z")); + + assertThat(temporal.occurredStart()).isEqualTo(Instant.parse("2026-04-15T10:15:00Z")); + assertThat(temporal.occurredEnd()).isNull(); + assertThat(temporal.granularity()).isEqualTo("unknown"); + assertThat(temporal.compatibilityOccurredAt()) + .isEqualTo(Instant.parse("2026-04-15T10:15:00Z")); + } + + @Test + void normalizeRangeRejectsReversedInterval() { + var time = + new MemoryItemExtractionResponse.ExtractedTime( + "周三到周一", "2026-04-16T00:00:00Z", "2026-04-14T00:00:00Z", "range"); + + var temporal = + TemporalNormalizer.normalize(time, null, Instant.parse("2026-04-16T10:00:00Z")); + + assertThat(temporal.occurredStart()).isNull(); + assertThat(temporal.occurredEnd()).isNull(); + assertThat(temporal.compatibilityOccurredAt()).isNull(); + } + + @Test + void normalizeChineseMonthYearExpressionBuildsMonthBucket() { + var time = new MemoryItemExtractionResponse.ExtractedTime("今年3月", null, null, "month"); + + var temporal = + TemporalNormalizer.normalize(time, null, Instant.parse("2026-04-16T10:00:00Z")); + + assertThat(temporal.occurredStart()).isEqualTo(Instant.parse("2026-02-28T16:00:00Z")); + assertThat(temporal.occurredEnd()).isEqualTo(Instant.parse("2026-03-31T16:00:00Z")); + assertThat(temporal.granularity()).isEqualTo("month"); + assertThat(temporal.compatibilityOccurredAt()).isNull(); + } + + @Test + void normalizeRelativeDayUsesSystemZoneBeforePersistingUtc() { + var time = new MemoryItemExtractionResponse.ExtractedTime("昨天", null, null, "day"); + + var temporal = + TemporalNormalizer.normalize(time, null, Instant.parse("2026-04-15T17:00:00Z")); + + assertThat(temporal.occurredStart()).isEqualTo(Instant.parse("2026-04-14T16:00:00Z")); + assertThat(temporal.occurredEnd()).isEqualTo(Instant.parse("2026-04-15T16:00:00Z")); + assertThat(temporal.granularity()).isEqualTo("day"); + assertThat(temporal.compatibilityOccurredAt()) + .isEqualTo(Instant.parse("2026-04-14T16:00:00Z")); + } + + @Test + void normalizeMisalignedMonthBucketRecomputesCanonicalSystemZoneInterval() { + var time = + new MemoryItemExtractionResponse.ExtractedTime( + "今年3月", "2026-03-01T00:00:00Z", "2026-04-01T00:00:00Z", "month"); + + var temporal = + TemporalNormalizer.normalize(time, null, Instant.parse("2026-04-16T10:00:00Z")); + + assertThat(temporal.occurredStart()).isEqualTo(Instant.parse("2026-02-28T16:00:00Z")); + assertThat(temporal.occurredEnd()).isEqualTo(Instant.parse("2026-03-31T16:00:00Z")); + assertThat(temporal.granularity()).isEqualTo("month"); + assertThat(temporal.compatibilityOccurredAt()).isNull(); + } +} diff --git a/memind-core/src/test/java/com/openmemind/ai/memory/core/prompt/extraction/item/MemoryItemUnifiedPromptsTest.java b/memind-core/src/test/java/com/openmemind/ai/memory/core/prompt/extraction/item/MemoryItemUnifiedPromptsTest.java index 415c28cb..60a7991f 100644 --- a/memind-core/src/test/java/com/openmemind/ai/memory/core/prompt/extraction/item/MemoryItemUnifiedPromptsTest.java +++ b/memind-core/src/test/java/com/openmemind/ai/memory/core/prompt/extraction/item/MemoryItemUnifiedPromptsTest.java @@ -22,8 +22,11 @@ import java.time.Instant; import java.util.List; import java.util.Set; +import java.util.TimeZone; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.ResourceLock; +import org.junit.jupiter.api.parallel.Resources; class MemoryItemUnifiedPromptsTest { @@ -122,8 +125,8 @@ void shouldExcludeSupportiveAssistantSuggestionsFromAgentMemory() { } @Test - @DisplayName("Rendered prompt should enforce strict occurredAt semantics") - void shouldEnforceStrictOccurredAtSemantics() { + @DisplayName("Rendered prompt should enforce structured temporal semantics") + void shouldEnforceStructuredTemporalSemantics() { var insightTypes = List.of( createInsightType("profile", List.of("profile")), @@ -149,12 +152,57 @@ void shouldEnforceStrictOccurredAtSemantics() { .contains( "Profile, behavior, directive, playbook, resolution, and tool items should" + " normally set") - .contains("Event items should populate `occurredAt` only when the text itself") + .contains("Event items should populate `time` only when the text itself") .contains( - "Do NOT use message timestamps or conversation timestamps as `occurredAt`") + "Do NOT use message timestamps or conversation timestamps as default" + + " temporal") + .contains("canonical half-open bucket") .contains("reference anchor for resolving relative expressions"); } + @Test + @DisplayName("Rendered prompt should expose structured time schema") + void shouldRenderStructuredTimeSchemaInUnifiedPrompt() { + var result = + MemoryItemUnifiedPrompts.build( + List.of(), + "user: 我上周去了杭州", + Instant.parse("2026-04-16T00:00:00Z"), + null, + Set.of(MemoryCategory.EVENT)) + .render("English"); + + assertThat(result.systemPrompt()) + .contains("time.expression") + .contains("time.start") + .contains("time.end") + .contains("time.granularity"); + } + + @Test + @ResourceLock(Resources.SYSTEM_PROPERTIES) + @DisplayName("Rendered prompt should resolve temporal context in system zone") + void shouldRenderTemporalContextUsingSystemZone() { + var originalTimeZone = TimeZone.getDefault(); + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Shanghai")); + try { + var result = + MemoryItemUnifiedPrompts.build( + List.of(), + "user: 我昨天修好了线上 bug", + Instant.parse("2026-04-15T17:00:00Z"), + null, + Set.of(MemoryCategory.EVENT)) + .render("English"); + + assertThat(result.systemPrompt()) + .contains("System Time Zone: Asia/Shanghai") + .contains("Today's date: 2026-04-16"); + } finally { + TimeZone.setDefault(originalTimeZone); + } + } + @Test @DisplayName("Rendered prompt should require explicit third-party subject references") void shouldRequireExplicitThirdPartySubjectReferences() { diff --git a/memind-core/src/test/java/com/openmemind/ai/memory/core/prompt/extraction/item/SelfVerificationPromptsTest.java b/memind-core/src/test/java/com/openmemind/ai/memory/core/prompt/extraction/item/SelfVerificationPromptsTest.java index 5f4f2862..bfde4fbd 100644 --- a/memind-core/src/test/java/com/openmemind/ai/memory/core/prompt/extraction/item/SelfVerificationPromptsTest.java +++ b/memind-core/src/test/java/com/openmemind/ai/memory/core/prompt/extraction/item/SelfVerificationPromptsTest.java @@ -24,8 +24,11 @@ import java.time.Instant; import java.util.List; import java.util.Set; +import java.util.TimeZone; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.ResourceLock; +import org.junit.jupiter.api.parallel.Resources; class SelfVerificationPromptsTest { @@ -184,8 +187,8 @@ void shouldDescribeNewAgentCategoriesAndStrictGating() { } @Test - @DisplayName("Review prompt should enforce strict occurredAt semantics") - void shouldEnforceStrictOccurredAtSemantics() { + @DisplayName("Review prompt should enforce structured temporal semantics") + void shouldEnforceStructuredTemporalSemantics() { var template = SelfVerificationPrompts.build( """ @@ -209,12 +212,59 @@ void shouldEnforceStrictOccurredAtSemantics() { .contains( "Profile, behavior, directive, playbook, resolution, and tool items should" + " normally set") - .contains("Event items should populate `occurredAt` only when the text itself") + .contains("Event items should populate `time` only when the text itself") .contains( - "Do NOT use message timestamps or conversation timestamps as `occurredAt`") + "Do NOT use message timestamps or conversation timestamps as default" + + " temporal") + .contains("canonical half-open bucket") .contains("reference anchor for resolving relative expressions"); } + @Test + @DisplayName("Review prompt should expose structured time schema") + void shouldRenderStructuredTimeSchemaInReviewPrompt() { + var result = + SelfVerificationPrompts.build( + "user: 我上周去了杭州", + List.of(), + Instant.parse("2026-04-16T00:00:00Z"), + List.of(createInsightType("experiences", List.of("event"))), + null, + Set.of(MemoryCategory.EVENT)) + .render("English"); + + assertThat(result.systemPrompt()) + .contains("time.expression") + .contains("time.start") + .contains("time.end") + .contains("time.granularity"); + } + + @Test + @ResourceLock(Resources.SYSTEM_PROPERTIES) + @DisplayName("Review prompt should resolve temporal context in system zone") + void shouldRenderTemporalContextUsingSystemZone() { + var originalTimeZone = TimeZone.getDefault(); + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Shanghai")); + try { + var result = + SelfVerificationPrompts.build( + "user: 我昨天修好了线上 bug", + List.of(), + Instant.parse("2026-04-15T17:00:00Z"), + List.of(createInsightType("experiences", List.of("event"))), + null, + Set.of(MemoryCategory.EVENT)) + .render("English"); + + assertThat(result.systemPrompt()) + .contains("System Time Zone: Asia/Shanghai") + .contains("Today's date: 2026-04-16"); + } finally { + TimeZone.setDefault(originalTimeZone); + } + } + @Test @DisplayName("Review prompt should reject ambiguous third-party pronouns") void shouldRejectAmbiguousThirdPartyPronouns() { diff --git a/memind-plugins/memind-plugin-jdbc/src/main/java/com/openmemind/ai/memory/plugin/jdbc/internal/schema/StoreSchemaBootstrap.java b/memind-plugins/memind-plugin-jdbc/src/main/java/com/openmemind/ai/memory/plugin/jdbc/internal/schema/StoreSchemaBootstrap.java index d5cac802..c3ae50da 100644 --- a/memind-plugins/memind-plugin-jdbc/src/main/java/com/openmemind/ai/memory/plugin/jdbc/internal/schema/StoreSchemaBootstrap.java +++ b/memind-plugins/memind-plugin-jdbc/src/main/java/com/openmemind/ai/memory/plugin/jdbc/internal/schema/StoreSchemaBootstrap.java @@ -25,16 +25,22 @@ public final class StoreSchemaBootstrap { "db/jdbc/sqlite/store/V2__multimodal.sql"; private static final String SQLITE_BUBBLE_STATE_RESOURCE = "db/jdbc/sqlite/store/V3__bubble_state.sql"; + private static final String SQLITE_ITEM_TEMPORAL_RESOURCE = + "db/jdbc/sqlite/store/V4__item_temporal_fields.sql"; private static final String MYSQL_STORE_RESOURCE = "db/jdbc/mysql/store/V1__init.sql"; private static final String MYSQL_MULTIMODAL_RESOURCE = "db/jdbc/mysql/store/V2__multimodal.sql"; private static final String MYSQL_BUBBLE_STATE_RESOURCE = "db/jdbc/mysql/store/V3__bubble_state.sql"; + private static final String MYSQL_ITEM_TEMPORAL_RESOURCE = + "db/jdbc/mysql/store/V4__item_temporal_fields.sql"; private static final String POSTGRESQL_STORE_RESOURCE = "db/jdbc/postgresql/store/V1__init.sql"; private static final String POSTGRESQL_MULTIMODAL_RESOURCE = "db/jdbc/postgresql/store/V2__multimodal.sql"; private static final String POSTGRESQL_BUBBLE_STATE_RESOURCE = "db/jdbc/postgresql/store/V3__bubble_state.sql"; + private static final String POSTGRESQL_ITEM_TEMPORAL_RESOURCE = + "db/jdbc/postgresql/store/V4__item_temporal_fields.sql"; private StoreSchemaBootstrap() {} @@ -60,6 +66,7 @@ public static StoreSchemaInitResult ensureSqlite( SqlScriptRunner.execute(dataSource, SQLITE_MULTIMODAL_RESOURCE); } ensureSqliteBubbleStateSchema(dataSource, createIfNotExist); + ensureSqliteItemTemporalSchema(dataSource, createIfNotExist); ensureSqliteInsightConfidenceRemoved(dataSource, createIfNotExist); boolean hasInsightTypeTable = SchemaVerifier.hasSqliteTable(dataSource, "memory_insight_type"); @@ -88,6 +95,7 @@ public static StoreSchemaInitResult ensureMysql( SqlScriptRunner.execute(dataSource, MYSQL_MULTIMODAL_RESOURCE); } ensureMysqlBubbleStateSchema(dataSource, createIfNotExist); + ensureMysqlItemTemporalSchema(dataSource, createIfNotExist); ensureMysqlInsightConfidenceRemoved(dataSource, createIfNotExist); boolean hasInsightTypeTable = SchemaVerifier.hasMysqlTable(dataSource, "memory_insight_type"); @@ -116,6 +124,7 @@ public static StoreSchemaInitResult ensurePostgresql( SqlScriptRunner.execute(dataSource, POSTGRESQL_MULTIMODAL_RESOURCE); } ensurePostgresqlBubbleStateSchema(dataSource, createIfNotExist); + ensurePostgresqlItemTemporalSchema(dataSource, createIfNotExist); ensurePostgresqlInsightConfidenceRemoved(dataSource, createIfNotExist); boolean hasInsightTypeTable = SchemaVerifier.hasPostgresqlTable(dataSource, "memory_insight_type"); @@ -177,6 +186,33 @@ private static void ensureSqliteBubbleStateSchema( SqlScriptRunner.execute(dataSource, SQLITE_BUBBLE_STATE_RESOURCE); } + private static void ensureSqliteItemTemporalSchema( + DataSource dataSource, boolean createIfNotExist) { + if (hasRequiredSqliteItemTemporalSchema(dataSource)) { + return; + } + if (!createIfNotExist) { + throw new JdbcPluginException( + "Missing required SQLite item temporal schema: occurred_start"); + } + if (hasAnySqliteItemTemporalColumn(dataSource)) { + ensureSqliteItemTemporalColumn( + dataSource, + "occurred_start", + "ALTER TABLE memory_item ADD COLUMN occurred_start TEXT"); + ensureSqliteItemTemporalColumn( + dataSource, + "occurred_end", + "ALTER TABLE memory_item ADD COLUMN occurred_end TEXT"); + ensureSqliteItemTemporalColumn( + dataSource, + "time_granularity", + "ALTER TABLE memory_item ADD COLUMN time_granularity TEXT"); + return; + } + SqlScriptRunner.execute(dataSource, SQLITE_ITEM_TEMPORAL_RESOURCE); + } + private static void ensureMysqlBubbleStateSchema( DataSource dataSource, boolean createIfNotExist) { if (SchemaVerifier.hasMysqlTable(dataSource, "memory_insight_bubble_state")) { @@ -189,6 +225,33 @@ private static void ensureMysqlBubbleStateSchema( SqlScriptRunner.execute(dataSource, MYSQL_BUBBLE_STATE_RESOURCE); } + private static void ensureMysqlItemTemporalSchema( + DataSource dataSource, boolean createIfNotExist) { + if (hasRequiredMysqlItemTemporalSchema(dataSource)) { + return; + } + if (!createIfNotExist) { + throw new JdbcPluginException( + "Missing required MySQL item temporal schema: occurred_start"); + } + if (hasAnyMysqlItemTemporalColumn(dataSource)) { + ensureMysqlItemTemporalColumn( + dataSource, + "occurred_start", + "ALTER TABLE memory_item ADD COLUMN occurred_start DATETIME(3) NULL"); + ensureMysqlItemTemporalColumn( + dataSource, + "occurred_end", + "ALTER TABLE memory_item ADD COLUMN occurred_end DATETIME(3) NULL"); + ensureMysqlItemTemporalColumn( + dataSource, + "time_granularity", + "ALTER TABLE memory_item ADD COLUMN time_granularity VARCHAR(16) NULL"); + return; + } + SqlScriptRunner.execute(dataSource, MYSQL_ITEM_TEMPORAL_RESOURCE); + } + private static void ensurePostgresqlBubbleStateSchema( DataSource dataSource, boolean createIfNotExist) { if (SchemaVerifier.hasPostgresqlTable(dataSource, "memory_insight_bubble_state")) { @@ -201,6 +264,33 @@ private static void ensurePostgresqlBubbleStateSchema( SqlScriptRunner.execute(dataSource, POSTGRESQL_BUBBLE_STATE_RESOURCE); } + private static void ensurePostgresqlItemTemporalSchema( + DataSource dataSource, boolean createIfNotExist) { + if (hasRequiredPostgresqlItemTemporalSchema(dataSource)) { + return; + } + if (!createIfNotExist) { + throw new JdbcPluginException( + "Missing required PostgreSQL item temporal schema: occurred_start"); + } + if (hasAnyPostgresqlItemTemporalColumn(dataSource)) { + ensurePostgresqlItemTemporalColumn( + dataSource, + "occurred_start", + "ALTER TABLE memory_item ADD COLUMN occurred_start TIMESTAMPTZ"); + ensurePostgresqlItemTemporalColumn( + dataSource, + "occurred_end", + "ALTER TABLE memory_item ADD COLUMN occurred_end TIMESTAMPTZ"); + ensurePostgresqlItemTemporalColumn( + dataSource, + "time_granularity", + "ALTER TABLE memory_item ADD COLUMN time_granularity VARCHAR(16)"); + return; + } + SqlScriptRunner.execute(dataSource, POSTGRESQL_ITEM_TEMPORAL_RESOURCE); + } + private static void ensureSqliteInsightConfidenceRemoved( DataSource dataSource, boolean createIfNotExist) { if (!SchemaVerifier.hasSqliteColumn(dataSource, "memory_insight", "confidence")) { @@ -236,4 +326,66 @@ private static void ensurePostgresqlInsightConfidenceRemoved( } JdbcExecutor.execute(dataSource, "ALTER TABLE memory_insight DROP COLUMN confidence"); } + + private static boolean hasRequiredSqliteItemTemporalSchema(DataSource dataSource) { + return SchemaVerifier.hasSqliteColumn(dataSource, "memory_item", "occurred_start") + && SchemaVerifier.hasSqliteColumn(dataSource, "memory_item", "occurred_end") + && SchemaVerifier.hasSqliteColumn(dataSource, "memory_item", "time_granularity"); + } + + private static boolean hasAnySqliteItemTemporalColumn(DataSource dataSource) { + return SchemaVerifier.hasSqliteColumn(dataSource, "memory_item", "occurred_start") + || SchemaVerifier.hasSqliteColumn(dataSource, "memory_item", "occurred_end") + || SchemaVerifier.hasSqliteColumn(dataSource, "memory_item", "time_granularity"); + } + + private static void ensureSqliteItemTemporalColumn( + DataSource dataSource, String columnName, String sql) { + if (SchemaVerifier.hasSqliteColumn(dataSource, "memory_item", columnName)) { + return; + } + JdbcExecutor.execute(dataSource, sql); + } + + private static boolean hasRequiredMysqlItemTemporalSchema(DataSource dataSource) { + return SchemaVerifier.hasMysqlColumn(dataSource, "memory_item", "occurred_start") + && SchemaVerifier.hasMysqlColumn(dataSource, "memory_item", "occurred_end") + && SchemaVerifier.hasMysqlColumn(dataSource, "memory_item", "time_granularity"); + } + + private static boolean hasAnyMysqlItemTemporalColumn(DataSource dataSource) { + return SchemaVerifier.hasMysqlColumn(dataSource, "memory_item", "occurred_start") + || SchemaVerifier.hasMysqlColumn(dataSource, "memory_item", "occurred_end") + || SchemaVerifier.hasMysqlColumn(dataSource, "memory_item", "time_granularity"); + } + + private static void ensureMysqlItemTemporalColumn( + DataSource dataSource, String columnName, String sql) { + if (SchemaVerifier.hasMysqlColumn(dataSource, "memory_item", columnName)) { + return; + } + JdbcExecutor.execute(dataSource, sql); + } + + private static boolean hasRequiredPostgresqlItemTemporalSchema(DataSource dataSource) { + return SchemaVerifier.hasPostgresqlColumn(dataSource, "memory_item", "occurred_start") + && SchemaVerifier.hasPostgresqlColumn(dataSource, "memory_item", "occurred_end") + && SchemaVerifier.hasPostgresqlColumn( + dataSource, "memory_item", "time_granularity"); + } + + private static boolean hasAnyPostgresqlItemTemporalColumn(DataSource dataSource) { + return SchemaVerifier.hasPostgresqlColumn(dataSource, "memory_item", "occurred_start") + || SchemaVerifier.hasPostgresqlColumn(dataSource, "memory_item", "occurred_end") + || SchemaVerifier.hasPostgresqlColumn( + dataSource, "memory_item", "time_granularity"); + } + + private static void ensurePostgresqlItemTemporalColumn( + DataSource dataSource, String columnName, String sql) { + if (SchemaVerifier.hasPostgresqlColumn(dataSource, "memory_item", columnName)) { + return; + } + JdbcExecutor.execute(dataSource, sql); + } } diff --git a/memind-plugins/memind-plugin-jdbc/src/main/java/com/openmemind/ai/memory/plugin/jdbc/mysql/MysqlMemoryStore.java b/memind-plugins/memind-plugin-jdbc/src/main/java/com/openmemind/ai/memory/plugin/jdbc/mysql/MysqlMemoryStore.java index a65cea90..0fc98cc0 100644 --- a/memind-plugins/memind-plugin-jdbc/src/main/java/com/openmemind/ai/memory/plugin/jdbc/mysql/MysqlMemoryStore.java +++ b/memind-plugins/memind-plugin-jdbc/src/main/java/com/openmemind/ai/memory/plugin/jdbc/mysql/MysqlMemoryStore.java @@ -343,9 +343,9 @@ public void insertItems(MemoryId memoryId, List items) { INSERT INTO memory_item (biz_id, user_id, agent_id, memory_id, content, scope, category, vector_id, raw_data_id, content_hash, occurred_at, - observed_at, type, raw_data_type, metadata, created_at, - updated_at, deleted) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0) + occurred_start, occurred_end, time_granularity, observed_at, + type, raw_data_type, metadata, created_at, updated_at, deleted) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0) """)) { Instant now = Instant.now(); for (MemoryItem item : items) { @@ -863,14 +863,17 @@ private void bindItemInsert( statement.setString(9, item.rawDataId()); statement.setString(10, item.contentHash()); setTimestamp(statement, 11, item.occurredAt()); - setTimestamp(statement, 12, item.observedAt()); + setTimestamp(statement, 12, item.occurredStart()); + setTimestamp(statement, 13, item.occurredEnd()); + statement.setString(14, item.timeGranularity()); + setTimestamp(statement, 15, item.observedAt()); statement.setString( - 13, item.type() != null ? item.type().name() : MemoryItemType.FACT.name()); + 16, item.type() != null ? item.type().name() : MemoryItemType.FACT.name()); statement.setString( - 14, item.contentType() != null ? item.contentType() : ConversationContent.TYPE); - statement.setString(15, jsonHelper.toJson(item.metadata())); - setTimestamp(statement, 16, item.createdAt() != null ? item.createdAt() : now); - setTimestamp(statement, 17, now); + 17, item.contentType() != null ? item.contentType() : ConversationContent.TYPE); + statement.setString(18, jsonHelper.toJson(item.metadata())); + setTimestamp(statement, 19, item.createdAt() != null ? item.createdAt() : now); + setTimestamp(statement, 20, now); } private void bindInsightTypeUpsert( @@ -951,6 +954,15 @@ private MemoryResource mapResource(ResultSet resultSet) throws SQLException { } private MemoryItem mapItem(ResultSet resultSet) throws SQLException { + Instant occurredAt = parseInstant(resultSet.getTimestamp("occurred_at")); + Instant occurredStart = parseInstant(resultSet.getTimestamp("occurred_start")); + if (occurredStart == null) { + occurredStart = occurredAt; + } + String timeGranularity = resultSet.getString("time_granularity"); + if (occurredStart != null && (timeGranularity == null || timeGranularity.isBlank())) { + timeGranularity = "unknown"; + } return new MemoryItem( resultSet.getLong("biz_id"), resultSet.getString("memory_id"), @@ -961,7 +973,10 @@ private MemoryItem mapItem(ResultSet resultSet) throws SQLException { resultSet.getString("vector_id"), resultSet.getString("raw_data_id"), resultSet.getString("content_hash"), - parseInstant(resultSet.getTimestamp("occurred_at")), + occurredAt, + occurredStart, + parseInstant(resultSet.getTimestamp("occurred_end")), + timeGranularity, parseInstant(resultSet.getTimestamp("observed_at")), jsonHelper.fromJson(resultSet.getString("metadata"), OBJECT_MAP_TYPE), parseInstant(resultSet.getTimestamp("created_at")), diff --git a/memind-plugins/memind-plugin-jdbc/src/main/java/com/openmemind/ai/memory/plugin/jdbc/postgresql/PostgresqlMemoryStore.java b/memind-plugins/memind-plugin-jdbc/src/main/java/com/openmemind/ai/memory/plugin/jdbc/postgresql/PostgresqlMemoryStore.java index 543bb1e7..2b704bf7 100644 --- a/memind-plugins/memind-plugin-jdbc/src/main/java/com/openmemind/ai/memory/plugin/jdbc/postgresql/PostgresqlMemoryStore.java +++ b/memind-plugins/memind-plugin-jdbc/src/main/java/com/openmemind/ai/memory/plugin/jdbc/postgresql/PostgresqlMemoryStore.java @@ -344,9 +344,9 @@ public void insertItems(MemoryId memoryId, List items) { INSERT INTO memory_item (biz_id, user_id, agent_id, memory_id, content, scope, category, vector_id, raw_data_id, content_hash, occurred_at, - observed_at, type, raw_data_type, metadata, created_at, - updated_at, deleted) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, FALSE) + occurred_start, occurred_end, time_granularity, observed_at, + type, raw_data_type, metadata, created_at, updated_at, deleted) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, FALSE) """)) { Instant now = Instant.now(); for (MemoryItem item : items) { @@ -864,14 +864,17 @@ private void bindItemInsert( statement.setString(9, item.rawDataId()); statement.setString(10, item.contentHash()); setTimestamp(statement, 11, item.occurredAt()); - setTimestamp(statement, 12, item.observedAt()); + setTimestamp(statement, 12, item.occurredStart()); + setTimestamp(statement, 13, item.occurredEnd()); + statement.setString(14, item.timeGranularity()); + setTimestamp(statement, 15, item.observedAt()); statement.setString( - 13, item.type() != null ? item.type().name() : MemoryItemType.FACT.name()); + 16, item.type() != null ? item.type().name() : MemoryItemType.FACT.name()); statement.setString( - 14, item.contentType() != null ? item.contentType() : ConversationContent.TYPE); - setJsonb(statement, 15, jsonHelper.toJson(item.metadata())); - setTimestamp(statement, 16, item.createdAt() != null ? item.createdAt() : now); - setTimestamp(statement, 17, now); + 17, item.contentType() != null ? item.contentType() : ConversationContent.TYPE); + setJsonb(statement, 18, jsonHelper.toJson(item.metadata())); + setTimestamp(statement, 19, item.createdAt() != null ? item.createdAt() : now); + setTimestamp(statement, 20, now); } private void bindInsightTypeUpsert( @@ -952,6 +955,15 @@ private MemoryResource mapResource(ResultSet resultSet) throws SQLException { } private MemoryItem mapItem(ResultSet resultSet) throws SQLException { + Instant occurredAt = parseInstant(resultSet.getTimestamp("occurred_at")); + Instant occurredStart = parseInstant(resultSet.getTimestamp("occurred_start")); + if (occurredStart == null) { + occurredStart = occurredAt; + } + String timeGranularity = resultSet.getString("time_granularity"); + if (occurredStart != null && (timeGranularity == null || timeGranularity.isBlank())) { + timeGranularity = "unknown"; + } return new MemoryItem( resultSet.getLong("biz_id"), resultSet.getString("memory_id"), @@ -962,7 +974,10 @@ private MemoryItem mapItem(ResultSet resultSet) throws SQLException { resultSet.getString("vector_id"), resultSet.getString("raw_data_id"), resultSet.getString("content_hash"), - parseInstant(resultSet.getTimestamp("occurred_at")), + occurredAt, + occurredStart, + parseInstant(resultSet.getTimestamp("occurred_end")), + timeGranularity, parseInstant(resultSet.getTimestamp("observed_at")), jsonHelper.fromJson(resultSet.getString("metadata"), OBJECT_MAP_TYPE), parseInstant(resultSet.getTimestamp("created_at")), diff --git a/memind-plugins/memind-plugin-jdbc/src/main/java/com/openmemind/ai/memory/plugin/jdbc/sqlite/SqliteMemoryStore.java b/memind-plugins/memind-plugin-jdbc/src/main/java/com/openmemind/ai/memory/plugin/jdbc/sqlite/SqliteMemoryStore.java index c6c0bbfd..bda27ccd 100644 --- a/memind-plugins/memind-plugin-jdbc/src/main/java/com/openmemind/ai/memory/plugin/jdbc/sqlite/SqliteMemoryStore.java +++ b/memind-plugins/memind-plugin-jdbc/src/main/java/com/openmemind/ai/memory/plugin/jdbc/sqlite/SqliteMemoryStore.java @@ -357,9 +357,9 @@ public void insertItems(MemoryId memoryId, List items) { INSERT INTO memory_item (biz_id, user_id, agent_id, memory_id, content, scope, category, vector_id, raw_data_id, content_hash, occurred_at, - observed_at, type, raw_data_type, metadata, created_at, - updated_at, deleted) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0) + occurred_start, occurred_end, time_granularity, observed_at, + type, raw_data_type, metadata, created_at, updated_at, deleted) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0) """)) { Instant now = Instant.now(); for (MemoryItem item : items) { @@ -879,14 +879,17 @@ private void bindItemInsert( statement.setString(9, item.rawDataId()); statement.setString(10, item.contentHash()); statement.setString(11, writeInstant(item.occurredAt())); - statement.setString(12, writeInstant(item.observedAt())); + statement.setString(12, writeInstant(item.occurredStart())); + statement.setString(13, writeInstant(item.occurredEnd())); + statement.setString(14, item.timeGranularity()); + statement.setString(15, writeInstant(item.observedAt())); statement.setString( - 13, item.type() != null ? item.type().name() : MemoryItemType.FACT.name()); + 16, item.type() != null ? item.type().name() : MemoryItemType.FACT.name()); statement.setString( - 14, item.contentType() != null ? item.contentType() : ConversationContent.TYPE); - statement.setString(15, jsonHelper.toJson(item.metadata())); - statement.setString(16, writeInstant(item.createdAt() != null ? item.createdAt() : now)); - statement.setString(17, writeInstant(now)); + 17, item.contentType() != null ? item.contentType() : ConversationContent.TYPE); + statement.setString(18, jsonHelper.toJson(item.metadata())); + statement.setString(19, writeInstant(item.createdAt() != null ? item.createdAt() : now)); + statement.setString(20, writeInstant(now)); } private void bindInsightTypeUpsert( @@ -968,6 +971,15 @@ private MemoryResource mapResource(ResultSet resultSet) throws SQLException { } private MemoryItem mapItem(ResultSet resultSet) throws SQLException { + Instant occurredAt = parseInstant(resultSet.getString("occurred_at")); + Instant occurredStart = parseInstant(resultSet.getString("occurred_start")); + if (occurredStart == null) { + occurredStart = occurredAt; + } + String timeGranularity = resultSet.getString("time_granularity"); + if (occurredStart != null && (timeGranularity == null || timeGranularity.isBlank())) { + timeGranularity = "unknown"; + } return new MemoryItem( resultSet.getLong("biz_id"), resultSet.getString("memory_id"), @@ -978,7 +990,10 @@ private MemoryItem mapItem(ResultSet resultSet) throws SQLException { resultSet.getString("vector_id"), resultSet.getString("raw_data_id"), resultSet.getString("content_hash"), - parseInstant(resultSet.getString("occurred_at")), + occurredAt, + occurredStart, + parseInstant(resultSet.getString("occurred_end")), + timeGranularity, parseInstant(resultSet.getString("observed_at")), jsonHelper.fromJson(resultSet.getString("metadata"), OBJECT_MAP_TYPE), parseInstant(resultSet.getString("created_at")), diff --git a/memind-plugins/memind-plugin-jdbc/src/main/resources/db/jdbc/mysql/store/V4__item_temporal_fields.sql b/memind-plugins/memind-plugin-jdbc/src/main/resources/db/jdbc/mysql/store/V4__item_temporal_fields.sql new file mode 100644 index 00000000..db648237 --- /dev/null +++ b/memind-plugins/memind-plugin-jdbc/src/main/resources/db/jdbc/mysql/store/V4__item_temporal_fields.sql @@ -0,0 +1,18 @@ +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +-- + +ALTER TABLE memory_item + ADD COLUMN occurred_start DATETIME(3) NULL, + ADD COLUMN occurred_end DATETIME(3) NULL, + ADD COLUMN time_granularity VARCHAR(16) NULL; diff --git a/memind-plugins/memind-plugin-jdbc/src/main/resources/db/jdbc/postgresql/store/V4__item_temporal_fields.sql b/memind-plugins/memind-plugin-jdbc/src/main/resources/db/jdbc/postgresql/store/V4__item_temporal_fields.sql new file mode 100644 index 00000000..b5e487a4 --- /dev/null +++ b/memind-plugins/memind-plugin-jdbc/src/main/resources/db/jdbc/postgresql/store/V4__item_temporal_fields.sql @@ -0,0 +1,18 @@ +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +-- + +ALTER TABLE memory_item + ADD COLUMN occurred_start TIMESTAMPTZ, + ADD COLUMN occurred_end TIMESTAMPTZ, + ADD COLUMN time_granularity VARCHAR(16); diff --git a/memind-plugins/memind-plugin-jdbc/src/main/resources/db/jdbc/sqlite/store/V4__item_temporal_fields.sql b/memind-plugins/memind-plugin-jdbc/src/main/resources/db/jdbc/sqlite/store/V4__item_temporal_fields.sql new file mode 100644 index 00000000..c8f6518c --- /dev/null +++ b/memind-plugins/memind-plugin-jdbc/src/main/resources/db/jdbc/sqlite/store/V4__item_temporal_fields.sql @@ -0,0 +1,17 @@ +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +-- + +ALTER TABLE memory_item ADD COLUMN occurred_start TEXT; +ALTER TABLE memory_item ADD COLUMN occurred_end TEXT; +ALTER TABLE memory_item ADD COLUMN time_granularity TEXT; diff --git a/memind-plugins/memind-plugin-jdbc/src/test/java/com/openmemind/ai/memory/plugin/jdbc/internal/schema/StoreSchemaBootstrapTest.java b/memind-plugins/memind-plugin-jdbc/src/test/java/com/openmemind/ai/memory/plugin/jdbc/internal/schema/StoreSchemaBootstrapTest.java index 8530e70a..b572776f 100644 --- a/memind-plugins/memind-plugin-jdbc/src/test/java/com/openmemind/ai/memory/plugin/jdbc/internal/schema/StoreSchemaBootstrapTest.java +++ b/memind-plugins/memind-plugin-jdbc/src/test/java/com/openmemind/ai/memory/plugin/jdbc/internal/schema/StoreSchemaBootstrapTest.java @@ -70,6 +70,9 @@ void ensureSqliteUpgradesLegacyStoreSchemaWithMultimodalTablesAndColumns( assertThat(columnExists(dataSource, "memory_insight", "confidence")).isFalse(); assertThat(columnExists(dataSource, "memory_raw_data", "resource_id")).isTrue(); assertThat(columnExists(dataSource, "memory_raw_data", "mime_type")).isTrue(); + assertThat(columnExists(dataSource, "memory_item", "occurred_start")).isTrue(); + assertThat(columnExists(dataSource, "memory_item", "occurred_end")).isTrue(); + assertThat(columnExists(dataSource, "memory_item", "time_granularity")).isTrue(); assertThat(result.createdInsightTypeTable()).isFalse(); } @@ -96,7 +99,25 @@ CREATE TABLE memory_raw_data ( statement.execute( """ CREATE TABLE memory_item ( - id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + biz_id INTEGER NOT NULL, + user_id TEXT NOT NULL, + agent_id TEXT NOT NULL, + memory_id TEXT NOT NULL, + content TEXT NOT NULL, + scope TEXT NOT NULL, + category TEXT, + vector_id TEXT, + raw_data_id TEXT, + content_hash TEXT, + occurred_at TEXT, + observed_at TEXT, + type TEXT NOT NULL DEFAULT 'FACT', + raw_data_type TEXT NOT NULL DEFAULT 'CONVERSATION', + metadata TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + deleted INTEGER NOT NULL DEFAULT 0 ) """); statement.execute( diff --git a/memind-plugins/memind-plugin-jdbc/src/test/java/com/openmemind/ai/memory/plugin/jdbc/sqlite/SqliteMemoryStoreTest.java b/memind-plugins/memind-plugin-jdbc/src/test/java/com/openmemind/ai/memory/plugin/jdbc/sqlite/SqliteMemoryStoreTest.java index 9e552ee5..ad714d66 100644 --- a/memind-plugins/memind-plugin-jdbc/src/test/java/com/openmemind/ai/memory/plugin/jdbc/sqlite/SqliteMemoryStoreTest.java +++ b/memind-plugins/memind-plugin-jdbc/src/test/java/com/openmemind/ai/memory/plugin/jdbc/sqlite/SqliteMemoryStoreTest.java @@ -82,6 +82,9 @@ void constructorInitializesSchemaByDefault() { assertThat(columnExists(emptyDataSource, "memory_insight", "confidence")).isFalse(); assertThat(columnExists(emptyDataSource, "memory_raw_data", "resource_id")).isTrue(); assertThat(columnExists(emptyDataSource, "memory_raw_data", "mime_type")).isTrue(); + assertThat(columnExists(emptyDataSource, "memory_item", "occurred_start")).isTrue(); + assertThat(columnExists(emptyDataSource, "memory_item", "occurred_end")).isTrue(); + assertThat(columnExists(emptyDataSource, "memory_item", "time_granularity")).isTrue(); } @Test @@ -370,6 +373,89 @@ void itemsCanPersistWithNullOccurredAt() { }); } + @Test + void itemsRoundTripStructuredTemporalFields() { + Instant occurredAt = BASE_TIME.minusSeconds(120); + Instant occurredStart = BASE_TIME.minusSeconds(120); + Instant occurredEnd = BASE_TIME.plusSeconds(3600); + Instant observedAt = BASE_TIME.plusSeconds(30); + MemoryItem structuredTemporalItem = + new MemoryItem( + 104L, + memoryId.toIdentifier(), + "timeline event", + MemoryScope.USER, + MemoryCategory.EVENT, + ConversationContent.TYPE, + "vec-104", + "rd-104", + "hash-104", + occurredAt, + occurredStart, + occurredEnd, + "range", + observedAt, + Map.of("kind", "event"), + BASE_TIME, + MemoryItemType.FACT); + + store.insertItems(memoryId, List.of(structuredTemporalItem)); + + assertThat(store.getItemsByIds(memoryId, List.of(104L))) + .singleElement() + .satisfies( + item -> { + assertThat(item.occurredAt()).isEqualTo(occurredAt); + assertThat(item.occurredStart()).isEqualTo(occurredStart); + assertThat(item.occurredEnd()).isEqualTo(occurredEnd); + assertThat(item.timeGranularity()).isEqualTo("range"); + assertThat(item.observedAt()).isEqualTo(observedAt); + }); + } + + @Test + void legacyItemRowsFallbackToOccurredAtWhenStructuredTemporalFieldsAreNull() { + Instant occurredAt = BASE_TIME.minusSeconds(1800); + Instant observedAt = BASE_TIME.plusSeconds(45); + + executeUpdate( + """ + INSERT INTO memory_item + (biz_id, user_id, agent_id, memory_id, content, scope, category, vector_id, + raw_data_id, content_hash, occurred_at, observed_at, type, raw_data_type, + metadata, created_at, updated_at, deleted) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0) + """, + 105L, + memoryId.userId(), + memoryId.agentId(), + memoryId.toIdentifier(), + "legacy temporal item", + MemoryScope.USER.name(), + MemoryCategory.EVENT.name(), + "vec-105", + "rd-105", + "hash-105", + occurredAt.toString(), + observedAt.toString(), + MemoryItemType.FACT.name(), + ConversationContent.TYPE, + "{\"legacy\":true}", + BASE_TIME.toString(), + BASE_TIME.toString()); + + assertThat(store.getItemsByIds(memoryId, List.of(105L))) + .singleElement() + .satisfies( + item -> { + assertThat(item.occurredAt()).isEqualTo(occurredAt); + assertThat(item.occurredStart()).isEqualTo(occurredAt); + assertThat(item.occurredEnd()).isNull(); + assertThat(item.timeGranularity()).isEqualTo("unknown"); + assertThat(item.observedAt()).isEqualTo(observedAt); + }); + } + @Test void insightTypesCanBeUpsertedAndListed() { MemoryInsightType initial = diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-toolcall/src/main/java/com/openmemind/ai/memory/plugin/rawdata/toolcall/item/strategy/LlmToolCallItemExtractionStrategy.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-toolcall/src/main/java/com/openmemind/ai/memory/plugin/rawdata/toolcall/item/strategy/LlmToolCallItemExtractionStrategy.java index 12d8eac2..4cca677f 100644 --- a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-toolcall/src/main/java/com/openmemind/ai/memory/plugin/rawdata/toolcall/item/strategy/LlmToolCallItemExtractionStrategy.java +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-toolcall/src/main/java/com/openmemind/ai/memory/plugin/rawdata/toolcall/item/strategy/LlmToolCallItemExtractionStrategy.java @@ -135,6 +135,9 @@ private ExtractedMemoryEntry toEntry( response.content(), 1.0f, null, + null, + null, + null, observedAt, rawDataId, null, diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-toolcall/src/test/java/com/openmemind/ai/memory/plugin/rawdata/toolcall/item/strategy/LlmToolCallItemExtractionStrategyTest.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-toolcall/src/test/java/com/openmemind/ai/memory/plugin/rawdata/toolcall/item/strategy/LlmToolCallItemExtractionStrategyTest.java index 6e2e1e98..8721c616 100644 --- a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-toolcall/src/test/java/com/openmemind/ai/memory/plugin/rawdata/toolcall/item/strategy/LlmToolCallItemExtractionStrategyTest.java +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-toolcall/src/test/java/com/openmemind/ai/memory/plugin/rawdata/toolcall/item/strategy/LlmToolCallItemExtractionStrategyTest.java @@ -54,6 +54,29 @@ void extractShouldUseToolItemOverrideInstruction() { .contains("Custom tool item instruction"); } + @Test + @DisplayName("extract should keep tool call temporal fields empty") + void extractShouldKeepToolCallTemporalFieldsEmpty() { + var client = new FakeStructuredChatClient(new ToolItemResponse("Tool usage insight", 1.0)); + var strategy = new LlmToolCallItemExtractionStrategy(client); + + StepVerifier.create( + strategy.extract( + List.of(sampleSegment()), + List.of(), + ItemExtractionConfig.defaults())) + .assertNext( + entries -> { + assertThat(entries).hasSize(1); + var entry = entries.getFirst(); + assertThat(entry.occurredAt()).isNull(); + assertThat(entry.occurredStart()).isNull(); + assertThat(entry.occurredEnd()).isNull(); + assertThat(entry.timeGranularity()).isNull(); + }) + .verifyComplete(); + } + private static ParsedSegment sampleSegment() { return new ParsedSegment( "tool-call summary", diff --git a/memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-mybatis-plus-starter/src/main/java/com/openmemind/ai/memory/plugin/store/mybatis/converter/ItemConverter.java b/memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-mybatis-plus-starter/src/main/java/com/openmemind/ai/memory/plugin/store/mybatis/converter/ItemConverter.java index b9efc157..e4c95af0 100644 --- a/memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-mybatis-plus-starter/src/main/java/com/openmemind/ai/memory/plugin/store/mybatis/converter/ItemConverter.java +++ b/memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-mybatis-plus-starter/src/main/java/com/openmemind/ai/memory/plugin/store/mybatis/converter/ItemConverter.java @@ -41,6 +41,9 @@ public static MemoryItemDO toDO(MemoryId memoryId, MemoryItem record) { dataObject.setRawDataId(record.rawDataId()); dataObject.setContentHash(record.contentHash()); dataObject.setOccurredAt(record.occurredAt()); + dataObject.setOccurredStart(record.occurredStart()); + dataObject.setOccurredEnd(record.occurredEnd()); + dataObject.setTimeGranularity(record.timeGranularity()); dataObject.setObservedAt(record.observedAt()); dataObject.setMetadata(record.metadata()); dataObject.setCreatedAt(record.createdAt() != null ? record.createdAt() : Instant.now()); @@ -51,6 +54,15 @@ public static MemoryItemDO toDO(MemoryId memoryId, MemoryItem record) { } public static MemoryItem toRecord(MemoryItemDO dataObject) { + Instant occurredAt = dataObject.getOccurredAt(); + Instant occurredStart = dataObject.getOccurredStart(); + if (occurredStart == null) { + occurredStart = occurredAt; + } + String timeGranularity = dataObject.getTimeGranularity(); + if (occurredStart != null && (timeGranularity == null || timeGranularity.isBlank())) { + timeGranularity = "unknown"; + } return new MemoryItem( dataObject.getBizId(), dataObject.getMemoryId(), @@ -61,7 +73,10 @@ public static MemoryItem toRecord(MemoryItemDO dataObject) { dataObject.getVectorId(), dataObject.getRawDataId(), dataObject.getContentHash(), - dataObject.getOccurredAt(), + occurredAt, + occurredStart, + dataObject.getOccurredEnd(), + timeGranularity, dataObject.getObservedAt(), dataObject.getMetadata(), dataObject.getCreatedAt(), diff --git a/memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-mybatis-plus-starter/src/main/java/com/openmemind/ai/memory/plugin/store/mybatis/dataobject/MemoryItemDO.java b/memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-mybatis-plus-starter/src/main/java/com/openmemind/ai/memory/plugin/store/mybatis/dataobject/MemoryItemDO.java index 20cef7bc..d129e7f0 100644 --- a/memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-mybatis-plus-starter/src/main/java/com/openmemind/ai/memory/plugin/store/mybatis/dataobject/MemoryItemDO.java +++ b/memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-mybatis-plus-starter/src/main/java/com/openmemind/ai/memory/plugin/store/mybatis/dataobject/MemoryItemDO.java @@ -41,6 +41,14 @@ public class MemoryItemDO extends BaseDO { @TableField(typeHandler = InstantTypeHandler.class) private Instant occurredAt; + @TableField(typeHandler = InstantTypeHandler.class) + private Instant occurredStart; + + @TableField(typeHandler = InstantTypeHandler.class) + private Instant occurredEnd; + + private String timeGranularity; + @TableField(typeHandler = InstantTypeHandler.class) private Instant observedAt; @@ -151,6 +159,30 @@ public Instant getObservedAt() { return observedAt; } + public Instant getOccurredStart() { + return occurredStart; + } + + public void setOccurredStart(Instant occurredStart) { + this.occurredStart = occurredStart; + } + + public Instant getOccurredEnd() { + return occurredEnd; + } + + public void setOccurredEnd(Instant occurredEnd) { + this.occurredEnd = occurredEnd; + } + + public String getTimeGranularity() { + return timeGranularity; + } + + public void setTimeGranularity(String timeGranularity) { + this.timeGranularity = timeGranularity; + } + public void setObservedAt(Instant observedAt) { this.observedAt = observedAt; } diff --git a/memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-mybatis-plus-starter/src/main/java/com/openmemind/ai/memory/plugin/store/mybatis/schema/DatabaseDialect.java b/memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-mybatis-plus-starter/src/main/java/com/openmemind/ai/memory/plugin/store/mybatis/schema/DatabaseDialect.java index 359da316..8ab301ea 100644 --- a/memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-mybatis-plus-starter/src/main/java/com/openmemind/ai/memory/plugin/store/mybatis/schema/DatabaseDialect.java +++ b/memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-mybatis-plus-starter/src/main/java/com/openmemind/ai/memory/plugin/store/mybatis/schema/DatabaseDialect.java @@ -20,17 +20,20 @@ public enum DatabaseDialect { "db/migration/sqlite/V1__init_store.sql", "db/migration/sqlite/V2__init_text_search.sql", "db/migration/sqlite/V3__multimodal.sql", - "db/migration/sqlite/V4__bubble_state.sql"), + "db/migration/sqlite/V4__bubble_state.sql", + "db/migration/sqlite/V5__item_temporal_fields.sql"), MYSQL( "db/migration/mysql/V1__init_store.sql", "db/migration/mysql/V2__init_text_search.sql", "db/migration/mysql/V3__multimodal.sql", - "db/migration/mysql/V4__bubble_state.sql"), + "db/migration/mysql/V4__bubble_state.sql", + "db/migration/mysql/V5__item_temporal_fields.sql"), POSTGRESQL( "db/migration/postgresql/V1__init_store.sql", "db/migration/postgresql/V2__init_text_search.sql", "db/migration/postgresql/V3__multimodal.sql", - "db/migration/postgresql/V4__bubble_state.sql"); + "db/migration/postgresql/V4__bubble_state.sql", + "db/migration/postgresql/V5__item_temporal_fields.sql"); private final List scriptPaths; diff --git a/memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-mybatis-plus-starter/src/main/java/com/openmemind/ai/memory/plugin/store/mybatis/schema/MemoryStoreDdl.java b/memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-mybatis-plus-starter/src/main/java/com/openmemind/ai/memory/plugin/store/mybatis/schema/MemoryStoreDdl.java index 27b5f973..4d14480d 100644 --- a/memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-mybatis-plus-starter/src/main/java/com/openmemind/ai/memory/plugin/store/mybatis/schema/MemoryStoreDdl.java +++ b/memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-mybatis-plus-starter/src/main/java/com/openmemind/ai/memory/plugin/store/mybatis/schema/MemoryStoreDdl.java @@ -48,6 +48,7 @@ public void runScript(Consumer consumer) { boolean hadInsightTypeTable = tableExists("memory_insight_type"); consumer.accept(dataSource); dropInsightConfidenceIfPresent(); + ensureItemTemporalColumnsPresent(); if (!hadInsightTypeTable && tableExists("memory_insight_type")) { seedDefaultInsightTypes(); } @@ -109,6 +110,63 @@ private void dropInsightConfidenceIfPresent() { new JdbcTemplate(dataSource).execute(sql); } + private void ensureItemTemporalColumnsPresent() { + if (!tableExists("memory_item")) { + return; + } + switch (databaseDialectDetector.detect(dataSource)) { + case SQLITE -> { + ensureColumn( + "memory_item", + "occurred_start", + "ALTER TABLE memory_item ADD COLUMN occurred_start TEXT"); + ensureColumn( + "memory_item", + "occurred_end", + "ALTER TABLE memory_item ADD COLUMN occurred_end TEXT"); + ensureColumn( + "memory_item", + "time_granularity", + "ALTER TABLE memory_item ADD COLUMN time_granularity TEXT"); + } + case MYSQL -> { + ensureColumn( + "memory_item", + "occurred_start", + "ALTER TABLE memory_item ADD COLUMN occurred_start DATETIME(3) NULL"); + ensureColumn( + "memory_item", + "occurred_end", + "ALTER TABLE memory_item ADD COLUMN occurred_end DATETIME(3) NULL"); + ensureColumn( + "memory_item", + "time_granularity", + "ALTER TABLE memory_item ADD COLUMN time_granularity VARCHAR(16) NULL"); + } + case POSTGRESQL -> { + ensureColumn( + "memory_item", + "occurred_start", + "ALTER TABLE memory_item ADD COLUMN occurred_start TIMESTAMPTZ"); + ensureColumn( + "memory_item", + "occurred_end", + "ALTER TABLE memory_item ADD COLUMN occurred_end TIMESTAMPTZ"); + ensureColumn( + "memory_item", + "time_granularity", + "ALTER TABLE memory_item ADD COLUMN time_granularity VARCHAR(16)"); + } + } + } + + private void ensureColumn(String tableName, String columnName, String sql) { + if (columnExists(tableName, columnName)) { + return; + } + new JdbcTemplate(dataSource).execute(sql); + } + private void seedDefaultInsightTypes() { JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource); String sql = insightTypeInsertSql(); diff --git a/memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-mybatis-plus-starter/src/main/resources/db/migration/mysql/V5__item_temporal_fields.sql b/memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-mybatis-plus-starter/src/main/resources/db/migration/mysql/V5__item_temporal_fields.sql new file mode 100644 index 00000000..31d87397 --- /dev/null +++ b/memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-mybatis-plus-starter/src/main/resources/db/migration/mysql/V5__item_temporal_fields.sql @@ -0,0 +1,15 @@ +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +-- + +-- Temporal item columns are ensured by MemoryStoreDdl for dialect-safe upgrades. diff --git a/memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-mybatis-plus-starter/src/main/resources/db/migration/postgresql/V5__item_temporal_fields.sql b/memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-mybatis-plus-starter/src/main/resources/db/migration/postgresql/V5__item_temporal_fields.sql new file mode 100644 index 00000000..31d87397 --- /dev/null +++ b/memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-mybatis-plus-starter/src/main/resources/db/migration/postgresql/V5__item_temporal_fields.sql @@ -0,0 +1,15 @@ +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +-- + +-- Temporal item columns are ensured by MemoryStoreDdl for dialect-safe upgrades. diff --git a/memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-mybatis-plus-starter/src/main/resources/db/migration/sqlite/V5__item_temporal_fields.sql b/memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-mybatis-plus-starter/src/main/resources/db/migration/sqlite/V5__item_temporal_fields.sql new file mode 100644 index 00000000..31d87397 --- /dev/null +++ b/memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-mybatis-plus-starter/src/main/resources/db/migration/sqlite/V5__item_temporal_fields.sql @@ -0,0 +1,15 @@ +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +-- + +-- Temporal item columns are ensured by MemoryStoreDdl for dialect-safe upgrades. diff --git a/memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-mybatis-plus-starter/src/test/java/com/openmemind/ai/memory/plugin/store/mybatis/MybatisPlusMemoryStoreBatchOperationsTest.java b/memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-mybatis-plus-starter/src/test/java/com/openmemind/ai/memory/plugin/store/mybatis/MybatisPlusMemoryStoreBatchOperationsTest.java index fe9f4782..e47bfac6 100644 --- a/memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-mybatis-plus-starter/src/test/java/com/openmemind/ai/memory/plugin/store/mybatis/MybatisPlusMemoryStoreBatchOperationsTest.java +++ b/memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-mybatis-plus-starter/src/test/java/com/openmemind/ai/memory/plugin/store/mybatis/MybatisPlusMemoryStoreBatchOperationsTest.java @@ -57,6 +57,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.datasource.DataSourceTransactionManager; import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.annotation.EnableTransactionManagement; @@ -286,6 +287,100 @@ void insertItemsAllowsNullOccurredAtForNonTemporalItems() { }); } + @Test + @DisplayName("insertItems round-trips structured temporal fields") + void insertItemsRoundTripsStructuredTemporalFields() { + newContextRunner(tempDir.resolve("structured-temporal-items.db")) + .run( + context -> { + ItemOperations itemOps = + context.getBean(MemoryStore.class).itemOperations(); + Instant occurredAt = BASE_TIME.minusSeconds(120); + Instant occurredStart = BASE_TIME.minusSeconds(120); + Instant occurredEnd = BASE_TIME.plusSeconds(3600); + + itemOps.insertItems( + MEMORY_ID, + List.of( + memoryItem( + 11L, + "temporal range item", + occurredAt, + occurredStart, + occurredEnd, + "range"))); + + assertThat(itemOps.getItemsByIds(MEMORY_ID, List.of(11L))) + .singleElement() + .satisfies( + item -> { + assertThat(item.occurredAt()).isEqualTo(occurredAt); + assertThat(item.occurredStart()) + .isEqualTo(occurredStart); + assertThat(item.occurredEnd()) + .isEqualTo(occurredEnd); + assertThat(item.timeGranularity()) + .isEqualTo("range"); + }); + }); + } + + @Test + @DisplayName("legacy rows fall back to occurredAt when structured temporal fields are empty") + void legacyRowsFallbackToOccurredAtWhenStructuredTemporalFieldsAreEmpty() { + newContextRunner(tempDir.resolve("legacy-temporal-items.db")) + .run( + context -> { + ItemOperations itemOps = + context.getBean(MemoryStore.class).itemOperations(); + JdbcTemplate jdbcTemplate = + new JdbcTemplate(context.getBean(DataSource.class)); + Instant occurredAt = BASE_TIME.minusSeconds(1800); + Instant observedAt = BASE_TIME.plusSeconds(45); + + jdbcTemplate.update( + """ + INSERT INTO memory_item + (biz_id, user_id, agent_id, memory_id, content, scope, + category, vector_id, raw_data_id, content_hash, occurred_at, + observed_at, type, raw_data_type, metadata, created_at, + updated_at, deleted) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + 12L, + MEMORY_ID.getAttribute("userId"), + MEMORY_ID.getAttribute("agentId"), + MEMORY_ID.toIdentifier(), + "legacy temporal item", + MemoryScope.USER.name(), + MemoryCategory.EVENT.name(), + "vector-12", + "raw-12", + "hash-12", + occurredAt.toString(), + observedAt.toString(), + MemoryItemType.FACT.name(), + "conversation", + "{\"legacy\":true}", + BASE_TIME.toString(), + BASE_TIME.toString(), + 0); + + assertThat(itemOps.getItemsByIds(MEMORY_ID, List.of(12L))) + .singleElement() + .satisfies( + item -> { + assertThat(item.occurredAt()).isEqualTo(occurredAt); + assertThat(item.occurredStart()) + .isEqualTo(occurredAt); + assertThat(item.occurredEnd()).isNull(); + assertThat(item.timeGranularity()) + .isEqualTo("unknown"); + assertThat(item.observedAt()).isEqualTo(observedAt); + }); + }); + } + private ApplicationContextRunner newContextRunner(Path dbPath) { return new ApplicationContextRunner() .withConfiguration( @@ -339,6 +434,33 @@ private static MemoryItem memoryItem(Long id, String content, Instant occurredAt MemoryItemType.FACT); } + private static MemoryItem memoryItem( + Long id, + String content, + Instant occurredAt, + Instant occurredStart, + Instant occurredEnd, + String timeGranularity) { + return new MemoryItem( + id, + MEMORY_ID.toIdentifier(), + content, + MemoryScope.USER, + MemoryCategory.EVENT, + "conversation", + "vector-" + id, + "raw-" + id, + "hash-" + id, + occurredAt, + occurredStart, + occurredEnd, + timeGranularity, + BASE_TIME.plusSeconds(30), + Map.of("content", content), + BASE_TIME, + MemoryItemType.FACT); + } + @Configuration(proxyBeanMethods = false) @Import({TransactionalTestConfig.class, SqliteTestSupportConfig.class}) static class TestInfrastructureConfig { diff --git a/memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-mybatis-plus-starter/src/test/java/com/openmemind/ai/memory/plugin/store/mybatis/schema/MemorySchemaAutoConfigurationTest.java b/memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-mybatis-plus-starter/src/test/java/com/openmemind/ai/memory/plugin/store/mybatis/schema/MemorySchemaAutoConfigurationTest.java index 73c0fc98..c7d8292f 100644 --- a/memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-mybatis-plus-starter/src/test/java/com/openmemind/ai/memory/plugin/store/mybatis/schema/MemorySchemaAutoConfigurationTest.java +++ b/memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-mybatis-plus-starter/src/test/java/com/openmemind/ai/memory/plugin/store/mybatis/schema/MemorySchemaAutoConfigurationTest.java @@ -61,7 +61,8 @@ void activatesWhenDataSourceExistsAndInitSchemaEnabled() { "db/migration/sqlite/V1__init_store.sql", "db/migration/sqlite/V2__init_text_search.sql", "db/migration/sqlite/V3__multimodal.sql", - "db/migration/sqlite/V4__bubble_state.sql"); + "db/migration/sqlite/V4__bubble_state.sql", + "db/migration/sqlite/V5__item_temporal_fields.sql"); DdlApplicationRunner runner = context.getBean(DdlApplicationRunner.class); diff --git a/memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-mybatis-plus-starter/src/test/java/com/openmemind/ai/memory/plugin/store/mybatis/schema/MemoryStoreDdlTest.java b/memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-mybatis-plus-starter/src/test/java/com/openmemind/ai/memory/plugin/store/mybatis/schema/MemoryStoreDdlTest.java index 760ea2ae..422a7823 100644 --- a/memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-mybatis-plus-starter/src/test/java/com/openmemind/ai/memory/plugin/store/mybatis/schema/MemoryStoreDdlTest.java +++ b/memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-mybatis-plus-starter/src/test/java/com/openmemind/ai/memory/plugin/store/mybatis/schema/MemoryStoreDdlTest.java @@ -66,7 +66,8 @@ void loadsSqliteScripts() { "db/migration/sqlite/V1__init_store.sql", "db/migration/sqlite/V2__init_text_search.sql", "db/migration/sqlite/V3__multimodal.sql", - "db/migration/sqlite/V4__bubble_state.sql"); + "db/migration/sqlite/V4__bubble_state.sql", + "db/migration/sqlite/V5__item_temporal_fields.sql"); } @Test @@ -81,7 +82,8 @@ void loadsMysqlScripts() { "db/migration/mysql/V1__init_store.sql", "db/migration/mysql/V2__init_text_search.sql", "db/migration/mysql/V3__multimodal.sql", - "db/migration/mysql/V4__bubble_state.sql"); + "db/migration/mysql/V4__bubble_state.sql", + "db/migration/mysql/V5__item_temporal_fields.sql"); } @Test @@ -98,7 +100,8 @@ void loadsPostgresqlScripts() { "db/migration/postgresql/V1__init_store.sql", "db/migration/postgresql/V2__init_text_search.sql", "db/migration/postgresql/V3__multimodal.sql", - "db/migration/postgresql/V4__bubble_state.sql"); + "db/migration/postgresql/V4__bubble_state.sql", + "db/migration/postgresql/V5__item_temporal_fields.sql"); } @Test