Add a large-context group

allowing users to access larger context by request [AI-assisted, #745]

The group is automatically generated at initialization, if it doesn't
exist. The size of the context may be larger than the usual max token
context for other users. It is configurable with
"max.token.context.size.large" property in the kustvakt.config file.

Change-Id: I83986a858a3646f7061277cdbba1f4327ebdecfe
diff --git a/src/main/java/de/ids_mannheim/korap/config/KustvaktConfiguration.java b/src/main/java/de/ids_mannheim/korap/config/KustvaktConfiguration.java
index b671c1a..7ce4845 100644
--- a/src/main/java/de/ids_mannheim/korap/config/KustvaktConfiguration.java
+++ b/src/main/java/de/ids_mannheim/korap/config/KustvaktConfiguration.java
@@ -58,9 +58,14 @@
 
     private String serverHost;
 
+	// The maximum number of tokens allowed in the context of a match result. 
+    // The context will be cut in QueryContextRewrite, if it exceeds 
+    // this number. A value of 0 means no limit.
     private int maxTokenContext;
 //    private int maxTokenMatch; // EM: Not implemented yet
 
+    private int maxTokenContextLarge;
+    
     private int maxhits;
     private int returnhits;
     private String keystoreLocation;
@@ -241,6 +246,9 @@
         maxTokenContext = Integer.parseInt(properties.getProperty(
                 "max.token.context.size", "0"));
         
+        maxTokenContextLarge = Integer.parseInt(properties.getProperty(
+                "max.token.context.size.large", "0"));
+        
         // Timeout validity in milis
         guestTimeout = Integer.parseInt(properties.getProperty(
                 "timeout.guest", "10000"));
diff --git a/src/main/java/de/ids_mannheim/korap/init/Initializator.java b/src/main/java/de/ids_mannheim/korap/init/Initializator.java
index e13a75b..353cc09 100644
--- a/src/main/java/de/ids_mannheim/korap/init/Initializator.java
+++ b/src/main/java/de/ids_mannheim/korap/init/Initializator.java
@@ -1,6 +1,7 @@
 package de.ids_mannheim.korap.init;
 
 import java.io.IOException;
+import java.sql.SQLException;
 import java.util.EnumSet;
 
 import org.springframework.beans.factory.annotation.Autowired;
@@ -12,11 +13,15 @@
 import de.ids_mannheim.korap.constant.OAuth2Scope;
 import de.ids_mannheim.korap.constant.QueryType;
 import de.ids_mannheim.korap.constant.ResourceType;
+import de.ids_mannheim.korap.constant.UserGroupStatus;
 import de.ids_mannheim.korap.dao.AdminDao;
+import de.ids_mannheim.korap.dao.UserGroupDao;
 import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.exceptions.StatusCodes;
 import de.ids_mannheim.korap.oauth2.dao.AccessScopeDao;
 import de.ids_mannheim.korap.oauth2.service.OAuth2InitClientService;
 import de.ids_mannheim.korap.service.QueryServiceImpl;
+import de.ids_mannheim.korap.service.UserGroupService;
 import de.ids_mannheim.korap.user.KorAPUser;
 import de.ids_mannheim.korap.util.QueryException;
 import de.ids_mannheim.korap.web.input.QueryJson;
@@ -44,6 +49,10 @@
     private OAuth2InitClientService clientService;
     @Autowired
     private QueryServiceImpl queryService;
+    @Autowired
+    private UserGroupService userGroupService;
+    @Autowired
+    private UserGroupDao userGroupDao;
     
     private double apiVersion = 1.1;
 
@@ -59,7 +68,8 @@
             clientService.createInitialSuperClient(
                     OAuth2InitClientService.OUTPUT_FILENAME);
         }
-
+        createLargeContextGroup ();
+        
         vcLoader.apiVersion = apiVersion;
         Thread t = new Thread(vcLoader);
         t.start();
@@ -85,6 +95,48 @@
 		q.setQueryType(QueryType.QUERY);
 		queryService.handlePutRequest("system", "system", "system-q", q, 
 				apiVersion);
