Merge pull request 'CCIP automation + reference quality (auto-enqueue, auto-apply, contamination fix, cache)' (#152) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 28s
Build images / build-web (push) Successful in 2m20s
Build images / build-ml (push) Successful in 2m40s
Build images / build-agent (push) Successful in 3m17s
CI / integration (push) Successful in 3m27s
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 28s
Build images / build-web (push) Successful in 2m20s
Build images / build-ml (push) Successful in 2m40s
Build images / build-agent (push) Successful in 3m17s
CI / integration (push) Successful in 3m27s
This commit was merged in pull request #152.
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
"""ml_settings: CCIP auto-apply switch + threshold (#114)
|
||||
|
||||
Confident CCIP character matches auto-tag (source='ccip_auto') on a daily sweep,
|
||||
so identity tags keep flowing without pressing a button. ON by default (opt-out,
|
||||
like head auto-apply); the high threshold (0.92, above the 0.85 suggest cut) +
|
||||
single-character references keep it safe, and every auto-tag is reversible.
|
||||
|
||||
Revision ID: 0064
|
||||
Revises: 0063
|
||||
Create Date: 2026-06-30
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0064"
|
||||
down_revision: Union[str, None] = "0063"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"ml_settings",
|
||||
sa.Column(
|
||||
"ccip_auto_apply_enabled", sa.Boolean(), nullable=False,
|
||||
server_default=sa.true(),
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"ml_settings",
|
||||
sa.Column(
|
||||
"ccip_auto_apply_threshold", sa.Float(), nullable=False,
|
||||
server_default="0.92",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("ml_settings", "ccip_auto_apply_threshold")
|
||||
op.drop_column("ml_settings", "ccip_auto_apply_enabled")
|
||||
@@ -62,6 +62,13 @@ async def overview():
|
||||
)
|
||||
).all() if v
|
||||
]
|
||||
auto_applied = (
|
||||
await session.execute(
|
||||
select(func.count()).select_from(image_tag).where(
|
||||
image_tag.c.source == "ccip_auto"
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
return jsonify({
|
||||
"regions_by_kind": by_kind,
|
||||
"images_with_figure_ccip": images_with_figure_ccip,
|
||||
@@ -70,6 +77,7 @@ async def overview():
|
||||
{"tag_id": t, "name": n, "n_refs": c} for (t, n, c) in ref_rows
|
||||
],
|
||||
"embedding_versions": versions,
|
||||
"auto_applied": auto_applied,
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ _EDITABLE = (
|
||||
"head_auto_apply_enabled",
|
||||
"head_auto_apply_min_positives",
|
||||
"ccip_match_threshold",
|
||||
"ccip_auto_apply_enabled",
|
||||
"ccip_auto_apply_threshold",
|
||||
)
|
||||
|
||||
|
||||
@@ -50,6 +52,8 @@ async def get_settings():
|
||||
"head_auto_apply_enabled": s.head_auto_apply_enabled,
|
||||
"head_auto_apply_min_positives": s.head_auto_apply_min_positives,
|
||||
"ccip_match_threshold": s.ccip_match_threshold,
|
||||
"ccip_auto_apply_enabled": s.ccip_auto_apply_enabled,
|
||||
"ccip_auto_apply_threshold": s.ccip_auto_apply_threshold,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -119,6 +123,8 @@ def _validate(p: dict) -> str | None:
|
||||
return "head_auto_apply_min_positives must be >= 1"
|
||||
if not (0.5 <= float(p["ccip_match_threshold"]) <= 0.999):
|
||||
return "ccip_match_threshold must be between 0.5 and 0.999"
|
||||
if not (0.5 <= float(p["ccip_auto_apply_threshold"]) <= 0.999):
|
||||
return "ccip_auto_apply_threshold must be between 0.5 and 0.999"
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -121,6 +121,15 @@ def make_celery() -> Celery:
|
||||
"task": "backend.app.tasks.ml.recover_orphaned_gpu_jobs",
|
||||
"schedule": 60.0, # quick pickup of work a dead agent orphaned
|
||||
},
|
||||
"enqueue-ccip-backfill-hourly": {
|
||||
"task": "backend.app.tasks.ml.enqueue_gpu_backfill",
|
||||
"schedule": 3600.0, # auto-feed new images (+ retry errored) so
|
||||
"args": ("ccip",), # the queue keeps moving without the button
|
||||
},
|
||||
"ccip-auto-apply-daily": {
|
||||
"task": "backend.app.tasks.ml.scheduled_ccip_auto_apply",
|
||||
"schedule": 86400.0, # no-op unless ccip_auto_apply_enabled
|
||||
},
|
||||
"snapshot-head-metrics-daily": {
|
||||
"task": "backend.app.tasks.maintenance.snapshot_head_metrics",
|
||||
"schedule": 86400.0,
|
||||
|
||||
@@ -92,6 +92,15 @@ class MLSettings(Base):
|
||||
ccip_match_threshold: Mapped[float] = mapped_column(
|
||||
Float, nullable=False, default=0.85
|
||||
)
|
||||
# CCIP auto-apply (#114). Confident matches (>= ccip_auto_apply_threshold,
|
||||
# above the suggest cut) auto-tag on a daily sweep. ON by default (opt-out);
|
||||
# single-character references + the high bar keep it safe, every tag reversible.
|
||||
ccip_auto_apply_enabled: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=True
|
||||
)
|
||||
ccip_auto_apply_threshold: Mapped[float] = mapped_column(
|
||||
Float, nullable=False, default=0.92
|
||||
)
|
||||
tagger_model_version: Mapped[str] = mapped_column(
|
||||
String(128), nullable=False, default="camie-tagger-v2"
|
||||
)
|
||||
|
||||
@@ -13,7 +13,7 @@ exact CCIP difference metric/threshold gets validated against the model during
|
||||
the hands-on eval. numpy is imported lazily (API worker has it via pgvector).
|
||||
"""
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ...models import ImageRegion, MLSettings, Tag, TagKind
|
||||
@@ -41,10 +41,54 @@ def _l2norm(mat, np):
|
||||
return mat / n
|
||||
|
||||
|
||||
# Single-shot cache of the (expensive) reference load, keyed on a cheap
|
||||
# signature that changes exactly when references could: a character tag added/
|
||||
# removed (n_char_tags) or a figure embedded (max/ n of ccip regions). Shared by
|
||||
# the live matcher (every modal open) and the auto-apply sweep.
|
||||
_REF_CACHE: dict = {"sig": None, "refs": None}
|
||||
|
||||
|
||||
def _single_character_images():
|
||||
"""Subquery of image ids carrying EXACTLY ONE character tag. References come
|
||||
only from these — on a multi-character image the tag is image-level, so every
|
||||
figure would otherwise pollute each character's prototype set (a 2-character
|
||||
image tagged 'Velma' would make Daphne's figure a Velma reference)."""
|
||||
return (
|
||||
select(image_tag.c.image_record_id)
|
||||
.join(Tag, Tag.id == image_tag.c.tag_id)
|
||||
.where(Tag.kind == TagKind.character)
|
||||
.group_by(image_tag.c.image_record_id)
|
||||
.having(func.count() == 1)
|
||||
)
|
||||
|
||||
|
||||
async def _ref_signature(session: AsyncSession) -> tuple:
|
||||
n_tags = (
|
||||
await session.execute(
|
||||
select(func.count())
|
||||
.select_from(image_tag)
|
||||
.join(Tag, Tag.id == image_tag.c.tag_id)
|
||||
.where(Tag.kind == TagKind.character)
|
||||
)
|
||||
).scalar_one()
|
||||
n_regs, max_id = (
|
||||
await session.execute(
|
||||
select(func.count(), func.max(ImageRegion.id)).where(
|
||||
ImageRegion.kind.in_(_FIGURE_KINDS),
|
||||
ImageRegion.ccip_embedding.is_not(None),
|
||||
)
|
||||
)
|
||||
).one()
|
||||
return (n_tags, n_regs, max_id)
|
||||
|
||||
|
||||
async def character_references(session: AsyncSession) -> dict[int, list]:
|
||||
"""Per character-tag CCIP reference vectors: figure/face-region CCIP
|
||||
embeddings on images that carry that character tag (the operator's examples).
|
||||
Multi-prototype — several vectors per character."""
|
||||
embeddings on UNAMBIGUOUS (single-character) images carrying that tag.
|
||||
Multi-prototype — several vectors per character. Cached on a cheap signature."""
|
||||
sig = await _ref_signature(session)
|
||||
if _REF_CACHE["sig"] == sig and _REF_CACHE["refs"] is not None:
|
||||
return _REF_CACHE["refs"]
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(image_tag.c.tag_id, ImageRegion.ccip_embedding)
|
||||
@@ -57,11 +101,13 @@ async def character_references(session: AsyncSession) -> dict[int, list]:
|
||||
.where(Tag.kind == TagKind.character)
|
||||
.where(ImageRegion.kind.in_(_FIGURE_KINDS))
|
||||
.where(ImageRegion.ccip_embedding.is_not(None))
|
||||
.where(ImageRegion.image_record_id.in_(_single_character_images()))
|
||||
)
|
||||
).all()
|
||||
refs: dict[int, list] = {}
|
||||
for tag_id, vec in rows:
|
||||
refs.setdefault(tag_id, []).append(vec)
|
||||
_REF_CACHE.update(sig=sig, refs=refs)
|
||||
return refs
|
||||
|
||||
|
||||
|
||||
@@ -795,3 +795,124 @@ def recover_orphaned_gpu_jobs() -> int:
|
||||
)
|
||||
session.commit()
|
||||
return res.rowcount or 0
|
||||
|
||||
|
||||
@celery.task(
|
||||
name="backend.app.tasks.ml.scheduled_ccip_auto_apply",
|
||||
soft_time_limit=1800, time_limit=2100,
|
||||
)
|
||||
def scheduled_ccip_auto_apply() -> str:
|
||||
"""Auto-tag confident CCIP character matches (source='ccip_auto') so identity
|
||||
tags keep flowing without a button. No-op unless ccip_auto_apply_enabled.
|
||||
References come only from single-character images (unambiguous); a tag is
|
||||
applied where any figure's best cosine to a character's prototypes clears
|
||||
ccip_auto_apply_threshold and it isn't already applied/rejected. Reversible."""
|
||||
import numpy as np
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy import select as sa_select
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
from ..models import ImageRegion, MLSettings, Tag, TagKind, TagSuggestionRejection
|
||||
from ..models.tag import image_tag
|
||||
|
||||
fig = ("face", "figure")
|
||||
|
||||
def _l2(m):
|
||||
n = np.linalg.norm(m, axis=1, keepdims=True)
|
||||
n[n == 0] = 1.0
|
||||
return m / n
|
||||
|
||||
SessionLocal = _sync_session_factory()
|
||||
with SessionLocal() as session:
|
||||
s = session.get(MLSettings, 1)
|
||||
if s is None or not s.ccip_auto_apply_enabled:
|
||||
return "disabled"
|
||||
thr = float(s.ccip_auto_apply_threshold)
|
||||
|
||||
single = (
|
||||
sa_select(image_tag.c.image_record_id)
|
||||
.join(Tag, Tag.id == image_tag.c.tag_id)
|
||||
.where(Tag.kind == TagKind.character)
|
||||
.group_by(image_tag.c.image_record_id)
|
||||
.having(func.count() == 1)
|
||||
)
|
||||
ref_rows = session.execute(
|
||||
sa_select(image_tag.c.tag_id, ImageRegion.ccip_embedding)
|
||||
.select_from(ImageRegion)
|
||||
.join(
|
||||
image_tag,
|
||||
image_tag.c.image_record_id == ImageRegion.image_record_id,
|
||||
)
|
||||
.join(Tag, Tag.id == image_tag.c.tag_id)
|
||||
.where(Tag.kind == TagKind.character)
|
||||
.where(ImageRegion.kind.in_(fig))
|
||||
.where(ImageRegion.ccip_embedding.is_not(None))
|
||||
.where(ImageRegion.image_record_id.in_(single))
|
||||
).all()
|
||||
if not ref_rows:
|
||||
return "no-references"
|
||||
|
||||
by_char: dict[int, list] = {}
|
||||
for tid, vec in ref_rows:
|
||||
by_char.setdefault(tid, []).append(vec)
|
||||
ref_tags = list(by_char)
|
||||
mats = [_l2(np.asarray(by_char[t], dtype=np.float32)) for t in ref_tags]
|
||||
allref = np.vstack(mats) # (total, 768)
|
||||
seg = np.cumsum([0] + [len(m) for m in mats])[:-1] # per-char start
|
||||
|
||||
# Per character: images that already carry OR rejected the tag — skip.
|
||||
skip = {t: set() for t in ref_tags}
|
||||
for t in ref_tags:
|
||||
for (iid,) in session.execute(
|
||||
sa_select(image_tag.c.image_record_id).where(
|
||||
image_tag.c.tag_id == t
|
||||
)
|
||||
):
|
||||
skip[t].add(iid)
|
||||
for (iid,) in session.execute(
|
||||
sa_select(TagSuggestionRejection.image_record_id).where(
|
||||
TagSuggestionRejection.tag_id == t
|
||||
)
|
||||
):
|
||||
skip[t].add(iid)
|
||||
|
||||
img_ids = list(session.execute(
|
||||
sa_select(ImageRegion.image_record_id)
|
||||
.where(ImageRegion.kind.in_(fig), ImageRegion.ccip_embedding.is_not(None))
|
||||
.distinct()
|
||||
).scalars())
|
||||
|
||||
applied = 0
|
||||
chunk_n = 500
|
||||
for start in range(0, len(img_ids), chunk_n):
|
||||
chunk = img_ids[start:start + chunk_n]
|
||||
rows = session.execute(
|
||||
sa_select(ImageRegion.image_record_id, ImageRegion.ccip_embedding)
|
||||
.where(
|
||||
ImageRegion.image_record_id.in_(chunk),
|
||||
ImageRegion.kind.in_(fig),
|
||||
ImageRegion.ccip_embedding.is_not(None),
|
||||
)
|
||||
).all()
|
||||
by_img: dict[int, list] = {}
|
||||
for iid, vec in rows:
|
||||
by_img.setdefault(iid, []).append(vec)
|
||||
for iid, vecs in by_img.items():
|
||||
q = _l2(np.asarray(vecs, dtype=np.float32)) # (nq, 768)
|
||||
colmax = (q @ allref.T).max(axis=0) # (total,)
|
||||
charmax = np.maximum.reduceat(colmax, seg) # (n_chars,)
|
||||
for ci in np.where(charmax >= thr)[0]:
|
||||
t = ref_tags[int(ci)]
|
||||
if iid in skip[t]:
|
||||
continue
|
||||
skip[t].add(iid)
|
||||
session.execute(
|
||||
pg_insert(image_tag)
|
||||
.values(
|
||||
image_record_id=iid, tag_id=t, source="ccip_auto",
|
||||
)
|
||||
.on_conflict_do_nothing()
|
||||
)
|
||||
applied += 1
|
||||
session.commit()
|
||||
return f"applied={applied}"
|
||||
|
||||
@@ -76,6 +76,26 @@
|
||||
stricter — fewer but more confident matches. 0.85 recommended; below ~0.80
|
||||
a heavily-tagged character starts matching everything.
|
||||
</p>
|
||||
|
||||
<!-- Auto-apply -->
|
||||
<div v-if="ml.settings" class="d-flex align-center mt-5" style="gap:12px">
|
||||
<v-switch
|
||||
v-model="autoApply" color="accent" hide-details density="compact"
|
||||
:loading="savingAuto" label="Auto-apply confident matches"
|
||||
@update:model-value="onSaveAuto"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model.number="autoThreshold" type="number" min="0.80" max="0.99"
|
||||
step="0.01" density="compact" hide-details variant="outlined"
|
||||
style="max-width:96px" :disabled="!autoApply" label="at"
|
||||
@change="onSaveAuto"
|
||||
/>
|
||||
</div>
|
||||
<p class="fc-muted text-caption mt-1 mb-0">
|
||||
When on, a very-confident character match tags the image on its own (daily,
|
||||
reversible) — so identity tags keep flowing without review. Stricter than
|
||||
the suggest cut; 0.92 recommended.
|
||||
</p>
|
||||
</MaintenanceTile>
|
||||
</template>
|
||||
|
||||
@@ -97,6 +117,9 @@ const rotating = ref(false)
|
||||
const backfilling = ref(false)
|
||||
const threshold = ref(0.85)
|
||||
const savingThreshold = ref(false)
|
||||
const autoApply = ref(true)
|
||||
const autoThreshold = ref(0.92)
|
||||
const savingAuto = ref(false)
|
||||
const queue = ref({ pending: 0, leased: 0, done: 0, error: 0 })
|
||||
let pollTimer = null
|
||||
|
||||
@@ -119,8 +142,27 @@ onMounted(async () => {
|
||||
if (ml.settings?.ccip_match_threshold != null) {
|
||||
threshold.value = ml.settings.ccip_match_threshold
|
||||
}
|
||||
if (ml.settings?.ccip_auto_apply_enabled != null) {
|
||||
autoApply.value = ml.settings.ccip_auto_apply_enabled
|
||||
autoThreshold.value = ml.settings.ccip_auto_apply_threshold
|
||||
}
|
||||
} catch { /* non-fatal */ }
|
||||
})
|
||||
|
||||
async function onSaveAuto() {
|
||||
savingAuto.value = true
|
||||
try {
|
||||
await ml.patchSettings({
|
||||
ccip_auto_apply_enabled: autoApply.value,
|
||||
ccip_auto_apply_threshold: autoThreshold.value,
|
||||
})
|
||||
toast({ text: 'Auto-apply settings saved', type: 'success' })
|
||||
} catch (e) {
|
||||
toast({ text: `Could not save: ${e.message}`, type: 'error' })
|
||||
} finally {
|
||||
savingAuto.value = false
|
||||
}
|
||||
}
|
||||
onUnmounted(() => { if (pollTimer) clearInterval(pollTimer) })
|
||||
|
||||
async function onSaveThreshold() {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""CCIP few-shot character matcher (#114). numpy cosine on stored vectors — no
|
||||
model needed, so it runs in CI with synthetic CCIP vectors."""
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from backend.app.models import ImageRecord, ImageRegion, TagKind
|
||||
from backend.app.models.tag import image_tag
|
||||
@@ -103,3 +104,40 @@ async def test_threshold_gates_borderline_match(db):
|
||||
|
||||
assert any(m["tag_id"] == raven.id for m in await match_image(db, query.id, 0.85))
|
||||
assert await match_image(db, query.id, 0.95) == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multi_character_image_not_used_as_reference(db):
|
||||
# A figure on a 2-character image is ambiguous (tag is image-level), so it
|
||||
# must NOT seed either character's prototypes — else it'd match both.
|
||||
raven = await TagService(db).find_or_create("Raven", TagKind.character)
|
||||
daphne = await TagService(db).find_or_create("Daphne", TagKind.character)
|
||||
multi = await _img(db, "j" * 64)
|
||||
await _figure(db, multi.id, _ccip(0))
|
||||
await _tag_image(db, multi.id, raven.id)
|
||||
await _tag_image(db, multi.id, daphne.id)
|
||||
query = await _img(db, "k" * 64)
|
||||
await _figure(db, query.id, _ccip(0)) # identical to the ambiguous figure
|
||||
await db.commit()
|
||||
assert await match_image(db, query.id) == [] # no clean references → nothing
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_apply_tags_confident_match(db):
|
||||
raven = await TagService(db).find_or_create("Raven", TagKind.character)
|
||||
ref = await _img(db, "l" * 64)
|
||||
await _figure(db, ref.id, _ccip(0))
|
||||
await _tag_image(db, ref.id, raven.id) # single-character reference
|
||||
query = await _img(db, "m" * 64)
|
||||
await _figure(db, query.id, _ccip(0)) # identical → cosine 1.0
|
||||
await db.commit()
|
||||
|
||||
from backend.app.tasks.ml import scheduled_ccip_auto_apply
|
||||
assert "applied=" in scheduled_ccip_auto_apply() # sync task, own session
|
||||
|
||||
rows = (await db.execute(
|
||||
select(image_tag.c.tag_id, image_tag.c.source).where(
|
||||
image_tag.c.image_record_id == query.id
|
||||
)
|
||||
)).all()
|
||||
assert (raven.id, "ccip_auto") in [(t, s) for t, s in rows]
|
||||
|
||||
Reference in New Issue
Block a user