diff --git a/.tegami/2026-08-10-assistant-governed-file-evidence.md b/.tegami/2026-08-10-assistant-governed-file-evidence.md new file mode 100644 index 000000000..0ebf00340 --- /dev/null +++ b/.tegami/2026-08-10-assistant-governed-file-evidence.md @@ -0,0 +1,20 @@ +--- +packages: + orgmemory: minor +subject: Use governed files as Assistant evidence +--- + +## Features + +- Upload up to three supported documents from the Assistant composer, publish + them to a chosen Knowledge Space, and wait for governed ingestion before use. +- Keep the exact ordered file selection across a failed retry and cite the same + permission-verified evidence used for the answer. + +## Security + +- Recheck conversation ownership, current Source revision, actor access, and + active retrieval-engine readiness before each selected-file turn. +- Keep selected files as a hard retrieval ceiling through graph expansion and + citation output; direct provider files and transient attachment bypasses + remain unavailable. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c07f2c5f5..cf569d77d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -163,7 +163,11 @@ Executive facts and resolve organization/department existence without exposing Organization persistence or roles. Source Ledger resolves tenant-scoped ready revision plus validated blob state through `SourceCitationEvidenceQuery`, so citation opening consumes immutable evidence rather than revision/blob -persistence. Asset has no direct dependency on Retrieval and is a closed +persistence. Parent Knowledge also exposes the exact `knowledge::evidence` +named interface for governed byte registration and exact Source/revision state. +Source Ledger implements it through the canonical upload and query services; +Assistant consumes it without importing Source Ledger persistence, parsing, or +processing types. Asset has no direct dependency on Retrieval and is a closed nested module with an exact outgoing dependency allowlist. Parent Knowledge exposes the stable permission-aware search contract, immutable evidence, secure result, and verified grounding as @@ -331,6 +335,14 @@ an in-flight turn may finish under its request snapshot and is bounded by the configured turn timeout. Missing, unknown, stale, unsupported, changed, or denied retrieval decisions fail closed. +Assistant governed-file turns add an immutable `KnowledgeEvidenceSelection` +after actor authorization. Canonical retrieval intersects the selected Assets +before ranking; GraphRAG carries the same ceiling through seed, expansion, +closure verification, and citation output. The selection pins exact binding, +Source, revision, and Asset identity, and every selected Source must contribute +usable final evidence before generation. Upload remains the ordinary durable +Source pipeline; the API never parses or embeds the multipart bytes. + ACL evidence is sealed and append-only. ACL rotation appends a new generation and compare-and-set advances the current head. The current head has a 24-hour freshness requirement; the ingestion snapshot remains a historical ceiling. diff --git a/apps/api/src/main/java/com/orgmemory/api/assistant/AssistantChatRequest.java b/apps/api/src/main/java/com/orgmemory/api/assistant/AssistantChatRequest.java index eff892b23..d68e65d9b 100644 --- a/apps/api/src/main/java/com/orgmemory/api/assistant/AssistantChatRequest.java +++ b/apps/api/src/main/java/com/orgmemory/api/assistant/AssistantChatRequest.java @@ -2,11 +2,21 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; +import java.util.List; import java.util.UUID; record AssistantChatRequest( - @NotBlank @Size(max = 1_000) String message, + @NotBlank @Size(max = 8_000) String message, Integer limit, UUID conversationId, - UUID modelActivationId) { + UUID modelActivationId, + @Size(max = 3) List evidenceBindingIds) { + + AssistantChatRequest( + String message, + Integer limit, + UUID conversationId, + UUID modelActivationId) { + this(message, limit, conversationId, modelActivationId, List.of()); + } } diff --git a/apps/api/src/main/java/com/orgmemory/api/assistant/AssistantConfiguration.java b/apps/api/src/main/java/com/orgmemory/api/assistant/AssistantConfiguration.java index 11880ba27..3bccf7abf 100644 --- a/apps/api/src/main/java/com/orgmemory/api/assistant/AssistantConfiguration.java +++ b/apps/api/src/main/java/com/orgmemory/api/assistant/AssistantConfiguration.java @@ -4,6 +4,7 @@ import com.orgmemory.core.assistant.AssistantAssetToolService; import com.orgmemory.core.assistant.AssistantAgentModelPort; import com.orgmemory.core.assistant.AssistantAssetTraceRecorder; +import com.orgmemory.core.assistant.AssistantEvidenceAnswerabilityPort; import com.orgmemory.core.assistant.AssistantService; import com.orgmemory.core.assistant.observability.AssistantStageEventSink; import com.orgmemory.core.assistant.observability.AssistantTurnEvent; @@ -14,6 +15,7 @@ import com.orgmemory.core.assetregistry.promptcontract.PromptAssistantOperations; import com.orgmemory.core.knowledge.retrieval.CanonicalHybridKnowledgeSearch; import com.orgmemory.core.knowledge.retrieval.GraphRagKnowledgeRetrievalService; +import com.orgmemory.core.knowledge.graph.GraphEvidenceAnswerabilityQuery; import com.orgmemory.core.knowledge.search.PermissionAwareKnowledgeSearch; import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.observation.ObservationRegistry; @@ -71,6 +73,29 @@ PermissionAwareKnowledgeSearch permissionAwareKnowledgeSearch( }; } + @Bean + AssistantEvidenceAnswerabilityPort assistantEvidenceAnswerability( + AssistantProperties properties, + ObjectProvider graphAnswerability) { + return switch (properties.retrievalEngine()) { + case CANONICAL_HYBRID -> source -> + AssistantEvidenceAnswerabilityPort.Answerability.ready(); + case GRAPH_RAG -> source -> { + var graph = graphAnswerability.getIfAvailable(() -> { + throw new IllegalStateException( + "Assistant retrieval engine GRAPH_RAG requires graph evidence readiness"); + }); + var answerability = graph.evaluate(source); + return switch (answerability.state()) { + case INDEXING -> AssistantEvidenceAnswerabilityPort.Answerability.indexing(); + case READY -> AssistantEvidenceAnswerabilityPort.Answerability.ready(); + case FAILED -> AssistantEvidenceAnswerabilityPort.Answerability.failed( + answerability.failureCode()); + }; + }; + }; + } + @Bean AssistantService assistantService( PermissionAwareKnowledgeSearch retrieval, diff --git a/apps/api/src/main/java/com/orgmemory/api/assistant/AssistantController.java b/apps/api/src/main/java/com/orgmemory/api/assistant/AssistantController.java index 65bf78329..30579703f 100644 --- a/apps/api/src/main/java/com/orgmemory/api/assistant/AssistantController.java +++ b/apps/api/src/main/java/com/orgmemory/api/assistant/AssistantController.java @@ -1,5 +1,6 @@ package com.orgmemory.api.assistant; +import com.orgmemory.api.ApiRequestException; import com.orgmemory.api.security.CurrentActorProvider; import com.orgmemory.core.assistant.AssistantAnswerFeedbackView; import com.orgmemory.core.assistant.AssistantAnswerSentiment; @@ -8,6 +9,10 @@ import com.orgmemory.core.assistant.AssistantConversationMessageView; import com.orgmemory.core.assistant.AssistantConversationService; import com.orgmemory.core.assistant.AssistantConversationSummary; +import com.orgmemory.core.assistant.AssistantEvidenceTurnClaim; +import com.orgmemory.core.assistant.AssistantEvidenceBindingView; +import com.orgmemory.core.assistant.AssistantEvidenceService; +import com.orgmemory.core.assistant.AssistantEvidenceUploadService; import com.orgmemory.core.assistant.AssistantService; import com.orgmemory.core.assistant.AssistantTurn; import com.orgmemory.core.assistant.AssistantTurnRef; @@ -17,12 +22,15 @@ import com.orgmemory.core.ai.AssistantModelSelectionRef; import com.orgmemory.core.knowledge.retrieval.CitationEvidenceReference; import com.orgmemory.core.knowledge.retrieval.CitationEvidenceService; +import com.orgmemory.core.knowledge.search.KnowledgeEvidenceSelection; import com.orgmemory.core.organization.CurrentActor; +import com.orgmemory.core.permission.KnowledgeClassification; import io.swagger.v3.oas.annotations.Operation; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; +import java.io.IOException; import java.util.List; import java.util.Map; import java.util.UUID; @@ -43,8 +51,10 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; import reactor.core.publisher.Flux; import tools.jackson.databind.ObjectMapper; @@ -75,6 +85,8 @@ class AssistantController { private final AssistantModelAuthorityService modelAuthority; private final CitationEvidenceService citationEvidence; private final AssistantRetrievalScheduler retrievalScheduler; + private final AssistantEvidenceUploadService evidenceUploads; + private final AssistantEvidenceService evidence; private final ObjectMapper json; AssistantController( @@ -85,6 +97,8 @@ class AssistantController { AssistantModelAuthorityService modelAuthority, CitationEvidenceService citationEvidence, AssistantRetrievalScheduler retrievalScheduler, + AssistantEvidenceUploadService evidenceUploads, + AssistantEvidenceService evidence, ObjectMapper json) { this.assistant = assistant; this.conversations = conversations; @@ -93,9 +107,66 @@ class AssistantController { this.modelAuthority = modelAuthority; this.citationEvidence = citationEvidence; this.retrievalScheduler = retrievalScheduler; + this.evidenceUploads = evidenceUploads; + this.evidence = evidence; this.json = json; } + @PostMapping(path = "/evidence", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @ResponseStatus(HttpStatus.CREATED) + @Operation( + operationId = "uploadAssistantEvidence", + summary = "Upload one governed file and bind it to an Assistant conversation") + AssistantEvidenceBindingView uploadEvidence( + @RequestPart("file") MultipartFile file, + @RequestParam(required = false) UUID conversationId, + @RequestParam UUID knowledgeSpaceId, + @RequestParam(defaultValue = "CONFIDENTIAL") + KnowledgeClassification classification, + Authentication authentication) { + CurrentActor actor = actors.current(authentication); + try (var content = file.getInputStream()) { + return evidenceUploads.upload( + actor, + conversationId, + knowledgeSpaceId, + classification, + file.getOriginalFilename(), + file.getSize(), + content); + } catch (IOException failure) { + throw new ApiRequestException( + "The uploaded Assistant file could not be read", + failure); + } + } + + @GetMapping("/conversations/{conversationId}/evidence/{bindingId}") + @Operation( + operationId = "getAssistantEvidence", + summary = "Read the active-engine preparation state of one owned binding") + AssistantEvidenceBindingView evidence( + @PathVariable UUID conversationId, + @PathVariable UUID bindingId, + Authentication authentication) { + return evidence.get( + actors.current(authentication), + conversationId, + bindingId); + } + + @GetMapping("/conversations/{conversationId}/evidence") + @Operation( + operationId = "listAssistantEvidence", + summary = "List governed file bindings for one owned conversation") + List evidence( + @PathVariable UUID conversationId, + Authentication authentication) { + return evidence.list( + actors.current(authentication), + conversationId); + } + @PostMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE) @Operation(operationId = "streamAssistantChat", summary = "Stream an answer from permission-verified knowledge") ResponseEntity>> chat( @@ -108,11 +179,24 @@ ResponseEntity>> chat( request.modelActivationId()); AssistantModelSelectionRef modelSelection = modelAuthority.selectionRef( routeAuthority); - AssistantTurnRef turnRef = conversations.beginTurn( - actor, - request.conversationId(), - request.message(), - modelSelection); + List requestedEvidence = request.evidenceBindingIds() == null + ? List.of() + : request.evidenceBindingIds(); + AssistantEvidenceTurnClaim turnClaim = requestedEvidence.isEmpty() + ? new AssistantEvidenceTurnClaim( + conversations.beginTurn( + actor, + request.conversationId(), + request.message(), + modelSelection), + KnowledgeEvidenceSelection.unrestricted()) + : conversations.beginTurnWithEvidence( + actor, + request.conversationId(), + request.message(), + modelSelection, + requestedEvidence); + AssistantTurnRef turnRef = turnClaim.turn(); UUID conversationId = turnRef.conversationId(); UUID assistantMessageId = UUID.randomUUID(); Flux parts = Flux.defer(() -> { @@ -131,7 +215,8 @@ ResponseEntity>> chat( requestId, conversationId.toString(), routeAuthority, - turnStartedAtNanos)) + turnStartedAtNanos, + turnClaim.selection())) .flatMapMany(turn -> completedTurnParts( actor, turnRef, diff --git a/apps/api/src/test/java/com/orgmemory/api/OpenApiContractTests.java b/apps/api/src/test/java/com/orgmemory/api/OpenApiContractTests.java index 7c86187a6..54ca23eec 100644 --- a/apps/api/src/test/java/com/orgmemory/api/OpenApiContractTests.java +++ b/apps/api/src/test/java/com/orgmemory/api/OpenApiContractTests.java @@ -7,6 +7,8 @@ import com.fasterxml.jackson.databind.ObjectMapper; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Set; +import java.util.TreeSet; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; @@ -50,6 +52,26 @@ void theCommittedScimContractDescribesOnlyTheLiveScimApi() throws Exception { verifyContract("scim", "scim-openapi.json", "https://memory.company.com"); } + @Test + void assistantEvidenceContractHasNoBindExistingSourceEndpoint() throws Exception { + ObjectMapper objectMapper = new ObjectMapper(); + JsonNode contract = objectMapper.readTree( + Files.readString(repositoryRoot().resolve("contracts/openapi.json"))); + TreeSet evidencePaths = new TreeSet<>(); + contract.path("paths").fieldNames().forEachRemaining(path -> { + if (path.startsWith("/api/assistant") && path.contains("evidence")) { + evidencePaths.add(path); + } + }); + + assertEquals( + Set.of( + "/api/assistant/evidence", + "/api/assistant/conversations/{conversationId}/evidence", + "/api/assistant/conversations/{conversationId}/evidence/{bindingId}"), + evidencePaths); + } + private void verifyContract(String group, String fileName, String serverUrl) throws Exception { String generated = mockMvc.perform(get("/v3/api-docs/{group}", group)) diff --git a/apps/api/src/test/java/com/orgmemory/api/assistant/AssistantChatRequestValidationTests.java b/apps/api/src/test/java/com/orgmemory/api/assistant/AssistantChatRequestValidationTests.java index 3fbfc292e..8d758325a 100644 --- a/apps/api/src/test/java/com/orgmemory/api/assistant/AssistantChatRequestValidationTests.java +++ b/apps/api/src/test/java/com/orgmemory/api/assistant/AssistantChatRequestValidationTests.java @@ -12,9 +12,13 @@ import com.orgmemory.api.security.CurrentActorProvider; import com.orgmemory.core.ai.AssistantModelAuthorityService; import com.orgmemory.core.assistant.AssistantConversationService; +import com.orgmemory.core.assistant.AssistantEvidenceService; +import com.orgmemory.core.assistant.AssistantEvidenceUploadService; import com.orgmemory.core.assistant.AssistantService; import com.orgmemory.core.knowledge.retrieval.CitationEvidenceService; import jakarta.validation.Validation; +import java.util.List; +import java.util.UUID; import org.junit.jupiter.api.Test; import tools.jackson.databind.ObjectMapper; @@ -25,9 +29,9 @@ void enforcesTheMessageLimitBoundary() { try (var factory = Validation.buildDefaultValidatorFactory()) { var validator = factory.getValidator(); var accepted = validator.validate( - new AssistantChatRequest("a".repeat(1_000), null, null, null)); + new AssistantChatRequest("a".repeat(8_000), null, null, null)); var rejected = validator.validate( - new AssistantChatRequest("a".repeat(1_001), null, null, null)); + new AssistantChatRequest("a".repeat(8_001), null, null, null)); assertEquals(0, accepted.size()); assertEquals(1, rejected.size()); @@ -37,6 +41,27 @@ void enforcesTheMessageLimitBoundary() { } } + @Test + void limitsOneTurnToThreeEvidenceBindings() { + try (var factory = Validation.buildDefaultValidatorFactory()) { + var violations = factory.getValidator().validate(new AssistantChatRequest( + "Compare these files", + null, + null, + null, + List.of( + UUID.randomUUID(), + UUID.randomUUID(), + UUID.randomUUID(), + UUID.randomUUID()))); + + assertEquals(1, violations.size()); + assertEquals( + "evidenceBindingIds", + violations.iterator().next().getPropertyPath().toString()); + } + } + @Test void rejectsAnOversizedMessageBeforeOpeningTheStreamOrCreatingATurn() throws Exception { var assistant = mock(AssistantService.class); @@ -55,13 +80,15 @@ void rejectsAnOversizedMessageBeforeOpeningTheStreamOrCreatingATurn() throws Exc modelAuthority, citationEvidence, retrievalScheduler, + mock(AssistantEvidenceUploadService.class), + mock(AssistantEvidenceService.class), json)) .build(); mvc.perform(post("/api/assistant/chat") .contentType(APPLICATION_JSON) .accept(TEXT_EVENT_STREAM) - .content("{\"message\":\"" + "a".repeat(1_001) + "\"}")) + .content("{\"message\":\"" + "a".repeat(8_001) + "\"}")) .andExpect(status().isBadRequest()); verifyNoInteractions( diff --git a/apps/api/src/test/java/com/orgmemory/api/assistant/AssistantControllerStreamingTests.java b/apps/api/src/test/java/com/orgmemory/api/assistant/AssistantControllerStreamingTests.java index b8e54bb2e..d5509115e 100644 --- a/apps/api/src/test/java/com/orgmemory/api/assistant/AssistantControllerStreamingTests.java +++ b/apps/api/src/test/java/com/orgmemory/api/assistant/AssistantControllerStreamingTests.java @@ -22,9 +22,12 @@ import com.orgmemory.core.assistant.AssistantCitation; import com.orgmemory.core.assistant.AssistantCitationReference; import com.orgmemory.core.assistant.AssistantConversationService; +import com.orgmemory.core.assistant.AssistantEvidenceService; +import com.orgmemory.core.assistant.AssistantEvidenceUploadService; import com.orgmemory.core.assistant.AssistantService; import com.orgmemory.core.assistant.AssistantTurn; import com.orgmemory.core.assistant.AssistantTurnRef; +import com.orgmemory.core.knowledge.search.KnowledgeEvidenceSelection; import com.orgmemory.core.knowledge.search.RetrievedKnowledgeEvidence; import com.orgmemory.core.knowledge.retrieval.CitationEvidenceService; import com.orgmemory.core.knowledge.retrieval.CitationEvidenceReference; @@ -92,6 +95,8 @@ void delegatesFeedbackThroughTheAuthenticatedActor() { mock(AssistantModelAuthorityService.class), mock(CitationEvidenceService.class), mock(AssistantRetrievalScheduler.class), + mock(AssistantEvidenceUploadService.class), + mock(AssistantEvidenceService.class), mock(ObjectMapper.class)); AssistantAnswerFeedbackView actual = controller.setFeedback( @@ -151,6 +156,8 @@ void exposesSafeModelChoicesAndPersistsOnlyAnAuthorizedSelectionReference() { authority, mock(CitationEvidenceService.class), mock(AssistantRetrievalScheduler.class), + mock(AssistantEvidenceUploadService.class), + mock(AssistantEvidenceService.class), mock(ObjectMapper.class)); AssistantController.AssistantModelOptionsResponse response = @@ -196,6 +203,8 @@ void hydratesOnlyCurrentlyVisibleCitationsWithoutCachingTheAuthorizationResult() mock(AssistantModelAuthorityService.class), evidenceService, mock(AssistantRetrievalScheduler.class), + mock(AssistantEvidenceUploadService.class), + mock(AssistantEvidenceService.class), mock(ObjectMapper.class)); var response = controller.citations(messageId, authentication); @@ -234,7 +243,8 @@ void usesOneServerOwnedIdentityForTheStreamAndPersistedAnswer() { anyString(), eq(conversationId.toString()), isNull(), - anyLong())) + anyLong(), + eq(KnowledgeEvidenceSelection.unrestricted()))) .thenReturn(new AssistantTurn( "request-1", List.of(), reactor.core.publisher.Flux.just("Answer"))); when(properties.heartbeatInterval()).thenReturn(Duration.ofHours(1)); @@ -248,6 +258,8 @@ void usesOneServerOwnedIdentityForTheStreamAndPersistedAnswer() { mock(AssistantModelAuthorityService.class), mock(CitationEvidenceService.class), scheduler, + mock(AssistantEvidenceUploadService.class), + mock(AssistantEvidenceService.class), new ObjectMapper()); List frames = controller.chat( @@ -300,7 +312,8 @@ void emitsStreamStartAndRetrievalActivityWhileRetrievalIsStillBlocked() anyString(), eq(conversationId.toString()), isNull(), - anyLong())) + anyLong(), + eq(KnowledgeEvidenceSelection.unrestricted()))) .thenAnswer(invocation -> { retrievalEntered.countDown(); releaseRetrieval.await(); @@ -320,6 +333,8 @@ void emitsStreamStartAndRetrievalActivityWhileRetrievalIsStillBlocked() mock(AssistantModelAuthorityService.class), mock(CitationEvidenceService.class), scheduler, + mock(AssistantEvidenceUploadService.class), + mock(AssistantEvidenceService.class), new ObjectMapper()); try { @@ -427,6 +442,8 @@ void deletesAConversationThroughTheOwnedTranscriptAlone() { mock(AssistantModelAuthorityService.class), mock(CitationEvidenceService.class), mock(AssistantRetrievalScheduler.class), + mock(AssistantEvidenceUploadService.class), + mock(AssistantEvidenceService.class), mock(ObjectMapper.class)); controller.delete(conversationId, authentication); @@ -446,6 +463,8 @@ private static AssistantController controller() { mock(AssistantModelAuthorityService.class), mock(CitationEvidenceService.class), mock(AssistantRetrievalScheduler.class), + mock(AssistantEvidenceUploadService.class), + mock(AssistantEvidenceService.class), mock(ObjectMapper.class)); } diff --git a/apps/docs/content/docs/reference/api-reference/assistant.mdx b/apps/docs/content/docs/reference/api-reference/assistant.mdx index 175ff7b43..306fb1de2 100644 --- a/apps/docs/content/docs/reference/api-reference/assistant.mdx +++ b/apps/docs/content/docs/reference/api-reference/assistant.mdx @@ -7,7 +7,7 @@ audience: status: public sourceRefs: - contracts/openapi.json -lastReviewed: '2026-07-29' +lastReviewed: '2026-08-10' _openapi: preload: - orgmemory-public @@ -46,6 +46,9 @@ _openapi: - depth: 2 title: Submit feedback against an exact release after explicit confirmation url: '#submit-feedback-against-an-exact-release-after-explicit-confirmation' + - depth: 2 + title: Upload one governed file and bind it to an Assistant conversation + url: '#upload-one-governed-file-and-bind-it-to-an-assistant-conversation' - depth: 2 title: Stream an answer from permission-verified knowledge url: '#stream-an-answer-from-permission-verified-knowledge' @@ -79,6 +82,12 @@ _openapi: - depth: 2 title: Replay a tenant- and actor-scoped full conversation transcript url: '#replay-a-tenant--and-actor-scoped-full-conversation-transcript' + - depth: 2 + title: List governed file bindings for one owned conversation + url: '#list-governed-file-bindings-for-one-owned-conversation' + - depth: 2 + title: Read the active-engine preparation state of one owned binding + url: '#read-the-active-engine-preparation-state-of-one-owned-binding' structuredData: headings: - content: Update actor-derived Pack progress after explicit confirmation @@ -105,6 +114,8 @@ _openapi: id: fork-an-exact-release-after-explicit-draft-creation-confirmation - content: Submit feedback against an exact release after explicit confirmation id: submit-feedback-against-an-exact-release-after-explicit-confirmation + - content: Upload one governed file and bind it to an Assistant conversation + id: upload-one-governed-file-and-bind-it-to-an-assistant-conversation - content: Stream an answer from permission-verified knowledge id: stream-an-answer-from-permission-verified-knowledge - content: Rename the current actor's conversation @@ -127,6 +138,10 @@ _openapi: id: list-the-current-actors-conversations-by-recent-activity - content: Replay a tenant- and actor-scoped full conversation transcript id: replay-a-tenant--and-actor-scoped-full-conversation-transcript + - content: List governed file bindings for one owned conversation + id: list-governed-file-bindings-for-one-owned-conversation + - content: Read the active-engine preparation state of one owned binding + id: read-the-active-engine-preparation-state-of-one-owned-binding contents: [] --- @@ -139,7 +154,7 @@ export default function Layout(props) { return ( <> {props.children} - + ); } diff --git a/apps/docs/generated/openapi.public.json b/apps/docs/generated/openapi.public.json index 203223fc8..32e60b14d 100644 --- a/apps/docs/generated/openapi.public.json +++ b/apps/docs/generated/openapi.public.json @@ -1634,6 +1634,80 @@ } } }, + "/api/assistant/evidence": { + "post": { + "tags": [ + "Assistant" + ], + "summary": "Upload one governed file and bind it to an Assistant conversation", + "operationId": "uploadAssistantEvidence", + "parameters": [ + { + "name": "conversationId", + "in": "query", + "required": false, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "knowledgeSpaceId", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "classification", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "CONFIDENTIAL", + "enum": [ + "PUBLIC", + "INTERNAL", + "CONFIDENTIAL", + "RESTRICTED" + ] + } + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "file": { + "type": "string", + "format": "binary" + } + }, + "required": [ + "file" + ] + } + } + } + }, + "responses": { + "201": { + "description": "Created", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/AssistantEvidenceBindingView" + } + } + } + } + } + } + }, "/api/assistant/chat": { "post": { "tags": [ @@ -4455,6 +4529,82 @@ } } }, + "/api/assistant/conversations/{conversationId}/evidence": { + "get": { + "tags": [ + "Assistant" + ], + "summary": "List governed file bindings for one owned conversation", + "operationId": "listAssistantEvidence", + "parameters": [ + { + "name": "conversationId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AssistantEvidenceBindingView" + } + } + } + } + } + } + } + }, + "/api/assistant/conversations/{conversationId}/evidence/{bindingId}": { + "get": { + "tags": [ + "Assistant" + ], + "summary": "Read the active-engine preparation state of one owned binding", + "operationId": "getAssistantEvidence", + "parameters": [ + { + "name": "conversationId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "bindingId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/AssistantEvidenceBindingView" + } + } + } + } + } + } + }, "/api/assets/{assetId}": { "get": { "tags": [ @@ -7726,12 +7876,60 @@ } } }, + "AssistantEvidenceBindingView": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "conversationId": { + "type": "string", + "format": "uuid" + }, + "sourceObjectId": { + "type": "string", + "format": "uuid" + }, + "sourceRevisionId": { + "type": "string", + "format": "uuid" + }, + "knowledgeAssetId": { + "type": "string", + "format": "uuid" + }, + "title": { + "type": "string" + }, + "fileName": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "PROCESSING", + "INDEXING", + "READY", + "FAILED", + "UNAVAILABLE" + ] + }, + "failureCode": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + } + }, "AssistantChatRequest": { "type": "object", "properties": { "message": { "type": "string", - "maxLength": 1000, + "maxLength": 8000, "minLength": 0 }, "limit": { @@ -7745,6 +7943,15 @@ "modelActivationId": { "type": "string", "format": "uuid" + }, + "evidenceBindingIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "maxItems": 3, + "minItems": 0 } }, "required": [ diff --git a/apps/docs/scripts/generate-openapi.ts b/apps/docs/scripts/generate-openapi.ts index bcaf6e40f..75628195e 100644 --- a/apps/docs/scripts/generate-openapi.ts +++ b/apps/docs/scripts/generate-openapi.ts @@ -215,7 +215,7 @@ const files = await generateFilesOnly({ audience: ['developer'], status: 'public', sourceRefs: ['contracts/openapi.json'], - lastReviewed: '2026-07-29', + lastReviewed: title === 'Assistant' ? '2026-08-10' : '2026-07-29', }; }, }); diff --git a/apps/web/src/features/assistant/api/chat-transport.ts b/apps/web/src/features/assistant/api/chat-transport.ts index e853b526f..f163447ec 100644 --- a/apps/web/src/features/assistant/api/chat-transport.ts +++ b/apps/web/src/features/assistant/api/chat-transport.ts @@ -5,10 +5,12 @@ import { csrfFetch } from "@/features/session/csrf-fetch" export function createAssistantTransport({ conversationId, modelActivationId, + evidenceBindingIds, onConversationId, }: { conversationId: () => string | undefined modelActivationId: () => string | undefined + evidenceBindingIds: () => string[] onConversationId: (conversationId: string) => void }) { return new DefaultChatTransport({ @@ -33,6 +35,7 @@ export function createAssistantTransport({ limit: 5, conversationId: conversationId(), modelActivationId: modelActivationId(), + evidenceBindingIds: evidenceBindingIds(), }, } }, diff --git a/apps/web/src/features/assistant/assistant-draft-storage.test.ts b/apps/web/src/features/assistant/assistant-draft-storage.test.ts index ebb5eae94..04efd9aee 100644 --- a/apps/web/src/features/assistant/assistant-draft-storage.test.ts +++ b/apps/web/src/features/assistant/assistant-draft-storage.test.ts @@ -24,15 +24,15 @@ describe("assistant draft storage", () => { it("bounds drafts persisted by an older client", () => { sessionStorage.setItem( "orgmemory:assistant-draft:v1:actor-a:new", - "x".repeat(1_100), + "x".repeat(8_100), ) - expect(readAssistantDraft("actor-a")).toHaveLength(1_000) + expect(readAssistantDraft("actor-a")).toHaveLength(8_000) }) it("caps drafts at the server message limit and clears lifecycle scopes", () => { - const bounded = writeAssistantDraft("actor-a", "conversation-1", "x".repeat(1_100)) - expect(bounded).toHaveLength(1_000) + const bounded = writeAssistantDraft("actor-a", "conversation-1", "x".repeat(8_100)) + expect(bounded).toHaveLength(8_000) clearAssistantDraft("actor-a", "conversation-1") expect(readAssistantDraft("actor-a", "conversation-1")).toBe("") diff --git a/apps/web/src/features/assistant/assistant-evidence.test.ts b/apps/web/src/features/assistant/assistant-evidence.test.ts new file mode 100644 index 000000000..ae6aae4f4 --- /dev/null +++ b/apps/web/src/features/assistant/assistant-evidence.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest" + +import { + assistantEvidenceReady, + assistantEvidenceShouldPoll, + assistantEvidenceStatusLabel, + assistantEvidenceUploadDisabledReason, +} from "@/features/assistant/assistant-evidence" + +describe("Assistant governed file evidence", () => { + it("requires every selected file to be ready", () => { + expect(assistantEvidenceReady([{ status: "READY" }, { status: "READY" }])).toBe(true) + expect(assistantEvidenceReady([{ status: "READY" }, { status: "INDEXING" }])).toBe(false) + expect(assistantEvidenceReady([{ status: "READY" }, { status: "FAILED" }])).toBe(false) + }) + + it("keeps upload closed without a governed Space and after three selections", () => { + const base = { + busy: false, + uploading: false, + targetsLoading: false, + targetsError: false, + targetCount: 1, + selectedCount: 0, + } + expect(assistantEvidenceUploadDisabledReason({ ...base, targetCount: 0 })) + .toBe("No Knowledge Space is available for governed upload") + expect(assistantEvidenceUploadDisabledReason({ ...base, selectedCount: 3 })) + .toBe("A turn can include at most three files") + expect(assistantEvidenceUploadDisabledReason(base)).toBeUndefined() + }) + + it("keeps polling through missing responses and stops only at terminal states", () => { + expect(assistantEvidenceShouldPoll(undefined)).toBe(true) + expect(assistantEvidenceShouldPoll("PROCESSING")).toBe(true) + expect(assistantEvidenceShouldPoll("INDEXING")).toBe(true) + expect(assistantEvidenceShouldPoll("READY")).toBe(false) + expect(assistantEvidenceShouldPoll("FAILED")).toBe(false) + expect(assistantEvidenceShouldPoll("UNAVAILABLE")).toBe(false) + }) + + it("provides product-facing evidence status labels", () => { + expect(assistantEvidenceStatusLabel("PROCESSING")).toBe("Processing") + expect(assistantEvidenceStatusLabel("INDEXING")).toBe("Indexing") + expect(assistantEvidenceStatusLabel("READY")).toBe("Ready") + expect(assistantEvidenceStatusLabel("UNAVAILABLE")).toBe("Unavailable") + expect(assistantEvidenceStatusLabel(undefined)).toBe("Status unavailable") + }) +}) diff --git a/apps/web/src/features/assistant/assistant-evidence.ts b/apps/web/src/features/assistant/assistant-evidence.ts new file mode 100644 index 000000000..dd7dcca4e --- /dev/null +++ b/apps/web/src/features/assistant/assistant-evidence.ts @@ -0,0 +1,58 @@ +import type { AssistantEvidenceBindingView } from "@/lib/hey-api" + +export const MAX_ASSISTANT_EVIDENCE_FILES = 3 + +export function assistantEvidenceReady(bindings: AssistantEvidenceBindingView[]) { + return bindings.every((binding) => binding.status === "READY") +} + +export function assistantEvidenceShouldPoll( + status: AssistantEvidenceBindingView["status"] | undefined, +) { + return status === undefined || status === "PROCESSING" || status === "INDEXING" +} + +export function assistantEvidenceStatusLabel( + status: AssistantEvidenceBindingView["status"] | undefined, +) { + switch (status) { + case "PROCESSING": + return "Processing" + case "INDEXING": + return "Indexing" + case "READY": + return "Ready" + case "FAILED": + return "Failed" + case "UNAVAILABLE": + return "Unavailable" + default: + return "Status unavailable" + } +} + +export function assistantEvidenceUploadDisabledReason({ + busy, + uploading, + targetsLoading, + targetsError, + targetCount, + selectedCount, +}: { + busy: boolean + uploading: boolean + targetsLoading: boolean + targetsError: boolean + targetCount: number + selectedCount: number +}) { + if (busy) return "Wait for the current turn to finish" + if (uploading) return "A governed file is uploading" + if (targetsLoading) return "Loading available Knowledge Spaces" + if (targetsError) return "Knowledge Spaces could not be loaded" + if (targetCount === 0) return "No Knowledge Space is available for governed upload" + if (selectedCount >= MAX_ASSISTANT_EVIDENCE_FILES) { + return "A turn can include at most three files" + } + return undefined +} diff --git a/apps/web/src/features/assistant/assistant-message-constraints.ts b/apps/web/src/features/assistant/assistant-message-constraints.ts index fb4531947..755f3fcbf 100644 --- a/apps/web/src/features/assistant/assistant-message-constraints.ts +++ b/apps/web/src/features/assistant/assistant-message-constraints.ts @@ -1 +1 @@ -export const ASSISTANT_MESSAGE_MAX_CHARACTERS = 1_000 +export const ASSISTANT_MESSAGE_MAX_CHARACTERS = 8_000 diff --git a/apps/web/src/features/assistant/components/assistant-page.tsx b/apps/web/src/features/assistant/components/assistant-page.tsx index 88f9376be..2518d5de4 100644 --- a/apps/web/src/features/assistant/components/assistant-page.tsx +++ b/apps/web/src/features/assistant/components/assistant-page.tsx @@ -1,17 +1,20 @@ import { useChat } from "@ai-sdk/react" -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query" import { type SourceUrlUIPart, type UIMessage } from "ai" import { Bot, Check, ChevronsUpDown, Copy, + FileText, LoaderCircle, + Paperclip, RotateCcw, ThumbsDown, ThumbsUp, + X, } from "lucide-react" -import { Fragment, type ReactNode, type RefObject } from "react" +import { Fragment, lazy, Suspense, type ReactNode, type RefObject } from "react" import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { toast } from "sonner" @@ -43,6 +46,7 @@ import { PromptInputBody, PromptInputButton, PromptInputFooter, + PromptInputHeader, type PromptInputMessage, PromptInputSubmit, PromptInputTextarea, @@ -53,6 +57,13 @@ import { Suggestion, Suggestions } from "@/components/ai-elements/suggestion" import { Button } from "@/components/ui/button" import { createAssistantTransport } from "@/features/assistant/api/chat-transport" import { ASSISTANT_MESSAGE_MAX_CHARACTERS } from "@/features/assistant/assistant-message-constraints" +import { + assistantEvidenceReady, + assistantEvidenceShouldPoll, + assistantEvidenceStatusLabel, + assistantEvidenceUploadDisabledReason, + MAX_ASSISTANT_EVIDENCE_FILES, +} from "@/features/assistant/assistant-evidence" import { activityLabel, hasVisibleAssistantOutput, @@ -67,28 +78,39 @@ import { AssistantSourcesPanel, } from "@/features/assistant/components/assistant-sources-panel" import { GovernedDocumentViewer } from "@/features/sources/components/governed-document-viewer" +import type { UploadSourceInput } from "@/features/sources/components/source-upload-dialog" import { useAssistantDraft } from "@/features/assistant/hooks/use-assistant-draft" import { scopeActorQueryKey } from "@/features/session/actor-cache-key" import { copyWithToast } from "@/lib/copy" import { deleteAssistantAnswerFeedbackMutation, getAssistantConversationHistoryOptions, + getAssistantEvidenceOptions, getAssistantMessageCitationsOptions, getAssistantModelOptionsOptions, listAssistantStartersOptions, listAssistantConversationsQueryKey, + listKnowledgeSpaceUploadTargetsOptions, selectAssistantConversationModelMutation, setAssistantAnswerFeedbackMutation, + uploadAssistantEvidenceMutation, } from "@/lib/hey-api/@tanstack/react-query.gen" import type { AssistantConversationMessageView, AssistantConversationSummary, + AssistantEvidenceBindingView, AssistantCitationResponse, AssistantModelOptionResponse, } from "@/lib/hey-api" +import { apiErrorMessage } from "@/lib/api-error" type AnswerSentiment = "HELPFUL" | "NOT_HELPFUL" +const SourceUploadDialog = lazy(async () => { + const module = await import("@/features/sources/components/source-upload-dialog") + return { default: module.SourceUploadDialog } +}) + function textFor(message: UIMessage) { return message.parts .filter((part) => part.type === "text") @@ -357,6 +379,9 @@ function AssistantModelPicker({ const selected = options.find((option) => option.id === selectedId) ?? options.find((option) => option.defaultChoice) + const selectedName = selected?.defaultChoice + ? selected.modelId ?? selected.displayName + : selected?.displayName ?? selected?.modelId const groups = Object.entries( options.reduce>((current, option) => { const gateway = option.gatewayLabel ?? "Organization models" @@ -372,7 +397,7 @@ function AssistantModelPicker({ type="button" size="sm" disabled={disabled || loading || options.length === 0} - aria-label={`Choose model${selected?.displayName ? `, current model ${selected.displayName}` : ""}`} + aria-label={`Choose model${selectedName ? `, current model ${selectedName}` : ""}`} className="max-w-48 rounded-full px-2.5 text-content-secondary hover:text-content-primary" > {selected?.provider && selected.provider !== "custom" ? ( @@ -381,7 +406,7 @@ function AssistantModelPicker({