Compare commits

..

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

68 changed files with 4515 additions and 14246 deletions

1
.gitattributes vendored
View File

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

32
.gitignore vendored
View File

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

Binary file not shown.

View File

@ -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
View File

@ -31,6 +31,7 @@ from workflow_registry import WORKFLOW_CONFIG, VALID_ROUTE_FLAGS
from main_agent import extract_conversation_title from main_agent import extract_conversation_title
from workflows.history_manager import filter_image_urls, preprocess_from_request from workflows.history_manager import filter_image_urls, preprocess_from_request
from config import set_request_user_config from config import set_request_user_config
from utils.source_citations import merge_rag_source_citations
from checkpointer_config import ( from checkpointer_config import (
CheckpointerManager, CheckpointerManager,
checkpointer_manager checkpointer_manager
@ -462,7 +463,7 @@ async def stream_main_agent_execution(
agent_task = asyncio.create_task(run_agent()) agent_task = asyncio.create_task(run_agent())
execution_complete_received = False execution_complete_received = False
rag_result = [] rag_result = {}
graph_result = [] graph_result = []
while True: while True:
try: try:
@ -482,16 +483,7 @@ async def stream_main_agent_execution(
if title == "知识库搜索工具" and result and isinstance(result, dict): if title == "知识库搜索工具" and result and isinstance(result, dict):
print("===============================", result) print("===============================", result)
rag_result_raw = result.get("sourceCitation", {}) rag_result_raw = result.get("sourceCitation", {})
rag_result = {} merge_rag_source_citations(rag_result, rag_result_raw)
for doc_name, chunks in rag_result_raw.items():
if isinstance(chunks, list):
rag_result[doc_name] = [
{k: v for k, v in item.items() if k != "text"}
if isinstance(item, dict) else item
for item in chunks
]
else:
rag_result[doc_name] = chunks
elif title == "图谱检索工具" and result and isinstance(result, dict): elif title == "图谱检索工具" and result and isinstance(result, dict):
print("===============================", result) print("===============================", result)
graph_result = result.get("xxxx.graph", []) graph_result = result.get("xxxx.graph", [])
@ -647,12 +639,25 @@ async def download_file(file_name: str):
Returns: Returns:
文件下载响应 文件下载响应
""" """
pdf_path = os.path.join(PDF_DIR, file_name) safe_name = os.path.basename(file_name)
word_path = os.path.join(WORD_DIR, file_name) if safe_name != file_name or safe_name in ("", ".", ".."):
file_path = pdf_path if os.path.exists(pdf_path) else word_path 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}") print(f"下载文件:{file_path}")
# 检查文件是否存在 # 检查文件是否存在
ext = os.path.splitext(file_name)[1].lower() ext = os.path.splitext(file_name)[1].lower()
ext = os.path.splitext(safe_name)[1].lower()
media_type = { media_type = {
".pdf": "application/pdf", ".pdf": "application/pdf",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
@ -662,8 +667,9 @@ async def download_file(file_name: str):
# 返回文件 # 返回文件
return FileResponse( return FileResponse(
path=file_path, path=file_path,
filename=file_name, filename=safe_name,
media_type=media_type media_type=media_type,
headers={"X-Content-Type-Options": "nosniff"}
) )
@ -1003,4 +1009,3 @@ if __name__ == "__main__":

1010
app63.py

File diff suppressed because it is too large Load Diff

1012
app8964.py

File diff suppressed because it is too large Load Diff

BIN
audio/test.wav Normal file

Binary file not shown.

View 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 '未配置'}")

0
config
View File

View File

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

View File

@ -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@"
}

View File

@ -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"

Binary file not shown.

View File

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

View File

@ -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())

View File

View File

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

View File

@ -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)

View File

@ -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)

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1023 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -19,16 +19,17 @@
COMMON_PROMPTS = { COMMON_PROMPTS = {
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# 格式规范系统提示词 # 格式规范系统提示词
# 用于: workflow_baike.py (generate_answer)、workflow_utils.py (stream_format_and_postprocess) # 兼容保留:当前最终回复路径已改为单次流式生成 + 本地后处理
# ------------------------------------------------------------------ # ------------------------------------------------------------------
"format_system": ( "format_system": (
"你是一个专业的格式规范助手,负责将给定的文本内容规范成美观、易读的格式。" "你是一个专业的格式规范助手,负责在不改变事实内容和资源引用的前提下,将给定文本整理成清晰、稳定、易读的格式。"
"图片资源只能来自用户提供的原始内容或参考资料中真实出现的链接;不得使用提示词中的格式示意,也不得自行构造图片链接。"
), ),
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# 格式规范用户提示词(带图片示例,用于百科问答和通用格式化) # 格式规范用户提示词(带图片示例,用于百科问答和通用格式化)
# 输入变量: raw_content # 输入变量: raw_content
# 用于: workflow_baike.py (generate_answer)、workflow_utils.py (stream_format_and_postprocess 无content_label时) # 兼容保留:当前最终回复路径已改为单次流式生成 + 本地后处理
# ------------------------------------------------------------------ # ------------------------------------------------------------------
"format_user_with_image_examples": ( "format_user_with_image_examples": (
"请将以下内容规范成美观、易读的Markdown格式\n" "请将以下内容规范成美观、易读的Markdown格式\n"
@ -37,28 +38,22 @@ COMMON_PROMPTS = {
"{raw_content}\n" "{raw_content}\n"
"\n" "\n"
"【格式规范要求】\n" "【格式规范要求】\n"
"1. 保持原始内容的完整性和准确性,**不要修改任何内容**,只调整格式\n" "1. 保持原始内容的完整性和准确性,**不要修改事实、结论、步骤或资源链接**,只调整格式\n"
# "2. 适当使用标题、段落分隔等Markdown格式使内容更易读\n" # "2. 适当使用标题、段落分隔等Markdown格式使内容更易读\n"
"2. 不得出现'''mardown'''符号\n" "2. 不得出现```markdown、'''markdown'''、格式化说明等包裹符号\n"
"3. 确保图片链接在相关位置自然展示使用,并且只输出准确格式,`![图片](/api/v1/knowledge/files/images/xxx.jpg)` \n" "3. 图片属于原始内容中的资源引用:应保留在与对应设备、部件、步骤或说明最相关的位置\n"
"4. 如果参考资料中没有图片,**不要输出任何关于图片的说明**\n" "4. 图片只输出为单个 Markdown 图片标签,格式为 `![图片](原始内容中真实存在的完整图片URL)`URL 必须来自【原始内容】,不得来自本提示词或自行构造;同一图片资源重复出现时只保留一次\n"
"5. 确保LaTeX公式格式正确$$...$$,并在 $$...$$ 前后加空格\n" "5. 图片文件名和路径必须完整照抄原始资源,不要改写、拆分、补全、连续重复或作为普通正文输出\n"
"6. 如果有代码片段,使用适当的代码块格式\n" "6. 图片规则只用于处理已有资源引用,不要围绕图片资源状态输出任何文字说明\n"
"7. 不要添加任何额外的解释或说明\n" "7. 禁止输出占位图片名、示例图片名或根据相似文字补全出的图片链接\n"
"8. 确保LaTeX公式格式正确$$...$$,并在 $$...$$ 前后加空格\n"
"9. 如果有代码片段,使用适当的代码块格式\n"
"10. 不要添加任何额外的解释或说明\n"
"\n" "\n"
"【图片输出示例】\n" "【图片处理规则】\n"
"示例一: 原始内容:`![图片](images/xxx.jpg)`\n" "- 当【原始内容】中已经出现图片路径或图片 Markdown 标签时,可以输出图片\n"
" 规范后输出:`![图片](/api/v1/knowledge/files/images/xxx.jpg)`\n" "- 输出图片时必须逐字复制原始图片的文件名和路径,只整理 Markdown 形式,不能引入任何新文件名\n"
"示例二: 原始内容:`![图片](/api/v1/knowledge/files/images/xxx.jpg)`\n" "- 本提示词中的格式说明不是可用图片资源,禁止原样输出\n"
" 规范后输出:`![图片](/api/v1/knowledge/files/images/xxx.jpg)`\n"
"示例三: 原始内容:`images/xxx.jpg`\n"
" 规范后输出:`![图片](/api/v1/knowledge/files/images/xxx.jpg)`\n"
"示例四: 原始内容:`xxx.jpg`\n"
" 规范后输出:`![图片](/api/v1/knowledge/files/images/xxx.jpg)`\n"
"示例五: 原始内容:`![油污清理](/api/v1/knowledge/files/images/ce9650912c24fa0ed693228469322ec2a735f14d9ac729a1dc87427441bad0b.jpg)`\n"
" 规范后输出:`![图片](/api/v1/knowledge/files/images/ce9650912c24fa0ed693228469322ec2a735f14d9ac729a1dc87427441bad0b.jpg)`\n"
"示例六: 原始内容:`![部分断裂的螺栓及垫片](![图片](/api/v1/knowledge/files/images/e34eb1ddbe26730c84409e02365db416e74ce9e3bf2793a2605db5a085548415.jpg)`\n"
" 规范后输出:`![图片](/api/v1/knowledge/files/images/e34eb1ddbe26730c84409e02365db416e74ce9e3bf2793a2605db5a085548415.jpg)`\n"
"\n" "\n"
"请直接输出规范后的内容:" "请直接输出规范后的内容:"
), ),
@ -66,7 +61,7 @@ COMMON_PROMPTS = {
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# 格式规范用户提示词(简化版,用于方案重新格式化,图片链接保持原样) # 格式规范用户提示词(简化版,用于方案重新格式化,图片链接保持原样)
# 输入变量: content_label, raw_content # 输入变量: content_label, raw_content
# 用于: workflow_utils.py (stream_format_and_postprocess 有content_label时) # 兼容保留:当前最终回复路径已改为单次流式生成 + 本地后处理
# ------------------------------------------------------------------ # ------------------------------------------------------------------
"format_user_simple": ( "format_user_simple": (
"请将以下{content_label}内容规范成美观、易读的Markdown格式\n" "请将以下{content_label}内容规范成美观、易读的Markdown格式\n"
@ -75,13 +70,16 @@ COMMON_PROMPTS = {
"{raw_content}\n" "{raw_content}\n"
"\n" "\n"
"【格式规范要求】\n" "【格式规范要求】\n"
"1. 保持原始内容的完整性和准确性,**不要修改任何内容**,只调整格式\n" "1. 保持原始内容的完整性和准确性,**不要修改事实、结论、步骤或资源链接**,只调整格式\n"
# "2. 适当使用标题、列表、段落分隔等Markdown格式使内容更易读\n" # "2. 适当使用标题、列表、段落分隔等Markdown格式使内容更易读\n"
"2. 不得出现'''markdown'''符号\n" "2. 不得出现```markdown、'''markdown'''、格式化说明等包裹符号\n"
"3. 确保图片链接保持原样:`![图片](URL)`\n" "3. 只保留【原始内容】中真实存在的图片链接,并放在相关内容附近;同一图片资源重复出现时只保留一次\n"
"4. 确保LaTeX公式格式正确$$...$$,并在 $$...$$ 前后加空格\n" "4. 图片文件名和路径必须完整照抄原始资源,不要改写、拆分、补全、连续重复或作为普通正文输出\n"
"5. 如果有代码片段,使用适当的代码块格式\n" "5. 图片规则只用于处理已有资源引用,不要围绕图片资源状态输出任何文字说明\n"
"6. 不要添加任何额外的解释或说明\n" "6. 禁止输出占位图片名、示例图片名或根据相似文字补全出的图片链接\n"
"7. 确保LaTeX公式格式正确$$...$$,并在 $$...$$ 前后加空格\n"
"8. 如果有代码片段,使用适当的代码块格式\n"
"9. 不要添加任何额外的解释或说明\n"
"\n" "\n"
"请直接输出规范后的内容:" "请直接输出规范后的内容:"
), ),
@ -175,7 +173,7 @@ COMMON_PROMPTS = {
"generate_response_no_rag_system": ( "generate_response_no_rag_system": (
"你是一个专业的{biz_label}助手。\n" "你是一个专业的{biz_label}助手。\n"
"虽然没有检索到相关资料,但可以根据用户提供的信息给出一般性建议。\n" "虽然没有检索到相关资料,但可以根据用户提供的信息给出一般性建议。\n"
"注意:明确告知用户这是基于一般经验的建议。" "注意:明确告知用户这是基于一般经验的建议。没有检索资料时不要输出图片链接。"
), ),
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -183,7 +181,7 @@ COMMON_PROMPTS = {
# ------------------------------------------------------------------ # ------------------------------------------------------------------
"generate_response_no_rag_simple_system": ( "generate_response_no_rag_simple_system": (
"你是一个专业的{biz_label}助手。\n" "你是一个专业的{biz_label}助手。\n"
"虽然没有检索到相关资料,但可以根据用户提供的信息给出一般性建议。" "虽然没有检索到相关资料,但可以根据用户提供的信息给出一般性建议。没有检索资料时不要输出图片链接。"
), ),
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -207,11 +205,13 @@ COMMON_PROMPTS = {
"{combined_query}\n" "{combined_query}\n"
"\n" "\n"
"【输出要求】\n" "【输出要求】\n"
"1. {image_guidance}\n" "1. 先直接回应用户当前追问,再结合已有方案和参考资料补充必要依据,避免重复整篇重写\n"
"2. 如果参考资料包含表格,请转化成不用表格的形式输出内容\n" "2. {image_guidance}\n"
"3. 如果参考资料包含 LaTeX 公式,请转为 $$...$$ 公式格式输出,并在 $$...$$ 前后加空格\n" "3. 参考资料中的图片可放在对应部件、现象、步骤或注意事项附近;同一图片资源重复出现时只引用一次;图片规则不要写入正文\n"
"4. 所有链接必须与参考资料保持一致,不得裁剪、转义或改写\n" "4. 图片、链接、公式等资源引用必须与参考资料保持一致,不得裁剪、转义、改写、拆分或补全;图片 URL 必须逐字来自【参考资料】,不得使用占位链接或自行构造\n"
"5. 不能虚构参考资料中未提及的图片、表格、零件号或步骤\n" "5. 如果参考资料包含表格,请转化成不用表格的形式输出内容\n"
"6. 如果参考资料包含 LaTeX 公式,请转为 $$...$$ 公式格式输出,并在 $$...$$ 前后加空格\n"
"7. 不能虚构参考资料中未提及的图片、表格、零件号或步骤\n"
"\n" "\n"
"请给出回复:" "请给出回复:"
), ),
@ -222,7 +222,7 @@ COMMON_PROMPTS = {
# detail_info, item_action, rag_answer # detail_info, item_action, rag_answer
# ------------------------------------------------------------------ # ------------------------------------------------------------------
"generate_response_normal": ( "generate_response_normal": (
"你是一名资深工业设备{biz_label}工程师,请根据以下信息生成{biz_label}分析与{biz_label}方案。如果参考资料中有图片,一定要把图片输出在结果中\n" "你是一名资深工业设备{biz_label}工程师,请根据以下信息生成{biz_label}分析与{biz_label}方案。回答要把文字依据、操作步骤和资料中的可用图示组织成一份可执行方案。\n"
"\n" "\n"
"【设备信息】\n" "【设备信息】\n"
"- 舷号:{ship_number}\n" "- 舷号:{ship_number}\n"
@ -235,11 +235,13 @@ COMMON_PROMPTS = {
"{rag_answer}\n" "{rag_answer}\n"
"\n" "\n"
"【输出要求】\n" "【输出要求】\n"
"1. **重要**:如果参考资料中包含图片链接(格式为 `![图片](URL)` 或 `images/xxx.jpg`),请务必在相关位置自然展示使用,如果参考资料中没有图片,**不要输出任何关于图片的说明**\n" "1. 先给出针对当前{item_label}的判断或操作目标,再展开原因/要点、处理步骤、复核与安全注意事项\n"
"2. 如果参考资料包含 LaTeX 公式、连接等,请在对应位置正常使用,不得修改或删除\n" "2. 【参考资料】中真实存在的图片可作为资料引用放在对应段落或步骤后自然展示;图片规则不要写入正文\n"
"3. 请专注于内容的准确性、完整性和逻辑性,不要担心格式的美观性\n" "3. 图片只作为资料引用输出为单个 Markdown 图片标签URL 和文件名必须逐字来自【参考资料】,不要改写、拆分、补全、连续重复或自行构造;同一图片资源重复出现时只引用一次\n"
"4. 可以适当使用简单的段落分隔但不要使用复杂的Markdown格式\n" "4. 如果参考资料包含 LaTeX 公式、链接等,请在对应位置正常使用,不得修改或删除\n"
"5. 如果参考资料包含表格,请不要输出表格,而要直接描述表格的内容\n" "5. 请专注于内容的准确性、完整性和逻辑性,不要担心格式的美观性\n"
"6. 可以适当使用简单的段落分隔但不要使用复杂的Markdown格式\n"
"7. 如果参考资料包含表格,请不要输出表格,而要直接描述表格的内容\n"
"\n" "\n"
"请给出回复:" "请给出回复:"
), ),
@ -251,7 +253,7 @@ COMMON_PROMPTS = {
# rag_answer, image_guidance # rag_answer, image_guidance
# ------------------------------------------------------------------ # ------------------------------------------------------------------
"generate_response_regenerating": ( "generate_response_regenerating": (
"你是一名资深工业设备{biz_label}工程师。用户对之前的方案有反馈,请根据反馈重新生成{biz_label}分析与{biz_label}方案。如果参考资料中有图片,一定要把图片输出在结果中\n" "你是一名资深工业设备{biz_label}工程师。用户对之前的方案有反馈,请根据反馈重新生成{biz_label}分析与{biz_label}方案,并把参考资料中的文字依据和可用图示组织成一份更贴合反馈的方案。\n"
"\n" "\n"
"【设备信息】\n" "【设备信息】\n"
"- 舷号:{ship_number}\n" "- 舷号:{ship_number}\n"
@ -269,11 +271,12 @@ COMMON_PROMPTS = {
"2. 然后给出{biz_label}方案,步骤清晰、可直接执行\n" "2. 然后给出{biz_label}方案,步骤清晰、可直接执行\n"
"3. 根据用户的反馈调整方案\n" "3. 根据用户的反馈调整方案\n"
"4. 如果参考资料信息不足,明确指出缺少什么\n" "4. 如果参考资料信息不足,明确指出缺少什么\n"
"5. {image_guidance},如果参考资料中没有图片,**不要输出任何关于图片的说明**\n" "5. {image_guidance}\n"
"6. 如果参考资料包含表格,请转化成不用表格的形式输出内容\n" "6. 参考资料中的图片可放在反馈、部件、现象或步骤对应内容附近;同一图片资源重复出现时只引用一次;图片规则不要写入正文\n"
"7. 如果参考资料包含 LaTeX 公式,请转为 $$...$$ 公式格式输出,并在 $$...$$ 前后加空格\n" "7. 图片、链接、公式等资源引用必须与参考资料保持一致,不得裁剪、转义、改写、拆分或补全;图片 URL 必须逐字来自【参考资料】,不得使用占位链接或自行构造\n"
"8. 所有链接必须与参考资料保持一致,不得裁剪、转义或改写\n" "8. 如果参考资料包含表格,请转化成不用表格的形式输出内容\n"
"9. 不能虚构参考资料中未提及的图片、表格、零件号或步骤\n" "9. 如果参考资料包含 LaTeX 公式,请转为 $$...$$ 公式格式输出,并在 $$...$$ 前后加空格\n"
"10. 不能虚构参考资料中未提及的图片、表格、零件号或步骤\n"
"\n" "\n"
"请直接输出调整后的方案:" "请直接输出调整后的方案:"
), ),
@ -282,7 +285,7 @@ COMMON_PROMPTS = {
# 生成内容系统提示词(故障诊断和操作指导共用) # 生成内容系统提示词(故障诊断和操作指导共用)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
"generate_response_content_system": ( "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 # analysis_points_text, original_rag_text, graph_results
# ------------------------------------------------------------------ # ------------------------------------------------------------------
"generate_deep_response": ( "generate_deep_response": (
"你是一名资深工业设备{biz_label}专家。用户反馈之前的方案没有解决问题,请根据多维度深度检索的资料,进行深入的{item_label}原因分析和解决方案规划\n" "你是一名资深工业设备{biz_label}专家。用户反馈之前的方案没有解决问题,请根据多维度深度检索的资料,进行深入的{item_label}原因分析和解决方案规划,并把文字依据、图示资源和图谱结果统一组织到回答中\n"
"\n" "\n"
"【设备信息】\n" "【设备信息】\n"
"- 舷号:{ship_number}\n" "- 舷号:{ship_number}\n"
@ -353,11 +356,12 @@ COMMON_PROMPTS = {
"1. 首先重新深入分析{item_label}原因,结合所有检索资料进行推理\n" "1. 首先重新深入分析{item_label}原因,结合所有检索资料进行推理\n"
"2. 给出更详细、更精准的{biz_label}方案,步骤清晰、可直接执行\n" "2. 给出更详细、更精准的{biz_label}方案,步骤清晰、可直接执行\n"
"3. 重点关注用户反馈的问题,根据用户反馈和检索资料,进行{item_label}原因分析和解决方案规划。\n" "3. 重点关注用户反馈的问题,根据用户反馈和检索资料,进行{item_label}原因分析和解决方案规划。\n"
"4. **重要**:如果参考资料中包含图片链接(格式为 `![图片](URL)` 或 `images/xxx.jpg`),请务必在相关位置自然展示使用,如果参考资料中没有图片,**不要输出任何关于图片的说明**\n" "4. 参考资料中真实存在的图片可在原因链路、部件位置、检测项或处理步骤对应内容附近自然展示;图片规则不要写入正文\n"
"5. 如果参考资料包含 LaTeX 公式、连接等,请在对应位置正常使用,不得修改或删除\n" "5. 图片只作为资料引用输出为单个 Markdown 图片标签URL 和文件名必须逐字来自参考资料,不要改写、拆分、补全、连续重复或自行构造;同一图片资源重复出现时只引用一次\n"
"6. 请专注于内容的准确性、完整性和逻辑性,不要担心格式的美观性\n" "6. 如果参考资料包含 LaTeX 公式、链接等,请在对应位置正常使用,不得修改或删除\n"
"7. 可以适当使用简单的段落分隔但不要使用复杂的Markdown格式\n" "7. 请专注于内容的准确性、完整性和逻辑性,不要担心格式的美观性\n"
"8. 如果参考资料包含表格,请不要输出表格,而要直接描述表格的内容\n" "8. 可以适当使用简单的段落分隔但不要使用复杂的Markdown格式\n"
"9. 如果参考资料包含表格,请不要输出表格,而要直接描述表格的内容\n"
"\n" "\n"
"请直接输出深度分析和方案:" "请直接输出深度分析和方案:"
), ),
@ -366,7 +370,7 @@ COMMON_PROMPTS = {
# 深度响应内容系统提示词(故障诊断和操作指导共用) # 深度响应内容系统提示词(故障诊断和操作指导共用)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
"generate_deep_response_content_system": ( "generate_deep_response_content_system": (
"你是一名资深工业设备{biz_label}专家,严格依据检索信息进行深度分析和方案生成。请专注于内容的准确性、完整性和逻辑性,如果参考资料中包含图片,请务必在回答中展示出来" "你是一名资深工业设备{biz_label}专家,严格依据检索信息进行深度分析和方案生成。请专注于内容的准确性、完整性和逻辑性,并把检索资料中的相关图片作为证据资源放在对应分析点或步骤附近。只能使用检索资料中真实存在的图片链接,不得使用占位链接或自行构造图片链接"
), ),
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -395,10 +399,11 @@ COMMON_PROMPTS = {
"2. 然后基于之前的方案进行修改和优化,给出调整后的方案\n" "2. 然后基于之前的方案进行修改和优化,给出调整后的方案\n"
"3. 重点解决用户反馈的问题\n" "3. 重点解决用户反馈的问题\n"
"4. {image_guidance}\n" "4. {image_guidance}\n"
"5. 如果之前方案包含表格,请转化成不用表格的形式输出内容\n" "5. 原方案中与反馈内容仍相关的图片应保留在对应段落或步骤附近;同一图片资源重复出现时只引用一次\n"
"6. 如果之前方案包含 LaTeX 公式,请转为 $$...$$ 公式格式输出,并在 $$...$$ 前后加空格\n" "6. 如果之前方案包含表格,请转化成不用表格的形式输出内容\n"
"7. 所有链接必须与原方案保持一致,不得裁剪、转义或改写\n" "7. 如果之前方案包含 LaTeX 公式,请转为 $$...$$ 公式格式输出,并在 $$...$$ 前后加空格\n"
"8. 不能虚构原方案中未提及的图片、表格、零件号或步骤\n" "8. 所有链接必须与原方案保持一致,不得裁剪、转义、改写、拆分或补全;不得新增原方案中不存在的图片 URL\n"
"9. 不能虚构原方案中未提及的图片、表格、零件号或步骤\n"
"\n" "\n"
"请直接输出修改后的完整方案:" "请直接输出修改后的完整方案:"
), ),
@ -422,29 +427,19 @@ BAIKE_PROMPTS = {
# 输入变量: history_messages, original_query # 输入变量: history_messages, original_query
# ------------------------------------------------------------------ # ------------------------------------------------------------------
"judge_need_retrieval": ( "judge_need_retrieval": (
"你是一个专业的问题分析助手,请判断用户的问题是否需要检索知识库才能回答。\n" "You are an intent classifier for a knowledge-base QA assistant. Decide whether the user question needs retrieval.\n"
"\n" "\n"
"【判断标准】\n" "Return true when the question asks about documents, files, knowledge-base content, equipment, systems, faults, operations, standards, procedures, meeting notes, database-related materials, or any factual/domain-specific information.\n"
"需要检索的情况:\n" "Return false only for pure greetings, thanks, cancellation/confirmation with no substantive question, or a follow-up that can be fully answered from the conversation history alone.\n"
"- 询问特定设备、技术、规程、规范的具体内容\n" "Default rule: when unsure, return true.\n"
"- 需要专业知识或特定数据支持的问题\n"
"- 询问故障诊断、维修方法等专业领域问题\n"
"- 询问历史事件、法规条款、技术标准等需要查证的内容\n"
"- 例如发动机的组成等\n"
"\n" "\n"
"不需要检索的情况:\n" "Conversation history:\n"
"- 简单的问候、寒暄\n"
"- 常识性问题(如常识性知识、简单计算等)\n"
"- 明确不需要专业知识的问题\n"
"- 对之前对话的简单追问,上下文已足够回答\n"
"\n"
"【对话历史】\n"
"{history_messages}\n" "{history_messages}\n"
"\n" "\n"
"【当前用户问题】\n" "User question:\n"
"{original_query}\n" "{original_query}\n"
"\n" "\n"
"请仅输出 \"true\"\"false\"不带引号true 表示需要检索false 表示不需要检索。" "Output only true or false."
), ),
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -452,31 +447,35 @@ BAIKE_PROMPTS = {
# 输入变量: last_user_query, history_str, original_query # 输入变量: last_user_query, history_str, original_query
# ------------------------------------------------------------------ # ------------------------------------------------------------------
"rewrite_query_with_history": ( "rewrite_query_with_history": (
"你是一个专业的检索query重写助手负责根据对话历史和当前用户问题生成**适合向量检索/RAG 的单条查询语句**。\n" "You are a query understanding assistant for a Chinese knowledge-base retrieval system.\n"
"\n" "\n"
"请严格遵守以下要求:\n" "Tasks:\n"
"1. 只融合与当前问题语义强相关的**最近几轮对话信息**,不要机械拼接全部历史。\n" "1. Rewrite the current user question into a self-contained Chinese retrieval query.\n"
"2. 消除指代和省略(例如:这个、刚才那个问题、上面说的故障等),补全成自洽、完整的描述。\n" "2. Generate 2-5 short search queries for hybrid retrieval.\n"
"3. 不要加入与用户问题无关的背景信息,不要过度扩展检索范围。\n"
"4. 输出必须是一句自然的中文问句或陈述句,便于检索;不要包含条目符号、编号或解释性文字。\n"
"5. 只输出改写后的query本身\n"
" - 不要输出说明文字\n"
" - 不要包含「改写结果」「检索query」等前缀\n"
" - 不要添加引号\n"
"\n" "\n"
"【特别提示】\n" "Rules:\n"
"- 请优先参考**上一轮用户的问题**来重写本轮query。\n" "- Resolve references and ellipsis using only relevant recent history.\n"
"- 上一轮用户的问题是:{last_user_query}\n" "- Preserve concrete entities, file names, database/table terms, equipment names, fault names, standards, procedure names, and exact keywords.\n"
"- Do not output meta instructions such as “在知识库中查找”. Output the actual search content.\n"
"- Include compact keyword queries that help exact matching, for example document titles, equipment names, or core nouns.\n"
"- The rewritten query and search queries must be in Chinese unless the original keyword is English.\n"
"\n" "\n"
"【对话历史JSON 格式,已按时间排序)】\n" "Last user question:\n"
"{last_user_query}\n"
"\n"
"Conversation history JSON:\n"
"{history_str}\n" "{history_str}\n"
"\n" "\n"
"【当前用户问题】\n" "Current user question:\n"
"{original_query}\n" "{original_query}\n"
"\n" "\n"
"请给出最终用于检索的单条中文query。" "Output ONLY valid JSON:\n"
"{{\"rewrite_query\":\"string\",\"search_queries\":[\"string\"]}}"
), ),
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# generate_suggested_replies_baike 函数:生成百科问答的建议回复问题 # generate_suggested_replies_baike 函数:生成百科问答的建议回复问题
# 输入变量: question, answer # 输入变量: question, answer
@ -509,8 +508,15 @@ BAIKE_PROMPTS = {
# generate_answer 节点 - 有检索结果时的系统提示词 # generate_answer 节点 - 有检索结果时的系统提示词
# 输入变量: domain_desc # 输入变量: domain_desc
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# ------------------------------------------------------------------
"generate_answer_with_retrieval_system": ( "generate_answer_with_retrieval_system": (
"我是一个专业的{domain_desc}知识问答助手,根据用户的问题,综合所有检索结果,提供准确、专业、完整的回答内容。如果参考资料中有图片,一定要把图片输出在结果中" "You are a professional knowledge-base assistant for {domain_desc}.\n"
"Answer ONLY from the retrieved evidence provided by the user message. Do not use prior knowledge as factual evidence.\n"
"If the evidence is insufficient, say clearly what is missing and what can be confirmed from the evidence.\n"
"Prefer concrete facts, names, parameters, steps, conclusions, and source relationships from the evidence.\n"
"When sources conflict, explain the conflict instead of inventing a merged fact.\n"
"Use images only when their URLs appear in the retrieved evidence; never fabricate or repair image URLs.\n"
"Always respond in Chinese. Do not expose internal tool names or implementation details."
), ),
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -518,28 +524,30 @@ BAIKE_PROMPTS = {
# 输入变量: extracted_text, rag_count, all_source_text # 输入变量: extracted_text, rag_count, all_source_text
# ------------------------------------------------------------------ # ------------------------------------------------------------------
"generate_answer_with_retrieval_user": ( "generate_answer_with_retrieval_user": (
"用户问题:\n"
"{extracted_text}\n" "{extracted_text}\n"
"\n" "\n"
"# 检索到的相关信息(共 {rag_count} 条):\n" "# Retrieved evidence ({rag_count} items)\n"
"\n" "\n"
"{all_source_text}\n" "{all_source_text}\n"
"\n" "\n"
"【输出要求】\n" "【输出要求】\n"
"1. **重要**:如果参考资料中包含图片链接(格式为 `![图片](URL)` 或 `images/xxx.jpg`),请务必在相关位置自然展示使用,如果参考资料中没有图片,**不要输出任何关于图片的说明**\n" "1. 先直接回答用户问题,再补充必要依据。\n"
"2. 如果参考资料包含 LaTeX 公式、连接等,请在对应位置正常使用,不得修改或删除\n" "2. 每个关键事实都必须能在 Retrieved evidence 中找到依据;不要补充资料外结论。\n"
"3. 请专注于内容的准确性、完整性和逻辑性,不要担心格式的美观性\n" "3. 参考资料中真实存在的图片可在对应段落附近展示URL 必须逐字来自资料。\n"
"4. 可以适当使用简单的段落分隔但不要使用复杂的Markdown格式\n" "4. 表格内容请转成自然语言描述,不要输出复杂表格。\n"
"5. 如果参考资料包含表格,请不要输出表格,而要直接描述表格的内容\n"
"\n" "\n"
"请给出回复:" "请给出回复:"
), ),
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# generate_answer 节点 - 无检索结果时的系统提示词 # generate_answer 节点 - 无检索结果时的系统提示词
# 输入变量: domain_desc # 输入变量: domain_desc
# ------------------------------------------------------------------ # ------------------------------------------------------------------
"generate_answer_without_retrieval_system": ( "generate_answer_without_retrieval_system": (
"我是一个专业的{domain_desc}知识问答助手,根据用户的问题,结合我的专业知识,提供准确、专业、完整的回答内容。" "我是一个专业的{domain_desc}知识问答助手,根据用户的问题,结合我的专业知识,提供准确、专业、完整的回答内容。没有检索资料时不要输出图片链接。"
), ),
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -553,6 +561,7 @@ BAIKE_PROMPTS = {
"1. 请根据您的专业知识提供准确、完整的回答\n" "1. 请根据您的专业知识提供准确、完整的回答\n"
"2. 可以适当使用简单的段落分隔但不要使用复杂的Markdown格式\n" "2. 可以适当使用简单的段落分隔但不要使用复杂的Markdown格式\n"
"3. 请专注于内容的准确性和专业性\n" "3. 请专注于内容的准确性和专业性\n"
"4. 只回答文字内容,不输出资源链接\n"
"\n" "\n"
"请给出回复:" "请给出回复:"
), ),
@ -968,7 +977,7 @@ FAULT_DIAGNOSIS_PROMPTS = {
" - 示例:\"生成报告\"\"写报告\"\"导出报告\"\"下载报告\"\"保存报告\"\n" " - 示例:\"生成报告\"\"写报告\"\"导出报告\"\"下载报告\"\"保存报告\"\n"
"\n" "\n"
"2. modify_info: 用户需要修改或补充之前提供的故障信息(舷号、设备名称、故障现象等)\n" "2. modify_info: 用户需要修改或补充之前提供的故障信息(舷号、设备名称、故障现象等)\n"
" - 示例:\"其实是...\"\"不对,应该是...\"\"我补充一下...\"\"修改设备名称是...\"\"更正一下,故障是...\"\n" " - 示例:\"其实是...\"\"不对,应该是...\"\"我补充一下...\"\"修改设备名称是...\"\"更正一下,故障是...\"\"我需要补充...\"\n"
"\n" "\n"
"3. has_error: 用户指出之前生成的维修方案有错误、需要修改或调整方案内容\n" "3. has_error: 用户指出之前生成的维修方案有错误、需要修改或调整方案内容\n"
" - 示例:\"方案有误\"\"这个步骤不对\"\"需要修改方案\"\"这个方案有问题\"\n" " - 示例:\"方案有误\"\"这个步骤不对\"\"需要修改方案\"\"这个方案有问题\"\n"

View File

@ -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())

View File

@ -1,41 +1,41 @@
""" """
工具模块 工具模块
包含RAGGraphRAGVLMASRTTS等工具 包含RAGGraphRAGVLMASRTTS等工具
""" """
from .function_tool import ( from .function_tool import (
detect_input_type, detect_input_type,
graph_rag_search, graph_rag_search,
fault_graph_rag_search, fault_graph_rag_search,
operation_graph_rag_search, operation_graph_rag_search,
rag_search_tool, rag_search_tool,
vlm_image_to_text, vlm_image_to_text,
asr_audio_to_text, asr_audio_to_text,
extract_device_and_fault, extract_device_and_fault,
image_rag_describe, image_rag_describe,
file_to_text_tool, file_to_text_tool,
fault_type_statistics_tool, fault_type_statistics_tool,
fault_frequency_statistics_tool, fault_frequency_statistics_tool,
spare_parts_statistics_tool, spare_parts_statistics_tool,
ALL_TOOLS, ALL_TOOLS,
TOOL_CATEGORIES TOOL_CATEGORIES
) )
__all__ = [ __all__ = [
"detect_input_type", "detect_input_type",
"graph_rag_search", "graph_rag_search",
"fault_graph_rag_search", "fault_graph_rag_search",
"operation_graph_rag_search", "operation_graph_rag_search",
"rag_search_tool", "rag_search_tool",
"vlm_image_to_text", "vlm_image_to_text",
"asr_audio_to_text", "asr_audio_to_text",
"extract_device_and_fault", "extract_device_and_fault",
"image_rag_describe", "image_rag_describe",
"file_to_text_tool", "file_to_text_tool",
"fault_type_statistics_tool", "fault_type_statistics_tool",
"fault_frequency_statistics_tool", "fault_frequency_statistics_tool",
"spare_parts_statistics_tool", "spare_parts_statistics_tool",
"ALL_TOOLS", "ALL_TOOLS",
"TOOL_CATEGORIES" "TOOL_CATEGORIES"
] ]

View File

@ -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@"
}

View File

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

View File

@ -37,80 +37,61 @@ def fuzzy_match_system(user_input: str, system_name: str, threshold: float = 0.6
return similarity >= threshold return similarity >= threshold
async def get_device_system_from_neo4j(device_name: str) -> 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(): if not device_name or not device_name.strip():
return "其他" return "其他"
device_name_clean = device_name.strip().lower() device_name_clean = device_name.strip().lower()
ship_number_clean = str(ship_number or "").strip().lower()
cache_key = f"{ship_number_clean}|{device_name_clean}"
if device_name_clean in neo4j_cache: if cache_key in neo4j_cache:
return neo4j_cache[device_name_clean] return neo4j_cache[cache_key]
from config import DEVICE_SYSTEM_CONFIG
endpoint = DEVICE_SYSTEM_CONFIG.get("endpoint")
timeout = DEVICE_SYSTEM_CONFIG.get("timeout", 30.0)
min_hops = DEVICE_SYSTEM_CONFIG.get("default_min_hops", 2)
max_hops = DEVICE_SYSTEM_CONFIG.get("default_max_hops", 8)
top_k = DEVICE_SYSTEM_CONFIG.get("default_top_k", 10)
if not endpoint:
result = "其他"
neo4j_cache[device_name_clean] = result
return result
try: try:
payload = { from tools.graph_tools import find_system_by_device
"device_name": device_name.strip(),
"min_hops": min_hops,
"max_hops": max_hops,
"top_k": top_k
}
async with httpx.AsyncClient(timeout=timeout) as client: result_json = await find_system_by_device(
response = await client.post( device_name=device_name.strip(),
endpoint, ship_number=ship_number,
json=payload, min_hops=2,
headers={"Content-Type": "application/json"} max_hops=8,
) top_k=10,
)
if response.status_code != 200:
result = "其他"
neo4j_cache[device_name_clean] = result
return result
result_json = response.json()
if not result_json.get("success", False):
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
if not result_json.get("success", False):
result = "其他" result = "其他"
neo4j_cache[device_name_clean] = result neo4j_cache[cache_key] = result
return 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: except Exception as e:
print(f"查询设备系统失败: {str(e)}") print(f"查询设备系统失败: {str(e)}")
result = "其他" result = "其他"
neo4j_cache[device_name_clean] = result neo4j_cache[cache_key] = result
return result return result

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -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)

View File

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

View File

@ -1,4 +1,4 @@
""" """
工具模块 工具模块
""" """

View File

@ -1,360 +1,360 @@
""" """
使用装饰器实现全栈函数调用追踪 使用装饰器实现全栈函数调用追踪
解决LangGraph无法捕获子函数内部状态的问题 解决LangGraph无法捕获子函数内部状态的问题
""" """
import functools import functools
import inspect import inspect
import asyncio import asyncio
import contextvars import contextvars
from typing import Callable, Any, Dict, Optional, List from typing import Callable, Any, Dict, Optional, List
from datetime import datetime from datetime import datetime
from collections import deque from collections import deque
# 使用 contextvars 实现请求级别的隔离,避免多个并发请求互相干扰 # 使用 contextvars 实现请求级别的隔离,避免多个并发请求互相干扰
_event_callbacks: contextvars.ContextVar[List[Callable]] = contextvars.ContextVar('event_callbacks', default=None) _event_callbacks: contextvars.ContextVar[List[Callable]] = contextvars.ContextVar('event_callbacks', default=None)
_current_stream_handler: contextvars.ContextVar[Optional[Any]] = contextvars.ContextVar('current_stream_handler', _current_stream_handler: contextvars.ContextVar[Optional[Any]] = contextvars.ContextVar('current_stream_handler',
default=None) default=None)
def register_event_callback(callback: Callable[[Dict[str, Any]], None]): def register_event_callback(callback: Callable[[Dict[str, Any]], None]):
""" """
注册事件回调函数使用 contextvars 实现请求隔离 注册事件回调函数使用 contextvars 实现请求隔离
""" """
callbacks = _event_callbacks.get() callbacks = _event_callbacks.get()
if callbacks is None: if callbacks is None:
callbacks = [] callbacks = []
_event_callbacks.set(callbacks) _event_callbacks.set(callbacks)
callbacks.append(callback) callbacks.append(callback)
def unregister_event_callback(callback: Callable[[Dict[str, Any]], None]): def unregister_event_callback(callback: Callable[[Dict[str, Any]], None]):
""" """
移除事件回调函数使用 contextvars 实现请求隔离 移除事件回调函数使用 contextvars 实现请求隔离
""" """
callbacks = _event_callbacks.get() callbacks = _event_callbacks.get()
if callbacks and callback in callbacks: if callbacks and callback in callbacks:
callbacks.remove(callback) callbacks.remove(callback)
def clear_event_callbacks(): def clear_event_callbacks():
""" """
清空所有事件回调使用 contextvars 实现请求隔离 清空所有事件回调使用 contextvars 实现请求隔离
""" """
callbacks = _event_callbacks.get() callbacks = _event_callbacks.get()
if callbacks: if callbacks:
callbacks.clear() callbacks.clear()
def get_all_callbacks(): def get_all_callbacks():
""" """
获取所有注册的回调函数使用 contextvars 实现请求隔离 获取所有注册的回调函数使用 contextvars 实现请求隔离
""" """
callbacks = _event_callbacks.get() callbacks = _event_callbacks.get()
return callbacks.copy() if callbacks else [] return callbacks.copy() if callbacks else []
def extract_function_description(func: Callable) -> str: def extract_function_description(func: Callable) -> str:
""" """
提取函数的描述docstring的第一行或前100个字符 提取函数的描述docstring的第一行或前100个字符
""" """
if func.__doc__: if func.__doc__:
doc = func.__doc__.strip() doc = func.__doc__.strip()
# 取第一行或前100个字符 # 取第一行或前100个字符
first_line = doc.split('\n')[0].strip() first_line = doc.split('\n')[0].strip()
return first_line[:100] if len(first_line) > 100 else first_line return first_line[:100] if len(first_line) > 100 else first_line
return "" return ""
def _extract_details_from_result(result: Any) -> str: def _extract_details_from_result(result: Any) -> str:
""" """
从函数返回值中提取 details顺序固定为 details 再中文 从函数返回值中提取 details顺序固定为 details 再中文
1) 先看返回的 dict 里是否有details 字段__details___detailsdetails 或键名含 "details" 1) 先看返回的 dict 里是否有details 字段__details___detailsdetails 或键名含 "details"
且其值为非空则用该值 且其值为非空则用该值
2) 若没有或为空再走原本的中文兜底从返回结构中找一段包含中文的内容作为 details 2) 若没有或为空再走原本的中文兜底从返回结构中找一段包含中文的内容作为 details
""" """
default = "完成" default = "完成"
if not result: if not result:
return default return default
# 第一步:优先用 details 类字段(仅当有值且非空时采用) # 第一步:优先用 details 类字段(仅当有值且非空时采用)
if isinstance(result, dict): if isinstance(result, dict):
for key in ("__details__", "_details", "details"): for key in ("__details__", "_details", "details"):
if key in result and result[key] is not None: if key in result and result[key] is not None:
s = str(result[key]).strip() s = str(result[key]).strip()
if s: if s:
return str(result[key]) return str(result[key])
for k, v in result.items(): for k, v in result.items():
if "details" in k.lower() and v is not None: if "details" in k.lower() and v is not None:
s = str(v).strip() s = str(v).strip()
if s: if s:
return str(v) return str(v)
# 第二步:保留原本的中文兜底 # 第二步:保留原本的中文兜底
def _contains_chinese(text: str) -> bool: def _contains_chinese(text: str) -> bool:
if not text: if not text:
return False return False
return any("\u4e00" <= c <= "\u9fff" for c in str(text)) return any("\u4e00" <= c <= "\u9fff" for c in str(text))
def _find_chinese(obj: Any, max_length: int = 200) -> Optional[str]: def _find_chinese(obj: Any, max_length: int = 200) -> Optional[str]:
if isinstance(obj, str): if isinstance(obj, str):
return (obj[:max_length] + "...") if _contains_chinese(obj) and len(obj) > max_length else ( return (obj[:max_length] + "...") if _contains_chinese(obj) and len(obj) > max_length else (
obj if _contains_chinese(obj) else None) obj if _contains_chinese(obj) else None)
if isinstance(obj, dict): if isinstance(obj, dict):
for key, value in obj.items(): for key, value in obj.items():
if "details" in key.lower(): if "details" in key.lower():
continue continue
found = _find_chinese(value, max_length) found = _find_chinese(value, max_length)
if found: if found:
return found return found
if isinstance(obj, list): if isinstance(obj, list):
for item in obj: for item in obj:
found = _find_chinese(item, max_length) found = _find_chinese(item, max_length)
if found: if found:
return found return found
return None return None
chinese = _find_chinese(result) chinese = _find_chinese(result)
return chinese if chinese else default return chinese if chinese else default
def track_function_calls(func: Callable) -> Callable: def track_function_calls(func: Callable) -> Callable:
""" """
装饰器追踪函数调用过程支持同步和异步函数 装饰器追踪函数调用过程支持同步和异步函数
Args: Args:
func: 被装饰的函数 func: 被装饰的函数
""" """
@functools.wraps(func) @functools.wraps(func)
def sync_wrapper(*args, **kwargs): def sync_wrapper(*args, **kwargs):
# 获取函数签名信息 # 获取函数签名信息
sig = inspect.signature(func) sig = inspect.signature(func)
bound_args = sig.bind(*args, **kwargs) bound_args = sig.bind(*args, **kwargs)
bound_args.apply_defaults() bound_args.apply_defaults()
func_name = func.__name__ func_name = func.__name__
module_name = func.__module__ module_name = func.__module__
# 尝试从函数参数中获取 state # 尝试从函数参数中获取 state
state = None state = None
if args and isinstance(args[0], dict): if args and isinstance(args[0], dict):
state = args[0] state = args[0]
elif 'state' in bound_args.arguments: elif 'state' in bound_args.arguments:
state = bound_args.arguments.get('state') state = bound_args.arguments.get('state')
func_description = extract_function_description(func) func_description = extract_function_description(func)
# 准备函数参数信息 # 准备函数参数信息
params = {k: v for k, v in bound_args.arguments.items()} params = {k: v for k, v in bound_args.arguments.items()}
try: try:
# 执行原函数 # 执行原函数
result = func(*args, **kwargs) result = func(*args, **kwargs)
# 构建 details优先用返回值中的 details 类字段__details__、_details、details 或键名含 details # 构建 details优先用返回值中的 details 类字段__details__、_details、details 或键名含 details
details = _extract_details_from_result(result) details = _extract_details_from_result(result)
# 合并函数执行事件(只在执行完成后发送一次) # 合并函数执行事件(只在执行完成后发送一次)
execution_event = { execution_event = {
"type": "function_execution", "type": "function_execution",
"title": func_description, "title": func_description,
"details": details, "details": details,
"result": result, # 添加函数返回结果 "result": result, # 添加函数返回结果
"timestamp": datetime.now().isoformat() "timestamp": datetime.now().isoformat()
} }
# 调用所有注册的回调(从上下文变量获取) # 调用所有注册的回调(从上下文变量获取)
callbacks = _event_callbacks.get() or [] callbacks = _event_callbacks.get() or []
for callback in callbacks: for callback in callbacks:
try: try:
callback(execution_event) callback(execution_event)
except Exception as e: except Exception as e:
print(f"回调执行出错: {e}") print(f"回调执行出错: {e}")
return result return result
except Exception as e: except Exception as e:
# 发送函数执行失败事件 # 发送函数执行失败事件
error_event = { error_event = {
"type": "function_error", "type": "function_error",
"title": func_description, "title": func_description,
"details": f"错误: {str(e)}", "details": f"错误: {str(e)}",
"timestamp": datetime.now().isoformat() "timestamp": datetime.now().isoformat()
} }
callbacks = _event_callbacks.get() or [] callbacks = _event_callbacks.get() or []
for callback in callbacks: for callback in callbacks:
try: try:
callback(error_event) callback(error_event)
except Exception as ex: except Exception as ex:
print(f"回调执行出错: {ex}") print(f"回调执行出错: {ex}")
raise raise
@functools.wraps(func) @functools.wraps(func)
async def async_wrapper(*args, **kwargs): async def async_wrapper(*args, **kwargs):
# 获取函数签名信息 # 获取函数签名信息
sig = inspect.signature(func) sig = inspect.signature(func)
bound_args = sig.bind(*args, **kwargs) bound_args = sig.bind(*args, **kwargs)
bound_args.apply_defaults() bound_args.apply_defaults()
func_name = func.__name__ func_name = func.__name__
module_name = func.__module__ module_name = func.__module__
# 尝试从函数参数中获取 state # 尝试从函数参数中获取 state
state = None state = None
if args and isinstance(args[0], dict): if args and isinstance(args[0], dict):
state = args[0] state = args[0]
elif 'state' in bound_args.arguments: elif 'state' in bound_args.arguments:
state = bound_args.arguments.get('state') state = bound_args.arguments.get('state')
func_description = extract_function_description(func) func_description = extract_function_description(func)
# 准备函数参数信息 # 准备函数参数信息
params = {k: v for k, v in bound_args.arguments.items()} params = {k: v for k, v in bound_args.arguments.items()}
try: try:
# 执行原函数 # 执行原函数
result = await func(*args, **kwargs) result = await func(*args, **kwargs)
# 构建 details优先用返回值中的 details 类字段__details__、_details、details 或键名含 details # 构建 details优先用返回值中的 details 类字段__details__、_details、details 或键名含 details
details = _extract_details_from_result(result) details = _extract_details_from_result(result)
# 合并函数执行事件(只在执行完成后发送一次) # 合并函数执行事件(只在执行完成后发送一次)
execution_event = { execution_event = {
"type": "function_execution", "type": "function_execution",
"title": func_description, "title": func_description,
"details": details, "details": details,
"result": result, # 添加函数返回结果 "result": result, # 添加函数返回结果
"timestamp": datetime.now().isoformat() "timestamp": datetime.now().isoformat()
} }
# 调用所有注册的回调(从上下文变量获取) # 调用所有注册的回调(从上下文变量获取)
callbacks = _event_callbacks.get() or [] callbacks = _event_callbacks.get() or []
for callback in callbacks: for callback in callbacks:
try: try:
callback(execution_event) callback(execution_event)
except Exception as e: except Exception as e:
print(f"回调执行出错: {e}") print(f"回调执行出错: {e}")
return result return result
except Exception as e: except Exception as e:
# 发送函数执行失败事件 # 发送函数执行失败事件
error_event = { error_event = {
"type": "function_error", "type": "function_error",
"title": func_description, "title": func_description,
"details": f"错误: {str(e)}", "details": f"错误: {str(e)}",
"timestamp": datetime.now().isoformat() "timestamp": datetime.now().isoformat()
} }
callbacks = _event_callbacks.get() or [] callbacks = _event_callbacks.get() or []
for callback in callbacks: for callback in callbacks:
try: try:
callback(error_event) callback(error_event)
except Exception as ex: except Exception as ex:
print(f"回调执行出错: {ex}") print(f"回调执行出错: {ex}")
raise raise
# 根据函数是否为协程函数返回对应的装饰器 # 根据函数是否为协程函数返回对应的装饰器
if asyncio.iscoroutinefunction(func): if asyncio.iscoroutinefunction(func):
return async_wrapper return async_wrapper
else: else:
return sync_wrapper return sync_wrapper
# 事件处理器 # 事件处理器
def console_event_handler(event: Dict[str, Any]): def console_event_handler(event: Dict[str, Any]):
""" """
控制台事件处理器 控制台事件处理器
""" """
event_type = event["type"] event_type = event["type"]
title = event.get("title", "") title = event.get("title", "")
details = event.get("details", "") details = event.get("details", "")
if event_type == "function_execution": if event_type == "function_execution":
print(f"✅ 函数执行: {title}") print(f"✅ 函数执行: {title}")
if details: if details:
print(f" 详情: {details}") print(f" 详情: {details}")
elif event_type == "function_error": elif event_type == "function_error":
print(f"❌ 函数错误: {title}") print(f"❌ 函数错误: {title}")
if details: if details:
print(f" 详情: {details}") print(f" 详情: {details}")
class StreamEventHandler: class StreamEventHandler:
""" """
流式事件处理器将装饰器事件放入队列供流式输出使用 流式事件处理器将装饰器事件放入队列供流式输出使用
""" """
def __init__(self, queue: asyncio.Queue): def __init__(self, queue: asyncio.Queue):
self.queue = queue self.queue = queue
def __call__(self, event: Dict[str, Any]): def __call__(self, event: Dict[str, Any]):
""" """
处理事件将事件放入队列 处理事件将事件放入队列
注意这是同步方法但队列是异步的需要特殊处理 注意这是同步方法但队列是异步的需要特殊处理
""" """
try: try:
# 使用线程安全的方式将事件放入队列 # 使用线程安全的方式将事件放入队列
# 如果队列已满,使用 put_nowait 并忽略错误 # 如果队列已满,使用 put_nowait 并忽略错误
try: try:
self.queue.put_nowait(event) self.queue.put_nowait(event)
except asyncio.QueueFull: except asyncio.QueueFull:
# 队列已满,跳过此事件(不应该发生,因为队列大小足够) # 队列已满,跳过此事件(不应该发生,因为队列大小足够)
pass pass
except Exception as e: except Exception as e:
# 忽略错误,避免影响主流程 # 忽略错误,避免影响主流程
pass pass
def send_stream_content(self, content: str, title: str = ""): def send_stream_content(self, content: str, title: str = ""):
""" """
发送流式内容事件 发送流式内容事件
Args: Args:
content: 流式内容片段 content: 流式内容片段
title: 函数描述/标题可选 title: 函数描述/标题可选
""" """
try: try:
event = { event = {
"type": "stream_content", "type": "stream_content",
"content": content, "content": content,
"title": title, "title": title,
"timestamp": datetime.now().isoformat() "timestamp": datetime.now().isoformat()
} }
self.queue.put_nowait(event) self.queue.put_nowait(event)
except Exception as e: except Exception as e:
# 忽略错误,避免影响主流程 # 忽略错误,避免影响主流程
pass pass
def create_stream_event_handler() -> tuple[StreamEventHandler, asyncio.Queue]: def create_stream_event_handler() -> tuple[StreamEventHandler, asyncio.Queue]:
""" """
创建流式事件处理器和对应的队列使用 contextvars 实现请求隔离 创建流式事件处理器和对应的队列使用 contextvars 实现请求隔离
Returns: Returns:
(handler, queue): 事件处理器和事件队列 (handler, queue): 事件处理器和事件队列
""" """
queue = asyncio.Queue(maxsize=1000) # 设置足够大的队列大小 queue = asyncio.Queue(maxsize=1000) # 设置足够大的队列大小
handler = StreamEventHandler(queue) handler = StreamEventHandler(queue)
_current_stream_handler.set(handler) # 保存到上下文变量 _current_stream_handler.set(handler) # 保存到上下文变量
return handler, queue return handler, queue
def get_current_stream_handler() -> Optional[StreamEventHandler]: def get_current_stream_handler() -> Optional[StreamEventHandler]:
""" """
获取当前活动的流式事件处理器使用 contextvars 实现请求隔离 获取当前活动的流式事件处理器使用 contextvars 实现请求隔离
Returns: Returns:
当前流式事件处理器如果没有则返回 None 当前流式事件处理器如果没有则返回 None
""" """
return _current_stream_handler.get() return _current_stream_handler.get()
def clear_current_stream_handler(): def clear_current_stream_handler():
""" """
清除当前活动的流式事件处理器使用 contextvars 实现请求隔离 清除当前活动的流式事件处理器使用 contextvars 实现请求隔离
""" """
_current_stream_handler.set(None) _current_stream_handler.set(None)

View File

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

View File

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

View File

@ -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
View 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

View File

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

View File

@ -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)
- ![alt]`url`
- ! `url`
返回 [(start, end, url), ...]
"""
results = []
# 格式1: ![alt](url)
for m in re.finditer(r'!\[([^\]]*)\]\(([^)]+)\)', line_content):
url = m.group(2).strip()
if url.startswith('`') and url.endswith('`'):
url = url[1:-1]
results.append((m.start(), m.end(), url))
# 格式2: ![alt]`url` (未被格式1匹配的)
for m in re.finditer(r'!\[([^\]]*)\]\s*`([^`]+)`', line_content):
url = m.group(2).strip()
already = any(s <= m.start() and e >= m.end() for s, e, _ in results)
if not already:
results.append((m.start(), m.end(), url))
# 格式3: ! `url` (模型常见的破损格式)
for m in re.finditer(r'!\s*`([^`]+\.(?:jpg|jpeg|png|gif|bmp|webp))`', line_content, re.IGNORECASE):
url = m.group(1).strip()
already = any(s <= m.start() and e >= m.end() for s, e, _ in results)
if not already:
results.append((m.start(), m.end(), url))
results.sort(key=lambda x: x[0])
return results
def add_table(self, lines):
try:
rows = []
for line in lines:
line = line.strip("|")
cells = [c.strip() for c in line.split("|")]
rows.append(cells)
if not rows:
return
header = rows[0]
data_start_index = 1
if len(rows) > 1 and all(c.startswith('-') or c == '' for c in rows[1]):
data_start_index = 2
data = rows[data_start_index:]
max_cols = len(header)
table = self.doc.add_table(rows=len(data) + 1, cols=max_cols)
table.style = "Table Grid"
for i, cell in enumerate(header):
if i < max_cols:
table.rows[0].cells[i].text = cell
for r, row in enumerate(data):
for c, val in enumerate(row):
if c < max_cols:
table.rows[r + 1].cells[c].text = val
logger.info("表格添加成功")
except Exception as e:
logger.error(f"添加表格失败: {e}")
def _add_horizontal_rule(self):
p = self.doc.add_paragraph()
pPr = p._element.get_or_add_pPr()
pBdr = p._element.makeelement(qn('w:pBdr'), {})
bottom = p._element.makeelement(qn('w:bottom'), {
qn('w:val'): 'single',
qn('w:sz'): '6',
qn('w:space'): '1',
qn('w:color'): 'CCCCCC',
})
pBdr.append(bottom)
pPr.append(pBdr)
def parse(self, markdown):
lines = markdown.split("\n")
table_buffer = []
block_formula = []
in_block_formula = False
i = 0
while i < len(lines):
line = lines[i]
line_stripped = line.rstrip()
line_content = line_stripped.lstrip()
# 1. 处理块级公式
if line_content.startswith("$$"):
if not in_block_formula:
remaining = line_content[2:]
close_idx = remaining.find("$$")
if close_idx >= 0:
formula = remaining[:close_idx].strip()
after_formula = remaining[close_idx + 2:].strip()
p = self.doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
if not (self._omml_available and self._add_omml_to_paragraph(p, formula)):
self.add_paragraph(f"[公式: {formula}]")
if after_formula:
self.add_paragraph(after_formula)
else:
in_block_formula = True
block_formula = []
if remaining.strip():
block_formula.append(remaining.strip())
else:
remaining = line_content[2:]
in_block_formula = False
formula = "".join(block_formula).strip()
p = self.doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
if not (self._omml_available and self._add_omml_to_paragraph(p, formula)):
self.add_paragraph(f"[公式: {formula}]")
if remaining.strip():
self.add_paragraph(remaining.strip())
i += 1
continue
if in_block_formula:
block_formula.append(line)
i += 1
continue
# 2. 处理表格
if line_content.startswith("|") and line_content.endswith("|"):
table_buffer.append(line_stripped)
i += 1
continue
else:
if table_buffer:
self.add_table(table_buffer)
table_buffer = []
# 空行跳过
if not line_stripped:
i += 1
continue
# 3. 水平分隔线
if re.match(r'^\s*[-*_]{3,}\s*$', line_stripped):
self._add_horizontal_rule()
i += 1
continue
# 4. 标题
heading_match = re.match(r'^(#{1,3})\s+(.*)', line_content)
if heading_match:
level = len(heading_match.group(1))
text = heading_match.group(2)
self.add_heading(text, level)
i += 1
continue
# 5. 有序列表(带子列表支持)
list_match_ordered = re.match(r'^(\d+)\.\s*(.*)', line_content)
if list_match_ordered:
number = int(list_match_ordered.group(1))
text = list_match_ordered.group(2)
self.add_list(text, numbered=True, number=number)
i += 1
while i < len(lines):
next_line = lines[i]
next_stripped = next_line.rstrip()
sub_match = re.match(r'^[\s\t]+[-*]\s*(.*)', next_stripped)
if sub_match:
sub_text = sub_match.group(1)
self.add_sub_list(sub_text)
i += 1
elif (next_stripped.startswith(' ') or next_stripped.startswith('\t')):
sub_content = next_stripped.strip()
if sub_content and not re.match(r'^\d+\.\s', sub_content):
self.add_sub_list(sub_content)
i += 1
else:
break
else:
break
continue
# 6. 无序列表
is_bold_line = line_content.startswith('**')
if not is_bold_line:
list_match_unordered = re.match(r'^\s*[-*]\s+(.*)', line_stripped)
if not list_match_unordered:
m = re.match(r'^\s*-([^-].*)$', line_stripped)
if m and m.group(1).strip():
list_match_unordered = m
if list_match_unordered:
text = list_match_unordered.group(1).strip()
if text:
self.add_list(text, numbered=False)
i += 1
continue
# 7. 图片链接 - 统一提取所有格式的图片URL
img_urls = self._extract_image_urls(line_content)
if img_urls:
has_only_img = (len(img_urls) == 1 and
line_content.strip().startswith('!') and
img_urls[0][0] == 0)
if has_only_img:
_, _, url = img_urls[0]
self.add_image(url)
else:
p = self.doc.add_paragraph()
last_end = 0
for start, end, url in img_urls:
before_text = line_content[last_end:start].strip()
if before_text:
self._add_rich_text_with_formulas(p, before_text)
self.add_image(url, width_inches=4.5)
p = self.doc.add_paragraph()
last_end = end
after_text = line_content[last_end:].strip()
if after_text:
self._add_rich_text_with_formulas(p, after_text)
i += 1
continue
# 8. 普通链接
if line_content.startswith("["):
match = re.match(r'\[(.*?)\]\((.*?)\)', line_content)
if match:
p = self.doc.add_paragraph()
run = p.add_run(match.group(1))
run.font.color.rgb = RGBColor(0, 0, 255)
run.font.underline = True
i += 1
continue
# 9. 普通段落
p = self.doc.add_paragraph()
indent_chars = 0
temp_line = line_stripped
while temp_line.startswith('\u3000'):
indent_chars += 1
temp_line = temp_line[1:]
if indent_chars >= 2:
p.paragraph_format.first_line_indent = Inches(0.5)
elif indent_chars == 1:
p.paragraph_format.first_line_indent = Inches(0.25)
self._add_rich_text_with_formulas(p, line_content)
i += 1
if table_buffer:
self.add_table(table_buffer)
def generate(self, markdown, title=None, output=None, images=None):
try:
if title:
logger.info(f"添加文档标题: {title}")
p = self.doc.add_paragraph()
run = p.add_run(title)
self.set_font(run, 20, True)
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
logger.info("开始解析 Markdown 内容")
self.parse(markdown)
if images:
self._append_images_section(images)
if not output:
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
output = f"report_{timestamp}.docx"
logger.info(f"正在保存文件: {output}")
self.doc.save(output)
if os.path.exists(output):
file_size = os.path.getsize(output)
logger.info(f"文件保存成功: {output}, 大小: {file_size} bytes")
return output
else:
logger.error(f"文件保存后未找到: {output}")
raise FileNotFoundError(f"File {output} was not created.")
except Exception as e:
logger.error(f"生成报告过程中发生严重错误: {e}", exc_info=True)
raise
def _append_images_section(self, images):
try:
self.add_heading("附件图片", level=2)
for idx, img in enumerate(images, 1):
if isinstance(img, dict):
url = img.get('url', '')
summary = img.get('summary', '')
else:
url = str(img)
summary = ''
p = self.doc.add_paragraph()
run = p.add_run(f"图片{idx}")
self.set_font(run, bold=True)
if url:
self.add_image(url, width_inches=4)
if summary:
p = self.doc.add_paragraph()
run = p.add_run(f"说明:{summary}")
self.set_font(run, size=10)
self.doc.add_paragraph()
logger.info(f"图片附件部分添加完成,共 {len(images)} 张图片")
except Exception as e:
logger.error(f"添加图片附件部分失败: {e}")
def generate_report_word(markdown, title=None, output=None, images=None):
logger.info("=== 开始生成报告 ===")
try:
generator = MarkdownWordGenerator()
if images:
logger.info(f"准备插入 {len(images)} 张图片")
file_path = generator.generate(markdown, title=title, output=output, images=images)
logger.info(f"=== 报告生成完成: {file_path} ===")
return file_path
except Exception as e:
logger.critical(f"报告生成失败: {e}")
raise
if __name__ == "__main__":
md = r"""
## **测试报告 - OMML公式和图片**
### **一、行内公式**
温度要求 $ \leqslant 40 ^ { \circ } \mathrm { C } $不得超过
公差范围 $ \geqslant 0.05 \ \mathrm { mm } $ 以内合格
### **二、列表中的公式**
1. 温度 $ \leqslant 40 ^ { \circ } \mathrm { C } $ 要求
2. 间隙 $ \leqslant 0.05 \ \mathrm { mm } $ 标准
- 清洁轴颈 $ \geqslant 0.05 \ \mathrm { mm } $
### **四、图片测试**
![图片](http://192.168.0.46:11000/api/v1/knowledge/files/images/56b1c0a02ff03d9d202627e88603e6671d3f193511d98a9fab0daf8687e6a9fa.jpg)
"""
try:
result_path = generate_report_word(md)
print(f"报告已生成,路径: {result_path}")
except Exception as e:
print(f"程序执行出错: {e}")
import traceback
traceback.print_exc()

View File

@ -1,158 +1,158 @@
""" """
工作流注册表 - 主代理与 app 共用 工作流注册表 - 主代理与 app 共用
主代理阶段四根据此配置路由到所有注册的子 agent 主代理阶段四根据此配置路由到所有注册的子 agent
app 据此执行对应工作流 app 据此执行对应工作流
添加新工作流时在此配置 WORKFLOW_CONFIG ROUTE_DESCRIPTIONS 添加新工作流时在此配置 WORKFLOW_CONFIG ROUTE_DESCRIPTIONS
""" """
# ============================================================================ # ============================================================================
# =============== 工作流配置区域 - 添加新工作流时只需修改这里 =============== # =============== 工作流配置区域 - 添加新工作流时只需修改这里 ===============
# ============================================================================ # ============================================================================
# 格式: # 格式:
# "route_flag": { # "route_flag": {
# "module": "workflows.workflow_xxx", # "module": "workflows.workflow_xxx",
# "function": "run_xxx_workflow", # "function": "run_xxx_workflow",
# "is_async": True/False, # "is_async": True/False,
# } # }
# ============================================================================ # ============================================================================
WORKFLOW_CONFIG = { WORKFLOW_CONFIG = {
"fault_diagnosis": { "fault_diagnosis": {
"module": "workflows.workflow_fault_diagnosis", "module": "workflows.workflow_fault_diagnosis",
"function": "run_fault_diagnosis_workflow", "function": "run_fault_diagnosis_workflow",
"is_async": True, "is_async": True,
}, },
"qa": { "qa": {
"module": "workflows.workflow_qa", "module": "workflows.workflow_qa",
"function": "run_qa_workflow", "function": "run_qa_workflow",
"is_async": True, "is_async": True,
}, },
"问题排查": { "问题排查": {
"module": "workflows.workflow_fault_diagnosis", "module": "workflows.workflow_fault_diagnosis",
"function": "run_fault_diagnosis_workflow", "function": "run_fault_diagnosis_workflow",
"is_async": True, "is_async": True,
}, },
"spare_parts_statistics": { "spare_parts_statistics": {
"module": "workflows.workflow_spare_parts_statistics", "module": "workflows.workflow_spare_parts_statistics",
"function": "run_spare_parts_statistics_workflow", "function": "run_spare_parts_statistics_workflow",
"is_async": True, "is_async": True,
}, },
"rules": { "rules": {
"module": "workflows.workflow_qa", "module": "workflows.workflow_qa",
"function": "run_qa_workflow", "function": "run_qa_workflow",
"is_async": True, "is_async": True,
}, },
"other": { "other": {
"module": "workflows.workflow_fault_diagnosis", "module": "workflows.workflow_fault_diagnosis",
"function": "run_fault_diagnosis_workflow", "function": "run_fault_diagnosis_workflow",
"is_async": True, "is_async": True,
# #
# "module": "workflows.workflow_other", # "module": "workflows.workflow_other",
# "function": "run_other_workflow", # "function": "run_other_workflow",
# "is_async": True, # "is_async": True,
}, },
"report":{ "report":{
# "module": "workflows.workflow_qa_report", # "module": "workflows.workflow_qa_report",
# "function": "run_qa_report_agent", # "function": "run_qa_report_agent",
# "is_async": True, # "is_async": True,
"module": "workflows.workflow_fault_diagnosis", "module": "workflows.workflow_fault_diagnosis",
"function": "run_fault_diagnosis_workflow", "function": "run_fault_diagnosis_workflow",
"is_async": True, "is_async": True,
}, },
"百科问答":{ "百科问答":{
"module": "workflows.workflow_baike", "module": "workflows.workflow_baike",
"function": "run_baike_workflow", "function": "run_baike_workflow",
"is_async": True, "is_async": True,
}, },
"操作使用":{ "操作使用":{
"module": "workflows.workflow_operate", "module": "workflows.workflow_operate",
"function": "run_operate_workflow", "function": "run_operate_workflow",
"is_async": True, "is_async": True,
}, },
"统计":{ "统计":{
"module": "workflows.workflow_statistics", "module": "workflows.workflow_statistics",
"function": "run_statistics_workflow", "function": "run_statistics_workflow",
"is_async": True, "is_async": True,
} }
} }
# 路由标志 -> 任务分类描述(供主代理阶段四 LLM 使用) # 路由标志 -> 任务分类描述(供主代理阶段四 LLM 使用)
ROUTE_DESCRIPTIONS = { ROUTE_DESCRIPTIONS = {
"fault_diagnosis": """ "fault_diagnosis": """
- 特征用户描述**当前或近期发生的设备异常报警性能下降等具体故障现象**需要分析原因或定位问题 - 特征用户描述**当前或近期发生的设备异常报警性能下降等具体故障现象**需要分析原因或定位问题
- 关键词报警异常异响冒烟过热无法启动突然停机振动大压力低温度高故障代码不工作 - 关键词报警异常异响冒烟过热无法启动突然停机振动大压力低温度高故障代码不工作
- 示例 - 示例
主机滑油压力突然降到0.2MPa并报警 主机滑油压力突然降到0.2MPa并报警
柴油机启动后冒黑烟且转速不稳 柴油机启动后冒黑烟且转速不稳
舵机发出异常噪音疑似齿轮损坏 舵机发出异常噪音疑似齿轮损坏
- 注意若历史记录中包含请补充需要更多信息通常属于此类因已聚焦具体故障场景 - 注意若历史记录中包含请补充需要更多信息通常属于此类因已聚焦具体故障场景
当用户问题中只有舷号设备或故障现象时属于此类舷号一般为连续的阿拉伯数字如101,"111","122","163""16","17","554","602","545","985","31","3261","799","905","781",或者数字后加舰比如101","199","18"等。 当用户问题中只有舷号设备或故障现象时属于此类舷号一般为连续的阿拉伯数字如101,"111","122","163""16","17","554","602","545","985","31","3261","799","905","781",或者数字后加舰比如101","199","18"等。
""", """,
"qa": """ "qa": """
- 特征询问定义原理结构功能分类标准配置常见类型安全规范等**一般性知识**也包括对专业术语现象部件作用的解释 - 特征询问定义原理结构功能分类标准配置常见类型安全规范等**一般性知识**也包括对专业术语现象部件作用的解释
**即使问题表述模糊不完整缺少上下文只要涉及船舶机械电气动力等技术内容均优先归为此类** **即使问题表述模糊不完整缺少上下文只要涉及船舶机械电气动力等技术内容均优先归为此类**
- 关键词什么是如何工作为什么请问介绍解释有哪些常见典型一般作用组成是不是叫什么 - 关键词什么是如何工作为什么请问介绍解释有哪些常见典型一般作用组成是不是叫什么
- 示例 - 示例
船舶主发动机有哪些常见故障类型 船舶主发动机有哪些常见故障类型
涡轮增压器的工作原理是什么 涡轮增压器的工作原理是什么
主机由哪些主要部件组成 主机由哪些主要部件组成
什么是MAN B&W柴油机 什么是MAN B&W柴油机
- 注意 - 注意
**本类作为技术相关问题的默认兜底类别**凡涉及设备系统部件工况参数等技术语境的问题若无法明确归入 fault_diagnosis / repair / spare_parts_statistics / rules则优先视为 qa **本类作为技术相关问题的默认兜底类别**凡涉及设备系统部件工况参数等技术语境的问题若无法明确归入 fault_diagnosis / repair / spare_parts_statistics / rules则优先视为 qa
""", """,
"repair": """ "repair": """
- 特征用户明确请求**维修方法操作步骤检修流程更换方案或现场处理建议**通常在已知故障或需预防性维护时提出 - 特征用户明确请求**维修方法操作步骤检修流程更换方案或现场处理建议**通常在已知故障或需预防性维护时提出
- 关键词怎么修如何维修维修步骤检修方法更换拆卸安装处理修复维护保养调整 - 关键词怎么修如何维修维修步骤检修方法更换拆卸安装处理修复维护保养调整
- 示例 - 示例
主机喷油器漏油怎么更换 主机喷油器漏油怎么更换
给出主机缸套磨损的维修方案 给出主机缸套磨损的维修方案
如何拆卸增压器进行检修 如何拆卸增压器进行检修
""", """,
"spare_parts_statistics": """ "spare_parts_statistics": """
- 特征用户需要对**备品备件的数量使用记录库存消耗趋势预测需求或成本分析**进行查询或统计 - 特征用户需要对**备品备件的数量使用记录库存消耗趋势预测需求或成本分析**进行查询或统计
- 关键词备件备品库存数量统计预测分析消耗需求清单台账寿命更换周期用了多少还剩多少 - 关键词备件备品库存数量统计预测分析消耗需求清单台账寿命更换周期用了多少还剩多少
- 示例 - 示例
统计过去一年主机喷油嘴的使用数量 统计过去一年主机喷油嘴的使用数量
预测下季度所需空冷器备件数量 预测下季度所需空冷器备件数量
主机活塞环的平均更换周期是多少 主机活塞环的平均更换周期是多少
- 注意若仅问某个部件是不是备件这个备件叫什么名字属于知识问答qa而非本类 - 注意若仅问某个部件是不是备件这个备件叫什么名字属于知识问答qa而非本类
""", """,
"rules": """ "rules": """
- 特征用户询问与船舶设备维修检验安全环保保密等相关的**国家法规军用标准如GJB行业规范船级社要求或管理制度** - 特征用户询问与船舶设备维修检验安全环保保密等相关的**国家法规军用标准如GJB行业规范船级社要求或管理制度**
- 关键词法规标准规范规定合规条例依据要求准则制度认证资质船级社CCSIMOSOLASMARPOL资格 - 关键词法规标准规范规定合规条例依据要求准则制度认证资质船级社CCSIMOSOLASMARPOL资格
- 示例 - 示例
船舶主机修理需遵循哪些GJB标准 船舶主机修理需遵循哪些GJB标准
IMO对主机排放有哪些强制要求 IMO对主机排放有哪些强制要求
舰船维修单位需要哪些资质 舰船维修单位需要哪些资质
保密资格申请流程是什么 保密资格申请流程是什么
- 注意若问题提到标准里有没有但未指明具体法规/标准名称且无法规标准等关键词可先归为 qa一旦明确提及法规标准规范SOLAS则归为 rules - 注意若问题提到标准里有没有但未指明具体法规/标准名称且无法规标准等关键词可先归为 qa一旦明确提及法规标准规范SOLAS则归为 rules
""", """,
"other": """ "other": """
- 特征**完全不涉及新的技术问题故障诊断维修请求备件统计法规查询或报告生成**而是针对**已有对话内容方案步骤或文本的反馈修正补充顺序调整或格式优化**典型场景包括 - 特征**完全不涉及新的技术问题故障诊断维修请求备件统计法规查询或报告生成**而是针对**已有对话内容方案步骤或文本的反馈修正补充顺序调整或格式优化**典型场景包括
指出之前提供的操作步骤有误异物清理步骤有误 指出之前提供的操作步骤有误异物清理步骤有误
要求调整操作顺序这一步应当先怎样然后再怎么操作最后 要求调整操作顺序这一步应当先怎样然后再怎么操作最后
对生成内容提出修改意见步骤有误/有补充请重写第三点 对生成内容提出修改意见步骤有误/有补充请重写第三点
请求优化语言格式或结构请用更简洁的方式表达 请求优化语言格式或结构请用更简洁的方式表达
闲聊非技术性确认或与当前技术任务无关的交互 闲聊非技术性确认或与当前技术任务无关的交互
- 注意 - 注意
**只要用户意图是修正补充或调整已有内容而非提出新问题即使涉及技术术语也应归为 other** **只要用户意图是修正补充或调整已有内容而非提出新问题即使涉及技术术语也应归为 other**
不要将此类反馈误判为 repair qa它们不是在问该怎么修原理是什么而是在说你刚才说的不对/不全 不要将此类反馈误判为 repair qa它们不是在问该怎么修原理是什么而是在说你刚才说的不对/不全
此类通常出现在多轮对话中是对前文输出的回应 此类通常出现在多轮对话中是对前文输出的回应
""", """,
"report": """ "report": """
- 特征用户明确要求**生成整理导出或汇总**某种形式的文档报表清单或总结材料通常涉及将分散的故障记录维修历史备件数据或问答内容整合成结构化文本如日报周报故障分析报告维修总结备件消耗表等 - 特征用户明确要求**生成整理导出或汇总**某种形式的文档报表清单或总结材料通常涉及将分散的故障记录维修历史备件数据或问答内容整合成结构化文本如日报周报故障分析报告维修总结备件消耗表等
- 关键词生成报告写总结导出表格整理清单汇总数据形成文档日报周报月报分析报告维修记录单台账报表 - 关键词生成报告写总结导出表格整理清单汇总数据形成文档日报周报月报分析报告维修记录单台账报表
- 示例 - 示例
请根据本周的故障记录生成一份主机系统运行分析报告 请根据本周的故障记录生成一份主机系统运行分析报告
总结这次对话 总结这次对话
导出过去一年的备件消耗统计报告 导出过去一年的备件消耗统计报告
帮我写一份关于此次主机停机事故的详细处理总结 帮我写一份关于此次主机停机事故的详细处理总结
""" """
} }
VALID_ROUTE_FLAGS = tuple(WORKFLOW_CONFIG.keys()) VALID_ROUTE_FLAGS = tuple(WORKFLOW_CONFIG.keys())

View File

@ -1,20 +1,20 @@
""" """
工作流模块 工作流模块
包含所有工作流Agent 包含所有工作流Agent
""" """
from .workflow_fault_diagnosis import run_fault_diagnosis_workflow from .workflow_fault_diagnosis import run_fault_diagnosis_workflow
# 其他工作流暂未实现,待后续添加 # 其他工作流暂未实现,待后续添加
# from .workflow_repair import run_repair_workflow # from .workflow_repair import run_repair_workflow
# from .workflow_statistics import run_statistics_workflow # from .workflow_statistics import run_statistics_workflow
# from .workflow_qa import run_qa_workflow # from .workflow_qa import run_qa_workflow
# from .workflow_report import run_report_workflow # from .workflow_report import run_report_workflow
__all__ = [ __all__ = [
"run_fault_diagnosis_workflow", "run_fault_diagnosis_workflow",
# "run_repair_workflow", # "run_repair_workflow",
# "run_statistics_workflow", # "run_statistics_workflow",
# "run_qa_workflow", # "run_qa_workflow",
# "run_report_workflow" # "run_report_workflow"
] ]

View File

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

View File

@ -1,591 +1,544 @@
""" """
工作流普通问答Agent使用LangGraph 百科问答工作流
输入文本转化后和原文本query图片描述语音文本
步骤 核心链路借鉴 WeKnora
1. 使用RAG和GraphRAG检索相关信息 1. 意图判断默认偏向检索
2. 生成回答 2. 基于历史重写检索 query并生成多个检索变体
使用 OpenaiAPI 方法调用大模型尽可能使用异步 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 asyncio
import json import json
import re import os
import random 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): class QAState(TypedDict):
"""普通问答工作流状态""" extracted_text: str
extracted_text: str # 已提取的文本(统一接口,只接收文本)
combined_query: str combined_query: str
retrieval_query: str # 用于RAG/图谱检索的重写后query retrieval_query: str
route_flag: str # 路由标识 retrieval_queries: List[str]
history_message: str # 历史消息JSON格式 route_flag: str
history_messages: List[Dict[str, Any]] # 过滤后的历史消息列表 history_message: str
need_retrieval: Optional[bool] # 是否需要检索 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]: def get_baike_history_context(state: QAState) -> Dict[str, Any]:
""" return init_history(state.get("history_message", "") or "")
知识问答步骤获取问答类历史信息
问答工作流只关注历史问答记录用于提供上下文
"""
history_message = state.get("history_message", "") or ""
return init_history(history_message)
async def judge_need_retrieval(state: QAState) -> Dict[str, Any]: async def judge_need_retrieval(state: QAState) -> Dict[str, Any]:
"""
判断用户问题是否需要检索知识库
"""
original_query = (state.get("combined_query") or state.get("extracted_text") or "").strip() original_query = (state.get("combined_query") or state.get("extracted_text") or "").strip()
history_messages = state.get("history_messages", []) or [] history_messages = state.get("history_messages", []) or []
prompt = BAIKE_PROMPTS["judge_need_retrieval"].format( prompt = BAIKE_PROMPTS["judge_need_retrieval"].format(
history_messages=history_messages if history_messages else '', history_messages=history_messages if history_messages else "",
original_query=original_query original_query=original_query,
) )
need_retrieval = True substantive_query = _looks_like_substantive_query(original_query)
need_retrieval = substantive_query
try: try:
result = await OpenaiAPI.open_api_chat_without_thinking(prompt, model=None) result = await OpenaiAPI.open_api_chat_without_thinking(prompt, model=None)
if result: if not substantive_query and result and result.strip().lower() == "false":
result = result.strip().lower() need_retrieval = False
if result == "false": except Exception as exc:
need_retrieval = False print(f"判断是否需要检索失败: {exc}")
except Exception as e:
print(f"判断是否需要检索失败: {e}")
need_retrieval = True
print(f"是否需要检索: {need_retrieval}") print(f"是否需要检索: {need_retrieval}")
return {"need_retrieval": 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]: 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() original_query = (state.get("combined_query") or state.get("extracted_text") or "").strip()
history_messages = state.get("history_messages", []) or [] history_messages = state.get("history_messages", []) or []
# 控制历史长度:只保留最近的若干条,避免上下文过长 trimmed_history = history_messages[-DEFAULT_HISTORY_MAX_MESSAGES:] if history_messages else []
trimmed_history = history_messages[-8:] if history_messages else [] history_str = _json_dumps(trimmed_history)
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如果有
last_user_query = "" last_user_query = ""
if trimmed_history: for msg in reversed(trimmed_history):
# 从后往前找最近一条 role == "user" 的消息 if msg.get("role") == "user":
for msg in reversed(trimmed_history): last_user_query = str(msg.get("content") or "").strip()
if msg.get("role") == "user": break
last_user_query = msg.get("content", "").strip()
break if not original_query:
return {"retrieval_query": "", "retrieval_queries": []}
prompt = BAIKE_PROMPTS["rewrite_query_with_history"].format( 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, history_str=history_str,
original_query=original_query original_query=original_query,
) )
rewritten_query = original_query rewritten_query = original_query
query_variants: List[str] = []
try: try:
result = await OpenaiAPI.open_api_chat_without_thinking(prompt, model=None) raw = await OpenaiAPI.open_api_chat_without_thinking(prompt, model=None, json_output=True)
if result: data = _parse_json_object(raw)
candidate = result.strip().strip("“”\"'") if data:
# 只在模型返回的内容明显为单行短文本时替换,避免异常输出 candidate = str(data.get("rewrite_query") or "").strip()
if candidate and len(candidate) <= 200 and "\n" not in candidate: if candidate:
rewritten_query = candidate rewritten_query = candidate[:180]
except Exception as e: for q in data.get("search_queries") or []:
print(f"query 重写失败: {e}") if isinstance(q, str) and q.strip():
query_variants.append(q.strip()[:180])
except Exception as exc:
title_options = [f"✨正在分析中,请稍等..."] print(f"query 重写失败: {exc}")
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
query_variants = _build_search_queries(rewritten_query, original_query, query_variants)
_emit_event("✨正在分析中,请稍等...", "正在生成检索策略...")
print("原始检索query:", original_query) print("原始检索query:", original_query)
print("重写后检索query:", last_user_query+rewritten_query) print("重写后检索query:", rewritten_query)
print("检索query变体:", query_variants)
return {"retrieval_query":last_user_query+rewritten_query} return {"retrieval_query": rewritten_query, "retrieval_queries": query_variants}
@track_function_calls @track_function_calls
async def search_rag_and_graph(state: QAState) -> Dict[str, Any]: async def search_rag_and_graph(state: QAState) -> Dict[str, Any]:
""" 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()
kb_id = None query_variants = _build_search_queries(query, extracted_text, state.get("retrieval_queries") or [])
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]
try: try:
# 异步并行调用RAG检索和图谱检索 rag_task = _search_rag_variants(query_variants[:3], kb_id=kb_id)
print(kb_id) graph_task = graph_rag_search.ainvoke({"query": query, "top_k": BAIKE_GRAPH_TOP_K})
print(33333333333333333333333333333333333333) rag_results, graph_results = await asyncio.gather(rag_task, graph_task)
evidence_items = _build_evidence_items(
async def _do_rag_search(): rag_results=rag_results,
if kb_id: graph_results=graph_results if isinstance(graph_results, str) else "",
return await rag_search( query=query,
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()
) )
evidence_items = await _rerank_evidence(query, evidence_items)
# 处理 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]
return { return {
"rag_search_result": rag_search_result, "rag_search_result": rag_results,
"graph_search_result": graph_results if isinstance(graph_results, str) else "" "graph_search_result": graph_results if isinstance(graph_results, str) else "",
"evidence_items": evidence_items,
} }
except Exception as e: except Exception as exc:
return { return {
"rag_search_result": [], "rag_search_result": [],
"graph_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]]: 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: try:
result = await OpenaiAPI.open_api_chat_without_thinking(prompt, model=None, json_output=True) result = await OpenaiAPI.open_api_chat_without_thinking(prompt, model=None, json_output=True)
# 尝试解析JSON json_match = re.search(r"\[.*\]", result or "", re.DOTALL)
# 提取JSON部分
json_match = re.search(r'\[.*\]', result, re.DOTALL)
if json_match: if json_match:
json_str = json_match.group(0) replies = json.loads(json_match.group(0))
suggested_replies = json.loads(json_str) formatted = [
# 验证格式 {"title": str(x["title"]), "content": str(x["content"])}
if isinstance(suggested_replies, list) and len(suggested_replies) >= 2: for x in replies[:2]
# 确保每个元素都有title和content if isinstance(x, dict) and x.get("title") and x.get("content")
formatted_replies = [] ]
for reply in suggested_replies[:2]: if len(formatted) == 2:
if isinstance(reply, dict) and "title" in reply and "content" in reply: return formatted
formatted_replies.append({ except Exception:
"title": str(reply["title"]), pass
"content": str(reply["content"]) return [
}) {"title": "查看相关依据", "content": "这个回答对应的资料依据有哪些?"},
if len(formatted_replies) == 2: {"title": "继续展开说明", "content": "请把这个问题再展开说明一下。"},
return formatted_replies ]
# 如果解析失败,返回默认问题
return [
{"title": "这个设备的具体工作原理是什么?", "content": "这个设备的具体工作原理是什么?"},
{"title": "还有哪些相关的知识点?", "content": "还有哪些相关的知识点?"}
]
except Exception as e:
# 如果生成失败,返回默认问题
return [
{"title": "这个设备的具体工作原理是什么?", "content": "这个设备的具体工作原理是什么?"},
{"title": "还有哪些相关的知识点?", "content": "还有哪些相关的知识点?"}
]
@track_function_calls @track_function_calls
async def generate_answer(state: QAState) -> Dict[str, Any]: async def generate_answer(state: QAState) -> Dict[str, Any]:
""" question = state.get("combined_query") or state.get("extracted_text") or ""
正在生成问答结果 evidence_items = state.get("evidence_items") or []
"""
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 []
need_retrieval = state.get("need_retrieval", True) need_retrieval = state.get("need_retrieval", True)
print("history_message(qa)", history_messages) history_messages = state.get("history_messages", []) or []
print("是否使用检索结果:", need_retrieval)
try: try:
# 1. 处理检索结果(如果有) source_text = ""
rag_ctx_parts: List[str] = [] original_image_paths: List[str] = []
file_name_list = [] if need_retrieval and evidence_items:
all_source_text = "" source_text = _format_evidence_context(evidence_items)
original_image_paths = [] original_image_paths = extract_image_paths(source_text)
source_counts = _count_sources(evidence_items)
if need_retrieval: _emit_event(
# 收集所有 RAG 结果文本(只取前 4 条) f"✨已融合 {len(evidence_items)} 条证据",
for item in (rag_results or [])[:1]: f"RAG {source_counts.get('rag', 0)} 条,图谱 {source_counts.get('graph', 0)}",
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
) )
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 = [] domain_desc = (
if history_messages: "舰船维修相关的法规、规范、行业标准及技术文件"
content_messages.extend(history_messages) if state.get("route_flag") == "rules"
content_messages.append({"role": "user", "content": content_user_content}) else "工业设备的结构原理、维护规程、故障诊断与维修操作"
# 第一步:生成内容
raw_content = await OpenaiAPI.open_api_chat_without_thinking(
model=None,
system_prompt=content_system_prompt,
messages=content_messages,
temperature=0.5
) )
# 过滤掉异常标签(如果有) if need_retrieval and source_text.strip():
raw_content = re.sub(r'ynchroneg>.*?ost switching>', '', raw_content, flags=re.DOTALL) system_prompt = BAIKE_PROMPTS["generate_answer_with_retrieval_system"].format(domain_desc=domain_desc)
raw_content = raw_content.strip() if raw_content else "抱歉,无法生成回答。" 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 = ["🤖 诊断分析中", "🤖 故障预检中"] messages = []
start_event = { if history_messages:
"type": "function_execution", messages.extend(history_messages)
"title": random.choice(title_options), messages.append({"role": "user", "content": user_prompt})
"details": raw_content[:30] + "..."
} _emit_event(random.choice(["🔍 证据整理中", "🧠 正在生成回答"]), "正在基于检索资料生成回答...")
for callback in get_all_callbacks(): 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: try:
callback(start_event) return json.loads(match.group(0))
except Exception: except Exception:
pass return {}
return {}
# ========== 第二步:专注于规范格式 ========== def _json_dumps(value: Any) -> str:
# 构建第二步的提示词:只关注格式规范,不修改内容 try:
format_system_prompt = COMMON_PROMPTS["format_system"] return json.dumps(value, ensure_ascii=False)
except Exception:
format_user_content = COMMON_PROMPTS["format_user_with_image_examples"].format(raw_content=raw_content) 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)
# 生成建议回复问题 def _dedupe_strings(values: List[str]) -> List[str]:
suggested_replies = await generate_suggested_replies_baike(answer, extracted_text) 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: def route_after_judge(state: QAState) -> str:
""" return "rewrite_query_with_history" if state.get("need_retrieval", True) else "generate_answer"
判断后的路由根据need_retrieval决定下一步
"""
need_retrieval = state.get("need_retrieval", True)
if need_retrieval:
return "rewrite_query_with_history"
else:
return "generate_answer"
# =============== 构建工作流 ===============
def create_baike_workflow(): def create_baike_workflow():
"""创建普通问答工作流"""
workflow = StateGraph(QAState) workflow = StateGraph(QAState)
# 添加节点
workflow.add_node("get_baike_history_context", get_baike_history_context) workflow.add_node("get_baike_history_context", get_baike_history_context)
workflow.add_node("judge_need_retrieval", judge_need_retrieval) workflow.add_node("judge_need_retrieval", judge_need_retrieval)
workflow.add_node("rewrite_query_with_history", rewrite_query_with_history) 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("search_rag_and_graph", search_rag_and_graph)
workflow.add_node("generate_answer", generate_answer) workflow.add_node("generate_answer", generate_answer)
# 设置边
workflow.add_edge(START, "get_baike_history_context") workflow.add_edge(START, "get_baike_history_context")
workflow.add_edge("get_baike_history_context", "judge_need_retrieval") workflow.add_edge("get_baike_history_context", "judge_need_retrieval")
workflow.add_conditional_edges( workflow.add_conditional_edges(
"judge_need_retrieval", "judge_need_retrieval",
route_after_judge, 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("rewrite_query_with_history", "search_rag_and_graph")
workflow.add_edge("search_rag_and_graph", "generate_answer") workflow.add_edge("search_rag_and_graph", "generate_answer")
workflow.add_edge("generate_answer", END) workflow.add_edge("generate_answer", END)
# 编译工作流
return workflow.compile() return workflow.compile()
# =============== 工作流执行函数 ===============
async def run_baike_workflow( async def run_baike_workflow(
extracted_text: str, extracted_text: str,
combined_query: str, combined_query: str,
history_message: str = "", history_message: str = "",
route_flag: str = "" route_flag: str = "",
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""
执行普通问答工作流统一接口
Args:
extracted_text: 文本描述统一的输入参数
history_message: 历史消息目前只接收不做处理
Returns:
工作流执行结果
"""
# 创建并编译工作流
app = create_baike_workflow() app = create_baike_workflow()
# 初始化状态统一接口只使用extracted_text和history_message
initial_state = { initial_state = {
"extracted_text": extracted_text, "extracted_text": extracted_text,
"combined_query": combined_query, "combined_query": combined_query,
"retrieval_query": "", "retrieval_query": "",
"retrieval_queries": [],
"route_flag": route_flag, "route_flag": route_flag,
"history_message": history_message, "history_message": history_message,
"history_messages": [], "history_messages": [],
"need_retrieval": None, "need_retrieval": None,
"rag_search_result": None, "rag_search_result": None,
"graph_search_result": None, "graph_search_result": None,
"evidence_items": None,
"response": "", "response": "",
"actions": [], "actions": [],
"suggestedReplies": [], "suggestedReplies": [],
"error_message": "" "error_message": "",
} }
try: try:
# 执行工作流(使用异步方式)
final_state = await app.ainvoke(initial_state) final_state = await app.ainvoke(initial_state)
# 检查是否有错误
if final_state.get("error_message"): if final_state.get("error_message"):
return { return {
"response": f"普通问答工作流执行失败: {final_state['error_message']}", "response": f"普通问答工作流执行失败: {final_state['error_message']}",
"actions": [], "actions": [],
"result_tag": "qa", "result_tag": "qa",
"suggestedReplies": [] "suggestedReplies": [],
} }
# 统一输出格式:完全透传 generate_answer 的结果
return { return {
"response": final_state.get("response", ""), "response": final_state.get("response", ""),
"actions": final_state.get("actions", []), "actions": final_state.get("actions", []),
"result_tag": "qa", "result_tag": "qa",
"suggestedReplies": final_state.get("suggestedReplies", []) "suggestedReplies": final_state.get("suggestedReplies", []),
} }
except Exception as exc:
except Exception as e:
return { return {
"response": f"普通问答工作流执行失败: {str(e)}", "response": f"普通问答工作流执行失败: {exc}",
"actions": [], "actions": [],
"result_tag": "qa", "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())

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -1,293 +1,293 @@
""" """
工作流普通问答Agent使用LangGraph 工作流普通问答Agent使用LangGraph
输入文本转化后和原文本query图片描述语音文本 输入文本转化后和原文本query图片描述语音文本
步骤 步骤
1. 使用RAG和GraphRAG检索相关信息 1. 使用RAG和GraphRAG检索相关信息
2. 生成回答 2. 生成回答
使用 OpenaiAPI 方法调用大模型尽可能使用异步 使用 OpenaiAPI 方法调用大模型尽可能使用异步
""" """
from typing import TypedDict, Optional, Dict, Any, List from typing import TypedDict, Optional, Dict, Any, List
from langgraph.graph import StateGraph, START, END from langgraph.graph import StateGraph, START, END
from modelsAPI.model_api import OpenaiAPI from modelsAPI.model_api import OpenaiAPI
from utils.function_tracker import track_function_calls, get_current_stream_handler from utils.function_tracker import track_function_calls, get_current_stream_handler
import asyncio import asyncio
import json import json
import re import re
# =============== 定义状态 =============== # =============== 定义状态 ===============
class Othertate(TypedDict): class Othertate(TypedDict):
"""普通问答工作流状态""" """普通问答工作流状态"""
extracted_text: str # 已提取的文本(统一接口,只接收文本) extracted_text: str # 已提取的文本(统一接口,只接收文本)
combined_query: str combined_query: str
route_flag: str # 路由标识 route_flag: str # 路由标识
history_message: str # 历史消息JSON格式 history_message: str # 历史消息JSON格式
history_messages: List[Dict[str, Any]] # 过滤后的历史消息列表 history_messages: List[Dict[str, Any]] # 过滤后的历史消息列表
# RAG检索结果 # RAG检索结果
rag_search_result: Optional[List[Dict[str, Any]]] # RAG检索结果 rag_search_result: Optional[List[Dict[str, Any]]] # RAG检索结果
graph_search_result: Optional[str] # 图谱检索结果 graph_search_result: Optional[str] # 图谱检索结果
# 最终响应 # 最终响应
response: str # 最终响应 response: str # 最终响应
actions: List[Dict[str, Any]] # 按钮信息列表 [{"type": "...", "label": "...", "icon": "..."}] actions: List[Dict[str, Any]] # 按钮信息列表 [{"type": "...", "label": "...", "icon": "..."}]
suggestedReplies: List[Dict[str, Any]] # 建议回复问题列表 suggestedReplies: List[Dict[str, Any]] # 建议回复问题列表
error_message: str # 错误信息 error_message: str # 错误信息
# =============== 节点函数 =============== # =============== 节点函数 ===============
def get_other_history_context(state: Othertate) -> Dict[str, Any]: def get_other_history_context(state: Othertate) -> Dict[str, Any]:
""" """
知识问答步骤获取问答类历史信息 知识问答步骤获取问答类历史信息
问答工作流只关注历史问答记录用于提供上下文 问答工作流只关注历史问答记录用于提供上下文
支持格式[{'role': 'user', 'content': '...'}, {'role': 'assistant', 'content': '...'}] 支持格式[{'role': 'user', 'content': '...'}, {'role': 'assistant', 'content': '...'}]
返回消息列表history_message已经在app.py中过滤过了 返回消息列表history_message已经在app.py中过滤过了
""" """
import json import json
history_message = state.get("history_message", "") or "" history_message = state.get("history_message", "") or ""
history_messages = [] history_messages = []
if history_message: if history_message:
try: try:
# history_message已经在app.py中过滤过了直接解析 # history_message已经在app.py中过滤过了直接解析
if isinstance(history_message, str): if isinstance(history_message, str):
history_data = json.loads(history_message) history_data = json.loads(history_message)
else: else:
history_data = history_message history_data = history_message
if isinstance(history_data, list): if isinstance(history_data, list):
# 只取最近10条历史记录避免上下文过长 # 只取最近10条历史记录避免上下文过长
history_messages = history_data[-10:] history_messages = history_data[-10:]
except Exception as e: except Exception as e:
pass pass
return {"history_messages": history_messages} return {"history_messages": history_messages}
async def generate_suggested_replies_other(answer: str, question: str) -> List[Dict[str, Any]]: async def generate_suggested_replies_other(answer: str, question: str) -> List[Dict[str, Any]]:
""" """
生成针对问答结果的建议回复问题 生成针对问答结果的建议回复问题
使用大模型生成两个相关问题 使用大模型生成两个相关问题
""" """
prompt = f""" prompt = f"""
你是一个问答助手根据以下问答内容生成两个用户可能关心的后续问题 你是一个问答助手根据以下问答内容生成两个用户可能关心的后续问题
用户问题 用户问题
{question} {question}
回答内容 回答内容
{answer[:500]} {answer[:500]}
请生成两个简洁实用的问题这些问题应该 请生成两个简洁实用的问题这些问题应该
1. 不得出现需要归档和下载等操作相关的问题 1. 不得出现需要归档和下载等操作相关的问题
2. 与当前问答内容相关能够帮助用户进一步了解相关信息 2. 与当前问答内容相关能够帮助用户进一步了解相关信息
3. 问题要具体可操作 3. 问题要具体可操作
4. 每个问题不超过20个字 4. 每个问题不超过20个字
请以JSON格式输出格式如下 请以JSON格式输出格式如下
[ [
{{"title": "问题1的标题", "content": "问题1的完整内容"}}, {{"title": "问题1的标题", "content": "问题1的完整内容"}},
{{"title": "问题2的标题", "content": "问题2的完整内容"}} {{"title": "问题2的标题", "content": "问题2的完整内容"}}
] ]
只输出JSON不要有其他文字说明 只输出JSON不要有其他文字说明
""" """
try: try:
result = await OpenaiAPI.open_api_chat_without_thinking(prompt, model=None) result = await OpenaiAPI.open_api_chat_without_thinking(prompt, model=None)
# 尝试解析JSON # 尝试解析JSON
# 提取JSON部分 # 提取JSON部分
json_match = re.search(r'\[.*\]', result, re.DOTALL) json_match = re.search(r'\[.*\]', result, re.DOTALL)
if json_match: if json_match:
json_str = json_match.group(0) json_str = json_match.group(0)
suggested_replies = json.loads(json_str) suggested_replies = json.loads(json_str)
# 验证格式 # 验证格式
if isinstance(suggested_replies, list) and len(suggested_replies) >= 2: if isinstance(suggested_replies, list) and len(suggested_replies) >= 2:
# 确保每个元素都有title和content # 确保每个元素都有title和content
formatted_replies = [] formatted_replies = []
for reply in suggested_replies[:2]: for reply in suggested_replies[:2]:
if isinstance(reply, dict) and "title" in reply and "content" in reply: if isinstance(reply, dict) and "title" in reply and "content" in reply:
formatted_replies.append({ formatted_replies.append({
"title": str(reply["title"]), "title": str(reply["title"]),
"content": str(reply["content"]) "content": str(reply["content"])
}) })
if len(formatted_replies) == 2: if len(formatted_replies) == 2:
return formatted_replies return formatted_replies
# 如果解析失败,返回默认问题 # 如果解析失败,返回默认问题
return [ return [
{"title": "这个设备的具体工作原理是什么?", "content": "这个设备的具体工作原理是什么?"}, {"title": "这个设备的具体工作原理是什么?", "content": "这个设备的具体工作原理是什么?"},
{"title": "还有哪些相关的知识点?", "content": "还有哪些相关的知识点?"} {"title": "还有哪些相关的知识点?", "content": "还有哪些相关的知识点?"}
] ]
except Exception as e: except Exception as e:
# 如果生成失败,返回默认问题 # 如果生成失败,返回默认问题
return [ return [
{"title": "这个设备的具体工作原理是什么?", "content": "这个设备的具体工作原理是什么?"}, {"title": "这个设备的具体工作原理是什么?", "content": "这个设备的具体工作原理是什么?"},
{"title": "还有哪些相关的知识点?", "content": "还有哪些相关的知识点?"} {"title": "还有哪些相关的知识点?", "content": "还有哪些相关的知识点?"}
] ]
@track_function_calls @track_function_calls
async def generate_answer(state: Othertate) -> Dict[str, Any]: async def generate_answer(state: Othertate) -> Dict[str, Any]:
""" """
正在生成问答结果 正在生成问答结果
""" """
extracted_text = state.get("combined_query", "") extracted_text = state.get("combined_query", "")
history_messages = state.get("history_messages", []) or [] history_messages = state.get("history_messages", []) or []
print("history_message(qa)", history_messages) print("history_message(qa)", history_messages)
try: try:
# 构建系统提示词 # 构建系统提示词
system_prompt = """我是AI助手根据历史信息和用户问题进行回答。 system_prompt = """我是AI助手根据历史信息和用户问题进行回答。
请基于以上信息给出准确专业的回答如果有历史问答上下文请结合历史对话内容理解当前问题""" 请基于以上信息给出准确专业的回答如果有历史问答上下文请结合历史对话内容理解当前问题"""
# 构建消息列表 # 构建消息列表
messages = [] messages = []
# 添加历史消息 # 添加历史消息
if history_messages: if history_messages:
messages.extend(history_messages) messages.extend(history_messages)
# 添加当前用户查询 # 添加当前用户查询
messages.append({"role": "user", "content": f"用户问题:{extracted_text}"}) messages.append({"role": "user", "content": f"用户问题:{extracted_text}"})
# 调用LLM生成回答异步禁用thinking # 调用LLM生成回答异步禁用thinking
answer = await OpenaiAPI.open_api_chat_without_thinking( answer = await OpenaiAPI.open_api_chat_without_thinking(
model=None, model=None,
system_prompt=system_prompt, system_prompt=system_prompt,
messages=messages messages=messages
) )
# 过滤掉 <think> 标签(如果有) # 过滤掉 <think> 标签(如果有)
import re import re
answer = re.sub(r'<think>.*?</think>', '', answer, flags=re.DOTALL) answer = re.sub(r'<think>.*?</think>', '', answer, flags=re.DOTALL)
answer = answer.strip() if answer else "抱歉,无法生成回答。" answer = answer.strip() if answer else "抱歉,无法生成回答。"
# 返回按钮:确认结果、重新生成 # 返回按钮:确认结果、重新生成
actions = [] actions = []
# 生成建议回复问题 # 生成建议回复问题
suggested_replies = await generate_suggested_replies_other(answer, extracted_text) suggested_replies = await generate_suggested_replies_other(answer, extracted_text)
return { return {
"response": answer, "response": answer,
"actions": actions, "actions": actions,
"result_tag": "other", "result_tag": "other",
"suggestedReplies": suggested_replies "suggestedReplies": suggested_replies
} }
except Exception as e: except Exception as e:
return { return {
"response": f"生成回答失败: {str(e)}", "response": f"生成回答失败: {str(e)}",
"actions": [], "actions": [],
"suggestedReplies": [] "suggestedReplies": []
} }
# =============== 构建工作流 =============== # =============== 构建工作流 ===============
def create_qa_workflow(): def create_qa_workflow():
"""创建普通问答工作流""" """创建普通问答工作流"""
workflow = StateGraph(Othertate) workflow = StateGraph(Othertate)
# 添加节点 # 添加节点
workflow.add_node("get_other_history_context", get_other_history_context) workflow.add_node("get_other_history_context", get_other_history_context)
workflow.add_node("generate_answer", generate_answer) workflow.add_node("generate_answer", generate_answer)
# 设置边 # 设置边
workflow.add_edge(START, "get_other_history_context") workflow.add_edge(START, "get_other_history_context")
workflow.add_edge("get_other_history_context", "generate_answer") workflow.add_edge("get_other_history_context", "generate_answer")
workflow.add_edge("generate_answer", END) workflow.add_edge("generate_answer", END)
# 编译工作流 # 编译工作流
return workflow.compile() return workflow.compile()
# =============== 工作流执行函数 =============== # =============== 工作流执行函数 ===============
async def run_other_workflow( async def run_other_workflow(
extracted_text: str, extracted_text: str,
combined_query: str, combined_query: str,
history_message: str = "", history_message: str = "",
route_flag: str = "" route_flag: str = ""
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
执行普通问答工作流统一接口 执行普通问答工作流统一接口
Args: Args:
extracted_text: 文本描述统一的输入参数 extracted_text: 文本描述统一的输入参数
history_message: 历史消息目前只接收不做处理 history_message: 历史消息目前只接收不做处理
Returns: Returns:
工作流执行结果 工作流执行结果
""" """
# 创建并编译工作流 # 创建并编译工作流
app = create_qa_workflow() app = create_qa_workflow()
# 初始化状态统一接口只使用extracted_text和history_message # 初始化状态统一接口只使用extracted_text和history_message
initial_state = { initial_state = {
"extracted_text": extracted_text, "extracted_text": extracted_text,
"combined_query": combined_query, "combined_query": combined_query,
"history_message": history_message, "history_message": history_message,
"history_messages": [], "history_messages": [],
"response": "", "response": "",
"actions": [], "actions": [],
"suggestedReplies": [], "suggestedReplies": [],
"error_message": "" "error_message": ""
} }
try: try:
# 执行工作流(使用异步方式) # 执行工作流(使用异步方式)
final_state = await app.ainvoke(initial_state) final_state = await app.ainvoke(initial_state)
# 检查是否有错误 # 检查是否有错误
if final_state.get("error_message"): if final_state.get("error_message"):
return { return {
"response": f"普通问答工作流执行失败: {final_state['error_message']}", "response": f"普通问答工作流执行失败: {final_state['error_message']}",
"actions": [], "actions": [],
"result_tag": "qa", "result_tag": "qa",
"suggestedReplies": [] "suggestedReplies": []
} }
# 统一输出格式:完全透传 generate_answer 的结果 # 统一输出格式:完全透传 generate_answer 的结果
return { return {
"response": final_state.get("response", ""), "response": final_state.get("response", ""),
"actions": final_state.get("actions", []), "actions": final_state.get("actions", []),
"result_tag": "other", "result_tag": "other",
"suggestedReplies": final_state.get("suggestedReplies", []) "suggestedReplies": final_state.get("suggestedReplies", [])
} }
except Exception as e: except Exception as e:
return { return {
"response": f"普通问答工作流执行失败: {str(e)}", "response": f"普通问答工作流执行失败: {str(e)}",
"actions": [], "actions": [],
"result_tag": "other", "result_tag": "other",
"suggestedReplies": [] "suggestedReplies": []
} }
# =============== 测试 =============== # =============== 测试 ===============
if __name__ == "__main__": if __name__ == "__main__":
import asyncio import asyncio
# 测试用例 # 测试用例
test_cases = [ test_cases = [
"什么是主机?", "什么是主机?",
"主机的作用是什么?" "主机的作用是什么?"
] ]
async def test(): async def test():
for i, test_text in enumerate(test_cases, 1): for i, test_text in enumerate(test_cases, 1):
print(f"\n{'=' * 60}") print(f"\n{'=' * 60}")
print(f"测试用例 {i}") print(f"测试用例 {i}")
print(f"{'=' * 60}") print(f"{'=' * 60}")
result = await run_other_workflow(extracted_text=test_text) result = await run_other_workflow(extracted_text=test_text)
print(f"\n执行结果:") print(f"\n执行结果:")
print(f"成功: {result.get('success', False)}") print(f"成功: {result.get('success', False)}")
print(f"\n响应内容:") print(f"\n响应内容:")
print(result.get("response", "无响应")) print(result.get("response", "无响应"))
asyncio.run(test()) asyncio.run(test())

View File

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

File diff suppressed because it is too large Load Diff