feat(#210): feature-flag mode gating + deep-link redirect

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<DocMode, boolean>
  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
This commit is contained in:
Pier-Jean Malandrino 2026-04-29 17:50:21 +02:00
parent ef98464b68
commit 7351159eeb
11 changed files with 270 additions and 6 deletions

View file

@ -46,6 +46,12 @@ class HealthResponse(_CamelModel):
# available: REASONING_ENABLED=true AND deps importable. Doesn't imply # available: REASONING_ENABLED=true AND deps importable. Doesn't imply
# Ollama itself is reachable — that's checked per-call. # Ollama itself is reachable — that's checked per-call.
reasoning_available: bool = False 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): class DocumentResponse(_CamelModel):

View file

@ -55,6 +55,12 @@ class Settings:
cors_origins: list[str] = field( cors_origins: list[str] = field(
default_factory=lambda: ["http://localhost:3000", "http://localhost:5173"] 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: def __post_init__(self) -> None:
errors: list[str] = [] errors: list[str] = []
@ -153,6 +159,13 @@ class Settings:
max_paste_image_size_mb=int(os.environ.get("MAX_PASTE_IMAGE_SIZE_MB", "10")), 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()], 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(",")], 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"),
) )

View file

@ -304,4 +304,8 @@ async def health() -> HealthResponse:
# actual Ollama reachability is checked lazily at call-time to avoid # actual Ollama reachability is checked lazily at call-time to avoid
# blocking health checks on the LLM host. # blocking health checks on the LLM host.
reasoning_available=runner is not None and runner.is_available, 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,
) )

View file

@ -68,6 +68,18 @@ class TestHealthEndpoint:
data = resp.json() data = resp.json()
assert data["ingestionAvailable"] is True 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: class TestDocumentEndpoints:
def test_list_documents(self, client, mock_document_service): def test_list_documents(self, client, mock_document_service):

View file