+
+		createLargeContextGroup ();
+	}
+	
+	private void createLargeContextGroup () throws KustvaktException {
+		String groupName = "LargeContextGroup";
+		String groupAdmin = "korap_admin";
+		String description = "Users allowed to access search results with "
+				+ "larger contexts";
+        boolean groupExists = false;
+        try {
+        	userGroupService.retrieveUserGroupByName(groupName);
+            groupExists = true;
+        }
+        catch (KustvaktException e) {
+            if (e.getStatusCode() != StatusCodes.NO_RESOURCE_FOUND) {
+                throw e;
+            }
+        }
+
+        if (!groupExists) {
+            try {
+                userGroupDao.createGroup(groupName, description, groupAdmin,
+                        UserGroupStatus.ACTIVE);
+                userGroupService.retrieveUserGroupByName(groupName);
+            }
+            // handle DB exceptions, e.g. unique constraint
+            catch (Exception e) {
+                Throwable cause = e;
+                Throwable lastCause = null;
+                while ((cause = cause.getCause()) != null
+                        && !cause.equals(lastCause)) {
+                    if (cause instanceof SQLException) {
+                        break;
+                    }
+                    lastCause = cause;
+                }
+                throw new KustvaktException(StatusCodes.DB_INSERT_FAILED,
+                        cause.getMessage());
+            }
+        }
+
 	}
 
     public void initResourceTest () throws IOException, KustvaktException {
diff --git a/src/main/java/de/ids_mannheim/korap/rewrite/QueryContextRewrite.java b/src/main/java/de/ids_mannheim/korap/rewrite/QueryContextRewrite.java
index 6e7fa1c..9f5088a 100644
--- a/src/main/java/de/ids_mannheim/korap/rewrite/QueryContextRewrite.java
+++ b/src/main/java/de/ids_mannheim/korap/rewrite/QueryContextRewrite.java
@@ -7,38 +7,58 @@
 import com.fasterxml.jackson.databind.node.ArrayNode;
 
 import de.ids_mannheim.korap.config.KustvaktConfiguration;
+import de.ids_mannheim.korap.dao.UserGroupDao;
+import de.ids_mannheim.korap.entity.UserGroup;
 import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.service.UserGroupService;
 import de.ids_mannheim.korap.user.User;
 import de.ids_mannheim.korap.utils.JsonUtils;
 
 @Component
 public class QueryContextRewrite implements RewriteTask.RewriteQuery {
 
+    private static final String LARGE_CONTEXT_GROUP = "LargeContextGroup";
+
     @Autowired
-    private KustvaktConfiguration config;
+    private UserGroupService userGroupService;
+
+    @Autowired
+    private UserGroupDao userGroupDao;
 
     @Override
     public KoralNode rewriteQuery (KoralNode node, KustvaktConfiguration config,
             User user, double apiVersion) throws KustvaktException {
-        
-        if (config.getMaxTokenContext() > 0) {
+
+        int maxContext = isInLargeContextGroup(user)
+                ? config.getMaxTokenContextLarge()
+                : config.getMaxTokenContext();
+        if (maxContext > 0) {
             boolean isContextCut = false;
             KoralNode context = node.at("/meta/context");
-            isContextCut = cutContext(context, "left");
-            isContextCut = cutContext(context, "right") || isContextCut;
+            isContextCut = cutContext(context, "left", maxContext);
+            isContextCut = cutContext(context, "right", maxContext) || isContextCut;
             if (isContextCut) context.buildRewrites();
         }
         return node;
     }
+
+    private boolean isInLargeContextGroup (User user)
+            throws KustvaktException {
+        if (user == null) return false;
+        UserGroup group = userGroupDao.retrieveGroupByName(LARGE_CONTEXT_GROUP,
+                false);
+        if (group == null) return false;
+        return userGroupService.isMember(user.getUsername(), group);
+    }
     
-    private boolean cutContext (KoralNode context, String position) 
+    private boolean cutContext (KoralNode context, String position,
+            int maxContextLength)
             throws KustvaktException {
         KoralNode contextPosition = context.at("/" + position);
         String type = contextPosition.at("/0").asText();
 
         if (type.equals("token")) {
             int length = contextPosition.at("/1").asInt();
-            int maxContextLength = config.getMaxTokenContext();
             if (length > maxContextLength) {
                 JsonNode sourceNode = JsonUtils
                         .readTree(contextPosition.toString());
diff --git a/src/main/resources/kustvakt.conf b/src/main/resources/kustvakt.conf
index 2c051ae..8bd0ff9 100644
--- a/src/main/resources/kustvakt.conf
+++ b/src/main/resources/kustvakt.conf
@@ -50,6 +50,7 @@
 # Virtual corpus and queries
 max.user.persistent.queries = 5
 # max.token.context.size = 40
+max.token.context.size.large = 50
 vc.list.statistics.enabled = false
 
 # Availability regex only support |
diff --git a/src/test/java/de/ids_mannheim/korap/dao/LargeContextGroupTest.java b/src/test/java/de/ids_mannheim/korap/dao/LargeContextGroupTest.java
new file mode 100644
index 0000000..e185cbd
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/dao/LargeContextGroupTest.java
@@ -0,0 +1,37 @@
+package de.ids_mannheim.korap.dao;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.springframework.test.context.ContextConfiguration;
+import org.springframework.test.context.junit.jupiter.SpringExtension;
+
+import de.ids_mannheim.korap.constant.UserGroupStatus;
+import de.ids_mannheim.korap.entity.UserGroup;
+import de.ids_mannheim.korap.exceptions.KustvaktException;
+
+/**
+ * Tests that the LargeContextGroup is correctly created during
+ * initialization (see {@link de.ids_mannheim.korap.init.Initializator#initTest()}).
+ *
+ * @author auto-generated
+ */
+@ExtendWith(SpringExtension.class)
+@ContextConfiguration("classpath:test-config.xml")
+public class LargeContextGroupTest extends DaoTestBase {
+
+    private static final String LARGE_CONTEXT_GROUP_NAME = "LargeContextGroup";
+    private static final String LARGE_CONTEXT_GROUP_ADMIN = "korap_admin";
+
+    @Test
+    public void testLargeContextGroupExists () throws KustvaktException {
+        UserGroup group = userGroupDao.retrieveGroupByName(
+                LARGE_CONTEXT_GROUP_NAME, false);
+        assertNotNull(group, "LargeContextGroup should exist after initialization");
+        assertEquals(LARGE_CONTEXT_GROUP_NAME, group.getName());
+        assertEquals(LARGE_CONTEXT_GROUP_ADMIN, group.getCreatedBy());
+        assertEquals(UserGroupStatus.ACTIVE, group.getStatus());
+    }
+}
\ No newline at end of file
diff --git a/src/test/java/de/ids_mannheim/korap/rewrite/QueryContextRewriteTest.java b/src/test/java/de/ids_mannheim/korap/rewrite/QueryContextRewriteTest.java
index 5a596c2..f4f47a6 100644
--- a/src/test/java/de/ids_mannheim/korap/rewrite/QueryContextRewriteTest.java
+++ b/src/test/java/de/ids_mannheim/korap/rewrite/QueryContextRewriteTest.java
@@ -7,6 +7,8 @@
 
 import com.fasterxml.jackson.databind.JsonNode;
 
+import de.ids_mannheim.korap.authentication.http.HttpAuthorizationHandler;
+import de.ids_mannheim.korap.config.Attributes;
 import de.ids_mannheim.korap.config.KustvaktConfiguration;
 import de.ids_mannheim.korap.config.SpringJerseyTest;
 import de.ids_mannheim.korap.exceptions.KustvaktException;
@@ -14,7 +16,10 @@
 import de.ids_mannheim.korap.query.serialize.QuerySerializer;
 import de.ids_mannheim.korap.user.KorAPUser;
 import de.ids_mannheim.korap.utils.JsonUtils;
+import jakarta.ws.rs.client.Entity;
+import jakarta.ws.rs.core.Form;
 import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.Response.Status;
 
 public class QueryContextRewriteTest extends SpringJerseyTest {
     
@@ -24,6 +29,10 @@
     @Autowired
     private KustvaktConfiguration config;
 
+    private static final String LARGE_CONTEXT_GROUP = "LargeContextGroup";
+    private static final String LARGE_CONTEXT_GROUP_ADMIN = "korap_admin";
+    private static final String TEST_USER = "largeContextTestUser";
+
     @Test
     public void testCutTokenContext () throws KustvaktException, Exception {
         Response response = target().path(API_VERSION).path("search")
@@ -44,6 +53,62 @@
         assertEquals(config.getMaxTokenContext(), context.at("/left/1").asInt());
         assertEquals(config.getMaxTokenContext(), context.at("/right/1").asInt());
     }
+    
+    /** AI generated
+     * @throws KustvaktException
+     */
+    @Test
+    public void testCutTokenContextLarge ()
+            throws KustvaktException {
+        // add test user to LargeContextGroup via web service
+        Form form = new Form();
+        form.param("members", TEST_USER);
+        Response addResponse = target().path(API_VERSION).path("group")
+                .path("@" + LARGE_CONTEXT_GROUP).path("member").request()
+                .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                        .createBasicAuthorizationHeaderValue(
+                                LARGE_CONTEXT_GROUP_ADMIN, "pass"))
+                .put(Entity.form(form));
+        assertEquals(Status.OK.getStatusCode(), addResponse.getStatus());
+
+        try {
+            Response searchResponse = target().path(API_VERSION).path("search")
+                    .queryParam("q", "Sonne")
+                    .queryParam("ql", "poliqarp")
+                    .queryParam("context", "60-token,60-token")
+                    .request()
+                    .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                            .createBasicAuthorizationHeaderValue(
+                                    TEST_USER, "pass"))
+                    .get();
+            String ent = searchResponse.readEntity(String.class);
+            JsonNode node = JsonUtils.readTree(ent);
+
+            JsonNode context = node.at("/meta/context");
+            assertEquals(config.getMaxTokenContextLarge(),
+                    context.at("/left/1").asInt());
+            assertEquals(config.getMaxTokenContextLarge(),
+                    context.at("/right/1").asInt());
+
+            // match context
+            context = node.at("/matches/0/context");
+            assertEquals(config.getMaxTokenContextLarge(),
+                    context.at("/left/1").asInt());
+            assertEquals(config.getMaxTokenContextLarge(),
+                    context.at("/right/1").asInt());
+        }
+        finally {
+            // remove test user from group via web service
+            Response deleteResponse = target().path(API_VERSION).path("group")
+                    .path("@" + LARGE_CONTEXT_GROUP)
+                    .path("~" + TEST_USER).request()
+                    .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
+                            .createBasicAuthorizationHeaderValue(
+                                    LARGE_CONTEXT_GROUP_ADMIN, "pass"))
+                    .delete();
+            assertEquals(Status.OK.getStatusCode(), deleteResponse.getStatus());
+        }
+    }
 
     @Test
     public void testMetaRewrite () throws KustvaktException {
@@ -79,6 +144,7 @@
         assertEquals("right", context.at("/rewrites/1/scope").asText());
         assertEquals("token", context.at("/rewrites/1/original/0").asText());
         assertEquals(60, context.at("/rewrites/1/original/1").asInt());
-        
     }
+
+    
 }
diff --git a/src/test/java/de/ids_mannheim/korap/web/controller/usergroup/UserGroupControllerAdminTest.java b/src/test/java/de/ids_mannheim/korap/web/controller/usergroup/UserGroupControllerAdminTest.java
index 7982294..cd1ce37 100644
--- a/src/test/java/de/ids_mannheim/korap/web/controller/usergroup/UserGroupControllerAdminTest.java
+++ b/src/test/java/de/ids_mannheim/korap/web/controller/usergroup/UserGroupControllerAdminTest.java
@@ -164,8 +164,8 @@
         assertEquals(Status.OK.getStatusCode(), response.getStatus());
         String entity = response.readEntity(String.class);
         JsonNode node = JsonUtils.readTree(entity);
-        assertEquals(2, node.size());
-        assertEquals("HIDDEN", node.get(0).at("/status").asText());
+        assertEquals(3, node.size());
+        assertEquals("HIDDEN", node.get(1).at("/status").asText());
 
         deleteGroupByName(doryGroupName, "dory");
     }
diff --git a/src/test/resources/kustvakt-test.conf b/src/test/resources/kustvakt-test.conf
index bb8997d..9cd1a9e 100644
--- a/src/test/resources/kustvakt-test.conf
+++ b/src/test/resources/kustvakt-test.conf
@@ -49,6 +49,8 @@
 # Virtual corpus and queries
 max.user.persistent.queries = 5
 max.token.context.size = 40
+max.token.context.size.large = 50
+
 # default false
 vc.list.statistics.enabled = true