docs: update implementation plan with fuzzy matching decision
- Update SQL queries to use fuzzy matching for tags_filter
- Add ORDER BY clause changes for tag similarity scoring
- Update test code to use setupDeviceTest() instead of setupTestServer()
- Document fuzzy matching behavior throughout
- Update examples to show fuzzy matching ("Sci Fi" → "Science Fiction")
- Add missing comma fix to SQL ORDER BY clause
- Correct test helper function references
- Note that collection-rules.ts already supports both genre and tags
Updates the implementation plan to reflect the decision to use fuzzy
matching for tags_filter, making it consistent with other filters.
Includes corrections to test code and documentation improvements.
Relates to IMPLEMENTATION_TAGS_FILTER.md planning updates
This commit is contained in:
@@ -5,6 +5,7 @@
|
|||||||
**Type:** Full-Stack Task
|
**Type:** Full-Stack Task
|
||||||
**Priority:** Medium
|
**Priority:** Medium
|
||||||
**Estimated Time:** 2-3 hours
|
**Estimated Time:** 2-3 hours
|
||||||
|
**Note:** Tags filter uses fuzzy matching (consistent with author/series filters)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -65,11 +66,13 @@ The current genre filter expects a single `genre` text field, which is always NU
|
|||||||
|
|
||||||
## 📋 Implementation Checklist
|
## 📋 Implementation Checklist
|
||||||
|
|
||||||
### Phase 1: Backend - SQL Layer (15 minutes)
|
### Phase 1: Backend - SQL Layer (25 minutes)
|
||||||
- [ ] Add `tags_filter` parameter to `SearchMediaItemsUnified` query
|
- [ ] Add `tags_filter` parameter to `SearchMediaItemsUnified` query
|
||||||
- [ ] Update filter logic from `word_similarity()` to `= ANY()` for array matching
|
- [ ] Add fuzzy matching filter logic with EXISTS clause and word_similarity()
|
||||||
|
- [ ] Add tag similarity scoring to ORDER BY clause (GREATEST function)
|
||||||
- [ ] Keep `genre_filter` for backward compatibility
|
- [ ] Keep `genre_filter` for backward compatibility
|
||||||
- [ ] Regenerate Go code with `sqlc generate`
|
- [ ] Regenerate Go code with `sqlc generate`
|
||||||
|
- [ ] Test SQL query in database console (verify fuzzy matching works)
|
||||||
|
|
||||||
### Phase 2: Backend - Service Layer (20 minutes)
|
### Phase 2: Backend - Service Layer (20 minutes)
|
||||||
- [ ] Add `TagsFilter string` to `SearchParams` struct
|
- [ ] Add `TagsFilter string` to `SearchParams` struct
|
||||||
@@ -96,10 +99,11 @@ The current genre filter expects a single `genre` text field, which is always NU
|
|||||||
- [ ] Regenerate template Go files with `templ generate`
|
- [ ] Regenerate template Go files with `templ generate`
|
||||||
|
|
||||||
### Phase 6: Bruno Collection Updates (15 minutes)
|
### Phase 6: Bruno Collection Updates (15 minutes)
|
||||||
- [ ] Create `Fuzzy Tags Filter.yml` (rename/update genre filter test)
|
- [ ] Create `Fuzzy Tags Filter.yml` (test fuzzy matching behavior)
|
||||||
- [ ] Create `Field Values Search - Tags.yml` (rename genre autocomplete)
|
- [ ] Create `Field Values Search - Tags.yml` (test autocomplete)
|
||||||
- [ ] Update `Combined Search and Filters.yml` (genre → tags_filter)
|
- [ ] Update `Combined Search and Filters.yml` (genre → tags_filter)
|
||||||
- [ ] Add documentation for tags filter behavior
|
- [ ] Add test cases for fuzzy matching (e.g., "Sci Fi" → "Science Fiction")
|
||||||
|
- [ ] Add documentation for fuzzy tags filter behavior
|
||||||
|
|
||||||
### Phase 7: Documentation (20 minutes)
|
### Phase 7: Documentation (20 minutes)
|
||||||
- [ ] Create `docs/developer/api/media-items/search_media_items.md` (or update existing)
|
- [ ] Create `docs/developer/api/media-items/search_media_items.md` (or update existing)
|
||||||
@@ -142,8 +146,11 @@ AND (sqlc.narg('genre_filter') = '' OR word_similarity(sqlc.narg('genre_filter')
|
|||||||
-- AFTER:
|
-- AFTER:
|
||||||
-- Fuzzy genre filter (kept for backward compatibility)
|
-- Fuzzy genre filter (kept for backward compatibility)
|
||||||
AND (sqlc.narg('genre_filter') = '' OR word_similarity(sqlc.narg('genre_filter'), COALESCE(mi.genre, '')) > 0.3)
|
AND (sqlc.narg('genre_filter') = '' OR word_similarity(sqlc.narg('genre_filter'), COALESCE(mi.genre, '')) > 0.3)
|
||||||
-- Tags filter (NEW - matches against tags array)
|
-- Tags filter (NEW - fuzzy match against tags array)
|
||||||
AND (sqlc.narg('tags_filter') = '' OR sqlc.narg('tags_filter') = ANY(mi.tags_search))
|
AND (sqlc.narg('tags_filter') = '' OR EXISTS (
|
||||||
|
SELECT 1 FROM unnest(mi.tags_search) AS tag
|
||||||
|
WHERE word_similarity(sqlc.narg('tags_filter'), tag) > 0.3
|
||||||
|
))
|
||||||
```
|
```
|
||||||
|
|
||||||
**New Query:** Add `SearchTagsValues` for autocomplete
|
**New Query:** Add `SearchTagsValues` for autocomplete
|
||||||
@@ -165,6 +172,26 @@ ORDER BY word_similarity(sqlc.narg('search_query'), unnest(mi.tags_search))::flo
|
|||||||
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
|
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**ORDER BY Clause Update (line ~484):**
|
||||||
|
Add tag similarity scoring to the GREATEST() calculation:
|
||||||
|
```sql
|
||||||
|
-- In the ORDER BY clause, add to the GREATEST() function:
|
||||||
|
GREATEST(
|
||||||
|
CASE WHEN sqlc.narg('is_exact_search') = false THEN
|
||||||
|
word_similarity(sqlc.narg('search_query'), mi.title)
|
||||||
|
ELSE 0 END,
|
||||||
|
CASE WHEN sqlc.narg('is_exact_search') = false THEN
|
||||||
|
word_similarity(sqlc.narg('search_query'), COALESCE(mi.author, ''))
|
||||||
|
ELSE 0 END,
|
||||||
|
word_similarity(sqlc.narg('author_filter'), COALESCE(mi.author, '')),
|
||||||
|
word_similarity(sqlc.narg('genre_filter'), COALESCE(mi.genre, '')),
|
||||||
|
(SELECT MAX(word_similarity(sqlc.narg('tags_filter'), tag))
|
||||||
|
FROM unnest(mi.tags_search) AS tag) -- NEW: Tag similarity score
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
This ensures results with better tag matches appear first.
|
||||||
|
|
||||||
### 2. Service Layer Changes
|
### 2. Service Layer Changes
|
||||||
|
|
||||||
**File:** `internal/services/search.go`
|
**File:** `internal/services/search.go`
|
||||||
@@ -289,19 +316,6 @@ async fetchTagValues(input: HTMLInputElement): Promise<void> {
|
|||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
**File:** `web/src/collection-rules.ts`
|
|
||||||
|
|
||||||
**Change:** Line 45
|
|
||||||
```typescript
|
|
||||||
// AFTER (add new field, keep old commented out):
|
|
||||||
const ruleFields = {
|
|
||||||
tags: "Tags",
|
|
||||||
// TODO: Uncomment if genre field is populated in future
|
|
||||||
// genre: "Genre",
|
|
||||||
// ... other fields
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Template Changes
|
### 5. Template Changes
|
||||||
|
|
||||||
**File:** `templates/bookshelf.templ` (source file)
|
**File:** `templates/bookshelf.templ` (source file)
|
||||||
@@ -360,7 +374,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestTagsFilter(t *testing.T) {
|
func TestTagsFilter(t *testing.T) {
|
||||||
setup := setupTestServer(t) // ✅ Using test_helpers
|
setup := setupDeviceTest(t) // ✅ Using test_helpers
|
||||||
defer setup.Server.Close()
|
defer setup.Server.Close()
|
||||||
|
|
||||||
libraryID := setup.CreateLibrary(t, "Test Tags Library", "ebooks")
|
libraryID := setup.CreateLibrary(t, "Test Tags Library", "ebooks")
|
||||||
@@ -392,12 +406,20 @@ func TestTagsFilter(t *testing.T) {
|
|||||||
createBook("The Hobbit", "Tolkien", []string{"Fantasy", "Adventure"})
|
createBook("The Hobbit", "Tolkien", []string{"Fantasy", "Adventure"})
|
||||||
|
|
||||||
t.Run("Tags filter - Science Fiction", func(t *testing.T) {
|
t.Run("Tags filter - Science Fiction", func(t *testing.T) {
|
||||||
req := httptest.NewRequest("GET", "/api/media-items/search?library_id="+libraryID+"&tags_filter=Science+Foundation", nil)
|
req := httptest.NewRequest("GET", "/api/media-items/search?library_id="+libraryID+"&tags_filter=Science+Fiction", nil)
|
||||||
req.Header.Set("Authorization", "Bearer "+setup.UserToken)
|
req.Header.Set("Authorization", "Bearer "+setup.UserToken)
|
||||||
// ... test implementation
|
// ... test implementation
|
||||||
// Should return Foundation, Dune, Neuromancer (all have "Science Fiction" tag)
|
// Should return Foundation, Dune, Neuromancer (all have "Science Fiction" tag)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("Tags filter - fuzzy match", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest("GET", "/api/media-items/search?library_id="+libraryID+"&tags_filter=Sci+Fi", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+setup.UserToken)
|
||||||
|
// ... test implementation
|
||||||
|
// Should return Foundation, Dune, Neuromancer (fuzzy matches "Science Fiction")
|
||||||
|
// Results should be ranked by similarity score
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("Tags filter - Fantasy", func(t *testing.T) {
|
t.Run("Tags filter - Fantasy", func(t *testing.T) {
|
||||||
// Should return only The Hobbit
|
// Should return only The Hobbit
|
||||||
})
|
})
|
||||||
@@ -405,7 +427,7 @@ func TestTagsFilter(t *testing.T) {
|
|||||||
t.Run("Tags autocomplete", func(t *testing.T) {
|
t.Run("Tags autocomplete", func(t *testing.T) {
|
||||||
req := httptest.NewRequest("GET", "/api/media-items/search?tags=Sci&library_id="+libraryID, nil)
|
req := httptest.NewRequest("GET", "/api/media-items/search?tags=Sci&library_id="+libraryID, nil)
|
||||||
req.Header.Set("Authorization", "Bearer "+setup.UserToken)
|
req.Header.Set("Authorization", "Bearer "+setup.UserToken)
|
||||||
// Should return "Science Fiction" in autocomplete results
|
// Should return "Science Fiction" in autocomplete results with fuzzy matching
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("Backward compatibility - genre_filter still works", func(t *testing.T) {
|
t.Run("Backward compatibility - genre_filter still works", func(t *testing.T) {
|
||||||
@@ -413,7 +435,7 @@ func TestTagsFilter(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("Combined filters - tags + author", func(t *testing.T) {
|
t.Run("Combined filters - tags + author", func(t *testing.T) {
|
||||||
// tags_filter=Science+Fiction&author_filter=Asimov
|
// tags_filter=Sci+Fi&author_filter=Asimov
|
||||||
// Should return only Foundation
|
// Should return only Foundation
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -422,6 +444,8 @@ func TestTagsFilter(t *testing.T) {
|
|||||||
### Manual QA Checklist
|
### Manual QA Checklist
|
||||||
|
|
||||||
- [ ] Tags filter returns books with matching tags
|
- [ ] Tags filter returns books with matching tags
|
||||||
|
- [ ] Tags filter uses fuzzy matching (e.g., "Sci Fi" matches "Science Fiction")
|
||||||
|
- [ ] Results are ranked by tag similarity score (best matches first)
|
||||||
- [ ] Tags autocomplete shows tag values as you type
|
- [ ] Tags autocomplete shows tag values as you type
|
||||||
- [ ] Multiple tags in book are all searchable
|
- [ ] Multiple tags in book are all searchable
|
||||||
- [ ] Combined filters work (tags + author + series)
|
- [ ] Combined filters work (tags + author + series)
|
||||||
@@ -455,7 +479,7 @@ Search and filter media items with fuzzy matching and tag-based filtering.
|
|||||||
| `library_id` | string | No | Filter by library UUID |
|
| `library_id` | string | No | Filter by library UUID |
|
||||||
| `author_filter` | string | No | Fuzzy match author field |
|
| `author_filter` | string | No | Fuzzy match author field |
|
||||||
| `series_filter` | string | No | Fuzzy match series field |
|
| `series_filter` | string | No | Fuzzy match series field |
|
||||||
| `tags_filter` | string | No | **Exact match tags array** (NEW) |
|
| `tags_filter` | string | No | **Fuzzy match tags array** (NEW) |
|
||||||
| `language_filter` | string | No | Fuzzy match language field |
|
| `language_filter` | string | No | Fuzzy match language field |
|
||||||
| `year_min` | integer | No | Minimum copyright year |
|
| `year_min` | integer | No | Minimum copyright year |
|
||||||
| `year_max` | integer | No | Maximum copyright year |
|
| `year_max` | integer | No | Maximum copyright year |
|
||||||
@@ -468,21 +492,29 @@ Search and filter media items with fuzzy matching and tag-based filtering.
|
|||||||
|
|
||||||
**New in v1.x:** The `tags_filter` parameter filters books by their tags array.
|
**New in v1.x:** The `tags_filter` parameter filters books by their tags array.
|
||||||
|
|
||||||
- **Matching:** Exact match (not fuzzy) - the filter value must equal a tag
|
- **Matching:** Fuzzy match using `word_similarity()` - matches tags with similarity > 0.3
|
||||||
- **Calibre Integration:** Works seamlessly with Calibre tags (which include genre)
|
- **Calibre Integration:** Works seamlessly with Calibre tags (which include genre)
|
||||||
|
- **Result Ranking:** Best tag matches appear first in results
|
||||||
- **Example Tags:** `["Science Fiction", "Adventure", "Dystopian"]`
|
- **Example Tags:** `["Science Fiction", "Adventure", "Dystopian"]`
|
||||||
|
|
||||||
**Examples:**
|
**Fuzzy Matching Examples:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Filter by "Science Fiction" tag
|
# Exact match: "Science Fiction"
|
||||||
GET /api/media-items/search?tags_filter=Science+Fiction
|
GET /api/media-items/search?tags_filter=Science+Fiction
|
||||||
|
# Returns: All books with "Science Fiction" tag
|
||||||
|
|
||||||
|
# Fuzzy match: "Sci Fi" → "Science Fiction"
|
||||||
|
GET /api/media-items/search?tags_filter=Sci+Fi
|
||||||
|
# Returns: Books with "Science Fiction", "Sci-Fi", "Scifi" (score > 0.3)
|
||||||
|
|
||||||
# Combine tags with author
|
# Combine tags with author
|
||||||
GET /api/media-items/search?tags_filter=Science+Fiction&author_filter=Asimov
|
GET /api/media-items/search?tags_filter=Sci+Fi&author_filter=Asimov
|
||||||
|
# Returns: Science Fiction books by Asimov, best matches first
|
||||||
|
|
||||||
# Filter by multiple tags (returns books with ANY matching tag)
|
# Fuzzy match with variations
|
||||||
GET /api/media-items/search?tags_filter=Cyberpunk
|
GET /api/media-items/search?tags_filter=Cyber
|
||||||
|
# Returns: Books with "Cyberpunk", "Cyber", "Cyberthriller" tags
|
||||||
```
|
```
|
||||||
|
|
||||||
### Backward Compatibility
|
### Backward Compatibility
|
||||||
@@ -519,7 +551,6 @@ tags_filter=Science+Fiction
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
```
|
|
||||||
|
|
||||||
### User Documentation
|
### User Documentation
|
||||||
|
|
||||||
@@ -560,8 +591,9 @@ You can combine tags with other filters:
|
|||||||
|
|
||||||
- Tags from Calibre are automatically imported
|
- Tags from Calibre are automatically imported
|
||||||
- You can add custom tags when editing book metadata
|
- You can add custom tags when editing book metadata
|
||||||
- Tags are case-insensitive for searching
|
- Tags use fuzzy matching - "Sci Fi" will match "Science Fiction"
|
||||||
- Autocomplete shows existing tags as you type
|
- Best matching tags appear first in results
|
||||||
|
- Autocomplete shows existing tags as you type with relevance scores
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -570,9 +602,11 @@ You can combine tags with other filters:
|
|||||||
|
|
||||||
### Commit 1: Backend SQL Layer
|
### Commit 1: Backend SQL Layer
|
||||||
```
|
```
|
||||||
feat: add tags_filter to search query
|
feat: add fuzzy tags_filter to search query
|
||||||
|
|
||||||
- Add tags_filter parameter to SearchMediaItemsUnified
|
- Add fuzzy tags_filter parameter to SearchMediaItemsUnified
|
||||||
|
- Add EXISTS clause with word_similarity() for fuzzy tag matching
|
||||||
|
- Add tag similarity scoring to ORDER BY clause
|
||||||
- Add SearchTagsValues query for autocomplete
|
- Add SearchTagsValues query for autocomplete
|
||||||
- Keep genre_filter for backward compatibility
|
- Keep genre_filter for backward compatibility
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user