okstra 0.154.0 → 0.154.2
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/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/python/okstra_ctl/clarification_items.py +57 -14
- package/runtime/python/okstra_ctl/report_html/render.py +7 -2
- package/runtime/python/okstra_ctl/report_html/report_index.py +75 -0
- package/runtime/python/okstra_ctl/user_response.py +5 -1
- package/runtime/templates/reports/html/assets/base.css +8 -1
- package/runtime/templates/reports/html/i18n/en.json +4 -1
- package/runtime/templates/reports/html/i18n/ko.json +4 -1
- package/runtime/templates/reports/html/macros/forms.html +3 -3
package/package.json
CHANGED
package/runtime/BUILD.json
CHANGED
|
@@ -395,11 +395,39 @@ def scan_clarification_blockers(
|
|
|
395
395
|
report_path: Path, blocking_values: frozenset[str]
|
|
396
396
|
) -> ClarificationScan:
|
|
397
397
|
"""Shared fail-closed clarification walk for both gates above — schema-v2
|
|
398
|
-
reads its rows from the data sibling, schema-v1 from the §1 table
|
|
398
|
+
reads its rows from the data sibling, schema-v1 from the §1 table, and the
|
|
399
|
+
user's `user-responses/` sidecar outranks whatever the report says about
|
|
400
|
+
those rows."""
|
|
399
401
|
v2_scan = _scan_v2_blockers(report_path, blocking_values)
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
402
|
+
scan = (
|
|
403
|
+
v2_scan if v2_scan is not None
|
|
404
|
+
else scan_section_1_blockers(_read_report_text(report_path), blocking_values)
|
|
405
|
+
)
|
|
406
|
+
return _resolve_blockers_answered_by_user(report_path, scan)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _resolve_blockers_answered_by_user(
|
|
410
|
+
report_path: Path, scan: ClarificationScan
|
|
411
|
+
) -> ClarificationScan:
|
|
412
|
+
"""사용자가 사이드카로 답한 행을 blocker 에서 뺀 스캔.
|
|
413
|
+
|
|
414
|
+
답의 정본은 사용자의 `user-responses/` 사이드카다. 리포트의 `Status` 는 그
|
|
415
|
+
run 이 스스로 적어둔 값이고, 답이 사이드카로만 들어오는 경로(HTML 뷰의
|
|
416
|
+
`Export user response`, `okstra user-response write`)에서는 갱신되지
|
|
417
|
+
않는다 — 게이트가 리포트만 보면 사용자가 답을 다 채운 뒤에도 같은 항목이
|
|
418
|
+
영원히 미해결로 남아 다음 phase 를 막는다.
|
|
419
|
+
|
|
420
|
+
fail-closed 는 그대로다: 행 자체를 못 읽은 스캔(`unreadable_reason`)은
|
|
421
|
+
어떤 id 가 blocker 인지 모르는 상태이므로 사이드카로 덮지 않는다.
|
|
422
|
+
"""
|
|
423
|
+
if scan.unreadable_reason is not None or not scan.blockers:
|
|
424
|
+
return scan
|
|
425
|
+
answers = sidecar_answers(report_path)
|
|
426
|
+
if not answers:
|
|
427
|
+
return scan
|
|
428
|
+
return ClarificationScan(
|
|
429
|
+
[b for b in scan.blockers if b.row_id not in answers], None
|
|
430
|
+
)
|
|
403
431
|
|
|
404
432
|
|
|
405
433
|
def scan_section_1_blockers(
|
|
@@ -587,11 +615,20 @@ def _sidecars_for_attachment(source: Path) -> list[Path]:
|
|
|
587
615
|
return sorted([*ordinary, *selected])
|
|
588
616
|
|
|
589
617
|
|
|
590
|
-
def
|
|
618
|
+
def sidecar_answers(source: Path) -> dict[str, str]:
|
|
591
619
|
"""`user-responses/` 사이드카들의 답변을 `{clarification-id: value}` 로 모은다.
|
|
592
620
|
|
|
593
|
-
|
|
594
|
-
|
|
621
|
+
사용자가 답한 항목이 무엇인지 아는 단일 참조점 — carry-in 병합도, 승인
|
|
622
|
+
게이트도, 스킬의 열린 항목 목록도 전부 이 한 곳을 본다.
|
|
623
|
+
|
|
624
|
+
`disposition` 이 `answer` 인 항목만 답으로 센다. `reframe` 은 "다음 run 에서
|
|
625
|
+
다시 물어달라" 이지 답이 아니므로(`skills/okstra-user-response/SKILL.md` 의
|
|
626
|
+
"A reframe is not an answer") 답 집합에서 빠져야 게이트가 그 항목을 계속
|
|
627
|
+
미해결로 잡는다.
|
|
628
|
+
|
|
629
|
+
같은 id 가 여러 사이드카에 나오면 이름순 마지막(최신 seq)이 이긴다 — 최신이
|
|
630
|
+
reframe 이거나 값이 비면 앞선 답을 지운다. 그래야 답을 물렀을 때 그 항목이
|
|
631
|
+
미해결로 돌아온다. `user_response` 를 지연 import 해 순환 참조를 피한다.
|
|
595
632
|
"""
|
|
596
633
|
from okstra_ctl.user_response import parse_user_response_entries
|
|
597
634
|
|
|
@@ -600,8 +637,10 @@ def _sidecar_answers(source: Path) -> dict[str, str]:
|
|
|
600
637
|
for entry in parse_user_response_entries(
|
|
601
638
|
sidecar.read_text(encoding="utf-8")
|
|
602
639
|
):
|
|
603
|
-
if entry.value:
|
|
640
|
+
if entry.value and entry.disposition == "answer":
|
|
604
641
|
answers[entry.response_id] = entry.value
|
|
642
|
+
else:
|
|
643
|
+
answers.pop(entry.response_id, None)
|
|
605
644
|
return answers
|
|
606
645
|
|
|
607
646
|
|
|
@@ -644,7 +683,7 @@ def clarification_response_with_sidecars(source: Path) -> str:
|
|
|
644
683
|
"""
|
|
645
684
|
text = source.read_text(encoding="utf-8")
|
|
646
685
|
section = attached_user_responses_section(source)
|
|
647
|
-
answers =
|
|
686
|
+
answers = sidecar_answers(source)
|
|
648
687
|
body = _clarification_carry_body(source, text, answers)
|
|
649
688
|
if not section:
|
|
650
689
|
return body
|
|
@@ -750,9 +789,13 @@ def _locate_user_input_column(lines: list[str]) -> tuple[int, int]:
|
|
|
750
789
|
|
|
751
790
|
|
|
752
791
|
def _reconcile_row(line: str, ui_col: int, answers: dict[str, str]) -> str:
|
|
753
|
-
"""답이 있고 open/answered
|
|
754
|
-
resolved 로 바꾼 줄을, 그 외에는 원본 줄을 그대로 돌려준다.
|
|
755
|
-
|
|
792
|
+
"""답이 있고 open/answered 인 행이면 `User input` 칸을 그 답으로 채우고 Status 를
|
|
793
|
+
resolved 로 바꾼 줄을, 그 외에는 원본 줄을 그대로 돌려준다.
|
|
794
|
+
|
|
795
|
+
칸에 이미 값이 있어도 사용자의 사이드카 답이 이긴다. 그 칸을 채우는 것은
|
|
796
|
+
run 자신(직전 렌더가 옮겨 적은 값)이고, 사용자가 나중에 답을 바꾸면 둘이
|
|
797
|
+
갈라진다 — 사용자가 쓴 쪽을 정본으로 삼지 않으면 run 이 자기가 적어둔 값으로
|
|
798
|
+
계속 되돌아간다.
|
|
756
799
|
|
|
757
800
|
판정은 앵커/백틱을 벗긴 셀(`_split_pipe_row`)로 — 그래야 `_meta_id` 가 스크롤
|
|
758
801
|
앵커의 소문자 slug 대신 진짜 대문자 ID 를 읽는다. 재조립은 원본 셀
|
|
@@ -764,7 +807,7 @@ def _reconcile_row(line: str, ui_col: int, answers: dict[str, str]) -> str:
|
|
|
764
807
|
if item.status not in UNRESOLVED_STATUSES:
|
|
765
808
|
return line
|
|
766
809
|
raw = split_pipe_row(line)
|
|
767
|
-
if not
|
|
810
|
+
if not 0 <= ui_col < len(raw):
|
|
768
811
|
return line
|
|
769
812
|
raw[ui_col] = answers[item.row_id]
|
|
770
813
|
raw[0] = _STATUS_RESOLVE_RE.sub(r"\1resolved", raw[0])
|
|
@@ -772,7 +815,7 @@ def _reconcile_row(line: str, ui_col: int, answers: dict[str, str]) -> str:
|
|
|
772
815
|
|
|
773
816
|
|
|
774
817
|
def _reconcile_user_input(section: str, answers: dict[str, str]) -> str:
|
|
775
|
-
"""§1 표에서 사이드카 답이 있는 미해결 행의
|
|
818
|
+
"""§1 표에서 사이드카 답이 있는 미해결 행의 `User input` 칸을 답으로 채우고
|
|
776
819
|
Status 를 resolved 로 바꾼 §1 본문을 돌려준다.
|
|
777
820
|
|
|
778
821
|
답의 정본 위치를 §1 표 안으로 옮긴다 — 표만 읽는 승인 게이트·프롬프트
|
|
@@ -24,6 +24,7 @@ from .filters import (
|
|
|
24
24
|
paragraphs,
|
|
25
25
|
)
|
|
26
26
|
from .models import HtmlRunMeta
|
|
27
|
+
from .report_index import inject_report_index
|
|
27
28
|
from .router import HtmlRenderError, resolve_html_route
|
|
28
29
|
|
|
29
30
|
|
|
@@ -133,7 +134,8 @@ def render_v2_html_view(
|
|
|
133
134
|
# threading the index through every macro and call site.
|
|
134
135
|
anchors = anchor_index(data)
|
|
135
136
|
chrome = load_dictionary(lang, HTML_DICTIONARY_REL)
|
|
136
|
-
|
|
137
|
+
translate = make_jinja_global(chrome)
|
|
138
|
+
env.globals["t"] = translate
|
|
137
139
|
env.filters["code_evidence"] = code_evidence
|
|
138
140
|
env.filters["enum_label"] = lambda value, vocabulary: enum_label(value, vocabulary, chrome)
|
|
139
141
|
env.filters["enum_legend"] = lambda vocabulary: enum_legend(vocabulary, chrome)
|
|
@@ -154,7 +156,10 @@ def render_v2_html_view(
|
|
|
154
156
|
"js": response_js + "\n" + base_js,
|
|
155
157
|
}
|
|
156
158
|
output_path = _html_path(data_path)
|
|
157
|
-
|
|
159
|
+
document = env.get_template(route.template_name).render(**context)
|
|
160
|
+
output_path.write_text(
|
|
161
|
+
inject_report_index(document, label=translate("base.contents")), encoding="utf-8"
|
|
162
|
+
)
|
|
158
163
|
if context["clarificationItems"]:
|
|
159
164
|
# The footer tells the reader to drop the exported file here, so the
|
|
160
165
|
# directory has to exist before they go looking for it.
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Build the reader's index that heads the rendered report.
|
|
2
|
+
|
|
3
|
+
The index is derived from the rendered document instead of being declared in
|
|
4
|
+
each task template. A template that gains a section gets an index entry with
|
|
5
|
+
it, and there is no second list to keep in step.
|
|
6
|
+
|
|
7
|
+
Anchors come from the id a section already carries or from its
|
|
8
|
+
``data-report-section`` slug — never from the heading text, which changes with
|
|
9
|
+
the report language and would move every link with it.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
|
|
15
|
+
# Every section of the human view opens with its own h2. A block that does not
|
|
16
|
+
# is not a place the reader navigates to, so it stays out of the index.
|
|
17
|
+
_SECTION_HEAD_RE = re.compile(
|
|
18
|
+
r'<section\b(?P<attrs>[^>]*)>(?P<heading_open>\s*<h2[^>]*>)(?P<title>.*?)</h2>',
|
|
19
|
+
re.DOTALL,
|
|
20
|
+
)
|
|
21
|
+
_MAIN_OPEN_RE = re.compile(r"<main\b[^>]*>")
|
|
22
|
+
_ID_RE = re.compile(r'\bid="([^"]+)"')
|
|
23
|
+
_SLUG_RE = re.compile(r'\bdata-report-section="([^"]+)"')
|
|
24
|
+
_TAG_RE = re.compile(r"<[^>]+>")
|
|
25
|
+
|
|
26
|
+
INDEX_TITLE_ID = "report-index-title"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _heading_text(title_markup: str) -> str:
|
|
30
|
+
"""The heading's words without the markup a link cannot carry."""
|
|
31
|
+
return " ".join(_TAG_RE.sub("", title_markup).split())
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def inject_report_index(document: str, *, label: str) -> str:
|
|
35
|
+
"""Return ``document`` with a section index at the top of ``<main>``.
|
|
36
|
+
|
|
37
|
+
Sections that lack both an id and a slug are skipped rather than given a
|
|
38
|
+
generated anchor: a link whose target moves between renders is worse than
|
|
39
|
+
an entry the reader never had.
|
|
40
|
+
"""
|
|
41
|
+
opening = _MAIN_OPEN_RE.search(document)
|
|
42
|
+
if opening is None:
|
|
43
|
+
return document
|
|
44
|
+
head, body = document[: opening.end()], document[opening.end() :]
|
|
45
|
+
entries: list[tuple[str, str]] = []
|
|
46
|
+
|
|
47
|
+
def _anchor_section(match: re.Match[str]) -> str:
|
|
48
|
+
attrs = match.group("attrs")
|
|
49
|
+
existing = _ID_RE.search(attrs)
|
|
50
|
+
if existing:
|
|
51
|
+
anchor = existing.group(1)
|
|
52
|
+
else:
|
|
53
|
+
slug = _SLUG_RE.search(attrs)
|
|
54
|
+
if slug is None:
|
|
55
|
+
return match.group(0)
|
|
56
|
+
anchor = f"section-{slug.group(1)}"
|
|
57
|
+
# Last, not first: templates lead a section with the attribute the
|
|
58
|
+
# tests and the validator select it by.
|
|
59
|
+
attrs = f'{attrs} id="{anchor}"'
|
|
60
|
+
entries.append((anchor, _heading_text(match.group("title"))))
|
|
61
|
+
return f'<section{attrs}>{match.group("heading_open")}{match.group("title")}</h2>'
|
|
62
|
+
|
|
63
|
+
body = _SECTION_HEAD_RE.sub(_anchor_section, body)
|
|
64
|
+
if not entries:
|
|
65
|
+
return document
|
|
66
|
+
items = "".join(
|
|
67
|
+
f'<li><a href="#{anchor}">{text}</a></li>' for anchor, text in entries
|
|
68
|
+
)
|
|
69
|
+
index = (
|
|
70
|
+
f'<nav class="report-index" aria-labelledby="{INDEX_TITLE_ID}">'
|
|
71
|
+
f'<h2 id="{INDEX_TITLE_ID}">{label}</h2>'
|
|
72
|
+
f"<ol>{items}</ol>"
|
|
73
|
+
"</nav>"
|
|
74
|
+
)
|
|
75
|
+
return f"{head}\n{index}{body}"
|
|
@@ -27,6 +27,7 @@ from okstra_ctl.clarification_items import (
|
|
|
27
27
|
read_clarification_rows,
|
|
28
28
|
scan_open_user_input,
|
|
29
29
|
section_1_present_but_unparsed,
|
|
30
|
+
sidecar_answers,
|
|
30
31
|
_section_1_slice,
|
|
31
32
|
)
|
|
32
33
|
|
|
@@ -545,10 +546,13 @@ def resolve_refs(report_text: str, refs: list[str]) -> list[dict]:
|
|
|
545
546
|
|
|
546
547
|
def show_open_rows(report_path: Path) -> dict:
|
|
547
548
|
text = report_path.read_text(encoding="utf-8")
|
|
549
|
+
# 사이드카에 답이 있는 행은 사용자가 이미 답한 것이다. 리포트의 `Status` 는
|
|
550
|
+
# 그 답을 반영하지 않으므로, 이걸 빼지 않으면 스킬이 같은 질문을 다시 묻는다.
|
|
551
|
+
answered = sidecar_answers(report_path)
|
|
548
552
|
rows = []
|
|
549
553
|
for r in read_clarification_rows(report_path):
|
|
550
554
|
it = r["item"]
|
|
551
|
-
if it.status not in ("open", "answered"):
|
|
555
|
+
if it.status not in ("open", "answered") or it.row_id in answered:
|
|
552
556
|
continue
|
|
553
557
|
statement, expected = r["statement"], r["expected_form"]
|
|
554
558
|
refs = sorted(set(_SECTION_REF_RE.findall(statement + " " + expected)))
|
|
@@ -16,6 +16,13 @@ main { display: grid; gap: 1rem; padding-bottom: 3rem; }
|
|
|
16
16
|
section { padding: 1.4rem; border: 1px solid color-mix(in srgb, CanvasText 14%, transparent); border-radius: 16px; background: color-mix(in srgb, Canvas 94%, CanvasText 6%); }
|
|
17
17
|
h2 { margin-top: 0; font-size: 1.45rem; }
|
|
18
18
|
h3 { margin-bottom: .35rem; }
|
|
19
|
+
/* The report opens with what it holds. It scrolls away with the rest rather
|
|
20
|
+
than sticking: on a phone a pinned index of a dozen sections is the page. */
|
|
21
|
+
nav.report-index { padding: 1.2rem 1.4rem; border: 1px solid color-mix(in srgb, CanvasText 14%, transparent); border-radius: 16px; background: color-mix(in srgb, Canvas 94%, CanvasText 6%); }
|
|
22
|
+
nav.report-index h2 { font-size: .82rem; text-transform: uppercase; letter-spacing: .08em; color: GrayText; margin-bottom: .6rem; }
|
|
23
|
+
nav.report-index ol { margin: 0; padding-left: 1.4rem; columns: 2; column-gap: 2.5rem; }
|
|
24
|
+
nav.report-index li { margin: .2em 0; break-inside: avoid; }
|
|
25
|
+
nav.report-index a { color: inherit; }
|
|
19
26
|
/* One card per row. Fitting three across left each body about sixteen
|
|
20
27
|
characters wide — a third of what reads comfortably — and turned every card
|
|
21
28
|
into a narrow vertical strip. These carry paragraphs, not labels. */
|
|
@@ -92,6 +99,6 @@ button:focus-visible { outline: 2px solid Highlight; outline-offset: 2px; }
|
|
|
92
99
|
button[data-action="export-user-response"] { background: Highlight; border-color: Highlight; color: HighlightText; font-weight: 600; }
|
|
93
100
|
button[data-action="export-user-response"]:hover { background: color-mix(in srgb, Highlight 82%, CanvasText); }
|
|
94
101
|
/* A seven-column table needs 42em of floor, more than a phone can give. */
|
|
95
|
-
@media (max-width: 640px) { section { padding: 1rem; } .visualization { display: none; } th, td { min-width: 4.5em; } }
|
|
102
|
+
@media (max-width: 640px) { section { padding: 1rem; } .visualization { display: none; } th, td { min-width: 4.5em; } nav.report-index ol { columns: 1; } }
|
|
96
103
|
@media print { .skip-link, script { display: none !important; } body { color: #000; background: #fff; } section { break-inside: avoid; border-color: #bbb; } .visualization-fallback { display: table; } }
|
|
97
104
|
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .9em; padding: .1em .3em; border-radius: 4px; background: color-mix(in srgb, CanvasText 8%, Canvas); }
|
|
@@ -72,6 +72,7 @@
|
|
|
72
72
|
},
|
|
73
73
|
"base": {
|
|
74
74
|
"skip-to-report-content": "Skip to report content",
|
|
75
|
+
"contents": "Contents",
|
|
75
76
|
"task": "Task",
|
|
76
77
|
"task-key": "Task key",
|
|
77
78
|
"written": "Written",
|
|
@@ -104,7 +105,9 @@
|
|
|
104
105
|
"export-my-answers": "Export my answers",
|
|
105
106
|
"at-the-foot-of-the-page": "at the foot of the page.",
|
|
106
107
|
"answer-as": "Answer as",
|
|
107
|
-
"questions-waiting-on-you": "Questions waiting on you"
|
|
108
|
+
"questions-waiting-on-you": "Questions waiting on you",
|
|
109
|
+
"count-questions-waiting-on-you": "{count} questions waiting on you",
|
|
110
|
+
"your-answer-to-id": "Your answer to {id}"
|
|
108
111
|
},
|
|
109
112
|
"visualizations": {
|
|
110
113
|
"component": "Component",
|
|
@@ -72,6 +72,7 @@
|
|
|
72
72
|
},
|
|
73
73
|
"base": {
|
|
74
74
|
"skip-to-report-content": "리포트 본문으로 건너뛰기",
|
|
75
|
+
"contents": "목차",
|
|
75
76
|
"task": "태스크",
|
|
76
77
|
"task-key": "태스크 키",
|
|
77
78
|
"written": "작성",
|
|
@@ -104,7 +105,9 @@
|
|
|
104
105
|
"export-my-answers": "내 답변 내보내기",
|
|
105
106
|
"at-the-foot-of-the-page": "버튼을 누르세요.",
|
|
106
107
|
"answer-as": "답변 형식",
|
|
107
|
-
"questions-waiting-on-you": "답변을 기다리는 질문"
|
|
108
|
+
"questions-waiting-on-you": "답변을 기다리는 질문",
|
|
109
|
+
"count-questions-waiting-on-you": "답변을 기다리는 질문 {count}건",
|
|
110
|
+
"your-answer-to-id": "{id}에 대한 답변"
|
|
108
111
|
},
|
|
109
112
|
"visualizations": {
|
|
110
113
|
"component": "구성 요소",
|
|
@@ -32,15 +32,15 @@
|
|
|
32
32
|
|
|
33
33
|
{% macro clarification_responses(items) -%}
|
|
34
34
|
{% if items %}
|
|
35
|
-
<section class="clarification-responses" aria-label="{{ t('macros.forms.questions-waiting-on-you') }}">
|
|
36
|
-
<h2>{{ items | length }}
|
|
35
|
+
<section class="clarification-responses" data-report-section="clarification-responses" aria-label="{{ t('macros.forms.questions-waiting-on-you') }}">
|
|
36
|
+
<h2>{{ t('macros.forms.count-questions-waiting-on-you') | replace('{count}', items | length) }}</h2>
|
|
37
37
|
<p class="clarification-lede">{{ t('macros.forms.the-code-alone-could-not-settle-these-fill-i') }} <strong>{{ t('macros.forms.export-my-answers') }}</strong> {{ t('macros.forms.at-the-foot-of-the-page') }}</p>
|
|
38
38
|
{% for row in items %}
|
|
39
39
|
<article class="clarification-item" id="id-{{ row.id }}" data-response-id="{{ row.id }}" data-kind="{{ row.kind }}">
|
|
40
40
|
<p class="eyebrow">{{ row.id }} · {{ row.kind }}</p>
|
|
41
41
|
{{ row.statement | paragraphs }}
|
|
42
42
|
<dl class="clarification-expected"><dt>{{ t('macros.forms.answer-as') }}</dt><dd>{{ row.expectedForm | inline_code }}</dd></dl>
|
|
43
|
-
<label for="response-{{ row.id }}">
|
|
43
|
+
<label for="response-{{ row.id }}">{{ t('macros.forms.your-answer-to-id') | replace('{id}', row.id) }}</label>
|
|
44
44
|
<textarea id="response-{{ row.id }}" data-response-id="{{ row.id }}" rows="4">{{ row.userInput | default('') }}</textarea>
|
|
45
45
|
</article>
|
|
46
46
|
{% endfor %}
|