From 27e3323bffe71a44954d2c5e05e06e9c659be16f Mon Sep 17 00:00:00 2001 From: Pier-Jean Malandrino Date: Mon, 11 May 2026 15:52:29 +0200 Subject: [PATCH] feat(#257): surface gating via STUDIO_MODE + RAG_PIPELINE master flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces two master feature flags that select which UI surface is exposed, replacing the previous "delete legacy pages" approach with a softer isolation: - STUDIO_MODE_ENABLED (default false) — legacy OCR-debug surface - RAG_PIPELINE_ENABLED (default true) — new doc-centric ingestion + viz At least one master must be enabled (validated server-side at startup). Sub-flags (inspect / linked / ask) are effective only when the RAG pipeline master is on. CHUNKS_MODE_ENABLED renamed to LINKED_MODE_ENABLED in anticipation of T3 (Linked view replaces the Chunks tab). The DocMode union value 'chunks' is preserved for now and will be renamed in T3 alongside the route segment, to keep this PR scoped. Router-level guard added: requests to a route whose surface is disabled are redirected to the other surface's landing page (or /home as a defensive fallback). Logic extracted into a pure resolveSurface helper with full test coverage. i18n strings that pointed users to "Studio" rewritten to be surface- agnostic ("from the library" / "depuis la bibliothèque") since Studio is hidden by default in 0.6.1. Backend: - infra/settings.py: add studio_mode_enabled + rag_pipeline_enabled; rename chunks_mode_enabled → linked_mode_enabled; add at-least-one master validation in __post_init__ - api/schemas.py: HealthResponse exposes both master flags + renamed sub-flag - main.py: health endpoint wires the new fields - tests: surface-flag + renamed sub-flag assertions Frontend: - features/feature-flags/store: add studioMode + ragPipeline registry entries; rename chunksMode → linkedMode; sub-flags now require ragPipeline enabled; modeFlags() maps linkedModeEnabled → key 'chunks' (transitional) - shared/routing/resolveSurface: pure helper + tests - app/router: beforeEach guard consumes resolveSurface - shared/i18n: Studio-pointing strings rewritten (en + fr) + test sync - features/reasoning: stale "from StudioPage" comment generalized --- document-parser/api/schemas.py | 11 ++- document-parser/infra/settings.py | 25 ++++-- document-parser/main.py | 7 +- document-parser/tests/test_api_endpoints.py | 18 ++++- frontend/src/app/router/index.ts | 26 +++++-- .../src/features/feature-flags/store.test.ts | 53 +++++++++++-- frontend/src/features/feature-flags/store.ts | 77 +++++++++++++------ .../features/reasoning/ui/ReasoningPanel.vue | 2 +- frontend/src/shared/i18n.test.ts | 6 +- frontend/src/shared/i18n.ts | 12 +-- .../src/shared/routing/resolveSurface.test.ts | 41 ++++++++++ frontend/src/shared/routing/resolveSurface.ts | 48 ++++++++++++ 12 files changed, 264 insertions(+), 62 deletions(-) create mode 100644 frontend/src/shared/routing/resolveSurface.test.ts create mode 100644 frontend/src/shared/routing/resolveSurface.ts diff --git a/document-parser/api/schemas.py b/document-parser/api/schemas.py index 7d3d329..2cc9252 100644 --- a/document-parser/api/schemas.py +++ b/document-parser/api/schemas.py @@ -46,11 +46,14 @@ 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. + # 0.6.1 — Surface flags (#257). Master flags select which surface(s) + # the frontend exposes. Defaults match the production target (RAG only). + studio_mode_enabled: bool = False + rag_pipeline_enabled: bool = True + # 0.6.0 — RAG-pipeline sub-flags (#210, renamed in #257). Default true + # so frontends pointed at an older backend keep every mode visible. inspect_mode_enabled: bool = True - chunks_mode_enabled: bool = True + linked_mode_enabled: bool = True ask_mode_enabled: bool = True diff --git a/document-parser/infra/settings.py b/document-parser/infra/settings.py index 6186168..ced0a07 100644 --- a/document-parser/infra/settings.py +++ b/document-parser/infra/settings.py @@ -55,11 +55,17 @@ 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. + # 0.6.1 — Surface flags (#257). Two master flags select which UI surface + # is exposed: STUDIO_MODE_ENABLED (legacy OCR-debug) and + # RAG_PIPELINE_ENABLED (new doc-centric ingestion + visualization). + # At least one must be enabled. Sub-flags below gate individual modes + # inside the RAG pipeline surface. + studio_mode_enabled: bool = False + rag_pipeline_enabled: bool = True + # 0.6.0 — Doc workspace mode flags (#210, renamed in #257). + # Sub-flags effective only when rag_pipeline_enabled is true. inspect_mode_enabled: bool = True - chunks_mode_enabled: bool = True + linked_mode_enabled: bool = True ask_mode_enabled: bool = True def __post_init__(self) -> None: @@ -96,6 +102,8 @@ class Settings: ) if self.embedding_dimension < 1: errors.append(f"embedding_dimension must be >= 1 (got {self.embedding_dimension})") + if not (self.studio_mode_enabled or self.rag_pipeline_enabled): + errors.append("at least one of STUDIO_MODE_ENABLED / RAG_PIPELINE_ENABLED must be true") if self.default_table_mode not in ("accurate", "fast"): errors.append( f"default_table_mode must be 'accurate' or 'fast' (got '{self.default_table_mode}')" @@ -159,10 +167,15 @@ 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. + # 0.6.1 — Surface flags (#257). + studio_mode_enabled=os.environ.get("STUDIO_MODE_ENABLED", "false").lower() + in ("1", "true", "yes", "on"), + rag_pipeline_enabled=os.environ.get("RAG_PIPELINE_ENABLED", "true").lower() + in ("1", "true", "yes", "on"), + # 0.6.0 — RAG-pipeline sub-flags (#210, renamed in #257). 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() + linked_mode_enabled=os.environ.get("LINKED_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 ce00bdd..0008dea 100644 --- a/document-parser/main.py +++ b/document-parser/main.py @@ -343,8 +343,11 @@ 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). + # 0.6.1 — Surface flags (#257). + studio_mode_enabled=settings.studio_mode_enabled, + rag_pipeline_enabled=settings.rag_pipeline_enabled, + # 0.6.0 — RAG-pipeline sub-flags (#210, renamed in #257). inspect_mode_enabled=settings.inspect_mode_enabled, - chunks_mode_enabled=settings.chunks_mode_enabled, + linked_mode_enabled=settings.linked_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 1b43cf4..46851ed 100644 --- a/document-parser/tests/test_api_endpoints.py +++ b/document-parser/tests/test_api_endpoints.py @@ -68,16 +68,26 @@ class TestHealthEndpoint: data = resp.json() assert data["ingestionAvailable"] is True + def test_health_exposes_surface_flags(self, client): + """0.6.1 (#257): /api/health surfaces studio + rag_pipeline master flags.""" + resp = client.get("/api/health") + data = resp.json() + assert "studioModeEnabled" in data + assert "ragPipelineEnabled" in data + # Defaults: studio off, rag pipeline on (production target). + assert data["studioModeEnabled"] is False + assert data["ragPipelineEnabled"] is True + def test_health_exposes_doc_mode_flags(self, client): - """0.6.0 (#210): /api/health surfaces inspect/chunks/ask mode flags.""" + """0.6.0 (#210, renamed in #257): /api/health surfaces inspect/linked/ask sub-flags.""" resp = client.get("/api/health") data = resp.json() assert "inspectModeEnabled" in data - assert "chunksModeEnabled" in data + assert "linkedModeEnabled" in data assert "askModeEnabled" in data - # Defaults preserve current behaviour (all enabled). + # Sub-flag defaults preserve current behaviour (all enabled). assert data["inspectModeEnabled"] is True - assert data["chunksModeEnabled"] is True + assert data["linkedModeEnabled"] is True assert data["askModeEnabled"] is True diff --git a/frontend/src/app/router/index.ts b/frontend/src/app/router/index.ts index d63c0fe..16ea104 100644 --- a/frontend/src/app/router/index.ts +++ b/frontend/src/app/router/index.ts @@ -4,6 +4,7 @@ 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 { resolveSurface } from '../../shared/routing/resolveSurface' import { routes } from './routes' export { routes } @@ -14,20 +15,29 @@ export const router = createRouter({ }) /** - * Doc workspace mode guard (#210). + * Surface gating (#257) + mode gating (#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. + * Surface: two master flags select which UI surface is exposed. When a + * route from a disabled surface is requested, redirect to the other + * surface's landing page — or to `/` if both are off (defensive net; + * backend refuses to start in that state). * - * Pure resolution lives in `resolveMode`; the guard is just the - * router-level wiring. + * Mode: inside the doc workspace, the requested mode falls back to the + * first enabled mode when the deep-linked one is off. */ router.beforeEach((to) => { + const flagStore = useFeatureFlagStore() + const name = String(to.name ?? '') + + const surfaceRedirect = resolveSurface(name, { + studio: flagStore.studioModeEnabled, + rag: flagStore.ragPipelineEnabled, + }) + if (surfaceRedirect !== null) return { name: surfaceRedirect } + if (to.name !== ROUTES.DOC_WORKSPACE) return true - const flags = useFeatureFlagStore().modeFlags() + const flags = flagStore.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) diff --git a/frontend/src/features/feature-flags/store.test.ts b/frontend/src/features/feature-flags/store.test.ts index 33193cc..f2bee9f 100644 --- a/frontend/src/features/feature-flags/store.test.ts +++ b/frontend/src/features/feature-flags/store.test.ts @@ -156,19 +156,58 @@ describe('useFeatureFlagStore', () => { expect(store.isEnabled('disclaimer')).toBe(false) }) - // 0.6.0 — Doc workspace mode flags (#210). - it('exposes inspectMode / chunksMode / askMode flags from /api/health', async () => { + // 0.6.1 — Surface master flags (#257). + it('exposes studioMode / ragPipeline master flags from /api/health', async () => { mockApiFetch.mockResolvedValue({ status: 'ok', engine: 'local', - inspectModeEnabled: false, - chunksModeEnabled: true, + studioModeEnabled: true, + ragPipelineEnabled: false, + }) + const store = useFeatureFlagStore() + await store.load() + expect(store.isEnabled('studioMode')).toBe(true) + expect(store.isEnabled('ragPipeline')).toBe(false) + }) + + it('defaults studio off, rag pipeline on when /api/health omits the fields', async () => { + mockApiFetch.mockResolvedValue({ status: 'ok', engine: 'local' }) + const store = useFeatureFlagStore() + await store.load() + expect(store.isEnabled('studioMode')).toBe(false) + expect(store.isEnabled('ragPipeline')).toBe(true) + }) + + it('sub-flags require ragPipeline enabled', async () => { + mockApiFetch.mockResolvedValue({ + status: 'ok', + engine: 'local', + ragPipelineEnabled: false, + inspectModeEnabled: true, + linkedModeEnabled: true, askModeEnabled: true, }) const store = useFeatureFlagStore() await store.load() expect(store.isEnabled('inspectMode')).toBe(false) - expect(store.isEnabled('chunksMode')).toBe(true) + expect(store.isEnabled('linkedMode')).toBe(false) + expect(store.isEnabled('askMode')).toBe(false) + }) + + // 0.6.0 — RAG-pipeline sub-flags (#210, renamed in #257). + it('exposes inspectMode / linkedMode / askMode flags from /api/health', async () => { + mockApiFetch.mockResolvedValue({ + status: 'ok', + engine: 'local', + ragPipelineEnabled: true, + inspectModeEnabled: false, + linkedModeEnabled: true, + askModeEnabled: true, + }) + const store = useFeatureFlagStore() + await store.load() + expect(store.isEnabled('inspectMode')).toBe(false) + expect(store.isEnabled('linkedMode')).toBe(true) expect(store.isEnabled('askMode')).toBe(true) }) @@ -177,7 +216,7 @@ describe('useFeatureFlagStore', () => { const store = useFeatureFlagStore() await store.load() expect(store.isEnabled('inspectMode')).toBe(true) - expect(store.isEnabled('chunksMode')).toBe(true) + expect(store.isEnabled('linkedMode')).toBe(true) expect(store.isEnabled('askMode')).toBe(true) }) @@ -186,7 +225,7 @@ describe('useFeatureFlagStore', () => { status: 'ok', engine: 'local', inspectModeEnabled: true, - chunksModeEnabled: false, + linkedModeEnabled: false, askModeEnabled: true, }) const store = useFeatureFlagStore() diff --git a/frontend/src/features/feature-flags/store.ts b/frontend/src/features/feature-flags/store.ts index 653270b..fa83c05 100644 --- a/frontend/src/features/feature-flags/store.ts +++ b/frontend/src/features/feature-flags/store.ts @@ -15,10 +15,14 @@ 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. + // 0.6.1 — Surface master flags (#257). Optional for backward compat: + // studio defaults to false (production target), rag pipeline to true. + studioModeEnabled?: boolean + ragPipelineEnabled?: boolean + // 0.6.0 — RAG-pipeline sub-flags (#210, renamed in #257). Optional so an + // older backend image without these fields keeps working: missing → true. inspectModeEnabled?: boolean - chunksModeEnabled?: boolean + linkedModeEnabled?: boolean askModeEnabled?: boolean } @@ -27,8 +31,10 @@ export type FeatureFlag = | 'disclaimer' | 'ingestion' | 'reasoning' + | 'studioMode' + | 'ragPipeline' | 'inspectMode' - | 'chunksMode' + | 'linkedMode' | 'askMode' interface FeatureFlagDef { @@ -41,8 +47,10 @@ interface FeatureFlagContext { deploymentMode: DeploymentMode | null ingestionAvailable: boolean reasoningAvailable: boolean + studioModeEnabled: boolean + ragPipelineEnabled: boolean inspectModeEnabled: boolean - chunksModeEnabled: boolean + linkedModeEnabled: boolean askModeEnabled: boolean } @@ -67,20 +75,31 @@ 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. + // 0.6.1 — Surface master flags (#257). Select which UI surface(s) are + // exposed; at least one must be on (validated server-side too). + studioMode: { + description: 'Legacy Studio surface (OCR debug, original 0.5.x UI)', + isEnabled: (ctx) => ctx.studioModeEnabled, + }, + ragPipeline: { + description: 'New doc-centric RAG ingestion + visualization pipeline', + isEnabled: (ctx) => ctx.ragPipelineEnabled, + }, + // 0.6.0 — RAG-pipeline sub-flags (#210, renamed in #257). Each gates a + // mode inside the doc workspace and triggers a router-level redirect + // when a disabled mode is requested via deep link. Effective only when + // ragPipeline is enabled. inspectMode: { description: 'Doc workspace Inspect mode (tree + bbox debug view)', - isEnabled: (ctx) => ctx.inspectModeEnabled, + isEnabled: (ctx) => ctx.ragPipelineEnabled && ctx.inspectModeEnabled, }, - chunksMode: { - description: 'Doc workspace Chunks mode (editable chunkset + push to store)', - isEnabled: (ctx) => ctx.chunksModeEnabled, + linkedMode: { + description: 'Doc workspace Linked mode (preview + aligned chunks panel)', + isEnabled: (ctx) => ctx.ragPipelineEnabled && ctx.linkedModeEnabled, }, askMode: { description: 'Doc workspace Ask mode (agentic reasoning over the doc)', - isEnabled: (ctx) => ctx.askModeEnabled, + isEnabled: (ctx) => ctx.ragPipelineEnabled && ctx.askModeEnabled, }, } @@ -91,10 +110,14 @@ 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. + // 0.6.1 — Surface master flags (#257). Defaults match production target: + // legacy Studio off, new RAG pipeline on. + const studioModeEnabled = ref(false) + const ragPipelineEnabled = ref(true) + // 0.6.0 — RAG-pipeline sub-flags (#210, renamed in #257). Default true + // so a backend without these fields behaves like the legacy one. const inspectModeEnabled = ref(true) - const chunksModeEnabled = ref(true) + const linkedModeEnabled = ref(true) const askModeEnabled = ref(true) const appVersion = ref(__APP_VERSION__) const loaded = ref(false) @@ -105,8 +128,10 @@ export const useFeatureFlagStore = defineStore('feature-flags', () => { deploymentMode: deploymentMode.value, ingestionAvailable: ingestionAvailable.value, reasoningAvailable: reasoningAvailable.value, + studioModeEnabled: studioModeEnabled.value, + ragPipelineEnabled: ragPipelineEnabled.value, inspectModeEnabled: inspectModeEnabled.value, - chunksModeEnabled: chunksModeEnabled.value, + linkedModeEnabled: linkedModeEnabled.value, askModeEnabled: askModeEnabled.value, })) @@ -125,10 +150,14 @@ 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. + // 0.6.1 — surface flags. Backward compat: missing studio → false + // (production target), missing rag pipeline → true (legacy behaviour). + studioModeEnabled.value = data.studioModeEnabled ?? false + ragPipelineEnabled.value = data.ragPipelineEnabled ?? true + // Sub-flags: fall back to true so an older backend keeps every mode + // visible. inspectModeEnabled.value = data.inspectModeEnabled ?? true - chunksModeEnabled.value = data.chunksModeEnabled ?? true + linkedModeEnabled.value = data.linkedModeEnabled ?? true askModeEnabled.value = data.askModeEnabled ?? true appMaxFileSizeMb.value = maxFileSizeMb.value appMaxPageCount.value = maxPageCount.value @@ -147,10 +176,12 @@ export const useFeatureFlagStore = defineStore('feature-flags', () => { * guard does not need to know about the FeatureFlag union. */ function modeFlags(): { ask: boolean; inspect: boolean; chunks: boolean } { + // Mode key 'chunks' is kept until T3 (Linked view) renames the DocMode + // union and route segment. The flag is already linkedModeEnabled. return { ask: askModeEnabled.value, inspect: inspectModeEnabled.value, - chunks: chunksModeEnabled.value, + chunks: linkedModeEnabled.value, } } @@ -161,8 +192,10 @@ export const useFeatureFlagStore = defineStore('feature-flags', () => { maxFileSizeMb, ingestionAvailable, reasoningAvailable, + studioModeEnabled, + ragPipelineEnabled, inspectModeEnabled, - chunksModeEnabled, + linkedModeEnabled, askModeEnabled, appVersion, loaded, diff --git a/frontend/src/features/reasoning/ui/ReasoningPanel.vue b/frontend/src/features/reasoning/ui/ReasoningPanel.vue index 64f4e5f..e502549 100644 --- a/frontend/src/features/reasoning/ui/ReasoningPanel.vue +++ b/frontend/src/features/reasoning/ui/ReasoningPanel.vue @@ -108,7 +108,7 @@ const props = defineProps<{ /** * The live Cytoscape instance from the GraphView. May be `null` while the * graph is loading or if Maintain hasn't been run for this document. - * Passed down from StudioPage via `graphViewRef.cy`. + * Passed down by the host page via `graphViewRef.cy`. */ cy: Core | null }>() diff --git a/frontend/src/shared/i18n.test.ts b/frontend/src/shared/i18n.test.ts index a96ce62..ac33012 100644 --- a/frontend/src/shared/i18n.test.ts +++ b/frontend/src/shared/i18n.test.ts @@ -50,7 +50,9 @@ describe('useI18n', () => { const { t } = useI18n() expect(t('history.tabAnalyses')).toBe('Analyses') expect(t('history.tabDocuments')).toBe('Documents') - expect(t('history.emptyDocs')).toBe('Aucun document. Importez un document depuis le Studio.') + expect(t('history.emptyDocs')).toBe( + 'Aucun document. Importez un document depuis la bibliothèque.', + ) }) it('has history tab keys in English', () => { @@ -59,7 +61,7 @@ describe('useI18n', () => { const { t } = useI18n() expect(t('history.tabAnalyses')).toBe('Analyses') expect(t('history.tabDocuments')).toBe('Documents') - expect(t('history.emptyDocs')).toBe('No documents yet. Upload a document from the Studio.') + expect(t('history.emptyDocs')).toBe('No documents yet. Upload a document from the library.') }) it('has detailed pipeline option hints in French', () => { diff --git a/frontend/src/shared/i18n.ts b/frontend/src/shared/i18n.ts index 2654e78..1566e54 100644 --- a/frontend/src/shared/i18n.ts +++ b/frontend/src/shared/i18n.ts @@ -207,8 +207,8 @@ const messages: Messages = { 'history.title': 'Historique', 'history.tabAnalyses': 'Analyses', 'history.tabDocuments': 'Documents', - 'history.empty': 'Aucune analyse. Allez dans Studio pour analyser votre premier document.', - 'history.emptyDocs': 'Aucun document. Importez un document depuis le Studio.', + 'history.empty': 'Aucune analyse. Analysez votre premier document pour commencer.', + 'history.emptyDocs': 'Aucun document. Importez un document depuis la bibliothèque.', 'history.open': 'Ouvrir', // Chunking @@ -264,7 +264,7 @@ const messages: Messages = { 'reasoning.uploading': 'Import du document...', 'reasoning.existingDocs': 'Documents déjà analysés', 'reasoning.noAnalyzedDocs': - 'Aucun des documents existants n\u2019a encore été analysé — lance-en un depuis Studio, ou dépose un nouveau PDF ci-dessus.', + 'Aucun des documents existants n\u2019a encore été analysé — lance-en un depuis la bibliothèque, ou dépose un nouveau PDF ci-dessus.', 'reasoning.pagesCount': '{n} pages', 'reasoning.changeDoc': 'Changer de document', 'reasoning.modeSwitchLabel': 'Mode d\u2019affichage', @@ -712,8 +712,8 @@ const messages: Messages = { 'history.title': 'History', 'history.tabAnalyses': 'Analyses', 'history.tabDocuments': 'Documents', - 'history.empty': 'No analyses yet. Go to Studio to analyze your first document.', - 'history.emptyDocs': 'No documents yet. Upload a document from the Studio.', + 'history.empty': 'No analyses yet. Analyze your first document to get started.', + 'history.emptyDocs': 'No documents yet. Upload a document from the library.', 'history.open': 'Open', 'studio.prepare': 'Prepare', @@ -767,7 +767,7 @@ const messages: Messages = { 'reasoning.uploading': 'Uploading document...', 'reasoning.existingDocs': 'Previously analyzed documents', 'reasoning.noAnalyzedDocs': - 'None of your existing documents have been analyzed yet — run one from Studio, or drop a new PDF above.', + 'None of your existing documents have been analyzed yet — run one from the library, or drop a new PDF above.', 'reasoning.pagesCount': '{n} pages', 'reasoning.changeDoc': 'Change document', 'reasoning.modeSwitchLabel': 'View mode', diff --git a/frontend/src/shared/routing/resolveSurface.test.ts b/frontend/src/shared/routing/resolveSurface.test.ts new file mode 100644 index 0000000..98b4fe4 --- /dev/null +++ b/frontend/src/shared/routing/resolveSurface.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' + +import { ROUTES } from './names' +import { resolveSurface } from './resolveSurface' + +describe('resolveSurface', () => { + it('allows studio routes when studio surface is enabled', () => { + expect(resolveSurface(ROUTES.STUDIO, { studio: true, rag: false })).toBeNull() + expect(resolveSurface(ROUTES.HISTORY, { studio: true, rag: true })).toBeNull() + }) + + it('allows rag routes when rag surface is enabled', () => { + expect(resolveSurface(ROUTES.DOCS_LIBRARY, { studio: false, rag: true })).toBeNull() + expect(resolveSurface(ROUTES.DOC_WORKSPACE, { studio: true, rag: true })).toBeNull() + }) + + it('redirects studio routes to docs library when studio off and rag on', () => { + expect(resolveSurface(ROUTES.STUDIO, { studio: false, rag: true })).toBe(ROUTES.DOCS_LIBRARY) + expect(resolveSurface(ROUTES.HISTORY, { studio: false, rag: true })).toBe(ROUTES.DOCS_LIBRARY) + expect(resolveSurface(ROUTES.DOCUMENTS, { studio: false, rag: true })).toBe(ROUTES.DOCS_LIBRARY) + expect(resolveSurface(ROUTES.SEARCH, { studio: false, rag: true })).toBe(ROUTES.DOCS_LIBRARY) + }) + + it('redirects rag routes to studio when rag off and studio on', () => { + expect(resolveSurface(ROUTES.DOCS_LIBRARY, { studio: true, rag: false })).toBe(ROUTES.STUDIO) + expect(resolveSurface(ROUTES.STORES_LIST, { studio: true, rag: false })).toBe(ROUTES.STUDIO) + expect(resolveSurface(ROUTES.RUNS, { studio: true, rag: false })).toBe(ROUTES.STUDIO) + }) + + it('falls back to home when both surfaces are off', () => { + // Defensive case — backend refuses to start in this state. + expect(resolveSurface(ROUTES.STUDIO, { studio: false, rag: false })).toBe(ROUTES.HOME) + expect(resolveSurface(ROUTES.DOCS_LIBRARY, { studio: false, rag: false })).toBe(ROUTES.HOME) + }) + + it('leaves surface-neutral routes untouched', () => { + expect(resolveSurface(ROUTES.HOME, { studio: false, rag: true })).toBeNull() + expect(resolveSurface(ROUTES.SETTINGS, { studio: false, rag: true })).toBeNull() + expect(resolveSurface(ROUTES.REASONING, { studio: false, rag: true })).toBeNull() + }) +}) diff --git a/frontend/src/shared/routing/resolveSurface.ts b/frontend/src/shared/routing/resolveSurface.ts new file mode 100644 index 0000000..efec7f8 --- /dev/null +++ b/frontend/src/shared/routing/resolveSurface.ts @@ -0,0 +1,48 @@ +import { ROUTES } from './names' + +/** + * Surface gating (#257). + * + * Two master flags select which UI surface is exposed: + * - `studioMode` gates the legacy Studio surface + * - `ragPipeline` gates the new doc-centric RAG pipeline + * + * `resolveSurface` is a pure helper consumed by the router's `beforeEach` + * guard. Returns the target route name to redirect to, or `null` when + * the requested route is allowed. + */ + +export const STUDIO_SURFACE_ROUTES: ReadonlySet = new Set([ + ROUTES.STUDIO, + ROUTES.HISTORY, + ROUTES.DOCUMENTS, + ROUTES.SEARCH, +]) + +export const RAG_SURFACE_ROUTES: ReadonlySet = new Set([ + ROUTES.DOCS_LIBRARY, + ROUTES.DOCS_NEW, + ROUTES.DOC_WORKSPACE, + ROUTES.STORES_LIST, + ROUTES.STORE_CREATE, + ROUTES.STORE_DETAIL, + ROUTES.STORE_EDIT, + ROUTES.STORE_QUERY, + ROUTES.RUNS, + ROUTES.RUN_DETAIL, +]) + +export interface SurfaceFlags { + studio: boolean + rag: boolean +} + +export function resolveSurface(routeName: string, flags: SurfaceFlags): string | null { + if (STUDIO_SURFACE_ROUTES.has(routeName) && !flags.studio) { + return flags.rag ? ROUTES.DOCS_LIBRARY : ROUTES.HOME + } + if (RAG_SURFACE_ROUTES.has(routeName) && !flags.rag) { + return flags.studio ? ROUTES.STUDIO : ROUTES.HOME + } + return null +}