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:
Zimin A.N.
2026-05-16 13:55:16 +03:00
parent db5d5c19e2
commit e4cd7b9709
10 changed files with 861 additions and 1 deletions
@@ -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>