This commit is contained in:
amb
2026-09-01 20:51:46 +08:00
parent 70ece67910
commit b80161972a
18 changed files with 1878 additions and 359 deletions
+199 -20
View File
@@ -1,7 +1,8 @@
"""文档分类 API 路由。"""
"""文档分类 API 路由(树形目录结构)"""
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_db
@@ -13,73 +14,219 @@ from app.repositories.kb_repo import KnowledgeBaseRepository
router = APIRouter(prefix="/knowledge-bases/{kb_id}/categories", tags=["categories"])
class CategoryRequest(BaseModel):
class CategoryCreateRequest(BaseModel):
name: str = Field(min_length=1, max_length=255)
parent_id: str | None = None # NULL = 顶层
sort_order: int = 0
is_folder: bool = True
class CategoryResponse(BaseModel):
class CategoryUpdateRequest(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=255)
parent_id: str | None = None # 移动到新父节点
sort_order: int | None = None
is_folder: bool | None = None
class CategoryTreeNode(BaseModel):
id: str
name: str
path: str
is_folder: bool
sort_order: int
doc_count: int = 0
children: list["CategoryTreeNode"] = []
model_config = {"from_attributes": True}
@router.get("", response_model=list[CategoryResponse])
def list_categories(
CategoryTreeNode.model_rebuild()
@router.get("/tree", response_model=list[CategoryTreeNode])
def get_category_tree(
kb_id: str,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> list[CategoryResponse]:
) -> list[CategoryTreeNode]:
"""获取完整目录树(含文档数量)。"""
_check_kb_owner(kb_id, user, db)
# 获取所有分类
stmt = (
select(DocumentCategory)
.where(DocumentCategory.knowledge_base_id == kb_id)
.order_by(DocumentCategory.sort_order, DocumentCategory.name)
)
all_cats = list(db.scalars(stmt).all())
# 获取每个分类的文档数量
from app.models.document import Document
from sqlalchemy import func
count_stmt = (
select(Document.category_id, func.count())
.where(
Document.knowledge_base_id == kb_id,
Document.status != "DELETED",
)
.group_by(Document.category_id)
)
doc_count_map = dict(db.execute(count_stmt).all())
# 构建树
cat_map = {}
for cat in all_cats:
cat_map[cat.id] = CategoryTreeNode(
id=cat.id,
name=cat.name,
path=cat.path or "/",
is_folder=cat.is_folder if hasattr(cat, 'is_folder') else True,
sort_order=cat.sort_order,
doc_count=doc_count_map.get(cat.id, 0),
children=[],
)
# 构建父子关系
roots = []
for cat in all_cats:
node = cat_map[cat.id]
if cat.parent_id and cat.parent_id in cat_map:
cat_map[cat.parent_id].children.append(node)
else:
roots.append(node)
return roots
@router.get("", response_model=list[CategoryTreeNode])
def list_categories_flat(
kb_id: str,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> list[dict]:
"""获取平铺的分类列表。"""
_check_kb_owner(kb_id, user, db)
from sqlalchemy import select
stmt = (
select(DocumentCategory)
.where(DocumentCategory.knowledge_base_id == kb_id)
.order_by(DocumentCategory.sort_order)
.order_by(DocumentCategory.path, DocumentCategory.sort_order)
)
cats = list(db.scalars(stmt).all())
return [CategoryResponse.model_validate(c) for c in cats]
return [
{
"id": c.id,
"name": c.name,
"path": c.path,
"is_folder": c.is_folder if hasattr(c, 'is_folder') else True,
"sort_order": c.sort_order,
"parent_id": c.parent_id,
}
for c in cats
]
@router.post("", response_model=CategoryResponse, status_code=201)
@router.post("", response_model=CategoryTreeNode, status_code=201)
def create_category(
kb_id: str,
body: CategoryRequest,
body: CategoryCreateRequest,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> CategoryResponse:
) -> CategoryTreeNode:
"""创建分类(支持子目录)。"""
_check_kb_owner(kb_id, user, db)
# 计算 path
parent_path = "/"
if body.parent_id:
parent = db.get(DocumentCategory, body.parent_id)
if parent is None or parent.knowledge_base_id != kb_id:
raise NotFoundError("父分类不存在。")
parent_path = parent.path
# 生成 path
cat_id_placeholder = "new" # 创建后会更新
path = f"{parent_path}{body.name}/"
cat = DocumentCategory(
knowledge_base_id=kb_id,
parent_id=body.parent_id,
name=body.name,
path=path,
is_folder=body.is_folder,
sort_order=body.sort_order,
)
db.add(cat)
db.flush()
# 更新 path 使用实际 ID
cat.path = f"{parent_path}{body.name}/"
db.commit()
db.refresh(cat)
return CategoryResponse.model_validate(cat)
return CategoryTreeNode(
id=cat.id,
name=cat.name,
path=cat.path,
is_folder=cat.is_folder,
sort_order=cat.sort_order,
doc_count=0,
children=[],
)
@router.put("/{cat_id}", response_model=CategoryResponse)
@router.put("/{cat_id}", response_model=CategoryTreeNode)
def update_category(
kb_id: str,
cat_id: str,
body: CategoryRequest,
body: CategoryUpdateRequest,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> CategoryResponse:
) -> CategoryTreeNode:
"""更新分类(改名、移动、改类型)。"""
_check_kb_owner(kb_id, user, db)
cat = db.get(DocumentCategory, cat_id)
if cat is None or cat.knowledge_base_id != kb_id:
raise NotFoundError("分类不存在。")
cat.name = body.name
cat.sort_order = body.sort_order
old_path = cat.path
if body.name is not None:
cat.name = body.name
if body.sort_order is not None:
cat.sort_order = body.sort_order
if body.is_folder is not None:
cat.is_folder = body.is_folder
if body.parent_id is not None:
cat.parent_id = body.parent_id
# 重新计算 path
if body.parent_id is not None or body.name is not None:
parent_path = "/"
if cat.parent_id:
parent = db.get(DocumentCategory, cat.parent_id)
if parent:
parent_path = parent.path
new_path = f"{parent_path}{cat.name}/"
# 更新所有子节点的 path
if old_path != new_path:
_update_children_paths(db, kb_id, old_path, new_path)
cat.path = new_path
db.commit()
db.refresh(cat)
return CategoryResponse.model_validate(cat)
return CategoryTreeNode(
id=cat.id,
name=cat.name,
path=cat.path,
is_folder=cat.is_folder,
sort_order=cat.sort_order,
doc_count=0,
children=[],
)
@router.delete("/{cat_id}", status_code=204)
@@ -89,16 +236,48 @@ def delete_category(
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> None:
"""删除分类(同时删除子分类,关联文档的 category_id 置 NULL)。"""
_check_kb_owner(kb_id, user, db)
cat = db.get(DocumentCategory, cat_id)
if cat is None or cat.knowledge_base_id != kb_id:
raise NotFoundError("分类不存在。")
# 删除所有子分类
if cat.path:
stmt = select(DocumentCategory).where(
DocumentCategory.knowledge_base_id == kb_id,
DocumentCategory.path.startswith(cat.path),
DocumentCategory.id != cat_id,
)
children = list(db.scalars(stmt).all())
for child in children:
db.delete(child)
# 关联文档的 category_id 置 NULL
from app.models.document import Document
doc_stmt = select(Document).where(Document.category_id == cat_id)
docs = list(db.scalars(doc_stmt).all())
for doc in docs:
doc.category_id = None
db.delete(cat)
db.commit()
def _update_children_paths(db: Session, kb_id: str, old_path: str, new_path: str) -> None:
"""更新所有子节点的 path。"""
stmt = select(DocumentCategory).where(
DocumentCategory.knowledge_base_id == kb_id,
DocumentCategory.path.startswith(old_path),
)
children = list(db.scalars(stmt).all())
for child in children:
child.path = child.path.replace(old_path, new_path, 1)
def _check_kb_owner(kb_id: str, user: User, db: Session) -> None:
repo = KnowledgeBaseRepository(db)
kb = repo.get_by_id(kb_id)
if kb is None or kb.user_id != user.id or kb.status == "DELETED":
raise NotFoundError("知识库不存在。")
raise NotFoundError("知识库不存在。")
+30
View File
@@ -1,6 +1,7 @@
"""文档 API 路由。"""
from fastapi import APIRouter, Depends, Query, UploadFile, File, Form
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_db
@@ -16,10 +17,19 @@ from app.services.doc_service import DocumentService
router = APIRouter(prefix="/documents", tags=["documents"])
class CreateTextRequest(BaseModel):
kb_id: str
title: str = Field(min_length=1, max_length=512)
content: str = Field(min_length=1)
content_format: str = "markdown" # markdown / text
category_id: str | None = None
@router.post("/upload", response_model=DocumentUploadResponse, status_code=201)
async def upload_document(
kb_id: str = Form(...),
file: UploadFile = File(...),
category_id: str = Form(None),
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> DocumentUploadResponse:
@@ -31,6 +41,7 @@ async def upload_document(
kb_id=kb_id,
filename=file.filename or "unknown",
content=content,
category_id=category_id,
)
return DocumentUploadResponse(
id=doc.id,
@@ -41,6 +52,25 @@ async def upload_document(
)
@router.post("/create-text", response_model=DocumentResponse, status_code=201)
def create_text_document(
body: CreateTextRequest,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> DocumentResponse:
"""直接创建文本内容文档(无需上传文件)。"""
svc = DocumentService(db)
doc = svc.create_text(
user=user,
kb_id=body.kb_id,
title=body.title,
content=body.content,
content_format=body.content_format,
category_id=body.category_id,
)
return _to_response(doc)
@router.get("", response_model=DocumentListResponse)
def list_documents(
kb_id: str = Query(...),
+13 -1
View File
@@ -114,12 +114,24 @@ async def app_error_handler(_: Request, exc: AppError) -> JSONResponse:
async def validation_error_handler(_: Request, exc: RequestValidationError) -> JSONResponse:
# 序列化错误详情,过滤不可 JSON 序列化的对象
errors = []
for err in exc.errors():
clean_err = {
"type": err.get("type", ""),
"loc": err.get("loc", []),
"msg": err.get("msg", ""),
}
if "input" in err:
clean_err["input"] = str(err["input"])
errors.append(clean_err)
return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content={
"code": "VALIDATION_ERROR",
"message": "请求参数不正确。",
"detail": exc.errors(),
"detail": errors,
},
)
+22 -8
View File
@@ -37,12 +37,23 @@ class Document(UUIDPrimaryKeyMixin, TimestampMixin, Base):
original_filename: Mapped[str] = mapped_column(
String(512),
nullable=False,
comment="原始文件名 (仅展示)",
comment="原始文件名或文本标题 (仅展示)",
)
storage_path: Mapped[str] = mapped_column(
storage_path: Mapped[str | None] = mapped_column(
String(1024),
nullable=True,
comment="相对于 data/ 的物理存储路径(文本内容文档为 NULL)",
)
content: Mapped[str | None] = mapped_column(
Text,
nullable=True,
comment="直接输入的文本内容(非文件上传时使用)",
)
content_format: Mapped[str] = mapped_column(
String(16),
default="markdown",
nullable=False,
comment="相对于 data/ 的物理存储路径",
comment="内容格式:markdown / text",
)
markdown_path: Mapped[str | None] = mapped_column(
String(1024),
@@ -51,23 +62,26 @@ class Document(UUIDPrimaryKeyMixin, TimestampMixin, Base):
)
file_size: Mapped[int] = mapped_column(
Integer,
default=0,
nullable=False,
comment="文件大小 (字节)",
comment="文件大小 (字节),文本内容文档为内容长度",
)
mime_type: Mapped[str] = mapped_column(
String(127),
default="text/markdown",
nullable=False,
comment="MIME 类型",
)
file_ext: Mapped[str] = mapped_column(
String(16),
default=".md",
nullable=False,
comment="文件扩展名 (.docx/.pdf)",
comment="文件扩展名 (.docx/.pdf/.md)",
)
sha256: Mapped[str] = mapped_column(
sha256: Mapped[str | None] = mapped_column(
String(64),
nullable=False,
comment="文件 SHA-256 哈希",
nullable=True,
comment="文件 SHA-256 哈希(文本内容文档为 NULL",
)
doc_token_hash: Mapped[str] = mapped_column(
String(64),
+27 -5
View File
@@ -1,15 +1,16 @@
"""DocumentCategory 文档分类模型。"""
"""DocumentCategory 文档分类模型(支持树形目录结构)"""
from sqlalchemy import ForeignKey, Integer, String
from sqlalchemy import Boolean, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
class DocumentCategory(UUIDPrimaryKeyMixin, TimestampMixin, Base):
"""文档分类模型。
"""文档分类模型(树形结构)
技术审查 §2.2document_categories 表
通过 parent_id 实现层级关系,path 用于高效查询子树
is_folder=True 表示文件夹(可包含子项),False 表示叶子分类。
"""
__tablename__ = "document_categories"
@@ -21,11 +22,30 @@ class DocumentCategory(UUIDPrimaryKeyMixin, TimestampMixin, Base):
index=True,
comment="所属知识库 ID",
)
parent_id: Mapped[str | None] = mapped_column(
String(32),
ForeignKey("document_categories.id"),
nullable=True,
index=True,
comment="父分类 IDNULL = 顶层)",
)
name: Mapped[str] = mapped_column(
String(255),
nullable=False,
comment="分类名称",
)
path: Mapped[str] = mapped_column(
Text,
default="/",
nullable=False,
comment="物化路径,如 /01_公司层/04_岗位AI角色/",
)
is_folder: Mapped[bool] = mapped_column(
Boolean,
default=True,
nullable=False,
comment="True=文件夹(可含子项),False=叶子分类",
)
sort_order: Mapped[int] = mapped_column(
Integer,
default=0,
@@ -35,7 +55,9 @@ class DocumentCategory(UUIDPrimaryKeyMixin, TimestampMixin, Base):
# 关系
knowledge_base = relationship("KnowledgeBase", back_populates="categories", lazy="selectin")
parent = relationship("DocumentCategory", remote_side="DocumentCategory.id", lazy="selectin")
children = relationship("DocumentCategory", back_populates="parent", lazy="selectin")
documents = relationship("Document", back_populates="category", lazy="selectin")
def __repr__(self) -> str:
return f"<DocumentCategory {self.name!r} (kb={self.knowledge_base_id!r})>"
return f"<DocumentCategory {self.name!r} path={self.path!r}>"
+120 -17
View File
@@ -12,12 +12,14 @@ import json
from fastapi import APIRouter, Depends, Query, Request, Response
from fastapi.responses import HTMLResponse, PlainTextResponse
from fastapi import Path as PathParam
from sqlalchemy.orm import Session
from app.api.deps import get_db
from app.core.errors import NotFoundError, RateLimitedError
from app.core.rate_limit import check_rate_limit
from app.core.security import decrypt_token
from app.models.document_category import DocumentCategory
from app.services.access_log_service import AccessLogService
from app.services.kb_public_service import KbPublicService
@@ -151,10 +153,14 @@ def kb_index_json(
"updated_at": doc.updated_at,
})
# 获取目录树
category_tree = svc.get_category_tree(kb)
data = {
"name": kb.name,
"description": kb.description,
"document_count": len(doc_list),
"categories": category_tree,
"documents": doc_list,
}
@@ -168,15 +174,52 @@ def kb_index_json(
def kb_index_html(
token: str,
request: Request,
category: str = Query(None, description="按分类路径过滤,如 /01公司层/公司基本信息/"),
page: int = Query(1, ge=1),
db: Session = Depends(get_db),
) -> HTMLResponse:
"""知识库首页(HTML)。"""
"""知识库首页(HTML)。支持按目录过滤。"""
_rate_limit(request, token)
svc = KbPublicService(db)
kb = svc.get_kb_by_token(token)
_log_access(db, kb.id, f"/k/{token}", request, req_type="html")
docs, total = svc.list_documents(kb, page=page, page_size=50)
# 获取目录树
category_tree = svc.get_category_tree(kb)
# 按分类过滤
category_id = None
if category:
# 根据路径查找分类 ID
from sqlalchemy import select
stmt = select(DocumentCategory).where(
DocumentCategory.knowledge_base_id == kb.id,
DocumentCategory.path == category,
)
cat = db.scalars(stmt).first()
if cat:
category_id = cat.id
docs, total = svc.list_documents(kb, category_id=category_id, page=page, page_size=50)
# 渲染目录树侧边栏
def render_tree(nodes: list, level: int = 0) -> str:
html = ""
for node in nodes:
indent = " " * level
is_active = category == node["path"]
active_class = ' class="active"' if is_active else ""
doc_count = f' <span class="count">({node["doc_count"]})</span>' if node["doc_count"] > 0 else ""
if node["is_folder"]:
html += f'{indent}<li{active_class}><a href="/k/{token}?category={node["path"]}">{node["name"]}</a>{doc_count}</li>\n'
if node["children"]:
html += f'{indent}<ul>\n{render_tree(node["children"], level + 1)}{indent}</ul>\n'
else:
html += f'{indent}<li{active_class}><a href="/k/{token}?category={node["path"]}">{node["name"]}</a>{doc_count}</li>\n'
return html
tree_html = render_tree(category_tree)
doc_rows = ""
for doc in docs:
@@ -208,8 +251,19 @@ def kb_index_html(
{_robots_meta()}
{_referrer_meta()}
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; line-height: 1.6; }}
h1 {{ color: #333; }}
* {{ box-sizing: border-box; }}
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin: 0; padding: 20px; line-height: 1.6; display: flex; gap: 30px; }}
.sidebar {{ width: 280px; flex-shrink: 0; }}
.main {{ flex: 1; max-width: 800px; }}
h1 {{ color: #333; margin-top: 0; }}
.tree {{ list-style: none; padding: 0; margin: 0; }}
.tree ul {{ list-style: none; padding-left: 20px; margin: 0; }}
.tree li {{ padding: 6px 10px; border-radius: 4px; }}
.tree li:hover {{ background: #f5f7fa; }}
.tree li.active {{ background: #ecf5ff; }}
.tree a {{ color: #333; text-decoration: none; font-size: 0.95em; }}
.tree a:hover {{ color: #409eff; }}
.tree .count {{ color: #999; font-size: 0.85em; }}
.doc-item {{ border-bottom: 1px solid #eee; padding: 15px 0; }}
.doc-item h3 {{ margin: 0 0 5px 0; }}
.doc-item a {{ color: #0066cc; text-decoration: none; }}
@@ -220,17 +274,29 @@ def kb_index_html(
.pagination {{ color: #666; font-size: 0.9em; text-align: center; }}
.status-badge {{ color: #e67e22; font-size: 0.8em; font-weight: normal; }}
.footer {{ margin-top: 30px; padding-top: 15px; border-top: 1px solid #eee; color: #999; font-size: 0.85em; }}
.back-link {{ display: inline-block; margin-bottom: 15px; color: #0066cc; text-decoration: none; }}
.back-link:hover {{ text-decoration: underline; }}
</style>
</head>
<body>
<h1>{kb.name}</h1>
{"<p>" + kb.description + "</p>" if kb.description else ""}
<h2>文档列表</h2>
{doc_rows if doc_rows else "<p>暂无文档</p>"}
{pagination}
<div class="footer">
<p>This page is an AI-readable knowledge base index. Use the document links above to retrieve specific documents.</p>
<p>本页为 AI 可读知识库目录,请通过上述文档链接获取具体内容。</p>
<div class="sidebar">
<h2 style="margin-top: 0; font-size: 1.1em;">目录</h2>
<ul class="tree">
<li{" class='active'" if not category else ""}><a href="/k/{token}">全部文档</a> <span class="count">({total})</span></li>
{tree_html}
</ul>
</div>
<div class="main">
<h1>{kb.name}</h1>
{"<p>" + kb.description + "</p>" if kb.description else ""}
{"<a href='/k/" + token + "' class='back-link'>← 返回全部文档</a>" if category else ""}
<h2>{"当前分类:" + category if category else "文档列表"}</h2>
{doc_rows if doc_rows else "<p>暂无文档。</p>"}
{pagination}
<div class="footer">
<p>This page is an AI-readable knowledge base index. Use the document links above to retrieve specific documents.</p>
<p>本页为 AI 可读知识库目录,请通过上述文档链接获取具体内容。</p>
</div>
</div>
</body>
</html>"""
@@ -240,12 +306,49 @@ def kb_index_html(
# --- 单文档访问 ---
@router.get("/{token}/doc/{doc_token}")
def doc_page_html(
@router.get("/{token}/doc/{doc_token}.md")
def doc_page_markdown(
token: str,
doc_token: str,
request: Request,
db: Session = Depends(get_db),
) -> PlainTextResponse:
"""文档(Markdown)。"""
_rate_limit(request, token)
svc = KbPublicService(db)
kb = svc.get_kb_by_token(token)
doc = svc.get_document_by_token(kb, doc_token)
_log_access(db, kb.id, f"/k/{token}/doc/{doc_token}.md", request, doc_id=doc.id, req_type="doc_md")
markdown_content = svc.get_document_content(doc)
return PlainTextResponse(content=markdown_content, media_type="text/markdown")
@router.get("/{token}/doc/{doc_token}.txt")
def doc_page_text(
token: str,
doc_token: str,
request: Request,
db: Session = Depends(get_db),
) -> PlainTextResponse:
"""文档(纯文本)。"""
_rate_limit(request, token)
svc = KbPublicService(db)
kb = svc.get_kb_by_token(token)
doc = svc.get_document_by_token(kb, doc_token)
_log_access(db, kb.id, f"/k/{token}/doc/{doc_token}.txt", request, doc_id=doc.id, req_type="doc_txt")
markdown_content = svc.get_document_content(doc)
import re
text = re.sub(r"[#*_`\[\]()>]", "", markdown_content)
text = re.sub(r"\n{3,}", "\n\n", text)
return PlainTextResponse(content=text.strip(), media_type="text/plain")
@router.get("/{token}/doc/{doc_token}")
def doc_page_html(
token: str,
doc_token: str = PathParam(pattern=r"^[A-Za-z0-9_-]+$"),
request: Request = None,
db: Session = Depends(get_db),
) -> HTMLResponse:
"""文档页面(HTML)。"""
_rate_limit(request, token)
@@ -253,7 +356,7 @@ def doc_page_html(
kb = svc.get_kb_by_token(token)
doc = svc.get_document_by_token(kb, doc_token)
_log_access(db, kb.id, f"/k/{token}/doc/{doc_token}", request, doc_id=doc.id, req_type="doc_html")
markdown_content = svc.get_document_markdown(doc)
markdown_content = svc.get_document_content(doc)
# Markdown → HTML(简单转换)
html_content = _markdown_to_html(markdown_content)
@@ -313,7 +416,7 @@ def doc_page_markdown(
kb = svc.get_kb_by_token(token)
doc = svc.get_document_by_token(kb, doc_token)
_log_access(db, kb.id, f"/k/{token}/doc/{doc_token}.md", request, doc_id=doc.id, req_type="doc_md")
markdown_content = svc.get_document_markdown(doc)
markdown_content = svc.get_document_content(doc)
return PlainTextResponse(content=markdown_content, media_type="text/markdown")
@@ -330,7 +433,7 @@ def doc_page_text(
kb = svc.get_kb_by_token(token)
doc = svc.get_document_by_token(kb, doc_token)
_log_access(db, kb.id, f"/k/{token}/doc/{doc_token}.txt", request, doc_id=doc.id, req_type="doc_txt")
markdown_content = svc.get_document_markdown(doc)
markdown_content = svc.get_document_content(doc)
# 去掉 Markdown 标记
import re
+4 -1
View File
@@ -21,17 +21,20 @@ class DocumentRepository:
self,
kb_id: str,
*,
category_id: str | None = None,
page: int = 1,
page_size: int = 50,
status: str | None = None,
) -> tuple[list[Document], int]:
"""分页获取知识库的文档列表。"""
"""分页获取知识库的文档列表。支持按分类过滤。"""
conditions = [
Document.knowledge_base_id == kb_id,
Document.status != "DELETED",
]
if status:
conditions.append(Document.status == status)
if category_id:
conditions.append(Document.category_id == category_id)
count_stmt = select(func.count()).select_from(Document).where(*conditions)
total = self._session.scalar(count_stmt) or 0
+86
View File
@@ -41,6 +41,7 @@ class DocumentService:
kb_id: str,
filename: str,
content: bytes,
category_id: str | None = None,
) -> Document:
"""上传文档。
@@ -111,6 +112,7 @@ class DocumentService:
doc_token_hash=doc_token_hash,
doc_token_encrypted=doc_token_encrypted,
doc_token_hint=doc_token_hint,
category_id=category_id,
)
# 更新存储路径中的 document_id
@@ -200,3 +202,87 @@ class DocumentService:
processor = LocalDocumentProcessor(self._session)
processor.process(doc.id)
def create_text(
self,
user: User,
kb_id: str,
title: str,
content: str,
content_format: str = "markdown",
category_id: str | None = None,
) -> Document:
"""创建文本内容文档(无需上传文件)。"""
# 校验 KB 归属
kb = self._kb_repo.get_by_id(kb_id)
if kb is None or kb.user_id != user.id or kb.status == "DELETED":
raise NotFoundError("知识库不存在。")
# 生成文档 token
doc_token = generate_token()
doc_token_hash = hash_token(doc_token)
doc_token_encrypted = encrypt_token(doc_token)
doc_token_hint = doc_token[-8:] if len(doc_token) >= 8 else doc_token
# 计算内容大小
content_size = len(content.encode("utf-8"))
# 配额检查
self._check_quota(user, content_size)
# 从内容提取摘要和关键词
summary = self._extract_summary(content)
keywords = self._extract_keywords(content)
# 创建文档记录
doc = Document(
knowledge_base_id=kb_id,
user_id=user.id,
category_id=category_id,
original_filename=title,
storage_path=None, # 文本内容不存储文件
markdown_path=None,
content=content,
content_format=content_format,
file_size=content_size,
mime_type="text/markdown" if content_format == "markdown" else "text/plain",
file_ext=".md" if content_format == "markdown" else ".txt",
sha256=None,
doc_token_hash=doc_token_hash,
doc_token_encrypted=doc_token_encrypted,
doc_token_hint=doc_token_hint,
title=title,
content_summary=summary,
keywords=keywords,
status="READY", # 文本内容直接就绪
)
self._session.add(doc)
# 扣减配额
self._deduct_quota(user, content_size)
self._session.commit()
return doc
def _extract_summary(self, content: str, max_len: int = 200) -> str:
"""提取摘要。"""
import re
text = re.sub(r"[#*_`\[\]()>]", "", content)
text = re.sub(r"\s+", " ", text).strip()
if len(text) <= max_len:
return text
return text[:max_len] + "..."
def _extract_keywords(self, content: str, top_k: int = 10) -> str:
"""提取关键词。"""
try:
import jieba.analyse
import re
text = re.sub(r"[#*_`\[\]()>]", "", content)
text = re.sub(r"\s+", " ", text).strip()
keywords = jieba.analyse.extract_tags(text, topK=top_k)
return ",".join(keywords)
except Exception:
return ""
+77 -19
View File
@@ -1,14 +1,17 @@
"""公共知识库服务(Phase 10/11/12 统一数据来源)。
HTML/MD/TXT/JSON/搜索全部通过此 Service 获取数据,不各自写查询逻辑。
支持目录树结构和分类过滤。
"""
from app.core.errors import NotFoundError
from app.core.security import hash_token
from app.models.document import Document
from app.models.document_category import DocumentCategory
from app.models.knowledge_base import KnowledgeBase
from app.repositories.doc_repo import DocumentRepository
from app.repositories.kb_repo import KnowledgeBaseRepository
from sqlalchemy import func, select
from sqlalchemy.orm import Session
@@ -26,12 +29,55 @@ class KbPublicService:
raise NotFoundError("知识库不存在。")
return kb
def get_category_tree(self, kb: KnowledgeBase) -> list[dict]:
"""获取目录树(含文档数量)。"""
stmt = (
select(DocumentCategory)
.where(DocumentCategory.knowledge_base_id == kb.id)
.order_by(DocumentCategory.sort_order, DocumentCategory.name)
)
all_cats = list(self._session.scalars(stmt).all())
# 获取每个分类的文档数量
count_stmt = (
select(Document.category_id, func.count())
.where(
Document.knowledge_base_id == kb.id,
Document.status != "DELETED",
)
.group_by(Document.category_id)
)
doc_count_map = dict(self._session.execute(count_stmt).all())
# 构建树
cat_map = {}
for cat in all_cats:
cat_map[cat.id] = {
"id": cat.id,
"name": cat.name,
"path": cat.path or "/",
"is_folder": getattr(cat, 'is_folder', True),
"sort_order": cat.sort_order,
"doc_count": doc_count_map.get(cat.id, 0),
"children": [],
}
roots = []
for cat in all_cats:
node = cat_map[cat.id]
if cat.parent_id and cat.parent_id in cat_map:
cat_map[cat.parent_id]["children"].append(node)
else:
roots.append(node)
return roots
def list_documents(
self, kb: KnowledgeBase, *, page: int = 1, page_size: int = 50
self, kb: KnowledgeBase, *, category_id: str | None = None, page: int = 1, page_size: int = 50
) -> tuple[list[Document], int]:
"""获取知识库的文档列表(排除 DELETED)。"""
"""获取知识库的文档列表(排除 DELETED)。支持按分类过滤。"""
return self._doc_repo.list_by_knowledge_base(
kb.id, page=page, page_size=page_size
kb.id, category_id=category_id, page=page, page_size=page_size
)
def get_document_by_token(self, kb: KnowledgeBase, doc_token: str) -> Document:
@@ -42,34 +88,38 @@ class KbPublicService:
raise NotFoundError("文档不存在。")
return doc
def get_document_markdown(self, doc: Document) -> str:
"""取文档 Markdown 内容"""
from app.storage.local_storage import get_storage
def get_document_content(self, doc: Document) -> str:
"""取文档内容(优先直接内容,其次 Markdown 文件)"""
# 直接文本内容
if doc.content:
return doc.content
if not doc.markdown_path:
return ""
storage = get_storage()
content = storage.read(doc.markdown_path)
return content.decode("utf-8")
# 从文件读取 Markdown
if doc.markdown_path:
from app.storage.local_storage import get_storage
storage = get_storage()
content = storage.read(doc.markdown_path)
return content.decode("utf-8")
return ""
def search_documents(
self, kb: KnowledgeBase, query: str, *, page: int = 1, page_size: int = 20
) -> tuple[list[dict], int]:
"""关键词搜索文档(Phase 12SQLite LIKE / FTS5)。"""
# MVP 简化:使用 LIKE 搜索标题+描述+关键词
from sqlalchemy import func, or_, select
conditions = [
Document.knowledge_base_id == kb.id,
Document.status == "READY",
]
like_pattern = f"%{query}%"
search_condition = or_(
Document.title.like(like_pattern),
Document.description.like(like_pattern),
Document.keywords.like(like_pattern),
Document.content_summary.like(like_pattern),
search_condition = (
Document.title.like(like_pattern)
| Document.description.like(like_pattern)
| Document.keywords.like(like_pattern)
| Document.content_summary.like(like_pattern)
| Document.content.like(like_pattern)
)
conditions.append(search_condition)
@@ -87,6 +137,13 @@ class KbPublicService:
results = []
for doc in docs:
# 获取分类路径
category_path = ""
if doc.category_id:
cat = self._session.get(DocumentCategory, doc.category_id)
if cat:
category_path = cat.path or ""
results.append({
"id": doc.id,
"title": doc.title or doc.original_filename,
@@ -95,6 +152,7 @@ class KbPublicService:
"file_type": doc.file_ext,
"updated_at": doc.updated_at,
"url_hint": doc.doc_token_hint,
"category_path": category_path,
})
return results, total
+40 -1
View File
@@ -1,6 +1,7 @@
"""知识库服务:CRUD + Token 管理。"""
"""知识库服务:CRUD + Token 管理 + 默认目录树"""
from app.core.errors import NotFoundError, PermissionDeniedError
from app.models.document_category import DocumentCategory
from app.models.knowledge_base import KnowledgeBase
from app.models.user import User
from app.repositories.kb_repo import KnowledgeBaseRepository
@@ -18,6 +19,7 @@ class KnowledgeBaseService:
"""创建知识库。返回 (kb, full_token)。
full_token 仅此一次返回,用于构建完整 AI URL。
自动创建默认目录树结构。
"""
token, token_hash, token_encrypted, token_hint = self._token_svc.create_token_pair()
kb = self._kb_repo.create(
@@ -28,9 +30,46 @@ class KnowledgeBaseService:
token_encrypted=token_encrypted,
token_hint=token_hint,
)
self._seed_default_categories(kb.id)
self._session.commit()
return kb, token
def _seed_default_categories(self, kb_id: str) -> None:
"""创建默认目录树结构。"""
default_tree = [
("01 公司层", ["公司基本信息", "经营理念", "四大价值", "A/M/B三态"]),
("02 战略层", ["公司战略", "客户战略", "AI战略", "产品战略"]),
("03 部门层", ["企划", "技术", "交付", "市场"]),
("04 岗位/AI角色", []),
("05 业务知识", ["价值创造", "价值传递", "价值交付", "价值支持"]),
("06 资产库", ["文案", "SOP", "模板", "案例", "Prompt"]),
]
for i, (folder_name, children) in enumerate(default_tree):
# 创建顶层文件夹
folder = DocumentCategory(
knowledge_base_id=kb_id,
parent_id=None,
name=folder_name,
path=f"/{folder_name}/",
is_folder=True,
sort_order=i,
)
self._session.add(folder)
self._session.flush()
# 创建子分类
for j, child_name in enumerate(children):
child = DocumentCategory(
knowledge_base_id=kb_id,
parent_id=folder.id,
name=child_name,
path=f"/{folder_name}/{child_name}/",
is_folder=False,
sort_order=j,
)
self._session.add(child)
def get_or_404(self, kb_id: str, user: User) -> KnowledgeBase:
"""获取知识库,校验所有权。不存在或无权 → 404。"""
kb = self._kb_repo.get_by_id(kb_id)