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
|
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). Подсчёт записей с
|
* Empty-state hint payload (read-api scheduled-summary). Подсчёт записей с
|
||||||
* {@code validFrom > now AND validTo > now} в текущем scope view.
|
* {@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 { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
apiClient,
|
apiClient,
|
||||||
|
type AiFieldSuggestion,
|
||||||
type BulkCloseRequest,
|
type BulkCloseRequest,
|
||||||
type BulkCloseResponse,
|
type BulkCloseResponse,
|
||||||
type CascadeCloseResult,
|
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,
|
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 = (
|
export const useRecordRaw = (
|
||||||
dictionaryName: string,
|
dictionaryName: string,
|
||||||
businessKey: string | undefined,
|
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,
|
type TabItem,
|
||||||
} from '@/ui'
|
} from '@/ui'
|
||||||
import { useCreateDictionary, useUpdateDictionary } from '@/api/mutations'
|
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 type { CreateDictionaryRequest, DataScope, DictionaryDetail } from '@/api/client'
|
||||||
import { SchemaBuilder } from './SchemaBuilder'
|
import { SchemaBuilder } from './SchemaBuilder'
|
||||||
import { TemplatePicker } from './TemplatePicker'
|
import { TemplatePicker } from './TemplatePicker'
|
||||||
|
import { AiFieldSuggestPanel } from './AiFieldSuggestPanel'
|
||||||
|
import { SparkleIcon } from '@phosphor-icons/react'
|
||||||
import { EventsPreviewTab } from './EventsPreviewTab'
|
import { EventsPreviewTab } from './EventsPreviewTab'
|
||||||
import { CreateSchemaDraftModal } from '@/components/workflow/CreateSchemaDraftModal'
|
import { CreateSchemaDraftModal } from '@/components/workflow/CreateSchemaDraftModal'
|
||||||
import {
|
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
|
* into the schema-draft modal with the maker's in-progress schemaJson
|
||||||
* pre-loaded — no retyping. */
|
* pre-loaded — no retyping. */
|
||||||
const [draftHandoffOpen, setDraftHandoffOpen] = useState(false)
|
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,
|
// Template (только в create-mode): admin выбирает существующий dict,
|
||||||
// его schema + locale + scope копируются. Имя остаётся пустым (admin
|
// его schema + locale + scope копируются. Имя остаётся пустым (admin
|
||||||
@@ -438,6 +447,51 @@ export const DictionaryEditorDialog = ({ open, mode, onClose, onSuccess }: Props
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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} />
|
<SchemaBuilder properties={properties} onChange={setProperties} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -370,6 +370,24 @@ i18n
|
|||||||
'dict.scheduled.cta': 'Перейти к этой дате',
|
'dict.scheduled.cta': 'Перейти к этой дате',
|
||||||
'dict.scheduled.row.badge': 'Запланировано',
|
'dict.scheduled.row.badge': 'Запланировано',
|
||||||
'dict.scheduled.row.tooltip': 'У записи есть запланированная будущая версия',
|
'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.businessKey': 'Бизнес-ключ',
|
||||||
'dict.col.scope': 'Scope',
|
'dict.col.scope': 'Scope',
|
||||||
'dict.col.validFrom': 'Действует с',
|
'dict.col.validFrom': 'Действует с',
|
||||||
@@ -1189,6 +1207,24 @@ i18n
|
|||||||
'dict.scheduled.cta': 'Jump to that date',
|
'dict.scheduled.cta': 'Jump to that date',
|
||||||
'dict.scheduled.row.badge': 'Scheduled',
|
'dict.scheduled.row.badge': 'Scheduled',
|
||||||
'dict.scheduled.row.tooltip': 'This record has an upcoming scheduled version',
|
'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.businessKey': 'Business key',
|
||||||
'dict.col.scope': 'Scope',
|
'dict.col.scope': 'Scope',
|
||||||
'dict.col.validFrom': 'Valid from',
|
'dict.col.validFrom': 'Valid from',
|
||||||
|
|||||||
@@ -230,6 +230,18 @@ ordinis:
|
|||||||
require-authentication: ${ORDINIS_AUTH_REQUIRED:false}
|
require-authentication: ${ORDINIS_AUTH_REQUIRED:false}
|
||||||
allow-query-scope: ${ORDINIS_AUTH_ALLOW_QUERY_SCOPE:true}
|
allow-query-scope: ${ORDINIS_AUTH_ALLOW_QUERY_SCOPE:true}
|
||||||
|
|
||||||
|
# AI Schema Assist (Phase 1 step 3-6). Off by default — controllers/services
|
||||||
|
# bean-conditionally registered, frontend hides AI button when 404.
|
||||||
|
# Enable per-env через helm values (см. charts/ordinis/values-staging.yaml).
|
||||||
|
# endpoint: OpenAI-compatible /v1/chat/completions root (без /v1 suffix —
|
||||||
|
# adapter добавит сам). bearer-token optional (Ollama не требует, OpenAI да).
|
||||||
|
ai:
|
||||||
|
enabled: ${ORDINIS_AI_ENABLED:false}
|
||||||
|
endpoint: ${ORDINIS_AI_ENDPOINT:}
|
||||||
|
model: ${ORDINIS_AI_MODEL:qwen2.5:7b}
|
||||||
|
bearer-token: ${ORDINIS_AI_BEARER_TOKEN:}
|
||||||
|
timeout-seconds: ${ORDINIS_AI_TIMEOUT_SECONDS:15}
|
||||||
|
|
||||||
# Keycloak Admin REST integration for UserDisplayService Phase 2 — on-demand
|
# Keycloak Admin REST integration for UserDisplayService Phase 2 — on-demand
|
||||||
# sub→user lookup when JWT capture cache + DB cache both miss. See
|
# sub→user lookup when JWT capture cache + DB cache both miss. See
|
||||||
# KeycloakAdminUserResolver.java + 0023-user-display-cache.xml migration.
|
# KeycloakAdminUserResolver.java + 0023-user-display-cache.xml migration.
|
||||||
|
|||||||
+206
@@ -0,0 +1,206 @@
|
|||||||
|
package cloud.nstart.terravault.ordinis.restapi.service.ai;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI Schema Assist — per-field LLM suggest service (Approach C core).
|
||||||
|
*
|
||||||
|
* <p>Принимает existing JSON Schema (context) + free-form prompt (что добавить)
|
||||||
|
* → возвращает {@code {fieldName, schema}} suggestion. Admin reviews, accepts
|
||||||
|
* или edits через standard draft flow.
|
||||||
|
*
|
||||||
|
* <p>System prompt enforces:
|
||||||
|
* <ul>
|
||||||
|
* <li>Single field output (не whole schema regen)</li>
|
||||||
|
* <li>Use x-localized / x-references / x-unique / format где уместно</li>
|
||||||
|
* <li>НЕ изобретать GOST коды или business validations</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>Circuit breaker (in-memory): 10 fails in 60s → reject все calls в течение
|
||||||
|
* 5 min. Reset на первом успешном после window. Защита от GPU outage.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@ConditionalOnBean(LlmAdapter.class)
|
||||||
|
public class AiSchemaService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(AiSchemaService.class);
|
||||||
|
|
||||||
|
private static final String SYSTEM_PROMPT = """
|
||||||
|
Ты помогаешь админу справочников ДЗЗ строить JSON Schema fields.
|
||||||
|
Получаешь existing schema (текущие поля) + prompt (описание нового поля).
|
||||||
|
Возвращаешь СТРОГО валидный JSON с двумя ключами: fieldName, schema.
|
||||||
|
|
||||||
|
Правила:
|
||||||
|
- fieldName — snake_case, латиница, без префиксов/dots
|
||||||
|
- schema — JSON Schema fragment для ОДНОГО поля (один root type)
|
||||||
|
- x-localized: true для локализованных текстов (multi-language)
|
||||||
|
- x-references: "dict.field" для FK на другой справочник
|
||||||
|
- x-unique: true для бизнес-ключей
|
||||||
|
- format: date / date-time / email / uri где уместно
|
||||||
|
- description: краткий русский текст что означает поле
|
||||||
|
|
||||||
|
НЕ ИЗОБРЕТАЙ:
|
||||||
|
- GOST коды или иные стандартизованные codes
|
||||||
|
- Business validations (только structural type/format/min/max)
|
||||||
|
- Имена существующих справочников (если не уверен в имени — verbal hint в description без x-references)
|
||||||
|
|
||||||
|
Возвращай ТОЛЬКО JSON, без markdown fences, без комментариев, без преамбулы.
|
||||||
|
""";
|
||||||
|
|
||||||
|
private static final String FEW_SHOT_EXAMPLE = """
|
||||||
|
Пример 1.
|
||||||
|
Existing: {"properties":{"code":{"type":"string"},"name":{"type":"object"}}}
|
||||||
|
Prompt: "Орбита — апогей, перигей, наклонение в градусах"
|
||||||
|
Output: {"fieldName":"orbit","schema":{"type":"object","description":"Параметры орбиты","properties":{"apogee_km":{"type":"number","description":"Апогей, км","minimum":0},"perigee_km":{"type":"number","description":"Перигей, км","minimum":0},"inclination_deg":{"type":"number","minimum":0,"maximum":180}}}}
|
||||||
|
|
||||||
|
Пример 2.
|
||||||
|
Existing: {"properties":{"code":{"type":"string"},"name":{"type":"object"}}}
|
||||||
|
Prompt: "Email контакт"
|
||||||
|
Output: {"fieldName":"contact_email","schema":{"type":"string","format":"email","description":"Контактный email"}}
|
||||||
|
|
||||||
|
Пример 3.
|
||||||
|
Existing: {"properties":{"code":{"type":"string"}}}
|
||||||
|
Prompt: "Ссылка на оператора"
|
||||||
|
Output: {"fieldName":"operator_code","schema":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{1,31}$","x-references":"operator.code","description":"FK на operator.code"}}
|
||||||
|
""";
|
||||||
|
|
||||||
|
private final LlmAdapter llm;
|
||||||
|
private final ObjectMapper mapper;
|
||||||
|
|
||||||
|
// Simple in-memory circuit breaker — счётчик fails за rolling 60s window,
|
||||||
|
// если >= 10 → open для 5 min. State per JVM (writer single instance).
|
||||||
|
private final AtomicInteger recentFails = new AtomicInteger(0);
|
||||||
|
private final AtomicLong windowStartedAt = new AtomicLong(System.currentTimeMillis());
|
||||||
|
private final AtomicLong openUntil = new AtomicLong(0);
|
||||||
|
|
||||||
|
private static final long WINDOW_MS = 60_000;
|
||||||
|
private static final int FAIL_THRESHOLD = 10;
|
||||||
|
private static final long OPEN_DURATION_MS = 5 * 60_000;
|
||||||
|
|
||||||
|
public AiSchemaService(LlmAdapter llm, ObjectMapper mapper) {
|
||||||
|
this.llm = llm;
|
||||||
|
this.mapper = mapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suggest single field. Returns parsed {@code {fieldName, schema}} JsonNode.
|
||||||
|
*
|
||||||
|
* @throws AiSchemaException на circuit breaker open, LLM error, или invalid output
|
||||||
|
*/
|
||||||
|
public JsonNode suggestField(JsonNode existingSchema, String prompt) throws AiSchemaException {
|
||||||
|
if (isOpen()) {
|
||||||
|
throw new AiSchemaException("AI временно недоступен (circuit breaker open)", "circuit_open");
|
||||||
|
}
|
||||||
|
if (prompt == null || prompt.isBlank()) {
|
||||||
|
throw new AiSchemaException("prompt пустой", "bad_prompt");
|
||||||
|
}
|
||||||
|
if (prompt.length() > 500) {
|
||||||
|
throw new AiSchemaException("prompt слишком длинный (max 500 chars)", "bad_prompt");
|
||||||
|
}
|
||||||
|
|
||||||
|
String existingJson = existingSchema == null ? "{\"properties\":{}}" : existingSchema.toString();
|
||||||
|
if (existingJson.length() > 4000) {
|
||||||
|
// Truncate context if monstrous — каркасные templates обычно ~500 chars.
|
||||||
|
log.warn("existingSchema truncated from {} to 4000 chars", existingJson.length());
|
||||||
|
existingJson = existingJson.substring(0, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
String userPrompt = FEW_SHOT_EXAMPLE
|
||||||
|
+ "\n\nТеперь твой ход.\nExisting: " + existingJson
|
||||||
|
+ "\nPrompt: \"" + prompt.replace("\"", "\\\"") + "\"\nOutput:";
|
||||||
|
|
||||||
|
String raw;
|
||||||
|
try {
|
||||||
|
raw = llm.chat(SYSTEM_PROMPT, userPrompt);
|
||||||
|
} catch (LlmAdapter.LlmException e) {
|
||||||
|
recordFail();
|
||||||
|
throw new AiSchemaException(e.getMessage(), "llm_error");
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonNode parsed = parseFieldSuggestion(raw);
|
||||||
|
if (parsed == null) {
|
||||||
|
recordFail();
|
||||||
|
throw new AiSchemaException(
|
||||||
|
"LLM вернул невалидный JSON: " + truncate(raw, 200), "bad_output");
|
||||||
|
}
|
||||||
|
if (!parsed.has("fieldName") || !parsed.has("schema")) {
|
||||||
|
recordFail();
|
||||||
|
throw new AiSchemaException(
|
||||||
|
"LLM output missing fieldName/schema keys", "bad_output");
|
||||||
|
}
|
||||||
|
String fieldName = parsed.get("fieldName").asText("");
|
||||||
|
if (!fieldName.matches("^[a-z][a-z0-9_]{0,63}$")) {
|
||||||
|
recordFail();
|
||||||
|
throw new AiSchemaException(
|
||||||
|
"fieldName '" + fieldName + "' не валидный (snake_case, латиница, ≤64)", "bad_field_name");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success — reset breaker counter.
|
||||||
|
recentFails.set(0);
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try parse LLM output as JSON. Robust к markdown fences (```json ... ```)
|
||||||
|
* которые small models иногда добавляют несмотря на system prompt.
|
||||||
|
*/
|
||||||
|
JsonNode parseFieldSuggestion(String raw) {
|
||||||
|
if (raw == null) return null;
|
||||||
|
String cleaned = raw.trim();
|
||||||
|
// Strip markdown code fence
|
||||||
|
if (cleaned.startsWith("```")) {
|
||||||
|
Matcher m = Pattern.compile("```(?:json)?\\s*([\\s\\S]*?)```", Pattern.MULTILINE)
|
||||||
|
.matcher(cleaned);
|
||||||
|
if (m.find()) cleaned = m.group(1).trim();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return mapper.readTree(cleaned);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isOpen() {
|
||||||
|
return openUntil.get() > System.currentTimeMillis();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void recordFail() {
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
if (now - windowStartedAt.get() > WINDOW_MS) {
|
||||||
|
// Reset rolling window
|
||||||
|
windowStartedAt.set(now);
|
||||||
|
recentFails.set(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int fails = recentFails.incrementAndGet();
|
||||||
|
if (fails >= FAIL_THRESHOLD) {
|
||||||
|
openUntil.set(now + OPEN_DURATION_MS);
|
||||||
|
log.warn("AI circuit breaker OPEN after {} fails in {}ms — closed until {}ms",
|
||||||
|
fails, WINDOW_MS, openUntil.get());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String truncate(String s, int max) {
|
||||||
|
return s.length() <= max ? s : s.substring(0, max) + "…";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Domain exception with stable error code для frontend (i18n + error UX). */
|
||||||
|
public static class AiSchemaException extends Exception {
|
||||||
|
private final String code;
|
||||||
|
public AiSchemaException(String message, String code) {
|
||||||
|
super(message);
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
public String getCode() { return code; }
|
||||||
|
}
|
||||||
|
}
|
||||||
+160
@@ -0,0 +1,160 @@
|
|||||||
|
package cloud.nstart.terravault.ordinis.restapi.service.ai;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||||
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI Schema Assist — thin HTTP client для OpenAI-compatible chat completion
|
||||||
|
* endpoints (Ollama via `/v1/chat/completions`, vLLM, OpenAI itself).
|
||||||
|
*
|
||||||
|
* <p>Минимальный subset: model + messages → assistant content. Без streaming,
|
||||||
|
* без tool calls — для AI Schema Assist достаточно single-shot JSON gen.
|
||||||
|
*
|
||||||
|
* <p>Bean создаётся ТОЛЬКО когда {@code ordinis.ai.enabled=true} —
|
||||||
|
* controllers/services которые требуют LlmAdapter должны быть guarded тем же
|
||||||
|
* flag'ом. Это позволяет run prod без AI deps когда GPU не готов / license off.
|
||||||
|
*
|
||||||
|
* <p>Bearer token optional (Ollama works без auth; OpenAI требует).
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@ConditionalOnProperty(name = "ordinis.ai.enabled", havingValue = "true")
|
||||||
|
public class LlmAdapter {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(LlmAdapter.class);
|
||||||
|
|
||||||
|
private final HttpClient http;
|
||||||
|
private final ObjectMapper mapper;
|
||||||
|
private final String endpoint;
|
||||||
|
private final String model;
|
||||||
|
private final String bearerToken;
|
||||||
|
private final Duration timeout;
|
||||||
|
|
||||||
|
public LlmAdapter(
|
||||||
|
ObjectMapper mapper,
|
||||||
|
@Value("${ordinis.ai.endpoint:}") String endpoint,
|
||||||
|
@Value("${ordinis.ai.model:qwen2.5:7b}") String model,
|
||||||
|
@Value("${ordinis.ai.bearer-token:}") String bearerToken,
|
||||||
|
@Value("${ordinis.ai.timeout-seconds:15}") int timeoutSeconds) {
|
||||||
|
this.mapper = mapper;
|
||||||
|
this.endpoint = trimTrailingSlash(endpoint);
|
||||||
|
this.model = model;
|
||||||
|
this.bearerToken = bearerToken;
|
||||||
|
this.timeout = Duration.ofSeconds(timeoutSeconds);
|
||||||
|
this.http = HttpClient.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(5))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String trimTrailingSlash(String s) {
|
||||||
|
if (s == null) return "";
|
||||||
|
return s.endsWith("/") ? s.substring(0, s.length() - 1) : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chat completion с single turn (system + user). Returns assistant message
|
||||||
|
* content (typically JSON-formatted для AI Schema Assist use case).
|
||||||
|
*
|
||||||
|
* @throws LlmException на timeout / network failure / non-2xx response
|
||||||
|
*/
|
||||||
|
public String chat(String systemPrompt, String userPrompt) throws LlmException {
|
||||||
|
if (endpoint == null || endpoint.isBlank()) {
|
||||||
|
throw new LlmException("ordinis.ai.endpoint не настроен");
|
||||||
|
}
|
||||||
|
String url = endpoint + "/v1/chat/completions";
|
||||||
|
|
||||||
|
ObjectNode body = mapper.createObjectNode();
|
||||||
|
body.put("model", model);
|
||||||
|
body.put("stream", false);
|
||||||
|
// Lower temperature для structured output (JSON) — детерминизм важнее
|
||||||
|
// diversity. 0.2 standard для code/schema generation.
|
||||||
|
body.put("temperature", 0.2);
|
||||||
|
ArrayNode messages = body.putArray("messages");
|
||||||
|
messages.addObject().put("role", "system").put("content", systemPrompt);
|
||||||
|
messages.addObject().put("role", "user").put("content", userPrompt);
|
||||||
|
|
||||||
|
String payload;
|
||||||
|
try {
|
||||||
|
payload = mapper.writeValueAsString(body);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new LlmException("serialization failed: " + e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
HttpRequest.Builder reqBuilder = HttpRequest.newBuilder(URI.create(url))
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.timeout(timeout)
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(payload));
|
||||||
|
if (bearerToken != null && !bearerToken.isBlank()) {
|
||||||
|
reqBuilder.header("Authorization", "Bearer " + bearerToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
HttpResponse<String> response;
|
||||||
|
long start = System.currentTimeMillis();
|
||||||
|
try {
|
||||||
|
response = http.send(reqBuilder.build(), HttpResponse.BodyHandlers.ofString());
|
||||||
|
} catch (java.net.http.HttpTimeoutException e) {
|
||||||
|
throw new LlmException("LLM timeout > " + timeout.toSeconds() + "s");
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new LlmException("LLM connection failed: " + e.getClass().getSimpleName() + " — " + e.getMessage());
|
||||||
|
}
|
||||||
|
long durationMs = System.currentTimeMillis() - start;
|
||||||
|
|
||||||
|
int status = response.statusCode();
|
||||||
|
if (status < 200 || status >= 300) {
|
||||||
|
log.warn("LLM HTTP {} {}ms: {}", status, durationMs, truncate(response.body(), 300));
|
||||||
|
throw new LlmException("LLM HTTP " + status);
|
||||||
|
}
|
||||||
|
|
||||||
|
String content;
|
||||||
|
try {
|
||||||
|
JsonNode root = mapper.readTree(response.body());
|
||||||
|
JsonNode choices = root.get("choices");
|
||||||
|
if (choices == null || !choices.isArray() || choices.isEmpty()) {
|
||||||
|
throw new LlmException("LLM response missing 'choices' array");
|
||||||
|
}
|
||||||
|
JsonNode msg = choices.get(0).get("message");
|
||||||
|
if (msg == null || msg.get("content") == null) {
|
||||||
|
throw new LlmException("LLM response missing message.content");
|
||||||
|
}
|
||||||
|
content = msg.get("content").asText();
|
||||||
|
} catch (LlmException le) {
|
||||||
|
throw le;
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new LlmException("LLM response parse failed: " + e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("LLM ok model={} duration_ms={} response_chars={}", model, durationMs, content.length());
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> describeConfig() {
|
||||||
|
return List.of(
|
||||||
|
"endpoint=" + endpoint,
|
||||||
|
"model=" + model,
|
||||||
|
"timeout=" + timeout.toSeconds() + "s",
|
||||||
|
"bearer=" + (bearerToken == null || bearerToken.isBlank() ? "no" : "yes"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String truncate(String s, int max) {
|
||||||
|
if (s == null) return "";
|
||||||
|
return s.length() <= max ? s : s.substring(0, max) + "…[" + s.length() + "]";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Domain exception для всех LLM HTTP / parse failures. Caught controller-side. */
|
||||||
|
public static class LlmException extends Exception {
|
||||||
|
public LlmException(String message) { super(message); }
|
||||||
|
}
|
||||||
|
}
|
||||||
+130
@@ -0,0 +1,130 @@
|
|||||||
|
package cloud.nstart.terravault.ordinis.restapi.web;
|
||||||
|
|
||||||
|
import cloud.nstart.terravault.ordinis.restapi.service.ai.AiSchemaService;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI Schema Assist — per-field suggest endpoint.
|
||||||
|
*
|
||||||
|
* <p>{@code POST /api/v1/ai/suggest-field}
|
||||||
|
* <pre>
|
||||||
|
* Request: { existingSchema: {...JSON Schema}, prompt: "Орбита — апогей..." }
|
||||||
|
* Success: { fieldName: "orbit", schema: {type:"object", properties:{...}} }
|
||||||
|
* Error: 422 {code, message} — bad output / parse failure
|
||||||
|
* 503 {code:"circuit_open"} — breaker active
|
||||||
|
* 429 {code:"rate_limit"} — too many calls
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* <p>Rate limit: 30 calls/minute per IP. Simple in-memory counter — для
|
||||||
|
* single-writer setup достаточно (production usage будет ≤ десятков
|
||||||
|
* запросов в день).
|
||||||
|
*
|
||||||
|
* <p>Endpoint conditionally registered ({@code ConditionalOnBean(AiSchemaService.class)})
|
||||||
|
* — если AI disabled (ordinis.ai.enabled=false) controller не создаётся,
|
||||||
|
* frontend получит 404 и спрячет AI button.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/ai")
|
||||||
|
@ConditionalOnBean(AiSchemaService.class)
|
||||||
|
public class AiSchemaController {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(AiSchemaController.class);
|
||||||
|
|
||||||
|
private static final int RATE_LIMIT_PER_MIN = 30;
|
||||||
|
private static final long WINDOW_MS = 60_000;
|
||||||
|
|
||||||
|
private final AiSchemaService service;
|
||||||
|
private final ConcurrentHashMap<String, RateBucket> buckets = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
public AiSchemaController(AiSchemaService service) {
|
||||||
|
this.service = service;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight probe endpoint — frontend hits this чтобы decide показывать
|
||||||
|
* ли AI buttons. 200 = enabled (controller bean exists), 404 = disabled
|
||||||
|
* (bean missing through @ConditionalOnBean).
|
||||||
|
*/
|
||||||
|
@GetMapping("/info")
|
||||||
|
public AiInfoResponse info() {
|
||||||
|
return new AiInfoResponse(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public record AiInfoResponse(boolean enabled) {}
|
||||||
|
|
||||||
|
@PostMapping("/suggest-field")
|
||||||
|
public JsonNode suggestField(
|
||||||
|
@RequestBody SuggestFieldRequest req,
|
||||||
|
jakarta.servlet.http.HttpServletRequest http) {
|
||||||
|
|
||||||
|
String clientKey = http.getRemoteAddr() == null ? "anonymous" : http.getRemoteAddr();
|
||||||
|
if (!allowRequest(clientKey)) {
|
||||||
|
log.warn("AI rate limit hit для {}", clientKey);
|
||||||
|
throw new ResponseStatusException(
|
||||||
|
HttpStatus.TOO_MANY_REQUESTS,
|
||||||
|
"AI suggest rate limit exceeded (30/min). Try again in a minute.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req == null || req.prompt == null || req.prompt.isBlank()) {
|
||||||
|
throw new ResponseStatusException(
|
||||||
|
HttpStatus.BAD_REQUEST, "prompt is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return service.suggestField(req.existingSchema, req.prompt);
|
||||||
|
} catch (AiSchemaService.AiSchemaException e) {
|
||||||
|
log.warn("AI suggest failed code={} msg={}", e.getCode(), e.getMessage());
|
||||||
|
HttpStatus status = switch (e.getCode()) {
|
||||||
|
case "circuit_open" -> HttpStatus.SERVICE_UNAVAILABLE;
|
||||||
|
case "bad_prompt", "bad_field_name", "bad_output" -> HttpStatus.UNPROCESSABLE_ENTITY;
|
||||||
|
case "llm_error" -> HttpStatus.BAD_GATEWAY;
|
||||||
|
default -> HttpStatus.INTERNAL_SERVER_ERROR;
|
||||||
|
};
|
||||||
|
throw new ResponseStatusException(status, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Simple per-IP rate limit (sliding window counter). */
|
||||||
|
private boolean allowRequest(String key) {
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
RateBucket bucket = buckets.computeIfAbsent(key, k -> new RateBucket(now));
|
||||||
|
synchronized (bucket) {
|
||||||
|
if (now - bucket.windowStartedAt.get() > WINDOW_MS) {
|
||||||
|
bucket.windowStartedAt.set(now);
|
||||||
|
bucket.count.set(1);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return bucket.count.incrementAndGet() <= RATE_LIMIT_PER_MIN;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Body class — Jackson auto-binds. */
|
||||||
|
public static class SuggestFieldRequest {
|
||||||
|
public JsonNode existingSchema;
|
||||||
|
public String prompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class RateBucket {
|
||||||
|
final AtomicLong windowStartedAt;
|
||||||
|
final AtomicInteger count;
|
||||||
|
|
||||||
|
RateBucket(long now) {
|
||||||
|
this.windowStartedAt = new AtomicLong(now);
|
||||||
|
this.count = new AtomicInteger(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user