feat(wiki): structured no-evidence compile + ops admin endpoints

This commit is contained in:
matevip 2026-04-25 19:02:33 +08:00
parent 5206d65be7
commit 58b49f6e20
4 changed files with 123 additions and 2 deletions

View File

@ -0,0 +1,81 @@
package vip.mate.wiki.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import vip.mate.wiki.job.WikiChunkTokenBackfillJob;
import vip.mate.wiki.service.WikiOverviewService;
import vip.mate.wiki.service.WikiScaffoldService;
import java.util.HashMap;
import java.util.Map;
/**
* RFC-051 follow-up: small set of operator-facing endpoints for things the
* scheduled jobs / event hooks normally handle automatically. Useful when the
* cron hasn't fired yet (fresh upgrade), the auto-rebuild was skipped, or you
* just want to force-refresh during debugging.
*
* <p>All endpoints are idempotent and synchronous.
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/wiki/admin")
@RequiredArgsConstructor
@Tag(name = "Wiki Admin", description = "Operator endpoints for system pages and backfill jobs")
public class WikiAdminController {
private final WikiScaffoldService scaffoldService;
/** Optional so the controller can boot in environments where the rebuilder isn't wired (e.g. minimal tests). */
@Autowired(required = false)
private WikiOverviewService overviewService;
@Autowired(required = false)
private WikiChunkTokenBackfillJob backfillJob;
@Operation(summary = "Ensure overview/log scaffold + rebuild overview stats now",
description = "Idempotent. Use after manual data imports or when stats look stale.")
@PostMapping("/kb/{kbId}/rebuild-overview")
public ResponseEntity<Map<String, Object>> rebuildOverview(@PathVariable Long kbId) {
Map<String, Object> body = new HashMap<>();
scaffoldService.ensureScaffold(kbId);
if (overviewService != null) {
overviewService.rebuild(kbId);
body.put("rebuilt", true);
} else {
body.put("rebuilt", false);
body.put("note", "Overview service not wired; only scaffold ensured");
}
body.put("kbId", kbId);
return ResponseEntity.ok(body);
}
@Operation(summary = "Force-run the token-count backfill batch now",
description = "Picks up to BATCH_SIZE chunks with token_count IS NULL and fills them. "
+ "Returns the pending count after the batch so callers can poll.")
@PostMapping("/backfill-tokens")
public ResponseEntity<Map<String, Object>> backfillTokens() {
Map<String, Object> body = new HashMap<>();
if (backfillJob == null) {
body.put("ok", false);
body.put("note", "Backfill job not wired");
return ResponseEntity.ok(body);
}
long beforePending = backfillJob.pendingCount();
backfillJob.runOnce();
long afterPending = backfillJob.pendingCount();
body.put("ok", true);
body.put("pendingBefore", beforePending);
body.put("pendingAfter", afterPending);
body.put("filledThisBatch", Math.max(0, beforePending - afterPending));
return ResponseEntity.ok(body);
}
}

View File

@ -39,6 +39,16 @@ public class WikiChunkTokenBackfillJob {
private final WikiChunkMapper chunkMapper;
/**
* RFC-051 follow-up: count chunks still missing a token estimate.
* Used by the admin endpoint to decide whether a manual rerun is worthwhile.
*/
public long pendingCount() {
return chunkMapper.selectCount(
new LambdaQueryWrapper<WikiChunkEntity>()
.isNull(WikiChunkEntity::getTokenCount));
}
@Async
@Scheduled(cron = "${mate.wiki.chunk-token-backfill-cron:0 */30 * * * ?}")
public void runOnce() {

View File

@ -58,8 +58,22 @@ public class WikiCompileService {
@Autowired(required = false)
private WikiLogService logService;
/**
* Compile outcome.
* <ul>
* <li>{@code pageId}, {@code slug}, {@code title} non-null page produced.</li>
* <li>{@code evidenceChunkCount == 0} no chunks matched the topic;
* {@code pageId/slug/title} all null. Caller (agent) should fall
* back to {@code wiki_search_pages} or report no source material.</li>
* </ul>
*/
public record CompileResult(Long pageId, String slug, String title, int evidenceChunkCount,
boolean created) {}
boolean created) {
public static CompileResult noEvidence() {
return new CompileResult(null, null, null, 0, false);
}
}
/**
* Compile or update a single page on the topic.
@ -79,7 +93,10 @@ public class WikiCompileService {
// 1. Retrieve evidence chunks via semantic search (hybrid retriever).
List<HybridRetriever.ChunkHit> hits = hybridRetriever.searchChunks(kbId, topic, cap);
if (hits.isEmpty()) {
throw new IllegalStateException("No evidence chunks found for topic: " + topic);
// Structured "nothing matched" result rather than throw lets the
// tool surface respond with a clean message instead of a stack trace.
log.info("[WikiCompile] No evidence chunks for topic='{}' kbId={}", topic, kbId);
return CompileResult.noEvidence();
}
List<Long> evidenceChunkIds = new ArrayList<>(hits.size());

View File

@ -390,8 +390,21 @@ public class WikiTool {
try {
WikiCompileService.CompileResult res = compileService.compilePage(kbId, topic, slug, maxEvidenceChunks);
// RFC-051 follow-up: distinguish "no source material" from a hard error
// so the agent can decide whether to retry, fall back to search, or tell
// the user there's nothing on this topic.
if (res.evidenceChunkCount() == 0) {
return JSONUtil.createObj()
.set("ok", true)
.set("compiled", false)
.set("reason", "no_evidence")
.set("message", "No chunks matched the topic. Try wiki_search_pages, or upload source material first.")
.set("evidenceChunks", 0)
.toString();
}
return JSONUtil.createObj()
.set("ok", true)
.set("compiled", true)
.set("slug", res.slug())
.set("title", res.title())
.set("evidenceChunks", res.evidenceChunkCount())