feat(#251): add Neo4j as a StoreKind

- domain : StoreKind.NEO4J ajouté à l'enum
- service : validation config par kind étendue (Neo4j requires non-empty index_name)
- frontend : Neo4jConfigForm (index_name + database optionnelle), dispatcher StoreConfigForm, dropdown kind, i18n FR/EN
- tests service : 2 cas Neo4j (create OK + missing index_name 422)

Auth (URI/user/password) reste pilotée par les variables d'environnement
globales (cf. infra/neo4j.py) — la config par store ne porte que les
paramètres routables (index, database).
This commit is contained in:
Pier-Jean Malandrino 2026-05-04 13:37:44 +02:00
parent 61e1c30b12
commit 3fe0d3daa5
7 changed files with 158 additions and 7 deletions

View file

@ -40,11 +40,11 @@ class DocumentLifecycleState(StrEnum):
class StoreKind(StrEnum): class StoreKind(StrEnum):
"""Backing technology of a Store. Today only OpenSearch is implemented; """Backing technology of a Store. New backends plug in here without
the enum is here so future backends (Pinecone, Qdrant, pgvector) can be touching the persistence schema."""
added without touching the persistence schema."""
OPENSEARCH = "opensearch" OPENSEARCH = "opensearch"
NEO4J = "neo4j"
class DocumentStoreLinkState(StrEnum): class DocumentStoreLinkState(StrEnum):

View file

@ -87,13 +87,17 @@ def _validate_slug(slug: str) -> None:
def _validate_config_for_kind(kind: StoreKind, config: dict) -> None: def _validate_config_for_kind(kind: StoreKind, config: dict) -> None:
"""Per-kind config schema. Today only OpenSearch is wired; new kinds plug """Per-kind config schema. New kinds plug in here without touching the
in here without touching the rest of the pipeline.""" rest of the pipeline. Authentication for remote stores comes from the
deployment env (see `infra/settings.py`)."""
if kind is StoreKind.OPENSEARCH: if kind is StoreKind.OPENSEARCH:
index_name = config.get("index_name") or config.get("indexName") index_name = config.get("index_name") or config.get("indexName")
if not isinstance(index_name, str) or not index_name.strip(): if not isinstance(index_name, str) or not index_name.strip():
raise StoreValidationError("OpenSearch config requires a non-empty 'index_name'") raise StoreValidationError("OpenSearch config requires a non-empty 'index_name'")
# Future: add LlamaIndex / LangChain / pgvector branches here. elif kind is StoreKind.NEO4J:
index_name = config.get("index_name") or config.get("indexName")
if not isinstance(index_name, str) or not index_name.strip():
raise StoreValidationError("Neo4j config requires a non-empty 'index_name'")
class StoreService: class StoreService:

View file

@ -110,6 +110,27 @@ class TestCreate:
config={}, config={},
) )
async def test_create_neo4j_ok(self, service):
store = await service.create_store(
name="kg",
slug="kg",
kind=StoreKind.NEO4J,
embedder="bge-m3",
config={"index_name": "chunks-vec", "database": "neo4j"},
)
assert store.kind is StoreKind.NEO4J
assert store.config["index_name"] == "chunks-vec"
async def test_create_neo4j_rejects_missing_index_name(self, service):
with pytest.raises(StoreValidationError):
await service.create_store(
name="kg",
slug="kg",
kind=StoreKind.NEO4J,
embedder="bge-m3",
config={},
)
async def test_create_default_clears_others(self, service): async def test_create_default_clears_others(self, service):
# The seeded 'default' store starts as is_default=True. # The seeded 'default' store starts as is_default=True.
new_default = await service.create_store( new_default = await service.create_store(

View file

@ -0,0 +1,109 @@
<template>
<div class="config-form">
<div class="field">
<label class="field-label" for="store-neo4j-index">{{
t('storeForm.neo4j.indexName')
}}</label>
<input
id="store-neo4j-index"
v-model="indexName"
class="field-input"
type="text"
placeholder="chunks-vec"
:aria-invalid="showError && !indexName.trim()"
@input="emitChange"
/>
<p v-if="showError && !indexName.trim()" class="field-error">
{{ t('storeForm.required') }}
</p>
<p v-else class="field-help">{{ t('storeForm.neo4j.indexNameHelp') }}</p>
</div>
<div class="field">
<label class="field-label" for="store-neo4j-db">{{ t('storeForm.neo4j.database') }}</label>
<input
id="store-neo4j-db"
v-model="database"
class="field-input"
type="text"
placeholder="neo4j"
@input="emitChange"
/>
<p class="field-help">{{ t('storeForm.neo4j.databaseHelp') }}</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useI18n } from '../../../shared/i18n'
const props = defineProps<{
modelValue: Record<string, unknown>
showError?: boolean
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: Record<string, unknown>): void
(e: 'valid', value: boolean): void
}>()
const { t } = useI18n()
const indexName = ref(String(props.modelValue.indexName ?? props.modelValue.index_name ?? ''))
const database = ref(String(props.modelValue.database ?? ''))
function emitChange() {
emit('update:modelValue', {
indexName: indexName.value,
database: database.value || undefined,
})
emit('valid', indexName.value.trim().length > 0)
}
watch(
() => props.modelValue,
(next) => {
const incomingIndex = String(next.indexName ?? next.index_name ?? '')
if (incomingIndex !== indexName.value) indexName.value = incomingIndex
const incomingDb = String(next.database ?? '')
if (incomingDb !== database.value) database.value = incomingDb
},
)
emit('valid', indexName.value.trim().length > 0)
</script>
<style scoped>
.config-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.field-label {
font-weight: 500;
font-size: 0.875rem;
}
.field-input {
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border, #d1d5db);
border-radius: 0.375rem;
font-size: 0.875rem;
}
.field-input[aria-invalid='true'] {
border-color: #dc2626;
}
.field-help {
font-size: 0.75rem;
color: var(--color-text-muted, #6b7280);
}
.field-error {
font-size: 0.75rem;
color: #dc2626;
}
</style>

View file

@ -10,6 +10,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import OpenSearchConfigForm from './OpenSearchConfigForm.vue' import OpenSearchConfigForm from './OpenSearchConfigForm.vue'
import Neo4jConfigForm from './Neo4jConfigForm.vue'
const props = defineProps<{ const props = defineProps<{
kind: string kind: string
@ -37,6 +38,8 @@ const component = computed(() => {
switch (props.kind) { switch (props.kind) {
case 'opensearch': case 'opensearch':
return OpenSearchConfigForm return OpenSearchConfigForm
case 'neo4j':
return Neo4jConfigForm
default: default:
return OpenSearchConfigForm return OpenSearchConfigForm
} }

View file

@ -117,7 +117,10 @@ const emit = defineEmits<{
const { t } = useI18n() const { t } = useI18n()
const kindOptions = [{ value: 'opensearch', label: 'OpenSearch' }] const kindOptions = [
{ value: 'opensearch', label: 'OpenSearch' },
{ value: 'neo4j', label: 'Neo4j' },
]
const form = reactive<StoreCreatePayload>({ const form = reactive<StoreCreatePayload>({
name: props.initialValue?.name ?? '', name: props.initialValue?.name ?? '',

View file

@ -473,6 +473,12 @@ const messages: Messages = {
'storeForm.opensearch.indexName': 'Nom de l\u2019index', 'storeForm.opensearch.indexName': 'Nom de l\u2019index',
'storeForm.opensearch.indexNameHelp': 'storeForm.opensearch.indexNameHelp':
'Index OpenSearch cible (cr\u00e9\u00e9 \u00e0 la premi\u00e8re ingestion).', 'Index OpenSearch cible (cr\u00e9\u00e9 \u00e0 la premi\u00e8re ingestion).',
'storeForm.neo4j.indexName': 'Nom de l\u2019index vectoriel',
'storeForm.neo4j.indexNameHelp':
'Index vector Neo4j (5.x+) cible. URI et auth viennent des variables d\u2019environnement.',
'storeForm.neo4j.database': 'Base',
'storeForm.neo4j.databaseHelp':
'Optionnel \u2014 par d\u00e9faut \u00ab\u00a0neo4j\u00a0\u00bb.',
'storeForm.cancel': 'Annuler', 'storeForm.cancel': 'Annuler',
'storeForm.save': 'Enregistrer', 'storeForm.save': 'Enregistrer',
'storeForm.create': 'Cr\u00e9er', 'storeForm.create': 'Cr\u00e9er',
@ -962,6 +968,11 @@ const messages: Messages = {
'storeForm.sectionConfig': 'Configuration', 'storeForm.sectionConfig': 'Configuration',
'storeForm.opensearch.indexName': 'Index name', 'storeForm.opensearch.indexName': 'Index name',
'storeForm.opensearch.indexNameHelp': 'Target OpenSearch index (created on first ingestion).', 'storeForm.opensearch.indexNameHelp': 'Target OpenSearch index (created on first ingestion).',
'storeForm.neo4j.indexName': 'Vector index name',
'storeForm.neo4j.indexNameHelp':
'Target Neo4j (5.x+) vector index. URI and auth come from environment variables.',
'storeForm.neo4j.database': 'Database',
'storeForm.neo4j.databaseHelp': 'Optional — defaults to "neo4j".',
'storeForm.cancel': 'Cancel', 'storeForm.cancel': 'Cancel',
'storeForm.save': 'Save', 'storeForm.save': 'Save',
'storeForm.create': 'Create', 'storeForm.create': 'Create',