Compare commits
No commits in common. "main" and "46" have entirely different histories.
1
.gitattributes
vendored
@ -1 +0,0 @@
|
||||
docker-images/*.tar.gz filter=lfs diff=lfs merge=lfs -text
|
||||
32
.gitignore
vendored
@ -1,16 +1,30 @@
|
||||
# Python caches
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.cache/
|
||||
|
||||
*.log
|
||||
*.tmp
|
||||
*.swp
|
||||
.DS_Store
|
||||
.nfs*
|
||||
# Virtual environments
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
|
||||
# Local env files
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# Runtime data and generated outputs
|
||||
data/*.db
|
||||
data/*.db-*
|
||||
created_pdf/
|
||||
created_word/
|
||||
image_output/
|
||||
audio/
|
||||
data/
|
||||
|
||||
# Logs and temporary files
|
||||
*.log
|
||||
*.tmp
|
||||
.pytest_cache/
|
||||
|
||||
# OS/editor files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
38
README.md
@ -1,38 +0,0 @@
|
||||
# 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
|
||||
```
|
||||
39
app.py
@ -31,6 +31,7 @@ from workflow_registry import WORKFLOW_CONFIG, VALID_ROUTE_FLAGS
|
||||
from main_agent import extract_conversation_title
|
||||
from workflows.history_manager import filter_image_urls, preprocess_from_request
|
||||
from config import set_request_user_config
|
||||
from utils.source_citations import merge_rag_source_citations
|
||||
from checkpointer_config import (
|
||||
CheckpointerManager,
|
||||
checkpointer_manager
|
||||
@ -462,7 +463,7 @@ async def stream_main_agent_execution(
|
||||
agent_task = asyncio.create_task(run_agent())
|
||||
|
||||
execution_complete_received = False
|
||||
rag_result = []
|
||||
rag_result = {}
|
||||
graph_result = []
|
||||
while True:
|
||||
try:
|
||||
@ -482,16 +483,7 @@ async def stream_main_agent_execution(
|
||||
if title == "知识库搜索工具" and result and isinstance(result, dict):
|
||||
print("===============================", result)
|
||||
rag_result_raw = result.get("sourceCitation", {})
|
||||
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
|
||||
merge_rag_source_citations(rag_result, rag_result_raw)
|
||||
elif title == "图谱检索工具" and result and isinstance(result, dict):
|
||||
print("===============================", result)
|
||||
graph_result = result.get("xxxx.graph", [])
|
||||
@ -647,12 +639,25 @@ async def download_file(file_name: str):
|
||||
Returns:
|
||||
文件下载响应
|
||||
"""
|
||||
pdf_path = os.path.join(PDF_DIR, file_name)
|
||||
word_path = os.path.join(WORD_DIR, file_name)
|
||||
file_path = pdf_path if os.path.exists(pdf_path) else word_path
|
||||
safe_name = os.path.basename(file_name)
|
||||
if safe_name != file_name or safe_name in ("", ".", ".."):
|
||||
raise HTTPException(status_code=400, detail="Invalid file name")
|
||||
|
||||
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}")
|
||||
# 检查文件是否存在
|
||||
ext = os.path.splitext(file_name)[1].lower()
|
||||
ext = os.path.splitext(safe_name)[1].lower()
|
||||
media_type = {
|
||||
".pdf": "application/pdf",
|
||||
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
@ -662,8 +667,9 @@ async def download_file(file_name: str):
|
||||
# 返回文件
|
||||
return FileResponse(
|
||||
path=file_path,
|
||||
filename=file_name,
|
||||
media_type=media_type
|
||||
filename=safe_name,
|
||||
media_type=media_type,
|
||||
headers={"X-Content-Type-Options": "nosniff"}
|
||||
)
|
||||
|
||||
|
||||
@ -1003,4 +1009,3 @@ if __name__ == "__main__":
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
1012
app8964.py
BIN
audio/test.wav
Normal file
@ -1,493 +0,0 @@
|
||||
"""
|
||||
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 '未配置'}")
|
||||
|
||||
61
config.py
@ -12,23 +12,22 @@ load_dotenv()
|
||||
|
||||
# ==================== 大模型配置 ====================
|
||||
LLM_CONFIG = {
|
||||
# "model": "Qwen3.5-35B-A3B",
|
||||
"model": "46-qwen3.5-35B",
|
||||
# "model": "Qwen3-32B",
|
||||
# "model": "Qwen3-14B",
|
||||
#"base_url": "http://192.168.1.164:9800/v1",
|
||||
"base_url":"https://openai.zkzdht.com/v1",
|
||||
"base_url": "http://192.168.0.46:59800/v1",
|
||||
"api_key": "gpustack_dee9ca823290886c_5edfc86aeeeceb1e9ee5162941cb2cb5",
|
||||
"temperature": float("0.1"),
|
||||
"max_tokens": int("20096"),
|
||||
"max_tokens": int("9216"),
|
||||
"timeout": int("30")
|
||||
}
|
||||
|
||||
# ==================== 嵌入模型配置 ====================
|
||||
EMBEDDING_CONFIG = {
|
||||
"model": "bge-m3",
|
||||
"base_url": "http://embedding:9700/v1/embeddings",
|
||||
"base_url": "http://192.168.0.46:59700/v1/embeddings",
|
||||
"api_key": "gpustack_dee9ca823290886c_5edfc86aeeeceb1e9ee5162941cb2cb5",
|
||||
"base_url_v2" : "http://embedding:9700/v1/embeddings",
|
||||
"base_url_v2" : "http://192.168.0.46:59700/v1",
|
||||
}
|
||||
|
||||
|
||||
@ -38,7 +37,7 @@ _request_user_config: ContextVar[Optional[Dict[str, str]]] = ContextVar('request
|
||||
|
||||
# RAG配置的默认值
|
||||
_RAG_CONFIG_DEFAULT = {
|
||||
"search_endpoint": "http://192.168.1.108:11000/api/v1/knowledge/search/",
|
||||
"search_endpoint": "http://192.168.0.46:11000/api/v1/knowledge/search/",
|
||||
"x-user-id": "1",
|
||||
"x-user-name": "testuser",
|
||||
"x-role": "admin",
|
||||
@ -122,15 +121,15 @@ def set_request_user_config(x_user_id: Optional[str] = None,
|
||||
|
||||
# ==================== 图谱检索服务配置 ====================
|
||||
GRAPH_SEARCH_CONFIG = {
|
||||
"search_endpoint": "http://kgrag:9085/search_graph",
|
||||
"search_endpoint": "http://192.168.0.46:59085/search_graph",
|
||||
"graph_timeout": float("30.0"),
|
||||
"default_top_k": int("10"),
|
||||
"operation_search_endpoint": "http://kgrag:9085/operation_graph_search"
|
||||
"operation_search_endpoint": "http://192.168.0.46:59085/operation_graph_search"
|
||||
}
|
||||
|
||||
# ==================== 设备到系统检索服务配置 ====================
|
||||
DEVICE_SYSTEM_CONFIG = {
|
||||
"endpoint": "http://kgrag:9085/device_to_system",
|
||||
"endpoint": "http://192.168.0.46:59085/device_to_system",
|
||||
"timeout": float("30.0"),
|
||||
"default_min_hops": int("2"),
|
||||
"default_max_hops": int("8"),
|
||||
@ -139,7 +138,7 @@ DEVICE_SYSTEM_CONFIG = {
|
||||
|
||||
# ==================== 图册检索服务配置 ====================
|
||||
ATLAS_CONFIG = {
|
||||
"endpoint": "http://kgrag:9085/atlas_retrieval",
|
||||
"endpoint": "http://192.168.0.46:59085/atlas_retrieval",
|
||||
"timeout": float("30.0"),
|
||||
"default_top_k": int("10")
|
||||
}
|
||||
@ -153,15 +152,14 @@ WORKFLOW_CONFIG = {
|
||||
|
||||
# ==================== 服务器配置 ====================
|
||||
SERVER_CONFIG = {
|
||||
"host": "agent",
|
||||
"port": "9088",
|
||||
"base_url": "http://agent:9088",
|
||||
"report" : "http://192.168.1.164:9088"
|
||||
"host": "192.168.0.46",
|
||||
"port": "59088",
|
||||
"base_url": "http://192.168.0.46:59088"
|
||||
}
|
||||
|
||||
# ==================== ASR服务配置 ====================
|
||||
ASR_CONFIG = {
|
||||
"api_base": "http://asr:9900/v1",
|
||||
"api_base": "http://192.168.0.46:59900/v1",
|
||||
"model": "base",
|
||||
"language": "zh",
|
||||
"timeout": int("30")
|
||||
@ -169,33 +167,33 @@ ASR_CONFIG = {
|
||||
|
||||
# ==================== VLM服务配置 ====================
|
||||
VLM_CONFIG = {
|
||||
"api_base" : "http://kgrag:9085/v1",
|
||||
"api_base" : "http://192.168.0.46:59801/v1",
|
||||
"model" : "Qwen3-VL-8B"
|
||||
}
|
||||
|
||||
# ==================== Neo4J配置 ====================
|
||||
NEO4J_CONFIG = {
|
||||
"uri": "neo4j://neo4j:7687",
|
||||
"uri": "neo4j://192.168.0.46:57687",
|
||||
"user": "neo4j",
|
||||
"password": "zdht123"
|
||||
"password": "zdht123@"
|
||||
}
|
||||
|
||||
# ==================== 知识库匹配配置 ====================
|
||||
KB_TREE_CONFIG = {
|
||||
"url": "http://htknow:8080/api/v1/knowledge/knowledge_base/tree"
|
||||
"url": "http://192.168.0.46:11000/api/v1/knowledge/knowledge_base/tree"
|
||||
}
|
||||
|
||||
# ==================== 维修反馈统计配置 ====================
|
||||
REPAIR_FEEDBACK_CONFIG = {
|
||||
"url": "http://webui:22000",
|
||||
"url": "http://192.168.0.46: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"
|
||||
POSTGRES_CONNECTION_STRING = "postgresql://postgres:password@192.168.0.46:5432/an_webui"
|
||||
#POSTGRES_CONNECTION_STRING = "postgresql+psycopg://postgres:password@192.168.0.46:5432/an_webui"
|
||||
POSTGRES_SQLALCHEMY_URL = "postgresql+psycopg://postgres:password@192.168.0.46:5432/an_webui"
|
||||
|
||||
|
||||
# ==================== 索引配置 ====================
|
||||
@ -204,7 +202,7 @@ INDEX_CONFIG = {
|
||||
"FULLTEXT_PROPERTY" :"fulltext",
|
||||
"EMBEDDING_PROPERTY" : "embedding",
|
||||
"SEARCH_LABEL" : "Searchable",
|
||||
"VECTOR_INDEX_NAME" : "global_searchabl_embedding",
|
||||
"VECTOR_INDEX_NAME" : "global_searchable_embedding",
|
||||
"FULLTEXT_INDEX_NAME" : "global_searchable_content_search"
|
||||
}
|
||||
|
||||
@ -214,12 +212,21 @@ MAP_INS={
|
||||
"系统" : "system_name",
|
||||
"设备" : "device_name",
|
||||
"舷号" : "ship_number",
|
||||
"消耗备件" : "spare_parts"
|
||||
}
|
||||
|
||||
# ==================== neo4j配置 ====================
|
||||
NEO4J_CONFIG = {
|
||||
"uri": "neo4j://neo4j:7687",
|
||||
"uri": "neo4j://192.168.0.46:57687",
|
||||
"user": "neo4j",
|
||||
"password": "zdht123@"
|
||||
}
|
||||
|
||||
# ==================== Rerank 服务配置 ====================
|
||||
RERANK_CONFIG = {
|
||||
"enabled": "true".lower() == "true",
|
||||
"endpoint": "http://192.168.0.46:59600/v1/rerank",
|
||||
"model": "bge-rerank",
|
||||
"timeout": 30.0,
|
||||
"max_candidates": 24,
|
||||
"top_k": 10,
|
||||
}
|
||||
|
||||
223
config.txt
@ -1,223 +0,0 @@
|
||||
"""
|
||||
项目配置文件
|
||||
从.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
@ -1,221 +0,0 @@
|
||||
"""
|
||||
项目配置文件
|
||||
从.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"
|
||||
BIN
docker-images/wx_agent_latest.docker-save.tar.gz
(Stored with Git LFS)
@ -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:
|
||||
route_flag = "故障排查与修理"
|
||||
route_flag = "问题排查"
|
||||
|
||||
query_text = state.get("extracted_text", "")
|
||||
combined_query = state.get("combined_query", "") or query_text
|
||||
|
||||
285
main_agent63.py
@ -1,285 +0,0 @@
|
||||
"""
|
||||
主脑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())
|
||||
@ -4,7 +4,7 @@ from typing import List, Union
|
||||
import os
|
||||
import numpy as np
|
||||
from openai import OpenAI
|
||||
from config import LLM_CONFIG, EMBEDDING_CONFIG
|
||||
from config import LLM_CONFIG, EMBEDDING_CONFIG, RERANK_CONFIG
|
||||
from neo4j_graphrag.embeddings.base import Embedder
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
@ -24,6 +24,25 @@ def _get_async_client() -> AsyncOpenAI:
|
||||
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:
|
||||
@staticmethod
|
||||
async def open_api_chat_without_thinking(
|
||||
@ -64,7 +83,7 @@ class OpenaiAPI:
|
||||
if json_output:
|
||||
kwargs["response_format"] = {"type": "json_object"}
|
||||
|
||||
print("model API. history message", message_list)
|
||||
_log_model_messages("model API call", model, message_list)
|
||||
response = await client.chat.completions.create(**kwargs)
|
||||
return response.choices[0].message.content
|
||||
|
||||
@ -112,7 +131,7 @@ class OpenaiAPI:
|
||||
if json_output:
|
||||
kwargs["response_format"] = {"type": "json_object"}
|
||||
|
||||
print("model API stream. history message", message_list)
|
||||
_log_model_messages("model API stream call", model, message_list)
|
||||
|
||||
stream = await client.chat.completions.create(**kwargs)
|
||||
async for chunk in stream:
|
||||
@ -224,6 +243,66 @@ class OpenaiAPI:
|
||||
embedding = response.json()["data"][0]["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
|
||||
async def get_embeddings_batch_async(texts: List[str]) -> List[List[float]]:
|
||||
embedding_base_url = EMBEDDING_CONFIG["base_url"]
|
||||
@ -384,3 +463,4 @@ if __name__ == "__main__":
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -1,304 +0,0 @@
|
||||
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)
|
||||
|
||||
@ -1,380 +0,0 @@
|
||||
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)
|
||||
|
||||
|
||||
BIN
picture/015ebb33d2a3_20260324072409.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
picture/08f8efd6f1d4_20260324110619.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
picture/1ee23229170a_20260324082344.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
picture/22f06245ed56_20260601022623.png
Normal file
|
After Width: | Height: | Size: 1023 KiB |
BIN
picture/334ee511347b_20260323061707.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
picture/44b07d453030_20260323024633.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
picture/50da933dcb11_20260323062013.jpg
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
picture/58a85f24f7cf_20260323024053.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
picture/79493c5be2ac_20260324081251.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
picture/95f8928fb88c_20260324083528.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
picture/bbeb197ac231_20260323060515.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
picture/d345fbf33f95_20260325031818.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
picture/dcd6a33ef2b0_20260324071427.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
picture/e0bb42e29c58_20260323062055.jpg
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
picture/f233ccc07264_20260323060328.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
picture/f38267265a8a_20260323032735.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
215
prompts.py
@ -19,16 +19,17 @@
|
||||
COMMON_PROMPTS = {
|
||||
# ------------------------------------------------------------------
|
||||
# 格式规范系统提示词
|
||||
# 用于: workflow_baike.py (generate_answer)、workflow_utils.py (stream_format_and_postprocess)
|
||||
# 兼容保留:当前最终回复路径已改为单次流式生成 + 本地后处理
|
||||
# ------------------------------------------------------------------
|
||||
"format_system": (
|
||||
"你是一个专业的格式规范助手,负责将给定的文本内容规范成美观、易读的格式。"
|
||||
"你是一个专业的格式规范助手,负责在不改变事实内容和资源引用的前提下,将给定文本整理成清晰、稳定、易读的格式。"
|
||||
"图片资源只能来自用户提供的原始内容或参考资料中真实出现的链接;不得使用提示词中的格式示意,也不得自行构造图片链接。"
|
||||
),
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 格式规范用户提示词(带图片示例,用于百科问答和通用格式化)
|
||||
# 输入变量: raw_content
|
||||
# 用于: workflow_baike.py (generate_answer)、workflow_utils.py (stream_format_and_postprocess 无content_label时)
|
||||
# 兼容保留:当前最终回复路径已改为单次流式生成 + 本地后处理
|
||||
# ------------------------------------------------------------------
|
||||
"format_user_with_image_examples": (
|
||||
"请将以下内容规范成美观、易读的Markdown格式:\n"
|
||||
@ -37,28 +38,22 @@ COMMON_PROMPTS = {
|
||||
"{raw_content}\n"
|
||||
"\n"
|
||||
"【格式规范要求】\n"
|
||||
"1. 保持原始内容的完整性和准确性,**不要修改任何内容**,只调整格式\n"
|
||||
"1. 保持原始内容的完整性和准确性,**不要修改事实、结论、步骤或资源链接**,只调整格式\n"
|
||||
# "2. 适当使用标题、段落分隔等Markdown格式,使内容更易读\n"
|
||||
"2. 不得出现'''mardown'''符号\n"
|
||||
"3. 确保图片链接在相关位置自然展示使用,并且只输出准确格式,`` \n"
|
||||
"4. 如果参考资料中没有图片,**不要输出任何关于图片的说明**\n"
|
||||
"5. 确保LaTeX公式格式正确:$$...$$,并在 $$...$$ 前后加空格\n"
|
||||
"6. 如果有代码片段,使用适当的代码块格式\n"
|
||||
"7. 不要添加任何额外的解释或说明\n"
|
||||
"2. 不得出现```markdown、'''markdown'''、格式化说明等包裹符号\n"
|
||||
"3. 图片属于原始内容中的资源引用:应保留在与对应设备、部件、步骤或说明最相关的位置\n"
|
||||
"4. 图片只输出为单个 Markdown 图片标签,格式为 ``;URL 必须来自【原始内容】,不得来自本提示词或自行构造;同一图片资源重复出现时只保留一次\n"
|
||||
"5. 图片文件名和路径必须完整照抄原始资源,不要改写、拆分、补全、连续重复或作为普通正文输出\n"
|
||||
"6. 图片规则只用于处理已有资源引用,不要围绕图片资源状态输出任何文字说明\n"
|
||||
"7. 禁止输出占位图片名、示例图片名或根据相似文字补全出的图片链接\n"
|
||||
"8. 确保LaTeX公式格式正确:$$...$$,并在 $$...$$ 前后加空格\n"
|
||||
"9. 如果有代码片段,使用适当的代码块格式\n"
|
||||
"10. 不要添加任何额外的解释或说明\n"
|
||||
"\n"
|
||||
"【图片输出示例】\n"
|
||||
"示例一: 原始内容:``\n"
|
||||
" 规范后输出:``\n"
|
||||
"示例二: 原始内容:``\n"
|
||||
" 规范后输出:``\n"
|
||||
"示例三: 原始内容:`images/xxx.jpg`\n"
|
||||
" 规范后输出:``\n"
|
||||
"示例四: 原始内容:`xxx.jpg`\n"
|
||||
" 规范后输出:``\n"
|
||||
"示例五: 原始内容:``\n"
|
||||
" 规范后输出:``\n"
|
||||
"示例六: 原始内容:``\n"
|
||||
" 规范后输出:``\n"
|
||||
"【图片处理规则】\n"
|
||||
"- 当【原始内容】中已经出现图片路径或图片 Markdown 标签时,可以输出图片\n"
|
||||
"- 输出图片时必须逐字复制原始图片的文件名和路径,只整理 Markdown 形式,不能引入任何新文件名\n"
|
||||
"- 本提示词中的格式说明不是可用图片资源,禁止原样输出\n"
|
||||
"\n"
|
||||
"请直接输出规范后的内容:"
|
||||
),
|
||||
@ -66,7 +61,7 @@ COMMON_PROMPTS = {
|
||||
# ------------------------------------------------------------------
|
||||
# 格式规范用户提示词(简化版,用于方案重新格式化,图片链接保持原样)
|
||||
# 输入变量: content_label, raw_content
|
||||
# 用于: workflow_utils.py (stream_format_and_postprocess 有content_label时)
|
||||
# 兼容保留:当前最终回复路径已改为单次流式生成 + 本地后处理
|
||||
# ------------------------------------------------------------------
|
||||
"format_user_simple": (
|
||||
"请将以下{content_label}内容规范成美观、易读的Markdown格式:\n"
|
||||
@ -75,13 +70,16 @@ COMMON_PROMPTS = {
|
||||
"{raw_content}\n"
|
||||
"\n"
|
||||
"【格式规范要求】\n"
|
||||
"1. 保持原始内容的完整性和准确性,**不要修改任何内容**,只调整格式\n"
|
||||
"1. 保持原始内容的完整性和准确性,**不要修改事实、结论、步骤或资源链接**,只调整格式\n"
|
||||
# "2. 适当使用标题、列表、段落分隔等Markdown格式,使内容更易读\n"
|
||||
"2. 不得出现'''markdown'''符号\n"
|
||||
"3. 确保图片链接保持原样:``\n"
|
||||
"4. 确保LaTeX公式格式正确:$$...$$,并在 $$...$$ 前后加空格\n"
|
||||
"5. 如果有代码片段,使用适当的代码块格式\n"
|
||||
"6. 不要添加任何额外的解释或说明\n"
|
||||
"2. 不得出现```markdown、'''markdown'''、格式化说明等包裹符号\n"
|
||||
"3. 只保留【原始内容】中真实存在的图片链接,并放在相关内容附近;同一图片资源重复出现时只保留一次\n"
|
||||
"4. 图片文件名和路径必须完整照抄原始资源,不要改写、拆分、补全、连续重复或作为普通正文输出\n"
|
||||
"5. 图片规则只用于处理已有资源引用,不要围绕图片资源状态输出任何文字说明\n"
|
||||
"6. 禁止输出占位图片名、示例图片名或根据相似文字补全出的图片链接\n"
|
||||
"7. 确保LaTeX公式格式正确:$$...$$,并在 $$...$$ 前后加空格\n"
|
||||
"8. 如果有代码片段,使用适当的代码块格式\n"
|
||||
"9. 不要添加任何额外的解释或说明\n"
|
||||
"\n"
|
||||
"请直接输出规范后的内容:"
|
||||
),
|
||||
@ -175,7 +173,7 @@ COMMON_PROMPTS = {
|
||||
"generate_response_no_rag_system": (
|
||||
"你是一个专业的{biz_label}助手。\n"
|
||||
"虽然没有检索到相关资料,但可以根据用户提供的信息给出一般性建议。\n"
|
||||
"注意:明确告知用户这是基于一般经验的建议。"
|
||||
"注意:明确告知用户这是基于一般经验的建议。没有检索资料时不要输出图片链接。"
|
||||
),
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@ -183,7 +181,7 @@ COMMON_PROMPTS = {
|
||||
# ------------------------------------------------------------------
|
||||
"generate_response_no_rag_simple_system": (
|
||||
"你是一个专业的{biz_label}助手。\n"
|
||||
"虽然没有检索到相关资料,但可以根据用户提供的信息给出一般性建议。"
|
||||
"虽然没有检索到相关资料,但可以根据用户提供的信息给出一般性建议。没有检索资料时不要输出图片链接。"
|
||||
),
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@ -207,11 +205,13 @@ COMMON_PROMPTS = {
|
||||
"{combined_query}\n"
|
||||
"\n"
|
||||
"【输出要求】\n"
|
||||
"1. {image_guidance}\n"
|
||||
"2. 如果参考资料包含表格,请转化成不用表格的形式输出内容\n"
|
||||
"3. 如果参考资料包含 LaTeX 公式,请转为 $$...$$ 公式格式输出,并在 $$...$$ 前后加空格\n"
|
||||
"4. 所有链接必须与参考资料保持一致,不得裁剪、转义或改写\n"
|
||||
"5. 不能虚构参考资料中未提及的图片、表格、零件号或步骤\n"
|
||||
"1. 先直接回应用户当前追问,再结合已有方案和参考资料补充必要依据,避免重复整篇重写\n"
|
||||
"2. {image_guidance}\n"
|
||||
"3. 参考资料中的图片可放在对应部件、现象、步骤或注意事项附近;同一图片资源重复出现时只引用一次;图片规则不要写入正文\n"
|
||||
"4. 图片、链接、公式等资源引用必须与参考资料保持一致,不得裁剪、转义、改写、拆分或补全;图片 URL 必须逐字来自【参考资料】,不得使用占位链接或自行构造\n"
|
||||
"5. 如果参考资料包含表格,请转化成不用表格的形式输出内容\n"
|
||||
"6. 如果参考资料包含 LaTeX 公式,请转为 $$...$$ 公式格式输出,并在 $$...$$ 前后加空格\n"
|
||||
"7. 不能虚构参考资料中未提及的图片、表格、零件号或步骤\n"
|
||||
"\n"
|
||||
"请给出回复:"
|
||||
),
|
||||
@ -222,7 +222,7 @@ COMMON_PROMPTS = {
|
||||
# detail_info, item_action, rag_answer
|
||||
# ------------------------------------------------------------------
|
||||
"generate_response_normal": (
|
||||
"你是一名资深工业设备{biz_label}工程师,请根据以下信息生成{biz_label}分析与{biz_label}方案。如果参考资料中有图片,一定要把图片输出在结果中\n"
|
||||
"你是一名资深工业设备{biz_label}工程师,请根据以下信息生成{biz_label}分析与{biz_label}方案。回答要把文字依据、操作步骤和资料中的可用图示组织成一份可执行方案。\n"
|
||||
"\n"
|
||||
"【设备信息】\n"
|
||||
"- 舷号:{ship_number}\n"
|
||||
@ -235,11 +235,13 @@ COMMON_PROMPTS = {
|
||||
"{rag_answer}\n"
|
||||
"\n"
|
||||
"【输出要求】\n"
|
||||
"1. **重要**:如果参考资料中包含图片链接(格式为 `` 或 `images/xxx.jpg`),请务必在相关位置自然展示使用,如果参考资料中没有图片,**不要输出任何关于图片的说明**\n"
|
||||
"2. 如果参考资料包含 LaTeX 公式、连接等,请在对应位置正常使用,不得修改或删除\n"
|
||||
"3. 请专注于内容的准确性、完整性和逻辑性,不要担心格式的美观性\n"
|
||||
"4. 可以适当使用简单的段落分隔,但不要使用复杂的Markdown格式\n"
|
||||
"5. 如果参考资料包含表格,请不要输出表格,而要直接描述表格的内容\n"
|
||||
"1. 先给出针对当前{item_label}的判断或操作目标,再展开原因/要点、处理步骤、复核与安全注意事项\n"
|
||||
"2. 【参考资料】中真实存在的图片可作为资料引用放在对应段落或步骤后自然展示;图片规则不要写入正文\n"
|
||||
"3. 图片只作为资料引用输出为单个 Markdown 图片标签,URL 和文件名必须逐字来自【参考资料】,不要改写、拆分、补全、连续重复或自行构造;同一图片资源重复出现时只引用一次\n"
|
||||
"4. 如果参考资料包含 LaTeX 公式、链接等,请在对应位置正常使用,不得修改或删除\n"
|
||||
"5. 请专注于内容的准确性、完整性和逻辑性,不要担心格式的美观性\n"
|
||||
"6. 可以适当使用简单的段落分隔,但不要使用复杂的Markdown格式\n"
|
||||
"7. 如果参考资料包含表格,请不要输出表格,而要直接描述表格的内容\n"
|
||||
"\n"
|
||||
"请给出回复:"
|
||||
),
|
||||
@ -251,7 +253,7 @@ COMMON_PROMPTS = {
|
||||
# rag_answer, image_guidance
|
||||
# ------------------------------------------------------------------
|
||||
"generate_response_regenerating": (
|
||||
"你是一名资深工业设备{biz_label}工程师。用户对之前的方案有反馈,请根据反馈重新生成{biz_label}分析与{biz_label}方案。如果参考资料中有图片,一定要把图片输出在结果中\n"
|
||||
"你是一名资深工业设备{biz_label}工程师。用户对之前的方案有反馈,请根据反馈重新生成{biz_label}分析与{biz_label}方案,并把参考资料中的文字依据和可用图示组织成一份更贴合反馈的方案。\n"
|
||||
"\n"
|
||||
"【设备信息】\n"
|
||||
"- 舷号:{ship_number}\n"
|
||||
@ -269,11 +271,12 @@ COMMON_PROMPTS = {
|
||||
"2. 然后给出{biz_label}方案,步骤清晰、可直接执行\n"
|
||||
"3. 根据用户的反馈调整方案\n"
|
||||
"4. 如果参考资料信息不足,明确指出缺少什么\n"
|
||||
"5. {image_guidance},如果参考资料中没有图片,**不要输出任何关于图片的说明**\n"
|
||||
"6. 如果参考资料包含表格,请转化成不用表格的形式输出内容\n"
|
||||
"7. 如果参考资料包含 LaTeX 公式,请转为 $$...$$ 公式格式输出,并在 $$...$$ 前后加空格\n"
|
||||
"8. 所有链接必须与参考资料保持一致,不得裁剪、转义或改写\n"
|
||||
"9. 不能虚构参考资料中未提及的图片、表格、零件号或步骤\n"
|
||||
"5. {image_guidance}\n"
|
||||
"6. 参考资料中的图片可放在反馈、部件、现象或步骤对应内容附近;同一图片资源重复出现时只引用一次;图片规则不要写入正文\n"
|
||||
"7. 图片、链接、公式等资源引用必须与参考资料保持一致,不得裁剪、转义、改写、拆分或补全;图片 URL 必须逐字来自【参考资料】,不得使用占位链接或自行构造\n"
|
||||
"8. 如果参考资料包含表格,请转化成不用表格的形式输出内容\n"
|
||||
"9. 如果参考资料包含 LaTeX 公式,请转为 $$...$$ 公式格式输出,并在 $$...$$ 前后加空格\n"
|
||||
"10. 不能虚构参考资料中未提及的图片、表格、零件号或步骤\n"
|
||||
"\n"
|
||||
"请直接输出调整后的方案:"
|
||||
),
|
||||
@ -282,7 +285,7 @@ COMMON_PROMPTS = {
|
||||
# 生成内容系统提示词(故障诊断和操作指导共用)
|
||||
# ------------------------------------------------------------------
|
||||
"generate_response_content_system": (
|
||||
"你是一名资深工业设备{biz_label}工程师,严格依据检索信息生成{biz_label}方案。请专注于内容的准确性、完整性和逻辑性,如果参考资料中包含图片,请务必在回答中展示出来。"
|
||||
"你是一名资深工业设备{biz_label}工程师,严格依据检索信息生成{biz_label}方案。请专注于内容的准确性、完整性和逻辑性,并把检索资料中的相关图片作为证据资源放在对应说明或步骤附近。只能使用检索资料中真实存在的图片链接,不得使用占位链接或自行构造图片链接。"
|
||||
),
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@ -332,7 +335,7 @@ COMMON_PROMPTS = {
|
||||
# analysis_points_text, original_rag_text, graph_results
|
||||
# ------------------------------------------------------------------
|
||||
"generate_deep_response": (
|
||||
"你是一名资深工业设备{biz_label}专家。用户反馈之前的方案没有解决问题,请根据多维度深度检索的资料,进行深入的{item_label}原因分析和解决方案规划。\n"
|
||||
"你是一名资深工业设备{biz_label}专家。用户反馈之前的方案没有解决问题,请根据多维度深度检索的资料,进行深入的{item_label}原因分析和解决方案规划,并把文字依据、图示资源和图谱结果统一组织到回答中。\n"
|
||||
"\n"
|
||||
"【设备信息】\n"
|
||||
"- 舷号:{ship_number}\n"
|
||||
@ -353,11 +356,12 @@ COMMON_PROMPTS = {
|
||||
"1. 首先重新深入分析{item_label}原因,结合所有检索资料进行推理\n"
|
||||
"2. 给出更详细、更精准的{biz_label}方案,步骤清晰、可直接执行\n"
|
||||
"3. 重点关注用户反馈的问题,根据用户反馈和检索资料,进行{item_label}原因分析和解决方案规划。\n"
|
||||
"4. **重要**:如果参考资料中包含图片链接(格式为 `` 或 `images/xxx.jpg`),请务必在相关位置自然展示使用,如果参考资料中没有图片,**不要输出任何关于图片的说明**\n"
|
||||
"5. 如果参考资料包含 LaTeX 公式、连接等,请在对应位置正常使用,不得修改或删除\n"
|
||||
"6. 请专注于内容的准确性、完整性和逻辑性,不要担心格式的美观性\n"
|
||||
"7. 可以适当使用简单的段落分隔,但不要使用复杂的Markdown格式\n"
|
||||
"8. 如果参考资料包含表格,请不要输出表格,而要直接描述表格的内容\n"
|
||||
"4. 参考资料中真实存在的图片可在原因链路、部件位置、检测项或处理步骤对应内容附近自然展示;图片规则不要写入正文\n"
|
||||
"5. 图片只作为资料引用输出为单个 Markdown 图片标签,URL 和文件名必须逐字来自参考资料,不要改写、拆分、补全、连续重复或自行构造;同一图片资源重复出现时只引用一次\n"
|
||||
"6. 如果参考资料包含 LaTeX 公式、链接等,请在对应位置正常使用,不得修改或删除\n"
|
||||
"7. 请专注于内容的准确性、完整性和逻辑性,不要担心格式的美观性\n"
|
||||
"8. 可以适当使用简单的段落分隔,但不要使用复杂的Markdown格式\n"
|
||||
"9. 如果参考资料包含表格,请不要输出表格,而要直接描述表格的内容\n"
|
||||
"\n"
|
||||
"请直接输出深度分析和方案:"
|
||||
),
|
||||
@ -366,7 +370,7 @@ COMMON_PROMPTS = {
|
||||
# 深度响应内容系统提示词(故障诊断和操作指导共用)
|
||||
# ------------------------------------------------------------------
|
||||
"generate_deep_response_content_system": (
|
||||
"你是一名资深工业设备{biz_label}专家,严格依据检索信息进行深度分析和方案生成。请专注于内容的准确性、完整性和逻辑性,如果参考资料中包含图片,请务必在回答中展示出来。"
|
||||
"你是一名资深工业设备{biz_label}专家,严格依据检索信息进行深度分析和方案生成。请专注于内容的准确性、完整性和逻辑性,并把检索资料中的相关图片作为证据资源放在对应分析点或步骤附近。只能使用检索资料中真实存在的图片链接,不得使用占位链接或自行构造图片链接。"
|
||||
),
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@ -395,10 +399,11 @@ COMMON_PROMPTS = {
|
||||
"2. 然后基于之前的方案进行修改和优化,给出调整后的方案\n"
|
||||
"3. 重点解决用户反馈的问题\n"
|
||||
"4. {image_guidance}\n"
|
||||
"5. 如果之前方案包含表格,请转化成不用表格的形式输出内容\n"
|
||||
"6. 如果之前方案包含 LaTeX 公式,请转为 $$...$$ 公式格式输出,并在 $$...$$ 前后加空格\n"
|
||||
"7. 所有链接必须与原方案保持一致,不得裁剪、转义或改写\n"
|
||||
"8. 不能虚构原方案中未提及的图片、表格、零件号或步骤\n"
|
||||
"5. 原方案中与反馈内容仍相关的图片应保留在对应段落或步骤附近;同一图片资源重复出现时只引用一次\n"
|
||||
"6. 如果之前方案包含表格,请转化成不用表格的形式输出内容\n"
|
||||
"7. 如果之前方案包含 LaTeX 公式,请转为 $$...$$ 公式格式输出,并在 $$...$$ 前后加空格\n"
|
||||
"8. 所有链接必须与原方案保持一致,不得裁剪、转义、改写、拆分或补全;不得新增原方案中不存在的图片 URL\n"
|
||||
"9. 不能虚构原方案中未提及的图片、表格、零件号或步骤\n"
|
||||
"\n"
|
||||
"请直接输出修改后的完整方案:"
|
||||
),
|
||||
@ -422,29 +427,19 @@ BAIKE_PROMPTS = {
|
||||
# 输入变量: history_messages, original_query
|
||||
# ------------------------------------------------------------------
|
||||
"judge_need_retrieval": (
|
||||
"你是一个专业的问题分析助手,请判断用户的问题是否需要检索知识库才能回答。\n"
|
||||
"You are an intent classifier for a knowledge-base QA assistant. Decide whether the user question needs retrieval.\n"
|
||||
"\n"
|
||||
"【判断标准】\n"
|
||||
"需要检索的情况:\n"
|
||||
"- 询问特定设备、技术、规程、规范的具体内容\n"
|
||||
"- 需要专业知识或特定数据支持的问题\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"
|
||||
"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"
|
||||
"Default rule: when unsure, return true.\n"
|
||||
"\n"
|
||||
"不需要检索的情况:\n"
|
||||
"- 简单的问候、寒暄\n"
|
||||
"- 常识性问题(如常识性知识、简单计算等)\n"
|
||||
"- 明确不需要专业知识的问题\n"
|
||||
"- 对之前对话的简单追问,上下文已足够回答\n"
|
||||
"\n"
|
||||
"【对话历史】\n"
|
||||
"Conversation history:\n"
|
||||
"{history_messages}\n"
|
||||
"\n"
|
||||
"【当前用户问题】\n"
|
||||
"User question:\n"
|
||||
"{original_query}\n"
|
||||
"\n"
|
||||
"请仅输出 \"true\" 或 \"false\"(不带引号),true 表示需要检索,false 表示不需要检索。"
|
||||
"Output only true or false."
|
||||
),
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@ -452,31 +447,35 @@ BAIKE_PROMPTS = {
|
||||
# 输入变量: last_user_query, history_str, original_query
|
||||
# ------------------------------------------------------------------
|
||||
"rewrite_query_with_history": (
|
||||
"你是一个专业的检索query重写助手,负责根据对话历史和当前用户问题,生成**适合向量检索/RAG 的单条查询语句**。\n"
|
||||
"You are a query understanding assistant for a Chinese knowledge-base retrieval system.\n"
|
||||
"\n"
|
||||
"请严格遵守以下要求:\n"
|
||||
"1. 只融合与当前问题语义强相关的**最近几轮对话信息**,不要机械拼接全部历史。\n"
|
||||
"2. 消除指代和省略(例如:这个、刚才那个问题、上面说的故障等),补全成自洽、完整的描述。\n"
|
||||
"3. 不要加入与用户问题无关的背景信息,不要过度扩展检索范围。\n"
|
||||
"4. 输出必须是一句自然的中文问句或陈述句,便于检索;不要包含条目符号、编号或解释性文字。\n"
|
||||
"5. 只输出改写后的query本身:\n"
|
||||
" - 不要输出说明文字\n"
|
||||
" - 不要包含「改写结果:」「检索query:」等前缀\n"
|
||||
" - 不要添加引号\n"
|
||||
"Tasks:\n"
|
||||
"1. Rewrite the current user question into a self-contained Chinese retrieval query.\n"
|
||||
"2. Generate 2-5 short search queries for hybrid retrieval.\n"
|
||||
"\n"
|
||||
"【特别提示】\n"
|
||||
"- 请优先参考**上一轮用户的问题**来重写本轮query。\n"
|
||||
"- 上一轮用户的问题是:{last_user_query}\n"
|
||||
"Rules:\n"
|
||||
"- Resolve references and ellipsis using only relevant recent history.\n"
|
||||
"- Preserve concrete entities, file names, database/table terms, equipment names, fault names, standards, procedure names, and exact keywords.\n"
|
||||
"- Do not output meta instructions such as “在知识库中查找”. Output the actual search content.\n"
|
||||
"- Include compact keyword queries that help exact matching, for example document titles, equipment names, or core nouns.\n"
|
||||
"- The rewritten query and search queries must be in Chinese unless the original keyword is English.\n"
|
||||
"\n"
|
||||
"【对话历史(JSON 格式,已按时间排序)】\n"
|
||||
"Last user question:\n"
|
||||
"{last_user_query}\n"
|
||||
"\n"
|
||||
"Conversation history JSON:\n"
|
||||
"{history_str}\n"
|
||||
"\n"
|
||||
"【当前用户问题】\n"
|
||||
"Current user question:\n"
|
||||
"{original_query}\n"
|
||||
"\n"
|
||||
"请给出最终用于检索的单条中文query。"
|
||||
"Output ONLY valid JSON:\n"
|
||||
"{{\"rewrite_query\":\"string\",\"search_queries\":[\"string\"]}}"
|
||||
),
|
||||
|
||||
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# generate_suggested_replies_baike 函数:生成百科问答的建议回复问题
|
||||
# 输入变量: question, answer
|
||||
@ -509,8 +508,15 @@ BAIKE_PROMPTS = {
|
||||
# generate_answer 节点 - 有检索结果时的系统提示词
|
||||
# 输入变量: domain_desc
|
||||
# ------------------------------------------------------------------
|
||||
# ------------------------------------------------------------------
|
||||
"generate_answer_with_retrieval_system": (
|
||||
"我是一个专业的{domain_desc}知识问答助手,根据用户的问题,综合所有检索结果,提供准确、专业、完整的回答内容。如果参考资料中有图片,一定要把图片输出在结果中"
|
||||
"You are a professional knowledge-base assistant for {domain_desc}.\n"
|
||||
"Answer ONLY from the retrieved evidence provided by the user message. Do not use prior knowledge as factual evidence.\n"
|
||||
"If the evidence is insufficient, say clearly what is missing and what can be confirmed from the evidence.\n"
|
||||
"Prefer concrete facts, names, parameters, steps, conclusions, and source relationships from the evidence.\n"
|
||||
"When sources conflict, explain the conflict instead of inventing a merged fact.\n"
|
||||
"Use images only when their URLs appear in the retrieved evidence; never fabricate or repair image URLs.\n"
|
||||
"Always respond in Chinese. Do not expose internal tool names or implementation details."
|
||||
),
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@ -518,28 +524,30 @@ BAIKE_PROMPTS = {
|
||||
# 输入变量: extracted_text, rag_count, all_source_text
|
||||
# ------------------------------------------------------------------
|
||||
"generate_answer_with_retrieval_user": (
|
||||
"用户问题:\n"
|
||||
"{extracted_text}\n"
|
||||
"\n"
|
||||
"# 检索到的相关信息(共 {rag_count} 条):\n"
|
||||
"# Retrieved evidence ({rag_count} items)\n"
|
||||
"\n"
|
||||
"{all_source_text}\n"
|
||||
"\n"
|
||||
"【输出要求】\n"
|
||||
"1. **重要**:如果参考资料中包含图片链接(格式为 `` 或 `images/xxx.jpg`),请务必在相关位置自然展示使用,如果参考资料中没有图片,**不要输出任何关于图片的说明**\n"
|
||||
"2. 如果参考资料包含 LaTeX 公式、连接等,请在对应位置正常使用,不得修改或删除\n"
|
||||
"3. 请专注于内容的准确性、完整性和逻辑性,不要担心格式的美观性\n"
|
||||
"4. 可以适当使用简单的段落分隔,但不要使用复杂的Markdown格式\n"
|
||||
"5. 如果参考资料包含表格,请不要输出表格,而要直接描述表格的内容\n"
|
||||
"1. 先直接回答用户问题,再补充必要依据。\n"
|
||||
"2. 每个关键事实都必须能在 Retrieved evidence 中找到依据;不要补充资料外结论。\n"
|
||||
"3. 参考资料中真实存在的图片可在对应段落附近展示;URL 必须逐字来自资料。\n"
|
||||
"4. 表格内容请转成自然语言描述,不要输出复杂表格。\n"
|
||||
"\n"
|
||||
"请给出回复:"
|
||||
),
|
||||
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# generate_answer 节点 - 无检索结果时的系统提示词
|
||||
# 输入变量: domain_desc
|
||||
# ------------------------------------------------------------------
|
||||
"generate_answer_without_retrieval_system": (
|
||||
"我是一个专业的{domain_desc}知识问答助手,根据用户的问题,结合我的专业知识,提供准确、专业、完整的回答内容。"
|
||||
"我是一个专业的{domain_desc}知识问答助手,根据用户的问题,结合我的专业知识,提供准确、专业、完整的回答内容。没有检索资料时不要输出图片链接。"
|
||||
),
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@ -553,6 +561,7 @@ BAIKE_PROMPTS = {
|
||||
"1. 请根据您的专业知识提供准确、完整的回答\n"
|
||||
"2. 可以适当使用简单的段落分隔,但不要使用复杂的Markdown格式\n"
|
||||
"3. 请专注于内容的准确性和专业性\n"
|
||||
"4. 只回答文字内容,不输出资源链接\n"
|
||||
"\n"
|
||||
"请给出回复:"
|
||||
),
|
||||
@ -968,7 +977,7 @@ FAULT_DIAGNOSIS_PROMPTS = {
|
||||
" - 示例:\"生成报告\"、\"写报告\"、\"导出报告\"、\"下载报告\"、\"保存报告\"\n"
|
||||
"\n"
|
||||
"2. modify_info: 用户需要修改或补充之前提供的故障信息(舷号、设备名称、故障现象等)\n"
|
||||
" - 示例:\"其实是...\"、\"不对,应该是...\"、\"我补充一下...\"、\"修改设备名称是...\"、\"更正一下,故障是...\"\n"
|
||||
" - 示例:\"其实是...\"、\"不对,应该是...\"、\"我补充一下...\"、\"修改设备名称是...\"、\"更正一下,故障是...\"、\"我需要补充...\"\n"
|
||||
"\n"
|
||||
"3. has_error: 用户指出之前生成的维修方案有错误、需要修改或调整方案内容\n"
|
||||
" - 示例:\"方案有误\"、\"这个步骤不对\"、\"需要修改方案\"、\"这个方案有问题\"\n"
|
||||
|
||||
@ -1,124 +0,0 @@
|
||||
"""
|
||||
智能舷号映射 + 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,41 +1,41 @@
|
||||
|
||||
"""
|
||||
工具模块
|
||||
包含RAG、GraphRAG、VLM、ASR、TTS等工具
|
||||
"""
|
||||
from .function_tool import (
|
||||
detect_input_type,
|
||||
graph_rag_search,
|
||||
fault_graph_rag_search,
|
||||
operation_graph_rag_search,
|
||||
rag_search_tool,
|
||||
vlm_image_to_text,
|
||||
asr_audio_to_text,
|
||||
extract_device_and_fault,
|
||||
image_rag_describe,
|
||||
file_to_text_tool,
|
||||
fault_type_statistics_tool,
|
||||
fault_frequency_statistics_tool,
|
||||
spare_parts_statistics_tool,
|
||||
ALL_TOOLS,
|
||||
TOOL_CATEGORIES
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"detect_input_type",
|
||||
"graph_rag_search",
|
||||
"fault_graph_rag_search",
|
||||
"operation_graph_rag_search",
|
||||
"rag_search_tool",
|
||||
"vlm_image_to_text",
|
||||
"asr_audio_to_text",
|
||||
"extract_device_and_fault",
|
||||
"image_rag_describe",
|
||||
"file_to_text_tool",
|
||||
"fault_type_statistics_tool",
|
||||
"fault_frequency_statistics_tool",
|
||||
"spare_parts_statistics_tool",
|
||||
"ALL_TOOLS",
|
||||
"TOOL_CATEGORIES"
|
||||
]
|
||||
|
||||
|
||||
"""
|
||||
工具模块
|
||||
包含RAG、GraphRAG、VLM、ASR、TTS等工具
|
||||
"""
|
||||
from .function_tool import (
|
||||
detect_input_type,
|
||||
graph_rag_search,
|
||||
fault_graph_rag_search,
|
||||
operation_graph_rag_search,
|
||||
rag_search_tool,
|
||||
vlm_image_to_text,
|
||||
asr_audio_to_text,
|
||||
extract_device_and_fault,
|
||||
image_rag_describe,
|
||||
file_to_text_tool,
|
||||
fault_type_statistics_tool,
|
||||
fault_frequency_statistics_tool,
|
||||
spare_parts_statistics_tool,
|
||||
ALL_TOOLS,
|
||||
TOOL_CATEGORIES
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"detect_input_type",
|
||||
"graph_rag_search",
|
||||
"fault_graph_rag_search",
|
||||
"operation_graph_rag_search",
|
||||
"rag_search_tool",
|
||||
"vlm_image_to_text",
|
||||
"asr_audio_to_text",
|
||||
"extract_device_and_fault",
|
||||
"image_rag_describe",
|
||||
"file_to_text_tool",
|
||||
"fault_type_statistics_tool",
|
||||
"fault_frequency_statistics_tool",
|
||||
"spare_parts_statistics_tool",
|
||||
"ALL_TOOLS",
|
||||
"TOOL_CATEGORIES"
|
||||
]
|
||||
|
||||
|
||||
222
tools/config.py
@ -1,222 +0,0 @@
|
||||
"""
|
||||
项目配置文件
|
||||
从.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@"
|
||||
}
|
||||
@ -217,7 +217,7 @@ async def record_fault_from_state(state: Dict[str, Any]) -> bool:
|
||||
system_name = "其他"
|
||||
if device_name:
|
||||
from tools.fault_statistics import get_device_system_from_neo4j
|
||||
system_name = await get_device_system_from_neo4j(device_name)
|
||||
system_name = await get_device_system_from_neo4j(device_name, ship_number=ship_number)
|
||||
|
||||
return await save_fault_record(
|
||||
ship_number=ship_number,
|
||||
|
||||
@ -37,80 +37,61 @@ def fuzzy_match_system(user_input: str, system_name: str, threshold: float = 0.6
|
||||
return similarity >= threshold
|
||||
|
||||
|
||||
async def get_device_system_from_neo4j(device_name: str) -> str:
|
||||
async def get_device_system_from_neo4j(device_name: str, ship_number: Optional[str] = None) -> str:
|
||||
"""
|
||||
从 Neo4j 图谱中查询设备对应的系统(调用外部接口),带缓存机制
|
||||
从 Neo4j 图谱中查询设备对应的系统,带缓存机制。
|
||||
|
||||
实际检索由 tools.graph_tools.find_system_by_device 负责;该函数会使用
|
||||
config.INDEX_CONFIG 中配置的全局 Neo4j 全文索引和向量索引。
|
||||
"""
|
||||
if not device_name or not device_name.strip():
|
||||
return "其他"
|
||||
|
||||
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 device_name_clean in neo4j_cache:
|
||||
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
|
||||
if cache_key in neo4j_cache:
|
||||
return neo4j_cache[cache_key]
|
||||
|
||||
try:
|
||||
payload = {
|
||||
"device_name": device_name.strip(),
|
||||
"min_hops": min_hops,
|
||||
"max_hops": max_hops,
|
||||
"top_k": top_k
|
||||
}
|
||||
from tools.graph_tools import find_system_by_device
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
response = await client.post(
|
||||
endpoint,
|
||||
json=payload,
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
|
||||
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):
|
||||
result = "其他"
|
||||
neo4j_cache[device_name_clean] = result
|
||||
return result
|
||||
|
||||
systems = result_json.get("systems", [])
|
||||
if not systems or not isinstance(systems, list):
|
||||
result = "其他"
|
||||
neo4j_cache[device_name_clean] = result
|
||||
return result
|
||||
|
||||
first_system = systems[0]
|
||||
if isinstance(first_system, dict):
|
||||
system_name = first_system.get("name") or first_system.get("系统名称") or first_system.get("名称")
|
||||
if system_name:
|
||||
result = system_name.strip()
|
||||
neo4j_cache[device_name_clean] = result
|
||||
return result
|
||||
result_json = await find_system_by_device(
|
||||
device_name=device_name.strip(),
|
||||
ship_number=ship_number,
|
||||
min_hops=2,
|
||||
max_hops=8,
|
||||
top_k=10,
|
||||
)
|
||||
|
||||
if not result_json.get("success", False):
|
||||
result = "其他"
|
||||
neo4j_cache[device_name_clean] = result
|
||||
neo4j_cache[cache_key] = result
|
||||
return result
|
||||
|
||||
systems = result_json.get("systems", [])
|
||||
if not systems or not isinstance(systems, list):
|
||||
result = "其他"
|
||||
neo4j_cache[cache_key] = result
|
||||
return result
|
||||
|
||||
first_system = systems[0]
|
||||
if isinstance(first_system, dict):
|
||||
system_name = first_system.get("name") or first_system.get("系统名称") or first_system.get("名称")
|
||||
if system_name:
|
||||
result = system_name.strip()
|
||||
neo4j_cache[cache_key] = result
|
||||
return result
|
||||
|
||||
result = "其他"
|
||||
neo4j_cache[cache_key] = result
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
print(f"查询设备系统失败: {str(e)}")
|
||||
result = "其他"
|
||||
neo4j_cache[device_name_clean] = result
|
||||
neo4j_cache[cache_key] = result
|
||||
return result
|
||||
|
||||
|
||||
|
||||
1232
tools/graph_tools.py
@ -1,208 +0,0 @@
|
||||
"""
|
||||
舷号-型号映射数据库模块
|
||||
用于管理舰船型号、舷号、别名、舰名的映射关系
|
||||
数据存储在 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
@ -1,251 +0,0 @@
|
||||
# -*- 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,7 +148,8 @@ async def generate_sql(question: str, current_date: str) -> Dict[str, Any]:
|
||||
current_date=current_date,
|
||||
extra_info=extra_info or ""
|
||||
)
|
||||
|
||||
print("输出最终sql的提示词内容")
|
||||
print(prompt)
|
||||
try:
|
||||
result = await OpenaiAPI.open_api_chat_without_thinking(
|
||||
prompt,
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""
|
||||
工具模块
|
||||
"""
|
||||
|
||||
"""
|
||||
工具模块
|
||||
"""
|
||||
|
||||
|
||||
@ -1,360 +1,360 @@
|
||||
"""
|
||||
使用装饰器实现全栈函数调用追踪
|
||||
解决LangGraph无法捕获子函数内部状态的问题
|
||||
"""
|
||||
|
||||
import functools
|
||||
import inspect
|
||||
import asyncio
|
||||
import contextvars
|
||||
from typing import Callable, Any, Dict, Optional, List
|
||||
from datetime import datetime
|
||||
from collections import deque
|
||||
|
||||
# 使用 contextvars 实现请求级别的隔离,避免多个并发请求互相干扰
|
||||
_event_callbacks: contextvars.ContextVar[List[Callable]] = contextvars.ContextVar('event_callbacks', default=None)
|
||||
_current_stream_handler: contextvars.ContextVar[Optional[Any]] = contextvars.ContextVar('current_stream_handler',
|
||||
default=None)
|
||||
|
||||
|
||||
def register_event_callback(callback: Callable[[Dict[str, Any]], None]):
|
||||
"""
|
||||
注册事件回调函数(使用 contextvars 实现请求隔离)
|
||||
"""
|
||||
callbacks = _event_callbacks.get()
|
||||
if callbacks is None:
|
||||
callbacks = []
|
||||
_event_callbacks.set(callbacks)
|
||||
callbacks.append(callback)
|
||||
|
||||
|
||||
def unregister_event_callback(callback: Callable[[Dict[str, Any]], None]):
|
||||
"""
|
||||
移除事件回调函数(使用 contextvars 实现请求隔离)
|
||||
"""
|
||||
callbacks = _event_callbacks.get()
|
||||
if callbacks and callback in callbacks:
|
||||
callbacks.remove(callback)
|
||||
|
||||
|
||||
def clear_event_callbacks():
|
||||
"""
|
||||
清空所有事件回调(使用 contextvars 实现请求隔离)
|
||||
"""
|
||||
callbacks = _event_callbacks.get()
|
||||
if callbacks:
|
||||
callbacks.clear()
|
||||
|
||||
|
||||
def get_all_callbacks():
|
||||
"""
|
||||
获取所有注册的回调函数(使用 contextvars 实现请求隔离)
|
||||
"""
|
||||
callbacks = _event_callbacks.get()
|
||||
return callbacks.copy() if callbacks else []
|
||||
|
||||
|
||||
def extract_function_description(func: Callable) -> str:
|
||||
"""
|
||||
提取函数的描述(docstring的第一行或前100个字符)
|
||||
"""
|
||||
if func.__doc__:
|
||||
doc = func.__doc__.strip()
|
||||
# 取第一行或前100个字符
|
||||
first_line = doc.split('\n')[0].strip()
|
||||
return first_line[:100] if len(first_line) > 100 else first_line
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_details_from_result(result: Any) -> str:
|
||||
"""
|
||||
从函数返回值中提取 details,顺序固定为:先 details 再中文。
|
||||
1) 先看返回的 dict 里是否有「details 类」字段(__details__、_details、details 或键名含 "details"),
|
||||
且其值为非空,则用该值;
|
||||
2) 若没有或为空,再走原本的中文兜底:从返回结构中找一段包含中文的内容作为 details。
|
||||
"""
|
||||
default = "完成"
|
||||
if not result:
|
||||
return default
|
||||
|
||||
# 第一步:优先用 details 类字段(仅当有值且非空时采用)
|
||||
if isinstance(result, dict):
|
||||
for key in ("__details__", "_details", "details"):
|
||||
if key in result and result[key] is not None:
|
||||
s = str(result[key]).strip()
|
||||
if s:
|
||||
return str(result[key])
|
||||
for k, v in result.items():
|
||||
if "details" in k.lower() and v is not None:
|
||||
s = str(v).strip()
|
||||
if s:
|
||||
return str(v)
|
||||
|
||||
# 第二步:保留原本的中文兜底
|
||||
def _contains_chinese(text: str) -> bool:
|
||||
if not text:
|
||||
return False
|
||||
return any("\u4e00" <= c <= "\u9fff" for c in str(text))
|
||||
|
||||
def _find_chinese(obj: Any, max_length: int = 200) -> Optional[str]:
|
||||
if isinstance(obj, str):
|
||||
return (obj[:max_length] + "...") if _contains_chinese(obj) and len(obj) > max_length else (
|
||||
obj if _contains_chinese(obj) else None)
|
||||
if isinstance(obj, dict):
|
||||
for key, value in obj.items():
|
||||
if "details" in key.lower():
|
||||
continue
|
||||
found = _find_chinese(value, max_length)
|
||||
if found:
|
||||
return found
|
||||
if isinstance(obj, list):
|
||||
for item in obj:
|
||||
found = _find_chinese(item, max_length)
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
|
||||
chinese = _find_chinese(result)
|
||||
return chinese if chinese else default
|
||||
|
||||
|
||||
def track_function_calls(func: Callable) -> Callable:
|
||||
"""
|
||||
装饰器:追踪函数调用过程,支持同步和异步函数
|
||||
|
||||
Args:
|
||||
func: 被装饰的函数
|
||||
"""
|
||||
|
||||
@functools.wraps(func)
|
||||
def sync_wrapper(*args, **kwargs):
|
||||
# 获取函数签名信息
|
||||
sig = inspect.signature(func)
|
||||
bound_args = sig.bind(*args, **kwargs)
|
||||
bound_args.apply_defaults()
|
||||
|
||||
func_name = func.__name__
|
||||
module_name = func.__module__
|
||||
|
||||
# 尝试从函数参数中获取 state
|
||||
state = None
|
||||
if args and isinstance(args[0], dict):
|
||||
state = args[0]
|
||||
elif 'state' in bound_args.arguments:
|
||||
state = bound_args.arguments.get('state')
|
||||
|
||||
func_description = extract_function_description(func)
|
||||
|
||||
# 准备函数参数信息
|
||||
params = {k: v for k, v in bound_args.arguments.items()}
|
||||
|
||||
try:
|
||||
# 执行原函数
|
||||
result = func(*args, **kwargs)
|
||||
|
||||
# 构建 details:优先用返回值中的 details 类字段(__details__、_details、details 或键名含 details)
|
||||
details = _extract_details_from_result(result)
|
||||
|
||||
# 合并函数执行事件(只在执行完成后发送一次)
|
||||
execution_event = {
|
||||
"type": "function_execution",
|
||||
"title": func_description,
|
||||
"details": details,
|
||||
"result": result, # 添加函数返回结果
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
# 调用所有注册的回调(从上下文变量获取)
|
||||
callbacks = _event_callbacks.get() or []
|
||||
for callback in callbacks:
|
||||
try:
|
||||
callback(execution_event)
|
||||
except Exception as e:
|
||||
print(f"回调执行出错: {e}")
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
# 发送函数执行失败事件
|
||||
error_event = {
|
||||
"type": "function_error",
|
||||
"title": func_description,
|
||||
"details": f"错误: {str(e)}",
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
callbacks = _event_callbacks.get() or []
|
||||
for callback in callbacks:
|
||||
try:
|
||||
callback(error_event)
|
||||
except Exception as ex:
|
||||
print(f"回调执行出错: {ex}")
|
||||
|
||||
raise
|
||||
|
||||
@functools.wraps(func)
|
||||
async def async_wrapper(*args, **kwargs):
|
||||
# 获取函数签名信息
|
||||
sig = inspect.signature(func)
|
||||
bound_args = sig.bind(*args, **kwargs)
|
||||
bound_args.apply_defaults()
|
||||
|
||||
func_name = func.__name__
|
||||
module_name = func.__module__
|
||||
|
||||
# 尝试从函数参数中获取 state
|
||||
state = None
|
||||
if args and isinstance(args[0], dict):
|
||||
state = args[0]
|
||||
elif 'state' in bound_args.arguments:
|
||||
state = bound_args.arguments.get('state')
|
||||
|
||||
func_description = extract_function_description(func)
|
||||
|
||||
# 准备函数参数信息
|
||||
params = {k: v for k, v in bound_args.arguments.items()}
|
||||
|
||||
try:
|
||||
# 执行原函数
|
||||
result = await func(*args, **kwargs)
|
||||
|
||||
# 构建 details:优先用返回值中的 details 类字段(__details__、_details、details 或键名含 details)
|
||||
details = _extract_details_from_result(result)
|
||||
|
||||
# 合并函数执行事件(只在执行完成后发送一次)
|
||||
execution_event = {
|
||||
"type": "function_execution",
|
||||
"title": func_description,
|
||||
"details": details,
|
||||
"result": result, # 添加函数返回结果
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
# 调用所有注册的回调(从上下文变量获取)
|
||||
callbacks = _event_callbacks.get() or []
|
||||
for callback in callbacks:
|
||||
try:
|
||||
callback(execution_event)
|
||||
except Exception as e:
|
||||
print(f"回调执行出错: {e}")
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
# 发送函数执行失败事件
|
||||
error_event = {
|
||||
"type": "function_error",
|
||||
"title": func_description,
|
||||
"details": f"错误: {str(e)}",
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
callbacks = _event_callbacks.get() or []
|
||||
for callback in callbacks:
|
||||
try:
|
||||
callback(error_event)
|
||||
except Exception as ex:
|
||||
print(f"回调执行出错: {ex}")
|
||||
|
||||
raise
|
||||
|
||||
# 根据函数是否为协程函数返回对应的装饰器
|
||||
if asyncio.iscoroutinefunction(func):
|
||||
return async_wrapper
|
||||
else:
|
||||
return sync_wrapper
|
||||
|
||||
|
||||
# 事件处理器
|
||||
def console_event_handler(event: Dict[str, Any]):
|
||||
"""
|
||||
控制台事件处理器
|
||||
"""
|
||||
event_type = event["type"]
|
||||
title = event.get("title", "")
|
||||
details = event.get("details", "")
|
||||
|
||||
if event_type == "function_execution":
|
||||
print(f"✅ 函数执行: {title}")
|
||||
if details:
|
||||
print(f" 详情: {details}")
|
||||
|
||||
elif event_type == "function_error":
|
||||
print(f"❌ 函数错误: {title}")
|
||||
if details:
|
||||
print(f" 详情: {details}")
|
||||
|
||||
|
||||
class StreamEventHandler:
|
||||
"""
|
||||
流式事件处理器,将装饰器事件放入队列供流式输出使用
|
||||
"""
|
||||
def __init__(self, queue: asyncio.Queue):
|
||||
self.queue = queue
|
||||
|
||||
def __call__(self, event: Dict[str, Any]):
|
||||
"""
|
||||
处理事件,将事件放入队列
|
||||
注意:这是同步方法,但队列是异步的,需要特殊处理
|
||||
"""
|
||||
try:
|
||||
# 使用线程安全的方式将事件放入队列
|
||||
# 如果队列已满,使用 put_nowait 并忽略错误
|
||||
try:
|
||||
self.queue.put_nowait(event)
|
||||
except asyncio.QueueFull:
|
||||
# 队列已满,跳过此事件(不应该发生,因为队列大小足够)
|
||||
pass
|
||||
except Exception as e:
|
||||
# 忽略错误,避免影响主流程
|
||||
pass
|
||||
|
||||
def send_stream_content(self, content: str, title: str = ""):
|
||||
"""
|
||||
发送流式内容事件
|
||||
|
||||
Args:
|
||||
content: 流式内容片段
|
||||
title: 函数描述/标题(可选)
|
||||
"""
|
||||
try:
|
||||
event = {
|
||||
"type": "stream_content",
|
||||
"content": content,
|
||||
"title": title,
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
self.queue.put_nowait(event)
|
||||
except Exception as e:
|
||||
# 忽略错误,避免影响主流程
|
||||
pass
|
||||
|
||||
|
||||
def create_stream_event_handler() -> tuple[StreamEventHandler, asyncio.Queue]:
|
||||
"""
|
||||
创建流式事件处理器和对应的队列(使用 contextvars 实现请求隔离)
|
||||
|
||||
Returns:
|
||||
(handler, queue): 事件处理器和事件队列
|
||||
"""
|
||||
queue = asyncio.Queue(maxsize=1000) # 设置足够大的队列大小
|
||||
handler = StreamEventHandler(queue)
|
||||
_current_stream_handler.set(handler) # 保存到上下文变量
|
||||
return handler, queue
|
||||
|
||||
|
||||
def get_current_stream_handler() -> Optional[StreamEventHandler]:
|
||||
"""
|
||||
获取当前活动的流式事件处理器(使用 contextvars 实现请求隔离)
|
||||
|
||||
Returns:
|
||||
当前流式事件处理器,如果没有则返回 None
|
||||
"""
|
||||
return _current_stream_handler.get()
|
||||
|
||||
|
||||
def clear_current_stream_handler():
|
||||
"""
|
||||
清除当前活动的流式事件处理器(使用 contextvars 实现请求隔离)
|
||||
"""
|
||||
_current_stream_handler.set(None)
|
||||
"""
|
||||
使用装饰器实现全栈函数调用追踪
|
||||
解决LangGraph无法捕获子函数内部状态的问题
|
||||
"""
|
||||
|
||||
import functools
|
||||
import inspect
|
||||
import asyncio
|
||||
import contextvars
|
||||
from typing import Callable, Any, Dict, Optional, List
|
||||
from datetime import datetime
|
||||
from collections import deque
|
||||
|
||||
# 使用 contextvars 实现请求级别的隔离,避免多个并发请求互相干扰
|
||||
_event_callbacks: contextvars.ContextVar[List[Callable]] = contextvars.ContextVar('event_callbacks', default=None)
|
||||
_current_stream_handler: contextvars.ContextVar[Optional[Any]] = contextvars.ContextVar('current_stream_handler',
|
||||
default=None)
|
||||
|
||||
|
||||
def register_event_callback(callback: Callable[[Dict[str, Any]], None]):
|
||||
"""
|
||||
注册事件回调函数(使用 contextvars 实现请求隔离)
|
||||
"""
|
||||
callbacks = _event_callbacks.get()
|
||||
if callbacks is None:
|
||||
callbacks = []
|
||||
_event_callbacks.set(callbacks)
|
||||
callbacks.append(callback)
|
||||
|
||||
|
||||
def unregister_event_callback(callback: Callable[[Dict[str, Any]], None]):
|
||||
"""
|
||||
移除事件回调函数(使用 contextvars 实现请求隔离)
|
||||
"""
|
||||
callbacks = _event_callbacks.get()
|
||||
if callbacks and callback in callbacks:
|
||||
callbacks.remove(callback)
|
||||
|
||||
|
||||
def clear_event_callbacks():
|
||||
"""
|
||||
清空所有事件回调(使用 contextvars 实现请求隔离)
|
||||
"""
|
||||
callbacks = _event_callbacks.get()
|
||||
if callbacks:
|
||||
callbacks.clear()
|
||||
|
||||
|
||||
def get_all_callbacks():
|
||||
"""
|
||||
获取所有注册的回调函数(使用 contextvars 实现请求隔离)
|
||||
"""
|
||||
callbacks = _event_callbacks.get()
|
||||
return callbacks.copy() if callbacks else []
|
||||
|
||||
|
||||
def extract_function_description(func: Callable) -> str:
|
||||
"""
|
||||
提取函数的描述(docstring的第一行或前100个字符)
|
||||
"""
|
||||
if func.__doc__:
|
||||
doc = func.__doc__.strip()
|
||||
# 取第一行或前100个字符
|
||||
first_line = doc.split('\n')[0].strip()
|
||||
return first_line[:100] if len(first_line) > 100 else first_line
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_details_from_result(result: Any) -> str:
|
||||
"""
|
||||
从函数返回值中提取 details,顺序固定为:先 details 再中文。
|
||||
1) 先看返回的 dict 里是否有「details 类」字段(__details__、_details、details 或键名含 "details"),
|
||||
且其值为非空,则用该值;
|
||||
2) 若没有或为空,再走原本的中文兜底:从返回结构中找一段包含中文的内容作为 details。
|
||||
"""
|
||||
default = "完成"
|
||||
if not result:
|
||||
return default
|
||||
|
||||
# 第一步:优先用 details 类字段(仅当有值且非空时采用)
|
||||
if isinstance(result, dict):
|
||||
for key in ("__details__", "_details", "details"):
|
||||
if key in result and result[key] is not None:
|
||||
s = str(result[key]).strip()
|
||||
if s:
|
||||
return str(result[key])
|
||||
for k, v in result.items():
|
||||
if "details" in k.lower() and v is not None:
|
||||
s = str(v).strip()
|
||||
if s:
|
||||
return str(v)
|
||||
|
||||
# 第二步:保留原本的中文兜底
|
||||
def _contains_chinese(text: str) -> bool:
|
||||
if not text:
|
||||
return False
|
||||
return any("\u4e00" <= c <= "\u9fff" for c in str(text))
|
||||
|
||||
def _find_chinese(obj: Any, max_length: int = 200) -> Optional[str]:
|
||||
if isinstance(obj, str):
|
||||
return (obj[:max_length] + "...") if _contains_chinese(obj) and len(obj) > max_length else (
|
||||
obj if _contains_chinese(obj) else None)
|
||||
if isinstance(obj, dict):
|
||||
for key, value in obj.items():
|
||||
if "details" in key.lower():
|
||||
continue
|
||||
found = _find_chinese(value, max_length)
|
||||
if found:
|
||||
return found
|
||||
if isinstance(obj, list):
|
||||
for item in obj:
|
||||
found = _find_chinese(item, max_length)
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
|
||||
chinese = _find_chinese(result)
|
||||
return chinese if chinese else default
|
||||
|
||||
|
||||
def track_function_calls(func: Callable) -> Callable:
|
||||
"""
|
||||
装饰器:追踪函数调用过程,支持同步和异步函数
|
||||
|
||||
Args:
|
||||
func: 被装饰的函数
|
||||
"""
|
||||
|
||||
@functools.wraps(func)
|
||||
def sync_wrapper(*args, **kwargs):
|
||||
# 获取函数签名信息
|
||||
sig = inspect.signature(func)
|
||||
bound_args = sig.bind(*args, **kwargs)
|
||||
bound_args.apply_defaults()
|
||||
|
||||
func_name = func.__name__
|
||||
module_name = func.__module__
|
||||
|
||||
# 尝试从函数参数中获取 state
|
||||
state = None
|
||||
if args and isinstance(args[0], dict):
|
||||
state = args[0]
|
||||
elif 'state' in bound_args.arguments:
|
||||
state = bound_args.arguments.get('state')
|
||||
|
||||
func_description = extract_function_description(func)
|
||||
|
||||
# 准备函数参数信息
|
||||
params = {k: v for k, v in bound_args.arguments.items()}
|
||||
|
||||
try:
|
||||
# 执行原函数
|
||||
result = func(*args, **kwargs)
|
||||
|
||||
# 构建 details:优先用返回值中的 details 类字段(__details__、_details、details 或键名含 details)
|
||||
details = _extract_details_from_result(result)
|
||||
|
||||
# 合并函数执行事件(只在执行完成后发送一次)
|
||||
execution_event = {
|
||||
"type": "function_execution",
|
||||
"title": func_description,
|
||||
"details": details,
|
||||
"result": result, # 添加函数返回结果
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
# 调用所有注册的回调(从上下文变量获取)
|
||||
callbacks = _event_callbacks.get() or []
|
||||
for callback in callbacks:
|
||||
try:
|
||||
callback(execution_event)
|
||||
except Exception as e:
|
||||
print(f"回调执行出错: {e}")
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
# 发送函数执行失败事件
|
||||
error_event = {
|
||||
"type": "function_error",
|
||||
"title": func_description,
|
||||
"details": f"错误: {str(e)}",
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
callbacks = _event_callbacks.get() or []
|
||||
for callback in callbacks:
|
||||
try:
|
||||
callback(error_event)
|
||||
except Exception as ex:
|
||||
print(f"回调执行出错: {ex}")
|
||||
|
||||
raise
|
||||
|
||||
@functools.wraps(func)
|
||||
async def async_wrapper(*args, **kwargs):
|
||||
# 获取函数签名信息
|
||||
sig = inspect.signature(func)
|
||||
bound_args = sig.bind(*args, **kwargs)
|
||||
bound_args.apply_defaults()
|
||||
|
||||
func_name = func.__name__
|
||||
module_name = func.__module__
|
||||
|
||||
# 尝试从函数参数中获取 state
|
||||
state = None
|
||||
if args and isinstance(args[0], dict):
|
||||
state = args[0]
|
||||
elif 'state' in bound_args.arguments:
|
||||
state = bound_args.arguments.get('state')
|
||||
|
||||
func_description = extract_function_description(func)
|
||||
|
||||
# 准备函数参数信息
|
||||
params = {k: v for k, v in bound_args.arguments.items()}
|
||||
|
||||
try:
|
||||
# 执行原函数
|
||||
result = await func(*args, **kwargs)
|
||||
|
||||
# 构建 details:优先用返回值中的 details 类字段(__details__、_details、details 或键名含 details)
|
||||
details = _extract_details_from_result(result)
|
||||
|
||||
# 合并函数执行事件(只在执行完成后发送一次)
|
||||
execution_event = {
|
||||
"type": "function_execution",
|
||||
"title": func_description,
|
||||
"details": details,
|
||||
"result": result, # 添加函数返回结果
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
# 调用所有注册的回调(从上下文变量获取)
|
||||
callbacks = _event_callbacks.get() or []
|
||||
for callback in callbacks:
|
||||
try:
|
||||
callback(execution_event)
|
||||
except Exception as e:
|
||||
print(f"回调执行出错: {e}")
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
# 发送函数执行失败事件
|
||||
error_event = {
|
||||
"type": "function_error",
|
||||
"title": func_description,
|
||||
"details": f"错误: {str(e)}",
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
callbacks = _event_callbacks.get() or []
|
||||
for callback in callbacks:
|
||||
try:
|
||||
callback(error_event)
|
||||
except Exception as ex:
|
||||
print(f"回调执行出错: {ex}")
|
||||
|
||||
raise
|
||||
|
||||
# 根据函数是否为协程函数返回对应的装饰器
|
||||
if asyncio.iscoroutinefunction(func):
|
||||
return async_wrapper
|
||||
else:
|
||||
return sync_wrapper
|
||||
|
||||
|
||||
# 事件处理器
|
||||
def console_event_handler(event: Dict[str, Any]):
|
||||
"""
|
||||
控制台事件处理器
|
||||
"""
|
||||
event_type = event["type"]
|
||||
title = event.get("title", "")
|
||||
details = event.get("details", "")
|
||||
|
||||
if event_type == "function_execution":
|
||||
print(f"✅ 函数执行: {title}")
|
||||
if details:
|
||||
print(f" 详情: {details}")
|
||||
|
||||
elif event_type == "function_error":
|
||||
print(f"❌ 函数错误: {title}")
|
||||
if details:
|
||||
print(f" 详情: {details}")
|
||||
|
||||
|
||||
class StreamEventHandler:
|
||||
"""
|
||||
流式事件处理器,将装饰器事件放入队列供流式输出使用
|
||||
"""
|
||||
def __init__(self, queue: asyncio.Queue):
|
||||
self.queue = queue
|
||||
|
||||
def __call__(self, event: Dict[str, Any]):
|
||||
"""
|
||||
处理事件,将事件放入队列
|
||||
注意:这是同步方法,但队列是异步的,需要特殊处理
|
||||
"""
|
||||
try:
|
||||
# 使用线程安全的方式将事件放入队列
|
||||
# 如果队列已满,使用 put_nowait 并忽略错误
|
||||
try:
|
||||
self.queue.put_nowait(event)
|
||||
except asyncio.QueueFull:
|
||||
# 队列已满,跳过此事件(不应该发生,因为队列大小足够)
|
||||
pass
|
||||
except Exception as e:
|
||||
# 忽略错误,避免影响主流程
|
||||
pass
|
||||
|
||||
def send_stream_content(self, content: str, title: str = ""):
|
||||
"""
|
||||
发送流式内容事件
|
||||
|
||||
Args:
|
||||
content: 流式内容片段
|
||||
title: 函数描述/标题(可选)
|
||||
"""
|
||||
try:
|
||||
event = {
|
||||
"type": "stream_content",
|
||||
"content": content,
|
||||
"title": title,
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
self.queue.put_nowait(event)
|
||||
except Exception as e:
|
||||
# 忽略错误,避免影响主流程
|
||||
pass
|
||||
|
||||
|
||||
def create_stream_event_handler() -> tuple[StreamEventHandler, asyncio.Queue]:
|
||||
"""
|
||||
创建流式事件处理器和对应的队列(使用 contextvars 实现请求隔离)
|
||||
|
||||
Returns:
|
||||
(handler, queue): 事件处理器和事件队列
|
||||
"""
|
||||
queue = asyncio.Queue(maxsize=1000) # 设置足够大的队列大小
|
||||
handler = StreamEventHandler(queue)
|
||||
_current_stream_handler.set(handler) # 保存到上下文变量
|
||||
return handler, queue
|
||||
|
||||
|
||||
def get_current_stream_handler() -> Optional[StreamEventHandler]:
|
||||
"""
|
||||
获取当前活动的流式事件处理器(使用 contextvars 实现请求隔离)
|
||||
|
||||
Returns:
|
||||
当前流式事件处理器,如果没有则返回 None
|
||||
"""
|
||||
return _current_stream_handler.get()
|
||||
|
||||
|
||||
def clear_current_stream_handler():
|
||||
"""
|
||||
清除当前活动的流式事件处理器(使用 contextvars 实现请求隔离)
|
||||
"""
|
||||
_current_stream_handler.set(None)
|
||||
|
||||
@ -1,20 +1,110 @@
|
||||
"""
|
||||
图片处理工具模块 - 最终版 v4
|
||||
核心思路(最简单直接):
|
||||
1. 找到所有 .jpg/.png 文件名及其周围的 
|
||||
"""
|
||||
"""图片处理工具模块。"""
|
||||
import re
|
||||
from typing import List
|
||||
from typing import List, Tuple
|
||||
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]:
|
||||
"""从文本中提取图片路径"""
|
||||
pattern = r'images/[a-zA-Z0-9_\-./]+\.(?:jpg|jpeg|png|gif|bmp|webp)'
|
||||
matches = re.findall(pattern, text, re.IGNORECASE)
|
||||
return list(set(matches))
|
||||
"""从文本中提取图片路径或 URL,并保持原始出现顺序。"""
|
||||
if not text:
|
||||
return []
|
||||
|
||||
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:
|
||||
@ -37,7 +127,7 @@ def find_closest_image_path(generated_path: str, original_paths: List[str]) -> s
|
||||
best_score = score
|
||||
best_match = orig_path
|
||||
|
||||
if best_match and best_score > 0.5:
|
||||
if best_match and _is_acceptable_image_match(generated_filename, best_match.split('/')[-1], best_score):
|
||||
return best_match
|
||||
return generated_path
|
||||
|
||||
@ -52,12 +142,15 @@ def normalize_markdown_images(text: str, base_prefix: str = "/api/v1/knowledge/f
|
||||
"""
|
||||
if not text or original_paths is None:
|
||||
return text
|
||||
trusted_paths = [path for path in original_paths if path]
|
||||
if not trusted_paths:
|
||||
return _remove_untrusted_image_references(text)
|
||||
|
||||
# ========== 第一轮:检查和修改格式(包括固定URL前缀) ==========
|
||||
result = _normalize_image_format(text, base_prefix)
|
||||
|
||||
# ========== 第二轮:只检查和替换最后文件名部分 ==========
|
||||
result = _normalize_image_paths(result, base_prefix, original_paths)
|
||||
result = _normalize_image_paths(result, base_prefix, trusted_paths)
|
||||
|
||||
return result
|
||||
|
||||
@ -71,10 +164,35 @@ def _normalize_image_format(text: str, base_prefix: str) -> str:
|
||||
if not text:
|
||||
return text
|
||||
|
||||
IMG_EXTENSIONS = r'(?:jpg|jpeg|png|gif|bmp|webp)'
|
||||
img_pattern = rf'([a-zA-Z0-9_\-./]+)\.({IMG_EXTENSIONS})'
|
||||
|
||||
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))
|
||||
|
||||
if not matches:
|
||||
@ -108,12 +226,8 @@ def _normalize_image_format(text: str, base_prefix: str) -> str:
|
||||
if '\n' not in middle and '
|
||||
pure_filename = filename_with_ext.split('/')[-1]
|
||||
|
||||
# 构建完整的标准格式(包括固定URL前缀)
|
||||
full_url = base_prefix.rstrip('/') + '/images/' + pure_filename
|
||||
standard_format = f''
|
||||
standard_format = build_standard_tag(filename_with_ext)
|
||||
|
||||
result = result[:mark_start] + standard_format + result[mark_end:]
|
||||
|
||||
@ -163,7 +277,7 @@ def _normalize_image_paths(text: str, base_prefix: str, original_paths: List[str
|
||||
best_match = None
|
||||
best_score = 0
|
||||
|
||||
generated_filename = filename.lower()
|
||||
generated_filename = _strip_url_suffix(filename).lower()
|
||||
|
||||
for orig_path in original_paths:
|
||||
orig_filename = orig_path.split('/')[-1].lower()
|
||||
@ -172,11 +286,10 @@ def _normalize_image_paths(text: str, base_prefix: str, original_paths: List[str
|
||||
best_score = score
|
||||
best_match = orig_path
|
||||
|
||||
if best_match and best_score > 0.5:
|
||||
if best_match and _is_acceptable_image_match(generated_filename, best_match.split('/')[-1], best_score):
|
||||
# 匹配成功,替换为正确路径(保持固定URL前缀)
|
||||
# 只替换最后文件名部分,保持前缀不变
|
||||
pure_orig_filename = best_match.split('/')[-1]
|
||||
full_url = base_prefix.rstrip('/') + '/images/' + pure_orig_filename
|
||||
full_url = _path_to_image_url(best_match, base_prefix)
|
||||
|
||||
standard_format = f''
|
||||
result = result[:tag_start] + standard_format + result[tag_end:]
|
||||
@ -190,12 +303,10 @@ def _normalize_image_paths(text: str, base_prefix: str, original_paths: List[str
|
||||
def get_image_prompt_guidance() -> str:
|
||||
"""获取图片格式处理的提示词指导"""
|
||||
return """【图片格式特别要求】
|
||||
- 检索结果中的图片链接格式通常为:``
|
||||
- 输出时必须严格保留此格式,不得修改、省略或截断
|
||||
- 图片的 alt 文本必须保持为「图片」
|
||||
- 图片 URL 必须完整包含 `/api/v1/knowledge/files/images` 前缀
|
||||
- 不要将图片链接转换为纯文本描述
|
||||
- 如果有多个图片,在对应位置分别引用,保持原始顺序
|
||||
- 严禁修改图片文件名或路径结构
|
||||
- 如果需要展示图片,直接使用原始的 `` 格式即可"""
|
||||
- 只允许引用本次参考资料中真实出现的图片链接
|
||||
- 输出图片时使用 Markdown 图片标签:alt 文本固定为「图片」,括号内必须是参考资料里的原始完整 URL
|
||||
- 图片文件名、路径和 URL 前缀必须逐字复制,不得补全、改写、截断、转义或根据记忆/示例构造
|
||||
- 不要输出提示词中的占位符或示例文件名,不要把普通文字描述改写成图片链接
|
||||
- 图片规则只用于生成图片标签,不要围绕图片资源状态输出任何文字说明
|
||||
- 多个图片可在对应位置分别引用并保持原始顺序;同一图片只引用一次"""
|
||||
|
||||
|
||||
@ -22,6 +22,15 @@ from config import KB_TREE_CONFIG
|
||||
_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]:
|
||||
"""
|
||||
从知识库树 API 加载所有知识库,返回 {名称: kb_id} 的映射
|
||||
@ -171,6 +180,26 @@ async def _build_ship_mapping_candidates() -> Dict[str, Dict[str, Any]]:
|
||||
**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
|
||||
|
||||
|
||||
@ -206,8 +235,10 @@ async def smart_ship_number_mapping(
|
||||
user_input = user_input.strip()
|
||||
candidates = await get_ship_mapping_candidates()
|
||||
|
||||
if user_input in candidates:
|
||||
info = candidates[user_input]
|
||||
normalized_input = _normalize_ship_lookup_text(user_input)
|
||||
exact_key = user_input if user_input in candidates else normalized_input
|
||||
if exact_key in candidates:
|
||||
info = candidates[exact_key]
|
||||
match_type = f"exact_{info['type']}"
|
||||
model = info["model"]
|
||||
numbers = info["numbers"]
|
||||
@ -332,7 +363,7 @@ async def search_with_ship_number_strategy(
|
||||
else:
|
||||
print(f"步骤1 - 舷号 {matched_ship_number} 无对应知识库,跳过指定舷号检索")
|
||||
|
||||
if match_type in ["exact_number", "exact_name", "embedding"] and model_all_numbers:
|
||||
if match_type in ["exact_number", "exact_name", "embedding", "exact_model"] and model_all_numbers:
|
||||
send_search_status(
|
||||
"正在型号检索",
|
||||
f"识别为型号 '{matched_model}',使用该型号所有舷号 {', '.join(model_all_numbers)} 进行检索..."
|
||||
|
||||
@ -1,467 +0,0 @@
|
||||
"""
|
||||
舷号搜索策略模块
|
||||
提供三级搜索策略:
|
||||
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 ""
|
||||
71
utils/source_citations.py
Normal file
@ -0,0 +1,71 @@
|
||||
"""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,11 +356,22 @@ class MarkdownWordGenerator:
|
||||
def _get_http_headers(url: str) -> dict:
|
||||
headers = {}
|
||||
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:
|
||||
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"))
|
||||
add_header("x-user-id", RAG_CONFIG.get("x-user-id", "1"), "1")
|
||||
add_header("x-user-name", RAG_CONFIG.get("x-user-name", "testuser"), "testuser")
|
||||
add_header("x-role", RAG_CONFIG.get("x-role", "admin"), "admin")
|
||||
except Exception:
|
||||
headers["x-user-id"] = "1"
|
||||
headers["x-user-name"] = "testuser"
|
||||
@ -788,3 +799,4 @@ if __name__ == "__main__":
|
||||
print(f"程序执行出错: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
@ -1,716 +0,0 @@
|
||||
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,158 +1,158 @@
|
||||
"""
|
||||
工作流注册表 - 主代理与 app 共用
|
||||
主代理阶段四根据此配置路由到所有注册的子 agent;
|
||||
app 据此执行对应工作流。
|
||||
添加新工作流时,在此配置 WORKFLOW_CONFIG 与 ROUTE_DESCRIPTIONS。
|
||||
"""
|
||||
# ============================================================================
|
||||
# =============== 工作流配置区域 - 添加新工作流时只需修改这里 ===============
|
||||
# ============================================================================
|
||||
# 格式:
|
||||
# "route_flag": {
|
||||
# "module": "workflows.workflow_xxx",
|
||||
# "function": "run_xxx_workflow",
|
||||
# "is_async": True/False,
|
||||
# }
|
||||
# ============================================================================
|
||||
|
||||
WORKFLOW_CONFIG = {
|
||||
"fault_diagnosis": {
|
||||
"module": "workflows.workflow_fault_diagnosis",
|
||||
"function": "run_fault_diagnosis_workflow",
|
||||
"is_async": True,
|
||||
},
|
||||
"qa": {
|
||||
"module": "workflows.workflow_qa",
|
||||
"function": "run_qa_workflow",
|
||||
"is_async": True,
|
||||
},
|
||||
"问题排查": {
|
||||
"module": "workflows.workflow_fault_diagnosis",
|
||||
"function": "run_fault_diagnosis_workflow",
|
||||
"is_async": True,
|
||||
},
|
||||
"spare_parts_statistics": {
|
||||
"module": "workflows.workflow_spare_parts_statistics",
|
||||
"function": "run_spare_parts_statistics_workflow",
|
||||
"is_async": True,
|
||||
},
|
||||
"rules": {
|
||||
"module": "workflows.workflow_qa",
|
||||
"function": "run_qa_workflow",
|
||||
"is_async": True,
|
||||
},
|
||||
"other": {
|
||||
"module": "workflows.workflow_fault_diagnosis",
|
||||
"function": "run_fault_diagnosis_workflow",
|
||||
"is_async": True,
|
||||
#
|
||||
# "module": "workflows.workflow_other",
|
||||
# "function": "run_other_workflow",
|
||||
# "is_async": True,
|
||||
},
|
||||
"report":{
|
||||
# "module": "workflows.workflow_qa_report",
|
||||
# "function": "run_qa_report_agent",
|
||||
# "is_async": True,
|
||||
"module": "workflows.workflow_fault_diagnosis",
|
||||
"function": "run_fault_diagnosis_workflow",
|
||||
"is_async": True,
|
||||
},
|
||||
"百科问答":{
|
||||
"module": "workflows.workflow_baike",
|
||||
"function": "run_baike_workflow",
|
||||
"is_async": True,
|
||||
},
|
||||
"操作使用":{
|
||||
"module": "workflows.workflow_operate",
|
||||
"function": "run_operate_workflow",
|
||||
"is_async": True,
|
||||
},
|
||||
"统计":{
|
||||
"module": "workflows.workflow_statistics",
|
||||
"function": "run_statistics_workflow",
|
||||
"is_async": True,
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
# 路由标志 -> 任务分类描述(供主代理阶段四 LLM 使用)
|
||||
ROUTE_DESCRIPTIONS = {
|
||||
"fault_diagnosis": """
|
||||
- 特征:用户描述**当前或近期发生的设备异常、报警、性能下降等具体故障现象**,需要分析原因或定位问题。
|
||||
- 关键词:报警、异常、异响、冒烟、过热、无法启动、突然停机、振动大、压力低、温度高、故障代码、不工作
|
||||
- 示例:
|
||||
• 主机滑油压力突然降到0.2MPa并报警
|
||||
• 柴油机启动后冒黑烟且转速不稳
|
||||
• 舵机发出异常噪音,疑似齿轮损坏
|
||||
- 注意:若历史记录中包含“请补充”、“【需要更多信息】”,通常属于此类,因已聚焦具体故障场景。
|
||||
当用户问题中只有舷号、设备或故障现象时属于此类,舷号一般为连续的阿拉伯数字如“101”,"111","122","163","16","17","554","602","545","985","31","3261","799","905","781"等,或者数字后加舰,比如“101舰","199舰","18舰"等。
|
||||
""",
|
||||
|
||||
"qa": """
|
||||
- 特征:询问定义、原理、结构、功能、分类、标准配置、常见类型、安全规范等**一般性知识**;也包括对专业术语、现象、部件作用的解释。
|
||||
**即使问题表述模糊、不完整、缺少上下文,只要涉及船舶、机械、电气、动力等技术内容,均优先归为此类。**
|
||||
- 关键词:什么是、如何工作、为什么、请问、介绍、解释、有哪些、常见、典型、一般、作用、组成、是不是、叫什么
|
||||
- 示例:
|
||||
• 船舶主发动机有哪些常见故障类型?
|
||||
• 涡轮增压器的工作原理是什么?
|
||||
• 主机由哪些主要部件组成?
|
||||
• 什么是MAN B&W柴油机?
|
||||
- 注意:
|
||||
• **本类作为技术相关问题的默认兜底类别**——凡涉及设备、系统、部件、工况、参数等技术语境的问题,若无法明确归入 fault_diagnosis / repair / spare_parts_statistics / rules,则优先视为 qa。
|
||||
""",
|
||||
|
||||
"repair": """
|
||||
- 特征:用户明确请求**维修方法、操作步骤、检修流程、更换方案或现场处理建议**,通常在已知故障或需预防性维护时提出。
|
||||
- 关键词:怎么修、如何维修、维修步骤、检修方法、更换、拆卸、安装、处理、修复、维护、保养、调整
|
||||
- 示例:
|
||||
• 主机喷油器漏油,怎么更换?
|
||||
• 给出主机缸套磨损的维修方案
|
||||
• 如何拆卸增压器进行检修?
|
||||
""",
|
||||
|
||||
"spare_parts_statistics": """
|
||||
- 特征:用户需要对**备品备件的数量、使用记录、库存、消耗趋势、预测需求或成本分析**进行查询或统计。
|
||||
- 关键词:备件、备品、库存、数量、统计、预测、分析、消耗、需求、清单、台账、寿命、更换周期、用了多少、还剩多少
|
||||
- 示例:
|
||||
• 统计过去一年主机喷油嘴的使用数量
|
||||
• 预测下季度所需空冷器备件数量
|
||||
• 主机活塞环的平均更换周期是多少?
|
||||
- 注意:若仅问“某个部件是不是备件”或“这个备件叫什么名字”,属于知识问答(qa),而非本类。
|
||||
""",
|
||||
|
||||
"rules": """
|
||||
- 特征:用户询问与船舶设备维修、检验、安全、环保、保密等相关的**国家法规、军用标准(如GJB)、行业规范、船级社要求或管理制度**。
|
||||
- 关键词:法规、标准、规范、规定、合规、条例、依据、要求、准则、制度、认证、资质、船级社、CCS、IMO、SOLAS、MARPOL、资格
|
||||
- 示例:
|
||||
• 船舶主机修理需遵循哪些GJB标准?
|
||||
• IMO对主机排放有哪些强制要求?
|
||||
• 舰船维修单位需要哪些资质?
|
||||
• 保密资格申请流程是什么?
|
||||
- 注意:若问题提到“标准里有没有…”但未指明具体法规/标准名称,且无“法规”“标准”等关键词,可先归为 qa;一旦明确提及“法规”“标准”“规范”“SOLAS”等,则归为 rules。
|
||||
""",
|
||||
"other": """
|
||||
- 特征:**完全不涉及新的技术问题、故障诊断、维修请求、备件统计、法规查询或报告生成**,而是针对**已有对话内容、方案、步骤或文本的反馈、修正、补充、顺序调整或格式优化**。典型场景包括:
|
||||
• 指出之前提供的操作步骤有误(如“异物清理步骤有误”)
|
||||
• 要求调整操作顺序(如“这一步应当先怎样,然后再怎么操作,最后……”)
|
||||
• 对生成内容提出修改意见(如“步骤有误/有补充”“请重写第三点”)
|
||||
• 请求优化语言、格式或结构(如“请用更简洁的方式表达”)
|
||||
• 闲聊、非技术性确认或与当前技术任务无关的交互
|
||||
|
||||
- 注意:
|
||||
• **只要用户意图是修正、补充或调整已有内容,而非提出新问题,即使涉及技术术语,也应归为 other**。
|
||||
• 不要将此类反馈误判为 repair 或 qa——它们不是在问“该怎么修”或“原理是什么”,而是在说“你刚才说的不对/不全”。
|
||||
• 此类通常出现在多轮对话中,是对前文输出的回应。
|
||||
""",
|
||||
"report": """
|
||||
- 特征:用户明确要求**生成、整理、导出或汇总**某种形式的文档、报表、清单或总结材料。通常涉及将分散的故障记录、维修历史、备件数据或问答内容整合成结构化文本(如日报、周报、故障分析报告、维修总结、备件消耗表等)。
|
||||
- 关键词:生成报告、写总结、导出表格、整理清单、汇总数据、形成文档、日报、周报、月报、分析报告、维修记录单、台账报表
|
||||
- 示例:
|
||||
• 请根据本周的故障记录生成一份主机系统运行分析报告
|
||||
• 总结这次对话
|
||||
• 导出过去一年的备件消耗统计报告
|
||||
• 帮我写一份关于此次主机停机事故的详细处理总结
|
||||
"""
|
||||
}
|
||||
|
||||
VALID_ROUTE_FLAGS = tuple(WORKFLOW_CONFIG.keys())
|
||||
"""
|
||||
工作流注册表 - 主代理与 app 共用
|
||||
主代理阶段四根据此配置路由到所有注册的子 agent;
|
||||
app 据此执行对应工作流。
|
||||
添加新工作流时,在此配置 WORKFLOW_CONFIG 与 ROUTE_DESCRIPTIONS。
|
||||
"""
|
||||
# ============================================================================
|
||||
# =============== 工作流配置区域 - 添加新工作流时只需修改这里 ===============
|
||||
# ============================================================================
|
||||
# 格式:
|
||||
# "route_flag": {
|
||||
# "module": "workflows.workflow_xxx",
|
||||
# "function": "run_xxx_workflow",
|
||||
# "is_async": True/False,
|
||||
# }
|
||||
# ============================================================================
|
||||
|
||||
WORKFLOW_CONFIG = {
|
||||
"fault_diagnosis": {
|
||||
"module": "workflows.workflow_fault_diagnosis",
|
||||
"function": "run_fault_diagnosis_workflow",
|
||||
"is_async": True,
|
||||
},
|
||||
"qa": {
|
||||
"module": "workflows.workflow_qa",
|
||||
"function": "run_qa_workflow",
|
||||
"is_async": True,
|
||||
},
|
||||
"问题排查": {
|
||||
"module": "workflows.workflow_fault_diagnosis",
|
||||
"function": "run_fault_diagnosis_workflow",
|
||||
"is_async": True,
|
||||
},
|
||||
"spare_parts_statistics": {
|
||||
"module": "workflows.workflow_spare_parts_statistics",
|
||||
"function": "run_spare_parts_statistics_workflow",
|
||||
"is_async": True,
|
||||
},
|
||||
"rules": {
|
||||
"module": "workflows.workflow_qa",
|
||||
"function": "run_qa_workflow",
|
||||
"is_async": True,
|
||||
},
|
||||
"other": {
|
||||
"module": "workflows.workflow_fault_diagnosis",
|
||||
"function": "run_fault_diagnosis_workflow",
|
||||
"is_async": True,
|
||||
#
|
||||
# "module": "workflows.workflow_other",
|
||||
# "function": "run_other_workflow",
|
||||
# "is_async": True,
|
||||
},
|
||||
"report":{
|
||||
# "module": "workflows.workflow_qa_report",
|
||||
# "function": "run_qa_report_agent",
|
||||
# "is_async": True,
|
||||
"module": "workflows.workflow_fault_diagnosis",
|
||||
"function": "run_fault_diagnosis_workflow",
|
||||
"is_async": True,
|
||||
},
|
||||
"百科问答":{
|
||||
"module": "workflows.workflow_baike",
|
||||
"function": "run_baike_workflow",
|
||||
"is_async": True,
|
||||
},
|
||||
"操作使用":{
|
||||
"module": "workflows.workflow_operate",
|
||||
"function": "run_operate_workflow",
|
||||
"is_async": True,
|
||||
},
|
||||
"统计":{
|
||||
"module": "workflows.workflow_statistics",
|
||||
"function": "run_statistics_workflow",
|
||||
"is_async": True,
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
# 路由标志 -> 任务分类描述(供主代理阶段四 LLM 使用)
|
||||
ROUTE_DESCRIPTIONS = {
|
||||
"fault_diagnosis": """
|
||||
- 特征:用户描述**当前或近期发生的设备异常、报警、性能下降等具体故障现象**,需要分析原因或定位问题。
|
||||
- 关键词:报警、异常、异响、冒烟、过热、无法启动、突然停机、振动大、压力低、温度高、故障代码、不工作
|
||||
- 示例:
|
||||
• 主机滑油压力突然降到0.2MPa并报警
|
||||
• 柴油机启动后冒黑烟且转速不稳
|
||||
• 舵机发出异常噪音,疑似齿轮损坏
|
||||
- 注意:若历史记录中包含“请补充”、“【需要更多信息】”,通常属于此类,因已聚焦具体故障场景。
|
||||
当用户问题中只有舷号、设备或故障现象时属于此类,舷号一般为连续的阿拉伯数字如“101”,"111","122","163","16","17","554","602","545","985","31","3261","799","905","781"等,或者数字后加舰,比如“101舰","199舰","18舰"等。
|
||||
""",
|
||||
|
||||
"qa": """
|
||||
- 特征:询问定义、原理、结构、功能、分类、标准配置、常见类型、安全规范等**一般性知识**;也包括对专业术语、现象、部件作用的解释。
|
||||
**即使问题表述模糊、不完整、缺少上下文,只要涉及船舶、机械、电气、动力等技术内容,均优先归为此类。**
|
||||
- 关键词:什么是、如何工作、为什么、请问、介绍、解释、有哪些、常见、典型、一般、作用、组成、是不是、叫什么
|
||||
- 示例:
|
||||
• 船舶主发动机有哪些常见故障类型?
|
||||
• 涡轮增压器的工作原理是什么?
|
||||
• 主机由哪些主要部件组成?
|
||||
• 什么是MAN B&W柴油机?
|
||||
- 注意:
|
||||
• **本类作为技术相关问题的默认兜底类别**——凡涉及设备、系统、部件、工况、参数等技术语境的问题,若无法明确归入 fault_diagnosis / repair / spare_parts_statistics / rules,则优先视为 qa。
|
||||
""",
|
||||
|
||||
"repair": """
|
||||
- 特征:用户明确请求**维修方法、操作步骤、检修流程、更换方案或现场处理建议**,通常在已知故障或需预防性维护时提出。
|
||||
- 关键词:怎么修、如何维修、维修步骤、检修方法、更换、拆卸、安装、处理、修复、维护、保养、调整
|
||||
- 示例:
|
||||
• 主机喷油器漏油,怎么更换?
|
||||
• 给出主机缸套磨损的维修方案
|
||||
• 如何拆卸增压器进行检修?
|
||||
""",
|
||||
|
||||
"spare_parts_statistics": """
|
||||
- 特征:用户需要对**备品备件的数量、使用记录、库存、消耗趋势、预测需求或成本分析**进行查询或统计。
|
||||
- 关键词:备件、备品、库存、数量、统计、预测、分析、消耗、需求、清单、台账、寿命、更换周期、用了多少、还剩多少
|
||||
- 示例:
|
||||
• 统计过去一年主机喷油嘴的使用数量
|
||||
• 预测下季度所需空冷器备件数量
|
||||
• 主机活塞环的平均更换周期是多少?
|
||||
- 注意:若仅问“某个部件是不是备件”或“这个备件叫什么名字”,属于知识问答(qa),而非本类。
|
||||
""",
|
||||
|
||||
"rules": """
|
||||
- 特征:用户询问与船舶设备维修、检验、安全、环保、保密等相关的**国家法规、军用标准(如GJB)、行业规范、船级社要求或管理制度**。
|
||||
- 关键词:法规、标准、规范、规定、合规、条例、依据、要求、准则、制度、认证、资质、船级社、CCS、IMO、SOLAS、MARPOL、资格
|
||||
- 示例:
|
||||
• 船舶主机修理需遵循哪些GJB标准?
|
||||
• IMO对主机排放有哪些强制要求?
|
||||
• 舰船维修单位需要哪些资质?
|
||||
• 保密资格申请流程是什么?
|
||||
- 注意:若问题提到“标准里有没有…”但未指明具体法规/标准名称,且无“法规”“标准”等关键词,可先归为 qa;一旦明确提及“法规”“标准”“规范”“SOLAS”等,则归为 rules。
|
||||
""",
|
||||
"other": """
|
||||
- 特征:**完全不涉及新的技术问题、故障诊断、维修请求、备件统计、法规查询或报告生成**,而是针对**已有对话内容、方案、步骤或文本的反馈、修正、补充、顺序调整或格式优化**。典型场景包括:
|
||||
• 指出之前提供的操作步骤有误(如“异物清理步骤有误”)
|
||||
• 要求调整操作顺序(如“这一步应当先怎样,然后再怎么操作,最后……”)
|
||||
• 对生成内容提出修改意见(如“步骤有误/有补充”“请重写第三点”)
|
||||
• 请求优化语言、格式或结构(如“请用更简洁的方式表达”)
|
||||
• 闲聊、非技术性确认或与当前技术任务无关的交互
|
||||
|
||||
- 注意:
|
||||
• **只要用户意图是修正、补充或调整已有内容,而非提出新问题,即使涉及技术术语,也应归为 other**。
|
||||
• 不要将此类反馈误判为 repair 或 qa——它们不是在问“该怎么修”或“原理是什么”,而是在说“你刚才说的不对/不全”。
|
||||
• 此类通常出现在多轮对话中,是对前文输出的回应。
|
||||
""",
|
||||
"report": """
|
||||
- 特征:用户明确要求**生成、整理、导出或汇总**某种形式的文档、报表、清单或总结材料。通常涉及将分散的故障记录、维修历史、备件数据或问答内容整合成结构化文本(如日报、周报、故障分析报告、维修总结、备件消耗表等)。
|
||||
- 关键词:生成报告、写总结、导出表格、整理清单、汇总数据、形成文档、日报、周报、月报、分析报告、维修记录单、台账报表
|
||||
- 示例:
|
||||
• 请根据本周的故障记录生成一份主机系统运行分析报告
|
||||
• 总结这次对话
|
||||
• 导出过去一年的备件消耗统计报告
|
||||
• 帮我写一份关于此次主机停机事故的详细处理总结
|
||||
"""
|
||||
}
|
||||
|
||||
VALID_ROUTE_FLAGS = tuple(WORKFLOW_CONFIG.keys())
|
||||
|
||||
@ -1,20 +1,20 @@
|
||||
"""
|
||||
工作流模块
|
||||
包含所有工作流Agent
|
||||
"""
|
||||
from .workflow_fault_diagnosis import run_fault_diagnosis_workflow
|
||||
|
||||
# 其他工作流暂未实现,待后续添加
|
||||
# from .workflow_repair import run_repair_workflow
|
||||
# from .workflow_statistics import run_statistics_workflow
|
||||
# from .workflow_qa import run_qa_workflow
|
||||
# from .workflow_report import run_report_workflow
|
||||
|
||||
__all__ = [
|
||||
"run_fault_diagnosis_workflow",
|
||||
# "run_repair_workflow",
|
||||
# "run_statistics_workflow",
|
||||
# "run_qa_workflow",
|
||||
# "run_report_workflow"
|
||||
]
|
||||
|
||||
"""
|
||||
工作流模块
|
||||
包含所有工作流Agent
|
||||
"""
|
||||
from .workflow_fault_diagnosis import run_fault_diagnosis_workflow
|
||||
|
||||
# 其他工作流暂未实现,待后续添加
|
||||
# from .workflow_repair import run_repair_workflow
|
||||
# from .workflow_statistics import run_statistics_workflow
|
||||
# from .workflow_qa import run_qa_workflow
|
||||
# from .workflow_report import run_report_workflow
|
||||
|
||||
__all__ = [
|
||||
"run_fault_diagnosis_workflow",
|
||||
# "run_repair_workflow",
|
||||
# "run_statistics_workflow",
|
||||
# "run_qa_workflow",
|
||||
# "run_report_workflow"
|
||||
]
|
||||
|
||||
|
||||
@ -7,13 +7,17 @@ import json
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
DEFAULT_HISTORY_MAX_MESSAGES = 12
|
||||
DEFAULT_ASSISTANT_TRUNCATE = 1000
|
||||
|
||||
|
||||
class HistoryManager:
|
||||
"""统一的历史对话管理器,提供解析、截断、格式化、过滤等功能"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
max_messages: int = 8,
|
||||
max_assistant_chars: int = 300,
|
||||
max_messages: int = DEFAULT_HISTORY_MAX_MESSAGES,
|
||||
max_assistant_chars: int = DEFAULT_ASSISTANT_TRUNCATE,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
@ -192,8 +196,8 @@ def parse_history(history_message: Any) -> List[Dict[str, Any]]:
|
||||
|
||||
def build_history_str(
|
||||
history_messages: List[Dict[str, Any]],
|
||||
recent_count: int = 4,
|
||||
assistant_truncate: int = 300,
|
||||
recent_count: int = DEFAULT_HISTORY_MAX_MESSAGES,
|
||||
assistant_truncate: int = DEFAULT_ASSISTANT_TRUNCATE,
|
||||
) -> str:
|
||||
"""兼容旧调用的模块级函数"""
|
||||
mgr = HistoryManager(max_messages=recent_count, max_assistant_chars=assistant_truncate)
|
||||
@ -214,4 +218,3 @@ def init_history(history_message: Any) -> Dict[str, Any]:
|
||||
"""兼容旧调用的模块级函数"""
|
||||
return _default_manager.init_history(history_message)
|
||||
|
||||
|
||||
|
||||
@ -1,591 +1,544 @@
|
||||
"""
|
||||
工作流:普通问答Agent(使用LangGraph)
|
||||
输入:文本(转化后和原文本query)、图片描述、语音文本
|
||||
步骤:
|
||||
1. 使用RAG和GraphRAG检索相关信息
|
||||
2. 生成回答
|
||||
使用 OpenaiAPI 方法调用大模型,尽可能使用异步
|
||||
百科问答工作流。
|
||||
|
||||
核心链路借鉴 WeKnora:
|
||||
1. 意图判断默认偏向检索
|
||||
2. 基于历史重写检索 query,并生成多个检索变体
|
||||
3. RAG / Graph 并行召回
|
||||
4. 多源 evidence 去重、融合排序、rerank
|
||||
5. 只基于 evidence 生成可追溯回答
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
|
||||
# 添加项目根目录到Python搜索路径
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from typing import TypedDict, Optional, Dict, Any, List
|
||||
from langgraph.graph import StateGraph, START, END
|
||||
from workflows.history_manager import init_history
|
||||
|
||||
from tools.function_tool import (
|
||||
graph_rag_search
|
||||
)
|
||||
from tools.rag_tools import rag_search
|
||||
from modelsAPI.model_api import OpenaiAPI
|
||||
from utils.function_tracker import track_function_calls, get_current_stream_handler, get_all_callbacks
|
||||
from utils.image_utils import extract_image_paths, find_closest_image_path, normalize_markdown_images, get_image_prompt_guidance
|
||||
from prompts import BAIKE_PROMPTS, COMMON_PROMPTS
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import sys
|
||||
from typing import Any, Dict, List, Optional, TypedDict
|
||||
|
||||
from langgraph.graph import END, START, StateGraph
|
||||
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from modelsAPI.model_api import OpenaiAPI
|
||||
from prompts import BAIKE_PROMPTS
|
||||
from tools.function_tool import graph_rag_search
|
||||
from tools.rag_tools import rag_search
|
||||
from utils.function_tracker import get_all_callbacks, track_function_calls
|
||||
from utils.image_utils import extract_image_paths
|
||||
from workflows.history_manager import DEFAULT_HISTORY_MAX_MESSAGES, init_history
|
||||
from workflows.workflow_utils import dedupe_rag_results, stream_generate_and_postprocess
|
||||
|
||||
|
||||
BAIKE_RAG_TOP_K = 8
|
||||
BAIKE_GRAPH_TOP_K = 240
|
||||
BAIKE_RESULT_LIMIT = 8
|
||||
BAIKE_EVIDENCE_LIMIT = 10
|
||||
|
||||
|
||||
# =============== 定义状态 ===============
|
||||
class QAState(TypedDict):
|
||||
"""普通问答工作流状态"""
|
||||
extracted_text: str # 已提取的文本(统一接口,只接收文本)
|
||||
extracted_text: str
|
||||
combined_query: str
|
||||
retrieval_query: str # 用于RAG/图谱检索的重写后query
|
||||
route_flag: str # 路由标识
|
||||
history_message: str # 历史消息(JSON格式)
|
||||
history_messages: List[Dict[str, Any]] # 过滤后的历史消息列表
|
||||
need_retrieval: Optional[bool] # 是否需要检索
|
||||
retrieval_query: str
|
||||
retrieval_queries: List[str]
|
||||
route_flag: str
|
||||
history_message: str
|
||||
history_messages: List[Dict[str, Any]]
|
||||
need_retrieval: Optional[bool]
|
||||
rag_search_result: Optional[List[Dict[str, Any]]]
|
||||
graph_search_result: Optional[str]
|
||||
evidence_items: Optional[List[Dict[str, Any]]]
|
||||
response: str
|
||||
actions: List[Dict[str, Any]]
|
||||
suggestedReplies: List[Dict[str, Any]]
|
||||
error_message: str
|
||||
|
||||
# RAG检索结果
|
||||
rag_search_result: Optional[List[Dict[str, Any]]] # RAG检索结果
|
||||
graph_search_result: Optional[str] # 图谱检索结果
|
||||
|
||||
# 最终响应
|
||||
response: str # 最终响应
|
||||
actions: List[Dict[str, Any]] # 按钮信息列表 [{"type": "...", "label": "...", "icon": "..."}]
|
||||
suggestedReplies: List[Dict[str, Any]] # 建议回复问题列表
|
||||
error_message: str # 错误信息
|
||||
|
||||
|
||||
# =============== 节点函数 ===============
|
||||
|
||||
def get_baike_history_context(state: QAState) -> Dict[str, Any]:
|
||||
"""
|
||||
知识问答步骤:获取问答类历史信息
|
||||
问答工作流只关注历史问答记录,用于提供上下文
|
||||
"""
|
||||
history_message = state.get("history_message", "") or ""
|
||||
return init_history(history_message)
|
||||
return init_history(state.get("history_message", "") or "")
|
||||
|
||||
|
||||
async def judge_need_retrieval(state: QAState) -> Dict[str, Any]:
|
||||
"""
|
||||
判断用户问题是否需要检索知识库
|
||||
"""
|
||||
original_query = (state.get("combined_query") or state.get("extracted_text") or "").strip()
|
||||
history_messages = state.get("history_messages", []) or []
|
||||
|
||||
prompt = BAIKE_PROMPTS["judge_need_retrieval"].format(
|
||||
history_messages=history_messages if history_messages else '无',
|
||||
original_query=original_query
|
||||
history_messages=history_messages if history_messages else "无",
|
||||
original_query=original_query,
|
||||
)
|
||||
|
||||
need_retrieval = True
|
||||
|
||||
substantive_query = _looks_like_substantive_query(original_query)
|
||||
need_retrieval = substantive_query
|
||||
try:
|
||||
result = await OpenaiAPI.open_api_chat_without_thinking(prompt, model=None)
|
||||
if result:
|
||||
result = result.strip().lower()
|
||||
if result == "false":
|
||||
need_retrieval = False
|
||||
except Exception as e:
|
||||
print(f"判断是否需要检索失败: {e}")
|
||||
need_retrieval = True
|
||||
|
||||
|
||||
if not substantive_query and result and result.strip().lower() == "false":
|
||||
need_retrieval = False
|
||||
except Exception as exc:
|
||||
print(f"判断是否需要检索失败: {exc}")
|
||||
|
||||
print(f"是否需要检索: {need_retrieval}")
|
||||
return {"need_retrieval": need_retrieval}
|
||||
|
||||
|
||||
def _looks_like_substantive_query(query: str) -> bool:
|
||||
text = re.sub(r"\s+", "", (query or "").strip())
|
||||
if not text:
|
||||
return False
|
||||
if re.fullmatch(r"(你好|您好|hi|hello|在吗|谢谢|多谢|好的|好|嗯|哦|确认|取消|不用了)[。!!?\s]*", text, re.I):
|
||||
return False
|
||||
if len(text) >= 4:
|
||||
return True
|
||||
return bool(re.search(r"[??]|[A-Za-z0-9]{2,}", text))
|
||||
|
||||
|
||||
async def rewrite_query_with_history(state: QAState) -> Dict[str, Any]:
|
||||
"""
|
||||
使用历史上下文对当前问题进行query重写,生成适合RAG/图谱检索的独立查询语句。
|
||||
只让大模型在有限的最近历史中挑选与当前问题语义相关的内容进行融合,避免简单拼接全量历史。
|
||||
"""
|
||||
import json
|
||||
|
||||
# 原始问题:优先使用 combined_query,其次使用 extracted_text
|
||||
original_query = (state.get("combined_query") or state.get("extracted_text") or "").strip()
|
||||
|
||||
history_messages = state.get("history_messages", []) or []
|
||||
# 控制历史长度:只保留最近的若干条,避免上下文过长
|
||||
trimmed_history = history_messages[-8:] if history_messages else []
|
||||
|
||||
try:
|
||||
history_str = json.dumps(trimmed_history, ensure_ascii=False)
|
||||
except Exception:
|
||||
history_str = str(trimmed_history)
|
||||
|
||||
# 如果没有历史或当前问题过短,就直接跳过重写
|
||||
if not original_query:
|
||||
return {"retrieval_query": original_query}
|
||||
# 在构造 prompt 前,提取上一轮用户 query(如果有)
|
||||
trimmed_history = history_messages[-DEFAULT_HISTORY_MAX_MESSAGES:] if history_messages else []
|
||||
history_str = _json_dumps(trimmed_history)
|
||||
last_user_query = ""
|
||||
if trimmed_history:
|
||||
# 从后往前找最近一条 role == "user" 的消息
|
||||
for msg in reversed(trimmed_history):
|
||||
if msg.get("role") == "user":
|
||||
last_user_query = msg.get("content", "").strip()
|
||||
break
|
||||
for msg in reversed(trimmed_history):
|
||||
if msg.get("role") == "user":
|
||||
last_user_query = str(msg.get("content") or "").strip()
|
||||
break
|
||||
|
||||
if not original_query:
|
||||
return {"retrieval_query": "", "retrieval_queries": []}
|
||||
|
||||
prompt = BAIKE_PROMPTS["rewrite_query_with_history"].format(
|
||||
last_user_query=last_user_query or '(无)',
|
||||
last_user_query=last_user_query or "无",
|
||||
history_str=history_str,
|
||||
original_query=original_query
|
||||
original_query=original_query,
|
||||
)
|
||||
|
||||
rewritten_query = original_query
|
||||
query_variants: List[str] = []
|
||||
try:
|
||||
result = await OpenaiAPI.open_api_chat_without_thinking(prompt, model=None)
|
||||
if result:
|
||||
candidate = result.strip().strip("“”\"'")
|
||||
# 只在模型返回的内容明显为单行短文本时替换,避免异常输出
|
||||
if candidate and len(candidate) <= 200 and "\n" not in candidate:
|
||||
rewritten_query = candidate
|
||||
except Exception as e:
|
||||
print(f"query 重写失败: {e}")
|
||||
|
||||
|
||||
title_options = [f"✨正在分析中,请稍等..."]
|
||||
start_event = {
|
||||
"type": "function_execution",
|
||||
"title": random.choice(title_options),
|
||||
"details": f"正在调取百科数据..."
|
||||
}
|
||||
for callback in get_all_callbacks():
|
||||
try:
|
||||
callback(start_event)
|
||||
except Exception:
|
||||
pass
|
||||
raw = await OpenaiAPI.open_api_chat_without_thinking(prompt, model=None, json_output=True)
|
||||
data = _parse_json_object(raw)
|
||||
if data:
|
||||
candidate = str(data.get("rewrite_query") or "").strip()
|
||||
if candidate:
|
||||
rewritten_query = candidate[:180]
|
||||
for q in data.get("search_queries") or []:
|
||||
if isinstance(q, str) and q.strip():
|
||||
query_variants.append(q.strip()[:180])
|
||||
except Exception as exc:
|
||||
print(f"query 重写失败: {exc}")
|
||||
|
||||
query_variants = _build_search_queries(rewritten_query, original_query, query_variants)
|
||||
_emit_event("✨正在分析中,请稍等...", "正在生成检索策略...")
|
||||
print("原始检索query:", original_query)
|
||||
print("重写后检索query:", last_user_query+rewritten_query)
|
||||
|
||||
return {"retrieval_query":last_user_query+rewritten_query}
|
||||
print("重写后检索query:", rewritten_query)
|
||||
print("检索query变体:", query_variants)
|
||||
return {"retrieval_query": rewritten_query, "retrieval_queries": query_variants}
|
||||
|
||||
|
||||
@track_function_calls
|
||||
async def search_rag_and_graph(state: QAState) -> Dict[str, Any]:
|
||||
"""
|
||||
进行文本知识检索和图谱知识检索
|
||||
"""
|
||||
kb_id = None
|
||||
if state.get("route_flag") == "rules":
|
||||
kb_id = '3'
|
||||
|
||||
# 优先使用重写后的检索query,其次使用 combined_query,最后回退到原始 extracted_text
|
||||
query = state.get("retrieval_query") or state.get("combined_query") or state.get("extracted_text", "")
|
||||
if "用户问题:" in query:
|
||||
query = query.split("用户问题:", 1)[1]
|
||||
|
||||
print("Graph 检索query", query)
|
||||
extracted_text = state.get("extracted_text", "")
|
||||
if "用户问题:" in extracted_text:
|
||||
extracted_text = extracted_text.split("用户问题:", 1)[1]
|
||||
|
||||
kb_id = "3" if state.get("route_flag") == "rules" else None
|
||||
query = (state.get("retrieval_query") or state.get("combined_query") or state.get("extracted_text") or "").strip()
|
||||
extracted_text = (state.get("extracted_text") or "").strip()
|
||||
query_variants = _build_search_queries(query, extracted_text, state.get("retrieval_queries") or [])
|
||||
try:
|
||||
# 异步并行调用RAG检索和图谱检索
|
||||
print(kb_id)
|
||||
print(33333333333333333333333333333333333333)
|
||||
|
||||
async def _do_rag_search():
|
||||
if kb_id:
|
||||
return await rag_search(
|
||||
query=extracted_text,
|
||||
kb_id=kb_id,
|
||||
top_k=2
|
||||
)
|
||||
else:
|
||||
return await rag_search(
|
||||
query=extracted_text,
|
||||
top_k=2
|
||||
)
|
||||
|
||||
async def _do_graph_search():
|
||||
return await graph_rag_search.ainvoke({
|
||||
"query": query,
|
||||
"top_k": 3
|
||||
})
|
||||
|
||||
rag_result, graph_results = await asyncio.gather(
|
||||
_do_rag_search(),
|
||||
_do_graph_search()
|
||||
rag_task = _search_rag_variants(query_variants[:3], kb_id=kb_id)
|
||||
graph_task = graph_rag_search.ainvoke({"query": query, "top_k": BAIKE_GRAPH_TOP_K})
|
||||
rag_results, graph_results = await asyncio.gather(rag_task, graph_task)
|
||||
evidence_items = _build_evidence_items(
|
||||
rag_results=rag_results,
|
||||
graph_results=graph_results if isinstance(graph_results, str) else "",
|
||||
query=query,
|
||||
)
|
||||
|
||||
# 处理 RAG 结果,提取成列表格式
|
||||
rag_search_result = []
|
||||
if rag_result.get("success", False):
|
||||
source_citation = rag_result.get("sourceCitation", {})
|
||||
for resource, items in source_citation.items():
|
||||
for item in items:
|
||||
rag_search_result.append({
|
||||
"text": item.get("text", ""),
|
||||
"filename": item.get("filename", ""),
|
||||
"resource": item.get("resource", ""),
|
||||
"score": item.get("score", 0.0)
|
||||
})
|
||||
|
||||
# 只保留前 4 条数据
|
||||
rag_search_result = rag_search_result[:2]
|
||||
evidence_items = await _rerank_evidence(query, evidence_items)
|
||||
|
||||
return {
|
||||
"rag_search_result": rag_search_result,
|
||||
"graph_search_result": graph_results if isinstance(graph_results, str) else ""
|
||||
"rag_search_result": rag_results,
|
||||
"graph_search_result": graph_results if isinstance(graph_results, str) else "",
|
||||
"evidence_items": evidence_items,
|
||||
}
|
||||
except Exception as e:
|
||||
except Exception as exc:
|
||||
return {
|
||||
"rag_search_result": [],
|
||||
"graph_search_result": "",
|
||||
"error_message": f"检索失败: {str(e)}"
|
||||
"evidence_items": [],
|
||||
"error_message": f"检索失败: {exc}",
|
||||
}
|
||||
|
||||
|
||||
|
||||
async def generate_suggested_replies_baike(answer: str, question: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
生成针对问答结果的建议回复问题
|
||||
使用大模型生成两个相关问题
|
||||
"""
|
||||
prompt = BAIKE_PROMPTS["generate_suggested_replies"].format(
|
||||
question=question,
|
||||
answer=answer[:500]
|
||||
)
|
||||
prompt = BAIKE_PROMPTS["generate_suggested_replies"].format(question=question, answer=answer[:500])
|
||||
try:
|
||||
result = await OpenaiAPI.open_api_chat_without_thinking(prompt, model=None, json_output=True)
|
||||
# 尝试解析JSON
|
||||
# 提取JSON部分
|
||||
json_match = re.search(r'\[.*\]', result, re.DOTALL)
|
||||
json_match = re.search(r"\[.*\]", result or "", re.DOTALL)
|
||||
if json_match:
|
||||
json_str = json_match.group(0)
|
||||
suggested_replies = json.loads(json_str)
|
||||
# 验证格式
|
||||
if isinstance(suggested_replies, list) and len(suggested_replies) >= 2:
|
||||
# 确保每个元素都有title和content
|
||||
formatted_replies = []
|
||||
for reply in suggested_replies[:2]:
|
||||
if isinstance(reply, dict) and "title" in reply and "content" in reply:
|
||||
formatted_replies.append({
|
||||
"title": str(reply["title"]),
|
||||
"content": str(reply["content"])
|
||||
})
|
||||
if len(formatted_replies) == 2:
|
||||
return formatted_replies
|
||||
|
||||
# 如果解析失败,返回默认问题
|
||||
return [
|
||||
{"title": "这个设备的具体工作原理是什么?", "content": "这个设备的具体工作原理是什么?"},
|
||||
{"title": "还有哪些相关的知识点?", "content": "还有哪些相关的知识点?"}
|
||||
]
|
||||
except Exception as e:
|
||||
# 如果生成失败,返回默认问题
|
||||
return [
|
||||
{"title": "这个设备的具体工作原理是什么?", "content": "这个设备的具体工作原理是什么?"},
|
||||
{"title": "还有哪些相关的知识点?", "content": "还有哪些相关的知识点?"}
|
||||
]
|
||||
replies = json.loads(json_match.group(0))
|
||||
formatted = [
|
||||
{"title": str(x["title"]), "content": str(x["content"])}
|
||||
for x in replies[:2]
|
||||
if isinstance(x, dict) and x.get("title") and x.get("content")
|
||||
]
|
||||
if len(formatted) == 2:
|
||||
return formatted
|
||||
except Exception:
|
||||
pass
|
||||
return [
|
||||
{"title": "查看相关依据", "content": "这个回答对应的资料依据有哪些?"},
|
||||
{"title": "继续展开说明", "content": "请把这个问题再展开说明一下。"},
|
||||
]
|
||||
|
||||
|
||||
@track_function_calls
|
||||
async def generate_answer(state: QAState) -> Dict[str, Any]:
|
||||
"""
|
||||
正在生成问答结果
|
||||
"""
|
||||
extracted_text = state.get("combined_query", "")
|
||||
rag_results = state.get("rag_search_result", [])
|
||||
graph_results = state.get("graph_search_result", "")
|
||||
history_messages = state.get("history_messages", []) or []
|
||||
question = state.get("combined_query") or state.get("extracted_text") or ""
|
||||
evidence_items = state.get("evidence_items") or []
|
||||
need_retrieval = state.get("need_retrieval", True)
|
||||
print("history_message(qa)", history_messages)
|
||||
print("是否使用检索结果:", need_retrieval)
|
||||
history_messages = state.get("history_messages", []) or []
|
||||
|
||||
try:
|
||||
# 1. 处理检索结果(如果有)
|
||||
rag_ctx_parts: List[str] = []
|
||||
file_name_list = []
|
||||
all_source_text = ""
|
||||
original_image_paths = []
|
||||
|
||||
if need_retrieval:
|
||||
# 收集所有 RAG 结果文本(只取前 4 条)
|
||||
for item in (rag_results or [])[:1]:
|
||||
print(item)
|
||||
print(111111111111111111111111111)
|
||||
if isinstance(item, dict):
|
||||
text = (item.get("text") or "").strip()
|
||||
file_name = (item.get("filename") or "").strip()
|
||||
if text:
|
||||
rag_ctx_parts.append(text)
|
||||
if file_name:
|
||||
file_name_list.append(file_name)
|
||||
|
||||
# 处理图谱结果
|
||||
print("123456", graph_results)
|
||||
if graph_results and "图谱检索完成,但未返回有效数据。" in graph_results:
|
||||
graph_results = ""
|
||||
print("654321", graph_results)
|
||||
|
||||
# 从所有结果中提取图片路径
|
||||
if graph_results:
|
||||
all_source_text = graph_results + "\n" + "\n".join(rag_ctx_parts)
|
||||
else:
|
||||
all_source_text = "\n".join(rag_ctx_parts)
|
||||
original_image_paths = extract_image_paths(all_source_text)
|
||||
print("提取到的原始图片路径:", original_image_paths)
|
||||
|
||||
# 发送事件通知(可选)
|
||||
title_options = [f"✨已综合 {len(rag_ctx_parts)} 条检索结果", f"✨本次检索策略为综合多源信息"]
|
||||
start_event = {
|
||||
"type": "function_execution",
|
||||
"title": random.choice(title_options),
|
||||
"details": f"综合了 {len(rag_ctx_parts)} 条检索结果"
|
||||
}
|
||||
for callback in get_all_callbacks():
|
||||
try:
|
||||
callback(start_event)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
query_desc = extracted_text if extracted_text else "当前未明确描述问题"
|
||||
|
||||
# 构建系统提示词
|
||||
if state.get("route_flag") == "rules":
|
||||
domain_desc = "舰船修理相关的法规、规范、行业标准及技术文件"
|
||||
else:
|
||||
domain_desc = "工业设备的结构原理、维护规程、故障诊断与维修操作"
|
||||
|
||||
# ========== 第一步:专注于生成高质量内容 ==========
|
||||
# 根据是否有检索结果构建不同的提示词
|
||||
if need_retrieval and all_source_text.strip():
|
||||
content_system_prompt = BAIKE_PROMPTS["generate_answer_with_retrieval_system"].format(domain_desc=domain_desc)
|
||||
content_user_content = BAIKE_PROMPTS["generate_answer_with_retrieval_user"].format(
|
||||
extracted_text=extracted_text,
|
||||
rag_count=len(rag_ctx_parts),
|
||||
all_source_text=all_source_text
|
||||
source_text = ""
|
||||
original_image_paths: List[str] = []
|
||||
if need_retrieval and evidence_items:
|
||||
source_text = _format_evidence_context(evidence_items)
|
||||
original_image_paths = extract_image_paths(source_text)
|
||||
source_counts = _count_sources(evidence_items)
|
||||
_emit_event(
|
||||
f"✨已融合 {len(evidence_items)} 条证据",
|
||||
f"RAG {source_counts.get('rag', 0)} 条,图谱 {source_counts.get('graph', 0)} 条",
|
||||
)
|
||||
else:
|
||||
content_system_prompt = BAIKE_PROMPTS["generate_answer_without_retrieval_system"].format(domain_desc=domain_desc)
|
||||
content_user_content = BAIKE_PROMPTS["generate_answer_without_retrieval_user"].format(extracted_text=extracted_text)
|
||||
|
||||
content_messages = []
|
||||
if history_messages:
|
||||
content_messages.extend(history_messages)
|
||||
content_messages.append({"role": "user", "content": content_user_content})
|
||||
|
||||
# 第一步:生成内容
|
||||
raw_content = await OpenaiAPI.open_api_chat_without_thinking(
|
||||
model=None,
|
||||
system_prompt=content_system_prompt,
|
||||
messages=content_messages,
|
||||
temperature=0.5
|
||||
domain_desc = (
|
||||
"舰船维修相关的法规、规范、行业标准及技术文件"
|
||||
if state.get("route_flag") == "rules"
|
||||
else "工业设备的结构原理、维护规程、故障诊断与维修操作"
|
||||
)
|
||||
|
||||
# 过滤掉异常标签(如果有)
|
||||
raw_content = re.sub(r'ynchroneg>.*?ost switching>', '', raw_content, flags=re.DOTALL)
|
||||
raw_content = raw_content.strip() if raw_content else "抱歉,无法生成回答。"
|
||||
if need_retrieval and source_text.strip():
|
||||
system_prompt = BAIKE_PROMPTS["generate_answer_with_retrieval_system"].format(domain_desc=domain_desc)
|
||||
user_prompt = BAIKE_PROMPTS["generate_answer_with_retrieval_user"].format(
|
||||
extracted_text=question,
|
||||
rag_count=len(evidence_items),
|
||||
all_source_text=source_text,
|
||||
)
|
||||
else:
|
||||
system_prompt = BAIKE_PROMPTS["generate_answer_without_retrieval_system"].format(domain_desc=domain_desc)
|
||||
user_prompt = BAIKE_PROMPTS["generate_answer_without_retrieval_user"].format(extracted_text=question)
|
||||
|
||||
title_options = ["🤖 诊断分析中", "🤖 故障预检中"]
|
||||
start_event = {
|
||||
"type": "function_execution",
|
||||
"title": random.choice(title_options),
|
||||
"details": raw_content[:30] + "..."
|
||||
}
|
||||
for callback in get_all_callbacks():
|
||||
messages = []
|
||||
if history_messages:
|
||||
messages.extend(history_messages)
|
||||
messages.append({"role": "user", "content": user_prompt})
|
||||
|
||||
_emit_event(random.choice(["🔍 证据整理中", "🧠 正在生成回答"]), "正在基于检索资料生成回答...")
|
||||
answer = await stream_generate_and_postprocess(
|
||||
system_prompt=system_prompt,
|
||||
messages=messages,
|
||||
original_image_paths=original_image_paths,
|
||||
temperature=0.2,
|
||||
fallback="抱歉,无法生成回答。",
|
||||
)
|
||||
suggested_replies = await generate_suggested_replies_baike(answer, question)
|
||||
return {"response": answer, "actions": [], "result_tag": "qa", "suggestedReplies": suggested_replies}
|
||||
except Exception as exc:
|
||||
return {"response": f"生成回答失败:{exc}", "actions": [], "suggestedReplies": []}
|
||||
|
||||
|
||||
async def _search_rag_variants(queries: List[str], kb_id: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
tasks = [rag_search(query=q, kb_id=kb_id, top_k=BAIKE_RAG_TOP_K) for q in queries if q.strip()]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True) if tasks else []
|
||||
items: List[Dict[str, Any]] = []
|
||||
for result in results:
|
||||
if isinstance(result, Exception) or not result.get("success"):
|
||||
continue
|
||||
for resource, rows in (result.get("sourceCitation") or {}).items():
|
||||
for row in rows:
|
||||
item = dict(row)
|
||||
item.setdefault("resource", resource)
|
||||
items.append(item)
|
||||
items = sorted(items, key=lambda x: float(x.get("score") or 0), reverse=True)
|
||||
return dedupe_rag_results(items, limit=BAIKE_RESULT_LIMIT)
|
||||
|
||||
|
||||
def _build_evidence_items(
|
||||
rag_results: List[Dict[str, Any]],
|
||||
graph_results: str,
|
||||
query: str,
|
||||
) -> List[Dict[str, Any]]:
|
||||
candidates: List[Dict[str, Any]] = []
|
||||
for item in rag_results or []:
|
||||
candidates.append({
|
||||
"source_type": "rag",
|
||||
"title": item.get("filename") or item.get("resource") or "RAG 检索结果",
|
||||
"filename": item.get("filename") or item.get("resource") or "",
|
||||
"text": item.get("text") or "",
|
||||
"score": float(item.get("score") or 0),
|
||||
"file_id": str(item.get("file_id") or ""),
|
||||
"slice_id": str(item.get("id") or item.get("slice_id") or ""),
|
||||
"metadata": item,
|
||||
})
|
||||
|
||||
graph_text = str(graph_results or "").strip()
|
||||
if graph_text and "图谱检索完成,但未返回有效数据。" not in graph_text and graph_text != "查询无结果":
|
||||
candidates.append({
|
||||
"source_type": "graph",
|
||||
"title": "图谱检索结果",
|
||||
"filename": "",
|
||||
"text": graph_text,
|
||||
"score": 0.45,
|
||||
"file_id": "",
|
||||
"slice_id": "",
|
||||
"metadata": {},
|
||||
})
|
||||
|
||||
deduped: Dict[str, Dict[str, Any]] = {}
|
||||
for item in candidates:
|
||||
text = _clean_text(item.get("text") or "")
|
||||
if not text:
|
||||
continue
|
||||
item["text"] = text[:5000]
|
||||
key = item.get("slice_id") or _content_key(text)
|
||||
local_score = _local_evidence_score(query, item)
|
||||
item["local_score"] = local_score
|
||||
existing = deduped.get(key)
|
||||
if not existing or local_score > float(existing.get("local_score") or 0):
|
||||
deduped[key] = item
|
||||
return sorted(deduped.values(), key=lambda x: float(x.get("local_score") or 0), reverse=True)
|
||||
|
||||
|
||||
async def _rerank_evidence(query: str, evidence_items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
if not query.strip() or not evidence_items:
|
||||
return evidence_items[:BAIKE_EVIDENCE_LIMIT]
|
||||
selected = evidence_items[:24]
|
||||
documents = [
|
||||
"\n".join([
|
||||
str(item.get("title") or ""),
|
||||
str(item.get("filename") or ""),
|
||||
str(item.get("text") or ""),
|
||||
])[:4000]
|
||||
for item in selected
|
||||
]
|
||||
rows = await OpenaiAPI.rerank(query=query, documents=documents, top_k=BAIKE_EVIDENCE_LIMIT)
|
||||
if not rows:
|
||||
return evidence_items[:BAIKE_EVIDENCE_LIMIT]
|
||||
|
||||
ranked: List[Dict[str, Any]] = []
|
||||
seen = set()
|
||||
for row in rows:
|
||||
idx = int(row.get("index") or 0)
|
||||
if idx < 0 or idx >= len(selected) or idx in seen:
|
||||
continue
|
||||
seen.add(idx)
|
||||
item = dict(selected[idx])
|
||||
item["rerank_score"] = float(row.get("relevance_score") or 0.0)
|
||||
ranked.append(item)
|
||||
for idx, item in enumerate(selected):
|
||||
if idx not in seen and len(ranked) < BAIKE_EVIDENCE_LIMIT:
|
||||
ranked.append(item)
|
||||
return ranked[:BAIKE_EVIDENCE_LIMIT]
|
||||
|
||||
|
||||
def _format_evidence_context(items: List[Dict[str, Any]]) -> str:
|
||||
parts = []
|
||||
label_map = {"rag": "RAG", "graph": "Graph"}
|
||||
for idx, item in enumerate(items[:BAIKE_EVIDENCE_LIMIT], start=1):
|
||||
source = label_map.get(item.get("source_type"), item.get("source_type") or "unknown")
|
||||
score = item.get("rerank_score", item.get("local_score", item.get("score", "")))
|
||||
meta_lines = [
|
||||
f"[Evidence {idx}] source={source} score={score}",
|
||||
f"Title: {item.get('title') or ''}",
|
||||
]
|
||||
if item.get("filename"):
|
||||
meta_lines.append(f"File: {item.get('filename')}")
|
||||
if item.get("file_id"):
|
||||
meta_lines.append(f"File ID: {item.get('file_id')}")
|
||||
if item.get("slice_id"):
|
||||
meta_lines.append(f"Slice ID: {item.get('slice_id')}")
|
||||
meta_lines.append("Content:")
|
||||
meta_lines.append(str(item.get("text") or ""))
|
||||
parts.append("\n".join(meta_lines))
|
||||
return "\n\n---\n\n".join(parts)
|
||||
|
||||
|
||||
def _local_evidence_score(query: str, item: Dict[str, Any]) -> float:
|
||||
base = {"rag": 1.0, "graph": 0.65}.get(item.get("source_type"), 0.8)
|
||||
raw = float(item.get("score") or 0)
|
||||
score = base + min(max(raw, 0.0), 1.0)
|
||||
text = " ".join([
|
||||
str(item.get("title") or ""),
|
||||
str(item.get("filename") or ""),
|
||||
str(item.get("text") or "")[:1500],
|
||||
]).lower()
|
||||
title = str(item.get("title") or "").lower()
|
||||
for term in _extract_search_terms(query):
|
||||
t = term.lower()
|
||||
if not t:
|
||||
continue
|
||||
if t in title:
|
||||
score += 0.7
|
||||
elif t in text:
|
||||
score += 0.25
|
||||
return score
|
||||
|
||||
|
||||
def _build_search_queries(main_query: str, original_query: str = "", extra_queries: Optional[List[str]] = None) -> List[str]:
|
||||
candidates: List[str] = []
|
||||
for text in [main_query, original_query, *(extra_queries or [])]:
|
||||
text = _strip_query_noise(text)
|
||||
if not text:
|
||||
continue
|
||||
candidates.append(text)
|
||||
regex_terms = _extract_search_terms(text)
|
||||
if regex_terms:
|
||||
candidates.append(" ".join(regex_terms[:4]))
|
||||
candidates.extend(regex_terms[:4])
|
||||
return _dedupe_strings(candidates)[:10]
|
||||
|
||||
|
||||
def _extract_search_terms(query: str) -> List[str]:
|
||||
stop = {"什么", "如何", "怎么", "哪些", "主要", "内容", "相关", "信息", "一下", "这个", "那个", "是否", "有没有"}
|
||||
terms = []
|
||||
for token in re.findall(r"[\u4e00-\u9fff]{2,12}|[A-Za-z0-9][A-Za-z0-9_\-]{1,30}", query or ""):
|
||||
token = token.strip("::,,。.;;、()()[]【】")
|
||||
if len(token) < 2 or token in stop or re.fullmatch(r"\d+", token):
|
||||
continue
|
||||
terms.append(token)
|
||||
return _dedupe_strings(terms)
|
||||
|
||||
|
||||
def _strip_query_noise(text: str) -> str:
|
||||
text = str(text or "").strip().strip('"“”')
|
||||
if "用户问题:" in text:
|
||||
text = text.split("用户问题:", 1)[1].strip()
|
||||
return re.sub(r"\s+", " ", text)
|
||||
|
||||
|
||||
def _parse_json_object(raw: str) -> Dict[str, Any]:
|
||||
if not raw:
|
||||
return {}
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except Exception:
|
||||
match = re.search(r"\{.*\}", raw, re.DOTALL)
|
||||
if match:
|
||||
try:
|
||||
callback(start_event)
|
||||
return json.loads(match.group(0))
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
return {}
|
||||
|
||||
|
||||
# ========== 第二步:专注于规范格式 ==========
|
||||
# 构建第二步的提示词:只关注格式规范,不修改内容
|
||||
format_system_prompt = COMMON_PROMPTS["format_system"]
|
||||
|
||||
format_user_content = COMMON_PROMPTS["format_user_with_image_examples"].format(raw_content=raw_content)
|
||||
def _json_dumps(value: Any) -> str:
|
||||
try:
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
except Exception:
|
||||
return str(value)
|
||||
|
||||
# 第二步:规范格式(流式输出)
|
||||
stream_handler = get_current_stream_handler()
|
||||
answer = ""
|
||||
accumulated_content = "" # 用于逐步发送给前端的内容
|
||||
|
||||
# 使用 async for 遍历流式生成器
|
||||
async for chunk in OpenaiAPI.open_api_chat_stream(
|
||||
model=None,
|
||||
system_prompt=format_system_prompt,
|
||||
messages=[{"role": "user", "content": format_user_content}],
|
||||
temperature=0.1
|
||||
):
|
||||
answer += chunk
|
||||
accumulated_content += chunk
|
||||
# 每收到新的 chunk 就发送一次更新
|
||||
if stream_handler:
|
||||
stream_handler.send_stream_content(accumulated_content)
|
||||
|
||||
answer = answer.strip() if answer else raw_content
|
||||
def _content_key(text: str) -> str:
|
||||
return re.sub(r"\s+", "", text or "")[:220]
|
||||
|
||||
# LaTeX 公式空格规范化
|
||||
def normalize_latex_spacing(text: str) -> str:
|
||||
placeholder = "\x00DOUBLE_DOLLAR\x00"
|
||||
text = text.replace("$$", placeholder)
|
||||
text = re.sub(r'(?<!\s)\$', r' $', text)
|
||||
text = re.sub(r'\$(?!\s)', r'$ ', text)
|
||||
text = text.replace(placeholder, "$$")
|
||||
text = re.sub(r'(?<!\s)\$\$', r' $$', text)
|
||||
text = re.sub(r'\$\$(?!\s)', r'$$ ', text)
|
||||
return text
|
||||
|
||||
answer = normalize_latex_spacing(answer)
|
||||
def _clean_text(text: str) -> str:
|
||||
return re.sub(r"\n{3,}", "\n\n", str(text or "")).strip()
|
||||
|
||||
# 使用增强的图片路径匹配与规范化(仅在有检索结果时)
|
||||
if need_retrieval and original_image_paths:
|
||||
answer = normalize_markdown_images(answer, original_paths=original_image_paths)
|
||||
print("llm output", answer)
|
||||
|
||||
# 生成建议回复问题
|
||||
suggested_replies = await generate_suggested_replies_baike(answer, extracted_text)
|
||||
def _dedupe_strings(values: List[str]) -> List[str]:
|
||||
out = []
|
||||
seen = set()
|
||||
for value in values:
|
||||
value = str(value or "").strip()
|
||||
if not value or value in seen:
|
||||
continue
|
||||
seen.add(value)
|
||||
out.append(value)
|
||||
return out
|
||||
|
||||
|
||||
def _count_sources(items: List[Dict[str, Any]]) -> Dict[str, int]:
|
||||
counts: Dict[str, int] = {}
|
||||
for item in items:
|
||||
key = str(item.get("source_type") or "unknown")
|
||||
counts[key] = counts.get(key, 0) + 1
|
||||
return counts
|
||||
|
||||
|
||||
def _emit_event(title: str, details: str) -> None:
|
||||
event = {"type": "function_execution", "title": title, "details": details}
|
||||
for callback in get_all_callbacks():
|
||||
try:
|
||||
callback(event)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
return {
|
||||
"response": answer,
|
||||
"actions": [],
|
||||
"result_tag": "qa",
|
||||
"suggestedReplies": suggested_replies
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"response": f"生成回答失败:{str(e)}",
|
||||
"actions": [],
|
||||
"suggestedReplies": []
|
||||
}
|
||||
def route_after_judge(state: QAState) -> str:
|
||||
"""
|
||||
判断后的路由:根据need_retrieval决定下一步
|
||||
"""
|
||||
need_retrieval = state.get("need_retrieval", True)
|
||||
if need_retrieval:
|
||||
return "rewrite_query_with_history"
|
||||
else:
|
||||
return "generate_answer"
|
||||
return "rewrite_query_with_history" if state.get("need_retrieval", True) else "generate_answer"
|
||||
|
||||
|
||||
# =============== 构建工作流 ===============
|
||||
def create_baike_workflow():
|
||||
"""创建普通问答工作流"""
|
||||
workflow = StateGraph(QAState)
|
||||
|
||||
# 添加节点
|
||||
workflow.add_node("get_baike_history_context", get_baike_history_context)
|
||||
workflow.add_node("judge_need_retrieval", judge_need_retrieval)
|
||||
workflow.add_node("rewrite_query_with_history", rewrite_query_with_history)
|
||||
workflow.add_node("search_rag_and_graph", search_rag_and_graph)
|
||||
workflow.add_node("generate_answer", generate_answer)
|
||||
|
||||
# 设置边
|
||||
workflow.add_edge(START, "get_baike_history_context")
|
||||
workflow.add_edge("get_baike_history_context", "judge_need_retrieval")
|
||||
workflow.add_conditional_edges(
|
||||
"judge_need_retrieval",
|
||||
route_after_judge,
|
||||
{
|
||||
"rewrite_query_with_history": "rewrite_query_with_history",
|
||||
"generate_answer": "generate_answer"
|
||||
}
|
||||
{"rewrite_query_with_history": "rewrite_query_with_history", "generate_answer": "generate_answer"},
|
||||
)
|
||||
workflow.add_edge("rewrite_query_with_history", "search_rag_and_graph")
|
||||
workflow.add_edge("search_rag_and_graph", "generate_answer")
|
||||
workflow.add_edge("generate_answer", END)
|
||||
|
||||
# 编译工作流
|
||||
return workflow.compile()
|
||||
|
||||
|
||||
# =============== 工作流执行函数 ===============
|
||||
async def run_baike_workflow(
|
||||
extracted_text: str,
|
||||
combined_query: str,
|
||||
history_message: str = "",
|
||||
route_flag: str = ""
|
||||
route_flag: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
执行普通问答工作流(统一接口)
|
||||
|
||||
Args:
|
||||
extracted_text: 文本描述(统一的输入参数)
|
||||
history_message: 历史消息(目前只接收,不做处理)
|
||||
|
||||
Returns:
|
||||
工作流执行结果
|
||||
"""
|
||||
# 创建并编译工作流
|
||||
app = create_baike_workflow()
|
||||
|
||||
# 初始化状态(统一接口,只使用extracted_text和history_message)
|
||||
initial_state = {
|
||||
"extracted_text": extracted_text,
|
||||
"combined_query": combined_query,
|
||||
"retrieval_query": "",
|
||||
"retrieval_queries": [],
|
||||
"route_flag": route_flag,
|
||||
"history_message": history_message,
|
||||
"history_messages": [],
|
||||
"need_retrieval": None,
|
||||
"rag_search_result": None,
|
||||
"graph_search_result": None,
|
||||
"evidence_items": None,
|
||||
"response": "",
|
||||
"actions": [],
|
||||
"suggestedReplies": [],
|
||||
"error_message": ""
|
||||
"error_message": "",
|
||||
}
|
||||
|
||||
|
||||
try:
|
||||
# 执行工作流(使用异步方式)
|
||||
final_state = await app.ainvoke(initial_state)
|
||||
|
||||
# 检查是否有错误
|
||||
if final_state.get("error_message"):
|
||||
return {
|
||||
"response": f"普通问答工作流执行失败: {final_state['error_message']}",
|
||||
"actions": [],
|
||||
"result_tag": "qa",
|
||||
"suggestedReplies": []
|
||||
"suggestedReplies": [],
|
||||
}
|
||||
|
||||
# 统一输出格式:完全透传 generate_answer 的结果
|
||||
return {
|
||||
"response": final_state.get("response", ""),
|
||||
"actions": final_state.get("actions", []),
|
||||
"result_tag": "qa",
|
||||
"suggestedReplies": final_state.get("suggestedReplies", [])
|
||||
"suggestedReplies": final_state.get("suggestedReplies", []),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
except Exception as exc:
|
||||
return {
|
||||
"response": f"普通问答工作流执行失败: {str(e)}",
|
||||
"response": f"普通问答工作流执行失败: {exc}",
|
||||
"actions": [],
|
||||
"result_tag": "qa",
|
||||
"suggestedReplies": []
|
||||
"suggestedReplies": [],
|
||||
}
|
||||
|
||||
|
||||
|
||||
# =============== 测试 ===============
|
||||
if __name__ == "__main__":
|
||||
import asyncio
|
||||
|
||||
# 测试用例
|
||||
test_cases = [
|
||||
"船舰主发动机的安全警告是什么",
|
||||
]
|
||||
|
||||
async def test():
|
||||
for i, test_text in enumerate(test_cases, 1):
|
||||
print(f"\n{'='*60}")
|
||||
print(f"测试用例 {i}")
|
||||
print(f"{'='*60}")
|
||||
|
||||
# ✅ 修复:传入 combined_query 参数
|
||||
result = await run_baike_workflow(
|
||||
extracted_text=test_text,
|
||||
combined_query=test_text
|
||||
)
|
||||
|
||||
print(f"\n执行结果:")
|
||||
print(f"成功:{result.get('success', False)}")
|
||||
print(f"\n响应内容:")
|
||||
print(result.get("response", "无响应"))
|
||||
|
||||
asyncio.run(test())
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -13,9 +13,13 @@ from utils.image_utils import extract_image_paths, normalize_markdown_images, ge
|
||||
from workflows.workflow_baike import run_baike_workflow
|
||||
from workflows.workflow_utils import (
|
||||
safe_json_extract, parse_history, normalize_latex_spacing,
|
||||
remove_duplicate_content, convert_rag_result, calculate_info_completeness,
|
||||
remove_duplicate_content, convert_rag_result, dedupe_rag_results, calculate_info_completeness,
|
||||
emit_callback_event, build_history_str, build_detail_info,
|
||||
append_atlas_section, stream_format_and_postprocess,
|
||||
append_atlas_section, stream_generate_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.intent_matcher import is_confirmation_intent
|
||||
@ -28,6 +32,8 @@ import re
|
||||
import random
|
||||
import os
|
||||
import time
|
||||
import zipfile
|
||||
from urllib.parse import quote
|
||||
|
||||
class FaultDiagnosisState(TypedDict):
|
||||
"""故障诊断工作流状态"""
|
||||
@ -37,6 +43,7 @@ class FaultDiagnosisState(TypedDict):
|
||||
|
||||
ship_number: str
|
||||
device_name: str
|
||||
system_name: str
|
||||
fault: str
|
||||
additional_info: str
|
||||
fault_code: str
|
||||
@ -139,6 +146,18 @@ def _preprocess_scheme_for_word(content: str) -> str:
|
||||
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]]:
|
||||
"""
|
||||
从方案内容直接生成报告并返回下载按钮
|
||||
@ -195,13 +214,15 @@ def _generate_report_from_scheme(scheme_content: str, ship_number: str = "", dev
|
||||
|
||||
# 构建下载按钮
|
||||
actions = []
|
||||
if word_result and os.path.exists(word_result):
|
||||
base_url = SERVER_CONFIG.get("report", "")
|
||||
download_url = f"{base_url}/api/download/{file_name}"
|
||||
if word_result and _is_valid_docx(word_result):
|
||||
base_url = SERVER_CONFIG.get("report") or SERVER_CONFIG.get("base_url", "")
|
||||
download_url = f"{base_url}/api/download/{quote(file_name)}"
|
||||
|
||||
actions.append({"type": "download","label": f"{report_title}.docx","url": download_url,"fileName": file_name,"content": report_content})
|
||||
actions.append({"type": "download","label": f"{report_title}.docx","url": download_url,"fileName": file_name})
|
||||
|
||||
print(f"[_generate_report_from_scheme] 报告生成成功: {output_path}")
|
||||
else:
|
||||
print(f"[_generate_report_from_scheme] Word 文件生成后校验失败: {output_path}")
|
||||
|
||||
return actions
|
||||
|
||||
@ -212,6 +233,12 @@ def _generate_report_from_scheme(scheme_content: str, ship_number: str = "", dev
|
||||
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}
|
||||
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:
|
||||
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}
|
||||
@ -225,6 +252,7 @@ async def classify_intent(state: FaultDiagnosisState) -> Dict[str, Any]:
|
||||
extracted_info = state.get("extracted_info", {}) or {}
|
||||
ship_number = state.get("ship_number", "") or ""
|
||||
device_name = state.get("device_name", "") or ""
|
||||
system_name = state.get("system_name", "") or ""
|
||||
fault = state.get("fault", "") or ""
|
||||
|
||||
# 如果已经提取过维修信息(有设备名或故障现象),说明已在维修流程中,继续维修流程
|
||||
@ -273,7 +301,7 @@ async def extract_info(state: FaultDiagnosisState) -> Dict[str, Any]:
|
||||
combined_query = state.get("combined_query", "") or ""
|
||||
history_messages = state.get("history_messages", []) or []
|
||||
|
||||
history_str = build_history_str(history_messages, recent_count=4)
|
||||
history_str = build_history_str(history_messages, recent_count=HISTORY_RECENT_COUNT)
|
||||
|
||||
existing_info = []
|
||||
existing_ship_number = state.get("ship_number", "") or ""
|
||||
@ -350,6 +378,7 @@ async def agent_think(state: FaultDiagnosisState) -> Dict[str, Any]:
|
||||
combined_query = state.get("combined_query", "") or ""
|
||||
ship_number = state.get("ship_number", "") or ""
|
||||
device_name = state.get("device_name", "") or ""
|
||||
system_name = state.get("system_name", "") or ""
|
||||
fault = state.get("fault", "") or ""
|
||||
fault_code = state.get("fault_code", "") or ""
|
||||
fault_time = state.get("fault_time", "") or ""
|
||||
@ -390,7 +419,7 @@ async def agent_think(state: FaultDiagnosisState) -> Dict[str, Any]:
|
||||
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",}
|
||||
|
||||
history_str = build_history_str(history_messages, recent_count=5, assistant_truncate=300)
|
||||
history_str = build_history_str(history_messages, recent_count=HISTORY_RECENT_COUNT, assistant_truncate=HISTORY_ASSISTANT_TRUNCATE)
|
||||
|
||||
intent_system_prompt = FAULT_DIAGNOSIS_PROMPTS["agent_think_intent_system"]
|
||||
|
||||
@ -515,6 +544,7 @@ async def ask_user(state: FaultDiagnosisState) -> Command:
|
||||
missing_info = state.get("missing_info", [])
|
||||
ship_number = state.get("ship_number", "") or ""
|
||||
device_name = state.get("device_name", "") or ""
|
||||
system_name = state.get("system_name", "") or ""
|
||||
fault = state.get("fault", "") or ""
|
||||
fault_code = state.get("fault_code", "") or ""
|
||||
fault_time = state.get("fault_time", "") or ""
|
||||
@ -632,6 +662,7 @@ async def confirm_info(state: FaultDiagnosisState) -> Command:
|
||||
"""
|
||||
ship_number = state.get("ship_number", "") or ""
|
||||
device_name = state.get("device_name", "") or ""
|
||||
system_name = state.get("system_name", "") or ""
|
||||
fault = state.get("fault", "") or ""
|
||||
fault_code = state.get("fault_code", "") or ""
|
||||
fault_time = state.get("fault_time", "") or ""
|
||||
@ -709,6 +740,7 @@ async def call_tools(state: FaultDiagnosisState) -> Dict[str, Any]:
|
||||
tools_to_call = state.get("tools_to_call", [])
|
||||
ship_number = state.get("ship_number", "") or ""
|
||||
device_name = state.get("device_name", "") or ""
|
||||
system_name = state.get("system_name", "") or ""
|
||||
fault = state.get("fault", "") or ""
|
||||
additional_info = state.get("additional_info", "") or ""
|
||||
|
||||
@ -719,67 +751,105 @@ async def call_tools(state: FaultDiagnosisState) -> Dict[str, Any]:
|
||||
matched_kb_name = ""
|
||||
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():
|
||||
from utils.ship_number_search import search_with_ship_number_strategy
|
||||
|
||||
base_query = f"{device_name}中{fault}该怎么修复"
|
||||
results, kb_name, kb_id, mapped_num = await search_with_ship_number_strategy(
|
||||
base_query=base_query, ship_number=ship_number, top_k=5, search_type="fault"
|
||||
results, kb_name, kb_id, mapped_number = await search_with_ship_number_strategy(
|
||||
base_query=base_query,
|
||||
ship_number=ship_number,
|
||||
top_k=RAG_TOP_K,
|
||||
search_type="fault",
|
||||
)
|
||||
if not kb_name:
|
||||
kb_name = "通用知识库"
|
||||
print(f"RAG 检索来源: {kb_name}")
|
||||
return results, kb_name, kb_id, mapped_num
|
||||
return results, kb_name, kb_id, mapped_number
|
||||
|
||||
async def _do_graph_search():
|
||||
if not (device_name and fault):
|
||||
return ""
|
||||
try:
|
||||
graph_query = f"{device_name}中{fault}该怎么修复"
|
||||
results = await graph_rag_search.ainvoke({"query": graph_query})
|
||||
results = await graph_rag_search.ainvoke({"query": graph_query, "top_k": GRAPH_TOP_K})
|
||||
if len(results) < 60:
|
||||
graph_query = f"{fault}该怎么修复"
|
||||
results = await graph_rag_search.ainvoke({"query": graph_query})
|
||||
results = await graph_rag_search.ainvoke({"query": graph_query, "top_k": GRAPH_TOP_K})
|
||||
print(f"图谱检索完成,结果长度: {len(results) if results else 0}")
|
||||
return results
|
||||
except Exception as e:
|
||||
print(f"图谱检索失败: {str(e)}")
|
||||
return ""
|
||||
|
||||
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
|
||||
|
||||
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()
|
||||
elif need_graph:
|
||||
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:
|
||||
async def _do_atlas_search():
|
||||
if not device_name:
|
||||
return {}
|
||||
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())}")
|
||||
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:
|
||||
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:
|
||||
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()
|
||||
|
||||
if device_name:
|
||||
atlas_results = await _do_atlas_search()
|
||||
|
||||
has_rag = bool(rag_results and len(rag_results) > 0)
|
||||
has_graph = bool(graph_results and len(graph_results) > 100)
|
||||
|
||||
if not has_rag and not has_graph:
|
||||
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,"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,"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,"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",}
|
||||
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",}
|
||||
|
||||
async def model_confirm(state: FaultDiagnosisState) -> Command:
|
||||
"""
|
||||
@ -830,6 +900,7 @@ async def generate_response(state: FaultDiagnosisState) -> Dict[str, Any]:
|
||||
history_messages = state.get("history_messages", []) or []
|
||||
ship_number = state.get("ship_number", "") or ""
|
||||
device_name = state.get("device_name", "") or ""
|
||||
system_name = state.get("system_name", "") or ""
|
||||
fault = state.get("fault", "") or ""
|
||||
additional_info = state.get("additional_info", "") or ""
|
||||
fault_code = state.get("fault_code", "") or ""
|
||||
@ -843,7 +914,7 @@ async def generate_response(state: FaultDiagnosisState) -> Dict[str, Any]:
|
||||
matched_kb_name = state.get("matched_kb_name", "")
|
||||
|
||||
print("rag_results", rag_results)
|
||||
history_str = build_history_str(history_messages, recent_count=5, assistant_truncate=500)
|
||||
history_str = build_history_str(history_messages, recent_count=HISTORY_RECENT_COUNT, assistant_truncate=HISTORY_ASSISTANT_TRUNCATE)
|
||||
|
||||
has_rag = rag_results and len(rag_results) > 0
|
||||
has_graph = graph_results and len(graph_results) > 100
|
||||
@ -868,28 +939,27 @@ async def generate_response(state: FaultDiagnosisState) -> Dict[str, Any]:
|
||||
|
||||
try:
|
||||
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",}
|
||||
except Exception as e:
|
||||
return {"response": f"抱歉,处理过程中出现错误: {str(e)}","suggestedReplies": [],"current_node": "end",}
|
||||
|
||||
rag_ctx_parts: List[str] = []
|
||||
if isinstance(rag_results, str) and rag_results.strip():
|
||||
rag_ctx_parts = [rag_results.strip()]
|
||||
elif isinstance(rag_results, list):
|
||||
for item in rag_results[:1]:
|
||||
if isinstance(item, dict):
|
||||
text = (item.get("text") or "").strip()
|
||||
if text:
|
||||
rag_ctx_parts.append(text)
|
||||
elif isinstance(item, str) and item.strip():
|
||||
rag_ctx_parts.append(item.strip())
|
||||
|
||||
if len(graph_results) <= 50:
|
||||
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_ctx_parts: List[str] = []
|
||||
if not graph_results:
|
||||
if isinstance(rag_results, str) and rag_results.strip():
|
||||
rag_ctx_parts = [rag_results.strip()]
|
||||
elif isinstance(rag_results, list):
|
||||
deduped_rag_results = dedupe_rag_results(rag_results, limit=4)
|
||||
for item in deduped_rag_results:
|
||||
if isinstance(item, dict):
|
||||
text = (item.get("text") or "").strip()
|
||||
if text:
|
||||
rag_ctx_parts.append(text)
|
||||
elif isinstance(item, str) and item.strip():
|
||||
rag_ctx_parts.append(item.strip())
|
||||
|
||||
rag_answer = ""
|
||||
rag_type = "none"
|
||||
@ -898,9 +968,12 @@ async def generate_response(state: FaultDiagnosisState) -> Dict[str, Any]:
|
||||
rag_answer = graph_results
|
||||
rag_type = "graph"
|
||||
elif rag_ctx_parts:
|
||||
rag_answer = "\n\n".join(rag_ctx_parts[:1])
|
||||
rag_answer = "\n\n".join(rag_ctx_parts)
|
||||
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":
|
||||
system_prompt = COMMON_PROMPTS["generate_response_no_rag_simple_system"].format(biz_label="故障诊断")
|
||||
prompt = FAULT_DIAGNOSIS_PROMPTS["generate_response_no_rag_simple_user"].format(
|
||||
@ -911,6 +984,7 @@ async def generate_response(state: FaultDiagnosisState) -> Dict[str, Any]:
|
||||
|
||||
try:
|
||||
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",}
|
||||
except Exception as e:
|
||||
return {"response": f"抱歉,处理过程中出现错误: {str(e)}","suggestedReplies": [],"current_node": "end",}
|
||||
@ -920,6 +994,8 @@ async def generate_response(state: FaultDiagnosisState) -> Dict[str, Any]:
|
||||
emit_callback_event([f"✨当前使用的检索类型为{rag_type}", f"✨本次检索策略为{rag_type}"], f"内容为{rag_answer[:50]}...")
|
||||
|
||||
detail_info = ""
|
||||
if system_name:
|
||||
detail_info += f"- 所属系统:{system_name}\n"
|
||||
if fault_code:
|
||||
detail_info += f"- 故障码/报警代码:{fault_code}\n"
|
||||
if fault_time:
|
||||
@ -979,16 +1055,17 @@ async def generate_response(state: FaultDiagnosisState) -> Dict[str, Any]:
|
||||
)
|
||||
|
||||
try:
|
||||
# ========== 第一步:专注于生成高质量内容 ==========
|
||||
content_system_prompt = COMMON_PROMPTS["generate_response_content_system"].format(biz_label="维修")
|
||||
|
||||
raw_content = await OpenaiAPI.open_api_chat_without_thinking(model=None,system_prompt=content_system_prompt,messages=[{"role": "user", "content": prompt}],)
|
||||
emit_callback_event(["🤖 诊断分析中", "🤖 故障预检中"], "正在生成维修方案...")
|
||||
|
||||
raw_content = raw_content.strip() if raw_content else "抱歉,无法生成回答。"
|
||||
|
||||
emit_callback_event(["🤖 诊断分析中", "🤖 故障预检中"], raw_content[:30] + "...")
|
||||
|
||||
answer = await stream_format_and_postprocess(raw_content, original_image_paths, temperature=0.3)
|
||||
answer = await stream_generate_and_postprocess(
|
||||
system_prompt=content_system_prompt,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
original_image_paths=original_image_paths,
|
||||
temperature=0.3,
|
||||
fallback="抱歉,无法生成回答。"
|
||||
)
|
||||
|
||||
scheme_source = f"本方案依据{rag_type}知识库生成。"
|
||||
answer = f"{scheme_source}\n\n{answer}"
|
||||
@ -1042,6 +1119,7 @@ async def deep_rag_search(state: FaultDiagnosisState) -> Dict[str, Any]:
|
||||
"""
|
||||
ship_number = state.get("ship_number", "") or ""
|
||||
device_name = state.get("device_name", "") or ""
|
||||
system_name = state.get("system_name", "") or ""
|
||||
fault = state.get("fault", "") or ""
|
||||
fault_code = state.get("fault_code", "") or ""
|
||||
additional_info = state.get("additional_info", "") or ""
|
||||
@ -1052,6 +1130,27 @@ async def deep_rag_search(state: FaultDiagnosisState) -> Dict[str, Any]:
|
||||
combined_query = state.get("combined_query", "") or ""
|
||||
history_messages = state.get("history_messages", []) 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_analysis_points = []
|
||||
@ -1061,7 +1160,7 @@ async def deep_rag_search(state: FaultDiagnosisState) -> Dict[str, Any]:
|
||||
# ========== 第一步:分析之前方案和用户问题,选择深度分析点 ==========
|
||||
if last_generated_scheme or combined_query:
|
||||
try:
|
||||
history_str = build_history_str(history_messages, recent_count=5, assistant_truncate=300)
|
||||
history_str = build_history_str(history_messages, recent_count=HISTORY_RECENT_COUNT, assistant_truncate=HISTORY_ASSISTANT_TRUNCATE)
|
||||
|
||||
detail_info = ""
|
||||
if fault_code:
|
||||
@ -1110,9 +1209,12 @@ async def deep_rag_search(state: FaultDiagnosisState) -> Dict[str, Any]:
|
||||
if fault and fault not in search_query:
|
||||
search_query = f"{search_query} {fault}"
|
||||
|
||||
rag_result = await rag_search(search_query, top_k=3)
|
||||
rag_result = await rag_search(search_query, top_k=DEEP_RAG_TOP_K)
|
||||
if rag_result.get("success", False):
|
||||
deep_results[f"analysis_point_{idx}"] = convert_rag_result(rag_result)
|
||||
deep_results[f"analysis_point_{idx}"] = dedupe_rag_results(
|
||||
convert_rag_result(rag_result),
|
||||
limit=DEEP_RAG_TOP_K,
|
||||
)
|
||||
else:
|
||||
deep_results[f"analysis_point_{idx}"] = []
|
||||
except Exception as e:
|
||||
@ -1127,14 +1229,14 @@ async def deep_rag_search(state: FaultDiagnosisState) -> Dict[str, Any]:
|
||||
|
||||
if entity_names:
|
||||
print(f"[深度RAG] 图册检索实体: {entity_names}")
|
||||
atlas_result = await atlas_retrieval(node_names=entity_names, top_k=10)
|
||||
atlas_result = await atlas_retrieval(node_names=entity_names, top_k=ATLAS_TOP_K)
|
||||
if atlas_result.get("success", False):
|
||||
atlas_results = atlas_result.get("data", {})
|
||||
print(f"[深度RAG] 图册检索结果: {list(atlas_results.keys())}")
|
||||
except Exception as e:
|
||||
print(f"[深度RAG] 图册检索失败: {str(e)}")
|
||||
|
||||
return {"deep_rag_results": deep_results,"deep_analysis_points": deep_analysis_points,"atlas_results": atlas_results,"current_node": "generate_deep_response",}
|
||||
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",}
|
||||
|
||||
async def generate_deep_response(state: FaultDiagnosisState) -> Dict[str, Any]:
|
||||
"""
|
||||
@ -1144,6 +1246,7 @@ async def generate_deep_response(state: FaultDiagnosisState) -> Dict[str, Any]:
|
||||
history_messages = state.get("history_messages", []) or []
|
||||
ship_number = state.get("ship_number", "") or ""
|
||||
device_name = state.get("device_name", "") or ""
|
||||
system_name = state.get("system_name", "") or ""
|
||||
fault = state.get("fault", "") or ""
|
||||
fault_code = state.get("fault_code", "") or ""
|
||||
fault_time = state.get("fault_time", "") or ""
|
||||
@ -1176,20 +1279,22 @@ async def generate_deep_response(state: FaultDiagnosisState) -> Dict[str, Any]:
|
||||
original_rag_text = ""
|
||||
if original_rag_results:
|
||||
original_rag_text = "\n【原始检索资料】\n"
|
||||
for i, item in enumerate(original_rag_results[:3], 1):
|
||||
for i, item in enumerate(dedupe_rag_results(original_rag_results, limit=3), 1):
|
||||
if isinstance(item, dict):
|
||||
text = item.get("text", "")
|
||||
if text:
|
||||
original_rag_text += f"[{i}] {text}\n"
|
||||
|
||||
# 整理图册资料
|
||||
atlas_text = format_atlas_results(atlas_results)
|
||||
|
||||
# 提取所有资料中的图片路径
|
||||
all_source_text = analysis_points_text + original_rag_text + atlas_text + graph_results
|
||||
original_image_paths = extract_image_paths(all_source_text)
|
||||
if graph_results and len(graph_results) > 50:
|
||||
original_image_paths = extract_image_paths(graph_results)
|
||||
print("提取到的deep graph原始图片路径:", original_image_paths)
|
||||
else:
|
||||
original_image_paths = extract_image_paths(analysis_points_text + original_rag_text)
|
||||
print("提取到的deep rag原始图片路径:", original_image_paths)
|
||||
|
||||
detail_info = ""
|
||||
if system_name:
|
||||
detail_info += f"- 所属系统:{system_name}\n"
|
||||
if fault_code:
|
||||
detail_info += f"- 故障码/报警代码:{fault_code}\n"
|
||||
if fault_time:
|
||||
@ -1224,16 +1329,17 @@ async def generate_deep_response(state: FaultDiagnosisState) -> Dict[str, Any]:
|
||||
)
|
||||
|
||||
try:
|
||||
# ========== 第一步:专注于生成高质量内容 ==========
|
||||
content_system_prompt = COMMON_PROMPTS["generate_deep_response_content_system"].format(biz_label="维修")
|
||||
|
||||
raw_content = await OpenaiAPI.open_api_chat_without_thinking(model=None,system_prompt=content_system_prompt,messages=[{"role": "user", "content": prompt}],)
|
||||
emit_callback_event(["🤖 诊断分析中", "🤖 故障预检中"], "正在生成深度分析...")
|
||||
|
||||
raw_content = raw_content.strip() if raw_content else "抱歉,无法生成深度分析。"
|
||||
|
||||
emit_callback_event(["🤖 诊断分析中", "🤖 故障预检中"], raw_content[:30] + "...")
|
||||
|
||||
answer = await stream_format_and_postprocess(raw_content, original_image_paths, temperature=0.1)
|
||||
answer = await stream_generate_and_postprocess(
|
||||
system_prompt=content_system_prompt,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
original_image_paths=original_image_paths,
|
||||
temperature=0.1,
|
||||
fallback="抱歉,无法生成深度分析。"
|
||||
)
|
||||
|
||||
answer = append_atlas_section(answer, atlas_results)
|
||||
|
||||
@ -1257,6 +1363,7 @@ async def regenerate_scheme_from_feedback(state: FaultDiagnosisState) -> Dict[st
|
||||
"""
|
||||
ship_number = state.get("ship_number", "") or ""
|
||||
device_name = state.get("device_name", "") or ""
|
||||
system_name = state.get("system_name", "") or ""
|
||||
fault = state.get("fault", "") or ""
|
||||
additional_info = state.get("additional_info", "") or ""
|
||||
fault_code = state.get("fault_code", "") or ""
|
||||
@ -1275,6 +1382,8 @@ 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"],}
|
||||
|
||||
detail_info = ""
|
||||
if system_name:
|
||||
detail_info += f"- 所属系统:{system_name}\n"
|
||||
if fault_code:
|
||||
detail_info += f"- 故障码/报警代码:{fault_code}\n"
|
||||
if fault_time:
|
||||
@ -1307,18 +1416,16 @@ async def regenerate_scheme_from_feedback(state: FaultDiagnosisState) -> Dict[st
|
||||
)
|
||||
|
||||
try:
|
||||
# ========== 第一步:专注于生成高质量内容 ==========
|
||||
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)
|
||||
answer = await stream_format_and_postprocess(raw_content, original_image_paths, temperature=0.3, content_label="维修方案")
|
||||
answer = await stream_generate_and_postprocess(
|
||||
system_prompt=content_system_prompt,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
original_image_paths=original_image_paths,
|
||||
temperature=0.3,
|
||||
fallback="抱歉,无法根据反馈修改方案。"
|
||||
)
|
||||
|
||||
suggested_replies = [{"title": "方案有误", "content": "方案还是有问题:"},]
|
||||
|
||||
@ -1617,7 +1724,7 @@ async def run_fault_diagnosis_workflow(extracted_text: str,combined_query: str,h
|
||||
initial_state["user_confirmed_info"] = True
|
||||
result = await app.ainvoke(initial_state, config)
|
||||
else:
|
||||
initial_state = {"extracted_text": extracted_text,"combined_query": combined_query,"history_messages": parse_history(history_message),"ship_number": "","device_name": "","fault": "","additional_info": "",
|
||||
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": "","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": {},
|
||||
@ -1630,7 +1737,7 @@ async def run_fault_diagnosis_workflow(extracted_text: str,combined_query: str,h
|
||||
traceback.print_exc()
|
||||
result = {}
|
||||
else:
|
||||
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": "",
|
||||
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": "",
|
||||
"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,
|
||||
"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": "",}
|
||||
@ -1661,4 +1768,3 @@ 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,}
|
||||
|
||||
|
||||
|
||||
@ -9,14 +9,18 @@ from tools.rag_tools import rag_search
|
||||
from tools.graph_tools import atlas_retrieval, format_atlas_results, extract_entity_names_from_history
|
||||
from modelsAPI.model_api import OpenaiAPI
|
||||
from utils.function_tracker import get_all_callbacks
|
||||
from utils.image_utils import extract_image_paths, get_image_prompt_guidance
|
||||
from utils.image_utils import extract_image_paths, normalize_markdown_images, get_image_prompt_guidance
|
||||
from utils.intent_matcher import is_confirmation_intent
|
||||
from workflows.workflow_baike import run_baike_workflow
|
||||
from workflows.workflow_utils import (
|
||||
safe_json_extract, parse_history, normalize_latex_spacing,
|
||||
remove_duplicate_content, convert_rag_result, calculate_info_completeness,
|
||||
remove_duplicate_content, convert_rag_result, dedupe_rag_results, calculate_info_completeness,
|
||||
emit_callback_event, build_history_str, build_detail_info,
|
||||
append_atlas_section, stream_format_and_postprocess,
|
||||
append_atlas_section, stream_generate_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
|
||||
import asyncio
|
||||
@ -32,6 +36,7 @@ class OperateGuideState(TypedDict):
|
||||
|
||||
ship_number: str
|
||||
device_name: str
|
||||
system_name: str
|
||||
operation_item: str
|
||||
additional_info: str
|
||||
operation_code: str
|
||||
@ -93,6 +98,12 @@ class OperateGuideState(TypedDict):
|
||||
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}
|
||||
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:
|
||||
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}
|
||||
@ -106,6 +117,7 @@ async def classify_intent(state: OperateGuideState) -> Dict[str, Any]:
|
||||
extracted_info = state.get("extracted_info", {}) or {}
|
||||
ship_number = state.get("ship_number", "") or ""
|
||||
device_name = state.get("device_name", "") or ""
|
||||
system_name = state.get("system_name", "") or ""
|
||||
operation_item = state.get("operation_item", "") or ""
|
||||
|
||||
if extracted_info or device_name or operation_item or ship_number:
|
||||
@ -153,7 +165,7 @@ async def extract_info(state: OperateGuideState) -> Dict[str, Any]:
|
||||
combined_query = state.get("combined_query", "") or ""
|
||||
history_messages = state.get("history_messages", []) or []
|
||||
|
||||
history_str = build_history_str(history_messages, recent_count=4)
|
||||
history_str = build_history_str(history_messages, recent_count=HISTORY_RECENT_COUNT)
|
||||
|
||||
existing_info = []
|
||||
existing_ship_number = state.get("ship_number", "") or ""
|
||||
@ -230,6 +242,7 @@ async def agent_think(state: OperateGuideState) -> Dict[str, Any]:
|
||||
combined_query = state.get("combined_query", "") or ""
|
||||
ship_number = state.get("ship_number", "") or ""
|
||||
device_name = state.get("device_name", "") or ""
|
||||
system_name = state.get("system_name", "") or ""
|
||||
operation_item = state.get("operation_item", "") or ""
|
||||
operation_code = state.get("operation_code", "") or ""
|
||||
operation_time = state.get("operation_time", "") or ""
|
||||
@ -270,7 +283,7 @@ async def agent_think(state: OperateGuideState) -> Dict[str, Any]:
|
||||
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",}
|
||||
|
||||
history_str = build_history_str(history_messages, recent_count=5, assistant_truncate=300)
|
||||
history_str = build_history_str(history_messages, recent_count=HISTORY_RECENT_COUNT, assistant_truncate=HISTORY_ASSISTANT_TRUNCATE)
|
||||
|
||||
intent_system_prompt = OPERATE_PROMPTS["agent_think_intent_system"]
|
||||
|
||||
@ -385,6 +398,7 @@ async def ask_user(state: OperateGuideState) -> Command:
|
||||
missing_info = state.get("missing_info", [])
|
||||
ship_number = state.get("ship_number", "") or ""
|
||||
device_name = state.get("device_name", "") or ""
|
||||
system_name = state.get("system_name", "") or ""
|
||||
operation_item = state.get("operation_item", "") or ""
|
||||
operation_code = state.get("operation_code", "") or ""
|
||||
operation_time = state.get("operation_time", "") or ""
|
||||
@ -502,6 +516,7 @@ async def confirm_info(state: OperateGuideState) -> Command:
|
||||
"""
|
||||
ship_number = state.get("ship_number", "") or ""
|
||||
device_name = state.get("device_name", "") or ""
|
||||
system_name = state.get("system_name", "") or ""
|
||||
operation_item = state.get("operation_item", "") or ""
|
||||
operation_code = state.get("operation_code", "") or ""
|
||||
operation_time = state.get("operation_time", "") or ""
|
||||
@ -579,6 +594,7 @@ async def call_tools(state: OperateGuideState) -> Dict[str, Any]:
|
||||
tools_to_call = state.get("tools_to_call", [])
|
||||
ship_number = state.get("ship_number", "") or ""
|
||||
device_name = state.get("device_name", "") or ""
|
||||
system_name = state.get("system_name", "") or ""
|
||||
operation_item = state.get("operation_item", "") or ""
|
||||
additional_info = state.get("additional_info", "") or ""
|
||||
|
||||
@ -588,24 +604,47 @@ async def call_tools(state: OperateGuideState) -> Dict[str, Any]:
|
||||
|
||||
matched_kb_name = ""
|
||||
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():
|
||||
from utils.ship_number_search import search_with_ship_number_strategy
|
||||
base_query = f"如何执行{device_name}的{operation_item}操作"
|
||||
results, kb_name, kb_id, _ = await search_with_ship_number_strategy(
|
||||
base_query=base_query, ship_number=ship_number, top_k=5, search_type="operate"
|
||||
base_query = f"设备{device_name},操作{operation_item}的操作指导方案"
|
||||
results, kb_name, kb_id, mapped_number = await search_with_ship_number_strategy(
|
||||
base_query=base_query, ship_number=ship_number, top_k=RAG_TOP_K, search_type="operate"
|
||||
)
|
||||
if not kb_name:
|
||||
kb_name = "通用知识库"
|
||||
print(f"RAG 检索来源: {kb_name}")
|
||||
return results, kb_name, kb_id
|
||||
return results, kb_name, kb_id, mapped_number
|
||||
|
||||
async def _do_graph_search():
|
||||
if not (device_name and operation_item):
|
||||
return ""
|
||||
try:
|
||||
graph_query = f"如何执行{device_name}的{operation_item}操作"
|
||||
results = await graph_rag_search.ainvoke({"query": graph_query})
|
||||
graph_query = f"设备{device_name},操作{operation_item}的操作指导方案"
|
||||
results = await graph_rag_search.ainvoke({"query": graph_query, "top_k": GRAPH_TOP_K})
|
||||
|
||||
print(f"图谱检索完成,结果长度: {len(results) if results else 0}")
|
||||
return results
|
||||
except Exception as e:
|
||||
@ -615,18 +654,32 @@ async def call_tools(state: OperateGuideState) -> Dict[str, Any]:
|
||||
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
|
||||
|
||||
if need_rag and need_graph:
|
||||
(rag_results, matched_kb_name, matched_kb_id), graph_results = \
|
||||
await asyncio.gather(_do_rag_search(), _do_graph_search())
|
||||
elif need_rag:
|
||||
rag_results, matched_kb_name, matched_kb_id = await _do_rag_search()
|
||||
elif need_graph:
|
||||
if need_rag:
|
||||
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:
|
||||
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 need_graph:
|
||||
graph_results = await _do_graph_search()
|
||||
|
||||
if device_name:
|
||||
try:
|
||||
print(f"[图册检索] 使用设备名称: {device_name}")
|
||||
atlas_result = await atlas_retrieval(node_names=[device_name], top_k=10)
|
||||
atlas_result = await atlas_retrieval(node_names=[device_name], top_k=ATLAS_TOP_K)
|
||||
if atlas_result.get("success", False):
|
||||
atlas_results = atlas_result.get("data", {})
|
||||
print(f"[图册检索] 图册检索结果: {list(atlas_results.keys())}")
|
||||
@ -638,9 +691,9 @@ async def call_tools(state: OperateGuideState) -> Dict[str, Any]:
|
||||
|
||||
if not has_rag and not has_graph:
|
||||
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,"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,"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,"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",}
|
||||
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",}
|
||||
|
||||
async def model_confirm(state: OperateGuideState) -> Command:
|
||||
"""
|
||||
@ -691,6 +744,7 @@ async def generate_response(state: OperateGuideState) -> Dict[str, Any]:
|
||||
history_messages = state.get("history_messages", []) or []
|
||||
ship_number = state.get("ship_number", "") or ""
|
||||
device_name = state.get("device_name", "") or ""
|
||||
system_name = state.get("system_name", "") or ""
|
||||
operation_item = state.get("operation_item", "") or ""
|
||||
additional_info = state.get("additional_info", "") or ""
|
||||
operation_code = state.get("operation_code", "") or ""
|
||||
@ -705,7 +759,7 @@ async def generate_response(state: OperateGuideState) -> Dict[str, Any]:
|
||||
matched_kb_name = state.get("matched_kb_name", "")
|
||||
|
||||
print("rag_results", rag_results)
|
||||
history_str = build_history_str(history_messages, recent_count=5, assistant_truncate=500)
|
||||
history_str = build_history_str(history_messages, recent_count=HISTORY_RECENT_COUNT, assistant_truncate=HISTORY_ASSISTANT_TRUNCATE)
|
||||
|
||||
has_rag = rag_results and len(rag_results) > 0
|
||||
has_graph = graph_results and len(graph_results) > 100
|
||||
@ -730,28 +784,27 @@ async def generate_response(state: OperateGuideState) -> Dict[str, Any]:
|
||||
|
||||
try:
|
||||
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",}
|
||||
except Exception as e:
|
||||
return {"response": f"抱歉,处理过程中出现错误: {str(e)}","suggestedReplies": [],"current_node": "end",}
|
||||
|
||||
rag_ctx_parts: List[str] = []
|
||||
if isinstance(rag_results, str) and rag_results.strip():
|
||||
rag_ctx_parts = [rag_results.strip()]
|
||||
elif isinstance(rag_results, list):
|
||||
for item in rag_results[:8]:
|
||||
if isinstance(item, dict):
|
||||
text = (item.get("text") or "").strip()
|
||||
if text:
|
||||
rag_ctx_parts.append(text)
|
||||
elif isinstance(item, str) and item.strip():
|
||||
rag_ctx_parts.append(item.strip())
|
||||
|
||||
if len(graph_results) <= 50:
|
||||
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_ctx_parts: List[str] = []
|
||||
if not graph_results:
|
||||
if isinstance(rag_results, str) and rag_results.strip():
|
||||
rag_ctx_parts = [rag_results.strip()]
|
||||
elif isinstance(rag_results, list):
|
||||
deduped_rag_results = dedupe_rag_results(rag_results, limit=4)
|
||||
for item in deduped_rag_results:
|
||||
if isinstance(item, dict):
|
||||
text = (item.get("text") or "").strip()
|
||||
if text:
|
||||
rag_ctx_parts.append(text)
|
||||
elif isinstance(item, str) and item.strip():
|
||||
rag_ctx_parts.append(item.strip())
|
||||
|
||||
rag_answer = ""
|
||||
rag_type = "none"
|
||||
@ -760,9 +813,12 @@ async def generate_response(state: OperateGuideState) -> Dict[str, Any]:
|
||||
rag_answer = graph_results
|
||||
rag_type = "graph"
|
||||
elif rag_ctx_parts:
|
||||
rag_answer = "\n\n".join(rag_ctx_parts[:3])
|
||||
rag_answer = "\n\n".join(rag_ctx_parts)
|
||||
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":
|
||||
system_prompt = COMMON_PROMPTS["generate_response_no_rag_simple_system"].format(biz_label="操作指导")
|
||||
prompt = OPERATE_PROMPTS["generate_response_no_rag_simple_user"].format(
|
||||
@ -773,6 +829,7 @@ async def generate_response(state: OperateGuideState) -> Dict[str, Any]:
|
||||
|
||||
try:
|
||||
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",}
|
||||
except Exception as e:
|
||||
return {"response": f"抱歉,处理过程中出现错误: {str(e)}","suggestedReplies": [],"current_node": "end",}
|
||||
@ -782,6 +839,8 @@ async def generate_response(state: OperateGuideState) -> Dict[str, Any]:
|
||||
emit_callback_event([f"✨当前使用的检索类型为{rag_type}", f"✨本次检索策略为{rag_type}"], f"内容为{rag_answer[:50]}...")
|
||||
|
||||
detail_info = ""
|
||||
if system_name:
|
||||
detail_info += f"- 所属系统:{system_name}\n"
|
||||
if operation_code:
|
||||
detail_info += f"- 操作代码/报警代码:{operation_code}\n"
|
||||
if operation_time:
|
||||
@ -843,13 +902,15 @@ async def generate_response(state: OperateGuideState) -> Dict[str, Any]:
|
||||
try:
|
||||
content_system_prompt = COMMON_PROMPTS["generate_response_content_system"].format(biz_label="操作指导")
|
||||
|
||||
raw_content = await OpenaiAPI.open_api_chat_without_thinking(model=None,system_prompt=content_system_prompt,messages=[{"role": "user", "content": prompt}],)
|
||||
emit_callback_event(["🤖 操作分析中", "🤖 方案生成中"], "正在生成操作指导方案...")
|
||||
|
||||
raw_content = raw_content.strip() if raw_content else "抱歉,无法生成回答。"
|
||||
|
||||
emit_callback_event(["🤖 操作分析中", "🤖 方案生成中"], raw_content[:30] + "...")
|
||||
|
||||
answer = await stream_format_and_postprocess(raw_content, original_image_paths, temperature=0.1)
|
||||
answer = await stream_generate_and_postprocess(
|
||||
system_prompt=content_system_prompt,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
original_image_paths=original_image_paths,
|
||||
temperature=0.1,
|
||||
fallback="抱歉,无法生成回答。"
|
||||
)
|
||||
|
||||
answer = append_atlas_section(answer, atlas_results)
|
||||
|
||||
@ -866,6 +927,7 @@ async def regenerate_scheme_from_feedback(state: OperateGuideState) -> Dict[str,
|
||||
"""
|
||||
ship_number = state.get("ship_number", "") or ""
|
||||
device_name = state.get("device_name", "") or ""
|
||||
system_name = state.get("system_name", "") or ""
|
||||
operation_item = state.get("operation_item", "") or ""
|
||||
additional_info = state.get("additional_info", "") or ""
|
||||
operation_code = state.get("operation_code", "") or ""
|
||||
@ -884,6 +946,8 @@ 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"],}
|
||||
|
||||
detail_info = ""
|
||||
if system_name:
|
||||
detail_info += f"- 所属系统:{system_name}\n"
|
||||
if operation_code:
|
||||
detail_info += f"- 操作代码/报警代码:{operation_code}\n"
|
||||
if operation_time:
|
||||
@ -917,15 +981,15 @@ async def regenerate_scheme_from_feedback(state: OperateGuideState) -> Dict[str,
|
||||
|
||||
try:
|
||||
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)
|
||||
answer = await stream_format_and_postprocess(raw_content, original_image_paths, temperature=0.3, content_label="操作指导方案")
|
||||
answer = await stream_generate_and_postprocess(
|
||||
system_prompt=content_system_prompt,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
original_image_paths=original_image_paths,
|
||||
temperature=0.3,
|
||||
fallback="抱歉,无法根据反馈修改方案。"
|
||||
)
|
||||
|
||||
suggested_replies = [{"title": "方案有误", "content": "方案还是有问题:"},]
|
||||
|
||||
@ -943,6 +1007,7 @@ async def follow_up_qa(state: OperateGuideState) -> Dict[str, Any]:
|
||||
history_messages = state.get("history_messages", []) or []
|
||||
ship_number = state.get("ship_number", "") or ""
|
||||
device_name = state.get("device_name", "") or ""
|
||||
system_name = state.get("system_name", "") or ""
|
||||
operation_item = state.get("operation_item", "") or ""
|
||||
|
||||
history_message_json = json.dumps(history_messages, ensure_ascii=False) if history_messages else ""
|
||||
@ -1094,6 +1159,27 @@ async def deep_rag_search(state: OperateGuideState) -> Dict[str, Any]:
|
||||
combined_query = state.get("combined_query", "") or ""
|
||||
history_messages = state.get("history_messages", []) 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_analysis_points = []
|
||||
@ -1103,7 +1189,7 @@ async def deep_rag_search(state: OperateGuideState) -> Dict[str, Any]:
|
||||
# ========== 第一步:分析之前方案和用户问题,选择深度分析点 ==========
|
||||
if last_generated_scheme or combined_query:
|
||||
try:
|
||||
history_str = build_history_str(history_messages, recent_count=4, assistant_truncate=300)
|
||||
history_str = build_history_str(history_messages, recent_count=HISTORY_RECENT_COUNT, assistant_truncate=HISTORY_ASSISTANT_TRUNCATE)
|
||||
|
||||
detail_info = ""
|
||||
if operation_code:
|
||||
@ -1151,9 +1237,12 @@ async def deep_rag_search(state: OperateGuideState) -> Dict[str, Any]:
|
||||
if operation_item and operation_item not in search_query:
|
||||
search_query = f"{search_query} {operation_item}"
|
||||
|
||||
rag_result = await rag_search(search_query, top_k=3)
|
||||
rag_result = await rag_search(search_query, top_k=DEEP_RAG_TOP_K)
|
||||
if rag_result.get("success", False):
|
||||
deep_results[f"analysis_point_{idx}"] = convert_rag_result(rag_result)
|
||||
deep_results[f"analysis_point_{idx}"] = dedupe_rag_results(
|
||||
convert_rag_result(rag_result),
|
||||
limit=DEEP_RAG_TOP_K,
|
||||
)
|
||||
else:
|
||||
deep_results[f"analysis_point_{idx}"] = []
|
||||
except Exception as e:
|
||||
@ -1165,7 +1254,7 @@ async def deep_rag_search(state: OperateGuideState) -> Dict[str, Any]:
|
||||
try:
|
||||
if device_name and operation_item:
|
||||
graph_query = f"设备{device_name},操作{operation_item}的操作指导方案"
|
||||
graph_deep_results = await graph_rag_search.ainvoke({"query": graph_query})
|
||||
graph_deep_results = await graph_rag_search.ainvoke({"query": graph_query, "top_k": GRAPH_TOP_K})
|
||||
if graph_deep_results:
|
||||
print(f"[深度RAG] 图谱检索结果长度: {len(graph_deep_results)}")
|
||||
except Exception as e:
|
||||
@ -1178,14 +1267,14 @@ async def deep_rag_search(state: OperateGuideState) -> Dict[str, Any]:
|
||||
|
||||
if entity_names:
|
||||
print(f"[深度RAG] 图册检索实体: {entity_names}")
|
||||
atlas_result = await atlas_retrieval(node_names=entity_names, top_k=10)
|
||||
atlas_result = await atlas_retrieval(node_names=entity_names, top_k=ATLAS_TOP_K)
|
||||
if atlas_result.get("success", False):
|
||||
atlas_results = atlas_result.get("data", {})
|
||||
print(f"[深度RAG] 图册检索结果: {list(atlas_results.keys())}")
|
||||
except Exception as 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,"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,"ship_number": ship_number,"device_name": device_name,"system_name": system_name,"current_node": "generate_deep_response",}
|
||||
|
||||
|
||||
async def generate_deep_response(state: OperateGuideState) -> Dict[str, Any]:
|
||||
@ -1196,6 +1285,7 @@ async def generate_deep_response(state: OperateGuideState) -> Dict[str, Any]:
|
||||
history_messages = state.get("history_messages", []) or []
|
||||
ship_number = state.get("ship_number", "") or ""
|
||||
device_name = state.get("device_name", "") or ""
|
||||
system_name = state.get("system_name", "") or ""
|
||||
operation_item = state.get("operation_item", "") or ""
|
||||
operation_code = state.get("operation_code", "") or ""
|
||||
operation_time = state.get("operation_time", "") or ""
|
||||
@ -1218,7 +1308,7 @@ async def generate_deep_response(state: OperateGuideState) -> Dict[str, Any]:
|
||||
results = deep_rag_results.get(key, [])
|
||||
if results:
|
||||
analysis_points_text += f"\n【分析点:{point}】\n"
|
||||
for i, item in enumerate(results[:2], 1):
|
||||
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):
|
||||
if isinstance(item, dict):
|
||||
text = item.get("text", "")
|
||||
if text:
|
||||
@ -1228,20 +1318,22 @@ async def generate_deep_response(state: OperateGuideState) -> Dict[str, Any]:
|
||||
original_rag_text = ""
|
||||
if original_rag_results:
|
||||
original_rag_text = "\n【原始检索资料】\n"
|
||||
for i, item in enumerate(original_rag_results[:3], 1):
|
||||
for i, item in enumerate(dedupe_rag_results(original_rag_results, limit=2), 1):
|
||||
if isinstance(item, dict):
|
||||
text = item.get("text", "")
|
||||
if text:
|
||||
original_rag_text += f"[{i}] {text}\n"
|
||||
|
||||
# 整理图册资料
|
||||
atlas_text = format_atlas_results(atlas_results)
|
||||
|
||||
# 提取所有资料中的图片路径
|
||||
all_source_text = analysis_points_text + original_rag_text + atlas_text + graph_results
|
||||
original_image_paths = extract_image_paths(all_source_text)
|
||||
if graph_results and len(graph_results) > 50:
|
||||
original_image_paths = extract_image_paths(graph_results)
|
||||
print("提取到的deep graph原始图片路径:", original_image_paths)
|
||||
else:
|
||||
original_image_paths = extract_image_paths(analysis_points_text + original_rag_text)
|
||||
print("提取到的deep rag原始图片路径:", original_image_paths)
|
||||
|
||||
detail_info = ""
|
||||
if system_name:
|
||||
detail_info += f"- 所属系统:{system_name}\n"
|
||||
if operation_code:
|
||||
detail_info += f"- 操作代码:{operation_code}\n"
|
||||
if operation_time:
|
||||
@ -1278,13 +1370,15 @@ async def generate_deep_response(state: OperateGuideState) -> Dict[str, Any]:
|
||||
try:
|
||||
content_system_prompt = COMMON_PROMPTS["generate_deep_response_content_system"].format(biz_label="操作指导")
|
||||
|
||||
raw_content = await OpenaiAPI.open_api_chat_without_thinking(model=None,system_prompt=content_system_prompt,messages=[{"role": "user", "content": prompt}],)
|
||||
emit_callback_event(["🤖 分析中", "🤖 操作指导生成中"], "正在生成深度操作指导...")
|
||||
|
||||
raw_content = raw_content.strip() if raw_content else "抱歉,无法生成深度分析。"
|
||||
|
||||
emit_callback_event(["🤖 分析中", "🤖 操作指导生成中"], raw_content[:30] + "...")
|
||||
|
||||
answer = await stream_format_and_postprocess(raw_content, original_image_paths, temperature=0.1)
|
||||
answer = await stream_generate_and_postprocess(
|
||||
system_prompt=content_system_prompt,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
original_image_paths=original_image_paths,
|
||||
temperature=0.1,
|
||||
fallback="抱歉,无法生成深度分析。"
|
||||
)
|
||||
|
||||
answer = append_atlas_section(answer, atlas_results)
|
||||
|
||||
@ -1422,7 +1516,7 @@ async def run_operate_workflow(extracted_text: str,combined_query: str,history_m
|
||||
initial_state["user_confirmed_info"] = True
|
||||
result = await app.ainvoke(initial_state, config)
|
||||
else:
|
||||
initial_state = {"extracted_text": extracted_text,"combined_query": combined_query,"history_messages": parse_history(history_message),"ship_number": "","device_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": "","system_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": [],"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": {},
|
||||
@ -1435,7 +1529,7 @@ async def run_operate_workflow(extracted_text: str,combined_query: str,history_m
|
||||
traceback.print_exc()
|
||||
result = {}
|
||||
else:
|
||||
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": "",
|
||||
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": "",
|
||||
"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,
|
||||
"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": "",}
|
||||
@ -1466,4 +1560,3 @@ 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,}
|
||||
|
||||
|
||||
|
||||
@ -1,293 +1,293 @@
|
||||
"""
|
||||
工作流:普通问答Agent(使用LangGraph)
|
||||
输入:文本(转化后和原文本query)、图片描述、语音文本
|
||||
步骤:
|
||||
1. 使用RAG和GraphRAG检索相关信息
|
||||
2. 生成回答
|
||||
使用 OpenaiAPI 方法调用大模型,尽可能使用异步
|
||||
"""
|
||||
from typing import TypedDict, Optional, Dict, Any, List
|
||||
from langgraph.graph import StateGraph, START, END
|
||||
from modelsAPI.model_api import OpenaiAPI
|
||||
from utils.function_tracker import track_function_calls, get_current_stream_handler
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
|
||||
|
||||
# =============== 定义状态 ===============
|
||||
class Othertate(TypedDict):
|
||||
"""普通问答工作流状态"""
|
||||
extracted_text: str # 已提取的文本(统一接口,只接收文本)
|
||||
combined_query: str
|
||||
route_flag: str # 路由标识
|
||||
history_message: str # 历史消息(JSON格式)
|
||||
history_messages: List[Dict[str, Any]] # 过滤后的历史消息列表
|
||||
|
||||
# RAG检索结果
|
||||
rag_search_result: Optional[List[Dict[str, Any]]] # RAG检索结果
|
||||
graph_search_result: Optional[str] # 图谱检索结果
|
||||
|
||||
# 最终响应
|
||||
response: str # 最终响应
|
||||
actions: List[Dict[str, Any]] # 按钮信息列表 [{"type": "...", "label": "...", "icon": "..."}]
|
||||
suggestedReplies: List[Dict[str, Any]] # 建议回复问题列表
|
||||
error_message: str # 错误信息
|
||||
|
||||
|
||||
# =============== 节点函数 ===============
|
||||
|
||||
def get_other_history_context(state: Othertate) -> Dict[str, Any]:
|
||||
"""
|
||||
知识问答步骤:获取问答类历史信息
|
||||
问答工作流只关注历史问答记录,用于提供上下文
|
||||
支持格式:[{'role': 'user', 'content': '...'}, {'role': 'assistant', 'content': '...'}]
|
||||
返回消息列表(history_message已经在app.py中过滤过了)
|
||||
"""
|
||||
import json
|
||||
|
||||
history_message = state.get("history_message", "") or ""
|
||||
history_messages = []
|
||||
|
||||
if history_message:
|
||||
try:
|
||||
# history_message已经在app.py中过滤过了,直接解析
|
||||
if isinstance(history_message, str):
|
||||
history_data = json.loads(history_message)
|
||||
else:
|
||||
history_data = history_message
|
||||
|
||||
if isinstance(history_data, list):
|
||||
# 只取最近10条历史记录,避免上下文过长
|
||||
history_messages = history_data[-10:]
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
return {"history_messages": history_messages}
|
||||
|
||||
|
||||
async def generate_suggested_replies_other(answer: str, question: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
生成针对问答结果的建议回复问题
|
||||
使用大模型生成两个相关问题
|
||||
"""
|
||||
prompt = f"""
|
||||
你是一个问答助手。根据以下问答内容,生成两个用户可能关心的后续问题。
|
||||
|
||||
【用户问题】
|
||||
{question}
|
||||
|
||||
【回答内容】
|
||||
{answer[:500]}
|
||||
|
||||
请生成两个简洁、实用的问题,这些问题应该:
|
||||
1. 不得出现需要归档和下载等操作相关的问题
|
||||
2. 与当前问答内容相关,能够帮助用户进一步了解相关信息
|
||||
3. 问题要具体、可操作
|
||||
4. 每个问题不超过20个字
|
||||
|
||||
请以JSON格式输出,格式如下:
|
||||
[
|
||||
{{"title": "问题1的标题", "content": "问题1的完整内容"}},
|
||||
{{"title": "问题2的标题", "content": "问题2的完整内容"}}
|
||||
]
|
||||
|
||||
只输出JSON,不要有其他文字说明。
|
||||
"""
|
||||
try:
|
||||
result = await OpenaiAPI.open_api_chat_without_thinking(prompt, model=None)
|
||||
# 尝试解析JSON
|
||||
# 提取JSON部分
|
||||
json_match = re.search(r'\[.*\]', result, re.DOTALL)
|
||||
if json_match:
|
||||
json_str = json_match.group(0)
|
||||
suggested_replies = json.loads(json_str)
|
||||
# 验证格式
|
||||
if isinstance(suggested_replies, list) and len(suggested_replies) >= 2:
|
||||
# 确保每个元素都有title和content
|
||||
formatted_replies = []
|
||||
for reply in suggested_replies[:2]:
|
||||
if isinstance(reply, dict) and "title" in reply and "content" in reply:
|
||||
formatted_replies.append({
|
||||
"title": str(reply["title"]),
|
||||
"content": str(reply["content"])
|
||||
})
|
||||
if len(formatted_replies) == 2:
|
||||
return formatted_replies
|
||||
|
||||
# 如果解析失败,返回默认问题
|
||||
return [
|
||||
{"title": "这个设备的具体工作原理是什么?", "content": "这个设备的具体工作原理是什么?"},
|
||||
{"title": "还有哪些相关的知识点?", "content": "还有哪些相关的知识点?"}
|
||||
]
|
||||
except Exception as e:
|
||||
# 如果生成失败,返回默认问题
|
||||
return [
|
||||
{"title": "这个设备的具体工作原理是什么?", "content": "这个设备的具体工作原理是什么?"},
|
||||
{"title": "还有哪些相关的知识点?", "content": "还有哪些相关的知识点?"}
|
||||
]
|
||||
|
||||
|
||||
@track_function_calls
|
||||
async def generate_answer(state: Othertate) -> Dict[str, Any]:
|
||||
"""
|
||||
正在生成问答结果
|
||||
"""
|
||||
extracted_text = state.get("combined_query", "")
|
||||
history_messages = state.get("history_messages", []) or []
|
||||
print("history_message(qa)", history_messages)
|
||||
|
||||
try:
|
||||
# 构建系统提示词
|
||||
system_prompt = """我是AI助手,根据历史信息和用户问题进行回答。
|
||||
请基于以上信息,给出准确、专业的回答。如果有历史问答上下文,请结合历史对话内容理解当前问题。"""
|
||||
|
||||
# 构建消息列表
|
||||
messages = []
|
||||
|
||||
# 添加历史消息
|
||||
if history_messages:
|
||||
messages.extend(history_messages)
|
||||
|
||||
# 添加当前用户查询
|
||||
messages.append({"role": "user", "content": f"用户问题:{extracted_text}"})
|
||||
|
||||
# 调用LLM生成回答(异步,禁用thinking)
|
||||
answer = await OpenaiAPI.open_api_chat_without_thinking(
|
||||
model=None,
|
||||
system_prompt=system_prompt,
|
||||
messages=messages
|
||||
)
|
||||
|
||||
# 过滤掉 <think> 标签(如果有)
|
||||
import re
|
||||
answer = re.sub(r'<think>.*?</think>', '', answer, flags=re.DOTALL)
|
||||
answer = answer.strip() if answer else "抱歉,无法生成回答。"
|
||||
|
||||
# 返回按钮:确认结果、重新生成
|
||||
actions = []
|
||||
|
||||
# 生成建议回复问题
|
||||
suggested_replies = await generate_suggested_replies_other(answer, extracted_text)
|
||||
|
||||
return {
|
||||
"response": answer,
|
||||
"actions": actions,
|
||||
"result_tag": "other",
|
||||
"suggestedReplies": suggested_replies
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"response": f"生成回答失败: {str(e)}",
|
||||
"actions": [],
|
||||
"suggestedReplies": []
|
||||
}
|
||||
|
||||
|
||||
# =============== 构建工作流 ===============
|
||||
def create_qa_workflow():
|
||||
"""创建普通问答工作流"""
|
||||
workflow = StateGraph(Othertate)
|
||||
|
||||
# 添加节点
|
||||
workflow.add_node("get_other_history_context", get_other_history_context)
|
||||
workflow.add_node("generate_answer", generate_answer)
|
||||
|
||||
# 设置边
|
||||
workflow.add_edge(START, "get_other_history_context")
|
||||
workflow.add_edge("get_other_history_context", "generate_answer")
|
||||
workflow.add_edge("generate_answer", END)
|
||||
|
||||
# 编译工作流
|
||||
return workflow.compile()
|
||||
|
||||
|
||||
# =============== 工作流执行函数 ===============
|
||||
async def run_other_workflow(
|
||||
extracted_text: str,
|
||||
combined_query: str,
|
||||
history_message: str = "",
|
||||
route_flag: str = ""
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
执行普通问答工作流(统一接口)
|
||||
|
||||
Args:
|
||||
extracted_text: 文本描述(统一的输入参数)
|
||||
history_message: 历史消息(目前只接收,不做处理)
|
||||
|
||||
Returns:
|
||||
工作流执行结果
|
||||
"""
|
||||
# 创建并编译工作流
|
||||
app = create_qa_workflow()
|
||||
|
||||
# 初始化状态(统一接口,只使用extracted_text和history_message)
|
||||
initial_state = {
|
||||
"extracted_text": extracted_text,
|
||||
"combined_query": combined_query,
|
||||
"history_message": history_message,
|
||||
"history_messages": [],
|
||||
"response": "",
|
||||
"actions": [],
|
||||
"suggestedReplies": [],
|
||||
"error_message": ""
|
||||
}
|
||||
|
||||
try:
|
||||
# 执行工作流(使用异步方式)
|
||||
final_state = await app.ainvoke(initial_state)
|
||||
|
||||
# 检查是否有错误
|
||||
if final_state.get("error_message"):
|
||||
return {
|
||||
"response": f"普通问答工作流执行失败: {final_state['error_message']}",
|
||||
"actions": [],
|
||||
"result_tag": "qa",
|
||||
"suggestedReplies": []
|
||||
}
|
||||
|
||||
# 统一输出格式:完全透传 generate_answer 的结果
|
||||
return {
|
||||
"response": final_state.get("response", ""),
|
||||
"actions": final_state.get("actions", []),
|
||||
"result_tag": "other",
|
||||
"suggestedReplies": final_state.get("suggestedReplies", [])
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"response": f"普通问答工作流执行失败: {str(e)}",
|
||||
"actions": [],
|
||||
"result_tag": "other",
|
||||
"suggestedReplies": []
|
||||
}
|
||||
|
||||
|
||||
# =============== 测试 ===============
|
||||
if __name__ == "__main__":
|
||||
import asyncio
|
||||
|
||||
# 测试用例
|
||||
test_cases = [
|
||||
"什么是主机?",
|
||||
"主机的作用是什么?"
|
||||
]
|
||||
|
||||
|
||||
async def test():
|
||||
for i, test_text in enumerate(test_cases, 1):
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f"测试用例 {i}")
|
||||
print(f"{'=' * 60}")
|
||||
|
||||
result = await run_other_workflow(extracted_text=test_text)
|
||||
|
||||
print(f"\n执行结果:")
|
||||
print(f"成功: {result.get('success', False)}")
|
||||
print(f"\n响应内容:")
|
||||
print(result.get("response", "无响应"))
|
||||
|
||||
|
||||
asyncio.run(test())
|
||||
|
||||
"""
|
||||
工作流:普通问答Agent(使用LangGraph)
|
||||
输入:文本(转化后和原文本query)、图片描述、语音文本
|
||||
步骤:
|
||||
1. 使用RAG和GraphRAG检索相关信息
|
||||
2. 生成回答
|
||||
使用 OpenaiAPI 方法调用大模型,尽可能使用异步
|
||||
"""
|
||||
from typing import TypedDict, Optional, Dict, Any, List
|
||||
from langgraph.graph import StateGraph, START, END
|
||||
from modelsAPI.model_api import OpenaiAPI
|
||||
from utils.function_tracker import track_function_calls, get_current_stream_handler
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
|
||||
|
||||
# =============== 定义状态 ===============
|
||||
class Othertate(TypedDict):
|
||||
"""普通问答工作流状态"""
|
||||
extracted_text: str # 已提取的文本(统一接口,只接收文本)
|
||||
combined_query: str
|
||||
route_flag: str # 路由标识
|
||||
history_message: str # 历史消息(JSON格式)
|
||||
history_messages: List[Dict[str, Any]] # 过滤后的历史消息列表
|
||||
|
||||
# RAG检索结果
|
||||
rag_search_result: Optional[List[Dict[str, Any]]] # RAG检索结果
|
||||
graph_search_result: Optional[str] # 图谱检索结果
|
||||
|
||||
# 最终响应
|
||||
response: str # 最终响应
|
||||
actions: List[Dict[str, Any]] # 按钮信息列表 [{"type": "...", "label": "...", "icon": "..."}]
|
||||
suggestedReplies: List[Dict[str, Any]] # 建议回复问题列表
|
||||
error_message: str # 错误信息
|
||||
|
||||
|
||||
# =============== 节点函数 ===============
|
||||
|
||||
def get_other_history_context(state: Othertate) -> Dict[str, Any]:
|
||||
"""
|
||||
知识问答步骤:获取问答类历史信息
|
||||
问答工作流只关注历史问答记录,用于提供上下文
|
||||
支持格式:[{'role': 'user', 'content': '...'}, {'role': 'assistant', 'content': '...'}]
|
||||
返回消息列表(history_message已经在app.py中过滤过了)
|
||||
"""
|
||||
import json
|
||||
|
||||
history_message = state.get("history_message", "") or ""
|
||||
history_messages = []
|
||||
|
||||
if history_message:
|
||||
try:
|
||||
# history_message已经在app.py中过滤过了,直接解析
|
||||
if isinstance(history_message, str):
|
||||
history_data = json.loads(history_message)
|
||||
else:
|
||||
history_data = history_message
|
||||
|
||||
if isinstance(history_data, list):
|
||||
# 只取最近10条历史记录,避免上下文过长
|
||||
history_messages = history_data[-10:]
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
return {"history_messages": history_messages}
|
||||
|
||||
|
||||
async def generate_suggested_replies_other(answer: str, question: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
生成针对问答结果的建议回复问题
|
||||
使用大模型生成两个相关问题
|
||||
"""
|
||||
prompt = f"""
|
||||
你是一个问答助手。根据以下问答内容,生成两个用户可能关心的后续问题。
|
||||
|
||||
【用户问题】
|
||||
{question}
|
||||
|
||||
【回答内容】
|
||||
{answer[:500]}
|
||||
|
||||
请生成两个简洁、实用的问题,这些问题应该:
|
||||
1. 不得出现需要归档和下载等操作相关的问题
|
||||
2. 与当前问答内容相关,能够帮助用户进一步了解相关信息
|
||||
3. 问题要具体、可操作
|
||||
4. 每个问题不超过20个字
|
||||
|
||||
请以JSON格式输出,格式如下:
|
||||
[
|
||||
{{"title": "问题1的标题", "content": "问题1的完整内容"}},
|
||||
{{"title": "问题2的标题", "content": "问题2的完整内容"}}
|
||||
]
|
||||
|
||||
只输出JSON,不要有其他文字说明。
|
||||
"""
|
||||
try:
|
||||
result = await OpenaiAPI.open_api_chat_without_thinking(prompt, model=None)
|
||||
# 尝试解析JSON
|
||||
# 提取JSON部分
|
||||
json_match = re.search(r'\[.*\]', result, re.DOTALL)
|
||||
if json_match:
|
||||
json_str = json_match.group(0)
|
||||
suggested_replies = json.loads(json_str)
|
||||
# 验证格式
|
||||
if isinstance(suggested_replies, list) and len(suggested_replies) >= 2:
|
||||
# 确保每个元素都有title和content
|
||||
formatted_replies = []
|
||||
for reply in suggested_replies[:2]:
|
||||
if isinstance(reply, dict) and "title" in reply and "content" in reply:
|
||||
formatted_replies.append({
|
||||
"title": str(reply["title"]),
|
||||
"content": str(reply["content"])
|
||||
})
|
||||
if len(formatted_replies) == 2:
|
||||
return formatted_replies
|
||||
|
||||
# 如果解析失败,返回默认问题
|
||||
return [
|
||||
{"title": "这个设备的具体工作原理是什么?", "content": "这个设备的具体工作原理是什么?"},
|
||||
{"title": "还有哪些相关的知识点?", "content": "还有哪些相关的知识点?"}
|
||||
]
|
||||
except Exception as e:
|
||||
# 如果生成失败,返回默认问题
|
||||
return [
|
||||
{"title": "这个设备的具体工作原理是什么?", "content": "这个设备的具体工作原理是什么?"},
|
||||
{"title": "还有哪些相关的知识点?", "content": "还有哪些相关的知识点?"}
|
||||
]
|
||||
|
||||
|
||||
@track_function_calls
|
||||
async def generate_answer(state: Othertate) -> Dict[str, Any]:
|
||||
"""
|
||||
正在生成问答结果
|
||||
"""
|
||||
extracted_text = state.get("combined_query", "")
|
||||
history_messages = state.get("history_messages", []) or []
|
||||
print("history_message(qa)", history_messages)
|
||||
|
||||
try:
|
||||
# 构建系统提示词
|
||||
system_prompt = """我是AI助手,根据历史信息和用户问题进行回答。
|
||||
请基于以上信息,给出准确、专业的回答。如果有历史问答上下文,请结合历史对话内容理解当前问题。"""
|
||||
|
||||
# 构建消息列表
|
||||
messages = []
|
||||
|
||||
# 添加历史消息
|
||||
if history_messages:
|
||||
messages.extend(history_messages)
|
||||
|
||||
# 添加当前用户查询
|
||||
messages.append({"role": "user", "content": f"用户问题:{extracted_text}"})
|
||||
|
||||
# 调用LLM生成回答(异步,禁用thinking)
|
||||
answer = await OpenaiAPI.open_api_chat_without_thinking(
|
||||
model=None,
|
||||
system_prompt=system_prompt,
|
||||
messages=messages
|
||||
)
|
||||
|
||||
# 过滤掉 <think> 标签(如果有)
|
||||
import re
|
||||
answer = re.sub(r'<think>.*?</think>', '', answer, flags=re.DOTALL)
|
||||
answer = answer.strip() if answer else "抱歉,无法生成回答。"
|
||||
|
||||
# 返回按钮:确认结果、重新生成
|
||||
actions = []
|
||||
|
||||
# 生成建议回复问题
|
||||
suggested_replies = await generate_suggested_replies_other(answer, extracted_text)
|
||||
|
||||
return {
|
||||
"response": answer,
|
||||
"actions": actions,
|
||||
"result_tag": "other",
|
||||
"suggestedReplies": suggested_replies
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"response": f"生成回答失败: {str(e)}",
|
||||
"actions": [],
|
||||
"suggestedReplies": []
|
||||
}
|
||||
|
||||
|
||||
# =============== 构建工作流 ===============
|
||||
def create_qa_workflow():
|
||||
"""创建普通问答工作流"""
|
||||
workflow = StateGraph(Othertate)
|
||||
|
||||
# 添加节点
|
||||
workflow.add_node("get_other_history_context", get_other_history_context)
|
||||
workflow.add_node("generate_answer", generate_answer)
|
||||
|
||||
# 设置边
|
||||
workflow.add_edge(START, "get_other_history_context")
|
||||
workflow.add_edge("get_other_history_context", "generate_answer")
|
||||
workflow.add_edge("generate_answer", END)
|
||||
|
||||
# 编译工作流
|
||||
return workflow.compile()
|
||||
|
||||
|
||||
# =============== 工作流执行函数 ===============
|
||||
async def run_other_workflow(
|
||||
extracted_text: str,
|
||||
combined_query: str,
|
||||
history_message: str = "",
|
||||
route_flag: str = ""
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
执行普通问答工作流(统一接口)
|
||||
|
||||
Args:
|
||||
extracted_text: 文本描述(统一的输入参数)
|
||||
history_message: 历史消息(目前只接收,不做处理)
|
||||
|
||||
Returns:
|
||||
工作流执行结果
|
||||
"""
|
||||
# 创建并编译工作流
|
||||
app = create_qa_workflow()
|
||||
|
||||
# 初始化状态(统一接口,只使用extracted_text和history_message)
|
||||
initial_state = {
|
||||
"extracted_text": extracted_text,
|
||||
"combined_query": combined_query,
|
||||
"history_message": history_message,
|
||||
"history_messages": [],
|
||||
"response": "",
|
||||
"actions": [],
|
||||
"suggestedReplies": [],
|
||||
"error_message": ""
|
||||
}
|
||||
|
||||
try:
|
||||
# 执行工作流(使用异步方式)
|
||||
final_state = await app.ainvoke(initial_state)
|
||||
|
||||
# 检查是否有错误
|
||||
if final_state.get("error_message"):
|
||||
return {
|
||||
"response": f"普通问答工作流执行失败: {final_state['error_message']}",
|
||||
"actions": [],
|
||||
"result_tag": "qa",
|
||||
"suggestedReplies": []
|
||||
}
|
||||
|
||||
# 统一输出格式:完全透传 generate_answer 的结果
|
||||
return {
|
||||
"response": final_state.get("response", ""),
|
||||
"actions": final_state.get("actions", []),
|
||||
"result_tag": "other",
|
||||
"suggestedReplies": final_state.get("suggestedReplies", [])
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"response": f"普通问答工作流执行失败: {str(e)}",
|
||||
"actions": [],
|
||||
"result_tag": "other",
|
||||
"suggestedReplies": []
|
||||
}
|
||||
|
||||
|
||||
# =============== 测试 ===============
|
||||
if __name__ == "__main__":
|
||||
import asyncio
|
||||
|
||||
# 测试用例
|
||||
test_cases = [
|
||||
"什么是主机?",
|
||||
"主机的作用是什么?"
|
||||
]
|
||||
|
||||
|
||||
async def test():
|
||||
for i, test_text in enumerate(test_cases, 1):
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f"测试用例 {i}")
|
||||
print(f"{'=' * 60}")
|
||||
|
||||
result = await run_other_workflow(extracted_text=test_text)
|
||||
|
||||
print(f"\n执行结果:")
|
||||
print(f"成功: {result.get('success', False)}")
|
||||
print(f"\n响应内容:")
|
||||
print(result.get("response", "无响应"))
|
||||
|
||||
|
||||
asyncio.run(test())
|
||||
|
||||
|
||||
@ -356,8 +356,8 @@ async def generate_analysis_report_text(state: HistoryQAAnalysisState) -> Dict[s
|
||||
actions: List[Dict[str, Any]] = []
|
||||
|
||||
if word_result and os.path.exists(word_result):
|
||||
base_url = SERVER_CONFIG.get("report")
|
||||
download_url = f"http://192.168.1.164:9088/api/download/{file_name}"
|
||||
base_url = SERVER_CONFIG.get("base_url")
|
||||
download_url = f"{base_url}/api/download/{file_name}"
|
||||
|
||||
actions.append({
|
||||
"type": "download",
|
||||
|
||||