28 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
0567837dfe feat(impression): add impression recaller for projected entities 2026-06-09 11:43:28 +08:00
6dad6fdd6f fix(impression): compare active entities by runtimeId 2026-06-09 11:32:29 +08:00
e583276938 Remove empty impression capability methods 2026-06-07 22:42:37 +08:00
42407567b1 docs(impression): document vector fusion plan 2026-06-06 23:02:48 +08:00
e5d19f31ca doc: adjust location of design documents 2026-06-06 22:59:43 +08:00
ddf7f8da98 feat(impression): project text search hits into active entities 2026-06-06 22:57:55 +08:00
9269d4f678 chore: update gitignore 2026-06-06 22:57:55 +08:00
03087fb259 docs: note first encounter module design 2026-06-05 22:45:57 +08:00
b73696cc24 feat(impression): Add impression text search 2026-05-31 21:06:02 +08:00
96817d84fe feat(impression): introduce active entity recall model 2026-05-30 23:26:08 +08:00
dd64599154 feat(impression): add structured entity evidence metadata 2026-05-30 21:34:32 +08:00
4b638b756e fix(vector): skip upsert when vector client is unavailable 2026-05-30 21:04:22 +08:00
23a1b7093e feat(vector): implement Impression vector upsert and sync 2026-05-28 22:41:41 +08:00
9de46f3589 refactor(vector): add model id assigning support 2026-05-28 21:57:11 +08:00
fd8a0642b3 chore: remove meaningless warn 2026-05-28 21:05:26 +08:00
cffb369aef feat(impression): persist entity vectors 2026-05-28 20:59:20 +08:00
a929b3e0e6 feat(impression): add vector index skeleton 2026-05-27 23:27:51 +08:00
fe6895d10b feat(impression): serialize entity state 2026-05-27 23:11:41 +08:00
52 changed files with 3654 additions and 194 deletions

16
.codegraph/.gitignore vendored Normal file
View File

@@ -0,0 +1,16 @@
# CodeGraph data files
# These are local to each machine and should not be committed
# Database
*.db
*.db-wal
*.db-shm
# Cache
cache/
# Logs
*.log
# Hook markers
.dirty

View File

@@ -65,6 +65,11 @@
<artifactId>cron-utils</artifactId>
<version>9.2.1</version>
</dependency>
<dependency>
<groupId>com.huaban</groupId>
<artifactId>jieba-analysis</artifactId>
<version>1.0.2</version>
</dependency>
</dependencies>
<properties>

View File

