Compare commits

..

No commits in common. "46-wiki" and "main" have entirely different histories.

77 changed files with 13359 additions and 9336 deletions

1
.gitattributes vendored Normal file
View File

@ -0,0 +1 @@
docker-images/*.tar.gz filter=lfs diff=lfs merge=lfs -text

34
.gitignore vendored
View File

@ -1,32 +1,16 @@
# Python caches
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
.pytest_cache/
.cache/
# Virtual environments
.venv/
venv/
env/
# Local env files
.env
.env.*
!.env.example
# Runtime data and generated outputs
data/*.db
data/*.db-*
created_pdf/
created_word/
image_output/
# Logs and temporary files
*.log *.log
*.tmp *.tmp
*.swp *.swp
.pytest_cache/
# OS/editor files
.DS_Store .DS_Store
Thumbs.db .nfs*
.idea/
.vscode/ created_pdf/
created_word/
image_output/
audio/
data/

BIN
Python-3.12.12.tgz Normal file

Binary file not shown.

View File

@ -0,0 +1,38 @@
# WX Agent 镜像和代码备份
来源设备:`192.168.1.108`
原始代码路径:`/home/workspace/wxagent`
镜像名称:`wx-agent:latest`
## 主要内容
- `app.py`Agent 服务入口
- `main_agent.py`Agent 主流程
- `config.py`:当前配置文件
- `requirements.txt`Python 依赖
- `docker-images/wx_agent_latest.docker-save.tar.gz``wx-agent:latest` 镜像备份
## 已清理内容
本仓库未上传生成文件、音频/图片输出、运行数据、缓存,以及 `.bak` 或带日期后缀的历史备份文件。
## 大文件下载
本仓库使用 Git LFS 保存 Docker 镜像包。
克隆仓库后执行:
```bash
git lfs install
git lfs pull
```
如果镜像文件内容是 `version https://git-lfs.github.com/spec/v1`,说明当前只是 LFS 指针文件,还需要执行 `git lfs pull` 下载真实文件。
## 恢复镜像
```bash
gunzip -c docker-images/wx_agent_latest.docker-save.tar.gz | docker load
```

View File

@ -1,39 +0,0 @@
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,
)

50
app.py
View File

@ -31,13 +31,10 @@ from workflow_registry import WORKFLOW_CONFIG, VALID_ROUTE_FLAGS
from main_agent import extract_conversation_title from main_agent import extract_conversation_title
from workflows.history_manager import filter_image_urls, preprocess_from_request from workflows.history_manager import filter_image_urls, preprocess_from_request
from config import set_request_user_config from config import set_request_user_config
from utils.source_citations import merge_rag_source_citations
from checkpointer_config import ( 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)
@ -56,9 +53,6 @@ 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")
@ -69,12 +63,6 @@ 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 已配置")
@ -474,7 +462,7 @@ async def stream_main_agent_execution(
agent_task = asyncio.create_task(run_agent()) agent_task = asyncio.create_task(run_agent())
execution_complete_received = False execution_complete_received = False
rag_result = {} rag_result = []
graph_result = [] graph_result = []
while True: while True:
try: try:
@ -494,7 +482,16 @@ async def stream_main_agent_execution(
if title == "知识库搜索工具" and result and isinstance(result, dict): if title == "知识库搜索工具" and result and isinstance(result, dict):
print("===============================", result) print("===============================", result)
rag_result_raw = result.get("sourceCitation", {}) rag_result_raw = result.get("sourceCitation", {})
merge_rag_source_citations(rag_result, rag_result_raw) rag_result = {}
for doc_name, chunks in rag_result_raw.items():
if isinstance(chunks, list):
rag_result[doc_name] = [
{k: v for k, v in item.items() if k != "text"}
if isinstance(item, dict) else item
for item in chunks
]
else:
rag_result[doc_name] = chunks
elif title == "图谱检索工具" and result and isinstance(result, dict): elif title == "图谱检索工具" and result and isinstance(result, dict):
print("===============================", result) print("===============================", result)
graph_result = result.get("xxxx.graph", []) graph_result = result.get("xxxx.graph", [])
@ -650,25 +647,12 @@ async def download_file(file_name: str):
Returns: Returns:
文件下载响应 文件下载响应
""" """
safe_name = os.path.basename(file_name) pdf_path = os.path.join(PDF_DIR, file_name)
if safe_name != file_name or safe_name in ("", ".", ".."): word_path = os.path.join(WORD_DIR, file_name)
raise HTTPException(status_code=400, detail="Invalid file name") file_path = pdf_path if os.path.exists(pdf_path) else word_path
pdf_path = os.path.abspath(os.path.join(PDF_DIR, safe_name))
word_path = os.path.abspath(os.path.join(WORD_DIR, safe_name))
allowed_dirs = (os.path.abspath(PDF_DIR), os.path.abspath(WORD_DIR))
candidates = []
for candidate in (pdf_path, word_path):
if any(os.path.commonpath([candidate, allowed_dir]) == allowed_dir for allowed_dir in allowed_dirs):
candidates.append(candidate)
file_path = next((path for path in candidates if os.path.isfile(path)), None)
if not file_path:
raise HTTPException(status_code=404, detail="File not found")
print(f"下载文件:{file_path}") print(f"下载文件:{file_path}")
# 检查文件是否存在 # 检查文件是否存在
ext = os.path.splitext(file_name)[1].lower() ext = os.path.splitext(file_name)[1].lower()
ext = os.path.splitext(safe_name)[1].lower()
media_type = { media_type = {
".pdf": "application/pdf", ".pdf": "application/pdf",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
@ -678,9 +662,8 @@ async def download_file(file_name: str):
# 返回文件 # 返回文件
return FileResponse( return FileResponse(
path=file_path, path=file_path,
filename=safe_name, filename=file_name,
media_type=media_type, media_type=media_type
headers={"X-Content-Type-Options": "nosniff"}
) )
@ -1021,4 +1004,3 @@ if __name__ == "__main__":

1010
app63.py Normal file

File diff suppressed because it is too large Load Diff

1012
app8964.py Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

493
checkpointer_config521.py Normal file
View File

@ -0,0 +1,493 @@
"""
LangGraph Checkpointer 配置模块
支持多种持久化后端
1. MemorySaver - 内存存储仅用于测试
2. PostgresSaver - PostgreSQL 数据库生产环境
使用 message_id chat_id 作为 thread_id 来恢复工作流状态
"""
import os
import asyncio
from typing import Optional
from langgraph.checkpoint.memory import MemorySaver
from config import POSTGRES_CONNECTION_STRING
CHECKPOINTER_TYPE = "postgres"
def get_thread_id(chat_id: str, message_id: str) -> str:
"""
生成唯一的 thread_id
Args:
chat_id: 聊天会话 ID
message_id: 消息 ID
Returns:
组合后的唯一 thread_id
"""
return f"{chat_id}:{message_id}"
def parse_thread_id(thread_id: str) -> tuple[str, str]:
"""
解析 thread_id 获取 chat_id message_id
Args:
thread_id: 组合的 thread_id
Returns:
(chat_id, message_id) 元组
"""
parts = thread_id.split(":", 1)
if len(parts) == 2:
return parts[0], parts[1]
return parts[0], ""
async def verify_checkpointer_tables_exist() -> bool:
"""
验证 checkpointer 所需的数据库表是否存在且结构正确
Returns:
True 如果所有必需的表都存在且结构正确否则 False
"""
try:
from psycopg_pool import AsyncConnectionPool
except ImportError:
return False
try:
async with AsyncConnectionPool(
POSTGRES_CONNECTION_STRING,
kwargs={"autocommit": True}
) as pool:
async with pool.connection() as conn:
async with conn.cursor() as cur:
# 检查 checkpoints 表
await cur.execute("""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'checkpoints'
)
""")
checkpoints_exists = (await cur.fetchone())[0]
# 检查 checkpoint_writes 表
await cur.execute("""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'checkpoint_writes'
)
""")
writes_exists = (await cur.fetchone())[0]
# 检查 checkpoint_blobs 表
await cur.execute("""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'checkpoint_blobs'
)
""")
blobs_exists = (await cur.fetchone())[0]
if not (checkpoints_exists and writes_exists and blobs_exists):
return False
# 检查 checkpoints 表的 checkpoint 字段是否为 JSONB 类型
await cur.execute("""
SELECT data_type
FROM information_schema.columns
WHERE table_name = 'checkpoints'
AND column_name = 'checkpoint'
""")
result = await cur.fetchone()
if not result or result[0] != 'jsonb':
print(f"[Checkpointer] checkpoints.checkpoint column type is {result[0] if result else 'NULL'}, expected 'jsonb'")
return False
# 检查 checkpoint_writes 表是否有 task_path 列
await cur.execute("""
SELECT EXISTS (
SELECT FROM information_schema.columns
WHERE table_name = 'checkpoint_writes'
AND column_name = 'task_path'
)
""")
task_path_exists = (await cur.fetchone())[0]
if not task_path_exists:
print(f"[Checkpointer] checkpoint_writes.task_path column does not exist")
return False
# 检查 checkpoint_writes 表的主键是否正确
await cur.execute("""
SELECT COUNT(*)
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name
WHERE tc.table_name = 'checkpoint_writes'
AND tc.constraint_type = 'PRIMARY KEY'
""")
pk_count = (await cur.fetchone())[0]
if pk_count != 5: # 应该有 5 个主键列
print(f"[Checkpointer] checkpoint_writes has {pk_count} primary key columns, expected 5")
return False
return True
except Exception as e:
print(f"[Checkpointer] Error verifying tables: {e}")
return False
async def init_checkpointer_db():
"""
初始化 PostgreSQL checkpointer 数据库表
使用 AsyncPostgresSaver setup 方法创建正确的表结构
"""
if CHECKPOINTER_TYPE != "postgres":
return
try:
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
from psycopg_pool import AsyncConnectionPool
import psycopg
except ImportError as e:
print(f"[Checkpointer] ERROR: Missing required package: {e}")
print(f"[Checkpointer] Please install: pip install langgraph-checkpoint-postgres psycopg-pool")
raise ImportError(
"langgraph-checkpoint-postgres or psycopg-pool not installed. "
"Run: pip install langgraph-checkpoint-postgres psycopg-pool"
) from e
# 首先检查表是否已存在
tables_exist = await verify_checkpointer_tables_exist()
if tables_exist:
print(f"[Checkpointer] Database tables already exist, skipping initialization")
return
print(f"[Checkpointer] Initializing PostgreSQL database tables...")
# 尝试使用 AsyncPostgresSaver 的 setup 方法
try:
async with AsyncConnectionPool(
POSTGRES_CONNECTION_STRING,
kwargs={"autocommit": True}
) as pool:
checkpointer = AsyncPostgresSaver(pool)
await checkpointer.setup()
print(f"[Checkpointer] AsyncPostgresSaver.setup() executed")
except Exception as e:
print(f"[Checkpointer] AsyncPostgresSaver.setup() failed: {e}")
# 验证表是否真的被创建
tables_exist_after = await verify_checkpointer_tables_exist()
if not tables_exist_after:
print(f"[Checkpointer] Tables not created by setup(), using direct SQL...")
try:
await create_checkpointer_tables_directly()
except Exception as e2:
print(f"[Checkpointer] Direct SQL initialization also failed: {e2}")
raise
else:
print(f"[Checkpointer] PostgreSQL database tables verified successfully")
async def create_checkpointer_tables_directly():
"""
直接通过 SQL 语句创建 checkpointer 所需的数据库表
这是备用方案 AsyncPostgresSaver.setup() 失败时使用
注意LangGraph AsyncPostgresSaver 使用 JSONB 类型存储数据
表名必须是: checkpoints, checkpoint_writes, checkpoint_blobs
"""
try:
from psycopg_pool import AsyncConnectionPool
except ImportError as e:
print(f"[Checkpointer] ERROR: Missing psycopg-pool: {e}")
raise
print(f"[Checkpointer] Creating checkpointer tables using direct SQL...")
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("DROP TABLE IF EXISTS checkpoints CASCADE")
await cur.execute("DROP TABLE IF EXISTS writes CASCADE")
await cur.execute("DROP TABLE IF EXISTS checkpoint_blobs CASCADE")
await cur.execute("DROP TABLE IF EXISTS checkpoint_writes CASCADE")
await cur.execute("DROP TABLE IF EXISTS blobs CASCADE")
await cur.execute("DROP TABLE IF EXISTS checkpoint_migrations CASCADE")
print(f"[Checkpointer] Dropped existing tables")
# 创建 checkpoint_migrations 表(用于跟踪迁移版本)
await cur.execute("""
CREATE TABLE IF NOT EXISTS checkpoint_migrations (
v INTEGER PRIMARY KEY
)
""")
# 创建 checkpoints 表 - 使用 JSONB 类型
await cur.execute("""
CREATE TABLE IF NOT EXISTS checkpoints (
thread_id TEXT NOT NULL,
checkpoint_ns TEXT NOT NULL DEFAULT '',
checkpoint_id TEXT NOT NULL,
parent_checkpoint_id TEXT,
type TEXT,
checkpoint JSONB NOT NULL DEFAULT '{}',
metadata JSONB NOT NULL DEFAULT '{}',
PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id)
)
""")
# 创建 checkpoint_writes 表 - 使用 BYTEA 类型
await cur.execute("""
CREATE TABLE IF NOT EXISTS checkpoint_writes (
thread_id TEXT NOT NULL,
checkpoint_ns TEXT NOT NULL DEFAULT '',
checkpoint_id TEXT NOT NULL,
task_id TEXT NOT NULL,
task_path TEXT NOT NULL DEFAULT '',
idx INTEGER NOT NULL,
channel TEXT NOT NULL,
type TEXT,
blob BYTEA,
PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id, task_id, idx)
)
""")
# 创建 checkpoint_blobs 表 - 使用 BYTEA 类型存储二进制数据
await cur.execute("""
CREATE TABLE IF NOT EXISTS checkpoint_blobs (
thread_id TEXT NOT NULL,
checkpoint_ns TEXT NOT NULL DEFAULT '',
channel TEXT NOT NULL,
version TEXT NOT NULL,
type TEXT NOT NULL,
blob BYTEA,
PRIMARY KEY (thread_id, checkpoint_ns, channel, version)
)
""")
# 创建索引以提高查询性能
await cur.execute("""
CREATE INDEX IF NOT EXISTS checkpoints_thread_id_idx ON checkpoints(thread_id)
""")
await cur.execute("""
CREATE INDEX IF NOT EXISTS checkpoint_writes_thread_id_idx ON checkpoint_writes(thread_id)
""")
await cur.execute("""
CREATE INDEX IF NOT EXISTS checkpoint_blobs_thread_id_idx ON checkpoint_blobs(thread_id)
""")
# 插入迁移版本记录
await cur.execute("""
INSERT INTO checkpoint_migrations (v) VALUES (1) ON CONFLICT DO NOTHING
""")
print(f"[Checkpointer] Checkpointer tables created successfully via direct SQL")
def create_checkpointer(checkpointer_type: str = None):
"""
创建同步 checkpointer 实例
Args:
checkpointer_type: checkpointer 类型可选 "memory", "postgres"
Returns:
checkpointer 实例
"""
checkpointer_type = checkpointer_type or CHECKPOINTER_TYPE
if checkpointer_type == "memory":
return MemorySaver()
elif checkpointer_type == "postgres":
try:
from langgraph.checkpoint.postgres import PostgresSaver
from psycopg import Connection
if not POSTGRES_CONNECTION_STRING:
raise ValueError("POSTGRES_CONNECTION_STRING 环境变量未设置")
conn = Connection.connect(POSTGRES_CONNECTION_STRING)
return PostgresSaver(conn)
except ImportError:
print("[WARNING] psycopg 或 langgraph.checkpoint.postgres 未安装,回退到 memory")
return MemorySaver()
else:
print(f"[WARNING] 未知的 checkpointer 类型: {checkpointer_type},使用 memory")
return MemorySaver()
def create_async_checkpointer(checkpointer_type: str = None):
"""
创建异步 checkpointer 实例
Args:
checkpointer_type: checkpointer 类型
Returns:
异步 checkpointer 实例
"""
checkpointer_type = checkpointer_type or CHECKPOINTER_TYPE
print(f"[Checkpointer] 创建异步 checkpointer, 类型配置: {checkpointer_type}")
if checkpointer_type == "memory":
print("[Checkpointer] 使用 MemorySaver")
return MemorySaver()
elif checkpointer_type == "postgres":
try:
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
from psycopg_pool import AsyncConnectionPool
if not POSTGRES_CONNECTION_STRING:
raise ValueError("POSTGRES_CONNECTION_STRING 环境变量未设置")
pool = AsyncConnectionPool(POSTGRES_CONNECTION_STRING)
return AsyncPostgresSaver(pool)
except ImportError:
print("[WARNING] psycopg_pool 或 langgraph.checkpoint.postgres 未安装,回退到 memory")
return MemorySaver()
else:
print(f"[WARNING] 未知的 checkpointer 类型: {checkpointer_type},使用 memory")
return MemorySaver()
async def get_or_create_async_checkpointer(checkpointer_type: str = None):
"""
获取或创建异步 checkpointer 实例正确处理上下文管理器
Args:
checkpointer_type: checkpointer 类型
Returns:
已初始化的异步 checkpointer 实例
"""
checkpointer = create_async_checkpointer(checkpointer_type)
if hasattr(checkpointer, '__aenter__'):
checkpointer = await checkpointer.__aenter__()
return checkpointer
class CheckpointerManager:
"""
Checkpointer 管理器 - 单例模式
用于管理多个工作流的 checkpointer 实例
"""
_instance = None
_sync_checkpointer = None
_async_conn_pool = None
_async_checkpointer = None
_initialized = False
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def get_checkpointer(self):
"""获取同步 checkpointer 实例"""
if self._sync_checkpointer is None:
self._sync_checkpointer = create_checkpointer()
return self._sync_checkpointer
async def get_async_checkpointer(self):
"""
获取异步 checkpointer 实例
"""
if self._async_checkpointer is None:
if CHECKPOINTER_TYPE == "postgres":
try:
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
from psycopg_pool import AsyncConnectionPool
if not POSTGRES_CONNECTION_STRING:
raise ValueError("POSTGRES_CONNECTION_STRING not set")
self._async_conn_pool = AsyncConnectionPool(
POSTGRES_CONNECTION_STRING,
open=False
)
await self._async_conn_pool.open()
self._async_checkpointer = AsyncPostgresSaver(self._async_conn_pool)
# 检查表是否存在
tables_exist = await verify_checkpointer_tables_exist()
if not tables_exist:
# 尝试使用 setup() 方法
try:
await self._async_checkpointer.setup()
print(f"[Checkpointer] AsyncPostgresSaver.setup() executed")
except Exception as setup_e:
print(f"[Checkpointer] AsyncPostgresSaver.setup() failed: {setup_e}")
# 再次验证表是否被创建
tables_exist_after = await verify_checkpointer_tables_exist()
if not tables_exist_after:
print(f"[Checkpointer] Tables not created by setup(), using direct SQL...")
await create_checkpointer_tables_directly()
else:
print(f"[Checkpointer] Database tables already exist")
print(f"[Checkpointer] AsyncPostgresSaver created, connection: {POSTGRES_CONNECTION_STRING.split('@')[1] if '@' in POSTGRES_CONNECTION_STRING else POSTGRES_CONNECTION_STRING}")
except ImportError as e:
print(f"[WARNING] psycopg_pool or langgraph.checkpoint.postgres not installed: {e}, fallback to memory")
self._async_checkpointer = MemorySaver()
except Exception as e:
print(f"[Checkpointer] ERROR: Failed to create AsyncPostgresSaver: {e}")
raise
elif CHECKPOINTER_TYPE == "memory":
self._async_checkpointer = MemorySaver()
print("[Checkpointer] Using MemorySaver")
else:
self._async_checkpointer = MemorySaver()
print(f"[Checkpointer] Unknown type {CHECKPOINTER_TYPE}, using MemorySaver")
print(f"[Checkpointer] Async checkpointer type: {type(self._async_checkpointer).__name__}")
return self._async_checkpointer
async def setup(self):
"""
初始化 checkpointer创建数据库表等
"""
if not self._initialized:
await init_checkpointer_db()
try:
from tools.fault_record_db import init_fault_records_table
await init_fault_records_table()
except Exception as e:
print(f"[Checkpointer] 初始化 fault_records 表失败: {e}")
self._initialized = True
def reset(self):
"""重置 checkpointer主要用于测试"""
self._sync_checkpointer = None
self._async_checkpointer = None
self._async_conn_pool = None
self._initialized = False
checkpointer_manager = CheckpointerManager()
if __name__ == "__main__":
print("Checkpointer 配置测试:")
print(f" 类型: {CHECKPOINTER_TYPE}")
print(f" PostgreSQL 连接: {'已配置' if POSTGRES_CONNECTION_STRING else '未配置'}")

0
config Normal file
View File

108
config.py
View File

@ -12,22 +12,23 @@ load_dotenv()
# ==================== 大模型配置 ==================== # ==================== 大模型配置 ====================
LLM_CONFIG = { LLM_CONFIG = {
# "model": "Qwen3.5-35B-A3B",
"model": "46-qwen3.5-35B", "model": "46-qwen3.5-35B",
# "model": "Qwen3-32B",
# "model": "Qwen3-14B", # "model": "Qwen3-14B",
"base_url": "http://192.168.0.46:59800/v1", #"base_url": "http://192.168.1.164:9800/v1",
"base_url":"https://openai.zkzdht.com/v1",
"api_key": "gpustack_dee9ca823290886c_5edfc86aeeeceb1e9ee5162941cb2cb5", "api_key": "gpustack_dee9ca823290886c_5edfc86aeeeceb1e9ee5162941cb2cb5",
"temperature": float("0.1"), "temperature": float("0.1"),
"max_tokens": int("9216"), "max_tokens": int("20096"),
"timeout": int("30") "timeout": int("30")
} }
# ==================== 嵌入模型配置 ==================== # ==================== 嵌入模型配置 ====================
EMBEDDING_CONFIG = { EMBEDDING_CONFIG = {
"model": "bge-m3", "model": "bge-m3",
"base_url": "http://192.168.0.46:59700/v1/embeddings", "base_url": "http://embedding:9700/v1/embeddings",
"api_key": "gpustack_dee9ca823290886c_5edfc86aeeeceb1e9ee5162941cb2cb5", "api_key": "gpustack_dee9ca823290886c_5edfc86aeeeceb1e9ee5162941cb2cb5",
"base_url_v2" : "http://192.168.0.46:59700/v1", "base_url_v2" : "http://embedding:9700/v1/embeddings",
} }
@ -37,7 +38,7 @@ _request_user_config: ContextVar[Optional[Dict[str, str]]] = ContextVar('request
# RAG配置的默认值 # RAG配置的默认值
_RAG_CONFIG_DEFAULT = { _RAG_CONFIG_DEFAULT = {
"search_endpoint": "http://192.168.0.46:11000/api/v1/knowledge/search/", "search_endpoint": "http://192.168.1.108:11000/api/v1/knowledge/search/",
"x-user-id": "1", "x-user-id": "1",
"x-user-name": "testuser", "x-user-name": "testuser",
"x-role": "admin", "x-role": "admin",
@ -90,63 +91,6 @@ 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,
@ -178,15 +122,15 @@ def set_request_user_config(x_user_id: Optional[str] = None,
# ==================== 图谱检索服务配置 ==================== # ==================== 图谱检索服务配置 ====================
GRAPH_SEARCH_CONFIG = { GRAPH_SEARCH_CONFIG = {
"search_endpoint": "http://192.168.0.46:59085/search_graph", "search_endpoint": "http://kgrag:9085/search_graph",
"graph_timeout": float("30.0"), "graph_timeout": float("30.0"),
"default_top_k": int("10"), "default_top_k": int("10"),
"operation_search_endpoint": "http://192.168.0.46:59085/operation_graph_search" "operation_search_endpoint": "http://kgrag:9085/operation_graph_search"
} }
# ==================== 设备到系统检索服务配置 ==================== # ==================== 设备到系统检索服务配置 ====================
DEVICE_SYSTEM_CONFIG = { DEVICE_SYSTEM_CONFIG = {
"endpoint": "http://192.168.0.46:59085/device_to_system", "endpoint": "http://kgrag:9085/device_to_system",
"timeout": float("30.0"), "timeout": float("30.0"),
"default_min_hops": int("2"), "default_min_hops": int("2"),
"default_max_hops": int("8"), "default_max_hops": int("8"),
@ -195,7 +139,7 @@ DEVICE_SYSTEM_CONFIG = {
# ==================== 图册检索服务配置 ==================== # ==================== 图册检索服务配置 ====================
ATLAS_CONFIG = { ATLAS_CONFIG = {
"endpoint": "http://192.168.0.46:59085/atlas_retrieval", "endpoint": "http://kgrag:9085/atlas_retrieval",
"timeout": float("30.0"), "timeout": float("30.0"),
"default_top_k": int("10") "default_top_k": int("10")
} }
@ -209,14 +153,15 @@ WORKFLOW_CONFIG = {
# ==================== 服务器配置 ==================== # ==================== 服务器配置 ====================
SERVER_CONFIG = { SERVER_CONFIG = {
"host": "192.168.0.46", "host": "agent",
"port": "59088", "port": "9088",
"base_url": "http://192.168.0.46:59088" "base_url": "http://agent:9088",
"report" : "http://192.168.1.164:9088"
} }
# ==================== ASR服务配置 ==================== # ==================== ASR服务配置 ====================
ASR_CONFIG = { ASR_CONFIG = {
"api_base": "http://192.168.0.46:59900/v1", "api_base": "http://asr:9900/v1",
"model": "base", "model": "base",
"language": "zh", "language": "zh",
"timeout": int("30") "timeout": int("30")
@ -224,33 +169,33 @@ ASR_CONFIG = {
# ==================== VLM服务配置 ==================== # ==================== VLM服务配置 ====================
VLM_CONFIG = { VLM_CONFIG = {
"api_base" : "http://192.168.0.46:59801/v1", "api_base" : "http://kgrag:9085/v1",
"model" : "Qwen3-VL-8B" "model" : "Qwen3-VL-8B"
} }
# ==================== Neo4J配置 ==================== # ==================== Neo4J配置 ====================
NEO4J_CONFIG = { NEO4J_CONFIG = {
"uri": "neo4j://192.168.0.46:57687", "uri": "neo4j://neo4j:7687",
"user": "neo4j", "user": "neo4j",
"password": "zdht123@" "password": "zdht123"
} }
# ==================== 知识库匹配配置 ==================== # ==================== 知识库匹配配置 ====================
KB_TREE_CONFIG = { KB_TREE_CONFIG = {
"url": "http://192.168.0.46:11000/api/v1/knowledge/knowledge_base/tree" "url": "http://htknow:8080/api/v1/knowledge/knowledge_base/tree"
} }
# ==================== 维修反馈统计配置 ==================== # ==================== 维修反馈统计配置 ====================
REPAIR_FEEDBACK_CONFIG = { REPAIR_FEEDBACK_CONFIG = {
"url": "http://192.168.0.46:22000", "url": "http://webui:22000",
"email": "15088888888@163.com", "email": "15088888888@163.com",
"password": "11223344" "password": "11223344"
} }
# ==================== 数据库配置 ==================== # ==================== 数据库配置 ====================
POSTGRES_CONNECTION_STRING = "postgresql://postgres:password@192.168.0.46:5432/an_webui" POSTGRES_CONNECTION_STRING = "postgresql://postgres:zdht123@postgres:5432/hj_webui"
#POSTGRES_CONNECTION_STRING = "postgresql+psycopg://postgres:password@192.168.0.46:5432/an_webui" #POSTGRES_CONNECTION_STRING = "postgresql+psycopg://postgres:zdht123@postgres:5432/hj_webui"
POSTGRES_SQLALCHEMY_URL = "postgresql+psycopg://postgres:password@192.168.0.46:5432/an_webui" POSTGRES_SQLALCHEMY_URL = "postgresql+psycopg://postgres:zdht123@postgres:5432/hj_webui"
# ==================== 索引配置 ==================== # ==================== 索引配置 ====================
@ -259,7 +204,7 @@ INDEX_CONFIG = {
"FULLTEXT_PROPERTY" :"fulltext", "FULLTEXT_PROPERTY" :"fulltext",
"EMBEDDING_PROPERTY" : "embedding", "EMBEDDING_PROPERTY" : "embedding",
"SEARCH_LABEL" : "Searchable", "SEARCH_LABEL" : "Searchable",
"VECTOR_INDEX_NAME" : "global_searchable_embedding", "VECTOR_INDEX_NAME" : "global_searchabl_embedding",
"FULLTEXT_INDEX_NAME" : "global_searchable_content_search" "FULLTEXT_INDEX_NAME" : "global_searchable_content_search"
} }
@ -269,11 +214,12 @@ MAP_INS={
"系统" : "system_name", "系统" : "system_name",
"设备" : "device_name", "设备" : "device_name",
"舷号" : "ship_number", "舷号" : "ship_number",
"消耗备件" : "spare_parts"
} }
# ==================== neo4j配置 ==================== # ==================== neo4j配置 ====================
NEO4J_CONFIG = { NEO4J_CONFIG = {
"uri": "neo4j://192.168.0.46:57687", "uri": "neo4j://neo4j:7687",
"user": "neo4j", "user": "neo4j",
"password": "zdht123@" "password": "zdht123@"
} }

223
config.txt Normal file
View File

@ -0,0 +1,223 @@
"""
项目配置文件
从.env文件读取配置
"""
import os
from dotenv import load_dotenv
from contextvars import ContextVar
from typing import Optional, Dict, Any
# 加载.env文件
load_dotenv()
# ==================== 大模型配置 ====================
LLM_CONFIG = {
# "model": "Qwen3.5-35B-A3B",
"model": "Qwen3-32B",
# "model": "Qwen3-14B",
"base_url": "http://llm:9800/v1",
"api_key": "gpustack_dee9ca823290886c_5edfc86aeeeceb1e9ee5162941cb2cb5",
"temperature": float("0.1"),
"max_tokens": int("4096"),
"timeout": int("30")
}
# ==================== 嵌入模型配置 ====================
EMBEDDING_CONFIG = {
"model": "bge-m3",
"base_url": "http://embedding:9700/v1/embeddings",
"api_key": "gpustack_dee9ca823290886c_5edfc86aeeeceb1e9ee5162941cb2cb5",
"base_url_v2" : "http://embedding:9700/v1",
}
# ==================== 线程安全的请求上下文配置 ====================
# 使用 contextvars 存储每个请求的用户配置,确保多用户并发时不会相互干扰
_request_user_config: ContextVar[Optional[Dict[str, str]]] = ContextVar('request_user_config', default=None)
# RAG配置的默认值
_RAG_CONFIG_DEFAULT = {
"search_endpoint": "http://htknow:8080/api/v1/knowledge/search/",
"x-user-id": "1",
"x-user-name": "testuser",
"x-role": "admin",
"kb-id": None,
"file-id": None
}
class DynamicRAGConfig(dict):
"""
动态RAG配置类支持线程安全的请求级配置
当访问用户相关配置时,自动从当前请求上下文读取
其他配置项直接返回默认值
兼容字典的所有操作方式(如 get(), [], in 等)
"""
def __getitem__(self, key: str):
# 如果是用户相关配置或知识库参数,从当前请求上下文读取
if key in ["x-user-id", "x-user-name", "x-role", "kb-id", "file-id"]:
request_config = _request_user_config.get()
if request_config and key in request_config:
return request_config[key]
# 如果没有设置,返回默认值
return _RAG_CONFIG_DEFAULT[key]
# 其他配置项直接返回默认值
return _RAG_CONFIG_DEFAULT.get(key)
def get(self, key: str, default=None):
"""支持 get() 方法"""
try:
return self[key]
except KeyError:
return default
def __contains__(self, key: str):
"""支持 in 操作符"""
return key in _RAG_CONFIG_DEFAULT
def keys(self):
"""返回所有键"""
return _RAG_CONFIG_DEFAULT.keys()
def values(self):
"""返回所有值(动态读取用户配置)"""
return [self[key] for key in _RAG_CONFIG_DEFAULT.keys()]
def items(self):
"""返回所有键值对(动态读取用户配置)"""
return [(key, self[key]) for key in _RAG_CONFIG_DEFAULT.keys()]
# ==================== RAG服务配置 ====================
# 使用动态配置类,支持多用户并发时自动读取各自的配置
RAG_CONFIG = DynamicRAGConfig()
def set_request_user_config(x_user_id: Optional[str] = None,
x_user_name: Optional[str] = None,
x_role: Optional[str] = None,
text_kb_id: Optional[str] = None,
text_file_id: Optional[str] = None):
"""
设置当前请求的用户配置和知识库参数(线程安全)
Args:
x_user_id: 用户ID
x_user_name: 用户名称
x_role: 角色
text_kb_id: 文本检索知识库ID
text_file_id: 文本检索文件ID
"""
config = {}
if x_user_id is not None:
config["x-user-id"] = x_user_id
if x_user_name is not None:
config["x-user-name"] = x_user_name
if x_role is not None:
config["x-role"] = x_role
# 仅当非空字符串时才设置,空字符串时不传入 RAG 接口
if text_kb_id is not None and str(text_kb_id).strip():
config["kb-id"] = str(text_kb_id).strip()
if text_file_id is not None and str(text_file_id).strip():
config["file-id"] = str(text_file_id).strip()
_request_user_config.set(config if config else None)
# ==================== 图谱检索服务配置 ====================
GRAPH_SEARCH_CONFIG = {
"search_endpoint": "http://kgrag:9085/search_graph",
"graph_timeout": float("30.0"),
"default_top_k": int("10"),
"operation_search_endpoint": "http://kgrag:9085/operation_graph_search"
}
# ==================== 设备到系统检索服务配置 ====================
DEVICE_SYSTEM_CONFIG = {
"endpoint": "http://kgrag:9085/device_to_system",
"timeout": float("30.0"),
"default_min_hops": int("2"),
"default_max_hops": int("8"),
"default_top_k": int("10")
}
# ==================== 图册检索服务配置 ====================
ATLAS_CONFIG = {
"endpoint": "http://kgrag:9085/atlas_retrieval",
"timeout": float("30.0"),
"default_top_k": int("10")
}
# ==================== 工作流配置 ====================
WORKFLOW_CONFIG = {
"max_retry_count": int("2"), # 最大重试次数
"image_clarity_threshold": float("0.7"), # 图片清晰度阈值
"default_timeout": int("30") # 默认超时时间(秒)
}
# ==================== 服务器配置 ====================
SERVER_CONFIG = {
"host": "agent",
"port": "59088",
"base_url": "http://agent:9088"
}
# ==================== ASR服务配置 ====================
ASR_CONFIG = {
"api_base": "http://asr:9900/v1",
"model": "base",
"language": "zh",
2"timeout": int("30")
}
# ==================== VLM服务配置 ====================
VLM_CONFIG = {
"api_base" : "http://kgrag:9085/v1",
"model" : "Qwen3-VL-8B"
}
# ==================== Neo4J配置 ====================
NEO4J_CONFIG = {
"uri": "neo4j://neo4j:7687",
"user": "neo4j",
"password": "zdht123@"
}
# ==================== 知识库匹配配置 ====================
KB_TREE_CONFIG = {
"url": "http://htknow:8080/api/v1/knowledge/knowledge_base/tree"
}
# ==================== 维修反馈统计配置 ====================
REPAIR_FEEDBACK_CONFIG = {
"url": "http://webui:22000",
"email": "15088888888@163.com",
"password": "11223344"
}
# ==================== 数据库配置 ====================
POSTGRES_CONNECTION_STRING = "postgresql://postgres:0737d36c55d3614fc0b36d69e75fcea97283cdcebda7fe25@postgres:5432/hj_webui"
#POSTGRES_CONNECTION_STRING = "postgresql+psycopg://postgres:password@postgres:5432/an_webui"
POSTGRES_SQLALCHEMY_URL = "postgresql+psycopg://postgres:password@postgres:5432/an_webui"
# ==================== 索引配置 ====================
INDEX_CONFIG = {
"NAME_PROPERTY" : "名称",
"FULLTEXT_PROPERTY" :"fulltext",
"EMBEDDING_PROPERTY" : "embedding",
"SEARCH_LABEL" : "Searchable",
"VECTOR_INDEX_NAME" : "searchable_vector",
"FULLTEXT_INDEX_NAME" : "global_searchable_content_search"
}
# ==================== sql表格字段与neo4j节点映射 ====================
MAP_INS={
"故障模式" : "fault",
"系统" : "system_name",
"设备" : "device_name",
"舷号" : "ship_number",
}
# ==================== neo4j配置 ====================
NEO4J_CONFIG = {
"uri": "neo4j://neo4j:7687",
"user": "neo4j",
"password": "zdht123@"
}

221
config521.py Normal file
View File

@ -0,0 +1,221 @@
"""
项目配置文件
.env文件读取配置
"""
import os
from dotenv import load_dotenv
from contextvars import ContextVar
from typing import Optional, Dict, Any
# 加载.env文件
load_dotenv()
# ==================== 大模型配置 ====================
LLM_CONFIG = {
"model": "Qwen3.5-35B-A3B",
# "model": "Qwen3-32B",
"base_url": "http://192.168.0.46:59800/v1",
"api_key": "gpustack_dee9ca823290886c_5edfc86aeeeceb1e9ee5162941cb2cb5",
"temperature": float("0.1"),
"max_tokens": int("4096"),
"timeout": int("30")
}
# ==================== 嵌入模型配置 ====================
EMBEDDING_CONFIG = {
"model": "bge-m3",
"base_url": "http://192.168.0.46:59700/v1/embeddings",
"api_key": "gpustack_dee9ca823290886c_5edfc86aeeeceb1e9ee5162941cb2cb5",
}
# ==================== 线程安全的请求上下文配置 ====================
# 使用 contextvars 存储每个请求的用户配置,确保多用户并发时不会相互干扰
_request_user_config: ContextVar[Optional[Dict[str, str]]] = ContextVar('request_user_config', default=None)
# RAG配置的默认值
_RAG_CONFIG_DEFAULT = {
"search_endpoint": "http://192.168.0.46:11000/api/v1/knowledge/search/",
"x-user-id": "1",
"x-user-name": "testuser",
"x-role": "admin",
"kb-id": None,
"file-id": None
}
class DynamicRAGConfig(dict):
"""
动态RAG配置类支持线程安全的请求级配置
当访问用户相关配置时自动从当前请求上下文读取
其他配置项直接返回默认值
兼容字典的所有操作方式 get(), [], in
"""
def __getitem__(self, key: str):
# 如果是用户相关配置或知识库参数,从当前请求上下文读取
if key in ["x-user-id", "x-user-name", "x-role", "kb-id", "file-id"]:
request_config = _request_user_config.get()
if request_config and key in request_config:
return request_config[key]
# 如果没有设置,返回默认值
return _RAG_CONFIG_DEFAULT[key]
# 其他配置项直接返回默认值
return _RAG_CONFIG_DEFAULT.get(key)
def get(self, key: str, default=None):
"""支持 get() 方法"""
try:
return self[key]
except KeyError:
return default
def __contains__(self, key: str):
"""支持 in 操作符"""
return key in _RAG_CONFIG_DEFAULT
def keys(self):
"""返回所有键"""
return _RAG_CONFIG_DEFAULT.keys()
def values(self):
"""返回所有值(动态读取用户配置)"""
return [self[key] for key in _RAG_CONFIG_DEFAULT.keys()]
def items(self):
"""返回所有键值对(动态读取用户配置)"""
return [(key, self[key]) for key in _RAG_CONFIG_DEFAULT.keys()]
# ==================== RAG服务配置 ====================
# 使用动态配置类,支持多用户并发时自动读取各自的配置
RAG_CONFIG = DynamicRAGConfig()
def set_request_user_config(x_user_id: Optional[str] = None,
x_user_name: Optional[str] = None,
x_role: Optional[str] = None,
text_kb_id: Optional[str] = None,
text_file_id: Optional[str] = None):
"""
设置当前请求的用户配置和知识库参数线程安全
Args:
x_user_id: 用户ID
x_user_name: 用户名称
x_role: 角色
text_kb_id: 文本检索知识库ID
text_file_id: 文本检索文件ID
"""
config = {}
if x_user_id is not None:
config["x-user-id"] = x_user_id
if x_user_name is not None:
config["x-user-name"] = x_user_name
if x_role is not None:
config["x-role"] = x_role
# 仅当非空字符串时才设置,空字符串时不传入 RAG 接口
if text_kb_id is not None and str(text_kb_id).strip():
config["kb-id"] = str(text_kb_id).strip()
if text_file_id is not None and str(text_file_id).strip():
config["file-id"] = str(text_file_id).strip()
_request_user_config.set(config if config else None)
# ==================== 图谱检索服务配置 ====================
GRAPH_SEARCH_CONFIG = {
"search_endpoint": "http://192.168.0.46:59085/search_graph",
"graph_timeout": float("30.0"),
"default_top_k": int("10"),
"operation_search_endpoint": "http://192.168.0.46:59085/operation_graph_search"
}
# ==================== 设备到系统检索服务配置 ====================
DEVICE_SYSTEM_CONFIG = {
"endpoint": "http://192.168.0.46:59085/device_to_system",
"timeout": float("30.0"),
"default_min_hops": int("2"),
"default_max_hops": int("8"),
"default_top_k": int("10")
}
# ==================== 图册检索服务配置 ====================
ATLAS_CONFIG = {
"endpoint": "http://192.168.0.46:59085/atlas_retrieval",
"timeout": float("30.0"),
"default_top_k": int("10")
}
# ==================== 工作流配置 ====================
WORKFLOW_CONFIG = {
"max_retry_count": int("2"), # 最大重试次数
"image_clarity_threshold": float("0.7"), # 图片清晰度阈值
"default_timeout": int("30") # 默认超时时间(秒)
}
# ==================== 服务器配置 ====================
SERVER_CONFIG = {
"host": "192.168.0.46",
"port": "59088",
"base_url": "http://192.168.0.46:59088"
}
# ==================== ASR服务配置 ====================
ASR_CONFIG = {
"api_base": "http://192.168.0.46:59900/v1",
"model": "base",
"language": "zh",
"timeout": int("30")
}
# ==================== VLM服务配置 ====================
VLM_CONFIG = {
"api_base" : "http://192.168.0.46:59801/v1",
"model" : "Qwen3-VL-8B"
}
# ==================== 任务类型映射 ====================
TASK_TYPE_MAPPING = {
"fault_diagnosis": "故障诊断",
"repair": "维修",
"statistics": "统计",
"qa": "常规问答",
"report": "报告生成"
}
# ==================== Neo4J配置 ====================
NEO4J_CONFIG = {
"uri": "neo4j://192.168.0.46:57687",
"user": "neo4j",
"password": "zdht123@"
}
# ==================== 知识库匹配配置 ====================
KB_TREE_CONFIG = {
"url": "http://192.168.0.46:11000/api/v1/knowledge/knowledge_base/tree"
}
# ==================== 维修反馈统计配置 ====================
REPAIR_FEEDBACK_CONFIG = {
"url": "http://192.168.0.46:22000",
"email": "15088888888@163.com",
"password": "11223344"
}
# ==================== 舷号-型号映射配置 ====================
# 格式:{型号名称: {"numbers": [舷号列表], "aliases": [别名列表], "names": [舰名列表]}}
SHIP_MODEL_MAPPING = {
"055型驱逐舰": {
"numbers": ["102", "103", "104", "105", "106", "107", "108"],
"aliases": ["055", "055型", "055驱逐舰", "万吨大驱"],
"names": ["拉萨舰", "鞍山舰", "无锡舰", "大连舰", "延安舰", "遵义舰", "咸阳舰"],
},
"052D型驱逐舰": {
"numbers": ["172", "173", "174", "175", "163"],
"aliases": ["052D", "052D型", "052D驱逐舰"],
"names": ["昆明舰", "长沙舰", "合肥舰", "银川舰", "南昌舰"],
},
"054A型护卫舰": {
"numbers": ["529", "530", "547", "568", "570"],
"aliases": ["054A", "054A型", "054A护卫舰"],
"names": ["舟山舰", "徐州舰", "临沂舰", "衡阳舰", "黄山舰"],
},
}
# ==================== 数据库配置 ====================
POSTGRES_CONNECTION_STRING = "postgresql://postgres:password@192.168.0.46:5432/an_webui"

View File

@ -1,28 +0,0 @@
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

BIN
docker-images/wx_agent_latest.docker-save.tar.gz (Stored with Git LFS) Normal file

Binary file not shown.

View File

@ -153,10 +153,10 @@ def route_to_workflow(state: MainAgentState) -> Dict[str, Any]:
""" """
🤖分析意图调用相关智能体 🤖分析意图调用相关智能体
""" """
route_flag = state.get("route_flag", "问题排查") route_flag = state.get("route_flag", "故障排查与修理")
if route_flag not in VALID_ROUTE_FLAGS: if route_flag not in VALID_ROUTE_FLAGS:
route_flag = "问题排查" route_flag = "故障排查与修理"
query_text = state.get("extracted_text", "") query_text = state.get("extracted_text", "")
combined_query = state.get("combined_query", "") or query_text combined_query = state.get("combined_query", "") or query_text

285
main_agent63.py Normal file
View File

@ -0,0 +1,285 @@
"""
主脑Agent - 简化版无意图分类
职责
1. 输入处理图片音频文本提取
2. 直接路由根据外部传入的 route_flag 决定调用哪个工作流
工作流列表来自 workflow_registry
- 故障排查与修理
- 舰船百科
- 操作使用
"""
from typing import TypedDict, Literal, Optional, Dict, Any, List
from langgraph.graph import StateGraph, START, END
from tools.function_tool import (
vlm_image_to_text,
asr_audio_to_text,
detect_input_type
)
from modelsAPI.model_api import OpenaiAPI
from utils.function_tracker import track_function_calls
from workflow_registry import VALID_ROUTE_FLAGS
import json
import re
class MainAgentState(TypedDict):
"""主脑Agent状态"""
raw_input: Dict[str, Any]
has_image: bool
has_audio: bool
has_query: bool
has_file_text: bool
input_type: str
history_message: str
extracted_text: str
image_description: str
asr_text: str
file_text: str
combined_query: str
workflow_result: Optional[Dict[str, Any]]
final_response: str
error_message: str
route_flag: str
route_params: Dict[str, Any]
def detect_input_type_node(state: MainAgentState) -> Dict[str, Any]:
"""检测输入类型"""
raw = state["raw_input"]
result = detect_input_type.invoke({"raw_input": raw})
has_image = result.get("has_image", False)
has_audio = result.get("has_audio", False)
has_query = result.get("has_query", False)
has_file_text = False
file_text_v = raw.get("file_text")
if isinstance(file_text_v, str) and file_text_v.strip():
has_file_text = True
input_type = "mixed"
count = sum([has_image, has_audio, has_query, has_file_text])
if count == 1:
if has_image:
input_type = "image"
elif has_audio:
input_type = "audio"
elif has_query:
input_type = "text"
elif has_file_text:
input_type = "file"
return {
"has_image": has_image,
"has_audio": has_audio,
"has_query": has_query,
"has_file_text": has_file_text,
"input_type": input_type,
}
async def process_multimodal_input(state: MainAgentState) -> Dict[str, Any]:
"""处理多模态输入"""
has_image = state.get("has_image", False)
has_audio = state.get("has_audio", False)
has_query = state.get("has_query", False)
has_file_text = state.get("has_file_text", False)
raw = state["raw_input"]
image_description = ""
asr_text = ""
extracted_text = ""
try:
if has_image:
image_path = raw.get("image", "")
if image_path:
try:
image_description = await vlm_image_to_text.ainvoke({
"image_path": image_path
})
if not image_description or image_description.startswith("VLM 调用失败"):
image_description = ""
except Exception:
image_description = ""
if has_audio:
audio_path = raw.get("audio", "")
if audio_path:
try:
asr_text = await asr_audio_to_text.ainvoke({
"audio_path": audio_path
})
if not asr_text or asr_text.startswith("ASR 调用失败"):
asr_text = ""
except Exception:
asr_text = ""
text_parts = []
if raw.get("query"):
text_parts.append(raw["query"])
if image_description:
text_parts.append(f"[图片描述] {image_description}")
if asr_text:
text_parts.append(f"[语音转文字] {asr_text}")
if raw.get("file_text"):
text_parts.append(f"[文件内容] {raw['file_text']}")
extracted_text = "\n".join(text_parts) if text_parts else ""
combined_query = raw.get("query", "") or extracted_text
return {
"extracted_text": extracted_text,
"image_description": image_description,
"asr_text": asr_text,
"combined_query": combined_query,
"file_text": raw.get("file_text", ""),
}
except Exception as e:
return {
"error_message": f"输入处理失败: {str(e)}",
"extracted_text": "",
}
# @track_function_calls
def route_to_workflow(state: MainAgentState) -> Dict[str, Any]:
"""
🤖分析意图调用相关智能体
"""
route_flag = state.get("route_flag", "故障排查与修理")
if route_flag not in VALID_ROUTE_FLAGS:
route_flag = "故障排查与修理"
query_text = state.get("extracted_text", "")
combined_query = state.get("combined_query", "") or query_text
raw_input = state.get("raw_input", {})
history_message = raw_input.get("history_message", "")
route_params = {
"extracted_text": query_text,
"combined_query": combined_query,
"history_message": history_message,
}
if raw_input.get("file_text"):
route_params["file_text"] = raw_input["file_text"]
print(f"[主脑Agent] 路由到: {route_flag}")
print(f"[主脑Agent] 参数: combined_query={combined_query[:100]}...")
if route_flag == "故障排查与修理":
detail = "正在调用故障排查与修理智能体"
elif route_flag == "舰船百科":
detail = "正在调用舰船百科智能体"
elif route_flag == "操作使用":
detail = "正在调用操作使用智能体"
elif route_flag == "统计":
detail = "正在调用统计智能体"
else:
detail = "正在调用通用智能体"
return {
"route_flag": route_flag,
"route_params": route_params,
"__detail__": detail,
}
def create_main_agent():
"""创建主脑Agent"""
workflow = StateGraph(MainAgentState)
workflow.add_node("detect_input_type", detect_input_type_node)
workflow.add_node("process_input", process_multimodal_input)
workflow.add_node("route", route_to_workflow)
workflow.add_edge(START, "detect_input_type")
workflow.add_edge("detect_input_type", "process_input")
workflow.add_edge("process_input", "route")
workflow.add_edge("route", END)
return workflow.compile()
def filter_image_urls(history_message: str) -> Any:
"""过滤历史消息中的图片URL"""
if not history_message:
return history_message
try:
history_messages = json.loads(history_message) if isinstance(history_message, str) else history_message
except (json.JSONDecodeError, TypeError):
return history_message
if not isinstance(history_messages, list):
return history_messages
filtered_messages = []
for msg in history_messages:
if not isinstance(msg, dict):
filtered_messages.append(msg)
continue
new_msg = msg.copy()
content = msg.get("content")
if isinstance(content, list):
text_parts = []
for content_item in content:
if isinstance(content_item, dict):
if content_item.get("type") == "text":
text_value = content_item.get("text", "")
if text_value:
text_parts.append(text_value)
elif isinstance(content_item, str):
text_parts.append(content_item)
if text_parts:
new_msg["content"] = "".join(text_parts)
filtered_messages.append(new_msg)
else:
filtered_messages.append(new_msg)
return filtered_messages
async def extract_conversation_title(content: str) -> str:
"""提取对话标题"""
try:
prompt = f"请从以下对话内容中提取一个简短的标题不超过20字\n\n{content[:500]}"
response = await OpenaiAPI.open_api_chat_without_thinking(
query=prompt,
model=None,
system_prompt="你是一个标题提取助手,请从对话内容中提取一个简短的标题。",
messages=[]
)
title = response.strip() if response else "新对话"
if len(title) > 30:
title = title[:30] + "..."
return title
except Exception:
return "新对话"
if __name__ == "__main__":
import asyncio
async def test():
print("测试主脑Agent")
agent = create_main_agent()
result = await agent.ainvoke({
"raw_input": {"query": "101舰主机发生异响"},
"route_flag": "故障排查与修理"
})
print(f"结果: {result}")
asyncio.run(test())

0
modelsAPI/model_api Normal file
View File

View File

@ -4,7 +4,7 @@ from typing import List, Union
import os import os
import numpy as np import numpy as np
from openai import OpenAI from openai import OpenAI
from config import LLM_CONFIG, EMBEDDING_CONFIG, RERANK_CONFIG from config import LLM_CONFIG, EMBEDDING_CONFIG
from neo4j_graphrag.embeddings.base import Embedder from neo4j_graphrag.embeddings.base import Embedder
from openai import AsyncOpenAI from openai import AsyncOpenAI
@ -24,25 +24,6 @@ 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(
@ -83,7 +64,7 @@ class OpenaiAPI:
if json_output: if json_output:
kwargs["response_format"] = {"type": "json_object"} kwargs["response_format"] = {"type": "json_object"}
_log_model_messages("model API call", model, message_list) print("model API. history message", 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
@ -131,7 +112,7 @@ class OpenaiAPI:
if json_output: if json_output:
kwargs["response_format"] = {"type": "json_object"} kwargs["response_format"] = {"type": "json_object"}
_log_model_messages("model API stream call", model, message_list) print("model API stream. history message", 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:
@ -243,66 +224,6 @@ 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"]

304
modelsAPI/model_api420.py Normal file
View File

@ -0,0 +1,304 @@
import asyncio
import httpx
from typing import List, Union
import os
import numpy as np
from openai import OpenAI
from config import LLM_CONFIG, EMBEDDING_CONFIG
from openai import AsyncOpenAI
class OpenaiAPI:
@staticmethod
async def open_api_chat_without_thinking(
query: str = None,
model: str = None,
json_output: bool = False,
system_prompt: str = None,
messages: list = None,
enable_thinking: bool = True,
temperature: float = 0.1
) -> str:
if model is None:
model = LLM_CONFIG["model"]
if system_prompt is None:
system_prompt = "我是一个专业的AI助手请用中文回答问题。请直接给出最终答案不要包含任何推理步骤、思考过程、解释性文字或前缀。"
# 构建消息
message_list = [{"role": "system", "content": system_prompt}]
if messages is not None:
message_list.extend(messages)
if query is not None:
message_list.append({"role": "user", "content": query})
client = AsyncOpenAI(
api_key=LLM_CONFIG["api_key"],
base_url=LLM_CONFIG["base_url"],
)
kwargs = {
"model": model,
"messages": message_list,
"temperature": temperature,
"stream": False,
"max_tokens":LLM_CONFIG["max_tokens"]
}
kwargs["extra_body"] = {"chat_template_kwargs": {"enable_thinking": False}}
if json_output:
kwargs["response_format"] = {"type": "json_object"}
print("model API. history message", message_list)
response = await client.chat.completions.create(**kwargs)
return response.choices[0].message.content
@staticmethod
async def open_api_vl_without_thinking(
image_url: str
) -> str:
"""
专门用于从图片中提取设备名称故障现象
自动过滤思考过程仅返回核心结果
参数:
image_url: Base64 格式的图片数据 (data:image/...;base64,...)
model: 模型名称默认为配置中的模型
custom_instruction: 额外的特定指令 (可选)
"""
# 1. 确定模型名称
model = LLM_CONFIG.get("model")
# 2. 构建强约束的 System Prompt
# 核心目标:禁止思考标签,禁止废话,只给结果
system_prompt = (
"你是一个工业视觉分析专家。你的任务是从图片中识别设备并诊断故障。\n"
"【严格约束】\n"
"1. 绝对禁止输出 <think>, <think>, <reasoning> 等任何思考过程标签。\n"
"2. 绝对禁止输出'好的''根据图片''分析如下'等开场白或结束语。\n"
"3. 直接输出最终结论,格式必须严格遵守下面的模板。"
)
# 3. 构建针对性的 User Prompt
default_task = (
"请分析这张图片,描述图片内容,重点关注以下信息\n"
"1. 设备识别:图中是什么设备?型号或标签是否可见?\n"
"2. 状态评估:仪表盘数值是否在正常区间?指示灯颜色是什么?\n"
"3. 异常检测:是否存在漏油、生锈、电缆破损、螺丝松动或烟雾?\n"
"4. 环境风险:周围是否有杂物堆放或安全隐患?\n"
"请用专业、客观、简短的语言描述。"
)
final_user_prompt = default_task
# 4. 初始化客户端
client = AsyncOpenAI(
api_key=LLM_CONFIG.get("api_key"),
base_url=LLM_CONFIG.get("base_url"),
)
# 5. 构建请求参数
kwargs = {
"model": model,
"messages": [
{"role": "system", "content": system_prompt},
{
"role": "user",
"content": [
{"type": "text", "text": final_user_prompt},
{"type": "image_url", "image_url": {"url": image_url}}
]
}
],
"temperature": 0.1, # 低温度以保证事实准确性
"stream": False,
"max_tokens": LLM_CONFIG.get("max_tokens"),
}
# 尝试通过参数关闭思考 (取决于后端支持情况)
kwargs["extra_body"] = {"chat_template_kwargs": {"enable_thinking": False}}
try:
print(f"🚀 正在调用 {model} 进行设备故障分析...")
response = await client.chat.completions.create(**kwargs)
raw_content = response.choices[0].message.content or ""
print(f"✅ 分析完成:\n{raw_content}")
return raw_content
except Exception as e:
import traceback
error_msg = f"❌ 设备故障分析失败:{str(e)}\n{traceback.format_exc()}"
print(error_msg)
return f"Error: {str(e)}"
@staticmethod
def get_embeddings(embedding_text: str):
embedding_base_url = EMBEDDING_CONFIG["base_url"]
with httpx.Client(timeout=60) as client:
response = client.post(
embedding_base_url,
json={"model": EMBEDDING_CONFIG["model"], "input": embedding_text},
headers={"Authorization": EMBEDDING_CONFIG["api_key"]},
)
embedding_text = response.json()["data"][0]["embedding"]
return embedding_text
@staticmethod
async def get_embeddings_async(embedding_text: str) -> List[float]:
embedding_base_url = EMBEDDING_CONFIG["base_url"]
async with httpx.AsyncClient(timeout=60) as client:
response = await client.post(
embedding_base_url,
json={"model": EMBEDDING_CONFIG["model"], "input": embedding_text},
headers={"Authorization": EMBEDDING_CONFIG["api_key"]},
)
embedding = response.json()["data"][0]["embedding"]
return embedding
@staticmethod
async def get_embeddings_batch_async(texts: List[str]) -> List[List[float]]:
embedding_base_url = EMBEDDING_CONFIG["base_url"]
async with httpx.AsyncClient(timeout=120) as client:
response = await client.post(
embedding_base_url,
json={"model": EMBEDDING_CONFIG["model"], "input": texts},
headers={"Authorization": EMBEDDING_CONFIG["api_key"]},
)
data = response.json()["data"]
sorted_data = sorted(data, key=lambda x: x["index"])
embeddings = [item["embedding"] for item in sorted_data]
return embeddings
@staticmethod
def cosine_similarity(vec1: Union[List[float], np.ndarray], vec2: Union[List[float], np.ndarray]) -> float:
v1 = np.array(vec1) if not isinstance(vec1, np.ndarray) else vec1
v2 = np.array(vec2) if not isinstance(vec2, np.ndarray) else vec2
norm1 = np.linalg.norm(v1)
norm2 = np.linalg.norm(v2)
if norm1 == 0 or norm2 == 0:
return 0.0
return float(np.dot(v1, v2) / (norm1 * norm2))
@staticmethod
def cosine_similarity_batch(query_vec: Union[List[float], np.ndarray],
candidates_vecs: List[Union[List[float], np.ndarray]]) -> List[float]:
query = np.array(query_vec) if not isinstance(query_vec, np.ndarray) else query_vec
candidates = [np.array(v) if not isinstance(v, np.ndarray) else v for v in candidates_vecs]
query_norm = np.linalg.norm(query)
if query_norm == 0:
return [0.0] * len(candidates)
similarities = []
for candidate in candidates:
cand_norm = np.linalg.norm(candidate)
if cand_norm == 0:
similarities.append(0.0)
else:
sim = float(np.dot(query, candidate) / (query_norm * cand_norm))
similarities.append(sim)
return similarities
@staticmethod
async def hybrid_match_with_embeddings(
query_text: str,
candidates: List[dict],
name_key: str = "display_name",
embedding_key: str = "embedding",
text_weight: float = 0.3,
semantic_weight: float = 0.7,
text_threshold: float = 0.3,
semantic_threshold: float = 0.5
) -> tuple:
if not candidates:
return None, 0.0
query_embedding = await OpenaiAPI.get_embeddings_async(query_text)
scored_candidates = []
for idx, candidate in enumerate(candidates):
name = candidate.get(name_key, "")
if not name:
props = candidate.get("props", {})
name = props.get("name") or props.get("名称") or props.get("设备名称") or ""
text_score = OpenaiAPI._compute_text_similarity(query_text, name)
props = candidate.get("props", {})
candidate_embedding = props.get(embedding_key) or candidate.get(embedding_key)
if candidate_embedding:
semantic_score = OpenaiAPI.cosine_similarity(query_embedding, candidate_embedding)
else:
semantic_score = 0.0
combined_score = text_weight * text_score + semantic_weight * semantic_score
scored_candidates.append({
"candidate": candidate,
"text_score": text_score,
"semantic_score": semantic_score,
"combined_score": combined_score,
"index": idx
})
scored_candidates.sort(key=lambda x: x["combined_score"], reverse=True)
best = scored_candidates[0]
best_candidate = best["candidate"]
best_score = best["combined_score"]
min_threshold = max(text_threshold * text_weight + semantic_threshold * semantic_weight, 0.3)
if best_score < min_threshold:
return None, best_score
return best_candidate, best_score
@staticmethod
def _compute_text_similarity(a: str, b: str) -> float:
if not a or not b:
return 0.0
a = str(a).strip().lower()
b = str(b).strip().lower()
if not a or not b:
return 0.0
if a == b:
return 1.0
if a in b or b in a:
return 0.9
set_a, set_b = set(a), set(b)
inter = len(set_a & set_b)
union = len(set_a | set_b) or 1
jaccard = inter / union
len_diff = abs(len(a) - len(b))
len_penalty = max(0.0, 1.0 - len_diff / max(len(a), len(b), 1))
return 0.5 * jaccard + 0.5 * len_penalty
if __name__ == "__main__":
answer = asyncio.run(OpenaiAPI.open_api_chat_without_thinking(
query="你好",
json_output=True,
system_prompt="你是一个助手,请用中文回答问题。请直接给出最终答案,不要包含任何推理步骤、思考过程、解释性文字或前缀。",
enable_thinking=True
))
print(answer)

380
modelsAPI/model_api64.py Normal file
View File

@ -0,0 +1,380 @@
import asyncio
import httpx
from typing import List, Union
import os
import numpy as np
from openai import OpenAI
from config import LLM_CONFIG, EMBEDDING_CONFIG
from neo4j_graphrag.embeddings.base import Embedder
from openai import AsyncOpenAI
class OpenaiAPI:
@staticmethod
async def open_api_chat_without_thinking(
query: str = None,
model: str = None,
json_output: bool = False,
system_prompt: str = None,
messages: list = None,
enable_thinking: bool = True,
temperature: float = 0.1
) -> str:
if model is None:
model = LLM_CONFIG["model"]
if system_prompt is None:
system_prompt = "我是一个专业的AI助手请用中文回答问题。请直接给出最终答案不要包含任何推理步骤、思考过程、解释性文字或前缀。"
# 构建消息
message_list = [{"role": "system", "content": system_prompt}]
if messages is not None:
message_list.extend(messages)
if query is not None:
message_list.append({"role": "user", "content": query})
client = AsyncOpenAI(
api_key=LLM_CONFIG["api_key"],
base_url=LLM_CONFIG["base_url"],
)
kwargs = {
"model": model,
"messages": message_list,
"temperature": temperature,
"stream": False,
"max_tokens":LLM_CONFIG["max_tokens"]
}
kwargs["extra_body"] = {"chat_template_kwargs": {"enable_thinking": False}}
if json_output:
kwargs["response_format"] = {"type": "json_object"}
print("model API. history message", message_list)
response = await client.chat.completions.create(**kwargs)
return response.choices[0].message.content
@staticmethod
async def open_api_chat_stream(
query: str = None,
model: str = None,
json_output: bool = False,
system_prompt: str = None,
messages: list = None,
enable_thinking: bool = True,
temperature: float = 0.1
):
"""
流式输出大模型响应
Yields:
str: 流式内容片段
"""
if model is None:
model = LLM_CONFIG["model"]
if system_prompt is None:
system_prompt = "我是一个专业的AI助手请用中文回答问题。请直接给出最终答案不要包含任何推理步骤、思考过程、解释性文字或前缀。"
# 构建消息
message_list = [{"role": "system", "content": system_prompt}]
if messages is not None:
message_list.extend(messages)
if query is not None:
message_list.append({"role": "user", "content": query})
client = AsyncOpenAI(
api_key=LLM_CONFIG["api_key"],
base_url=LLM_CONFIG["base_url"],
)
kwargs = {
"model": model,
"messages": message_list,
"temperature": temperature,
"stream": True,
"max_tokens":LLM_CONFIG["max_tokens"]
}
kwargs["extra_body"] = {"chat_template_kwargs": {"enable_thinking": False}}
if json_output:
kwargs["response_format"] = {"type": "json_object"}
print("model API stream. history message", message_list)
stream = await client.chat.completions.create(**kwargs)
async for chunk in stream:
if chunk.choices and len(chunk.choices) > 0:
delta = chunk.choices[0].delta
if delta.content:
yield delta.content
@staticmethod
async def open_api_vl_without_thinking(
image_url: str
) -> str:
"""
专门用于从图片中提取设备名称故障现象
自动过滤思考过程仅返回核心结果
参数:
image_url: Base64 格式的图片数据 (data:image/...;base64,...)
model: 模型名称默认为配置中的模型
custom_instruction: 额外的特定指令 (可选)
"""
# 1. 确定模型名称
model = LLM_CONFIG.get("model")
# 2. 构建强约束的 System Prompt
# 核心目标:禁止思考标签,禁止废话,只给结果
system_prompt = (
"你是一个工业视觉分析专家。你的任务是从图片中识别设备并诊断故障。\n"
"【严格约束】\n"
"1. 绝对禁止输出 <think>, <think>, <reasoning> 等任何思考过程标签。\n"
"2. 绝对禁止输出'好的''根据图片''分析如下'等开场白或结束语。\n"
"3. 直接输出最终结论,格式必须严格遵守下面的模板。"
)
# 3. 构建针对性的 User Prompt
default_task = (
"请分析这张图片,描述图片内容,重点关注以下信息\n"
"1. 设备识别:图中是什么设备?型号或标签是否可见?\n"
"2. 状态评估:仪表盘数值是否在正常区间?指示灯颜色是什么?\n"
"3. 异常检测:是否存在漏油、生锈、电缆破损、螺丝松动或烟雾?\n"
"4. 环境风险:周围是否有杂物堆放或安全隐患?\n"
"请用专业、客观、简短的语言描述。"
)
final_user_prompt = default_task
# 4. 初始化客户端
client = AsyncOpenAI(
api_key=LLM_CONFIG.get("api_key"),
base_url=LLM_CONFIG.get("base_url"),
)
# 5. 构建请求参数
kwargs = {
"model": model,
"messages": [
{"role": "system", "content": system_prompt},
{
"role": "user",
"content": [
{"type": "text", "text": final_user_prompt},
{"type": "image_url", "image_url": {"url": image_url}}
]
}
],
"temperature": 0.1, # 低温度以保证事实准确性
"stream": False,
"max_tokens": LLM_CONFIG.get("max_tokens"),
}
# 尝试通过参数关闭思考 (取决于后端支持情况)
kwargs["extra_body"] = {"chat_template_kwargs": {"enable_thinking": False}}
try:
print(f"🚀 正在调用 {model} 进行设备故障分析...")
response = await client.chat.completions.create(**kwargs)
raw_content = response.choices[0].message.content or ""
print(f"✅ 分析完成:\n{raw_content}")
return raw_content
except Exception as e:
import traceback
error_msg = f"❌ 设备故障分析失败:{str(e)}\n{traceback.format_exc()}"
print(error_msg)
return f"Error: {str(e)}"
@staticmethod
def get_embeddings(embedding_text: str):
embedding_base_url = EMBEDDING_CONFIG["base_url"]
with httpx.Client(timeout=60) as client:
response = client.post(
embedding_base_url,
json={"model": EMBEDDING_CONFIG["model"], "input": embedding_text},
headers={"Authorization": EMBEDDING_CONFIG["api_key"]},
)
embedding_text = response.json()["data"][0]["embedding"]
return embedding_text
@staticmethod
async def get_embeddings_async(embedding_text: str) -> List[float]:
embedding_base_url = EMBEDDING_CONFIG["base_url"]
async with httpx.AsyncClient(timeout=60) as client:
response = await client.post(
embedding_base_url,
json={"model": EMBEDDING_CONFIG["model"], "input": embedding_text},
headers={"Authorization": EMBEDDING_CONFIG["api_key"]},
)
embedding = response.json()["data"][0]["embedding"]
return embedding
@staticmethod
async def get_embeddings_batch_async(texts: List[str]) -> List[List[float]]:
embedding_base_url = EMBEDDING_CONFIG["base_url"]
async with httpx.AsyncClient(timeout=120) as client:
response = await client.post(
embedding_base_url,
json={"model": EMBEDDING_CONFIG["model"], "input": texts},
headers={"Authorization": EMBEDDING_CONFIG["api_key"]},
)
data = response.json()["data"]
sorted_data = sorted(data, key=lambda x: x["index"])
embeddings = [item["embedding"] for item in sorted_data]
return embeddings
@staticmethod
def cosine_similarity(vec1: Union[List[float], np.ndarray], vec2: Union[List[float], np.ndarray]) -> float:
v1 = np.array(vec1) if not isinstance(vec1, np.ndarray) else vec1
v2 = np.array(vec2) if not isinstance(vec2, np.ndarray) else vec2
norm1 = np.linalg.norm(v1)
norm2 = np.linalg.norm(v2)
if norm1 == 0 or norm2 == 0:
return 0.0
return float(np.dot(v1, v2) / (norm1 * norm2))
@staticmethod
def cosine_similarity_batch(query_vec: Union[List[float], np.ndarray],
candidates_vecs: List[Union[List[float], np.ndarray]]) -> List[float]:
query = np.array(query_vec) if not isinstance(query_vec, np.ndarray) else query_vec
candidates = [np.array(v) if not isinstance(v, np.ndarray) else v for v in candidates_vecs]
query_norm = np.linalg.norm(query)
if query_norm == 0:
return [0.0] * len(candidates)
similarities = []
for candidate in candidates:
cand_norm = np.linalg.norm(candidate)
if cand_norm == 0:
similarities.append(0.0)
else:
sim = float(np.dot(query, candidate) / (query_norm * cand_norm))
similarities.append(sim)
return similarities
@staticmethod
async def hybrid_match_with_embeddings(
query_text: str,
candidates: List[dict],
name_key: str = "display_name",
embedding_key: str = "embedding",
text_weight: float = 0.3,
semantic_weight: float = 0.7,
text_threshold: float = 0.3,
semantic_threshold: float = 0.5
) -> tuple:
if not candidates:
return None, 0.0
query_embedding = await OpenaiAPI.get_embeddings_async(query_text)
scored_candidates = []
for idx, candidate in enumerate(candidates):
name = candidate.get(name_key, "")
if not name:
props = candidate.get("props", {})
name = props.get("name") or props.get("名称") or props.get("设备名称") or ""
text_score = OpenaiAPI._compute_text_similarity(query_text, name)
props = candidate.get("props", {})
candidate_embedding = props.get(embedding_key) or candidate.get(embedding_key)
if candidate_embedding:
semantic_score = OpenaiAPI.cosine_similarity(query_embedding, candidate_embedding)
else:
semantic_score = 0.0
combined_score = text_weight * text_score + semantic_weight * semantic_score
scored_candidates.append({
"candidate": candidate,
"text_score": text_score,
"semantic_score": semantic_score,
"combined_score": combined_score,
"index": idx
})
scored_candidates.sort(key=lambda x: x["combined_score"], reverse=True)
best = scored_candidates[0]
best_candidate = best["candidate"]
best_score = best["combined_score"]
min_threshold = max(text_threshold * text_weight + semantic_threshold * semantic_weight, 0.3)
if best_score < min_threshold:
return None, best_score
return best_candidate, best_score
@staticmethod
def _compute_text_similarity(a: str, b: str) -> float:
if not a or not b:
return 0.0
a = str(a).strip().lower()
b = str(b).strip().lower()
if not a or not b:
return 0.0
if a == b:
return 1.0
if a in b or b in a:
return 0.9
set_a, set_b = set(a), set(b)
inter = len(set_a & set_b)
union = len(set_a | set_b) or 1
jaccard = inter / union
len_diff = abs(len(a) - len(b))
len_penalty = max(0.0, 1.0 - len_diff / max(len(a), len(b), 1))
return 0.5 * jaccard + 0.5 * len_penalty
class LocalBgeM3Embeddings(Embedder):
def __init__(self, base_url: str, api_key: str, model_name: str = "bge-m3"):
self.base_url = base_url.rstrip("/")
self.api_key = api_key
self.model_name = model_name
def embed_query(self, text: str) -> List[float]:
return self.embed_documents([text])[0]
def embed_documents(self, texts: List[str]) -> List[List[float]]:
url = self.base_url + "/embeddings"
with httpx.Client(timeout=60) as client:
response = client.post(
url,
json={"model": self.model_name, "input": texts},
headers={"Authorization": f"Bearer {self.api_key}"},
)
return [item["embedding"] for item in response.json()["data"]]
if __name__ == "__main__":
answer = asyncio.run(OpenaiAPI.open_api_chat_without_thinking(
query="你好",
json_output=True,
system_prompt="你是一个助手,请用中文回答问题。请直接给出最终答案,不要包含任何推理步骤、思考过程、解释性文字或前缀。",
enable_thinking=True
))
print(answer)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1023 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -19,17 +19,16 @@
COMMON_PROMPTS = { COMMON_PROMPTS = {
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# 格式规范系统提示词 # 格式规范系统提示词
# 兼容保留:当前最终回复路径已改为单次流式生成 + 本地后处理 # 用于: workflow_baike.py (generate_answer)、workflow_utils.py (stream_format_and_postprocess)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
"format_system": ( "format_system": (
"你是一个专业的格式规范助手,负责在不改变事实内容和资源引用的前提下,将给定文本整理成清晰、稳定、易读的格式。" "你是一个专业的格式规范助手,负责将给定的文本内容规范成美观、易读的格式。"
"图片资源只能来自用户提供的原始内容或参考资料中真实出现的链接;不得使用提示词中的格式示意,也不得自行构造图片链接。"
), ),
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# 格式规范用户提示词(带图片示例,用于百科问答和通用格式化) # 格式规范用户提示词(带图片示例,用于百科问答和通用格式化)
# 输入变量: raw_content # 输入变量: raw_content
# 兼容保留:当前最终回复路径已改为单次流式生成 + 本地后处理 # 用于: workflow_baike.py (generate_answer)、workflow_utils.py (stream_format_and_postprocess 无content_label时)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
"format_user_with_image_examples": ( "format_user_with_image_examples": (
"请将以下内容规范成美观、易读的Markdown格式\n" "请将以下内容规范成美观、易读的Markdown格式\n"
@ -38,22 +37,28 @@ COMMON_PROMPTS = {
"{raw_content}\n" "{raw_content}\n"
"\n" "\n"
"【格式规范要求】\n" "【格式规范要求】\n"
"1. 保持原始内容的完整性和准确性,**不要修改事实、结论、步骤或资源链接**,只调整格式\n" "1. 保持原始内容的完整性和准确性,**不要修改任何内容**,只调整格式\n"
# "2. 适当使用标题、段落分隔等Markdown格式使内容更易读\n" # "2. 适当使用标题、段落分隔等Markdown格式使内容更易读\n"
"2. 不得出现```markdown、'''markdown'''、格式化说明等包裹符号\n" "2. 不得出现'''mardown'''符号\n"
"3. 图片属于原始内容中的资源引用:应保留在与对应设备、部件、步骤或说明最相关的位置\n" "3. 确保图片链接在相关位置自然展示使用,并且只输出准确格式,`![图片](/api/v1/knowledge/files/images/xxx.jpg)` \n"
"4. 图片只输出为单个 Markdown 图片标签,格式为 `![图片](原始内容中真实存在的完整图片URL)`URL 必须来自【原始内容】,不得来自本提示词或自行构造;同一图片资源重复出现时只保留一次\n" "4. 如果参考资料中没有图片,**不要输出任何关于图片的说明**\n"
"5. 图片文件名和路径必须完整照抄原始资源,不要改写、拆分、补全、连续重复或作为普通正文输出\n" "5. 确保LaTeX公式格式正确$$...$$,并在 $$...$$ 前后加空格\n"
"6. 图片规则只用于处理已有资源引用,不要围绕图片资源状态输出任何文字说明\n" "6. 如果有代码片段,使用适当的代码块格式\n"
"7. 禁止输出占位图片名、示例图片名或根据相似文字补全出的图片链接\n" "7. 不要添加任何额外的解释或说明\n"
"8. 确保LaTeX公式格式正确$$...$$,并在 $$...$$ 前后加空格\n"
"9. 如果有代码片段,使用适当的代码块格式\n"
"10. 不要添加任何额外的解释或说明\n"
"\n" "\n"
"【图片处理规则】\n" "【图片输出示例】\n"
"- 当【原始内容】中已经出现图片路径或图片 Markdown 标签时,可以输出图片\n" "示例一: 原始内容:`![图片](images/xxx.jpg)`\n"
"- 输出图片时必须逐字复制原始图片的文件名和路径,只整理 Markdown 形式,不能引入任何新文件名\n" " 规范后输出:`![图片](/api/v1/knowledge/files/images/xxx.jpg)`\n"
"- 本提示词中的格式说明不是可用图片资源,禁止原样输出\n" "示例二: 原始内容:`![图片](/api/v1/knowledge/files/images/xxx.jpg)`\n"
" 规范后输出:`![图片](/api/v1/knowledge/files/images/xxx.jpg)`\n"
"示例三: 原始内容:`images/xxx.jpg`\n"
" 规范后输出:`![图片](/api/v1/knowledge/files/images/xxx.jpg)`\n"
"示例四: 原始内容:`xxx.jpg`\n"
" 规范后输出:`![图片](/api/v1/knowledge/files/images/xxx.jpg)`\n"
"示例五: 原始内容:`![油污清理](/api/v1/knowledge/files/images/ce9650912c24fa0ed693228469322ec2a735f14d9ac729a1dc87427441bad0b.jpg)`\n"
" 规范后输出:`![图片](/api/v1/knowledge/files/images/ce9650912c24fa0ed693228469322ec2a735f14d9ac729a1dc87427441bad0b.jpg)`\n"
"示例六: 原始内容:`![部分断裂的螺栓及垫片](![图片](/api/v1/knowledge/files/images/e34eb1ddbe26730c84409e02365db416e74ce9e3bf2793a2605db5a085548415.jpg)`\n"
" 规范后输出:`![图片](/api/v1/knowledge/files/images/e34eb1ddbe26730c84409e02365db416e74ce9e3bf2793a2605db5a085548415.jpg)`\n"
"\n" "\n"
"请直接输出规范后的内容:" "请直接输出规范后的内容:"
), ),
@ -61,7 +66,7 @@ COMMON_PROMPTS = {
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# 格式规范用户提示词(简化版,用于方案重新格式化,图片链接保持原样) # 格式规范用户提示词(简化版,用于方案重新格式化,图片链接保持原样)
# 输入变量: content_label, raw_content # 输入变量: content_label, raw_content
# 兼容保留:当前最终回复路径已改为单次流式生成 + 本地后处理 # 用于: workflow_utils.py (stream_format_and_postprocess 有content_label时)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
"format_user_simple": ( "format_user_simple": (
"请将以下{content_label}内容规范成美观、易读的Markdown格式\n" "请将以下{content_label}内容规范成美观、易读的Markdown格式\n"
@ -70,16 +75,13 @@ COMMON_PROMPTS = {
"{raw_content}\n" "{raw_content}\n"
"\n" "\n"
"【格式规范要求】\n" "【格式规范要求】\n"
"1. 保持原始内容的完整性和准确性,**不要修改事实、结论、步骤或资源链接**,只调整格式\n" "1. 保持原始内容的完整性和准确性,**不要修改任何内容**,只调整格式\n"
# "2. 适当使用标题、列表、段落分隔等Markdown格式使内容更易读\n" # "2. 适当使用标题、列表、段落分隔等Markdown格式使内容更易读\n"
"2. 不得出现```markdown、'''markdown'''、格式化说明等包裹符号\n" "2. 不得出现'''markdown'''符号\n"
"3. 只保留【原始内容】中真实存在的图片链接,并放在相关内容附近;同一图片资源重复出现时只保留一次\n" "3. 确保图片链接保持原样:`![图片](URL)`\n"
"4. 图片文件名和路径必须完整照抄原始资源,不要改写、拆分、补全、连续重复或作为普通正文输出\n" "4. 确保LaTeX公式格式正确$$...$$,并在 $$...$$ 前后加空格\n"
"5. 图片规则只用于处理已有资源引用,不要围绕图片资源状态输出任何文字说明\n" "5. 如果有代码片段,使用适当的代码块格式\n"
"6. 禁止输出占位图片名、示例图片名或根据相似文字补全出的图片链接\n" "6. 不要添加任何额外的解释或说明\n"
"7. 确保LaTeX公式格式正确$$...$$,并在 $$...$$ 前后加空格\n"
"8. 如果有代码片段,使用适当的代码块格式\n"
"9. 不要添加任何额外的解释或说明\n"
"\n" "\n"
"请直接输出规范后的内容:" "请直接输出规范后的内容:"
), ),
@ -173,7 +175,7 @@ COMMON_PROMPTS = {
"generate_response_no_rag_system": ( "generate_response_no_rag_system": (
"你是一个专业的{biz_label}助手。\n" "你是一个专业的{biz_label}助手。\n"
"虽然没有检索到相关资料,但可以根据用户提供的信息给出一般性建议。\n" "虽然没有检索到相关资料,但可以根据用户提供的信息给出一般性建议。\n"
"注意:明确告知用户这是基于一般经验的建议。没有检索资料时不要输出图片链接。" "注意:明确告知用户这是基于一般经验的建议。"
), ),
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -181,7 +183,7 @@ COMMON_PROMPTS = {
# ------------------------------------------------------------------ # ------------------------------------------------------------------
"generate_response_no_rag_simple_system": ( "generate_response_no_rag_simple_system": (
"你是一个专业的{biz_label}助手。\n" "你是一个专业的{biz_label}助手。\n"
"虽然没有检索到相关资料,但可以根据用户提供的信息给出一般性建议。没有检索资料时不要输出图片链接。" "虽然没有检索到相关资料,但可以根据用户提供的信息给出一般性建议。"
), ),
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -205,13 +207,11 @@ COMMON_PROMPTS = {
"{combined_query}\n" "{combined_query}\n"
"\n" "\n"
"【输出要求】\n" "【输出要求】\n"
"1. 先直接回应用户当前追问,再结合已有方案和参考资料补充必要依据,避免重复整篇重写\n" "1. {image_guidance}\n"
"2. {image_guidance}\n" "2. 如果参考资料包含表格,请转化成不用表格的形式输出内容\n"
"3. 参考资料中的图片可放在对应部件、现象、步骤或注意事项附近;同一图片资源重复出现时只引用一次;图片规则不要写入正文\n" "3. 如果参考资料包含 LaTeX 公式,请转为 $$...$$ 公式格式输出,并在 $$...$$ 前后加空格\n"
"4. 图片、链接、公式等资源引用必须与参考资料保持一致,不得裁剪、转义、改写、拆分或补全;图片 URL 必须逐字来自【参考资料】,不得使用占位链接或自行构造\n" "4. 所有链接必须与参考资料保持一致,不得裁剪、转义或改写\n"
"5. 如果参考资料包含表格,请转化成不用表格的形式输出内容\n" "5. 不能虚构参考资料中未提及的图片、表格、零件号或步骤\n"
"6. 如果参考资料包含 LaTeX 公式,请转为 $$...$$ 公式格式输出,并在 $$...$$ 前后加空格\n"
"7. 不能虚构参考资料中未提及的图片、表格、零件号或步骤\n"
"\n" "\n"
"请给出回复:" "请给出回复:"
), ),
@ -222,7 +222,7 @@ COMMON_PROMPTS = {
# detail_info, item_action, rag_answer # detail_info, item_action, rag_answer
# ------------------------------------------------------------------ # ------------------------------------------------------------------
"generate_response_normal": ( "generate_response_normal": (
"你是一名资深工业设备{biz_label}工程师,请根据以下信息生成{biz_label}分析与{biz_label}方案。回答要把文字依据、操作步骤和资料中的可用图示组织成一份可执行方案。\n" "你是一名资深工业设备{biz_label}工程师,请根据以下信息生成{biz_label}分析与{biz_label}方案。如果参考资料中有图片,一定要把图片输出在结果中\n"
"\n" "\n"
"【设备信息】\n" "【设备信息】\n"
"- 舷号:{ship_number}\n" "- 舷号:{ship_number}\n"
@ -235,13 +235,11 @@ COMMON_PROMPTS = {
"{rag_answer}\n" "{rag_answer}\n"
"\n" "\n"
"【输出要求】\n" "【输出要求】\n"
"1. 先给出针对当前{item_label}的判断或操作目标,再展开原因/要点、处理步骤、复核与安全注意事项\n" "1. **重要**:如果参考资料中包含图片链接(格式为 `![图片](URL)` 或 `images/xxx.jpg`),请务必在相关位置自然展示使用,如果参考资料中没有图片,**不要输出任何关于图片的说明**\n"
"2. 【参考资料】中真实存在的图片可作为资料引用放在对应段落或步骤后自然展示;图片规则不要写入正文\n" "2. 如果参考资料包含 LaTeX 公式、连接等,请在对应位置正常使用,不得修改或删除\n"
"3. 图片只作为资料引用输出为单个 Markdown 图片标签URL 和文件名必须逐字来自【参考资料】,不要改写、拆分、补全、连续重复或自行构造;同一图片资源重复出现时只引用一次\n" "3. 请专注于内容的准确性、完整性和逻辑性,不要担心格式的美观性\n"
"4. 如果参考资料包含 LaTeX 公式、链接等,请在对应位置正常使用,不得修改或删除\n" "4. 可以适当使用简单的段落分隔但不要使用复杂的Markdown格式\n"
"5. 请专注于内容的准确性、完整性和逻辑性,不要担心格式的美观性\n" "5. 如果参考资料包含表格,请不要输出表格,而要直接描述表格的内容\n"
"6. 可以适当使用简单的段落分隔但不要使用复杂的Markdown格式\n"
"7. 如果参考资料包含表格,请不要输出表格,而要直接描述表格的内容\n"
"\n" "\n"
"请给出回复:" "请给出回复:"
), ),
@ -253,7 +251,7 @@ COMMON_PROMPTS = {
# rag_answer, image_guidance # rag_answer, image_guidance
# ------------------------------------------------------------------ # ------------------------------------------------------------------
"generate_response_regenerating": ( "generate_response_regenerating": (
"你是一名资深工业设备{biz_label}工程师。用户对之前的方案有反馈,请根据反馈重新生成{biz_label}分析与{biz_label}方案,并把参考资料中的文字依据和可用图示组织成一份更贴合反馈的方案。\n" "你是一名资深工业设备{biz_label}工程师。用户对之前的方案有反馈,请根据反馈重新生成{biz_label}分析与{biz_label}方案。如果参考资料中有图片,一定要把图片输出在结果中\n"
"\n" "\n"
"【设备信息】\n" "【设备信息】\n"
"- 舷号:{ship_number}\n" "- 舷号:{ship_number}\n"
@ -271,12 +269,11 @@ COMMON_PROMPTS = {
"2. 然后给出{biz_label}方案,步骤清晰、可直接执行\n" "2. 然后给出{biz_label}方案,步骤清晰、可直接执行\n"
"3. 根据用户的反馈调整方案\n" "3. 根据用户的反馈调整方案\n"
"4. 如果参考资料信息不足,明确指出缺少什么\n" "4. 如果参考资料信息不足,明确指出缺少什么\n"
"5. {image_guidance}\n" "5. {image_guidance},如果参考资料中没有图片,**不要输出任何关于图片的说明**\n"
"6. 参考资料中的图片可放在反馈、部件、现象或步骤对应内容附近;同一图片资源重复出现时只引用一次;图片规则不要写入正文\n" "6. 如果参考资料包含表格,请转化成不用表格的形式输出内容\n"
"7. 图片、链接、公式等资源引用必须与参考资料保持一致,不得裁剪、转义、改写、拆分或补全;图片 URL 必须逐字来自【参考资料】,不得使用占位链接或自行构造\n" "7. 如果参考资料包含 LaTeX 公式,请转为 $$...$$ 公式格式输出,并在 $$...$$ 前后加空格\n"
"8. 如果参考资料包含表格,请转化成不用表格的形式输出内容\n" "8. 所有链接必须与参考资料保持一致,不得裁剪、转义或改写\n"
"9. 如果参考资料包含 LaTeX 公式,请转为 $$...$$ 公式格式输出,并在 $$...$$ 前后加空格\n" "9. 不能虚构参考资料中未提及的图片、表格、零件号或步骤\n"
"10. 不能虚构参考资料中未提及的图片、表格、零件号或步骤\n"
"\n" "\n"
"请直接输出调整后的方案:" "请直接输出调整后的方案:"
), ),
@ -285,7 +282,7 @@ COMMON_PROMPTS = {
# 生成内容系统提示词(故障诊断和操作指导共用) # 生成内容系统提示词(故障诊断和操作指导共用)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
"generate_response_content_system": ( "generate_response_content_system": (
"你是一名资深工业设备{biz_label}工程师,严格依据检索信息生成{biz_label}方案。请专注于内容的准确性、完整性和逻辑性,并把检索资料中的相关图片作为证据资源放在对应说明或步骤附近。只能使用检索资料中真实存在的图片链接,不得使用占位链接或自行构造图片链接" "你是一名资深工业设备{biz_label}工程师,严格依据检索信息生成{biz_label}方案。请专注于内容的准确性、完整性和逻辑性,如果参考资料中包含图片,请务必在回答中展示出来"
), ),
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -335,7 +332,7 @@ COMMON_PROMPTS = {
# analysis_points_text, original_rag_text, graph_results # analysis_points_text, original_rag_text, graph_results
# ------------------------------------------------------------------ # ------------------------------------------------------------------
"generate_deep_response": ( "generate_deep_response": (
"你是一名资深工业设备{biz_label}专家。用户反馈之前的方案没有解决问题,请根据多维度深度检索的资料,进行深入的{item_label}原因分析和解决方案规划,并把文字依据、图示资源和图谱结果统一组织到回答中\n" "你是一名资深工业设备{biz_label}专家。用户反馈之前的方案没有解决问题,请根据多维度深度检索的资料,进行深入的{item_label}原因分析和解决方案规划\n"
"\n" "\n"
"【设备信息】\n" "【设备信息】\n"
"- 舷号:{ship_number}\n" "- 舷号:{ship_number}\n"
@ -356,12 +353,11 @@ COMMON_PROMPTS = {
"1. 首先重新深入分析{item_label}原因,结合所有检索资料进行推理\n" "1. 首先重新深入分析{item_label}原因,结合所有检索资料进行推理\n"
"2. 给出更详细、更精准的{biz_label}方案,步骤清晰、可直接执行\n" "2. 给出更详细、更精准的{biz_label}方案,步骤清晰、可直接执行\n"
"3. 重点关注用户反馈的问题,根据用户反馈和检索资料,进行{item_label}原因分析和解决方案规划。\n" "3. 重点关注用户反馈的问题,根据用户反馈和检索资料,进行{item_label}原因分析和解决方案规划。\n"
"4. 参考资料中真实存在的图片可在原因链路、部件位置、检测项或处理步骤对应内容附近自然展示;图片规则不要写入正文\n" "4. **重要**:如果参考资料中包含图片链接(格式为 `![图片](URL)` 或 `images/xxx.jpg`),请务必在相关位置自然展示使用,如果参考资料中没有图片,**不要输出任何关于图片的说明**\n"
"5. 图片只作为资料引用输出为单个 Markdown 图片标签URL 和文件名必须逐字来自参考资料,不要改写、拆分、补全、连续重复或自行构造;同一图片资源重复出现时只引用一次\n" "5. 如果参考资料包含 LaTeX 公式、连接等,请在对应位置正常使用,不得修改或删除\n"
"6. 如果参考资料包含 LaTeX 公式、链接等,请在对应位置正常使用,不得修改或删除\n" "6. 请专注于内容的准确性、完整性和逻辑性,不要担心格式的美观性\n"
"7. 请专注于内容的准确性、完整性和逻辑性,不要担心格式的美观性\n" "7. 可以适当使用简单的段落分隔但不要使用复杂的Markdown格式\n"
"8. 可以适当使用简单的段落分隔但不要使用复杂的Markdown格式\n" "8. 如果参考资料包含表格,请不要输出表格,而要直接描述表格的内容\n"
"9. 如果参考资料包含表格,请不要输出表格,而要直接描述表格的内容\n"
"\n" "\n"
"请直接输出深度分析和方案:" "请直接输出深度分析和方案:"
), ),
@ -370,7 +366,7 @@ COMMON_PROMPTS = {
# 深度响应内容系统提示词(故障诊断和操作指导共用) # 深度响应内容系统提示词(故障诊断和操作指导共用)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
"generate_deep_response_content_system": ( "generate_deep_response_content_system": (
"你是一名资深工业设备{biz_label}专家,严格依据检索信息进行深度分析和方案生成。请专注于内容的准确性、完整性和逻辑性,并把检索资料中的相关图片作为证据资源放在对应分析点或步骤附近。只能使用检索资料中真实存在的图片链接,不得使用占位链接或自行构造图片链接" "你是一名资深工业设备{biz_label}专家,严格依据检索信息进行深度分析和方案生成。请专注于内容的准确性、完整性和逻辑性,如果参考资料中包含图片,请务必在回答中展示出来"
), ),
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -399,11 +395,10 @@ COMMON_PROMPTS = {
"2. 然后基于之前的方案进行修改和优化,给出调整后的方案\n" "2. 然后基于之前的方案进行修改和优化,给出调整后的方案\n"
"3. 重点解决用户反馈的问题\n" "3. 重点解决用户反馈的问题\n"
"4. {image_guidance}\n" "4. {image_guidance}\n"
"5. 原方案中与反馈内容仍相关的图片应保留在对应段落或步骤附近;同一图片资源重复出现时只引用一次\n" "5. 如果之前方案包含表格,请转化成不用表格的形式输出内容\n"
"6. 如果之前方案包含表格,请转化成不用表格的形式输出内容\n" "6. 如果之前方案包含 LaTeX 公式,请转为 $$...$$ 公式格式输出,并在 $$...$$ 前后加空格\n"
"7. 如果之前方案包含 LaTeX 公式,请转为 $$...$$ 公式格式输出,并在 $$...$$ 前后加空格\n" "7. 所有链接必须与原方案保持一致,不得裁剪、转义或改写\n"
"8. 所有链接必须与原方案保持一致,不得裁剪、转义、改写、拆分或补全;不得新增原方案中不存在的图片 URL\n" "8. 不能虚构原方案中未提及的图片、表格、零件号或步骤\n"
"9. 不能虚构原方案中未提及的图片、表格、零件号或步骤\n"
"\n" "\n"
"请直接输出修改后的完整方案:" "请直接输出修改后的完整方案:"
), ),
@ -427,19 +422,29 @@ BAIKE_PROMPTS = {
# 输入变量: history_messages, original_query # 输入变量: history_messages, original_query
# ------------------------------------------------------------------ # ------------------------------------------------------------------
"judge_need_retrieval": ( "judge_need_retrieval": (
"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"
"【对话历史】\n"
"{history_messages}\n" "{history_messages}\n"
"\n" "\n"
"User question:\n" "【当前用户问题】\n"
"{original_query}\n" "{original_query}\n"
"\n" "\n"
"Output only true or false." "请仅输出 \"true\"\"false\"不带引号true 表示需要检索false 表示不需要检索。"
), ),
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -447,30 +452,29 @@ BAIKE_PROMPTS = {
# 输入变量: last_user_query, history_str, original_query # 输入变量: last_user_query, history_str, original_query
# ------------------------------------------------------------------ # ------------------------------------------------------------------
"rewrite_query_with_history": ( "rewrite_query_with_history": (
"You are a query understanding assistant for a Chinese knowledge-base retrieval system.\n" "你是一个专业的检索query重写助手负责根据对话历史和当前用户问题生成**适合向量检索/RAG 的单条查询语句**。\n"
"\n" "\n"
"Tasks:\n" "请严格遵守以下要求:\n"
"1. Rewrite the current user question into a self-contained Chinese retrieval query.\n" "1. 只融合与当前问题语义强相关的**最近几轮对话信息**,不要机械拼接全部历史。\n"
"2. Generate 2-5 short search queries for hybrid retrieval.\n" "2. 消除指代和省略(例如:这个、刚才那个问题、上面说的故障等),补全成自洽、完整的描述。\n"
"3. 不要加入与用户问题无关的背景信息,不要过度扩展检索范围。\n"
"4. 输出必须是一句自然的中文问句或陈述句,便于检索;不要包含条目符号、编号或解释性文字。\n"
"5. 只输出改写后的query本身\n"
" - 不要输出说明文字\n"
" - 不要包含「改写结果」「检索query」等前缀\n"
" - 不要添加引号\n"
"\n" "\n"
"Rules:\n" "【特别提示】\n"
"- Resolve references and ellipsis using only relevant recent history.\n" "- 请优先参考**上一轮用户的问题**来重写本轮query。\n"
"- Preserve concrete entities, file names, database/table terms, equipment names, fault names, standards, procedure names, and exact keywords.\n" "- 上一轮用户的问题是:{last_user_query}\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"
"Last user question:\n" "【对话历史JSON 格式,已按时间排序)】\n"
"{last_user_query}\n"
"\n"
"Conversation history JSON:\n"
"{history_str}\n" "{history_str}\n"
"\n" "\n"
"Current user question:\n" "【当前用户问题】\n"
"{original_query}\n" "{original_query}\n"
"\n" "\n"
"Output ONLY valid JSON:\n" "请给出最终用于检索的单条中文query。"
"{{\"rewrite_query\":\"string\",\"search_queries\":[\"string\"]}}"
), ),
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -506,13 +510,7 @@ BAIKE_PROMPTS = {
# 输入变量: domain_desc # 输入变量: domain_desc
# ------------------------------------------------------------------ # ------------------------------------------------------------------
"generate_answer_with_retrieval_system": ( "generate_answer_with_retrieval_system": (
"You are a professional knowledge-base assistant for {domain_desc}.\n" "我是一个专业的{domain_desc}知识问答助手,根据用户的问题,综合所有检索结果,提供准确、专业、完整的回答内容。如果参考资料中有图片,一定要把图片输出在结果中"
"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."
), ),
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -520,20 +518,18 @@ 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"
"# Retrieved evidence ({rag_count} items)\n" "# 检索到的相关信息(共 {rag_count} 条):\n"
"\n" "\n"
"{all_source_text}\n" "{all_source_text}\n"
"\n" "\n"
"【输出要求】\n" "【输出要求】\n"
"1. 先直接回答用户问题,再补充必要依据。\n" "1. **重要**:如果参考资料中包含图片链接(格式为 `![图片](URL)` 或 `images/xxx.jpg`),请务必在相关位置自然展示使用,如果参考资料中没有图片,**不要输出任何关于图片的说明**\n"
"2. 每个关键事实都必须能在 Retrieved evidence 中找到依据;不要补充资料外结论。\n" "2. 如果参考资料包含 LaTeX 公式、连接等,请在对应位置正常使用,不得修改或删除\n"
"3. 如果资料只能说明部分内容,请明确说“资料中只能确认...”和“资料中未提供...”。\n" "3. 请专注于内容的准确性、完整性和逻辑性,不要担心格式的美观性\n"
"4. 优先使用 evidence 中的文件名、标题、File ID、Slice ID 做简短来源说明,但不要暴露无意义的内部参数。\n" "4. 可以适当使用简单的段落分隔但不要使用复杂的Markdown格式\n"
"5. 参考资料中真实存在的图片可在对应段落附近展示URL 必须逐字来自资料。\n" "5. 如果参考资料包含表格,请不要输出表格,而要直接描述表格的内容\n"
"6. 表格内容请转成自然语言描述,不要输出复杂表格。\n"
"\n" "\n"
"请给出回复:" "请给出回复:"
), ),
@ -543,7 +539,7 @@ BAIKE_PROMPTS = {
# 输入变量: domain_desc # 输入变量: domain_desc
# ------------------------------------------------------------------ # ------------------------------------------------------------------
"generate_answer_without_retrieval_system": ( "generate_answer_without_retrieval_system": (
"我是一个专业的{domain_desc}知识问答助手,根据用户的问题,结合我的专业知识,提供准确、专业、完整的回答内容。没有检索资料时不要输出图片链接。" "我是一个专业的{domain_desc}知识问答助手,根据用户的问题,结合我的专业知识,提供准确、专业、完整的回答内容。"
), ),
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -557,7 +553,6 @@ BAIKE_PROMPTS = {
"1. 请根据您的专业知识提供准确、完整的回答\n" "1. 请根据您的专业知识提供准确、完整的回答\n"
"2. 可以适当使用简单的段落分隔但不要使用复杂的Markdown格式\n" "2. 可以适当使用简单的段落分隔但不要使用复杂的Markdown格式\n"
"3. 请专注于内容的准确性和专业性\n" "3. 请专注于内容的准确性和专业性\n"
"4. 只回答文字内容,不输出资源链接\n"
"\n" "\n"
"请给出回复:" "请给出回复:"
), ),
@ -973,7 +968,7 @@ FAULT_DIAGNOSIS_PROMPTS = {
" - 示例:\"生成报告\"\"写报告\"\"导出报告\"\"下载报告\"\"保存报告\"\n" " - 示例:\"生成报告\"\"写报告\"\"导出报告\"\"下载报告\"\"保存报告\"\n"
"\n" "\n"
"2. modify_info: 用户需要修改或补充之前提供的故障信息(舷号、设备名称、故障现象等)\n" "2. modify_info: 用户需要修改或补充之前提供的故障信息(舷号、设备名称、故障现象等)\n"
" - 示例:\"其实是...\"\"不对,应该是...\"\"我补充一下...\"\"修改设备名称是...\"\"更正一下,故障是...\"\"我需要补充...\"\n" " - 示例:\"其实是...\"\"不对,应该是...\"\"我补充一下...\"\"修改设备名称是...\"\"更正一下,故障是...\"\n"
"\n" "\n"
"3. has_error: 用户指出之前生成的维修方案有错误、需要修改或调整方案内容\n" "3. has_error: 用户指出之前生成的维修方案有错误、需要修改或调整方案内容\n"
" - 示例:\"方案有误\"\"这个步骤不对\"\"需要修改方案\"\"这个方案有问题\"\n" " - 示例:\"方案有误\"\"这个步骤不对\"\"需要修改方案\"\"这个方案有问题\"\n"

124
test_ship_mapping.py Normal file
View File

@ -0,0 +1,124 @@
"""
智能舷号映射 + RAG检索 完整测试脚本
直接调用 search_with_ship_number_strategy展示真实检索结果
"""
import asyncio
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from utils.ship_number_search import search_with_ship_number_strategy, smart_ship_number_mapping
async def interactive_test():
"""交互式完整测试"""
print("=" * 60)
print("智能舷号映射 + RAG检索 完整测试")
print("=" * 60)
print("输入 'exit''quit' 退出")
print("输入 'help' 查看说明")
print("=" * 60)
while True:
print("\n" + "-" * 60)
ship_input = input("请输入舷号/型号/舰名: ").strip()
if ship_input.lower() in ['exit', 'quit', 'q']:
print("退出测试")
break
if ship_input.lower() == 'help':
print("\n使用说明:")
print(" 先输入舷号/型号/舰名(如 163、南昌舰、055驱逐舰")
print(" 再输入查询内容(如 主机排烟温度高)")
print(" 系统会执行完整的智能映射 + RAG三步检索")
continue
if not ship_input:
print("请输入有效内容")
continue
query_input = input("请输入查询内容: ").strip()
if not query_input:
query_input = "主机排烟温度高的维修方案"
print(f"使用默认查询: {query_input}")
search_type = input("搜索类型 (fault/operate回车默认fault): ").strip()
if search_type not in ["fault", "operate"]:
search_type = "fault"
print("\n" + "=" * 60)
print("第一步:智能映射")
print("=" * 60)
try:
matched_ship_number, matched_model, all_numbers, match_type = await smart_ship_number_mapping(ship_input)
print(f"输入: '{ship_input}'")
print(f"匹配类型: {match_type}")
print(f"匹配舷号: {matched_ship_number}")
print(f"匹配型号: {matched_model}")
print(f"该型号所有舷号: {all_numbers}")
if match_type == "none":
print("未匹配到,将使用原始输入进行检索")
except Exception as e:
print(f"映射失败: {str(e)}")
matched_ship_number = None
matched_model = None
all_numbers = None
match_type = "none"
print("\n" + "=" * 60)
print("第二步RAG检索三步策略")
print("=" * 60)
try:
rag_results, matched_kb_name, matched_kb_id = await search_with_ship_number_strategy(
base_query=query_input,
ship_number=ship_input,
top_k=5,
search_type=search_type
)
print(f"\n检索完成!")
print(f"知识库: {matched_kb_name or '未匹配'}")
print(f"知识库ID: {matched_kb_id or '未匹配'}")
print(f"结果数量: {len(rag_results)}")
if rag_results:
print(f"\n{'=' * 60}")
print("第三步:检索结果详情")
print("=" * 60)
for i, item in enumerate(rag_results, 1):
if isinstance(item, dict):
text = item.get("text", "")
kb_name = item.get("kb_name", "")
score = item.get("score", "")
source = item.get("source", "")
print(f"\n--- 结果 {i} ---")
if kb_name:
print(f"知识库: {kb_name}")
if score:
print(f"相似度: {score}")
if source:
print(f"来源: {source}")
if text:
preview = text[:300] + "..." if len(text) > 300 else text
print(f"内容预览:\n{preview}")
elif isinstance(item, str):
preview = item[:300] + "..." if len(item) > 300 else item
print(f"\n--- 结果 {i} ---")
print(f"内容预览:\n{preview}")
else:
print("\n未检索到任何结果")
except Exception as e:
print(f"\n检索失败: {str(e)}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
asyncio.run(interactive_test())

View File

@ -1,86 +0,0 @@
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,
}

222
tools/config.py Normal file
View File

@ -0,0 +1,222 @@
"""
项目配置文件
.env文件读取配置
"""
import os
from dotenv import load_dotenv
from contextvars import ContextVar
from typing import Optional, Dict, Any
# 加载.env文件
load_dotenv()
# ==================== 大模型配置 ====================
LLM_CONFIG = {
# "model": "Qwen3.5-35B-A3B",
"model": "Qwen3-32B",
# "model": "Qwen3-14B",
"base_url": "http://192.168.1.164:9800/v1",
"api_key": "gpustack_dee9ca823290886c_5edfc86aeeeceb1e9ee5162941cb2cb5",
"temperature": float("0.1"),
"max_tokens": int("4096"),
"timeout": int("30")
}
# ==================== 嵌入模型配置 ====================
EMBEDDING_CONFIG = {
"model": "bge-m3",
"base_url": "http://embedding:9700/v1/embeddings",
"api_key": "gpustack_dee9ca823290886c_5edfc86aeeeceb1e9ee5162941cb2cb5",
"base_url_v2" : "http://embedding:9700/v1",
}
# ==================== 线程安全的请求上下文配置 ====================
# 使用 contextvars 存储每个请求的用户配置,确保多用户并发时不会相互干扰
_request_user_config: ContextVar[Optional[Dict[str, str]]] = ContextVar('request_user_config', default=None)
# RAG配置的默认值
_RAG_CONFIG_DEFAULT = {
"search_endpoint": "http://htknow:8080/api/v1/knowledge/search/",
"x-user-id": "1",
"x-user-name": "testuser",
"x-role": "admin",
"kb-id": None,
"file-id": None
}
class DynamicRAGConfig(dict):
"""
动态RAG配置类支持线程安全的请求级配置
当访问用户相关配置时自动从当前请求上下文读取
其他配置项直接返回默认值
兼容字典的所有操作方式 get(), [], in
"""
def __getitem__(self, key: str):
# 如果是用户相关配置或知识库参数,从当前请求上下文读取
if key in ["x-user-id", "x-user-name", "x-role", "kb-id", "file-id"]:
request_config = _request_user_config.get()
if request_config and key in request_config:
return request_config[key]
# 如果没有设置,返回默认值
return _RAG_CONFIG_DEFAULT[key]
# 其他配置项直接返回默认值
return _RAG_CONFIG_DEFAULT.get(key)
def get(self, key: str, default=None):
"""支持 get() 方法"""
try:
return self[key]
except KeyError:
return default
def __contains__(self, key: str):
"""支持 in 操作符"""
return key in _RAG_CONFIG_DEFAULT
def keys(self):
"""返回所有键"""
return _RAG_CONFIG_DEFAULT.keys()
def values(self):
"""返回所有值(动态读取用户配置)"""
return [self[key] for key in _RAG_CONFIG_DEFAULT.keys()]
def items(self):
"""返回所有键值对(动态读取用户配置)"""
return [(key, self[key]) for key in _RAG_CONFIG_DEFAULT.keys()]
# ==================== RAG服务配置 ====================
# 使用动态配置类,支持多用户并发时自动读取各自的配置
RAG_CONFIG = DynamicRAGConfig()
def set_request_user_config(x_user_id: Optional[str] = None,
x_user_name: Optional[str] = None,
x_role: Optional[str] = None,
text_kb_id: Optional[str] = None,
text_file_id: Optional[str] = None):
"""
设置当前请求的用户配置和知识库参数线程安全
Args:
x_user_id: 用户ID
x_user_name: 用户名称
x_role: 角色
text_kb_id: 文本检索知识库ID
text_file_id: 文本检索文件ID
"""
config = {}
if x_user_id is not None:
config["x-user-id"] = x_user_id
if x_user_name is not None:
config["x-user-name"] = x_user_name
if x_role is not None:
config["x-role"] = x_role
# 仅当非空字符串时才设置,空字符串时不传入 RAG 接口
if text_kb_id is not None and str(text_kb_id).strip():
config["kb-id"] = str(text_kb_id).strip()
if text_file_id is not None and str(text_file_id).strip():
config["file-id"] = str(text_file_id).strip()
_request_user_config.set(config if config else None)
# ==================== 图谱检索服务配置 ====================
GRAPH_SEARCH_CONFIG = {
"search_endpoint": "http://kgrag:9085/search_graph",
"graph_timeout": float("30.0"),
"default_top_k": int("10"),
"operation_search_endpoint": "http://kgrag:9085/operation_graph_search"
}
# ==================== 设备到系统检索服务配置 ====================
DEVICE_SYSTEM_CONFIG = {
"endpoint": "http://kgrag:9085/device_to_system",
"timeout": float("30.0"),
"default_min_hops": int("2"),
"default_max_hops": int("8"),
"default_top_k": int("10")
}
# ==================== 图册检索服务配置 ====================
ATLAS_CONFIG = {
"endpoint": "http://kgrag:9085/atlas_retrieval",
"timeout": float("30.0"),
"default_top_k": int("10")
}
# ==================== 工作流配置 ====================
WORKFLOW_CONFIG = {
"max_retry_count": int("2"), # 最大重试次数
"image_clarity_threshold": float("0.7"), # 图片清晰度阈值
"default_timeout": int("30") # 默认超时时间(秒)
}
# ==================== 服务器配置 ====================
SERVER_CONFIG = {
"host": "agent",
"port": "9088",
"base_url": "http://agent:9088"
}
# ==================== ASR服务配置 ====================
ASR_CONFIG = {
"api_base": "http://asr:9900/v1",
"model": "base",
"language": "zh",
"timeout": int("30")
}
# ==================== VLM服务配置 ====================
VLM_CONFIG = {
"api_base" : "http://kgrag:9085/v1",
"model" : "Qwen3-VL-8B"
}
# ==================== Neo4J配置 ====================
NEO4J_CONFIG = {
"uri": "neo4j://neo4j:7687",
"user": "neo4j",
"password": "zdht123"
}
# ==================== 知识库匹配配置 ====================
KB_TREE_CONFIG = {
"url": "http://htknow:8080/api/v1/knowledge/knowledge_base/tree"
}
# ==================== 维修反馈统计配置 ====================
REPAIR_FEEDBACK_CONFIG = {
"url": "http://webui:22000",
"email": "15088888888@163.com",
"password": "11223344"
}
# ==================== 数据库配置 ====================
POSTGRES_CONNECTION_STRING = "postgresql://postgres:zdht123@postgres:5432/hj_webui"
#POSTGRES_CONNECTION_STRING = "postgresql+psycopg://postgres:zdht123@postgres:5432/hj_webui"
POSTGRES_SQLALCHEMY_URL = "postgresql+psycopg://postgres:zdht123@postgres:5432/hj_webui"
# ==================== 索引配置 ====================
INDEX_CONFIG = {
"NAME_PROPERTY" : "名称",
"FULLTEXT_PROPERTY" :"fulltext",
"EMBEDDING_PROPERTY" : "embedding",
"SEARCH_LABEL" : "Searchable",
"VECTOR_INDEX_NAME" : "global_searchable_embedding",
"FULLTEXT_INDEX_NAME" : "global_searchable_content_search"
}
# ==================== sql表格字段与neo4j节点映射 ====================
MAP_INS={
"故障模式" : "fault",
"系统" : "system_name",
"设备" : "device_name",
"舷号" : "ship_number",
}
# ==================== neo4j配置 ====================
NEO4J_CONFIG = {
"uri": "neo4j://neo4j:7687",
"user": "neo4j",
"password": "zdht123@"
}

View File

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

View File

@ -217,7 +217,7 @@ async def record_fault_from_state(state: Dict[str, Any]) -> bool:
system_name = "其他" system_name = "其他"
if device_name: if device_name:
from tools.fault_statistics import get_device_system_from_neo4j from tools.fault_statistics import get_device_system_from_neo4j
system_name = await get_device_system_from_neo4j(device_name, ship_number=ship_number) system_name = await get_device_system_from_neo4j(device_name)
return await save_fault_record( return await save_fault_record(
ship_number=ship_number, ship_number=ship_number,

View File

@ -37,61 +37,80 @@ def fuzzy_match_system(user_input: str, system_name: str, threshold: float = 0.6
return similarity >= threshold return similarity >= threshold
async def get_device_system_from_neo4j(device_name: str, ship_number: Optional[str] = None) -> str: async def get_device_system_from_neo4j(device_name: str) -> str:
""" """
Neo4j 图谱中查询设备对应的系统带缓存机制 Neo4j 图谱中查询设备对应的系统调用外部接口带缓存机制
实际检索由 tools.graph_tools.find_system_by_device 负责该函数会使用
config.INDEX_CONFIG 中配置的全局 Neo4j 全文索引和向量索引
""" """
if not device_name or not device_name.strip(): if not device_name or not device_name.strip():
return "其他" return "其他"
device_name_clean = device_name.strip().lower() device_name_clean = device_name.strip().lower()
ship_number_clean = str(ship_number or "").strip().lower()
cache_key = f"{ship_number_clean}|{device_name_clean}"
if cache_key in neo4j_cache: if device_name_clean in neo4j_cache:
return neo4j_cache[cache_key] return neo4j_cache[device_name_clean]
from config import DEVICE_SYSTEM_CONFIG
endpoint = DEVICE_SYSTEM_CONFIG.get("endpoint")
timeout = DEVICE_SYSTEM_CONFIG.get("timeout", 30.0)
min_hops = DEVICE_SYSTEM_CONFIG.get("default_min_hops", 2)
max_hops = DEVICE_SYSTEM_CONFIG.get("default_max_hops", 8)
top_k = DEVICE_SYSTEM_CONFIG.get("default_top_k", 10)
if not endpoint:
result = "其他"
neo4j_cache[device_name_clean] = result
return result
try: try:
from tools.graph_tools import find_system_by_device payload = {
"device_name": device_name.strip(),
"min_hops": min_hops,
"max_hops": max_hops,
"top_k": top_k
}
result_json = await find_system_by_device( async with httpx.AsyncClient(timeout=timeout) as client:
device_name=device_name.strip(), response = await client.post(
ship_number=ship_number, endpoint,
min_hops=2, json=payload,
max_hops=8, headers={"Content-Type": "application/json"}
top_k=10, )
)
if not result_json.get("success", False): if response.status_code != 200:
result = "其他" result = "其他"
neo4j_cache[cache_key] = result neo4j_cache[device_name_clean] = result
return result
systems = result_json.get("systems", [])
if not systems or not isinstance(systems, list):
result = "其他"
neo4j_cache[cache_key] = result
return result
first_system = systems[0]
if isinstance(first_system, dict):
system_name = first_system.get("name") or first_system.get("系统名称") or first_system.get("名称")
if system_name:
result = system_name.strip()
neo4j_cache[cache_key] = result
return result return result
result = "其他" result_json = response.json()
neo4j_cache[cache_key] = result
return result if not result_json.get("success", False):
result = "其他"
neo4j_cache[device_name_clean] = result
return result
systems = result_json.get("systems", [])
if not systems or not isinstance(systems, list):
result = "其他"
neo4j_cache[device_name_clean] = result
return result
first_system = systems[0]
if isinstance(first_system, dict):
system_name = first_system.get("name") or first_system.get("系统名称") or first_system.get("名称")
if system_name:
result = system_name.strip()
neo4j_cache[device_name_clean] = result
return result
result = "其他"
neo4j_cache[device_name_clean] = result
return result
except Exception as e: except Exception as e:
print(f"查询设备系统失败: {str(e)}") print(f"查询设备系统失败: {str(e)}")
result = "其他" result = "其他"
neo4j_cache[cache_key] = result neo4j_cache[device_name_clean] = result
return result return result

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

1076
tools/graph_tools623.py Normal file

File diff suppressed because it is too large Load Diff

1093
tools/graph_tools624.py Normal file

File diff suppressed because it is too large Load Diff

208
tools/ship_model_db528.py Normal file
View File

@ -0,0 +1,208 @@
"""
舷号-型号映射数据库模块
用于管理舰船型号舷号别名舰名的映射关系
数据存储在 PostgreSQL ship_model_mapping 表中
"""
import json
from typing import Dict, List, Any, Optional
from config import POSTGRES_CONNECTION_STRING
async def init_ship_model_mapping_table():
"""
初始化 ship_model_mapping 表并插入初始数据
"""
try:
from psycopg_pool import AsyncConnectionPool
except ImportError:
print("[ship_model_db] psycopg_pool 未安装,无法初始化表")
return
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("""
CREATE TABLE IF NOT EXISTS ship_model_mapping (
id SERIAL PRIMARY KEY,
model_name TEXT NOT NULL UNIQUE,
numbers JSONB NOT NULL DEFAULT '[]',
aliases JSONB NOT NULL DEFAULT '[]',
names JSONB NOT NULL DEFAULT '[]'
)
""")
await cur.execute("""
CREATE INDEX IF NOT EXISTS ship_model_mapping_model_name_idx ON ship_model_mapping(model_name)
""")
print("[ship_model_db] ship_model_mapping 表初始化完成")
# 检查是否已有数据,没有则插入初始数据
await cur.execute("SELECT COUNT(*) FROM ship_model_mapping")
count = (await cur.fetchone())[0]
if count == 0:
initial_data = [
("055型驱逐舰", ["102", "103", "104", "105", "106", "107", "108"],
["055", "055型", "055驱逐舰", "万吨大驱"],
["拉萨舰", "鞍山舰", "无锡舰", "大连舰", "延安舰", "遵义舰", "咸阳舰"]),
("052D型驱逐舰", ["172", "173", "174", "175", "163"],
["052D", "052D型", "052D驱逐舰"],
["昆明舰", "长沙舰", "合肥舰", "银川舰", "南昌舰"]),
("054A型护卫舰", ["529", "530", "547", "568", "570"],
["054A", "054A型", "054A护卫舰"],
["舟山舰", "徐州舰", "临沂舰", "衡阳舰", "黄山舰"]),
]
for model_name, numbers, aliases, names in initial_data:
await cur.execute(
"""
INSERT INTO ship_model_mapping (model_name, numbers, aliases, names)
VALUES (%s, %s, %s, %s)
""",
(model_name,
json.dumps(numbers, ensure_ascii=False),
json.dumps(aliases, ensure_ascii=False),
json.dumps(names, ensure_ascii=False))
)
print(f"[ship_model_db] 已插入 {len(initial_data)} 条初始数据")
async def load_ship_model_mapping() -> Dict[str, Dict[str, List[str]]]:
"""
从数据库加载全部舷号-型号映射数据
Returns:
与原 config.SHIP_MODEL_MAPPING 格式一致的字典
{
"055型驱逐舰": {
"numbers": ["102", ...],
"aliases": ["055", ...],
"names": ["拉萨舰", ...]
},
...
}
"""
try:
from psycopg_pool import AsyncConnectionPool
except ImportError:
print("[ship_model_db] psycopg_pool 未安装,返回空映射")
return {}
try:
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(
"SELECT model_name, numbers, aliases, names FROM ship_model_mapping"
)
rows = await cur.fetchall()
result = {}
for row in rows:
model_name, numbers, aliases, names = row
# JSONB 返回的可能是 list 或 str统一处理
if isinstance(numbers, str):
numbers = json.loads(numbers)
if isinstance(aliases, str):
aliases = json.loads(aliases)
if isinstance(names, str):
names = json.loads(names)
result[model_name] = {
"numbers": numbers,
"aliases": aliases,
"names": names,
}
return result
except Exception as e:
print(f"[ship_model_db] 加载映射数据失败: {str(e)}")
return {}
async def add_ship_model(
model_name: str,
numbers: List[str],
aliases: List[str],
names: List[str]
) -> bool:
"""
添加舰船型号映射
Args:
model_name: 型号名称
numbers: 舷号列表
aliases: 别名列表
names: 舰名列表
Returns:
是否添加成功
"""
try:
from psycopg_pool import AsyncConnectionPool
except ImportError:
print("[ship_model_db] psycopg_pool 未安装,无法添加")
return False
try:
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 ship_model_mapping (model_name, numbers, aliases, names)
VALUES (%s, %s, %s, %s)
ON CONFLICT (model_name) DO UPDATE SET
numbers = EXCLUDED.numbers,
aliases = EXCLUDED.aliases,
names = EXCLUDED.names
""",
(model_name,
json.dumps(numbers, ensure_ascii=False),
json.dumps(aliases, ensure_ascii=False),
json.dumps(names, ensure_ascii=False))
)
print(f"[ship_model_db] 舰船型号 '{model_name}' 已保存")
return True
except Exception as e:
print(f"[ship_model_db] 添加舰船型号失败: {str(e)}")
return False
async def delete_ship_model(model_name: str) -> bool:
"""
删除舰船型号映射
Args:
model_name: 型号名称
Returns:
是否删除成功
"""
try:
from psycopg_pool import AsyncConnectionPool
except ImportError:
print("[ship_model_db] psycopg_pool 未安装,无法删除")
return False
try:
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(
"DELETE FROM ship_model_mapping WHERE model_name = %s",
(model_name,)
)
print(f"[ship_model_db] 舰船型号 '{model_name}' 已删除")
return True
except Exception as e:
print(f"[ship_model_db] 删除舰船型号失败: {str(e)}")
return False

251
tools/test.py Normal file
View File

@ -0,0 +1,251 @@
# -*- coding: utf-8 -*-
import os
import ast
import re
import asyncio
import httpx
from typing import List, Dict
from openai import AsyncOpenAI
from neo4j import GraphDatabase
from neo4j_graphrag.retrievers import HybridCypherRetriever
from neo4j_graphrag.embeddings.base import Embedder
#from modelsAPI.model_api import LocalBgeM3Embeddings, OpenaiAPI
from config import MAP_INS, NEO4J_CONFIG, INDEX_CONFIG, EMBEDDING_CONFIG
from config import LLM_CONFIG, EMBEDDING_CONFIG
# ================== 配置 ==================
URI = NEO4J_CONFIG["uri"]
AUTH = (NEO4J_CONFIG["user"], NEO4J_CONFIG["password"])
NAME_PROPERTY = INDEX_CONFIG["NAME_PROPERTY"]
FULLTEXT_PROPERTY = INDEX_CONFIG["FULLTEXT_PROPERTY"]
EMBEDDING_PROPERTY = INDEX_CONFIG["EMBEDDING_PROPERTY"]
SEARCH_LABEL = INDEX_CONFIG["SEARCH_LABEL"]
VECTOR_INDEX_NAME = INDEX_CONFIG["VECTOR_INDEX_NAME"]
FULLTEXT_INDEX_NAME = INDEX_CONFIG["FULLTEXT_INDEX_NAME"]
map_ins = MAP_INS
# ================== 全局复用driver / embedder 只初始化一次 ==================
_driver = None
_embedder = None
def get_driver():
global _driver
if _driver is None:
_driver = GraphDatabase.driver(URI, auth=AUTH)
return _driver
def get_embedder():
global _embedder
if _embedder is None:
_embedder = LocalBgeM3Embeddings(
base_url=EMBEDDING_CONFIG["base_url_v2"],
api_key=EMBEDDING_CONFIG["api_key"],
)
return _embedder
class LocalBgeM3Embeddings(Embedder):
def __init__(self, base_url: str, api_key: str, model_name: str = "bge-m3"):
self.base_url = base_url.rstrip("/")
self.api_key = api_key
self.model_name = model_name
def embed_query(self, text: str) -> List[float]:
return self.embed_documents([text])[0]
def embed_documents(self, texts: List[str]) -> List[List[float]]:
url = self.base_url + "/embeddings"
with httpx.Client(timeout=60) as client:
response = client.post(
url,
json={"model": self.model_name, "input": texts},
headers={"Authorization": f"Bearer {self.api_key}"},
)
return [item["embedding"] for item in response.json()["data"]]
async def open_api_chat_without_thinking(
query: str = None,
model: str = None,
json_output: bool = False,
system_prompt: str = None,
messages: list = None,
enable_thinking: bool = True,
temperature: float = 0.1
) -> str:
if model is None:
model = LLM_CONFIG["model"]
if system_prompt is None:
system_prompt = "我是一个专业的AI助手请用中文回答问题。请直接给出最终答案不要包含任何推理步骤、思考过程、解释性文字或前缀。"
# 构建消息
message_list = [{"role": "system", "content": system_prompt}]
if messages is not None:
message_list.extend(messages)
if query is not None:
message_list.append({"role": "user", "content": query})
client = AsyncOpenAI(
api_key=LLM_CONFIG["api_key"],
base_url=LLM_CONFIG["base_url"],
)
kwargs = {
"model": model,
"messages": message_list,
"temperature": temperature,
"stream": False,
"max_tokens":LLM_CONFIG["max_tokens"]
}
kwargs["extra_body"] = {"chat_template_kwargs": {"enable_thinking": False}}
if json_output:
kwargs["response_format"] = {"type": "json_object"}
print("model API. history message", message_list)
response = await client.chat.completions.create(**kwargs)
return response.choices[0].message.content
# ================== 辅助:解析 Record 字符串 ==================
def parse_record_string(s: str):
name_match = re.search(r"name='([^']*)'", s)
labels_match = re.search(r"labels=(\[[^\]]*\])", s)
score_match = re.search(r"score=([\d.]+)", s)
name = name_match.group(1) if name_match else "未知名称"
labels = ["未知标签"]
if labels_match:
try:
labels = ast.literal_eval(labels_match.group(1))
except (ValueError, SyntaxError):
pass
score = float(score_match.group(1)) if score_match else 0.0
return name, labels, score
# ================== 构建 Retriever ==================
def build_retriever(sync_driver, embedder: Embedder) -> HybridCypherRetriever:
return HybridCypherRetriever(
driver=sync_driver,
vector_index_name=VECTOR_INDEX_NAME,
fulltext_index_name=FULLTEXT_INDEX_NAME,
embedder=embedder,
retrieval_query=f"""
RETURN
node.`{NAME_PROPERTY}` AS name,
[lbl IN labels(node) WHERE lbl <> '{SEARCH_LABEL}'] AS labels,
score
""",
)
# ================== 同步检索(在线程池中执行,不阻塞事件循环)==================
def _hybrid_search_sync(sentence: str, top_k: int = 10) -> List[Dict]:
retriever = build_retriever(get_driver(), get_embedder())
results_raw = retriever.search(query_text=sentence, top_k=top_k)
hybrid_results: List[Dict] = []
for item in results_raw.items:
content = item.content
name = "未知名称"
labels = ["未知标签"]
score = 0.0
if hasattr(content, "keys"):
name = content.get("name", name)
labels = content.get("labels", labels)
score = content.get("score", score)
elif isinstance(content, str):
if content.startswith("<Record"):
name, labels, score = parse_record_string(content)
else:
try:
parsed = ast.literal_eval(content)
if isinstance(parsed, dict):
name = parsed.get("name", name)
labels = parsed.get("labels", labels)
score = parsed.get("score", score)
except (ValueError, SyntaxError):
name = content
if score == 0.0 and hasattr(item, "metadata") and item.metadata:
score = item.metadata.get("score", score)
hybrid_results.append({
"标签": labels,
"名称": name,
"得分": score,
"来源": "hybrid",
"fulltext_snippet": "",
})
return hybrid_results
# ================== Prompt 模板 ==================
PROMPT_TEMPLATE = """
请根据给出的问题以及问题相关的检索结果以及实体类型和sql表字段映射表筛选出与问题相关的信息并输出
要求
1. 请仔细分析问题和检索结果确定问题中出现的实体类型和sql表字段
2. 输出内容可以为充分分析后的一段文本即你对这个问题中所用到的实体类型和sql表字段的描述
3. 给出的信息尽量精简不能省略任何重要信息
实体类型和sql表字段映射表
"故障模式" : "fault",
"系统" : "system_name",
"设备" : "device_name",
"舷号" : "ship_number",
其中键表示实体类型值表示sql表字段
检索结果
{results}
问题{user_question}
案例1
用户问题为扫气箱内发现润滑油泄漏发生了多少次
检索知识库为
[ 1] score=1.0000 | 活塞杆填料函密封失效导致扫气箱内发现润滑油泄漏 标签=['故障模式']
[ 2] score=0.9298 | 排除喷油器雾化不良故障 标签=['维修项目']
[ 3] score=0.9115 | 执行发动机燃油泄漏应急封堵操作 标签=['操作项目']
[ 4] score=0.9082 | 活塞环磨损或断裂导致机座与机架组件润滑油消耗异常增加 标签=['故障模式']
[ 5] score=0.9058 | 检测气缸套内径磨损量 标签=['维修项目']
[ 6] score=0.9049 | 拆检活塞头磨损情况 标签=['维修项目']
[ 7] score=0.9048 | 检查排气阀杆密封面磨损 标签=['维修项目']
[ 8] score=0.9015 | 润滑油压力骤降导致轴承组件紧急停机 标签=['故障模式']
[ 9] score=0.8985 | 喷油器组件的维修工作 标签=['维修工作']
[10] score=0.8979 | 发动机的安全警告 标签=['安全警告']
输出为
问题中扫气箱内发现润滑油泄漏是一个故障现象属于表中fault字段
"""
SYSTEM_PROMPT = "你是一个专业信息筛选助手,根据给出的问题和问题检索到的相关实体关系,筛选出与问题相关的信息。"
# ================== 主函数(传参调用)==================
async def get_extra_info(user_question: str, top_k: int = 20) -> str:
"""
参数:
user_question: 用户问题
top_k: 检索返回条数
返回:
LLM 筛选结果字符串
"""
# 同步检索放入线程池,不阻塞事件循环
results = await asyncio.to_thread(_hybrid_search_sync, user_question, top_k)
# 构建 prompt 并异步调用 LLM
res = await open_api_chat_without_thinking(
system_prompt=SYSTEM_PROMPT,
query=PROMPT_TEMPLATE.format(results=results, user_question=user_question),
)
return res.strip()
# ================== 入口 ==================
if __name__ == "__main__":
answer = asyncio.run(get_extra_info("发动机这个月发生了多少次故障"))
print(111111111111111111)
print(answer)

View File

@ -148,8 +148,7 @@ async def generate_sql(question: str, current_date: str) -> Dict[str, Any]:
current_date=current_date, current_date=current_date,
extra_info=extra_info or "" extra_info=extra_info or ""
) )
print("输出最终sql的提示词内容")
print(prompt)
try: try:
result = await OpenaiAPI.open_api_chat_without_thinking( result = await OpenaiAPI.open_api_chat_without_thinking(
prompt, prompt,

View File

@ -1,139 +0,0 @@
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)

View File

@ -1,110 +1,20 @@
"""图片处理工具模块。""" """
图片处理工具模块 - 最终版 v4
核心思路最简单直接
1. 找到所有 .jpg/.png 文件名及其周围的 ![图片]( 上下文
2. 不管周围格式如何也不管文件名是否被模型改过
3. 统一提取文件名 -> original_paths 模匹 -> 替换成正确的 ![图片](完整URL)
"""
import re import re
from typing import List, Tuple from typing import List
from difflib import SequenceMatcher from difflib import SequenceMatcher
IMG_EXTENSIONS = r'(?:jpg|jpeg|png|gif|bmp|webp)'
def _dedupe_keep_order(values: List[str]) -> List[str]:
seen = set()
result = []
for value in values:
cleaned = (value or "").strip().strip("`'\",。;;")
if not cleaned:
continue
key = cleaned.lower()
if key in seen:
continue
seen.add(key)
result.append(cleaned)
return result
def extract_image_paths(text: str) -> List[str]: def extract_image_paths(text: str) -> List[str]:
"""从文本中提取图片路径或 URL并保持原始出现顺序。""" """从文本中提取图片路径"""
if not text: pattern = r'images/[a-zA-Z0-9_\-./]+\.(?:jpg|jpeg|png|gif|bmp|webp)'
return [] matches = re.findall(pattern, text, re.IGNORECASE)
return list(set(matches))
patterns = [
# Markdown 图片:![alt](url)
rf'!\[[^\]\n]*\]\(\s*([^)\s]+?\.{IMG_EXTENSIONS}(?:[?#][^)\s]*)?)\s*\)',
# 完整 URL。
rf'(https?://[^\s<>"\'\]\)]+?\.{IMG_EXTENSIONS}(?:[?#][^\s<>"\'\]\)]*)?)',
# API 绝对路径。
rf'(?<![\w:/.%-])(/api/v1/knowledge/files/images/[^\s<>"\'\]\)]+?\.{IMG_EXTENSIONS}(?:[?#][^\s<>"\'\]\)]*)?)',
# 相对 images 路径。
rf'(?<![\w/.-])(images/[A-Za-z0-9_\-./%]+?\.{IMG_EXTENSIONS}(?:[?#][^\s<>"\'\]\)]*)?)',
# 裸文件名,兼容检索结果只给文件名的情况。
rf'(?<![\w/.-])([A-Za-z0-9_-]{{8,}}\.{IMG_EXTENSIONS})(?![\w/.-])',
]
matches: List[Tuple[int, str]] = []
for pattern in patterns:
matches.extend((m.start(1), m.group(1)) for m in re.finditer(pattern, text, re.IGNORECASE))
matches.sort(key=lambda item: item[0])
return _dedupe_keep_order([value for _, value in matches])
def _strip_url_suffix(filename: str) -> str:
return re.sub(r'[?#].*$', '', filename or '').strip()
def _split_filename(filename: str) -> Tuple[str, str]:
filename = _strip_url_suffix(filename).lower()
if "." not in filename:
return filename, ""
stem, ext = filename.rsplit(".", 1)
return stem, ext
def _path_to_image_url(path: str, base_prefix: str) -> str:
cleaned = (path or "").strip()
if cleaned.startswith(("http://", "https://", "/")):
return cleaned
if cleaned.lower().startswith("images/"):
return base_prefix.rstrip("/") + "/" + cleaned
return base_prefix.rstrip("/") + "/images/" + cleaned.split("/")[-1]
def _is_acceptable_image_match(generated_filename: str, original_filename: str, score: float) -> bool:
"""只接受完全一致、明确截断或高度相似的文件名,避免把幻觉图片错配到真实图片。"""
generated_stem, generated_ext = _split_filename(generated_filename)
original_stem, original_ext = _split_filename(original_filename)
if not generated_stem or not original_stem or generated_ext != original_ext:
return False
if generated_stem == original_stem:
return True
if len(generated_stem) >= 12 and original_stem.startswith(generated_stem):
return True
if len(original_stem) >= 12 and generated_stem.startswith(original_stem):
return True
return score >= 0.85
def _remove_untrusted_image_references(text: str) -> str:
"""没有可信原图列表时,删除模型输出的图片标签和裸图片路径。"""
if not text:
return text
result = re.sub(
rf'!\[[^\]\n]*\]\([^\)\n]*\.{IMG_EXTENSIONS}(?:[?#][^\)\n]*)?\)',
'',
text,
flags=re.IGNORECASE,
)
result = re.sub(
rf'(?<![\w/.-])(?:https?://[^\s<>)]+/images/[^\s<>)]+|/api/v1/knowledge/files/images/[^\s<>)]+|images/[^\s<>)]+|[A-Za-z0-9_-]+\.{IMG_EXTENSIONS})(?:[?#][^\s<>)]+)?',
'',
result,
flags=re.IGNORECASE,
)
result = re.sub(r'[ \t]+\n', '\n', result)
result = re.sub(r'\n{3,}', '\n\n', result)
return result.strip()
def find_closest_image_path(generated_path: str, original_paths: List[str]) -> str: def find_closest_image_path(generated_path: str, original_paths: List[str]) -> str:
@ -127,7 +37,7 @@ def find_closest_image_path(generated_path: str, original_paths: List[str]) -> s
best_score = score best_score = score
best_match = orig_path best_match = orig_path
if best_match and _is_acceptable_image_match(generated_filename, best_match.split('/')[-1], best_score): if best_match and best_score > 0.5:
return best_match return best_match
return generated_path return generated_path
@ -142,15 +52,12 @@ def normalize_markdown_images(text: str, base_prefix: str = "/api/v1/knowledge/f
""" """
if not text or original_paths is None: if not text or original_paths is None:
return text return text
trusted_paths = [path for path in original_paths if path]
if not trusted_paths:
return _remove_untrusted_image_references(text)
# ========== 第一轮检查和修改格式包括固定URL前缀 ========== # ========== 第一轮检查和修改格式包括固定URL前缀 ==========
result = _normalize_image_format(text, base_prefix) result = _normalize_image_format(text, base_prefix)
# ========== 第二轮:只检查和替换最后文件名部分 ========== # ========== 第二轮:只检查和替换最后文件名部分 ==========
result = _normalize_image_paths(result, base_prefix, trusted_paths) result = _normalize_image_paths(result, base_prefix, original_paths)
return result return result
@ -164,35 +71,10 @@ def _normalize_image_format(text: str, base_prefix: str) -> str:
if not text: if not text:
return text return text
IMG_EXTENSIONS = r'(?:jpg|jpeg|png|gif|bmp|webp)'
img_pattern = rf'([a-zA-Z0-9_\-./]+)\.({IMG_EXTENSIONS})' img_pattern = rf'([a-zA-Z0-9_\-./]+)\.({IMG_EXTENSIONS})'
result = text result = text
def build_standard_tag(path: str) -> str:
full_url = _path_to_image_url(path, base_prefix)
return f'![图片]({full_url})'
# 先处理已经是 Markdown 图片语法的内容,包含任意 alt 文本。
# 例如:![图2-3 消磁电源柜功能框图](images/xxx.jpg)
markdown_img_pattern = rf'!\[[^\]\n]*\]\(([^)\n]*?\.{IMG_EXTENSIONS})\)'
result = re.sub(
markdown_img_pattern,
lambda m: build_standard_tag(m.group(1)),
result,
flags=re.IGNORECASE
)
# 再兜底清理模型或前序处理产生的嵌套图片语法。
# 例如:![图2-3](![图片](/api/v1/knowledge/files/images/xxx.jpg)
nested_any_alt_pattern = rf'!\[[^\]\n]*\]\([^)\n]*!\[[^\]\n]*\]\(([^)\n]+\.{IMG_EXTENSIONS})\)\)?'
while re.search(nested_any_alt_pattern, result, re.IGNORECASE):
result = re.sub(
nested_any_alt_pattern,
lambda m: build_standard_tag(m.group(1)),
result,
flags=re.IGNORECASE
)
matches = list(re.finditer(img_pattern, result, re.IGNORECASE)) matches = list(re.finditer(img_pattern, result, re.IGNORECASE))
if not matches: if not matches:
@ -226,8 +108,12 @@ def _normalize_image_format(text: str, base_prefix: str) -> str:
if '\n' not in middle and '![图片](' not in middle: if '\n' not in middle and '![图片](' not in middle:
mark_end = file_end + close_paren_pos + 1 mark_end = file_end + close_paren_pos + 1
# 提取纯文件名(去掉可能的路径前缀)
pure_filename = filename_with_ext.split('/')[-1]
# 构建完整的标准格式包括固定URL前缀 # 构建完整的标准格式包括固定URL前缀
standard_format = build_standard_tag(filename_with_ext) full_url = base_prefix.rstrip('/') + '/images/' + pure_filename
standard_format = f'![图片]({full_url})'
result = result[:mark_start] + standard_format + result[mark_end:] result = result[:mark_start] + standard_format + result[mark_end:]
@ -277,7 +163,7 @@ def _normalize_image_paths(text: str, base_prefix: str, original_paths: List[str
best_match = None best_match = None
best_score = 0 best_score = 0
generated_filename = _strip_url_suffix(filename).lower() generated_filename = filename.lower()
for orig_path in original_paths: for orig_path in original_paths:
orig_filename = orig_path.split('/')[-1].lower() orig_filename = orig_path.split('/')[-1].lower()
@ -286,10 +172,11 @@ def _normalize_image_paths(text: str, base_prefix: str, original_paths: List[str
best_score = score best_score = score
best_match = orig_path best_match = orig_path
if best_match and _is_acceptable_image_match(generated_filename, best_match.split('/')[-1], best_score): if best_match and best_score > 0.5:
# 匹配成功替换为正确路径保持固定URL前缀 # 匹配成功替换为正确路径保持固定URL前缀
# 只替换最后文件名部分,保持前缀不变 # 只替换最后文件名部分,保持前缀不变
full_url = _path_to_image_url(best_match, base_prefix) pure_orig_filename = best_match.split('/')[-1]
full_url = base_prefix.rstrip('/') + '/images/' + pure_orig_filename
standard_format = f'![图片]({full_url})' standard_format = f'![图片]({full_url})'
result = result[:tag_start] + standard_format + result[tag_end:] result = result[:tag_start] + standard_format + result[tag_end:]
@ -303,10 +190,12 @@ def _normalize_image_paths(text: str, base_prefix: str, original_paths: List[str
def get_image_prompt_guidance() -> str: def get_image_prompt_guidance() -> str:
"""获取图片格式处理的提示词指导""" """获取图片格式处理的提示词指导"""
return """【图片格式特别要求】 return """【图片格式特别要求】
- 只允许引用本次参考资料中真实出现的图片链接 - 检索结果中的图片链接格式通常为`![图片](/api/v1/knowledge/files/images/xxx.jpg)`
- 输出图片时使用 Markdown 图片标签alt 文本固定为图片括号内必须是参考资料里的原始完整 URL - 输出时必须严格保留此格式不得修改省略或截断
- 图片文件名路径和 URL 前缀必须逐字复制不得补全改写截断转义或根据记忆/示例构造 - 图片的 alt 文本必须保持为图片
- 不要输出提示词中的占位符或示例文件名不要把普通文字描述改写成图片链接 - 图片 URL 必须完整包含 `/api/v1/knowledge/files/images` 前缀
- 图片规则只用于生成图片标签不要围绕图片资源状态输出任何文字说明 - 不要将图片链接转换为纯文本描述
- 多个图片可在对应位置分别引用并保持原始顺序同一图片只引用一次""" - 如果有多个图片在对应位置分别引用保持原始顺序
- 严禁修改图片文件名或路径结构
- 如果需要展示图片直接使用原始的 `![图片](URL)` 格式即可"""

View File

@ -22,15 +22,6 @@ from config import KB_TREE_CONFIG
_kb_name_to_id_cache: Optional[Dict[str, str]] = None _kb_name_to_id_cache: Optional[Dict[str, str]] = None
def _normalize_ship_lookup_text(text: str) -> str:
value = str(text or "").strip().lower()
value = re.sub(r"\s+", "", value)
value = value.replace("", "(").replace("", ")")
for token in ("型驱逐舰", "型护卫舰", "驱逐舰", "护卫舰", "", "", ""):
value = value.replace(token, "")
return value
async def _load_kb_tree() -> Dict[str, str]: async def _load_kb_tree() -> Dict[str, str]:
""" """
从知识库树 API 加载所有知识库返回 {名称: kb_id} 的映射 从知识库树 API 加载所有知识库返回 {名称: kb_id} 的映射
@ -180,26 +171,6 @@ async def _build_ship_mapping_candidates() -> Dict[str, Dict[str, Any]]:
**mapping_info **mapping_info
} }
normalized_ship_name = _normalize_ship_lookup_text(ship_name)
if normalized_ship_name and normalized_ship_name not in candidates:
candidates[normalized_ship_name] = {
"type": "name",
"ship_number": hull_number,
**mapping_info
}
for model, numbers in model_numbers.items():
model_info = {
"type": "model",
"ship_number": None,
"model": model,
"numbers": numbers.copy()
}
candidates[model] = model_info
normalized_model = _normalize_ship_lookup_text(model)
if normalized_model:
candidates[normalized_model] = model_info
return candidates return candidates
@ -235,10 +206,8 @@ async def smart_ship_number_mapping(
user_input = user_input.strip() user_input = user_input.strip()
candidates = await get_ship_mapping_candidates() candidates = await get_ship_mapping_candidates()
normalized_input = _normalize_ship_lookup_text(user_input) if user_input in candidates:
exact_key = user_input if user_input in candidates else normalized_input info = candidates[user_input]
if exact_key in candidates:
info = candidates[exact_key]
match_type = f"exact_{info['type']}" match_type = f"exact_{info['type']}"
model = info["model"] model = info["model"]
numbers = info["numbers"] numbers = info["numbers"]
@ -363,7 +332,7 @@ async def search_with_ship_number_strategy(
else: else:
print(f"步骤1 - 舷号 {matched_ship_number} 无对应知识库,跳过指定舷号检索") print(f"步骤1 - 舷号 {matched_ship_number} 无对应知识库,跳过指定舷号检索")
if match_type in ["exact_number", "exact_name", "embedding", "exact_model"] and model_all_numbers: if match_type in ["exact_number", "exact_name", "embedding"] and model_all_numbers:
send_search_status( send_search_status(
"正在型号检索", "正在型号检索",
f"识别为型号 '{matched_model}',使用该型号所有舷号 {', '.join(model_all_numbers)} 进行检索..." f"识别为型号 '{matched_model}',使用该型号所有舷号 {', '.join(model_all_numbers)} 进行检索..."

View File

@ -0,0 +1,467 @@
"""
舷号搜索策略模块
提供三级搜索策略
1. 指定舷号搜索
2. 同型号所有舷号搜索
3. 全局搜索
新增功能
- 智能舷号映射支持舷号型号舰名的智能识别和映射
"""
from typing import List, Dict, Any, Optional, Tuple
import random
import re
import httpx
from utils.function_tracker import get_all_callbacks
from tools.ship_model_db import load_ship_model_mapping
from tools.rag_tools import rag_search
from modelsAPI.model_api import OpenaiAPI
from config import KB_TREE_CONFIG
# ==================== 舷号 -> kb_id 映射 ====================
_kb_name_to_id_cache: Optional[Dict[str, str]] = None
async def _load_kb_tree() -> Dict[str, str]:
"""
从知识库树 API 加载所有知识库返回 {名称: kb_id} 的映射
递归遍历树结构带缓存只请求一次
"""
global _kb_name_to_id_cache
if _kb_name_to_id_cache is not None:
return _kb_name_to_id_cache
url = KB_TREE_CONFIG.get("url", "")
if not url:
print("[ship_number_search] KB_TREE_CONFIG.url 未配置")
return {}
headers = {
"accept": "application/json",
"x-user-id": "1",
"x-user-name": "testuser",
"x-role": "admin",
}
try:
async with httpx.AsyncClient(timeout=30.0, verify=False) as client:
response = await client.get(url, headers=headers)
if response.status_code != 200:
print(f"[ship_number_search] 知识库树 API 请求失败: HTTP {response.status_code}")
return {}
result = response.json()
# API 返回的是列表,每个元素可能有 children
if isinstance(result, list):
top_nodes = result
elif isinstance(result, dict):
kb_data = result.get("data") or result.get("list") or {}
top_nodes = kb_data.get("children", []) if isinstance(kb_data, dict) else []
else:
top_nodes = []
# 递归收集所有知识库
name_to_id = {}
def _collect(nodes):
for node in nodes:
kb_name = node.get("name") or ""
kb_id = str(node.get("id", "")) if node.get("id") is not None else ""
if kb_name and kb_id:
name_to_id[kb_name] = kb_id
children = node.get("children", [])
if children:
_collect(children)
_collect(top_nodes)
_kb_name_to_id_cache = name_to_id
return name_to_id
except Exception as e:
print(f"[ship_number_search] 加载知识库树失败: {str(e)}")
return {}
async def resolve_kb_id(ship_number: str) -> Optional[str]:
"""
根据舷号从知识库树中查找对应的真实 kb_id
匹配策略在知识库名称中查找包含该舷号的条目
例如 ship_number="163"知识库名称为 "163舰资料"则匹配成功
Args:
ship_number: 舷号 "163"
Returns:
匹配到的 kb_id未找到返回 None
"""
if not ship_number:
return None
name_to_id = await _load_kb_tree()
# 优先精确匹配:名称就是舷号本身
if ship_number in name_to_id:
return name_to_id[ship_number]
# 其次:名称包含该舷号(确保是完整数字,避免 "16" 匹配到 "163"
for kb_name, kb_id in name_to_id.items():
# 用正则匹配完整数字,确保舷号作为独立数字出现在名称中
if re.search(rf'(?<!\d){re.escape(ship_number)}(?!\d)', kb_name):
return kb_id
return None
async def get_ship_numbers_by_model(ship_number: str) -> list:
"""
根据舷号获取同型号的所有舷号
Args:
ship_number: 舷号
Returns:
list: 同型号的所有舷号列表如果未找到则返回只包含输入舷号的列表
"""
ship_number = str(ship_number).strip()
ship_model_mapping = await load_ship_model_mapping()
for model, info in ship_model_mapping.items():
numbers = info.get("numbers", [])
if ship_number in numbers:
return numbers.copy()
return [ship_number]
async def _build_ship_mapping_candidates() -> Dict[str, Dict[str, Any]]:
"""
构建舷号映射候选词库
舰名与舷号按位置一一对应 names[0] 对应 numbers[0]
"""
candidates = {}
ship_model_mapping = await load_ship_model_mapping()
for model_name, info in ship_model_mapping.items():
numbers = info.get("numbers", [])
aliases = info.get("aliases", [])
names = info.get("names", [])
mapping_info = {
"model": model_name,
"numbers": numbers.copy()
}
for num in numbers:
candidates[str(num)] = {
"type": "number",
"ship_number": str(num),
**mapping_info
}
for alias in aliases:
if alias:
candidates[alias] = {
"type": "alias",
"ship_number": None,
**mapping_info
}
for idx, name in enumerate(names):
if name:
ship_num = str(numbers[idx]) if idx < len(numbers) else None
candidates[name] = {
"type": "name",
"ship_number": ship_num,
**mapping_info
}
return candidates
_SHIP_MAPPING_CANDIDATES = None
async def get_ship_mapping_candidates() -> Dict[str, Dict[str, Any]]:
"""
获取舷号映射候选词库带缓存
"""
global _SHIP_MAPPING_CANDIDATES
if _SHIP_MAPPING_CANDIDATES is None:
_SHIP_MAPPING_CANDIDATES = await _build_ship_mapping_candidates()
return _SHIP_MAPPING_CANDIDATES
async def smart_ship_number_mapping(
user_input: str,
similarity_threshold: float = 0.8
) -> Tuple[Optional[str], Optional[str], Optional[List[str]], str]:
"""
智能舷号映射函数
将用户输入的舷号/型号/舰名映射到准确的舷号或型号
Returns:
Tuple: (matched_ship_number, matched_model, all_numbers, match_type)
- matched_ship_number: 匹配到的具体舷号舰名也会映射到对应舷号
- matched_model: 匹配到的型号名称
- all_numbers: 该型号下的所有舷号列表
- match_type: 匹配类型 ("exact_number", "exact_alias", "exact_name", "embedding", "none")
"""
if not user_input or not user_input.strip():
return None, None, None, "none"
user_input = user_input.strip()
candidates = await get_ship_mapping_candidates()
if user_input in candidates:
info = candidates[user_input]
match_type = f"exact_{info['type']}"
model = info["model"]
numbers = info["numbers"]
ship_number = info.get("ship_number")
return ship_number, model, numbers, match_type
try:
candidate_texts = list(candidates.keys())
query_embedding = await OpenaiAPI.get_embeddings_async(user_input)
candidate_embeddings = await OpenaiAPI.get_embeddings_batch_async(candidate_texts)
best_score = -1
best_idx = -1
for idx, emb in enumerate(candidate_embeddings):
score = OpenaiAPI.cosine_similarity(query_embedding, emb)
if score > best_score:
best_score = score
best_idx = idx
if best_score >= similarity_threshold and best_idx >= 0:
matched_text = candidate_texts[best_idx]
info = candidates[matched_text]
model = info["model"]
numbers = info["numbers"]
ship_number = info.get("ship_number")
print(f"[智能映射] 用户输入 '{user_input}' -> 匹配到 '{matched_text}' (相似度: {best_score:.3f}, 类型: {info['type']})")
return ship_number, model, numbers, "embedding"
except Exception as e:
print(f"[智能映射] Embedding 匹配失败: {str(e)}")
return None, None, None, "none"
def send_search_status(title: str, details: str):
"""发送搜索状态事件"""
title_options = [f"🔍 {title}", f"🔎 {title}", f"📚 {title}"]
start_event = {
"type": "function_execution",
"title": random.choice(title_options),
"details": details
}
for callback in get_all_callbacks():
try:
callback(start_event)
except Exception:
pass
def convert_rag_result(rag_result):
"""将 rag_search 的返回结果转换为原来的格式"""
if not rag_result or not rag_result.get("success", False):
return []
source_citation = rag_result.get("sourceCitation", {})
flat_results = []
for resource, items in source_citation.items():
for item in items:
flat_results.append(item)
return flat_results
async def search_with_ship_number_strategy(
base_query: str,
ship_number: str,
top_k: int = 5,
search_type: str = "fault"
) -> Tuple[List[Dict[str, Any]], str, str, str]:
"""
使用舷号三级搜索策略进行RAG检索
返回: (rag_results, matched_kb_name, matched_kb_id, matched_ship_number)
matched_ship_number: 映射后的舷号未映射到具体舷号时为空字符串
"""
rag_results = []
matched_kb_name = ""
matched_kb_id = ""
matched_ship_number = None
matched_model = None
model_all_numbers = None
match_type = "none"
if ship_number and ship_number.strip():
send_search_status(
"正在智能识别",
f"识别 '{ship_number}' 的含义(舷号/型号/舰名)..."
)
matched_ship_number, matched_model, model_all_numbers, match_type = await smart_ship_number_mapping(ship_number)
if match_type != "none":
print(f"[智能映射结果] 输入='{ship_number}' -> 舷号={matched_ship_number}, 型号={matched_model}, 所有舷号={model_all_numbers}, 匹配类型={match_type}")
else:
print(f"[智能映射结果] 输入='{ship_number}' -> 未找到匹配,将使用原始值进行检索")
if match_type in ["exact_number", "exact_name", "embedding"] and matched_ship_number:
real_kb_id = await resolve_kb_id(matched_ship_number)
if real_kb_id:
send_search_status(
"正在指定舷号检索",
f"使用舷号 {matched_ship_number} 进行检索..."
)
rag_query = f"{base_query}"
try:
rag_result_raw = await rag_search(rag_query, top_k=top_k, kb_id=real_kb_id)
rag_results = convert_rag_result(rag_result_raw)
if rag_results:
print(f"步骤1 - 舷号 {matched_ship_number} 检索完成,返回 {len(rag_results)} 条结果")
for item in rag_results:
if isinstance(item, dict):
found_kb_name = item.get("kb_name", "")
found_kb_id = item.get("kb_id", "")
if found_kb_name and not matched_kb_name:
matched_kb_name = str(found_kb_name).strip()
if found_kb_id and not matched_kb_id:
matched_kb_id = str(found_kb_id).strip()
return rag_results, matched_kb_name, matched_kb_id, matched_ship_number or ""
except Exception as e:
print(f"步骤1 - 舷号 {matched_ship_number} 检索失败: {str(e)}")
else:
print(f"步骤1 - 舷号 {matched_ship_number} 无对应知识库,跳过指定舷号检索")
if match_type in ["exact_number", "exact_alias", "exact_name", "embedding"] and model_all_numbers:
send_search_status(
"正在型号检索",
f"识别为型号 '{matched_model}',使用该型号所有舷号 {', '.join(model_all_numbers)} 进行检索..."
)
for num in model_all_numbers:
rag_query = f"{base_query}"
try:
real_kb_id = await resolve_kb_id(num)
if not real_kb_id:
continue
rag_result_raw = await rag_search(rag_query, top_k=top_k, kb_id=real_kb_id)
current_results = convert_rag_result(rag_result_raw)
if current_results:
rag_results.extend(current_results)
print(f"步骤2 - 舷号 {num} 检索完成,返回 {len(current_results)} 条结果")
if not matched_kb_name:
for item in current_results:
if isinstance(item, dict):
found_kb_name = item.get("kb_name", "")
found_kb_id = item.get("kb_id", "")
if found_kb_name and not matched_kb_name:
matched_kb_name = str(found_kb_name).strip()
if found_kb_id and not matched_kb_id:
matched_kb_id = str(found_kb_id).strip()
except Exception as e:
print(f"步骤2 - 舷号 {num} 检索失败: {str(e)}")
if rag_results:
return rag_results, matched_kb_name, matched_kb_id, matched_ship_number or ""
if ship_number and ship_number.strip() and match_type == "none":
real_kb_id = await resolve_kb_id(ship_number)
if real_kb_id:
send_search_status(
"正在指定舰名检索",
f"使用舰名 {ship_number} 进行检索..."
)
rag_query = f"{base_query}"
try:
rag_result_raw = await rag_search(rag_query, top_k=top_k, kb_id=real_kb_id)
rag_results = convert_rag_result(rag_result_raw)
if rag_results:
print(f"步骤1 - 舷号 {ship_number} 检索完成,返回 {len(rag_results)} 条结果")
for item in rag_results:
if isinstance(item, dict):
found_kb_name = item.get("kb_name", "")
found_kb_id = item.get("kb_id", "")
if found_kb_name and not matched_kb_name:
matched_kb_name = str(found_kb_name).strip()
if found_kb_id and not matched_kb_id:
matched_kb_id = str(found_kb_id).strip()
return rag_results, matched_kb_name, matched_kb_id, matched_ship_number or ""
except Exception as e:
print(f"步骤1 - 舷号 {ship_number} 检索失败: {str(e)}")
else:
print(f"步骤1 - 舷号 {ship_number} 无对应知识库,跳过指定舰名检索")
model_ship_numbers = await get_ship_numbers_by_model(ship_number)
if len(model_ship_numbers) > 1 or (len(model_ship_numbers) == 1 and ship_number not in model_ship_numbers):
send_search_status(
"正在同型号检索",
f"使用同型号舷号 {', '.join(model_ship_numbers)} 进行检索..."
)
for num in model_ship_numbers:
if num == ship_number:
continue
rag_query = f"{base_query}"
try:
real_kb_id = await resolve_kb_id(num)
if not real_kb_id:
continue
rag_result_raw = await rag_search(rag_query, top_k=top_k, kb_id=real_kb_id)
current_results = convert_rag_result(rag_result_raw)
if current_results:
rag_results.extend(current_results)
print(f"步骤2 - 舷号 {num} 检索完成,返回 {len(current_results)} 条结果")
if not matched_kb_name:
for item in current_results:
if isinstance(item, dict):
found_kb_name = item.get("kb_name", "")
found_kb_id = item.get("kb_id", "")
if found_kb_name and not matched_kb_name:
matched_kb_name = str(found_kb_name).strip()
if found_kb_id and not matched_kb_id:
matched_kb_id = str(found_kb_id).strip()
except Exception as e:
print(f"步骤2 - 舷号 {num} 检索失败: {str(e)}")
if rag_results:
return rag_results, matched_kb_name, matched_kb_id, matched_ship_number or ""
send_search_status(
"正在全局检索",
"未找到舷号相关资料,进行全局检索..."
)
try:
rag_result_raw = await rag_search(base_query, top_k=top_k)
rag_results = convert_rag_result(rag_result_raw)
if rag_results:
print(f"步骤3 - 全局检索完成,返回 {len(rag_results)} 条结果")
for item in rag_results:
if isinstance(item, dict):
found_kb_name = item.get("kb_name", "")
found_kb_id = item.get("kb_id", "")
if found_kb_name and not matched_kb_name:
matched_kb_name = str(found_kb_name).strip()
if found_kb_id and not matched_kb_id:
matched_kb_id = str(found_kb_id).strip()
except Exception as e:
print(f"步骤3 - 全局检索失败: {str(e)}")
rag_results = []
return rag_results, matched_kb_name, matched_kb_id, matched_ship_number or ""

View File

@ -1,71 +0,0 @@
"""Utilities for accumulating RAG source citations across multiple searches."""
import json
from typing import Any, Dict
def _citation_item_key(item: Any) -> str:
"""Build a stable identity for one RAG citation chunk."""
if not isinstance(item, dict):
return json.dumps(item, ensure_ascii=False, sort_keys=True, default=str)
chunk_id = item.get("id")
if chunk_id not in (None, ""):
return f"id:{chunk_id}"
location = {
"file_id": item.get("file_id"),
"page_idx": item.get("page_idx"),
"positions": item.get("positions"),
}
if any(value not in (None, "", []) for value in location.values()):
return "location:" + json.dumps(
location, ensure_ascii=False, sort_keys=True, default=str
)
# index/score may differ between rewritten queries and do not identify a source.
fallback = {
key: value
for key, value in item.items()
if key not in {"index", "score", "text"}
}
return "fallback:" + json.dumps(
fallback, ensure_ascii=False, sort_keys=True, default=str
)
def merge_rag_source_citations(
accumulated: Dict[str, Any], incoming: Any
) -> Dict[str, Any]:
"""
Append a RAG call's citations without losing earlier calls or duplicating chunks.
Citation text is intentionally removed here because the stream response has never
exposed it; the full text remains available inside the workflow for answer generation.
"""
if not isinstance(incoming, dict) or not incoming:
return accumulated
for doc_name, chunks in incoming.items():
if isinstance(chunks, list):
target = accumulated.setdefault(doc_name, [])
if not isinstance(target, list):
# Be defensive about malformed/legacy data while preserving both values.
target = [target]
accumulated[doc_name] = target
seen = {_citation_item_key(item) for item in target}
for item in chunks:
sanitized_item = (
{key: value for key, value in item.items() if key != "text"}
if isinstance(item, dict)
else item
)
item_key = _citation_item_key(sanitized_item)
if item_key not in seen:
target.append(sanitized_item)
seen.add(item_key)
elif doc_name not in accumulated:
accumulated[doc_name] = chunks
return accumulated

View File

@ -356,22 +356,11 @@ class MarkdownWordGenerator:
def _get_http_headers(url: str) -> dict: def _get_http_headers(url: str) -> dict:
headers = {} headers = {}
if "/api/v1/knowledge/" in url: if "/api/v1/knowledge/" in url:
def add_header(key: str, value: object, fallback: str = None):
value = str(value if value is not None else fallback or "")
if not value:
return
try:
value.encode("latin-1")
except UnicodeEncodeError:
logger.warning(f"跳过非 latin-1 请求头 {key}: {value}")
return
headers[key] = value
try: try:
from config import RAG_CONFIG from config import RAG_CONFIG
add_header("x-user-id", RAG_CONFIG.get("x-user-id", "1"), "1") headers["x-user-id"] = str(RAG_CONFIG.get("x-user-id", "1"))
add_header("x-user-name", RAG_CONFIG.get("x-user-name", "testuser"), "testuser") headers["x-user-name"] = str(RAG_CONFIG.get("x-user-name", "testuser"))
add_header("x-role", RAG_CONFIG.get("x-role", "admin"), "admin") headers["x-role"] = str(RAG_CONFIG.get("x-role", "admin"))
except Exception: except Exception:
headers["x-user-id"] = "1" headers["x-user-id"] = "1"
headers["x-user-name"] = "testuser" headers["x-user-name"] = "testuser"
@ -799,4 +788,3 @@ if __name__ == "__main__":
print(f"程序执行出错: {e}") print(f"程序执行出错: {e}")
import traceback import traceback
traceback.print_exc() traceback.print_exc()

716
utils/word_generator518.py Normal file
View File

@ -0,0 +1,716 @@
import os
import re
import uuid
import logging
import tempfile
from datetime import datetime
from docx import Document
from docx.shared import Pt, RGBColor, Inches
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.oxml.ns import qn
from lxml import etree
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
logger = logging.getLogger(__name__)
_MML2OMML_XSLT = None
def _load_mml2omml_xslt():
global _MML2OMML_XSLT
if _MML2OMML_XSLT is not None:
return _MML2OMML_XSLT
search_paths = [
r"C:\Program Files\Microsoft Office\root\Office16\MML2OMML.XSL",
r"C:\Program Files (x86)\Microsoft Office\root\Office16\MML2OMML.XSL",
r"C:\Program Files\Microsoft Office\Office16\MML2OMML.XSL",
r"C:\Program Files (x86)\Microsoft Office\Office16\MML2OMML.XSL",
"/usr/share/mml2omml/MML2OMML.XSL",
]
for path in search_paths:
if os.path.isfile(path):
_MML2OMML_XSLT = etree.parse(path)
logger.info(f"加载 MML2OMML.XSL 成功: {path}")
return _MML2OMML_XSLT
logger.warning("未找到 MML2OMML.XSL公式将使用图片回退方式渲染")
return None
class MarkdownWordGenerator:
def __init__(self):
try:
self.doc = Document()
self.cache_dir = os.path.join(tempfile.gettempdir(), "latex_formula_cache")
os.makedirs(self.cache_dir, exist_ok=True)
self.formula_cache = {}
self._omml_available = _load_mml2omml_xslt() is not None
logger.info("MarkdownWordGenerator 初始化成功")
except Exception as e:
logger.error(f"初始化 MarkdownWordGenerator 失败: {e}")
raise
def set_font(self, run, size=11, bold=False):
try:
run.font.name = "微软雅黑"
rPr = run._element.get_or_add_rPr()
rFonts = rPr.find(qn('w:rFonts'))
if rFonts is None:
rFonts = run._element.makeelement(qn('w:rFonts'), {})
rPr.insert(0, rFonts)
rFonts.set(qn('w:eastAsia'), "微软雅黑")
run.font.size = Pt(size)
run.font.bold = True if bold else False
except Exception as e:
logger.warning(f"设置字体时出错: {e}")
def add_heading(self, text, level):
try:
p = self.doc.add_heading(level=level)
size_map = {1: 18, 2: 16, 3: 14}
font_size = size_map.get(level, 12)
self._add_rich_text(p, text, default_size=font_size, default_bold=True)
except Exception as e:
logger.error(f"添加标题失败 '{text}': {e}")
def add_paragraph(self, text):
try:
if not text.strip():
return
p = self.doc.add_paragraph()
indent_chars = 0
temp_text = text
while temp_text.startswith('\u3000'):
indent_chars += 1
temp_text = temp_text[1:]
if indent_chars >= 2:
p.paragraph_format.first_line_indent = Inches(0.5)
text = temp_text
elif indent_chars == 1:
p.paragraph_format.first_line_indent = Inches(0.25)
text = temp_text
self._add_rich_text(p, text)
except Exception as e:
logger.error(f"添加段落失败: {e}")
def _add_rich_text(self, paragraph, text, default_size=11, default_bold=False):
if not text:
return
pattern = r'\*\*(.+?)\*\*'
last_end = 0
for match in re.finditer(pattern, text):
start, end = match.span()
if start > last_end:
plain = text[last_end:start]
if plain:
run = paragraph.add_run(plain)
self.set_font(run, size=default_size, bold=default_bold)
bold_content = match.group(1)
if bold_content:
run = paragraph.add_run(bold_content)
self.set_font(run, size=default_size, bold=True)
last_end = end
if last_end < len(text):
remaining = text[last_end:]
if remaining:
run = paragraph.add_run(remaining)
self.set_font(run, size=default_size, bold=default_bold)
@staticmethod
def _preprocess_latex_for_omml(latex: str) -> str:
replacements = {
r'\leqslant': r'\leq',
r'\geqslant': r'\geq',
r'\nleqslant': r'\nleq',
r'\ngeqslant': r'\ngeq',
r'\eqslantless': r'\leq',
r'\eqslantgtr': r'\geq',
r'\varnothing': r'\emptyset',
r'\dfrac': r'\frac',
r'\tfrac': r'\frac',
r'\boldsymbol': r'\mathbf',
r'\bm': r'\mathbf',
}
for old, new in replacements.items():
latex = latex.replace(old, new)
latex = re.sub(r'\\mathrm\s*\{([^}]*)\}', lambda m: r'\mathrm{' + m.group(1) + '}', latex)
latex = re.sub(r'\\text\s*\{([^}]*)\}', lambda m: r'\mathrm{' + m.group(1) + '}', latex)
latex = re.sub(r'\\textbf\s*\{([^}]*)\}', lambda m: r'\mathbf{' + m.group(1) + '}', latex)
latex = re.sub(r'\\textit\s*\{([^}]*)\}', lambda m: r'\mathit{' + m.group(1) + '}', latex)
return latex
def _latex_to_omml(self, latex_str):
try:
import latex2mathml.converter
latex_str = self._preprocess_latex_for_omml(latex_str)
mathml = latex2mathml.converter.convert(latex_str)
mathml_tree = etree.fromstring(mathml.encode('utf-8'))
xslt = _load_mml2omml_xslt()
if xslt is None:
return None
transform = etree.XSLT(xslt)
omml_tree = transform(mathml_tree)
return omml_tree.getroot()
except Exception as e:
logger.error(f"LaTeX->OMML 转换失败 '{latex_str}': {e}")
return None
def _add_omml_to_paragraph(self, paragraph, latex_str):
omml = self._latex_to_omml(latex_str)
if omml is not None:
paragraph._element.append(omml)
return True
return False
def _add_rich_text_with_formulas(self, paragraph, text, default_size=11, default_bold=False):
if not text:
return
has_formula = bool(re.search(r'\$[^$]+?\$', text))
has_image = bool(self._extract_image_urls(text))
if not has_formula and not has_image:
self._add_rich_text(paragraph, text, default_size=default_size, default_bold=default_bold)
return
if has_image:
img_urls = self._extract_image_urls(text)
last_end = 0
for start, end, url in img_urls:
before_text = text[last_end:start]
if before_text and before_text.strip():
self._add_rich_text_with_formulas(paragraph, before_text, default_size=default_size, default_bold=default_bold)
self.add_image(url, width_inches=4.5)
last_end = end
after_text = text[last_end:]
if after_text and after_text.strip():
p = self.doc.add_paragraph()
self._add_rich_text_with_formulas(p, after_text, default_size=default_size, default_bold=default_bold)
return
segments = re.split(r'(\$[^$]+?\$)', text)
for segment in segments:
if not segment:
continue
if segment.startswith('$') and segment.endswith('$') and len(segment) > 2:
latex_content = segment[1:-1].strip()
if self._omml_available and self._add_omml_to_paragraph(paragraph, latex_content):
pass
else:
run = paragraph.add_run(segment)
self.set_font(run, size=default_size, bold=default_bold)
else:
self._add_rich_text(paragraph, segment, default_size=default_size, default_bold=default_bold)
def add_list(self, text, numbered=False, number=None):
try:
text = text.strip()
if not text:
return
if re.match(r'^[-_*]{2,}$', text):
return
if numbered and number is not None:
p = self.doc.add_paragraph()
p.paragraph_format.left_indent = Pt(18)
p.paragraph_format.first_line_indent = Pt(-18)
num_run = p.add_run(f"{number}. ")
self.set_font(num_run, bold=False)
self._add_rich_text_with_formulas(p, text)
else:
p = self.doc.add_paragraph(style="List Bullet")
self._add_rich_text_with_formulas(p, text)
except Exception as e:
logger.error(f"添加列表失败: {e}")
def add_sub_list(self, text):
try:
text = text.strip()
if not text:
return
if re.match(r'^[-_*]{2,}$', text):
return
try:
p = self.doc.add_paragraph(style="List Bullet 2")
except KeyError:
p = self.doc.add_paragraph(style="List Bullet")
p.paragraph_format.left_indent = Pt(36)
self._add_rich_text_with_formulas(p, text)
except Exception as e:
logger.error(f"添加子列表失败: {e}")
def _convert_url_to_local_path(self, url: str) -> str:
if not url:
return None
from urllib.parse import urlparse
path = urlparse(url).path
if path.startswith('/picture/'):
docker_path = f"/app/picture/{path[len('/picture/'):]}"
if os.path.isfile(docker_path):
return docker_path
try:
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
local_path = os.path.join(project_root, "picture", path[len('/picture/'):])
if os.path.isfile(local_path):
return local_path
except Exception:
pass
return None
return None
@staticmethod
def _get_http_headers(url: str) -> dict:
headers = {}
if "/api/v1/knowledge/" in url:
try:
from config import RAG_CONFIG
headers["x-user-id"] = str(RAG_CONFIG.get("x-user-id", "1"))
headers["x-user-name"] = str(RAG_CONFIG.get("x-user-name", "testuser"))
headers["x-role"] = str(RAG_CONFIG.get("x-role", "admin"))
except Exception:
headers["x-user-id"] = "1"
headers["x-user-name"] = "testuser"
headers["x-role"] = "admin"
return headers
def add_image(self, url_or_path: str, width_inches: float = 5.5):
if not url_or_path:
return
try:
import io
image_stream = None
if os.path.isfile(url_or_path):
image_stream = url_or_path
elif url_or_path.startswith("http://") or url_or_path.startswith("https://"):
local_path = self._convert_url_to_local_path(url_or_path)
if local_path and os.path.isfile(local_path):
image_stream = local_path
else:
import requests
headers = self._get_http_headers(url_or_path)
resp = requests.get(url_or_path, timeout=15, headers=headers, proxies={"http": None, "https": None})
resp.raise_for_status()
content_type = resp.headers.get("Content-Type", "")
logger.info(f"HTTP 下载完成, Content-Type: {content_type}, 大小: {len(resp.content)} bytes")
if not content_type.startswith("image/"):
logger.error(f"响应不是图片类型: {content_type}")
self.add_paragraph(f"[图片加载失败: {url_or_path}]")
return
image_stream = io.BytesIO(resp.content)
else:
logger.warning(f"图片路径无效: {url_or_path}")
return
from PIL import Image as PilImage
if isinstance(image_stream, str):
pil_img = PilImage.open(image_stream)
else:
pil_img = PilImage.open(image_stream)
png_stream = io.BytesIO()
pil_img.convert("RGBA" if pil_img.mode in ("RGBA", "P") else "RGB").save(png_stream, format="PNG")
png_stream.seek(0)
p = self.doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = p.add_run()
run.add_picture(png_stream, width=Inches(width_inches))
logger.info(f"图片插入成功: {url_or_path}")
except Exception as e:
logger.error(f"插入图片失败 '{url_or_path}': {e!r}", exc_info=True)
self.add_paragraph(f"[图片加载失败: {url_or_path}]")
def _extract_image_urls(self, line_content):
"""
从一行文本中提取所有图片URL支持多种格式
- ![alt](url)
- ![alt]`url`
- ! `url`
返回 [(start, end, url), ...]
"""
results = []
# 格式1: ![alt](url)
for m in re.finditer(r'!\[([^\]]*)\]\(([^)]+)\)', line_content):
url = m.group(2).strip()
if url.startswith('`') and url.endswith('`'):
url = url[1:-1]
results.append((m.start(), m.end(), url))
# 格式2: ![alt]`url` (未被格式1匹配的)
for m in re.finditer(r'!\[([^\]]*)\]\s*`([^`]+)`', line_content):
url = m.group(2).strip()
already = any(s <= m.start() and e >= m.end() for s, e, _ in results)
if not already:
results.append((m.start(), m.end(), url))
# 格式3: ! `url` (模型常见的破损格式)
for m in re.finditer(r'!\s*`([^`]+\.(?:jpg|jpeg|png|gif|bmp|webp))`', line_content, re.IGNORECASE):
url = m.group(1).strip()
already = any(s <= m.start() and e >= m.end() for s, e, _ in results)
if not already:
results.append((m.start(), m.end(), url))
results.sort(key=lambda x: x[0])
return results
def add_table(self, lines):
try:
rows = []
for line in lines:
line = line.strip("|")
cells = [c.strip() for c in line.split("|")]
rows.append(cells)
if not rows:
return
header = rows[0]
data_start_index = 1
if len(rows) > 1 and all(c.startswith('-') or c == '' for c in rows[1]):
data_start_index = 2
data = rows[data_start_index:]
max_cols = len(header)
table = self.doc.add_table(rows=len(data) + 1, cols=max_cols)
table.style = "Table Grid"
for i, cell in enumerate(header):
if i < max_cols:
table.rows[0].cells[i].text = cell
for r, row in enumerate(data):
for c, val in enumerate(row):
if c < max_cols:
table.rows[r + 1].cells[c].text = val
logger.info("表格添加成功")
except Exception as e:
logger.error(f"添加表格失败: {e}")
def _add_horizontal_rule(self):
p = self.doc.add_paragraph()
pPr = p._element.get_or_add_pPr()
pBdr = p._element.makeelement(qn('w:pBdr'), {})
bottom = p._element.makeelement(qn('w:bottom'), {
qn('w:val'): 'single',
qn('w:sz'): '6',
qn('w:space'): '1',
qn('w:color'): 'CCCCCC',
})
pBdr.append(bottom)
pPr.append(pBdr)
def parse(self, markdown):
lines = markdown.split("\n")
table_buffer = []
block_formula = []
in_block_formula = False
i = 0
while i < len(lines):
line = lines[i]
line_stripped = line.rstrip()
line_content = line_stripped.lstrip()
# 1. 处理块级公式
if line_content.startswith("$$"):
if not in_block_formula:
remaining = line_content[2:]
close_idx = remaining.find("$$")
if close_idx >= 0:
formula = remaining[:close_idx].strip()
after_formula = remaining[close_idx + 2:].strip()
p = self.doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
if not (self._omml_available and self._add_omml_to_paragraph(p, formula)):
self.add_paragraph(f"[公式: {formula}]")
if after_formula:
self.add_paragraph(after_formula)
else:
in_block_formula = True
block_formula = []
if remaining.strip():
block_formula.append(remaining.strip())
else:
remaining = line_content[2:]
in_block_formula = False
formula = "".join(block_formula).strip()
p = self.doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
if not (self._omml_available and self._add_omml_to_paragraph(p, formula)):
self.add_paragraph(f"[公式: {formula}]")
if remaining.strip():
self.add_paragraph(remaining.strip())
i += 1
continue
if in_block_formula:
block_formula.append(line)
i += 1
continue
# 2. 处理表格
if line_content.startswith("|") and line_content.endswith("|"):
table_buffer.append(line_stripped)
i += 1
continue
else:
if table_buffer:
self.add_table(table_buffer)
table_buffer = []
# 空行跳过
if not line_stripped:
i += 1
continue
# 3. 水平分隔线
if re.match(r'^\s*[-*_]{3,}\s*$', line_stripped):
self._add_horizontal_rule()
i += 1
continue
# 4. 标题
heading_match = re.match(r'^(#{1,3})\s+(.*)', line_content)
if heading_match:
level = len(heading_match.group(1))
text = heading_match.group(2)
self.add_heading(text, level)
i += 1
continue
# 5. 有序列表(带子列表支持)
list_match_ordered = re.match(r'^(\d+)\.\s*(.*)', line_content)
if list_match_ordered:
number = int(list_match_ordered.group(1))
text = list_match_ordered.group(2)
self.add_list(text, numbered=True, number=number)
i += 1
while i < len(lines):
next_line = lines[i]
next_stripped = next_line.rstrip()
sub_match = re.match(r'^[\s\t]+[-*]\s*(.*)', next_stripped)
if sub_match:
sub_text = sub_match.group(1)
self.add_sub_list(sub_text)
i += 1
elif (next_stripped.startswith(' ') or next_stripped.startswith('\t')):
sub_content = next_stripped.strip()
if sub_content and not re.match(r'^\d+\.\s', sub_content):
self.add_sub_list(sub_content)
i += 1
else:
break
else:
break
continue
# 6. 无序列表
is_bold_line = line_content.startswith('**')
if not is_bold_line:
list_match_unordered = re.match(r'^\s*[-*]\s+(.*)', line_stripped)
if not list_match_unordered:
m = re.match(r'^\s*-([^-].*)$', line_stripped)
if m and m.group(1).strip():
list_match_unordered = m
if list_match_unordered:
text = list_match_unordered.group(1).strip()
if text:
self.add_list(text, numbered=False)
i += 1
continue
# 7. 图片链接 - 统一提取所有格式的图片URL
img_urls = self._extract_image_urls(line_content)
if img_urls:
has_only_img = (len(img_urls) == 1 and
line_content.strip().startswith('!') and
img_urls[0][0] == 0)
if has_only_img:
_, _, url = img_urls[0]
self.add_image(url)
else:
p = self.doc.add_paragraph()
last_end = 0
for start, end, url in img_urls:
before_text = line_content[last_end:start].strip()
if before_text:
self._add_rich_text_with_formulas(p, before_text)
self.add_image(url, width_inches=4.5)
p = self.doc.add_paragraph()
last_end = end
after_text = line_content[last_end:].strip()
if after_text:
self._add_rich_text_with_formulas(p, after_text)
i += 1
continue
# 8. 普通链接
if line_content.startswith("["):
match = re.match(r'\[(.*?)\]\((.*?)\)', line_content)
if match:
p = self.doc.add_paragraph()
run = p.add_run(match.group(1))
run.font.color.rgb = RGBColor(0, 0, 255)
run.font.underline = True
i += 1
continue
# 9. 普通段落
p = self.doc.add_paragraph()
indent_chars = 0
temp_line = line_stripped
while temp_line.startswith('\u3000'):
indent_chars += 1
temp_line = temp_line[1:]
if indent_chars >= 2:
p.paragraph_format.first_line_indent = Inches(0.5)
elif indent_chars == 1:
p.paragraph_format.first_line_indent = Inches(0.25)
self._add_rich_text_with_formulas(p, line_content)
i += 1
if table_buffer:
self.add_table(table_buffer)
def generate(self, markdown, title=None, output=None, images=None):
try:
if title:
logger.info(f"添加文档标题: {title}")
p = self.doc.add_paragraph()
run = p.add_run(title)
self.set_font(run, 20, True)
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
logger.info("开始解析 Markdown 内容")
self.parse(markdown)
if images:
self._append_images_section(images)
if not output:
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
output = f"report_{timestamp}.docx"
logger.info(f"正在保存文件: {output}")
self.doc.save(output)
if os.path.exists(output):
file_size = os.path.getsize(output)
logger.info(f"文件保存成功: {output}, 大小: {file_size} bytes")
return output
else:
logger.error(f"文件保存后未找到: {output}")
raise FileNotFoundError(f"File {output} was not created.")
except Exception as e:
logger.error(f"生成报告过程中发生严重错误: {e}", exc_info=True)
raise
def _append_images_section(self, images):
try:
self.add_heading("附件图片", level=2)
for idx, img in enumerate(images, 1):
if isinstance(img, dict):
url = img.get('url', '')
summary = img.get('summary', '')
else:
url = str(img)
summary = ''
p = self.doc.add_paragraph()
run = p.add_run(f"图片{idx}")
self.set_font(run, bold=True)
if url:
self.add_image(url, width_inches=4)
if summary:
p = self.doc.add_paragraph()
run = p.add_run(f"说明:{summary}")
self.set_font(run, size=10)
self.doc.add_paragraph()
logger.info(f"图片附件部分添加完成,共 {len(images)} 张图片")
except Exception as e:
logger.error(f"添加图片附件部分失败: {e}")
def generate_report_word(markdown, title=None, output=None, images=None):
logger.info("=== 开始生成报告 ===")
try:
generator = MarkdownWordGenerator()
if images:
logger.info(f"准备插入 {len(images)} 张图片")
file_path = generator.generate(markdown, title=title, output=output, images=images)
logger.info(f"=== 报告生成完成: {file_path} ===")
return file_path
except Exception as e:
logger.critical(f"报告生成失败: {e}")
raise
if __name__ == "__main__":
md = r"""
## **测试报告 - OMML公式和图片**
### **一、行内公式**
温度要求 $ \leqslant 40 ^ { \circ } \mathrm { C } $不得超过
公差范围 $ \geqslant 0.05 \ \mathrm { mm } $ 以内合格
### **二、列表中的公式**
1. 温度 $ \leqslant 40 ^ { \circ } \mathrm { C } $ 要求
2. 间隙 $ \leqslant 0.05 \ \mathrm { mm } $ 标准
- 清洁轴颈 $ \geqslant 0.05 \ \mathrm { mm } $
### **四、图片测试**
![图片](http://192.168.0.46:11000/api/v1/knowledge/files/images/56b1c0a02ff03d9d202627e88603e6671d3f193511d98a9fab0daf8687e6a9fa.jpg)
"""
try:
result_path = generate_report_word(md)
print(f"报告已生成,路径: {result_path}")
except Exception as e:
print(f"程序执行出错: {e}")
import traceback
traceback.print_exc()

View File

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

View File

@ -1,100 +0,0 @@
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"

File diff suppressed because it is too large Load Diff

View File

@ -1,132 +0,0 @@
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 {}

View File

@ -1,230 +0,0 @@
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"])

View File

@ -1,967 +0,0 @@
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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -72,16 +72,6 @@ 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,
} }
} }

View File

@ -7,17 +7,13 @@ import json
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
DEFAULT_HISTORY_MAX_MESSAGES = 12
DEFAULT_ASSISTANT_TRUNCATE = 1000
class HistoryManager: class HistoryManager:
"""统一的历史对话管理器,提供解析、截断、格式化、过滤等功能""" """统一的历史对话管理器,提供解析、截断、格式化、过滤等功能"""
def __init__( def __init__(
self, self,
max_messages: int = DEFAULT_HISTORY_MAX_MESSAGES, max_messages: int = 8,
max_assistant_chars: int = DEFAULT_ASSISTANT_TRUNCATE, max_assistant_chars: int = 300,
): ):
""" """
Args: Args:
@ -196,8 +192,8 @@ def parse_history(history_message: Any) -> List[Dict[str, Any]]:
def build_history_str( def build_history_str(
history_messages: List[Dict[str, Any]], history_messages: List[Dict[str, Any]],
recent_count: int = DEFAULT_HISTORY_MAX_MESSAGES, recent_count: int = 4,
assistant_truncate: int = DEFAULT_ASSISTANT_TRUNCATE, assistant_truncate: int = 300,
) -> str: ) -> str:
"""兼容旧调用的模块级函数""" """兼容旧调用的模块级函数"""
mgr = HistoryManager(max_messages=recent_count, max_assistant_chars=assistant_truncate) mgr = HistoryManager(max_messages=recent_count, max_assistant_chars=assistant_truncate)
@ -218,3 +214,4 @@ def init_history(history_message: Any) -> Dict[str, Any]:
"""兼容旧调用的模块级函数""" """兼容旧调用的模块级函数"""
return _default_manager.init_history(history_message) return _default_manager.init_history(history_message)

File diff suppressed because it is too large Load Diff

View File

@ -13,13 +13,9 @@ from utils.image_utils import extract_image_paths, normalize_markdown_images, ge
from workflows.workflow_baike import run_baike_workflow from workflows.workflow_baike import run_baike_workflow
from workflows.workflow_utils import ( from workflows.workflow_utils import (
safe_json_extract, parse_history, normalize_latex_spacing, safe_json_extract, parse_history, normalize_latex_spacing,
remove_duplicate_content, convert_rag_result, dedupe_rag_results, calculate_info_completeness, remove_duplicate_content, convert_rag_result, calculate_info_completeness,
emit_callback_event, build_history_str, build_detail_info, emit_callback_event, build_history_str, build_detail_info,
append_atlas_section, stream_generate_and_postprocess, append_atlas_section, stream_format_and_postprocess,
normalize_device_name_for_workflow,
resolve_ship_number_for_workflow,
resolve_ship_scope_for_workflow,
resolve_device_system_for_workflow,
) )
from utils.word_generator import generate_report_word from utils.word_generator import generate_report_word
from utils.intent_matcher import is_confirmation_intent from utils.intent_matcher import is_confirmation_intent
@ -32,8 +28,6 @@ import re
import random import random
import os import os
import time import time
import zipfile
from urllib.parse import quote
class FaultDiagnosisState(TypedDict): class FaultDiagnosisState(TypedDict):
"""故障诊断工作流状态""" """故障诊断工作流状态"""
@ -43,7 +37,6 @@ class FaultDiagnosisState(TypedDict):
ship_number: str ship_number: str
device_name: str device_name: str
system_name: str
fault: str fault: str
additional_info: str additional_info: str
fault_code: str fault_code: str
@ -146,18 +139,6 @@ def _preprocess_scheme_for_word(content: str) -> str:
return content return content
def _is_valid_docx(file_path: str) -> bool:
if not file_path or not os.path.isfile(file_path) or not zipfile.is_zipfile(file_path):
return False
required_parts = {"[Content_Types].xml", "word/document.xml"}
try:
with zipfile.ZipFile(file_path) as docx_file:
return required_parts.issubset(set(docx_file.namelist()))
except zipfile.BadZipFile:
return False
def _generate_report_from_scheme(scheme_content: str, ship_number: str = "", device_name: str = "", fault: str = "") -> List[Dict[str, Any]]: def _generate_report_from_scheme(scheme_content: str, ship_number: str = "", device_name: str = "", fault: str = "") -> List[Dict[str, Any]]:
""" """
从方案内容直接生成报告并返回下载按钮 从方案内容直接生成报告并返回下载按钮
@ -214,15 +195,13 @@ def _generate_report_from_scheme(scheme_content: str, ship_number: str = "", dev
# 构建下载按钮 # 构建下载按钮
actions = [] actions = []
if word_result and _is_valid_docx(word_result): if word_result and os.path.exists(word_result):
base_url = SERVER_CONFIG.get("report") or SERVER_CONFIG.get("base_url", "") base_url = SERVER_CONFIG.get("report", "")
download_url = f"{base_url}/api/download/{quote(file_name)}" download_url = f"{base_url}/api/download/{file_name}"
actions.append({"type": "download","label": f"{report_title}.docx","url": download_url,"fileName": file_name}) actions.append({"type": "download","label": f"{report_title}.docx","url": download_url,"fileName": file_name,"content": report_content})
print(f"[_generate_report_from_scheme] 报告生成成功: {output_path}") print(f"[_generate_report_from_scheme] 报告生成成功: {output_path}")
else:
print(f"[_generate_report_from_scheme] Word 文件生成后校验失败: {output_path}")
return actions return actions
@ -233,12 +212,6 @@ def _generate_report_from_scheme(scheme_content: str, ship_number: str = "", dev
return [] return []
FAULT_INFO_WEIGHTS = {"ship_number": 0.20, "device_name": 0.20, "fault": 0.25, "fault_code": 0.08, "fault_time": 0.06, "fault_frequency": 0.06, "tried_measures": 0.06, "running_condition": 0.05, "additional_info": 0.04} FAULT_INFO_WEIGHTS = {"ship_number": 0.20, "device_name": 0.20, "fault": 0.25, "fault_code": 0.08, "fault_time": 0.06, "fault_frequency": 0.06, "tried_measures": 0.06, "running_condition": 0.05, "additional_info": 0.04}
HISTORY_RECENT_COUNT = 12
HISTORY_ASSISTANT_TRUNCATE = 1000
RAG_TOP_K = 8
DEEP_RAG_TOP_K = 6
ATLAS_TOP_K = 20
GRAPH_TOP_K = 240
def _calculate_info_completeness(ship_number: str = "", device_name: str = "", fault: str = "", fault_code: str = "", fault_time: str = "", fault_frequency: str = "", tried_measures: str = "", running_condition: str = "", additional_info: str = "") -> float: def _calculate_info_completeness(ship_number: str = "", device_name: str = "", fault: str = "", fault_code: str = "", fault_time: str = "", fault_frequency: str = "", tried_measures: str = "", running_condition: str = "", additional_info: str = "") -> float:
values = {"ship_number": ship_number, "device_name": device_name, "fault": fault, "fault_code": fault_code, "fault_time": fault_time, "fault_frequency": fault_frequency, "tried_measures": tried_measures, "running_condition": running_condition, "additional_info": additional_info} values = {"ship_number": ship_number, "device_name": device_name, "fault": fault, "fault_code": fault_code, "fault_time": fault_time, "fault_frequency": fault_frequency, "tried_measures": tried_measures, "running_condition": running_condition, "additional_info": additional_info}
@ -252,7 +225,6 @@ async def classify_intent(state: FaultDiagnosisState) -> Dict[str, Any]:
extracted_info = state.get("extracted_info", {}) or {} extracted_info = state.get("extracted_info", {}) or {}
ship_number = state.get("ship_number", "") or "" ship_number = state.get("ship_number", "") or ""
device_name = state.get("device_name", "") or "" device_name = state.get("device_name", "") or ""
system_name = state.get("system_name", "") or ""
fault = state.get("fault", "") or "" fault = state.get("fault", "") or ""
# 如果已经提取过维修信息(有设备名或故障现象),说明已在维修流程中,继续维修流程 # 如果已经提取过维修信息(有设备名或故障现象),说明已在维修流程中,继续维修流程
@ -301,7 +273,7 @@ async def extract_info(state: FaultDiagnosisState) -> Dict[str, Any]:
combined_query = state.get("combined_query", "") or "" combined_query = state.get("combined_query", "") or ""
history_messages = state.get("history_messages", []) or [] history_messages = state.get("history_messages", []) or []
history_str = build_history_str(history_messages, recent_count=HISTORY_RECENT_COUNT) history_str = build_history_str(history_messages, recent_count=4)
existing_info = [] existing_info = []
existing_ship_number = state.get("ship_number", "") or "" existing_ship_number = state.get("ship_number", "") or ""
@ -378,7 +350,6 @@ async def agent_think(state: FaultDiagnosisState) -> Dict[str, Any]:
combined_query = state.get("combined_query", "") or "" combined_query = state.get("combined_query", "") or ""
ship_number = state.get("ship_number", "") or "" ship_number = state.get("ship_number", "") or ""
device_name = state.get("device_name", "") or "" device_name = state.get("device_name", "") or ""
system_name = state.get("system_name", "") or ""
fault = state.get("fault", "") or "" fault = state.get("fault", "") or ""
fault_code = state.get("fault_code", "") or "" fault_code = state.get("fault_code", "") or ""
fault_time = state.get("fault_time", "") or "" fault_time = state.get("fault_time", "") or ""
@ -419,7 +390,7 @@ async def agent_think(state: FaultDiagnosisState) -> Dict[str, Any]:
tools_to_call.append("graph_rag_search") tools_to_call.append("graph_rag_search")
return {"scheme_generated": False,"rag_search_result": None,"graph_search_result": None,"deep_rag_results": None,"has_rag_result": False,"has_graph_result": False,"is_regenerating": False,"user_feedback_for_regenerate": "","follow_up_intent": "","user_confirmed_info": True,"initialized": True,"iteration_count": 0,"ask_round": 0,"agent_decision": "call_tools","tools_to_call": tools_to_call,"current_node": "call_tools",} return {"scheme_generated": False,"rag_search_result": None,"graph_search_result": None,"deep_rag_results": None,"has_rag_result": False,"has_graph_result": False,"is_regenerating": False,"user_feedback_for_regenerate": "","follow_up_intent": "","user_confirmed_info": True,"initialized": True,"iteration_count": 0,"ask_round": 0,"agent_decision": "call_tools","tools_to_call": tools_to_call,"current_node": "call_tools",}
history_str = build_history_str(history_messages, recent_count=HISTORY_RECENT_COUNT, assistant_truncate=HISTORY_ASSISTANT_TRUNCATE) history_str = build_history_str(history_messages, recent_count=5, assistant_truncate=300)
intent_system_prompt = FAULT_DIAGNOSIS_PROMPTS["agent_think_intent_system"] intent_system_prompt = FAULT_DIAGNOSIS_PROMPTS["agent_think_intent_system"]
@ -544,7 +515,6 @@ async def ask_user(state: FaultDiagnosisState) -> Command:
missing_info = state.get("missing_info", []) missing_info = state.get("missing_info", [])
ship_number = state.get("ship_number", "") or "" ship_number = state.get("ship_number", "") or ""
device_name = state.get("device_name", "") or "" device_name = state.get("device_name", "") or ""
system_name = state.get("system_name", "") or ""
fault = state.get("fault", "") or "" fault = state.get("fault", "") or ""
fault_code = state.get("fault_code", "") or "" fault_code = state.get("fault_code", "") or ""
fault_time = state.get("fault_time", "") or "" fault_time = state.get("fault_time", "") or ""
@ -662,7 +632,6 @@ async def confirm_info(state: FaultDiagnosisState) -> Command:
""" """
ship_number = state.get("ship_number", "") or "" ship_number = state.get("ship_number", "") or ""
device_name = state.get("device_name", "") or "" device_name = state.get("device_name", "") or ""
system_name = state.get("system_name", "") or ""
fault = state.get("fault", "") or "" fault = state.get("fault", "") or ""
fault_code = state.get("fault_code", "") or "" fault_code = state.get("fault_code", "") or ""
fault_time = state.get("fault_time", "") or "" fault_time = state.get("fault_time", "") or ""
@ -740,7 +709,6 @@ async def call_tools(state: FaultDiagnosisState) -> Dict[str, Any]:
tools_to_call = state.get("tools_to_call", []) tools_to_call = state.get("tools_to_call", [])
ship_number = state.get("ship_number", "") or "" ship_number = state.get("ship_number", "") or ""
device_name = state.get("device_name", "") or "" device_name = state.get("device_name", "") or ""
system_name = state.get("system_name", "") or ""
fault = state.get("fault", "") or "" fault = state.get("fault", "") or ""
additional_info = state.get("additional_info", "") or "" additional_info = state.get("additional_info", "") or ""
@ -751,105 +719,67 @@ async def call_tools(state: FaultDiagnosisState) -> Dict[str, Any]:
matched_kb_name = "" matched_kb_name = ""
matched_kb_id = "" matched_kb_id = ""
mapped_ship_number = "" mapped_ship_number = ""
ship_scope_numbers = []
if ship_number:
ship_number, ship_scope_numbers, _ = await resolve_ship_scope_for_workflow(
ship_number=ship_number,
context="故障诊断-call_tools",
)
if device_name:
device_name, _ = await normalize_device_name_for_workflow(
device_name=device_name,
ship_number=ship_number,
ship_numbers=ship_scope_numbers,
context="故障诊断-call_tools",
)
system_name, _ = await resolve_device_system_for_workflow(
device_name=device_name,
ship_number=ship_number,
ship_numbers=ship_scope_numbers,
context="故障诊断-call_tools",
)
async def _do_rag_search(): async def _do_rag_search():
from utils.ship_number_search import search_with_ship_number_strategy from utils.ship_number_search import search_with_ship_number_strategy
base_query = f"{device_name}{fault}该怎么修复" base_query = f"{device_name}{fault}该怎么修复"
results, kb_name, kb_id, mapped_number = await search_with_ship_number_strategy( results, kb_name, kb_id, mapped_num = await search_with_ship_number_strategy(
base_query=base_query, base_query=base_query, ship_number=ship_number, top_k=5, search_type="fault"
ship_number=ship_number,
top_k=RAG_TOP_K,
search_type="fault",
) )
if not kb_name: if not kb_name:
kb_name = "通用知识库" kb_name = "通用知识库"
print(f"RAG 检索来源: {kb_name}") print(f"RAG 检索来源: {kb_name}")
return results, kb_name, kb_id, mapped_number return results, kb_name, kb_id, mapped_num
async def _do_graph_search(): async def _do_graph_search():
if not (device_name and fault): if not (device_name and fault):
return "" return ""
try: try:
graph_query = f"{device_name}{fault}该怎么修复" graph_query = f"{device_name}{fault}该怎么修复"
results = await graph_rag_search.ainvoke({"query": graph_query, "top_k": GRAPH_TOP_K}) results = await graph_rag_search.ainvoke({"query": graph_query})
if len(results) < 60: if len(results) < 60:
graph_query = f"{fault}该怎么修复" graph_query = f"{fault}该怎么修复"
results = await graph_rag_search.ainvoke({"query": graph_query, "top_k": GRAPH_TOP_K}) results = await graph_rag_search.ainvoke({"query": graph_query})
print(f"图谱检索完成,结果长度: {len(results) if results else 0}") print(f"图谱检索完成,结果长度: {len(results) if results else 0}")
return results return results
except Exception as e: except Exception as e:
print(f"图谱检索失败: {str(e)}") print(f"图谱检索失败: {str(e)}")
return "" return ""
async def _do_atlas_search(): need_rag = "rag_search" in tools_to_call and rag_results is None
if not device_name: need_graph = "graph_rag_search" in tools_to_call and graph_results is None
return {}
try:
print(f"[图册检索] 使用设备名称: {device_name}")
result = await atlas_retrieval(node_names=[device_name], top_k=ATLAS_TOP_K)
if result.get("success", False):
data = result.get("data", {})
print(f"[图册检索] 图册检索结果: {list(data.keys())}")
return data
except Exception as e:
print(f"[图册检索] 图册检索失败: {str(e)}")
return {}
if "rag_search" in tools_to_call and rag_results is None: if need_rag and need_graph:
(rag_results, matched_kb_name, matched_kb_id, mapped_ship_number), graph_results = \
await asyncio.gather(_do_rag_search(), _do_graph_search())
elif need_rag:
rag_results, matched_kb_name, matched_kb_id, mapped_ship_number = await _do_rag_search() rag_results, matched_kb_name, matched_kb_id, mapped_ship_number = await _do_rag_search()
if mapped_ship_number and mapped_ship_number != ship_number: elif need_graph:
print(f"[call_tools] RAG 舷号映射: '{ship_number}' -> '{mapped_ship_number}',重新标准化设备名")
ship_number = mapped_ship_number
if device_name:
device_name, _ = await normalize_device_name_for_workflow(
device_name=device_name,
ship_number=ship_number,
ship_numbers=[ship_number],
context="故障诊断-call_tools-rag_mapped",
)
system_name, _ = await resolve_device_system_for_workflow(
device_name=device_name,
ship_number=ship_number,
ship_numbers=[ship_number],
context="故障诊断-call_tools-rag_mapped",
)
if "graph_rag_search" in tools_to_call and graph_results is None:
graph_results = await _do_graph_search() graph_results = await _do_graph_search()
if mapped_ship_number and mapped_ship_number != ship_number:
print(f"[call_tools] 舷号映射: '{ship_number}' -> '{mapped_ship_number}',更新工作流状态")
ship_number = mapped_ship_number
# 图册检索:只传入设备名称
if device_name: if device_name:
atlas_results = await _do_atlas_search() try:
print(f"[图册检索] 使用设备名称: {device_name}")
atlas_result = await atlas_retrieval(node_names=[device_name], top_k=10)
if atlas_result.get("success", False):
atlas_results = atlas_result.get("data", {})
print(f"[图册检索] 图册检索结果: {list(atlas_results.keys())}")
except Exception as e:
print(f"[图册检索] 图册检索失败: {str(e)}")
has_rag = bool(rag_results and len(rag_results) > 0) has_rag = bool(rag_results and len(rag_results) > 0)
has_graph = bool(graph_results and len(graph_results) > 100) has_graph = bool(graph_results and len(graph_results) > 100)
if not has_rag and not has_graph: if not has_rag and not has_graph:
print(f"[call_tools] 未找到任何相关资料,需要用户确认使用模型能力") print(f"[call_tools] 未找到任何相关资料,需要用户确认使用模型能力")
return {"rag_search_result": rag_results if isinstance(rag_results, list) else [],"graph_search_result": graph_results if isinstance(graph_results, str) else "","atlas_results": atlas_results,"matched_kb_id": matched_kb_id,"matched_kb_name": matched_kb_name,"ship_number": ship_number,"device_name": device_name,"system_name": system_name,"has_rag_result": False,"has_graph_result": False,"need_model_confirm": True,"current_node": "model_confirm",} return {"rag_search_result": rag_results if isinstance(rag_results, list) else [],"graph_search_result": graph_results if isinstance(graph_results, str) else "","atlas_results": atlas_results,"matched_kb_id": matched_kb_id,"matched_kb_name": matched_kb_name,"ship_number": ship_number,"has_rag_result": False,"has_graph_result": False,"need_model_confirm": True,"current_node": "model_confirm",}
return {"rag_search_result": rag_results,"graph_search_result": graph_results,"atlas_results": atlas_results,"matched_kb_id": matched_kb_id,"matched_kb_name": matched_kb_name,"ship_number": ship_number,"device_name": device_name,"system_name": system_name,"has_rag_result": has_rag,"has_graph_result": has_graph,"need_model_confirm": False,"current_node": "generate_response",} return {"rag_search_result": rag_results,"graph_search_result": graph_results,"atlas_results": atlas_results,"matched_kb_id": matched_kb_id,"matched_kb_name": matched_kb_name,"ship_number": ship_number,"has_rag_result": has_rag,"has_graph_result": has_graph,"need_model_confirm": False,"current_node": "generate_response",}
async def model_confirm(state: FaultDiagnosisState) -> Command: async def model_confirm(state: FaultDiagnosisState) -> Command:
""" """
@ -900,7 +830,6 @@ async def generate_response(state: FaultDiagnosisState) -> Dict[str, Any]:
history_messages = state.get("history_messages", []) or [] history_messages = state.get("history_messages", []) or []
ship_number = state.get("ship_number", "") or "" ship_number = state.get("ship_number", "") or ""
device_name = state.get("device_name", "") or "" device_name = state.get("device_name", "") or ""
system_name = state.get("system_name", "") or ""
fault = state.get("fault", "") or "" fault = state.get("fault", "") or ""
additional_info = state.get("additional_info", "") or "" additional_info = state.get("additional_info", "") or ""
fault_code = state.get("fault_code", "") or "" fault_code = state.get("fault_code", "") or ""
@ -914,7 +843,7 @@ async def generate_response(state: FaultDiagnosisState) -> Dict[str, Any]:
matched_kb_name = state.get("matched_kb_name", "") matched_kb_name = state.get("matched_kb_name", "")
print("rag_results", rag_results) print("rag_results", rag_results)
history_str = build_history_str(history_messages, recent_count=HISTORY_RECENT_COUNT, assistant_truncate=HISTORY_ASSISTANT_TRUNCATE) history_str = build_history_str(history_messages, recent_count=5, assistant_truncate=500)
has_rag = rag_results and len(rag_results) > 0 has_rag = rag_results and len(rag_results) > 0
has_graph = graph_results and len(graph_results) > 100 has_graph = graph_results and len(graph_results) > 100
@ -939,27 +868,28 @@ async def generate_response(state: FaultDiagnosisState) -> Dict[str, Any]:
try: try:
response = await OpenaiAPI.open_api_chat_without_thinking(query=prompt,model=None,system_prompt=system_prompt,messages=[]) response = await OpenaiAPI.open_api_chat_without_thinking(query=prompt,model=None,system_prompt=system_prompt,messages=[])
response = normalize_markdown_images(response.strip(), original_paths=[]) if response else ""
return {"response": response.strip() if response else "抱歉,未能找到相关资料。","suggestedReplies": [{"title": "提供更详细信息", "content": "我来提供更详细的信息"},],"current_node": "end",} return {"response": response.strip() if response else "抱歉,未能找到相关资料。","suggestedReplies": [{"title": "提供更详细信息", "content": "我来提供更详细的信息"},],"current_node": "end",}
except Exception as e: except Exception as e:
return {"response": f"抱歉,处理过程中出现错误: {str(e)}","suggestedReplies": [],"current_node": "end",} return {"response": f"抱歉,处理过程中出现错误: {str(e)}","suggestedReplies": [],"current_node": "end",}
rag_ctx_parts: List[str] = []
if isinstance(rag_results, str) and rag_results.strip():
rag_ctx_parts = [rag_results.strip()]
elif isinstance(rag_results, list):
for item in rag_results[:1]:
if isinstance(item, dict):
text = (item.get("text") or "").strip()
if text:
rag_ctx_parts.append(text)
elif isinstance(item, str) and item.strip():
rag_ctx_parts.append(item.strip())
if len(graph_results) <= 50: if len(graph_results) <= 50:
graph_results = "" graph_results = ""
rag_ctx_parts: List[str] = [] all_source_text = graph_results + "\n" + "\n".join(rag_ctx_parts) if graph_results else "\n".join(rag_ctx_parts)
if not graph_results: original_image_paths = extract_image_paths(all_source_text)
if isinstance(rag_results, str) and rag_results.strip(): print("提取到的原始图片路径:", original_image_paths)
rag_ctx_parts = [rag_results.strip()]
elif isinstance(rag_results, list):
deduped_rag_results = dedupe_rag_results(rag_results, limit=4)
for item in deduped_rag_results:
if isinstance(item, dict):
text = (item.get("text") or "").strip()
if text:
rag_ctx_parts.append(text)
elif isinstance(item, str) and item.strip():
rag_ctx_parts.append(item.strip())
rag_answer = "" rag_answer = ""
rag_type = "none" rag_type = "none"
@ -968,12 +898,9 @@ async def generate_response(state: FaultDiagnosisState) -> Dict[str, Any]:
rag_answer = graph_results rag_answer = graph_results
rag_type = "graph" rag_type = "graph"
elif rag_ctx_parts: elif rag_ctx_parts:
rag_answer = "\n\n".join(rag_ctx_parts) rag_answer = "\n\n".join(rag_ctx_parts[:1])
rag_type = "rag" rag_type = "rag"
original_image_paths = extract_image_paths(rag_answer)
print(f"提取到的{rag_type}原始图片路径:", original_image_paths)
if not rag_answer or rag_answer == "NO_RELEVANT_RESULT": if not rag_answer or rag_answer == "NO_RELEVANT_RESULT":
system_prompt = COMMON_PROMPTS["generate_response_no_rag_simple_system"].format(biz_label="故障诊断") system_prompt = COMMON_PROMPTS["generate_response_no_rag_simple_system"].format(biz_label="故障诊断")
prompt = FAULT_DIAGNOSIS_PROMPTS["generate_response_no_rag_simple_user"].format( prompt = FAULT_DIAGNOSIS_PROMPTS["generate_response_no_rag_simple_user"].format(
@ -984,7 +911,6 @@ async def generate_response(state: FaultDiagnosisState) -> Dict[str, Any]:
try: try:
response = await OpenaiAPI.open_api_chat_without_thinking(query=prompt,model=None,system_prompt=system_prompt,messages=[]) response = await OpenaiAPI.open_api_chat_without_thinking(query=prompt,model=None,system_prompt=system_prompt,messages=[])
response = normalize_markdown_images(response.strip(), original_paths=[]) if response else ""
return {"response": response.strip() if response else "抱歉,未能找到相关资料。","suggestedReplies": [],"current_node": "end",} return {"response": response.strip() if response else "抱歉,未能找到相关资料。","suggestedReplies": [],"current_node": "end",}
except Exception as e: except Exception as e:
return {"response": f"抱歉,处理过程中出现错误: {str(e)}","suggestedReplies": [],"current_node": "end",} return {"response": f"抱歉,处理过程中出现错误: {str(e)}","suggestedReplies": [],"current_node": "end",}
@ -994,8 +920,6 @@ async def generate_response(state: FaultDiagnosisState) -> Dict[str, Any]:
emit_callback_event([f"✨当前使用的检索类型为{rag_type}", f"✨本次检索策略为{rag_type}"], f"内容为{rag_answer[:50]}...") emit_callback_event([f"✨当前使用的检索类型为{rag_type}", f"✨本次检索策略为{rag_type}"], f"内容为{rag_answer[:50]}...")
detail_info = "" detail_info = ""
if system_name:
detail_info += f"- 所属系统:{system_name}\n"
if fault_code: if fault_code:
detail_info += f"- 故障码/报警代码:{fault_code}\n" detail_info += f"- 故障码/报警代码:{fault_code}\n"
if fault_time: if fault_time:
@ -1055,17 +979,16 @@ async def generate_response(state: FaultDiagnosisState) -> Dict[str, Any]:
) )
try: try:
# ========== 第一步:专注于生成高质量内容 ==========
content_system_prompt = COMMON_PROMPTS["generate_response_content_system"].format(biz_label="维修") content_system_prompt = COMMON_PROMPTS["generate_response_content_system"].format(biz_label="维修")
emit_callback_event(["🤖 诊断分析中", "🤖 故障预检中"], "正在生成维修方案...") raw_content = await OpenaiAPI.open_api_chat_without_thinking(model=None,system_prompt=content_system_prompt,messages=[{"role": "user", "content": prompt}],)
answer = await stream_generate_and_postprocess( raw_content = raw_content.strip() if raw_content else "抱歉,无法生成回答。"
system_prompt=content_system_prompt,
messages=[{"role": "user", "content": prompt}], emit_callback_event(["🤖 诊断分析中", "🤖 故障预检中"], raw_content[:30] + "...")
original_image_paths=original_image_paths,
temperature=0.3, answer = await stream_format_and_postprocess(raw_content, original_image_paths, temperature=0.3)
fallback="抱歉,无法生成回答。"
)
scheme_source = f"本方案依据{rag_type}知识库生成。" scheme_source = f"本方案依据{rag_type}知识库生成。"
answer = f"{scheme_source}\n\n{answer}" answer = f"{scheme_source}\n\n{answer}"
@ -1119,7 +1042,6 @@ async def deep_rag_search(state: FaultDiagnosisState) -> Dict[str, Any]:
""" """
ship_number = state.get("ship_number", "") or "" ship_number = state.get("ship_number", "") or ""
device_name = state.get("device_name", "") or "" device_name = state.get("device_name", "") or ""
system_name = state.get("system_name", "") or ""
fault = state.get("fault", "") or "" fault = state.get("fault", "") or ""
fault_code = state.get("fault_code", "") or "" fault_code = state.get("fault_code", "") or ""
additional_info = state.get("additional_info", "") or "" additional_info = state.get("additional_info", "") or ""
@ -1130,27 +1052,6 @@ async def deep_rag_search(state: FaultDiagnosisState) -> Dict[str, Any]:
combined_query = state.get("combined_query", "") or "" combined_query = state.get("combined_query", "") or ""
history_messages = state.get("history_messages", []) or [] history_messages = state.get("history_messages", []) or []
last_generated_scheme = state.get("last_generated_scheme", "") or "" last_generated_scheme = state.get("last_generated_scheme", "") or ""
ship_scope_numbers = []
if ship_number:
ship_number, ship_scope_numbers, _ = await resolve_ship_scope_for_workflow(
ship_number=ship_number,
context="故障诊断-deep_rag_search",
)
if device_name:
device_name, _ = await normalize_device_name_for_workflow(
device_name=device_name,
ship_number=ship_number,
ship_numbers=ship_scope_numbers,
context="故障诊断-deep_rag_search",
)
system_name, _ = await resolve_device_system_for_workflow(
device_name=device_name,
ship_number=ship_number,
ship_numbers=ship_scope_numbers,
context="故障诊断-deep_rag_search",
)
deep_results = {} deep_results = {}
deep_analysis_points = [] deep_analysis_points = []
@ -1160,7 +1061,7 @@ async def deep_rag_search(state: FaultDiagnosisState) -> Dict[str, Any]:
# ========== 第一步:分析之前方案和用户问题,选择深度分析点 ========== # ========== 第一步:分析之前方案和用户问题,选择深度分析点 ==========
if last_generated_scheme or combined_query: if last_generated_scheme or combined_query:
try: try:
history_str = build_history_str(history_messages, recent_count=HISTORY_RECENT_COUNT, assistant_truncate=HISTORY_ASSISTANT_TRUNCATE) history_str = build_history_str(history_messages, recent_count=5, assistant_truncate=300)
detail_info = "" detail_info = ""
if fault_code: if fault_code:
@ -1209,12 +1110,9 @@ async def deep_rag_search(state: FaultDiagnosisState) -> Dict[str, Any]:
if fault and fault not in search_query: if fault and fault not in search_query:
search_query = f"{search_query} {fault}" search_query = f"{search_query} {fault}"
rag_result = await rag_search(search_query, top_k=DEEP_RAG_TOP_K) rag_result = await rag_search(search_query, top_k=3)
if rag_result.get("success", False): if rag_result.get("success", False):
deep_results[f"analysis_point_{idx}"] = dedupe_rag_results( deep_results[f"analysis_point_{idx}"] = convert_rag_result(rag_result)
convert_rag_result(rag_result),
limit=DEEP_RAG_TOP_K,
)
else: else:
deep_results[f"analysis_point_{idx}"] = [] deep_results[f"analysis_point_{idx}"] = []
except Exception as e: except Exception as e:
@ -1229,14 +1127,14 @@ async def deep_rag_search(state: FaultDiagnosisState) -> Dict[str, Any]:
if entity_names: if entity_names:
print(f"[深度RAG] 图册检索实体: {entity_names}") print(f"[深度RAG] 图册检索实体: {entity_names}")
atlas_result = await atlas_retrieval(node_names=entity_names, top_k=ATLAS_TOP_K) atlas_result = await atlas_retrieval(node_names=entity_names, top_k=10)
if atlas_result.get("success", False): if atlas_result.get("success", False):
atlas_results = atlas_result.get("data", {}) atlas_results = atlas_result.get("data", {})
print(f"[深度RAG] 图册检索结果: {list(atlas_results.keys())}") print(f"[深度RAG] 图册检索结果: {list(atlas_results.keys())}")
except Exception as e: except Exception as e:
print(f"[深度RAG] 图册检索失败: {str(e)}") print(f"[深度RAG] 图册检索失败: {str(e)}")
return {"deep_rag_results": deep_results,"deep_analysis_points": deep_analysis_points,"atlas_results": atlas_results,"ship_number": ship_number,"device_name": device_name,"system_name": system_name,"current_node": "generate_deep_response",} return {"deep_rag_results": deep_results,"deep_analysis_points": deep_analysis_points,"atlas_results": atlas_results,"current_node": "generate_deep_response",}
async def generate_deep_response(state: FaultDiagnosisState) -> Dict[str, Any]: async def generate_deep_response(state: FaultDiagnosisState) -> Dict[str, Any]:
""" """
@ -1246,7 +1144,6 @@ async def generate_deep_response(state: FaultDiagnosisState) -> Dict[str, Any]:
history_messages = state.get("history_messages", []) or [] history_messages = state.get("history_messages", []) or []
ship_number = state.get("ship_number", "") or "" ship_number = state.get("ship_number", "") or ""
device_name = state.get("device_name", "") or "" device_name = state.get("device_name", "") or ""
system_name = state.get("system_name", "") or ""
fault = state.get("fault", "") or "" fault = state.get("fault", "") or ""
fault_code = state.get("fault_code", "") or "" fault_code = state.get("fault_code", "") or ""
fault_time = state.get("fault_time", "") or "" fault_time = state.get("fault_time", "") or ""
@ -1279,22 +1176,20 @@ async def generate_deep_response(state: FaultDiagnosisState) -> Dict[str, Any]:
original_rag_text = "" original_rag_text = ""
if original_rag_results: if original_rag_results:
original_rag_text = "\n【原始检索资料】\n" original_rag_text = "\n【原始检索资料】\n"
for i, item in enumerate(dedupe_rag_results(original_rag_results, limit=3), 1): for i, item in enumerate(original_rag_results[:3], 1):
if isinstance(item, dict): if isinstance(item, dict):
text = item.get("text", "") text = item.get("text", "")
if text: if text:
original_rag_text += f"[{i}] {text}\n" original_rag_text += f"[{i}] {text}\n"
if graph_results and len(graph_results) > 50: # 整理图册资料
original_image_paths = extract_image_paths(graph_results) atlas_text = format_atlas_results(atlas_results)
print("提取到的deep graph原始图片路径:", original_image_paths)
else: # 提取所有资料中的图片路径
original_image_paths = extract_image_paths(analysis_points_text + original_rag_text) all_source_text = analysis_points_text + original_rag_text + atlas_text + graph_results
print("提取到的deep rag原始图片路径:", original_image_paths) original_image_paths = extract_image_paths(all_source_text)
detail_info = "" detail_info = ""
if system_name:
detail_info += f"- 所属系统:{system_name}\n"
if fault_code: if fault_code:
detail_info += f"- 故障码/报警代码:{fault_code}\n" detail_info += f"- 故障码/报警代码:{fault_code}\n"
if fault_time: if fault_time:
@ -1329,17 +1224,16 @@ async def generate_deep_response(state: FaultDiagnosisState) -> Dict[str, Any]:
) )
try: try:
# ========== 第一步:专注于生成高质量内容 ==========
content_system_prompt = COMMON_PROMPTS["generate_deep_response_content_system"].format(biz_label="维修") content_system_prompt = COMMON_PROMPTS["generate_deep_response_content_system"].format(biz_label="维修")
emit_callback_event(["🤖 诊断分析中", "🤖 故障预检中"], "正在生成深度分析...") raw_content = await OpenaiAPI.open_api_chat_without_thinking(model=None,system_prompt=content_system_prompt,messages=[{"role": "user", "content": prompt}],)
answer = await stream_generate_and_postprocess( raw_content = raw_content.strip() if raw_content else "抱歉,无法生成深度分析。"
system_prompt=content_system_prompt,
messages=[{"role": "user", "content": prompt}], emit_callback_event(["🤖 诊断分析中", "🤖 故障预检中"], raw_content[:30] + "...")
original_image_paths=original_image_paths,
temperature=0.1, answer = await stream_format_and_postprocess(raw_content, original_image_paths, temperature=0.1)
fallback="抱歉,无法生成深度分析。"
)
answer = append_atlas_section(answer, atlas_results) answer = append_atlas_section(answer, atlas_results)
@ -1363,7 +1257,6 @@ async def regenerate_scheme_from_feedback(state: FaultDiagnosisState) -> Dict[st
""" """
ship_number = state.get("ship_number", "") or "" ship_number = state.get("ship_number", "") or ""
device_name = state.get("device_name", "") or "" device_name = state.get("device_name", "") or ""
system_name = state.get("system_name", "") or ""
fault = state.get("fault", "") or "" fault = state.get("fault", "") or ""
additional_info = state.get("additional_info", "") or "" additional_info = state.get("additional_info", "") or ""
fault_code = state.get("fault_code", "") or "" fault_code = state.get("fault_code", "") or ""
@ -1382,8 +1275,6 @@ async def regenerate_scheme_from_feedback(state: FaultDiagnosisState) -> Dict[st
return {"is_regenerating": True,"rag_search_result": None,"graph_search_result": None,"has_rag_result": False,"has_graph_result": False,"scheme_generated": False,"current_node": "call_tools","tools_to_call": ["rag_search", "graph_rag_search"],} return {"is_regenerating": True,"rag_search_result": None,"graph_search_result": None,"has_rag_result": False,"has_graph_result": False,"scheme_generated": False,"current_node": "call_tools","tools_to_call": ["rag_search", "graph_rag_search"],}
detail_info = "" detail_info = ""
if system_name:
detail_info += f"- 所属系统:{system_name}\n"
if fault_code: if fault_code:
detail_info += f"- 故障码/报警代码:{fault_code}\n" detail_info += f"- 故障码/报警代码:{fault_code}\n"
if fault_time: if fault_time:
@ -1416,16 +1307,18 @@ async def regenerate_scheme_from_feedback(state: FaultDiagnosisState) -> Dict[st
) )
try: try:
# ========== 第一步:专注于生成高质量内容 ==========
content_system_prompt = COMMON_PROMPTS["regenerate_scheme_from_feedback_system"].format(biz_label="维修") content_system_prompt = COMMON_PROMPTS["regenerate_scheme_from_feedback_system"].format(biz_label="维修")
# 构建第一步的内容提示词(简化格式要求)
content_prompt = prompt.replace("【输出要求】", "【输出要求】\n- 请专注于内容的准确性、完整性和逻辑性,不要担心格式的美观性\n- 可以适当使用简单的段落分隔,但不要使用复杂的格式")
raw_content = await OpenaiAPI.open_api_chat_without_thinking(model=None,system_prompt=content_system_prompt,messages=[{"role": "user", "content": content_prompt}],)
raw_content = raw_content.strip() if raw_content else "抱歉,无法根据反馈修改方案。"
original_image_paths = extract_image_paths(last_scheme) original_image_paths = extract_image_paths(last_scheme)
answer = await stream_generate_and_postprocess( answer = await stream_format_and_postprocess(raw_content, original_image_paths, temperature=0.3, content_label="维修方案")
system_prompt=content_system_prompt,
messages=[{"role": "user", "content": prompt}],
original_image_paths=original_image_paths,
temperature=0.3,
fallback="抱歉,无法根据反馈修改方案。"
)
suggested_replies = [{"title": "方案有误", "content": "方案还是有问题:"},] suggested_replies = [{"title": "方案有误", "content": "方案还是有问题:"},]
@ -1724,7 +1617,7 @@ async def run_fault_diagnosis_workflow(extracted_text: str,combined_query: str,h
initial_state["user_confirmed_info"] = True initial_state["user_confirmed_info"] = True
result = await app.ainvoke(initial_state, config) result = await app.ainvoke(initial_state, config)
else: else:
initial_state = {"extracted_text": extracted_text,"combined_query": combined_query,"history_messages": parse_history(history_message),"ship_number": "","device_name": "","system_name": "","fault": "","additional_info": "", initial_state = {"extracted_text": extracted_text,"combined_query": combined_query,"history_messages": parse_history(history_message),"ship_number": "","device_name": "","fault": "","additional_info": "",
"fault_code": "","fault_time": "","fault_frequency": "","tried_measures": "","running_condition": "","rag_search_result": None,"graph_search_result": None,"response": "", "fault_code": "","fault_time": "","fault_frequency": "","tried_measures": "","running_condition": "","rag_search_result": None,"graph_search_result": None,"response": "",
"suggestedReplies": [],"actions": [],"error_message": "","agent_decision": "","agent_reasoning": "","tools_to_call": [],"missing_info": [],"info_completeness": 0.0, "suggestedReplies": [],"actions": [],"error_message": "","agent_decision": "","agent_reasoning": "","tools_to_call": [],"missing_info": [],"info_completeness": 0.0,
"matched_kb_id": "","matched_kb_name": "","iteration_count": 0,"max_iterations": 5,"scheme_generated": False,"waiting_for_supplement": False,"extracted_info": {}, "matched_kb_id": "","matched_kb_name": "","iteration_count": 0,"max_iterations": 5,"scheme_generated": False,"waiting_for_supplement": False,"extracted_info": {},
@ -1737,7 +1630,7 @@ async def run_fault_diagnosis_workflow(extracted_text: str,combined_query: str,h
traceback.print_exc() traceback.print_exc()
result = {} result = {}
else: else:
initial_state = {"extracted_text": extracted_text,"combined_query": combined_query,"history_messages": parse_history(history_message),"ship_number": "","device_name": "","system_name": "","fault": "","additional_info": "","fault_code": "","fault_time": "", initial_state = {"extracted_text": extracted_text,"combined_query": combined_query,"history_messages": parse_history(history_message),"ship_number": "","device_name": "","fault": "","additional_info": "","fault_code": "","fault_time": "",
"fault_frequency": "","tried_measures": "","running_condition": "","rag_search_result": None,"graph_search_result": None,"response": "","suggestedReplies": [],"actions": [],"error_message": "","agent_decision": "","agent_reasoning": "","tools_to_call": [], "fault_frequency": "","tried_measures": "","running_condition": "","rag_search_result": None,"graph_search_result": None,"response": "","suggestedReplies": [],"actions": [],"error_message": "","agent_decision": "","agent_reasoning": "","tools_to_call": [],
"missing_info": [],"info_completeness": 0.0,"matched_kb_id": "","matched_kb_name": "","iteration_count": 0,"max_iterations": 5,"scheme_generated": False,"waiting_for_supplement": False,"extracted_info": {},"has_rag_result": False,"has_graph_result": False, "missing_info": [],"info_completeness": 0.0,"matched_kb_id": "","matched_kb_name": "","iteration_count": 0,"max_iterations": 5,"scheme_generated": False,"waiting_for_supplement": False,"extracted_info": {},"has_rag_result": False,"has_graph_result": False,
"is_execution_feedback": False,"execution_detail": "","ask_round": 0,"user_confirmed_info": False,"need_model_confirm": False,"user_confirmed_model": False,"waiting_feedback": False,"user_feedback": "","waiting_report_confirm": False,"report_confirmed": False,"initialized": False,"current_node": "","user_intent": "","last_combined_query": "",} "is_execution_feedback": False,"execution_detail": "","ask_round": 0,"user_confirmed_info": False,"need_model_confirm": False,"user_confirmed_model": False,"waiting_feedback": False,"user_feedback": "","waiting_report_confirm": False,"report_confirmed": False,"initialized": False,"current_node": "","user_intent": "","last_combined_query": "",}
@ -1768,3 +1661,4 @@ async def run_fault_diagnosis_workflow(extracted_text: str,combined_query: str,h
return {"response": result.get("response", ""),"actions": result.get("actions", []),"result_tag": route_flag,"suggestedReplies": result.get("suggestedReplies", []),"route_flag": route_flag,} return {"response": result.get("response", ""),"actions": result.get("actions", []),"result_tag": route_flag,"suggestedReplies": result.get("suggestedReplies", []),"route_flag": route_flag,}

File diff suppressed because it is too large Load Diff

View File

@ -9,18 +9,14 @@ from tools.rag_tools import rag_search
from tools.graph_tools import atlas_retrieval, format_atlas_results, extract_entity_names_from_history from tools.graph_tools import atlas_retrieval, format_atlas_results, extract_entity_names_from_history
from modelsAPI.model_api import OpenaiAPI from modelsAPI.model_api import OpenaiAPI
from utils.function_tracker import get_all_callbacks from utils.function_tracker import get_all_callbacks
from utils.image_utils import extract_image_paths, normalize_markdown_images, get_image_prompt_guidance from utils.image_utils import extract_image_paths, get_image_prompt_guidance
from utils.intent_matcher import is_confirmation_intent from utils.intent_matcher import is_confirmation_intent
from workflows.workflow_baike import run_baike_workflow from workflows.workflow_baike import run_baike_workflow
from workflows.workflow_utils import ( from workflows.workflow_utils import (
safe_json_extract, parse_history, normalize_latex_spacing, safe_json_extract, parse_history, normalize_latex_spacing,
remove_duplicate_content, convert_rag_result, dedupe_rag_results, calculate_info_completeness, remove_duplicate_content, convert_rag_result, calculate_info_completeness,
emit_callback_event, build_history_str, build_detail_info, emit_callback_event, build_history_str, build_detail_info,
append_atlas_section, stream_generate_and_postprocess, append_atlas_section, stream_format_and_postprocess,
normalize_device_name_for_workflow,
resolve_ship_number_for_workflow,
resolve_ship_scope_for_workflow,
resolve_device_system_for_workflow,
) )
from prompts import OPERATE_PROMPTS, COMMON_PROMPTS from prompts import OPERATE_PROMPTS, COMMON_PROMPTS
import asyncio import asyncio
@ -36,7 +32,6 @@ class OperateGuideState(TypedDict):
ship_number: str ship_number: str
device_name: str device_name: str
system_name: str
operation_item: str operation_item: str
additional_info: str additional_info: str
operation_code: str operation_code: str
@ -98,12 +93,6 @@ class OperateGuideState(TypedDict):
last_combined_query: str # 上一轮生成方案时的用户输入 last_combined_query: str # 上一轮生成方案时的用户输入
OPERATE_INFO_WEIGHTS = {"ship_number": 0.20, "device_name": 0.20, "operation_item": 0.25, "operation_code": 0.08, "operation_time": 0.06, "operation_frequency": 0.06, "tried_measures": 0.06, "running_condition": 0.05, "additional_info": 0.04} OPERATE_INFO_WEIGHTS = {"ship_number": 0.20, "device_name": 0.20, "operation_item": 0.25, "operation_code": 0.08, "operation_time": 0.06, "operation_frequency": 0.06, "tried_measures": 0.06, "running_condition": 0.05, "additional_info": 0.04}
HISTORY_RECENT_COUNT = 12
HISTORY_ASSISTANT_TRUNCATE = 1000
RAG_TOP_K = 8
DEEP_RAG_TOP_K = 6
ATLAS_TOP_K = 20
GRAPH_TOP_K = 240
def _calculate_info_completeness(ship_number: str = "", device_name: str = "", operation_item: str = "", operation_code: str = "", operation_time: str = "", operation_frequency: str = "", tried_measures: str = "", running_condition: str = "", additional_info: str = "") -> float: def _calculate_info_completeness(ship_number: str = "", device_name: str = "", operation_item: str = "", operation_code: str = "", operation_time: str = "", operation_frequency: str = "", tried_measures: str = "", running_condition: str = "", additional_info: str = "") -> float:
values = {"ship_number": ship_number, "device_name": device_name, "operation_item": operation_item, "operation_code": operation_code, "operation_time": operation_time, "operation_frequency": operation_frequency, "tried_measures": tried_measures, "running_condition": running_condition, "additional_info": additional_info} values = {"ship_number": ship_number, "device_name": device_name, "operation_item": operation_item, "operation_code": operation_code, "operation_time": operation_time, "operation_frequency": operation_frequency, "tried_measures": tried_measures, "running_condition": running_condition, "additional_info": additional_info}
@ -117,7 +106,6 @@ async def classify_intent(state: OperateGuideState) -> Dict[str, Any]:
extracted_info = state.get("extracted_info", {}) or {} extracted_info = state.get("extracted_info", {}) or {}
ship_number = state.get("ship_number", "") or "" ship_number = state.get("ship_number", "") or ""
device_name = state.get("device_name", "") or "" device_name = state.get("device_name", "") or ""
system_name = state.get("system_name", "") or ""
operation_item = state.get("operation_item", "") or "" operation_item = state.get("operation_item", "") or ""
if extracted_info or device_name or operation_item or ship_number: if extracted_info or device_name or operation_item or ship_number:
@ -165,7 +153,7 @@ async def extract_info(state: OperateGuideState) -> Dict[str, Any]:
combined_query = state.get("combined_query", "") or "" combined_query = state.get("combined_query", "") or ""
history_messages = state.get("history_messages", []) or [] history_messages = state.get("history_messages", []) or []
history_str = build_history_str(history_messages, recent_count=HISTORY_RECENT_COUNT) history_str = build_history_str(history_messages, recent_count=4)
existing_info = [] existing_info = []
existing_ship_number = state.get("ship_number", "") or "" existing_ship_number = state.get("ship_number", "") or ""
@ -242,7 +230,6 @@ async def agent_think(state: OperateGuideState) -> Dict[str, Any]:
combined_query = state.get("combined_query", "") or "" combined_query = state.get("combined_query", "") or ""
ship_number = state.get("ship_number", "") or "" ship_number = state.get("ship_number", "") or ""
device_name = state.get("device_name", "") or "" device_name = state.get("device_name", "") or ""
system_name = state.get("system_name", "") or ""
operation_item = state.get("operation_item", "") or "" operation_item = state.get("operation_item", "") or ""
operation_code = state.get("operation_code", "") or "" operation_code = state.get("operation_code", "") or ""
operation_time = state.get("operation_time", "") or "" operation_time = state.get("operation_time", "") or ""
@ -283,7 +270,7 @@ async def agent_think(state: OperateGuideState) -> Dict[str, Any]:
tools_to_call.append("graph_rag_search") tools_to_call.append("graph_rag_search")
return {"scheme_generated": False,"rag_search_result": None,"graph_search_result": None,"deep_rag_results": None,"has_rag_result": False,"has_graph_result": False,"is_regenerating": False,"user_feedback_for_regenerate": "","follow_up_intent": "","user_confirmed_info": True,"initialized": True,"iteration_count": 0,"ask_round": 0,"agent_decision": "call_tools","tools_to_call": tools_to_call,"current_node": "call_tools",} return {"scheme_generated": False,"rag_search_result": None,"graph_search_result": None,"deep_rag_results": None,"has_rag_result": False,"has_graph_result": False,"is_regenerating": False,"user_feedback_for_regenerate": "","follow_up_intent": "","user_confirmed_info": True,"initialized": True,"iteration_count": 0,"ask_round": 0,"agent_decision": "call_tools","tools_to_call": tools_to_call,"current_node": "call_tools",}
history_str = build_history_str(history_messages, recent_count=HISTORY_RECENT_COUNT, assistant_truncate=HISTORY_ASSISTANT_TRUNCATE) history_str = build_history_str(history_messages, recent_count=5, assistant_truncate=300)
intent_system_prompt = OPERATE_PROMPTS["agent_think_intent_system"] intent_system_prompt = OPERATE_PROMPTS["agent_think_intent_system"]
@ -398,7 +385,6 @@ async def ask_user(state: OperateGuideState) -> Command:
missing_info = state.get("missing_info", []) missing_info = state.get("missing_info", [])
ship_number = state.get("ship_number", "") or "" ship_number = state.get("ship_number", "") or ""
device_name = state.get("device_name", "") or "" device_name = state.get("device_name", "") or ""
system_name = state.get("system_name", "") or ""
operation_item = state.get("operation_item", "") or "" operation_item = state.get("operation_item", "") or ""
operation_code = state.get("operation_code", "") or "" operation_code = state.get("operation_code", "") or ""
operation_time = state.get("operation_time", "") or "" operation_time = state.get("operation_time", "") or ""
@ -516,7 +502,6 @@ async def confirm_info(state: OperateGuideState) -> Command:
""" """
ship_number = state.get("ship_number", "") or "" ship_number = state.get("ship_number", "") or ""
device_name = state.get("device_name", "") or "" device_name = state.get("device_name", "") or ""
system_name = state.get("system_name", "") or ""
operation_item = state.get("operation_item", "") or "" operation_item = state.get("operation_item", "") or ""
operation_code = state.get("operation_code", "") or "" operation_code = state.get("operation_code", "") or ""
operation_time = state.get("operation_time", "") or "" operation_time = state.get("operation_time", "") or ""
@ -594,7 +579,6 @@ async def call_tools(state: OperateGuideState) -> Dict[str, Any]:
tools_to_call = state.get("tools_to_call", []) tools_to_call = state.get("tools_to_call", [])
ship_number = state.get("ship_number", "") or "" ship_number = state.get("ship_number", "") or ""
device_name = state.get("device_name", "") or "" device_name = state.get("device_name", "") or ""
system_name = state.get("system_name", "") or ""
operation_item = state.get("operation_item", "") or "" operation_item = state.get("operation_item", "") or ""
additional_info = state.get("additional_info", "") or "" additional_info = state.get("additional_info", "") or ""
@ -604,47 +588,24 @@ async def call_tools(state: OperateGuideState) -> Dict[str, Any]:
matched_kb_name = "" matched_kb_name = ""
matched_kb_id = "" matched_kb_id = ""
mapped_ship_number = ""
ship_scope_numbers = []
if ship_number:
ship_number, ship_scope_numbers, _ = await resolve_ship_scope_for_workflow(
ship_number=ship_number,
context="操作指导-call_tools",
)
if device_name:
device_name, _ = await normalize_device_name_for_workflow(
device_name=device_name,
ship_number=ship_number,
ship_numbers=ship_scope_numbers,
context="操作指导-call_tools",
)
system_name, _ = await resolve_device_system_for_workflow(
device_name=device_name,
ship_number=ship_number,
ship_numbers=ship_scope_numbers,
context="操作指导-call_tools",
)
async def _do_rag_search(): async def _do_rag_search():
from utils.ship_number_search import search_with_ship_number_strategy from utils.ship_number_search import search_with_ship_number_strategy
base_query = f"设备{device_name},操作{operation_item}的操作指导方案" base_query = f"如何执行{device_name}{operation_item}操作"
results, kb_name, kb_id, mapped_number = await search_with_ship_number_strategy( results, kb_name, kb_id, _ = await search_with_ship_number_strategy(
base_query=base_query, ship_number=ship_number, top_k=RAG_TOP_K, search_type="operate" base_query=base_query, ship_number=ship_number, top_k=5, search_type="operate"
) )
if not kb_name: if not kb_name:
kb_name = "通用知识库" kb_name = "通用知识库"
print(f"RAG 检索来源: {kb_name}") print(f"RAG 检索来源: {kb_name}")
return results, kb_name, kb_id, mapped_number return results, kb_name, kb_id
async def _do_graph_search(): async def _do_graph_search():
if not (device_name and operation_item): if not (device_name and operation_item):
return "" return ""
try: try:
graph_query = f"设备{device_name},操作{operation_item}的操作指导方案" graph_query = f"如何执行{device_name}{operation_item}操作"
results = await graph_rag_search.ainvoke({"query": graph_query, "top_k": GRAPH_TOP_K}) results = await graph_rag_search.ainvoke({"query": graph_query})
print(f"图谱检索完成,结果长度: {len(results) if results else 0}") print(f"图谱检索完成,结果长度: {len(results) if results else 0}")
return results return results
except Exception as e: except Exception as e:
@ -654,32 +615,18 @@ async def call_tools(state: OperateGuideState) -> Dict[str, Any]:
need_rag = "rag_search" in tools_to_call and rag_results is None need_rag = "rag_search" in tools_to_call and rag_results is None
need_graph = "graph_rag_search" in tools_to_call and graph_results is None need_graph = "graph_rag_search" in tools_to_call and graph_results is None
if need_rag: if need_rag and need_graph:
rag_results, matched_kb_name, matched_kb_id, mapped_ship_number = await _do_rag_search() (rag_results, matched_kb_name, matched_kb_id), graph_results = \
if mapped_ship_number and mapped_ship_number != ship_number: await asyncio.gather(_do_rag_search(), _do_graph_search())
print(f"[call_tools] RAG 舷号映射: '{ship_number}' -> '{mapped_ship_number}',重新标准化设备名") elif need_rag:
ship_number = mapped_ship_number rag_results, matched_kb_name, matched_kb_id = await _do_rag_search()
if device_name: elif need_graph:
device_name, _ = await normalize_device_name_for_workflow(
device_name=device_name,
ship_number=ship_number,
ship_numbers=[ship_number],
context="操作指导-call_tools-rag_mapped",
)
system_name, _ = await resolve_device_system_for_workflow(
device_name=device_name,
ship_number=ship_number,
ship_numbers=[ship_number],
context="操作指导-call_tools-rag_mapped",
)
if need_graph:
graph_results = await _do_graph_search() graph_results = await _do_graph_search()
if device_name: if device_name:
try: try:
print(f"[图册检索] 使用设备名称: {device_name}") print(f"[图册检索] 使用设备名称: {device_name}")
atlas_result = await atlas_retrieval(node_names=[device_name], top_k=ATLAS_TOP_K) atlas_result = await atlas_retrieval(node_names=[device_name], top_k=10)
if atlas_result.get("success", False): if atlas_result.get("success", False):
atlas_results = atlas_result.get("data", {}) atlas_results = atlas_result.get("data", {})
print(f"[图册检索] 图册检索结果: {list(atlas_results.keys())}") print(f"[图册检索] 图册检索结果: {list(atlas_results.keys())}")
@ -691,9 +638,9 @@ async def call_tools(state: OperateGuideState) -> Dict[str, Any]:
if not has_rag and not has_graph: if not has_rag and not has_graph:
print(f"[call_tools] 未找到任何相关资料,需要用户确认使用模型能力") print(f"[call_tools] 未找到任何相关资料,需要用户确认使用模型能力")
return {"rag_search_result": rag_results if isinstance(rag_results, list) else [],"graph_search_result": graph_results if isinstance(graph_results, str) else "","atlas_results": atlas_results,"matched_kb_id": matched_kb_id,"matched_kb_name": matched_kb_name,"ship_number": ship_number,"device_name": device_name,"system_name": system_name,"has_rag_result": False,"has_graph_result": False,"need_model_confirm": True,"current_node": "model_confirm",} return {"rag_search_result": rag_results if isinstance(rag_results, list) else [],"graph_search_result": graph_results if isinstance(graph_results, str) else "","atlas_results": atlas_results,"matched_kb_id": matched_kb_id,"matched_kb_name": matched_kb_name,"has_rag_result": False,"has_graph_result": False,"need_model_confirm": True,"current_node": "model_confirm",}
return {"rag_search_result": rag_results,"graph_search_result": graph_results,"atlas_results": atlas_results,"matched_kb_id": matched_kb_id,"matched_kb_name": matched_kb_name,"ship_number": ship_number,"device_name": device_name,"system_name": system_name,"has_rag_result": has_rag,"has_graph_result": has_graph,"need_model_confirm": False,"current_node": "generate_response",} return {"rag_search_result": rag_results,"graph_search_result": graph_results,"atlas_results": atlas_results,"matched_kb_id": matched_kb_id,"matched_kb_name": matched_kb_name,"has_rag_result": has_rag,"has_graph_result": has_graph,"need_model_confirm": False,"current_node": "generate_response",}
async def model_confirm(state: OperateGuideState) -> Command: async def model_confirm(state: OperateGuideState) -> Command:
""" """
@ -744,7 +691,6 @@ async def generate_response(state: OperateGuideState) -> Dict[str, Any]:
history_messages = state.get("history_messages", []) or [] history_messages = state.get("history_messages", []) or []
ship_number = state.get("ship_number", "") or "" ship_number = state.get("ship_number", "") or ""
device_name = state.get("device_name", "") or "" device_name = state.get("device_name", "") or ""
system_name = state.get("system_name", "") or ""
operation_item = state.get("operation_item", "") or "" operation_item = state.get("operation_item", "") or ""
additional_info = state.get("additional_info", "") or "" additional_info = state.get("additional_info", "") or ""
operation_code = state.get("operation_code", "") or "" operation_code = state.get("operation_code", "") or ""
@ -759,7 +705,7 @@ async def generate_response(state: OperateGuideState) -> Dict[str, Any]:
matched_kb_name = state.get("matched_kb_name", "") matched_kb_name = state.get("matched_kb_name", "")
print("rag_results", rag_results) print("rag_results", rag_results)
history_str = build_history_str(history_messages, recent_count=HISTORY_RECENT_COUNT, assistant_truncate=HISTORY_ASSISTANT_TRUNCATE) history_str = build_history_str(history_messages, recent_count=5, assistant_truncate=500)
has_rag = rag_results and len(rag_results) > 0 has_rag = rag_results and len(rag_results) > 0
has_graph = graph_results and len(graph_results) > 100 has_graph = graph_results and len(graph_results) > 100
@ -784,27 +730,28 @@ async def generate_response(state: OperateGuideState) -> Dict[str, Any]:
try: try:
response = await OpenaiAPI.open_api_chat_without_thinking(query=prompt,model=None,system_prompt=system_prompt,messages=[]) response = await OpenaiAPI.open_api_chat_without_thinking(query=prompt,model=None,system_prompt=system_prompt,messages=[])
response = normalize_markdown_images(response.strip(), original_paths=[]) if response else ""
return {"response": response.strip() if response else "抱歉,未能找到相关资料。","suggestedReplies": [{"title": "提供更详细信息", "content": "我来提供更详细的信息"},],"current_node": "end",} return {"response": response.strip() if response else "抱歉,未能找到相关资料。","suggestedReplies": [{"title": "提供更详细信息", "content": "我来提供更详细的信息"},],"current_node": "end",}
except Exception as e: except Exception as e:
return {"response": f"抱歉,处理过程中出现错误: {str(e)}","suggestedReplies": [],"current_node": "end",} return {"response": f"抱歉,处理过程中出现错误: {str(e)}","suggestedReplies": [],"current_node": "end",}
rag_ctx_parts: List[str] = []
if isinstance(rag_results, str) and rag_results.strip():
rag_ctx_parts = [rag_results.strip()]
elif isinstance(rag_results, list):
for item in rag_results[:8]:
if isinstance(item, dict):
text = (item.get("text") or "").strip()
if text:
rag_ctx_parts.append(text)
elif isinstance(item, str) and item.strip():
rag_ctx_parts.append(item.strip())
if len(graph_results) <= 50: if len(graph_results) <= 50:
graph_results = "" graph_results = ""
rag_ctx_parts: List[str] = [] all_source_text = graph_results + "\n" + "\n".join(rag_ctx_parts) if graph_results else "\n".join(rag_ctx_parts)
if not graph_results: original_image_paths = extract_image_paths(all_source_text)
if isinstance(rag_results, str) and rag_results.strip(): print("提取到的原始图片路径:", original_image_paths)
rag_ctx_parts = [rag_results.strip()]
elif isinstance(rag_results, list):
deduped_rag_results = dedupe_rag_results(rag_results, limit=4)
for item in deduped_rag_results:
if isinstance(item, dict):
text = (item.get("text") or "").strip()
if text:
rag_ctx_parts.append(text)
elif isinstance(item, str) and item.strip():
rag_ctx_parts.append(item.strip())
rag_answer = "" rag_answer = ""
rag_type = "none" rag_type = "none"
@ -813,12 +760,9 @@ async def generate_response(state: OperateGuideState) -> Dict[str, Any]:
rag_answer = graph_results rag_answer = graph_results
rag_type = "graph" rag_type = "graph"
elif rag_ctx_parts: elif rag_ctx_parts:
rag_answer = "\n\n".join(rag_ctx_parts) rag_answer = "\n\n".join(rag_ctx_parts[:3])
rag_type = "rag" rag_type = "rag"
original_image_paths = extract_image_paths(rag_answer)
print(f"提取到的{rag_type}原始图片路径:", original_image_paths)
if not rag_answer or rag_answer == "NO_RELEVANT_RESULT": if not rag_answer or rag_answer == "NO_RELEVANT_RESULT":
system_prompt = COMMON_PROMPTS["generate_response_no_rag_simple_system"].format(biz_label="操作指导") system_prompt = COMMON_PROMPTS["generate_response_no_rag_simple_system"].format(biz_label="操作指导")
prompt = OPERATE_PROMPTS["generate_response_no_rag_simple_user"].format( prompt = OPERATE_PROMPTS["generate_response_no_rag_simple_user"].format(
@ -829,7 +773,6 @@ async def generate_response(state: OperateGuideState) -> Dict[str, Any]:
try: try:
response = await OpenaiAPI.open_api_chat_without_thinking(query=prompt,model=None,system_prompt=system_prompt,messages=[]) response = await OpenaiAPI.open_api_chat_without_thinking(query=prompt,model=None,system_prompt=system_prompt,messages=[])
response = normalize_markdown_images(response.strip(), original_paths=[]) if response else ""
return {"response": response.strip() if response else "抱歉,未能找到相关资料。","suggestedReplies": [],"current_node": "end",} return {"response": response.strip() if response else "抱歉,未能找到相关资料。","suggestedReplies": [],"current_node": "end",}
except Exception as e: except Exception as e:
return {"response": f"抱歉,处理过程中出现错误: {str(e)}","suggestedReplies": [],"current_node": "end",} return {"response": f"抱歉,处理过程中出现错误: {str(e)}","suggestedReplies": [],"current_node": "end",}
@ -839,8 +782,6 @@ async def generate_response(state: OperateGuideState) -> Dict[str, Any]:
emit_callback_event([f"✨当前使用的检索类型为{rag_type}", f"✨本次检索策略为{rag_type}"], f"内容为{rag_answer[:50]}...") emit_callback_event([f"✨当前使用的检索类型为{rag_type}", f"✨本次检索策略为{rag_type}"], f"内容为{rag_answer[:50]}...")
detail_info = "" detail_info = ""
if system_name:
detail_info += f"- 所属系统:{system_name}\n"
if operation_code: if operation_code:
detail_info += f"- 操作代码/报警代码:{operation_code}\n" detail_info += f"- 操作代码/报警代码:{operation_code}\n"
if operation_time: if operation_time:
@ -902,15 +843,13 @@ async def generate_response(state: OperateGuideState) -> Dict[str, Any]:
try: try:
content_system_prompt = COMMON_PROMPTS["generate_response_content_system"].format(biz_label="操作指导") content_system_prompt = COMMON_PROMPTS["generate_response_content_system"].format(biz_label="操作指导")
emit_callback_event(["🤖 操作分析中", "🤖 方案生成中"], "正在生成操作指导方案...") raw_content = await OpenaiAPI.open_api_chat_without_thinking(model=None,system_prompt=content_system_prompt,messages=[{"role": "user", "content": prompt}],)
answer = await stream_generate_and_postprocess( raw_content = raw_content.strip() if raw_content else "抱歉,无法生成回答。"
system_prompt=content_system_prompt,
messages=[{"role": "user", "content": prompt}], emit_callback_event(["🤖 操作分析中", "🤖 方案生成中"], raw_content[:30] + "...")
original_image_paths=original_image_paths,
temperature=0.1, answer = await stream_format_and_postprocess(raw_content, original_image_paths, temperature=0.1)
fallback="抱歉,无法生成回答。"
)
answer = append_atlas_section(answer, atlas_results) answer = append_atlas_section(answer, atlas_results)
@ -927,7 +866,6 @@ async def regenerate_scheme_from_feedback(state: OperateGuideState) -> Dict[str,
""" """
ship_number = state.get("ship_number", "") or "" ship_number = state.get("ship_number", "") or ""
device_name = state.get("device_name", "") or "" device_name = state.get("device_name", "") or ""
system_name = state.get("system_name", "") or ""
operation_item = state.get("operation_item", "") or "" operation_item = state.get("operation_item", "") or ""
additional_info = state.get("additional_info", "") or "" additional_info = state.get("additional_info", "") or ""
operation_code = state.get("operation_code", "") or "" operation_code = state.get("operation_code", "") or ""
@ -946,8 +884,6 @@ async def regenerate_scheme_from_feedback(state: OperateGuideState) -> Dict[str,
return {"is_regenerating": True,"rag_search_result": None,"graph_search_result": None,"has_rag_result": False,"has_graph_result": False,"scheme_generated": False,"current_node": "call_tools","tools_to_call": ["rag_search", "graph_rag_search"],} return {"is_regenerating": True,"rag_search_result": None,"graph_search_result": None,"has_rag_result": False,"has_graph_result": False,"scheme_generated": False,"current_node": "call_tools","tools_to_call": ["rag_search", "graph_rag_search"],}
detail_info = "" detail_info = ""
if system_name:
detail_info += f"- 所属系统:{system_name}\n"
if operation_code: if operation_code:
detail_info += f"- 操作代码/报警代码:{operation_code}\n" detail_info += f"- 操作代码/报警代码:{operation_code}\n"
if operation_time: if operation_time:
@ -982,14 +918,14 @@ async def regenerate_scheme_from_feedback(state: OperateGuideState) -> Dict[str,
try: try:
content_system_prompt = COMMON_PROMPTS["regenerate_scheme_from_feedback_system"].format(biz_label="操作指导") content_system_prompt = COMMON_PROMPTS["regenerate_scheme_from_feedback_system"].format(biz_label="操作指导")
content_prompt = prompt.replace("【输出要求】", "【输出要求】\n- 请专注于内容的准确性、完整性和逻辑性,不要担心格式的美观性\n- 可以适当使用简单的段落分隔,但不要使用复杂的格式")
raw_content = await OpenaiAPI.open_api_chat_without_thinking(model=None,system_prompt=content_system_prompt,messages=[{"role": "user", "content": content_prompt}],)
raw_content = raw_content.strip() if raw_content else "抱歉,无法根据反馈修改方案。"
original_image_paths = extract_image_paths(last_scheme) original_image_paths = extract_image_paths(last_scheme)
answer = await stream_generate_and_postprocess( answer = await stream_format_and_postprocess(raw_content, original_image_paths, temperature=0.3, content_label="操作指导方案")
system_prompt=content_system_prompt,
messages=[{"role": "user", "content": prompt}],
original_image_paths=original_image_paths,
temperature=0.3,
fallback="抱歉,无法根据反馈修改方案。"
)
suggested_replies = [{"title": "方案有误", "content": "方案还是有问题:"},] suggested_replies = [{"title": "方案有误", "content": "方案还是有问题:"},]
@ -1007,7 +943,6 @@ async def follow_up_qa(state: OperateGuideState) -> Dict[str, Any]:
history_messages = state.get("history_messages", []) or [] history_messages = state.get("history_messages", []) or []
ship_number = state.get("ship_number", "") or "" ship_number = state.get("ship_number", "") or ""
device_name = state.get("device_name", "") or "" device_name = state.get("device_name", "") or ""
system_name = state.get("system_name", "") or ""
operation_item = state.get("operation_item", "") or "" operation_item = state.get("operation_item", "") or ""
history_message_json = json.dumps(history_messages, ensure_ascii=False) if history_messages else "" history_message_json = json.dumps(history_messages, ensure_ascii=False) if history_messages else ""
@ -1159,27 +1094,6 @@ async def deep_rag_search(state: OperateGuideState) -> Dict[str, Any]:
combined_query = state.get("combined_query", "") or "" combined_query = state.get("combined_query", "") or ""
history_messages = state.get("history_messages", []) or [] history_messages = state.get("history_messages", []) or []
last_generated_scheme = state.get("last_generated_scheme", "") or "" last_generated_scheme = state.get("last_generated_scheme", "") or ""
ship_scope_numbers = []
if ship_number:
ship_number, ship_scope_numbers, _ = await resolve_ship_scope_for_workflow(
ship_number=ship_number,
context="操作指导-deep_rag_search",
)
if device_name:
device_name, _ = await normalize_device_name_for_workflow(
device_name=device_name,
ship_number=ship_number,
ship_numbers=ship_scope_numbers,
context="操作指导-deep_rag_search",
)
system_name, _ = await resolve_device_system_for_workflow(
device_name=device_name,
ship_number=ship_number,
ship_numbers=ship_scope_numbers,
context="操作指导-deep_rag_search",
)
deep_results = {} deep_results = {}
deep_analysis_points = [] deep_analysis_points = []
@ -1189,7 +1103,7 @@ async def deep_rag_search(state: OperateGuideState) -> Dict[str, Any]:
# ========== 第一步:分析之前方案和用户问题,选择深度分析点 ========== # ========== 第一步:分析之前方案和用户问题,选择深度分析点 ==========
if last_generated_scheme or combined_query: if last_generated_scheme or combined_query:
try: try:
history_str = build_history_str(history_messages, recent_count=HISTORY_RECENT_COUNT, assistant_truncate=HISTORY_ASSISTANT_TRUNCATE) history_str = build_history_str(history_messages, recent_count=4, assistant_truncate=300)
detail_info = "" detail_info = ""
if operation_code: if operation_code:
@ -1237,12 +1151,9 @@ async def deep_rag_search(state: OperateGuideState) -> Dict[str, Any]:
if operation_item and operation_item not in search_query: if operation_item and operation_item not in search_query:
search_query = f"{search_query} {operation_item}" search_query = f"{search_query} {operation_item}"
rag_result = await rag_search(search_query, top_k=DEEP_RAG_TOP_K) rag_result = await rag_search(search_query, top_k=3)
if rag_result.get("success", False): if rag_result.get("success", False):
deep_results[f"analysis_point_{idx}"] = dedupe_rag_results( deep_results[f"analysis_point_{idx}"] = convert_rag_result(rag_result)
convert_rag_result(rag_result),
limit=DEEP_RAG_TOP_K,
)
else: else:
deep_results[f"analysis_point_{idx}"] = [] deep_results[f"analysis_point_{idx}"] = []
except Exception as e: except Exception as e:
@ -1254,7 +1165,7 @@ async def deep_rag_search(state: OperateGuideState) -> Dict[str, Any]:
try: try:
if device_name and operation_item: if device_name and operation_item:
graph_query = f"设备{device_name},操作{operation_item}的操作指导方案" graph_query = f"设备{device_name},操作{operation_item}的操作指导方案"
graph_deep_results = await graph_rag_search.ainvoke({"query": graph_query, "top_k": GRAPH_TOP_K}) graph_deep_results = await graph_rag_search.ainvoke({"query": graph_query})
if graph_deep_results: if graph_deep_results:
print(f"[深度RAG] 图谱检索结果长度: {len(graph_deep_results)}") print(f"[深度RAG] 图谱检索结果长度: {len(graph_deep_results)}")
except Exception as e: except Exception as e:
@ -1267,14 +1178,14 @@ async def deep_rag_search(state: OperateGuideState) -> Dict[str, Any]:
if entity_names: if entity_names:
print(f"[深度RAG] 图册检索实体: {entity_names}") print(f"[深度RAG] 图册检索实体: {entity_names}")
atlas_result = await atlas_retrieval(node_names=entity_names, top_k=ATLAS_TOP_K) atlas_result = await atlas_retrieval(node_names=entity_names, top_k=10)
if atlas_result.get("success", False): if atlas_result.get("success", False):
atlas_results = atlas_result.get("data", {}) atlas_results = atlas_result.get("data", {})
print(f"[深度RAG] 图册检索结果: {list(atlas_results.keys())}") print(f"[深度RAG] 图册检索结果: {list(atlas_results.keys())}")
except Exception as e: except Exception as e:
print(f"[深度RAG] 图册检索失败: {str(e)}") print(f"[深度RAG] 图册检索失败: {str(e)}")
return {"deep_rag_results": deep_results,"deep_analysis_points": deep_analysis_points,"graph_search_result": graph_deep_results or (state.get("graph_search_result", "") or ""),"atlas_results": atlas_results,"ship_number": ship_number,"device_name": device_name,"system_name": system_name,"current_node": "generate_deep_response",} return {"deep_rag_results": deep_results,"deep_analysis_points": deep_analysis_points,"graph_search_result": graph_deep_results or (state.get("graph_search_result", "") or ""),"atlas_results": atlas_results,"current_node": "generate_deep_response",}
async def generate_deep_response(state: OperateGuideState) -> Dict[str, Any]: async def generate_deep_response(state: OperateGuideState) -> Dict[str, Any]:
@ -1285,7 +1196,6 @@ async def generate_deep_response(state: OperateGuideState) -> Dict[str, Any]:
history_messages = state.get("history_messages", []) or [] history_messages = state.get("history_messages", []) or []
ship_number = state.get("ship_number", "") or "" ship_number = state.get("ship_number", "") or ""
device_name = state.get("device_name", "") or "" device_name = state.get("device_name", "") or ""
system_name = state.get("system_name", "") or ""
operation_item = state.get("operation_item", "") or "" operation_item = state.get("operation_item", "") or ""
operation_code = state.get("operation_code", "") or "" operation_code = state.get("operation_code", "") or ""
operation_time = state.get("operation_time", "") or "" operation_time = state.get("operation_time", "") or ""
@ -1308,7 +1218,7 @@ async def generate_deep_response(state: OperateGuideState) -> Dict[str, Any]:
results = deep_rag_results.get(key, []) results = deep_rag_results.get(key, [])
if results: if results:
analysis_points_text += f"\n【分析点:{point}\n" analysis_points_text += f"\n【分析点:{point}\n"
for i, item in enumerate(sorted(results, key=lambda x: float(x.get("score") or 0) if isinstance(x, dict) else 0, reverse=True)[:2], 1): for i, item in enumerate(results[:2], 1):
if isinstance(item, dict): if isinstance(item, dict):
text = item.get("text", "") text = item.get("text", "")
if text: if text:
@ -1318,22 +1228,20 @@ async def generate_deep_response(state: OperateGuideState) -> Dict[str, Any]:
original_rag_text = "" original_rag_text = ""
if original_rag_results: if original_rag_results:
original_rag_text = "\n【原始检索资料】\n" original_rag_text = "\n【原始检索资料】\n"
for i, item in enumerate(dedupe_rag_results(original_rag_results, limit=2), 1): for i, item in enumerate(original_rag_results[:3], 1):
if isinstance(item, dict): if isinstance(item, dict):
text = item.get("text", "") text = item.get("text", "")
if text: if text:
original_rag_text += f"[{i}] {text}\n" original_rag_text += f"[{i}] {text}\n"
if graph_results and len(graph_results) > 50: # 整理图册资料
original_image_paths = extract_image_paths(graph_results) atlas_text = format_atlas_results(atlas_results)
print("提取到的deep graph原始图片路径:", original_image_paths)
else: # 提取所有资料中的图片路径
original_image_paths = extract_image_paths(analysis_points_text + original_rag_text) all_source_text = analysis_points_text + original_rag_text + atlas_text + graph_results
print("提取到的deep rag原始图片路径:", original_image_paths) original_image_paths = extract_image_paths(all_source_text)
detail_info = "" detail_info = ""
if system_name:
detail_info += f"- 所属系统:{system_name}\n"
if operation_code: if operation_code:
detail_info += f"- 操作代码:{operation_code}\n" detail_info += f"- 操作代码:{operation_code}\n"
if operation_time: if operation_time:
@ -1370,15 +1278,13 @@ async def generate_deep_response(state: OperateGuideState) -> Dict[str, Any]:
try: try:
content_system_prompt = COMMON_PROMPTS["generate_deep_response_content_system"].format(biz_label="操作指导") content_system_prompt = COMMON_PROMPTS["generate_deep_response_content_system"].format(biz_label="操作指导")
emit_callback_event(["🤖 分析中", "🤖 操作指导生成中"], "正在生成深度操作指导...") raw_content = await OpenaiAPI.open_api_chat_without_thinking(model=None,system_prompt=content_system_prompt,messages=[{"role": "user", "content": prompt}],)
answer = await stream_generate_and_postprocess( raw_content = raw_content.strip() if raw_content else "抱歉,无法生成深度分析。"
system_prompt=content_system_prompt,
messages=[{"role": "user", "content": prompt}], emit_callback_event(["🤖 分析中", "🤖 操作指导生成中"], raw_content[:30] + "...")
original_image_paths=original_image_paths,
temperature=0.1, answer = await stream_format_and_postprocess(raw_content, original_image_paths, temperature=0.1)
fallback="抱歉,无法生成深度分析。"
)
answer = append_atlas_section(answer, atlas_results) answer = append_atlas_section(answer, atlas_results)
@ -1516,7 +1422,7 @@ async def run_operate_workflow(extracted_text: str,combined_query: str,history_m
initial_state["user_confirmed_info"] = True initial_state["user_confirmed_info"] = True
result = await app.ainvoke(initial_state, config) result = await app.ainvoke(initial_state, config)
else: else:
initial_state = {"extracted_text": extracted_text,"combined_query": combined_query,"history_messages": parse_history(history_message),"ship_number": "","device_name": "","system_name": "","operation_item": "","additional_info": "", initial_state = {"extracted_text": extracted_text,"combined_query": combined_query,"history_messages": parse_history(history_message),"ship_number": "","device_name": "","operation_item": "","additional_info": "",
"operation_code": "","operation_time": "","operation_frequency": "","tried_measures": "","running_condition": "","rag_search_result": None,"graph_search_result": None,"response": "", "operation_code": "","operation_time": "","operation_frequency": "","tried_measures": "","running_condition": "","rag_search_result": None,"graph_search_result": None,"response": "",
"suggestedReplies": [],"actions": [],"error_message": "","agent_decision": "","agent_reasoning": "","tools_to_call": [],"missing_info": [],"info_completeness": 0.0, "suggestedReplies": [],"actions": [],"error_message": "","agent_decision": "","agent_reasoning": "","tools_to_call": [],"missing_info": [],"info_completeness": 0.0,
"matched_kb_id": "","matched_kb_name": "","iteration_count": 0,"max_iterations": 5,"scheme_generated": False,"waiting_for_supplement": False,"extracted_info": {}, "matched_kb_id": "","matched_kb_name": "","iteration_count": 0,"max_iterations": 5,"scheme_generated": False,"waiting_for_supplement": False,"extracted_info": {},
@ -1529,7 +1435,7 @@ async def run_operate_workflow(extracted_text: str,combined_query: str,history_m
traceback.print_exc() traceback.print_exc()
result = {} result = {}
else: else:
initial_state = {"extracted_text": extracted_text,"combined_query": combined_query,"history_messages": parse_history(history_message),"ship_number": "","device_name": "","system_name": "","operation_item": "","additional_info": "","operation_code": "","operation_time": "", initial_state = {"extracted_text": extracted_text,"combined_query": combined_query,"history_messages": parse_history(history_message),"ship_number": "","device_name": "","operation_item": "","additional_info": "","operation_code": "","operation_time": "",
"operation_frequency": "","tried_measures": "","running_condition": "","rag_search_result": None,"graph_search_result": None,"response": "","suggestedReplies": [],"actions": [],"error_message": "","agent_decision": "","agent_reasoning": "","tools_to_call": [], "operation_frequency": "","tried_measures": "","running_condition": "","rag_search_result": None,"graph_search_result": None,"response": "","suggestedReplies": [],"actions": [],"error_message": "","agent_decision": "","agent_reasoning": "","tools_to_call": [],
"missing_info": [],"info_completeness": 0.0,"matched_kb_id": "","matched_kb_name": "","iteration_count": 0,"max_iterations": 5,"scheme_generated": False,"waiting_for_supplement": False,"extracted_info": {},"has_rag_result": False,"has_graph_result": False, "missing_info": [],"info_completeness": 0.0,"matched_kb_id": "","matched_kb_name": "","iteration_count": 0,"max_iterations": 5,"scheme_generated": False,"waiting_for_supplement": False,"extracted_info": {},"has_rag_result": False,"has_graph_result": False,
"is_execution_feedback": False,"execution_detail": "","ask_round": 0,"user_confirmed_info": False,"need_model_confirm": False,"user_confirmed_model": False,"waiting_feedback": False,"user_feedback": "","waiting_report_confirm": False,"report_confirmed": False,"initialized": False,"current_node": "","user_intent": "","last_combined_query": "",} "is_execution_feedback": False,"execution_detail": "","ask_round": 0,"user_confirmed_info": False,"need_model_confirm": False,"user_confirmed_model": False,"waiting_feedback": False,"user_feedback": "","waiting_report_confirm": False,"report_confirmed": False,"initialized": False,"current_node": "","user_intent": "","last_combined_query": "",}
@ -1560,3 +1466,4 @@ async def run_operate_workflow(extracted_text: str,combined_query: str,history_m
return {"response": result.get("response", ""),"actions": result.get("actions", []),"result_tag": route_flag,"suggestedReplies": result.get("suggestedReplies", []),"route_flag": route_flag,} return {"response": result.get("response", ""),"actions": result.get("actions", []),"result_tag": route_flag,"suggestedReplies": result.get("suggestedReplies", []),"route_flag": route_flag,}

File diff suppressed because it is too large Load Diff

View File

@ -356,8 +356,8 @@ async def generate_analysis_report_text(state: HistoryQAAnalysisState) -> Dict[s
actions: List[Dict[str, Any]] = [] actions: List[Dict[str, Any]] = []
if word_result and os.path.exists(word_result): if word_result and os.path.exists(word_result):
base_url = SERVER_CONFIG.get("base_url") base_url = SERVER_CONFIG.get("report")
download_url = f"{base_url}/api/download/{file_name}" download_url = f"http://192.168.1.164:9088/api/download/{file_name}"
actions.append({ actions.append({
"type": "download", "type": "download",

File diff suppressed because it is too large Load Diff

View File

@ -1,493 +0,0 @@
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