okstra 0.214.1 → 0.216.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/README.md +2 -1
- package/dist/cli-registry.d.mts +14 -0
- package/dist/cli-registry.mjs +12 -0
- package/dist/cli-registry.mjs.map +1 -1
- package/dist/commands/chat/chat.mjs +2 -56
- package/dist/commands/chat/chat.mjs.map +1 -1
- package/dist/commands/execute/render-bundle.mjs +5 -2
- package/dist/commands/execute/render-bundle.mjs.map +1 -1
- package/dist/commands/execute/wizard.mjs +6 -0
- package/dist/commands/execute/wizard.mjs.map +1 -1
- package/dist/commands/memory/memory.mjs +57 -6
- package/dist/commands/memory/memory.mjs.map +1 -1
- package/dist/lib/file-lock.d.mts +1 -0
- package/dist/lib/file-lock.mjs +60 -0
- package/dist/lib/file-lock.mjs.map +1 -0
- package/dist/lib/python-command.mjs +11 -1
- package/dist/lib/python-command.mjs.map +1 -1
- package/dist/lib/skill-catalog.mjs +1 -0
- package/dist/lib/skill-catalog.mjs.map +1 -1
- package/dist/lib/types.d.mts +1 -1
- package/docs/architecture.md +2 -2
- package/docs/cli.md +36 -14
- package/docs/project-structure-overview.md +6 -3
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/bin/lib/okstra/cli.sh +9 -2
- package/runtime/bin/lib/okstra/globals.sh +2 -0
- package/runtime/bin/lib/okstra/interactive.sh +8 -2
- package/runtime/bin/lib/okstra/usage.sh +3 -1
- package/runtime/bin/okstra.sh +7 -1
- package/runtime/prompts/duties/discussion-worker.json +32 -0
- package/runtime/prompts/lead/convergence.md +58 -136
- package/runtime/prompts/lead/okstra-lead-contract.md +45 -12
- package/runtime/prompts/profiles/_common-contract.md +1 -1
- package/runtime/prompts/wizard/prompts.ko.json +14 -1
- package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +4 -0
- package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +4 -11
- package/runtime/python/okstra_ctl/agent/invocation.py +1 -0
- package/runtime/python/okstra_ctl/agent/prompt_cli/dynamic_verifier.py +2 -2
- package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +4 -4
- package/runtime/python/okstra_ctl/agent/prompt_cli/run_identity.py +2 -2
- package/runtime/python/okstra_ctl/analysis_packet.py +3 -0
- package/runtime/python/okstra_ctl/consumers.py +8 -2
- package/runtime/python/okstra_ctl/convergence.py +143 -29
- package/runtime/python/okstra_ctl/convergence_critic_prompt.py +57 -0
- package/runtime/python/okstra_ctl/convergence_critic_verify_prompt.py +106 -0
- package/runtime/python/okstra_ctl/convergence_engine.py +140 -95
- package/runtime/python/okstra_ctl/convergence_reverify_prompt.py +3 -27
- package/runtime/python/okstra_ctl/convergence_store.py +11 -4
- package/runtime/python/okstra_ctl/dispatch_core.py +4 -1
- package/runtime/python/okstra_ctl/domain/role.py +1 -0
- package/runtime/python/okstra_ctl/manager_cli.py +27 -1
- package/runtime/python/okstra_ctl/manager_launch.py +44 -4
- package/runtime/python/okstra_ctl/manager_sync.py +14 -1
- package/runtime/python/okstra_ctl/manager_view.py +53 -1
- package/runtime/python/okstra_ctl/next_phase.py +1 -0
- package/runtime/python/okstra_ctl/phases/catalog.py +2 -1
- package/runtime/python/okstra_ctl/phases/discussion/__init__.py +1 -0
- package/runtime/python/okstra_ctl/phases/discussion/boundary.json +11 -0
- package/runtime/python/okstra_ctl/phases/discussion/cli.py +512 -0
- package/runtime/python/okstra_ctl/phases/discussion/entry.py +116 -0
- package/runtime/python/okstra_ctl/phases/discussion/followup.py +876 -0
- package/runtime/python/okstra_ctl/phases/discussion/memory.py +294 -0
- package/runtime/python/okstra_ctl/phases/discussion/overview.py +85 -0
- package/runtime/python/okstra_ctl/phases/discussion/profile.json +31 -0
- package/runtime/python/okstra_ctl/phases/discussion/profile.md +48 -0
- package/runtime/python/okstra_ctl/phases/discussion/report.py +73 -0
- package/runtime/python/okstra_ctl/phases/discussion/report_assets/discussion.template.html +15 -0
- package/runtime/python/okstra_ctl/phases/discussion/report_assets/discussion.template.md +6 -0
- package/runtime/python/okstra_ctl/phases/discussion/spec.md +197 -0
- package/runtime/python/okstra_ctl/phases/discussion/store.py +1143 -0
- package/runtime/python/okstra_ctl/phases/discussion/validation.py +111 -0
- package/runtime/python/okstra_ctl/phases/discussion/wizard.py +90 -0
- package/runtime/python/okstra_ctl/phases/discussion/workflow.py +667 -0
- package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-deliverable.md +1 -1
- package/runtime/python/okstra_ctl/phases/implementation_planning/host-rules.md +4 -1
- package/runtime/python/okstra_ctl/phases/implementation_planning/instructions/plan-body-verification.md +1 -1
- package/runtime/python/okstra_ctl/phases/requirements_discovery/spec.md +1 -1
- package/runtime/python/okstra_ctl/plan_run_root.py +8 -0
- package/runtime/python/okstra_ctl/render.py +7 -20
- package/runtime/python/okstra_ctl/render_final_report.py +2 -1
- package/runtime/python/okstra_ctl/report_assembly.py +22 -0
- package/runtime/python/okstra_ctl/report_contract.py +2 -0
- package/runtime/python/okstra_ctl/report_narrative.py +13 -4
- package/runtime/python/okstra_ctl/report_synthesis_packet.py +10 -0
- package/runtime/python/okstra_ctl/run.py +40 -7
- package/runtime/python/okstra_ctl/stage_close.py +46 -11
- package/runtime/python/okstra_ctl/stage_reopen.py +151 -0
- package/runtime/python/okstra_ctl/verdict_blocks.py +11 -35
- package/runtime/python/okstra_ctl/wizard/cli.py +8 -0
- package/runtime/python/okstra_ctl/wizard/confirmation.py +4 -1
- package/runtime/python/okstra_ctl/wizard/engine.py +8 -0
- package/runtime/python/okstra_ctl/wizard/ids.py +12 -0
- package/runtime/python/okstra_ctl/wizard/registry.py +35 -3
- package/runtime/python/okstra_ctl/wizard/render.py +4 -1
- package/runtime/python/okstra_ctl/wizard/sources.py +2 -2
- package/runtime/python/okstra_ctl/wizard/state.py +11 -1
- package/runtime/python/okstra_ctl/wizard/steps_identity.py +4 -4
- package/runtime/python/okstra_ctl/worker_prompt_policy.py +24 -6
- package/runtime/python/okstra_token_usage/pricing.py +2 -0
- package/runtime/schemas/convergence-groups-v1.0.schema.json +1 -1
- package/runtime/schemas/convergence-groups-v2.0.schema.json +1 -1
- package/runtime/schemas/final-report-v3.0.schema.json +90 -1
- package/runtime/skills/_fragments/wizard-bash-invocation-rule.md +1 -0
- package/runtime/skills/_fragments/wizard-confirm-step.md +7 -0
- package/runtime/skills/_fragments/wizard-lead-takeover.md +8 -0
- package/runtime/skills/_fragments/wizard-preflight.md +75 -0
- package/runtime/skills/_fragments/wizard-prompt-loop.md +22 -0
- package/runtime/skills/_fragments/wizard-relay-protocol.md +40 -0
- package/runtime/skills/_fragments/wizard-render-bundle.md +74 -0
- package/runtime/skills/okstra-discussion/SKILL.md +347 -0
- package/runtime/skills/okstra-pr-gen/SKILL.md +7 -0
- package/runtime/skills/okstra-run/SKILL.md +22 -2
- package/runtime/templates/manager/view.template.html +3 -0
- package/runtime/templates/reports/html/i18n/en.json +5 -0
- package/runtime/templates/reports/html/i18n/ko.json +5 -0
- package/runtime/validators/validate-brief.py +3 -2
- package/runtime/validators/validate-run.py +4 -6
|
@@ -0,0 +1,1143 @@
|
|
|
1
|
+
"""task 단위 토론 기록 — append-only 입력 기록과 초안 읽기 모델.
|
|
2
|
+
|
|
3
|
+
`<task-root>/discussion/` 아래:
|
|
4
|
+
|
|
5
|
+
- `records.jsonl` — 입력 기록(시작 입력·확인된 대상과 질문·질문·답변·결정, 조사 주기·
|
|
6
|
+
워커 결과·실패·참여자 변경·주장·주기 종료·새 근거와 대안). 디렉터리 잠금 아래 다른
|
|
7
|
+
무엇보다 먼저 append 한다.
|
|
8
|
+
- `draft.json` — 기록에서 다시 만든 읽기 모델. 만든 기록의 digest 를 실어, 기록보다
|
|
9
|
+
뒤처지거나 없으면 다음 읽기에서 다시 만든다.
|
|
10
|
+
- `revisions/revision-NNN.json` — 사용자 완료 확인마다 한 번 쓰는 불변 확정본. 토론 결과의
|
|
11
|
+
정본이고, run 보고서는 확정본 id 와 내용 digest 만 참조한다.
|
|
12
|
+
- `links.jsonl` — 후속 작업 연결과 확정본 적용 기록(`followup.py`). 후속 작업이 쓰는 확정본의
|
|
13
|
+
정본은 그 작업의 브리프이고, 이 목록은 대상 위치를 찾는 조회용이다.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
from contextlib import contextmanager
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from datetime import datetime, timezone
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, Iterator, Optional
|
|
25
|
+
|
|
26
|
+
from okstra_project.slug import slugify
|
|
27
|
+
|
|
28
|
+
from okstra_ctl.json_boundary import (
|
|
29
|
+
JsonBoundaryError,
|
|
30
|
+
load_owned_object,
|
|
31
|
+
write_owned_object_atomic,
|
|
32
|
+
)
|
|
33
|
+
from okstra_ctl.paths import RunRef, task_dir
|
|
34
|
+
from okstra_ctl.run_context import dir_flock
|
|
35
|
+
|
|
36
|
+
SCHEMA_VERSION = "1.0"
|
|
37
|
+
# 읽기 모델의 모양이 바뀌면 올린다 — 기록 digest 가 같아도 옛 모양의 draft 는 다시 만든다.
|
|
38
|
+
DRAFT_SCHEMA_VERSION = "1.3"
|
|
39
|
+
REVISION_SCHEMA_VERSION = "1.1"
|
|
40
|
+
STORE_DIRNAME = "discussion"
|
|
41
|
+
RECORDS_FILENAME = "records.jsonl"
|
|
42
|
+
DRAFT_JSON_FILENAME = "draft.json"
|
|
43
|
+
INPUT_FILENAME = "input.md"
|
|
44
|
+
REVISIONS_DIRNAME = "revisions"
|
|
45
|
+
LINKS_FILENAME = "links.jsonl"
|
|
46
|
+
_LOCK_FILENAME = ".discussion.lock"
|
|
47
|
+
|
|
48
|
+
USER = "user"
|
|
49
|
+
LEADER = "leader"
|
|
50
|
+
_ID_PREFIX = {
|
|
51
|
+
"start": "I",
|
|
52
|
+
"subject": "S",
|
|
53
|
+
"question": "Q",
|
|
54
|
+
"answer": "A",
|
|
55
|
+
"decision": "D",
|
|
56
|
+
"cycle": "R",
|
|
57
|
+
"result": "W",
|
|
58
|
+
"failure": "X",
|
|
59
|
+
"recovery": "P",
|
|
60
|
+
"claim": "K",
|
|
61
|
+
"close": "E",
|
|
62
|
+
"material": "M",
|
|
63
|
+
"requirement": "N",
|
|
64
|
+
"scope": "B",
|
|
65
|
+
"confirmation": "F",
|
|
66
|
+
"publication": "V",
|
|
67
|
+
"reopen": "O",
|
|
68
|
+
"withdrawal": "U",
|
|
69
|
+
}
|
|
70
|
+
# 중복 판정 키에 넣는 필드. 출처 run 은 빼서 재개한 run 의 재전송도 같은 기록으로 본다.
|
|
71
|
+
# 조사 주기만 run 을 넣는다 — 교체 run 이 같은 입력으로 다시 연 주기는 새 주기다.
|
|
72
|
+
_IDENTITY_FIELDS = {
|
|
73
|
+
"start": ("startKind", "text"),
|
|
74
|
+
"subject": ("subject", "question"),
|
|
75
|
+
"question": ("to", "text"),
|
|
76
|
+
"answer": ("questionId", "text"),
|
|
77
|
+
"decision": ("questionId", "text"),
|
|
78
|
+
"cycle": ("inputRefs", "sourceRun"),
|
|
79
|
+
"result": ("cycleId", "dispatch"),
|
|
80
|
+
"failure": ("cycleId", "questionId", "dispatch"),
|
|
81
|
+
"recovery": ("failureId", "choice"),
|
|
82
|
+
"claim": ("cycleId", "findingId"),
|
|
83
|
+
"close": ("cycleId",),
|
|
84
|
+
"material": ("materialKind", "text"),
|
|
85
|
+
"requirement": ("appliesTo", "text", "draftGeneration"),
|
|
86
|
+
"scope": ("scopeKind", "text", "draftGeneration"),
|
|
87
|
+
"confirmation": ("draftVersion", "direction", "riskAcknowledgement"),
|
|
88
|
+
"publication": ("confirmationId",),
|
|
89
|
+
"reopen": ("revisionId", "reason"),
|
|
90
|
+
"withdrawal": ("itemId", "reason", "draftGeneration"),
|
|
91
|
+
}
|
|
92
|
+
# 비어 있어도 되는 문자열 필드 — 질문에 묶이지 않은 결정, 주기 밖 직접 질문의 실패.
|
|
93
|
+
_OPTIONAL_FIELDS = frozenset({"questionId", "cycleId"})
|
|
94
|
+
# 다음 조사 입력의 참조 집합이 되는 기록 — 시작 입력·대상·답변·새 근거와 대안. 사용자 결정은
|
|
95
|
+
# 방향 선택이라 넣지 않는다: 넣으면 방향을 고른 직후 같은 근거로 조사가 다시 열린다.
|
|
96
|
+
# 주기의 산출물(결과·주장)은 넣지 않는다: 그것을 넣으면 같은 주장의 반복도 늘 새 입력이 된다.
|
|
97
|
+
INPUT_REF_KINDS = ("start", "subject", "answer", "material")
|
|
98
|
+
SCOPE_KINDS = ("in-scope", "excluded")
|
|
99
|
+
ITEM_KINDS = ("requirement", "scope")
|
|
100
|
+
COMMON = "common"
|
|
101
|
+
# 확정본 발행 뒤에 받는 기록. 나머지는 재토론(`reopen`)이 새 초안을 연 뒤에만 받는다.
|
|
102
|
+
_AFTER_COMPLETION_KINDS = frozenset({"reopen", "publication"})
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class DiscussionStoreError(Exception):
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True)
|
|
110
|
+
class SourceRun:
|
|
111
|
+
manifest: str
|
|
112
|
+
participants: frozenset[str]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass(frozen=True)
|
|
116
|
+
class DiscussionStore:
|
|
117
|
+
project_root: Path
|
|
118
|
+
task_key: str
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def task_root(self) -> Path:
|
|
122
|
+
_, task_group, task_id = _split_task_key(self.task_key)
|
|
123
|
+
return task_dir(self.project_root, task_group, task_id)
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def dir(self) -> Path:
|
|
127
|
+
return self.task_root / STORE_DIRNAME
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def records_path(self) -> Path:
|
|
131
|
+
return self.dir / RECORDS_FILENAME
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def draft_json_path(self) -> Path:
|
|
135
|
+
return self.dir / DRAFT_JSON_FILENAME
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def input_path(self) -> Path:
|
|
139
|
+
return self.dir / INPUT_FILENAME
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def revisions_dir(self) -> Path:
|
|
143
|
+
return self.dir / REVISIONS_DIRNAME
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def links_path(self) -> Path:
|
|
147
|
+
return self.dir / LINKS_FILENAME
|
|
148
|
+
|
|
149
|
+
def revision_path(self, sequence: int) -> Path:
|
|
150
|
+
return self.revisions_dir / f"revision-{sequence:03d}.json"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _split_task_key(task_key: str) -> tuple[str, str, str]:
|
|
154
|
+
parts = (task_key or "").split(":")
|
|
155
|
+
if len(parts) != 3 or not all(part.strip() for part in parts):
|
|
156
|
+
raise DiscussionStoreError(
|
|
157
|
+
f"invalid task key {task_key!r} (expected project-id:task-group:task-id)"
|
|
158
|
+
)
|
|
159
|
+
return parts[0], parts[1], parts[2]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def same_task(left: str, right: str) -> bool:
|
|
163
|
+
"""task-group·task-id 는 경로 세그먼트(slug)로 비교한다 — task 루트가 같으면 같은 task 다."""
|
|
164
|
+
try:
|
|
165
|
+
a = _split_task_key(left)
|
|
166
|
+
b = _split_task_key(right)
|
|
167
|
+
except DiscussionStoreError:
|
|
168
|
+
return False
|
|
169
|
+
return a[0] == b[0] and slugify(a[1]) == slugify(b[1]) and slugify(a[2]) == slugify(b[2])
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _now_iso() -> str:
|
|
173
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _project_relative(store: DiscussionStore, path: Path) -> str:
|
|
177
|
+
return path.resolve().relative_to(store.project_root.resolve()).as_posix()
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def resolve_source_run(store: DiscussionStore, manifest_path: str) -> SourceRun:
|
|
181
|
+
if not manifest_path.strip():
|
|
182
|
+
raise DiscussionStoreError("--run-manifest is required: name the discussion run manifest")
|
|
183
|
+
path = Path(manifest_path)
|
|
184
|
+
if not path.is_absolute():
|
|
185
|
+
path = store.project_root / path
|
|
186
|
+
manifests_dir = RunRef.from_task_root(store.task_root, "discussion").run_dir / "manifests"
|
|
187
|
+
try:
|
|
188
|
+
path.resolve().relative_to(manifests_dir.resolve())
|
|
189
|
+
except ValueError as exc:
|
|
190
|
+
raise DiscussionStoreError(
|
|
191
|
+
f"run manifest {manifest_path} is not a discussion run of {store.task_key}; "
|
|
192
|
+
f"records of another task are refused (expected under {manifests_dir})"
|
|
193
|
+
) from exc
|
|
194
|
+
try:
|
|
195
|
+
manifest = load_owned_object(path, artifact="discussion run manifest")
|
|
196
|
+
except JsonBoundaryError as exc:
|
|
197
|
+
raise DiscussionStoreError(str(exc)) from exc
|
|
198
|
+
if not same_task(str(manifest.get("taskKey", "")), store.task_key):
|
|
199
|
+
raise DiscussionStoreError(
|
|
200
|
+
f"run manifest {manifest_path} belongs to {manifest.get('taskKey')!r}, "
|
|
201
|
+
f"not {store.task_key}; records of another task are refused"
|
|
202
|
+
)
|
|
203
|
+
if manifest.get("taskType") != "discussion":
|
|
204
|
+
raise DiscussionStoreError(
|
|
205
|
+
f"run manifest {manifest_path} is a {manifest.get('taskType')!r} run, not a discussion run"
|
|
206
|
+
)
|
|
207
|
+
workers = {
|
|
208
|
+
str(row["workerId"])
|
|
209
|
+
for row in manifest.get("workerAssignments") or []
|
|
210
|
+
if isinstance(row, dict) and row.get("workerId")
|
|
211
|
+
}
|
|
212
|
+
return SourceRun(
|
|
213
|
+
manifest=_project_relative(store, path),
|
|
214
|
+
participants=frozenset({LEADER, *workers}),
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _complete_lines(raw: bytes) -> bytes:
|
|
219
|
+
"""끝 개행이 없는 마지막 조각은 중단된 append 다 — 확정된 기록이 아니다."""
|
|
220
|
+
end = raw.rfind(b"\n")
|
|
221
|
+
return raw[: end + 1] if end >= 0 else b""
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _read_records(store: DiscussionStore) -> tuple[list[dict[str, Any]], str]:
|
|
225
|
+
path = store.records_path
|
|
226
|
+
raw = path.read_bytes() if path.is_file() else b""
|
|
227
|
+
complete = _complete_lines(raw)
|
|
228
|
+
rows: list[dict[str, Any]] = []
|
|
229
|
+
for number, line in enumerate(complete.decode("utf-8").splitlines(), start=1):
|
|
230
|
+
if not line.strip():
|
|
231
|
+
continue
|
|
232
|
+
try:
|
|
233
|
+
row = json.loads(line)
|
|
234
|
+
except json.JSONDecodeError as exc:
|
|
235
|
+
raise DiscussionStoreError(
|
|
236
|
+
f"{path}:{number} is not a JSON record ({exc.msg}); the discussion record is damaged"
|
|
237
|
+
) from exc
|
|
238
|
+
if not isinstance(row, dict) or row.get("kind") not in _ID_PREFIX:
|
|
239
|
+
raise DiscussionStoreError(f"{path}:{number} is not a discussion record")
|
|
240
|
+
if not same_task(str(row.get("taskKey", "")), store.task_key):
|
|
241
|
+
raise DiscussionStoreError(
|
|
242
|
+
f"{path}:{number} belongs to {row.get('taskKey')!r}, not {store.task_key}"
|
|
243
|
+
)
|
|
244
|
+
rows.append(row)
|
|
245
|
+
return rows, "sha256:" + hashlib.sha256(complete).hexdigest()
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _drop_interrupted_tail(store: DiscussionStore) -> None:
|
|
249
|
+
drop_incomplete_line(store.records_path)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def drop_incomplete_line(path: Path) -> None:
|
|
253
|
+
if not path.is_file():
|
|
254
|
+
return
|
|
255
|
+
raw = path.read_bytes()
|
|
256
|
+
complete = _complete_lines(raw)
|
|
257
|
+
if len(complete) != len(raw):
|
|
258
|
+
with path.open("r+b") as handle:
|
|
259
|
+
handle.truncate(len(complete))
|
|
260
|
+
handle.flush()
|
|
261
|
+
os.fsync(handle.fileno())
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _identity_key(kind: str, author: str, fields: dict[str, Any]) -> str:
|
|
265
|
+
identity = {"kind": kind, "author": author}
|
|
266
|
+
identity.update({name: fields.get(name, "") for name in _IDENTITY_FIELDS[kind]})
|
|
267
|
+
encoded = json.dumps(identity, ensure_ascii=False, sort_keys=True).encode("utf-8")
|
|
268
|
+
return "sha256:" + hashlib.sha256(encoded).hexdigest()
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _check_record(
|
|
272
|
+
kind: str, author: str, fields: dict[str, Any], rows: list[dict[str, Any]],
|
|
273
|
+
source_run: SourceRun,
|
|
274
|
+
) -> None:
|
|
275
|
+
by_id = {row["recordId"]: row for row in rows}
|
|
276
|
+
completed = completed_publication(rows)
|
|
277
|
+
if completed is not None and kind not in _AFTER_COMPLETION_KINDS:
|
|
278
|
+
raise DiscussionStoreError(
|
|
279
|
+
f"the discussion is completed as {completed['revisionId']}; open a new draft with "
|
|
280
|
+
f"`okstra discussion reopen --revision {completed['revisionId']} --reason \"<why it changes>\"` "
|
|
281
|
+
"before recording more"
|
|
282
|
+
)
|
|
283
|
+
if kind == "reopen":
|
|
284
|
+
if completed is None or fields["revisionId"] != completed["revisionId"]:
|
|
285
|
+
current = completed["revisionId"] if completed else "none (the discussion is a draft)"
|
|
286
|
+
raise DiscussionStoreError(
|
|
287
|
+
f"reopen names {fields['revisionId']}, but the latest completed revision is {current}"
|
|
288
|
+
)
|
|
289
|
+
return
|
|
290
|
+
if kind == "withdrawal":
|
|
291
|
+
items = draft_items(rows)
|
|
292
|
+
withdrawn = next((row for row in items["withdrawn"] if row["recordId"] == fields["itemId"]), None)
|
|
293
|
+
if withdrawn is not None:
|
|
294
|
+
raise DiscussionStoreError(
|
|
295
|
+
f"{fields['itemId']} is already withdrawn from the current draft "
|
|
296
|
+
f"({withdrawn['withdrawal']['recordId']})"
|
|
297
|
+
)
|
|
298
|
+
if fields["itemId"] not in {row["recordId"] for row in items["effective"]}:
|
|
299
|
+
raise DiscussionStoreError(
|
|
300
|
+
f"{fields['itemId']} is not a requirement or scope item of the current draft"
|
|
301
|
+
)
|
|
302
|
+
return
|
|
303
|
+
if kind in ITEM_KINDS and author != USER and author not in source_run.participants:
|
|
304
|
+
raise DiscussionStoreError(
|
|
305
|
+
f"author {author!r} is neither the user nor a participant of {source_run.manifest}"
|
|
306
|
+
)
|
|
307
|
+
if kind == "scope" and fields["scopeKind"] not in SCOPE_KINDS:
|
|
308
|
+
raise DiscussionStoreError(f"--kind must be one of {', '.join(SCOPE_KINDS)}")
|
|
309
|
+
for name, expected in (("cycleId", "cycle"), ("failureId", "failure")):
|
|
310
|
+
ref = fields.get(name)
|
|
311
|
+
if ref and (by_id.get(ref) or {}).get("kind") != expected:
|
|
312
|
+
raise DiscussionStoreError(f"no {expected} {ref} in this discussion")
|
|
313
|
+
if kind == "recovery":
|
|
314
|
+
earlier = next(
|
|
315
|
+
(row for row in rows if row["kind"] == "recovery" and row["failureId"] == fields["failureId"]),
|
|
316
|
+
None,
|
|
317
|
+
)
|
|
318
|
+
if earlier is not None:
|
|
319
|
+
raise DiscussionStoreError(
|
|
320
|
+
f"{fields['failureId']} already has the user's choice {earlier['choice']!r} "
|
|
321
|
+
f"({earlier['recordId']}); a later failure gets its own choice"
|
|
322
|
+
)
|
|
323
|
+
return
|
|
324
|
+
if kind == "close":
|
|
325
|
+
earlier = next(
|
|
326
|
+
(row for row in rows if row["kind"] == "close" and row["cycleId"] == fields["cycleId"]),
|
|
327
|
+
None,
|
|
328
|
+
)
|
|
329
|
+
if earlier is not None:
|
|
330
|
+
raise DiscussionStoreError(
|
|
331
|
+
f"{fields['cycleId']} is already closed ({earlier['recordId']})"
|
|
332
|
+
)
|
|
333
|
+
return
|
|
334
|
+
if kind == "material" and author != USER and author not in source_run.participants:
|
|
335
|
+
raise DiscussionStoreError(
|
|
336
|
+
f"author {author!r} is neither the user nor a participant of {source_run.manifest}"
|
|
337
|
+
)
|
|
338
|
+
if kind == "question":
|
|
339
|
+
if author == USER:
|
|
340
|
+
if fields["to"] not in source_run.participants - {LEADER}:
|
|
341
|
+
raise DiscussionStoreError(
|
|
342
|
+
f"{fields['to']!r} is not a participant of {source_run.manifest}; "
|
|
343
|
+
f"address one of: {', '.join(sorted(source_run.participants - {LEADER}))}"
|
|
344
|
+
)
|
|
345
|
+
elif author not in source_run.participants:
|
|
346
|
+
raise DiscussionStoreError(
|
|
347
|
+
f"author {author!r} is not a participant of {source_run.manifest}"
|
|
348
|
+
)
|
|
349
|
+
return
|
|
350
|
+
if kind == "answer" and not fields.get("questionId"):
|
|
351
|
+
raise DiscussionStoreError("an answer names the question it answers (--question)")
|
|
352
|
+
if kind in ("answer", "decision") and fields.get("questionId"):
|
|
353
|
+
question = by_id.get(fields["questionId"])
|
|
354
|
+
if question is None or question["kind"] != "question":
|
|
355
|
+
raise DiscussionStoreError(f"no question {fields['questionId']} in this discussion")
|
|
356
|
+
if kind == "answer":
|
|
357
|
+
if author != question["to"]:
|
|
358
|
+
raise DiscussionStoreError(
|
|
359
|
+
f"{fields['questionId']} is addressed to {question['to']}; "
|
|
360
|
+
f"an answer by {author} is refused"
|
|
361
|
+
)
|
|
362
|
+
earlier = next(
|
|
363
|
+
(row for row in rows
|
|
364
|
+
if row["kind"] == "answer" and row["questionId"] == fields["questionId"]),
|
|
365
|
+
None,
|
|
366
|
+
)
|
|
367
|
+
if earlier is not None:
|
|
368
|
+
raise DiscussionStoreError(
|
|
369
|
+
f"{fields['questionId']} is already answered ({earlier['recordId']}); "
|
|
370
|
+
"record a decision or ask a follow-up question instead"
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
@dataclass
|
|
375
|
+
class Transaction:
|
|
376
|
+
"""잠금 하나 안에서 읽은 기록과, 그 위에 이어 쓰는 append."""
|
|
377
|
+
|
|
378
|
+
store: DiscussionStore
|
|
379
|
+
rows: list[dict[str, Any]] = field(default_factory=list)
|
|
380
|
+
|
|
381
|
+
def append(
|
|
382
|
+
self, kind: str, fields: dict[str, Any], *, author: str, source_run: SourceRun,
|
|
383
|
+
) -> tuple[dict[str, Any], bool]:
|
|
384
|
+
if kind not in _ID_PREFIX:
|
|
385
|
+
raise DiscussionStoreError(f"unknown discussion record kind: {kind}")
|
|
386
|
+
for name, value in fields.items():
|
|
387
|
+
if isinstance(value, str) and name not in _OPTIONAL_FIELDS and not value.strip():
|
|
388
|
+
raise DiscussionStoreError(f"--{name} must not be empty")
|
|
389
|
+
# 재토론 초안은 같은 요구사항을 다시 적어도 새 기록이다 — 앞 확정본의 기록으로 합치지 않는다.
|
|
390
|
+
generation = next((row["recordId"] for row in reversed(self.rows) if row["kind"] == "reopen"), "")
|
|
391
|
+
key = _identity_key(
|
|
392
|
+
kind, author, {**fields, "sourceRun": source_run.manifest, "draftGeneration": generation},
|
|
393
|
+
)
|
|
394
|
+
existing = next((row for row in reversed(self.rows) if row.get("idempotencyKey") == key), None)
|
|
395
|
+
if kind in ITEM_KINDS:
|
|
396
|
+
# 철회한 항목을 다시 적으면 새 기록이다. 이월된 같은 항목은 그 기록을 그대로 돌려준다.
|
|
397
|
+
effective = draft_items(self.rows)["effective"]
|
|
398
|
+
if existing is not None and existing["recordId"] not in {row["recordId"] for row in effective}:
|
|
399
|
+
existing = None
|
|
400
|
+
existing = existing or next(
|
|
401
|
+
(row for row in effective
|
|
402
|
+
if row["kind"] == kind and all(row.get(name) == fields.get(name) for name in fields)),
|
|
403
|
+
None,
|
|
404
|
+
)
|
|
405
|
+
if existing is not None:
|
|
406
|
+
return existing, False
|
|
407
|
+
_check_record(kind, author, fields, self.rows, source_run)
|
|
408
|
+
sequence = 1 + sum(1 for row in self.rows if row["kind"] == kind)
|
|
409
|
+
record = {
|
|
410
|
+
"schemaVersion": SCHEMA_VERSION,
|
|
411
|
+
"taskKey": self.store.task_key,
|
|
412
|
+
"recordId": f"{_ID_PREFIX[kind]}-{sequence:04d}",
|
|
413
|
+
"kind": kind,
|
|
414
|
+
"author": author,
|
|
415
|
+
**fields,
|
|
416
|
+
"sourceRun": source_run.manifest,
|
|
417
|
+
"idempotencyKey": key,
|
|
418
|
+
"recordedAt": _now_iso(),
|
|
419
|
+
}
|
|
420
|
+
line = json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n"
|
|
421
|
+
with self.store.records_path.open("ab") as handle:
|
|
422
|
+
handle.write(line.encode("utf-8"))
|
|
423
|
+
handle.flush()
|
|
424
|
+
os.fsync(handle.fileno())
|
|
425
|
+
self.rows.append(record)
|
|
426
|
+
return record, True
|
|
427
|
+
|
|
428
|
+
def draft(self) -> dict[str, Any]:
|
|
429
|
+
_, digest = _read_records(self.store)
|
|
430
|
+
return _project(self.rows, self.store.task_key, digest)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
@contextmanager
|
|
434
|
+
def transaction(store: DiscussionStore) -> Iterator[Transaction]:
|
|
435
|
+
"""여러 기록을 한 판정으로 쓸 때 — 판정에 쓴 기록과 append 사이에 다른 쓰기가 끼지 않는다."""
|
|
436
|
+
with dir_flock(store.dir, _LOCK_FILENAME):
|
|
437
|
+
_drop_interrupted_tail(store)
|
|
438
|
+
rows, _ = _read_records(store)
|
|
439
|
+
txn = Transaction(store, rows)
|
|
440
|
+
try:
|
|
441
|
+
yield txn
|
|
442
|
+
finally:
|
|
443
|
+
_rebuild_locked(store)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def append_record(
|
|
447
|
+
store: DiscussionStore,
|
|
448
|
+
kind: str,
|
|
449
|
+
fields: dict[str, Any],
|
|
450
|
+
*,
|
|
451
|
+
author: str,
|
|
452
|
+
source_run: SourceRun,
|
|
453
|
+
) -> tuple[dict[str, Any], bool]:
|
|
454
|
+
with transaction(store) as txn:
|
|
455
|
+
return txn.append(kind, fields, author=author, source_run=source_run)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
_SUPPORTED = ("full-consensus", "partial-consensus")
|
|
459
|
+
_DISPUTED = ("contested", "worker-unique")
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _claim_standing(classification: Any) -> str:
|
|
463
|
+
if classification in _SUPPORTED:
|
|
464
|
+
return "supported"
|
|
465
|
+
if classification in _DISPUTED:
|
|
466
|
+
return "disputed"
|
|
467
|
+
return "unverified"
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _of(rows: list[dict[str, Any]], kind: str) -> list[dict[str, Any]]:
|
|
471
|
+
return [row for row in rows if row["kind"] == kind]
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _cycles(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
475
|
+
recoveries = {row["failureId"]: row for row in _of(rows, "recovery")}
|
|
476
|
+
closes = {row["cycleId"]: row for row in _of(rows, "close")}
|
|
477
|
+
cycles = []
|
|
478
|
+
for row in _of(rows, "cycle"):
|
|
479
|
+
cycle_id = row["recordId"]
|
|
480
|
+
failures = [
|
|
481
|
+
{**failure, "recovery": recoveries.get(failure["recordId"])}
|
|
482
|
+
for failure in _of(rows, "failure") if failure["cycleId"] == cycle_id
|
|
483
|
+
]
|
|
484
|
+
replaced = any((f["recovery"] or {}).get("choice") == "replace" for f in failures)
|
|
485
|
+
cycles.append({
|
|
486
|
+
**row,
|
|
487
|
+
"status": "closed" if cycle_id in closes else "superseded" if replaced else "open",
|
|
488
|
+
"close": closes.get(cycle_id),
|
|
489
|
+
"results": [r for r in _of(rows, "result") if r["cycleId"] == cycle_id],
|
|
490
|
+
"failures": failures,
|
|
491
|
+
"claims": [
|
|
492
|
+
{**claim, "standing": _claim_standing(claim["classification"])}
|
|
493
|
+
for claim in _of(rows, "claim") if claim["cycleId"] == cycle_id
|
|
494
|
+
],
|
|
495
|
+
})
|
|
496
|
+
return cycles
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def completed_publication(rows: list[dict[str, Any]]) -> Optional[dict[str, Any]]:
|
|
500
|
+
"""재토론이 열리지 않은 마지막 확정본 발행 기록 — 없으면 토론은 초안이다."""
|
|
501
|
+
for row in reversed(rows):
|
|
502
|
+
if row["kind"] == "reopen":
|
|
503
|
+
return None
|
|
504
|
+
if row["kind"] == "publication":
|
|
505
|
+
return row
|
|
506
|
+
return None
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def draft_items(rows: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]:
|
|
510
|
+
"""현재 초안의 요구사항·범위 — 재토론이 연 확정본의 유효 항목을 이월하고, 이 초안의 철회와 추가를 접는다.
|
|
511
|
+
|
|
512
|
+
이월 항목은 원 기록 그대로(id·작성자·run)이고 `carriedFrom` 에 그 확정본 id 를 단다. 확정본도 같은
|
|
513
|
+
기록에서 만들어지므로 파일을 읽지 않고 기록만으로 같은 집합이 나온다.
|
|
514
|
+
"""
|
|
515
|
+
effective: dict[str, dict[str, Any]] = {}
|
|
516
|
+
withdrawn: list[dict[str, Any]] = []
|
|
517
|
+
for row in rows:
|
|
518
|
+
if row["kind"] == "reopen":
|
|
519
|
+
effective = {key: {**item, "carriedFrom": row["revisionId"]} for key, item in effective.items()}
|
|
520
|
+
withdrawn = []
|
|
521
|
+
elif row["kind"] in ITEM_KINDS:
|
|
522
|
+
effective[row["recordId"]] = {**row, "carriedFrom": None}
|
|
523
|
+
elif row["kind"] == "withdrawal" and row["itemId"] in effective:
|
|
524
|
+
withdrawn.append({**effective.pop(row["itemId"]), "withdrawal": _ref(row, "reason")})
|
|
525
|
+
return {"effective": list(effective.values()), "withdrawn": withdrawn}
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _phase(draft: dict[str, Any]) -> str:
|
|
529
|
+
"""토론이 지금 기다리는 것. `completed` 는 사용자 완료 확인으로 발행한 확정본에서만 나온다."""
|
|
530
|
+
cycles = draft["cycles"]
|
|
531
|
+
last = cycles[-1] if cycles else None
|
|
532
|
+
closed = [cycle for cycle in cycles if cycle["status"] == "closed"]
|
|
533
|
+
if draft["completedRevision"] is not None:
|
|
534
|
+
return "completed"
|
|
535
|
+
if draft["subject"] is None:
|
|
536
|
+
return "framing"
|
|
537
|
+
if draft["unrecoveredFailures"]:
|
|
538
|
+
return "awaiting-recovery"
|
|
539
|
+
if last is not None and last["status"] == "open":
|
|
540
|
+
return "researching"
|
|
541
|
+
if last is not None and last["status"] == "superseded":
|
|
542
|
+
return "awaiting-new-run"
|
|
543
|
+
if draft["pendingForUser"]:
|
|
544
|
+
return "awaiting-user"
|
|
545
|
+
if draft["pendingForParticipants"]:
|
|
546
|
+
return "awaiting-participant"
|
|
547
|
+
if not closed or closed[-1]["inputRefs"] != draft["inputRefs"]:
|
|
548
|
+
return "ready-for-research"
|
|
549
|
+
return "awaiting-choice"
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _dropped_questions(rows: list[dict[str, Any]]) -> set[str]:
|
|
553
|
+
"""참여자가 답하지 못했고 사용자가 그 답 없이 계속하기로 한 질문."""
|
|
554
|
+
reduced = {row["failureId"] for row in _of(rows, "recovery") if row["choice"] == "reduce"}
|
|
555
|
+
return {
|
|
556
|
+
row["questionId"] for row in _of(rows, "failure")
|
|
557
|
+
if row.get("questionId") and row["recordId"] in reduced
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _project(rows: list[dict[str, Any]], task_key: str, digest: str) -> dict[str, Any]:
|
|
562
|
+
dropped = _dropped_questions(rows)
|
|
563
|
+
questions = [
|
|
564
|
+
{
|
|
565
|
+
**row,
|
|
566
|
+
"answers": [a for a in rows if a["kind"] == "answer" and a["questionId"] == row["recordId"]],
|
|
567
|
+
"dropped": row["recordId"] in dropped,
|
|
568
|
+
}
|
|
569
|
+
for row in rows if row["kind"] == "question"
|
|
570
|
+
]
|
|
571
|
+
subjects = _of(rows, "subject")
|
|
572
|
+
recovered = {row["failureId"] for row in _of(rows, "recovery")}
|
|
573
|
+
cycles = _cycles(rows)
|
|
574
|
+
closed = [cycle for cycle in cycles if cycle["status"] == "closed"]
|
|
575
|
+
pending_for_user = [q["recordId"] for q in questions if q["to"] == USER and not q["answers"]]
|
|
576
|
+
draft = {
|
|
577
|
+
"schemaVersion": DRAFT_SCHEMA_VERSION,
|
|
578
|
+
"taskKey": task_key,
|
|
579
|
+
"recordCount": len(rows),
|
|
580
|
+
"recordsDigest": digest,
|
|
581
|
+
"startInputs": _of(rows, "start"),
|
|
582
|
+
"subject": subjects[-1] if subjects else None,
|
|
583
|
+
"decisions": _of(rows, "decision"),
|
|
584
|
+
"questions": questions,
|
|
585
|
+
"pendingForUser": pending_for_user,
|
|
586
|
+
"nextForUser": pending_for_user[0] if pending_for_user else None,
|
|
587
|
+
"pendingForParticipants": [
|
|
588
|
+
q["recordId"] for q in questions if q["to"] != USER and not q["answers"] and not q["dropped"]
|
|
589
|
+
],
|
|
590
|
+
"materials": _of(rows, "material"),
|
|
591
|
+
"cycles": cycles,
|
|
592
|
+
"failures": [
|
|
593
|
+
{**row, "recovery": next((r for r in _of(rows, "recovery") if r["failureId"] == row["recordId"]), None)}
|
|
594
|
+
for row in _of(rows, "failure")
|
|
595
|
+
],
|
|
596
|
+
"unrecoveredFailures": [
|
|
597
|
+
row["recordId"] for row in _of(rows, "failure") if row["recordId"] not in recovered
|
|
598
|
+
],
|
|
599
|
+
"remainingDisagreements": [
|
|
600
|
+
claim["recordId"] for claim in (closed[-1]["claims"] if closed else [])
|
|
601
|
+
if claim["standing"] != "supported"
|
|
602
|
+
],
|
|
603
|
+
"inputRefs": [row["recordId"] for row in rows if row["kind"] in INPUT_REF_KINDS],
|
|
604
|
+
}
|
|
605
|
+
items = draft_items(rows)
|
|
606
|
+
reopens = _of(rows, "reopen")
|
|
607
|
+
completed = completed_publication(rows)
|
|
608
|
+
draft.update({
|
|
609
|
+
"requirements": _of(items["effective"], "requirement"),
|
|
610
|
+
"scope": _of(items["effective"], "scope"),
|
|
611
|
+
"withdrawn": items["withdrawn"],
|
|
612
|
+
"confirmations": _of(rows, "confirmation"),
|
|
613
|
+
"revisions": [
|
|
614
|
+
{key: row[key] for key in ("recordId", "revisionId", "sequence", "revisionPath",
|
|
615
|
+
"contentDigest", "confirmationId", "sourceRun")}
|
|
616
|
+
for row in _of(rows, "publication")
|
|
617
|
+
],
|
|
618
|
+
"completedRevision": completed["revisionId"] if completed else None,
|
|
619
|
+
"basedOn": reopens[-1] if reopens and completed is None else None,
|
|
620
|
+
})
|
|
621
|
+
draft["phase"] = _phase(draft)
|
|
622
|
+
return draft
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _rebuild_locked(store: DiscussionStore) -> dict[str, Any]:
|
|
626
|
+
rows, digest = _read_records(store)
|
|
627
|
+
draft = _project(rows, store.task_key, digest)
|
|
628
|
+
write_owned_object_atomic(store.draft_json_path, draft, artifact="discussion draft")
|
|
629
|
+
return draft
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def completed_revision(store: DiscussionStore) -> Optional[str]:
|
|
633
|
+
"""쓰지 않고 읽기만 한다 — prepare 가 어떤 쓰기보다 먼저 부른다."""
|
|
634
|
+
rows, _ = _read_records(store)
|
|
635
|
+
publication = completed_publication(rows)
|
|
636
|
+
return publication["revisionId"] if publication else None
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def has_records(store: DiscussionStore) -> bool:
|
|
640
|
+
return store.records_path.is_file() and bool(_complete_lines(store.records_path.read_bytes()).strip())
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def read_draft(store: DiscussionStore) -> dict[str, Any]:
|
|
644
|
+
_, digest = _read_records(store)
|
|
645
|
+
current = _load_draft(store)
|
|
646
|
+
if (
|
|
647
|
+
current is not None
|
|
648
|
+
and current.get("recordsDigest") == digest
|
|
649
|
+
and current.get("schemaVersion") == DRAFT_SCHEMA_VERSION
|
|
650
|
+
):
|
|
651
|
+
return current
|
|
652
|
+
with dir_flock(store.dir, _LOCK_FILENAME):
|
|
653
|
+
_drop_interrupted_tail(store)
|
|
654
|
+
return _rebuild_locked(store)
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def project_draft(store: DiscussionStore) -> dict[str, Any]:
|
|
658
|
+
"""기록에서 읽기 모델을 만들기만 한다 — 다른 프로젝트를 읽는 조회(관리자·후속 연결)는 그 프로젝트에 쓰지 않는다."""
|
|
659
|
+
rows, digest = _read_records(store)
|
|
660
|
+
return _project(rows, store.task_key, digest)
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def _load_draft(store: DiscussionStore) -> Optional[dict[str, Any]]:
|
|
664
|
+
if not store.draft_json_path.is_file():
|
|
665
|
+
return None
|
|
666
|
+
try:
|
|
667
|
+
return load_owned_object(store.draft_json_path, artifact="discussion draft")
|
|
668
|
+
except JsonBoundaryError:
|
|
669
|
+
# expected-miss: 깨진 읽기 모델은 기록에서 다시 만든다.
|
|
670
|
+
return None
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
_REVISION_SEPARATOR = "/revision-"
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def revision_id(task_key: str, sequence: int) -> str:
|
|
677
|
+
"""확정본 id — 토론 task key 와 완료 순번. run 순번과 무관하다."""
|
|
678
|
+
return f"{task_key}{_REVISION_SEPARATOR}{sequence:03d}"
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def parse_revision_id(value: str) -> tuple[str, int]:
|
|
682
|
+
task_key, separator, number = (value or "").rpartition(_REVISION_SEPARATOR)
|
|
683
|
+
if not separator or not number.isdigit() or int(number) < 1:
|
|
684
|
+
raise DiscussionStoreError(
|
|
685
|
+
f"invalid revision id {value!r} (expected <task-key>{_REVISION_SEPARATOR}NNN)"
|
|
686
|
+
)
|
|
687
|
+
_split_task_key(task_key)
|
|
688
|
+
return task_key, int(number)
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def _digest_bytes(data: bytes) -> str:
|
|
692
|
+
return "sha256:" + hashlib.sha256(data).hexdigest()
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def _ref(row: dict[str, Any], *names: str) -> dict[str, Any]:
|
|
696
|
+
return {
|
|
697
|
+
"recordId": row["recordId"], "author": row["author"], "sourceRun": row["sourceRun"],
|
|
698
|
+
**{name: row.get(name) for name in names},
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def _claim_view(claim: dict[str, Any]) -> dict[str, Any]:
|
|
703
|
+
return {
|
|
704
|
+
**_ref(claim, "cycleId", "findingId", "summary", "classification", "standing", "evidence",
|
|
705
|
+
"discoveredBy"),
|
|
706
|
+
"verdicts": claim["verdicts"],
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def _unverified(draft: dict[str, Any], claims: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
711
|
+
rows: list[dict[str, Any]] = [
|
|
712
|
+
{"recordId": claim["recordId"], "kind": "claim", "detail": claim["summary"]}
|
|
713
|
+
for claim in claims if claim["standing"] == "unverified"
|
|
714
|
+
]
|
|
715
|
+
rows += [
|
|
716
|
+
{"recordId": failure["recordId"], "kind": "reduced-participant",
|
|
717
|
+
"detail": f"{failure['author']} failed ({failure['reason']}); the user continued without it"}
|
|
718
|
+
for failure in draft["failures"] if (failure["recovery"] or {}).get("choice") == "reduce"
|
|
719
|
+
]
|
|
720
|
+
rows += [
|
|
721
|
+
{"recordId": cycle["recordId"], "kind": "cycle-not-cross-checked",
|
|
722
|
+
"detail": "fewer than two participants returned research, so its claims were not cross-checked"}
|
|
723
|
+
for cycle in draft["cycles"] if (cycle["close"] or {}).get("stopReason") == "auto-disabled"
|
|
724
|
+
]
|
|
725
|
+
rows += [
|
|
726
|
+
{"recordId": question["recordId"], "kind": "dropped-question", "detail": question["text"]}
|
|
727
|
+
for question in draft["questions"] if question["dropped"]
|
|
728
|
+
]
|
|
729
|
+
return rows
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def _build_revision(
|
|
733
|
+
store: DiscussionStore, rows: list[dict[str, Any]], confirmation: dict[str, Any], sequence: int,
|
|
734
|
+
) -> dict[str, Any]:
|
|
735
|
+
"""확인 기록까지의 기록에서만 만든다 — 같은 기록이면 언제 다시 만들어도 같은 바이트다."""
|
|
736
|
+
upto = rows[: next(i for i, row in enumerate(rows) if row["recordId"] == confirmation["recordId"]) + 1]
|
|
737
|
+
draft = _project(upto, store.task_key, confirmation["draftVersion"])
|
|
738
|
+
claims = [claim for cycle in draft["cycles"] for claim in cycle["claims"]]
|
|
739
|
+
disputed = set(draft["remainingDisagreements"])
|
|
740
|
+
previous = None
|
|
741
|
+
if draft["basedOn"] is not None:
|
|
742
|
+
reopen = draft["basedOn"]
|
|
743
|
+
earlier = next(row for row in _of(upto, "publication") if row["revisionId"] == reopen["revisionId"])
|
|
744
|
+
previous = {
|
|
745
|
+
"revisionId": reopen["revisionId"],
|
|
746
|
+
"contentDigest": earlier["contentDigest"],
|
|
747
|
+
"reopenRecord": reopen["recordId"],
|
|
748
|
+
"changeReason": reopen["reason"],
|
|
749
|
+
}
|
|
750
|
+
failures = draft["failures"]
|
|
751
|
+
return {
|
|
752
|
+
"schemaVersion": REVISION_SCHEMA_VERSION,
|
|
753
|
+
"revisionId": revision_id(store.task_key, sequence),
|
|
754
|
+
"sequence": sequence,
|
|
755
|
+
"taskKey": store.task_key,
|
|
756
|
+
"previousRevision": previous,
|
|
757
|
+
"confirmation": {
|
|
758
|
+
**_ref(confirmation, "draftVersion", "direction", "riskAcknowledgement"),
|
|
759
|
+
"confirmedAt": confirmation["recordedAt"],
|
|
760
|
+
},
|
|
761
|
+
"subject": _ref(draft["subject"], "subject", "question"),
|
|
762
|
+
"startInputs": [_ref(row, "startKind", "text") for row in draft["startInputs"]],
|
|
763
|
+
"materials": [_ref(row, "materialKind", "text") for row in draft["materials"]],
|
|
764
|
+
"direction": confirmation["direction"],
|
|
765
|
+
"requirements": [_ref(row, "appliesTo", "text", "carriedFrom") for row in draft["requirements"]],
|
|
766
|
+
"scope": {
|
|
767
|
+
"inScope": [_ref(row, "text", "carriedFrom") for row in draft["scope"] if row["scopeKind"] == "in-scope"],
|
|
768
|
+
"excluded": [_ref(row, "text", "carriedFrom") for row in draft["scope"] if row["scopeKind"] == "excluded"],
|
|
769
|
+
},
|
|
770
|
+
"itemChanges": _item_changes(draft),
|
|
771
|
+
"decisions": [_ref(row, "questionId", "text") for row in draft["decisions"]],
|
|
772
|
+
"answeredQuestions": [
|
|
773
|
+
{**_ref(question, "to", "text"), "answers": [_ref(answer, "text") for answer in question["answers"]]}
|
|
774
|
+
for question in draft["questions"] if question["answers"]
|
|
775
|
+
],
|
|
776
|
+
"disagreements": [_claim_view(claim) for claim in claims if claim["recordId"] in disputed],
|
|
777
|
+
"evidence": [_claim_view(claim) for claim in claims],
|
|
778
|
+
"unverified": _unverified(draft, claims),
|
|
779
|
+
"participants": {
|
|
780
|
+
"cycles": [
|
|
781
|
+
{"cycleId": cycle["recordId"], "run": cycle["sourceRun"], "status": cycle["status"],
|
|
782
|
+
"roster": cycle["roster"],
|
|
783
|
+
"results": [_ref(row, "resultPath") for row in cycle["results"]]}
|
|
784
|
+
for cycle in draft["cycles"]
|
|
785
|
+
],
|
|
786
|
+
"failures": [
|
|
787
|
+
{**_ref(row, "cycleId", "questionId", "stage", "attempts", "reason"),
|
|
788
|
+
"recovery": _ref(row["recovery"], "choice") if row["recovery"] else None}
|
|
789
|
+
for row in failures
|
|
790
|
+
],
|
|
791
|
+
"reducedRoster": any((row["recovery"] or {}).get("choice") == "reduce" for row in failures),
|
|
792
|
+
},
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def _item_view(row: dict[str, Any]) -> dict[str, Any]:
|
|
797
|
+
detail = "appliesTo" if row["kind"] == "requirement" else "scopeKind"
|
|
798
|
+
return {"recordId": row["recordId"], "kind": row["kind"], detail: row[detail], "text": row["text"]}
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
def _item_changes(draft: dict[str, Any]) -> dict[str, Any]:
|
|
802
|
+
"""이전 확정본에서 무엇을 이월·철회·추가했는가 — 첫 확정본은 이월이 없다."""
|
|
803
|
+
items = [*draft["requirements"], *draft["scope"]]
|
|
804
|
+
return {
|
|
805
|
+
"carriedFrom": draft["basedOn"]["revisionId"] if draft["basedOn"] else None,
|
|
806
|
+
"carried": [_item_view(row) for row in items if row["carriedFrom"]],
|
|
807
|
+
"added": [_item_view(row) for row in items if not row["carriedFrom"]],
|
|
808
|
+
"withdrawn": [
|
|
809
|
+
{**_item_view(row), "carriedFrom": row["carriedFrom"], "withdrawal": row["withdrawal"]}
|
|
810
|
+
for row in draft["withdrawn"]
|
|
811
|
+
],
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def _revision_bytes(revision: dict[str, Any]) -> bytes:
|
|
816
|
+
return (json.dumps(revision, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def _write_revision(path: Path, data: bytes) -> None:
|
|
820
|
+
"""한 번만 쓴다 — 임시 파일을 끝까지 쓴 뒤 link 로 붙여, 중단돼도 반쪽 확정본이 남지 않는다."""
|
|
821
|
+
if path.exists():
|
|
822
|
+
if path.read_bytes() != data:
|
|
823
|
+
raise DiscussionStoreError(
|
|
824
|
+
f"{path} already holds different content; a published revision is never rewritten"
|
|
825
|
+
)
|
|
826
|
+
return
|
|
827
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
828
|
+
temp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
829
|
+
try:
|
|
830
|
+
with temp.open("wb") as handle:
|
|
831
|
+
handle.write(data)
|
|
832
|
+
handle.flush()
|
|
833
|
+
os.fsync(handle.fileno())
|
|
834
|
+
os.chmod(temp, 0o444)
|
|
835
|
+
os.link(temp, path)
|
|
836
|
+
finally:
|
|
837
|
+
temp.unlink(missing_ok=True)
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
def _publish_locked(txn: "Transaction", confirmation: dict[str, Any], source_run: SourceRun) -> dict[str, Any]:
|
|
841
|
+
from .validation import revision_errors
|
|
842
|
+
|
|
843
|
+
existing = next(
|
|
844
|
+
(row for row in _of(txn.rows, "publication") if row["confirmationId"] == confirmation["recordId"]),
|
|
845
|
+
None,
|
|
846
|
+
)
|
|
847
|
+
if existing is not None:
|
|
848
|
+
return existing
|
|
849
|
+
sequence = 1 + len(_of(txn.rows, "publication"))
|
|
850
|
+
revision = _build_revision(txn.store, txn.rows, confirmation, sequence)
|
|
851
|
+
errors = revision_errors(revision)
|
|
852
|
+
if errors:
|
|
853
|
+
raise DiscussionStoreError("the revision is incomplete: " + "; ".join(errors))
|
|
854
|
+
data = _revision_bytes(revision)
|
|
855
|
+
path = txn.store.revision_path(sequence)
|
|
856
|
+
_write_revision(path, data)
|
|
857
|
+
record, _ = txn.append(
|
|
858
|
+
"publication",
|
|
859
|
+
{
|
|
860
|
+
"confirmationId": confirmation["recordId"],
|
|
861
|
+
"revisionId": revision["revisionId"],
|
|
862
|
+
"sequence": sequence,
|
|
863
|
+
"revisionPath": _project_relative(txn.store, path),
|
|
864
|
+
"contentDigest": _digest_bytes(data),
|
|
865
|
+
},
|
|
866
|
+
author=LEADER,
|
|
867
|
+
source_run=source_run,
|
|
868
|
+
)
|
|
869
|
+
return record
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
def confirm_completion(
|
|
873
|
+
store: DiscussionStore,
|
|
874
|
+
*,
|
|
875
|
+
draft_version: str,
|
|
876
|
+
direction: str,
|
|
877
|
+
risk_acknowledgement: str,
|
|
878
|
+
source_run: SourceRun,
|
|
879
|
+
) -> tuple[dict[str, Any], bool]:
|
|
880
|
+
"""사용자 완료 확인을 그 초안 버전에 묶어 기록하고 확정본을 발행한다.
|
|
881
|
+
|
|
882
|
+
같은 확인의 재전송은 이미 발행한 확정본을 돌려준다. 확인 뒤 기록이 하나라도 늘었으면
|
|
883
|
+
그 확인은 낡은 초안의 것이라 발행하지 않는다.
|
|
884
|
+
"""
|
|
885
|
+
from .validation import completion_errors
|
|
886
|
+
|
|
887
|
+
fields = {"draftVersion": draft_version, "direction": direction,
|
|
888
|
+
"riskAcknowledgement": risk_acknowledgement}
|
|
889
|
+
with transaction(store) as txn:
|
|
890
|
+
key = _identity_key("confirmation", USER, fields)
|
|
891
|
+
earlier = next((row for row in txn.rows if row.get("idempotencyKey") == key), None)
|
|
892
|
+
if earlier is not None:
|
|
893
|
+
publication = next(
|
|
894
|
+
(row for row in _of(txn.rows, "publication") if row["confirmationId"] == earlier["recordId"]),
|
|
895
|
+
None,
|
|
896
|
+
)
|
|
897
|
+
if publication is not None:
|
|
898
|
+
return publication, False
|
|
899
|
+
later = [row["recordId"] for row in txn.rows[txn.rows.index(earlier) + 1:]]
|
|
900
|
+
if later:
|
|
901
|
+
raise DiscussionStoreError(
|
|
902
|
+
f"{earlier['recordId']} confirmed draft version {draft_version}, but "
|
|
903
|
+
f"{', '.join(later)} arrived after it; show the user the current draft and confirm again"
|
|
904
|
+
)
|
|
905
|
+
return _publish_locked(txn, earlier, source_run), True
|
|
906
|
+
_, current = _read_records(store)
|
|
907
|
+
if draft_version != current:
|
|
908
|
+
raise DiscussionStoreError(
|
|
909
|
+
f"the confirmation names draft version {draft_version}, but the current draft is "
|
|
910
|
+
f"{current}; show the user the current draft (`okstra discussion draft`) and confirm again"
|
|
911
|
+
)
|
|
912
|
+
draft = txn.draft()
|
|
913
|
+
if draft["completedRevision"] is not None:
|
|
914
|
+
raise DiscussionStoreError(
|
|
915
|
+
f"the discussion is already completed as {draft['completedRevision']}; "
|
|
916
|
+
"reopen it to confirm a new revision"
|
|
917
|
+
)
|
|
918
|
+
errors = completion_errors(draft, direction, risk_acknowledgement)
|
|
919
|
+
if errors:
|
|
920
|
+
raise DiscussionStoreError("the draft cannot be completed yet: " + "; ".join(errors))
|
|
921
|
+
confirmation, _ = txn.append("confirmation", fields, author=USER, source_run=source_run)
|
|
922
|
+
return _publish_locked(txn, confirmation, source_run), True
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
def read_revision(store: DiscussionStore, value: str) -> tuple[dict[str, Any], bytes]:
|
|
926
|
+
"""발행 기록과 파일 바이트가 맞는 확정본만 돌려준다."""
|
|
927
|
+
task_key, sequence = parse_revision_id(value)
|
|
928
|
+
if not same_task(task_key, store.task_key):
|
|
929
|
+
raise DiscussionStoreError(f"{value} is a revision of {task_key}, not of {store.task_key}")
|
|
930
|
+
rows, _ = _read_records(store)
|
|
931
|
+
publication = next((row for row in _of(rows, "publication") if row["sequence"] == sequence), None)
|
|
932
|
+
if publication is None:
|
|
933
|
+
raise DiscussionStoreError(f"no revision {value} is published in this discussion")
|
|
934
|
+
path = store.revision_path(sequence)
|
|
935
|
+
if not path.is_file():
|
|
936
|
+
raise DiscussionStoreError(f"{publication['revisionPath']} of {value} is missing")
|
|
937
|
+
data = path.read_bytes()
|
|
938
|
+
if _digest_bytes(data) != publication["contentDigest"]:
|
|
939
|
+
raise DiscussionStoreError(
|
|
940
|
+
f"{publication['revisionPath']} no longer matches the published digest {publication['contentDigest']}"
|
|
941
|
+
)
|
|
942
|
+
return publication, data
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
def _attribution(row: dict[str, Any]) -> str:
|
|
946
|
+
return f"{row['recordId']} · {row['author']} · run `{row['sourceRun']}`"
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
def render_draft_text(draft: dict[str, Any]) -> str:
|
|
950
|
+
lines = [
|
|
951
|
+
"# Discussion record",
|
|
952
|
+
"",
|
|
953
|
+
f"- Task: `{draft['taskKey']}`",
|
|
954
|
+
f"- Records: {draft['recordCount']} (`{draft['recordsDigest']}`)",
|
|
955
|
+
f"- Phase: {draft['phase']}",
|
|
956
|
+
f"- Draft version: `{draft['recordsDigest']}`",
|
|
957
|
+
"",
|
|
958
|
+
"## Confirmed subject and question",
|
|
959
|
+
"",
|
|
960
|
+
]
|
|
961
|
+
subject = draft["subject"]
|
|
962
|
+
if subject is None:
|
|
963
|
+
lines.append("- Not confirmed yet. Confirm the subject and the question with the user first.")
|
|
964
|
+
else:
|
|
965
|
+
lines += [
|
|
966
|
+
f"- Subject: {subject['subject']}",
|
|
967
|
+
f"- Question: {subject['question']}",
|
|
968
|
+
f"- Confirmed: {_attribution(subject)}",
|
|
969
|
+
]
|
|
970
|
+
lines += ["", "## Start inputs", ""]
|
|
971
|
+
lines += [
|
|
972
|
+
f"- [{row['startKind']}] {row['text']} ({_attribution(row)})"
|
|
973
|
+
for row in draft["startInputs"]
|
|
974
|
+
] or ["- None."]
|
|
975
|
+
lines += ["", "## User decisions", ""]
|
|
976
|
+
lines += [
|
|
977
|
+
f"- {row['text']}"
|
|
978
|
+
+ (f" (on {row['questionId']})" if row.get("questionId") else "")
|
|
979
|
+
+ f" ({_attribution(row)})"
|
|
980
|
+
for row in draft["decisions"]
|
|
981
|
+
] or ["- None."]
|
|
982
|
+
by_id = {q["recordId"]: q for q in draft["questions"]}
|
|
983
|
+
lines += ["", "## Questions waiting for the user", ""]
|
|
984
|
+
lines += [
|
|
985
|
+
f"- {qid} from {by_id[qid]['author']}: {by_id[qid]['text']}"
|
|
986
|
+
for qid in draft["pendingForUser"]
|
|
987
|
+
] or ["- None."]
|
|
988
|
+
if draft["nextForUser"]:
|
|
989
|
+
lines.append(f"- Ask {draft['nextForUser']} next; put one question to the user at a time.")
|
|
990
|
+
lines += ["", "## Questions waiting for a participant", ""]
|
|
991
|
+
lines += [
|
|
992
|
+
f"- {qid} to {by_id[qid]['to']}: {by_id[qid]['text']}"
|
|
993
|
+
for qid in draft["pendingForParticipants"]
|
|
994
|
+
] or ["- None."]
|
|
995
|
+
lines += [
|
|
996
|
+
f"- {q['recordId']} to {q['to']}: not answered — the participant failed and the user "
|
|
997
|
+
f"continued without the answer: {q['text']}"
|
|
998
|
+
for q in draft["questions"] if q["dropped"]
|
|
999
|
+
]
|
|
1000
|
+
lines += ["", "## Answered questions", ""]
|
|
1001
|
+
answered = [q for q in draft["questions"] if q["answers"]]
|
|
1002
|
+
if not answered:
|
|
1003
|
+
lines.append("- None.")
|
|
1004
|
+
for question in answered:
|
|
1005
|
+
lines.append(f"### {question['recordId']} — {question['author']} to {question['to']}")
|
|
1006
|
+
lines += ["", question["text"], ""]
|
|
1007
|
+
for answer in question["answers"]:
|
|
1008
|
+
lines += [f"Answer ({_attribution(answer)}):", "", answer["text"], ""]
|
|
1009
|
+
lines += ["", "## New evidence and alternatives", ""]
|
|
1010
|
+
lines += [
|
|
1011
|
+
f"- [{row['materialKind']}] {row['text']} ({_attribution(row)})"
|
|
1012
|
+
for row in draft["materials"]
|
|
1013
|
+
] or ["- None."]
|
|
1014
|
+
lines += _render_cycles(draft)
|
|
1015
|
+
lines += _render_claims(draft)
|
|
1016
|
+
lines += _render_completion(draft)
|
|
1017
|
+
return "\n".join(lines).rstrip("\n") + "\n"
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
def _render_completion(draft: dict[str, Any]) -> list[str]:
|
|
1021
|
+
lines = ["", "## Requirements of this draft", ""]
|
|
1022
|
+
lines += [
|
|
1023
|
+
f"- [{row['appliesTo']}] {row['text']} ({_attribution(row)}{_carried(row)})" for row in draft["requirements"]
|
|
1024
|
+
] or ["- None yet."]
|
|
1025
|
+
lines += ["", "## Scope of this draft", ""]
|
|
1026
|
+
lines += [
|
|
1027
|
+
f"- [{row['scopeKind']}] {row['text']} ({_attribution(row)}{_carried(row)})" for row in draft["scope"]
|
|
1028
|
+
] or ["- None yet."]
|
|
1029
|
+
if draft["withdrawn"]:
|
|
1030
|
+
lines += ["", "## Withdrawn from this draft", ""]
|
|
1031
|
+
lines += [
|
|
1032
|
+
f"- {row['recordId']} [{row.get('appliesTo') or row.get('scopeKind')}] {row['text']} — withdrawn by "
|
|
1033
|
+
f"{row['withdrawal']['author']} in {row['withdrawal']['recordId']}: {row['withdrawal']['reason']}"
|
|
1034
|
+
for row in draft["withdrawn"]
|
|
1035
|
+
]
|
|
1036
|
+
if draft["basedOn"]:
|
|
1037
|
+
lines += [
|
|
1038
|
+
"",
|
|
1039
|
+
"Items carried from the reopened revision stay in this draft until "
|
|
1040
|
+
"`okstra discussion withdraw --item <id> --reason \"<why>\"` removes them; "
|
|
1041
|
+
"replace an item by withdrawing it and adding the new one.",
|
|
1042
|
+
]
|
|
1043
|
+
lines += ["", "## Revisions", ""]
|
|
1044
|
+
lines += [
|
|
1045
|
+
f"- {row['revisionId']} (`{row['contentDigest']}`) confirmed by the user in {row['confirmationId']}"
|
|
1046
|
+
for row in draft["revisions"]
|
|
1047
|
+
] or ["- None. The discussion completes only when the user confirms completion."]
|
|
1048
|
+
if draft["completedRevision"]:
|
|
1049
|
+
lines.append(f"- Completed as {draft['completedRevision']}; reopen it to change the outcome.")
|
|
1050
|
+
if draft["basedOn"]:
|
|
1051
|
+
lines.append(
|
|
1052
|
+
f"- This draft reopens {draft['basedOn']['revisionId']} ({_attribution(draft['basedOn'])}): "
|
|
1053
|
+
f"{draft['basedOn']['reason']}"
|
|
1054
|
+
)
|
|
1055
|
+
return lines
|
|
1056
|
+
|
|
1057
|
+
|
|
1058
|
+
def _carried(row: dict[str, Any]) -> str:
|
|
1059
|
+
return f" · carried from {row['carriedFrom']}" if row.get("carriedFrom") else ""
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
def _render_cycles(draft: dict[str, Any]) -> list[str]:
|
|
1063
|
+
lines = ["", "## Research cycles", ""]
|
|
1064
|
+
if not draft["cycles"]:
|
|
1065
|
+
return lines + ["- None yet."]
|
|
1066
|
+
for cycle in draft["cycles"]:
|
|
1067
|
+
roster = ", ".join(f"{row['worker']} ({row['model']})" for row in cycle["roster"]) or "none"
|
|
1068
|
+
lines += [
|
|
1069
|
+
f"### {cycle['recordId']} — {cycle['status']} (run `{cycle['sourceRun']}`)",
|
|
1070
|
+
"",
|
|
1071
|
+
f"- Participants: {roster}",
|
|
1072
|
+
f"- Input references: {', '.join(cycle['inputRefs']) or 'none'}",
|
|
1073
|
+
]
|
|
1074
|
+
close = cycle["close"]
|
|
1075
|
+
if close is not None:
|
|
1076
|
+
lines.append(
|
|
1077
|
+
f"- Verification cycle ended: convergence `{close['finalState']}` "
|
|
1078
|
+
f"({close['stopReason']}) recorded in {close['recordId']}"
|
|
1079
|
+
)
|
|
1080
|
+
if close["stopReason"] == "auto-disabled":
|
|
1081
|
+
lines.append(
|
|
1082
|
+
"- The claims of this cycle were not cross-checked: fewer than two "
|
|
1083
|
+
"participants returned research"
|
|
1084
|
+
)
|
|
1085
|
+
lines += [f"- Result {_attribution(row)}: `{row['resultPath']}`" for row in cycle["results"]]
|
|
1086
|
+
for failure in cycle["failures"]:
|
|
1087
|
+
recovery = failure["recovery"]
|
|
1088
|
+
choice = (
|
|
1089
|
+
f"user's choice: {recovery['choice']} ({recovery['recordId']})"
|
|
1090
|
+
if recovery else "waiting for the user's choice"
|
|
1091
|
+
)
|
|
1092
|
+
lines.append(
|
|
1093
|
+
f"- Failure {_attribution(failure)}: {failure['stage']} after "
|
|
1094
|
+
f"{failure['attempts']} attempt(s) — {failure['reason']}; {choice}"
|
|
1095
|
+
)
|
|
1096
|
+
lines.append("")
|
|
1097
|
+
return lines
|
|
1098
|
+
|
|
1099
|
+
|
|
1100
|
+
def _render_claims(draft: dict[str, Any]) -> list[str]:
|
|
1101
|
+
lines = [
|
|
1102
|
+
"",
|
|
1103
|
+
"## Evidence claims",
|
|
1104
|
+
"",
|
|
1105
|
+
"Fact checks of claims about the code or external sources. They never settle the "
|
|
1106
|
+
"user's intent: the user decisions above stand until the user changes them.",
|
|
1107
|
+
"",
|
|
1108
|
+
]
|
|
1109
|
+
claims = [claim for cycle in draft["cycles"] for claim in cycle["claims"]]
|
|
1110
|
+
if not claims:
|
|
1111
|
+
return lines + ["- None yet."]
|
|
1112
|
+
for claim in claims:
|
|
1113
|
+
lines += [
|
|
1114
|
+
f"### {claim['recordId']} — {claim['standing']} ({claim['classification']}), "
|
|
1115
|
+
f"{claim['findingId']} of {claim['cycleId']}",
|
|
1116
|
+
"",
|
|
1117
|
+
claim["summary"],
|
|
1118
|
+
"",
|
|
1119
|
+
f"- Claimed by: {', '.join(claim['discoveredBy'])} (origin {claim['author']}); "
|
|
1120
|
+
f"evidence: {claim['evidence']}",
|
|
1121
|
+
]
|
|
1122
|
+
lines += [
|
|
1123
|
+
f"- Round {vote['round']} · {vote['worker']}: {vote['verdict']}"
|
|
1124
|
+
+ (f" ({vote['disagreeBasis']})" if vote.get("disagreeBasis") else "")
|
|
1125
|
+
+ f" — {vote['explanation']}"
|
|
1126
|
+
for vote in claim["verdicts"]
|
|
1127
|
+
] or ["- No independent participant checked this claim."]
|
|
1128
|
+
lines.append("")
|
|
1129
|
+
return lines
|
|
1130
|
+
|
|
1131
|
+
|
|
1132
|
+
def render_full_record(store: DiscussionStore) -> str:
|
|
1133
|
+
"""토론 기록 전문 — 초안 읽기 모델에 각 참여자의 조사 결과 본문을 붙인다."""
|
|
1134
|
+
draft = read_draft(store)
|
|
1135
|
+
lines = [render_draft_text(draft).rstrip("\n"), "", "## Research result texts", ""]
|
|
1136
|
+
results = [(cycle, row) for cycle in draft["cycles"] for row in cycle["results"]]
|
|
1137
|
+
if not results:
|
|
1138
|
+
lines.append("- None yet.")
|
|
1139
|
+
for cycle, row in results:
|
|
1140
|
+
path = store.project_root / row["resultPath"]
|
|
1141
|
+
body = path.read_text(encoding="utf-8") if path.is_file() else f"(missing: `{row['resultPath']}`)\n"
|
|
1142
|
+
lines += [f"### {row['recordId']} — {row['author']} in {cycle['recordId']}", "", body.rstrip("\n"), ""]
|
|
1143
|
+
return "\n".join(lines).rstrip("\n") + "\n"
|