mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(wiki): optional JSON Schema on json-format transformations
This commit is contained in:
parent
8dde62e689
commit
026afa2ba5
@ -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;
|
||||
|
||||
|
||||
@ -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) {}
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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) =>
|
||||
|
||||
@ -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:',
|
||||
|
||||
@ -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: '已保存:',
|
||||
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user