From 026afa2ba531e9f522da1a867952ed15244c6d24 Mon Sep 17 00:00:00 2001 From: matevip Date: Tue, 12 May 2026 14:35:31 +0800 Subject: [PATCH] feat(wiki): optional JSON Schema on json-format transformations --- .../wiki/model/WikiTransformationEntity.java | 8 +++ .../service/WikiTransformationExecutor.java | 66 +++++++++++++++++-- .../service/WikiTransformationService.java | 26 ++++++++ ...111__wiki_transformation_output_schema.sql | 7 ++ ...111__wiki_transformation_output_schema.sql | 7 ++ mateclaw-ui/src/api/index.ts | 2 + mateclaw-ui/src/i18n/locales/en-US.ts | 4 ++ mateclaw-ui/src/i18n/locales/zh-CN.ts | 4 ++ .../Wiki/components/TransformationsPanel.vue | 24 +++++++ 9 files changed, 141 insertions(+), 7 deletions(-) create mode 100644 mateclaw-server/src/main/resources/db/migration/h2/V111__wiki_transformation_output_schema.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/mysql/V111__wiki_transformation_output_schema.sql diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiTransformationEntity.java b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiTransformationEntity.java index 7a734ba5..7acc5ec5 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiTransformationEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiTransformationEntity.java @@ -76,6 +76,14 @@ public class WikiTransformationEntity { */ private String outputFormat; + /** + * Optional JSON Schema text describing the expected shape when + * {@code outputFormat == 'json'}. Injected into the prompt verbatim + * so the LLM has explicit field expectations; the executor also runs + * a lightweight required-fields check after parsing. + */ + private String outputSchema; + @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiTransformationExecutor.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiTransformationExecutor.java index 444b2a2a..c6961dd2 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiTransformationExecutor.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiTransformationExecutor.java @@ -212,12 +212,19 @@ public class WikiTransformationExecutor { : sourceText; boolean wantJson = "json".equalsIgnoreCase(transformation.getOutputFormat()); + String schema = transformation.getOutputSchema(); + boolean hasSchema = wantJson && schema != null && !schema.isBlank(); String systemPrompt = PromptLoader.loadPrompt( wantJson ? "wiki/transformation-system-json" : "wiki/transformation-system"); String instruction = (transformation.getPromptTemplate() == null ? "" : transformation.getPromptTemplate()) .replace("{input_text}", trimmedInput) .replace("{title}", sourceTitle); + if (hasSchema) { + instruction = instruction + + "\n\n---\n\n输出必须严格符合下面这个 JSON Schema:\n```json\n" + + schema + "\n```"; + } String userPrompt = PromptLoader.loadPrompt("wiki/transformation-user") .replace("{instruction}", instruction) .replace("{source_title}", sourceTitle) @@ -231,27 +238,72 @@ public class WikiTransformationExecutor { accumulateUsage(run, first); if (wantJson) { String coerced = coerceToJson(first.text()); - if (coerced != null) { + String validationError = coerced != null ? validateAgainstSchema(coerced, schema) : "not valid JSON"; + if (coerced != null && validationError == null) { // Wrap in a fenced block so UI rendering and save-as-page // keep the existing markdown contract. The raw JSON is the // first thing inside the block, so downstream tools can grep. return "```json\n" + coerced + "\n```"; } - // One retry with an explicit nudge. - log.info("[WikiTransformation] JSON parse failed for template={}; retrying with stricter reminder", - transformation.getName()); - String retryUserPrompt = userPrompt + "\n\n---\n\n上一次回复不是合法 JSON。请只返回一个合法 JSON 文档,前后不要有任何文字或代码块标记。"; + // One retry with an explicit nudge about what failed. + log.info("[WikiTransformation] JSON validation failed for template={} ({}); retrying with stricter reminder", + transformation.getName(), validationError); + String reminder = "上一次回复无效:" + validationError + "。请只返回一个合法 JSON 文档," + + "前后不要有任何文字或代码块标记" + + (hasSchema ? ",并严格匹配上面给出的 JSON Schema。" : "。"); + String retryUserPrompt = userPrompt + "\n\n---\n\n" + reminder; CallResult retry = callOnce(chatModel, systemPrompt, retryUserPrompt); accumulateUsage(run, retry); String coercedRetry = coerceToJson(retry.text()); - if (coercedRetry != null) { + String retryError = coercedRetry != null ? validateAgainstSchema(coercedRetry, schema) : "not valid JSON"; + if (coercedRetry != null && retryError == null) { return "```json\n" + coercedRetry + "\n```"; } - throw new IllegalStateException("LLM output is not valid JSON after one retry"); + throw new IllegalStateException("LLM output failed JSON validation after one retry: " + retryError); } return first.text(); } + /** + * Lightweight JSON Schema check — verifies the parsed value is the + * declared top-level type and contains every entry in the + * {@code required} array. Deep validation (per-field types, enums, + * patterns) is out of scope; the prompt-time schema injection does + * most of the work and this check just guards the obvious failures. + * + * @return {@code null} when valid, otherwise a short failure description + */ + private static String validateAgainstSchema(String jsonText, String schemaText) { + if (schemaText == null || schemaText.isBlank()) return null; + try { + com.fasterxml.jackson.databind.JsonNode value = JSON_MAPPER.readTree(jsonText); + com.fasterxml.jackson.databind.JsonNode schema = JSON_MAPPER.readTree(schemaText); + + String type = schema.path("type").asText(""); + if ("object".equals(type) && !value.isObject()) { + return "expected object at top level, got " + value.getNodeType().name().toLowerCase(); + } + if ("array".equals(type) && !value.isArray()) { + return "expected array at top level, got " + value.getNodeType().name().toLowerCase(); + } + + com.fasterxml.jackson.databind.JsonNode required = schema.get("required"); + if (required != null && required.isArray() && value.isObject()) { + List missing = new java.util.ArrayList<>(); + for (com.fasterxml.jackson.databind.JsonNode req : required) { + String field = req.asText(); + if (!field.isBlank() && !value.has(field)) missing.add(field); + } + if (!missing.isEmpty()) { + return "missing required field(s): " + String.join(", ", missing); + } + } + return null; + } catch (Exception e) { + return "schema check error: " + e.getMessage(); + } + } + /** Tuple returned from a single LLM call: cleaned text + usage (null when provider didn't surface usage). */ private record CallResult(String text, Long inputTokens, Long outputTokens, Long totalTokens) {} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiTransformationService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiTransformationService.java index a131697a..90ed1606 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiTransformationService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiTransformationService.java @@ -111,6 +111,7 @@ public class WikiTransformationService { entity.setModelId(input.getModelId() != null && input.getModelId() < 0 ? null : input.getModelId()); entity.setOutputTarget(normalizeOutputTarget(input.getOutputTarget())); entity.setOutputFormat(normalizeOutputFormat(input.getOutputFormat())); + entity.setOutputSchema(sanitizeOutputSchema(input.getOutputSchema())); transformationMapper.insert(entity); log.info("[WikiTransformation] created id={} name={} kbId={}", entity.getId(), entity.getName(), entity.getKbId()); @@ -139,6 +140,10 @@ public class WikiTransformationService { if (patch.getOutputFormat() != null) { entity.setOutputFormat(normalizeOutputFormat(patch.getOutputFormat())); } + if (patch.getOutputSchema() != null) { + // Empty string clears the schema; non-blank gets stored after a parse check. + entity.setOutputSchema(sanitizeOutputSchema(patch.getOutputSchema())); + } transformationMapper.updateById(entity); return entity; } @@ -163,6 +168,27 @@ public class WikiTransformationService { }; } + /** + * Sanitises the user-supplied JSON Schema text. Empty / blank values + * clear the column. Non-parseable values are rejected at the API + * boundary so the executor doesn't have to defend against garbage + * stored on the template. + */ + private static final com.fasterxml.jackson.databind.ObjectMapper SCHEMA_MAPPER = + new com.fasterxml.jackson.databind.ObjectMapper(); + + private static String sanitizeOutputSchema(String raw) { + if (raw == null) return null; + String trimmed = raw.trim(); + if (trimmed.isEmpty()) return null; + try { + SCHEMA_MAPPER.readTree(trimmed); + } catch (Exception e) { + throw new IllegalArgumentException("output_schema is not valid JSON: " + e.getMessage()); + } + return trimmed; + } + @Transactional public void delete(Long id) { transformationMapper.deleteById(id); diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V111__wiki_transformation_output_schema.sql b/mateclaw-server/src/main/resources/db/migration/h2/V111__wiki_transformation_output_schema.sql new file mode 100644 index 00000000..cd9cce1d --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V111__wiki_transformation_output_schema.sql @@ -0,0 +1,7 @@ +-- Optional JSON Schema describing the shape the LLM should produce when +-- output_format='json'. The executor injects the schema into the prompt +-- so the model has explicit field/type expectations, and validates the +-- parsed JSON against a lightweight required-fields check after parsing. +-- Stored as TEXT — the schema can be arbitrary JSON Schema text. + +ALTER TABLE mate_wiki_transformation ADD COLUMN IF NOT EXISTS output_schema CLOB DEFAULT NULL; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V111__wiki_transformation_output_schema.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V111__wiki_transformation_output_schema.sql new file mode 100644 index 00000000..2bec544c --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V111__wiki_transformation_output_schema.sql @@ -0,0 +1,7 @@ +-- Optional JSON Schema column. See h2 sibling for the prose explanation. + +SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_wiki_transformation' + AND COLUMN_NAME = 'output_schema'); +SET @s := IF(@c = 0, 'ALTER TABLE mate_wiki_transformation ADD COLUMN output_schema MEDIUMTEXT DEFAULT NULL', 'SELECT 1'); +PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index 68e97bff..5f708ccf 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -692,6 +692,7 @@ export const wikiApi = { modelId?: number | null outputTarget?: 'none' | 'page' outputFormat?: 'markdown' | 'json' + outputSchema?: string | null }) => http.post('/wiki/transformations', data), updateTransformation: (id: number, data: { @@ -703,6 +704,7 @@ export const wikiApi = { modelId?: number | null outputTarget?: 'none' | 'page' outputFormat?: 'markdown' | 'json' + outputSchema?: string | null }) => http.put(`/wiki/transformations/${id}`, data), deleteTransformation: (id: number) => diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index c05a8545..a556685a 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -1822,6 +1822,10 @@ export default { outputFormatMarkdown: 'Markdown (default, human-readable)', outputFormatJson: 'JSON (structured for downstream tools; one auto-retry on parse failure)', outputFormatJsonBadge: 'JSON', + outputSchemaLabel: 'JSON Schema (optional)', + outputSchemaHelp: 'Schema text is injected into the prompt and checked for required fields after parsing. Leave blank to skip validation.', + outputSchemaPlaceholder: '{\n "type": "object",\n "required": ["title", "items"],\n "properties": {\n "title": { "type": "string" },\n "items": { "type": "array" }\n }\n}', + outputSchemaBadge: 'Schema', saveAsPageBtn: 'Save as page', saving: 'Saving…', savedAsPage: 'Saved as:', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index ac60df27..db577580 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -1834,6 +1834,10 @@ export default { outputFormatMarkdown: 'Markdown(默认,适合人读)', outputFormatJson: 'JSON(结构化,便于下游程序消费;失败会自动重试 1 次)', outputFormatJsonBadge: 'JSON', + outputSchemaLabel: 'JSON Schema(可选)', + outputSchemaHelp: '写在这里的 JSON Schema 会注入到 prompt,并在解析后做必填字段校验。留空表示不校验。', + outputSchemaPlaceholder: '{\n "type": "object",\n "required": ["title", "items"],\n "properties": {\n "title": { "type": "string" },\n "items": { "type": "array" }\n }\n}', + outputSchemaBadge: 'Schema', saveAsPageBtn: '保存为页面', saving: '保存中…', savedAsPage: '已保存:', diff --git a/mateclaw-ui/src/views/Wiki/components/TransformationsPanel.vue b/mateclaw-ui/src/views/Wiki/components/TransformationsPanel.vue index 62b125eb..57516125 100644 --- a/mateclaw-ui/src/views/Wiki/components/TransformationsPanel.vue +++ b/mateclaw-ui/src/views/Wiki/components/TransformationsPanel.vue @@ -44,6 +44,9 @@ {{ t('wiki.transformations.outputFormatJsonBadge') }} + + {{ t('wiki.transformations.outputSchemaBadge') }} + {{ modelLabelFor(tpl.modelId) }} @@ -270,6 +273,17 @@ {{ t('wiki.transformations.outputFormatJson') }} + +