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
7 changes: 7 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@
<!-- Dependencies versions -->
<!-- **************************************** -->
<addon.exo.ecms.version>7.3.x-ai-contribution-SNAPSHOT</addon.exo.ecms.version>
<!-- documents provides the transcript chapters and speakers the recordings tool lists -->
<addon.exo.documents.version>7.3.x-ai-contribution-SNAPSHOT</addon.exo.documents.version>

<!-- Sonar properties -->
<sonar.organization>exoplatform</sonar.organization>
Expand All @@ -69,6 +71,11 @@
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.exoplatform.documents</groupId>
<artifactId>documents-api</artifactId>
<version>${addon.exo.documents.version}</version>
</dependency>
<!-- Video Calls extension -->
<dependency>
<groupId>${project.groupId}</groupId>
Expand Down
6 changes: 6 additions & 0 deletions services/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,12 @@
<artifactId>mcp-server-tools</artifactId>
<scope>provided</scope>
</dependency>
<!-- Documents API (the transcript chapters and speakers of a recording) -->
<dependency>
<groupId>org.exoplatform.documents</groupId>
<artifactId>documents-api</artifactId>
<scope>provided</scope>
</dependency>
</dependencies>
<build>
<resources>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@
*/
package org.exoplatform.webconferencing.mcp;

import java.util.ArrayList;
import java.util.Date;
import java.util.List;

import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
Expand All @@ -28,6 +30,7 @@
import org.exoplatform.webconferencing.WebConferencingService;
import org.exoplatform.webconferencing.mcp.model.MeetingRecordingModel;
import org.exoplatform.webconferencing.mcp.model.MeetingRecordingsModel;
import org.exoplatform.webconferencing.mcp.model.RecordingOutlineModel;
import org.exoplatform.webconferencing.model.RecordingInfo;
import org.exoplatform.webconferencing.util.RecordingDateUtils;

