Compare commits

...
6 Commits
Author SHA1 Message Date
bvandeusen b1d58bc3b8 Merge pull request 'fix(ci): POSIX-safe SHORT_SHA in build.yml' (#42) from dev into main
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 15s
CI / frontend-build (push) Successful in 20s
Build images / sign-extension (push) Successful in 3s
Build images / build-web (push) Successful in 2m17s
Build images / build-ml (push) Successful in 3m24s
CI / intimp (push) Successful in 3m22s
CI / intapi (push) Successful in 7m51s
CI / intcore (push) Successful in 8m5s
2026-06-01 08:03:17 -04:00
bvandeusen 9564d073b9 fix(ci): POSIX-safe SHORT_SHA in build.yml (runner uses dash, not bash)
CI / backend-lint-and-test (push) Successful in 24s
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 17s
CI / intimp (push) Successful in 3m46s
CI / intapi (push) Successful in 7m26s
CI / intcore (push) Successful in 8m3s
`${GITHUB_SHA:0:7}` substring expansion is bash-only; the runner
executes the step via dash/BusyBox sh and errored with
`Bad substitution` (act_runner workflow shell, observed on the
65386f0 main-push run). Switched to
`SHORT_SHA=$(printf '%s' "$GITHUB_SHA" | cut -c1-7)` which works in
both shells. Both build-web and build-ml tag-determination steps
updated.
2026-06-01 07:53:33 -04:00
bvandeusen 65386f02a0 Merge pull request 'View modal batch: autofocus, suggestions UX, post-title click, retire copyright/artist' (#41) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Failing after 4s
Build images / build-web (push) Failing after 3s
CI / backend-lint-and-test (push) Successful in 15s
CI / frontend-build (push) Successful in 17s
CI / intimp (push) Successful in 3m46s
CI / intapi (push) Successful in 7m17s
CI / intcore (push) Successful in 8m2s
2026-06-01 07:01:42 -04:00
bvandeusen f87a06a6bd feat(modal): post title is the primary click (artist-scoped) + suggestion rows look like buttons
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 11s
CI / frontend-build (push) Successful in 16s
CI / intimp (push) Successful in 4m16s
CI / intapi (push) Successful in 8m16s
CI / intcore (push) Successful in 8m41s
Two operator-asked modal UX changes (Scribe plan #514):

1. **Post title click → artist-scoped posts feed**
   - The bold post title in ProvenancePanel is now the primary click
     target. Click navigates to /posts?post_id=N&artist_id=A; the
     PostsView already filters on `artist_id`, so the user lands in
     that creator's stream, not the global one.
   - The redundant "View post" link is removed. "Show description"
     stays as the only action link below the meta line.
   - Title is styled as a button-link: accent color, hover underline,
     focus ring for keyboard nav (family rule 24 — UI quality bar).

2. **Suggestion rows look like buttons**
   - SuggestionItem becomes a chip-card: visible border, hover/focus
     background, unified container for name + score + actions
     (operator's "nothing visual to unify the buttons to their object"
     complaint).
   - Accept is an explicit tonal pill button labeled "Accept"
     (operator's pick over whole-row-clickable). 3-dot menu retains
     alias/dismiss, now `variant="outlined"` so it reads as a button.
   - Score is a fixed-width monospace pill on the right.
   - "+ new" badge upgraded to a pill chip with accent border so
     it's visibly an annotation, not part of the tag name.
2026-06-01 02:21:59 -04:00
bvandeusen 5d284aae9f fix(test): unpin general-threshold test from old 0.95 default (alembic 0029)
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 26s
CI / lint (push) Successful in 3s
CI / intimp (push) Successful in 3m38s
CI / intapi (push) Successful in 7m55s
CI / intcore (push) Successful in 8m46s
2026-06-01 02:18:54 -04:00
bvandeusen af7b5c95e9 feat(modal): autofocus tag input, expand general suggestions, retire copyright/artist categories
CI / backend-lint-and-test (push) Successful in 17s
CI / frontend-build (push) Successful in 18s
CI / lint (push) Successful in 2s
CI / intimp (push) Successful in 3m48s
CI / intapi (push) Successful in 7m46s
CI / intcore (push) Failing after 8m18s
Four coupled operator-asked changes to the view modal (Scribe plan #509):

1. **Autofocus tag entry on modal open** — TagAutocomplete grabs focus
   in onMounted/nextTick so the caret is in the input the moment the
   modal renders. No click needed to start typing.

2. **General suggestions expanded by default** — SuggestionsPanel's
   general-category group now mounts with `:default-open="true"`.
   Operator can collapse if too noisy, but the v1 frame shows them.

3. **Lower general threshold default 0.95 → 0.50** — MLSettings.
   suggestion_threshold_general default matches character. Alembic
   0029 also bumps the existing singleton row's value if it's still
   at the old 0.95. Operator can re-tune from Settings → ML.

4. **Retire `copyright` + `artist` as ML suggestion categories** —
   neither feeds a Tag.kind (`artist` retired in FC-2d-vii-c, never
   really existed as a copyright tag-kind). They were surfaced in the
   suggestions pipeline + threshold settings UI but had no follow-
   through. Drop from SURFACED_CATEGORIES, suggestions._threshold_for,
   ml_admin GET/PATCH allowlist, MLSettings columns (alembic 0029
   drops the two columns), frontend CATEGORY_ORDER + CATEGORY_LABELS,
   SuggestionsPanel.peopleCats, AliasPickerDialog kind-check, and
   MLThresholdSliders rows.

Out of scope (intentional): `tag_kind` Postgres enum still includes
`artist` for historic Tag row queryability (per the model comment);
no operator pain reported, no enum-shrink needed.

Tests:
- test_surfaced_categories asserts {character, general}, excludes
  artist + copyright.
- test_threshold_for_artist_is_unsurfaced extended to cover copyright.
- test_get_and_patch_settings asserts new 0.50 default and the absent
  artist + copyright keys in the GET payload.
2026-06-01 02:08:10 -04:00
17 changed files with 222 additions and 62 deletions
+10 -2
View File
@@ -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",
),
)
-4
View File
@@ -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,
+4 -7
View File
@@ -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
+3 -3
View File
@@ -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)
+7 -4
View File
@@ -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 })
@@ -18,9 +18,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 +33,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>
@@ -137,10 +137,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>
@@ -163,8 +169,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' }
]
+4 -4
View File
@@ -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'
}
+6 -1
View File
@@ -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}
+2 -2
View File
@@ -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
+5 -2
View File
@@ -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
+6 -3
View File
@@ -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():