蓝图「看哪些数」对到的数据源和表要真的存在:卡片上照实说是哪一项、怎么改(bp.data_source_unknown / bp.data_table_unknown,带白话),挡住试运行和发布单,助手自查也拿得到;填报登记里本应用的记录表写 @records、表名固定,提示词和模板说明写明这个约定;「对到的表」改成从已接入的数据源和表里选,填报登记多一项「本应用的记录表」,卡片上照白话显示(以前要到试运行才报「绑定的工具不存在」,用户找不到在哪改)

This commit is contained in:
ThearchyHelios
2026-10-15 14:32:25 +08:00
parent 7739340607
commit be845bb823
14 changed files with 329 additions and 36 deletions
+11 -11
View File
@@ -77,12 +77,12 @@ async def _blueprint(session: AsyncSession, blueprint_id: str) -> Blueprint:
return await load(session, blueprint_id)
def blueprint_out(bp: Blueprint) -> dict[str, Any]:
"""接口形状(frontend/src/typesBlueprint.ts 的 Blueprint,blueprints/store.blueprint_out):图本身不下发(只给哈希),
关节只给状态,问题带白话。"""
from app.blueprints.store import blueprint_out as shaped
async def blueprint_out(session: AsyncSession, bp: Blueprint) -> dict[str, Any]:
"""接口形状(frontend/src/typesBlueprint.ts 的 Blueprint,blueprints/store.blueprint_view):图本身不下发(只给哈希),
关节只给状态,问题带白话,含要查库的检查(对到的数据源和表在不在)。"""
from app.blueprints.store import blueprint_view
return shaped(bp)
return await blueprint_view(session, bp)
def _refused(e: Any) -> HTTPException:
@@ -180,7 +180,7 @@ async def create_blueprint(payload: BlueprintIn, session: AsyncSession = Depends
await record_event(session, bp, "create", {"source": bp.source, "scope": bp.scope}, principal=principal)
await session.commit()
await session.refresh(bp)
return blueprint_out(bp)
return await blueprint_out(session, bp)
def _template_spec(spec: dict[str, Any], template_id: str | None) -> dict[str, Any]:
@@ -273,7 +273,7 @@ async def list_blueprints(scope: Literal["shared", "personal"] | None = None,
rows += list((await session.execute(select(Blueprint).where(mine).order_by(Blueprint.updated_at.desc())
.limit(_LIST_MAX))).scalars())
rows.sort(key=lambda bp: bp.updated_at or bp.created_at, reverse=True)
return {"items": [blueprint_out(bp) for bp in rows[:_LIST_MAX]]}
return {"items": [await blueprint_out(session, bp) for bp in rows[:_LIST_MAX]]}
@router.get("/blueprints/{blueprint_id}")
@@ -282,7 +282,7 @@ async def get_blueprint(blueprint_id: str, session: AsyncSession = Depends(get_s
from app.blueprints.trial import settle
bp = await _blueprint(session, blueprint_id)
return blueprint_out(await settle(session, bp))
return await blueprint_out(session, await settle(session, bp))
@router.get("/blueprints/{blueprint_id}/conversation")
@@ -331,7 +331,7 @@ async def patch_blueprint(blueprint_id: str, payload: BlueprintPatch, session: A
raise CodedHTTPException(422, e.message, e.code) from e
await session.commit()
await session.refresh(bp)
return blueprint_out(bp)
return await blueprint_out(session, bp)
# --------------------------------------------------------------------------
@@ -351,7 +351,7 @@ async def instantiate_blueprint(blueprint_id: str, session: AsyncSession = Depen
result = await materialize(session, bp, principal=principal)
except TrialRefused as e:
raise _refused(e) from e
return {"blueprint": blueprint_out(bp), "app_spec": result.app_spec, "problems": result.problems}
return {"blueprint": await blueprint_out(session, bp), "app_spec": result.app_spec, "problems": result.problems}
class TrialIn(BaseModel):
@@ -376,7 +376,7 @@ async def trial(blueprint_id: str, payload: TrialIn, session: AsyncSession = Dep
raise _refused(e) from e
except LaunchRefused as e:
raise _refused(e) from e
return {"run_id": run.id, "blueprint": blueprint_out(bp), "inputs": dict(run.input or {})}
return {"run_id": run.id, "blueprint": await blueprint_out(session, bp), "inputs": dict(run.input or {})}
@router.get("/blueprints/{blueprint_id}/preview")
+5 -4
View File
@@ -48,7 +48,8 @@ BLUEPRINT_SYSTEM_PROMPT = """\
- 场景:用 bp_template 选模板(见下面的模板清单),选了之后模板会预置几项默认值
- /data 看哪些数,每一项:{"id":"gmv","label":"销售额","unit":"元","table":{"source":"库名","table":"表名"},
"measure":"怎么算(白话或简单聚合)","date":{"column":"业务日期的列","rule":"按支付日"},"filter":"筛选条件","grain":"粒度"}
对到已发布的口径卡时写 "reuse"(照抄 look 给的复用写法),不写 table
对到已发布的口径卡时写 "reuse"(照抄 look 给的复用写法),不写 table。table 的 source 只能是上面概览里列出的数据源名,
不要自己起名;填报登记汇总这个应用自己收上来的记录时,写 {"source":"@records","table":"records"}(记录表建好后系统换成真名)
- /cadence 多久更新:{"preset":"daily|weekly|monthly","weekday":1,"day":1,"time":"09:00","tz":"Asia/Shanghai",
"period":"yesterday|last_iso_week|last_month|today|this_iso_week|this_month"}(weekly 写 weekday,1 是周一;monthly 写 day)
- /people 谁来做,每一步:{"id":"finance","label":"财务会签","who":[{"kind":"user|group|slot|initiator","name":"…"}],
@@ -298,7 +299,7 @@ async def blueprint_stream(payload: BlueprintStreamIn, session: AsyncSession = D
issues=BLUEPRINT_ISSUES_PROMPT.format(lines="\n".join(f"- {i['path']} {i['message']}" for i in missing))
if missing else ""), history)
applier = BlueprintApplier(spec, calibers=calibers)
checker = BlueprintChecker(applier)
checker = BlueprintChecker(applier, template_id=row.template_id)
started = time.monotonic()
gen = copilot.Generation(op_model, system=system, applier=applier, checker=checker, looks=looks, started=started,
look_limit=BLUEPRINT_LOOK_LIMIT)
@@ -329,7 +330,7 @@ async def blueprint_stream(payload: BlueprintStreamIn, session: AsyncSession = D
target = await own.get(Blueprint, blueprint_id)
saved = await store.apply_ops_and_save(own, target, [*applier.effective, *([asked] if asked else [])],
actor, verified, "assistant", partial=True)
out = store.blueprint_out(target)
out = await store.blueprint_view(own, target)
conflicts = [{"code": r["code"], "level": "warning", "path": r["op"].get("path"),
"message": f"助手提的这一处没有采纳:{r['message']}"} for r in saved.refused]
answer = applier.explanation or (asked["question"] if asked else "")
@@ -533,7 +534,7 @@ async def fill_joints_stream(payload: FillJointsIn, session: AsyncSession = Depe
target.joints = joints
target.updated_by = actor
await own.commit()
out = store.blueprint_out(target)
out = await store.blueprint_view(own, target)
yield _sse({"op": "final", "blueprint": out,
"groups": {g: {"status": "filled" if not (results.get(g) or {}).get("error")
and not (results.get(g) or {}).get("issues") else "failed",
+4 -4
View File
@@ -45,11 +45,11 @@ def _refused(e: Any) -> HTTPException:
return HTTPException(e.status_code, e.message)
def blueprint_out(row: Any) -> dict[str, Any]:
async def blueprint_out(session: AsyncSession, row: Any) -> dict[str, Any]:
"""蓝图的接口形状只有一份(api/blueprints.blueprint_out,带蓝图检查的白话):存成个人应用、取副本交回的也是它。"""
from app.api.blueprints import blueprint_out as out
return out(row)
return await out(session, row)
async def _blueprint(session: AsyncSession, blueprint_id: str) -> Any:
@@ -89,7 +89,7 @@ async def save_personal(blueprint_id: str, session: AsyncSession = Depends(get_s
return JSONResponse(status_code=e.status_code,
content={"detail": e.message, "code": e.code, "issues": e.issues})
raise _refused(e) from e
return {"app_id": app.id, "blueprint": blueprint_out(await _blueprint(session, blueprint_id))}
return {"app_id": app.id, "blueprint": await blueprint_out(session, await _blueprint(session, blueprint_id))}
@router.post("/blueprints/{blueprint_id}/copy", status_code=201)
@@ -101,7 +101,7 @@ async def copy_for_review(blueprint_id: str, session: AsyncSession = Depends(get
row = await copy(session, await _blueprint(session, blueprint_id), principal=_principal(x_actor))
except PersonalRefused as e:
raise _refused(e) from e
return blueprint_out(row)
return await blueprint_out(session, row)
# --------------------------------------------------------------------------
+15 -2
View File
@@ -143,18 +143,31 @@ class BlueprintChecker:
交回它改。缺的项(bp.missing、bp.data_unmapped……)不交回:缺的往往是只有用户知道的事,逼它补只会让它编,由它在
这一轮里 bp_ask 问,或者留给用户在卡片上填。"""
def __init__(self, applier: BlueprintApplier) -> None:
def __init__(self, applier: BlueprintApplier, *, template_id: str | None = None) -> None:
self.applier = applier
self.template_id = template_id
def lint(self) -> list[dict[str, Any]]:
from app.blueprints.ops import blueprint_issues
return blueprint_issues(self.applier.spec, for_="trial")
async def sources(self) -> list[dict[str, Any]]:
"""对到的数据源和表在不在(要查库,用自己的会话:流式回答时接口的会话可能已经关了)。对到一个不存在的源是助手
提错了,交回它改,不留给用户到试运行时才碰上。"""
from app.blueprints.store import data_source_problems
from app.db.base import SessionLocal
spec = self.applier.spec
template_id = (spec.get("template") or {}).get("id") or self.template_id
async with SessionLocal() as session:
return await data_source_problems(session, spec, template_id=template_id)
async def issues(self) -> list[dict[str, Any]]:
refused = [{"code": r["code"], "level": "error", "path": r["path"], "message": r["message"]}
for r in self.applier.unreported()]
return [*refused, *(i for i in self.lint() if i["code"] == "bp.template_mismatch" and i["level"] == "error")]
return [*refused, *(i for i in self.lint() if i["code"] == "bp.template_mismatch" and i["level"] == "error"),
*await self.sources()]
def repair_request(self, issues: list[dict[str, Any]], looks_text: str) -> str:
return BLUEPRINT_REPAIR_PROMPT.format(spec=json.dumps(self.applier.spec, ensure_ascii=False),
+3
View File
@@ -119,6 +119,9 @@ def _table(value: Any, _spec: dict[str, Any]) -> Any:
if not isinstance(value, dict) or set(value) - {"source", "table"}:
raise BpRefused("对到的表要写明数据源和表名两项")
out = {k: str(value.get(k) or "").strip() for k in ("source", "table")}
if out["source"] == "@records":
# 本应用的记录表(填报登记,blueprints/store.SELF_RECORDS)只有一张,表名固定,用户和模型都不用写
out["table"] = "records"
if not all(out.values()) or any(len(v) > 200 for v in out.values()):
raise BpRefused("对到的表要写明数据源和表名")
return out
+4 -1
View File
@@ -304,7 +304,10 @@ async def _preflight(session: AsyncSession, bp: Blueprint, target: _Target, *, l
from app.core.artifact_store import graph_hash
# 蓝图:缺的项、待确认的项(发布前要人看过,bp.unconfirmed)、还没填的生成关节(同蓝图卡片上显示的那一份)
out: list[dict[str, Any]] = [{**i, "source": "blueprint"} for i in blueprint_issues_of(bp)]
from app.blueprints.store import data_source_issues
out: list[dict[str, Any]] = [{**i, "source": "blueprint"}
for i in [*blueprint_issues_of(bp), *await data_source_issues(session, bp)]]
# 发布的级别:受管级、把受管降为已发布都只给管理员(Q8,同发布接口)
if level == "governed" and not principal.at_least("admin"):
out.append(_problem(ADMIN_REQUIRED, "以受管级别发布需要管理员", source="plan"))
+58 -2
View File
@@ -22,6 +22,9 @@ from app.blueprints.ops import BpRefused, apply_bp_op, parse_pointer
#: 蓝图不存在(或者是别人的个人蓝图)
BLUEPRINT_GONE = "蓝图不存在,可能已被删除"
#: 「看哪些数」里指填报登记这个应用自己的记录表(templates/records.RECORDS_SELF):建表之前还没有数据源名,实例化时
#: 换成记录表登记成的数据源
SELF_RECORDS = "@records"
PERSONAL_OFF = "管理员还没有开放个人应用:想给自己做一个看板,可以请管理员打开,或请搭建者做成应用"
@@ -319,7 +322,60 @@ def blueprint_issues_of(blueprint: Any) -> list[dict[str, Any]]:
return [with_plain(i) for i in issues]
def blueprint_out(blueprint: Any, *, issues: bool = True) -> dict[str, Any]:
async def data_source_problems(session: AsyncSession, spec: dict[str, Any], *,
template_id: str | None) -> list[dict[str, Any]]:
"""「看哪些数」对到的数据源和表在不在。卡片的白名单(ops._table)只核形状、不查库:对到一个不存在的源,要到试运行
发起时才报「绑定的工具不存在」,用户还找不到在哪改——这里提前在卡片上照实说,挡住试运行和发布(P1)。
本应用的记录表写 @records,只有填报登记认;源存在、表结构探查过、表却不在里面的,同样照实说。没探查过的不猜。"""
from app.data import catalog
from app.db.models import DataSource
items = [(i, item, (item.get("table") or {}).get("value")) for i, item in enumerate(spec.get("data") or [])
if isinstance(item, dict)]
names = {t["source"] for _, _, t in items if isinstance(t, dict) and t.get("source") and t["source"] != SELF_RECORDS}
rows = {r.name: r for r in (await session.execute(select(DataSource).where(DataSource.name.in_(names)))).scalars()} \
if names else {}
records = template_id == "records"
hint = ";汇总本应用填报的记录,请选「本应用的记录表」" if records else ""
out: list[dict[str, Any]] = []
for i, item, table in items:
if not isinstance(table, dict) or not table.get("source"):
continue
label = (item.get("label") or {}).get("value") or item.get("id")
path = f"/data/{i}/table"
source = table["source"]
if source == SELF_RECORDS:
if not records:
out.append({"code": "bp.data_source_unknown", "level": "error", "path": path,
"message": f"「{label}」对到了「本应用的记录表」,只有填报登记才有记录表:请重新选数据源"})
continue
row = rows.get(source)
if row is None or not row.enabled:
out.append({"code": "bp.data_source_unknown", "level": "error", "path": path,
"message": f"「{label}」对到的数据源「{source}」{'已停用' if row is not None else '不存在'}:请在"
f"「看哪些数」里重新选数据源{hint}"})
elif (row.schema_cache or {}).get("tables") and not catalog.resolve_table_name(row.schema_cache, table.get("table") or ""):
out.append({"code": "bp.data_table_unknown", "level": "error", "path": path,
"message": f"数据源「{source}」里没有表「{table.get('table')}」:请在「看哪些数」里重新选表"})
return out
async def data_source_issues(session: AsyncSession, blueprint: Any) -> list[dict[str, Any]]:
"""data_source_problems 加白话,按这张蓝图的模板判。"""
from app.plain import with_plain
spec = blueprint.spec or {}
template_id = (spec.get("template") or {}).get("id") or blueprint.template_id
return [with_plain(i) for i in await data_source_problems(session, spec, template_id=template_id)]
async def blueprint_view(session: AsyncSession, blueprint: Any) -> dict[str, Any]:
"""接口交回的蓝图:blueprint_out 再加上要查库的检查(对到的数据源和表在不在)。"""
return blueprint_out(blueprint, extra=await data_source_issues(session, blueprint))
def blueprint_out(blueprint: Any, *, issues: bool = True, extra: list[dict[str, Any]] | None = None) -> dict[str, Any]:
"""一张蓝图交给前端的样子(frontend/src/typesBlueprint.ts 的 Blueprint)。关节只给状态和模板声明的说法、类型、分组
(JointState:status、by、note、label、kind、group),不给填的值(值在图上看)。"""
joints = {jid: {k: v for k, v in state.items() if k in ("status", "by", "note", "label", "kind", "group")}
@@ -335,5 +391,5 @@ def blueprint_out(blueprint: Any, *, issues: bool = True) -> dict[str, Any]:
"updated_at": blueprint.updated_at.isoformat() if blueprint.updated_at else None,
}
if issues:
out["issues"] = blueprint_issues_of(blueprint)
out["issues"] = [*blueprint_issues_of(blueprint), *(extra or [])]
return out
+2 -1
View File
@@ -203,7 +203,8 @@ TEMPLATE = register(TemplateDef(
cards={"purpose": "必填:一句话说清登记什么、谁来填",
"fields": "必填:每次提交填哪些字段(名称、类型、必填、上下限、选项),可以标敏感",
"people": "可选:提交后要谁审批(审批通过才落进记录表)",
"data": "可选:汇总看哪些数(对到本应用的记录表)",
"data": "可选:汇总看哪些数。汇总这个应用收上来的记录,对到的表写数据源 @records、表 records(记录表建好后"
"换成真名)",
"cadence": "可选:汇总多久更新一次",
"access": "可选:谁能填(只看得到自己的记录)、谁能看全部、谁能管理"},
personal=None,
+6
View File
@@ -274,6 +274,12 @@ async def start_trial(session: AsyncSession, blueprint: Blueprint, *, inputs: di
from app.api.deps import restricted
from app.engine.launch import launch_run
from app.blueprints.store import data_source_issues
# 对到的数据源和表不在:发起时只会报「绑定的工具不存在」,这里先按卡片照实说是哪一项、怎么改
unknown = [i for i in await data_source_issues(session, blueprint) if i.get("level") == "error"]
if unknown:
raise TrialRefused(409, NOT_READY_TEXT, NOT_READY, unknown)
result = await materialize(session, blueprint, principal=principal)
if result.blocked:
raise TrialRefused(409, NOT_READY_TEXT, NOT_READY, [p for p in result.problems if p.get("level") == "error"])
+6
View File
@@ -321,6 +321,12 @@ PLAIN.update({
"bp.data_unmapped": Plain(
"有要看的数还没对到表或口径", "对不到数据,就算不出这个数。", _A,
"请助手按数据目录对到表(或复用已发布的口径),对到之后由你确认。"),
"bp.data_source_unknown": Plain(
"有要看的数对到的数据源不存在", "试运行和发布时找不到这个数据源,这个数算不出来。", _Y,
"在「看哪些数」里点「改」重新选数据源;填报登记汇总本应用的记录,选「本应用的记录表」。"),
"bp.data_table_unknown": Plain(
"有要看的数对到的表不在那个数据源里", "查不到这张表,这个数算不出来。", _Y,
"在「看哪些数」里点「改」重新选表;表刚建的话,先到「数据」页读取一次表结构。"),
"bp.people_unbound": Plain(
"有一步还没定谁来处理", "流程走到这一步就没人接手。", _Y, "在「谁来做」里选人、组或岗位。"),
"bp.template_mismatch": Plain(
@@ -0,0 +1,118 @@
"""「看哪些数」对到的数据源和表在不在(blueprints/store.data_source_problems)。
卡片的白名单只核形状、不查库:以前对到一个不存在的源(模型自己起的「本应用」),要到试运行发起时才报「绑定的工具
不存在」,用户还找不到在哪改。现在:
- 卡片上就照实说是哪一项、怎么改(bp.data_source_unknown / bp.data_table_unknown,带白话),挡住试运行和发布;
- 本应用的记录表写 @records,只有填报登记认,表名固定为 records;
- 模型自查(BlueprintChecker)拿得到这类问题,交回它改。
库和数字都是编的。
"""
from __future__ import annotations
import sqlite3
import uuid
import pytest
from httpx import ASGITransport, AsyncClient
from app.blueprints.ops import BpRefused, apply_bp_op
from app.blueprints.spec import empty_blueprint
from app.blueprints.store import SELF_RECORDS, data_source_problems
from app.data.introspect import introspect
from app.db.base import SessionLocal
from app.db.models import DataSource
from app.engine.runner import run_manager
from app.main import app
from tests import blueprint_samples as s
@pytest.fixture(autouse=True)
async def engine_up():
await run_manager.setup()
yield
await run_manager.shutdown()
@pytest.fixture
async def client():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
async def _source(tmp_path, *, explored: bool = True) -> str:
path = tmp_path / f"shop_{uuid.uuid4().hex[:6]}.db"
with sqlite3.connect(path) as db:
db.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, amount REAL, paid_at TEXT)")
name = f"shop_{uuid.uuid4().hex[:6]}"
async with SessionLocal() as session:
row = DataSource(name=name, kind="sqlite", database=str(path), readonly=True, enabled=True)
session.add(row)
await session.commit()
if explored:
row.schema_cache = await introspect(row)
await session.commit()
return name
def _spec(*tables: dict) -> dict:
spec = empty_blueprint()
spec["data"] = [{"id": f"m{i}", "label": s.item(f"指标{i}"), "table": s.item(t)} for i, t in enumerate(tables)]
return spec
def test_the_apps_own_records_table_has_a_fixed_name():
spec = empty_blueprint()
spec = apply_bp_op(spec, {"op": "bp_add", "path": "/data", "value": {"id": "total"}}, source="user")
spec = apply_bp_op(spec, {"op": "bp_set", "path": "/data/0/table",
"value": {"source": SELF_RECORDS, "table": "生产日报记录"}}, source="assistant")
assert spec["data"][0]["table"]["value"] == {"source": SELF_RECORDS, "table": "records"}
with pytest.raises(BpRefused):
apply_bp_op(spec, {"op": "bp_set", "path": "/data/0/table", "value": {"source": "", "table": "x"}}, source="user")
async def test_unknown_sources_and_tables_are_named_on_the_card(tmp_path):
known = await _source(tmp_path)
bare = await _source(tmp_path, explored=False)
spec = _spec({"source": "本应用", "table": "生产日报记录"}, {"source": known, "table": "orders"},
{"source": known, "table": "refunds"}, {"source": bare, "table": "anything"},
{"source": SELF_RECORDS, "table": "records"})
async with SessionLocal() as session:
board = await data_source_problems(session, spec, template_id="periodic_board")
records = await data_source_problems(session, spec, template_id="records")
by_path = {i["path"]: i for i in board}
# 不存在的源:说是哪一项、怎么改
assert by_path["/data/0/table"]["code"] == "bp.data_source_unknown" and "「本应用」不存在" in by_path["/data/0/table"]["message"]
# 对得上的不报;表结构探查过、表不在里面的照实说;没探查过的不猜
assert "/data/1/table" not in by_path and "/data/3/table" not in by_path
assert by_path["/data/2/table"]["code"] == "bp.data_table_unknown" and "refunds" in by_path["/data/2/table"]["message"]
# 本应用的记录表只有填报登记认
assert by_path["/data/4/table"]["code"] == "bp.data_source_unknown"
records_paths = {i["path"]: i for i in records}
assert "/data/4/table" not in records_paths
assert "本应用的记录表" in records_paths["/data/0/table"]["message"], "填报登记提示改选本应用的记录表"
assert all(i["level"] == "error" for i in board + records)
async def test_a_trial_on_an_unknown_source_is_refused_with_the_card_problem(client, tmp_path):
source = await s.register_source(s.make_source(tmp_path))
wf_id = await s.published_workflow(s.board_graph(source), f"周报-{uuid.uuid4().hex[:4]}")
bp = (await client.post("/api/blueprints", json={"source": {"kind": "workflow_version", "id": wf_id},
"scope": "shared"})).json()
await s.edit_spec(bp["id"], purpose=s.item("每周看销售额"), cadence=s.item(s.WEEKLY),
data=_spec({"source": "本应用", "table": "orders"})["data"])
got = (await client.get(f"/api/blueprints/{bp['id']}")).json()
issue = next(i for i in got["issues"] if i["code"] == "bp.data_source_unknown")
assert issue["path"] == "/data/0/table" and issue["plain"]["who"] == "you"
r = await client.post(f"/api/blueprints/{bp['id']}/trial", json={"inputs": s.PERIOD})
assert r.status_code == 409, r.text
assert "本应用" in r.text, "挡在发起之前,照实说是哪一项,不是「绑定的工具不存在」"
async def test_the_assistants_self_check_sees_unknown_sources():
from app.blueprints.generation import BlueprintApplier, BlueprintChecker
spec = _spec({"source": "本应用", "table": "生产日报记录"})
checker = BlueprintChecker(BlueprintApplier(spec), template_id="records")
issues = await checker.issues()
assert any(i["code"] == "bp.data_source_unknown" for i in issues)
+9
View File
@@ -295,6 +295,15 @@ export const BP_OP_TEXT = {
export const BP_EDIT_TEXT = {
source: '数据源',
table: '表名',
/** 填报登记里「看哪些数」对到这个应用自己收上来的记录(服务端写 @records) */
selfRecords: '本应用的记录表',
selfRecordsHint: '这个应用每次提交存下的那张表;发布后系统建好表、换成真名',
pickSource: '选一个数据源',
pickTable: '选一张表',
tablesLoading: '正在读取表…',
/** 读不到表的列表(没读取过表结构、或没有权限)时直接填表名 */
tableManual: '读不到这个数据源的表,请直接填表名',
sourcesFailed: '读不到数据源列表,请直接填数据源名和表名',
column: '日期列',
rule: '按什么算(例如按支付日)',
preset: '频率',
@@ -25,7 +25,7 @@ import { chatBusy, useBlueprint } from '../../store/blueprint'
import type {
Blueprint, BpDataItem, BpField, BpItem, BpOp, BpStep, ItemStatus, JointState, PlainIssue, TemplateInfo,
} from '../../typesBlueprint'
import { ValueEditor, valueText, type EditorKind, type EditorScope } from './editors'
import { SELF_RECORDS, ValueEditor, valueText, type EditorKind, type EditorScope } from './editors'
import { isItem, missingPaths, visibleCards, type CardKey } from './shared'
const STATUS_COLOR: Record<ItemStatus, string> = {
@@ -115,6 +115,7 @@ export function BlueprintCards({ blueprintId, templates }: { blueprintId: string
scope: {
fields: spec.fields.map((f) => ({ name: f.name, title: String((f.spec?.value as Record<string, unknown> | null)?.title ?? f.name) })),
metrics: spec.data.map((d) => ({ id: d.id, title: String(d.label?.value ?? d.id) })),
recordsSelf: (spec.template?.id ?? bp.template_id) === 'records',
},
}
return (
@@ -353,7 +354,7 @@ function DataBlock({ ctx, item, index }: { ctx: CardCtx; item: BpDataItem; index
<div className="mb-1.5 rounded border bg-bg px-2 py-1.5 text-2xs leading-relaxed" data-bp-match={`${table.source}.${table.table}`}>
<div className="flex flex-wrap items-center gap-x-3 gap-y-0.5">
<span className="inline-flex items-center gap-1 text-fg"><Database size={11} aria-hidden className="text-faint" />
{BLUEPRINT_TEXT.dataTable}:<span className="mono">{table.source}.{table.table}</span></span>
{BLUEPRINT_TEXT.dataTable}:<span className={table.source === SELF_RECORDS ? '' : 'mono'}>{valueText('table', table)}</span></span>
{item.grain?.value != null && <span>{BLUEPRINT_TEXT.dataGrain}:<span className="text-fg">{String(item.grain.value)}</span></span>}
{date?.column && <span>{BLUEPRINT_TEXT.dataDate}:<span className="text-fg">{valueText('date', date)}</span></span>}
</div>
+85 -9
View File
@@ -7,7 +7,7 @@
* agentlab.form/1 的一个属性另加是否必填;字段对应的取值是表单字段(form.x)、指标(metric.y)、核对出来的行(rows)或
* 常量(文字加单引号)。说法都是白话:时限写「2 天 3 小时」,存的是 ISO 8601 时长;处理人写名字,不写 kind。
*/
import { useState } from 'react'
import { useEffect, useState } from 'react'
import type { ReactNode } from 'react'
import { Plus, X } from 'lucide-react'
import { PrincipalPicker, type Principal as PickerPrincipal } from '../../components/PrincipalPicker'
@@ -17,17 +17,23 @@ import {
TIMEOUT_LABEL, WEEKDAY_LABEL, cadenceText, durationIso, durationText, policyText, principalText,
} from '../../lib/terms'
import type { BpPrincipal } from '../../typesBlueprint'
import type { DataSourceName } from '../../types'
import { api } from '../../api/client'
export type EditorKind =
| 'text' | 'textarea' | 'table' | 'date' | 'cadence' | 'principals' | 'grantees' | 'policy' | 'duration' | 'timeout'
| 'threshold' | 'field' | 'bool' | 'mapping'
/** 编辑框要的选项:「填什么」里的字段、「看哪些数」里的指标(金额线、字段对应从里面选) */
/** 编辑框要的选项:「填什么」里的字段、「看哪些数」里的指标(金额线、字段对应从里面选);填报登记能对到本应用的记录表 */
export interface EditorScope {
fields: { name: string; title: string }[]
metrics: { id: string; title: string }[]
recordsSelf?: boolean
}
/** 「看哪些数」里指这个应用自己的记录表(服务端 blueprints/store.SELF_RECORDS),表名固定 */
export const SELF_RECORDS = '@records'
type Obj = Record<string, any>
const obj = (v: unknown): Obj => (v && typeof v === 'object' && !Array.isArray(v) ? v as Obj : {})
const principals = (v: unknown): BpPrincipal[] => (Array.isArray(v) ? v.filter((p) => p && typeof p.name === 'string') : [])
@@ -79,7 +85,9 @@ export function valueText(kind: EditorKind, value: unknown, scope?: EditorScope)
if (value == null || value === '') return BLUEPRINT_TEXT.empty
const o = obj(value)
switch (kind) {
case 'table': return o.source || o.table ? `${o.source ?? ''}.${o.table ?? ''}` : BLUEPRINT_TEXT.empty
case 'table':
if (o.source === SELF_RECORDS) return BP_EDIT_TEXT.selfRecords
return o.source || o.table ? `${o.source ?? ''}.${o.table ?? ''}` : BLUEPRINT_TEXT.empty
case 'date': return o.column ? `${o.column}${o.rule ? `(${o.rule})` : ''}` : BLUEPRINT_TEXT.empty
case 'cadence': return cadenceText(o) || BLUEPRINT_TEXT.empty
case 'principals':
@@ -160,17 +168,85 @@ function TextEditor({ kind, value, busy, onSave, onCancel }: ValueEditorProps) {
)
}
function TableEditor({ value, busy, onSave, onCancel }: ValueEditorProps) {
/**
* 对到哪张表:先选数据源(已接入的,填报登记另有「本应用的记录表」),再选表(读这个数据源读取过的表结构)。读不到列表时
* 退回直接填写——合不合规照旧由服务端判,对到不存在的源、表由蓝图检查在卡片上照实说
*/
function TableEditor({ value, busy, onSave, onCancel, scope }: ValueEditorProps) {
const o = obj(value)
const [source, setSource] = useState(String(o.source ?? ''))
const [table, setTable] = useState(String(o.table ?? ''))
const [table, setTable] = useState(String(o.source === SELF_RECORDS ? 'records' : o.table ?? ''))
const [sources, setSources] = useState<DataSourceName[] | null>(null)
const [tables, setTables] = useState<string[] | null>(null)
const [loadingTables, setLoadingTables] = useState(false)
const [manual, setManual] = useState(false)
useEffect(() => {
let alive = true
api.datasources.names()
.then((list) => { if (alive) setSources(list.filter((x) => x.enabled !== false)) })
.catch(() => { if (alive) { setSources([]); setManual(true) } })
return () => { alive = false }
}, [])
const picked = sources?.find((x) => x.name === source)
useEffect(() => {
setTables(null)
if (!picked) return
let alive = true
setLoadingTables(true)
api.datasources.schema(picked.id)
.then((r) => { if (alive) setTables(Array.isArray(r?.tables) ? r.tables.map(String) : []) })
.catch(() => { if (alive) setTables([]) })
.finally(() => { if (alive) setLoadingTables(false) })
return () => { alive = false }
}, [picked?.id]) // eslint-disable-line react-hooks/exhaustive-deps
const self = source === SELF_RECORDS
const ok = self || (!!source.trim() && !!table.trim())
const save = () => onSave(self ? { source: SELF_RECORDS, table: 'records' } : { source: source.trim(), table: table.trim() })
if (manual) {
return (
<Shell busy={busy} onCancel={onCancel} ok={ok} onSave={save}>
<p className="text-2xs text-dim" data-bp-table-manual="">{BP_EDIT_TEXT.sourcesFailed}</p>
<div className="grid grid-cols-2 gap-2">
<Field label={BP_EDIT_TEXT.source}>{(p) => <input {...p} className="field" value={source} onChange={(e) => setSource(e.target.value)} />}</Field>
<Field label={BP_EDIT_TEXT.table}>{(p) => <input {...p} className="field" value={table} onChange={(e) => setTable(e.target.value)} />}</Field>
</div>
</Shell>
)
}
return (
<Shell busy={busy} onCancel={onCancel} ok={!!source.trim() && !!table.trim()}
onSave={() => onSave({ source: source.trim(), table: table.trim() })}>
<Shell busy={busy} onCancel={onCancel} ok={ok} onSave={save}>
<div className="grid grid-cols-2 gap-2">
<Field label={BP_EDIT_TEXT.source}>{(p) => <input {...p} className="field" value={source} onChange={(e) => setSource(e.target.value)} />}</Field>
<Field label={BP_EDIT_TEXT.table}>{(p) => <input {...p} className="field" value={table} onChange={(e) => setTable(e.target.value)} />}</Field>
<Field label={BP_EDIT_TEXT.source}>
{(p) => (
<select {...p} className="field" value={source} data-bp-table-source="" disabled={sources == null}
onChange={(e) => { setSource(e.target.value); setTable(e.target.value === SELF_RECORDS ? 'records' : '') }}>
<option value="">{sources == null ? BP_EDIT_TEXT.tablesLoading : BP_EDIT_TEXT.pickSource}</option>
{scope?.recordsSelf && <option value={SELF_RECORDS}>{BP_EDIT_TEXT.selfRecords}</option>}
{/* 原来填的源不在列表里(不存在、已停用)也列出来,看得出要换 */}
{source && source !== SELF_RECORDS && sources && !picked && <option value={source}>{source}</option>}
{(sources ?? []).map((x) => (
<option key={x.id} value={x.name}>{x.description ? `${x.name}(${x.description})` : x.name}</option>
))}
</select>
)}
</Field>
<Field label={BP_EDIT_TEXT.table}>
{(p) => self ? (
<input {...p} className="field" value={BP_EDIT_TEXT.selfRecords} disabled title={BP_EDIT_TEXT.selfRecordsHint} />
) : tables && tables.length ? (
<select {...p} className="field" value={table} data-bp-table-name="" onChange={(e) => setTable(e.target.value)}>
<option value="">{BP_EDIT_TEXT.pickTable}</option>
{table && !tables.includes(table) && <option value={table}>{table}</option>}
{tables.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
) : (
<input {...p} className="field" value={table} disabled={!source || loadingTables} data-bp-table-name=""
placeholder={loadingTables ? BP_EDIT_TEXT.tablesLoading : picked ? BP_EDIT_TEXT.tableManual : ''}
onChange={(e) => setTable(e.target.value)} />
)}
</Field>
</div>
{self && <p className="text-2xs text-dim">{BP_EDIT_TEXT.selfRecordsHint}</p>}
</Shell>
)
}