mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 05:31:59 +00:00
09949fc8ac
- 模型接入:支持跨 Anthropic / OpenAI 兼容网关显式关闭深度思考(thinking=off / effort=low),对不识别推理参数的网关自动剥离重发并做进程级记忆 - 快速直查:收敛直查图推理预算(max_steps=4),杜绝长思维链与多轮现场盲探 - 语义指标与预命中:Oracle / PostgreSQL 分组聚合补充 DESC NULLS LAST;基于关系图、逆表频共享键与低基数统计(1..20 distinct)实现零硬编码的通用关联维表发现、分类列抽样及全空分组折叠诊断 - 零延迟数字证据:支持纯数字文本编码(含前导零)无损回绑、多表同行实体键与列序号消歧,结合上下文事实白名单与冲突守卫消除误报并保留防编造拦截
1311 lines
60 KiB
Python
1311 lines
60 KiB
Python
"""后台口径文档一键拆解与智能指标提取任务引擎(SemanticDecomposeJob)。
|
||
|
||
一次性打通业务文档(粘贴 Markdown、上传附件、选择知识库文档)与数据源目录的四层语义资产:
|
||
1. 知识库归档与判异红线留存(KB Document + guardrail_notes):自动将粘贴或上传的口径总表存入知识库,保留物理红线、异常阈值与「暂无数据源」说明供问数检索。
|
||
2. 字段目录补丁(catalog_patches):提取表中文名、粒度、业务日期列、有效过滤条件,以及列级符号映射(如 ATTRIBUTE01 → 计划开班时长)、单位、流量或存量角色、存储陷阱备注;经 catalog.plan_patch 严格核验,并在内存里即时注入下一阶段!
|
||
3. 单表与多表参数化指标口径(metric_candidates):结合最新字段符号映射,同时提取单表聚合表达式(calc_mode="expression")与跨表 JOIN / CTE / 动态周期天数参数化模板(calc_mode="sql_template"),逐条通过 AST 与只读守卫预编译核验。
|
||
4. 标准业务实体与物理列编码映射(entity_changes):提取产线、车间、模具、不良代码等共用维度实体及表列绑定,经 entity_patch.plan_entity_patch 核验。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import contextlib
|
||
import copy
|
||
import json
|
||
import logging
|
||
import re
|
||
import time
|
||
from collections.abc import Mapping, Sequence
|
||
from datetime import timezone
|
||
from typing import Any
|
||
|
||
from langchain_core.messages import HumanMessage, SystemMessage
|
||
from sqlalchemy import select, update
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.core.errors import describe_exception, first_line
|
||
from app.data import catalog, catalog_assistant, entity_docs, entity_patch
|
||
from app.data.engine import masked_columns
|
||
from app.data.entities import confirmed_bindings, list_entities
|
||
from app.data.names import name_key
|
||
from app.data.semantic_metrics import (
|
||
SUGGEST_SYSTEM_PROMPT,
|
||
MetricConflict,
|
||
MetricInvalid,
|
||
MetricNotFound,
|
||
_normalize_metric_payload,
|
||
_parse_suggested_metrics,
|
||
_resolve_source,
|
||
create_metric,
|
||
list_metrics,
|
||
metric_to_dict,
|
||
probe_schema_lookup_and_discriminators,
|
||
update_metric,
|
||
)
|
||
from app.db.base import SessionLocal, utcnow
|
||
from app.db.models import DataSource, Document
|
||
from app.db.models_metrics import SemanticDecomposeJob, SemanticMetric
|
||
from app.engine.state import message_text
|
||
from app.memory import kb
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
OPEN_STATUSES = ("queued", "running")
|
||
VALID_SCOPES = ("catalog", "metrics", "entities")
|
||
MAX_DOC_CHARS = 32_000
|
||
MAX_NOTE_CHARS = 1500
|
||
|
||
DECOMPOSE_BUSY_CODE = "decompose_job_busy"
|
||
DECOMPOSE_FINISHED_CODE = "decompose_job_finished"
|
||
DECOMPOSE_NOT_FOUND_CODE = "decompose_job_not_found"
|
||
|
||
INTERRUPTED_ERROR = "服务重启,后台口径拆解任务中断,请重新发起"
|
||
JOB_FAILED_TEXT = "口径拆解任务执行失败:{reason}"
|
||
|
||
_FENCE_RE = re.compile(r"```(?:json)?\s*([\s\S]*?)```", re.IGNORECASE)
|
||
_ACTIVE_TASKS: dict[str, asyncio.Task[Any]] = {}
|
||
_CREATE_LOCK = asyncio.Lock()
|
||
_KB_BG_TASKS: set[asyncio.Task[Any]] = set()
|
||
|
||
|
||
class DecomposeBusy(Exception):
|
||
"""当前数据源已有正在执行的口径拆解任务。"""
|
||
|
||
|
||
class DecomposeFinished(Exception):
|
||
"""要取消的口径拆解任务已经结束。"""
|
||
|
||
|
||
class DecomposeNotFound(LookupError):
|
||
"""找不到指定的口径拆解任务。"""
|
||
|
||
|
||
CATALOG_DECOMPOSE_SYSTEM_PROMPT = """你是 AgentLab 的「数据目录与字段字典拆解专家」。请阅读数据源物理表结构、现有数据目录以及业务口径文档(如《指标公式总表》),提取需要补充或修正的「表目录与字段字典补丁」,同时摘录文档中的「物理判异红线与缺源提醒」。
|
||
|
||
硬性纪律:
|
||
1. 只输出一个合法 JSON 对象,格式为:
|
||
{
|
||
"tables": [
|
||
{
|
||
"table": "物理表名(逐字取自输入表结构)",
|
||
"changes": [
|
||
{"path": "路径", "value": "取值", "reason": "简明依据"}
|
||
]
|
||
}
|
||
],
|
||
"guardrail_notes": [
|
||
{"title": "规则标题", "rule": "判异红线、存储陷阱或暂无数据源说明", "category": "redline | storage | missing_source"}
|
||
]
|
||
}
|
||
不要输出任何额外解释文字。
|
||
2. 表名与列名必须 100% 逐字存在于输入提供的表结构中,严禁臆造不存在的表或列;标有「[遮罩列]」的敏感列严禁输出补丁。
|
||
3. changes[].path 仅支持以下规范路径:
|
||
- 表级:
|
||
· "label":表中文名称(纯名词短语,勿写公式)
|
||
· "description":表业务用途与多口径/多层级存储说明
|
||
· "grain":一行代表什么业务粒度(如 "每个门店每天一行" 或 "混存多种汇总层级与分类记录")
|
||
· "business_date":{"column": "物理日期列名", "rule": "统计归属说明(可空串)"}
|
||
· "valid_filter":默认有效记录 SQL 过滤片段(如 "is_deleted = 0",不含 WHERE;仅填全表通用的有效条件,若不同指标查不同分类切勿把某一分类锁死在表级 valid_filter)
|
||
· "dedup":去重与防重算说明(如 "同表混存汇总行与明细行,聚合前必须按层级或类型列过滤")
|
||
- 列级(把 <col> 替换为真实物理列名):
|
||
· "columns.<col>.label":字段中文名或符号含义(纯名称,严禁包含 =、/、*、SUM( 等计算公式符号!)
|
||
· "columns.<col>.unit":单位(如 "元"、"秒"、"%"、"件"、"人次")
|
||
· "columns.<col>.measure":仅限 "flow"(流量,可跨日加总)、"stock"(存量,不可跨期直接求和)、"ratio"(比率)、"identifier"(编码标识)、"status"(状态/分类码)、"attribute"(属性/分组码)
|
||
· "columns.<col>.meaning":关键业务含义与存储说明(如百分数存储方式、跨分类字段复用含义等;注意文字中不要写等号公式,用自然中文表述)
|
||
· "columns.<col>.codes":枚举码值字典,如 {"1": "正常", "0": "停用"}(务必使用主表实探出的精确码值字符串作为 key)
|
||
4. 同表异构、多粒度或字典驱动字段拆解准则:凡文档或【关联字典配置表与低基数分类列实探】给出了编码对照、层级说明或通用列映射的,务必完整提取分类列的 `codes`、度量列的 `label`/`unit`/`measure`/`meaning` 以及表级 `dedup` 或 `guardrail_notes` 防混算红线!
|
||
"""
|
||
|
||
ENTITY_DECOMPOSE_SYSTEM_PROMPT = """你是 AgentLab 的「标准业务实体拆解专家」。请结合数据源物理表结构(含最新字段说明)与业务口径文档,识别多张表或业务提问中共用的核心维度实体(如产线、车间、机台、产品型号、不良原因、区域等),输出增量操作 JSON。
|
||
|
||
硬性纪律:
|
||
1. 只输出一个合法 JSON 对象,格式为 {"operations": [...]},不要输出任何额外解释文字。
|
||
2. 表名、列名、数据源名必须 100% 逐字存在于输入材料中,严禁编造;纯自增流水主键或纯度量数值列不要建为业务实体;标有「[遮罩列]」的敏感列严禁绑定或提取成员。
|
||
3. 若没有明确可建的标准业务实体,返回 {"operations": []}。
|
||
4. operations 支持以下结构(新建实体时请先输出一条 upsert_entity,再输出其 upsert_binding / upsert_member):
|
||
- {"op": "upsert_entity", "entity_key": "小写英文下划线标识,如 production_line", "label": "产线", "description": "制造产线标准维度", "aliases": ["生产线", "线体"], "reason": "依据"}
|
||
- {"op": "upsert_binding", "entity_key": "production_line", "entity_label": "产线", "source_name": "数据源名称", "table_name": "物理表名", "column_name": "物理列名", "note": "产线编码列", "reason": "依据"}
|
||
- {"op": "upsert_member", "entity_key": "production_line", "entity_label": "产线", "value": "L1", "label": "一号产线", "aliases": ["1线"], "source_codes": {"数据源名称": "L1"}, "reason": "依据"}
|
||
"""
|
||
|
||
|
||
def _iso(val: Any) -> str | None:
|
||
if val is None or not hasattr(val, "isoformat"):
|
||
return None
|
||
if getattr(val, "tzinfo", None) is None:
|
||
val = val.replace(tzinfo=timezone.utc)
|
||
return val.isoformat()
|
||
|
||
|
||
def _parse_json_obj(raw_text: str) -> dict[str, Any]:
|
||
text = (raw_text or "").strip()
|
||
if not text:
|
||
return {}
|
||
m = _FENCE_RE.search(text)
|
||
candidate = m.group(1).strip() if m else text
|
||
for snippet in (candidate, text):
|
||
start = snippet.find("{")
|
||
end = snippet.rfind("}")
|
||
if start >= 0 and end > start:
|
||
try:
|
||
obj = json.loads(snippet[start : end + 1])
|
||
if isinstance(obj, Mapping):
|
||
return dict(obj)
|
||
except ValueError:
|
||
continue
|
||
return {}
|
||
|
||
|
||
def _normalize_scopes(raw_scopes: Sequence[str] | None, *, has_docs: bool) -> list[str]:
|
||
if not raw_scopes:
|
||
return ["catalog", "metrics", "entities"] if has_docs else ["metrics"]
|
||
out: list[str] = []
|
||
for s in raw_scopes:
|
||
clean = str(s or "").strip().lower()
|
||
if clean in VALID_SCOPES and clean not in out:
|
||
out.append(clean)
|
||
return out or (["catalog", "metrics", "entities"] if has_docs else ["metrics"])
|
||
|
||
|
||
def _initial_steps(scopes: Sequence[str], *, has_docs: bool) -> list[dict[str, Any]]:
|
||
steps: list[dict[str, Any]] = [
|
||
{
|
||
"key": "prepare",
|
||
"label": "准备表结构并归档口径文档" if has_docs else "读取数据源表结构与目录上下文",
|
||
"status": "pending",
|
||
"detail": "等待执行",
|
||
"count": 0,
|
||
"elapsed_ms": 0,
|
||
}
|
||
]
|
||
if "catalog" in scopes:
|
||
steps.append(
|
||
{
|
||
"key": "catalog",
|
||
"label": "拆解字段符号映射与表目录补丁",
|
||
"status": "pending",
|
||
"detail": "等待执行",
|
||
"count": 0,
|
||
"elapsed_ms": 0,
|
||
}
|
||
)
|
||
if "metrics" in scopes:
|
||
steps.append(
|
||
{
|
||
"key": "metrics",
|
||
"label": "生成单表表达式与多表模板指标口径",
|
||
"status": "pending",
|
||
"detail": "等待执行",
|
||
"count": 0,
|
||
"elapsed_ms": 0,
|
||
}
|
||
)
|
||
if "entities" in scopes:
|
||
steps.append(
|
||
{
|
||
"key": "entities",
|
||
"label": "识别标准业务实体与物理列映射",
|
||
"status": "pending",
|
||
"detail": "等待执行",
|
||
"count": 0,
|
||
"elapsed_ms": 0,
|
||
}
|
||
)
|
||
return steps
|
||
|
||
|
||
def job_to_dict(row: SemanticDecomposeJob, *, include_result: bool = True) -> dict[str, Any]:
|
||
data: dict[str, Any] = {
|
||
"id": row.id,
|
||
"status": row.status,
|
||
"source_id": row.source_id,
|
||
"source_name": row.source_name or "",
|
||
"scopes": list(row.scopes or []),
|
||
"document_ids": list(row.document_ids or []),
|
||
"attachment_ids": list(row.attachment_ids or []),
|
||
"doc_title": row.doc_title or "",
|
||
"has_doc_text": bool((row.doc_text or "").strip()),
|
||
"save_to_kb": bool(row.save_to_kb),
|
||
"note": row.note or "",
|
||
"phase": row.phase or "",
|
||
"phase_label": row.phase_label or "",
|
||
"progress": int(row.progress or 0),
|
||
"steps": list(row.steps or []),
|
||
"summary": dict(row.summary or {}),
|
||
"error": row.error,
|
||
"started_by": row.started_by,
|
||
"started_at": _iso(row.started_at),
|
||
"finished_at": _iso(row.finished_at),
|
||
"created_at": _iso(row.created_at),
|
||
"updated_at": _iso(row.updated_at),
|
||
}
|
||
if include_result:
|
||
data["result"] = dict(row.result or {})
|
||
return data
|
||
|
||
|
||
async def get_job(session: AsyncSession, job_id: str) -> SemanticDecomposeJob:
|
||
clean = str(job_id or "").strip()
|
||
if not clean:
|
||
raise DecomposeNotFound("未指定口径拆解任务编号")
|
||
row = await session.get(SemanticDecomposeJob, clean)
|
||
if row is None:
|
||
raise DecomposeNotFound(f"找不到口径拆解任务「{clean}」")
|
||
return row
|
||
|
||
|
||
async def get_active_or_latest_job(
|
||
session: AsyncSession,
|
||
*,
|
||
source_id: str | None = None,
|
||
) -> dict[str, Any]:
|
||
"""返回正在排队或运行中的任务,以及最近一次已完成任务(供口径台账页顶部横幅轮询与恢复审阅)。"""
|
||
active_stmt = select(SemanticDecomposeJob).where(SemanticDecomposeJob.status.in_(OPEN_STATUSES))
|
||
latest_stmt = select(SemanticDecomposeJob)
|
||
if source_id and source_id.strip():
|
||
sid = source_id.strip()
|
||
active_stmt = active_stmt.where(SemanticDecomposeJob.source_id == sid)
|
||
latest_stmt = latest_stmt.where(SemanticDecomposeJob.source_id == sid)
|
||
active_row = (
|
||
await session.execute(active_stmt.order_by(SemanticDecomposeJob.created_at.desc()).limit(1))
|
||
).scalars().first()
|
||
latest_row = (
|
||
await session.execute(latest_stmt.order_by(SemanticDecomposeJob.created_at.desc()).limit(1))
|
||
).scalars().first()
|
||
return {
|
||
"active": job_to_dict(active_row, include_result=False) if active_row else None,
|
||
"latest": job_to_dict(latest_row, include_result=True) if latest_row else None,
|
||
}
|
||
|
||
|
||
async def list_jobs(
|
||
session: AsyncSession,
|
||
*,
|
||
source_id: str | None = None,
|
||
limit: int = 10,
|
||
) -> list[dict[str, Any]]:
|
||
stmt = select(SemanticDecomposeJob).order_by(SemanticDecomposeJob.created_at.desc()).limit(max(1, min(limit, 30)))
|
||
if source_id and source_id.strip():
|
||
stmt = stmt.where(SemanticDecomposeJob.source_id == source_id.strip())
|
||
rows = list((await session.execute(stmt)).scalars().all())
|
||
return [job_to_dict(r, include_result=False) for r in rows]
|
||
|
||
|
||
async def create_decompose_job(
|
||
session: AsyncSession,
|
||
*,
|
||
source_id: str,
|
||
scopes: Sequence[str] | None = None,
|
||
table_names: Sequence[str] | None = None,
|
||
document_ids: Sequence[str] | None = None,
|
||
attachment_ids: Sequence[str] | None = None,
|
||
doc_title: str = "",
|
||
doc_text: str = "",
|
||
save_to_kb: bool = True,
|
||
note: str = "",
|
||
actor: str | None = None,
|
||
) -> SemanticDecomposeJob:
|
||
"""创建并启动后台口径文档一键拆解或智能提取指标任务。"""
|
||
ds = await _resolve_source(session, source_id)
|
||
cache = ds.schema_cache if isinstance(ds.schema_cache, Mapping) else {}
|
||
tables = cache.get("tables") if isinstance(cache, Mapping) else None
|
||
if not isinstance(tables, Mapping) or not tables:
|
||
raise MetricInvalid(f"数据源「{ds.name}」尚未探查表结构,请先在数据源设置中完成探查", field="source_id")
|
||
|
||
clean_docs = [str(d).strip() for d in (document_ids or []) if str(d or "").strip()]
|
||
clean_atts = [str(a).strip() for a in (attachment_ids or []) if str(a or "").strip()]
|
||
clean_text = (doc_text or "").strip()
|
||
clean_title = (doc_title or "").strip()[:240]
|
||
has_docs = bool(clean_docs or clean_atts or clean_text)
|
||
norm_scopes = _normalize_scopes(scopes, has_docs=has_docs)
|
||
clean_tables = [str(t).strip() for t in (table_names or []) if str(t or "").strip()]
|
||
|
||
async with _CREATE_LOCK:
|
||
existing_open = (
|
||
await session.execute(
|
||
select(SemanticDecomposeJob).where(
|
||
SemanticDecomposeJob.source_id == ds.id,
|
||
SemanticDecomposeJob.status.in_(OPEN_STATUSES),
|
||
)
|
||
)
|
||
).scalars().first()
|
||
if existing_open is not None:
|
||
raise DecomposeBusy(f"数据源「{ds.name}」已有正在后台执行的拆解任务,请等待完成或先取消当前任务")
|
||
|
||
job = SemanticDecomposeJob(
|
||
status="queued",
|
||
source_id=ds.id,
|
||
source_name=ds.name,
|
||
scopes=norm_scopes,
|
||
document_ids=clean_docs,
|
||
attachment_ids=clean_atts,
|
||
doc_title=clean_title,
|
||
doc_text=clean_text,
|
||
save_to_kb=bool(save_to_kb),
|
||
note=(note or "").strip()[:MAX_NOTE_CHARS],
|
||
phase="queued",
|
||
phase_label="已进入后台队列,准备读取数据源与文档",
|
||
progress=4,
|
||
steps=_initial_steps(norm_scopes, has_docs=has_docs),
|
||
result={"table_names": clean_tables} if clean_tables else {},
|
||
summary={},
|
||
started_by=actor,
|
||
started_at=utcnow(),
|
||
)
|
||
session.add(job)
|
||
await session.commit()
|
||
await session.refresh(job)
|
||
|
||
task = asyncio.create_task(_execute_job(job.id), name=f"semantic-decompose-{job.id}")
|
||
_ACTIVE_TASKS[job.id] = task
|
||
task.add_done_callback(lambda _t, jid=job.id: _ACTIVE_TASKS.pop(jid, None))
|
||
return job
|
||
|
||
|
||
def _update_step_list(
|
||
steps: list[dict[str, Any]],
|
||
step_key: str,
|
||
*,
|
||
status: str,
|
||
detail: str | None = None,
|
||
count: int | None = None,
|
||
elapsed_ms: int | None = None,
|
||
) -> list[dict[str, Any]]:
|
||
updated = [copy.deepcopy(s) for s in steps]
|
||
for s in updated:
|
||
if s.get("key") == step_key:
|
||
s["status"] = status
|
||
if detail is not None:
|
||
s["detail"] = detail
|
||
if count is not None:
|
||
s["count"] = count
|
||
if elapsed_ms is not None:
|
||
s["elapsed_ms"] = elapsed_ms
|
||
return updated
|
||
|
||
|
||
async def _commit_progress(
|
||
session: AsyncSession,
|
||
job: SemanticDecomposeJob,
|
||
*,
|
||
phase: str,
|
||
phase_label: str,
|
||
progress: int,
|
||
steps: list[dict[str, Any]],
|
||
) -> None:
|
||
job.status = "running"
|
||
job.phase = phase
|
||
job.phase_label = phase_label
|
||
job.progress = max(0, min(99, int(progress)))
|
||
job.steps = steps
|
||
await session.commit()
|
||
|
||
|
||
def _infer_doc_title(doc_title: str, doc_text: str, source_name: str) -> str:
|
||
if doc_title.strip():
|
||
return doc_title.strip()[:200]
|
||
for line in doc_text.splitlines():
|
||
clean = line.strip().lstrip("#").strip()
|
||
if clean:
|
||
return clean[:120]
|
||
return f"{source_name} 业务指标口径文档"
|
||
|
||
|
||
def _build_schema_prompt_block(
|
||
ds: DataSource,
|
||
tables: Mapping[str, Any],
|
||
selected_tables: Sequence[str],
|
||
entries: Mapping[str, Any],
|
||
masked_set: set[str],
|
||
col_entity: Mapping[tuple[str, str], str],
|
||
overlay_notes: Mapping[str, Mapping[str, Any]] | None = None,
|
||
probe_hints: str = "",
|
||
) -> str:
|
||
lines: list[str] = [f"数据源:{ds.name}(方言:{ds.kind})"]
|
||
if (ds.kind or "").strip().lower() == "oracle":
|
||
lines.append("方言注意:Oracle 严禁写 LIMIT、严禁写 CAST(x AS TEXT)、表或子查询别名不写 AS;动态周期天数请直接用 {{period_days}} 槽位。")
|
||
lines.append("\n【物理表结构与数据目录】")
|
||
for tname in selected_tables:
|
||
tinfo = tables.get(tname) or {}
|
||
base_notes = catalog.visible_notes(entries[tname].notes) if tname in entries else {}
|
||
notes = copy.deepcopy(dict(base_notes))
|
||
if overlay_notes and tname in overlay_notes:
|
||
ov = overlay_notes[tname]
|
||
for k, v in ov.items():
|
||
if k == "columns" and isinstance(v, Mapping):
|
||
cols_dict = dict(notes.get("columns") or {}) if isinstance(notes.get("columns"), Mapping) else {}
|
||
for ck, cv in v.items():
|
||
prev_c = dict(cols_dict.get(ck) or {}) if isinstance(cols_dict.get(ck), Mapping) else {}
|
||
if isinstance(cv, Mapping):
|
||
prev_c.update(cv)
|
||
cols_dict[ck] = prev_c
|
||
notes["columns"] = cols_dict
|
||
else:
|
||
notes[k] = v
|
||
|
||
t_label = (notes.get("label") or {}).get("value") if isinstance(notes.get("label"), Mapping) else ""
|
||
t_grain = (notes.get("grain") or {}).get("value") if isinstance(notes.get("grain"), Mapping) else ""
|
||
b_date_raw = (notes.get("business_date") or {}).get("value") if isinstance(notes.get("business_date"), Mapping) else ""
|
||
if isinstance(b_date_raw, Mapping):
|
||
b_col = str(b_date_raw.get("column") or "").strip()
|
||
b_rule = str(b_date_raw.get("rule") or "").strip()
|
||
b_date = f"{b_col}({b_rule})" if (b_col and b_rule) else (b_col or b_rule)
|
||
else:
|
||
b_date = str(b_date_raw or "").strip()
|
||
v_filt = (notes.get("valid_filter") or {}).get("value") if isinstance(notes.get("valid_filter"), Mapping) else ""
|
||
|
||
head_bits = [f"表 {tname}"]
|
||
qualified_t = str(tinfo.get("qualified") or "").strip()
|
||
if qualified_t and qualified_t != tname:
|
||
head_bits.append(f"SQL 全名:{qualified_t}")
|
||
if t_label:
|
||
head_bits.append(f"中文名:{t_label}")
|
||
if t_grain:
|
||
head_bits.append(f"粒度:{t_grain}")
|
||
if b_date:
|
||
head_bits.append(f"业务日期列:{b_date}")
|
||
if v_filt:
|
||
head_bits.append(f"有效过滤:{v_filt}")
|
||
lines.append("- " + " | ".join(head_bits))
|
||
|
||
col_notes = notes.get("columns") if isinstance(notes.get("columns"), Mapping) else {}
|
||
t_short = name_key(tname.rsplit(".", 1)[-1])
|
||
for col in (tinfo.get("columns") or [])[:90]:
|
||
if not isinstance(col, Mapping) or not col.get("name"):
|
||
continue
|
||
cname = str(col["name"])
|
||
ctype = str(col.get("type") or "")
|
||
if name_key(cname) in masked_set:
|
||
lines.append(f" · {cname} ({ctype}) [遮罩列]")
|
||
continue
|
||
c_comment = str(col.get("comment") or "").strip()
|
||
cn = col_notes.get(cname) if isinstance(col_notes, Mapping) else None
|
||
c_label = (cn.get("label") or {}).get("value") if isinstance(cn, Mapping) and isinstance(cn.get("label"), Mapping) else ""
|
||
c_unit = (cn.get("unit") or {}).get("value") if isinstance(cn, Mapping) and isinstance(cn.get("unit"), Mapping) else ""
|
||
c_measure = (cn.get("measure") or {}).get("value") if isinstance(cn, Mapping) and isinstance(cn.get("measure"), Mapping) else ""
|
||
c_meaning = (
|
||
(cn.get("meaning") or {}).get("value")
|
||
if isinstance(cn, Mapping) and isinstance(cn.get("meaning"), Mapping)
|
||
else ((cn.get("note") or {}).get("value") if isinstance(cn, Mapping) and isinstance(cn.get("note"), Mapping) else "")
|
||
)
|
||
c_codes_raw = (cn.get("codes") or {}).get("value") if isinstance(cn, Mapping) and isinstance(cn.get("codes"), Mapping) else None
|
||
c_codes_str = ""
|
||
if isinstance(c_codes_raw, Mapping) and c_codes_raw:
|
||
code_pairs = [f"{k}={v}" if v else str(k) for k, v in list(c_codes_raw.items())[:16]]
|
||
c_codes_str = f"码值:{'/'.join(code_pairs)}"
|
||
ent_key = col_entity.get((t_short, name_key(cname)), "")
|
||
extra_bits = [
|
||
b
|
||
for b in (
|
||
c_label or c_comment,
|
||
f"单位:{c_unit}" if c_unit else "",
|
||
f"度量:{c_measure}" if c_measure else "",
|
||
f"含义:{c_meaning}" if c_meaning else "",
|
||
c_codes_str,
|
||
f"已绑定标准实体:{ent_key}" if ent_key else "",
|
||
)
|
||
if b
|
||
]
|
||
suffix = f" — {','.join(extra_bits)}" if extra_bits else ""
|
||
lines.append(f" · {cname} ({ctype}){suffix}")
|
||
if probe_hints:
|
||
lines.append(probe_hints)
|
||
return "\n".join(lines)
|
||
|
||
|
||
def _align_catalog_changes_for_table(
|
||
raw_changes: Sequence[Any],
|
||
*,
|
||
tinfo: Mapping[str, Any],
|
||
masked_set: set[str],
|
||
) -> list[dict[str, Any]]:
|
||
"""对齐列路径大小写,过滤敏感遮罩列,避免模型把 ATTRIBUTE01 写成 attribute01 导致落空。"""
|
||
col_exact_by_key: dict[str, str] = {}
|
||
for c in tinfo.get("columns") or []:
|
||
if isinstance(c, Mapping) and c.get("name"):
|
||
cname = str(c["name"])
|
||
col_exact_by_key[name_key(cname)] = cname
|
||
|
||
aligned: list[dict[str, Any]] = []
|
||
for item in raw_changes or []:
|
||
if not isinstance(item, Mapping):
|
||
continue
|
||
raw_path = str(item.get("path") or "").strip()
|
||
if not raw_path or "value" not in item:
|
||
continue
|
||
val = copy.deepcopy(item["value"])
|
||
reason = str(item.get("reason") or "").strip()
|
||
|
||
if raw_path.startswith("columns."):
|
||
parts = raw_path.split(".")
|
||
if len(parts) == 3:
|
||
raw_col, slot_field = parts[1], parts[2]
|
||
if name_key(raw_col) in masked_set:
|
||
continue
|
||
if slot_field in ("note", "description"):
|
||
slot_field = "meaning"
|
||
exact_col = col_exact_by_key.get(name_key(raw_col), raw_col)
|
||
raw_path = f"columns.{exact_col}.{slot_field}"
|
||
# 若模型不小心在 label 里写了等号,自动清洗掉等号右侧以免被 _formula_like 整条丢弃
|
||
if slot_field == "label" and isinstance(val, str) and "=" in val:
|
||
val = val.split("=", 1)[0].strip()
|
||
elif raw_path == "business_date" and isinstance(val, Mapping) and val.get("column"):
|
||
bcol = str(val["column"])
|
||
if name_key(bcol) in masked_set:
|
||
continue
|
||
val = {**dict(val), "column": col_exact_by_key.get(name_key(bcol), bcol)}
|
||
elif raw_path == "business_date" and isinstance(val, str) and val.strip():
|
||
bcol = val.strip()
|
||
if name_key(bcol) in masked_set:
|
||
continue
|
||
val = {"column": col_exact_by_key.get(name_key(bcol), bcol), "rule": ""}
|
||
|
||
aligned.append({"path": raw_path, "value": val, "reason": reason})
|
||
return aligned
|
||
|
||
|
||
async def _execute_job(job_id: str) -> None:
|
||
try:
|
||
async with SessionLocal() as session:
|
||
job = await session.get(SemanticDecomposeJob, job_id)
|
||
if job is None or job.status not in OPEN_STATUSES:
|
||
return
|
||
|
||
steps = list(job.steps or [])
|
||
scopes = list(job.scopes or ["metrics"])
|
||
requested_tables = list((job.result or {}).get("table_names") or [])
|
||
|
||
# ---------------- 阶段 1:准备表结构与归档知识库 ----------------
|
||
t_step = time.perf_counter()
|
||
steps = _update_step_list(steps, "prepare", status="running", detail="正在加载数据源表结构与口径文档")
|
||
await _commit_progress(
|
||
session,
|
||
job,
|
||
phase="preparing",
|
||
phase_label="正在加载数据源结构并整理业务口径文档…",
|
||
progress=12,
|
||
steps=steps,
|
||
)
|
||
|
||
ds = await _resolve_source(session, job.source_id)
|
||
cache = ds.schema_cache if isinstance(ds.schema_cache, Mapping) else {}
|
||
tables = cache.get("tables") if isinstance(cache, Mapping) else {}
|
||
if not isinstance(tables, Mapping) or not tables:
|
||
raise MetricInvalid(f"数据源「{ds.name}」尚未探查表结构")
|
||
|
||
entries = await catalog.read_catalog(session, ds.id)
|
||
masked_set = {name_key(c) for c in masked_columns(ds.options)}
|
||
entities_list = await list_entities(session)
|
||
existing_metrics = await list_metrics(session, source_id=ds.id)
|
||
existing_metric_keys = {m.key for m in existing_metrics}
|
||
|
||
col_entity: dict[tuple[str, str], str] = {}
|
||
for ent in entities_list:
|
||
for b in confirmed_bindings(ent):
|
||
if name_key(str(b.get("source_name") or "")) == name_key(ds.name):
|
||
t_short = name_key(str(b.get("table_name") or "").rsplit(".", 1)[-1])
|
||
c_key = name_key(str(b.get("column_name") or ""))
|
||
col_entity[(t_short, c_key)] = ent.key
|
||
|
||
selected_tables: list[str] = []
|
||
if requested_tables:
|
||
for raw_t in requested_tables:
|
||
resolved = catalog.resolve_table_name(cache, str(raw_t))
|
||
if resolved and resolved not in selected_tables:
|
||
selected_tables.append(resolved)
|
||
if not selected_tables:
|
||
def _table_priority(tname: str) -> tuple[int, str]:
|
||
has_cat = 0 if (tname in entries and catalog.visible_notes(entries[tname].notes)) else 1
|
||
return (has_cat, tname)
|
||
|
||
selected_tables = sorted(tables.keys(), key=_table_priority)[:32]
|
||
|
||
# 组装文档内容,并按需把粘贴文本归档入知识库
|
||
kb_documents: list[dict[str, Any]] = []
|
||
doc_sections: list[str] = []
|
||
doc_budget = MAX_DOC_CHARS
|
||
|
||
pasted_text = (job.doc_text or "").strip()
|
||
if pasted_text:
|
||
inferred_title = _infer_doc_title(job.doc_title or "", pasted_text, ds.name)
|
||
created_doc_id: str | None = None
|
||
if job.save_to_kb:
|
||
existing_doc = (
|
||
await session.execute(
|
||
select(Document).where(Document.title == inferred_title).order_by(Document.created_at.desc()).limit(1)
|
||
)
|
||
).scalars().first()
|
||
if existing_doc is not None and (existing_doc.content or "").strip() == pasted_text:
|
||
created_doc_id = existing_doc.id
|
||
kb_documents.append({"id": existing_doc.id, "title": inferred_title, "created_now": False})
|
||
else:
|
||
new_doc = await kb.create_document(
|
||
session,
|
||
collection="default",
|
||
title=inferred_title,
|
||
content=pasted_text,
|
||
source=inferred_title,
|
||
mime="text/markdown",
|
||
meta={"origin": "semantic_decompose", "source_id": ds.id},
|
||
status="processing",
|
||
)
|
||
await session.flush()
|
||
try:
|
||
await kb.process_document(session, new_doc)
|
||
except Exception as kb_exc: # noqa: BLE001
|
||
logger.warning("知识库文档切块索引降级(%s):%s", new_doc.id, kb_exc)
|
||
new_doc.status = "ready"
|
||
created_doc_id = new_doc.id
|
||
kb_documents.append({"id": new_doc.id, "title": inferred_title, "created_now": True})
|
||
|
||
snippet = pasted_text[: min(len(pasted_text), doc_budget)]
|
||
doc_budget -= len(snippet)
|
||
doc_sections.append(f"--- 文档《{inferred_title}》 ---\n{snippet}")
|
||
if created_doc_id and created_doc_id not in (job.document_ids or []):
|
||
job.document_ids = [*(job.document_ids or []), created_doc_id]
|
||
|
||
bundles = await entity_docs.load_doc_bundles(
|
||
session,
|
||
attachment_ids=job.attachment_ids or [],
|
||
document_ids=job.document_ids or [],
|
||
)
|
||
seen_kb_ids = {d["id"] for d in kb_documents}
|
||
for bundle in bundles:
|
||
if bundle.document_id and bundle.document_id in seen_kb_ids:
|
||
continue
|
||
joined = "\n\n".join(bundle.chunks).strip()
|
||
if not joined or doc_budget <= 0:
|
||
continue
|
||
if bundle.kind == "attachment" and job.save_to_kb and not bundle.document_id:
|
||
att_doc = await kb.create_document(
|
||
session,
|
||
collection="default",
|
||
title=bundle.title,
|
||
content=joined,
|
||
source=bundle.title,
|
||
mime=bundle.mime,
|
||
meta={"origin": "semantic_decompose", "source_id": ds.id},
|
||
status="processing",
|
||
)
|
||
await session.flush()
|
||
try:
|
||
await kb.process_document(session, att_doc)
|
||
except Exception as kb_exc: # noqa: BLE001
|
||
logger.warning("附件文档切块索引降级(%s):%s", att_doc.id, kb_exc)
|
||
att_doc.status = "ready"
|
||
kb_documents.append({"id": att_doc.id, "title": bundle.title, "created_now": True})
|
||
seen_kb_ids.add(att_doc.id)
|
||
elif bundle.document_id and bundle.document_id not in seen_kb_ids:
|
||
kb_documents.append({"id": bundle.document_id, "title": bundle.title, "created_now": False})
|
||
seen_kb_ids.add(bundle.document_id)
|
||
|
||
snippet = joined[: min(len(joined), doc_budget)]
|
||
doc_budget -= len(snippet)
|
||
doc_sections.append(f"--- 文档《{bundle.title}》 ---\n{snippet}")
|
||
|
||
raw_notes_map = {t: e.notes for t, e in entries.items()}
|
||
visible_by_table = {t: catalog.visible_notes(e.notes) for t, e in entries.items()}
|
||
rel_graph = catalog.relation_graph(raw_notes_map, schema_cache=cache)
|
||
probe_hints = await probe_schema_lookup_and_discriminators(
|
||
ds,
|
||
tables,
|
||
selected_tables,
|
||
visible_by_table=visible_by_table,
|
||
rel_graph=rel_graph,
|
||
masked_set=masked_set,
|
||
bound_entity_cols=set(col_entity.keys()),
|
||
)
|
||
|
||
prep_detail = f"已扫描 {len(selected_tables)} 张物理表"
|
||
if probe_hints:
|
||
prep_detail += ",实探关联配置维表与分类取值"
|
||
if doc_sections:
|
||
prep_detail += f",载入 {len(doc_sections)} 份口径文档"
|
||
if any(d.get("created_now") for d in kb_documents):
|
||
prep_detail += "并同步归档至知识库"
|
||
steps = _update_step_list(
|
||
steps,
|
||
"prepare",
|
||
status="succeeded",
|
||
detail=prep_detail,
|
||
count=len(selected_tables),
|
||
elapsed_ms=int((time.perf_counter() - t_step) * 1000),
|
||
)
|
||
await _commit_progress(
|
||
session,
|
||
job,
|
||
phase="prepared",
|
||
phase_label=prep_detail,
|
||
progress=22,
|
||
steps=steps,
|
||
)
|
||
|
||
model, model_id = await catalog_assistant.resolve_model(session)
|
||
|
||
# ---------------- 阶段 2:拆解字段符号映射与表目录补丁 ----------------
|
||
catalog_patches: list[dict[str, Any]] = []
|
||
catalog_skipped: list[str] = []
|
||
guardrail_notes: list[dict[str, str]] = []
|
||
overlay_notes: dict[str, dict[str, Any]] = {}
|
||
|
||
if "catalog" in scopes:
|
||
t_cat = time.perf_counter()
|
||
if not doc_sections and not (job.note or "").strip() and not probe_hints:
|
||
steps = _update_step_list(
|
||
steps,
|
||
"catalog",
|
||
status="skipped",
|
||
detail="未提供业务口径文档,跳过字段目录拆解",
|
||
count=0,
|
||
elapsed_ms=0,
|
||
)
|
||
else:
|
||
steps = _update_step_list(
|
||
steps,
|
||
"catalog",
|
||
status="running",
|
||
detail="正在识别字段符号映射、单位、流量或存量角色与存储陷阱",
|
||
)
|
||
await _commit_progress(
|
||
session,
|
||
job,
|
||
phase="extracting_catalog",
|
||
phase_label="正在拆解字段符号映射与数据目录补丁(1/3)…",
|
||
progress=34,
|
||
steps=steps,
|
||
)
|
||
schema_block = _build_schema_prompt_block(
|
||
ds, tables, selected_tables, entries, masked_set, col_entity, probe_hints=probe_hints
|
||
)
|
||
cat_prompt_parts = [schema_block]
|
||
if doc_sections:
|
||
cat_prompt_parts.append("\n【业务口径文档】\n" + "\n\n".join(doc_sections))
|
||
if job.note:
|
||
cat_prompt_parts.append(f"\n【用户补充要求】\n{job.note}")
|
||
|
||
cat_reply = await model.ainvoke(
|
||
[
|
||
SystemMessage(content=CATALOG_DECOMPOSE_SYSTEM_PROMPT),
|
||
HumanMessage(content="\n".join(cat_prompt_parts)),
|
||
]
|
||
)
|
||
cat_json = _parse_json_obj(message_text(cat_reply))
|
||
|
||
for g in cat_json.get("guardrail_notes") or []:
|
||
if isinstance(g, Mapping) and str(g.get("rule") or "").strip():
|
||
guardrail_notes.append(
|
||
{
|
||
"title": str(g.get("title") or "口径规则提醒").strip()[:120],
|
||
"rule": str(g.get("rule") or "").strip()[:600],
|
||
"category": str(g.get("category") or "redline").strip()[:32],
|
||
}
|
||
)
|
||
|
||
total_cat_changes = 0
|
||
for tbl_item in cat_json.get("tables") or []:
|
||
if not isinstance(tbl_item, Mapping):
|
||
continue
|
||
raw_tbl = str(tbl_item.get("table") or "").strip()
|
||
resolved_tbl = catalog.resolve_table_name(cache, raw_tbl)
|
||
if not resolved_tbl:
|
||
if raw_tbl:
|
||
catalog_skipped.append(f"跳过不存在的表「{raw_tbl}」")
|
||
continue
|
||
tinfo = tables.get(resolved_tbl) or {}
|
||
aligned_changes = _align_catalog_changes_for_table(
|
||
tbl_item.get("changes") or [],
|
||
tinfo=tinfo,
|
||
masked_set=masked_set,
|
||
)
|
||
if not aligned_changes:
|
||
continue
|
||
entry = entries.get(resolved_tbl)
|
||
plan = catalog.plan_patch(
|
||
entry.notes if entry else {},
|
||
aligned_changes,
|
||
table=resolved_tbl,
|
||
tables=tables,
|
||
)
|
||
catalog_skipped.extend(f"{resolved_tbl}:{p}" for p in plan.problems)
|
||
active_changes = [c for c in plan.changes if c.state != "same"]
|
||
if not active_changes:
|
||
continue
|
||
|
||
# 将通过核验的目录补丁写入内存覆盖层,直接供给下一阶段指标提取!
|
||
tbl_ov = overlay_notes.setdefault(resolved_tbl, {"columns": {}})
|
||
serialized_changes: list[dict[str, Any]] = []
|
||
for c_idx, ch in enumerate(active_changes):
|
||
slot = catalog.parse_path(ch.path)
|
||
slot_label = catalog.describe_slot(slot) if slot[0] != "r" else "关联关系"
|
||
serialized_changes.append(
|
||
{
|
||
"id": f"{resolved_tbl}:{ch.path}:{c_idx}",
|
||
"table": resolved_tbl,
|
||
"path": ch.path,
|
||
"slot_label": slot_label,
|
||
"before": ch.before,
|
||
"after": ch.after,
|
||
"value": ch.value,
|
||
"reason": ch.reason,
|
||
"state": ch.state,
|
||
}
|
||
)
|
||
if slot[0] == "t":
|
||
tbl_ov[slot[1]] = {"value": ch.after}
|
||
elif slot[0] == "c":
|
||
col_ov = tbl_ov["columns"].setdefault(slot[1], {})
|
||
col_ov[slot[2]] = {"value": ch.after}
|
||
|
||
total_cat_changes += len(serialized_changes)
|
||
catalog_patches.append(
|
||
{
|
||
"table": resolved_tbl,
|
||
"if_version": int(entry.version) if entry else 0,
|
||
"changes": serialized_changes,
|
||
}
|
||
)
|
||
|
||
steps = _update_step_list(
|
||
steps,
|
||
"catalog",
|
||
status="succeeded",
|
||
detail=f"提取出 {total_cat_changes} 项字段与表目录补丁"
|
||
+ (f"、{len(guardrail_notes)} 条判异红线" if guardrail_notes else ""),
|
||
count=total_cat_changes,
|
||
elapsed_ms=int((time.perf_counter() - t_cat) * 1000),
|
||
)
|
||
await _commit_progress(
|
||
session,
|
||
job,
|
||
phase="catalog_done",
|
||
phase_label="字段目录拆解完成,准备提取语义指标口径…",
|
||
progress=52,
|
||
steps=steps,
|
||
)
|
||
|
||
# ---------------- 阶段 3:提取单表与多表参数化指标口径 ----------------
|
||
metric_candidates: list[dict[str, Any]] = []
|
||
metric_skipped: list[dict[str, str]] = []
|
||
|
||
if "metrics" in scopes:
|
||
t_met = time.perf_counter()
|
||
steps = _update_step_list(
|
||
steps,
|
||
"metrics",
|
||
status="running",
|
||
detail="正在结合字段目录映射生成单表聚合与多表模板指标并预编译校验",
|
||
)
|
||
await _commit_progress(
|
||
session,
|
||
job,
|
||
phase="extracting_metrics",
|
||
phase_label="正在生成单表与多表参数化 SQL 指标口径并逐条校验…",
|
||
progress=64,
|
||
steps=steps,
|
||
)
|
||
|
||
enriched_schema_block = _build_schema_prompt_block(
|
||
ds,
|
||
tables,
|
||
selected_tables,
|
||
entries,
|
||
masked_set,
|
||
col_entity,
|
||
overlay_notes=overlay_notes,
|
||
probe_hints=probe_hints,
|
||
)
|
||
met_lines = [enriched_schema_block]
|
||
if existing_metric_keys:
|
||
met_lines.append(f"\n已存在的指标标识(若口径需要升级可沿用并更新,否则请勿无故重复):{', '.join(sorted(existing_metric_keys))}")
|
||
if doc_sections:
|
||
met_lines.append("\n【参考业务口径文档】\n" + "\n\n".join(doc_sections))
|
||
if job.note:
|
||
met_lines.append(f"\n【用户补充要求】\n{job.note}")
|
||
|
||
met_reply = await model.ainvoke(
|
||
[
|
||
SystemMessage(content=SUGGEST_SYSTEM_PROMPT),
|
||
HumanMessage(content="\n".join(met_lines)),
|
||
]
|
||
)
|
||
raw_candidates = _parse_suggested_metrics(message_text(met_reply))
|
||
seen_batch_keys: set[str] = set()
|
||
for raw in raw_candidates:
|
||
candidate = {**raw, "source_id": ds.id, "source": "ai", "status": "enabled"}
|
||
raw_k = str(candidate.get("key") or "").strip().lower()
|
||
if raw_k and raw_k in seen_batch_keys:
|
||
metric_skipped.append({"key": raw_k, "reason": "同批候选指标标识重复"})
|
||
continue
|
||
try:
|
||
_, norm = await _normalize_metric_payload(session, candidate, existing=None)
|
||
seen_batch_keys.add(norm["key"])
|
||
metric_candidates.append(
|
||
{
|
||
**norm,
|
||
"source_name": ds.name,
|
||
"exists_in_db": norm["key"] in existing_metric_keys,
|
||
}
|
||
)
|
||
except Exception as exc: # noqa: BLE001
|
||
metric_skipped.append(
|
||
{"key": raw_k or str(candidate.get("name") or "未命名指标"), "reason": str(exc)}
|
||
)
|
||
|
||
tpl_cnt = sum(1 for m in metric_candidates if m.get("calc_mode") == "sql_template")
|
||
expr_cnt = len(metric_candidates) - tpl_cnt
|
||
met_detail = f"核验通过 {len(metric_candidates)} 个指标(单表 {expr_cnt} 个、多表模板 {tpl_cnt} 个)"
|
||
steps = _update_step_list(
|
||
steps,
|
||
"metrics",
|
||
status="succeeded",
|
||
detail=met_detail,
|
||
count=len(metric_candidates),
|
||
elapsed_ms=int((time.perf_counter() - t_met) * 1000),
|
||
)
|
||
await _commit_progress(
|
||
session,
|
||
job,
|
||
phase="metrics_done",
|
||
phase_label=met_detail,
|
||
progress=82,
|
||
steps=steps,
|
||
)
|
||
|
||
# ---------------- 阶段 4:识别标准业务实体与编码映射 ----------------
|
||
entity_changes: list[dict[str, Any]] = []
|
||
entity_skipped: list[str] = []
|
||
|
||
if "entities" in scopes:
|
||
t_ent = time.perf_counter()
|
||
steps = _update_step_list(
|
||
steps,
|
||
"entities",
|
||
status="running",
|
||
detail="正在识别共用业务实体维度与表列绑定",
|
||
)
|
||
await _commit_progress(
|
||
session,
|
||
job,
|
||
phase="extracting_entities",
|
||
phase_label="正在识别标准业务实体与物理列编码映射…",
|
||
progress=88,
|
||
steps=steps,
|
||
)
|
||
enriched_schema_block = _build_schema_prompt_block(
|
||
ds,
|
||
tables,
|
||
selected_tables,
|
||
entries,
|
||
masked_set,
|
||
col_entity,
|
||
overlay_notes=overlay_notes,
|
||
probe_hints=probe_hints,
|
||
)
|
||
ent_lines = [enriched_schema_block]
|
||
if entities_list:
|
||
ent_summary = "、".join(f"{e.key}({e.label})" for e in entities_list[:30])
|
||
ent_lines.append(f"\n已有标准业务实体:{ent_summary}")
|
||
if doc_sections:
|
||
ent_lines.append("\n【业务口径文档】\n" + "\n\n".join(doc_sections))
|
||
if job.note:
|
||
ent_lines.append(f"\n【用户补充要求】\n{job.note}")
|
||
|
||
ent_reply = await model.ainvoke(
|
||
[
|
||
SystemMessage(content=ENTITY_DECOMPOSE_SYSTEM_PROMPT),
|
||
HumanMessage(content="\n".join(ent_lines)),
|
||
]
|
||
)
|
||
ent_json = _parse_json_obj(message_text(ent_reply))
|
||
raw_ops = ent_json.get("operations") if isinstance(ent_json.get("operations"), list) else []
|
||
|
||
safe_ops: list[dict[str, Any]] = []
|
||
for op_item in raw_ops:
|
||
if not isinstance(op_item, Mapping):
|
||
continue
|
||
op_copy = dict(op_item)
|
||
if op_copy.get("op") == "upsert_binding":
|
||
if not op_copy.get("source_name"):
|
||
op_copy["source_name"] = ds.name
|
||
col_candidate = str(op_copy.get("column_name") or "").strip()
|
||
if name_key(col_candidate) in masked_set:
|
||
entity_skipped.append(f"跳过敏感遮罩列绑定「{col_candidate}」")
|
||
continue
|
||
safe_ops.append(op_copy)
|
||
|
||
if safe_ops:
|
||
ent_plan = await entity_patch.plan_entity_patch(session, safe_ops, mode="confirmed")
|
||
entity_changes = [ch.as_dict() for ch in ent_plan.changes]
|
||
entity_skipped.extend(ent_plan.problems)
|
||
|
||
steps = _update_step_list(
|
||
steps,
|
||
"entities",
|
||
status="succeeded",
|
||
detail=f"识别出 {len(entity_changes)} 项标准实体与绑定变更",
|
||
count=len(entity_changes),
|
||
elapsed_ms=int((time.perf_counter() - t_ent) * 1000),
|
||
)
|
||
|
||
# ---------------- 完成汇总 ----------------
|
||
catalog_change_count = sum(len(t.get("changes") or []) for t in catalog_patches)
|
||
summary = {
|
||
"model_id": model_id,
|
||
"kb_synced_count": sum(1 for d in kb_documents if d.get("created_now")),
|
||
"kb_doc_count": len(kb_documents),
|
||
"catalog_table_count": len(catalog_patches),
|
||
"catalog_change_count": catalog_change_count,
|
||
"metric_count": len(metric_candidates),
|
||
"metric_template_count": sum(1 for m in metric_candidates if m.get("calc_mode") == "sql_template"),
|
||
"metric_skipped_count": len(metric_skipped),
|
||
"entity_change_count": len(entity_changes),
|
||
"guardrail_count": len(guardrail_notes),
|
||
}
|
||
job.status = "succeeded"
|
||
job.phase = "done"
|
||
job.phase_label = (
|
||
f"拆解完成:{len(metric_candidates)} 个指标"
|
||
+ (f"、{catalog_change_count} 项字段目录补丁" if catalog_change_count else "")
|
||
+ (f"、{len(entity_changes)} 项实体映射" if entity_changes else "")
|
||
)
|
||
job.progress = 100
|
||
job.steps = steps
|
||
job.summary = summary
|
||
job.result = {
|
||
"model_id": model_id,
|
||
"kb_documents": kb_documents,
|
||
"guardrail_notes": guardrail_notes,
|
||
"catalog_patches": catalog_patches,
|
||
"catalog_skipped": catalog_skipped,
|
||
"metric_candidates": metric_candidates,
|
||
"metric_skipped": metric_skipped,
|
||
"entity_changes": entity_changes,
|
||
"entity_skipped": entity_skipped,
|
||
"applied": None,
|
||
}
|
||
job.finished_at = utcnow()
|
||
await session.commit()
|
||
|
||
except asyncio.CancelledError:
|
||
async with SessionLocal() as session:
|
||
job = await session.get(SemanticDecomposeJob, job_id)
|
||
if job is not None and job.status in OPEN_STATUSES:
|
||
job.status = "cancelled"
|
||
job.phase = "cancelled"
|
||
job.phase_label = "已取消后台拆解任务"
|
||
job.finished_at = utcnow()
|
||
await session.commit()
|
||
raise
|
||
except Exception as exc: # noqa: BLE001
|
||
logger.warning("口径拆解任务执行失败(%s):%s", job_id, exc, exc_info=True)
|
||
async with SessionLocal() as session:
|
||
job = await session.get(SemanticDecomposeJob, job_id)
|
||
if job is not None and job.status in OPEN_STATUSES:
|
||
steps = [
|
||
{**s, "status": "failed", "detail": first_line(exc) or describe_exception(exc)}
|
||
if s.get("status") == "running"
|
||
else s
|
||
for s in (job.steps or [])
|
||
]
|
||
job.status = "failed"
|
||
job.phase = "failed"
|
||
job.phase_label = "拆解任务执行失败"
|
||
job.steps = steps
|
||
job.error = JOB_FAILED_TEXT.format(reason=first_line(exc) or describe_exception(exc))
|
||
job.finished_at = utcnow()
|
||
await session.commit()
|
||
|
||
|
||
async def cancel_decompose_job(session: AsyncSession, job_id: str) -> SemanticDecomposeJob:
|
||
job = await get_job(session, job_id)
|
||
if job.status not in OPEN_STATUSES:
|
||
raise DecomposeFinished("该口径拆解任务已经结束")
|
||
task = _ACTIVE_TASKS.get(job.id)
|
||
if task is not None and not task.done():
|
||
task.cancel()
|
||
with contextlib.suppress(asyncio.CancelledError, Exception):
|
||
await asyncio.wait_for(asyncio.shield(task), timeout=2.0)
|
||
await session.refresh(job)
|
||
if job.status in OPEN_STATUSES:
|
||
job.status = "cancelled"
|
||
job.phase = "cancelled"
|
||
job.phase_label = "已取消后台拆解任务"
|
||
job.finished_at = utcnow()
|
||
await session.commit()
|
||
await session.refresh(job)
|
||
return job
|
||
|
||
|
||
async def apply_decompose_job(
|
||
session: AsyncSession,
|
||
job_id: str,
|
||
*,
|
||
catalog_patches: Sequence[Mapping[str, Any]] | None = None,
|
||
metrics: Sequence[Mapping[str, Any]] | None = None,
|
||
entity_operations: Sequence[Mapping[str, Any]] | None = None,
|
||
overwrite_existing_metrics: bool = True,
|
||
actor: str | None = None,
|
||
) -> dict[str, Any]:
|
||
"""一键应用用户在审阅抽屉中勾选的「字段目录补丁 + 语义指标口径 + 标准业务实体」。"""
|
||
job = await get_job(session, job_id)
|
||
if job.status != "succeeded":
|
||
raise MetricInvalid("仅已完成的拆解任务支持一键应用")
|
||
|
||
ds = await _resolve_source(session, job.source_id)
|
||
cache = ds.schema_cache if isinstance(ds.schema_cache, Mapping) else {}
|
||
tables = cache.get("tables") if isinstance(cache, Mapping) else {}
|
||
|
||
res = dict(job.result or {})
|
||
selected_cat = (
|
||
list(catalog_patches)
|
||
if catalog_patches is not None
|
||
else [
|
||
{
|
||
"table": t["table"],
|
||
"changes": [
|
||
{"path": c["path"], "value": c["value"], "reason": c.get("reason", "")}
|
||
for c in (t.get("changes") or [])
|
||
],
|
||
}
|
||
for t in (res.get("catalog_patches") or [])
|
||
]
|
||
)
|
||
selected_metrics = list(metrics) if metrics is not None else list(res.get("metric_candidates") or [])
|
||
selected_ent_ops = (
|
||
list(entity_operations)
|
||
if entity_operations is not None
|
||
else [c["op_payload"] for c in (res.get("entity_changes") or []) if isinstance(c, Mapping) and c.get("op_payload")]
|
||
)
|
||
|
||
catalog_applied_count = 0
|
||
catalog_errors: list[str] = []
|
||
pending_after_reviews: list[tuple[str, str, Any, Any]] = []
|
||
for tbl_patch in selected_cat:
|
||
tbl_name = str(tbl_patch.get("table") or "").strip()
|
||
changes = list(tbl_patch.get("changes") or [])
|
||
if not tbl_name or not changes:
|
||
continue
|
||
resolved_tbl = catalog.resolve_table_name(cache, tbl_name) or tbl_name
|
||
try:
|
||
async with session.begin_nested():
|
||
entry = await catalog.read_entry(session, ds.id, resolved_tbl)
|
||
cur_ver = int(entry.version) if entry else 0
|
||
new_notes = catalog.apply_patch(
|
||
entry.notes if entry else {},
|
||
changes,
|
||
table=resolved_tbl,
|
||
tables=tables if isinstance(tables, Mapping) else None,
|
||
)
|
||
written = await catalog.write_entry(
|
||
session, ds.id, resolved_tbl, new_notes, if_version=cur_ver, actor=actor, commit=False
|
||
)
|
||
pending_after_reviews.append((ds.id, resolved_tbl, entry.notes if entry else None, written))
|
||
catalog_applied_count += len(changes)
|
||
except Exception as exc: # noqa: BLE001
|
||
catalog_errors.append(f"{resolved_tbl}:{exc}")
|
||
|
||
created_metrics: list[dict[str, Any]] = []
|
||
updated_metrics: list[dict[str, Any]] = []
|
||
metric_errors: list[dict[str, str]] = []
|
||
for m_item in selected_metrics:
|
||
payload = {**dict(m_item), "source_id": ds.id}
|
||
payload.pop("exists_in_db", None)
|
||
payload.pop("source_name", None)
|
||
m_key = str(payload.get("key") or "").strip().lower()
|
||
try:
|
||
async with session.begin_nested():
|
||
existing_row = (
|
||
await session.execute(select(SemanticMetric).where(SemanticMetric.key == m_key))
|
||
).scalar_one_or_none()
|
||
if existing_row is not None:
|
||
if overwrite_existing_metrics:
|
||
row, _ = await update_metric(session, existing_row.id, payload, actor=actor)
|
||
updated_metrics.append(metric_to_dict(row, source_name=ds.name))
|
||
else:
|
||
metric_errors.append({"key": m_key, "reason": f"指标「{m_key}」已存在,已跳过覆盖"})
|
||
else:
|
||
row, _ = await create_metric(session, payload, actor=actor)
|
||
created_metrics.append(metric_to_dict(row, source_name=ds.name))
|
||
except (MetricConflict, MetricInvalid, MetricNotFound, Exception) as exc: # noqa: BLE001
|
||
metric_errors.append({"key": m_key or "unknown", "reason": str(exc)})
|
||
|
||
entity_apply_summary: dict[str, Any] = {"applied_count": 0, "problems": []}
|
||
if selected_ent_ops:
|
||
try:
|
||
async with session.begin_nested():
|
||
entity_apply_summary = await entity_patch.apply_entity_patch(
|
||
session,
|
||
selected_ent_ops,
|
||
mode="confirmed",
|
||
actor=actor,
|
||
)
|
||
except Exception as exc: # noqa: BLE001
|
||
entity_apply_summary = {"applied_count": 0, "problems": [str(exc)]}
|
||
|
||
applied_record = {
|
||
"applied_at": _iso(utcnow()),
|
||
"applied_by": actor,
|
||
"catalog_applied_count": catalog_applied_count,
|
||
"catalog_errors": catalog_errors,
|
||
"metrics_created_count": len(created_metrics),
|
||
"metrics_updated_count": len(updated_metrics),
|
||
"metric_errors": metric_errors,
|
||
"entities_applied_count": int(entity_apply_summary.get("applied_count") or 0),
|
||
"entity_errors": list(entity_apply_summary.get("problems") or []),
|
||
}
|
||
res["applied"] = applied_record
|
||
job.result = res
|
||
job.summary = {**dict(job.summary or {}), "applied": True, "applied_at": applied_record["applied_at"]}
|
||
await session.commit()
|
||
|
||
for rev_sid, rev_tbl, rev_before, rev_written in pending_after_reviews:
|
||
catalog.after_review(rev_sid, rev_tbl, rev_before, rev_written)
|
||
|
||
return {
|
||
"job_id": job.id,
|
||
"applied": applied_record,
|
||
"created_metrics": created_metrics,
|
||
"updated_metrics": updated_metrics,
|
||
}
|
||
|
||
|
||
async def startup(*, live: Any = None) -> int:
|
||
"""服务启动或清理孤儿任务时,将上次残留未完成的拆解任务标记为 failed(中断说明)。"""
|
||
_ = live
|
||
running_ids = set(_ACTIVE_TASKS.keys())
|
||
async with SessionLocal() as session:
|
||
rows = list(
|
||
(
|
||
await session.execute(
|
||
select(SemanticDecomposeJob.id).where(SemanticDecomposeJob.status.in_(OPEN_STATUSES))
|
||
)
|
||
).scalars()
|
||
)
|
||
stale_ids = [jid for jid in rows if jid not in running_ids]
|
||
if stale_ids:
|
||
await session.execute(
|
||
update(SemanticDecomposeJob)
|
||
.where(SemanticDecomposeJob.id.in_(stale_ids), SemanticDecomposeJob.status.in_(OPEN_STATUSES))
|
||
.values(
|
||
status="failed",
|
||
phase="interrupted",
|
||
phase_label="服务重启导致任务中断",
|
||
error=INTERRUPTED_ERROR,
|
||
finished_at=utcnow(),
|
||
)
|
||
)
|
||
await session.commit()
|
||
return len(stale_ids)
|
||
|
||
|
||
async def shutdown() -> None:
|
||
"""服务关停时取消进程内正在执行的拆解任务。"""
|
||
tasks = list(_ACTIVE_TASKS.values())
|
||
for task in tasks:
|
||
if not task.done():
|
||
task.cancel()
|
||
for task in tasks:
|
||
with contextlib.suppress(asyncio.CancelledError, Exception):
|
||
await task
|
||
await startup()
|