mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 20:08:18 +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;
|
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)
|
@TableField(fill = FieldFill.INSERT)
|
||||||
private LocalDateTime createTime;
|
private LocalDateTime createTime;
|
||||||
|
|
||||||
|
|||||||
@ -212,12 +212,19 @@ public class WikiTransformationExecutor {
|
|||||||
: sourceText;
|
: sourceText;
|
||||||
|
|
||||||
boolean wantJson = "json".equalsIgnoreCase(transformation.getOutputFormat());
|
boolean wantJson = "json".equalsIgnoreCase(transformation.getOutputFormat());
|
||||||
|
String schema = transformation.getOutputSchema();
|
||||||
|
boolean hasSchema = wantJson && schema != null && !schema.isBlank();
|
||||||
|
|
||||||
String systemPrompt = PromptLoader.loadPrompt(
|
String systemPrompt = PromptLoader.loadPrompt(
|
||||||
wantJson ? "wiki/transformation-system-json" : "wiki/transformation-system");
|
wantJson ? "wiki/transformation-system-json" : "wiki/transformation-system");
|
||||||
String instruction = (transformation.getPromptTemplate() == null ? "" : transformation.getPromptTemplate())
|
String instruction = (transformation.getPromptTemplate() == null ? "" : transformation.getPromptTemplate())
|
||||||
.replace("{input_text}", trimmedInput)
|
.replace("{input_text}", trimmedInput)
|
||||||
.replace("{title}", sourceTitle);
|
.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")
|
String userPrompt = PromptLoader.loadPrompt("wiki/transformation-user")
|
||||||
.replace("{instruction}", instruction)
|
.replace("{instruction}", instruction)
|
||||||
.replace("{source_title}", sourceTitle)
|
.replace("{source_title}", sourceTitle)
|
||||||
@ -231,27 +238,72 @@ public class WikiTransformationExecutor {
|
|||||||
accumulateUsage(run, first);
|
accumulateUsage(run, first);
|
||||||
if (wantJson) {
|
if (wantJson) {
|
||||||
String coerced = coerceToJson(first.text());
|
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
|
// Wrap in a fenced block so UI rendering and save-as-page
|
||||||
// keep the existing markdown contract. The raw JSON is the
|
// keep the existing markdown contract. The raw JSON is the
|
||||||
// first thing inside the block, so downstream tools can grep.
|
// first thing inside the block, so downstream tools can grep.
|
||||||
return "```json\n" + coerced + "\n```";
|
return "```json\n" + coerced + "\n```";
|
||||||
}
|
}
|
||||||
// One retry with an explicit nudge.
|
// One retry with an explicit nudge about what failed.
|
||||||
log.info("[WikiTransformation] JSON parse failed for template={}; retrying with stricter reminder",
|
log.info("[WikiTransformation] JSON validation failed for template={} ({}); retrying with stricter reminder",
|
||||||
transformation.getName());
|
transformation.getName(), validationError);
|
||||||
String retryUserPrompt = userPrompt + "\n\n---\n\n上一次回复不是合法 JSON。请只返回一个合法 JSON 文档,前后不要有任何文字或代码块标记。";
|
String reminder = "上一次回复无效:" + validationError + "。请只返回一个合法 JSON 文档,"
|
||||||
|
+ "前后不要有任何文字或代码块标记"
|
||||||
|
+ (hasSchema ? ",并严格匹配上面给出的 JSON Schema。" : "。");
|
||||||
|
String retryUserPrompt = userPrompt + "\n\n---\n\n" + reminder;
|
||||||
CallResult retry = callOnce(chatModel, systemPrompt, retryUserPrompt);
|
CallResult retry = callOnce(chatModel, systemPrompt, retryUserPrompt);
|
||||||
accumulateUsage(run, retry);
|
accumulateUsage(run, retry);
|
||||||
String coercedRetry = coerceToJson(retry.text());
|
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```";
|
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();
|
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). */
|
/** 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) {}
|
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.setModelId(input.getModelId() != null && input.getModelId() < 0 ? null : input.getModelId());
|
||||||
entity.setOutputTarget(normalizeOutputTarget(input.getOutputTarget()));
|
entity.setOutputTarget(normalizeOutputTarget(input.getOutputTarget()));
|
||||||
entity.setOutputFormat(normalizeOutputFormat(input.getOutputFormat()));
|
entity.setOutputFormat(normalizeOutputFormat(input.getOutputFormat()));
|
||||||
|
entity.setOutputSchema(sanitizeOutputSchema(input.getOutputSchema()));
|
||||||
transformationMapper.insert(entity);
|
transformationMapper.insert(entity);
|
||||||
log.info("[WikiTransformation] created id={} name={} kbId={}",
|
log.info("[WikiTransformation] created id={} name={} kbId={}",
|
||||||
entity.getId(), entity.getName(), entity.getKbId());
|
entity.getId(), entity.getName(), entity.getKbId());
|
||||||
@ -139,6 +140,10 @@ public class WikiTransformationService {
|
|||||||
if (patch.getOutputFormat() != null) {
|
if (patch.getOutputFormat() != null) {
|
||||||
entity.setOutputFormat(normalizeOutputFormat(patch.getOutputFormat()));
|
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);
|
transformationMapper.updateById(entity);
|
||||||
return 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
|
@Transactional
|
||||||
public void delete(Long id) {
|
public void delete(Long id) {
|
||||||
transformationMapper.deleteById(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
|
modelId?: number | null
|
||||||
outputTarget?: 'none' | 'page'
|
outputTarget?: 'none' | 'page'
|
||||||
outputFormat?: 'markdown' | 'json'
|
outputFormat?: 'markdown' | 'json'
|
||||||
|
outputSchema?: string | null
|
||||||
}) =>
|
}) =>
|
||||||
http.post('/wiki/transformations', data),
|
http.post('/wiki/transformations', data),
|
||||||
updateTransformation: (id: number, data: {
|
updateTransformation: (id: number, data: {
|
||||||
@ -703,6 +704,7 @@ export const wikiApi = {
|
|||||||
modelId?: number | null
|
modelId?: number | null
|
||||||
outputTarget?: 'none' | 'page'
|
outputTarget?: 'none' | 'page'
|
||||||
outputFormat?: 'markdown' | 'json'
|
outputFormat?: 'markdown' | 'json'
|
||||||
|
outputSchema?: string | null
|
||||||
}) =>
|
}) =>
|
||||||
http.put(`/wiki/transformations/${id}`, data),
|
http.put(`/wiki/transformations/${id}`, data),
|
||||||
deleteTransformation: (id: number) =>
|
deleteTransformation: (id: number) =>
|
||||||
|
|||||||
@ -1822,6 +1822,10 @@ export default {
|
|||||||
outputFormatMarkdown: 'Markdown (default, human-readable)',
|
outputFormatMarkdown: 'Markdown (default, human-readable)',
|
||||||
outputFormatJson: 'JSON (structured for downstream tools; one auto-retry on parse failure)',
|
outputFormatJson: 'JSON (structured for downstream tools; one auto-retry on parse failure)',
|
||||||
outputFormatJsonBadge: 'JSON',
|
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',
|
saveAsPageBtn: 'Save as page',
|
||||||
saving: 'Saving…',
|
saving: 'Saving…',
|
||||||
savedAsPage: 'Saved as:',
|
savedAsPage: 'Saved as:',
|
||||||
|
|||||||
@ -1834,6 +1834,10 @@ export default {
|
|||||||
outputFormatMarkdown: 'Markdown(默认,适合人读)',
|
outputFormatMarkdown: 'Markdown(默认,适合人读)',
|
||||||
outputFormatJson: 'JSON(结构化,便于下游程序消费;失败会自动重试 1 次)',
|
outputFormatJson: 'JSON(结构化,便于下游程序消费;失败会自动重试 1 次)',
|
||||||
outputFormatJsonBadge: 'JSON',
|
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: '保存为页面',
|
saveAsPageBtn: '保存为页面',
|
||||||
saving: '保存中…',
|
saving: '保存中…',
|
||||||
savedAsPage: '已保存:',
|
savedAsPage: '已保存:',
|
||||||
|
|||||||
@ -44,6 +44,9 @@
|
|||||||
<span v-if="tpl.outputFormat === 'json'" class="flag flag--scope">
|
<span v-if="tpl.outputFormat === 'json'" class="flag flag--scope">
|
||||||
{{ t('wiki.transformations.outputFormatJsonBadge') }}
|
{{ t('wiki.transformations.outputFormatJsonBadge') }}
|
||||||
</span>
|
</span>
|
||||||
|
<span v-if="tpl.outputSchema" class="flag flag--scope">
|
||||||
|
{{ t('wiki.transformations.outputSchemaBadge') }}
|
||||||
|
</span>
|
||||||
<span v-if="tpl.modelId" class="flag flag--scope">
|
<span v-if="tpl.modelId" class="flag flag--scope">
|
||||||
{{ modelLabelFor(tpl.modelId) }}
|
{{ modelLabelFor(tpl.modelId) }}
|
||||||
</span>
|
</span>
|
||||||
@ -270,6 +273,17 @@
|
|||||||
<span>{{ t('wiki.transformations.outputFormatJson') }}</span>
|
<span>{{ t('wiki.transformations.outputFormatJson') }}</span>
|
||||||
</label>
|
</label>
|
||||||
</fieldset>
|
</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>
|
||||||
|
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
@ -337,6 +351,7 @@ interface WikiTransformation {
|
|||||||
modelId: number | null
|
modelId: number | null
|
||||||
outputTarget: 'none' | 'page' | null
|
outputTarget: 'none' | 'page' | null
|
||||||
outputFormat: 'markdown' | 'json' | null
|
outputFormat: 'markdown' | 'json' | null
|
||||||
|
outputSchema: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WikiTransformationRun {
|
interface WikiTransformationRun {
|
||||||
@ -394,6 +409,7 @@ const form = reactive<{
|
|||||||
enabled: boolean
|
enabled: boolean
|
||||||
outputTarget: 'none' | 'page'
|
outputTarget: 'none' | 'page'
|
||||||
outputFormat: 'markdown' | 'json'
|
outputFormat: 'markdown' | 'json'
|
||||||
|
outputSchema: string
|
||||||
modelId: number | null
|
modelId: number | null
|
||||||
}>({
|
}>({
|
||||||
name: '',
|
name: '',
|
||||||
@ -404,6 +420,7 @@ const form = reactive<{
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
outputTarget: 'none',
|
outputTarget: 'none',
|
||||||
outputFormat: 'markdown',
|
outputFormat: 'markdown',
|
||||||
|
outputSchema: '',
|
||||||
modelId: null,
|
modelId: null,
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -498,6 +515,7 @@ function openCreate() {
|
|||||||
form.enabled = true
|
form.enabled = true
|
||||||
form.outputTarget = 'none'
|
form.outputTarget = 'none'
|
||||||
form.outputFormat = 'markdown'
|
form.outputFormat = 'markdown'
|
||||||
|
form.outputSchema = ''
|
||||||
form.modelId = null
|
form.modelId = null
|
||||||
editorOpen.value = true
|
editorOpen.value = true
|
||||||
ensureModelsLoaded()
|
ensureModelsLoaded()
|
||||||
@ -513,6 +531,7 @@ function openEdit(tpl: WikiTransformation) {
|
|||||||
form.enabled = tpl.enabled !== false
|
form.enabled = tpl.enabled !== false
|
||||||
form.outputTarget = tpl.outputTarget === 'page' ? 'page' : 'none'
|
form.outputTarget = tpl.outputTarget === 'page' ? 'page' : 'none'
|
||||||
form.outputFormat = tpl.outputFormat === 'json' ? 'json' : 'markdown'
|
form.outputFormat = tpl.outputFormat === 'json' ? 'json' : 'markdown'
|
||||||
|
form.outputSchema = tpl.outputSchema || ''
|
||||||
form.modelId = tpl.modelId ?? null
|
form.modelId = tpl.modelId ?? null
|
||||||
editorOpen.value = true
|
editorOpen.value = true
|
||||||
ensureModelsLoaded()
|
ensureModelsLoaded()
|
||||||
@ -531,6 +550,9 @@ async function onSave() {
|
|||||||
}
|
}
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
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) {
|
if (editing.value) {
|
||||||
// Update path: backend treats `-1` as "clear modelId"; null is skipped.
|
// Update path: backend treats `-1` as "clear modelId"; null is skipped.
|
||||||
const updateModelId = form.modelId == null ? -1 : form.modelId
|
const updateModelId = form.modelId == null ? -1 : form.modelId
|
||||||
@ -542,6 +564,7 @@ async function onSave() {
|
|||||||
enabled: form.enabled,
|
enabled: form.enabled,
|
||||||
outputTarget: form.outputTarget,
|
outputTarget: form.outputTarget,
|
||||||
outputFormat: form.outputFormat,
|
outputFormat: form.outputFormat,
|
||||||
|
outputSchema: schemaPayload,
|
||||||
modelId: updateModelId,
|
modelId: updateModelId,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
@ -555,6 +578,7 @@ async function onSave() {
|
|||||||
enabled: form.enabled,
|
enabled: form.enabled,
|
||||||
outputTarget: form.outputTarget,
|
outputTarget: form.outputTarget,
|
||||||
outputFormat: form.outputFormat,
|
outputFormat: form.outputFormat,
|
||||||
|
outputSchema: schemaPayload || null,
|
||||||
modelId: form.modelId,
|
modelId: form.modelId,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user