okstra 0.188.0 → 0.189.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/cli.md +2 -2
- package/docs/project-structure-overview.md +2 -1
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/report-writer-worker.md +2 -0
- package/runtime/prompts/lead/okstra-lead-contract.md +1 -1
- package/runtime/prompts/lead/plan-body-verification.md +1 -1
- package/runtime/prompts/lead/report-writer.md +11 -8
- package/runtime/python/okstra_ctl/agent/activity.py +3 -0
- package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +51 -1
- package/runtime/python/okstra_ctl/agent/prompt_cli/corrections.py +280 -0
- package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +191 -0
- package/runtime/python/okstra_ctl/report_corrections.py +443 -0
- package/runtime/python/okstra_ctl/report_finalize.py +40 -1
- package/runtime/python/okstra_ctl/report_narrative.py +44 -3
- package/runtime/python/okstra_ctl/report_projections.py +3 -1
- package/runtime/python/okstra_token_usage/collect.py +34 -2
- package/runtime/python/okstra_token_usage/report.py +1 -1
- package/runtime/schemas/final-report-v3.0.schema.json +1 -0
- package/runtime/schemas/report-writer-corrections-v1.0.schema.json +52 -0
- package/runtime/templates/report-writer-prompt-preamble.md +6 -0
|
@@ -32,13 +32,25 @@ from ...assignment_resolver import AssignmentContext, resolve_dispatch_assignmen
|
|
|
32
32
|
from ...path_hints import hydrate_active_run_context
|
|
33
33
|
from ...worker_prompt_headers import worker_prompt_headers
|
|
34
34
|
from ...final_report_paths import final_report_data_path
|
|
35
|
+
from ...final_report_schema import load_schema_version
|
|
35
36
|
from ...report_inputs import report_narrative_path, uses_report_contract_v3
|
|
37
|
+
from ...report_narrative import NarrativeContractError, parse_narrative_structure
|
|
36
38
|
from ...report_synthesis_packet import (
|
|
37
39
|
ReportSynthesisPacketError,
|
|
38
40
|
materialize_report_synthesis_packet,
|
|
39
41
|
)
|
|
40
42
|
from ...worker_prompt_body import report_writer_input_lines
|
|
41
43
|
from ...dispatch_state import detect_terminal_backend
|
|
44
|
+
from ...report_corrections import (
|
|
45
|
+
body_owned_section_conflicts,
|
|
46
|
+
render_corrections_section,
|
|
47
|
+
render_output_section,
|
|
48
|
+
)
|
|
49
|
+
from .corrections import (
|
|
50
|
+
corrections_payload,
|
|
51
|
+
run_corrections_apply,
|
|
52
|
+
run_corrections_check,
|
|
53
|
+
)
|
|
42
54
|
from .dynamic_verifier import (
|
|
43
55
|
_dynamic_verifier_source,
|
|
44
56
|
_reserve_dynamic_verifier_request,
|
|
@@ -270,6 +282,26 @@ def _materialize_run(
|
|
|
270
282
|
narrative_path=result_path,
|
|
271
283
|
),
|
|
272
284
|
)
|
|
285
|
+
if args.audience == "report-writer" and uses_report_contract_v3(manifest):
|
|
286
|
+
body = _with_report_writer_sections(
|
|
287
|
+
args,
|
|
288
|
+
project_root,
|
|
289
|
+
manifest,
|
|
290
|
+
authorized,
|
|
291
|
+
active_context,
|
|
292
|
+
body,
|
|
293
|
+
narrative_path=result_path,
|
|
294
|
+
)
|
|
295
|
+
elif getattr(args, "corrections", None):
|
|
296
|
+
if args.audience != "report-writer":
|
|
297
|
+
raise AgentPromptCliError(
|
|
298
|
+
"--corrections is a report-writer materialization option; "
|
|
299
|
+
f"audience {args.audience} has no corrections ledger"
|
|
300
|
+
)
|
|
301
|
+
raise AgentPromptCliError(
|
|
302
|
+
"--corrections requires report contract 3.0 (a Markdown narrative); "
|
|
303
|
+
"this run uses an older contract"
|
|
304
|
+
)
|
|
273
305
|
request = AgentInvocationRequest(
|
|
274
306
|
invocation_id=args.invocation_id,
|
|
275
307
|
worker_id=args.worker_id if identity is None else None,
|
|
@@ -315,6 +347,165 @@ def _normalized(path: Path) -> Path:
|
|
|
315
347
|
return Path(os.path.normpath(path))
|
|
316
348
|
|
|
317
349
|
|
|
350
|
+
def _with_report_writer_sections(
|
|
351
|
+
args: argparse.Namespace,
|
|
352
|
+
project_root: Path,
|
|
353
|
+
manifest: Mapping[str, Any],
|
|
354
|
+
authorized: Mapping[str, Any],
|
|
355
|
+
active_context: Mapping[str, Any],
|
|
356
|
+
body: str,
|
|
357
|
+
*,
|
|
358
|
+
narrative_path: Path,
|
|
359
|
+
) -> str:
|
|
360
|
+
"""run 갈래 report-writer 프롬프트의 okstra 소유 절을 본문 끝에 렌더한다.
|
|
361
|
+
|
|
362
|
+
`## Output` 은 매 디스패치에 렌더한다 — 세 산출물을 리드가 손으로 열거하다
|
|
363
|
+
하나를 빠뜨리면 `required worker artifact was not produced` 로 끝났다.
|
|
364
|
+
`--corrections` 가 있으면 원장을 대조해 `## Corrections` 를 그 앞에 둔다.
|
|
365
|
+
리드가 자유 서술로 적던 교정 지시는 기계가 대조할 수 없어 값 오류가
|
|
366
|
+
조립에서야 드러났다(2026-09-03 실측: 재실행 6회 중 4회). 그래서 원장 없는
|
|
367
|
+
교정 디스패치(서사가 이미 있고 파싱되는 경우)는 거절한다. 두 절은 okstra 가
|
|
368
|
+
렌더하므로 본문에 같은 제목이 있으면 거절한다.
|
|
369
|
+
"""
|
|
370
|
+
conflicts = body_owned_section_conflicts(body)
|
|
371
|
+
if conflicts:
|
|
372
|
+
raise AgentPromptCliError(
|
|
373
|
+
f"instruction body must not contain {', '.join(conflicts)}: okstra "
|
|
374
|
+
"renders those sections itself (## Output from the run manifest, "
|
|
375
|
+
"## Corrections from the --corrections ledger)"
|
|
376
|
+
)
|
|
377
|
+
sections: list[str] = []
|
|
378
|
+
if getattr(args, "corrections", None):
|
|
379
|
+
corrections_path = _authorized_path(
|
|
380
|
+
project_root,
|
|
381
|
+
args.corrections,
|
|
382
|
+
authorized.get("instructionRoots"),
|
|
383
|
+
"corrections",
|
|
384
|
+
must_exist=True,
|
|
385
|
+
)
|
|
386
|
+
check = run_corrections_check(
|
|
387
|
+
project_root=project_root,
|
|
388
|
+
manifest=manifest,
|
|
389
|
+
active_context=active_context,
|
|
390
|
+
team_state=_load_team_state(project_root, manifest),
|
|
391
|
+
corrections_path=corrections_path,
|
|
392
|
+
narrative_path=narrative_path,
|
|
393
|
+
)
|
|
394
|
+
if check.defects:
|
|
395
|
+
raise AgentPromptCliError(
|
|
396
|
+
"report-writer corrections defects: " + "; ".join(check.defects)
|
|
397
|
+
)
|
|
398
|
+
sections.extend(render_corrections_section(
|
|
399
|
+
check,
|
|
400
|
+
base_narrative_rel=str(check.ledger.get("baseNarrativePath") or ""),
|
|
401
|
+
corrections_rel=_relative(project_root, corrections_path),
|
|
402
|
+
))
|
|
403
|
+
sections.append("")
|
|
404
|
+
else:
|
|
405
|
+
_refuse_free_form_correction(project_root, narrative_path)
|
|
406
|
+
sections.extend(render_output_section())
|
|
407
|
+
return body.rstrip("\n") + "\n\n" + "\n".join(sections) + "\n"
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _refuse_free_form_correction(project_root: Path, narrative_path: Path) -> None:
|
|
411
|
+
"""서사가 이미 있고 구조가 읽히면 이 디스패치는 교정이다 — 원장 없이는 거절한다.
|
|
412
|
+
|
|
413
|
+
구조가 읽히지 않는 서사(줄 문법·소유권 결함)는 교정 대상이 아니라 재저작
|
|
414
|
+
대상이다(원장의 경로가 해소될 자료가 없다). 그 디스패치는 원장 없이
|
|
415
|
+
통과한다. 값 결함(패턴 밖 id, enum 밖 값)은 구조가 읽히는 서사이고, 그것이
|
|
416
|
+
원장이 고치는 자리다 — 실측(dev-10626 a3)의 `SC-` id 20곳이 이 경우다.
|
|
417
|
+
"""
|
|
418
|
+
if not narrative_path.is_file():
|
|
419
|
+
return
|
|
420
|
+
try:
|
|
421
|
+
parse_narrative_structure(
|
|
422
|
+
narrative_path.read_text(encoding="utf-8"), load_schema_version("3.0"),
|
|
423
|
+
)
|
|
424
|
+
except NarrativeContractError:
|
|
425
|
+
return
|
|
426
|
+
narrative_rel = _relative(project_root, narrative_path)
|
|
427
|
+
raise AgentPromptCliError(
|
|
428
|
+
f"report-writer narrative already exists at {narrative_rel} and parses, "
|
|
429
|
+
"so this is a corrective dispatch, and a free-form correction is refused: "
|
|
430
|
+
"nothing can check it before the writer runs. Write a corrections ledger "
|
|
431
|
+
"(schemas/report-writer-corrections-v1.0.schema.json; baseNarrativePath "
|
|
432
|
+
"names a preserved copy of that attempt), run `okstra agent-prompt "
|
|
433
|
+
"check-corrections --project-root <root> --run-manifest <manifest> "
|
|
434
|
+
"--corrections <ledger>` until it reports no defect, then pass the same "
|
|
435
|
+
"--corrections here. A ledger of only replace/remove entries needs no "
|
|
436
|
+
"writer round: `okstra agent-prompt apply-corrections` writes the "
|
|
437
|
+
"narrative and records the activity row. Only a narrative whose "
|
|
438
|
+
"structure does not parse (line grammar, unknown top-level field) is "
|
|
439
|
+
"re-authored without a ledger; value defects are what the ledger fixes"
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _corrections_context(
|
|
444
|
+
args: argparse.Namespace,
|
|
445
|
+
) -> tuple[Path, Path, dict[str, Any], Mapping[str, Any], Path]:
|
|
446
|
+
"""`check-corrections`·`apply-corrections` 가 공유하는 입력 해소."""
|
|
447
|
+
project_root = _project_root(args.project_root)
|
|
448
|
+
manifest_path = _project_input(project_root, args.run_manifest, "run manifest")
|
|
449
|
+
manifest = _read_json_object(manifest_path, "run manifest")
|
|
450
|
+
contract = _mapping(manifest.get("agentContract"), "run agent contract")
|
|
451
|
+
authorized = _mapping(contract.get("authorizedPaths"), "authorized paths")
|
|
452
|
+
corrections_path = _authorized_path(
|
|
453
|
+
project_root,
|
|
454
|
+
args.corrections,
|
|
455
|
+
authorized.get("instructionRoots"),
|
|
456
|
+
"corrections",
|
|
457
|
+
must_exist=True,
|
|
458
|
+
)
|
|
459
|
+
active_context_path = _project_manifest_path(
|
|
460
|
+
project_root,
|
|
461
|
+
manifest.get("activeRunContextPath"),
|
|
462
|
+
"active run context",
|
|
463
|
+
must_exist=True,
|
|
464
|
+
)
|
|
465
|
+
active_context = hydrate_active_run_context(
|
|
466
|
+
_read_json_object(active_context_path, "active run context")
|
|
467
|
+
)
|
|
468
|
+
return project_root, manifest_path, manifest, active_context, corrections_path
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _check_corrections(args: argparse.Namespace) -> dict[str, Any]:
|
|
472
|
+
"""`okstra agent-prompt check-corrections` — 실체화 없이 원장만 대조한다."""
|
|
473
|
+
project_root, _manifest_path, manifest, active_context, corrections_path = (
|
|
474
|
+
_corrections_context(args)
|
|
475
|
+
)
|
|
476
|
+
narrative_path = (
|
|
477
|
+
report_narrative_path(project_root, manifest)
|
|
478
|
+
if uses_report_contract_v3(manifest)
|
|
479
|
+
else project_root
|
|
480
|
+
)
|
|
481
|
+
check = run_corrections_check(
|
|
482
|
+
project_root=project_root,
|
|
483
|
+
manifest=manifest,
|
|
484
|
+
active_context=active_context,
|
|
485
|
+
team_state=_load_team_state(project_root, manifest),
|
|
486
|
+
corrections_path=corrections_path,
|
|
487
|
+
narrative_path=narrative_path,
|
|
488
|
+
)
|
|
489
|
+
return corrections_payload(
|
|
490
|
+
check, project_root=project_root, corrections_path=corrections_path,
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _apply_corrections(args: argparse.Namespace) -> dict[str, Any]:
|
|
495
|
+
"""`okstra agent-prompt apply-corrections` — 기계적 원장을 서사에 쓴다."""
|
|
496
|
+
project_root, manifest_path, manifest, active_context, corrections_path = (
|
|
497
|
+
_corrections_context(args)
|
|
498
|
+
)
|
|
499
|
+
return run_corrections_apply(
|
|
500
|
+
project_root=project_root,
|
|
501
|
+
manifest=manifest,
|
|
502
|
+
manifest_path=manifest_path,
|
|
503
|
+
active_context=active_context,
|
|
504
|
+
team_state=_load_team_state(project_root, manifest),
|
|
505
|
+
corrections_path=corrections_path,
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
|
|
318
509
|
def _validate_report_writer_paths(
|
|
319
510
|
project_root: Path,
|
|
320
511
|
manifest: Mapping[str, Any],
|
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
"""report-writer 교정 원장 — 리드의 교정 요청을 기계가 대조하는 형태로 받는다.
|
|
2
|
+
|
|
3
|
+
리드가 report-writer 에게 보내던 교정 지시는 자유 서술 Markdown 이라, 필드
|
|
4
|
+
경로·현재값·대체값을 정확히 적으라는 계약이 있어도 기계가 대조할 수 없었다.
|
|
5
|
+
실측(2026-09-03, dev-10626 implementation-option-selection): 재실행 6회 중 4회가
|
|
6
|
+
리드 지시문이 저작 계약과 반대인 경우였고, 오류는 report assembly 에서야
|
|
7
|
+
드러났다. 이 모듈은 그 지시를 원장(`report-writer-corrections-*.json`)으로
|
|
8
|
+
받아 이전 서사에 적용해 보고, 스키마·task 의미 검증기로 대조해 결함 전건을
|
|
9
|
+
한 번에 낸다. 설계: `.project-docs/specs/2026-09-03-report-writer-structured-corrections-design.md`.
|
|
10
|
+
|
|
11
|
+
경로 문법은 검증기 메시지의 것과 같다 — 스키마 키를 `.` 로 잇고 배열 항목은
|
|
12
|
+
0 기반 `[i]` 다. 조립 거절 메시지의 경로를 그대로 복사해 원장에 넣을 수 있다.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import re
|
|
18
|
+
from copy import deepcopy
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any, Callable, Mapping, Sequence
|
|
22
|
+
|
|
23
|
+
from .final_report_schema import validate
|
|
24
|
+
from .json_boundary import JsonBoundaryError, load_owned_object
|
|
25
|
+
from .report_markdown import humanise
|
|
26
|
+
from .report_narrative import (
|
|
27
|
+
NarrativeContractError,
|
|
28
|
+
parse_narrative_structure,
|
|
29
|
+
render_narrative,
|
|
30
|
+
validate_writer_owned,
|
|
31
|
+
writer_owned_path_defect,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
CORRECTION_KINDS = ("replace", "remove", "rewrite")
|
|
36
|
+
MECHANICAL_KINDS = frozenset({"replace", "remove"})
|
|
37
|
+
# okstra 가 렌더하는 절. 지시문 본문에 있으면 두 절이 갈라지므로 거절한다.
|
|
38
|
+
OKSTRA_OWNED_SECTIONS = ("## Corrections", "## Output")
|
|
39
|
+
|
|
40
|
+
_SCHEMA_RELATIVE = ("schemas", "report-writer-corrections-v1.0.schema.json")
|
|
41
|
+
_SEGMENT_RE = re.compile(r"^([A-Za-z][A-Za-z0-9]*)((?:\[\d+\])*)$")
|
|
42
|
+
_INDEX_RE = re.compile(r"\[(\d+)\]")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ReportCorrectionsError(ValueError):
|
|
46
|
+
"""교정 원장을 적용할 수 없다 — 결함 전건이 메시지에 실린다."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, defects: Sequence[str]) -> None:
|
|
49
|
+
self.defects = tuple(defects)
|
|
50
|
+
super().__init__("; ".join(self.defects))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class CorrectionsCheck:
|
|
55
|
+
"""원장 대조 결과. `defects` 가 비어야 디스패치할 수 있다."""
|
|
56
|
+
|
|
57
|
+
ledger: dict[str, Any]
|
|
58
|
+
corrections: tuple[dict[str, Any], ...]
|
|
59
|
+
# 기계적 교정(replace·remove)을 적용한 서사 자료. 기준 서사를 못 읽었으면 None.
|
|
60
|
+
scratch: dict[str, Any] | None
|
|
61
|
+
# 작성자 라운드 없이 끝나는가 — rewrite 가 없고 결함도 없다.
|
|
62
|
+
mechanical: bool
|
|
63
|
+
# rewrite 교정 id → 그 경로의 스키마 제약 문장(`task_block_rules` 줄).
|
|
64
|
+
constraints: dict[str, tuple[str, ...]]
|
|
65
|
+
defects: tuple[str, ...]
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def ok(self) -> bool:
|
|
69
|
+
return not self.defects
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _defect(correction_id: str, path: str, reason: str) -> str:
|
|
73
|
+
return f"owner=lead correction={correction_id} path={path} reason={reason}"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _schema_path() -> Path:
|
|
77
|
+
from .paths import find_asset_root
|
|
78
|
+
|
|
79
|
+
root = find_asset_root(_SCHEMA_RELATIVE)
|
|
80
|
+
if root is None:
|
|
81
|
+
raise ReportCorrectionsError([
|
|
82
|
+
_defect("ledger", "-", "could not locate report-writer-corrections-v1.0.schema.json; "
|
|
83
|
+
"set OKSTRA_HOME or run from a checkout that contains schemas/")
|
|
84
|
+
])
|
|
85
|
+
return root.joinpath(*_SCHEMA_RELATIVE)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def corrections_schema() -> dict[str, Any]:
|
|
89
|
+
return load_owned_object(_schema_path(), artifact="report-writer corrections schema")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def load_corrections(path: Path) -> tuple[dict[str, Any], list[str]]:
|
|
93
|
+
"""원장을 읽고 스키마 결함을 전건 모은다. 읽기 실패도 결함 한 건이다."""
|
|
94
|
+
try:
|
|
95
|
+
payload = load_owned_object(path, artifact="report-writer corrections")
|
|
96
|
+
except (OSError, JsonBoundaryError) as exc:
|
|
97
|
+
return {}, [_defect("ledger", str(path), f"corrections ledger is unreadable: {exc}")]
|
|
98
|
+
defects = [
|
|
99
|
+
_defect("ledger", error.split(": ", 1)[0], error)
|
|
100
|
+
for error in validate(payload, corrections_schema())
|
|
101
|
+
]
|
|
102
|
+
return payload, defects
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def parse_field_path(path: str) -> tuple[str | int, ...]:
|
|
106
|
+
"""`a.b[1].c` → ("a", "b", 1, "c"). 문법 밖이면 ValueError."""
|
|
107
|
+
tokens: list[str | int] = []
|
|
108
|
+
if not isinstance(path, str) or not path.strip():
|
|
109
|
+
raise ValueError("field path is empty")
|
|
110
|
+
for segment in path.split("."):
|
|
111
|
+
match = _SEGMENT_RE.match(segment)
|
|
112
|
+
if match is None:
|
|
113
|
+
raise ValueError(
|
|
114
|
+
f"field path segment `{segment}` is not `<key>` or `<key>[<index>]`"
|
|
115
|
+
)
|
|
116
|
+
tokens.append(match.group(1))
|
|
117
|
+
tokens.extend(int(index) for index in _INDEX_RE.findall(match.group(2)))
|
|
118
|
+
return tuple(tokens)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def normalise_field_path(path: str) -> str:
|
|
122
|
+
"""`a.b[1].c` → `a.b[].c` — `task_block_rules` 가 쓰는 경로 표기."""
|
|
123
|
+
return _INDEX_RE.sub("[]", path)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def humanise_field_path(tokens: Sequence[str | int]) -> str:
|
|
127
|
+
"""스키마 키 경로를 작성자가 쓰는 라벨 경로로: `Ranked Options` -> `Item 2`."""
|
|
128
|
+
parts = [
|
|
129
|
+
f"`Item {token + 1}`" if isinstance(token, int) else f"`{humanise(token)}`"
|
|
130
|
+
for token in tokens
|
|
131
|
+
]
|
|
132
|
+
return " -> ".join(parts)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _resolve(data: Any, tokens: Sequence[str | int]) -> tuple[bool, Any]:
|
|
136
|
+
node = data
|
|
137
|
+
for token in tokens:
|
|
138
|
+
if isinstance(token, int):
|
|
139
|
+
if not isinstance(node, list) or not 0 <= token < len(node):
|
|
140
|
+
return False, None
|
|
141
|
+
node = node[token]
|
|
142
|
+
else:
|
|
143
|
+
if not isinstance(node, dict) or token not in node:
|
|
144
|
+
return False, None
|
|
145
|
+
node = node[token]
|
|
146
|
+
return True, node
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _parent(data: Any, tokens: Sequence[str | int]) -> tuple[bool, Any]:
|
|
150
|
+
return _resolve(data, tokens[:-1]) if tokens else (False, None)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _set(data: Any, tokens: Sequence[str | int], value: Any) -> None:
|
|
154
|
+
found, parent = _parent(data, tokens)
|
|
155
|
+
if not found:
|
|
156
|
+
raise KeyError(tokens)
|
|
157
|
+
last = tokens[-1]
|
|
158
|
+
if isinstance(last, int):
|
|
159
|
+
parent[last] = value
|
|
160
|
+
else:
|
|
161
|
+
parent[last] = value
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _remove(data: Any, tokens: Sequence[str | int]) -> None:
|
|
165
|
+
found, parent = _parent(data, tokens)
|
|
166
|
+
if not found:
|
|
167
|
+
raise KeyError(tokens)
|
|
168
|
+
last = tokens[-1]
|
|
169
|
+
if isinstance(last, int):
|
|
170
|
+
del parent[last]
|
|
171
|
+
else:
|
|
172
|
+
del parent[last]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _removal_order(token: str | int) -> tuple[int, Any]:
|
|
176
|
+
# 같은 배열의 항목은 뒤 번호부터 지워야 앞 번호가 밀리지 않는다.
|
|
177
|
+
return (0, token) if isinstance(token, int) else (1, token)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _current_matches(expected: Any, actual: Any) -> bool:
|
|
181
|
+
return json.dumps(expected, sort_keys=True) == json.dumps(actual, sort_keys=True)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _short(value: Any) -> str:
|
|
185
|
+
text = json.dumps(value, ensure_ascii=False)
|
|
186
|
+
return text if len(text) <= 120 else text[:117] + "..."
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def check_corrections(
|
|
190
|
+
*,
|
|
191
|
+
ledger: Mapping[str, Any],
|
|
192
|
+
base_narrative: str,
|
|
193
|
+
schema: Mapping[str, Any],
|
|
194
|
+
block_rules: Sequence[str] = (),
|
|
195
|
+
semantic_validator: Callable[[dict[str, Any]], Sequence[str]] | None = None,
|
|
196
|
+
) -> CorrectionsCheck:
|
|
197
|
+
"""원장을 기준 서사에 대조한다. 설계 §4.2 의 1~6단계.
|
|
198
|
+
|
|
199
|
+
1. 원장 스키마, id·경로 중복, 경로 문법.
|
|
200
|
+
2. 기준 서사 파싱 — 구조만 읽는다(`parse_narrative_structure`). 줄 문법이나
|
|
201
|
+
소유권 결함이면 그 결함만 내고 멈춘다(적용할 자료가 없다). 기준 서사의
|
|
202
|
+
값 결함은 원장이 고칠 자리이므로 여기서는 결함이 아니고, 5단계에서
|
|
203
|
+
적용 뒤에도 남아 있는 것만 보고한다.
|
|
204
|
+
3. 경로 해소와 `current` 일치. `rewrite` 는 배열의 `[len]` 추가 위치를 허용한다.
|
|
205
|
+
4. 작성자 소유 경로인가.
|
|
206
|
+
5. replace·remove 를 적용한 사본을 작성자 소유 값 스키마와 task 의미 검증기로
|
|
207
|
+
대조한다. rewrite 경로 아래의 스키마 결함은 뺀다(작성자가 다시 쓸 자리다).
|
|
208
|
+
6. rewrite 마다 그 경로의 스키마 제약 문장을 붙인다.
|
|
209
|
+
"""
|
|
210
|
+
ledger_dict = dict(ledger)
|
|
211
|
+
defects: list[str] = [
|
|
212
|
+
_defect("ledger", error.split(": ", 1)[0], error)
|
|
213
|
+
for error in validate(ledger_dict, corrections_schema())
|
|
214
|
+
]
|
|
215
|
+
raw_corrections = ledger_dict.get("corrections")
|
|
216
|
+
corrections = tuple(
|
|
217
|
+
dict(item) for item in (raw_corrections if isinstance(raw_corrections, list) else [])
|
|
218
|
+
if isinstance(item, Mapping)
|
|
219
|
+
)
|
|
220
|
+
ids = [str(item.get("id")) for item in corrections]
|
|
221
|
+
for duplicate in sorted({value for value in ids if ids.count(value) > 1}):
|
|
222
|
+
defects.append(_defect(duplicate, "-", "correction id is not unique"))
|
|
223
|
+
paths = [str(item.get("path")) for item in corrections]
|
|
224
|
+
for duplicate in sorted({value for value in paths if paths.count(value) > 1}):
|
|
225
|
+
defects.append(_defect("-", duplicate, "two corrections name the same path"))
|
|
226
|
+
parsed: dict[str, tuple[str | int, ...]] = {}
|
|
227
|
+
for item in corrections:
|
|
228
|
+
correction_id, path = str(item.get("id")), str(item.get("path"))
|
|
229
|
+
try:
|
|
230
|
+
parsed[correction_id] = parse_field_path(path)
|
|
231
|
+
except ValueError as exc:
|
|
232
|
+
defects.append(_defect(correction_id, path, str(exc)))
|
|
233
|
+
if defects and not parsed:
|
|
234
|
+
return CorrectionsCheck(ledger_dict, corrections, None, False, {}, tuple(defects))
|
|
235
|
+
|
|
236
|
+
try:
|
|
237
|
+
data, _base_defects = parse_narrative_structure(base_narrative, schema)
|
|
238
|
+
except NarrativeContractError as exc:
|
|
239
|
+
defects.append(_defect(
|
|
240
|
+
"ledger", str(ledger_dict.get("baseNarrativePath") or "-"),
|
|
241
|
+
f"base narrative does not parse: {exc}",
|
|
242
|
+
))
|
|
243
|
+
return CorrectionsCheck(ledger_dict, corrections, None, False, {}, tuple(defects))
|
|
244
|
+
|
|
245
|
+
scratch = deepcopy(data)
|
|
246
|
+
rewrite_paths: list[str] = []
|
|
247
|
+
constraints: dict[str, tuple[str, ...]] = {}
|
|
248
|
+
for item in corrections:
|
|
249
|
+
correction_id, path, kind = str(item.get("id")), str(item.get("path")), str(item.get("kind"))
|
|
250
|
+
tokens = parsed.get(correction_id)
|
|
251
|
+
if tokens is None:
|
|
252
|
+
continue
|
|
253
|
+
owned_defect = writer_owned_path_defect(path)
|
|
254
|
+
if owned_defect:
|
|
255
|
+
defects.append(_defect(correction_id, path, owned_defect))
|
|
256
|
+
continue
|
|
257
|
+
found, value = _resolve(data, tokens)
|
|
258
|
+
if not found:
|
|
259
|
+
parent_found, parent = _parent(data, tokens)
|
|
260
|
+
appendable = (
|
|
261
|
+
kind == "rewrite"
|
|
262
|
+
and isinstance(tokens[-1], int)
|
|
263
|
+
and parent_found
|
|
264
|
+
and isinstance(parent, list)
|
|
265
|
+
and tokens[-1] == len(parent)
|
|
266
|
+
)
|
|
267
|
+
if not appendable:
|
|
268
|
+
defects.append(_defect(
|
|
269
|
+
correction_id, path,
|
|
270
|
+
"path does not resolve in the base narrative"
|
|
271
|
+
+ (" (a rewrite may append at index len(array))" if kind == "rewrite" else ""),
|
|
272
|
+
))
|
|
273
|
+
continue
|
|
274
|
+
elif "current" in item and not _current_matches(item["current"], value):
|
|
275
|
+
defects.append(_defect(
|
|
276
|
+
correction_id, path,
|
|
277
|
+
f"current value is {_short(value)}, not {_short(item['current'])}",
|
|
278
|
+
))
|
|
279
|
+
continue
|
|
280
|
+
if kind == "rewrite":
|
|
281
|
+
rewrite_paths.append(path)
|
|
282
|
+
constraints[correction_id] = _constraints_for(path, block_rules)
|
|
283
|
+
|
|
284
|
+
replacements = [c for c in corrections if c.get("kind") == "replace" and str(c.get("id")) in parsed]
|
|
285
|
+
removals = [c for c in corrections if c.get("kind") == "remove" and str(c.get("id")) in parsed]
|
|
286
|
+
defective_ids = {
|
|
287
|
+
line.split("correction=", 1)[1].split(" ", 1)[0] for line in defects if "correction=" in line
|
|
288
|
+
}
|
|
289
|
+
for item in replacements:
|
|
290
|
+
correction_id = str(item.get("id"))
|
|
291
|
+
if correction_id in defective_ids:
|
|
292
|
+
continue
|
|
293
|
+
_set(scratch, parsed[correction_id], deepcopy(item.get("replacement")))
|
|
294
|
+
for item in sorted(
|
|
295
|
+
removals,
|
|
296
|
+
key=lambda c: tuple(_removal_order(t) for t in parsed[str(c.get("id"))]),
|
|
297
|
+
reverse=True,
|
|
298
|
+
):
|
|
299
|
+
correction_id = str(item.get("id"))
|
|
300
|
+
if correction_id in defective_ids:
|
|
301
|
+
continue
|
|
302
|
+
_remove(scratch, parsed[correction_id])
|
|
303
|
+
|
|
304
|
+
# 원장의 일부가 결함이어도 나머지를 적용한 사본은 검증한다 — 리드가 한
|
|
305
|
+
# 회차에 전건을 보게 하려는 것이 이 모듈의 이유다. 결함인 교정은 적용하지
|
|
306
|
+
# 않았으므로 그 자리는 기준 서사의 값 그대로다.
|
|
307
|
+
if scratch is not None:
|
|
308
|
+
for error in validate_writer_owned(scratch, schema):
|
|
309
|
+
location = error.split(": ", 1)[0]
|
|
310
|
+
if any(location == rp or location.startswith(f"{rp}.") or location.startswith(f"{rp}[")
|
|
311
|
+
for rp in rewrite_paths):
|
|
312
|
+
continue
|
|
313
|
+
defects.append(_defect("applied", location, error))
|
|
314
|
+
if semantic_validator is not None:
|
|
315
|
+
for error in semantic_validator(scratch):
|
|
316
|
+
defects.append(_defect("applied", "-", str(error)))
|
|
317
|
+
|
|
318
|
+
mechanical = not defects and not rewrite_paths
|
|
319
|
+
return CorrectionsCheck(
|
|
320
|
+
ledger_dict, corrections, scratch, mechanical, constraints, tuple(defects)
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _constraints_for(path: str, block_rules: Sequence[str]) -> tuple[str, ...]:
|
|
325
|
+
"""이 경로(또는 가장 가까운 상위 객체)의 `task_block_rules` 줄."""
|
|
326
|
+
normalised = normalise_field_path(path)
|
|
327
|
+
candidates = [normalised]
|
|
328
|
+
while "." in candidates[-1]:
|
|
329
|
+
candidates.append(candidates[-1].rsplit(".", 1)[0])
|
|
330
|
+
for candidate in candidates:
|
|
331
|
+
lines = tuple(
|
|
332
|
+
rule for rule in block_rules
|
|
333
|
+
if rule.startswith(f"`{candidate}`: ")
|
|
334
|
+
)
|
|
335
|
+
if lines:
|
|
336
|
+
return lines
|
|
337
|
+
return ()
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def render_applied_narrative(
|
|
341
|
+
check: CorrectionsCheck, schema: Mapping[str, Any],
|
|
342
|
+
) -> str:
|
|
343
|
+
"""기계적 교정(replace·remove)만 있는 원장을 적용한 서사 본문.
|
|
344
|
+
|
|
345
|
+
작성자 라운드 없이 okstra 가 `reportNarrativePath` 에 쓰는 문서다. 결함이
|
|
346
|
+
있거나 `rewrite` 가 섞인 대조 결과는 받지 않는다 — 그 원장은 작성자가 다시
|
|
347
|
+
써야 할 자리가 있다.
|
|
348
|
+
"""
|
|
349
|
+
if not check.mechanical or check.scratch is None:
|
|
350
|
+
pending = [
|
|
351
|
+
str(item.get("id")) for item in check.corrections
|
|
352
|
+
if item.get("kind") == "rewrite"
|
|
353
|
+
]
|
|
354
|
+
raise ReportCorrectionsError(
|
|
355
|
+
list(check.defects)
|
|
356
|
+
or [_defect(
|
|
357
|
+
", ".join(pending) or "-", "-",
|
|
358
|
+
"rewrite entries need a writer round; only a ledger of replace "
|
|
359
|
+
"and remove entries can be applied mechanically",
|
|
360
|
+
)]
|
|
361
|
+
)
|
|
362
|
+
return render_narrative(check.scratch, schema)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def body_owned_section_conflicts(body: str) -> list[str]:
|
|
366
|
+
"""지시문 본문이 okstra 소유 절의 제목을 쓰면 그 제목들."""
|
|
367
|
+
present = {line.strip() for line in body.splitlines()}
|
|
368
|
+
return [section for section in OKSTRA_OWNED_SECTIONS if section in present]
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _scalar_text(value: Any) -> str:
|
|
372
|
+
if isinstance(value, str):
|
|
373
|
+
if "\n" in value:
|
|
374
|
+
lines = value.split("\n")
|
|
375
|
+
return "the following lines, one `> ` line each: " + " / ".join(f"`{line}`" for line in lines)
|
|
376
|
+
return f"`{value}`"
|
|
377
|
+
if value is None:
|
|
378
|
+
return "`_none_`"
|
|
379
|
+
if isinstance(value, bool):
|
|
380
|
+
return f"`{str(value).lower()}`"
|
|
381
|
+
if isinstance(value, (int, float)):
|
|
382
|
+
return f"`{value}`"
|
|
383
|
+
return "this JSON value rendered in narrative form: `" + json.dumps(value, ensure_ascii=False) + "`"
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def render_corrections_section(
|
|
387
|
+
check: CorrectionsCheck, *, base_narrative_rel: str, corrections_rel: str,
|
|
388
|
+
) -> list[str]:
|
|
389
|
+
"""프롬프트의 `## Corrections` 절. 검증을 통과한 원장만 렌더한다."""
|
|
390
|
+
lines = [
|
|
391
|
+
"## Corrections",
|
|
392
|
+
"",
|
|
393
|
+
f"Your previous attempt is preserved at `{base_narrative_rel}`. Read that copy "
|
|
394
|
+
"and reproduce it verbatim, then apply only the corrections below. Do not read "
|
|
395
|
+
"your own `**Result Path:**` for this purpose: it is the same document and you "
|
|
396
|
+
f"are about to overwrite it. Corrections ledger: `{corrections_rel}`.",
|
|
397
|
+
"",
|
|
398
|
+
]
|
|
399
|
+
for item in check.corrections:
|
|
400
|
+
correction_id = str(item.get("id"))
|
|
401
|
+
path = str(item.get("path"))
|
|
402
|
+
kind = str(item.get("kind"))
|
|
403
|
+
try:
|
|
404
|
+
label = humanise_field_path(parse_field_path(path))
|
|
405
|
+
except ValueError:
|
|
406
|
+
label = f"`{path}`"
|
|
407
|
+
head = f"- {correction_id} `{kind}` — {label} (`{path}`): "
|
|
408
|
+
if kind == "replace":
|
|
409
|
+
current = (
|
|
410
|
+
f"current {_scalar_text(item['current'])}, " if "current" in item else ""
|
|
411
|
+
)
|
|
412
|
+
action = f"{current}write exactly {_scalar_text(item.get('replacement'))}."
|
|
413
|
+
elif kind == "remove":
|
|
414
|
+
action = "remove this item or field entirely; renumber the remaining `- Item N` rows 1..N."
|
|
415
|
+
else:
|
|
416
|
+
action = f"rewrite this part so that: {item.get('rule')}"
|
|
417
|
+
constraint_lines = check.constraints.get(correction_id, ())
|
|
418
|
+
if constraint_lines:
|
|
419
|
+
action += " Schema constraint: " + " ".join(constraint_lines)
|
|
420
|
+
lines.append(f"{head}{action} Reason: {item.get('reason')}")
|
|
421
|
+
context = check.ledger.get("context")
|
|
422
|
+
if isinstance(context, str) and context.strip():
|
|
423
|
+
lines.extend(["", "### Context", "", context.strip()])
|
|
424
|
+
lines.extend([
|
|
425
|
+
"",
|
|
426
|
+
"Change nothing else: not a sentence, not a score, not an evidence path, not a "
|
|
427
|
+
"verdict, not the `Human Summary`, and not the blockquote indentation where each "
|
|
428
|
+
"value line sits two spaces deeper than its own `- **Label**` line.",
|
|
429
|
+
])
|
|
430
|
+
return lines
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def render_output_section() -> list[str]:
|
|
434
|
+
"""프롬프트의 `## Output` 절 — 세 산출물을 okstra 가 열거한다."""
|
|
435
|
+
return [
|
|
436
|
+
"## Output",
|
|
437
|
+
"",
|
|
438
|
+
"Write all three artifacts before returning.",
|
|
439
|
+
"",
|
|
440
|
+
"1. The report narrative Markdown at `**Result Path:**`.",
|
|
441
|
+
"2. The pointer record at `**Worker Result Path:**`.",
|
|
442
|
+
"3. The reading audit at `**Audit sidecar path:**`.",
|
|
443
|
+
]
|