mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
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:
parent
bf86b1f737
commit
9d292a9893
@ -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}. */
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
) {}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user