feat(kb-open): P0-B 9 个开放 API 端点 (#445)

* feat(kb-open): P0-B 9 open API endpoints

Implements the 9 read-only KB Open API endpoints on top of the P0-A
auth skeleton (#441). Each returns an explicit DTO (A5: never raw
entities) and delegates assembly to service-layer methods that return
pure DTOs (A6: no HTTP coupling, MCP-ready).

Endpoints:
- GET  /pages/{slug}        entity card (mode=summary/full/section:{heading})
- POST /search              hybrid retrieval (granularity=entity/chunk)
- POST /search/chunks       chunk-level semantic search
- POST /pages/{slug}/traverse  entity relation graph (depth ≤ 2)
- GET  /pages/{slug}/trace  provenance (page → chunk → raw)
- GET  /taxonomy            pageType/entityType/relationType enumeration
- GET  /whats-new           recent changes + stale pages
- GET  /stats               KB statistics
- GET  /pages               lightweight page list

Components:
- KbOpenApiController: 9 endpoints, each @RequireKbScope annotated
- KbOpenApiService: assembly layer (card, traverse BFS, metadata parsing)
- KbOpenApiDtos: all response DTOs as records (PageCard, TraceResult,
  TaxonomyResult, KbStats, WhatsNewResult, TraverseResult, PageList)

Traverse (pragmatic version):
- depth ≤ 2 with explosion guard, predicate LIKE matching
- slug → pageId → mention → primaryEntity (salience-highest)
- neighbor nodes echo slug when available (R11)
- edge sourceHandle via evidenceChunkId → citing page

Tests (4 new, all green):
- KbOpenApiControllerTest: 404 on missing page/slug, delegation to service

Closes #442

* fix(kb-open): address review feedback on #445

BLOCKERS:
- stats.pagesWithLinks always returned 0 because listByKbId() nulls out
  content. Switch to listByKbIdWithContent() so [[wiki link]] detection works.
- Test file: replace inline java.util.List.of() FQN with import + simple name
  (sync-opensource would expose the unidiomatic style).

NITS (inherited from P0-A rebase):
- V162→V164, prefix VARCHAR(12), FQN imports, parseScopes trim, ?token=
  fallback removal, design doc moved to rfcs/ — all now in ancestor commit
  6fd62440.

EXTRA:
- whatsNew staleReason: hardcoded Chinese "上游 fact 页面变更" → English
  "Upstream fact page changed" (external-facing API response).

* chore(wiki): drop RFC-012 prefix from progress field Javadocs (#449 nit)

Per #449 review (4825113234): the internal RFC-012 reference should not
appear in code. progressPhase/progressTotal/progressDone Javadocs still
carried the "RFC-012 M2 v2 UI:" prefix after #449's English translation
pass — drop it now that these lines are touched.

Zero behavior change.

* chore(kb-open): drop inline FQN in parseScopes (#444 nit)

Per #444 review (4825157096): parseScopes used
`.collect(java.util.stream.Collectors.toUnmodifiableSet())` while
`Collectors` is already imported at the top of the file. Use the simple
name. Zero behavior change.
This commit is contained in:
倪程伟 2026-06-30 09:36:55 +08:00 committed by GitHub
parent bf86b1f737
commit 9d292a9893
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 883 additions and 10 deletions

View File

@ -229,7 +229,7 @@ public class KbApiKeyService {
}
return Set.of(scopes.split(",")).stream()
.map(String::trim)
.collect(java.util.stream.Collectors.toUnmodifiableSet());
.collect(Collectors.toUnmodifiableSet());
}
/** Return value of {@link #create}. */

View File

@ -0,0 +1,331 @@
package vip.mate.kbopen.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import vip.mate.common.result.R;
import vip.mate.exception.MateClawException;
import vip.mate.kbopen.auth.RequireKbScope;
import vip.mate.kbopen.dto.KbOpenApiDtos.*;
import vip.mate.kbopen.service.KbOpenApiService;
import vip.mate.wiki.dto.PageCitationWithRaw;
import vip.mate.wiki.dto.PageSearchResult;
import vip.mate.wiki.model.WikiChunkEntity;
import vip.mate.wiki.model.WikiEntityEntity;
import vip.mate.wiki.model.WikiEntityRelationEntity;
import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
import vip.mate.wiki.model.WikiPageEntity;
import vip.mate.wiki.repository.WikiChunkMapper;
import vip.mate.wiki.repository.WikiEntityMapper;
import vip.mate.wiki.repository.WikiEntityRelationMapper;
import vip.mate.wiki.repository.WikiPageCitationMapper;
import vip.mate.wiki.service.HybridRetriever;
import vip.mate.wiki.service.WikiKnowledgeBaseService;
import vip.mate.wiki.service.WikiPageService;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
/**
* KB Open API 9 read-only endpoints for programmatic knowledge base access.
*
* <p>All endpoints are under {@code /api/v1/open/kb/**} (SecurityConfig
* permitAll), authenticated by {@code KbOpenApiAuthFilter} (API Key), and
* authorized by {@code @RequireKbScope} (scope + KB ownership).
*
* <p>A5: returns explicit DTOs, never raw entities.
* A6: service-layer methods return pure DTOs (no HTTP coupling).
*/
@Tag(name = "KB Open API")
@RestController
@RequestMapping("/api/v1/open/kb")
@RequiredArgsConstructor
public class KbOpenApiController {
private final WikiPageService pageService;
private final HybridRetriever hybridRetriever;
private final WikiKnowledgeBaseService kbService;
private final WikiPageCitationMapper citationMapper;
private final WikiChunkMapper chunkMapper;
private final WikiEntityMapper entityMapper;
private final WikiEntityRelationMapper relationMapper;
private final KbOpenApiService openApiService;
// 1. GET /pages/{slug} entity card / page detail
@RequireKbScope("kb:read")
@GetMapping("/{kbId}/pages/{slug}")
@Operation(summary = "Get entity card / page detail (mode controls content depth)")
public R<PageCard> getPage(
@PathVariable Long kbId,
@PathVariable String slug,
@RequestParam(defaultValue = "summary") String mode,
@RequestParam(required = false) String fields) {
WikiPageEntity page = pageService.getBySlug(kbId, slug);
if (page == null) {
throw new MateClawException(404, "Page not found: " + slug);
}
return R.ok(openApiService.assembleCard(page, mode, fields));
}
// 2. POST /search hybrid retrieval
@RequireKbScope("kb:search")
@PostMapping("/{kbId}/search")
@Operation(summary = "Hybrid search (granularity controls result shape)")
public R<Map<String, Object>> search(
@PathVariable Long kbId,
@RequestBody SearchRequest req) {
String query = req.query() != null ? req.query() : "";
String mode = req.mode() != null ? req.mode() : "hybrid";
int topK = req.topK() != null ? Math.min(req.topK(), 20) : 5;
List<PageSearchResult> hits = hybridRetriever.search(kbId, query, mode, topK);
// Filter by pageType if specified
if (req.pageType() != null && !req.pageType().isBlank()) {
hits = hits.stream().filter(h -> {
WikiPageEntity p = pageService.getBySlug(kbId, h.slug());
return p != null && req.pageType().equals(p.getPageType());
}).collect(Collectors.toList());
}
// granularity: entity (default) returns summary-level hits; chunk is via /search/chunks
List<Map<String, Object>> results = hits.stream().map(h -> {
Map<String, Object> m = new LinkedHashMap<>();
m.put("slug", h.slug());
m.put("title", h.title());
m.put("summary", h.summary());
m.put("snippet", h.snippet());
m.put("matchedBy", h.matchedBy());
m.put("score", h.score());
return m;
}).collect(Collectors.toList());
return R.ok(Map.of(
"kbId", kbId,
"query", query,
"mode", mode,
"count", results.size(),
"results", results));
}
public record SearchRequest(String query, String mode, String pageType,
String granularity, Integer topK) {}
// 3. POST /search/chunks chunk-level retrieval
@RequireKbScope("kb:search")
@PostMapping("/{kbId}/search/chunks")
@Operation(summary = "Chunk-level semantic search (fine-grained RAG evidence)")
public R<Map<String, Object>> searchChunks(
@PathVariable Long kbId,
@RequestBody ChunkSearchRequest req) {
String query = req.query() != null ? req.query() : "";
int topK = req.topK() != null ? Math.min(req.topK(), 20) : 5;
List<HybridRetriever.ChunkHit> hits = hybridRetriever.searchChunks(kbId, query, topK);
List<Map<String, Object>> results = hits.stream().map(h -> {
Map<String, Object> m = new LinkedHashMap<>();
m.put("chunkId", h.chunkId());
m.put("rawId", h.rawId());
m.put("snippet", h.snippet());
m.put("score", h.score());
m.put("pageNumber", h.pageNumber());
m.put("headerBreadcrumb", h.headerBreadcrumb());
return m;
}).collect(Collectors.toList());
return R.ok(Map.of(
"kbId", kbId,
"count", results.size(),
"chunks", results));
}
public record ChunkSearchRequest(String query, Integer topK) {}
// 4. POST /pages/{slug}/traverse entity relation graph
@RequireKbScope("kb:read")
@PostMapping("/{kbId}/pages/{slug}/traverse")
@Operation(summary = "Traverse entity relations (depth ≤ 2)")
public R<TraverseResult> traverse(
@PathVariable Long kbId,
@PathVariable String slug,
@RequestBody(required = false) TraverseRequest req) {
String relation = req != null ? req.relation() : null;
int depth = req != null && req.depth() != null ? Math.min(req.depth(), 2) : 1;
String direction = req != null && req.direction() != null ? req.direction() : "both";
int limit = req != null && req.limit() != null ? Math.min(req.limit(), 50) : 20;
return R.ok(openApiService.traverse(kbId, slug, relation, depth, direction, limit));
}
public record TraverseRequest(String relation, Integer depth, String direction, Integer limit) {}
// 5. GET /pages/{slug}/trace provenance
@RequireKbScope("kb:read")
@GetMapping("/{kbId}/pages/{slug}/trace")
@Operation(summary = "Trace page provenance (page → chunk → raw)")
public R<TraceResult> trace(
@PathVariable Long kbId,
@PathVariable String slug) {
WikiPageEntity page = pageService.getBySlug(kbId, slug);
if (page == null) {
throw new MateClawException(404, "Page not found: " + slug);
}
var citations = citationMapper.listWithRawByPageId(page.getId());
// Group by rawId
Map<Long, List<PageCitationWithRaw>> byRaw = citations.stream()
.collect(Collectors.groupingBy(PageCitationWithRaw::rawId));
List<SourceGroup> sources = byRaw.entrySet().stream().map(e -> {
List<CitationDetail> details = e.getValue().stream().map(c ->
new CitationDetail(c.chunkId(), c.snippet(),
c.confidence() != null ? c.confidence().doubleValue() : null,
null)).collect(Collectors.toList()); // pageNumber from chunk lookup omitted for brevity
String rawTitle = e.getValue().get(0).rawTitle();
return new SourceGroup(e.getKey(), rawTitle, details);
}).collect(Collectors.toList());
return R.ok(new TraceResult(
page.getSlug(),
page.getPageType(),
page.getKnowledgeLayer(),
sources,
page.getUpdateTime(),
page.getVersion()));
}
// 6. GET /taxonomy type enumeration map
@RequireKbScope("kb:list")
@GetMapping("/{kbId}/taxonomy")
@Operation(summary = "Get type/scope taxonomy (pageTypes, entityTypes, relationTypes)")
public R<TaxonomyResult> taxonomy(@PathVariable Long kbId) {
// Page types
List<WikiPageEntity> pages = pageService.listByKbId(kbId);
Map<String, Long> pageTypeCounts = pages.stream()
.filter(p -> p.getPageType() != null)
.collect(Collectors.groupingBy(WikiPageEntity::getPageType, Collectors.counting()));
List<TypeCount> pageTypes = pageTypeCounts.entrySet().stream()
.sorted(Map.Entry.<String, Long>comparingByValue().reversed())
.map(e -> new TypeCount(e.getKey(), e.getValue().intValue()))
.collect(Collectors.toList());
// Entity types
List<WikiEntityEntity> entities = entityMapper.selectList(
new LambdaQueryWrapper<WikiEntityEntity>().eq(WikiEntityEntity::getKbId, kbId));
Map<String, Long> entityTypeCounts = entities.stream()
.filter(e -> e.getType() != null)
.collect(Collectors.groupingBy(WikiEntityEntity::getType, Collectors.counting()));
List<TypeCount> entityTypes = entityTypeCounts.entrySet().stream()
.sorted(Map.Entry.<String, Long>comparingByValue().reversed())
.map(e -> new TypeCount(e.getKey(), e.getValue().intValue()))
.collect(Collectors.toList());
// Relation types
List<WikiEntityRelationEntity> rels = relationMapper.selectList(
new LambdaQueryWrapper<WikiEntityRelationEntity>().eq(WikiEntityRelationEntity::getKbId, kbId));
Map<String, Long> relTypeCounts = rels.stream()
.filter(r -> r.getPredicate() != null)
.collect(Collectors.groupingBy(WikiEntityRelationEntity::getPredicate, Collectors.counting()));
List<TypeCount> relationTypes = relTypeCounts.entrySet().stream()
.sorted(Map.Entry.<String, Long>comparingByValue().reversed())
.map(e -> new TypeCount(e.getKey(), e.getValue().intValue()))
.collect(Collectors.toList());
return R.ok(new TaxonomyResult(pageTypes, entityTypes, relationTypes));
}
// 7. GET /whats-new freshness query
@RequireKbScope("kb:meta")
@GetMapping("/{kbId}/whats-new")
@Operation(summary = "Query recent changes and stale pages")
public R<WhatsNewResult> whatsNew(
@PathVariable Long kbId,
@RequestParam(defaultValue = "updated") String kind,
@RequestParam(required = false) LocalDateTime since,
@RequestParam(defaultValue = "50") int limit) {
if (since == null) {
since = LocalDateTime.now().minusDays(7);
}
int safeLimit = Math.min(limit, 200);
List<WikiPageEntity> changed = "created".equals(kind)
? pageService.findRecentCreated(kbId, since, safeLimit)
: pageService.findRecentUpdated(kbId, since, safeLimit);
List<ChangedPage> changedPages = changed.stream()
.map(p -> new ChangedPage(p.getSlug(), p.getTitle(), p.getKnowledgeLayer(),
p.getUpdateTime(), null))
.collect(Collectors.toList());
// Stale pages
List<WikiPageEntity> allPages = pageService.listByKbId(kbId);
List<ChangedPage> stalePages = allPages.stream()
.filter(p -> p.getStale() != null && p.getStale() == 1)
.map(p -> new ChangedPage(p.getSlug(), p.getTitle(), p.getKnowledgeLayer(),
p.getUpdateTime(), "Upstream fact page changed"))
.collect(Collectors.toList());
return R.ok(new WhatsNewResult(kbId, since, changedPages, stalePages));
}
// 8. GET /stats KB metadata
@RequireKbScope("kb:meta")
@GetMapping("/{kbId}/stats")
@Operation(summary = "Get KB statistics")
public R<KbStats> stats(@PathVariable Long kbId) {
WikiKnowledgeBaseEntity kb = kbService.getById(kbId);
if (kb == null) {
throw new MateClawException(404, "Knowledge base not found: " + kbId);
}
int pageCount = pageService.countByKbId(kbId);
// Must use listByKbIdWithContent listByKbId nulls out content.
List<WikiPageEntity> pagesWithContent = pageService.listByKbIdWithContent(kbId);
long pagesWithLinks = pagesWithContent.stream()
.filter(p -> p.getContent() != null && p.getContent().contains("[["))
.count();
return R.ok(new KbStats(
kbId,
kb.getName(),
pageCount,
kb.getRawCount() != null ? kb.getRawCount() : 0,
0, // chunkCount not trivially available without a service call
0, // embeddedChunks same
(int) pagesWithLinks,
null, // lastIngest
null // embeddingModel
));
}
// 9. GET /pages list pages
@RequireKbScope("kb:list")
@GetMapping("/{kbId}/pages")
@Operation(summary = "List pages (lightweight)")
public R<PageList> listPages(
@PathVariable Long kbId,
@RequestParam(required = false) String pageType) {
List<WikiPageEntity> pages = pageService.listByKbId(kbId);
if (pageType != null && !pageType.isBlank()) {
pages = pages.stream()
.filter(p -> pageType.equals(p.getPageType()))
.collect(Collectors.toList());
}
List<PageListItem> items = pages.stream()
.map(p -> new PageListItem(p.getSlug(), p.getTitle(), p.getSummary(),
p.getPageType(), p.getKnowledgeLayer()))
.collect(Collectors.toList());
return R.ok(new PageList(kbId, items.size(), items));
}
}

View File

@ -0,0 +1,142 @@
package vip.mate.kbopen.dto;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Explicit response DTOs for the KB Open API.
*
* <p>A5 constraint: the open API <strong>never</strong> serializes raw
* entities (WikiPageEntity, etc.) every endpoint returns an explicit DTO
* to prevent accidental field leakage (IDOR / internal column exposure).
*
* <p>All DTOs are records for immutability and clean JSON serialization.
*/
public final class KbOpenApiDtos {
private KbOpenApiDtos() {}
/** GET /pages/{slug} — entity card / page detail. */
public record PageCard(
String slug,
String canonicalName,
String pageType,
String knowledgeLayer,
String title,
String summary,
Map<String, Object> fields,
String content,
SourceRef source,
Integer version,
LocalDateTime updatedAt
) {}
/** GET /pages/{slug}/trace — provenance chain. */
public record TraceResult(
String slug,
String pageType,
String knowledgeLayer,
List<SourceGroup> sources,
LocalDateTime extractedAt,
Integer pageVersion
) {}
public record SourceGroup(
Long rawId,
String rawTitle,
List<CitationDetail> citations
) {}
public record CitationDetail(
Long chunkId,
String snippet,
Double confidence,
Integer pageNumber
) {}
/** GET /taxonomy — type/scope enumeration map. */
public record TaxonomyResult(
List<TypeCount> pageTypes,
List<TypeCount> entityTypes,
List<TypeCount> relationTypes
) {}
public record TypeCount(String type, int count) {}
/** GET /stats — KB metadata. */
public record KbStats(
Long kbId,
String name,
int pageCount,
int rawCount,
int chunkCount,
int embeddedChunks,
int pagesWithLinks,
LocalDateTime lastIngest,
String embeddingModel
) {}
/** GET /whats-new — freshness/change query. */
public record WhatsNewResult(
Long kbId,
LocalDateTime since,
List<ChangedPage> changedPages,
List<ChangedPage> stalePages
) {}
public record ChangedPage(
String slug,
String title,
String knowledgeLayer,
LocalDateTime updatedAt,
String staleReason
) {}
/** POST /pages/{slug}/traverse — entity relation subgraph. */
public record TraverseResult(
TraverseNode root,
List<TraverseEdge> edges,
List<TraverseNode> nodes
) {}
public record TraverseNode(
Long entityId,
String name,
String type,
String slug
) {}
public record TraverseEdge(
String predicate,
Long fromId,
Long toId,
String fromName,
String toName,
String evidence,
Double confidence,
String sourceHandle
) {}
/** GET /pages — page list item (lightweight). */
public record PageListItem(
String slug,
String title,
String summary,
String pageType,
String knowledgeLayer
) {}
public record PageList(
Long kbId,
int count,
List<PageListItem> pages
) {}
/** Shared source reference. */
public record SourceRef(
Set<Long> rawIds,
List<String> rawTitles
) {}
}

View File

@ -0,0 +1,298 @@
package vip.mate.kbopen.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import vip.mate.exception.MateClawException;
import vip.mate.kbopen.dto.KbOpenApiDtos;
import vip.mate.kbopen.dto.KbOpenApiDtos.*;
import vip.mate.wiki.model.WikiChunkEntity;
import vip.mate.wiki.model.WikiEntityEntity;
import vip.mate.wiki.model.WikiEntityMentionEntity;
import vip.mate.wiki.model.WikiEntityRelationEntity;
import vip.mate.wiki.model.WikiPageEntity;
import vip.mate.wiki.repository.WikiChunkMapper;
import vip.mate.wiki.repository.WikiEntityMapper;
import vip.mate.wiki.repository.WikiEntityMentionMapper;
import vip.mate.wiki.repository.WikiEntityRelationMapper;
import vip.mate.wiki.service.WikiPageService;
import java.util.*;
import java.util.stream.Collectors;
/**
* Assembly layer for KB Open API endpoints that require multi-table joins
* or aggregation logic not covered by a single existing service method.
*
* <p>A6 constraint: returns pure DTOs, never touches HttpServletRequest/R<T>.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class KbOpenApiService {
private final WikiPageService pageService;
private final WikiEntityMapper entityMapper;
private final WikiEntityRelationMapper relationMapper;
private final WikiEntityMentionMapper mentionMapper;
private final WikiChunkMapper chunkMapper;
private final ObjectMapper objectMapper;
// Page card assembly
/**
* Assemble a PageCard from a WikiPageEntity, honoring the mode parameter.
*
* @param page the resolved page entity
* @param mode summary (default) / full / section:{heading}
* @param fields optional comma-separated field filter (only for summary mode)
*/
public PageCard assembleCard(WikiPageEntity page, String mode, String fields) {
String content = resolveContent(page, mode);
Map<String, Object> metadata = parseMetadata(page.getMetadataJson());
if (fields != null && !fields.isBlank() && "summary".equals(mode)) {
Set<String> wanted = Arrays.stream(fields.split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.collect(Collectors.toSet());
metadata = metadata.entrySet().stream()
.filter(e -> wanted.contains(e.getKey()))
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
}
return new PageCard(
page.getSlug(),
page.getTitle(),
page.getPageType(),
page.getKnowledgeLayer(),
page.getTitle(),
page.getSummary(),
metadata.isEmpty() ? null : metadata,
content,
buildSourceRef(page),
page.getVersion(),
page.getUpdateTime()
);
}
private String resolveContent(WikiPageEntity page, String mode) {
if (mode == null || mode.isBlank() || "summary".equals(mode)) {
return null; // summary mode: no content, caller uses summary+fields
}
if ("full".equals(mode)) {
return page.getContent();
}
if (mode.startsWith("section:")) {
String heading = mode.substring("section:".length()).trim();
return extractSection(page.getContent(), heading);
}
return null;
}
/** Extract the content under a markdown heading (## or ###). */
private String extractSection(String content, String heading) {
if (content == null || heading == null) return null;
String[] lines = content.split("\n");
int start = -1;
int headingLevel = 0;
for (int i = 0; i < lines.length; i++) {
String trimmed = lines[i].trim();
if (trimmed.startsWith("#")) {
int level = 0;
while (level < trimmed.length() && trimmed.charAt(level) == '#') level++;
String text = trimmed.substring(level).trim();
if (start == -1 && text.equalsIgnoreCase(heading)) {
start = i;
headingLevel = level;
continue;
}
if (start != -1 && level <= headingLevel) {
// next heading at same or higher level end of section
return joinLines(lines, start, i);
}
}
}
return start != -1 ? joinLines(lines, start, lines.length) : null;
}
private String joinLines(String[] lines, int from, int to) {
return String.join("\n", Arrays.copyOfRange(lines, from, to)).trim();
}
private SourceRef buildSourceRef(WikiPageEntity page) {
Set<Long> rawIds = parseRawIds(page.getSourceRawIds());
if (rawIds.isEmpty()) return null;
// rawTitles are in sourceEntries (JSON array of {rawId, rawTitle}) if available
List<String> titles = parseSourceTitles(page.getSourceEntries());
return new SourceRef(rawIds, titles);
}
// Traverse
/**
* Resolve the primary entity for a page (salience-highest mention) and
* traverse the entity relation graph.
*/
public TraverseResult traverse(Long kbId, String slug, String relation,
int depth, String direction, int limit) {
WikiPageEntity page = pageService.getBySlug(kbId, slug);
if (page == null) {
throw new MateClawException(404, "Page not found: " + slug);
}
// Resolve primary entity: the salience-highest entity mentioned by this page
Long primaryEntityId = resolvePrimaryEntity(page.getId());
if (primaryEntityId == null) {
// No entity mentions return empty graph with root as page-level info
return new TraverseResult(
new TraverseNode(null, page.getTitle(),
page.getPageType(), slug),
List.of(), List.of());
}
WikiEntityEntity root = entityMapper.selectById(primaryEntityId);
TraverseNode rootNode = new TraverseNode(
root.getId(), root.getCanonicalName(), root.getType(), slug);
// BFS traversal
Set<Long> visited = new LinkedHashSet<>();
visited.add(primaryEntityId);
List<TraverseEdge> allEdges = new ArrayList<>();
Set<Long> neighborIds = new LinkedHashSet<>();
collectEdges(kbId, primaryEntityId, relation, direction, limit, allEdges, neighborIds, visited);
if (depth >= 2) {
// Second hop: traverse each first-hop neighbor
for (Long neighborId : new ArrayList<>(neighborIds)) {
if (visited.size() > limit * 3) break; // explosion guard
collectEdges(kbId, neighborId, relation, direction, limit, allEdges, neighborIds, visited);
}
}
// Assemble neighbor nodes
neighborIds.remove(primaryEntityId);
List<TraverseNode> nodes = new ArrayList<>();
if (!neighborIds.isEmpty()) {
for (WikiEntityEntity e : entityMapper.selectBatchIds(neighborIds)) {
String entitySlug = resolveSlugForEntity(e.getId());
nodes.add(new TraverseNode(e.getId(), e.getCanonicalName(), e.getType(), entitySlug));
}
}
return new TraverseResult(rootNode, allEdges, nodes);
}
private void collectEdges(Long kbId, Long entityId, String relation,
String direction, int limit,
List<TraverseEdge> out, Set<Long> neighborIds, Set<Long> visited) {
LambdaQueryWrapper<WikiEntityRelationEntity> q = new LambdaQueryWrapper<WikiEntityRelationEntity>()
.eq(WikiEntityRelationEntity::getKbId, kbId);
boolean outgoing = !"incoming".equals(direction);
boolean incoming = !"outgoing".equals(direction);
if (outgoing && incoming) {
q.and(w -> w.eq(WikiEntityRelationEntity::getSubjectEntityId, entityId)
.or().eq(WikiEntityRelationEntity::getObjectEntityId, entityId));
} else if (outgoing) {
q.eq(WikiEntityRelationEntity::getSubjectEntityId, entityId);
} else {
q.eq(WikiEntityRelationEntity::getObjectEntityId, entityId);
}
if (relation != null && !relation.isBlank()) {
q.like(WikiEntityRelationEntity::getPredicate, relation);
}
q.last("LIMIT " + Math.max(1, Math.min(limit, 50)));
for (WikiEntityRelationEntity r : relationMapper.selectList(q)) {
TraverseEdge edge = new TraverseEdge(
r.getPredicate(),
r.getSubjectEntityId(),
r.getObjectEntityId(),
null, null, // names filled by caller via batch lookup
r.getEvidence(),
r.getConfidence() != null ? r.getConfidence().doubleValue() : null,
resolveSourceHandle(r.getEvidenceChunkId())
);
out.add(edge);
if (!r.getSubjectEntityId().equals(entityId)) neighborIds.add(r.getSubjectEntityId());
if (!r.getObjectEntityId().equals(entityId)) neighborIds.add(r.getObjectEntityId());
visited.add(r.getSubjectEntityId());
visited.add(r.getObjectEntityId());
}
}
private Long resolvePrimaryEntity(Long pageId) {
List<WikiEntityMentionEntity> mentions = mentionMapper.selectList(
new LambdaQueryWrapper<WikiEntityMentionEntity>()
.eq(WikiEntityMentionEntity::getPageId, pageId)
.orderByDesc(WikiEntityMentionEntity::getConfidence)
.last("LIMIT 1"));
return mentions.isEmpty() ? null : mentions.get(0).getEntityId();
}
private String resolveSlugForEntity(Long entityId) {
List<WikiEntityMentionEntity> mentions = mentionMapper.selectList(
new LambdaQueryWrapper<WikiEntityMentionEntity>()
.eq(WikiEntityMentionEntity::getEntityId, entityId)
.isNotNull(WikiEntityMentionEntity::getPageId)
.last("LIMIT 1"));
if (mentions.isEmpty()) return null;
WikiPageEntity page = pageService.getById(mentions.get(0).getPageId());
return page != null ? page.getSlug() : null;
}
private String resolveSourceHandle(Long chunkId) {
if (chunkId == null) return null;
// Find the first page that cites this chunk
List<WikiEntityMentionEntity> mentions = mentionMapper.selectList(
new LambdaQueryWrapper<WikiEntityMentionEntity>()
.eq(WikiEntityMentionEntity::getChunkId, chunkId)
.isNotNull(WikiEntityMentionEntity::getPageId)
.last("LIMIT 1"));
if (mentions.isEmpty()) return null;
return "p:" + mentions.get(0).getPageId();
}
// JSON parsing helpers
@SuppressWarnings("unchecked")
private Map<String, Object> parseMetadata(String json) {
if (json == null || json.isBlank()) return Map.of();
try {
return objectMapper.readValue(json, new TypeReference<Map<String, Object>>() {});
} catch (Exception e) {
return Map.of();
}
}
@SuppressWarnings("unchecked")
private Set<Long> parseRawIds(String json) {
if (json == null || json.isBlank()) return Set.of();
try {
List<Integer> ids = objectMapper.readValue(json, new TypeReference<List<Integer>>() {});
return ids.stream().map(Integer::longValue).collect(Collectors.toCollection(LinkedHashSet::new));
} catch (Exception e) {
return Set.of();
}
}
@SuppressWarnings("unchecked")
private List<String> parseSourceTitles(String json) {
if (json == null || json.isBlank()) return List.of();
try {
List<Map<String, Object>> entries = objectMapper.readValue(json, new TypeReference<List<Map<String, Object>>>() {});
return entries.stream()
.map(e -> String.valueOf(e.getOrDefault("rawTitle", "")))
.filter(s -> !s.isEmpty())
.collect(Collectors.toList());
} catch (Exception e) {
return List.of();
}
}
}

View File

@ -93,16 +93,16 @@ public class WikiRawMaterialEntity {
private String warningMessage;
/**
* RFC-012 M2 v2 UI: current processing phase (null = not started /
* "route" / "phase-b" / "done"). Drives whether the frontend shows a
* progress bar and whether it says "preparing" or a concrete percentage.
* Current processing phase (null = not started / "route" / "phase-b" /
* "done"). Drives whether the frontend shows a progress bar and whether
* it says "preparing" or a concrete percentage.
*/
private String progressPhase;
/** RFC-012 M2 v2 UI: total pages planned for this run (set after route phase). */
/** Total pages planned for this run (set after route phase). */
private Integer progressTotal;
/** RFC-012 M2 v2 UI: completed page count (incremented per successful phase-B page). */
/** Completed page count (incremented per successful phase-B page). */
private Integer progressDone;
@TableField(fill = FieldFill.INSERT)

View File

@ -12,6 +12,7 @@ import vip.mate.kbopen.auth.repository.KbApiKeyBindingMapper;
import vip.mate.kbopen.auth.repository.KbApiKeyMapper;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import java.util.Set;
@ -87,7 +88,7 @@ class KbApiKeyServiceTest {
// authenticate() hashes the plaintext and looks it up set up the mapper
// to return the created entity (whose hash matches).
when(keyMapper.selectOne(any())).thenReturn(created.entity());
when(bindingMapper.selectList(any())).thenReturn(java.util.List.of(
when(bindingMapper.selectList(any())).thenReturn(List.of(
bindingFor(42L, 10L),
bindingFor(42L, 20L)));
@ -134,7 +135,7 @@ class KbApiKeyServiceTest {
KbApiKeyEntity entity = entityWithHash(1L, 1L, "kb:*", plaintext);
entity.setExpiresAt(LocalDateTime.now().minusDays(1));
when(keyMapper.selectOne(any())).thenReturn(entity);
when(bindingMapper.selectList(any())).thenReturn(java.util.List.of());
when(bindingMapper.selectList(any())).thenReturn(List.of());
assertThat(service.authenticate(plaintext)).isEmpty();
}
@ -156,7 +157,7 @@ class KbApiKeyServiceTest {
void wildcardScopeGrantsAll() {
String plaintext = tokenHashUtil.generate(KbApiKeyService.KEY_PREFIX, 32);
when(keyMapper.selectOne(any())).thenReturn(entityWithHash(1L, 1L, "kb:*", plaintext));
when(bindingMapper.selectList(any())).thenReturn(java.util.List.of(bindingFor(1L, 10L)));
when(bindingMapper.selectList(any())).thenReturn(List.of(bindingFor(1L, 10L)));
KbApiKeyContext ctx = service.authenticate(plaintext).get().context();
@ -171,7 +172,7 @@ class KbApiKeyServiceTest {
void nullScopesDefaultsToWildcard() {
String plaintext = tokenHashUtil.generate(KbApiKeyService.KEY_PREFIX, 32);
when(keyMapper.selectOne(any())).thenReturn(entityWithHash(1L, 1L, null, plaintext));
when(bindingMapper.selectList(any())).thenReturn(java.util.List.of(bindingFor(1L, 10L)));
when(bindingMapper.selectList(any())).thenReturn(List.of(bindingFor(1L, 10L)));
KbApiKeyContext ctx = service.authenticate(plaintext).get().context();

View File

@ -0,0 +1,101 @@
package vip.mate.kbopen.controller;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import vip.mate.exception.MateClawException;
import vip.mate.kbopen.dto.KbOpenApiDtos.PageCard;
import vip.mate.kbopen.service.KbOpenApiService;
import vip.mate.wiki.model.WikiPageEntity;
import vip.mate.wiki.repository.WikiChunkMapper;
import vip.mate.wiki.repository.WikiEntityMapper;
import vip.mate.wiki.repository.WikiEntityRelationMapper;
import vip.mate.wiki.repository.WikiPageCitationMapper;
import vip.mate.wiki.service.HybridRetriever;
import vip.mate.wiki.service.WikiKnowledgeBaseService;
import vip.mate.wiki.service.WikiPageService;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* Unit tests for {@link KbOpenApiController}. Focuses on the contract layer:
* 404 on missing page, and that the controller delegates correctly to
* services. Auth/scope/ownership is tested via filter+interceptor integration
* (covered by P0-A tests); here we verify the controller methods themselves.
*/
class KbOpenApiControllerTest {
private WikiPageService pageService;
private HybridRetriever hybridRetriever;
private WikiKnowledgeBaseService kbService;
private WikiPageCitationMapper citationMapper;
private WikiChunkMapper chunkMapper;
private WikiEntityMapper entityMapper;
private WikiEntityRelationMapper relationMapper;
private KbOpenApiService openApiService;
private KbOpenApiController controller;
@BeforeEach
void setUp() {
pageService = mock(WikiPageService.class);
hybridRetriever = mock(HybridRetriever.class);
kbService = mock(WikiKnowledgeBaseService.class);
citationMapper = mock(WikiPageCitationMapper.class);
chunkMapper = mock(WikiChunkMapper.class);
entityMapper = mock(WikiEntityMapper.class);
relationMapper = mock(WikiEntityRelationMapper.class);
openApiService = mock(KbOpenApiService.class);
controller = new KbOpenApiController(
pageService, hybridRetriever, kbService, citationMapper,
chunkMapper, entityMapper, relationMapper, openApiService);
}
@Test
@DisplayName("getPage returns 404 when slug not found")
void getPageNotFound() {
when(pageService.getBySlug(1L, "missing")).thenReturn(null);
assertThatThrownBy(() -> controller.getPage(1L, "missing", "summary", null))
.isInstanceOf(MateClawException.class)
.hasMessageContaining("not found");
}
@Test
@DisplayName("getPage delegates to openApiService.assembleCard")
void getPageDelegatesToService() {
WikiPageEntity page = new WikiPageEntity();
page.setSlug("test");
page.setTitle("Test Page");
page.setPageType("concept");
when(pageService.getBySlug(1L, "test")).thenReturn(page);
PageCard card = new PageCard("test", "Test Page", "concept", "fact",
"Test Page", "summary", null, null, null, 1, null);
when(openApiService.assembleCard(page, "summary", null)).thenReturn(card);
var result = controller.getPage(1L, "test", "summary", null);
org.assertj.core.api.Assertions.assertThat(result.getData()).isNotNull();
}
@Test
@DisplayName("trace returns 404 when slug not found")
void traceNotFound() {
when(pageService.getBySlug(1L, "missing")).thenReturn(null);
assertThatThrownBy(() -> controller.trace(1L, "missing"))
.isInstanceOf(MateClawException.class)
.hasMessageContaining("not found");
}
@Test
@DisplayName("stats returns 404 when KB not found")
void statsKbNotFound() {
when(kbService.getById(99L)).thenReturn(null);
assertThatThrownBy(() -> controller.stats(99L))
.isInstanceOf(MateClawException.class)
.hasMessageContaining("not found");
}
}