@ -1,5 +1,9 @@
import { createRouter, createWebHistory } from 'vue-router' 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' import { routes } from './routes'
export { routes } export { routes }
@ -8,3 +12,37 @@ export const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
routes, routes,
}) })
/**
* Doc workspace mode guard (#210).
*
* - `/docs/:id?mode=<disabled>` 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
})

View file

@ -155,4 +155,42 @@ describe('useFeatureFlagStore', () => {
expect(store.isEnabled('chunking')).toBe(false) expect(store.isEnabled('chunking')).toBe(false)
expect(store.isEnabled('disclaimer')).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<DocMode, boolean>', 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 })
})
}) })

View file

@ -15,9 +15,21 @@ interface HealthResponse {
maxFileSizeMb?: number maxFileSizeMb?: number
ingestionAvailable?: boolean ingestionAvailable?: boolean
reasoningAvailable?: 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 { interface FeatureFlagDef {
description: string description: string
@ -29,6 +41,9 @@ interface FeatureFlagContext {
deploymentMode: DeploymentMode | null deploymentMode: DeploymentMode | null
ingestionAvailable: boolean ingestionAvailable: boolean
reasoningAvailable: boolean reasoningAvailable: boolean
inspectModeEnabled: boolean
chunksModeEnabled: boolean
askModeEnabled: boolean
} }
const featureRegistry: Record<FeatureFlag, FeatureFlagDef> = { const featureRegistry: Record<FeatureFlag, FeatureFlagDef> = {
@ -52,6 +67,21 @@ const featureRegistry: Record<FeatureFlag, FeatureFlagDef> = {
description: 'Reasoning trace tunnel (docling-agent ReasoningResult viewer)', description: 'Reasoning trace tunnel (docling-agent ReasoningResult viewer)',
isEnabled: (ctx) => ctx.reasoningAvailable, 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', () => { export const useFeatureFlagStore = defineStore('feature-flags', () => {
@ -61,6 +91,11 @@ export const useFeatureFlagStore = defineStore('feature-flags', () => {
const maxFileSizeMb = ref<number>(0) const maxFileSizeMb = ref<number>(0)
const ingestionAvailable = ref(false) const ingestionAvailable = ref(false)
const reasoningAvailable = 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<string>(__APP_VERSION__) const appVersion = ref<string>(__APP_VERSION__)
const loaded = ref(false) const loaded = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
@ -70,6 +105,9 @@ export const useFeatureFlagStore = defineStore('feature-flags', () => {
deploymentMode: deploymentMode.value, deploymentMode: deploymentMode.value,
ingestionAvailable: ingestionAvailable.value, ingestionAvailable: ingestionAvailable.value,
reasoningAvailable: reasoningAvailable.value, reasoningAvailable: reasoningAvailable.value,
inspectModeEnabled: inspectModeEnabled.value,
chunksModeEnabled: chunksModeEnabled.value,
askModeEnabled: askModeEnabled.value,
})) }))
function isEnabled(flag: FeatureFlag): boolean { function isEnabled(flag: FeatureFlag): boolean {
@ -87,6 +125,11 @@ export const useFeatureFlagStore = defineStore('feature-flags', () => {
maxFileSizeMb.value = data.maxFileSizeMb ?? 0 maxFileSizeMb.value = data.maxFileSizeMb ?? 0
ingestionAvailable.value = data.ingestionAvailable ?? false ingestionAvailable.value = data.ingestionAvailable ?? false
reasoningAvailable.value = data.reasoningAvailable ?? 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 appMaxFileSizeMb.value = maxFileSizeMb.value
appMaxPageCount.value = maxPageCount.value appMaxPageCount.value = maxPageCount.value
if (data.version) appVersion.value = data.version 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<DocMode, boolean>` 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 { return {
engine, engine,
deploymentMode, deploymentMode,
@ -105,10 +161,14 @@ export const useFeatureFlagStore = defineStore('feature-flags', () => {
maxFileSizeMb, maxFileSizeMb,
ingestionAvailable, ingestionAvailable,
reasoningAvailable, reasoningAvailable,
inspectModeEnabled,
chunksModeEnabled,
askModeEnabled,
appVersion, appVersion,
loaded, loaded,
error, error,
isEnabled, isEnabled,
modeFlags,
load, load,
} }
}) })

View file

@ -1,14 +1,39 @@
<template> <template>
<ComingSoonShell <div>
:title="t('comingSoon.title')" <div v-if="showFlashAllModesDisabled" class="flash flash--warning" role="alert">
:subtitle="t('comingSoon.subtitle.docsLibrary')" {{ t('flags.allModesDisabled') }}
/> </div>
<ComingSoonShell
:title="t('comingSoon.title')"
:subtitle="t('comingSoon.subtitle.docsLibrary')"
/>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from '../shared/i18n' import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from '../shared/i18n'
import ComingSoonShell from '../shared/ui/ComingSoonShell.vue' import ComingSoonShell from '../shared/ui/ComingSoonShell.vue'
const { t } = useI18n() const { t } = useI18n()
const route = useRoute()
// Surfaced when the router redirected here because every doc workspace
// mode is feature-flagged off (#210). #211 will replace this with a
// proper banner inside the library page.
const showFlashAllModesDisabled = computed(() => route.query.reason === 'no-mode-enabled')
</script> </script>
<style scoped>
.flash {
margin: 1rem;
padding: 0.75rem 1rem;
border-radius: 6px;
font-size: 0.875rem;
color: #92400e;
background: #fef3c7;
border: 1px solid #fde68a;
}
</style>

View file

@ -33,6 +33,10 @@ const messages: Messages = {
'breadcrumb.mode.inspect': 'Inspect', 'breadcrumb.mode.inspect': 'Inspect',
'breadcrumb.mode.chunks': 'Chunks', '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) // Coming-soon placeholders (0.6.0 doc-centric routes — #207)
'comingSoon.title': 'Bientôt disponible', 'comingSoon.title': 'Bientôt disponible',
'comingSoon.subtitle.docsLibrary': 'comingSoon.subtitle.docsLibrary':
@ -333,6 +337,10 @@ const messages: Messages = {
'breadcrumb.mode.inspect': 'Inspect', 'breadcrumb.mode.inspect': 'Inspect',
'breadcrumb.mode.chunks': 'Chunks', '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) // Coming-soon placeholders (0.6.0 doc-centric routes — #207)
'comingSoon.title': 'Coming soon', 'comingSoon.title': 'Coming soon',
'comingSoon.subtitle.docsLibrary': 'comingSoon.subtitle.docsLibrary':

View file

@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import type { DocMode } from './modes'
import { MODE_PRIORITY, resolveMode } from './resolveMode'
const allEnabled: Record<DocMode, boolean> = { ask: true, chunks: true, inspect: true }
const allDisabled: Record<DocMode, boolean> = { 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'])
})
})

View file

@ -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=<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, boolean>,
): DocMode | null {
if (requested && enabled[requested]) return requested
return MODE_PRIORITY.find((m) => enabled[m]) ?? null
}