From 7351159eeb75973dbebcf4bf239530166690614a Mon Sep 17 00:00:00 2001 From: Pier-Jean Malandrino Date: Wed, 29 Apr 2026 17:50:21 +0200 Subject: [PATCH] feat(#210): feature-flag mode gating + deep-link redirect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend - HealthResponse exposes inspectModeEnabled / chunksModeEnabled / askModeEnabled (additive; defaults true). main.py /api/health populates them from settings. - infra/settings.py: three new env-var-driven booleans (defaults true) parsed in from_env() like the existing reasoning_enabled flag. - tests/test_api_endpoints.py: extra assertion that /api/health surfaces the three new fields with their defaults. Frontend — flag store - features/feature-flags/store.ts: FeatureFlag union extended with inspectMode / chunksMode / askMode. New entries in featureRegistry are gated on context fields populated from health. Missing fields fall back to true so a frontend pointed at an older backend keeps every mode visible. - store gains a modeFlags() helper returning Record so the routing guard does not need to know the FeatureFlag union. Frontend — routing - shared/routing/resolveMode.ts: pure resolver. If the requested mode is enabled, return it; else first enabled in priority ask > chunks > inspect; else null. - app/router/index.ts: beforeEach guard scoped to ROUTES.DOC_WORKSPACE. Disabled mode → rewrite ?mode= to the first enabled one. All three off → redirect to /docs?reason=no-mode-enabled. Frontend — flash - pages/DocsLibraryPage.vue: shows a banner when ?reason=no-mode-enabled is set. #211 will move this into the proper library page banner. - i18n flags.allModesDisabled added in fr + en. Tests - shared/routing/resolveMode.test.ts (6 cases): every (requested, enabled) combination including all-disabled, priority order, missing requested. - features/feature-flags/store.test.ts: three new cases covering the new fields in /api/health, fall-back-to-true on missing fields, and modeFlags() shape. Refs #210 --- document-parser/api/schemas.py | 6 ++ document-parser/infra/settings.py | 13 ++++ document-parser/main.py | 4 ++ document-parser/tests/test_api_endpoints.py | 12 ++++ frontend/src/app/router/index.ts | 38 ++++++++++++ .../src/features/feature-flags/store.test.ts | 38 ++++++++++++ frontend/src/features/feature-flags/store.ts | 62 ++++++++++++++++++- frontend/src/pages/DocsLibraryPage.vue | 35 +++++++++-- frontend/src/shared/i18n.ts | 8 +++ .../src/shared/routing/resolveMode.test.ts | 39 ++++++++++++ frontend/src/shared/routing/resolveMode.ts | 21 +++++++ 11 files changed, 270 insertions(+), 6 deletions(-) create mode 100644 frontend/src/shared/routing/resolveMode.test.ts create mode 100644 frontend/src/shared/routing/resolveMode.ts diff --git a/document-parser/api/schemas.py b/document-parser/api/schemas.py index 707e146..8744411 100644 --- a/document-parser/api/schemas.py +++ b/document-parser/api/schemas.py @@ -46,6 +46,12 @@ class HealthResponse(_CamelModel): # available: REASONING_ENABLED=true AND deps importable. Doesn't imply # Ollama itself is reachable — that's checked per-call. reasoning_available: bool = False + # 0.6.0 — Doc workspace mode flags (#210). Default true so existing + # frontends without the new keys (legacy backend image rolling forward) + # see the same behaviour they had. + inspect_mode_enabled: bool = True + chunks_mode_enabled: bool = True + ask_mode_enabled: bool = True class DocumentResponse(_CamelModel): diff --git a/document-parser/infra/settings.py b/document-parser/infra/settings.py index 0ffb877..6186168 100644 --- a/document-parser/infra/settings.py +++ b/document-parser/infra/settings.py @@ -55,6 +55,12 @@ class Settings: cors_origins: list[str] = field( default_factory=lambda: ["http://localhost:3000", "http://localhost:5173"] ) + # 0.6.0 — Doc workspace mode flags (#210). All on by default to preserve + # existing behaviour; operators flip a flag off to hide a mode tab + redirect + # deep links. Per-tenant gating is out of scope for 0.6.0. + inspect_mode_enabled: bool = True + chunks_mode_enabled: bool = True + ask_mode_enabled: bool = True def __post_init__(self) -> None: errors: list[str] = [] @@ -153,6 +159,13 @@ class Settings: max_paste_image_size_mb=int(os.environ.get("MAX_PASTE_IMAGE_SIZE_MB", "10")), paste_allowed_image_types=[t.strip() for t in paste_types_raw.split(",") if t.strip()], cors_origins=[o.strip() for o in cors_raw.split(",")], + # 0.6.0 — Doc workspace mode flags (#210). Defaults: enabled. + inspect_mode_enabled=os.environ.get("INSPECT_MODE_ENABLED", "true").lower() + in ("1", "true", "yes", "on"), + chunks_mode_enabled=os.environ.get("CHUNKS_MODE_ENABLED", "true").lower() + in ("1", "true", "yes", "on"), + ask_mode_enabled=os.environ.get("ASK_MODE_ENABLED", "true").lower() + in ("1", "true", "yes", "on"), ) diff --git a/document-parser/main.py b/document-parser/main.py index ad9844f..e344f2f 100644 --- a/document-parser/main.py +++ b/document-parser/main.py @@ -304,4 +304,8 @@ async def health() -> HealthResponse: # actual Ollama reachability is checked lazily at call-time to avoid # blocking health checks on the LLM host. reasoning_available=runner is not None and runner.is_available, + # 0.6.0 — Doc workspace mode flags (#210). + inspect_mode_enabled=settings.inspect_mode_enabled, + chunks_mode_enabled=settings.chunks_mode_enabled, + ask_mode_enabled=settings.ask_mode_enabled, ) diff --git a/document-parser/tests/test_api_endpoints.py b/document-parser/tests/test_api_endpoints.py index 0f069af..29e2d6a 100644 --- a/document-parser/tests/test_api_endpoints.py +++ b/document-parser/tests/test_api_endpoints.py @@ -68,6 +68,18 @@ class TestHealthEndpoint: data = resp.json() assert data["ingestionAvailable"] is True + def test_health_exposes_doc_mode_flags(self, client): + """0.6.0 (#210): /api/health surfaces inspect/chunks/ask mode flags.""" + resp = client.get("/api/health") + data = resp.json() + assert "inspectModeEnabled" in data + assert "chunksModeEnabled" in data + assert "askModeEnabled" in data + # Defaults preserve current behaviour (all enabled). + assert data["inspectModeEnabled"] is True + assert data["chunksModeEnabled"] is True + assert data["askModeEnabled"] is True + class TestDocumentEndpoints: def test_list_documents(self, client, mock_document_service): diff --git a/frontend/src/app/router/index.ts b/frontend/src/app/router/index.ts index d8745be..d63c0fe 100644 --- a/frontend/src/app/router/index.ts +++ b/frontend/src/app/router/index.ts @@ -1,5 +1,9 @@ import { createRouter, createWebHistory } from 'vue-router' +import { useFeatureFlagStore } from '../../features/feature-flags/store' +import { isDocMode } from '../../shared/routing/modes' +import { ROUTES } from '../../shared/routing/names' +import { resolveMode } from '../../shared/routing/resolveMode' import { routes } from './routes' export { routes } @@ -8,3 +12,37 @@ export const router = createRouter({ history: createWebHistory(), routes, }) + +/** + * Doc workspace mode guard (#210). + * + * - `/docs/:id?mode=` → redirect with the same id but the + * first enabled mode (ask > chunks > inspect priority). + * - All three modes off → redirect to `/docs?reason=no-mode-enabled`. + * - Any other route is left untouched. + * + * Pure resolution lives in `resolveMode`; the guard is just the + * router-level wiring. + */ +router.beforeEach((to) => { + if (to.name !== ROUTES.DOC_WORKSPACE) return true + + const flags = useFeatureFlagStore().modeFlags() + const requestedRaw = Array.isArray(to.query.mode) ? to.query.mode[0] : to.query.mode + const requested = isDocMode(requestedRaw) ? requestedRaw : undefined + const resolved = resolveMode(requested, flags) + + if (resolved === null) { + return { + name: ROUTES.DOCS_LIBRARY, + query: { reason: 'no-mode-enabled' }, + } + } + if (resolved !== requested) { + return { + ...to, + query: { ...to.query, mode: resolved }, + } + } + return true +}) diff --git a/frontend/src/features/feature-flags/store.test.ts b/frontend/src/features/feature-flags/store.test.ts index 8cb4c75..33193cc 100644 --- a/frontend/src/features/feature-flags/store.test.ts +++ b/frontend/src/features/feature-flags/store.test.ts @@ -155,4 +155,42 @@ describe('useFeatureFlagStore', () => { expect(store.isEnabled('chunking')).toBe(false) expect(store.isEnabled('disclaimer')).toBe(false) }) + + // 0.6.0 — Doc workspace mode flags (#210). + it('exposes inspectMode / chunksMode / askMode flags from /api/health', async () => { + mockApiFetch.mockResolvedValue({ + status: 'ok', + engine: 'local', + inspectModeEnabled: false, + chunksModeEnabled: true, + askModeEnabled: true, + }) + const store = useFeatureFlagStore() + await store.load() + expect(store.isEnabled('inspectMode')).toBe(false) + expect(store.isEnabled('chunksMode')).toBe(true) + expect(store.isEnabled('askMode')).toBe(true) + }) + + it('falls back to all-modes-enabled when /api/health omits the new fields', async () => { + mockApiFetch.mockResolvedValue({ status: 'ok', engine: 'local' }) + const store = useFeatureFlagStore() + await store.load() + expect(store.isEnabled('inspectMode')).toBe(true) + expect(store.isEnabled('chunksMode')).toBe(true) + expect(store.isEnabled('askMode')).toBe(true) + }) + + it('modeFlags() returns the three flags in a Record', async () => { + mockApiFetch.mockResolvedValue({ + status: 'ok', + engine: 'local', + inspectModeEnabled: true, + chunksModeEnabled: false, + askModeEnabled: true, + }) + const store = useFeatureFlagStore() + await store.load() + expect(store.modeFlags()).toEqual({ ask: true, chunks: false, inspect: true }) + }) }) diff --git a/frontend/src/features/feature-flags/store.ts b/frontend/src/features/feature-flags/store.ts index 6ccfc67..653270b 100644 --- a/frontend/src/features/feature-flags/store.ts +++ b/frontend/src/features/feature-flags/store.ts @@ -15,9 +15,21 @@ interface HealthResponse { maxFileSizeMb?: number ingestionAvailable?: boolean reasoningAvailable?: boolean + // 0.6.0 — Doc workspace mode flags (#210). Optional so an older backend + // image without these fields keeps working: missing → fall back to true. + inspectModeEnabled?: boolean + chunksModeEnabled?: boolean + askModeEnabled?: boolean } -export type FeatureFlag = 'chunking' | 'disclaimer' | 'ingestion' | 'reasoning' +export type FeatureFlag = + | 'chunking' + | 'disclaimer' + | 'ingestion' + | 'reasoning' + | 'inspectMode' + | 'chunksMode' + | 'askMode' interface FeatureFlagDef { description: string @@ -29,6 +41,9 @@ interface FeatureFlagContext { deploymentMode: DeploymentMode | null ingestionAvailable: boolean reasoningAvailable: boolean + inspectModeEnabled: boolean + chunksModeEnabled: boolean + askModeEnabled: boolean } const featureRegistry: Record = { @@ -52,6 +67,21 @@ const featureRegistry: Record = { description: 'Reasoning trace tunnel (docling-agent ReasoningResult viewer)', isEnabled: (ctx) => ctx.reasoningAvailable, }, + // 0.6.0 — Doc workspace mode flags (#210). Each one gates a tab in the + // doc workspace (#216 / E4) and triggers a router-level redirect when a + // disabled mode is requested via deep link. Defaults: enabled. + inspectMode: { + description: 'Doc workspace Inspect mode (tree + bbox debug view)', + isEnabled: (ctx) => ctx.inspectModeEnabled, + }, + chunksMode: { + description: 'Doc workspace Chunks mode (editable chunkset + push to store)', + isEnabled: (ctx) => ctx.chunksModeEnabled, + }, + askMode: { + description: 'Doc workspace Ask mode (agentic reasoning over the doc)', + isEnabled: (ctx) => ctx.askModeEnabled, + }, } export const useFeatureFlagStore = defineStore('feature-flags', () => { @@ -61,6 +91,11 @@ export const useFeatureFlagStore = defineStore('feature-flags', () => { const maxFileSizeMb = ref(0) const ingestionAvailable = ref(false) const reasoningAvailable = ref(false) + // 0.6.0 — Doc workspace mode flags (#210). Default true so a backend + // that hasn't shipped the new fields yet behaves like the legacy one. + const inspectModeEnabled = ref(true) + const chunksModeEnabled = ref(true) + const askModeEnabled = ref(true) const appVersion = ref(__APP_VERSION__) const loaded = ref(false) const error = ref(null) @@ -70,6 +105,9 @@ export const useFeatureFlagStore = defineStore('feature-flags', () => { deploymentMode: deploymentMode.value, ingestionAvailable: ingestionAvailable.value, reasoningAvailable: reasoningAvailable.value, + inspectModeEnabled: inspectModeEnabled.value, + chunksModeEnabled: chunksModeEnabled.value, + askModeEnabled: askModeEnabled.value, })) function isEnabled(flag: FeatureFlag): boolean { @@ -87,6 +125,11 @@ export const useFeatureFlagStore = defineStore('feature-flags', () => { maxFileSizeMb.value = data.maxFileSizeMb ?? 0 ingestionAvailable.value = data.ingestionAvailable ?? false reasoningAvailable.value = data.reasoningAvailable ?? false + // 0.6.0 — fall back to true when the field is missing so a frontend + // pointed at an older backend keeps every mode visible. + inspectModeEnabled.value = data.inspectModeEnabled ?? true + chunksModeEnabled.value = data.chunksModeEnabled ?? true + askModeEnabled.value = data.askModeEnabled ?? true appMaxFileSizeMb.value = maxFileSizeMb.value appMaxPageCount.value = maxPageCount.value if (data.version) appVersion.value = data.version @@ -98,6 +141,19 @@ export const useFeatureFlagStore = defineStore('feature-flags', () => { } } + /** + * Convenience accessor for `resolveMode` — returns the three doc + * workspace mode flags as a `Record` so the routing + * guard does not need to know about the FeatureFlag union. + */ + function modeFlags(): { ask: boolean; inspect: boolean; chunks: boolean } { + return { + ask: askModeEnabled.value, + inspect: inspectModeEnabled.value, + chunks: chunksModeEnabled.value, + } + } + return { engine, deploymentMode, @@ -105,10 +161,14 @@ export const useFeatureFlagStore = defineStore('feature-flags', () => { maxFileSizeMb, ingestionAvailable, reasoningAvailable, + inspectModeEnabled, + chunksModeEnabled, + askModeEnabled, appVersion, loaded, error, isEnabled, + modeFlags, load, } }) diff --git a/frontend/src/pages/DocsLibraryPage.vue b/frontend/src/pages/DocsLibraryPage.vue index 9449be2..d09144d 100644 --- a/frontend/src/pages/DocsLibraryPage.vue +++ b/frontend/src/pages/DocsLibraryPage.vue @@ -1,14 +1,39 @@ + + diff --git a/frontend/src/shared/i18n.ts b/frontend/src/shared/i18n.ts index e9fca0c..093fe65 100644 --- a/frontend/src/shared/i18n.ts +++ b/frontend/src/shared/i18n.ts @@ -33,6 +33,10 @@ const messages: Messages = { 'breadcrumb.mode.inspect': 'Inspect', 'breadcrumb.mode.chunks': 'Chunks', + // Feature flags (0.6.0 — #210) + 'flags.allModesDisabled': + "Aucun mode (Ask / Inspect / Chunks) n'est activé pour ce déploiement. Contactez votre administrateur.", + // Coming-soon placeholders (0.6.0 doc-centric routes — #207) 'comingSoon.title': 'Bientôt disponible', 'comingSoon.subtitle.docsLibrary': @@ -333,6 +337,10 @@ const messages: Messages = { 'breadcrumb.mode.inspect': 'Inspect', 'breadcrumb.mode.chunks': 'Chunks', + // Feature flags (0.6.0 — #210) + 'flags.allModesDisabled': + 'No doc workspace mode (Ask / Inspect / Chunks) is enabled for this deployment. Contact your administrator.', + // Coming-soon placeholders (0.6.0 doc-centric routes — #207) 'comingSoon.title': 'Coming soon', 'comingSoon.subtitle.docsLibrary': diff --git a/frontend/src/shared/routing/resolveMode.test.ts b/frontend/src/shared/routing/resolveMode.test.ts new file mode 100644 index 0000000..834cfc1 --- /dev/null +++ b/frontend/src/shared/routing/resolveMode.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' + +import type { DocMode } from './modes' +import { MODE_PRIORITY, resolveMode } from './resolveMode' + +const allEnabled: Record = { ask: true, chunks: true, inspect: true } +const allDisabled: Record = { ask: false, chunks: false, inspect: false } + +describe('resolveMode', () => { + it('returns the requested mode when it is enabled', () => { + expect(resolveMode('ask', allEnabled)).toBe('ask') + expect(resolveMode('chunks', allEnabled)).toBe('chunks') + expect(resolveMode('inspect', allEnabled)).toBe('inspect') + }) + + it('falls back to the highest-priority enabled mode when the requested one is disabled', () => { + expect(resolveMode('chunks', { ...allEnabled, chunks: false })).toBe('ask') + expect(resolveMode('chunks', { ask: false, chunks: false, inspect: true })).toBe('inspect') + }) + + it('honours the priority order ask > chunks > inspect', () => { + expect(resolveMode(undefined, allEnabled)).toBe('ask') + expect(resolveMode(undefined, { ask: false, chunks: true, inspect: true })).toBe('chunks') + expect(resolveMode(undefined, { ask: false, chunks: false, inspect: true })).toBe('inspect') + }) + + it('returns null when no mode is enabled', () => { + expect(resolveMode('ask', allDisabled)).toBeNull() + expect(resolveMode(undefined, allDisabled)).toBeNull() + }) + + it('handles missing requested gracefully', () => { + expect(resolveMode(undefined, allEnabled)).toBe('ask') + }) + + it('exposes the priority in the right order', () => { + expect(MODE_PRIORITY).toEqual(['ask', 'chunks', 'inspect']) + }) +}) diff --git a/frontend/src/shared/routing/resolveMode.ts b/frontend/src/shared/routing/resolveMode.ts new file mode 100644 index 0000000..133ab33 --- /dev/null +++ b/frontend/src/shared/routing/resolveMode.ts @@ -0,0 +1,21 @@ +import { type DocMode } from './modes' + +/** + * Doc workspace mode resolution under feature flags (#210). + * + * The router consults this when a user opens `/docs/:id?mode=`: + * + * - If the requested mode is enabled, return it. + * - Otherwise, return the first enabled mode in `MODE_PRIORITY`. + * - If no mode is enabled, return `null` (the router redirects to + * the docs library with a flash message). + */ +export const MODE_PRIORITY: readonly DocMode[] = ['ask', 'chunks', 'inspect'] as const + +export function resolveMode( + requested: DocMode | undefined, + enabled: Record, +): DocMode | null { + if (requested && enabled[requested]) return requested + return MODE_PRIORITY.find((m) => enabled[m]) ?? null +}