feat(gallery): hidden-view review endpoints — list + keep + un-hide (#141 step 5)
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 36s
CI / integration (push) Successful in 3m46s

GET /api/gallery/hidden-review lists unresolved presentation auto-hide flags
(image + presentation tag + conflict tag/score), most-concerning first. POST
.../keep resolves the flag (the tag stays). POST .../unhide removes the
presentation tag (image returns to the gallery), records a TagSuggestionRejection
so the head learns it misfired, and resolves the flag. Tests for list/keep/unhide.
Frontend review strip (shown when Show-hidden is on) next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
This commit is contained in:
2026-07-06 23:34:18 -04:00
parent 1f548d8a7b
commit 6c34f86477
2 changed files with 190 additions and 1 deletions
+101 -1
View File
@@ -3,9 +3,18 @@
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from quart import Blueprint, jsonify, request from quart import Blueprint, jsonify, request
from sqlalchemy import delete, select, update
from sqlalchemy.orm import aliased
from ..extensions import get_session from ..extensions import get_session
from ..services.gallery_service import GalleryService from ..models import (
ImageRecord,
PresentationReview,
Tag,
TagSuggestionRejection,
)
from ..models.tag import image_tag
from ..services.gallery_service import GalleryService, image_url, thumbnail_url
gallery_bp = Blueprint("gallery", __name__, url_prefix="/api/gallery") gallery_bp = Blueprint("gallery", __name__, url_prefix="/api/gallery")
@@ -219,6 +228,97 @@ async def jump():
return jsonify({"cursor": cursor}) return jsonify({"cursor": cursor})
# -- Hidden-view review (#141): auto-hidden chrome flagged "also looks like
# content", surfaced in the gallery's Show-hidden review strip. -----------
@gallery_bp.route("/hidden-review", methods=["GET"])
async def hidden_review():
"""Unresolved presentation auto-hide flags, most-concerning first (highest
content score) — for the gallery's Hidden-view review strip."""
ptag = aliased(Tag)
ctag = aliased(Tag)
async with get_session() as session:
rows = (await session.execute(
select(
PresentationReview.image_record_id,
PresentationReview.tag_id,
PresentationReview.conflict_tag_id,
PresentationReview.conflict_score,
ImageRecord.path, ImageRecord.thumbnail_path,
ImageRecord.sha256, ImageRecord.mime,
ptag.name.label("tag_name"),
ctag.name.label("conflict_name"),
)
.join(ImageRecord, ImageRecord.id == PresentationReview.image_record_id)
.join(ptag, ptag.id == PresentationReview.tag_id)
.outerjoin(ctag, ctag.id == PresentationReview.conflict_tag_id)
.where(PresentationReview.resolved_at.is_(None))
.order_by(PresentationReview.conflict_score.desc())
)).all()
return jsonify({"items": [
{
"image_id": r.image_record_id,
"tag_id": r.tag_id,
"tag_name": r.tag_name,
"conflict_tag_id": r.conflict_tag_id,
"conflict_name": r.conflict_name,
"conflict_score": r.conflict_score,
"thumbnail_url": thumbnail_url(r.thumbnail_path, r.sha256, r.mime),
"image_url": image_url(r.path),
}
for r in rows
]})
@gallery_bp.route(
"/hidden-review/<int:image_id>/<int:tag_id>/keep", methods=["POST"]
)
async def hidden_review_keep(image_id, tag_id):
"""Keep the auto-hide: resolve the flag; the tag stays applied (#141)."""
async with get_session() as session:
await session.execute(
update(PresentationReview)
.where(
PresentationReview.image_record_id == image_id,
PresentationReview.tag_id == tag_id,
)
.values(resolved_at=datetime.now(UTC))
)
await session.commit()
return "", 204
@gallery_bp.route(
"/hidden-review/<int:image_id>/<int:tag_id>/unhide", methods=["POST"]
)
async def hidden_review_unhide(image_id, tag_id):
"""Un-hide: remove the presentation tag (image returns to the gallery), record
a rejection so the head LEARNS it misfired, and resolve the flag (#141)."""
from sqlalchemy.dialects.postgresql import insert as pg_insert
async with get_session() as session:
await session.execute(
delete(image_tag).where(
image_tag.c.image_record_id == image_id,
image_tag.c.tag_id == tag_id,
)
)
await session.execute(
pg_insert(TagSuggestionRejection)
.values(image_record_id=image_id, tag_id=tag_id)
.on_conflict_do_nothing()
)
await session.execute(
update(PresentationReview)
.where(
PresentationReview.image_record_id == image_id,
PresentationReview.tag_id == tag_id,
)
.values(resolved_at=datetime.now(UTC))
)
await session.commit()
return "", 204
@gallery_bp.route("/image/<int:image_id>", methods=["GET"]) @gallery_bp.route("/image/<int:image_id>", methods=["GET"])
async def image_detail(image_id: int): async def image_detail(image_id: int):
async with get_session() as session: async with get_session() as session:
+89
View File
@@ -0,0 +1,89 @@
"""Hidden-view review endpoints (#141): list flagged auto-hides + keep / un-hide."""
import pytest
from sqlalchemy import select
from backend.app.models import (
ImageRecord,
PresentationReview,
Tag,
TagKind,
TagSuggestionRejection,
)
from backend.app.models.tag import image_tag
pytestmark = pytest.mark.integration
async def _setup_flag(db):
banner = (await db.execute(
select(Tag).where(Tag.is_system.is_(True), Tag.name == "banner")
)).scalar_one()
content = Tag(name="looksreal", kind=TagKind.general)
db.add(content)
img = ImageRecord(
path="/images/rv.jpg", sha256="rv" * 32, size_bytes=1, mime="image/jpeg",
width=1, height=1, origin="imported_filesystem",
integrity_status="unknown",
)
db.add(img)
await db.flush()
await db.execute(image_tag.insert().values(
image_record_id=img.id, tag_id=banner.id, source="presentation_auto"))
db.add(PresentationReview(
image_record_id=img.id, tag_id=banner.id,
conflict_tag_id=content.id, conflict_score=0.8,
))
await db.commit()
return img, banner, content
async def _source(db, image_id, tag_id):
return (await db.execute(
select(image_tag.c.source).where(
image_tag.c.image_record_id == image_id,
image_tag.c.tag_id == tag_id,
)
)).scalar_one_or_none()
@pytest.mark.asyncio
async def test_hidden_review_lists_flag(client, db):
img, banner, content = await _setup_flag(db)
body = await (await client.get("/api/gallery/hidden-review")).get_json()
assert len(body["items"]) == 1
it = body["items"][0]
assert it["image_id"] == img.id
assert it["tag_id"] == banner.id
assert it["conflict_tag_id"] == content.id
assert it["conflict_name"] == "looksreal"
assert it["conflict_score"] == 0.8
assert it["thumbnail_url"]
@pytest.mark.asyncio
async def test_hidden_review_keep_resolves_but_keeps_tag(client, db):
img, banner, _ = await _setup_flag(db)
resp = await client.post(
f"/api/gallery/hidden-review/{img.id}/{banner.id}/keep")
assert resp.status_code == 204
body = await (await client.get("/api/gallery/hidden-review")).get_json()
assert body["items"] == [] # flag resolved
assert await _source(db, img.id, banner.id) == "presentation_auto" # tag stays
@pytest.mark.asyncio
async def test_hidden_review_unhide_removes_tag_and_records_rejection(client, db):
img, banner, _ = await _setup_flag(db)
resp = await client.post(
f"/api/gallery/hidden-review/{img.id}/{banner.id}/unhide")
assert resp.status_code == 204
assert await _source(db, img.id, banner.id) is None # back in the gallery
rej = (await db.execute(
select(TagSuggestionRejection).where(
TagSuggestionRejection.image_record_id == img.id,
TagSuggestionRejection.tag_id == banner.id,
)
)).scalar_one_or_none()
assert rej is not None # head learns it misfired
body = await (await client.get("/api/gallery/hidden-review")).get_json()
assert body["items"] == []