feat(wiki): PR-4 on-demand compile + multi-page read tools

This commit is contained in:
matevip 2026-04-25 09:56:19 +08:00
parent e4818a8ee2
commit b41496ed46
3 changed files with 306 additions and 0 deletions

View File

@ -62,6 +62,36 @@ public class WikiCitationService {
log.debug("[WikiCitation] Built citations for pageId={}, kbId={}", pageId, kbId);
}
/**
* RFC-051 PR-4: rebuild citations from a specific list of evidence chunks
* rather than the union of all chunks for the page's source raws. Used by
* {@code WikiCompileService} so on-demand pages cite only the chunks the
* compile prompt actually saw keeping relation/citation signals clean.
* <p>
* Falls back to the raw-level rebuild if the evidence list is null/empty,
* so callers don't have to special-case "no evidence found".
*/
public void buildCitations(Long pageId, Long kbId, List<Long> evidenceChunkIds) {
if (evidenceChunkIds == null || evidenceChunkIds.isEmpty()) {
buildCitations(pageId, kbId);
return;
}
WikiPageEntity page = pageMapper.selectById(pageId);
if (page == null) return;
citationMapper.softDeleteByPageId(pageId);
for (Long chunkId : evidenceChunkIds) {
WikiPageCitationEntity citation = new WikiPageCitationEntity();
citation.setPageId(pageId);
citation.setChunkId(chunkId);
citation.setConfidence(BigDecimal.ONE);
citation.setCreatedBy("compile");
citationMapper.insert(citation);
}
log.info("[WikiCitation] Built {} evidence citations for pageId={}, kbId={}",
evidenceChunkIds.size(), pageId, kbId);
}
private List<Long> parseRawIds(String json) {
if (json == null || json.isBlank()) return List.of();
try {

View File

@ -0,0 +1,188 @@
package vip.mate.wiki.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import vip.mate.wiki.job.WikiJobStep;
import vip.mate.wiki.job.WikiModelRoutingService;
import vip.mate.wiki.model.WikiChunkEntity;
import vip.mate.wiki.model.WikiPageEntity;
import vip.mate.wiki.repository.WikiChunkMapper;
import java.util.ArrayList;
import java.util.List;
/**
* RFC-051 PR-4: on-demand page compilation.
* <p>
* Given a topic (or an explicit {@code slug}), search for the most relevant
* chunks via {@link HybridRetriever}, build a short evidence pack from the
* top hits, and ask the LLM for a single Markdown page. The resulting page
* is persisted and its citations are bound to the evidence chunk IDs only
* not to every chunk of the source raw so relation signals stay clean.
* <p>
* This is the bridge between lazy ingest (where 0 pages is the steady state)
* and the user/agent saying "now produce a page about X".
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class WikiCompileService {
private final HybridRetriever hybridRetriever;
private final WikiChunkMapper chunkMapper;
private final WikiPageService pageService;
private final WikiCitationService citationService;
private final ObjectMapper objectMapper;
/**
* Optional. When wired we use the routing chain (stepModels[CREATE_PAGE]
* -&gt; wikiDefaultModelId -&gt; system default); otherwise the caller's
* pre-built {@link ChatModel} factory wins.
*/
@Autowired(required = false)
private WikiModelRoutingService modelRoutingService;
public record CompileResult(Long pageId, String slug, String title, int evidenceChunkCount,
boolean created) {}
/**
* Compile or update a single page on the topic.
*
* @param kbId knowledge base id
* @param topic natural-language topic; required
* @param slug optional explicit slug; auto-derived from topic when null/blank
* @param maxEvidenceChunks evidence pack cap (defaults to 8 when null)
* @return result with the persisted page id and how many chunks were cited
*/
public CompileResult compilePage(Long kbId, String topic, String slug, Integer maxEvidenceChunks) {
if (kbId == null) throw new IllegalArgumentException("kbId is required");
if (topic == null || topic.isBlank()) throw new IllegalArgumentException("topic is required");
int cap = (maxEvidenceChunks == null || maxEvidenceChunks <= 0)
? 8 : Math.min(20, maxEvidenceChunks);
// 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);
}
List<Long> evidenceChunkIds = new ArrayList<>(hits.size());
StringBuilder evidenceBlock = new StringBuilder();
for (int i = 0; i < hits.size(); i++) {
HybridRetriever.ChunkHit hit = hits.get(i);
evidenceChunkIds.add(hit.chunkId());
WikiChunkEntity chunk = chunkMapper.selectById(hit.chunkId());
if (chunk == null || chunk.getContent() == null) continue;
evidenceBlock.append("### Evidence ").append(i + 1)
.append(" (chunk=").append(hit.chunkId()).append(")");
if (hit.headerBreadcrumb() != null && !hit.headerBreadcrumb().isBlank()) {
evidenceBlock.append("").append(hit.headerBreadcrumb());
}
if (hit.pageNumber() != null) {
evidenceBlock.append(" (page ").append(hit.pageNumber()).append(")");
}
evidenceBlock.append("\n\n").append(chunk.getContent()).append("\n\n");
}
// 2. Resolve slug + title.
String resolvedSlug = (slug == null || slug.isBlank()) ? WikiPageService.toSlug(topic) : slug;
if (resolvedSlug == null || resolvedSlug.isBlank()) {
resolvedSlug = "page-" + System.currentTimeMillis();
}
WikiPageEntity existing = pageService.getBySlug(kbId, resolvedSlug);
if (existing != null && WikiPageService.isProtected(existing)) {
throw new IllegalStateException("Refusing to compile over protected page: " + resolvedSlug);
}
// 3. Build LLM prompt.
String system = """
You are a wiki page compiler. Produce a single Markdown page that
synthesizes the supplied evidence. Constraints:
- Output ONLY a JSON object: {"title": "...", "summary": "...", "content": "..."}
- Do not invent facts beyond the evidence.
- Use [[wikilinks]] when an evidence breadcrumb names a related concept.
- Keep summary <= 300 characters.
- content must be Markdown with ## headers.
""";
String user = "## Topic\n\n" + topic + "\n\n## Evidence\n\n" + evidenceBlock;
ChatModel chatModel = resolveChatModel(kbId);
ChatResponse resp = chatModel.call(new Prompt(List.of(
new SystemMessage(system), new UserMessage(user))));
if (resp == null || resp.getResult() == null
|| resp.getResult().getOutput() == null
|| resp.getResult().getOutput().getText() == null) {
throw new IllegalStateException("LLM returned no compile output");
}
String body = resp.getResult().getOutput().getText();
JsonNode parsed = parseJson(body);
if (parsed == null) {
throw new IllegalStateException("LLM compile output was not valid JSON");
}
String title = parsed.path("title").asText(topic);
String summary = parsed.path("summary").asText("");
String content = parsed.path("content").asText("");
if (content.isBlank()) {
throw new IllegalStateException("LLM compile output missing content");
}
// 4. Persist (create or update via AI path).
WikiPageEntity persisted;
boolean created;
if (existing == null) {
persisted = pageService.createPage(kbId, resolvedSlug, title, content, summary, null);
created = true;
} else {
persisted = pageService.updatePageByAi(kbId, resolvedSlug, content, summary, null);
created = false;
if (persisted == null) persisted = existing;
}
// 5. Bind evidence citations only.
try {
citationService.buildCitations(persisted.getId(), kbId, evidenceChunkIds);
} catch (Exception e) {
log.warn("[WikiCompile] Failed to attach evidence citations for pageId={}: {}",
persisted.getId(), e.getMessage());
}
log.info("[WikiCompile] {} page slug={} title='{}' from {} evidence chunks (kbId={})",
created ? "Created" : "Updated", resolvedSlug, title, evidenceChunkIds.size(), kbId);
return new CompileResult(persisted.getId(), resolvedSlug, title, evidenceChunkIds.size(), created);
}
private ChatModel resolveChatModel(Long kbId) {
if (modelRoutingService == null) {
throw new IllegalStateException("ModelRoutingService unavailable; cannot compile");
}
Long modelId = modelRoutingService.selectModelId(kbId, "compile_page", WikiJobStep.CREATE_PAGE);
return modelRoutingService.buildChatModel(modelId);
}
private JsonNode parseJson(String text) {
if (text == null) return null;
try {
return objectMapper.readTree(text);
} catch (Exception ignored) {
int s = text.indexOf('{');
int e = text.lastIndexOf('}');
if (s >= 0 && e > s) {
try {
return objectMapper.readTree(text.substring(s, e + 1));
} catch (Exception ignored2) {
return null;
}
}
return null;
}
}
}

