feat: add AI学习助手 agent (KG+RAG ideal) and renshenguo feishu bot

- Add AI学习助手 agent creation script with all 39 tools, 3-layer KG+RAG memory
- Add renshenguo (人参果) feishu bot integration (app_service + ws_handler)
- Register renshenguo WS client in main.py startup
- Add RENSHENGUO_APP_ID / RENSHENGUO_APP_SECRET / RENSHENGUO_AGENT_ID config
- Reorganize docs from root into docs/ subdirectories
- Move startup scripts to scripts/startup/
- Various backend optimizations and tool improvements

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
renjianbo
2026-05-06 01:37:13 +08:00
parent f33bc461ff
commit eabf90c496
171 changed files with 4906 additions and 445 deletions

View File

@@ -14,6 +14,7 @@ from app.agent_runtime.schemas import (
AgentLLMConfig,
AgentToolConfig,
AgentBudgetConfig,
AgentMemoryConfig,
)
logger = logging.getLogger(__name__)
@@ -83,20 +84,35 @@ async def run_agent_node(
if "max_tool_calls" in budget_limits:
budget.max_tool_calls = max(1, int(budget_limits["max_tool_calls"]))
# 构建记忆配置(从 node_data 读取完整字段,兼容简化配置)
mem_enabled = bool(node_data.get("memory", True))
memory_config = AgentMemoryConfig(
enabled=mem_enabled,
max_history_messages=int(node_data.get("memory_max_history", 20)),
persist_to_db=bool(node_data.get("memory_persist", mem_enabled)),
vector_memory_enabled=bool(node_data.get("memory_vector_enabled", True)),
vector_memory_top_k=int(node_data.get("memory_vector_top_k", 5)),
learning_enabled=bool(node_data.get("memory_learning", True)),
)
# 构建工具审批配置
tool_config = AgentToolConfig(
include_tools=node_data.get("tools") or [],
exclude_tools=node_data.get("exclude_tools") or [],
require_approval=node_data.get("require_approval") or [],
approval_timeout_ms=int(node_data.get("approval_timeout_ms", 60000)),
approval_default=node_data.get("approval_default", "deny"),
)
agent_config = AgentConfig(
name=node_data.get("label", "agent_node"),
system_prompt=formatted_prompt,
llm=llm_config,
tools=AgentToolConfig(
include_tools=node_data.get("tools", []),
exclude_tools=node_data.get("exclude_tools", []),
),
memory={
"enabled": node_data.get("memory", True),
"persist_to_db": node_data.get("memory", True),
},
tools=tool_config,
memory=memory_config,
budget=budget,
user_id=user_id,
memory_scope_id=node_data.get("memory_scope_id") or node_data.get("agent_id", ""),
self_review_enabled=node_data.get("self_review_enabled", False),
)

View File

@@ -1,7 +1,7 @@
"""
Agent管理API
"""
from fastapi import APIRouter, Depends, HTTPException, status, Query
from fastapi import APIRouter, Depends, HTTPException, status, Query, Response
from sqlalchemy.orm import Session
from pydantic import BaseModel, Field
from typing import List, Optional, Dict, Any
@@ -101,6 +101,7 @@ class AgentResponse(BaseModel):
@router.get("", response_model=List[AgentResponse])
async def get_agents(
response: Response,
skip: int = Query(0, ge=0, description="跳过记录数"),
limit: int = Query(100, ge=1, le=100, description="每页记录数"),
search: Optional[str] = Query(None, description="搜索关键词(按名称或描述)"),
@@ -152,9 +153,12 @@ async def get_agents(
if status:
query = query.filter(Agent.status == status)
# 先获取总数(不带分页)
total_count = query.count()
# 排序和分页
agents = query.order_by(Agent.created_at.desc()).offset(skip).limit(limit).all()
# 转换为响应格式确保user_id和日期时间字段正确处理
result = []
for agent in agents:
@@ -170,7 +174,9 @@ async def get_agents(
"created_at": agent.created_at if agent.created_at else datetime.now(),
"updated_at": agent.updated_at if agent.updated_at else datetime.now()
})
# 通过 X-Total-Count 响应头返回总数,前端借此正确分页
response.headers["X-Total-Count"] = str(total_count)
return result

View File

@@ -110,6 +110,11 @@ class Settings(BaseSettings):
LINGXI_APP_SECRET: str = ""
LINGXI_AGENT_ID: str = "" # 创建灵犀后写入
# 人参果飞书应用配置(独立 WS 连接,路由到 AI学习助手 Agent — KG+RAG理想版
RENSHENGUO_APP_ID: str = ""
RENSHENGUO_APP_SECRET: str = ""
RENSHENGUO_AGENT_ID: str = "" # 创建 AI学习助手 后写入
class Config:
env_file = str(_ENV_PATH)
case_sensitive = True

View File

@@ -8,7 +8,7 @@ logger = logging.getLogger(__name__)
_registered = False
_EXPECTED_BUILTIN = 35
_EXPECTED_BUILTIN = 39
def ensure_builtin_tools_registered() -> None:
@@ -53,6 +53,10 @@ def ensure_builtin_tools_registered() -> None:
code_tool_create_tool,
extension_log_tool,
self_review_tool,
knowledge_graph_search_tool,
knowledge_graph_add_tool,
entity_search_tool,
learning_path_tool,
HTTP_REQUEST_SCHEMA,
FILE_READ_SCHEMA,
FILE_WRITE_SCHEMA,
@@ -88,6 +92,10 @@ def ensure_builtin_tools_registered() -> None:
CODE_TOOL_CREATE_SCHEMA,
EXTENSION_LOG_SCHEMA,
SELF_REVIEW_SCHEMA,
KNOWLEDGE_GRAPH_SEARCH_SCHEMA,
KNOWLEDGE_GRAPH_ADD_SCHEMA,
ENTITY_SEARCH_SCHEMA,
LEARNING_PATH_SCHEMA,
)
tool_registry.register_builtin_tool("http_request", http_request_tool, HTTP_REQUEST_SCHEMA)
@@ -125,6 +133,10 @@ def ensure_builtin_tools_registered() -> None:
tool_registry.register_builtin_tool("code_tool_create", code_tool_create_tool, CODE_TOOL_CREATE_SCHEMA)
tool_registry.register_builtin_tool("extension_log", extension_log_tool, EXTENSION_LOG_SCHEMA)
tool_registry.register_builtin_tool("self_review", self_review_tool, SELF_REVIEW_SCHEMA)
tool_registry.register_builtin_tool("knowledge_graph_search", knowledge_graph_search_tool, KNOWLEDGE_GRAPH_SEARCH_SCHEMA)
tool_registry.register_builtin_tool("knowledge_graph_add", knowledge_graph_add_tool, KNOWLEDGE_GRAPH_ADD_SCHEMA)
tool_registry.register_builtin_tool("entity_search", entity_search_tool, ENTITY_SEARCH_SCHEMA)
tool_registry.register_builtin_tool("learning_path", learning_path_tool, LEARNING_PATH_SCHEMA)
_registered = True
n = tool_registry.builtin_tool_count()

View File

@@ -248,6 +248,13 @@ async def startup_event():
except Exception as e:
logger.error(f"灵犀长连接启动失败: {e}")
# 启动人参果飞书长连接AI学习助手 — KG+RAG理想版
try:
from app.services.renshenguo_ws_handler import start_ws_client as start_renshenguo_ws
asyncio.ensure_future(start_renshenguo_ws())
except Exception as e:
logger.error(f"人参果长连接启动失败: {e}")
# 注册路由
from app.api import auth, uploads, workflows, executions, websocket, execution_logs, data_sources, agents, platform_templates, model_configs, webhooks, template_market, batch_operations, collaboration, permissions, monitoring, alert_rules, node_test, node_templates, tools, agent_chat, agent_monitoring, knowledge_base, agent_schedules, notifications, feishu_bind, approval

View File

@@ -68,3 +68,43 @@ class GlobalKnowledge(Base):
def __repr__(self):
return f"<GlobalKnowledge(id={self.id}, source_agent={self.source_agent_id})>"
class KnowledgeEntity(Base):
"""知识图谱实体表 — 学习知识点、概念、术语"""
__tablename__ = "knowledge_entities"
id = Column(CHAR(36), primary_key=True, default=lambda: str(uuid.uuid4()), comment="实体ID")
name = Column(String(200), nullable=False, comment="实体名称")
entity_type = Column(String(50), nullable=False, default="concept", comment="实体类型: concept/formula/fact/term/task/skill")
description = Column(Text, comment="实体描述")
embedding = Column(Text, nullable=True, comment="实体名称+描述的 embeddingJSON 序列化)")
metadata_ = Column("metadata", JSON, nullable=True, comment="扩展元数据")
source = Column(String(50), default="extracted", comment="来源: extracted/manual/imported")
confidence = Column(String(20), default="medium", comment="置信度: low/medium/high")
scope_kind = Column(String(50), default="agent", comment="作用域类型")
scope_id = Column(String(100), default="", comment="作用域 ID")
user_id = Column(CHAR(36), ForeignKey("users.id"), nullable=True, comment="创建者ID")
created_at = Column(DateTime, default=func.now(), comment="创建时间")
updated_at = Column(DateTime, default=func.now(), onupdate=func.now(), comment="更新时间")
def __repr__(self):
return f"<KnowledgeEntity(id={self.id}, name={self.name}, type={self.entity_type})>"
class KnowledgeRelation(Base):
"""知识图谱关系表 — 实体之间的语义关系"""
__tablename__ = "knowledge_relations"
id = Column(CHAR(36), primary_key=True, default=lambda: str(uuid.uuid4()), comment="关系ID")
source_entity_id = Column(CHAR(36), nullable=False, index=True, comment="源实体ID")
target_entity_id = Column(CHAR(36), nullable=False, index=True, comment="目标实体ID")
relation_type = Column(String(50), nullable=False, comment="关系类型: prerequisite/extends/contains/related_to/example_of/applies_to")
description = Column(Text, comment="关系描述")
weight = Column(String(20), default="1.0", comment="关系权重")
scope_kind = Column(String(50), default="agent", comment="作用域类型")
scope_id = Column(String(100), default="", comment="作用域 ID")
created_at = Column(DateTime, default=func.now(), comment="创建时间")
def __repr__(self):
return f"<KnowledgeRelation({self.source_entity_id}) -[{self.relation_type}]-> ({self.target_entity_id})>"

View File

@@ -1756,9 +1756,9 @@ async def schedule_create_tool(
if not agent:
return json.dumps({"error": f"Agent 不存在: {agent_id}"}, ensure_ascii=False)
# 尝试计算下次执行时间
# 尝试计算下次执行时间(使用北京时间,与 schedule 表 timezone 默认值一致)
try:
next_run = compute_next_run(cron_expression)
next_run = compute_next_run(cron_expression, tz="Asia/Shanghai")
except (ValueError, KeyError) as e:
return json.dumps({"error": f"cron 表达式无效: {e}(标准 5 位格式,如 0 9 * * *"}, ensure_ascii=False)
@@ -4388,3 +4388,314 @@ SELF_REVIEW_SCHEMA = {
},
},
}
# ── knowledge_graph_search ────────────────────────────────────
async def knowledge_graph_search_tool(
query: str,
top_k: int = 5,
include_graph: bool = True,
scope_id: str = "",
) -> str:
"""知识图谱混合检索:向量语义搜索 + 图谱邻居展开。
当用户询问学习相关问题时,用此工具搜索已构建的知识图谱,
同时获取语义相似的知识实体和图谱中关联的邻居知识点。
Args:
query: 搜索查询(用户的问题或关键词)
top_k: 返回的向量匹配实体数(默认 5
include_graph: 是否展开图谱邻居(默认 true
scope_id: 作用域 ID默认使用当前 Agent ID
"""
import asyncio as _asyncio
from app.services.knowledge_graph_service import hybrid_search
try:
result = await hybrid_search(
query=query,
scope_kind="agent",
scope_id=scope_id or "learning_assistant",
top_k=top_k,
include_neighbors=include_graph,
)
formatted = result.get("formatted_context", "")
vector_count = len(result.get("vector_matches", []))
graph_count = len(result.get("graph_expansion", {}).get("entities", []))
return json.dumps({
"query": query,
"vector_matches_count": vector_count,
"graph_entities_count": graph_count,
"context": formatted,
"raw": {
"vector_matches": result.get("vector_matches", []),
"graph_expansion": result.get("graph_expansion", {}),
},
}, ensure_ascii=False)
except Exception as e:
logger.error(f"knowledge_graph_search 失败: {e}")
return json.dumps({"error": f"知识图谱搜索失败: {e}"}, ensure_ascii=False)
KNOWLEDGE_GRAPH_SEARCH_SCHEMA = {
"type": "function",
"function": {
"name": "knowledge_graph_search",
"description": (
"知识图谱混合检索:用向量语义搜索找到相关知识实体,"
"再展开图谱邻居获取关联知识点。适合学习场景中的知识检索、"
"概念关联、前置知识查找。返回结构化的知识上下文。"
),
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "搜索查询(用户问题或关键词)"},
"top_k": {"type": "integer", "description": "返回的向量匹配实体数", "default": 5},
"include_graph": {"type": "boolean", "description": "是否展开图谱邻居", "default": True},
"scope_id": {"type": "string", "description": "作用域 ID默认 learning_assistant"},
},
"required": ["query"],
},
},
}
# ── knowledge_graph_add ────────────────────────────────────────
async def knowledge_graph_add_tool(
text: str,
scope_id: str = "",
) -> str:
"""从文本中提取知识点实体和关系,写入知识图谱。
当用户分享学习内容、知识点总结、对话中有价值的信息时,
调用此工具自动提取实体和关系并持久化到知识图谱。
Args:
text: 要提取知识的文本内容
scope_id: 作用域 ID默认使用当前 Agent ID
"""
import asyncio as _asyncio
from app.services.knowledge_graph_service import extract_from_text
if not text or len(text.strip()) < 20:
return json.dumps({
"error": "文本太短(至少需要 20 个字符)",
"entity_count": 0,
"relation_count": 0,
}, ensure_ascii=False)
try:
result = await extract_from_text(
text=text,
scope_kind="agent",
scope_id=scope_id or "learning_assistant",
)
return json.dumps({
"entity_count": result.get("entity_count", 0),
"relation_count": result.get("relation_count", 0),
"entities": [
{"name": e["name"], "type": e["entity_type"], "description": e.get("description", "")[:200]}
for e in result.get("entities", [])
],
"relations": result.get("relations", []),
"hint": f"已从文本中提取 {result.get('entity_count', 0)} 个实体和 {result.get('relation_count', 0)} 个关系,可使用 knowledge_graph_search 检索",
}, ensure_ascii=False)
except Exception as e:
logger.error(f"knowledge_graph_add 失败: {e}")
return json.dumps({"error": f"知识图谱添加失败: {e}"}, ensure_ascii=False)
KNOWLEDGE_GRAPH_ADD_SCHEMA = {
"type": "function",
"function": {
"name": "knowledge_graph_add",
"description": (
"从文本中提取知识点实体和关系,写入知识图谱。"
"自动识别概念、公式、术语、事实等实体类型,"
"并建立前置/扩展/包含/示例/应用等语义关系。"
"适合将学习材料、对话内容转化为结构化知识网络。"
),
"parameters": {
"type": "object",
"properties": {
"text": {"type": "string", "description": "从中提取知识的文本内容(学习材料、知识点总结等)"},
"scope_id": {"type": "string", "description": "作用域 ID默认 learning_assistant"},
},
"required": ["text"],
},
},
}
# ── entity_search ──────────────────────────────────────────────
async def entity_search_tool(
keyword: str = "",
entity_type: str = "",
scope_id: str = "",
limit: int = 10,
) -> str:
"""关键词搜索知识图谱中的实体。
Args:
keyword: 搜索关键词(在名称和描述中查找)
entity_type: 实体类型筛选concept/formula/fact/term/task/skill留空=全部
scope_id: 作用域 ID
limit: 返回数量
"""
from app.services.knowledge_graph_service import search_entities
from app.core.database import SessionLocal
db = None
try:
db = SessionLocal()
entities = search_entities(
db,
keyword=keyword,
entity_type=entity_type or None,
scope_kind="agent",
scope_id=scope_id or "learning_assistant",
limit=limit,
)
return json.dumps({
"keyword": keyword,
"count": len(entities),
"entities": [
{
"id": e.id,
"name": e.name,
"type": e.entity_type,
"description": e.description,
"confidence": e.confidence,
}
for e in entities
],
}, ensure_ascii=False)
except Exception as e:
logger.error(f"entity_search 失败: {e}")
return json.dumps({"error": f"实体搜索失败: {e}"}, ensure_ascii=False)
finally:
if db:
db.close()
ENTITY_SEARCH_SCHEMA = {
"type": "function",
"function": {
"name": "entity_search",
"description": (
"在知识图谱中按关键词搜索实体。"
"可按实体类型(概念/公式/术语/事实/任务/技能)筛选。"
"适合查找特定知识点或浏览知识库。"
),
"parameters": {
"type": "object",
"properties": {
"keyword": {"type": "string", "description": "搜索关键词(在名称和描述中查找)", "default": ""},
"entity_type": {
"type": "string",
"enum": ["concept", "formula", "fact", "term", "task", "skill"],
"description": "实体类型筛选,留空=全部",
},
"scope_id": {"type": "string", "description": "作用域 ID"},
"limit": {"type": "integer", "description": "返回数量", "default": 10},
},
"required": [],
},
},
}
# ── learning_path ─────────────────────────────────────────────
async def learning_path_tool(
entity_names: str,
scope_id: str = "",
) -> str:
"""基于知识图谱推荐学习路径。
给定一组目标知识点名称(逗号分隔),分析其前置依赖关系,
返回建议的学习顺序。
Args:
entity_names: 目标知识点名称,逗号分隔(如 "微积分,导数,极限"
scope_id: 作用域 ID
"""
from app.services.knowledge_graph_service import search_entities, recommend_learning_path
from app.core.database import SessionLocal
if not entity_names or not entity_names.strip():
return json.dumps({"error": "请提供至少一个知识点名称"}, ensure_ascii=False)
names = [n.strip() for n in entity_names.split(",") if n.strip()]
db = None
try:
db = SessionLocal()
entity_ids = []
found_names = []
for name in names:
entities = search_entities(
db, keyword=name,
scope_kind="agent", scope_id=scope_id or "learning_assistant",
limit=3,
)
for e in entities:
if e.name == name or name in e.name:
entity_ids.append(e.id)
found_names.append(e.name)
break
if not entity_ids:
return json.dumps({
"message": f"未在知识图谱中找到这些知识点: {', '.join(names)}",
"hint": "请先用 knowledge_graph_add 添加相关知识,或使用更通用的名称搜索",
}, ensure_ascii=False)
path = recommend_learning_path(
db, entity_ids,
scope_kind="agent", scope_id=scope_id or "learning_assistant",
)
return json.dumps({
"target_entities": names,
"found_entities": found_names,
"suggested_order": path.get("suggested_order", []),
"summary": path.get("summary", ""),
"prerequisites": [
{
"prerequisite": p["prerequisite"]["name"],
"target": p["target"]["name"],
"relation": p["relation"].get("description", ""),
}
for p in path.get("prerequisites", [])
],
}, ensure_ascii=False)
except Exception as e:
logger.error(f"learning_path 失败: {e}")
return json.dumps({"error": f"学习路径推荐失败: {e}"}, ensure_ascii=False)
finally:
if db:
db.close()
LEARNING_PATH_SCHEMA = {
"type": "function",
"function": {
"name": "learning_path",
"description": (
"基于知识图谱推荐学习路径。给定一组目标知识点,"
"分析前置依赖关系,给出建议的学习顺序。"
"适合制定学习计划、了解知识点间的先后关系。"
),
"parameters": {
"type": "object",
"properties": {
"entity_names": {"type": "string", "description": "目标知识点名称,逗号分隔(如 \"微积分,导数,极限\""},
"scope_id": {"type": "string", "description": "作用域 ID"},
},
"required": ["entity_names"],
},
},
}

