From e4cd7b9709da01c55cfba7f82107eec55f346b1d Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Sat, 16 May 2026 13:55:16 +0300 Subject: [PATCH] =?UTF-8?q?feat(ai):=20Phase=201=20step=203-6=20=E2=80=94?= =?UTF-8?q?=20LLM=20adapter=20+=20per-field=20AI=20suggest=20endpoint=20+?= =?UTF-8?q?=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ordinis-admin-ui/src/api/client.ts | 9 + ordinis-admin-ui/src/api/mutations.ts | 26 +++ ordinis-admin-ui/src/api/queries.ts | 28 +++ .../components/schema/AiFieldSuggestPanel.tsx | 199 +++++++++++++++++ .../schema/DictionaryEditorDialog.tsx | 56 ++++- ordinis-admin-ui/src/i18n.ts | 36 +++ .../src/main/resources/application.yml | 12 + .../restapi/service/ai/AiSchemaService.java | 206 ++++++++++++++++++ .../restapi/service/ai/LlmAdapter.java | 160 ++++++++++++++ .../restapi/web/AiSchemaController.java | 130 +++++++++++ 10 files changed, 861 insertions(+), 1 deletion(-) create mode 100644 ordinis-admin-ui/src/components/schema/AiFieldSuggestPanel.tsx create mode 100644 ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/ai/AiSchemaService.java create mode 100644 ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/ai/LlmAdapter.java create mode 100644 ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/AiSchemaController.java diff --git a/ordinis-admin-ui/src/api/client.ts b/ordinis-admin-ui/src/api/client.ts index 2d2a55f..299eed0 100644 --- a/ordinis-admin-ui/src/api/client.ts +++ b/ordinis-admin-ui/src/api/client.ts @@ -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 +} + /** * Empty-state hint payload (read-api scheduled-summary). Подсчёт записей с * {@code validFrom > now AND validTo > now} в текущем scope view. diff --git a/ordinis-admin-ui/src/api/mutations.ts b/ordinis-admin-ui/src/api/mutations.ts index d81d37e..111eb09 100644 --- a/ordinis-admin-ui/src/api/mutations.ts +++ b/ordinis-admin-ui/src/api/mutations.ts @@ -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 => { + const { data } = await apiClient.post( + '/ai/suggest-field', + req, + { timeout: 30_000 }, // override default 10s — LLM может быть slow + ) + return data + }, + }) +} diff --git a/ordinis-admin-ui/src/api/queries.ts b/ordinis-admin-ui/src/api/queries.ts index 3c25b48..938a25b 100644 --- a/ordinis-admin-ui/src/api/queries.ts +++ b/ordinis-admin-ui/src/api/queries.ts @@ -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. + * + *

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 => { + 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, diff --git a/ordinis-admin-ui/src/components/schema/AiFieldSuggestPanel.tsx b/ordinis-admin-ui/src/components/schema/AiFieldSuggestPanel.tsx new file mode 100644 index 0000000..02ba594 --- /dev/null +++ b/ordinis-admin-ui/src/components/schema/AiFieldSuggestPanel.tsx @@ -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. + * + *

Expanded inline (не modal) — admin вводит описание → preview JSON suggestion + * → accept/reject. Accept callback получает {fieldName, schema} как plain JSON + * fragment, caller интегрирует в свой schema state. + * + *

Graceful degradation: + *

    + *
  • 404 от endpoint (AI disabled на бэке) → caller должен hide button entirely
  • + *
  • 503 circuit_open → banner «AI временно недоступен», retry в 5 мин
  • + *
  • 422 bad_output → error message, suggest другой prompt
  • + *
  • 429 rate_limit → «слишком много запросов, подожди минуту»
  • + *
+ */ +type Props = { + /** Current schema serialized as JSON object — будет передан как context */ + existingSchema: Record + /** Called когда юзер accepted suggestion */ + onAccept: (suggestion: { fieldName: string; schema: Record }) => 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 + } | 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 ( +
+
+ + + {t('aiSuggest.title', { defaultValue: 'AI: добавить поле' })} + + {onClose && ( + + )} +
+ +
+