Compare commits
No commits in common. "46-wiki" and "main" have entirely different histories.
1
.gitattributes
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
docker-images/*.tar.gz filter=lfs diff=lfs merge=lfs -text
|
||||||
34
.gitignore
vendored
@ -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
38
README.md
@ -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
|
||||||
|
```
|
||||||
@ -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
@ -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__":
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
1012
app8964.py
Normal file
BIN
audio/test.wav
493
checkpointer_config521.py
Normal 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 '未配置'}")
|
||||||
|
|
||||||
108
config.py
@ -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
@ -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
@ -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"
|
||||||
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 1023 KiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.8 KiB |
211
prompts.py
@ -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. 确保图片链接在相关位置自然展示使用,并且只输出准确格式,`` \n"
|
||||||
"4. 图片只输出为单个 Markdown 图片标签,格式为 ``;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"
|
"示例一: 原始内容:``\n"
|
||||||
"- 输出图片时必须逐字复制原始图片的文件名和路径,只整理 Markdown 形式,不能引入任何新文件名\n"
|
" 规范后输出:``\n"
|
||||||
"- 本提示词中的格式说明不是可用图片资源,禁止原样输出\n"
|
"示例二: 原始内容:``\n"
|
||||||
|
" 规范后输出:``\n"
|
||||||
|
"示例三: 原始内容:`images/xxx.jpg`\n"
|
||||||
|
" 规范后输出:``\n"
|
||||||
|
"示例四: 原始内容:`xxx.jpg`\n"
|
||||||
|
" 规范后输出:``\n"
|
||||||
|
"示例五: 原始内容:``\n"
|
||||||
|
" 规范后输出:``\n"
|
||||||
|
"示例六: 原始内容:``\n"
|
||||||
|
" 规范后输出:``\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. 确保图片链接保持原样:``\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. **重要**:如果参考资料中包含图片链接(格式为 `` 或 `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. **重要**:如果参考资料中包含图片链接(格式为 `` 或 `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. **重要**:如果参考资料中包含图片链接(格式为 `` 或 `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
@ -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())
|
||||||
@ -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
@ -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@"
|
||||||
|
}
|
||||||
@ -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},
|
|
||||||
)
|
|
||||||
@ -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,
|
||||||
|
|||||||
@ -37,43 +37,62 @@ 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 response.status_code != 200:
|
||||||
|
result = "其他"
|
||||||
|
neo4j_cache[device_name_clean] = result
|
||||||
|
return result
|
||||||
|
|
||||||
|
result_json = response.json()
|
||||||
|
|
||||||
if not result_json.get("success", False):
|
if not result_json.get("success", False):
|
||||||
result = "其他"
|
result = "其他"
|
||||||
neo4j_cache[cache_key] = result
|
neo4j_cache[device_name_clean] = result
|
||||||
return result
|
return result
|
||||||
|
|
||||||
systems = result_json.get("systems", [])
|
systems = result_json.get("systems", [])
|
||||||
if not systems or not isinstance(systems, list):
|
if not systems or not isinstance(systems, list):
|
||||||
result = "其他"
|
result = "其他"
|
||||||
neo4j_cache[cache_key] = result
|
neo4j_cache[device_name_clean] = result
|
||||||
return result
|
return result
|
||||||
|
|
||||||
first_system = systems[0]
|
first_system = systems[0]
|
||||||
@ -81,17 +100,17 @@ async def get_device_system_from_neo4j(device_name: str, ship_number: Optional[s
|
|||||||
system_name = first_system.get("name") or first_system.get("系统名称") or first_system.get("名称")
|
system_name = first_system.get("name") or first_system.get("系统名称") or first_system.get("名称")
|
||||||
if system_name:
|
if system_name:
|
||||||
result = system_name.strip()
|
result = system_name.strip()
|
||||||
neo4j_cache[cache_key] = result
|
neo4j_cache[device_name_clean] = result
|
||||||
return result
|
return result
|
||||||
|
|
||||||
result = "其他"
|
result = "其他"
|
||||||
neo4j_cache[cache_key] = result
|
neo4j_cache[device_name_clean] = result
|
||||||
return 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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
1232
tools/graph_tools.py
1067
tools/graph_tools.py包含ship_number的故障图谱检索
Normal file
1076
tools/graph_tools623.py
Normal file
1093
tools/graph_tools624.py
Normal file
208
tools/ship_model_db528.py
Normal 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
@ -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)
|
||||||
|
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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)
|
|
||||||
@ -1,110 +1,20 @@
|
|||||||
"""图片处理工具模块。"""
|
"""
|
||||||
|
图片处理工具模块 - 最终版 v4
|
||||||
|
核心思路(最简单直接):
|
||||||
|
1. 找到所有 .jpg/.png 文件名及其周围的 
|
||||||
|
"""
|
||||||
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 图片:
|
|
||||||
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''
|
|
||||||
|
|
||||||
# 先处理已经是 Markdown 图片语法的内容,包含任意 alt 文本。
|
|
||||||
# 例如:
|
|
||||||
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
|
|
||||||
)
|
|
||||||
|
|
||||||
# 再兜底清理模型或前序处理产生的嵌套图片语法。
|
|
||||||
# 例如:
|
|
||||||
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 '
|
||||||
|
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''
|
||||||
|
|
||||||
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''
|
standard_format = f''
|
||||||
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 """【图片格式特别要求】
|
||||||
- 只允许引用本次参考资料中真实出现的图片链接
|
- 检索结果中的图片链接格式通常为:``
|
||||||
- 输出图片时使用 Markdown 图片标签:alt 文本固定为「图片」,括号内必须是参考资料里的原始完整 URL
|
- 输出时必须严格保留此格式,不得修改、省略或截断
|
||||||
- 图片文件名、路径和 URL 前缀必须逐字复制,不得补全、改写、截断、转义或根据记忆/示例构造
|
- 图片的 alt 文本必须保持为「图片」
|
||||||
- 不要输出提示词中的占位符或示例文件名,不要把普通文字描述改写成图片链接
|
- 图片 URL 必须完整包含 `/api/v1/knowledge/files/images` 前缀
|
||||||
- 图片规则只用于生成图片标签,不要围绕图片资源状态输出任何文字说明
|
- 不要将图片链接转换为纯文本描述
|
||||||
- 多个图片可在对应位置分别引用并保持原始顺序;同一图片只引用一次"""
|
- 如果有多个图片,在对应位置分别引用,保持原始顺序
|
||||||
|
- 严禁修改图片文件名或路径结构
|
||||||
|
- 如果需要展示图片,直接使用原始的 `` 格式即可"""
|
||||||
|
|
||||||
|
|||||||
@ -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)} 进行检索..."
|
||||||
|
|||||||
467
utils/ship_number_search528.py
Normal 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 ""
|
||||||
@ -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
|
|
||||||
@ -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
@ -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`
|
||||||
|
- ! `url`
|
||||||
|
返回 [(start, end, url), ...]
|
||||||
|
"""
|
||||||
|
results = []
|
||||||
|
|
||||||
|
# 格式1: 
|
||||||
|
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 } $
|
||||||
|
|
||||||
|
|
||||||
|
### **四、图片测试**
|
||||||
|
|
||||||
|

|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
result_path = generate_report_word(md)
|
||||||
|
print(f"报告已生成,路径: {result_path}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"程序执行出错: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
@ -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.
|
|
||||||
"""
|
|
||||||
@ -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"
|
|
||||||
1798
wiki_engine/db.py
@ -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 {}
|
|
||||||
@ -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"])
|
|
||||||
@ -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
|
|
||||||
@ -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))
|
|
||||||
@ -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,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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,21 +868,15 @@ 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",}
|
||||||
|
|
||||||
if len(graph_results) <= 50:
|
|
||||||
graph_results = ""
|
|
||||||
|
|
||||||
rag_ctx_parts: List[str] = []
|
rag_ctx_parts: List[str] = []
|
||||||
if not graph_results:
|
|
||||||
if isinstance(rag_results, str) and rag_results.strip():
|
if isinstance(rag_results, str) and rag_results.strip():
|
||||||
rag_ctx_parts = [rag_results.strip()]
|
rag_ctx_parts = [rag_results.strip()]
|
||||||
elif isinstance(rag_results, list):
|
elif isinstance(rag_results, list):
|
||||||
deduped_rag_results = dedupe_rag_results(rag_results, limit=4)
|
for item in rag_results[:1]:
|
||||||
for item in deduped_rag_results:
|
|
||||||
if isinstance(item, dict):
|
if isinstance(item, dict):
|
||||||
text = (item.get("text") or "").strip()
|
text = (item.get("text") or "").strip()
|
||||||
if text:
|
if text:
|
||||||
@ -961,6 +884,13 @@ async def generate_response(state: FaultDiagnosisState) -> Dict[str, Any]:
|
|||||||
elif isinstance(item, str) and item.strip():
|
elif isinstance(item, str) and item.strip():
|
||||||
rag_ctx_parts.append(item.strip())
|
rag_ctx_parts.append(item.strip())
|
||||||
|
|
||||||
|
if len(graph_results) <= 50:
|
||||||
|
graph_results = ""
|
||||||
|
|
||||||
|
all_source_text = graph_results + "\n" + "\n".join(rag_ctx_parts) if graph_results else "\n".join(rag_ctx_parts)
|
||||||
|
original_image_paths = extract_image_paths(all_source_text)
|
||||||
|
print("提取到的原始图片路径:", original_image_paths)
|
||||||
|
|
||||||
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,}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
1648
workflows/workflow_fault_diagnosis.py写死
Normal 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,21 +730,15 @@ 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",}
|
||||||
|
|
||||||
if len(graph_results) <= 50:
|
|
||||||
graph_results = ""
|
|
||||||
|
|
||||||
rag_ctx_parts: List[str] = []
|
rag_ctx_parts: List[str] = []
|
||||||
if not graph_results:
|
|
||||||
if isinstance(rag_results, str) and rag_results.strip():
|
if isinstance(rag_results, str) and rag_results.strip():
|
||||||
rag_ctx_parts = [rag_results.strip()]
|
rag_ctx_parts = [rag_results.strip()]
|
||||||
elif isinstance(rag_results, list):
|
elif isinstance(rag_results, list):
|
||||||
deduped_rag_results = dedupe_rag_results(rag_results, limit=4)
|
for item in rag_results[:8]:
|
||||||
for item in deduped_rag_results:
|
|
||||||
if isinstance(item, dict):
|
if isinstance(item, dict):
|
||||||
text = (item.get("text") or "").strip()
|
text = (item.get("text") or "").strip()
|
||||||
if text:
|
if text:
|
||||||
@ -806,6 +746,13 @@ async def generate_response(state: OperateGuideState) -> Dict[str, Any]:
|
|||||||
elif isinstance(item, str) and item.strip():
|
elif isinstance(item, str) and item.strip():
|
||||||
rag_ctx_parts.append(item.strip())
|
rag_ctx_parts.append(item.strip())
|
||||||
|
|
||||||
|
if len(graph_results) <= 50:
|
||||||
|
graph_results = ""
|
||||||
|
|
||||||
|
all_source_text = graph_results + "\n" + "\n".join(rag_ctx_parts) if graph_results else "\n".join(rag_ctx_parts)
|
||||||
|
original_image_paths = extract_image_paths(all_source_text)
|
||||||
|
print("提取到的原始图片路径:", original_image_paths)
|
||||||
|
|
||||||
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,}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
1469
workflows/workflow_operate624.py
Normal 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",
|
||||||
|
|||||||
@ -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
|
|
||||||