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):
"""Backing technology of a Store. Today only OpenSearch is implemented;
the enum is here so future backends (Pinecone, Qdrant, pgvector) can be
added without touching the persistence schema."""
"""Backing technology of a Store. New backends plug in here without
touching the persistence schema."""
OPENSEARCH = "opensearch"
NEO4J = "neo4j"
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:
"""Per-kind config schema. Today only OpenSearch is wired; new kinds plug
in here without touching the rest of the pipeline."""
"""Per-kind config schema. New kinds plug in here without touching the
rest of the pipeline. Authentication for remote stores comes from the
deployment env (see `infra/settings.py`)."""
if kind is StoreKind.OPENSEARCH:
index_name = config.get("index_name") or config.get("indexName")
if not isinstance(index_name, str) or not index_name.strip():
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:

View file

@ -110,6 +110,27 @@ class TestCreate:
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):
# The seeded 'default' store starts as is_default=True.
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">
import { computed, ref, watch } from 'vue'
import OpenSearchConfigForm from './OpenSearchConfigForm.vue'
import Neo4jConfigForm from './Neo4jConfigForm.vue'
const props = defineProps<{
kind: string
@ -37,6 +38,8 @@ const component = computed(() => {
switch (props.kind) {
case 'opensearch':
return OpenSearchConfigForm
case 'neo4j':
return Neo4jConfigForm
default:
return OpenSearchConfigForm
}

View file

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

View file

@ -473,6 +473,12 @@ const messages: Messages = {
'storeForm.opensearch.indexName': 'Nom de l\u2019index',
'storeForm.opensearch.indexNameHelp':
'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.save': 'Enregistrer',
'storeForm.create': 'Cr\u00e9er',
@ -962,6 +968,11 @@ const messages: Messages = {
'storeForm.sectionConfig': 'Configuration',
'storeForm.opensearch.indexName': 'Index name',
'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.save': 'Save',
'storeForm.create': 'Create',