Skip to content

Commit f32966b

Browse files
hf-kkleinhf-kkleinclaude
authored
feat: ship every activity diagram, linked per sequence-diagram variant (#26)
* feat: ship every activity diagram, linked per SD variant webapp_export looked for a process's activity diagram at the bare {pid}.svg only. But p11 names them after the document's own AD headings, so a process with several sequence-diagram variants has {pid}_{slug} files — note the SINGLE underscore, where the sequence artifacts use {pid}__{slug}. On the v0.0.9 dataset that mismatch left 115 of 173 rendered diagrams unreachable: they were generated, committed and then dropped here without a word. Resolve each variant's diagram via {pid}_{slug} with the bare {pid} as fallback, and record the result per diagram as activitySvg (None when that variant has none, so the app never links a 404). Copy EVERY rendered diagram into the webapp, not just the linked ones, and emit activity_diagrams.json indexing all of them with a `linked` flag — the ones whose p11 names predate the UC de-truncation still have no process to hang off, but they are at least reachable now. Unlinked names print as a worklist, like unresolved refs. On the v0.0.9 dataset: 173 diagrams copied (was 58), 82 linked, and processes showing an activity diagram go from 58 to 68 of 194. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep an AD-only process, and report ambiguous activity-diagram names Review findings on the per-variant resolution: - `any(ad_for_slug)` is False for a process with no sequence diagram at all, so a process whose Aktivitätsdiagramm exists without an SD would have lost its diagram — reintroducing, in a new place, the silent drop this branch removes. p11 renders ADs independently of p06, so fall back to the bare artifact when the process has no SD variants. - `{pid}_{slug}` is indistinguishable from a bare id ending in that slug (`wechsel` + `lieferant` vs a process `wechsel_lieferant`). First claim wins, which is deterministic because processes are walked in sorted order, but the collision is now reported instead of silently absorbed. It does not occur on the v0.0.9 dataset. - build_detail emits activitySvg as None rather than a path it cannot verify; run() attaches the real one, the same contract as `approval`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: state the naming trap without dataset-specific counts Copilot review: the comments pinned counts from one dataset version, and disagreed with each other about which count. Numbers that drift with every regeneration do not belong in code comments — the measured figures live in the PR description, stamped to the dataset tag they were measured on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: hf-kklein <konstantin.klein+claude@hochfrequenz.de> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 782b4c4 commit f32966b

3 files changed

Lines changed: 216 additions & 4 deletions

File tree

src/makoralle/grouping.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,19 @@ def sd_artifact_key(uc_id: str, slug: str, n_sds: int) -> str:
6868
return uc_id if n_sds <= 1 else f"{uc_id}__{slug}"
6969

7070

71+
def ad_artifact_key(uc_id: str, slug: str, n_sds: int) -> str:
72+
"""Artifact filename key for an activity diagram (``output/bpmn/``).
73+
74+
Same shape as :func:`sd_artifact_key` but with a SINGLE underscore, because
75+
p11 names the activity diagrams after the document's own AD headings:
76+
``bestellung_zur_stammdatenänderung_an_lf_verantwortlich`` next to the SD's
77+
``bestellung_zur_stammdatenänderung__an_lf_verantwortlich``. Getting this
78+
separator wrong silently strands every variant's diagram, so it is spelled out
79+
here rather than inlined at the call site.
80+
"""
81+
return uc_id if n_sds <= 1 else f"{uc_id}_{slug}"
82+
83+
7184
def _parent(section_id: str) -> str:
7285
return section_id.rsplit(".", 1)[0] if "." in section_id else section_id
7386

src/makoralle/webapp_export.py

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
import yaml
1919

20-
from makoralle.grouping import sd_artifact_key
20+
from makoralle.grouping import ad_artifact_key, sd_artifact_key
2121
from makoralle.ref_links import build_ref_map, load_ref_overrides, resolve_ref
2222

2323

@@ -142,6 +142,10 @@ def build_detail(process: dict[str, Any], *, review_notes: list[str]) -> dict[st
142142
"deadlines": _deadline_table(d_steps),
143143
"pids": _pid_table(d_steps),
144144
"svg": f"/diagrams/sequence/{key}.svg",
145+
# Attached by run(), which can see which artifact actually exists;
146+
# build_detail has no filesystem, so it emits None rather than a
147+
# path that may 404 (same contract as `approval` below).
148+
"activitySvg": None,
145149
}
146150
)
147151
# Back-compat: the top-level steps/deadlines/pids/participants mirror the
@@ -314,6 +318,12 @@ def run( # pylint: disable=too-many-locals,too-many-branches,too-many-statement
314318
# Every current diagram artifact key we consult, so after the loop we can spot
315319
# approval ENTRIES that match no diagram at all (variant removed / slug renamed).
316320
consulted_keys: set[str] = set()
321+
# Activity-diagram artifact -> the process that claims it. Whatever is left over
322+
# is still copied and indexed, but is not reachable from a process page. Two
323+
# processes claiming one artifact means p11's naming is ambiguous (a bare id that
324+
# looks like another process's {pid}_{slug}), so record it rather than absorb it.
325+
claimed_ads: dict[str, str] = {}
326+
contested_ads: list[str] = []
317327

318328
# Load every process up front so the subprocess-ref resolver sees ALL SD
319329
# variants (a ref names another process's SD, which may sort later).
@@ -352,7 +362,24 @@ def run( # pylint: disable=too-many-locals,too-many-branches,too-many-statement
352362
if step["ref_target"] is None:
353363
unresolved_refs.add(ref)
354364
keys = [sd_artifact_key(pid, d.get("slug", ""), len(diagrams)) for d in diagrams]
355-
has_bpmn = (bpmn_svg / f"{pid}.svg").exists()
365+
# Activity diagrams are keyed per SD variant ({pid}_{slug}), with the bare
366+
# {pid} as fallback — checking only the bare name leaves every variant's
367+
# diagram unreachable. Record which artifacts got claimed so the leftovers
368+
# can be listed (and still shipped) below.
369+
ad_keys = [ad_artifact_key(pid, d.get("slug", ""), len(diagrams)) for d in diagrams]
370+
ad_for_slug: list[str | None] = []
371+
for ad_key in ad_keys:
372+
found = next((k for k in (ad_key, pid) if (bpmn_svg / f"{k}.svg").exists()), None)
373+
ad_for_slug.append(found)
374+
if found and claimed_ads.setdefault(found, pid) != pid:
375+
contested_ads.append(f"{found} (claimed by {claimed_ads[found]} and {pid})")
376+
# A process with no sequence diagram at all still gets its bare activity
377+
# diagram: p11 renders ADs independently of SDs, and `any([])` would have
378+
# dropped it — the exact silent-drop this change exists to remove.
379+
bare_only = not diagrams and (bpmn_svg / f"{pid}.svg").exists()
380+
if bare_only:
381+
claimed_ads.setdefault(pid, pid)
382+
has_bpmn = any(ad_for_slug) or bare_only
356383
has_seq = any((seq_svg / f"{key}.svg").exists() for key in keys)
357384
# [REVIEW] notes ("Prüfung nötig" worklist) can live in ANY SD's .wsd;
358385
# aggregate across all, de-duplicating while preserving order.
@@ -393,6 +420,10 @@ def run( # pylint: disable=too-many-locals,too-many-branches,too-many-statement
393420
d_svg = seq_svg / f"{key}.svg"
394421
if d_svg.exists():
395422
shutil.copyfile(d_svg, dest_seq / f"{key}.svg")
423+
# Point each diagram at the activity artifact that actually exists (None
424+
# when this variant has none), so the app never links a 404.
425+
for diagram, resolved_ad in zip(detail["diagrams"], ad_for_slug, strict=True):
426+
diagram["activitySvg"] = f"/diagrams/bpmn/{resolved_ad}.svg" if resolved_ad else None
396427
# detail.approval = the PRIMARY diagram's approval (back-compat; single-SD
397428
# key == pid so this equals the old {pid}.wsd result).
398429
detail["approval"] = detail["diagrams"][0]["approval"] if detail["diagrams"] else None
@@ -413,8 +444,32 @@ def run( # pylint: disable=too-many-locals,too-many-branches,too-many-statement
413444
# on the file itself, not has_seq (multi-SD has only {pid}__{slug}.svg).
414445
if (seq_svg / f"{pid}.svg").exists():
415446
shutil.copyfile(seq_svg / f"{pid}.svg", dest_seq / f"{pid}.svg")
416-
if has_bpmn:
417-
shutil.copyfile(bpmn_svg / f"{pid}.svg", dest_bpmn / f"{pid}.svg")
447+
448+
# Copy EVERY rendered activity diagram, not just the ones a process page links:
449+
# anything unlinked used to be dropped here without a word. The ones that resolve
450+
# to no process/variant stay reachable through activity_diagrams.json (browse view).
451+
all_ads = sorted(p.stem for p in bpmn_svg.glob("*.svg")) if bpmn_svg.exists() else []
452+
for ad_key in all_ads:
453+
shutil.copyfile(bpmn_svg / f"{ad_key}.svg", dest_bpmn / f"{ad_key}.svg")
454+
unclaimed = [k for k in all_ads if k not in claimed_ads]
455+
(data_dir / "activity_diagrams.json").write_text(
456+
json.dumps(
457+
[{"name": k, "svg": f"/diagrams/bpmn/{k}.svg", "linked": k in claimed_ads} for k in all_ads],
458+
ensure_ascii=False,
459+
indent=2,
460+
),
461+
"utf-8",
462+
)
463+
print(
464+
f"activity diagrams: {len(all_ads)} copied, {len(claimed_ads)} linked to a process, {len(unclaimed)} unlinked"
465+
)
466+
# One artifact wanted by two processes: p11's {pid}_{slug} is indistinguishable
467+
# from a bare id that ends in that slug. First claim wins (processes are walked in
468+
# sorted order, so it is at least deterministic), but say so rather than hide it.
469+
if contested_ads:
470+
print(f"ambiguous activity-diagram names: {len(contested_ads)}")
471+
for contested in contested_ads:
472+
print(f" - {contested}")
418473

419474
index.sort(key=lambda e: (e["category"], e["name"].lower()))
420475
(data_dir / "processes.json").write_text(json.dumps(index, ensure_ascii=False, indent=2), "utf-8")
@@ -431,6 +486,13 @@ def run( # pylint: disable=too-many-locals,too-many-branches,too-many-statement
431486
print(f"approved: {approved_count}/{len(index)}{note}")
432487
# Worklist: distinct subprocess refs that resolved to no target. Curate these
433488
# in sd_ref_links.yaml (ambiguous / garbled scenario-bundle refs).
489+
# Worklist: rendered activity diagrams that resolve to no process/variant —
490+
# mostly p11 names predating the UC de-truncation (see the dataset's
491+
# REGENERATION.md). They ship, but only the browse view reaches them.
492+
if unclaimed:
493+
print(f"unlinked activity diagrams: {len(unclaimed)}")
494+
for ad_key in unclaimed:
495+
print(f" - {ad_key}")
434496
if unresolved_refs:
435497
print(f"unresolved refs: {len(unresolved_refs)} (add to sd_ref_links.yaml):")
436498
for ref in sorted(unresolved_refs):

unittests/test_build_webapp_data.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import yaml
66

7+
from makoralle.grouping import ad_artifact_key, sd_artifact_key
78
from makoralle.webapp_export import (
89
build_detail,
910
build_index_entry,
@@ -745,3 +746,139 @@ def test_run_reports_orphaned_approval_entries(tmp_path: pathlib.Path, capsys: A
745746

746747
out_text = capsys.readouterr().out
747748
assert "orphaned" in out_text and "ghost__variant" in out_text
749+
750+
751+
def test_ad_artifact_key_uses_a_single_underscore() -> None:
752+
"""p11 names activity diagrams `{pid}_{slug}` where the SDs use `{pid}__{slug}`.
753+
Conflating the two strands every variant's diagram."""
754+
assert ad_artifact_key("wechsel", "lieferant", 1) == "wechsel"
755+
assert ad_artifact_key("wechsel", "lieferant", 3) == "wechsel_lieferant"
756+
assert ad_artifact_key("wechsel", "lieferant", 3) != sd_artifact_key("wechsel", "lieferant", 3)
757+
758+
759+
def test_run_links_per_variant_activity_diagrams(tmp_path: pathlib.Path) -> None:
760+
"""A multi-SD process's activity diagrams live at {pid}_{slug}; each variant gets
761+
its own, and a variant without one gets None rather than a 404 link."""
762+
out, web = tmp_path / "output", tmp_path / "webapp"
763+
pid = "wechsel"
764+
_write(out / "yaml" / f"{pid}.yaml", yaml.safe_dump(TWO_SD, allow_unicode=True))
765+
# only the first variant has an activity diagram rendered
766+
_write(out / "bpmn" / f"{pid}_lieferant.svg", "<svg>ad-lieferant</svg>")
767+
768+
run(output_dir=out, webapp_dir=web)
769+
770+
detail = json.loads((web / f"src/data/processes/{pid}.json").read_text("utf-8"))
771+
by_slug = {d["slug"]: d["activitySvg"] for d in detail["diagrams"]}
772+
assert by_slug["lieferant"] == f"/diagrams/bpmn/{pid}_lieferant.svg"
773+
assert by_slug["netzbetreiber"] is None
774+
assert json.loads((web / "src/data/processes.json").read_text("utf-8"))[0]["hasBpmn"] is True
775+
assert (web / f"public/diagrams/bpmn/{pid}_lieferant.svg").exists()
776+
777+
778+
def test_run_falls_back_to_the_bare_pid_activity_diagram(tmp_path: pathlib.Path) -> None:
779+
"""A multi-SD process whose activity diagram is named after the process only
780+
(no variant suffix) still resolves — every variant points at it."""
781+
out, web = tmp_path / "output", tmp_path / "webapp"
782+
pid = "wechsel"
783+
_write(out / "yaml" / f"{pid}.yaml", yaml.safe_dump(TWO_SD, allow_unicode=True))
784+
_write(out / "bpmn" / f"{pid}.svg", "<svg>ad</svg>")
785+
786+
run(output_dir=out, webapp_dir=web)
787+
788+
detail = json.loads((web / f"src/data/processes/{pid}.json").read_text("utf-8"))
789+
assert {d["activitySvg"] for d in detail["diagrams"]} == {f"/diagrams/bpmn/{pid}.svg"}
790+
791+
792+
def test_run_ships_and_indexes_activity_diagrams_that_link_to_no_process(tmp_path: pathlib.Path) -> None:
793+
"""Diagrams whose p11 name matches no process/variant (stale pre-de-truncation
794+
names) must still be copied and indexed — they used to be dropped silently."""
795+
out, web = tmp_path / "output", tmp_path / "webapp"
796+
pid = "abstimmung_der_netzzeitreihe"
797+
_write(out / "yaml" / f"{pid}.yaml", yaml.safe_dump(SAMPLE, allow_unicode=True))
798+
_write(out / "bpmn" / f"{pid}.svg", "<svg>linked</svg>")
799+
_write(out / "bpmn" / "zuordnung_eines_bilanzkreises_zur_aufnah-.svg", "<svg>orphan</svg>")
800+
801+
run(output_dir=out, webapp_dir=web)
802+
803+
ads = json.loads((web / "src/data/activity_diagrams.json").read_text("utf-8"))
804+
by_name = {a["name"]: a for a in ads}
805+
assert by_name[pid]["linked"] is True
806+
orphan = by_name["zuordnung_eines_bilanzkreises_zur_aufnah-"]
807+
assert orphan["linked"] is False
808+
assert orphan["svg"] == "/diagrams/bpmn/zuordnung_eines_bilanzkreises_zur_aufnah-.svg"
809+
# shipped despite linking to nothing, so the browse view can reach it
810+
assert (web / "public/diagrams/bpmn/zuordnung_eines_bilanzkreises_zur_aufnah-.svg").exists()
811+
812+
813+
def test_run_rewrites_activity_diagram_dir_without_orphans(tmp_path: pathlib.Path) -> None:
814+
"""The dest dir is fully generated: a diagram removed upstream must not survive."""
815+
out, web = tmp_path / "output", tmp_path / "webapp"
816+
pid = "abstimmung_der_netzzeitreihe"
817+
_write(out / "yaml" / f"{pid}.yaml", yaml.safe_dump(SAMPLE, allow_unicode=True))
818+
_write(out / "bpmn" / f"{pid}.svg", "<svg>ad</svg>")
819+
_write(web / "public/diagrams/bpmn/gone.svg", "<svg>stale</svg>")
820+
821+
run(output_dir=out, webapp_dir=web)
822+
823+
assert not (web / "public/diagrams/bpmn/gone.svg").exists()
824+
assert (web / f"public/diagrams/bpmn/{pid}.svg").exists()
825+
826+
827+
NO_SD: dict[str, Any] = {
828+
"process": {"id": "nur_ad", "name": "Nur AD", "category": "GPKE", "source": ""},
829+
"use_case": {"roles": []},
830+
}
831+
832+
833+
def test_run_links_the_activity_diagram_of_a_process_without_any_sequence_diagram(
834+
tmp_path: pathlib.Path,
835+
) -> None:
836+
"""p11 renders activity diagrams independently of p06's sequence diagrams, so a
837+
process can have an AD and no SD. Resolving per variant must not drop it."""
838+
out, web = tmp_path / "output", tmp_path / "webapp"
839+
_write(out / "yaml" / "nur_ad.yaml", yaml.safe_dump(NO_SD, allow_unicode=True))
840+
_write(out / "bpmn" / "nur_ad.svg", "<svg>ad</svg>")
841+
842+
run(output_dir=out, webapp_dir=web)
843+
844+
entry = json.loads((web / "src/data/processes.json").read_text("utf-8"))[0]
845+
assert entry["sdCount"] == 0
846+
assert entry["hasBpmn"] is True
847+
ads = json.loads((web / "src/data/activity_diagrams.json").read_text("utf-8"))
848+
assert [a["linked"] for a in ads if a["name"] == "nur_ad"] == [True]
849+
850+
851+
def test_run_reports_an_activity_diagram_two_processes_could_claim(tmp_path: pathlib.Path, capsys: Any) -> None:
852+
"""`{pid}_{slug}` is ambiguous with a bare id that happens to contain the slug:
853+
`wechsel` + variant `lieferant` and a process named `wechsel_lieferant` both want
854+
wechsel_lieferant.svg. Report it instead of silently handing it to whoever sorts first."""
855+
out, web = tmp_path / "output", tmp_path / "webapp"
856+
_write(out / "yaml" / "wechsel.yaml", yaml.safe_dump(TWO_SD, allow_unicode=True))
857+
collider = {
858+
"process": {"id": "wechsel_lieferant", "name": "Wechsel Lieferant", "category": "GPKE", "source": ""},
859+
"use_case": {"roles": []},
860+
"sequence_diagram": {"participants": [], "steps": []},
861+
}
862+
_write(out / "yaml" / "wechsel_lieferant.yaml", yaml.safe_dump(collider, allow_unicode=True))
863+
_write(out / "bpmn" / "wechsel_lieferant.svg", "<svg>contested</svg>")
864+
865+
run(output_dir=out, webapp_dir=web)
866+
867+
printed = capsys.readouterr().out
868+
assert "ambiguous activity-diagram names: 1" in printed
869+
assert "wechsel_lieferant (claimed by" in printed
870+
871+
872+
def test_run_prints_the_unlinked_activity_diagrams_as_a_worklist(tmp_path: pathlib.Path, capsys: Any) -> None:
873+
out, web = tmp_path / "output", tmp_path / "webapp"
874+
pid = "abstimmung_der_netzzeitreihe"
875+
_write(out / "yaml" / f"{pid}.yaml", yaml.safe_dump(SAMPLE, allow_unicode=True))
876+
_write(out / "bpmn" / f"{pid}.svg", "<svg>linked</svg>")
877+
_write(out / "bpmn" / "zuordnung_eines_bilanzkreises_zur_aufnah-.svg", "<svg>orphan</svg>")
878+
879+
run(output_dir=out, webapp_dir=web)
880+
881+
printed = capsys.readouterr().out
882+
assert "173 copied" not in printed # sanity: this fixture has 2, not the real dataset
883+
assert "2 copied, 1 linked to a process, 1 unlinked" in printed
884+
assert " - zuordnung_eines_bilanzkreises_zur_aufnah-" in printed

0 commit comments

Comments
 (0)