diff --git a/bootstrap/sql/migrations/native/2.0.0/mysql/schemaChanges.sql b/bootstrap/sql/migrations/native/2.0.0/mysql/schemaChanges.sql index 814df2c138bb..45f25fd8f6c4 100644 --- a/bootstrap/sql/migrations/native/2.0.0/mysql/schemaChanges.sql +++ b/bootstrap/sql/migrations/native/2.0.0/mysql/schemaChanges.sql @@ -340,3 +340,18 @@ CREATE TABLE IF NOT EXISTS search_index_retry_queue ( KEY idx_search_index_retry_queue_status (status), KEY idx_search_index_retry_queue_claimed_at (claimedAt) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +-- ContextMemory entity - reusable Context Center memory. +CREATE TABLE IF NOT EXISTS context_memory ( + id VARCHAR(36) GENERATED ALWAYS AS (json ->> '$.id') STORED NOT NULL, + name VARCHAR(256) GENERATED ALWAYS AS (json ->> '$.name') STORED NOT NULL, + nameHash VARCHAR(256) NOT NULL COLLATE ascii_bin, + json JSON NOT NULL, + updatedAt BIGINT UNSIGNED GENERATED ALWAYS AS (json ->> '$.updatedAt') STORED NOT NULL, + updatedBy VARCHAR(256) GENERATED ALWAYS AS (json ->> '$.updatedBy') STORED NOT NULL, + deleted BOOLEAN GENERATED ALWAYS AS (json -> '$.deleted') STORED, + + PRIMARY KEY (id), + UNIQUE KEY unique_context_memory_name (nameHash), + INDEX idx_context_memory_updated_at (updatedAt) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; diff --git a/bootstrap/sql/migrations/native/2.0.0/postgres/schemaChanges.sql b/bootstrap/sql/migrations/native/2.0.0/postgres/schemaChanges.sql index f544b9511cea..6ce3236f6daa 100644 --- a/bootstrap/sql/migrations/native/2.0.0/postgres/schemaChanges.sql +++ b/bootstrap/sql/migrations/native/2.0.0/postgres/schemaChanges.sql @@ -291,3 +291,18 @@ CREATE INDEX IF NOT EXISTS idx_search_index_retry_queue_status ON search_index_retry_queue (status); CREATE INDEX IF NOT EXISTS idx_search_index_retry_queue_claimed_at ON search_index_retry_queue (claimedAt); + +-- ContextMemory entity - reusable Context Center memory. +CREATE TABLE IF NOT EXISTS context_memory ( + id VARCHAR(36) GENERATED ALWAYS AS (json ->> 'id') STORED NOT NULL, + name VARCHAR(256) GENERATED ALWAYS AS (json ->> 'name') STORED NOT NULL, + nameHash VARCHAR(256) NOT NULL, + json JSONB NOT NULL, + updatedAt BIGINT GENERATED ALWAYS AS ((json ->> 'updatedAt')::bigint) STORED NOT NULL, + updatedBy VARCHAR(256) GENERATED ALWAYS AS (json ->> 'updatedBy') STORED NOT NULL, + deleted BOOLEAN GENERATED ALWAYS AS ((json ->> 'deleted')::boolean) STORED, + + PRIMARY KEY (id), + UNIQUE (nameHash) +); +CREATE INDEX IF NOT EXISTS idx_context_memory_updated_at ON context_memory (updatedAt); diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/ContextMemoryIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/ContextMemoryIT.java new file mode 100644 index 000000000000..15c0bf386488 --- /dev/null +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/ContextMemoryIT.java @@ -0,0 +1,587 @@ +package org.openmetadata.it.tests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; +import org.openmetadata.it.util.SdkClients; +import org.openmetadata.it.util.TestNamespace; +import org.openmetadata.schema.api.context.CreateContextMemory; +import org.openmetadata.schema.entity.context.ContextMemory; +import org.openmetadata.schema.entity.context.ContextMemoryScope; +import org.openmetadata.schema.entity.context.ContextMemoryStatus; +import org.openmetadata.schema.entity.context.ContextMemoryType; +import org.openmetadata.schema.entity.context.MemoryShareConfig; +import org.openmetadata.schema.entity.context.MemorySharedPrincipal; +import org.openmetadata.schema.entity.context.MemoryVisibility; +import org.openmetadata.schema.entity.teams.User; +import org.openmetadata.schema.type.EntityHistory; +import org.openmetadata.schema.type.EntityReference; +import org.openmetadata.sdk.fluent.Users; +import org.openmetadata.sdk.models.ListParams; +import org.openmetadata.sdk.models.ListResponse; +import org.openmetadata.sdk.services.context.ContextMemoryService; + +/** + * Integration tests for ContextMemory entity operations. + * + *

Tests ContextMemory CRUD operations, status lifecycle transitions, scope/visibility handling, + * and context-memory-specific validations. + * + *

Modeled on LearningResourceIT, the reference entity for the ContextMemory OSS implementation. + */ +@Execution(ExecutionMode.CONCURRENT) +public class ContextMemoryIT extends BaseEntityIT { + + public ContextMemoryIT() { + supportsPatch = true; + supportsFollowers = false; + supportsTags = true; + supportsOwners = true; + supportsDomains = true; + supportsDataProducts = false; + supportsCustomExtension = true; + supportsSearchIndex = false; + } + + // =================================================================== + // ABSTRACT METHOD IMPLEMENTATIONS (Required by BaseEntityIT) + // =================================================================== + + @Override + protected CreateContextMemory createMinimalRequest(TestNamespace ns) { + return new CreateContextMemory() + .withName(ns.prefix("context-memory")) + .withDescription("Test context memory") + .withQuestion("How do I find certified tables?") + .withAnswer("Filter the Explore page by the Certification tag."); + } + + @Override + protected CreateContextMemory createRequest(String name, TestNamespace ns) { + return new CreateContextMemory() + .withName(name) + .withDescription("Test context memory") + .withQuestion("What is the data quality SLA?") + .withAnswer("Critical tables must pass tests every 24 hours."); + } + + @Override + protected ContextMemory createEntity(CreateContextMemory createRequest) { + return getContextMemoryService().create(createRequest); + } + + @Override + protected ContextMemory getEntity(String id) { + return getContextMemoryService().get(id); + } + + @Override + protected ContextMemory getEntityByName(String fqn) { + return getContextMemoryService().getByName(fqn); + } + + @Override + protected ContextMemory patchEntity(String id, ContextMemory entity) { + return getContextMemoryService().update(id, entity); + } + + @Override + protected void deleteEntity(String id) { + getContextMemoryService().delete(id); + } + + @Override + protected void restoreEntity(String id) { + getContextMemoryService().restore(id); + } + + @Override + protected void hardDeleteEntity(String id) { + Map params = new HashMap<>(); + params.put("hardDelete", "true"); + getContextMemoryService().delete(id, params); + } + + @Override + protected String getEntityType() { + return "contextMemory"; + } + + @Override + protected void validateCreatedEntity(ContextMemory entity, CreateContextMemory createRequest) { + assertEquals(createRequest.getName(), entity.getName()); + + if (createRequest.getDescription() != null) { + assertEquals(createRequest.getDescription(), entity.getDescription()); + } + + if (createRequest.getDisplayName() != null) { + assertEquals(createRequest.getDisplayName(), entity.getDisplayName()); + } + + assertEquals(createRequest.getQuestion(), entity.getQuestion()); + assertEquals(createRequest.getAnswer(), entity.getAnswer()); + + assertTrue( + entity.getFullyQualifiedName().contains(entity.getName()), + "FQN should contain memory name"); + } + + @Override + protected ListResponse listEntities(ListParams params) { + return getContextMemoryService().list(params); + } + + @Override + protected ContextMemory getEntityWithFields(String id, String fields) { + return getContextMemoryService().get(id, fields); + } + + @Override + protected ContextMemory getEntityByNameWithFields(String fqn, String fields) { + return getContextMemoryService().getByName(fqn, fields); + } + + @Override + protected ContextMemory getEntityIncludeDeleted(String id) { + return getContextMemoryService().get(id, null, "deleted"); + } + + @Override + protected EntityHistory getVersionHistory(UUID id) { + return getContextMemoryService().getVersionList(id); + } + + @Override + protected ContextMemory getVersion(UUID id, Double version) { + return getContextMemoryService().getVersion(id.toString(), version); + } + + // =================================================================== + // CRUD TESTS + // =================================================================== + + @Test + void post_contextMemory_200_OK(TestNamespace ns) { + CreateContextMemory request = + new CreateContextMemory() + .withName(ns.prefix("crud-memory")) + .withDescription("CRUD happy path") + .withQuestion("Where are the gold datasets?") + .withAnswer("Under the Sales domain tagged Tier.Gold."); + + ContextMemory memory = createEntity(request); + assertNotNull(memory.getId()); + assertEquals(request.getName(), memory.getName()); + assertEquals("Where are the gold datasets?", memory.getQuestion()); + assertEquals("Under the Sales domain tagged Tier.Gold.", memory.getAnswer()); + assertEquals(0.1, memory.getVersion(), 0.001); + + ContextMemory fetched = getEntity(memory.getId().toString()); + assertEquals(memory.getId(), fetched.getId()); + assertEquals(memory.getName(), fetched.getName()); + } + + @Test + void post_contextMemoryWithQuestionAnswerSummaryTitle_200_OK(TestNamespace ns) { + CreateContextMemory request = + new CreateContextMemory() + .withName(ns.prefix("rich-memory")) + .withDisplayName("Certification Lookup") + .withDescription("Full content memory") + .withTitle("How to find certified data") + .withSummary("Use the Certification tag filter on Explore.") + .withQuestion("How do I find certified tables?") + .withAnswer("Filter the Explore page by Certification = Certified."); + + ContextMemory memory = createEntity(request); + assertEquals("Certification Lookup", memory.getDisplayName()); + assertEquals("How to find certified data", memory.getTitle()); + assertEquals("Use the Certification tag filter on Explore.", memory.getSummary()); + assertEquals("How do I find certified tables?", memory.getQuestion()); + assertEquals("Filter the Explore page by Certification = Certified.", memory.getAnswer()); + } + + @Test + void post_contextMemoryWithoutRequiredFields_400(TestNamespace ns) { + assertThrows( + Exception.class, + () -> createEntity(new CreateContextMemory().withName(null)), + "Creating memory without name should fail"); + + assertThrows( + Exception.class, + () -> + createEntity( + new CreateContextMemory() + .withName(ns.prefix("no-question")) + .withAnswer("An answer without a question.")), + "Creating memory without question should fail"); + + assertThrows( + Exception.class, + () -> + createEntity( + new CreateContextMemory() + .withName(ns.prefix("no-answer")) + .withQuestion("A question without an answer?")), + "Creating memory without answer should fail"); + } + + @Test + void post_contextMemoryDuplicateName_409(TestNamespace ns) { + String memoryName = ns.prefix("duplicate-memory"); + + createEntity( + new CreateContextMemory() + .withName(memoryName) + .withDescription("First memory") + .withQuestion("First question?") + .withAnswer("First answer.")); + + CreateContextMemory duplicate = + new CreateContextMemory() + .withName(memoryName) + .withDescription("Duplicate memory") + .withQuestion("Duplicate question?") + .withAnswer("Duplicate answer."); + + assertThrows(Exception.class, () -> createEntity(duplicate), "Duplicate name should fail"); + } + + @Test + void get_contextMemoryByFqn_200_OK(TestNamespace ns) { + CreateContextMemory request = + new CreateContextMemory() + .withName(ns.prefix("fqn-memory")) + .withDescription("FQN lookup test") + .withQuestion("What is the FQN of this memory?") + .withAnswer("The FQN equals the name for context memories."); + + ContextMemory memory = createEntity(request); + + // FQN == name for ContextMemory (ContextMemoryRepository.setFullyQualifiedName). + assertEquals(memory.getName(), memory.getFullyQualifiedName()); + + ContextMemory byFqn = getEntityByName(memory.getFullyQualifiedName()); + assertEquals(memory.getId(), byFqn.getId()); + assertEquals(memory.getName(), byFqn.getName()); + } + + // =================================================================== + // STATUS LIFECYCLE TESTS + // =================================================================== + + @Test + void put_contextMemoryStatusTransitions_valid_200_OK(TestNamespace ns) { + CreateContextMemory request = + new CreateContextMemory() + .withName(ns.prefix("status-valid")) + .withDescription("Valid status transitions") + .withQuestion("What is the status flow?") + .withAnswer("Draft to Active to Archived and back to Active.") + .withStatus(ContextMemoryStatus.DRAFT); + + ContextMemory memory = createEntity(request); + assertEquals(ContextMemoryStatus.DRAFT, memory.getStatus()); + + request.withStatus(ContextMemoryStatus.ACTIVE); + ContextMemory active = getContextMemoryService().put(request); + assertEquals(ContextMemoryStatus.ACTIVE, active.getStatus()); + + request.withStatus(ContextMemoryStatus.ARCHIVED); + ContextMemory archived = getContextMemoryService().put(request); + assertEquals(ContextMemoryStatus.ARCHIVED, archived.getStatus()); + + request.withStatus(ContextMemoryStatus.ACTIVE); + ContextMemory reactivated = getContextMemoryService().put(request); + assertEquals(ContextMemoryStatus.ACTIVE, reactivated.getStatus()); + } + + @Test + void put_contextMemoryStatusTransition_invalid_fails(TestNamespace ns) { + CreateContextMemory request = + new CreateContextMemory() + .withName(ns.prefix("status-invalid")) + .withDescription("Invalid status transition") + .withQuestion("Can Active go back to Draft?") + .withAnswer("No, Active cannot revert to Draft.") + .withStatus(ContextMemoryStatus.ACTIVE); + + ContextMemory memory = createEntity(request); + assertEquals(ContextMemoryStatus.ACTIVE, memory.getStatus()); + + request.withStatus(ContextMemoryStatus.DRAFT); + assertThrows( + Exception.class, + () -> getContextMemoryService().put(request), + "Transition from Active to Draft should be rejected"); + } + + @Test + void put_statusOnlyChange_persistsAfterGet(TestNamespace ns) { + CreateContextMemory request = + new CreateContextMemory() + .withName(ns.prefix("status-persist")) + .withDescription("Status-only update persistence test") + .withQuestion("Does the status persist?") + .withAnswer("Yes, after a status-only PUT.") + .withStatus(ContextMemoryStatus.DRAFT); + + ContextMemory memory = createEntity(request); + assertEquals(ContextMemoryStatus.DRAFT, memory.getStatus()); + + request.withStatus(ContextMemoryStatus.ACTIVE); + ContextMemory putResponse = getContextMemoryService().put(request); + assertEquals(ContextMemoryStatus.ACTIVE, putResponse.getStatus()); + + ContextMemory fetched = getEntity(memory.getId().toString()); + assertEquals( + ContextMemoryStatus.ACTIVE, + fetched.getStatus(), + "Status should persist after a status-only PUT update"); + assertTrue( + fetched.getVersion() > memory.getVersion(), + "Version should be incremented after status change"); + } + + @Test + void put_statusChanges_recordVersionHistory(TestNamespace ns) { + CreateContextMemory request = + new CreateContextMemory() + .withName(ns.prefix("status-history")) + .withDescription("Status change history test") + .withQuestion("Are status changes versioned?") + .withAnswer("Yes, each transition bumps the version.") + .withStatus(ContextMemoryStatus.DRAFT); + + ContextMemory memory = createEntity(request); + + request.withStatus(ContextMemoryStatus.ACTIVE); + getContextMemoryService().put(request); + + request.withStatus(ContextMemoryStatus.ARCHIVED); + getContextMemoryService().put(request); + + EntityHistory history = getVersionHistory(memory.getId()); + assertTrue( + history.getVersions().size() >= 3, + "Should have at least 3 versions: create + 2 status updates"); + } + + // =================================================================== + // SCOPE / TYPE / VISIBILITY TESTS + // =================================================================== + + @Test + void post_contextMemoryWithScopeAndType_200_OK(TestNamespace ns) { + CreateContextMemory request = + new CreateContextMemory() + .withName(ns.prefix("scope-type")) + .withDescription("Scope and type test") + .withQuestion("What is my reporting preference?") + .withAnswer("Always include row counts in summaries.") + .withMemoryScope(ContextMemoryScope.USER_GLOBAL) + .withMemoryType(ContextMemoryType.PREFERENCE); + + ContextMemory memory = createEntity(request); + assertEquals(ContextMemoryScope.USER_GLOBAL, memory.getMemoryScope()); + assertEquals(ContextMemoryType.PREFERENCE, memory.getMemoryType()); + } + + @Test + void post_contextMemoryWithShareConfigVisibility_200_OK(TestNamespace ns) { + CreateContextMemory request = + new CreateContextMemory() + .withName(ns.prefix("visibility")) + .withDescription("Visibility test") + .withQuestion("Who can see this memory?") + .withAnswer("Only the owner while visibility is Private.") + .withShareConfig(new MemoryShareConfig().withVisibility(MemoryVisibility.PRIVATE)); + + ContextMemory memory = createEntity(request); + assertNotNull(memory.getShareConfig()); + assertEquals(MemoryVisibility.PRIVATE, memory.getShareConfig().getVisibility()); + } + + // =================================================================== + // VALIDATION TESTS + // =================================================================== + + @Test + void patch_contextMemorySelfParentReference_4xx(TestNamespace ns) { + ContextMemory memory = createEntity(createMinimalRequest(ns)); + + ContextMemory selfParent = getEntity(memory.getId().toString()); + selfParent.setParentMemory(memory.getEntityReference()); + + assertThrows( + Exception.class, + () -> patchEntity(memory.getId().toString(), selfParent), + "A memory must not reference itself as parentMemory"); + } + + @Test + void post_contextMemoryInvalidSharedPrincipalType_4xx(TestNamespace ns) { + ContextMemory principalMemory = createEntity(createMinimalRequest(ns)); + + CreateContextMemory request = + new CreateContextMemory() + .withName(ns.prefix("bad-principal")) + .withDescription("Invalid shared principal type") + .withQuestion("Can a memory be a shared principal?") + .withAnswer("No - only user, team, or domain principals are allowed.") + .withShareConfig( + new MemoryShareConfig() + .withVisibility(MemoryVisibility.SHARED) + .withSharedWith( + List.of( + new MemorySharedPrincipal() + .withPrincipal(principalMemory.getEntityReference())))); + + assertThrows( + Exception.class, + () -> createEntity(request), + "Sharing with a non-user/team/domain principal must be rejected"); + } + + @Test + void post_contextMemoryAllTypes_200_OK(TestNamespace ns) { + for (ContextMemoryType type : ContextMemoryType.values()) { + CreateContextMemory request = + new CreateContextMemory() + .withName(ns.prefix("type-" + type.value().toLowerCase())) + .withDescription("Memory of type " + type.value()) + .withQuestion("Question for " + type.value() + "?") + .withAnswer("Answer for " + type.value() + ".") + .withMemoryType(type); + + ContextMemory memory = createEntity(request); + assertEquals(type, memory.getMemoryType()); + } + } + + // =================================================================== + // LIST TESTS + // =================================================================== + + @Test + void test_listContextMemories(TestNamespace ns) { + CreateContextMemory request1 = + new CreateContextMemory() + .withName(ns.prefix("list-1")) + .withDescription("First memory") + .withQuestion("First list question?") + .withAnswer("First list answer."); + + CreateContextMemory request2 = + new CreateContextMemory() + .withName(ns.prefix("list-2")) + .withDescription("Second memory") + .withQuestion("Second list question?") + .withAnswer("Second list answer."); + + createEntity(request1); + createEntity(request2); + + ListParams params = new ListParams(); + params.setLimit(10); + ListResponse response = listEntities(params); + + assertNotNull(response); + assertFalse(response.getData().isEmpty()); + assertTrue(response.getData().size() >= 2); + } + + // =================================================================== + // OWNERSHIP TEST OVERRIDES + // =================================================================== + + /** + * ContextMemory auto-assigns the creating user as owner when the create request omits owners + * (see {@code ContextMemoryRepository#setCreatorAsDefaultOwner}), so it deliberately diverges + * from the generic BaseEntityIT precondition that a freshly created entity has no owner. The + * PATCH contract is unchanged: setting an explicit owner replaces the creator. + */ + @Test + @Override + void patch_entityUpdateOwner_200(TestNamespace ns) { + ContextMemory created = createEntity(createMinimalRequest(ns)); + + ContextMemory fetched = getEntityWithFields(created.getId().toString(), "owners"); + assertNotNull(fetched.getOwners(), "ContextMemory should be owned by its creator initially"); + assertEquals( + 1, fetched.getOwners().size(), "ContextMemory creator should be the sole initial owner"); + + User botUser = Users.getByName("ingestion-bot"); + EntityReference ownerRef = + new EntityReference() + .withId(botUser.getId()) + .withType("user") + .withName(botUser.getName()) + .withFullyQualifiedName(botUser.getFullyQualifiedName()); + + fetched.setOwners(List.of(ownerRef)); + ContextMemory updated = patchEntity(fetched.getId().toString(), fetched); + + ContextMemory updatedFetched = getEntityWithFields(updated.getId().toString(), "owners"); + assertNotNull(updatedFetched.getOwners(), "Entity should have owners"); + assertEquals(1, updatedFetched.getOwners().size(), "Entity should have 1 owner"); + assertEquals( + botUser.getId(), + updatedFetched.getOwners().get(0).getId(), + "Owner should be ingestion-bot user"); + } + + /** + * ContextMemory already has the creating user as its sole owner before this PATCH (see {@code + * ContextMemoryRepository#setCreatorAsDefaultOwner}); the original "from null" precondition does + * not hold. Setting an explicit owners list still replaces it wholesale. + */ + @Test + @Override + void patch_entityUpdateOwnerFromNull_200(TestNamespace ns) { + ContextMemory entity = createEntity(createMinimalRequest(ns)); + + ContextMemory fetched = getEntityWithFields(entity.getId().toString(), "owners"); + assertNotNull(fetched.getOwners(), "ContextMemory should be owned by its creator initially"); + assertEquals( + 1, fetched.getOwners().size(), "ContextMemory creator should be the sole initial owner"); + + EntityReference owner1 = + new EntityReference() + .withId(testUser1().getId()) + .withType("user") + .withName(testUser1().getName()); + EntityReference owner2 = + new EntityReference() + .withId(testUser2().getId()) + .withType("user") + .withName(testUser2().getName()); + + fetched.setOwners(List.of(owner1, owner2)); + ContextMemory updated = patchEntity(fetched.getId().toString(), fetched); + + ContextMemory verify = getEntityWithFields(updated.getId().toString(), "owners"); + assertNotNull(verify.getOwners(), "Entity should have owners"); + assertEquals(2, verify.getOwners().size(), "Entity should have 2 owners"); + } + + // =================================================================== + // HELPER METHODS + // =================================================================== + + private ContextMemoryService getContextMemoryService() { + return new ContextMemoryService(SdkClients.adminClient().getHttpClient()); + } +} diff --git a/openmetadata-sdk/src/main/java/org/openmetadata/sdk/services/context/ContextMemoryService.java b/openmetadata-sdk/src/main/java/org/openmetadata/sdk/services/context/ContextMemoryService.java new file mode 100644 index 000000000000..cbd55011973c --- /dev/null +++ b/openmetadata-sdk/src/main/java/org/openmetadata/sdk/services/context/ContextMemoryService.java @@ -0,0 +1,27 @@ +package org.openmetadata.sdk.services.context; + +import org.openmetadata.schema.api.context.CreateContextMemory; +import org.openmetadata.schema.entity.context.ContextMemory; +import org.openmetadata.sdk.exceptions.OpenMetadataException; +import org.openmetadata.sdk.network.HttpClient; +import org.openmetadata.sdk.network.HttpMethod; +import org.openmetadata.sdk.services.EntityServiceBase; + +public class ContextMemoryService extends EntityServiceBase { + public ContextMemoryService(HttpClient httpClient) { + super(httpClient, "/v1/contextCenter/memories"); + } + + @Override + protected Class getEntityClass() { + return ContextMemory.class; + } + + public ContextMemory create(CreateContextMemory request) throws OpenMetadataException { + return httpClient.execute(HttpMethod.POST, basePath, request, ContextMemory.class); + } + + public ContextMemory put(CreateContextMemory request) throws OpenMetadataException { + return httpClient.execute(HttpMethod.PUT, basePath, request, ContextMemory.class); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/Entity.java b/openmetadata-service/src/main/java/org/openmetadata/service/Entity.java index fbfdaae9202e..a190f2cdff49 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/Entity.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/Entity.java @@ -310,6 +310,7 @@ public final class Entity { public static final String DOCUMENT = "document"; public static final String LEARNING_RESOURCE = "learningResource"; + public static final String CONTEXT_MEMORY = "contextMemory"; // ServiceType - Service Entity name map static final Map SERVICE_TYPE_ENTITY_MAP = new EnumMap<>(ServiceType.class); // entity type to service entity name map diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java index 240936ff3275..0f08e3c21f99 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java @@ -99,6 +99,7 @@ import org.openmetadata.schema.entity.automations.Workflow; import org.openmetadata.schema.entity.classification.Classification; import org.openmetadata.schema.entity.classification.Tag; +import org.openmetadata.schema.entity.context.ContextMemory; import org.openmetadata.schema.entity.data.APICollection; import org.openmetadata.schema.entity.data.APIEndpoint; import org.openmetadata.schema.entity.data.Chart; @@ -460,6 +461,9 @@ public interface CollectionDAO { @CreateSqlObject LearningResourceDAO learningResourceDAO(); + @CreateSqlObject + ContextMemoryDAO contextMemoryDAO(); + @CreateSqlObject SuggestionDAO suggestionDAO(); @@ -10871,6 +10875,23 @@ default String getNameHashColumn() { } } + interface ContextMemoryDAO extends EntityDAO { + @Override + default String getTableName() { + return "context_memory"; + } + + @Override + default Class getEntityClass() { + return ContextMemory.class; + } + + @Override + default String getNameHashColumn() { + return "nameHash"; + } + } + interface SuggestionDAO { default String getTableName() { return "suggestions"; diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ContextMemoryRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ContextMemoryRepository.java new file mode 100644 index 000000000000..3af048a1c9a7 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ContextMemoryRepository.java @@ -0,0 +1,323 @@ +/* + * Copyright 2024 Collate + * 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 org.openmetadata.service.jdbi3; + +import static org.openmetadata.common.utils.CommonUtil.listOrEmpty; +import static org.openmetadata.common.utils.CommonUtil.nullOrEmpty; + +import jakarta.ws.rs.BadRequestException; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import lombok.extern.slf4j.Slf4j; +import org.openmetadata.schema.entity.context.ContextMemory; +import org.openmetadata.schema.entity.context.ContextMemoryStatus; +import org.openmetadata.schema.type.EntityReference; +import org.openmetadata.schema.type.Include; +import org.openmetadata.schema.type.Relationship; +import org.openmetadata.schema.type.change.ChangeSource; +import org.openmetadata.service.Entity; +import org.openmetadata.service.resources.context.ContextMemoryResource; +import org.openmetadata.service.util.EntityUtil; +import org.openmetadata.service.util.EntityUtil.Fields; +import org.openmetadata.service.util.EntityUtil.RelationIncludes; +import org.openmetadata.service.util.FullyQualifiedName; + +@Slf4j +@Repository(name = "ContextMemoryRepository") +public class ContextMemoryRepository extends EntityRepository { + + public ContextMemoryRepository() { + super( + ContextMemoryResource.COLLECTION_PATH, + Entity.CONTEXT_MEMORY, + ContextMemory.class, + Entity.getCollectionDAO().contextMemoryDAO(), + "", + ""); + supportsSearch = false; + } + + @Override + protected void setFields(ContextMemory entity, Fields fields, RelationIncludes relationIncludes) { + // ContextMemory stores its fields in the entity JSON for now. + } + + @Override + protected void clearFields(ContextMemory entity, Fields fields) { + // ContextMemory stores its fields in the entity JSON for now. + } + + @Override + public void setFullyQualifiedName(ContextMemory entity) { + if (!nullOrEmpty(entity.getFullyQualifiedName())) { + return; + } + // FQN is the (immutable) memory name. Deriving it from mutable fields such as + // primaryEntity or owners would change nameHash on update, risking unique-constraint + // collisions and orphaned references. The link to primaryEntity/owners is captured + // via the relationship table instead. FullyQualifiedName.build quotes reserved + // characters, matching the convention in every other top-level entity repository. + entity.setFullyQualifiedName(FullyQualifiedName.build(entity.getName())); + } + + private static final Set ALLOWED_SHARED_PRINCIPAL_TYPES = + Set.of(Entity.USER, Entity.TEAM, Entity.DOMAIN); + + @Override + public void prepare(ContextMemory entity, boolean update) { + if (entity.getPrimaryEntity() != null) { + EntityReference primaryEntity = + Entity.getEntityReference(entity.getPrimaryEntity(), Include.NON_DELETED); + entity.setPrimaryEntity(primaryEntity); + } + entity.setRelatedEntities(EntityUtil.populateEntityReferences(entity.getRelatedEntities())); + + if (entity.getRootMemory() != null) { + ContextMemory rootMemory = Entity.getEntity(entity.getRootMemory(), "", Include.NON_DELETED); + validateNotSelfReference(entity, rootMemory.getId(), "rootMemory"); + entity.setRootMemory(rootMemory.getEntityReference()); + } + if (entity.getParentMemory() != null) { + ContextMemory parentMemory = + Entity.getEntity(entity.getParentMemory(), "", Include.NON_DELETED); + validateNotSelfReference(entity, parentMemory.getId(), "parentMemory"); + entity.setParentMemory(parentMemory.getEntityReference()); + } + validateSharedPrincipals(entity); + setCreatorAsDefaultOwner(entity, update); + } + + private void validateNotSelfReference(ContextMemory entity, UUID referencedId, String field) { + if (entity.getId() != null && entity.getId().equals(referencedId)) { + throw new BadRequestException( + String.format("A context memory cannot reference itself as %s", field)); + } + } + + private void validateSharedPrincipals(ContextMemory entity) { + if (entity.getShareConfig() == null || entity.getShareConfig().getSharedWith() == null) { + return; + } + for (var sharedPrincipal : entity.getShareConfig().getSharedWith()) { + if (sharedPrincipal.getPrincipal() == null) { + continue; + } + EntityReference principal = + Entity.getEntityReference(sharedPrincipal.getPrincipal(), Include.NON_DELETED); + if (!ALLOWED_SHARED_PRINCIPAL_TYPES.contains(principal.getType())) { + throw new BadRequestException( + String.format( + "Invalid shared principal type '%s'. Supported types: %s", + principal.getType(), ALLOWED_SHARED_PRINCIPAL_TYPES)); + } + sharedPrincipal.setPrincipal(principal); + } + } + + /** + * The creator owns the memory only at creation time. On update/PUT the owners are managed by the + * standard framework path so omitting owners no longer silently replaces previously set owners. + */ + private void setCreatorAsDefaultOwner(ContextMemory entity, boolean update) { + if (update || !nullOrEmpty(entity.getOwners())) { + return; + } + entity.setOwners( + List.of( + Entity.getEntityReferenceByName( + Entity.USER, entity.getUpdatedBy(), Include.NON_DELETED))); + } + + @Override + public void storeEntity(ContextMemory entity, boolean update) { + store(entity, update); + } + + @Override + public void storeRelationships(ContextMemory entity) { + // Add-only: addRelationship upserts, so re-running on update is idempotent. Stale-edge + // cleanup on update is handled in ContextMemoryUpdater via updateFromRelationship(s), + // which deletes only the specific changed refs. A blanket deleteTo here would also wipe + // the framework's domain --HAS--> memory edge (storeDomains runs before storeRelationships). + if (entity.getPrimaryEntity() != null) { + addRelationship( + entity.getPrimaryEntity().getId(), + entity.getId(), + entity.getPrimaryEntity().getType(), + Entity.CONTEXT_MEMORY, + Relationship.HAS); + } + + for (var relatedEntity : listOrEmpty(entity.getRelatedEntities())) { + addRelationship( + relatedEntity.getId(), + entity.getId(), + relatedEntity.getType(), + Entity.CONTEXT_MEMORY, + Relationship.RELATED_TO); + } + + // Distinct relationship types (CONTAINS for root-ancestor, PARENT_OF for direct parent) + // so the two hierarchies resolve independently and neither collides with the framework's + // HAS edges (domains). + if (entity.getRootMemory() != null) { + addRelationship( + entity.getRootMemory().getId(), + entity.getId(), + Entity.CONTEXT_MEMORY, + Entity.CONTEXT_MEMORY, + Relationship.CONTAINS); + } + + if (entity.getParentMemory() != null) { + addRelationship( + entity.getParentMemory().getId(), + entity.getId(), + Entity.CONTEXT_MEMORY, + Entity.CONTEXT_MEMORY, + Relationship.PARENT_OF); + } + } + + private static List asRefList(EntityReference ref) { + return ref == null ? List.of() : List.of(ref); + } + + // ------------------------------------------------------------------ + // Lifecycle enforcement + // ------------------------------------------------------------------ + + /** + * Valid status transitions: + * DRAFT → ACTIVE + * DRAFT → ARCHIVED + * ACTIVE → ARCHIVED + * ARCHIVED → ACTIVE (re-activate) + * + * Invalid: + * ARCHIVED → DRAFT (cannot revert to draft) + * ACTIVE → DRAFT (cannot revert to draft) + */ + private static final Map> VALID_TRANSITIONS = + Map.of( + ContextMemoryStatus.DRAFT, + Set.of(ContextMemoryStatus.ACTIVE, ContextMemoryStatus.ARCHIVED), + ContextMemoryStatus.ACTIVE, Set.of(ContextMemoryStatus.ARCHIVED), + ContextMemoryStatus.ARCHIVED, Set.of(ContextMemoryStatus.ACTIVE)); + + /** Validate that a status transition is allowed. */ + public static void validateStatusTransition(ContextMemoryStatus from, ContextMemoryStatus to) { + if (from == to) { + return; // No change + } + Set allowed = VALID_TRANSITIONS.get(from); + if (allowed == null) { + throw new BadRequestException( + String.format("No transitions defined for status %s", from.value())); + } + if (!allowed.contains(to)) { + throw new BadRequestException( + String.format( + "Invalid memory status transition from %s to %s. Allowed transitions from %s: %s", + from.value(), to.value(), from.value(), allowed)); + } + } + + @Override + public EntityUpdater getUpdater( + ContextMemory original, ContextMemory updated, Operation operation, ChangeSource source) { + return new ContextMemoryUpdater(original, updated, operation); + } + + public class ContextMemoryUpdater extends EntityUpdater { + public ContextMemoryUpdater( + ContextMemory original, ContextMemory updated, Operation operation) { + super(original, updated, operation); + } + + @Override + public void entitySpecificUpdate(boolean consolidatingChanges) { + recordChange("title", original.getTitle(), updated.getTitle()); + recordChange("summary", original.getSummary(), updated.getSummary()); + recordChange("question", original.getQuestion(), updated.getQuestion()); + recordChange("answer", original.getAnswer(), updated.getAnswer()); + recordChange("memoryType", original.getMemoryType(), updated.getMemoryType()); + recordChange("memoryScope", original.getMemoryScope(), updated.getMemoryScope()); + recordChange("sourceType", original.getSourceType(), updated.getSourceType()); + recordChange( + "sourceConversation", original.getSourceConversation(), updated.getSourceConversation()); + recordChange( + "sourceHumanMessage", original.getSourceHumanMessage(), updated.getSourceHumanMessage()); + recordChange( + "sourceAssistantMessage", + original.getSourceAssistantMessage(), + updated.getSourceAssistantMessage()); + recordChange( + "machineRepresentation", + original.getMachineRepresentation(), + updated.getMachineRepresentation()); + + // Validate lifecycle transition before recording status change + if (original.getStatus() != null + && updated.getStatus() != null + && original.getStatus() != updated.getStatus()) { + validateStatusTransition(original.getStatus(), updated.getStatus()); + } + recordChange("status", original.getStatus(), updated.getStatus()); + + recordChange("shareConfig", original.getShareConfig(), updated.getShareConfig()); + + // Relationship-backed fields: these helpers record the version change and delete only + // the specific changed refs (never a blanket delete), so the framework's + // domain --HAS--> memory edge is left intact. + updateFromRelationships( + "primaryEntity", + Entity.CONTEXT_MEMORY, + asRefList(original.getPrimaryEntity()), + asRefList(updated.getPrimaryEntity()), + Relationship.HAS, + Entity.CONTEXT_MEMORY, + original.getId()); + updateFromRelationships( + "relatedEntities", + Entity.CONTEXT_MEMORY, + listOrEmpty(original.getRelatedEntities()), + listOrEmpty(updated.getRelatedEntities()), + Relationship.RELATED_TO, + Entity.CONTEXT_MEMORY, + original.getId()); + updateFromRelationship( + "rootMemory", + Entity.CONTEXT_MEMORY, + original.getRootMemory(), + updated.getRootMemory(), + Relationship.CONTAINS, + Entity.CONTEXT_MEMORY, + original.getId()); + updateFromRelationship( + "parentMemory", + Entity.CONTEXT_MEMORY, + original.getParentMemory(), + updated.getParentMemory(), + Relationship.PARENT_OF, + Entity.CONTEXT_MEMORY, + original.getId()); + + // usageCount and lastUsedAt are AI-retrieval telemetry, intentionally excluded from + // version history so routine retrieval does not churn the entity version. + } + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/context/ContextMemoryMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/context/ContextMemoryMapper.java new file mode 100644 index 000000000000..0cb2dd27efd2 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/context/ContextMemoryMapper.java @@ -0,0 +1,45 @@ +/* + * Copyright 2024 Collate + * 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 org.openmetadata.service.resources.context; + +import org.openmetadata.schema.api.context.CreateContextMemory; +import org.openmetadata.schema.entity.context.ContextMemory; +import org.openmetadata.service.mapper.EntityMapper; + +public class ContextMemoryMapper implements EntityMapper { + @Override + public ContextMemory createToEntity(CreateContextMemory create, String user) { + // copy() owns the common fields: it sanitizes description and validates owners, + // domains, and reviewers. Re-setting them here would reintroduce the raw + // (unsanitized/unvalidated) values, so only ContextMemory-specific fields are set. + return copy(new ContextMemory(), create, user) + .withTitle(create.getTitle()) + .withSummary(create.getSummary()) + .withQuestion(create.getQuestion()) + .withAnswer(create.getAnswer()) + .withMemoryType(create.getMemoryType()) + .withMemoryScope(create.getMemoryScope()) + .withStatus(create.getStatus()) + .withShareConfig(create.getShareConfig()) + .withPrimaryEntity(create.getPrimaryEntity()) + .withRelatedEntities(create.getRelatedEntities()) + .withSourceType(create.getSourceType()) + .withSourceConversation(create.getSourceConversation()) + .withSourceHumanMessage(create.getSourceHumanMessage()) + .withSourceAssistantMessage(create.getSourceAssistantMessage()) + .withRootMemory(create.getRootMemory()) + .withParentMemory(create.getParentMemory()) + .withMachineRepresentation(create.getMachineRepresentation()); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/context/ContextMemoryResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/context/ContextMemoryResource.java new file mode 100644 index 000000000000..9ba868a9bc19 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/context/ContextMemoryResource.java @@ -0,0 +1,414 @@ +/* + * Copyright 2024 Collate + * 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 org.openmetadata.service.resources.context; + +import io.swagger.v3.oas.annotations.ExternalDocumentation; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.json.JsonPatch; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.PATCH; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.SecurityContext; +import jakarta.ws.rs.core.UriInfo; +import java.util.List; +import java.util.UUID; +import lombok.extern.slf4j.Slf4j; +import org.openmetadata.schema.api.context.CreateContextMemory; +import org.openmetadata.schema.api.data.RestoreEntity; +import org.openmetadata.schema.entity.context.ContextMemory; +import org.openmetadata.schema.type.EntityHistory; +import org.openmetadata.schema.type.Include; +import org.openmetadata.schema.type.MetadataOperation; +import org.openmetadata.schema.utils.ResultList; +import org.openmetadata.service.Entity; +import org.openmetadata.service.jdbi3.ContextMemoryRepository; +import org.openmetadata.service.jdbi3.ListFilter; +import org.openmetadata.service.limits.Limits; +import org.openmetadata.service.resources.Collection; +import org.openmetadata.service.resources.EntityResource; +import org.openmetadata.service.security.Authorizer; + +@Slf4j +@Tag(name = "Context Memories", description = "APIs for managing reusable Context Center memories.") +@Path("/v1/contextCenter/memories") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +@Collection(name = "contextMemories") +public class ContextMemoryResource extends EntityResource { + public static final String COLLECTION_PATH = "v1/contextCenter/memories/"; + public static final String FIELDS = "owners,tags,domains"; + + private final ContextMemoryMapper mapper = new ContextMemoryMapper(); + + public ContextMemoryResource(Authorizer authorizer, Limits limits) { + super(Entity.CONTEXT_MEMORY, authorizer, limits); + } + + public static class ContextMemoryList extends ResultList { + /* Required for serde */ + } + + @Override + protected List getEntitySpecificOperations() { + return null; + } + + @Override + public ContextMemory addHref(UriInfo uriInfo, ContextMemory memory) { + super.addHref(uriInfo, memory); + Entity.withHref(uriInfo, memory.getPrimaryEntity()); + Entity.withHref(uriInfo, memory.getRelatedEntities()); + Entity.withHref(uriInfo, memory.getRootMemory()); + Entity.withHref(uriInfo, memory.getParentMemory()); + return memory; + } + + @GET + @Operation( + operationId = "listContextMemories", + summary = "List context memories", + description = "Get a paginated list of context memories.", + responses = { + @ApiResponse( + responseCode = "200", + description = "List of context memories", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = ContextMemoryList.class))) + }) + public ResultList list( + @Context UriInfo uriInfo, + @Context SecurityContext securityContext, + @Parameter( + description = "Fields requested in the returned resource", + schema = @Schema(type = "string", example = FIELDS)) + @QueryParam("fields") + String fieldsParam, + @Parameter(description = "Limit the number of results returned. (1 to 1000000, default = 10)") + @DefaultValue("10") + @Min(0) + @Max(1000000) + @QueryParam("limit") + int limitParam, + @Parameter(description = "Returns list of context memories before this cursor") + @QueryParam("before") + String before, + @Parameter(description = "Returns list of context memories after this cursor") + @QueryParam("after") + String after, + @Parameter( + description = "Include all, deleted, or non-deleted entities", + schema = @Schema(implementation = Include.class)) + @QueryParam("include") + @DefaultValue("non-deleted") + Include include) { + return addHref( + uriInfo, + listInternal( + uriInfo, + securityContext, + fieldsParam, + new ListFilter(include), + limitParam, + before, + after)); + } + + @GET + @Path("/{id}") + @Operation( + operationId = "getContextMemory", + summary = "Get a memory by id", + description = "Get a context memory by `id`.", + responses = { + @ApiResponse( + responseCode = "200", + description = "The context memory", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = ContextMemory.class))), + @ApiResponse(responseCode = "404", description = "Memory not found") + }) + public ContextMemory get( + @Context UriInfo uriInfo, + @Context SecurityContext securityContext, + @Parameter(description = "Id of the context memory", schema = @Schema(type = "UUID")) + @PathParam("id") + UUID id, + @Parameter(description = "Fields requested in the returned resource") @QueryParam("fields") + String fieldsParam, + @Parameter(description = "Include all, deleted, or non-deleted entities") + @QueryParam("include") + @DefaultValue("non-deleted") + Include include) { + return getInternal(uriInfo, securityContext, id, fieldsParam, include); + } + + @GET + @Path("/name/{fqn}") + @Operation( + operationId = "getContextMemoryByFqn", + summary = "Get a memory by fully qualified name", + description = "Get a context memory by fully qualified name.", + responses = { + @ApiResponse( + responseCode = "200", + description = "The context memory", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = ContextMemory.class))), + @ApiResponse(responseCode = "404", description = "Memory not found") + }) + public ContextMemory getByName( + @Context UriInfo uriInfo, + @Context SecurityContext securityContext, + @Parameter(description = "Fully qualified name of the context memory") @PathParam("fqn") + String fqn, + @Parameter(description = "Fields requested in the returned resource") @QueryParam("fields") + String fieldsParam, + @Parameter(description = "Include deleted memories") + @QueryParam("include") + @DefaultValue("non-deleted") + Include include) { + return getByNameInternal(uriInfo, securityContext, fqn, fieldsParam, include); + } + + @GET + @Path("/{id}/versions") + @Operation( + operationId = "listAllContextMemoryVersions", + summary = "List context memory versions", + description = "Get a list of all the versions of a context memory identified by `id`.", + responses = { + @ApiResponse( + responseCode = "200", + description = "List of versions", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = EntityHistory.class))) + }) + public EntityHistory listVersions( + @Context SecurityContext securityContext, + @Parameter(description = "Id of the context memory", schema = @Schema(type = "UUID")) + @PathParam("id") + UUID id) { + return listVersionsInternal(securityContext, id); + } + + @GET + @Path("/{id}/versions/{version}") + @Operation( + operationId = "getSpecificContextMemoryVersion", + summary = "Get a version of a context memory", + description = "Get a version of a context memory by given `id`.", + responses = { + @ApiResponse( + responseCode = "200", + description = "Context memory version details", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = ContextMemory.class))) + }) + public ContextMemory getVersion( + @Context SecurityContext securityContext, + @Parameter(description = "Id of the context memory", schema = @Schema(type = "UUID")) + @PathParam("id") + UUID id, + @Parameter(description = "Context memory version", schema = @Schema(type = "string")) + @PathParam("version") + String version) { + return getVersionInternal(securityContext, id, version); + } + + @POST + @Operation( + operationId = "createContextMemory", + summary = "Create a memory", + description = "Create a new context memory.", + responses = { + @ApiResponse( + responseCode = "200", + description = "The created memory", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = ContextMemory.class))) + }) + public Response create( + @Context UriInfo uriInfo, + @Context SecurityContext securityContext, + @Valid CreateContextMemory create) { + ContextMemory memory = + mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); + return create(uriInfo, securityContext, memory); + } + + @PUT + @Operation( + operationId = "createOrUpdateContextMemory", + summary = "Create or update a memory", + description = "Create a new context memory, or update an existing one if it already exists.", + responses = { + @ApiResponse( + responseCode = "200", + description = "The updated memory", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = ContextMemory.class))) + }) + public Response createOrUpdate( + @Context UriInfo uriInfo, + @Context SecurityContext securityContext, + @Valid CreateContextMemory create) { + ContextMemory memory = + mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); + return createOrUpdate(uriInfo, securityContext, memory); + } + + @PATCH + @Path("/{id}") + @Consumes(MediaType.APPLICATION_JSON_PATCH_JSON) + @Operation( + operationId = "patchContextMemory", + summary = "Update a memory", + description = "Apply a JSONPatch to a context memory.", + externalDocs = + @ExternalDocumentation( + description = "JsonPatch RFC", + url = "https://tools.ietf.org/html/rfc6902")) + public Response patch( + @Context UriInfo uriInfo, + @Context SecurityContext securityContext, + @Parameter(description = "Id of the context memory", schema = @Schema(type = "UUID")) + @PathParam("id") + UUID id, + @RequestBody( + description = "JsonPatch with array of operations", + content = + @Content( + mediaType = MediaType.APPLICATION_JSON_PATCH_JSON, + examples = + @ExampleObject("[{op:replace, path:/displayName, value: 'New name'}]"))) + JsonPatch patch) { + return patchInternal(uriInfo, securityContext, id, patch); + } + + @DELETE + @Path("/{id}") + @Operation( + operationId = "deleteContextMemory", + summary = "Delete a memory by id", + description = "Delete a context memory by `id`.", + responses = { + @ApiResponse(responseCode = "200", description = "OK"), + @ApiResponse(responseCode = "404", description = "Memory not found") + }) + public Response delete( + @Context UriInfo uriInfo, + @Context SecurityContext securityContext, + @Parameter(description = "Recursively delete this entity and its children. (Default = false)") + @DefaultValue("false") + @QueryParam("recursive") + boolean recursive, + @Parameter(description = "Hard delete the entity. (Default = false)") + @DefaultValue("false") + @QueryParam("hardDelete") + boolean hardDelete, + @Parameter(description = "Id of the context memory", schema = @Schema(type = "UUID")) + @PathParam("id") + UUID id) { + return delete(uriInfo, securityContext, id, recursive, hardDelete); + } + + @DELETE + @Path("/name/{fqn}") + @Operation( + operationId = "deleteContextMemoryByFqn", + summary = "Delete a memory by fully qualified name", + description = "Delete a context memory by `fullyQualifiedName`.", + responses = { + @ApiResponse(responseCode = "200", description = "OK"), + @ApiResponse(responseCode = "404", description = "Memory not found") + }) + public Response deleteByFqn( + @Context UriInfo uriInfo, + @Context SecurityContext securityContext, + @Parameter(description = "Recursively delete this entity and its children. (Default = false)") + @DefaultValue("false") + @QueryParam("recursive") + boolean recursive, + @Parameter(description = "Hard delete the entity. (Default = false)") + @DefaultValue("false") + @QueryParam("hardDelete") + boolean hardDelete, + @Parameter(description = "Fully qualified name of the context memory") @PathParam("fqn") + String fqn) { + return deleteByName(uriInfo, securityContext, fqn, recursive, hardDelete); + } + + @PUT + @Path("/restore") + @Operation( + operationId = "restoreContextMemory", + summary = "Restore a soft-deleted memory", + description = "Restore a previously soft-deleted context memory.", + responses = { + @ApiResponse( + responseCode = "200", + description = "The restored memory", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = ContextMemory.class))) + }) + public Response restore( + @Context UriInfo uriInfo, + @Context SecurityContext securityContext, + @RequestBody( + description = "Id of the context memory to restore", + content = + @Content( + mediaType = "application/json", + schema = @Schema(type = "string", format = "uuid"))) + RestoreEntity restore) { + return restoreEntity(uriInfo, securityContext, restore.getId()); + } +} diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/resources/context/ContextMemoryStatusTransitionTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/resources/context/ContextMemoryStatusTransitionTest.java new file mode 100644 index 000000000000..d8e693dbbda5 --- /dev/null +++ b/openmetadata-service/src/test/java/org/openmetadata/service/resources/context/ContextMemoryStatusTransitionTest.java @@ -0,0 +1,52 @@ +package org.openmetadata.service.resources.context; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import jakarta.ws.rs.BadRequestException; +import org.junit.jupiter.api.Test; +import org.openmetadata.schema.entity.context.ContextMemoryStatus; +import org.openmetadata.service.jdbi3.ContextMemoryRepository; + +class ContextMemoryStatusTransitionTest { + + @Test + void testValidStatusTransitionsAreAccepted() { + ContextMemoryRepository.validateStatusTransition( + ContextMemoryStatus.DRAFT, ContextMemoryStatus.ACTIVE); + ContextMemoryRepository.validateStatusTransition( + ContextMemoryStatus.DRAFT, ContextMemoryStatus.ARCHIVED); + ContextMemoryRepository.validateStatusTransition( + ContextMemoryStatus.ACTIVE, ContextMemoryStatus.ARCHIVED); + ContextMemoryRepository.validateStatusTransition( + ContextMemoryStatus.ARCHIVED, ContextMemoryStatus.ACTIVE); + } + + @Test + void testNoOpStatusTransitionIsAccepted() { + ContextMemoryRepository.validateStatusTransition( + ContextMemoryStatus.ACTIVE, ContextMemoryStatus.ACTIVE); + ContextMemoryRepository.validateStatusTransition( + ContextMemoryStatus.DRAFT, ContextMemoryStatus.DRAFT); + } + + @Test + void testActiveToDraftIsRejected() { + BadRequestException exception = + assertThrows( + BadRequestException.class, + () -> + ContextMemoryRepository.validateStatusTransition( + ContextMemoryStatus.ACTIVE, ContextMemoryStatus.DRAFT)); + assertTrue(exception.getMessage().contains("Invalid memory status transition")); + } + + @Test + void testArchivedToDraftIsRejected() { + assertThrows( + BadRequestException.class, + () -> + ContextMemoryRepository.validateStatusTransition( + ContextMemoryStatus.ARCHIVED, ContextMemoryStatus.DRAFT)); + } +} diff --git a/openmetadata-spec/src/main/antlr4/org/openmetadata/schema/EntityLink.g4 b/openmetadata-spec/src/main/antlr4/org/openmetadata/schema/EntityLink.g4 index b07dff497711..847d580dc5f8 100644 --- a/openmetadata-spec/src/main/antlr4/org/openmetadata/schema/EntityLink.g4 +++ b/openmetadata-spec/src/main/antlr4/org/openmetadata/schema/EntityLink.g4 @@ -102,6 +102,7 @@ ENTITY_TYPE | 'folder' | 'contextFile' | 'contextFileContent' + | 'contextMemory' | 'type' | 'aiApplication' | 'llmModel' diff --git a/openmetadata-spec/src/main/resources/json/schema/api/context/createContextMemory.json b/openmetadata-spec/src/main/resources/json/schema/api/context/createContextMemory.json new file mode 100644 index 000000000000..4d87cea0cc07 --- /dev/null +++ b/openmetadata-spec/src/main/resources/json/schema/api/context/createContextMemory.json @@ -0,0 +1,100 @@ +{ + "$id": "https://open-metadata.org/schema/api/context/createContextMemory.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CreateContextMemory", + "description": "Request to create a reusable Context Center memory.", + "type": "object", + "javaType": "org.openmetadata.schema.api.context.CreateContextMemory", + "javaInterfaces": ["org.openmetadata.schema.CreateEntity"], + "properties": { + "name": { + "description": "Stable system name for the memory.", + "$ref": "../../type/basic.json#/definitions/entityName" + }, + "displayName": { + "description": "Display name for the memory.", + "type": "string" + }, + "description": { + "description": "Optional markdown description for the memory.", + "$ref": "../../type/basic.json#/definitions/markdown" + }, + "title": { + "description": "Short title shown in Context Center.", + "type": "string" + }, + "summary": { + "description": "Optional summary of the memory.", + "type": "string" + }, + "question": { + "description": "Canonical question or instruction represented by this memory.", + "type": "string" + }, + "answer": { + "description": "Canonical answer or retained guidance represented by this memory.", + "type": "string" + }, + "memoryType": { + "$ref": "../../entity/context/contextMemory.json#/definitions/memoryType" + }, + "memoryScope": { + "$ref": "../../entity/context/contextMemory.json#/definitions/memoryScope" + }, + "status": { + "$ref": "../../entity/context/contextMemory.json#/definitions/memoryStatus" + }, + "shareConfig": { + "$ref": "../../entity/context/contextMemory.json#/definitions/shareConfig" + }, + "primaryEntity": { + "$ref": "../../type/entityReference.json" + }, + "relatedEntities": { + "$ref": "../../type/entityReferenceList.json" + }, + "sourceType": { + "$ref": "../../entity/context/contextMemory.json#/definitions/sourceType" + }, + "sourceConversation": { + "$ref": "../../type/basic.json#/definitions/uuid" + }, + "sourceHumanMessage": { + "$ref": "../../type/basic.json#/definitions/uuid" + }, + "sourceAssistantMessage": { + "$ref": "../../type/basic.json#/definitions/uuid" + }, + "rootMemory": { + "$ref": "../../type/entityReference.json" + }, + "parentMemory": { + "$ref": "../../type/entityReference.json" + }, + "machineRepresentation": { + "$ref": "../../entity/context/contextMemory.json#/definitions/machineRepresentation" + }, + "owners": { + "description": "Owners of this memory.", + "$ref": "../../type/entityReferenceList.json", + "default": null + }, + "tags": { + "description": "Tags associated with this memory.", + "type": "array", + "items": { + "$ref": "../../type/tagLabel.json" + }, + "default": null + }, + "domains": { + "description": "Fully qualified names of the domains this memory belongs to.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["name", "question", "answer"], + "additionalProperties": false +} diff --git a/openmetadata-spec/src/main/resources/json/schema/entity/context/contextMemory.json b/openmetadata-spec/src/main/resources/json/schema/entity/context/contextMemory.json new file mode 100644 index 000000000000..9418ca558475 --- /dev/null +++ b/openmetadata-spec/src/main/resources/json/schema/entity/context/contextMemory.json @@ -0,0 +1,304 @@ +{ + "$id": "https://open-metadata.org/schema/entity/context/contextMemory.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ContextMemory", + "$comment": "@om-entity-type", + "description": "Reusable context memory for Context Center and AI-assisted retrieval.", + "type": "object", + "javaType": "org.openmetadata.schema.entity.context.ContextMemory", + "javaInterfaces": ["org.openmetadata.schema.EntityInterface"], + "definitions": { + "memoryType": { + "javaType": "org.openmetadata.schema.entity.context.ContextMemoryType", + "description": "High-level type of reusable memory.", + "type": "string", + "enum": ["Preference", "UseCase", "Note", "Runbook", "Faq"], + "javaEnums": [ + { "name": "PREFERENCE" }, + { "name": "USE_CASE" }, + { "name": "NOTE" }, + { "name": "RUNBOOK" }, + { "name": "FAQ" } + ], + "default": "Note" + }, + "memoryScope": { + "javaType": "org.openmetadata.schema.entity.context.ContextMemoryScope", + "description": "Scope where the memory should be applied.", + "type": "string", + "enum": ["UserGlobal", "EntityScoped"], + "javaEnums": [ + { "name": "USER_GLOBAL" }, + { "name": "ENTITY_SCOPED" } + ], + "default": "EntityScoped" + }, + "memoryStatus": { + "javaType": "org.openmetadata.schema.entity.context.ContextMemoryStatus", + "description": "Lifecycle state of the memory. Any status may be set at creation (e.g. importing an already-archived memory); the Draft -> Active -> Archived transition rules are only enforced on subsequent updates.", + "type": "string", + "enum": ["Draft", "Active", "Archived"], + "javaEnums": [ + { "name": "DRAFT" }, + { "name": "ACTIVE" }, + { "name": "ARCHIVED" } + ], + "default": "Active" + }, + "sourceType": { + "javaType": "org.openmetadata.schema.entity.context.ContextMemorySourceType", + "description": "How the memory was created.", + "type": "string", + "enum": ["Manual", "ChatPromotion", "RememberRequest"], + "javaEnums": [ + { "name": "MANUAL" }, + { "name": "CHAT_PROMOTION" }, + { "name": "REMEMBER_REQUEST" } + ], + "default": "Manual" + }, + "shareVisibility": { + "javaType": "org.openmetadata.schema.entity.context.MemoryVisibility", + "description": "Visibility level for the memory.", + "type": "string", + "enum": ["Private", "Entity", "Shared"], + "javaEnums": [ + { "name": "PRIVATE" }, + { "name": "ENTITY" }, + { "name": "SHARED" } + ], + "default": "Private" + }, + "shareRole": { + "javaType": "org.openmetadata.schema.entity.context.MemoryShareRole", + "description": "Role granted to a shared principal.", + "type": "string", + "enum": ["Viewer", "Editor"], + "javaEnums": [ + { "name": "VIEWER" }, + { "name": "EDITOR" } + ], + "default": "Viewer" + }, + "sharedPrincipal": { + "javaType": "org.openmetadata.schema.entity.context.MemorySharedPrincipal", + "description": "A principal granted access to the memory.", + "type": "object", + "properties": { + "principal": { + "description": "Principal receiving access. Supported principal types are user, team, and domain.", + "$ref": "../../type/entityReference.json" + }, + "role": { + "description": "Role granted to the principal.", + "$ref": "#/definitions/shareRole" + } + }, + "additionalProperties": false + }, + "shareConfig": { + "javaType": "org.openmetadata.schema.entity.context.MemoryShareConfig", + "description": "Visibility and sharing configuration for the memory.", + "type": "object", + "properties": { + "visibility": { + "$ref": "#/definitions/shareVisibility" + }, + "sharedWith": { + "description": "Explicit principals the memory is shared with.", + "type": "array", + "items": { + "$ref": "#/definitions/sharedPrincipal" + }, + "default": [] + } + }, + "additionalProperties": false + }, + "machineRepresentationStatus": { + "javaType": "org.openmetadata.schema.entity.context.MachineRepresentationStatus", + "description": "Availability state of the machine-oriented representation.", + "type": "string", + "enum": ["Pending", "Ready", "Stale", "Failed"], + "javaEnums": [ + { "name": "PENDING" }, + { "name": "READY" }, + { "name": "STALE" }, + { "name": "FAILED" } + ], + "default": "Pending" + }, + "machineRepresentation": { + "javaType": "org.openmetadata.schema.entity.context.ContextMemoryRepresentation", + "description": "Optional machine-oriented representation used for prompt packing.", + "type": "object", + "properties": { + "format": { + "description": "Representation format identifier.", + "type": "string" + }, + "version": { + "description": "Version of the representation format.", + "type": "string" + }, + "content": { + "description": "Compressed or transformed memory content.", + "type": "string" + }, + "generatedFromHash": { + "description": "Hash of the canonical source content used to generate this representation.", + "type": "string" + }, + "generatedAt": { + "description": "Timestamp when the representation was generated.", + "$ref": "../../type/basic.json#/definitions/timestamp" + }, + "status": { + "$ref": "#/definitions/machineRepresentationStatus" + } + }, + "additionalProperties": false + } + }, + "properties": { + "id": { + "description": "Unique identifier of the memory.", + "$ref": "../../type/basic.json#/definitions/uuid" + }, + "name": { + "description": "Stable system name for the memory.", + "$ref": "../../type/basic.json#/definitions/entityName" + }, + "fullyQualifiedName": { + "description": "Fully qualified name of the memory.", + "$ref": "../../type/basic.json#/definitions/fullyQualifiedEntityName" + }, + "displayName": { + "description": "Display name of the memory.", + "type": "string" + }, + "description": { + "description": "Optional markdown description for the memory.", + "$ref": "../../type/basic.json#/definitions/markdown" + }, + "title": { + "description": "Short title shown in Context Center.", + "type": "string" + }, + "summary": { + "description": "Optional summary of the memory.", + "type": "string" + }, + "question": { + "description": "Canonical question or instruction represented by this memory.", + "type": "string" + }, + "answer": { + "description": "Canonical answer or retained guidance represented by this memory.", + "type": "string" + }, + "memoryType": { + "$ref": "#/definitions/memoryType" + }, + "memoryScope": { + "$ref": "#/definitions/memoryScope" + }, + "status": { + "$ref": "#/definitions/memoryStatus" + }, + "shareConfig": { + "$ref": "#/definitions/shareConfig" + }, + "primaryEntity": { + "description": "Primary entity this memory should attach to for reuse.", + "$ref": "../../type/entityReference.json" + }, + "relatedEntities": { + "description": "Additional related entities this memory applies to.", + "$ref": "../../type/entityReferenceList.json" + }, + "sourceType": { + "$ref": "#/definitions/sourceType" + }, + "sourceConversation": { + "description": "Conversation identifier that produced this memory.", + "$ref": "../../type/basic.json#/definitions/uuid" + }, + "sourceHumanMessage": { + "description": "Human message identifier used to produce this memory.", + "$ref": "../../type/basic.json#/definitions/uuid" + }, + "sourceAssistantMessage": { + "description": "Assistant message identifier used to produce this memory.", + "$ref": "../../type/basic.json#/definitions/uuid" + }, + "rootMemory": { + "description": "Root memory in an append-style memory thread.", + "$ref": "../../type/entityReference.json" + }, + "parentMemory": { + "description": "Immediate parent memory in an append-style thread.", + "$ref": "../../type/entityReference.json" + }, + "machineRepresentation": { + "$ref": "#/definitions/machineRepresentation" + }, + "usageCount": { + "description": "How many times this memory has been used in AI-assisted retrieval.", + "type": "integer", + "default": 0 + }, + "lastUsedAt": { + "description": "Last time the memory was used by AI-assisted retrieval.", + "$ref": "../../type/basic.json#/definitions/timestamp" + }, + "owners": { + "description": "Owners of this memory.", + "$ref": "../../type/entityReferenceList.json", + "default": null + }, + "tags": { + "description": "Tags associated with this memory.", + "type": "array", + "items": { + "$ref": "../../type/tagLabel.json" + }, + "default": null + }, + "domains": { + "description": "Domains this memory belongs to.", + "$ref": "../../type/entityReferenceList.json" + }, + "version": { + "description": "Metadata version of the entity.", + "$ref": "../../type/entityHistory.json#/definitions/entityVersion" + }, + "updatedAt": { + "description": "Last update time in Unix epoch time milliseconds.", + "$ref": "../../type/basic.json#/definitions/timestamp" + }, + "updatedBy": { + "description": "User who made the update.", + "type": "string" + }, + "href": { + "description": "Link to this resource.", + "$ref": "../../type/basic.json#/definitions/href" + }, + "changeDescription": { + "description": "Change that led to this version.", + "$ref": "../../type/entityHistory.json#/definitions/changeDescription" + }, + "incrementalChangeDescription": { + "description": "Incremental change that led to this version.", + "$ref": "../../type/entityHistory.json#/definitions/changeDescription" + }, + "deleted": { + "description": "When true indicates the entity has been soft deleted.", + "type": "boolean", + "default": false + } + }, + "required": ["id", "name"], + "additionalProperties": false +} diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/api/context/createContextMemory.ts b/openmetadata-ui/src/main/resources/ui/src/generated/api/context/createContextMemory.ts new file mode 100644 index 000000000000..86121ceca80f --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/generated/api/context/createContextMemory.ts @@ -0,0 +1,448 @@ +/* + * Copyright 2026 Collate. + * 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. + */ +/** + * Request to create a reusable Context Center memory. + */ +export interface CreateContextMemory { + /** + * Canonical answer or retained guidance represented by this memory. + */ + answer: string; + /** + * Optional markdown description for the memory. + */ + description?: string; + /** + * Display name for the memory. + */ + displayName?: string; + /** + * Fully qualified names of the domains this memory belongs to. + */ + domains?: string[]; + machineRepresentation?: MachineRepresentation; + memoryScope?: MemoryScope; + memoryType?: MemoryType; + /** + * Stable system name for the memory. + */ + name: string; + /** + * Owners of this memory. + */ + owners?: EntityReference[]; + parentMemory?: EntityReference; + primaryEntity?: EntityReference; + /** + * Canonical question or instruction represented by this memory. + */ + question: string; + relatedEntities?: EntityReference[]; + rootMemory?: EntityReference; + shareConfig?: ShareConfig; + sourceAssistantMessage?: string; + sourceConversation?: string; + sourceHumanMessage?: string; + sourceType?: SourceType; + status?: MemoryStatus; + /** + * Optional summary of the memory. + */ + summary?: string; + /** + * Tags associated with this memory. + */ + tags?: TagLabel[]; + /** + * Short title shown in Context Center. + */ + title?: string; +} + +/** + * Optional machine-oriented representation used for prompt packing. + */ +export interface MachineRepresentation { + /** + * Compressed or transformed memory content. + */ + content?: string; + /** + * Representation format identifier. + */ + format?: string; + /** + * Timestamp when the representation was generated. + */ + generatedAt?: number; + /** + * Hash of the canonical source content used to generate this representation. + */ + generatedFromHash?: string; + status?: MachineRepresentationStatus; + /** + * Version of the representation format. + */ + version?: string; +} + +/** + * Availability state of the machine-oriented representation. + */ +export enum MachineRepresentationStatus { + Failed = "Failed", + Pending = "Pending", + Ready = "Ready", + Stale = "Stale", +} + +/** + * Scope where the memory should be applied. + */ +export enum MemoryScope { + EntityScoped = "EntityScoped", + UserGlobal = "UserGlobal", +} + +/** + * High-level type of reusable memory. + */ +export enum MemoryType { + FAQ = "Faq", + Note = "Note", + Preference = "Preference", + Runbook = "Runbook", + UseCase = "UseCase", +} + +/** + * Owners of this memory. + * + * This schema defines the EntityReferenceList type used for referencing an entity. + * EntityReference is used for capturing relationships from one entity to another. For + * example, a table has an attribute called database of type EntityReference that captures + * the relationship of a table `belongs to a` database. + * + * This schema defines the EntityReference type used for referencing an entity. + * EntityReference is used for capturing relationships from one entity to another. For + * example, a table has an attribute called database of type EntityReference that captures + * the relationship of a table `belongs to a` database. + * + * Principal receiving access. Supported principal types are user, team, and domain. + */ +export interface EntityReference { + /** + * If true the entity referred to has been soft-deleted. + */ + deleted?: boolean; + /** + * Optional description of entity. + */ + description?: string; + /** + * Display Name that identifies this entity. + */ + displayName?: string; + /** + * Fully qualified name of the entity instance. For entities such as tables, databases + * fullyQualifiedName is returned in this field. For entities that don't have name hierarchy + * such as `user` and `team` this will be same as the `name` field. + */ + fullyQualifiedName?: string; + /** + * Link to the entity resource. + */ + href?: string; + /** + * Unique identifier that identifies an entity instance. + */ + id: string; + /** + * If true the relationship indicated by this entity reference is inherited from the parent + * entity. + */ + inherited?: boolean; + /** + * Name of the entity instance. + */ + name?: string; + /** + * Entity type/class name - Examples: `database`, `table`, `metrics`, `databaseService`, + * `dashboardService`... + */ + type: string; +} + +/** + * Visibility and sharing configuration for the memory. + */ +export interface ShareConfig { + /** + * Explicit principals the memory is shared with. + */ + sharedWith?: SharedPrincipal[]; + visibility?: ShareVisibility; +} + +/** + * A principal granted access to the memory. + */ +export interface SharedPrincipal { + /** + * Principal receiving access. Supported principal types are user, team, and domain. + */ + principal?: EntityReference; + /** + * Role granted to the principal. + */ + role?: ShareRole; +} + +/** + * Role granted to the principal. + * + * Role granted to a shared principal. + */ +export enum ShareRole { + Editor = "Editor", + Viewer = "Viewer", +} + +/** + * Visibility level for the memory. + */ +export enum ShareVisibility { + Entity = "Entity", + Private = "Private", + Shared = "Shared", +} + +/** + * How the memory was created. + */ +export enum SourceType { + ChatPromotion = "ChatPromotion", + Manual = "Manual", + RememberRequest = "RememberRequest", +} + +/** + * Lifecycle state of the memory. Any status may be set at creation (e.g. importing an + * already-archived memory); the Draft -> Active -> Archived transition rules are only + * enforced on subsequent updates. + */ +export enum MemoryStatus { + Active = "Active", + Archived = "Archived", + Draft = "Draft", +} + +/** + * This schema defines the type for labeling an entity with a Tag. + */ +export interface TagLabel { + /** + * Timestamp when this tag was applied in ISO 8601 format + */ + appliedAt?: Date; + /** + * Who it is that applied this tag (e.g: a bot, AI or a human) + */ + appliedBy?: string; + /** + * Description for the tag label. + */ + description?: string; + /** + * Display Name that identifies this tag. + */ + displayName?: string; + /** + * Link to the tag resource. + */ + href?: string; + /** + * Label type describes how a tag label was applied. 'Manual' indicates the tag label was + * applied by a person. 'Derived' indicates a tag label was derived using the associated tag + * relationship (see Classification.json for more details). 'Propagated` indicates a tag + * label was propagated from upstream based on lineage. 'Automated' is used when a tool was + * used to determine the tag label. + */ + labelType: LabelType; + /** + * Additional metadata associated with this tag label, such as recognizer information for + * automatically applied tags. + */ + metadata?: TagLabelMetadata; + /** + * Name of the tag or glossary term. + */ + name?: string; + /** + * An explanation of why this tag was proposed, specially for autoclassification tags + */ + reason?: string; + /** + * Label is from Tags or Glossary. + */ + source: TagSource; + /** + * 'Suggested' state is used when a tag label is suggested by users or tools. Owner of the + * entity must confirm the suggested labels before it is marked as 'Confirmed'. + */ + state: State; + style?: Style; + tagFQN: string; +} + +/** + * Label type describes how a tag label was applied. 'Manual' indicates the tag label was + * applied by a person. 'Derived' indicates a tag label was derived using the associated tag + * relationship (see Classification.json for more details). 'Propagated` indicates a tag + * label was propagated from upstream based on lineage. 'Automated' is used when a tool was + * used to determine the tag label. + */ +export enum LabelType { + Automated = "Automated", + Derived = "Derived", + Generated = "Generated", + Manual = "Manual", + Propagated = "Propagated", +} + +/** + * Additional metadata associated with this tag label, such as recognizer information for + * automatically applied tags. + * + * Additional metadata associated with a tag label, including information about how the tag + * was applied. + */ +export interface TagLabelMetadata { + /** + * Epoch time in milliseconds when the certification tag expires + */ + expiryDate?: number; + /** + * Metadata about the recognizer that automatically applied this tag + */ + recognizer?: TagLabelRecognizerMetadata; +} + +/** + * Metadata about the recognizer that automatically applied this tag + * + * Metadata about the recognizer that applied a tag, including scoring and pattern + * information. + */ +export interface TagLabelRecognizerMetadata { + /** + * Details of patterns that matched during recognition + */ + patterns?: PatternMatch[]; + /** + * Unique identifier of the recognizer that applied this tag + */ + recognizerId: string; + /** + * Human-readable name of the recognizer + */ + recognizerName: string; + /** + * Confidence score assigned by the recognizer (0.0 to 1.0) + */ + score: number; + /** + * What the recognizer analyzed to apply this tag + */ + target?: Target; +} + +/** + * Information about a pattern that matched during recognition + */ +export interface PatternMatch { + /** + * Name of the pattern that matched + */ + name: string; + /** + * Regular expression or pattern definition + */ + regex?: string; + /** + * Confidence score for this specific pattern match + */ + score: number; +} + +/** + * What the recognizer analyzed to apply this tag + */ +export enum Target { + ColumnName = "column_name", + Content = "content", +} + +/** + * Label is from Tags or Glossary. + */ +export enum TagSource { + Classification = "Classification", + Glossary = "Glossary", +} + +/** + * 'Suggested' state is used when a tag label is suggested by users or tools. Owner of the + * entity must confirm the suggested labels before it is marked as 'Confirmed'. + */ +export enum State { + Confirmed = "Confirmed", + Suggested = "Suggested", +} + +/** + * UI Style is used to associate a color code and/or icon to entity to customize the look of + * that entity in UI. + */ +export interface Style { + /** + * Hex Color Code to mark an entity such as GlossaryTerm, Tag, Domain or Data Product. + */ + color?: string; + /** + * Cover image configuration for the entity. + */ + coverImage?: CoverImage; + /** + * An icon to associate with GlossaryTerm, Tag, Domain or Data Product. + */ + iconURL?: string; +} + +/** + * Cover image configuration for the entity. + * + * Cover image configuration for an entity. This is used to display a banner or header image + * for entities like Domain, Glossary, Data Product, etc. + */ +export interface CoverImage { + /** + * Position of the cover image in CSS background-position format. Supports keywords (top, + * center, bottom) or pixel values (e.g., '20px 30px'). + */ + position?: string; + /** + * URL of the cover image. + */ + url?: string; +} diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/entity/context/contextMemory.ts b/openmetadata-ui/src/main/resources/ui/src/generated/entity/context/contextMemory.ts new file mode 100644 index 000000000000..e7670fee6e9e --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/context/contextMemory.ts @@ -0,0 +1,586 @@ +/* + * Copyright 2026 Collate. + * 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. + */ +/** + * Reusable context memory for Context Center and AI-assisted retrieval. + */ +export interface ContextMemory { + /** + * Canonical answer or retained guidance represented by this memory. + */ + answer?: string; + /** + * Change that led to this version. + */ + changeDescription?: ChangeDescription; + /** + * When true indicates the entity has been soft deleted. + */ + deleted?: boolean; + /** + * Optional markdown description for the memory. + */ + description?: string; + /** + * Display name of the memory. + */ + displayName?: string; + /** + * Domains this memory belongs to. + */ + domains?: EntityReference[]; + /** + * Fully qualified name of the memory. + */ + fullyQualifiedName?: string; + /** + * Link to this resource. + */ + href?: string; + /** + * Unique identifier of the memory. + */ + id: string; + /** + * Incremental change that led to this version. + */ + incrementalChangeDescription?: ChangeDescription; + /** + * Last time the memory was used by AI-assisted retrieval. + */ + lastUsedAt?: number; + machineRepresentation?: MachineRepresentation; + memoryScope?: MemoryScope; + memoryType?: MemoryType; + /** + * Stable system name for the memory. + */ + name: string; + /** + * Owners of this memory. + */ + owners?: EntityReference[]; + /** + * Immediate parent memory in an append-style thread. + */ + parentMemory?: EntityReference; + /** + * Primary entity this memory should attach to for reuse. + */ + primaryEntity?: EntityReference; + /** + * Canonical question or instruction represented by this memory. + */ + question?: string; + /** + * Additional related entities this memory applies to. + */ + relatedEntities?: EntityReference[]; + /** + * Root memory in an append-style memory thread. + */ + rootMemory?: EntityReference; + shareConfig?: ShareConfig; + /** + * Assistant message identifier used to produce this memory. + */ + sourceAssistantMessage?: string; + /** + * Conversation identifier that produced this memory. + */ + sourceConversation?: string; + /** + * Human message identifier used to produce this memory. + */ + sourceHumanMessage?: string; + sourceType?: SourceType; + status?: MemoryStatus; + /** + * Optional summary of the memory. + */ + summary?: string; + /** + * Tags associated with this memory. + */ + tags?: TagLabel[]; + /** + * Short title shown in Context Center. + */ + title?: string; + /** + * Last update time in Unix epoch time milliseconds. + */ + updatedAt?: number; + /** + * User who made the update. + */ + updatedBy?: string; + /** + * How many times this memory has been used in AI-assisted retrieval. + */ + usageCount?: number; + /** + * Metadata version of the entity. + */ + version?: number; +} + +/** + * Change that led to this version. + * + * Description of the change. + * + * Incremental change that led to this version. + */ +export interface ChangeDescription { + changeSummary?: { [key: string]: ChangeSummary }; + /** + * Names of fields added during the version changes. + */ + fieldsAdded?: FieldChange[]; + /** + * Fields deleted during the version changes with old value before deleted. + */ + fieldsDeleted?: FieldChange[]; + /** + * Fields modified during the version changes with old and new values. + */ + fieldsUpdated?: FieldChange[]; + /** + * When a change did not result in change, this could be same as the current version. + */ + previousVersion?: number; +} + +export interface ChangeSummary { + changedAt?: number; + /** + * Name of the user or bot who made this change + */ + changedBy?: string; + changeSource?: ChangeSource; + [property: string]: any; +} + +/** + * The source of the change. This will change based on the context of the change (example: + * manual vs programmatic) + */ +export enum ChangeSource { + Automated = "Automated", + Derived = "Derived", + Ingested = "Ingested", + Manual = "Manual", + Propagated = "Propagated", + Suggested = "Suggested", +} + +export interface FieldChange { + /** + * Name of the entity field that changed. + */ + name?: string; + /** + * New value of the field. Note that this is a JSON string and use the corresponding field + * type to deserialize it. + */ + newValue?: any; + /** + * Previous value of the field. Note that this is a JSON string and use the corresponding + * field type to deserialize it. + */ + oldValue?: any; +} + +/** + * Domains this memory belongs to. + * + * This schema defines the EntityReferenceList type used for referencing an entity. + * EntityReference is used for capturing relationships from one entity to another. For + * example, a table has an attribute called database of type EntityReference that captures + * the relationship of a table `belongs to a` database. + * + * This schema defines the EntityReference type used for referencing an entity. + * EntityReference is used for capturing relationships from one entity to another. For + * example, a table has an attribute called database of type EntityReference that captures + * the relationship of a table `belongs to a` database. + * + * Immediate parent memory in an append-style thread. + * + * Primary entity this memory should attach to for reuse. + * + * Root memory in an append-style memory thread. + * + * Principal receiving access. Supported principal types are user, team, and domain. + */ +export interface EntityReference { + /** + * If true the entity referred to has been soft-deleted. + */ + deleted?: boolean; + /** + * Optional description of entity. + */ + description?: string; + /** + * Display Name that identifies this entity. + */ + displayName?: string; + /** + * Fully qualified name of the entity instance. For entities such as tables, databases + * fullyQualifiedName is returned in this field. For entities that don't have name hierarchy + * such as `user` and `team` this will be same as the `name` field. + */ + fullyQualifiedName?: string; + /** + * Link to the entity resource. + */ + href?: string; + /** + * Unique identifier that identifies an entity instance. + */ + id: string; + /** + * If true the relationship indicated by this entity reference is inherited from the parent + * entity. + */ + inherited?: boolean; + /** + * Name of the entity instance. + */ + name?: string; + /** + * Entity type/class name - Examples: `database`, `table`, `metrics`, `databaseService`, + * `dashboardService`... + */ + type: string; +} + +/** + * Optional machine-oriented representation used for prompt packing. + */ +export interface MachineRepresentation { + /** + * Compressed or transformed memory content. + */ + content?: string; + /** + * Representation format identifier. + */ + format?: string; + /** + * Timestamp when the representation was generated. + */ + generatedAt?: number; + /** + * Hash of the canonical source content used to generate this representation. + */ + generatedFromHash?: string; + status?: MachineRepresentationStatus; + /** + * Version of the representation format. + */ + version?: string; +} + +/** + * Availability state of the machine-oriented representation. + */ +export enum MachineRepresentationStatus { + Failed = "Failed", + Pending = "Pending", + Ready = "Ready", + Stale = "Stale", +} + +/** + * Scope where the memory should be applied. + */ +export enum MemoryScope { + EntityScoped = "EntityScoped", + UserGlobal = "UserGlobal", +} + +/** + * High-level type of reusable memory. + */ +export enum MemoryType { + FAQ = "Faq", + Note = "Note", + Preference = "Preference", + Runbook = "Runbook", + UseCase = "UseCase", +} + +/** + * Visibility and sharing configuration for the memory. + */ +export interface ShareConfig { + /** + * Explicit principals the memory is shared with. + */ + sharedWith?: SharedPrincipal[]; + visibility?: ShareVisibility; +} + +/** + * A principal granted access to the memory. + */ +export interface SharedPrincipal { + /** + * Principal receiving access. Supported principal types are user, team, and domain. + */ + principal?: EntityReference; + /** + * Role granted to the principal. + */ + role?: ShareRole; +} + +/** + * Role granted to the principal. + * + * Role granted to a shared principal. + */ +export enum ShareRole { + Editor = "Editor", + Viewer = "Viewer", +} + +/** + * Visibility level for the memory. + */ +export enum ShareVisibility { + Entity = "Entity", + Private = "Private", + Shared = "Shared", +} + +/** + * How the memory was created. + */ +export enum SourceType { + ChatPromotion = "ChatPromotion", + Manual = "Manual", + RememberRequest = "RememberRequest", +} + +/** + * Lifecycle state of the memory. Any status may be set at creation (e.g. importing an + * already-archived memory); the Draft -> Active -> Archived transition rules are only + * enforced on subsequent updates. + */ +export enum MemoryStatus { + Active = "Active", + Archived = "Archived", + Draft = "Draft", +} + +/** + * This schema defines the type for labeling an entity with a Tag. + */ +export interface TagLabel { + /** + * Timestamp when this tag was applied in ISO 8601 format + */ + appliedAt?: Date; + /** + * Who it is that applied this tag (e.g: a bot, AI or a human) + */ + appliedBy?: string; + /** + * Description for the tag label. + */ + description?: string; + /** + * Display Name that identifies this tag. + */ + displayName?: string; + /** + * Link to the tag resource. + */ + href?: string; + /** + * Label type describes how a tag label was applied. 'Manual' indicates the tag label was + * applied by a person. 'Derived' indicates a tag label was derived using the associated tag + * relationship (see Classification.json for more details). 'Propagated` indicates a tag + * label was propagated from upstream based on lineage. 'Automated' is used when a tool was + * used to determine the tag label. + */ + labelType: LabelType; + /** + * Additional metadata associated with this tag label, such as recognizer information for + * automatically applied tags. + */ + metadata?: TagLabelMetadata; + /** + * Name of the tag or glossary term. + */ + name?: string; + /** + * An explanation of why this tag was proposed, specially for autoclassification tags + */ + reason?: string; + /** + * Label is from Tags or Glossary. + */ + source: TagSource; + /** + * 'Suggested' state is used when a tag label is suggested by users or tools. Owner of the + * entity must confirm the suggested labels before it is marked as 'Confirmed'. + */ + state: State; + style?: Style; + tagFQN: string; +} + +/** + * Label type describes how a tag label was applied. 'Manual' indicates the tag label was + * applied by a person. 'Derived' indicates a tag label was derived using the associated tag + * relationship (see Classification.json for more details). 'Propagated` indicates a tag + * label was propagated from upstream based on lineage. 'Automated' is used when a tool was + * used to determine the tag label. + */ +export enum LabelType { + Automated = "Automated", + Derived = "Derived", + Generated = "Generated", + Manual = "Manual", + Propagated = "Propagated", +} + +/** + * Additional metadata associated with this tag label, such as recognizer information for + * automatically applied tags. + * + * Additional metadata associated with a tag label, including information about how the tag + * was applied. + */ +export interface TagLabelMetadata { + /** + * Epoch time in milliseconds when the certification tag expires + */ + expiryDate?: number; + /** + * Metadata about the recognizer that automatically applied this tag + */ + recognizer?: TagLabelRecognizerMetadata; +} + +/** + * Metadata about the recognizer that automatically applied this tag + * + * Metadata about the recognizer that applied a tag, including scoring and pattern + * information. + */ +export interface TagLabelRecognizerMetadata { + /** + * Details of patterns that matched during recognition + */ + patterns?: PatternMatch[]; + /** + * Unique identifier of the recognizer that applied this tag + */ + recognizerId: string; + /** + * Human-readable name of the recognizer + */ + recognizerName: string; + /** + * Confidence score assigned by the recognizer (0.0 to 1.0) + */ + score: number; + /** + * What the recognizer analyzed to apply this tag + */ + target?: Target; +} + +/** + * Information about a pattern that matched during recognition + */ +export interface PatternMatch { + /** + * Name of the pattern that matched + */ + name: string; + /** + * Regular expression or pattern definition + */ + regex?: string; + /** + * Confidence score for this specific pattern match + */ + score: number; +} + +/** + * What the recognizer analyzed to apply this tag + */ +export enum Target { + ColumnName = "column_name", + Content = "content", +} + +/** + * Label is from Tags or Glossary. + */ +export enum TagSource { + Classification = "Classification", + Glossary = "Glossary", +} + +/** + * 'Suggested' state is used when a tag label is suggested by users or tools. Owner of the + * entity must confirm the suggested labels before it is marked as 'Confirmed'. + */ +export enum State { + Confirmed = "Confirmed", + Suggested = "Suggested", +} + +/** + * UI Style is used to associate a color code and/or icon to entity to customize the look of + * that entity in UI. + */ +export interface Style { + /** + * Hex Color Code to mark an entity such as GlossaryTerm, Tag, Domain or Data Product. + */ + color?: string; + /** + * Cover image configuration for the entity. + */ + coverImage?: CoverImage; + /** + * An icon to associate with GlossaryTerm, Tag, Domain or Data Product. + */ + iconURL?: string; +} + +/** + * Cover image configuration for the entity. + * + * Cover image configuration for an entity. This is used to display a banner or header image + * for entities like Domain, Glossary, Data Product, etc. + */ +export interface CoverImage { + /** + * Position of the cover image in CSS background-position format. Supports keywords (top, + * center, bottom) or pixel values (e.g., '20px 30px'). + */ + position?: string; + /** + * URL of the cover image. + */ + url?: string; +}