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.
This commit is contained in:
@@ -794,6 +794,15 @@ export type SchemaTemplateDetail = SchemaTemplateSummary & {
|
||||
schemaJson: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* AI Schema Assist suggest-field response. Shape должен matchить
|
||||
* AiSchemaController output (parsed LLM JSON: {fieldName, schema}).
|
||||
*/
|
||||
export type AiFieldSuggestion = {
|
||||
fieldName: string
|
||||
schema: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty-state hint payload (read-api scheduled-summary). Подсчёт записей с
|
||||
* {@code validFrom > now AND validTo > now} в текущем scope view.
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useRef } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
apiClient,
|
||||
type AiFieldSuggestion,
|
||||
type BulkCloseRequest,
|
||||
type BulkCloseResponse,
|
||||
type CascadeCloseResult,
|
||||
@@ -741,3 +742,28 @@ export const useUpdateNotificationPreferences = () => {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* AI Schema Assist — per-field suggest. Returns parsed {fieldName, schema}.
|
||||
* Каллер показывает diff preview, user accept/edit/reject.
|
||||
*
|
||||
* 404 → AI disabled на бэкенде — frontend hides button (handled в caller).
|
||||
* 503/circuit_open → temporary unavailable, show banner.
|
||||
* 422 → bad output from LLM — show error message, suggest retry.
|
||||
* 429 → rate limit, show "try again in a minute".
|
||||
*/
|
||||
export const useAiSuggestField = () => {
|
||||
return useMutation({
|
||||
mutationFn: async (req: {
|
||||
existingSchema: unknown
|
||||
prompt: string
|
||||
}): Promise<AiFieldSuggestion> => {
|
||||
const { data } = await apiClient.post<AiFieldSuggestion>(
|
||||
'/ai/suggest-field',
|
||||
req,
|
||||
{ timeout: 30_000 }, // override default 10s — LLM может быть slow
|
||||
)
|
||||
return data
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -852,6 +852,34 @@ export const schemaTemplateDetailQuery = (id: string) =>
|
||||
},
|
||||
staleTime: 5 * 60_000,
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// AI feature detection — probe endpoint
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Probe AI Schema Assist availability. Backend регистрирует /ai/suggest-field
|
||||
* conditionally на ordinis.ai.enabled — если выключено, OPTIONS возвращает 404.
|
||||
*
|
||||
* <p>Used UI чтобы decide показывать ли «AI suggest» button. Cache 5 min —
|
||||
* feature flag меняется только при helm upgrade.
|
||||
*/
|
||||
export const aiFeatureAvailableQuery = queryOptions({
|
||||
queryKey: ['ai-feature-available'] as const,
|
||||
queryFn: async (): Promise<boolean> => {
|
||||
try {
|
||||
const { data } = await apiClient.get<{ enabled: boolean }>('/ai/info')
|
||||
return Boolean(data?.enabled)
|
||||
} catch {
|
||||
// 404 (AI disabled) или network error → hide button (graceful default).
|
||||
return false
|
||||
}
|
||||
},
|
||||
staleTime: 5 * 60_000,
|
||||
retry: false,
|
||||
})
|
||||
|
||||
export const useAiFeatureAvailable = () => useQuery(aiFeatureAvailableQuery)
|
||||
export const useRecordRaw = (
|
||||
dictionaryName: string,
|
||||
businessKey: string | undefined,
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import axios from 'axios'
|
||||
import { SparkleIcon, XIcon } from '@phosphor-icons/react'
|
||||
import { Alert, Button, TextArea } from '@/ui'
|
||||
import { useAiSuggestField } from '@/api/mutations'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* AI Schema Assist — per-field suggest panel.
|
||||
*
|
||||
* <p>Expanded inline (не modal) — admin вводит описание → preview JSON suggestion
|
||||
* → accept/reject. Accept callback получает {fieldName, schema} как plain JSON
|
||||
* fragment, caller интегрирует в свой schema state.
|
||||
*
|
||||
* <p>Graceful degradation:
|
||||
* <ul>
|
||||
* <li>404 от endpoint (AI disabled на бэке) → caller должен hide button entirely</li>
|
||||
* <li>503 circuit_open → banner «AI временно недоступен», retry в 5 мин</li>
|
||||
* <li>422 bad_output → error message, suggest другой prompt</li>
|
||||
* <li>429 rate_limit → «слишком много запросов, подожди минуту»</li>
|
||||
* </ul>
|
||||
*/
|
||||
type Props = {
|
||||
/** Current schema serialized as JSON object — будет передан как context */
|
||||
existingSchema: Record<string, unknown>
|
||||
/** Called когда юзер accepted suggestion */
|
||||
onAccept: (suggestion: { fieldName: string; schema: Record<string, unknown> }) => void
|
||||
/** Optional close handler — hide panel */
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
export function AiFieldSuggestPanel({ existingSchema, onAccept, onClose }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const [prompt, setPrompt] = useState('')
|
||||
const [suggestion, setSuggestion] = useState<{
|
||||
fieldName: string
|
||||
schema: Record<string, unknown>
|
||||
} | null>(null)
|
||||
const mutation = useAiSuggestField()
|
||||
|
||||
const handleSuggest = () => {
|
||||
if (!prompt.trim()) return
|
||||
setSuggestion(null)
|
||||
mutation.mutate(
|
||||
{ existingSchema, prompt },
|
||||
{
|
||||
onSuccess: (data) => setSuggestion(data),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const handleAccept = () => {
|
||||
if (!suggestion) return
|
||||
onAccept(suggestion)
|
||||
setPrompt('')
|
||||
setSuggestion(null)
|
||||
}
|
||||
|
||||
const handleRetry = () => {
|
||||
setSuggestion(null)
|
||||
handleSuggest()
|
||||
}
|
||||
|
||||
const errorMessage = mutation.error ? formatError(mutation.error, t) : null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-4 rounded-md border border-line bg-surface-2/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<SparkleIcon size={16} weight="fill" className="text-accent shrink-0" />
|
||||
<span className="text-body font-semibold text-ink flex-1">
|
||||
{t('aiSuggest.title', { defaultValue: 'AI: добавить поле' })}
|
||||
</span>
|
||||
{onClose && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t('common.close', { defaultValue: 'Закрыть' })}
|
||||
className="text-mute hover:text-ink p-1 rounded-sm hover:bg-surface"
|
||||
>
|
||||
<XIcon size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<TextArea
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder={t('aiSuggest.placeholder', {
|
||||
defaultValue: 'Опиши поле — например "орбита: апогей, перигей, наклонение"',
|
||||
})}
|
||||
rows={2}
|
||||
disabled={mutation.isPending}
|
||||
maxLength={500}
|
||||
/>
|
||||
<div className="text-cell text-mute">
|
||||
{t('aiSuggest.hint', {
|
||||
defaultValue: 'AI предложит структуру (type/format/x-references). GOST коды и validations добавь сам.',
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!suggestion && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleSuggest}
|
||||
disabled={mutation.isPending || !prompt.trim()}
|
||||
>
|
||||
{mutation.isPending
|
||||
? t('aiSuggest.thinking', { defaultValue: 'Думаю…' })
|
||||
: t('aiSuggest.suggest', { defaultValue: 'Предложить' })}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errorMessage && (
|
||||
<Alert variant="error">{errorMessage}</Alert>
|
||||
)}
|
||||
|
||||
{suggestion && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="text-cap text-mute uppercase tracking-wider">
|
||||
{t('aiSuggest.preview', { defaultValue: 'Предложение' })}
|
||||
</div>
|
||||
<div className="rounded-sm border border-line bg-surface p-3">
|
||||
<div className="text-body font-semibold text-ink mb-1">
|
||||
<span className="text-mono text-accent">{suggestion.fieldName}</span>
|
||||
</div>
|
||||
<pre className={cn(
|
||||
'text-mono text-cell text-ink-2 max-h-64 overflow-auto whitespace-pre-wrap',
|
||||
'bg-surface-2 rounded-sm p-2',
|
||||
)}>
|
||||
{JSON.stringify(suggestion.schema, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleRetry}
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
{t('aiSuggest.retry', { defaultValue: 'Перегенерировать' })}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleAccept}
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
{t('aiSuggest.accept', { defaultValue: 'Добавить в схему' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatError(
|
||||
err: unknown,
|
||||
t: (k: string, opts?: Record<string, unknown>) => string,
|
||||
): string {
|
||||
if (axios.isAxiosError(err)) {
|
||||
const status = err.response?.status
|
||||
if (status === 503) {
|
||||
return t('aiSuggest.error.circuit', {
|
||||
defaultValue: 'AI временно недоступен. Попробуй через несколько минут.',
|
||||
})
|
||||
}
|
||||
if (status === 429) {
|
||||
return t('aiSuggest.error.rate', {
|
||||
defaultValue: 'Слишком много запросов. Подожди минуту.',
|
||||
})
|
||||
}
|
||||
if (status === 422) {
|
||||
return t('aiSuggest.error.bad_output', {
|
||||
defaultValue: 'AI вернул невалидный ответ. Попробуй переформулировать prompt.',
|
||||
})
|
||||
}
|
||||
if (status === 502) {
|
||||
return t('aiSuggest.error.upstream', {
|
||||
defaultValue: 'LLM endpoint недоступен. Жди restore или попробуй позже.',
|
||||
})
|
||||
}
|
||||
if (status === 404) {
|
||||
return t('aiSuggest.error.disabled', {
|
||||
defaultValue: 'AI отключён в этой инсталляции.',
|
||||
})
|
||||
}
|
||||
}
|
||||
return t('aiSuggest.error.generic', { defaultValue: 'Не удалось получить предложение.' })
|
||||
}
|
||||
@@ -16,10 +16,16 @@ import {
|
||||
type TabItem,
|
||||
} from '@/ui'
|
||||
import { useCreateDictionary, useUpdateDictionary } from '@/api/mutations'
|
||||
import { dictionaryDetailQuery, useDictionaries } from '@/api/queries'
|
||||
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 {
|
||||
@@ -127,6 +133,9 @@ export const DictionaryEditorDialog = ({ open, mode, onClose, onSuccess }: Props
|
||||
* 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
|
||||
@@ -438,6 +447,51 @@ export const DictionaryEditorDialog = ({ open, mode, onClose, onSuccess }: Props
|
||||
/>
|
||||
</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>
|
||||
|
||||
|
||||
@@ -370,6 +370,24 @@ i18n
|
||||
'dict.scheduled.cta': 'Перейти к этой дате',
|
||||
'dict.scheduled.row.badge': 'Запланировано',
|
||||
'dict.scheduled.row.tooltip': 'У записи есть запланированная будущая версия',
|
||||
'aiSuggest.openButton': 'AI: добавить поле',
|
||||
'aiSuggest.title': 'AI: добавить поле',
|
||||
'aiSuggest.placeholder': 'Опиши поле — например «орбита: апогей, перигей, наклонение»',
|
||||
'aiSuggest.hint': 'AI предложит структуру (type/format/x-references). GOST коды и validations добавь сам.',
|
||||
'aiSuggest.suggest': 'Предложить',
|
||||
'aiSuggest.thinking': 'Думаю…',
|
||||
'aiSuggest.preview': 'Предложение',
|
||||
'aiSuggest.retry': 'Перегенерировать',
|
||||
'aiSuggest.accept': 'Добавить в схему',
|
||||
'aiSuggest.error.circuit': 'AI временно недоступен. Попробуй через несколько минут.',
|
||||
'aiSuggest.error.rate': 'Слишком много запросов. Подожди минуту.',
|
||||
'aiSuggest.error.bad_output': 'AI вернул невалидный ответ. Попробуй переформулировать.',
|
||||
'aiSuggest.error.upstream': 'LLM endpoint недоступен. Жди restore или попробуй позже.',
|
||||
'aiSuggest.error.disabled': 'AI отключён в этой инсталляции.',
|
||||
'aiSuggest.error.generic': 'Не удалось получить предложение.',
|
||||
'schemaTemplates.label': 'Начать с шаблона',
|
||||
'schemaTemplates.hint': 'Шаблон загрузит начальные поля. Дополни их вручную.',
|
||||
'common.close': 'Закрыть',
|
||||
'dict.col.businessKey': 'Бизнес-ключ',
|
||||
'dict.col.scope': 'Scope',
|
||||
'dict.col.validFrom': 'Действует с',
|
||||
@@ -1189,6 +1207,24 @@ i18n
|
||||
'dict.scheduled.cta': 'Jump to that date',
|
||||
'dict.scheduled.row.badge': 'Scheduled',
|
||||
'dict.scheduled.row.tooltip': 'This record has an upcoming scheduled version',
|
||||
'aiSuggest.openButton': 'AI: add field',
|
||||
'aiSuggest.title': 'AI: add field',
|
||||
'aiSuggest.placeholder': 'Describe the field — e.g. "orbit: apogee, perigee, inclination"',
|
||||
'aiSuggest.hint': 'AI suggests structure (type/format/x-references). Add GOST codes and validations manually.',
|
||||
'aiSuggest.suggest': 'Suggest',
|
||||
'aiSuggest.thinking': 'Thinking…',
|
||||
'aiSuggest.preview': 'Suggestion',
|
||||
'aiSuggest.retry': 'Regenerate',
|
||||
'aiSuggest.accept': 'Add to schema',
|
||||
'aiSuggest.error.circuit': 'AI temporarily unavailable. Try again in a few minutes.',
|
||||
'aiSuggest.error.rate': 'Too many requests. Wait a minute.',
|
||||
'aiSuggest.error.bad_output': 'AI returned invalid response. Try rephrasing.',
|
||||
'aiSuggest.error.upstream': 'LLM endpoint unreachable. Wait or try later.',
|
||||
'aiSuggest.error.disabled': 'AI is disabled in this installation.',
|
||||
'aiSuggest.error.generic': 'Failed to get suggestion.',
|
||||
'schemaTemplates.label': 'Start from template',
|
||||
'schemaTemplates.hint': 'Template loads initial fields. Extend manually.',
|
||||
'common.close': 'Close',
|
||||
'dict.col.businessKey': 'Business key',
|
||||
'dict.col.scope': 'Scope',
|
||||
'dict.col.validFrom': 'Valid from',
|
||||
|
||||
Reference in New Issue
Block a user