feat(recipes): add prompt-aware duplicate detection toggle

This commit is contained in:
Will Miao
2026-08-10 00:07:14 +08:00
parent 8237e5f9ea
commit 95fb3c7fc9
20 changed files with 582 additions and 39 deletions

View File

@@ -2,13 +2,22 @@ import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const showToastMock = vi.fn();
const recreateVirtualScrollMock = vi.fn();
const translateMock = vi.fn((key) => key);
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: showToastMock,
}));
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
translate: translateMock,
}));
vi.mock('../../../static/js/components/RecipeCard.js', () => ({
RecipeCard: class {},
RecipeCard: class {
constructor() {
this.element = document.createElement('div');
}
},
}));
vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({
@@ -85,3 +94,120 @@ describe('DuplicatesManager exitDuplicateMode', () => {
expect(document.getElementById('duplicatesBanner').style.display).toBe('none');
});
});
describe('DuplicatesManager prompt matching toggle', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
setCurrentPageType('recipes');
setupDom();
state.pendingLayoutRecreate = false;
state.virtualScroller = { enable: vi.fn(), disable: vi.fn() };
});
afterEach(() => {
state.pendingLayoutRecreate = false;
state.virtualScroller = null;
});
it('sends include_prompt=1 when the preference is enabled', async () => {
localStorage.setItem('recipes_duplicates_include_prompt', '1');
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
success: true,
duplicate_groups: [
{ type: 'fingerprint', key: 'g-1', fingerprint: 'abc:0.8', count: 2, recipes: [{ id: 'r1', modified: 1 }, { id: 'r2', modified: 2 }] },
],
}),
});
const manager = new DuplicatesManager({});
await manager.findDuplicates();
expect(globalThis.fetch).toHaveBeenCalledWith('/api/lm/recipes/find-duplicates?include_prompt=1');
});
it('calls the endpoint without the param when disabled', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true, duplicate_groups: [] }),
});
const manager = new DuplicatesManager({});
await manager.findDuplicates();
expect(globalThis.fetch).toHaveBeenCalledWith('/api/lm/recipes/find-duplicates');
});
it('stays in duplicate mode with an empty view when a re-run finds no groups', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true, duplicate_groups: [] }),
});
const manager = new DuplicatesManager({});
manager.inDuplicateMode = true;
await manager.findDuplicates();
// The view stays open (with the empty state) so the matching-basis
// toggle remains reachable — the deadlock fix
expect(manager.inDuplicateMode).toBe(true);
expect(manager.duplicateGroups).toEqual([]);
expect(document.getElementById('duplicatesBanner').style.display).toBe('block');
expect(document.querySelector('.duplicates-empty-state')).not.toBeNull();
});
it('enters the empty duplicates view when the toggle is on but no groups match', async () => {
localStorage.setItem('recipes_duplicates_include_prompt', '1');
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true, duplicate_groups: [] }),
});
const manager = new DuplicatesManager({});
await manager.findDuplicates();
expect(manager.inDuplicateMode).toBe(true);
expect(document.getElementById('duplicatesBanner').style.display).toBe('block');
});
it('toasts and stays on the library grid when the toggle is off and no groups match', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true, duplicate_groups: [] }),
});
const manager = new DuplicatesManager({});
await manager.findDuplicates();
expect(manager.inDuplicateMode).toBe(false);
expect(showToastMock).toHaveBeenCalledWith('toast.duplicates.noDuplicatesFound', { type: 'recipes' }, 'info');
});
it('renders the matching basis and checkbox from the stored preference', () => {
document.body.innerHTML = `
<span id="duplicatesBasis"></span>
<span id="duplicatesHelpText"></span>
<input type="checkbox" id="promptMatchInput">
`;
localStorage.setItem('recipes_duplicates_include_prompt', '1');
const manager = new DuplicatesManager({});
manager.updateBasisDisplay();
expect(translateMock).toHaveBeenCalledWith('recipes.duplicates.basis.loraComboAndPrompt');
expect(translateMock).toHaveBeenCalledWith('recipes.duplicates.basis.hintPromptIncluded');
expect(document.getElementById('promptMatchInput').checked).toBe(true);
});
it('shows the lora-combo basis when the preference is disabled', () => {
document.body.innerHTML = `<span id="duplicatesBasis"></span>`;
const manager = new DuplicatesManager({});
manager.updateBasisDisplay();
expect(translateMock).toHaveBeenCalledWith('recipes.duplicates.basis.loraCombo');
expect(document.getElementById('duplicatesBasis').textContent).toBe('recipes.duplicates.basis.loraCombo');
});
});

