okstra 0.147.0 → 0.148.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -7
- package/docs/architecture/storage-model.md +34 -61
- package/docs/architecture.md +51 -49
- package/docs/cli.md +38 -21
- package/docs/for-ai/skills/okstra-run.md +13 -34
- package/docs/performance-improvement-plan-v2.md +2 -2
- package/docs/pr-template-usage.md +1 -1
- package/docs/project-structure-overview.md +10 -8
- package/docs/task-process/README.md +4 -4
- package/docs/task-process/common-flow.md +12 -12
- package/docs/task-process/final-verification.md +2 -2
- package/docs/task-process/implementation.md +1 -1
- package/docs/task-process/release-handoff.md +1 -1
- package/package.json +2 -2
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/antigravity-worker.md +2 -2
- package/runtime/agents/workers/claude-worker.md +1 -1
- package/runtime/agents/workers/codex-worker.md +2 -2
- package/runtime/agents/workers/grok-worker.md +256 -0
- package/runtime/agents/workers/kimi-worker.md +256 -0
- package/runtime/agents/workers/report-writer-worker.md +2 -2
- package/runtime/bin/lib/okstra/cli.sh +13 -1
- package/runtime/bin/lib/okstra/globals.sh +3 -0
- package/runtime/bin/lib/okstra/usage.sh +17 -12
- package/runtime/bin/okstra-grok-exec.sh +5 -0
- package/runtime/bin/okstra-kimi-exec.sh +5 -0
- package/runtime/bin/okstra-provider-exec.py +235 -0
- package/runtime/bin/okstra.sh +3 -0
- package/runtime/prompts/lead/adapters/antigravity.md +48 -0
- package/runtime/prompts/lead/adapters/claude-code.md +13 -11
- package/runtime/prompts/lead/adapters/codex.md +7 -7
- package/runtime/prompts/lead/okstra-lead-contract.md +1 -1
- package/runtime/prompts/lead/report-writer.md +1 -1
- package/runtime/prompts/profiles/_coding-conventions-preflight.md +1 -1
- package/runtime/prompts/profiles/_common-contract.md +4 -4
- package/runtime/prompts/profiles/_implementation-deliverable.md +2 -2
- package/runtime/prompts/profiles/_implementation-diff-review.md +1 -1
- package/runtime/prompts/profiles/_implementation-executor.md +12 -12
- package/runtime/prompts/profiles/_implementation-self-check.md +4 -4
- package/runtime/prompts/profiles/_implementation-verifier.md +3 -3
- package/runtime/prompts/profiles/change-impact-analysis.md +2 -0
- package/runtime/prompts/profiles/error-analysis.md +2 -0
- package/runtime/prompts/profiles/feature-analysis.md +2 -0
- package/runtime/prompts/profiles/final-verification.md +3 -1
- package/runtime/prompts/profiles/forbidden-actions.json +4 -4
- package/runtime/prompts/profiles/implementation-planning.md +3 -1
- package/runtime/prompts/profiles/implementation.md +2 -2
- package/runtime/prompts/profiles/improvement-discovery.md +3 -1
- package/runtime/prompts/profiles/project-analysis.md +2 -0
- package/runtime/prompts/profiles/release-handoff.md +7 -7
- package/runtime/prompts/profiles/requirements-discovery.md +2 -0
- package/runtime/prompts/wizard/prompts.ko.json +9 -1
- package/runtime/python/okstra_ctl/codex_dispatch.py +68 -87
- package/runtime/python/okstra_ctl/dispatch_core.py +4 -22
- package/runtime/python/okstra_ctl/lead_events.py +1 -1
- package/runtime/python/okstra_ctl/lead_runtime.py +13 -2
- package/runtime/python/okstra_ctl/models.py +156 -8
- package/runtime/python/okstra_ctl/path_hints.py +9 -25
- package/runtime/python/okstra_ctl/paths.py +1 -1
- package/runtime/python/okstra_ctl/render.py +172 -74
- package/runtime/python/okstra_ctl/report_html/common.py +38 -2
- package/runtime/python/okstra_ctl/report_html/filters.py +104 -0
- package/runtime/python/okstra_ctl/report_html/render.py +7 -0
- package/runtime/python/okstra_ctl/report_html/view_models/change_impact_analysis.py +2 -1
- package/runtime/python/okstra_ctl/report_html/view_models/error_analysis.py +2 -1
- package/runtime/python/okstra_ctl/report_html/view_models/feature_analysis.py +2 -1
- package/runtime/python/okstra_ctl/report_html/view_models/final_verification.py +2 -1
- package/runtime/python/okstra_ctl/report_html/view_models/implementation.py +2 -1
- package/runtime/python/okstra_ctl/report_html/view_models/implementation_planning.py +2 -1
- package/runtime/python/okstra_ctl/report_html/view_models/improvement_discovery.py +2 -1
- package/runtime/python/okstra_ctl/report_html/view_models/release_handoff.py +2 -1
- package/runtime/python/okstra_ctl/report_html/view_models/requirements_discovery.py +2 -1
- package/runtime/python/okstra_ctl/report_html/visualizations.py +32 -6
- package/runtime/python/okstra_ctl/run.py +264 -45
- package/runtime/python/okstra_ctl/runner_resolution.py +103 -0
- package/runtime/python/okstra_ctl/team.py +2 -7
- package/runtime/python/okstra_ctl/wizard.py +194 -21
- package/runtime/python/okstra_ctl/worker_artifacts.py +46 -0
- package/runtime/python/okstra_ctl/workers.py +3 -1
- package/runtime/python/okstra_ctl/workflow.py +4 -2
- package/runtime/python/okstra_token_usage/__init__.py +1 -0
- package/runtime/python/okstra_token_usage/collect.py +32 -23
- package/runtime/python/okstra_token_usage/pricing.py +35 -3
- package/runtime/schemas/final-report-v2.0.schema.json +2 -2
- package/runtime/skills/okstra-run/SKILL.md +31 -42
- package/runtime/templates/prd/pr-body.template.md +1 -1
- package/runtime/templates/reports/html/assets/base.css +6 -3
- package/runtime/templates/reports/html/base.template.html +22 -8
- package/runtime/templates/reports/html/macros/forms.html +2 -2
- package/runtime/templates/reports/html/macros/layout.html +5 -5
- package/runtime/templates/reports/html/macros/visualizations.html +11 -1
- package/runtime/templates/reports/html/tasks/change-impact-analysis.template.html +11 -11
- package/runtime/templates/reports/html/tasks/error-analysis.template.html +12 -12
- package/runtime/templates/reports/html/tasks/feature-analysis.template.html +9 -9
- package/runtime/templates/reports/html/tasks/final-verification.template.html +7 -7
- package/runtime/templates/reports/html/tasks/implementation-planning.template.html +12 -12
- package/runtime/templates/reports/html/tasks/implementation.template.html +7 -7
- package/runtime/templates/reports/html/tasks/improvement-discovery.template.html +6 -6
- package/runtime/templates/reports/html/tasks/project-analysis.template.html +11 -11
- package/runtime/templates/reports/html/tasks/release-handoff.template.html +5 -5
- package/runtime/templates/reports/html/tasks/requirements-discovery.template.html +8 -8
- package/runtime/templates/reports/report.js +21 -4
- package/runtime/templates/reports/settings.template.json +4 -0
- package/runtime/templates/reports/task-brief.template.md +7 -7
- package/runtime/validators/validate-run.py +11 -6
- package/runtime/validators/validate_session_conformance.py +2 -1
- package/src/cli-registry.mjs +4 -4
- package/src/commands/execute/codex-dispatch.mjs +7 -10
- package/src/commands/execute/render-bundle.mjs +3 -3
- package/src/commands/execute/run.mjs +17 -52
- package/src/commands/execute/wizard.mjs +4 -1
- package/src/commands/lifecycle/doctor.mjs +6 -3
- package/src/commands/lifecycle/install.mjs +31 -8
- package/src/lib/runtime-manifest.mjs +1 -1
- package/src/lib/runtime-resolver.mjs +2 -2
- package/src/lib/worker-agent-render.mjs +50 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Jinja filters for the task-specific HTML report."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import html
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
import okstra_vendor # noqa: F401 # registers vendored dependency aliases
|
|
9
|
+
from markupsafe import Markup, escape
|
|
10
|
+
|
|
11
|
+
_INLINE_CODE = re.compile(r"`([^`]+)`")
|
|
12
|
+
_SENTENCE_END = re.compile(r"(?<=[.!?])\s+")
|
|
13
|
+
_SENTENCES_PER_PARAGRAPH = 2
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def audit_json(value: object) -> Markup:
|
|
17
|
+
"""Serialize an audit payload for reading inside a ``<pre>`` text node.
|
|
18
|
+
|
|
19
|
+
Jinja's ``tojson`` guards a ``<script>`` context, so it escapes quotes and
|
|
20
|
+
angle brackets as ``\\uXXXX`` and leaves shell commands and non-ASCII
|
|
21
|
+
evidence unreadable. A text node only needs ``&``, ``<`` and ``>`` escaped,
|
|
22
|
+
hence ``quote=False`` — quotes carry no meaning outside an attribute value.
|
|
23
|
+
"""
|
|
24
|
+
rendered = json.dumps(value, indent=2, ensure_ascii=False, sort_keys=True)
|
|
25
|
+
return Markup(html.escape(rendered, quote=False))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def inline_code(value: object) -> Markup:
|
|
29
|
+
"""Convert paired backticks to ``<code>``, escaping everything else.
|
|
30
|
+
|
|
31
|
+
Workers author report prose with Markdown conventions, so an identifier
|
|
32
|
+
arrives wrapped in backticks. ``escape`` also covers quotes, unlike
|
|
33
|
+
``audit_json`` above, which is text-node only.
|
|
34
|
+
"""
|
|
35
|
+
text = "" if value is None else str(value)
|
|
36
|
+
out: list[str] = []
|
|
37
|
+
cursor = 0
|
|
38
|
+
for match in _INLINE_CODE.finditer(text):
|
|
39
|
+
out.append(str(escape(text[cursor:match.start()])))
|
|
40
|
+
out.append(f"<code>{escape(match.group(1))}</code>")
|
|
41
|
+
cursor = match.end()
|
|
42
|
+
out.append(str(escape(text[cursor:])))
|
|
43
|
+
return Markup("".join(out))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _grouped_paragraphs(text: str) -> list[str]:
|
|
47
|
+
explicit = [block.strip() for block in re.split(r"\n\s*\n", text) if block.strip()]
|
|
48
|
+
if len(explicit) > 1:
|
|
49
|
+
return explicit
|
|
50
|
+
sentences = [s.strip() for s in _SENTENCE_END.split(text.strip()) if s.strip()]
|
|
51
|
+
if len(sentences) <= _SENTENCES_PER_PARAGRAPH:
|
|
52
|
+
return [text.strip()] if text.strip() else []
|
|
53
|
+
return [
|
|
54
|
+
" ".join(sentences[i:i + _SENTENCES_PER_PARAGRAPH])
|
|
55
|
+
for i in range(0, len(sentences), _SENTENCES_PER_PARAGRAPH)
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def paragraphs(value: object) -> Markup:
|
|
60
|
+
"""Split prose into ``<p>`` blocks, converting inline code inside each.
|
|
61
|
+
|
|
62
|
+
``userNarrative`` prose arrives as one unbroken run of sentences, so the
|
|
63
|
+
reader gets no paragraph boundary anywhere in the document. An author who
|
|
64
|
+
did supply blank lines keeps their own boundaries.
|
|
65
|
+
"""
|
|
66
|
+
text = "" if value is None else str(value)
|
|
67
|
+
return Markup("".join(f"<p>{inline_code(block)}</p>" for block in _grouped_paragraphs(text)))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def evidence_refs(refs: object, index: object) -> Markup:
|
|
71
|
+
"""Render evidence ids as anchors when the ledger defines them.
|
|
72
|
+
|
|
73
|
+
A citation whose id has no ledger row would link to a missing anchor, so it
|
|
74
|
+
stays text. No schema behind this filter constrains that text to an id —
|
|
75
|
+
most of it is prose citing a ``path:line``, so it takes ``inline_code`` like
|
|
76
|
+
every other prose cell. An anchor's own text is the id it links to and gets
|
|
77
|
+
no ``<code>`` wrapper.
|
|
78
|
+
"""
|
|
79
|
+
rows = index if isinstance(index, dict) else {}
|
|
80
|
+
items = refs if isinstance(refs, (list, tuple)) else []
|
|
81
|
+
out: list[str] = []
|
|
82
|
+
for ref in items:
|
|
83
|
+
if str(ref) not in rows:
|
|
84
|
+
out.append(str(inline_code(ref)))
|
|
85
|
+
continue
|
|
86
|
+
text = escape(str(ref))
|
|
87
|
+
out.append(f'<a href="#ev-{text}">{text}</a>')
|
|
88
|
+
return Markup(", ".join(out))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def code_evidence(rows: object) -> Markup:
|
|
92
|
+
"""Render ``currentCodeEvidence`` entries as ``path:line`` code spans."""
|
|
93
|
+
items = rows if isinstance(rows, (list, tuple)) else []
|
|
94
|
+
out: list[str] = []
|
|
95
|
+
for row in items:
|
|
96
|
+
if not isinstance(row, dict):
|
|
97
|
+
continue
|
|
98
|
+
path = str(row.get("path", "")).strip()
|
|
99
|
+
if not path:
|
|
100
|
+
continue
|
|
101
|
+
line = row.get("line")
|
|
102
|
+
ref = f"{path}:{line}" if line else path
|
|
103
|
+
out.append(f"<code>{escape(ref)}</code>")
|
|
104
|
+
return Markup(", ".join(out))
|
|
@@ -9,6 +9,7 @@ import okstra_vendor # noqa: F401 # registers vendored dependency aliases
|
|
|
9
9
|
from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoescape
|
|
10
10
|
|
|
11
11
|
from ..final_report_schema import load_schema_for_data, validate
|
|
12
|
+
from .filters import audit_json, code_evidence, evidence_refs, inline_code, paragraphs
|
|
12
13
|
from .models import HtmlRunMeta
|
|
13
14
|
from .router import HtmlRenderError, resolve_html_route
|
|
14
15
|
|
|
@@ -52,6 +53,12 @@ def render_v2_html_view(
|
|
|
52
53
|
view = route.view_builder(data)
|
|
53
54
|
root = _templates_root(templates_root)
|
|
54
55
|
env = Environment(loader=FileSystemLoader(str(root)), autoescape=select_autoescape(("html",)), undefined=StrictUndefined)
|
|
56
|
+
env.policies["json.dumps_kwargs"] = {"sort_keys": True, "ensure_ascii": False}
|
|
57
|
+
env.filters["audit_json"] = audit_json
|
|
58
|
+
env.filters["code_evidence"] = code_evidence
|
|
59
|
+
env.filters["evidence_refs"] = evidence_refs
|
|
60
|
+
env.filters["inline_code"] = inline_code
|
|
61
|
+
env.filters["paragraphs"] = paragraphs
|
|
55
62
|
response_js = (root / "report.js").read_text(encoding="utf-8")
|
|
56
63
|
base_js = (root / "html/assets/base.js").read_text(encoding="utf-8")
|
|
57
64
|
context = {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Human-first change-impact-analysis view model."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
-
from ..common import analysis_review_ids, audit_context
|
|
4
|
+
from ..common import analysis_review_ids, audit_context, evidence_index
|
|
5
5
|
from ..models import HumanReportView, VisualEdge, VisualNode
|
|
6
6
|
from ..visualizations import flow_figure
|
|
7
7
|
|
|
@@ -28,6 +28,7 @@ def build_change_impact_analysis_view(data: dict) -> HumanReportView:
|
|
|
28
28
|
"narrative": change["userNarrative"],
|
|
29
29
|
"blastRadiusFigure": figure,
|
|
30
30
|
"analysisReviewIds": analysis_review_ids(data),
|
|
31
|
+
"evidenceIndex": evidence_index(data),
|
|
31
32
|
"audit": audit_context(data),
|
|
32
33
|
}
|
|
33
34
|
return HumanReportView(
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Human-first error-analysis view model."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
-
from ..common import audit_context
|
|
4
|
+
from ..common import audit_context, evidence_index
|
|
5
5
|
from ..models import HumanReportView, VisualEdge, VisualNode
|
|
6
6
|
from ..visualizations import cause_graph_figure
|
|
7
7
|
|
|
@@ -38,6 +38,7 @@ def build_error_analysis_view(data: dict) -> HumanReportView:
|
|
|
38
38
|
"error": error,
|
|
39
39
|
"narrative": error["userNarrative"],
|
|
40
40
|
"causeFigure": figure,
|
|
41
|
+
"evidenceIndex": evidence_index(data),
|
|
41
42
|
"audit": audit_context(data),
|
|
42
43
|
}
|
|
43
44
|
return HumanReportView(
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Human-first feature-analysis view model."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
-
from ..common import analysis_review_ids, audit_context
|
|
4
|
+
from ..common import analysis_review_ids, audit_context, evidence_index
|
|
5
5
|
from ..models import HumanReportView, VisualNode
|
|
6
6
|
from ..visualizations import flow_figure
|
|
7
7
|
|
|
@@ -28,6 +28,7 @@ def build_feature_analysis_view(data: dict) -> HumanReportView:
|
|
|
28
28
|
"narrative": feature["userNarrative"],
|
|
29
29
|
"flowFigure": figure,
|
|
30
30
|
"analysisReviewIds": analysis_review_ids(data),
|
|
31
|
+
"evidenceIndex": evidence_index(data),
|
|
31
32
|
"audit": audit_context(data),
|
|
32
33
|
}
|
|
33
34
|
return HumanReportView(
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Human-first final-verification view model."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
-
from ..common import audit_context
|
|
4
|
+
from ..common import audit_context, evidence_index
|
|
5
5
|
from ..models import HumanReportView, VisualNode
|
|
6
6
|
from ..visualizations import coverage_figure
|
|
7
7
|
|
|
@@ -36,6 +36,7 @@ def build_final_verification_view(data: dict) -> HumanReportView:
|
|
|
36
36
|
"narrative": final["userNarrative"],
|
|
37
37
|
"coverageFigure": figure,
|
|
38
38
|
"releaseAllowed": data["verdictCard"]["verdictToken"] == "accepted",
|
|
39
|
+
"evidenceIndex": evidence_index(data),
|
|
39
40
|
"audit": audit,
|
|
40
41
|
}
|
|
41
42
|
return HumanReportView(
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Human-first implementation delivery view model."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
-
from ..common import audit_context
|
|
4
|
+
from ..common import audit_context, evidence_index
|
|
5
5
|
from ..models import HumanReportView, VisualNode
|
|
6
6
|
from ..visualizations import change_map_figure
|
|
7
7
|
|
|
@@ -36,6 +36,7 @@ def build_implementation_view(data: dict) -> HumanReportView:
|
|
|
36
36
|
"implementation": implementation,
|
|
37
37
|
"narrative": implementation["userNarrative"],
|
|
38
38
|
"changeFigure": figure,
|
|
39
|
+
"evidenceIndex": evidence_index(data),
|
|
39
40
|
"audit": audit,
|
|
40
41
|
}
|
|
41
42
|
return HumanReportView(
|
|
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
import re
|
|
5
5
|
from dataclasses import dataclass
|
|
6
6
|
|
|
7
|
-
from ..common import audit_context
|
|
7
|
+
from ..common import audit_context, evidence_index
|
|
8
8
|
from ..models import HumanReportView, VisualEdge, VisualNode
|
|
9
9
|
from ..visualizations import stage_map_figure
|
|
10
10
|
|
|
@@ -92,6 +92,7 @@ def build_implementation_planning_view(data: dict) -> HumanReportView:
|
|
|
92
92
|
if row.get("blocks") == "approval"
|
|
93
93
|
and row.get("status") in {"open", "answered"}
|
|
94
94
|
],
|
|
95
|
+
"evidenceIndex": evidence_index(data),
|
|
95
96
|
"audit": audit,
|
|
96
97
|
}
|
|
97
98
|
return HumanReportView(
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Human-first improvement-discovery view model."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
-
from ..common import audit_context
|
|
4
|
+
from ..common import audit_context, evidence_index
|
|
5
5
|
from ..models import HumanReportView, VisualNode
|
|
6
6
|
from ..visualizations import matrix_figure
|
|
7
7
|
|
|
@@ -32,6 +32,7 @@ def build_improvement_discovery_view(data: dict) -> HumanReportView:
|
|
|
32
32
|
"improvement": improvement,
|
|
33
33
|
"narrative": improvement["userNarrative"],
|
|
34
34
|
"matrixFigure": figure,
|
|
35
|
+
"evidenceIndex": evidence_index(data),
|
|
35
36
|
"audit": audit_context(data),
|
|
36
37
|
}
|
|
37
38
|
return HumanReportView(
|
|
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
|
|
4
4
|
from urllib.parse import urlparse
|
|
5
5
|
|
|
6
|
-
from ..common import audit_context
|
|
6
|
+
from ..common import audit_context, evidence_index
|
|
7
7
|
from ..models import HumanReportView, VisualNode
|
|
8
8
|
from ..visualizations import timeline_figure
|
|
9
9
|
|
|
@@ -43,6 +43,7 @@ def build_release_handoff_view(data: dict) -> HumanReportView:
|
|
|
43
43
|
"narrative": handoff["userNarrative"],
|
|
44
44
|
"handoffFigure": figure,
|
|
45
45
|
"prUrl": _safe_http_url(handoff["pullRequestOutcome"].get("url", "")),
|
|
46
|
+
"evidenceIndex": evidence_index(data),
|
|
46
47
|
"audit": audit,
|
|
47
48
|
}
|
|
48
49
|
return HumanReportView(
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Human-first requirements-discovery view model."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
-
from ..common import audit_context
|
|
4
|
+
from ..common import audit_context, evidence_index
|
|
5
5
|
from ..models import HumanReportView, VisualEdge, VisualNode
|
|
6
6
|
from ..visualizations import decision_flow_figure
|
|
7
7
|
|
|
@@ -44,6 +44,7 @@ def build_requirements_discovery_view(data: dict) -> HumanReportView:
|
|
|
44
44
|
"narrative": requirements["userNarrative"],
|
|
45
45
|
"routingFigure": figure,
|
|
46
46
|
"clarificationItems": data.get("clarificationItems", []),
|
|
47
|
+
"evidenceIndex": evidence_index(data),
|
|
47
48
|
"audit": audit_context(data),
|
|
48
49
|
}
|
|
49
50
|
return HumanReportView(
|
|
@@ -8,28 +8,54 @@ from typing import Iterable, Sequence
|
|
|
8
8
|
from .models import FigureModel, VisualEdge, VisualNode
|
|
9
9
|
|
|
10
10
|
|
|
11
|
+
_ROWS_PER_COLUMN = 8
|
|
12
|
+
_COLUMN_WIDTH = 260
|
|
13
|
+
_ROW_HEIGHT = 100
|
|
14
|
+
|
|
15
|
+
|
|
11
16
|
def _node_positions(nodes: Sequence[VisualNode]) -> dict[str, tuple[int, int]]:
|
|
12
|
-
|
|
13
|
-
|
|
17
|
+
"""Lay out one column per group, wrapping tall groups into extra columns."""
|
|
18
|
+
groups = sorted({n.group for n in nodes})
|
|
19
|
+
counts: dict[str, int] = {}
|
|
20
|
+
column_offset: dict[str, int] = {}
|
|
21
|
+
offset = 0
|
|
22
|
+
for group in groups:
|
|
23
|
+
column_offset[group] = offset
|
|
24
|
+
size = sum(1 for n in nodes if n.group == group)
|
|
25
|
+
offset += max(1, -(-size // _ROWS_PER_COLUMN))
|
|
14
26
|
positions: dict[str, tuple[int, int]] = {}
|
|
15
27
|
for node in nodes:
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
28
|
+
index = counts.get(node.group, 0)
|
|
29
|
+
counts[node.group] = index + 1
|
|
30
|
+
column = column_offset[node.group] + index // _ROWS_PER_COLUMN
|
|
31
|
+
row = index % _ROWS_PER_COLUMN
|
|
32
|
+
positions[node.id] = (50 + column * _COLUMN_WIDTH, 55 + row * _ROW_HEIGHT)
|
|
19
33
|
return positions
|
|
20
34
|
|
|
21
35
|
|
|
36
|
+
_ARROW_MARKER = (
|
|
37
|
+
'<defs><marker id="edge-arrow" viewBox="0 0 10 10" refX="9" refY="5" '
|
|
38
|
+
'markerWidth="6" markerHeight="6" orient="auto-start-reverse">'
|
|
39
|
+
'<path d="M 0 0 L 10 5 L 0 10 z" class="edge-arrow-head" /></marker></defs>'
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
22
43
|
def _svg_document(nodes: Sequence[VisualNode], edges: Sequence[VisualEdge]) -> str:
|
|
23
44
|
positions = _node_positions(nodes)
|
|
24
45
|
width = max((x for x, _ in positions.values()), default=50) + 230
|
|
25
46
|
height = max((y for _, y in positions.values()), default=55) + 90
|
|
26
47
|
parts = [f'<svg viewBox="0 0 {width} {height}" role="img" xmlns="http://www.w3.org/2000/svg">']
|
|
48
|
+
parts.append(_ARROW_MARKER)
|
|
27
49
|
for edge in edges:
|
|
28
50
|
if edge.source not in positions or edge.target not in positions:
|
|
29
51
|
continue
|
|
30
52
|
x1, y1 = positions[edge.source]
|
|
31
53
|
x2, y2 = positions[edge.target]
|
|
32
|
-
parts.append(
|
|
54
|
+
parts.append(
|
|
55
|
+
f'<g class="edge-group"><title>{html.escape(edge.label)}</title>'
|
|
56
|
+
f'<line x1="{x1 + 90}" y1="{y1 + 25}" x2="{x2 + 90}" y2="{y2 + 25}" '
|
|
57
|
+
f'class="edge edge-{html.escape(edge.kind)}" marker-end="url(#edge-arrow)" /></g>'
|
|
58
|
+
)
|
|
33
59
|
for node in nodes:
|
|
34
60
|
x, y = positions[node.id]
|
|
35
61
|
node_id = html.escape(node.id)
|