Files
mdm-ordinis/ordinis-admin-ui/src/components/schema/DictionaryEditorDialog.tsx
T
Zimin A.N. e4cd7b9709 feat(ai): Phase 1 step 3-6 — LLM adapter + per-field AI suggest endpoint + UI
Approach C upgrade на templates foundation (!233). Admin может попросить AI
предложить новое поле — system prompt force'ит structural JSON Schema
fragment, admin review через accept/edit/reject. Bitemporal draft workflow
unchanged downstream.

## Backend
- `LlmAdapter` — OpenAI-compatible /v1/chat/completions client (Ollama,
  vLLM, OpenAI). Bean conditional на ordinis.ai.enabled=true.
- `AiSchemaService` — system prompt + 3 few-shot examples, JSON output
  parsing (с markdown fence stripping), validation (fieldName snake_case).
  In-memory circuit breaker: 10 fails в 60s → open 5 min.
- `AiSchemaController`:
  - GET /api/v1/ai/info → 200 (enabled) / 404 (disabled). Frontend probe.
  - POST /api/v1/ai/suggest-field → suggestion. Per-IP rate limit 30/min.
- All bean-conditional: ordinis.ai.enabled=false → controllers нет →
  endpoints 404 → frontend hides AI buttons automatically.

## Frontend
- `useAiFeatureAvailable` query — probes /ai/info, cache 5 min
- `useAiSuggestField` mutation — 30s timeout (LLM может быть slow)
- `AiFieldSuggestPanel` component — inline expandable: prompt → preview →
  accept/retry/reject. Error handling per HTTP status (503/429/422/502/404).
- DictionaryEditorDialog: AI toggle visible на schema tab когда
  aiAvailable=true. Accept wraps suggestion в mini-schema → parseSchemaJson
  (reuse existing kind-inference) → push в properties (overwrite если
  duplicate fieldName).
- i18n RU + EN

## Config
- application.yml: ordinis.ai.{enabled,endpoint,model,bearer-token,
  timeout-seconds}
- Helm writer.yaml: ORDINIS_AI_* env vars, optional secretRef для bearer
- values.yaml: ai.{enabled,endpoint,model,timeoutSeconds,bearerTokenSecretRef}
  defaults disabled

Pair с infra MR — values-staging enables AI с vortex.nstart.cloud Ollama
serving qwen2.5:7b.
2026-05-16 13:55:16 +03:00

