okstra 0.187.0 → 0.188.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/dist/commands/lifecycle/install.mjs +3 -1
- package/dist/commands/lifecycle/install.mjs.map +1 -1
- package/docs/architecture.md +2 -2
- package/docs/cli.md +2 -2
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/bin/okstra-render-report-views.py +23 -5
- package/runtime/prompts/lead/adapters/cmux.md +3 -3
- package/runtime/prompts/lead/convergence.md +3 -1
- package/runtime/prompts/lead/report-writer.md +1 -1
- package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +23 -0
- package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +8 -2
- package/runtime/python/okstra_ctl/agent/activity.py +4 -1
- package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +25 -12
- package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +229 -32
- package/runtime/python/okstra_ctl/approval_decisions.py +27 -2
- package/runtime/python/okstra_ctl/context_cost.py +16 -2
- package/runtime/python/okstra_ctl/convergence.py +6 -1
- package/runtime/python/okstra_ctl/dispatch_core.py +134 -24
- package/runtime/python/okstra_ctl/dispatch_state.py +150 -3
- package/runtime/python/okstra_ctl/domain/worker_presentation.py +7 -0
- package/runtime/python/okstra_ctl/exact_coverage.py +5 -0
- package/runtime/python/okstra_ctl/final_report_schema.py +229 -1
- package/runtime/python/okstra_ctl/implementation_options.py +7 -2
- package/runtime/python/okstra_ctl/render_final_report.py +1 -1
- package/runtime/python/okstra_ctl/report_finalize.py +7 -1
- package/runtime/python/okstra_ctl/report_narrative.py +132 -33
- package/runtime/python/okstra_ctl/report_synthesis_packet.py +135 -0
- package/runtime/python/okstra_ctl/report_view_artifacts.py +42 -2
- package/runtime/python/okstra_ctl/verdict_blocks.py +28 -8
- package/runtime/python/okstra_ctl/worker_runner.py +42 -8
- package/runtime/python/okstra_token_usage/antigravity.py +50 -12
- package/runtime/python/okstra_token_usage/collect.py +105 -31
- package/runtime/python/okstra_token_usage/pricing.py +6 -3
- package/runtime/templates/report-writer-prompt-preamble.md +2 -0
- package/runtime/validators/validate-run.py +6 -1
|
@@ -67,6 +67,11 @@ def _allowed_top_level() -> frozenset[str]:
|
|
|
67
67
|
return frozenset(_narrative_schema().get("properties", {}))
|
|
68
68
|
|
|
69
69
|
|
|
70
|
+
def allowed_top_level_fields() -> frozenset[str]:
|
|
71
|
+
"""작성자가 최상위에 쓸 수 있는 필드 이름(서사 스키마의 properties)."""
|
|
72
|
+
return _allowed_top_level()
|
|
73
|
+
|
|
74
|
+
|
|
70
75
|
# 완성 리포트 스키마가 작성자 소유 블록 안에서 required 로 거는 기계 소유 필드.
|
|
71
76
|
# `writer_owned_data` 가 떼어내고 `_NESTED_FORBIDDEN` 이 저작을 막는 바로 그
|
|
72
77
|
# 이름들이라, 값 제약을 그대로 쓰면 정상 서사가 전부 "필수 필드 없음" 으로
|
|
@@ -273,7 +278,7 @@ def _parse_tree(markdown: str) -> _Node:
|
|
|
273
278
|
_append_node(stack, field, item, level, number)
|
|
274
279
|
elif value:
|
|
275
280
|
level = _line_level(value.group("indent"), number)
|
|
276
|
-
_append_value(stack, value.group("value"), level, number)
|
|
281
|
+
_append_value(stack, value.group("value"), level, number, lines)
|
|
277
282
|
else:
|
|
278
283
|
raise NarrativeContractError(
|
|
279
284
|
f"line {number}: unsupported Markdown syntax `{line.strip()}` — "
|
|
@@ -299,9 +304,45 @@ def _append_node(stack: list[_Node], field: Any, item: Any, level: int, number:
|
|
|
299
304
|
stack.append(node)
|
|
300
305
|
|
|
301
306
|
|
|
302
|
-
def
|
|
307
|
+
def _misplaced_value_lines(lines: list[str]) -> int:
|
|
308
|
+
"""자기 라벨보다 두 칸 더 들여쓰지 않은 `> value` 줄의 수.
|
|
309
|
+
|
|
310
|
+
파서는 첫 결함에서 멈추므로 규모를 말하지 못한다 — 129줄이 전부 0열에
|
|
311
|
+
붙은 서술문이 `line 5` 한 건으로 보고됐다(2026-09-02 실측). 여기서는
|
|
312
|
+
직전 라벨(`- **…**` / `- Item N`)의 들여쓰기와 견줘 세기만 한다; 판정은
|
|
313
|
+
여전히 파서의 것이다.
|
|
314
|
+
"""
|
|
315
|
+
misplaced = 0
|
|
316
|
+
label_indent = None
|
|
317
|
+
for line in lines[1:]:
|
|
318
|
+
if not line.strip():
|
|
319
|
+
continue
|
|
320
|
+
match = _FIELD_RE.match(line) or _ITEM_RE.match(line)
|
|
321
|
+
if match:
|
|
322
|
+
label_indent = len(match.group("indent"))
|
|
323
|
+
continue
|
|
324
|
+
value = _VALUE_RE.match(line)
|
|
325
|
+
if value and label_indent is not None:
|
|
326
|
+
if len(value.group("indent")) != label_indent + 2:
|
|
327
|
+
misplaced += 1
|
|
328
|
+
return misplaced
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _append_value(
|
|
332
|
+
stack: list[_Node], value: str, level: int, number: int, lines: list[str] | None = None,
|
|
333
|
+
) -> None:
|
|
303
334
|
if stack[-1].level != level - 1:
|
|
304
|
-
|
|
335
|
+
count = _misplaced_value_lines(lines) if lines is not None else 0
|
|
336
|
+
scale = (
|
|
337
|
+
f"; {count} value line(s) in this file share the defect"
|
|
338
|
+
if count > 1
|
|
339
|
+
else ""
|
|
340
|
+
)
|
|
341
|
+
raise NarrativeContractError(
|
|
342
|
+
f"line {number}: value is outside its field — a `> value` line is "
|
|
343
|
+
"indented exactly two spaces deeper than the `- **Label**` or "
|
|
344
|
+
f"`- Item N` line it belongs to{scale}"
|
|
345
|
+
)
|
|
305
346
|
if stack[-1].children:
|
|
306
347
|
raise NarrativeContractError(f"line {number}: field mixes values and children")
|
|
307
348
|
stack[-1].values.append(value)
|
|
@@ -317,7 +358,37 @@ def _allowed_labels(node: Any, index: SchemaIndex, path: str) -> list[str]:
|
|
|
317
358
|
return sorted(humanise(key) for key in keys)
|
|
318
359
|
|
|
319
360
|
|
|
320
|
-
|
|
361
|
+
class _Defects:
|
|
362
|
+
"""값 단계에서 모은 결함. 첫 건에서 멈추지 않고 문서 전체를 읽는다.
|
|
363
|
+
|
|
364
|
+
파서가 첫 강제 변환 실패에서 예외를 내던 동안, 같은 종류의 결함이 여러
|
|
365
|
+
자리에 있어도 한 자리만 보고돼 작성자가 같은 문서를 회차마다 한 자리씩
|
|
366
|
+
고쳤다(2026-09-03 실측, dev-10626 implementation-option-selection: 소수
|
|
367
|
+
`coveragePercent` 가 랭킹 옵션 둘에 있는데 `rankedOptions[1]` 한 건만).
|
|
368
|
+
스키마 검증(`validate`)은 이미 전건을 모으므로 값 단계도 그렇게 한다.
|
|
369
|
+
"""
|
|
370
|
+
|
|
371
|
+
def __init__(self) -> None:
|
|
372
|
+
self.messages: list[str] = []
|
|
373
|
+
self.paths: list[str] = []
|
|
374
|
+
|
|
375
|
+
def add(self, path: str, message: str) -> None:
|
|
376
|
+
self.paths.append(path)
|
|
377
|
+
self.messages.append(message)
|
|
378
|
+
|
|
379
|
+
def covers(self, error: str) -> bool:
|
|
380
|
+
"""스키마 검증이 같은 자리(또는 그 아래)에 낸 오류인가 — 값 단계가 이미
|
|
381
|
+
보고한 자리를 두 번 말하지 않는다."""
|
|
382
|
+
location = error.split(": ", 1)[0]
|
|
383
|
+
return any(
|
|
384
|
+
location == path or location.startswith(f"{path}.") or location.startswith(f"{path}[")
|
|
385
|
+
for path in self.paths
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _field_key(
|
|
390
|
+
label: str, node: Any, index: SchemaIndex, path: str, defects: _Defects,
|
|
391
|
+
) -> str | None:
|
|
321
392
|
candidates: dict[str, list[str]] = {}
|
|
322
393
|
for key in index.key_order(node):
|
|
323
394
|
candidates.setdefault(label_key(humanise(key)), []).append(key)
|
|
@@ -330,20 +401,26 @@ def _field_key(label: str, node: Any, index: SchemaIndex, path: str) -> str:
|
|
|
330
401
|
if not matches:
|
|
331
402
|
allowed = _allowed_labels(node, index, path)
|
|
332
403
|
listing = ", ".join(f"`{item}`" for item in allowed) or "(none)"
|
|
333
|
-
|
|
404
|
+
defects.add(
|
|
405
|
+
display_path,
|
|
334
406
|
f"owner=report-writer field `{display_path}` is not an allowed unique "
|
|
335
|
-
f"field; allowed at this position: {listing}"
|
|
407
|
+
f"field; allowed at this position: {listing}",
|
|
336
408
|
)
|
|
409
|
+
return None
|
|
337
410
|
if len(matches) > 1:
|
|
338
411
|
collisions = ", ".join(f"`{key}`" for key in matches)
|
|
339
|
-
|
|
412
|
+
defects.add(
|
|
413
|
+
display_path,
|
|
340
414
|
f"owner=report-writer field `{display_path}` is not an allowed unique "
|
|
341
|
-
f"field; the label matches more than one schema key: {collisions}"
|
|
415
|
+
f"field; the label matches more than one schema key: {collisions}",
|
|
342
416
|
)
|
|
417
|
+
return None
|
|
343
418
|
return matches[0]
|
|
344
419
|
|
|
345
420
|
|
|
346
|
-
def _parse_scalar(
|
|
421
|
+
def _parse_scalar(
|
|
422
|
+
values: list[str], node: Any, index: SchemaIndex, path: str, defects: _Defects,
|
|
423
|
+
) -> Any:
|
|
347
424
|
types = _node_types(node, index)
|
|
348
425
|
text = "\n".join(values)
|
|
349
426
|
if text == EMPTY_MARKER and "null" in types:
|
|
@@ -353,17 +430,21 @@ def _parse_scalar(values: list[str], node: Any, index: SchemaIndex, path: str) -
|
|
|
353
430
|
if "integer" in types:
|
|
354
431
|
try:
|
|
355
432
|
return int(text)
|
|
356
|
-
except ValueError
|
|
357
|
-
|
|
433
|
+
except ValueError:
|
|
434
|
+
defects.add(path, f"{path}: expected integer")
|
|
435
|
+
return text
|
|
358
436
|
if "number" in types:
|
|
359
437
|
try:
|
|
360
438
|
return float(text)
|
|
361
|
-
except ValueError
|
|
362
|
-
|
|
439
|
+
except ValueError:
|
|
440
|
+
defects.add(path, f"{path}: expected number")
|
|
441
|
+
return text
|
|
363
442
|
return text
|
|
364
443
|
|
|
365
444
|
|
|
366
|
-
def _parse_value(
|
|
445
|
+
def _parse_value(
|
|
446
|
+
node: _Node, schema_node: Any, index: SchemaIndex, path: str, defects: _Defects,
|
|
447
|
+
) -> Any:
|
|
367
448
|
types = _node_types(schema_node, index)
|
|
368
449
|
if node.values:
|
|
369
450
|
if node.values == [EMPTY_MARKER] and "null" in types:
|
|
@@ -372,43 +453,54 @@ def _parse_value(node: _Node, schema_node: Any, index: SchemaIndex, path: str) -
|
|
|
372
453
|
return {}
|
|
373
454
|
if node.values == [EMPTY_MARKER] and "array" in types:
|
|
374
455
|
return []
|
|
375
|
-
return _parse_scalar(node.values, schema_node, index, path)
|
|
456
|
+
return _parse_scalar(node.values, schema_node, index, path, defects)
|
|
376
457
|
if "array" in types or all(child.kind == "item" for child in node.children):
|
|
377
|
-
return _parse_array(node, schema_node, index, path)
|
|
378
|
-
return _parse_object(node, schema_node, index, path)
|
|
458
|
+
return _parse_array(node, schema_node, index, path, defects)
|
|
459
|
+
return _parse_object(node, schema_node, index, path, defects)
|
|
379
460
|
|
|
380
461
|
|
|
381
|
-
def _parse_array(
|
|
462
|
+
def _parse_array(
|
|
463
|
+
node: _Node, schema_node: Any, index: SchemaIndex, path: str, defects: _Defects,
|
|
464
|
+
) -> list[Any]:
|
|
382
465
|
if any(child.kind != "item" for child in node.children):
|
|
383
|
-
|
|
466
|
+
defects.add(path, f"{path}: array requires Item rows")
|
|
467
|
+
return []
|
|
384
468
|
positions = [int(child.label) for child in node.children]
|
|
385
469
|
if positions != list(range(1, len(positions) + 1)):
|
|
386
|
-
|
|
470
|
+
defects.add(path, f"{path}: Item numbers must be 1..N")
|
|
471
|
+
return []
|
|
387
472
|
item_schema = index.item(schema_node)
|
|
388
473
|
return [
|
|
389
|
-
_parse_value(child, item_schema, index, f"{path}[{position - 1}]")
|
|
474
|
+
_parse_value(child, item_schema, index, f"{path}[{position - 1}]", defects)
|
|
390
475
|
for position, child in zip(positions, node.children, strict=True)
|
|
391
476
|
]
|
|
392
477
|
|
|
393
478
|
|
|
394
|
-
def _parse_object(
|
|
479
|
+
def _parse_object(
|
|
480
|
+
node: _Node, schema_node: Any, index: SchemaIndex, path: str, defects: _Defects,
|
|
481
|
+
) -> dict[str, Any]:
|
|
395
482
|
result: dict[str, Any] = {}
|
|
396
483
|
for child in node.children:
|
|
397
484
|
if child.kind != "field":
|
|
398
|
-
|
|
399
|
-
|
|
485
|
+
defects.add(path, f"{path}: object requires named fields")
|
|
486
|
+
return result
|
|
487
|
+
key = _field_key(child.label, schema_node, index, path, defects)
|
|
488
|
+
if key is None:
|
|
489
|
+
continue
|
|
400
490
|
child_path = f"{path}.{key}" if path else key
|
|
401
491
|
if child_path in _NESTED_FORBIDDEN:
|
|
402
|
-
|
|
403
|
-
f"owner=report-writer cannot author `{path}.{child.label}`"
|
|
492
|
+
defects.add(
|
|
493
|
+
child_path, f"owner=report-writer cannot author `{path}.{child.label}`"
|
|
404
494
|
)
|
|
495
|
+
continue
|
|
405
496
|
if key in result:
|
|
406
|
-
|
|
497
|
+
defects.add(child_path, f"duplicate field: {child_path}")
|
|
498
|
+
continue
|
|
407
499
|
child_schema = index.child(schema_node, key)
|
|
408
500
|
if not _node_types(child_schema, index):
|
|
409
501
|
child_schema = index.schema_for_key(key)
|
|
410
502
|
result[key] = _parse_value(
|
|
411
|
-
child, child_schema, index, child_path
|
|
503
|
+
child, child_schema, index, child_path, defects
|
|
412
504
|
)
|
|
413
505
|
return result
|
|
414
506
|
|
|
@@ -417,11 +509,14 @@ def parse_narrative(markdown: str, schema: Mapping[str, Any]) -> dict[str, Any]:
|
|
|
417
509
|
"""Markdown을 작성자 소유 자료로 읽고, 소유권 표면과 값 제약을 검증한다.
|
|
418
510
|
|
|
419
511
|
`schema` 는 완성 리포트 스키마다. 이름·타입 해석과 값 검증이 모두 그
|
|
420
|
-
한 벌에서 나온다(`_writer_owned_schema`).
|
|
512
|
+
한 벌에서 나온다(`_writer_owned_schema`). 값 단계의 결함(강제 변환·모양·
|
|
513
|
+
허용되지 않는 필드)과 스키마 검증의 결함을 한 예외에 모두 싣는다 — 값
|
|
514
|
+
단계가 보고한 자리는 스키마 검증에서 다시 말하지 않는다.
|
|
421
515
|
"""
|
|
422
516
|
root = _parse_tree(markdown)
|
|
423
517
|
index = SchemaIndex(schema)
|
|
424
|
-
|
|
518
|
+
defects = _Defects()
|
|
519
|
+
result = _parse_object(root, schema, index, "", defects)
|
|
425
520
|
unknown = sorted(set(result) - _allowed_top_level())
|
|
426
521
|
if unknown:
|
|
427
522
|
labels = [humanise(key) for key in unknown]
|
|
@@ -432,7 +527,11 @@ def parse_narrative(markdown: str, schema: Mapping[str, Any]) -> dict[str, Any]:
|
|
|
432
527
|
f"owner=report-writer cannot author fields: {labels}; "
|
|
433
528
|
f"writer-owned top-level fields: {owned}"
|
|
434
529
|
)
|
|
435
|
-
errors =
|
|
436
|
-
|
|
437
|
-
|
|
530
|
+
errors = [
|
|
531
|
+
error
|
|
532
|
+
for error in validate(result, _writer_owned_schema(schema))
|
|
533
|
+
if not defects.covers(error)
|
|
534
|
+
]
|
|
535
|
+
if defects.messages or errors:
|
|
536
|
+
raise NarrativeContractError("; ".join(defects.messages + errors))
|
|
438
537
|
return result
|
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
|
|
4
4
|
import copy
|
|
5
5
|
import hashlib
|
|
6
|
+
import json
|
|
6
7
|
import os
|
|
7
8
|
import re
|
|
8
9
|
import tempfile
|
|
@@ -16,6 +17,7 @@ from .implementation_options import (
|
|
|
16
17
|
EVALUATION_CRITERIA,
|
|
17
18
|
MAX_CRITERION_VALUE,
|
|
18
19
|
MAX_RANKED_OPTIONS,
|
|
20
|
+
MAX_RAW_CANDIDATES_PER_ANALYSER,
|
|
19
21
|
MIN_CRITERION_VALUE,
|
|
20
22
|
MIN_FEASIBLE_VOTES,
|
|
21
23
|
NO_VALID_OPTIONS_ROUTING,
|
|
@@ -23,6 +25,12 @@ from .implementation_options import (
|
|
|
23
25
|
from .report_narrative import writer_owned_data
|
|
24
26
|
from .scope_provenance import brief_end_state_id_sequence
|
|
25
27
|
|
|
28
|
+
from .exact_coverage import COVERAGE_VERDICT_PRECEDENCE
|
|
29
|
+
from .final_report_schema import task_block_rules, verdict_token_rule
|
|
30
|
+
from .report_contract import TASK_TYPE_DATA_PROPERTY
|
|
31
|
+
from .report_markdown import humanise
|
|
32
|
+
from .report_narrative import NarrativeContractError, allowed_top_level_fields
|
|
33
|
+
|
|
26
34
|
|
|
27
35
|
@dataclass(frozen=True)
|
|
28
36
|
class ReportSynthesisPacketIssue:
|
|
@@ -67,6 +75,21 @@ class ReportSynthesisPacket:
|
|
|
67
75
|
participating_analysers: tuple[str, ...] = ()
|
|
68
76
|
# 증분 판정. 저작 계약이 이번 run 의 stage 번호를 말할 수 있는 유일한 출처다.
|
|
69
77
|
incremental_decision: dict[str, Any] | None = None
|
|
78
|
+
# 넘겨받은 완성 리포트 스키마에서 뽑은 값. 작성자는 스키마 가지를 읽지 않으므로
|
|
79
|
+
# 여기 적어 줘야 도달한다(2026-09-02 실측: `Human Summary` 절 누락, `Verdict
|
|
80
|
+
# Token` 에 `analysis-complete` — 둘 다 HTML 렌더에서야 거절됐다).
|
|
81
|
+
required_top_level: tuple[str, ...] = ()
|
|
82
|
+
verdict_tokens: tuple[str, ...] = ()
|
|
83
|
+
# 이 task type 의 데이터 블록과, 스키마가 그 블록 안쪽에 못 박은 모양(객체당
|
|
84
|
+
# 한 줄). 최상위 필드까지만 싣던 동안 리드는 하위 구조의 필수 필드·식별자
|
|
85
|
+
# 패턴을 스키마 JSON 에서 손으로 뽑았고, 놓친 만큼 리포트를 다시 썼다
|
|
86
|
+
# (2026-09-03 실측: implementation-option-selection 네 회차).
|
|
87
|
+
block_key: str = ""
|
|
88
|
+
block_rules: tuple[str, ...] = ()
|
|
89
|
+
# 작성자가 최상위에 쓸 수 있는 필드 전체(서사 스키마의 properties). 필수만
|
|
90
|
+
# 적던 동안 리드가 어느 task type 에도 없는 절을 지시했고, 작성자는 조립이
|
|
91
|
+
# 거절할 때까지 그 지시를 거를 근거가 없었다(2026-09-03 실측).
|
|
92
|
+
allowed_top_level: tuple[str, ...] = ()
|
|
70
93
|
|
|
71
94
|
def _carry_instructions(self) -> list[str]:
|
|
72
95
|
"""이번 run 의 이월 지시. 증분이 아니면 빈 목록.
|
|
@@ -90,6 +113,45 @@ class ReportSynthesisPacket:
|
|
|
90
113
|
f"rejects a carried stage that changed or is missing.",
|
|
91
114
|
]
|
|
92
115
|
|
|
116
|
+
def _schema_instructions(self) -> list[str]:
|
|
117
|
+
"""스키마가 이 task type 에 못 박은 값을 저작 계약 문장으로 옮긴다."""
|
|
118
|
+
lines: list[str] = []
|
|
119
|
+
if self.required_top_level:
|
|
120
|
+
labels = ", ".join(f"`{label}`" for label in self.required_top_level)
|
|
121
|
+
lines.append(
|
|
122
|
+
f"Required top-level fields for this task type: {labels}. "
|
|
123
|
+
"Report assembly refuses a narrative that omits any of them."
|
|
124
|
+
)
|
|
125
|
+
if len(self.verdict_tokens) == 1:
|
|
126
|
+
lines.append(
|
|
127
|
+
f"`Final Verdict` -> `Verdict Token`: write exactly "
|
|
128
|
+
f"`{self.verdict_tokens[0]}`; the schema pins it for task type "
|
|
129
|
+
f"`{self.task_type}` and refuses every other value."
|
|
130
|
+
)
|
|
131
|
+
elif self.verdict_tokens:
|
|
132
|
+
allowed = ", ".join(f"`{token}`" for token in self.verdict_tokens)
|
|
133
|
+
lines.append(
|
|
134
|
+
f"`Final Verdict` -> `Verdict Token`: one of {allowed} for task type "
|
|
135
|
+
f"`{self.task_type}`."
|
|
136
|
+
)
|
|
137
|
+
if self.allowed_top_level:
|
|
138
|
+
labels = ", ".join(f"`{label}`" for label in self.allowed_top_level)
|
|
139
|
+
lines.append(
|
|
140
|
+
f"Writer-owned top-level fields, the complete set a narrative may "
|
|
141
|
+
f"contain: {labels}. Report assembly refuses any other top-level "
|
|
142
|
+
"field, whichever instruction asked for it."
|
|
143
|
+
)
|
|
144
|
+
if self.block_rules:
|
|
145
|
+
lines.append(
|
|
146
|
+
f"Shape of the `{self.block_key}` block, one line per object, from "
|
|
147
|
+
"the frozen schema. Paths are schema keys: write each key as its "
|
|
148
|
+
"Title Case label (`rankedOptions` -> `Ranked Options`), and `[]` "
|
|
149
|
+
"marks the items of an `- Item N` list. Report assembly refuses a "
|
|
150
|
+
"narrative that breaks any line."
|
|
151
|
+
)
|
|
152
|
+
lines.extend(self.block_rules)
|
|
153
|
+
return lines
|
|
154
|
+
|
|
93
155
|
def _carry_validation_rules(self) -> list[str]:
|
|
94
156
|
return ["carried-stages-unchanged"] if self.incremental_decision else []
|
|
95
157
|
|
|
@@ -114,6 +176,24 @@ class ReportSynthesisPacket:
|
|
|
114
176
|
"`criterionScores` row per evaluation criterion. Weights and scores "
|
|
115
177
|
f"are integers from {MIN_CRITERION_VALUE} through {MAX_CRITERION_VALUE}; "
|
|
116
178
|
"`weightedScore` is the recalculated weighted mean.",
|
|
179
|
+
"`proposedBy` names exactly one participating analyser id, never a "
|
|
180
|
+
"list. Counting `rankedOptions` and `candidateAudit` together, each "
|
|
181
|
+
f"analyser may propose at most {MAX_RAW_CANDIDATES_PER_ANALYSER} raw "
|
|
182
|
+
"candidates and the run may hold at most "
|
|
183
|
+
f"{MAX_RAW_CANDIDATES_PER_ANALYSER} times the analyser count.",
|
|
184
|
+
"`coverageSummary` is recalculated from the row's `requirementCoverage` "
|
|
185
|
+
"statuses and `scopeCommitments`, and every field must equal the "
|
|
186
|
+
"recalculation: `totalCount` = number of original requirement ids; "
|
|
187
|
+
"`coveredCount` = rows with status `covered`; `coveragePercent` = "
|
|
188
|
+
"round(coveredCount / totalCount * 100, 2); `unmappedCommitments` = "
|
|
189
|
+
"commitment ids whose `requirementIds` is empty or names an id outside "
|
|
190
|
+
"the original set; `scopePrecisionPercent` = round((commitments - "
|
|
191
|
+
"unmapped) / commitments * 100, 2); `contradictedRequirements` = "
|
|
192
|
+
"original ids with status `contradicted`, in original order; "
|
|
193
|
+
"`coverageVerdict` = the first that applies of "
|
|
194
|
+
+ ", ".join(f"`{verdict}`" for verdict in COVERAGE_VERDICT_PRECEDENCE)
|
|
195
|
+
+ " (any contradicted; coveredCount below totalCount; any unmapped; "
|
|
196
|
+
"otherwise).",
|
|
117
197
|
"A ranked option is valid only when every participating analyser "
|
|
118
198
|
"supplied one feasibility vote, at least "
|
|
119
199
|
f"{MIN_FEASIBLE_VOTES} votes are `feasible`, and both `safetyBlockers` "
|
|
@@ -170,6 +250,7 @@ class ReportSynthesisPacket:
|
|
|
170
250
|
"Do not invent a value when a source is missing or contradictory.",
|
|
171
251
|
"Write only the narrative, pointer, and audit artifacts named by the dispatch.",
|
|
172
252
|
*_INTERNAL_IDENTIFIER_INSTRUCTION,
|
|
253
|
+
*self._schema_instructions(),
|
|
173
254
|
*self._task_instructions(),
|
|
174
255
|
*self._carry_instructions(),
|
|
175
256
|
],
|
|
@@ -208,6 +289,7 @@ class ReportSynthesisPacket:
|
|
|
208
289
|
# 작성자가 실제로 읽는 것은 마크다운이다. JSON 정본에만 실으면 지시가
|
|
209
290
|
# 도달하지 않는다.
|
|
210
291
|
lines.extend(f"- {text}" for text in _INTERNAL_IDENTIFIER_INSTRUCTION)
|
|
292
|
+
lines.extend(f"- {text}" for text in self._schema_instructions())
|
|
211
293
|
lines.extend(f"- {text}" for text in self._task_instructions())
|
|
212
294
|
lines.extend(f"- {text}" for text in self._carry_instructions())
|
|
213
295
|
lines.extend(_accounting_markdown(self.accounting_snapshot))
|
|
@@ -646,10 +728,18 @@ def build_report_synthesis_packet(
|
|
|
646
728
|
for worker in roster
|
|
647
729
|
if _string(worker) and _string(worker) != "report-writer"
|
|
648
730
|
)
|
|
731
|
+
required_top_level, verdict_tokens, block_rules = _schema_authoring_rules(
|
|
732
|
+
sources, task_type
|
|
733
|
+
)
|
|
649
734
|
return ReportSynthesisPacket(
|
|
650
735
|
task_key=_string(manifest.get("taskKey")),
|
|
651
736
|
task_type=task_type,
|
|
652
737
|
result_path=_relative(project_root, narrative_path),
|
|
738
|
+
required_top_level=required_top_level,
|
|
739
|
+
verdict_tokens=verdict_tokens,
|
|
740
|
+
block_key=TASK_TYPE_DATA_PROPERTY.get(task_type, "") if block_rules else "",
|
|
741
|
+
block_rules=block_rules,
|
|
742
|
+
allowed_top_level=_allowed_top_level_labels(),
|
|
653
743
|
sources=sources,
|
|
654
744
|
accounting_snapshot=_accounting_snapshot(team_state),
|
|
655
745
|
original_requirement_ids=original_requirement_ids,
|
|
@@ -658,6 +748,51 @@ def build_report_synthesis_packet(
|
|
|
658
748
|
)
|
|
659
749
|
|
|
660
750
|
|
|
751
|
+
def _schema_authoring_rules(
|
|
752
|
+
sources: tuple[ReportSynthesisSource, ...], task_type: str,
|
|
753
|
+
) -> tuple[tuple[str, ...], tuple[str, ...], tuple[str, ...]]:
|
|
754
|
+
"""넘겨받은(동결된) 완성 리포트 스키마에서 작성자 몫의 규칙 세 가지를 뽑는다.
|
|
755
|
+
|
|
756
|
+
최상위 `required` 가운데 작성자 소유 이름(서사 스키마의 properties)만
|
|
757
|
+
사람이 읽는 라벨로, 이 task type 의 `Verdict Token` 허용값, 그리고 이 task
|
|
758
|
+
type 의 데이터 블록 안쪽 모양(`task_block_rules`). 스키마 소스가 없거나
|
|
759
|
+
JSON 이 아니면 빈 값이다 — 이 함수는 조립 검증을 대신하지 않고 도달하지
|
|
760
|
+
못하던 규칙을 저작 계약에 옮길 뿐이다.
|
|
761
|
+
"""
|
|
762
|
+
schema_text = next(
|
|
763
|
+
(source.content for source in sources if source.label == "Final report schema"),
|
|
764
|
+
"",
|
|
765
|
+
)
|
|
766
|
+
try:
|
|
767
|
+
schema = json.loads(schema_text) if schema_text else {}
|
|
768
|
+
except ValueError:
|
|
769
|
+
schema = {}
|
|
770
|
+
if not isinstance(schema, dict):
|
|
771
|
+
schema = {}
|
|
772
|
+
try:
|
|
773
|
+
allowed = allowed_top_level_fields()
|
|
774
|
+
except (NarrativeContractError, JsonBoundaryError):
|
|
775
|
+
allowed = frozenset()
|
|
776
|
+
required = schema.get("required")
|
|
777
|
+
required_top_level = tuple(
|
|
778
|
+
humanise(key)
|
|
779
|
+
for key in (required if isinstance(required, list) else [])
|
|
780
|
+
if isinstance(key, str) and key in allowed
|
|
781
|
+
)
|
|
782
|
+
block_key = TASK_TYPE_DATA_PROPERTY.get(task_type, "")
|
|
783
|
+
block_rules = task_block_rules(schema, block_key) if block_key else ()
|
|
784
|
+
return required_top_level, verdict_token_rule(schema, task_type), block_rules
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def _allowed_top_level_labels() -> tuple[str, ...]:
|
|
788
|
+
"""작성자 소유 최상위 필드 전체 — 서사 스키마 부재는 빈 값(조립이 판정한다)."""
|
|
789
|
+
try:
|
|
790
|
+
allowed = allowed_top_level_fields()
|
|
791
|
+
except (NarrativeContractError, JsonBoundaryError):
|
|
792
|
+
return ()
|
|
793
|
+
return tuple(humanise(key) for key in sorted(allowed))
|
|
794
|
+
|
|
795
|
+
|
|
661
796
|
def report_synthesis_packet_paths(narrative_path: Path) -> tuple[Path, Path]:
|
|
662
797
|
name = narrative_path.name
|
|
663
798
|
prefix = "report-writer-narrative-"
|
|
@@ -3,6 +3,8 @@ from __future__ import annotations
|
|
|
3
3
|
|
|
4
4
|
from pathlib import Path
|
|
5
5
|
|
|
6
|
+
from .json_boundary import JsonBoundaryError, load_owned_object
|
|
7
|
+
|
|
6
8
|
|
|
7
9
|
def html_view_path(report_path: Path) -> Path:
|
|
8
10
|
"""Return the HTML sibling for a final-report Markdown or data path."""
|
|
@@ -18,5 +20,43 @@ def user_responses_dir_for_report(report_path: Path) -> Path:
|
|
|
18
20
|
|
|
19
21
|
|
|
20
22
|
def team_state_path_for_report(report_path: Path, task_type: str, seq: str) -> Path:
|
|
21
|
-
"""
|
|
22
|
-
|
|
23
|
+
"""The team-state of the run that produced this report.
|
|
24
|
+
|
|
25
|
+
The report's sequence is not the run's: categories are numbered on their
|
|
26
|
+
own (`paths.compute_run_paths`), so two prepared-and-abandoned runs leave
|
|
27
|
+
the report slot free while consuming state slots, and the run that finally
|
|
28
|
+
writes `final-report-…-002` keeps its usage in `team-state-…-004`. Naming
|
|
29
|
+
the state after the report's seq read the earlier run's usage — a header
|
|
30
|
+
elapsed of 389h taken from a run that shared nothing but the number
|
|
31
|
+
(observed 2026-09-02, dev-10626 error-analysis). The run manifest names
|
|
32
|
+
both files (`expectedReportRecordPath`, `teamStatePath`), so the latest
|
|
33
|
+
manifest naming this report decides; the seq-derived sibling is the
|
|
34
|
+
fallback for a run directory without such a manifest.
|
|
35
|
+
"""
|
|
36
|
+
run_dir = report_path.parent.parent
|
|
37
|
+
recorded = _team_state_from_manifests(run_dir, report_path.name)
|
|
38
|
+
if recorded is not None:
|
|
39
|
+
return recorded
|
|
40
|
+
return run_dir / "state" / f"team-state-{task_type}-{seq}.json"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _team_state_from_manifests(run_dir: Path, report_name: str) -> Path | None:
|
|
44
|
+
# 최신 manifest 부터 본다 — 같은 보고서 자리를 이름한 run 이 여럿이면(앞선
|
|
45
|
+
# run 이 준비만 하고 끝난 경우) 마지막 run 이 그 보고서를 쓴 run 이다.
|
|
46
|
+
# `Path.glob` 는 디렉터리가 없어도 빈 결과를 낸다.
|
|
47
|
+
for manifest_path in sorted(
|
|
48
|
+
(run_dir / "manifests").glob("run-manifest-*.json"), reverse=True
|
|
49
|
+
):
|
|
50
|
+
try:
|
|
51
|
+
manifest = load_owned_object(manifest_path, artifact="run manifest")
|
|
52
|
+
except (OSError, JsonBoundaryError):
|
|
53
|
+
# 깨진 manifest 는 이 보고서를 이름할 수 없다 — 다음 후보를 본다.
|
|
54
|
+
continue
|
|
55
|
+
expected = manifest.get("expectedReportRecordPath")
|
|
56
|
+
team_state = manifest.get("teamStatePath")
|
|
57
|
+
if not isinstance(expected, str) or not isinstance(team_state, str):
|
|
58
|
+
continue
|
|
59
|
+
if Path(expected).name != report_name or not team_state:
|
|
60
|
+
continue
|
|
61
|
+
return run_dir / "state" / Path(team_state).name
|
|
62
|
+
return None
|
|
@@ -106,10 +106,33 @@ def parse_finding_votes(text: str, *, adversarial: bool) -> dict[str, FindingVot
|
|
|
106
106
|
an unfamiliar token silently read as a different verdict.
|
|
107
107
|
"""
|
|
108
108
|
vocabulary = ADVERSARIAL_VERDICTS if adversarial else COLLABORATIVE_VERDICTS
|
|
109
|
-
return
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
109
|
+
return _collect_blocks(
|
|
110
|
+
_scan_blocks(text),
|
|
111
|
+
lambda item_id, fields: _finding_vote(item_id, fields, vocabulary),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _collect_blocks(blocks: dict[str, dict[str, str]], parse) -> dict:
|
|
116
|
+
"""모든 블록을 읽고 결함을 한 번에 보고한다.
|
|
117
|
+
|
|
118
|
+
첫 결함에서 멈추면 15건 중 13건이 같은 결함인 응답도 한 건씩만 드러나,
|
|
119
|
+
교정 디스패치를 그 수만큼 반복하게 된다(2026-09-02 실측). 한 응답의 결함은
|
|
120
|
+
서로 독립이므로 전부 모아 하나의 오류로 낸다.
|
|
121
|
+
"""
|
|
122
|
+
parsed: dict = {}
|
|
123
|
+
errors: list[str] = []
|
|
124
|
+
for item_id, fields in blocks.items():
|
|
125
|
+
try:
|
|
126
|
+
parsed[item_id] = parse(item_id, fields)
|
|
127
|
+
except VerdictBlockError as exc:
|
|
128
|
+
errors.append(str(exc))
|
|
129
|
+
if errors:
|
|
130
|
+
if len(errors) == 1:
|
|
131
|
+
raise VerdictBlockError(errors[0])
|
|
132
|
+
raise VerdictBlockError(
|
|
133
|
+
f"{len(errors)} of {len(blocks)} blocks are malformed: " + "; ".join(errors)
|
|
134
|
+
)
|
|
135
|
+
return parsed
|
|
113
136
|
|
|
114
137
|
|
|
115
138
|
def _finding_vote(
|
|
@@ -155,10 +178,7 @@ class VerdictBlock:
|
|
|
155
178
|
|
|
156
179
|
def parse_verdict_blocks(text: str) -> dict[str, VerdictBlock]:
|
|
157
180
|
"""Every `### <item-id>` plan-body verdict block in *text*, keyed by item id."""
|
|
158
|
-
return
|
|
159
|
-
item_id: _block(item_id, fields)
|
|
160
|
-
for item_id, fields in _scan_blocks(text).items()
|
|
161
|
-
}
|
|
181
|
+
return _collect_blocks(_scan_blocks(text), _block)
|
|
162
182
|
|
|
163
183
|
|
|
164
184
|
def _verdict_token(item_id: str, raw: str) -> tuple[str, str]:
|