mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:11:59 +00:00
蓝图「看哪些数」对到的数据源和表要真的存在:卡片上照实说是哪一项、怎么改(bp.data_source_unknown / bp.data_table_unknown,带白话),挡住试运行和发布单,助手自查也拿得到;填报登记里本应用的记录表写 @records、表名固定,提示词和模板说明写明这个约定;「对到的表」改成从已接入的数据源和表里选,填报登记多一项「本应用的记录表」,卡片上照白话显示(以前要到试运行才报「绑定的工具不存在」,用户找不到在哪改)
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -203,7 +203,8 @@ TEMPLATE = register(TemplateDef(
|
||||
cards={"purpose": "必填:一句话说清登记什么、谁来填",
|
||||
"fields": "必填:每次提交填哪些字段(名称、类型、必填、上下限、选项),可以标敏感",
|
||||
"people": "可选:提交后要谁审批(审批通过才落进记录表)",
|
||||
"data": "可选:汇总看哪些数(对到本应用的记录表)",
|
||||
"data": "可选:汇总看哪些数。汇总这个应用收上来的记录,对到的表写数据源 @records、表 records(记录表建好后"
|
||||
"换成真名)",
|
||||
"cadence": "可选:汇总多久更新一次",
|
||||
"access": "可选:谁能填(只看得到自己的记录)、谁能看全部、谁能管理"},
|
||||
personal=None,
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user