diff --git a/mateclaw-server/src/main/java/vip/mate/kbopen/auth/KbApiKeyService.java b/mateclaw-server/src/main/java/vip/mate/kbopen/auth/KbApiKeyService.java index c0bd0260..db153008 100644 --- a/mateclaw-server/src/main/java/vip/mate/kbopen/auth/KbApiKeyService.java +++ b/mateclaw-server/src/main/java/vip/mate/kbopen/auth/KbApiKeyService.java @@ -229,7 +229,7 @@ private Set parseScopes(String scopes) { } return Set.of(scopes.split(",")).stream() .map(String::trim) - .collect(java.util.stream.Collectors.toUnmodifiableSet()); + .collect(Collectors.toUnmodifiableSet()); } /** Return value of {@link #create}. */ diff --git a/mateclaw-server/src/main/java/vip/mate/kbopen/controller/KbOpenApiController.java b/mateclaw-server/src/main/java/vip/mate/kbopen/controller/KbOpenApiController.java new file mode 100644 index 00000000..089d247f --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/kbopen/controller/KbOpenApiController.java @@ -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. + * + *

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). + * + *

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 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> 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 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> results = hits.stream().map(h -> { + Map 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> 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 hits = hybridRetriever.searchChunks(kbId, query, topK); + + List> results = hits.stream().map(h -> { + Map 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 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 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> byRaw = citations.stream() + .collect(Collectors.groupingBy(PageCitationWithRaw::rawId)); + + List sources = byRaw.entrySet().stream().map(e -> { + List 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 taxonomy(@PathVariable Long kbId) { + // Page types + List pages = pageService.listByKbId(kbId); + Map pageTypeCounts = pages.stream() + .filter(p -> p.getPageType() != null) + .collect(Collectors.groupingBy(WikiPageEntity::getPageType, Collectors.counting())); + List pageTypes = pageTypeCounts.entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed()) + .map(e -> new TypeCount(e.getKey(), e.getValue().intValue())) + .collect(Collectors.toList()); + + // Entity types + List entities = entityMapper.selectList( + new LambdaQueryWrapper().eq(WikiEntityEntity::getKbId, kbId)); + Map entityTypeCounts = entities.stream() + .filter(e -> e.getType() != null) + .collect(Collectors.groupingBy(WikiEntityEntity::getType, Collectors.counting())); + List entityTypes = entityTypeCounts.entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed()) + .map(e -> new TypeCount(e.getKey(), e.getValue().intValue())) + .collect(Collectors.toList()); + + // Relation types + List rels = relationMapper.selectList( + new LambdaQueryWrapper().eq(WikiEntityRelationEntity::getKbId, kbId)); + Map relTypeCounts = rels.stream() + .filter(r -> r.getPredicate() != null) + .collect(Collectors.groupingBy(WikiEntityRelationEntity::getPredicate, Collectors.counting())); + List relationTypes = relTypeCounts.entrySet().stream() + .sorted(Map.Entry.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 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 changed = "created".equals(kind) + ? pageService.findRecentCreated(kbId, since, safeLimit) + : pageService.findRecentUpdated(kbId, since, safeLimit); + + List changedPages = changed.stream() + .map(p -> new ChangedPage(p.getSlug(), p.getTitle(), p.getKnowledgeLayer(), + p.getUpdateTime(), null)) + .collect(Collectors.toList()); + + // Stale pages + List allPages = pageService.listByKbId(kbId); + List 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 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 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 listPages( + @PathVariable Long kbId, + @RequestParam(required = false) String pageType) { + List pages = pageService.listByKbId(kbId); + if (pageType != null && !pageType.isBlank()) { + pages = pages.stream() + .filter(p -> pageType.equals(p.getPageType())) + .collect(Collectors.toList()); + } + List 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)); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/kbopen/dto/KbOpenApiDtos.java b/mateclaw-server/src/main/java/vip/mate/kbopen/dto/KbOpenApiDtos.java new file mode 100644 index 00000000..bd60b084 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/kbopen/dto/KbOpenApiDtos.java @@ -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. + * + *

A5 constraint: the open API never serializes raw + * entities (WikiPageEntity, etc.) — every endpoint returns an explicit DTO + * to prevent accidental field leakage (IDOR / internal column exposure). + * + *

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 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 sources, + LocalDateTime extractedAt, + Integer pageVersion + ) {} + + public record SourceGroup( + Long rawId, + String rawTitle, + List citations + ) {} + + public record CitationDetail( + Long chunkId, + String snippet, + Double confidence, + Integer pageNumber + ) {} + + /** GET /taxonomy — type/scope enumeration map. */ + public record TaxonomyResult( + List pageTypes, + List entityTypes, + List 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 changedPages, + List 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 edges, + List 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 pages + ) {} + + /** Shared source reference. */ + public record SourceRef( + Set rawIds, + List rawTitles + ) {} +} diff --git a/mateclaw-server/src/main/java/vip/mate/kbopen/service/KbOpenApiService.java b/mateclaw-server/src/main/java/vip/mate/kbopen/service/KbOpenApiService.java new file mode 100644 index 00000000..81623fbf --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/kbopen/service/KbOpenApiService.java @@ -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. + * + *

A6 constraint: returns pure DTOs, never touches HttpServletRequest/R. + */ +@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 metadata = parseMetadata(page.getMetadataJson()); + if (fields != null && !fields.isBlank() && "summary".equals(mode)) { + Set 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 rawIds = parseRawIds(page.getSourceRawIds()); + if (rawIds.isEmpty()) return null; + // rawTitles are in sourceEntries (JSON array of {rawId, rawTitle}) if available + List 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 visited = new LinkedHashSet<>(); + visited.add(primaryEntityId); + List allEdges = new ArrayList<>(); + Set 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 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 out, Set neighborIds, Set visited) { + LambdaQueryWrapper q = new LambdaQueryWrapper() + .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 mentions = mentionMapper.selectList( + new LambdaQueryWrapper() + .eq(WikiEntityMentionEntity::getPageId, pageId) + .orderByDesc(WikiEntityMentionEntity::getConfidence) + .last("LIMIT 1")); + return mentions.isEmpty() ? null : mentions.get(0).getEntityId(); + } + + private String resolveSlugForEntity(Long entityId) { + List mentions = mentionMapper.selectList( + new LambdaQueryWrapper() + .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 mentions = mentionMapper.selectList( + new LambdaQueryWrapper() + .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 parseMetadata(String json) { + if (json == null || json.isBlank()) return Map.of(); + try { + return objectMapper.readValue(json, new TypeReference>() {}); + } catch (Exception e) { + return Map.of(); + } + } + + @SuppressWarnings("unchecked") + private Set parseRawIds(String json) { + if (json == null || json.isBlank()) return Set.of(); + try { + List ids = objectMapper.readValue(json, new TypeReference>() {}); + return ids.stream().map(Integer::longValue).collect(Collectors.toCollection(LinkedHashSet::new)); + } catch (Exception e) { + return Set.of(); + } + } + + @SuppressWarnings("unchecked") + private List parseSourceTitles(String json) { + if (json == null || json.isBlank()) return List.of(); + try { + List> entries = objectMapper.readValue(json, new TypeReference>>() {}); + return entries.stream() + .map(e -> String.valueOf(e.getOrDefault("rawTitle", ""))) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toList()); + } catch (Exception e) { + return List.of(); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiRawMaterialEntity.java b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiRawMaterialEntity.java index 9e765e9b..e770fc4a 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiRawMaterialEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiRawMaterialEntity.java @@ -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) diff --git a/mateclaw-server/src/test/java/vip/mate/kbopen/auth/KbApiKeyServiceTest.java b/mateclaw-server/src/test/java/vip/mate/kbopen/auth/KbApiKeyServiceTest.java index a44c25f7..1dd662a7 100644 --- a/mateclaw-server/src/test/java/vip/mate/kbopen/auth/KbApiKeyServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/kbopen/auth/KbApiKeyServiceTest.java @@ -12,6 +12,7 @@ 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 @@ void createAndAuthenticateRoundTrip() { // 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 @@ void authenticateExpired() { 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 @@ void authenticateDisabled() { 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 @@ void wildcardScopeGrantsAll() { 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(); diff --git a/mateclaw-server/src/test/java/vip/mate/kbopen/controller/KbOpenApiControllerTest.java b/mateclaw-server/src/test/java/vip/mate/kbopen/controller/KbOpenApiControllerTest.java new file mode 100644 index 00000000..cde00b41 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/kbopen/controller/KbOpenApiControllerTest.java @@ -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"); + } +}