ummaya 0.2.3 → 0.2.5

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.
Files changed (534) hide show
  1. package/README.md +17 -3
  2. package/bin/ummaya +10 -1
  3. package/npm-shrinkwrap.json +253 -2
  4. package/package.json +5 -1
  5. package/prompts/manifest.yaml +2 -2
  6. package/prompts/session_guidance_v1.md +3 -1
  7. package/prompts/system_v1.md +9 -7
  8. package/pyproject.toml +26 -7
  9. package/specs/2803-document-production-hardening/contracts/document-tools.schema.json +1043 -0
  10. package/src/ummaya/_canonical/__init__.py +2 -0
  11. package/src/ummaya/context/builder.py +17 -11
  12. package/src/ummaya/engine/engine.py +30 -113
  13. package/src/ummaya/engine/query.py +20 -0
  14. package/src/ummaya/evidence/__init__.py +44 -0
  15. package/src/ummaya/evidence/__main__.py +7 -0
  16. package/src/ummaya/evidence/dataset_contract.py +193 -0
  17. package/src/ummaya/evidence/document_authoring_cases.py +33 -0
  18. package/src/ummaya/evidence/document_harness.py +313 -0
  19. package/src/ummaya/evidence/document_viewer_ux.py +391 -0
  20. package/src/ummaya/evidence/gates.py +70 -0
  21. package/src/ummaya/evidence/json_types.py +20 -0
  22. package/src/ummaya/evidence/models.py +145 -0
  23. package/src/ummaya/evidence/output_payload.py +89 -0
  24. package/src/ummaya/evidence/payload_documents.py +233 -0
  25. package/src/ummaya/evidence/route_contracts.py +224 -0
  26. package/src/ummaya/evidence/route_helpers.py +150 -0
  27. package/src/ummaya/evidence/runner.py +177 -0
  28. package/src/ummaya/evidence/source_provenance.py +246 -0
  29. package/src/ummaya/evidence/source_provenance_redaction.py +176 -0
  30. package/src/ummaya/evidence/task_registry.py +264 -0
  31. package/src/ummaya/evidence/tool_layer.py +39 -0
  32. package/src/ummaya/evidence/tool_layer_models.py +151 -0
  33. package/src/ummaya/ipc/adapter_manifest_emitter.py +26 -10
  34. package/src/ummaya/ipc/document_intent_normalization.py +185 -0
  35. package/src/ummaya/ipc/frame_schema.py +52 -5
  36. package/src/ummaya/ipc/route_diagnostics.py +73 -0
  37. package/src/ummaya/ipc/stdio.py +2282 -417
  38. package/src/ummaya/llm/client.py +234 -59
  39. package/src/ummaya/llm/config.py +8 -3
  40. package/src/ummaya/llm/reasoning.py +84 -0
  41. package/src/ummaya/primitives/__init__.py +6 -2
  42. package/src/ummaya/primitives/delegation.py +1 -1
  43. package/src/ummaya/primitives/document.py +28 -0
  44. package/src/ummaya/settings.py +0 -3
  45. package/src/ummaya/tools/discovery_bridge.py +34 -2
  46. package/src/ummaya/tools/documents/__init__.py +297 -0
  47. package/src/ummaya/tools/documents/adapter_registry.py +487 -0
  48. package/src/ummaya/tools/documents/archive_container_probe.py +167 -0
  49. package/src/ummaya/tools/documents/artifact_store.py +454 -0
  50. package/src/ummaya/tools/documents/authoring.py +283 -0
  51. package/src/ummaya/tools/documents/baselines.py +114 -0
  52. package/src/ummaya/tools/documents/capability.py +331 -0
  53. package/src/ummaya/tools/documents/contracts.py +112 -0
  54. package/src/ummaya/tools/documents/conversion.py +521 -0
  55. package/src/ummaya/tools/documents/diff.py +275 -0
  56. package/src/ummaya/tools/documents/engines.py +163 -0
  57. package/src/ummaya/tools/documents/evaluation.py +291 -0
  58. package/src/ummaya/tools/documents/explicit_values.py +108 -0
  59. package/src/ummaya/tools/documents/fixtures.py +174 -0
  60. package/src/ummaya/tools/documents/format_completion_audit.py +471 -0
  61. package/src/ummaya/tools/documents/formats/__init__.py +2 -0
  62. package/src/ummaya/tools/documents/formats/archive.py +528 -0
  63. package/src/ummaya/tools/documents/formats/base.py +41 -0
  64. package/src/ummaya/tools/documents/formats/code_file.py +211 -0
  65. package/src/ummaya/tools/documents/formats/data_file.py +272 -0
  66. package/src/ummaya/tools/documents/formats/hwp.py +284 -0
  67. package/src/ummaya/tools/documents/formats/hwpx.py +1837 -0
  68. package/src/ummaya/tools/documents/formats/odf.py +435 -0
  69. package/src/ummaya/tools/documents/formats/ooxml.py +1030 -0
  70. package/src/ummaya/tools/documents/formats/passive.py +766 -0
  71. package/src/ummaya/tools/documents/formats/pdf.py +702 -0
  72. package/src/ummaya/tools/documents/formats/text_web.py +268 -0
  73. package/src/ummaya/tools/documents/hwp_conversion_probe.py +178 -0
  74. package/src/ummaya/tools/documents/hwp_direct_candidate.py +141 -0
  75. package/src/ummaya/tools/documents/inspection.py +289 -0
  76. package/src/ummaya/tools/documents/intake.py +1079 -0
  77. package/src/ummaya/tools/documents/legacy_office_promotion_probe.py +366 -0
  78. package/src/ummaya/tools/documents/models.py +1598 -0
  79. package/src/ummaya/tools/documents/odf_promotion_probe.py +167 -0
  80. package/src/ummaya/tools/documents/orchestrator.py +96 -0
  81. package/src/ummaya/tools/documents/passive_capability_probe.py +251 -0
  82. package/src/ummaya/tools/documents/patch.py +170 -0
  83. package/src/ummaya/tools/documents/pdfa_conformance.py +284 -0
  84. package/src/ummaya/tools/documents/pdfa_promotion_probe.py +198 -0
  85. package/src/ummaya/tools/documents/permissions.py +110 -0
  86. package/src/ummaya/tools/documents/planner.py +616 -0
  87. package/src/ummaya/tools/documents/registry.py +2733 -0
  88. package/src/ummaya/tools/documents/render.py +978 -0
  89. package/src/ummaya/tools/documents/render_comparison.py +113 -0
  90. package/src/ummaya/tools/documents/render_comparison_models.py +74 -0
  91. package/src/ummaya/tools/documents/render_comparison_regions.py +73 -0
  92. package/src/ummaya/tools/documents/render_comparison_style.py +161 -0
  93. package/src/ummaya/tools/documents/reread.py +157 -0
  94. package/src/ummaya/tools/documents/runtime_authoring.py +244 -0
  95. package/src/ummaya/tools/documents/runtime_authoring_bundle.py +76 -0
  96. package/src/ummaya/tools/documents/scorecard.py +184 -0
  97. package/src/ummaya/tools/documents/socratic_planner.py +193 -0
  98. package/src/ummaya/tools/documents/style.py +48 -0
  99. package/src/ummaya/tools/documents/tool_defs.py +523 -0
  100. package/src/ummaya/tools/documents/validate.py +347 -0
  101. package/src/ummaya/tools/executor.py +61 -12
  102. package/src/ummaya/tools/geocoding/kakao_client.py +1 -2
  103. package/src/ummaya/tools/kma/apihub_catalog.py +984 -1
  104. package/src/ummaya/tools/kma/apihub_structured_adapter.py +86 -6
  105. package/src/ummaya/tools/kma/apihub_url_adapter.py +593 -0
  106. package/src/ummaya/tools/kma/apihub_url_catalog.py +296 -0
  107. package/src/ummaya/tools/live_proxy.py +0 -3
  108. package/src/ummaya/tools/location_adapters.py +8 -6
  109. package/src/ummaya/tools/manifest_metadata.py +16 -3
  110. package/src/ummaya/tools/models.py +5 -1
  111. package/src/ummaya/tools/mvp_surface.py +2 -2
  112. package/src/ummaya/tools/nmc/emergency_search.py +8 -6
  113. package/src/ummaya/tools/register_all.py +17 -0
  114. package/src/ummaya/tools/registry.py +10 -1
  115. package/src/ummaya/tools/resolve_location.py +4 -4
  116. package/src/ummaya/tools/routing/__init__.py +59 -0
  117. package/src/ummaya/tools/routing/builder.py +105 -0
  118. package/src/ummaya/tools/routing/cards.py +29 -0
  119. package/src/ummaya/tools/routing/decision_service.py +534 -0
  120. package/src/ummaya/tools/routing/decision_types.py +74 -0
  121. package/src/ummaya/tools/routing/feasibility.py +122 -0
  122. package/src/ummaya/tools/routing/intent.py +17 -0
  123. package/src/ummaya/tools/routing/intent_extractor.py +207 -0
  124. package/src/ummaya/tools/routing/intent_patterns.py +160 -0
  125. package/src/ummaya/tools/routing/intent_public_data.py +150 -0
  126. package/src/ummaya/tools/routing/intent_types.py +48 -0
  127. package/src/ummaya/tools/routing/lint.py +78 -0
  128. package/src/ummaya/tools/routing/metadata.py +174 -0
  129. package/src/ummaya/tools/routing/projection.py +340 -0
  130. package/src/ummaya/tools/routing/retrieval_policy.py +629 -0
  131. package/src/ummaya/tools/routing/schema.py +81 -0
  132. package/src/ummaya/tools/routing/types.py +96 -0
  133. package/src/ummaya/tools/routing_index.py +2 -2
  134. package/src/ummaya/tools/search.py +40 -106
  135. package/src/ummaya/tools/verified_data_go_kr/_manifest.py +115 -25
  136. package/src/ummaya/tools/verified_data_go_kr/airkorea_air_quality.py +109 -4
  137. package/src/ummaya/tools/verified_data_go_kr/nmc_aed_site.py +108 -2
  138. package/src/ummaya/tools/verified_data_go_kr/pps_bid_public_info.py +174 -9
  139. package/src/ummaya/tools/verified_data_go_kr/tago_bus_arrival.py +66 -3
  140. package/src/ummaya/tools/verified_data_go_kr/tago_bus_location.py +12 -2
  141. package/src/ummaya/tools/verified_data_go_kr/tago_bus_route.py +8 -2
  142. package/src/ummaya/tools/verified_data_go_kr/tago_bus_route_station.py +114 -0
  143. package/src/ummaya/tools/verified_data_go_kr/tago_bus_station.py +14 -3
  144. package/src/ummaya/tools/verify_canonical_map.py +21 -0
  145. package/tests/fixtures/documents/public_forms/baselines.yaml +113 -0
  146. package/tui/package.json +1 -2
  147. package/tui/src/.cc-byte-identical-whitelist.yaml +266 -0
  148. package/tui/src/QueryEngine.ts +12 -4
  149. package/tui/src/bridge/inboundAttachments.ts +3 -3
  150. package/tui/src/cli/handlers/auth.ts +4 -13
  151. package/tui/src/cli/handlers/mcp.tsx +3 -3
  152. package/tui/src/cli/print.ts +69 -18
  153. package/tui/src/cli/update.ts +13 -13
  154. package/tui/src/commands/copy/index.ts +1 -1
  155. package/tui/src/commands/cost/cost.ts +2 -2
  156. package/tui/src/commands/init-verifiers.ts +5 -5
  157. package/tui/src/commands/init.ts +30 -30
  158. package/tui/src/commands/insights.ts +44 -44
  159. package/tui/src/commands/install-github-app/install-github-app.tsx +2 -2
  160. package/tui/src/commands/install-github-app/setupGitHubActions.ts +3 -3
  161. package/tui/src/commands/install-github-app/types.ts +8 -30
  162. package/tui/src/commands/install.tsx +5 -5
  163. package/tui/src/commands/mcp/addCommand.ts +5 -5
  164. package/tui/src/commands/mcp/xaaIdpCommand.ts +2 -2
  165. package/tui/src/commands/plugin/ManageMarketplaces.tsx +2 -2
  166. package/tui/src/commands/plugin/types.ts +6 -28
  167. package/tui/src/commands/plugin/unifiedTypes.ts +4 -26
  168. package/tui/src/commands/reasoning/index.ts +13 -0
  169. package/tui/src/commands/reasoning/reasoning.tsx +177 -0
  170. package/tui/src/commands/rename/generateSessionName.ts +1 -1
  171. package/tui/src/commands/thinkback/thinkback.tsx +3 -3
  172. package/tui/src/commands.ts +2 -0
  173. package/tui/src/components/Feedback.tsx +1 -1
  174. package/tui/src/components/LogoV2/EmergencyTip.tsx +11 -2
  175. package/tui/src/components/LogoV2/WelcomeV2.tsx +1 -3
  176. package/tui/src/components/Messages.tsx +2 -1
  177. package/tui/src/components/ScrollKeybindingHandler.tsx +6 -6
  178. package/tui/src/components/Spinner/types.ts +6 -28
  179. package/tui/src/components/Spinner.tsx +2 -2
  180. package/tui/src/components/agents/generateAgent.ts +1 -1
  181. package/tui/src/components/agents/new-agent-creation/types.ts +4 -26
  182. package/tui/src/components/config/EnvSecretIsolatedEditor.tsx +1 -1
  183. package/tui/src/components/design-system/LoadingState.tsx +2 -2
  184. package/tui/src/components/mcp/types.ts +16 -38
  185. package/tui/src/components/messages/AssistantToolUseMessage.tsx +3 -2
  186. package/tui/src/components/messages/UserCrossSessionMessage.ts +16 -4
  187. package/tui/src/components/messages/UserForkBoilerplateMessage.ts +16 -4
  188. package/tui/src/components/messages/UserGitHubWebhookMessage.ts +16 -4
  189. package/tui/src/components/messages/UserToolResultMessage/utils.tsx +3 -2
  190. package/tui/src/components/permissions/MonitorPermissionRequest/MonitorPermissionRequest.ts +9 -4
  191. package/tui/src/components/permissions/ReviewArtifactPermissionRequest/ReviewArtifactPermissionRequest.ts +9 -4
  192. package/tui/src/components/primitive/DocumentSocraticReviewBlock.tsx +129 -0
  193. package/tui/src/components/primitive/DocumentToolResultCard.tsx +224 -0
  194. package/tui/src/components/primitive/documentSocraticReview.ts +215 -0
  195. package/tui/src/components/primitive/index.tsx +43 -1
  196. package/tui/src/components/primitive/types.ts +137 -0
  197. package/tui/src/components/ui/option.ts +4 -26
  198. package/tui/src/constants/common.ts +0 -2
  199. package/tui/src/constants/prompts.ts +4 -3
  200. package/tui/src/constants/querySource.ts +4 -26
  201. package/tui/src/entrypoints/sdk/controlTypes.ts +26 -48
  202. package/tui/src/entrypoints/sdk/coreTypes.generated.ts +3 -25
  203. package/tui/src/entrypoints/sdk/runtimeTypes.ts +38 -60
  204. package/tui/src/entrypoints/sdk/sdkUtilityTypes.ts +4 -26
  205. package/tui/src/entrypoints/sdk/settingsTypes.generated.ts +3 -25
  206. package/tui/src/entrypoints/sdk/toolTypes.ts +3 -25
  207. package/tui/src/hooks/toolPermission/handlers/interactiveHandler.ts +10 -0
  208. package/tui/src/hooks/useApiKeyVerification.ts +1 -1
  209. package/tui/src/hooks/useVirtualScroll.ts +1 -1
  210. package/tui/src/ink/ink.tsx +33 -14
  211. package/tui/src/ink/reconciler.ts +2 -3
  212. package/tui/src/ink/render-to-screen.ts +30 -10
  213. package/tui/src/ipc/bridge.ts +62 -15
  214. package/tui/src/ipc/bridgeSingleton.ts +5 -1
  215. package/tui/src/ipc/codec.ts +29 -3
  216. package/tui/src/ipc/frames.generated.ts +407 -312
  217. package/tui/src/ipc/llmClient.ts +279 -76
  218. package/tui/src/ipc/llmTypes.ts +16 -1
  219. package/tui/src/ipc/schema/frame.schema.json +1 -3475
  220. package/tui/src/keybindings/defaultBindings.ts +4 -0
  221. package/tui/src/main.tsx +32 -11
  222. package/tui/src/native-ts/file-index/index.ts +33 -3
  223. package/tui/src/observability/surface.ts +2 -2
  224. package/tui/src/probes/toolRegistryProbe.tsx +3 -1
  225. package/tui/src/projectOnboardingState.ts +7 -6
  226. package/tui/src/query/chatMessageTypes.ts +18 -0
  227. package/tui/src/query/chatMessagesBuilder.ts +1 -1
  228. package/tui/src/query/deps.ts +1 -1
  229. package/tui/src/query/messageGuards.ts +106 -0
  230. package/tui/src/query/publicDataTerminalRepair.ts +384 -0
  231. package/tui/src/query/run.ts +1075 -0
  232. package/tui/src/query/supportBoundary.ts +168 -0
  233. package/tui/src/query/toolResultErrors.ts +103 -0
  234. package/tui/src/query/toolRunner.ts +687 -0
  235. package/tui/src/query/unavailableToolRepair.ts +118 -0
  236. package/tui/src/query.ts +9 -1721
  237. package/tui/src/screens/REPL.tsx +42 -31
  238. package/tui/src/services/api/adapterManifest.ts +4 -0
  239. package/tui/src/services/api/backendChat/events.ts +117 -0
  240. package/tui/src/services/api/backendChat/finalMessage.ts +40 -0
  241. package/tui/src/services/api/backendChat/frame.ts +9 -0
  242. package/tui/src/services/api/backendChat/streaming.ts +430 -0
  243. package/tui/src/services/api/backendChat/types.ts +62 -0
  244. package/tui/src/services/api/backendChat.ts +1 -0
  245. package/tui/src/services/api/client.ts +98 -14
  246. package/tui/src/services/api/errorUtils.ts +5 -5
  247. package/tui/src/services/api/errors.ts +1 -1
  248. package/tui/src/services/api/logging.ts +1 -1
  249. package/tui/src/services/api/ummaya/evidence.ts +194 -0
  250. package/tui/src/services/api/ummaya/messages.ts +255 -0
  251. package/tui/src/services/api/ummaya/nonStreaming.ts +66 -0
  252. package/tui/src/services/api/ummaya/provider.ts +200 -0
  253. package/tui/src/services/api/ummaya/reasoning.ts +24 -0
  254. package/tui/src/services/api/ummaya/request.ts +200 -0
  255. package/tui/src/services/api/ummaya/selectionContext.ts +240 -0
  256. package/tui/src/services/api/ummaya/streaming.ts +365 -0
  257. package/tui/src/services/api/ummaya/streamingPayload.ts +129 -0
  258. package/tui/src/services/api/ummaya/streamingReader.ts +40 -0
  259. package/tui/src/services/api/ummaya/toolSelection.ts +217 -0
  260. package/tui/src/services/api/ummaya/types.ts +110 -0
  261. package/tui/src/services/api/ummaya/usage.ts +30 -0
  262. package/tui/src/services/api/ummaya.ts +26 -364
  263. package/tui/src/services/api/withRetry.ts +1 -1
  264. package/tui/src/services/awaySummary.ts +2 -2
  265. package/tui/src/services/claudeAiLimits.ts +1 -1
  266. package/tui/src/services/compact/autoCompact.ts +1 -1
  267. package/tui/src/services/compact/compact.ts +1 -1
  268. package/tui/src/services/lsp/types.ts +8 -30
  269. package/tui/src/services/tips/types.ts +6 -28
  270. package/tui/src/services/tokenEstimation.ts +1 -1
  271. package/tui/src/services/toolRegistry/bootGuard.ts +5 -5
  272. package/tui/src/services/toolUseSummary/toolUseSummaryGenerator.ts +1 -1
  273. package/tui/src/services/tools/toolExecution.ts +94 -1
  274. package/tui/src/skills/bundled/stuck.ts +12 -12
  275. package/tui/src/state/AppStateStore.ts +7 -0
  276. package/tui/src/store/pendingPermissionSlot.ts +1 -1
  277. package/tui/src/store/session-store.ts +10 -36
  278. package/tui/src/stubs/any-stub.ts +15 -10
  279. package/tui/src/stubs/color-diff-napi.ts +37 -23
  280. package/tui/src/stubs/globals.d.ts +3 -3
  281. package/tui/src/stubs/macro-preload.ts +23 -12
  282. package/tui/src/tools/AdapterTool/AdapterTool.ts +1239 -163
  283. package/tui/src/tools/AdapterTool/routeDiagnostics.ts +75 -0
  284. package/tui/src/tools/AgentTool/AgentTool.tsx +84 -1371
  285. package/tui/src/tools/AgentTool/agentToolHandoff.ts +114 -0
  286. package/tui/src/tools/AgentTool/agentToolPartialResult.ts +16 -0
  287. package/tui/src/tools/AgentTool/agentToolProgress.ts +32 -0
  288. package/tui/src/tools/AgentTool/agentToolResolver.ts +161 -0
  289. package/tui/src/tools/AgentTool/agentToolResult.ts +163 -0
  290. package/tui/src/tools/AgentTool/agentToolUtils.ts +14 -686
  291. package/tui/src/tools/AgentTool/asyncAgentLifecycle.ts +208 -0
  292. package/tui/src/tools/AgentTool/asyncLifecycle.ts +153 -0
  293. package/tui/src/tools/AgentTool/backgroundedCompletion.ts +126 -0
  294. package/tui/src/tools/AgentTool/backgroundedLifecycle.ts +174 -0
  295. package/tui/src/tools/AgentTool/foregroundBackground.ts +83 -0
  296. package/tui/src/tools/AgentTool/foregroundDrain.tsx +133 -0
  297. package/tui/src/tools/AgentTool/foregroundFinalize.ts +98 -0
  298. package/tui/src/tools/AgentTool/foregroundLifecycle.tsx +237 -0
  299. package/tui/src/tools/AgentTool/foregroundProgress.tsx +169 -0
  300. package/tui/src/tools/AgentTool/foregroundTask.ts +89 -0
  301. package/tui/src/tools/AgentTool/forkSubagent.ts +1 -12
  302. package/tui/src/tools/AgentTool/forkSubagentGate.ts +34 -0
  303. package/tui/src/tools/AgentTool/launchRouting.ts +203 -0
  304. package/tui/src/tools/AgentTool/lifecycle.ts +244 -0
  305. package/tui/src/tools/AgentTool/mcpRouting.ts +73 -0
  306. package/tui/src/tools/AgentTool/orchestrationSupport.ts +70 -0
  307. package/tui/src/tools/AgentTool/permissions.ts +39 -0
  308. package/tui/src/tools/AgentTool/promptSetup.ts +181 -0
  309. package/tui/src/tools/AgentTool/remoteRouting.ts +62 -0
  310. package/tui/src/tools/AgentTool/resultMapping.ts +116 -0
  311. package/tui/src/tools/AgentTool/resumeAgent.ts +39 -107
  312. package/tui/src/tools/AgentTool/resumeAgentHelpers.ts +140 -0
  313. package/tui/src/tools/AgentTool/runAgent.ts +1 -1
  314. package/tui/src/tools/AgentTool/runtimeConfig.ts +57 -0
  315. package/tui/src/tools/AgentTool/schemas.ts +196 -0
  316. package/tui/src/tools/AgentTool/sourceVerificationPropagation.ts +263 -0
  317. package/tui/src/tools/AgentTool/worktreeLifecycle.ts +105 -0
  318. package/tui/src/tools/AskUserQuestionTool/AskUserQuestionTool.tsx +174 -202
  319. package/tui/src/tools/BashTool/BashTool.tsx +71 -1072
  320. package/tui/src/tools/BashTool/bashCommandHelpers.ts +12 -12
  321. package/tui/src/tools/BashTool/bashPermissions/astPreflight.ts +173 -0
  322. package/tui/src/tools/BashTool/bashPermissions/classifierChecks.ts +199 -0
  323. package/tui/src/tools/BashTool/bashPermissions/compoundGuards.ts +53 -0
  324. package/tui/src/tools/BashTool/bashPermissions/constants.ts +99 -0
  325. package/tui/src/tools/BashTool/bashPermissions/index.ts +38 -0
  326. package/tui/src/tools/BashTool/bashPermissions/legacyMisparsing.ts +62 -0
  327. package/tui/src/tools/BashTool/bashPermissions/main.ts +135 -0
  328. package/tui/src/tools/BashTool/bashPermissions/normalizedCommands.ts +33 -0
  329. package/tui/src/tools/BashTool/bashPermissions/operatorFlow.ts +98 -0
  330. package/tui/src/tools/BashTool/bashPermissions/permissionChecks.ts +200 -0
  331. package/tui/src/tools/BashTool/bashPermissions/prefixSuggestions.ts +88 -0
  332. package/tui/src/tools/BashTool/bashPermissions/promptClassifierRules.ts +125 -0
  333. package/tui/src/tools/BashTool/bashPermissions/ruleDelegates.ts +19 -0
  334. package/tui/src/tools/BashTool/bashPermissions/ruleMatching.ts +145 -0
  335. package/tui/src/tools/BashTool/bashPermissions/sandboxAutoAllow.ts +75 -0
  336. package/tui/src/tools/BashTool/bashPermissions/subcommandFlow.ts +205 -0
  337. package/tui/src/tools/BashTool/bashPermissions/subcommandGuards.ts +73 -0
  338. package/tui/src/tools/BashTool/bashPermissions/subcommandResultHelpers.ts +116 -0
  339. package/tui/src/tools/BashTool/bashPermissions/types.ts +26 -0
  340. package/tui/src/tools/BashTool/bashPermissions/wrapperStripping.ts +139 -0
  341. package/tui/src/tools/BashTool/bashPermissions.ts +26 -2621
  342. package/tui/src/tools/BashTool/call.ts +202 -0
  343. package/tui/src/tools/BashTool/callLoader.ts +35 -0
  344. package/tui/src/tools/BashTool/commandClassification.ts +151 -0
  345. package/tui/src/tools/BashTool/commandClassificationLoader.ts +40 -0
  346. package/tui/src/tools/BashTool/cwdReset.ts +33 -0
  347. package/tui/src/tools/BashTool/lineTruncation.ts +11 -0
  348. package/tui/src/tools/BashTool/modeValidation.ts +13 -1
  349. package/tui/src/tools/BashTool/outputPersistence.ts +42 -0
  350. package/tui/src/tools/BashTool/permissionClassification.ts +66 -0
  351. package/tui/src/tools/BashTool/permissionLoader.ts +44 -0
  352. package/tui/src/tools/BashTool/resultLoader.ts +29 -0
  353. package/tui/src/tools/BashTool/resultMapping.ts +83 -0
  354. package/tui/src/tools/BashTool/sandboxPolicy.ts +79 -0
  355. package/tui/src/tools/BashTool/schemas.ts +65 -0
  356. package/tui/src/tools/BashTool/sedEditExecution.ts +59 -0
  357. package/tui/src/tools/BashTool/shellExecution.tsx +245 -0
  358. package/tui/src/tools/BashTool/shellOutputUtils.ts +85 -0
  359. package/tui/src/tools/BashTool/shellPermissionGauntlet.ts +97 -0
  360. package/tui/src/tools/BashTool/uiLoader.ts +37 -0
  361. package/tui/src/tools/BriefTool/upload.ts +1 -1
  362. package/tui/src/tools/CalculatorTool/parser.ts +2 -2
  363. package/tui/src/tools/DocumentPrimitive/DocumentPrimitive.ts +262 -0
  364. package/tui/src/tools/DocumentPrimitive/dispatchNormalization.ts +270 -0
  365. package/tui/src/tools/DocumentPrimitive/documentDestinationPath.ts +18 -0
  366. package/tui/src/tools/DocumentPrimitive/documentMutationGuard.ts +22 -0
  367. package/tui/src/tools/DocumentPrimitive/documentPatchNormalization.ts +248 -0
  368. package/tui/src/tools/DocumentPrimitive/documentSourceVerification.ts +245 -0
  369. package/tui/src/tools/DocumentPrimitive/documentSourceVerificationFields.ts +103 -0
  370. package/tui/src/tools/DocumentPrimitive/modelVisibleOutput.ts +40 -0
  371. package/tui/src/tools/DocumentPrimitive/prompt.ts +35 -0
  372. package/tui/src/tools/FileEditTool/FileEditTool.ts +9 -507
  373. package/tui/src/tools/FileEditTool/call.ts +228 -0
  374. package/tui/src/tools/FileEditTool/validateInput.ts +196 -0
  375. package/tui/src/tools/FileReadTool/imageProcessor.ts +13 -0
  376. package/tui/src/tools/FileWriteTool/FileWriteTool.ts +7 -300
  377. package/tui/src/tools/FileWriteTool/call.ts +223 -0
  378. package/tui/src/tools/FileWriteTool/validateInput.ts +80 -0
  379. package/tui/src/tools/ListMcpResourcesTool/ListMcpResourcesTool.ts +19 -3
  380. package/tui/src/tools/LookupPrimitive/LookupPrimitive.ts +48 -29
  381. package/tui/src/tools/LookupPrimitive/prompt.ts +6 -7
  382. package/tui/src/tools/MCPTool/trustPolicy.ts +118 -0
  383. package/tui/src/tools/McpAuthTool/McpAuthTool.ts +21 -3
  384. package/tui/src/tools/NotebookEditTool/NotebookEditTool.ts +7 -326
  385. package/tui/src/tools/NotebookEditTool/call.ts +254 -0
  386. package/tui/src/tools/NotebookEditTool/notebookModel.ts +51 -0
  387. package/tui/src/tools/NotebookEditTool/validateInput.ts +142 -0
  388. package/tui/src/tools/PowerShellTool/PowerShellTool.tsx +46 -937
  389. package/tui/src/tools/PowerShellTool/acceptEditsCommandValidation.ts +162 -0
  390. package/tui/src/tools/PowerShellTool/call.ts +179 -0
  391. package/tui/src/tools/PowerShellTool/callLoader.ts +37 -0
  392. package/tui/src/tools/PowerShellTool/commandClassification.ts +86 -0
  393. package/tui/src/tools/PowerShellTool/modeValidation.ts +25 -332
  394. package/tui/src/tools/PowerShellTool/outputPersistence.ts +42 -0
  395. package/tui/src/tools/PowerShellTool/permissionClassification.ts +28 -0
  396. package/tui/src/tools/PowerShellTool/resultLoader.ts +31 -0
  397. package/tui/src/tools/PowerShellTool/resultMapping.ts +75 -0
  398. package/tui/src/tools/PowerShellTool/schemas.ts +40 -0
  399. package/tui/src/tools/PowerShellTool/shellExecution.tsx +258 -0
  400. package/tui/src/tools/PowerShellTool/symlinkModeValidation.ts +44 -0
  401. package/tui/src/tools/PowerShellTool/uiLoader.ts +37 -0
  402. package/tui/src/tools/PowerShellTool/validation.ts +39 -0
  403. package/tui/src/tools/ReadMcpResourceTool/ReadMcpResourceTool.ts +19 -3
  404. package/tui/src/tools/ResolveLocationPrimitive/ResolveLocationPrimitive.ts +30 -19
  405. package/tui/src/tools/ResolveLocationPrimitive/prompt.ts +2 -6
  406. package/tui/src/tools/SkillTool/SkillTool.ts +2 -2
  407. package/tui/src/tools/SubmitPrimitive/SubmitPrimitive.ts +51 -18
  408. package/tui/src/tools/TaskCreateTool/TaskCreateTool.ts +16 -2
  409. package/tui/src/tools/TaskGetTool/TaskGetTool.ts +23 -3
  410. package/tui/src/tools/TaskListTool/TaskListTool.ts +22 -4
  411. package/tui/src/tools/TaskOutputTool/TaskOutputTool.tsx +46 -547
  412. package/tui/src/tools/TaskOutputTool/lookup.ts +216 -0
  413. package/tui/src/tools/TaskOutputTool/render.tsx +257 -0
  414. package/tui/src/tools/TaskOutputTool/schemas.ts +55 -0
  415. package/tui/src/tools/TaskOutputTool/serialization.ts +36 -0
  416. package/tui/src/tools/TaskStopTool/TaskStopTool.ts +10 -0
  417. package/tui/src/tools/TaskUpdateTool/TaskUpdateTool.ts +14 -364
  418. package/tui/src/tools/TaskUpdateTool/completion.ts +62 -0
  419. package/tui/src/tools/TaskUpdateTool/schemas.ts +62 -0
  420. package/tui/src/tools/TaskUpdateTool/serialization.ts +46 -0
  421. package/tui/src/tools/TaskUpdateTool/statusUpdate.ts +247 -0
  422. package/tui/src/tools/TodoWriteTool/TodoWriteTool.ts +21 -2
  423. package/tui/src/tools/ToolSearchTool/ToolSearchTool.ts +21 -302
  424. package/tui/src/tools/ToolSearchTool/ccSupportTools.ts +223 -0
  425. package/tui/src/tools/ToolSearchTool/descriptionCache.ts +50 -0
  426. package/tui/src/tools/ToolSearchTool/keywordSearch.ts +216 -0
  427. package/tui/src/tools/ToolSearchTool/prompt.ts +10 -4
  428. package/tui/src/tools/ToolSearchTool/resultMapping.ts +30 -0
  429. package/tui/src/tools/ToolSearchTool/schemas.ts +30 -0
  430. package/tui/src/tools/ToolSearchTool/searchPool.ts +47 -0
  431. package/tui/src/tools/ToolSearchTool/supportIntentHints.ts +140 -0
  432. package/tui/src/tools/TranslateTool/TranslateTool.ts +1 -1
  433. package/tui/src/tools/VerifyPrimitive/VerifyPrimitive.ts +27 -10
  434. package/tui/src/tools/WebFetchTool/WebFetchTool.ts +43 -138
  435. package/tui/src/tools/WebFetchTool/call.ts +227 -0
  436. package/tui/src/tools/WebFetchTool/resolvedAddressSafety.ts +78 -0
  437. package/tui/src/tools/WebFetchTool/sourceVerification.ts +204 -0
  438. package/tui/src/tools/WebFetchTool/types.ts +23 -0
  439. package/tui/src/tools/WebFetchTool/urlSafety.ts +181 -0
  440. package/tui/src/tools/WebFetchTool/utils.ts +1 -1
  441. package/tui/src/tools/WebSearchTool/UI.tsx +0 -1
  442. package/tui/src/tools/WebSearchTool/WebSearchTool.ts +9 -313
  443. package/tui/src/tools/WebSearchTool/call.ts +33 -0
  444. package/tui/src/tools/WebSearchTool/responseMapping.ts +190 -0
  445. package/tui/src/tools/WebSearchTool/resultBlock.ts +47 -0
  446. package/tui/src/tools/WebSearchTool/schemas.ts +47 -0
  447. package/tui/src/tools/WebSearchTool/toolSchema.ts +12 -0
  448. package/tui/src/tools/WorkspaceToolAdapter/WorkspaceToolAdapter.ts +79 -0
  449. package/tui/src/tools/WorkspaceToolAdapter/allowedRootPolicy.ts +85 -0
  450. package/tui/src/tools/WorkspaceToolAdapter/documentFormatGuards.ts +73 -0
  451. package/tui/src/tools/WorkspaceToolAdapter/inputNormalization.ts +105 -0
  452. package/tui/src/tools/WorkspaceToolAdapter/mcpExposurePolicy.ts +64 -0
  453. package/tui/src/tools/WorkspaceToolAdapter/toolDefFactory.ts +215 -0
  454. package/tui/src/tools/WorkspaceToolAdapter/toolNames.ts +6 -0
  455. package/tui/src/tools/WorkspaceToolAdapter/workspacePolicy.ts +15 -0
  456. package/tui/src/tools/_shared/citizenUserText.ts +49 -0
  457. package/tui/src/tools/_shared/dispatchPrimitive.ts +6 -6
  458. package/tui/src/tools/_shared/documentChangeToPatch.ts +125 -0
  459. package/tui/src/tools/_shared/documentDispatchArguments.ts +87 -0
  460. package/tui/src/tools/_shared/documentPrimitiveTimeout.ts +13 -0
  461. package/tui/src/tools/_shared/documentToolResultRender.ts +98 -0
  462. package/tui/src/tools/_shared/locationInputRepair.ts +112 -0
  463. package/tui/src/tools/_shared/pendingCallRegistry.ts +1 -6
  464. package/tui/src/tools/_shared/rootPrimitiveInput.ts +68 -0
  465. package/tui/src/tools/_shared/toolChoiceRepair/documentCompletionPatterns.ts +58 -0
  466. package/tui/src/tools/_shared/toolChoiceRepair/documentCompletionPrompt.ts +271 -0
  467. package/tui/src/tools/_shared/toolChoiceRepair/documentRepair.ts +452 -0
  468. package/tui/src/tools/_shared/toolChoiceRepair/messageAccess.ts +80 -0
  469. package/tui/src/tools/_shared/toolChoiceRepair/publicDataRepair.ts +92 -0
  470. package/tui/src/tools/_shared/toolChoiceRepair/supportRepair.ts +135 -0
  471. package/tui/src/tools/_shared/toolChoiceRepair.ts +61 -0
  472. package/tui/src/tools/shared/mockDisclaimer.ts +1 -1
  473. package/tui/src/tools.ts +39 -190
  474. package/tui/src/types/fileSuggestion.ts +4 -26
  475. package/tui/src/types/generated/events_mono/claude_code/v1/claude_code_internal_event.ts +186 -148
  476. package/tui/src/types/generated/events_mono/common/v1/auth.ts +25 -11
  477. package/tui/src/types/generated/events_mono/growthbook/v1/growthbook_experiment_event.ts +47 -30
  478. package/tui/src/types/generated/google/protobuf/timestamp.ts +21 -7
  479. package/tui/src/types/message.ts +80 -102
  480. package/tui/src/types/messageQueueTypes.ts +6 -28
  481. package/tui/src/types/notebook.ts +16 -38
  482. package/tui/src/types/statusLine.ts +4 -26
  483. package/tui/src/types/tools.ts +24 -46
  484. package/tui/src/types/utils.ts +6 -28
  485. package/tui/src/upstreamproxy/relay.ts +7 -3
  486. package/tui/src/upstreamproxy/upstreamproxy.ts +1 -1
  487. package/tui/src/utils/assistantMessageFactories.ts +9 -3
  488. package/tui/src/utils/attachments.ts +1 -1
  489. package/tui/src/utils/auth.ts +129 -139
  490. package/tui/src/utils/bash/ast.ts +23 -23
  491. package/tui/src/utils/bash/bashParser.ts +5 -5
  492. package/tui/src/utils/billing.ts +1 -1
  493. package/tui/src/utils/collapseReadSearch.ts +3 -3
  494. package/tui/src/utils/cronTasks.ts +1 -1
  495. package/tui/src/utils/execFileNoThrow.ts +1 -1
  496. package/tui/src/utils/filePersistence/types.ts +16 -38
  497. package/tui/src/utils/forkedAgent.ts +1 -1
  498. package/tui/src/utils/gracefulShutdown.ts +4 -4
  499. package/tui/src/utils/heapDumpService.ts +12 -8
  500. package/tui/src/utils/hooks/apiQueryHookHelper.ts +1 -1
  501. package/tui/src/utils/hooks/execPromptHook.ts +1 -1
  502. package/tui/src/utils/hooks/skillImprovement.ts +1 -1
  503. package/tui/src/utils/kExaoneReasoning.ts +138 -0
  504. package/tui/src/utils/mcp/dateTimeParser.ts +1 -1
  505. package/tui/src/utils/messages.ts +19 -0
  506. package/tui/src/utils/migrateSessions.ts +3 -3
  507. package/tui/src/utils/model/model.ts +6 -6
  508. package/tui/src/utils/multiToolLayout.ts +13 -0
  509. package/tui/src/utils/permissions/yoloClassifier.ts +1 -1
  510. package/tui/src/utils/plugins/headlessPluginInstall.ts +1 -1
  511. package/tui/src/utils/plugins/mcpPluginIntegration.ts +1 -1
  512. package/tui/src/utils/plugins/mcpbHandler.ts +1 -1
  513. package/tui/src/utils/plugins/pluginLoader.ts +8 -8
  514. package/tui/src/utils/processUserInput/processSlashCommand.tsx +2 -2
  515. package/tui/src/utils/processUserInput/processUserInput.ts +26 -0
  516. package/tui/src/utils/protectedNamespace.ts +5 -3
  517. package/tui/src/utils/rawJsonToolCall.ts +242 -0
  518. package/tui/src/utils/ripgrep.ts +16 -7
  519. package/tui/src/utils/sessionTitle.ts +1 -1
  520. package/tui/src/utils/settings/applySettingsChange.ts +4 -0
  521. package/tui/src/utils/settings/permissionValidation.ts +14 -2
  522. package/tui/src/utils/settings/types.ts +9 -3
  523. package/tui/src/utils/shell/prefix.ts +1 -1
  524. package/tui/src/utils/sideQuery.ts +1 -1
  525. package/tui/src/utils/stats.ts +1 -1
  526. package/tui/src/utils/systemThemeWatcher.ts +13 -3
  527. package/tui/src/utils/teleport.tsx +1 -1
  528. package/uv.lock +394 -22
  529. package/assets/copilot-gate-logo.svg +0 -58
  530. package/assets/govon-logo.svg +0 -40
  531. package/src/ummaya/eval/__init__.py +0 -5
  532. package/src/ummaya/eval/retrieval.py +0 -713
  533. package/tui/src/services/api/claude.ts +0 -3510
  534. package/tui/src/utils/messageStream.ts +0 -186
