diff --git a/Partner-Core/src/main/java/work/slhaf/partner/core/cognition/CognitionCapability.java b/Partner-Core/src/main/java/work/slhaf/partner/core/cognition/CognitionCapability.java index 8141c303..d0776597 100644 --- a/Partner-Core/src/main/java/work/slhaf/partner/core/cognition/CognitionCapability.java +++ b/Partner-Core/src/main/java/work/slhaf/partner/core/cognition/CognitionCapability.java @@ -11,6 +11,8 @@ public interface CognitionCapability { String initiateTurn(String input, String target); + ContextWorkspace contextWorkspace(); + List getChatMessages(); List snapshotChatMessages(); diff --git a/Partner-Core/src/main/java/work/slhaf/partner/core/cognition/CognitionCore.java b/Partner-Core/src/main/java/work/slhaf/partner/core/cognition/CognitionCore.java index 65dd069f..60e46c3c 100644 --- a/Partner-Core/src/main/java/work/slhaf/partner/core/cognition/CognitionCore.java +++ b/Partner-Core/src/main/java/work/slhaf/partner/core/cognition/CognitionCore.java @@ -35,9 +35,16 @@ public class CognitionCore extends PartnerCore { */ private List chatMessages = new ArrayList<>(); + private final ContextWorkspace contextWorkspace = new ContextWorkspace(); + public CognitionCore() throws IOException, ClassNotFoundException { } + @CapabilityMethod + public ContextWorkspace contextWorkspace() { + return contextWorkspace; + } + @CapabilityMethod public String initiateTurn(String input, String target) { PartnerRunningFlowContext primaryContext = PartnerRunningFlowContext.Companion.fromSelf(input); diff --git a/Partner-Core/src/main/java/work/slhaf/partner/core/cognition/ContextWorkspace.kt b/Partner-Core/src/main/java/work/slhaf/partner/core/cognition/ContextWorkspace.kt new file mode 100644 index 00000000..5fe9108c --- /dev/null +++ b/Partner-Core/src/main/java/work/slhaf/partner/core/cognition/ContextWorkspace.kt @@ -0,0 +1,309 @@ +package work.slhaf.partner.core.cognition + +import org.w3c.dom.Document +import org.w3c.dom.Element +import java.io.StringWriter +import java.time.Duration +import java.time.Instant +import java.util.concurrent.locks.ReentrantReadWriteLock +import javax.xml.parsers.DocumentBuilderFactory +import javax.xml.transform.OutputKeys +import javax.xml.transform.TransformerFactory +import javax.xml.transform.dom.DOMSource +import javax.xml.transform.stream.StreamResult +import kotlin.concurrent.write +import kotlin.math.max +import kotlin.math.min + +class ContextWorkspace { + + private val stateSet = mutableSetOf() + private val lock = ReentrantReadWriteLock() + + /** + * 根据传入的 [ContextBlock.VisibleDomain] 列表,获取上下文块 + * @param domains 需要获取上下文的域列表,顺序将决定权重优先级,按照列表排序将具备线性权重分层,最终反映到 blockContent 列表的排序上 + */ + fun resolve(domains: List): List = lock.write { + if (domains.isEmpty()) { + return@write emptyList() + } + + val domainWeights = domains + .distinct() + .withIndex() + .associate { (index, domain) -> domain to (domains.size - index) } + + val activeBlocks = mutableListOf() + val iterator = stateSet.iterator() + while (iterator.hasNext()) { + val block = iterator.next() + val activationScore = block.applyTimeFade() + if (activationScore <= 0.0) { + iterator.remove() + continue + } + + val matchedDomains = block.visibleTo.intersect(domainWeights.keys) + if (matchedDomains.isEmpty()) { + continue + } + + activeBlocks += ResolvedContextBlock( + block = block, + domainWeight = matchedDomains.sumOf { domainWeights.getValue(it) }, + activationScore = activationScore + ) + } + + activeBlocks + .sortedWith( + compareBy { it.domainWeight } + .thenBy { it.block.sourceKey.blockName } + .thenBy { it.block.sourceKey.source } + .thenBy { it.activationScore } + .thenBy { it.block.blockContent.encodeToXmlString() } + ) + .map { it.block.render() } + } + + + /** + * @param contextBlock 注册的新上下文块 + */ + fun register(contextBlock: ContextBlock) = lock.write { + val iterator = stateSet.iterator() + while (iterator.hasNext()) { + val currentBlock = iterator.next() + if (!currentBlock.sameWith(contextBlock)) { + continue + } + + if (currentBlock.applyReplaceFade() <= 0.0) { + iterator.remove() + } + } + stateSet += contextBlock + } + + fun expire(blockName: String, source: String) = lock.write { + val sourceKey = ContextBlock.SourceKey(blockName, source) + val iterator = stateSet.iterator() + while (iterator.hasNext()) { + if (iterator.next().sourceKey == sourceKey) { + iterator.remove() + } + } + } + +} + +private data class ResolvedContextBlock( + val block: ContextBlock, + val domainWeight: Int, + val activationScore: Double +) + +data class ContextBlock @JvmOverloads constructor( + val blockContent: BlockContent, + val compactBlock: BlockContent = blockContent, + val abstractBlock: BlockContent = blockContent, + /** + * 对哪些域可见 + */ + val visibleTo: Set, + + /** + * 新的 [blockContent] 属性与其相同时,发生的衰退步长 + */ + private val replaceFadeFactor: Double, + + /** + * 随时间发生的衰退步长,按照分钟定义 + */ + private val timeFadeFactor: Double, + + /** + * 触发一次激活时,发生的强化步长 + */ + private val activateFactor: Double +) { + + /** + * 默认活跃分数,降低至0时将在 [ContextWorkspace] 中移除该 block + * 此外还参与到当同源 block 存在时的排序,按该分数升序排列,只影响同源 block 间的顺序 + */ + private var activationScore = 100.0 + private var lastTouchedAt = Instant.now() + + enum class VisibleDomain { + ACTION, + MEMORY, + PERCEIVE, + COGNITION, + COMMUNICATION, + } + + internal val sourceKey: SourceKey + get() = SourceKey(blockContent.blockName, blockContent.source) + + fun applyTimeFade(): Double { + val now = Instant.now() + val elapsedSeconds = Duration.between(lastTouchedAt, now).toMillis() / 1000.0 + activationScore = max(0.0, activationScore - elapsedSeconds * (timeFadeFactor / 60.0)) + lastTouchedAt = now + return activationScore + } + + fun applyReplaceFade(): Double { + applyTimeFade() + activationScore = max(0.0, activationScore - replaceFadeFactor) + return activationScore + } + + fun activate(): Double { + applyTimeFade() + activationScore = min(100.0, activationScore + activateFactor) + return activationScore + } + + fun sameWith(contextBlock: ContextBlock): Boolean { + return this.sourceKey == contextBlock.sourceKey + } + + fun render(): BlockContent { + return when { + activationScore < 30 -> abstractBlock + activationScore < 70 -> compactBlock + else -> blockContent + } + } + + data class SourceKey( + val blockName: String, + val source: String + ) +} + +abstract class BlockContent protected constructor( + val blockName: String, + val source: String, +) { + + fun encodeToXml(): Element { + val document = DocumentBuilderFactory.newInstance() + .newDocumentBuilder() + .newDocument() + + val root = document.createElement(blockName) + root.setAttribute("source", source) + document.appendChild(root) + + fillXml(document, root) + + return root + } + + fun encodeToXmlString(): String { + val transformer = TransformerFactory.newInstance().newTransformer().apply { + setOutputProperty(OutputKeys.INDENT, "yes") + setOutputProperty(OutputKeys.ENCODING, "UTF-8") + setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "2") + } + + return StringWriter().use { writer -> + transformer.transform(DOMSource(encodeToXml()), StreamResult(writer)) + writer.toString() + } + } + + protected abstract fun fillXml(document: Document, root: Element) + + protected fun appendTextElement( + document: Document, + parent: Element, + tagName: String, + value: Any? + ): Element { + val element = document.createElement(tagName) + element.textContent = value?.toString() ?: "" + parent.appendChild(element) + return element + } + + protected fun appendChildElement( + document: Document, + parent: Element, + tagName: String, + block: Element.() -> Unit = {} + ): Element { + val element = document.createElement(tagName) + parent.appendChild(element) + element.block() + return element + } + + protected fun appendCDataElement( + document: Document, + parent: Element, + tagName: String, + value: String? + ): Element { + val element = document.createElement(tagName) + element.appendChild(document.createCDATASection(value ?: "")) + parent.appendChild(element) + return element + } + + protected fun appendListElement( + document: Document, + parent: Element, + wrapperTagName: String, + itemTagName: String, + values: Iterable, + block: Element.(T) -> Unit = { value -> + textContent = value?.toString() ?: "" + } + ): Element { + val wrapper = document.createElement(wrapperTagName) + parent.appendChild(wrapper) + + for (value in values) { + val item = document.createElement(itemTagName) + wrapper.appendChild(item) + item.block(value) + } + + return wrapper + } + + protected fun appendRepeatedElements( + document: Document, + parent: Element, + itemTagName: String, + values: Iterable, + block: Element.(T) -> Unit = { value -> + textContent = value?.toString() ?: "" + } + ) { + for (value in values) { + val item = document.createElement(itemTagName) + parent.appendChild(item) + item.block(value) + } + } +} + +abstract class CommunicationBlockContent( + blockName: String, + source: String, +) : BlockContent( + blockName, + source, +) { + + enum class Projection { + SUPPLY, + CONTEXT + } +} diff --git a/Partner-Core/src/test/java/work/slhaf/partner/core/cognition/ContextWorkspaceTest.kt b/Partner-Core/src/test/java/work/slhaf/partner/core/cognition/ContextWorkspaceTest.kt new file mode 100644 index 00000000..610f2fa1 --- /dev/null +++ b/Partner-Core/src/test/java/work/slhaf/partner/core/cognition/ContextWorkspaceTest.kt @@ -0,0 +1,249 @@ +package work.slhaf.partner.core.cognition + +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class ContextWorkspaceTest { + + @Test + fun `sameWith uses blockName and source only`() { + val left = contextBlock( + blockName = "memory", + source = "main", + content = "left", + compactContent = "left-compact", + abstractContent = "left-abstract" + ) + val sameSource = contextBlock( + blockName = "memory", + source = "main", + content = "right" + ) + val differentSource = contextBlock( + blockName = "memory", + source = "backup", + content = "right" + ) + + assertTrue(left.sameWith(sameSource)) + assertFalse(left.sameWith(differentSource)) + } + + @Test + fun `resolve sorts by accumulated domain weight across sources`() { + val manager = ContextWorkspace() + val lowWeight = contextBlock( + blockName = "low", + source = "source-low", + content = "low", + visibleTo = setOf(ContextBlock.VisibleDomain.COGNITION) + ) + val midWeight = contextBlock( + blockName = "mid", + source = "source-mid", + content = "mid", + visibleTo = setOf(ContextBlock.VisibleDomain.MEMORY) + ) + val highWeight = contextBlock( + blockName = "high", + source = "source-high", + content = "high", + visibleTo = setOf(ContextBlock.VisibleDomain.ACTION, ContextBlock.VisibleDomain.MEMORY) + ) + + manager.register(highWeight) + manager.register(lowWeight) + manager.register(midWeight) + + val resolved = manager.resolve( + listOf( + ContextBlock.VisibleDomain.ACTION, + ContextBlock.VisibleDomain.MEMORY, + ContextBlock.VisibleDomain.COGNITION + ) + ) + + assertEquals( + listOf("low", "mid", "high"), + resolved.map { (it as TestBlockContent).content } + ) + } + + @Test + fun `resolve uses activation score only within same source`() { + val manager = ContextWorkspace() + val older = contextBlock( + blockName = "memory", + source = "main", + content = "older", + replaceFadeFactor = 20.0, + visibleTo = setOf(ContextBlock.VisibleDomain.MEMORY) + ) + val newer = contextBlock( + blockName = "memory", + source = "main", + content = "newer", + replaceFadeFactor = 20.0, + visibleTo = setOf(ContextBlock.VisibleDomain.MEMORY) + ) + val otherSource = contextBlock( + blockName = "memory", + source = "secondary", + content = "other-source", + visibleTo = setOf(ContextBlock.VisibleDomain.MEMORY) + ) + + manager.register(older) + manager.register(newer) + manager.register(otherSource) + + val resolved = manager.resolve(listOf(ContextBlock.VisibleDomain.MEMORY)) + + assertEquals( + listOf("older", "newer", "other-source"), + resolved.map { (it as TestBlockContent).content } + ) + } + + @Test + fun `register fades matching source blocks and removes zero score ones`() { + val manager = ContextWorkspace() + val evicted = contextBlock( + blockName = "memory", + source = "main", + content = "evicted", + replaceFadeFactor = 100.0, + visibleTo = setOf(ContextBlock.VisibleDomain.MEMORY) + ) + val replacement = contextBlock( + blockName = "memory", + source = "main", + content = "replacement", + replaceFadeFactor = 100.0, + visibleTo = setOf(ContextBlock.VisibleDomain.MEMORY) + ) + + manager.register(evicted) + manager.register(replacement) + + val resolved = manager.resolve(listOf(ContextBlock.VisibleDomain.MEMORY)) + + assertEquals(listOf("replacement"), resolved.map { (it as TestBlockContent).content }) + } + + @Test + fun `expire removes all matching source blocks`() { + val manager = ContextWorkspace() + val first = contextBlock( + blockName = "memory", + source = "main", + content = "first" + ) + val second = contextBlock( + blockName = "memory", + source = "main", + content = "second" + ) + val survivor = contextBlock( + blockName = "memory", + source = "secondary", + content = "survivor" + ) + + manager.register(first) + manager.register(second) + manager.register(survivor) + + manager.expire("memory", "main") + + val resolved = manager.resolve(listOf(ContextBlock.VisibleDomain.MEMORY)) + + assertEquals(listOf("survivor"), resolved.map { (it as TestBlockContent).content }) + } + + @Test + fun `expire allows same source to be registered again`() { + val manager = ContextWorkspace() + val original = contextBlock( + blockName = "memory", + source = "main", + content = "original" + ) + val restored = contextBlock( + blockName = "memory", + source = "main", + content = "restored" + ) + + manager.register(original) + manager.expire("memory", "main") + manager.register(restored) + + val resolved = manager.resolve(listOf(ContextBlock.VisibleDomain.MEMORY)) + + assertEquals(listOf("restored"), resolved.map { (it as TestBlockContent).content }) + } + + @Test + fun `render switches projections by activation score`() { + val original = TestBlockContent("memory", "main", "full") + val compact = TestBlockContent("memory", "main", "compact") + val summary = TestBlockContent("memory", "main", "summary") + + val compactBlock = ContextBlock( + blockContent = original, + compactBlock = compact, + abstractBlock = summary, + visibleTo = setOf(ContextBlock.VisibleDomain.MEMORY), + replaceFadeFactor = 40.0, + timeFadeFactor = 0.0, + activateFactor = 0.0 + ) + compactBlock.applyReplaceFade() + assertSame(compact, compactBlock.render()) + + val summaryBlock = ContextBlock( + blockContent = original, + compactBlock = compact, + abstractBlock = summary, + visibleTo = setOf(ContextBlock.VisibleDomain.MEMORY), + replaceFadeFactor = 80.0, + timeFadeFactor = 0.0, + activateFactor = 0.0 + ) + summaryBlock.applyReplaceFade() + assertSame(summary, summaryBlock.render()) + } + + private fun contextBlock( + blockName: String, + source: String, + content: String, + compactContent: String = "${content}-compact", + abstractContent: String = "${content}-abstract", + visibleTo: Set = setOf(ContextBlock.VisibleDomain.MEMORY), + replaceFadeFactor: Double = 10.0, + timeFadeFactor: Double = 0.0, + activateFactor: Double = 0.0 + ): ContextBlock { + return ContextBlock( + blockContent = TestBlockContent(blockName, source, content), + compactBlock = TestBlockContent(blockName, source, compactContent), + abstractBlock = TestBlockContent(blockName, source, abstractContent), + visibleTo = visibleTo, + replaceFadeFactor = replaceFadeFactor, + timeFadeFactor = timeFadeFactor, + activateFactor = activateFactor + ) + } + + private class TestBlockContent( + blockName: String, + source: String, + val content: String + ) : BlockContent(blockName, source) { + override fun fillXml(document: org.w3c.dom.Document, root: org.w3c.dom.Element) { + appendTextElement(document, root, "content", content) + } + } +}