4 Commits

Author SHA1 Message Date
9f9f7247f0 docs: clarify license status 2026-06-12 22:08:00 +08:00
15c24154f8 feat(impression): expose entity identity updates
Add core-owned APIs for renaming canonical subjects and adding aliases so updater logic can request identity changes without bypassing indexes.

Synchronize bound active entity subjects after renames and keep capability test stubs aligned.
2026-06-10 14:50:13 +08:00
a23657ec0c feat(impression): support entity aliases
Separate canonical entity subject from aliases and persist alias metadata for recall.

Index aliases as subject-like search documents and cover alias recall in SimpleTextSearch tests.
2026-06-10 14:44:50 +08:00
371b4a01d7 feat(impression): add core mutation APIs
Expose core-owned entity creation, binding, and impression update methods so updater logic can request mutations without bypassing indexes.

Add ActiveEntity snapshots for safe inspection and keep test stubs aligned with CognitionCapability.
2026-06-10 14:02:31 +08:00
10 changed files with 595 additions and 5 deletions

View File

@@ -3,10 +3,12 @@ package work.slhaf.partner.core.cognition;
import org.w3c.dom.Element;
import work.slhaf.partner.core.cognition.context.ContextWorkspace;
import work.slhaf.partner.core.cognition.impression.ActiveEntity;
import work.slhaf.partner.core.cognition.impression.Entity;
import work.slhaf.partner.framework.agent.factory.capability.annotation.Capability;
import work.slhaf.partner.framework.agent.model.pojo.Message;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.locks.Lock;
@@ -29,6 +31,55 @@ public interface CognitionCapability {
Lock getMessageLock();
/**
* Project user input onto known or currently active entities and append the input as runtime evidence.
*/
Set<ActiveEntity> projectEntity(String input);
/**
* Return current active entities with their bound known entities when available.
* ActiveEntity values are snapshots; Entity values are live known-entity references and should be updated through this capability.
*/
Map<ActiveEntity, Entity> showEntities();
/**
* Create and register a new known entity by subject, then refresh search indexes for it.
*/
Entity createEntity(String subject);
/**
* Return a known entity by uuid, or null when it does not exist.
*/
Entity getEntity(String uuid);
/**
* Bind an active runtime entity to a known entity and refresh the active-entity search document.
*/
boolean bindActiveEntity(String runtimeId, String entityUuid);
/**
* Rename the canonical subject of a known entity and refresh entity/active-entity indexes.
*/
boolean renameEntitySubject(String entityUuid, String newSubject);
/**
* Add an alias or mention form for a known entity and refresh entity indexes.
*/
boolean addEntityAlias(String entityUuid, String alias, boolean deprecated);
/**
* Add or replace an impression on a known entity and refresh all entity indexes.
*/
boolean updateEntityImpression(String entityUuid, String impression, String newImpression, double confidence);
/**
* Add or replace a stable feature on a known entity and refresh all entity indexes.
*/
boolean updateEntityFeature(String entityUuid, String feature, String newFeature, double confidence);
/**
* Add or update a relation from one known entity to another target and refresh all entity indexes.
*/
boolean updateEntityRelation(String entityUuid, String target, String relation, double strength);
}

View File

