feat(wiki): add SHA-256 keyed image caption cache

This commit is contained in:
matevip 2026-05-02 19:04:25 +08:00
parent 6f0ead162e
commit b26a4ee4d1
5 changed files with 259 additions and 0 deletions

View File

@ -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.
*
* <p>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;
}

View File

@ -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.
*
* <p>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<WikiImageCaptionCacheEntity> {
/**
* 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);
}

View File

@ -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.
*
* <p>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.
*
* <p>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<WikiImageCaptionCacheEntity> lookup(String sha256) {
if (sha256 == null || sha256.isBlank()) {
return Optional.empty();
}
WikiImageCaptionCacheEntity row = mapper.selectOne(
new LambdaQueryWrapper<WikiImageCaptionCacheEntity>()
.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.
*
* <p>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()));
}
}

View File

@ -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);

View File

@ -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).';