feat(wiki): optional JSON Schema on json-format transformations

This commit is contained in:
matevip 2026-05-12 14:35:31 +08:00
parent 8dde62e689
commit 026afa2ba5
9 changed files with 141 additions and 7 deletions

View File

@ -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;

View File

@ -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<String> 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) {}

View File

@ -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);

View File

@ -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;

View File

@ -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;

View File

@ -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) =>

View File

@ -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:',

View File

@ -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: '已保存:',

View File

@ -44,6 +44,9 @@
<span v-if="tpl.outputFormat === 'json'" class="flag flag--scope">
{{ t('wiki.transformations.outputFormatJsonBadge') }}
</span>
<span v-if="tpl.outputSchema" class="flag flag--scope">
{{ t('wiki.transformations.outputSchemaBadge') }}
</span>
<span v-if="tpl.modelId" class="flag flag--scope">
{{ modelLabelFor(tpl.modelId) }}
</span>
@ -270,6 +273,17 @@
<span>{{ t('wiki.transformations.outputFormatJson') }}</span>
</label>
</fieldset>
<label v-if="form.outputFormat === 'json'" class="field">
<span class="field-label">{{ t('wiki.transformations.outputSchemaLabel') }}</span>
<textarea
v-model="form.outputSchema"
class="field-textarea"
rows="8"
:placeholder="t('wiki.transformations.outputSchemaPlaceholder')"
></textarea>
<span class="field-hint">{{ t('wiki.transformations.outputSchemaHelp') }}</span>
</label>
</div>
<div class="modal-actions">
@ -337,6 +351,7 @@ interface WikiTransformation {
modelId: number | null
outputTarget: 'none' | 'page' | null
outputFormat: 'markdown' | 'json' | null
outputSchema: string | null
}
interface WikiTransformationRun {
@ -394,6 +409,7 @@ const form = reactive<{
enabled: boolean
outputTarget: 'none' | 'page'
outputFormat: 'markdown' | 'json'
outputSchema: string
modelId: number | null
}>({
name: '',
@ -404,6 +420,7 @@ const form = reactive<{
enabled: true,
outputTarget: 'none',
outputFormat: 'markdown',
outputSchema: '',
modelId: null,
})
@ -498,6 +515,7 @@ function openCreate() {
form.enabled = true
form.outputTarget = 'none'
form.outputFormat = 'markdown'
form.outputSchema = ''
form.modelId = null
editorOpen.value = true
ensureModelsLoaded()
@ -513,6 +531,7 @@ function openEdit(tpl: WikiTransformation) {
form.enabled = tpl.enabled !== false
form.outputTarget = tpl.outputTarget === 'page' ? 'page' : 'none'
form.outputFormat = tpl.outputFormat === 'json' ? 'json' : 'markdown'
form.outputSchema = tpl.outputSchema || ''
form.modelId = tpl.modelId ?? null
editorOpen.value = true
ensureModelsLoaded()
@ -531,6 +550,9 @@ async function onSave() {
}
saving.value = true
try {
// Schema is only persisted when format=json; otherwise we always send
// an empty string so the backend can clear a previously-stored value.
const schemaPayload = form.outputFormat === 'json' ? form.outputSchema.trim() : ''
if (editing.value) {
// Update path: backend treats `-1` as "clear modelId"; null is skipped.
const updateModelId = form.modelId == null ? -1 : form.modelId
@ -542,6 +564,7 @@ async function onSave() {
enabled: form.enabled,
outputTarget: form.outputTarget,
outputFormat: form.outputFormat,
outputSchema: schemaPayload,
modelId: updateModelId,
})
} else {
@ -555,6 +578,7 @@ async function onSave() {
enabled: form.enabled,
outputTarget: form.outputTarget,
outputFormat: form.outputFormat,
outputSchema: schemaPayload || null,
modelId: form.modelId,
})
}