@@ -0,0 +1,978 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Renderer facade for local document evidence artifacts."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import hashlib
7
+ import html
8
+ import re
9
+ import shutil
10
+ import subprocess
11
+ from decimal import Decimal
12
+ from pathlib import Path
13
+ from typing import Protocol, cast, runtime_checkable
14
+
15
+ from pydantic import BaseModel, ConfigDict, Field
16
+
17
+ from ummaya.tools.documents.artifact_store import (
18
+ ArtifactStoreConflictError,
19
+ DocumentArtifactStore,
20
+ )
21
+ from ummaya.tools.documents.diff import RenderArtifactRecord
22
+ from ummaya.tools.documents.engines import (
23
+ DocumentEngineRegistry,
24
+ DocumentInspectionEngine,
25
+ UnsupportedDocumentEngineError,
26
+ )
27
+ from ummaya.tools.documents.models import (
28
+ ArtifactLineage,
29
+ BlockedReason,
30
+ DocumentArtifact,
31
+ DocumentChange,
32
+ DocumentChangedViewport,
33
+ DocumentClipRect,
34
+ DocumentDiff,
35
+ DocumentFormat,
36
+ DocumentViewportCamera,
37
+ PromotionCapability,
38
+ PromotionChecklistItem,
39
+ PromotionChecklistStatus,
40
+ PromotionGateResult,
41
+ PromotionState,
42
+ ToolResultStatus,
43
+ )
44
+
45
+
46
+ @runtime_checkable
47
+ class DocumentRenderEngine(DocumentInspectionEngine, Protocol):
48
+ """Promoted engine that can render reviewer-readable evidence."""
49
+
50
+ def render(self, path: Path, *, artifact_id: str, output_dir: Path) -> tuple[bytes, ...]:
51
+ """Return one or more page, sheet, or slide render payloads."""
52
+
53
+
54
+ class DocumentRenderResult(BaseModel):
55
+ """Result of rendering one derivative artifact for review."""
56
+
57
+ model_config = ConfigDict(frozen=True, extra="forbid")
58
+
59
+ status: ToolResultStatus
60
+ correlation_id: str
61
+ source_artifact_id: str
62
+ source_sha256: str
63
+ records: tuple[RenderArtifactRecord, ...] = ()
64
+ baseline_records: tuple[RenderArtifactRecord, ...] = ()
65
+ changed_viewports: tuple[DocumentChangedViewport, ...] = ()
66
+ viewport_cameras: tuple[DocumentViewportCamera, ...] = ()
67
+ artifact_refs: list[str] = Field(default_factory=list)
68
+ render_passed: bool
69
+ blocked_reason: BlockedReason | None = None
70
+ promotion_gate_result: PromotionGateResult | None = None
71
+ text_summary: str
72
+
73
+
74
+ def _write_or_reuse_derivative(
75
+ parent: DocumentArtifact,
76
+ store: DocumentArtifactStore,
77
+ *,
78
+ artifact_id: str,
79
+ lineage: ArtifactLineage,
80
+ destination_name: str,
81
+ payload: bytes,
82
+ document_format: DocumentFormat | None = None,
83
+ mime_type: str | None = None,
84
+ expanded_byte_size: int | None = None,
85
+ ) -> DocumentArtifact:
86
+ """Write a deterministic render artifact, or reuse an identical existing one."""
87
+
88
+ try:
89
+ return store.write_derivative(
90
+ parent,
91
+ artifact_id=artifact_id,
92
+ lineage=lineage,
93
+ destination_name=destination_name,
94
+ payload=payload,
95
+ document_format=document_format,
96
+ mime_type=mime_type,
97
+ expanded_byte_size=expanded_byte_size,
98
+ )
99
+ except ArtifactStoreConflictError as err:
100
+ existing = store.load_artifact(artifact_id)
101
+ if existing is None:
102
+ raise
103
+ mismatches = _derivative_metadata_mismatches(
104
+ existing,
105
+ parent,
106
+ payload=payload,
107
+ lineage=lineage,
108
+ document_format=document_format,
109
+ mime_type=mime_type,
110
+ expanded_byte_size=expanded_byte_size,
111
+ )
112
+ if mismatches:
113
+ mismatch_list = ", ".join(mismatches)
114
+ raise ArtifactStoreConflictError(
115
+ f"artifact already exists with divergent render metadata: "
116
+ f"{artifact_id} ({mismatch_list})"
117
+ ) from err
118
+ return existing
119
+
120
+
121
+ def _derivative_metadata_mismatches(
122
+ existing: DocumentArtifact,
123
+ parent: DocumentArtifact,
124
+ *,
125
+ payload: bytes,
126
+ lineage: ArtifactLineage,
127
+ document_format: DocumentFormat | None,
128
+ mime_type: str | None,
129
+ expanded_byte_size: int | None,
130
+ ) -> list[str]:
131
+ expected_expanded_byte_size = (
132
+ expanded_byte_size if expanded_byte_size is not None else len(payload)
133
+ )
134
+ expected = {
135
+ "sha256": hashlib.sha256(payload).hexdigest(),
136
+ "byte_size": len(payload),
137
+ "expanded_byte_size": expected_expanded_byte_size,
138
+ "format": document_format or parent.format,
139
+ "mime_type": mime_type or parent.mime_type,
140
+ "lineage": lineage,
141
+ "parent_artifact_id": parent.artifact_id,
142
+ }
143
+ actual = {
144
+ "sha256": existing.sha256,
145
+ "byte_size": existing.byte_size,
146
+ "expanded_byte_size": existing.expanded_byte_size,
147
+ "format": existing.format,
148
+ "mime_type": existing.mime_type,
149
+ "lineage": existing.lineage,
150
+ "parent_artifact_id": existing.parent_artifact_id,
151
+ }
152
+ return [field for field, expected_value in expected.items() if actual[field] != expected_value]
153
+
154
+
155
+ def render_document_evidence(
156
+ store: DocumentArtifactStore,
157
+ artifact: DocumentArtifact,
158
+ *,
159
+ engine_registry: DocumentEngineRegistry,
160
+ correlation_id: str,
161
+ artifact_id_prefix: str,
162
+ diff: DocumentDiff | None = None,
163
+ baseline_artifact: DocumentArtifact | None = None,
164
+ ) -> DocumentRenderResult:
165
+ """Render a derivative through the promoted format engine and store artifacts."""
166
+ try:
167
+ engine = _require_render_engine(engine_registry, artifact)
168
+ except UnsupportedDocumentEngineError:
169
+ promotion_gate_result = _blocked_render_promotion_gate(artifact)
170
+ return DocumentRenderResult(
171
+ status=ToolResultStatus.blocked,
172
+ correlation_id=correlation_id,
173
+ source_artifact_id=artifact.artifact_id,
174
+ source_sha256=artifact.sha256,
175
+ render_passed=False,
176
+ blocked_reason=BlockedReason.unsupported_operation,
177
+ promotion_gate_result=promotion_gate_result,
178
+ text_summary=_unsupported_render_summary(artifact),
179
+ )
180
+
181
+ output_dir = store.session_root / "renders" / artifact_id_prefix
182
+ try:
183
+ payloads = engine.render(
184
+ Path(artifact.source_path),
185
+ artifact_id=artifact.artifact_id,
186
+ output_dir=output_dir,
187
+ )
188
+ baseline_payloads: tuple[bytes, ...] = ()
189
+ if diff is not None and baseline_artifact is not None:
190
+ baseline_payloads = engine.render(
191
+ Path(baseline_artifact.source_path),
192
+ artifact_id=baseline_artifact.artifact_id,
193
+ output_dir=output_dir / "baseline",
194
+ )
195
+ except Exception as exc: # noqa: BLE001 - native render bridges fail as runtime exceptions.
196
+ return _render_engine_failure_result(
197
+ artifact,
198
+ correlation_id=correlation_id,
199
+ engine=engine,
200
+ exc=exc,
201
+ )
202
+ artifact_path = Path(artifact.source_path)
203
+ render_extension = _render_artifact_extension(engine, artifact_path=artifact_path)
204
+ render_mime_type = _render_mime_type(
205
+ engine,
206
+ extension=render_extension,
207
+ artifact_path=artifact_path,
208
+ )
209
+ records: list[RenderArtifactRecord] = []
210
+ baseline_records: list[RenderArtifactRecord] = []
211
+ changed_viewports: list[DocumentChangedViewport] = []
212
+ viewport_cameras: list[DocumentViewportCamera] = []
213
+ changed_viewport_anchored = False
214
+ for index, payload in enumerate(payloads, start=1):
215
+ render_artifact_id = f"{artifact_id_prefix}-{index:03d}"
216
+ render_payload, viewport_anchored, page_viewports = _detect_changed_viewports(
217
+ payload,
218
+ diff=diff,
219
+ mime_type=render_mime_type,
220
+ page_number=index,
221
+ render_artifact_id=render_artifact_id,
222
+ )
223
+ changed_viewport_anchored = changed_viewport_anchored or viewport_anchored
224
+ render_artifact = _write_or_reuse_derivative(
225
+ artifact,
226
+ store,
227
+ artifact_id=render_artifact_id,
228
+ lineage=ArtifactLineage.render,
229
+ destination_name=f"{render_artifact_id}.{render_extension}",
230
+ payload=render_payload,
231
+ document_format=artifact.format,
232
+ mime_type=render_mime_type,
233
+ )
234
+ raster_update = (
235
+ _optional_png_render_update(
236
+ store,
237
+ artifact,
238
+ render_artifact_id=render_artifact.artifact_id,
239
+ render_path=Path(render_artifact.source_path),
240
+ )
241
+ if render_mime_type == "image/svg+xml"
242
+ else {}
243
+ )
244
+ raster_artifact_ref = cast(str | None, raster_update.get("raster_artifact_ref"))
245
+ raster_artifact_path = cast(Path | None, raster_update.get("raster_artifact_path"))
246
+ raster_mime_type = cast(str | None, raster_update.get("raster_mime_type"))
247
+ records.append(
248
+ _render_artifact_record(
249
+ artifact=artifact,
250
+ render_artifact=render_artifact,
251
+ render_mime_type=render_mime_type,
252
+ raster_artifact_ref=raster_artifact_ref,
253
+ raster_artifact_path=raster_artifact_path,
254
+ raster_mime_type=raster_mime_type,
255
+ page_number=index,
256
+ correlation_id=correlation_id,
257
+ engine=engine,
258
+ artifact_path=artifact_path,
259
+ )
260
+ )
261
+ baseline_record: RenderArtifactRecord | None = None
262
+ baseline_payload = baseline_payloads[index - 1] if index <= len(baseline_payloads) else None
263
+ if baseline_artifact is not None and baseline_payload is not None:
264
+ baseline_record = _write_full_page_render_record(
265
+ store,
266
+ baseline_artifact,
267
+ render_artifact_id=f"{artifact_id_prefix}-baseline-{index:03d}",
268
+ render_payload=baseline_payload,
269
+ render_extension=render_extension,
270
+ render_mime_type=render_mime_type,
271
+ page_number=index,
272
+ correlation_id=correlation_id,
273
+ engine=engine,
274
+ artifact_path=Path(baseline_artifact.source_path),
275
+ )
276
+ baseline_records.append(baseline_record)
277
+ if render_mime_type == "image/svg+xml":
278
+ page_changed_viewports = _write_changed_viewport_artifacts(
279
+ store,
280
+ artifact,
281
+ after_render_payload=render_payload,
282
+ before_artifact=baseline_artifact,
283
+ before_render_payload=baseline_payload,
284
+ viewports=page_viewports,
285
+ )
286
+ changed_viewports.extend(page_changed_viewports)
287
+ if baseline_record is not None:
288
+ viewport_cameras.extend(
289
+ _viewport_cameras_for_page(
290
+ page_changed_viewports,
291
+ source_render_artifact_id=render_artifact.artifact_id,
292
+ baseline_render_artifact_id=baseline_record.render_artifact_id,
293
+ page_index=index - 1,
294
+ )
295
+ )
296
+ else:
297
+ changed_viewports.extend(page_viewports)
298
+
299
+ render_passed = len(records) > 0
300
+ return DocumentRenderResult(
301
+ status=ToolResultStatus.ok if render_passed else ToolResultStatus.blocked,
302
+ correlation_id=correlation_id,
303
+ source_artifact_id=artifact.artifact_id,
304
+ source_sha256=artifact.sha256,
305
+ records=tuple(records),
306
+ baseline_records=tuple(baseline_records),
307
+ changed_viewports=tuple(changed_viewports),
308
+ viewport_cameras=tuple(viewport_cameras),
309
+ artifact_refs=[record.render_artifact_id for record in records],
310
+ render_passed=render_passed,
311
+ blocked_reason=None if render_passed else BlockedReason.validation_failed,
312
+ text_summary=_render_text_summary(
313
+ record_count=len(records),
314
+ engine=engine,
315
+ render_passed=render_passed,
316
+ visual_diff_requested=diff is not None,
317
+ changed_viewport_anchored=changed_viewport_anchored,
318
+ artifact_path=artifact_path,
319
+ ),
320
+ )
321
+
322
+
323
+ def _write_full_page_render_record(
324
+ store: DocumentArtifactStore,
325
+ artifact: DocumentArtifact,
326
+ *,
327
+ render_artifact_id: str,
328
+ render_payload: bytes,
329
+ render_extension: str,
330
+ render_mime_type: str,
331
+ page_number: int,
332
+ correlation_id: str,
333
+ engine: DocumentRenderEngine,
334
+ artifact_path: Path,
335
+ ) -> RenderArtifactRecord:
336
+ render_artifact = _write_or_reuse_derivative(
337
+ artifact,
338
+ store,
339
+ artifact_id=render_artifact_id,
340
+ lineage=ArtifactLineage.render,
341
+ destination_name=f"{render_artifact_id}.{render_extension}",
342
+ payload=render_payload,
343
+ document_format=artifact.format,
344
+ mime_type=render_mime_type,
345
+ )
346
+ raster_update = (
347
+ _optional_png_render_update(
348
+ store,
349
+ artifact,
350
+ render_artifact_id=render_artifact.artifact_id,
351
+ render_path=Path(render_artifact.source_path),
352
+ )
353
+ if render_mime_type == "image/svg+xml"
354
+ else {}
355
+ )
356
+ return _render_artifact_record(
357
+ artifact=artifact,
358
+ render_artifact=render_artifact,
359
+ render_mime_type=render_mime_type,
360
+ raster_artifact_ref=cast(str | None, raster_update.get("raster_artifact_ref")),
361
+ raster_artifact_path=cast(Path | None, raster_update.get("raster_artifact_path")),
362
+ raster_mime_type=cast(str | None, raster_update.get("raster_mime_type")),
363
+ page_number=page_number,
364
+ correlation_id=correlation_id,
365
+ engine=engine,
366
+ artifact_path=artifact_path,
367
+ )
368
+
369
+
370
+ def _render_artifact_record(
371
+ *,
372
+ artifact: DocumentArtifact,
373
+ render_artifact: DocumentArtifact,
374
+ render_mime_type: str,
375
+ raster_artifact_ref: str | None,
376
+ raster_artifact_path: Path | None,
377
+ raster_mime_type: str | None,
378
+ page_number: int,
379
+ correlation_id: str,
380
+ engine: DocumentRenderEngine,
381
+ artifact_path: Path,
382
+ ) -> RenderArtifactRecord:
383
+ return RenderArtifactRecord(
384
+ render_artifact_id=render_artifact.artifact_id,
385
+ source_artifact_id=artifact.artifact_id,
386
+ source_sha256=artifact.sha256,
387
+ render_sha256=render_artifact.sha256,
388
+ render_path=render_artifact.source_path,
389
+ render_mime_type=render_mime_type,
390
+ raster_artifact_ref=raster_artifact_ref,
391
+ raster_artifact_path=raster_artifact_path,
392
+ raster_mime_type=raster_mime_type,
393
+ page_number=page_number,
394
+ correlation_id=correlation_id,
395
+ engine_id=_render_engine_id(engine, artifact_path=artifact_path),
396
+ )
397
+
398
+
399
+ def _viewport_cameras_for_page(
400
+ viewports: tuple[DocumentChangedViewport, ...],
401
+ *,
402
+ source_render_artifact_id: str,
403
+ baseline_render_artifact_id: str,
404
+ page_index: int,
405
+ ) -> tuple[DocumentViewportCamera, ...]:
406
+ return tuple(
407
+ DocumentViewportCamera(
408
+ source_render_artifact_id=source_render_artifact_id,
409
+ baseline_render_artifact_id=baseline_render_artifact_id,
410
+ page_index=page_index,
411
+ viewport_rect=viewport.clip_rect,
412
+ zoom=Decimal("1"),
413
+ change_ids=viewport.change_ids,
414
+ )
415
+ for viewport in viewports
416
+ )
417
+
418
+
419
+ def _write_changed_viewport_artifacts(
420
+ store: DocumentArtifactStore,
421
+ artifact: DocumentArtifact,
422
+ *,
423
+ after_render_payload: bytes,
424
+ before_artifact: DocumentArtifact | None = None,
425
+ before_render_payload: bytes | None = None,
426
+ viewports: tuple[DocumentChangedViewport, ...],
427
+ ) -> tuple[DocumentChangedViewport, ...]:
428
+ updated: list[DocumentChangedViewport] = []
429
+ for viewport in viewports:
430
+ after_viewport_payload = _viewport_svg_payload(
431
+ after_render_payload,
432
+ viewport_id=viewport.viewport_id,
433
+ clip_rect=viewport.clip_rect,
434
+ )
435
+ after_viewport_artifact = _write_or_reuse_derivative(
436
+ artifact,
437
+ store,
438
+ artifact_id=viewport.viewport_id,
439
+ lineage=ArtifactLineage.render,
440
+ destination_name=f"{viewport.viewport_id}.svg",
441
+ payload=after_viewport_payload,
442
+ document_format=artifact.format,
443
+ mime_type="image/svg+xml",
444
+ )
445
+ after_png_update = _optional_png_viewport_update(
446
+ store,
447
+ artifact,
448
+ viewport_id=viewport.viewport_id,
449
+ viewport_svg_path=Path(after_viewport_artifact.source_path),
450
+ )
451
+ update: dict[str, str | Path | None] = {
452
+ "svg_artifact_ref": after_viewport_artifact.artifact_id,
453
+ "svg_artifact_path": after_viewport_artifact.source_path,
454
+ "after_svg_artifact_ref": after_viewport_artifact.artifact_id,
455
+ "after_svg_artifact_path": after_viewport_artifact.source_path,
456
+ **after_png_update,
457
+ **_prefixed_png_update(after_png_update, prefix="after"),
458
+ }
459
+ if before_artifact is not None and before_render_payload is not None:
460
+ before_viewport_id = f"{viewport.viewport_id}-before"
461
+ before_viewport_payload = _viewport_svg_payload(
462
+ before_render_payload,
463
+ viewport_id=before_viewport_id,
464
+ clip_rect=viewport.clip_rect,
465
+ )
466
+ before_viewport_artifact = _write_or_reuse_derivative(
467
+ before_artifact,
468
+ store,
469
+ artifact_id=before_viewport_id,
470
+ lineage=ArtifactLineage.render,
471
+ destination_name=f"{before_viewport_id}.svg",
472
+ payload=before_viewport_payload,
473
+ document_format=before_artifact.format,
474
+ mime_type="image/svg+xml",
475
+ )
476
+ before_png_update = _optional_png_viewport_update(
477
+ store,
478
+ before_artifact,
479
+ viewport_id=before_viewport_id,
480
+ viewport_svg_path=Path(before_viewport_artifact.source_path),
481
+ )
482
+ update.update(
483
+ {
484
+ "before_svg_artifact_ref": before_viewport_artifact.artifact_id,
485
+ "before_svg_artifact_path": before_viewport_artifact.source_path,
486
+ **_prefixed_png_update(before_png_update, prefix="before"),
487
+ }
488
+ )
489
+ updated.append(
490
+ viewport.model_copy(
491
+ update=update,
492
+ )
493
+ )
494
+ return tuple(updated)
495
+
496
+
497
+ def _prefixed_png_update(
498
+ png_update: dict[str, str | Path],
499
+ *,
500
+ prefix: str,
501
+ ) -> dict[str, str | Path]:
502
+ prefixed: dict[str, str | Path] = {}
503
+ if "png_artifact_ref" in png_update:
504
+ prefixed[f"{prefix}_png_artifact_ref"] = png_update["png_artifact_ref"]
505
+ if "png_artifact_path" in png_update:
506
+ prefixed[f"{prefix}_png_artifact_path"] = png_update["png_artifact_path"]
507
+ return prefixed
508
+
509
+
510
+ def _optional_png_viewport_update(
511
+ store: DocumentArtifactStore,
512
+ artifact: DocumentArtifact,
513
+ *,
514
+ viewport_id: str,
515
+ viewport_svg_path: Path,
516
+ ) -> dict[str, str | Path]:
517
+ return _optional_svg_to_png_update(
518
+ store,
519
+ artifact,
520
+ png_artifact_id=f"{viewport_id}-png",
521
+ svg_path=viewport_svg_path,
522
+ )
523
+
524
+
525
+ def _optional_svg_to_png_update(
526
+ store: DocumentArtifactStore,
527
+ artifact: DocumentArtifact,
528
+ *,
529
+ png_artifact_id: str,
530
+ svg_path: Path,
531
+ ) -> dict[str, str | Path]:
532
+ rasterizer = shutil.which("rsvg-convert")
533
+ if rasterizer is None:
534
+ return {}
535
+
536
+ png_path = (
537
+ store.session_root / "render" / f"{png_artifact_id}-raster-tmp" / f"{png_artifact_id}.png"
538
+ )
539
+ png_path.parent.mkdir(parents=True, exist_ok=True)
540
+ # Local rasterizer only, no shell: fixed executable plus local artifact paths.
541
+ completed = subprocess.run( # noqa: S603
542
+ [rasterizer, str(svg_path), "-o", str(png_path)],
543
+ check=False,
544
+ stdout=subprocess.DEVNULL,
545
+ stderr=subprocess.DEVNULL,
546
+ )
547
+ if completed.returncode != 0 or not png_path.is_file():
548
+ return {}
549
+
550
+ png_payload = png_path.read_bytes()
551
+ png_artifact = _write_or_reuse_derivative(
552
+ artifact,
553
+ store,
554
+ artifact_id=png_artifact_id,
555
+ lineage=ArtifactLineage.render,
556
+ destination_name=f"{png_artifact_id}.png",
557
+ payload=png_payload,
558
+ document_format=artifact.format,
559
+ mime_type="image/png",
560
+ )
561
+ return {
562
+ "png_artifact_ref": png_artifact.artifact_id,
563
+ "png_artifact_path": png_artifact.source_path,
564
+ }
565
+
566
+
567
+ def _optional_png_render_update(
568
+ store: DocumentArtifactStore,
569
+ artifact: DocumentArtifact,
570
+ *,
571
+ render_artifact_id: str,
572
+ render_path: Path,
573
+ ) -> dict[str, str | Path]:
574
+ png_update = _optional_svg_to_png_update(
575
+ store,
576
+ artifact,
577
+ png_artifact_id=f"{render_artifact_id}-png",
578
+ svg_path=render_path,
579
+ )
580
+ if not png_update:
581
+ return {}
582
+ return {
583
+ "raster_artifact_ref": png_update["png_artifact_ref"],
584
+ "raster_artifact_path": png_update["png_artifact_path"],
585
+ "raster_mime_type": "image/png",
586
+ }
587
+
588
+
589
+ def _viewport_svg_payload(
590
+ render_payload: bytes,
591
+ *,
592
+ viewport_id: str,
593
+ clip_rect: DocumentClipRect,
594
+ ) -> bytes:
595
+ svg = render_payload.decode("utf-8")
596
+ clip_x = float(clip_rect.x)
597
+ clip_y = float(clip_rect.y)
598
+ clip_width = max(float(clip_rect.width), 1.0)
599
+ clip_height = max(float(clip_rect.height), 1.0)
600
+ escaped_viewport_id = html.escape(viewport_id, quote=True)
601
+ return (
602
+ '<svg xmlns="http://www.w3.org/2000/svg" '
603
+ f'width="{clip_width:.2f}" height="{clip_height:.2f}" '
604
+ f'viewBox="{clip_x:.2f} {clip_y:.2f} {clip_width:.2f} {clip_height:.2f}" '
605
+ 'preserveAspectRatio="xMinYMin meet" overflow="hidden" '
606
+ f'data-ummaya-viewport-id="{escaped_viewport_id}">'
607
+ "<title>UMMAYA changed document viewport</title>"
608
+ f"{_svg_inner_markup(svg)}</svg>"
609
+ ).encode()
610
+
611
+
612
+ def _svg_inner_markup(svg: str) -> str:
613
+ match = re.search(r"<svg\b[^>]*>(?P<body>.*)</svg>", svg, re.DOTALL)
614
+ if match is None:
615
+ return html.escape(svg)
616
+ return match.group("body")
617
+
618
+
619
+ def _render_artifact_extension(
620
+ engine: DocumentRenderEngine,
621
+ *,
622
+ artifact_path: Path | None = None,
623
+ ) -> str:
624
+ dynamic_extension = getattr(engine, "render_artifact_extension_for", None)
625
+ if dynamic_extension is not None and artifact_path is not None:
626
+ extension = str(dynamic_extension(artifact_path)).lstrip(".").lower()
627
+ else:
628
+ extension = str(getattr(engine, "render_artifact_extension", "txt")).lstrip(".").lower()
629
+ supported_extensions = {"txt", "svg", "png", "pdf", "html"}
630
+ if extension not in supported_extensions:
631
+ raise ValueError(f"Unsupported render artifact extension: {extension}")
632
+ return extension
633
+
634
+
635
+ def _render_mime_type(
636
+ engine: DocumentRenderEngine,
637
+ *,
638
+ extension: str,
639
+ artifact_path: Path | None = None,
640
+ ) -> str:
641
+ dynamic_mime_type = getattr(engine, "render_mime_type_for", None)
642
+ if dynamic_mime_type is not None and artifact_path is not None:
643
+ return str(dynamic_mime_type(artifact_path))
644
+ explicit_mime_type = getattr(engine, "render_mime_type", None)
645
+ if explicit_mime_type is not None:
646
+ return str(explicit_mime_type)
647
+ return {
648
+ "html": "text/html",
649
+ "pdf": "application/pdf",
650
+ "png": "image/png",
651
+ "svg": "image/svg+xml",
652
+ "txt": "text/plain",
653
+ }[extension]
654
+
655
+
656
+ def _render_engine_id(
657
+ engine: DocumentRenderEngine,
658
+ *,
659
+ artifact_path: Path | None = None,
660
+ ) -> str:
661
+ dynamic_engine_id = getattr(engine, "render_engine_id_for", None)
662
+ if dynamic_engine_id is not None and artifact_path is not None:
663
+ return str(dynamic_engine_id(artifact_path))
664
+ return str(getattr(engine, "render_engine_id", engine.engine_id))
665
+
666
+
667
+ def _render_text_summary(
668
+ *,
669
+ record_count: int,
670
+ engine: DocumentRenderEngine,
671
+ render_passed: bool,
672
+ visual_diff_requested: bool,
673
+ changed_viewport_anchored: bool,
674
+ artifact_path: Path | None = None,
675
+ ) -> str:
676
+ if not render_passed:
677
+ return "Renderer produced no reviewer evidence artifacts."
678
+ engine_id = _render_engine_id(engine, artifact_path=artifact_path)
679
+ if changed_viewport_anchored:
680
+ return (
681
+ f"Rendered {record_count} reviewer evidence artifact(s) through {engine_id} "
682
+ "with changed viewport evidence."
683
+ )
684
+ if visual_diff_requested:
685
+ return (
686
+ f"Rendered {record_count} reviewer evidence artifact(s) through {engine_id}; "
687
+ "no changed viewport anchors matched the rendered page."
688
+ )
689
+ return f"Rendered {record_count} reviewer evidence artifact(s) through {engine_id}."
690
+
691
+
692
+ def _render_engine_failure_result(
693
+ artifact: DocumentArtifact,
694
+ *,
695
+ correlation_id: str,
696
+ engine: DocumentRenderEngine,
697
+ exc: Exception,
698
+ ) -> DocumentRenderResult:
699
+ return DocumentRenderResult(
700
+ status=ToolResultStatus.blocked,
701
+ correlation_id=correlation_id,
702
+ source_artifact_id=artifact.artifact_id,
703
+ source_sha256=artifact.sha256,
704
+ render_passed=False,
705
+ blocked_reason=BlockedReason.validation_failed,
706
+ text_summary=_render_engine_failure_summary(engine=engine, exc=exc),
707
+ )
708
+
709
+
710
+ def _render_engine_failure_summary(
711
+ *,
712
+ engine: DocumentRenderEngine,
713
+ exc: Exception,
714
+ ) -> str:
715
+ message = str(exc).strip() or exc.__class__.__name__
716
+ if len(message) > 300:
717
+ message = f"{message[:300]}..."
718
+ return f"Document render failed through {_render_engine_id(engine)}: {message}"
719
+
720
+
721
+ def _require_render_engine(
722
+ engine_registry: DocumentEngineRegistry,
723
+ artifact: DocumentArtifact,
724
+ ) -> DocumentRenderEngine:
725
+ engine = engine_registry.require(artifact.format)
726
+ if not isinstance(engine, DocumentRenderEngine):
727
+ raise UnsupportedDocumentEngineError(artifact.format)
728
+ return engine
729
+
730
+
731
+ def _blocked_render_promotion_gate(artifact: DocumentArtifact) -> PromotionGateResult:
732
+ failure_id = (
733
+ "hwpx_render_engine_unpromoted"
734
+ if artifact.format is DocumentFormat.hwpx
735
+ else f"{artifact.format.value}_render_engine_unavailable"
736
+ )
737
+ return PromotionGateResult(
738
+ gate_id=f"gate-{artifact.artifact_id}-render",
739
+ profile_id=f"profile-{artifact.format.value}",
740
+ capability=PromotionCapability.render,
741
+ score_total=0,
742
+ extraction_fidelity=0,
743
+ write_fidelity=0,
744
+ style_layout_control=0,
745
+ deterministic_round_trip=0,
746
+ public_form_validation=0,
747
+ security_privacy=0,
748
+ license_maintenance_tool_usability=0,
749
+ hard_gates_passed=False,
750
+ hard_gate_failures=[failure_id],
751
+ promotion_state=PromotionState.blocked,
752
+ promotion_checklist=_promotion_checklist_for_render(artifact),
753
+ evidence_record_ids=[],
754
+ )
755
+
756
+
757
+ def _promotion_checklist_for_render(artifact: DocumentArtifact) -> list[PromotionChecklistItem]:
758
+ if artifact.format is not DocumentFormat.hwpx:
759
+ return []
760
+ evidence_items = {
761
+ "page_geometry": "Rendered pages preserve paper size, margins, and page breaks.",
762
+ "table_spans": "Rendered tables preserve row spans, column spans, borders, and cell text.",
763
+ "font_fallback": (
764
+ "Korean fonts resolve deterministically without missing-glyph fallback drift."
765
+ ),
766
+ "korean_line_breaks": "Korean line breaks and paragraph spacing match the source fixture.",
767
+ "visible_field_values": (
768
+ "Filled values are visible at the expected anchors in the rendered output."
769
+ ),
770
+ "package_integrity": "The HWPX package remains valid after render preparation.",
771
+ "no_external_egress": (
772
+ "Rendering uses only local files and performs no external network egress."
773
+ ),
774
+ }
775
+ return [
776
+ PromotionChecklistItem(
777
+ check_id=check_id,
778
+ capability=PromotionCapability.render,
779
+ status=PromotionChecklistStatus.required,
780
+ evidence_required=evidence_required,
781
+ )
782
+ for check_id, evidence_required in evidence_items.items()
783
+ ]
784
+
785
+
786
+ def _unsupported_render_summary(artifact: DocumentArtifact) -> str:
787
+ if artifact.format is DocumentFormat.hwpx:
788
+ return (
789
+ "HWPX visual render is not promoted for the hwpx-package-text engine. "
790
+ "Use extraction, structured diff, and public-form validation evidence "
791
+ "until an HWPX renderer passes the visual fixture gate."
792
+ )
793
+ return f"No render-capable engine is registered for {artifact.format.value}."
794
+
795
+
796
+ _TEXT_ELEMENT_RE = re.compile(r"<text\b(?P<attrs>[^>]*)>(?P<text>.*?)</text>", re.DOTALL)
797
+ _NUMBER_ATTR_RE = re.compile(r'\b(?P<name>x|y|font-size|textLength)="(?P<value>-?\d+(?:\.\d+)?)"')
798
+ _MIN_CHANGED_VIEWPORT_WIDTH = 240.0
799
+ _MIN_CHANGED_VIEWPORT_HEIGHT = 140.0
800
+
801
+
802
+ class _SvgTextRun(BaseModel):
803
+ """One positioned SVG text run extracted from renderer output."""
804
+
805
+ model_config = ConfigDict(frozen=True)
806
+
807
+ index: int
808
+ x: float
809
+ y: float
810
+ width: float
811
+ height: float
812
+ text: str
813
+
814
+
815
+ class _SvgViewportMatch(BaseModel):
816
+ """Matched SVG visual change location plus its page crop rectangle."""
817
+
818
+ model_config = ConfigDict(frozen=True)
819
+
820
+ clip_x: float
821
+ clip_y: float
822
+ clip_width: float
823
+ clip_height: float
824
+ text_fallback: tuple[str, ...]
825
+
826
+
827
+ def _detect_changed_viewports(
828
+ payload: bytes,
829
+ *,
830
+ diff: DocumentDiff | None,
831
+ mime_type: str,
832
+ page_number: int,
833
+ render_artifact_id: str,
834
+ ) -> tuple[bytes, bool, tuple[DocumentChangedViewport, ...]]:
835
+ if diff is None or mime_type != "image/svg+xml":
836
+ return payload, False, ()
837
+
838
+ svg = payload.decode("utf-8")
839
+ runs = _svg_text_runs(svg)
840
+ changed_viewports: list[DocumentChangedViewport] = []
841
+ for change in diff.changes:
842
+ viewport = _svg_viewport_for_change(change, runs=runs, page_number=page_number)
843
+ if viewport is None:
844
+ continue
845
+ changed_viewports.append(
846
+ DocumentChangedViewport(
847
+ viewport_id=f"viewport-{render_artifact_id}-{change.change_id}",
848
+ change_ids=(change.change_id,),
849
+ page_number=page_number,
850
+ source_render_artifact_id=render_artifact_id,
851
+ clip_rect=DocumentClipRect(
852
+ x=_decimal(viewport.clip_x),
853
+ y=_decimal(viewport.clip_y),
854
+ width=_decimal(viewport.clip_width),
855
+ height=_decimal(viewport.clip_height),
856
+ ),
857
+ svg_artifact_ref=render_artifact_id,
858
+ text_fallback=viewport.text_fallback,
859
+ anchor_strategy="exact_text_run",
860
+ confidence=Decimal("0.90"),
861
+ )
862
+ )
863
+ if not changed_viewports:
864
+ return payload, False, ()
865
+
866
+ return payload, True, tuple(changed_viewports)
867
+
868
+
869
+ def _svg_text_runs(svg: str) -> list[_SvgTextRun]:
870
+ runs: list[_SvgTextRun] = []
871
+ for index, match in enumerate(_TEXT_ELEMENT_RE.finditer(svg)):
872
+ attrs = _number_attrs(match.group("attrs"))
873
+ text = html.unescape(re.sub(r"<[^>]+>", "", match.group("text")))
874
+ if not text:
875
+ continue
876
+ x = attrs.get("x")
877
+ y = attrs.get("y")
878
+ font_size = attrs.get("font-size", 12.0)
879
+ if x is None or y is None:
880
+ continue
881
+ width = attrs.get("textLength", _estimated_text_width(text, font_size))
882
+ runs.append(
883
+ _SvgTextRun(
884
+ index=index,
885
+ x=x,
886
+ y=y,
887
+ width=width,
888
+ height=max(font_size, 8.0),
889
+ text=text,
890
+ )
891
+ )
892
+ return runs
893
+
894
+
895
+ def _number_attrs(attrs: str) -> dict[str, float]:
896
+ return {
897
+ match.group("name"): float(match.group("value"))
898
+ for match in _NUMBER_ATTR_RE.finditer(attrs)
899
+ }
900
+
901
+
902
+ def _estimated_text_width(text: str, font_size: float) -> float:
903
+ width = 0.0
904
+ for character in text:
905
+ width += font_size if ord(character) > 0x7F else font_size * 0.55
906
+ return max(width, font_size * 0.55)
907
+
908
+
909
+ def _svg_viewport_for_change(
910
+ change: DocumentChange,
911
+ *,
912
+ runs: list[_SvgTextRun],
913
+ page_number: int,
914
+ ) -> _SvgViewportMatch | None:
915
+ if change.after_value is None:
916
+ return None
917
+ matched_runs = _match_text_runs(runs, change.after_value)
918
+ if not matched_runs:
919
+ return None
920
+
921
+ x1 = min(run.x for run in matched_runs)
922
+ y1 = min(run.y - run.height for run in matched_runs) - 3
923
+ x2 = max(run.x + run.width for run in matched_runs)
924
+ y2 = max(run.y + 3 for run in matched_runs)
925
+ width = max(x2 - x1 + 6, 10)
926
+ height = max(y2 - y1 + 6, 10)
927
+ center_x = (x1 + x2) / 2
928
+ center_y = (y1 + y2) / 2
929
+ clip_width = max(width + 96.0, _MIN_CHANGED_VIEWPORT_WIDTH)
930
+ clip_height = max(height + 96.0, _MIN_CHANGED_VIEWPORT_HEIGHT)
931
+ clip_x = max(0.0, center_x - (clip_width / 2))
932
+ clip_y = max(0.0, center_y - (clip_height / 2))
933
+ return _SvgViewportMatch(
934
+ clip_x=clip_x,
935
+ clip_y=clip_y,
936
+ clip_width=clip_width,
937
+ clip_height=clip_height,
938
+ text_fallback=_change_text_fallback(change, page_number),
939
+ )
940
+
941
+
942
+ def _match_text_runs(runs: list[_SvgTextRun], value: str) -> list[_SvgTextRun]:
943
+ target = _normalize_match_text(value)
944
+ if not target:
945
+ return []
946
+
947
+ for start_index in range(len(runs)):
948
+ matched: list[_SvgTextRun] = []
949
+ observed = ""
950
+ for run in runs[start_index:]:
951
+ normalized = _normalize_match_text(run.text)
952
+ if not normalized:
953
+ continue
954
+ observed += normalized
955
+ matched.append(run)
956
+ if target.startswith(observed):
957
+ if observed == target:
958
+ return matched
959
+ continue
960
+ break
961
+ return []
962
+
963
+
964
+ def _normalize_match_text(value: str) -> str:
965
+ return "".join(value.split())
966
+
967
+
968
+ def _change_text_fallback(change: DocumentChange, page_number: int) -> tuple[str, ...]:
969
+ lines = [f"Page {page_number} · {change.change_id} · {change.target_path}"]
970
+ if change.before_value is not None:
971
+ lines.append(f"- {change.before_value}")
972
+ if change.after_value is not None:
973
+ lines.append(f"+ {change.after_value}")
974
+ return tuple(lines)
975
+
976
+
977
+ def _decimal(value: float) -> Decimal:
978
+ return Decimal(f"{value:.2f}")