mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(wiki): add SHA-256 keyed image caption cache
This commit is contained in:
parent
6f0ead162e
commit
b26a4ee4d1
@ -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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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()));
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
@ -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).';
|
||||
Loading…
Reference in New Issue
Block a user