@@ -64,6 +64,23 @@ class ActiveEntity @JvmOverloads constructor(
impressions.forEach { _projectedImpressions[it.first] = it.second }
}
/**
* Creates a detached runtime snapshot for external inspection without exposing mutable internal collections.
*/
fun snapshot(): ActiveEntity {
val copied = ActiveEntity(
runtimeId = runtimeId,
createdAt = createdAt,
boundEntityUuid = boundEntityUuid,
_evidences = synchronized(_evidences) { _evidences.toMutableList() },
)
copied.updateSubject(subject)
copied.touch(lastMentionedAt)
copied.addProjectedFeatures(*projectedFeatures.entries.map { it.key to it.value }.toTypedArray())
copied.addProjectedImpressions(*projectedImpressions.entries.map { it.key to it.value }.toTypedArray())
return copied
}
override fun fillXml(document: Document, root: Element) {
root.setAttribute("runtime_id", runtimeId)
boundEntityUuid?.let { root.setAttribute("bound_entity_uuid", it) }

View File

@@ -6,6 +6,7 @@ import work.slhaf.partner.framework.agent.state.State
import work.slhaf.partner.framework.agent.state.StateSerializable
import work.slhaf.partner.framework.agent.state.StateValue
import java.nio.file.Path
import java.time.Instant
import java.util.*
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.locks.ReentrantLock
@@ -13,15 +14,67 @@ import kotlin.concurrent.withLock
class Entity @JvmOverloads constructor(
val uuid: String = UUID.randomUUID().toString(),
val subject: String,
subject: String,
private val relations: MutableMap<String, MutableMap<String, Double>> = mutableMapOf(),
private val impressions: MutableMap<String, IndexableData> = mutableMapOf(),
private val features: MutableMap<String, IndexableData> = mutableMapOf()
private val features: MutableMap<String, IndexableData> = mutableMapOf(),
private val aliases: MutableMap<String, AliasMetadata> = mutableMapOf()
) : StateSerializable {
private var _subject: String = normalizeIdentityText(subject)
private val impressionLock = ReentrantLock()
private val relationLock = ReentrantLock()
private val featureLock = ReentrantLock()
private val identityLock = ReentrantLock()
val subject: String
get() = identityLock.withLock { _subject }
@JvmOverloads
fun renameSubject(newSubject: String, keepOldSubjectAsAlias: Boolean = true): Boolean = identityLock.withLock {
val normalizedSubject = normalizeIdentityText(newSubject)
if (normalizedSubject.isBlank() || normalizedSubject == _subject) {
return@withLock false
}
val previousSubject = _subject
if (keepOldSubjectAsAlias && previousSubject.isNotBlank()) {
aliases[previousSubject] = aliases[previousSubject]?.copy(deprecated = true)
?: AliasMetadata(Instant.now(), deprecated = true)
}
aliases.remove(normalizedSubject)
_subject = normalizedSubject
true
}
@JvmOverloads
fun addAlias(alias: String, deprecated: Boolean = false): Boolean = identityLock.withLock {
val normalizedAlias = normalizeIdentityText(alias)
if (normalizedAlias.isBlank() || normalizedAlias == _subject) {
return@withLock false
}
aliases[normalizedAlias] = aliases[normalizedAlias]?.copy(deprecated = deprecated)
?: AliasMetadata(Instant.now(), deprecated)
true
}
@JvmOverloads
fun showAliases(includeDeprecated: Boolean = false): Set<AliasView> = identityLock.withLock {
aliases.asSequence()
.filter { (_, metadata) -> includeDeprecated || !metadata.deprecated }
.map { (alias, metadata) ->
AliasView(alias, metadata.instant, metadata.deprecated)
}
.sortedWith(compareBy<AliasView> { it.createdAt }.thenBy { it.alias })
.toCollection(LinkedHashSet())
}
fun snapshotAliases(): Map<String, AliasMetadata> = identityLock.withLock {
aliases.mapValues { (_, metadata) -> metadata.copy() }
}
@JvmOverloads
fun updateRelation(
@@ -154,18 +207,56 @@ class Entity @JvmOverloads constructor(
}
}
identityLock.withLock {
state.getString("subject")
?.let(::normalizeIdentityText)
?.takeIf(String::isNotBlank)
?.let { _subject = it }
}
state.getJSONObject("features")?.let { loadedFeatures ->
featureLock.withLock {
features.clear()
features.putAll(loadIndexableDataMap(loadedFeatures))
}
}
state.getJSONObject("aliases")?.let { loadedAliases ->
identityLock.withLock {
aliases.clear()
loadedAliases.forEach { (alias, metadataValue) ->
val normalizedAlias = normalizeIdentityText(alias)
if (normalizedAlias.isBlank() || normalizedAlias == _subject) {
return@forEach
}
val metadata = when (metadataValue) {
is JSONObject -> loadAliasMetadata(metadataValue)
else -> AliasMetadata(Instant.now(), deprecated = false)
}
aliases[normalizedAlias] = metadata
}
}
}
}
override fun convert(): State {
val state = State()
state.append("uuid", StateValue.str(uuid))
state.append("subject", StateValue.str(subject))
val identityState = identityLock.withLock {
IdentityState(
subject = _subject,
aliases = aliases.mapValues { (_, metadata) ->
mapOf(
"timestamp" to metadata.instant.toEpochMilli(),
"deprecated" to metadata.deprecated
)
}
)
}
state.append("subject", StateValue.str(identityState.subject))
state.append("aliases", StateValue.obj(identityState.aliases))
val relationState = relationLock.withLock {
relations.mapValues { (_, relationMap) -> relationMap.toMap() }
@@ -187,6 +278,22 @@ class Entity @JvmOverloads constructor(
override fun autoLoadOnRegister(): Boolean = false
private fun normalizeIdentityText(value: String): String =
value.replace(IDENTITY_WHITESPACE_REGEX, " ").trim()
private fun loadAliasMetadata(state: JSONObject): AliasMetadata {
val instant = state.getLong("timestamp")
?.let(Instant::ofEpochMilli)
?: state.getString("instant")
?.let { runCatching { Instant.parse(it) }.getOrNull() }
?: Instant.now()
return AliasMetadata(
instant = instant,
deprecated = state.getBoolean("deprecated") ?: false
)
}
private fun loadIndexableDataMap(state: JSONObject): Map<String, IndexableData> {
val loaded = mutableMapOf<String, IndexableData>()
state.forEach { (key, value) ->
@@ -269,4 +376,24 @@ class Entity @JvmOverloads constructor(
val confidence: Double,
val vector: FloatArray?
)
private data class IdentityState(
val subject: String,
val aliases: Map<String, Map<String, Any>>
)
data class AliasView(
val alias: String,
val createdAt: Instant,
val deprecated: Boolean
)
data class AliasMetadata(
val instant: Instant,
val deprecated: Boolean
)
companion object {
private val IDENTITY_WHITESPACE_REGEX = Regex("\\s+")
}
}

View File

@@ -69,6 +69,209 @@ public class ImpressionCore implements StateSerializable {
return projected;
}
/**
* 列出当前已存在的 ActiveEntity 以及对应的 Entity。ActiveEntity 返回快照Entity 返回当前已知实体引用。
*
* 注意:外部模块不要直接修改返回的 Entity否则文本索引 / 向量索引不会刷新。
* Impression 更新应走 updateEntity* 系列接口。
*
* @return ActiveEntity 快照与已绑定 Entity 的映射
*/
@CapabilityMethod
public Map<ActiveEntity, Entity> showEntities() {
Map<ActiveEntity, Entity> result = new LinkedHashMap<>();
List<ActiveEntity> entities;
synchronized (activeEntities) {
entities = activeEntities.stream()
.sorted(Comparator
.comparing(ActiveEntity::getLastMentionedAt)
.reversed()
.thenComparing(ActiveEntity::getRuntimeId))
.toList();
}
for (ActiveEntity activeEntity : entities) {
Entity boundEntity = Optional.ofNullable(activeEntity.getBoundEntityUuid())
.map(knownEntitiesByUuid::get)
.orElse(null);
result.put(activeEntity.snapshot(), boundEntity);
}
return Collections.unmodifiableMap(result);
}
/**
* Create a new known entity and make it visible to recall/update indexes immediately.
*/
@CapabilityMethod
public Entity createEntity(String subject) {
if (subject == null || subject.isBlank()) {
throw new IllegalArgumentException("subject must not be blank");
}
Entity entity = new Entity(UUID.randomUUID().toString(), subject.trim());
entity.register();
knownEntitiesByUuid.put(entity.getUuid(), entity);
refreshKnownEntityIndexes(entity);
return entity;
}
/**
* Look up a known entity by stable uuid.
*/
@CapabilityMethod
public Entity getEntity(String uuid) {
if (uuid == null || uuid.isBlank()) {
return null;
}
return knownEntitiesByUuid.get(uuid);
}
/**
* Bind a runtime active entity to a known entity.
* This keeps the active entity in current context while giving later updates a stable storage target.
*/
@CapabilityMethod
public boolean bindActiveEntity(String runtimeId, String entityUuid) {
if (runtimeId == null || runtimeId.isBlank() || entityUuid == null || entityUuid.isBlank()) {
return false;
}
Entity entity = knownEntitiesByUuid.get(entityUuid);
if (entity == null) {
return false;
}
Optional<ActiveEntity> activeEntity = findActiveEntityByRuntimeId(runtimeId);
if (activeEntity.isEmpty()) {
return false;
}
ActiveEntity active = activeEntity.get();
active.bindEntity(entityUuid);
active.updateSubject(entity.getSubject());
refreshActiveEntityTextSearch(active);
return true;
}
/**
* Rename the canonical subject of a known entity and keep its previous subject as a historical alias.
*/
@CapabilityMethod
public boolean renameEntitySubject(String entityUuid, String newSubject) {
Entity entity = knownEntitiesByUuid.get(entityUuid);
if (entity == null || newSubject == null || newSubject.isBlank()) {
return false;
}
boolean renamed = entity.renameSubject(newSubject.trim());
if (!renamed) {
return false;
}
refreshKnownEntityIndexes(entity);
syncBoundActiveEntitySubjects(entity);
return true;
}
/**
* Add an alias or mention form for a known entity and refresh search indexes.
*/
@CapabilityMethod
public boolean addEntityAlias(String entityUuid, String alias, boolean deprecated) {
Entity entity = knownEntitiesByUuid.get(entityUuid);
if (entity == null || alias == null || alias.isBlank()) {
return false;
}
boolean added = entity.addAlias(alias.trim(), deprecated);
if (!added) {
return false;
}
refreshKnownEntityIndexes(entity);
return true;
}
/**
* Update a known entity impression through the core so text/vector indexes stay consistent.
* newImpression can be null or blank to update the existing impression in place.
*/
@CapabilityMethod
public boolean updateEntityImpression(
String entityUuid,
String impression,
String newImpression,
double confidence
) {
Entity entity = knownEntitiesByUuid.get(entityUuid);
if (entity == null || impression == null || impression.isBlank()) {
return false;
}
entity.updateImpression(
impression.trim(),
normalizeNullableText(newImpression),
confidence
);
refreshKnownEntityIndexes(entity);
return true;
}
/**
* Update a known entity feature through the core so text/vector indexes stay consistent.
* newFeature can be null or blank to update the existing feature in place.
*/
@CapabilityMethod
public boolean updateEntityFeature(
String entityUuid,
String feature,
String newFeature,
double confidence
) {
Entity entity = knownEntitiesByUuid.get(entityUuid);
if (entity == null || feature == null || feature.isBlank()) {
return false;
}
entity.updateFeature(
feature.trim(),
normalizeNullableText(newFeature),
confidence
);
refreshKnownEntityIndexes(entity);
return true;
}
/**
* Update a known entity relation through the core so search documents reflect the changed relation.
*/
@CapabilityMethod
public boolean updateEntityRelation(
String entityUuid,
String target,
String relation,
double strength
) {
Entity entity = knownEntitiesByUuid.get(entityUuid);
if (entity == null || target == null || target.isBlank() || relation == null || relation.isBlank()) {
return false;
}
entity.updateRelation(target.trim(), relation.trim(), strength);
refreshKnownEntityIndexes(entity);
return true;
}
/**
* Normalize optional replacement text used by update methods.
*/
private String normalizeNullableText(String value) {
if (value == null || value.isBlank()) {
return null;
}
return value.trim();
}
private List<EntityAssociationMatch> aggregateMatches(
List<ImpressionSearchHit> hits,
int limit
@@ -183,6 +386,42 @@ public class ImpressionCore implements StateSerializable {
}
}
/**
* Refresh every index derived from a known entity after mutation.
*/
private void refreshKnownEntityIndexes(Entity entity) {
vectorIndex.sync(entity);
refreshKnownEntityTextSearch(entity);
}
private void syncBoundActiveEntitySubjects(Entity entity) {
List<ActiveEntity> boundEntities;
synchronized (activeEntities) {
boundEntities = activeEntities.stream()
.filter(activeEntity -> entity.getUuid().equals(activeEntity.getBoundEntityUuid()))
.toList();
}
boundEntities.forEach(activeEntity -> {
activeEntity.updateSubject(entity.getSubject());
refreshActiveEntityTextSearch(activeEntity);
});
}
/**
* Replace text-search documents for one known entity.
*/
private void refreshKnownEntityTextSearch(Entity entity) {
ImpressionSearchTarget target = new ImpressionSearchTarget(
ImpressionSearchTarget.Type.ENTITY,
entity.getUuid()
);
textSearch.removeByTarget(target);
for (ImpressionSearchDocument document : ImpressionSearchDocuments.INSTANCE.fromEntity(entity)) {
textSearch.upsert(document);
}
}
private void rebuildTextSearch() {
List<ImpressionSearchDocument> documents = new ArrayList<>();
knownEntitiesByUuid.values().forEach(entity ->
@@ -222,6 +461,7 @@ public class ImpressionCore implements StateSerializable {
}
Entity entity = new Entity(uuid, subject);
entity.register();
entity.load();
vectorIndex.sync(entity);
knownEntitiesByUuid.put(uuid, entity);

View File

@@ -84,6 +84,18 @@ object ImpressionSearchDocuments {
)
)
entity.showAliases(includeDeprecated = true).forEachIndexed { index, alias ->
add(
ImpressionSearchDocument(
id = "entity:${entity.uuid}:alias:$index",
target = target,
field = ImpressionSearchField.SUBJECT,
text = alias.alias,
weight = SUBJECT_WEIGHT * ALIAS_WEIGHT_FACTOR,
)
)
}
entity.snapshotFeatures().keys.forEachIndexed { index, feature ->
add(
ImpressionSearchDocument(
@@ -131,6 +143,7 @@ object ImpressionSearchDocuments {
}
private const val SUBJECT_WEIGHT = 1.0
private const val ALIAS_WEIGHT_FACTOR = 0.9
private const val FEATURE_WEIGHT = 0.85
private const val IMPRESSION_WEIGHT = 0.75
private const val RELATION_WEIGHT = 0.65

View File

@@ -0,0 +1,19 @@
package work.slhaf.partner.module.impression;
import work.slhaf.partner.core.cognition.CognitionCapability;
import work.slhaf.partner.framework.agent.factory.capability.annotation.InjectCapability;
import work.slhaf.partner.framework.agent.factory.component.abstracts.AbstractAgentModule;
import work.slhaf.partner.module.communication.AfterRolling;
import work.slhaf.partner.module.communication.RollingResult;
public class ImpressionUpdater extends AbstractAgentModule.Standalone implements AfterRolling {
@InjectCapability
private CognitionCapability cognitionCapability;
@Override
public void consume(RollingResult result) {
}
}

View File

@@ -5,6 +5,7 @@ import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import work.slhaf.partner.core.cognition.impression.ActiveEntity
import work.slhaf.partner.core.cognition.impression.Entity
class SimpleTextSearchTest {
@@ -116,6 +117,21 @@ class SimpleTextSearchTest {
assertEquals("report", hits.first().document.target.id)
}
@Test
fun `search recalls known entity by alias documents`() {
val search = SimpleTextSearch(TestTokenizer())
val entity = Entity("entity-1", "Partner")
entity.addAlias("智能体项目")
search.rebuild(ImpressionSearchDocuments.fromEntity(entity))
val hits = search.search("智能体项目", limit = 10)
assertFalse(hits.isEmpty())
assertEquals(ImpressionSearchTarget.Type.ENTITY, hits.first().document.target.type)
assertEquals("entity-1", hits.first().document.target.id)
}
@Test
fun `upsert replaces previous index terms for the same document id`() {
val search = SimpleTextSearch(TestTokenizer())
@@ -207,7 +223,8 @@ class SimpleTextSearchTest {
private val dictionary = listOf(
"城南", "旧书店", "老板", "推荐", "工程", "教材", "水利", "熟悉", "旧书",
"java", "kotlin", "jieba", "分词", "simpletextsearch", "倒排", "索引", "检索", "测试", "召回",
"vivado", "实验报告", "实验", "报告", "模板", "docx", "室友", "整理", "文件"
"vivado", "实验报告", "实验", "报告", "模板", "docx", "室友", "整理", "文件",
"智能体", "项目", "智能体项目"
)
private val alphaNumericRegex = Regex("[a-z0-9]+(?:[-_./][a-z0-9]+)*")

View File

@@ -145,5 +145,58 @@ class CommunicationProducerTest {
public Lock getMessageLock() {
return lock;
}
@Override
public java.util.Set<work.slhaf.partner.core.cognition.impression.ActiveEntity> projectEntity(String input) {
return java.util.Set.of();
}
@Override
public java.util.Map<
work.slhaf.partner.core.cognition.impression.ActiveEntity,
work.slhaf.partner.core.cognition.impression.Entity
> showEntities() {
return java.util.Map.of();
}
@Override
public work.slhaf.partner.core.cognition.impression.Entity createEntity(String subject) {
return null;
}
@Override
public work.slhaf.partner.core.cognition.impression.Entity getEntity(String uuid) {
return null;
}
@Override
public boolean bindActiveEntity(String runtimeId, String entityUuid) {
return false;
}
@Override
public boolean renameEntitySubject(String entityUuid, String newSubject) {
return false;
}
@Override
public boolean addEntityAlias(String entityUuid, String alias, boolean deprecated) {
return false;
}
@Override
public boolean updateEntityImpression(String entityUuid, String impression, String newImpression, double confidence) {
return false;
}
@Override
public boolean updateEntityFeature(String entityUuid, String feature, String newFeature, double confidence) {
return false;
}
@Override
public boolean updateEntityRelation(String entityUuid, String target, String relation, double strength) {
return false;
}
}
}

View File

@@ -98,6 +98,59 @@ class MemoryRuntimeTest {
public Lock getMessageLock() {
return lock;
}
@Override
public java.util.Set<work.slhaf.partner.core.cognition.impression.ActiveEntity> projectEntity(String input) {
return java.util.Set.of();
}
@Override
public java.util.Map<
work.slhaf.partner.core.cognition.impression.ActiveEntity,
work.slhaf.partner.core.cognition.impression.Entity
> showEntities() {
return java.util.Map.of();
}
@Override
public work.slhaf.partner.core.cognition.impression.Entity createEntity(String subject) {
return null;
}
@Override
public work.slhaf.partner.core.cognition.impression.Entity getEntity(String uuid) {
return null;
}
@Override
public boolean bindActiveEntity(String runtimeId, String entityUuid) {
return false;
}
@Override
public boolean renameEntitySubject(String entityUuid, String newSubject) {
return false;
}
@Override
public boolean addEntityAlias(String entityUuid, String alias, boolean deprecated) {
return false;
}
@Override
public boolean updateEntityImpression(String entityUuid, String impression, String newImpression, double confidence) {
return false;
}
@Override
public boolean updateEntityFeature(String entityUuid, String feature, String newFeature, double confidence) {
return false;
}
@Override
public boolean updateEntityRelation(String entityUuid, String target, String relation, double strength) {
return false;
}
};
}

View File

@@ -203,4 +203,4 @@ Partner/
## License
暂未指定
暂未选择开源许可证。当前仓库主要作为个人项目展示与学习研究记录,未经授权不建议复制、分发或商用