Add koral:rewrite to query and corpus transformations

Change-Id: I97e3050d39b936256616bdf46203a784de6a3414
diff --git a/cmd/koralmapper/main.go b/cmd/koralmapper/main.go
index d8e51a6..75a1d9c 100644
--- a/cmd/koralmapper/main.go
+++ b/cmd/koralmapper/main.go
@@ -77,6 +77,7 @@
 	FoundryB string
 	LayerA   string
 	LayerB   string
+	Rewrites *bool // nil = use mapping list default; non-nil = override
 }
 
 // MappingSectionData contains per-section UI metadata so request and response
@@ -187,6 +188,11 @@
 		LayerB:   c.Query("layerB", ""),
 	}
 
+	if rewrites := c.Query("rewrites", ""); rewrites != "" {
+		v := rewrites == "true"
+		params.Rewrites = &v
+	}
+
 	// Validate input parameters
 	if err := validateInput(params.MapID, params.Dir, params.FoundryA, params.FoundryB, params.LayerA, params.LayerB, c.Body()); err != nil {
 		return nil, err
@@ -317,10 +323,10 @@
 	app.Post("/response/:cfg", handleCompositeResponseTransform(m, yamlConfig.Lists))
 
 	// Transformation endpoint
-	app.Post("/:map/query", handleTransform(m))
+	app.Post("/:map/query", handleTransform(m, yamlConfig.Lists))
 
 	// Response transformation endpoint
-	app.Post("/:map/response", handleResponseTransform(m))
+	app.Post("/:map/response", handleResponseTransform(m, yamlConfig.Lists))
 
 	// Kalamar plugin endpoint
 	app.Get("/", handleKalamarPlugin(yamlConfig, configTmpl, pluginTmpl))
@@ -405,6 +411,11 @@
 }
 
 func handleCompositeQueryTransform(m *mapper.Mapper, lists []config.MappingList) fiber.Handler {
+	listsByID := make(map[string]*config.MappingList, len(lists))
+	for i := range lists {
+		listsByID[lists[i].ID] = &lists[i]
+	}
+
 	return func(c *fiber.Ctx) error {
 		cfgRaw := c.Params("cfg")
 		if len(cfgRaw) > maxParamLength {
@@ -431,6 +442,13 @@
 			return c.JSON(jsonData)
 		}
 
+		rewrites := c.Query("rewrites", "")
+		var rewritesOverride *bool
+		if rewrites != "" {
+			v := rewrites == "true"
+			rewritesOverride = &v
+		}
+
 		orderedIDs := make([]string, 0, len(entries))
 		opts := make([]mapper.MappingOptions, 0, len(entries))
 		for _, entry := range entries {
@@ -439,15 +457,24 @@
 				dir = mapper.BtoA
 			}
 
+			addRewrites := false
+			if list, ok := listsByID[entry.ID]; ok {
+				addRewrites = list.Rewrites
+			}
+			if rewritesOverride != nil {
+				addRewrites = *rewritesOverride
+			}
+
 			orderedIDs = append(orderedIDs, entry.ID)
 			opts = append(opts, mapper.MappingOptions{
-				Direction: dir,
-				FoundryA:  entry.FoundryA,
-				LayerA:    entry.LayerA,
-				FoundryB:  entry.FoundryB,
-				LayerB:    entry.LayerB,
-				FieldA:    entry.FieldA,
-				FieldB:    entry.FieldB,
+				Direction:   dir,
+				FoundryA:    entry.FoundryA,
+				LayerA:      entry.LayerA,
+				FoundryB:    entry.FoundryB,
+				LayerB:      entry.LayerB,
+				FieldA:      entry.FieldA,
+				FieldB:      entry.FieldB,
+				AddRewrites: addRewrites,
 			})
 		}
 
@@ -464,6 +491,11 @@
 }
 
 func handleCompositeResponseTransform(m *mapper.Mapper, lists []config.MappingList) fiber.Handler {
+	listsByID := make(map[string]*config.MappingList, len(lists))
+	for i := range lists {
+		listsByID[lists[i].ID] = &lists[i]
+	}
+
 	return func(c *fiber.Ctx) error {
 		cfgRaw := c.Params("cfg")
 		if len(cfgRaw) > maxParamLength {
@@ -490,6 +522,13 @@
 			return c.JSON(jsonData)
 		}
 
+		rewrites := c.Query("rewrites", "")
+		var rewritesOverride *bool
+		if rewrites != "" {
+			v := rewrites == "true"
+			rewritesOverride = &v
+		}
+
 		orderedIDs := make([]string, 0, len(entries))
 		opts := make([]mapper.MappingOptions, 0, len(entries))
 		for _, entry := range entries {
@@ -498,15 +537,24 @@
 				dir = mapper.BtoA
 			}
 
+			addRewrites := false
+			if list, ok := listsByID[entry.ID]; ok {
+				addRewrites = list.Rewrites
+			}
+			if rewritesOverride != nil {
+				addRewrites = *rewritesOverride
+			}
+
 			orderedIDs = append(orderedIDs, entry.ID)
 			opts = append(opts, mapper.MappingOptions{
-				Direction: dir,
-				FoundryA:  entry.FoundryA,
-				LayerA:    entry.LayerA,
-				FoundryB:  entry.FoundryB,
-				LayerB:    entry.LayerB,
-				FieldA:    entry.FieldA,
-				FieldB:    entry.FieldB,
+				Direction:   dir,
+				FoundryA:    entry.FoundryA,
+				LayerA:      entry.LayerA,
+				FoundryB:    entry.FoundryB,
+				LayerB:      entry.LayerB,
+				FieldA:      entry.FieldA,
+				FieldB:      entry.FieldB,
+				AddRewrites: addRewrites,
 			})
 		}
 
@@ -522,7 +570,12 @@
 	}
 }
 
