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:
+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