feat(gallery): hidden-view review endpoints — list + keep + un-hide (#141 step 5)
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:
+101
-1
@@ -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:
|
||||||
|
|||||||
@@ -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"] == []
|
||||||
Reference in New Issue
Block a user