diff --git a/backend/app/public/routes.py b/backend/app/public/routes.py index 27edd67..3913541 100644 --- a/backend/app/public/routes.py +++ b/backend/app/public/routes.py @@ -1,5 +1,11 @@ """公共 AI 页面路由(/k/**)。 +核心设计: +- 主页 /k/{token} = 目录索引(TOC),显示所有目录及其文档数量 +- 目录页 /k/{token}/category/{path} = 该目录下的文档列表 +- 文档页 /k/{token}/doc/{doc_token} = 单个文档内容 +- AI 先看目录索引 → 根据用户描述选择目录 → 进入该目录看文档 + 规则: - 零 JS、零 Cookie、零登录、SSR 输出、标准 HTML - @@ -9,6 +15,7 @@ """ import json +import re from fastapi import APIRouter, Depends, Query, Request, Response from fastapi.responses import HTMLResponse, PlainTextResponse @@ -27,7 +34,7 @@ router = APIRouter(prefix="/k", tags=["public"]) def _log_access(db: Session, kb_id: str, path: str, request: Request, doc_id: str | None = None, req_type: str | None = None) -> None: - """记录访问日志(best-effort,不阻塞响应)。""" + """记录访问日志(best-effort)。""" try: ua = request.headers.get("user-agent", "") AccessLogService(db).record( @@ -38,11 +45,10 @@ def _log_access(db: Session, kb_id: str, path: str, request: Request, doc_id: st request_type=req_type, ) except Exception: - pass # 日志失败不影响响应 + pass def _rate_limit(request: Request, token: str) -> None: - """限流检查。""" ip = request.client.host if request.client else "unknown" if not check_rate_limit(token_key=token[:16], ip_key=ip): raise RateLimitedError() @@ -56,7 +62,87 @@ def _referrer_meta() -> str: return '' -# --- 知识库入口(后缀路由必须先于无后缀路由注册)--- +def _get_category_by_path(db: Session, kb_id: str, path: str) -> DocumentCategory | None: + """根据路径查找分类。""" + from sqlalchemy import select + stmt = select(DocumentCategory).where( + DocumentCategory.knowledge_base_id == kb_id, + DocumentCategory.path == path, + ) + return db.scalars(stmt).first() + + +# ============================================================ +# 目录索引(主页)- AI 从这里了解知识库结构 +# ============================================================ + + +@router.get("/{token}.json") +def kb_index_json( + token: str, + request: Request, + db: Session = Depends(get_db), +) -> Response: + """知识库完整内容(JSON)- 按目录分组,包含所有文档内容。 + + AI 访问此接口获取知识库全部内容,按目录结构组织。 + """ + _rate_limit(request, token) + svc = KbPublicService(db) + kb = svc.get_kb_by_token(token) + _log_access(db, kb.id, f"/k/{token}.json", request, req_type="json") + + category_tree = svc.get_category_tree(kb) + + # 获取所有文档(按目录分组) + docs, _ = svc.list_documents(kb) + + # 按 category_id 分组 + doc_map: dict[str, list] = {} + for doc in docs: + cat_id = doc.category_id or "__uncategorized__" + if cat_id not in doc_map: + doc_map[cat_id] = [] + doc_map[cat_id].append({ + "title": doc.title or doc.original_filename, + "file_type": doc.file_ext, + "description": doc.description, + "summary": doc.content_summary, + "keywords": doc.keywords.split(",") if doc.keywords else [], + "updated_at": doc.updated_at, + }) + + # 递归构建目录 + 文档 + def build_tree_with_docs(nodes: list) -> list: + result = [] + for node in nodes: + cat_docs = doc_map.get(node["id"], []) + node_data = { + "name": node["name"], + "path": node["path"], + "is_folder": node["is_folder"], + "documents": cat_docs, + "children": build_tree_with_docs(node.get("children", [])), + } + result.append(node_data) + return result + + tree_with_docs = build_tree_with_docs(category_tree) + + # 未分类文档 + uncategorized = doc_map.get("__uncategorized__", []) + + data = { + "name": kb.name, + "description": kb.description, + "categories": tree_with_docs, + "uncategorized_documents": uncategorized, + } + + return Response( + content=json.dumps(data, ensure_ascii=False, indent=2), + media_type="application/json", + ) @router.get("/{token}.md") @@ -65,33 +151,39 @@ def kb_index_markdown( request: Request, db: Session = Depends(get_db), ) -> PlainTextResponse: - """知识库首页(Markdown)。""" + """知识库目录索引(Markdown)。""" _rate_limit(request, token) svc = KbPublicService(db) kb = svc.get_kb_by_token(token) _log_access(db, kb.id, f"/k/{token}.md", request, req_type="md") - docs, _ = svc.list_documents(kb) + + category_tree = svc.get_category_tree(kb) lines = [f"# {kb.name}", ""] if kb.description: lines.append(kb.description) lines.append("") - lines.append("## 文档列表") + lines.append("## 目录结构") + lines.append("") + lines.append("请通过以下目录链接访问对应内容:") lines.append("") - for doc in docs: - title = doc.title or doc.original_filename - lines.append(f"### {title}") - lines.append(f"- 类型:{doc.file_ext}") - if doc.description: - lines.append(f"- 描述:{doc.description}") - if doc.keywords: - lines.append(f"- 关键词:{doc.keywords}") - if doc.content_summary: - lines.append(f"- 摘要:{doc.content_summary}") - lines.append(f"- 更新时间:{doc.updated_at}") - lines.append("") + def render_tree_md(nodes: list, level: int = 0): + for node in nodes: + prefix = " " * level + icon = "📁" if node["is_folder"] else "📄" + doc_count = f" ({node['doc_count']}篇)" if node["doc_count"] > 0 else "" + url = f"/k/{token}/category{node['path']}" + lines.append(f"{prefix}- {icon} [{node['name']}]({url}){doc_count}") + if node.get("children"): + render_tree_md(node["children"], level + 1) + + render_tree_md(category_tree) + + lines.append("") + lines.append("---") + lines.append(f"访问各目录链接查看具体文档。如需搜索:/k/{token}/search?q=关键词") return PlainTextResponse(content="\n".join(lines), media_type="text/markdown") @@ -102,48 +194,272 @@ def kb_index_text( request: Request, db: Session = Depends(get_db), ) -> PlainTextResponse: - """知识库首页(纯文本)。""" + """知识库目录索引(纯文本)。""" _rate_limit(request, token) svc = KbPublicService(db) kb = svc.get_kb_by_token(token) _log_access(db, kb.id, f"/k/{token}.txt", request, req_type="txt") - docs, _ = svc.list_documents(kb) + + category_tree = svc.get_category_tree(kb) lines = [kb.name, "=" * len(kb.name), ""] if kb.description: lines.append(kb.description) lines.append("") - lines.append("文档列表:") + lines.append("目录结构:") lines.append("") - for i, doc in enumerate(docs, 1): - title = doc.title or doc.original_filename - lines.append(f"{i}. {title}") - if doc.description: - lines.append(f" 描述:{doc.description}") - if doc.keywords: - lines.append(f" 关键词:{doc.keywords}") - lines.append("") + def render_tree_txt(nodes: list, level: int = 0): + for node in nodes: + prefix = " " * level + icon = "[文件夹]" if node["is_folder"] else "[文档]" + doc_count = f" ({node['doc_count']}篇)" if node["doc_count"] > 0 else "" + url = f"/k/{token}/category{node['path']}" + lines.append(f"{prefix}{icon} {node['name']}{doc_count}") + lines.append(f"{prefix} → {url}") + if node.get("children"): + render_tree_txt(node["children"], level + 1) + + render_tree_txt(category_tree) return PlainTextResponse(content="\n".join(lines), media_type="text/plain") -@router.get("/{token}.json") -def kb_index_json( +@router.get("/{token}") +def kb_index_html( token: str, request: Request, db: Session = Depends(get_db), -) -> Response: - """知识库首页(JSON)。""" +) -> HTMLResponse: + """知识库完整内容(HTML)- 按目录分组显示所有文档。 + + AI 直接读取此页面即可获取全部内容,无需点击。 + 人类用户可通过左侧目录快速跳转到对应章节。 + """ _rate_limit(request, token) svc = KbPublicService(db) kb = svc.get_kb_by_token(token) - _log_access(db, kb.id, f"/k/{token}.json", request, req_type="json") + _log_access(db, kb.id, f"/k/{token}", request, req_type="html") + + category_tree = svc.get_category_tree(kb) docs, _ = svc.list_documents(kb) + # 按 category_id 分组 + doc_map: dict[str, list] = {} + for doc in docs: + cat_id = doc.category_id or "__uncategorized__" + if cat_id not in doc_map: + doc_map[cat_id] = [] + doc_map[cat_id].append(doc) + + # 递归渲染目录 + 文档(按目录顺序) + section_counter = [0] + + def render_section(nodes: list, level: int = 0) -> tuple[str, str]: + """返回 (内容HTML, 目录HTML)""" + content_html = "" + toc_html = "" + for node in nodes: + section_counter[0] += 1 + section_id = f"section-{section_counter[0]}" + icon = "📁" if node["is_folder"] else "📄" + heading_tag = "h2" if level == 0 else "h3" if level == 1 else "h4" + indent = " " * level + + # 目录项 + doc_count = f" ({node['doc_count']}篇)" if node["doc_count"] > 0 else "" + toc_html += f'{indent}
' + toc_html += f'{icon} {node["name"]}{doc_count}
\n' + + # 内容标题 + content_html += f'<{heading_tag} id="{section_id}">{icon} {node["name"]}\n' + + # 该目录下的文档 + cat_docs = doc_map.get(node["id"], []) + for doc in cat_docs: + doc_token = decrypt_token(doc.doc_token_encrypted) if doc.doc_token_encrypted else "" + doc_url = f"/k/{token}/doc/{doc_token}" + summary = doc.content_summary or "" + keywords = doc.keywords or "" + status_badge = f' [{doc.status}]' if doc.status != "READY" else "" + + content_html += f'
\n' + content_html += f'

{doc.title or doc.original_filename}{status_badge}

\n' + content_html += f'

类型:{doc.file_ext} | 更新:{doc.updated_at}

\n' + if summary: + content_html += f'

{summary}

\n' + if keywords: + content_html += f'

关键词:{keywords}

\n' + content_html += f'
\n' + + # 子目录 + if node.get("children"): + child_content, child_toc = render_section(node["children"], level + 1) + content_html += child_content + toc_html += child_toc + + return content_html, toc_html + + all_content, all_toc = render_section(category_tree) + + # 未分类文档 + uncategorized = doc_map.get("__uncategorized__", []) + if uncategorized: + all_content += '

📄 未分类文档

\n' + for doc in uncategorized: + doc_token = decrypt_token(doc.doc_token_encrypted) if doc.doc_token_encrypted else "" + doc_url = f"/k/{token}/doc/{doc_token}" + all_content += f'

{doc.title or doc.original_filename}

\n' + + html = f""" + + + + + {kb.name} + {_robots_meta()} + {_referrer_meta()} + + + +
+ +
+ +
+ + +

{kb.name}

+ {"

" + kb.description + "

" if kb.description else ""} + + {all_content} + + +
+
+ + +
+ + +
+ + + +""" + return HTMLResponse(content=html) + + +# ============================================================ +# 目录页面 - 显示某个目录下的文档 +# ============================================================ + + +@router.get("/{token}/category/{path:path}.json") +def category_json( + token: str, + path: str, + request: Request, + db: Session = Depends(get_db), +) -> Response: + """目录下的文档列表(JSON)。 + + path 格式:01公司层/公司基本信息(不含前后斜杠) + """ + _rate_limit(request, token) + svc = KbPublicService(db) + kb = svc.get_kb_by_token(token) + + # 规范化路径 + category_path = f"/{path.strip('/')}/" + cat = _get_category_by_path(db, kb.id, category_path) + if cat is None: + raise NotFoundError("目录不存在。") + + _log_access(db, kb.id, f"/k/{token}/category/{path}.json", request, req_type="category_json") + + # 获取该目录下的文档 + docs, total = svc.list_documents(kb, category_id=cat.id) + doc_list = [] for doc in docs: + doc_token = decrypt_token(doc.doc_token_encrypted) if doc.doc_token_encrypted else "" doc_list.append({ "title": doc.title or doc.original_filename, "file_type": doc.file_ext, @@ -151,17 +467,15 @@ def kb_index_json( "summary": doc.content_summary, "keywords": doc.keywords.split(",") if doc.keywords else [], "updated_at": doc.updated_at, + "url": f"/k/{token}/doc/{doc_token}", }) - # 获取目录树 - category_tree = svc.get_category_tree(kb) - data = { - "name": kb.name, - "description": kb.description, - "document_count": len(doc_list), - "categories": category_tree, + "category": cat.name, + "category_path": cat.path, + "document_count": total, "documents": doc_list, + "back_to_index": f"/k/{token}.json", } return Response( @@ -170,140 +484,176 @@ def kb_index_json( ) -@router.get("/{token}") -def kb_index_html( +@router.get("/{token}/category/{path:path}.md") +def category_markdown( token: str, + path: str, request: Request, - category: str = Query(None, description="按分类路径过滤,如 /01公司层/公司基本信息/"), - page: int = Query(1, ge=1), db: Session = Depends(get_db), -) -> HTMLResponse: - """知识库首页(HTML)。支持按目录过滤。""" +) -> PlainTextResponse: + """目录下的文档列表(Markdown)。""" _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") - # 获取目录树 - category_tree = svc.get_category_tree(kb) + category_path = f"/{path.strip('/')}/" + cat = _get_category_by_path(db, kb.id, category_path) + if cat is None: + raise NotFoundError("目录不存在。") - # 按分类过滤 - 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 + _log_access(db, kb.id, f"/k/{token}/category/{path}.md", request, req_type="category_md") - docs, total = svc.list_documents(kb, category_id=category_id, page=page, page_size=50) + docs, _ = svc.list_documents(kb, category_id=cat.id) - # 渲染目录树侧边栏 - 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' ({node["doc_count"]})' if node["doc_count"] > 0 else "" + lines = [f"# {cat.name}", ""] + lines.append(f"知识库:{kb.name}") + lines.append("") - if node["is_folder"]: - html += f'{indent}{node["name"]}{doc_count}\n' - if node["children"]: - html += f'{indent}\n' - else: - html += f'{indent}{node["name"]}{doc_count}\n' - return html + if docs: + lines.append("## 文档列表") + lines.append("") + for doc in docs: + title = doc.title or doc.original_filename + lines.append(f"### {title}") + if doc.description: + lines.append(f"- 描述:{doc.description}") + if doc.keywords: + lines.append(f"- 关键词:{doc.keywords}") + if doc.content_summary: + lines.append(f"- 摘要:{doc.content_summary}") + lines.append(f"- 更新:{doc.updated_at}") + lines.append("") + else: + lines.append("暂无文档。") - tree_html = render_tree(category_tree) + return PlainTextResponse(content="\n".join(lines), media_type="text/markdown") + + +@router.get("/{token}/category/{path:path}.txt") +def category_text( + token: str, + path: str, + request: Request, + db: Session = Depends(get_db), +) -> PlainTextResponse: + """目录下的文档列表(纯文本)。""" + _rate_limit(request, token) + svc = KbPublicService(db) + kb = svc.get_kb_by_token(token) + + category_path = f"/{path.strip('/')}/" + cat = _get_category_by_path(db, kb.id, category_path) + if cat is None: + raise NotFoundError("目录不存在。") + + _log_access(db, kb.id, f"/k/{token}/category/{path}.txt", request, req_type="category_txt") + + docs, _ = svc.list_documents(kb, category_id=cat.id) + + lines = [cat.name, "=" * len(cat.name), ""] + lines.append(f"知识库:{kb.name}") + lines.append("") + + if docs: + for i, doc in enumerate(docs, 1): + title = doc.title or doc.original_filename + lines.append(f"{i}. {title}") + if doc.description: + lines.append(f" 描述:{doc.description}") + if doc.keywords: + lines.append(f" 关键词:{doc.keywords}") + lines.append("") + else: + lines.append("暂无文档。") + + return PlainTextResponse(content="\n".join(lines), media_type="text/plain") + + +@router.get("/{token}/category/{path:path}") +def category_html( + token: str, + path: str, + request: Request, + db: Session = Depends(get_db), +) -> HTMLResponse: + """目录下的文档列表(HTML)。""" + _rate_limit(request, token) + svc = KbPublicService(db) + kb = svc.get_kb_by_token(token) + + category_path = f"/{path.strip('/')}/" + cat = _get_category_by_path(db, kb.id, category_path) + if cat is None: + raise NotFoundError("目录不存在。") + + _log_access(db, kb.id, f"/k/{token}/category/{path}", request, req_type="category_html") + + docs, total = svc.list_documents(kb, category_id=cat.id) doc_rows = "" for doc in docs: - doc_url = f"/k/{token}/doc/{decrypt_token(doc.doc_token_encrypted) if doc.doc_token_encrypted else ''}" - keywords_html = f'关键词:{doc.keywords}' if doc.keywords else "" + doc_token = decrypt_token(doc.doc_token_encrypted) if doc.doc_token_encrypted else "" + doc_url = f"/k/{token}/doc/{doc_token}" + keywords_html = f'

关键词:{doc.keywords}

' if doc.keywords else "" summary_html = f'

{doc.content_summary or ""}

' if doc.content_summary else "" - status_badge = "" - if doc.status != "READY": - status_badge = f' [{doc.status}]' + doc_rows += f"""
-

{doc.title or doc.original_filename}{status_badge}

+

{doc.title or doc.original_filename}

类型:{doc.file_ext} | 更新:{doc.updated_at}

{summary_html} {keywords_html}
""" - total_pages = (total + 49) // 50 - pagination = "" - if total_pages > 1: - pagination = f'

第 {page} / {total_pages} 页

' - html = f""" - {kb.name} + {cat.name} - {kb.name} {_robots_meta()} {_referrer_meta()} -