Compare commits
10
Commits
ext-1.0.6
...
8cf8d2ca4d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8cf8d2ca4d | ||
|
|
94e7d20792 | ||
|
|
fb605af959 | ||
|
|
4c56cf121f | ||
|
|
b1d58bc3b8 | ||
|
|
9564d073b9 | ||
|
|
65386f02a0 | ||
|
|
f87a06a6bd | ||
|
|
5d284aae9f | ||
|
|
af7b5c95e9 |
@@ -258,7 +258,11 @@ jobs:
|
|||||||
# anything else → safety net; shouldn't fire given the `on:`
|
# anything else → safety net; shouldn't fire given the `on:`
|
||||||
# config above. Tag :dev to surface the
|
# config above. Tag :dev to surface the
|
||||||
# unexpected run in the registry.
|
# unexpected run in the registry.
|
||||||
SHORT_SHA="${GITHUB_SHA:0:7}"
|
# POSIX-safe substring (the runner shell is dash/BusyBox sh, not
|
||||||
|
# bash — `${var:0:7}` errors with "Bad substitution"; cut works
|
||||||
|
# everywhere). Operator-flagged 2026-06-01 after first :c-<sha>
|
||||||
|
# main-push build failed at this step.
|
||||||
|
SHORT_SHA=$(printf '%s' "$GITHUB_SHA" | cut -c1-7)
|
||||||
if [ "${GITHUB_REF#refs/tags/}" != "${GITHUB_REF}" ]; then
|
if [ "${GITHUB_REF#refs/tags/}" != "${GITHUB_REF}" ]; then
|
||||||
TAG_NAME="${GITHUB_REF#refs/tags/}"
|
TAG_NAME="${GITHUB_REF#refs/tags/}"
|
||||||
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:${TAG_NAME}" >> "$GITHUB_OUTPUT"
|
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:${TAG_NAME}" >> "$GITHUB_OUTPUT"
|
||||||
@@ -297,7 +301,11 @@ jobs:
|
|||||||
# safety-net dev) including the per-commit :c-<short_sha> tag
|
# safety-net dev) including the per-commit :c-<short_sha> tag
|
||||||
# on main-push per the family release-posture rule. The -ml
|
# on main-push per the family release-posture rule. The -ml
|
||||||
# image follows the same release cadence as the web image.
|
# image follows the same release cadence as the web image.
|
||||||
SHORT_SHA="${GITHUB_SHA:0:7}"
|
# POSIX-safe substring (the runner shell is dash/BusyBox sh, not
|
||||||
|
# bash — `${var:0:7}` errors with "Bad substitution"; cut works
|
||||||
|
# everywhere). Operator-flagged 2026-06-01 after first :c-<sha>
|
||||||
|
# main-push build failed at this step.
|
||||||
|
SHORT_SHA=$(printf '%s' "$GITHUB_SHA" | cut -c1-7)
|
||||||
if [ "${GITHUB_REF#refs/tags/}" != "${GITHUB_REF}" ]; then
|
if [ "${GITHUB_REF#refs/tags/}" != "${GITHUB_REF}" ]; then
|
||||||
TAG_NAME="${GITHUB_REF#refs/tags/}"
|
TAG_NAME="${GITHUB_REF#refs/tags/}"
|
||||||
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:${TAG_NAME}" >> "$GITHUB_OUTPUT"
|
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:${TAG_NAME}" >> "$GITHUB_OUTPUT"
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""drop artist + copyright ml thresholds; lower general default to 0.50
|
||||||
|
|
||||||
|
Revision ID: 0029
|
||||||
|
Revises: 0028
|
||||||
|
Create Date: 2026-06-01
|
||||||
|
|
||||||
|
Operator-flagged 2026-06-01: the view modal's Suggestions panel hides
|
||||||
|
most general-category predictions because the default threshold is
|
||||||
|
0.95. Lowering the default to 0.50 (matches character) so general
|
||||||
|
suggestions surface more aggressively; the value remains tunable in
|
||||||
|
Settings → ML.
|
||||||
|
|
||||||
|
Same change retires two ML suggestion categories whose Tag.kind
|
||||||
|
surfaces are unused:
|
||||||
|
|
||||||
|
- `artist`: retired in FC-2d-vii-c — artist identity is acquisition-
|
||||||
|
derived (image_record.artist_id), never ML-inferred. The threshold
|
||||||
|
column was a leftover from before that retirement.
|
||||||
|
- `copyright`: retired 2026-06-01 — the app uses `fandom` for the
|
||||||
|
franchise/copyright concept (per TagsView.vue's doc comment); no
|
||||||
|
Tag rows of kind=copyright exist, and the threshold column never
|
||||||
|
fed anything user-visible.
|
||||||
|
|
||||||
|
Both columns are dropped from ml_settings; the existing row's
|
||||||
|
suggestion_threshold_general value is bumped from 0.95 to 0.50 iff
|
||||||
|
it's still at the old default, so deployed installs pick up the new
|
||||||
|
UX without overriding any operator tuning.
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
revision: str = "0029"
|
||||||
|
down_revision: Union[str, None] = "0028"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Bump the general threshold for installs still at the old default.
|
||||||
|
op.execute(text(
|
||||||
|
"UPDATE ml_settings "
|
||||||
|
"SET suggestion_threshold_general = 0.50 "
|
||||||
|
"WHERE id = 1 AND suggestion_threshold_general = 0.95"
|
||||||
|
))
|
||||||
|
op.drop_column("ml_settings", "suggestion_threshold_artist")
|
||||||
|
op.drop_column("ml_settings", "suggestion_threshold_copyright")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Restore the columns with their prior defaults. The bump from
|
||||||
|
# 0.95 → 0.50 isn't reversible without remembering whether the
|
||||||
|
# operator had explicitly set 0.95 (unlikely — that was just the
|
||||||
|
# default) so we leave the current general value as-is.
|
||||||
|
from sqlalchemy import Column, Float
|
||||||
|
|
||||||
|
op.add_column(
|
||||||
|
"ml_settings",
|
||||||
|
Column(
|
||||||
|
"suggestion_threshold_artist",
|
||||||
|
Float, nullable=False, server_default="0.30",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"ml_settings",
|
||||||
|
Column(
|
||||||
|
"suggestion_threshold_copyright",
|
||||||
|
Float, nullable=False, server_default="0.50",
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -9,9 +9,7 @@ ml_admin_bp = Blueprint("ml_admin", __name__, url_prefix="/api/ml")
|
|||||||
|
|
||||||
|
|
||||||
_EDITABLE = (
|
_EDITABLE = (
|
||||||
"suggestion_threshold_artist",
|
|
||||||
"suggestion_threshold_character",
|
"suggestion_threshold_character",
|
||||||
"suggestion_threshold_copyright",
|
|
||||||
"suggestion_threshold_general",
|
"suggestion_threshold_general",
|
||||||
"centroid_similarity_threshold",
|
"centroid_similarity_threshold",
|
||||||
"min_reference_images",
|
"min_reference_images",
|
||||||
@@ -28,9 +26,7 @@ async def get_settings():
|
|||||||
).scalar_one()
|
).scalar_one()
|
||||||
return jsonify(
|
return jsonify(
|
||||||
{
|
{
|
||||||
"suggestion_threshold_artist": s.suggestion_threshold_artist,
|
|
||||||
"suggestion_threshold_character": s.suggestion_threshold_character,
|
"suggestion_threshold_character": s.suggestion_threshold_character,
|
||||||
"suggestion_threshold_copyright": s.suggestion_threshold_copyright,
|
|
||||||
"suggestion_threshold_general": s.suggestion_threshold_general,
|
"suggestion_threshold_general": s.suggestion_threshold_general,
|
||||||
"centroid_similarity_threshold": s.centroid_similarity_threshold,
|
"centroid_similarity_threshold": s.centroid_similarity_threshold,
|
||||||
"min_reference_images": s.min_reference_images,
|
"min_reference_images": s.min_reference_images,
|
||||||
|
|||||||
@@ -15,17 +15,14 @@ class MLSettings(Base):
|
|||||||
__table_args__ = (CheckConstraint("id = 1", name="singleton"),)
|
__table_args__ = (CheckConstraint("id = 1", name="singleton"),)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
suggestion_threshold_artist: Mapped[float] = mapped_column(
|
|
||||||
Float, nullable=False, default=0.30
|
|
||||||
)
|
|
||||||
suggestion_threshold_character: Mapped[float] = mapped_column(
|
suggestion_threshold_character: Mapped[float] = mapped_column(
|
||||||
Float, nullable=False, default=0.50
|
Float, nullable=False, default=0.50
|
||||||
)
|
)
|
||||||
suggestion_threshold_copyright: Mapped[float] = mapped_column(
|
# Default lowered 0.95 → 0.50 on 2026-06-01 — operator-flagged that
|
||||||
Float, nullable=False, default=0.50
|
# 0.95 hid most general suggestions. Operator-tunable via Settings →
|
||||||
)
|
# ML if too noisy.
|
||||||
suggestion_threshold_general: Mapped[float] = mapped_column(
|
suggestion_threshold_general: Mapped[float] = mapped_column(
|
||||||
Float, nullable=False, default=0.95
|
Float, nullable=False, default=0.50
|
||||||
)
|
)
|
||||||
centroid_similarity_threshold: Mapped[float] = mapped_column(
|
centroid_similarity_threshold: Mapped[float] = mapped_column(
|
||||||
Float, nullable=False, default=0.55
|
Float, nullable=False, default=0.55
|
||||||
|
|||||||
@@ -48,11 +48,11 @@ class SuggestionService:
|
|||||||
).scalar_one()
|
).scalar_one()
|
||||||
|
|
||||||
def _threshold_for(self, s: MLSettings, category: str) -> float:
|
def _threshold_for(self, s: MLSettings, category: str) -> float:
|
||||||
# 'artist' intentionally absent (FC-2d-vii-c) — falls through to
|
# 'artist' (FC-2d-vii-c) and 'copyright' (2026-06-01) retired;
|
||||||
# the 1.01 "never surfaces" default like any unsurfaced category.
|
# both fall through to the 1.01 "never surfaces" default like any
|
||||||
|
# unsurfaced category.
|
||||||
return {
|
return {
|
||||||
"character": s.suggestion_threshold_character,
|
"character": s.suggestion_threshold_character,
|
||||||
"copyright": s.suggestion_threshold_copyright,
|
|
||||||
"general": s.suggestion_threshold_general,
|
"general": s.suggestion_threshold_general,
|
||||||
}.get(category, 1.01)
|
}.get(category, 1.01)
|
||||||
|
|
||||||
|
|||||||
@@ -38,10 +38,13 @@ STORE_FLOOR = float(os.environ.get("TAGGER_STORE_FLOOR", "0.05"))
|
|||||||
|
|
||||||
# The categories FC-2b surfaces in the UI. Others (meta/rating/year) are
|
# The categories FC-2b surfaces in the UI. Others (meta/rating/year) are
|
||||||
# still stored but the suggestion service filters them out.
|
# still stored but the suggestion service filters them out.
|
||||||
# FC-2d-vii-c: 'artist' retired — artist identity is acquisition-derived
|
# 'artist' retired in FC-2d-vii-c — artist identity is acquisition-derived
|
||||||
# (image_record.artist_id), never ML-inferred. Raw predictions are still
|
# (image_record.artist_id), never ML-inferred. 'copyright' retired
|
||||||
# stored at STORE_FLOOR but artist never surfaces.
|
# 2026-06-01 — operator doesn't use the copyright tag-kind; fandom is
|
||||||
SURFACED_CATEGORIES = {"character", "copyright", "general"}
|
# this app's franchise/series concept (per TagsView.vue's doc comment).
|
||||||
|
# Raw predictions for both categories still get stored at STORE_FLOOR but
|
||||||
|
# don't surface in suggestions.
|
||||||
|
SURFACED_CATEGORIES = {"character", "general"}
|
||||||
|
|
||||||
# ImageNet preprocessing constants (per Camie v2 onnx_inference.py).
|
# ImageNet preprocessing constants (per Camie v2 onnx_inference.py).
|
||||||
_IMAGENET_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)
|
_IMAGENET_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)
|
||||||
|
|||||||
@@ -48,10 +48,11 @@ function onSearch(q) {
|
|||||||
if (!query) { results.value = []; return }
|
if (!query) { results.value = []; return }
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
// Scope the autocomplete to the prediction's category where it maps
|
// Scope the autocomplete to the prediction's category where it
|
||||||
// to a tag kind. 'copyright' has no tag kind; search unscoped there.
|
// maps to a tag kind. Only 'character' surfaces as both a
|
||||||
const kind = ['artist', 'character'].includes(props.category)
|
// suggestion category and a tag kind now ('artist' + 'copyright'
|
||||||
? props.category : null
|
// retired); other categories search unscoped.
|
||||||
|
const kind = props.category === 'character' ? 'character' : null
|
||||||
const params = { q: query, limit: 20 }
|
const params = { q: query, limit: 20 }
|
||||||
if (kind) params.kind = kind
|
if (kind) params.kind = kind
|
||||||
results.value = await api.get('/api/tags/autocomplete', { params })
|
results.value = await api.get('/api/tags/autocomplete', { params })
|
||||||
|
|||||||
@@ -92,7 +92,16 @@ let prevBodyOverflow = null
|
|||||||
// own keystrokes.
|
// own keystrokes.
|
||||||
function onKeyDown(ev) {
|
function onKeyDown(ev) {
|
||||||
if (ev.key === 'Escape') {
|
if (ev.key === 'Escape') {
|
||||||
if (isTextEntry(ev.target)) return
|
// Escape closes the modal even from inside a text input — that's
|
||||||
|
// the universal "get me out of here" expectation, and the
|
||||||
|
// autofocused tag-entry field would otherwise trap focus with no
|
||||||
|
// visible escape (operator-flagged 2026-06-01). EXCEPTION: when a
|
||||||
|
// nested Vuetify overlay is open (v-menu autocomplete dropdown,
|
||||||
|
// FandomPicker v-dialog, per-suggestion 3-dot menu), let that
|
||||||
|
// overlay's own Esc handling fire instead of closing the whole
|
||||||
|
// modal mid-interaction. Vuetify marks open overlays with
|
||||||
|
// `.v-overlay--active`.
|
||||||
|
if (document.querySelector('.v-overlay--active')) return
|
||||||
ev.preventDefault()
|
ev.preventDefault()
|
||||||
emit('close')
|
emit('close')
|
||||||
} else if (ev.key === 'ArrowLeft') {
|
} else if (ev.key === 'ArrowLeft') {
|
||||||
|
|||||||
@@ -10,7 +10,11 @@
|
|||||||
density="compact"
|
density="compact"
|
||||||
>{{ state.error }}</v-alert>
|
>{{ state.error }}</v-alert>
|
||||||
|
|
||||||
<template v-else>
|
<!-- Cards scroll independently of the section title + attachments
|
||||||
|
below them. Cap at ~2.5 cards visible (operator-asked 2026-06-01:
|
||||||
|
keeps the Tags section anchored below at a consistent position;
|
||||||
|
the half-visible third card hints there's more). -->
|
||||||
|
<div v-else class="fc-prov__cards">
|
||||||
<article
|
<article
|
||||||
v-for="e in state.entries" :key="e.provenance_id" class="fc-prov__card"
|
v-for="e in state.entries" :key="e.provenance_id" class="fc-prov__card"
|
||||||
>
|
>
|
||||||
@@ -18,9 +22,13 @@
|
|||||||
<span class="fc-prov__platform">{{ e.source.platform }}</span>
|
<span class="fc-prov__platform">{{ e.source.platform }}</span>
|
||||||
<span v-if="postDate(e)" class="fc-prov__date">{{ postDate(e) }}</span>
|
<span v-if="postDate(e)" class="fc-prov__date">{{ postDate(e) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="fc-prov__post">
|
<button
|
||||||
|
type="button" class="fc-prov__post"
|
||||||
|
:title="`Open ${postTitle(e)} in the posts feed for ${e.artist.name}`"
|
||||||
|
@click="openPost(e.post.id, e.artist.id)"
|
||||||
|
>
|
||||||
{{ postTitle(e) }}
|
{{ postTitle(e) }}
|
||||||
</div>
|
</button>
|
||||||
<div class="fc-prov__meta">
|
<div class="fc-prov__meta">
|
||||||
<RouterLink :to="`/artist/${e.artist.slug}`">
|
<RouterLink :to="`/artist/${e.artist.slug}`">
|
||||||
by {{ e.artist.name }}
|
by {{ e.artist.name }}
|
||||||
@@ -29,12 +37,8 @@
|
|||||||
· {{ e.post.attachment_count }} files
|
· {{ e.post.attachment_count }} files
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="fc-prov__actions">
|
<div v-if="e.post.description_html" class="fc-prov__actions">
|
||||||
<a href="#" @click.prevent="openPost(e.post.id)">
|
|
||||||
View post
|
|
||||||
</a>
|
|
||||||
<a
|
<a
|
||||||
v-if="e.post.description_html"
|
|
||||||
href="#" @click.prevent="toggleDesc(e.provenance_id)"
|
href="#" @click.prevent="toggleDesc(e.provenance_id)"
|
||||||
>{{ expanded[e.provenance_id] ? 'Hide description ▴' : 'Show description ▾' }}</a>
|
>{{ expanded[e.provenance_id] ? 'Hide description ▴' : 'Show description ▾' }}</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -58,7 +62,7 @@
|
|||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</template>
|
</div>
|
||||||
|
|
||||||
<div v-if="attachments.length" class="fc-prov__attach">
|
<div v-if="attachments.length" class="fc-prov__attach">
|
||||||
<h4 class="fc-prov__attach-title">Attachments</h4>
|
<h4 class="fc-prov__attach-title">Attachments</h4>
|
||||||
@@ -137,10 +141,16 @@ function postTitle(e) {
|
|||||||
return toPlainText(e.post.title) || `Post ${e.post.external_post_id}`
|
return toPlainText(e.post.title) || `Post ${e.post.external_post_id}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function openPost(postId) {
|
function openPost(postId, artistId) {
|
||||||
// Land on the post in the posts feed (in context), not the gallery
|
// Land on the post in the posts feed (in context), not the gallery
|
||||||
// image grid. Operator-flagged 2026-05-28.
|
// image grid. Scope the feed to this artist so the user lands in
|
||||||
router.push({ path: '/posts', query: { post_id: postId } })
|
// that creator's stream, not the global one — operator-flagged
|
||||||
|
// 2026-06-01. PostsView reads `artist_id` from the query string
|
||||||
|
// (PostsView.vue line ~92) and filters via post_feed_service.
|
||||||
|
router.push({
|
||||||
|
path: '/posts',
|
||||||
|
query: { post_id: postId, artist_id: artistId },
|
||||||
|
})
|
||||||
modal.close()
|
modal.close()
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -153,6 +163,18 @@ function openPost(postId) {
|
|||||||
color: rgb(var(--v-theme-on-surface));
|
color: rgb(var(--v-theme-on-surface));
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
.fc-prov__cards {
|
||||||
|
/* 2.5 cards-worth at the typical collapsed card height (~108px each
|
||||||
|
incl. 10px gap). Slightly under to ensure the third card's bottom
|
||||||
|
edge is clipped — the visual cue that there's more below. */
|
||||||
|
max-height: 270px;
|
||||||
|
overflow-y: auto;
|
||||||
|
/* Hairline scrollbar that doesn't compete with content. */
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgb(var(--v-theme-surface-light)) transparent;
|
||||||
|
/* Pad-right so the scrollbar gutter doesn't squeeze card borders. */
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
.fc-prov__card {
|
.fc-prov__card {
|
||||||
border: 1px solid rgb(var(--v-theme-surface-light));
|
border: 1px solid rgb(var(--v-theme-surface-light));
|
||||||
border-radius: 6px; padding: 10px 12px; margin-bottom: 10px;
|
border-radius: 6px; padding: 10px 12px; margin-bottom: 10px;
|
||||||
@@ -163,8 +185,21 @@ function openPost(postId) {
|
|||||||
text-transform: lowercase;
|
text-transform: lowercase;
|
||||||
}
|
}
|
||||||
.fc-prov__post {
|
.fc-prov__post {
|
||||||
font-weight: 700; margin: 4px 0;
|
/* Clickable title — opens the post in the artist-scoped feed
|
||||||
color: rgb(var(--v-theme-on-surface));
|
(operator-flagged 2026-06-01: title IS the primary action, the
|
||||||
|
prior "View post" link was redundant). Styled as a button-link:
|
||||||
|
accent color, underline on hover, focus ring for keyboard nav. */
|
||||||
|
display: block; width: 100%; text-align: left;
|
||||||
|
background: none; border: none; padding: 0;
|
||||||
|
font: inherit; font-weight: 700;
|
||||||
|
margin: 4px 0;
|
||||||
|
color: rgb(var(--v-theme-accent));
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.fc-prov__post:hover { text-decoration: underline; }
|
||||||
|
.fc-prov__post:focus-visible {
|
||||||
|
outline: 2px solid rgb(var(--v-theme-accent));
|
||||||
|
outline-offset: 2px; border-radius: 3px;
|
||||||
}
|
}
|
||||||
.fc-prov__meta {
|
.fc-prov__meta {
|
||||||
font-size: 13px; color: rgb(var(--v-theme-on-surface-variant));
|
font-size: 13px; color: rgb(var(--v-theme-on-surface-variant));
|
||||||
|
|||||||
@@ -1,19 +1,33 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<!-- Chip-card row: visible border + hover/focus state unifies the
|
||||||
|
name, score, and action buttons as one "object" (operator-asked
|
||||||
|
2026-06-01). The row itself is informational; the explicit
|
||||||
|
Accept button + 3-dot menu are the action affordances. -->
|
||||||
<div class="fc-suggestion">
|
<div class="fc-suggestion">
|
||||||
<span class="fc-suggestion__name">
|
<span class="fc-suggestion__name">
|
||||||
{{ suggestion.display_name }}
|
{{ suggestion.display_name }}
|
||||||
<span v-if="suggestion.creates_new_tag" class="fc-suggestion__new"
|
<span v-if="suggestion.creates_new_tag" class="fc-suggestion__new"
|
||||||
title="No matching tag yet — accepting creates it">+new</span>
|
title="No matching tag yet — accepting creates it">+ new</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="fc-suggestion__score">{{ scorePct }}</span>
|
<span class="fc-suggestion__score">{{ scorePct }}</span>
|
||||||
<v-btn
|
<v-btn
|
||||||
icon="mdi-plus" size="x-small" variant="text" color="accent"
|
class="fc-suggestion__accept"
|
||||||
|
size="small" variant="tonal" color="accent"
|
||||||
|
density="compact" rounded="pill"
|
||||||
:aria-label="`Accept ${suggestion.display_name}`"
|
:aria-label="`Accept ${suggestion.display_name}`"
|
||||||
@click="$emit('accept', suggestion)"
|
@click="$emit('accept', suggestion)"
|
||||||
/>
|
>
|
||||||
|
Accept
|
||||||
|
</v-btn>
|
||||||
<v-menu>
|
<v-menu>
|
||||||
<template #activator="{ props }">
|
<template #activator="{ props }">
|
||||||
<v-btn icon="mdi-dots-vertical" size="x-small" variant="text" v-bind="props" />
|
<v-btn
|
||||||
|
class="fc-suggestion__menu"
|
||||||
|
icon="mdi-dots-vertical" size="small"
|
||||||
|
variant="outlined" density="compact"
|
||||||
|
:aria-label="`More actions for ${suggestion.display_name}`"
|
||||||
|
v-bind="props"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<v-list density="compact">
|
<v-list density="compact">
|
||||||
<v-list-item @click="$emit('alias', suggestion)">
|
<v-list-item @click="$emit('alias', suggestion)">
|
||||||
@@ -38,17 +52,45 @@ const scorePct = computed(() => `${Math.round(props.suggestion.score * 100)}%`)
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.fc-suggestion {
|
.fc-suggestion {
|
||||||
display: flex; align-items: center; gap: 6px;
|
display: flex; align-items: center; gap: 8px;
|
||||||
padding: 2px 0;
|
padding: 6px 10px; margin-bottom: 4px;
|
||||||
|
background: rgb(var(--v-theme-surface));
|
||||||
|
border: 1px solid rgb(var(--v-theme-surface-light));
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: background 120ms ease, border-color 120ms ease;
|
||||||
|
}
|
||||||
|
.fc-suggestion:hover {
|
||||||
|
background: rgb(var(--v-theme-surface-light));
|
||||||
|
border-color: rgb(var(--v-theme-accent), 0.4);
|
||||||
|
}
|
||||||
|
.fc-suggestion__name {
|
||||||
|
flex: 1; min-width: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: rgb(var(--v-theme-on-surface));
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
}
|
}
|
||||||
.fc-suggestion__name { flex: 1; min-width: 0; }
|
|
||||||
.fc-suggestion__new {
|
.fc-suggestion__new {
|
||||||
font-size: 10px; color: rgb(var(--v-theme-accent));
|
display: inline-block;
|
||||||
margin-left: 4px;
|
font-size: 10px; font-weight: 600;
|
||||||
|
color: rgb(var(--v-theme-accent));
|
||||||
|
background: rgba(var(--v-theme-accent), 0.12);
|
||||||
|
border: 1px solid rgb(var(--v-theme-accent), 0.4);
|
||||||
|
padding: 1px 6px; border-radius: 999px;
|
||||||
|
margin-left: 6px;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.04em;
|
||||||
}
|
}
|
||||||
.fc-suggestion__score {
|
.fc-suggestion__score {
|
||||||
|
flex: 0 0 auto; min-width: 38px; text-align: right;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: rgb(var(--v-theme-on-surface-variant, var(--v-theme-on-surface)));
|
color: rgb(var(--v-theme-on-surface-variant, var(--v-theme-on-surface)));
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
}
|
}
|
||||||
|
/* Vuetify's compact density doesn't shrink the tonal button enough
|
||||||
|
for a tight row; clamp the min-width so Accept stays compact. */
|
||||||
|
.fc-suggestion__accept :deep(.v-btn__content) {
|
||||||
|
font-size: 12px; letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
.fc-suggestion__menu {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
<SuggestionsCategoryGroup
|
<SuggestionsCategoryGroup
|
||||||
v-if="store.byCategory.general && store.byCategory.general.length"
|
v-if="store.byCategory.general && store.byCategory.general.length"
|
||||||
label="General" :items="store.byCategory.general"
|
label="General" :items="store.byCategory.general"
|
||||||
collapsible :default-open="false"
|
collapsible :default-open="true"
|
||||||
@accept="onAccept" @alias="onAlias" @dismiss="store.dismiss"
|
@accept="onAccept" @alias="onAlias" @dismiss="store.dismiss"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
@@ -47,7 +47,10 @@ import AliasPickerDialog from './AliasPickerDialog.vue'
|
|||||||
const props = defineProps({ imageId: { type: Number, required: true } })
|
const props = defineProps({ imageId: { type: Number, required: true } })
|
||||||
const store = useSuggestionsStore()
|
const store = useSuggestionsStore()
|
||||||
|
|
||||||
const peopleCats = ['artist', 'character', 'copyright']
|
// 'artist' (FC-2d-vii-c) and 'copyright' (2026-06-01) retired as
|
||||||
|
// suggestion categories. Only 'character' remains as a people-style
|
||||||
|
// category alongside the general bucket.
|
||||||
|
const peopleCats = ['character']
|
||||||
function labelFor(c) { return CATEGORY_LABELS[c] || c }
|
function labelFor(c) { return CATEGORY_LABELS[c] || c }
|
||||||
|
|
||||||
const isEmpty = computed(() =>
|
const isEmpty = computed(() =>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="fc-tag-autocomplete">
|
<div class="fc-tag-autocomplete">
|
||||||
<v-text-field
|
<v-text-field
|
||||||
|
ref="inputRef"
|
||||||
v-model="query"
|
v-model="query"
|
||||||
placeholder="Add tag (or kind:name — character/fandom/series)"
|
placeholder="Add tag (or kind:name — character/fandom/series)"
|
||||||
density="compact" hide-details
|
density="compact" hide-details
|
||||||
@@ -52,13 +53,21 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||||
import { useTagStore } from '../../stores/tags.js'
|
import { useTagStore } from '../../stores/tags.js'
|
||||||
import FandomPicker from './FandomPicker.vue'
|
import FandomPicker from './FandomPicker.vue'
|
||||||
|
|
||||||
const emit = defineEmits(['pick-existing', 'pick-new', 'cancel'])
|
const emit = defineEmits(['pick-existing', 'pick-new', 'cancel'])
|
||||||
const store = useTagStore()
|
const store = useTagStore()
|
||||||
|
|
||||||
|
// Autofocus on modal open so the operator can type the moment the view
|
||||||
|
// modal renders, no extra click required (operator-asked 2026-06-01).
|
||||||
|
// Vuetify's v-text-field exposes .focus() on the component instance;
|
||||||
|
// nextTick waits for the modal's mount to finish so the inner <input>
|
||||||
|
// element exists.
|
||||||
|
const inputRef = ref(null)
|
||||||
|
onMounted(() => { nextTick(() => inputRef.value?.focus?.()) })
|
||||||
|
|
||||||
// Single text input; no kind dropdown. Client-side mirror of the
|
// Single text input; no kind dropdown. Client-side mirror of the
|
||||||
// backend's parse_kind_prefix lives below — kept in sync with
|
// backend's parse_kind_prefix lives below — kept in sync with
|
||||||
// KNOWN_KINDS in backend/app/utils/tag_prefix.py. The backend is the
|
// KNOWN_KINDS in backend/app/utils/tag_prefix.py. The backend is the
|
||||||
|
|||||||
@@ -22,10 +22,10 @@ import { reactive, watch } from 'vue'
|
|||||||
import { useMLStore } from '../../stores/ml.js'
|
import { useMLStore } from '../../stores/ml.js'
|
||||||
|
|
||||||
const store = useMLStore()
|
const store = useMLStore()
|
||||||
|
// 'artist' (FC-2d-vii-c) and 'copyright' (2026-06-01) retired as
|
||||||
|
// suggestion categories; their threshold rows are gone.
|
||||||
const fields = [
|
const fields = [
|
||||||
{ key: 'suggestion_threshold_artist', label: 'Artist' },
|
|
||||||
{ key: 'suggestion_threshold_character', label: 'Character' },
|
{ key: 'suggestion_threshold_character', label: 'Character' },
|
||||||
{ key: 'suggestion_threshold_copyright', label: 'Copyright' },
|
|
||||||
{ key: 'suggestion_threshold_general', label: 'General' },
|
{ key: 'suggestion_threshold_general', label: 'General' },
|
||||||
{ key: 'centroid_similarity_threshold', label: 'Centroid similarity' }
|
{ key: 'centroid_similarity_threshold', label: 'Centroid similarity' }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -58,6 +58,7 @@
|
|||||||
:retrying-all="retryingAll"
|
:retrying-all="retryingAll"
|
||||||
@retry="onRetrySource"
|
@retry="onRetrySource"
|
||||||
@retry-all="onRetryAll"
|
@retry-all="onRetryAll"
|
||||||
|
@view-logs="onViewFailingLogs"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div v-if="store.loading && store.events.length === 0" class="fc-dl__loading">
|
<div v-if="store.loading && store.events.length === 0" class="fc-dl__loading">
|
||||||
@@ -364,6 +365,23 @@ watch(filterModel, async (m) => {
|
|||||||
async function openDetail(id) {
|
async function openDetail(id) {
|
||||||
await store.loadOne(id)
|
await store.loadOne(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onViewFailingLogs(source) {
|
||||||
|
// Find and open the most recent DownloadEvent for this source.
|
||||||
|
// Reuses the existing DownloadDetailModal — same stdout/stderr/error
|
||||||
|
// surface the row-click in the events feed shows.
|
||||||
|
try {
|
||||||
|
const ev = await store.loadLastForSource(source.id)
|
||||||
|
if (!ev) {
|
||||||
|
toast({
|
||||||
|
text: `No download events recorded for ${source.artist_name || source.platform} yet.`,
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast({ text: `Failed to load logs: ${e.message}`, type: 'error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -29,6 +29,14 @@
|
|||||||
{{ s.last_error || 'no error message recorded' }}
|
{{ s.last_error || 'no error message recorded' }}
|
||||||
</span>
|
</span>
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
|
<v-btn
|
||||||
|
size="x-small" variant="text" prepend-icon="mdi-text-box-search-outline"
|
||||||
|
:loading="logLoadingIds.has(s.id)"
|
||||||
|
@click="onViewLogs(s)"
|
||||||
|
title="Show the most recent download event's stdout/stderr/error"
|
||||||
|
>
|
||||||
|
Logs
|
||||||
|
</v-btn>
|
||||||
<v-btn
|
<v-btn
|
||||||
size="x-small" variant="text" prepend-icon="mdi-refresh"
|
size="x-small" variant="text" prepend-icon="mdi-refresh"
|
||||||
:loading="retryingIds.has(s.id)"
|
:loading="retryingIds.has(s.id)"
|
||||||
@@ -52,9 +60,23 @@ defineProps({
|
|||||||
retryingIds: { type: Set, default: () => new Set() },
|
retryingIds: { type: Set, default: () => new Set() },
|
||||||
retryingAll: { type: Boolean, default: false },
|
retryingAll: { type: Boolean, default: false },
|
||||||
})
|
})
|
||||||
defineEmits(['retry', 'retry-all'])
|
const emit = defineEmits(['retry', 'retry-all', 'view-logs'])
|
||||||
|
|
||||||
const open = ref(true)
|
const open = ref(true)
|
||||||
|
// Per-row loading flag so the spinner lives on the row whose Logs
|
||||||
|
// button was clicked, not on every row.
|
||||||
|
const logLoadingIds = ref(new Set())
|
||||||
|
async function onViewLogs(s) {
|
||||||
|
if (logLoadingIds.value.has(s.id)) return
|
||||||
|
logLoadingIds.value = new Set(logLoadingIds.value).add(s.id)
|
||||||
|
try {
|
||||||
|
await emit('view-logs', s)
|
||||||
|
} finally {
|
||||||
|
const next = new Set(logLoadingIds.value)
|
||||||
|
next.delete(s.id)
|
||||||
|
logLoadingIds.value = next
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -55,6 +55,21 @@ export const useDownloadsStore = defineStore('downloads', () => {
|
|||||||
return selected.value
|
return selected.value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Open the detail modal for the most recent DownloadEvent of a given
|
||||||
|
// source. Used by the failing-sources rollup's "Logs" button so the
|
||||||
|
// operator can troubleshoot without leaving the Downloads tab to find
|
||||||
|
// the row (operator-flagged 2026-06-01).
|
||||||
|
async function loadLastForSource(sourceId) {
|
||||||
|
const events = await api.get('/api/downloads', {
|
||||||
|
params: { source_id: sourceId, limit: 1 },
|
||||||
|
})
|
||||||
|
if (!events.length) {
|
||||||
|
selected.value = null
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return await loadOne(events[0].id)
|
||||||
|
}
|
||||||
|
|
||||||
async function applyFilter(patch) {
|
async function applyFilter(patch) {
|
||||||
filter.value = { ...filter.value, ...patch }
|
filter.value = { ...filter.value, ...patch }
|
||||||
await loadFirst()
|
await loadFirst()
|
||||||
@@ -98,7 +113,8 @@ export const useDownloadsStore = defineStore('downloads', () => {
|
|||||||
return {
|
return {
|
||||||
events, cursor, hasMore, filter, selected, loading, error, stats,
|
events, cursor, hasMore, filter, selected, loading, error, stats,
|
||||||
activity, failing, activeEvents,
|
activity, failing, activeEvents,
|
||||||
loadFirst, loadMore, loadOne, applyFilter, closeDetail, loadStats,
|
loadFirst, loadMore, loadOne, loadLastForSource, applyFilter,
|
||||||
|
closeDetail, loadStats,
|
||||||
loadActivity, loadFailing, loadActive, recoverStalled,
|
loadActivity, loadFailing, loadActive, recoverStalled,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -59,14 +59,37 @@ export const usePostsStore = defineStore('posts', () => {
|
|||||||
return await api.get(`/api/posts/${id}`)
|
return await api.get(`/api/posts/${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filter overlay for the around/older/newer (in-context anchored)
|
||||||
|
// path. Keep this distinct from `filters.value` (the down-only feed)
|
||||||
|
// so a normal-feed filter change doesn't leak into an active anchored
|
||||||
|
// view (or vice versa). Caller of loadAround passes the snapshot; the
|
||||||
|
// subsequent loadOlder/loadNewer use it verbatim.
|
||||||
|
function _aroundParams(extra) {
|
||||||
|
const p = { ...extra }
|
||||||
|
if (filters.value.artist_id != null) p.artist_id = filters.value.artist_id
|
||||||
|
if (filters.value.platform) p.platform = filters.value.platform
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
// Load a window centered on `postId`: newer posts above, the post, older
|
// Load a window centered on `postId`: newer posts above, the post, older
|
||||||
// posts below. Sets both directional cursors for subsequent scrolling.
|
// posts below. Sets both directional cursors for subsequent scrolling.
|
||||||
async function loadAround(postId) {
|
// Accepts the same filter shape as loadInitial so the anchored view
|
||||||
|
// stays artist/platform-scoped (operator-flagged 2026-06-01: clicking a
|
||||||
|
// post title from the modal's Provenance card opens the post in the
|
||||||
|
// posts feed; without this the older/newer scroll loaded unfiltered
|
||||||
|
// global posts instead of staying in the artist's stream).
|
||||||
|
async function loadAround(postId, newFilters) {
|
||||||
|
filters.value = {
|
||||||
|
artist_id: newFilters?.artist_id ?? null,
|
||||||
|
platform: newFilters?.platform ?? null,
|
||||||
|
}
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
anchorId.value = null
|
anchorId.value = null
|
||||||
try {
|
try {
|
||||||
const body = await api.get('/api/posts', { params: { around: postId } })
|
const body = await api.get('/api/posts', {
|
||||||
|
params: _aroundParams({ around: postId }),
|
||||||
|
})
|
||||||
items.value = body.items
|
items.value = body.items
|
||||||
cursorOlder.value = body.cursor_older
|
cursorOlder.value = body.cursor_older
|
||||||
cursorNewer.value = body.cursor_newer
|
cursorNewer.value = body.cursor_newer
|
||||||
@@ -85,7 +108,9 @@ export const usePostsStore = defineStore('posts', () => {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const body = await api.get('/api/posts', {
|
const body = await api.get('/api/posts', {
|
||||||
params: { cursor: cursorOlder.value, direction: 'older' },
|
params: _aroundParams({
|
||||||
|
cursor: cursorOlder.value, direction: 'older',
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
items.value.push(...body.items)
|
items.value.push(...body.items)
|
||||||
cursorOlder.value = body.next_cursor
|
cursorOlder.value = body.next_cursor
|
||||||
@@ -102,7 +127,9 @@ export const usePostsStore = defineStore('posts', () => {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const body = await api.get('/api/posts', {
|
const body = await api.get('/api/posts', {
|
||||||
params: { cursor: cursorNewer.value, direction: 'newer' },
|
params: _aroundParams({
|
||||||
|
cursor: cursorNewer.value, direction: 'newer',
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
items.value.unshift(...body.items)
|
items.value.unshift(...body.items)
|
||||||
cursorNewer.value = body.next_cursor
|
cursorNewer.value = body.next_cursor
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ import { ref } from 'vue'
|
|||||||
import { useApi } from '../composables/useApi.js'
|
import { useApi } from '../composables/useApi.js'
|
||||||
import { useAsyncAction } from '../composables/useAsyncAction.js'
|
import { useAsyncAction } from '../composables/useAsyncAction.js'
|
||||||
|
|
||||||
// Category display order: people/sources first, general last.
|
// Category display order: people first, general last.
|
||||||
export const CATEGORY_ORDER = ['artist', 'character', 'copyright', 'general']
|
// 'artist' (FC-2d-vii-c) and 'copyright' (2026-06-01) retired — only
|
||||||
|
// character and general surface as suggestion categories now.
|
||||||
|
export const CATEGORY_ORDER = ['character', 'general']
|
||||||
export const CATEGORY_LABELS = {
|
export const CATEGORY_LABELS = {
|
||||||
artist: 'Artist',
|
|
||||||
character: 'Character',
|
character: 'Character',
|
||||||
copyright: 'Copyright',
|
|
||||||
general: 'General'
|
general: 'General'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -161,7 +161,15 @@ function setupAroundObservers() {
|
|||||||
}
|
}
|
||||||
async function loadAroundAndAnchor() {
|
async function loadAroundAndAnchor() {
|
||||||
teardownFeed()
|
teardownFeed()
|
||||||
await store.loadAround(postIdFilter.value)
|
// Pass artist_id + platform through so the anchored view stays
|
||||||
|
// scoped — the older/newer infinite scrolls then read these filters
|
||||||
|
// back via the store's _aroundParams (operator-flagged 2026-06-01:
|
||||||
|
// post-title click from the modal landed scoped but the scroll then
|
||||||
|
// pulled unfiltered global posts).
|
||||||
|
await store.loadAround(postIdFilter.value, {
|
||||||
|
artist_id: artistFilter.value,
|
||||||
|
platform: platformFilter.value,
|
||||||
|
})
|
||||||
await nextTick()
|
await nextTick()
|
||||||
const el = document.getElementById(`fc-post-${store.anchorId}`)
|
const el = document.getElementById(`fc-post-${store.anchorId}`)
|
||||||
if (el) el.scrollIntoView({ block: 'center' })
|
if (el) el.scrollIntoView({ block: 'center' })
|
||||||
|
|||||||
@@ -19,7 +19,12 @@ async def test_get_and_patch_settings(client):
|
|||||||
resp = await client.get("/api/ml/settings")
|
resp = await client.get("/api/ml/settings")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
body = await resp.get_json()
|
body = await resp.get_json()
|
||||||
assert body["suggestion_threshold_general"] == pytest.approx(0.95)
|
# Default lowered 0.95 → 0.50 on 2026-06-01 (alembic 0029) — 0.95
|
||||||
|
# hid most general suggestions in the view modal.
|
||||||
|
assert body["suggestion_threshold_general"] == pytest.approx(0.50)
|
||||||
|
# Retired threshold columns must not appear in the payload.
|
||||||
|
assert "suggestion_threshold_artist" not in body
|
||||||
|
assert "suggestion_threshold_copyright" not in body
|
||||||
|
|
||||||
resp = await client.patch(
|
resp = await client.patch(
|
||||||
"/api/ml/settings", json={"suggestion_threshold_general": 0.90}
|
"/api/ml/settings", json={"suggestion_threshold_general": 0.90}
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ def test_threshold_for_artist_is_unsurfaced():
|
|||||||
|
|
||||||
class _S:
|
class _S:
|
||||||
suggestion_threshold_character = 0.5
|
suggestion_threshold_character = 0.5
|
||||||
suggestion_threshold_copyright = 0.5
|
|
||||||
suggestion_threshold_general = 0.5
|
suggestion_threshold_general = 0.5
|
||||||
|
|
||||||
svc = SuggestionService.__new__(SuggestionService)
|
svc = SuggestionService.__new__(SuggestionService)
|
||||||
# 'artist' must fall through to the 1.01 "never surfaces" default
|
# 'artist' and 'copyright' both retired — fall through to 1.01
|
||||||
assert svc._threshold_for(_S(), "artist") == 1.01
|
assert svc._threshold_for(_S(), "artist") == 1.01
|
||||||
|
assert svc._threshold_for(_S(), "copyright") == 1.01
|
||||||
|
|||||||
@@ -25,10 +25,13 @@ def _img(sha: str, predictions: dict) -> ImageRecord:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_threshold_filters_low_confidence_general(db):
|
async def test_threshold_filters_low_confidence_general(db):
|
||||||
|
# Default general threshold is 0.50 (alembic 0029 lowered it from
|
||||||
|
# 0.95). Use 0.30/0.60 to keep the test asserting threshold behavior
|
||||||
|
# rather than the exact cutoff number.
|
||||||
img = _img(
|
img = _img(
|
||||||
"a" * 64,
|
"a" * 64,
|
||||||
{
|
{
|
||||||
"smile": {"category": "general", "confidence": 0.80},
|
"lowconf": {"category": "general", "confidence": 0.30},
|
||||||
"sword": {"category": "general", "confidence": 0.97},
|
"sword": {"category": "general", "confidence": 0.97},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -37,7 +40,7 @@ async def test_threshold_filters_low_confidence_general(db):
|
|||||||
sl = await SuggestionService(db).for_image(img.id)
|
sl = await SuggestionService(db).for_image(img.id)
|
||||||
names = [s.display_name for s in sl.by_category.get("general", [])]
|
names = [s.display_name for s in sl.by_category.get("general", [])]
|
||||||
assert "sword" in names
|
assert "sword" in names
|
||||||
assert "smile" not in names
|
assert "lowconf" not in names
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -17,10 +17,13 @@ from backend.app.services.ml.tagger import (
|
|||||||
|
|
||||||
|
|
||||||
def test_surfaced_categories():
|
def test_surfaced_categories():
|
||||||
# FC-2d-vii-c: 'artist' retired — artist identity is acquisition-derived
|
# FC-2d-vii-c: 'artist' retired — artist identity is acquisition-
|
||||||
# (image_record.artist_id), never ML-inferred.
|
# derived (image_record.artist_id), never ML-inferred.
|
||||||
assert SURFACED_CATEGORIES == {"character", "copyright", "general"}
|
# 2026-06-01: 'copyright' retired — fandom serves as the franchise/
|
||||||
|
# copyright concept; operator doesn't use a separate copyright kind.
|
||||||
|
assert SURFACED_CATEGORIES == {"character", "general"}
|
||||||
assert "artist" not in SURFACED_CATEGORIES
|
assert "artist" not in SURFACED_CATEGORIES
|
||||||
|
assert "copyright" not in SURFACED_CATEGORIES
|
||||||
|
|
||||||
|
|
||||||
def test_store_floor_is_low():
|
def test_store_floor_is_low():
|
||||||
|
|||||||
Reference in New Issue
Block a user