Compare commits
10
Commits
| 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:`
|
||||
# config above. Tag :dev to surface the
|
||||
# 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
|
||||
TAG_NAME="${GITHUB_REF#refs/tags/}"
|
||||
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
|
||||
# on main-push per the family release-posture rule. The -ml
|
||||
# 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
|
||||
TAG_NAME="${GITHUB_REF#refs/tags/}"
|
||||
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 = (
|
||||
"suggestion_threshold_artist",
|
||||
"suggestion_threshold_character",
|
||||
"suggestion_threshold_copyright",
|
||||
"suggestion_threshold_general",
|
||||
"centroid_similarity_threshold",
|
||||
"min_reference_images",
|
||||
@@ -28,9 +26,7 @@ async def get_settings():
|
||||
).scalar_one()
|
||||
return jsonify(
|
||||
{
|
||||
"suggestion_threshold_artist": s.suggestion_threshold_artist,
|
||||
"suggestion_threshold_character": s.suggestion_threshold_character,
|
||||
"suggestion_threshold_copyright": s.suggestion_threshold_copyright,
|
||||
"suggestion_threshold_general": s.suggestion_threshold_general,
|
||||
"centroid_similarity_threshold": s.centroid_similarity_threshold,
|
||||
"min_reference_images": s.min_reference_images,
|
||||
|
||||
@@ -15,17 +15,14 @@ class MLSettings(Base):
|
||||
__table_args__ = (CheckConstraint("id = 1", name="singleton"),)
|
||||
|
||||
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(
|
||||
Float, nullable=False, default=0.50
|
||||
)
|
||||
suggestion_threshold_copyright: Mapped[float] = mapped_column(
|
||||
Float, nullable=False, default=0.50
|
||||
)
|
||||
# Default lowered 0.95 → 0.50 on 2026-06-01 — operator-flagged that
|
||||
# 0.95 hid most general suggestions. Operator-tunable via Settings →
|
||||
# ML if too noisy.
|
||||
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(
|
||||
Float, nullable=False, default=0.55
|
||||
|
||||
@@ -48,11 +48,11 @@ class SuggestionService:
|
||||
).scalar_one()
|
||||
|
||||
def _threshold_for(self, s: MLSettings, category: str) -> float:
|
||||
# 'artist' intentionally absent (FC-2d-vii-c) — falls through to
|
||||
# the 1.01 "never surfaces" default like any unsurfaced category.
|
||||
# 'artist' (FC-2d-vii-c) and 'copyright' (2026-06-01) retired;
|
||||
# both fall through to the 1.01 "never surfaces" default like any
|
||||
# unsurfaced category.
|
||||
return {
|
||||
"character": s.suggestion_threshold_character,
|
||||
"copyright": s.suggestion_threshold_copyright,
|
||||
"general": s.suggestion_threshold_general,
|
||||
}.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
|
||||
# still stored but the suggestion service filters them out.
|
||||
# FC-2d-vii-c: 'artist' retired — artist identity is acquisition-derived
|
||||
# (image_record.artist_id), never ML-inferred. Raw predictions are still
|
||||
# stored at STORE_FLOOR but artist never surfaces.
|
||||
SURFACED_CATEGORIES = {"character", "copyright", "general"}
|
||||
# 'artist' retired in FC-2d-vii-c — artist identity is acquisition-derived
|
||||
# (image_record.artist_id), never ML-inferred. 'copyright' retired
|
||||
# 2026-06-01 — operator doesn't use the copyright tag-kind; fandom is
|
||||
# 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_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)
|
||||
|
||||
@@ -48,10 +48,11 @@ function onSearch(q) {
|
||||
if (!query) { results.value = []; return }
|
||||
loading.value = true
|
||||
try {
|
||||
// Scope the autocomplete to the prediction's category where it maps
|
||||
// to a tag kind. 'copyright' has no tag kind; search unscoped there.
|
||||
const kind = ['artist', 'character'].includes(props.category)
|
||||
? props.category : null
|
||||
// Scope the autocomplete to the prediction's category where it
|
||||
// maps to a tag kind. Only 'character' surfaces as both a
|
||||
// suggestion category and a tag kind now ('artist' + 'copyright'
|
||||
// retired); other categories search unscoped.
|
||||
const kind = props.category === 'character' ? 'character' : null
|
||||
const params = { q: query, limit: 20 }
|
||||
if (kind) params.kind = kind
|
||||
results.value = await api.get('/api/tags/autocomplete', { params })
|
||||
|
||||
@@ -92,7 +92,16 @@ let prevBodyOverflow = null
|
||||
// own keystrokes.
|
||||
function onKeyDown(ev) {
|
||||
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()
|
||||
emit('close')
|
||||
} else if (ev.key === 'ArrowLeft') {
|
||||
|
||||
@@ -10,7 +10,11 @@
|
||||
density="compact"
|
||||
>{{ 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
|
||||
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 v-if="postDate(e)" class="fc-prov__date">{{ postDate(e) }}</span>
|
||||
</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) }}
|
||||
</div>
|
||||
</button>
|
||||
<div class="fc-prov__meta">
|
||||
<RouterLink :to="`/artist/${e.artist.slug}`">
|
||||
by {{ e.artist.name }}
|
||||
@@ -29,12 +37,8 @@
|
||||
· {{ e.post.attachment_count }} files
|
||||
</span>
|
||||
</div>
|
||||
<div class="fc-prov__actions">
|
||||
<a href="#" @click.prevent="openPost(e.post.id)">
|
||||
View post
|
||||
</a>
|
||||
<div v-if="e.post.description_html" class="fc-prov__actions">
|
||||
<a
|
||||
v-if="e.post.description_html"
|
||||
href="#" @click.prevent="toggleDesc(e.provenance_id)"
|
||||
>{{ expanded[e.provenance_id] ? 'Hide description ▴' : 'Show description ▾' }}</a>
|
||||
</div>
|
||||
@@ -58,7 +62,7 @@
|
||||
</RouterLink>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="attachments.length" class="fc-prov__attach">
|
||||
<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}`
|
||||
}
|
||||
|
||||
function openPost(postId) {
|
||||
function openPost(postId, artistId) {
|
||||
// Land on the post in the posts feed (in context), not the gallery
|
||||
// image grid. Operator-flagged 2026-05-28.
|
||||
router.push({ path: '/posts', query: { post_id: postId } })
|
||||
// image grid. Scope the feed to this artist so the user lands in
|
||||
// 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()
|
||||
}
|
||||
</script>
|
||||
@@ -153,6 +163,18 @@ function openPost(postId) {
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
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 {
|
||||
border: 1px solid rgb(var(--v-theme-surface-light));
|
||||
border-radius: 6px; padding: 10px 12px; margin-bottom: 10px;
|
||||
@@ -163,8 +185,21 @@ function openPost(postId) {
|
||||
text-transform: lowercase;
|
||||
}
|
||||
.fc-prov__post {
|
||||
font-weight: 700; margin: 4px 0;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
/* Clickable title — opens the post in the artist-scoped feed
|
||||
(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 {
|
||||
font-size: 13px; color: rgb(var(--v-theme-on-surface-variant));
|
||||
|
||||
@@ -1,19 +1,33 @@
|
||||
<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">
|
||||
<span class="fc-suggestion__name">
|
||||
{{ suggestion.display_name }}
|
||||
<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 class="fc-suggestion__score">{{ scorePct }}</span>
|
||||
<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}`"
|
||||
@click="$emit('accept', suggestion)"
|
||||
/>
|
||||
>
|
||||
Accept
|
||||
</v-btn>
|
||||
<v-menu>
|
||||
<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>
|
||||
<v-list density="compact">
|
||||
<v-list-item @click="$emit('alias', suggestion)">
|
||||
@@ -38,17 +52,45 @@ const scorePct = computed(() => `${Math.round(props.suggestion.score * 100)}%`)
|
||||
|
||||
<style scoped>
|
||||
.fc-suggestion {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 2px 0;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
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 {
|
||||
font-size: 10px; color: rgb(var(--v-theme-accent));
|
||||
margin-left: 4px;
|
||||
display: inline-block;
|
||||
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 {
|
||||
flex: 0 0 auto; min-width: 38px; text-align: right;
|
||||
font-size: 11px;
|
||||
color: rgb(var(--v-theme-on-surface-variant, var(--v-theme-on-surface)));
|
||||
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>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<SuggestionsCategoryGroup
|
||||
v-if="store.byCategory.general && store.byCategory.general.length"
|
||||
label="General" :items="store.byCategory.general"
|
||||
collapsible :default-open="false"
|
||||
collapsible :default-open="true"
|
||||
@accept="onAccept" @alias="onAlias" @dismiss="store.dismiss"
|
||||
/>
|
||||
</template>
|
||||
@@ -47,7 +47,10 @@ import AliasPickerDialog from './AliasPickerDialog.vue'
|
||||
const props = defineProps({ imageId: { type: Number, required: true } })
|
||||
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 }
|
||||
|
||||
const isEmpty = computed(() =>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<div class="fc-tag-autocomplete">
|
||||
<v-text-field
|
||||
ref="inputRef"
|
||||
v-model="query"
|
||||
placeholder="Add tag (or kind:name — character/fandom/series)"
|
||||
density="compact" hide-details
|
||||
@@ -52,13 +53,21 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { useTagStore } from '../../stores/tags.js'
|
||||
import FandomPicker from './FandomPicker.vue'
|
||||
|
||||
const emit = defineEmits(['pick-existing', 'pick-new', 'cancel'])
|
||||
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
|
||||
// backend's parse_kind_prefix lives below — kept in sync with
|
||||
// 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'
|
||||
|
||||
const store = useMLStore()
|
||||
// 'artist' (FC-2d-vii-c) and 'copyright' (2026-06-01) retired as
|
||||
// suggestion categories; their threshold rows are gone.
|
||||
const fields = [
|
||||
{ key: 'suggestion_threshold_artist', label: 'Artist' },
|
||||
{ key: 'suggestion_threshold_character', label: 'Character' },
|
||||
{ key: 'suggestion_threshold_copyright', label: 'Copyright' },
|
||||
{ key: 'suggestion_threshold_general', label: 'General' },
|
||||
{ key: 'centroid_similarity_threshold', label: 'Centroid similarity' }
|
||||
]
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
:retrying-all="retryingAll"
|
||||
@retry="onRetrySource"
|
||||
@retry-all="onRetryAll"
|
||||
@view-logs="onViewFailingLogs"
|
||||
/>
|
||||
|
||||
<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) {
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -29,6 +29,14 @@
|
||||
{{ s.last_error || 'no error message recorded' }}
|
||||
</span>
|
||||
<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
|
||||
size="x-small" variant="text" prepend-icon="mdi-refresh"
|
||||
:loading="retryingIds.has(s.id)"
|
||||
@@ -52,9 +60,23 @@ defineProps({
|
||||
retryingIds: { type: Set, default: () => new Set() },
|
||||
retryingAll: { type: Boolean, default: false },
|
||||
})
|
||||
defineEmits(['retry', 'retry-all'])
|
||||
const emit = defineEmits(['retry', 'retry-all', 'view-logs'])
|
||||
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -55,6 +55,21 @@ export const useDownloadsStore = defineStore('downloads', () => {
|
||||
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) {
|
||||
filter.value = { ...filter.value, ...patch }
|
||||
await loadFirst()
|
||||
@@ -98,7 +113,8 @@ export const useDownloadsStore = defineStore('downloads', () => {
|
||||
return {
|
||||
events, cursor, hasMore, filter, selected, loading, error, stats,
|
||||
activity, failing, activeEvents,
|
||||
loadFirst, loadMore, loadOne, applyFilter, closeDetail, loadStats,
|
||||
loadFirst, loadMore, loadOne, loadLastForSource, applyFilter,
|
||||
closeDetail, loadStats,
|
||||
loadActivity, loadFailing, loadActive, recoverStalled,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -59,14 +59,37 @@ export const usePostsStore = defineStore('posts', () => {
|
||||
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
|
||||
// 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
|
||||
error.value = null
|
||||
anchorId.value = null
|
||||
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
|
||||
cursorOlder.value = body.cursor_older
|
||||
cursorNewer.value = body.cursor_newer
|
||||
@@ -85,7 +108,9 @@ export const usePostsStore = defineStore('posts', () => {
|
||||
loading.value = true
|
||||
try {
|
||||
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)
|
||||
cursorOlder.value = body.next_cursor
|
||||
@@ -102,7 +127,9 @@ export const usePostsStore = defineStore('posts', () => {
|
||||
loading.value = true
|
||||
try {
|
||||
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)
|
||||
cursorNewer.value = body.next_cursor
|
||||
|
||||
@@ -4,12 +4,12 @@ import { ref } from 'vue'
|
||||
import { useApi } from '../composables/useApi.js'
|
||||
import { useAsyncAction } from '../composables/useAsyncAction.js'
|
||||
|
||||
// Category display order: people/sources first, general last.
|
||||
export const CATEGORY_ORDER = ['artist', 'character', 'copyright', 'general']
|
||||
// Category display order: people first, general last.
|
||||
// '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 = {
|
||||
artist: 'Artist',
|
||||
character: 'Character',
|
||||
copyright: 'Copyright',
|
||||
general: 'General'
|
||||
}
|
||||
|
||||
|
||||
@@ -161,7 +161,15 @@ function setupAroundObservers() {
|
||||
}
|
||||
async function loadAroundAndAnchor() {
|
||||
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()
|
||||
const el = document.getElementById(`fc-post-${store.anchorId}`)
|
||||
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")
|
||||
assert resp.status_code == 200
|
||||
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(
|
||||
"/api/ml/settings", json={"suggestion_threshold_general": 0.90}
|
||||
|
||||
@@ -19,9 +19,9 @@ def test_threshold_for_artist_is_unsurfaced():
|
||||
|
||||
class _S:
|
||||
suggestion_threshold_character = 0.5
|
||||
suggestion_threshold_copyright = 0.5
|
||||
suggestion_threshold_general = 0.5
|
||||
|
||||
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(), "copyright") == 1.01
|
||||
|
||||
@@ -25,10 +25,13 @@ def _img(sha: str, predictions: dict) -> ImageRecord:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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(
|
||||
"a" * 64,
|
||||
{
|
||||
"smile": {"category": "general", "confidence": 0.80},
|
||||
"lowconf": {"category": "general", "confidence": 0.30},
|
||||
"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)
|
||||
names = [s.display_name for s in sl.by_category.get("general", [])]
|
||||
assert "sword" in names
|
||||
assert "smile" not in names
|
||||
assert "lowconf" not in names
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -17,10 +17,13 @@ from backend.app.services.ml.tagger import (
|
||||
|
||||
|
||||
def test_surfaced_categories():
|
||||
# FC-2d-vii-c: 'artist' retired — artist identity is acquisition-derived
|
||||
# (image_record.artist_id), never ML-inferred.
|
||||
assert SURFACED_CATEGORIES == {"character", "copyright", "general"}
|
||||
# FC-2d-vii-c: 'artist' retired — artist identity is acquisition-
|
||||
# derived (image_record.artist_id), never ML-inferred.
|
||||
# 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 "copyright" not in SURFACED_CATEGORIES
|
||||
|
||||
|
||||
def test_store_floor_is_low():
|
||||
|
||||
Reference in New Issue
Block a user