feat(wiki): feature-flag toggle UI + defer extracted_text cache when vision unavailable

This commit is contained in:
matevip 2026-05-02 19:04:58 +08:00
parent bcefe43234
commit f910d762a3
8 changed files with 428 additions and 17 deletions

View File

@ -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.
*
* <p>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.

View File

@ -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.
*
* <p>{@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}]: <caption>} 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.
*
* <p>Returns an {@link EnrichmentOutcome} that distinguishes three cases:
* <ul>
* <li>Non-PDF / no extractor / no images present text unchanged,
* {@code shouldCache=true}.</li>
* <li>Vision disabled but the PDF has inline images text unchanged,
* {@code shouldCache=false} so the next read retries after the
* operator flips {@code wiki.ocr.enabled} on.</li>
* <li>Vision enabled and at least one image captioned enriched text,
* {@code shouldCache=true}.</li>
* <li>Vision enabled but every image's caption call failed text
* unchanged, {@code shouldCache=true} (transient failures should
* not block the cache; if the model is wedged, retry happens via
* a manual reprocess).</li>
* </ul>
*/
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<String> snippets = pdfImageExtractor.captionInlineImages(
java.nio.file.Paths.get(entity.getSourcePath()));
List<String> 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);
}
}

View File

@ -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<FeatureFlag[]>('/feature-flags'),
update: (flagKey: string, data: FeatureFlagUpdate) =>
http.put(`/feature-flags/${flagKey}`, data),
}

View File

@ -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',

View File

@ -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: '系统设置',

View File

@ -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',

View File

