mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 11:58:34 +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