@@ -12,6 +12,7 @@ public abstract class VectorClient {
public static boolean status = false;
public static VectorClient INSTANCE;
public static String VECTOR_MODEL_ID;
public static void startClient(VectorConfig config) {
try {
@@ -23,6 +24,7 @@ public abstract class VectorClient {
return;
}
status = true;
VECTOR_MODEL_ID = config.modelId;
} catch (VectorClientStartupException e) {
throw e;
} catch (VectorClientExecutionException e) {

View File

@@ -39,7 +39,7 @@ public class VectorClientRegistry implements Configurable, ConfigRegistration<Ve
@Nullable
@Override
public VectorConfig defaultConfig() {
return new VectorConfig(false, null);
return new VectorConfig(false, null, null);
}
@Override

View File

@@ -5,10 +5,16 @@ import work.slhaf.partner.framework.agent.config.Config;
public sealed class VectorConfig extends Config permits VectorConfig.Ollama, VectorConfig.Onnx {
final boolean enabled;
final Type type;
final String modelId;
public VectorConfig(boolean enabled, Type type) {
public VectorConfig(boolean enabled, Type type, String modelId) {
this.enabled = enabled;
this.type = type;
this.modelId = modelId;
}
protected static String fallbackModelId(String modelId, String fallback) {
return modelId == null || modelId.isBlank() ? fallback : modelId;
}
public enum Type {
@@ -21,8 +27,8 @@ public sealed class VectorConfig extends Config permits VectorConfig.Ollama, Vec
final String tokenizerPath;
final String embeddingModelPath;
public Onnx(boolean enabled, Type type, String tokenizerPath, String embeddingModelPath) {
super(enabled, type);
public Onnx(boolean enabled, Type type, String tokenizerPath, String embeddingModelPath, String modelId) {
super(enabled, type, fallbackModelId(modelId, embeddingModelPath));
this.tokenizerPath = tokenizerPath;
this.embeddingModelPath = embeddingModelPath;
}
@@ -33,12 +39,10 @@ public sealed class VectorConfig extends Config permits VectorConfig.Ollama, Vec
final String ollamaEmbeddingUrl;
final String ollamaEmbeddingModel;
public Ollama(boolean enabled, Type type, String ollamaEmbeddingUrl, String ollamaEmbeddingModel) {
super(enabled, type);
public Ollama(boolean enabled, Type type, String ollamaEmbeddingUrl, String ollamaEmbeddingModel, String modelId) {
super(enabled, type, fallbackModelId(modelId, ollamaEmbeddingModel));
this.ollamaEmbeddingUrl = ollamaEmbeddingUrl;
this.ollamaEmbeddingModel = ollamaEmbeddingModel;
}
}
}
}

View File

@@ -2,10 +2,14 @@ package work.slhaf.partner.core.cognition;
import org.w3c.dom.Element;
import work.slhaf.partner.core.cognition.context.ContextWorkspace;
import work.slhaf.partner.core.cognition.impression.ActiveEntity;
import work.slhaf.partner.core.cognition.impression.Entity;
import work.slhaf.partner.framework.agent.factory.capability.annotation.Capability;
import work.slhaf.partner.framework.agent.model.pojo.Message;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.locks.Lock;
@Capability("cognition")
@@ -27,4 +31,60 @@ public interface CognitionCapability {
Lock getMessageLock();
/**
* Project user input onto known or currently active entities and append the input as runtime evidence.
*/
Set<ActiveEntity> projectEntity(String input);
/**
* Return current active entities with their bound known entities when available.
* ActiveEntity values are snapshots; Entity values are live known-entity references and should be updated through this capability.
*/
Map<ActiveEntity, Entity> showEntities();
/**
* Create and register a new known entity by subject, then refresh search indexes for it.
*/
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

@@ -3,18 +3,30 @@ package work.slhaf.partner.core.cognition.impression
import org.w3c.dom.Document
import org.w3c.dom.Element
import work.slhaf.partner.core.cognition.context.BlockContent
import java.time.Instant
import java.time.ZoneId
import java.util.*
import java.util.concurrent.atomic.AtomicReference
class ActiveEntity @JvmOverloads constructor(
timestamp: Long = System.currentTimeMillis(),
private val _evidences: MutableList<String> = mutableListOf(),
) : BlockContent("active_entity_$timestamp", "impression") {
val evidences: List<String>
val runtimeId: String = newActiveEntityRuntimeId(),
val createdAt: Instant = Instant.now(),
boundEntityUuid: String? = null,
private val _evidences: MutableList<EntityEvidence> = mutableListOf(),
) : BlockContent("active_entity_$runtimeId", "impression") {
val evidences: List<EntityEvidence>
get() = synchronized(_evidences) { _evidences.toList() }
@Volatile
var lastMentionedAt: Instant = createdAt
private set
private val _subject = AtomicReference("UNKNOWN")
val subject: String get() = _subject.get()
private val _boundEntityUuid = AtomicReference<String?>(boundEntityUuid)
val boundEntityUuid: String? get() = _boundEntityUuid.get()
private val _projectedFeatures: MutableMap<String, Double> = mutableMapOf()
val projectedFeatures: Map<String, Double>
get() = synchronized(_projectedFeatures) { _projectedFeatures.toMap() }
@@ -23,12 +35,27 @@ class ActiveEntity @JvmOverloads constructor(
val projectedImpressions: Map<String, Double>
get() = synchronized(_projectedImpressions) { _projectedImpressions.toMap() }
fun addEvidence(evidence: String) = synchronized(_evidences) {
@JvmOverloads
fun addEvidence(
content: String,
associationConfidence: Double = 1.0,
source: EntityEvidence.Source = EntityEvidence.Source.USER_INPUT,
timestamp: Long = System.currentTimeMillis(),
) = addEvidence(EntityEvidence(content, associationConfidence, source, timestamp))
fun addEvidence(evidence: EntityEvidence) = synchronized(_evidences) {
_evidences.add(evidence)
touch(Instant.ofEpochMilli(evidence.timestamp))
}
fun updateSubject(subject: String) = _subject.set(subject)
fun bindEntity(uuid: String?) = _boundEntityUuid.set(uuid)
fun touch(time: Instant = Instant.now()) {
lastMentionedAt = time
}
fun addProjectedFeatures(vararg features: Pair<String, Double>) = synchronized(_projectedFeatures) {
features.forEach { _projectedFeatures[it.first] = it.second }
}
@@ -37,7 +64,29 @@ class ActiveEntity @JvmOverloads constructor(
impressions.forEach { _projectedImpressions[it.first] = it.second }
}
/**
* Creates a detached runtime snapshot for external inspection without exposing mutable internal collections.
*/
fun snapshot(): ActiveEntity {
val copied = ActiveEntity(
runtimeId = runtimeId,
createdAt = createdAt,
boundEntityUuid = boundEntityUuid,
_evidences = synchronized(_evidences) { _evidences.toMutableList() },
)
copied.updateSubject(subject)
copied.touch(lastMentionedAt)
copied.addProjectedFeatures(*projectedFeatures.entries.map { it.key to it.value }.toTypedArray())
copied.addProjectedImpressions(*projectedImpressions.entries.map { it.key to it.value }.toTypedArray())
return copied
}
override fun fillXml(document: Document, root: Element) {
root.setAttribute("runtime_id", runtimeId)
boundEntityUuid?.let { root.setAttribute("bound_entity_uuid", it) }
root.setAttribute("created_at", modelTime(createdAt))
root.setAttribute("last_mentioned_at", modelTime(lastMentionedAt))
appendTextElement(document, root, "subject", subject)
appendListElement(
@@ -46,7 +95,14 @@ class ActiveEntity @JvmOverloads constructor(
"evidences",
"evidence",
synchronized(_evidences) { _evidences.toList() }
)
) { evidence ->
setAttribute("association_confidence", evidence.associationConfidence.toString())
setAttribute("source", evidence.source.name)
setAttribute("timestamp", evidence.timestamp.toString())
setAttribute("truncated", evidence.isContentTruncated().toString())
setAttribute("original_length", evidence.content.length.toString())
textContent = evidence.contentForContext()
}
appendListElement(
document,
@@ -70,4 +126,20 @@ class ActiveEntity @JvmOverloads constructor(
textContent = entry.key
}
}
}
private fun modelTime(time: Instant): String =
time.atZone(ZoneId.systemDefault()).toString()
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is ActiveEntity) return false
return runtimeId == other.runtimeId
}
override fun hashCode(): Int {
return runtimeId.hashCode()
}
}
private fun newActiveEntityRuntimeId(): String =
UUID.randomUUID().toString().replace("-", "").take(12)

View File

@@ -1,9 +1,12 @@
package work.slhaf.partner.core.cognition.impression
import com.alibaba.fastjson2.JSONArray
import com.alibaba.fastjson2.JSONObject
import work.slhaf.partner.framework.agent.state.State
import work.slhaf.partner.framework.agent.state.StateSerializable
import work.slhaf.partner.framework.agent.state.StateValue
import java.nio.file.Path
import java.time.Instant
import java.util.*
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.locks.ReentrantLock
@@ -11,15 +14,67 @@ import kotlin.concurrent.withLock
class Entity @JvmOverloads constructor(
val uuid: String = UUID.randomUUID().toString(),
val subject: String,
subject: String,
private val relations: MutableMap<String, MutableMap<String, Double>> = mutableMapOf(),
private val impressions: MutableMap<String, IndexableData> = mutableMapOf(),
private val features: MutableMap<String, IndexableData> = mutableMapOf()
private val features: MutableMap<String, IndexableData> = mutableMapOf(),
private val aliases: MutableMap<String, AliasMetadata> = mutableMapOf()
) : StateSerializable {
private var _subject: String = normalizeIdentityText(subject)
private val impressionLock = ReentrantLock()
private val relationLock = ReentrantLock()
private val featureLock = ReentrantLock()
private val identityLock = ReentrantLock()
val subject: String
get() = identityLock.withLock { _subject }
@JvmOverloads
fun renameSubject(newSubject: String, keepOldSubjectAsAlias: Boolean = true): Boolean = identityLock.withLock {
val normalizedSubject = normalizeIdentityText(newSubject)
if (normalizedSubject.isBlank() || normalizedSubject == _subject) {
return@withLock false
}
val previousSubject = _subject
if (keepOldSubjectAsAlias && previousSubject.isNotBlank()) {
aliases[previousSubject] = aliases[previousSubject]?.copy(deprecated = true)
?: AliasMetadata(Instant.now(), deprecated = true)
}
aliases.remove(normalizedSubject)
_subject = normalizedSubject
true
}
@JvmOverloads
fun addAlias(alias: String, deprecated: Boolean = false): Boolean = identityLock.withLock {
val normalizedAlias = normalizeIdentityText(alias)
if (normalizedAlias.isBlank() || normalizedAlias == _subject) {
return@withLock false
}
aliases[normalizedAlias] = aliases[normalizedAlias]?.copy(deprecated = deprecated)
?: AliasMetadata(Instant.now(), deprecated)
true
}
@JvmOverloads
fun showAliases(includeDeprecated: Boolean = false): Set<AliasView> = identityLock.withLock {
aliases.asSequence()
.filter { (_, metadata) -> includeDeprecated || !metadata.deprecated }
.map { (alias, metadata) ->
AliasView(alias, metadata.instant, metadata.deprecated)
}
.sortedWith(compareBy<AliasView> { it.createdAt }.thenBy { it.alias })
.toCollection(LinkedHashSet())
}
fun snapshotAliases(): Map<String, AliasMetadata> = identityLock.withLock {
aliases.mapValues { (_, metadata) -> metadata.copy() }
}
@JvmOverloads
fun updateRelation(
@@ -109,7 +164,6 @@ class Entity @JvmOverloads constructor(
}.toSet()
}
fun showFeatures(): Set<FeatureView> = featureLock.withLock {
features.map {
FeatureView(
@@ -119,33 +173,191 @@ class Entity @JvmOverloads constructor(
}.toSet()
}
fun snapshotImpressions(): Map<String, IndexableData> = impressionLock.withLock {
impressions.toMap()
}
fun snapshotFeatures(): Map<String, IndexableData> = featureLock.withLock {
features.toMap()
}
override fun statePath(): Path = Path.of("core", "impression", "entity-$uuid.json")
override fun load(state: JSONObject) {
TODO("Not yet implemented")
state.getJSONObject("relations")?.let { loadedRelations ->
relationLock.withLock {
relations.clear()
loadedRelations.forEach { (target, relationValue) ->
val relationObject = relationValue as? JSONObject ?: return@forEach
val relationMap = mutableMapOf<String, Double>()
relationObject.forEach { (relation, strengthValue) ->
doubleValue(strengthValue)?.let { relationMap[relation] = it }
}
if (relationMap.isNotEmpty()) {
relations[target] = relationMap
}
}
}
}
state.getJSONObject("impressions")?.let { loadedImpressions ->
impressionLock.withLock {
impressions.clear()
impressions.putAll(loadIndexableDataMap(loadedImpressions))
}
}
identityLock.withLock {
state.getString("subject")
?.let(::normalizeIdentityText)
?.takeIf(String::isNotBlank)
?.let { _subject = it }
}
state.getJSONObject("features")?.let { loadedFeatures ->
featureLock.withLock {
features.clear()
features.putAll(loadIndexableDataMap(loadedFeatures))
}
}
state.getJSONObject("aliases")?.let { loadedAliases ->
identityLock.withLock {
aliases.clear()
loadedAliases.forEach { (alias, metadataValue) ->
val normalizedAlias = normalizeIdentityText(alias)
if (normalizedAlias.isBlank() || normalizedAlias == _subject) {
return@forEach
}
val metadata = when (metadataValue) {
is JSONObject -> loadAliasMetadata(metadataValue)
else -> AliasMetadata(Instant.now(), deprecated = false)
}
aliases[normalizedAlias] = metadata
}
}
}
}
override fun convert(): State {
TODO("Not yet implemented")
val state = State()
state.append("uuid", StateValue.str(uuid))
val identityState = identityLock.withLock {
IdentityState(
subject = _subject,
aliases = aliases.mapValues { (_, metadata) ->
mapOf(
"timestamp" to metadata.instant.toEpochMilli(),
"deprecated" to metadata.deprecated
)
}
)
}
state.append("subject", StateValue.str(identityState.subject))
state.append("aliases", StateValue.obj(identityState.aliases))
val relationState = relationLock.withLock {
relations.mapValues { (_, relationMap) -> relationMap.toMap() }
}
state.append("relations", StateValue.obj(relationState))
val impressionState = impressionLock.withLock {
indexableDataState(impressions)
}
state.append("impressions", StateValue.obj(impressionState))
val featureState = featureLock.withLock {
indexableDataState(features)
}
state.append("features", StateValue.obj(featureState))
return state
}
override fun autoLoadOnRegister(): Boolean = false
private fun normalizeIdentityText(value: String): String =
value.replace(IDENTITY_WHITESPACE_REGEX, " ").trim()
private fun loadAliasMetadata(state: JSONObject): AliasMetadata {
val instant = state.getLong("timestamp")
?.let(Instant::ofEpochMilli)
?: state.getString("instant")
?.let { runCatching { Instant.parse(it) }.getOrNull() }
?: Instant.now()
return AliasMetadata(
instant = instant,
deprecated = state.getBoolean("deprecated") ?: false
)
}
private fun loadIndexableDataMap(state: JSONObject): Map<String, IndexableData> {
val loaded = mutableMapOf<String, IndexableData>()
state.forEach { (key, value) ->
val data = when (value) {
is JSONObject -> loadIndexableData(value)
else -> IndexableData(doubleValue(value) ?: return@forEach)
}
loaded[key] = data
}
return loaded
}
private fun loadIndexableData(state: JSONObject): IndexableData {
val data = IndexableData(state.getDouble("confidence") ?: 1.0)
state.getJSONObject("vectors")?.forEach { (embeddingModel, vectorValue) ->
val vectorArray = vectorValue as? JSONArray ?: return@forEach
val vector = FloatArray(vectorArray.size)
for (index in vectorArray.indices) {
vector[index] = floatValue(vectorArray[index]) ?: return@forEach
}
data.updateVector(embeddingModel, vector)
}
return data
}
private fun indexableDataState(source: Map<String, IndexableData>): Map<String, Map<String, Any>> =
source.mapValues { (_, data) ->
mapOf(
"confidence" to data.confidence,
"vectors" to data.snapshotVectors().mapValues { (_, vector) -> vector.toList() }
)
}
private fun doubleValue(value: Any?): Double? = when (value) {
is Number -> value.toDouble()
is String -> value.toDoubleOrNull()
else -> null
}
private fun floatValue(value: Any?): Float? = when (value) {
is Number -> value.toFloat()
is String -> value.toFloatOrNull()
else -> null
}
data class IndexableData(
var confidence: Double
) {
private val vectors: ConcurrentHashMap<String, DoubleArray> = ConcurrentHashMap()
private val vectors: ConcurrentHashMap<String, FloatArray> = ConcurrentHashMap()
fun updateVector(
embeddingModel: String,
vector: DoubleArray
vector: FloatArray
) {
vectors[embeddingModel] = vector
vectors[embeddingModel] = vector.copyOf()
}
fun getVector(embeddingModel: String): DoubleArray? {
fun getVector(embeddingModel: String): FloatArray? {
return vectors[embeddingModel]?.copyOf()
}
fun snapshotVectors(): Map<String, FloatArray> {
return vectors.mapValues { (_, vector) -> vector.copyOf() }
}
}
data class RelationView(
@@ -162,6 +374,26 @@ class Entity @JvmOverloads constructor(
data class ImpressionView(
val impression: String,
val confidence: Double,
val vector: DoubleArray?
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

@@ -0,0 +1,112 @@
package work.slhaf.partner.core.cognition.impression
/**
* Runtime evidence associated with an active entity.
*
* The confidence describes how strongly this evidence is associated with the
* current active entity, not whether the evidence content itself is true.
*/
data class EntityEvidence @JvmOverloads constructor(
val content: String,
val associationConfidence: Double = 1.0,
val source: Source = Source.USER_INPUT,
val timestamp: Long = System.currentTimeMillis(),
) {
enum class Source {
USER_INPUT,
ASSISTANT_REPLY
}
fun isContentTruncated(maxLength: Int = CONTEXT_CONTENT_MAX_LENGTH): Boolean =
content.length > maxLength
fun contentForContext(maxLength: Int = CONTEXT_CONTENT_MAX_LENGTH): String {
if (content.length <= maxLength) {
return content
}
val available = maxLength - OMITTED_MARKER.length
if (available <= 0) {
return content.take(maxLength)
}
val headBudget = available / 2
val tailBudget = available - headBudget
val headEnd = adjustHeadEnd(content, headBudget)
val tailStart = adjustTailStart(content, content.length - tailBudget)
if (tailStart <= headEnd) {
return content.take(maxLength).trimEnd()
}
return content.substring(0, headEnd).trimEnd() +
OMITTED_MARKER +
content.substring(tailStart).trimStart()
}
private fun adjustHeadEnd(source: String, preferredEnd: Int): Int {
val safePreferredEnd = preferredEnd.coerceIn(0, source.length)
findForwardBoundary(source, safePreferredEnd, STRONG_BOUNDARY_SEARCH_WINDOW, ::isStrongBoundary)?.let {
return it + 1
}
findForwardBoundary(source, safePreferredEnd, SOFT_BOUNDARY_SEARCH_WINDOW, ::isSoftBoundary)?.let {
return it + 1
}
return safePreferredEnd
}
private fun adjustTailStart(source: String, preferredStart: Int): Int {
val safePreferredStart = preferredStart.coerceIn(0, source.length)
findBackwardBoundary(source, safePreferredStart, STRONG_BOUNDARY_SEARCH_WINDOW, ::isStrongBoundary)?.let {
return it
}
findBackwardBoundary(source, safePreferredStart, SOFT_BOUNDARY_SEARCH_WINDOW, ::isSoftBoundary)?.let {
return it
}
return safePreferredStart
}
private fun findForwardBoundary(
source: String,
start: Int,
window: Int,
predicate: (Char) -> Boolean,
): Int? {
val end = (start + window).coerceAtMost(source.length)
for (index in start until end) {
if (predicate(source[index])) {
return index
}
}
return null
}
private fun findBackwardBoundary(
source: String,
start: Int,
window: Int,
predicate: (Char) -> Boolean,
): Int? {
val end = (start - window).coerceAtLeast(0)
for (index in start downTo end + 1) {
if (predicate(source[index - 1])) {
return index
}
}
return null
}
private fun isStrongBoundary(char: Char): Boolean = char == '\n'
private fun isSoftBoundary(char: Char): Boolean = when (char) {
'。', '', '', ';', '', '.' -> true
else -> false
}
companion object {
const val CONTEXT_CONTENT_MAX_LENGTH = 480
private const val OMITTED_MARKER = "\n...[omitted]...\n"
private const val STRONG_BOUNDARY_SEARCH_WINDOW = 120
private const val SOFT_BOUNDARY_SEARCH_WINDOW = 80
}
}

View File

@@ -3,6 +3,7 @@ package work.slhaf.partner.core.cognition.impression;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import org.jetbrains.annotations.NotNull;
import work.slhaf.partner.core.cognition.impression.search.*;
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.state.State;
@@ -10,10 +11,9 @@ import work.slhaf.partner.framework.agent.state.StateSerializable;
import work.slhaf.partner.framework.agent.state.StateValue;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
@CapabilityCore(value = "cognition")
public class ImpressionCore implements StateSerializable {
@@ -22,21 +22,427 @@ public class ImpressionCore implements StateSerializable {
* Keyed by entity uuid. Subject can be revised or merged later, so it should not be used as the stable key.
*/
private final ConcurrentHashMap<String, Entity> knownEntitiesByUuid = new ConcurrentHashMap<>();
private final ImpressionVectorIndex vectorIndex = new ImpressionVectorIndex();
private final Set<ActiveEntity> activeEntities = new HashSet<>();
private final ImpressionTextSearch textSearch = new SimpleTextSearch();
private static final int TEXT_SEARCH_LIMIT = 20;
private static final int ASSOCIATION_MATCH_LIMIT = 8;
private static final double SUPPORTING_HIT_FACTOR = 0.3;
private static final double ASSOCIATION_CONFIDENCE_DIVISOR = 5.0;
/**
* 根据新的 Input 召回相关的实体,如果实体已重复,则将输入追加到 ActiveEntity 的证据中。
*
* @param input 本次输入内容
* @return 本次被召回的活跃实体(包括重复的实体)
*/
@CapabilityMethod
public void updateRelation() {
public Set<ActiveEntity> projectEntity(String input) {
if (input == null || input.isBlank()) {
return Set.of();
}
List<ImpressionSearchHit> textSearchHits = textSearch.search(input, TEXT_SEARCH_LIMIT);
List<EntityAssociationMatch> associationMatches = aggregateMatches(textSearchHits, ASSOCIATION_MATCH_LIMIT);
if (associationMatches.isEmpty()) {
return Set.of();
}
Set<ActiveEntity> projected = new HashSet<>();
for (EntityAssociationMatch match : associationMatches) {
Optional<ActiveEntity> activeEntity = resolveActiveEntity(match.getTarget());
if (activeEntity.isEmpty()) {
continue;
}
ActiveEntity entity = activeEntity.get();
entity.addEvidence(
input,
associationConfidence(match),
EntityEvidence.Source.USER_INPUT
);
refreshActiveEntityTextSearch(entity);
projected.add(entity);
}
return projected;
}
/**
* 列出当前已存在的 ActiveEntity 以及对应的 Entity。ActiveEntity 返回快照Entity 返回当前已知实体引用。
*
* 注意:外部模块不要直接修改返回的 Entity否则文本索引 / 向量索引不会刷新。
* Impression 更新应走 updateEntity* 系列接口。
*
* @return ActiveEntity 快照与已绑定 Entity 的映射
*/
@CapabilityMethod
public void updateImpression() {
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 void showImpressions() {
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 void projectEntity(Set<ActiveEntity> activeEntities) {
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(
List<ImpressionSearchHit> hits,
int limit
) {
if (hits == null || hits.isEmpty() || limit <= 0) {
return List.of();
}
return hits.stream()
.collect(Collectors.groupingBy(
hit -> hit.getDocument().getTarget(),
LinkedHashMap::new,
Collectors.toList()
))
.entrySet()
.stream()
.map(entry -> {
List<ImpressionSearchHit> sortedHits = entry.getValue()
.stream()
.sorted(Comparator
.comparingDouble(ImpressionSearchHit::getScore)
.reversed()
.thenComparing(hit -> hit.getDocument().getId()))
.toList();
return new EntityAssociationMatch(
entry.getKey(),
aggregateScore(sortedHits),
sortedHits
);
})
.sorted(Comparator
.comparingDouble(EntityAssociationMatch::getScore)
.reversed()
.thenComparing(match -> match.getTarget().getType().name())
.thenComparing(match -> match.getTarget().getId()))
.limit(limit)
.toList();
}
private double aggregateScore(List<ImpressionSearchHit> sortedHits) {
if (sortedHits.isEmpty()) {
return 0.0;
}
double bestHitScore = sortedHits.getFirst().getScore();
double supportingScore = sortedHits.stream()
.skip(1)
.limit(2)
.mapToDouble(hit -> hit.getScore() * SUPPORTING_HIT_FACTOR)
.sum();
return bestHitScore + supportingScore;
}
private Optional<ActiveEntity> resolveActiveEntity(ImpressionSearchTarget target) {
return switch (target.getType()) {
case ACTIVE_ENTITY -> findActiveEntityByRuntimeId(target.getId());
case ENTITY -> activateKnownEntityLive(target.getId());
};
}
private Optional<ActiveEntity> findActiveEntityByRuntimeId(String runtimeId) {
synchronized (activeEntities) {
return activeEntities.stream()
.filter(activeEntity -> activeEntity.getRuntimeId().equals(runtimeId))
.findFirst();
}
}
private Optional<ActiveEntity> findActiveEntityByBoundEntityUuid(String uuid) {
synchronized (activeEntities) {
return activeEntities.stream()
.filter(activeEntity -> uuid.equals(activeEntity.getBoundEntityUuid()))
.findFirst();
}
}
private Optional<ActiveEntity> activateKnownEntityLive(String uuid) {
Entity knownEntity = knownEntitiesByUuid.get(uuid);
if (knownEntity == null) {
return Optional.empty();
}
Optional<ActiveEntity> existing = findActiveEntityByBoundEntityUuid(uuid);
if (existing.isPresent()) {
return existing;
}
ActiveEntity activeEntity = new ActiveEntity();
activeEntity.updateSubject(knownEntity.getSubject());
activeEntity.bindEntity(uuid);
synchronized (activeEntities) {
activeEntities.add(activeEntity);
}
refreshActiveEntityTextSearch(activeEntity);
return Optional.of(activeEntity);
}
private double associationConfidence(EntityAssociationMatch match) {
double normalized = match.getScore() / ASSOCIATION_CONFIDENCE_DIVISOR;
return Math.clamp(normalized, 0.05, 1.0);
}
private void refreshActiveEntityTextSearch(ActiveEntity activeEntity) {
ImpressionSearchTarget target = new ImpressionSearchTarget(
ImpressionSearchTarget.Type.ACTIVE_ENTITY,
activeEntity.getRuntimeId()
);
textSearch.removeByTarget(target);
for (ImpressionSearchDocument document : ImpressionSearchDocuments.INSTANCE.fromActiveEntity(activeEntity)) {
textSearch.upsert(document);
}
}
/**
* Refresh every index derived from a known entity after mutation.
*/
private void refreshKnownEntityIndexes(Entity entity) {
vectorIndex.sync(entity);
refreshKnownEntityTextSearch(entity);
}
private void syncBoundActiveEntitySubjects(Entity entity) {
List<ActiveEntity> boundEntities;
synchronized (activeEntities) {
boundEntities = activeEntities.stream()
.filter(activeEntity -> entity.getUuid().equals(activeEntity.getBoundEntityUuid()))
.toList();
}
boundEntities.forEach(activeEntity -> {
activeEntity.updateSubject(entity.getSubject());
refreshActiveEntityTextSearch(activeEntity);
});
}
/**
* Replace text-search documents for one known entity.
*/
private void refreshKnownEntityTextSearch(Entity entity) {
ImpressionSearchTarget target = new ImpressionSearchTarget(
ImpressionSearchTarget.Type.ENTITY,
entity.getUuid()
);
textSearch.removeByTarget(target);
for (ImpressionSearchDocument document : ImpressionSearchDocuments.INSTANCE.fromEntity(entity)) {
textSearch.upsert(document);
}
}
private void rebuildTextSearch() {
List<ImpressionSearchDocument> documents = new ArrayList<>();
knownEntitiesByUuid.values().forEach(entity ->
documents.addAll(ImpressionSearchDocuments.INSTANCE.fromEntity(entity))
);
synchronized (activeEntities) {
activeEntities.forEach(activeEntity ->
documents.addAll(ImpressionSearchDocuments.INSTANCE.fromActiveEntity(activeEntity))
);
}
textSearch.rebuild(documents);
}
@Override
@@ -65,12 +471,14 @@ public class ImpressionCore implements StateSerializable {
}
Entity entity = new Entity(uuid, subject);
entity.register();
entity.load();
vectorIndex.sync(entity);
knownEntitiesByUuid.put(uuid, entity);
}
rebuildTextSearch();
}
@Override
public @NotNull State convert() {
State state = new State();

View File

@@ -0,0 +1,38 @@
package work.slhaf.partner.core.cognition.impression;
import work.slhaf.partner.common.vector.VectorClient;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
public class ImpressionVectorIndex {
private final Executor executor = Executors.newFixedThreadPool(2, r -> {
Thread thread = new Thread(r, "impression-vector-index");
thread.setDaemon(true);
return thread;
});
public void sync(Entity entity) {
if (!VectorClient.status){
return;
}
entity.snapshotFeatures().forEach(this::upsert);
entity.snapshotImpressions().forEach(this::upsert);
}
public void upsert(String text, Entity.IndexableData indexableData){
if (!VectorClient.status){
return;
}
String modelId = VectorClient.VECTOR_MODEL_ID;
if (indexableData.getVector(modelId) != null) {
return;
}
executor.execute(() -> {
float[] vector = VectorClient.INSTANCE.compute(text);
indexableData.updateVector(modelId,vector);
});
}
}

View File

@@ -0,0 +1,7 @@
package work.slhaf.partner.core.cognition.impression.search
data class EntityAssociationMatch(
val target: ImpressionSearchTarget,
val score: Double,
val hits: List<ImpressionSearchHit> = emptyList(),
)

View File

@@ -0,0 +1,10 @@
package work.slhaf.partner.core.cognition.impression.search
data class ImpressionSearchDocument(
val id: String,
val target: ImpressionSearchTarget,
val field: ImpressionSearchField,
val text: String,
val weight: Double = 1.0,
val metadata: Map<String, String> = emptyMap(),
)

View File

@@ -0,0 +1,151 @@
package work.slhaf.partner.core.cognition.impression.search
import work.slhaf.partner.core.cognition.impression.ActiveEntity
import work.slhaf.partner.core.cognition.impression.Entity
object ImpressionSearchDocuments {
fun fromActiveEntity(activeEntity: ActiveEntity): List<ImpressionSearchDocument> {
val target = ImpressionSearchTarget(
ImpressionSearchTarget.Type.ACTIVE_ENTITY,
activeEntity.runtimeId
)
val metadata = activeEntity.boundEntityUuid
?.let { mapOf("boundEntityUuid" to it) }
.orEmpty()
return buildList {
add(
ImpressionSearchDocument(
id = "active:${activeEntity.runtimeId}:subject",
target = target,
field = ImpressionSearchField.SUBJECT,
text = activeEntity.subject,
weight = SUBJECT_WEIGHT,
metadata = metadata,
)
)
activeEntity.evidences.forEachIndexed { index, evidence ->
add(
ImpressionSearchDocument(
id = "active:${activeEntity.runtimeId}:evidence:$index",
target = target,
field = ImpressionSearchField.EVIDENCE,
text = evidence.contentForContext(),
weight = EVIDENCE_WEIGHT * evidence.associationConfidence,
metadata = metadata,
)
)
}
activeEntity.projectedFeatures.entries.forEachIndexed { index, entry ->
add(
ImpressionSearchDocument(
id = "active:${activeEntity.runtimeId}:feature:$index",
target = target,
field = ImpressionSearchField.FEATURE,
text = entry.key,
weight = FEATURE_WEIGHT * entry.value,
metadata = metadata,
)
)
}
activeEntity.projectedImpressions.entries.forEachIndexed { index, entry ->
add(
ImpressionSearchDocument(
id = "active:${activeEntity.runtimeId}:impression:$index",
target = target,
field = ImpressionSearchField.IMPRESSION,
text = entry.key,
weight = IMPRESSION_WEIGHT * entry.value,
metadata = metadata,
)
)
}
}
}
fun fromEntity(entity: Entity): List<ImpressionSearchDocument> {
val target = ImpressionSearchTarget(
ImpressionSearchTarget.Type.ENTITY,
entity.uuid
)
return buildList {
add(
ImpressionSearchDocument(
id = "entity:${entity.uuid}:subject",
target = target,
field = ImpressionSearchField.SUBJECT,
text = entity.subject,
weight = SUBJECT_WEIGHT,
)
)
entity.showAliases(includeDeprecated = true).forEachIndexed { index, alias ->
add(
ImpressionSearchDocument(
id = "entity:${entity.uuid}:alias:$index",
target = target,
field = ImpressionSearchField.SUBJECT,
text = alias.alias,
weight = SUBJECT_WEIGHT * ALIAS_WEIGHT_FACTOR,
)
)
}
entity.snapshotFeatures().keys.forEachIndexed { index, feature ->
add(
ImpressionSearchDocument(
id = "entity:${entity.uuid}:feature:$index",
target = target,
field = ImpressionSearchField.FEATURE,
text = feature,
weight = FEATURE_WEIGHT,
)
)
}
entity.snapshotImpressions().keys.forEachIndexed { index, impression ->
add(
ImpressionSearchDocument(
id = "entity:${entity.uuid}:impression:$index",
target = target,
field = ImpressionSearchField.IMPRESSION,
text = impression,
weight = IMPRESSION_WEIGHT,
)
)
}
entity.showRelations().forEachIndexed { index, relation ->
val relationText = buildString {
append(relation.target)
relation.relations.keys.forEach { name ->
append(' ')
append(name)
}
}
add(
ImpressionSearchDocument(
id = "entity:${entity.uuid}:relation:$index",
target = target,
field = ImpressionSearchField.RELATION,
text = relationText,
weight = RELATION_WEIGHT,
)
)
}
}
}
private const val SUBJECT_WEIGHT = 1.0
private const val ALIAS_WEIGHT_FACTOR = 0.9
private const val FEATURE_WEIGHT = 0.85
private const val IMPRESSION_WEIGHT = 0.75
private const val RELATION_WEIGHT = 0.65
private const val EVIDENCE_WEIGHT = 0.8
}

View File

@@ -0,0 +1,9 @@
package work.slhaf.partner.core.cognition.impression.search
enum class ImpressionSearchField {
SUBJECT,
FEATURE,
IMPRESSION,
RELATION,
EVIDENCE
}

View File

@@ -0,0 +1,7 @@
package work.slhaf.partner.core.cognition.impression.search
data class ImpressionSearchHit(
val document: ImpressionSearchDocument,
val score: Double,
val matchedTerms: Set<String> = emptySet(),
)

View File

@@ -0,0 +1,11 @@
package work.slhaf.partner.core.cognition.impression.search
data class ImpressionSearchTarget(
val type: Type,
val id: String,
) {
enum class Type {
ACTIVE_ENTITY,
ENTITY
}
}

View File

@@ -0,0 +1,15 @@
package work.slhaf.partner.core.cognition.impression.search
interface ImpressionTextSearch {
fun rebuild(documents: Collection<ImpressionSearchDocument>)
fun upsert(document: ImpressionSearchDocument)
fun removeByTarget(target: ImpressionSearchTarget)
fun search(
query: String,
limit: Int = 20,
): List<ImpressionSearchHit>
}

View File

@@ -0,0 +1,5 @@
package work.slhaf.partner.core.cognition.impression.search
interface ImpressionTokenizer {
fun tokenize(text: String): Set<String>
}

View File

@@ -0,0 +1,37 @@
package work.slhaf.partner.core.cognition.impression.search
import com.huaban.analysis.jieba.JiebaSegmenter
class JiebaImpressionTokenizer(
private val segmenter: JiebaSegmenter = JiebaSegmenter(),
private val mode: JiebaSegmenter.SegMode = JiebaSegmenter.SegMode.SEARCH,
) : ImpressionTokenizer {
override fun tokenize(text: String): Set<String> {
val normalized = normalize(text)
if (normalized.isBlank()) {
return emptySet()
}
val jiebaTerms = segmenter.process(normalized, mode)
.asSequence()
.map { it.word }
.map(::normalize)
.filter { it.isNotBlank() }
return (jiebaTerms + alphaNumericTerms(normalized)).toSet()
}
private fun alphaNumericTerms(text: String): Sequence<String> =
ALPHA_NUMERIC_REGEX.findAll(text).map { it.value }
private fun normalize(text: String): String =
text.lowercase()
.replace(WHITESPACE_REGEX, " ")
.trim()
companion object {
private val WHITESPACE_REGEX = Regex("\\s+")
private val ALPHA_NUMERIC_REGEX = Regex("[a-z0-9]+(?:[-_./][a-z0-9]+)*")
}
}

View File

@@ -0,0 +1,136 @@
package work.slhaf.partner.core.cognition.impression.search
class SimpleTextSearch(
private val tokenizer: ImpressionTokenizer = JiebaImpressionTokenizer(),
) : ImpressionTextSearch {
private val documents = linkedMapOf<String, IndexedDocument>()
private val invertedIndex = linkedMapOf<String, MutableSet<String>>()
@Synchronized
override fun rebuild(documents: Collection<ImpressionSearchDocument>) {
this.documents.clear()
invertedIndex.clear()
documents.forEach(::upsertInternal)
}
@Synchronized
override fun upsert(document: ImpressionSearchDocument) {
removeByDocumentId(document.id)
upsertInternal(document)
}
@Synchronized
override fun removeByTarget(target: ImpressionSearchTarget) {
documents.values
.asSequence()
.filter { it.document.target == target }
.map { it.document.id }
.toList()
.forEach(::removeByDocumentId)
}
@Synchronized
override fun search(query: String, limit: Int): List<ImpressionSearchHit> {
if (limit <= 0) {
return emptyList()
}
val normalizedQuery = normalize(query)
if (normalizedQuery.isBlank()) {
return emptyList()
}
val queryTerms = tokenizer.tokenize(normalizedQuery)
val candidateIds = if (queryTerms.isEmpty()) {
documents.keys.toSet()
} else {
queryTerms
.asSequence()
.flatMap { invertedIndex[it].orEmpty().asSequence() }
.toSet()
}
return candidateIds
.asSequence()
.mapNotNull { documentId -> scoreDocument(documents[documentId] ?: return@mapNotNull null, normalizedQuery, queryTerms) }
.filter { it.score > 0.0 }
.sortedWith(compareByDescending<ImpressionSearchHit> { it.score }.thenBy { it.document.id })
.take(limit)
.toList()
}
private fun upsertInternal(document: ImpressionSearchDocument) {
val normalizedText = normalize(document.text)
val terms = tokenizer.tokenize(normalizedText)
val indexedDocument = IndexedDocument(document, normalizedText, terms)
documents[document.id] = indexedDocument
terms.forEach { term ->
invertedIndex.getOrPut(term) { linkedSetOf() }.add(document.id)
}
}
private fun removeByDocumentId(documentId: String) {
val indexedDocument = documents.remove(documentId) ?: return
indexedDocument.terms.forEach { term ->
val ids = invertedIndex[term] ?: return@forEach
ids.remove(documentId)
if (ids.isEmpty()) {
invertedIndex.remove(term)
}
}
}
private fun scoreDocument(
indexedDocument: IndexedDocument,
normalizedQuery: String,
queryTerms: Set<String>,
): ImpressionSearchHit? {
val matchedTerms = if (queryTerms.isEmpty()) {
emptySet()
} else {
queryTerms.intersect(indexedDocument.terms)
}
val exactPhraseMatched = indexedDocument.normalizedText.contains(normalizedQuery)
if (matchedTerms.isEmpty() && !exactPhraseMatched) {
return null
}
val coverage = if (queryTerms.isEmpty()) 0.0 else matchedTerms.size.toDouble() / queryTerms.size.toDouble()
val termScore = matchedTerms.size.toDouble()
val exactPhraseBonus = if (exactPhraseMatched) EXACT_PHRASE_BONUS else 0.0
val fieldBonus = fieldBonus(indexedDocument.document.field)
val score = (termScore + coverage + exactPhraseBonus + fieldBonus) * indexedDocument.document.weight
return ImpressionSearchHit(
document = indexedDocument.document,
score = score,
matchedTerms = matchedTerms,
)
}
private fun fieldBonus(field: ImpressionSearchField): Double = when (field) {
ImpressionSearchField.SUBJECT -> 0.8
ImpressionSearchField.FEATURE -> 0.35
ImpressionSearchField.IMPRESSION -> 0.25
ImpressionSearchField.RELATION -> 0.15
ImpressionSearchField.EVIDENCE -> 0.0
}
private fun normalize(text: String): String =
text.lowercase()
.replace(WHITESPACE_REGEX, " ")
.trim()
private data class IndexedDocument(
val document: ImpressionSearchDocument,
val normalizedText: String,
val terms: Set<String>,
)
companion object {
private const val EXACT_PHRASE_BONUS = 1.5
private val WHITESPACE_REGEX = Regex("\\s+")
}
}

View File

@@ -1,7 +1,7 @@
package work.slhaf.partner.core.memory;
import work.slhaf.partner.core.memory.pojo.MemorySlice;
import work.slhaf.partner.core.memory.pojo.MemoryUnit;
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.Capability;
import work.slhaf.partner.framework.agent.model.pojo.Message;
import work.slhaf.partner.framework.agent.support.Result;
@@ -12,13 +12,13 @@ import java.util.List;
@Capability(value = "memory")
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();

View File

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

View File

@@ -33,6 +33,16 @@ public class MemorySlice implements Comparable<MemorySlice> {
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
public int compareTo(MemorySlice memorySlice) {
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();
}
public MemoryUnitSnapshot snapshot() {
return new MemoryUnitSnapshot(
id,
List.copyOf(conversationMessages),
timestamp == null ? 0L : timestamp,
slices.stream().map(MemorySlice::snapshot).toList()
);
}
@Override
public @NotNull Path statePath() {
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.ContextBlock;
import work.slhaf.partner.core.memory.MemoryCapability;
import work.slhaf.partner.core.memory.pojo.MemorySlice;
import work.slhaf.partner.core.memory.pojo.MemoryUnit;
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.annotation.AgentComponent;
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 -> {
String unitId = BuiltinActionRegistry.BuiltinActionDefinition.requireString(params, "unit_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) {
return JSONObject.of(
"ok", false,
"message", sliceResult.exceptionOrNull().getLocalizedMessage()
).toJSONString();
}
MemorySlice slice = sliceResult.getOrThrow();
MemorySliceSnapshot slice = sliceResult.getOrThrow();
MemoryUnit unit = memoryCapability.getMemoryUnit(unitId);
MemoryUnitSnapshot unit = memoryCapability.getMemoryUnit(unitId);
cognitionCapability.contextWorkspace().register(new ContextBlock(
buildMemoryRecallFullBlock(unit, slice),
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") {
@Override
protected void fillXml(@NotNull Document document, @NotNull Element root) {
root.setAttribute("unit_id", unit.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.setTextContent(message.getContent());
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.ContextBlock;
import work.slhaf.partner.core.memory.MemoryCapability;
import work.slhaf.partner.core.memory.pojo.MemorySlice;
import work.slhaf.partner.core.memory.pojo.MemoryUnit;
import work.slhaf.partner.core.memory.pojo.MemorySliceSnapshot;
import work.slhaf.partner.core.memory.pojo.MemoryUnitSnapshot;
import work.slhaf.partner.core.perceive.PerceiveCapability;
import work.slhaf.partner.framework.agent.exception.AgentRuntimeException;
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.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
@@ -140,7 +141,7 @@ public class DialogRolling extends AbstractAgentModule.Running<PartnerRunningFlo
if (memoryId.isBlank()) {
return fullChatSnapshot;
}
MemoryUnit existingUnit = memoryCapability.getMemoryUnit(memoryId);
MemoryUnitSnapshot existingUnit = memoryCapability.getMemoryUnit(memoryId);
if (existingUnit.getConversationMessages().isEmpty()) {
return fullChatSnapshot;
}
@@ -158,8 +159,9 @@ public class DialogRolling extends AbstractAgentModule.Running<PartnerRunningFlo
@NotNull
RollingResult buildRollingResult(List<Message> chatSnapshot, int rollingSize, int retainDivisor) {
messageCompressor.execute(chatSnapshot);
Result<String> summaryResult = messageSummarizer.execute(chatSnapshot);
List<Message> rollingMessages = new ArrayList<>(chatSnapshot);
messageCompressor.execute(rollingMessages);
Result<String> summaryResult = messageSummarizer.execute(rollingMessages);
String summary = summaryResult.fold(
value -> value,
exp -> "no summary, due to exception"
@@ -167,20 +169,20 @@ public class DialogRolling extends AbstractAgentModule.Running<PartnerRunningFlo
if (summary.isBlank()) {
summary = "no summary, due to empty summarize result";
}
MemoryUnit memoryUnit = memoryCapability.updateMemoryUnit(chatSnapshot, summary);
MemorySlice newSlice = memoryUnit.getSlices().getLast();
return new RollingResult(memoryUnit, newSlice, List.copyOf(chatSnapshot), newSlice.getSummary(), rollingSize, retainDivisor);
MemoryUnitSnapshot memoryUnit = memoryCapability.updateMemoryUnit(rollingMessages, summary);
MemorySliceSnapshot newSlice = memoryUnit.getSlices().getLast();
return new RollingResult(memoryUnit, newSlice, rollingSize, retainDivisor);
}
private void applyRolling(RollingResult result) {
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),
20,
5,
10
));
cognitionCapability.rollChatMessagesWithSnapshot(result.rollingSize(), result.retainDivisor());
cognitionCapability.rollChatMessagesWithSnapshot(result.getRollingSize(), result.getRetainDivisor());
}
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,54 @@
package work.slhaf.partner.module.impression;
import lombok.val;
import org.jetbrains.annotations.NotNull;
import work.slhaf.partner.core.cognition.CognitionCapability;
import work.slhaf.partner.core.cognition.context.ContextBlock;
import work.slhaf.partner.framework.agent.factory.capability.annotation.InjectCapability;
import work.slhaf.partner.framework.agent.factory.component.abstracts.AbstractAgentModule;
import work.slhaf.partner.runtime.PartnerRunningFlowContext;
import java.util.Collection;
import java.util.Set;
import java.util.stream.Collectors;
public class ImpressionRecaller extends AbstractAgentModule.Running<PartnerRunningFlowContext> {
@InjectCapability
private CognitionCapability cognitionCapability;
/**
* 从交互中积累谈论的内容的特征(证据),基于证据创建 ActiveEntity然后交给 CognitionCapability 进行投影并更新上下文
*/
@Override
protected void doExecute(@NotNull PartnerRunningFlowContext context) {
val contextWorkspace = cognitionCapability.contextWorkspace();
context.getInputs()
.stream()
.map(inputEntry -> {
val content = inputEntry.getContent();
return cognitionCapability.projectEntity(content);
})
.flatMap(Collection::stream)
.collect(Collectors.toSet())
.forEach(activeEntity -> {
contextWorkspace.register(new ContextBlock(
activeEntity,
activeEntity,
activeEntity,
Set.of(
ContextBlock.FocusedDomain.COGNITION,
ContextBlock.FocusedDomain.MEMORY
),
100,
0.5,
20
));
});
}
@Override
public int order() {
return 2;
}
}

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 work.slhaf.partner.core.cognition.CognitionCapability;
import work.slhaf.partner.core.memory.MemoryCapability;
import work.slhaf.partner.core.memory.pojo.MemorySlice;
import work.slhaf.partner.core.memory.pojo.MemoryUnit;
import work.slhaf.partner.core.memory.pojo.MemorySliceSnapshot;
import work.slhaf.partner.core.memory.pojo.MemoryUnitSnapshot;
import work.slhaf.partner.core.memory.pojo.SliceRef;
import work.slhaf.partner.framework.agent.exception.ExceptionReporterHandler;
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,
List<String> relatedTopicPaths,
ActivationProfile activationProfile) {
MemorySlice memorySlice = memoryUnit.getSlices().getLast();
MemorySliceSnapshot memorySlice = memoryUnit.getSlices().getLast();
SliceRef sliceRef = new SliceRef(memoryUnit.getId(), memorySlice.getId());
LocalDate date = toLocalDate(memorySlice.getTimestamp());
runtimeLock.lock();
@@ -159,13 +159,13 @@ public class MemoryRuntime extends AbstractAgentModule.Standalone implements Sta
}
private ActivatedMemorySlice buildActivatedMemorySlice(SliceRef ref) {
MemoryUnit memoryUnit = memoryCapability.getMemoryUnit(ref.getUnitId());
Result<MemorySlice> memorySliceResult = memoryCapability.getMemorySlice(ref.getUnitId(), ref.getSliceId());
MemoryUnitSnapshot memoryUnit = memoryCapability.getMemoryUnit(ref.getUnitId());
Result<MemorySliceSnapshot> memorySliceResult = memoryCapability.getMemorySlice(ref.getUnitId(), ref.getSliceId());
if (memoryUnit == null || memorySliceResult.exceptionOrNull() != null) {
return null;
}
MemorySlice memorySlice = memorySliceResult.getOrThrow();
List<Message> messages = sliceMessages(memoryUnit, memorySlice);
MemorySliceSnapshot memorySlice = memorySliceResult.getOrThrow();
List<Message> messages = memoryUnit.messagesOf(memorySlice);
LocalDate date = toLocalDate(memorySlice.getTimestamp());
return ActivatedMemorySlice.builder()
.unitId(ref.getUnitId())
@@ -177,19 +177,6 @@ public class MemoryRuntime extends AbstractAgentModule.Standalone implements Sta
.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) {
return Instant.ofEpochMilli(timestamp)

View File

@@ -149,7 +149,7 @@ public class MemoryRecallProfileExtractor extends AbstractAgentModule.Standalone
@Override
public void consume(RollingResult result) {
List<Message> slicedMessages = sliceMessages(result);
List<Message> slicedMessages = result.incrementMessages();
if (slicedMessages.isEmpty()) {
return;
}
@@ -169,31 +169,21 @@ public class MemoryRecallProfileExtractor extends AbstractAgentModule.Standalone
relatedTopicPaths,
slicedMessages
);
memoryRuntime.recordMemory(result.memoryUnit(), topicPath, relatedTopicPaths, activationProfile);
memoryRuntime.recordMemory(result.getMemoryUnit(), topicPath, relatedTopicPaths, activationProfile);
}).onFailure(exp -> memoryRuntime.recordMemory(
result.memoryUnit(),
result.getMemoryUnit(),
null,
List.of(),
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) {
return new TaskBlock() {
@Override
protected void fillXml(@NotNull Document document, @NotNull Element root) {
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) -> {
messageElement.setAttribute("role", message.roleValue());
messageElement.setTextContent(message.getContent());

View File

@@ -0,0 +1,243 @@
package work.slhaf.partner.core.cognition.impression.search
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import work.slhaf.partner.core.cognition.impression.ActiveEntity
import work.slhaf.partner.core.cognition.impression.Entity
class SimpleTextSearchTest {
@Test
fun `search ranks subject hit before evidence hit when both match similar terms`() {
val search = SimpleTextSearch(TestTokenizer())
val targetA = activeTarget("a")
val targetB = activeTarget("b")
search.rebuild(
listOf(
document("a-subject", targetA, ImpressionSearchField.SUBJECT, "城南旧书店老板", 1.0),
document("b-evidence", targetB, ImpressionSearchField.EVIDENCE, "用户提到城南旧书店附近有一家打印店", 0.8),
)
)
val hits = search.search("城南旧书店", limit = 10)
assertEquals(listOf("a-subject", "b-evidence"), hits.map { it.document.id })
assertTrue(hits.first().score > hits[1].score)
assertTrue(hits.first().matchedTerms.containsAll(setOf("城南", "旧书店")))
}
@Test
fun `exact phrase match can beat partial subject match`() {
val search = SimpleTextSearch(TestTokenizer())
val partialSubject = activeTarget("partial")
val exactEvidence = activeTarget("exact")
search.rebuild(
listOf(
document("partial-subject", partialSubject, ImpressionSearchField.SUBJECT, "工程教材", 1.0),
document("exact-evidence", exactEvidence, ImpressionSearchField.EVIDENCE, "旧书店老板推荐过工程教材", 0.8),
)
)
val hits = search.search("旧书店老板推荐过工程教材", limit = 10)
assertEquals("exact-evidence", hits.first().document.id)
assertTrue(hits.first().matchedTerms.containsAll(setOf("旧书店", "老板", "推荐", "工程", "教材")))
}
@Test
fun `search recalls bookstore owner from generated active entity documents`() {
val search = SimpleTextSearch(TestTokenizer())
val bookstoreOwner = activeEntity("bookstore", "城南旧书店老板") {
addEvidence("用户上周提到城南旧书店老板推荐过一本水利工程教材")
addProjectedFeatures("熟悉工程类旧书" to 0.9)
}
val technicalPartner = activeEntity("technical", "Java 技术搭子") {
addEvidence("用户正在讨论 Jieba 分词、SimpleTextSearch 和倒排索引")
addProjectedFeatures("熟悉 Kotlin 与检索实现" to 0.9)
}
val reportRoommate = activeEntity("report", "实验报告室友") {
addEvidence("用户帮室友整理 Vivado 进阶仿真实验报告模板和 docx 文件")
}
search.rebuild(
listOf(bookstoreOwner, technicalPartner, reportRoommate)
.flatMap(ImpressionSearchDocuments::fromActiveEntity)
)
val hits = search.search("旧书店老板推荐的工程教材", limit = 10)
assertFalse(hits.isEmpty())
assertEquals("bookstore", hits.first().document.target.id)
}
@Test
fun `search recalls technical active entity from implementation terms`() {
val search = SimpleTextSearch(TestTokenizer())
val technicalPartner = activeEntity("technical", "Java 技术搭子") {
addEvidence("用户正在讨论 Jieba 分词、SimpleTextSearch 和倒排索引")
addProjectedImpressions("需要补充搜索召回测试" to 0.8)
}
val reportRoommate = activeEntity("report", "实验报告室友") {
addEvidence("用户帮室友整理 Vivado 进阶仿真实验报告模板和 docx 文件")
}
search.rebuild(
listOf(technicalPartner, reportRoommate)
.flatMap(ImpressionSearchDocuments::fromActiveEntity)
)
val hits = search.search("jieba 分词 SimpleTextSearch 倒排索引", limit = 10)
assertFalse(hits.isEmpty())
assertEquals("technical", hits.first().document.target.id)
}
@Test
fun `search recalls report active entity from document task terms`() {
val search = SimpleTextSearch(TestTokenizer())
val technicalPartner = activeEntity("technical", "Java 技术搭子") {
addEvidence("用户正在讨论 Kotlin、Jieba 分词和 SimpleTextSearch")
}
val reportRoommate = activeEntity("report", "实验报告室友") {
addEvidence("用户帮室友整理 Vivado 进阶仿真实验报告模板和 docx 文件")
}
search.rebuild(
listOf(technicalPartner, reportRoommate)
.flatMap(ImpressionSearchDocuments::fromActiveEntity)
)
val hits = search.search("Vivado 实验报告模板", limit = 10)
assertFalse(hits.isEmpty())
assertEquals("report", hits.first().document.target.id)
}
@Test
fun `search recalls known entity by alias documents`() {
val search = SimpleTextSearch(TestTokenizer())
val entity = Entity("entity-1", "Partner")
entity.addAlias("智能体项目")
search.rebuild(ImpressionSearchDocuments.fromEntity(entity))
val hits = search.search("智能体项目", limit = 10)
assertFalse(hits.isEmpty())
assertEquals(ImpressionSearchTarget.Type.ENTITY, hits.first().document.target.type)
assertEquals("entity-1", hits.first().document.target.id)
}
@Test
fun `upsert replaces previous index terms for the same document id`() {
val search = SimpleTextSearch(TestTokenizer())
val target = activeTarget("entity")
search.upsert(document("doc", target, ImpressionSearchField.EVIDENCE, "旧书店老板", 1.0))
assertEquals(listOf("doc"), search.search("老板", limit = 10).map { it.document.id })
search.upsert(document("doc", target, ImpressionSearchField.EVIDENCE, "实验报告模板", 1.0))
assertTrue(search.search("老板", limit = 10).isEmpty())
assertEquals(listOf("doc"), search.search("实验报告", limit = 10).map { it.document.id })
}
@Test
fun `removeByTarget removes all documents belonging to that target`() {
val search = SimpleTextSearch(TestTokenizer())
val removed = activeTarget("removed")
val kept = activeTarget("kept")
search.rebuild(
listOf(
document("removed-subject", removed, ImpressionSearchField.SUBJECT, "旧书店老板", 1.0),
document("removed-evidence", removed, ImpressionSearchField.EVIDENCE, "工程教材", 0.8),
document("kept-evidence", kept, ImpressionSearchField.EVIDENCE, "实验报告模板", 0.8),
)
)
search.removeByTarget(removed)
val hits = search.search("实验报告", limit = 10)
assertEquals(listOf("kept-evidence"), hits.map { it.document.id })
assertFalse(hits.any { it.document.target == removed })
assertTrue(search.search("旧书店", limit = 10).isEmpty())
}
@Test
fun `rebuild clears previous documents and index terms`() {
val search = SimpleTextSearch(TestTokenizer())
val target = activeTarget("entity")
search.rebuild(listOf(document("old", target, ImpressionSearchField.SUBJECT, "旧书店老板", 1.0)))
assertEquals(listOf("old"), search.search("老板", limit = 10).map { it.document.id })
search.rebuild(listOf(document("new", target, ImpressionSearchField.SUBJECT, "实验报告模板", 1.0)))
assertTrue(search.search("老板", limit = 10).isEmpty())
assertEquals(listOf("new"), search.search("实验报告", limit = 10).map { it.document.id })
}
@Test
fun `blank unmatched and zero limit queries return empty hits`() {
val search = SimpleTextSearch(TestTokenizer())
val target = activeTarget("entity")
search.rebuild(listOf(document("doc", target, ImpressionSearchField.SUBJECT, "旧书店老板", 1.0)))
assertTrue(search.search(" ", limit = 10).isEmpty())
assertTrue(search.search("完全不存在", limit = 10).isEmpty())
assertTrue(search.search("旧书店", limit = 0).isEmpty())
}
private fun activeTarget(id: String) =
ImpressionSearchTarget(ImpressionSearchTarget.Type.ACTIVE_ENTITY, id)
private fun activeEntity(
runtimeId: String,
subject: String,
configure: ActiveEntity.() -> Unit,
): ActiveEntity = ActiveEntity(runtimeId = runtimeId).apply {
updateSubject(subject)
configure()
}
private fun document(
id: String,
target: ImpressionSearchTarget,
field: ImpressionSearchField,
text: String,
weight: Double,
) = ImpressionSearchDocument(
id = id,
target = target,
field = field,
text = text,
weight = weight,
)
private class TestTokenizer : ImpressionTokenizer {
private val dictionary = listOf(
"城南", "旧书店", "老板", "推荐", "工程", "教材", "水利", "熟悉", "旧书",
"java", "kotlin", "jieba", "分词", "simpletextsearch", "倒排", "索引", "检索", "测试", "召回",
"vivado", "实验报告", "实验", "报告", "模板", "docx", "室友", "整理", "文件",
"智能体", "项目", "智能体项目"
)
private val alphaNumericRegex = Regex("[a-z0-9]+(?:[-_./][a-z0-9]+)*")
override fun tokenize(text: String): Set<String> {
val normalized = text.lowercase().trim()
if (normalized.isBlank()) {
return emptySet()
}
return buildSet {
dictionary.filterTo(this) { normalized.contains(it) }
alphaNumericRegex.findAll(normalized).mapTo(this) { it.value }
}
}
}
}

View File

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

View File

@@ -145,5 +145,63 @@ class CommunicationProducerTest {
public Lock getMessageLock() {
return lock;
}
@Override
public java.util.Set<work.slhaf.partner.core.cognition.impression.ActiveEntity> projectEntity(String input) {
return java.util.Set.of();
}
@Override
public java.util.Map<
work.slhaf.partner.core.cognition.impression.ActiveEntity,
work.slhaf.partner.core.cognition.impression.Entity
> showEntities() {
return java.util.Map.of();
}
@Override
public 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 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.model.pojo.Message;
import work.slhaf.partner.framework.agent.support.Result;
import work.slhaf.partner.module.communication.summarizer.MessageCompressor;
@@ -63,19 +65,19 @@ class DialogRollingTest {
message(Message.Character.ASSISTANT, "new-assistant")
), 4, 6);
MemoryUnit merged = memoryCapability.getMemoryUnit(sessionId);
MemoryUnitSnapshot merged = memoryCapability.getMemoryUnit(sessionId);
assertEquals(List.of("old-user", "old-assistant", "new-user", "new-assistant"),
merged.getConversationMessages().stream().map(Message::getContent).toList());
assertEquals(2, merged.getSlices().size());
MemorySlice appendedSlice = merged.getSlices().getLast();
MemorySliceSnapshot appendedSlice = merged.getSlices().getLast();
assertNotNull(appendedSlice.getId());
assertEquals(2, appendedSlice.getStartIndex());
assertEquals(4, appendedSlice.getEndIndex());
assertEquals("new-summary", appendedSlice.getSummary());
assertEquals(sessionId, rollingResult.memoryUnit().getId());
assertEquals(appendedSlice.getId(), rollingResult.memorySlice().getId());
assertEquals("new-summary", rollingResult.summary());
assertEquals(sessionId, rollingResult.getMemoryUnit().getId());
assertEquals(appendedSlice.getId(), rollingResult.getMemorySlice().getId());
assertEquals("new-summary", rollingResult.getSummary());
}
@Test
@@ -96,7 +98,7 @@ class DialogRollingTest {
message(Message.Character.ASSISTANT, "second")
), 2, 6);
MemoryUnit created = memoryCapability.getMemoryUnit(sessionId);
MemoryUnitSnapshot created = memoryCapability.getMemoryUnit(sessionId);
assertNotNull(created);
assertEquals(List.of("first", "second"),
created.getConversationMessages().stream().map(Message::getContent).toList());
@@ -104,7 +106,7 @@ class DialogRollingTest {
assertEquals(0, created.getSlices().getFirst().getStartIndex());
assertEquals(2, created.getSlices().getFirst().getEndIndex());
assertEquals("fresh-summary", created.getSlices().getFirst().getSummary());
assertEquals(created, rollingResult.memoryUnit());
assertEquals(created, rollingResult.getMemoryUnit());
}
@Test
@@ -151,8 +153,8 @@ class DialogRollingTest {
message(Message.Character.ASSISTANT, "a1")
), 2, 6);
assertEquals(sessionId, rollingResult.memoryUnit().getId());
assertEquals("no summary, due to empty summarize result", rollingResult.summary());
assertEquals(sessionId, rollingResult.getMemoryUnit().getId());
assertEquals("no summary, due to empty summarize result", rollingResult.getSummary());
}
private static final class StubMemoryCapability implements MemoryCapability {
@@ -172,28 +174,29 @@ class DialogRollingTest {
}
@Override
public MemoryUnit getMemoryUnit(String unitId) {
return units.get(unitId);
public MemoryUnitSnapshot getMemoryUnit(String unitId) {
MemoryUnit unit = units.get(unitId);
return unit == null ? null : unit.snapshot();
}
@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;
}
@Override
public MemoryUnit updateMemoryUnit(List<Message> chatMessages, String summary) {
public MemoryUnitSnapshot updateMemoryUnit(List<Message> chatMessages, String summary) {
MemoryUnit unit = units.computeIfAbsent(sessionId, MemoryUnit::new);
unit.updateTimestamp();
int startIndex = unit.getConversationMessages().size();
unit.getConversationMessages().addAll(chatMessages);
unit.getSlices().add(new MemorySlice(startIndex, startIndex + chatMessages.size(), summary));
return unit;
return unit.snapshot();
}
@Override
public Collection<MemoryUnit> listMemoryUnits() {
return units.values();
public Collection<MemoryUnitSnapshot> listMemoryUnits() {
return units.values().stream().map(MemoryUnit::snapshot).toList();
}
@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.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.model.pojo.Message;
import work.slhaf.partner.framework.agent.support.Result;
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 java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.nio.file.Path;
import java.time.LocalDate;
import java.util.Collection;
@@ -41,11 +42,8 @@ class MemoryRuntimeTest {
System.setProperty("user.home", tempDir.toAbsolutePath().toString());
}
@SuppressWarnings("unchecked")
private static List<Message> invokeSliceMessages(MemoryRuntime runtime, MemoryUnit unit, MemorySlice slice) throws Exception {
Method method = MemoryRuntime.class.getDeclaredMethod("sliceMessages", MemoryUnit.class, MemorySlice.class);
method.setAccessible(true);
return (List<Message>) method.invoke(runtime, unit, slice);
private static List<Message> invokeSliceMessages(MemoryRuntime runtime, MemoryUnit unit, MemorySlice slice) {
return unit.snapshot().messagesOf(slice.snapshot());
}
private static void setField(Object target, String fieldName, Object value) throws Exception {
@@ -98,6 +96,64 @@ class MemoryRuntimeTest {
public Lock getMessageLock() {
return lock;
}
@Override
public java.util.Set<work.slhaf.partner.core.cognition.impression.ActiveEntity> projectEntity(String input) {
return java.util.Set.of();
}
@Override
public java.util.Map<
work.slhaf.partner.core.cognition.impression.ActiveEntity,
work.slhaf.partner.core.cognition.impression.Entity
> showEntities() {
return java.util.Map.of();
}
@Override
public 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));
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");
assertEquals(List.of("slice-2"), topicResult.stream().map(ActivatedMemorySlice::getSliceId).toList());
@@ -187,8 +243,8 @@ class MemoryRuntimeTest {
relatedUnit.getSlices().add(relatedSlice);
memoryCapability.remember(relatedUnit);
runtime.recordMemory(mainUnit, "topic/main", List.of("topic/related"), DEFAULT_PROFILE);
runtime.recordMemory(relatedUnit, "topic/related", List.of(), DEFAULT_PROFILE);
runtime.recordMemory(mainUnit.snapshot(), "topic/main", List.of("topic/related"), DEFAULT_PROFILE);
runtime.recordMemory(relatedUnit.snapshot(), "topic/related", List.of(), DEFAULT_PROFILE);
List<ActivatedMemorySlice> topicResult = runtime.queryActivatedMemoryByTopicPath("topic/main");
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);
firstUnitSnapshot.getSlices().add(firstSlice);
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().addAll(List.of(message("m2"), message("m3")));
@@ -215,7 +271,7 @@ class MemoryRuntimeTest {
firstUnitSnapshot.getSlices().clear();
firstUnitSnapshot.getSlices().add(secondSlice);
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());
JSONArray dateIndex = state.getJSONArray("date_index");
@@ -253,14 +309,14 @@ class MemoryRuntimeTest {
MemorySlice secondSlice = MemorySlice.restore("slice-2", 2, 4, "second", 172_800_000L);
mainUnit.getSlices().addAll(List.of(firstSlice, secondSlice));
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");
relatedUnit.getConversationMessages().addAll(List.of(message("r0"), message("r1")));
MemorySlice relatedSlice = MemorySlice.restore("slice-3", 0, 2, "related", 259_200_000L);
relatedUnit.getSlices().add(relatedSlice);
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());
JSONArray topicSlices = state.getJSONArray("topic_slices");
@@ -327,21 +383,21 @@ class MemoryRuntimeTest {
MemorySlice primarySlice = MemorySlice.restore("slice-primary", 0, 2, "primary", System.currentTimeMillis());
primaryUnit.getSlices().add(primarySlice);
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");
relatedUnit.getConversationMessages().addAll(List.of(message("r0"), message("r1")));
MemorySlice relatedSlice = MemorySlice.restore("slice-related-rank", 0, 2, "related", System.currentTimeMillis());
relatedUnit.getSlices().add(relatedSlice);
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");
parentUnit.getConversationMessages().addAll(List.of(message("x0"), message("x1")));
MemorySlice parentSlice = MemorySlice.restore("slice-parent", 0, 2, "parent", System.currentTimeMillis());
parentUnit.getSlices().add(parentSlice);
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");
assertEquals(List.of("slice-primary", "slice-related-rank", "slice-parent"),
@@ -361,7 +417,7 @@ class MemoryRuntimeTest {
primaryUnit.getSlices().add(primarySlice);
memoryCapability.remember(primaryUnit);
runtime.recordMemory(
primaryUnit,
primaryUnit.snapshot(),
"topic->main",
List.of("topic->related"),
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());
relatedUnit.getSlices().add(relatedSlice);
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");
assertEquals(List.of("slice-primary-zero"), topicResult.stream().map(ActivatedMemorySlice::getSliceId).toList());
@@ -391,10 +447,10 @@ class MemoryRuntimeTest {
unit.getSlices().add(slice);
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().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 mainTopic = state.getJSONArray("topic_slices").stream()
@@ -428,12 +484,13 @@ class MemoryRuntimeTest {
}
@Override
public MemoryUnit getMemoryUnit(String unitId) {
return units.get(unitId);
public MemoryUnitSnapshot getMemoryUnit(String unitId) {
MemoryUnit unit = units.get(unitId);
return unit == null ? null : unit.snapshot();
}
@Override
public Result<MemorySlice> getMemorySlice(String unitId, String sliceId) {
public Result<MemorySliceSnapshot> getMemorySlice(String unitId, String sliceId) {
MemoryUnit unit = units.get(unitId);
if (unit == null || unit.getSlices() == null) {
return Result.failure(new MemoryLookupException(
@@ -445,7 +502,7 @@ class MemoryRuntimeTest {
return unit.getSlices().stream()
.filter(slice -> sliceId.equals(slice.getId()))
.findFirst()
.map(Result::success)
.map(slice -> Result.success(slice.snapshot()))
.orElseGet(() -> Result.failure(new MemoryLookupException(
"Memory slice not found: " + unitId + ":" + sliceId,
unitId + ":" + sliceId,
@@ -454,13 +511,13 @@ class MemoryRuntimeTest {
}
@Override
public MemoryUnit updateMemoryUnit(List<Message> chatMessages, String summary) {
public MemoryUnitSnapshot updateMemoryUnit(List<Message> chatMessages, String summary) {
return null;
}
@Override
public Collection<MemoryUnit> listMemoryUnits() {
return units.values();
public Collection<MemoryUnitSnapshot> listMemoryUnits() {
return units.values().stream().map(MemoryUnit::snapshot).toList();
}
@Override

View File

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

View File

@@ -199,8 +199,14 @@ Partner/
- [行动系统](doc/action/action.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
暂未指定
暂未选择开源许可证。当前仓库主要作为个人项目展示与学习研究记录,未经授权不建议复制、分发或商用

View File

@@ -0,0 +1,281 @@
# First Encounter Module / 初见模块设计草案
## 背景
Partner 当前已经不是“不能跑”的项目,但用户面对一个新的 agent 时,仍然会有明显的启动成本。
这个启动成本不完全来自工程状态,而来自互动预期的不确定:
- 不知道该怎么和它说话;
- 不知道它知道什么、不知道什么;
- 不知道它会不会误解用户;
- 不知道它能不能被纠正;
- 不知道纠正之后会不会真正改变后续行为。
因此Partner 需要一个“初见模块”。
它解决的不是程序启动问题,而是关系和预期建立问题。
## 定位
初见模块不应该只是 `InitModule`
`InitModule` 更像加载配置、初始化资源、检查运行状态;而初见模块面对的是用户第一次或重新面对 Partner 时的交互问题。
因此,代码层可以命名为:
```text
FirstEncounterModule
```
产品/概念层称为:
```text
初见模块
```
它的职责是:
> 在新用户、长时间未使用、上下文断裂、版本升级,或用户主动询问“你现在知道我什么”时,组织一次清醒、温和、可校准的开场。
## 与 Impression 模块的关系
初见模块应当依托 Impression但不属于 ImpressionCore。
边界如下:
```text
ImpressionCore
负责存储、召回、更新关于用户、agent 自身、关系契约、项目上下文等印象。
FirstEncounterModule
负责判断是否进入初见/重逢模式,并将召回的印象组织成本轮对话可用的 EncounterFrame。
EncounterState
负责记录初见流程是否已经完成,以及哪些环节已经向用户公开。
```
也就是说:
> Impression 负责“我对你有什么印象”。
> FirstEncounterModule 负责“第一次见面时,我该如何使用这些印象”。
不应把开场策略、纠错协议、对话引导逻辑直接塞进 ImpressionCore否则记忆模块会被迫承担表达和流程控制职责。
## 触发场景
初见模块可以在以下场景触发:
- 新用户第一次进入;
- 当前 session 没有足够上下文;
- 长时间未使用后重新进入;
- Partner 发生较大版本升级;
- Impression 召回结果置信度较低;
- 用户主动询问:
- “你知道我什么?”
- “你现在能做什么?”
- “我该怎么和你说话?”
- “你是不是还记得之前的事?”
- 系统检测到当前对话存在明显预期不稳定,例如用户多次纠正 agent 的语气、事实或任务边界。
## 核心流程
推荐流程:
```text
User Input
InteractionHub
EncounterDetector
ImpressionRecaller
FirstEncounterModule
EncounterFrame
PromptContributor / AppendPrompt
CoreModel Reply
ImpressionUpdater
```
其中:
1. `EncounterDetector` 判断是否需要进入初见/重逢模式;
2. `ImpressionRecaller` 召回相关印象;
3. `FirstEncounterModule` 将召回结果整理成 EncounterFrame
4. `PromptContributor` 将 EncounterFrame 注入模型上下文;
5. 对话结束后,`ImpressionUpdater` 根据用户反馈更新印象。
## EncounterFrame
`EncounterFrame` 是初见模块的核心输出。它不是长期记忆,而是本轮对话使用的临时认知框架。
示例结构:
```kotlin
data class EncounterFrame(
val mode: EncounterMode,
val knownAboutUser: List<ImpressionProjection>,
val knownAboutSelf: List<ImpressionProjection>,
val knownAboutRelationship: List<ImpressionProjection>,
val uncertainty: List<String>,
val correctionProtocol: CorrectionProtocol,
val openingStrategy: OpeningStrategy
)
```
其中:
- `mode`:当前是初见、重逢、版本升级后再介绍,还是用户主动询问;
- `knownAboutUser`:关于用户的可靠印象;
- `knownAboutSelf`Partner 对自身能力和边界的描述;
- `knownAboutRelationship`:关于互动方式、纠错方式、语气偏好等印象;
- `uncertainty`:当前不能确定的部分;
- `correctionProtocol`:用户如何纠正 Partner
- `openingStrategy`:本次开场应采用的表达策略。
## Impression Subject 建议
为了支持初见模块Impression 可以支持一些特殊 subject
```text
user
agent_self
relationship_contract
interaction_preference
project_context
```
例如:
```text
user:
- 用户偏好技术回答直接,不喜欢客服腔。
- 用户面对陌生 agent 时会在意互动预期是否稳定。
- 用户更容易接受从一个小切口开始推进。
agent_self:
- Partner 当前不是完全成熟的 agent。
- Partner 应公开自己的已知、未知和不确定。
- Partner 不应该在缺少依据时假装熟悉用户。
relationship_contract:
- 用户可以直接纠正 Partner。
- Partner 需要区分事实错误、语气偏差、理解偏差和任务边界偏差。
- 纠正应作为后续 impression 更新的重要信号。
```
## 初见开场策略
初见模块不应一上来问很多问题,也不应假装已经充分了解用户。
更合适的开场结构是:
```text
我现在对你还没有足够稳定的了解。
我会先说明:
- 我目前知道什么;
- 我不知道什么;
- 你可以怎么纠正我;
- 我会如何处理这些纠正。
接下来我们可以从一个很小的任务开始。
```
在 prompt 中可组织为:
```text
你正在与用户进行初见/重逢式对话。
你目前可靠知道:
- 用户希望技术讨论直接、少废话;
- 用户对陌生 agent 的互动预期尚未建立;
- 用户不喜欢 agent 在缺少依据时假装熟悉。
你应该主动说明:
- 你知道什么;
- 你不知道什么;
- 用户可以如何纠正你;
- 你会如何处理纠正。
不要一次性问很多问题。
不要假装亲近。
先从一个很小的任务或对话入口开始。
```
## EncounterState
初见模块需要少量流程状态,但这些状态不一定属于 Impression。
示例:
```kotlin
data class EncounterState(
val hasIntroducedSelf: Boolean,
val hasShownKnownUnknown: Boolean,
val hasExplainedCorrectionProtocol: Boolean,
val firstEncounterCompleted: Boolean,
val lastEncounterVersion: String?
)
```
这些状态表示流程是否完成,而不是关于用户的长期印象。
真正应该进入 Impression 的,是对用户、关系、互动方式的理解,例如:
```text
用户面对新的 agent 时,会担心互动预期不稳定。
用户希望 agent 明确边界,而不是一上来装熟。
用户能接受通过纠正来校准 agent。
```
## 最小实现方案
第一版可以很轻,不需要完整工程化。
建议步骤:
1. 新增 `FirstEncounterPromptContributor`
2. 新增 `EncounterDetector`,先用简单规则判断是否触发;
3.`ImpressionRecaller` 召回 `user``agent_self``relationship_contract``interaction_preference``project_context` 相关印象;
4. 生成一个简化版 `EncounterFrame`
5. 将 EncounterFrame 注入 AppendPrompt
6. 用户纠正后,将纠正内容作为 evidence 交给 ImpressionUpdater。
第一版不需要复杂策略模型,规则足够:
```text
新 session + 低熟悉度 → 初见模式
长时间未使用 + 有历史 impression → 重逢模式
用户主动询问已知/未知 → 自我公开模式
多次纠正 → 关系校准模式
```
## 不做什么
初见模块第一版不做以下内容:
- 不做完整 onboarding 表单;
- 不一次性询问大量偏好;
- 不把用户画像写死;
- 不假装已经理解用户;
- 不替代 ImpressionCore
- 不直接负责长期记忆写入;
- 不在每轮对话中重复自我介绍。
它只负责在关系尚未稳定时,提供一个清醒、可纠正、可继续的开场。
## 价值
初见模块的价值不只是“第一次使用体验更好”。
它实际上补上了 Partner 作为 agent 的一个关键能力:
> 在上下文断裂、长期未见、版本变化或记忆不确定时,仍然能让用户知道该如何继续与它相处。
这使 Partner 不只是一个能运行的程序,而是一个能够建立互动预期、暴露不确定性、接受校准,并逐步形成稳定关系的 agent。

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 决策。

View File

@@ -0,0 +1,210 @@
# Impression Vector Fusion Plan
## Context
Current `ImpressionCore.projectEntity` already connects text recall to active entity projection:
```text
input
-> SimpleTextSearch.search(input)
-> group document hits by ImpressionSearchTarget
-> aggregate into EntityAssociationMatch
-> resolve ACTIVE_ENTITY or ENTITY target
-> append EntityEvidence
-> refresh active entity text-search documents
```
This gives the Impression module a first explainable recall path. Vector recall should not replace this path. It should become another recall signal that is fused with text recall before projection.
## Why not implement vector fusion immediately
Vector fusion is a recall-source enhancement, not the next foundation step.
Before adding more recall sources, the module still needs a clearer organization pipeline:
- how an unmatched input becomes a new `ActiveEntity`;
- how runtime evidence is accumulated, merged, or decayed;
- how an `ActiveEntity` is rolled into a long-term `Entity`;
- how extracted features and impressions update known entities;
- when `textSearch` and `vectorIndex` are refreshed after entity updates.
Unmatched entity creation and `ActiveEntity` rolling are closely related: both decide how temporary evidence becomes a stable entity-level impression. They should be considered as one organization chain rather than two unrelated features.
## Target shape
Future `projectEntity` should have this shape:
```text
input
-> text recall signals
-> vector recall signals
-> normalize scores
-> fuse signals by ImpressionSearchTarget
-> resolve or create ActiveEntity
-> append evidence
-> refresh runtime indexes
```
The later half should stay shared. Text recall, vector recall, relation recall, and recency recall should all produce association signals. Projection should not depend on which recall source produced a match.
## First vector scope
The first vector implementation should only recall long-term `ENTITY` targets.
Reason:
- `ImpressionVectorIndex` already syncs known `Entity` data.
- Known entities have relatively stable features and impressions.
- Active entity evidence changes frequently; embedding every new evidence item would add update cost and lifecycle complexity too early.
So the first vector target should be:
```text
Entity feature / impression vector
-> ImpressionSearchTarget(Type.ENTITY, entityUuid)
```
Later, after the active entity organization chain is stable, active evidence vectors can be added as:
```text
ActiveEntity evidence / projected feature / projected impression vector
-> ImpressionSearchTarget(Type.ACTIVE_ENTITY, runtimeId)
```
## Signal model
`EntityAssociationMatch` is currently text-oriented because it stores `List<ImpressionSearchHit>`.
For fusion, introduce a source-neutral signal model:
```kotlin
data class EntityAssociationSignal(
val target: ImpressionSearchTarget,
val source: Source,
val score: Double,
val reason: String,
val textHit: ImpressionSearchHit? = null,
val vectorHit: ImpressionVectorHit? = null,
) {
enum class Source {
TEXT,
VECTOR,
RELATION,
RECENCY
}
}
```
Then change or extend `EntityAssociationMatch` toward:
```kotlin
data class EntityAssociationMatch(
val target: ImpressionSearchTarget,
val score: Double,
val signals: List<EntityAssociationSignal> = emptyList(),
)
```
This keeps fusion explainable. A match can still tell the model or logs why an entity was recalled.
## Score normalization
Text search score and vector similarity should not be added directly.
Text search currently produces an internal score based on token hits, coverage, exact phrase bonus, field bonus, and document weight. Vector search is usually cosine-like similarity. Normalize both into association-strength-like values before fusion.
Possible first normalization:
```text
textScore01 = clamp(textScore / 5.0, 0.0, 1.0)
vectorScore01 =
similarity < 0.55 -> 0.0
otherwise -> clamp((similarity - 0.55) / 0.35, 0.0, 1.0)
```
The constants are placeholders. They should be tuned with tests and logs.
## Fusion rule
Use strong-hit priority with multi-source support, not simple averaging.
A first rule can be:
```text
targetScore =
max(bestTextScore, bestVectorScore * 0.9)
+ sameTargetCrossSourceBonus
+ supportingSignalBonus
```
Suggested behavior:
- direct subject or phrase text match should beat vague vector similarity;
- vector recall should recover semantically related entities when text recall is weak or empty;
- if text and vector both hit the same target, the target should receive a small confidence boost;
- long documents or many weak signals should not dominate a single strong subject/evidence hit.
## Execution strategy
First implementation can be conservative:
```text
always run TextSearch
run VectorSearch only when:
- text recall is empty; or
- top text match confidence is low; or
- input is long and semantic rather than name-like
```
If the embedding model is local and cheap enough, this can later become parallel text + vector recall.
## Implementation phases
### Phase 1: organization chain first
Implement before vector fusion:
- unmatched input -> new `ActiveEntity` candidate;
- active evidence update and dedup/merge rules;
- active entity rolling into known `Entity`;
- known entity feature/impression update;
- index refresh after entity updates.
### Phase 2: signal abstraction
Introduce `EntityAssociationSignal` and make text hits convert into signals.
Keep current behavior equivalent after refactor.
### Phase 3: long-term entity vector recall
Add vector recall only for known `Entity` targets:
```text
input embedding
-> ImpressionVectorIndex.search(...)
-> vector hits
-> EntityAssociationSignal(source = VECTOR)
-> fuse with text signals
```
### Phase 4: active entity vector recall
Only after active entity lifecycle is stable:
- vectorize active evidence or projected features;
- update active vector index when evidence changes;
- fuse `ACTIVE_ENTITY` vector hits with text hits.
## Non-goals for first vector pass
Do not start with:
- vectorizing every raw evidence item immediately;
- replacing text search ranking;
- using vector score as direct `associationConfidence` without normalization;
- adding opaque fusion that cannot explain why an entity was recalled;
- expanding `projectEntity` into a large source-specific method.
The intended direction is: multiple recall sources produce explainable signals, then `ImpressionCore` performs one shared entity projection flow.