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:
parent
61e1c30b12
commit
3fe0d3daa5
7 changed files with 158 additions and 7 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
109
frontend/src/features/store/ui/Neo4jConfigForm.vue
Normal file
109
frontend/src/features/store/ui/Neo4jConfigForm.vue
Normal 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>
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ?? '',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue