feat: add 49088 wiki service snapshot
This commit is contained in:
parent
e242d9caa8
commit
209b699640
9
.env.example
Normal file
9
.env.example
Normal 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
2
.gitignore
vendored
@ -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
|
||||||
|
|||||||
24
README.md
24
README.md
@ -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
39
admin_routes.py
Normal 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
11
app.py
@ -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 已配置")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
57
config.py
57
config.py
@ -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
28
docker-compose.yml
Normal 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
|
||||||
@ -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"]
|
||||||
|
|||||||
81
prompts.py
81
prompts.py
@ -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"
|
||||||
"请给出回复:"
|
"请给出回复:"
|
||||||
),
|
),
|
||||||
|
|||||||
86
tools/agent_registration.py
Normal file
86
tools/agent_registration.py
Normal 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,
|
||||||
|
}
|
||||||
106
tools/conversation_cleanup.py
Normal file
106
tools/conversation_cleanup.py
Normal 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},
|
||||||
|
)
|
||||||
@ -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
139
tools/wiki_tools.py
Normal 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
6
wiki_engine/__init__.py
Normal 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.
|
||||||
|
"""
|
||||||
100
wiki_engine/chunk_enhancer.py
Normal file
100
wiki_engine/chunk_enhancer.py
Normal 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
1798
wiki_engine/db.py
Normal file
File diff suppressed because it is too large
Load Diff
132
wiki_engine/htknow_client.py
Normal file
132
wiki_engine/htknow_client.py
Normal 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
230
wiki_engine/routes.py
Normal 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
967
wiki_engine/service.py
Normal 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
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
138
wiki_engine/worker.py
Normal 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))
|
||||||
@ -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
@ -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 code、pre、script、style
|
|
||||||
- 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:
|
|||||||
- equation、align、gather 等 LaTeX 环境
|
- equation、align、gather 等 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):
|
||||||
|
|||||||
493
workflows/workflow_wiki_admin.py
Normal file
493
workflows/workflow_wiki_admin.py
Normal 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
|
||||||
Loading…
x
Reference in New Issue
Block a user