View File

@@ -0,0 +1,822 @@
"""
知识图谱服务 — 实体抽取、关系构建、图谱查询、向量融合检索
为智能学习助手提供 KG+RAG 核心能力:
- 从对话/文本中提取知识点实体
- 构建实体间的语义关系(前置/扩展/包含/示例/关联)
- 图谱查询:邻近节点、路径查找、子图展开
- 向量+图谱融合检索:同时命中语义相似和结构关联
"""
from __future__ import annotations
import json
import logging
from typing import Any, Dict, List, Optional, Tuple, Set
from sqlalchemy.orm import Session
from sqlalchemy import or_, and_
from app.core.database import SessionLocal
from app.models.agent import KnowledgeEntity, KnowledgeRelation
from app.services.embedding_service import embedding_service, VectorEntry
logger = logging.getLogger(__name__)
# 实体类型定义
ENTITY_TYPES = ["concept", "formula", "fact", "term", "task", "skill"]
# 关系类型定义
RELATION_TYPES = {
"prerequisite": "前置知识(学习 B 前需要先掌握 A",
"extends": "扩展B 是 A 的深入/延伸)",
"contains": "包含A 包含子知识点 B",
"related_to": "相关A 与 B 存在关联)",
"example_of": "示例B 是 A 的实例/例题)",
"applies_to": "应用A 可应用于 B",
}
def _build_entity_embedding_text(name: str, entity_type: str, description: str) -> str:
"""构建用于 embedding 的统一文本。"""
parts = [f"[{entity_type}] {name}"]
if description:
parts.append(f": {description[:500]}")
return "".join(parts)
def _serialize_embedding(emb: Optional[List[float]]) -> Optional[str]:
if not emb:
return None
return embedding_service.serialize_embedding(emb)
# ═══════════════════════════════════════════════════════════════
# 实体管理
# ═══════════════════════════════════════════════════════════════
async def add_entity(
db: Session,
name: str,
entity_type: str = "concept",
description: str = "",
metadata: Optional[Dict[str, Any]] = None,
source: str = "extracted",
confidence: str = "medium",
scope_kind: str = "agent",
scope_id: str = "",
user_id: Optional[str] = None,
) -> Optional[KnowledgeEntity]:
"""添加或更新知识实体(按 name+scope 去重,更新描述和 embedding"""
name = name.strip()[:200]
if not name:
return None
if entity_type not in ENTITY_TYPES:
entity_type = "concept"
# 查找已有实体(同名+同scope
existing = (
db.query(KnowledgeEntity)
.filter(
KnowledgeEntity.name == name,
KnowledgeEntity.scope_kind == scope_kind,
KnowledgeEntity.scope_id == scope_id,
)
.first()
)
# 生成 embedding
emb_text = _build_entity_embedding_text(name, entity_type, description)
embedding_json = None
try:
emb = await embedding_service.generate_embedding(emb_text)
if emb:
embedding_json = _serialize_embedding(emb)
except Exception as e:
logger.warning("生成实体 embedding 失败: %s", e)
if existing:
# 更新已有实体
if description and len(description) > len(existing.description or ""):
existing.description = description
if embedding_json:
existing.embedding = embedding_json
if metadata:
merged = {**(existing.metadata_ or {}), **metadata}
existing.metadata_ = merged
existing.confidence = confidence
db.commit()
db.refresh(existing)
logger.debug("更新知识实体: %s (%s)", name, entity_type)
return existing
entity = KnowledgeEntity(
name=name,
entity_type=entity_type,
description=description,
embedding=embedding_json,
metadata_=metadata or {},
source=source,
confidence=confidence,
scope_kind=scope_kind,
scope_id=scope_id,
user_id=user_id,
)
db.add(entity)
db.commit()
db.refresh(entity)
logger.info("新增知识实体: %s (%s) id=%s", name, entity_type, entity.id)
return entity
async def add_entities_batch(
db: Session,
entities: List[Dict[str, Any]],
scope_kind: str = "agent",
scope_id: str = "",
user_id: Optional[str] = None,
) -> List[KnowledgeEntity]:
"""批量添加实体,返回新增/更新后的实体列表。"""
results: List[KnowledgeEntity] = []
for ent in entities:
try:
e = await add_entity(
db,
name=ent.get("name", ""),
entity_type=ent.get("entity_type", "concept"),
description=ent.get("description", ""),
metadata=ent.get("metadata"),
source=ent.get("source", "extracted"),
confidence=ent.get("confidence", "medium"),
scope_kind=scope_kind,
scope_id=scope_id,
user_id=user_id,
)
if e:
results.append(e)
except Exception as ex:
logger.warning("批量添加实体失败: name=%s err=%s", ent.get("name"), ex)
return results
# ═══════════════════════════════════════════════════════════════
# 关系管理
# ═══════════════════════════════════════════════════════════════
def add_relation(
db: Session,
source_entity_id: str,
target_entity_id: str,
relation_type: str = "related_to",
description: str = "",
weight: float = 1.0,
scope_kind: str = "agent",
scope_id: str = "",
) -> Optional[KnowledgeRelation]:
"""添加两个实体间的关系(去重)。"""
if source_entity_id == target_entity_id:
return None
if relation_type not in RELATION_TYPES:
relation_type = "related_to"
existing = (
db.query(KnowledgeRelation)
.filter(
KnowledgeRelation.source_entity_id == source_entity_id,
KnowledgeRelation.target_entity_id == target_entity_id,
KnowledgeRelation.relation_type == relation_type,
KnowledgeRelation.scope_kind == scope_kind,
KnowledgeRelation.scope_id == scope_id,
)
.first()
)
if existing:
logger.debug("关系已存在: %s -[%s]-> %s", source_entity_id[:8], relation_type, target_entity_id[:8])
return existing
rel = KnowledgeRelation(
source_entity_id=source_entity_id,
target_entity_id=target_entity_id,
relation_type=relation_type,
description=description,
weight=str(weight),
scope_kind=scope_kind,
scope_id=scope_id,
)
db.add(rel)
db.commit()
db.refresh(rel)
logger.info("新增关系: %s -[%s]-> %s", source_entity_id[:8], relation_type, target_entity_id[:8])
return rel
def add_relations_from_map(
db: Session,
entity_map: Dict[str, str], # name -> entity_id
relations: List[Dict[str, Any]],
scope_kind: str = "agent",
scope_id: str = "",
) -> int:
"""从关系映射批量添加关系。relations 中 source/target 使用实体名称,自动映射为 ID。"""
count = 0
for rel in relations:
src_name = rel.get("source", "")
tgt_name = rel.get("target", "")
src_id = entity_map.get(src_name)
tgt_id = entity_map.get(tgt_name)
if not src_id or not tgt_id:
continue
r = add_relation(
db,
source_entity_id=src_id,
target_entity_id=tgt_id,
relation_type=rel.get("relation_type", "related_to"),
description=rel.get("description", ""),
weight=float(rel.get("weight", 1.0)),
scope_kind=scope_kind,
scope_id=scope_id,
)
if r:
count += 1
return count
# ═══════════════════════════════════════════════════════════════
# 图谱查询
# ═══════════════════════════════════════════════════════════════
def get_entity_by_id(db: Session, entity_id: str) -> Optional[KnowledgeEntity]:
return db.query(KnowledgeEntity).filter(KnowledgeEntity.id == entity_id).first()
def search_entities(
db: Session,
keyword: str = "",
entity_type: Optional[str] = None,
scope_kind: str = "agent",
scope_id: str = "",
limit: int = 20,
) -> List[KnowledgeEntity]:
"""关键词搜索实体。"""
q = db.query(KnowledgeEntity).filter(
KnowledgeEntity.scope_kind == scope_kind,
KnowledgeEntity.scope_id == scope_id,
)
if entity_type:
q = q.filter(KnowledgeEntity.entity_type == entity_type)
if keyword:
pattern = f"%{keyword}%"
q = q.filter(
or_(
KnowledgeEntity.name.like(pattern),
KnowledgeEntity.description.like(pattern),
)
)
return q.order_by(KnowledgeEntity.confidence.desc(), KnowledgeEntity.created_at.desc()).limit(limit).all()
def get_neighbors(
db: Session,
entity_id: str,
relation_types: Optional[List[str]] = None,
direction: str = "both",
max_depth: int = 1,
limit: int = 30,
) -> Dict[str, Any]:
"""
获取实体的图谱邻居。
Returns:
{
"entity": {...},
"neighbors": [{"entity": {...}, "relation": {...}, "direction": "out"|"in"}],
"subgraph_size": int,
}
"""
entity = get_entity_by_id(db, entity_id)
if not entity:
return {"entity": None, "neighbors": [], "subgraph_size": 0}
neighbors: List[Dict[str, Any]] = []
if direction in ("out", "both"):
q = db.query(KnowledgeRelation).filter(
KnowledgeRelation.source_entity_id == entity_id,
)
if relation_types:
q = q.filter(KnowledgeRelation.relation_type.in_(relation_types))
for rel in q.limit(limit).all():
target = get_entity_by_id(db, rel.target_entity_id)
if target:
neighbors.append({
"entity": _entity_to_dict(target),
"relation": _relation_to_dict(rel),
"direction": "out",
})
if direction in ("in", "both"):
q = db.query(KnowledgeRelation).filter(
KnowledgeRelation.target_entity_id == entity_id,
)
if relation_types:
q = q.filter(KnowledgeRelation.relation_type.in_(relation_types))
for rel in q.limit(limit).all():
source = get_entity_by_id(db, rel.source_entity_id)
if source:
neighbors.append({
"entity": _entity_to_dict(source),
"relation": _relation_to_dict(rel),
"direction": "in",
})
return {
"entity": _entity_to_dict(entity),
"neighbors": neighbors[:limit],
"subgraph_size": len(neighbors),
}
def get_entity_graph(
db: Session,
entity_id: str,
max_depth: int = 2,
limit: int = 50,
) -> Dict[str, Any]:
"""获取以实体为中心的子图BFS 展开)。"""
entity = get_entity_by_id(db, entity_id)
if not entity:
return {"nodes": [], "edges": [], "center_id": entity_id}
visited: Set[str] = {entity_id}
nodes: Dict[str, Dict[str, Any]] = {entity_id: _entity_to_dict(entity)}
edges: List[Dict[str, Any]] = []
frontier = {entity_id}
for depth in range(max_depth):
next_frontier: Set[str] = set()
if len(nodes) >= limit:
break
for nid in list(frontier):
# 出边
out_rels = (
db.query(KnowledgeRelation)
.filter(KnowledgeRelation.source_entity_id == nid)
.limit(limit // 2)
.all()
)
for rel in out_rels:
edges.append(_relation_to_dict(rel))
if rel.target_entity_id not in visited and len(nodes) < limit:
tgt = get_entity_by_id(db, rel.target_entity_id)
if tgt:
nodes[rel.target_entity_id] = _entity_to_dict(tgt)
visited.add(rel.target_entity_id)
next_frontier.add(rel.target_entity_id)
# 入边
in_rels = (
db.query(KnowledgeRelation)
.filter(KnowledgeRelation.target_entity_id == nid)
.limit(limit // 2)
.all()
)
for rel in in_rels:
edges.append(_relation_to_dict(rel))
if rel.source_entity_id not in visited and len(nodes) < limit:
src = get_entity_by_id(db, rel.source_entity_id)
if src:
nodes[rel.source_entity_id] = _entity_to_dict(src)
visited.add(rel.source_entity_id)
next_frontier.add(rel.source_entity_id)
frontier = next_frontier
if not frontier:
break
return {
"nodes": list(nodes.values()),
"edges": edges,
"center_id": entity_id,
}
def find_path(
db: Session,
source_id: str,
target_id: str,
max_depth: int = 4,
) -> Optional[List[Dict[str, Any]]]:
"""BFS 查找两个实体间的最短路径。"""
if source_id == target_id:
return [{"entity": _entity_to_dict(get_entity_by_id(db, source_id)), "relation": None}]
visited: Set[str] = {source_id}
# BFS queue: (current_id, path_so_far)
from collections import deque
queue: deque = deque()
queue.append((source_id, []))
while queue:
current_id, path = queue.popleft()
if len(path) >= max_depth:
continue
# 检查所有出边
out_rels = (
db.query(KnowledgeRelation)
.filter(KnowledgeRelation.source_entity_id == current_id)
.all()
)
for rel in out_rels:
if rel.target_entity_id in visited:
continue
new_path = path + [{
"from": _entity_to_dict(get_entity_by_id(db, current_id)),
"relation": _relation_to_dict(rel),
"to": _entity_to_dict(get_entity_by_id(db, rel.target_entity_id)),
}]
if rel.target_entity_id == target_id:
return new_path
visited.add(rel.target_entity_id)
queue.append((rel.target_entity_id, new_path))
# 检查所有入边
in_rels = (
db.query(KnowledgeRelation)
.filter(KnowledgeRelation.target_entity_id == current_id)
.all()
)
for rel in in_rels:
if rel.source_entity_id in visited:
continue
new_path = path + [{
"from": _entity_to_dict(get_entity_by_id(db, current_id)),
"relation": _relation_to_dict(rel),
"to": _entity_to_dict(get_entity_by_id(db, rel.source_entity_id)),
}]
if rel.source_entity_id == target_id:
return new_path
visited.add(rel.source_entity_id)
queue.append((rel.source_entity_id, new_path))
return None
# ═══════════════════════════════════════════════════════════════
# LLM 驱动的实体与关系提取
# ═══════════════════════════════════════════════════════════════
EXTRACTION_PROMPT = """你是一个知识图谱构建助手。请分析以下文本,提取其中的知识点实体和它们之间的关系。
请返回 JSON 格式(不要 markdown 包裹),包含:
1. entities: 提取的知识实体列表,每个实体包含:
- name: 实体名称(简洁准确)
- entity_type: 类型concept=概念, formula=公式, fact=事实, term=术语, task=任务, skill=技能)
- description: 简要描述1-2句
- confidence: 置信度low/medium/high
2. relations: 实体间的关系列表,每个关系包含:
- source: 源实体名称
- target: 目标实体名称
- relation_type: 关系类型prerequisite=前置知识, extends=扩展, contains=包含, related_to=相关, example_of=示例, applies_to=应用)
- description: 关系说明1句话
注意:
- 只提取文本中明确出现的知识点,不编造
- 关系应只在同段文本中有关联的实体间建立
- 如果文本中知识点不足,返回空数组即可
文本内容:
{text}"""
async def extract_from_text(
text: str,
scope_kind: str = "agent",
scope_id: str = "",
user_id: Optional[str] = None,
) -> Dict[str, Any]:
"""
使用 LLM 从文本中提取实体和关系,并写入知识图谱。
Returns:
{"entities": [...], "relations": [...], "entity_count": int, "relation_count": int}
"""
if not text or len(text.strip()) < 20:
return {"entities": [], "relations": [], "entity_count": 0, "relation_count": 0}
from openai import AsyncOpenAI
from app.core.config import settings
# 调用 LLM 提取
try:
api_key = settings.DEEPSEEK_API_KEY or settings.OPENAI_API_KEY or ""
base_url = settings.DEEPSEEK_BASE_URL or settings.OPENAI_BASE_URL or "https://api.deepseek.com"
if api_key == "your-openai-api-key":
api_key = settings.DEEPSEEK_API_KEY or ""
base_url = settings.DEEPSEEK_BASE_URL or "https://api.deepseek.com"
if not api_key:
logger.warning("知识图谱提取:未配置 API Key跳过")
return {"entities": [], "relations": [], "entity_count": 0, "relation_count": 0}
client = AsyncOpenAI(api_key=api_key, base_url=base_url)
resp = await client.chat.completions.create(
model="deepseek-v4-flash",
messages=[{"role": "user", "content": EXTRACTION_PROMPT.format(text=text[:4000])}],
temperature=0.2,
max_tokens=2048,
timeout=30,
)
raw = resp.choices[0].message.content or ""
# 解析 JSON
raw = raw.strip().removeprefix("```json").removesuffix("```").strip()
result = json.loads(raw)
except json.JSONDecodeError:
logger.warning("知识图谱提取LLM 返回非 JSON跳过")
return {"entities": [], "relations": [], "entity_count": 0, "relation_count": 0}
except Exception as e:
logger.warning("知识图谱提取失败: %s", e)
return {"entities": [], "relations": [], "entity_count": 0, "relation_count": 0}
extracted_entities = result.get("entities", [])
extracted_relations = result.get("relations", [])
if not extracted_entities:
return {"entities": [], "relations": [], "entity_count": 0, "relation_count": 0}
# 写入数据库
db: Optional[Session] = None
try:
db = SessionLocal()
# 批量添加实体
created = await add_entities_batch(
db, extracted_entities,
scope_kind=scope_kind, scope_id=scope_id, user_id=user_id,
)
# 构建 name->id 映射
entity_map: Dict[str, str] = {}
for e in created:
entity_map[e.name] = e.id
# 添加关系
rel_count = add_relations_from_map(
db, entity_map, extracted_relations,
scope_kind=scope_kind, scope_id=scope_id,
)
return {
"entities": [_entity_to_dict(e) for e in created],
"relations": extracted_relations[:rel_count],
"entity_count": len(created),
"relation_count": rel_count,
}
except Exception as e:
logger.error("写入知识图谱失败: %s", e)
if db:
db.rollback()
return {"entities": [], "relations": [], "entity_count": 0, "relation_count": 0}
finally:
if db:
db.close()
# ═══════════════════════════════════════════════════════════════
# 向量+图谱融合检索(核心 RAG 能力)
# ═══════════════════════════════════════════════════════════════
async def hybrid_search(
query: str,
scope_kind: str = "agent",
scope_id: str = "",
top_k: int = 5,
include_neighbors: bool = True,
) -> Dict[str, Any]:
"""
向量+图谱融合检索。
1. 用 query 生成 embedding在知识实体中做向量相似度搜索
2. 对 Top-K 实体展开图谱邻居
3. 返回融合后的结构化知识上下文
Returns:
{
"query": str,
"vector_matches": [...], # 向量检索命中的实体
"graph_expansion": {...}, # 图谱展开的邻居子图
"formatted_context": str, # 格式化后的文本上下文(可直接注入 LLM
}
"""
if not query or not query.strip():
return {"query": query, "vector_matches": [], "graph_expansion": {}, "formatted_context": ""}
db: Optional[Session] = None
try:
db = SessionLocal()
# Step 1: 向量检索
query_emb = await embedding_service.generate_embedding(query)
if not query_emb:
# 降级:关键词检索
entities = search_entities(db, keyword=query, scope_kind=scope_kind, scope_id=scope_id, limit=top_k * 3)
vector_matches = [_entity_to_dict(e) for e in entities[:top_k]]
else:
all_entities = (
db.query(KnowledgeEntity)
.filter(
KnowledgeEntity.scope_kind == scope_kind,
KnowledgeEntity.scope_id == scope_id,
KnowledgeEntity.embedding.isnot(None),
)
.limit(200)
.all()
)
entries: List[VectorEntry] = []
for e in all_entities:
emb = embedding_service.deserialize_embedding(e.embedding) if e.embedding else []
if emb:
entries.append({
"id": e.id,
"scope_kind": scope_kind,
"scope_id": scope_id,
"content_text": _build_entity_embedding_text(e.name, e.entity_type, e.description or ""),
"embedding": emb,
"metadata": {"entity_type": e.entity_type, "name": e.name, "entity_id": e.id},
})
matched = await embedding_service.similarity_search(query_emb, entries, top_k=top_k)
# 重新获取完整实体信息
matched_ids = [m["metadata"].get("entity_id", "") for m in matched]
vector_matches = []
for mid in matched_ids:
e = get_entity_by_id(db, mid)
if e:
score = next((m.get("score", 0) for m in matched if m["metadata"].get("entity_id") == mid), 0)
d = _entity_to_dict(e)
d["score"] = score
vector_matches.append(d)
# Step 2: 图谱展开(对 Top-3 实体取邻居)
graph_expansion: Dict[str, Any] = {"entities": [], "relations": []}
seen_entity_ids: Set[str] = set()
if include_neighbors and vector_matches:
for vm in vector_matches[:3]:
eid = vm["id"]
if eid in seen_entity_ids:
continue
seen_entity_ids.add(eid)
sub = get_neighbors(db, eid, direction="both", limit=5)
if sub["entity"] and sub["entity"] not in graph_expansion["entities"]:
graph_expansion["entities"].append(sub["entity"])
for nb in sub["neighbors"]:
if nb["entity"] not in graph_expansion["entities"]:
graph_expansion["entities"].append(nb["entity"])
rel_dict = nb["relation"]
if rel_dict not in graph_expansion["relations"]:
graph_expansion["relations"].append(rel_dict)
# Step 3: 格式化上下文
formatted = _format_hybrid_context(query, vector_matches, graph_expansion)
return {
"query": query,
"vector_matches": vector_matches,
"graph_expansion": graph_expansion,
"formatted_context": formatted,
}
except Exception as e:
logger.error("融合检索失败: %s", e)
return {"query": query, "vector_matches": [], "graph_expansion": {}, "formatted_context": ""}
finally:
if db:
db.close()
def _format_hybrid_context(
query: str,
vector_matches: List[Dict[str, Any]],
graph_expansion: Dict[str, Any],
) -> str:
"""格式化融合检索结果为 LLM 可读的文本块。"""
parts: List[str] = []
if vector_matches:
parts.append("## 相关知识实体(向量检索)")
for i, vm in enumerate(vector_matches, 1):
score_str = f" (匹配度: {vm.get('score', 1.0):.2f})" if vm.get('score', 1.0) < 1.0 else ""
parts.append(f"{i}. [{vm.get('entity_type', 'concept')}] **{vm.get('name', '')}**{score_str}")
if vm.get("description"):
parts.append(f" {vm['description'][:300]}")
if graph_expansion.get("entities"):
parts.append("\n## 关联知识点(图谱展开)")
for ent in graph_expansion["entities"]:
parts.append(f"- [{ent.get('entity_type', '')}] **{ent.get('name', '')}**")
if ent.get("description"):
parts.append(f" {ent['description'][:200]}")
if graph_expansion.get("relations"):
parts.append("\n## 知识关系")
for rel in graph_expansion["relations"]:
parts.append(f"- `{rel.get('relation_type', '')}`: {rel.get('description', '')}")
return "\n".join(parts) if parts else ""
# ═══════════════════════════════════════════════════════════════
# 学习路径推荐
# ═══════════════════════════════════════════════════════════════
def recommend_learning_path(
db: Session,
target_entity_ids: List[str],
scope_kind: str = "agent",
scope_id: str = "",
) -> Dict[str, Any]:
"""
基于知识图谱推荐学习路径。
对目标实体集合,分析前置关系,给出建议的学习顺序。
"""
all_entities: Dict[str, Dict[str, Any]] = {}
all_prereqs: List[Dict[str, Any]] = []
for eid in target_entity_ids:
entity = get_entity_by_id(db, eid)
if not entity:
continue
all_entities[eid] = _entity_to_dict(entity)
# 查找前置知识
prereqs = (
db.query(KnowledgeRelation)
.filter(
KnowledgeRelation.target_entity_id == eid,
KnowledgeRelation.relation_type == "prerequisite",
KnowledgeRelation.scope_kind == scope_kind,
KnowledgeRelation.scope_id == scope_id,
)
.all()
)
for pr in prereqs:
src = get_entity_by_id(db, pr.source_entity_id)
if src:
all_entities[pr.source_entity_id] = _entity_to_dict(src)
all_prereqs.append({
"prerequisite": _entity_to_dict(src),
"target": _entity_to_dict(entity),
"relation": _relation_to_dict(pr),
})
# 简单排序:按依赖数量(叶子在前,被依赖的在后)
def _dep_count(eid: str) -> int:
return sum(1 for p in all_prereqs if p["target"]["id"] == eid)
sorted_entities = sorted(all_entities.values(), key=lambda e: _dep_count(e["id"]))
return {
"entities": sorted_entities,
"prerequisites": all_prereqs,
"suggested_order": [e["name"] for e in sorted_entities],
"summary": f"建议按以下顺序学习: {''.join(e['name'] for e in sorted_entities)}" if sorted_entities else "暂无学习路径",
}
# ═══════════════════════════════════════════════════════════════
# 工具函数
# ═══════════════════════════════════════════════════════════════
def _entity_to_dict(entity: KnowledgeEntity) -> Dict[str, Any]:
return {
"id": entity.id,
"name": entity.name,
"entity_type": entity.entity_type,
"description": entity.description,
"confidence": entity.confidence,
"source": entity.source,
"metadata": entity.metadata_ or {},
"created_at": entity.created_at.isoformat() if entity.created_at else None,
}
def _relation_to_dict(rel: KnowledgeRelation) -> Dict[str, Any]:
return {
"id": rel.id,
"source_entity_id": rel.source_entity_id,
"target_entity_id": rel.target_entity_id,
"relation_type": rel.relation_type,
"description": rel.description,
"weight": float(rel.weight) if rel.weight else 1.0,
}

View File

@@ -0,0 +1,105 @@
"""人参果飞书应用 API 服务 — 通过人参果应用发送消息到用户"""
from __future__ import annotations
import json
import logging
import time
from typing import Optional
import httpx
from app.core.config import settings
logger = logging.getLogger(__name__)
_token_cache: dict = {"token": None, "expires_at": 0}
def _get_tenant_access_token() -> Optional[str]:
now = time.time()
if _token_cache["token"] and now < _token_cache["expires_at"] - 300:
return _token_cache["token"]
app_id = settings.RENSHENGUO_APP_ID
app_secret = settings.RENSHENGUO_APP_SECRET
if not app_id or not app_secret:
logger.warning("人参果应用未配置RENSHENGUO_APP_ID / RENSHENGUO_APP_SECRET")
return None
try:
with httpx.Client(timeout=10) as client:
resp = client.post(
"https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal",
json={"app_id": app_id, "app_secret": app_secret},
)
result = resp.json()
if resp.is_success and result.get("code") == 0:
token = result["tenant_access_token"]
expire = result.get("expire", 7200)
_token_cache["token"] = token
_token_cache["expires_at"] = now + expire
logger.info("人参果 tenant_access_token 获取成功")
return token
else:
logger.warning("人参果 token 获取失败: %s", result)
return None
except Exception as e:
logger.warning("人参果 token 获取异常: %s", e)
return None
def send_message_to_user(
open_id: str, title: str, content: str,
status: str = "info", detail_link: Optional[str] = None,
) -> bool:
token = _get_tenant_access_token()
if not token:
return False
color_map = {"success": "green", "failed": "red", "info": "blue"}
color = color_map.get(status, "blue")
elements = [{"tag": "markdown", "content": content}]
if detail_link:
elements.append({
"tag": "action",
"actions": [{"tag": "button", "text": {"tag": "plain_text", "content": "查看详情"}, "url": detail_link, "type": "default"}],
})
card = {
"config": {"wide_screen_mode": True},
"header": {"title": {"tag": "plain_text", "content": title}, "template": color},
"elements": elements,
}
try:
with httpx.Client(timeout=10) as client:
resp = client.post(
"https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=open_id",
headers={"Authorization": f"Bearer {token}"},
json={"receive_id": open_id, "msg_type": "interactive", "content": json.dumps(card, ensure_ascii=False)},
)
result = resp.json()
if resp.is_success and result.get("code") == 0:
logger.info("人参果消息发送成功: open_id=%s title=%s", open_id[:20], title)
return True
else:
logger.warning("人参果消息发送失败: code=%s msg=%s", result.get("code"), result.get("msg"))
return False
except Exception as e:
logger.warning("人参果消息发送异常: %s", e)
return False
def send_plain_text(open_id: str, text: str) -> bool:
token = _get_tenant_access_token()
if not token:
return False
try:
with httpx.Client(timeout=10) as client:
resp = client.post(
"https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=open_id",
headers={"Authorization": f"Bearer {token}"},
json={"receive_id": open_id, "msg_type": "text", "content": json.dumps({"text": text}, ensure_ascii=False)},
)
result = resp.json()
return resp.is_success and result.get("code") == 0
except Exception as e:
logger.warning("人参果文本消息发送异常: %s", e)
return False

View File

@@ -0,0 +1,304 @@
"""人参果飞书长连接 — 固定路由到 AI学习助手 Agent知识图谱+RAG理想版"""
from __future__ import annotations
import asyncio
import json
import logging
from collections import deque
from typing import Optional
from app.core.config import settings
logger = logging.getLogger(__name__)
_processed_msg_ids: deque[str] = deque(maxlen=20)
def _get_message_id(data) -> Optional[str]:
try:
ev = data.event
msg = getattr(ev, "message", None)
if msg:
return getattr(msg, "message_id", None)
except Exception:
return None
return None
def _get_message_text(data) -> Optional[str]:
try:
ev = data.event
msg = getattr(ev, "message", None)
if not msg:
return None
content_str = getattr(msg, "content", None)
msg_type = getattr(msg, "message_type", "")
if not content_str:
return None
if msg_type == "text":
parsed = json.loads(content_str)
return parsed.get("text", "")
return None
except Exception as e:
logger.warning("解析人参果消息内容失败: %s", e)
return None
def _get_sender_open_id(data) -> Optional[str]:
try:
ev = data.event
sender = getattr(ev, "sender", None)
if not sender:
return None
sender_id = getattr(sender, "sender_id", None)
if not sender_id:
return None
return getattr(sender_id, "open_id", None)
except Exception:
return None
def _get_sender_union_id(data) -> Optional[str]:
try:
ev = data.event
sender = getattr(ev, "sender", None)
if not sender:
return None
sender_id = getattr(sender, "sender_id", None)
if not sender_id:
return None
return getattr(sender_id, "union_id", None)
except Exception:
return None
def _get_chat_type(data) -> str:
try:
ev = data.event
msg = getattr(ev, "message", None)
return getattr(msg, "chat_type", "") if msg else ""
except Exception:
return ""
def _reply_to_feishu(open_id: str, text: str):
try:
from app.services.renshenguo_app_service import send_plain_text
send_plain_text(open_id, text)
except Exception as e:
logger.warning("人参果回复消息失败: %s", e)
def _reply_card(open_id: str, title: str, content: str, status: str = "info"):
try:
from app.services.renshenguo_app_service import send_message_to_user
send_message_to_user(open_id, title, content, status=status)
except Exception as e:
logger.warning("人参果回复卡片失败: %s", e)
def _make_llm_logger(db, agent_id: Optional[str] = None, user_id: Optional[str] = None):
def _log(metrics: dict):
try:
from app.models.agent_llm_log import AgentLLMLog
log = AgentLLMLog(
agent_id=agent_id, session_id=metrics.get("session_id"),
user_id=user_id, model=metrics.get("model", ""),
provider=metrics.get("provider"),
prompt_tokens=metrics.get("prompt_tokens", 0),
completion_tokens=metrics.get("completion_tokens", 0),
total_tokens=metrics.get("total_tokens", 0),
latency_ms=metrics.get("latency_ms", 0),
iteration_number=metrics.get("iteration_number", 0),
step_type=metrics.get("step_type"),
tool_name=metrics.get("tool_name"),
status=metrics.get("status", "success"),
error_message=metrics.get("error_message"),
)
db.add(log)
db.commit()
except Exception as e:
logger.warning("写入 AgentLLMLog 失败: %s", e)
return _log
async def _handle_message_async(data):
open_id = _get_sender_open_id(data)
union_id = _get_sender_union_id(data)
chat_type = _get_chat_type(data)
text = _get_message_text(data)
if not open_id or chat_type != "p2p":
return
logger.info("人参果收到消息: open_id=%s text=%s", open_id[:20], text[:50] if text else "(空)")
if not text:
return
from sqlalchemy.orm import Session
from app.core.database import SessionLocal
from app.models.agent import Agent
from app.services.feishu_open_id_service import resolve_user_and_save
db: Optional[Session] = None
try:
db = SessionLocal()
# 自动保存/关联此应用的 open_id跨应用识别
resolved_uid = resolve_user_and_save(
db, app_id=settings.RENSHENGUO_APP_ID or "",
open_id=open_id, union_id=union_id,
)
agent_id = settings.RENSHENGUO_AGENT_ID
if not agent_id:
_reply_to_feishu(open_id, "人参果尚未配置,请联系管理员。")
return
agent = db.query(Agent).filter(Agent.id == agent_id).first()
if not agent:
_reply_to_feishu(open_id, "人参果 Agent 已不存在,请联系管理员。")
return
_reply_to_feishu(open_id, "正在思考,请稍候...")
from app.agent_runtime import AgentRuntime, AgentConfig, AgentLLMConfig, AgentToolConfig, AgentMemoryConfig
wc = agent.workflow_config or {}
nodes = wc.get("nodes", [])
system_prompt = agent.description or ""
model = "deepseek-v4-flash"
provider = "deepseek"
temperature = 0.7
max_iterations = 15
tools_whitelist = []
for n in nodes:
if n.get("type") not in ("agent", "llm", "template"):
continue
cfg = n.get("data", {}) if isinstance(n, dict) else getattr(n, "data", {})
system_prompt = cfg.get("system_prompt", "") or system_prompt
model = cfg.get("model", model)
provider = cfg.get("provider", provider)
temperature = float(cfg.get("temperature", temperature))
max_iterations = int(cfg.get("max_iterations", max_iterations))
tools_whitelist = cfg.get("tools", tools_whitelist)
break
config = AgentConfig(
name=agent.name or "人参果",
system_prompt=system_prompt + (
f"\n\n## 系统信息\n"
f"你的 Agent ID 是: {agent.id}\n"
f"在调用 schedule_list、schedule_delete 等工具时,使用此 ID 作为 agent_id 参数。"
),
llm=AgentLLMConfig(
model=model, provider=provider,
temperature=temperature, max_iterations=max_iterations,
),
tools=AgentToolConfig(include_tools=tools_whitelist),
memory=AgentMemoryConfig(
max_history_messages=int(cfg.get("memory_max_history", 40)),
vector_memory_top_k=int(cfg.get("memory_vector_top_k", 10)),
persist_to_db=bool(cfg.get("memory_persist", True)),
vector_memory_enabled=bool(cfg.get("memory_vector_enabled", True)),
learning_enabled=bool(cfg.get("memory_learning", True)),
),
user_id=resolved_uid,
memory_scope_id=str(agent.id),
)
on_llm_call = _make_llm_logger(db, agent_id=str(agent.id))
runtime = AgentRuntime(config=config, on_llm_call=on_llm_call)
result = await runtime.run(text)
if result.content:
_reply_card(open_id, f"{agent.name}", result.content.strip(), status="success")
else:
_reply_to_feishu(open_id, "Agent 未返回有效回复,请重试。")
logger.info(
"人参果 Agent 回复完成: open_id=%s agent=%s iterations=%d tools=%d",
open_id[:20], agent.name, result.iterations_used, result.tool_calls_made,
)
except Exception as e:
logger.error("人参果消息处理失败: %s", e)
try:
_reply_to_feishu(open_id, f"处理失败: {e!s}")
except Exception:
pass
finally:
if db:
db.close()
def _handle_message_internal(data):
msg_id = _get_message_id(data)
if msg_id:
if msg_id in _processed_msg_ids:
return
_processed_msg_ids.append(msg_id)
open_id = _get_sender_open_id(data)
chat_type = _get_chat_type(data)
text = _get_message_text(data)
if not open_id or chat_type != "p2p" or not text:
return
try:
loop = asyncio.get_event_loop()
if loop.is_running():
asyncio.ensure_future(_handle_message_async(data))
else:
loop.run_until_complete(_handle_message_async(data))
except Exception as e:
logger.error("人参果创建消息处理任务失败: %s", e)
try:
_reply_to_feishu(open_id, f"处理失败: {e!s}")
except Exception:
pass
def _build_event_handler():
from lark_oapi.event.dispatcher_handler import EventDispatcherHandler
def on_message_receive(data):
_handle_message_internal(data)
builder = EventDispatcherHandler.builder(encrypt_key="", verification_token="")
builder.register_p2_im_message_receive_v1(on_message_receive)
return builder.build()
async def start_ws_client():
if not settings.RENSHENGUO_APP_ID or not settings.RENSHENGUO_APP_SECRET:
logger.warning("人参果应用未配置,跳过人参果长连接启动")
return
from lark_oapi.ws import Client as WSClient
handler = _build_event_handler()
client = WSClient(
app_id=settings.RENSHENGUO_APP_ID,
app_secret=settings.RENSHENGUO_APP_SECRET,
event_handler=handler,
auto_reconnect=True,
)
logger.info("人参果长连接客户端启动中...")
while True:
try:
await client._connect()
logger.info("人参果长连接已建立")
asyncio.ensure_future(client._ping_loop())
while True:
await asyncio.sleep(3600)
except asyncio.CancelledError:
break
except Exception as e:
logger.warning("人参果长连接断开3秒后重连: %s", e)
await asyncio.sleep(3)

View File

@@ -112,6 +112,17 @@ def build_workflow_for_template(template_id: str, parameters: Optional[Dict[str,
prompt_fn: PromptBuilder = meta["prompt_builder"]
prompt = prompt_fn(parameters)
# 使用模板自定义的 workflow builder如果有
workflow_builder = meta.get("workflow_builder")
if workflow_builder:
return workflow_builder(
prompt,
temperature=temperature,
enable_tools=enable_tools,
tools=tools,
)
return _build_minimal_workflow(
prompt,
temperature=temperature,
@@ -120,6 +131,143 @@ def build_workflow_for_template(template_id: str, parameters: Optional[Dict[str,
)
def _default_prompt_learning(params: Dict[str, Any]) -> str:
extra = (params.get("extra_instructions") or "").strip()
subject = params.get("subject") or "通用"
level = params.get("level") or "中级"
base = f"""# 角色:智能学习助手(知识图谱 + RAG 增强版)
你是专为深度学习场景设计的 AI 学习助手,具备**知识图谱构建**、**向量语义检索**和**永久记忆**能力。
## 核心架构
你的知识系统由三层组成:
1. **知识图谱 (Knowledge Graph)**:结构化存储知识点实体及其前置/扩展/包含/示例关系,构建学科知识网络
2. **向量记忆 (Vector Memory)**:语义检索历史对话和相关知识,找到最相似的学习内容
3. **长期记忆 (Persistent Memory)**:跨会话保存用户画像、学习进度、薄弱环节
## 当前学习配置
- 学科领域:{subject}
- 难度级别:{level}
- 用户输入将包含学习问题、材料或请求
## 工作流程(每次对话必须遵循)
### 阶段 1理解与分析
1. 理解用户的学习意图(提问/复习/练习/总结/规划)
2. 用 `knowledge_graph_search` 检索相关知识图谱实体
3. 如果用户提供了新的学习材料/知识点,用 `knowledge_graph_add` 自动提取并存储
### 阶段 2知识检索与融合
4. 结合图谱检索结果和历史向量记忆,构建完整的知识上下文
5. 用 `entity_search` 查找特定概念的前置知识和扩展内容
6. 如果需要学习路径建议,用 `learning_path` 分析依赖关系
### 阶段 3生成与交付
7. 基于融合后的知识上下文生成高质量回答
8. 回答应包含:
- 核心概念解释(关联知识图谱中的实体)
- 前置知识提醒(如果有依赖关系)
- 实例或练习题(如适用)
- 扩展阅读建议(关联的扩展知识点)
9. 用 `self_review` 自检回答质量
### 阶段 4巩固与记忆
10. 将本轮对话中的重要知识点持久化到长期记忆
11. 更新用户画像(掌握程度、薄弱环节、学习偏好)
## 知识图谱工具使用指南
| 工具 | 用途 | 何时使用 |
|------|------|---------|
| `knowledge_graph_search` | 向量+图谱混合检索 | 每次回答学习问题前 |
| `knowledge_graph_add` | 从文本提取实体和关系 | 用户分享学习材料/新知识点时 |
| `entity_search` | 关键词搜索实体 | 查找特定概念详情时 |
| `learning_path` | 推荐学习路径 | 用户询问学习顺序/计划时 |
## 回答风格
- 使用 Markdown 格式,层次分明
- 关键概念用 **粗体** 标记
- 公式用代码块或 LaTeX 表达
- 每个回答末尾附上 "📚 相关知识点" 列表(来自图谱检索结果)
- 必要时用 `task_plan` 为用户制定学习计划
## 记忆与个性化
- 记住用户的学习进度和薄弱环节
- 根据用户级别({level})调整解释深度
- 对反复出错的知识点主动提醒和强化"""
if extra:
return f"{base}\n\n【额外说明】\n{extra}"
return base
def _build_learning_workflow(
prompt: str,
*,
temperature: float = 0.7,
enable_tools: bool = True,
tools: Optional[List[str]] = None,
) -> Dict[str, Any]:
"""为学习助手构建更丰富的工作流含开始→LLM→结束LLM配置KG+RAG工具"""
tools = tools or []
return {
"nodes": [
{
"id": "start-1",
"type": "start",
"position": {"x": 80, "y": 200},
"data": {"label": "学习任务开始"},
},
{
"id": "llm-learning",
"type": "llm",
"position": {"x": 350, "y": 200},
"data": {
"label": "智能学习助手 (KG+RAG)",
"prompt": prompt,
"temperature": float(temperature),
"enable_tools": enable_tools,
"tools": tools,
"selected_tools": tools,
"model": "deepseek-chat",
"provider": "deepseek",
"max_iterations": 20,
"memory": True,
"memory_max_history": 30,
"memory_vector_enabled": True,
"memory_vector_top_k": 8,
"memory_persist": True,
"memory_learning": True,
},
},
{
"id": "end-1",
"type": "end",
"position": {"x": 620, "y": 200},
"data": {"label": "学习完成"},
},
],
"edges": [
{
"id": "e_start_learning",
"source": "start-1",
"target": "llm-learning",
"sourceHandle": "right",
"targetHandle": "left",
},
{
"id": "e_learning_end",
"source": "llm-learning",
"target": "end-1",
"sourceHandle": "right",
"targetHandle": "left",
},
],
}
SCENE_TEMPLATE_REGISTRY: Dict[str, Dict[str, Any]] = {
"template_customer_service": {
"title": "客服场景",
@@ -145,6 +293,29 @@ SCENE_TEMPLATE_REGISTRY: Dict[str, Dict[str, Any]] = {
"default_tools": [],
"prompt_builder": _default_prompt_ops,
},
"template_learning_assistant": {
"title": "智能学习助手 (KG+RAG)",
"description": "知识图谱 + RAG 增强学习助手:实体抽取、关系图谱、向量检索、永久记忆,适合有规模要求的学习场景。",
"category": "education",
"default_temperature": 0.7,
"default_tools": [
"knowledge_graph_search",
"knowledge_graph_add",
"entity_search",
"learning_path",
"file_read",
"file_write",
"text_analyze",
"web_search",
"task_plan",
"self_review",
"math_calculate",
"json_process",
"datetime",
],
"prompt_builder": _default_prompt_learning,
"workflow_builder": _build_learning_workflow,
},
}
@@ -152,6 +323,12 @@ def list_scene_template_meta() -> List[Dict[str, Any]]:
"""供 GET 接口返回(不含 prompt_builder"""
out: List[Dict[str, Any]] = []
for tid, meta in SCENE_TEMPLATE_REGISTRY.items():
hints = ["temperature", "enable_tools", "tools", "extra_instructions"]
if tid == "template_dev_codegen":
hints.append("preferred_language")
if tid == "template_learning_assistant":
hints.extend(["subject学科领域", "level难度级别初级/中级/高级)"])
out.append(
{
"id": tid,
@@ -159,13 +336,7 @@ def list_scene_template_meta() -> List[Dict[str, Any]]:
"description": meta["description"],
"category": meta.get("category"),
"default_temperature": meta.get("default_temperature"),
"parameter_hints": [
"temperature",
"enable_tools",
"tools",
"extra_instructions",
"preferred_language仅研发模板",
],
"parameter_hints": hints,
}
)
return out

View File

@@ -5525,7 +5525,6 @@ class WorkflowEngine:
# 调用 LLM 评判
try:
from app.services.llm_service import llm_service
judge_prompt = (
"你是严格的内容质量评审专家。请根据以下标准对内容进行评分。\n\n"
f"【评判标准】\n{criteria}\n\n"
@@ -5700,9 +5699,13 @@ class WorkflowEngine:
can_execute = True
incoming_edges = [e for e in active_edges if e["target"] == node_id]
if not incoming_edges:
if node_id not in [n["id"] for n in self.nodes.values() if n.get("type") == "start"]:
logger.debug(f"[rjb] 节点 {node_id} 没有入边,跳过执行")
continue
# 节点无入边:必须是 start 类型,或整个工作流中没有 start 节点才可作为起点
is_start_node = node_id in [n["id"] for n in self.nodes.values() if n.get("type") == "start"]
if not is_start_node:
has_any_start = any(n.get("type") == "start" for n in self.nodes.values())
if has_any_start:
logger.debug(f"[rjb] 节点 {node_id} 没有入边,跳过执行")
continue
else:
for edge in incoming_edges:
src = edge["source"]