Add disclaimer banner for shared deployments (HuggingFace)

Feature-flagged via DEPLOYMENT_MODE env var and /api/health endpoint.
Disabled by default (self-hosted), enabled when deploymentMode=huggingface.
This commit is contained in:
Pier-Jean Malandrino 2026-04-04 21:06:38 +02:00
parent 76e8c55b7c
commit ee57b04f84
7 changed files with 106 additions and 3 deletions

View file

@ -10,6 +10,7 @@ from dataclasses import dataclass, field
class Settings:
app_version: str = "dev"
conversion_engine: str = "local" # "local" or "remote"
deployment_mode: str = "self-hosted" # "self-hosted" or "huggingface"
docling_serve_url: str = "http://localhost:5001"
docling_serve_api_key: str | None = None
conversion_timeout: int = 600
@ -27,6 +28,7 @@ class Settings:
return cls(
app_version=os.environ.get("APP_VERSION", "dev"),
conversion_engine=os.environ.get("CONVERSION_ENGINE", "local"),
deployment_mode=os.environ.get("DEPLOYMENT_MODE", "self-hosted"),
docling_serve_url=os.environ.get("DOCLING_SERVE_URL", "http://localhost:5001"),
docling_serve_api_key=os.environ.get("DOCLING_SERVE_API_KEY"),
conversion_timeout=int(os.environ.get("CONVERSION_TIMEOUT", "600")),

View file

@ -117,5 +117,6 @@ async def health() -> dict[str, str]:
"status": status,
"version": settings.app_version,
"engine": settings.conversion_engine,
"deploymentMode": settings.deployment_mode,
"database": db_status,
}

View file

