Skip to content

Commit 5fdcf50

Browse files
committed
feat(web): compact dashboard trace to cross-law output breakdown
Expanding every cross_law_reference with its raw resolves and its own downstream cross-law calls made 'Gebruikte gegevens' a 50-entry wall of bsn/leeftijd/pensioenleeftijd/partner_bsn/box-inputs that mirrors the engine's internal resolution, not the decomposition a citizen wants. Match the production dashboard: under a cross-law service expansion, only show the action outputs that decompose the requested output — e.g. wet_inkomstenbelasting#inkomen breaks down into box1_inkomen, box2_inkomen, box3_inkomen, buitenlands_inkomen. Drop raw resolve nodes and nested cross_law_reference bodies at this level.
1 parent f5a2902 commit 5fdcf50

1 file changed

Lines changed: 84 additions & 52 deletions

File tree

web/engines/regelrecht_engine/engine.py

Lines changed: 84 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,7 @@ def _index(node: dict, in_crosslaw: str | None) -> None:
237237
details=details,
238238
)
239239
if cross_node:
240-
node.children = _crosslaw_children_as_pathnodes(cross_node, regulation)
240+
node.children = _crosslaw_children_as_pathnodes(cross_node, regulation, output)
241241
root.children.append(node)
242242
else:
243243
if name not in resolve_nodes:
@@ -255,72 +255,104 @@ def _index(node: dict, in_crosslaw: str | None) -> None:
255255
return root
256256

257257

