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:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user