Skip to content
Draft
Show file tree
Hide file tree
Changes from 35 commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
1bf7f07
Update ModelCostData.java
vincentkoc Nov 7, 2025
3240de3
feat: BE support online eval with video
vincentkoc Nov 7, 2025
3c6e831
Update ModelCapabilitiesTest.java
vincentkoc Nov 7, 2025
7f075ec
Update MessageContentNormalizerTest.java
vincentkoc Nov 7, 2025
2af6e18
feat: BE llm message type for video
vincentkoc Nov 7, 2025
620cb49
chore: type for images in FE
vincentkoc Nov 7, 2025
914764e
Update modelCapabilities.ts
vincentkoc Nov 7, 2025
0521e58
feat: FE llm video type
vincentkoc Nov 7, 2025
40765d9
Update useMessageContent.ts
vincentkoc Nov 7, 2025
1e4e7eb
chore: media on prompt improvement panel
vincentkoc Nov 7, 2025
248d7e2
feat: video on prompts page
vincentkoc Nov 7, 2025
fcd3f62
feat: video on playground
vincentkoc Nov 7, 2025
1895068
chore: video datasets
vincentkoc Nov 7, 2025
fee3276
chore: video on experiments
vincentkoc Nov 7, 2025
1919f1e
feat: attachments for video
vincentkoc Nov 7, 2025
03276d5
feat: judge using video
vincentkoc Nov 7, 2025
405c099
Update chat_content_renderer_registry.py
vincentkoc Nov 7, 2025
63e3715
Update chat_prompt_template.py
vincentkoc Nov 7, 2025
dcbdf97
Update types.py
vincentkoc Nov 7, 2025
7e82ba9
Update evaluator.py
vincentkoc Nov 7, 2025
e56867b
feat: SDK model capabilities
vincentkoc Nov 7, 2025
6420e9f
Update OnlineScoringEngine.java
vincentkoc Nov 7, 2025
6ab3021
Update MessageContentNormalizer.java
vincentkoc Nov 7, 2025
d059f51
chore: supports vision is mapped
vincentkoc Nov 7, 2025
2eb230a
Merge branch 'main' into feat/video-eval
vincentkoc Nov 7, 2025
8b2c358
Update MessageContentNormalizer.java
vincentkoc Nov 7, 2025
5cd8534
chore: lint
vincentkoc Nov 7, 2025
2d493d0
Merge branch 'feat/video-eval' of https://github.com/comet-ml/opik in…
vincentkoc Nov 7, 2025
a48c229
Update model_capabilities.py
vincentkoc Nov 7, 2025
4fdbf20
Update apps/opik-frontend/src/lib/modelCapabilities.ts
vincentkoc Nov 7, 2025
081f456
chore: copilot fixes
vincentkoc Nov 7, 2025
0d6ab81
Merge branch 'feat/video-eval' of https://github.com/comet-ml/opik in…
vincentkoc Nov 7, 2025
6af3039
Update evaluate_multimodal.mdx
vincentkoc Nov 7, 2025
e0501a6
chore: reset python sdk
vincentkoc Nov 8, 2025
01f21bf
Merge branch 'main' into feat/video-eval
vincentkoc Nov 8, 2025
a47b41f
fixing base64 video in playground & dataset
YarivHashaiComet Nov 10, 2025
d0cd62a
fixing base64 img & video on annodation queue
YarivHashaiComet Nov 10, 2025
6048173
removing unrelated code
YarivHashaiComet Nov 10, 2025
c4757c5
Merge branch 'main' into feat/video-eval
vincentkoc Nov 11, 2025
57b718d
[OPIK-3030] [BE] Cursor fixes for videos in online scoring (#4025)
AndreiCautisanu Nov 11, 2025
c34303c
Merge branch 'main' into feat/video-eval
vincentkoc Nov 11, 2025
8bc5878
[OPIK-3040] add video thumbnail preview
CometActions Nov 12, 2025
0c6db42
[OPIK-3040] add file input for video/image
CometActions Nov 12, 2025
d9d6280
[OPIK-3040] merge main
CometActions Nov 12, 2025
10a5179
fix: revert breaking change
vincentkoc Nov 12, 2025
c8f97df
fix: video model mapping
vincentkoc Nov 12, 2025
05319f1
Update OnlineScoringEngine.java
vincentkoc Nov 12, 2025
48e6e2f
Merge branch 'main' into feat/video-eval
vincentkoc Nov 12, 2025
082bcd1
fix: SDK issue with null values in template
vincentkoc Nov 12, 2025
bc421bb
Merge branch 'feat/video-eval' of https://github.com/comet-ml/opik in…
vincentkoc Nov 12, 2025
bc18f3b
Update test_prompt_template.py
vincentkoc Nov 12, 2025
cb38c8d
[OPIK-3040] fix formats
CometActions Nov 13, 2025
c41cedf
[OPIK-3040] handle base64 as file
CometActions Nov 13, 2025
3807c0c
Merge branch 'feat/video-eval' of https://github.com/comet-ml/opik in…
CometActions Nov 13, 2025
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
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,6 @@ public record ModelCostData(String litellmProvider,
String outputCostPerToken,
String cacheCreationInputTokenCost,
String cacheReadInputTokenCost,
boolean supportsVision) {
boolean supportsVision,
boolean supportsVideoInput) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
import com.comet.opik.api.Trace;
import com.comet.opik.api.evaluators.LlmAsJudgeMessage;
import com.comet.opik.domain.evaluators.python.TraceThreadPythonEvaluatorRequest;
import com.comet.opik.domain.llm.MessageContentNormalizer;
import com.comet.opik.domain.llm.structuredoutput.StructuredOutputStrategy;
import com.comet.opik.utils.JsonUtils;
import com.comet.opik.utils.TemplateParseUtils;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
Expand All @@ -15,10 +15,13 @@
import com.google.api.gax.rpc.InvalidArgumentException;
import com.jayway.jsonpath.JsonPath;
import dev.langchain4j.data.message.ChatMessage;
import dev.langchain4j.data.message.Content;
import dev.langchain4j.data.message.ImageContent;
import dev.langchain4j.data.message.SystemMessage;
import dev.langchain4j.data.message.TextContent;
import dev.langchain4j.data.message.UserMessage;
import dev.langchain4j.data.message.VideoContent;
import dev.langchain4j.data.video.Video;
import dev.langchain4j.model.chat.request.ChatRequest;
import dev.langchain4j.model.chat.response.ChatResponse;
import jakarta.validation.constraints.NotNull;
Expand Down Expand Up @@ -60,9 +63,8 @@ public class OnlineScoringEngine {

private static final Pattern JSON_BLOCK_PATTERN = Pattern.compile(
"```(?:json)?\\s*(\\{.*?})\\s*```", Pattern.DOTALL);
private static final Pattern IMAGE_PLACEHOLDER_PATTERN = Pattern.compile(
Pattern.quote(MessageContentNormalizer.IMAGE_PLACEHOLDER_START) + "(.*?)"
+ Pattern.quote(MessageContentNormalizer.IMAGE_PLACEHOLDER_END),
private static final Pattern MEDIA_PLACEHOLDER_PATTERN = Pattern.compile(
"<<<(image|video)>>>(.*?)<<</(image|video)>>>",
Pattern.DOTALL);

/**
Expand Down Expand Up @@ -221,36 +223,42 @@ static List<MessageVariableMapping> toVariableMapping(Map<String, String> evalua
}

private UserMessage buildUserMessage(String content) {
var matcher = IMAGE_PLACEHOLDER_PATTERN.matcher(content);
var foundAny = false;
var matcher = MEDIA_PLACEHOLDER_PATTERN.matcher(content);

if (!matcher.find()) {
return UserMessage.from(content);
}

matcher.reset();

var builder = UserMessage.builder();
var lastIndex = 0;

while (matcher.find()) {
foundAny = true;

if (matcher.start() > lastIndex) {
var textSegment = content.substring(lastIndex, matcher.start());
appendTextContent(builder, textSegment);
}

var url = matcher.group(1).trim();
if (!url.isEmpty()) {
// Unescape HTML entities for backward compatibility with templates using {{variable}}
// RECOMMENDED: Use {{{variable}}} (triple braces) or {{&variable}} in Mustache templates
// to prevent HTML escaping of URLs in the first place
var unescapedUrl = StringEscapeUtils.unescapeHtml4(url);
builder.addContent(ImageContent.from(unescapedUrl));
var mediaType = matcher.group(1);
var rawValue = matcher.group(2).trim();
var placeholderText = matcher.group(0);

if (!rawValue.isEmpty()) {
// Mustache templates can HTML-escape URLs; unescape before building structured content
// so providers receive the exact user-specified payload.
var unescapedValue = StringEscapeUtils.unescapeHtml4(rawValue);
var mediaContent = createMediaContent(mediaType, unescapedValue);
if (mediaContent != null) {
builder.addContent(mediaContent);
} else {
appendTextContent(builder, placeholderText);
}
}

lastIndex = matcher.end();
}

if (!foundAny) {
return UserMessage.from(content);
}

if (lastIndex < content.length()) {
var trailingText = content.substring(lastIndex);
appendTextContent(builder, trailingText);
Expand All @@ -259,12 +267,207 @@ private UserMessage buildUserMessage(String content) {
return builder.build();
}

private Content createMediaContent(String mediaType, String rawValue) {
if ("image".equalsIgnoreCase(mediaType)) {
return createImageContent(rawValue);
}

if ("video".equalsIgnoreCase(mediaType)) {
return createVideoContent(rawValue);
}

log.warn("Unsupported media type placeholder encountered: '{}'", mediaType);
return null;
}

private void appendTextContent(UserMessage.Builder builder, String textSegment) {
if (StringUtils.isNotBlank(textSegment)) {
builder.addContent(TextContent.from(textSegment));
}
}

private Content createImageContent(String rawValue) {
if (StringUtils.isBlank(rawValue)) {
return null;
}

try {
return ImageContent.from(rawValue);
} catch (IllegalArgumentException exception) {
log.warn("Failed to build image content for placeholder: {}", rawValue, exception);
return null;
}
}

private Content createVideoContent(String rawValue) {
var trimmed = StringUtils.trimToEmpty(rawValue);
if (trimmed.isEmpty()) {
return null;
}

if (looksLikeJson(trimmed)) {
Object parsed = tryParseJson(trimmed);
var fromJson = convertVideoPayload(parsed);
if (fromJson != null) {
return fromJson;
}
}

return buildVideoFromValue(trimmed, null);
}

private Content convertVideoPayload(Object payload) {
if (payload instanceof Map<?, ?> mapPayload) {
return convertVideoObject(mapPayload);
}

if (payload instanceof List<?> listPayload) {
for (var entry : listPayload) {
var converted = convertVideoPayload(entry);
if (converted != null) {
return converted;
}
}
}

if (payload instanceof String stringPayload) {
return buildVideoFromValue(stringPayload, null);
}

return null;
}

private Content convertVideoObject(Map<?, ?> payload) {
var type = stringValue(payload.get("type"));

if ("video_url".equalsIgnoreCase(type) || payload.containsKey("video_url")) {
return buildVideoFromVideoUrl(payload.get("video_url"));
}

if ("file".equalsIgnoreCase(type) || payload.containsKey("file")) {
return buildVideoFromFile(payload.get("file"));
}

if (type == null) {
return buildVideoFromVideoUrl(payload.get("url"));
}

return null;
}

private Content buildVideoFromVideoUrl(Object videoNode) {
if (videoNode instanceof String url) {
return buildVideoFromValue(url, null);
}

if (videoNode instanceof Map<?, ?> videoMap) {
var url = stringValue(videoMap.get("url"));
var mimeType = firstNonBlank(
stringValue(videoMap.get("mime_type")),
stringValue(videoMap.get("format")));
return buildVideoFromValue(url, mimeType);
}

return null;
}

private Content buildVideoFromFile(Object fileNode) {
if (!(fileNode instanceof Map<?, ?> fileMap)) {
return null;
}

var fileId = stringValue(fileMap.get("file_id"));
if (StringUtils.isNotBlank(fileId)) {
return buildVideoFromValue(fileId, stringValue(fileMap.get("format")));
}

var fileData = stringValue(fileMap.get("file_data"));
if (StringUtils.isNotBlank(fileData)) {
return buildVideoFromBase64(fileData, stringValue(fileMap.get("format")));
}

return null;
}

private Content buildVideoFromValue(String value, String mimeType) {
if (StringUtils.isBlank(value)) {
return null;
}

if (value.startsWith("data:video/")) {
return buildVideoFromUrl(value, mimeType);
}

if (value.startsWith("http://") || value.startsWith("https://")) {
return buildVideoFromUrl(value, mimeType);
}

return buildVideoFromBase64(value, mimeType);
}

private Content buildVideoFromUrl(String url, String mimeType) {
if (StringUtils.isBlank(url)) {
return null;
}

try {
if (StringUtils.isBlank(mimeType)) {
return VideoContent.from(url);
}

var video = Video.builder()
.url(url)
.mimeType(mimeType)
.build();
return VideoContent.from(video);
} catch (IllegalArgumentException exception) {
log.warn("Failed to build video content for url placeholder: {}", url, exception);
return null;
}
}

private Content buildVideoFromBase64(String base64Data, String mimeType) {
if (StringUtils.isBlank(base64Data)) {
return null;
}

var safeMimeType = StringUtils.defaultIfBlank(mimeType, "video/mp4");
try {
return VideoContent.from(base64Data, safeMimeType);
} catch (IllegalArgumentException exception) {
log.warn("Failed to build video content from base64 payload", exception);
return null;
}
}

private boolean looksLikeJson(String value) {
var trimmed = StringUtils.trimToEmpty(value);
return trimmed.startsWith("{") || trimmed.startsWith("[");
}

private Object tryParseJson(String rawValue) {
try {
return JsonUtils.readValue(rawValue, Object.class);
} catch (RuntimeException exception) {
log.warn("Failed to parse media placeholder as JSON: {}", rawValue, exception);
return null;
}
}

private String stringValue(Object value) {
return value instanceof String str ? str : null;
}

private String firstNonBlank(String first, String second) {
if (StringUtils.isNotBlank(first)) {
return first;
}
if (StringUtils.isNotBlank(second)) {
return second;
}
return null;
}

private static String extractFromJson(JsonNode json, String path) {
Map<String, Object> forcedObject;
try {
Expand Down
Loading