@ -10,6 +10,7 @@ class TestSettingsDefaults:
s = Settings()
assert s.app_version == "dev"
assert s.conversion_engine == "local"
assert s.deployment_mode == "self-hosted"
assert s.docling_serve_url == "http://localhost:5001"
assert s.docling_serve_api_key is None
assert s.conversion_timeout == 600
@ -30,6 +31,7 @@ class TestSettingsFromEnv:
def test_reads_env_vars(self, monkeypatch):
monkeypatch.setenv("APP_VERSION", "1.2.3")
monkeypatch.setenv("CONVERSION_ENGINE", "remote")
monkeypatch.setenv("DEPLOYMENT_MODE", "huggingface")
monkeypatch.setenv("DOCLING_SERVE_URL", "http://serve:9000")
monkeypatch.setenv("DOCLING_SERVE_API_KEY", "secret-key")
monkeypatch.setenv("CONVERSION_TIMEOUT", "120")
@ -41,6 +43,7 @@ class TestSettingsFromEnv:
assert s.app_version == "1.2.3"
assert s.conversion_engine == "remote"
assert s.deployment_mode == "huggingface"
assert s.docling_serve_url == "http://serve:9000"
assert s.docling_serve_api_key == "secret-key"
assert s.conversion_timeout == 120
@ -53,6 +56,7 @@ class TestSettingsFromEnv:
for key in (
"APP_VERSION",
"CONVERSION_ENGINE",
"DEPLOYMENT_MODE",
"DOCLING_SERVE_URL",
"DOCLING_SERVE_API_KEY",
"CONVERSION_TIMEOUT",

View file

@ -29,6 +29,13 @@
</button>
</header>
<div v-if="showDisclaimer" class="disclaimer-banner" role="alert">
{{ t('disclaimer.banner') }}
<button class="disclaimer-close" @click="dismissDisclaimer" aria-label="Close">
&times;
</button>
</div>
<div class="app-body">
<AppSidebar :open="sidebarOpen" />
<main class="main">
@ -39,11 +46,12 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, computed } from 'vue'
import { RouterView, useRouter } from 'vue-router'
import { AppSidebar } from '../shared/ui/index'
import { useSettingsStore } from '../features/settings/store'
import { useDocumentStore } from '../features/document/store'
import { useFeatureFlag } from '../features/feature-flags'
import { useI18n } from '../shared/i18n'
useSettingsStore()
@ -52,6 +60,13 @@ const router = useRouter()
const documentStore = useDocumentStore()
const sidebarOpen = ref(true)
const disclaimerEnabled = useFeatureFlag('disclaimer')
const disclaimerDismissed = ref(false)
const showDisclaimer = computed(() => disclaimerEnabled.value && !disclaimerDismissed.value)
function dismissDisclaimer() {
disclaimerDismissed.value = true
}
function newAnalysis() {
documentStore.selectedId = null
@ -143,6 +158,36 @@ body {
height: 100vh;
}
.disclaimer-banner {
background: #f59e0b;
color: #1a1a1d;
font-size: 13px;
font-weight: 500;
text-align: center;
padding: 8px 40px 8px 16px;
position: relative;
flex-shrink: 0;
}
.disclaimer-close {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: #1a1a1d;
font-size: 18px;
cursor: pointer;
padding: 0 4px;
line-height: 1;
opacity: 0.7;
}
.disclaimer-close:hover {
opacity: 1;
}
.topbar {
height: var(--topbar-height);
min-height: var(--topbar-height);

View file

@ -17,6 +17,7 @@ describe('useFeatureFlagStore', () => {
const store = useFeatureFlagStore()
expect(store.loaded).toBe(false)
expect(store.isEnabled('chunking')).toBe(false)
expect(store.isEnabled('disclaimer')).toBe(false)
})
it('enables chunking when engine is local', async () => {
@ -36,6 +37,37 @@ describe('useFeatureFlagStore', () => {
expect(store.isEnabled('chunking')).toBe(false)
})
it('enables disclaimer when deploymentMode is huggingface', async () => {
mockApiFetch.mockResolvedValue({
status: 'ok',
engine: 'local',
deploymentMode: 'huggingface',
})
const store = useFeatureFlagStore()
await store.load()
expect(store.deploymentMode).toBe('huggingface')
expect(store.isEnabled('disclaimer')).toBe(true)
})
it('disables disclaimer when deploymentMode is self-hosted', async () => {
mockApiFetch.mockResolvedValue({
status: 'ok',
engine: 'local',
deploymentMode: 'self-hosted',
})
const store = useFeatureFlagStore()
await store.load()
expect(store.isEnabled('disclaimer')).toBe(false)
})
it('defaults deploymentMode to self-hosted when missing', async () => {
mockApiFetch.mockResolvedValue({ status: 'ok', engine: 'local' })
const store = useFeatureFlagStore()
await store.load()
expect(store.deploymentMode).toBe('self-hosted')
expect(store.isEnabled('disclaimer')).toBe(false)
})
it('handles health endpoint failure gracefully', async () => {
mockApiFetch.mockRejectedValue(new Error('Network error'))
const store = useFeatureFlagStore()
@ -43,5 +75,6 @@ describe('useFeatureFlagStore', () => {
expect(store.loaded).toBe(true)
expect(store.error).toBe('Network error')
expect(store.isEnabled('chunking')).toBe(false)
expect(store.isEnabled('disclaimer')).toBe(false)
})
})

View file

@ -3,13 +3,15 @@ import { ref, computed } from 'vue'
import { apiFetch } from '../../shared/api/http'
type ConversionEngine = 'local' | 'remote'
type DeploymentMode = 'self-hosted' | 'huggingface'
interface HealthResponse {
status: string
engine: ConversionEngine
deploymentMode?: DeploymentMode
}
export type FeatureFlag = 'chunking'
export type FeatureFlag = 'chunking' | 'disclaimer'
interface FeatureFlagDef {
description: string
@ -18,6 +20,7 @@ interface FeatureFlagDef {
interface FeatureFlagContext {
engine: ConversionEngine | null
deploymentMode: DeploymentMode | null
}
const featureRegistry: Record<FeatureFlag, FeatureFlagDef> = {
@ -25,15 +28,21 @@ const featureRegistry: Record<FeatureFlag, FeatureFlagDef> = {
description: 'Document chunking for RAG preparation',
isEnabled: (ctx) => ctx.engine === 'local',
},
disclaimer: {
description: 'Show shared-instance disclaimer banner',
isEnabled: (ctx) => ctx.deploymentMode === 'huggingface',
},
}
export const useFeatureFlagStore = defineStore('feature-flags', () => {
const engine = ref<ConversionEngine | null>(null)
const deploymentMode = ref<DeploymentMode | null>(null)
const loaded = ref(false)
const error = ref<string | null>(null)
const context = computed<FeatureFlagContext>(() => ({
engine: engine.value,
deploymentMode: deploymentMode.value,
}))
function isEnabled(flag: FeatureFlag): boolean {
@ -46,6 +55,7 @@ export const useFeatureFlagStore = defineStore('feature-flags', () => {
try {
const data = await apiFetch<HealthResponse>('/api/health')
engine.value = data.engine
deploymentMode.value = data.deploymentMode ?? 'self-hosted'
loaded.value = true
error.value = null
} catch (e) {
@ -54,5 +64,5 @@ export const useFeatureFlagStore = defineStore('feature-flags', () => {
}
}
return { engine, loaded, error, isEnabled, load }
return { engine, deploymentMode, loaded, error, isEnabled, load }
})

View file

@ -129,6 +129,10 @@ const messages: Messages = {
'settings.themeDark': 'Sombre',
'settings.themeLight': 'Clair',
'settings.language': 'Langue',
// Disclaimer
'disclaimer.banner':
'Instance de d\u00e9monstration \u2014 les documents upload\u00e9s sont partag\u00e9s et temporaires. Ne pas envoyer de fichiers confidentiels.',
},
en: {
'nav.home': 'Home',
@ -242,6 +246,10 @@ const messages: Messages = {
'settings.themeDark': 'Dark',
'settings.themeLight': 'Light',
'settings.language': 'Language',
// Disclaimer
'disclaimer.banner':
'Demo instance \u2014 uploaded documents are shared and temporary. Do not upload confidential files.',
},
}