Merge branch 'feat/ai-schema-suggest' into 'main'
feat(ai): Phase 1 step 3-6 — LLM adapter + per-field AI suggest endpoint + UI See merge request 2-6/2-6-4/terravault/ordinis!234
This commit is contained in:
@@ -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<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty-state hint payload (read-api scheduled-summary). Подсчёт записей с
|
||||
* {@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 {
|
||||
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<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,
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 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 = (
|
||||
dictionaryName: string,
|
||||
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,
|
||||
} 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>
|
||||
|
||||
|
||||
@@ -370,6 +370,24 @@ i18n
|
||||
'dict.scheduled.cta': 'Перейти к этой дате',
|
||||
'dict.scheduled.row.badge': 'Запланировано',
|
||||
'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.scope': 'Scope',
|
||||
'dict.col.validFrom': 'Действует с',
|
||||
@@ -1189,6 +1207,24 @@ i18n
|
||||
'dict.scheduled.cta': 'Jump to that date',
|
||||
'dict.scheduled.row.badge': 'Scheduled',
|
||||
'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.scope': 'Scope',
|
||||
'dict.col.validFrom': 'Valid from',
|
||||
|
||||
@@ -230,6 +230,18 @@ ordinis:
|
||||
require-authentication: ${ORDINIS_AUTH_REQUIRED:false}
|
||||
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
|
||||
# sub→user lookup when JWT capture cache + DB cache both miss. See
|
||||
# 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