package vip.mate.wiki.service; import cn.hutool.json.JSONObject; import cn.hutool.json.JSONUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import vip.mate.system.featureflag.FeatureFlagService; import vip.mate.tool.builtin.DocumentExtractTool; import vip.mate.tool.image.vision.ImageVisionService; import vip.mate.tool.image.vision.VisionRequest; import vip.mate.tool.image.vision.VisionResult; import vip.mate.wiki.WikiProperties; import vip.mate.wiki.event.WikiProcessingEvent; import vip.mate.wiki.model.WikiRawMaterialEntity; import vip.mate.wiki.repository.WikiRawMaterialMapper; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.util.HexFormat; import java.util.List; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; /** * Wiki 原始材料服务 * * @author MateClaw Team */ @Slf4j @Service @RequiredArgsConstructor public class WikiRawMaterialService { private static final String VISION_FLAG_KEY = "wiki.ocr.enabled"; private final WikiRawMaterialMapper rawMapper; private final WikiKnowledgeBaseService kbService; private final WikiProperties properties; private final ApplicationEventPublisher eventPublisher; private final DocumentExtractTool documentExtractTool; /** Optional — wired by Spring; null in minimal test harnesses where the cascade path is exercised separately. */ @org.springframework.beans.factory.annotation.Autowired(required = false) private WikiPageService pageService; /** RFC-013:删除时级联清理 chunk */ private final WikiChunkService chunkService; private final ImageVisionService imageVisionService; private final PdfImageExtractor pdfImageExtractor; private final FeatureFlagService featureFlagService; /** * RFC-012 follow-up #3:从 partial 状态触发的 reprocess 会在此 set 中打标, * 供 {@link vip.mate.wiki.service.WikiProcessingService#processRawMaterial(Long, boolean)} * 在 claim 之前消费,从而决定是否保留已生成的 exclusive page(续传语义)。 *
* 内存态:server 重启会丢,但原 raw 的 status 已被 reprocess 改为 pending,
* 重启后按正常 pending 流程跑(退化为「不删旧页的全量重跑」,功能不丢失只是
* 没有走 route 的 "update" 识别路径)。
*/
private final Set Backwards-compatible overload that omits the MIME type. Callers
* with the upload Content-Type in hand should prefer the four-argument
* variant — image-routing in particular needs an authoritative MIME so
* downstream vision providers know what they are decoding.
*/
@Transactional
public WikiRawMaterialEntity addFile(Long kbId, String title, String sourceType,
String sourcePath, long fileSize) {
return addFile(kbId, title, sourceType, null, sourcePath, fileSize);
}
/**
* Adds a file-type raw material with explicit MIME type.
*
* @param mimeType Content-Type string from the upload (e.g. {@code image/png});
* may be null if unknown
*/
@Transactional
public WikiRawMaterialEntity addFile(Long kbId, String title, String sourceType,
String mimeType, String sourcePath, long fileSize) {
WikiRawMaterialEntity entity = new WikiRawMaterialEntity();
entity.setKbId(kbId);
entity.setTitle(title);
entity.setSourceType(sourceType);
entity.setMimeType(capMimeType(mimeType));
entity.setSourcePath(sourcePath);
entity.setFileSize(fileSize);
entity.setProcessingStatus("pending");
// Compute hash of original upload bytes (for dedup). RFC-051: hash raw bytes
// directly — the previous `new String(bytes, UTF_8)` round-trip produced unstable
// hashes for binary files (PDF/Office) because invalid UTF-8 sequences become
// replacement characters, collapsing distinct files into the same hash.
try {
byte[] bytes = java.nio.file.Files.readAllBytes(java.nio.file.Paths.get(sourcePath));
entity.setContentHash(computeHashOfBytes(bytes));
} catch (Exception e) {
log.warn("[Wiki] Could not compute file hash for dedup: {}", e.getMessage());
}
// Dedup: reuse any existing row with the same hash in this KB (any status)
if (entity.getContentHash() != null) {
WikiRawMaterialEntity existing = rawMapper.selectOne(
new LambdaQueryWrapper
* 在 {@code WikiProcessingService.processChunkTwoPhase} 的四个节点被调用:
*
* RFC-051: this method no longer touches {@code contentHash}. The previous
* behavior overwrote the original-upload hash with an extracted-text hash,
* which broke upload dedup (re-uploading the same file would compute a hash
* over raw bytes but find a row whose hash had been replaced with extracted
* text). The {@code contentHash} field is now an immutable identity for the
* uploaded artifact; downstream short-circuiting uses {@code lastProcessedHash}.
*/
@Transactional
public void updateExtractedText(Long id, String extractedText) {
WikiRawMaterialEntity entity = rawMapper.selectById(id);
if (entity == null) return;
entity.setExtractedText(extractedText);
rawMapper.updateById(entity);
}
/**
* 记录本次成功处理时的 content_hash(RFC-012 Change 5 的短路依据)。
*/
@Transactional
public void setLastProcessedHash(Long id, String hash) {
WikiRawMaterialEntity entity = rawMapper.selectById(id);
if (entity == null) return;
entity.setLastProcessedHash(hash);
rawMapper.updateById(entity);
}
/**
* 重新处理:重置状态为 pending 并发布事件。
*
* 如果之前状态是 {@code partial},把 rawId 加入 {@link #partialResumeIds},
* 下游的 WikiProcessingService 会据此决定是否保留已生成的 exclusive page(续传语义)。
*/
@Transactional
public void reprocess(Long id) {
WikiRawMaterialEntity entity = rawMapper.selectById(id);
if (entity == null) {
throw new IllegalArgumentException("Raw material not found: " + id);
}
boolean wasPartial = "partial".equals(entity.getProcessingStatus());
entity.setProcessingStatus("pending");
entity.setErrorMessage(null);
rawMapper.updateById(entity);
if (wasPartial) {
partialResumeIds.add(id);
log.info("[Wiki] Raw material queued for PARTIAL RESUME: id={} (existing pages will be kept)", id);
} else {
log.info("[Wiki] Raw material queued for reprocessing: id={}", id);
}
eventPublisher.publishEvent(new WikiProcessingEvent(this, entity.getId(), entity.getKbId()));
}
/**
* 消费 partial resume 标记:若存在则返回 true 并从 set 中移除(一次性)。
*
* 必须在 {@link #claimForProcessing(Long)} 之前调用:claim 会把 status 改成 processing,
* 此时已无法区分 raw 原本是从 partial 还是从 failed/pending 过来的。
*/
public boolean consumePartialResumeFlag(Long id) {
return partialResumeIds.remove(id);
}
@Transactional
public void delete(Long id) {
// Load the entity first so we know which KB the cascade lives in.
// Once the raw row is gone we'd lose kb_id and couldn't run the
// page cleanup; do it before the deleteById.
WikiRawMaterialEntity entity = rawMapper.selectById(id);
// Cascade-delete pages this raw was the sole source of, and strip
// the raw_id reference from multi-source pages — same semantics
// reprocess uses (WikiProcessingService line ~257). Without this,
// pages survive the raw delete and become orphans: search keeps
// returning them, the page list is polluted, citations dangle.
if (entity != null && entity.getKbId() != null && pageService != null) {
try {
int cleaned = pageService.deleteExclusiveBySourceRawId(entity.getKbId(), id);
if (cleaned > 0) {
log.info("[Wiki] Cascade-deleted {} exclusive page(s) for raw={}", cleaned, id);
}
} catch (Exception e) {
log.warn("[Wiki] Failed to cascade-delete pages for raw={}: {}", id, e.getMessage());
}
}
rawMapper.deleteById(id);
// Cascade-clean chunks so semantic search doesn't hit orphan rows.
try {
if (chunkService != null) {
chunkService.deleteByRawId(id);
}
} catch (Exception e) {
log.warn("[Wiki] Failed to cascade-delete chunks for raw={}: {}", id, e.getMessage());
}
// Source file last — DB pointer is gone, no other row references this
// path (each upload gets a timestamp-prefixed unique name), so
// leaving it on disk would just accumulate as the upload tree grows.
// Failure here is soft-logged and non-blocking — operator can run a
// sweep later if disk usage matters more than the delete RTT.
if (entity != null) {
cleanupFile(entity.getSourcePath());
}
}
/**
* 获取可用文本内容
*
* 优先级:已缓存的 extractedText → 原始文本 → 调用 DocumentExtractTool 提取二进制文件
*/
public String getTextContent(WikiRawMaterialEntity entity) {
// 已有缓存的提取文本
if (entity.getExtractedText() != null && !entity.getExtractedText().isBlank()) {
return entity.getExtractedText();
}
// 文本类型直接返回原始内容
if ("text".equals(entity.getSourceType())) {
return entity.getOriginalContent();
}
// Image source: route through the vision-in pipeline. Failures (feature
// flag off, no provider configured, all providers failed) degrade to an
// empty caption rather than blocking the upload — the user keeps the
// raw row and can retry once vision is configured.
if ("image".equals(entity.getSourceType())) {
return extractTextFromImage(entity);
}
// 二进制文件:调用 DocumentExtractTool 提取
if (entity.getSourcePath() != null && !entity.getSourcePath().isBlank()) {
try {
String result = documentExtractTool.extract_document_text(entity.getSourcePath(), null);
JSONObject json = JSONUtil.parseObj(result);
if (json.getBool("success", false)) {
String text = json.getStr("text");
if (text != null && !text.isBlank()) {
boolean truncated = json.getBool("truncated", false);
// Append inline-image captions for PDFs so chunk-level search
// hits chart/diagram contents that the text extractor missed.
// Failures are non-fatal: the body text is still returned.
String enriched = appendPdfImageCaptions(entity, text);
if (truncated) {
// Truncated results are not cached so we don't lose the tail
// permanently — we still return the text for chunking use.
log.warn("[Wiki] Extracted text truncated at {} chars for: {} (full document may be larger)",
text.length(), entity.getSourcePath());
} else {
updateExtractedText(entity.getId(), enriched);
}
log.info("[Wiki] Extracted text from {}: {} chars (text) → {} chars (enriched), method={}, truncated={}, cached={}",
entity.getSourcePath(), text.length(), enriched.length(),
json.getStr("method"), truncated, !truncated);
return enriched;
}
}
log.warn("[Wiki] Document extraction returned no text for: {}", entity.getSourcePath());
} catch (Exception e) {
log.error("[Wiki] Document extraction failed for {}: {}", entity.getSourcePath(), e.getMessage());
}
}
return entity.getOriginalContent();
}
/**
* For PDF raw materials, walks the inline images and appends a section of
* {@code [图 P{n}#{m}]: When {@link #VISION_FLAG_KEY} is off, returns the body unchanged
* without re-parsing the PDF — the operator's "off = ignore images"
* intent. Flipping the flag on later requires a manual reprocess for
* existing rows to pick up captions.
*/
private String appendPdfImageCaptions(WikiRawMaterialEntity entity, String body) {
if (!"pdf".equals(entity.getSourceType()) || pdfImageExtractor == null) {
return body;
}
if (featureFlagService == null || !featureFlagService.isEnabled(VISION_FLAG_KEY)) {
return body;
}
java.nio.file.Path pdfPath = java.nio.file.Paths.get(entity.getSourcePath());
try {
List Failure modes (feature flag off, no provider, all providers failed,
* IO errors reading the image bytes) are intentionally swallowed and
* surfaced as the empty string. The upload still succeeded; the raw
* row remains and downstream code is expected to tolerate "no
* extracted text yet" — calling this method again later (e.g. after
* an operator enables the feature flag) re-runs the pipeline.
*/
private String extractTextFromImage(WikiRawMaterialEntity entity) {
if (entity.getSourcePath() == null || entity.getSourcePath().isBlank()) {
log.warn("[Wiki] Image raw material missing sourcePath: id={}", entity.getId());
return "";
}
if (featureFlagService == null || !featureFlagService.isEnabled(VISION_FLAG_KEY)) {
log.info("[Wiki] Image raw id={} skipped: {} is disabled",
entity.getId(), VISION_FLAG_KEY);
return "";
}
byte[] imageBytes;
try {
imageBytes = java.nio.file.Files.readAllBytes(java.nio.file.Paths.get(entity.getSourcePath()));
} catch (Exception e) {
log.error("[Wiki] Failed to read image bytes for id={}: {}", entity.getId(), e.getMessage());
return "";
}
VisionRequest request = VisionRequest.builder()
.imageBytes(imageBytes)
.mimeType(resolveMimeType(entity))
.build();
VisionResult result;
try {
result = imageVisionService.caption(request);
} catch (Exception e) {
log.warn("[Wiki] Vision pipeline failed for raw id={}: {}", entity.getId(), e.getMessage());
return "";
}
StringBuilder text = new StringBuilder(result.getCaption() == null ? "" : result.getCaption());
if (result.getVisibleText() != null && !result.getVisibleText().isBlank()) {
text.append("\n\n--- Visible text ---\n").append(result.getVisibleText());
}
String combined = text.toString();
updateExtractedText(entity.getId(), combined);
log.info("[Wiki] Image vision captioned raw id={} provider={} model={} chars={}",
entity.getId(), result.getProviderId(), result.getModel(), combined.length());
return combined;
}
/** Best-effort MIME resolution: prefer the persisted column, fall back to file extension. */
private static String resolveMimeType(WikiRawMaterialEntity entity) {
if (entity.getMimeType() != null && !entity.getMimeType().isBlank()) {
return entity.getMimeType();
}
String path = entity.getSourcePath() == null ? "" : entity.getSourcePath().toLowerCase();
int dot = path.lastIndexOf('.');
if (dot < 0) return "application/octet-stream";
String ext = path.substring(dot + 1);
return switch (ext) {
case "png" -> "image/png";
case "jpg", "jpeg" -> "image/jpeg";
case "webp" -> "image/webp";
case "gif" -> "image/gif";
case "bmp" -> "image/bmp";
case "tiff", "tif" -> "image/tiff";
default -> "application/octet-stream";
};
}
/**
* Recover raw materials stuck in 'processing' status after a server restart.
* Resets them to 'pending', clears stale progress fields, and optionally
* fires processing events so they get picked up automatically.
*
* @return number of recovered rows
*/
@Transactional
public int recoverStuckRawMaterialsOnStartup() {
List
*
*/
@Transactional
public void updateProgress(Long id, String phase, int done, int total) {
WikiRawMaterialEntity entity = rawMapper.selectById(id);
if (entity == null) return;
entity.setProgressPhase(phase);
entity.setProgressDone(done);
entity.setProgressTotal(total);
rawMapper.updateById(entity);
}
@Transactional
public void updateProcessingStatus(Long id, String status, String errorMessage) {
WikiRawMaterialEntity entity = rawMapper.selectById(id);
if (entity == null) return;
entity.setProcessingStatus(status);
entity.setErrorMessage(errorMessage);
if ("completed".equals(status)) {
entity.setLastProcessedAt(java.time.LocalDateTime.now());
}
// Cancellation flag is only meaningful while a row is being processed.
// Any transition out of 'processing' clears it so the field reflects
// an idle row's true state and the next reprocess starts clean.
if (!"processing".equals(status)) {
entity.setCancelRequested(Boolean.FALSE);
}
rawMapper.updateById(entity);
}
/**
* Cache the extracted text for a raw material.
*