@ -0,0 +1,221 @@
<template>
<div class="feature-flags-page">
<header class="page-header">
<div class="mc-page-kicker">{{ t('settings.kicker') }}</div>
<h2 class="page-title">{{ t('settings.featureFlags.title') }}</h2>
<p class="page-desc">{{ t('settings.featureFlags.description') }}</p>
</header>
<div v-if="loading" class="state-row">
<el-icon class="is-loading"><Loading /></el-icon>
<span>{{ t('common.loading') }}</span>
</div>
<div v-else-if="error" class="state-row state-row--error">
<el-icon><WarningFilled /></el-icon>
<span>{{ error }}</span>
<el-button size="small" @click="load">{{ t('common.retry', 'Retry') }}</el-button>
</div>
<ul v-else-if="flags.length > 0" class="flag-list">
<li v-for="flag in flags" :key="flag.flagKey" class="flag-row">
<div class="flag-text">
<div class="flag-key">{{ flag.flagKey }}</div>
<div v-if="describe(flag)" class="flag-desc">{{ describe(flag) }}</div>
<div v-if="hasScope(flag)" class="flag-scope">
<span v-if="flag.whitelistKbIds">
{{ t('settings.featureFlags.scope.kb') }}: {{ flag.whitelistKbIds }}
</span>
<span v-if="flag.whitelistUserIds">
{{ t('settings.featureFlags.scope.user') }}: {{ flag.whitelistUserIds }}
</span>
<span v-if="(flag.rolloutPercent ?? 0) > 0 && (flag.rolloutPercent ?? 0) < 100">
{{ t('settings.featureFlags.scope.rollout', { pct: flag.rolloutPercent }) }}
</span>
</div>
</div>
<div class="flag-actions">
<el-switch
:model-value="flag.enabled"
:loading="pending[flag.flagKey] === true"
:disabled="pending[flag.flagKey] === true"
@change="(value: any) => onToggle(flag, !!value)"
/>
</div>
</li>
</ul>
<div v-else class="state-row">
<span>{{ t('settings.featureFlags.empty') }}</span>
</div>
<footer class="page-footer">
<p>{{ t('settings.featureFlags.footer') }}</p>
</footer>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElButton, ElIcon, ElMessage, ElSwitch } from 'element-plus'
import { Loading, WarningFilled } from '@element-plus/icons-vue'
import { featureFlagApi, type FeatureFlag } from '@/api/index'
const { t, te } = useI18n()
/**
* Resolves a flag's description from i18n first, falling back to the backend
* column when no translation key is registered. The backend value is English-
* only by design (it's a stable reference identifier in the DB seed); UI
* copy lives next to other strings in the locale files.
*/
function describe(flag: FeatureFlag): string {
const i18nKey = `settings.featureFlags.descriptions.${flag.flagKey}`
if (te(i18nKey)) {
return t(i18nKey)
}
return flag.description ?? ''
}
const loading = ref(true)
const error = ref<string>('')
const flags = ref<FeatureFlag[]>([])
const pending = reactive<Record<string, boolean>>({})
function hasScope(flag: FeatureFlag): boolean {
return !!flag.whitelistKbIds
|| !!flag.whitelistUserIds
|| ((flag.rolloutPercent ?? 0) > 0 && (flag.rolloutPercent ?? 0) < 100)
}
async function load() {
loading.value = true
error.value = ''
try {
const resp: any = await featureFlagApi.list()
flags.value = (resp?.data ?? []).slice().sort((a: FeatureFlag, b: FeatureFlag) =>
a.flagKey.localeCompare(b.flagKey))
} catch (e: any) {
error.value = e?.message ?? String(e)
} finally {
loading.value = false
}
}
async function onToggle(flag: FeatureFlag, next: boolean) {
pending[flag.flagKey] = true
try {
await featureFlagApi.update(flag.flagKey, { enabled: next })
flag.enabled = next // optimistic local update
ElMessage.success(t(next ? 'settings.featureFlags.enabled' : 'settings.featureFlags.disabled',
{ key: flag.flagKey }))
} catch (e: any) {
ElMessage.error(e?.message ?? t('settings.featureFlags.toggleFailed'))
// Revert by refreshing list to true server state.
await load()
} finally {
pending[flag.flagKey] = false
}
}
onMounted(load)
</script>
<style scoped>
.feature-flags-page {
display: flex;
flex-direction: column;
gap: 18px;
max-width: 880px;
}
.page-header {
border-bottom: 1px solid var(--mc-border-light);
padding-bottom: 16px;
}
.page-title {
font-size: 22px;
font-weight: 700;
margin: 4px 0 8px;
color: var(--mc-text-primary);
}
.page-desc {
color: var(--mc-text-secondary);
font-size: 13px;
line-height: 1.6;
margin: 0;
}
.flag-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 1px;
background: var(--mc-border-light);
border-radius: 10px;
overflow: hidden;
}
.flag-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 18px;
background: var(--mc-bg-base);
}
.flag-text {
flex: 1;
min-width: 0;
}
.flag-key {
font-family: var(--mc-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
font-size: 13px;
font-weight: 600;
color: var(--mc-text-primary);
}
.flag-desc {
font-size: 12px;
color: var(--mc-text-secondary);
margin-top: 4px;
line-height: 1.5;
}
.flag-scope {
font-size: 11px;
color: var(--mc-text-tertiary);
margin-top: 4px;
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.state-row {
display: flex;
align-items: center;
gap: 8px;
padding: 18px;
color: var(--mc-text-secondary);
background: var(--mc-bg-muted);
border-radius: 10px;
}
.state-row--error {
color: var(--el-color-danger);
background: var(--el-color-danger-light-9);
}
.page-footer {
font-size: 12px;
color: var(--mc-text-tertiary);
line-height: 1.5;
padding-top: 12px;
border-top: 1px solid var(--mc-border-light);
}
</style>

View File

@ -188,6 +188,12 @@ const sections = computed(() => [
label: t('nav.tokenUsage'),
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 1v22M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>',
},
{
id: 'feature-flags',
path: '/settings/feature-flags',
label: t('settings.sections.featureFlags', 'Feature Flags'),
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 21V4l12 4-12 4"/><path d="M4 12v9"/></svg>',
},
{
id: 'about',
path: '/settings/about',