mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 01:51:57 +00:00
8d9a712eae
- 新增标准业务实体(CanonicalEntity)模型、确定性跨源编码翻译、后台候选实体扫描与文档驱动实体智能助手 - 新增指标本体图谱(factor / reconciles_with / upstream_driver)、DAG 防环校验以及 ontology_neighbors / source_health 内置工具 - 新增跨源对账(analysis_reconcile)与滞后共变(analysis_cochange)分析算子,打通 [[h:H1]] 数据源健康证据链、报告导出与本体消融评测 - 数据目录新增「标准业务实体」视图与候选审阅工作台,口径页集成指标本体拓扑面板与端到端浏览器检查
943 lines
44 KiB
Python
943 lines
44 KiB
Python
"""实体助手(E2):在标准业务实体页与使用者流式对话,跨数据源与业务文档对齐维护 CanonicalEntity。
|
||
|
||
核心规矩:
|
||
1. 只提结构化提案(propose_entity_changes),绝不静默直接写库;使用者在界面看过后点击「保存修改」才经
|
||
apply_entity_patch(mode="confirmed") 落库。
|
||
2. 跨源只读探查(run_query)仅对已开启数据剖析的数据源放行,严格经 guard.check(readonly=True) 与遮罩列脱敏
|
||
(mask_query_result);遮罩列只给列名,不给注释或码值。
|
||
3. 支持直接读取本次对话挂载的上传附件或知识库文档片段(read_document),并要求在提案 reason 中区分「使用者说:……」、
|
||
「文档《…》写着:……」与「数据探查显示:……」,同时主动向使用者汇报文档码表与实表取值的差异。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import contextlib
|
||
import json
|
||
import logging
|
||
from collections.abc import AsyncIterator, Iterable, Mapping, Sequence
|
||
from dataclasses import dataclass, field
|
||
from datetime import date, datetime
|
||
from decimal import Decimal
|
||
from typing import Any
|
||
|
||
from sqlalchemy import select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.data import catalog, catalog_assistant, catalog_profile, entities, entity_docs, entity_patch, introspect, table_versions
|
||
from app.data.draft_model import DRAFT_MODEL_PLACE
|
||
from app.data.engine import SnapshotTampered, masked_columns
|
||
from app.db.base import SessionLocal
|
||
from app.db.models import DataSource, Workflow, WorkflowVersion
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
MAX_MESSAGES = 40
|
||
MESSAGE_MAX = 4000
|
||
MAX_ROUNDS = 8
|
||
MAX_QUERIES = 6
|
||
QUERY_ROWS = 50
|
||
SHOWN_ROWS = 10
|
||
CELL_MAX = 300
|
||
TOOL_CHARS = 12_000
|
||
RETRY_DELAYS_S = (3.0, 10.0)
|
||
HEARTBEAT_S = 10.0
|
||
|
||
INSPECT_TOOL = "inspect_sources"
|
||
READ_DOC_TOOL = "read_document"
|
||
QUERY_TOOL = "run_query"
|
||
PROPOSE_TOOL = "propose_entity_changes"
|
||
|
||
CODE_MODEL = "assistant_model_error"
|
||
CODE_TRUNCATED = "assistant_truncated"
|
||
CODE_TIMEOUT = "assistant_timeout"
|
||
CODE_ROUNDS = "assistant_rounds"
|
||
CODE_FAILED = "assistant_failed"
|
||
|
||
# ---- 面向界面展示的错误文案
|
||
ERROR_MODEL = (
|
||
"调用模型「{model}」失败:{reason}。请稍后重试;如持续失败,请在{place}中换一个模型,"
|
||
"或到「设置 → 模型接入」测试该模型"
|
||
)
|
||
ERROR_TRUNCATED = "{cut},回答没有写完。可以把问题拆小一点再问,或在{place}中换一个模型"
|
||
ERROR_TIMEOUT = "模型 {seconds} 秒内没有回答完,已停止这一轮。可以把问题拆小一点再问,或在{place}中换一个模型"
|
||
ERROR_ROUNDS = "这一轮已经调用了 {n} 次模型仍没有答完,已停止。可以换个问法,或者分几次问"
|
||
ERROR_FAILED = "实体助手出错:{line}"
|
||
LAST_NOT_USER = "对话的最后一条应当是使用者的话,请重新发送"
|
||
QUERY_TAMPERED = "数据源「{name}」的{error}。请联系管理员检查数据目录"
|
||
|
||
# ---- 面向模型的提示词(命名含 _SYSTEM / _PROMPT)
|
||
ENTITY_ASSISTANT_SYSTEM = """你是 AgentLab 的「实体助手」,协助搭建者围绕当前选定范围的数据源与业务文档建立、完善「标准业务实体」(CanonicalEntity):
|
||
包括实体的标准标识(key)、中文名称(label)、常用叫法(aliases)、物理表列绑定(bindings)、标准成员编码映射(members.source_codes)与口径入口参数映射(caliber_params)。
|
||
|
||
核心规矩:
|
||
1. 你不能修改业务数据库数据,也不直接写入标准业务实体:一切新增或修改都通过 propose_entity_changes 工具整理成结构化提案,由使用者在界面审阅后点击保存才生效。
|
||
2. 严格限定在「当前分析范围的数据源」内工作:只探查、查询和绑定范围内的数据源,绝不提及、猜测或扩展到未纳入范围的其他数据源。若当前范围只有 1 个数据源,聚焦盘点该数据源内的核心业务实体、多表共享主外键、码值字典与口径入参;若选了多个数据源,则重点对齐这些选定数据源之间的同义实体与编码映射。
|
||
3. 表名、列名、数据源名、工作流 ID、口径参数名必须先通过 inspect_sources 核实,严禁猜测不存在的表名或列名。
|
||
4. 若挂载了业务文档或编码对照表,优先用 read_document 阅读原文定义或码值映射,并结合 inspect_sources / run_query 去真实数据源交叉核验:
|
||
- 如果文档里提到的编码与数据库实表抽样不一致(例如实表多出文档未登记的新编码,或文档编码在实表未出现),必须在回答正文中明确告知使用者,询问是否一并纳入。
|
||
- 如果拿不准两套编码如何对应,先简短具体地向使用者提问,不要硬猜。
|
||
5. 提案中每条操作的 reason 字段必须如实写明依据来源:
|
||
- 来自本次对话使用者亲口说明的,写「使用者说:……」;
|
||
- 来自挂载文档或知识库文档的,写「文档《文档名》写着:……」;
|
||
- 来自表目录或只读 SQL 核查的,写「数据探查显示:……」。
|
||
6. 标着「已确认」或「已验证」的既有绑定,除非使用者明确要求调整,否则不要擅自改动或删除;标着「已驳回」的绑定使用者曾明确拒绝过,不要重复提议。
|
||
7. 标着「遮罩列」的敏感字段禁止查询明细取值,也禁止为其生成成员编码映射(members.source_codes)。
|
||
8. 回答用简短直接的中文。
|
||
|
||
propose_entity_changes 的 operations 数组支持以下 5 种增量操作:
|
||
- upsert_entity: {"op": "upsert_entity", "entity_key": "小写英文下划线标识", "label": "中文名称", "description": "业务粒度说明", "aliases": ["常用叫法"], "reason": "依据"}
|
||
- upsert_binding: {"op": "upsert_binding", "entity_key": "实体标识", "entity_label": "中文名(新建时必填)", "source_name": "数据源名", "table_name": "物理表名", "column_name": "物理列名", "note": "备注", "reason": "依据"}
|
||
- remove_binding: {"op": "remove_binding", "entity_key": "实体标识", "source_name": "数据源名", "table_name": "物理表名", "column_name": "物理列名", "reason": "依据"}
|
||
- upsert_member: {"op": "upsert_member", "entity_key": "实体标识", "entity_label": "中文名", "value": "标准值", "label": "标准展示名", "aliases": ["成员别名"], "source_codes": {"数据源名": "该源底层编码"}, "reason": "依据"}
|
||
- upsert_caliber_param: {"op": "upsert_caliber_param", "entity_key": "实体标识", "entity_label": "中文名", "workflow_id": "工作流ID", "card_id": "可选口径卡节点ID", "param_name": "入口参数名", "note": "备注", "reason": "依据"}
|
||
"""
|
||
|
||
ENTITY_QUERY_PROMPT = (
|
||
"跨源只读查询:可对开启了数据剖析的数据源调用 run_query 执行只读 SQL(如 SELECT DISTINCT 核对编码取值与行数分布),"
|
||
"单次最多返回 {rows} 行,本轮最多执行 {limit} 次。可查询的数据源:{sources}。"
|
||
)
|
||
ENTITY_NO_QUERY_PROMPT = "当前范围内没有开启数据剖析的数据源,不能执行 run_query 查实表数据,请根据表结构、数据目录与业务文档推断对齐。"
|
||
ENTITY_OPENING_PROMPT = (
|
||
"(使用者刚打开实体助手,尚未发言。)请结合当前分析范围的数据源、既有实体与挂载文档概况,用两三句话指出当前范围内最值得优先建立或补齐的业务实体,并提出一两个具体问题;先不要调用 propose_entity_changes。"
|
||
)
|
||
ENTITY_OPENED_PROMPT = "(使用者打开了实体助手。)"
|
||
|
||
ENTITY_TOOL_PROMPT = {
|
||
"query_disabled_source": "数据源「{source}」未开启数据剖析或不存在,无法执行只读查询。可查询的数据源:{allowed}",
|
||
"query_capped": "这一轮的只读查询次数已达上限({n} 次),请根据已有信息回答或提出提案。",
|
||
"empty_sql": "请填写要执行的只读 SQL 语句。",
|
||
"query_failed": "查询未成功:{error}",
|
||
"no_ops": "operations 为空,请提供至少一条修改操作。",
|
||
"proposed": "已通过核验并向界面提交 {n} 项实体变更提案:{items}",
|
||
"nothing": "没有产生新的有效变更(可能与当前状态完全相同,或全部未通过核验)。",
|
||
"dropped": "以下项未通过平台核验,请核对表列名或参数后修正:{reasons}",
|
||
"no_docs": "当前对话未挂载任何业务文档或对照表附件。",
|
||
"unknown_tool": "未知工具「{name}」,可用工具:{tools}",
|
||
"last_round": "注意:下一轮是最后一次模型调用,请直接用中文向使用者总结结论,不要再调用工具。",
|
||
}
|
||
|
||
INSPECT_TOOL_PROMPT: dict[str, Any] = {
|
||
"type": "function",
|
||
"function": {
|
||
"name": INSPECT_TOOL,
|
||
"description": "在已启用的数据源表结构、数据目录(列中文名、含义、码值)及已发布口径入口参数中检索与业务实体相关的表列和入参。",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"keyword": {
|
||
"type": "string",
|
||
"description": "检索关键词(如「产线」「line」「仓库」「region」);留空则返回各数据源表概览与口径入参清单。",
|
||
},
|
||
"source_name": {
|
||
"type": "string",
|
||
"description": "可选:限定只查看某一个数据源名称。",
|
||
},
|
||
},
|
||
},
|
||
},
|
||
}
|
||
|
||
READ_DOC_TOOL_PROMPT: dict[str, Any] = {
|
||
"type": "function",
|
||
"function": {
|
||
"name": READ_DOC_TOOL,
|
||
"description": "读取本次对话挂载的业务文档、数据字典或编码对照表片段。",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"query": {
|
||
"type": "string",
|
||
"description": "按关键词检索相关段落或表格行;留空则按顺序读取分段。",
|
||
},
|
||
"doc_ref": {
|
||
"type": "string",
|
||
"description": "可选:限定某一份文档的 ID 或标题关键词。",
|
||
},
|
||
"start_chunk": {
|
||
"type": "integer",
|
||
"description": "顺序读取时的起始段号(从 0 开始)。",
|
||
},
|
||
"limit": {
|
||
"type": "integer",
|
||
"description": "最多返回几段(默认 6,最多 15)。",
|
||
},
|
||
},
|
||
},
|
||
},
|
||
}
|
||
|
||
QUERY_TOOL_PROMPT: dict[str, Any] = {
|
||
"type": "function",
|
||
"function": {
|
||
"name": QUERY_TOOL,
|
||
"description": "在指定数据源上执行一条只读 SQL(如 SELECT DISTINCT code, name FROM ... LIMIT 50),用于核对跨源成员编码与分布。",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"source_name": {"type": "string", "description": "要查询的数据源名称"},
|
||
"sql": {"type": "string", "description": "只读 SELECT 查询语句"},
|
||
},
|
||
"required": ["source_name", "sql"],
|
||
},
|
||
},
|
||
}
|
||
|
||
PROPOSE_TOOL_PROMPT: dict[str, Any] = {
|
||
"type": "function",
|
||
"function": {
|
||
"name": PROPOSE_TOOL,
|
||
"description": "提交一批标准业务实体的新建或修改提案(经平台核验后展示在界面待保存区,不直接写库)。",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"operations": {
|
||
"type": "array",
|
||
"description": "增量操作数组(upsert_entity / upsert_binding / remove_binding / upsert_member / upsert_caliber_param)",
|
||
"items": {"type": "object"},
|
||
},
|
||
},
|
||
"required": ["operations"],
|
||
},
|
||
},
|
||
}
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class _SourceEntry:
|
||
row: DataSource
|
||
resolved: Any
|
||
kind: str
|
||
masked: set[str]
|
||
settings: catalog_profile.ProfileSettings
|
||
can_query: bool
|
||
catalog_notes: dict[str, Any]
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class EntityAssistantContext:
|
||
sources: dict[str, _SourceEntry]
|
||
workflows: list[dict[str, Any]]
|
||
doc_bundles: list[entity_docs.EntityDocBundle]
|
||
pending_ops: list[dict[str, Any]] = field(default_factory=list)
|
||
system: str = ""
|
||
|
||
@property
|
||
def queryable_sources(self) -> list[str]:
|
||
return [entry.row.name for entry in self.sources.values() if entry.can_query]
|
||
|
||
def tools(self) -> list[dict[str, Any]]:
|
||
items = [INSPECT_TOOL_PROMPT]
|
||
if self.doc_bundles:
|
||
items.append(READ_DOC_TOOL_PROMPT)
|
||
if self.queryable_sources:
|
||
items.append(QUERY_TOOL_PROMPT)
|
||
items.append(PROPOSE_TOOL_PROMPT)
|
||
return items
|
||
|
||
|
||
resolve_model = catalog_assistant.resolve_model
|
||
|
||
|
||
def conversation_problem(messages: Iterable[Mapping[str, Any]]) -> str | None:
|
||
said = [m for m in messages if str(m.get("content") or "").strip()]
|
||
if any(m.get("role") == "user" for m in said) and said[-1].get("role") != "user":
|
||
return LAST_NOT_USER
|
||
return None
|
||
|
||
|
||
def _history(messages: Iterable[Mapping[str, Any]]) -> list[Any]:
|
||
from langchain_core.messages import AIMessage, HumanMessage
|
||
|
||
merged: list[tuple[str, str]] = []
|
||
for m in messages:
|
||
text = str(m.get("content") or "").strip()
|
||
if not text:
|
||
continue
|
||
role = "user" if m.get("role") == "user" else "assistant"
|
||
if merged and merged[-1][0] == role:
|
||
merged[-1] = (role, f"{merged[-1][1]}\n\n{text}")
|
||
else:
|
||
merged.append((role, text))
|
||
out: list[Any] = []
|
||
if merged and merged[0][0] == "assistant":
|
||
out.append(HumanMessage(content=ENTITY_OPENED_PROMPT))
|
||
for role, text in merged:
|
||
out.append(HumanMessage(content=text) if role == "user" else AIMessage(content=text))
|
||
if not any(role == "user" for role, _ in merged):
|
||
out.append(HumanMessage(content=ENTITY_OPENING_PROMPT))
|
||
return out
|
||
|
||
|
||
def _extract_wf_summary(
|
||
wf_rows: Sequence[Workflow],
|
||
ver_rows: Sequence[WorkflowVersion],
|
||
*,
|
||
allowed_sources: set[str] | None = None,
|
||
) -> list[dict[str, Any]]:
|
||
from app.tools.datasource import source_of_tool
|
||
|
||
published_by_wf: dict[str, WorkflowVersion] = {}
|
||
for ver in ver_rows:
|
||
cur = published_by_wf.get(ver.workflow_id)
|
||
if cur is None or int(ver.version or 0) > int(cur.version or 0):
|
||
published_by_wf[ver.workflow_id] = ver
|
||
|
||
summaries: list[dict[str, Any]] = []
|
||
for wf in wf_rows:
|
||
ver = published_by_wf.get(wf.id)
|
||
graph = (ver.graph if ver and isinstance(ver.graph, Mapping) else wf.graph) or {}
|
||
if not isinstance(graph, Mapping):
|
||
continue
|
||
cards: list[dict[str, Any]] = []
|
||
inputs: list[dict[str, Any]] = []
|
||
wf_sources: set[str] = set()
|
||
for node in graph.get("nodes") or []:
|
||
if not isinstance(node, Mapping):
|
||
continue
|
||
ntype = str(node.get("type") or "")
|
||
nid = str(node.get("id") or "")
|
||
data = node.get("data") if isinstance(node.get("data"), Mapping) else {}
|
||
cfg = data.get("config") if isinstance(data.get("config"), Mapping) else (
|
||
node.get("config") if isinstance(node.get("config"), Mapping) else {}
|
||
)
|
||
t_single = str(cfg.get("tool") or "").strip()
|
||
if s := source_of_tool(t_single):
|
||
if "{{" not in s:
|
||
wf_sources.add(s.lower())
|
||
for t_item in cfg.get("tools") or []:
|
||
if isinstance(t_item, str) and (s := source_of_tool(t_item.strip())) and "{{" not in s:
|
||
wf_sources.add(s.lower())
|
||
for skey in ("source", "source_name", "datasource", "source_id"):
|
||
sv = str(cfg.get(skey) or "").strip()
|
||
if sv and "{{" not in sv:
|
||
wf_sources.add(sv.lower())
|
||
|
||
if ntype == "metrics" and nid:
|
||
m_names = [
|
||
str(m.get("name") or m.get("id") or "")
|
||
for m in (cfg.get("metrics") or [])
|
||
if isinstance(m, Mapping)
|
||
]
|
||
cards.append({"card_id": nid, "metrics": [x for x in m_names if x][:8]})
|
||
elif ntype == "input":
|
||
for f in cfg.get("fields") or []:
|
||
if isinstance(f, Mapping) and str(f.get("name") or "").strip():
|
||
inputs.append({
|
||
"name": str(f["name"]).strip(),
|
||
"label": str(f.get("label") or f.get("description") or "").strip(),
|
||
"type": str(f.get("type") or "string"),
|
||
})
|
||
if allowed_sources is not None:
|
||
if wf_sources:
|
||
if not (wf_sources & allowed_sources):
|
||
continue
|
||
elif not allowed_sources:
|
||
continue
|
||
if cards and inputs:
|
||
summaries.append({
|
||
"workflow_id": wf.id,
|
||
"name": wf.name,
|
||
"published": ver is not None,
|
||
"inputs": inputs,
|
||
"cards": cards,
|
||
"sources": sorted(wf_sources),
|
||
})
|
||
return summaries
|
||
|
||
|
||
async def prepare(
|
||
session: AsyncSession,
|
||
*,
|
||
source_ids: Sequence[str] | None = None,
|
||
focus_entity_key: str | None = None,
|
||
attachment_ids: Sequence[str] = (),
|
||
document_ids: Sequence[str] = (),
|
||
pending_ops: Sequence[Mapping[str, Any]] = (),
|
||
) -> EntityAssistantContext:
|
||
"""组装实体助手上下文:按选定范围过滤的数据源表目录、既有标准实体、口径入参、挂载文档摘要与待保存提案。"""
|
||
all_enabled = list(
|
||
(await session.execute(select(DataSource).where(DataSource.enabled.is_(True)).order_by(DataSource.name))).scalars()
|
||
)
|
||
if source_ids is not None:
|
||
wanted = {str(s or "").strip().lower() for s in source_ids if str(s or "").strip()}
|
||
src_rows = [r for r in all_enabled if r.id.lower() in wanted or r.name.lower() in wanted]
|
||
else:
|
||
src_rows = all_enabled
|
||
|
||
allowed_sources: set[str] | None = (
|
||
{x for r in src_rows for x in (r.id.lower(), r.name.lower())}
|
||
if source_ids is not None
|
||
else None
|
||
)
|
||
|
||
sources: dict[str, _SourceEntry] = {}
|
||
source_lines: list[str] = ["当前分析范围的数据源:"]
|
||
for row in src_rows:
|
||
try:
|
||
resolved = await table_versions.resolve_source(session, row)
|
||
except table_versions.SnapshotMissing:
|
||
resolved = row
|
||
opts = dict(row.options or {}) if isinstance(row.options, Mapping) else {}
|
||
masked = {c.lower() for c in masked_columns(opts)}
|
||
p_settings = catalog_profile.profile_settings(opts)
|
||
can_query = bool(p_settings.enabled and row.enabled)
|
||
cat_entries = await catalog.read_catalog(session, resolved.id)
|
||
notes_map = {tname: entry.notes for tname, entry in cat_entries.items()}
|
||
sources[row.name.lower()] = _SourceEntry(
|
||
row=row,
|
||
resolved=resolved,
|
||
kind=str(row.kind or ""),
|
||
masked=masked,
|
||
settings=p_settings,
|
||
can_query=can_query,
|
||
catalog_notes=notes_map,
|
||
)
|
||
tables = ((resolved.schema_cache or {}).get("tables") or {}) if isinstance(resolved.schema_cache, Mapping) else {}
|
||
q_flag = "可只读查询" if can_query else "未开剖析"
|
||
source_lines.append(f"- {row.name}({row.kind},共 {len(tables)} 张表,{q_flag}){(':' + row.description) if row.description else ''}")
|
||
if len(source_lines) == 1:
|
||
source_lines.append("- (当前未选定任何数据源,仅基于挂载文档与既有实体分析)")
|
||
|
||
clean_pending = [dict(op) for op in (pending_ops or ()) if isinstance(op, Mapping)]
|
||
pending_keys = {
|
||
str(op.get("entity_key") or "").strip().lower()
|
||
for op in clean_pending
|
||
if str(op.get("entity_key") or "").strip()
|
||
}
|
||
|
||
ent_rows = await entities.list_entities(session)
|
||
focus_norm = (focus_entity_key or "").strip().lower()
|
||
in_scope_entities: list[Any] = []
|
||
other_domain_keys: list[str] = []
|
||
for erow in ent_rows:
|
||
ekey_low = erow.key.lower()
|
||
b_list = [b for b in (erow.bindings or []) if isinstance(b, Mapping)]
|
||
if allowed_sources is None or ekey_low == focus_norm or ekey_low in pending_keys or not b_list:
|
||
in_scope_entities.append(erow)
|
||
continue
|
||
touches_scope = any(
|
||
str(b.get("source_name") or "").strip().lower() in allowed_sources
|
||
or str(b.get("source_id") or "").strip().lower() in allowed_sources
|
||
for b in b_list
|
||
)
|
||
if touches_scope:
|
||
in_scope_entities.append(erow)
|
||
else:
|
||
other_domain_keys.append(f"{erow.key}({erow.label})")
|
||
|
||
entity_lines: list[str] = ["", f"当前范围相关的标准业务实体(共 {len(in_scope_entities)} 个):"]
|
||
for erow in in_scope_entities[:60]:
|
||
b_list = [b for b in (erow.bindings or []) if isinstance(b, Mapping)]
|
||
if allowed_sources is not None and erow.key.lower() != focus_norm:
|
||
b_list = [
|
||
b for b in b_list
|
||
if str(b.get("source_name") or "").strip().lower() in allowed_sources
|
||
or str(b.get("source_id") or "").strip().lower() in allowed_sources
|
||
]
|
||
active_b = [f"{b.get('source_name')}.{b.get('table_name')}.{b.get('column_name')}" for b in b_list if b.get("status") in ("confirmed", "verified")]
|
||
prop_b = [f"{b.get('source_name')}.{b.get('table_name')}.{b.get('column_name')}" for b in b_list if b.get("status") == "proposed"]
|
||
rej_b = [f"{b.get('source_name')}.{b.get('table_name')}.{b.get('column_name')}" for b in b_list if b.get("status") == "rejected"]
|
||
m_count = len(erow.members or [])
|
||
head = f"- {erow.label}(key={erow.key},v{erow.version},成员 {m_count} 项)"
|
||
if active_b:
|
||
head += f"|已确认绑定:{', '.join(active_b[:8])}"
|
||
if prop_b:
|
||
head += f"|待确认:{', '.join(prop_b[:6])}"
|
||
if rej_b:
|
||
head += f"|已驳回(勿重提):{', '.join(rej_b[:6])}"
|
||
entity_lines.append(head)
|
||
if focus_norm and erow.key.lower() == focus_norm:
|
||
entity_lines.append(f" 【当前使用者正在查看该实体】说明:{erow.description or '无'};别名:{', '.join(erow.aliases or []) or '无'}")
|
||
for m in (erow.members or [])[:25]:
|
||
if isinstance(m, Mapping):
|
||
codes_txt = ", ".join(f"{k}={v}" for k, v in (m.get("source_codes") or {}).items())
|
||
entity_lines.append(f" · 成员 {m.get('value')}({m.get('label')}):{codes_txt or '无跨源编码'}")
|
||
if other_domain_keys:
|
||
entity_lines.append(
|
||
f"(其他未选定数据源已占用的全局实体 key,避免重名冲突,无需分析未选定源:{', '.join(other_domain_keys[:30])})"
|
||
)
|
||
|
||
wf_rows = list((await session.execute(select(Workflow))).scalars())
|
||
ver_rows = list((await session.execute(select(WorkflowVersion).where(WorkflowVersion.published_at.is_not(None)))).scalars())
|
||
workflows = _extract_wf_summary(wf_rows, ver_rows, allowed_sources=allowed_sources)
|
||
|
||
doc_bundles = await entity_docs.load_doc_bundles(session, attachment_ids=attachment_ids, document_ids=document_ids)
|
||
doc_lines: list[str] = []
|
||
if doc_bundles:
|
||
doc_lines.append("")
|
||
doc_lines.append(f"本次对话挂载的业务文档/对照表(共 {len(doc_bundles)} 份,可用 read_document 检索详情):")
|
||
for b in doc_bundles:
|
||
preview = b.chunks[0][:300].replace("\n", " ") if b.chunks else ""
|
||
doc_lines.append(f"- 《{b.title}》(ref={b.ref_id},共 {len(b.chunks)} 段,{b.char_count} 字)首段预览:{preview}…")
|
||
|
||
pending_lines: list[str] = []
|
||
if clean_pending:
|
||
pending_lines.append("")
|
||
pending_lines.append(f"界面待保存区已有的修改提案(共 {len(clean_pending)} 项,避免重复提议):")
|
||
for pop in clean_pending[-40:]:
|
||
pending_lines.append(f"- {json.dumps(pop, ensure_ascii=False, default=str)}")
|
||
|
||
queryable = [e.row.name for e in sources.values() if e.can_query]
|
||
query_prompt = (
|
||
ENTITY_QUERY_PROMPT.format(rows=QUERY_ROWS, limit=MAX_QUERIES, sources="、".join(queryable))
|
||
if queryable
|
||
else ENTITY_NO_QUERY_PROMPT
|
||
)
|
||
|
||
system = "\n\n".join(
|
||
part
|
||
for part in [
|
||
ENTITY_ASSISTANT_SYSTEM,
|
||
query_prompt,
|
||
"\n".join([*source_lines, *entity_lines, *doc_lines, *pending_lines]),
|
||
]
|
||
if part
|
||
)
|
||
return EntityAssistantContext(
|
||
sources=sources,
|
||
workflows=workflows,
|
||
doc_bundles=doc_bundles,
|
||
pending_ops=clean_pending,
|
||
system=system,
|
||
)
|
||
|
||
|
||
def _cell(value: Any, limit: int | None = None) -> Any:
|
||
if value is None or isinstance(value, (bool, int, float)):
|
||
return value
|
||
if isinstance(value, (datetime, date)):
|
||
text = value.isoformat()
|
||
elif isinstance(value, Decimal):
|
||
text = str(value)
|
||
elif isinstance(value, (bytes, bytearray)):
|
||
text = f"<{len(value)} 字节>"
|
||
else:
|
||
text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, default=str)
|
||
if limit is not None and len(text) > limit:
|
||
return text[:limit] + "…"
|
||
return text
|
||
|
||
|
||
def _rows(rows: Any, n: int, limit: int | None) -> list[list[Any]]:
|
||
out: list[list[Any]] = []
|
||
for row in (rows or [])[:n]:
|
||
cells = row if isinstance(row, list) else list(row.values()) if isinstance(row, dict) else [row]
|
||
out.append([_cell(v, limit) for v in cells])
|
||
return out
|
||
|
||
|
||
class _EntityTools:
|
||
def __init__(self, ctx: EntityAssistantContext) -> None:
|
||
from app.engine.masking import SeenValues
|
||
|
||
self.ctx = ctx
|
||
self.queries = 0
|
||
self.seen_by_source: dict[str, Any] = {k: SeenValues() for k in ctx.sources}
|
||
|
||
def inspect(self, keyword: Any, source_name: Any) -> str:
|
||
kw = str(keyword or "").strip().lower()
|
||
src_filter = str(source_name or "").strip().lower()
|
||
lines: list[str] = []
|
||
|
||
for skey, entry in self.ctx.sources.items():
|
||
if src_filter and skey != src_filter and entry.row.id.lower() != src_filter:
|
||
continue
|
||
tables = ((entry.resolved.schema_cache or {}).get("tables") or {}) if isinstance(entry.resolved.schema_cache, Mapping) else {}
|
||
if not tables:
|
||
lines.append(f"数据源 {entry.row.name}:尚未探查表结构")
|
||
continue
|
||
matched_tables: list[str] = []
|
||
for tname, tmeta in tables.items():
|
||
if not isinstance(tmeta, Mapping):
|
||
continue
|
||
notes = entry.catalog_notes.get(tname) if isinstance(entry.catalog_notes.get(tname), Mapping) else {}
|
||
t_label_item = notes.get("label") if isinstance(notes.get("label"), Mapping) else {}
|
||
t_label = str(t_label_item.get("value") or "") if t_label_item.get("status") != "rejected" else ""
|
||
col_notes = notes.get("columns") if isinstance(notes.get("columns"), Mapping) else {}
|
||
cols = introspect.table_columns(dict(tmeta))
|
||
|
||
col_hits: list[str] = []
|
||
for col in cols:
|
||
cname = str(col.get("name") or "")
|
||
is_masked = (
|
||
cname.lower() in entry.masked
|
||
or f"{tname.lower()}.{cname.lower()}" in entry.masked
|
||
)
|
||
cnote = col_notes.get(cname) if isinstance(col_notes.get(cname), Mapping) else {}
|
||
clabel = ""
|
||
cmeaning = ""
|
||
ccodes = ""
|
||
if not is_masked and isinstance(cnote, Mapping):
|
||
li = cnote.get("label")
|
||
if isinstance(li, Mapping) and li.get("status") != "rejected":
|
||
clabel = str(li.get("value") or "")
|
||
mi = cnote.get("meaning")
|
||
if isinstance(mi, Mapping) and mi.get("status") != "rejected":
|
||
cmeaning = str(mi.get("value") or "")
|
||
ci = cnote.get("codes")
|
||
if isinstance(ci, Mapping) and ci.get("status") != "rejected" and isinstance(ci.get("value"), Mapping):
|
||
pairs = [f"{k}={v}" for k, v in list(ci["value"].items())[:12]]
|
||
ccodes = f" 码值[{', '.join(pairs)}]" if pairs else ""
|
||
comment = "" if is_masked else str(col.get("comment") or "")
|
||
haystack = " ".join([tname, t_label, cname, clabel, cmeaning, comment, ccodes]).lower()
|
||
if not kw or kw in haystack:
|
||
desc_parts = [p for p in [clabel, cmeaning, comment] if p]
|
||
mask_tag = " [遮罩列]" if is_masked else ""
|
||
col_hits.append(
|
||
f" · {cname} ({col.get('type') or 'unknown'}){mask_tag}"
|
||
+ (f":{' / '.join(desc_parts)}" if desc_parts else "")
|
||
+ ccodes
|
||
)
|
||
if col_hits:
|
||
head = f" 表 {tname}" + (f"({t_label})" if t_label else "")
|
||
matched_tables.append(head + "\n" + "\n".join(col_hits[:25]))
|
||
if matched_tables:
|
||
lines.append(f"【数据源 {entry.row.name}】\n" + "\n".join(matched_tables[:15]))
|
||
|
||
wf_hits: list[str] = []
|
||
for wf in self.ctx.workflows:
|
||
hay = json.dumps(wf, ensure_ascii=False).lower()
|
||
if not kw or kw in hay:
|
||
in_txt = ", ".join(f"{i['name']}({i['label'] or i['type']})" for i in wf["inputs"])
|
||
cd_txt = ", ".join(f"{c['card_id']}[{'/'.join(c['metrics'])}]" for c in wf["cards"])
|
||
pub_tag = "已发布" if wf["published"] else "草稿"
|
||
wf_hits.append(f"- 工作流 {wf['name']}(workflow_id={wf['workflow_id']},{pub_tag})|入口参数:{in_txt}|口径卡:{cd_txt}")
|
||
if wf_hits:
|
||
lines.append("【口径工作流与入口参数】\n" + "\n".join(wf_hits[:20]))
|
||
|
||
if not lines:
|
||
return f"未找到与「{keyword}」匹配的表列或口径参数,请尝试更短的词根或留空 keyword 查看全景概览。"
|
||
text = "\n\n".join(lines)
|
||
return text[:TOOL_CHARS] + ("…(已截断)" if len(text) > TOOL_CHARS else "")
|
||
|
||
def read_doc(self, query: Any, doc_ref: Any, start_chunk: Any, limit: Any) -> str:
|
||
if not self.ctx.doc_bundles:
|
||
return ENTITY_TOOL_PROMPT["no_docs"]
|
||
hits = entity_docs.search_doc_bundles(
|
||
self.ctx.doc_bundles,
|
||
query=str(query or ""),
|
||
doc_ref=str(doc_ref or ""),
|
||
start_chunk=int(start_chunk) if isinstance(start_chunk, int) else 0,
|
||
limit=int(limit) if isinstance(limit, int) else 6,
|
||
)
|
||
if not hits:
|
||
return "未检索到匹配的文档段落,可清空 query 按 start_chunk=0 顺序浏览。"
|
||
blocks = [
|
||
f"【{h['citation']}(共 {h['total_chunks']} 段,ref={h['doc_ref']})】\n{h['content']}"
|
||
for h in hits
|
||
]
|
||
text = "\n\n".join(blocks)
|
||
return text[:TOOL_CHARS] + ("…(已截断)" if len(text) > TOOL_CHARS else "")
|
||
|
||
async def query(self, source_name: Any, sql: Any) -> tuple[dict[str, Any] | None, str]:
|
||
prompts = ENTITY_TOOL_PROMPT
|
||
src_key = str(source_name or "").strip().lower()
|
||
entry = self.ctx.sources.get(src_key)
|
||
if entry is None or not entry.can_query:
|
||
allowed = "、".join(self.ctx.queryable_sources) or "无"
|
||
return None, prompts["query_disabled_source"].format(source=source_name or "", allowed=allowed)
|
||
if self.queries >= MAX_QUERIES:
|
||
return None, prompts["query_capped"].format(n=MAX_QUERIES)
|
||
clean_sql = sql.strip() if isinstance(sql, str) else ""
|
||
if not clean_sql:
|
||
return None, prompts["empty_sql"]
|
||
|
||
self.queries += 1
|
||
seen = self.seen_by_source[src_key]
|
||
mask = masked_columns(dict(entry.row.options or {}) if isinstance(entry.row.options, Mapping) else None)
|
||
known = await asyncio.to_thread(seen.pooled)
|
||
known = known if known.strong else None
|
||
error: str | None = None
|
||
result: Any = None
|
||
try:
|
||
result = await catalog_profile.run_single_query(
|
||
entry.resolved,
|
||
clean_sql,
|
||
settings=entry.settings,
|
||
max_rows=QUERY_ROWS,
|
||
kind=entry.kind,
|
||
)
|
||
except catalog_profile.QueryFailed as exc:
|
||
error = exc.detail
|
||
except SnapshotTampered as exc:
|
||
error = QUERY_TAMPERED.format(name=entry.row.name, error=exc)
|
||
except ValueError as exc:
|
||
error = str(exc)
|
||
|
||
if error is not None:
|
||
if mask or known is not None:
|
||
from app.engine.masking import mask_error_text
|
||
|
||
error = mask_error_text(error, clean_sql, known)
|
||
event = {
|
||
"type": "query",
|
||
"source": entry.row.name,
|
||
"sql": clean_sql,
|
||
"columns": [],
|
||
"rows": [],
|
||
"row_count": 0,
|
||
"truncated": False,
|
||
"error": error,
|
||
}
|
||
return event, prompts["query_failed"].format(error=error)
|
||
|
||
payload = result.to_payload()
|
||
if mask or known is not None:
|
||
from app.engine.masking import mask_query_result
|
||
|
||
schema = getattr(entry.resolved, "schema_cache", None)
|
||
done = await asyncio.to_thread(mask_query_result, payload, mask, entry.kind, known, schema=schema)
|
||
payload = done.payload
|
||
await asyncio.to_thread(seen.add, done.secrets)
|
||
|
||
columns = [str(c) for c in payload.get("columns") or []]
|
||
rows = payload.get("rows") or []
|
||
event = {
|
||
"type": "query",
|
||
"source": entry.row.name,
|
||
"sql": str(payload.get("sql") or clean_sql),
|
||
"columns": columns,
|
||
"rows": _rows(rows, SHOWN_ROWS, None),
|
||
"row_count": int(payload.get("row_count") or len(rows)),
|
||
"truncated": bool(payload.get("truncated")),
|
||
"error": None,
|
||
}
|
||
given = {
|
||
"source": entry.row.name,
|
||
"columns": columns,
|
||
"rows": _rows(rows, QUERY_ROWS, CELL_MAX),
|
||
"row_count": event["row_count"],
|
||
"truncated": event["truncated"],
|
||
}
|
||
text = json.dumps(given, ensure_ascii=False, default=str)
|
||
while len(text) > TOOL_CHARS and given["rows"]:
|
||
given["rows"] = given["rows"][: max(1, len(given["rows"]) // 2)] if len(given["rows"]) > 1 else []
|
||
text = json.dumps(given, ensure_ascii=False, default=str)
|
||
return event, text
|
||
|
||
async def propose(self, operations: Any) -> tuple[dict[str, Any] | None, str]:
|
||
prompts = ENTITY_TOOL_PROMPT
|
||
if not isinstance(operations, list) or not operations:
|
||
return None, prompts["no_ops"]
|
||
async with SessionLocal() as session:
|
||
plan = await entity_patch.plan_entity_patch(
|
||
session,
|
||
operations,
|
||
pending_ops=self.ctx.pending_ops,
|
||
mode="confirmed",
|
||
)
|
||
said: list[str] = []
|
||
event: dict[str, Any] | None = None
|
||
if plan.changes:
|
||
for ch in plan.changes:
|
||
self.ctx.pending_ops.append(ch.op_payload)
|
||
event = {
|
||
"type": "proposal",
|
||
"changes": [ch.as_dict() for ch in plan.changes],
|
||
"expected_versions": plan.expected_versions,
|
||
}
|
||
said.append(
|
||
prompts["proposed"].format(
|
||
n=len(plan.changes),
|
||
items=";".join(ch.summary for ch in plan.changes),
|
||
)
|
||
)
|
||
else:
|
||
said.append(prompts["nothing"])
|
||
if plan.problems:
|
||
said.append(prompts["dropped"].format(reasons=";".join(plan.problems)))
|
||
return event, "\n".join(said)
|
||
|
||
|
||
_SENTENCE_END = ("。", "!", "?", ";", ":", ".", "!", "?", ";", ":")
|
||
|
||
|
||
def _sse(obj: Mapping[str, Any]) -> str:
|
||
return f"data: {json.dumps(obj, ensure_ascii=False, default=str)}\n\n"
|
||
|
||
|
||
class _CallFailed(Exception):
|
||
def __init__(self, code: str, message: str) -> None:
|
||
super().__init__(message)
|
||
self.code = code
|
||
self.message = message
|
||
|
||
|
||
def _seconds(value: Any) -> str:
|
||
number = float(value)
|
||
return str(int(number)) if number.is_integer() else f"{number:g}"
|
||
|
||
|
||
def _transient(exc: BaseException) -> bool:
|
||
from app.core.errors import NETWORK, RATE_LIMIT, SERVER, classify
|
||
|
||
return classify(exc) in (RATE_LIMIT, SERVER, NETWORK)
|
||
|
||
|
||
async def _pump(runner: Any, history: list[Any], queue: asyncio.Queue[tuple[str, Any]]) -> None:
|
||
try:
|
||
async for chunk in runner.astream(history):
|
||
await queue.put(("chunk", chunk))
|
||
await queue.put(("end", None))
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception as exc: # noqa: BLE001
|
||
await queue.put(("error", exc))
|
||
|
||
|
||
async def _round(runner: Any, history: list[Any], model_id: str) -> AsyncIterator[tuple[str, Any]]:
|
||
from app.core.errors import TIMEOUT, classify, describe_exception
|
||
from app.engine.state import message_text
|
||
from app.providers.budget import cut_off
|
||
|
||
loop = asyncio.get_running_loop()
|
||
attempt = 0
|
||
while True:
|
||
queue: asyncio.Queue[tuple[str, Any]] = asyncio.Queue()
|
||
task = asyncio.ensure_future(_pump(runner, history, queue))
|
||
deadline = loop.time() + float(catalog.MODEL_TIMEOUT_S)
|
||
message: Any = None
|
||
cut = False
|
||
started = False
|
||
failure: BaseException | None = None
|
||
try:
|
||
while True:
|
||
remaining = deadline - loop.time()
|
||
if remaining <= 0:
|
||
raise _CallFailed(
|
||
CODE_TIMEOUT,
|
||
ERROR_TIMEOUT.format(seconds=_seconds(catalog.MODEL_TIMEOUT_S), place=DRAFT_MODEL_PLACE),
|
||
)
|
||
try:
|
||
kind, value = await asyncio.wait_for(queue.get(), timeout=min(remaining, HEARTBEAT_S))
|
||
except asyncio.TimeoutError:
|
||
yield "beat", None
|
||
continue
|
||
if kind == "end":
|
||
break
|
||
if kind == "error":
|
||
failure = value
|
||
break
|
||
message = value if message is None else message + value
|
||
cut = cut or bool(cut_off(value))
|
||
text = message_text(value)
|
||
if text:
|
||
started = True
|
||
yield "text", text
|
||
finally:
|
||
if not task.done():
|
||
task.cancel()
|
||
with contextlib.suppress(asyncio.CancelledError, Exception):
|
||
await task
|
||
if failure is None:
|
||
yield "message", (message, cut)
|
||
return
|
||
if not started and attempt < len(RETRY_DELAYS_S) and _transient(failure):
|
||
logger.info("实体助手:调用模型出错(%s),%s 秒后重试", type(failure).__name__, RETRY_DELAYS_S[attempt])
|
||
await asyncio.sleep(RETRY_DELAYS_S[attempt])
|
||
attempt += 1
|
||
continue
|
||
if isinstance(failure, TimeoutError) or classify(failure) == TIMEOUT:
|
||
raise _CallFailed(
|
||
CODE_TIMEOUT,
|
||
ERROR_TIMEOUT.format(seconds=_seconds(catalog.MODEL_TIMEOUT_S), place=DRAFT_MODEL_PLACE),
|
||
) from failure
|
||
raise _CallFailed(
|
||
CODE_MODEL,
|
||
ERROR_MODEL.format(model=model_id or "", reason=describe_exception(failure), place=DRAFT_MODEL_PLACE),
|
||
) from failure
|
||
|
||
|
||
async def stream(
|
||
model: Any,
|
||
model_id: str,
|
||
ctx: EntityAssistantContext,
|
||
messages: Iterable[Mapping[str, Any]],
|
||
) -> AsyncIterator[str]:
|
||
"""实体助手流式生成器:逐行产出 SSE data 事件与心跳注释。"""
|
||
from langchain_core.messages import SystemMessage, ToolMessage, message_chunk_to_message
|
||
|
||
from app.providers.budget import cut_words
|
||
|
||
history: list[Any] = [SystemMessage(content=ctx.system), *_history(messages)]
|
||
tools = _EntityTools(ctx)
|
||
usage = {"input_tokens": 0, "output_tokens": 0}
|
||
wrote = ""
|
||
tool_defs = ctx.tools()
|
||
names = [t["function"]["name"] for t in tool_defs]
|
||
try:
|
||
runner = model.bind_tools(tool_defs)
|
||
for round_no in range(MAX_ROUNDS):
|
||
fresh = True
|
||
message: Any = None
|
||
cut = False
|
||
async with contextlib.aclosing(_round(runner, history, model_id)) as rounds:
|
||
async for kind, value in rounds:
|
||
if kind == "beat":
|
||
yield ": keep-alive\n\n"
|
||
elif kind == "text":
|
||
if fresh and wrote.endswith(_SENTENCE_END):
|
||
yield _sse({"type": "text", "delta": "\n\n"})
|
||
fresh = False
|
||
wrote = value
|
||
yield _sse({"type": "text", "delta": value})
|
||
else:
|
||
message, cut = value
|
||
meta = getattr(message, "usage_metadata", None) or {}
|
||
usage["input_tokens"] += int(meta.get("input_tokens") or 0)
|
||
usage["output_tokens"] += int(meta.get("output_tokens") or 0)
|
||
if cut:
|
||
limit = getattr(model, "max_tokens", None)
|
||
yield _sse({
|
||
"type": "error",
|
||
"code": CODE_TRUNCATED,
|
||
"message": ERROR_TRUNCATED.format(cut=cut_words("实体助手", limit), place=DRAFT_MODEL_PLACE),
|
||
})
|
||
return
|
||
calls = list(getattr(message, "tool_calls", None) or []) if message is not None else []
|
||
if not calls:
|
||
yield _sse({"type": "done", "usage": usage, "queries": tools.queries})
|
||
return
|
||
if round_no == MAX_ROUNDS - 1:
|
||
yield _sse({"type": "error", "code": CODE_ROUNDS, "message": ERROR_ROUNDS.format(n=MAX_ROUNDS)})
|
||
return
|
||
history.append(message_chunk_to_message(message))
|
||
for call in calls:
|
||
name = str(call.get("name") or "")
|
||
args = call.get("args") if isinstance(call.get("args"), Mapping) else {}
|
||
call_id = str(call.get("id") or f"{name}-{round_no}-{len(history)}")
|
||
event: dict[str, Any] | None = None
|
||
if name == INSPECT_TOOL:
|
||
content = tools.inspect(args.get("keyword"), args.get("source_name"))
|
||
elif name == READ_DOC_TOOL:
|
||
content = tools.read_doc(
|
||
args.get("query"),
|
||
args.get("doc_ref"),
|
||
args.get("start_chunk"),
|
||
args.get("limit"),
|
||
)
|
||
elif name == QUERY_TOOL:
|
||
event, content = await tools.query(args.get("source_name"), args.get("sql"))
|
||
elif name == PROPOSE_TOOL:
|
||
event, content = await tools.propose(args.get("operations"))
|
||
else:
|
||
content = ENTITY_TOOL_PROMPT["unknown_tool"].format(name=name, tools="、".join(names))
|
||
if event is not None:
|
||
yield _sse(event)
|
||
if round_no == MAX_ROUNDS - 2:
|
||
content = f"{content}\n{ENTITY_TOOL_PROMPT['last_round']}"
|
||
history.append(ToolMessage(content=content, tool_call_id=call_id))
|
||
except _CallFailed as exc:
|
||
yield _sse({"type": "error", "code": exc.code, "message": exc.message})
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception as exc: # noqa: BLE001
|
||
from app.core.errors import describe_exception, first_line
|
||
|
||
logger.exception("实体助手执行异常")
|
||
yield _sse({
|
||
"type": "error",
|
||
"code": CODE_FAILED,
|
||
"message": ERROR_FAILED.format(line=first_line(exc) or describe_exception(exc)),
|
||
})
|