From f910d762a34a9f76fe25e3fe0c8af21c77151608 Mon Sep 17 00:00:00 2001 From: matevip Date: Sat, 2 May 2026 19:04:58 +0800 Subject: [PATCH] feat(wiki): feature-flag toggle UI + defer extracted_text cache when vision unavailable --- .../mate/wiki/service/PdfImageExtractor.java | 48 ++++ .../wiki/service/WikiRawMaterialService.java | 85 +++++-- mateclaw-ui/src/api/index.ts | 27 +++ mateclaw-ui/src/i18n/locales/en-US.ts | 26 +++ mateclaw-ui/src/i18n/locales/zh-CN.ts | 26 +++ mateclaw-ui/src/router/index.ts | 6 + .../src/views/Settings/FeatureFlags/index.vue | 221 ++++++++++++++++++ mateclaw-ui/src/views/Settings/Layout.vue | 6 + 8 files changed, 428 insertions(+), 17 deletions(-) create mode 100644 mateclaw-ui/src/views/Settings/FeatureFlags/index.vue diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/PdfImageExtractor.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/PdfImageExtractor.java index 443a8cef..742d3228 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/PdfImageExtractor.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/PdfImageExtractor.java @@ -63,6 +63,54 @@ public class PdfImageExtractor { // a mock or a no-op wrapper. } + /** + * Quick check: does the PDF contain at least one inline image that + * passes the size threshold? Stops scanning at the first qualifying + * image so it's cheap on large documents. + * + *

Used by the upload pipeline to decide whether the absence of + * captions is an "actually nothing to caption" outcome (safe to cache) + * vs a "vision was unavailable" outcome (defer caching until flag + * flips on so the next read retries). + */ + public boolean hasInlineImages(Path pdfPath) { + if (pdfPath == null) { + return false; + } + File pdfFile = pdfPath.toFile(); + if (!pdfFile.isFile()) { + return false; + } + try (PDDocument doc = Loader.loadPDF(pdfFile)) { + for (PDPage page : doc.getPages()) { + PDResources resources = page.getResources(); + if (resources == null) continue; + for (COSName name : resources.getXObjectNames()) { + PDXObject obj; + try { + obj = resources.getXObject(name); + } catch (IOException ignored) { + continue; + } + if (!(obj instanceof PDImageXObject pdImage)) continue; + BufferedImage bi; + try { + bi = pdImage.getImage(); + } catch (IOException | RuntimeException ignored) { + continue; + } + if (bi.getWidth() >= MIN_IMAGE_SIDE_PX + && bi.getHeight() >= MIN_IMAGE_SIDE_PX) { + return true; + } + } + } + } catch (IOException e) { + log.debug("[PdfImage] hasInlineImages probe failed for {}: {}", pdfPath, e.getMessage()); + } + return false; + } + /** * Walks every page of {@code pdfPath}, captions each qualifying inline * image, and returns the rendered marker lines in document order. diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRawMaterialService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRawMaterialService.java index d4b1a746..8085b6ad 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRawMaterialService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRawMaterialService.java @@ -8,6 +8,7 @@ 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; @@ -34,6 +35,8 @@ import java.util.concurrent.ConcurrentHashMap; @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; @@ -43,6 +46,7 @@ public class WikiRawMaterialService { private final WikiChunkService chunkService; private final ImageVisionService imageVisionService; private final PdfImageExtractor pdfImageExtractor; + private final FeatureFlagService featureFlagService; /** * RFC-012 follow-up #3:从 partial 状态触发的 reprocess 会在此 set 中打标, @@ -356,19 +360,26 @@ public class WikiRawMaterialService { // 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); + EnrichmentOutcome enrichment = appendPdfImageCaptions(entity, text); if (truncated) { // 截断的结果不缓存,避免永久丢失后半内容。返回文本供分块处理使用。 log.warn("[Wiki] Extracted text truncated at {} chars for: {} (full document may be larger)", text.length(), entity.getSourcePath()); + } else if (!enrichment.shouldCache()) { + // Vision was unavailable but the PDF has images we'd otherwise + // caption — leave extracted_text NULL so the next call retries + // once the operator enables wiki.ocr.enabled. + log.info("[Wiki] PDF id={} captioning deferred (vision disabled or unavailable); " + + "extracted_text not cached so next read retries", entity.getId()); } else { // Full extraction: cache to avoid re-extracting on subsequent calls. - updateExtractedText(entity.getId(), enriched); + updateExtractedText(entity.getId(), enrichment.text()); } - log.info("[Wiki] Extracted text from {}: {} chars (text) → {} chars (enriched), method={}, truncated={}", - entity.getSourcePath(), text.length(), enriched.length(), json.getStr("method"), truncated); - return enriched; + log.info("[Wiki] Extracted text from {}: {} chars (text) → {} chars (enriched), method={}, truncated={}, cached={}", + entity.getSourcePath(), text.length(), enrichment.text().length(), + json.getStr("method"), truncated, enrichment.shouldCache() && !truncated); + return enrichment.text(); } } log.warn("[Wiki] Document extraction returned no text for: {}", entity.getSourcePath()); @@ -379,35 +390,75 @@ public class WikiRawMaterialService { return entity.getOriginalContent(); } + /** + * Enrichment outcome: text + a hint about whether the result is final. + * + *

{@code shouldCache=false} signals the caller to skip the + * extracted_text cache write so the next call re-runs PDF image + * captioning. This handles the common operator workflow of uploading + * a PDF before flipping {@code wiki.ocr.enabled} on — without the + * skip, the partial text-only result would block the eventual + * caption catch-up. + */ + private record EnrichmentOutcome(String text, boolean shouldCache) { } + /** * For PDF raw materials, walks the inline images and appends a section of * {@code [图 P{n}#{m}]: } markers so downstream chunking and - * search can index image contents. No-op for non-PDF source types and - * when the vision pipeline is unavailable. + * search can index image contents. + * + *

Returns an {@link EnrichmentOutcome} that distinguishes three cases: + *

*/ - private String appendPdfImageCaptions(WikiRawMaterialEntity entity, String body) { - if (!"pdf".equals(entity.getSourceType())) { - return body; + private EnrichmentOutcome appendPdfImageCaptions(WikiRawMaterialEntity entity, String body) { + if (!"pdf".equals(entity.getSourceType()) || pdfImageExtractor == null) { + return new EnrichmentOutcome(body, true); } - if (pdfImageExtractor == null) { - return body; + + java.nio.file.Path pdfPath = java.nio.file.Paths.get(entity.getSourcePath()); + + // Vision-disabled path: only refuse the cache when the PDF actually has + // qualifying images. Image-less PDFs can still be cached as text-only. + boolean visionEnabled = featureFlagService != null + && featureFlagService.isEnabled(VISION_FLAG_KEY); + if (!visionEnabled) { + boolean hasImages = pdfImageExtractor.hasInlineImages(pdfPath); + if (hasImages) { + log.info("[Wiki] PDF id={} has inline images but {} is disabled; " + + "skipping captioning and deferring extracted_text cache", + entity.getId(), VISION_FLAG_KEY); + return new EnrichmentOutcome(body, false); + } + return new EnrichmentOutcome(body, true); } + try { - List snippets = pdfImageExtractor.captionInlineImages( - java.nio.file.Paths.get(entity.getSourcePath())); + List snippets = pdfImageExtractor.captionInlineImages(pdfPath); if (snippets.isEmpty()) { - return body; + return new EnrichmentOutcome(body, true); } StringBuilder sb = new StringBuilder(body); sb.append("\n\n--- Inline images ---\n"); for (String snippet : snippets) { sb.append(snippet).append('\n'); } - return sb.toString(); + return new EnrichmentOutcome(sb.toString(), true); } catch (Exception e) { log.warn("[Wiki] PDF inline-image captioning failed for id={}: {}", entity.getId(), e.getMessage()); - return body; + return new EnrichmentOutcome(body, true); } } diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index 3cfbe384..b101122d 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -626,3 +626,30 @@ export const auditApi = { size?: number }) => http.get('/audit/events', { params }), } + +// ==================== Feature Flags ==================== +export interface FeatureFlag { + id: number + flagKey: string + enabled: boolean + description?: string + whitelistKbIds?: string + whitelistUserIds?: string + rolloutPercent?: number + createTime?: string + updateTime?: string +} + +export interface FeatureFlagUpdate { + enabled?: boolean + description?: string + whitelistKbIds?: string + whitelistUserIds?: string + rolloutPercent?: number +} + +export const featureFlagApi = { + list: () => http.get('/feature-flags'), + update: (flagKey: string, data: FeatureFlagUpdate) => + http.put(`/feature-flags/${flagKey}`, data), +} diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 3e0a9bf5..f5659a05 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -318,9 +318,35 @@ export default { music: 'Music Generation', video: 'Video Generation', model3d: '3D Generation', + featureFlags: 'Feature Flags', about: 'About', advanced: 'Advanced', }, + featureFlags: { + title: 'Feature Flags', + description: 'Runtime-toggleable feature switches. Edits apply immediately on the writing instance and propagate to peers within one cache refresh tick (≈30 s); no server restart required.', + empty: 'No feature flags registered.', + enabled: 'Enabled {key}', + disabled: 'Disabled {key}', + toggleFailed: 'Toggle failed, please retry', + footer: 'New features ship disabled by default. After enabling, watch the metrics and logs for the relevant module before widening the rollout.', + scope: { + kb: 'KB whitelist', + user: 'User whitelist', + rollout: 'Rollout {pct}%', + }, + descriptions: { + 'wiki.ocr.enabled': 'Image OCR / vision-in pipeline for wiki uploads — captions images via a vision model on upload', + 'wiki.compile.4stage.enabled': 'Four-stage knowledge base compilation pipeline (Summary / Planning / Generation / Index)', + 'wiki.compile.cache.enabled': 'Prompt cache layer for the wiki compile pipeline — cuts ~30% input tokens on cache hits', + 'wiki.confidence.enabled': 'Confidence taxonomy on wiki relations and pages (EXTRACTED / INFERRED / AMBIGUOUS / UNVERIFIED)', + 'wiki.hot_cache.enabled': 'KB-level recent-activity snapshot injected into the agent system prompt', + 'wiki.graph.insights.enabled': 'Wiki graph insights panel (surprising connections / gaps / bridges)', + 'wiki.graph.adamic_adar.enabled': 'Adamic-Adar topology signal, additive to the existing four relevance signals', + 'wiki.graph.boundary.enabled': 'Boundary score for surfacing dangling pages (high out-degree, low in-degree)', + 'wiki.relation.cache.enabled': 'Persistent cache for wiki page-to-page relation computation (default on, performance baseline)', + }, + }, modelTitle: 'Model Management', modelDesc: 'Manage provider presets and default model selection', systemTitle: 'System', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index 0ed52cc8..e19f6aca 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -308,9 +308,35 @@ export default { music: '音乐生成', video: '视频生成', model3d: '3D 生成', + featureFlags: '功能开关', about: '关于', advanced: '高级', }, + featureFlags: { + title: '功能开关', + description: '运行时可切换的功能开关。修改后立即在所有节点生效(每实例 30 秒内同步缓存);不需要重启服务。', + empty: '当前没有已注册的功能开关。', + enabled: '已开启 {key}', + disabled: '已关闭 {key}', + toggleFailed: '切换失败,请稍后重试', + footer: '默认情况下新功能以关闭状态发布。开启后请关注对应模块的指标与日志,确认无回归再扩大灰度。', + scope: { + kb: '知识库白名单', + user: '用户白名单', + rollout: '灰度比例 {pct}%', + }, + descriptions: { + 'wiki.ocr.enabled': '维基上传的图片 OCR / 视觉理解流水线(开启后图片会调用 vision 模型生成事实性描述)', + 'wiki.compile.4stage.enabled': '四阶段知识库编译流水线(摘要 / 规划 / 生成 / 索引)', + 'wiki.compile.cache.enabled': '维基编译流水线的提示词缓存层(命中后能省 30%+ token)', + 'wiki.confidence.enabled': '维基关系与页面的置信度分类(EXTRACTED / INFERRED / AMBIGUOUS / UNVERIFIED)', + 'wiki.hot_cache.enabled': '知识库级"最近活动"快照,注入到 Agent 的系统提示', + 'wiki.graph.insights.enabled': '维基图谱洞察面板(意外连接 / 知识缺口 / 桥接节点)', + 'wiki.graph.adamic_adar.enabled': 'Adamic-Adar 拓扑相似度信号(叠加在现有四个信号之上)', + 'wiki.graph.boundary.enabled': '边界分数:让"悬空"页面(出度大入度小)冒头', + 'wiki.relation.cache.enabled': '维基页面间关系计算的持久化缓存(默认开启,性能基线)', + }, + }, modelTitle: '模型管理', modelDesc: '管理模型预设与默认模型选择', systemTitle: '系统设置', diff --git a/mateclaw-ui/src/router/index.ts b/mateclaw-ui/src/router/index.ts index c36c05a9..31eab3a7 100644 --- a/mateclaw-ui/src/router/index.ts +++ b/mateclaw-ui/src/router/index.ts @@ -189,6 +189,12 @@ const router = createRouter({ component: () => import('@/views/TokenUsage.vue'), meta: { title: 'Settings - Token Usage' }, }, + { + path: 'feature-flags', + name: 'SettingsFeatureFlags', + component: () => import('@/views/Settings/FeatureFlags/index.vue'), + meta: { title: 'Settings - Feature Flags' }, + }, { path: 'about', name: 'SettingsAbout', diff --git a/mateclaw-ui/src/views/Settings/FeatureFlags/index.vue b/mateclaw-ui/src/views/Settings/FeatureFlags/index.vue new file mode 100644 index 00000000..67ed4ab1 --- /dev/null +++ b/mateclaw-ui/src/views/Settings/FeatureFlags/index.vue @@ -0,0 +1,221 @@ + + + + + diff --git a/mateclaw-ui/src/views/Settings/Layout.vue b/mateclaw-ui/src/views/Settings/Layout.vue index 3d60381d..77f0b03f 100644 --- a/mateclaw-ui/src/views/Settings/Layout.vue +++ b/mateclaw-ui/src/views/Settings/Layout.vue @@ -188,6 +188,12 @@ const sections = computed(() => [ label: t('nav.tokenUsage'), icon: '', }, + { + id: 'feature-flags', + path: '/settings/feature-flags', + label: t('settings.sections.featureFlags', 'Feature Flags'), + icon: '', + }, { id: 'about', path: '/settings/about',