diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index a84edb05d..c3282f756 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -1224,6 +1224,12 @@ export const en = { rulesShort: "Business rules", ruleExpressionReadOnly: "This definition is read-only in this form. You can edit its name and description without changing its expressions or conditions.", ruleUnknownExpression: "Unsupported expression (read-only)", + ruleDependencies: "Potential dependencies", + ruleDependenciesHint: "Candidates from rule definitions, not proof of execution. Readings, conditions and time determine what actually runs; disabled definitions are included.", + ruleDependenciesIncomplete: "Some definitions or classes could not be read completely. This list may be incomplete.", + rulePotentialProducers: "May receive input from", + rulePotentialConsumers: "May provide input to", + ruleDependenciesEmpty: "No candidates found in the readable definitions.", rulesTitle: "Business rules", /* 说清三件事:谁写的、结论是什么身份、什么时候重算。第三件最容易被误解成 「保存就生效」,而它其实等下一轮物化 */ diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 67593ffbc..b809c1a74 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -1100,6 +1100,12 @@ export const zh: Strings = { rulesShort: "业务规则", ruleExpressionReadOnly: "此表单只读展示这条规则的定义。可修改名称和说明,表达式和条件保持原样。", ruleUnknownExpression: "暂不支持的表达式(只读)", + ruleDependencies: "潜在依赖", + ruleDependenciesHint: "根据规则定义列出的候选关系,不代表实际执行。读数、条件和时间决定哪些规则成立;这里也包括已停用的定义。", + ruleDependenciesIncomplete: "部分定义或类信息无法完整读取,此列表可能不完整。", + rulePotentialProducers: "可能从这些规则获得输入", + rulePotentialConsumers: "可能为这些规则提供输入", + ruleDependenciesEmpty: "在可读取的定义中未找到候选关系。", rulesTitle: "业务规则", rulesHint: "按实体自己的属性判定类别、或算出取值的规则。结论是派生的,依据没了就自动失效。", rulesEmpty: "还没有规则。", diff --git a/web/src/pages/RulesPanel.tsx b/web/src/pages/RulesPanel.tsx index f04245aeb..40845d168 100644 --- a/web/src/pages/RulesPanel.tsx +++ b/web/src/pages/RulesPanel.tsx @@ -8,7 +8,7 @@ * * 样式按 web/DESIGN.md 那五条:字号五档、间距六档、颜色只用 token、控件与状态 * 一律从 ui/ 来。 */ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Pencil, Play, Plus, Search, Trash2 } from "lucide-react"; import { @@ -40,6 +40,7 @@ import { } from "../ui"; import { toast } from "../toast"; import { expressionText, metadataOnly, metadataPatch } from "./ruleExpressions"; +import { ruleDependencies } from "./ruleDependencies"; /** op → 那句话里的动词。**数字与集合两类分开**,因为它们的操作数长得不一样 */ const OPS: { @@ -258,6 +259,15 @@ export function RulesPanel({ /** 展开了哪条规则的命中列表。一次只展开一条——两份长列表并排读不了 */ const [opened, setOpened] = useState(null); const [filter, setFilter] = useState(""); + const [dependenciesOf, setDependenciesOf] = useState(null); + const [navigation, setNavigation] = useState<{ id: string } | null>(null); + useEffect(() => { setDependenciesOf(null); setNavigation(null); }, [kbId]); + useEffect(() => { + if (!navigation) return; + const row = document.getElementById(`rule-${kbId}-${navigation.id}`); + row?.scrollIntoView({ block: "center" }); + row?.focus(); + }, [kbId, navigation]); const invalidate = () => { qc.invalidateQueries({ queryKey: ["rules", kbId] }); @@ -344,6 +354,11 @@ export function RulesPanel({ }); const all = rules.data?.rules ?? []; + const dependencies = useMemo(() => ruleDependencies(rules.data?.rules ?? [], classes, attributes), [rules.data, classes, attributes]); + const inspecting = all.find((r) => r.id === dependenciesOf); + const navigateRule = (id: string) => { + setFilter(""); setDependenciesOf(null); setNavigation({ id }); + }; const needle = filter.trim().toLowerCase(); const list = needle ? all.filter((r) => searchText(r, attributes).includes(needle)) @@ -432,13 +447,16 @@ export function RulesPanel({ {list.map((r) => (
{r.name}
+ setDependenciesOf(r.id)}>{S.ontology.ruleDependencies} {r.description && (
{r.description}
)} @@ -513,6 +531,17 @@ export function RulesPanel({ )} + !open && setDependenciesOf(null)} + closeLabel={S.ui.close} title={inspecting?.name ?? ""} description={S.ontology.ruleDependenciesHint}> + {inspecting &&
+ {dependencies.incomplete &&

{S.ontology.ruleDependenciesIncomplete}

} + dependencies.links.get(inspecting.id)?.producers.has(r.id))} onSelect={navigateRule} /> + dependencies.links.get(inspecting.id)?.consumers.has(r.id))} onSelect={navigateRule} /> +
} +
+ {/* 命中:这一条此刻推出了哪些结论 */} ; } + +export function RuleDependencyList({ title, rules, onSelect }: { + title: string; rules: BusinessRule[]; onSelect: (id: string) => void; +}) { + return
+

{title}

+ {rules.length ? rules.map((r) =>
+ onSelect(r.id)}>{r.name} + {r.subject_label} · {r.enabled ? S.ontology.ruleEnabled : S.ontology.ruleDisabled} +
) :

{S.ontology.ruleDependenciesEmpty}

} +
; +} diff --git a/web/src/pages/ruleDependencies.test.ts b/web/src/pages/ruleDependencies.test.ts new file mode 100644 index 000000000..c103f47b0 --- /dev/null +++ b/web/src/pages/ruleDependencies.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from "vitest"; +import { Children, isValidElement, type ReactElement, type ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import type { BusinessRule } from "../api"; +import { ruleDependencies } from "./ruleDependencies"; +import { RuleDependencyList } from "./RulesPanel"; +import { en } from "../i18n/en"; +import { zh } from "../i18n/zh"; +const r = (id: string, patch: Partial = {}): BusinessRule => ({ + id, name: id, subject_type_id: "root", subject_label: "Class", conclusion: "attribute", + conclude_predicate_id: `out-${id}`, conclude_type_label: null, conclude_predicate_label: "Value", + conditions: [{ predicate_id: "input", op: "gt", operand: 100 }], enabled: true, derived_count: 0, capped: 0, ...patch, +}); +const classes = [{ id: "root", parents: [] }, { id: "child", parents: ["root"] }, { id: "leaf", parents: ["child"] }]; +describe("potential definition dependencies", () => { + it("finds every producer and consumer by ID, regardless of labels, thresholds or enabled state", () => { + const rules = [r("a", { conclude_predicate_id: "p" }), r("b", { conclude_predicate_id: "p", enabled: false }), r("c", { conditions: [{ predicate_id: "p", op: "lt", operand: -999 }] }), r("d", { conclude_predicate_id: "q" })]; + const before = JSON.stringify(rules); + const { links } = ruleDependencies(rules, classes); + expect([...links.get("c")!.producers]).toEqual(["a", "b"]); + expect([...links.get("a")!.consumers]).toEqual(["c"]); + expect(links.get("d")!.consumers.size).toBe(0); + expect(JSON.stringify(rules)).toBe(before); + }); + it("reads nested computed expressions and condition operands", () => { + const expr = { op: "div", l: { const: 2 }, r: { op: "sub", l: { attr: "p" }, r: { attr: "q" } } }; + const computed = { ...r("computed"), conclusion: "computed", conclude_expr: expr }; + const { links } = ruleDependencies([r("p", { conclude_predicate_id: "p" }), r("q", { conclude_predicate_id: "q" }), computed, r("operand", { conditions: [{ predicate_id: "other", op: "gt", operand: expr }] })], classes); + expect([...links.get("computed")!.producers]).toEqual(["p", "q"]); + expect([...links.get("operand")!.producers]).toEqual(["p", "q"]); + }); + it("matches concluded subclasses to ancestor scopes, not the reverse", () => { + const { links } = ruleDependencies([r("child-output", { conclusion: "typing", conclude_type_id: "child" }), r("root-output", { conclusion: "typing", conclude_type_id: "root" }), r("child-scope", { subject_type_id: "child" }), r("leaf-scope", { subject_type_id: "leaf" })], classes); + expect(links.get("child-output")!.consumers.has("child-scope")).toBe(true); + expect(links.get("child-output")!.consumers.has("root-output")).toBe(true); + expect(links.get("child-output")!.consumers.has("leaf-scope")).toBe(false); + expect(links.get("root-output")!.consumers.has("child-scope")).toBe(false); + }); + it("includes explicit readers of the built-in typing predicate", () => { + const { links } = ruleDependencies([r("typing", { conclusion: "typing", conclude_type_id: "child" }), r("literal", { subject_type_id: "leaf", conditions: [{ predicate_id: "is-a-id", op: "present" }] })], classes, [{ id: "is-a-id", key: "is_a" }]); + expect(links.get("typing")!.consumers.has("literal")).toBe(true); + }); + it("preserves self-dependencies and cycles without treating them as errors", () => { + const { links } = ruleDependencies([r("a", { conclude_predicate_id: "input" }), r("b", { conclude_predicate_id: "input" })], classes); + expect([...links.get("a")!.consumers]).toEqual(["a", "b"]); + expect([...links.get("b")!.consumers]).toEqual(["a", "b"]); + }); + it("does not reuse another snapshot and reports incomplete definitions", () => { + const first = ruleDependencies([r("old", { conclude_predicate_id: "input" })], classes); + expect(first.links.get("old")!.producers.size).toBe(1); + expect(ruleDependencies([], classes).links.size).toBe(0); + expect(ruleDependencies([{ ...r("new"), conclusion: "computed", conclude_expr: { op: "future" } }], classes).incomplete).toBe(true); + expect(ruleDependencies([r("new")], []).incomplete).toBe(true); + }); + it("handles hierarchy cycles and all parents", () => { + const tree = [{ id: "x", parents: ["y", "z"] }, { id: "y", parents: ["x"] }, { id: "z", parents: [] }]; + const { links } = ruleDependencies([r("p", { conclusion: "typing", conclude_type_id: "x", subject_type_id: "x" }), r("c", { subject_type_id: "z" })], tree); + expect(links.get("p")!.consumers.has("c")).toBe(true); + }); + it("does not truncate a large sparse rule set", () => { + const rules = Array.from({ length: 10000 }, (_, i) => r(String(i), { conclude_predicate_id: `p${i}`, conditions: [{ predicate_id: `p${i-1}`, op: "present" }] })); + const start = performance.now(); + const { links } = ruleDependencies(rules, classes); + expect(links.size).toBe(10000); + expect([...links.get("9999")!.producers]).toEqual(["9998"]); + expect([...links.values()].reduce((n, x) => n + x.consumers.size, 0)).toBe(9999); + expect(performance.now() - start).toBeLessThan(3000); + }); + it("renders disabled definitions and navigates to the selected ID", () => { + const onSelect = vi.fn(); + const element = RuleDependencyList({ title: "May receive input from", rules: [r("chosen", { enabled: false })], onSelect }); + const markup = renderToStaticMarkup(element); + expect(markup).toContain("chosen"); expect(markup).toContain(en.ontology.ruleDisabled); + type El = ReactElement<{ children?: ReactNode; onClick?: () => void }>; + const walk = (e: El): void => { if (e.props.onClick) e.props.onClick(); Children.forEach(e.props.children, (c) => { if (isValidElement(c)) walk(c as El); }); }; + walk(element as El); expect(onSelect).toHaveBeenCalledWith("chosen"); + for (const bundle of [en, zh]) expect(bundle.ontology.ruleDependenciesHint.length).toBeGreaterThan(30); + }); +}); diff --git a/web/src/pages/ruleDependencies.ts b/web/src/pages/ruleDependencies.ts new file mode 100644 index 000000000..b163e4bd2 --- /dev/null +++ b/web/src/pages/ruleDependencies.ts @@ -0,0 +1,75 @@ +import type { BusinessRule, EntityTypeView, RelationTypeView } from "../api"; + +type Definition = Omit & { conclusion: string; conclude_expr?: unknown }; +export interface RuleLinks { producers: Set; consumers: Set } + +/** Definition-level candidates only. Conditions, time and actual readings decide + * which links are used in a proof. Disabled rules remain part of the definition. */ +export function ruleDependencies(rules: Definition[], classes: Pick[], attributes: Pick[] = []) { + const links = new Map(rules.map((r) => [r.id, { producers: new Set(), consumers: new Set() }])); + const readers = new Map>(); + const scopes = new Map>(); + const parents = new Map(classes.map((c) => [c.id, c.parents])); + let incomplete = false; + const isA = attributes.find((a) => a.key === "is_a")?.id; + const index = (map: Map>, key: string, id: string) => { + if (!map.has(key)) map.set(key, new Set()); + map.get(key)!.add(id); + }; + const readExpr = (raw: unknown, id: string, depth = 0): void => { + if (depth > 4 || !raw || typeof raw !== "object" || Array.isArray(raw)) { incomplete = true; return; } + const node = raw as Record; + if (typeof node.attr === "string") { index(readers, node.attr, id); return; } + if (typeof node.const === "number" || typeof node.const === "string") return; + if (["add", "sub", "mul", "div"].includes(String(node.op))) { + readExpr(node.l, id, depth + 1); readExpr(node.r, id, depth + 1); + } else incomplete = true; + }; + for (const r of rules) { + index(scopes, r.subject_type_id, r.id); + if (!parents.has(r.subject_type_id)) incomplete = true; + for (const c of r.conditions) { + index(readers, c.predicate_id, r.id); + if (c.operand && typeof c.operand === "object" && !Array.isArray(c.operand)) readExpr(c.operand, r.id); + } + if (r.conclusion === "computed") readExpr(r.conclude_expr, r.id); + else if (!["typing", "attribute"].includes(r.conclusion)) incomplete = true; + } + const connect = (producer: string, consumers?: Set) => { + for (const consumer of consumers ?? []) { + links.get(producer)!.consumers.add(consumer); + links.get(consumer)!.producers.add(producer); + } + }; + const ancestorCache = new Map>(); + const ancestors = (type: string) => { + const cached = ancestorCache.get(type); + if (cached) return cached; + const seen = new Set(); const pending = [type]; + while (pending.length) { + const id = pending.pop()!; + if (seen.has(id)) continue; + seen.add(id); + const ps = parents.get(id); + if (!ps) incomplete = true; + else pending.push(...ps); + } + ancestorCache.set(type, seen); + return seen; + }; + for (const r of rules) { + if (r.conclusion === "typing" && r.conclude_type_id) { + // reasoning::attribute_rules scopes a rule to its class and descendants; + // therefore a concluded child can supply membership in an ancestor scope. + for (const type of ancestors(r.conclude_type_id)) connect(r.id, scopes.get(type)); + // Typings also enter the literal fact pool on the built-in is_a predicate. + if (isA) connect(r.id, readers.get(isA)); + } else if (["attribute", "computed"].includes(r.conclusion) && r.conclude_predicate_id) { + connect(r.id, readers.get(r.conclude_predicate_id)); + // A literal is_a conclusion can also affect scope, but the ontology view + // does not expose every IRI the runner resolves. Do not claim completeness. + if (r.conclude_predicate_id === isA) incomplete = true; + } + } + return { links, incomplete }; +}