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:
parent
ef98464b68
commit
7351159eeb
11 changed files with 270 additions and 6 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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=<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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<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 })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<FeatureFlag, FeatureFlagDef> = {
|
||||
|
|
@ -52,6 +67,21 @@ const featureRegistry: Record<FeatureFlag, FeatureFlagDef> = {
|
|||
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<number>(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<string>(__APP_VERSION__)
|
||||
const loaded = ref(false)
|
||||
const error = ref<string | null>(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<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 {
|
||||
engine,
|
||||
deploymentMode,
|
||||
|
|
@ -105,10 +161,14 @@ export const useFeatureFlagStore = defineStore('feature-flags', () => {
|
|||
maxFileSizeMb,
|
||||
ingestionAvailable,
|
||||
reasoningAvailable,
|
||||
inspectModeEnabled,
|
||||
chunksModeEnabled,
|
||||
askModeEnabled,
|
||||
appVersion,
|
||||
loaded,
|
||||
error,
|
||||
isEnabled,
|
||||
modeFlags,
|
||||
load,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,14 +1,39 @@
|
|||
<template>
|
||||
<div>
|
||||
<div v-if="showFlashAllModesDisabled" class="flash flash--warning" role="alert">
|
||||
{{ t('flags.allModesDisabled') }}
|
||||
</div>
|
||||
<ComingSoonShell
|
||||
:title="t('comingSoon.title')"
|
||||
:subtitle="t('comingSoon.subtitle.docsLibrary')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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'
|
||||
|
||||
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>
|
||||
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
39
frontend/src/shared/routing/resolveMode.test.ts
Normal file
39
frontend/src/shared/routing/resolveMode.test.ts
Normal 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'])
|
||||
})
|
||||
})
|
||||
21
frontend/src/shared/routing/resolveMode.ts
Normal file
21
frontend/src/shared/routing/resolveMode.ts
Normal 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
|
||||
}
|
||||
Loading…
Reference in a new issue