10 Commits

Author SHA1 Message Date
bc2c993473 docs: update README document references 2026-06-27 23:15:30 +08:00
0e693310f8 docs(impression): design observation update pipeline 2026-06-24 18:20:46 +08:00
82a33c3909 feat(impression): add after-rolling updater pipeline 2026-06-19 22:51:33 +08:00
6a64ff29c4 refactor(memory): snapshot rolling results 2026-06-14 18:27:41 +08:00
03f0e1e11f feat(impression): model update plans for impression updater 2026-06-14 16:48:55 +08:00
0211ba9ac8 refactor(impression): expose choice of keepOldSubjectAsAlias in ImpressionCore 2026-06-14 16:42:05 +08:00
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
33 changed files with 1980 additions and 165 deletions

View File

@@ -3,10 +3,12 @@ package work.slhaf.partner.core.cognition;
import org.w3c.dom.Element; import org.w3c.dom.Element;
import work.slhaf.partner.core.cognition.context.ContextWorkspace; import work.slhaf.partner.core.cognition.context.ContextWorkspace;
import work.slhaf.partner.core.cognition.impression.ActiveEntity; 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.factory.capability.annotation.Capability;
import work.slhaf.partner.framework.agent.model.pojo.Message; import work.slhaf.partner.framework.agent.model.pojo.Message;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.Lock;
@@ -29,6 +31,60 @@ public interface CognitionCapability {
Lock getMessageLock(); Lock getMessageLock();
/**
* Project user input onto known or currently active entities and append the input as runtime evidence.
*/
Set<ActiveEntity> projectEntity(String input); 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.
*/
String createEntity(String subject);
/**
* Return a known entity by uuid, or null when it does not exist.
*/
Entity getEntity(String uuid);
/**
* Activate a known entity into the runtime context and return a detached active-entity snapshot.
*/
ActiveEntity activateKnownEntity(String entityUuid);
/**
* 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, boolean keepOldSubjectAsAlias);
/**
* 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 } 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) { override fun fillXml(document: Document, root: Element) {
root.setAttribute("runtime_id", runtimeId) root.setAttribute("runtime_id", runtimeId)
boundEntityUuid?.let { root.setAttribute("bound_entity_uuid", it) } 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.StateSerializable
import work.slhaf.partner.framework.agent.state.StateValue import work.slhaf.partner.framework.agent.state.StateValue
import java.nio.file.Path import java.nio.file.Path
import java.time.Instant
import java.util.* import java.util.*
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.locks.ReentrantLock import java.util.concurrent.locks.ReentrantLock
@@ -13,15 +14,67 @@ import kotlin.concurrent.withLock
class Entity @JvmOverloads constructor( class Entity @JvmOverloads constructor(
val uuid: String = UUID.randomUUID().toString(), val uuid: String = UUID.randomUUID().toString(),
val subject: String, subject: String,
private val relations: MutableMap<String, MutableMap<String, Double>> = mutableMapOf(), private val relations: MutableMap<String, MutableMap<String, Double>> = mutableMapOf(),
private val impressions: MutableMap<String, IndexableData> = 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 { ) : StateSerializable {
private var _subject: String = normalizeIdentityText(subject)
private val impressionLock = ReentrantLock() private val impressionLock = ReentrantLock()
private val relationLock = ReentrantLock() private val relationLock = ReentrantLock()
private val featureLock = 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 @JvmOverloads
fun updateRelation( 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 -> state.getJSONObject("features")?.let { loadedFeatures ->
featureLock.withLock { featureLock.withLock {
features.clear() features.clear()
features.putAll(loadIndexableDataMap(loadedFeatures)) 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 { override fun convert(): State {
val state = State() val state = State()
state.append("uuid", StateValue.str(uuid)) 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 { val relationState = relationLock.withLock {
relations.mapValues { (_, relationMap) -> relationMap.toMap() } relations.mapValues { (_, relationMap) -> relationMap.toMap() }
@@ -187,6 +278,22 @@ class Entity @JvmOverloads constructor(
override fun autoLoadOnRegister(): Boolean = false 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> { private fun loadIndexableDataMap(state: JSONObject): Map<String, IndexableData> {
val loaded = mutableMapOf<String, IndexableData>() val loaded = mutableMapOf<String, IndexableData>()
state.forEach { (key, value) -> state.forEach { (key, value) ->
@@ -269,4 +376,24 @@ class Entity @JvmOverloads constructor(
val confidence: Double, val confidence: Double,
val vector: FloatArray? 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,219 @@ public class ImpressionCore implements StateSerializable {
return projected; 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 String 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.getUuid();
}
/**
* 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);
}
/**
* Activate a known entity and return a detached snapshot for external consumers.
*/
@CapabilityMethod
public ActiveEntity activateKnownEntity(String entityUuid) {
return activateKnownEntityLive(entityUuid)
.map(ActiveEntity::snapshot)
.orElse(null);
}
/**
* 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 optionally keep its previous subject as a historical alias.
*/
@CapabilityMethod
public boolean renameEntitySubject(String entityUuid, String newSubject, boolean keepOldSubjectAsAlias) {
Entity entity = knownEntitiesByUuid.get(entityUuid);
if (entity == null || newSubject == null || newSubject.isBlank()) {
return false;
}
boolean renamed = entity.renameSubject(newSubject.trim(), keepOldSubjectAsAlias);
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( private List<EntityAssociationMatch> aggregateMatches(
List<ImpressionSearchHit> hits, List<ImpressionSearchHit> hits,
int limit int limit
@@ -125,7 +338,7 @@ public class ImpressionCore implements StateSerializable {
private Optional<ActiveEntity> resolveActiveEntity(ImpressionSearchTarget target) { private Optional<ActiveEntity> resolveActiveEntity(ImpressionSearchTarget target) {
return switch (target.getType()) { return switch (target.getType()) {
case ACTIVE_ENTITY -> findActiveEntityByRuntimeId(target.getId()); case ACTIVE_ENTITY -> findActiveEntityByRuntimeId(target.getId());
case ENTITY -> activateKnownEntity(target.getId()); case ENTITY -> activateKnownEntityLive(target.getId());
}; };
} }
@@ -145,7 +358,7 @@ public class ImpressionCore implements StateSerializable {
} }
} }
private Optional<ActiveEntity> activateKnownEntity(String uuid) { private Optional<ActiveEntity> activateKnownEntityLive(String uuid) {
Entity knownEntity = knownEntitiesByUuid.get(uuid); Entity knownEntity = knownEntitiesByUuid.get(uuid);
if (knownEntity == null) { if (knownEntity == null) {
return Optional.empty(); return Optional.empty();
@@ -183,6 +396,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() { private void rebuildTextSearch() {
List<ImpressionSearchDocument> documents = new ArrayList<>(); List<ImpressionSearchDocument> documents = new ArrayList<>();
knownEntitiesByUuid.values().forEach(entity -> knownEntitiesByUuid.values().forEach(entity ->
@@ -222,6 +471,7 @@ public class ImpressionCore implements StateSerializable {
} }
Entity entity = new Entity(uuid, subject); Entity entity = new Entity(uuid, subject);
entity.register();
entity.load(); entity.load();
vectorIndex.sync(entity); vectorIndex.sync(entity);
knownEntitiesByUuid.put(uuid, 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 -> entity.snapshotFeatures().keys.forEachIndexed { index, feature ->
add( add(
ImpressionSearchDocument( ImpressionSearchDocument(
@@ -131,6 +143,7 @@ object ImpressionSearchDocuments {
} }
private const val SUBJECT_WEIGHT = 1.0 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 FEATURE_WEIGHT = 0.85
private const val IMPRESSION_WEIGHT = 0.75 private const val IMPRESSION_WEIGHT = 0.75
private const val RELATION_WEIGHT = 0.65 private const val RELATION_WEIGHT = 0.65

View File

@@ -1,7 +1,7 @@
package work.slhaf.partner.core.memory; package work.slhaf.partner.core.memory;
import work.slhaf.partner.core.memory.pojo.MemorySlice; import work.slhaf.partner.core.memory.pojo.MemorySliceSnapshot;
import work.slhaf.partner.core.memory.pojo.MemoryUnit; import work.slhaf.partner.core.memory.pojo.MemoryUnitSnapshot;
import work.slhaf.partner.framework.agent.factory.capability.annotation.Capability; import work.slhaf.partner.framework.agent.factory.capability.annotation.Capability;
import work.slhaf.partner.framework.agent.model.pojo.Message; import work.slhaf.partner.framework.agent.model.pojo.Message;
import work.slhaf.partner.framework.agent.support.Result; import work.slhaf.partner.framework.agent.support.Result;
@@ -12,13 +12,13 @@ import java.util.List;
@Capability(value = "memory") @Capability(value = "memory")
public interface MemoryCapability { public interface MemoryCapability {
MemoryUnit getMemoryUnit(String unitId); MemoryUnitSnapshot getMemoryUnit(String unitId);
Result<MemorySlice> getMemorySlice(String unitId, String sliceId); Result<MemorySliceSnapshot> getMemorySlice(String unitId, String sliceId);
MemoryUnit updateMemoryUnit(List<Message> chatMessages, String summary); MemoryUnitSnapshot updateMemoryUnit(List<Message> chatMessages, String summary);
Collection<MemoryUnit> listMemoryUnits(); Collection<MemoryUnitSnapshot> listMemoryUnits();
void refreshMemorySession(); void refreshMemorySession();

View File

@@ -5,7 +5,9 @@ import com.alibaba.fastjson2.JSONObject;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import work.slhaf.partner.core.memory.pojo.MemorySlice; import work.slhaf.partner.core.memory.pojo.MemorySlice;
import work.slhaf.partner.core.memory.pojo.MemorySliceSnapshot;
import work.slhaf.partner.core.memory.pojo.MemoryUnit; import work.slhaf.partner.core.memory.pojo.MemoryUnit;
import work.slhaf.partner.core.memory.pojo.MemoryUnitSnapshot;
import work.slhaf.partner.framework.agent.factory.capability.annotation.CapabilityCore; import work.slhaf.partner.framework.agent.factory.capability.annotation.CapabilityCore;
import work.slhaf.partner.framework.agent.factory.capability.annotation.CapabilityMethod; import work.slhaf.partner.framework.agent.factory.capability.annotation.CapabilityMethod;
import work.slhaf.partner.framework.agent.model.pojo.Message; import work.slhaf.partner.framework.agent.model.pojo.Message;
@@ -36,10 +38,10 @@ public class MemoryCore implements StateSerializable {
} }
@CapabilityMethod @CapabilityMethod
public MemoryUnit updateMemoryUnit(List<Message> chatMessages, String summary) { public MemoryUnitSnapshot updateMemoryUnit(List<Message> chatMessages, String summary) {
memoryLock.lock(); memoryLock.lock();
try { try {
MemoryUnit unit = getMemoryUnit(memorySessionId); MemoryUnit unit = getOrLoadMemoryUnit(memorySessionId);
unit.updateTimestamp(); unit.updateTimestamp();
List<Message> conversationMessages = unit.getConversationMessages(); List<Message> conversationMessages = unit.getConversationMessages();
@@ -55,14 +57,60 @@ public class MemoryCore implements StateSerializable {
unit.getSlices().add(memorySlice); unit.getSlices().add(memorySlice);
normalizeMemoryUnit(unit); normalizeMemoryUnit(unit);
return unit; return unit.snapshot();
} finally { } finally {
memoryLock.unlock(); memoryLock.unlock();
} }
} }
@CapabilityMethod @CapabilityMethod
public MemoryUnit getMemoryUnit(String unitId) { public MemoryUnitSnapshot getMemoryUnit(String unitId) {
memoryLock.lock();
try {
MemoryUnit unit = getOrLoadMemoryUnit(unitId);
normalizeMemoryUnit(unit);
return unit.snapshot();
} finally {
memoryLock.unlock();
}
}
@CapabilityMethod
public Result<MemorySliceSnapshot> getMemorySlice(String unitId, String sliceId) {
memoryLock.lock();
try {
MemoryUnit memoryUnit = memoryUnits.get(unitId);
if (memoryUnit == null) {
return memorySliceNotFound(unitId, sliceId);
}
memoryUnit.load();
normalizeMemoryUnit(memoryUnit);
for (MemorySlice slice : memoryUnit.getSlices()) {
if (sliceId.equals(slice.getId())) {
return Result.success(slice.snapshot());
}
}
return memorySliceNotFound(unitId, sliceId);
} finally {
memoryLock.unlock();
}
}
@CapabilityMethod
public Collection<MemoryUnitSnapshot> listMemoryUnits() {
memoryLock.lock();
try {
return memoryUnits.values().stream()
.peek(MemoryUnit::load)
.peek(this::normalizeMemoryUnit)
.map(MemoryUnit::snapshot)
.toList();
} finally {
memoryLock.unlock();
}
}
private MemoryUnit getOrLoadMemoryUnit(String unitId) {
MemoryUnit unit = memoryUnits.computeIfAbsent(unitId, id -> { MemoryUnit unit = memoryUnits.computeIfAbsent(unitId, id -> {
MemoryUnit newUnit = new MemoryUnit(id); MemoryUnit newUnit = new MemoryUnit(id);
newUnit.register(); newUnit.register();
@@ -72,21 +120,7 @@ public class MemoryCore implements StateSerializable {
return unit; return unit;
} }
@CapabilityMethod private Result<MemorySliceSnapshot> memorySliceNotFound(String unitId, String sliceId) {
public Result<MemorySlice> getMemorySlice(String unitId, String sliceId) {
MemoryUnit memoryUnit = memoryUnits.get(unitId);
if (memoryUnit == null) {
return Result.failure(new MemoryLookupException(
"Memory slice not found: " + unitId + ":" + sliceId,
unitId + ":" + sliceId,
"MEMORY_SLICE"
));
}
for (MemorySlice slice : memoryUnit.getSlices()) {
if (sliceId.equals(slice.getId())) {
return Result.success(slice);
}
}
return Result.failure(new MemoryLookupException( return Result.failure(new MemoryLookupException(
"Memory slice not found: " + unitId + ":" + sliceId, "Memory slice not found: " + unitId + ":" + sliceId,
unitId + ":" + sliceId, unitId + ":" + sliceId,
@@ -94,11 +128,6 @@ public class MemoryCore implements StateSerializable {
)); ));
} }
@CapabilityMethod
public Collection<MemoryUnit> listMemoryUnits() {
return new ArrayList<>(memoryUnits.values());
}
@CapabilityMethod @CapabilityMethod
public void refreshMemorySession() { public void refreshMemorySession() {
memorySessionId = UUID.randomUUID().toString(); memorySessionId = UUID.randomUUID().toString();

View File

@@ -33,6 +33,16 @@ public class MemorySlice implements Comparable<MemorySlice> {
return new MemorySlice(id, startIndex, endIndex, summary, timestamp); return new MemorySlice(id, startIndex, endIndex, summary, timestamp);
} }
public MemorySliceSnapshot snapshot() {
return new MemorySliceSnapshot(
id,
startIndex == null ? 0 : startIndex,
endIndex == null ? 0 : endIndex,
summary,
timestamp == null ? 0L : timestamp
);
}
@Override @Override
public int compareTo(MemorySlice memorySlice) { public int compareTo(MemorySlice memorySlice) {
if (memorySlice.getTimestamp() > this.getTimestamp()) { if (memorySlice.getTimestamp() > this.getTimestamp()) {

View File

@@ -0,0 +1,9 @@
package work.slhaf.partner.core.memory.pojo
data class MemorySliceSnapshot(
val id: String,
val startIndex: Int,
val endIndex: Int,
val summary: String?,
val timestamp: Long,
)

View File

@@ -31,6 +31,15 @@ public class MemoryUnit implements StateSerializable {
timestamp = System.currentTimeMillis(); timestamp = System.currentTimeMillis();
} }
public MemoryUnitSnapshot snapshot() {
return new MemoryUnitSnapshot(
id,
List.copyOf(conversationMessages),
timestamp == null ? 0L : timestamp,
slices.stream().map(MemorySlice::snapshot).toList()
);
}
@Override @Override
public @NotNull Path statePath() { public @NotNull Path statePath() {
return Path.of("core", "memory", "memory-unit" + id + ".json"); return Path.of("core", "memory", "memory-unit" + id + ".json");

View File

@@ -0,0 +1,23 @@
package work.slhaf.partner.core.memory.pojo
import work.slhaf.partner.framework.agent.model.pojo.Message
data class MemoryUnitSnapshot(
val id: String,
val conversationMessages: List<Message>,
val timestamp: Long,
val slices: List<MemorySliceSnapshot>,
) {
fun messagesOf(slice: MemorySliceSnapshot): List<Message> {
if (conversationMessages.isEmpty()) {
return emptyList()
}
val start = slice.startIndex.coerceIn(0, conversationMessages.size)
val end = slice.endIndex.coerceIn(start, conversationMessages.size)
if (start >= end) {
return emptyList()
}
return conversationMessages.subList(start, end).toList()
}
}

View File

@@ -10,8 +10,8 @@ import work.slhaf.partner.core.cognition.CognitionCapability;
import work.slhaf.partner.core.cognition.context.BlockContent; import work.slhaf.partner.core.cognition.context.BlockContent;
import work.slhaf.partner.core.cognition.context.ContextBlock; import work.slhaf.partner.core.cognition.context.ContextBlock;
import work.slhaf.partner.core.memory.MemoryCapability; import work.slhaf.partner.core.memory.MemoryCapability;
import work.slhaf.partner.core.memory.pojo.MemorySlice; import work.slhaf.partner.core.memory.pojo.MemorySliceSnapshot;
import work.slhaf.partner.core.memory.pojo.MemoryUnit; import work.slhaf.partner.core.memory.pojo.MemoryUnitSnapshot;
import work.slhaf.partner.framework.agent.factory.capability.annotation.InjectCapability; import work.slhaf.partner.framework.agent.factory.capability.annotation.InjectCapability;
import work.slhaf.partner.framework.agent.factory.component.annotation.AgentComponent; import work.slhaf.partner.framework.agent.factory.component.annotation.AgentComponent;
import work.slhaf.partner.framework.agent.factory.component.annotation.Init; import work.slhaf.partner.framework.agent.factory.component.annotation.Init;
@@ -75,16 +75,16 @@ class BuiltinCapabilityActionProvider implements BuiltinActionProvider {
Function<Map<String, Object>, String> invoker = params -> { Function<Map<String, Object>, String> invoker = params -> {
String unitId = BuiltinActionRegistry.BuiltinActionDefinition.requireString(params, "unit_id"); String unitId = BuiltinActionRegistry.BuiltinActionDefinition.requireString(params, "unit_id");
String sliceId = BuiltinActionRegistry.BuiltinActionDefinition.requireString(params, "slice_id"); String sliceId = BuiltinActionRegistry.BuiltinActionDefinition.requireString(params, "slice_id");
Result<MemorySlice> sliceResult = memoryCapability.getMemorySlice(unitId, sliceId); Result<MemorySliceSnapshot> sliceResult = memoryCapability.getMemorySlice(unitId, sliceId);
if (sliceResult.exceptionOrNull() != null) { if (sliceResult.exceptionOrNull() != null) {
return JSONObject.of( return JSONObject.of(
"ok", false, "ok", false,
"message", sliceResult.exceptionOrNull().getLocalizedMessage() "message", sliceResult.exceptionOrNull().getLocalizedMessage()
).toJSONString(); ).toJSONString();
} }
MemorySlice slice = sliceResult.getOrThrow(); MemorySliceSnapshot slice = sliceResult.getOrThrow();
MemoryUnit unit = memoryCapability.getMemoryUnit(unitId); MemoryUnitSnapshot unit = memoryCapability.getMemoryUnit(unitId);
cognitionCapability.contextWorkspace().register(new ContextBlock( cognitionCapability.contextWorkspace().register(new ContextBlock(
buildMemoryRecallFullBlock(unit, slice), buildMemoryRecallFullBlock(unit, slice),
Set.of(ContextBlock.FocusedDomain.MEMORY), Set.of(ContextBlock.FocusedDomain.MEMORY),
@@ -105,13 +105,13 @@ class BuiltinCapabilityActionProvider implements BuiltinActionProvider {
); );
} }
private @NotNull BlockContent buildMemoryRecallFullBlock(MemoryUnit unit, MemorySlice slice) { private @NotNull BlockContent buildMemoryRecallFullBlock(MemoryUnitSnapshot unit, MemorySliceSnapshot slice) {
return new BlockContent("memory_recall", "memory_capability") { return new BlockContent("memory_recall", "memory_capability") {
@Override @Override
protected void fillXml(@NotNull Document document, @NotNull Element root) { protected void fillXml(@NotNull Document document, @NotNull Element root) {
root.setAttribute("unit_id", unit.getId()); root.setAttribute("unit_id", unit.getId());
root.setAttribute("slice_id", slice.getId()); root.setAttribute("slice_id", slice.getId());
appendRepeatedElements(document, root, "message", unit.getConversationMessages().subList(slice.getStartIndex(), slice.getEndIndex()), (messageElement, message) -> { appendRepeatedElements(document, root, "message", unit.messagesOf(slice), (messageElement, message) -> {
messageElement.setAttribute("role", message.getRole().name().toLowerCase(Locale.ROOT)); messageElement.setAttribute("role", message.getRole().name().toLowerCase(Locale.ROOT));
messageElement.setTextContent(message.getContent()); messageElement.setTextContent(message.getContent());
return Unit.INSTANCE; return Unit.INSTANCE;

View File

@@ -13,8 +13,8 @@ import work.slhaf.partner.core.cognition.CognitionCapability;
import work.slhaf.partner.core.cognition.context.BlockContent; import work.slhaf.partner.core.cognition.context.BlockContent;
import work.slhaf.partner.core.cognition.context.ContextBlock; import work.slhaf.partner.core.cognition.context.ContextBlock;
import work.slhaf.partner.core.memory.MemoryCapability; import work.slhaf.partner.core.memory.MemoryCapability;
import work.slhaf.partner.core.memory.pojo.MemorySlice; import work.slhaf.partner.core.memory.pojo.MemorySliceSnapshot;
import work.slhaf.partner.core.memory.pojo.MemoryUnit; import work.slhaf.partner.core.memory.pojo.MemoryUnitSnapshot;
import work.slhaf.partner.core.perceive.PerceiveCapability; import work.slhaf.partner.core.perceive.PerceiveCapability;
import work.slhaf.partner.framework.agent.exception.AgentRuntimeException; import work.slhaf.partner.framework.agent.exception.AgentRuntimeException;
import work.slhaf.partner.framework.agent.exception.ExceptionReporterHandler; import work.slhaf.partner.framework.agent.exception.ExceptionReporterHandler;
@@ -31,6 +31,7 @@ import work.slhaf.partner.runtime.PartnerRunningFlowContext;
import java.time.ZonedDateTime; import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
@@ -140,7 +141,7 @@ public class DialogRolling extends AbstractAgentModule.Running<PartnerRunningFlo
if (memoryId.isBlank()) { if (memoryId.isBlank()) {
return fullChatSnapshot; return fullChatSnapshot;
} }
MemoryUnit existingUnit = memoryCapability.getMemoryUnit(memoryId); MemoryUnitSnapshot existingUnit = memoryCapability.getMemoryUnit(memoryId);
if (existingUnit.getConversationMessages().isEmpty()) { if (existingUnit.getConversationMessages().isEmpty()) {
return fullChatSnapshot; return fullChatSnapshot;
} }
@@ -158,8 +159,9 @@ public class DialogRolling extends AbstractAgentModule.Running<PartnerRunningFlo
@NotNull @NotNull
RollingResult buildRollingResult(List<Message> chatSnapshot, int rollingSize, int retainDivisor) { RollingResult buildRollingResult(List<Message> chatSnapshot, int rollingSize, int retainDivisor) {
messageCompressor.execute(chatSnapshot); List<Message> rollingMessages = new ArrayList<>(chatSnapshot);
Result<String> summaryResult = messageSummarizer.execute(chatSnapshot); messageCompressor.execute(rollingMessages);
Result<String> summaryResult = messageSummarizer.execute(rollingMessages);
String summary = summaryResult.fold( String summary = summaryResult.fold(
value -> value, value -> value,
exp -> "no summary, due to exception" exp -> "no summary, due to exception"
@@ -167,20 +169,20 @@ public class DialogRolling extends AbstractAgentModule.Running<PartnerRunningFlo
if (summary.isBlank()) { if (summary.isBlank()) {
summary = "no summary, due to empty summarize result"; summary = "no summary, due to empty summarize result";
} }
MemoryUnit memoryUnit = memoryCapability.updateMemoryUnit(chatSnapshot, summary); MemoryUnitSnapshot memoryUnit = memoryCapability.updateMemoryUnit(rollingMessages, summary);
MemorySlice newSlice = memoryUnit.getSlices().getLast(); MemorySliceSnapshot newSlice = memoryUnit.getSlices().getLast();
return new RollingResult(memoryUnit, newSlice, List.copyOf(chatSnapshot), newSlice.getSummary(), rollingSize, retainDivisor); return new RollingResult(memoryUnit, newSlice, rollingSize, retainDivisor);
} }
private void applyRolling(RollingResult result) { private void applyRolling(RollingResult result) {
cognitionCapability.contextWorkspace().register(new ContextBlock( cognitionCapability.contextWorkspace().register(new ContextBlock(
buildDialogAbstractBlock(result.summary(), result.memoryUnit().getId(), result.memorySlice().getId()), buildDialogAbstractBlock(result.getSummary(), result.getMemoryUnit().getId(), result.getMemorySlice().getId()),
Set.of(ContextBlock.FocusedDomain.MEMORY, ContextBlock.FocusedDomain.COMMUNICATION), Set.of(ContextBlock.FocusedDomain.MEMORY, ContextBlock.FocusedDomain.COMMUNICATION),
20, 20,
5, 5,
10 10
)); ));
cognitionCapability.rollChatMessagesWithSnapshot(result.rollingSize(), result.retainDivisor()); cognitionCapability.rollChatMessagesWithSnapshot(result.getRollingSize(), result.getRetainDivisor());
} }
private @NotNull BlockContent buildDialogAbstractBlock(String summary, String unitId, String sliceId) { private @NotNull BlockContent buildDialogAbstractBlock(String summary, String unitId, String sliceId) {

View File

@@ -1,17 +0,0 @@
package work.slhaf.partner.module.communication;
import work.slhaf.partner.core.memory.pojo.MemorySlice;
import work.slhaf.partner.core.memory.pojo.MemoryUnit;
import work.slhaf.partner.framework.agent.model.pojo.Message;
import java.util.List;
public record RollingResult(
MemoryUnit memoryUnit,
MemorySlice memorySlice,
List<Message> incrementMessages,
String summary,
int rollingSize,
int retainDivisor
) {
}

View File

@@ -0,0 +1,17 @@
package work.slhaf.partner.module.communication
import work.slhaf.partner.core.memory.pojo.MemorySliceSnapshot
import work.slhaf.partner.core.memory.pojo.MemoryUnitSnapshot
import work.slhaf.partner.framework.agent.model.pojo.Message
data class RollingResult(
val memoryUnit: MemoryUnitSnapshot,
val memorySlice: MemorySliceSnapshot,
val rollingSize: Int,
val retainDivisor: Int,
) {
val summary: String
get() = memorySlice.summary ?: ""
fun incrementMessages(): List<Message> = memoryUnit.messagesOf(memorySlice)
}

View File

@@ -0,0 +1,16 @@
package work.slhaf.partner.module.impression;
import java.util.List;
public record ImpressionUpdateApplyResult(
List<String> createdEntityUuids
) {
public ImpressionUpdateApplyResult {
createdEntityUuids = createdEntityUuids == null ? List.of() : List.copyOf(createdEntityUuids);
}
public static ImpressionUpdateApplyResult empty() {
return new ImpressionUpdateApplyResult(List.of());
}
}

View File

@@ -0,0 +1,19 @@
package work.slhaf.partner.module.impression;
import work.slhaf.partner.framework.agent.model.pojo.Message;
import java.util.List;
public record ImpressionUpdateContext(
String memoryUnitId,
String memorySliceId,
String summary,
int rollingSize,
int retainDivisor,
int sliceStartIndex,
int sliceEndIndex,
long sliceTimestamp,
long unitTimestamp,
List<Message> incrementMessages
) {
}

View File

@@ -0,0 +1,66 @@
package work.slhaf.partner.module.impression
/**
* A conservative, auditable plan produced after message rolling.
*
* The updater should treat this model as intent only: validation decides whether
* a step is safe to execute, and the applier performs mutations through
* CognitionCapability / ImpressionCore so indexes stay consistent.
*/
data class ImpressionUpdatePlan @JvmOverloads constructor(
val steps: List<ImpressionUpdateStep>,
val status: PlanStatus = PlanStatus.PREPARED,
val reason: String? = null,
)
enum class PlanStatus {
PREPARED,
CONFIRMED,
REJECTED,
}
sealed class ImpressionUpdateStep
data class UpdateExistingStep(
val entityUuid: String,
val updatePatch: UpdatePatch,
) : ImpressionUpdateStep()
data class CreateEntityStep(
val subject: String,
val impressions: List<ImpressionPatch> = emptyList(),
val features: List<FeaturePatch> = emptyList(),
val aliases: List<AliasPatch> = emptyList(),
val relations: List<RelationPatch> = emptyList(),
) : ImpressionUpdateStep()
sealed class UpdatePatch
data class ImpressionPatch @JvmOverloads constructor(
val impression: String,
val newImpression: String? = null,
val confidence: Double = 1.0,
) : UpdatePatch()
data class FeaturePatch @JvmOverloads constructor(
val feature: String,
val newFeature: String? = null,
val confidence: Double = 1.0,
) : UpdatePatch()
data class AliasPatch @JvmOverloads constructor(
val alias: String,
val deprecated: Boolean = false,
) : UpdatePatch()
data class SubjectPatch @JvmOverloads constructor(
val subject: String,
val keepOldSubjectAsAlias: Boolean = true,
) : UpdatePatch()
data class RelationPatch @JvmOverloads constructor(
val target: String,
val relation: String,
val strength: Double = 1.0,
) : UpdatePatch()

View File

@@ -0,0 +1,96 @@
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.framework.agent.support.Result;
import java.util.ArrayList;
import java.util.List;
public class ImpressionUpdatePlanApplier extends AbstractAgentModule.Sub<ImpressionUpdatePlan, Result<ImpressionUpdateApplyResult>> {
@InjectCapability
private CognitionCapability cognitionCapability;
@Override
protected Result<ImpressionUpdateApplyResult> doExecute(ImpressionUpdatePlan plan) {
return apply(plan);
}
public Result<ImpressionUpdateApplyResult> apply(ImpressionUpdatePlan plan) {
return Result.runCatching(() -> {
if (plan == null || plan.getStatus() != PlanStatus.CONFIRMED) {
throw new IllegalArgumentException("only confirmed impression update plans can be applied");
}
List<String> createdEntityUuids = new ArrayList<>();
for (ImpressionUpdateStep step : plan.getSteps()) {
String createdEntityUuid = applyStep(step);
if (createdEntityUuid != null && !createdEntityUuid.isBlank()) {
createdEntityUuids.add(createdEntityUuid);
}
}
return new ImpressionUpdateApplyResult(createdEntityUuids);
});
}
private String applyStep(ImpressionUpdateStep step) {
if (step instanceof UpdateExistingStep updateStep) {
applyPatch(updateStep.getEntityUuid(), updateStep.getUpdatePatch());
return null;
}
if (step instanceof CreateEntityStep createStep) {
String entityUuid = cognitionCapability.createEntity(createStep.getSubject());
if (entityUuid == null || entityUuid.isBlank()) {
throw new IllegalStateException("created entity uuid is blank");
}
applyPatches(entityUuid, createStep.getImpressions());
applyPatches(entityUuid, createStep.getFeatures());
applyPatches(entityUuid, createStep.getAliases());
applyPatches(entityUuid, createStep.getRelations());
return entityUuid;
}
throw new IllegalArgumentException("unsupported impression update step: " + step);
}
private void applyPatches(String entityUuid, List<? extends UpdatePatch> patches) {
for (UpdatePatch patch : patches) {
applyPatch(entityUuid, patch);
}
}
private void applyPatch(String entityUuid, UpdatePatch patch) {
boolean applied = switch (patch) {
case SubjectPatch subjectPatch -> cognitionCapability.renameEntitySubject(
entityUuid,
subjectPatch.getSubject(),
subjectPatch.getKeepOldSubjectAsAlias()
);
case AliasPatch aliasPatch -> cognitionCapability.addEntityAlias(entityUuid, aliasPatch.getAlias(), aliasPatch.getDeprecated());
case ImpressionPatch impressionPatch -> cognitionCapability.updateEntityImpression(
entityUuid,
impressionPatch.getImpression(),
impressionPatch.getNewImpression(),
impressionPatch.getConfidence()
);
case FeaturePatch featurePatch -> cognitionCapability.updateEntityFeature(
entityUuid,
featurePatch.getFeature(),
featurePatch.getNewFeature(),
featurePatch.getConfidence()
);
case RelationPatch relationPatch -> cognitionCapability.updateEntityRelation(
entityUuid,
relationPatch.getTarget(),
relationPatch.getRelation(),
relationPatch.getStrength()
);
case null, default -> throw new IllegalArgumentException("unsupported impression update patch: " + patch);
};
if (!applied) {
throw new IllegalStateException("failed to apply impression update patch: " + patch);
}
}
}

View File

@@ -0,0 +1,71 @@
package work.slhaf.partner.module.impression;
import org.jetbrains.annotations.NotNull;
import work.slhaf.partner.framework.agent.factory.component.abstracts.AbstractAgentModule;
import java.util.List;
public class ImpressionUpdatePlanValidator extends AbstractAgentModule.Sub<ImpressionUpdatePlan, Boolean> {
@Override
protected @NotNull Boolean doExecute(ImpressionUpdatePlan plan) {
return isExecutable(plan);
}
public boolean isExecutable(ImpressionUpdatePlan plan) {
if (plan == null || plan.getStatus() != PlanStatus.PREPARED) {
return false;
}
List<ImpressionUpdateStep> steps = plan.getSteps();
if (steps.isEmpty()) {
return false;
}
for (ImpressionUpdateStep step : steps) {
if (!isValidStep(step)) {
return false;
}
}
return true;
}
private boolean isValidStep(ImpressionUpdateStep step) {
if (step instanceof UpdateExistingStep updateStep) {
return hasText(updateStep.getEntityUuid()) && isValidPatch(updateStep.getUpdatePatch());
}
if (step instanceof CreateEntityStep createStep) {
return hasText(createStep.getSubject())
&& (!createStep.getImpressions().isEmpty()
|| !createStep.getFeatures().isEmpty()
|| !createStep.getAliases().isEmpty()
|| !createStep.getRelations().isEmpty())
&& createStep.getImpressions().stream().allMatch(this::isValidPatch)
&& createStep.getFeatures().stream().allMatch(this::isValidPatch)
&& createStep.getAliases().stream().allMatch(this::isValidPatch)
&& createStep.getRelations().stream().allMatch(this::isValidPatch);
}
return false;
}
private boolean isValidPatch(UpdatePatch patch) {
if (patch instanceof ImpressionPatch impressionPatch) {
return hasText(impressionPatch.getImpression());
}
if (patch instanceof FeaturePatch featurePatch) {
return hasText(featurePatch.getFeature());
}
if (patch instanceof AliasPatch aliasPatch) {
return hasText(aliasPatch.getAlias());
}
if (patch instanceof SubjectPatch subjectPatch) {
return hasText(subjectPatch.getSubject());
}
if (patch instanceof RelationPatch relationPatch) {
return hasText(relationPatch.getTarget()) && hasText(relationPatch.getRelation());
}
return false;
}
private boolean hasText(String value) {
return value != null && !value.isBlank();
}
}

View File

@@ -0,0 +1,78 @@
package work.slhaf.partner.module.impression;
import kotlin.Unit;
import org.jetbrains.annotations.NotNull;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import work.slhaf.partner.framework.agent.exception.AgentRuntimeException;
import work.slhaf.partner.framework.agent.exception.ModuleExecutionException;
import work.slhaf.partner.framework.agent.factory.component.abstracts.AbstractAgentModule;
import work.slhaf.partner.framework.agent.model.ActivateModel;
import work.slhaf.partner.framework.agent.model.pojo.Message;
import work.slhaf.partner.framework.agent.support.Result;
import work.slhaf.partner.module.TaskBlock;
import java.util.List;
public class ImpressionUpdatePlanner extends AbstractAgentModule.Sub<ImpressionUpdateContext, Result<ImpressionUpdatePlan>> implements ActivateModel {
private static final String MODULE_PROMPT = """
你负责在对话 rolling 后,根据新的 memory slice 证据生成保守的实体印象更新计划。
你只输出 ImpressionUpdatePlan 对应结构:
- 如果没有稳定、可复用的实体信息变化,返回 REJECTED 并说明原因。
- 只有当证据明确支持时,才返回 PREPARED 计划来创建实体或更新已有实体。
- 不要做复杂实体合并,不要发明不在证据中的事实。
- patch 字段必须使用简洁、稳定、可索引的表达。
- 不要输出 CONFIRMEDCONFIRMED 只能由代码 Validator 通过后设置。
""";
@Override
protected Result<ImpressionUpdatePlan> doExecute(ImpressionUpdateContext context) {
return plan(context);
}
public Result<ImpressionUpdatePlan> plan(ImpressionUpdateContext context) {
try {
return Result.success(formattedChat(List.of(buildTaskMessage(context)), ImpressionUpdatePlan.class).getOrThrow());
} catch (AgentRuntimeException e) {
return Result.failure(new ModuleExecutionException(
"planning impression update failed",
this.getClass(),
getModuleName()
));
}
}
private Message buildTaskMessage(ImpressionUpdateContext context) {
return new TaskBlock("impression_update_task") {
@Override
protected void fillXml(@NotNull Document document, @NotNull Element root) {
appendTextElement(document, root, "memory_unit_id", context.memoryUnitId());
appendTextElement(document, root, "memory_slice_id", context.memorySliceId());
appendTextElement(document, root, "summary", context.summary());
appendTextElement(document, root, "rolling_size", Integer.toString(context.rollingSize()));
appendTextElement(document, root, "retain_divisor", Integer.toString(context.retainDivisor()));
appendTextElement(document, root, "slice_start_index", Integer.toString(context.sliceStartIndex()));
appendTextElement(document, root, "slice_end_index", Integer.toString(context.sliceEndIndex()));
appendTextElement(document, root, "slice_timestamp", Long.toString(context.sliceTimestamp()));
appendTextElement(document, root, "unit_timestamp", Long.toString(context.unitTimestamp()));
appendListElement(document, root, "increment_messages", "message", context.incrementMessages(), (element, message) -> {
element.setAttribute("role", message.roleValue());
element.setTextContent(message.getContent());
return Unit.INSTANCE;
});
}
}.encodeToMessage();
}
@Override
public @NotNull String modelKey() {
return "impression_update_planner";
}
@Override
public @NotNull List<Message> modulePrompt() {
return List.of(new Message(Message.Character.SYSTEM, MODULE_PROMPT));
}
}

View File

@@ -0,0 +1,111 @@
package work.slhaf.partner.module.impression;
import org.jetbrains.annotations.NotNull;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import work.slhaf.partner.core.cognition.CognitionCapability;
import work.slhaf.partner.core.cognition.context.BlockContent;
import work.slhaf.partner.core.cognition.context.ContextBlock;
import work.slhaf.partner.core.cognition.impression.ActiveEntity;
import work.slhaf.partner.core.memory.pojo.MemorySliceSnapshot;
import work.slhaf.partner.core.memory.pojo.MemoryUnitSnapshot;
import work.slhaf.partner.framework.agent.factory.capability.annotation.InjectCapability;
import work.slhaf.partner.framework.agent.factory.component.abstracts.AbstractAgentModule;
import work.slhaf.partner.framework.agent.factory.component.annotation.Init;
import work.slhaf.partner.framework.agent.factory.component.annotation.InjectModule;
import work.slhaf.partner.framework.agent.support.Result;
import work.slhaf.partner.module.communication.AfterRolling;
import work.slhaf.partner.module.communication.AfterRollingRegistry;
import work.slhaf.partner.module.communication.RollingResult;
import java.util.List;
import java.util.Set;
public class ImpressionUpdater extends AbstractAgentModule.Standalone implements AfterRolling {
@InjectCapability
private CognitionCapability cognitionCapability;
@InjectModule
private AfterRollingRegistry afterRollingRegistry;
@InjectModule
private ImpressionUpdatePlanner planner;
@InjectModule
private ImpressionUpdatePlanValidator validator;
@InjectModule
private ImpressionUpdatePlanApplier applier;
@Init
public void init() {
afterRollingRegistry.register(this);
}
@Override
public void consume(RollingResult result) {
ImpressionUpdateContext context = buildContext(result);
Result<ImpressionUpdatePlan> planResult = planner.execute(context);
ImpressionUpdatePlan plan = planResult.getOrDefault(null);
if (!validator.execute(plan)) {
return;
}
ImpressionUpdatePlan confirmedPlan = new ImpressionUpdatePlan(
plan.getSteps(),
PlanStatus.CONFIRMED,
plan.getReason()
);
Result<ImpressionUpdateApplyResult> applyResult = applier.execute(confirmedPlan);
applyResult.onFailure(exp -> applierFailure(context, exp.getMessage()))
.onSuccess(applySummary -> applySummary.createdEntityUuids().forEach(entityUuid -> {
ActiveEntity activeEntity = cognitionCapability.activateKnownEntity(entityUuid);
if (activeEntity != null) {
registerActiveEntity(activeEntity);
}
}));
}
private ImpressionUpdateContext buildContext(RollingResult result) {
MemoryUnitSnapshot unit = result.getMemoryUnit();
MemorySliceSnapshot slice = result.getMemorySlice();
return new ImpressionUpdateContext(
unit.getId(),
slice.getId(),
result.getSummary(),
result.getRollingSize(),
result.getRetainDivisor(),
slice.getStartIndex(),
slice.getEndIndex(),
slice.getTimestamp(),
unit.getTimestamp(),
List.copyOf(result.incrementMessages())
);
}
private void applierFailure(ImpressionUpdateContext context, String message) {
cognitionCapability.contextWorkspace().register(new ContextBlock(
new BlockContent("impression_update_apply_failure", "impression_updater", BlockContent.Urgency.LOW) {
@Override
protected void fillXml(@NotNull Document document, @NotNull Element root) {
appendTextElement(document, root, "memory_unit_id", context.memoryUnitId());
appendTextElement(document, root, "memory_slice_id", context.memorySliceId());
appendTextElement(document, root, "message", message == null ? "" : message);
}
},
Set.of(ContextBlock.FocusedDomain.COGNITION),
20,
20,
0
));
}
private void registerActiveEntity(ActiveEntity activeEntity) {
cognitionCapability.contextWorkspace().register(new ContextBlock(
activeEntity,
activeEntity,
activeEntity,
Set.of(ContextBlock.FocusedDomain.COGNITION, ContextBlock.FocusedDomain.MEMORY),
100,
0.5,
20
));
}
}

View File

@@ -4,8 +4,8 @@ import com.alibaba.fastjson2.JSONObject;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import work.slhaf.partner.core.cognition.CognitionCapability; import work.slhaf.partner.core.cognition.CognitionCapability;
import work.slhaf.partner.core.memory.MemoryCapability; import work.slhaf.partner.core.memory.MemoryCapability;
import work.slhaf.partner.core.memory.pojo.MemorySlice; import work.slhaf.partner.core.memory.pojo.MemorySliceSnapshot;
import work.slhaf.partner.core.memory.pojo.MemoryUnit; import work.slhaf.partner.core.memory.pojo.MemoryUnitSnapshot;
import work.slhaf.partner.core.memory.pojo.SliceRef; import work.slhaf.partner.core.memory.pojo.SliceRef;
import work.slhaf.partner.framework.agent.exception.ExceptionReporterHandler; import work.slhaf.partner.framework.agent.exception.ExceptionReporterHandler;
import work.slhaf.partner.framework.agent.factory.capability.annotation.InjectCapability; import work.slhaf.partner.framework.agent.factory.capability.annotation.InjectCapability;
@@ -52,11 +52,11 @@ public class MemoryRuntime extends AbstractAgentModule.Standalone implements Sta
} }
} }
public void recordMemory(MemoryUnit memoryUnit, public void recordMemory(MemoryUnitSnapshot memoryUnit,
String topicPath, String topicPath,
List<String> relatedTopicPaths, List<String> relatedTopicPaths,
ActivationProfile activationProfile) { ActivationProfile activationProfile) {
MemorySlice memorySlice = memoryUnit.getSlices().getLast(); MemorySliceSnapshot memorySlice = memoryUnit.getSlices().getLast();
SliceRef sliceRef = new SliceRef(memoryUnit.getId(), memorySlice.getId()); SliceRef sliceRef = new SliceRef(memoryUnit.getId(), memorySlice.getId());
LocalDate date = toLocalDate(memorySlice.getTimestamp()); LocalDate date = toLocalDate(memorySlice.getTimestamp());
runtimeLock.lock(); runtimeLock.lock();
@@ -159,13 +159,13 @@ public class MemoryRuntime extends AbstractAgentModule.Standalone implements Sta
} }
private ActivatedMemorySlice buildActivatedMemorySlice(SliceRef ref) { private ActivatedMemorySlice buildActivatedMemorySlice(SliceRef ref) {
MemoryUnit memoryUnit = memoryCapability.getMemoryUnit(ref.getUnitId()); MemoryUnitSnapshot memoryUnit = memoryCapability.getMemoryUnit(ref.getUnitId());
Result<MemorySlice> memorySliceResult = memoryCapability.getMemorySlice(ref.getUnitId(), ref.getSliceId()); Result<MemorySliceSnapshot> memorySliceResult = memoryCapability.getMemorySlice(ref.getUnitId(), ref.getSliceId());
if (memoryUnit == null || memorySliceResult.exceptionOrNull() != null) { if (memoryUnit == null || memorySliceResult.exceptionOrNull() != null) {
return null; return null;
} }
MemorySlice memorySlice = memorySliceResult.getOrThrow(); MemorySliceSnapshot memorySlice = memorySliceResult.getOrThrow();
List<Message> messages = sliceMessages(memoryUnit, memorySlice); List<Message> messages = memoryUnit.messagesOf(memorySlice);
LocalDate date = toLocalDate(memorySlice.getTimestamp()); LocalDate date = toLocalDate(memorySlice.getTimestamp());
return ActivatedMemorySlice.builder() return ActivatedMemorySlice.builder()
.unitId(ref.getUnitId()) .unitId(ref.getUnitId())
@@ -177,19 +177,6 @@ public class MemoryRuntime extends AbstractAgentModule.Standalone implements Sta
.build(); .build();
} }
private List<Message> sliceMessages(MemoryUnit memoryUnit, MemorySlice memorySlice) {
List<Message> conversationMessages = memoryUnit.getConversationMessages();
if (conversationMessages.isEmpty()) {
return List.of();
}
int size = conversationMessages.size();
int start = Math.clamp(memorySlice.getStartIndex(), 0, size);
int end = Math.clamp(memorySlice.getEndIndex(), start, size);
if (start >= end) {
return List.of();
}
return new ArrayList<>(conversationMessages.subList(start, end));
}
private LocalDate toLocalDate(Long timestamp) { private LocalDate toLocalDate(Long timestamp) {
return Instant.ofEpochMilli(timestamp) return Instant.ofEpochMilli(timestamp)

View File

@@ -149,7 +149,7 @@ public class MemoryRecallProfileExtractor extends AbstractAgentModule.Standalone
@Override @Override
public void consume(RollingResult result) { public void consume(RollingResult result) {
List<Message> slicedMessages = sliceMessages(result); List<Message> slicedMessages = result.incrementMessages();
if (slicedMessages.isEmpty()) { if (slicedMessages.isEmpty()) {
return; return;
} }
@@ -169,31 +169,21 @@ public class MemoryRecallProfileExtractor extends AbstractAgentModule.Standalone
relatedTopicPaths, relatedTopicPaths,
slicedMessages slicedMessages
); );
memoryRuntime.recordMemory(result.memoryUnit(), topicPath, relatedTopicPaths, activationProfile); memoryRuntime.recordMemory(result.getMemoryUnit(), topicPath, relatedTopicPaths, activationProfile);
}).onFailure(exp -> memoryRuntime.recordMemory( }).onFailure(exp -> memoryRuntime.recordMemory(
result.memoryUnit(), result.getMemoryUnit(),
null, null,
List.of(), List.of(),
defaultActivationProfile() defaultActivationProfile()
)); ));
} }
private List<Message> sliceMessages(RollingResult result) {
int size = result.memoryUnit().getConversationMessages().size();
int start = Math.clamp(result.memorySlice().getStartIndex(), 0, size);
int end = Math.clamp(result.memorySlice().getEndIndex(), start, size);
if (start >= end) {
return List.of();
}
return result.memoryUnit().getConversationMessages().subList(start, end);
}
private Message resolveTopicTaskMessage(RollingResult result, List<Message> slicedMessages) { private Message resolveTopicTaskMessage(RollingResult result, List<Message> slicedMessages) {
return new TaskBlock() { return new TaskBlock() {
@Override @Override
protected void fillXml(@NotNull Document document, @NotNull Element root) { protected void fillXml(@NotNull Document document, @NotNull Element root) {
appendTextElement(document, root, "current_topic_tree", memoryRuntime.getTopicTree()); appendTextElement(document, root, "current_topic_tree", memoryRuntime.getTopicTree());
appendTextElement(document, root, "slice_summary", result.summary()); appendTextElement(document, root, "slice_summary", result.getSummary());
appendRepeatedElements(document, root, "message", slicedMessages, (messageElement, message) -> { appendRepeatedElements(document, root, "message", slicedMessages, (messageElement, message) -> {
messageElement.setAttribute("role", message.roleValue()); messageElement.setAttribute("role", message.roleValue());
messageElement.setTextContent(message.getContent()); messageElement.setTextContent(message.getContent());

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

View File

@@ -4,8 +4,8 @@ import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.api.io.TempDir;
import work.slhaf.partner.core.memory.pojo.MemorySlice; import work.slhaf.partner.core.memory.pojo.MemorySliceSnapshot;
import work.slhaf.partner.core.memory.pojo.MemoryUnit; import work.slhaf.partner.core.memory.pojo.MemoryUnitSnapshot;
import work.slhaf.partner.framework.agent.model.pojo.Message; import work.slhaf.partner.framework.agent.model.pojo.Message;
import java.nio.file.Path; import java.nio.file.Path;
@@ -32,7 +32,7 @@ class MemoryCoreTest {
void shouldCreateFirstSliceFromChatMessages() { void shouldCreateFirstSliceFromChatMessages() {
String sessionId = memoryCore.getMemorySessionId(); String sessionId = memoryCore.getMemorySessionId();
MemoryUnit updatedUnit = memoryCore.updateMemoryUnit(List.of( MemoryUnitSnapshot updatedUnit = memoryCore.updateMemoryUnit(List.of(
new Message(Message.Character.USER, "m0"), new Message(Message.Character.USER, "m0"),
new Message(Message.Character.USER, "m1"), new Message(Message.Character.USER, "m1"),
new Message(Message.Character.USER, "m2") new Message(Message.Character.USER, "m2")
@@ -43,7 +43,7 @@ class MemoryCoreTest {
updatedUnit.getConversationMessages().stream().map(Message::getContent).toList()); updatedUnit.getConversationMessages().stream().map(Message::getContent).toList());
assertEquals(1, updatedUnit.getSlices().size()); assertEquals(1, updatedUnit.getSlices().size());
MemorySlice firstSlice = updatedUnit.getSlices().getFirst(); MemorySliceSnapshot firstSlice = updatedUnit.getSlices().getFirst();
assertNotNull(firstSlice.getId()); assertNotNull(firstSlice.getId());
assertEquals(0, firstSlice.getStartIndex()); assertEquals(0, firstSlice.getStartIndex());
assertEquals(3, firstSlice.getEndIndex()); assertEquals(3, firstSlice.getEndIndex());
@@ -60,7 +60,7 @@ class MemoryCoreTest {
new Message(Message.Character.USER, "m0") new Message(Message.Character.USER, "m0")
), "first-summary"); ), "first-summary");
MemoryUnit updatedUnit = memoryCore.updateMemoryUnit(List.of( MemoryUnitSnapshot updatedUnit = memoryCore.updateMemoryUnit(List.of(
new Message(Message.Character.ASSISTANT, "m1"), new Message(Message.Character.ASSISTANT, "m1"),
new Message(Message.Character.USER, "m2") new Message(Message.Character.USER, "m2")
), "second-summary"); ), "second-summary");
@@ -70,14 +70,14 @@ class MemoryCoreTest {
updatedUnit.getConversationMessages().stream().map(Message::getContent).toList()); updatedUnit.getConversationMessages().stream().map(Message::getContent).toList());
assertEquals(2, updatedUnit.getSlices().size()); assertEquals(2, updatedUnit.getSlices().size());
MemorySlice appendedSlice = updatedUnit.getSlices().getLast(); MemorySliceSnapshot appendedSlice = updatedUnit.getSlices().getLast();
assertNotNull(appendedSlice.getId()); assertNotNull(appendedSlice.getId());
assertEquals(1, appendedSlice.getStartIndex()); assertEquals(1, appendedSlice.getStartIndex());
assertEquals(3, appendedSlice.getEndIndex()); assertEquals(3, appendedSlice.getEndIndex());
assertEquals("second-summary", appendedSlice.getSummary()); assertEquals("second-summary", appendedSlice.getSummary());
assertTrue(appendedSlice.getTimestamp() > 0); assertTrue(appendedSlice.getTimestamp() > 0);
MemorySlice loadedSlice = memoryCore.getMemorySlice(sessionId, appendedSlice.getId()).getOrThrow(); MemorySliceSnapshot loadedSlice = memoryCore.getMemorySlice(sessionId, appendedSlice.getId()).getOrThrow();
assertNotNull(loadedSlice); assertNotNull(loadedSlice);
assertEquals(1, loadedSlice.getStartIndex()); assertEquals(1, loadedSlice.getStartIndex());
assertEquals(3, loadedSlice.getEndIndex()); assertEquals(3, loadedSlice.getEndIndex());

View File

@@ -145,5 +145,63 @@ class CommunicationProducerTest {
public Lock getMessageLock() { public Lock getMessageLock() {
return lock; 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 String createEntity(String subject) {
return null;
}
@Override
public work.slhaf.partner.core.cognition.impression.Entity getEntity(String uuid) {
return null;
}
@Override
public work.slhaf.partner.core.cognition.impression.ActiveEntity activateKnownEntity(String entityUuid) {
return null;
}
@Override
public boolean bindActiveEntity(String runtimeId, String entityUuid) {
return false;
}
@Override
public boolean renameEntitySubject(String entityUuid, String newSubject, boolean keepOldSubjectAsAlias) {
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

@@ -6,7 +6,9 @@ import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mockito; import org.mockito.Mockito;
import work.slhaf.partner.core.memory.MemoryCapability; import work.slhaf.partner.core.memory.MemoryCapability;
import work.slhaf.partner.core.memory.pojo.MemorySlice; import work.slhaf.partner.core.memory.pojo.MemorySlice;
import work.slhaf.partner.core.memory.pojo.MemorySliceSnapshot;
import work.slhaf.partner.core.memory.pojo.MemoryUnit; import work.slhaf.partner.core.memory.pojo.MemoryUnit;
import work.slhaf.partner.core.memory.pojo.MemoryUnitSnapshot;
import work.slhaf.partner.framework.agent.model.pojo.Message; import work.slhaf.partner.framework.agent.model.pojo.Message;
import work.slhaf.partner.framework.agent.support.Result; import work.slhaf.partner.framework.agent.support.Result;
import work.slhaf.partner.module.communication.summarizer.MessageCompressor; import work.slhaf.partner.module.communication.summarizer.MessageCompressor;
@@ -63,19 +65,19 @@ class DialogRollingTest {
message(Message.Character.ASSISTANT, "new-assistant") message(Message.Character.ASSISTANT, "new-assistant")
), 4, 6); ), 4, 6);
MemoryUnit merged = memoryCapability.getMemoryUnit(sessionId); MemoryUnitSnapshot merged = memoryCapability.getMemoryUnit(sessionId);
assertEquals(List.of("old-user", "old-assistant", "new-user", "new-assistant"), assertEquals(List.of("old-user", "old-assistant", "new-user", "new-assistant"),
merged.getConversationMessages().stream().map(Message::getContent).toList()); merged.getConversationMessages().stream().map(Message::getContent).toList());
assertEquals(2, merged.getSlices().size()); assertEquals(2, merged.getSlices().size());
MemorySlice appendedSlice = merged.getSlices().getLast(); MemorySliceSnapshot appendedSlice = merged.getSlices().getLast();
assertNotNull(appendedSlice.getId()); assertNotNull(appendedSlice.getId());
assertEquals(2, appendedSlice.getStartIndex()); assertEquals(2, appendedSlice.getStartIndex());
assertEquals(4, appendedSlice.getEndIndex()); assertEquals(4, appendedSlice.getEndIndex());
assertEquals("new-summary", appendedSlice.getSummary()); assertEquals("new-summary", appendedSlice.getSummary());
assertEquals(sessionId, rollingResult.memoryUnit().getId()); assertEquals(sessionId, rollingResult.getMemoryUnit().getId());
assertEquals(appendedSlice.getId(), rollingResult.memorySlice().getId()); assertEquals(appendedSlice.getId(), rollingResult.getMemorySlice().getId());
assertEquals("new-summary", rollingResult.summary()); assertEquals("new-summary", rollingResult.getSummary());
} }
@Test @Test
@@ -96,7 +98,7 @@ class DialogRollingTest {
message(Message.Character.ASSISTANT, "second") message(Message.Character.ASSISTANT, "second")
), 2, 6); ), 2, 6);
MemoryUnit created = memoryCapability.getMemoryUnit(sessionId); MemoryUnitSnapshot created = memoryCapability.getMemoryUnit(sessionId);
assertNotNull(created); assertNotNull(created);
assertEquals(List.of("first", "second"), assertEquals(List.of("first", "second"),
created.getConversationMessages().stream().map(Message::getContent).toList()); created.getConversationMessages().stream().map(Message::getContent).toList());
@@ -104,7 +106,7 @@ class DialogRollingTest {
assertEquals(0, created.getSlices().getFirst().getStartIndex()); assertEquals(0, created.getSlices().getFirst().getStartIndex());
assertEquals(2, created.getSlices().getFirst().getEndIndex()); assertEquals(2, created.getSlices().getFirst().getEndIndex());
assertEquals("fresh-summary", created.getSlices().getFirst().getSummary()); assertEquals("fresh-summary", created.getSlices().getFirst().getSummary());
assertEquals(created, rollingResult.memoryUnit()); assertEquals(created, rollingResult.getMemoryUnit());
} }
@Test @Test
@@ -151,8 +153,8 @@ class DialogRollingTest {
message(Message.Character.ASSISTANT, "a1") message(Message.Character.ASSISTANT, "a1")
), 2, 6); ), 2, 6);
assertEquals(sessionId, rollingResult.memoryUnit().getId()); assertEquals(sessionId, rollingResult.getMemoryUnit().getId());
assertEquals("no summary, due to empty summarize result", rollingResult.summary()); assertEquals("no summary, due to empty summarize result", rollingResult.getSummary());
} }
private static final class StubMemoryCapability implements MemoryCapability { private static final class StubMemoryCapability implements MemoryCapability {
@@ -172,28 +174,29 @@ class DialogRollingTest {
} }
@Override @Override
public MemoryUnit getMemoryUnit(String unitId) { public MemoryUnitSnapshot getMemoryUnit(String unitId) {
return units.get(unitId); MemoryUnit unit = units.get(unitId);
return unit == null ? null : unit.snapshot();
} }
@Override @Override
public work.slhaf.partner.framework.agent.support.Result<MemorySlice> getMemorySlice(String unitId, String sliceId) { public work.slhaf.partner.framework.agent.support.Result<MemorySliceSnapshot> getMemorySlice(String unitId, String sliceId) {
return null; return null;
} }
@Override @Override
public MemoryUnit updateMemoryUnit(List<Message> chatMessages, String summary) { public MemoryUnitSnapshot updateMemoryUnit(List<Message> chatMessages, String summary) {
MemoryUnit unit = units.computeIfAbsent(sessionId, MemoryUnit::new); MemoryUnit unit = units.computeIfAbsent(sessionId, MemoryUnit::new);
unit.updateTimestamp(); unit.updateTimestamp();
int startIndex = unit.getConversationMessages().size(); int startIndex = unit.getConversationMessages().size();
unit.getConversationMessages().addAll(chatMessages); unit.getConversationMessages().addAll(chatMessages);
unit.getSlices().add(new MemorySlice(startIndex, startIndex + chatMessages.size(), summary)); unit.getSlices().add(new MemorySlice(startIndex, startIndex + chatMessages.size(), summary));
return unit; return unit.snapshot();
} }
@Override @Override
public Collection<MemoryUnit> listMemoryUnits() { public Collection<MemoryUnitSnapshot> listMemoryUnits() {
return units.values(); return units.values().stream().map(MemoryUnit::snapshot).toList();
} }
@Override @Override

View File

@@ -0,0 +1,323 @@
package work.slhaf.partner.module.impression;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.w3c.dom.Element;
import work.slhaf.partner.core.cognition.CognitionCapability;
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.core.memory.pojo.MemorySlice;
import work.slhaf.partner.core.memory.pojo.MemoryUnit;
import work.slhaf.partner.framework.agent.model.pojo.Message;
import work.slhaf.partner.framework.agent.support.Result;
import work.slhaf.partner.module.communication.AfterRollingRegistry;
import work.slhaf.partner.module.communication.RollingResult;
import java.lang.reflect.Field;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
class ImpressionUpdaterTest {
@BeforeAll
static void beforeAll(@TempDir Path tempDir) {
System.setProperty("user.home", tempDir.toAbsolutePath().toString());
}
private static void setField(Object target, String fieldName, Object value) throws Exception {
Field field = target.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
}
private static RollingResult rollingResult() {
String unitId = "impression-updater-test-" + UUID.randomUUID();
MemoryUnit unit = new MemoryUnit(unitId);
unit.getConversationMessages().addAll(List.of(
new Message(Message.Character.USER, "user likes quiet tools"),
new Message(Message.Character.ASSISTANT, "noted")
));
MemorySlice slice = new MemorySlice(0, 2, "summary");
unit.getSlices().add(slice);
return new RollingResult(unit.snapshot(), slice.snapshot(), 2, 6);
}
private static ImpressionUpdatePlan plan(PlanStatus status, ImpressionUpdateStep... steps) {
return new ImpressionUpdatePlan(List.of(steps), status, null);
}
@Test
void shouldRegisterItselfToAfterRollingRegistryOnInit() throws Exception {
ImpressionUpdater updater = new ImpressionUpdater();
AfterRollingRegistry registry = mock(AfterRollingRegistry.class);
setField(updater, "afterRollingRegistry", registry);
updater.init();
verify(registry).register(updater);
}
@Test
void shouldNotApplyEmptyPlan() throws Exception {
TestApplier applier = new TestApplier(Result.success(ImpressionUpdateApplyResult.empty()));
ImpressionUpdater updater = updaterWith(plan(PlanStatus.PREPARED), applier, new RecordingCognitionCapability());
updater.consume(rollingResult());
assertEquals(0, applier.applyCount);
}
@Test
void shouldNotApplyRejectedOrInvalidPlan() throws Exception {
TestApplier applier = new TestApplier(Result.success(ImpressionUpdateApplyResult.empty()));
ImpressionUpdater updater = updaterWith(
plan(PlanStatus.REJECTED, new UpdateExistingStep("entity-1", new ImpressionPatch("stable"))),
applier,
new RecordingCognitionCapability()
);
updater.consume(rollingResult());
assertEquals(0, applier.applyCount);
}
@Test
void shouldApplyConfirmedUpdateExistingPlanThroughMutationApi() throws Exception {
RecordingCognitionCapability cognitionCapability = new RecordingCognitionCapability();
ImpressionUpdatePlanApplier applier = new ImpressionUpdatePlanApplier();
setField(applier, "cognitionCapability", cognitionCapability);
Result<ImpressionUpdateApplyResult> result = applier.apply(plan(
PlanStatus.CONFIRMED,
new UpdateExistingStep("entity-1", new ImpressionPatch("old impression", "new impression", 0.7))
));
assertNull(result.exceptionOrNull());
assertEquals("entity-1", cognitionCapability.lastImpressionEntityUuid);
assertEquals("old impression", cognitionCapability.lastImpression);
assertEquals("new impression", cognitionCapability.lastNewImpression);
assertEquals(0.7, cognitionCapability.lastConfidence);
}
@Test
void shouldCreateEntityApplyPatchesActivateAndRegisterActiveSnapshot() throws Exception {
RecordingCognitionCapability cognitionCapability = new RecordingCognitionCapability();
cognitionCapability.createdEntityUuid = "entity-created";
ImpressionUpdatePlanApplier applier = new ImpressionUpdatePlanApplier();
setField(applier, "cognitionCapability", cognitionCapability);
Result<ImpressionUpdateApplyResult> result = applier.apply(plan(
PlanStatus.CONFIRMED,
new CreateEntityStep(
"User",
List.of(new ImpressionPatch("prefers concise updates")),
List.of(),
List.of(new AliasPatch("operator")),
List.of()
)
));
assertNull(result.exceptionOrNull());
assertEquals("User", cognitionCapability.createdSubject);
assertEquals("entity-created", cognitionCapability.lastImpressionEntityUuid);
assertEquals("entity-created", cognitionCapability.lastAliasEntityUuid);
assertEquals(List.of("entity-created"), result.getOrThrow().createdEntityUuids());
}
@Test
void shouldConfirmValidatedPreparedPlanAndRegisterCreatedActiveEntity() throws Exception {
RecordingCognitionCapability cognitionCapability = new RecordingCognitionCapability();
ActiveEntity activeEntity = new ActiveEntity("runtime-1");
activeEntity.updateSubject("User");
activeEntity.bindEntity("entity-created");
cognitionCapability.activatedEntity = activeEntity.snapshot();
TestApplier applier = new TestApplier(Result.success(new ImpressionUpdateApplyResult(List.of("entity-created"))));
ImpressionUpdater updater = updaterWith(
plan(PlanStatus.PREPARED, new CreateEntityStep(
"User",
List.of(new ImpressionPatch("prefers concise updates")),
List.of(),
List.of(),
List.of()
)),
applier,
cognitionCapability
);
updater.consume(rollingResult());
assertEquals(1, applier.applyCount);
assertEquals(PlanStatus.CONFIRMED, applier.lastPlanStatus);
assertEquals("entity-created", cognitionCapability.activatedEntityUuid);
String resolvedXml = cognitionCapability.contextWorkspace()
.resolve(List.of(work.slhaf.partner.core.cognition.context.ContextBlock.FocusedDomain.COGNITION))
.encodeToMessage()
.getContent();
assertTrue(resolvedXml.contains("active_entity_runtime-1"));
}
private static ImpressionUpdater updaterWith(ImpressionUpdatePlan plan,
ImpressionUpdatePlanApplier applier,
CognitionCapability cognitionCapability) throws Exception {
ImpressionUpdater updater = new ImpressionUpdater();
setField(updater, "cognitionCapability", cognitionCapability);
setField(updater, "planner", new TestPlanner(Result.success(plan)));
setField(updater, "validator", new ImpressionUpdatePlanValidator());
setField(updater, "applier", applier);
return updater;
}
private static class TestPlanner extends ImpressionUpdatePlanner {
private final Result<ImpressionUpdatePlan> result;
private TestPlanner(Result<ImpressionUpdatePlan> result) {
this.result = result;
}
@Override
public Result<ImpressionUpdatePlan> plan(ImpressionUpdateContext context) {
return result;
}
}
private static class TestApplier extends ImpressionUpdatePlanApplier {
private final Result<ImpressionUpdateApplyResult> result;
private int applyCount;
private PlanStatus lastPlanStatus;
private TestApplier(Result<ImpressionUpdateApplyResult> result) {
this.result = result;
}
@Override
public Result<ImpressionUpdateApplyResult> apply(ImpressionUpdatePlan plan) {
applyCount++;
lastPlanStatus = plan.getStatus();
return result;
}
}
private static class RecordingCognitionCapability implements CognitionCapability {
private final ContextWorkspace contextWorkspace = new ContextWorkspace();
private final Lock lock = new ReentrantLock();
private String createdEntityUuid = "entity-1";
private String createdSubject;
private String lastImpressionEntityUuid;
private String lastImpression;
private String lastNewImpression;
private double lastConfidence;
private String lastAliasEntityUuid;
private String activatedEntityUuid;
private ActiveEntity activatedEntity;
@Override
public void initiateTurn(String input, String target, String... skippedModules) {
}
@Override
public ContextWorkspace contextWorkspace() {
return contextWorkspace;
}
@Override
public List<Message> getChatMessages() {
return List.of();
}
@Override
public List<Message> snapshotChatMessages() {
return List.of();
}
@Override
public void rollChatMessagesWithSnapshot(int snapshotSize, int retainDivisor) {
}
@Override
public void refreshRecentChatMessagesContext() {
}
@Override
public Element messageNotesElement() {
return null;
}
@Override
public Lock getMessageLock() {
return lock;
}
@Override
public Set<ActiveEntity> projectEntity(String input) {
return Set.of();
}
@Override
public Map<ActiveEntity, Entity> showEntities() {
return Map.of();
}
@Override
public String createEntity(String subject) {
createdSubject = subject;
return createdEntityUuid;
}
@Override
public Entity getEntity(String uuid) {
return null;
}
@Override
public ActiveEntity activateKnownEntity(String entityUuid) {
activatedEntityUuid = entityUuid;
return activatedEntity;
}
@Override
public boolean bindActiveEntity(String runtimeId, String entityUuid) {
return true;
}
@Override
public boolean renameEntitySubject(String entityUuid, String newSubject, boolean keepOldSubjectAsAlias) {
return true;
}
@Override
public boolean addEntityAlias(String entityUuid, String alias, boolean deprecated) {
lastAliasEntityUuid = entityUuid;
return true;
}
@Override
public boolean updateEntityImpression(String entityUuid, String impression, String newImpression, double confidence) {
lastImpressionEntityUuid = entityUuid;
lastImpression = impression;
lastNewImpression = newImpression;
lastConfidence = confidence;
return true;
}
@Override
public boolean updateEntityFeature(String entityUuid, String feature, String newFeature, double confidence) {
return true;
}
@Override
public boolean updateEntityRelation(String entityUuid, String target, String relation, double strength) {
return true;
}
}
}

View File

@@ -11,7 +11,9 @@ import work.slhaf.partner.core.cognition.CognitionCapability;
import work.slhaf.partner.core.cognition.context.ContextWorkspace; import work.slhaf.partner.core.cognition.context.ContextWorkspace;
import work.slhaf.partner.core.memory.MemoryCapability; import work.slhaf.partner.core.memory.MemoryCapability;
import work.slhaf.partner.core.memory.pojo.MemorySlice; import work.slhaf.partner.core.memory.pojo.MemorySlice;
import work.slhaf.partner.core.memory.pojo.MemorySliceSnapshot;
import work.slhaf.partner.core.memory.pojo.MemoryUnit; import work.slhaf.partner.core.memory.pojo.MemoryUnit;
import work.slhaf.partner.core.memory.pojo.MemoryUnitSnapshot;
import work.slhaf.partner.framework.agent.model.pojo.Message; import work.slhaf.partner.framework.agent.model.pojo.Message;
import work.slhaf.partner.framework.agent.support.Result; import work.slhaf.partner.framework.agent.support.Result;
import work.slhaf.partner.module.memory.pojo.ActivationProfile; import work.slhaf.partner.module.memory.pojo.ActivationProfile;
@@ -19,7 +21,6 @@ import work.slhaf.partner.module.memory.runtime.exception.MemoryLookupException;
import work.slhaf.partner.module.memory.selector.ActivatedMemorySlice; import work.slhaf.partner.module.memory.selector.ActivatedMemorySlice;
import java.lang.reflect.Field; import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.nio.file.Path; import java.nio.file.Path;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.Collection; import java.util.Collection;
@@ -41,11 +42,8 @@ class MemoryRuntimeTest {
System.setProperty("user.home", tempDir.toAbsolutePath().toString()); System.setProperty("user.home", tempDir.toAbsolutePath().toString());
} }
@SuppressWarnings("unchecked") private static List<Message> invokeSliceMessages(MemoryRuntime runtime, MemoryUnit unit, MemorySlice slice) {
private static List<Message> invokeSliceMessages(MemoryRuntime runtime, MemoryUnit unit, MemorySlice slice) throws Exception { return unit.snapshot().messagesOf(slice.snapshot());
Method method = MemoryRuntime.class.getDeclaredMethod("sliceMessages", MemoryUnit.class, MemorySlice.class);
method.setAccessible(true);
return (List<Message>) method.invoke(runtime, unit, slice);
} }
private static void setField(Object target, String fieldName, Object value) throws Exception { private static void setField(Object target, String fieldName, Object value) throws Exception {
@@ -98,6 +96,64 @@ class MemoryRuntimeTest {
public Lock getMessageLock() { public Lock getMessageLock() {
return lock; 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 String createEntity(String subject) {
return null;
}
@Override
public work.slhaf.partner.core.cognition.impression.Entity getEntity(String uuid) {
return null;
}
@Override
public work.slhaf.partner.core.cognition.impression.ActiveEntity activateKnownEntity(String entityUuid) {
return null;
}
@Override
public boolean bindActiveEntity(String runtimeId, String entityUuid) {
return false;
}
@Override
public boolean renameEntitySubject(String entityUuid, String newSubject, boolean keepOldSubjectAsAlias) {
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;
}
}; };
} }
@@ -147,7 +203,7 @@ class MemoryRuntimeTest {
unit.getSlices().addAll(List.of(firstSlice, secondSlice)); unit.getSlices().addAll(List.of(firstSlice, secondSlice));
memoryCapability.remember(unit); memoryCapability.remember(unit);
runtime.recordMemory(unit, "topic/main", List.of("topic/related"), DEFAULT_PROFILE); runtime.recordMemory(unit.snapshot(), "topic/main", List.of("topic/related"), DEFAULT_PROFILE);
List<ActivatedMemorySlice> topicResult = runtime.queryActivatedMemoryByTopicPath("topic/main"); List<ActivatedMemorySlice> topicResult = runtime.queryActivatedMemoryByTopicPath("topic/main");
assertEquals(List.of("slice-2"), topicResult.stream().map(ActivatedMemorySlice::getSliceId).toList()); assertEquals(List.of("slice-2"), topicResult.stream().map(ActivatedMemorySlice::getSliceId).toList());
@@ -187,8 +243,8 @@ class MemoryRuntimeTest {
relatedUnit.getSlices().add(relatedSlice); relatedUnit.getSlices().add(relatedSlice);
memoryCapability.remember(relatedUnit); memoryCapability.remember(relatedUnit);
runtime.recordMemory(mainUnit, "topic/main", List.of("topic/related"), DEFAULT_PROFILE); runtime.recordMemory(mainUnit.snapshot(), "topic/main", List.of("topic/related"), DEFAULT_PROFILE);
runtime.recordMemory(relatedUnit, "topic/related", List.of(), DEFAULT_PROFILE); runtime.recordMemory(relatedUnit.snapshot(), "topic/related", List.of(), DEFAULT_PROFILE);
List<ActivatedMemorySlice> topicResult = runtime.queryActivatedMemoryByTopicPath("topic/main"); List<ActivatedMemorySlice> topicResult = runtime.queryActivatedMemoryByTopicPath("topic/main");
assertEquals(List.of("slice-main", "slice-related"), assertEquals(List.of("slice-main", "slice-related"),
@@ -207,7 +263,7 @@ class MemoryRuntimeTest {
MemorySlice firstSlice = MemorySlice.restore("slice-1", 0, 1, "first", 86_400_000L); MemorySlice firstSlice = MemorySlice.restore("slice-1", 0, 1, "first", 86_400_000L);
firstUnitSnapshot.getSlices().add(firstSlice); firstUnitSnapshot.getSlices().add(firstSlice);
memoryCapability.remember(firstUnitSnapshot); memoryCapability.remember(firstUnitSnapshot);
runtime.recordMemory(firstUnitSnapshot, "topic/main", List.of(), DEFAULT_PROFILE); runtime.recordMemory(firstUnitSnapshot.snapshot(), "topic/main", List.of(), DEFAULT_PROFILE);
firstUnitSnapshot.getConversationMessages().clear(); firstUnitSnapshot.getConversationMessages().clear();
firstUnitSnapshot.getConversationMessages().addAll(List.of(message("m2"), message("m3"))); firstUnitSnapshot.getConversationMessages().addAll(List.of(message("m2"), message("m3")));
@@ -215,7 +271,7 @@ class MemoryRuntimeTest {
firstUnitSnapshot.getSlices().clear(); firstUnitSnapshot.getSlices().clear();
firstUnitSnapshot.getSlices().add(secondSlice); firstUnitSnapshot.getSlices().add(secondSlice);
memoryCapability.remember(firstUnitSnapshot); memoryCapability.remember(firstUnitSnapshot);
runtime.recordMemory(firstUnitSnapshot, "topic/main", List.of(), DEFAULT_PROFILE); runtime.recordMemory(firstUnitSnapshot.snapshot(), "topic/main", List.of(), DEFAULT_PROFILE);
JSONObject state = JSONObject.parseObject(runtime.convert().toString()); JSONObject state = JSONObject.parseObject(runtime.convert().toString());
JSONArray dateIndex = state.getJSONArray("date_index"); JSONArray dateIndex = state.getJSONArray("date_index");
@@ -253,14 +309,14 @@ class MemoryRuntimeTest {
MemorySlice secondSlice = MemorySlice.restore("slice-2", 2, 4, "second", 172_800_000L); MemorySlice secondSlice = MemorySlice.restore("slice-2", 2, 4, "second", 172_800_000L);
mainUnit.getSlices().addAll(List.of(firstSlice, secondSlice)); mainUnit.getSlices().addAll(List.of(firstSlice, secondSlice));
memoryCapability.remember(mainUnit); memoryCapability.remember(mainUnit);
runtime.recordMemory(mainUnit, "topic/main", List.of("topic/related"), DEFAULT_PROFILE); runtime.recordMemory(mainUnit.snapshot(), "topic/main", List.of("topic/related"), DEFAULT_PROFILE);
MemoryUnit relatedUnit = new MemoryUnit("unit-201"); MemoryUnit relatedUnit = new MemoryUnit("unit-201");
relatedUnit.getConversationMessages().addAll(List.of(message("r0"), message("r1"))); relatedUnit.getConversationMessages().addAll(List.of(message("r0"), message("r1")));
MemorySlice relatedSlice = MemorySlice.restore("slice-3", 0, 2, "related", 259_200_000L); MemorySlice relatedSlice = MemorySlice.restore("slice-3", 0, 2, "related", 259_200_000L);
relatedUnit.getSlices().add(relatedSlice); relatedUnit.getSlices().add(relatedSlice);
memoryCapability.remember(relatedUnit); memoryCapability.remember(relatedUnit);
runtime.recordMemory(relatedUnit, "topic/related", List.of(), DEFAULT_PROFILE); runtime.recordMemory(relatedUnit.snapshot(), "topic/related", List.of(), DEFAULT_PROFILE);
JSONObject state = JSONObject.parseObject(runtime.convert().toString()); JSONObject state = JSONObject.parseObject(runtime.convert().toString());
JSONArray topicSlices = state.getJSONArray("topic_slices"); JSONArray topicSlices = state.getJSONArray("topic_slices");
@@ -327,21 +383,21 @@ class MemoryRuntimeTest {
MemorySlice primarySlice = MemorySlice.restore("slice-primary", 0, 2, "primary", System.currentTimeMillis()); MemorySlice primarySlice = MemorySlice.restore("slice-primary", 0, 2, "primary", System.currentTimeMillis());
primaryUnit.getSlices().add(primarySlice); primaryUnit.getSlices().add(primarySlice);
memoryCapability.remember(primaryUnit); memoryCapability.remember(primaryUnit);
runtime.recordMemory(primaryUnit, "topic->main", List.of("topic->related"), new ActivationProfile(0.9f, 0.1f, 0.9f)); runtime.recordMemory(primaryUnit.snapshot(), "topic->main", List.of("topic->related"), new ActivationProfile(0.9f, 0.1f, 0.9f));
MemoryUnit relatedUnit = new MemoryUnit("unit-related-rank"); MemoryUnit relatedUnit = new MemoryUnit("unit-related-rank");
relatedUnit.getConversationMessages().addAll(List.of(message("r0"), message("r1"))); relatedUnit.getConversationMessages().addAll(List.of(message("r0"), message("r1")));
MemorySlice relatedSlice = MemorySlice.restore("slice-related-rank", 0, 2, "related", System.currentTimeMillis()); MemorySlice relatedSlice = MemorySlice.restore("slice-related-rank", 0, 2, "related", System.currentTimeMillis());
relatedUnit.getSlices().add(relatedSlice); relatedUnit.getSlices().add(relatedSlice);
memoryCapability.remember(relatedUnit); memoryCapability.remember(relatedUnit);
runtime.recordMemory(relatedUnit, "topic->related", List.of(), new ActivationProfile(1.0f, 1.0f, 1.0f)); runtime.recordMemory(relatedUnit.snapshot(), "topic->related", List.of(), new ActivationProfile(1.0f, 1.0f, 1.0f));
MemoryUnit parentUnit = new MemoryUnit("unit-parent"); MemoryUnit parentUnit = new MemoryUnit("unit-parent");
parentUnit.getConversationMessages().addAll(List.of(message("x0"), message("x1"))); parentUnit.getConversationMessages().addAll(List.of(message("x0"), message("x1")));
MemorySlice parentSlice = MemorySlice.restore("slice-parent", 0, 2, "parent", System.currentTimeMillis()); MemorySlice parentSlice = MemorySlice.restore("slice-parent", 0, 2, "parent", System.currentTimeMillis());
parentUnit.getSlices().add(parentSlice); parentUnit.getSlices().add(parentSlice);
memoryCapability.remember(parentUnit); memoryCapability.remember(parentUnit);
runtime.recordMemory(parentUnit, "topic", List.of(), new ActivationProfile(1.0f, 1.0f, 1.0f)); runtime.recordMemory(parentUnit.snapshot(), "topic", List.of(), new ActivationProfile(1.0f, 1.0f, 1.0f));
List<ActivatedMemorySlice> topicResult = runtime.queryActivatedMemoryByTopicPath("topic->main"); List<ActivatedMemorySlice> topicResult = runtime.queryActivatedMemoryByTopicPath("topic->main");
assertEquals(List.of("slice-primary", "slice-related-rank", "slice-parent"), assertEquals(List.of("slice-primary", "slice-related-rank", "slice-parent"),
@@ -361,7 +417,7 @@ class MemoryRuntimeTest {
primaryUnit.getSlices().add(primarySlice); primaryUnit.getSlices().add(primarySlice);
memoryCapability.remember(primaryUnit); memoryCapability.remember(primaryUnit);
runtime.recordMemory( runtime.recordMemory(
primaryUnit, primaryUnit.snapshot(),
"topic->main", "topic->main",
List.of("topic->related"), List.of("topic->related"),
new ActivationProfile(0.8f, 0.0f, 0.8f) new ActivationProfile(0.8f, 0.0f, 0.8f)
@@ -372,7 +428,7 @@ class MemoryRuntimeTest {
MemorySlice relatedSlice = MemorySlice.restore("slice-related-zero", 0, 2, "related", System.currentTimeMillis()); MemorySlice relatedSlice = MemorySlice.restore("slice-related-zero", 0, 2, "related", System.currentTimeMillis());
relatedUnit.getSlices().add(relatedSlice); relatedUnit.getSlices().add(relatedSlice);
memoryCapability.remember(relatedUnit); memoryCapability.remember(relatedUnit);
runtime.recordMemory(relatedUnit, "topic->related", List.of(), new ActivationProfile(1.0f, 1.0f, 1.0f)); runtime.recordMemory(relatedUnit.snapshot(), "topic->related", List.of(), new ActivationProfile(1.0f, 1.0f, 1.0f));
List<ActivatedMemorySlice> topicResult = runtime.queryActivatedMemoryByTopicPath("topic->main"); List<ActivatedMemorySlice> topicResult = runtime.queryActivatedMemoryByTopicPath("topic->main");
assertEquals(List.of("slice-primary-zero"), topicResult.stream().map(ActivatedMemorySlice::getSliceId).toList()); assertEquals(List.of("slice-primary-zero"), topicResult.stream().map(ActivatedMemorySlice::getSliceId).toList());
@@ -391,10 +447,10 @@ class MemoryRuntimeTest {
unit.getSlices().add(slice); unit.getSlices().add(slice);
memoryCapability.remember(unit); memoryCapability.remember(unit);
runtime.recordMemory(unit, "topic->main", List.of("topic->related"), new ActivationProfile(0.2f, 0.1f, 0.2f)); runtime.recordMemory(unit.snapshot(), "topic->main", List.of("topic->related"), new ActivationProfile(0.2f, 0.1f, 0.2f));
unit.getSlices().clear(); unit.getSlices().clear();
unit.getSlices().add(MemorySlice.restore("slice-refresh", 0, 2, "summary", 172_800_000L)); unit.getSlices().add(MemorySlice.restore("slice-refresh", 0, 2, "summary", 172_800_000L));
runtime.recordMemory(unit, "topic->main", List.of("topic->related-2"), new ActivationProfile(0.9f, 0.8f, 0.7f)); runtime.recordMemory(unit.snapshot(), "topic->main", List.of("topic->related-2"), new ActivationProfile(0.9f, 0.8f, 0.7f));
JSONObject state = JSONObject.parseObject(runtime.convert().toString()); JSONObject state = JSONObject.parseObject(runtime.convert().toString());
JSONObject mainTopic = state.getJSONArray("topic_slices").stream() JSONObject mainTopic = state.getJSONArray("topic_slices").stream()
@@ -428,12 +484,13 @@ class MemoryRuntimeTest {
} }
@Override @Override
public MemoryUnit getMemoryUnit(String unitId) { public MemoryUnitSnapshot getMemoryUnit(String unitId) {
return units.get(unitId); MemoryUnit unit = units.get(unitId);
return unit == null ? null : unit.snapshot();
} }
@Override @Override
public Result<MemorySlice> getMemorySlice(String unitId, String sliceId) { public Result<MemorySliceSnapshot> getMemorySlice(String unitId, String sliceId) {
MemoryUnit unit = units.get(unitId); MemoryUnit unit = units.get(unitId);
if (unit == null || unit.getSlices() == null) { if (unit == null || unit.getSlices() == null) {
return Result.failure(new MemoryLookupException( return Result.failure(new MemoryLookupException(
@@ -445,7 +502,7 @@ class MemoryRuntimeTest {
return unit.getSlices().stream() return unit.getSlices().stream()
.filter(slice -> sliceId.equals(slice.getId())) .filter(slice -> sliceId.equals(slice.getId()))
.findFirst() .findFirst()
.map(Result::success) .map(slice -> Result.success(slice.snapshot()))
.orElseGet(() -> Result.failure(new MemoryLookupException( .orElseGet(() -> Result.failure(new MemoryLookupException(
"Memory slice not found: " + unitId + ":" + sliceId, "Memory slice not found: " + unitId + ":" + sliceId,
unitId + ":" + sliceId, unitId + ":" + sliceId,
@@ -454,13 +511,13 @@ class MemoryRuntimeTest {
} }
@Override @Override
public MemoryUnit updateMemoryUnit(List<Message> chatMessages, String summary) { public MemoryUnitSnapshot updateMemoryUnit(List<Message> chatMessages, String summary) {
return null; return null;
} }
@Override @Override
public Collection<MemoryUnit> listMemoryUnits() { public Collection<MemoryUnitSnapshot> listMemoryUnits() {
return units.values(); return units.values().stream().map(MemoryUnit::snapshot).toList();
} }
@Override @Override

View File

@@ -79,13 +79,10 @@ class MemoryRecallProfileExtractorTest {
MemorySlice slice = new MemorySlice(2, 4, "slice-summary"); MemorySlice slice = new MemorySlice(2, 4, "slice-summary");
unit.getSlices().add(slice); unit.getSlices().add(slice);
updater.consume(new RollingResult(unit, slice, List.of( updater.consume(new RollingResult(unit.snapshot(), slice.snapshot(), 4, 6));
message(Message.Character.USER, "new"),
message(Message.Character.ASSISTANT, "new-reply")
), "slice-summary", 4, 6));
verify(memoryRuntime).recordMemory( verify(memoryRuntime).recordMemory(
eq(unit), eq(unit.snapshot()),
eq("root->branch"), eq("root->branch"),
eq(List.of("root->related")), eq(List.of("root->related")),
argThat(profile -> profile != null argThat(profile -> profile != null
@@ -113,10 +110,10 @@ class MemoryRecallProfileExtractorTest {
MemorySlice slice = new MemorySlice(0, 2, "slice-summary"); MemorySlice slice = new MemorySlice(0, 2, "slice-summary");
unit.getSlices().add(slice); unit.getSlices().add(slice);
updater.consume(new RollingResult(unit, slice, unit.getConversationMessages(), "slice-summary", 2, 6)); updater.consume(new RollingResult(unit.snapshot(), slice.snapshot(), 2, 6));
verify(memoryRuntime).recordMemory( verify(memoryRuntime).recordMemory(
eq(unit), eq(unit.snapshot()),
eq(null), eq(null),
eq(List.of()), eq(List.of()),
argThat(profile -> profile != null argThat(profile -> profile != null
@@ -147,10 +144,10 @@ class MemoryRecallProfileExtractorTest {
MemorySlice slice = new MemorySlice(0, 1, "slice-summary"); MemorySlice slice = new MemorySlice(0, 1, "slice-summary");
unit.getSlices().add(slice); unit.getSlices().add(slice);
updater.consume(new RollingResult(unit, slice, unit.getConversationMessages(), "slice-summary", 1, 6)); updater.consume(new RollingResult(unit.snapshot(), slice.snapshot(), 1, 6));
verify(memoryRuntime).recordMemory( verify(memoryRuntime).recordMemory(
eq(unit), eq(unit.snapshot()),
eq("root->branch"), eq("root->branch"),
eq(List.of()), eq(List.of()),
argThat(profile -> profile != null argThat(profile -> profile != null

View File

@@ -199,8 +199,14 @@ Partner/
- [行动系统](doc/action/action.md) - [行动系统](doc/action/action.md)
- [记忆存储与组织](doc/memory/memory.md) - [记忆存储与组织](doc/memory/memory.md)
### 设计草案与后续方向
- [初见模块](doc/design/first-encounter-module.md)
- [印象模块更新管线](doc/design/impression-update-observation-pipeline.md)
- [印象模块向量融合扩展](doc/design/impression-vector-fusion.md)
--- ---
## License ## License
暂未指定 暂未选择开源许可证。当前仓库主要作为个人项目展示与学习研究记录,未经授权不建议复制、分发或商用

View File

@@ -0,0 +1,375 @@
# Impression Update Observation Pipeline / 印象更新观察管线设计草案
## 背景
当前 `ImpressionUpdater` 已经接入 `AfterRolling`,并形成了第一版更新闭环:
```text
RollingResult
-> ImpressionUpdateContext
-> ImpressionUpdatePlanner
-> ImpressionUpdatePlanValidator
-> ImpressionUpdatePlanApplier
```
这一版验证了 rolling 后自动更新 Impression 的主链路是可行的Planner 生成计划Validator 做基础校验Applier 只接受 `CONFIRMED` 计划并通过 `CognitionCapability` mutation API 落地。
但当前 Planner 直接输出最终 mutation plan
```text
UpdateExistingStep(entityUuid, patch)
CreateEntityStep(subject, patches...)
```
这会让 LLM 过早承担稳定身份决策:它既要判断“这次 rolling 里出现了什么实体观察”,又要判断“该更新哪个 known entity / 是否创建新 entity”。后者更适合由代码侧 identity resolver 和 validator 处理,不应该完全交给模型。
## 核心问题
当前方案主要有三个隐患。
### 1. Planner 不应直接决定 known entity uuid
模型可以从证据中抽取实体观察,但它不适合直接决定稳定存储层 uuid。
即使 prompt 给了候选 entity模型仍可能
- 编造不存在的 uuid
- 选择错误的 uuid
- 把同一个实体拆成多个新实体;
- 对 subject / alias 相近的实体做过早合并。
因此Planner 的输出应从“最终更新计划”降级为“初始观察计划”。
### 2. KnownEntities 不宜提前整体塞给 Planner
全量 known entity 可能随长期使用不断增长。若每次 rolling 后把所有 known entity 的 subject、alias、impression、feature 都塞给 Planner会导致
- 上下文压力随实体数量增长;
- 无关实体污染判断;
- 成本和延迟不稳定;
- 模型在大量候选中发生错误关联。
但是,完全只看少数候选也可能漏掉“其实应更新某个已知实体”的场景。
因此,较合适的边界是:
> Planner 不看全量 known entities后续 resolver 可以使用轻量的 known entity identity index。
这里的 identity index 只包含确定性身份信息,例如 uuid、subject、alias而不包含完整 impressions/features/relations。
### 3. 单批 max candidates 不应变成语义丢弃
简单地设置 `maxKnownCandidates` 后截断,会让维护语义变成“只维护前 N 个实体”。
这不适合 Impression 这类长期维护模块。更合理的是:
```text
batch size 是模型上下文预算,不是语义覆盖上限。
```
也就是说,可以把 active entities 分批交给 Planner 提取观察,但最后应聚合所有 batch 的观察,再统一决定更新或创建。
## 目标形态
目标是把 ImpressionUpdater 拆成“观察抽取”和“身份决策/落库”两层:
```text
RollingResult
-> ActiveEntity batches
-> Observation Planner per batch
-> Observation Aggregate
-> Identity Resolver
-> Final Plan Builder
-> Context-aware Validator
-> Applier
```
其中:
- Planner 只负责提取观察,不直接输出最终 mutation
- Aggregate 汇总、去重、合并不同 batch 的观察;
- Resolver 使用 known entity identity index 决定 update existing 还是 create entity
- FinalPlanBuilder 生成现有 `ImpressionUpdatePlan`
- Validator 做全局安全校验;
- Applier 继续负责 confirmed plan 落地。
## 数据模型建议
### ImpressionObservationPlan
Planner 输出不再是 `ImpressionUpdatePlan`,而是观察层计划:
```kotlin
data class ImpressionObservationPlan(
val observations: List<ImpressionEntityObservation>,
val status: ObservationPlanStatus = ObservationPlanStatus.PREPARED,
val reason: String? = null,
)
enum class ObservationPlanStatus {
PREPARED,
REJECTED,
}
```
### ImpressionEntityObservation
实体观察不绑定 known entity uuid只表达“从证据中看到了什么”
```kotlin
data class ImpressionEntityObservation(
val proposedSubject: String,
val aliases: List<String> = emptyList(),
val impressions: List<ImpressionPatch> = emptyList(),
val features: List<FeaturePatch> = emptyList(),
val relations: List<RelationPatch> = emptyList(),
val sourceActiveRuntimeIds: List<String> = emptyList(),
val evidenceSnippets: List<String> = emptyList(),
val confidence: Double = 1.0,
val reason: String? = null,
)
```
设计重点:
- `proposedSubject` 是观察层身份名,不一定是最终 canonical subject
- `sourceActiveRuntimeIds` 记录观察来自哪些 active entity
- `evidenceSnippets` 保存可审计证据片段;
- `confidence` 表示观察可靠度,不是最终落库许可;
- 观察层允许重复,后续 Aggregate 负责合并。
### KnownEntityIdentity
Resolver 使用轻量身份索引,而不是完整实体上下文:
```kotlin
data class KnownEntityIdentity(
val entityUuid: String,
val subject: String,
val aliases: List<String> = emptyList(),
)
```
后续可能需要在 `CognitionCapability` 增加只读 API
```java
List<KnownEntityIdentity> showKnownEntityIdentities();
```
这个 API 不暴露 impressions/features/relations也不允许外部直接修改 entity只用于 deterministic identity resolution。
## 执行流程
### 1. 构建 active entity batches
`cognitionCapability.showEntities()` 获取当前 active entities 及其 bound known entity。
按照 `lastMentionedAt` 或 runtime 顺序划分 batch
```text
activeEntities
-> batch 1
-> batch 2
-> batch 3
```
这里的 batch size 只是上下文预算,例如每批 8 或 12 个 active entity。它不表示只处理前 N 个,而是所有 active entity 都会被覆盖。
每个 batch 与同一份 `RollingResult` 组成 observation context
```text
RollingResult + ActiveEntityBatch
-> ImpressionObservationPlanner
```
### 2. Planner 只产初始观察
Planner 的职责变成:
> 根据本次 rolling 证据和当前 batch 的 active entities提取可能需要进入长期印象系统的实体观察。
Planner 不允许:
- 输出 known entity uuid
- 直接决定 update existing / create entity
- 做实体合并;
- 访问或推断全量 known entity 状态。
它只输出 `ImpressionObservationPlan`
### 3. Aggregate 汇总所有 batch observation
Aggregate 收集所有 batch 的观察:
```text
ObservationPlan(batch1)
ObservationPlan(batch2)
ObservationPlan(batch3)
-> ImpressionObservationAggregate
```
Aggregate 负责:
- 丢弃 `REJECTED` 或空 observation
- normalize subject / alias
- 合并相同 normalized subject 的观察;
- 合并 alias 重叠的观察;
- 合并 evidenceHash 或 sourceActiveRuntimeIds 高度重合的观察;
- 去掉低置信、无证据、模板化、过长的 patch
- 生成去重后的独立实体观察集合。
这一阶段仍不落库。
### 4. Identity Resolver 决定更新还是创建
Resolver 使用 `KnownEntityIdentityIndex`,只根据 subject / alias 做轻量身份匹配。
规则第一版可以保守:
```text
exact normalized subject match
-> update existing
exact normalized alias match
-> update existing
multiple matched entities
-> ambiguous, reject / postpone
no match + observation evidence sufficient
-> create entity
no match + evidence weak
-> reject / postpone
```
Resolver 不做复杂语义合并,不调用 LLM不根据 impression 内容强行判断两个实体是否相同。
### 5. FinalPlanBuilder 生成现有 mutation plan
Identity resolution 完成后,才生成真正的 `ImpressionUpdatePlan`
```text
ResolvedObservation(update entityUuid)
-> UpdateExistingStep(entityUuid, patch)
ResolvedObservation(create subject)
-> CreateEntityStep(subject, patches...)
```
这样可以复用现有 `ImpressionUpdatePlanApplier`,不需要把落库 API 全部推翻。
### 6. Validator 做全局校验
Validator 应从基础语法校验升级为 context-aware / identity-aware 校验:
- `UpdateExistingStep.entityUuid` 必须存在;
- `CreateEntityStep.subject` 不能与 known subject / alias 重复;
- `RelationPatch.target` 必须能解析到已知 entity 或本批即将创建的 entity
- patch 文本必须非空且长度有限;
- `confidence` / `strength` 必须是有限数值并落在合法范围;
- ambiguous resolution 不允许落库;
- final plan 不允许空 step。
### 7. Applier 执行 confirmed plan
Applier 仍只接受 `CONFIRMED``ImpressionUpdatePlan`,并通过 `CognitionCapability` mutation API 执行。
如果 final plan 较大,应用阶段可以分批执行,但这只是资源与事务层面的 batch不再影响身份决策。
也就是说:
```text
可以 batch apply finalized steps。
不可以 batch planner -> batch apply。
```
## 与当前实现的关系
当前代码中的 `ImpressionUpdatePlan``ImpressionUpdatePlanApplier` 可以保留。
需要调整的是 Planner 前后链路:
```text
当前:
ImpressionUpdatePlanner -> ImpressionUpdatePlan -> Validator -> Applier
目标:
ImpressionObservationPlanner -> ImpressionObservationPlan
-> ImpressionObservationAggregator
-> ImpressionIdentityResolver
-> ImpressionUpdatePlanBuilder
-> ImpressionUpdatePlanValidator
-> ImpressionUpdatePlanApplier
```
第一阶段可以不删除当前 Planner而是先新增 observation pipeline再逐步迁移 `ImpressionUpdater.consume()`
## 分阶段落地建议
### Phase 1写入观察模型与文档
- 新增 `ImpressionObservationPlan`
- 新增 `ImpressionEntityObservation`
- 新增 `KnownEntityIdentity`
- 保留现有 update plan 和 applier
- 不改主链路。
### Phase 2ObservationPlanner 替代直接 update planner
- 新增 `ImpressionObservationPlanner`
- 每批 active entity + rolling result 生成 observation plan
- Planner prompt 明确不输出 uuid、不决定 update/create。
### Phase 3Aggregate / Resolver / PlanBuilder
- 聚合所有 batch observations
- 使用 known identity index 做 subject / alias 级 resolution
- 转成最终 `ImpressionUpdatePlan`
### Phase 4Validator 升级
- Validator 改为接收 final plan + resolution context
- 增加 uuid、重复 subject/alias、relation target、patch 边界检查。
### Phase 5替换 `ImpressionUpdater.consume()` 主流程
将主流程改为:
```text
buildObservationContexts(result)
-> planner per batch
-> aggregate
-> resolve
-> build final plan
-> validate
-> confirm
-> apply
```
## 非目标
第一版不做:
- 向量召回;
- 全库 impressions/features 语义扫描;
- LLM 实体合并;
- 多实体复杂冲突解决;
- 自动删除或降权旧 impression
- 基于弱证据的大规模新实体创建。
这些应在 observation pipeline 稳定后单独设计。
## 当前结论
本设计的核心边界是:
```text
Planner 负责观察。
Aggregate 负责去重与合并。
Resolver 负责身份决策。
Validator 负责安全确认。
Applier 负责落库。
```
这样可以保留 LLM 对自然语言证据的抽取能力,同时避免让它直接承担稳定实体身份和数据库 mutation 决策。