feat: add 49088 wiki service snapshot

This commit is contained in:
zc 2026-07-22 10:46:29 +08:00
parent e242d9caa8
commit 209b699640
25 changed files with 6610 additions and 773 deletions

9
.env.example Normal file
View File

@ -0,0 +1,9 @@
OPENAI_API_BASE=http://qwen3-14b:8000/v1
OPENAI_API_EMBEDDING_BASE=http://bge-m3:8000/v1
Milvus_URI=http://standalone:19350
OPENAI_EMBEDDING_MODEL=bge-m3
OPENAI_API_RERANKER_BASE=http://bge-rerank:8000/v1
OPENAI_MODEL=Qwen3-14B
OPENAI_API_KEY=replace_me
CONVERT_DOC_URL=http://libreoffice:8000
OPENAI_RERANKER_MODEL=bge-rerank

2
.gitignore vendored
View File

@ -10,6 +10,7 @@ env/
# Local env files # Local env files
.env .env
.env.* .env.*
!.env.example
# Runtime data and generated outputs # Runtime data and generated outputs
data/*.db data/*.db
@ -21,6 +22,7 @@ image_output/
# Logs and temporary files # Logs and temporary files
*.log *.log
*.tmp *.tmp
*.swp
.pytest_cache/ .pytest_cache/
# OS/editor files # OS/editor files

View File

@ -0,0 +1,24 @@
# 46 Wiki Agent
本分支保存服务器 `192.168.0.46` 上、宿主机端口 `49088` 对应的
`46-wx-agent-wiki-49088` 服务源码快照。
## Docker 启动
运行前需要满足以下条件:
- 本机已有镜像 `wx-agent:hj_v3`
- 已存在外部 Docker 网络 `weixiu-hj_test-network`,并且该网络中可以访问
Qwen、BGE、Milvus 和 LibreOffice 等依赖服务。
- 将 `.env.example` 复制为 `.env`,并填写有效的 `OPENAI_API_KEY`
启动服务:
```bash
docker compose up -d
```
服务监听容器内的 `9088` 端口,并映射到宿主机的 `49088` 端口。
运行期数据库、缓存、生成的 Word/PDF 文件、Python 字节码及编辑器临时文件
不会纳入 Git。

39
admin_routes.py Normal file
View File

@ -0,0 +1,39 @@
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from tools.conversation_cleanup import (
delete_conversation_by_chat_id,
delete_conversations_by_time,
)
router = APIRouter(prefix="/api/admin", tags=["admin"])
class DeleteConversationByChatRequest(BaseModel):
chat_id: str
class DeleteConversationsByTimeRequest(BaseModel):
before: Optional[datetime] = None
after: Optional[datetime] = None
@router.post("/conversations/delete/by-chat")
async def delete_by_chat(request: DeleteConversationByChatRequest):
if not request.chat_id.strip():
raise HTTPException(status_code=400, detail="chat_id is empty")
return await delete_conversation_by_chat_id(request.chat_id)
@router.post("/conversations/delete/by-time")
async def delete_by_time(request: DeleteConversationsByTimeRequest):
if request.before is None and request.after is None:
raise HTTPException(status_code=400, detail="before or after is required")
return await delete_conversations_by_time(
before=request.before,
after=request.after,
)

11
app.py
View File

@ -35,6 +35,8 @@ from checkpointer_config import (
CheckpointerManager, CheckpointerManager,
checkpointer_manager checkpointer_manager
) )
from admin_routes import router as admin_router
from wiki_engine.routes import router as wiki_router
# 创建 FastAPI 应用 # 创建 FastAPI 应用
app = FastAPI(max_request_size=1024 * 1024 * 10) app = FastAPI(max_request_size=1024 * 1024 * 10)
@ -53,6 +55,9 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
app.include_router(wiki_router)
app.include_router(admin_router)
# app.mount("/picture", StaticFiles(directory="/app/picture"), name="pictures") # app.mount("/picture", StaticFiles(directory="/app/picture"), name="pictures")
@ -63,6 +68,12 @@ async def startup_event():
# 初始化智能体使用统计表 # 初始化智能体使用统计表
from tools.agent_usage_statistics import init_agent_usage_table from tools.agent_usage_statistics import init_agent_usage_table
await init_agent_usage_table() await init_agent_usage_table()
from wiki_engine.db import init_wiki_tables
await init_wiki_tables()
from wiki_engine.worker import start_wiki_queue_workers
await start_wiki_queue_workers()
from tools.agent_registration import register_wiki_admin_agent
await register_wiki_admin_agent()
logger.info("应用启动完成PostgreSQL Checkpointer 已配置") logger.info("应用启动完成PostgreSQL Checkpointer 已配置")

View File

@ -90,6 +90,63 @@ class DynamicRAGConfig(dict):
# 使用动态配置类,支持多用户并发时自动读取各自的配置 # 使用动态配置类,支持多用户并发时自动读取各自的配置
RAG_CONFIG = DynamicRAGConfig() RAG_CONFIG = DynamicRAGConfig()
# ==================== Wiki pointer layer config ====================
_HTKNOW_BASE_URL_DEFAULT = (
_RAG_CONFIG_DEFAULT.get("search_endpoint", "").split("/api/v1/knowledge/search")[0].rstrip("/")
or "http://htknow:8080"
)
WIKI_CONFIG = {
"enabled": os.getenv("WIKI_ENABLED", "true").lower() == "true",
"htknow_base_url": os.getenv("HTKNOW_BASE_URL", _HTKNOW_BASE_URL_DEFAULT).rstrip("/"),
"default_user_id": os.getenv("WIKI_DEFAULT_USER_ID", "1"),
"default_user_name": os.getenv("WIKI_DEFAULT_USER_NAME", "testuser"),
"default_role": os.getenv("WIKI_DEFAULT_ROLE", "admin"),
"default_sync_limit": int(os.getenv("WIKI_DEFAULT_SYNC_LIMIT", "20")),
"max_slices_per_file": int(os.getenv("WIKI_MAX_SLICES_PER_FILE", "300")),
"max_pages_per_file": int(os.getenv("WIKI_MAX_PAGES_PER_FILE", "25")),
"llm_summary_enabled": os.getenv("WIKI_LLM_SUMMARY_ENABLED", "true").lower() == "true",
"embedding_enabled": os.getenv("WIKI_EMBEDDING_ENABLED", "false").lower() == "true",
"embedding_batch_size": int(os.getenv("WIKI_EMBEDDING_BATCH_SIZE", "16")),
"embedding_vector_dim": int(os.getenv("WIKI_EMBEDDING_VECTOR_DIM", "1024")),
"embedding_text_max_chars": int(os.getenv("WIKI_EMBEDDING_TEXT_MAX_CHARS", "2200")),
"vector_search_top_k": int(os.getenv("WIKI_VECTOR_SEARCH_TOP_K", "32")),
"slice_text_search_top_k": int(os.getenv("WIKI_SLICE_TEXT_SEARCH_TOP_K", "32")),
"page_search_top_k": int(os.getenv("WIKI_PAGE_SEARCH_TOP_K", "12")),
"vector_min_score": float(os.getenv("WIKI_VECTOR_MIN_SCORE", "0.2")),
"max_vector_candidates": int(os.getenv("WIKI_MAX_VECTOR_CANDIDATES", "50000")),
"rrf_k": int(os.getenv("WIKI_RRF_K", "60")),
"evidence_slices_per_page": int(os.getenv("WIKI_EVIDENCE_SLICES_PER_PAGE", "6")),
"language": os.getenv("WIKI_LANGUAGE", "Chinese"),
"extraction_granularity": os.getenv("WIKI_EXTRACTION_GRANULARITY", "standard"),
"full_content_max_chars": int(os.getenv("WIKI_FULL_CONTENT_MAX_CHARS", "120000")),
"citation_concurrency": int(os.getenv("WIKI_CITATION_CONCURRENCY", "3")),
"reduce_concurrency": int(os.getenv("WIKI_REDUCE_CONCURRENCY", "3")),
"llm_concurrency": int(os.getenv("WIKI_LLM_CONCURRENCY", "4")),
"llm_cache_enabled": os.getenv("WIKI_LLM_CACHE_ENABLED", "true").lower() == "true",
"llm_cache_ttl_seconds": int(os.getenv("WIKI_LLM_CACHE_TTL_SECONDS", str(7 * 24 * 3600))),
"llm_retry_count": int(os.getenv("WIKI_LLM_RETRY_COUNT", "2")),
"structured_template_enabled": os.getenv("WIKI_STRUCTURED_TEMPLATE_ENABLED", "true").lower() == "true",
"stage_progress_enabled": os.getenv("WIKI_STAGE_PROGRESS_ENABLED", "true").lower() == "true",
"finalize_enabled": os.getenv("WIKI_FINALIZE_ENABLED", "true").lower() == "true",
"finalize_debounce_seconds": int(os.getenv("WIKI_FINALIZE_DEBOUNCE_SECONDS", "20")),
"slug_lock_ttl_seconds": int(os.getenv("WIKI_SLUG_LOCK_TTL_SECONDS", "600")),
"queue_enabled": os.getenv("WIKI_QUEUE_ENABLED", "true").lower() == "true",
"queue_worker_concurrency": int(os.getenv("WIKI_QUEUE_WORKER_CONCURRENCY", "2")),
"queue_per_kb_concurrency": int(os.getenv("WIKI_QUEUE_PER_KB_CONCURRENCY", "2")),
"queue_claim_stale_seconds": int(os.getenv("WIKI_QUEUE_CLAIM_STALE_SECONDS", "1800")),
"queue_max_retries": int(os.getenv("WIKI_QUEUE_MAX_RETRIES", "3")),
}
# ==================== Rerank 服务配置 ====================
RERANK_CONFIG = {
"enabled": os.getenv("RERANK_ENABLED", "true").lower() == "true",
"endpoint": os.getenv("RERANK_ENDPOINT", "http://192.168.0.46:59600/v1/rerank"),
"model": os.getenv("RERANK_MODEL", "bge-rerank"),
"timeout": float(os.getenv("RERANK_TIMEOUT", "30")),
"max_candidates": int(os.getenv("RERANK_MAX_CANDIDATES", "24")),
"top_k": int(os.getenv("RERANK_TOP_K", "10")),
}
def set_request_user_config(x_user_id: Optional[str] = None, def set_request_user_config(x_user_id: Optional[str] = None,
x_user_name: Optional[str] = None, x_user_name: Optional[str] = None,
x_role: Optional[str] = None, x_role: Optional[str] = None,

28
docker-compose.yml Normal file
View File

@ -0,0 +1,28 @@
services:
wx-agent:
image: wx-agent:hj_v3
container_name: 46-wx-agent-wiki-49088
working_dir: /app
tty: true
stdin_open: true
restart: unless-stopped
ports:
- "49088:9088"
volumes:
- .:/app
env_file:
- .env
command:
- uvicorn
- app:app
- --host
- 0.0.0.0
- --port
- "9088"
networks:
- weixiu-hj_test-network
networks:
weixiu-hj_test-network:
external: true
name: weixiu-hj_test-network

View File

@ -4,7 +4,7 @@ from typing import List, Union
import os import os
import numpy as np import numpy as np
from openai import OpenAI from openai import OpenAI
from config import LLM_CONFIG, EMBEDDING_CONFIG from config import LLM_CONFIG, EMBEDDING_CONFIG, RERANK_CONFIG
from neo4j_graphrag.embeddings.base import Embedder from neo4j_graphrag.embeddings.base import Embedder
from openai import AsyncOpenAI from openai import AsyncOpenAI
@ -24,6 +24,25 @@ def _get_async_client() -> AsyncOpenAI:
return _async_client return _async_client
def _log_model_messages(label: str, model: str, message_list: list) -> None:
if os.getenv("LLM_LOG_FULL_PROMPTS", "false").lower() == "true":
print(label, message_list)
return
if os.getenv("LLM_LOG_CALLS", "true").lower() != "true":
return
summary = []
total_chars = 0
for message in message_list or []:
content = message.get("content") if isinstance(message, dict) else ""
if isinstance(content, str):
chars = len(content)
else:
chars = len(str(content))
total_chars += chars
summary.append({"role": message.get("role", ""), "chars": chars})
print(label, {"model": model, "messages": summary, "total_chars": total_chars})
class OpenaiAPI: class OpenaiAPI:
@staticmethod @staticmethod
async def open_api_chat_without_thinking( async def open_api_chat_without_thinking(
@ -64,7 +83,7 @@ class OpenaiAPI:
if json_output: if json_output:
kwargs["response_format"] = {"type": "json_object"} kwargs["response_format"] = {"type": "json_object"}
print("model API. history message", message_list) _log_model_messages("model API call", model, message_list)
response = await client.chat.completions.create(**kwargs) response = await client.chat.completions.create(**kwargs)
return response.choices[0].message.content return response.choices[0].message.content
@ -112,7 +131,7 @@ class OpenaiAPI:
if json_output: if json_output:
kwargs["response_format"] = {"type": "json_object"} kwargs["response_format"] = {"type": "json_object"}
print("model API stream. history message", message_list) _log_model_messages("model API stream call", model, message_list)
stream = await client.chat.completions.create(**kwargs) stream = await client.chat.completions.create(**kwargs)
async for chunk in stream: async for chunk in stream:
@ -224,6 +243,66 @@ class OpenaiAPI:
embedding = response.json()["data"][0]["embedding"] embedding = response.json()["data"][0]["embedding"]
return embedding return embedding
@staticmethod
async def rerank(
query: str,
documents: List[str],
top_k: int = None,
model: str = None,
) -> List[dict]:
"""
调用 59600 rerank 服务
返回格式:
[
{"index": 0, "relevance_score": 0.98},
...
]
"""
if not RERANK_CONFIG.get("enabled", True) or not query or not documents:
return []
max_candidates = int(RERANK_CONFIG.get("max_candidates") or 24)
selected_documents = [str(x or "")[:4000] for x in documents[:max_candidates]]
try:
async with httpx.AsyncClient(
timeout=float(RERANK_CONFIG.get("timeout") or 30),
verify=False,
trust_env=False,
) as client:
response = await client.post(
str(RERANK_CONFIG["endpoint"]),
json={
"model": model or RERANK_CONFIG.get("model") or "bge-rerank",
"query": query,
"documents": selected_documents,
},
)
response.raise_for_status()
payload = response.json()
except Exception as exc:
print(f"[rerank] rerank service failed: {exc}")
return []
rows: List[dict] = []
for row in payload.get("results", []):
try:
idx = int(row.get("index"))
except Exception:
continue
if idx < 0 or idx >= len(selected_documents):
continue
rows.append({
"index": idx,
"relevance_score": float(row.get("relevance_score") or 0.0),
})
rows.sort(key=lambda x: float(x.get("relevance_score") or 0), reverse=True)
if top_k is not None:
rows = rows[:top_k]
return rows
@staticmethod @staticmethod
async def get_embeddings_batch_async(texts: List[str]) -> List[List[float]]: async def get_embeddings_batch_async(texts: List[str]) -> List[List[float]]:
embedding_base_url = EMBEDDING_CONFIG["base_url"] embedding_base_url = EMBEDDING_CONFIG["base_url"]

View File

@ -427,29 +427,19 @@ BAIKE_PROMPTS = {
# 输入变量: history_messages, original_query # 输入变量: history_messages, original_query
# ------------------------------------------------------------------ # ------------------------------------------------------------------
"judge_need_retrieval": ( "judge_need_retrieval": (
"你是一个专业的问题分析助手,请判断用户的问题是否需要检索知识库才能回答。\n" "You are an intent classifier for a knowledge-base QA assistant. Decide whether the user question needs retrieval.\n"
"\n" "\n"
"【判断标准】\n" "Return true when the question asks about documents, files, knowledge-base content, equipment, systems, faults, operations, standards, procedures, meeting notes, database-related materials, or any factual/domain-specific information.\n"
"需要检索的情况:\n" "Return false only for pure greetings, thanks, cancellation/confirmation with no substantive question, or a follow-up that can be fully answered from the conversation history alone.\n"
"- 询问特定设备、技术、规程、规范的具体内容\n" "Default rule: when unsure, return true.\n"
"- 需要专业知识或特定数据支持的问题\n"
"- 询问故障诊断、维修方法等专业领域问题\n"
"- 询问历史事件、法规条款、技术标准等需要查证的内容\n"
"- 例如发动机的组成等\n"
"\n" "\n"
"不需要检索的情况:\n" "Conversation history:\n"
"- 简单的问候、寒暄\n"
"- 常识性问题(如常识性知识、简单计算等)\n"
"- 明确不需要专业知识的问题\n"
"- 对之前对话的简单追问,上下文已足够回答\n"
"\n"
"【对话历史】\n"
"{history_messages}\n" "{history_messages}\n"
"\n" "\n"
"【当前用户问题】\n" "User question:\n"
"{original_query}\n" "{original_query}\n"
"\n" "\n"
"请仅输出 \"true\"\"false\"不带引号true 表示需要检索false 表示不需要检索。" "Output only true or false."
), ),
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -457,29 +447,30 @@ BAIKE_PROMPTS = {
# 输入变量: last_user_query, history_str, original_query # 输入变量: last_user_query, history_str, original_query
# ------------------------------------------------------------------ # ------------------------------------------------------------------
"rewrite_query_with_history": ( "rewrite_query_with_history": (
"你是一个专业的检索query重写助手负责根据对话历史和当前用户问题生成**适合向量检索/RAG 的单条查询语句**。\n" "You are a query understanding assistant for a Chinese knowledge-base retrieval system.\n"
"\n" "\n"
"请严格遵守以下要求:\n" "Tasks:\n"
"1. 只融合与当前问题语义强相关的**最近几轮对话信息**,不要机械拼接全部历史。\n" "1. Rewrite the current user question into a self-contained Chinese retrieval query.\n"
"2. 消除指代和省略(例如:这个、刚才那个问题、上面说的故障等),补全成自洽、完整的描述。\n" "2. Generate 2-5 short search queries for hybrid retrieval.\n"
"3. 不要加入与用户问题无关的背景信息,不要过度扩展检索范围。\n"
"4. 输出必须是一句自然的中文问句或陈述句,便于检索;不要包含条目符号、编号或解释性文字。\n"
"5. 只输出改写后的query本身\n"
" - 不要输出说明文字\n"
" - 不要包含「改写结果」「检索query」等前缀\n"
" - 不要添加引号\n"
"\n" "\n"
"【特别提示】\n" "Rules:\n"
"- 请优先参考**上一轮用户的问题**来重写本轮query。\n" "- Resolve references and ellipsis using only relevant recent history.\n"
"- 上一轮用户的问题是:{last_user_query}\n" "- Preserve concrete entities, file names, database/table terms, equipment names, fault names, standards, procedure names, and exact keywords.\n"
"- Do not output meta instructions such as “在知识库中查找”. Output the actual search content.\n"
"- Include compact keyword queries that help exact matching, for example document titles, equipment names, or core nouns.\n"
"- The rewritten query and search queries must be in Chinese unless the original keyword is English.\n"
"\n" "\n"
"【对话历史JSON 格式,已按时间排序)】\n" "Last user question:\n"
"{last_user_query}\n"
"\n"
"Conversation history JSON:\n"
"{history_str}\n" "{history_str}\n"
"\n" "\n"
"【当前用户问题】\n" "Current user question:\n"
"{original_query}\n" "{original_query}\n"
"\n" "\n"
"请给出最终用于检索的单条中文query。" "Output ONLY valid JSON:\n"
"{{\"rewrite_query\":\"string\",\"search_queries\":[\"string\"]}}"
), ),
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -515,7 +506,13 @@ BAIKE_PROMPTS = {
# 输入变量: domain_desc # 输入变量: domain_desc
# ------------------------------------------------------------------ # ------------------------------------------------------------------
"generate_answer_with_retrieval_system": ( "generate_answer_with_retrieval_system": (
"我是一个专业的{domain_desc}知识问答助手,根据用户的问题综合检索结果,提供准确、专业、完整的回答。回答应围绕问题组织知识点、依据和必要图示,把参考资料中的相关图片作为资料证据放在对应说明附近。只能使用参考资料中真实存在的图片链接,不得使用占位链接或自行构造图片链接。" "You are a professional knowledge-base assistant for {domain_desc}.\n"
"Answer ONLY from the retrieved evidence provided by the user message. Do not use prior knowledge as factual evidence.\n"
"If the evidence is insufficient, say clearly what is missing and what can be confirmed from the evidence.\n"
"Prefer concrete facts, names, parameters, steps, conclusions, and source relationships from the evidence.\n"
"When sources conflict, explain the conflict instead of inventing a merged fact.\n"
"Use images only when their URLs appear in the retrieved evidence; never fabricate or repair image URLs.\n"
"Always respond in Chinese. Do not expose internal tool names or implementation details."
), ),
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -523,20 +520,20 @@ BAIKE_PROMPTS = {
# 输入变量: extracted_text, rag_count, all_source_text # 输入变量: extracted_text, rag_count, all_source_text
# ------------------------------------------------------------------ # ------------------------------------------------------------------
"generate_answer_with_retrieval_user": ( "generate_answer_with_retrieval_user": (
"用户问题:\n"
"{extracted_text}\n" "{extracted_text}\n"
"\n" "\n"
"# 检索到的相关信息(共 {rag_count} 条):\n" "# Retrieved evidence ({rag_count} items)\n"
"\n" "\n"
"{all_source_text}\n" "{all_source_text}\n"
"\n" "\n"
"【输出要求】\n" "【输出要求】\n"
"1. 先直接回答用户问题,再按需要补充结构、原理、参数、应用场景、注意事项或资料依据\n" "1. 先直接回答用户问题,再补充必要依据。\n"
"2. 参考资料中真实存在的图片可在当前问题、设备结构、部件位置、流程步骤或现象说明对应段落附近自然展示;图片规则不要写入正文\n" "2. 每个关键事实都必须能在 Retrieved evidence 中找到依据;不要补充资料外结论。\n"
"3. 图片只作为资料引用输出为单个 Markdown 图片标签URL 和文件名必须逐字来自参考资料,不要改写、拆分、补全、连续重复或自行构造;同一图片资源重复出现时只引用一次\n" "3. 如果资料只能说明部分内容,请明确说“资料中只能确认...”和“资料中未提供...”。\n"
"4. 如果参考资料包含 LaTeX 公式、链接等,请在对应位置正常使用,不得修改或删除\n" "4. 优先使用 evidence 中的文件名、标题、File ID、Slice ID 做简短来源说明,但不要暴露无意义的内部参数。\n"
"5. 请专注于内容的准确性、完整性和逻辑性,不要担心格式的美观性\n" "5. 参考资料中真实存在的图片可在对应段落附近展示URL 必须逐字来自资料。\n"
"6. 可以适当使用简单的段落分隔但不要使用复杂的Markdown格式\n" "6. 表格内容请转成自然语言描述,不要输出复杂表格。\n"
"7. 如果参考资料包含表格,请不要输出表格,而要直接描述表格的内容\n"
"\n" "\n"
"请给出回复:" "请给出回复:"
), ),

View File

@ -0,0 +1,86 @@
import json
import time
from typing import Any, Dict
from config import POSTGRES_CONNECTION_STRING
WIKI_ADMIN_AGENT_ID = "6f0b5c4f-7d4a-4db4-9c4c-8f89b2a4f7a1"
WIKI_ADMIN_ROLE_ID = "5"
WIKI_ADMIN_AGENT: Dict[str, Any] = {
"id": WIKI_ADMIN_AGENT_ID,
"name": "Wiki知识库管理",
"description": "管理 Wiki 指针层的同步、检查、重建、清理和删除,帮助维护 11000 知识库与 Wiki 数据的一致性。",
"prompt": "您好,我是 Wiki 知识库管理助手。您可以直接用知识库名称让我检查同步情况、同步指定文件或知识库、清理 11000 已删除但 Wiki 仍存在的数据。我会先生成待执行计划,列出知识库、文件和动作;您确认后才会执行。删除类操作只删除 Wiki 指针层数据,不会删除 11000 原始知识库文件。",
"background": "Wiki 管理助手用于维护 wx-agent 内置 Wiki 指针层,不负责回答业务知识问题。",
"access_control": {},
"default_query": [
{"title": "检查知识库同步情况 →", "content": "检查知识库 163舰 的 Wiki 同步情况"},
{"title": "同步指定知识库 →", "content": "同步知识库 163舰 的前 20 个文件"},
{"title": "清理脏数据预览 →", "content": "清理知识库 163舰 中 11000 已删除但 Wiki 还存在的数据"},
{"title": "确认执行计划 →", "content": "确认执行"},
],
}
async def register_wiki_admin_agent() -> Dict[str, Any]:
"""Register the Wiki admin agent in an_webui tables and bind it to role 5."""
try:
from psycopg_pool import AsyncConnectionPool
except ImportError:
print("[agent_registration] psycopg_pool is not installed; skip wiki admin agent registration")
return {"success": False, "error": "psycopg_pool is not installed"}
now = int(time.time())
async with AsyncConnectionPool(
POSTGRES_CONNECTION_STRING,
kwargs={"autocommit": True},
) as pool:
async with pool.connection() as conn:
async with conn.cursor() as cur:
await cur.execute(
"""
INSERT INTO agent (
id, name, description, prompt, background, access_control,
created_time, updated_time, default_query
)
VALUES (%s, %s, %s, %s, %s, %s::json, %s, %s, %s::json)
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
description = EXCLUDED.description,
prompt = EXCLUDED.prompt,
background = EXCLUDED.background,
access_control = EXCLUDED.access_control,
updated_time = EXCLUDED.updated_time,
default_query = EXCLUDED.default_query
""",
(
WIKI_ADMIN_AGENT["id"],
WIKI_ADMIN_AGENT["name"],
WIKI_ADMIN_AGENT["description"],
WIKI_ADMIN_AGENT["prompt"],
WIKI_ADMIN_AGENT["background"],
json.dumps(WIKI_ADMIN_AGENT["access_control"], ensure_ascii=False),
now,
now,
json.dumps(WIKI_ADMIN_AGENT["default_query"], ensure_ascii=False),
),
)
await cur.execute(
"""
INSERT INTO agent_role_association (agent_id, agent_role_id, is_active)
VALUES (%s, %s, TRUE)
ON CONFLICT (agent_id, agent_role_id) DO UPDATE SET
is_active = EXCLUDED.is_active
""",
(WIKI_ADMIN_AGENT_ID, WIKI_ADMIN_ROLE_ID),
)
print(f"[agent_registration] wiki admin agent registered: {WIKI_ADMIN_AGENT_ID} -> role {WIKI_ADMIN_ROLE_ID}")
return {
"success": True,
"agent_id": WIKI_ADMIN_AGENT_ID,
"agent_role_id": WIKI_ADMIN_ROLE_ID,
}

View File

@ -0,0 +1,106 @@
from datetime import datetime
from typing import Any, Dict, List, Optional
from config import POSTGRES_CONNECTION_STRING
async def delete_conversation_by_chat_id(chat_id: str) -> Dict[str, Any]:
"""Delete wx-agent LangGraph checkpoint state for one chat_id."""
chat_id = str(chat_id or "").strip()
if not chat_id:
raise ValueError("chat_id is empty")
counts = {
"checkpoint_writes": 0,
"checkpoint_blobs": 0,
"checkpoints": 0,
}
async with await _connect_pool() as pool:
async with pool.connection() as conn:
async with conn.cursor() as cur:
await _delete_checkpoint_threads(cur, chat_id, counts)
return {"success": True, "chat_id": chat_id, "deleted": counts}
async def delete_conversations_by_time(
before: Optional[datetime] = None,
after: Optional[datetime] = None,
) -> Dict[str, Any]:
"""Delete LangGraph checkpoint state whose checkpoint timestamp falls in a time range."""
if before is None and after is None:
raise ValueError("before or after is required")
where_parts = []
params: List[Any] = []
if before is not None:
where_parts.append("(checkpoint->>'ts')::timestamptz < %s")
params.append(before)
if after is not None:
where_parts.append("(checkpoint->>'ts')::timestamptz >= %s")
params.append(after)
where_sql = " AND ".join(where_parts)
counts = {
"checkpoint_writes": 0,
"checkpoint_blobs": 0,
"checkpoints": 0,
}
deleted_thread_ids: List[str] = []
async with await _connect_pool() as pool:
async with pool.connection() as conn:
async with conn.cursor() as cur:
await cur.execute(
f"SELECT DISTINCT thread_id FROM checkpoints WHERE {where_sql}",
params,
)
deleted_thread_ids = [str(row[0]) for row in await cur.fetchall() if row and row[0]]
for thread_id in deleted_thread_ids:
await _delete_exact_thread(cur, thread_id, counts)
return {
"success": True,
"before": before.isoformat() if before else "",
"after": after.isoformat() if after else "",
"thread_count": len(deleted_thread_ids),
"thread_ids": deleted_thread_ids,
"deleted": counts,
}
async def _delete_checkpoint_threads(cur, chat_id: str, counts: Dict[str, int]) -> None:
thread_like = f"{chat_id}:%"
await cur.execute(
"DELETE FROM checkpoint_writes WHERE thread_id = %s OR thread_id LIKE %s",
(chat_id, thread_like),
)
counts["checkpoint_writes"] += cur.rowcount or 0
await cur.execute(
"DELETE FROM checkpoint_blobs WHERE thread_id = %s OR thread_id LIKE %s",
(chat_id, thread_like),
)
counts["checkpoint_blobs"] += cur.rowcount or 0
await cur.execute(
"DELETE FROM checkpoints WHERE thread_id = %s OR thread_id LIKE %s",
(chat_id, thread_like),
)
counts["checkpoints"] += cur.rowcount or 0
async def _delete_exact_thread(cur, thread_id: str, counts: Dict[str, int]) -> None:
await cur.execute("DELETE FROM checkpoint_writes WHERE thread_id = %s", (thread_id,))
counts["checkpoint_writes"] += cur.rowcount or 0
await cur.execute("DELETE FROM checkpoint_blobs WHERE thread_id = %s", (thread_id,))
counts["checkpoint_blobs"] += cur.rowcount or 0
await cur.execute("DELETE FROM checkpoints WHERE thread_id = %s", (thread_id,))
counts["checkpoints"] += cur.rowcount or 0
async def _connect_pool():
from psycopg_pool import AsyncConnectionPool
return AsyncConnectionPool(
POSTGRES_CONNECTION_STRING,
kwargs={"autocommit": True},
)

View File

@ -536,6 +536,7 @@ def _node_record_to_dict(record: Any) -> Dict[str, Any]:
return result return result
@track_function_calls
async def normalize_device_name_by_ship_graph( async def normalize_device_name_by_ship_graph(
device_name: str, device_name: str,
fulltext_index: str = GLOBAL_FULLTEXT_INDEX, fulltext_index: str = GLOBAL_FULLTEXT_INDEX,
@ -902,6 +903,7 @@ def _build_fault_graph_results_text(
return "\n".join(parts) return "\n".join(parts)
@track_function_calls
async def find_system_by_device( async def find_system_by_device(
device_name: str, device_name: str,
min_hops: int = 2, min_hops: int = 2,

139
tools/wiki_tools.py Normal file
View File

@ -0,0 +1,139 @@
from typing import Any, Dict, Optional
from langchain_core.tools import tool
from wiki_engine.service import (
delete_by_file,
delete_by_kb,
diff_kb,
evidence_search,
get_stats,
reconcile_kb,
resolve_kb_reference,
search_wiki,
sync_file,
sync_kb,
)
async def wiki_pointer_search(
query: str,
kb_id: Optional[str] = None,
limit: int = 5,
) -> Dict[str, Any]:
"""Search local Wiki pointer pages and return related 11000 slice ids."""
if not query or not query.strip():
return {"success": False, "error": "query is empty", "items": []}
return await search_wiki(query=query.strip(), kb_id=kb_id, limit=limit, include_slices=True)
async def wiki_evidence_search(
query: str,
kb_id: Optional[str] = None,
limit: int = 5,
) -> Dict[str, Any]:
"""Return Wiki results in the unified evidence-like format."""
if not query or not query.strip():
return {"success": False, "error": "query is empty", "items": []}
return await evidence_search(query=query.strip(), kb_id=kb_id, limit=limit)
async def wiki_admin_stats() -> Dict[str, Any]:
return {"success": True, "stats": await get_stats()}
async def wiki_admin_sync_file(file_id: str, kb_id: Optional[str] = None, use_llm: bool = False) -> Dict[str, Any]:
return await sync_file(file_id=file_id, kb_id=kb_id, use_llm=use_llm)
async def wiki_admin_sync_kb(
kb_id: Optional[str] = None,
kb_name: Optional[str] = None,
limit: int = 20,
use_llm: bool = False,
) -> Dict[str, Any]:
target = await _resolve_kb_target(kb_id=kb_id, kb_name=kb_name)
if not target.get("success"):
return target
result = await sync_kb(kb_id=target["kb_id"], limit=limit, use_llm=use_llm)
result.update({"kb_name": target.get("kb_name", ""), "kb_path": target.get("kb_path", "")})
return result
async def wiki_admin_diff_kb(
kb_id: Optional[str] = None,
kb_name: Optional[str] = None,
include_children: bool = False,
limit: int = 200,
) -> Dict[str, Any]:
target = await _resolve_kb_target(kb_id=kb_id, kb_name=kb_name)
if not target.get("success"):
return target
result = await diff_kb(kb_id=target["kb_id"], include_children=include_children, limit=limit)
result.update({"kb_name": target.get("kb_name", ""), "kb_path": target.get("kb_path", "")})
return result
async def wiki_admin_reconcile_kb(
kb_id: Optional[str] = None,
kb_name: Optional[str] = None,
include_children: bool = False,
limit: int = 200,
sync_missing: bool = True,
rebuild_stale: bool = True,
delete_orphan: bool = False,
use_llm: bool = False,
dry_run: bool = True,
) -> Dict[str, Any]:
target = await _resolve_kb_target(kb_id=kb_id, kb_name=kb_name)
if not target.get("success"):
return target
result = await reconcile_kb(
kb_id=target["kb_id"],
include_children=include_children,
limit=limit,
sync_missing=sync_missing,
rebuild_stale=rebuild_stale,
delete_orphan=delete_orphan,
use_llm=use_llm,
dry_run=dry_run,
)
result.update({"kb_name": target.get("kb_name", ""), "kb_path": target.get("kb_path", "")})
return result
async def wiki_admin_delete_file(file_id: str) -> Dict[str, Any]:
return await delete_by_file(file_id)
async def wiki_admin_delete_kb(kb_id: Optional[str] = None, kb_name: Optional[str] = None) -> Dict[str, Any]:
target = await _resolve_kb_target(kb_id=kb_id, kb_name=kb_name)
if not target.get("success"):
return target
result = await delete_by_kb(target["kb_id"])
result.update({"kb_name": target.get("kb_name", ""), "kb_path": target.get("kb_path", "")})
return result
async def _resolve_kb_target(kb_id: Optional[str] = None, kb_name: Optional[str] = None) -> Dict[str, Any]:
if kb_id and str(kb_id).strip():
return {"success": True, "kb_id": str(kb_id).strip(), "kb_name": "", "kb_path": ""}
if kb_name and str(kb_name).strip():
return await resolve_kb_reference(str(kb_name).strip())
return {"success": False, "error": "kb_name is required", "matches": []}
@tool
async def wiki_pointer_search_tool(query: str, limit: int = 5) -> str:
"""Search Wiki pointer pages and format them for workflow prompts."""
result = await wiki_pointer_search(query=query, limit=limit)
if not result.get("success"):
return ""
parts = []
for item in result.get("items", []):
title = item.get("title", "")
summary = item.get("summary", "")
slice_ids = ", ".join(item.get("related_slice_ids", [])[:8])
if title or summary:
parts.append(f"[Wiki] {title}\n{summary}\nrelated_slice_ids: {slice_ids}")
return "\n\n".join(parts)

6
wiki_engine/__init__.py Normal file
View File

@ -0,0 +1,6 @@
"""Lightweight Wiki pointer layer for wx-agent.
The module stores topic pages and enhanced slice pointers in PostgreSQL. It does
not copy original files or become the source of truth; source text remains in
the 11000 knowledge service.
"""

View File

@ -0,0 +1,100 @@
import hashlib
import re
from collections import Counter
from typing import Any, Dict, Iterable, List
STOPWORDS = {
"", "", "", "", "", "", "", "", "", "", "", "",
"进行", "使用", "相关", "根据", "如下", "可以", "需要", "应当", "以及",
}
def enhance_slices(
slices: Iterable[Dict[str, Any]],
file_info: Dict[str, Any],
max_slices: int,
) -> List[Dict[str, Any]]:
refs: List[Dict[str, Any]] = []
filename = str(file_info.get("filename") or file_info.get("name") or "")
kb_id = str(file_info.get("kb_id") or "")
file_id = str(file_info.get("file_id") or file_info.get("id") or "")
for idx, raw in enumerate(list(slices)[:max_slices]):
text = _slice_text(raw)
if not text.strip():
continue
slice_id = str(raw.get("id") or raw.get("slice_id") or raw.get("uuid") or f"{file_id}:{idx}")
title_path = _infer_title_path(text, filename)
keywords = extract_keywords(text, limit=12)
refs.append({
"kb_id": str(raw.get("kb_id") or kb_id),
"file_id": str(raw.get("file_id") or file_id),
"slice_id": slice_id,
"ordinal": int(raw.get("ordinal") or raw.get("index") or idx),
"filename": str(raw.get("filename") or filename),
"title_path": title_path,
"keywords": keywords,
"brief": _brief(text),
"chunk_type": _infer_chunk_type(text),
"content_hash": hashlib.sha1(text.encode("utf-8", errors="ignore")).hexdigest(),
"_text": text,
})
return refs
def extract_keywords(text: str, limit: int = 10) -> List[str]:
text = normalize_text(text)
candidates = re.findall(r"[\u4e00-\u9fff]{2,12}|[A-Za-z0-9][A-Za-z0-9_\-]{1,30}", text)
normalized: List[str] = []
for token in candidates:
token = token.strip(":,。.;;、()()[]【】")
if len(token) < 2 or token in STOPWORDS:
continue
if re.fullmatch(r"\d+", token):
continue
normalized.append(token)
counts = Counter(normalized)
return [term for term, _ in counts.most_common(limit)]
def normalize_text(text: str) -> str:
return re.sub(r"\s+", " ", text or "").strip()
def _slice_text(raw: Dict[str, Any]) -> str:
for key in ("content", "text", "page_content", "slice_content", "markdown"):
value = raw.get(key)
if isinstance(value, str) and value.strip():
return value
return ""
def _brief(text: str, limit: int = 360) -> str:
return normalize_text(text)[:limit]
def _infer_title_path(text: str, filename: str) -> List[str]:
path = []
if filename:
path.append(filename)
lines = [line.strip() for line in (text or "").splitlines() if line.strip()]
for line in lines[:6]:
line = re.sub(r"^#+\s*", "", line).strip()
if 2 <= len(line) <= 50 and not line.endswith(("", ".", "", ";")):
path.append(line)
break
return path[:4] if path else []
def _infer_chunk_type(text: str) -> str:
compact = normalize_text(text)
if "|" in text and "---" in text:
return "table"
if re.search(r"(步骤|流程|操作|检查|确认|打开|关闭|启动|停止)", compact):
return "procedure"
if re.search(r"(故障|异常|报警|失效|原因|处理|排查)", compact):
return "fault"
if re.search(r"!\[.*?\]\(.*?\)", text):
return "image"
return "text"

1798
wiki_engine/db.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,132 @@
import asyncio
from typing import Any, Dict, List, Optional
from urllib.parse import quote
import httpx
from config import RAG_CONFIG, WIKI_CONFIG
class HTKnowClient:
"""Small async client for the 11000 knowledge service."""
def __init__(self, base_url: Optional[str] = None):
self.base_url = (base_url or WIKI_CONFIG["htknow_base_url"]).rstrip("/")
def _headers(self) -> Dict[str, str]:
return {
"accept": "application/json",
"x-user-id": _safe_header(RAG_CONFIG.get("x-user-id") or WIKI_CONFIG["default_user_id"]),
"x-user-name": _safe_header(RAG_CONFIG.get("x-user-name") or WIKI_CONFIG["default_user_name"]),
"x-role": _safe_header(RAG_CONFIG.get("x-role") or WIKI_CONFIG["default_role"]),
}
async def list_files(
self,
kb_id: Optional[str] = None,
limit: int = 20,
offset: int = 0,
) -> List[Dict[str, Any]]:
url = f"{self.base_url}/api/v1/knowledge/files/"
# The 11000 service is more stable with its default-ish page sizes.
# Fetch at least 10 rows and trim locally instead of requesting tiny pages.
size = min(max(limit, 10), 100)
page = max(offset // size, 0) + 1
collected: List[Dict[str, Any]] = []
async with httpx.AsyncClient(timeout=60.0, verify=False, trust_env=False) as client:
while len(collected) < limit:
params: Dict[str, Any] = {"page": page, "size": size}
if kb_id:
params["kb_id"] = kb_id
payload = await _get_json_with_retry(client, url, params=params, headers=self._headers())
items = _extract_list(payload, candidates=("results", "data", "items", "files"))
if not items:
break
collected.extend(items)
if len(items) < params["size"]:
break
page += 1
return collected[:limit]
async def get_file_slices(self, file_id: str, limit: Optional[int] = None) -> List[Dict[str, Any]]:
url = f"{self.base_url}/api/v1/knowledge/files/{file_id}/slices"
params = {}
if limit:
params["limit"] = limit
async with httpx.AsyncClient(timeout=120.0, verify=False, trust_env=False) as client:
payload = await _get_json_with_retry(client, url, params=params, headers=self._headers())
return _extract_list(payload, candidates=("slices", "results", "data", "items"))
async def get_file(self, file_id: str) -> Dict[str, Any]:
url = f"{self.base_url}/api/v1/knowledge/files/{file_id}"
async with httpx.AsyncClient(timeout=60.0, verify=False, trust_env=False) as client:
payload = await _get_json_with_retry(client, url, params={}, headers=self._headers())
if isinstance(payload, dict):
for key in ("data", "file", "item"):
value = payload.get(key)
if isinstance(value, dict):
return value
return payload
return {}
async def get_kb_tree(self) -> List[Dict[str, Any]]:
url = f"{self.base_url}/api/v1/knowledge/knowledge_base/tree"
async with httpx.AsyncClient(timeout=60.0, verify=False, trust_env=False) as client:
payload = await _get_json_with_retry(client, url, params={}, headers=self._headers())
if isinstance(payload, list):
return [x for x in payload if isinstance(x, dict)]
if isinstance(payload, dict):
data = payload.get("data") or payload.get("list") or payload
if isinstance(data, dict) and isinstance(data.get("children"), list):
return [x for x in data["children"] if isinstance(x, dict)]
if isinstance(data, list):
return [x for x in data if isinstance(x, dict)]
return []
def _extract_list(payload: Any, candidates: tuple[str, ...]) -> List[Dict[str, Any]]:
if isinstance(payload, list):
return [x for x in payload if isinstance(x, dict)]
if not isinstance(payload, dict):
return []
for key in candidates:
value = payload.get(key)
if isinstance(value, list):
return [x for x in value if isinstance(x, dict)]
if isinstance(value, dict):
nested = _extract_list(value, candidates)
if nested:
return nested
return []
def _safe_header(value: Any) -> str:
text = str(value or "")
try:
text.encode("latin-1")
return text
except UnicodeEncodeError:
return quote(text, safe="")
async def _get_json_with_retry(
client: httpx.AsyncClient,
url: str,
params: Dict[str, Any],
headers: Dict[str, str],
retries: int = 2,
) -> Any:
last_exc: Optional[Exception] = None
for attempt in range(retries + 1):
try:
response = await client.get(url, params=params, headers=headers)
response.raise_for_status()
return response.json()
except (httpx.HTTPStatusError, httpx.RequestError) as exc:
last_exc = exc
if attempt >= retries:
raise
await asyncio.sleep(0.5 * (attempt + 1))
if last_exc:
raise last_exc
return {}

230
wiki_engine/routes.py Normal file
View File

@ -0,0 +1,230 @@
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from wiki_engine import db
from wiki_engine import service
router = APIRouter(prefix="/api/wiki", tags=["wiki"])
class WikiSyncFileRequest(BaseModel):
kb_id: Optional[str] = None
kb_name: Optional[str] = None
use_llm: bool = False
background: bool = True
class WikiSyncKbRequest(BaseModel):
kb_id: Optional[str] = None
kb_name: Optional[str] = None
limit: Optional[int] = None
use_llm: bool = False
background: bool = True
class WikiSearchRequest(BaseModel):
query: str
kb_id: Optional[str] = None
kb_name: Optional[str] = None
limit: int = 8
include_slices: bool = True
class WikiDeleteKbRequest(BaseModel):
kb_id: Optional[str] = None
kb_name: Optional[str] = None
class WikiDeleteFileRequest(BaseModel):
file_id: str
class WikiDeleteTimeRequest(BaseModel):
before: Optional[datetime] = None
after: Optional[datetime] = None
kb_id: Optional[str] = None
kb_name: Optional[str] = None
class WikiDiffKbRequest(BaseModel):
kb_id: Optional[str] = None
kb_name: Optional[str] = None
include_children: bool = False
limit: int = 200
parsed_only: bool = True
class WikiReconcileKbRequest(BaseModel):
kb_id: Optional[str] = None
kb_name: Optional[str] = None
include_children: bool = False
limit: int = 200
sync_missing: bool = True
rebuild_stale: bool = True
delete_orphan: bool = False
use_llm: bool = False
dry_run: bool = True
@router.post("/init")
async def init_wiki():
await db.init_wiki_tables()
return {"success": True}
@router.post("/sync/file/{file_id}")
async def sync_file(file_id: str, request: WikiSyncFileRequest):
kb_id = await _resolve_optional_kb_id(request.kb_id, request.kb_name)
if request.background:
job_id = await service.start_sync_job(
target_type="file",
target_id=file_id,
kb_id=kb_id,
use_llm=request.use_llm,
)
return {"success": True, "job_id": job_id, "status": "running", "job": await db.get_job(job_id)}
return await service.sync_file(file_id=file_id, kb_id=kb_id, use_llm=request.use_llm)
@router.post("/sync/kb")
async def sync_kb(request: WikiSyncKbRequest):
kb_id = await _resolve_required_kb_id(request.kb_id, request.kb_name)
if request.background:
job_id = await service.start_sync_job(
target_type="kb",
target_id=kb_id,
kb_id=kb_id,
limit=request.limit,
use_llm=request.use_llm,
)
return {"success": True, "job_id": job_id, "status": "running", "job": await db.get_job(job_id)}
return await service.sync_kb(kb_id=kb_id, limit=request.limit, use_llm=request.use_llm)
@router.get("/sync/jobs/{job_id}")
async def get_sync_job(job_id: str):
job = await db.get_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="sync job not found")
return {"success": True, "job": job}
@router.post("/search")
async def search_wiki(request: WikiSearchRequest):
if not request.query.strip():
raise HTTPException(status_code=400, detail="query is empty")
kb_id = await _resolve_optional_kb_id(request.kb_id, request.kb_name)
return await service.search_wiki(
query=request.query,
kb_id=kb_id,
limit=request.limit,
include_slices=request.include_slices,
)
@router.post("/evidence/search")
async def search_evidence(request: WikiSearchRequest):
if not request.query.strip():
raise HTTPException(status_code=400, detail="query is empty")
kb_id = await _resolve_optional_kb_id(request.kb_id, request.kb_name)
return await service.evidence_search(
query=request.query,
kb_id=kb_id,
limit=request.limit,
)
@router.get("/pages/{page_id}")
async def get_page(page_id: int):
page = await service.get_page(page_id)
if not page:
raise HTTPException(status_code=404, detail="wiki page not found")
return {"success": True, "page": page}
@router.get("/stats")
async def get_stats():
return {"success": True, "stats": await service.get_stats()}
@router.get("/queue/stats")
async def get_queue_stats():
return {"success": True, "stats": await service.get_queue_stats()}
@router.get("/tree")
async def get_kb_tree():
return await service.get_kb_tree()
@router.post("/diff/kb")
async def diff_kb(request: WikiDiffKbRequest):
kb_id = await _resolve_required_kb_id(request.kb_id, request.kb_name)
return await service.diff_kb(
kb_id=kb_id,
include_children=request.include_children,
limit=request.limit,
parsed_only=request.parsed_only,
)
@router.post("/reconcile/kb")
async def reconcile_kb(request: WikiReconcileKbRequest):
kb_id = await _resolve_required_kb_id(request.kb_id, request.kb_name)
return await service.reconcile_kb(
kb_id=kb_id,
include_children=request.include_children,
limit=request.limit,
sync_missing=request.sync_missing,
rebuild_stale=request.rebuild_stale,
delete_orphan=request.delete_orphan,
use_llm=request.use_llm,
dry_run=request.dry_run,
)
@router.post("/delete/by-kb")
async def delete_by_kb(request: WikiDeleteKbRequest):
kb_id = await _resolve_required_kb_id(request.kb_id, request.kb_name)
return await service.delete_by_kb(kb_id)
@router.post("/delete/by-file")
async def delete_by_file(request: WikiDeleteFileRequest):
if not request.file_id.strip():
raise HTTPException(status_code=400, detail="file_id is empty")
return await service.delete_by_file(request.file_id)
@router.post("/delete/by-time")
async def delete_by_time(request: WikiDeleteTimeRequest):
if request.before is None and request.after is None:
raise HTTPException(status_code=400, detail="before or after is required")
kb_id = await _resolve_optional_kb_id(request.kb_id, request.kb_name)
return await service.delete_by_time(
before=request.before,
after=request.after,
kb_id=kb_id,
)
async def _resolve_required_kb_id(kb_id: Optional[str], kb_name: Optional[str]) -> str:
resolved = await _resolve_optional_kb_id(kb_id, kb_name)
if not resolved:
raise HTTPException(status_code=400, detail="kb_name or kb_id is required")
return resolved
async def _resolve_optional_kb_id(kb_id: Optional[str], kb_name: Optional[str]) -> Optional[str]:
if kb_id and kb_id.strip():
return kb_id.strip()
if not kb_name or not kb_name.strip():
return None
resolved = await service.resolve_kb_reference(kb_name.strip())
if not resolved.get("success"):
raise HTTPException(status_code=404, detail=resolved)
return str(resolved["kb_id"])

967
wiki_engine/service.py Normal file
View File

@ -0,0 +1,967 @@
import asyncio
import hashlib
import json
import re
import uuid
from datetime import datetime
from typing import Any, Dict, List, Optional
from config import WIKI_CONFIG
from wiki_engine import db
from wiki_engine.chunk_enhancer import enhance_slices
from wiki_engine.htknow_client import HTKnowClient
from wiki_engine.wiki_builder import build_pages_for_file
_FILE_SLICE_TEXT_CACHE: Dict[str, Dict[str, str]] = {}
async def sync_file(
file_id: str,
kb_id: Optional[str] = None,
file_info: Optional[Dict[str, Any]] = None,
use_llm: bool = False,
job_id: Optional[str] = None,
force: bool = False,
) -> Dict[str, Any]:
client = HTKnowClient()
file_info = dict(file_info or {})
filename = str(file_info.get("filename") or file_info.get("name") or "")
if job_id:
await db.update_job(
job_id,
stage="fetch_file",
current_file_id=str(file_id),
current_filename=filename,
)
if not file_info or not (file_info.get("filename") or file_info.get("name")):
try:
file_info.update(await client.get_file(str(file_id)))
except Exception as exc:
print(f"[wiki_engine] get file detail failed for {file_id}: {exc}")
file_info.setdefault("id", str(file_id))
file_info.setdefault("file_id", str(file_id))
if kb_id:
file_info.setdefault("kb_id", str(kb_id))
filename = str(file_info.get("filename") or file_info.get("name") or "")
if job_id:
await db.update_job(job_id, stage="fetch_slices", current_filename=filename)
slices = await client.get_file_slices(str(file_id), limit=WIKI_CONFIG.get("max_slices_per_file"))
if job_id:
await db.update_job(job_id, stage="enhance_slices")
refs = enhance_slices(
slices,
file_info=file_info,
max_slices=int(WIKI_CONFIG.get("max_slices_per_file") or 300),
)
if not refs:
raise ValueError(f"no slices returned for file {file_id}; keep existing wiki data unchanged")
content_signature = _file_content_signature(refs)
existing_state = await db.get_file_state(str(file_id))
existing_page_count = await db.count_file_pages(str(file_id)) if existing_state else 0
if not force and _is_unchanged_file(existing_state, refs, content_signature) and existing_page_count > 0:
if job_id:
await db.update_job(job_id, stage="skipped_unchanged")
return {
"success": True,
"skipped": True,
"reason": "unchanged",
"file_id": str(file_id),
"kb_id": str(kb_id or file_info.get("kb_id") or ""),
"slice_count": len(refs),
"embedding_count": 0,
"embedding_error": "",
"page_count": existing_page_count,
"page_ids": [],
}
source_hash = str(file_info.get("hash") or "")
metadata = file_info.get("metadata") if isinstance(file_info.get("metadata"), dict) else {}
metadata = dict(metadata)
metadata["content_signature"] = content_signature
metadata["source_hash"] = source_hash
metadata["wiki_pipeline_version"] = "wiki-sync-v3"
file_info["metadata"] = metadata
file_info["content_signature"] = content_signature
if not source_hash:
file_info["hash"] = content_signature
await db.upsert_file_state(file_info, slice_count=len(refs))
if job_id:
await db.update_job(job_id, stage="write_slices")
await db.replace_file_slice_refs(str(file_id), refs)
if job_id:
await db.update_job(job_id, stage="build_pages")
async def progress(stage: str) -> None:
if job_id and WIKI_CONFIG.get("stage_progress_enabled", True):
await db.update_job(job_id, stage=stage)
pages = await build_pages_for_file(file_info, refs, use_llm=use_llm, progress=progress if job_id else None)
if job_id:
await db.update_job(job_id, stage="write_pages")
page_ids = []
for page in pages:
page_id = await _upsert_page_with_slug_lock(page, owner=job_id or f"sync:{file_id}")
page_ids.append(page_id)
await db.replace_page_slices(page_id, page.get("links", []))
if job_id:
await db.update_job(job_id, stage="file_done")
return {
"success": True,
"skipped": False,
"file_id": str(file_id),
"kb_id": str(kb_id or file_info.get("kb_id") or ""),
"slice_count": len(refs),
"embedding_count": 0,
"embedding_error": "",
"page_count": len(page_ids),
"page_ids": page_ids,
}
async def sync_kb(
kb_id: str,
limit: Optional[int] = None,
use_llm: bool = False,
job_id: Optional[str] = None,
force: bool = False,
) -> Dict[str, Any]:
limit = int(limit or WIKI_CONFIG.get("default_sync_limit") or 20)
client = HTKnowClient()
files = await client.list_files(kb_id=kb_id, limit=limit)
if job_id:
await db.update_job(job_id, total_files=len(files))
processed = 0
total_slices = 0
skipped = 0
page_count = 0
errors: List[Dict[str, str]] = []
for file_info in files:
file_id = str(file_info.get("id") or file_info.get("file_id") or "")
if not file_id:
continue
try:
result = await sync_file(file_id, kb_id=kb_id, file_info=file_info, use_llm=use_llm, job_id=job_id, force=force)
processed += 1
total_slices += int(result.get("slice_count") or 0)
skipped += 1 if result.get("skipped") else 0
if not result.get("skipped"):
page_count += int(result.get("page_count") or 0)
except Exception as exc:
errors.append({"file_id": file_id, "error": str(exc)})
if job_id:
await db.update_job(
job_id,
processed_files=processed,
total_slices=total_slices,
skipped_files=skipped,
page_count=page_count,
)
await asyncio.sleep(0)
if job_id and WIKI_CONFIG.get("finalize_enabled", True):
await enqueue_finalize_job(kb_id, job_id)
return {
"success": not errors,
"kb_id": str(kb_id),
"file_count": len(files),
"processed_files": processed,
"total_slices": total_slices,
"skipped_files": skipped,
"page_count": page_count,
"errors": errors[:20],
}
async def start_sync_job(
target_type: str,
target_id: str,
kb_id: Optional[str] = None,
limit: Optional[int] = None,
use_llm: bool = False,
force: bool = False,
) -> str:
job_id = str(uuid.uuid4())
await db.create_job(job_id, target_type=target_type, target_id=target_id, kb_id=kb_id or "")
if WIKI_CONFIG.get("queue_enabled", True):
if target_type == "file":
await db.set_job_totals(job_id, 1)
await db.enqueue_wiki_file_op(
job_id=job_id,
kb_id=kb_id or "",
file_id=target_id,
file_info={},
use_llm=use_llm,
force=force,
)
return job_id
if target_type == "kb":
client = HTKnowClient()
files = await client.list_files(kb_id=target_id, limit=limit or WIKI_CONFIG.get("default_sync_limit"))
valid_files = [
(str(file_info.get("id") or file_info.get("file_id") or ""), file_info)
for file_info in files
if str(file_info.get("id") or file_info.get("file_id") or "")
]
await db.set_job_totals(job_id, len(valid_files))
if not valid_files:
await db.update_job(job_id, status="success")
return job_id
for file_id, file_info in valid_files:
await db.enqueue_wiki_file_op(
job_id=job_id,
kb_id=target_id,
file_id=file_id,
file_info=file_info,
use_llm=use_llm,
force=force,
)
return job_id
async def _runner() -> None:
try:
if target_type == "file":
await sync_file(target_id, kb_id=kb_id, use_llm=use_llm, job_id=job_id, force=force)
elif target_type == "kb":
await sync_kb(target_id, limit=limit, use_llm=use_llm, job_id=job_id, force=force)
else:
raise ValueError(f"unsupported target_type: {target_type}")
await db.update_job(job_id, status="success")
except Exception as exc:
await db.update_job(job_id, status="failed", error=str(exc))
asyncio.create_task(_runner())
return job_id
async def get_queue_stats() -> Dict[str, Any]:
return await db.get_wiki_queue_stats()
async def get_sync_job(job_id: str) -> Optional[Dict[str, Any]]:
return await db.get_job(job_id)
async def enqueue_finalize_job(kb_id: str, job_id: str = "") -> Dict[str, Any]:
if not WIKI_CONFIG.get("finalize_enabled", True):
return {"success": True, "enqueued": False, "reason": "disabled"}
op_id = await db.enqueue_wiki_finalize_op(
job_id=job_id,
kb_id=kb_id,
delay_seconds=int(WIKI_CONFIG.get("finalize_debounce_seconds") or 20),
)
return {"success": True, "enqueued": bool(op_id), "op_id": op_id}
async def finalize_kb(kb_id: str, job_id: str = "") -> Dict[str, Any]:
if job_id:
await db.update_job(job_id, stage="finalize")
page_count = await db.count_pages_for_kb(kb_id)
if job_id:
await db.update_job(job_id, stage="completed", page_count=page_count, current_file_id="", current_filename="")
return {"success": True, "kb_id": str(kb_id or ""), "page_count": page_count}
async def search_wiki(
query: str,
kb_id: Optional[str] = None,
limit: int = 8,
include_slices: bool = True,
) -> Dict[str, Any]:
page_limit = max(int(WIKI_CONFIG.get("page_search_top_k") or limit or 8), limit)
pages = await db.search_pages(query=query, kb_id=kb_id, limit=page_limit)
slice_map = {}
if include_slices and pages:
slice_map = await db.get_page_slices(
[int(p["id"]) for p in pages],
max_slices_per_page=int(WIKI_CONFIG.get("evidence_slices_per_page") or 6),
)
items = []
for rank, page in enumerate(pages, start=1):
page_id = int(page["id"])
slices = slice_map.get(page_id, [])
related_slice_ids = [str(s.get("slice_id")) for s in slices if s.get("slice_id")]
related_file_ids = list({
str(s.get("file_id"))
for s in slices
if s.get("file_id")
} | set([str(x) for x in (page.get("related_file_ids") or []) if x]))
items.append({
"source_type": "wiki",
"source_id": str(page_id),
"kb_id": str(page.get("kb_id") or ""),
"rank_sources": {"page": rank},
"slug": page.get("slug", ""),
"title": page.get("title", ""),
"page_type": page.get("page_type", ""),
"summary": page.get("summary", ""),
"content": page.get("content", ""),
"keywords": page.get("keywords", []),
"aliases": page.get("aliases", []),
"score": page.get("score", 0.0),
"related_file_ids": related_file_ids,
"related_slice_ids": related_slice_ids,
"slices": slices,
})
items = _fuse_wiki_items(items, limit=max(limit, 1))
await db.log_query(query, kb_id, len(items))
return {
"success": True,
"query": query,
"kb_id": kb_id or "",
"items": items,
"debug": {"page_hits": len(pages), "vector_hits": 0, "text_hits": 0},
}
async def evidence_search(query: str, kb_id: Optional[str] = None, limit: int = 5) -> Dict[str, Any]:
result = await search_wiki(
query=query,
kb_id=kb_id,
limit=max(limit * 2, limit),
include_slices=True,
)
evidence_items = []
for item in result.get("items", []):
slices = await _hydrate_wiki_slices(item.get("slices", []))
slice_texts = []
for s in slices[:6]:
text = str(s.get("content") or s.get("brief") or "").strip()
if not text:
continue
filename = str(s.get("filename") or "").strip()
title_path = " / ".join(str(x) for x in (s.get("title_path") or []) if x)
prefix_parts = [x for x in (filename, title_path) if x]
prefix = f"{' - '.join(prefix_parts)}\n" if prefix_parts else ""
slice_texts.append(
f"{prefix}{text}\n"
f"(file_id={s.get('file_id') or ''}, slice_id={s.get('slice_id') or ''})"
)
deep_text = "\n\n".join(slice_texts)
page_text = "\n".join(x for x in [
str(item.get("summary") or "").strip(),
str(item.get("content") or "").strip(),
] if x).strip()
text = "\n\n".join(x for x in [page_text, deep_text] if x).strip()
if not text:
continue
evidence_items.append({
"source_type": "wiki",
"source_id": item.get("source_id"),
"title": item.get("title"),
"text": text,
"score": item.get("score", 0.0),
"kb_id": item.get("kb_id", ""),
"metadata": {
"kb_id": item.get("kb_id", ""),
"page_type": item.get("page_type"),
"keywords": item.get("keywords", []),
"related_file_ids": item.get("related_file_ids", []),
"related_slice_ids": item.get("related_slice_ids", []),
"slices": slices,
"rank_sources": item.get("rank_sources", {}),
},
})
# Keep the page-search order here. The baike workflow does a later cross-source
# rerank, and preserving the Wiki top hit makes the displayed Wiki evidence
# match `/api/wiki/search`.
evidence_items.sort(key=lambda x: float(x.get("score") or 0.0), reverse=True)
return {"success": True, "query": query, "items": evidence_items[:limit], "debug": result.get("debug", {})}
def _fuse_wiki_items(items: List[Dict[str, Any]], limit: int) -> List[Dict[str, Any]]:
rrf_k = max(int(WIKI_CONFIG.get("rrf_k") or 60), 1)
by_key: Dict[str, Dict[str, Any]] = {}
for item in items:
key = str(item.get("source_id") or item.get("title") or "")
if not key:
continue
fused_score = float(item.get("score") or 0.0)
for rank in (item.get("rank_sources") or {}).values():
try:
fused_score += 1.0 / (rrf_k + int(rank))
except Exception:
continue
item = dict(item)
item["score"] = fused_score
existing = by_key.get(key)
if existing:
merged_sources = dict(existing.get("rank_sources") or {})
for source, rank in (item.get("rank_sources") or {}).items():
if source not in merged_sources or int(rank) < int(merged_sources.get(source) or 999999):
merged_sources[source] = rank
item["rank_sources"] = merged_sources
fused_score = max(fused_score, float(existing.get("score") or 0.0))
for rank in merged_sources.values():
try:
fused_score += 1.0 / (rrf_k + int(rank))
except Exception:
continue
item["score"] = fused_score
if not existing or fused_score > float(existing.get("score") or 0.0):
by_key[key] = item
return sorted(by_key.values(), key=lambda x: float(x.get("score") or 0.0), reverse=True)[:limit]
async def _rerank_wiki_evidence(query: str, items: List[Dict[str, Any]], top_k: int) -> List[Dict[str, Any]]:
if not query.strip() or not items:
return items[:top_k]
try:
from modelsAPI.model_api import OpenaiAPI
selected = items[:24]
documents = [
"\n".join([
str(item.get("title") or ""),
str(item.get("text") or ""),
])[:4000]
for item in selected
]
rows = await OpenaiAPI.rerank(query=query, documents=documents, top_k=top_k)
if not rows:
return items[:top_k]
ranked: List[Dict[str, Any]] = []
used = set()
for row in rows:
idx = int(row.get("index"))
if idx < 0 or idx >= len(selected) or idx in used:
continue
item = dict(selected[idx])
item["rerank_score"] = float(row.get("relevance_score") or 0.0)
item["score"] = max(float(item.get("score") or 0.0), item["rerank_score"])
ranked.append(item)
used.add(idx)
for idx, item in enumerate(selected):
if idx not in used and len(ranked) < top_k:
ranked.append(item)
return ranked[:top_k]
except Exception as exc:
print(f"[wiki_engine] wiki evidence rerank failed: {exc}")
return items[:top_k]
async def _hydrate_wiki_slices(slices: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Fill missing slice content from 11000 for old Wiki rows."""
out = [dict(s or {}) for s in slices]
missing_by_file: Dict[str, set[str]] = {}
for s in out:
if str(s.get("content") or "").strip():
continue
file_id = str(s.get("file_id") or "").strip()
slice_id = str(s.get("slice_id") or "").strip()
if file_id and slice_id:
missing_by_file.setdefault(file_id, set()).add(slice_id)
if not missing_by_file:
return out
client = HTKnowClient()
fetched: Dict[str, str] = {}
for file_id, wanted_ids in missing_by_file.items():
cached = _FILE_SLICE_TEXT_CACHE.get(file_id)
if cached is not None:
fetched.update({sid: cached.get(sid, "") for sid in wanted_ids})
continue
try:
raw_slices = await client.get_file_slices(file_id, limit=WIKI_CONFIG.get("max_slices_per_file"))
except Exception as exc:
print(f"[wiki_engine] hydrate slices failed for file {file_id}: {exc}")
continue
file_texts: Dict[str, str] = {}
for idx, raw in enumerate(raw_slices):
sid = str(raw.get("id") or raw.get("slice_id") or raw.get("uuid") or f"{file_id}:{idx}")
text = _raw_slice_text(raw)
if text:
file_texts[sid] = text
if sid in wanted_ids:
fetched[sid] = text
_FILE_SLICE_TEXT_CACHE[file_id] = file_texts
for s in out:
sid = str(s.get("slice_id") or "")
if sid in fetched and fetched[sid]:
s["content"] = fetched[sid]
return out
def _file_content_signature(refs: List[Dict[str, Any]]) -> str:
rows = []
for ref in sorted(refs, key=lambda x: (int(x.get("ordinal") or 0), str(x.get("slice_id") or ""))):
rows.append({
"slice_id": str(ref.get("slice_id") or ""),
"content_hash": str(ref.get("content_hash") or ""),
"title_path": ref.get("title_path") or [],
"keywords": ref.get("keywords") or [],
})
raw = json.dumps(rows, ensure_ascii=False, sort_keys=True)
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
def _is_unchanged_file(
state: Optional[Dict[str, Any]],
refs: List[Dict[str, Any]],
content_signature: str,
) -> bool:
if not state:
return False
if int(state.get("slice_count") or 0) != len(refs):
return False
metadata = state.get("metadata") if isinstance(state.get("metadata"), dict) else {}
stored_signature = str(metadata.get("content_signature") or "")
if stored_signature and stored_signature == content_signature:
return True
return str(state.get("file_hash") or "") == content_signature
async def _upsert_page_with_slug_lock(page: Dict[str, Any], owner: str) -> int:
kb_id = str(page.get("kb_id") or "")
slug = str(page.get("slug") or "")
if not kb_id or not slug:
return await db.upsert_wiki_page(page)
ttl = int(WIKI_CONFIG.get("slug_lock_ttl_seconds") or 600)
acquired = False
for _ in range(600):
acquired = await db.acquire_wiki_page_lock(kb_id, slug, owner=owner, ttl_seconds=ttl)
if acquired:
break
await asyncio.sleep(0.1)
if not acquired:
raise TimeoutError(f"wiki page lock timeout: {kb_id}/{slug}")
try:
return await db.upsert_wiki_page(page)
finally:
await db.release_wiki_page_lock(kb_id, slug, owner=owner)
def _raw_slice_text(raw: Dict[str, Any]) -> str:
for key in ("content", "text", "page_content", "slice_content", "markdown"):
value = raw.get(key)
if isinstance(value, str) and value.strip():
return value
return ""
async def get_page(page_id: int) -> Optional[Dict[str, Any]]:
return await db.get_page(page_id)
async def get_stats() -> Dict[str, Any]:
return await db.get_stats()
async def get_kb_tree() -> Dict[str, Any]:
client = HTKnowClient()
tree = await client.get_kb_tree()
return {"success": True, "tree": tree}
async def resolve_kb_reference(reference: str) -> Dict[str, Any]:
"""Resolve a user-facing knowledge-base name or admin id to a kb_id."""
ref = str(reference or "").strip()
if not ref:
return {"success": False, "error": "knowledge base name is empty", "matches": []}
client = HTKnowClient()
tree = await client.get_kb_tree()
nodes = _flatten_kb_tree(tree)
ref_norm = _normalize_kb_text(ref)
scored: List[Dict[str, Any]] = []
for node in nodes:
node_id = str(node.get("id") or "")
names = _node_names(node)
best_score = 0
best_name = names[0] if names else ""
if node_id and node_id == ref:
best_score = 10000
for name in names:
name_norm = _normalize_kb_text(name)
if not name_norm:
continue
score = 0
if name_norm == ref_norm:
score = 9000 + len(name_norm)
elif name_norm in ref_norm:
score = 7000 + len(name_norm)
elif ref_norm in name_norm:
score = 5000 + len(ref_norm)
if score > best_score:
best_score = score
best_name = name
if best_score:
scored.append({
"kb_id": node_id,
"name": best_name,
"path": node.get("_path", best_name),
"score": best_score,
})
scored.sort(key=lambda item: (-int(item.get("score") or 0), -len(str(item.get("path") or ""))))
if not scored:
return {
"success": False,
"error": f"未找到名为“{ref}”的知识库",
"matches": [],
"candidates": [
{"kb_id": str(node.get("id") or ""), "name": (_node_names(node) or [""])[0], "path": node.get("_path", "")}
for node in nodes[:20]
],
}
top_score = int(scored[0].get("score") or 0)
top_matches = [item for item in scored if int(item.get("score") or 0) == top_score]
if len({item.get("kb_id") for item in top_matches}) > 1 and top_score < 10000:
return {
"success": False,
"error": f"知识库名称“{ref}”匹配到多个结果,请说得更完整一点",
"matches": top_matches[:10],
}
best = scored[0]
return {
"success": True,
"kb_id": str(best.get("kb_id") or ""),
"kb_name": str(best.get("name") or ""),
"kb_path": str(best.get("path") or ""),
"matches": scored[:10],
}
async def diff_kb(
kb_id: str,
include_children: bool = False,
limit: int = 200,
parsed_only: bool = True,
) -> Dict[str, Any]:
client = HTKnowClient()
kb_ids = await _resolve_kb_ids(client, kb_id, include_children=include_children)
ht_files: Dict[str, Dict[str, Any]] = {}
skipped_files: List[Dict[str, Any]] = []
for kid in kb_ids:
files = await client.list_files(kb_id=kid, limit=max(int(limit or 200), 1))
for file_info in files:
fid = str(file_info.get("id") or file_info.get("file_id") or "")
if not fid:
continue
if parsed_only and str(file_info.get("status")) != "1":
skipped_files.append(_compact_file(file_info))
continue
file_info["file_id"] = fid
file_info["kb_id"] = str(file_info.get("kb_id") or kid)
ht_files[fid] = file_info
wiki_states = await db.list_file_states(kb_ids)
wiki_by_file = {str(item.get("file_id")): item for item in wiki_states}
missing_in_wiki = []
stale_in_wiki = []
for fid, file_info in ht_files.items():
state = wiki_by_file.get(fid)
if not state:
missing_in_wiki.append(_compact_file(file_info))
elif _is_stale(file_info, state):
stale_in_wiki.append({
"file_id": fid,
"kb_id": str(file_info.get("kb_id") or ""),
"filename": str(file_info.get("filename") or ""),
"htknow_hash": str(file_info.get("hash") or ""),
"wiki_hash": str(state.get("file_hash") or ""),
"htknow_updated_at": str(file_info.get("updated_at") or ""),
"wiki_source_updated_at": str(state.get("source_updated_at") or ""),
"last_synced_at": state.get("last_synced_at") or "",
})
orphan_in_wiki = []
for fid, state in wiki_by_file.items():
if fid not in ht_files:
orphan_in_wiki.append({
"file_id": fid,
"kb_id": str(state.get("kb_id") or ""),
"filename": str(state.get("filename") or ""),
"last_synced_at": state.get("last_synced_at") or "",
})
return {
"success": True,
"kb_id": str(kb_id),
"include_children": include_children,
"kb_ids": kb_ids,
"summary": {
"htknow_files": len(ht_files),
"wiki_files": len(wiki_by_file),
"missing_in_wiki": len(missing_in_wiki),
"stale_in_wiki": len(stale_in_wiki),
"orphan_in_wiki": len(orphan_in_wiki),
"skipped_unparsed": len(skipped_files),
},
"missing_in_wiki": missing_in_wiki,
"stale_in_wiki": stale_in_wiki,
"orphan_in_wiki": orphan_in_wiki,
"skipped_unparsed": skipped_files[:50],
}
async def reconcile_kb(
kb_id: str,
include_children: bool = False,
limit: int = 200,
sync_missing: bool = True,
rebuild_stale: bool = True,
delete_orphan: bool = False,
use_llm: bool = False,
dry_run: bool = True,
) -> Dict[str, Any]:
diff = await diff_kb(kb_id=kb_id, include_children=include_children, limit=limit, parsed_only=True)
actions: List[Dict[str, Any]] = []
if sync_missing:
actions.extend({"action": "sync_file", "file_id": x["file_id"], "kb_id": x.get("kb_id", "")} for x in diff["missing_in_wiki"])
if rebuild_stale:
actions.extend({"action": "rebuild_file", "file_id": x["file_id"], "kb_id": x.get("kb_id", "")} for x in diff["stale_in_wiki"])
if delete_orphan:
actions.extend({"action": "delete_wiki_file", "file_id": x["file_id"], "kb_id": x.get("kb_id", "")} for x in diff["orphan_in_wiki"])
executed: List[Dict[str, Any]] = []
if not dry_run:
client = HTKnowClient()
for action in actions:
try:
if action["action"] in {"sync_file", "rebuild_file"}:
file_info = await client.get_file(action["file_id"])
if action.get("kb_id"):
file_info["kb_id"] = action["kb_id"]
result = await sync_file(
action["file_id"],
kb_id=action.get("kb_id"),
file_info=file_info,
use_llm=use_llm,
force=action["action"] == "rebuild_file",
)
elif action["action"] == "delete_wiki_file":
result = await delete_by_file(action["file_id"])
else:
result = {"success": False, "error": "unknown action"}
executed.append({"action": action, "result": result})
except Exception as exc:
executed.append({"action": action, "result": {"success": False, "error": str(exc)}})
return {
"success": True,
"dry_run": dry_run,
"kb_id": str(kb_id),
"include_children": include_children,
"summary": diff.get("summary", {}),
"planned_actions": actions,
"executed": executed,
}
async def create_admin_plan(
chat_id: str,
intent: str,
scope: str,
actions: List[Dict[str, Any]],
kb_id: str = "",
kb_name: str = "",
summary: Optional[Dict[str, Any]] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
return await db.create_admin_plan(
plan_id=str(uuid.uuid4()),
chat_id=chat_id,
intent=intent,
scope=scope,
kb_id=kb_id,
kb_name=kb_name,
summary=summary or {},
actions=actions,
metadata=metadata or {},
)
async def get_pending_admin_plan(chat_id: str) -> Optional[Dict[str, Any]]:
return await db.get_latest_pending_admin_plan(chat_id)
async def cancel_admin_plan(plan_id: str) -> Optional[Dict[str, Any]]:
return await db.update_admin_plan_status(plan_id, "cancelled")
async def execute_admin_plan(
plan: Dict[str, Any],
selected_indexes: Optional[List[int]] = None,
use_llm: bool = False,
) -> Dict[str, Any]:
actions = plan.get("actions") or []
if selected_indexes:
selected = {idx for idx in selected_indexes if idx >= 1}
actions = [action for idx, action in enumerate(actions, start=1) if idx in selected]
executed: List[Dict[str, Any]] = []
client = HTKnowClient()
for action in actions:
try:
action_type = action.get("action")
if action_type in {"sync_file", "rebuild_file"}:
job_id = await start_sync_job(
target_type="file",
target_id=str(action.get("file_id") or ""),
kb_id=str(action.get("kb_id") or ""),
use_llm=bool(action.get("use_llm") or use_llm),
force=action_type == "rebuild_file",
)
job = await db.get_job(job_id)
result = {"success": True, "status": "running", "job_id": job_id, "job": job}
elif action_type == "delete_wiki_file":
result = await delete_by_file(str(action.get("file_id") or ""))
elif action_type == "delete_wiki_kb":
result = await delete_by_kb(str(action.get("kb_id") or plan.get("kb_id") or ""))
else:
result = {"success": False, "error": f"unknown action: {action_type}"}
executed.append({"action": action, "result": result})
except Exception as exc:
executed.append({"action": action, "result": {"success": False, "error": str(exc)}})
await asyncio.sleep(0)
failed = [item for item in executed if not item.get("result", {}).get("success")]
status = "failed" if failed else "executed"
await db.update_admin_plan_status(str(plan.get("id") or ""), status, executed=executed)
return {
"success": not failed,
"plan_id": plan.get("id", ""),
"status": status,
"executed_count": len(executed),
"failed_count": len(failed),
"executed": executed,
}
async def delete_by_file(file_id: str) -> Dict[str, Any]:
counts = await db.delete_wiki_by_file(file_id)
return {"success": True, "scope": "file", "file_id": str(file_id), "deleted": counts}
async def delete_by_kb(kb_id: str) -> Dict[str, Any]:
counts = await db.delete_wiki_by_kb(kb_id)
return {"success": True, "scope": "kb", "kb_id": str(kb_id), "deleted": counts}
async def delete_by_time(
before: Optional[datetime] = None,
after: Optional[datetime] = None,
kb_id: Optional[str] = None,
) -> Dict[str, Any]:
counts = await db.delete_wiki_by_time(before=before, after=after, kb_id=kb_id)
return {
"success": True,
"scope": "time",
"kb_id": str(kb_id or ""),
"before": before.isoformat() if before else "",
"after": after.isoformat() if after else "",
"deleted": counts,
}
async def _resolve_kb_ids(client: HTKnowClient, kb_id: str, include_children: bool = False) -> List[str]:
root = str(kb_id)
if not include_children:
return [root]
tree = await client.get_kb_tree()
found = _find_kb_node(tree, root)
if not found:
return [root]
ids: List[str] = []
_collect_kb_ids(found, ids)
return ids or [root]
def _find_kb_node(nodes: List[Dict[str, Any]], kb_id: str) -> Optional[Dict[str, Any]]:
for node in nodes:
if str(node.get("id")) == str(kb_id):
return node
children = node.get("children") or []
if isinstance(children, list):
found = _find_kb_node(children, kb_id)
if found:
return found
return None
def _collect_kb_ids(node: Dict[str, Any], out: List[str]) -> None:
kid = node.get("id")
if kid is not None:
out.append(str(kid))
children = node.get("children") or []
if isinstance(children, list):
for child in children:
if isinstance(child, dict):
_collect_kb_ids(child, out)
def _flatten_kb_tree(nodes: List[Dict[str, Any]], parent_path: str = "") -> List[Dict[str, Any]]:
flattened: List[Dict[str, Any]] = []
for node in nodes:
if not isinstance(node, dict):
continue
item = dict(node)
names = _node_names(item)
display_name = names[0] if names else str(item.get("id") or "")
item["_path"] = f"{parent_path}/{display_name}" if parent_path else display_name
flattened.append(item)
children = item.get("children") or []
if isinstance(children, list):
flattened.extend(_flatten_kb_tree(children, item["_path"]))
return flattened
def _node_names(node: Dict[str, Any]) -> List[str]:
keys = (
"name",
"title",
"label",
"kb_name",
"knowledge_base_name",
"knowledgeBaseName",
)
names: List[str] = []
for key in keys:
value = node.get(key)
if value is not None:
text = str(value).strip()
if text and text not in names:
names.append(text)
return names
def _normalize_kb_text(text: str) -> str:
return re.sub(r"[\s:,.。;\"'“”‘’《》<>()【】\\[\\]_-]+", "", str(text or "").lower())
def _compact_file(file_info: Dict[str, Any]) -> Dict[str, Any]:
return {
"file_id": str(file_info.get("file_id") or file_info.get("id") or ""),
"kb_id": str(file_info.get("kb_id") or ""),
"filename": str(file_info.get("filename") or file_info.get("name") or ""),
"hash": str(file_info.get("hash") or ""),
"status": str(file_info.get("status") or ""),
"updated_at": str(file_info.get("updated_at") or ""),
}
def _is_stale(file_info: Dict[str, Any], state: Dict[str, Any]) -> bool:
source_hash = str(file_info.get("hash") or "")
wiki_hash = str(state.get("file_hash") or "")
if source_hash and wiki_hash and source_hash != wiki_hash:
return True
source_updated = str(file_info.get("updated_at") or "")
wiki_source_updated = str(state.get("source_updated_at") or "")
if source_updated and wiki_source_updated and source_updated != wiki_source_updated:
return True
return False

1317
wiki_engine/wiki_builder.py Normal file

File diff suppressed because it is too large Load Diff

138
wiki_engine/worker.py Normal file
View File

@ -0,0 +1,138 @@
import asyncio
import json
from typing import Any, Dict, List
from config import WIKI_CONFIG
from wiki_engine import db
_WORKER_TASKS: List[asyncio.Task] = []
_STARTED = False
async def start_wiki_queue_workers() -> None:
"""Start durable Wiki queue workers for background sync jobs."""
global _STARTED
if _STARTED or not WIKI_CONFIG.get("queue_enabled", True):
return
_STARTED = True
worker_count = _bounded_int(WIKI_CONFIG.get("queue_worker_concurrency"), 2, 1, 8)
for index in range(worker_count):
_WORKER_TASKS.append(asyncio.create_task(_worker_loop(index + 1)))
print(f"[wiki_worker] started {worker_count} worker(s)")
async def stop_wiki_queue_workers() -> None:
for task in _WORKER_TASKS:
task.cancel()
if _WORKER_TASKS:
await asyncio.gather(*_WORKER_TASKS, return_exceptions=True)
_WORKER_TASKS.clear()
async def _worker_loop(worker_id: int) -> None:
idle_sleep = 1.0
while True:
try:
ops = await db.claim_wiki_file_ops(
limit=1,
stale_seconds=_bounded_int(WIKI_CONFIG.get("queue_claim_stale_seconds"), 1800, 60, 24 * 3600),
per_kb_concurrency=_bounded_int(WIKI_CONFIG.get("queue_per_kb_concurrency"), 1, 1, 8),
)
if not ops:
finalize_ops = await db.claim_wiki_finalize_ops(limit=1)
if finalize_ops:
for finalize_op in finalize_ops:
await _process_finalize_op(worker_id, finalize_op)
continue
await asyncio.sleep(idle_sleep)
continue
for op in ops:
await _process_op(worker_id, op)
except asyncio.CancelledError:
raise
except Exception as exc:
print(f"[wiki_worker] worker {worker_id} loop failed: {exc}")
await asyncio.sleep(3.0)
async def _process_op(worker_id: int, op: Dict[str, Any]) -> None:
payload = _decode_payload(op.get("payload"))
job_id = str(payload.get("job_id") or op.get("job_id") or "")
kb_id = str(payload.get("kb_id") or op.get("kb_id") or "")
file_id = str(payload.get("file_id") or op.get("file_id") or "")
file_info = payload.get("file_info") if isinstance(payload.get("file_info"), dict) else {}
use_llm = bool(payload.get("use_llm"))
force = bool(payload.get("force"))
print(f"[wiki_worker] worker {worker_id} sync_file file_id={file_id} kb_id={kb_id} job_id={job_id}")
try:
from wiki_engine import service
result = await service.sync_file(
file_id=file_id,
kb_id=kb_id,
file_info=file_info,
use_llm=use_llm,
job_id=job_id,
force=force,
)
await db.complete_wiki_op(int(op["id"]))
await db.increment_job_progress(
job_id,
processed_delta=1,
slice_delta=int(result.get("slice_count") or 0),
skipped_delta=1 if result.get("skipped") else 0,
page_delta=int(result.get("page_count") or 0) if not result.get("skipped") else 0,
stage="queued_finalize" if kb_id else "file_done",
)
if job_id and kb_id:
job = await db.get_job(job_id)
if job and int(job.get("processed_files") or 0) >= int(job.get("total_files") or 0):
await service.enqueue_finalize_job(kb_id=kb_id, job_id=job_id)
except Exception as exc:
terminal = await db.fail_wiki_op(
op,
error=str(exc),
max_retries=_bounded_int(WIKI_CONFIG.get("queue_max_retries"), 3, 1, 10),
)
if terminal and job_id:
await db.increment_job_progress(job_id, processed_delta=1, slice_delta=0, error=f"{file_id}: {exc}")
print(f"[wiki_worker] worker {worker_id} sync_file failed file_id={file_id}: {exc}")
async def _process_finalize_op(worker_id: int, op: Dict[str, Any]) -> None:
payload = _decode_payload(op.get("payload"))
job_id = str(payload.get("job_id") or op.get("job_id") or "")
kb_id = str(payload.get("kb_id") or op.get("kb_id") or "")
print(f"[wiki_worker] worker {worker_id} finalize_kb kb_id={kb_id} job_id={job_id}")
try:
from wiki_engine import service
await service.finalize_kb(kb_id=kb_id, job_id=job_id)
await db.complete_wiki_finalize_op(int(op["id"]))
except Exception as exc:
await db.fail_wiki_finalize_op(int(op["id"]), str(exc))
if job_id:
await db.update_job(job_id, stage="finalize_failed", error=str(exc))
print(f"[wiki_worker] worker {worker_id} finalize failed kb_id={kb_id}: {exc}")
def _decode_payload(value: Any) -> Dict[str, Any]:
if isinstance(value, dict):
return value
if isinstance(value, str) and value.strip():
try:
parsed = json.loads(value)
return parsed if isinstance(parsed, dict) else {}
except Exception:
return {}
return {}
def _bounded_int(value: Any, default: int, minimum: int, maximum: int) -> int:
try:
number = int(value)
except Exception:
number = default
return max(minimum, min(maximum, number))

View File

@ -72,6 +72,16 @@ WORKFLOW_CONFIG = {
"module": "workflows.workflow_statistics", "module": "workflows.workflow_statistics",
"function": "run_statistics_workflow", "function": "run_statistics_workflow",
"is_async": True, "is_async": True,
},
"wiki_admin": {
"module": "workflows.workflow_wiki_admin",
"function": "run_wiki_admin_workflow",
"is_async": True,
},
"Wiki\u77e5\u8bc6\u5e93\u7ba1\u7406": {
"module": "workflows.workflow_wiki_admin",
"function": "run_wiki_admin_workflow",
"is_async": True,
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -46,35 +46,27 @@ def safe_json_extract(text: str) -> Optional[Any]:
from workflows.history_manager import parse_history, build_history_str, filter_image_urls, init_history # noqa: F401 from workflows.history_manager import parse_history, build_history_str, filter_image_urls, init_history # noqa: F401
import re import re
def normalize_latex_spacing(text: str) -> str: def normalize_latex_spacing(text: str) -> str:
""" """
规范 Markdown 中的 LaTeX 公式 规范 Markdown 中的 LaTeX 公式
规则 处理
1. 行内公式 $...$ - $...$行内公式清理内侧空格并补充正文间空格
- 清除公式定界符内部首尾空格 - $$...$$段落公式完整保留开始和结束定界符
- 公式前后只要存在非空白字符就补一个空格 - \\(...\\)转换为 $...$
- ($T$) 会转换为 ( $T$ ) - \\[...\\]转换为 $$...$$
2. 段落公式 $$...$$ 不修改
- 多行公式完整保留不会删除结尾的 $$ - Markdown 围栏代码块
- 单行 $$公式$$ 转换为标准多行段落公式 - Markdown 行内代码
- Markdown 表格中的 $$公式$$ 不拆行 - HTML code/pre/script/style
- HTML 注释
3. 转换 - 转义美元符号 \\$
- \\(...\\) 转换为 $...$
- \\[...\\] 转换为 $$...$$
4. 不处理
- Markdown 围栏代码块
- Markdown 行内代码
- HTML codeprescriptstyle
- HTML 注释
- Markdown 链接地址
- 转义美元符号 \\$
""" """
if not isinstance(text, str): if not isinstance(text, str):
raise TypeError( raise TypeError(
@ -101,13 +93,9 @@ def normalize_latex_spacing(text: str) -> str:
for index, original in enumerate(protected_parts): for index, original in enumerate(protected_parts):
token = f"\uE000LATEX_PROTECTED_{index}\uE001" token = f"\uE000LATEX_PROTECTED_{index}\uE001"
content = content.replace(token, original) content = content.replace(token, original)
return content return content
def is_escaped(content: str, position: int) -> bool: def is_escaped(content: str, position: int) -> bool:
"""
判断 content[position] 是否被奇数个反斜杠转义
"""
slash_count = 0 slash_count = 0
position -= 1 position -= 1
@ -118,27 +106,27 @@ def normalize_latex_spacing(text: str) -> str:
return slash_count % 2 == 1 return slash_count % 2 == 1
# ================================================================ # ================================================================
# 1. 保护 Markdown 围栏代码块 # 保护 Markdown 围栏代码块
# ================================================================ # ================================================================
lines = text.splitlines(keepends=True) lines = text.splitlines(keepends=True)
protected_lines: list[str] = [] output_lines: list[str] = []
line_index = 0 line_index = 0
while line_index < len(lines): while line_index < len(lines):
line = lines[line_index] line = lines[line_index]
opening_match = re.match( fence_match = re.match(
r"^[ \t]{0,3}(`{3,}|~{3,})", r"^[ \t]{0,3}(`{3,}|~{3,})",
line, line,
) )
if not opening_match: if not fence_match:
protected_lines.append(line) output_lines.append(line)
line_index += 1 line_index += 1
continue continue
opening_fence = opening_match.group(1) opening_fence = fence_match.group(1)
fence_character = opening_fence[0] fence_character = opening_fence[0]
fence_length = len(opening_fence) fence_length = len(opening_fence)
@ -160,14 +148,14 @@ def normalize_latex_spacing(text: str) -> str:
if closing_pattern.match(current_line): if closing_pattern.match(current_line):
break break
protected_lines.append( output_lines.append(
protect("".join(block_lines)) protect("".join(block_lines))
) )
text = "".join(protected_lines) text = "".join(output_lines)
# ================================================================ # ================================================================
# 2. 保护 HTML 代码区域和注释 # 保护 HTML 代码区域和注释
# ================================================================ # ================================================================
text = re.sub( text = re.sub(
@ -186,7 +174,7 @@ def normalize_latex_spacing(text: str) -> str:
) )
# ================================================================ # ================================================================
# 3. 保护 Markdown 链接地址 # 保护 Markdown 链接地址
# ================================================================ # ================================================================
text = re.sub( text = re.sub(
@ -199,27 +187,16 @@ def normalize_latex_spacing(text: str) -> str:
text, text,
) )
text = re.sub(
r"(?im)"
r"^([ \t]{0,3}\[[^\]\n]+\]:[ \t]*)"
r"(\S+(?:[ \t]+.*)?)$",
lambda match: (
match.group(1)
+ protect(match.group(2))
),
text,
)
# ================================================================ # ================================================================
# 4. 保护 Markdown 行内代码 # 保护 Markdown 行内代码
# ================================================================ # ================================================================
inline_code_result: list[str] = [] inline_code_output: list[str] = []
position = 0 position = 0
while position < len(text): while position < len(text):
if text[position] != "`": if text[position] != "`":
inline_code_result.append(text[position]) inline_code_output.append(text[position])
position += 1 position += 1
continue continue
@ -250,11 +227,11 @@ def normalize_latex_spacing(text: str) -> str:
else "" else ""
) )
candidate_end = candidate + len(delimiter) next_position = candidate + len(delimiter)
next_character = ( next_character = (
text[candidate_end] text[next_position]
if candidate_end < len(text) if next_position < len(text)
else "" else ""
) )
@ -268,22 +245,22 @@ def normalize_latex_spacing(text: str) -> str:
search_position = candidate + 1 search_position = candidate + 1
if closing_position < 0: if closing_position < 0:
inline_code_result.append(delimiter) inline_code_output.append(delimiter)
position = delimiter_end position = delimiter_end
continue continue
end_position = closing_position + len(delimiter) end_position = closing_position + len(delimiter)
inline_code_result.append( inline_code_output.append(
protect(text[position:end_position]) protect(text[position:end_position])
) )
position = end_position position = end_position
text = "".join(inline_code_result) text = "".join(inline_code_output)
# ================================================================ # ================================================================
# 5. 转换其他 LaTeX 公式定界符 # 转换其他 LaTeX 定界符
# ================================================================ # ================================================================
# \( x + y \) -> $x + y$ # \( x + y \) -> $x + y$
@ -305,166 +282,78 @@ def normalize_latex_spacing(text: str) -> str:
) )
# ================================================================ # ================================================================
# 6. 完整提取并保护 $$...$$ 段落公式 # 扫描公式
# ================================================================ # ================================================================
display_result: list[str] = [] opening_punctuation = set(
"([{(【《〈“‘"
)
closing_punctuation = set(
")]})】》〉”’、,。;:!?,.!?;:"
)
result: list[str] = []
position = 0 position = 0
while position < len(text): while position < len(text):
if not ( # ------------------------------------------------------------
# 段落公式 $$...$$
# ------------------------------------------------------------
if (
text.startswith("$$", position) text.startswith("$$", position)
and not is_escaped(text, position) and not is_escaped(text, position)
): ):
display_result.append(text[position]) search_position = position + 2
position += 1 closing_position = -1
continue
opening_position = position while search_position < len(text):
search_position = position + 2 candidate = text.find(
closing_position = -1 "$$",
search_position,
while search_position < len(text): )
candidate = text.find(
"$$", if candidate < 0:
search_position, break
)
if not is_escaped(text, candidate):
if candidate < 0: closing_position = candidate
break break
if not is_escaped(text, candidate): search_position = candidate + 2
closing_position = candidate
break # 未闭合时,原样保留剩余内容。
if closing_position < 0:
search_position = candidate + 2 result.append(text[position:])
break
# 未找到闭合 $$,剩余内容原样保留。
if closing_position < 0: complete_formula = text[
display_result.append( position:closing_position + 2
text[opening_position:] ]
)
position = len(text) formula_body = text[
break position + 2:closing_position
]
complete_formula = text[
opening_position:closing_position + 2 # 多行段落公式完整原样保留。
] if "\n" in complete_formula:
result.append(complete_formula)
formula_body = text[ else:
opening_position + 2:closing_position # 单行段落公式只清理内侧空格,
] # 不删除或重建结束的 $$。
result.append(
# ------------------------------------------------------------ "$$" + formula_body.strip() + "$$"
# 已经是多行段落公式时,完整原样保留。
#
# $$
# R = K
# $$
#
# 不拆解、不重建,避免结尾 $$ 丢失。
# ------------------------------------------------------------
if "\n" in complete_formula:
display_result.append(
protect(complete_formula)
)
position = closing_position + 2
continue
# ------------------------------------------------------------
# 判断公式是否位于 Markdown 表格内。
# 表格中的公式不能强制拆成多行。
# ------------------------------------------------------------
line_start = text.rfind(
"\n",
0,
opening_position,
) + 1
line_end = text.find(
"\n",
closing_position + 2,
)
if line_end < 0:
line_end = len(text)
current_line = text[line_start:line_end]
clean_body = formula_body.strip()
if "|" in current_line:
display_result.append(
protect(
"$$" + clean_body + "$$"
) )
)
position = closing_position + 2 position = closing_position + 2
continue continue
# ------------------------------------------------------------ # ------------------------------------------------------------
# 单行段落公式转换成标准多行格式。 # 行内公式 $...$
#
# $$R = K$$
#
# 转换为:
#
# $$
# R = K
# $$
# ------------------------------------------------------------ # ------------------------------------------------------------
normalized_formula = ( if (
"$$\n"
+ clean_body
+ "\n$$"
)
before_text = "".join(display_result)
# 公式前存在普通正文时,确保前面有空行。
if before_text and not before_text.endswith("\n\n"):
before_text = before_text.rstrip(" \t\n") + "\n\n"
display_result = [before_text] if before_text else []
display_result.append(
protect(normalized_formula)
)
position = closing_position + 2
# 清理公式后已有的普通空格。
while (
position < len(text)
and text[position] in " \t"
):
position += 1
# 后面还有内容时,确保公式后有空行。
if position < len(text):
if text[position] == "\n":
while (
position < len(text)
and text[position] == "\n"
):
position += 1
display_result.append("\n\n")
text = "".join(display_result)
# ================================================================
# 7. 规范 $...$ 行内公式
# ================================================================
inline_result: list[str] = []
position = 0
while position < len(text):
is_inline_opening = (
text[position] == "$" text[position] == "$"
and not is_escaped(text, position) and not is_escaped(text, position)
and not text.startswith("$$", position) and not text.startswith("$$", position)
@ -472,127 +361,111 @@ def normalize_latex_spacing(text: str) -> str:
position > 0 position > 0
and text[position - 1] == "$" and text[position - 1] == "$"
) )
) ):
opening_position = position
search_position = position + 1
closing_position = -1
if not is_inline_opening: while search_position < len(text):
inline_result.append(text[position]) candidate = text.find(
position += 1 "$",
continue search_position,
)
opening_position = position if candidate < 0:
break
# 避免把 $100 识别成行内公式开始。 # 行内公式不允许跨行。
next_opening_character = ( if "\n" in text[
text[position + 1] opening_position + 1:candidate
if position + 1 < len(text) ]:
else "" break
)
if next_opening_character.isdigit(): if is_escaped(text, candidate):
inline_result.append("$") search_position = candidate + 1
position += 1 continue
continue
search_position = position + 1 if text.startswith("$$", candidate):
closing_position = -1 search_position = candidate + 2
continue
while search_position < len(text): next_character = (
candidate = text.find( text[candidate + 1]
"$", if candidate + 1 < len(text)
search_position, else ""
) )
if candidate < 0: # 避免把 $100 和 $200 配对成公式。
if next_character.isdigit():
search_position = candidate + 1
continue
closing_position = candidate
break break
# 行内公式不能跨行。 if closing_position < 0:
if "\n" in text[ result.append("$")
opening_position + 1:candidate position += 1
]:
break
if is_escaped(text, candidate):
search_position = candidate + 1
continue continue
# 跳过属于 $$ 的美元符号。 formula_body = text[
if text.startswith("$$", candidate): opening_position + 1:closing_position
search_position = candidate + 2 ].strip()
if not formula_body:
result.append(
text[
opening_position:
closing_position + 1
]
)
position = closing_position + 1
continue continue
closing_position = candidate formula_body = re.sub(
break r"[ \t]+",
" ",
# 未找到闭合符时原样保留。 formula_body,
if closing_position < 0:
inline_result.append("$")
position += 1
continue
formula_body = text[
opening_position + 1:closing_position
].strip()
# 空公式不处理。
if not formula_body:
inline_result.append(
text[
opening_position:closing_position + 1
]
) )
previous_character = ""
if result and result[-1]:
previous_character = result[-1][-1]
next_character = (
text[closing_position + 1]
if closing_position + 1 < len(text)
else ""
)
if (
previous_character
and not previous_character.isspace()
and previous_character
not in opening_punctuation
):
result.append(" ")
result.append(
"$" + formula_body + "$"
)
if (
next_character
and not next_character.isspace()
and next_character
not in closing_punctuation
):
result.append(" ")
position = closing_position + 1 position = closing_position + 1
continue continue
# 折叠公式内部普通空格,不修改 LaTeX 命令。 result.append(text[position])
formula_body = re.sub( position += 1
r"[ \t]+",
" ",
formula_body,
)
previous_character = "" return restore("".join(result))
for part in reversed(inline_result):
if part:
previous_character = part[-1]
break
next_character = (
text[closing_position + 1]
if closing_position + 1 < len(text)
else ""
)
# 公式前只要存在非空白字符,就补空格。
#
# 参数$T$ -> 参数 $T$
# ($T$) -> ( $T$
# $T$ -> $T$
if (
previous_character
and not previous_character.isspace()
):
inline_result.append(" ")
inline_result.append(
"$" + formula_body + "$"
)
# 公式后只要存在非空白字符,就补空格。
#
# $T$参数 -> $T$ 参数
# ($T$) -> $T$ )
# $T$ -> $T$
if (
next_character
and not next_character.isspace()
):
inline_result.append(" ")
position = closing_position + 1
return restore("".join(inline_result))
def remove_duplicate_content(text: str) -> str: def remove_duplicate_content(text: str) -> str:
@ -605,18 +478,13 @@ def remove_duplicate_content(text: str) -> str:
- equationaligngather LaTeX 环境 - equationaligngather LaTeX 环境
- Markdown 围栏代码块 - Markdown 围栏代码块
- Markdown 缩进代码块 - Markdown 缩进代码块
- Markdown 标题列表引用表格等结构行 - 标题列表引用表格等 Markdown 结构行
这样不会删除段落公式末尾的第二个 $$ 这样不会删除段落公式末尾的第二个 $$
""" """
if not text or len(text) < 10: if not text or len(text) < 10:
return text return text
if not isinstance(text, str):
raise TypeError(
f"text 必须是 str实际类型为 {type(text).__name__}"
)
text = text.replace("\r\n", "\n").replace("\r", "\n") text = text.replace("\r\n", "\n").replace("\r", "\n")
lines = text.split("\n") lines = text.split("\n")
@ -662,9 +530,6 @@ def remove_duplicate_content(text: str) -> str:
return slash_count % 2 == 1 return slash_count % 2 == 1
def count_unescaped_double_dollars(value: str) -> int: def count_unescaped_double_dollars(value: str) -> int:
"""
统计一行中未转义的 $$ 数量
"""
count = 0 count = 0
position = 0 position = 0
@ -681,16 +546,13 @@ def remove_duplicate_content(text: str) -> str:
return count return count
def is_markdown_structure_line(value: str) -> bool: def is_markdown_structure_line(value: str) -> bool:
"""
Markdown 结构行不参与去重避免破坏语义
"""
stripped = value.strip() stripped = value.strip()
if not stripped: if not stripped:
return True return True
patterns = ( patterns = (
# 标题 # Markdown 标题
r"^#{1,6}\s+", r"^#{1,6}\s+",
# 无序列表 # 无序列表
@ -708,7 +570,7 @@ def remove_duplicate_content(text: str) -> str:
# 水平分隔线 # 水平分隔线
r"^(?:-{3,}|\*{3,}|_{3,})$", r"^(?:-{3,}|\*{3,}|_{3,})$",
# 表格分隔行 # Markdown 表格分隔行
r"^\|?\s*:?-{3,}:?" r"^\|?\s*:?-{3,}:?"
r"(?:\s*\|\s*:?-{3,}:?)+\s*\|?$", r"(?:\s*\|\s*:?-{3,}:?)+\s*\|?$",
@ -738,7 +600,7 @@ def remove_duplicate_content(text: str) -> str:
stripped = line.strip() stripped = line.strip()
# ============================================================ # ============================================================
# 1. Markdown 围栏代码块 # Markdown 围栏代码块
# ============================================================ # ============================================================
fence_match = re.match( fence_match = re.match(
@ -773,13 +635,13 @@ def remove_duplicate_content(text: str) -> str:
continue continue
# Markdown 缩进代码块不参与去重 # Markdown 缩进代码块
if re.match(r"^(?:\t| {4})", line): if re.match(r"^(?:\t| {4})", line):
result.append(line) result.append(line)
continue continue
# ============================================================ # ============================================================
# 2. \[ ... \] 段落公式 # \[ ... \] 段落公式
# ============================================================ # ============================================================
if in_bracket_math: if in_bracket_math:
@ -807,7 +669,7 @@ def remove_duplicate_content(text: str) -> str:
continue continue
# ============================================================ # ============================================================
# 3. LaTeX display 环境 # LaTeX display 环境
# ============================================================ # ============================================================
begin_matches = re.findall( begin_matches = re.findall(
@ -869,7 +731,7 @@ def remove_duplicate_content(text: str) -> str:
continue continue
# ============================================================ # ============================================================
# 4. $$ ... $$ 段落公式 # $$ ... $$ 段落公式
# ============================================================ # ============================================================
double_dollar_count = ( double_dollar_count = (
@ -877,7 +739,7 @@ def remove_duplicate_content(text: str) -> str:
) )
if in_dollar_math: if in_dollar_math:
# 公式块中的内容全部保留,包括最后一行的 $$ # 公式内部和结束的 $$ 全部原样保留
result.append(line) result.append(line)
if double_dollar_count % 2 == 1: if double_dollar_count % 2 == 1:
@ -886,17 +748,17 @@ def remove_duplicate_content(text: str) -> str:
continue continue
if double_dollar_count > 0: if double_dollar_count > 0:
# $$ 的行永远不参与去重。 # 含 $$ 的行不参与去重。
result.append(line) result.append(line)
# 奇数个 $$ 表开启了跨行段落公式。 # 奇数个 $$ 开启了跨行公式。
if double_dollar_count % 2 == 1: if double_dollar_count % 2 == 1:
in_dollar_math = True in_dollar_math = True
continue continue
# ============================================================ # ============================================================
# 5. 空行和 Markdown 结构行 # 空行和 Markdown 结构行
# ============================================================ # ============================================================
if not stripped: if not stripped:
@ -908,7 +770,7 @@ def remove_duplicate_content(text: str) -> str:
continue continue
# ============================================================ # ============================================================
# 6. 仅对普通正文行去重 # 仅对普通正文行去重
# ============================================================ # ============================================================
normalized_line = re.sub( normalized_line = re.sub(
@ -931,6 +793,41 @@ def remove_duplicate_content(text: str) -> str:
# text = remove_duplicate_content(text) # text = remove_duplicate_content(text)
# text = normalize_latex_spacing(text) # text = normalize_latex_spacing(text)
#def normalize_latex_spacing(text: str) -> str:
# placeholder = "\x00DOUBLE_DOLLAR\x00"
# text = text.replace("$$", placeholder)
# text = re.sub(r'(?<!\s)\$', r' $', text)
# text = re.sub(r'\$(?!\s)', r'$ ', text)
# text = text.replace(placeholder, "$$")
# text = re.sub(r'(?<!\s)\$\$', r' $$', text)
# text = re.sub(r'\$\$(?!\s)', r'$$ ', text)
# return text
#def remove_duplicate_content(text: str) -> str:
# if not text or len(text) < 10:
# return text
#
# lines = text.split('\n')
# result = []
# seen_segments = set()
#
# for line in lines:
# line = line.rstrip()
# if not line:
# result.append(line)
# continue
#
# normalized_line = line.strip().lower()
# if normalized_line in seen_segments:
# continue
#
# seen_segments.add(normalized_line)
# result.append(line)
#
# cleaned_text = '\n'.join(result)
# return cleaned_text
def _extract_rag_text(item: Any) -> str: def _extract_rag_text(item: Any) -> str:
if isinstance(item, dict): if isinstance(item, dict):

View File

@ -0,0 +1,493 @@
import json
import re
from typing import Any, Dict, List, Optional
from wiki_engine import service
CONFIRM_WORDS = ("确认", "执行", "开始", "确定", "同意", "dry_run=false", "不是预览")
CANCEL_WORDS = ("取消", "先不", "不要执行", "作废")
async def run_wiki_admin_workflow(
extracted_text: str,
combined_query: str,
history_message: str = "",
route_flag: str = "",
chat_id: str = "",
message_id: str = "",
**_: Any,
) -> Dict[str, Any]:
query = (combined_query or extracted_text or "").strip()
if not query:
return _response("请告诉我要管理哪个知识库或文件,例如:检查知识库 163舰 的 Wiki 同步情况。")
try:
result = await _dispatch(query=query, chat_id=chat_id)
return _response(_format_result(result), result=result)
except Exception as exc:
return _response(f"Wiki 管理操作失败:{exc}", error=str(exc))
async def _dispatch(query: str, chat_id: str = "") -> Dict[str, Any]:
pending_plan = await service.get_pending_admin_plan(chat_id) if chat_id else None
confirmed = _is_confirmed(query)
selected_indexes = _extract_selection_indexes(query)
job_id = _extract_job_id(query)
if job_id and any(word in query for word in ("进度", "状态", "查看", "job", "任务", "同步")):
job = await service.get_sync_job(job_id)
if not job:
return {"operation": "job_progress", "data": {"success": False, "job_id": job_id, "message": "未找到这个同步任务。"}}
return {"operation": "job_progress", "data": {"success": True, "job": job}}
if pending_plan and _is_cancel(query):
cancelled = await service.cancel_admin_plan(str(pending_plan.get("id") or ""))
return {"operation": "plan_cancelled", "data": {"success": True, "plan": cancelled or pending_plan}}
if pending_plan and confirmed and not _contains_new_target(query):
data = await service.execute_admin_plan(pending_plan, selected_indexes=selected_indexes)
data["plan"] = pending_plan
return {"operation": "plan_executed", "data": data}
kb_id = _extract_kb_id(query)
file_id = _extract_id(query, ("文件", "file", "file_id"))
include_children = any(word in query for word in ("子知识库", "子库", "包含子", "包括子"))
use_llm = any(word.lower() in query.lower() for word in ("大模型", "llm", "模型摘要", "智能摘要"))
limit = _extract_limit(query) or 20
if any(word in query for word in ("统计", "状态", "概览", "数量")) and not kb_id and not file_id:
return {"operation": "stats", "data": {"success": True, "stats": await service.get_stats()}}
if any(word in query for word in ("检查", "对比", "差异", "巡检", "同步情况")):
target = await _resolve_kb_target(query, kb_id)
if not target.get("success"):
return {"operation": "need_kb_name", "data": target}
diff = await service.diff_kb(kb_id=target["kb_id"], include_children=include_children, limit=limit)
diff.update({"kb_name": target.get("kb_name", ""), "kb_path": target.get("kb_path", "")})
actions = _sync_actions_from_diff(diff, use_llm=use_llm)
plan = await _save_plan(
chat_id=chat_id,
intent="sync_after_check",
scope="kb",
target=target,
summary=diff.get("summary", {}),
actions=actions,
metadata={"source_operation": "diff_kb", "include_children": include_children, "limit": limit},
)
diff["plan"] = plan
diff["planned_actions"] = actions
return {"operation": "diff_kb", "data": diff}
if any(word in query for word in ("清理", "修复", "对齐", "同步当前", "reconcile")):
target = await _resolve_kb_target(query, kb_id)
if not target.get("success"):
return {"operation": "need_kb_name", "data": target}
delete_orphan = any(word in query for word in ("删除孤儿", "删除脏数据", "清理脏数据", "已删除", "不存在", "孤儿"))
preview = await service.reconcile_kb(
kb_id=target["kb_id"],
include_children=include_children,
limit=limit,
sync_missing=True,
rebuild_stale=True,
delete_orphan=delete_orphan,
use_llm=use_llm,
dry_run=True,
)
preview.update({"kb_name": target.get("kb_name", ""), "kb_path": target.get("kb_path", "")})
return await _plan_or_execute(
chat_id=chat_id,
confirmed=confirmed,
intent="reconcile_kb",
scope="kb",
target=target,
summary=preview.get("summary", {}),
actions=preview.get("planned_actions", []),
metadata={"delete_orphan": delete_orphan, "include_children": include_children, "limit": limit},
preview=preview,
)
if any(word in query for word in ("删除", "移除")):
if file_id:
action = {"action": "delete_wiki_file", "file_id": file_id}
return await _plan_or_execute(
chat_id=chat_id,
confirmed=confirmed,
intent="delete_wiki_file",
scope="file",
target={"kb_id": kb_id or "", "kb_name": "", "kb_path": ""},
summary={"delete_files": 1},
actions=[action],
metadata={"destructive": True},
preview={"success": True, "planned_actions": [action]},
)
target = await _resolve_kb_target(query, kb_id)
if target.get("success"):
action = {"action": "delete_wiki_kb", "kb_id": target["kb_id"], "kb_name": target.get("kb_name", "")}
return await _plan_or_execute(
chat_id=chat_id,
confirmed=confirmed,
intent="delete_wiki_kb",
scope="kb",
target=target,
summary={"delete_kb": 1},
actions=[action],
metadata={"destructive": True},
preview={"success": True, "planned_actions": [action], **target},
)
return {"operation": "need_target", "data": {"success": False, "message": "删除 Wiki 数据需要提供文件 id或说清楚知识库名称。", **target}}
if any(word in query for word in ("同步", "构建", "建立", "重建", "生成")):
if file_id:
action = {"action": "sync_file", "file_id": file_id, "kb_id": kb_id or "", "use_llm": use_llm}
return await _plan_or_execute(
chat_id=chat_id,
confirmed=confirmed,
intent="sync_file",
scope="file",
target={"kb_id": kb_id or "", "kb_name": "", "kb_path": ""},
summary={"sync_files": 1},
actions=[action],
metadata={"use_llm": use_llm},
preview={"success": True, "planned_actions": [action]},
)
target = await _resolve_kb_target(query, kb_id)
if target.get("success"):
preview = await service.reconcile_kb(
kb_id=target["kb_id"],
include_children=include_children,
limit=limit,
sync_missing=True,
rebuild_stale=True,
delete_orphan=False,
use_llm=use_llm,
dry_run=True,
)
preview.update({"kb_name": target.get("kb_name", ""), "kb_path": target.get("kb_path", "")})
return await _plan_or_execute(
chat_id=chat_id,
confirmed=confirmed,
intent="sync_kb",
scope="kb",
target=target,
summary=preview.get("summary", {}),
actions=preview.get("planned_actions", []),
metadata={"include_children": include_children, "limit": limit, "use_llm": use_llm},
preview=preview,
)
return {"operation": "need_target", "data": {"success": False, "message": "同步 Wiki 需要提供文件 id或说清楚知识库名称。", **target}}
if pending_plan:
return {"operation": "pending_plan", "data": {"success": True, "plan": pending_plan}}
return {
"operation": "help",
"data": {
"success": True,
"examples": [
"检查知识库 163舰 的 Wiki 同步情况",
"同步知识库 163舰 的前 50 个文件",
"清理知识库 163舰 中 11000 已删除但 Wiki 还存在的数据",
"确认执行",
"只执行第 1、3 个",
],
},
}
async def _plan_or_execute(
chat_id: str,
confirmed: bool,
intent: str,
scope: str,
target: Dict[str, Any],
summary: Dict[str, Any],
actions: List[Dict[str, Any]],
metadata: Dict[str, Any],
preview: Dict[str, Any],
) -> Dict[str, Any]:
plan = await _save_plan(chat_id, intent, scope, target, summary, actions, metadata)
preview["plan"] = plan
preview["planned_actions"] = actions
if confirmed and actions:
if not plan:
return {"operation": "plan_preview", "data": preview}
executed = await service.execute_admin_plan(plan)
executed["plan"] = plan
return {"operation": "plan_executed", "data": executed}
return {"operation": "plan_preview", "data": preview}
async def _save_plan(
chat_id: str,
intent: str,
scope: str,
target: Dict[str, Any],
summary: Dict[str, Any],
actions: List[Dict[str, Any]],
metadata: Dict[str, Any],
) -> Dict[str, Any]:
if not chat_id or not actions:
return {}
return await service.create_admin_plan(
chat_id=chat_id,
intent=intent,
scope=scope,
kb_id=str(target.get("kb_id") or ""),
kb_name=str(target.get("kb_name") or target.get("kb_path") or ""),
summary=summary,
actions=actions,
metadata=metadata,
)
def _sync_actions_from_diff(diff: Dict[str, Any], use_llm: bool = False) -> List[Dict[str, Any]]:
actions: List[Dict[str, Any]] = []
for item in diff.get("missing_in_wiki", []):
actions.append({
"action": "sync_file",
"file_id": item.get("file_id", ""),
"kb_id": item.get("kb_id") or diff.get("kb_id", ""),
"filename": item.get("filename", ""),
"use_llm": use_llm,
})
for item in diff.get("stale_in_wiki", []):
actions.append({
"action": "rebuild_file",
"file_id": item.get("file_id", ""),
"kb_id": item.get("kb_id") or diff.get("kb_id", ""),
"filename": item.get("filename", ""),
"use_llm": use_llm,
})
return actions
def _format_result(result: Dict[str, Any]) -> str:
op = result.get("operation", "")
data = result.get("data", {})
if op == "help":
return "我是 Wiki 管理助手,可以检查、生成计划、同步、清理和删除 Wiki 指针数据。\n\n示例:\n" + "\n".join(f"- {x}" for x in data.get("examples", []))
if op in {"need_kb_id", "need_kb_name", "need_target"}:
matches = data.get("matches") or data.get("candidates") or []
if matches:
options = "\n".join(
f"- {item.get('path') or item.get('name')}id: {item.get('kb_id')}"
for item in matches[:5]
)
return f"{data.get('message') or data.get('error') or '请说清楚知识库名称。'}\n可选知识库:\n{options}"
return data.get("message", "请补充必要参数。")
if op == "stats":
stats = data.get("stats", {})
return "当前 Wiki 数据统计:\n" + "\n".join(f"- {k}: {v}" for k, v in stats.items())
if op == "job_progress":
if not data.get("success"):
return data.get("message") or "未找到同步任务。"
return _format_job_progress(data.get("job") or {})
if op == "diff_kb":
summary = data.get("summary", {})
target = data.get("kb_path") or data.get("kb_name") or data.get("kb_id")
lines = [
f"知识库 {target} 的 Wiki 同步检查完成。",
f"- 11000 可用文件数:{summary.get('htknow_files', 0)}",
f"- Wiki 已同步文件数:{summary.get('wiki_files', 0)}",
f"- 11000 有但 Wiki 缺失:{summary.get('missing_in_wiki', 0)}",
f"- Wiki 已过期需重建:{summary.get('stale_in_wiki', 0)}",
f"- Wiki 有但 11000 不存在:{summary.get('orphan_in_wiki', 0)}",
f"- 跳过未解析文件:{summary.get('skipped_unparsed', 0)}",
]
lines.extend(_format_plan_tail(data.get("plan") or {}, data.get("planned_actions") or []))
return "\n".join(lines)
if op in {"plan_preview", "pending_plan"}:
plan = data.get("plan") or {}
actions = data.get("planned_actions") or plan.get("actions") or []
target = data.get("kb_path") or data.get("kb_name") or plan.get("kb_name") or plan.get("kb_id") or "当前目标"
lines = [f"已生成 {target} 的操作计划。", f"- 计划编号:{plan.get('id') or '未保存'}", f"- 待执行动作:{len(actions)}"]
lines.extend(_format_actions(actions))
if plan:
lines.append("确认无误后,请回复“确认执行”;也可以说“只执行第 1、3 个”或“取消”。")
else:
lines.append("当前请求没有 chat_id计划未落库需要前端带 chat_id 才能多轮确认执行。")
return "\n".join(lines)
if op == "plan_executed":
plan = data.get("plan") or {}
lines = [
f"已提交 Wiki 同步计划 {plan.get('id') or ''}",
f"- 已提交任务:{data.get('executed_count', 0)}",
f"- 提交失败:{data.get('failed_count', 0)}",
]
jobs = _extract_executed_jobs(data.get("executed") or [])
if jobs:
lines.append("同步正在后台执行,可继续使用系统。当前任务:")
for job in jobs[:8]:
lines.append(f"- job_id: {job.get('id') or job.get('job_id')},进度:{_job_progress_text(job)}")
if len(jobs) > 8:
lines.append(f"- 还有 {len(jobs) - 8} 个后台任务")
lines.append("想看进度时,可以问:查看同步进度 <job_id>")
return "\n".join(lines)
if op == "plan_cancelled":
plan = data.get("plan") or {}
return f"已取消待执行计划 {plan.get('id') or ''}"
return json.dumps(data, ensure_ascii=False, indent=2)
def _format_plan_tail(plan: Dict[str, Any], actions: List[Dict[str, Any]]) -> List[str]:
if not actions:
return ["当前没有需要同步或重建的 Wiki 动作。"]
lines = [f"已为缺失/过期数据生成待确认计划:{plan.get('id') or '未保存'}", f"- 待同步/重建动作:{len(actions)}"]
lines.extend(_format_actions(actions))
if plan:
lines.append("如果要执行,请回复“确认执行”;如果要清理孤儿数据,请明确说“清理孤儿数据”。")
return lines
def _format_actions(actions: List[Dict[str, Any]], max_items: int = 8) -> List[str]:
lines = []
for idx, action in enumerate(actions[:max_items], start=1):
lines.append(f" {idx}. {_action_label(action)}")
if len(actions) > max_items:
lines.append(f" ... 还有 {len(actions) - max_items} 个动作")
return lines
def _action_label(action: Dict[str, Any]) -> str:
action_type = action.get("action", "")
if action_type == "sync_file":
return f"同步文件 {action.get('filename') or action.get('file_id')}"
if action_type == "rebuild_file":
return f"重建文件 {action.get('filename') or action.get('file_id')}"
if action_type == "delete_wiki_file":
return f"删除 Wiki 中的文件数据 {action.get('file_id')}"
if action_type == "delete_wiki_kb":
return f"删除 Wiki 中的知识库数据 {action.get('kb_name') or action.get('kb_id')}"
return json.dumps(action, ensure_ascii=False)
def _extract_executed_jobs(executed: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
jobs: List[Dict[str, Any]] = []
for item in executed:
result = item.get("result") or {}
job = result.get("job") if isinstance(result.get("job"), dict) else {}
if job:
jobs.append(job)
elif result.get("job_id"):
jobs.append({"id": result.get("job_id"), "status": result.get("status") or "running"})
return jobs
def _format_job_progress(job: Dict[str, Any]) -> str:
current = str(job.get("current_filename") or job.get("current_file_id") or "").strip()
lines = [
f"同步任务 {job.get('id') or ''}",
f"- 状态:{job.get('status') or ''}",
f"- 目标:{job.get('target_type') or ''} {job.get('target_id') or ''}",
f"- 当前阶段:{job.get('stage') or '处理中'}",
]
if current:
lines.append(f"- 当前文件:{current}")
lines.extend([
f"- 进度:{_job_progress_text(job)}",
f"- 已处理文件:{job.get('processed_files', 0)} / {job.get('total_files', 0)}",
f"- 已跳过未变化文件:{job.get('skipped_files', 0)}",
f"- 已同步切片:{job.get('total_slices', 0)}",
f"- 已生成/更新页面:{job.get('page_count', 0)}",
f"- 更新时间:{job.get('updated_at') or ''}",
f"- 错误:{job.get('error') or ''}",
])
return "\n".join(lines)
def _job_progress_text(job: Dict[str, Any]) -> str:
total = int(job.get("total_files") or 0)
processed = int(job.get("processed_files") or 0)
status = str(job.get("status") or "")
if total > 0:
percent = min(100.0, max(0.0, processed * 100.0 / total))
return f"{processed}/{total}{percent:.0f}%),状态:{status}"
return f"等待统计文件数,状态:{status}"
def _response(text: str, result: Optional[Dict[str, Any]] = None, error: str = "") -> Dict[str, Any]:
return {
"response": text,
"actions": [],
"result_tag": "wiki_admin",
"suggestedReplies": [
{"title": "检查同步情况", "content": "检查知识库 163舰 的 Wiki 同步情况"},
{"title": "确认执行", "content": "确认执行"},
],
"route_flag": "wiki_admin",
"wiki_admin_result": result or {},
"error_message": error,
}
def _is_confirmed(query: str) -> bool:
return any(word in query for word in CONFIRM_WORDS)
def _is_cancel(query: str) -> bool:
return any(word in query for word in CANCEL_WORDS)
def _contains_new_target(query: str) -> bool:
return any(word in query for word in ("知识库", "文件", "file_id", "kb_id"))
def _extract_selection_indexes(query: str) -> Optional[List[int]]:
if not any(word in query for word in ("", "", "选择", "操作", "执行")):
return None
nums = [int(x) for x in re.findall(r"\d+", query)]
return nums or None
def _extract_job_id(query: str) -> Optional[str]:
match = re.search(r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b", query or "")
return match.group(0) if match else None
def _extract_id(query: str, labels: tuple[str, ...]) -> Optional[str]:
for label in labels:
patterns = [
rf"{re.escape(label)}\s*[:]?\s*([A-Za-z0-9_\-]+)",
rf"([A-Za-z0-9_\-]+)\s*号?{re.escape(label)}",
]
for pattern in patterns:
match = re.search(pattern, query, flags=re.IGNORECASE)
if match:
return match.group(1)
return None
def _extract_kb_id(query: str) -> Optional[str]:
patterns = [
r"(?:kb_id|kb|知识库\s*(?:id|ID|编号))\s*[:=]?\s*([A-Za-z0-9_\-]+)",
r"知识库\s*[:]?\s*(\d+)(?![A-Za-z0-9_\-])",
r"(\d+)\s*号?\s*知识库",
]
for pattern in patterns:
match = re.search(pattern, query, flags=re.IGNORECASE)
if match:
return match.group(1)
return None
async def _resolve_kb_target(query: str, kb_id: Optional[str]) -> Dict[str, Any]:
if kb_id:
return {"success": True, "kb_id": kb_id, "kb_name": "", "kb_path": ""}
resolved = await service.resolve_kb_reference(query)
if resolved.get("success"):
return resolved
return {
"success": False,
"message": resolved.get("error") or "请说清楚知识库名称。",
"matches": resolved.get("matches") or resolved.get("candidates") or [],
}
def _extract_limit(query: str) -> Optional[int]:
patterns = [r"\s*(\d+)\s*个", r"limit\s*[:=]\s*(\d+)", r"最多\s*(\d+)\s*个"]
for pattern in patterns:
match = re.search(pattern, query, flags=re.IGNORECASE)
if match:
return max(1, min(int(match.group(1)), 1000))
return None