mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(agent): retain image context across turns for follow-ups (#303)
This commit is contained in:
parent
dfc8e4c786
commit
aaf06b262c
@ -955,6 +955,11 @@ public abstract class BaseAgent {
|
||||
if (decision.strategy() == MultimodalRoutingDecision.Strategy.SIDECAR
|
||||
&& mediaCaptionService != null
|
||||
&& decision.sidecarModel() != null) {
|
||||
// The user's actual question (text parts only, excluding media markers)
|
||||
// so the vision model tailors its description to what was asked rather
|
||||
// than emitting a generic caption.
|
||||
String userQuestion = extractUserQuestion(parts);
|
||||
boolean captionPersisted = false;
|
||||
for (MessageContentPart part : parts) {
|
||||
if (part == null) continue;
|
||||
String contentType = part.getContentType();
|
||||
@ -963,13 +968,23 @@ public abstract class BaseAgent {
|
||||
&& !contentType.contains("svg");
|
||||
if (!isImage) continue;
|
||||
MediaCaptionService.CaptionResult result = mediaCaptionService.caption(
|
||||
decision.sidecarModel(), part, userLocale);
|
||||
decision.sidecarModel(), part, userLocale, userQuestion);
|
||||
if (result.isFailure()) {
|
||||
log.warn("[{}] Sidecar caption failed for {}: {}",
|
||||
agentName, part.getFileName(), result.failure().getMessage());
|
||||
textBuilder.append("\n\n[系统提示] 视觉模型未能解析附件 ")
|
||||
.append(part.getFileName())
|
||||
.append(",请稍后重试或在「设置 → 模型」检查视觉模型配置。");
|
||||
if (isRemoteOnlyAttachment(part)) {
|
||||
// The image was never downloaded locally (only a remote
|
||||
// channel URL survives) — for WeCom/aibot that URL points
|
||||
// at short-lived AES-encrypted bytes, so captioning can
|
||||
// never succeed until media download is enabled.
|
||||
textBuilder.append("\n\n[系统提示] 图片 ")
|
||||
.append(part.getFileName())
|
||||
.append(" 未下载到本地,无法识别;请在「设置 → 渠道」开启该渠道的媒体下载。");
|
||||
} else {
|
||||
textBuilder.append("\n\n[系统提示] 视觉模型未能解析附件 ")
|
||||
.append(part.getFileName())
|
||||
.append(",请稍后重试或在「设置 → 模型」检查视觉模型配置。");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
textBuilder.append("\n\n[图片附件描述: ")
|
||||
@ -977,9 +992,17 @@ public abstract class BaseAgent {
|
||||
.append("]\n")
|
||||
.append(result.description())
|
||||
.append("\n[/图片附件描述]");
|
||||
// Persist the caption onto the part so later turns retain the image
|
||||
// content: history user messages replay as text only, and without a
|
||||
// stored caption every follow-up question loses the attachment.
|
||||
part.setCaption(result.description());
|
||||
captionPersisted = true;
|
||||
String identifier = identifyPart(part);
|
||||
if (identifier != null) sidecarHandledIdentifiers.add(identifier);
|
||||
}
|
||||
if (captionPersisted && conversationService != null) {
|
||||
conversationService.updateMessageParts(message, parts);
|
||||
}
|
||||
}
|
||||
|
||||
List<Media> mediaList = new ArrayList<>();
|
||||
@ -1048,7 +1071,7 @@ public abstract class BaseAgent {
|
||||
if (mediaPath == null) {
|
||||
log.warn("[{}] {} file not found for attachment: {}, path: {}, mediaId: {}",
|
||||
agentName, isVideo ? "Video" : "Image", part.getFileName(), part.getPath(), part.getMediaId());
|
||||
skippedAttachments.add(part.getFileName() + "(文件未找到)");
|
||||
skippedAttachments.add(part.getFileName() + unresolvedAttachmentReason(part));
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
@ -1083,6 +1106,52 @@ public abstract class BaseAgent {
|
||||
return new CurrentTurnUserMessage(built, decision);
|
||||
}
|
||||
|
||||
/**
|
||||
* Concatenate the text parts of a message into the user's question, dropping
|
||||
* image/file/media parts. Returns {@code null} when there is no usable text
|
||||
* (e.g. an image-only IM message), which makes the caption fall back to the
|
||||
* generic full-description prompt.
|
||||
*/
|
||||
private static String extractUserQuestion(List<MessageContentPart> parts) {
|
||||
if (parts == null || parts.isEmpty()) return null;
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (MessageContentPart part : parts) {
|
||||
if (part == null || !"text".equals(part.getType())) continue;
|
||||
String text = part.getText();
|
||||
if (text == null || text.isBlank()) continue;
|
||||
if (sb.length() > 0) sb.append('\n');
|
||||
sb.append(text.trim());
|
||||
}
|
||||
String question = sb.toString().trim();
|
||||
return question.isEmpty() ? null : question;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when an attachment has no resolvable local file and its only locator
|
||||
* is a remote http(s) URL — i.e. the IM channel never downloaded it locally.
|
||||
* For WeCom/aibot images that URL points at short-lived AES-encrypted bytes,
|
||||
* so it is unusable as-is. Lets callers turn a generic "file not found" into
|
||||
* an actionable hint instead of a dead end.
|
||||
*/
|
||||
private static boolean isRemoteOnlyAttachment(MessageContentPart part) {
|
||||
if (part == null) return false;
|
||||
if (part.getPath() != null && !part.getPath().isBlank()) return false;
|
||||
String locator = part.getMediaId();
|
||||
if (locator == null || locator.isBlank()) locator = part.getFileUrl();
|
||||
return locator != null && (locator.startsWith("http://") || locator.startsWith("https://"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reason string appended to a skipped attachment whose local file could not
|
||||
* be resolved — distinguishes "never downloaded" (channel media download
|
||||
* off) from a genuine missing-file so the user gets an actionable message.
|
||||
*/
|
||||
private static String unresolvedAttachmentReason(MessageContentPart part) {
|
||||
return isRemoteOnlyAttachment(part)
|
||||
? "(图片未下载到本地,无法识别;请在「设置 → 渠道」开启该渠道的媒体下载)"
|
||||
: "(文件未找到)";
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable identifier for de-duplicating parts already handled by the sidecar
|
||||
* pass. Falls back across {@code path → mediaId → fileName} since not every
|
||||
@ -1194,7 +1263,14 @@ public abstract class BaseAgent {
|
||||
if ("user".equals(msg.getRole())) {
|
||||
// 用 DB 中的实际内容(可能包含 contentParts),不用传入的 text
|
||||
String content = conversationService.renderMessageContent(msg);
|
||||
return buildUserMessageForCurrentTurn(msg, content != null && !content.isBlank() ? content : userMessageText);
|
||||
CurrentTurnUserMessage built = buildUserMessageForCurrentTurn(
|
||||
msg, content != null && !content.isBlank() ? content : userMessageText);
|
||||
// Vision-capable models replay history as text only, so a
|
||||
// follow-up question about an earlier image would otherwise be
|
||||
// answered blind. Re-attach the most recent image to this turn
|
||||
// so the model actually re-sees it. (Text-only models instead
|
||||
// rely on the persisted sidecar caption — see buildUserMessageInternal.)
|
||||
return maybeCarryRecentImage(history, i, msg, built);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
@ -1204,6 +1280,85 @@ public abstract class BaseAgent {
|
||||
return new CurrentTurnUserMessage(new UserMessage(userMessageText), null);
|
||||
}
|
||||
|
||||
/** How far back (in messages) to look for an image to carry into a follow-up turn. */
|
||||
private static final int CARRY_IMAGE_LOOKBACK = 8;
|
||||
|
||||
/**
|
||||
* For a vision-capable model, re-attach the most recent image from a recent
|
||||
* earlier turn to the current user message when the current turn carries no
|
||||
* image of its own. History is replayed as text only (see {@link #toSpringMessage}),
|
||||
* so without this a follow-up like "what's in the top-left of that photo?" is
|
||||
* answered blind. Bounded to a single image within {@link #CARRY_IMAGE_LOOKBACK}
|
||||
* messages so a long conversation doesn't re-send pixels on every turn.
|
||||
*
|
||||
* <p>No-op (returns {@code built} unchanged) when: the model can't see images,
|
||||
* the current turn already has an image, no recent image exists, or the recent
|
||||
* image has no resolvable local file (e.g. an undownloaded channel URL).
|
||||
*/
|
||||
private CurrentTurnUserMessage maybeCarryRecentImage(List<MessageEntity> history, int currentIdx,
|
||||
MessageEntity currentMsg, CurrentTurnUserMessage built) {
|
||||
try {
|
||||
if (built == null || !modelSupportsVision()) return built;
|
||||
if (messageHasImagePart(currentMsg)) return built; // current turn already carries an image
|
||||
|
||||
int from = Math.max(0, currentIdx - CARRY_IMAGE_LOOKBACK);
|
||||
for (int j = currentIdx - 1; j >= from; j--) {
|
||||
MessageEntity m = history.get(j);
|
||||
if (m == null || !"user".equals(m.getRole())) continue;
|
||||
List<MessageContentPart> parts = conversationService.parseMessageParts(m);
|
||||
for (int k = parts.size() - 1; k >= 0; k--) {
|
||||
MessageContentPart part = parts.get(k);
|
||||
if (!isResolvableImagePart(part)) continue;
|
||||
Path imgPath = resolveImagePath(part.getPath());
|
||||
if (imgPath == null && part.getMediaId() != null) imgPath = resolveImagePath(part.getMediaId());
|
||||
if (imgPath == null) continue;
|
||||
String contentType = part.getContentType();
|
||||
if (contentType == null || "image/*".equals(contentType)) contentType = "image/jpeg";
|
||||
try {
|
||||
Media carried = new Media(MimeType.valueOf(contentType), new FileSystemResource(imgPath));
|
||||
UserMessage orig = built.userMessage();
|
||||
String name = part.getFileName() == null ? "image" : part.getFileName();
|
||||
String text = (orig.getText() == null ? "" : orig.getText())
|
||||
+ "\n\n[系统提示] 以下图片是用户本次对话中较早发送的「" + name
|
||||
+ "」,当前问题很可能与它相关。请直接查看该图片作答,不要凭记忆猜测。";
|
||||
List<Media> media = new ArrayList<>();
|
||||
if (orig.getMedia() != null) media.addAll(orig.getMedia());
|
||||
media.add(carried);
|
||||
log.debug("[{}] Carried recent image {} into follow-up turn for vision model",
|
||||
agentName, name);
|
||||
return new CurrentTurnUserMessage(
|
||||
UserMessage.builder().text(text).media(media).build(),
|
||||
built.routingDecision());
|
||||
} catch (Exception e) {
|
||||
log.debug("[{}] Failed to carry recent image {}: {}",
|
||||
agentName, part.getFileName(), e.getMessage());
|
||||
return built;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("[{}] maybeCarryRecentImage failed: {}", agentName, e.getMessage());
|
||||
}
|
||||
return built;
|
||||
}
|
||||
|
||||
private boolean messageHasImagePart(MessageEntity message) {
|
||||
for (MessageContentPart part : conversationService.parseMessageParts(message)) {
|
||||
if (isResolvableImagePart(part)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** An image part (not SVG) — the raster kind a multimodal API can ingest. */
|
||||
private static boolean isResolvableImagePart(MessageContentPart part) {
|
||||
if (part == null) return false;
|
||||
String type = part.getType();
|
||||
String contentType = part.getContentType();
|
||||
boolean isImage = ("image".equals(type) || "file".equals(type))
|
||||
&& contentType != null && contentType.startsWith("image/");
|
||||
return isImage && !contentType.contains("svg");
|
||||
}
|
||||
|
||||
protected Path resolveImagePath(String relativePath) {
|
||||
if (relativePath == null || relativePath.isBlank()) {
|
||||
return null;
|
||||
|
||||
@ -42,6 +42,18 @@ public class MediaCaptionService {
|
||||
private final RetryTemplate retryTemplate;
|
||||
|
||||
public CaptionResult caption(ModelConfigEntity visionModel, MessageContentPart imagePart, Locale locale) {
|
||||
return caption(visionModel, imagePart, locale, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Caption an image, optionally tailored to a user question. When
|
||||
* {@code userQuestion} is non-blank the vision model is asked to answer it
|
||||
* directly (in addition to describing the image), so multi-turn follow-ups
|
||||
* get an answer rather than a generic description. When blank, falls back to
|
||||
* the factual full-description prompt.
|
||||
*/
|
||||
public CaptionResult caption(ModelConfigEntity visionModel, MessageContentPart imagePart, Locale locale,
|
||||
String userQuestion) {
|
||||
if (visionModel == null || imagePart == null) {
|
||||
return CaptionResult.failure(0, new IllegalArgumentException("vision model or image part is null"));
|
||||
}
|
||||
@ -59,7 +71,7 @@ public class MediaCaptionService {
|
||||
ChatModel chatModel = chatModelFactory.buildFor(visionModel, retryTemplate);
|
||||
ChatClient client = ChatClient.create(chatModel);
|
||||
UserMessage userMessage = UserMessage.builder()
|
||||
.text(buildPrompt(locale, imagePart.getFileName()))
|
||||
.text(buildPrompt(locale, imagePart.getFileName(), userQuestion))
|
||||
.media(List.of(new Media(MimeType.valueOf(contentType), new FileSystemResource(mediaPath))))
|
||||
.build();
|
||||
String description = client.prompt()
|
||||
@ -87,9 +99,27 @@ public class MediaCaptionService {
|
||||
* (matches the primary user base) but switches to English so vision-model output
|
||||
* matches the chat language and avoids polluting English-only contexts.
|
||||
*/
|
||||
private String buildPrompt(Locale locale, String fileName) {
|
||||
private String buildPrompt(Locale locale, String fileName, String userQuestion) {
|
||||
boolean english = locale != null && Locale.ENGLISH.getLanguage().equalsIgnoreCase(locale.getLanguage());
|
||||
String fileHint = (fileName == null || fileName.isBlank()) ? "" : " (" + fileName + ")";
|
||||
boolean hasQuestion = userQuestion != null && !userQuestion.isBlank();
|
||||
|
||||
if (hasQuestion) {
|
||||
String question = userQuestion.trim();
|
||||
// Question-aware: extract everything relevant to the user's ask, then
|
||||
// answer it. Answering in the question's own language keeps the caption
|
||||
// consistent with the chat regardless of the configured locale.
|
||||
if (english) {
|
||||
return "Look at this image" + fileHint + " and answer the user's question. "
|
||||
+ "First note any details relevant to the question (objects, scene, visible text/OCR, "
|
||||
+ "numbers, layout), then answer directly. Reply in the same language as the question. "
|
||||
+ "Question: " + question;
|
||||
}
|
||||
return "请仔细查看这张图片" + fileHint + ",并回答用户的问题。"
|
||||
+ "先指出与问题相关的细节(物体、场景、画面文字/OCR、数字、排版等),再直接作答。"
|
||||
+ "用与问题相同的语言回复。问题:" + question;
|
||||
}
|
||||
|
||||
if (english) {
|
||||
return "Describe this image" + fileHint
|
||||
+ " concisely: list the main objects, scene, any visible text (OCR), "
|
||||
|
||||
@ -1033,10 +1033,38 @@ public class ConversationService {
|
||||
name = "未命名";
|
||||
}
|
||||
String path = safe(part.getPath());
|
||||
if (path.isBlank()) {
|
||||
return label + " " + name;
|
||||
StringBuilder rendered = new StringBuilder(label).append(' ').append(name);
|
||||
if (!path.isBlank()) {
|
||||
rendered.append("(路径: ").append(path).append(")");
|
||||
}
|
||||
// A persisted caption (vision sidecar output) carries the image content
|
||||
// into later turns: history user messages replay as text only, so without
|
||||
// this the model would lose all knowledge of the attachment after turn 1.
|
||||
String caption = safe(part.getCaption());
|
||||
if (!caption.isBlank()) {
|
||||
rendered.append("\n[图片内容] ").append(caption.trim());
|
||||
}
|
||||
return rendered.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Overwrite a message's {@code content_parts} with an updated list — used by
|
||||
* the vision sidecar to persist generated captions back onto image parts so
|
||||
* later turns retain the image description (history replay is text-only).
|
||||
* Best-effort: a serialization or DB failure is logged, not propagated, so
|
||||
* the in-flight chat turn is never broken by a caption write.
|
||||
*/
|
||||
public void updateMessageParts(MessageEntity message, List<MessageContentPart> parts) {
|
||||
if (message == null || message.getId() == null || parts == null || parts.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
message.setContentParts(serializeParts(parts));
|
||||
messageMapper.updateById(message);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to persist updated content_parts for message {}: {}",
|
||||
message.getId(), e.getMessage());
|
||||
}
|
||||
return label + " " + name + "(路径: " + path + ")";
|
||||
}
|
||||
|
||||
private void appendSegment(StringBuilder builder, String text) {
|
||||
|
||||
@ -49,6 +49,15 @@ public class MessageContentPart {
|
||||
*/
|
||||
private String mediaId;
|
||||
|
||||
/**
|
||||
* Vision-model description of an image/video part, produced by the sidecar
|
||||
* captioning path when the primary model is text-only. Persisted so the
|
||||
* description survives into later turns: history replay sends user messages
|
||||
* as text, and without a stored caption the image content would be lost on
|
||||
* every follow-up question. Null for non-media parts or when no captioning ran.
|
||||
*/
|
||||
private String caption;
|
||||
|
||||
// ==================== 工厂方法 ====================
|
||||
|
||||
public static MessageContentPart text(String text) {
|
||||
|
||||
@ -0,0 +1,176 @@
|
||||
package vip.mate.agent;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.ai.chat.messages.UserMessage;
|
||||
import vip.mate.llm.service.ModelCapabilityService;
|
||||
import vip.mate.workspace.conversation.ConversationService;
|
||||
import vip.mate.workspace.conversation.model.MessageContentPart;
|
||||
import vip.mate.workspace.conversation.model.MessageEntity;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.EnumSet;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* Issue #303 follow-up: a vision-capable model replays history as text only, so a
|
||||
* follow-up question about an earlier image was answered blind. The current turn
|
||||
* must re-attach the most recent image so the model actually re-sees it.
|
||||
*/
|
||||
class BaseAgentCarryRecentImageTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("Vision model + follow-up with no image → most recent image is carried into the turn")
|
||||
void followUp_carriesRecentImage() throws Exception {
|
||||
Path img = Files.createTempFile("carry-test", ".jpg");
|
||||
Files.write(img, new byte[]{(byte) 0xFF, (byte) 0xD8, (byte) 0xFF, 0x00});
|
||||
try {
|
||||
TestAgent agent = visionAgent();
|
||||
|
||||
MessageEntity imgTurn = userMsg("看看这张图");
|
||||
MessageContentPart imagePart = imagePart(img.toAbsolutePath().toString());
|
||||
MessageEntity asst = assistantMsg("图里是手写的字");
|
||||
MessageEntity followUp = userMsg("左上角有没有小字");
|
||||
|
||||
List<MessageEntity> history = List.of(imgTurn, asst, followUp);
|
||||
when(agent.conversationService.listMessages("c1")).thenReturn(history);
|
||||
when(agent.conversationService.renderMessageContent(followUp)).thenReturn("左上角有没有小字");
|
||||
when(agent.conversationService.parseMessageParts(imgTurn)).thenReturn(List.of(imagePart));
|
||||
when(agent.conversationService.parseMessageParts(followUp)).thenReturn(List.of());
|
||||
when(agent.conversationService.parseMessageParts(asst)).thenReturn(List.of());
|
||||
|
||||
UserMessage result = agent.callBuildCurrent("c1", "左上角有没有小字");
|
||||
|
||||
assertTrue(result.getMedia() != null && result.getMedia().size() == 1,
|
||||
"the recent image must be re-attached to the follow-up turn");
|
||||
assertTrue(result.getText().contains("较早发送的"),
|
||||
"a note must explain the carried image to the model");
|
||||
} finally {
|
||||
Files.deleteIfExists(img);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Text-only model → no image carried (relies on persisted caption instead)")
|
||||
void textOnlyModel_doesNotCarry() throws Exception {
|
||||
Path img = Files.createTempFile("carry-test", ".jpg");
|
||||
Files.write(img, new byte[]{(byte) 0xFF, (byte) 0xD8, (byte) 0xFF, 0x00});
|
||||
try {
|
||||
TestAgent agent = newAgent(EnumSet.of(ModelCapabilityService.Modality.TEXT));
|
||||
MessageEntity imgTurn = userMsg("看看这张图");
|
||||
MessageEntity followUp = userMsg("左上角有没有小字");
|
||||
List<MessageEntity> history = List.of(imgTurn, followUp);
|
||||
when(agent.conversationService.listMessages("c1")).thenReturn(history);
|
||||
when(agent.conversationService.renderMessageContent(followUp)).thenReturn("左上角有没有小字");
|
||||
when(agent.conversationService.parseMessageParts(any())).thenReturn(List.of());
|
||||
|
||||
UserMessage result = agent.callBuildCurrent("c1", "左上角有没有小字");
|
||||
|
||||
assertTrue(result.getMedia() == null || result.getMedia().isEmpty(),
|
||||
"text-only model must not get raw image bytes carried over");
|
||||
} finally {
|
||||
Files.deleteIfExists(img);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Current turn already has an image → nothing extra carried")
|
||||
void currentTurnHasImage_noCarry() throws Exception {
|
||||
Path older = Files.createTempFile("carry-old", ".jpg");
|
||||
Path now = Files.createTempFile("carry-now", ".jpg");
|
||||
Files.write(older, new byte[]{(byte) 0xFF, (byte) 0xD8, (byte) 0xFF, 0x00});
|
||||
Files.write(now, new byte[]{(byte) 0xFF, (byte) 0xD8, (byte) 0xFF, 0x00});
|
||||
try {
|
||||
TestAgent agent = visionAgent();
|
||||
MessageEntity oldTurn = userMsg("第一张");
|
||||
MessageEntity curTurn = userMsg("第二张");
|
||||
List<MessageEntity> history = List.of(oldTurn, curTurn);
|
||||
when(agent.conversationService.listMessages("c1")).thenReturn(history);
|
||||
when(agent.conversationService.renderMessageContent(curTurn)).thenReturn("第二张");
|
||||
when(agent.conversationService.parseMessageParts(oldTurn))
|
||||
.thenReturn(List.of(imagePart(older.toAbsolutePath().toString())));
|
||||
when(agent.conversationService.parseMessageParts(curTurn))
|
||||
.thenReturn(List.of(imagePart(now.toAbsolutePath().toString())));
|
||||
|
||||
UserMessage result = agent.callBuildCurrent("c1", "第二张");
|
||||
|
||||
assertTrue(result.getMedia() != null && result.getMedia().size() == 1,
|
||||
"only the current turn's own image should be present — no extra carry");
|
||||
assertFalse(result.getText().contains("较早发送的"),
|
||||
"no carry note when the current turn already has an image");
|
||||
} finally {
|
||||
Files.deleteIfExists(older);
|
||||
Files.deleteIfExists(now);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- scaffold ----------
|
||||
|
||||
private static MessageContentPart imagePart(String path) {
|
||||
MessageContentPart p = new MessageContentPart();
|
||||
p.setType("image");
|
||||
p.setContentType("image/jpeg");
|
||||
p.setFileName("image.jpg");
|
||||
p.setPath(path);
|
||||
return p;
|
||||
}
|
||||
|
||||
private static MessageEntity userMsg(String content) {
|
||||
MessageEntity m = new MessageEntity();
|
||||
m.setRole("user");
|
||||
m.setContent(content);
|
||||
return m;
|
||||
}
|
||||
|
||||
private static MessageEntity assistantMsg(String content) {
|
||||
MessageEntity m = new MessageEntity();
|
||||
m.setRole("assistant");
|
||||
m.setContent(content);
|
||||
return m;
|
||||
}
|
||||
|
||||
private static TestAgent visionAgent() {
|
||||
return newAgent(EnumSet.of(ModelCapabilityService.Modality.VISION, ModelCapabilityService.Modality.TEXT));
|
||||
}
|
||||
|
||||
private static TestAgent newAgent(EnumSet<ModelCapabilityService.Modality> caps) {
|
||||
ConversationService conv = mock(ConversationService.class);
|
||||
TestAgent agent = new TestAgent(conv);
|
||||
agent.modelCapabilities = caps;
|
||||
agent.modelName = "test-model";
|
||||
agent.agentName = "test-agent";
|
||||
return agent;
|
||||
}
|
||||
|
||||
static class TestAgent extends BaseAgent {
|
||||
TestAgent(ConversationService conv) {
|
||||
super(null, conv);
|
||||
}
|
||||
|
||||
UserMessage callBuildCurrent(String conversationId, String text) {
|
||||
return buildCurrentUserMessageWithRouting(conversationId, text).userMessage();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String chat(String userMessage, String conversationId) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public reactor.core.publisher.Flux<String> chatStream(String userMessage, String conversationId) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String execute(String goal, String conversationId) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,163 @@
|
||||
package vip.mate.agent;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.ai.chat.messages.UserMessage;
|
||||
import vip.mate.llm.model.ModelConfigEntity;
|
||||
import vip.mate.llm.routing.MediaCaptionService;
|
||||
import vip.mate.llm.routing.MultimodalRouter;
|
||||
import vip.mate.llm.routing.model.MultimodalRoutingDecision;
|
||||
import vip.mate.llm.service.ModelCapabilityService;
|
||||
import vip.mate.workspace.conversation.ConversationService;
|
||||
import vip.mate.workspace.conversation.model.MessageContentPart;
|
||||
import vip.mate.workspace.conversation.model.MessageEntity;
|
||||
|
||||
import java.util.EnumSet;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* Issue #303: when a text-only primary model captions an uploaded image via the
|
||||
* vision sidecar, the caption must (1) be tailored to the user's actual question
|
||||
* and (2) be persisted back onto the message part, so later turns — which replay
|
||||
* user messages as text only — retain the image content instead of losing it.
|
||||
*/
|
||||
class BaseAgentImageCaptionPersistTest {
|
||||
|
||||
private static final String DESCRIPTION = "图中是一段 NullPointerException 堆栈,发生在 UserService.login 第 42 行。";
|
||||
|
||||
@Test
|
||||
@DisplayName("Sidecar caption is persisted onto the image part and folded into the prompt")
|
||||
void sidecarCaption_persistedAndInjected() {
|
||||
TestHarness h = newHarness();
|
||||
MessageContentPart image = imagePart();
|
||||
MessageEntity msg = userMessage();
|
||||
when(h.agent.conversationService.parseMessageParts(msg))
|
||||
.thenReturn(List.of(MessageContentPart.text("图里的报错是什么"), image));
|
||||
|
||||
UserMessage result = h.agent.callBuildCurrentTurn(msg, "图里的报错是什么");
|
||||
|
||||
// (1) caption stored on the part → survives into later turns
|
||||
assertEquals(DESCRIPTION, image.getCaption(), "caption must be written onto the image part");
|
||||
verify(h.agent.conversationService).updateMessageParts(eq(msg), any());
|
||||
// (2) caption folded into the current-turn prompt text
|
||||
assertTrue(result.getText().contains(DESCRIPTION), "caption must be injected into the prompt");
|
||||
assertTrue(result.getText().contains("[图片附件描述"), "caption must be wrapped in the attachment marker");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("The user's text question is passed to the caption service")
|
||||
void userQuestion_passedToCaption() {
|
||||
TestHarness h = newHarness();
|
||||
MessageEntity msg = userMessage();
|
||||
when(h.agent.conversationService.parseMessageParts(msg))
|
||||
.thenReturn(List.of(MessageContentPart.text("报错的行号是多少"), imagePart()));
|
||||
|
||||
h.agent.callBuildCurrentTurn(msg, "报错的行号是多少");
|
||||
|
||||
ArgumentCaptor<String> question = ArgumentCaptor.forClass(String.class);
|
||||
verify(h.caption).caption(any(), any(), any(), question.capture());
|
||||
assertEquals("报错的行号是多少", question.getValue(),
|
||||
"the user's question (text part) must drive a context-aware caption");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Image-only message (no text part) → caption called with null question")
|
||||
void imageOnly_nullQuestion() {
|
||||
TestHarness h = newHarness();
|
||||
MessageEntity msg = userMessage();
|
||||
// WeChat Work image upload: only an image part, content placeholder "[图片]".
|
||||
when(h.agent.conversationService.parseMessageParts(msg))
|
||||
.thenReturn(List.of(imagePart()));
|
||||
|
||||
h.agent.callBuildCurrentTurn(msg, "[图片]");
|
||||
|
||||
ArgumentCaptor<String> question = ArgumentCaptor.forClass(String.class);
|
||||
verify(h.caption).caption(any(), any(), any(), question.capture());
|
||||
assertEquals(null, question.getValue(),
|
||||
"no text part → null question → caption falls back to generic description");
|
||||
}
|
||||
|
||||
// ---------- scaffold ----------
|
||||
|
||||
private static MessageContentPart imagePart() {
|
||||
MessageContentPart p = new MessageContentPart();
|
||||
p.setType("image");
|
||||
p.setContentType("image/png");
|
||||
p.setFileName("err.png");
|
||||
p.setMediaId("media-1");
|
||||
return p;
|
||||
}
|
||||
|
||||
private static MessageEntity userMessage() {
|
||||
MessageEntity m = new MessageEntity();
|
||||
m.setId(1001L);
|
||||
m.setRole("user");
|
||||
m.setContent("[图片]");
|
||||
return m;
|
||||
}
|
||||
|
||||
private TestHarness newHarness() {
|
||||
ConversationService conv = mock(ConversationService.class);
|
||||
MultimodalRouter router = mock(MultimodalRouter.class);
|
||||
MediaCaptionService caption = mock(MediaCaptionService.class);
|
||||
ModelConfigEntity sidecar = mock(ModelConfigEntity.class);
|
||||
|
||||
when(router.route(any(), any())).thenReturn(
|
||||
MultimodalRoutingDecision.sidecar(sidecar,
|
||||
EnumSet.of(ModelCapabilityService.Modality.VISION),
|
||||
EnumSet.of(ModelCapabilityService.Modality.VISION)));
|
||||
when(caption.caption(any(), any(), any(), any()))
|
||||
.thenReturn(MediaCaptionService.CaptionResult.success(DESCRIPTION, 12L, false));
|
||||
|
||||
TestAgent agent = new TestAgent(conv);
|
||||
agent.multimodalRouter = router;
|
||||
agent.mediaCaptionService = caption;
|
||||
agent.modelCapabilities = EnumSet.noneOf(ModelCapabilityService.Modality.class);
|
||||
agent.modelName = "text-only-model";
|
||||
agent.agentName = "test-agent";
|
||||
|
||||
TestHarness h = new TestHarness();
|
||||
h.agent = agent;
|
||||
h.caption = caption;
|
||||
return h;
|
||||
}
|
||||
|
||||
static class TestHarness {
|
||||
TestAgent agent;
|
||||
MediaCaptionService caption;
|
||||
}
|
||||
|
||||
static class TestAgent extends BaseAgent {
|
||||
TestAgent(ConversationService conv) {
|
||||
super(null, conv);
|
||||
}
|
||||
|
||||
UserMessage callBuildCurrentTurn(MessageEntity msg, String renderedContent) {
|
||||
return buildUserMessageForCurrentTurn(msg, renderedContent).userMessage();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String chat(String userMessage, String conversationId) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public reactor.core.publisher.Flux<String> chatStream(String userMessage, String conversationId) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String execute(String goal, String conversationId) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -86,6 +86,35 @@ class BaseAgentMultimodalSkipNoticeTest {
|
||||
"image must NOT be injected when model has no VISION capability");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Vision-capable model + image with only a remote URL (never downloaded) → actionable 'enable media download' hint")
|
||||
void remoteOnlyImage_emitsDownloadHint() {
|
||||
// Issue #303 follow-up: WeCom/aibot delivers images as short-lived,
|
||||
// AES-encrypted COS URLs. With channel media download off, the part is
|
||||
// stored URL-only (path=null, mediaId=https://...). The model supports
|
||||
// vision, so we reach the file-resolution branch — which must surface an
|
||||
// actionable hint instead of a dead-end "文件未找到".
|
||||
TestAgent agent = newAgentWithCaps(
|
||||
EnumSet.of(ModelCapabilityService.Modality.VISION, ModelCapabilityService.Modality.TEXT));
|
||||
MessageEntity msg = userMessage("看看这张图");
|
||||
MessageContentPart remote = new MessageContentPart();
|
||||
remote.setType("image");
|
||||
remote.setContentType("image/jpeg");
|
||||
remote.setFileName("image.jpg");
|
||||
remote.setMediaId("https://ww-aibot-img.cos.example.com/x?sign=abc");
|
||||
when(agent.conversationService.parseMessageParts(msg)).thenReturn(List.of(remote));
|
||||
|
||||
UserMessage result = agent.callBuildUserMessage(msg, "看看这张图");
|
||||
|
||||
String text = result.getText();
|
||||
assertTrue(text.contains("未下载到本地"),
|
||||
"remote-only attachment must hint that it was never downloaded locally");
|
||||
assertTrue(text.contains("开启") && text.contains("媒体下载"),
|
||||
"hint must tell the user to enable channel media download");
|
||||
assertTrue(result.getMedia() == null || result.getMedia().isEmpty(),
|
||||
"a remote URL must not be injected as Media");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("No attachments → no system notice, prompt text unchanged")
|
||||
void noAttachments_noNoticeAdded() {
|
||||
|
||||
@ -0,0 +1,70 @@
|
||||
package vip.mate.llm.routing;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Locale;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Pins the two-stage prompt contract of {@link MediaCaptionService}: when a user
|
||||
* question is supplied the vision model must be asked to answer it (so multi-turn
|
||||
* follow-ups get a tailored answer instead of a generic caption), and when no
|
||||
* question is supplied it must fall back to the factual full-description prompt.
|
||||
*
|
||||
* <p>{@code buildPrompt} is private — it is the smallest unit that captures the
|
||||
* branching, so it is exercised via reflection rather than driving a real
|
||||
* (networked) vision call.
|
||||
*/
|
||||
class MediaCaptionServiceBuildPromptTest {
|
||||
|
||||
private static String buildPrompt(Locale locale, String fileName, String userQuestion) throws Exception {
|
||||
// Dependencies are unused by buildPrompt; null is fine for this unit.
|
||||
MediaCaptionService service = new MediaCaptionService(null, null);
|
||||
Method m = MediaCaptionService.class.getDeclaredMethod(
|
||||
"buildPrompt", Locale.class, String.class, String.class);
|
||||
m.setAccessible(true);
|
||||
return (String) m.invoke(service, locale, fileName, userQuestion);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Question + Chinese locale → question-aware prompt embedding the question")
|
||||
void chineseWithQuestion_isQuestionAware() throws Exception {
|
||||
String prompt = buildPrompt(Locale.SIMPLIFIED_CHINESE, "err.png", "图里的报错是什么");
|
||||
|
||||
assertTrue(prompt.contains("图里的报错是什么"), "the user's question must be embedded verbatim");
|
||||
assertTrue(prompt.contains("回答用户的问题"), "must instruct the model to answer, not just describe");
|
||||
assertFalse(prompt.contains("不超过 300 字"),
|
||||
"question-aware prompt must not reuse the generic description template");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Question + English locale → English question-aware prompt")
|
||||
void englishWithQuestion_isQuestionAware() throws Exception {
|
||||
String prompt = buildPrompt(Locale.ENGLISH, "err.png", "What is the error message?");
|
||||
|
||||
assertTrue(prompt.contains("What is the error message?"));
|
||||
assertTrue(prompt.contains("answer the user's question"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Blank question → generic Chinese description prompt")
|
||||
void chineseNoQuestion_isGeneric() throws Exception {
|
||||
String prompt = buildPrompt(Locale.SIMPLIFIED_CHINESE, "photo.jpg", " ");
|
||||
|
||||
assertTrue(prompt.contains("请用一段简洁的中文描述这张图片"));
|
||||
assertFalse(prompt.contains("回答用户的问题"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Null question + English → generic English description prompt")
|
||||
void englishNoQuestion_isGeneric() throws Exception {
|
||||
String prompt = buildPrompt(Locale.ENGLISH, null, null);
|
||||
|
||||
assertTrue(prompt.contains("Describe this image"));
|
||||
assertFalse(prompt.contains("answer the user's question"));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user