Merge pull request 'dev→main: gallery similarity search (Phase 3) + UI/mobile polish' (#63) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
CI / backend-lint-and-test (push) Successful in 12s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 21s
CI / integration (push) Successful in 2m56s
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
CI / backend-lint-and-test (push) Successful in 12s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 21s
CI / integration (push) Successful in 2m56s
This commit was merged in pull request #63.
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
"""image_record.siglip_embedding: HNSW cosine index for "more like this"
|
||||
|
||||
Revision ID: 0036
|
||||
Revises: 0035
|
||||
Create Date: 2026-06-04
|
||||
|
||||
Gallery Phase 3 (visual similarity search) ranks images by
|
||||
`siglip_embedding.cosine_distance(source_embedding)`. Without an index that's
|
||||
a sequential scan computing a 1152-dim distance for every row — fine at small
|
||||
scale, but it grows linearly with the library. Add an HNSW index with
|
||||
`vector_cosine_ops` so the top-N nearest search is sub-50ms ANN.
|
||||
|
||||
1152 dims is under pgvector's 2000-dim HNSW limit, so HNSW (no training,
|
||||
better recall than IVFFlat) is the right choice. ONE-TIME COST: building the
|
||||
index over the existing embeddings (~57k vectors on the operator's library)
|
||||
locks image_record for ~30-60s during this migration on deploy — acceptable
|
||||
for a single-operator homelab. NULL embeddings (videos / not-yet-embedded
|
||||
rows) are simply not indexed.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0036"
|
||||
down_revision: Union[str, None] = "0035"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Raw SQL: alembic's create_index doesn't express the `USING hnsw (...
|
||||
# vector_cosine_ops)` access-method + opclass cleanly. Must match the
|
||||
# query's cosine_distance operator class to be usable by the planner.
|
||||
op.execute(
|
||||
"CREATE INDEX ix_image_record_siglip_hnsw "
|
||||
"ON image_record USING hnsw (siglip_embedding vector_cosine_ops)"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_image_record_siglip_hnsw", table_name="image_record")
|
||||
+46
-14
@@ -10,6 +10,21 @@ from ..services.gallery_service import GalleryService
|
||||
gallery_bp = Blueprint("gallery", __name__, url_prefix="/api/gallery")
|
||||
|
||||
|
||||
def _image_json(i):
|
||||
"""Serialize a GalleryImage for the scroll/similar list responses."""
|
||||
return {
|
||||
"id": i.id,
|
||||
"sha256": i.sha256,
|
||||
"mime": i.mime,
|
||||
"width": i.width,
|
||||
"height": i.height,
|
||||
"created_at": i.created_at.isoformat(),
|
||||
"posted_at": i.posted_at.isoformat() if i.posted_at else None,
|
||||
"thumbnail_url": i.thumbnail_url,
|
||||
"artist": i.artist,
|
||||
}
|
||||
|
||||
|
||||
def _parse_date(raw):
|
||||
"""Parse a YYYY-MM-DD query value to a UTC midnight datetime, or None.
|
||||
Raises ValueError (→ 400) on a malformed value."""
|
||||
@@ -76,20 +91,7 @@ async def scroll():
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"id": i.id,
|
||||
"sha256": i.sha256,
|
||||
"mime": i.mime,
|
||||
"width": i.width,
|
||||
"height": i.height,
|
||||
"created_at": i.created_at.isoformat(),
|
||||
"posted_at": i.posted_at.isoformat() if i.posted_at else None,
|
||||
"thumbnail_url": i.thumbnail_url,
|
||||
"artist": i.artist,
|
||||
}
|
||||
for i in page.images
|
||||
],
|
||||
"images": [_image_json(i) for i in page.images],
|
||||
"next_cursor": page.next_cursor,
|
||||
"date_groups": [
|
||||
{"year": y, "month": m, "image_ids": ids} for y, m, ids in page.date_groups
|
||||
@@ -98,6 +100,36 @@ async def scroll():
|
||||
)
|
||||
|
||||
|
||||
@gallery_bp.route("/similar", methods=["GET"])
|
||||
async def similar():
|
||||
"""Visual "more like this": images ranked by cosine distance to the
|
||||
`similar_to` image's embedding. Composes with the scope filters (AND) but
|
||||
ignores post_id and sort. Bounded top-N, no cursor."""
|
||||
try:
|
||||
similar_to = int(request.args["similar_to"])
|
||||
limit = int(request.args.get("limit", "100"))
|
||||
filters, _sort = _parse_filters()
|
||||
except (KeyError, ValueError):
|
||||
return jsonify({"error": "similar_to query param required"}), 400
|
||||
# post_id is the exclusive post-detail view — not a similarity scope.
|
||||
scope = {k: v for k, v in filters.items() if k != "post_id"}
|
||||
async with get_session() as session:
|
||||
svc = GalleryService(session)
|
||||
try:
|
||||
images = await svc.similar(image_id=similar_to, limit=limit, **scope)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
if images is None:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
return jsonify(
|
||||
{
|
||||
"images": [_image_json(i) for i in images],
|
||||
"next_cursor": None,
|
||||
"date_groups": [],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@gallery_bp.route("/timeline", methods=["GET"])
|
||||
async def timeline():
|
||||
try:
|
||||
|
||||
@@ -245,6 +245,27 @@ def _provenance_clause(post_id, artist_id):
|
||||
return None
|
||||
|
||||
|
||||
def _gallery_images(rows, artists: dict[int, dict]) -> list[GalleryImage]:
|
||||
"""Build GalleryImage list from (record, posted_at, eff_date) rows + the
|
||||
artist hydration map. Shared by scroll() and similar()."""
|
||||
return [
|
||||
GalleryImage(
|
||||
id=record.id,
|
||||
path=record.path,
|
||||
sha256=record.sha256,
|
||||
mime=record.mime,
|
||||
width=record.width,
|
||||
height=record.height,
|
||||
created_at=record.created_at,
|
||||
effective_date=eff_date,
|
||||
posted_at=posted_at,
|
||||
thumbnail_url=thumbnail_url(record.thumbnail_path, record.sha256, record.mime),
|
||||
artist=artists.get(record.id),
|
||||
)
|
||||
for record, posted_at, eff_date in rows
|
||||
]
|
||||
|
||||
|
||||
async def _artists_for(session, image_ids: list[int]) -> dict[int, dict]:
|
||||
"""Map image_id -> {"name","slug"} via the canonical
|
||||
image_record.artist_id (FC-2d-vii-c). Bounded by page size."""
|
||||
@@ -324,22 +345,7 @@ class GalleryService:
|
||||
artists = await _artists_for(
|
||||
self.session, [r[0].id for r in rows]
|
||||
)
|
||||
images = [
|
||||
GalleryImage(
|
||||
id=record.id,
|
||||
path=record.path,
|
||||
sha256=record.sha256,
|
||||
mime=record.mime,
|
||||
width=record.width,
|
||||
height=record.height,
|
||||
created_at=record.created_at,
|
||||
effective_date=eff_date,
|
||||
posted_at=posted_at,
|
||||
thumbnail_url=thumbnail_url(record.thumbnail_path, record.sha256, record.mime),
|
||||
artist=artists.get(record.id),
|
||||
)
|
||||
for record, posted_at, eff_date in rows
|
||||
]
|
||||
images = _gallery_images(rows, artists)
|
||||
return GalleryPage(
|
||||
images=images,
|
||||
next_cursor=next_cursor,
|
||||
@@ -505,6 +511,49 @@ class GalleryService:
|
||||
date_min=dmin, date_max=dmax,
|
||||
)
|
||||
|
||||
async def similar(
|
||||
self, image_id: int, limit: int = 100, *,
|
||||
tag_ids: list[int] | None = None, artist_id: int | None = None,
|
||||
media_type: str | None = None, platform: str | None = None,
|
||||
untagged: bool = False, no_artist: bool = False,
|
||||
date_from: datetime | None = None, date_to: datetime | None = None,
|
||||
) -> list[GalleryImage] | None:
|
||||
"""Visual "more like this": images ranked by cosine distance to
|
||||
`image_id`'s SigLIP embedding (pgvector, HNSW-indexed — alembic 0036).
|
||||
No ML inference here; the embedding was computed at import.
|
||||
|
||||
Returns None if the source image doesn't exist (→ 404), [] if it has
|
||||
no embedding (a video / not-yet-embedded). Composes with the Phase-1/2
|
||||
scope filters (AND) but REPLACES the date sort — always nearest-first,
|
||||
bounded to `limit` (no cursor; distance-ranking has no date cursor).
|
||||
"""
|
||||
if limit < 1 or limit > 200:
|
||||
raise ValueError("limit must be between 1 and 200")
|
||||
src = await self.session.get(ImageRecord, image_id)
|
||||
if src is None:
|
||||
return None
|
||||
if src.siglip_embedding is None:
|
||||
return []
|
||||
|
||||
distance = ImageRecord.siglip_embedding.cosine_distance(src.siglip_embedding)
|
||||
eff = _effective_date_col()
|
||||
stmt = select(ImageRecord, Post.post_date, eff.label("eff"))
|
||||
stmt = _outer_join_primary_post(stmt)
|
||||
stmt = stmt.where(
|
||||
ImageRecord.siglip_embedding.is_not(None),
|
||||
ImageRecord.id != image_id,
|
||||
)
|
||||
stmt = _apply_scope(
|
||||
stmt, tag_ids=tag_ids, post_id=None,
|
||||
artist_id=artist_id, media_type=media_type,
|
||||
platform=platform, untagged=untagged, no_artist=no_artist,
|
||||
date_from=date_from, date_to=date_to,
|
||||
)
|
||||
stmt = stmt.order_by(distance.asc()).limit(limit)
|
||||
rows = (await self.session.execute(stmt)).all()
|
||||
artists = await _artists_for(self.session, [r[0].id for r in rows])
|
||||
return _gallery_images(rows, artists)
|
||||
|
||||
async def get_image_with_tags(self, image_id: int) -> dict | None:
|
||||
record = await self.session.get(ImageRecord, image_id)
|
||||
if record is None:
|
||||
@@ -542,6 +591,9 @@ class GalleryService:
|
||||
"height": record.height,
|
||||
"size_bytes": record.size_bytes,
|
||||
"integrity_status": record.integrity_status,
|
||||
# Phase 3: lets the modal hide the "Related"/find-similar surface
|
||||
# for images that have no embedding yet (videos / pending ML).
|
||||
"has_embedding": record.siglip_embedding is not None,
|
||||
"created_at": record.created_at.isoformat(),
|
||||
"posted_at": posted_at.isoformat() if posted_at else None,
|
||||
"thumbnail_url": thumbnail_url(record.thumbnail_path, record.sha256, record.mime),
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
<PipelineStatusChip />
|
||||
</div>
|
||||
|
||||
<!-- Desktop: inline links, centered. Hidden on mobile (see media query),
|
||||
where they fold into the hamburger menu on the right. -->
|
||||
<nav class="fc-links">
|
||||
<RouterLink
|
||||
v-for="r in navRoutes"
|
||||
@@ -20,9 +22,31 @@
|
||||
>{{ r.meta.title }}</RouterLink>
|
||||
</nav>
|
||||
|
||||
<!-- Per-view contextual actions teleport here (Showcase: Shuffle,
|
||||
Gallery: Select). TopNav owns the slot, not its contents. -->
|
||||
<div id="fc-nav-actions" class="fc-nav-actions" />
|
||||
<div class="fc-nav-right">
|
||||
<!-- Per-view contextual actions teleport here (Showcase: Shuffle,
|
||||
Gallery: Select). TopNav owns the slot, not its contents. -->
|
||||
<div id="fc-nav-actions" class="fc-nav-actions" />
|
||||
|
||||
<!-- Mobile nav: the link row collides with brand + actions below ~768px
|
||||
(7+ links in one flex row), so collapse it into a menu. -->
|
||||
<v-menu location="bottom end">
|
||||
<template #activator="{ props: menuProps }">
|
||||
<v-btn
|
||||
v-bind="menuProps"
|
||||
icon="mdi-menu" variant="text" size="small"
|
||||
class="fc-nav-burger" aria-label="Menu"
|
||||
/>
|
||||
</template>
|
||||
<v-list density="compact" min-width="180">
|
||||
<v-list-item
|
||||
v-for="r in navRoutes"
|
||||
:key="r.name"
|
||||
:to="{ name: r.name }"
|
||||
:title="r.meta.title"
|
||||
/>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
@@ -137,7 +161,7 @@ const health = computed(() => {
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.fc-nav-actions {
|
||||
.fc-nav-right {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
@@ -145,4 +169,22 @@ const health = computed(() => {
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.fc-nav-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
/* The hamburger only exists on mobile; the inline links carry desktop. */
|
||||
.fc-nav-burger { display: none; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.fc-topnav { gap: 0.5rem; padding: 0.6rem 0.75rem; }
|
||||
/* Fold the link row into the hamburger menu. */
|
||||
.fc-links { display: none; }
|
||||
.fc-nav-burger { display: inline-flex; }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
/* Reclaim width on the smallest phones — the glyph alone still brands. */
|
||||
.fc-brand__text { display: none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -186,7 +186,9 @@ async function onDeleteConfirm(token) {
|
||||
<style scoped>
|
||||
.fc-bulk-panel {
|
||||
position: fixed; top: 0; right: 0;
|
||||
width: 320px; height: 100vh; z-index: 1100;
|
||||
/* min(320px, 90vw): on phones the panel never swallows the whole screen —
|
||||
leaves a sliver of the gallery visible behind it. */
|
||||
width: min(320px, 90vw); height: 100vh; z-index: 1100;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
border-left: 1px solid rgb(var(--v-theme-surface-light));
|
||||
box-shadow: -2px 0 16px rgba(0, 0, 0, 0.4);
|
||||
|
||||
@@ -143,4 +143,11 @@ onBeforeUnmount(() => { if (debounce) clearTimeout(debounce) })
|
||||
.fc-facets__empty { color: rgb(var(--v-theme-on-surface-variant)); font-size: 0.8rem; }
|
||||
.fc-facets__date { max-width: 160px; }
|
||||
.fc-facets__dash { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||
|
||||
/* Phones: let each facet group wrap and the side-by-side date inputs grow to
|
||||
full width so they don't overflow a ~360px viewport. */
|
||||
@media (max-width: 480px) {
|
||||
.fc-facets__group { flex-wrap: wrap; }
|
||||
.fc-facets__date { max-width: none; flex: 1 1 9rem; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -38,6 +38,12 @@
|
||||
prepend-icon="mdi-account"
|
||||
@click:close="clearArtist"
|
||||
>{{ store.artistLabel || `Artist #${store.filter.artist_id}` }}</v-chip>
|
||||
<v-chip
|
||||
v-if="store.filter.similar_to"
|
||||
size="small" closable color="accent" variant="tonal"
|
||||
prepend-icon="mdi-image-multiple"
|
||||
@click:close="clearSimilar"
|
||||
>Similar to #{{ store.filter.similar_to }}</v-chip>
|
||||
</div>
|
||||
|
||||
<v-spacer />
|
||||
@@ -52,7 +58,9 @@
|
||||
<v-btn value="video" size="small">Videos</v-btn>
|
||||
</v-btn-toggle>
|
||||
|
||||
<!-- Sort is meaningless in similar-mode (results are distance-ranked). -->
|
||||
<v-select
|
||||
v-if="!store.filter.similar_to"
|
||||
:model-value="store.filter.sort"
|
||||
:items="SORTS"
|
||||
density="compact" hide-details variant="outlined"
|
||||
@@ -114,6 +122,7 @@ const hasActiveFilters = computed(() =>
|
||||
store.filter.artist_id != null ||
|
||||
store.filter.media_type != null ||
|
||||
store.filter.sort !== 'newest' ||
|
||||
store.filter.similar_to != null ||
|
||||
hasRefineFilters.value
|
||||
)
|
||||
|
||||
@@ -190,6 +199,7 @@ function clearArtist() {
|
||||
store.noteArtistLabel(null)
|
||||
pushFilter((n) => { n.artist_id = null })
|
||||
}
|
||||
function clearSimilar() { pushFilter((n) => { n.similar_to = null }) }
|
||||
function setMedia(m) { pushFilter((n) => { n.media_type = m }) }
|
||||
function setSort(s) { pushFilter((n) => { n.sort = s }) }
|
||||
function clearAll() { router.push({ name: 'gallery', query: {} }) }
|
||||
@@ -207,7 +217,7 @@ function pushFilter(mutate) {
|
||||
|
||||
<style scoped>
|
||||
/* The whole chrome (bar row + expandable refine panel) is one sticky,
|
||||
hazey block pinned directly under the 64px TopNav and continuous with it. */
|
||||
frosted block pinned directly under the 64px TopNav and continuous with it. */
|
||||
.fc-filterbar-wrap {
|
||||
position: sticky;
|
||||
top: 64px;
|
||||
@@ -217,9 +227,18 @@ function pushFilter(mutate) {
|
||||
and a gap shows through when scrolled to the top. */
|
||||
margin-top: -8px;
|
||||
margin-bottom: 12px;
|
||||
/* Same hazey obsidian (#14171A = 20,23,26) + blur as the TopNav so the two
|
||||
read as one piece of chrome; content scrolls under both. */
|
||||
background: rgba(20, 23, 26, 0.55);
|
||||
/* EXACT same gradiated obsidian (#14171A = 20,23,26) frost as the TopNav so
|
||||
the two read as one continuous piece of chrome — images scroll visibly
|
||||
under both. The nav's gradient fades to transparent at ITS bottom; this
|
||||
bar re-darkens at its top, so a faint seam (the page/image showing through
|
||||
the nav's transparent edge) separates them when scrolled to the very top,
|
||||
while under-scroll they frost as one. */
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(20, 23, 26, 0.92) 0%,
|
||||
rgba(20, 23, 26, 0.65) 60%,
|
||||
rgba(20, 23, 26, 0) 100%
|
||||
);
|
||||
backdrop-filter: blur(2px);
|
||||
-webkit-backdrop-filter: blur(2px);
|
||||
}
|
||||
@@ -239,4 +258,12 @@ function pushFilter(mutate) {
|
||||
.fc-filterbar__search { max-width: 320px; min-width: 200px; }
|
||||
.fc-filterbar__chips { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
.fc-filterbar__sort { max-width: 150px; }
|
||||
|
||||
/* Phones: the search's 200px min-width jams the wrapping bar. Give search its
|
||||
own full-width row and let sort grow; everything else wraps under it. */
|
||||
@media (max-width: 600px) {
|
||||
.fc-filterbar { gap: 8px; }
|
||||
.fc-filterbar__search { min-width: 100%; max-width: none; }
|
||||
.fc-filterbar__sort { max-width: none; flex: 1 1 auto; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -51,6 +51,9 @@
|
||||
<aside v-if="modal.current" class="fc-viewer__side">
|
||||
<ProvenancePanel />
|
||||
<TagPanel />
|
||||
<!-- Non-blocking: fetches its own similar set after the modal is up;
|
||||
collapses silently if empty/slow/failed (see RelatedStrip). -->
|
||||
<RelatedStrip />
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
@@ -65,6 +68,7 @@ import ImageCanvas from './ImageCanvas.vue'
|
||||
import VideoCanvas from './VideoCanvas.vue'
|
||||
import TagPanel from './TagPanel.vue'
|
||||
import ProvenancePanel from './ProvenancePanel.vue'
|
||||
import RelatedStrip from './RelatedStrip.vue'
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<!-- Collapses entirely unless the source has an embedding AND we're loading
|
||||
or have results — so a slow/empty/failed fetch leaves no trace and never
|
||||
affects the modal. -->
|
||||
<div v-if="show" class="fc-related">
|
||||
<div class="fc-related__head">
|
||||
<span class="fc-related__title">Related</span>
|
||||
<v-btn
|
||||
size="x-small" variant="text" color="accent"
|
||||
:disabled="loading || !results.length"
|
||||
@click="seeAll"
|
||||
>See all similar</v-btn>
|
||||
</div>
|
||||
<div class="fc-related__row">
|
||||
<template v-if="loading">
|
||||
<div v-for="n in 6" :key="n" class="fc-related__skel" />
|
||||
</template>
|
||||
<button
|
||||
v-for="img in results" :key="img.id"
|
||||
class="fc-related__item" type="button"
|
||||
@click="openImage(img.id)"
|
||||
>
|
||||
<img :src="img.thumbnail_url" :alt="`image ${img.id}`" loading="lazy" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useApi } from '../../composables/useApi.js'
|
||||
import { useModalStore } from '../../stores/modal.js'
|
||||
|
||||
// Deferred so the modal's main image gets network/decode priority — the strip
|
||||
// is a nice-to-have and must never block or slow the modal load.
|
||||
const DEFER_MS = 200
|
||||
const STRIP_LIMIT = 12
|
||||
|
||||
const api = useApi()
|
||||
const router = useRouter()
|
||||
const modal = useModalStore()
|
||||
|
||||
const results = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
const hasEmbedding = computed(() => modal.current?.has_embedding === true)
|
||||
const show = computed(() => hasEmbedding.value && (loading.value || results.value.length > 0))
|
||||
|
||||
let seq = 0
|
||||
let timer = null
|
||||
|
||||
async function fetchSimilar(id) {
|
||||
const mine = ++seq
|
||||
loading.value = true
|
||||
results.value = []
|
||||
try {
|
||||
const body = await api.get('/api/gallery/similar', {
|
||||
params: { similar_to: id, limit: STRIP_LIMIT },
|
||||
})
|
||||
if (mine !== seq) return
|
||||
results.value = body.images || []
|
||||
} catch {
|
||||
if (mine === seq) results.value = [] // quietly collapse on error
|
||||
} finally {
|
||||
if (mine === seq) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Re-fetch whenever the modal lands on a new embedded image. modal.current is
|
||||
// null while the next image loads, so this also clears the strip during
|
||||
// prev/next nav and repopulates once the new payload arrives.
|
||||
watch(
|
||||
() => (hasEmbedding.value ? modal.current?.id : null),
|
||||
(id) => {
|
||||
if (timer) { clearTimeout(timer); timer = null }
|
||||
seq++ // cancel any in-flight fetch
|
||||
results.value = []
|
||||
loading.value = false
|
||||
if (!id) return
|
||||
timer = setTimeout(() => fetchSimilar(id), DEFER_MS)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
onBeforeUnmount(() => { if (timer) clearTimeout(timer) })
|
||||
|
||||
function openImage(id) {
|
||||
modal.open(id)
|
||||
}
|
||||
|
||||
function seeAll() {
|
||||
const id = modal.current?.id
|
||||
if (!id) return
|
||||
modal.close()
|
||||
router.push({ name: 'gallery', query: { similar_to: String(id) } })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-related {
|
||||
padding: 12px;
|
||||
border-top: 1px solid rgb(var(--v-theme-surface-light));
|
||||
}
|
||||
.fc-related__head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.fc-related__title {
|
||||
font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.06em;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
.fc-related__row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
.fc-related__item {
|
||||
display: block; padding: 0; border: 0; background: none;
|
||||
cursor: pointer; border-radius: 4px; overflow: hidden;
|
||||
aspect-ratio: 1; width: 100%;
|
||||
}
|
||||
.fc-related__item img {
|
||||
width: 100%; height: 100%; object-fit: cover; display: block;
|
||||
background: rgb(var(--v-theme-surface-light));
|
||||
transition: transform 0.2s ease, filter 0.2s ease;
|
||||
}
|
||||
.fc-related__item:hover img { transform: scale(1.05); filter: brightness(1.1); }
|
||||
.fc-related__skel {
|
||||
aspect-ratio: 1; border-radius: 4px;
|
||||
background: rgb(var(--v-theme-surface-light));
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
@@ -13,7 +13,7 @@
|
||||
clearable
|
||||
no-filter
|
||||
return-object
|
||||
style="min-width: 240px"
|
||||
class="fc-posts-filters__artist"
|
||||
@update:search="onArtistSearch"
|
||||
@update:model-value="onArtistPicked"
|
||||
/>
|
||||
@@ -25,7 +25,7 @@
|
||||
density="compact"
|
||||
hide-details
|
||||
clearable
|
||||
style="min-width: 180px"
|
||||
class="fc-posts-filters__platform"
|
||||
@update:model-value="emitFilters"
|
||||
/>
|
||||
|
||||
@@ -135,6 +135,14 @@ watch(() => props.platform, (val) => {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
.fc-posts-filters__artist { min-width: 240px; flex: 1 1 240px; max-width: 360px; }
|
||||
.fc-posts-filters__platform { min-width: 180px; flex: 1 1 180px; max-width: 240px; }
|
||||
/* Phones: each field takes a full-width row instead of overflowing. */
|
||||
@media (max-width: 600px) {
|
||||
.fc-posts-filters__artist,
|
||||
.fc-posts-filters__platform { min-width: 100%; max-width: none; flex-basis: 100%; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -26,14 +26,14 @@
|
||||
:items="STATUS_OPTIONS"
|
||||
:disabled="needsAttention"
|
||||
density="compact" variant="outlined" hide-details
|
||||
style="max-width: 180px"
|
||||
class="fc-subs__status"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="search"
|
||||
density="compact" variant="outlined" hide-details clearable
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
placeholder="Search subscriptions"
|
||||
style="max-width: 320px"
|
||||
class="fc-subs__search"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -568,6 +568,14 @@ async function bulkDelete() {
|
||||
padding: 8px 0 1rem;
|
||||
}
|
||||
.fc-subs__bar .v-chip { cursor: pointer; }
|
||||
.fc-subs__status { max-width: 180px; }
|
||||
.fc-subs__search { max-width: 320px; flex: 1 1 200px; }
|
||||
/* Phones: status + search each take a full-width row; drop the spacer that
|
||||
would otherwise eat a row pushing them around. */
|
||||
@media (max-width: 600px) {
|
||||
.fc-subs__status, .fc-subs__search { max-width: none; flex-basis: 100%; }
|
||||
.fc-subs__bar :deep(.v-spacer) { display: none; }
|
||||
}
|
||||
.fc-subs__loading, .fc-subs__empty {
|
||||
display: flex; justify-content: center; padding: 2rem;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
|
||||
@@ -27,6 +27,9 @@ export const useGalleryStore = defineStore('gallery', () => {
|
||||
// Phase-2 faceted refine params.
|
||||
platform: null, untagged: false, no_artist: false,
|
||||
date_from: null, date_to: null,
|
||||
// Phase-3 visual similarity: when set, the gallery is in "similar mode" —
|
||||
// ranked by cosine distance to this image, bounded top-N, no cursor.
|
||||
similar_to: null,
|
||||
})
|
||||
|
||||
// Live facet counts for the refine panel; fetched on-demand (panel open +
|
||||
@@ -84,6 +87,39 @@ export const useGalleryStore = defineStore('gallery', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Visual "more like this": ranked top-N by cosine distance, scope filters
|
||||
// composed (AND). No cursor / no timeline — bounded result set.
|
||||
async function loadSimilar() {
|
||||
inflight.cancel()
|
||||
images.value = []
|
||||
dateGroups.value = []
|
||||
nextCursor.value = null
|
||||
timelineBuckets.value = []
|
||||
loading.value = true
|
||||
error.value = null
|
||||
const t = inflight.claim()
|
||||
try {
|
||||
const f = filter.value
|
||||
const params = { similar_to: f.similar_to, limit: 100 }
|
||||
if (f.tag_ids.length) params.tag_id = f.tag_ids.join(',')
|
||||
if (f.artist_id) params.artist_id = f.artist_id
|
||||
if (f.media_type) params.media = f.media_type
|
||||
if (f.platform) params.platform = f.platform
|
||||
if (f.untagged) params.untagged = '1'
|
||||
if (f.no_artist) params.no_artist = '1'
|
||||
if (f.date_from) params.date_from = f.date_from
|
||||
if (f.date_to) params.date_to = f.date_to
|
||||
const body = await api.get('/api/gallery/similar', { params })
|
||||
if (!t.isCurrent()) return
|
||||
images.value = body.images
|
||||
// ranked + bounded → no next page (nextCursor stays null → hasMore false)
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
if (t.isCurrent()) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function jumpTo(year, month) {
|
||||
// Rapid timeline-jump clicks need the same race guard as
|
||||
// loadMore — first jump's late body could clobber the second
|
||||
@@ -150,9 +186,15 @@ export const useGalleryStore = defineStore('gallery', () => {
|
||||
no_artist: _truthy(q.no_artist),
|
||||
date_from: _parseDate(q.date_from),
|
||||
date_to: _parseDate(q.date_to),
|
||||
similar_to: _toId(q.similar_to),
|
||||
}
|
||||
if (filter.value.similar_to) {
|
||||
// Similar mode: ranked, no timeline scroll.
|
||||
await loadSimilar()
|
||||
} else {
|
||||
await loadInitial()
|
||||
await loadTimeline()
|
||||
}
|
||||
await loadInitial()
|
||||
await loadTimeline()
|
||||
_resolveLabels()
|
||||
}
|
||||
|
||||
@@ -200,7 +242,7 @@ export const useGalleryStore = defineStore('gallery', () => {
|
||||
images, dateGroups, hasMore, isEmpty, loading, error,
|
||||
filter, tagLabels, artistLabel, timelineBuckets, timelineLoading,
|
||||
facets, facetsLoading,
|
||||
loadInitial, loadMore, loadTimeline, jumpTo, loadFacets,
|
||||
loadInitial, loadMore, loadTimeline, jumpTo, loadFacets, loadSimilar,
|
||||
applyFilterFromQuery, noteTagLabel, noteArtistLabel,
|
||||
}
|
||||
})
|
||||
@@ -213,6 +255,7 @@ export function cloneFilter(f) {
|
||||
tag_ids: [...f.tag_ids], artist_id: f.artist_id, media_type: f.media_type,
|
||||
sort: f.sort, platform: f.platform, untagged: f.untagged,
|
||||
no_artist: f.no_artist, date_from: f.date_from, date_to: f.date_to,
|
||||
similar_to: f.similar_to,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,6 +270,7 @@ export function filterToQuery(f) {
|
||||
if (f.no_artist) q.no_artist = '1'
|
||||
if (f.date_from) q.date_from = f.date_from
|
||||
if (f.date_to) q.date_to = f.date_to
|
||||
if (f.similar_to) q.similar_to = String(f.similar_to)
|
||||
return q
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,8 @@ import { preloadImage } from '../utils/preloadImage.js'
|
||||
// fetch + decode (a cold TABLESAMPLE is a separate, query-side concern);
|
||||
// everything after it is buffer-smoothed.
|
||||
const PAGE = 3
|
||||
const CADENCE_MS = 80 // floor between fully-loaded reveals
|
||||
const CADENCE_MS = 160 // floor between fully-loaded reveals (doubled
|
||||
// 2026-06-04 — slower, more deliberate cadence)
|
||||
const PRIME = 6 // items buffered before the drain starts
|
||||
const BUFFER_TARGET = 30 // producer tops the queue up to this
|
||||
const BUFFER_MIN = 12 // ...and refills once the queue dips below this
|
||||
|
||||
@@ -76,7 +76,9 @@ onMounted(async () => {
|
||||
}
|
||||
.fc-artists__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(440px, 1fr));
|
||||
/* min(440px, 100%) so a card never exceeds the viewport — on phones the
|
||||
min-track collapses to 100% (single column) instead of overflowing. */
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(440px, 100%), 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.fc-artists__sentinel {
|
||||
|
||||
@@ -16,7 +16,10 @@
|
||||
<EmptyState v-if="store.isEmpty" />
|
||||
<GalleryGrid v-else @open="openImage" />
|
||||
</div>
|
||||
<TimelineSidebar v-if="store.images.length > 0" class="fc-gallery-layout__sidebar" />
|
||||
<TimelineSidebar
|
||||
v-if="store.images.length > 0 && store.filter.similar_to == null"
|
||||
class="fc-gallery-layout__sidebar"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<BulkEditorPanel />
|
||||
@@ -68,4 +71,9 @@ function openImage(id) {
|
||||
.fc-gallery-layout { flex-direction: column-reverse; }
|
||||
.fc-gallery-layout__sidebar { width: 100%; max-height: 200px; }
|
||||
}
|
||||
/* Phones: the year/month timeline strip eats vertical space and reads poorly
|
||||
as a horizontal band — drop it; the gallery scroll is the primary nav here. */
|
||||
@media (max-width: 600px) {
|
||||
.fc-gallery-layout__sidebar { display: none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
|
||||
import RelatedStrip from '../../src/components/modal/RelatedStrip.vue'
|
||||
import { useModalStore } from '../../src/stores/modal.js'
|
||||
import { freshPinia, mountComponent } from '../support/mountComponent.js'
|
||||
|
||||
function stubFetch(handler) {
|
||||
globalThis.fetch = vi.fn(async (url) => {
|
||||
const { status, body } = handler(url)
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status, statusText: String(status),
|
||||
text: async () => JSON.stringify(body),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('RelatedStrip (modal "more like this")', () => {
|
||||
beforeEach(() => vi.useFakeTimers())
|
||||
afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks() })
|
||||
|
||||
it('stays hidden and never fetches when the image has no embedding', async () => {
|
||||
const pinia = freshPinia()
|
||||
useModalStore().current = { id: 1, has_embedding: false }
|
||||
const fetchSpy = vi.fn()
|
||||
globalThis.fetch = fetchSpy
|
||||
const w = mountComponent(RelatedStrip, { pinia })
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
expect(fetchSpy).not.toHaveBeenCalled()
|
||||
expect(w.find('.fc-related').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('deferred-fetches and renders similar thumbs for an embedded image', async () => {
|
||||
const pinia = freshPinia()
|
||||
useModalStore().current = { id: 1, has_embedding: true }
|
||||
stubFetch(() => ({
|
||||
status: 200,
|
||||
body: { images: [{ id: 2, thumbnail_url: '/t2' }, { id: 3, thumbnail_url: '/t3' }] },
|
||||
}))
|
||||
const w = mountComponent(RelatedStrip, { pinia })
|
||||
// Nothing before the defer elapses (modal image gets priority).
|
||||
expect(globalThis.fetch).not.toHaveBeenCalled()
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
await flushPromises()
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1)
|
||||
expect(w.findAll('.fc-related__item').length).toBe(2)
|
||||
})
|
||||
|
||||
it('collapses silently when the similar fetch returns nothing', async () => {
|
||||
const pinia = freshPinia()
|
||||
useModalStore().current = { id: 1, has_embedding: true }
|
||||
stubFetch(() => ({ status: 200, body: { images: [] } }))
|
||||
const w = mountComponent(RelatedStrip, { pinia })
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
await flushPromises()
|
||||
expect(w.find('.fc-related').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('clicking a thumb opens that image in the modal', async () => {
|
||||
const pinia = freshPinia()
|
||||
const modal = useModalStore()
|
||||
modal.current = { id: 1, has_embedding: true }
|
||||
const openSpy = vi.spyOn(modal, 'open').mockResolvedValue()
|
||||
stubFetch(() => ({ status: 200, body: { images: [{ id: 7, thumbnail_url: '/t7' }] } }))
|
||||
const w = mountComponent(RelatedStrip, { pinia })
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
await flushPromises()
|
||||
await w.find('.fc-related__item').trigger('click')
|
||||
expect(openSpy).toHaveBeenCalledWith(7)
|
||||
})
|
||||
})
|
||||
@@ -122,6 +122,28 @@ describe('gallery store: composable filter', () => {
|
||||
expect(s.facets.total).toBe(4)
|
||||
})
|
||||
|
||||
it('similar_to routes applyFilterFromQuery to the /similar endpoint, not scroll', async () => {
|
||||
const s = useGalleryStore()
|
||||
const urls = []
|
||||
stubFetch((url) => {
|
||||
urls.push(url)
|
||||
if (url.includes('/api/gallery/similar')) {
|
||||
return { status: 200, body: { images: [{ id: 9 }], next_cursor: null, date_groups: [] } }
|
||||
}
|
||||
return { status: 200, body: EMPTY }
|
||||
})
|
||||
await s.applyFilterFromQuery({ similar_to: '5', tag_id: '3' })
|
||||
expect(s.filter.similar_to).toBe(5)
|
||||
const sim = decodeURIComponent(urls.find((u) => u.includes('/api/gallery/similar')))
|
||||
expect(sim).toContain('similar_to=5')
|
||||
expect(sim).toContain('limit=100')
|
||||
expect(sim).toContain('tag_id=3') // scope composes
|
||||
expect(s.images.map((i) => i.id)).toEqual([9])
|
||||
expect(s.hasMore).toBe(false) // ranked + bounded → no pages
|
||||
expect(urls.some((u) => u.includes('/api/gallery/scroll'))).toBe(false)
|
||||
expect(urls.some((u) => u.includes('/api/gallery/timeline'))).toBe(false)
|
||||
})
|
||||
|
||||
it('loadInitial issues exactly one scroll request at the initial limit', async () => {
|
||||
const s = useGalleryStore()
|
||||
const urls = []
|
||||
@@ -154,6 +176,11 @@ describe('filterToQuery / cloneFilter', () => {
|
||||
expect(q.sort).toBeUndefined() // newest is the default → omitted
|
||||
})
|
||||
|
||||
it('serializes similar_to', () => {
|
||||
expect(filterToQuery({ tag_ids: [], similar_to: 42 }).similar_to).toBe('42')
|
||||
expect(filterToQuery({ tag_ids: [], similar_to: null }).similar_to).toBeUndefined()
|
||||
})
|
||||
|
||||
it('cloneFilter copies refine fields and detaches tag_ids', () => {
|
||||
const orig = {
|
||||
tag_ids: [1], artist_id: 2, media_type: 'image', sort: 'oldest',
|
||||
|
||||
@@ -46,8 +46,8 @@ describe('showcase store: buffered cascade', () => {
|
||||
stubShowcase()
|
||||
const s = useShowcaseStore()
|
||||
const p = s.loadInitial()
|
||||
// Past INITIAL_COUNT (60) × CADENCE (80ms) plus fetch microtasks.
|
||||
await vi.advanceTimersByTimeAsync(6000)
|
||||
// Past INITIAL_COUNT (60) × CADENCE (160ms = 9.6s) plus fetch microtasks.
|
||||
await vi.advanceTimersByTimeAsync(12000)
|
||||
await p
|
||||
expect(s.images.length).toBe(60)
|
||||
const ids = s.images.map((i) => i.id)
|
||||
@@ -65,7 +65,7 @@ describe('showcase store: buffered cascade', () => {
|
||||
// the cadence — a 1s window must not dump the whole buffer at once.
|
||||
expect(early).toBeGreaterThan(0)
|
||||
expect(early).toBeLessThan(60)
|
||||
await vi.advanceTimersByTimeAsync(6000)
|
||||
await vi.advanceTimersByTimeAsync(12000)
|
||||
await p
|
||||
expect(s.images.length).toBe(60)
|
||||
})
|
||||
@@ -80,7 +80,7 @@ describe('showcase store: buffered cascade', () => {
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
expect(s.images.length).toBe(0) // gated on the un-decoded first image
|
||||
release()
|
||||
await vi.advanceTimersByTimeAsync(6000)
|
||||
await vi.advanceTimersByTimeAsync(12000)
|
||||
await p
|
||||
expect(s.images.length).toBe(60) // cascade proceeds once it decodes
|
||||
})
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Phase-3 visual "more like this" — pgvector cosine ranking over the
|
||||
precomputed SigLIP image embeddings. No query-time ML inference."""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.app.models import ImageRecord, Tag, TagKind
|
||||
from backend.app.models.tag import image_tag
|
||||
from backend.app.services.gallery_service import GalleryService
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
def _vec(*head):
|
||||
"""A 1152-dim embedding with the given leading values, rest zero. Cosine
|
||||
distance to _vec(1,0) grows as the 2nd component grows, so callers can
|
||||
order fixtures deterministically by direction."""
|
||||
v = [0.0] * 1152
|
||||
for i, x in enumerate(head):
|
||||
v[i] = float(x)
|
||||
return v
|
||||
|
||||
|
||||
async def _img(db, n, emb):
|
||||
rec = ImageRecord(
|
||||
path=f"/images/sim/{n}.jpg", sha256=f"e{n:063d}",
|
||||
size_bytes=1, mime="image/jpeg", width=1, height=1,
|
||||
origin="imported_filesystem", integrity_status="unknown",
|
||||
siglip_embedding=emb,
|
||||
)
|
||||
base = datetime(2026, 1, 1, 12, 0, tzinfo=UTC)
|
||||
rec.created_at = base - timedelta(minutes=n)
|
||||
rec.effective_date = rec.created_at
|
||||
db.add(rec)
|
||||
await db.flush()
|
||||
return rec
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_similar_ranks_nearest_first(db):
|
||||
src = await _img(db, 1, _vec(1, 0))
|
||||
near = await _img(db, 2, _vec(1, 0.05)) # almost same direction
|
||||
mid = await _img(db, 3, _vec(1, 1)) # 45°
|
||||
far = await _img(db, 4, _vec(0, 1)) # orthogonal
|
||||
svc = GalleryService(db)
|
||||
res = await svc.similar(src.id, limit=10)
|
||||
assert [i.id for i in res] == [near.id, mid.id, far.id] # self excluded
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_similar_excludes_null_embeddings(db):
|
||||
src = await _img(db, 1, _vec(1, 0))
|
||||
have = await _img(db, 2, _vec(1, 0.1))
|
||||
await _img(db, 3, None) # un-embedded (e.g. a video) → excluded
|
||||
svc = GalleryService(db)
|
||||
res = await svc.similar(src.id, limit=10)
|
||||
assert [i.id for i in res] == [have.id]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_similar_source_without_embedding_returns_empty(db):
|
||||
src = await _img(db, 1, None)
|
||||
await _img(db, 2, _vec(1, 0))
|
||||
svc = GalleryService(db)
|
||||
assert await svc.similar(src.id, limit=10) == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_similar_missing_source_returns_none(db):
|
||||
svc = GalleryService(db)
|
||||
assert await svc.similar(99999, limit=10) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_similar_composes_with_tag_filter(db):
|
||||
src = await _img(db, 1, _vec(1, 0))
|
||||
await _img(db, 2, _vec(1, 0.02)) # nearest, but untagged
|
||||
tagged = await _img(db, 3, _vec(1, 0.6)) # farther, but carries the tag
|
||||
tag = Tag(name="t", kind=TagKind.general)
|
||||
db.add(tag)
|
||||
await db.flush()
|
||||
await db.execute(image_tag.insert().values(
|
||||
image_record_id=tagged.id, tag_id=tag.id, source="manual"))
|
||||
svc = GalleryService(db)
|
||||
res = await svc.similar(src.id, limit=10, tag_ids=[tag.id])
|
||||
assert [i.id for i in res] == [tagged.id] # scope AND-narrows the ranked set
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_similar_respects_limit(db):
|
||||
src = await _img(db, 1, _vec(1, 0))
|
||||
for n in range(2, 7):
|
||||
await _img(db, n, _vec(1, 0.1 * n))
|
||||
svc = GalleryService(db)
|
||||
res = await svc.similar(src.id, limit=2)
|
||||
assert len(res) == 2
|
||||
|
||||
|
||||
# --- API ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_similar_endpoint(client, db):
|
||||
src = await _img(db, 1, _vec(1, 0))
|
||||
near = await _img(db, 2, _vec(1, 0.05))
|
||||
await db.commit()
|
||||
resp = await client.get(f"/api/gallery/similar?similar_to={src.id}&limit=10")
|
||||
assert resp.status_code == 200
|
||||
body = await resp.get_json()
|
||||
assert [i["id"] for i in body["images"]] == [near.id]
|
||||
assert body["next_cursor"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_similar_404_when_source_missing(client):
|
||||
resp = await client.get("/api/gallery/similar?similar_to=99999")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_similar_requires_param(client):
|
||||
resp = await client.get("/api/gallery/similar")
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_image_detail_reports_has_embedding(client, db):
|
||||
embedded = await _img(db, 1, _vec(1, 0))
|
||||
plain = await _img(db, 2, None)
|
||||
await db.commit()
|
||||
e = await (await client.get(f"/api/gallery/image/{embedded.id}")).get_json()
|
||||
p = await (await client.get(f"/api/gallery/image/{plain.id}")).get_json()
|
||||
assert e["has_embedding"] is True
|
||||
assert p["has_embedding"] is False
|
||||
Reference in New Issue
Block a user