feat: Auto-adjust chunk recall weights based on user feedback (#12689)

### What problem does this PR solve?

Implements automatic adjustment of knowledge base chunk recall weights
based on user feedback (upvotes/downvotes). When users upvote or
downvote a response, the system locates the corresponding knowledge
snippets and adjusts their recall weight to improve future retrieval
quality.

**Closes #12670**

**How it works:**
1. User upvotes/downvotes a response via `POST /thumbup`
2. System extracts chunk IDs from the conversation reference
3. For each referenced chunk:
   - Reads current `pagerank_fea` value from document store
   - Increments (+1) for upvote or decrements (-1) for downvote
   - Clamps weight to [0, 100] range
   - Updates chunk in ES/Infinity/OceanBase
4. Future retrievals score these chunks higher/lower based on
accumulated feedback

**Files changed:**
- `api/db/services/chunk_feedback_service.py` - New service for updating
chunk pagerank weights
- `api/apps/conversation_app.py` - Integrated feedback service into
thumbup endpoint
- `test/testcases/test_web_api/test_chunk_feedback/` - Unit tests

### Type of change

- [x] New Feature (non-breaking change which adds functionality)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Chat message feedback now updates per-chunk relevance weights
(feature-flag gated), with configurable weighting and atomic updates
across storage backends.

* **Bug Fixes**
* Stricter validation for message feedback inputs and more robust
handling of feedback transitions.

* **Tests**
* Expanded test coverage for chunk-feedback behavior, weighting
strategies, storage backends, and thumb-flip scenarios.

* **Chores**
  * CI workflow extended to run the new chunk-feedback web API tests.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: mkdev11 <YOUR_GITHUB_ID+MkDev11@users.noreply.github.com>
Co-authored-by: mkdev11 <MkDev11@users.noreply.github.com>
This commit is contained in:
MkDev11
2026-04-07 18:52:18 -07:00
committed by GitHub
parent 4a2a17c27a
commit cfee2bc9db
11 changed files with 1293 additions and 13 deletions

View File

@@ -346,10 +346,21 @@ def _load_chat_module(monkeypatch):
def query(**_kwargs):
return []
user_service_mod.UserService = type("UserService", (), {})
user_service_mod.TenantService = _StubTenantService
user_service_mod.UserTenantService = _StubUserTenantService
monkeypatch.setitem(sys.modules, "api.db.services.user_service", user_service_mod)
chunk_feedback_service_mod = ModuleType("api.db.services.chunk_feedback_service")
class _StubChunkFeedbackService:
@staticmethod
def apply_feedback(**_kwargs):
return {"success_count": 0, "fail_count": 0, "chunk_ids": []}
chunk_feedback_service_mod.ChunkFeedbackService = _StubChunkFeedbackService
monkeypatch.setitem(sys.modules, "api.db.services.chunk_feedback_service", chunk_feedback_service_mod)
api_utils_mod = ModuleType("api.utils.api_utils")
def _check_duplicate_ids(ids, label):

View File

@@ -0,0 +1,15 @@
#
# Copyright 2025 The InfiniFlow Authors. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

View File

@@ -0,0 +1,584 @@
#
# Copyright 2025 The InfiniFlow Authors. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
"""
Tests for ChunkFeedbackService - adjusting chunk weights based on user feedback.
Uses importlib to load chunk_feedback_service.py in isolation so that
test/testcases/test_web_api/common.py (a test-helper module) does not shadow
the project-level common/ package during collection.
"""
import importlib.util
import sys
from pathlib import Path
from types import ModuleType, SimpleNamespace
from unittest.mock import MagicMock
import pytest
pytestmark = pytest.mark.p2
_REPO_ROOT = Path(__file__).resolve().parents[4]
def _load_feedback_module(monkeypatch):
"""Load chunk_feedback_service.py with lightweight stubs for its deps."""
common_pkg = ModuleType("common")
common_pkg.__path__ = [str(_REPO_ROOT / "common")]
monkeypatch.setitem(sys.modules, "common", common_pkg)
constants_mod = ModuleType("common.constants")
constants_mod.PAGERANK_FLD = "pagerank_fea"
monkeypatch.setitem(sys.modules, "common.constants", constants_mod)
settings_mod = ModuleType("common.settings")
settings_mod.docStoreConn = MagicMock()
# Non-ES engines accept pagerank_fea=0; tests below override for elasticsearch/opensearch.
settings_mod.DOC_ENGINE = "infinity"
monkeypatch.setitem(sys.modules, "common.settings", settings_mod)
common_pkg.settings = settings_mod
rag_pkg = ModuleType("rag")
rag_pkg.__path__ = []
monkeypatch.setitem(sys.modules, "rag", rag_pkg)
rag_nlp_pkg = ModuleType("rag.nlp")
rag_nlp_pkg.__path__ = []
rag_nlp_pkg.search = SimpleNamespace(index_name=lambda tid: f"idx-{tid}")
monkeypatch.setitem(sys.modules, "rag.nlp", rag_nlp_pkg)
rag_nlp_search_mod = ModuleType("rag.nlp.search")
rag_nlp_search_mod.index_name = lambda tid: f"idx-{tid}"
monkeypatch.setitem(sys.modules, "rag.nlp.search", rag_nlp_search_mod)
services_pkg = ModuleType("api.db.services")
services_pkg.__path__ = []
monkeypatch.setitem(sys.modules, "api.db.services", services_pkg)
spec = importlib.util.spec_from_file_location(
"api.db.services.chunk_feedback_service",
_REPO_ROOT / "api" / "db" / "services" / "chunk_feedback_service.py",
)
mod = importlib.util.module_from_spec(spec)
monkeypatch.setitem(
sys.modules, "api.db.services.chunk_feedback_service", mod
)
spec.loader.exec_module(mod)
return mod, settings_mod
@pytest.fixture
def feedback_env(monkeypatch):
"""Provide (module, settings_stub) for chunk feedback tests."""
return _load_feedback_module(monkeypatch)
class TestFeedbackRowsFromReference:
"""Chunk id + kb resolution via _feedback_rows_from_reference (single pass)."""
def test_empty_reference(self, feedback_env):
mod, _ = feedback_env
assert mod.ChunkFeedbackService._feedback_rows_from_reference({}) == []
assert mod.ChunkFeedbackService._feedback_rows_from_reference(None) == []
def test_reference_with_id_and_dataset(self, feedback_env):
mod, _ = feedback_env
reference = {
"chunks": [
{"id": "chunk1", "content": "test", "dataset_id": "kb1"},
{"id": "chunk2", "content": "test2", "dataset_id": "kb1"},
]
}
rows = mod.ChunkFeedbackService._feedback_rows_from_reference(reference)
assert [r[0] for r in rows] == ["chunk1", "chunk2"]
def test_reference_with_chunk_id_and_kb_id(self, feedback_env):
mod, _ = feedback_env
reference = {
"chunks": [
{"chunk_id": "chunk1", "content": "test", "kb_id": "kb1"},
{"chunk_id": "chunk2", "content": "test2", "kb_id": "kb1"},
]
}
rows = mod.ChunkFeedbackService._feedback_rows_from_reference(reference)
assert [r[0] for r in rows] == ["chunk1", "chunk2"]
def test_reference_skips_chunks_without_kb(self, feedback_env):
mod, _ = feedback_env
reference = {
"chunks": [
{"id": "chunk1", "dataset_id": "kb1"},
{"id": "chunk2", "content": "no kb"},
]
}
rows = mod.ChunkFeedbackService._feedback_rows_from_reference(reference)
assert [r[0] for r in rows] == ["chunk1"]
def test_reference_with_no_chunks(self, feedback_env):
mod, _ = feedback_env
reference = {"doc_aggs": [{"doc_id": "doc1"}]}
assert mod.ChunkFeedbackService._feedback_rows_from_reference(reference) == []
def test_chunk_id_to_kb_map_matches_row_pairs(self, feedback_env):
mod, _ = feedback_env
reference = {
"chunks": [
{"id": "a", "dataset_id": "kb1"},
{"chunk_id": "b", "kb_id": "kb2"},
]
}
rows = mod.ChunkFeedbackService._feedback_rows_from_reference(reference)
assert {r[0]: r[1] for r in rows} == {"a": "kb1", "b": "kb2"}
class TestUpdateChunkWeight:
"""Tests for update_chunk_weight method."""
def test_update_weight_success(self, feedback_env):
"""Should update chunk weight successfully."""
mod, settings_mod = feedback_env
settings_mod.DOC_ENGINE = "mysql"
mock_doc_store = MagicMock()
mock_doc_store.adjust_chunk_pagerank_fea = None
mock_doc_store.get.return_value = {"pagerank_fea": 10}
mock_doc_store.update.return_value = True
settings_mod.docStoreConn = mock_doc_store
result = mod.ChunkFeedbackService.update_chunk_weight(
tenant_id="tenant1",
chunk_id="chunk1",
kb_id="kb1",
delta=1
)
assert result is True
mock_doc_store.update.assert_called_once()
def test_update_weight_chunk_not_found(self, feedback_env):
"""Should return False if chunk not found."""
mod, settings_mod = feedback_env
settings_mod.DOC_ENGINE = "mysql"
mock_doc_store = MagicMock()
mock_doc_store.adjust_chunk_pagerank_fea = None
mock_doc_store.get.return_value = None
settings_mod.docStoreConn = mock_doc_store
result = mod.ChunkFeedbackService.update_chunk_weight(
tenant_id="tenant1",
chunk_id="chunk1",
kb_id="kb1",
delta=1
)
assert result is False
def test_update_weight_clamp_max(self, feedback_env):
"""Should clamp weight to MAX_PAGERANK_WEIGHT."""
mod, settings_mod = feedback_env
settings_mod.DOC_ENGINE = "mysql"
mock_doc_store = MagicMock()
mock_doc_store.adjust_chunk_pagerank_fea = None
mock_doc_store.get.return_value = {"pagerank_fea": mod.MAX_PAGERANK_WEIGHT}
mock_doc_store.update.return_value = True
settings_mod.docStoreConn = mock_doc_store
mod.ChunkFeedbackService.update_chunk_weight(
tenant_id="tenant1",
chunk_id="chunk1",
kb_id="kb1",
delta=10 # Would exceed max
)
# Verify the new_value passed to update has clamped weight
call_args = mock_doc_store.update.call_args
new_value = call_args[0][1]
assert new_value["pagerank_fea"] == mod.MAX_PAGERANK_WEIGHT
def test_update_weight_clamp_min(self, feedback_env):
"""Should clamp weight to MIN_PAGERANK_WEIGHT."""
mod, settings_mod = feedback_env
settings_mod.DOC_ENGINE = "mysql"
mock_doc_store = MagicMock()
mock_doc_store.adjust_chunk_pagerank_fea = None
mock_doc_store.get.return_value = {"pagerank_fea": 0}
mock_doc_store.update.return_value = True
settings_mod.docStoreConn = mock_doc_store
mod.ChunkFeedbackService.update_chunk_weight(
tenant_id="tenant1",
chunk_id="chunk1",
kb_id="kb1",
delta=-10 # Would go below min
)
call_args = mock_doc_store.update.call_args
new_value = call_args[0][1]
assert new_value["pagerank_fea"] == mod.MIN_PAGERANK_WEIGHT
def test_update_weight_elasticsearch_uses_atomic_adjust(self, feedback_env):
"""Elasticsearch uses script-based adjust (rank_feature zero handled in script)."""
mod, settings_mod = feedback_env
settings_mod.DOC_ENGINE = "elasticsearch"
mock_doc_store = MagicMock()
mock_adjust = MagicMock(return_value=True)
mock_doc_store.adjust_chunk_pagerank_fea = mock_adjust
settings_mod.docStoreConn = mock_doc_store
assert mod.ChunkFeedbackService.update_chunk_weight(
tenant_id="tenant1",
chunk_id="chunk1",
kb_id="kb1",
delta=-1,
)
mock_adjust.assert_called_once_with(
"chunk1",
"idx-tenant1",
"kb1",
-1,
mod.MIN_PAGERANK_WEIGHT,
mod.MAX_PAGERANK_WEIGHT,
)
def test_update_weight_elasticsearch_forwards_row_id(self, feedback_env):
"""Elasticsearch adjust accepts and forwards row_id without TypeError."""
mod, settings_mod = feedback_env
settings_mod.DOC_ENGINE = "elasticsearch"
mock_doc_store = MagicMock()
mock_adjust = MagicMock(return_value=True)
mock_doc_store.adjust_chunk_pagerank_fea = mock_adjust
settings_mod.docStoreConn = mock_doc_store
assert mod.ChunkFeedbackService.update_chunk_weight(
tenant_id="tenant1",
chunk_id="chunk1",
kb_id="kb1",
delta=-1,
row_id=42,
)
mock_adjust.assert_called_once_with(
"chunk1",
"idx-tenant1",
"kb1",
-1,
mod.MIN_PAGERANK_WEIGHT,
mod.MAX_PAGERANK_WEIGHT,
row_id=42,
)
def test_update_weight_opensearch_uses_atomic_adjust(self, feedback_env):
mod, settings_mod = feedback_env
settings_mod.DOC_ENGINE = "opensearch"
mock_doc_store = MagicMock()
mock_adjust = MagicMock(return_value=True)
mock_doc_store.adjust_chunk_pagerank_fea = mock_adjust
settings_mod.docStoreConn = mock_doc_store
mod.ChunkFeedbackService.update_chunk_weight(
tenant_id="tenant1",
chunk_id="chunk1",
kb_id="kb1",
delta=-2,
)
mock_adjust.assert_called_once_with(
"chunk1",
"idx-tenant1",
"kb1",
-2,
mod.MIN_PAGERANK_WEIGHT,
mod.MAX_PAGERANK_WEIGHT,
)
def test_update_weight_opensearch_forwards_row_id(self, feedback_env):
"""OpenSearch adjust accepts and forwards row_id without TypeError."""
mod, settings_mod = feedback_env
settings_mod.DOC_ENGINE = "opensearch"
mock_doc_store = MagicMock()
mock_adjust = MagicMock(return_value=True)
mock_doc_store.adjust_chunk_pagerank_fea = mock_adjust
settings_mod.docStoreConn = mock_doc_store
mod.ChunkFeedbackService.update_chunk_weight(
tenant_id="tenant1",
chunk_id="chunk1",
kb_id="kb1",
delta=-2,
row_id=77,
)
mock_adjust.assert_called_once_with(
"chunk1",
"idx-tenant1",
"kb1",
-2,
mod.MIN_PAGERANK_WEIGHT,
mod.MAX_PAGERANK_WEIGHT,
row_id=77,
)
def test_update_weight_infinity_uses_adjust_with_row_id(self, feedback_env):
"""Infinity path passes row_id to adjust_chunk_pagerank_fea."""
mod, settings_mod = feedback_env
settings_mod.DOC_ENGINE = "infinity"
mock_doc_store = MagicMock()
mock_adjust = MagicMock(return_value=True)
mock_doc_store.adjust_chunk_pagerank_fea = mock_adjust
settings_mod.docStoreConn = mock_doc_store
ok = mod.ChunkFeedbackService.update_chunk_weight(
tenant_id="tenant1",
chunk_id="chunk1",
kb_id="kb1",
delta=1,
row_id=42,
)
assert ok is True
mock_adjust.assert_called_once_with(
"chunk1",
"idx-tenant1",
"kb1",
1,
mod.MIN_PAGERANK_WEIGHT,
mod.MAX_PAGERANK_WEIGHT,
row_id=42,
)
class TestApplyFeedback:
"""Tests for apply_feedback method."""
def test_apply_feedback_disabled(self, feedback_env, monkeypatch):
"""Should return early when feature is disabled."""
mod, _ = feedback_env
monkeypatch.setattr(mod, "CHUNK_FEEDBACK_ENABLED", False)
result = mod.ChunkFeedbackService.apply_feedback(
tenant_id="tenant1",
reference={"chunks": [{"id": "chunk1", "dataset_id": "kb1"}]},
is_positive=True
)
assert result["success_count"] == 0
assert result["fail_count"] == 0
assert result.get("disabled") is True
def test_apply_positive_feedback(self, feedback_env, monkeypatch):
"""Relevance mode splits the per-event budget across chunks (equal when no scores)."""
mod, _ = feedback_env
monkeypatch.setattr(mod, "CHUNK_FEEDBACK_ENABLED", True)
mock_update = MagicMock(return_value=True)
monkeypatch.setattr(
mod.ChunkFeedbackService, "update_chunk_weight", mock_update
)
reference = {
"chunks": [
{"id": "chunk1", "dataset_id": "kb1"},
{"id": "chunk2", "dataset_id": "kb1"},
]
}
result = mod.ChunkFeedbackService.apply_feedback(
tenant_id="tenant1",
reference=reference,
is_positive=True
)
assert result["success_count"] == 1
assert result["fail_count"] == 0
assert mock_update.call_count == 1
mock_update.assert_called_once_with("tenant1", "chunk1", "kb1", 1, row_id=None)
def test_apply_negative_feedback(self, feedback_env, monkeypatch):
"""Should apply negative feedback with full budget when only one chunk."""
mod, _ = feedback_env
monkeypatch.setattr(mod, "CHUNK_FEEDBACK_ENABLED", True)
mock_update = MagicMock(return_value=True)
monkeypatch.setattr(
mod.ChunkFeedbackService, "update_chunk_weight", mock_update
)
reference = {"chunks": [{"id": "chunk1", "dataset_id": "kb1"}]}
result = mod.ChunkFeedbackService.apply_feedback(
tenant_id="tenant1",
reference=reference,
is_positive=False
)
assert result["success_count"] == 1
mock_update.assert_called_with("tenant1", "chunk1", "kb1", -1, row_id=None)
def test_apply_feedback_no_chunks(self, feedback_env, monkeypatch):
"""Should handle empty chunk list gracefully."""
mod, _ = feedback_env
monkeypatch.setattr(mod, "CHUNK_FEEDBACK_ENABLED", True)
result = mod.ChunkFeedbackService.apply_feedback(
tenant_id="tenant1",
reference={},
is_positive=True
)
assert result["success_count"] == 0
assert result["fail_count"] == 0
assert result["chunk_ids"] == []
def test_apply_feedback_partial_failure(self, feedback_env, monkeypatch):
"""Should count failures correctly (uniform gives each chunk a unit)."""
mod, _ = feedback_env
monkeypatch.setattr(mod, "CHUNK_FEEDBACK_ENABLED", True)
monkeypatch.setattr(mod, "CHUNK_FEEDBACK_WEIGHTING", "uniform")
mock_update = MagicMock(side_effect=[True, False])
monkeypatch.setattr(
mod.ChunkFeedbackService, "update_chunk_weight", mock_update
)
reference = {
"chunks": [
{"id": "chunk1", "dataset_id": "kb1"},
{"id": "chunk2", "dataset_id": "kb1"},
]
}
result = mod.ChunkFeedbackService.apply_feedback(
tenant_id="tenant1",
reference=reference,
is_positive=True
)
assert result["success_count"] == 1
assert result["fail_count"] == 1
def test_apply_positive_feedback_uniform_mode(self, feedback_env, monkeypatch):
"""uniform: each cited chunk gets the full increment (legacy)."""
mod, _ = feedback_env
monkeypatch.setattr(mod, "CHUNK_FEEDBACK_ENABLED", True)
monkeypatch.setattr(mod, "CHUNK_FEEDBACK_WEIGHTING", "uniform")
mock_update = MagicMock(return_value=True)
monkeypatch.setattr(
mod.ChunkFeedbackService, "update_chunk_weight", mock_update
)
reference = {
"chunks": [
{"id": "chunk1", "dataset_id": "kb1"},
{"id": "chunk2", "dataset_id": "kb1"},
]
}
mod.ChunkFeedbackService.apply_feedback(
tenant_id="tenant1", reference=reference, is_positive=True
)
mock_update.assert_any_call("tenant1", "chunk1", "kb1", mod.UPVOTE_WEIGHT_INCREMENT, row_id=None)
mock_update.assert_any_call("tenant1", "chunk2", "kb1", mod.UPVOTE_WEIGHT_INCREMENT, row_id=None)
def test_apply_positive_feedback_relevance_weighted(self, feedback_env, monkeypatch):
"""Higher retrieval similarity receives a larger share of the budget."""
mod, _ = feedback_env
monkeypatch.setattr(mod, "CHUNK_FEEDBACK_ENABLED", True)
monkeypatch.setattr(mod, "CHUNK_FEEDBACK_WEIGHTING", "relevance")
mock_update = MagicMock(return_value=True)
monkeypatch.setattr(
mod.ChunkFeedbackService, "update_chunk_weight", mock_update
)
reference = {
"chunks": [
{"id": "a", "dataset_id": "kb1", "similarity": 0.9},
{"id": "b", "dataset_id": "kb1", "similarity": 0.1},
]
}
mod.ChunkFeedbackService.apply_feedback(
tenant_id="tenant1", reference=reference, is_positive=True
)
mock_update.assert_called_once_with("tenant1", "a", "kb1", 1, row_id=None)
def test_apply_feedback_passes_row_id_from_reference(self, feedback_env, monkeypatch):
"""row_id from retrieval results flows through to update_chunk_weight."""
mod, _ = feedback_env
monkeypatch.setattr(mod, "CHUNK_FEEDBACK_ENABLED", True)
monkeypatch.setattr(mod, "CHUNK_FEEDBACK_WEIGHTING", "relevance")
mock_update = MagicMock(return_value=True)
monkeypatch.setattr(
mod.ChunkFeedbackService, "update_chunk_weight", mock_update
)
reference = {
"chunks": [
{"id": "c1", "dataset_id": "kb1", "similarity": 0.8, "row_id": 99},
]
}
mod.ChunkFeedbackService.apply_feedback(
tenant_id="tenant1", reference=reference, is_positive=True
)
mock_update.assert_called_once_with("tenant1", "c1", "kb1", 1, row_id=99)
class TestThumbFlipFeedback:
"""Verify that toggling thumbup↔thumbdown applies undo + new (two calls)."""
@staticmethod
def _simulate_feedback(mod, monkeypatch, reference, prior_thumb, new_thumb):
"""Reproduce the chat_api thumb-flip logic in isolation."""
monkeypatch.setattr(mod, "CHUNK_FEEDBACK_ENABLED", True)
mock_update = MagicMock(return_value=True)
monkeypatch.setattr(mod.ChunkFeedbackService, "update_chunk_weight", mock_update)
calls = []
apply_chunk_feedback = False
if new_thumb is True:
apply_chunk_feedback = prior_thumb is not True
else:
apply_chunk_feedback = prior_thumb is not False
if apply_chunk_feedback and reference:
if isinstance(prior_thumb, bool) and prior_thumb != new_thumb:
r = mod.ChunkFeedbackService.apply_feedback(
tenant_id="t1", reference=reference, is_positive=not prior_thumb,
)
calls.append(("undo", r))
r = mod.ChunkFeedbackService.apply_feedback(
tenant_id="t1", reference=reference, is_positive=new_thumb is True,
)
calls.append(("new", r))
return calls, mock_update
def test_toggle_thumbup_to_thumbdown(self, feedback_env, monkeypatch):
"""thumbup→thumbdown: undo (+1→-1) then apply new (-1). Two calls."""
mod, _ = feedback_env
ref = {"chunks": [{"id": "c1", "dataset_id": "kb1"}]}
calls, mock = self._simulate_feedback(mod, monkeypatch, ref, True, False)
assert len(calls) == 2
assert calls[0][0] == "undo"
assert calls[1][0] == "new"
def test_toggle_thumbdown_to_thumbup(self, feedback_env, monkeypatch):
"""thumbdown→thumbup: undo (-1→+1) then apply new (+1). Two calls."""
mod, _ = feedback_env
ref = {"chunks": [{"id": "c1", "dataset_id": "kb1"}]}
calls, mock = self._simulate_feedback(mod, monkeypatch, ref, False, True)
assert len(calls) == 2
assert calls[0][0] == "undo"
assert calls[1][0] == "new"
def test_no_prior_to_thumbup(self, feedback_env, monkeypatch):
"""None→thumbup: single apply, no undo."""
mod, _ = feedback_env
ref = {"chunks": [{"id": "c1", "dataset_id": "kb1"}]}
calls, mock = self._simulate_feedback(mod, monkeypatch, ref, None, True)
assert len(calls) == 1
assert calls[0][0] == "new"
def test_same_thumb_no_op(self, feedback_env, monkeypatch):
"""thumbup→thumbup: no feedback at all (apply_chunk_feedback is False)."""
mod, _ = feedback_env
ref = {"chunks": [{"id": "c1", "dataset_id": "kb1"}]}
calls, mock = self._simulate_feedback(mod, monkeypatch, ref, True, True)
assert len(calls) == 0