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.
*/
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("{raw_title}", rawTitle)
.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(
new SystemMessage(routeSystem),
new UserMessage(routeUser)
));
String routeResponse = callLlmWithResilientRetry(routePrompt, "route chunk of raw=" + rawId,
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
AtomicInteger created = new AtomicInteger(0);
@ -677,21 +681,59 @@ public class WikiProcessingService {
// 收集 route 输出 metadata content
List<JsonNode> createMetas = new ArrayList<>();
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);
List<String> updateSlugs = new ArrayList<>();
boolean structuredOk = false;
if (routeConverter != null) {
try {
vip.mate.wiki.dto.RouteResult bound = routeConverter.convert(routeResponse);
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 (updateNode.isArray()) {
for (JsonNode slugNode : updateNode) {
String slug = slugNode.asText("");
if (!slug.isBlank()) updateSlugs.add(slug);
if (!structuredOk) {
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;
}
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();