okstra 0.148.1 → 0.150.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/docs/architecture.md +1 -1
- package/docs/cli.md +1 -1
- package/docs/project-structure-overview.md +1 -1
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/translator-worker.md +67 -0
- package/runtime/bin/okstra-render-final-report.py +0 -11
- package/runtime/bin/okstra-render-report-views.py +20 -0
- package/runtime/bin/okstra-report-translate.py +158 -0
- package/runtime/prompts/lead/adapters/claude-code.md +1 -1
- package/runtime/prompts/lead/okstra-lead-contract.md +1 -0
- package/runtime/prompts/lead/report-writer.md +14 -13
- package/runtime/prompts/lead/team-contract.md +2 -2
- package/runtime/prompts/profiles/project-analysis.md +18 -0
- package/runtime/prompts/profiles/release-handoff.md +3 -0
- package/runtime/prompts/profiles/requirements-discovery.md +7 -0
- package/runtime/prompts/wizard/prompts.ko.json +17 -1
- package/runtime/python/okstra_ctl/analysis_inputs.py +24 -9
- package/runtime/python/okstra_ctl/analysis_packet.py +23 -1
- package/runtime/python/okstra_ctl/clarification_items.py +241 -44
- package/runtime/python/okstra_ctl/codex_dispatch.py +2 -1
- package/runtime/python/okstra_ctl/dispatch_core.py +2 -2
- package/runtime/python/okstra_ctl/dispatch_state.py +12 -1
- package/runtime/python/okstra_ctl/final_report_paths.py +22 -1
- package/runtime/python/okstra_ctl/i18n.py +12 -7
- package/runtime/python/okstra_ctl/render_final_report.py +18 -17
- package/runtime/python/okstra_ctl/report_html/common.py +77 -47
- package/runtime/python/okstra_ctl/report_html/filters.py +63 -30
- package/runtime/python/okstra_ctl/report_html/models.py +15 -0
- package/runtime/python/okstra_ctl/report_html/render.py +86 -5
- package/runtime/python/okstra_ctl/report_html/view_models/change_impact_analysis.py +14 -4
- package/runtime/python/okstra_ctl/report_html/view_models/error_analysis.py +3 -3
- package/runtime/python/okstra_ctl/report_html/view_models/feature_analysis.py +5 -3
- package/runtime/python/okstra_ctl/report_html/view_models/final_verification.py +2 -7
- package/runtime/python/okstra_ctl/report_html/view_models/implementation.py +5 -9
- package/runtime/python/okstra_ctl/report_html/view_models/implementation_planning.py +2 -5
- package/runtime/python/okstra_ctl/report_html/view_models/improvement_discovery.py +1 -2
- package/runtime/python/okstra_ctl/report_html/view_models/project_analysis.py +90 -4
- package/runtime/python/okstra_ctl/report_html/view_models/release_handoff.py +1 -8
- package/runtime/python/okstra_ctl/report_html/view_models/requirements_discovery.py +6 -4
- package/runtime/python/okstra_ctl/report_html/visualizations.py +146 -11
- package/runtime/python/okstra_ctl/report_translation.py +440 -0
- package/runtime/python/okstra_ctl/report_view_artifacts.py +5 -0
- package/runtime/python/okstra_ctl/report_views.py +23 -9
- package/runtime/python/okstra_ctl/run.py +1 -1
- package/runtime/python/okstra_ctl/time_report.py +2 -2
- package/runtime/python/okstra_ctl/usage_report.py +2 -2
- package/runtime/python/okstra_ctl/user_response.py +11 -6
- package/runtime/python/okstra_ctl/wizard.py +100 -25
- package/runtime/python/okstra_ctl/worker_liveness.py +130 -36
- package/runtime/schemas/final-report-v2.0.schema.json +229 -1
- package/runtime/templates/reports/final-report.template.md +55 -0
- package/runtime/templates/reports/html/assets/base.css +59 -9
- package/runtime/templates/reports/html/base.template.html +23 -44
- package/runtime/templates/reports/html/i18n/en.json +395 -0
- package/runtime/templates/reports/html/i18n/ko.json +395 -0
- package/runtime/templates/reports/html/macros/forms.html +20 -20
- package/runtime/templates/reports/html/macros/layout.html +18 -5
- package/runtime/templates/reports/html/macros/visualizations.html +7 -5
- package/runtime/templates/reports/html/tasks/change-impact-analysis.template.html +30 -15
- package/runtime/templates/reports/html/tasks/error-analysis.template.html +22 -15
- package/runtime/templates/reports/html/tasks/feature-analysis.template.html +35 -15
- package/runtime/templates/reports/html/tasks/final-verification.template.html +21 -14
- package/runtime/templates/reports/html/tasks/implementation-planning.template.html +78 -19
- package/runtime/templates/reports/html/tasks/implementation.template.html +34 -16
- package/runtime/templates/reports/html/tasks/improvement-discovery.template.html +11 -11
- package/runtime/templates/reports/html/tasks/project-analysis.template.html +65 -25
- package/runtime/templates/reports/html/tasks/release-handoff.template.html +28 -14
- package/runtime/templates/reports/html/tasks/requirements-discovery.template.html +35 -15
- package/runtime/templates/reports/report.js +8 -5
- package/runtime/validators/validate-report-views.py +1 -1
- package/runtime/validators/validate-run.py +28 -31
- package/runtime/validators/validate_analysis_report.py +36 -0
- package/src/cli-registry.mjs +11 -0
- package/src/commands/inspect/worker-liveness.mjs +9 -7
- package/src/commands/report/translate.mjs +31 -0
- package/src/lib/helper-scripts.mjs +1 -0
- package/runtime/templates/reports/i18n/ko.json +0 -273
|
@@ -1,64 +1,75 @@
|
|
|
1
1
|
"""Common data transformations that carry no task-specific section order."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# Every block whose rows prose cites but no section of its own renders, with
|
|
8
|
+
# the keys each one names its content and its provenance by. A key hunt across
|
|
9
|
+
# all of them would misread `crossVerification.consensus`, whose `evidence` key
|
|
10
|
+
# holds provenance while `evidence.primary` uses the same word for content —
|
|
11
|
+
# so the block a row came from is named here rather than guessed.
|
|
12
|
+
_LEDGER_BLOCKS = (
|
|
13
|
+
(("evidence", "primary"), "evidence", "source", "unclassified"),
|
|
14
|
+
(("evidence", "secondary"), "hypothesis", "confidence", "hypothesis"),
|
|
15
|
+
(("analysisCommon", "confirmedFacts"), "statement", "", "confirmed fact"),
|
|
16
|
+
(("analysisCommon", "inferences"), "statement", "confidence", "inference"),
|
|
17
|
+
(("analysisCommon", "unknowns"), "question", "reason", "unknown"),
|
|
18
|
+
(("crossVerification", "consensus"), "statement", "evidence", "cross-check consensus"),
|
|
19
|
+
(("crossVerification", "differences"), "disagreement", "workersPosition", "cross-check dissent"),
|
|
20
|
+
(("missingInformation",), "item", "risk", "missing information"),
|
|
21
|
+
(("endStateCoverage",), "coveredBy", "evidence", "exit contract"),
|
|
22
|
+
(("followUpTasks",), "title", "reason", "follow-up"),
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _dig(data: dict, path: tuple[str, ...]) -> list:
|
|
27
|
+
node: object = data
|
|
28
|
+
for key in path:
|
|
29
|
+
if not isinstance(node, dict):
|
|
30
|
+
return []
|
|
31
|
+
node = node.get(key)
|
|
32
|
+
return node if isinstance(node, list) else []
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _ledger_row(row: dict, text_key: str, source_key: str, kind: str) -> dict[str, object]:
|
|
6
36
|
return {
|
|
7
|
-
"
|
|
8
|
-
"
|
|
9
|
-
"
|
|
37
|
+
"id": row.get("id", ""),
|
|
38
|
+
"kind": kind,
|
|
39
|
+
"text": str(row.get(text_key) or ""),
|
|
40
|
+
"codeEvidence": row.get("currentCodeEvidence") or [],
|
|
41
|
+
"source": str(row.get(source_key) or "") if source_key else "",
|
|
10
42
|
}
|
|
11
43
|
|
|
12
44
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def _first_filled(row: dict, keys: tuple[str, ...]) -> str:
|
|
18
|
-
for key in keys:
|
|
19
|
-
value = row.get(key)
|
|
20
|
-
if value:
|
|
21
|
-
return str(value)
|
|
22
|
-
return ""
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def _ledger_row(row: dict) -> dict[str, object]:
|
|
26
|
-
"""Flatten one evidence row into the single shape the ledger renders.
|
|
45
|
+
def _own_section_ids(data: dict) -> set[str]:
|
|
46
|
+
"""Ids a section of the report already renders and anchors.
|
|
27
47
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
- ``evidence.primary`` — ``evidence`` / ``source``
|
|
33
|
-
- ``evidence.secondary`` — ``hypothesis`` / ``confidence``
|
|
34
|
-
- ``analysisCommon.confirmedFacts`` — ``statement`` / ``currentCodeEvidence``
|
|
35
|
-
- ``analysisCommon.inferences`` — ``statement`` / ``confidence``
|
|
36
|
-
- ``analysisCommon.unknowns`` — ``question`` / ``reason``
|
|
37
|
-
|
|
38
|
-
Only this function knows which block a row came from, so the key hunt
|
|
39
|
-
belongs here rather than in the template.
|
|
48
|
+
The ledger is the fallback home for a cited row, so it must not claim an id
|
|
49
|
+
that has one — two elements with the same anchor send half the links to the
|
|
50
|
+
wrong place. `crossVerification.consensus` numbers its rows `C-001` in some
|
|
51
|
+
runs, exactly where a clarification lives.
|
|
40
52
|
"""
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
53
|
+
from ..report_contract import TASK_TYPE_DATA_PROPERTY
|
|
54
|
+
|
|
55
|
+
found: set[str] = set()
|
|
56
|
+
_collect_ids(data.get("clarificationItems", []), found)
|
|
57
|
+
property_name = TASK_TYPE_DATA_PROPERTY.get(data.get("header", {}).get("taskType", ""))
|
|
58
|
+
if property_name:
|
|
59
|
+
_collect_ids(data.get(property_name, {}), found)
|
|
60
|
+
return found
|
|
47
61
|
|
|
48
62
|
|
|
49
63
|
def evidence_index(data: dict) -> dict[str, object]:
|
|
64
|
+
owned = _own_section_ids(data)
|
|
50
65
|
rows: dict[str, object] = {}
|
|
51
|
-
for
|
|
52
|
-
for row in data
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
rows[row_id] = _ledger_row(row)
|
|
56
|
-
analysis = data.get("analysisCommon", {})
|
|
57
|
-
for collection in ("confirmedFacts", "inferences", "unknowns"):
|
|
58
|
-
for row in analysis.get(collection, []):
|
|
66
|
+
for path, text_key, source_key, kind in _LEDGER_BLOCKS:
|
|
67
|
+
for row in _dig(data, path):
|
|
68
|
+
if not isinstance(row, dict):
|
|
69
|
+
continue
|
|
59
70
|
row_id = row.get("id")
|
|
60
|
-
if row_id:
|
|
61
|
-
rows[row_id] = _ledger_row(row)
|
|
71
|
+
if row_id and row_id not in owned and row_id not in rows:
|
|
72
|
+
rows[row_id] = _ledger_row(row, text_key, source_key, kind)
|
|
62
73
|
return rows
|
|
63
74
|
|
|
64
75
|
|
|
@@ -74,6 +85,25 @@ def _collect_ids(value: object, found: set[str]) -> None:
|
|
|
74
85
|
_collect_ids(nested, found)
|
|
75
86
|
|
|
76
87
|
|
|
88
|
+
_ROW_ID = re.compile(r"[A-Z]{1,3}-\d+")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def anchor_index(data: dict) -> dict[str, str]:
|
|
92
|
+
"""Map every row a reader can reach to the anchor name that lands on it.
|
|
93
|
+
|
|
94
|
+
Prose cites ids across section boundaries — a hotspot names a
|
|
95
|
+
cross-verification finding, a quality row names a difference — so the
|
|
96
|
+
target set spans the whole reader-facing report: the task's own sections,
|
|
97
|
+
the clarifications, and every block the ledger takes in.
|
|
98
|
+
|
|
99
|
+
It stops there. `summary` is the AI-facing digest and
|
|
100
|
+
`analysisCommon.scope` describes the analysis target rather than listing
|
|
101
|
+
rows; neither renders, so a link to one would land nowhere.
|
|
102
|
+
"""
|
|
103
|
+
found = _own_section_ids(data) | set(evidence_index(data))
|
|
104
|
+
return {row_id: f"id-{row_id}" for row_id in sorted(found) if _ROW_ID.fullmatch(row_id)}
|
|
105
|
+
|
|
106
|
+
|
|
77
107
|
def analysis_review_ids(data: dict) -> tuple[str, ...]:
|
|
78
108
|
found: set[str] = set()
|
|
79
109
|
_collect_ids(data.get("analysisCommon", {}), found)
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
"""Jinja filters for the task-specific HTML report."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
-
import html
|
|
5
|
-
import json
|
|
6
4
|
import re
|
|
7
5
|
|
|
8
6
|
import okstra_vendor # noqa: F401 # registers vendored dependency aliases
|
|
@@ -11,35 +9,43 @@ from markupsafe import Markup, escape
|
|
|
11
9
|
_INLINE_CODE = re.compile(r"`([^`]+)`")
|
|
12
10
|
_SENTENCE_END = re.compile(r"(?<=[.!?])\s+")
|
|
13
11
|
_SENTENCES_PER_PARAGRAPH = 2
|
|
12
|
+
_ID_TOKEN = re.compile(r"\b[A-Z]{1,3}-\d+\b")
|
|
14
13
|
|
|
15
14
|
|
|
16
|
-
def
|
|
17
|
-
"""
|
|
15
|
+
def _link_ids(escaped: str, anchors: dict) -> str:
|
|
16
|
+
"""Anchor the row ids inside text that is already escaped.
|
|
18
17
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
Running after the escape keeps this anchor the only markup in the result.
|
|
19
|
+
Only ids the document defines become links — an id with no row would point
|
|
20
|
+
at a missing anchor, and a ticket number like ``DEV-10339`` has the same
|
|
21
|
+
shape as a row id without being one.
|
|
23
22
|
"""
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
if not anchors:
|
|
24
|
+
return escaped
|
|
26
25
|
|
|
26
|
+
def swap(match: re.Match) -> str:
|
|
27
|
+
name = anchors.get(match.group(0))
|
|
28
|
+
return f'<a href="#{name}">{match.group(0)}</a>' if name else match.group(0)
|
|
27
29
|
|
|
28
|
-
|
|
30
|
+
return _ID_TOKEN.sub(swap, escaped)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def inline_code(value: object, anchors: dict | None = None) -> Markup:
|
|
29
34
|
"""Convert paired backticks to ``<code>``, escaping everything else.
|
|
30
35
|
|
|
31
36
|
Workers author report prose with Markdown conventions, so an identifier
|
|
32
|
-
arrives wrapped in backticks. ``
|
|
33
|
-
|
|
37
|
+
arrives wrapped in backticks. ``anchors`` links the row ids cited in the
|
|
38
|
+
prose, inside backticks and out.
|
|
34
39
|
"""
|
|
40
|
+
index = anchors or {}
|
|
35
41
|
text = "" if value is None else str(value)
|
|
36
42
|
out: list[str] = []
|
|
37
43
|
cursor = 0
|
|
38
44
|
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>")
|
|
45
|
+
out.append(_link_ids(str(escape(text[cursor:match.start()])), index))
|
|
46
|
+
out.append(f"<code>{_link_ids(str(escape(match.group(1))), index)}</code>")
|
|
41
47
|
cursor = match.end()
|
|
42
|
-
out.append(str(escape(text[cursor:])))
|
|
48
|
+
out.append(_link_ids(str(escape(text[cursor:])), index))
|
|
43
49
|
return Markup("".join(out))
|
|
44
50
|
|
|
45
51
|
|
|
@@ -56,7 +62,7 @@ def _grouped_paragraphs(text: str) -> list[str]:
|
|
|
56
62
|
]
|
|
57
63
|
|
|
58
64
|
|
|
59
|
-
def paragraphs(value: object) -> Markup:
|
|
65
|
+
def paragraphs(value: object, anchors: dict | None = None) -> Markup:
|
|
60
66
|
"""Split prose into ``<p>`` blocks, converting inline code inside each.
|
|
61
67
|
|
|
62
68
|
``userNarrative`` prose arrives as one unbroken run of sentences, so the
|
|
@@ -64,30 +70,57 @@ def paragraphs(value: object) -> Markup:
|
|
|
64
70
|
did supply blank lines keeps their own boundaries.
|
|
65
71
|
"""
|
|
66
72
|
text = "" if value is None else str(value)
|
|
67
|
-
return Markup(
|
|
73
|
+
return Markup(
|
|
74
|
+
"".join(f"<p>{inline_code(block, anchors)}</p>" for block in _grouped_paragraphs(text))
|
|
75
|
+
)
|
|
68
76
|
|
|
69
77
|
|
|
70
|
-
def evidence_refs(refs: object,
|
|
71
|
-
"""Render
|
|
78
|
+
def evidence_refs(refs: object, anchors: object) -> Markup:
|
|
79
|
+
"""Render a citation list, anchoring the entries the document defines.
|
|
72
80
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
no ``<code>`` wrapper.
|
|
81
|
+
The same index the prose links against decides here too — a citation and a
|
|
82
|
+
mid-sentence mention of the same id must land in the same place. No schema
|
|
83
|
+
behind this filter constrains an entry to an id: most of it is prose citing
|
|
84
|
+
a ``path:line``, which takes ``inline_code`` like every other prose cell.
|
|
78
85
|
"""
|
|
79
|
-
|
|
86
|
+
index = anchors if isinstance(anchors, dict) else {}
|
|
80
87
|
items = refs if isinstance(refs, (list, tuple)) else []
|
|
81
88
|
out: list[str] = []
|
|
82
89
|
for ref in items:
|
|
83
|
-
|
|
84
|
-
|
|
90
|
+
name = index.get(str(ref))
|
|
91
|
+
if name is None:
|
|
92
|
+
out.append(str(inline_code(ref, index)))
|
|
85
93
|
continue
|
|
86
|
-
|
|
87
|
-
out.append(f'<a href="#ev-{text}">{text}</a>')
|
|
94
|
+
out.append(f'<a href="#{name}">{escape(str(ref))}</a>')
|
|
88
95
|
return Markup(", ".join(out))
|
|
89
96
|
|
|
90
97
|
|
|
98
|
+
# Schema enums a reader has to decode. Each vocabulary is named so a value can
|
|
99
|
+
# mean different things in different fields without one table flattening them.
|
|
100
|
+
# The tables live in `templates/reports/html/i18n/<lang>.json` beside the rest
|
|
101
|
+
# of the page's fixed strings — a label is chrome, not data, so it follows the
|
|
102
|
+
# reader's language rather than the SSOT's. A value with no entry renders as
|
|
103
|
+
# itself: a new enum member shows up raw rather than silently reading as
|
|
104
|
+
# something it is not.
|
|
105
|
+
def enum_label(value: object, vocabulary: str, chrome: dict) -> str:
|
|
106
|
+
"""Render a schema enum as the words it stands for."""
|
|
107
|
+
raw = "" if value is None else str(value)
|
|
108
|
+
return (chrome.get("enum") or {}).get(vocabulary, {}).get(raw, raw)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def enum_legend(vocabulary: str, chrome: dict) -> list[tuple[str, str]]:
|
|
112
|
+
"""Pair each label with what it claims, for a legend beside the table.
|
|
113
|
+
|
|
114
|
+
A two-word label fits a table cell but cannot carry a definition, and a
|
|
115
|
+
reader meeting "Passes without proving" for the first time has nowhere to
|
|
116
|
+
look it up. Built from the same dictionary the cells render from, so a
|
|
117
|
+
legend cannot drift from the labels it explains.
|
|
118
|
+
"""
|
|
119
|
+
labels = (chrome.get("enum") or {}).get(vocabulary, {})
|
|
120
|
+
hints = (chrome.get("enumHint") or {}).get(vocabulary, {})
|
|
121
|
+
return [(labels.get(value, value), hint) for value, hint in hints.items()]
|
|
122
|
+
|
|
123
|
+
|
|
91
124
|
def code_evidence(rows: object) -> Markup:
|
|
92
125
|
"""Render ``currentCodeEvidence`` entries as ``path:line`` code spans."""
|
|
93
126
|
items = rows if isinstance(rows, (list, tuple)) else []
|
|
@@ -11,15 +11,30 @@ class HtmlRunMeta:
|
|
|
11
11
|
task_type: str
|
|
12
12
|
seq: str
|
|
13
13
|
source_report: str
|
|
14
|
+
# Wall-clock milliseconds for the run, or None when the team-state
|
|
15
|
+
# carried no timestamps to measure between.
|
|
16
|
+
elapsed_ms: int | None = None
|
|
14
17
|
|
|
15
18
|
|
|
16
19
|
@dataclass(frozen=True)
|
|
17
20
|
class VisualNode:
|
|
18
21
|
id: str
|
|
19
22
|
label: str
|
|
23
|
+
# Which column the figure puts this node in, and which fill it draws it
|
|
24
|
+
# with. Both are drawing instructions: `group` is often a path prefix and
|
|
25
|
+
# `status` is often a constant. The text table printed them raw under the
|
|
26
|
+
# headings "group" and "status", so in a single-root project every
|
|
27
|
+
# component read "src · stable" — a layout key and a literal, neither a
|
|
28
|
+
# fact about the project. Anything the reader should see goes in `note`.
|
|
20
29
|
group: str
|
|
21
30
|
status: str
|
|
22
31
|
detail: str
|
|
32
|
+
# Files the node stands for. The figure's text alternative is the only
|
|
33
|
+
# place a reader can look them up, so they travel with the node.
|
|
34
|
+
paths: tuple[str, ...] = ()
|
|
35
|
+
# What this node is, in the reader's words. Empty when the figure has
|
|
36
|
+
# nothing to say beyond the label.
|
|
37
|
+
note: str = ""
|
|
23
38
|
|
|
24
39
|
|
|
25
40
|
@dataclass(frozen=True)
|
|
@@ -3,14 +3,26 @@ from __future__ import annotations
|
|
|
3
3
|
|
|
4
4
|
import hashlib
|
|
5
5
|
import json
|
|
6
|
+
import sys
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
|
|
8
9
|
import okstra_vendor # noqa: F401 # registers vendored dependency aliases
|
|
9
10
|
from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoescape
|
|
10
11
|
|
|
12
|
+
from ..final_report_paths import translation_sidecar_path
|
|
11
13
|
from ..final_report_schema import load_schema_for_data, validate
|
|
14
|
+
from ..i18n import HTML_DICTIONARY_REL, load_dictionary, make_jinja_global
|
|
15
|
+
from ..report_translation import overlay
|
|
12
16
|
from ..report_view_artifacts import user_responses_dir_for_report
|
|
13
|
-
from .
|
|
17
|
+
from .common import anchor_index
|
|
18
|
+
from .filters import (
|
|
19
|
+
code_evidence,
|
|
20
|
+
enum_label,
|
|
21
|
+
enum_legend,
|
|
22
|
+
evidence_refs,
|
|
23
|
+
inline_code,
|
|
24
|
+
paragraphs,
|
|
25
|
+
)
|
|
14
26
|
from .models import HtmlRunMeta
|
|
15
27
|
from .router import HtmlRenderError, resolve_html_route
|
|
16
28
|
|
|
@@ -30,6 +42,65 @@ def _templates_root(start: Path | None = None) -> Path:
|
|
|
30
42
|
raise HtmlRenderError("could not locate templates/reports")
|
|
31
43
|
|
|
32
44
|
|
|
45
|
+
def _elapsed_text(elapsed_ms: int | None) -> str | None:
|
|
46
|
+
"""Render a run duration, or nothing when there is none to render.
|
|
47
|
+
|
|
48
|
+
A run whose team-state never recorded timestamps has no measured duration;
|
|
49
|
+
printing "0m" would claim it finished instantly.
|
|
50
|
+
"""
|
|
51
|
+
if not elapsed_ms or elapsed_ms < 0:
|
|
52
|
+
return None
|
|
53
|
+
minutes, seconds = divmod(round(elapsed_ms / 1000), 60)
|
|
54
|
+
hours, minutes = divmod(minutes, 60)
|
|
55
|
+
if hours:
|
|
56
|
+
return f"{hours}h {minutes}m"
|
|
57
|
+
if minutes:
|
|
58
|
+
return f"{minutes}m {seconds}s"
|
|
59
|
+
return f"{seconds}s"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _report_meta(data: dict, run_meta: HtmlRunMeta) -> dict[str, object]:
|
|
63
|
+
return {
|
|
64
|
+
"createdAt": data.get("header", {}).get("createdAt", ""),
|
|
65
|
+
"taskTitle": data.get("frontmatter", {}).get("title", ""),
|
|
66
|
+
"taskKey": run_meta.task_key,
|
|
67
|
+
"elapsed": _elapsed_text(run_meta.elapsed_ms),
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _localize(data: dict, data_path: Path) -> tuple[dict, str]:
|
|
72
|
+
"""Apply the translation sidecar for this report's language, if there is one.
|
|
73
|
+
|
|
74
|
+
The data.json is the English SSOT and is never rewritten — the sidecar is
|
|
75
|
+
read here and overlaid onto an in-memory copy, so only this HTML document
|
|
76
|
+
speaks the reader's language. A pointer the sidecar left untranslated
|
|
77
|
+
renders in English; that keeps a half-finished translation readable, and
|
|
78
|
+
the counts are printed because a silently half-empty sidecar otherwise
|
|
79
|
+
ships looking finished.
|
|
80
|
+
"""
|
|
81
|
+
lang = str((data.get("meta") or {}).get("reportLanguage") or "en")
|
|
82
|
+
if lang == "en":
|
|
83
|
+
return data, lang
|
|
84
|
+
sidecar_file = translation_sidecar_path(data_path, lang)
|
|
85
|
+
if not sidecar_file.is_file():
|
|
86
|
+
sys.stdout.write(
|
|
87
|
+
f"note: no {lang} translation sidecar at {sidecar_file.name}; "
|
|
88
|
+
"rendering the English source\n"
|
|
89
|
+
)
|
|
90
|
+
return data, lang
|
|
91
|
+
payload = json.loads(sidecar_file.read_text(encoding="utf-8"))
|
|
92
|
+
strings = payload.get("strings")
|
|
93
|
+
if not isinstance(strings, dict):
|
|
94
|
+
raise HtmlRenderError(f"translation sidecar has no 'strings' object: {sidecar_file}")
|
|
95
|
+
localized, report = overlay(data, strings)
|
|
96
|
+
sys.stdout.write(
|
|
97
|
+
f"translated {report.applied} string(s) into {lang}"
|
|
98
|
+
f" ({len(report.untranslated)} left in English"
|
|
99
|
+
f", {len(report.unresolved)} unresolved)\n"
|
|
100
|
+
)
|
|
101
|
+
return localized, lang
|
|
102
|
+
|
|
103
|
+
|
|
33
104
|
def _html_path(data_path: Path) -> Path:
|
|
34
105
|
suffix = ".data.json"
|
|
35
106
|
if not data_path.name.endswith(suffix):
|
|
@@ -50,21 +121,31 @@ def render_v2_html_view(
|
|
|
50
121
|
raise HtmlRenderError("invalid v2 final-report data: " + "; ".join(errors[:5]))
|
|
51
122
|
if not markdown_path.is_file():
|
|
52
123
|
raise HtmlRenderError(f"v2 markdown sibling not found: {markdown_path}")
|
|
124
|
+
# Validate the SSOT, then localize — the sidecar carries presentation and
|
|
125
|
+
# has no say in whether the report is well-formed.
|
|
126
|
+
data, lang = _localize(data, data_path)
|
|
53
127
|
route = resolve_html_route(run_meta.task_type)
|
|
54
128
|
view = route.view_builder(data)
|
|
55
129
|
root = _templates_root(templates_root)
|
|
56
130
|
env = Environment(loader=FileSystemLoader(str(root)), autoescape=select_autoescape(("html",)), undefined=StrictUndefined)
|
|
57
131
|
env.policies["json.dumps_kwargs"] = {"sort_keys": True, "ensure_ascii": False}
|
|
58
|
-
|
|
132
|
+
# Binding the index here is what lets a template cite an id without
|
|
133
|
+
# threading the index through every macro and call site.
|
|
134
|
+
anchors = anchor_index(data)
|
|
135
|
+
chrome = load_dictionary(lang, HTML_DICTIONARY_REL)
|
|
136
|
+
env.globals["t"] = make_jinja_global(chrome)
|
|
59
137
|
env.filters["code_evidence"] = code_evidence
|
|
60
|
-
env.filters["
|
|
61
|
-
env.filters["
|
|
62
|
-
env.filters["
|
|
138
|
+
env.filters["enum_label"] = lambda value, vocabulary: enum_label(value, vocabulary, chrome)
|
|
139
|
+
env.filters["enum_legend"] = lambda vocabulary: enum_legend(vocabulary, chrome)
|
|
140
|
+
env.filters["evidence_refs"] = lambda refs: evidence_refs(refs, anchors)
|
|
141
|
+
env.filters["inline_code"] = lambda value: inline_code(value, anchors)
|
|
142
|
+
env.filters["paragraphs"] = lambda value: paragraphs(value, anchors)
|
|
63
143
|
response_js = (root / "report.js").read_text(encoding="utf-8")
|
|
64
144
|
base_js = (root / "html/assets/base.js").read_text(encoding="utf-8")
|
|
65
145
|
context = {
|
|
66
146
|
**view.context,
|
|
67
147
|
"runMeta": run_meta,
|
|
148
|
+
"reportMeta": _report_meta(data, run_meta),
|
|
68
149
|
"taskType": view.task_type,
|
|
69
150
|
"dataSha256": _sha256(data_path),
|
|
70
151
|
"markdownSha256": _sha256(markdown_path),
|
|
@@ -1,20 +1,31 @@
|
|
|
1
1
|
"""Human-first change-impact-analysis view model."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
-
from ..common import analysis_review_ids,
|
|
4
|
+
from ..common import analysis_review_ids, evidence_index
|
|
5
5
|
from ..models import HumanReportView, VisualEdge, VisualNode
|
|
6
6
|
from ..visualizations import flow_figure
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
def _impact_figure(change: dict):
|
|
10
10
|
nodes = [
|
|
11
|
-
VisualNode(
|
|
11
|
+
VisualNode(
|
|
12
|
+
row["id"],
|
|
13
|
+
row["target"],
|
|
14
|
+
"affected",
|
|
15
|
+
row["level"],
|
|
16
|
+
row["impactKind"],
|
|
17
|
+
note=f'Impact {row["level"]}',
|
|
18
|
+
)
|
|
12
19
|
for row in change.get("impactItems", [])
|
|
13
20
|
]
|
|
14
21
|
edges: list[VisualEdge] = []
|
|
15
22
|
for index, row in enumerate(change.get("dependencyBlastRadius", []), start=1):
|
|
16
23
|
target_id = f"BR-{index:03d}"
|
|
17
|
-
nodes.append(
|
|
24
|
+
nodes.append(
|
|
25
|
+
VisualNode(
|
|
26
|
+
target_id, row["affectedTarget"], "downstream", "risk", row["direction"], note="Downstream"
|
|
27
|
+
)
|
|
28
|
+
)
|
|
18
29
|
edges.append(VisualEdge(row["sourceImpactId"], target_id, row["direction"], "impact"))
|
|
19
30
|
return flow_figure(nodes=tuple(nodes), edges=tuple(edges), title="Change blast radius")
|
|
20
31
|
|
|
@@ -29,7 +40,6 @@ def build_change_impact_analysis_view(data: dict) -> HumanReportView:
|
|
|
29
40
|
"blastRadiusFigure": figure,
|
|
30
41
|
"analysisReviewIds": analysis_review_ids(data),
|
|
31
42
|
"evidenceIndex": evidence_index(data),
|
|
32
|
-
"audit": audit_context(data),
|
|
33
43
|
}
|
|
34
44
|
return HumanReportView(
|
|
35
45
|
task_type="change-impact-analysis",
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
"""Human-first error-analysis view model."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
-
from ..common import
|
|
4
|
+
from ..common import evidence_index
|
|
5
5
|
from ..models import HumanReportView, VisualEdge, VisualNode
|
|
6
6
|
from ..visualizations import cause_graph_figure
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
def _cause_figure(error: dict):
|
|
10
10
|
symptom = VisualNode(
|
|
11
|
-
"symptom", "Observed failure", "effect", "risk", error["observableFailure"]
|
|
11
|
+
"symptom", "Observed failure", "effect", "risk", error["observableFailure"], note="Observed failure"
|
|
12
12
|
)
|
|
13
13
|
causes = tuple(
|
|
14
14
|
VisualNode(
|
|
@@ -17,6 +17,7 @@ def _cause_figure(error: dict):
|
|
|
17
17
|
"candidate",
|
|
18
18
|
row["confidence"],
|
|
19
19
|
row["disproveWith"],
|
|
20
|
+
note=f'Confidence {row["confidence"]}',
|
|
20
21
|
)
|
|
21
22
|
for row in error.get("causeCandidates", [])
|
|
22
23
|
)
|
|
@@ -39,7 +40,6 @@ def build_error_analysis_view(data: dict) -> HumanReportView:
|
|
|
39
40
|
"narrative": error["userNarrative"],
|
|
40
41
|
"causeFigure": figure,
|
|
41
42
|
"evidenceIndex": evidence_index(data),
|
|
42
|
-
"audit": audit_context(data),
|
|
43
43
|
}
|
|
44
44
|
return HumanReportView(
|
|
45
45
|
"error-analysis",
|
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
"""Human-first feature-analysis view model."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
-
from ..common import analysis_review_ids,
|
|
4
|
+
from ..common import analysis_review_ids, evidence_index
|
|
5
5
|
from ..models import HumanReportView, VisualNode
|
|
6
6
|
from ..visualizations import flow_figure
|
|
7
7
|
|
|
8
8
|
|
|
9
|
+
_FLOW_KINDS = {"normal": "Normal path", "alternative": "Alternative path", "failure": "Failure path"}
|
|
10
|
+
|
|
11
|
+
|
|
9
12
|
def _flow_nodes(feature: dict) -> tuple[VisualNode, ...]:
|
|
10
13
|
return tuple(
|
|
11
14
|
VisualNode(
|
|
12
15
|
id=row["id"],
|
|
13
|
-
label=row["kind"]
|
|
16
|
+
label=_FLOW_KINDS.get(row["kind"], row["kind"]),
|
|
14
17
|
group=row["kind"],
|
|
15
18
|
status="risk" if row["kind"] == "failure" else "stable",
|
|
16
19
|
detail=" → ".join(step["action"] for step in row.get("steps", [])),
|
|
@@ -29,7 +32,6 @@ def build_feature_analysis_view(data: dict) -> HumanReportView:
|
|
|
29
32
|
"flowFigure": figure,
|
|
30
33
|
"analysisReviewIds": analysis_review_ids(data),
|
|
31
34
|
"evidenceIndex": evidence_index(data),
|
|
32
|
-
"audit": audit_context(data),
|
|
33
35
|
}
|
|
34
36
|
return HumanReportView(
|
|
35
37
|
task_type="feature-analysis",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Human-first final-verification view model."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
-
from ..common import
|
|
4
|
+
from ..common import evidence_index
|
|
5
5
|
from ..models import HumanReportView, VisualNode
|
|
6
6
|
from ..visualizations import coverage_figure
|
|
7
7
|
|
|
@@ -14,6 +14,7 @@ def _coverage_nodes(final: dict) -> tuple[VisualNode, ...]:
|
|
|
14
14
|
"requirement",
|
|
15
15
|
row["status"],
|
|
16
16
|
row["artifact"],
|
|
17
|
+
note=row["status"],
|
|
17
18
|
)
|
|
18
19
|
for row in final["validationEvidence"]
|
|
19
20
|
)
|
|
@@ -24,11 +25,6 @@ def build_final_verification_view(data: dict) -> HumanReportView:
|
|
|
24
25
|
figure = coverage_figure(
|
|
25
26
|
rows=_coverage_nodes(final), title="Requirement verification coverage"
|
|
26
27
|
)
|
|
27
|
-
audit = audit_context(data)
|
|
28
|
-
audit["deliveryEvidence"] = {
|
|
29
|
-
"sourceImplementation": final["sourceImplementationReport"],
|
|
30
|
-
"readonlyCommandLog": final["readonlyCommandLog"],
|
|
31
|
-
}
|
|
32
28
|
context = {
|
|
33
29
|
"humanSummary": data["humanSummary"],
|
|
34
30
|
"verdict": data["verdictCard"],
|
|
@@ -37,7 +33,6 @@ def build_final_verification_view(data: dict) -> HumanReportView:
|
|
|
37
33
|
"coverageFigure": figure,
|
|
38
34
|
"releaseAllowed": data["verdictCard"]["verdictToken"] == "accepted",
|
|
39
35
|
"evidenceIndex": evidence_index(data),
|
|
40
|
-
"audit": audit,
|
|
41
36
|
}
|
|
42
37
|
return HumanReportView(
|
|
43
38
|
"final-verification",
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
"""Human-first implementation delivery view model."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
-
from ..common import
|
|
4
|
+
from ..common import evidence_index
|
|
5
5
|
from ..models import HumanReportView, VisualNode
|
|
6
6
|
from ..visualizations import change_map_figure
|
|
7
7
|
|
|
8
8
|
|
|
9
|
+
_FILE_ACTIONS = {"created": "Created", "modified": "Modified", "deleted": "Deleted"}
|
|
10
|
+
|
|
11
|
+
|
|
9
12
|
def _change_nodes(implementation: dict) -> tuple[VisualNode, ...]:
|
|
10
13
|
return tuple(
|
|
11
14
|
VisualNode(
|
|
@@ -14,6 +17,7 @@ def _change_nodes(implementation: dict) -> tuple[VisualNode, ...]:
|
|
|
14
17
|
row["planStep"],
|
|
15
18
|
row["action"],
|
|
16
19
|
row["lines"],
|
|
20
|
+
note=f'{_FILE_ACTIONS.get(row["action"], row["action"])} · plan step {row["planStep"]}',
|
|
17
21
|
)
|
|
18
22
|
for row in implementation["diffSummary"]["files"]
|
|
19
23
|
)
|
|
@@ -24,20 +28,12 @@ def build_implementation_view(data: dict) -> HumanReportView:
|
|
|
24
28
|
figure = change_map_figure(
|
|
25
29
|
nodes=_change_nodes(implementation), title="Delivered change areas"
|
|
26
30
|
)
|
|
27
|
-
audit = audit_context(data)
|
|
28
|
-
audit["deliveryEvidence"] = {
|
|
29
|
-
"commits": implementation["commitList"],
|
|
30
|
-
"rawDiffStat": implementation["diffSummary"]["rawStat"],
|
|
31
|
-
"verifiers": implementation["verifierResults"],
|
|
32
|
-
"validationCommands": implementation["validationEvidence"],
|
|
33
|
-
}
|
|
34
31
|
context = {
|
|
35
32
|
"humanSummary": data["humanSummary"],
|
|
36
33
|
"implementation": implementation,
|
|
37
34
|
"narrative": implementation["userNarrative"],
|
|
38
35
|
"changeFigure": figure,
|
|
39
36
|
"evidenceIndex": evidence_index(data),
|
|
40
|
-
"audit": audit,
|
|
41
37
|
}
|
|
42
38
|
return HumanReportView(
|
|
43
39
|
"implementation",
|