View File

@ -55,6 +55,10 @@ public class WikiTool {
@Autowired(required = false)
private WikiRawMaterialMapper rawMaterialMapper;
/** RFC-051 PR-4: optional on-demand compile. Tool surface skipped when missing. */
@Autowired(required = false)
private WikiCompileService compileService;
public WikiTool(WikiPageService pageService,
WikiKnowledgeBaseService kbService,
WikiRawMaterialService rawService,
@ -362,6 +366,90 @@ public class WikiTool {
.toString();
}
// ==================== RFC-051 PR-4: on-demand compile + batch read ====================
@Tool(description = """
Compile (or update) a single wiki page about a topic from existing chunks.
Use this AFTER lazy ingest when search has surfaced relevant content but no
page exists yet. The page will cite only the evidence chunks the compile
prompt actually used not every chunk of the source raw material.
Set slug to control the page slug; otherwise it's derived from the topic.
""")
public String wiki_compile_page(
@ToolParam(description = "Agent ID") Long agentId,
@ToolParam(description = "Topic to compile a page about (natural language)") String topic,
@ToolParam(description = "Optional explicit slug for the page", required = false) String slug,
@ToolParam(description = "Max evidence chunks (default 8, max 20)", required = false) Integer maxEvidenceChunks) {
if (topic == null || topic.isBlank()) {
return error("topic is required");
}
Long kbId = resolveKbId(agentId);
if (kbId == null) return error("No wiki knowledge base found for this agent");
if (compileService == null) return error("Compile service not available");
try {
WikiCompileService.CompileResult res = compileService.compilePage(kbId, topic, slug, maxEvidenceChunks);
return JSONUtil.createObj()
.set("ok", true)
.set("slug", res.slug())
.set("title", res.title())
.set("evidenceChunks", res.evidenceChunkCount())
.set("created", res.created())
.toString();
} catch (IllegalStateException | IllegalArgumentException e) {
return error(e.getMessage());
} catch (Exception e) {
log.warn("[WikiTool] wiki_compile_page failed: {}", e.getMessage());
return error("Compile failed: " + e.getMessage());
}
}
@Tool(description = """
Read multiple wiki pages in one call. Prefer this over multiple wiki_read_page
calls when you already know the slugs you need. The response is capped per
page; protected/system pages can still be read explicitly here.
""")
public String wiki_read_many(
@ToolParam(description = "Agent ID") Long agentId,
@ToolParam(description = "Comma-separated slugs (max 10)") String slugs,
@ToolParam(description = "Max chars returned per page (default 2000, max 8000)", required = false) Integer maxCharsPerPage) {
if (slugs == null || slugs.isBlank()) return error("slugs is required");
Long kbId = resolveKbId(agentId);
if (kbId == null) return error("No wiki knowledge base found for this agent");
int cap = (maxCharsPerPage == null || maxCharsPerPage <= 0) ? 2000 : Math.min(8000, maxCharsPerPage);
List<String> slugList = Arrays.stream(slugs.split(","))
.map(String::trim).filter(s -> !s.isEmpty()).limit(10).toList();
if (slugList.isEmpty()) return error("No valid slugs supplied");
JSONArray arr = new JSONArray();
for (String s : slugList) {
WikiPageEntity page = pageService.getBySlug(kbId, s);
if (page == null) {
arr.add(JSONUtil.createObj().set("slug", s).set("found", false));
continue;
}
String content = page.getContent() == null ? "" : page.getContent();
boolean truncated = content.length() > cap;
if (truncated) content = content.substring(0, cap) + "\n…(truncated)";
arr.add(JSONUtil.createObj()
.set("slug", s)
.set("found", true)
.set("title", page.getTitle())
.set("summary", page.getSummary())
.set("content", content)
.set("truncated", truncated));
pageService.trackReference(kbId, s);
}
return JSONUtil.createObj()
.set("kbId", kbId)
.set("requestedCount", slugList.size())
.set("pages", arr)
.toString();
}
@Tool(description = """
Delete an AI-generated wiki page. Cannot delete manually curated pages.
""")