Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions web/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
/* 说清三件事:谁写的、结论是什么身份、什么时候重算。第三件最容易被误解成
「保存就生效」,而它其实等下一轮物化 */
Expand Down
6 changes: 6 additions & 0 deletions web/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1100,6 +1100,12 @@ export const zh: Strings = {
rulesShort: "业务规则",
ruleExpressionReadOnly: "此表单只读展示这条规则的定义。可修改名称和说明,表达式和条件保持原样。",
ruleUnknownExpression: "暂不支持的表达式(只读)",
ruleDependencies: "潜在依赖",
ruleDependenciesHint: "根据规则定义列出的候选关系,不代表实际执行。读数、条件和时间决定哪些规则成立;这里也包括已停用的定义。",
ruleDependenciesIncomplete: "部分定义或类信息无法完整读取,此列表可能不完整。",
rulePotentialProducers: "可能从这些规则获得输入",
rulePotentialConsumers: "可能为这些规则提供输入",
ruleDependenciesEmpty: "在可读取的定义中未找到候选关系。",
rulesTitle: "业务规则",
rulesHint: "按实体自己的属性判定类别、或算出取值的规则。结论是派生的,依据没了就自动失效。",
rulesEmpty: "还没有规则。",
Expand Down
45 changes: 43 additions & 2 deletions web/src/pages/RulesPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -40,6 +40,7 @@ import {
} from "../ui";
import { toast } from "../toast";
import { expressionText, metadataOnly, metadataPatch } from "./ruleExpressions";
import { ruleDependencies } from "./ruleDependencies";

/** op → 那句话里的动词。**数字与集合两类分开**,因为它们的操作数长得不一样 */
const OPS: {
Expand Down Expand Up @@ -258,6 +259,15 @@ export function RulesPanel({
/** 展开了哪条规则的命中列表。一次只展开一条——两份长列表并排读不了 */
const [opened, setOpened] = useState<string | null>(null);
const [filter, setFilter] = useState("");
const [dependenciesOf, setDependenciesOf] = useState<string | null>(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] });
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -432,13 +447,16 @@ export function RulesPanel({
{list.map((r) => (
<Tr
key={r.id}
id={`rule-${kbId}-${r.id}`}
tabIndex={-1}
className={cn(
!r.enabled && "opacity-55",
r.id === focusId && "u-picked bg-surface-2",
(r.id === focusId || r.id === navigation?.id) && "u-picked bg-surface-2",
)}
>
<Td>
<div className="text-body text-ink">{r.name}</div>
<LinkButton onClick={() => setDependenciesOf(r.id)}>{S.ontology.ruleDependencies}</LinkButton>
{r.description && (
<div className="text-fine text-ink-2">{r.description}</div>
)}
Expand Down Expand Up @@ -513,6 +531,17 @@ export function RulesPanel({
</div>
)}

<Dialog open={!!inspecting} onOpenChange={(open) => !open && setDependenciesOf(null)}
closeLabel={S.ui.close} title={inspecting?.name ?? ""} description={S.ontology.ruleDependenciesHint}>
{inspecting && <div className="space-y-4">
{dependencies.incomplete && <p className="text-small text-warn">{S.ontology.ruleDependenciesIncomplete}</p>}
<RuleDependencyList title={S.ontology.rulePotentialProducers}
rules={all.filter((r) => dependencies.links.get(inspecting.id)?.producers.has(r.id))} onSelect={navigateRule} />
<RuleDependencyList title={S.ontology.rulePotentialConsumers}
rules={all.filter((r) => dependencies.links.get(inspecting.id)?.consumers.has(r.id))} onSelect={navigateRule} />
</div>}
</Dialog>

{/* 命中:这一条此刻推出了哪些结论 */}
<Dialog
open={!!opening}
Expand Down Expand Up @@ -848,3 +877,15 @@ function RuleMetadataDialog({ kbId, rule, attributes, onClose, onSaved }: {
</div>
</Dialog>;
}

export function RuleDependencyList({ title, rules, onSelect }: {
title: string; rules: BusinessRule[]; onSelect: (id: string) => void;
}) {
return <section className="space-y-2">
<h3 className="text-body font-medium text-ink">{title}</h3>
{rules.length ? rules.map((r) => <div key={r.id} className="flex flex-wrap items-baseline gap-2">
<LinkButton onClick={() => onSelect(r.id)}>{r.name}</LinkButton>
<span className="text-fine text-ink-2">{r.subject_label} · {r.enabled ? S.ontology.ruleEnabled : S.ontology.ruleDisabled}</span>
</div>) : <p className="text-small text-ink-2">{S.ontology.ruleDependenciesEmpty}</p>}
</section>;
}
79 changes: 79 additions & 0 deletions web/src/pages/ruleDependencies.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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);
});
});
75 changes: 75 additions & 0 deletions web/src/pages/ruleDependencies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import type { BusinessRule, EntityTypeView, RelationTypeView } from "../api";

type Definition = Omit<BusinessRule, "conclusion"> & { conclusion: string; conclude_expr?: unknown };
export interface RuleLinks { producers: Set<string>; consumers: Set<string> }

/** 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<EntityTypeView, "id" | "parents">[], attributes: Pick<RelationTypeView, "id" | "key">[] = []) {
const links = new Map<string, RuleLinks>(rules.map((r) => [r.id, { producers: new Set(), consumers: new Set() }]));
const readers = new Map<string, Set<string>>();
const scopes = new Map<string, Set<string>>();
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<string, Set<string>>, 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<string, unknown>;
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<string>) => {
for (const consumer of consumers ?? []) {
links.get(producer)!.consumers.add(consumer);
links.get(consumer)!.producers.add(producer);
}
};
const ancestorCache = new Map<string, Set<string>>();
const ancestors = (type: string) => {
const cached = ancestorCache.get(type);
if (cached) return cached;
const seen = new Set<string>(); 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 };
}
Loading