258-
def _crosslaw_children_as_pathnodes(cross_node: dict, regulation: str) -> list[PathNode]:
259-
"""Return the resolved inputs of a cross_law_reference as PathNodes.
258+
def _crosslaw_children_as_pathnodes(cross_node: dict, regulation: str, output: str) -> list[PathNode]:
259+
"""Return the "break-down" children for a cross-law service.
260260
261-
Nested cross_law_reference children recurse so the dashboard shows
262-
the full depth (wet_brp → wet_inkomstenbelasting → ...).
261+
The prod dashboard shows, under a cross-law expansion, only the
262+
*component outputs* that make up the requested output — not every
263+
internal input or downstream cross-law the referenced law happens
264+
to consume. For ``wet_inkomstenbelasting#inkomen`` that means
265+
``box1_inkomen``, ``box2_inkomen``, ``box3_inkomen``,
266+
``buitenlands_inkomen``, etc., because these are the outputs whose
267+
sum is the parent ``inkomen``.
268+
269+
The Rust trace exposes these as ``action`` nodes with a distinct
270+
output name. We surface the action nodes, skip the main output
271+
itself (it's already the parent), and drop raw ``resolve`` /
272+
nested ``cross_law_reference`` nodes.
263273
"""
264-
# Load the referenced law YAML once so we can classify each input.
265274
nested_specs = _input_specs_for_law(regulation)
275+
# Load the referenced law's own output declarations so we can
276+
# classify each action output as amount / boolean / etc.
277+
nested_outputs = _output_specs_for_law(regulation)
278+
266279
children: list[PathNode] = []
267280
seen: set[str] = set()
268281

269-
for child in cross_node.get("children", []):
270-
if not isinstance(child, dict):
271-
continue
272-
nt = child.get("node_type")
273-
cname = child.get("name", "")
274-
275-
if nt == "resolve":
276-
if cname in seen:
277-
continue
278-
seen.add(cname)
279-
spec = nested_specs.get(cname, {})
280-
resolve_type, details = _classify_input(spec) if spec else ("NONE", {"type": None, "type_spec": None})
281-
details["path"] = f"${cname}"
282+
def _walk_actions(node: dict) -> None:
283+
if not isinstance(node, dict):
284+
return
285+
nt = node.get("node_type")
286+
name = node.get("name", "")
287+
if nt == "action" and name and name != output and name not in seen:
288+
seen.add(name)
289+
out_spec = nested_outputs.get(name, {})
290+
details = {
291+
"type": out_spec.get("type"),
292+
"type_spec": out_spec.get("type_spec"),
293+
"path": f"${name}",
294+
}
282295
children.append(
283296
PathNode(
284297
type="resolve",
285-
name=cname,
286-
result=child.get("result"),
287-
resolve_type=resolve_type,
288-
required=bool(spec.get("required", False)) if spec else False,
298+
name=name,
299+
result=node.get("result"),
300+
resolve_type="NONE",
301+
required=False,
289302
details=details,
290303
)
291304
)
292-
elif nt == "cross_law_reference":
293-
# Cross-law ref within cross-law: find the input name on the
294-
# outer law whose source.regulation+output matches this key.
295-
reg, _, out = cname.partition("#")
296-
nested_input_name = _find_input_for_crosslaw(nested_specs, reg, out) or out
297-
if nested_input_name in seen:
298-
continue
299-
seen.add(nested_input_name)
300-
spec = nested_specs.get(nested_input_name, {})
301-
resolve_type, details = _classify_input(spec) if spec else ("SERVICE", {"type": None, "type_spec": None})
302-
details["path"] = f"${nested_input_name}"
303-
node = PathNode(
304-
type="service_evaluation",
305-
name=nested_input_name,
306-
result=child.get("result"),
307-
resolve_type="SERVICE",
308-
required=bool(spec.get("required", False)) if spec else False,
309-
details=details,
310-
)
311-
node.children = _crosslaw_children_as_pathnodes(child, reg)
312-
children.append(node)
313-
305+
return # don't descend into the action's own operation tree
306+
# Skip nested cross-law bodies so we stay at a single level of
307+
# breakdown; the outermost cross_node is still walked.
308+
if nt == "cross_law_reference" and node is not cross_node:
309+
return
310+
for child in node.get("children", []):
311+
_walk_actions(child)
312+
313+
# Also fall back to nested input name matching for cases where the
314+
# referenced law doesn't compute its result through discrete
315+
# actions (e.g. single-expression laws).
316+
_walk_actions(cross_node)
317+
# Preserve YAML declaration order when available.
318+
if nested_outputs:
319+
order = {name: i for i, name in enumerate(nested_outputs)}
320+
children.sort(key=lambda n: order.get(n.name, len(order)))
321+
# Consume nested_specs reference to keep the helper's contract
322+
# stable — future callers may want to classify extra nodes.
323+
_ = nested_specs
314324
return children
315325

316326

317-
def _find_input_for_crosslaw(specs: dict[str, dict[str, Any]], regulation: str, output: str) -> str | None:
318-
"""Find the outer input whose ``source`` matches ``regulation#output``."""
319-
for name, spec in specs.items():
320-
source = spec.get("source") or {}
321-
if source.get("regulation") == regulation and source.get("output", name) == output:
322-
return name
323-
return None
327+
def _output_specs_for_law(law_id: str) -> dict[str, dict[str, Any]]:
328+
"""Return ``{output_name: spec}`` for a law identified by ``$id``."""
329+
if law_id in _OUTPUT_SPEC_CACHE:
330+
return _OUTPUT_SPEC_CACHE[law_id]
331+
332+
from machine.utils import RuleResolver
333+
334+
specs: dict[str, dict[str, Any]] = {}
335+
try:
336+
resolver = RuleResolver()
337+
rules = [r for r in resolver.rules if r.law == law_id]
338+
if rules:
339+
latest = max(rules, key=lambda r: r.valid_from)
340+
data = yaml.safe_load(Path(latest.path).read_text())
341+
if isinstance(data, dict):
342+
for article in data.get("articles", []):
343+
execution = article.get("machine_readable", {}).get("execution", {})
344+
for out in execution.get("output", []):
345+
name = out.get("name")
346+
if name:
347+
specs[name] = out
348+
except Exception as exc:
349+
logger.debug("Could not load output specs for %s: %s", law_id, exc)
350+
351+
_OUTPUT_SPEC_CACHE[law_id] = specs
352+
return specs
353+
354+
355+
_OUTPUT_SPEC_CACHE: dict[str, dict[str, dict[str, Any]]] = {}
324356

325357

326358
_INPUT_SPEC_CACHE: dict[str, dict[str, dict[str, Any]]] = {}

0 commit comments

Comments
 (0)