Merged in coar-notify-7_CST-12115 (pull request #1251)

[CST-12115] added support to decide if a correction suggestion should be automatically processed

Approved-by: Andrea Bollini
This commit is contained in:
Mohamed Saber Eskander
2023-11-03 18:16:04 +00:00
committed by Andrea Bollini
12 changed files with 470 additions and 5 deletions

View File

@@ -7,11 +7,15 @@
*/
package org.dspace.app.ldn.action;
import java.math.BigDecimal;
import java.sql.SQLException;
import java.util.Date;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.dspace.app.ldn.NotifyServiceEntity;
import org.dspace.app.ldn.model.Notification;
import org.dspace.app.ldn.service.LDNMessageService;
import org.dspace.content.Item;
import org.dspace.content.QAEvent;
import org.dspace.content.service.ItemService;
@@ -33,14 +37,16 @@ public class LDNCorrectionAction implements LDNAction {
protected ItemService itemService;
@Autowired
private QAEventService qaEventService;
@Autowired
private LDNMessageService ldnMessageService;
@Override
public ActionStatus execute(Notification notification, Item item) throws Exception {
ActionStatus result = ActionStatus.ABORT;
ActionStatus result;
Context context = ContextUtil.obtainCurrentRequestContext();
QAEvent qaEvent = new QAEvent(QAEvent.COAR_NOTIFY,
notification.getObject().getId(), item.getID().toString(), item.getName(),
this.getQaEventTopic(), 0d,
this.getQaEventTopic(), getScore(context, notification).doubleValue(),
"{\"abstracts[0]\": \"" + notification.getObject().getIetfCiteAs() + "\"}"
, new Date());
qaEventService.store(context, qaEvent);
@@ -49,6 +55,21 @@ public class LDNCorrectionAction implements LDNAction {
return result;
}
private BigDecimal getScore(Context context, Notification notification) throws SQLException {
if (notification.getOrigin() == null) {
return BigDecimal.ZERO;
}
NotifyServiceEntity service = ldnMessageService.findNotifyService(context, notification.getOrigin());
if (service == null) {
return BigDecimal.ZERO;
}
return service.getScore();
}
public String getQaEventTopic() {
return qaEventTopic;
}

View File

@@ -11,7 +11,9 @@ import java.sql.SQLException;
import java.util.List;
import org.dspace.app.ldn.LDNMessageEntity;
import org.dspace.app.ldn.NotifyServiceEntity;
import org.dspace.app.ldn.model.Notification;
import org.dspace.app.ldn.model.Service;
import org.dspace.core.Context;
/**
@@ -94,4 +96,14 @@ public interface LDNMessageService {
* @param context The DSpace context
*/
public int extractAndProcessMessageFromQueue(Context context) throws SQLException;
/**
* find the related notify service entity
*
* @param context the context
* @param service the service
* @return the NotifyServiceEntity
* @throws SQLException if something goes wrong
*/
public NotifyServiceEntity findNotifyService(Context context, Service service) throws SQLException;
}

View File

@@ -148,7 +148,7 @@ public class LDNMessageServiceImpl implements LDNMessageService {
return null;
}
private NotifyServiceEntity findNotifyService(Context context, Service service) throws SQLException {
public NotifyServiceEntity findNotifyService(Context context, Service service) throws SQLException {
return notifyServiceDao.findByLdnUrl(context, service.getInbox());
}

View File

@@ -197,6 +197,7 @@ public class QAEvent {
public Class<? extends QAMessageDTO> getMessageDtoClass() {
switch (getSource()) {
case OPENAIRE_SOURCE:
case COAR_NOTIFY:
return OpenaireMessageDTO.class;
default:
throw new IllegalArgumentException("Unknown event's source: " + getSource());

View File

@@ -0,0 +1,17 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
package org.dspace.qaevent;
/**
* Enumeration of possible actions to perform over a {@link org.dspace.content.QAEvent}
*
* @author Mohamed Eskander (mohamed.eskander at 4science.com)
*/
public enum AutomaticProcessingAction {
REJECT, ACCEPT, IGNORE
}

View File

@@ -0,0 +1,31 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
package org.dspace.qaevent;
import org.dspace.content.QAEvent;
import org.dspace.core.Context;
/**
* This interface allows the implemnetation of Automation Processing rules
* defining which {@link AutomaticProcessingAction} should be eventually
* performed on a specific {@link QAEvent}
*
* @author Mohamed Eskander (mohamed.eskander at 4science.com)
*/
public interface QAEventAutomaticProcessingEvaluation {
/**
* Evaluate a {@link QAEvent} to decide which, if any, {@link AutomaticProcessingAction} should be performed
*
* @param context the DSpace context
* @param qaEvent the quality assurance event
* @return an action of {@link AutomaticProcessingAction} or null if no automatic action should be performed
*/
AutomaticProcessingAction evaluateAutomaticProcessing(Context context, QAEvent qaEvent);
}

View File

@@ -0,0 +1,151 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
package org.dspace.qaevent;
import java.sql.SQLException;
import java.util.UUID;
import org.dspace.content.Item;
import org.dspace.content.QAEvent;
import org.dspace.content.logic.LogicalStatement;
import org.dspace.content.service.ItemService;
import org.dspace.core.Context;
import org.springframework.beans.factory.annotation.Autowired;
/**
* A configurable implementation of {@link QAEventAutomaticProcessingEvaluation} allowing to define thresholds for
* automatic acceptance, rejection or ignore of {@link QAEvent} matching a specific, optional, item filter
* {@link LogicalStatement}. If the item filter is not defined only the score threshold will be used.
*
* @author Mohamed Eskander (mohamed.eskander at 4science.com)
*/
public class QAScoreAutomaticProcessingEvaluation implements QAEventAutomaticProcessingEvaluation {
/**
* The minimum score of QAEvent to be considered for automatic approval (trust must be greater or equals to that)
*/
private double scoreToApprove;
/**
* The threshold under which QAEvent are considered for automatic ignore (trust must be less or equals to that)
*/
private double scoreToIgnore;
/**
* The threshold under which QAEvent are considered for automatic rejection (trust must be less or equals to that)
*/
private double scoreToReject;
/**
* The optional logical statement that must pass for item target of a QAEvent to be considered for automatic
* approval
*/
private LogicalStatement itemFilterToApprove;
/**
* The optional logical statement that must pass for item target of a QAEvent to be considered for automatic
* ignore
*/
private LogicalStatement itemFilterToIgnore;
/**
* The optional logical statement that must pass for item target of a QAEvent to be considered for automatic
* rejection
*/
private LogicalStatement itemFilterToReject;
@Autowired
private ItemService itemService;
@Override
public AutomaticProcessingAction evaluateAutomaticProcessing(Context context, QAEvent qaEvent) {
Item item = findItem(context, qaEvent.getTarget());
if (shouldReject(context, qaEvent.getTrust(), item)) {
return AutomaticProcessingAction.REJECT;
} else if (shouldIgnore(context, qaEvent.getTrust(), item)) {
return AutomaticProcessingAction.IGNORE;
} else if (shouldApprove(context, qaEvent.getTrust(), item)) {
return AutomaticProcessingAction.ACCEPT;
} else {
return null;
}
}
private Item findItem(Context context, String uuid) {
try {
return itemService.find(context, UUID.fromString(uuid));
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
private boolean shouldReject(Context context, double trust, Item item) {
return trust <= scoreToReject &&
(itemFilterToReject == null || itemFilterToReject.getResult(context, item));
}
private boolean shouldIgnore(Context context, double trust, Item item) {
return trust <= scoreToIgnore &&
(itemFilterToIgnore == null || itemFilterToIgnore.getResult(context, item));
}
private boolean shouldApprove(Context context, double trust, Item item) {
return trust >= scoreToApprove &&
(itemFilterToApprove == null || itemFilterToApprove.getResult(context, item));
}
public double getScoreToApprove() {
return scoreToApprove;
}
public void setScoreToApprove(double scoreToApprove) {
this.scoreToApprove = scoreToApprove;
}
public double getScoreToIgnore() {
return scoreToIgnore;
}
public void setScoreToIgnore(double scoreToIgnore) {
this.scoreToIgnore = scoreToIgnore;
}
public double getScoreToReject() {
return scoreToReject;
}
public void setScoreToReject(double scoreToReject) {
this.scoreToReject = scoreToReject;
}
public LogicalStatement getItemFilterToApprove() {
return itemFilterToApprove;
}
public void setItemFilterToApprove(LogicalStatement itemFilterToApprove) {
this.itemFilterToApprove = itemFilterToApprove;
}
public LogicalStatement getItemFilterToIgnore() {
return itemFilterToIgnore;
}
public void setItemFilterToIgnore(LogicalStatement itemFilterToIgnore) {
this.itemFilterToIgnore = itemFilterToIgnore;
}
public LogicalStatement getItemFilterToReject() {
return itemFilterToReject;
}
public void setItemFilterToReject(LogicalStatement itemFilterToReject) {
this.itemFilterToReject = itemFilterToReject;
}
}

View File

@@ -16,6 +16,7 @@ import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;
@@ -41,14 +42,18 @@ import org.dspace.content.QAEvent;
import org.dspace.content.service.ItemService;
import org.dspace.core.Context;
import org.dspace.handle.service.HandleService;
import org.dspace.qaevent.AutomaticProcessingAction;
import org.dspace.qaevent.QAEventAutomaticProcessingEvaluation;
import org.dspace.qaevent.QASource;
import org.dspace.qaevent.QATopic;
import org.dspace.qaevent.dao.QAEventsDao;
import org.dspace.qaevent.dao.impl.QAEventsDaoImpl;
import org.dspace.qaevent.service.QAEventActionService;
import org.dspace.qaevent.service.QAEventService;
import org.dspace.services.ConfigurationService;
import org.dspace.services.factory.DSpaceServicesFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
/**
* Implementation of {@link QAEventService} that use Solr to store events. When
@@ -73,6 +78,13 @@ public class QAEventServiceImpl implements QAEventService {
@Autowired
private QAEventsDaoImpl qaEventsDao;
@Autowired
@Qualifier("qaAutomaticProcessingMap")
private Map<String, QAEventAutomaticProcessingEvaluation> qaAutomaticProcessingMap;
@Autowired
private QAEventActionService qaEventActionService;
private ObjectMapper jsonMapper;
public QAEventServiceImpl() {
@@ -321,12 +333,43 @@ public class QAEventServiceImpl implements QAEventService {
updateRequest.process(getSolr());
getSolr().commit();
performAutomaticProcessingIfNeeded(context, dto);
}
} catch (Exception e) {
throw new RuntimeException(e);
}
}
private void performAutomaticProcessingIfNeeded(Context context, QAEvent qaEvent) {
QAEventAutomaticProcessingEvaluation evaluation = qaAutomaticProcessingMap.get(qaEvent.getSource());
if (evaluation == null) {
return;
}
AutomaticProcessingAction action = evaluation.evaluateAutomaticProcessing(context, qaEvent);
if (action == null) {
return;
}
switch (action) {
case REJECT:
qaEventActionService.reject(context, qaEvent);
break;
case IGNORE:
qaEventActionService.discard(context, qaEvent);
break;
case ACCEPT:
qaEventActionService.accept(context, qaEvent);
break;
default:
throw new IllegalStateException("Unknown automatic action requested " + action);
}
}
@Override
public QAEvent findEventByEventId(String eventId) {
SolrQuery param = new SolrQuery(EVENT_ID + ":" + eventId);

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:context="http://www.springframework.org/schema/context"
xmlns:util="http://www.springframework.org/schema/util"
xsi:schemaLocation="http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans-2.5.xsd
http://www.springframework.org/schema/context
http://www.springframework.org/schema/context/spring-context-2.5.xsd
http://www.springframework.org/schema/util
http://www.springframework.org/schema/util/spring-util.xsd">
<context:annotation-config /> <!-- allows us to use spring annotations in beans -->
<!-- this file contains extra beans related to the qaevent feature configured only for test purpose -->
<util:map id="qaAutomaticProcessingMap">
<entry key="coar-notify" value-ref="qaScoreEvaluation"/>
</util:map>
<bean id="qaScoreEvaluation" class="org.dspace.qaevent.QAScoreAutomaticProcessingEvaluation">
<property name="scoreToReject" value="0.3" />
<property name="scoreToIgnore" value="0.5" />
<property name="scoreToApprove" value="0.8" />
<property name="itemFilterToReject" ref="simple-demo_filter" />
<property name="itemFilterToIgnore" ref="simple-demo_filter" />
<property name="itemFilterToApprove" ref="simple-demo_filter" />
</bean>
</beans>

View File

@@ -10,6 +10,7 @@ package org.dspace.app.rest;
import static com.jayway.jsonpath.matchers.JsonPathMatchers.hasJsonPath;
import static com.jayway.jsonpath.matchers.JsonPathMatchers.hasNoJsonPath;
import static org.dspace.app.rest.matcher.QAEventMatcher.matchQAEventEntry;
import static org.dspace.content.QAEvent.COAR_NOTIFY;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.empty;
import static org.hamcrest.Matchers.hasSize;
@@ -831,4 +832,140 @@ public class QAEventRestRepositoryIT extends AbstractControllerIntegrationTest {
assertThat(processedEvent.getEperson().getID(), is(admin.getID()));
}
@Test
public void createQAEventsAndAcceptAutomaticallyByScoreAndFilterTest() throws Exception {
context.turnOffAuthorisationSystem();
parentCommunity = CommunityBuilder.createCommunity(context).withName("Parent Community").build();
Collection col1 = CollectionBuilder.createCollection(context, parentCommunity).withName("Collection 1").build();
Item item = ItemBuilder.createItem(context, col1).withTitle("demo").build();
QAEvent event =
QAEventBuilder.createTarget(context, item)
.withSource(COAR_NOTIFY)
.withTrust(0.8)
.withTopic("ENRICH/MORE/REVIEW")
.withMessage("{\"abstracts[0]\": \"https://doi.org/10.3214/987654\"}")
.build();
context.restoreAuthSystemState();
String authToken = getAuthToken(admin.getEmail(), password);
getClient(authToken).perform(get("/api/core/items/" + item.getID()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.metadata['datacite.relation.isReviewedBy'][0].value",
is("https://doi.org/10.3214/987654")));
getClient(authToken).perform(get("/api/integration/qualityassuranceevents/" + event.getEventId()))
.andExpect(status().isNotFound());
}
@Test
public void createQAEventsAndIgnoreAutomaticallyByScoreAndFilterTest() throws Exception {
context.turnOffAuthorisationSystem();
parentCommunity = CommunityBuilder.createCommunity(context).withName("Parent Community").build();
Collection col1 = CollectionBuilder.createCollection(context, parentCommunity).withName("Collection 1").build();
Item item = ItemBuilder.createItem(context, col1).withTitle("demo").build();
QAEvent event =
QAEventBuilder.createTarget(context, item)
.withSource(COAR_NOTIFY)
.withTrust(0.4)
.withTopic("ENRICH/MORE/REVIEW")
.withMessage("{\"abstracts[0]\": \"https://doi.org/10.3214/987654\"}")
.build();
context.restoreAuthSystemState();
String authToken = getAuthToken(admin.getEmail(), password);
getClient(authToken).perform(get("/api/core/items/" + item.getID()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.metadata['datacite.relation.isReviewedBy']").doesNotExist());
getClient(authToken).perform(get("/api/integration/qualityassuranceevents/" + event.getEventId()))
.andExpect(status().isNotFound());
}
@Test
public void createQAEventsAndRejectAutomaticallyByScoreAndFilterTest() throws Exception {
context.turnOffAuthorisationSystem();
parentCommunity = CommunityBuilder.createCommunity(context).withName("Parent Community").build();
Collection col1 = CollectionBuilder.createCollection(context, parentCommunity).withName("Collection 1").build();
Item item = ItemBuilder.createItem(context, col1).withTitle("demo").build();
QAEvent event =
QAEventBuilder.createTarget(context, item)
.withSource(COAR_NOTIFY)
.withTrust(0.3)
.withTopic("ENRICH/MORE/REVIEW")
.withMessage("{\"abstracts[0]\": \"https://doi.org/10.3214/987654\"}")
.build();
context.restoreAuthSystemState();
String authToken = getAuthToken(admin.getEmail(), password);
getClient(authToken).perform(get("/api/core/items/" + item.getID()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.metadata['datacite.relation.isReviewedBy']").doesNotExist());
getClient(authToken).perform(get("/api/integration/qualityassuranceevents/" + event.getEventId()))
.andExpect(status().isNotFound());
}
@Test
public void createQAEventsAndDoNothingScoreNotInRangTest() throws Exception {
context.turnOffAuthorisationSystem();
parentCommunity = CommunityBuilder.createCommunity(context).withName("Parent Community").build();
Collection col1 = CollectionBuilder.createCollection(context, parentCommunity).withName("Collection 1").build();
Item item = ItemBuilder.createItem(context, col1).withTitle("demo").build();
QAEvent event =
QAEventBuilder.createTarget(context, item)
.withSource(COAR_NOTIFY)
.withTrust(0.7)
.withTopic("ENRICH/MORE/REVIEW")
.withMessage("{\"abstracts[0]\": \"https://doi.org/10.3214/987654\"}")
.build();
context.restoreAuthSystemState();
String authToken = getAuthToken(admin.getEmail(), password);
getClient(authToken).perform(get("/api/core/items/" + item.getID()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.metadata['datacite.relation.isReviewedBy']").doesNotExist());
getClient(authToken).perform(get("/api/integration/qualityassuranceevents/" + event.getEventId())
.param("projection", "full"))
.andExpect(status().isOk())
.andExpect(jsonPath("$", QAEventMatcher.matchQAEventFullEntry(event)));
}
@Test
public void createQAEventsAndDoNothingFilterNotCompatibleWithItemTest() throws Exception {
context.turnOffAuthorisationSystem();
parentCommunity = CommunityBuilder.createCommunity(context).withName("Parent Community").build();
Collection col1 = CollectionBuilder.createCollection(context, parentCommunity).withName("Collection 1").build();
Item item = ItemBuilder.createItem(context, col1).withTitle("item title").build();
QAEvent event =
QAEventBuilder.createTarget(context, item)
.withSource(COAR_NOTIFY)
.withTrust(0.8)
.withTopic("ENRICH/MORE/REVIEW")
.withMessage("{\"abstracts[0]\": \"https://doi.org/10.3214/987654\"}")
.build();
context.restoreAuthSystemState();
String authToken = getAuthToken(admin.getEmail(), password);
getClient(authToken).perform(get("/api/core/items/" + item.getID()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.metadata['datacite.relation.isReviewedBy']").doesNotExist());
getClient(authToken).perform(get("/api/integration/qualityassuranceevents/" + event.getEventId())
.param("projection", "full"))
.andExpect(status().isOk())
.andExpect(jsonPath("$", QAEventMatcher.matchQAEventFullEntry(event)));
}
}

View File

@@ -928,6 +928,7 @@ registry.metadata.load = schema-publicationVolume-types.xml
registry.metadata.load = openaire4-types.xml
registry.metadata.load = dspace-types.xml
registry.metadata.load = iiif-types.xml
registry.metadata.load = datacite-types.xml
#---------------------------------------------------------------#

View File

@@ -2,10 +2,13 @@
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:context="http://www.springframework.org/schema/context"
xmlns:util="http://www.springframework.org/schema/util"
xsi:schemaLocation="http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans-2.5.xsd
http://www.springframework.org/schema/context
http://www.springframework.org/schema/context/spring-context-2.5.xsd">
http://www.springframework.org/schema/context/spring-context-2.5.xsd
http://www.springframework.org/schema/util
http://www.springframework.org/schema/util/spring-util.xsd">
<context:annotation-config /> <!-- allows us to use spring annotations in beans -->
@@ -73,4 +76,24 @@
</property>
</bean>
<!--
To configure rules to automatic process specific qaevent you must provide a qaAutomaticProcessingMap
where the keys are the qaevent source provider name and the value is a reference to a
AutomaticProcessingEvaluation implementation. Below you can find an example of configuration defining
some thresholds rules for the coar-notify generated QAEvent to be approved, rejected and ignored
-->
<!--
<util:map id="qaAutomaticProcessingMap">
<entry key="coar-notify" value-ref="qaScoreEvaluation"/>
</util:map>
<bean id="qaScoreEvaluation" class="org.dspace.qaevent.QAScoreAutomaticProcessingEvaluation">
<property name="scoreToReject" value="0.3" />
<property name="scoreToIgnore" value="0.5" />
<property name="scoreToApprove" value="0.8" />
<property name="itemFilterToReject" ref="simple-demo_filter" />
<property name="itemFilterToIgnore" ref="simple-demo_filter" />
<property name="itemFilterToApprove" ref="simple-demo_filter" />
</bean>
-->
</beans>