-func handleTransform(m *mapper.Mapper) fiber.Handler {
+func handleTransform(m *mapper.Mapper, lists []config.MappingList) fiber.Handler {
+	listsByID := make(map[string]*config.MappingList, len(lists))
+	for i := range lists {
+		listsByID[lists[i].ID] = &lists[i]
+	}
+
 	return func(c *fiber.Ctx) error {
 		// Extract and validate parameters
 		params, err := extractRequestParams(c)
@@ -540,13 +593,23 @@
 			})
 		}
 
+		// Determine rewrites: query param overrides YAML default
+		addRewrites := false
+		if list, ok := listsByID[params.MapID]; ok {
+			addRewrites = list.Rewrites
+		}
+		if params.Rewrites != nil {
+			addRewrites = *params.Rewrites
+		}
+
 		// Apply mappings
 		result, err := m.ApplyQueryMappings(params.MapID, mapper.MappingOptions{
-			Direction: direction,
-			FoundryA:  params.FoundryA,
-			FoundryB:  params.FoundryB,
-			LayerA:    params.LayerA,
-			LayerB:    params.LayerB,
+			Direction:   direction,
+			FoundryA:    params.FoundryA,
+			FoundryB:    params.FoundryB,
+			LayerA:      params.LayerA,
+			LayerB:      params.LayerB,
+			AddRewrites: addRewrites,
 		}, jsonData)
 
 		if err != nil {
@@ -564,7 +627,12 @@
 	}
 }
 
-func handleResponseTransform(m *mapper.Mapper) fiber.Handler {
+func handleResponseTransform(m *mapper.Mapper, lists []config.MappingList) fiber.Handler {
+	listsByID := make(map[string]*config.MappingList, len(lists))
+	for i := range lists {
+		listsByID[lists[i].ID] = &lists[i]
+	}
+
 	return func(c *fiber.Ctx) error {
 		// Extract and validate parameters
 		params, err := extractRequestParams(c)
@@ -582,13 +650,23 @@
 			})
 		}
 
+		// Determine rewrites: query param overrides YAML default
+		addRewrites := false
+		if list, ok := listsByID[params.MapID]; ok {
+			addRewrites = list.Rewrites
+		}
+		if params.Rewrites != nil {
+			addRewrites = *params.Rewrites
+		}
+
 		// Apply response mappings
 		result, err := m.ApplyResponseMappings(params.MapID, mapper.MappingOptions{
-			Direction: direction,
-			FoundryA:  params.FoundryA,
-			FoundryB:  params.FoundryB,
-			LayerA:    params.LayerA,
-			LayerB:    params.LayerB,
+			Direction:   direction,
+			FoundryA:    params.FoundryA,
+			FoundryB:    params.FoundryB,
+			LayerA:      params.LayerA,
+			LayerB:      params.LayerB,
+			AddRewrites: addRewrites,
 		}, jsonData)
 
 		if err != nil {
diff --git a/cmd/koralmapper/main_test.go b/cmd/koralmapper/main_test.go
index 64ea8be..a9bb530 100644
--- a/cmd/koralmapper/main_test.go
+++ b/cmd/koralmapper/main_test.go
@@ -59,6 +59,7 @@
     layerA: p
     foundryB: upos
     layerB: p
+    rewrites: true
     mappings:
       - "[PIDAT] <> [opennlp/p=PIDAT & opennlp/p=AdjType:Pdt]"
       - "[DET] <> [opennlp/p=DET]"
@@ -121,7 +122,20 @@
 							"value": "Pdt"
 						}
 					],
