mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-06-29 23:41:12 +08:00
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:
@@ -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):
|
||||
|
||||
15
test/testcases/test_web_api/test_chunk_feedback/__init__.py
Normal file
15
test/testcases/test_web_api/test_chunk_feedback/__init__.py
Normal 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.
|
||||
#
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user