mirror of
https://github.com/Tencent/WeKnora.git
synced 2026-09-19 02:18:25 +08:00
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:
@@ -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) {
|
||||
|
||||
@@ -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 node’s 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',
|
||||
|
||||
@@ -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}개의 대기 중인 지식 충돌 또는 오류가 있습니다",
|
||||
|
||||
@@ -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} неразрешенных конфликтов или ошибок знаний',
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user