624 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useQueryClient } from '@tanstack/react-query'
import axios from 'axios'
import {
Alert,
Button,
FormActions,
Modal,
MultiSelect,
SingleSelect,
Tabs,
TextArea,
TextInput,
type SelectOption,
type TabItem,
} from '@/ui'
import { useCreateDictionary, useUpdateDictionary } from '@/api/mutations'
import {
dictionaryDetailQuery,
useAiFeatureAvailable,
useDictionaries,
} from '@/api/queries'
import type { CreateDictionaryRequest, DataScope, DictionaryDetail } from '@/api/client'
import { SchemaBuilder } from './SchemaBuilder'
import { TemplatePicker } from './TemplatePicker'
import { AiFieldSuggestPanel } from './AiFieldSuggestPanel'
import { SparkleIcon } from '@phosphor-icons/react'
import { EventsPreviewTab } from './EventsPreviewTab'
import { CreateSchemaDraftModal } from '@/components/workflow/CreateSchemaDraftModal'
import {
buildSchemaJson,
diffSchemas,
hasBreakingChanges,
parseSchemaJson,
suggestVersionBump,
type PropertyDef,
type SchemaChange,
} from './types'
type Mode =
| { kind: 'create' }
| { kind: 'edit'; existing: DictionaryDetail }
type Props = {
open: boolean
mode: Mode
onClose: () => void
onSuccess: (name: string) => void
}
const DEFAULT_LOCALES = ['ru-RU', 'en-US', 'zh-CN']
const SCOPE_OPTIONS = (t: (k: string) => string): SelectOption[] => [
{ id: 'PUBLIC', label: t('scope.PUBLIC') },
{ id: 'INTERNAL', label: t('scope.INTERNAL') },
{ id: 'RESTRICTED', label: t('scope.RESTRICTED') },
]
type EditorTab = 'metadata' | 'schema' | 'preview' | 'events'
/**
* Decide whether the editor's Save button should be blocked because the
* change has to go through the schema-draft workflow instead of inline PUT.
* Mirrors the three backend gate clauses in DictionaryDefinitionService
* exactly so the UX matches the server contract.
*
* Returns false (don't block) for create-mode, for non-approval-required
* dicts, and for pure metadata edits (display name / description / locales
* etc.) that leave schema + approval config alone.
*
* Exported for unit testability — the three-clause logic is the load-bearing
* piece, easier to verify in isolation than mounting the full editor with
* its query and mutation deps.
*/
export function computeRequiresDraftFlow({
isEdit,
initialApprovalRequired,
initialMinRole,
currentApprovalRequired,
currentMinRole,
schemaChangeCount,
}: {
isEdit: boolean
initialApprovalRequired: boolean
initialMinRole: string | null
currentApprovalRequired: boolean
currentMinRole: string | null
schemaChangeCount: number
}): boolean {
if (!isEdit || !initialApprovalRequired) return false
const schemaChanged = schemaChangeCount > 0
const approvalToggledOff = !currentApprovalRequired
const minRoleChanged = initialMinRole !== currentMinRole
return schemaChanged || approvalToggledOff || minRoleChanged
}
export const DictionaryEditorDialog = ({ open, mode, onClose, onSuccess }: Props) => {
const { t } = useTranslation()
const createMut = useCreateDictionary()
const updateMut = useUpdateDictionary()
const isEdit = mode.kind === 'edit'
const initial = mode.kind === 'edit' ? mode.existing : null
const parsed = useMemo(() => parseSchemaJson(initial?.schemaJson), [initial])
const [activeTab, setActiveTab] = useState<EditorTab>('metadata')
const [name, setName] = useState(initial?.name ?? '')
const [displayName, setDisplayName] = useState(initial?.displayName ?? '')
const [description, setDescription] = useState(initial?.description ?? '')
const [scope, setScope] = useState<DataScope>(initial?.scope ?? 'PUBLIC')
const [bundle, setBundle] = useState(initial?.bundle ?? 'cuod')
const [schemaVersion, setSchemaVersion] = useState(initial?.schemaVersion ?? '1.0.0')
const [supportedLocales, setSupportedLocales] = useState<string[]>(
initial?.supportedLocales ?? ['ru-RU'],
)
const [defaultLocale, setDefaultLocale] = useState(initial?.defaultLocale ?? 'ru-RU')
const [redisProjection, setRedisProjection] = useState(
initial?.redisProjectionEnabled ?? false,
)
const [approvalRequired, setApprovalRequired] = useState(
(initial as { approvalRequired?: boolean } | null)?.approvalRequired ?? false,
)
const [approvalMinRole, setApprovalMinRole] = useState(
(initial as { approvalMinRole?: string } | null)?.approvalMinRole ?? '',
)
const [properties, setProperties] = useState<PropertyDef[]>(parsed.properties)
const [idSource, setIdSource] = useState<string>(parsed.idSource ?? '')
const [nameError, setNameError] = useState<string | null>(null)
/** Open state for the "Создать черновик с моими изменениями" CTA. The CTA
* is the inline handoff from the disabled-Save path (requiresDraftFlow)
* into the schema-draft modal with the maker's in-progress schemaJson
* pre-loaded — no retyping. */
const [draftHandoffOpen, setDraftHandoffOpen] = useState(false)
/** AI Schema Assist (Phase 1 step 6): per-field LLM suggest panel toggle. */
const [aiPanelOpen, setAiPanelOpen] = useState(false)
const aiAvailable = useAiFeatureAvailable()
// Template (только в create-mode): admin выбирает существующий dict,
// его schema + locale + scope копируются. Имя остаётся пустым (admin
// вводит уникальное), version reset на 1.0.0.
const queryClient = useQueryClient()
const dictsQuery = useDictionaries()
const [loadingTemplate, setLoadingTemplate] = useState(false)
const templateOptions: SelectOption[] = useMemo(() => {
if (isEdit) return []
const list = dictsQuery.data ?? []
return list
.map((d) => ({
id: d.name,
label: d.displayName ? `${d.name}${d.displayName}` : d.name,
}))
.sort((a, b) => a.label.localeCompare(b.label, 'ru'))
}, [dictsQuery.data, isEdit])
const handleApplyTemplate = async (templateName: string) => {
if (!templateName) return
setLoadingTemplate(true)
try {
const detail = await queryClient.fetchQuery(dictionaryDetailQuery(templateName))
const tplParsed = parseSchemaJson(detail.schemaJson)
// Не трогаем name — admin должен ввести уникальное.
setDisplayName('')
setDescription(detail.description ?? '')
setScope(detail.scope)
setBundle(detail.bundle ?? 'cuod')
setSchemaVersion('1.0.0')
setSupportedLocales(detail.supportedLocales ?? ['ru-RU'])
setDefaultLocale(detail.defaultLocale ?? 'ru-RU')
setProperties(tplParsed.properties)
setIdSource(tplParsed.idSource ?? '')
} finally {
setLoadingTemplate(false)
}
}
const schemaJson = useMemo(
() => buildSchemaJson(properties, idSource || undefined),
[properties, idSource],
)
const changes: SchemaChange[] = useMemo(
() => (isEdit ? diffSchemas(initial?.schemaJson, schemaJson) : []),
[isEdit, initial, schemaJson],
)
const breaking = hasBreakingChanges(changes)
const suggestedVersion = useMemo(
() => (isEdit ? suggestVersionBump(initial?.schemaVersion, changes) : schemaVersion),
[isEdit, initial, changes, schemaVersion],
)
// Approval-aware Save: when editing an approval-required dict, any schema
// change OR toggling approvalRequired off OR changing approvalMinRole has
// to route through the schema-draft workflow. The backend will reject with
// 422 approval_required, but it's much better UX to disable Save up front
// and tell the user to open a draft instead. Mirror the three backend
// gate clauses exactly so the UX matches the server contract.
const requiresDraftFlow = computeRequiresDraftFlow({
isEdit,
initialApprovalRequired: Boolean(
(initial as { approvalRequired?: boolean } | null)?.approvalRequired,
),
initialMinRole:
((initial as { approvalMinRole?: string } | null)?.approvalMinRole ?? '') || null,
currentApprovalRequired: approvalRequired,
currentMinRole: approvalMinRole.trim() === '' ? null : approvalMinRole.trim(),
schemaChangeCount: changes.length,
})
const activeMutation = isEdit ? updateMut : createMut
const serverError = serverErrorMessage(activeMutation.error)
const localeOptions: SelectOption[] = DEFAULT_LOCALES.map((l) => ({ id: l, label: l }))
const defaultLocaleOptions: SelectOption[] = supportedLocales.map((l) => ({ id: l, label: l }))
const handleSubmit = () => {
if (!isEdit && !/^[a-z][a-z0-9_]{1,127}$/.test(name)) {
setNameError(t('schema.nameInvalid'))
setActiveTab('metadata')
return
}
setNameError(null)
if (isEdit && breaking) {
setActiveTab('metadata')
return
}
const payload: CreateDictionaryRequest = {
name,
displayName: displayName || undefined,
description: description || undefined,
scope,
schemaJson,
schemaVersion: schemaVersion || undefined,
bundle: bundle || undefined,
supportedLocales: supportedLocales.length > 0 ? supportedLocales : undefined,
defaultLocale: defaultLocale || undefined,
redisProjectionEnabled: redisProjection,
approvalRequired,
approvalMinRole: approvalMinRole.trim() || undefined,
}
if (isEdit) {
updateMut.mutate(
{ name: initial!.name, payload },
{ onSuccess: () => onSuccess(initial!.name) },
)
} else {
createMut.mutate(payload, { onSuccess: (resp) => onSuccess(resp.name) })
}
}
const tabs: TabItem[] = [
{ id: 'metadata', label: t('schema.tabs.metadata') },
{ id: 'schema', label: t('schema.tabs.schema') },
{ id: 'preview', label: t('schema.tabs.preview') },
{ id: 'events', label: t('schema.tabs.events') },
]
return (
<Modal
isOpen={open}
onClose={onClose}
title={isEdit ? `${t('schema.action.edit')}: ${initial?.name}` : t('schema.action.create')}
maxWidth="max-w-5xl"
panelClassName="max-h-[calc(100vh-2rem)] my-4 flex flex-col"
bodyClassName="overflow-y-auto flex-1"
>
<div className="space-y-4">
<Tabs items={tabs} value={activeTab} onValueChange={(id) => setActiveTab(id as EditorTab)} />
<div className={activeTab === 'metadata' ? 'block' : 'hidden'}>
{!isEdit && (
<div className="mb-3 p-3 rounded-md border border-accent/20 bg-accent/4">
<SingleSelect
label={t('schema.template.label')}
hint={t('schema.template.hint')}
options={templateOptions}
value=""
onChange={(id) => {
void handleApplyTemplate(id)
}}
placeholder={
dictsQuery.isLoading || loadingTemplate
? '…'
: t('schema.template.placeholder')
}
disabled={dictsQuery.isLoading || loadingTemplate}
/>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<TextInput
label={t('schema.name')}
required
disabled={isEdit}
placeholder="my_dictionary"
hint={t('schema.nameHint')}
error={nameError ?? undefined}
value={name}
onChange={(e) => setName(e.target.value)}
/>
<TextInput
label={t('schema.displayName')}
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
/>
<div className="sm:col-span-2">
<TextArea
label={t('schema.description')}
rows={2}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<SingleSelect
label={t('schema.scope')}
required
options={SCOPE_OPTIONS(t)}
value={scope}
onChange={(id) => setScope(id as DataScope)}
/>
<TextInput
label={t('schema.bundle')}
hint={t('schema.bundleHint')}
value={bundle}
onChange={(e) => setBundle(e.target.value)}
/>
<TextInput
label={t('schema.version')}
placeholder="1.0.0"
hint={
isEdit && suggestedVersion !== schemaVersion
? t('schema.versionSuggest', { v: suggestedVersion })
: undefined
}
value={schemaVersion}
onChange={(e) => setSchemaVersion(e.target.value)}
/>
<SingleSelect
label={t('schema.idSource')}
hint={t('schema.idSourceHint')}
options={[
{ id: '', label: '— ' + t('schema.idSourceManual') + ' —' },
...properties
.filter((p) => p.name && (p.kind === 'string' || p.kind === 'integer' || p.kind === 'number'))
.map((p) => ({ id: p.name, label: p.name })),
]}
value={idSource}
onChange={setIdSource}
/>
<MultiSelect
label={t('schema.supportedLocales')}
options={localeOptions}
value={supportedLocales}
onChange={(ids) => {
setSupportedLocales(ids)
if (!ids.includes(defaultLocale) && ids.length > 0) {
setDefaultLocale(ids[0])
}
}}
/>
<SingleSelect
label={t('schema.defaultLocale')}
options={defaultLocaleOptions}
value={defaultLocale}
onChange={setDefaultLocale}
/>
{/* CEO plan E2: per-dict Redis projection opt-in. Default false.
Включается когда dict хочет 10k+ RPS на read-api (вместо PG). */}
<label className="md:col-span-2 flex items-start gap-3 px-3 py-2 rounded-sm border border-line bg-line/20 cursor-pointer">
<input
type="checkbox"
checked={redisProjection}
onChange={(e) => setRedisProjection(e.target.checked)}
className="mt-0.5 size-4 accent-ultramarain"
/>
<span className="flex-1">
<span className="block text-body font-sans text-ink">
{t('schema.redisProjection.label')}
</span>
<span className="block text-cell text-mute mt-0.5">
{t('schema.redisProjection.hint')}
</span>
</span>
</label>
{/* Approval Workflow v2: per-dict opt-in. Default false. Включается
для критичных dicts (ground_station, spacecraft и т.д.) где
один admin не должен иметь power публиковать без review. */}
<label className="md:col-span-2 flex items-start gap-3 px-3 py-2 rounded-sm border border-warn bg-warn-bg cursor-pointer">
<input
type="checkbox"
checked={approvalRequired}
onChange={(e) => setApprovalRequired(e.target.checked)}
className="mt-0.5 size-4 accent-orbit"
/>
<span className="flex-1">
<span className="block text-body font-sans text-ink">
{t('schema.approvalRequired.label')}
</span>
<span className="block text-cell text-mute mt-0.5">
{t('schema.approvalRequired.hint')}
</span>
</span>
</label>
{approvalRequired && (
<div className="md:col-span-2">
<label
htmlFor="approval-min-role"
className="text-cap text-ink-2"
>
{t('schema.approvalMinRole.label')}
</label>
<TextInput
id="approval-min-role"
value={approvalMinRole}
onChange={(e) => setApprovalMinRole(e.target.value)}
placeholder="ordinis:internal"
/>
<p className="text-cell text-mute mt-0.5">
{t('schema.approvalMinRole.hint')}
</p>
</div>
)}
</div>
</div>
<div className={activeTab === 'schema' ? 'block' : 'hidden'}>
{/* AI Schema Assist Phase 1: template picker visible только в create-mode
* И только пока пользователь не начал заполнять properties (чтобы не
* перезатереть его работу одним кликом). После первой property —
* picker исчезает (можно reset через template via existing dict templ
* UI выше или вручную clear). */}
{!isEdit && properties.length === 0 && (
<div className="mb-4">
<TemplatePicker
disabled={loadingTemplate || createMut.isPending}
onPick={(template) => {
const tplParsed = parseSchemaJson(template.schemaJson)
setProperties(tplParsed.properties)
setIdSource(tplParsed.idSource ?? '')
}}
/>
</div>
)}
{/* AI Schema Assist Phase 1 step 6: per-field LLM suggest panel.
* Toggle hidden когда ordinis.ai.enabled=false на бэкенде (probe
* via /api/v1/ai/info → 404 = hide). */}
{aiAvailable.data && (
<div className="mb-4">
{!aiPanelOpen ? (
<button
type="button"
onClick={() => setAiPanelOpen(true)}
className="inline-flex items-center gap-1.5 text-cell text-accent hover:underline"
>
<SparkleIcon size={14} weight="fill" />
{t('aiSuggest.openButton', { defaultValue: 'AI: добавить поле' })}
</button>
) : (
<AiFieldSuggestPanel
existingSchema={schemaJson as Record<string, unknown>}
onClose={() => setAiPanelOpen(false)}
onAccept={(suggestion) => {
// Wrap suggestion в mini-schema → parse через existing
// parseSchemaJson → получаем PropertyDef. Reuse уже
// tested kind-inference logic.
const wrapper = {
type: 'object',
properties: { [suggestion.fieldName]: suggestion.schema },
}
const parsed = parseSchemaJson(wrapper as never)
if (parsed.properties.length > 0) {
// Avoid duplicate field name — overwrite если уже есть.
const existingIdx = properties.findIndex(
(p) => p.name === suggestion.fieldName,
)
if (existingIdx >= 0) {
const next = [...properties]
next[existingIdx] = parsed.properties[0]
setProperties(next)
} else {
setProperties([...properties, parsed.properties[0]])
}
}
}}
/>
)}
</div>
)}
<SchemaBuilder properties={properties} onChange={setProperties} />
</div>
<div className={activeTab === 'preview' ? 'block' : 'hidden'}>
<pre className="text-mono bg-line/30 rounded p-3 overflow-x-auto whitespace-pre-wrap">
{JSON.stringify(schemaJson, null, 2)}
</pre>
</div>
<div className={activeTab === 'events' ? 'block' : 'hidden'}>
{/* Phase 4.5 / CEO plan v1: integrators видят shape Kafka events
до published'а dictionary. Sample data на основе schema fields. */}
<EventsPreviewTab
dictionaryName={name}
dataScope={scope}
bundle={bundle}
schema={schemaJson}
businessKeyField={idSource || undefined}
/>
</div>
{isEdit && breaking && (
<Alert variant="error" title={t('schema.diff.blockedTitle')}>
<div className="space-y-2">
<p className="text-body">{t('schema.diff.blockedBody')}</p>
<ul className="list-disc pl-5 text-body space-y-0.5">
{changes
.filter((c) => c.severity === 'breaking')
.map((c, i) => (
<li key={i}>
<span className="font-mono">{c.field}</span> {c.reason}
</li>
))}
</ul>
</div>
</Alert>
)}
{isEdit && !breaking && changes.length > 0 && (
<Alert variant="info" title={t('schema.diff.compatibleTitle')}>
<ul className="list-disc pl-5 text-body space-y-0.5">
{changes.map((c, i) => (
<li key={i}>
<span className="font-mono">{c.field}</span> {c.reason}
</li>
))}
</ul>
</Alert>
)}
{serverError && <Alert variant="error">{serverError}</Alert>}
{requiresDraftFlow && (
<Alert
variant="warning"
title={t('schema.approvalRequired.editorBlockTitle', {
defaultValue: 'Изменения требуют ревью',
})}
action={
<Button
type="button"
variant="primary"
size="sm"
onClick={() => setDraftHandoffOpen(true)}
>
{t('schema.approvalRequired.openDraftCta', {
defaultValue: 'Создать черновик с моими изменениями',
})}
</Button>
}
>
{t('schema.approvalRequired.editorBlock', {
defaultValue:
'Этот словарь требует maker-checker review. Откройте черновик — ' +
'ваши текущие изменения будут предзаполнены, вам нужно только ' +
'указать причину и отправить на согласование.',
})}
</Alert>
)}
<FormActions align="end">
<Button type="button" variant="ghost" onClick={onClose} disabled={activeMutation.isPending}>
{t('form.cancel')}
</Button>
<Button
type="button"
variant="primary"
loading={activeMutation.isPending}
disabled={(isEdit && breaking) || requiresDraftFlow}
onClick={handleSubmit}
>
{activeMutation.isPending ? t('form.saving') : t('form.save')}
</Button>
</FormActions>
</div>
{/* Inline handoff: CreateSchemaDraftModal mounted with the in-progress
schemaJson pre-loaded. Maker hits "Создать черновик с моими изменениями"
→ modal opens with the same edits they had → they add a reason and
submit. No retyping, no hunt for a button on the dict page. */}
{isEdit && initial && (
<CreateSchemaDraftModal
open={draftHandoffOpen}
onClose={() => setDraftHandoffOpen(false)}
detail={initial}
initialProposedSchema={schemaJson}
onCreated={() => {
// Cascade close: dismiss the draft modal AND the editor. Once
// the draft is submitted, the in-memory edit in the editor is
// stale by design — the draft is now the source of truth until
// a reviewer decides. Leaving the editor open would let the
// maker silently mutate further state that can't go anywhere
// (Save still disabled until the draft resolves).
setDraftHandoffOpen(false)
onClose()
}}
/>
)}
</Modal>
)
}
const serverErrorMessage = (err: unknown): string | null => {
if (!err) return null
if (axios.isAxiosError(err)) {
const data = err.response?.data as { message?: string; error?: string } | undefined
return data?.message ?? data?.error ?? err.message
}
return err instanceof Error ? err.message : 'Unknown error'
}