From b26a4ee4d1e86f3661fa10d5f6f0994d48acbd22 Mon Sep 17 00:00:00 2001 From: matevip Date: Sat, 2 May 2026 19:04:25 +0800 Subject: [PATCH] feat(wiki): add SHA-256 keyed image caption cache --- .../model/WikiImageCaptionCacheEntity.java | 62 +++++++++++++ .../WikiImageCaptionCacheMapper.java | 32 +++++++ .../service/WikiImageCaptionCacheService.java | 86 +++++++++++++++++++ .../h2/V79__wiki_image_caption_cache.sql | 45 ++++++++++ .../mysql/V79__wiki_image_caption_cache.sql | 34 ++++++++ 5 files changed, 259 insertions(+) create mode 100644 mateclaw-server/src/main/java/vip/mate/wiki/model/WikiImageCaptionCacheEntity.java create mode 100644 mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiImageCaptionCacheMapper.java create mode 100644 mateclaw-server/src/main/java/vip/mate/wiki/service/WikiImageCaptionCacheService.java create mode 100644 mateclaw-server/src/main/resources/db/migration/h2/V79__wiki_image_caption_cache.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/mysql/V79__wiki_image_caption_cache.sql diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiImageCaptionCacheEntity.java b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiImageCaptionCacheEntity.java new file mode 100644 index 00000000..02a4ebc8 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiImageCaptionCacheEntity.java @@ -0,0 +1,62 @@ +package vip.mate.wiki.model; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * Cached factual caption for one image, keyed by SHA-256 of raw bytes. + * + *

Backed by {@code mate_wiki_image_caption_cache}. Cache is shared + * across all knowledge bases so an image uploaded twice in different + * contexts costs exactly one vision call. + * + * @author MateClaw Team + */ +@Data +@TableName("mate_wiki_image_caption_cache") +public class WikiImageCaptionCacheEntity { + + @TableId(type = IdType.AUTO) + private Long id; + + /** SHA-256 hex digest (64 chars, lowercase) of the original image bytes. */ + private String imageSha256; + + /** Primary output: 2-4 sentence factual description. */ + private String caption; + + /** Best-effort OCR text recovered from the image; may be null. */ + private String visibleText; + + private String mimeType; + + /** Vendor-specific model identifier (e.g. {@code qwen-vl-max}). */ + private String captureModel; + + /** Provider id from the SPI registry (e.g. {@code dashscope-vision}). */ + private String providerId; + + /** Wall-clock duration of the original vision call, in milliseconds. */ + private Long durationMs; + + /** Bumped lazily on each lookup hit; failure to bump is silently ignored. */ + private Long hitCount; + + private LocalDateTime capturedAt; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + + @TableLogic + private Integer deleted; +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiImageCaptionCacheMapper.java b/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiImageCaptionCacheMapper.java new file mode 100644 index 00000000..f690999c --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiImageCaptionCacheMapper.java @@ -0,0 +1,32 @@ +package vip.mate.wiki.repository; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Update; +import vip.mate.wiki.model.WikiImageCaptionCacheEntity; + +/** + * Mapper for the SHA-256 keyed image caption cache. + * + *

Read path goes through unique key {@code image_sha256} (uses + * {@link com.baomidou.mybatisplus.core.mapper.BaseMapper#selectList} + * with a query wrapper from the service layer). Write path uses + * {@link #bumpHitCount(String)} to increment the hit counter without + * round-tripping the row through Java memory. + * + * @author MateClaw Team + */ +@Mapper +public interface WikiImageCaptionCacheMapper extends BaseMapper { + + /** + * Atomically increments {@code hit_count} for the row matching the + * given SHA. Returns the number of rows affected (0 when no row + * exists or the row is soft-deleted). + */ + @Update("UPDATE mate_wiki_image_caption_cache " + + "SET hit_count = hit_count + 1 " + + "WHERE image_sha256 = #{sha256} AND deleted = 0") + int bumpHitCount(@Param("sha256") String sha256); +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiImageCaptionCacheService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiImageCaptionCacheService.java new file mode 100644 index 00000000..d47daf98 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiImageCaptionCacheService.java @@ -0,0 +1,86 @@ +package vip.mate.wiki.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import vip.mate.wiki.model.WikiImageCaptionCacheEntity; +import vip.mate.wiki.repository.WikiImageCaptionCacheMapper; + +import java.util.Optional; + +/** + * Service layer for the SHA-256 keyed image caption cache. + * + *

The cache is the storage half of the vision-in pipeline: the + * {@code ImageVisionService} (added in a subsequent PR) calls + * {@link #lookup(String)} before invoking a remote vision provider and + * calls {@link #persist(WikiImageCaptionCacheEntity)} once a fresh + * caption is available. Lookups bump the per-row hit counter on a + * best-effort basis — if the bump fails the read still succeeds, since + * the counter is operational metadata only. + * + *

Concurrent inserts of the same SHA are tolerated: the unique key + * lets the database serialize them, and the loser quietly drops its + * value (the earlier writer's caption is equally valid for a content- + * addressed cache). + * + * @author MateClaw Team + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class WikiImageCaptionCacheService { + + private final WikiImageCaptionCacheMapper mapper; + + /** Returns the cached row for the given image SHA-256, or empty on miss. */ + public Optional lookup(String sha256) { + if (sha256 == null || sha256.isBlank()) { + return Optional.empty(); + } + WikiImageCaptionCacheEntity row = mapper.selectOne( + new LambdaQueryWrapper() + .eq(WikiImageCaptionCacheEntity::getImageSha256, sha256)); + if (row == null) { + return Optional.empty(); + } + + // Best-effort hit-count bump; never fail the read on a counter mishap. + try { + mapper.bumpHitCount(sha256); + } catch (Exception e) { + log.debug("[ImageCaptionCache] hit_count bump failed for sha={}: {}", + truncate(sha256), e.getMessage()); + } + return Optional.of(row); + } + + /** + * Inserts a new cache row, treating duplicate-key collisions as success. + * + *

When two requests race on the same novel SHA, both arrive at the + * vision provider, both produce a caption, and both attempt to write. + * The DB enforces one winner; the loser sees a duplicate-key error, + * which we swallow because the earlier-written caption is just as good. + */ + @Transactional + public void persist(WikiImageCaptionCacheEntity row) { + if (row == null || row.getImageSha256() == null) { + throw new IllegalArgumentException("image_sha256 is required"); + } + try { + mapper.insert(row); + } catch (DuplicateKeyException dup) { + log.debug("[ImageCaptionCache] race on sha={}; keeping earlier writer's value", + truncate(row.getImageSha256())); + } + } + + /** Returns the first 8 hex chars of the SHA, suitable for log messages. */ + private static String truncate(String sha) { + return sha == null ? "null" : sha.substring(0, Math.min(8, sha.length())); + } +} diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V79__wiki_image_caption_cache.sql b/mateclaw-server/src/main/resources/db/migration/h2/V79__wiki_image_caption_cache.sql new file mode 100644 index 00000000..48ee664e --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V79__wiki_image_caption_cache.sql @@ -0,0 +1,45 @@ +-- mate_wiki_image_caption_cache: SHA-256 keyed image caption store. +-- +-- Cache is shared across all knowledge bases — the same image bytes +-- uploaded twice (in different KBs, by different users, or to the same +-- KB at different times) cost exactly one vision-LLM call total. +-- +-- The cache is content-addressed by raw image bytes; perceptual variations +-- (re-encoded JPEG, slightly cropped) are intentionally treated as misses. +-- A second-tier perceptual hash can be added later if the miss rate +-- becomes a cost concern. + +CREATE TABLE IF NOT EXISTS mate_wiki_image_caption_cache ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + image_sha256 CHAR(64) NOT NULL UNIQUE, + + -- Primary output: 2-4 sentence factual caption (≤ ~500 chars). + caption CLOB NOT NULL, + + -- Best-effort OCR text recovered from the image; may be null if the + -- vision provider didn't surface anything text-like. + visible_text CLOB, + + mime_type VARCHAR(64), + + -- Model identifier reported by the provider (e.g. 'qwen-vl-max', + -- 'gpt-4o-2024-08-06'); useful for invalidating cache on model upgrade. + capture_model VARCHAR(128) NOT NULL, + + -- Provider id from the SPI registry (e.g. 'dashscope-vision'). + provider_id VARCHAR(64) NOT NULL, + + -- Wall-clock time the original LLM call took, in milliseconds. + duration_ms BIGINT, + + -- How many times this row has served a lookup; bumped lazily. + hit_count BIGINT NOT NULL DEFAULT 0, + + captured_at TIMESTAMP NOT NULL, + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted INT NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_wicc_sha ON mate_wiki_image_caption_cache (image_sha256); +CREATE INDEX IF NOT EXISTS idx_wicc_captured ON mate_wiki_image_caption_cache (captured_at); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V79__wiki_image_caption_cache.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V79__wiki_image_caption_cache.sql new file mode 100644 index 00000000..1ac7b7ca --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V79__wiki_image_caption_cache.sql @@ -0,0 +1,34 @@ +-- mate_wiki_image_caption_cache: SHA-256 keyed image caption store. +-- +-- Cache is shared across all knowledge bases — the same image bytes +-- uploaded twice (in different KBs, by different users, or to the same +-- KB at different times) cost exactly one vision-LLM call total. +-- +-- The cache is content-addressed by raw image bytes; perceptual variations +-- (re-encoded JPEG, slightly cropped) are intentionally treated as misses. +-- A second-tier perceptual hash can be added later if the miss rate +-- becomes a cost concern. + +CREATE TABLE IF NOT EXISTS mate_wiki_image_caption_cache ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + image_sha256 CHAR(64) NOT NULL, + + caption TEXT NOT NULL, + visible_text TEXT, + mime_type VARCHAR(64), + + capture_model VARCHAR(128) NOT NULL, + provider_id VARCHAR(64) NOT NULL, + + duration_ms BIGINT, + hit_count BIGINT NOT NULL DEFAULT 0, + + captured_at DATETIME(3) NOT NULL, + create_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + update_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + deleted TINYINT NOT NULL DEFAULT 0, + + UNIQUE KEY uk_wicc_sha (image_sha256), + KEY idx_wicc_captured (captured_at) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci + COMMENT = 'SHA-256 keyed image caption cache (shared across knowledge bases).';