Compare commits
No commits in common. "main" and "KuangZhi" have entirely different histories.
1
.gitattributes
vendored
@ -1 +0,0 @@
|
||||
docker-images/*.tar.gz filter=lfs diff=lfs merge=lfs -text
|
||||
33
.gitignore
vendored
@ -1,16 +1,33 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.cache/
|
||||
*$py.class
|
||||
|
||||
.Python
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
|
||||
*.log
|
||||
*.tmp
|
||||
*.swp
|
||||
.DS_Store
|
||||
.nfs*
|
||||
*.bak*
|
||||
*.before_rollback_*
|
||||
*.tmp
|
||||
*.log
|
||||
|
||||
config.py
|
||||
config.txt
|
||||
|
||||
data/*.db
|
||||
data/*.db-*
|
||||
data/*.sqlite
|
||||
data/*.sqlite-*
|
||||
|
||||
created_pdf/
|
||||
created_word/
|
||||
image_output/
|
||||
audio/
|
||||
data/
|
||||
|
||||
workflows/=*
|
||||
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
|
||||
38
README.md
@ -1,38 +0,0 @@
|
||||
# WX Agent 镜像和代码备份
|
||||
|
||||
来源设备:`192.168.1.108`
|
||||
|
||||
原始代码路径:`/home/workspace/wxagent`
|
||||
|
||||
镜像名称:`wx-agent:latest`
|
||||
|
||||
## 主要内容
|
||||
|
||||
- `app.py`:Agent 服务入口
|
||||
- `main_agent.py`:Agent 主流程
|
||||
- `config.py`:当前配置文件
|
||||
- `requirements.txt`:Python 依赖
|
||||
- `docker-images/wx_agent_latest.docker-save.tar.gz`:`wx-agent:latest` 镜像备份
|
||||
|
||||
## 已清理内容
|
||||
|
||||
本仓库未上传生成文件、音频/图片输出、运行数据、缓存,以及 `.bak` 或带日期后缀的历史备份文件。
|
||||
|
||||
## 大文件下载
|
||||
|
||||
本仓库使用 Git LFS 保存 Docker 镜像包。
|
||||
|
||||
克隆仓库后执行:
|
||||
|
||||
```bash
|
||||
git lfs install
|
||||
git lfs pull
|
||||
```
|
||||
|
||||
如果镜像文件内容是 `version https://git-lfs.github.com/spec/v1`,说明当前只是 LFS 指针文件,还需要执行 `git lfs pull` 下载真实文件。
|
||||
|
||||
## 恢复镜像
|
||||
|
||||
```bash
|
||||
gunzip -c docker-images/wx_agent_latest.docker-save.tar.gz | docker load
|
||||
```
|
||||
129
app.py
@ -700,7 +700,20 @@ async def fault_type_statistics(ship_number: Optional[str] = None):
|
||||
status_code=500,
|
||||
detail=result.get("error", "统计失败")
|
||||
)
|
||||
|
||||
|
||||
# result = {
|
||||
# "success": True,
|
||||
# "faultTypeData": [
|
||||
# { "value": 35, "name": "作战系统", "itemStyle": { "color": "#00C2FF" } },
|
||||
# { "value": 25, "name": "电力系统", "itemStyle": { "color": "#FF4500" } },
|
||||
# { "value": 20, "name": "推进系统", "itemStyle": { "color": "#9400D3" } },
|
||||
# { "value": 10, "name": "航空保障系统", "itemStyle": { "color": "#228B22" } },
|
||||
# { "value": 5, "name": "船舶保障系统", "itemStyle": { "color": "#FF1493" } },
|
||||
# { "value": 5, "name": "其他", "itemStyle": { "color": "#2C2C2C" } }
|
||||
# ]
|
||||
# }
|
||||
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@ -729,7 +742,65 @@ async def fault_frequency_statistics(
|
||||
|
||||
start_date = None
|
||||
end_date = None
|
||||
|
||||
|
||||
# result = {
|
||||
# "success": True,
|
||||
# "highFreqFaults": [
|
||||
# { "rank": 1, "name": "燃气轮机燃油增压泵组泵体旋塞处泄漏", "system": "推进系统", "count": 135 },
|
||||
# { "rank": 2, "name": "气动蝶阀控制箱无法上电工作", "system": "推进系统", "count": 103 },
|
||||
# { "rank": 3, "name": "组合报警灯柱不能正常工作", "system": "船舶保障系统", "count": 86 },
|
||||
# { "rank": 4, "name": "岸电联接箱指示灯不亮", "system": "电力系统", "count": 77 },
|
||||
# { "rank": 5, "name": "主机冷却水泵密封泄漏", "system": "航空保障系统", "count": 76 },
|
||||
# { "rank": 6, "name": "加电后屏幕和故障指示灯没显示", "system": "作战系统", "count": 65 }
|
||||
# ]
|
||||
# }
|
||||
|
||||
# if time_range == "近七天":
|
||||
# end_date = datetime.now().strftime("%Y-%m-%d")
|
||||
# start_date = (datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d")
|
||||
# result = {
|
||||
# "success": True,
|
||||
# "highFreqFaults": [
|
||||
# { "rank": 1, "name": "气动蝶阀控制箱无法上电工作", "system": "推进系统", "count": 10 },
|
||||
# { "rank": 2, "name": "组合报警灯柱不能正常工作", "system": "船舶保障系统", "count": 6 },
|
||||
# { "rank": 3, "name": "岸电联接箱指示灯不亮", "system": "电力系统", "count": 3 },
|
||||
# { "rank": 4, "name": "主机冷却水泵密封泄漏", "system": "航空保障系统", "count": 3 },
|
||||
# { "rank": 5, "name": "加电后屏幕和故障指示灯没显示", "system": "作战系统", "count": 1 }
|
||||
# ]
|
||||
# }
|
||||
|
||||
# elif time_range == "近一个月":
|
||||
# end_date = datetime.now().strftime("%Y-%m-%d")
|
||||
# start_date = (datetime.now() - timedelta(days=30)).strftime("%Y-%m-%d")
|
||||
|
||||
# result = {
|
||||
# "success": True,
|
||||
# "highFreqFaults": [
|
||||
# { "rank": 1, "name": "燃气轮机燃油增压泵组泵体旋塞处泄漏", "system": "推进系统", "count": 38 },
|
||||
# { "rank": 2, "name": "气动蝶阀控制箱无法上电工作", "system": "推进系统", "count": 20 },
|
||||
# { "rank": 3, "name": "组合报警灯柱不能正常工作", "system": "船舶保障系统", "count": 14 },
|
||||
# { "rank": 4, "name": "岸电联接箱指示灯不亮", "system": "电力系统", "count": 13 },
|
||||
# { "rank": 5, "name": "主机冷却水泵密封泄漏", "system": "航空保障系统", "count": 10 },
|
||||
# { "rank": 6, "name": "加电后屏幕和故障指示灯没显示", "system": "作战系统", "count": 8 }
|
||||
# ]
|
||||
# }
|
||||
|
||||
# elif time_range == "近一年":
|
||||
# end_date = datetime.now().strftime("%Y-%m-%d")
|
||||
# start_date = (datetime.now() - timedelta(days=365)).strftime("%Y-%m-%d")
|
||||
# result = {
|
||||
# "success": True,
|
||||
# "highFreqFaults": [
|
||||
# { "rank": 1, "name": "燃气轮机燃油增压泵组泵体旋塞处泄漏", "system": "推进系统", "count": 68 },
|
||||
# { "rank": 2, "name": "气动蝶阀控制箱无法上电工作", "system": "推进系统", "count": 74 },
|
||||
# { "rank": 3, "name": "组合报警灯柱不能正常工作", "system": "船舶保障系统", "count": 73 },
|
||||
# { "rank": 4, "name": "岸电联接箱指示灯不亮", "system": "电力系统", "count": 63 },
|
||||
# { "rank": 5, "name": "主机冷却水泵密封泄漏", "system": "航空保障系统", "count": 56 },
|
||||
# { "rank": 6, "name": "加电后屏幕和故障指示灯没显示", "system": "作战系统", "count": 46 }
|
||||
# ]
|
||||
# }
|
||||
|
||||
|
||||
if time_range == "近七天":
|
||||
end_date = datetime.now().strftime("%Y-%m-%d")
|
||||
start_date = (datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d")
|
||||
@ -753,7 +824,7 @@ async def fault_frequency_statistics(
|
||||
status_code=500,
|
||||
detail=result.get("error", "统计失败")
|
||||
)
|
||||
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@ -784,7 +855,55 @@ async def spare_parts_statistics(
|
||||
|
||||
start_date = None
|
||||
end_date = None
|
||||
|
||||
|
||||
# result = {
|
||||
# "success": True,
|
||||
# "sparePartData": {
|
||||
# "categories": ["螺旋式熔断体", "断路器", "机械密封", "门铰链固定端", "减振器"],
|
||||
# "system": ["电力系统", "船舶保障系统", "推进系统", "作战系统", "推进系统"],
|
||||
# "counter": [86, 76, 43, 42, 33]
|
||||
# }
|
||||
# }
|
||||
|
||||
# if time_range == "近七天":
|
||||
# end_date = datetime.now().strftime("%Y-%m-%d")
|
||||
# start_date = (datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d")
|
||||
# result = {
|
||||
# "success": True,
|
||||
# "sparePartData": {
|
||||
# "categories": ["螺旋式熔断体", "断路器", "机械密封", "门铰链固定端"],
|
||||
# "system": ["电力系统", "船舶保障系统", "推进系统", "作战系统"],
|
||||
# "counter": [13, 9, 8, 5]
|
||||
# }
|
||||
# }
|
||||
# elif time_range == "近一个月":
|
||||
# end_date = datetime.now().strftime("%Y-%m-%d")
|
||||
# start_date = (datetime.now() - timedelta(days=30)).strftime("%Y-%m-%d")
|
||||
|
||||
# result = {
|
||||
# "success": True,
|
||||
# "sparePartData": {
|
||||
# "categories": ["螺旋式熔断体", "断路器", "机械密封", "门铰链固定端", "减振器"],
|
||||
# "system": ["电力系统", "船舶保障系统", "推进系统", "作战系统", "推进系统"],
|
||||
# "counter": [32, 26, 20, 14, 10]
|
||||
# }
|
||||
# }
|
||||
# elif time_range == "近一年":
|
||||
# end_date = datetime.now().strftime("%Y-%m-%d")
|
||||
# start_date = (datetime.now() - timedelta(days=365)).strftime("%Y-%m-%d")
|
||||
|
||||
# result = {
|
||||
# "success": True,
|
||||
# "sparePartData": {
|
||||
# "categories": ["螺旋式熔断体", "断路器", "机械密封", "门铰链固定端", "减振器"],
|
||||
# "system": ["电力系统", "船舶保障系统", "推进系统", "作战系统", "推进系统"],
|
||||
# "counter": [64, 63, 23, 20, 19]
|
||||
# }
|
||||
# }
|
||||
|
||||
|
||||
|
||||
|
||||
if time_range == "近七天":
|
||||
end_date = datetime.now().strftime("%Y-%m-%d")
|
||||
start_date = (datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d")
|
||||
@ -809,7 +928,7 @@ async def spare_parts_statistics(
|
||||
status_code=500,
|
||||
detail=result.get("error", "统计失败")
|
||||
)
|
||||
|
||||
|
||||
return result
|
||||
|
||||
|
||||
|
||||
1012
app8964.py
BIN
audio/test.wav
Normal file
@ -1,493 +0,0 @@
|
||||
"""
|
||||
LangGraph Checkpointer 配置模块
|
||||
|
||||
支持多种持久化后端:
|
||||
1. MemorySaver - 内存存储(仅用于测试)
|
||||
2. PostgresSaver - PostgreSQL 数据库(生产环境)
|
||||
|
||||
使用 message_id 和 chat_id 作为 thread_id 来恢复工作流状态
|
||||
"""
|
||||
|
||||
import os
|
||||
import asyncio
|
||||
from typing import Optional
|
||||
from langgraph.checkpoint.memory import MemorySaver
|
||||
from config import POSTGRES_CONNECTION_STRING
|
||||
|
||||
CHECKPOINTER_TYPE = "postgres"
|
||||
|
||||
|
||||
def get_thread_id(chat_id: str, message_id: str) -> str:
|
||||
"""
|
||||
生成唯一的 thread_id
|
||||
|
||||
Args:
|
||||
chat_id: 聊天会话 ID
|
||||
message_id: 消息 ID
|
||||
|
||||
Returns:
|
||||
组合后的唯一 thread_id
|
||||
"""
|
||||
return f"{chat_id}:{message_id}"
|
||||
|
||||
|
||||
def parse_thread_id(thread_id: str) -> tuple[str, str]:
|
||||
"""
|
||||
解析 thread_id 获取 chat_id 和 message_id
|
||||
|
||||
Args:
|
||||
thread_id: 组合的 thread_id
|
||||
|
||||
Returns:
|
||||
(chat_id, message_id) 元组
|
||||
"""
|
||||
parts = thread_id.split(":", 1)
|
||||
if len(parts) == 2:
|
||||
return parts[0], parts[1]
|
||||
return parts[0], ""
|
||||
|
||||
|
||||
async def verify_checkpointer_tables_exist() -> bool:
|
||||
"""
|
||||
验证 checkpointer 所需的数据库表是否存在且结构正确
|
||||
|
||||
Returns:
|
||||
True 如果所有必需的表都存在且结构正确,否则 False
|
||||
"""
|
||||
try:
|
||||
from psycopg_pool import AsyncConnectionPool
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
try:
|
||||
async with AsyncConnectionPool(
|
||||
POSTGRES_CONNECTION_STRING,
|
||||
kwargs={"autocommit": True}
|
||||
) as pool:
|
||||
async with pool.connection() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
# 检查 checkpoints 表
|
||||
await cur.execute("""
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'checkpoints'
|
||||
)
|
||||
""")
|
||||
checkpoints_exists = (await cur.fetchone())[0]
|
||||
|
||||
# 检查 checkpoint_writes 表
|
||||
await cur.execute("""
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'checkpoint_writes'
|
||||
)
|
||||
""")
|
||||
writes_exists = (await cur.fetchone())[0]
|
||||
|
||||
# 检查 checkpoint_blobs 表
|
||||
await cur.execute("""
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'checkpoint_blobs'
|
||||
)
|
||||
""")
|
||||
blobs_exists = (await cur.fetchone())[0]
|
||||
|
||||
if not (checkpoints_exists and writes_exists and blobs_exists):
|
||||
return False
|
||||
|
||||
# 检查 checkpoints 表的 checkpoint 字段是否为 JSONB 类型
|
||||
await cur.execute("""
|
||||
SELECT data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'checkpoints'
|
||||
AND column_name = 'checkpoint'
|
||||
""")
|
||||
result = await cur.fetchone()
|
||||
if not result or result[0] != 'jsonb':
|
||||
print(f"[Checkpointer] checkpoints.checkpoint column type is {result[0] if result else 'NULL'}, expected 'jsonb'")
|
||||
return False
|
||||
|
||||
# 检查 checkpoint_writes 表是否有 task_path 列
|
||||
await cur.execute("""
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.columns
|
||||
WHERE table_name = 'checkpoint_writes'
|
||||
AND column_name = 'task_path'
|
||||
)
|
||||
""")
|
||||
task_path_exists = (await cur.fetchone())[0]
|
||||
if not task_path_exists:
|
||||
print(f"[Checkpointer] checkpoint_writes.task_path column does not exist")
|
||||
return False
|
||||
|
||||
# 检查 checkpoint_writes 表的主键是否正确
|
||||
await cur.execute("""
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.key_column_usage kcu
|
||||
ON tc.constraint_name = kcu.constraint_name
|
||||
WHERE tc.table_name = 'checkpoint_writes'
|
||||
AND tc.constraint_type = 'PRIMARY KEY'
|
||||
""")
|
||||
pk_count = (await cur.fetchone())[0]
|
||||
if pk_count != 5: # 应该有 5 个主键列
|
||||
print(f"[Checkpointer] checkpoint_writes has {pk_count} primary key columns, expected 5")
|
||||
return False
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[Checkpointer] Error verifying tables: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def init_checkpointer_db():
|
||||
"""
|
||||
初始化 PostgreSQL checkpointer 数据库表
|
||||
使用 AsyncPostgresSaver 的 setup 方法创建正确的表结构
|
||||
"""
|
||||
if CHECKPOINTER_TYPE != "postgres":
|
||||
return
|
||||
|
||||
try:
|
||||
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
|
||||
from psycopg_pool import AsyncConnectionPool
|
||||
import psycopg
|
||||
except ImportError as e:
|
||||
print(f"[Checkpointer] ERROR: Missing required package: {e}")
|
||||
print(f"[Checkpointer] Please install: pip install langgraph-checkpoint-postgres psycopg-pool")
|
||||
raise ImportError(
|
||||
"langgraph-checkpoint-postgres or psycopg-pool not installed. "
|
||||
"Run: pip install langgraph-checkpoint-postgres psycopg-pool"
|
||||
) from e
|
||||
|
||||
# 首先检查表是否已存在
|
||||
tables_exist = await verify_checkpointer_tables_exist()
|
||||
if tables_exist:
|
||||
print(f"[Checkpointer] Database tables already exist, skipping initialization")
|
||||
return
|
||||
|
||||
print(f"[Checkpointer] Initializing PostgreSQL database tables...")
|
||||
|
||||
# 尝试使用 AsyncPostgresSaver 的 setup 方法
|
||||
try:
|
||||
async with AsyncConnectionPool(
|
||||
POSTGRES_CONNECTION_STRING,
|
||||
kwargs={"autocommit": True}
|
||||
) as pool:
|
||||
checkpointer = AsyncPostgresSaver(pool)
|
||||
await checkpointer.setup()
|
||||
print(f"[Checkpointer] AsyncPostgresSaver.setup() executed")
|
||||
except Exception as e:
|
||||
print(f"[Checkpointer] AsyncPostgresSaver.setup() failed: {e}")
|
||||
|
||||
# 验证表是否真的被创建
|
||||
tables_exist_after = await verify_checkpointer_tables_exist()
|
||||
|
||||
if not tables_exist_after:
|
||||
print(f"[Checkpointer] Tables not created by setup(), using direct SQL...")
|
||||
try:
|
||||
await create_checkpointer_tables_directly()
|
||||
except Exception as e2:
|
||||
print(f"[Checkpointer] Direct SQL initialization also failed: {e2}")
|
||||
raise
|
||||
else:
|
||||
print(f"[Checkpointer] PostgreSQL database tables verified successfully")
|
||||
|
||||
|
||||
async def create_checkpointer_tables_directly():
|
||||
"""
|
||||
直接通过 SQL 语句创建 checkpointer 所需的数据库表
|
||||
这是备用方案,当 AsyncPostgresSaver.setup() 失败时使用
|
||||
|
||||
注意:LangGraph AsyncPostgresSaver 使用 JSONB 类型存储数据
|
||||
表名必须是: checkpoints, checkpoint_writes, checkpoint_blobs
|
||||
"""
|
||||
try:
|
||||
from psycopg_pool import AsyncConnectionPool
|
||||
except ImportError as e:
|
||||
print(f"[Checkpointer] ERROR: Missing psycopg-pool: {e}")
|
||||
raise
|
||||
|
||||
print(f"[Checkpointer] Creating checkpointer tables using direct SQL...")
|
||||
|
||||
async with AsyncConnectionPool(
|
||||
POSTGRES_CONNECTION_STRING,
|
||||
kwargs={"autocommit": True}
|
||||
) as pool:
|
||||
async with pool.connection() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
# 先删除旧表(如果存在错误的表结构)
|
||||
await cur.execute("DROP TABLE IF EXISTS checkpoints CASCADE")
|
||||
await cur.execute("DROP TABLE IF EXISTS writes CASCADE")
|
||||
await cur.execute("DROP TABLE IF EXISTS checkpoint_blobs CASCADE")
|
||||
await cur.execute("DROP TABLE IF EXISTS checkpoint_writes CASCADE")
|
||||
await cur.execute("DROP TABLE IF EXISTS blobs CASCADE")
|
||||
await cur.execute("DROP TABLE IF EXISTS checkpoint_migrations CASCADE")
|
||||
print(f"[Checkpointer] Dropped existing tables")
|
||||
|
||||
# 创建 checkpoint_migrations 表(用于跟踪迁移版本)
|
||||
await cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS checkpoint_migrations (
|
||||
v INTEGER PRIMARY KEY
|
||||
)
|
||||
""")
|
||||
|
||||
# 创建 checkpoints 表 - 使用 JSONB 类型
|
||||
await cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS checkpoints (
|
||||
thread_id TEXT NOT NULL,
|
||||
checkpoint_ns TEXT NOT NULL DEFAULT '',
|
||||
checkpoint_id TEXT NOT NULL,
|
||||
parent_checkpoint_id TEXT,
|
||||
type TEXT,
|
||||
checkpoint JSONB NOT NULL DEFAULT '{}',
|
||||
metadata JSONB NOT NULL DEFAULT '{}',
|
||||
PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id)
|
||||
)
|
||||
""")
|
||||
|
||||
# 创建 checkpoint_writes 表 - 使用 BYTEA 类型
|
||||
await cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS checkpoint_writes (
|
||||
thread_id TEXT NOT NULL,
|
||||
checkpoint_ns TEXT NOT NULL DEFAULT '',
|
||||
checkpoint_id TEXT NOT NULL,
|
||||
task_id TEXT NOT NULL,
|
||||
task_path TEXT NOT NULL DEFAULT '',
|
||||
idx INTEGER NOT NULL,
|
||||
channel TEXT NOT NULL,
|
||||
type TEXT,
|
||||
blob BYTEA,
|
||||
PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id, task_id, idx)
|
||||
)
|
||||
""")
|
||||
|
||||
# 创建 checkpoint_blobs 表 - 使用 BYTEA 类型存储二进制数据
|
||||
await cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS checkpoint_blobs (
|
||||
thread_id TEXT NOT NULL,
|
||||
checkpoint_ns TEXT NOT NULL DEFAULT '',
|
||||
channel TEXT NOT NULL,
|
||||
version TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
blob BYTEA,
|
||||
PRIMARY KEY (thread_id, checkpoint_ns, channel, version)
|
||||
)
|
||||
""")
|
||||
|
||||
# 创建索引以提高查询性能
|
||||
await cur.execute("""
|
||||
CREATE INDEX IF NOT EXISTS checkpoints_thread_id_idx ON checkpoints(thread_id)
|
||||
""")
|
||||
await cur.execute("""
|
||||
CREATE INDEX IF NOT EXISTS checkpoint_writes_thread_id_idx ON checkpoint_writes(thread_id)
|
||||
""")
|
||||
await cur.execute("""
|
||||
CREATE INDEX IF NOT EXISTS checkpoint_blobs_thread_id_idx ON checkpoint_blobs(thread_id)
|
||||
""")
|
||||
|
||||
# 插入迁移版本记录
|
||||
await cur.execute("""
|
||||
INSERT INTO checkpoint_migrations (v) VALUES (1) ON CONFLICT DO NOTHING
|
||||
""")
|
||||
|
||||
print(f"[Checkpointer] Checkpointer tables created successfully via direct SQL")
|
||||
|
||||
|
||||
def create_checkpointer(checkpointer_type: str = None):
|
||||
"""
|
||||
创建同步 checkpointer 实例
|
||||
|
||||
Args:
|
||||
checkpointer_type: checkpointer 类型,可选 "memory", "postgres"
|
||||
|
||||
Returns:
|
||||
checkpointer 实例
|
||||
"""
|
||||
checkpointer_type = checkpointer_type or CHECKPOINTER_TYPE
|
||||
|
||||
if checkpointer_type == "memory":
|
||||
return MemorySaver()
|
||||
|
||||
elif checkpointer_type == "postgres":
|
||||
try:
|
||||
from langgraph.checkpoint.postgres import PostgresSaver
|
||||
from psycopg import Connection
|
||||
if not POSTGRES_CONNECTION_STRING:
|
||||
raise ValueError("POSTGRES_CONNECTION_STRING 环境变量未设置")
|
||||
conn = Connection.connect(POSTGRES_CONNECTION_STRING)
|
||||
return PostgresSaver(conn)
|
||||
except ImportError:
|
||||
print("[WARNING] psycopg 或 langgraph.checkpoint.postgres 未安装,回退到 memory")
|
||||
return MemorySaver()
|
||||
|
||||
else:
|
||||
print(f"[WARNING] 未知的 checkpointer 类型: {checkpointer_type},使用 memory")
|
||||
return MemorySaver()
|
||||
|
||||
|
||||
def create_async_checkpointer(checkpointer_type: str = None):
|
||||
"""
|
||||
创建异步 checkpointer 实例
|
||||
|
||||
Args:
|
||||
checkpointer_type: checkpointer 类型
|
||||
|
||||
Returns:
|
||||
异步 checkpointer 实例
|
||||
"""
|
||||
checkpointer_type = checkpointer_type or CHECKPOINTER_TYPE
|
||||
print(f"[Checkpointer] 创建异步 checkpointer, 类型配置: {checkpointer_type}")
|
||||
|
||||
if checkpointer_type == "memory":
|
||||
print("[Checkpointer] 使用 MemorySaver")
|
||||
return MemorySaver()
|
||||
|
||||
elif checkpointer_type == "postgres":
|
||||
try:
|
||||
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
|
||||
from psycopg_pool import AsyncConnectionPool
|
||||
if not POSTGRES_CONNECTION_STRING:
|
||||
raise ValueError("POSTGRES_CONNECTION_STRING 环境变量未设置")
|
||||
pool = AsyncConnectionPool(POSTGRES_CONNECTION_STRING)
|
||||
return AsyncPostgresSaver(pool)
|
||||
except ImportError:
|
||||
print("[WARNING] psycopg_pool 或 langgraph.checkpoint.postgres 未安装,回退到 memory")
|
||||
return MemorySaver()
|
||||
|
||||
else:
|
||||
print(f"[WARNING] 未知的 checkpointer 类型: {checkpointer_type},使用 memory")
|
||||
return MemorySaver()
|
||||
|
||||
|
||||
async def get_or_create_async_checkpointer(checkpointer_type: str = None):
|
||||
"""
|
||||
获取或创建异步 checkpointer 实例(正确处理上下文管理器)
|
||||
|
||||
Args:
|
||||
checkpointer_type: checkpointer 类型
|
||||
|
||||
Returns:
|
||||
已初始化的异步 checkpointer 实例
|
||||
"""
|
||||
checkpointer = create_async_checkpointer(checkpointer_type)
|
||||
|
||||
if hasattr(checkpointer, '__aenter__'):
|
||||
checkpointer = await checkpointer.__aenter__()
|
||||
|
||||
return checkpointer
|
||||
|
||||
|
||||
class CheckpointerManager:
|
||||
"""
|
||||
Checkpointer 管理器 - 单例模式
|
||||
|
||||
用于管理多个工作流的 checkpointer 实例
|
||||
"""
|
||||
|
||||
_instance = None
|
||||
_sync_checkpointer = None
|
||||
_async_conn_pool = None
|
||||
_async_checkpointer = None
|
||||
_initialized = False
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
def get_checkpointer(self):
|
||||
"""获取同步 checkpointer 实例"""
|
||||
if self._sync_checkpointer is None:
|
||||
self._sync_checkpointer = create_checkpointer()
|
||||
return self._sync_checkpointer
|
||||
|
||||
async def get_async_checkpointer(self):
|
||||
"""
|
||||
获取异步 checkpointer 实例
|
||||
"""
|
||||
if self._async_checkpointer is None:
|
||||
if CHECKPOINTER_TYPE == "postgres":
|
||||
try:
|
||||
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
|
||||
from psycopg_pool import AsyncConnectionPool
|
||||
if not POSTGRES_CONNECTION_STRING:
|
||||
raise ValueError("POSTGRES_CONNECTION_STRING not set")
|
||||
|
||||
self._async_conn_pool = AsyncConnectionPool(
|
||||
POSTGRES_CONNECTION_STRING,
|
||||
open=False
|
||||
)
|
||||
await self._async_conn_pool.open()
|
||||
self._async_checkpointer = AsyncPostgresSaver(self._async_conn_pool)
|
||||
|
||||
# 检查表是否存在
|
||||
tables_exist = await verify_checkpointer_tables_exist()
|
||||
|
||||
if not tables_exist:
|
||||
# 尝试使用 setup() 方法
|
||||
try:
|
||||
await self._async_checkpointer.setup()
|
||||
print(f"[Checkpointer] AsyncPostgresSaver.setup() executed")
|
||||
except Exception as setup_e:
|
||||
print(f"[Checkpointer] AsyncPostgresSaver.setup() failed: {setup_e}")
|
||||
|
||||
# 再次验证表是否被创建
|
||||
tables_exist_after = await verify_checkpointer_tables_exist()
|
||||
|
||||
if not tables_exist_after:
|
||||
print(f"[Checkpointer] Tables not created by setup(), using direct SQL...")
|
||||
await create_checkpointer_tables_directly()
|
||||
else:
|
||||
print(f"[Checkpointer] Database tables already exist")
|
||||
|
||||
print(f"[Checkpointer] AsyncPostgresSaver created, connection: {POSTGRES_CONNECTION_STRING.split('@')[1] if '@' in POSTGRES_CONNECTION_STRING else POSTGRES_CONNECTION_STRING}")
|
||||
except ImportError as e:
|
||||
print(f"[WARNING] psycopg_pool or langgraph.checkpoint.postgres not installed: {e}, fallback to memory")
|
||||
self._async_checkpointer = MemorySaver()
|
||||
except Exception as e:
|
||||
print(f"[Checkpointer] ERROR: Failed to create AsyncPostgresSaver: {e}")
|
||||
raise
|
||||
elif CHECKPOINTER_TYPE == "memory":
|
||||
self._async_checkpointer = MemorySaver()
|
||||
print("[Checkpointer] Using MemorySaver")
|
||||
else:
|
||||
self._async_checkpointer = MemorySaver()
|
||||
print(f"[Checkpointer] Unknown type {CHECKPOINTER_TYPE}, using MemorySaver")
|
||||
|
||||
print(f"[Checkpointer] Async checkpointer type: {type(self._async_checkpointer).__name__}")
|
||||
return self._async_checkpointer
|
||||
|
||||
async def setup(self):
|
||||
"""
|
||||
初始化 checkpointer(创建数据库表等)
|
||||
"""
|
||||
if not self._initialized:
|
||||
await init_checkpointer_db()
|
||||
try:
|
||||
from tools.fault_record_db import init_fault_records_table
|
||||
await init_fault_records_table()
|
||||
except Exception as e:
|
||||
print(f"[Checkpointer] 初始化 fault_records 表失败: {e}")
|
||||
self._initialized = True
|
||||
|
||||
def reset(self):
|
||||
"""重置 checkpointer(主要用于测试)"""
|
||||
self._sync_checkpointer = None
|
||||
self._async_checkpointer = None
|
||||
self._async_conn_pool = None
|
||||
self._initialized = False
|
||||
|
||||
|
||||
checkpointer_manager = CheckpointerManager()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Checkpointer 配置测试:")
|
||||
print(f" 类型: {CHECKPOINTER_TYPE}")
|
||||
print(f" PostgreSQL 连接: {'已配置' if POSTGRES_CONNECTION_STRING else '未配置'}")
|
||||
|
||||
13
config.py
@ -12,11 +12,10 @@ load_dotenv()
|
||||
|
||||
# ==================== 大模型配置 ====================
|
||||
LLM_CONFIG = {
|
||||
"model": "46-qwen3.5-35B",
|
||||
# "model": "Qwen3-32B",
|
||||
# "model": "Qwen3.5-35B-A3B",
|
||||
"model": "Qwen3-32B",
|
||||
# "model": "Qwen3-14B",
|
||||
#"base_url": "http://192.168.1.164:9800/v1",
|
||||
"base_url":"https://openai.zkzdht.com/v1",
|
||||
"base_url": "http://192.168.0.164:9800/v1",
|
||||
"api_key": "gpustack_dee9ca823290886c_5edfc86aeeeceb1e9ee5162941cb2cb5",
|
||||
"temperature": float("0.1"),
|
||||
"max_tokens": int("20096"),
|
||||
@ -38,7 +37,7 @@ _request_user_config: ContextVar[Optional[Dict[str, str]]] = ContextVar('request
|
||||
|
||||
# RAG配置的默认值
|
||||
_RAG_CONFIG_DEFAULT = {
|
||||
"search_endpoint": "http://192.168.1.108:11000/api/v1/knowledge/search/",
|
||||
"search_endpoint": "http://htknow:8080/api/v1/knowledge/search/",
|
||||
"x-user-id": "1",
|
||||
"x-user-name": "testuser",
|
||||
"x-role": "admin",
|
||||
@ -156,7 +155,7 @@ SERVER_CONFIG = {
|
||||
"host": "agent",
|
||||
"port": "9088",
|
||||
"base_url": "http://agent:9088",
|
||||
"report" : "http://192.168.1.164:9088"
|
||||
"report" : "http://192.168.0.164:9088"
|
||||
}
|
||||
|
||||
# ==================== ASR服务配置 ====================
|
||||
@ -204,7 +203,7 @@ INDEX_CONFIG = {
|
||||
"FULLTEXT_PROPERTY" :"fulltext",
|
||||
"EMBEDDING_PROPERTY" : "embedding",
|
||||
"SEARCH_LABEL" : "Searchable",
|
||||
"VECTOR_INDEX_NAME" : "global_searchabl_embedding",
|
||||
"VECTOR_INDEX_NAME" : "global_searchable_embedding",
|
||||
"FULLTEXT_INDEX_NAME" : "global_searchable_content_search"
|
||||
}
|
||||
|
||||
|
||||
223
config.txt
@ -1,223 +0,0 @@
|
||||
"""
|
||||
项目配置文件
|
||||
从.env文件读取配置
|
||||
"""
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
from contextvars import ContextVar
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
# 加载.env文件
|
||||
load_dotenv()
|
||||
|
||||
# ==================== 大模型配置 ====================
|
||||
LLM_CONFIG = {
|
||||
# "model": "Qwen3.5-35B-A3B",
|
||||
"model": "Qwen3-32B",
|
||||
# "model": "Qwen3-14B",
|
||||
"base_url": "http://llm:9800/v1",
|
||||
"api_key": "gpustack_dee9ca823290886c_5edfc86aeeeceb1e9ee5162941cb2cb5",
|
||||
"temperature": float("0.1"),
|
||||
"max_tokens": int("4096"),
|
||||
"timeout": int("30")
|
||||
}
|
||||
|
||||
# ==================== 嵌入模型配置 ====================
|
||||
EMBEDDING_CONFIG = {
|
||||
"model": "bge-m3",
|
||||
"base_url": "http://embedding:9700/v1/embeddings",
|
||||
"api_key": "gpustack_dee9ca823290886c_5edfc86aeeeceb1e9ee5162941cb2cb5",
|
||||
"base_url_v2" : "http://embedding:9700/v1",
|
||||
}
|
||||
|
||||
|
||||
# ==================== 线程安全的请求上下文配置 ====================
|
||||
# 使用 contextvars 存储每个请求的用户配置,确保多用户并发时不会相互干扰
|
||||
_request_user_config: ContextVar[Optional[Dict[str, str]]] = ContextVar('request_user_config', default=None)
|
||||
|
||||
# RAG配置的默认值
|
||||
_RAG_CONFIG_DEFAULT = {
|
||||
"search_endpoint": "http://htknow:8080/api/v1/knowledge/search/",
|
||||
"x-user-id": "1",
|
||||
"x-user-name": "testuser",
|
||||
"x-role": "admin",
|
||||
"kb-id": None,
|
||||
"file-id": None
|
||||
}
|
||||
|
||||
class DynamicRAGConfig(dict):
|
||||
"""
|
||||
动态RAG配置类,支持线程安全的请求级配置
|
||||
当访问用户相关配置时,自动从当前请求上下文读取
|
||||
其他配置项直接返回默认值
|
||||
兼容字典的所有操作方式(如 get(), [], in 等)
|
||||
"""
|
||||
def __getitem__(self, key: str):
|
||||
# 如果是用户相关配置或知识库参数,从当前请求上下文读取
|
||||
if key in ["x-user-id", "x-user-name", "x-role", "kb-id", "file-id"]:
|
||||
request_config = _request_user_config.get()
|
||||
if request_config and key in request_config:
|
||||
return request_config[key]
|
||||
# 如果没有设置,返回默认值
|
||||
return _RAG_CONFIG_DEFAULT[key]
|
||||
# 其他配置项直接返回默认值
|
||||
return _RAG_CONFIG_DEFAULT.get(key)
|
||||
|
||||
def get(self, key: str, default=None):
|
||||
"""支持 get() 方法"""
|
||||
try:
|
||||
return self[key]
|
||||
except KeyError:
|
||||
return default
|
||||
|
||||
def __contains__(self, key: str):
|
||||
"""支持 in 操作符"""
|
||||
return key in _RAG_CONFIG_DEFAULT
|
||||
|
||||
def keys(self):
|
||||
"""返回所有键"""
|
||||
return _RAG_CONFIG_DEFAULT.keys()
|
||||
|
||||
def values(self):
|
||||
"""返回所有值(动态读取用户配置)"""
|
||||
return [self[key] for key in _RAG_CONFIG_DEFAULT.keys()]
|
||||
|
||||
def items(self):
|
||||
"""返回所有键值对(动态读取用户配置)"""
|
||||
return [(key, self[key]) for key in _RAG_CONFIG_DEFAULT.keys()]
|
||||
|
||||
# ==================== RAG服务配置 ====================
|
||||
# 使用动态配置类,支持多用户并发时自动读取各自的配置
|
||||
RAG_CONFIG = DynamicRAGConfig()
|
||||
|
||||
def set_request_user_config(x_user_id: Optional[str] = None,
|
||||
x_user_name: Optional[str] = None,
|
||||
x_role: Optional[str] = None,
|
||||
text_kb_id: Optional[str] = None,
|
||||
text_file_id: Optional[str] = None):
|
||||
"""
|
||||
设置当前请求的用户配置和知识库参数(线程安全)
|
||||
|
||||
Args:
|
||||
x_user_id: 用户ID
|
||||
x_user_name: 用户名称
|
||||
x_role: 角色
|
||||
text_kb_id: 文本检索知识库ID
|
||||
text_file_id: 文本检索文件ID
|
||||
"""
|
||||
config = {}
|
||||
if x_user_id is not None:
|
||||
config["x-user-id"] = x_user_id
|
||||
if x_user_name is not None:
|
||||
config["x-user-name"] = x_user_name
|
||||
if x_role is not None:
|
||||
config["x-role"] = x_role
|
||||
# 仅当非空字符串时才设置,空字符串时不传入 RAG 接口
|
||||
if text_kb_id is not None and str(text_kb_id).strip():
|
||||
config["kb-id"] = str(text_kb_id).strip()
|
||||
if text_file_id is not None and str(text_file_id).strip():
|
||||
config["file-id"] = str(text_file_id).strip()
|
||||
_request_user_config.set(config if config else None)
|
||||
|
||||
# ==================== 图谱检索服务配置 ====================
|
||||
GRAPH_SEARCH_CONFIG = {
|
||||
"search_endpoint": "http://kgrag:9085/search_graph",
|
||||
"graph_timeout": float("30.0"),
|
||||
"default_top_k": int("10"),
|
||||
"operation_search_endpoint": "http://kgrag:9085/operation_graph_search"
|
||||
}
|
||||
|
||||
# ==================== 设备到系统检索服务配置 ====================
|
||||
DEVICE_SYSTEM_CONFIG = {
|
||||
"endpoint": "http://kgrag:9085/device_to_system",
|
||||
"timeout": float("30.0"),
|
||||
"default_min_hops": int("2"),
|
||||
"default_max_hops": int("8"),
|
||||
"default_top_k": int("10")
|
||||
}
|
||||
|
||||
# ==================== 图册检索服务配置 ====================
|
||||
ATLAS_CONFIG = {
|
||||
"endpoint": "http://kgrag:9085/atlas_retrieval",
|
||||
"timeout": float("30.0"),
|
||||
"default_top_k": int("10")
|
||||
}
|
||||
|
||||
# ==================== 工作流配置 ====================
|
||||
WORKFLOW_CONFIG = {
|
||||
"max_retry_count": int("2"), # 最大重试次数
|
||||
"image_clarity_threshold": float("0.7"), # 图片清晰度阈值
|
||||
"default_timeout": int("30") # 默认超时时间(秒)
|
||||
}
|
||||
|
||||
# ==================== 服务器配置 ====================
|
||||
SERVER_CONFIG = {
|
||||
"host": "agent",
|
||||
"port": "59088",
|
||||
"base_url": "http://agent:9088"
|
||||
}
|
||||
|
||||
# ==================== ASR服务配置 ====================
|
||||
ASR_CONFIG = {
|
||||
"api_base": "http://asr:9900/v1",
|
||||
"model": "base",
|
||||
"language": "zh",
|
||||
2"timeout": int("30")
|
||||
}
|
||||
|
||||
# ==================== VLM服务配置 ====================
|
||||
VLM_CONFIG = {
|
||||
"api_base" : "http://kgrag:9085/v1",
|
||||
"model" : "Qwen3-VL-8B"
|
||||
}
|
||||
|
||||
# ==================== Neo4J配置 ====================
|
||||
NEO4J_CONFIG = {
|
||||
"uri": "neo4j://neo4j:7687",
|
||||
"user": "neo4j",
|
||||
"password": "zdht123@"
|
||||
}
|
||||
|
||||
# ==================== 知识库匹配配置 ====================
|
||||
KB_TREE_CONFIG = {
|
||||
"url": "http://htknow:8080/api/v1/knowledge/knowledge_base/tree"
|
||||
}
|
||||
|
||||
# ==================== 维修反馈统计配置 ====================
|
||||
REPAIR_FEEDBACK_CONFIG = {
|
||||
"url": "http://webui:22000",
|
||||
"email": "15088888888@163.com",
|
||||
"password": "11223344"
|
||||
}
|
||||
|
||||
# ==================== 数据库配置 ====================
|
||||
POSTGRES_CONNECTION_STRING = "postgresql://postgres:0737d36c55d3614fc0b36d69e75fcea97283cdcebda7fe25@postgres:5432/hj_webui"
|
||||
|
||||
#POSTGRES_CONNECTION_STRING = "postgresql+psycopg://postgres:password@postgres:5432/an_webui"
|
||||
POSTGRES_SQLALCHEMY_URL = "postgresql+psycopg://postgres:password@postgres:5432/an_webui"
|
||||
|
||||
|
||||
# ==================== 索引配置 ====================
|
||||
INDEX_CONFIG = {
|
||||
"NAME_PROPERTY" : "名称",
|
||||
"FULLTEXT_PROPERTY" :"fulltext",
|
||||
"EMBEDDING_PROPERTY" : "embedding",
|
||||
"SEARCH_LABEL" : "Searchable",
|
||||
"VECTOR_INDEX_NAME" : "searchable_vector",
|
||||
"FULLTEXT_INDEX_NAME" : "global_searchable_content_search"
|
||||
}
|
||||
|
||||
# ==================== sql表格字段与neo4j节点映射 ====================
|
||||
MAP_INS={
|
||||
"故障模式" : "fault",
|
||||
"系统" : "system_name",
|
||||
"设备" : "device_name",
|
||||
"舷号" : "ship_number",
|
||||
}
|
||||
|
||||
# ==================== neo4j配置 ====================
|
||||
NEO4J_CONFIG = {
|
||||
"uri": "neo4j://neo4j:7687",
|
||||
"user": "neo4j",
|
||||
"password": "zdht123@"
|
||||
}
|
||||
221
config521.py
@ -1,221 +0,0 @@
|
||||
"""
|
||||
项目配置文件
|
||||
从.env文件读取配置
|
||||
"""
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
from contextvars import ContextVar
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
# 加载.env文件
|
||||
load_dotenv()
|
||||
|
||||
# ==================== 大模型配置 ====================
|
||||
LLM_CONFIG = {
|
||||
"model": "Qwen3.5-35B-A3B",
|
||||
# "model": "Qwen3-32B",
|
||||
"base_url": "http://192.168.0.46:59800/v1",
|
||||
"api_key": "gpustack_dee9ca823290886c_5edfc86aeeeceb1e9ee5162941cb2cb5",
|
||||
"temperature": float("0.1"),
|
||||
"max_tokens": int("4096"),
|
||||
"timeout": int("30")
|
||||
}
|
||||
|
||||
# ==================== 嵌入模型配置 ====================
|
||||
EMBEDDING_CONFIG = {
|
||||
"model": "bge-m3",
|
||||
"base_url": "http://192.168.0.46:59700/v1/embeddings",
|
||||
"api_key": "gpustack_dee9ca823290886c_5edfc86aeeeceb1e9ee5162941cb2cb5",
|
||||
}
|
||||
|
||||
|
||||
# ==================== 线程安全的请求上下文配置 ====================
|
||||
# 使用 contextvars 存储每个请求的用户配置,确保多用户并发时不会相互干扰
|
||||
_request_user_config: ContextVar[Optional[Dict[str, str]]] = ContextVar('request_user_config', default=None)
|
||||
|
||||
# RAG配置的默认值
|
||||
_RAG_CONFIG_DEFAULT = {
|
||||
"search_endpoint": "http://192.168.0.46:11000/api/v1/knowledge/search/",
|
||||
"x-user-id": "1",
|
||||
"x-user-name": "testuser",
|
||||
"x-role": "admin",
|
||||
"kb-id": None,
|
||||
"file-id": None
|
||||
}
|
||||
|
||||
class DynamicRAGConfig(dict):
|
||||
"""
|
||||
动态RAG配置类,支持线程安全的请求级配置
|
||||
当访问用户相关配置时,自动从当前请求上下文读取
|
||||
其他配置项直接返回默认值
|
||||
兼容字典的所有操作方式(如 get(), [], in 等)
|
||||
"""
|
||||
def __getitem__(self, key: str):
|
||||
# 如果是用户相关配置或知识库参数,从当前请求上下文读取
|
||||
if key in ["x-user-id", "x-user-name", "x-role", "kb-id", "file-id"]:
|
||||
request_config = _request_user_config.get()
|
||||
if request_config and key in request_config:
|
||||
return request_config[key]
|
||||
# 如果没有设置,返回默认值
|
||||
return _RAG_CONFIG_DEFAULT[key]
|
||||
# 其他配置项直接返回默认值
|
||||
return _RAG_CONFIG_DEFAULT.get(key)
|
||||
|
||||
def get(self, key: str, default=None):
|
||||
"""支持 get() 方法"""
|
||||
try:
|
||||
return self[key]
|
||||
except KeyError:
|
||||
return default
|
||||
|
||||
def __contains__(self, key: str):
|
||||
"""支持 in 操作符"""
|
||||
return key in _RAG_CONFIG_DEFAULT
|
||||
|
||||
def keys(self):
|
||||
"""返回所有键"""
|
||||
return _RAG_CONFIG_DEFAULT.keys()
|
||||
|
||||
def values(self):
|
||||
"""返回所有值(动态读取用户配置)"""
|
||||
return [self[key] for key in _RAG_CONFIG_DEFAULT.keys()]
|
||||
|
||||
def items(self):
|
||||
"""返回所有键值对(动态读取用户配置)"""
|
||||
return [(key, self[key]) for key in _RAG_CONFIG_DEFAULT.keys()]
|
||||
|
||||
# ==================== RAG服务配置 ====================
|
||||
# 使用动态配置类,支持多用户并发时自动读取各自的配置
|
||||
RAG_CONFIG = DynamicRAGConfig()
|
||||
|
||||
def set_request_user_config(x_user_id: Optional[str] = None,
|
||||
x_user_name: Optional[str] = None,
|
||||
x_role: Optional[str] = None,
|
||||
text_kb_id: Optional[str] = None,
|
||||
text_file_id: Optional[str] = None):
|
||||
"""
|
||||
设置当前请求的用户配置和知识库参数(线程安全)
|
||||
|
||||
Args:
|
||||
x_user_id: 用户ID
|
||||
x_user_name: 用户名称
|
||||
x_role: 角色
|
||||
text_kb_id: 文本检索知识库ID
|
||||
text_file_id: 文本检索文件ID
|
||||
"""
|
||||
config = {}
|
||||
if x_user_id is not None:
|
||||
config["x-user-id"] = x_user_id
|
||||
if x_user_name is not None:
|
||||
config["x-user-name"] = x_user_name
|
||||
if x_role is not None:
|
||||
config["x-role"] = x_role
|
||||
# 仅当非空字符串时才设置,空字符串时不传入 RAG 接口
|
||||
if text_kb_id is not None and str(text_kb_id).strip():
|
||||
config["kb-id"] = str(text_kb_id).strip()
|
||||
if text_file_id is not None and str(text_file_id).strip():
|
||||
config["file-id"] = str(text_file_id).strip()
|
||||
_request_user_config.set(config if config else None)
|
||||
|
||||
# ==================== 图谱检索服务配置 ====================
|
||||
GRAPH_SEARCH_CONFIG = {
|
||||
"search_endpoint": "http://192.168.0.46:59085/search_graph",
|
||||
"graph_timeout": float("30.0"),
|
||||
"default_top_k": int("10"),
|
||||
"operation_search_endpoint": "http://192.168.0.46:59085/operation_graph_search"
|
||||
}
|
||||
|
||||
# ==================== 设备到系统检索服务配置 ====================
|
||||
DEVICE_SYSTEM_CONFIG = {
|
||||
"endpoint": "http://192.168.0.46:59085/device_to_system",
|
||||
"timeout": float("30.0"),
|
||||
"default_min_hops": int("2"),
|
||||
"default_max_hops": int("8"),
|
||||
"default_top_k": int("10")
|
||||
}
|
||||
|
||||
# ==================== 图册检索服务配置 ====================
|
||||
ATLAS_CONFIG = {
|
||||
"endpoint": "http://192.168.0.46:59085/atlas_retrieval",
|
||||
"timeout": float("30.0"),
|
||||
"default_top_k": int("10")
|
||||
}
|
||||
|
||||
# ==================== 工作流配置 ====================
|
||||
WORKFLOW_CONFIG = {
|
||||
"max_retry_count": int("2"), # 最大重试次数
|
||||
"image_clarity_threshold": float("0.7"), # 图片清晰度阈值
|
||||
"default_timeout": int("30") # 默认超时时间(秒)
|
||||
}
|
||||
|
||||
# ==================== 服务器配置 ====================
|
||||
SERVER_CONFIG = {
|
||||
"host": "192.168.0.46",
|
||||
"port": "59088",
|
||||
"base_url": "http://192.168.0.46:59088"
|
||||
}
|
||||
|
||||
# ==================== ASR服务配置 ====================
|
||||
ASR_CONFIG = {
|
||||
"api_base": "http://192.168.0.46:59900/v1",
|
||||
"model": "base",
|
||||
"language": "zh",
|
||||
"timeout": int("30")
|
||||
}
|
||||
|
||||
# ==================== VLM服务配置 ====================
|
||||
VLM_CONFIG = {
|
||||
"api_base" : "http://192.168.0.46:59801/v1",
|
||||
"model" : "Qwen3-VL-8B"
|
||||
}
|
||||
|
||||
# ==================== 任务类型映射 ====================
|
||||
TASK_TYPE_MAPPING = {
|
||||
"fault_diagnosis": "故障诊断",
|
||||
"repair": "维修",
|
||||
"statistics": "统计",
|
||||
"qa": "常规问答",
|
||||
"report": "报告生成"
|
||||
}
|
||||
|
||||
# ==================== Neo4J配置 ====================
|
||||
NEO4J_CONFIG = {
|
||||
"uri": "neo4j://192.168.0.46:57687",
|
||||
"user": "neo4j",
|
||||
"password": "zdht123@"
|
||||
}
|
||||
|
||||
# ==================== 知识库匹配配置 ====================
|
||||
KB_TREE_CONFIG = {
|
||||
"url": "http://192.168.0.46:11000/api/v1/knowledge/knowledge_base/tree"
|
||||
}
|
||||
|
||||
# ==================== 维修反馈统计配置 ====================
|
||||
REPAIR_FEEDBACK_CONFIG = {
|
||||
"url": "http://192.168.0.46:22000",
|
||||
"email": "15088888888@163.com",
|
||||
"password": "11223344"
|
||||
}
|
||||
|
||||
# ==================== 舷号-型号映射配置 ====================
|
||||
# 格式:{型号名称: {"numbers": [舷号列表], "aliases": [别名列表], "names": [舰名列表]}}
|
||||
SHIP_MODEL_MAPPING = {
|
||||
"055型驱逐舰": {
|
||||
"numbers": ["102", "103", "104", "105", "106", "107", "108"],
|
||||
"aliases": ["055", "055型", "055驱逐舰", "万吨大驱"],
|
||||
"names": ["拉萨舰", "鞍山舰", "无锡舰", "大连舰", "延安舰", "遵义舰", "咸阳舰"],
|
||||
},
|
||||
"052D型驱逐舰": {
|
||||
"numbers": ["172", "173", "174", "175", "163"],
|
||||
"aliases": ["052D", "052D型", "052D驱逐舰"],
|
||||
"names": ["昆明舰", "长沙舰", "合肥舰", "银川舰", "南昌舰"],
|
||||
},
|
||||
"054A型护卫舰": {
|
||||
"numbers": ["529", "530", "547", "568", "570"],
|
||||
"aliases": ["054A", "054A型", "054A护卫舰"],
|
||||
"names": ["舟山舰", "徐州舰", "临沂舰", "衡阳舰", "黄山舰"],
|
||||
},
|
||||
}
|
||||
|
||||
# ==================== 数据库配置 ====================
|
||||
POSTGRES_CONNECTION_STRING = "postgresql://postgres:password@192.168.0.46:5432/an_webui"
|
||||
BIN
docker-images/wx_agent_latest.docker-save.tar.gz
(Stored with Git LFS)
@ -176,10 +176,10 @@ def route_to_workflow(state: MainAgentState) -> Dict[str, Any]:
|
||||
print(f"[主脑Agent] 路由到: {route_flag}")
|
||||
print(f"[主脑Agent] 参数: combined_query={combined_query[:100]}...")
|
||||
|
||||
if route_flag == "问题排查":
|
||||
detail = "正在调用问题排查智能体"
|
||||
elif route_flag == "百科问答":
|
||||
detail = "正在调用百科问答智能体"
|
||||
if route_flag == "故障排查与修理":
|
||||
detail = "正在调用故障排查与修理智能体"
|
||||
elif route_flag == "舰船百科":
|
||||
detail = "正在调用舰船百科智能体"
|
||||
elif route_flag == "操作使用":
|
||||
detail = "正在调用操作使用智能体"
|
||||
elif route_flag == "统计":
|
||||
|
||||
285
main_agent63.py
@ -1,285 +0,0 @@
|
||||
"""
|
||||
主脑Agent - 简化版(无意图分类)
|
||||
|
||||
职责:
|
||||
1. 输入处理:图片、音频、文本提取
|
||||
2. 直接路由:根据外部传入的 route_flag 决定调用哪个工作流
|
||||
|
||||
工作流列表(来自 workflow_registry):
|
||||
- 故障排查与修理
|
||||
- 舰船百科
|
||||
- 操作使用
|
||||
"""
|
||||
|
||||
from typing import TypedDict, Literal, Optional, Dict, Any, List
|
||||
from langgraph.graph import StateGraph, START, END
|
||||
from tools.function_tool import (
|
||||
vlm_image_to_text,
|
||||
asr_audio_to_text,
|
||||
detect_input_type
|
||||
)
|
||||
from modelsAPI.model_api import OpenaiAPI
|
||||
from utils.function_tracker import track_function_calls
|
||||
from workflow_registry import VALID_ROUTE_FLAGS
|
||||
import json
|
||||
import re
|
||||
|
||||
|
||||
class MainAgentState(TypedDict):
|
||||
"""主脑Agent状态"""
|
||||
raw_input: Dict[str, Any]
|
||||
has_image: bool
|
||||
has_audio: bool
|
||||
has_query: bool
|
||||
has_file_text: bool
|
||||
input_type: str
|
||||
|
||||
history_message: str
|
||||
extracted_text: str
|
||||
image_description: str
|
||||
asr_text: str
|
||||
file_text: str
|
||||
combined_query: str
|
||||
|
||||
workflow_result: Optional[Dict[str, Any]]
|
||||
final_response: str
|
||||
error_message: str
|
||||
|
||||
route_flag: str
|
||||
route_params: Dict[str, Any]
|
||||
|
||||
|
||||
def detect_input_type_node(state: MainAgentState) -> Dict[str, Any]:
|
||||
"""检测输入类型"""
|
||||
raw = state["raw_input"]
|
||||
result = detect_input_type.invoke({"raw_input": raw})
|
||||
|
||||
has_image = result.get("has_image", False)
|
||||
has_audio = result.get("has_audio", False)
|
||||
has_query = result.get("has_query", False)
|
||||
|
||||
has_file_text = False
|
||||
file_text_v = raw.get("file_text")
|
||||
if isinstance(file_text_v, str) and file_text_v.strip():
|
||||
has_file_text = True
|
||||
|
||||
input_type = "mixed"
|
||||
count = sum([has_image, has_audio, has_query, has_file_text])
|
||||
if count == 1:
|
||||
if has_image:
|
||||
input_type = "image"
|
||||
elif has_audio:
|
||||
input_type = "audio"
|
||||
elif has_query:
|
||||
input_type = "text"
|
||||
elif has_file_text:
|
||||
input_type = "file"
|
||||
|
||||
return {
|
||||
"has_image": has_image,
|
||||
"has_audio": has_audio,
|
||||
"has_query": has_query,
|
||||
"has_file_text": has_file_text,
|
||||
"input_type": input_type,
|
||||
}
|
||||
|
||||
|
||||
async def process_multimodal_input(state: MainAgentState) -> Dict[str, Any]:
|
||||
"""处理多模态输入"""
|
||||
has_image = state.get("has_image", False)
|
||||
has_audio = state.get("has_audio", False)
|
||||
has_query = state.get("has_query", False)
|
||||
has_file_text = state.get("has_file_text", False)
|
||||
raw = state["raw_input"]
|
||||
|
||||
image_description = ""
|
||||
asr_text = ""
|
||||
extracted_text = ""
|
||||
|
||||
try:
|
||||
if has_image:
|
||||
image_path = raw.get("image", "")
|
||||
if image_path:
|
||||
try:
|
||||
image_description = await vlm_image_to_text.ainvoke({
|
||||
"image_path": image_path
|
||||
})
|
||||
if not image_description or image_description.startswith("VLM 调用失败"):
|
||||
image_description = ""
|
||||
except Exception:
|
||||
image_description = ""
|
||||
|
||||
if has_audio:
|
||||
audio_path = raw.get("audio", "")
|
||||
if audio_path:
|
||||
try:
|
||||
asr_text = await asr_audio_to_text.ainvoke({
|
||||
"audio_path": audio_path
|
||||
})
|
||||
if not asr_text or asr_text.startswith("ASR 调用失败"):
|
||||
asr_text = ""
|
||||
except Exception:
|
||||
asr_text = ""
|
||||
|
||||
text_parts = []
|
||||
if raw.get("query"):
|
||||
text_parts.append(raw["query"])
|
||||
if image_description:
|
||||
text_parts.append(f"[图片描述] {image_description}")
|
||||
if asr_text:
|
||||
text_parts.append(f"[语音转文字] {asr_text}")
|
||||
if raw.get("file_text"):
|
||||
text_parts.append(f"[文件内容] {raw['file_text']}")
|
||||
|
||||
extracted_text = "\n".join(text_parts) if text_parts else ""
|
||||
combined_query = raw.get("query", "") or extracted_text
|
||||
|
||||
return {
|
||||
"extracted_text": extracted_text,
|
||||
"image_description": image_description,
|
||||
"asr_text": asr_text,
|
||||
"combined_query": combined_query,
|
||||
"file_text": raw.get("file_text", ""),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"error_message": f"输入处理失败: {str(e)}",
|
||||
"extracted_text": "",
|
||||
}
|
||||
|
||||
# @track_function_calls
|
||||
def route_to_workflow(state: MainAgentState) -> Dict[str, Any]:
|
||||
"""
|
||||
🤖分析意图,调用相关智能体
|
||||
"""
|
||||
route_flag = state.get("route_flag", "故障排查与修理")
|
||||
|
||||
if route_flag not in VALID_ROUTE_FLAGS:
|
||||
route_flag = "故障排查与修理"
|
||||
|
||||
query_text = state.get("extracted_text", "")
|
||||
combined_query = state.get("combined_query", "") or query_text
|
||||
|
||||
raw_input = state.get("raw_input", {})
|
||||
history_message = raw_input.get("history_message", "")
|
||||
|
||||
route_params = {
|
||||
"extracted_text": query_text,
|
||||
"combined_query": combined_query,
|
||||
"history_message": history_message,
|
||||
}
|
||||
|
||||
if raw_input.get("file_text"):
|
||||
route_params["file_text"] = raw_input["file_text"]
|
||||
|
||||
print(f"[主脑Agent] 路由到: {route_flag}")
|
||||
print(f"[主脑Agent] 参数: combined_query={combined_query[:100]}...")
|
||||
|
||||
if route_flag == "故障排查与修理":
|
||||
detail = "正在调用故障排查与修理智能体"
|
||||
elif route_flag == "舰船百科":
|
||||
detail = "正在调用舰船百科智能体"
|
||||
elif route_flag == "操作使用":
|
||||
detail = "正在调用操作使用智能体"
|
||||
elif route_flag == "统计":
|
||||
detail = "正在调用统计智能体"
|
||||
else:
|
||||
detail = "正在调用通用智能体"
|
||||
|
||||
return {
|
||||
"route_flag": route_flag,
|
||||
"route_params": route_params,
|
||||
"__detail__": detail,
|
||||
}
|
||||
|
||||
|
||||
def create_main_agent():
|
||||
"""创建主脑Agent"""
|
||||
workflow = StateGraph(MainAgentState)
|
||||
|
||||
workflow.add_node("detect_input_type", detect_input_type_node)
|
||||
workflow.add_node("process_input", process_multimodal_input)
|
||||
workflow.add_node("route", route_to_workflow)
|
||||
|
||||
workflow.add_edge(START, "detect_input_type")
|
||||
workflow.add_edge("detect_input_type", "process_input")
|
||||
workflow.add_edge("process_input", "route")
|
||||
workflow.add_edge("route", END)
|
||||
|
||||
return workflow.compile()
|
||||
|
||||
|
||||
def filter_image_urls(history_message: str) -> Any:
|
||||
"""过滤历史消息中的图片URL"""
|
||||
if not history_message:
|
||||
return history_message
|
||||
|
||||
try:
|
||||
history_messages = json.loads(history_message) if isinstance(history_message, str) else history_message
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return history_message
|
||||
|
||||
if not isinstance(history_messages, list):
|
||||
return history_messages
|
||||
|
||||
filtered_messages = []
|
||||
for msg in history_messages:
|
||||
if not isinstance(msg, dict):
|
||||
filtered_messages.append(msg)
|
||||
continue
|
||||
|
||||
new_msg = msg.copy()
|
||||
content = msg.get("content")
|
||||
|
||||
if isinstance(content, list):
|
||||
text_parts = []
|
||||
for content_item in content:
|
||||
if isinstance(content_item, dict):
|
||||
if content_item.get("type") == "text":
|
||||
text_value = content_item.get("text", "")
|
||||
if text_value:
|
||||
text_parts.append(text_value)
|
||||
elif isinstance(content_item, str):
|
||||
text_parts.append(content_item)
|
||||
|
||||
if text_parts:
|
||||
new_msg["content"] = "".join(text_parts)
|
||||
filtered_messages.append(new_msg)
|
||||
else:
|
||||
filtered_messages.append(new_msg)
|
||||
|
||||
return filtered_messages
|
||||
|
||||
|
||||
async def extract_conversation_title(content: str) -> str:
|
||||
"""提取对话标题"""
|
||||
try:
|
||||
prompt = f"请从以下对话内容中提取一个简短的标题(不超过20字):\n\n{content[:500]}"
|
||||
response = await OpenaiAPI.open_api_chat_without_thinking(
|
||||
query=prompt,
|
||||
model=None,
|
||||
system_prompt="你是一个标题提取助手,请从对话内容中提取一个简短的标题。",
|
||||
messages=[]
|
||||
)
|
||||
title = response.strip() if response else "新对话"
|
||||
if len(title) > 30:
|
||||
title = title[:30] + "..."
|
||||
return title
|
||||
except Exception:
|
||||
return "新对话"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import asyncio
|
||||
|
||||
async def test():
|
||||
print("测试主脑Agent")
|
||||
agent = create_main_agent()
|
||||
result = await agent.ainvoke({
|
||||
"raw_input": {"query": "101舰主机发生异响"},
|
||||
"route_flag": "故障排查与修理"
|
||||
})
|
||||
print(f"结果: {result}")
|
||||
|
||||
asyncio.run(test())
|
||||
@ -1,304 +0,0 @@
|
||||
import asyncio
|
||||
import httpx
|
||||
from typing import List, Union
|
||||
import os
|
||||
import numpy as np
|
||||
from openai import OpenAI
|
||||
from config import LLM_CONFIG, EMBEDDING_CONFIG
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
|
||||
class OpenaiAPI:
|
||||
@staticmethod
|
||||
async def open_api_chat_without_thinking(
|
||||
query: str = None,
|
||||
model: str = None,
|
||||
json_output: bool = False,
|
||||
system_prompt: str = None,
|
||||
messages: list = None,
|
||||
enable_thinking: bool = True,
|
||||
temperature: float = 0.1
|
||||
) -> str:
|
||||
if model is None:
|
||||
model = LLM_CONFIG["model"]
|
||||
if system_prompt is None:
|
||||
system_prompt = "我是一个专业的AI助手,请用中文回答问题。请直接给出最终答案,不要包含任何推理步骤、思考过程、解释性文字或前缀。"
|
||||
|
||||
|
||||
# 构建消息
|
||||
message_list = [{"role": "system", "content": system_prompt}]
|
||||
|
||||
if messages is not None:
|
||||
message_list.extend(messages)
|
||||
if query is not None:
|
||||
message_list.append({"role": "user", "content": query})
|
||||
|
||||
client = AsyncOpenAI(
|
||||
api_key=LLM_CONFIG["api_key"],
|
||||
base_url=LLM_CONFIG["base_url"],
|
||||
)
|
||||
|
||||
kwargs = {
|
||||
"model": model,
|
||||
"messages": message_list,
|
||||
"temperature": temperature,
|
||||
"stream": False,
|
||||
"max_tokens":LLM_CONFIG["max_tokens"]
|
||||
}
|
||||
|
||||
kwargs["extra_body"] = {"chat_template_kwargs": {"enable_thinking": False}}
|
||||
|
||||
if json_output:
|
||||
kwargs["response_format"] = {"type": "json_object"}
|
||||
|
||||
print("model API. history message", message_list)
|
||||
response = await client.chat.completions.create(**kwargs)
|
||||
return response.choices[0].message.content
|
||||
|
||||
@staticmethod
|
||||
async def open_api_vl_without_thinking(
|
||||
image_url: str
|
||||
) -> str:
|
||||
"""
|
||||
专门用于从图片中提取【设备名称】和【故障现象】。
|
||||
自动过滤思考过程,仅返回核心结果。
|
||||
|
||||
参数:
|
||||
image_url: Base64 格式的图片数据 (data:image/...;base64,...)
|
||||
model: 模型名称,默认为配置中的模型
|
||||
custom_instruction: 额外的特定指令 (可选)
|
||||
"""
|
||||
|
||||
# 1. 确定模型名称
|
||||
model = LLM_CONFIG.get("model")
|
||||
|
||||
# 2. 构建强约束的 System Prompt
|
||||
# 核心目标:禁止思考标签,禁止废话,只给结果
|
||||
system_prompt = (
|
||||
"你是一个工业视觉分析专家。你的任务是从图片中识别设备并诊断故障。\n"
|
||||
"【严格约束】\n"
|
||||
"1. 绝对禁止输出 <think>, <think>, <reasoning> 等任何思考过程标签。\n"
|
||||
"2. 绝对禁止输出'好的'、'根据图片'、'分析如下'等开场白或结束语。\n"
|
||||
"3. 直接输出最终结论,格式必须严格遵守下面的模板。"
|
||||
)
|
||||
|
||||
# 3. 构建针对性的 User Prompt
|
||||
default_task = (
|
||||
"请分析这张图片,描述图片内容,重点关注以下信息\n"
|
||||
"1. 设备识别:图中是什么设备?型号或标签是否可见?\n"
|
||||
"2. 状态评估:仪表盘数值是否在正常区间?指示灯颜色是什么?\n"
|
||||
"3. 异常检测:是否存在漏油、生锈、电缆破损、螺丝松动或烟雾?\n"
|
||||
"4. 环境风险:周围是否有杂物堆放或安全隐患?\n"
|
||||
"请用专业、客观、简短的语言描述。"
|
||||
)
|
||||
|
||||
final_user_prompt = default_task
|
||||
|
||||
# 4. 初始化客户端
|
||||
client = AsyncOpenAI(
|
||||
api_key=LLM_CONFIG.get("api_key"),
|
||||
base_url=LLM_CONFIG.get("base_url"),
|
||||
)
|
||||
|
||||
# 5. 构建请求参数
|
||||
kwargs = {
|
||||
"model": model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": final_user_prompt},
|
||||
{"type": "image_url", "image_url": {"url": image_url}}
|
||||
]
|
||||
}
|
||||
],
|
||||
"temperature": 0.1, # 低温度以保证事实准确性
|
||||
"stream": False,
|
||||
"max_tokens": LLM_CONFIG.get("max_tokens"),
|
||||
}
|
||||
|
||||
# 尝试通过参数关闭思考 (取决于后端支持情况)
|
||||
kwargs["extra_body"] = {"chat_template_kwargs": {"enable_thinking": False}}
|
||||
|
||||
try:
|
||||
print(f"🚀 正在调用 {model} 进行设备故障分析...")
|
||||
response = await client.chat.completions.create(**kwargs)
|
||||
raw_content = response.choices[0].message.content or ""
|
||||
|
||||
print(f"✅ 分析完成:\n{raw_content}")
|
||||
return raw_content
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
error_msg = f"❌ 设备故障分析失败:{str(e)}\n{traceback.format_exc()}"
|
||||
print(error_msg)
|
||||
return f"Error: {str(e)}"
|
||||
|
||||
@staticmethod
|
||||
def get_embeddings(embedding_text: str):
|
||||
embedding_base_url = EMBEDDING_CONFIG["base_url"]
|
||||
|
||||
with httpx.Client(timeout=60) as client:
|
||||
response = client.post(
|
||||
embedding_base_url,
|
||||
json={"model": EMBEDDING_CONFIG["model"], "input": embedding_text},
|
||||
headers={"Authorization": EMBEDDING_CONFIG["api_key"]},
|
||||
)
|
||||
embedding_text = response.json()["data"][0]["embedding"]
|
||||
return embedding_text
|
||||
|
||||
@staticmethod
|
||||
async def get_embeddings_async(embedding_text: str) -> List[float]:
|
||||
embedding_base_url = EMBEDDING_CONFIG["base_url"]
|
||||
|
||||
async with httpx.AsyncClient(timeout=60) as client:
|
||||
response = await client.post(
|
||||
embedding_base_url,
|
||||
json={"model": EMBEDDING_CONFIG["model"], "input": embedding_text},
|
||||
headers={"Authorization": EMBEDDING_CONFIG["api_key"]},
|
||||
)
|
||||
embedding = response.json()["data"][0]["embedding"]
|
||||
return embedding
|
||||
|
||||
@staticmethod
|
||||
async def get_embeddings_batch_async(texts: List[str]) -> List[List[float]]:
|
||||
embedding_base_url = EMBEDDING_CONFIG["base_url"]
|
||||
|
||||
async with httpx.AsyncClient(timeout=120) as client:
|
||||
response = await client.post(
|
||||
embedding_base_url,
|
||||
json={"model": EMBEDDING_CONFIG["model"], "input": texts},
|
||||
headers={"Authorization": EMBEDDING_CONFIG["api_key"]},
|
||||
)
|
||||
data = response.json()["data"]
|
||||
sorted_data = sorted(data, key=lambda x: x["index"])
|
||||
embeddings = [item["embedding"] for item in sorted_data]
|
||||
return embeddings
|
||||
|
||||
@staticmethod
|
||||
def cosine_similarity(vec1: Union[List[float], np.ndarray], vec2: Union[List[float], np.ndarray]) -> float:
|
||||
v1 = np.array(vec1) if not isinstance(vec1, np.ndarray) else vec1
|
||||
v2 = np.array(vec2) if not isinstance(vec2, np.ndarray) else vec2
|
||||
|
||||
norm1 = np.linalg.norm(v1)
|
||||
norm2 = np.linalg.norm(v2)
|
||||
|
||||
if norm1 == 0 or norm2 == 0:
|
||||
return 0.0
|
||||
|
||||
return float(np.dot(v1, v2) / (norm1 * norm2))
|
||||
|
||||
@staticmethod
|
||||
def cosine_similarity_batch(query_vec: Union[List[float], np.ndarray],
|
||||
candidates_vecs: List[Union[List[float], np.ndarray]]) -> List[float]:
|
||||
query = np.array(query_vec) if not isinstance(query_vec, np.ndarray) else query_vec
|
||||
candidates = [np.array(v) if not isinstance(v, np.ndarray) else v for v in candidates_vecs]
|
||||
|
||||
query_norm = np.linalg.norm(query)
|
||||
if query_norm == 0:
|
||||
return [0.0] * len(candidates)
|
||||
|
||||
similarities = []
|
||||
for candidate in candidates:
|
||||
cand_norm = np.linalg.norm(candidate)
|
||||
if cand_norm == 0:
|
||||
similarities.append(0.0)
|
||||
else:
|
||||
sim = float(np.dot(query, candidate) / (query_norm * cand_norm))
|
||||
similarities.append(sim)
|
||||
|
||||
return similarities
|
||||
|
||||
@staticmethod
|
||||
async def hybrid_match_with_embeddings(
|
||||
query_text: str,
|
||||
candidates: List[dict],
|
||||
name_key: str = "display_name",
|
||||
embedding_key: str = "embedding",
|
||||
text_weight: float = 0.3,
|
||||
semantic_weight: float = 0.7,
|
||||
text_threshold: float = 0.3,
|
||||
semantic_threshold: float = 0.5
|
||||
) -> tuple:
|
||||
if not candidates:
|
||||
return None, 0.0
|
||||
|
||||
query_embedding = await OpenaiAPI.get_embeddings_async(query_text)
|
||||
|
||||
scored_candidates = []
|
||||
|
||||
for idx, candidate in enumerate(candidates):
|
||||
name = candidate.get(name_key, "")
|
||||
if not name:
|
||||
props = candidate.get("props", {})
|
||||
name = props.get("name") or props.get("名称") or props.get("设备名称") or ""
|
||||
|
||||
text_score = OpenaiAPI._compute_text_similarity(query_text, name)
|
||||
|
||||
props = candidate.get("props", {})
|
||||
candidate_embedding = props.get(embedding_key) or candidate.get(embedding_key)
|
||||
|
||||
if candidate_embedding:
|
||||
semantic_score = OpenaiAPI.cosine_similarity(query_embedding, candidate_embedding)
|
||||
else:
|
||||
semantic_score = 0.0
|
||||
|
||||
combined_score = text_weight * text_score + semantic_weight * semantic_score
|
||||
|
||||
scored_candidates.append({
|
||||
"candidate": candidate,
|
||||
"text_score": text_score,
|
||||
"semantic_score": semantic_score,
|
||||
"combined_score": combined_score,
|
||||
"index": idx
|
||||
})
|
||||
|
||||
scored_candidates.sort(key=lambda x: x["combined_score"], reverse=True)
|
||||
|
||||
best = scored_candidates[0]
|
||||
best_candidate = best["candidate"]
|
||||
best_score = best["combined_score"]
|
||||
|
||||
min_threshold = max(text_threshold * text_weight + semantic_threshold * semantic_weight, 0.3)
|
||||
|
||||
if best_score < min_threshold:
|
||||
return None, best_score
|
||||
|
||||
return best_candidate, best_score
|
||||
|
||||
@staticmethod
|
||||
def _compute_text_similarity(a: str, b: str) -> float:
|
||||
if not a or not b:
|
||||
return 0.0
|
||||
a = str(a).strip().lower()
|
||||
b = str(b).strip().lower()
|
||||
if not a or not b:
|
||||
return 0.0
|
||||
if a == b:
|
||||
return 1.0
|
||||
if a in b or b in a:
|
||||
return 0.9
|
||||
|
||||
set_a, set_b = set(a), set(b)
|
||||
inter = len(set_a & set_b)
|
||||
union = len(set_a | set_b) or 1
|
||||
jaccard = inter / union
|
||||
|
||||
len_diff = abs(len(a) - len(b))
|
||||
len_penalty = max(0.0, 1.0 - len_diff / max(len(a), len(b), 1))
|
||||
|
||||
return 0.5 * jaccard + 0.5 * len_penalty
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
answer = asyncio.run(OpenaiAPI.open_api_chat_without_thinking(
|
||||
query="你好",
|
||||
json_output=True,
|
||||
system_prompt="你是一个助手,请用中文回答问题。请直接给出最终答案,不要包含任何推理步骤、思考过程、解释性文字或前缀。",
|
||||
enable_thinking=True
|
||||
))
|
||||
print(answer)
|
||||
|
||||
@ -1,380 +0,0 @@
|
||||
import asyncio
|
||||
import httpx
|
||||
from typing import List, Union
|
||||
import os
|
||||
import numpy as np
|
||||
from openai import OpenAI
|
||||
from config import LLM_CONFIG, EMBEDDING_CONFIG
|
||||
from neo4j_graphrag.embeddings.base import Embedder
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
|
||||
class OpenaiAPI:
|
||||
@staticmethod
|
||||
async def open_api_chat_without_thinking(
|
||||
query: str = None,
|
||||
model: str = None,
|
||||
json_output: bool = False,
|
||||
system_prompt: str = None,
|
||||
messages: list = None,
|
||||
enable_thinking: bool = True,
|
||||
temperature: float = 0.1
|
||||
) -> str:
|
||||
if model is None:
|
||||
model = LLM_CONFIG["model"]
|
||||
if system_prompt is None:
|
||||
system_prompt = "我是一个专业的AI助手,请用中文回答问题。请直接给出最终答案,不要包含任何推理步骤、思考过程、解释性文字或前缀。"
|
||||
|
||||
|
||||
# 构建消息
|
||||
message_list = [{"role": "system", "content": system_prompt}]
|
||||
|
||||
if messages is not None:
|
||||
message_list.extend(messages)
|
||||
if query is not None:
|
||||
message_list.append({"role": "user", "content": query})
|
||||
|
||||
client = AsyncOpenAI(
|
||||
api_key=LLM_CONFIG["api_key"],
|
||||
base_url=LLM_CONFIG["base_url"],
|
||||
)
|
||||
|
||||
kwargs = {
|
||||
"model": model,
|
||||
"messages": message_list,
|
||||
"temperature": temperature,
|
||||
"stream": False,
|
||||
"max_tokens":LLM_CONFIG["max_tokens"]
|
||||
}
|
||||
|
||||
kwargs["extra_body"] = {"chat_template_kwargs": {"enable_thinking": False}}
|
||||
|
||||
if json_output:
|
||||
kwargs["response_format"] = {"type": "json_object"}
|
||||
|
||||
print("model API. history message", message_list)
|
||||
response = await client.chat.completions.create(**kwargs)
|
||||
return response.choices[0].message.content
|
||||
|
||||
@staticmethod
|
||||
async def open_api_chat_stream(
|
||||
query: str = None,
|
||||
model: str = None,
|
||||
json_output: bool = False,
|
||||
system_prompt: str = None,
|
||||
messages: list = None,
|
||||
enable_thinking: bool = True,
|
||||
temperature: float = 0.1
|
||||
):
|
||||
"""
|
||||
流式输出大模型响应
|
||||
|
||||
Yields:
|
||||
str: 流式内容片段
|
||||
"""
|
||||
if model is None:
|
||||
model = LLM_CONFIG["model"]
|
||||
if system_prompt is None:
|
||||
system_prompt = "我是一个专业的AI助手,请用中文回答问题。请直接给出最终答案,不要包含任何推理步骤、思考过程、解释性文字或前缀。"
|
||||
|
||||
# 构建消息
|
||||
message_list = [{"role": "system", "content": system_prompt}]
|
||||
|
||||
if messages is not None:
|
||||
message_list.extend(messages)
|
||||
if query is not None:
|
||||
message_list.append({"role": "user", "content": query})
|
||||
|
||||
client = AsyncOpenAI(
|
||||
api_key=LLM_CONFIG["api_key"],
|
||||
base_url=LLM_CONFIG["base_url"],
|
||||
)
|
||||
|
||||
kwargs = {
|
||||
"model": model,
|
||||
"messages": message_list,
|
||||
"temperature": temperature,
|
||||
"stream": True,
|
||||
"max_tokens":LLM_CONFIG["max_tokens"]
|
||||
}
|
||||
|
||||
kwargs["extra_body"] = {"chat_template_kwargs": {"enable_thinking": False}}
|
||||
|
||||
if json_output:
|
||||
kwargs["response_format"] = {"type": "json_object"}
|
||||
|
||||
print("model API stream. history message", message_list)
|
||||
|
||||
stream = await client.chat.completions.create(**kwargs)
|
||||
async for chunk in stream:
|
||||
if chunk.choices and len(chunk.choices) > 0:
|
||||
delta = chunk.choices[0].delta
|
||||
if delta.content:
|
||||
yield delta.content
|
||||
|
||||
@staticmethod
|
||||
async def open_api_vl_without_thinking(
|
||||
image_url: str
|
||||
) -> str:
|
||||
"""
|
||||
专门用于从图片中提取【设备名称】和【故障现象】。
|
||||
自动过滤思考过程,仅返回核心结果。
|
||||
|
||||
参数:
|
||||
image_url: Base64 格式的图片数据 (data:image/...;base64,...)
|
||||
model: 模型名称,默认为配置中的模型
|
||||
custom_instruction: 额外的特定指令 (可选)
|
||||
"""
|
||||
|
||||
# 1. 确定模型名称
|
||||
model = LLM_CONFIG.get("model")
|
||||
|
||||
# 2. 构建强约束的 System Prompt
|
||||
# 核心目标:禁止思考标签,禁止废话,只给结果
|
||||
system_prompt = (
|
||||
"你是一个工业视觉分析专家。你的任务是从图片中识别设备并诊断故障。\n"
|
||||
"【严格约束】\n"
|
||||
"1. 绝对禁止输出 <think>, <think>, <reasoning> 等任何思考过程标签。\n"
|
||||
"2. 绝对禁止输出'好的'、'根据图片'、'分析如下'等开场白或结束语。\n"
|
||||
"3. 直接输出最终结论,格式必须严格遵守下面的模板。"
|
||||
)
|
||||
|
||||
# 3. 构建针对性的 User Prompt
|
||||
default_task = (
|
||||
"请分析这张图片,描述图片内容,重点关注以下信息\n"
|
||||
"1. 设备识别:图中是什么设备?型号或标签是否可见?\n"
|
||||
"2. 状态评估:仪表盘数值是否在正常区间?指示灯颜色是什么?\n"
|
||||
"3. 异常检测:是否存在漏油、生锈、电缆破损、螺丝松动或烟雾?\n"
|
||||
"4. 环境风险:周围是否有杂物堆放或安全隐患?\n"
|
||||
"请用专业、客观、简短的语言描述。"
|
||||
)
|
||||
|
||||
final_user_prompt = default_task
|
||||
|
||||
# 4. 初始化客户端
|
||||
client = AsyncOpenAI(
|
||||
api_key=LLM_CONFIG.get("api_key"),
|
||||
base_url=LLM_CONFIG.get("base_url"),
|
||||
)
|
||||
|
||||
# 5. 构建请求参数
|
||||
kwargs = {
|
||||
"model": model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": final_user_prompt},
|
||||
{"type": "image_url", "image_url": {"url": image_url}}
|
||||
]
|
||||
}
|
||||
],
|
||||
"temperature": 0.1, # 低温度以保证事实准确性
|
||||
"stream": False,
|
||||
"max_tokens": LLM_CONFIG.get("max_tokens"),
|
||||
}
|
||||
|
||||
# 尝试通过参数关闭思考 (取决于后端支持情况)
|
||||
kwargs["extra_body"] = {"chat_template_kwargs": {"enable_thinking": False}}
|
||||
|
||||
try:
|
||||
print(f"🚀 正在调用 {model} 进行设备故障分析...")
|
||||
response = await client.chat.completions.create(**kwargs)
|
||||
raw_content = response.choices[0].message.content or ""
|
||||
|
||||
print(f"✅ 分析完成:\n{raw_content}")
|
||||
return raw_content
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
error_msg = f"❌ 设备故障分析失败:{str(e)}\n{traceback.format_exc()}"
|
||||
print(error_msg)
|
||||
return f"Error: {str(e)}"
|
||||
|
||||
@staticmethod
|
||||
def get_embeddings(embedding_text: str):
|
||||
embedding_base_url = EMBEDDING_CONFIG["base_url"]
|
||||
|
||||
with httpx.Client(timeout=60) as client:
|
||||
response = client.post(
|
||||
embedding_base_url,
|
||||
json={"model": EMBEDDING_CONFIG["model"], "input": embedding_text},
|
||||
headers={"Authorization": EMBEDDING_CONFIG["api_key"]},
|
||||
)
|
||||
embedding_text = response.json()["data"][0]["embedding"]
|
||||
return embedding_text
|
||||
|
||||
@staticmethod
|
||||
async def get_embeddings_async(embedding_text: str) -> List[float]:
|
||||
embedding_base_url = EMBEDDING_CONFIG["base_url"]
|
||||
|
||||
async with httpx.AsyncClient(timeout=60) as client:
|
||||
response = await client.post(
|
||||
embedding_base_url,
|
||||
json={"model": EMBEDDING_CONFIG["model"], "input": embedding_text},
|
||||
headers={"Authorization": EMBEDDING_CONFIG["api_key"]},
|
||||
)
|
||||
embedding = response.json()["data"][0]["embedding"]
|
||||
return embedding
|
||||
|
||||
@staticmethod
|
||||
async def get_embeddings_batch_async(texts: List[str]) -> List[List[float]]:
|
||||
embedding_base_url = EMBEDDING_CONFIG["base_url"]
|
||||
|
||||
async with httpx.AsyncClient(timeout=120) as client:
|
||||
response = await client.post(
|
||||
embedding_base_url,
|
||||
json={"model": EMBEDDING_CONFIG["model"], "input": texts},
|
||||
headers={"Authorization": EMBEDDING_CONFIG["api_key"]},
|
||||
)
|
||||
data = response.json()["data"]
|
||||
sorted_data = sorted(data, key=lambda x: x["index"])
|
||||
embeddings = [item["embedding"] for item in sorted_data]
|
||||
return embeddings
|
||||
|
||||
@staticmethod
|
||||
def cosine_similarity(vec1: Union[List[float], np.ndarray], vec2: Union[List[float], np.ndarray]) -> float:
|
||||
v1 = np.array(vec1) if not isinstance(vec1, np.ndarray) else vec1
|
||||
v2 = np.array(vec2) if not isinstance(vec2, np.ndarray) else vec2
|
||||
|
||||
norm1 = np.linalg.norm(v1)
|
||||
norm2 = np.linalg.norm(v2)
|
||||
|
||||
if norm1 == 0 or norm2 == 0:
|
||||
return 0.0
|
||||
|
||||
return float(np.dot(v1, v2) / (norm1 * norm2))
|
||||
|
||||
@staticmethod
|
||||
def cosine_similarity_batch(query_vec: Union[List[float], np.ndarray],
|
||||
candidates_vecs: List[Union[List[float], np.ndarray]]) -> List[float]:
|
||||
query = np.array(query_vec) if not isinstance(query_vec, np.ndarray) else query_vec
|
||||
candidates = [np.array(v) if not isinstance(v, np.ndarray) else v for v in candidates_vecs]
|
||||
|
||||
query_norm = np.linalg.norm(query)
|
||||
if query_norm == 0:
|
||||
return [0.0] * len(candidates)
|
||||
|
||||
similarities = []
|
||||
for candidate in candidates:
|
||||
cand_norm = np.linalg.norm(candidate)
|
||||
if cand_norm == 0:
|
||||
similarities.append(0.0)
|
||||
else:
|
||||
sim = float(np.dot(query, candidate) / (query_norm * cand_norm))
|
||||
similarities.append(sim)
|
||||
|
||||
return similarities
|
||||
|
||||
@staticmethod
|
||||
async def hybrid_match_with_embeddings(
|
||||
query_text: str,
|
||||
candidates: List[dict],
|
||||
name_key: str = "display_name",
|
||||
embedding_key: str = "embedding",
|
||||
text_weight: float = 0.3,
|
||||
semantic_weight: float = 0.7,
|
||||
text_threshold: float = 0.3,
|
||||
semantic_threshold: float = 0.5
|
||||
) -> tuple:
|
||||
if not candidates:
|
||||
return None, 0.0
|
||||
|
||||
query_embedding = await OpenaiAPI.get_embeddings_async(query_text)
|
||||
|
||||
scored_candidates = []
|
||||
|
||||
for idx, candidate in enumerate(candidates):
|
||||
name = candidate.get(name_key, "")
|
||||
if not name:
|
||||
props = candidate.get("props", {})
|
||||
name = props.get("name") or props.get("名称") or props.get("设备名称") or ""
|
||||
|
||||
text_score = OpenaiAPI._compute_text_similarity(query_text, name)
|
||||
|
||||
props = candidate.get("props", {})
|
||||
candidate_embedding = props.get(embedding_key) or candidate.get(embedding_key)
|
||||
|
||||
if candidate_embedding:
|
||||
semantic_score = OpenaiAPI.cosine_similarity(query_embedding, candidate_embedding)
|
||||
else:
|
||||
semantic_score = 0.0
|
||||
|
||||
combined_score = text_weight * text_score + semantic_weight * semantic_score
|
||||
|
||||
scored_candidates.append({
|
||||
"candidate": candidate,
|
||||
"text_score": text_score,
|
||||
"semantic_score": semantic_score,
|
||||
"combined_score": combined_score,
|
||||
"index": idx
|
||||
})
|
||||
|
||||
scored_candidates.sort(key=lambda x: x["combined_score"], reverse=True)
|
||||
|
||||
best = scored_candidates[0]
|
||||
best_candidate = best["candidate"]
|
||||
best_score = best["combined_score"]
|
||||
|
||||
min_threshold = max(text_threshold * text_weight + semantic_threshold * semantic_weight, 0.3)
|
||||
|
||||
if best_score < min_threshold:
|
||||
return None, best_score
|
||||
|
||||
return best_candidate, best_score
|
||||
|
||||
@staticmethod
|
||||
def _compute_text_similarity(a: str, b: str) -> float:
|
||||
if not a or not b:
|
||||
return 0.0
|
||||
a = str(a).strip().lower()
|
||||
b = str(b).strip().lower()
|
||||
if not a or not b:
|
||||
return 0.0
|
||||
if a == b:
|
||||
return 1.0
|
||||
if a in b or b in a:
|
||||
return 0.9
|
||||
|
||||
set_a, set_b = set(a), set(b)
|
||||
inter = len(set_a & set_b)
|
||||
union = len(set_a | set_b) or 1
|
||||
jaccard = inter / union
|
||||
|
||||
len_diff = abs(len(a) - len(b))
|
||||
len_penalty = max(0.0, 1.0 - len_diff / max(len(a), len(b), 1))
|
||||
|
||||
return 0.5 * jaccard + 0.5 * len_penalty
|
||||
|
||||
class LocalBgeM3Embeddings(Embedder):
|
||||
def __init__(self, base_url: str, api_key: str, model_name: str = "bge-m3"):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.api_key = api_key
|
||||
self.model_name = model_name
|
||||
|
||||
def embed_query(self, text: str) -> List[float]:
|
||||
return self.embed_documents([text])[0]
|
||||
|
||||
def embed_documents(self, texts: List[str]) -> List[List[float]]:
|
||||
url = self.base_url + "/embeddings"
|
||||
with httpx.Client(timeout=60) as client:
|
||||
response = client.post(
|
||||
url,
|
||||
json={"model": self.model_name, "input": texts},
|
||||
headers={"Authorization": f"Bearer {self.api_key}"},
|
||||
)
|
||||
return [item["embedding"] for item in response.json()["data"]]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
answer = asyncio.run(OpenaiAPI.open_api_chat_without_thinking(
|
||||
query="你好",
|
||||
json_output=True,
|
||||
system_prompt="你是一个助手,请用中文回答问题。请直接给出最终答案,不要包含任何推理步骤、思考过程、解释性文字或前缀。",
|
||||
enable_thinking=True
|
||||
))
|
||||
print(answer)
|
||||
|
||||
|
||||
BIN
picture/015ebb33d2a3_20260324072409.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
picture/08f8efd6f1d4_20260324110619.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
picture/1ee23229170a_20260324082344.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
picture/22f06245ed56_20260601022623.png
Normal file
|
After Width: | Height: | Size: 1023 KiB |
BIN
picture/334ee511347b_20260323061707.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
picture/44b07d453030_20260323024633.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
picture/50da933dcb11_20260323062013.jpg
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
picture/58a85f24f7cf_20260323024053.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
picture/79493c5be2ac_20260324081251.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
picture/95f8928fb88c_20260324083528.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
picture/bbeb197ac231_20260323060515.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
picture/d345fbf33f95_20260325031818.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
picture/dcd6a33ef2b0_20260324071427.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
picture/e0bb42e29c58_20260323062055.jpg
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
picture/f233ccc07264_20260323060328.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
picture/f38267265a8a_20260323032735.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
@ -968,7 +968,7 @@ FAULT_DIAGNOSIS_PROMPTS = {
|
||||
" - 示例:\"生成报告\"、\"写报告\"、\"导出报告\"、\"下载报告\"、\"保存报告\"\n"
|
||||
"\n"
|
||||
"2. modify_info: 用户需要修改或补充之前提供的故障信息(舷号、设备名称、故障现象等)\n"
|
||||
" - 示例:\"其实是...\"、\"不对,应该是...\"、\"我补充一下...\"、\"修改设备名称是...\"、\"更正一下,故障是...\"\n"
|
||||
" - 示例:\"其实是...\"、\"不对,应该是...\"、\"我补充一下...\"、\"修改设备名称是...\"、\"更正一下,故障是...\"、\"我需要补充...\"\n"
|
||||
"\n"
|
||||
"3. has_error: 用户指出之前生成的维修方案有错误、需要修改或调整方案内容\n"
|
||||
" - 示例:\"方案有误\"、\"这个步骤不对\"、\"需要修改方案\"、\"这个方案有问题\"\n"
|
||||
|
||||
@ -1,124 +0,0 @@
|
||||
"""
|
||||
智能舷号映射 + RAG检索 完整测试脚本
|
||||
直接调用 search_with_ship_number_strategy,展示真实检索结果
|
||||
"""
|
||||
import asyncio
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from utils.ship_number_search import search_with_ship_number_strategy, smart_ship_number_mapping
|
||||
|
||||
|
||||
async def interactive_test():
|
||||
"""交互式完整测试"""
|
||||
print("=" * 60)
|
||||
print("智能舷号映射 + RAG检索 完整测试")
|
||||
print("=" * 60)
|
||||
print("输入 'exit' 或 'quit' 退出")
|
||||
print("输入 'help' 查看说明")
|
||||
print("=" * 60)
|
||||
|
||||
while True:
|
||||
print("\n" + "-" * 60)
|
||||
ship_input = input("请输入舷号/型号/舰名: ").strip()
|
||||
|
||||
if ship_input.lower() in ['exit', 'quit', 'q']:
|
||||
print("退出测试")
|
||||
break
|
||||
|
||||
if ship_input.lower() == 'help':
|
||||
print("\n使用说明:")
|
||||
print(" 先输入舷号/型号/舰名(如 163、南昌舰、055驱逐舰)")
|
||||
print(" 再输入查询内容(如 主机排烟温度高)")
|
||||
print(" 系统会执行完整的智能映射 + RAG三步检索")
|
||||
continue
|
||||
|
||||
if not ship_input:
|
||||
print("请输入有效内容")
|
||||
continue
|
||||
|
||||
query_input = input("请输入查询内容: ").strip()
|
||||
if not query_input:
|
||||
query_input = "主机排烟温度高的维修方案"
|
||||
print(f"使用默认查询: {query_input}")
|
||||
|
||||
search_type = input("搜索类型 (fault/operate,回车默认fault): ").strip()
|
||||
if search_type not in ["fault", "operate"]:
|
||||
search_type = "fault"
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("第一步:智能映射")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
matched_ship_number, matched_model, all_numbers, match_type = await smart_ship_number_mapping(ship_input)
|
||||
print(f"输入: '{ship_input}'")
|
||||
print(f"匹配类型: {match_type}")
|
||||
print(f"匹配舷号: {matched_ship_number}")
|
||||
print(f"匹配型号: {matched_model}")
|
||||
print(f"该型号所有舷号: {all_numbers}")
|
||||
|
||||
if match_type == "none":
|
||||
print("未匹配到,将使用原始输入进行检索")
|
||||
except Exception as e:
|
||||
print(f"映射失败: {str(e)}")
|
||||
matched_ship_number = None
|
||||
matched_model = None
|
||||
all_numbers = None
|
||||
match_type = "none"
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("第二步:RAG检索(三步策略)")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
rag_results, matched_kb_name, matched_kb_id = await search_with_ship_number_strategy(
|
||||
base_query=query_input,
|
||||
ship_number=ship_input,
|
||||
top_k=5,
|
||||
search_type=search_type
|
||||
)
|
||||
|
||||
print(f"\n检索完成!")
|
||||
print(f"知识库: {matched_kb_name or '未匹配'}")
|
||||
print(f"知识库ID: {matched_kb_id or '未匹配'}")
|
||||
print(f"结果数量: {len(rag_results)}")
|
||||
|
||||
if rag_results:
|
||||
print(f"\n{'=' * 60}")
|
||||
print("第三步:检索结果详情")
|
||||
print("=" * 60)
|
||||
for i, item in enumerate(rag_results, 1):
|
||||
if isinstance(item, dict):
|
||||
text = item.get("text", "")
|
||||
kb_name = item.get("kb_name", "")
|
||||
score = item.get("score", "")
|
||||
source = item.get("source", "")
|
||||
|
||||
print(f"\n--- 结果 {i} ---")
|
||||
if kb_name:
|
||||
print(f"知识库: {kb_name}")
|
||||
if score:
|
||||
print(f"相似度: {score}")
|
||||
if source:
|
||||
print(f"来源: {source}")
|
||||
if text:
|
||||
preview = text[:300] + "..." if len(text) > 300 else text
|
||||
print(f"内容预览:\n{preview}")
|
||||
elif isinstance(item, str):
|
||||
preview = item[:300] + "..." if len(item) > 300 else item
|
||||
print(f"\n--- 结果 {i} ---")
|
||||
print(f"内容预览:\n{preview}")
|
||||
else:
|
||||
print("\n未检索到任何结果")
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n检索失败: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(interactive_test())
|
||||
@ -9,21 +9,21 @@ from config import POSTGRES_CONNECTION_STRING
|
||||
|
||||
|
||||
ROUTE_FLAG_DISPLAY_NAMES = {
|
||||
"问题排查": "问题排查",
|
||||
"故障排查与修理": "故障排查与修理",
|
||||
"操作使用": "操作使用",
|
||||
"百科问答": "百科问答"
|
||||
"舰船百科": "舰船百科"
|
||||
}
|
||||
|
||||
ROUTE_FLAG_ICONS = {
|
||||
"问题排查": "detection",
|
||||
"故障排查与修理": "detection",
|
||||
"操作使用": "repair",
|
||||
"百科问答": "doc"
|
||||
"舰船百科": "doc"
|
||||
}
|
||||
|
||||
ROUTE_FLAG_COLORS = {
|
||||
"问题排查": "#FFB800",
|
||||
"故障排查与修理": "#FFB800",
|
||||
"操作使用": "#00C2FF",
|
||||
"百科问答": "#9153FF"
|
||||
"舰船百科": "#9153FF"
|
||||
}
|
||||
|
||||
|
||||
@ -131,7 +131,7 @@ async def get_agent_usage_statistics() -> Dict[str, Any]:
|
||||
print("[agent_usage_statistics] psycopg_pool 未安装,无法查询")
|
||||
return {"success": False, "data": None}
|
||||
|
||||
required_routes = ["操作使用", "问题排查", "百科问答"]
|
||||
required_routes = ["操作使用", "故障排查与修理", "舰船百科"]
|
||||
|
||||
now = datetime.now()
|
||||
last_6_months = []
|
||||
|
||||
222
tools/config.py
@ -1,222 +0,0 @@
|
||||
"""
|
||||
项目配置文件
|
||||
从.env文件读取配置
|
||||
"""
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
from contextvars import ContextVar
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
# 加载.env文件
|
||||
load_dotenv()
|
||||
|
||||
# ==================== 大模型配置 ====================
|
||||
LLM_CONFIG = {
|
||||
# "model": "Qwen3.5-35B-A3B",
|
||||
"model": "Qwen3-32B",
|
||||
# "model": "Qwen3-14B",
|
||||
"base_url": "http://192.168.1.164:9800/v1",
|
||||
"api_key": "gpustack_dee9ca823290886c_5edfc86aeeeceb1e9ee5162941cb2cb5",
|
||||
"temperature": float("0.1"),
|
||||
"max_tokens": int("4096"),
|
||||
"timeout": int("30")
|
||||
}
|
||||
|
||||
# ==================== 嵌入模型配置 ====================
|
||||
EMBEDDING_CONFIG = {
|
||||
"model": "bge-m3",
|
||||
"base_url": "http://embedding:9700/v1/embeddings",
|
||||
"api_key": "gpustack_dee9ca823290886c_5edfc86aeeeceb1e9ee5162941cb2cb5",
|
||||
"base_url_v2" : "http://embedding:9700/v1",
|
||||
}
|
||||
|
||||
|
||||
# ==================== 线程安全的请求上下文配置 ====================
|
||||
# 使用 contextvars 存储每个请求的用户配置,确保多用户并发时不会相互干扰
|
||||
_request_user_config: ContextVar[Optional[Dict[str, str]]] = ContextVar('request_user_config', default=None)
|
||||
|
||||
# RAG配置的默认值
|
||||
_RAG_CONFIG_DEFAULT = {
|
||||
"search_endpoint": "http://htknow:8080/api/v1/knowledge/search/",
|
||||
"x-user-id": "1",
|
||||
"x-user-name": "testuser",
|
||||
"x-role": "admin",
|
||||
"kb-id": None,
|
||||
"file-id": None
|
||||
}
|
||||
|
||||
class DynamicRAGConfig(dict):
|
||||
"""
|
||||
动态RAG配置类,支持线程安全的请求级配置
|
||||
当访问用户相关配置时,自动从当前请求上下文读取
|
||||
其他配置项直接返回默认值
|
||||
兼容字典的所有操作方式(如 get(), [], in 等)
|
||||
"""
|
||||
def __getitem__(self, key: str):
|
||||
# 如果是用户相关配置或知识库参数,从当前请求上下文读取
|
||||
if key in ["x-user-id", "x-user-name", "x-role", "kb-id", "file-id"]:
|
||||
request_config = _request_user_config.get()
|
||||
if request_config and key in request_config:
|
||||
return request_config[key]
|
||||
# 如果没有设置,返回默认值
|
||||
return _RAG_CONFIG_DEFAULT[key]
|
||||
# 其他配置项直接返回默认值
|
||||
return _RAG_CONFIG_DEFAULT.get(key)
|
||||
|
||||
def get(self, key: str, default=None):
|
||||
"""支持 get() 方法"""
|
||||
try:
|
||||
return self[key]
|
||||
except KeyError:
|
||||
return default
|
||||
|
||||
def __contains__(self, key: str):
|
||||
"""支持 in 操作符"""
|
||||
return key in _RAG_CONFIG_DEFAULT
|
||||
|
||||
def keys(self):
|
||||
"""返回所有键"""
|
||||
return _RAG_CONFIG_DEFAULT.keys()
|
||||
|
||||
def values(self):
|
||||
"""返回所有值(动态读取用户配置)"""
|
||||
return [self[key] for key in _RAG_CONFIG_DEFAULT.keys()]
|
||||
|
||||
def items(self):
|
||||
"""返回所有键值对(动态读取用户配置)"""
|
||||
return [(key, self[key]) for key in _RAG_CONFIG_DEFAULT.keys()]
|
||||
|
||||
# ==================== RAG服务配置 ====================
|
||||
# 使用动态配置类,支持多用户并发时自动读取各自的配置
|
||||
RAG_CONFIG = DynamicRAGConfig()
|
||||
|
||||
def set_request_user_config(x_user_id: Optional[str] = None,
|
||||
x_user_name: Optional[str] = None,
|
||||
x_role: Optional[str] = None,
|
||||
text_kb_id: Optional[str] = None,
|
||||
text_file_id: Optional[str] = None):
|
||||
"""
|
||||
设置当前请求的用户配置和知识库参数(线程安全)
|
||||
|
||||
Args:
|
||||
x_user_id: 用户ID
|
||||
x_user_name: 用户名称
|
||||
x_role: 角色
|
||||
text_kb_id: 文本检索知识库ID
|
||||
text_file_id: 文本检索文件ID
|
||||
"""
|
||||
config = {}
|
||||
if x_user_id is not None:
|
||||
config["x-user-id"] = x_user_id
|
||||
if x_user_name is not None:
|
||||
config["x-user-name"] = x_user_name
|
||||
if x_role is not None:
|
||||
config["x-role"] = x_role
|
||||
# 仅当非空字符串时才设置,空字符串时不传入 RAG 接口
|
||||
if text_kb_id is not None and str(text_kb_id).strip():
|
||||
config["kb-id"] = str(text_kb_id).strip()
|
||||
if text_file_id is not None and str(text_file_id).strip():
|
||||
config["file-id"] = str(text_file_id).strip()
|
||||
_request_user_config.set(config if config else None)
|
||||
|
||||
# ==================== 图谱检索服务配置 ====================
|
||||
GRAPH_SEARCH_CONFIG = {
|
||||
"search_endpoint": "http://kgrag:9085/search_graph",
|
||||
"graph_timeout": float("30.0"),
|
||||
"default_top_k": int("10"),
|
||||
"operation_search_endpoint": "http://kgrag:9085/operation_graph_search"
|
||||
}
|
||||
|
||||
# ==================== 设备到系统检索服务配置 ====================
|
||||
DEVICE_SYSTEM_CONFIG = {
|
||||
"endpoint": "http://kgrag:9085/device_to_system",
|
||||
"timeout": float("30.0"),
|
||||
"default_min_hops": int("2"),
|
||||
"default_max_hops": int("8"),
|
||||
"default_top_k": int("10")
|
||||
}
|
||||
|
||||
# ==================== 图册检索服务配置 ====================
|
||||
ATLAS_CONFIG = {
|
||||
"endpoint": "http://kgrag:9085/atlas_retrieval",
|
||||
"timeout": float("30.0"),
|
||||
"default_top_k": int("10")
|
||||
}
|
||||
|
||||
# ==================== 工作流配置 ====================
|
||||
WORKFLOW_CONFIG = {
|
||||
"max_retry_count": int("2"), # 最大重试次数
|
||||
"image_clarity_threshold": float("0.7"), # 图片清晰度阈值
|
||||
"default_timeout": int("30") # 默认超时时间(秒)
|
||||
}
|
||||
|
||||
# ==================== 服务器配置 ====================
|
||||
SERVER_CONFIG = {
|
||||
"host": "agent",
|
||||
"port": "9088",
|
||||
"base_url": "http://agent:9088"
|
||||
}
|
||||
|
||||
# ==================== ASR服务配置 ====================
|
||||
ASR_CONFIG = {
|
||||
"api_base": "http://asr:9900/v1",
|
||||
"model": "base",
|
||||
"language": "zh",
|
||||
"timeout": int("30")
|
||||
}
|
||||
|
||||
# ==================== VLM服务配置 ====================
|
||||
VLM_CONFIG = {
|
||||
"api_base" : "http://kgrag:9085/v1",
|
||||
"model" : "Qwen3-VL-8B"
|
||||
}
|
||||
|
||||
# ==================== Neo4J配置 ====================
|
||||
NEO4J_CONFIG = {
|
||||
"uri": "neo4j://neo4j:7687",
|
||||
"user": "neo4j",
|
||||
"password": "zdht123"
|
||||
}
|
||||
|
||||
# ==================== 知识库匹配配置 ====================
|
||||
KB_TREE_CONFIG = {
|
||||
"url": "http://htknow:8080/api/v1/knowledge/knowledge_base/tree"
|
||||
}
|
||||
|
||||
# ==================== 维修反馈统计配置 ====================
|
||||
REPAIR_FEEDBACK_CONFIG = {
|
||||
"url": "http://webui:22000",
|
||||
"email": "15088888888@163.com",
|
||||
"password": "11223344"
|
||||
}
|
||||
|
||||
# ==================== 数据库配置 ====================
|
||||
POSTGRES_CONNECTION_STRING = "postgresql://postgres:zdht123@postgres:5432/hj_webui"
|
||||
#POSTGRES_CONNECTION_STRING = "postgresql+psycopg://postgres:zdht123@postgres:5432/hj_webui"
|
||||
POSTGRES_SQLALCHEMY_URL = "postgresql+psycopg://postgres:zdht123@postgres:5432/hj_webui"
|
||||
|
||||
|
||||
# ==================== 索引配置 ====================
|
||||
INDEX_CONFIG = {
|
||||
"NAME_PROPERTY" : "名称",
|
||||
"FULLTEXT_PROPERTY" :"fulltext",
|
||||
"EMBEDDING_PROPERTY" : "embedding",
|
||||
"SEARCH_LABEL" : "Searchable",
|
||||
"VECTOR_INDEX_NAME" : "global_searchable_embedding",
|
||||
"FULLTEXT_INDEX_NAME" : "global_searchable_content_search"
|
||||
}
|
||||
|
||||
# ==================== sql表格字段与neo4j节点映射 ====================
|
||||
MAP_INS={
|
||||
"故障模式" : "fault",
|
||||
"系统" : "system_name",
|
||||
"设备" : "device_name",
|
||||
"舷号" : "ship_number",
|
||||
}
|
||||
|
||||
# ==================== neo4j配置 ====================
|
||||
NEO4J_CONFIG = {
|
||||
"uri": "neo4j://neo4j:7687",
|
||||
"user": "neo4j",
|
||||
"password": "zdht123@"
|
||||
}
|
||||
@ -49,64 +49,39 @@ async def get_device_system_from_neo4j(device_name: str) -> str:
|
||||
if device_name_clean in neo4j_cache:
|
||||
return neo4j_cache[device_name_clean]
|
||||
|
||||
from config import DEVICE_SYSTEM_CONFIG
|
||||
|
||||
endpoint = DEVICE_SYSTEM_CONFIG.get("endpoint")
|
||||
timeout = DEVICE_SYSTEM_CONFIG.get("timeout", 30.0)
|
||||
min_hops = DEVICE_SYSTEM_CONFIG.get("default_min_hops", 2)
|
||||
max_hops = DEVICE_SYSTEM_CONFIG.get("default_max_hops", 8)
|
||||
top_k = DEVICE_SYSTEM_CONFIG.get("default_top_k", 10)
|
||||
|
||||
if not endpoint:
|
||||
result = "其他"
|
||||
neo4j_cache[device_name_clean] = result
|
||||
return result
|
||||
|
||||
try:
|
||||
payload = {
|
||||
"device_name": device_name.strip(),
|
||||
"min_hops": min_hops,
|
||||
"max_hops": max_hops,
|
||||
"top_k": top_k
|
||||
}
|
||||
from tools.graph_tools import find_system_by_device
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
response = await client.post(
|
||||
endpoint,
|
||||
json=payload,
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
result = "其他"
|
||||
neo4j_cache[device_name_clean] = result
|
||||
return result
|
||||
|
||||
result_json = response.json()
|
||||
|
||||
if not result_json.get("success", False):
|
||||
result = "其他"
|
||||
neo4j_cache[device_name_clean] = result
|
||||
return result
|
||||
|
||||
systems = result_json.get("systems", [])
|
||||
if not systems or not isinstance(systems, list):
|
||||
result = "其他"
|
||||
neo4j_cache[device_name_clean] = result
|
||||
return result
|
||||
|
||||
first_system = systems[0]
|
||||
if isinstance(first_system, dict):
|
||||
system_name = first_system.get("name") or first_system.get("系统名称") or first_system.get("名称")
|
||||
if system_name:
|
||||
result = system_name.strip()
|
||||
neo4j_cache[device_name_clean] = result
|
||||
return result
|
||||
result_json = await find_system_by_device(
|
||||
device_name=device_name.strip(),
|
||||
min_hops=2,
|
||||
max_hops=8,
|
||||
top_k=10,
|
||||
)
|
||||
|
||||
if not result_json.get("success", False):
|
||||
result = "其他"
|
||||
neo4j_cache[device_name_clean] = result
|
||||
return result
|
||||
|
||||
systems = result_json.get("systems", [])
|
||||
if not systems or not isinstance(systems, list):
|
||||
result = "其他"
|
||||
neo4j_cache[device_name_clean] = result
|
||||
return result
|
||||
|
||||
first_system = systems[0]
|
||||
if isinstance(first_system, dict):
|
||||
system_name = first_system.get("name") or first_system.get("系统名称") or first_system.get("名称")
|
||||
if system_name:
|
||||
result = system_name.strip()
|
||||
neo4j_cache[device_name_clean] = result
|
||||
return result
|
||||
|
||||
result = "其他"
|
||||
neo4j_cache[device_name_clean] = result
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
print(f"查询设备系统失败: {str(e)}")
|
||||
result = "其他"
|
||||
@ -966,4 +941,3 @@ async def repair_feedback_statistics_tool(
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
1253
tools/graph_tools.py
@ -1,208 +0,0 @@
|
||||
"""
|
||||
舷号-型号映射数据库模块
|
||||
用于管理舰船型号、舷号、别名、舰名的映射关系
|
||||
数据存储在 PostgreSQL 的 ship_model_mapping 表中
|
||||
"""
|
||||
import json
|
||||
from typing import Dict, List, Any, Optional
|
||||
|
||||
from config import POSTGRES_CONNECTION_STRING
|
||||
|
||||
|
||||
async def init_ship_model_mapping_table():
|
||||
"""
|
||||
初始化 ship_model_mapping 表并插入初始数据
|
||||
"""
|
||||
try:
|
||||
from psycopg_pool import AsyncConnectionPool
|
||||
except ImportError:
|
||||
print("[ship_model_db] psycopg_pool 未安装,无法初始化表")
|
||||
return
|
||||
|
||||
async with AsyncConnectionPool(
|
||||
POSTGRES_CONNECTION_STRING,
|
||||
kwargs={"autocommit": True}
|
||||
) as pool:
|
||||
async with pool.connection() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS ship_model_mapping (
|
||||
id SERIAL PRIMARY KEY,
|
||||
model_name TEXT NOT NULL UNIQUE,
|
||||
numbers JSONB NOT NULL DEFAULT '[]',
|
||||
aliases JSONB NOT NULL DEFAULT '[]',
|
||||
names JSONB NOT NULL DEFAULT '[]'
|
||||
)
|
||||
""")
|
||||
await cur.execute("""
|
||||
CREATE INDEX IF NOT EXISTS ship_model_mapping_model_name_idx ON ship_model_mapping(model_name)
|
||||
""")
|
||||
print("[ship_model_db] ship_model_mapping 表初始化完成")
|
||||
|
||||
# 检查是否已有数据,没有则插入初始数据
|
||||
await cur.execute("SELECT COUNT(*) FROM ship_model_mapping")
|
||||
count = (await cur.fetchone())[0]
|
||||
if count == 0:
|
||||
initial_data = [
|
||||
("055型驱逐舰", ["102", "103", "104", "105", "106", "107", "108"],
|
||||
["055", "055型", "055驱逐舰", "万吨大驱"],
|
||||
["拉萨舰", "鞍山舰", "无锡舰", "大连舰", "延安舰", "遵义舰", "咸阳舰"]),
|
||||
("052D型驱逐舰", ["172", "173", "174", "175", "163"],
|
||||
["052D", "052D型", "052D驱逐舰"],
|
||||
["昆明舰", "长沙舰", "合肥舰", "银川舰", "南昌舰"]),
|
||||
("054A型护卫舰", ["529", "530", "547", "568", "570"],
|
||||
["054A", "054A型", "054A护卫舰"],
|
||||
["舟山舰", "徐州舰", "临沂舰", "衡阳舰", "黄山舰"]),
|
||||
]
|
||||
for model_name, numbers, aliases, names in initial_data:
|
||||
await cur.execute(
|
||||
"""
|
||||
INSERT INTO ship_model_mapping (model_name, numbers, aliases, names)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
""",
|
||||
(model_name,
|
||||
json.dumps(numbers, ensure_ascii=False),
|
||||
json.dumps(aliases, ensure_ascii=False),
|
||||
json.dumps(names, ensure_ascii=False))
|
||||
)
|
||||
print(f"[ship_model_db] 已插入 {len(initial_data)} 条初始数据")
|
||||
|
||||
|
||||
async def load_ship_model_mapping() -> Dict[str, Dict[str, List[str]]]:
|
||||
"""
|
||||
从数据库加载全部舷号-型号映射数据
|
||||
|
||||
Returns:
|
||||
与原 config.SHIP_MODEL_MAPPING 格式一致的字典
|
||||
{
|
||||
"055型驱逐舰": {
|
||||
"numbers": ["102", ...],
|
||||
"aliases": ["055", ...],
|
||||
"names": ["拉萨舰", ...]
|
||||
},
|
||||
...
|
||||
}
|
||||
"""
|
||||
try:
|
||||
from psycopg_pool import AsyncConnectionPool
|
||||
except ImportError:
|
||||
print("[ship_model_db] psycopg_pool 未安装,返回空映射")
|
||||
return {}
|
||||
|
||||
try:
|
||||
async with AsyncConnectionPool(
|
||||
POSTGRES_CONNECTION_STRING,
|
||||
kwargs={"autocommit": True}
|
||||
) as pool:
|
||||
async with pool.connection() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"SELECT model_name, numbers, aliases, names FROM ship_model_mapping"
|
||||
)
|
||||
rows = await cur.fetchall()
|
||||
result = {}
|
||||
for row in rows:
|
||||
model_name, numbers, aliases, names = row
|
||||
# JSONB 返回的可能是 list 或 str,统一处理
|
||||
if isinstance(numbers, str):
|
||||
numbers = json.loads(numbers)
|
||||
if isinstance(aliases, str):
|
||||
aliases = json.loads(aliases)
|
||||
if isinstance(names, str):
|
||||
names = json.loads(names)
|
||||
result[model_name] = {
|
||||
"numbers": numbers,
|
||||
"aliases": aliases,
|
||||
"names": names,
|
||||
}
|
||||
return result
|
||||
except Exception as e:
|
||||
print(f"[ship_model_db] 加载映射数据失败: {str(e)}")
|
||||
return {}
|
||||
|
||||
|
||||
async def add_ship_model(
|
||||
model_name: str,
|
||||
numbers: List[str],
|
||||
aliases: List[str],
|
||||
names: List[str]
|
||||
) -> bool:
|
||||
"""
|
||||
添加舰船型号映射
|
||||
|
||||
Args:
|
||||
model_name: 型号名称
|
||||
numbers: 舷号列表
|
||||
aliases: 别名列表
|
||||
names: 舰名列表
|
||||
|
||||
Returns:
|
||||
是否添加成功
|
||||
"""
|
||||
try:
|
||||
from psycopg_pool import AsyncConnectionPool
|
||||
except ImportError:
|
||||
print("[ship_model_db] psycopg_pool 未安装,无法添加")
|
||||
return False
|
||||
|
||||
try:
|
||||
async with AsyncConnectionPool(
|
||||
POSTGRES_CONNECTION_STRING,
|
||||
kwargs={"autocommit": True}
|
||||
) as pool:
|
||||
async with pool.connection() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"""
|
||||
INSERT INTO ship_model_mapping (model_name, numbers, aliases, names)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
ON CONFLICT (model_name) DO UPDATE SET
|
||||
numbers = EXCLUDED.numbers,
|
||||
aliases = EXCLUDED.aliases,
|
||||
names = EXCLUDED.names
|
||||
""",
|
||||
(model_name,
|
||||
json.dumps(numbers, ensure_ascii=False),
|
||||
json.dumps(aliases, ensure_ascii=False),
|
||||
json.dumps(names, ensure_ascii=False))
|
||||
)
|
||||
print(f"[ship_model_db] 舰船型号 '{model_name}' 已保存")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[ship_model_db] 添加舰船型号失败: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
async def delete_ship_model(model_name: str) -> bool:
|
||||
"""
|
||||
删除舰船型号映射
|
||||
|
||||
Args:
|
||||
model_name: 型号名称
|
||||
|
||||
Returns:
|
||||
是否删除成功
|
||||
"""
|
||||
try:
|
||||
from psycopg_pool import AsyncConnectionPool
|
||||
except ImportError:
|
||||
print("[ship_model_db] psycopg_pool 未安装,无法删除")
|
||||
return False
|
||||
|
||||
try:
|
||||
async with AsyncConnectionPool(
|
||||
POSTGRES_CONNECTION_STRING,
|
||||
kwargs={"autocommit": True}
|
||||
) as pool:
|
||||
async with pool.connection() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"DELETE FROM ship_model_mapping WHERE model_name = %s",
|
||||
(model_name,)
|
||||
)
|
||||
print(f"[ship_model_db] 舰船型号 '{model_name}' 已删除")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[ship_model_db] 删除舰船型号失败: {str(e)}")
|
||||
return False
|
||||
|
||||
@ -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 ""
|
||||
@ -1,716 +0,0 @@
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
import logging
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
|
||||
from docx import Document
|
||||
from docx.shared import Pt, RGBColor, Inches
|
||||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||
from docx.oxml.ns import qn
|
||||
from lxml import etree
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(levelname)s - %(message)s',
|
||||
datefmt='%Y-%m-%d %H:%M:%S'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MML2OMML_XSLT = None
|
||||
|
||||
|
||||
def _load_mml2omml_xslt():
|
||||
global _MML2OMML_XSLT
|
||||
if _MML2OMML_XSLT is not None:
|
||||
return _MML2OMML_XSLT
|
||||
search_paths = [
|
||||
r"C:\Program Files\Microsoft Office\root\Office16\MML2OMML.XSL",
|
||||
r"C:\Program Files (x86)\Microsoft Office\root\Office16\MML2OMML.XSL",
|
||||
r"C:\Program Files\Microsoft Office\Office16\MML2OMML.XSL",
|
||||
r"C:\Program Files (x86)\Microsoft Office\Office16\MML2OMML.XSL",
|
||||
"/usr/share/mml2omml/MML2OMML.XSL",
|
||||
]
|
||||
for path in search_paths:
|
||||
if os.path.isfile(path):
|
||||
_MML2OMML_XSLT = etree.parse(path)
|
||||
logger.info(f"加载 MML2OMML.XSL 成功: {path}")
|
||||
return _MML2OMML_XSLT
|
||||
logger.warning("未找到 MML2OMML.XSL,公式将使用图片回退方式渲染")
|
||||
return None
|
||||
|
||||
|
||||
class MarkdownWordGenerator:
|
||||
|
||||
def __init__(self):
|
||||
try:
|
||||
self.doc = Document()
|
||||
self.cache_dir = os.path.join(tempfile.gettempdir(), "latex_formula_cache")
|
||||
os.makedirs(self.cache_dir, exist_ok=True)
|
||||
self.formula_cache = {}
|
||||
self._omml_available = _load_mml2omml_xslt() is not None
|
||||
logger.info("MarkdownWordGenerator 初始化成功")
|
||||
except Exception as e:
|
||||
logger.error(f"初始化 MarkdownWordGenerator 失败: {e}")
|
||||
raise
|
||||
|
||||
def set_font(self, run, size=11, bold=False):
|
||||
try:
|
||||
run.font.name = "微软雅黑"
|
||||
rPr = run._element.get_or_add_rPr()
|
||||
rFonts = rPr.find(qn('w:rFonts'))
|
||||
if rFonts is None:
|
||||
rFonts = run._element.makeelement(qn('w:rFonts'), {})
|
||||
rPr.insert(0, rFonts)
|
||||
rFonts.set(qn('w:eastAsia'), "微软雅黑")
|
||||
run.font.size = Pt(size)
|
||||
run.font.bold = True if bold else False
|
||||
except Exception as e:
|
||||
logger.warning(f"设置字体时出错: {e}")
|
||||
|
||||
def add_heading(self, text, level):
|
||||
try:
|
||||
p = self.doc.add_heading(level=level)
|
||||
size_map = {1: 18, 2: 16, 3: 14}
|
||||
font_size = size_map.get(level, 12)
|
||||
self._add_rich_text(p, text, default_size=font_size, default_bold=True)
|
||||
except Exception as e:
|
||||
logger.error(f"添加标题失败 '{text}': {e}")
|
||||
|
||||
def add_paragraph(self, text):
|
||||
try:
|
||||
if not text.strip():
|
||||
return
|
||||
p = self.doc.add_paragraph()
|
||||
|
||||
indent_chars = 0
|
||||
temp_text = text
|
||||
while temp_text.startswith('\u3000'):
|
||||
indent_chars += 1
|
||||
temp_text = temp_text[1:]
|
||||
|
||||
if indent_chars >= 2:
|
||||
p.paragraph_format.first_line_indent = Inches(0.5)
|
||||
text = temp_text
|
||||
elif indent_chars == 1:
|
||||
p.paragraph_format.first_line_indent = Inches(0.25)
|
||||
text = temp_text
|
||||
|
||||
self._add_rich_text(p, text)
|
||||
except Exception as e:
|
||||
logger.error(f"添加段落失败: {e}")
|
||||
|
||||
def _add_rich_text(self, paragraph, text, default_size=11, default_bold=False):
|
||||
if not text:
|
||||
return
|
||||
|
||||
pattern = r'\*\*(.+?)\*\*'
|
||||
last_end = 0
|
||||
|
||||
for match in re.finditer(pattern, text):
|
||||
start, end = match.span()
|
||||
if start > last_end:
|
||||
plain = text[last_end:start]
|
||||
if plain:
|
||||
run = paragraph.add_run(plain)
|
||||
self.set_font(run, size=default_size, bold=default_bold)
|
||||
|
||||
bold_content = match.group(1)
|
||||
if bold_content:
|
||||
run = paragraph.add_run(bold_content)
|
||||
self.set_font(run, size=default_size, bold=True)
|
||||
|
||||
last_end = end
|
||||
|
||||
if last_end < len(text):
|
||||
remaining = text[last_end:]
|
||||
if remaining:
|
||||
run = paragraph.add_run(remaining)
|
||||
self.set_font(run, size=default_size, bold=default_bold)
|
||||
|
||||
@staticmethod
|
||||
def _preprocess_latex_for_omml(latex: str) -> str:
|
||||
replacements = {
|
||||
r'\leqslant': r'\leq',
|
||||
r'\geqslant': r'\geq',
|
||||
r'\nleqslant': r'\nleq',
|
||||
r'\ngeqslant': r'\ngeq',
|
||||
r'\eqslantless': r'\leq',
|
||||
r'\eqslantgtr': r'\geq',
|
||||
r'\varnothing': r'\emptyset',
|
||||
r'\dfrac': r'\frac',
|
||||
r'\tfrac': r'\frac',
|
||||
r'\boldsymbol': r'\mathbf',
|
||||
r'\bm': r'\mathbf',
|
||||
}
|
||||
for old, new in replacements.items():
|
||||
latex = latex.replace(old, new)
|
||||
|
||||
latex = re.sub(r'\\mathrm\s*\{([^}]*)\}', lambda m: r'\mathrm{' + m.group(1) + '}', latex)
|
||||
latex = re.sub(r'\\text\s*\{([^}]*)\}', lambda m: r'\mathrm{' + m.group(1) + '}', latex)
|
||||
latex = re.sub(r'\\textbf\s*\{([^}]*)\}', lambda m: r'\mathbf{' + m.group(1) + '}', latex)
|
||||
latex = re.sub(r'\\textit\s*\{([^}]*)\}', lambda m: r'\mathit{' + m.group(1) + '}', latex)
|
||||
|
||||
return latex
|
||||
|
||||
def _latex_to_omml(self, latex_str):
|
||||
try:
|
||||
import latex2mathml.converter
|
||||
latex_str = self._preprocess_latex_for_omml(latex_str)
|
||||
mathml = latex2mathml.converter.convert(latex_str)
|
||||
mathml_tree = etree.fromstring(mathml.encode('utf-8'))
|
||||
xslt = _load_mml2omml_xslt()
|
||||
if xslt is None:
|
||||
return None
|
||||
transform = etree.XSLT(xslt)
|
||||
omml_tree = transform(mathml_tree)
|
||||
return omml_tree.getroot()
|
||||
except Exception as e:
|
||||
logger.error(f"LaTeX->OMML 转换失败 '{latex_str}': {e}")
|
||||
return None
|
||||
|
||||
def _add_omml_to_paragraph(self, paragraph, latex_str):
|
||||
omml = self._latex_to_omml(latex_str)
|
||||
if omml is not None:
|
||||
paragraph._element.append(omml)
|
||||
return True
|
||||
return False
|
||||
|
||||
def _add_rich_text_with_formulas(self, paragraph, text, default_size=11, default_bold=False):
|
||||
if not text:
|
||||
return
|
||||
|
||||
has_formula = bool(re.search(r'\$[^$]+?\$', text))
|
||||
has_image = bool(self._extract_image_urls(text))
|
||||
|
||||
if not has_formula and not has_image:
|
||||
self._add_rich_text(paragraph, text, default_size=default_size, default_bold=default_bold)
|
||||
return
|
||||
|
||||
if has_image:
|
||||
img_urls = self._extract_image_urls(text)
|
||||
last_end = 0
|
||||
for start, end, url in img_urls:
|
||||
before_text = text[last_end:start]
|
||||
if before_text and before_text.strip():
|
||||
self._add_rich_text_with_formulas(paragraph, before_text, default_size=default_size, default_bold=default_bold)
|
||||
self.add_image(url, width_inches=4.5)
|
||||
last_end = end
|
||||
after_text = text[last_end:]
|
||||
if after_text and after_text.strip():
|
||||
p = self.doc.add_paragraph()
|
||||
self._add_rich_text_with_formulas(p, after_text, default_size=default_size, default_bold=default_bold)
|
||||
return
|
||||
|
||||
segments = re.split(r'(\$[^$]+?\$)', text)
|
||||
for segment in segments:
|
||||
if not segment:
|
||||
continue
|
||||
if segment.startswith('$') and segment.endswith('$') and len(segment) > 2:
|
||||
latex_content = segment[1:-1].strip()
|
||||
if self._omml_available and self._add_omml_to_paragraph(paragraph, latex_content):
|
||||
pass
|
||||
else:
|
||||
run = paragraph.add_run(segment)
|
||||
self.set_font(run, size=default_size, bold=default_bold)
|
||||
else:
|
||||
self._add_rich_text(paragraph, segment, default_size=default_size, default_bold=default_bold)
|
||||
|
||||
def add_list(self, text, numbered=False, number=None):
|
||||
try:
|
||||
text = text.strip()
|
||||
if not text:
|
||||
return
|
||||
|
||||
if re.match(r'^[-_*]{2,}$', text):
|
||||
return
|
||||
|
||||
if numbered and number is not None:
|
||||
p = self.doc.add_paragraph()
|
||||
p.paragraph_format.left_indent = Pt(18)
|
||||
p.paragraph_format.first_line_indent = Pt(-18)
|
||||
num_run = p.add_run(f"{number}. ")
|
||||
self.set_font(num_run, bold=False)
|
||||
self._add_rich_text_with_formulas(p, text)
|
||||
else:
|
||||
p = self.doc.add_paragraph(style="List Bullet")
|
||||
self._add_rich_text_with_formulas(p, text)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"添加列表失败: {e}")
|
||||
|
||||
def add_sub_list(self, text):
|
||||
try:
|
||||
text = text.strip()
|
||||
if not text:
|
||||
return
|
||||
if re.match(r'^[-_*]{2,}$', text):
|
||||
return
|
||||
|
||||
try:
|
||||
p = self.doc.add_paragraph(style="List Bullet 2")
|
||||
except KeyError:
|
||||
p = self.doc.add_paragraph(style="List Bullet")
|
||||
p.paragraph_format.left_indent = Pt(36)
|
||||
self._add_rich_text_with_formulas(p, text)
|
||||
except Exception as e:
|
||||
logger.error(f"添加子列表失败: {e}")
|
||||
|
||||
def _convert_url_to_local_path(self, url: str) -> str:
|
||||
if not url:
|
||||
return None
|
||||
from urllib.parse import urlparse
|
||||
path = urlparse(url).path
|
||||
if path.startswith('/picture/'):
|
||||
docker_path = f"/app/picture/{path[len('/picture/'):]}"
|
||||
if os.path.isfile(docker_path):
|
||||
return docker_path
|
||||
try:
|
||||
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
local_path = os.path.join(project_root, "picture", path[len('/picture/'):])
|
||||
if os.path.isfile(local_path):
|
||||
return local_path
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _get_http_headers(url: str) -> dict:
|
||||
headers = {}
|
||||
if "/api/v1/knowledge/" in url:
|
||||
try:
|
||||
from config import RAG_CONFIG
|
||||
headers["x-user-id"] = str(RAG_CONFIG.get("x-user-id", "1"))
|
||||
headers["x-user-name"] = str(RAG_CONFIG.get("x-user-name", "testuser"))
|
||||
headers["x-role"] = str(RAG_CONFIG.get("x-role", "admin"))
|
||||
except Exception:
|
||||
headers["x-user-id"] = "1"
|
||||
headers["x-user-name"] = "testuser"
|
||||
headers["x-role"] = "admin"
|
||||
return headers
|
||||
|
||||
def add_image(self, url_or_path: str, width_inches: float = 5.5):
|
||||
if not url_or_path:
|
||||
return
|
||||
|
||||
try:
|
||||
import io
|
||||
image_stream = None
|
||||
|
||||
if os.path.isfile(url_or_path):
|
||||
image_stream = url_or_path
|
||||
elif url_or_path.startswith("http://") or url_or_path.startswith("https://"):
|
||||
local_path = self._convert_url_to_local_path(url_or_path)
|
||||
if local_path and os.path.isfile(local_path):
|
||||
image_stream = local_path
|
||||
else:
|
||||
import requests
|
||||
headers = self._get_http_headers(url_or_path)
|
||||
resp = requests.get(url_or_path, timeout=15, headers=headers, proxies={"http": None, "https": None})
|
||||
resp.raise_for_status()
|
||||
content_type = resp.headers.get("Content-Type", "")
|
||||
logger.info(f"HTTP 下载完成, Content-Type: {content_type}, 大小: {len(resp.content)} bytes")
|
||||
if not content_type.startswith("image/"):
|
||||
logger.error(f"响应不是图片类型: {content_type}")
|
||||
self.add_paragraph(f"[图片加载失败: {url_or_path}]")
|
||||
return
|
||||
image_stream = io.BytesIO(resp.content)
|
||||
else:
|
||||
logger.warning(f"图片路径无效: {url_or_path}")
|
||||
return
|
||||
|
||||
from PIL import Image as PilImage
|
||||
if isinstance(image_stream, str):
|
||||
pil_img = PilImage.open(image_stream)
|
||||
else:
|
||||
pil_img = PilImage.open(image_stream)
|
||||
png_stream = io.BytesIO()
|
||||
pil_img.convert("RGBA" if pil_img.mode in ("RGBA", "P") else "RGB").save(png_stream, format="PNG")
|
||||
png_stream.seek(0)
|
||||
|
||||
p = self.doc.add_paragraph()
|
||||
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
run = p.add_run()
|
||||
run.add_picture(png_stream, width=Inches(width_inches))
|
||||
logger.info(f"图片插入成功: {url_or_path}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"插入图片失败 '{url_or_path}': {e!r}", exc_info=True)
|
||||
self.add_paragraph(f"[图片加载失败: {url_or_path}]")
|
||||
|
||||
def _extract_image_urls(self, line_content):
|
||||
"""
|
||||
从一行文本中提取所有图片URL,支持多种格式:
|
||||
- 
|
||||
- ![alt]`url`
|
||||
- ! `url`
|
||||
返回 [(start, end, url), ...]
|
||||
"""
|
||||
results = []
|
||||
|
||||
# 格式1: 
|
||||
for m in re.finditer(r'!\[([^\]]*)\]\(([^)]+)\)', line_content):
|
||||
url = m.group(2).strip()
|
||||
if url.startswith('`') and url.endswith('`'):
|
||||
url = url[1:-1]
|
||||
results.append((m.start(), m.end(), url))
|
||||
|
||||
# 格式2: ![alt]`url` (未被格式1匹配的)
|
||||
for m in re.finditer(r'!\[([^\]]*)\]\s*`([^`]+)`', line_content):
|
||||
url = m.group(2).strip()
|
||||
already = any(s <= m.start() and e >= m.end() for s, e, _ in results)
|
||||
if not already:
|
||||
results.append((m.start(), m.end(), url))
|
||||
|
||||
# 格式3: ! `url` (模型常见的破损格式)
|
||||
for m in re.finditer(r'!\s*`([^`]+\.(?:jpg|jpeg|png|gif|bmp|webp))`', line_content, re.IGNORECASE):
|
||||
url = m.group(1).strip()
|
||||
already = any(s <= m.start() and e >= m.end() for s, e, _ in results)
|
||||
if not already:
|
||||
results.append((m.start(), m.end(), url))
|
||||
|
||||
results.sort(key=lambda x: x[0])
|
||||
return results
|
||||
|
||||
def add_table(self, lines):
|
||||
try:
|
||||
rows = []
|
||||
for line in lines:
|
||||
line = line.strip("|")
|
||||
cells = [c.strip() for c in line.split("|")]
|
||||
rows.append(cells)
|
||||
|
||||
if not rows:
|
||||
return
|
||||
|
||||
header = rows[0]
|
||||
data_start_index = 1
|
||||
if len(rows) > 1 and all(c.startswith('-') or c == '' for c in rows[1]):
|
||||
data_start_index = 2
|
||||
|
||||
data = rows[data_start_index:]
|
||||
max_cols = len(header)
|
||||
|
||||
table = self.doc.add_table(rows=len(data) + 1, cols=max_cols)
|
||||
table.style = "Table Grid"
|
||||
|
||||
for i, cell in enumerate(header):
|
||||
if i < max_cols:
|
||||
table.rows[0].cells[i].text = cell
|
||||
|
||||
for r, row in enumerate(data):
|
||||
for c, val in enumerate(row):
|
||||
if c < max_cols:
|
||||
table.rows[r + 1].cells[c].text = val
|
||||
|
||||
logger.info("表格添加成功")
|
||||
except Exception as e:
|
||||
logger.error(f"添加表格失败: {e}")
|
||||
|
||||
def _add_horizontal_rule(self):
|
||||
p = self.doc.add_paragraph()
|
||||
pPr = p._element.get_or_add_pPr()
|
||||
pBdr = p._element.makeelement(qn('w:pBdr'), {})
|
||||
bottom = p._element.makeelement(qn('w:bottom'), {
|
||||
qn('w:val'): 'single',
|
||||
qn('w:sz'): '6',
|
||||
qn('w:space'): '1',
|
||||
qn('w:color'): 'CCCCCC',
|
||||
})
|
||||
pBdr.append(bottom)
|
||||
pPr.append(pBdr)
|
||||
|
||||
def parse(self, markdown):
|
||||
lines = markdown.split("\n")
|
||||
table_buffer = []
|
||||
block_formula = []
|
||||
in_block_formula = False
|
||||
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
line_stripped = line.rstrip()
|
||||
line_content = line_stripped.lstrip()
|
||||
|
||||
# 1. 处理块级公式
|
||||
if line_content.startswith("$$"):
|
||||
if not in_block_formula:
|
||||
remaining = line_content[2:]
|
||||
close_idx = remaining.find("$$")
|
||||
if close_idx >= 0:
|
||||
formula = remaining[:close_idx].strip()
|
||||
after_formula = remaining[close_idx + 2:].strip()
|
||||
p = self.doc.add_paragraph()
|
||||
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
if not (self._omml_available and self._add_omml_to_paragraph(p, formula)):
|
||||
self.add_paragraph(f"[公式: {formula}]")
|
||||
if after_formula:
|
||||
self.add_paragraph(after_formula)
|
||||
else:
|
||||
in_block_formula = True
|
||||
block_formula = []
|
||||
if remaining.strip():
|
||||
block_formula.append(remaining.strip())
|
||||
else:
|
||||
remaining = line_content[2:]
|
||||
in_block_formula = False
|
||||
formula = "".join(block_formula).strip()
|
||||
p = self.doc.add_paragraph()
|
||||
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
if not (self._omml_available and self._add_omml_to_paragraph(p, formula)):
|
||||
self.add_paragraph(f"[公式: {formula}]")
|
||||
if remaining.strip():
|
||||
self.add_paragraph(remaining.strip())
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if in_block_formula:
|
||||
block_formula.append(line)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# 2. 处理表格
|
||||
if line_content.startswith("|") and line_content.endswith("|"):
|
||||
table_buffer.append(line_stripped)
|
||||
i += 1
|
||||
continue
|
||||
else:
|
||||
if table_buffer:
|
||||
self.add_table(table_buffer)
|
||||
table_buffer = []
|
||||
|
||||
# 空行跳过
|
||||
if not line_stripped:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# 3. 水平分隔线
|
||||
if re.match(r'^\s*[-*_]{3,}\s*$', line_stripped):
|
||||
self._add_horizontal_rule()
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# 4. 标题
|
||||
heading_match = re.match(r'^(#{1,3})\s+(.*)', line_content)
|
||||
if heading_match:
|
||||
level = len(heading_match.group(1))
|
||||
text = heading_match.group(2)
|
||||
self.add_heading(text, level)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# 5. 有序列表(带子列表支持)
|
||||
list_match_ordered = re.match(r'^(\d+)\.\s*(.*)', line_content)
|
||||
if list_match_ordered:
|
||||
number = int(list_match_ordered.group(1))
|
||||
text = list_match_ordered.group(2)
|
||||
self.add_list(text, numbered=True, number=number)
|
||||
|
||||
i += 1
|
||||
while i < len(lines):
|
||||
next_line = lines[i]
|
||||
next_stripped = next_line.rstrip()
|
||||
sub_match = re.match(r'^[\s\t]+[-*]\s*(.*)', next_stripped)
|
||||
if sub_match:
|
||||
sub_text = sub_match.group(1)
|
||||
self.add_sub_list(sub_text)
|
||||
i += 1
|
||||
elif (next_stripped.startswith(' ') or next_stripped.startswith('\t')):
|
||||
sub_content = next_stripped.strip()
|
||||
if sub_content and not re.match(r'^\d+\.\s', sub_content):
|
||||
self.add_sub_list(sub_content)
|
||||
i += 1
|
||||
else:
|
||||
break
|
||||
else:
|
||||
break
|
||||
continue
|
||||
|
||||
# 6. 无序列表
|
||||
is_bold_line = line_content.startswith('**')
|
||||
if not is_bold_line:
|
||||
list_match_unordered = re.match(r'^\s*[-*]\s+(.*)', line_stripped)
|
||||
if not list_match_unordered:
|
||||
m = re.match(r'^\s*-([^-].*)$', line_stripped)
|
||||
if m and m.group(1).strip():
|
||||
list_match_unordered = m
|
||||
|
||||
if list_match_unordered:
|
||||
text = list_match_unordered.group(1).strip()
|
||||
if text:
|
||||
self.add_list(text, numbered=False)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# 7. 图片链接 - 统一提取所有格式的图片URL
|
||||
img_urls = self._extract_image_urls(line_content)
|
||||
|
||||
if img_urls:
|
||||
has_only_img = (len(img_urls) == 1 and
|
||||
line_content.strip().startswith('!') and
|
||||
img_urls[0][0] == 0)
|
||||
|
||||
if has_only_img:
|
||||
_, _, url = img_urls[0]
|
||||
self.add_image(url)
|
||||
else:
|
||||
p = self.doc.add_paragraph()
|
||||
last_end = 0
|
||||
for start, end, url in img_urls:
|
||||
before_text = line_content[last_end:start].strip()
|
||||
if before_text:
|
||||
self._add_rich_text_with_formulas(p, before_text)
|
||||
self.add_image(url, width_inches=4.5)
|
||||
p = self.doc.add_paragraph()
|
||||
last_end = end
|
||||
after_text = line_content[last_end:].strip()
|
||||
if after_text:
|
||||
self._add_rich_text_with_formulas(p, after_text)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# 8. 普通链接
|
||||
if line_content.startswith("["):
|
||||
match = re.match(r'\[(.*?)\]\((.*?)\)', line_content)
|
||||
if match:
|
||||
p = self.doc.add_paragraph()
|
||||
run = p.add_run(match.group(1))
|
||||
run.font.color.rgb = RGBColor(0, 0, 255)
|
||||
run.font.underline = True
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# 9. 普通段落
|
||||
p = self.doc.add_paragraph()
|
||||
|
||||
indent_chars = 0
|
||||
temp_line = line_stripped
|
||||
while temp_line.startswith('\u3000'):
|
||||
indent_chars += 1
|
||||
temp_line = temp_line[1:]
|
||||
|
||||
if indent_chars >= 2:
|
||||
p.paragraph_format.first_line_indent = Inches(0.5)
|
||||
elif indent_chars == 1:
|
||||
p.paragraph_format.first_line_indent = Inches(0.25)
|
||||
|
||||
self._add_rich_text_with_formulas(p, line_content)
|
||||
|
||||
i += 1
|
||||
|
||||
if table_buffer:
|
||||
self.add_table(table_buffer)
|
||||
|
||||
def generate(self, markdown, title=None, output=None, images=None):
|
||||
try:
|
||||
if title:
|
||||
logger.info(f"添加文档标题: {title}")
|
||||
p = self.doc.add_paragraph()
|
||||
run = p.add_run(title)
|
||||
self.set_font(run, 20, True)
|
||||
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
|
||||
logger.info("开始解析 Markdown 内容")
|
||||
self.parse(markdown)
|
||||
|
||||
if images:
|
||||
self._append_images_section(images)
|
||||
|
||||
if not output:
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
output = f"report_{timestamp}.docx"
|
||||
|
||||
logger.info(f"正在保存文件: {output}")
|
||||
self.doc.save(output)
|
||||
|
||||
if os.path.exists(output):
|
||||
file_size = os.path.getsize(output)
|
||||
logger.info(f"文件保存成功: {output}, 大小: {file_size} bytes")
|
||||
return output
|
||||
else:
|
||||
logger.error(f"文件保存后未找到: {output}")
|
||||
raise FileNotFoundError(f"File {output} was not created.")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"生成报告过程中发生严重错误: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
def _append_images_section(self, images):
|
||||
try:
|
||||
self.add_heading("附件图片", level=2)
|
||||
|
||||
for idx, img in enumerate(images, 1):
|
||||
if isinstance(img, dict):
|
||||
url = img.get('url', '')
|
||||
summary = img.get('summary', '')
|
||||
else:
|
||||
url = str(img)
|
||||
summary = ''
|
||||
|
||||
p = self.doc.add_paragraph()
|
||||
run = p.add_run(f"图片{idx}:")
|
||||
self.set_font(run, bold=True)
|
||||
|
||||
if url:
|
||||
self.add_image(url, width_inches=4)
|
||||
|
||||
if summary:
|
||||
p = self.doc.add_paragraph()
|
||||
run = p.add_run(f"说明:{summary}")
|
||||
self.set_font(run, size=10)
|
||||
|
||||
self.doc.add_paragraph()
|
||||
|
||||
logger.info(f"图片附件部分添加完成,共 {len(images)} 张图片")
|
||||
except Exception as e:
|
||||
logger.error(f"添加图片附件部分失败: {e}")
|
||||
|
||||
|
||||
def generate_report_word(markdown, title=None, output=None, images=None):
|
||||
logger.info("=== 开始生成报告 ===")
|
||||
try:
|
||||
generator = MarkdownWordGenerator()
|
||||
|
||||
if images:
|
||||
logger.info(f"准备插入 {len(images)} 张图片")
|
||||
|
||||
file_path = generator.generate(markdown, title=title, output=output, images=images)
|
||||
logger.info(f"=== 报告生成完成: {file_path} ===")
|
||||
return file_path
|
||||
except Exception as e:
|
||||
logger.critical(f"报告生成失败: {e}")
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
md = r"""
|
||||
## **测试报告 - OMML公式和图片**
|
||||
|
||||
### **一、行内公式**
|
||||
|
||||
温度要求 $ \leqslant 40 ^ { \circ } \mathrm { C } $,不得超过。
|
||||
|
||||
公差范围 $ \geqslant 0.05 \ \mathrm { mm } $ 以内合格。
|
||||
|
||||
### **二、列表中的公式**
|
||||
|
||||
1. 温度 $ \leqslant 40 ^ { \circ } \mathrm { C } $ 要求
|
||||
2. 间隙 $ \leqslant 0.05 \ \mathrm { mm } $ 标准
|
||||
|
||||
- 清洁轴颈 $ \geqslant 0.05 \ \mathrm { mm } $
|
||||
|
||||
|
||||
### **四、图片测试**
|
||||
|
||||

|
||||
"""
|
||||
|
||||
try:
|
||||
result_path = generate_report_word(md)
|
||||
print(f"报告已生成,路径: {result_path}")
|
||||
except Exception as e:
|
||||
print(f"程序执行出错: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
@ -26,7 +26,7 @@ WORKFLOW_CONFIG = {
|
||||
"function": "run_qa_workflow",
|
||||
"is_async": True,
|
||||
},
|
||||
"问题排查": {
|
||||
"故障排查与修理": {
|
||||
"module": "workflows.workflow_fault_diagnosis",
|
||||
"function": "run_fault_diagnosis_workflow",
|
||||
"is_async": True,
|
||||
@ -58,7 +58,7 @@ WORKFLOW_CONFIG = {
|
||||
"function": "run_fault_diagnosis_workflow",
|
||||
"is_async": True,
|
||||
},
|
||||
"百科问答":{
|
||||
"舰船百科":{
|
||||
"module": "workflows.workflow_baike",
|
||||
"function": "run_baike_workflow",
|
||||
"is_async": True,
|
||||
|
||||
@ -1,55 +0,0 @@
|
||||
Requirement already satisfied: langgraph in /usr/local/lib/python3.10/dist-packages (1.0.3)
|
||||
Collecting langgraph
|
||||
Downloading langgraph-1.1.3-py3-none-any.whl (168 kB)
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 168.1/168.1 KB 685.8 kB/s eta 0:00:00
|
||||
Requirement already satisfied: langgraph-checkpoint-postgres in /usr/local/lib/python3.10/dist-packages (3.0.5)
|
||||
Requirement already satisfied: langchain-core>=0.1 in /usr/local/lib/python3.10/dist-packages (from langgraph) (1.1.0)
|
||||
Collecting langgraph-sdk<0.4.0,>=0.3.0
|
||||
Downloading langgraph_sdk-0.3.12-py3-none-any.whl (95 kB)
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 95.8/95.8 KB 3.9 MB/s eta 0:00:00
|
||||
Requirement already satisfied: xxhash>=3.5.0 in /usr/local/lib/python3.10/dist-packages (from langgraph) (3.6.0)
|
||||
Requirement already satisfied: pydantic>=2.7.4 in /usr/local/lib/python3.10/dist-packages (from langgraph) (2.12.4)
|
||||
Requirement already satisfied: langgraph-checkpoint<5.0.0,>=2.1.0 in /usr/local/lib/python3.10/dist-packages (from langgraph) (3.0.1)
|
||||
Collecting langgraph-prebuilt<1.1.0,>=1.0.8
|
||||
Downloading langgraph_prebuilt-1.0.8-py3-none-any.whl (35 kB)
|
||||
Requirement already satisfied: psycopg-pool>=3.2.0 in /usr/local/lib/python3.10/dist-packages (from langgraph-checkpoint-postgres) (3.3.0)
|
||||
Requirement already satisfied: orjson>=3.11.5 in /usr/local/lib/python3.10/dist-packages (from langgraph-checkpoint-postgres) (3.11.7)
|
||||
Requirement already satisfied: psycopg>=3.2.0 in /usr/local/lib/python3.10/dist-packages (from langgraph-checkpoint-postgres) (3.3.3)
|
||||
Requirement already satisfied: jsonpatch<2.0.0,>=1.33.0 in /usr/local/lib/python3.10/dist-packages (from langchain-core>=0.1->langgraph) (1.33)
|
||||
Requirement already satisfied: langsmith<1.0.0,>=0.3.45 in /usr/local/lib/python3.10/dist-packages (from langchain-core>=0.1->langgraph) (0.4.46)
|
||||
Requirement already satisfied: packaging<26.0.0,>=23.2.0 in /usr/local/lib/python3.10/dist-packages (from langchain-core>=0.1->langgraph) (25.0)
|
||||
Requirement already satisfied: pyyaml<7.0.0,>=5.3.0 in /usr/local/lib/python3.10/dist-packages (from langchain-core>=0.1->langgraph) (6.0.3)
|
||||
Requirement already satisfied: tenacity!=8.4.0,<10.0.0,>=8.1.0 in /usr/local/lib/python3.10/dist-packages (from langchain-core>=0.1->langgraph) (9.1.2)
|
||||
Requirement already satisfied: typing-extensions<5.0.0,>=4.7.0 in /usr/local/lib/python3.10/dist-packages (from langchain-core>=0.1->langgraph) (4.15.0)
|
||||
Requirement already satisfied: ormsgpack>=1.12.0 in /usr/local/lib/python3.10/dist-packages (from langgraph-checkpoint<5.0.0,>=2.1.0->langgraph) (1.12.0)
|
||||
Requirement already satisfied: httpx>=0.25.2 in /usr/local/lib/python3.10/dist-packages (from langgraph-sdk<0.4.0,>=0.3.0->langgraph) (0.28.1)
|
||||
Requirement already satisfied: typing-inspection>=0.4.2 in /usr/local/lib/python3.10/dist-packages (from pydantic>=2.7.4->langgraph) (0.4.2)
|
||||
Requirement already satisfied: annotated-types>=0.6.0 in /usr/local/lib/python3.10/dist-packages (from pydantic>=2.7.4->langgraph) (0.7.0)
|
||||
Requirement already satisfied: pydantic-core==2.41.5 in /usr/local/lib/python3.10/dist-packages (from pydantic>=2.7.4->langgraph) (2.41.5)
|
||||
Requirement already satisfied: httpcore==1.* in /usr/local/lib/python3.10/dist-packages (from httpx>=0.25.2->langgraph-sdk<0.4.0,>=0.3.0->langgraph) (1.0.9)
|
||||
Requirement already satisfied: anyio in /usr/local/lib/python3.10/dist-packages (from httpx>=0.25.2->langgraph-sdk<0.4.0,>=0.3.0->langgraph) (4.11.0)
|
||||
Requirement already satisfied: certifi in /usr/local/lib/python3.10/dist-packages (from httpx>=0.25.2->langgraph-sdk<0.4.0,>=0.3.0->langgraph) (2025.11.12)
|
||||
Requirement already satisfied: idna in /usr/local/lib/python3.10/dist-packages (from httpx>=0.25.2->langgraph-sdk<0.4.0,>=0.3.0->langgraph) (3.11)
|
||||
Requirement already satisfied: h11>=0.16 in /usr/local/lib/python3.10/dist-packages (from httpcore==1.*->httpx>=0.25.2->langgraph-sdk<0.4.0,>=0.3.0->langgraph) (0.16.0)
|
||||
Requirement already satisfied: jsonpointer>=1.9 in /usr/local/lib/python3.10/dist-packages (from jsonpatch<2.0.0,>=1.33.0->langchain-core>=0.1->langgraph) (3.0.0)
|
||||
Requirement already satisfied: requests>=2.0.0 in /usr/local/lib/python3.10/dist-packages (from langsmith<1.0.0,>=0.3.45->langchain-core>=0.1->langgraph) (2.32.5)
|
||||
Requirement already satisfied: zstandard>=0.23.0 in /usr/local/lib/python3.10/dist-packages (from langsmith<1.0.0,>=0.3.45->langchain-core>=0.1->langgraph) (0.25.0)
|
||||
Requirement already satisfied: requests-toolbelt>=1.0.0 in /usr/local/lib/python3.10/dist-packages (from langsmith<1.0.0,>=0.3.45->langchain-core>=0.1->langgraph) (1.0.0)
|
||||
Requirement already satisfied: urllib3<3,>=1.21.1 in /usr/local/lib/python3.10/dist-packages (from requests>=2.0.0->langsmith<1.0.0,>=0.3.45->langchain-core>=0.1->langgraph) (2.5.0)
|
||||
Requirement already satisfied: charset_normalizer<4,>=2 in /usr/local/lib/python3.10/dist-packages (from requests>=2.0.0->langsmith<1.0.0,>=0.3.45->langchain-core>=0.1->langgraph) (3.4.4)
|
||||
Requirement already satisfied: exceptiongroup>=1.0.2 in /usr/local/lib/python3.10/dist-packages (from anyio->httpx>=0.25.2->langgraph-sdk<0.4.0,>=0.3.0->langgraph) (1.3.1)
|
||||
Requirement already satisfied: sniffio>=1.1 in /usr/local/lib/python3.10/dist-packages (from anyio->httpx>=0.25.2->langgraph-sdk<0.4.0,>=0.3.0->langgraph) (1.3.1)
|
||||
Installing collected packages: langgraph-sdk, langgraph-prebuilt, langgraph
|
||||
Attempting uninstall: langgraph-sdk
|
||||
Found existing installation: langgraph-sdk 0.2.9
|
||||
Uninstalling langgraph-sdk-0.2.9:
|
||||
Successfully uninstalled langgraph-sdk-0.2.9
|
||||
Attempting uninstall: langgraph-prebuilt
|
||||
Found existing installation: langgraph-prebuilt 1.0.5
|
||||
Uninstalling langgraph-prebuilt-1.0.5:
|
||||
Successfully uninstalled langgraph-prebuilt-1.0.5
|
||||
Attempting uninstall: langgraph
|
||||
Found existing installation: langgraph 1.0.3
|
||||
Uninstalling langgraph-1.0.3:
|
||||
Successfully uninstalled langgraph-1.0.3
|
||||
Successfully installed langgraph-1.1.3 langgraph-prebuilt-1.0.8 langgraph-sdk-0.3.12
|
||||
@ -220,7 +220,8 @@ async def search_rag_and_graph(state: QAState) -> Dict[str, Any]:
|
||||
})
|
||||
|
||||
# 只保留前 4 条数据
|
||||
rag_search_result = rag_search_result[:2]
|
||||
# rag_search_result = rag_search_result[:2]
|
||||
rag_search_result = sorted(rag_search_result,key=lambda x:float(x.get("socre") or 0), reverse=True)[:2]
|
||||
|
||||
return {
|
||||
"rag_search_result": rag_search_result,
|
||||
@ -300,7 +301,8 @@ async def generate_answer(state: QAState) -> Dict[str, Any]:
|
||||
|
||||
if need_retrieval:
|
||||
# 收集所有 RAG 结果文本(只取前 4 条)
|
||||
for item in (rag_results or [])[:1]:
|
||||
for item in sorted(rag_results or [], key=lambda x: float(x.get("score") or 0) if isinstance(x,dict) else 0, reverse=True)[:2]:
|
||||
# for item in (rag_results or [])[:1]:
|
||||
print(item)
|
||||
print(111111111111111111111111111)
|
||||
if isinstance(item, dict):
|
||||
|
||||
@ -4,9 +4,9 @@
|
||||
from typing import TypedDict, Optional, Dict, Any, List
|
||||
from langgraph.graph import StateGraph, START, END
|
||||
from langgraph.types import interrupt, Command
|
||||
from tools.function_tool import graph_rag_search
|
||||
from tools.function_tool import graph_rag_search, fault_graph_rag_search
|
||||
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, normalize_device_name_by_ship_graph
|
||||
from modelsAPI.model_api import OpenaiAPI
|
||||
from utils.function_tracker import get_all_callbacks
|
||||
from utils.image_utils import extract_image_paths, normalize_markdown_images, get_image_prompt_guidance
|
||||
@ -720,66 +720,95 @@ async def call_tools(state: FaultDiagnosisState) -> Dict[str, Any]:
|
||||
matched_kb_id = ""
|
||||
mapped_ship_number = ""
|
||||
|
||||
if device_name:
|
||||
normalized_result = await normalize_device_name_by_ship_graph(device_name)
|
||||
if normalized_result.get("success"):
|
||||
normalized_device_name = normalized_result.get("device_name") or device_name
|
||||
if normalized_device_name != device_name:
|
||||
print(
|
||||
f"[设备标准化] '{device_name}' -> '{normalized_device_name}', "
|
||||
f"score={normalized_result.get('score')}"
|
||||
)
|
||||
device_name = normalized_device_name
|
||||
else:
|
||||
print(f"[设备标准化] 使用原始设备名称 '{device_name}': {normalized_result.get('error')}")
|
||||
|
||||
async def _do_rag_search():
|
||||
from utils.ship_number_search import search_with_ship_number_strategy
|
||||
base_query = f"{device_name}中{fault}该怎么修复"
|
||||
results, kb_name, kb_id, mapped_num = await search_with_ship_number_strategy(
|
||||
base_query=base_query, ship_number=ship_number, top_k=5, search_type="fault"
|
||||
|
||||
base_query = f"设备{device_name},故障{fault}的维修方案"
|
||||
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",
|
||||
)
|
||||
if not kb_name:
|
||||
kb_name = "通用知识库"
|
||||
print(f"RAG 检索来源: {kb_name}")
|
||||
return results, kb_name, kb_id, mapped_num
|
||||
return results, kb_name, kb_id, mapped_number
|
||||
|
||||
async def _do_graph_search():
|
||||
if not (device_name and fault):
|
||||
return ""
|
||||
try:
|
||||
graph_query = f"{device_name}中{fault}该怎么修复"
|
||||
results = await graph_rag_search.ainvoke({"query": graph_query})
|
||||
if len(results) < 60:
|
||||
graph_query = f"{fault}该怎么修复"
|
||||
results = await graph_rag_search.ainvoke({"query": graph_query})
|
||||
results = await fault_graph_rag_search.ainvoke(
|
||||
{"device_name": device_name, "fault_symptom": fault, "ship_number": ""}
|
||||
)
|
||||
print(f"图谱检索完成,结果长度: {len(results) if results else 0}")
|
||||
return results
|
||||
except Exception as e:
|
||||
print(f"图谱检索失败: {str(e)}")
|
||||
return ""
|
||||
|
||||
need_rag = "rag_search" in tools_to_call and rag_results is None
|
||||
need_graph = "graph_rag_search" in tools_to_call and graph_results is None
|
||||
async def _do_atlas_search():
|
||||
if not device_name:
|
||||
return {}
|
||||
try:
|
||||
print(f"[图册检索] 使用设备名称: {device_name}")
|
||||
result = await atlas_retrieval(node_names=[device_name], top_k=10)
|
||||
if result.get("success", False):
|
||||
data = result.get("data", {})
|
||||
print(f"[图册检索] 图册检索结果: {list(data.keys())}")
|
||||
return data
|
||||
except Exception as e:
|
||||
print(f"[图册检索] 图册检索失败: {str(e)}")
|
||||
return {}
|
||||
|
||||
if 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()
|
||||
tasks = []
|
||||
task_names = []
|
||||
if "rag_search" in tools_to_call and rag_results is None:
|
||||
tasks.append(_do_rag_search())
|
||||
task_names.append("rag")
|
||||
if "graph_rag_search" in tools_to_call and graph_results is None:
|
||||
tasks.append(_do_graph_search())
|
||||
task_names.append("graph")
|
||||
if device_name:
|
||||
tasks.append(_do_atlas_search())
|
||||
task_names.append("atlas")
|
||||
|
||||
if tasks:
|
||||
task_results = await asyncio.gather(*tasks)
|
||||
for task_name, task_result in zip(task_names, task_results):
|
||||
if task_name == "rag":
|
||||
rag_results, matched_kb_name, matched_kb_id, mapped_ship_number = task_result
|
||||
elif task_name == "graph":
|
||||
graph_results = task_result
|
||||
elif task_name == "atlas":
|
||||
atlas_results = task_result
|
||||
|
||||
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:
|
||||
print(f"[图册检索] 使用设备名称: {device_name}")
|
||||
atlas_result = await atlas_retrieval(node_names=[device_name], top_k=10)
|
||||
if atlas_result.get("success", False):
|
||||
atlas_results = atlas_result.get("data", {})
|
||||
print(f"[图册检索] 图册检索结果: {list(atlas_results.keys())}")
|
||||
except Exception as e:
|
||||
print(f"[图册检索] 图册检索失败: {str(e)}")
|
||||
|
||||
has_rag = bool(rag_results and len(rag_results) > 0)
|
||||
has_graph = bool(graph_results and len(graph_results) > 100)
|
||||
|
||||
if not has_rag and not has_graph:
|
||||
print(f"[call_tools] 未找到任何相关资料,需要用户确认使用模型能力")
|
||||
return {"rag_search_result": rag_results if isinstance(rag_results, list) else [],"graph_search_result": graph_results if isinstance(graph_results, str) else "","atlas_results": atlas_results,"matched_kb_id": matched_kb_id,"matched_kb_name": matched_kb_name,"ship_number": ship_number,"has_rag_result": False,"has_graph_result": False,"need_model_confirm": True,"current_node": "model_confirm",}
|
||||
return {"rag_search_result": rag_results if isinstance(rag_results, list) else [],"graph_search_result": graph_results if isinstance(graph_results, str) else "","atlas_results": atlas_results,"matched_kb_id": matched_kb_id,"matched_kb_name": matched_kb_name,"ship_number": ship_number,"device_name": device_name,"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,"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:
|
||||
"""
|
||||
@ -1660,5 +1689,3 @@ async def run_fault_diagnosis_workflow(extracted_text: str,combined_query: str,h
|
||||
route_flag = "fault_diagnosis"
|
||||
|
||||
return {"response": result.get("response", ""),"actions": result.get("actions", []),"result_tag": route_flag,"suggestedReplies": result.get("suggestedReplies", []),"route_flag": route_flag,}
|
||||
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
from typing import TypedDict, Optional, Dict, Any, List
|
||||
from langgraph.graph import StateGraph, START, END
|
||||
from langgraph.types import interrupt, Command
|
||||
from tools.function_tool import graph_rag_search, fault_graph_rag_search
|
||||
from tools.function_tool import graph_rag_search
|
||||
from tools.rag_tools import rag_search
|
||||
from tools.graph_tools import atlas_retrieval, format_atlas_results, extract_entity_names_from_history
|
||||
from modelsAPI.model_api import OpenaiAPI
|
||||
@ -720,31 +720,47 @@ async def call_tools(state: FaultDiagnosisState) -> Dict[str, Any]:
|
||||
matched_kb_id = ""
|
||||
mapped_ship_number = ""
|
||||
|
||||
if "rag_search" in tools_to_call and rag_results is None:
|
||||
async def _do_rag_search():
|
||||
from utils.ship_number_search import search_with_ship_number_strategy
|
||||
base_query = f"{device_name}中{fault}该怎么修复"
|
||||
results, kb_name, kb_id, mapped_num = await search_with_ship_number_strategy(
|
||||
base_query=base_query, ship_number=ship_number, top_k=5, search_type="fault"
|
||||
)
|
||||
if not kb_name:
|
||||
kb_name = "通用知识库"
|
||||
print(f"RAG 检索来源: {kb_name}")
|
||||
return results, kb_name, kb_id, mapped_num
|
||||
|
||||
base_query = f"设备{device_name},故障{fault}的维修方案"
|
||||
async def _do_graph_search():
|
||||
if not (device_name and fault):
|
||||
return ""
|
||||
try:
|
||||
graph_query = f"{device_name}中{fault}该怎么修复"
|
||||
results = await graph_rag_search.ainvoke({"query": graph_query})
|
||||
if len(results) < 60:
|
||||
graph_query = f"{fault}该怎么修复"
|
||||
results = await graph_rag_search.ainvoke({"query": graph_query})
|
||||
print(f"图谱检索完成,结果长度: {len(results) if results else 0}")
|
||||
return results
|
||||
except Exception as e:
|
||||
print(f"图谱检索失败: {str(e)}")
|
||||
return ""
|
||||
|
||||
rag_results, matched_kb_name, matched_kb_id, mapped_ship_number = await search_with_ship_number_strategy(base_query=base_query,ship_number=ship_number,top_k=5,search_type="fault")
|
||||
need_rag = "rag_search" in tools_to_call and rag_results is None
|
||||
need_graph = "graph_rag_search" in tools_to_call and graph_results is None
|
||||
|
||||
if not matched_kb_name:
|
||||
matched_kb_name = "通用知识库"
|
||||
|
||||
print(f"RAG 检索来源: {matched_kb_name}")
|
||||
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 "graph_rag_search" in tools_to_call and graph_results is None:
|
||||
if device_name and fault:
|
||||
try:
|
||||
graph_results = await fault_graph_rag_search.ainvoke({"device_name": device_name, "fault_symptom": fault, "ship_number": ship_number or ""})
|
||||
print(f"图谱检索完成,结果长度: {len(graph_results) if graph_results else 0}")
|
||||
except Exception as e:
|
||||
print(f"图谱检索失败: {str(e)}")
|
||||
graph_results = ""
|
||||
|
||||
# 图册检索:只传入设备名称
|
||||
if device_name:
|
||||
try:
|
||||
@ -860,7 +876,8 @@ async def generate_response(state: FaultDiagnosisState) -> Dict[str, Any]:
|
||||
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]:
|
||||
for item in sorted(rag_results, key=lambda x: float(x.get("score") or 0) if isinstance(x, dict) else 0, reverse=True)[:2]:
|
||||
# for item in rag_results[:1]:
|
||||
if isinstance(item, dict):
|
||||
text = (item.get("text") or "").strip()
|
||||
if text:
|
||||
@ -882,7 +899,7 @@ async def generate_response(state: FaultDiagnosisState) -> Dict[str, Any]:
|
||||
rag_answer = graph_results
|
||||
rag_type = "graph"
|
||||
elif rag_ctx_parts:
|
||||
rag_answer = "\n\n".join(rag_ctx_parts[:1])
|
||||
rag_answer = "\n\n".join(rag_ctx_parts[:2])
|
||||
rag_type = "rag"
|
||||
|
||||
if not rag_answer or rag_answer == "NO_RELEVANT_RESULT":
|
||||
@ -4,7 +4,7 @@
|
||||
from typing import TypedDict, Optional, Dict, Any, List
|
||||
from langgraph.graph import StateGraph, START, END
|
||||
from langgraph.types import interrupt, Command
|
||||
from tools.function_tool import graph_rag_search
|
||||
from tools.function_tool import graph_rag_search, operation_graph_rag_search
|
||||
from tools.rag_tools import rag_search
|
||||
from tools.graph_tools import atlas_retrieval, format_atlas_results, extract_entity_names_from_history
|
||||
from modelsAPI.model_api import OpenaiAPI
|
||||
@ -153,7 +153,7 @@ async def extract_info(state: OperateGuideState) -> Dict[str, Any]:
|
||||
combined_query = state.get("combined_query", "") or ""
|
||||
history_messages = state.get("history_messages", []) or []
|
||||
|
||||
history_str = build_history_str(history_messages, recent_count=4)
|
||||
history_str = build_history_str(history_messages, recent_count=10)
|
||||
|
||||
existing_info = []
|
||||
existing_ship_number = state.get("ship_number", "") or ""
|
||||
@ -270,7 +270,7 @@ async def agent_think(state: OperateGuideState) -> Dict[str, Any]:
|
||||
tools_to_call.append("graph_rag_search")
|
||||
return {"scheme_generated": False,"rag_search_result": None,"graph_search_result": None,"deep_rag_results": None,"has_rag_result": False,"has_graph_result": False,"is_regenerating": False,"user_feedback_for_regenerate": "","follow_up_intent": "","user_confirmed_info": True,"initialized": True,"iteration_count": 0,"ask_round": 0,"agent_decision": "call_tools","tools_to_call": tools_to_call,"current_node": "call_tools",}
|
||||
|
||||
history_str = build_history_str(history_messages, recent_count=5, assistant_truncate=300)
|
||||
history_str = build_history_str(history_messages, recent_count=8, assistant_truncate=300)
|
||||
|
||||
intent_system_prompt = OPERATE_PROMPTS["agent_think_intent_system"]
|
||||
|
||||
@ -591,7 +591,7 @@ async def call_tools(state: OperateGuideState) -> Dict[str, Any]:
|
||||
|
||||
async def _do_rag_search():
|
||||
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(
|
||||
base_query=base_query, ship_number=ship_number, top_k=5, search_type="operate"
|
||||
)
|
||||
@ -604,8 +604,9 @@ async def call_tools(state: OperateGuideState) -> Dict[str, Any]:
|
||||
if not (device_name and operation_item):
|
||||
return ""
|
||||
try:
|
||||
graph_query = f"如何执行{device_name}的{operation_item}操作"
|
||||
results = await graph_rag_search.ainvoke({"query": graph_query})
|
||||
graph_query = f"设备{device_name},操作{operation_item}的操作指导方案"
|
||||
results = await operation_graph_rag_search.ainvoke({"ship_number": 163, "device_name": device_name, "operation_item": operation_item})
|
||||
|
||||
print(f"图谱检索完成,结果长度: {len(results) if results else 0}")
|
||||
return results
|
||||
except Exception as e:
|
||||
@ -616,8 +617,10 @@ async def call_tools(state: OperateGuideState) -> Dict[str, Any]:
|
||||
need_graph = "graph_rag_search" in tools_to_call and graph_results is None
|
||||
|
||||
if need_rag and need_graph:
|
||||
(rag_results, matched_kb_name, matched_kb_id), graph_results = \
|
||||
await asyncio.gather(_do_rag_search(), _do_graph_search())
|
||||
(rag_results, matched_kb_name, matched_kb_id), graph_results = await asyncio.gather(
|
||||
_do_rag_search(),
|
||||
_do_graph_search()
|
||||
)
|
||||
elif need_rag:
|
||||
rag_results, matched_kb_name, matched_kb_id = await _do_rag_search()
|
||||
elif need_graph:
|
||||
@ -705,7 +708,7 @@ async def generate_response(state: OperateGuideState) -> Dict[str, Any]:
|
||||
matched_kb_name = state.get("matched_kb_name", "")
|
||||
|
||||
print("rag_results", rag_results)
|
||||
history_str = build_history_str(history_messages, recent_count=5, assistant_truncate=500)
|
||||
history_str = build_history_str(history_messages, recent_count=12, assistant_truncate=500)
|
||||
|
||||
has_rag = rag_results and len(rag_results) > 0
|
||||
has_graph = graph_results and len(graph_results) > 100
|
||||
@ -738,7 +741,7 @@ async def generate_response(state: OperateGuideState) -> Dict[str, Any]:
|
||||
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]:
|
||||
for item in sorted(rag_results, key=lambda x: float(x.get("score") or 0) if isinstance(x, dict) else 0, reverse=True)[:2]:
|
||||
if isinstance(item, dict):
|
||||
text = (item.get("text") or "").strip()
|
||||
if text:
|
||||
@ -760,7 +763,7 @@ async def generate_response(state: OperateGuideState) -> Dict[str, Any]:
|
||||
rag_answer = graph_results
|
||||
rag_type = "graph"
|
||||
elif rag_ctx_parts:
|
||||
rag_answer = "\n\n".join(rag_ctx_parts[:3])
|
||||
rag_answer = "\n\n".join(rag_ctx_parts[:2])
|
||||
rag_type = "rag"
|
||||
|
||||
if not rag_answer or rag_answer == "NO_RELEVANT_RESULT":
|
||||
@ -1103,7 +1106,7 @@ async def deep_rag_search(state: OperateGuideState) -> Dict[str, Any]:
|
||||
# ========== 第一步:分析之前方案和用户问题,选择深度分析点 ==========
|
||||
if last_generated_scheme or combined_query:
|
||||
try:
|
||||
history_str = build_history_str(history_messages, recent_count=4, assistant_truncate=300)
|
||||
history_str = build_history_str(history_messages, recent_count=8, assistant_truncate=300)
|
||||
|
||||
detail_info = ""
|
||||
if operation_code:
|
||||
@ -1218,7 +1221,7 @@ async def generate_deep_response(state: OperateGuideState) -> Dict[str, Any]:
|
||||
results = deep_rag_results.get(key, [])
|
||||
if results:
|
||||
analysis_points_text += f"\n【分析点:{point}】\n"
|
||||
for i, item in enumerate(results[:2], 1):
|
||||
for i, item in enumerate(sorted(results, key=lambda x: float(x.get("score") or 0) if isinstance(x, dict) else 0, reverse=True)[:2], 1):
|
||||
if isinstance(item, dict):
|
||||
text = item.get("text", "")
|
||||
if text:
|
||||
@ -1228,7 +1231,7 @@ async def generate_deep_response(state: OperateGuideState) -> Dict[str, Any]:
|
||||
original_rag_text = ""
|
||||
if original_rag_results:
|
||||
original_rag_text = "\n【原始检索资料】\n"
|
||||
for i, item in enumerate(original_rag_results[:3], 1):
|
||||
for i, item in enumerate(sorted(original_rag_results, key=lambda x: float(x.get("score") or 0) if isinstance(x, dict) else 0, reverse=True)[:2], 1):
|
||||
if isinstance(item, dict):
|
||||
text = item.get("text", "")
|
||||
if text:
|
||||
@ -1465,5 +1468,3 @@ async def run_operate_workflow(extracted_text: str,combined_query: str,history_m
|
||||
route_flag = "operate"
|
||||
|
||||
return {"response": result.get("response", ""),"actions": result.get("actions", []),"result_tag": route_flag,"suggestedReplies": result.get("suggestedReplies", []),"route_flag": route_flag,}
|
||||
|
||||
|
||||
|
||||