okstra 0.219.0 → 0.219.1
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 +5 -5
- package/dist/cli-registry.mjs +0 -10
- package/dist/cli-registry.mjs.map +1 -1
- package/dist/commands/lifecycle/config.mjs +2 -3
- package/dist/commands/lifecycle/config.mjs.map +1 -1
- package/dist/lib/runtime-payload.mjs +1 -2
- package/dist/lib/runtime-payload.mjs.map +1 -1
- package/docs/architecture/storage-model.md +1 -1
- package/docs/architecture.md +9 -7
- package/docs/cli.md +2 -3
- package/docs/project-structure-overview.md +3 -4
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/prompts/launch.template.md +1 -1
- package/runtime/prompts/lead/okstra-lead-contract.md +1 -3
- package/runtime/prompts/lead/report-writer.md +10 -17
- package/runtime/prompts/lead/team-contract.md +1 -2
- package/runtime/prompts/wizard/prompts.ko.json +0 -1
- package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +1 -1
- package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +1 -1
- package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +0 -1
- package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +1 -1
- package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +0 -1
- package/runtime/python/okstra_ctl/adapters/providers/kimi/adapter.py +0 -1
- package/runtime/python/okstra_ctl/agent/invocation.py +1 -2
- package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +2 -2
- package/runtime/python/okstra_ctl/agent/prompt_cli/jobs.py +0 -2
- package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +2 -27
- package/runtime/python/okstra_ctl/agent/prompt_cli/run_identity.py +0 -4
- package/runtime/python/okstra_ctl/dispatch_core.py +3 -349
- package/runtime/python/okstra_ctl/dispatch_state.py +0 -13
- package/runtime/python/okstra_ctl/domain/provider.py +1 -1
- package/runtime/python/okstra_ctl/domain/role.py +0 -6
- package/runtime/python/okstra_ctl/final_report_paths.py +0 -16
- package/runtime/python/okstra_ctl/i18n.py +2 -2
- package/runtime/python/okstra_ctl/path_hints.py +0 -4
- package/runtime/python/okstra_ctl/paths.py +0 -3
- package/runtime/python/okstra_ctl/phases/final_verification/profile.md +1 -0
- package/runtime/python/okstra_ctl/phases/final_verification/spec.md +2 -0
- package/runtime/python/okstra_ctl/phases/final_verification/target.py +17 -1
- package/runtime/python/okstra_ctl/phases/implementation/entry.py +12 -0
- package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-deliverable.md +1 -0
- package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-executor.md +1 -0
- package/runtime/python/okstra_ctl/phases/implementation/spec.md +4 -0
- package/runtime/python/okstra_ctl/phases/release_handoff/boundary.json +1 -1
- package/runtime/python/okstra_ctl/phases/release_handoff/operations.py +24 -3
- package/runtime/python/okstra_ctl/phases/release_handoff/profile.md +9 -9
- package/runtime/python/okstra_ctl/phases/release_handoff/spec.md +4 -2
- package/runtime/python/okstra_ctl/render.py +0 -10
- package/runtime/python/okstra_ctl/render_final_report.py +4 -4
- package/runtime/python/okstra_ctl/report_finalize.py +37 -76
- package/runtime/python/okstra_ctl/report_html/render.py +4 -44
- package/runtime/python/okstra_ctl/report_language.py +3 -3
- package/runtime/python/okstra_ctl/report_prose_keys.py +153 -0
- package/runtime/python/okstra_ctl/report_synthesis_packet.py +1 -1
- package/runtime/python/okstra_ctl/run.py +1 -21
- package/runtime/python/okstra_ctl/stage_integrate.py +37 -2
- package/runtime/python/okstra_ctl/stage_rebase.py +189 -0
- package/runtime/python/okstra_ctl/stage_targets.py +29 -62
- package/runtime/python/okstra_ctl/team.py +1 -6
- package/runtime/python/okstra_ctl/wizard/confirmation.py +0 -8
- package/runtime/python/okstra_ctl/wizard/registry.py +2 -3
- package/runtime/python/okstra_ctl/wizard/sources.py +10 -0
- package/runtime/python/okstra_ctl/worker_prompt_policy.py +3 -12
- package/runtime/python/okstra_ctl/worktree/__init__.py +1 -1
- package/runtime/python/okstra_ctl/worktree/decisions.py +3 -4
- package/runtime/python/okstra_ctl/worktree/provision.py +27 -0
- package/runtime/python/okstra_ctl/worktree/rebase.py +108 -0
- package/runtime/python/okstra_ctl/worktree_registry.py +14 -0
- package/runtime/skills/_fragments/wizard-render-bundle.md +1 -1
- package/runtime/skills/okstra-discussion/SKILL.md +1 -1
- package/runtime/skills/okstra-run/SKILL.md +1 -1
- package/runtime/skills/okstra-setup/SKILL.md +2 -2
- package/runtime/skills/okstra-setup/references/project-config.md +2 -2
- package/runtime/templates/report-writer-prompt-preamble.md +1 -1
- package/runtime/templates/reports/html/i18n/en.json +0 -1
- package/runtime/templates/reports/html/i18n/ko.json +0 -1
- package/runtime/templates/worker-prompt-preamble.md +1 -1
- package/runtime/validators/validate-run.py +1 -28
- package/runtime/agents/operations/report-translation.json +0 -6
- package/runtime/agents/roles/translator.json +0 -19
- package/runtime/bin/okstra-report-translate.py +0 -404
- package/runtime/prompts/duties/translator.json +0 -32
- package/runtime/python/okstra_ctl/report_translation.py +0 -509
- package/runtime/python/okstra_ctl/report_translation_dispatch.py +0 -356
- package/runtime/templates/translator-prompt-preamble.md +0 -36
|
@@ -1,509 +0,0 @@
|
|
|
1
|
-
"""Which final-report strings a translator may rewrite, and how to apply them.
|
|
2
|
-
|
|
3
|
-
The data.json is authored in English and is the SSOT for every downstream
|
|
4
|
-
consumer. The human HTML is the one artifact that renders in the reader's
|
|
5
|
-
language, so a translation arrives as a sidecar keyed by JSON Pointer and is
|
|
6
|
-
overlaid at render time. Nothing here mutates the SSOT.
|
|
7
|
-
|
|
8
|
-
Classification is by leaf key name, and the split is not "prose vs. term" —
|
|
9
|
-
the translator is told to leave a term in English when English reads better.
|
|
10
|
-
The line drawn here is **load-bearing vs. displayed**: a value the renderer
|
|
11
|
-
feeds into a CSS class, a label-table lookup, or an anchor id cannot change
|
|
12
|
-
without breaking the page, so it never reaches the translator. Everything a
|
|
13
|
-
reader merely reads is offered, and the translator decides.
|
|
14
|
-
|
|
15
|
-
When a key name carries load-bearing values in even one place, it counts as
|
|
16
|
-
structural everywhere. Missing a translation leaves English on the page;
|
|
17
|
-
translating an anchor id breaks the link. ``PROSE_FIELDS`` is the one
|
|
18
|
-
exception: a field named by its enclosing keys, where that key holds prose
|
|
19
|
-
even though the same name is an id or a path elsewhere.
|
|
20
|
-
"""
|
|
21
|
-
from __future__ import annotations
|
|
22
|
-
|
|
23
|
-
import re
|
|
24
|
-
from typing import Any, Iterator, Mapping, NamedTuple
|
|
25
|
-
|
|
26
|
-
# Values the renderer reads as text and nothing else.
|
|
27
|
-
PROSE_KEYS = frozenset({
|
|
28
|
-
"acceptance",
|
|
29
|
-
"action",
|
|
30
|
-
"actions",
|
|
31
|
-
"addedWork",
|
|
32
|
-
"alternativesConsidered",
|
|
33
|
-
"answer",
|
|
34
|
-
"approach",
|
|
35
|
-
"approvalDisposition",
|
|
36
|
-
"approvalEvidence",
|
|
37
|
-
"architectureBoundaries",
|
|
38
|
-
"assumptions",
|
|
39
|
-
"behavior",
|
|
40
|
-
"blastRadius",
|
|
41
|
-
"blockReason",
|
|
42
|
-
"blockedReason",
|
|
43
|
-
"blockers",
|
|
44
|
-
"body",
|
|
45
|
-
"boundary",
|
|
46
|
-
"briefEvidence",
|
|
47
|
-
"canonical",
|
|
48
|
-
"carryIn",
|
|
49
|
-
"causeSummary",
|
|
50
|
-
"change",
|
|
51
|
-
"check",
|
|
52
|
-
"claim",
|
|
53
|
-
"commitment",
|
|
54
|
-
"complexity",
|
|
55
|
-
"condition",
|
|
56
|
-
"confirmingSignal",
|
|
57
|
-
"conformanceExemption",
|
|
58
|
-
"consequences",
|
|
59
|
-
"constraint",
|
|
60
|
-
"context",
|
|
61
|
-
"coreMechanism",
|
|
62
|
-
"coreReason",
|
|
63
|
-
"counterevidence",
|
|
64
|
-
"decision",
|
|
65
|
-
"decisions",
|
|
66
|
-
"declinedFixRecommendations",
|
|
67
|
-
"description",
|
|
68
|
-
"details",
|
|
69
|
-
"directionChange",
|
|
70
|
-
"disagreement",
|
|
71
|
-
"discrepancy",
|
|
72
|
-
"disproveWith",
|
|
73
|
-
"environmentSetup",
|
|
74
|
-
"escalationTrigger",
|
|
75
|
-
"evidence",
|
|
76
|
-
"evidenceRequired",
|
|
77
|
-
"exemptionReaffirm",
|
|
78
|
-
"exemptionReason",
|
|
79
|
-
"exitContract",
|
|
80
|
-
"exitContractSummary",
|
|
81
|
-
"expected",
|
|
82
|
-
"expectedBehaviorAfter",
|
|
83
|
-
"expectedChangeAreas",
|
|
84
|
-
"expectedForm",
|
|
85
|
-
"expectedOutcome",
|
|
86
|
-
"expectedResult",
|
|
87
|
-
"expectedVerification",
|
|
88
|
-
"explanation",
|
|
89
|
-
"fact",
|
|
90
|
-
"failureHandling",
|
|
91
|
-
"falsifyingEvidenceChecked",
|
|
92
|
-
"finalConclusion",
|
|
93
|
-
"goal",
|
|
94
|
-
"guardrails",
|
|
95
|
-
"headline",
|
|
96
|
-
"howToStart",
|
|
97
|
-
"hypothesis",
|
|
98
|
-
"ifStillOpen",
|
|
99
|
-
"injectedAs",
|
|
100
|
-
"interfaces",
|
|
101
|
-
"item",
|
|
102
|
-
"justification",
|
|
103
|
-
"knownFacts",
|
|
104
|
-
"label",
|
|
105
|
-
"mitigation",
|
|
106
|
-
"motivation",
|
|
107
|
-
"name",
|
|
108
|
-
"need",
|
|
109
|
-
"nextStep",
|
|
110
|
-
"noVariationRationale",
|
|
111
|
-
"notApplicableReason",
|
|
112
|
-
"note",
|
|
113
|
-
"observableFailure",
|
|
114
|
-
"observed",
|
|
115
|
-
"option",
|
|
116
|
-
"outcome",
|
|
117
|
-
"outputSummary",
|
|
118
|
-
"performed",
|
|
119
|
-
"planItem",
|
|
120
|
-
"position",
|
|
121
|
-
"problem",
|
|
122
|
-
"question",
|
|
123
|
-
"rationale",
|
|
124
|
-
"readyWhen",
|
|
125
|
-
"reason",
|
|
126
|
-
"rejectedSummary",
|
|
127
|
-
"rejectingSignal",
|
|
128
|
-
"relation",
|
|
129
|
-
"replacedInTest",
|
|
130
|
-
"replacedWith",
|
|
131
|
-
"requestKind",
|
|
132
|
-
"requiredDecision",
|
|
133
|
-
"requiredWork",
|
|
134
|
-
"requirement",
|
|
135
|
-
"resolution",
|
|
136
|
-
"responsibility",
|
|
137
|
-
"reversibility",
|
|
138
|
-
"risk",
|
|
139
|
-
"rolloutCost",
|
|
140
|
-
"routingRecommendation",
|
|
141
|
-
"satisfaction",
|
|
142
|
-
"scope",
|
|
143
|
-
"selfFixNote",
|
|
144
|
-
"sliceValue",
|
|
145
|
-
"stageTitle",
|
|
146
|
-
"stageValidation",
|
|
147
|
-
"statement",
|
|
148
|
-
"suggestedAction",
|
|
149
|
-
"summary",
|
|
150
|
-
"supersededStatement",
|
|
151
|
-
"symptom",
|
|
152
|
-
"systemInterpretation",
|
|
153
|
-
"testCaseBoundary",
|
|
154
|
-
"testCaseFailure",
|
|
155
|
-
"testCaseSuccess",
|
|
156
|
-
"testCoverageCost",
|
|
157
|
-
"text",
|
|
158
|
-
"title",
|
|
159
|
-
"topic",
|
|
160
|
-
"trigger",
|
|
161
|
-
"triggerSignal",
|
|
162
|
-
"unblockCondition",
|
|
163
|
-
"userChoice",
|
|
164
|
-
"userConstraints",
|
|
165
|
-
"userInput",
|
|
166
|
-
"userText",
|
|
167
|
-
"verification",
|
|
168
|
-
"verificationMethod",
|
|
169
|
-
"verificationSignal",
|
|
170
|
-
"whyItMatters",
|
|
171
|
-
"workingAssumption",
|
|
172
|
-
})
|
|
173
|
-
|
|
174
|
-
# Values something other than the reader depends on: a CSS class, a label-table
|
|
175
|
-
# key, an anchor id, a path the reader clicks, a command they paste, or a quote
|
|
176
|
-
# that must stay verbatim to be evidence at all.
|
|
177
|
-
STRUCTURAL_KEYS = frozenset({
|
|
178
|
-
"action", # implementation.py: _FILE_ACTIONS lookup key
|
|
179
|
-
"activityId",
|
|
180
|
-
"activityIds",
|
|
181
|
-
"adapter",
|
|
182
|
-
"affectedId",
|
|
183
|
-
"affectedTarget",
|
|
184
|
-
"area", # slug-shaped ('scan-coverage')
|
|
185
|
-
"artifact",
|
|
186
|
-
"baseBranch",
|
|
187
|
-
"baseKind",
|
|
188
|
-
"baseRefSha",
|
|
189
|
-
"baseSha",
|
|
190
|
-
"boundary",
|
|
191
|
-
"branchName",
|
|
192
|
-
"canonical",
|
|
193
|
-
"capturedHeadSha",
|
|
194
|
-
"carriedForwardFromSeq",
|
|
195
|
-
"carryJson",
|
|
196
|
-
"checkRefs",
|
|
197
|
-
"clarificationId",
|
|
198
|
-
"classification",
|
|
199
|
-
"claudeCode",
|
|
200
|
-
"command",
|
|
201
|
-
"commandOrObservation",
|
|
202
|
-
"commandOrTest",
|
|
203
|
-
"commands",
|
|
204
|
-
"commitListQuote",
|
|
205
|
-
"complexity",
|
|
206
|
-
"componentId",
|
|
207
|
-
"confidence",
|
|
208
|
-
"conformanceTests",
|
|
209
|
-
"coveredBy",
|
|
210
|
-
"createdAt",
|
|
211
|
-
"cwd",
|
|
212
|
-
"cycle",
|
|
213
|
-
"dataStore",
|
|
214
|
-
"date",
|
|
215
|
-
"dependsOn",
|
|
216
|
-
"diffSummaryQuote",
|
|
217
|
-
"direction",
|
|
218
|
-
"disposition",
|
|
219
|
-
"dispatches",
|
|
220
|
-
"executorWorktreePath",
|
|
221
|
-
"evidenceRefs",
|
|
222
|
-
"exitCode",
|
|
223
|
-
"existingPrUrl",
|
|
224
|
-
"externalSystem",
|
|
225
|
-
"featureId",
|
|
226
|
-
"field",
|
|
227
|
-
"file",
|
|
228
|
-
"files",
|
|
229
|
-
"followUpPhase",
|
|
230
|
-
"fromComponentId",
|
|
231
|
-
"fromTaskKey",
|
|
232
|
-
"fullSha",
|
|
233
|
-
"gitDiffStat",
|
|
234
|
-
"gitStatusShort",
|
|
235
|
-
"h1c",
|
|
236
|
-
"h2",
|
|
237
|
-
"headBranch",
|
|
238
|
-
"id",
|
|
239
|
-
"impact", # 'high' / 'medium' alongside prose
|
|
240
|
-
"impactKind",
|
|
241
|
-
"implementationBaseRef",
|
|
242
|
-
"implementationOption",
|
|
243
|
-
"independentValidationRerun",
|
|
244
|
-
"injectedAs",
|
|
245
|
-
"input",
|
|
246
|
-
"interfaceKind",
|
|
247
|
-
"interfaces",
|
|
248
|
-
"itemId",
|
|
249
|
-
"kind", # feature_analysis.py: _FLOW_KINDS lookup key
|
|
250
|
-
"leadModel",
|
|
251
|
-
"leadingCauseId",
|
|
252
|
-
"lens", # enum_label("lens")
|
|
253
|
-
"lines",
|
|
254
|
-
"linkedWork",
|
|
255
|
-
"match",
|
|
256
|
-
"model",
|
|
257
|
-
"newTaskId",
|
|
258
|
-
"number",
|
|
259
|
-
"okstraVersion",
|
|
260
|
-
"output",
|
|
261
|
-
"outputTail",
|
|
262
|
-
"owner",
|
|
263
|
-
"ownerComponentId",
|
|
264
|
-
"path",
|
|
265
|
-
"planFile",
|
|
266
|
-
"planItemIds",
|
|
267
|
-
"planStep",
|
|
268
|
-
"prepItemId",
|
|
269
|
-
"project",
|
|
270
|
-
"projectId",
|
|
271
|
-
"raw",
|
|
272
|
-
"rawStat",
|
|
273
|
-
"readBoundary",
|
|
274
|
-
"readOnlyCommandLog",
|
|
275
|
-
"recommendedDisposition",
|
|
276
|
-
"relatedIds",
|
|
277
|
-
"relation",
|
|
278
|
-
"replacedInTest",
|
|
279
|
-
"replacedWith",
|
|
280
|
-
"reportPath",
|
|
281
|
-
"resultPath",
|
|
282
|
-
"requestKind",
|
|
283
|
-
"requestPath",
|
|
284
|
-
"requestVerbatim", # the user's own words, quoted as evidence
|
|
285
|
-
"requestedValue",
|
|
286
|
-
"reversibility",
|
|
287
|
-
"risk", # tone-{{ row.risk }} class; an enum on ProjectHotspot
|
|
288
|
-
"role",
|
|
289
|
-
"rollbackCommand",
|
|
290
|
-
"runManifest",
|
|
291
|
-
"runSeq",
|
|
292
|
-
"scope", # 'PF-001' alongside prose
|
|
293
|
-
"scopeImpact", # clarification reach tokens the gate matches on
|
|
294
|
-
"sections",
|
|
295
|
-
"shortSha",
|
|
296
|
-
"signature",
|
|
297
|
-
"singleTicket",
|
|
298
|
-
"skippedWorkers",
|
|
299
|
-
"sliceValue",
|
|
300
|
-
"slug",
|
|
301
|
-
"source", # enum_label("rejectionSource") and evidence ids
|
|
302
|
-
"sourceCommit",
|
|
303
|
-
"sourceFile",
|
|
304
|
-
"sourceImpactId",
|
|
305
|
-
"sourceSection",
|
|
306
|
-
"status", # CSS class + enum_label("coverage")
|
|
307
|
-
"styleLintTypecheck",
|
|
308
|
-
"subject", # commit subject, must stay verbatim
|
|
309
|
-
"symbol",
|
|
310
|
-
"symptomVerbatim",
|
|
311
|
-
"target",
|
|
312
|
-
"targetReport",
|
|
313
|
-
"taskGroup",
|
|
314
|
-
"taskId",
|
|
315
|
-
"taskKey",
|
|
316
|
-
"taskType",
|
|
317
|
-
"tddEvidence",
|
|
318
|
-
"tddExemption",
|
|
319
|
-
"terminal",
|
|
320
|
-
"toComponentId",
|
|
321
|
-
"toTaskKey",
|
|
322
|
-
"url",
|
|
323
|
-
"verdictTokenQuote",
|
|
324
|
-
"verifier",
|
|
325
|
-
"version",
|
|
326
|
-
"worker",
|
|
327
|
-
"workerRole",
|
|
328
|
-
"worktreePath",
|
|
329
|
-
"writeBoundary",
|
|
330
|
-
})
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
# Fields whose key is structural by name but holds a sentence at this position.
|
|
334
|
-
# Each tuple matches the tail of the key path with array indices dropped;
|
|
335
|
-
# `endStateCoverage[].coveredBy` is absent because there it is a linked id.
|
|
336
|
-
PROSE_FIELDS = frozenset({
|
|
337
|
-
("requirementCoverage", "coveredBy"),
|
|
338
|
-
("commitList", "files"),
|
|
339
|
-
("manualUserTest", "items", "target"),
|
|
340
|
-
("manualUserTest", "items", "steps"),
|
|
341
|
-
})
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
def _is_prose_field(keys: tuple[str, ...]) -> bool:
|
|
345
|
-
if keys and keys[-1] in PROSE_KEYS:
|
|
346
|
-
return True
|
|
347
|
-
return any(keys[-len(field):] == field for field in PROSE_FIELDS)
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
# A key name says what a field is *for*; these say what one value actually is.
|
|
351
|
-
# `evidence` is the largest prose bucket in a real report and also holds bare
|
|
352
|
-
# `src/server.ts:33` citations — the key cannot separate them, the value can.
|
|
353
|
-
_MACHINE_CHARS = re.compile(r"^[A-Za-z0-9_.:@/\\#-]+$")
|
|
354
|
-
_LOWER_TOKEN = re.compile(r"^[a-z][a-z0-9]*([-_][a-z0-9]+)*$")
|
|
355
|
-
_FILE_EXTENSION = re.compile(r"\.[A-Za-z0-9]{1,6}$")
|
|
356
|
-
_LINE_NUMBER = re.compile(r":\d+$")
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
def is_translatable_value(text: str) -> bool:
|
|
360
|
-
"""Whether one value is prose rather than a citation or a token.
|
|
361
|
-
|
|
362
|
-
Anything with whitespace is prose. A single run of ASCII path characters
|
|
363
|
-
is not, once it carries a directory separator, a file extension, or a line
|
|
364
|
-
number — and neither is a bare lowercase token, which is how an enum reads.
|
|
365
|
-
"""
|
|
366
|
-
value = text.strip()
|
|
367
|
-
if not value:
|
|
368
|
-
return False
|
|
369
|
-
if any(char.isspace() for char in value):
|
|
370
|
-
return True
|
|
371
|
-
if not _MACHINE_CHARS.match(value):
|
|
372
|
-
return True
|
|
373
|
-
if "/" in value or "\\" in value:
|
|
374
|
-
return False
|
|
375
|
-
if _FILE_EXTENSION.search(value) or _LINE_NUMBER.search(value):
|
|
376
|
-
return False
|
|
377
|
-
return _LOWER_TOKEN.match(value) is None
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
def escape_token(token: str) -> str:
|
|
381
|
-
"""Escape one JSON Pointer reference token (RFC 6901)."""
|
|
382
|
-
return token.replace("~", "~0").replace("/", "~1")
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
def unescape_token(token: str) -> str:
|
|
386
|
-
return token.replace("~1", "/").replace("~0", "~")
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
def _walk(node: Any, pointer: str, keys: tuple[str, ...]) -> Iterator[tuple[str, str]]:
|
|
390
|
-
if isinstance(node, Mapping):
|
|
391
|
-
for child_key, value in node.items():
|
|
392
|
-
yield from _walk(
|
|
393
|
-
value, f"{pointer}/{escape_token(str(child_key))}", (*keys, str(child_key))
|
|
394
|
-
)
|
|
395
|
-
elif isinstance(node, (list, tuple)):
|
|
396
|
-
for index, value in enumerate(node):
|
|
397
|
-
# An array element inherits the key that named the array, so
|
|
398
|
-
# `evidence: [...]` translates each entry.
|
|
399
|
-
yield from _walk(value, f"{pointer}/{index}", keys)
|
|
400
|
-
elif isinstance(node, str) and _is_prose_field(keys) and is_translatable_value(node):
|
|
401
|
-
yield pointer, node
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
def extract(data: Mapping[str, Any]) -> dict[str, str]:
|
|
405
|
-
"""Map every translatable pointer to the English text at it.
|
|
406
|
-
|
|
407
|
-
The translator fills in this map rather than authoring pointers, so a
|
|
408
|
-
sidecar cannot cite a path the document does not have.
|
|
409
|
-
"""
|
|
410
|
-
return dict(_walk(data, "", ()))
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
def group_translation_pointers(
|
|
414
|
-
data: Mapping[str, Any], strings: Mapping[str, str],
|
|
415
|
-
) -> list[list[str]]:
|
|
416
|
-
"""배열 위치만 다른 동일 필드·원문을 한 번역 항목으로 묶는다."""
|
|
417
|
-
groups: dict[tuple[tuple[tuple[str, str], ...], str], list[str]] = {}
|
|
418
|
-
for pointer, source in strings.items():
|
|
419
|
-
node: Any = data
|
|
420
|
-
context: list[tuple[str, str]] = []
|
|
421
|
-
for token in pointer.split("/")[1:]:
|
|
422
|
-
if isinstance(node, (list, tuple)):
|
|
423
|
-
context.append(("index", ""))
|
|
424
|
-
node = node[int(token)]
|
|
425
|
-
else:
|
|
426
|
-
context.append(("key", token))
|
|
427
|
-
node = node[unescape_token(token)]
|
|
428
|
-
groups.setdefault((tuple(context), source), []).append(pointer)
|
|
429
|
-
return list(groups.values())
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
class OverlayReport(NamedTuple):
|
|
433
|
-
applied: int
|
|
434
|
-
# Pointers the extractor offered that the sidecar left untranslated. They
|
|
435
|
-
# render in English — a partial translation is a readable page, not a
|
|
436
|
-
# failure — but a silent fallback is how a half-empty sidecar ships
|
|
437
|
-
# unnoticed, so the count is reported.
|
|
438
|
-
untranslated: tuple[str, ...]
|
|
439
|
-
# Pointers the sidecar carries that do not resolve to a string here. A
|
|
440
|
-
# sidecar written against a different report.
|
|
441
|
-
unresolved: tuple[str, ...]
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
def _resolve_parent(data: Any, pointer: str) -> tuple[Any, str | int] | None:
|
|
445
|
-
tokens = [unescape_token(t) for t in pointer.split("/")[1:]]
|
|
446
|
-
if not tokens:
|
|
447
|
-
return None
|
|
448
|
-
node = data
|
|
449
|
-
for token in tokens[:-1]:
|
|
450
|
-
if isinstance(node, Mapping) and token in node:
|
|
451
|
-
node = node[token]
|
|
452
|
-
elif isinstance(node, list) and token.isdigit() and int(token) < len(node):
|
|
453
|
-
node = node[int(token)]
|
|
454
|
-
else:
|
|
455
|
-
return None
|
|
456
|
-
last = tokens[-1]
|
|
457
|
-
if isinstance(node, Mapping) and isinstance(node.get(last), str):
|
|
458
|
-
return node, last
|
|
459
|
-
if isinstance(node, list) and last.isdigit() and int(last) < len(node):
|
|
460
|
-
return (node, int(last)) if isinstance(node[int(last)], str) else None
|
|
461
|
-
return None
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
def overlay(
|
|
465
|
-
data: Mapping[str, Any], strings: Mapping[str, str]
|
|
466
|
-
) -> tuple[dict[str, Any], OverlayReport]:
|
|
467
|
-
"""Return a deep copy of ``data`` with the sidecar's strings substituted."""
|
|
468
|
-
import copy
|
|
469
|
-
|
|
470
|
-
out = copy.deepcopy(dict(data))
|
|
471
|
-
applied = 0
|
|
472
|
-
unresolved: list[str] = []
|
|
473
|
-
for pointer, text in strings.items():
|
|
474
|
-
target = _resolve_parent(out, pointer)
|
|
475
|
-
if target is None or not isinstance(text, str) or not text:
|
|
476
|
-
unresolved.append(pointer)
|
|
477
|
-
continue
|
|
478
|
-
parent, key = target
|
|
479
|
-
parent[key] = text
|
|
480
|
-
applied += 1
|
|
481
|
-
untranslated = tuple(sorted(set(extract(data)) - set(strings)))
|
|
482
|
-
return out, OverlayReport(applied, untranslated, tuple(sorted(unresolved)))
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
def _schema_string_keys(schema: Mapping[str, Any], *, enums: bool) -> set[str]:
|
|
486
|
-
found: set[str] = set()
|
|
487
|
-
|
|
488
|
-
def walk(node: Any) -> None:
|
|
489
|
-
if not isinstance(node, Mapping):
|
|
490
|
-
return
|
|
491
|
-
if node.get("type") == "object":
|
|
492
|
-
for key, child in (node.get("properties") or {}).items():
|
|
493
|
-
if isinstance(child, Mapping) and child.get("type") == "string":
|
|
494
|
-
if ("enum" in child) == enums:
|
|
495
|
-
found.add(str(key))
|
|
496
|
-
elif isinstance(child, Mapping) and enums and "enum" in child:
|
|
497
|
-
# An enum with no declared type is still an enum.
|
|
498
|
-
found.add(str(key))
|
|
499
|
-
walk(child)
|
|
500
|
-
elif node.get("type") == "array":
|
|
501
|
-
walk(node.get("items") or {})
|
|
502
|
-
for combinator in ("oneOf", "anyOf", "allOf"):
|
|
503
|
-
for branch in node.get(combinator) or []:
|
|
504
|
-
walk(branch)
|
|
505
|
-
|
|
506
|
-
for definition in (schema.get("$defs") or {}).values():
|
|
507
|
-
walk(definition)
|
|
508
|
-
walk(schema)
|
|
509
|
-
return found
|