perf(wiki): subgraph API and interactive exploration for large graphs

Wikis with tens of thousands of pages used to crash the browser trying
to render the entire link graph at once (30MB+ JSON, 100k+ SVG elements).
This change moves the graph viewer from "fetch everything, render
everything" to "fetch a slice, expand on demand" with several matching
UX improvements.

Backend

* GET /wiki/graph now accepts `mode` (overview | ego), `center`, `depth`,
  `types`, `limit` query params. Default is overview top-500 by
  link_count, capped at 2000. Response includes a Meta object so the
  frontend can render a truncation hint and drive the UI.
* Pure helper `computeGraphSubset` extracted for testability; six unit
  tests cover overview truncation / type filter / ego BFS / missing
  center error.
* WikiLintService passes Limit=0 (uncapped) so link integrity checks
  still walk every page.
* Repo `Search` adds CASE-based relevance ranking (title 4, slug 3,
  summary 2, content 1) so full-text results put the most obvious
  matches first instead of whatever was updated most recently.

Frontend

* Graph viewer fetches overview on entry; ego pivot on double-click,
  search, wiki-link click, URL ?slug=, global issues jump.
* Shift+click or hover ⊕ button blooms neighbors onto the current
  canvas additively. Bloom tracks generations and LRU-evicts oldest
  when total exceeds 1500 nodes; ego center / selected node / latest
  anchor are always protected.
* "Grow frontier (N)" legend action expands every dashed-ring node in
  one click (6-way concurrency) while skipping Index/Log super-nodes
  that would otherwise dump the whole wiki onto the canvas.
* Node dashed expansion ring + drawer "X/Y neighbors shown" hint tell
  users which nodes still have neighbors to load.
* Search dropdown uses remote full-text search (debounced, sequence-
  numbered to drop stale responses) and falls back to the overview
  top-500 snapshot when the input is empty.
* Type filters now round-trip to the server so top-N is always
  computed from the user's active type set rather than hiding nodes
  client-side and shrinking the view.
* Status card replaces the cramped "centered on X · N hops · M nodes"
  line with a structured focus/overview summary, resolving slugs to
  page titles.
* Help popover in the legend lists every canvas shortcut.

