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