okstra 0.30.3 → 0.32.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/kr/architecture.md +2 -2
- package/docs/kr/cli.md +2 -2
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/SKILL.md +7 -5
- package/runtime/agents/workers/claude-worker.md +1 -1
- package/runtime/agents/workers/codex-worker.md +23 -6
- package/runtime/agents/workers/gemini-worker.md +23 -6
- package/runtime/agents/workers/report-writer-worker.md +45 -66
- package/runtime/bin/okstra-codex-exec.sh +31 -0
- package/runtime/bin/okstra-gemini-exec.sh +26 -0
- package/runtime/bin/okstra-render-final-report.py +101 -0
- package/runtime/bin/okstra-render-report-views.py +17 -10
- package/runtime/bin/okstra-token-usage.py +3 -1
- package/runtime/python/lib/okstra/globals.sh +1 -1
- package/runtime/python/lib/okstra/usage.sh +2 -2
- package/runtime/python/okstra_ctl/final_report_schema.py +253 -0
- package/runtime/python/okstra_ctl/models.py +2 -0
- package/runtime/python/okstra_ctl/render_final_report.py +201 -0
- package/runtime/python/okstra_ctl/report_views.py +276 -297
- package/runtime/python/okstra_ctl/run.py +1 -1
- package/runtime/python/okstra_ctl/wizard.py +53 -14
- package/runtime/python/okstra_ctl/workers.py +45 -11
- package/runtime/python/okstra_token_usage/__init__.py +5 -1
- package/runtime/python/okstra_token_usage/cli.py +66 -36
- package/runtime/python/okstra_token_usage/pricing.py +1 -0
- package/runtime/python/okstra_token_usage/report.py +148 -65
- package/runtime/python/okstra_vendor/__init__.py +37 -0
- package/runtime/python/okstra_vendor/jinja2/__init__.py +38 -0
- package/runtime/python/okstra_vendor/jinja2/_identifier.py +6 -0
- package/runtime/python/okstra_vendor/jinja2/async_utils.py +99 -0
- package/runtime/python/okstra_vendor/jinja2/bccache.py +408 -0
- package/runtime/python/okstra_vendor/jinja2/compiler.py +1998 -0
- package/runtime/python/okstra_vendor/jinja2/constants.py +20 -0
- package/runtime/python/okstra_vendor/jinja2/debug.py +191 -0
- package/runtime/python/okstra_vendor/jinja2/defaults.py +48 -0
- package/runtime/python/okstra_vendor/jinja2/environment.py +1672 -0
- package/runtime/python/okstra_vendor/jinja2/exceptions.py +166 -0
- package/runtime/python/okstra_vendor/jinja2/ext.py +870 -0
- package/runtime/python/okstra_vendor/jinja2/filters.py +1873 -0
- package/runtime/python/okstra_vendor/jinja2/idtracking.py +318 -0
- package/runtime/python/okstra_vendor/jinja2/lexer.py +868 -0
- package/runtime/python/okstra_vendor/jinja2/loaders.py +693 -0
- package/runtime/python/okstra_vendor/jinja2/meta.py +112 -0
- package/runtime/python/okstra_vendor/jinja2/nativetypes.py +130 -0
- package/runtime/python/okstra_vendor/jinja2/nodes.py +1206 -0
- package/runtime/python/okstra_vendor/jinja2/optimizer.py +48 -0
- package/runtime/python/okstra_vendor/jinja2/parser.py +1049 -0
- package/runtime/python/okstra_vendor/jinja2/py.typed +0 -0
- package/runtime/python/okstra_vendor/jinja2/runtime.py +1062 -0
- package/runtime/python/okstra_vendor/jinja2/sandbox.py +436 -0
- package/runtime/python/okstra_vendor/jinja2/tests.py +256 -0
- package/runtime/python/okstra_vendor/jinja2/utils.py +766 -0
- package/runtime/python/okstra_vendor/jinja2/visitor.py +92 -0
- package/runtime/python/okstra_vendor/markupsafe/__init__.py +396 -0
- package/runtime/python/okstra_vendor/markupsafe/_native.py +8 -0
- package/runtime/python/okstra_vendor/markupsafe/py.typed +0 -0
- package/runtime/schemas/final-report-v1.0.schema.json +1391 -0
- package/runtime/skills/okstra-report-writer/SKILL.md +31 -30
- package/runtime/skills/okstra-run/SKILL.md +6 -4
- package/runtime/skills/okstra-team-contract/SKILL.md +27 -3
- package/runtime/templates/reports/final-report.template.md +370 -405
- package/runtime/templates/reports/report.css +57 -4
- package/runtime/templates/reports/report.js +63 -7
- package/runtime/templates/reports/settings.template.json +1 -0
- package/runtime/validators/lib/fixtures.sh +7 -7
- package/runtime/validators/validate-report-views.py +24 -153
- package/runtime/validators/validate-run.py +102 -19
- package/src/install.mjs +21 -1
|
@@ -1,11 +1,7 @@
|
|
|
1
|
-
"""Render
|
|
1
|
+
"""Render the self-contained HTML view of an okstra final-report markdown.
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Single product, single source of truth:
|
|
4
4
|
|
|
5
|
-
* ``slim_markdown(src_md)`` — deterministic token-saving stripper that
|
|
6
|
-
removes presentational fluff while preserving every substring the
|
|
7
|
-
``validators/validate-run.py`` substring checks rely on. Intended as
|
|
8
|
-
the AI consumption surface (next-phase lead prompt input).
|
|
9
5
|
* ``render_html(src_md, *, run_meta)`` — deterministic self-contained
|
|
10
6
|
HTML renderer for human readers. Sections §5/§6/§7 user-actionable
|
|
11
7
|
rows (those reachable from §5 ``C-*`` IDs) get embedded ``<form>``
|
|
@@ -18,44 +14,9 @@ response`` button (client-side JS, single-reference-point with the
|
|
|
18
14
|
Python ``serialize_user_response`` function below) and the user pastes
|
|
19
15
|
it to ``runs/<task-type>/user-responses/user-response-<task-type>-<seq>.md``.
|
|
20
16
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
zero is meaningful audit info).
|
|
25
|
-
- ``STRIP_DECORATIVE_HR`` — collapse 3+ consecutive horizontal rules
|
|
26
|
-
to a single ``---``.
|
|
27
|
-
- ``STRIP_VERDICT_DETAIL`` — drop ``### 4.5.9 Verdict details`` wide
|
|
28
|
-
matrix body when the preceding ``Verdict summary`` card is present.
|
|
29
|
-
Keep the heading line itself so heading-set invariants hold.
|
|
30
|
-
- ``STRIP_INTRO_RECIPE`` — drop the ``> **읽는 법**:`` quoted recipe
|
|
31
|
-
paragraph beneath the Token Usage Summary table. The definition is
|
|
32
|
-
audience-facing; the slim AI consumer already knows the schema.
|
|
33
|
-
- ``COLLAPSE_BLANK_LINES`` — normalise 3+ blank lines to one.
|
|
34
|
-
|
|
35
|
-
Absolutely-preserve invariants (validator hard-fail prevention):
|
|
36
|
-
- ``## Verdict Card`` block content byte-identical
|
|
37
|
-
- ``## 2. Final Verdict`` table rows for ``Verdict Token`` /
|
|
38
|
-
``Direction`` / ``Next Step``
|
|
39
|
-
- ``## 0. Clarification Response Carried In`` (when present)
|
|
40
|
-
- implementation-planning 9 substrings (Option Candidates,
|
|
41
|
-
Trade-off, Recommended Option, Stepwise Execution Order,
|
|
42
|
-
Dependency, Validation Checklist, Rollback, User Approval Request,
|
|
43
|
-
Plan Body Verification + Gate result:)
|
|
44
|
-
- implementation §4.7 8 sub-section substrings (Approved Plan
|
|
45
|
-
Reference, Commit List, Diff Summary, Out-of-plan Edits,
|
|
46
|
-
Validation Evidence, Verifier Results, Rollback Verification,
|
|
47
|
-
Routing Recommendation)
|
|
48
|
-
- final-verification §4.8 6 sub-section substrings (Source
|
|
49
|
-
Implementation Report, Acceptance Blockers, Residual Risk,
|
|
50
|
-
Validation Evidence, Read-only Command Log, Routing Recommendation)
|
|
51
|
-
- release-handoff §4.6.1–4.6.7 sub-section substrings (Source
|
|
52
|
-
Verification Report, Feature Branch & Working-Tree State, User
|
|
53
|
-
Selections, Executed Commands, Commit List, Merge Conflict Probe,
|
|
54
|
-
Pull Request Outcome, Routing Recommendation)
|
|
55
|
-
|
|
56
|
-
The substring list is sourced from validate-run.py via
|
|
57
|
-
``preserved_substrings()`` so a future contract change touches one
|
|
58
|
-
place only.
|
|
17
|
+
The next-phase lead prompt now consumes the source MD directly — the
|
|
18
|
+
former ``*.slim.md`` derived artefact was removed (its strip rules
|
|
19
|
+
saved no tokens worth the contract maintenance cost).
|
|
59
20
|
"""
|
|
60
21
|
from __future__ import annotations
|
|
61
22
|
|
|
@@ -68,27 +29,12 @@ from typing import Iterable, Optional
|
|
|
68
29
|
|
|
69
30
|
|
|
70
31
|
def source_digest(src_md: str) -> str:
|
|
71
|
-
"""Stable identifier for a final-report markdown body.
|
|
72
|
-
|
|
73
|
-
|
|
32
|
+
"""Stable identifier for a final-report markdown body. The HTML
|
|
33
|
+
view embeds this in its run-meta block so the validator can detect
|
|
34
|
+
a stale html that was generated from an older MD."""
|
|
74
35
|
return hashlib.sha256(src_md.encode("utf-8")).hexdigest()
|
|
75
36
|
|
|
76
37
|
|
|
77
|
-
_SOURCE_DIGEST_COMMENT_RE = re.compile(
|
|
78
|
-
r"^<!--\s*source-sha256:\s*([0-9a-f]{64})\s*-->\s*$", re.MULTILINE
|
|
79
|
-
)
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
def extract_slim_digest(slim_text: str) -> Optional[str]:
|
|
83
|
-
"""Return the source-sha256 token embedded in a slim MD's first
|
|
84
|
-
comment line, or None if absent (older artifacts)."""
|
|
85
|
-
first_nonblank = next(
|
|
86
|
-
(ln for ln in slim_text.splitlines() if ln.strip()), ""
|
|
87
|
-
)
|
|
88
|
-
m = _SOURCE_DIGEST_COMMENT_RE.match(first_nonblank)
|
|
89
|
-
return m.group(1) if m else None
|
|
90
|
-
|
|
91
|
-
|
|
92
38
|
_HTML_DIGEST_RE = re.compile(r'"source-sha256":"([0-9a-f]{64})"')
|
|
93
39
|
|
|
94
40
|
|
|
@@ -97,15 +43,17 @@ def extract_html_digest(html_text: str) -> Optional[str]:
|
|
|
97
43
|
return m.group(1) if m else None
|
|
98
44
|
|
|
99
45
|
|
|
100
|
-
|
|
101
|
-
""
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
46
|
+
_LEADING_FRONTMATTER_RE = re.compile(
|
|
47
|
+
r"\A---\s*\n.*?\n---\s*\n?", re.DOTALL
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _strip_leading_frontmatter(text: str) -> str:
|
|
52
|
+
"""Remove a leading YAML frontmatter block (``---\\n…\\n---\\n``) if
|
|
53
|
+
present. Used by ``render_html`` so the HTML view does not surface
|
|
54
|
+
the Obsidian-side frontmatter as a paragraph; the source MD keeps
|
|
55
|
+
the frontmatter for Obsidian/Templater consumers."""
|
|
56
|
+
return _LEADING_FRONTMATTER_RE.sub("", text, count=1)
|
|
109
57
|
|
|
110
58
|
from .clarification_items import (
|
|
111
59
|
_is_separator_row,
|
|
@@ -116,205 +64,10 @@ from .clarification_items import (
|
|
|
116
64
|
|
|
117
65
|
|
|
118
66
|
# --------------------------------------------------------------------------- #
|
|
119
|
-
#
|
|
120
|
-
# validators/validate-run.py).
|
|
121
|
-
# --------------------------------------------------------------------------- #
|
|
122
|
-
|
|
123
|
-
PRESERVED_SUBSTRINGS_PLANNING: tuple[str, ...] = (
|
|
124
|
-
"Option Candidates",
|
|
125
|
-
"Trade-off",
|
|
126
|
-
"Recommended Option",
|
|
127
|
-
"Stepwise Execution Order",
|
|
128
|
-
"Dependency",
|
|
129
|
-
"Validation Checklist",
|
|
130
|
-
"Rollback",
|
|
131
|
-
"User Approval Request",
|
|
132
|
-
"Plan Body Verification",
|
|
133
|
-
"Gate result:",
|
|
134
|
-
)
|
|
135
|
-
|
|
136
|
-
PRESERVED_SUBSTRINGS_IMPLEMENTATION: tuple[str, ...] = (
|
|
137
|
-
"Approved Plan Reference",
|
|
138
|
-
"Commit List",
|
|
139
|
-
"Diff Summary",
|
|
140
|
-
"Out-of-plan Edits",
|
|
141
|
-
"Validation Evidence",
|
|
142
|
-
"Verifier Results",
|
|
143
|
-
"Rollback Verification",
|
|
144
|
-
"Routing Recommendation",
|
|
145
|
-
)
|
|
146
|
-
|
|
147
|
-
PRESERVED_SUBSTRINGS_FINAL_VERIFICATION: tuple[str, ...] = (
|
|
148
|
-
"Source Implementation Report",
|
|
149
|
-
"Acceptance Blockers",
|
|
150
|
-
"Residual Risk",
|
|
151
|
-
"Validation Evidence",
|
|
152
|
-
"Read-only Command Log",
|
|
153
|
-
"Routing Recommendation",
|
|
154
|
-
)
|
|
155
|
-
|
|
156
|
-
PRESERVED_SUBSTRINGS_RELEASE_HANDOFF: tuple[str, ...] = (
|
|
157
|
-
"Source Verification Report",
|
|
158
|
-
"Feature Branch & Working-Tree State",
|
|
159
|
-
"User Selections",
|
|
160
|
-
"Executed Commands",
|
|
161
|
-
"Commit List",
|
|
162
|
-
"Merge Conflict Probe",
|
|
163
|
-
"Pull Request Outcome",
|
|
164
|
-
"Routing Recommendation",
|
|
165
|
-
)
|
|
166
|
-
|
|
167
|
-
PRESERVED_HEADINGS_ALWAYS: tuple[str, ...] = (
|
|
168
|
-
"## Verdict Card",
|
|
169
|
-
"## 0. Clarification Response Carried In",
|
|
170
|
-
"## 2. Final Verdict",
|
|
171
|
-
"## 5. Clarification Items",
|
|
172
|
-
"## 6. Recommended Next Steps",
|
|
173
|
-
"## 7. Follow-up Tasks",
|
|
174
|
-
)
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
def preserved_substrings(task_type: Optional[str]) -> tuple[str, ...]:
|
|
178
|
-
"""Return every substring slim_markdown MUST keep byte-identical."""
|
|
179
|
-
extras: tuple[str, ...] = ()
|
|
180
|
-
if task_type == "implementation-planning":
|
|
181
|
-
extras = PRESERVED_SUBSTRINGS_PLANNING
|
|
182
|
-
elif task_type == "implementation":
|
|
183
|
-
extras = PRESERVED_SUBSTRINGS_IMPLEMENTATION
|
|
184
|
-
elif task_type == "final-verification":
|
|
185
|
-
extras = PRESERVED_SUBSTRINGS_FINAL_VERIFICATION
|
|
186
|
-
elif task_type == "release-handoff":
|
|
187
|
-
extras = PRESERVED_SUBSTRINGS_RELEASE_HANDOFF
|
|
188
|
-
return PRESERVED_HEADINGS_ALWAYS + extras
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
# --------------------------------------------------------------------------- #
|
|
192
|
-
# Slim stripper
|
|
67
|
+
# HTML renderer — minimal markdown-to-HTML converter (no external deps)
|
|
193
68
|
# --------------------------------------------------------------------------- #
|
|
194
69
|
|
|
195
|
-
_HR_PATTERN = re.compile(r"^-{3,}\s*$")
|
|
196
70
|
_BLANK_PATTERN = re.compile(r"^\s*$")
|
|
197
|
-
_RECIPE_QUOTE_PATTERN = re.compile(r"^>\s*\*\*읽는 법\*\*")
|
|
198
|
-
_CLI_COST_ROW_HINT = "Codex/Gemini CLI 추가 비용"
|
|
199
|
-
# Verdict details heading historically lived under ``### 4.5.9 Verdict
|
|
200
|
-
# details`` and is now (post-c2c603d split) written as ``#### Verdict
|
|
201
|
-
# details`` directly under §4.5.9 Plan Body Verification. Match both
|
|
202
|
-
# forms so the slim stripper drops the wide matrix body regardless of
|
|
203
|
-
# which template the report was generated against. The trailing ``$``
|
|
204
|
-
# anchor avoids accidentally matching ``Verdict details (excerpt)`` etc.
|
|
205
|
-
_VERDICT_DETAIL_RE = re.compile(
|
|
206
|
-
r"^#{3,4}\s+(?:4\.5\.9\s+)?Verdict details\s*$"
|
|
207
|
-
)
|
|
208
|
-
_VERDICT_SUMMARY_RE = re.compile(
|
|
209
|
-
r"^#{3,4}\s+(?:4\.5\.9\s+)?Verdict summary\s*$"
|
|
210
|
-
)
|
|
211
|
-
_ANY_HEADING_RE = re.compile(r"^#{1,6}\s+\S")
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
def slim_markdown(src_md: str, *, task_type: Optional[str] = None) -> str:
|
|
215
|
-
"""Return a slimmed copy of ``src_md`` per the rules in the module
|
|
216
|
-
docstring. Deterministic — same input always yields the same output.
|
|
217
|
-
|
|
218
|
-
Idempotency note: the returned slim begins with a
|
|
219
|
-
``<!-- source-sha256: ... -->`` comment so the validator can detect
|
|
220
|
-
a stale slim against a later MD. Re-running slim on its own output
|
|
221
|
-
must produce a byte-identical result, so we first strip the leading
|
|
222
|
-
digest comment (if any) before re-deriving everything from the
|
|
223
|
-
normalized body.
|
|
224
|
-
"""
|
|
225
|
-
src_md = _strip_leading_digest_comment(src_md)
|
|
226
|
-
lines = src_md.splitlines()
|
|
227
|
-
out: list[str] = []
|
|
228
|
-
|
|
229
|
-
hr_run = 0
|
|
230
|
-
blank_run = 0
|
|
231
|
-
in_verdict_detail = False # inside the §4.5.9 Verdict details wide matrix
|
|
232
|
-
|
|
233
|
-
for line in lines:
|
|
234
|
-
# STRIP_VERDICT_DETAIL — drop body of Verdict details (both
|
|
235
|
-
# ``### 4.5.9 Verdict details`` and ``#### Verdict details``
|
|
236
|
-
# forms), keep the heading line itself.
|
|
237
|
-
if _VERDICT_DETAIL_RE.match(line):
|
|
238
|
-
in_verdict_detail = True
|
|
239
|
-
out.append(line)
|
|
240
|
-
continue
|
|
241
|
-
if in_verdict_detail:
|
|
242
|
-
if _ANY_HEADING_RE.match(line):
|
|
243
|
-
in_verdict_detail = False
|
|
244
|
-
# fall through and emit this heading
|
|
245
|
-
else:
|
|
246
|
-
continue
|
|
247
|
-
|
|
248
|
-
# STRIP_INTRO_RECIPE — drop the "> **읽는 법**:" quote paragraph.
|
|
249
|
-
if _RECIPE_QUOTE_PATTERN.match(line):
|
|
250
|
-
continue
|
|
251
|
-
|
|
252
|
-
# STRIP_TOKEN_PLACEHOLDER_ZEROS — drop $0.00 rows except CLI cost.
|
|
253
|
-
if _is_zero_cost_row(line) and _CLI_COST_ROW_HINT not in line:
|
|
254
|
-
continue
|
|
255
|
-
|
|
256
|
-
# STRIP_DECORATIVE_HR — collapse runs of horizontal rules.
|
|
257
|
-
if _HR_PATTERN.match(line):
|
|
258
|
-
hr_run += 1
|
|
259
|
-
if hr_run > 1:
|
|
260
|
-
continue
|
|
261
|
-
else:
|
|
262
|
-
hr_run = 0
|
|
263
|
-
|
|
264
|
-
# COLLAPSE_BLANK_LINES — collapse 3+ blank lines to one.
|
|
265
|
-
if _BLANK_PATTERN.match(line):
|
|
266
|
-
blank_run += 1
|
|
267
|
-
if blank_run > 1:
|
|
268
|
-
continue
|
|
269
|
-
else:
|
|
270
|
-
blank_run = 0
|
|
271
|
-
|
|
272
|
-
out.append(line)
|
|
273
|
-
|
|
274
|
-
result = "\n".join(out)
|
|
275
|
-
# STRIP_DECORATIVE_HR (post-process) — collapse runs of HRs that
|
|
276
|
-
# were separated only by blank lines (the line scanner above can't
|
|
277
|
-
# reach across blanks without losing blank-line semantics elsewhere).
|
|
278
|
-
result = re.sub(r"(?:^---\s*\n(?:\s*\n)*){2,}", "---\n", result, flags=re.MULTILINE)
|
|
279
|
-
if src_md.endswith("\n") and not result.endswith("\n"):
|
|
280
|
-
result += "\n"
|
|
281
|
-
|
|
282
|
-
_assert_preserved(src_md, result, task_type)
|
|
283
|
-
# Prepend the source-digest comment so the validator can detect a
|
|
284
|
-
# stale slim that was generated from an older MD. The digest is
|
|
285
|
-
# over the slim BODY itself, not over the original MD, so the
|
|
286
|
-
# validator's expected-slim recomputation embeds the same digest
|
|
287
|
-
# (any byte difference between expected and actual body shows up
|
|
288
|
-
# as a mismatched digest).
|
|
289
|
-
digest_line = f"<!-- source-sha256: {source_digest(result)} -->\n"
|
|
290
|
-
return digest_line + result
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
def _is_zero_cost_row(line: str) -> bool:
|
|
294
|
-
"""True iff this is a markdown table row whose cost cell is exactly
|
|
295
|
-
``$0.00`` and no other meaningful cell content suggests audit
|
|
296
|
-
relevance.
|
|
297
|
-
"""
|
|
298
|
-
if not line.lstrip().startswith("|"):
|
|
299
|
-
return False
|
|
300
|
-
if "$0.00" not in line:
|
|
301
|
-
return False
|
|
302
|
-
# Heuristic — defer to the CLI-cost guard at the caller.
|
|
303
|
-
return True
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
def _assert_preserved(original: str, slim: str, task_type: Optional[str]) -> None:
|
|
307
|
-
"""Raise AssertionError if any preserved substring was dropped."""
|
|
308
|
-
missing = [s for s in preserved_substrings(task_type) if s in original and s not in slim]
|
|
309
|
-
if missing:
|
|
310
|
-
raise AssertionError(
|
|
311
|
-
"slim_markdown dropped preserved substring(s): " + ", ".join(missing)
|
|
312
|
-
)
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
# --------------------------------------------------------------------------- #
|
|
316
|
-
# HTML renderer — minimal markdown-to-HTML converter (no external deps)
|
|
317
|
-
# --------------------------------------------------------------------------- #
|
|
318
71
|
|
|
319
72
|
_HEADING_PATTERN = re.compile(r"^(#{1,6})\s+(.*?)\s*$")
|
|
320
73
|
_CODEFENCE_PATTERN = re.compile(r"^```(.*)$")
|
|
@@ -344,15 +97,14 @@ def render_html(src_md: str, *, run_meta: RunMeta, css: str, js: str) -> str:
|
|
|
344
97
|
into the document (validator enforces this — see
|
|
345
98
|
validate-report-views.py).
|
|
346
99
|
"""
|
|
347
|
-
#
|
|
348
|
-
# previous slim cycle doesn't leak into the rendered HTML body, then
|
|
349
|
-
# compute the digest over the normalised MD body. The validator
|
|
100
|
+
# Compute the digest over the source MD body. The validator
|
|
350
101
|
# recomputes the same digest from the current MD and compares it to
|
|
351
102
|
# the value embedded in run-meta below.
|
|
352
|
-
src_md = _strip_leading_digest_comment(src_md)
|
|
353
|
-
body_html = _markdown_to_html(src_md)
|
|
354
|
-
response_ids = sorted({item.row_id for item in (parse_clarification_items(src_md) or [])})
|
|
355
103
|
digest = source_digest(src_md)
|
|
104
|
+
body_md = _strip_leading_frontmatter(src_md)
|
|
105
|
+
body_html, toc_headings = _markdown_to_html(body_md)
|
|
106
|
+
body_html = _inject_toc(body_html, toc_headings)
|
|
107
|
+
response_ids = sorted({item.row_id for item in (parse_clarification_items(body_md) or [])})
|
|
356
108
|
|
|
357
109
|
title = html.escape(f"{run_meta.task_key} — {run_meta.task_type} #{run_meta.seq}")
|
|
358
110
|
response_ids_json = "[" + ",".join('"' + html.escape(rid) + '"' for rid in response_ids) + "]"
|
|
@@ -391,15 +143,22 @@ def render_html(src_md: str, *, run_meta: RunMeta, css: str, js: str) -> str:
|
|
|
391
143
|
)
|
|
392
144
|
|
|
393
145
|
|
|
394
|
-
def _markdown_to_html(
|
|
146
|
+
def _markdown_to_html(
|
|
147
|
+
src_md: str,
|
|
148
|
+
) -> tuple[str, list[tuple[int, str, str]]]:
|
|
395
149
|
"""Tiny line-based markdown→HTML emitter. Handles only what the
|
|
396
150
|
final-report template uses: headings, paragraphs, pipe tables,
|
|
397
151
|
fenced code blocks, blockquotes, ordered+unordered lists, inline
|
|
398
152
|
code/bold/links. Anything outside that surface is passed through
|
|
399
153
|
as escaped text inside a paragraph — there are no extension points.
|
|
154
|
+
|
|
155
|
+
Returns ``(body_html, toc_headings)`` where ``toc_headings`` is a
|
|
156
|
+
list of ``(level, slug, text)`` for every emitted heading in source
|
|
157
|
+
order. Callers use this to build a navigable table of contents.
|
|
400
158
|
"""
|
|
401
159
|
lines = src_md.splitlines()
|
|
402
160
|
out: list[str] = []
|
|
161
|
+
headings: list[tuple[int, str, str]] = []
|
|
403
162
|
i = 0
|
|
404
163
|
n = len(lines)
|
|
405
164
|
current_section_path: list[str] = [] # ['## 5. ...', '### 5.1 ...'] etc.
|
|
@@ -414,6 +173,7 @@ def _markdown_to_html(src_md: str) -> str:
|
|
|
414
173
|
slug = _slugify(text)
|
|
415
174
|
current_section_path = _update_section_path(current_section_path, level, line)
|
|
416
175
|
out.append(f'<h{level} id="{slug}">{_inline(text)}</h{level}>')
|
|
176
|
+
headings.append((level, slug, text))
|
|
417
177
|
i += 1
|
|
418
178
|
continue
|
|
419
179
|
|
|
@@ -472,7 +232,48 @@ def _markdown_to_html(src_md: str) -> str:
|
|
|
472
232
|
if para_lines:
|
|
473
233
|
out.append("<p>" + _inline(" ".join(para_lines)) + "</p>")
|
|
474
234
|
|
|
475
|
-
return "\n".join(out)
|
|
235
|
+
return "\n".join(out), headings
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
_INDEX_BLOCK_RE = re.compile(
|
|
239
|
+
r'<h2 id="index">Index</h2>\n<ul>.*?</ul>', re.DOTALL
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _build_toc(headings: list[tuple[int, str, str]]) -> str:
|
|
244
|
+
"""Render a ``<nav class="toc">`` block from collected h2/h3 entries.
|
|
245
|
+
h1 (the report title) is omitted; h4+ are too granular for the TOC."""
|
|
246
|
+
items: list[str] = []
|
|
247
|
+
for level, slug, text in headings:
|
|
248
|
+
if level not in (2, 3):
|
|
249
|
+
continue
|
|
250
|
+
if slug == "index": # skip the source "Index" heading itself
|
|
251
|
+
continue
|
|
252
|
+
cls = "toc-h2" if level == 2 else "toc-h3"
|
|
253
|
+
items.append(
|
|
254
|
+
f'<li class="{cls}"><a href="#{slug}">{_inline(text)}</a></li>'
|
|
255
|
+
)
|
|
256
|
+
if not items:
|
|
257
|
+
return ""
|
|
258
|
+
return '<nav class="toc" aria-label="Table of contents">' \
|
|
259
|
+
+ '<div class="toc-title">목차</div>' \
|
|
260
|
+
+ "<ul>" + "".join(items) + "</ul>" \
|
|
261
|
+
+ "</nav>"
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _inject_toc(body_html: str, headings: list[tuple[int, str, str]]) -> str:
|
|
265
|
+
"""If the source had an ``## Index`` section followed by a bullet
|
|
266
|
+
list (the template's static TOC), replace it with the auto-built
|
|
267
|
+
nav. Otherwise prepend the nav at the top of the body so readers
|
|
268
|
+
still get a navigation aid. If no h2/h3 headings exist the body is
|
|
269
|
+
returned unchanged.
|
|
270
|
+
"""
|
|
271
|
+
nav = _build_toc(headings)
|
|
272
|
+
if not nav:
|
|
273
|
+
return body_html
|
|
274
|
+
if _INDEX_BLOCK_RE.search(body_html):
|
|
275
|
+
return _INDEX_BLOCK_RE.sub(nav, body_html, count=1)
|
|
276
|
+
return nav + "\n" + body_html
|
|
476
277
|
|
|
477
278
|
|
|
478
279
|
def _update_section_path(path: list[str], level: int, line: str) -> list[str]:
|
|
@@ -507,9 +308,20 @@ def _emit_table(lines: list[str], start: int, section_path: list[str]) -> tuple[
|
|
|
507
308
|
and "Status" in header_cells
|
|
508
309
|
)
|
|
509
310
|
|
|
311
|
+
narrow_cols = _narrow_columns(header_cells, rows)
|
|
312
|
+
# `User input` carries the embedded form widget (textarea / select /
|
|
313
|
+
# input) which needs all the horizontal space it can get; its
|
|
314
|
+
# markdown plain text is empty or a short placeholder so the auto
|
|
315
|
+
# detector would otherwise flag it as narrow. Force it wide.
|
|
316
|
+
if "User input" in header_cells:
|
|
317
|
+
narrow_cols.discard(header_cells.index("User input"))
|
|
318
|
+
|
|
510
319
|
head = (
|
|
511
320
|
"<thead><tr>"
|
|
512
|
-
+ "".join(
|
|
321
|
+
+ "".join(
|
|
322
|
+
f"<th{_col_class(idx, narrow_cols)}>{_inline(c)}</th>"
|
|
323
|
+
for idx, c in enumerate(header_cells)
|
|
324
|
+
)
|
|
513
325
|
+ "</tr></thead>"
|
|
514
326
|
)
|
|
515
327
|
|
|
@@ -517,6 +329,10 @@ def _emit_table(lines: list[str], start: int, section_path: list[str]) -> tuple[
|
|
|
517
329
|
id_col = header_cells.index("ID") if "ID" in header_cells else -1
|
|
518
330
|
kind_col = header_cells.index("Kind") if "Kind" in header_cells else -1
|
|
519
331
|
status_col = header_cells.index("Status") if "Status" in header_cells else -1
|
|
332
|
+
statement_col = next(
|
|
333
|
+
(i for i, h in enumerate(header_cells) if h.startswith("Statement")),
|
|
334
|
+
-1,
|
|
335
|
+
)
|
|
520
336
|
user_input_col = (
|
|
521
337
|
header_cells.index("User input") if "User input" in header_cells else -1
|
|
522
338
|
)
|
|
@@ -531,14 +347,21 @@ def _emit_table(lines: list[str], start: int, section_path: list[str]) -> tuple[
|
|
|
531
347
|
response_id = row[id_col]
|
|
532
348
|
kind = row[kind_col] if 0 <= kind_col < len(row) else ""
|
|
533
349
|
status = row[status_col] if 0 <= status_col < len(row) else ""
|
|
350
|
+
statement = (
|
|
351
|
+
row[statement_col]
|
|
352
|
+
if 0 <= statement_col < len(row)
|
|
353
|
+
else ""
|
|
354
|
+
)
|
|
534
355
|
cells_html: list[str] = []
|
|
535
356
|
for idx, cell in enumerate(row):
|
|
536
357
|
if idx == user_input_col:
|
|
537
358
|
cells_html.append(
|
|
538
|
-
f"<td>{_form_control(response_id, kind, status, cell)}</td>"
|
|
359
|
+
f"<td>{_form_control(response_id, kind, status, cell, statement)}</td>"
|
|
539
360
|
)
|
|
540
361
|
else:
|
|
541
|
-
cells_html.append(
|
|
362
|
+
cells_html.append(
|
|
363
|
+
f"<td{_col_class(idx, narrow_cols)}>{_inline(cell)}</td>"
|
|
364
|
+
)
|
|
542
365
|
body_rows.append(
|
|
543
366
|
f'<tr data-response-id="{html.escape(response_id)}" '
|
|
544
367
|
f'data-kind="{html.escape(kind)}" '
|
|
@@ -548,22 +371,109 @@ def _emit_table(lines: list[str], start: int, section_path: list[str]) -> tuple[
|
|
|
548
371
|
)
|
|
549
372
|
else:
|
|
550
373
|
body_rows.append(
|
|
551
|
-
"<tr>"
|
|
374
|
+
"<tr>"
|
|
375
|
+
+ "".join(
|
|
376
|
+
f"<td{_col_class(idx, narrow_cols)}>{_inline(c)}</td>"
|
|
377
|
+
for idx, c in enumerate(row)
|
|
378
|
+
)
|
|
379
|
+
+ "</tr>"
|
|
552
380
|
)
|
|
553
381
|
|
|
554
382
|
body = "<tbody>" + "".join(body_rows) + "</tbody>"
|
|
555
383
|
return f"<table>{head}{body}</table>", i - start
|
|
556
384
|
|
|
557
385
|
|
|
558
|
-
|
|
386
|
+
_ENUM_LETTERS = "abcde"
|
|
387
|
+
_ENUM_CUE_WORDS: tuple[str, ...] = (
|
|
388
|
+
"권장은", "권장 ", "추천은", "추천 ", "사유:", "사유 ", "근거:",
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _parse_enum_options(statement: str) -> list[tuple[str, str]]:
|
|
393
|
+
"""Return ``[(letter, text)]`` pairs if ``statement`` looks like a
|
|
394
|
+
``(a) X (b) Y (c) Z`` enumeration, else ``[]``. Stops at the first
|
|
395
|
+
recommendation cue (``권장``/``추천``/``사유``/``근거``) so the
|
|
396
|
+
"(c) — recommended because ..." tail doesn't leak into the last
|
|
397
|
+
option's text. Requires ≥ 2 options to trigger select rendering.
|
|
398
|
+
"""
|
|
399
|
+
if not statement:
|
|
400
|
+
return []
|
|
401
|
+
out: list[tuple[str, str]] = []
|
|
402
|
+
cursor = 0
|
|
403
|
+
for i, letter in enumerate(_ENUM_LETTERS):
|
|
404
|
+
anchor = f"({letter}) "
|
|
405
|
+
idx = statement.find(anchor, cursor)
|
|
406
|
+
if idx < 0:
|
|
407
|
+
break
|
|
408
|
+
start = idx + len(anchor)
|
|
409
|
+
end_candidates: list[int] = []
|
|
410
|
+
if i + 1 < len(_ENUM_LETTERS):
|
|
411
|
+
n_idx = statement.find(f"({_ENUM_LETTERS[i + 1]}) ", start)
|
|
412
|
+
if n_idx >= 0:
|
|
413
|
+
end_candidates.append(n_idx)
|
|
414
|
+
for cue in _ENUM_CUE_WORDS:
|
|
415
|
+
c_idx = statement.find(cue, start)
|
|
416
|
+
if c_idx >= 0:
|
|
417
|
+
end_candidates.append(c_idx)
|
|
418
|
+
end = min(end_candidates) if end_candidates else len(statement)
|
|
419
|
+
text = statement[start:end].strip(" .,—-")
|
|
420
|
+
if not text:
|
|
421
|
+
break
|
|
422
|
+
out.append((letter, text))
|
|
423
|
+
cursor = end
|
|
424
|
+
return out if len(out) >= 2 else []
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _form_control(
|
|
428
|
+
response_id: str,
|
|
429
|
+
kind: str,
|
|
430
|
+
status: str,
|
|
431
|
+
current_value: str,
|
|
432
|
+
statement: str = "",
|
|
433
|
+
) -> str:
|
|
559
434
|
rid = html.escape(response_id)
|
|
560
435
|
disabled = "" if status.lower() in ("open", "answered", "") else " disabled"
|
|
561
436
|
safe_value = html.escape(current_value or "")
|
|
437
|
+
kind_lc = kind.lower()
|
|
562
438
|
placeholder = {
|
|
563
439
|
"material": "파일 경로 또는 본문",
|
|
564
440
|
"decision": "선택 또는 짧은 응답",
|
|
565
441
|
"data-point": "값",
|
|
566
|
-
}.get(
|
|
442
|
+
}.get(kind_lc, "응답")
|
|
443
|
+
|
|
444
|
+
# decision 인 경우 statement 에서 (a)(b)(c) 후보를 추출해 select+기타 input 으로 렌더.
|
|
445
|
+
if kind_lc == "decision":
|
|
446
|
+
opts = _parse_enum_options(statement)
|
|
447
|
+
if opts:
|
|
448
|
+
select_opts = "".join(
|
|
449
|
+
f'<option value="{letter}">'
|
|
450
|
+
f'{_inline(f"({letter}) " + text)}</option>'
|
|
451
|
+
for letter, text in opts
|
|
452
|
+
)
|
|
453
|
+
select_html = (
|
|
454
|
+
f'<select name="{rid}" data-response-id="{rid}" '
|
|
455
|
+
f'data-kind="{html.escape(kind)}"{disabled}>'
|
|
456
|
+
'<option value="">(선택)</option>'
|
|
457
|
+
f"{select_opts}"
|
|
458
|
+
'<option value="__other__">기타 (직접 입력)</option>'
|
|
459
|
+
"</select>"
|
|
460
|
+
)
|
|
461
|
+
other_html = (
|
|
462
|
+
f' <input type="text" data-other-for="{rid}" '
|
|
463
|
+
f'placeholder="기타 응답"{disabled} hidden value="{safe_value}">'
|
|
464
|
+
)
|
|
465
|
+
return select_html + other_html
|
|
466
|
+
|
|
467
|
+
# material/data-point 류는 짧은 단일값. 텍스트 인풋으로.
|
|
468
|
+
if kind_lc in ("material", "data-point"):
|
|
469
|
+
return (
|
|
470
|
+
f'<input type="text" name="{rid}" data-response-id="{rid}" '
|
|
471
|
+
f'data-kind="{html.escape(kind)}" '
|
|
472
|
+
f'placeholder="{html.escape(placeholder)}"{disabled} '
|
|
473
|
+
f'value="{safe_value}">'
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
# 그 외 (decision-without-enum 포함) — 자유 본문은 textarea 유지.
|
|
567
477
|
return (
|
|
568
478
|
f'<textarea name="{rid}" data-response-id="{rid}" '
|
|
569
479
|
f'data-kind="{html.escape(kind)}" rows="2" '
|
|
@@ -599,6 +509,86 @@ def _emit_list(lines: list[str], start: int) -> tuple[str, int]:
|
|
|
599
509
|
return f"<{tag}>" + "".join(items) + f"</{tag}>", i - start
|
|
600
510
|
|
|
601
511
|
|
|
512
|
+
_NARROW_COL_MAX_PLAIN_LEN = 30
|
|
513
|
+
|
|
514
|
+
_INLINE_MD_STRIP_RE = re.compile(r"[`*_]")
|
|
515
|
+
|
|
516
|
+
# Header-name whitelist: columns whose width is effectively fixed by
|
|
517
|
+
# convention even when one row carries a long body cell (e.g. "출처"
|
|
518
|
+
# starts with a short token but the prose body of a single citation
|
|
519
|
+
# row may run 60+ chars). Matched as case-insensitive prefix on the
|
|
520
|
+
# header's first plain token, so "Ticket ID" / "출처 (brief/source/worker)"
|
|
521
|
+
# / "환산 토큰 (input 기준)" all hit the same entry. Keep this list
|
|
522
|
+
# tight — every entry is a promise that the column is *always* one of
|
|
523
|
+
# a handful of canonical short values across every final-report.
|
|
524
|
+
_NARROW_HEADER_PREFIXES: tuple[str, ...] = (
|
|
525
|
+
# English / latin
|
|
526
|
+
"id",
|
|
527
|
+
"ticket id",
|
|
528
|
+
"kind",
|
|
529
|
+
"status",
|
|
530
|
+
"blocks",
|
|
531
|
+
"origin",
|
|
532
|
+
"priority",
|
|
533
|
+
"auto-spawn",
|
|
534
|
+
# Korean
|
|
535
|
+
"출처",
|
|
536
|
+
"에이전트",
|
|
537
|
+
"역할",
|
|
538
|
+
"모델",
|
|
539
|
+
"상태",
|
|
540
|
+
"항목",
|
|
541
|
+
"처리 토큰",
|
|
542
|
+
"환산 토큰",
|
|
543
|
+
"비용",
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _plain_len(raw_cell: str) -> int:
|
|
548
|
+
return len(_INLINE_MD_STRIP_RE.sub("", raw_cell or "").strip())
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _matches_narrow_whitelist(header: str) -> bool:
|
|
552
|
+
plain = _INLINE_MD_STRIP_RE.sub("", header or "").strip().lower()
|
|
553
|
+
return any(plain.startswith(p) for p in _NARROW_HEADER_PREFIXES)
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _narrow_columns(
|
|
557
|
+
header_cells: list[str], rows: list[list[str]]
|
|
558
|
+
) -> set[int]:
|
|
559
|
+
"""Return the set of column indices that should render with the
|
|
560
|
+
fixed-narrow ``td-narrow`` width (5% under report.css).
|
|
561
|
+
|
|
562
|
+
A column qualifies when EITHER:
|
|
563
|
+
(a) its header matches `_NARROW_HEADER_PREFIXES` — these are
|
|
564
|
+
``ID`` / ``출처`` / ``에이전트`` etc. whose values are
|
|
565
|
+
conventionally short across every final-report even when a
|
|
566
|
+
single row drops in 60+ chars of prose; OR
|
|
567
|
+
(b) every cell in the column (header + body) fits within
|
|
568
|
+
``_NARROW_COL_MAX_PLAIN_LEN`` plain chars — this catches
|
|
569
|
+
ad-hoc short columns that the whitelist did not anticipate.
|
|
570
|
+
|
|
571
|
+
Per-column (not per-cell) so the verdict applies uniformly across
|
|
572
|
+
the column even when one neighbouring column holds long-form text.
|
|
573
|
+
"""
|
|
574
|
+
narrow: set[int] = set()
|
|
575
|
+
for col, header in enumerate(header_cells):
|
|
576
|
+
if _matches_narrow_whitelist(header):
|
|
577
|
+
narrow.add(col)
|
|
578
|
+
continue
|
|
579
|
+
max_len = _plain_len(header)
|
|
580
|
+
for row in rows:
|
|
581
|
+
if col < len(row):
|
|
582
|
+
max_len = max(max_len, _plain_len(row[col]))
|
|
583
|
+
if max_len <= _NARROW_COL_MAX_PLAIN_LEN:
|
|
584
|
+
narrow.add(col)
|
|
585
|
+
return narrow
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _col_class(col_idx: int, narrow_cols: set[int]) -> str:
|
|
589
|
+
return ' class="td-narrow"' if col_idx in narrow_cols else ""
|
|
590
|
+
|
|
591
|
+
|
|
602
592
|
def _inline(text: str) -> str:
|
|
603
593
|
out = html.escape(text)
|
|
604
594
|
# Restore inline markdown after escaping (we re-process the escaped
|
|
@@ -674,28 +664,17 @@ def serialize_user_response(
|
|
|
674
664
|
# Convenience entrypoint for tests + CLI.
|
|
675
665
|
# --------------------------------------------------------------------------- #
|
|
676
666
|
|
|
677
|
-
def
|
|
667
|
+
def render_html_view(
|
|
678
668
|
src_md_path: Path,
|
|
679
669
|
*,
|
|
680
670
|
run_meta: RunMeta,
|
|
681
671
|
css: str,
|
|
682
672
|
js: str,
|
|
683
|
-
) ->
|
|
684
|
-
"""Write
|
|
685
|
-
|
|
673
|
+
) -> Path:
|
|
674
|
+
"""Write ``<stem>.html`` next to ``src_md_path`` and return its
|
|
675
|
+
path. Idempotent — overwrites the existing html sibling."""
|
|
686
676
|
src_text = src_md_path.read_text(encoding="utf-8")
|
|
687
|
-
|
|
688
|
-
slim_path = src_md_path.with_suffix(".slim.md")
|
|
689
|
-
if src_md_path.suffix == ".md":
|
|
690
|
-
# ``with_suffix`` replaces the LAST suffix only; we want
|
|
691
|
-
# ``foo.md`` → ``foo.slim.md`` (insert before ``.md``).
|
|
692
|
-
slim_path = src_md_path.with_name(src_md_path.stem + ".slim.md")
|
|
693
|
-
|
|
694
677
|
html_path = src_md_path.with_name(src_md_path.stem + ".html")
|
|
695
|
-
|
|
696
|
-
slim_text = slim_markdown(src_text, task_type=run_meta.task_type)
|
|
697
678
|
html_text = render_html(src_text, run_meta=run_meta, css=css, js=js)
|
|
698
|
-
|
|
699
|
-
slim_path.write_text(slim_text, encoding="utf-8")
|
|
700
679
|
html_path.write_text(html_text, encoding="utf-8")
|
|
701
|
-
return
|
|
680
|
+
return html_path
|