diff --git a/docs/docs.go b/docs/docs.go
index 5058b0c60..bd8edfc7d 100644
--- a/docs/docs.go
+++ b/docs/docs.go
@@ -6350,6 +6350,71 @@ const docTemplate = `{
}
}
},
+ "/knowledgebase/{kb_id}/wiki/categories": {
+ "get": {
+ "security": [
+ {
+ "Bearer": []
+ }
+ ],
+ "description": "Retrieve direct child directories for a wiki page type and optional parent_path",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Wiki"
+ ],
+ "summary": "List wiki category paths",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Knowledge base ID",
+ "name": "kb_id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Wiki page type; comma-separated for multiple (e.g. entity,concept)",
+ "name": "page_type",
+ "in": "query",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Parent category path, slash-separated",
+ "name": "parent_path",
+ "in": "query"
+ },
+ {
+ "type": "integer",
+ "description": "Page number (1-based)",
+ "name": "page",
+ "in": "query"
+ },
+ {
+ "type": "integer",
+ "description": "Page size",
+ "name": "page_size",
+ "in": "query"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/github_com_Tencent_WeKnora_internal_types.WikiCategoryPathListResponse"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/github_com_Tencent_WeKnora_internal_errors.AppError"
+ }
+ }
+ }
+ }
+ },
"/knowledgebase/{kb_id}/wiki/graph": {
"get": {
"security": [
@@ -6682,7 +6747,7 @@ const docTemplate = `{
},
{
"type": "string",
- "description": "Filter by page type",
+ "description": "Filter by page type; comma-separated for multiple (e.g. entity,concept)",
"name": "page_type",
"in": "query"
},
@@ -13730,6 +13795,9 @@ const docTemplate = `{
"dimension": {
"type": "integer"
},
+ "supports_dimension_override": {
+ "type": "boolean"
+ },
"truncate_prompt_tokens": {
"type": "integer"
}
@@ -17095,6 +17163,43 @@ const docTemplate = `{
"WebSearchProviderTypeSearxng"
]
},
+ "github_com_Tencent_WeKnora_internal_types.WikiCategoryPath": {
+ "type": "object",
+ "properties": {
+ "count": {
+ "type": "integer"
+ },
+ "path": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "github_com_Tencent_WeKnora_internal_types.WikiCategoryPathListResponse": {
+ "type": "object",
+ "properties": {
+ "page": {
+ "type": "integer"
+ },
+ "page_size": {
+ "type": "integer"
+ },
+ "paths": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/github_com_Tencent_WeKnora_internal_types.WikiCategoryPath"
+ }
+ },
+ "total": {
+ "type": "integer"
+ },
+ "total_pages": {
+ "type": "integer"
+ }
+ }
+ },
"github_com_Tencent_WeKnora_internal_types.WikiConfig": {
"type": "object",
"properties": {
@@ -17223,14 +17328,32 @@ const docTemplate = `{
"github_com_Tencent_WeKnora_internal_types.WikiIndexEntry": {
"type": "object",
"properties": {
+ "category_path": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "depth": {
+ "type": "integer"
+ },
+ "parent_slug": {
+ "type": "string"
+ },
"slug": {
"type": "string"
},
+ "sort_order": {
+ "type": "integer"
+ },
"summary": {
"type": "string"
},
"title": {
"type": "string"
+ },
+ "wiki_path": {
+ "type": "string"
}
}
},
@@ -17350,6 +17473,13 @@ const docTemplate = `{
"type": "string"
}
},
+ "category_path": {
+ "description": "CategoryPath is the directory breadcrumb that groups this page in the\nwiki browser, e.g. [\"AI\", \"LLM 应用\", \"RAG\"]. It is intentionally\nlabel-based so intermediate directory nodes do not need to be real pages.",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
"chunk_refs": {
"description": "ChunkRefs records the specific source-document chunks this page was\nbuilt from — one UUID per cited chunk. Populated during ingest from\nthe chunk-citation pass; refreshed wholesale whenever the page is\nre-materialized. Empty for summary pages (they are document-level\nsynopses and don't carry chunk-level citations). Use this when you\nneed to surface the underlying evidence for a wiki page, or to\nretract citations when a source document is deleted.",
"type": "array",
@@ -17373,6 +17503,10 @@ const docTemplate = `{
}
]
},
+ "depth": {
+ "description": "Depth is len(CategoryPath), cached for filtering / display.",
+ "type": "integer"
+ },
"id": {
"description": "Unique identifier (UUID)",
"type": "string"
@@ -17406,10 +17540,18 @@ const docTemplate = `{
"description": "Page type: summary, entity, concept, index, log, synthesis, comparison",
"type": "string"
},
+ "parent_slug": {
+ "description": "ParentSlug optionally points at the wiki page that should act as this\npage's semantic parent in the directory tree. The parent may be empty\nwhen the page is grouped only by CategoryPath.",
+ "type": "string"
+ },
"slug": {
"description": "URL-friendly slug for addressing, e.g. \"entity/acme-corp\", \"concept/rag\"\nUnique within a knowledge base",
"type": "string"
},
+ "sort_order": {
+ "description": "SortOrder allows generated or manually edited pages to control sibling\nordering before falling back to title.",
+ "type": "integer"
+ },
"source_refs": {
"description": "References to source knowledge IDs that contributed to this page.\nFormat matches the legacy \"\u003cknowledge_id\u003e|\u003cdoc_title\u003e\" convention used\nacross the ingest pipeline, so retract / display code can split on ` + "`" + `|` + "`" + `\nto recover the title. Document-level granularity.",
"type": "array",
@@ -17440,6 +17582,10 @@ const docTemplate = `{
"version": {
"description": "Version number. Incremented only when a user-visible content field\n(title, content, summary, page_type, status) actually changes; pure\nbookkeeping writes (link maintenance, same-content re-ingest, status\nsync from background jobs) leave it untouched so it can be used as a\nreal \"the page was edited\" signal.",
"type": "integer"
+ },
+ "wiki_path": {
+ "description": "WikiPath is a normalized, sortable path derived from page_type,\ncategory_path, and title. It keeps large directory listings cheap to sort.",
+ "type": "string"
}
}
},
@@ -18203,6 +18349,9 @@ const docTemplate = `{
"source": {
"description": "为空时按需默认为 \"remote\"",
"type": "string"
+ },
+ "supportsDimensionOverride": {
+ "type": "boolean"
}
}
},
@@ -18437,6 +18586,9 @@ const docTemplate = `{
"source": {
"description": "为空时按需默认为 \"remote\"",
"type": "string"
+ },
+ "supportsDimensionOverride": {
+ "type": "boolean"
}
}
},
diff --git a/docs/swagger.json b/docs/swagger.json
index a2f311860..39cdafc74 100644
--- a/docs/swagger.json
+++ b/docs/swagger.json
@@ -6343,6 +6343,71 @@
}
}
},
+ "/knowledgebase/{kb_id}/wiki/categories": {
+ "get": {
+ "security": [
+ {
+ "Bearer": []
+ }
+ ],
+ "description": "Retrieve direct child directories for a wiki page type and optional parent_path",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Wiki"
+ ],
+ "summary": "List wiki category paths",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Knowledge base ID",
+ "name": "kb_id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Wiki page type; comma-separated for multiple (e.g. entity,concept)",
+ "name": "page_type",
+ "in": "query",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Parent category path, slash-separated",
+ "name": "parent_path",
+ "in": "query"
+ },
+ {
+ "type": "integer",
+ "description": "Page number (1-based)",
+ "name": "page",
+ "in": "query"
+ },
+ {
+ "type": "integer",
+ "description": "Page size",
+ "name": "page_size",
+ "in": "query"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/github_com_Tencent_WeKnora_internal_types.WikiCategoryPathListResponse"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/github_com_Tencent_WeKnora_internal_errors.AppError"
+ }
+ }
+ }
+ }
+ },
"/knowledgebase/{kb_id}/wiki/graph": {
"get": {
"security": [
@@ -6675,7 +6740,7 @@
},
{
"type": "string",
- "description": "Filter by page type",
+ "description": "Filter by page type; comma-separated for multiple (e.g. entity,concept)",
"name": "page_type",
"in": "query"
},
@@ -13723,6 +13788,9 @@
"dimension": {
"type": "integer"
},
+ "supports_dimension_override": {
+ "type": "boolean"
+ },
"truncate_prompt_tokens": {
"type": "integer"
}
@@ -17088,6 +17156,43 @@
"WebSearchProviderTypeSearxng"
]
},
+ "github_com_Tencent_WeKnora_internal_types.WikiCategoryPath": {
+ "type": "object",
+ "properties": {
+ "count": {
+ "type": "integer"
+ },
+ "path": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "github_com_Tencent_WeKnora_internal_types.WikiCategoryPathListResponse": {
+ "type": "object",
+ "properties": {
+ "page": {
+ "type": "integer"
+ },
+ "page_size": {
+ "type": "integer"
+ },
+ "paths": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/github_com_Tencent_WeKnora_internal_types.WikiCategoryPath"
+ }
+ },
+ "total": {
+ "type": "integer"
+ },
+ "total_pages": {
+ "type": "integer"
+ }
+ }
+ },
"github_com_Tencent_WeKnora_internal_types.WikiConfig": {
"type": "object",
"properties": {
@@ -17216,14 +17321,32 @@
"github_com_Tencent_WeKnora_internal_types.WikiIndexEntry": {
"type": "object",
"properties": {
+ "category_path": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "depth": {
+ "type": "integer"
+ },
+ "parent_slug": {
+ "type": "string"
+ },
"slug": {
"type": "string"
},
+ "sort_order": {
+ "type": "integer"
+ },
"summary": {
"type": "string"
},
"title": {
"type": "string"
+ },
+ "wiki_path": {
+ "type": "string"
}
}
},
@@ -17343,6 +17466,13 @@
"type": "string"
}
},
+ "category_path": {
+ "description": "CategoryPath is the directory breadcrumb that groups this page in the\nwiki browser, e.g. [\"AI\", \"LLM 应用\", \"RAG\"]. It is intentionally\nlabel-based so intermediate directory nodes do not need to be real pages.",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
"chunk_refs": {
"description": "ChunkRefs records the specific source-document chunks this page was\nbuilt from — one UUID per cited chunk. Populated during ingest from\nthe chunk-citation pass; refreshed wholesale whenever the page is\nre-materialized. Empty for summary pages (they are document-level\nsynopses and don't carry chunk-level citations). Use this when you\nneed to surface the underlying evidence for a wiki page, or to\nretract citations when a source document is deleted.",
"type": "array",
@@ -17366,6 +17496,10 @@
}
]
},
+ "depth": {
+ "description": "Depth is len(CategoryPath), cached for filtering / display.",
+ "type": "integer"
+ },
"id": {
"description": "Unique identifier (UUID)",
"type": "string"
@@ -17399,10 +17533,18 @@
"description": "Page type: summary, entity, concept, index, log, synthesis, comparison",
"type": "string"
},
+ "parent_slug": {
+ "description": "ParentSlug optionally points at the wiki page that should act as this\npage's semantic parent in the directory tree. The parent may be empty\nwhen the page is grouped only by CategoryPath.",
+ "type": "string"
+ },
"slug": {
"description": "URL-friendly slug for addressing, e.g. \"entity/acme-corp\", \"concept/rag\"\nUnique within a knowledge base",
"type": "string"
},
+ "sort_order": {
+ "description": "SortOrder allows generated or manually edited pages to control sibling\nordering before falling back to title.",
+ "type": "integer"
+ },
"source_refs": {
"description": "References to source knowledge IDs that contributed to this page.\nFormat matches the legacy \"\u003cknowledge_id\u003e|\u003cdoc_title\u003e\" convention used\nacross the ingest pipeline, so retract / display code can split on `|`\nto recover the title. Document-level granularity.",
"type": "array",
@@ -17433,6 +17575,10 @@
"version": {
"description": "Version number. Incremented only when a user-visible content field\n(title, content, summary, page_type, status) actually changes; pure\nbookkeeping writes (link maintenance, same-content re-ingest, status\nsync from background jobs) leave it untouched so it can be used as a\nreal \"the page was edited\" signal.",
"type": "integer"
+ },
+ "wiki_path": {
+ "description": "WikiPath is a normalized, sortable path derived from page_type,\ncategory_path, and title. It keeps large directory listings cheap to sort.",
+ "type": "string"
}
}
},
@@ -18196,6 +18342,9 @@
"source": {
"description": "为空时按需默认为 \"remote\"",
"type": "string"
+ },
+ "supportsDimensionOverride": {
+ "type": "boolean"
}
}
},
@@ -18430,6 +18579,9 @@
"source": {
"description": "为空时按需默认为 \"remote\"",
"type": "string"
+ },
+ "supportsDimensionOverride": {
+ "type": "boolean"
}
}
},
diff --git a/docs/swagger.yaml b/docs/swagger.yaml
index 1f0ee667a..ec4d4f8ae 100644
--- a/docs/swagger.yaml
+++ b/docs/swagger.yaml
@@ -850,6 +850,8 @@ definitions:
properties:
dimension:
type: integer
+ supports_dimension_override:
+ type: boolean
truncate_prompt_tokens:
type: integer
type: object
@@ -3345,6 +3347,30 @@ definitions:
- WebSearchProviderTypeOllama
- WebSearchProviderTypeBaidu
- WebSearchProviderTypeSearxng
+ github_com_Tencent_WeKnora_internal_types.WikiCategoryPath:
+ properties:
+ count:
+ type: integer
+ path:
+ items:
+ type: string
+ type: array
+ type: object
+ github_com_Tencent_WeKnora_internal_types.WikiCategoryPathListResponse:
+ properties:
+ page:
+ type: integer
+ page_size:
+ type: integer
+ paths:
+ items:
+ $ref: '#/definitions/github_com_Tencent_WeKnora_internal_types.WikiCategoryPath'
+ type: array
+ total:
+ type: integer
+ total_pages:
+ type: integer
+ type: object
github_com_Tencent_WeKnora_internal_types.WikiConfig:
properties:
extraction_granularity:
@@ -3450,12 +3476,24 @@ definitions:
type: object
github_com_Tencent_WeKnora_internal_types.WikiIndexEntry:
properties:
+ category_path:
+ items:
+ type: string
+ type: array
+ depth:
+ type: integer
+ parent_slug:
+ type: string
slug:
type: string
+ sort_order:
+ type: integer
summary:
type: string
title:
type: string
+ wiki_path:
+ type: string
type: object
github_com_Tencent_WeKnora_internal_types.WikiIndexGroup:
properties:
@@ -3549,6 +3587,14 @@ definitions:
items:
type: string
type: array
+ category_path:
+ description: |-
+ CategoryPath is the directory breadcrumb that groups this page in the
+ wiki browser, e.g. ["AI", "LLM 应用", "RAG"]. It is intentionally
+ label-based so intermediate directory nodes do not need to be real pages.
+ items:
+ type: string
+ type: array
chunk_refs:
description: |-
ChunkRefs records the specific source-document chunks this page was
@@ -3571,6 +3617,9 @@ definitions:
allOf:
- $ref: '#/definitions/gorm.DeletedAt'
description: Soft delete
+ depth:
+ description: Depth is len(CategoryPath), cached for filtering / display.
+ type: integer
id:
description: Unique identifier (UUID)
type: string
@@ -3596,11 +3645,22 @@ definitions:
description: 'Page type: summary, entity, concept, index, log, synthesis,
comparison'
type: string
+ parent_slug:
+ description: |-
+ ParentSlug optionally points at the wiki page that should act as this
+ page's semantic parent in the directory tree. The parent may be empty
+ when the page is grouped only by CategoryPath.
+ type: string
slug:
description: |-
URL-friendly slug for addressing, e.g. "entity/acme-corp", "concept/rag"
Unique within a knowledge base
type: string
+ sort_order:
+ description: |-
+ SortOrder allows generated or manually edited pages to control sibling
+ ordering before falling back to title.
+ type: integer
source_refs:
description: |-
References to source knowledge IDs that contributed to this page.
@@ -3633,6 +3693,11 @@ definitions:
sync from background jobs) leave it untouched so it can be used as a
real "the page was edited" signal.
type: integer
+ wiki_path:
+ description: |-
+ WikiPath is a normalized, sortable path derived from page_type,
+ category_path, and title. It keeps large directory listings cheap to sort.
+ type: string
type: object
github_com_Tencent_WeKnora_internal_types.WikiPageIssue:
properties:
@@ -4159,6 +4224,8 @@ definitions:
source:
description: 为空时按需默认为 "remote"
type: string
+ supportsDimensionOverride:
+ type: boolean
required:
- modelName
type: object
@@ -4325,6 +4392,8 @@ definitions:
source:
description: 为空时按需默认为 "remote"
type: string
+ supportsDimensionOverride:
+ type: boolean
required:
- modelName
type: object
@@ -8834,6 +8903,49 @@ paths:
summary: Auto-fix wiki issues
tags:
- Wiki
+ /knowledgebase/{kb_id}/wiki/categories:
+ get:
+ description: Retrieve direct child directories for a wiki page type and optional
+ parent_path
+ parameters:
+ - description: Knowledge base ID
+ in: path
+ name: kb_id
+ required: true
+ type: string
+ - description: Wiki page type; comma-separated for multiple (e.g. entity,concept)
+ in: query
+ name: page_type
+ required: true
+ type: string
+ - description: Parent category path, slash-separated
+ in: query
+ name: parent_path
+ type: string
+ - description: Page number (1-based)
+ in: query
+ name: page
+ type: integer
+ - description: Page size
+ in: query
+ name: page_size
+ type: integer
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/github_com_Tencent_WeKnora_internal_types.WikiCategoryPathListResponse'
+ "400":
+ description: Bad Request
+ schema:
+ $ref: '#/definitions/github_com_Tencent_WeKnora_internal_errors.AppError'
+ security:
+ - Bearer: []
+ summary: List wiki category paths
+ tags:
+ - Wiki
/knowledgebase/{kb_id}/wiki/graph:
get:
description: |-
@@ -9050,7 +9162,7 @@ paths:
name: kb_id
required: true
type: string
- - description: Filter by page type
+ - description: Filter by page type; comma-separated for multiple (e.g. entity,concept)
in: query
name: page_type
type: string
diff --git a/internal/agent/prompts_wiki.go b/internal/agent/prompts_wiki.go
index f200a8c75..330af0228 100644
--- a/internal/agent/prompts_wiki.go
+++ b/internal/agent/prompts_wiki.go
@@ -1,5 +1,25 @@
package agent
+// wikiExistingTaxonomyXML is injected into the page-modify prompt so each page's
+// CATEGORY line can reuse folder labels already present in the knowledge base
+// instead of inventing synonyms. Category assignment happens at page-write
+// (reduce) time, where the LLM sees the whole entity — not per source document.
+const wikiExistingTaxonomyXML = `
+
+{{.ExistingTaxonomy}}
+
+`
+
+// wikiExistingTaxonomyContinuityRules extends Directory Taxonomy Rules with
+// cross-document folder reuse requirements.
+const wikiExistingTaxonomyContinuityRules = `
+- If above lists folders already used in this knowledge base, you MUST reuse those **exact folder labels** (character-for-character) when the new item belongs under the same domain. Do NOT invent synonym folders (e.g. do NOT create "春节习俗" when "春节" already has a "传统习俗" subfolder and that path fits semantically).
+- Prefer nesting under an existing parent folder over creating a new top-level folder with a similar name.
+- When multiple existing paths could fit, pick the most specific existing path whose labels match the document's section hierarchy.
+- Only introduce a new folder label when no existing folder is a reasonable match.
+- You may extend an existing path with one new trailing folder only when the document clearly introduces a genuinely new sub-section.
+`
+
// Wiki ingest prompt templates for LLM-powered wiki page generation.
// These prompts are used by the wiki ingest pipeline to extract structured
// knowledge from raw documents and build/update wiki pages.
@@ -313,8 +333,22 @@ The block above is assembled from VERBATIM source chunks that
{{.AvailableSlugs}}
+
+{{.CurrentCategory}}
+
+` + wikiExistingTaxonomyXML + `
1. The FIRST line of your output MUST be: SUMMARY: {one sentence, 15-40 words, describing what this page is about after the update — for wiki index listing}
+1b. The SECOND line of your output MUST be: CATEGORY: {a navigation taxonomy path for this page, folders from broad to narrow separated by " / ", e.g. "CATEGORY: 人物" or "CATEGORY: 组织 / 企业"}. Follow the Directory Taxonomy Rules below. Almost every page can be filed under at least one broad top-level folder, so leaving it empty should be rare.
+
+### Directory Taxonomy Rules (for the CATEGORY line)
+The CATEGORY classifies WHAT this {{.PageType}} fundamentally IS — a label that stays true across the whole knowledge base — never the role it happens to play in one document.
+- Litmus test: keep a label only if it would still fit when this same {{.PageType}} shows up in a completely unrelated document. If a label only describes how it relates to THIS document's topic (who organized, sponsored, acknowledged, authored, or took part in something), that is a ROLE, not a category — leave it out and let the page body and [[wiki-links]] carry the relationship.
+- Always assign at least the single broadest top-level folder that fits (think "which shelf would this always sit on in a library"). A coarse one-level category is expected for most pages; reserve a second level for a genuinely durable sub-domain.
+- Do NOT use the item type itself as a folder (e.g. "实体", "概念", "摘要").
+- Do NOT put slashes inside a single label. Use at most 2 levels.
+- Treat as the default and keep it unless new evidence clearly points to a better stable classification.
+- Only output an empty "CATEGORY:" in the rare case where the page genuinely cannot be classified under any broad, durable folder.` + wikiExistingTaxonomyContinuityRules + `
{{if .HasRetractions}}
2. REMOVE facts/claims that were ONLY sourced from the and are NOT present in any or .
{{end}}
@@ -332,12 +366,51 @@ The block above is assembled from VERBATIM source chunks that
6. Maintain the existing page structure and formatting style. Use "# {{.PageTitle}}" as the top-level heading if the page does not already have one. Do NOT introduce new heading levels beyond what the source or existing page justifies.
7. **Image rule**: Include relevant images using Markdown syntax:  from new information if applicable. The URL inside  is an opaque token; reproduce it EXACTLY and VERBATIM, do not alter, shorten, or normalize it.
{{if .HasRetractions}}
-8. If after removing deleted content the page becomes nearly empty and there is no new information to add, output just: "SUMMARY: (empty page)\n# {{.PageTitle}}\n\n*This page's primary source document was removed.*"
+8. If after removing deleted content the page becomes nearly empty and there is no new information to add, output just: "SUMMARY: (empty page)\nCATEGORY:\n# {{.PageTitle}}\n\n*This page's primary source document was removed.*"
{{end}}
9. Write in {{.Language}}.
-Output the SUMMARY line first, then the updated Markdown content. Do not include any other preamble.`
+Output the SUMMARY line first, then the CATEGORY line, then the updated Markdown content. Do not include any other preamble.`
+
+// WikiTaxonomyReconcilePrompt is a per-knowledge-base maintenance prompt. It is
+// given the full set of distinct category_path folders currently in use and asks
+// the LLM to collapse synonymous folders onto a single canonical path. The
+// result is applied in Go by re-pointing matching pages' category_path — slugs
+// never change. This runs occasionally (after a batch introduces new folders, or
+// on manual trigger), NOT per page, so its cost is O(folder count).
+const WikiTaxonomyReconcilePrompt = `You are a taxonomy curator for a wiki knowledge base. Below is the complete list of directory paths (category_path) currently used to file wiki pages. Some of them are synonyms or near-duplicates that should share ONE canonical path.
+
+
+{{.CurrentTaxonomy}}
+
+
+
+Identify groups of paths that mean the SAME thing and pick the single best canonical path for each group, then output a remap from every non-canonical path to its canonical path.
+
+Rules:
+- Only merge paths that are genuinely synonymous or where one is clearly a less-canonical spelling/wording of another (e.g. "春节习俗" → "春节 / 传统习俗"; "AI厂商" and "AI行业 / 厂商" → one of them).
+- Prefer the more standard, concise, and reusable label as the canonical target. Prefer an existing path over inventing a new one.
+- Do NOT merge paths that refer to DIFFERENT things, even if they look similar.
+- Do NOT merge across clearly different domains.
+- Do NOT output an entry whose "from" equals its "to".
+- A canonical "to" path may itself be one of the listed paths; never map a path onto a synonym chain (every "to" must be a final canonical path, not another "from").
+- Keep canonical paths to at most 2 levels.
+- Each path is an array of folder labels from broad to narrow. Write ALL labels in {{.Language}}.
+- If nothing should be merged, return {"remap": []}.
+
+### JSON Formatting Rules
+- Output ONLY valid JSON, no preamble.
+- Do NOT use literal newlines inside JSON string values.
+
+
+Output format:
+{
+ "remap": [
+ {"from": ["春节习俗"], "to": ["春节", "传统习俗"]},
+ {"from": ["AI厂商"], "to": ["AI行业", "厂商"]}
+ ]
+}`
// WikiIndexIntroPrompt generates the introduction for a NEW index page (first time only).
const WikiIndexIntroPrompt = `You are a wiki editor. Write a brief introduction for a wiki knowledge base index page.
diff --git a/internal/application/repository/wiki_page.go b/internal/application/repository/wiki_page.go
index e3bbb2cd9..ec536518c 100644
--- a/internal/application/repository/wiki_page.go
+++ b/internal/application/repository/wiki_page.go
@@ -28,6 +28,13 @@ func NewWikiPageRepository(db *gorm.DB) interfaces.WikiPageRepository {
return &wikiPageRepository{db: db}
}
+func (r *wikiPageRepository) wikiCategoryRankOrder() string {
+ if r.db != nil && r.db.Dialector != nil && r.db.Dialector.Name() == "sqlite" {
+ return "CASE WHEN COALESCE(json_array_length(category_path), 0) > 0 THEN 0 ELSE 1 END ASC"
+ }
+ return "CASE WHEN COALESCE(jsonb_array_length(category_path), 0) > 0 THEN 0 ELSE 1 END ASC"
+}
+
// Create inserts a new wiki page record
func (r *wikiPageRepository) Create(ctx context.Context, page *types.WikiPage) error {
return r.db.WithContext(ctx).Create(page).Error
@@ -101,6 +108,11 @@ func (r *wikiPageRepository) UpdateMeta(ctx context.Context, page *types.WikiPag
"source_refs": page.SourceRefs,
"chunk_refs": page.ChunkRefs,
"page_metadata": page.PageMetadata,
+ "parent_slug": page.ParentSlug,
+ "category_path": page.CategoryPath,
+ "wiki_path": page.WikiPath,
+ "depth": page.Depth,
+ "sort_order": page.SortOrder,
"updated_at": page.UpdatedAt,
})
if result.Error != nil {
@@ -138,13 +150,38 @@ func (r *wikiPageRepository) GetBySlug(ctx context.Context, kbID string, slug st
return &page, nil
}
+// splitWikiPageTypes parses a page_type filter that may carry several
+// comma-separated types (e.g. "entity,concept") into a deduplicated slice.
+// An empty/whitespace-only input yields nil, meaning "no type filter".
+func splitWikiPageTypes(raw string) []string {
+ if strings.TrimSpace(raw) == "" {
+ return nil
+ }
+ seen := make(map[string]struct{})
+ out := make([]string, 0, 4)
+ for _, part := range strings.Split(raw, ",") {
+ part = strings.TrimSpace(part)
+ if part == "" {
+ continue
+ }
+ if _, ok := seen[part]; ok {
+ continue
+ }
+ seen[part] = struct{}{}
+ out = append(out, part)
+ }
+ return out
+}
+
// List retrieves wiki pages with filtering and pagination
func (r *wikiPageRepository) List(ctx context.Context, req *types.WikiPageListRequest) ([]*types.WikiPage, int64, error) {
query := r.db.WithContext(ctx).Model(&types.WikiPage{}).
Where("knowledge_base_id = ?", req.KnowledgeBaseID)
- if req.PageType != "" {
- query = query.Where("page_type = ?", req.PageType)
+ if pageTypes := splitWikiPageTypes(req.PageType); len(pageTypes) == 1 {
+ query = query.Where("page_type = ?", pageTypes[0])
+ } else if len(pageTypes) > 1 {
+ query = query.Where("page_type IN ?", pageTypes)
}
if req.Status != "" {
query = query.Where("status = ?", req.Status)
@@ -157,6 +194,24 @@ func (r *wikiPageRepository) List(ctx context.Context, req *types.WikiPageListRe
"%"+req.Query+"%",
)
}
+ // Directory filters are pushed to SQL so the DB does the counting and
+ // pagination instead of loading every page of the type into memory. `depth`
+ // is a cached column (= len(category_path)); `category_path` is a JSON column
+ // whose stored text is json.Marshal of the cleaned path, so we compare
+ // against the same encoding. Postgres needs an explicit jsonb cast for array
+ // equality; SQLite stores JSON as TEXT and compares directly.
+ if req.CategoryDepth != nil {
+ query = query.Where("depth = ?", *req.CategoryDepth)
+ }
+ if wantPath := cleanWikiRepositoryCategoryPath(req.CategoryPath); len(wantPath) > 0 {
+ if encoded, err := json.Marshal([]string(wantPath)); err == nil {
+ if r.db.Dialector != nil && r.db.Dialector.Name() == "postgres" {
+ query = query.Where("category_path::jsonb = ?::jsonb", string(encoded))
+ } else {
+ query = query.Where("category_path = ?", string(encoded))
+ }
+ }
+ }
var total int64
if err := query.Count(&total).Error; err != nil {
@@ -167,7 +222,7 @@ func (r *wikiPageRepository) List(ctx context.Context, req *types.WikiPageListRe
sortBy := "updated_at"
if req.SortBy != "" {
switch req.SortBy {
- case "title", "created_at", "updated_at", "page_type":
+ case "title", "created_at", "updated_at", "page_type", "wiki_path", "sort_order", "depth":
sortBy = req.SortBy
}
}
@@ -175,9 +230,15 @@ func (r *wikiPageRepository) List(ctx context.Context, req *types.WikiPageListRe
if req.SortOrder == "asc" {
sortOrder = "ASC"
}
- query = query.Order(fmt.Sprintf("%s %s", sortBy, sortOrder))
+ if sortBy == "wiki_path" {
+ query = query.Order(r.wikiCategoryRankOrder()).
+ Order(fmt.Sprintf("wiki_path %s", sortOrder)).
+ Order("sort_order ASC").
+ Order("title ASC")
+ } else {
+ query = query.Order(fmt.Sprintf("%s %s", sortBy, sortOrder))
+ }
- // Pagination
page := req.Page
if page < 1 {
page = 1
@@ -187,6 +248,8 @@ func (r *wikiPageRepository) List(ctx context.Context, req *types.WikiPageListRe
pageSize = 20
}
offset := (page - 1) * pageSize
+
+ // Pagination
query = query.Offset(offset).Limit(pageSize)
var pages []*types.WikiPage
@@ -249,7 +312,10 @@ func (r *wikiPageRepository) ListByTypeLight(
var entries []types.WikiIndexEntry
if err := base.
- Select("slug", "title", "summary").
+ Select("slug", "title", "summary", "parent_slug", "category_path", "wiki_path", "depth", "sort_order").
+ Order(r.wikiCategoryRankOrder()).
+ Order("wiki_path ASC").
+ Order("sort_order ASC").
Order("title ASC").
Limit(limit).
Offset(offset).
@@ -368,6 +434,199 @@ func (r *wikiPageRepository) ListBySlugs(
return out, nil
}
+// ListDistinctCategoryPaths scans entity/concept pages and returns unique
+// non-empty category_path values. Rows are read in wiki_path order so the
+// prompt tree is stable; deduplication happens in Go with a maxPaths cap.
+func (r *wikiPageRepository) ListDistinctCategoryPaths(
+ ctx context.Context,
+ kbID string,
+ maxPaths int,
+) ([][]string, error) {
+ if maxPaths <= 0 {
+ maxPaths = 150
+ }
+
+ type row struct {
+ CategoryPath types.StringArray `gorm:"column:category_path"`
+ }
+ var rows []row
+ if err := r.db.WithContext(ctx).
+ Model(&types.WikiPage{}).
+ Select("category_path").
+ Where("knowledge_base_id = ? AND page_type IN ? AND status <> ?",
+ kbID,
+ []string{types.WikiPageTypeEntity, types.WikiPageTypeConcept},
+ types.WikiPageStatusArchived,
+ ).
+ Order(r.wikiCategoryRankOrder()).
+ Order("wiki_path ASC").
+ Scan(&rows).Error; err != nil {
+ return nil, err
+ }
+
+ seen := make(map[string]struct{}, len(rows))
+ out := make([][]string, 0, len(rows))
+ for _, row := range rows {
+ if len(row.CategoryPath) == 0 {
+ continue
+ }
+ clean := cleanWikiRepositoryCategoryPath(row.CategoryPath)
+ if len(clean) == 0 {
+ continue
+ }
+ key := strings.Join(clean, "\x00")
+ if _, ok := seen[key]; ok {
+ continue
+ }
+ seen[key] = struct{}{}
+ out = append(out, clean)
+ if len(out) >= maxPaths {
+ break
+ }
+ }
+ return out, nil
+}
+
+// ListDistinctCategoryPathsByType scans one page type and returns unique
+// non-empty category_path values. Unlike ListDistinctCategoryPaths, this is
+// used by the UI and must work for every visible page_type bucket.
+func (r *wikiPageRepository) ListDistinctCategoryPathsByType(
+ ctx context.Context,
+ kbID string,
+ pageTypes []string,
+ parentPath []string,
+ maxPaths int,
+) ([]types.WikiCategoryPath, error) {
+ if maxPaths <= 0 {
+ maxPaths = 2000
+ }
+ if len(pageTypes) == 0 {
+ return nil, nil
+ }
+
+ type row struct {
+ CategoryPath types.StringArray `gorm:"column:category_path"`
+ }
+ var rows []row
+ if err := r.db.WithContext(ctx).
+ Model(&types.WikiPage{}).
+ Select("category_path").
+ Where("knowledge_base_id = ? AND page_type IN ? AND status <> ?",
+ kbID,
+ pageTypes,
+ types.WikiPageStatusArchived,
+ ).
+ Order(r.wikiCategoryRankOrder()).
+ Order("wiki_path ASC").
+ Scan(&rows).Error; err != nil {
+ return nil, err
+ }
+
+ parent := cleanWikiRepositoryCategoryPath(parentPath)
+
+ counts := make(map[string]int64, len(rows))
+ pathsByKey := make(map[string][]string, len(rows))
+ order := make([]string, 0, len(rows))
+ for _, row := range rows {
+ if len(row.CategoryPath) == 0 {
+ continue
+ }
+ clean := cleanWikiRepositoryCategoryPath(row.CategoryPath)
+ if len(clean) == 0 {
+ continue
+ }
+ if len(clean) <= len(parent) || !wikiPathHasPrefix(clean, parent) {
+ continue
+ }
+ path := clean[:len(parent)+1]
+ key := strings.Join(path, "\x00")
+ if _, ok := pathsByKey[key]; !ok {
+ cp := append([]string(nil), path...)
+ pathsByKey[key] = cp
+ order = append(order, key)
+ }
+ counts[key]++
+ }
+
+ out := make([]types.WikiCategoryPath, 0, len(order))
+ for _, key := range order {
+ out = append(out, types.WikiCategoryPath{
+ Path: pathsByKey[key],
+ Count: counts[key],
+ })
+ }
+ return out, nil
+}
+
+func cleanWikiRepositoryCategoryPath(parts []string) []string {
+ cleaned := make([]string, 0, len(parts))
+ for _, part := range parts {
+ for _, label := range cleanWikiRepositoryCategoryPart(part) {
+ if containsWikiRepositoryString(cleaned, label) {
+ continue
+ }
+ cleaned = append(cleaned, label)
+ }
+ }
+ return cleaned
+}
+
+func cleanWikiRepositoryCategoryPart(part string) []string {
+ part = strings.TrimSpace(part)
+ if part == "" {
+ return nil
+ }
+
+ part = strings.NewReplacer("/", "/", "|", "/", "|", "/").Replace(part)
+ rawParts := strings.Split(part, "/")
+ cleaned := make([]string, 0, len(rawParts))
+ for _, raw := range rawParts {
+ label := strings.TrimSpace(raw)
+ label = strings.Trim(label, `"'“”‘’[]()()`)
+ label = strings.TrimSpace(label)
+ if label == "" || isWikiRepositoryTypeCategoryLabel(label) {
+ continue
+ }
+ cleaned = append(cleaned, label)
+ }
+ return cleaned
+}
+
+func isWikiRepositoryTypeCategoryLabel(label string) bool {
+ normalized := strings.ToLower(strings.TrimSpace(label))
+ normalized = strings.TrimSuffix(normalized, "s")
+ switch normalized {
+ case "entity", "实体", "實體", "concept", "概念", "summary", "摘要", "wiki", "页面", "頁面":
+ return true
+ default:
+ return false
+ }
+}
+
+func containsWikiRepositoryString(slice []string, s string) bool {
+ for _, v := range slice {
+ if v == s {
+ return true
+ }
+ }
+ return false
+}
+
+func wikiPathHasPrefix(path []string, prefix []string) bool {
+ if len(prefix) == 0 {
+ return true
+ }
+ if len(path) < len(prefix) {
+ return false
+ }
+ for i, part := range prefix {
+ if path[i] != part {
+ return false
+ }
+ }
+ return true
+}
+
// ListSummariesByKnowledgeIDs returns summary-page content keyed by the
// knowledge id that authored it. The page_type filter is applied first
// (only summary pages have content suitable for retract framing); within
diff --git a/internal/application/repository/wiki_page_test.go b/internal/application/repository/wiki_page_test.go
index 4f3d5430f..1efc76157 100644
--- a/internal/application/repository/wiki_page_test.go
+++ b/internal/application/repository/wiki_page_test.go
@@ -29,6 +29,11 @@ CREATE TABLE IF NOT EXISTS wiki_pages (
status VARCHAR(32) NOT NULL DEFAULT 'published',
content TEXT NOT NULL DEFAULT '',
summary TEXT NOT NULL DEFAULT '',
+ parent_slug VARCHAR(255) NOT NULL DEFAULT '',
+ category_path TEXT DEFAULT '[]',
+ wiki_path VARCHAR(1024) NOT NULL DEFAULT '',
+ depth INTEGER NOT NULL DEFAULT 0,
+ sort_order INTEGER NOT NULL DEFAULT 0,
source_refs TEXT DEFAULT '[]',
chunk_refs TEXT DEFAULT '[]',
in_links TEXT DEFAULT '[]',
@@ -68,12 +73,57 @@ func makeWikiPage(kbID, slug, pageType, status string) *types.WikiPage {
Status: status,
Content: "body of " + slug,
Summary: "summary of " + slug,
+ WikiPath: pageType + "/" + title,
Version: 1,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
}
+func makeCategorizedWikiPage(kbID, slug, pageType, status string, categoryPath ...string) *types.WikiPage {
+ page := makeWikiPage(kbID, slug, pageType, status)
+ page.CategoryPath = types.StringArray(categoryPath)
+ if len(categoryPath) > 0 {
+ page.WikiPath = pageType + "/" + strings.Join(categoryPath, "/") + "/" + page.Title
+ page.Depth = len(categoryPath)
+ }
+ return page
+}
+
+// TestList_WikiPathSortReturnsCategorizedPagesFirst protects the sidebar's
+// IDE-like tree contract. Pagination happens in the repository, so the DB
+// must return pages with category_path before loose root pages; otherwise the
+// frontend cannot know about directories hiding on later pages.
+func TestList_WikiPathSortReturnsCategorizedPagesFirst(t *testing.T) {
+ db := setupWikiPagesTestDB(t)
+ repo := NewWikiPageRepository(db)
+ ctx := context.Background()
+
+ pages := []*types.WikiPage{
+ makeWikiPage("kb-a", "entity/000-root", types.WikiPageTypeEntity, types.WikiPageStatusPublished),
+ makeCategorizedWikiPage("kb-a", "entity/999-child", types.WikiPageTypeEntity, types.WikiPageStatusPublished, "zzz-folder"),
+ makeWikiPage("kb-a", "entity/001-root", types.WikiPageTypeEntity, types.WikiPageStatusPublished),
+ }
+ for _, p := range pages {
+ require.NoError(t, repo.Create(ctx, p))
+ }
+
+ got, total, err := repo.List(ctx, &types.WikiPageListRequest{
+ KnowledgeBaseID: "kb-a",
+ PageType: types.WikiPageTypeEntity,
+ Page: 1,
+ PageSize: 10,
+ SortBy: "wiki_path",
+ SortOrder: "asc",
+ })
+ require.NoError(t, err)
+ assert.Equal(t, int64(3), total)
+ require.Len(t, got, 3)
+ assert.Equal(t, "entity/999-child", got[0].Slug)
+ assert.Equal(t, "entity/000-root", got[1].Slug)
+ assert.Equal(t, "entity/001-root", got[2].Slug)
+}
+
// TestListByTypeLight_ProjectsNarrowColumnsAndExcludesArchived verifies
// that the index-view projection only emits the slug/title/summary
// triples and respects the archived-status filter. This is the whole
diff --git a/internal/application/service/wiki_ingest.go b/internal/application/service/wiki_ingest.go
index 9dbde1df9..447bb0256 100644
--- a/internal/application/service/wiki_ingest.go
+++ b/internal/application/service/wiki_ingest.go
@@ -658,6 +658,12 @@ type WikiBatchContext struct {
// Already Normalize()'d — consumers can assume it is one of the
// three valid values.
ExtractionGranularity types.WikiExtractionGranularity
+
+ // ExistingTaxonomy returns a formatted directory tree of category_path
+ // folders already used in this KB. Loaded once per batch and injected
+ // into entity/concept extraction prompts so new pages reuse established
+ // folder labels instead of inventing synonyms.
+ ExistingTaxonomy func(ctx context.Context) string
}
// SlugUpdate represents a single update operation for a specific slug
@@ -1197,6 +1203,126 @@ func collectLinkRefs(pages []*types.WikiPage) []linkRef {
return refs
}
+// wikiTaxonomyScanRowLimit caps how many wiki_pages rows we scan when
+// building the existing-taxonomy prompt block. Distinct paths are deduped
+// in Go; this bound keeps the query cheap on large KBs.
+const wikiTaxonomyScanRowLimit = 2000
+
+// wikiTaxonomyPromptMaxPaths caps how many unique category_path prefixes
+// are rendered into the LLM prompt to control token usage.
+const wikiTaxonomyPromptMaxPaths = 150
+
+func resolveExistingTaxonomyForPrompt(ctx context.Context, batchCtx *WikiBatchContext) string {
+ if batchCtx == nil || batchCtx.ExistingTaxonomy == nil {
+ return "(none — this knowledge base has no established folders yet)"
+ }
+ text := strings.TrimSpace(batchCtx.ExistingTaxonomy(ctx))
+ if text == "" {
+ return "(none — this knowledge base has no established folders yet)"
+ }
+ return text
+}
+
+type wikiTaxonomyNode struct {
+ children map[string]*wikiTaxonomyNode
+}
+
+func insertWikiTaxonomyPath(root *wikiTaxonomyNode, path []string) {
+ if root == nil || len(path) == 0 {
+ return
+ }
+ cur := root
+ for _, part := range path {
+ part = strings.TrimSpace(part)
+ if part == "" {
+ continue
+ }
+ if cur.children == nil {
+ cur.children = make(map[string]*wikiTaxonomyNode)
+ }
+ child := cur.children[part]
+ if child == nil {
+ child = &wikiTaxonomyNode{}
+ cur.children[part] = child
+ }
+ cur = child
+ }
+}
+
+func appendWikiTaxonomyNode(buf *strings.Builder, label string, node *wikiTaxonomyNode, depth int) {
+ if label != "" {
+ fmt.Fprintf(buf, "%s%s\n", strings.Repeat(" ", depth), label)
+ }
+ if node == nil || len(node.children) == 0 {
+ return
+ }
+ keys := make([]string, 0, len(node.children))
+ for k := range node.children {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ for _, k := range keys {
+ appendWikiTaxonomyNode(buf, k, node.children[k], depth+1)
+ }
+}
+
+// formatExistingTaxonomyForPrompt renders distinct category_path values as an
+// indented folder tree for LLM extraction prompts.
+func formatExistingTaxonomyForPrompt(paths [][]string) string {
+ if len(paths) == 0 {
+ return ""
+ }
+ root := &wikiTaxonomyNode{}
+ for _, path := range paths {
+ insertWikiTaxonomyPath(root, path)
+ }
+ if len(root.children) == 0 {
+ return ""
+ }
+ var buf strings.Builder
+ keys := make([]string, 0, len(root.children))
+ for k := range root.children {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ for _, k := range keys {
+ appendWikiTaxonomyNode(&buf, k, root.children[k], 0)
+ }
+ return strings.TrimSpace(buf.String())
+}
+
+// dedupeWikiCategoryPaths returns unique non-empty category_path slices,
+// preserving first-seen order up to maxPaths.
+func dedupeWikiCategoryPaths(paths [][]string, maxPaths int) [][]string {
+ if maxPaths <= 0 {
+ maxPaths = wikiTaxonomyPromptMaxPaths
+ }
+ seen := make(map[string]struct{}, len(paths))
+ out := make([][]string, 0, len(paths))
+ for _, raw := range paths {
+ clean := make([]string, 0, len(raw))
+ for _, part := range raw {
+ part = strings.TrimSpace(part)
+ if part != "" {
+ clean = append(clean, part)
+ }
+ }
+ if len(clean) == 0 {
+ continue
+ }
+ key := strings.Join(clean, "\x00")
+ if _, ok := seen[key]; ok {
+ continue
+ }
+ seen[key] = struct{}{}
+ out = append(out, clean)
+ if len(out) >= maxPaths {
+ break
+ }
+ }
+ return out
+}
+
// getExistingPageSlugsForKnowledge returns all page slugs that currently
// reference a given knowledge ID in their source_refs. Used to snapshot
// state before re-ingest so the reduce phase can reconcile additions vs
@@ -1435,6 +1561,50 @@ func splitSummaryLine(raw string) (summary string, content string) {
return "", raw
}
+// splitCategoryLine extracts a leading "CATEGORY: a / b" line from content
+// (the page body AFTER the SUMMARY line has been stripped) and returns the
+// parsed folder labels plus the remaining content with that line removed.
+//
+// Category assignment lives in the page-modify output (reduce time) rather
+// than per-document extraction so it reflects WHAT the whole entity IS, not
+// the role it played in one source document. An empty/absent CATEGORY line
+// yields nil so callers keep the page's existing category instead of wiping it.
+func splitCategoryLine(content string) (category []string, rest string) {
+ trimmed := strings.TrimLeft(content, " \t\r\n")
+ if !strings.HasPrefix(trimmed, "CATEGORY:") && !strings.HasPrefix(trimmed, "CATEGORY:") {
+ return nil, content
+ }
+ var line, remainder string
+ if idx := strings.IndexByte(trimmed, '\n'); idx < 0 {
+ line = trimmed
+ } else {
+ line = trimmed[:idx]
+ remainder = trimmed[idx+1:]
+ }
+ line = strings.TrimPrefix(line, "CATEGORY:")
+ line = strings.TrimPrefix(line, "CATEGORY:")
+ return parseCategoryPath(line), strings.TrimSpace(remainder)
+}
+
+// parseCategoryPath splits a "a / b" breadcrumb string into clean folder
+// labels. Separator variants and wrapping quotes/brackets are normalized; the
+// deeper structural cleaning (type-label stripping, depth cap, wiki_path) is
+// left to normalizeWikiHierarchy on the page.
+func parseCategoryPath(line string) []string {
+ line = strings.NewReplacer("/", "/", "|", "/", "|", "/", "›", "/", "»", "/", ">", "/").Replace(line)
+ parts := strings.Split(line, "/")
+ out := make([]string, 0, len(parts))
+ for _, p := range parts {
+ label := strings.TrimSpace(p)
+ label = strings.Trim(label, `"'“”‘’[]()()`)
+ label = strings.TrimSpace(label)
+ if label != "" {
+ out = append(out, label)
+ }
+ }
+ return out
+}
+
// buildLogEntry builds a WikiLogEntry struct for the current batch. It is
// pure (no DB access) so callers can accumulate entries cheaply under their
// lock and flush them in a single AppendBatch call at the end of the batch.
diff --git a/internal/application/service/wiki_ingest_batch.go b/internal/application/service/wiki_ingest_batch.go
index 4f1530f86..7023c8577 100644
--- a/internal/application/service/wiki_ingest_batch.go
+++ b/internal/application/service/wiki_ingest_batch.go
@@ -357,6 +357,28 @@ func (s *wikiIngestService) ProcessWikiIngest(ctx context.Context, t *asynq.Task
ExtractionGranularity: granularity,
}
+ var taxonomyOnce sync.Once
+ var taxonomyText string
+ batchCtx.ExistingTaxonomy = func(ctx context.Context) string {
+ taxonomyOnce.Do(func() {
+ if s.wikiService == nil {
+ return
+ }
+ paths, err := s.wikiService.ListDistinctCategoryPaths(ctx, payload.KnowledgeBaseID, wikiTaxonomyPromptMaxPaths)
+ if err != nil {
+ logger.Warnf(ctx, "wiki ingest: ListDistinctCategoryPaths failed: %v", err)
+ return
+ }
+ taxonomyText = formatExistingTaxonomyForPrompt(paths)
+ })
+ return taxonomyText
+ }
+
+ // Snapshot the KB's category folders before this batch runs so we can tell
+ // afterwards whether new (possibly synonymous) folders were introduced and
+ // a taxonomy reconcile pass is worth running.
+ startCategoryKeys := s.snapshotCategoryKeys(ctx, payload.KnowledgeBaseID)
+
// 1. MAP PHASE (Parallel extraction and generation of updates)
var mapMu sync.Mutex
var failedOps []WikiPendingOp
@@ -628,6 +650,14 @@ func (s *wikiIngestService) ProcessWikiIngest(ctx context.Context, t *asynq.Task
}
}
+ // Collapse synonymous category folders introduced by this batch (cold
+ // start, intra-batch races, long-term drift). Runs only when a genuinely
+ // new folder appeared vs the pre-batch snapshot; re-points pages' category
+ // without touching slugs, then refreshes the index.
+ if len(ingestPagesAffected) > 0 {
+ s.maybeReconcileTaxonomy(ctx, chatModel, payload.KnowledgeBaseID, lang, startCategoryKeys)
+ }
+
// Clean dead [[slug]] references whenever ANY page was touched this
// batch (not just retracts). Reduce-phase failures can leave stale
// references in pages we just rewrote (e.g. summary pages cite
@@ -960,7 +990,7 @@ func (s *wikiIngestService) mapOneDocument(
return
}
candidatesXML := renderCandidateSlugsXML(extractedEntities, extractedConcepts)
- citations, newSlugs, batchCount = s.classifyChunkCitations(ctx, chatModel, candidatesXML, chunks, lang)
+ citations, newSlugs, batchCount = s.classifyChunkCitations(ctx, chatModel, candidatesXML, chunks, lang, batchCtx)
s.tracker().EndSpan(ctx, classifySpan, types.JSONMap{
"cited_slugs": len(citations),
"new_slugs": len(newSlugs),
@@ -1585,6 +1615,11 @@ func (s *wikiIngestService) reduceSlugUpdates(
}
pageAliases := strings.Join(page.Aliases, ", ")
+ currentCategory := strings.Join(page.CategoryPath, " / ")
+ if strings.TrimSpace(currentCategory) == "" {
+ currentCategory = "(none yet)"
+ }
+
var updatedContent string
updatedContent, err = s.generateWithTemplate(ctx, chatModel, agent.WikiPageModifyPrompt, map[string]string{
"HasAdditions": hasAdditionsStr,
@@ -1598,11 +1633,22 @@ func (s *wikiIngestService) reduceSlugUpdates(
"DeletedContent": deletedContent.String(),
"RemainingSourcesContent": remainingSourcesContent.String(),
"AvailableSlugs": relatedSlugs.String(),
+ "CurrentCategory": currentCategory,
+ "ExistingTaxonomy": resolveExistingTaxonomyForPrompt(ctx, batchCtx),
"Language": language,
})
if err == nil && updatedContent != "" {
updatedSummary, updatedBody := splitSummaryLine(updatedContent)
+ // CATEGORY is the page-level intrinsic classification produced by
+ // the editor model. Only overwrite when the model actually emitted
+ // a non-empty path so a lazy/empty line never wipes a good category.
+ category, bodyAfterCategory := splitCategoryLine(updatedBody)
+ updatedBody = bodyAfterCategory
+ if len(category) > 0 {
+ page.CategoryPath = types.StringArray(category)
+ page.WikiPath = ""
+ }
if updatedBody != "" {
page.Content = updatedBody
} else {
diff --git a/internal/application/service/wiki_ingest_cite.go b/internal/application/service/wiki_ingest_cite.go
index 7f6ec99e1..48c684406 100644
--- a/internal/application/service/wiki_ingest_cite.go
+++ b/internal/application/service/wiki_ingest_cite.go
@@ -279,6 +279,7 @@ func (s *wikiIngestService) classifyChunkCitations(
candidatesXML string,
chunks []*types.Chunk,
lang string,
+ batchCtx *WikiBatchContext,
) (map[string][]string, []newSlugFromCitation, int) {
batches := splitChunksIntoCitationBatches(chunks)
if len(batches) == 0 || strings.TrimSpace(candidatesXML) == "" {
diff --git a/internal/application/service/wiki_ingest_taxonomy.go b/internal/application/service/wiki_ingest_taxonomy.go
new file mode 100644
index 000000000..b53cf6346
--- /dev/null
+++ b/internal/application/service/wiki_ingest_taxonomy.go
@@ -0,0 +1,259 @@
+package service
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ "github.com/Tencent/WeKnora/internal/agent"
+ "github.com/Tencent/WeKnora/internal/logger"
+ "github.com/Tencent/WeKnora/internal/models/chat"
+ "github.com/Tencent/WeKnora/internal/types"
+)
+
+// wikiTaxonomyReconcileMaxPages caps how many pages a single reconcile pass
+// will rewrite, so an over-eager remap cannot fan out into an unbounded write
+// storm on a very large KB. Pages beyond the cap are picked up by the next run.
+const wikiTaxonomyReconcileMaxPages = 5000
+
+// wikiTaxonomyReconcilePageSize is the cursor page size used when scanning the
+// KB to apply a category remap.
+const wikiTaxonomyReconcilePageSize = 500
+
+type wikiTaxonomyRemapEntry struct {
+ From []string `json:"from"`
+ To []string `json:"to"`
+}
+
+type wikiTaxonomyRemapResult struct {
+ Remap []wikiTaxonomyRemapEntry `json:"remap"`
+}
+
+// ReconcileTaxonomy is the manual entry point for collapsing synonymous
+// category folders across a wiki knowledge base. It resolves the KB's synthesis
+// chat model itself so it can be invoked outside an ingest batch (e.g. from a
+// future maintenance handler).
+func (s *wikiIngestService) ReconcileTaxonomy(ctx context.Context, kbID string) error {
+ kb, err := s.kbService.GetKnowledgeBaseByIDOnly(ctx, kbID)
+ if err != nil {
+ return fmt.Errorf("wiki taxonomy reconcile: get KB: %w", err)
+ }
+ if !kb.IsWikiEnabled() {
+ return fmt.Errorf("wiki taxonomy reconcile: KB %s is not wiki type", kbID)
+ }
+
+ synthesisModelID := ""
+ if kb.WikiConfig != nil {
+ synthesisModelID = kb.WikiConfig.SynthesisModelID
+ }
+ if synthesisModelID == "" {
+ synthesisModelID = kb.SummaryModelID
+ }
+ if synthesisModelID == "" {
+ return fmt.Errorf("wiki taxonomy reconcile: no synthesis model configured for KB %s", kbID)
+ }
+ chatModel, err := s.modelService.GetChatModel(ctx, synthesisModelID)
+ if err != nil {
+ return fmt.Errorf("wiki taxonomy reconcile: get chat model: %w", err)
+ }
+
+ return s.reconcileTaxonomy(ctx, chatModel, kbID, types.LanguageNameFromContext(ctx))
+}
+
+// reconcileTaxonomy collapses synonymous category_path folders across the KB
+// onto a single canonical path. It is per-KB (cost O(folder count), not page
+// count): one LLM call clusters the distinct folders, then matching pages are
+// re-pointed in Go. Slugs are NEVER touched — only the navigation category.
+func (s *wikiIngestService) reconcileTaxonomy(ctx context.Context, chatModel chat.Chat, kbID, lang string) error {
+ if s.wikiService == nil || chatModel == nil {
+ return nil
+ }
+ paths, err := s.wikiService.ListDistinctCategoryPaths(ctx, kbID, wikiTaxonomyPromptMaxPaths)
+ if err != nil {
+ return fmt.Errorf("list distinct category paths: %w", err)
+ }
+ if len(paths) < 2 {
+ return nil // nothing to merge
+ }
+
+ remap := s.buildTaxonomyRemap(ctx, chatModel, paths, lang)
+ if len(remap) == 0 {
+ return nil
+ }
+
+ updated := s.applyTaxonomyRemap(ctx, kbID, remap)
+ if updated > 0 {
+ logger.Infof(ctx, "wiki taxonomy reconcile: re-pointed %d pages via %d rules (kb=%s)", updated, len(remap), kbID)
+ if err := s.wikiService.RebuildIndexPage(ctx, kbID); err != nil {
+ logger.Warnf(ctx, "wiki taxonomy reconcile: rebuild index failed: %v", err)
+ }
+ }
+ return nil
+}
+
+// maybeReconcileTaxonomy runs a reconcile pass only when this batch introduced
+// at least one category folder that was not present in startKeys (the snapshot
+// taken before the batch ran). This keeps the extra LLM call off the hot path
+// for batches that only reuse established folders.
+func (s *wikiIngestService) maybeReconcileTaxonomy(
+ ctx context.Context, chatModel chat.Chat, kbID, lang string, startKeys map[string]bool,
+) {
+ if s.wikiService == nil {
+ return
+ }
+ paths, err := s.wikiService.ListDistinctCategoryPaths(ctx, kbID, wikiTaxonomyPromptMaxPaths)
+ if err != nil || len(paths) < 2 {
+ return
+ }
+ introducedNewFolder := false
+ for _, p := range paths {
+ if !startKeys[categoryPathKey(cleanCategoryPathParts(p))] {
+ introducedNewFolder = true
+ break
+ }
+ }
+ if !introducedNewFolder {
+ return
+ }
+ if err := s.reconcileTaxonomy(ctx, chatModel, kbID, lang); err != nil {
+ logger.Warnf(ctx, "wiki taxonomy reconcile failed: %v", err)
+ }
+}
+
+// snapshotCategoryKeys returns the set of canonical category keys currently in
+// use, used as the "before" baseline for maybeReconcileTaxonomy.
+func (s *wikiIngestService) snapshotCategoryKeys(ctx context.Context, kbID string) map[string]bool {
+ out := map[string]bool{}
+ if s.wikiService == nil {
+ return out
+ }
+ paths, err := s.wikiService.ListDistinctCategoryPaths(ctx, kbID, wikiTaxonomyPromptMaxPaths)
+ if err != nil {
+ return out
+ }
+ for _, p := range paths {
+ out[categoryPathKey(cleanCategoryPathParts(p))] = true
+ }
+ return out
+}
+
+// buildTaxonomyRemap asks the LLM to cluster synonymous folders and returns a
+// map keyed by the canonical key of each non-canonical path to its replacement.
+func (s *wikiIngestService) buildTaxonomyRemap(
+ ctx context.Context, chatModel chat.Chat, paths [][]string, lang string,
+) map[string][]string {
+ taxonomyText := formatExistingTaxonomyForPrompt(paths)
+ if strings.TrimSpace(taxonomyText) == "" {
+ return nil
+ }
+ raw, err := s.generateWithTemplate(ctx, chatModel, agent.WikiTaxonomyReconcilePrompt, map[string]string{
+ "CurrentTaxonomy": taxonomyText,
+ "Language": lang,
+ })
+ if err != nil {
+ logger.Warnf(ctx, "wiki taxonomy reconcile: LLM call failed: %v", err)
+ return nil
+ }
+ raw = cleanLLMJSON(raw)
+ var parsed wikiTaxonomyRemapResult
+ if jerr := json.Unmarshal([]byte(raw), &parsed); jerr != nil {
+ logger.Warnf(ctx, "wiki taxonomy reconcile: parse failed: %v\nRaw: %s", jerr, raw)
+ return nil
+ }
+
+ out := make(map[string][]string, len(parsed.Remap))
+ for _, e := range parsed.Remap {
+ from := cleanCategoryPathParts(e.From)
+ to := cleanCategoryPathParts(e.To)
+ if len(from) == 0 || len(to) == 0 {
+ continue
+ }
+ fromKey := categoryPathKey(from)
+ if fromKey == categoryPathKey(to) {
+ continue // no-op rule
+ }
+ out[fromKey] = to
+ }
+ return out
+}
+
+// applyTaxonomyRemap scans entity/concept pages and re-points any whose
+// category matches a remap rule. Returns the number of pages updated.
+func (s *wikiIngestService) applyTaxonomyRemap(ctx context.Context, kbID string, remap map[string][]string) int {
+ var cursor string
+ var updated int
+ for updated < wikiTaxonomyReconcileMaxPages {
+ pages, next, err := s.wikiService.ListPagesCursor(ctx, kbID, cursor, wikiTaxonomyReconcilePageSize)
+ if err != nil {
+ logger.Warnf(ctx, "wiki taxonomy reconcile: list pages failed: %v", err)
+ break
+ }
+ if len(pages) == 0 {
+ break
+ }
+ for _, p := range pages {
+ if p.PageType != types.WikiPageTypeEntity && p.PageType != types.WikiPageTypeConcept {
+ continue
+ }
+ newPath, ok := remapCategoryPath([]string(p.CategoryPath), remap)
+ if !ok {
+ continue
+ }
+ p.CategoryPath = types.StringArray(newPath)
+ p.WikiPath = "" // force recompute in normalizeWikiHierarchy
+ if err := s.wikiService.UpdatePageMeta(ctx, p); err != nil {
+ logger.Warnf(ctx, "wiki taxonomy reconcile: update %s failed: %v", p.Slug, err)
+ continue
+ }
+ updated++
+ if updated >= wikiTaxonomyReconcileMaxPages {
+ break
+ }
+ }
+ if next == "" {
+ break
+ }
+ cursor = next
+ }
+ return updated
+}
+
+// remapCategoryPath returns the canonical category for current when it matches
+// a remap "from" key, plus whether a change is needed. Pure (no DB) so it can
+// be unit-tested in isolation.
+func remapCategoryPath(current []string, remap map[string][]string) ([]string, bool) {
+ cleaned := cleanCategoryPathParts(current)
+ if len(cleaned) == 0 {
+ return nil, false
+ }
+ to, ok := remap[categoryPathKey(cleaned)]
+ if !ok {
+ return nil, false
+ }
+ if categoryPathKey(cleaned) == categoryPathKey(cleanCategoryPathParts(to)) {
+ return nil, false
+ }
+ return append([]string(nil), to...), true
+}
+
+// cleanCategoryPathParts normalizes a category path the same way the page
+// hierarchy normalizer does (separator/quote cleanup, type-label stripping,
+// depth cap) so remap keys match what is stored on pages.
+func cleanCategoryPathParts(parts []string) []string {
+ out := make([]string, 0, len(parts))
+ for _, part := range parts {
+ for _, c := range cleanWikiCategoryPart(part) {
+ out = append(out, c)
+ if len(out) >= 3 {
+ return out
+ }
+ }
+ }
+ return out
+}
+
+// categoryPathKey builds a stable comparison key for a cleaned category path.
+func categoryPathKey(parts []string) string {
+ return strings.Join(parts, "\u0000")
+}
diff --git a/internal/application/service/wiki_ingest_taxonomy_test.go b/internal/application/service/wiki_ingest_taxonomy_test.go
new file mode 100644
index 000000000..d8fab8f9d
--- /dev/null
+++ b/internal/application/service/wiki_ingest_taxonomy_test.go
@@ -0,0 +1,185 @@
+package service
+
+import (
+ "context"
+ "strings"
+ "testing"
+)
+
+func TestSplitCategoryLine(t *testing.T) {
+ tests := []struct {
+ name string
+ content string
+ wantCat []string
+ wantRest string
+ }{
+ {
+ name: "two level category",
+ content: "CATEGORY: 组织 / 企业\n# 标题\n正文",
+ wantCat: []string{"组织", "企业"},
+ wantRest: "# 标题\n正文",
+ },
+ {
+ name: "single level category",
+ content: "CATEGORY: 人物\n# 陈洋",
+ wantCat: []string{"人物"},
+ wantRest: "# 陈洋",
+ },
+ {
+ name: "empty category keeps existing (nil) and strips line",
+ content: "CATEGORY:\n# 标题",
+ wantCat: nil,
+ wantRest: "# 标题",
+ },
+ {
+ name: "no category line left untouched",
+ content: "# 标题\n正文",
+ wantCat: nil,
+ wantRest: "# 标题\n正文",
+ },
+ {
+ name: "fullwidth colon and separators",
+ content: "CATEGORY:组织|企业\n正文",
+ wantCat: []string{"组织", "企业"},
+ wantRest: "正文",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ cat, rest := splitCategoryLine(tt.content)
+ if strings.Join(cat, "/") != strings.Join(tt.wantCat, "/") {
+ t.Fatalf("splitCategoryLine() cat = %v, want %v", cat, tt.wantCat)
+ }
+ if rest != tt.wantRest {
+ t.Fatalf("splitCategoryLine() rest = %q, want %q", rest, tt.wantRest)
+ }
+ })
+ }
+}
+
+func TestFormatExistingTaxonomyForPrompt(t *testing.T) {
+ got := formatExistingTaxonomyForPrompt([][]string{
+ {"春节", "传统习俗"},
+ {"春节", "文化习俗", "节日习俗"},
+ {"春节习俗"},
+ {"产品定位"},
+ })
+ want := "产品定位\n" +
+ "春节\n" +
+ " 传统习俗\n" +
+ " 文化习俗\n" +
+ " 节日习俗\n" +
+ "春节习俗"
+ if got != want {
+ t.Fatalf("formatExistingTaxonomyForPrompt():\n%q\nwant:\n%q", got, want)
+ }
+}
+
+func TestFormatExistingTaxonomyForPromptEmpty(t *testing.T) {
+ if got := formatExistingTaxonomyForPrompt(nil); got != "" {
+ t.Fatalf("formatExistingTaxonomyForPrompt(nil) = %q, want empty", got)
+ }
+}
+
+func TestDedupeWikiCategoryPaths(t *testing.T) {
+ got := dedupeWikiCategoryPaths([][]string{
+ {"春节", "传统习俗"},
+ {" 春节 ", "传统习俗"},
+ {"春节", "文化习俗"},
+ {"", "空"},
+ {},
+ }, 10)
+ if len(got) != 3 {
+ t.Fatalf("dedupeWikiCategoryPaths() len = %d, want 3: %v", len(got), got)
+ }
+ if got[0][0] != "春节" || got[0][1] != "传统习俗" {
+ t.Fatalf("dedupeWikiCategoryPaths()[0] = %v", got[0])
+ }
+ if got[1][1] != "文化习俗" {
+ t.Fatalf("dedupeWikiCategoryPaths()[1] = %v", got[1])
+ }
+}
+
+func TestResolveExistingTaxonomyForPrompt(t *testing.T) {
+ t.Run("nil batch context", func(t *testing.T) {
+ got := resolveExistingTaxonomyForPrompt(t.Context(), nil)
+ if got == "" {
+ t.Fatal("expected fallback text for nil batch context")
+ }
+ })
+ t.Run("batch context with taxonomy", func(t *testing.T) {
+ batchCtx := &WikiBatchContext{
+ ExistingTaxonomy: func(ctx context.Context) string {
+ return "春节\n 传统习俗"
+ },
+ }
+ got := resolveExistingTaxonomyForPrompt(t.Context(), batchCtx)
+ if got != "春节\n 传统习俗" {
+ t.Fatalf("resolveExistingTaxonomyForPrompt() = %q", got)
+ }
+ })
+}
+
+func TestRemapCategoryPath(t *testing.T) {
+ remap := map[string][]string{
+ categoryPathKey([]string{"春节习俗"}): {"春节", "传统习俗"},
+ categoryPathKey([]string{"组织单位", "致谢人员"}): {"组织"},
+ }
+
+ tests := []struct {
+ name string
+ current []string
+ want []string
+ wantHit bool
+ }{
+ {
+ name: "single-label synonym remapped",
+ current: []string{"春节习俗"},
+ want: []string{"春节", "传统习俗"},
+ wantHit: true,
+ },
+ {
+ name: "document-role path collapsed to coarse folder",
+ current: []string{"组织单位", "致谢人员"},
+ want: []string{"组织"},
+ wantHit: true,
+ },
+ {
+ name: "noise normalized before matching",
+ current: []string{"实体/春节习俗"},
+ want: []string{"春节", "传统习俗"},
+ wantHit: true,
+ },
+ {
+ name: "unrelated path untouched",
+ current: []string{"人物"},
+ wantHit: false,
+ },
+ {
+ name: "empty path untouched",
+ current: nil,
+ wantHit: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, hit := remapCategoryPath(tt.current, remap)
+ if hit != tt.wantHit {
+ t.Fatalf("remapCategoryPath() hit = %v, want %v", hit, tt.wantHit)
+ }
+ if !hit {
+ return
+ }
+ if len(got) != len(tt.want) {
+ t.Fatalf("remapCategoryPath() = %v, want %v", got, tt.want)
+ }
+ for i := range tt.want {
+ if got[i] != tt.want[i] {
+ t.Fatalf("remapCategoryPath()[%d] = %q, want %q (full=%v)", i, got[i], tt.want[i], got)
+ }
+ }
+ })
+ }
+}
diff --git a/internal/application/service/wiki_page.go b/internal/application/service/wiki_page.go
index de71a4f62..a6750ae1e 100644
--- a/internal/application/service/wiki_page.go
+++ b/internal/application/service/wiki_page.go
@@ -67,6 +67,7 @@ func (s *wikiPageService) CreatePage(ctx context.Context, page *types.WikiPage)
// Parse outbound links from content
page.OutLinks = s.parseOutLinks(page.Content)
+ normalizeWikiHierarchy(page)
now := time.Now()
page.CreatedAt = now
@@ -115,6 +116,11 @@ func (s *wikiPageService) UpdatePage(ctx context.Context, page *types.WikiPage)
existing.SourceRefs = page.SourceRefs
existing.ChunkRefs = page.ChunkRefs
existing.PageMetadata = page.PageMetadata
+ existing.ParentSlug = page.ParentSlug
+ existing.CategoryPath = page.CategoryPath
+ existing.WikiPath = page.WikiPath
+ existing.Depth = page.Depth
+ existing.SortOrder = page.SortOrder
existing.Status = page.Status
existing.UpdatedAt = time.Now()
@@ -122,11 +128,18 @@ func (s *wikiPageService) UpdatePage(ctx context.Context, page *types.WikiPage)
// when content shifts. Re-parse unconditionally to stay consistent with
// the stored body.
existing.OutLinks = s.parseOutLinks(existing.Content)
+ normalizeWikiHierarchy(existing)
if contentChanged {
if err := s.repo.Update(ctx, existing); err != nil {
return nil, fmt.Errorf("update wiki page: %w", err)
}
+ // GORM's struct Updates path skips zero values, so persist hierarchy
+ // metadata through the explicit map path as well. This keeps clearing
+ // parent/category fields deterministic without changing version again.
+ if err := s.repo.UpdateMeta(ctx, existing); err != nil {
+ return nil, fmt.Errorf("update wiki page hierarchy meta: %w", err)
+ }
} else {
// No user-visible change — persist bookkeeping fields but preserve
// the version so downstream consumers can rely on it.
@@ -145,6 +158,7 @@ func (s *wikiPageService) UpdatePage(ctx context.Context, page *types.WikiPage)
// UpdatePageMeta updates only metadata (status, source_refs) without version bump or link re-parse.
func (s *wikiPageService) UpdatePageMeta(ctx context.Context, page *types.WikiPage) error {
+ normalizeWikiHierarchy(page)
page.UpdatedAt = time.Now()
return s.repo.UpdateMeta(ctx, page)
}
@@ -192,6 +206,9 @@ func (s *wikiPageService) ListPages(ctx context.Context, req *types.WikiPageList
if err != nil {
return nil, err
}
+ for _, page := range pages {
+ normalizeWikiHierarchy(page)
+ }
pageSize := req.PageSize
if pageSize < 1 {
@@ -323,6 +340,9 @@ func (s *wikiPageService) GetIndexView(
if entries == nil {
entries = []types.WikiIndexEntry{}
}
+ for i := range entries {
+ normalizeWikiIndexEntryHierarchy(&entries[i], pt)
+ }
next := ""
// Only emit a cursor when a full page was returned AND more rows
// remain past `offset + limit`. A short page or one that exactly
@@ -811,6 +831,20 @@ func (s *wikiPageService) FindSimilarPages(ctx context.Context, kbID string, que
return s.repo.FindSimilarPages(ctx, kbID, query, pageTypes, limit)
}
+// ListDistinctCategoryPaths returns unique category_path folders already used
+// by entity/concept pages. Used by wiki ingest to ground extraction prompts.
+func (s *wikiPageService) ListDistinctCategoryPaths(ctx context.Context, kbID string, maxPaths int) ([][]string, error) {
+ return s.repo.ListDistinctCategoryPaths(ctx, kbID, maxPaths)
+}
+
+// ListDistinctCategoryPathsByType returns unique category_path folders across
+// the given page types. The browser uses this to render directory nodes before
+// article pages are paged in; passing several types lets one tab (e.g. the
+// merged knowledge tab) build a single directory skeleton server-side.
+func (s *wikiPageService) ListDistinctCategoryPathsByType(ctx context.Context, kbID string, pageTypes []string, parentPath []string, maxPaths int) ([]types.WikiCategoryPath, error) {
+ return s.repo.ListDistinctCategoryPathsByType(ctx, kbID, pageTypes, parentPath, maxPaths)
+}
+
// CountByType is a service-layer pass-through over the repo. Used by
// the index intro path to frame the LLM prompt's "showing N of M" hint.
func (s *wikiPageService) CountByType(ctx context.Context, kbID string) (map[string]int64, error) {
@@ -917,6 +951,7 @@ func (s *wikiPageService) createDefaultPage(ctx context.Context, kbID string, sl
Summary: title,
Version: 1,
}
+ normalizeWikiHierarchy(page)
if err := s.repo.Create(ctx, page); err != nil {
return nil, fmt.Errorf("create default %s page: %w", slug, err)
@@ -924,6 +959,115 @@ func (s *wikiPageService) createDefaultPage(ctx context.Context, kbID string, sl
return page, nil
}
+func normalizeWikiHierarchy(page *types.WikiPage) {
+ if page == nil {
+ return
+ }
+ page.ParentSlug = strings.TrimSpace(page.ParentSlug)
+
+ cleanPath := make(types.StringArray, 0, len(page.CategoryPath))
+ for _, part := range page.CategoryPath {
+ for _, cleanPart := range cleanWikiCategoryPart(part) {
+ if !containsString(cleanPath, cleanPart) {
+ cleanPath = append(cleanPath, cleanPart)
+ }
+ if len(cleanPath) >= 3 {
+ break
+ }
+ }
+ if len(cleanPath) >= 3 {
+ break
+ }
+ }
+ page.CategoryPath = cleanPath
+ page.Depth = len(cleanPath)
+
+ parts := make([]string, 0, len(cleanPath)+2)
+ if page.PageType != "" {
+ parts = append(parts, page.PageType)
+ }
+ for _, part := range cleanPath {
+ parts = append(parts, part)
+ }
+ display := strings.TrimSpace(page.Title)
+ if display == "" {
+ display = strings.TrimSpace(page.Slug)
+ }
+ if display != "" {
+ parts = append(parts, display)
+ }
+ page.WikiPath = strings.Join(parts, "/")
+}
+
+func normalizeWikiIndexEntryHierarchy(entry *types.WikiIndexEntry, pageType string) {
+ if entry == nil {
+ return
+ }
+
+ cleanPath := make(types.StringArray, 0, len(entry.CategoryPath))
+ for _, part := range entry.CategoryPath {
+ for _, cleanPart := range cleanWikiCategoryPart(part) {
+ if !containsString(cleanPath, cleanPart) {
+ cleanPath = append(cleanPath, cleanPart)
+ }
+ if len(cleanPath) >= 3 {
+ break
+ }
+ }
+ if len(cleanPath) >= 3 {
+ break
+ }
+ }
+ entry.CategoryPath = cleanPath
+ entry.Depth = len(cleanPath)
+
+ parts := make([]string, 0, len(cleanPath)+2)
+ if strings.TrimSpace(pageType) != "" {
+ parts = append(parts, strings.TrimSpace(pageType))
+ }
+ parts = append(parts, cleanPath...)
+ display := strings.TrimSpace(entry.Title)
+ if display == "" {
+ display = strings.TrimSpace(entry.Slug)
+ }
+ if display != "" {
+ parts = append(parts, display)
+ }
+ entry.WikiPath = strings.Join(parts, "/")
+}
+
+func cleanWikiCategoryPart(part string) []string {
+ part = strings.TrimSpace(part)
+ if part == "" {
+ return nil
+ }
+
+ part = strings.NewReplacer("/", "/", "|", "/", "|", "/").Replace(part)
+ rawParts := strings.Split(part, "/")
+ cleaned := make([]string, 0, len(rawParts))
+ for _, raw := range rawParts {
+ label := strings.TrimSpace(raw)
+ label = strings.Trim(label, `"'“”‘’[]()()`)
+ label = strings.TrimSpace(label)
+ if label == "" || isWikiTypeCategoryLabel(label) {
+ continue
+ }
+ cleaned = append(cleaned, label)
+ }
+ return cleaned
+}
+
+func isWikiTypeCategoryLabel(label string) bool {
+ normalized := strings.ToLower(strings.TrimSpace(label))
+ normalized = strings.TrimSuffix(normalized, "s")
+ switch normalized {
+ case "entity", "实体", "實體", "concept", "概念", "summary", "摘要", "wiki", "页面", "頁面":
+ return true
+ default:
+ return false
+ }
+}
+
// containsString checks if a string slice contains a given string
func containsString(slice []string, s string) bool {
for _, v := range slice {
diff --git a/internal/application/service/wiki_page_test.go b/internal/application/service/wiki_page_test.go
index 0bb071c49..99e3e54e0 100644
--- a/internal/application/service/wiki_page_test.go
+++ b/internal/application/service/wiki_page_test.go
@@ -99,6 +99,56 @@ func TestNormalizeSlug(t *testing.T) {
}
}
+func TestNormalizeWikiHierarchyCleansModelCategoryNoise(t *testing.T) {
+ page := &types.WikiPage{
+ Slug: "concept/ai-knowledge",
+ Title: "AI时代的知识困境",
+ PageType: types.WikiPageTypeConcept,
+ CategoryPath: types.StringArray{"概念/爱护花草", " 实体/标牌 ", "摘要", "生态/平台", "多余层级"},
+ }
+
+ normalizeWikiHierarchy(page)
+
+ want := types.StringArray{"爱护花草", "标牌", "生态"}
+ if len(page.CategoryPath) != len(want) {
+ t.Fatalf("CategoryPath = %v, want %v", page.CategoryPath, want)
+ }
+ for i := range want {
+ if page.CategoryPath[i] != want[i] {
+ t.Fatalf("CategoryPath[%d] = %q, want %q; full path=%v", i, page.CategoryPath[i], want[i], page.CategoryPath)
+ }
+ }
+ if page.Depth != 3 {
+ t.Fatalf("Depth = %d, want 3", page.Depth)
+ }
+ if page.WikiPath != "concept/爱护花草/标牌/生态/AI时代的知识困境" {
+ t.Fatalf("WikiPath = %q", page.WikiPath)
+ }
+}
+
+func TestNormalizeWikiIndexEntryHierarchyCleansModelCategoryNoise(t *testing.T) {
+ entry := &types.WikiIndexEntry{
+ Slug: "entity/sign",
+ Title: "爱护花草标牌",
+ CategoryPath: types.StringArray{"实体/标牌", "概念", "生态/行为倡导"},
+ }
+
+ normalizeWikiIndexEntryHierarchy(entry, types.WikiPageTypeEntity)
+
+ want := types.StringArray{"标牌", "生态", "行为倡导"}
+ if len(entry.CategoryPath) != len(want) {
+ t.Fatalf("CategoryPath = %v, want %v", entry.CategoryPath, want)
+ }
+ for i := range want {
+ if entry.CategoryPath[i] != want[i] {
+ t.Fatalf("CategoryPath[%d] = %q, want %q; full path=%v", i, entry.CategoryPath[i], want[i], entry.CategoryPath)
+ }
+ }
+ if entry.WikiPath != "entity/标牌/生态/行为倡导/爱护花草标牌" {
+ t.Fatalf("WikiPath = %q", entry.WikiPath)
+ }
+}
+
func TestContainsString(t *testing.T) {
slice := []string{"a", "b", "c"}
if !containsString(slice, "b") {
diff --git a/internal/handler/wiki_page.go b/internal/handler/wiki_page.go
index 3b626aef8..065f0f073 100644
--- a/internal/handler/wiki_page.go
+++ b/internal/handler/wiki_page.go
@@ -77,7 +77,7 @@ func getSlugParam(c *gin.Context) string {
// @Tags Wiki
// @Produce json
// @Param kb_id path string true "Knowledge base ID"
-// @Param page_type query string false "Filter by page type"
+// @Param page_type query string false "Filter by page type; comma-separated for multiple (e.g. entity,concept)"
// @Param status query string false "Filter by status"
// @Param query query string false "Full-text search"
// @Param page query int false "Page number"
@@ -97,12 +97,21 @@ func (h *WikiPageHandler) ListPages(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
+ categoryPath := parseWikiCategoryPath(c.Query("category_path"))
+ var categoryDepth *int
+ if raw := c.Query("category_depth"); raw != "" {
+ if depth, parseErr := strconv.Atoi(raw); parseErr == nil && depth >= 0 {
+ categoryDepth = &depth
+ }
+ }
req := &types.WikiPageListRequest{
KnowledgeBaseID: kbID,
PageType: c.Query("page_type"),
Status: c.Query("status"),
Query: c.Query("query"),
+ CategoryPath: types.StringArray(categoryPath),
+ CategoryDepth: categoryDepth,
Page: page,
PageSize: pageSize,
SortBy: c.DefaultQuery("sort_by", "updated_at"),
@@ -118,6 +127,106 @@ func (h *WikiPageHandler) ListPages(c *gin.Context) {
c.JSON(http.StatusOK, resp)
}
+// ListCategories godoc
+// @Summary List wiki category paths
+// @Description Retrieve direct child directories for a wiki page type and optional parent_path
+// @Tags Wiki
+// @Produce json
+// @Param kb_id path string true "Knowledge base ID"
+// @Param page_type query string true "Wiki page type; comma-separated for multiple (e.g. entity,concept)"
+// @Param parent_path query string false "Parent category path, slash-separated"
+// @Param page query int false "Page number (1-based)"
+// @Param page_size query int false "Page size"
+// @Success 200 {object} types.WikiCategoryPathListResponse
+// @Failure 400 {object} errors.AppError
+// @Security Bearer
+// @Router /knowledgebase/{kb_id}/wiki/categories [get]
+func (h *WikiPageHandler) ListCategories(c *gin.Context) {
+ kbID, _, err := h.validateWikiKB(c)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ pageTypes := parseWikiPageTypes(c.Query("page_type"))
+ if len(pageTypes) == 0 {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "page_type is required"})
+ return
+ }
+ page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
+ if page < 1 {
+ page = 1
+ }
+ pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "2000"))
+ if pageSize <= 0 {
+ pageSize = 2000
+ }
+ parentPath := parseWikiCategoryPath(c.Query("parent_path"))
+ paths, err := h.wikiService.ListDistinctCategoryPathsByType(c.Request.Context(), kbID, pageTypes, parentPath, pageSize)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ total := len(paths)
+ offset := (page - 1) * pageSize
+ if offset > total {
+ offset = total
+ }
+ end := offset + pageSize
+ if end > total {
+ end = total
+ }
+ totalPages := 0
+ if pageSize > 0 {
+ totalPages = (total + pageSize - 1) / pageSize
+ }
+
+ c.JSON(http.StatusOK, types.WikiCategoryPathListResponse{
+ Paths: paths[offset:end],
+ Total: total,
+ Page: page,
+ PageSize: pageSize,
+ TotalPages: totalPages,
+ })
+}
+
+// parseWikiPageTypes splits a comma-separated page_type query value (e.g.
+// "entity,concept") into a deduplicated slice, dropping blanks.
+func parseWikiPageTypes(raw string) []string {
+ if strings.TrimSpace(raw) == "" {
+ return nil
+ }
+ seen := make(map[string]struct{})
+ out := make([]string, 0, 4)
+ for _, part := range strings.Split(raw, ",") {
+ part = strings.TrimSpace(part)
+ if part == "" {
+ continue
+ }
+ if _, ok := seen[part]; ok {
+ continue
+ }
+ seen[part] = struct{}{}
+ out = append(out, part)
+ }
+ return out
+}
+
+func parseWikiCategoryPath(raw string) []string {
+ if strings.TrimSpace(raw) == "" {
+ return nil
+ }
+ parts := strings.Split(raw, "/")
+ out := make([]string, 0, len(parts))
+ for _, part := range parts {
+ part = strings.TrimSpace(part)
+ if part != "" {
+ out = append(out, part)
+ }
+ }
+ return out
+}
+
// CreatePage godoc
// @Summary Create a wiki page
// @Description Create a new wiki page in the knowledge base
diff --git a/internal/router/router.go b/internal/router/router.go
index 7b72dcd76..ce197fe24 100644
--- a/internal/router/router.go
+++ b/internal/router/router.go
@@ -1748,6 +1748,7 @@ func RegisterWikiPageRoutes(r *gin.RouterGroup, wikiHandler *handler.WikiPageHan
{
// Page CRUD
wiki.GET("/pages", g.Viewer(), wikiHandler.ListPages)
+ wiki.GET("/categories", g.Viewer(), wikiHandler.ListCategories)
wiki.POST("/pages", g.OwnedWikiKBOrAdmin(), wikiHandler.CreatePage)
wiki.GET("/pages/*slug", g.Viewer(), wikiHandler.GetPage)
wiki.PUT("/pages/*slug", g.OwnedWikiKBOrAdmin(), wikiHandler.UpdatePage)
diff --git a/internal/types/interfaces/wiki_page.go b/internal/types/interfaces/wiki_page.go
index 170459dbc..47b9af839 100644
--- a/internal/types/interfaces/wiki_page.go
+++ b/internal/types/interfaces/wiki_page.go
@@ -156,6 +156,16 @@ type WikiPageService interface {
// merge targets server-side.
FindSimilarPages(ctx context.Context, kbID string, query string, pageTypes []string, limit int) ([]*types.WikiPageLite, error)
+ // ListDistinctCategoryPaths returns unique non-empty category_path
+ // values from entity/concept pages. Used by wiki ingest to inject
+ // established folder taxonomy into extraction prompts.
+ ListDistinctCategoryPaths(ctx context.Context, kbID string, maxPaths int) ([][]string, error)
+
+ // ListDistinctCategoryPathsByType returns unique non-empty category_path
+ // values across the given page types. Used by the wiki browser to render a
+ // stable directory skeleton independently of page pagination.
+ ListDistinctCategoryPathsByType(ctx context.Context, kbID string, pageTypes []string, parentPath []string, maxPaths int) ([]types.WikiCategoryPath, error)
+
// CountByType returns page counts grouped by type for a knowledge
// base. Re-exposed at the service layer so the index intro
// generation path can frame the LLM prompt with "showing N of M"
@@ -264,6 +274,14 @@ type WikiPageRepository interface {
// surface candidate merge targets server-side.
FindSimilarPages(ctx context.Context, kbID string, query string, pageTypes []string, limit int) ([]*types.WikiPageLite, error)
+ // ListDistinctCategoryPaths scans entity/concept pages and returns
+ // unique non-empty category_path values for wiki ingest prompts.
+ ListDistinctCategoryPaths(ctx context.Context, kbID string, maxPaths int) ([][]string, error)
+
+ // ListDistinctCategoryPathsByType returns unique non-empty category_path
+ // values across the given page types.
+ ListDistinctCategoryPathsByType(ctx context.Context, kbID string, pageTypes []string, parentPath []string, maxPaths int) ([]types.WikiCategoryPath, error)
+
// ListAll retrieves all wiki pages in a knowledge base (for link rebuilding, graph generation).
ListAll(ctx context.Context, kbID string) ([]*types.WikiPage, error)
diff --git a/internal/types/wiki_page.go b/internal/types/wiki_page.go
index 3b254db41..a2386ab5e 100644
--- a/internal/types/wiki_page.go
+++ b/internal/types/wiki_page.go
@@ -65,6 +65,22 @@ type WikiPage struct {
Summary string `json:"summary" gorm:"type:text"`
// Alternate names, abbreviations, acronyms or translated names
Aliases StringArray `json:"aliases" gorm:"type:json"`
+ // ParentSlug optionally points at the wiki page that should act as this
+ // page's semantic parent in the directory tree. The parent may be empty
+ // when the page is grouped only by CategoryPath.
+ ParentSlug string `json:"parent_slug,omitempty" gorm:"type:varchar(255);index"`
+ // CategoryPath is the directory breadcrumb that groups this page in the
+ // wiki browser, e.g. ["AI", "LLM 应用", "RAG"]. It is intentionally
+ // label-based so intermediate directory nodes do not need to be real pages.
+ CategoryPath StringArray `json:"category_path,omitempty" gorm:"type:json"`
+ // WikiPath is a normalized, sortable path derived from page_type,
+ // category_path, and title. It keeps large directory listings cheap to sort.
+ WikiPath string `json:"wiki_path,omitempty" gorm:"type:varchar(1024);index"`
+ // Depth is len(CategoryPath), cached for filtering / display.
+ Depth int `json:"depth,omitempty" gorm:"default:0;index"`
+ // SortOrder allows generated or manually edited pages to control sibling
+ // ordering before falling back to title.
+ SortOrder int `json:"sort_order,omitempty" gorm:"default:0;index"`
// References to source knowledge IDs that contributed to this page.
// Format matches the legacy "|" convention used
// across the ingest pipeline, so retract / display code can split on `|`
@@ -226,14 +242,16 @@ func (c *WikiConfig) Scan(value interface{}) error {
// WikiPageListRequest represents a request to list wiki pages with filtering
type WikiPageListRequest struct {
- KnowledgeBaseID string `json:"knowledge_base_id"`
- PageType string `json:"page_type,omitempty"` // filter by type
- Status string `json:"status,omitempty"` // filter by status
- Query string `json:"query,omitempty"` // full-text search
- Page int `json:"page,omitempty"` // pagination page (1-based)
- PageSize int `json:"page_size,omitempty"` // pagination size
- SortBy string `json:"sort_by,omitempty"` // "updated_at", "created_at", "title"
- SortOrder string `json:"sort_order,omitempty"` // "asc" or "desc"
+ KnowledgeBaseID string `json:"knowledge_base_id"`
+ PageType string `json:"page_type,omitempty"` // filter by type
+ Status string `json:"status,omitempty"` // filter by status
+ Query string `json:"query,omitempty"` // full-text search
+ CategoryPath StringArray `json:"category_path,omitempty"` // exact directory path
+ CategoryDepth *int `json:"category_depth,omitempty"` // exact directory depth, including 0 for root
+ Page int `json:"page,omitempty"` // pagination page (1-based)
+ PageSize int `json:"page_size,omitempty"` // pagination size
+ SortBy string `json:"sort_by,omitempty"` // "updated_at", "created_at", "title"
+ SortOrder string `json:"sort_order,omitempty"` // "asc" or "desc"
}
// WikiPageListResponse represents a paginated list of wiki pages
@@ -245,6 +263,21 @@ type WikiPageListResponse struct {
TotalPages int `json:"total_pages"`
}
+// WikiCategoryPathListResponse returns the directory skeleton for wiki pages.
+type WikiCategoryPathListResponse struct {
+ Paths []WikiCategoryPath `json:"paths"`
+ Total int `json:"total"`
+ Page int `json:"page"`
+ PageSize int `json:"page_size"`
+ TotalPages int `json:"total_pages"`
+}
+
+// WikiCategoryPath is one directory node in the wiki sidebar skeleton.
+type WikiCategoryPath struct {
+ Path []string `json:"path"`
+ Count int64 `json:"count"`
+}
+
// WikiGraphMode enumerates the graph query modes exposed to the API.
const (
// WikiGraphModeOverview returns the top-N most-connected pages as an
@@ -345,9 +378,14 @@ func (WikiPageIssue) TableName() string {
// carried — the backend projects SELECT slug, title, summary so a 40k-
// page KB does not pay for TEXT content transport on every index open.
type WikiIndexEntry struct {
- Slug string `json:"slug"`
- Title string `json:"title"`
- Summary string `json:"summary"`
+ Slug string `json:"slug"`
+ Title string `json:"title"`
+ Summary string `json:"summary"`
+ ParentSlug string `json:"parent_slug,omitempty"`
+ CategoryPath StringArray `json:"category_path,omitempty"`
+ WikiPath string `json:"wiki_path,omitempty"`
+ Depth int `json:"depth,omitempty"`
+ SortOrder int `json:"sort_order,omitempty"`
}
// WikiIndexGroup bundles the entries for one page_type into a page-sized
@@ -397,4 +435,3 @@ type WikiPageLite struct {
Aliases StringArray `json:"aliases,omitempty"`
OutLinks StringArray `json:"out_links,omitempty"`
}
-
diff --git a/migrations/versioned/000037_wiki_and_indexing.up.sql b/migrations/versioned/000037_wiki_and_indexing.up.sql
index 3b1dfecb2..36bae6ace 100644
--- a/migrations/versioned/000037_wiki_and_indexing.up.sql
+++ b/migrations/versioned/000037_wiki_and_indexing.up.sql
@@ -22,6 +22,11 @@ CREATE TABLE IF NOT EXISTS wiki_pages (
status VARCHAR(32) NOT NULL DEFAULT 'published',
content TEXT NOT NULL DEFAULT '',
summary TEXT NOT NULL DEFAULT '',
+ parent_slug VARCHAR(255) NOT NULL DEFAULT '',
+ category_path JSONB DEFAULT '[]'::JSONB,
+ wiki_path VARCHAR(1024) NOT NULL DEFAULT '',
+ depth INT NOT NULL DEFAULT 0,
+ sort_order INT NOT NULL DEFAULT 0,
source_refs JSONB DEFAULT '[]'::JSONB,
chunk_refs JSONB DEFAULT '[]'::JSONB,
in_links JSONB DEFAULT '[]'::JSONB,
@@ -45,6 +50,12 @@ CREATE INDEX IF NOT EXISTS idx_wiki_pages_kb_id
CREATE INDEX IF NOT EXISTS idx_wiki_pages_page_type
ON wiki_pages (knowledge_base_id, page_type);
+CREATE INDEX IF NOT EXISTS idx_wiki_pages_parent_slug
+ ON wiki_pages (knowledge_base_id, parent_slug);
+
+CREATE INDEX IF NOT EXISTS idx_wiki_pages_tree
+ ON wiki_pages (knowledge_base_id, page_type, wiki_path, sort_order, title);
+
CREATE INDEX IF NOT EXISTS idx_wiki_pages_tenant_id
ON wiki_pages (tenant_id);
diff --git a/migrations/versioned/000061_wiki_page_hierarchy.down.sql b/migrations/versioned/000061_wiki_page_hierarchy.down.sql
new file mode 100644
index 000000000..ee9168aa2
--- /dev/null
+++ b/migrations/versioned/000061_wiki_page_hierarchy.down.sql
@@ -0,0 +1,10 @@
+-- Migration: 000061_wiki_page_hierarchy (rollback)
+
+DROP INDEX IF EXISTS idx_wiki_pages_tree;
+DROP INDEX IF EXISTS idx_wiki_pages_parent_slug;
+
+ALTER TABLE wiki_pages DROP COLUMN IF EXISTS sort_order;
+ALTER TABLE wiki_pages DROP COLUMN IF EXISTS depth;
+ALTER TABLE wiki_pages DROP COLUMN IF EXISTS wiki_path;
+ALTER TABLE wiki_pages DROP COLUMN IF EXISTS category_path;
+ALTER TABLE wiki_pages DROP COLUMN IF EXISTS parent_slug;
diff --git a/migrations/versioned/000061_wiki_page_hierarchy.up.sql b/migrations/versioned/000061_wiki_page_hierarchy.up.sql
new file mode 100644
index 000000000..b10f29798
--- /dev/null
+++ b/migrations/versioned/000061_wiki_page_hierarchy.up.sql
@@ -0,0 +1,29 @@
+-- Migration: 000061_wiki_page_hierarchy
+-- Description: Add structured directory hierarchy fields to wiki_pages.
+
+DO $$ BEGIN RAISE NOTICE '[Migration 000061] Applying wiki page hierarchy schema'; END $$;
+
+ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS parent_slug VARCHAR(255) NOT NULL DEFAULT '';
+ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS category_path JSONB DEFAULT '[]'::JSONB;
+ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS wiki_path VARCHAR(1024) NOT NULL DEFAULT '';
+ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS depth INT NOT NULL DEFAULT 0;
+ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS sort_order INT NOT NULL DEFAULT 0;
+
+UPDATE wiki_pages
+SET
+ category_path = COALESCE(category_path, '[]'::JSONB),
+ depth = COALESCE(depth, 0),
+ wiki_path = CASE
+ WHEN COALESCE(wiki_path, '') <> '' THEN wiki_path
+ WHEN page_type IN ('index', 'log') THEN page_type || '/' || COALESCE(NULLIF(title, ''), slug)
+ ELSE page_type || '/' || COALESCE(NULLIF(title, ''), slug)
+ END
+WHERE wiki_path = '' OR wiki_path IS NULL OR category_path IS NULL OR depth IS NULL;
+
+CREATE INDEX IF NOT EXISTS idx_wiki_pages_parent_slug
+ ON wiki_pages (knowledge_base_id, parent_slug);
+
+CREATE INDEX IF NOT EXISTS idx_wiki_pages_tree
+ ON wiki_pages (knowledge_base_id, page_type, wiki_path, sort_order, title);
+
+DO $$ BEGIN RAISE NOTICE '[Migration 000061] wiki page hierarchy schema applied successfully'; END $$;