Expand Down Expand Up @@ -62,21 +65,37 @@ public class MeetingRecordingMcpTool implements McpToolPlugin {
+ "expression such as 'yesterday' or 'last week' into such a date yourself "
+ "before calling the tool again.";

/**
* How many recordings, the newest, are listed with their chapters and
* speakers: reading them costs one read of each transcript, and the assistant
* narrows an older recording down by date anyway.
*/
public static final int MAX_OUTLINED = 10;

private final WebConferencingService webConferencingService;

private final ObjectProvider<RecordingOutlineReader> outlineReader;

/**
* Builds the tool.
*
* @param webConferencingService the service holding the recordings lookup
* @param outlineReader the reader of the recordings' chapters and speakers,
* absent without the Documents add-on
*/
@Autowired
public MeetingRecordingMcpTool(WebConferencingService webConferencingService) {
public MeetingRecordingMcpTool(WebConferencingService webConferencingService,
ObjectProvider<RecordingOutlineReader> outlineReader) {
this.webConferencingService = webConferencingService;
this.outlineReader = outlineReader;
}

/**
* Lists the recordings of the meeting behind a conference link that the
* current user can open, newest first ({@code get_meeting_recordings}).
* current user can open, newest first ({@code get_meeting_recordings}). The
* {@link #MAX_OUTLINED} newest come with the chapters of their transcript and
* the names of its speakers, read with the same user's rights, so that the
* assistant picks a recording and a chapter before reading any transcript.
*
* @param conferenceUrl the meeting link, as {@code get_event_conference}
* returns it
Expand All @@ -101,14 +120,17 @@ public MeetingRecordingsModel getMeetingRecordings(String conferenceUrl,
Date from = parseDateArgument("recorded_from", recordedFrom, false);
Date to = parseDateArgument("recorded_to", recordedTo, true);
try {
List<MeetingRecordingModel> recordings =
webConferencingService.getCallRecordingsByConference(conferenceUrl,
getCurrentUserName(),
from,
to)
.stream()
.map(MeetingRecordingMcpTool::toModel)
.toList();
String username = getCurrentUserName();
List<RecordingInfo> found = webConferencingService.getCallRecordingsByConference(conferenceUrl, username, from, to);
RecordingOutlineReader reader = found.isEmpty() ? null : outlineReader.getIfAvailable();
long identityId = reader == null ? 0 : reader.getIdentityId(username);
List<MeetingRecordingModel> recordings = new ArrayList<>(found.size());
for (RecordingInfo recording : found) {
boolean outlined = identityId > 0 && recordings.size() < MAX_OUTLINED;
RecordingOutlineModel outline = outlined ? reader.readOutline(recording.getDocumentId(), identityId)
: RecordingOutlineModel.EMPTY;
recordings.add(toModel(recording, outline));
}
return new MeetingRecordingsModel(recordings, recordings.isEmpty() ? NO_RECORDING : null);
} catch (ObjectNotFoundException e) {
String reason = WebConferencingService.RECORDINGS_OTHER_PROVIDER.equals(e.getMessage()) ? OTHER_PROVIDER : NO_RECORDING;
Expand Down Expand Up @@ -140,17 +162,20 @@ private static Date parseDateArgument(String name, String value, boolean endOfRa
* Maps a recording to the tool's answer.
*
* @param recording the recording
* @param outline the chapters and speakers of its transcript
* @return its model
*/
private static MeetingRecordingModel toModel(RecordingInfo recording) {
private static MeetingRecordingModel toModel(RecordingInfo recording, RecordingOutlineModel outline) {
return new MeetingRecordingModel(recording.getDocumentId(),
recording.getTitle(),
RecordingDateUtils.formatDate(recording.getRecordedDate()),
RecordingDateUtils.formatDate(recording.getStartedDate()),
recording.getSizeBytes(),
recording.getMimeType(),
recording.getDurationSeconds(),
recording.getPreviewUrl());
recording.getPreviewUrl(),
outline.chapters(),
outline.speakers());
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
/*
* Copyright (C) 2026 eXo Platform SAS.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License
* as published by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <gnu.org/licenses>.
*/
package org.exoplatform.webconferencing.mcp;

import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.math.NumberUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;

import org.exoplatform.documents.model.TranscriptSpeakers;
import org.exoplatform.documents.service.DocumentFileService;
import org.exoplatform.services.log.ExoLogger;
import org.exoplatform.services.log.Log;
import org.exoplatform.social.core.identity.model.Identity;
import org.exoplatform.social.core.manager.IdentityManager;
import org.exoplatform.webconferencing.mcp.model.RecordingChapterModel;
import org.exoplatform.webconferencing.mcp.model.RecordingOutlineModel;

import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;

/**
* Reads what a recording's transcript holds before anyone reads it: its
* chapters and the names of its speakers, as Documents stores them, with the
* rights of the user listing the recordings.
* <p>
* Present only when the Documents add-on is: without it, the recordings are
* listed without an outline.
*/
@Component
@Profile("mcp-server")
@ConditionalOnClass(name = "org.exoplatform.documents.service.DocumentFileService")
public class RecordingOutlineReader {

private static final Log LOG = ExoLogger.getLogger(RecordingOutlineReader.class);

private static final ObjectMapper MAPPER = JsonMapper.builder().build();

private final DocumentFileService documentFileService;

private final IdentityManager identityManager;

/**
* Builds the reader.
*
* @param documentFileService the Documents service storing the transcripts
* @param identityManager the service resolving the users who speak
*/
@Autowired
public RecordingOutlineReader(DocumentFileService documentFileService, IdentityManager identityManager) {
this.documentFileService = documentFileService;
this.identityManager = identityManager;
}

/**
* The identity id of a user, the one the Documents reads take.
*
* @param username the user's name
* @return the identity id, or 0 when the user has no identity
*/
public long getIdentityId(String username) {
Identity identity = identityManager.getOrCreateUserIdentity(username);
return identity == null ? 0 : Long.parseLong(identity.getId());
}

/**
* The outline of a recording's transcript: its chapters and its speakers'
* names. Each is read on its own, so a failure on one never takes the other
* away, and a failure on both gives an empty outline, never an error: the
* recording is listed all the same.
*
* @param documentId the id of the recording's document
* @param identityId the identity id of the user listing the recordings
* @return the outline, {@link RecordingOutlineModel#EMPTY} when there is none
*/
public RecordingOutlineModel readOutline(String documentId, long identityId) {
List<RecordingChapterModel> chapters = readChapters(documentId, identityId);
List<String> speakers = readSpeakers(documentId, identityId);
return chapters.isEmpty() && speakers.isEmpty() ? RecordingOutlineModel.EMPTY
: new RecordingOutlineModel(chapters, speakers);
}

/**
* Reads the chapters of a recording's transcript, skipping any without a
* title or a valid start.
*
* @param documentId the id of the recording's document
* @param identityId the identity id of the user
* @return the chapters, empty when there are none or they cannot be read
*/
private List<RecordingChapterModel> readChapters(String documentId, long identityId) {
try {
String chapters = documentFileService.getTranscriptionChapters(documentId, identityId);
if (StringUtils.isBlank(chapters)) {
return List.of();
}
List<RecordingChapterModel> result = new ArrayList<>();
for (JsonNode chapter : MAPPER.readTree(chapters)) {
String title = chapter.hasNonNull("title") ? StringUtils.trimToNull(chapter.get("title").asString()) : null;
double start = chapter.hasNonNull("start") && chapter.get("start").isNumber() ? chapter.get("start").asDouble() : -1;
if (title != null && Double.isFinite(start) && start >= 0) {
result.add(new RecordingChapterModel((long) start, TranscriptSpeakers.formatMoment(start), title));
}
}
return result;
} catch (Exception e) { // NOSONAR the recording is listed without its chapters, whatever the failure
LOG.debug("The chapters of recording {} cannot be read, it is listed without them", documentId, e);
return List.of();
}
}

/**
* Reads the names of the speakers of a recording's transcript, in the order
* they first speak: a named speaker by the person's full name or the guest's
* name, any other by its number. A person named for two speakers is listed
* once.
*
* @param documentId the id of the recording's document
* @param identityId the identity id of the user
* @return the names, empty when the transcript does not tell its speakers
* apart or they cannot be read
*/
private List<String> readSpeakers(String documentId, long identityId) {
try {
String segments = documentFileService.getTranscriptionSegments(documentId, identityId);
if (StringUtils.isBlank(segments)) {
return List.of();
}
Set<String> speakerNumbers = new LinkedHashSet<>();
for (JsonNode segment : MAPPER.readTree(segments)) {
JsonNode speaker = segment.get("speaker");
if (speaker != null && speaker.isString() && StringUtils.isNotBlank(speaker.asString())) {
speakerNumbers.add(speaker.asString());
}
}
if (speakerNumbers.isEmpty()) {
return List.of();
}
Map<String, String> namedSpeakers = documentFileService.getTranscriptionSpeakers(documentId, identityId);
Set<String> names = new LinkedHashSet<>();
for (String speaker : speakerNumbers) {
names.add(TranscriptSpeakers.speakerName(speaker, namedSpeakers, this::getFullName));
}
return List.copyOf(names);
} catch (Exception e) { // NOSONAR the recording is listed without its speakers, whatever the failure
LOG.debug("The speakers of recording {} cannot be read, it is listed without them", documentId, e);
return List.of();
}
}

/**
* The full name of an identity.
*
* @param identityId the identity id
* @return the full name, or null when the identity is gone or the id is not
* one
*/
private String getFullName(String identityId) {
long id = NumberUtils.toLong(identityId, 0);
if (id <= 0) {
return null;
}
Identity identity = identityManager.getIdentity(id);
if (identity == null || identity.isDeleted() || identity.getProfile() == null) {
return null;
}
return StringUtils.trimToNull(identity.getProfile().getFullName());
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
*/
package org.exoplatform.webconferencing.mcp.model;

import java.util.List;

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include;

Expand All @@ -33,6 +35,10 @@
* @param durationSeconds how long the recording lasts, in seconds, absent
* when its file does not tell it
* @param previewUrl the link opening the recording in Documents
* @param chapters the chapters of the recording's transcript, each with the
* moment it starts and its title, absent when it has none
* @param speakers the names of the speakers of the recording's transcript,
* absent when it does not tell them apart
*/
@JsonInclude(value = Include.NON_EMPTY)
public record MeetingRecordingModel(String documentId,
Expand All @@ -42,5 +48,7 @@ public record MeetingRecordingModel(String documentId,
long sizeBytes,
String mimeType,
Long durationSeconds,
String previewUrl) {
String previewUrl,
List<RecordingChapterModel> chapters,
List<String> speakers) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*
* Copyright (C) 2026 eXo Platform SAS.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License
* as published by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <gnu.org/licenses>.
*/
package org.exoplatform.webconferencing.mcp.model;

/**
* A chapter of a recording's transcript, as the recordings tool lists it.
*
* @param start the moment the chapter starts, in whole seconds from the
* beginning of the recording
* @param time the same moment as {@code mm:ss}, or {@code h:mm:ss} past an
* hour
* @param title the chapter's title
*/
public record RecordingChapterModel(long start, String time, String title) {
}
Loading
Loading