diff --git a/.tegami/2026-08-04-assistant-interaction-foundation.md b/.tegami/2026-08-04-assistant-interaction-foundation.md new file mode 100644 index 00000000..7d23e4fd --- /dev/null +++ b/.tegami/2026-08-04-assistant-interaction-foundation.md @@ -0,0 +1,11 @@ +--- +packages: + orgmemory: minor +subject: Improve the Assistant conversation experience +--- + +## Features + +The Assistant now restores in-session conversation drafts, offers +server-curated starting prompts, retries completed answers with fresh governed +retrieval, and lets users save helpful or not-helpful feedback on an answer. 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 a0db45c9..7bf43711 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,6 +1,8 @@ package com.orgmemory.api.assistant; import com.orgmemory.api.security.CurrentActorProvider; +import com.orgmemory.core.assistant.AssistantAnswerFeedbackView; +import com.orgmemory.core.assistant.AssistantAnswerSentiment; import com.orgmemory.core.assistant.AssistantCitation; import com.orgmemory.core.assistant.AssistantConversationMessageView; import com.orgmemory.core.assistant.AssistantConversationService; @@ -11,6 +13,7 @@ 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.util.List; import java.util.UUID; @@ -26,6 +29,7 @@ import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.ResponseStatus; @@ -39,6 +43,19 @@ class AssistantController { private static final String UI_MESSAGE_STREAM_HEADER = "x-vercel-ai-ui-message-stream"; private static final String TEXT_PART_ID = "answer"; + private static final List STARTERS = List.of( + new AssistantStarterPrompt( + "people-policy", + "People policy", + "What is the probation policy?"), + new AssistantStarterPrompt( + "travel-expense", + "Travel expenses", + "How do I submit a travel expense claim?"), + new AssistantStarterPrompt( + "release-process", + "Release process", + "What is the product release process?")); private final AssistantService assistant; private final AssistantConversationService conversations; @@ -71,6 +88,7 @@ ResponseEntity>> chat( CurrentActor actor = actors.current(authentication); UUID conversationId = conversations.beginTurn( actor, request.conversationId(), request.message()); + UUID assistantMessageId = UUID.randomUUID(); AssistantTurn turn = assistant.startTurn( actor, request.message(), @@ -85,7 +103,10 @@ ResponseEntity>> chat( } }) .doOnComplete(() -> conversations.completeTurn( - actor, conversationId, completedAnswer.toString())); + actor, + conversationId, + assistantMessageId, + completedAnswer.toString())); return ResponseEntity.ok() .header("X-Request-ID", turn.requestId()) .header("X-Conversation-ID", conversationId.toString()) @@ -95,6 +116,7 @@ ResponseEntity>> chat( .header("X-Accel-Buffering", "no") .body(UiMessageStream.encode( parts, + assistantMessageId, json, properties.heartbeatInterval(), properties.turnTimeout())); @@ -104,6 +126,44 @@ record RenameConversationRequest( @NotBlank @Size(max = 120) String title) { } + record AnswerFeedbackRequest(@NotNull AssistantAnswerSentiment sentiment) { + } + + record AssistantStarterPrompt(String id, String label, String prompt) { + } + + @GetMapping("/starters") + @Operation( + operationId = "listAssistantStarters", + summary = "List supported prompts for starting an Assistant conversation") + List starters() { + return STARTERS; + } + + @PutMapping("/messages/{messageId}/feedback") + @Operation( + operationId = "setAssistantAnswerFeedback", + summary = "Create or replace feedback on an owned Assistant answer") + AssistantAnswerFeedbackView setFeedback( + @PathVariable UUID messageId, + @Valid @RequestBody AnswerFeedbackRequest request, + Authentication authentication) { + return conversations.setAnswerFeedback( + actors.current(authentication), messageId, request.sentiment()); + } + + @DeleteMapping("/messages/{messageId}/feedback") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation( + operationId = "deleteAssistantAnswerFeedback", + summary = "Remove feedback from an owned Assistant answer") + void deleteFeedback( + @PathVariable UUID messageId, + Authentication authentication) { + conversations.deleteAnswerFeedback( + actors.current(authentication), messageId); + } + @GetMapping("/conversations") @Operation( operationId = "listAssistantConversations", diff --git a/apps/api/src/main/java/com/orgmemory/api/assistant/UiMessageStream.java b/apps/api/src/main/java/com/orgmemory/api/assistant/UiMessageStream.java index d6f12b4d..ebde02e7 100644 --- a/apps/api/src/main/java/com/orgmemory/api/assistant/UiMessageStream.java +++ b/apps/api/src/main/java/com/orgmemory/api/assistant/UiMessageStream.java @@ -17,11 +17,12 @@ private UiMessageStream() { static Flux> encode( Flux source, + UUID messageId, ObjectMapper json, Duration heartbeatInterval, Duration turnTimeout) { return Flux.defer(() -> { - Encoder encoder = new Encoder(json); + Encoder encoder = new Encoder(json, messageId); Flux> live = withHeartbeat( limitDuration(source, turnTimeout).map(encoder::part), heartbeatInterval); @@ -61,10 +62,11 @@ private static Flux> withHeartbeat( private static final class Encoder { private final ObjectMapper json; - private final String messageId = UUID.randomUUID().toString(); + private final String messageId; - private Encoder(ObjectMapper json) { + private Encoder(ObjectMapper json, UUID messageId) { this.json = json; + this.messageId = messageId.toString(); } ServerSentEvent start() { diff --git a/apps/api/src/test/java/com/orgmemory/api/assistant/AssistantAnswerFeedbackConcurrencyIntegrationTests.java b/apps/api/src/test/java/com/orgmemory/api/assistant/AssistantAnswerFeedbackConcurrencyIntegrationTests.java new file mode 100644 index 00000000..08026a41 --- /dev/null +++ b/apps/api/src/test/java/com/orgmemory/api/assistant/AssistantAnswerFeedbackConcurrencyIntegrationTests.java @@ -0,0 +1,158 @@ +package com.orgmemory.api.assistant; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.orgmemory.core.assistant.AssistantAnswerSentiment; +import com.orgmemory.core.assistant.AssistantConversationService; +import com.orgmemory.core.organization.CurrentActor; +import com.orgmemory.core.organization.UserRole; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.annotation.DirtiesContext; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.postgresql.PostgreSQLContainer; + +@SpringBootTest +@Testcontainers +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class AssistantAnswerFeedbackConcurrencyIntegrationTests { + + @Container + @ServiceConnection + static PostgreSQLContainer postgres = new PostgreSQLContainer("pgvector/pgvector:pg18"); + + @Autowired + AssistantConversationService conversations; + + @Autowired + JdbcTemplate jdbc; + + @Test + void serializesConcurrentSetOperationsForOneAnswer() throws Exception { + Scenario scenario = scenario(); + + runConcurrently(24, index -> () -> { + conversations.setAnswerFeedback( + scenario.actor(), + scenario.answerId(), + index % 2 == 0 + ? AssistantAnswerSentiment.HELPFUL + : AssistantAnswerSentiment.NOT_HELPFUL); + return null; + }); + + assertEquals( + 1, + jdbc.queryForObject( + "SELECT count(*) FROM assistant_answer_feedback WHERE message_id = ?", + Integer.class, + scenario.answerId())); + } + + @Test + void serializesConcurrentSetAndDeleteOperationsForOneAnswer() throws Exception { + Scenario scenario = scenario(); + conversations.setAnswerFeedback( + scenario.actor(), scenario.answerId(), AssistantAnswerSentiment.HELPFUL); + + runConcurrently(24, index -> () -> { + if (index % 2 == 0) { + conversations.setAnswerFeedback( + scenario.actor(), + scenario.answerId(), + AssistantAnswerSentiment.NOT_HELPFUL); + } else { + conversations.deleteAnswerFeedback(scenario.actor(), scenario.answerId()); + } + return null; + }); + + conversations.setAnswerFeedback( + scenario.actor(), scenario.answerId(), AssistantAnswerSentiment.HELPFUL); + assertEquals( + "HELPFUL", + jdbc.queryForObject( + "SELECT sentiment FROM assistant_answer_feedback WHERE message_id = ?", + String.class, + scenario.answerId())); + } + + private Scenario scenario() { + UUID organizationId = UUID.randomUUID(); + UUID actorId = UUID.randomUUID(); + jdbc.update( + """ + INSERT INTO organizations (id, name, created_at, updated_at, version) + VALUES (?, 'Feedback concurrency', now(), now(), 0) + """, + organizationId); + jdbc.update( + """ + INSERT INTO app_users ( + id, organization_id, name, email, role, active, + created_at, updated_at, version) + VALUES (?, ?, 'Feedback actor', ?, 'EMPLOYEE', true, now(), now(), 0) + """, + actorId, + organizationId, + actorId + "@example.test"); + + CurrentActor actor = new CurrentActor( + actorId, + organizationId, + null, + "Feedback actor", + actorId + "@example.test", + UserRole.EMPLOYEE); + UUID conversationId = conversations.beginTurn(actor, null, "What is the policy?"); + UUID answerId = UUID.randomUUID(); + conversations.completeTurn(actor, conversationId, answerId, "The policy is available."); + return new Scenario(actor, answerId); + } + + private static void runConcurrently( + int attemptCount, AttemptFactory attemptFactory) throws Exception { + CountDownLatch ready = new CountDownLatch(attemptCount); + CountDownLatch start = new CountDownLatch(1); + List> attempts = new ArrayList<>(); + + try (var executor = Executors.newFixedThreadPool(attemptCount)) { + for (int index = 0; index < attemptCount; index++) { + int attemptIndex = index; + attempts.add(executor.submit(() -> { + ready.countDown(); + start.await(); + return attemptFactory.create(attemptIndex).call(); + })); + } + if (!ready.await(10, TimeUnit.SECONDS)) { + throw new IllegalStateException("Feedback attempts did not become ready"); + } + start.countDown(); + for (Future attempt : attempts) { + attempt.get(30, TimeUnit.SECONDS); + } + } + } + + @FunctionalInterface + private interface AttemptFactory { + + Callable create(int index); + } + + private record Scenario(CurrentActor actor, UUID answerId) { + } +} 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 16a6f55e..fe87e2d5 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 @@ -2,20 +2,28 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import com.orgmemory.api.security.CurrentActorProvider; +import com.orgmemory.core.assistant.AssistantAnswerFeedbackView; +import com.orgmemory.core.assistant.AssistantAnswerSentiment; import com.orgmemory.core.assistant.AssistantCitation; import com.orgmemory.core.assistant.AssistantConversationService; import com.orgmemory.core.assistant.AssistantService; import com.orgmemory.core.assistant.AssistantTurn; import com.orgmemory.core.knowledge.search.RetrievedKnowledgeEvidence; import com.orgmemory.core.organization.CurrentActor; +import java.time.Duration; +import java.time.Instant; import java.util.List; import java.util.UUID; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.mockito.InOrder; import org.springframework.ai.chat.memory.ChatMemory; import org.springframework.security.core.Authentication; @@ -25,6 +33,114 @@ class AssistantControllerStreamingTests { + @Test + void publishesClosedServerOwnedStarters() { + List starters = + controller().starters(); + + assertEquals( + List.of( + "What is the probation policy?", + "How do I submit a travel expense claim?", + "What is the product release process?"), + starters.stream() + .map(AssistantController.AssistantStarterPrompt::prompt) + .toList()); + } + + @Test + void delegatesFeedbackThroughTheAuthenticatedActor() { + AssistantConversationService conversations = + mock(AssistantConversationService.class); + CurrentActorProvider actors = mock(CurrentActorProvider.class); + Authentication authentication = mock(Authentication.class); + CurrentActor actor = new CurrentActor( + UUID.randomUUID(), + UUID.randomUUID(), + UUID.randomUUID(), + "Laura", + "laura@example.test"); + UUID messageId = UUID.randomUUID(); + AssistantAnswerFeedbackView expected = new AssistantAnswerFeedbackView( + messageId, + AssistantAnswerSentiment.HELPFUL, + Instant.parse("2026-08-04T10:00:00Z")); + when(actors.current(authentication)).thenReturn(actor); + when(conversations.setAnswerFeedback( + actor, messageId, AssistantAnswerSentiment.HELPFUL)) + .thenReturn(expected); + AssistantController controller = new AssistantController( + mock(AssistantService.class), + conversations, + mock(ChatMemory.class), + actors, + mock(AssistantProperties.class), + mock(ObjectMapper.class)); + + AssistantAnswerFeedbackView actual = controller.setFeedback( + messageId, + new AssistantController.AnswerFeedbackRequest( + AssistantAnswerSentiment.HELPFUL), + authentication); + controller.deleteFeedback(messageId, authentication); + + assertEquals(expected, actual); + verify(conversations).deleteAnswerFeedback(actor, messageId); + } + + @Test + void usesOneServerOwnedIdentityForTheStreamAndPersistedAnswer() { + AssistantService assistant = mock(AssistantService.class); + AssistantConversationService conversations = + mock(AssistantConversationService.class); + CurrentActorProvider actors = mock(CurrentActorProvider.class); + AssistantProperties properties = mock(AssistantProperties.class); + Authentication authentication = mock(Authentication.class); + CurrentActor actor = new CurrentActor( + UUID.randomUUID(), + UUID.randomUUID(), + UUID.randomUUID(), + "Laura", + "laura@example.test"); + UUID conversationId = UUID.randomUUID(); + when(actors.current(authentication)).thenReturn(actor); + when(conversations.beginTurn(actor, null, "Question")) + .thenReturn(conversationId); + when(assistant.startTurn( + eq(actor), + eq("Question"), + eq(5), + anyString(), + eq(conversationId.toString()))) + .thenReturn(new AssistantTurn( + "request-1", List.of(), reactor.core.publisher.Flux.just("Answer"))); + when(properties.heartbeatInterval()).thenReturn(Duration.ofHours(1)); + when(properties.turnTimeout()).thenReturn(Duration.ofMinutes(1)); + AssistantController controller = new AssistantController( + assistant, + conversations, + mock(ChatMemory.class), + actors, + properties, + new ObjectMapper()); + + List frames = controller.chat( + new AssistantChatRequest("Question", 5, null), authentication) + .getBody() + .map(event -> event.data()) + .collectList() + .block(); + + ArgumentCaptor messageId = ArgumentCaptor.forClass(UUID.class); + verify(conversations).completeTurn( + eq(actor), eq(conversationId), messageId.capture(), eq("Answer")); + assertEquals( + "{\"type\":\"start\",\"messageId\":\"" + + messageId.getValue() + + "\"}", + frames.getFirst()); + } + @Test void streamsTheVerifiedRequestSnapshotWithoutWaitingForModelCompletion() { RetrievedKnowledgeEvidence evidence = evidence(); diff --git a/apps/api/src/test/java/com/orgmemory/api/assistant/UiMessageStreamTests.java b/apps/api/src/test/java/com/orgmemory/api/assistant/UiMessageStreamTests.java index 10b46075..e908d51f 100644 --- a/apps/api/src/test/java/com/orgmemory/api/assistant/UiMessageStreamTests.java +++ b/apps/api/src/test/java/com/orgmemory/api/assistant/UiMessageStreamTests.java @@ -4,6 +4,7 @@ import java.time.Duration; import java.util.List; +import java.util.UUID; import org.junit.jupiter.api.Test; import org.springframework.http.codec.ServerSentEvent; import reactor.core.publisher.Flux; @@ -12,6 +13,8 @@ class UiMessageStreamTests { + private static final UUID MESSAGE_ID = + UUID.fromString("42000000-0000-0000-0000-000000000001"); private final ObjectMapper json = new ObjectMapper(); @Test @@ -28,6 +31,7 @@ void emitsAiSdkUiMessageFramesInOrder() { new AssistantStreamPart.TextDelta("answer", "Sixty days. [1]"), new AssistantStreamPart.TextEnd("answer"), new AssistantStreamPart.FinishStep()), + MESSAGE_ID, json, Duration.ofHours(1), Duration.ofMinutes(1)) @@ -36,7 +40,8 @@ void emitsAiSdkUiMessageFramesInOrder() { .block(); assertThat(data).isNotNull(); - assertThat(data.getFirst()).contains("\"type\":\"start\"").contains("\"messageId\":"); + assertThat(data.getFirst()) + .isEqualTo("{\"type\":\"start\",\"messageId\":\"" + MESSAGE_ID + "\"}"); assertThat(data.subList(1, data.size())).containsExactly( "{\"type\":\"start-step\"}", "{\"type\":\"source-url\",\"sourceId\":\"citation-1\",\"url\":\"https://example.test/handbook\",\"title\":\"Employee Handbook\",\"providerMetadata\":{\"orgmemory\":{\"citationNumber\":1}}}", @@ -51,7 +56,11 @@ void emitsAiSdkUiMessageFramesInOrder() { @Test void heartbeatIsAnSseComment() { StepVerifier.withVirtualTime(() -> UiMessageStream.encode( - Flux.never(), json, Duration.ofSeconds(15), Duration.ofMinutes(1))) + Flux.never(), + MESSAGE_ID, + json, + Duration.ofSeconds(15), + Duration.ofMinutes(1))) .assertNext(event -> assertThat(event.data()).contains("\"type\":\"start\"")) .thenAwait(Duration.ofSeconds(15)) .assertNext(event -> { @@ -67,6 +76,7 @@ void timeoutEmitsAbortAndDoneWithoutFinish() { Flux> dataEvents = UiMessageStream.encode( Flux.just(new AssistantStreamPart.StartStep()) .concatWith(Flux.never()), + MESSAGE_ID, json, Duration.ofSeconds(5), Duration.ofSeconds(20)) @@ -86,6 +96,7 @@ void timeoutEmitsAbortAndDoneWithoutFinish() { void providerFailureEmitsOpaqueErrorAndDone() { List data = UiMessageStream.encode( Flux.error(new IllegalStateException("provider secret")), + MESSAGE_ID, json, Duration.ofHours(1), Duration.ofMinutes(1)) diff --git a/apps/docs/content/docs/reference/api-reference/assistant.mdx b/apps/docs/content/docs/reference/api-reference/assistant.mdx index 077e9f79..6f00470b 100644 --- a/apps/docs/content/docs/reference/api-reference/assistant.mdx +++ b/apps/docs/content/docs/reference/api-reference/assistant.mdx @@ -15,6 +15,12 @@ _openapi: - depth: 2 title: Update actor-derived Pack progress after explicit confirmation url: '#update-actor-derived-pack-progress-after-explicit-confirmation' + - depth: 2 + title: Remove feedback from an owned Assistant answer + url: '#remove-feedback-from-an-owned-assistant-answer' + - depth: 2 + title: Create or replace feedback on an owned Assistant answer + url: '#create-or-replace-feedback-on-an-owned-assistant-answer' - depth: 2 title: Search canonical permission-aware Knowledge and return citation references @@ -55,6 +61,9 @@ _openapi: - depth: 2 title: Recommend exact usable Asset releases without leaking denied candidates url: '#recommend-exact-usable-asset-releases-without-leaking-denied-candidates' + - depth: 2 + title: List supported prompts for starting an Assistant conversation + url: '#list-supported-prompts-for-starting-an-assistant-conversation' - depth: 2 title: List the current actor's conversations by recent activity url: '#list-the-current-actors-conversations-by-recent-activity' @@ -65,6 +74,10 @@ _openapi: headings: - content: Update actor-derived Pack progress after explicit confirmation id: update-actor-derived-pack-progress-after-explicit-confirmation + - content: Remove feedback from an owned Assistant answer + id: remove-feedback-from-an-owned-assistant-answer + - content: Create or replace feedback on an owned Assistant answer + id: create-or-replace-feedback-on-an-owned-assistant-answer - content: Search canonical permission-aware Knowledge and return citation references id: search-canonical-permission-aware-knowledge-and-return-citation-references @@ -93,6 +106,8 @@ _openapi: id: resolve-the-variables-required-by-an-exact-prompt-release - content: Recommend exact usable Asset releases without leaking denied candidates id: recommend-exact-usable-asset-releases-without-leaking-denied-candidates + - content: List supported prompts for starting an Assistant conversation + id: list-supported-prompts-for-starting-an-assistant-conversation - content: List the current actor's conversations by recent activity id: list-the-current-actors-conversations-by-recent-activity - content: Replay a tenant- and actor-scoped full conversation transcript @@ -109,7 +124,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 8f5baf14..9b1316c4 100644 --- a/apps/docs/generated/openapi.public.json +++ b/apps/docs/generated/openapi.public.json @@ -65,6 +65,71 @@ } } }, + "/api/assistant/messages/{messageId}/feedback": { + "put": { + "tags": [ + "Assistant" + ], + "summary": "Create or replace feedback on an owned Assistant answer", + "operationId": "setAssistantAnswerFeedback", + "parameters": [ + { + "name": "messageId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnswerFeedbackRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/AssistantAnswerFeedbackView" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Assistant" + ], + "summary": "Remove feedback from an owned Assistant answer", + "operationId": "deleteAssistantAnswerFeedback", + "parameters": [ + { + "name": "messageId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "No Content" + } + } + } + }, "/api/assets/{assetId}/skill-draft": { "put": { "tags": [ @@ -3682,6 +3747,30 @@ } } }, + "/api/assistant/starters": { + "get": { + "tags": [ + "Assistant" + ], + "summary": "List supported prompts for starting an Assistant conversation", + "operationId": "listAssistantStarters", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AssistantStarterPrompt" + } + } + } + } + } + } + } + }, "/api/assistant/conversations": { "get": { "tags": [ @@ -5221,6 +5310,41 @@ } } }, + "AnswerFeedbackRequest": { + "type": "object", + "properties": { + "sentiment": { + "type": "string", + "enum": [ + "HELPFUL", + "NOT_HELPFUL" + ] + } + }, + "required": [ + "sentiment" + ] + }, + "AssistantAnswerFeedbackView": { + "type": "object", + "properties": { + "messageId": { + "type": "string", + "format": "uuid" + }, + "sentiment": { + "type": "string", + "enum": [ + "HELPFUL", + "NOT_HELPFUL" + ] + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, "AssetView": { "type": "object", "properties": { @@ -8596,6 +8720,20 @@ } } }, + "AssistantStarterPrompt": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "prompt": { + "type": "string" + } + } + }, "AssistantConversationSummary": { "type": "object", "properties": { @@ -8640,6 +8778,13 @@ "occurredAt": { "type": "string", "format": "date-time" + }, + "feedback": { + "type": "string", + "enum": [ + "HELPFUL", + "NOT_HELPFUL" + ] } } }, diff --git a/apps/web/src/features/assistant/assistant-draft-storage.test.ts b/apps/web/src/features/assistant/assistant-draft-storage.test.ts new file mode 100644 index 00000000..6f260ac1 --- /dev/null +++ b/apps/web/src/features/assistant/assistant-draft-storage.test.ts @@ -0,0 +1,40 @@ +import { beforeEach, describe, expect, it } from "vitest" + +import { + clearAllAssistantDrafts, + clearAssistantActorDrafts, + clearAssistantDraft, + readAssistantDraft, + writeAssistantDraft, +} from "@/features/assistant/assistant-draft-storage" + +describe("assistant draft storage", () => { + beforeEach(() => sessionStorage.clear()) + + it("isolates new and existing conversation drafts by actor", () => { + writeAssistantDraft("actor-a", undefined, "new draft") + writeAssistantDraft("actor-a", "conversation-1", "existing draft") + writeAssistantDraft("actor-b", "conversation-1", "other actor") + + expect(readAssistantDraft("actor-a")).toBe("new draft") + expect(readAssistantDraft("actor-a", "conversation-1")).toBe("existing draft") + expect(readAssistantDraft("actor-b", "conversation-1")).toBe("other actor") + }) + + it("caps drafts at the server message limit and clears lifecycle scopes", () => { + const bounded = writeAssistantDraft("actor-a", "conversation-1", "x".repeat(4_100)) + expect(bounded).toHaveLength(4_000) + + clearAssistantDraft("actor-a", "conversation-1") + expect(readAssistantDraft("actor-a", "conversation-1")).toBe("") + + writeAssistantDraft("actor-a", undefined, "a") + writeAssistantDraft("actor-b", undefined, "b") + clearAssistantActorDrafts("actor-a") + expect(readAssistantDraft("actor-a")).toBe("") + expect(readAssistantDraft("actor-b")).toBe("b") + + clearAllAssistantDrafts() + expect(readAssistantDraft("actor-b")).toBe("") + }) +}) diff --git a/apps/web/src/features/assistant/assistant-draft-storage.ts b/apps/web/src/features/assistant/assistant-draft-storage.ts new file mode 100644 index 00000000..d771c7f6 --- /dev/null +++ b/apps/web/src/features/assistant/assistant-draft-storage.ts @@ -0,0 +1,62 @@ +const DRAFT_PREFIX = "orgmemory:assistant-draft:v1:" +const MAX_DRAFT_LENGTH = 4_000 + +function draftKey(actorKey: string, conversationId?: string) { + return `${DRAFT_PREFIX}${encodeURIComponent(actorKey)}:${conversationId ?? "new"}` +} + +export function readAssistantDraft(actorKey: string, conversationId?: string) { + try { + return sessionStorage.getItem(draftKey(actorKey, conversationId)) ?? "" + } catch { + return "" + } +} + +export function writeAssistantDraft( + actorKey: string, + conversationId: string | undefined, + value: string, +) { + const bounded = value.slice(0, MAX_DRAFT_LENGTH) + const key = draftKey(actorKey, conversationId) + try { + if (bounded.length === 0) { + sessionStorage.removeItem(key) + } else { + sessionStorage.setItem(key, bounded) + } + } catch { + // The composer remains usable when browser storage is disabled. + } + return bounded +} + +export function clearAssistantDraft(actorKey: string, conversationId?: string) { + try { + sessionStorage.removeItem(draftKey(actorKey, conversationId)) + } catch { + // There is no persisted draft to clear when storage is unavailable. + } +} + +export function clearAssistantActorDrafts(actorKey: string) { + clearDraftsWithPrefix(`${DRAFT_PREFIX}${encodeURIComponent(actorKey)}:`) +} + +export function clearAllAssistantDrafts() { + clearDraftsWithPrefix(DRAFT_PREFIX) +} + +function clearDraftsWithPrefix(prefix: string) { + try { + const matchingKeys: string[] = [] + for (let index = 0; index < sessionStorage.length; index += 1) { + const key = sessionStorage.key(index) + if (key?.startsWith(prefix)) matchingKeys.push(key) + } + for (const key of matchingKeys) sessionStorage.removeItem(key) + } catch { + // There is no persisted draft to clear when storage is unavailable. + } +} diff --git a/apps/web/src/features/assistant/components/assistant-conversation-list.tsx b/apps/web/src/features/assistant/components/assistant-conversation-list.tsx index 200cffbe..f3acc955 100644 --- a/apps/web/src/features/assistant/components/assistant-conversation-list.tsx +++ b/apps/web/src/features/assistant/components/assistant-conversation-list.tsx @@ -30,6 +30,7 @@ import { SidebarMenuButton, SidebarMenuItem, } from "@/components/ui/sidebar" +import { clearAssistantDraft } from "@/features/assistant/assistant-draft-storage" import { scopeActorQueryKey } from "@/features/session/actor-cache-key" import { deleteAssistantConversationMutation, @@ -74,6 +75,7 @@ export function AssistantConversationList({ ...deleteAssistantConversationMutation(), onSuccess: async (_, variables) => { setDeleteCandidate(null) + clearAssistantDraft(actorKey, variables.path.conversationId) queryClient.setQueryData( conversationQueryKey, (current = []) => diff --git a/apps/web/src/features/assistant/components/assistant-page.tsx b/apps/web/src/features/assistant/components/assistant-page.tsx index 3e346836..000348d4 100644 --- a/apps/web/src/features/assistant/components/assistant-page.tsx +++ b/apps/web/src/features/assistant/components/assistant-page.tsx @@ -1,8 +1,16 @@ import { useChat } from "@ai-sdk/react" -import { useQuery, useQueryClient } from "@tanstack/react-query" +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { type SourceUrlUIPart, type UIMessage } from "ai" -import { Copy, LoaderCircle, RotateCcw, ShieldCheck } from "lucide-react" +import { + Copy, + LoaderCircle, + RotateCcw, + ShieldCheck, + ThumbsDown, + ThumbsUp, +} from "lucide-react" import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { toast } from "sonner" import { Conversation, @@ -34,23 +42,23 @@ import { type AssistantSourceRef, AssistantSourcesPanel, } from "@/features/assistant/components/assistant-sources-panel" +import { useAssistantDraft } from "@/features/assistant/hooks/use-assistant-draft" import { useAssistantThinkingVisibility } from "@/features/assistant/hooks/use-assistant-thinking-visibility" import { scopeActorQueryKey } from "@/features/session/actor-cache-key" import { copyWithToast } from "@/lib/copy" import { + deleteAssistantAnswerFeedbackMutation, getAssistantConversationHistoryOptions, + listAssistantStartersOptions, listAssistantConversationsQueryKey, + setAssistantAnswerFeedbackMutation, } from "@/lib/hey-api/@tanstack/react-query.gen" import type { AssistantConversationMessageView, AssistantConversationSummary, } from "@/lib/hey-api" -const SUGGESTIONS = [ - "What is the probation policy?", - "How do I submit a travel expense claim?", - "What is the product release process?", -] +type AnswerSentiment = "HELPFUL" | "NOT_HELPFUL" function textFor(message: UIMessage) { return message.parts @@ -167,6 +175,7 @@ export function AssistantPage({ () => scopeActorQueryKey(listAssistantConversationsQueryKey(), actorKey), [actorKey], ) + const actorKeyRef = useRef(actorKey) const conversationIdRef = useRef(conversationId) const locallyCreatedConversationRef = useRef(undefined) const nextTitleRef = useRef("New conversation") @@ -210,7 +219,13 @@ export function AssistantPage({ }), [conversationListQueryKey, queryClient], ) - const [text, setText] = useState("") + const { text, setText, clear: clearDraft } = useAssistantDraft( + actorKey, + conversationId, + ) + const [feedbackByMessage, setFeedbackByMessage] = useState< + Record + >({}) const [sourcePanel, setSourcePanel] = useState<{ messageId: string sources: AssistantSourceRef[] @@ -260,15 +275,43 @@ export function AssistantPage({ queryKey: scopeActorQueryKey(historyOptions.queryKey, actorKey), enabled: Boolean(conversationId), }) + const starterOptions = listAssistantStartersOptions() + const starters = useQuery({ + ...starterOptions, + queryKey: scopeActorQueryKey(starterOptions.queryKey, actorKey), + }) + const saveFeedback = useMutation({ + ...setAssistantAnswerFeedbackMutation(), + onSuccess: (_, variables) => { + setFeedbackByMessage((current) => ({ + ...current, + [variables.path.messageId]: variables.body.sentiment, + })) + }, + onError: () => toast.error("Answer feedback could not be saved"), + }) + const removeFeedback = useMutation({ + ...deleteAssistantAnswerFeedbackMutation(), + onSuccess: (_, variables) => { + setFeedbackByMessage((current) => ({ + ...current, + [variables.path.messageId]: undefined, + })) + }, + onError: () => toast.error("Answer feedback could not be removed"), + }) useEffect(() => { - if (conversationIdRef.current === conversationId) return + const actorChanged = actorKeyRef.current !== actorKey + if (!actorChanged && conversationIdRef.current === conversationId) return stop() + actorKeyRef.current = actorKey conversationIdRef.current = conversationId locallyCreatedConversationRef.current = undefined setSourcePanel(null) + setFeedbackByMessage({}) setMessages([]) - }, [conversationId, setMessages, stop]) + }, [actorKey, conversationId, setMessages, stop]) useEffect(() => { if ( @@ -283,6 +326,18 @@ export function AssistantPage({ historyMessage(conversationId, message, index), ), ) + setFeedbackByMessage( + Object.fromEntries( + history.data + .filter( + (message) => + message.id && + (message.feedback === "HELPFUL" || + message.feedback === "NOT_HELPFUL"), + ) + .map((message) => [message.id as string, message.feedback as AnswerSentiment]), + ), + ) }, [conversationId, history.data, setMessages]) const busy = status === "submitted" || status === "streaming" const latestMessage = messages.at(-1) @@ -313,7 +368,7 @@ export function AssistantPage({ [], ) - function send(rawMessage: string) { + function send(rawMessage: string, clearComposer = true) { const message = rawMessage.trim() if (!message || busy || submitLock.current) return @@ -322,7 +377,7 @@ export function AssistantPage({ message.length <= 80 ? message : `${message.slice(0, 77)}...` clearError() const turn = sendMessage({ text: message }) - setText("") + if (clearComposer) clearDraft() const release = () => { submitLock.current = false } @@ -334,6 +389,18 @@ export function AssistantPage({ return send(message.text) } + function toggleFeedback(messageId: string, sentiment: AnswerSentiment) { + if (busy || saveFeedback.isPending || removeFeedback.isPending) return + if (feedbackByMessage[messageId] === sentiment) { + removeFeedback.mutate({ path: { messageId } }) + return + } + saveFeedback.mutate({ + path: { messageId }, + body: { sentiment }, + }) + } + const composer = ( setText(event.currentTarget.value)} placeholder="Ask OrgMemory…" autoFocus + maxLength={4_000} className="min-h-12" /> @@ -365,12 +433,13 @@ export function AssistantPage({ ) - const isSwitchingConversation = - conversationId !== undefined && conversationIdRef.current !== conversationId + const isSwitchingScope = + actorKeyRef.current !== actorKey || + (conversationId !== undefined && conversationIdRef.current !== conversationId) if ( conversationId && - (isSwitchingConversation || (history.isPending && messages.length === 0)) + (isSwitchingScope || (history.isPending && messages.length === 0)) ) { return (
{greeting()}
{composer}
- {SUGGESTIONS.map((suggestion) => ( + {(starters.data ?? []).map((starter) => ( { void send(value)?.catch(() => undefined) @@ -432,10 +501,19 @@ export function AssistantPage({
- {messages.map((message) => { + {messages.map((message, index) => { const content = textFor(message) const sources = sourcesFor(message) const citedSources = citedSourcesFor(content, sources) + const precedingUserMessage = messages[index - 1] + const retryPrompt = + message.role === "assistant" && + precedingUserMessage?.role === "user" + ? textFor(precedingUserMessage) + : "" + const selectedFeedback = feedbackByMessage[message.id] + const feedbackPending = + saveFeedback.isPending || removeFeedback.isPending if (!content.trim() && sources.length === 0) return null return ( @@ -484,6 +562,40 @@ export function AssistantPage({ > + {message.role === "assistant" ? ( + <> + { + void send(retryPrompt, false)?.catch(() => undefined) + }} + > + + + toggleFeedback(message.id, "HELPFUL")} + > + + + toggleFeedback(message.id, "NOT_HELPFUL")} + > + + + + ) : null} ) : null} @@ -509,7 +621,7 @@ export function AssistantPage({ size="sm" disabled={!retryMessage || busy} onClick={() => { - void send(retryMessage)?.catch(() => undefined) + void send(retryMessage, false)?.catch(() => undefined) }} >