-					"relation": "relation:and"
+					"relation": "relation:and",
+					"rewrites": [
+						{
+							"@type": "koral:rewrite",
+							"editor": "Koral-Mapper",
+							"original": {
+								"@type": "koral:term",
+								"foundry": "opennlp",
+								"key": "PIDAT",
+								"layer": "p",
+								"match": "match:eq"
+							}
+						}
+					]
 				}
 			}`,
 		},
@@ -161,7 +175,34 @@
 					"foundry": "opennlp",
 					"key": "PIDAT",
 					"layer": "p",
-					"match": "match:eq"
+					"match": "match:eq",
+					"rewrites": [
+						{
+							"@type": "koral:rewrite",
+							"editor": "Koral-Mapper",
+							"original": {
+								"@type": "koral:termGroup",
+								"operands": [
+									{
+										"@type": "koral:term",
+										"foundry": "opennlp",
+										"key": "PIDAT",
+										"layer": "p",
+										"match": "match:eq"
+									},
+									{
+										"@type": "koral:term",
+										"foundry": "opennlp",
+										"key": "AdjType",
+										"layer": "p",
+										"match": "match:eq",
+										"value": "Pdt"
+									}
+								],
+								"relation": "relation:and"
+							}
+						}
+					]
 				}
 			}`,
 		},
@@ -202,7 +243,20 @@
 							"value": "Pdt"
 						}
 					],
-					"relation": "relation:and"
+					"relation": "relation:and",
+					"rewrites": [
+						{
+							"@type": "koral:rewrite",
+							"editor": "Koral-Mapper",
+							"original": {
+								"@type": "koral:term",
+								"foundry": "opennlp",
+								"key": "PIDAT",
+								"layer": "p",
+								"match": "match:eq"
+							}
+						}
+					]
 				}
 			}`,
 		},
@@ -1570,6 +1624,7 @@
     layerA: p
     foundryB: opennlp
     layerB: p
+    rewrites: true
     mappings:
       - "[PIDAT] <> [DET]"
   - id: step2
@@ -1577,6 +1632,7 @@
     layerA: p
     foundryB: upos
     layerB: p
+    rewrites: true
     mappings:
       - "[DET] <> [PRON]"
 `)
@@ -1615,6 +1671,14 @@
 					"key":     "PRON",
 					"layer":   "p",
 					"match":   "match:eq",
+					"rewrites": []any{
+						map[string]any{
+							"@type":    "koral:rewrite",
+							"editor":   "Koral-Mapper",
+							"scope":    "foundry",
+							"original": "opennlp",
+						},
+					},
 				},
 			},
 		},
@@ -2212,6 +2276,189 @@
 	assert.Contains(t, htmlContent, `type="button"`)
 }
 
