Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .tegami/2026-08-04-assistant-interaction-foundation.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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<AssistantStarterPrompt> 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;
Expand Down Expand Up @@ -71,6 +88,7 @@ ResponseEntity<Flux<ServerSentEvent<String>>> 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(),
Expand All @@ -85,7 +103,10 @@ ResponseEntity<Flux<ServerSentEvent<String>>> 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())
Expand All @@ -95,6 +116,7 @@ ResponseEntity<Flux<ServerSentEvent<String>>> chat(
.header("X-Accel-Buffering", "no")
.body(UiMessageStream.encode(
parts,
assistantMessageId,
json,
properties.heartbeatInterval(),
properties.turnTimeout()));
Expand All @@ -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<AssistantStarterPrompt> 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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,12 @@ private UiMessageStream() {

static Flux<ServerSentEvent<String>> encode(
Flux<AssistantStreamPart> source,
UUID messageId,
ObjectMapper json,
Duration heartbeatInterval,
Duration turnTimeout) {
return Flux.defer(() -> {
Encoder encoder = new Encoder(json);
Encoder encoder = new Encoder(json, messageId);
Flux<ServerSentEvent<String>> live = withHeartbeat(
limitDuration(source, turnTimeout).map(encoder::part),
heartbeatInterval);
Expand Down Expand Up @@ -61,10 +62,11 @@ private static Flux<ServerSentEvent<String>> 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<String> start() {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Future<Void>> 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<Void> attempt : attempts) {
attempt.get(30, TimeUnit.SECONDS);
}
}
}

@FunctionalInterface
private interface AttemptFactory {

Callable<Void> create(int index);
}

private record Scenario(CurrentActor actor, UUID answerId) {
}
}
Loading