feat(wiki): PR-6b structured-output route phase (opt-in)

This commit is contained in:
matevip 2026-04-25 19:02:32 +08:00
parent c8af19cae2
commit 850d59c04f
2 changed files with 76 additions and 20 deletions

View File

@ -164,4 +164,18 @@ public class WikiProperties {
* Default 15000 covers most documents while staying well within model limits. * Default 15000 covers most documents while staying well within model limits.
*/ */
private int documentAnalysisSampleChars = 15000; private int documentAnalysisSampleChars = 15000;
/**
* RFC-051 PR-6b: route-phase output binding. When {@code true}, the route
* LLM call uses a Spring AI {@code BeanOutputConverter<RouteResult>}
* the format hint is injected into the user prompt and the response is
* parsed strictly into the DTO. Failures fall back to the legacy lenient
* JSON parser so a flaky model never blocks ingest.
* <p>
* Default {@code false} keeps existing behavior on first upgrade. Flip on
* once you've validated the route prompt against the models you actually
* run (DashScope / OpenAI / Anthropic / DeepSeek tend to be fine; Ollama
* and weaker models may need the fallback).
*/
private boolean useStructuredRoute = false;
} }

View File

@ -657,19 +657,23 @@ public class WikiProcessingService {
.replace("{existing_pages}", freshIndex) .replace("{existing_pages}", freshIndex)
.replace("{raw_title}", rawTitle) .replace("{raw_title}", rawTitle)
.replace("{raw_content}", textContent); .replace("{raw_content}", textContent);
// RFC-051 PR-6b: optionally inject Spring AI's structured-output hint so the
// LLM produces strict RouteResult JSON. Default off; flip via mate.wiki.use-structured-route.
org.springframework.ai.converter.BeanOutputConverter<vip.mate.wiki.dto.RouteResult> routeConverter =
properties.isUseStructuredRoute()
? new org.springframework.ai.converter.BeanOutputConverter<>(vip.mate.wiki.dto.RouteResult.class)
: null;
if (routeConverter != null) {
routeUser = routeUser + "\n\n" + routeConverter.getFormat();
}
Prompt routePrompt = new Prompt(List.of( Prompt routePrompt = new Prompt(List.of(
new SystemMessage(routeSystem), new SystemMessage(routeSystem),
new UserMessage(routeUser) new UserMessage(routeUser)
)); ));
String routeResponse = callLlmWithResilientRetry(routePrompt, "route chunk of raw=" + rawId, String routeResponse = callLlmWithResilientRetry(routePrompt, "route chunk of raw=" + rawId,
kbId, vip.mate.wiki.job.WikiJobStep.ROUTE); kbId, vip.mate.wiki.job.WikiJobStep.ROUTE);
JsonNode routeJson = parseJsonResponse(routeResponse);
if (routeJson == null) {
log.warn("[Wiki] Route phase: failed to parse JSON for kbId={}, rawId={}, responseLen={}, first200={}",
kbId, rawId, routeResponse != null ? routeResponse.length() : 0,
routeResponse != null ? routeResponse.substring(0, Math.min(200, routeResponse.length())) : "null");
return 0;
}
// RFC-012 follow-up #3phase B 现在并行执行计数必须是 atomic // RFC-012 follow-up #3phase B 现在并行执行计数必须是 atomic
AtomicInteger created = new AtomicInteger(0); AtomicInteger created = new AtomicInteger(0);
@ -677,21 +681,59 @@ public class WikiProcessingService {
// 收集 route 输出 metadata content // 收集 route 输出 metadata content
List<JsonNode> createMetas = new ArrayList<>(); List<JsonNode> createMetas = new ArrayList<>();
JsonNode createNode = routeJson.path("create"); List<String> updateSlugs = new ArrayList<>();
if (createNode.isArray()) {
for (JsonNode metaNode : createNode) { boolean structuredOk = false;
String slug = metaNode.path("slug").asText(""); if (routeConverter != null) {
String title = metaNode.path("title").asText(""); try {
if (slug.isBlank() || title.isBlank()) continue; vip.mate.wiki.dto.RouteResult bound = routeConverter.convert(routeResponse);
createMetas.add(metaNode); if (bound != null) {
for (vip.mate.wiki.dto.RoutedPageMeta meta : bound.create()) {
if (meta == null || meta.slug() == null || meta.slug().isBlank()
|| meta.title() == null || meta.title().isBlank()) continue;
com.fasterxml.jackson.databind.node.ObjectNode node = objectMapper.createObjectNode();
node.put("slug", meta.slug());
node.put("title", meta.title());
if (meta.summary() != null) node.put("summary", meta.summary());
if (meta.purposeHint() != null) node.put("purposeHint", meta.purposeHint());
createMetas.add(node);
}
for (String slug : bound.update()) {
if (slug != null && !slug.isBlank()) updateSlugs.add(slug);
}
structuredOk = true;
log.debug("[Wiki] Route phase: structured parse ok kbId={} rawId={} create={} update={}",
kbId, rawId, createMetas.size(), updateSlugs.size());
}
} catch (Exception e) {
log.warn("[Wiki] Route phase: structured parse failed for rawId={}, falling back to lenient JSON: {}",
rawId, e.getMessage());
} }
} }
List<String> updateSlugs = new ArrayList<>();
JsonNode updateNode = routeJson.path("update"); if (!structuredOk) {
if (updateNode.isArray()) { JsonNode routeJson = parseJsonResponse(routeResponse);
for (JsonNode slugNode : updateNode) { if (routeJson == null) {
String slug = slugNode.asText(""); log.warn("[Wiki] Route phase: failed to parse JSON for kbId={}, rawId={}, responseLen={}, first200={}",
if (!slug.isBlank()) updateSlugs.add(slug); kbId, rawId, routeResponse != null ? routeResponse.length() : 0,
routeResponse != null ? routeResponse.substring(0, Math.min(200, routeResponse.length())) : "null");
return 0;
}
JsonNode createNode = routeJson.path("create");
if (createNode.isArray()) {
for (JsonNode metaNode : createNode) {
String slug = metaNode.path("slug").asText("");
String title = metaNode.path("title").asText("");
if (slug.isBlank() || title.isBlank()) continue;
createMetas.add(metaNode);
}
}
JsonNode updateNode = routeJson.path("update");
if (updateNode.isArray()) {
for (JsonNode slugNode : updateNode) {
String slug = slugNode.asText("");
if (!slug.isBlank()) updateSlugs.add(slug);
}
} }
} }
int totalPlanned = createMetas.size() + updateSlugs.size(); int totalPlanned = createMetas.size() + updateSlugs.size();