+func TestAddRewritesDefaultOff(t *testing.T) {
+	cfg := loadConfigFromYAML(t, `
+lists:
+  - id: test-mapper
+    foundryA: opennlp
+    layerA: p
+    foundryB: upos
+    layerB: p
+    mappings:
+      - "[PIDAT] <> [DET]"
+`)
+	m, err := mapper.NewMapper(cfg.Lists)
+	require.NoError(t, err)
+
+	app := fiber.New()
+	setupRoutes(app, m, cfg)
+
+	input := `{
+		"@type": "koral:token",
+		"wrap": {
+			"@type": "koral:term",
+			"foundry": "opennlp",
+			"key": "PIDAT",
+			"layer": "p",
+			"match": "match:eq"
+		}
+	}`
+
+	req := httptest.NewRequest(http.MethodPost, "/test-mapper/query?dir=atob", bytes.NewBufferString(input))
+	req.Header.Set("Content-Type", "application/json")
+	resp, err := app.Test(req)
+	require.NoError(t, err)
+	defer resp.Body.Close()
+
+	assert.Equal(t, http.StatusOK, resp.StatusCode)
+
+	var result map[string]any
+	err = json.NewDecoder(resp.Body).Decode(&result)
+	require.NoError(t, err)
+
+	wrap := result["wrap"].(map[string]any)
+	assert.Equal(t, "DET", wrap["key"])
+	assert.Nil(t, wrap["rewrites"], "rewrites should not be present by default")
+}
+
+func TestAddRewritesEnabledViaYAML(t *testing.T) {
+	cfg := loadConfigFromYAML(t, `
+lists:
+  - id: test-mapper
+    foundryA: opennlp
+    layerA: p
+    foundryB: upos
+    layerB: p
+    rewrites: true
+    mappings:
+      - "[PIDAT] <> [DET]"
+`)
+	m, err := mapper.NewMapper(cfg.Lists)
+	require.NoError(t, err)
+
+	app := fiber.New()
+	setupRoutes(app, m, cfg)
+
+	input := `{
+		"@type": "koral:token",
+		"wrap": {
+			"@type": "koral:term",
+			"foundry": "opennlp",
+			"key": "PIDAT",
+			"layer": "p",
+			"match": "match:eq"
+		}
+	}`
+
+	req := httptest.NewRequest(http.MethodPost, "/test-mapper/query?dir=atob", bytes.NewBufferString(input))
+	req.Header.Set("Content-Type", "application/json")
+	resp, err := app.Test(req)
+	require.NoError(t, err)
+	defer resp.Body.Close()
+
+	assert.Equal(t, http.StatusOK, resp.StatusCode)
+
+	var result map[string]any
+	err = json.NewDecoder(resp.Body).Decode(&result)
+	require.NoError(t, err)
+
+	wrap := result["wrap"].(map[string]any)
+	assert.Equal(t, "DET", wrap["key"])
+	assert.NotNil(t, wrap["rewrites"], "rewrites should be present when rewrites are enabled in YAML")
+}
+
+func TestAddRewritesQueryParamOverridesYAML(t *testing.T) {
+	cfg := loadConfigFromYAML(t, `
+lists:
+  - id: test-mapper
+    foundryA: opennlp
+    layerA: p
+    foundryB: upos
+    layerB: p
+    rewrites: true
+    mappings:
+      - "[PIDAT] <> [DET]"
+`)
+	m, err := mapper.NewMapper(cfg.Lists)
+	require.NoError(t, err)
+
+	app := fiber.New()
+	setupRoutes(app, m, cfg)
+
+	input := `{
+		"@type": "koral:token",
+		"wrap": {
+			"@type": "koral:term",
+			"foundry": "opennlp",
+			"key": "PIDAT",
+			"layer": "p",
+			"match": "match:eq"
+		}
+	}`
+
+	// Override YAML rewrites=true with query param rewrites=false
+	req := httptest.NewRequest(http.MethodPost, "/test-mapper/query?dir=atob&rewrites=false", bytes.NewBufferString(input))
+	req.Header.Set("Content-Type", "application/json")
+	resp, err := app.Test(req)
+	require.NoError(t, err)
+	defer resp.Body.Close()
+
+	assert.Equal(t, http.StatusOK, resp.StatusCode)
+
+	var result map[string]any
+	err = json.NewDecoder(resp.Body).Decode(&result)
+	require.NoError(t, err)
+
+	wrap := result["wrap"].(map[string]any)
+	assert.Equal(t, "DET", wrap["key"])
+	assert.Nil(t, wrap["rewrites"], "rewrites should be suppressed by query param override")
+}
+
+func TestAddRewritesQueryParamEnablesWhenYAMLOff(t *testing.T) {
+	cfg := loadConfigFromYAML(t, `
+lists:
+  - id: test-mapper
+    foundryA: opennlp
+    layerA: p
+    foundryB: upos
+    layerB: p
+    mappings:
+      - "[PIDAT] <> [DET]"
+`)
+	m, err := mapper.NewMapper(cfg.Lists)
+	require.NoError(t, err)
+
+	app := fiber.New()
+	setupRoutes(app, m, cfg)
+
+	input := `{
+		"@type": "koral:token",
+		"wrap": {
+			"@type": "koral:term",
+			"foundry": "opennlp",
+			"key": "PIDAT",
+			"layer": "p",
+			"match": "match:eq"
+		}
+	}`
+
+	req := httptest.NewRequest(http.MethodPost, "/test-mapper/query?dir=atob&rewrites=true", bytes.NewBufferString(input))
+	req.Header.Set("Content-Type", "application/json")
+	resp, err := app.Test(req)
+	require.NoError(t, err)
+	defer resp.Body.Close()
+
+	assert.Equal(t, http.StatusOK, resp.StatusCode)
+
+	var result map[string]any
+	err = json.NewDecoder(resp.Body).Decode(&result)
+	require.NoError(t, err)
+
+	wrap := result["wrap"].(map[string]any)
+	assert.Equal(t, "DET", wrap["key"])
+	assert.NotNil(t, wrap["rewrites"], "rewrites should be present when enabled by query param")
+}
+
 func TestConfigPagePreservesOrderOfMappings(t *testing.T) {
 	lists := []tmconfig.MappingList{
 		{