From dd64599154631e18c5db0ed783a677e0ac23522d Mon Sep 17 00:00:00 2001 From: slhafzjw Date: Sat, 30 May 2026 21:34:32 +0800 Subject: [PATCH] feat(impression): add structured entity evidence metadata --- .../core/cognition/impression/ActiveEntity.kt | 23 +++- .../cognition/impression/EntityEvidence.kt | 112 ++++++++++++++++++ 2 files changed, 131 insertions(+), 4 deletions(-) create mode 100644 Partner-Core/src/main/java/work/slhaf/partner/core/cognition/impression/EntityEvidence.kt diff --git a/Partner-Core/src/main/java/work/slhaf/partner/core/cognition/impression/ActiveEntity.kt b/Partner-Core/src/main/java/work/slhaf/partner/core/cognition/impression/ActiveEntity.kt index 5d792b00..b9acb777 100644 --- a/Partner-Core/src/main/java/work/slhaf/partner/core/cognition/impression/ActiveEntity.kt +++ b/Partner-Core/src/main/java/work/slhaf/partner/core/cognition/impression/ActiveEntity.kt @@ -7,9 +7,9 @@ import java.util.concurrent.atomic.AtomicReference class ActiveEntity @JvmOverloads constructor( timestamp: Long = System.currentTimeMillis(), - private val _evidences: MutableList = mutableListOf(), + private val _evidences: MutableList = mutableListOf(), ) : BlockContent("active_entity_$timestamp", "impression") { - val evidences: List + val evidences: List get() = synchronized(_evidences) { _evidences.toList() } private val _subject = AtomicReference("UNKNOWN") @@ -23,7 +23,15 @@ class ActiveEntity @JvmOverloads constructor( val projectedImpressions: Map 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) } @@ -46,7 +54,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, diff --git a/Partner-Core/src/main/java/work/slhaf/partner/core/cognition/impression/EntityEvidence.kt b/Partner-Core/src/main/java/work/slhaf/partner/core/cognition/impression/EntityEvidence.kt new file mode 100644 index 00000000..0dd70178 --- /dev/null +++ b/Partner-Core/src/main/java/work/slhaf/partner/core/cognition/impression/EntityEvidence.kt @@ -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 + } +}