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
@@ -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: 'Не удалось получить предложение.' })
}