View File

@@ -97,6 +97,15 @@ class StubRecipeScanner:
async def get_recipe_by_id(self, recipe_id: str) -> Optional[Dict[str, Any]]:
return self.recipes.get(recipe_id)
async def find_all_duplicate_recipes(
self, include_prompt: bool = False
) -> Dict[str, List[Any]]:
self.last_duplicate_include_prompt = include_prompt
return dict(getattr(self, "duplicate_groups_override", {}))
async def find_duplicate_recipes_by_source(self) -> Dict[str, List[Any]]:
return dict(getattr(self, "duplicate_source_groups_override", {}))
async def get_recipes_for_lora(self, lora_hash: str) -> List[Dict[str, Any]]:
return list(self.lora_lookup.get(lora_hash.lower(), []))
@@ -1951,3 +1960,56 @@ async def test_get_rematch_progress_returns_stored_progress(
assert response.status == 200
assert payload["success"] is True
assert payload["progress"]["status"] == "processing"
async def test_find_duplicates_defaults_to_fingerprint_only(
monkeypatch, tmp_path: Path
) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness:
harness.scanner.recipes = {
"r1": {"id": "r1", "title": "One", "modified": 100},
"r2": {"id": "r2", "title": "Two", "modified": 200},
}
harness.scanner.duplicate_groups_override = {"abc:0.8": ["r1", "r2"]}
harness.scanner.duplicate_source_groups_override = {}
response = await harness.client.get("/api/lm/recipes/find-duplicates")
payload = await response.json()
assert response.status == 200
assert payload["success"] is True
assert harness.scanner.last_duplicate_include_prompt is False
assert len(payload["duplicate_groups"]) == 1
group = payload["duplicate_groups"][0]
assert group["type"] == "fingerprint"
assert group["key"] == "g-1"
assert group["fingerprint"] == "abc:0.8"
assert group["count"] == 2
async def test_find_duplicates_forwards_include_prompt_and_assigns_unique_keys(
monkeypatch, tmp_path: Path
) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness:
harness.scanner.recipes = {
"r1": {"id": "r1", "title": "One", "modified": 100},
"r2": {"id": "r2", "title": "Two", "modified": 200},
"r3": {"id": "r3", "title": "Three", "modified": 300},
"r4": {"id": "r4", "title": "Four", "modified": 400},
}
harness.scanner.duplicate_groups_override = {"abc:0.8\x1fa girl": ["r1", "r2"]}
harness.scanner.duplicate_source_groups_override = {
"civitai.com/images/9": ["r3", "r4"]
}
response = await harness.client.get(
"/api/lm/recipes/find-duplicates?include_prompt=1"
)
payload = await response.json()
assert response.status == 200
assert harness.scanner.last_duplicate_include_prompt is True
groups = payload["duplicate_groups"]
assert len(groups) == 2
assert {g["type"] for g in groups} == {"fingerprint", "source_path"}
assert len({g["key"] for g in groups}) == 2

View File

@@ -3209,3 +3209,71 @@ async def test_rematch_all_autov3_cache_reuse_across_calls(
# are read once across both calls (Oracle R2-F4).
assert len(called) == 1
async def test_find_all_duplicate_recipes_groups_by_fingerprint(recipe_scanner, monkeypatch):
scanner, _ = recipe_scanner
cache = SimpleNamespace(
raw_data=[
{"id": "r1", "fingerprint": "abc:0.8", "gen_params": {"prompt": "A Girl, blue hair"}},
{"id": "r2", "fingerprint": "abc:0.8", "gen_params": {"prompt": "a boy"}},
{"id": "r3", "fingerprint": "abc:0.8", "gen_params": {"prompt": "a boy"}},
{"id": "r4", "fingerprint": "def:1.0", "gen_params": {"prompt": "A Girl, blue hair"}},
{"id": "r5", "fingerprint": "", "gen_params": {"prompt": "landscape"}},
{"id": "r6", "fingerprint": "", "gen_params": {}},
]
)
async def fake_get_cached_data():
return cache
monkeypatch.setattr(scanner, "get_cached_data", fake_get_cached_data)
groups = await scanner.find_all_duplicate_recipes()
assert groups == {"abc:0.8": ["r1", "r2", "r3"]}
async def test_find_all_duplicate_recipes_include_prompt_composite_key(recipe_scanner, monkeypatch):
scanner, _ = recipe_scanner
cache = SimpleNamespace(
raw_data=[
{"id": "r1", "fingerprint": "abc:0.8", "gen_params": {"prompt": "A Girl, blue hair"}},
{"id": "r2", "fingerprint": "abc:0.8", "gen_params": {"prompt": "a girl, blue hair"}},
{"id": "r3", "fingerprint": "abc:0.8", "gen_params": {"prompt": "a boy"}},
{"id": "r4", "fingerprint": "", "gen_params": {"prompt": " landscape "}},
{"id": "r5", "fingerprint": "", "gen_params": {"prompt": "landscape"}},
{"id": "r6", "fingerprint": "", "gen_params": {}},
{"id": "r7", "fingerprint": "def:1.0", "gen_params": {"prompt": "a girl, blue hair"}},
]
)
async def fake_get_cached_data():
return cache
monkeypatch.setattr(scanner, "get_cached_data", fake_get_cached_data)
groups = await scanner.find_all_duplicate_recipes(include_prompt=True)
assert groups == {
"abc:0.8\x1fa girl, blue hair": ["r1", "r2"],
"\x1flandscape": ["r4", "r5"],
}
# Same-lora recipes with different prompts are no longer duplicates
assert "abc:0.8\x1fa boy" not in groups
# Different-lora recipes with the same prompt are not grouped either
assert "def:1.0\x1fa girl, blue hair" not in groups
# Recipes with neither fingerprint nor prompt are skipped
assert "r6" not in [rid for ids in groups.values() for rid in ids]
async def test_find_all_duplicate_recipes_include_prompt_missing_gen_params(recipe_scanner, monkeypatch):
scanner, _ = recipe_scanner
cache = SimpleNamespace(
raw_data=[
{"id": "r1", "fingerprint": "abc:0.8"},
{"id": "r2", "fingerprint": "abc:0.8"},
{"id": "r3", "fingerprint": "abc:0.8", "gen_params": {"prompt": "a boy"}},
]
)
async def fake_get_cached_data():
return cache
monkeypatch.setattr(scanner, "get_cached_data", fake_get_cached_data)
groups = await scanner.find_all_duplicate_recipes(include_prompt=True)
# Recipes without gen_params/prompt normalize to empty prompt and match
assert groups == {"abc:0.8\x1f": ["r1", "r2"]}

View File

@@ -1,6 +1,6 @@
"""Test for modelVersionId fallback in fingerprint calculation."""
import pytest
from py.utils.utils import calculate_recipe_fingerprint
from py.utils.utils import calculate_recipe_fingerprint, normalize_prompt_for_dedup
def test_calculate_fingerprint_with_model_version_id_fallback():
@@ -98,3 +98,15 @@ def test_calculate_fingerprint_without_hash_or_version_id():
]
fingerprint = calculate_recipe_fingerprint(loras)
assert fingerprint == ""
def test_normalize_prompt_casefolds_and_collapses_whitespace():
assert normalize_prompt_for_dedup("A Girl, blue hair") == "a girl, blue hair"
assert normalize_prompt_for_dedup(" landscape \n\t with details ") == "landscape with details"
assert normalize_prompt_for_dedup("MASTERPIECE, best quality") == "masterpiece, best quality"
def test_normalize_prompt_handles_missing_or_non_string():
assert normalize_prompt_for_dedup(None) == ""
assert normalize_prompt_for_dedup("") == ""
assert normalize_prompt_for_dedup(12345) == ""