i18n entries added for zh-CN / en-US / ko-KR / ru-RU.
This commit is contained in:
wizardchen
2026-05-08 13:32:54 +08:00
committed by lyingbug
parent fc5d16e331
commit df1e9a1bfd
13 changed files with 1962 additions and 137 deletions
+36 -2
View File
@@ -37,9 +37,19 @@ export interface WikiPageListResponse {
total_pages: number;
}
export interface WikiGraphMeta {
mode: 'overview' | 'ego' | string;
total: number;
returned: number;
truncated: boolean;
center?: string;
depth?: number;
}
export interface WikiGraphData {
nodes: { slug: string; title: string; page_type: string; link_count: number }[];
edges: { source: string; target: string }[];
meta: WikiGraphMeta;
}
export interface WikiStats {
@@ -113,8 +123,32 @@ export function getWikiLog(kbId: string) {
return get(`/api/v1/knowledgebase/${kbId}/wiki/log`);
}
export function getWikiGraph(kbId: string) {
return get(`/api/v1/knowledgebase/${kbId}/wiki/graph`);
export interface WikiGraphQueryParams {
mode?: 'overview' | 'ego';
center?: string;
depth?: number;
types?: string[];
limit?: number;
}
// getWikiGraph fetches a slice of the wiki link graph. Without params the
// backend returns the top-500 most-connected pages (overview mode). Pass
// `mode: 'ego', center: <slug>` to drill into a specific page's neighborhood.
// For knowledge bases with tens of thousands of pages the overview cap is
// what prevents the browser from choking on a 30MB payload / 100k SVG nodes.
export function getWikiGraph(kbId: string, params?: WikiGraphQueryParams) {
const query = new URLSearchParams();
if (params) {
if (params.mode) query.set('mode', params.mode);
if (params.center) query.set('center', params.center);
if (params.depth !== undefined) query.set('depth', String(params.depth));
if (params.limit !== undefined) query.set('limit', String(params.limit));
if (params.types && params.types.length > 0) {
query.set('types', params.types.join(','));
}
}
const qs = query.toString();
return get(`/api/v1/knowledgebase/${kbId}/wiki/graph${qs ? '?' + qs : ''}`);
}
export function getWikiStats(kbId: string) {
+32
View File
@@ -1709,6 +1709,38 @@ export default {
graphNoData: 'No graph data yet. Upload documents first.',
showArrows: 'Show arrows',
hideArrows: 'Hide arrows',
expandNeighbors: 'Expand neighbors',
bloomNeighbors: 'Bloom neighbors',
growFrontier: 'Grow frontier ({count})',
growFrontierTitle: 'Expand all {count} dashed-ring nodes in one click',
cardEgoTitle: 'Current focus',
cardOverviewTitle: 'KB overview',
cardOverviewPrimary: '{returned} / {total} nodes',
cardOverviewHintTruncated: 'Double-click any node to focus on its subgraph',
cardOverviewHintFull: 'Showing every node in the knowledge base',
cardRelatedNodes: '{count} related nodes',
helpButtonTitle: 'Help',
helpTitle: 'Canvas actions',
helpClickAction: 'Click',
helpClickDesc: 'Open node details',
helpDblClickAction: 'Double-click',
helpDblClickDesc: 'Focus on this node',
helpShiftClickAction: 'Shift + click',
helpShiftClickDesc: 'Bloom neighbors onto canvas',
helpHoverPlusAction: 'Hover → ⊕',
helpHoverPlusDesc: 'Same as Shift + click',
helpDragAction: 'Drag node',
helpDragDesc: 'Reposition the node',
helpPanAction: 'Drag empty space',
helpPanDesc: 'Pan the canvas',
helpZoomAction: 'Scroll wheel',
helpZoomDesc: 'Zoom the canvas',
neighborsProgress: 'Showing {visible} of {total} neighbors ({hidden} not loaded)',
neighborsAllShown: 'All {total} neighbors are already on the canvas (click Expand to focus on this nodes subgraph)',
neighborsNone: 'This page has no linked neighbors',
neighborsCenterUnreachable: 'Showing {visible} of {total} neighbors ({hidden} unreachable: dead links, filtered, or deleted)',
neighborsOverviewHidden: 'Showing {visible} of {total} neighbors ({hidden} outside the overview — click Expand to pivot here)',
backToOverview: 'Back to overview',
healthCheck: 'Health Check',
queueStatus: '{count} pending tasks in Wiki queue',
issueTitle: 'This page has {count} pending knowledge conflicts or errors',
+32
View File
@@ -2321,6 +2321,38 @@ export default {
graphNoData: '그래프 데이터가 없습니다. 먼저 문서를 업로드하세요.',
showArrows: '화살표 표시',
hideArrows: '화살표 숨기기',
expandNeighbors: '이웃 확장',
bloomNeighbors: '이웃 추가',
growFrontier: '경계 확장 ({count})',
growFrontierTitle: '점선 링이 있는 {count}개 노드를 한 번에 확장',
cardEgoTitle: '현재 초점',
cardOverviewTitle: 'KB 개요',
cardOverviewPrimary: '{returned} / {total} 개 노드',
cardOverviewHintTruncated: '노드를 더블클릭하여 서브그래프에 집중',
cardOverviewHintFull: '지식 베이스의 모든 노드 표시 중',
cardRelatedNodes: '관련 노드 {count}개',
helpButtonTitle: '도움말',
helpTitle: '캔버스 작업',
helpClickAction: '클릭',
helpClickDesc: '노드 상세 보기',
helpDblClickAction: '더블클릭',
helpDblClickDesc: '이 노드에 집중',
helpShiftClickAction: 'Shift + 클릭',
helpShiftClickDesc: '이웃을 캔버스에 추가',
helpHoverPlusAction: '호버 → ⊕',
helpHoverPlusDesc: 'Shift + 클릭과 동일',
helpDragAction: '노드 드래그',
helpDragDesc: '노드 위치 조정',
helpPanAction: '빈 공간 드래그',
helpPanDesc: '캔버스 이동',
helpZoomAction: '스크롤 휠',
helpZoomDesc: '캔버스 확대/축소',
neighborsProgress: '이웃 {visible}/{total} 표시됨 ({hidden}개 미로딩)',
neighborsAllShown: '모든 {total}개 이웃이 이미 캔버스에 있습니다 (확장을 클릭하여 이 노드의 서브그래프에 집중)',
neighborsNone: '이 페이지에는 연결된 이웃이 없습니다',
neighborsCenterUnreachable: '이웃 {visible}/{total} 표시됨 ({hidden}개 연결 불가: 끊긴 링크, 필터됨 또는 삭제됨)',
neighborsOverviewHidden: '이웃 {visible}/{total} 표시됨 ({hidden}개는 개요 범위 밖 — 확장을 클릭하여 이곳으로 전환)',
backToOverview: '개요로 돌아가기',
healthCheck: "상태 점검",
queueStatus: "Wiki 대기열에 {count}개의 보류 중인 작업",
issueTitle: "이 페이지에는 {count}개의 대기 중인 지식 충돌 또는 오류가 있습니다",
+32
View File
@@ -1931,6 +1931,38 @@ export default {
graphNoData: 'Нет данных графа. Сначала загрузите документы.',
showArrows: 'Показать стрелки',
hideArrows: 'Скрыть стрелки',
expandNeighbors: 'Развернуть соседей',
bloomNeighbors: 'Добавить соседей',
growFrontier: 'Расширить границу ({count})',
growFrontierTitle: 'Развернуть все {count} узлов с пунктирным кольцом за один клик',
cardEgoTitle: 'Текущий фокус',
cardOverviewTitle: 'Обзор БЗ',
cardOverviewPrimary: '{returned} / {total} узлов',
cardOverviewHintTruncated: 'Дважды щёлкните узел, чтобы сфокусироваться на его подграфе',
cardOverviewHintFull: 'Показаны все узлы базы знаний',
cardRelatedNodes: '{count} связанных узлов',
helpButtonTitle: 'Справка',
helpTitle: 'Действия на холсте',
helpClickAction: 'Клик',
helpClickDesc: 'Открыть детали узла',
helpDblClickAction: 'Двойной клик',
helpDblClickDesc: 'Сфокусироваться на узле',
helpShiftClickAction: 'Shift + клик',
helpShiftClickDesc: 'Добавить соседей на холст',
helpHoverPlusAction: 'Наведение → ⊕',
helpHoverPlusDesc: 'То же, что Shift + клик',
helpDragAction: 'Перетащить узел',
helpDragDesc: 'Изменить положение узла',
helpPanAction: 'Перетащить пустое место',
helpPanDesc: 'Переместить холст',
helpZoomAction: 'Колесо мыши',
helpZoomDesc: 'Масштабировать холст',
neighborsProgress: 'Показано {visible} из {total} соседей (еще {hidden} не загружено)',
neighborsAllShown: 'Все {total} соседей уже на холсте (нажмите «Развернуть», чтобы сфокусироваться на подграфе этого узла)',
neighborsNone: 'У этой страницы нет связанных соседей',
neighborsCenterUnreachable: 'Показано {visible} из {total} соседей ({hidden} недоступны: мёртвые ссылки, отфильтрованы или удалены)',
neighborsOverviewHidden: 'Показано {visible} из {total} соседей ({hidden} вне обзора — нажмите «Развернуть», чтобы переключиться сюда)',
backToOverview: 'Вернуться к обзору',
healthCheck: 'Проверка здоровья',
queueStatus: '{count} ожидающих задач в очереди Wiki',
issueTitle: 'На этой странице есть {count} неразрешенных конфликтов или ошибок знаний',
+32
View File
@@ -2292,6 +2292,38 @@ export default {
graphNoData: '暂无图谱数据,请先上传文档',
showArrows: '显示箭头',
hideArrows: '隐藏箭头',
expandNeighbors: '展开邻居',
bloomNeighbors: '叠加邻居',
growFrontier: '扩张边缘 ({count})',
growFrontierTitle: '一键展开当前所有 {count} 个带虚线环的节点',
cardEgoTitle: '当前焦点',
cardOverviewTitle: '全库概览',
cardOverviewPrimary: '{returned} / {total} 个节点',
cardOverviewHintTruncated: '双击任意节点聚焦到该节点的子图',
cardOverviewHintFull: '已展示知识库全部节点',
cardRelatedNodes: '{count} 个相关节点',
helpButtonTitle: '操作帮助',
helpTitle: '画布操作',
helpClickAction: '单击',
helpClickDesc: '打开节点详情',
helpDblClickAction: '双击',
helpDblClickDesc: '以该节点为中心聚焦',
helpShiftClickAction: 'Shift + 单击',
helpShiftClickDesc: '叠加该节点邻居到画布',
helpHoverPlusAction: '悬浮 → ⊕',
helpHoverPlusDesc: '同 Shift + 单击',
helpDragAction: '拖拽节点',
helpDragDesc: '手动调整节点位置',
helpPanAction: '拖拽空白',
helpPanDesc: '平移画布',
helpZoomAction: '滚轮',
helpZoomDesc: '缩放画布',
neighborsProgress: '已显示邻居 {visible}/{total}(还有 {hidden} 个未加载)',
neighborsAllShown: '全部 {total} 个邻居已在图中(可点"展开邻居"聚焦到该节点子图)',
neighborsNone: '该节点没有链接邻居',
neighborsCenterUnreachable: '已显示邻居 {visible}/{total}(还有 {hidden} 个无法连通:可能是死链、被过滤或已删除)',
neighborsOverviewHidden: '已显示邻居 {visible}/{total}{hidden} 个不在概览范围,点"展开邻居"以此为中心查看)',
backToOverview: '返回概览',
healthCheck: "健康检查",
queueStatus: "Wiki 队列中 {count} 个待处理任务",
issueTitle: "此页面存在 {count} 个待处理的知识冲突或错误",
File diff suppressed because it is too large Load Diff
+26 -1
View File
@@ -329,6 +329,19 @@ func escapeLikePattern(s string) string {
// Search performs case-insensitive POSIX regex search on wiki pages within a knowledge base.
// The query is interpreted as a PostgreSQL regular expression (via ~*).
//
// Results are ranked by where the query hit, highest-relevance first:
//
// title hit → rank 4 (most obvious intent: user typed what the page is called)
// slug hit → rank 3 (url-like identifiers, direct jump)
// summary hit → rank 2 (short authored abstract)
// content hit → rank 1 (body mention — often surfaces unrelated pages whose
// prose merely mentions the query as trivia)
//
// Without this ranking, a user searching for "王新" on a 4万-page wiki will
// see pages like "华为" or "Index" ahead of the actual 王新 page just
// because they mention 王新 in their body and were updated more recently.
// updated_at stays as the tiebreaker so same-rank ties stay deterministic.
func (r *wikiPageRepository) Search(ctx context.Context, kbID string, query string, limit int) ([]*types.WikiPage, error) {
if limit <= 0 {
limit = 10
@@ -337,12 +350,24 @@ func (r *wikiPageRepository) Search(ctx context.Context, kbID string, query stri
limit = 50
}
// CASE expression is evaluated per-row during SELECT; we order by the
// alias so the DB only computes the rank once. Parameterized four
// times with the same regex to avoid coupling to GORM's positional
// arg rewriting quirks.
rankExpr := "CASE " +
"WHEN title ~* ? THEN 4 " +
"WHEN slug ~* ? THEN 3 " +
"WHEN summary ~* ? THEN 2 " +
"WHEN content ~* ? THEN 1 " +
"ELSE 0 END AS match_rank"
var pages []*types.WikiPage
if err := r.db.WithContext(ctx).
Select("*, "+rankExpr, query, query, query, query).
Where("knowledge_base_id = ? AND (title ~* ? OR content ~* ? OR summary ~* ? OR slug ~* ?)",
kbID, query, query, query, query).
Where("status != ?", "archived").
Order("updated_at DESC").
Order("match_rank DESC, updated_at DESC").
Limit(limit).
Find(&pages).Error; err != nil {
return nil, err
+13 -7
View File
@@ -14,12 +14,12 @@ import (
type WikiLintIssueType string
const (
LintIssueOrphanPage WikiLintIssueType = "orphan_page"
LintIssueBrokenLink WikiLintIssueType = "broken_link"
LintIssueStaleRef WikiLintIssueType = "stale_ref"
LintIssueOrphanPage WikiLintIssueType = "orphan_page"
LintIssueBrokenLink WikiLintIssueType = "broken_link"
LintIssueStaleRef WikiLintIssueType = "stale_ref"
LintIssueMissingCrossRef WikiLintIssueType = "missing_cross_ref"
LintIssueEmptyContent WikiLintIssueType = "empty_content"
LintIssueDuplicateSlug WikiLintIssueType = "duplicate_slug"
LintIssueEmptyContent WikiLintIssueType = "empty_content"
LintIssueDuplicateSlug WikiLintIssueType = "duplicate_slug"
)
// WikiLintIssueSeverity defines the severity of a lint issue
@@ -90,8 +90,14 @@ func (s *WikiLintService) RunLint(ctx context.Context, kbID string) (*WikiLintRe
return nil, fmt.Errorf("get stats: %w", err)
}
// Get graph for link analysis
graph, err := s.wikiService.GetGraph(ctx, kbID)
// Get graph for link analysis. Lint needs the FULL graph to detect
// orphans and broken links across every page, so we pass Limit=0
// which the service treats as "no cap".
graph, err := s.wikiService.GetGraph(ctx, &types.WikiGraphRequest{
KnowledgeBaseID: kbID,
Mode: types.WikiGraphModeOverview,
Limit: 0,
})
if err != nil {
return nil, fmt.Errorf("get graph: %w", err)
}
+230 -21
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"regexp"
"sort"
"strings"
"time"
@@ -258,50 +259,258 @@ func (s *wikiPageService) GetLog(ctx context.Context, kbID string) (*types.WikiP
return page, nil
}
// GetGraph returns the link graph data for visualization
func (s *wikiPageService) GetGraph(ctx context.Context, kbID string) (*types.WikiGraphData, error) {
pages, err := s.repo.ListAll(ctx, kbID)
// GetGraph returns a slice of the wiki link graph for visualization.
//
// Two modes are supported:
//
// - WikiGraphModeOverview (default): returns the top `Limit` pages sorted
// by link_count (in+out), plus every edge that connects two surviving
// nodes. This is what the frontend fetches on the first graph open —
// 4万-page wikis would otherwise ship ~30MB of JSON and crash the
// browser trying to render 100k SVG elements.
//
// - WikiGraphModeEgo: returns the BFS neighborhood of `Center` up to
// `Depth` undirected hops, capped at `Limit` total nodes. The
// frontend uses this to drill down when the user clicks / searches a
// node in the overview.
//
// `Types` is an optional page_type allow-list applied to both the candidate
// node set and (in ego mode) the frontier expansion. Leaving it empty means
// no type filter.
//
// `Limit <= 0` disables the cap entirely and is reserved for internal
// callers like the lint service that need to walk every page. The HTTP
// handler always clamps Limit into a safe range so external traffic can
// never opt out of truncation.
//
// Implementation note: pages are still fetched via repo.ListAll. At 4万
// pages that's ~10MB of rows + deserialization, which is already on the
// expensive side but still tractable and keeps the repository interface
// unchanged. Pushing the filter/top-N down into SQL is a follow-up step
// (cache layer + DB-side projection) — see CLAUDE.md plan.
func (s *wikiPageService) GetGraph(ctx context.Context, req *types.WikiGraphRequest) (*types.WikiGraphData, error) {
if req == nil {
return nil, errors.New("wiki graph request is required")
}
pages, err := s.repo.ListAll(ctx, req.KnowledgeBaseID)
if err != nil {
return nil, err
}
return computeGraphSubset(pages, req)
}
nodeMap := make(map[string]*types.WikiGraphNode)
var edges []types.WikiGraphEdge
// computeGraphSubset is the pure I/O-free core of GetGraph. It takes the
// full page list and a request description and returns the subgraph the
// caller asked for. Extracted from GetGraph so tests can exercise the
// mode/limit/type-filter behavior without plumbing a full repository mock.
func computeGraphSubset(pages []*types.WikiPage, req *types.WikiGraphRequest) (*types.WikiGraphData, error) {
mode := req.Mode
if mode == "" {
mode = types.WikiGraphModeOverview
}
// Build nodes
// Pre-compute link_count and the type allow-list used for candidate
// filtering. We keep the full page list around so ego mode can still
// traverse through neighbors whose type is in the allow-list.
typeAllow := make(map[string]bool, len(req.Types))
for _, t := range req.Types {
if t != "" {
typeAllow[t] = true
}
}
hasTypeFilter := len(typeAllow) > 0
pageBySlug := make(map[string]*types.WikiPage, len(pages))
linkCount := make(map[string]int, len(pages))
for _, p := range pages {
linkCount := len(p.InLinks) + len(p.OutLinks)
nodeMap[p.Slug] = &types.WikiGraphNode{
Slug: p.Slug,
Title: p.Title,
PageType: p.PageType,
LinkCount: linkCount,
pageBySlug[p.Slug] = p
linkCount[p.Slug] = len(p.InLinks) + len(p.OutLinks)
}
// Select the node slug set for the requested slice.
var selected map[string]struct{}
switch mode {
case types.WikiGraphModeEgo:
if req.Center == "" {
return nil, errors.New("ego graph requires a center slug")
}
if _, ok := pageBySlug[req.Center]; !ok {
return nil, fmt.Errorf("ego center slug %q not found", req.Center)
}
depth := req.Depth
if depth < 1 {
depth = 1
}
selected = bfsEgoSlugs(pageBySlug, req.Center, depth, typeAllow, req.Limit)
default:
// overview: keep only type-allowed candidates, sort by link_count desc, cap.
candidates := make([]*types.WikiPage, 0, len(pages))
for _, p := range pages {
if hasTypeFilter && !typeAllow[p.PageType] {
continue
}
candidates = append(candidates, p)
}
sort.SliceStable(candidates, func(i, j int) bool {
li := linkCount[candidates[i].Slug]
lj := linkCount[candidates[j].Slug]
if li != lj {
return li > lj
}
// Stable tiebreaker keeps the API deterministic between calls.
return candidates[i].Slug < candidates[j].Slug
})
if req.Limit > 0 && len(candidates) > req.Limit {
candidates = candidates[:req.Limit]
}
selected = make(map[string]struct{}, len(candidates))
for _, p := range candidates {
selected[p.Slug] = struct{}{}
}
}
// Build edges from outbound links
// Build nodes from the selected set.
nodes := make([]types.WikiGraphNode, 0, len(selected))
for slug := range selected {
p := pageBySlug[slug]
nodes = append(nodes, types.WikiGraphNode{
Slug: p.Slug,
Title: p.Title,
PageType: p.PageType,
LinkCount: linkCount[slug],
})
}
// Deterministic node ordering — the map iteration above is random.
sort.Slice(nodes, func(i, j int) bool {
if nodes[i].LinkCount != nodes[j].LinkCount {
return nodes[i].LinkCount > nodes[j].LinkCount
}
return nodes[i].Slug < nodes[j].Slug
})
// Build edges, keeping only edges whose endpoints both survived selection.
var edges []types.WikiGraphEdge
for _, p := range pages {
if _, ok := selected[p.Slug]; !ok {
continue
}
for _, target := range p.OutLinks {
if _, exists := nodeMap[target]; exists {
edges = append(edges, types.WikiGraphEdge{
Source: p.Slug,
Target: target,
})
if _, ok := selected[target]; !ok {
continue
}
edges = append(edges, types.WikiGraphEdge{
Source: p.Slug,
Target: target,
})
}
}
// total is the count of candidate nodes before truncation — i.e. the
// population the frontend would need to fetch if it asked for the
// whole graph. For overview this respects the type filter; for ego
// it is the total KB page count (the user still sees "X of Y" based
// on the full wiki, not a filtered denominator).
total := len(pages)
if mode == types.WikiGraphModeOverview && hasTypeFilter {
total = 0
for _, p := range pages {
if typeAllow[p.PageType] {
total++
}
}
}
nodes := make([]types.WikiGraphNode, 0, len(nodeMap))
for _, n := range nodeMap {
nodes = append(nodes, *n)
meta := types.WikiGraphMeta{
Mode: mode,
Total: total,
Returned: len(nodes),
Truncated: len(nodes) < total,
}
if mode == types.WikiGraphModeEgo {
meta.Center = req.Center
meta.Depth = req.Depth
if meta.Depth < 1 {
meta.Depth = 1
}
}
return &types.WikiGraphData{
Nodes: nodes,
Edges: edges,
Meta: meta,
}, nil
}
// bfsEgoSlugs computes the undirected BFS neighborhood of `center` up to
// `depth` hops using both inbound and outbound links. Type-filtered pages
// are excluded from the result but are also NOT traversed through — so a
// filter that hides "index" pages will not leak the whole wiki via the
// index. The caller guarantees center exists in pageBySlug.
func bfsEgoSlugs(
pageBySlug map[string]*types.WikiPage,
center string,
depth int,
typeAllow map[string]bool,
limit int,
) map[string]struct{} {
hasTypeFilter := len(typeAllow) > 0
centerPage, ok := pageBySlug[center]
if !ok {
return map[string]struct{}{}
}
// If the center itself fails the type filter we honor the filter and
// return an empty set — the handler will surface Returned=0.
if hasTypeFilter && !typeAllow[centerPage.PageType] {
return map[string]struct{}{}
}
visited := map[string]struct{}{center: {}}
frontier := []string{center}
for hop := 0; hop < depth; hop++ {
if limit > 0 && len(visited) >= limit {
break
}
next := make([]string, 0, len(frontier))
for _, slug := range frontier {
p, ok := pageBySlug[slug]
if !ok {
continue
}
neighbors := make([]string, 0, len(p.OutLinks)+len(p.InLinks))
neighbors = append(neighbors, p.OutLinks...)
neighbors = append(neighbors, p.InLinks...)
for _, nb := range neighbors {
if _, seen := visited[nb]; seen {
continue
}
np, exists := pageBySlug[nb]
if !exists {
continue
}
if hasTypeFilter && !typeAllow[np.PageType] {
continue
}
visited[nb] = struct{}{}
next = append(next, nb)
if limit > 0 && len(visited) >= limit {
break
}
}
if limit > 0 && len(visited) >= limit {
break
}
}
frontier = next
if len(frontier) == 0 {
break
}
}
return visited
}
// GetStats returns aggregate statistics about the wiki
func (s *wikiPageService) GetStats(ctx context.Context, kbID string) (*types.WikiStats, error) {
counts, err := s.repo.CountByType(ctx, kbID)
@@ -128,3 +128,250 @@ func TestRemoveString(t *testing.T) {
t.Errorf("Expected 4 items (nothing removed), got %d", len(result2))
}
}
// makeGraphFixture builds a small synthetic wiki for GetGraph tests.
//
// Edges (directed):
//
// hub -> a, hub -> b, hub -> c, hub -> d
// a -> hub
// b -> hub
// c -> d
// x -> y (isolated 2-node cluster, disconnected from hub)
//
// Page types are chosen so type-filter tests can exclude specific nodes.
func makeGraphFixture() []*types.WikiPage {
return []*types.WikiPage{
{
Slug: "hub",
Title: "Hub",
PageType: types.WikiPageTypeSummary,
OutLinks: types.StringArray{"a", "b", "c", "d"},
InLinks: types.StringArray{"a", "b"},
},
{
Slug: "a",
Title: "A",
PageType: types.WikiPageTypeEntity,
OutLinks: types.StringArray{"hub"},
InLinks: types.StringArray{"hub"},
},
{
Slug: "b",
Title: "B",
PageType: types.WikiPageTypeEntity,
OutLinks: types.StringArray{"hub"},
InLinks: types.StringArray{"hub"},
},
{
Slug: "c",
Title: "C",
PageType: types.WikiPageTypeConcept,
OutLinks: types.StringArray{"d"},
InLinks: types.StringArray{"hub"},
},
{
Slug: "d",
Title: "D",
PageType: types.WikiPageTypeConcept,
OutLinks: types.StringArray{},
InLinks: types.StringArray{"hub", "c"},
},
{
Slug: "x",
Title: "X",
PageType: types.WikiPageTypeEntity,
OutLinks: types.StringArray{"y"},
InLinks: types.StringArray{},
},
{
Slug: "y",
Title: "Y",
PageType: types.WikiPageTypeEntity,
OutLinks: types.StringArray{},
InLinks: types.StringArray{"x"},
},
}
}
func nodeSlugs(data *types.WikiGraphData) map[string]bool {
out := make(map[string]bool, len(data.Nodes))
for _, n := range data.Nodes {
out[n.Slug] = true
}
return out
}
// TestComputeGraphSubset_OverviewTruncatesByLinkCount verifies that overview
// mode returns the most-connected nodes first and reports truncation
// honestly in Meta. At 4万 pages this is the path that must NOT return the
// full graph — the cap is what keeps the response size and frontend
// rendering tractable.
func TestComputeGraphSubset_OverviewTruncatesByLinkCount(t *testing.T) {
pages := makeGraphFixture()
got, err := computeGraphSubset(pages, &types.WikiGraphRequest{
Mode: types.WikiGraphModeOverview,
Limit: 3,
})
if err != nil {
t.Fatalf("computeGraphSubset: %v", err)
}
if len(got.Nodes) != 3 {
t.Errorf("want 3 nodes, got %d (%v)", len(got.Nodes), nodeSlugs(got))
}
slugs := nodeSlugs(got)
// hub has link_count 6 (4 out + 2 in), must survive the cap.
if !slugs["hub"] {
t.Errorf("expected hub to survive the top-3 cap, got %v", slugs)
}
if !got.Meta.Truncated {
t.Errorf("expected Meta.Truncated=true when returned < total")
}
if got.Meta.Total != len(pages) {
t.Errorf("Meta.Total = %d, want %d", got.Meta.Total, len(pages))
}
if got.Meta.Returned != len(got.Nodes) {
t.Errorf("Meta.Returned mismatch: %d vs %d", got.Meta.Returned, len(got.Nodes))
}
// Every returned edge must connect two surviving nodes.
for _, e := range got.Edges {
if !slugs[e.Source] || !slugs[e.Target] {
t.Errorf("edge %s->%s references a non-returned node", e.Source, e.Target)
}
}
}
// TestComputeGraphSubset_OverviewUncapped ensures the Limit<=0 escape hatch
// still works for internal callers (wiki lint) that need every page.
func TestComputeGraphSubset_OverviewUncapped(t *testing.T) {
pages := makeGraphFixture()
got, err := computeGraphSubset(pages, &types.WikiGraphRequest{
Mode: types.WikiGraphModeOverview,
Limit: 0,
})
if err != nil {
t.Fatalf("computeGraphSubset: %v", err)
}
if len(got.Nodes) != len(pages) {
t.Errorf("want %d nodes (uncapped), got %d", len(pages), len(got.Nodes))
}
if got.Meta.Truncated {
t.Errorf("expected Meta.Truncated=false when nothing was dropped")
}
}
// TestComputeGraphSubset_OverviewTypeFilter ensures the type filter applies
// to the candidate set (not just post-hoc), so "total" reflects the
// filter-aware denominator the frontend shows to the user.
func TestComputeGraphSubset_OverviewTypeFilter(t *testing.T) {
pages := makeGraphFixture()
got, err := computeGraphSubset(pages, &types.WikiGraphRequest{
Mode: types.WikiGraphModeOverview,
Types: []string{types.WikiPageTypeEntity},
Limit: 100,
})
if err != nil {
t.Fatalf("computeGraphSubset: %v", err)
}
slugs := nodeSlugs(got)
// Only entity-typed pages: a, b, x, y.
for _, expected := range []string{"a", "b", "x", "y"} {
if !slugs[expected] {
t.Errorf("expected entity %q to be returned, got %v", expected, slugs)
}
}
for _, forbidden := range []string{"hub", "c", "d"} {
if slugs[forbidden] {
t.Errorf("%q should have been filtered out by type, got %v", forbidden, slugs)
}
}
if got.Meta.Total != 4 {
t.Errorf("Meta.Total should equal entity-typed page count (4), got %d", got.Meta.Total)
}
}
// TestComputeGraphSubset_EgoDepth1 checks that depth=1 returns center +
// immediate neighbors only, with no transitive hops.
func TestComputeGraphSubset_EgoDepth1(t *testing.T) {
pages := makeGraphFixture()
got, err := computeGraphSubset(pages, &types.WikiGraphRequest{
Mode: types.WikiGraphModeEgo,
Center: "hub",
Depth: 1,
Limit: 100,
})
if err != nil {
t.Fatalf("computeGraphSubset: %v", err)
}
slugs := nodeSlugs(got)
// hub's direct neighbors: a, b, c, d (out) a, b (in) = {a, b, c, d} + hub.
want := []string{"hub", "a", "b", "c", "d"}
for _, s := range want {
if !slugs[s] {
t.Errorf("expected %q in ego depth=1 of hub, got %v", s, slugs)
}
}
// x and y are in a disconnected cluster and must not leak in.
if slugs["x"] || slugs["y"] {
t.Errorf("disconnected nodes leaked into ego graph: %v", slugs)
}
if got.Meta.Mode != types.WikiGraphModeEgo || got.Meta.Center != "hub" || got.Meta.Depth != 1 {
t.Errorf("Meta not populated correctly for ego: %+v", got.Meta)
}
}
// TestComputeGraphSubset_EgoDepth2 checks that an extra hop reaches pages
// only accessible through a one-hop neighbor (c -> d becomes reachable from
// "a" at depth 2 because a -> hub -> c -> d ... wait, that's 3 hops. Use
// a clearer case: from "a", depth=2 should reach hub's neighbors.)
func TestComputeGraphSubset_EgoDepth2ExpandsFrontier(t *testing.T) {
pages := makeGraphFixture()
depth1, err := computeGraphSubset(pages, &types.WikiGraphRequest{
Mode: types.WikiGraphModeEgo,
Center: "a",
Depth: 1,
Limit: 100,
})
if err != nil {
t.Fatalf("depth=1: %v", err)
}
depth2, err := computeGraphSubset(pages, &types.WikiGraphRequest{
Mode: types.WikiGraphModeEgo,
Center: "a",
Depth: 2,
Limit: 100,
})
if err != nil {
t.Fatalf("depth=2: %v", err)
}
if len(depth2.Nodes) <= len(depth1.Nodes) {
t.Errorf("depth=2 should expand the frontier beyond depth=1 (%d <= %d)",
len(depth2.Nodes), len(depth1.Nodes))
}
// At depth 2 from "a": a -> hub -> {b, c, d}. So b, c, d must appear.
slugs := nodeSlugs(depth2)
for _, s := range []string{"a", "hub", "b", "c", "d"} {
if !slugs[s] {
t.Errorf("expected %q at depth=2 from a, got %v", s, slugs)
}
}
}
// TestComputeGraphSubset_EgoRejectsMissingCenter ensures we fail fast
// rather than returning an empty graph when the caller points at a
// non-existent slug — an empty result here would look identical to "your
// wiki has no links to that page" and hide the real bug.
func TestComputeGraphSubset_EgoRejectsMissingCenter(t *testing.T) {
pages := makeGraphFixture()
_, err := computeGraphSubset(pages, &types.WikiGraphRequest{
Mode: types.WikiGraphModeEgo,
Center: "does-not-exist",
Depth: 1,
Limit: 100,
})
if err == nil {
t.Fatalf("expected error for missing center slug")
}
}
+84 -3
View File
@@ -323,12 +323,33 @@ func (h *WikiPageHandler) GetLog(c *gin.Context) {
c.JSON(http.StatusOK, page)
}
// Graph query parameter bounds. The defaults cap an `overview` request at
// 500 nodes — comfortably renderable in the frontend's hand-rolled SVG
// force simulation — while the hard max of 2000 is the upper bound a
// power user can opt into before rendering gets choppy. Ego depth is
// capped at 3 hops because the node population grows super-linearly with
// depth and wider searches are better served by repeated ego jumps.
const (
wikiGraphDefaultLimit = 500
wikiGraphMaxLimit = 2000
wikiGraphMaxDepth = 3
wikiGraphDefaultDepth = 1
)
// GetGraph godoc
// @Summary Get wiki link graph
// @Description Returns the wiki link graph data for visualization
// @Description Returns a slice of the wiki link graph for visualization. Supports
// @Description `mode=overview` (top-N most-connected pages, default) and
// @Description `mode=ego` (BFS neighborhood of a center slug) to keep response
// @Description size tractable for knowledge bases with tens of thousands of pages.
// @Tags Wiki
// @Produce json
// @Param kb_id path string true "Knowledge base ID"
// @Param kb_id path string true "Knowledge base ID"
// @Param mode query string false "overview (default) | ego"
// @Param center query string false "Center slug for ego mode"
// @Param depth query int false "Ego BFS depth (1-3, default 1)"
// @Param types query string false "Comma-separated page_type allow-list"
// @Param limit query int false "Max nodes to return (default 500, max 2000)"
// @Success 200 {object} types.WikiGraphData
// @Security Bearer
// @Router /api/v1/knowledgebase/{kb_id}/wiki/graph [get]
@@ -339,7 +360,67 @@ func (h *WikiPageHandler) GetGraph(c *gin.Context) {
return
}
graph, err := h.wikiService.GetGraph(c.Request.Context(), kbID)
mode := strings.TrimSpace(c.Query("mode"))
if mode == "" {
mode = types.WikiGraphModeOverview
}
if mode != types.WikiGraphModeOverview && mode != types.WikiGraphModeEgo {
c.JSON(http.StatusBadRequest, gin.H{"error": "mode must be 'overview' or 'ego'"})
return
}
center := strings.TrimSpace(c.Query("center"))
if mode == types.WikiGraphModeEgo && center == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "center is required when mode=ego"})
return
}
depth := wikiGraphDefaultDepth
if v := c.Query("depth"); v != "" {
parsed, parseErr := strconv.Atoi(v)
if parseErr != nil || parsed < 1 {
c.JSON(http.StatusBadRequest, gin.H{"error": "depth must be a positive integer"})
return
}
if parsed > wikiGraphMaxDepth {
parsed = wikiGraphMaxDepth
}
depth = parsed
}
limit := wikiGraphDefaultLimit
if v := c.Query("limit"); v != "" {
parsed, parseErr := strconv.Atoi(v)
if parseErr != nil || parsed < 1 {
c.JSON(http.StatusBadRequest, gin.H{"error": "limit must be a positive integer"})
return
}
if parsed > wikiGraphMaxLimit {
parsed = wikiGraphMaxLimit
}
limit = parsed
}
var typesFilter []string
if v := strings.TrimSpace(c.Query("types")); v != "" {
for _, t := range strings.Split(v, ",") {
t = strings.TrimSpace(t)
if t != "" {
typesFilter = append(typesFilter, t)
}
}
}
req := &types.WikiGraphRequest{
KnowledgeBaseID: kbID,
Mode: mode,
Center: center,
Depth: depth,
Types: typesFilter,
Limit: limit,
}
graph, err := h.wikiService.GetGraph(c.Request.Context(), req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
+7 -2
View File
@@ -56,8 +56,13 @@ type WikiPageService interface {
// Creates a default one if it doesn't exist.
GetLog(ctx context.Context, kbID string) (*types.WikiPage, error)
// GetGraph returns the link graph data for visualization.
GetGraph(ctx context.Context, kbID string) (*types.WikiGraphData, error)
// GetGraph returns the link graph data for visualization. The caller
// supplies a WikiGraphRequest describing the desired slice of the graph
// (overview top-N or ego neighborhood around a center slug). Callers
// that need the full graph (e.g. wiki lint) can set Limit <= 0 to
// disable the node cap; the HTTP handler always clamps Limit to a
// safe range before invoking the service.
GetGraph(ctx context.Context, req *types.WikiGraphRequest) (*types.WikiGraphData, error)
// GetStats returns aggregate statistics about the wiki.
GetStats(ctx context.Context, kbID string) (*types.WikiStats, error)
+41 -1
View File
@@ -198,10 +198,50 @@ type WikiPageListResponse struct {
TotalPages int `json:"total_pages"`
}
// WikiGraphData represents the link graph structure for visualization
// WikiGraphMode enumerates the graph query modes exposed to the API.
const (
// WikiGraphModeOverview returns the top-N most-connected pages as an
// overview of the knowledge base. Intended for the first graph open.
WikiGraphModeOverview = "overview"
// WikiGraphModeEgo returns the neighborhood around a center page up to a
// configurable depth. Intended for drill-down interactions.
WikiGraphModeEgo = "ego"
)
// WikiGraphRequest is the service-layer input for graph queries. It is
// populated by the HTTP handler from query params and passed down to the
// service, which is responsible for enforcing mode-specific semantics.
//
// Limit policy: a non-positive `Limit` means "no cap" and is reserved for
// internal callers (e.g. wiki lint) that need the full graph. The HTTP
// handler always clamps `Limit` into a safe range before calling the
// service so external traffic can never request an uncapped graph.
type WikiGraphRequest struct {
KnowledgeBaseID string
Mode string // "overview" (default) | "ego"
Center string // ego mode center slug (required when Mode == "ego")
Depth int // ego mode BFS depth, >= 1
Types []string // optional page_type filter; empty = no filter
Limit int // max nodes to return; <= 0 means uncapped
}
// WikiGraphData represents the link graph structure for visualization.
type WikiGraphData struct {
Nodes []WikiGraphNode `json:"nodes"`
Edges []WikiGraphEdge `json:"edges"`
Meta WikiGraphMeta `json:"meta"`
}
// WikiGraphMeta describes how the returned subgraph relates to the full
// knowledge base graph. The frontend uses `Truncated` to decide whether to
// surface a "showing X of Y" hint and to enable ego-expansion UI.
type WikiGraphMeta struct {
Mode string `json:"mode"`
Total int `json:"total"` // total node count in the KB before filtering/limit
Returned int `json:"returned"` // number of nodes actually returned
Truncated bool `json:"truncated"` // true when Returned < Total (after filters)
Center string `json:"center,omitempty"` // populated in ego mode
Depth int `json:"depth,omitempty"` // populated in ego mode
}
// WikiGraphNode represents a node in the wiki link graph