diff --git a/AGENTS.md b/AGENTS.md index 1a14c29bc..9104dd1f4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -446,12 +446,6 @@ in this repo. topic components, or label evidence by a bare document, model, topic, rank, label, or display value. - When reviews find public/private identifier leaks, stale API fixture shapes, or recurring bug patterns, update tests, frontend mocks, E2E mocks, README examples, architecture docs, and explicitly record the anti-pattern in `AGENTS.md` so the same bug pattern does not reappear in copied examples. -- Mail attachment surfaces must list the current email's files with opaque - `asset_key` values and open the signed - `/api/data/repository-assets/{asset_key}/preview` contract. Do not expose - sequential attachment ids, render missing preview text as empty document - content, or send buyers only to the Data repository list as a substitute for - opening the HWPX file on the selected mail. - Memoized id-to-record Maps must be first-wins (`if (!map.has(key)) map.set(...)`). `new Map(items.map((item) => [String(item.id), item]))` is last-wins and desynchronizes first-wins label maps from the selected node or edge when @@ -599,14 +593,6 @@ in this repo. vector counts, unsupported embedding model names, static quality totals, or provider-write success claims; Data mocks and E2E fixtures must preserve the bearer-session call and omit public identity headers. -- Repository-asset preview is read-only and scoped. Unknown keys and - cross-workspace access both return 404 `repository_asset_not_found` so - existence cannot leak. Recognized HWPX text comes from stored ordered - paragraphs; pending or failed recognition must keep the current asset detail - and show an explicit next action. Do not treat missing preview text as empty - content, and do not fetch preview indiscriminately in E2E helpers — mock - known assets such as `roadmap.md` and `blank-notes.md`, then fail unmatched - preview routes closed. - Project workspace lists, milestones, task links, and decision logs must be source-backed by signed `/api/webdav/folders` and `/api/tasks` data or explicitly labeled pending. Do not reintroduce static project names, inert diff --git a/backend/api/data.py b/backend/api/data.py index 81c52d3a1..de20823d9 100644 --- a/backend/api/data.py +++ b/backend/api/data.py @@ -381,6 +381,23 @@ class DataDocumentActionResponse(BaseModel): message: str +class DataInkspanEditHandoffResponse(BaseModel): + """Read-only Inkspan capability probe that never mutates the source file.""" + + source_asset_key: str + source_asset_type: Literal["email_attachment", "workspace_document"] + parser_family: str | None + handoff_state: Literal["unavailable"] + editor_capability_name: str + mutation_allowed: bool + converts_source_to_plain_text: bool + overwrites_original: bool + provider_write_executed: bool + next_action: str + error_code: str + editable_document_payload: None = None + + class DataRepositoryAssetPreviewResponse(BaseModel): """Read-only preview of recognized or blocked repository-asset text.""" @@ -395,6 +412,7 @@ class DataRepositoryAssetPreviewResponse(BaseModel): provider_write_executed: bool provenance: Literal["server-authoritative"] audit_event: Literal["data.repository_asset.preview.viewed"] + edit_handoff: DataInkspanEditHandoffResponse | None = None class DataDocumentWebdavMaterializationResponse(BaseModel): @@ -2593,6 +2611,7 @@ def _preview_response( ) -> DataRepositoryAssetPreviewResponse: """Wrap a service preview in the signed read-only API envelope.""" + edit_handoff = preview.edit_handoff return DataRepositoryAssetPreviewResponse( asset_key=preview.asset_key, asset_type=preview.asset_type, @@ -2605,6 +2624,24 @@ def _preview_response( provider_write_executed=False, provenance="server-authoritative", audit_event="data.repository_asset.preview.viewed", + edit_handoff=( + None + if edit_handoff is None + else DataInkspanEditHandoffResponse( + source_asset_key=edit_handoff.source_asset_key, + source_asset_type=edit_handoff.source_asset_type, + parser_family=edit_handoff.parser_family, + handoff_state=edit_handoff.handoff_state, + editor_capability_name=edit_handoff.editor_capability_name, + mutation_allowed=False, + converts_source_to_plain_text=False, + overwrites_original=False, + provider_write_executed=False, + next_action=edit_handoff.next_action, + error_code=edit_handoff.error_code, + editable_document_payload=None, + ) + ), ) diff --git a/backend/services/inkspan_edit_handoff.py b/backend/services/inkspan_edit_handoff.py new file mode 100644 index 000000000..585860ca6 --- /dev/null +++ b/backend/services/inkspan_edit_handoff.py @@ -0,0 +1,153 @@ +"""Probe a read-only Inkspan edit handoff for recognized HWPX attachments. + +Naruon already exposes ordered HWPX paragraphs through the repository-asset +preview. This module does not convert those paragraphs into an editable +document, does not overwrite the original attachment, and does not invent a +write API. It only records whether a released, installed Inkspan Hangul +document engine is present and whether an authorized editor contract exists. + +Released Inkspan remains a Markdown/HTML editor. Hangul import/edit/export is +owned by unreleased inkspan Draft #320 and is not installed here, so the host +adapter hook stays empty and the buyer-visible handoff fails closed. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + +HANDOFF_STATE_UNAVAILABLE = "unavailable" +EDITOR_CAPABILITY_HANGUL_DOCUMENT_ENGINE = "inkspan_hangul_document_engine" +ERROR_INKSPAN_HANGUL_CAPABILITY_UNAVAILABLE = "inkspan_hangul_capability_unavailable" +ERROR_INKSPAN_EDIT_CONTRACT_UNAVAILABLE = "inkspan_edit_contract_unavailable" +NEXT_ACTION_KEEP_READING_RECOGNIZED_TEXT = "keep_reading_recognized_text" +HWPX_PARSER_FAMILY = "hwpx" +AUTHORIZED_EDIT_CONTRACTS: frozenset[str] = frozenset() + + +@dataclass(frozen=True, slots=True) +class InkspanEditHandoff: + """Carry one scoped, non-mutating Inkspan handoff for a recognized HWPX file.""" + + source_asset_key: str + source_asset_type: str + parser_family: str | None + handoff_state: Literal["unavailable"] + editor_capability_name: str + mutation_allowed: bool + converts_source_to_plain_text: bool + overwrites_original: bool + provider_write_executed: bool + next_action: str + error_code: str + editable_document_payload: None = None + + +def registered_inkspan_editor_capability() -> object | None: + """Return the host-owned Inkspan editor adapter when one is installed. + + Naruon does not vendor Inkspan. A future host adapter may register here + after a released Hangul document engine exists. The default workspace has + no such adapter. + """ + + return None + + +def installed_inkspan_editor_capability() -> object | None: + """Return a Hangul engine adapter only when it accepts HWPX without conversion.""" + + adapter = registered_inkspan_editor_capability() + if _is_hangul_hwpx_capability(adapter): + return adapter + return None + + +def _adapter_field(adapter: object | None, field_name: str) -> object | None: + """Read one adapter attribute without treating missing hosts as installed.""" + + if adapter is None: + return None + return getattr(adapter, field_name, None) + + +def _accepted_source_families(adapter: object | None) -> tuple[str, ...]: + """Return the source families an adapter can open without conversion. + + Malformed non-collection metadata is treated as absent so a recognized + preview can fail closed instead of raising. + """ + + families = _adapter_field(adapter, "accepted_source_families") + if not isinstance(families, (list, tuple, set, frozenset)): + return () + return tuple(str(family) for family in families) + + +def _is_hangul_hwpx_capability(adapter: object | None) -> bool: + """True only for a Hangul engine that accepts HWPX as HWPX.""" + + capability_name = _adapter_field(adapter, "capability_name") + return ( + capability_name == EDITOR_CAPABILITY_HANGUL_DOCUMENT_ENGINE + and HWPX_PARSER_FAMILY in _accepted_source_families(adapter) + ) + + +def _unavailable_handoff( + preview: object, + error_code: str, +) -> InkspanEditHandoff: + """Build a fail-closed handoff that keeps the exact source identity.""" + + return InkspanEditHandoff( + source_asset_key=str(getattr(preview, "asset_key")), + source_asset_type=str(getattr(preview, "asset_type")), + parser_family=str(getattr(preview, "parser_family") or "") or None, + handoff_state=HANDOFF_STATE_UNAVAILABLE, + editor_capability_name=EDITOR_CAPABILITY_HANGUL_DOCUMENT_ENGINE, + mutation_allowed=False, + converts_source_to_plain_text=False, + overwrites_original=False, + provider_write_executed=False, + next_action=NEXT_ACTION_KEEP_READING_RECOGNIZED_TEXT, + error_code=error_code, + editable_document_payload=None, + ) + + +def build_inkspan_edit_handoff(preview: object) -> InkspanEditHandoff | None: + """Return a read-only Inkspan handoff for recognized HWPX, or None. + + Pending, failed, unavailable, and non-HWPX previews do not offer an edit + control. Recognized HWPX always preserves the preview asset key and fails + closed unless a released Hangul capability and an authorized editor + contract are both present. No authorized contract exists in this slice. + """ + + preview_state = str(getattr(preview, "preview_state", "") or "") + parser_family = str(getattr(preview, "parser_family", "") or "") + if preview_state != "recognized" or parser_family != HWPX_PARSER_FAMILY: + return None + + adapter = registered_inkspan_editor_capability() + if not _is_hangul_hwpx_capability(adapter): + return _unavailable_handoff( + preview, + ERROR_INKSPAN_HANGUL_CAPABILITY_UNAVAILABLE, + ) + + contract_name = _adapter_field(adapter, "mutation_contract_name") + if ( + not isinstance(contract_name, str) + or contract_name not in AUTHORIZED_EDIT_CONTRACTS + ): + return _unavailable_handoff( + preview, + ERROR_INKSPAN_EDIT_CONTRACT_UNAVAILABLE, + ) + + return _unavailable_handoff( + preview, + ERROR_INKSPAN_EDIT_CONTRACT_UNAVAILABLE, + ) diff --git a/backend/services/repository_asset_preview.py b/backend/services/repository_asset_preview.py index 0250e38da..5c3ff7a39 100644 --- a/backend/services/repository_asset_preview.py +++ b/backend/services/repository_asset_preview.py @@ -8,9 +8,13 @@ from __future__ import annotations from collections.abc import Sequence -from dataclasses import dataclass +from dataclasses import dataclass, replace from typing import Literal +from services.inkspan_edit_handoff import ( + InkspanEditHandoff, + build_inkspan_edit_handoff, +) from services.text_safety import strip_html_markup PreviewState = Literal["recognized", "pending", "failed", "unavailable"] @@ -67,6 +71,7 @@ class RepositoryAssetPreview: next_action: str error_code: str | None provider_write_executed: bool = False + edit_handoff: InkspanEditHandoff | None = None def _safe_paragraph(value: object) -> str: @@ -158,6 +163,12 @@ def _blocked_preview( ) +def _with_edit_handoff(preview: RepositoryAssetPreview) -> RepositoryAssetPreview: + """Attach a fail-closed Inkspan handoff without changing preview semantics.""" + + return replace(preview, edit_handoff=build_inkspan_edit_handoff(preview)) + + def build_attachment_preview( asset_key: str, attachment: object, @@ -170,30 +181,34 @@ def build_attachment_preview( is_hwpx = parser_family == HWPX_PARSER_FAMILY or parse_status.startswith("hwpx_") if parse_status in _DEFERRED_PENDING_STATUSES: - return _blocked_preview( - asset_key=asset_key, - asset_type="email_attachment", - preview_state="pending", - parser_family=parser_family, - next_action=NEXT_ACTION_WAIT_FOR_RECOGNITION, - error_code=( - ERROR_HWPX_RECOGNITION_PENDING - if is_hwpx - else "recognition_pending" - ), + return _with_edit_handoff( + _blocked_preview( + asset_key=asset_key, + asset_type="email_attachment", + preview_state="pending", + parser_family=parser_family, + next_action=NEXT_ACTION_WAIT_FOR_RECOGNITION, + error_code=( + ERROR_HWPX_RECOGNITION_PENDING + if is_hwpx + else "recognition_pending" + ), + ) ) if parse_status in _FAILED_PARSE_STATUSES: - return _blocked_preview( - asset_key=asset_key, - asset_type="email_attachment", - preview_state="failed", - parser_family=parser_family, - next_action=NEXT_ACTION_CHOOSE_ANOTHER_FILE, - error_code=( - ERROR_HWPX_RECOGNITION_FAILED - if is_hwpx - else "recognition_failed" - ), + return _with_edit_handoff( + _blocked_preview( + asset_key=asset_key, + asset_type="email_attachment", + preview_state="failed", + parser_family=parser_family, + next_action=NEXT_ACTION_CHOOSE_ANOTHER_FILE, + error_code=( + ERROR_HWPX_RECOGNITION_FAILED + if is_hwpx + else "recognition_failed" + ), + ) ) paragraph_texts = _paragraphs_from_segments( @@ -204,21 +219,25 @@ def build_attachment_preview( str(getattr(attachment, "content", "") or "") ) if paragraph_texts: - return _recognized_preview( + return _with_edit_handoff( + _recognized_preview( + asset_key=asset_key, + asset_type="email_attachment", + parser_family=parser_family, + paragraph_texts=paragraph_texts, + ) + ) + return _with_edit_handoff( + _blocked_preview( asset_key=asset_key, asset_type="email_attachment", + preview_state="failed", parser_family=parser_family, - paragraph_texts=paragraph_texts, + next_action=NEXT_ACTION_CHOOSE_ANOTHER_FILE, + error_code=( + ERROR_HWPX_RECOGNITION_FAILED if is_hwpx else "recognition_failed" + ), ) - return _blocked_preview( - asset_key=asset_key, - asset_type="email_attachment", - preview_state="failed", - parser_family=parser_family, - next_action=NEXT_ACTION_CHOOSE_ANOTHER_FILE, - error_code=( - ERROR_HWPX_RECOGNITION_FAILED if is_hwpx else "recognition_failed" - ), ) @@ -230,29 +249,35 @@ def build_document_preview( document_status = str(getattr(document, "document_status", "") or "") if document_status in _DOCUMENT_PENDING_STATUSES: - return _blocked_preview( - asset_key=asset_key, - asset_type="workspace_document", - preview_state="pending", - parser_family=None, - next_action=NEXT_ACTION_WAIT_FOR_RECOGNITION, - error_code="recognition_pending", + return _with_edit_handoff( + _blocked_preview( + asset_key=asset_key, + asset_type="workspace_document", + preview_state="pending", + parser_family=None, + next_action=NEXT_ACTION_WAIT_FOR_RECOGNITION, + error_code="recognition_pending", + ) ) paragraph_texts = _paragraphs_from_text( getattr(document, "document_content", None) ) if paragraph_texts: - return _recognized_preview( + return _with_edit_handoff( + _recognized_preview( + asset_key=asset_key, + asset_type="workspace_document", + parser_family=None, + paragraph_texts=paragraph_texts, + ) + ) + return _with_edit_handoff( + _blocked_preview( asset_key=asset_key, asset_type="workspace_document", + preview_state="failed", parser_family=None, - paragraph_texts=paragraph_texts, + next_action=NEXT_ACTION_CHOOSE_ANOTHER_FILE, + error_code="document_content_unavailable", ) - return _blocked_preview( - asset_key=asset_key, - asset_type="workspace_document", - preview_state="failed", - parser_family=None, - next_action=NEXT_ACTION_CHOOSE_ANOTHER_FILE, - error_code="document_content_unavailable", ) diff --git a/backend/tests/test_inkspan_edit_handoff.py b/backend/tests/test_inkspan_edit_handoff.py new file mode 100644 index 000000000..004275aff --- /dev/null +++ b/backend/tests/test_inkspan_edit_handoff.py @@ -0,0 +1,236 @@ +"""Read-only Inkspan edit handoff must fail closed without a Hangul engine. + +Mail preview can already show recognized HWPX paragraphs. These tests require an +explicit buyer-visible ``Edit in Inkspan`` capability probe that preserves the +exact attachment identity, refuses silent HWPX-to-text conversion, never +overwrites the original, and does not invent a write API. Released Inkspan is a +Markdown/HTML editor; Hangul import/edit/export remains unreleased, so the +default installed capability is absent. +""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from services.inkspan_edit_handoff import ( + EDITOR_CAPABILITY_HANGUL_DOCUMENT_ENGINE, + ERROR_INKSPAN_EDIT_CONTRACT_UNAVAILABLE, + ERROR_INKSPAN_HANGUL_CAPABILITY_UNAVAILABLE, + HANDOFF_STATE_UNAVAILABLE, + NEXT_ACTION_KEEP_READING_RECOGNIZED_TEXT, + build_inkspan_edit_handoff, + installed_inkspan_editor_capability, +) +from services.repository_asset_preview import ( + NEXT_ACTION_READ_RECOGNIZED_TEXT, + RepositoryAssetPreview, + build_attachment_preview, +) + + +def _recognized_hwpx_preview() -> RepositoryAssetPreview: + """Return one recognized HWPX preview with the exact source asset key.""" + + return RepositoryAssetPreview( + asset_key="asset_mail_hwpx_recognized", + asset_type="email_attachment", + preview_state="recognized", + parser_family="hwpx", + paragraph_texts=("Quarterly decision record", "Approve the next action."), + preview_text="Quarterly decision record\n\nApprove the next action.", + next_action=NEXT_ACTION_READ_RECOGNIZED_TEXT, + error_code=None, + provider_write_executed=False, + ) + + +def _hwpx_attachment(*, parse_status: str, content: str) -> SimpleNamespace: + """Build one in-memory HWPX attachment for preview-to-handoff tests.""" + + return SimpleNamespace( + filename="decision.hwpx", + content=content, + content_type="application/hwp+zip", + parse_content_type="application/hwp+zip", + parser_key="hwpx", + parse_status=parse_status, + parse_error_code=None, + content_segments=[ + SimpleNamespace( + ordinal_index=0, + safe_text_content="Quarterly decision record", + ), + SimpleNamespace( + ordinal_index=1, + safe_text_content="Approve the next action.", + ), + ], + ) + + +def test_installed_inkspan_editor_capability_is_absent_by_default() -> None: + """Naruon has no released/installed Inkspan Hangul engine adapter.""" + + assert installed_inkspan_editor_capability() is None + + +def test_recognized_hwpx_handoff_fails_closed_without_hangul_capability() -> None: + """Recognized HWPX keeps identity and tells the buyer to keep reading.""" + + handoff = build_inkspan_edit_handoff(_recognized_hwpx_preview()) + + assert handoff is not None + assert handoff.source_asset_key == "asset_mail_hwpx_recognized" + assert handoff.source_asset_type == "email_attachment" + assert handoff.parser_family == "hwpx" + assert handoff.handoff_state == HANDOFF_STATE_UNAVAILABLE + assert handoff.editor_capability_name == EDITOR_CAPABILITY_HANGUL_DOCUMENT_ENGINE + assert handoff.mutation_allowed is False + assert handoff.converts_source_to_plain_text is False + assert handoff.overwrites_original is False + assert handoff.provider_write_executed is False + assert handoff.next_action == NEXT_ACTION_KEEP_READING_RECOGNIZED_TEXT + assert handoff.error_code == ERROR_INKSPAN_HANGUL_CAPABILITY_UNAVAILABLE + assert handoff.editable_document_payload is None + assert "Quarterly decision record" not in repr(handoff) + + +def test_malformed_adapter_family_metadata_fails_closed_without_server_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Non-iterable accepted_source_families must not 500 a recognized preview.""" + + monkeypatch.setattr( + "services.inkspan_edit_handoff.registered_inkspan_editor_capability", + lambda: SimpleNamespace( + capability_name=EDITOR_CAPABILITY_HANGUL_DOCUMENT_ENGINE, + accepted_source_families=1, + mutation_contract_name=None, + ), + ) + + handoff = build_inkspan_edit_handoff(_recognized_hwpx_preview()) + + assert handoff is not None + assert handoff.handoff_state == HANDOFF_STATE_UNAVAILABLE + assert handoff.mutation_allowed is False + assert handoff.provider_write_executed is False + assert handoff.converts_source_to_plain_text is False + assert handoff.overwrites_original is False + assert handoff.editable_document_payload is None + assert handoff.source_asset_key == "asset_mail_hwpx_recognized" + assert handoff.error_code == ERROR_INKSPAN_HANGUL_CAPABILITY_UNAVAILABLE + assert handoff.next_action == NEXT_ACTION_KEEP_READING_RECOGNIZED_TEXT + + +def test_markdown_only_inkspan_adapter_is_rejected_as_silent_conversion( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Released Markdown/HTML Inkspan must not receive HWPX as plain text.""" + + monkeypatch.setattr( + "services.inkspan_edit_handoff.registered_inkspan_editor_capability", + lambda: SimpleNamespace( + capability_name="inkspan_markdown_html_editor", + accepted_source_families=("markdown", "html"), + mutation_contract_name=None, + ), + ) + + handoff = build_inkspan_edit_handoff(_recognized_hwpx_preview()) + + assert handoff is not None + assert handoff.handoff_state == HANDOFF_STATE_UNAVAILABLE + assert handoff.converts_source_to_plain_text is False + assert handoff.mutation_allowed is False + assert handoff.overwrites_original is False + assert handoff.provider_write_executed is False + assert handoff.editable_document_payload is None + assert handoff.source_asset_key == "asset_mail_hwpx_recognized" + assert handoff.error_code == ERROR_INKSPAN_HANGUL_CAPABILITY_UNAVAILABLE + assert handoff.next_action == NEXT_ACTION_KEEP_READING_RECOGNIZED_TEXT + + +def test_hangul_capability_without_edit_contract_still_refuses_mutation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A Hangul engine without an authorized editor contract cannot write.""" + + monkeypatch.setattr( + "services.inkspan_edit_handoff.registered_inkspan_editor_capability", + lambda: SimpleNamespace( + capability_name=EDITOR_CAPABILITY_HANGUL_DOCUMENT_ENGINE, + accepted_source_families=("hwpx", "hwp"), + mutation_contract_name=None, + ), + ) + + handoff = build_inkspan_edit_handoff(_recognized_hwpx_preview()) + + assert handoff is not None + assert handoff.handoff_state == HANDOFF_STATE_UNAVAILABLE + assert handoff.mutation_allowed is False + assert handoff.overwrites_original is False + assert handoff.provider_write_executed is False + assert handoff.converts_source_to_plain_text is False + assert handoff.editable_document_payload is None + assert handoff.source_asset_key == "asset_mail_hwpx_recognized" + assert handoff.error_code == ERROR_INKSPAN_EDIT_CONTRACT_UNAVAILABLE + assert handoff.next_action == NEXT_ACTION_KEEP_READING_RECOGNIZED_TEXT + + +def test_pending_and_non_hwpx_previews_do_not_offer_edit_handoff() -> None: + """Edit in Inkspan is only defined for recognized HWPX attachments.""" + + pending = build_attachment_preview( + "asset_mail_hwpx_pending", + SimpleNamespace( + filename="pending.hwpx", + content="UEsDBAoAAAAAAretained-hwpx-bytes", + content_type="application/hwp+zip", + parse_content_type="application/hwp+zip", + parser_key="hwpx", + parse_status="hwpx_xml_package_pending", + parse_error_code=None, + content_segments=[], + ), + ) + markdown = RepositoryAssetPreview( + asset_key="doc_repository_ready", + asset_type="workspace_document", + preview_state="recognized", + parser_family=None, + paragraph_texts=("# Q2 roadmap",), + preview_text="# Q2 roadmap", + next_action=NEXT_ACTION_READ_RECOGNIZED_TEXT, + error_code=None, + provider_write_executed=False, + ) + + assert pending.preview_state == "pending" + assert build_inkspan_edit_handoff(pending) is None + assert build_inkspan_edit_handoff(markdown) is None + + +def test_attachment_preview_attaches_fail_closed_handoff_for_recognized_hwpx() -> None: + """The existing preview contract carries the read-only Inkspan handoff.""" + + preview = build_attachment_preview( + "asset_mail_hwpx_recognized", + _hwpx_attachment( + parse_status="hwpx_xml_package_parsed", + content="Quarterly decision record\n\nApprove the next action.", + ), + ) + + assert preview.preview_state == "recognized" + assert preview.next_action == NEXT_ACTION_READ_RECOGNIZED_TEXT + assert preview.edit_handoff is not None + assert preview.edit_handoff.source_asset_key == preview.asset_key + assert preview.edit_handoff.handoff_state == HANDOFF_STATE_UNAVAILABLE + assert preview.edit_handoff.mutation_allowed is False + assert preview.edit_handoff.provider_write_executed is False + assert preview.edit_handoff.editable_document_payload is None + assert preview.provider_write_executed is False diff --git a/backend/tests/test_repository_asset_preview.py b/backend/tests/test_repository_asset_preview.py index 98c8bcfec..3b1f7533c 100644 --- a/backend/tests/test_repository_asset_preview.py +++ b/backend/tests/test_repository_asset_preview.py @@ -406,6 +406,16 @@ def test_preview_route_returns_recognized_hwpx_for_scoped_attachment() -> None: assert data["error_code"] is None assert data["next_action"] == NEXT_ACTION_READ_RECOGNIZED_TEXT assert data["provider_write_executed"] is False + handoff = data["edit_handoff"] + assert handoff["source_asset_key"] == asset_key + assert handoff["handoff_state"] == "unavailable" + assert handoff["mutation_allowed"] is False + assert handoff["converts_source_to_plain_text"] is False + assert handoff["overwrites_original"] is False + assert handoff["provider_write_executed"] is False + assert handoff["editable_document_payload"] is None + assert handoff["next_action"] == "keep_reading_recognized_text" + assert handoff["error_code"] == "inkspan_hangul_capability_unavailable" def test_preview_route_loads_only_the_matched_attachment_payload() -> None: diff --git a/docs/doctoring/hwp-hwpx-attachment-recognition.md b/docs/doctoring/hwp-hwpx-attachment-recognition.md index 856c23b5e..4c0618d3c 100644 --- a/docs/doctoring/hwp-hwpx-attachment-recognition.md +++ b/docs/doctoring/hwp-hwpx-attachment-recognition.md @@ -181,6 +181,7 @@ traceability, not a substitute for current-head gates. | Parsed attachment text + graph provenance | shared content-graph landing path | HWPX worker + recognizer tests | Active stacked PR #1373 | | Buyer-visible recognized HWPX paragraph preview | `backend/services/repository_asset_preview.py`, Data attachment view | `test_repository_asset_preview.py`, `RepositoryAssetPreviewPanel.test.tsx` | Active stacked preview PR | | Mail-detail HWPX preview via the same read-only contract | `backend/api/emails.py`, `MailAttachmentPreview` | `test_email_attachment_preview.py`, `MailAttachmentPreview.test.tsx` | Active stacked mail preview PR | +| Fail-closed Inkspan edit handoff for recognized HWPX | `backend/services/inkspan_edit_handoff.py`, preview panel | `test_inkspan_edit_handoff.py`, `MailAttachmentPreview.test.tsx` | Active stacked handoff PR | | Binary HWP conversion | future sandboxed converter | none yet | Planned / out of this slice | | HWPX tables, images, layout fidelity | future bounded recognizers | none yet | Planned / out of this slice | | Protected-`develop` shipped HWP/HWPX recognition | protected branch | integrated release gates | Not yet shipped | @@ -225,6 +226,24 @@ preview still means choose another file. Missing text is never presented as empty document content. Unknown and cross-workspace resources remain one indistinguishable 404. +## Buyer-visible Inkspan edit handoff — stacked on PR #1406 + +Reading recognized paragraphs is not an edit path. This slice adds an +accessible **Inkspan에서 편집** control on recognized HWPX preview. The +control preserves the exact opaque `asset_key` already authorized by the +preview lookup and does not create a new document, overwrite the original +attachment, or pass paragraph text to Markdown/HTML Inkspan. + +Released Inkspan remains a Markdown/HTML editor (ContextualWisdomLab, 2026). +Bounded HWP/HWPX import/edit/export is owned by unreleased inkspan Draft #320 +and is frozen under inkspan #118, so the host adapter is absent. The handoff +therefore fails closed and tells the buyer to keep reading the recognized +text or choose another file. No write API is introduced. + +This capability probe does not change KS X 6101/OWPML recognition or the +preview contract owned by PRs #1353, #1373, #1404, and #1406 (Korean Agency +for Technology and Standards, 2024; Hancom Tech, 2025b, 2025c). + ## Out of scope This slice does not reconstruct HWPX tables, images, charts, layout, styles, or @@ -265,6 +284,10 @@ https://tech.hancom.com/python-hwpx-parsing-1/ Hancom Tech. (2025d). *Parsing HWPX format with Python (Part 2)*. https://tech.hancom.com/python-hwpx-parsing-2/ +ContextualWisdomLab. (2026). *Inkspan: commercial-grade Markdown + HTML WYSIWYG +editor module* (Version 0.3.1) [Computer software]. +https://github.com/ContextualWisdomLab/inkspan/releases/tag/v0.3.1 + PKWARE, Inc. (2024). *APPNOTE.TXT: .ZIP file format specification*. https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT diff --git a/frontend/src/components/EmailDetail.test.tsx b/frontend/src/components/EmailDetail.test.tsx index 77dcc9cb6..8baaf6306 100644 --- a/frontend/src/components/EmailDetail.test.tsx +++ b/frontend/src/components/EmailDetail.test.tsx @@ -1314,6 +1314,20 @@ describe("EmailDetail", () => { next_action: "read_recognized_text", error_code: null, provider_write_executed: false, + edit_handoff: { + source_asset_key: "asset_mail_hwpx_recognized", + source_asset_type: "email_attachment", + parser_family: "hwpx", + handoff_state: "unavailable", + editor_capability_name: "inkspan_hangul_document_engine", + mutation_allowed: false, + converts_source_to_plain_text: false, + overwrites_original: false, + provider_write_executed: false, + next_action: "keep_reading_recognized_text", + error_code: "inkspan_hangul_capability_unavailable", + editable_document_payload: null, + }, })); } throw new Error(`Unexpected fetch: ${url}`); @@ -1342,6 +1356,10 @@ describe("EmailDetail", () => { expect(container.textContent).toContain("Quarterly decision record"); expect(container.textContent).toContain("Approve the next action."); expect(container.textContent).toContain("인식된 본문"); + expect(container.textContent).toContain("Inkspan에서 편집"); + expect(container.textContent).toContain( + "설치된 Inkspan에 HWPX 편집 기능이 없습니다. 인식된 본문을 계속 읽거나 다른 파일을 선택하세요.", + ); expect(container.textContent).not.toContain("본문이 없습니다"); expect(container.textContent).not.toContain("asset_mail_hwpx_recognized"); }); diff --git a/frontend/src/components/MailAttachmentPreview.test.tsx b/frontend/src/components/MailAttachmentPreview.test.tsx index 72d5aded6..a9f937650 100644 --- a/frontend/src/components/MailAttachmentPreview.test.tsx +++ b/frontend/src/components/MailAttachmentPreview.test.tsx @@ -63,6 +63,20 @@ describe("MailAttachmentPreview", () => { next_action: "read_recognized_text", error_code: null, provider_write_executed: false, + edit_handoff: { + source_asset_key: "asset_mail_hwpx_recognized", + source_asset_type: "email_attachment", + parser_family: "hwpx", + handoff_state: "unavailable", + editor_capability_name: "inkspan_hangul_document_engine", + mutation_allowed: false, + converts_source_to_plain_text: false, + overwrites_original: false, + provider_write_executed: false, + next_action: "keep_reading_recognized_text", + error_code: "inkspan_hangul_capability_unavailable", + editable_document_payload: null, + }, })); } throw new Error(`Unexpected fetch: ${url}`); @@ -92,6 +106,69 @@ describe("MailAttachmentPreview", () => { expect(container?.textContent).not.toContain("본문이 없습니다"); expect(container?.textContent).not.toContain("asset_mail_hwpx_recognized"); expect(container?.textContent).not.toContain("99"); + const editButton = container?.querySelector( + '[aria-label="decision.hwpx Inkspan에서 편집"]', + ); + expect(editButton?.disabled).toBe(true); + expect(container?.textContent).toContain( + "설치된 Inkspan에 HWPX 편집 기능이 없습니다. 인식된 본문을 계속 읽거나 다른 파일을 선택하세요.", + ); + }); + + it("does not POST or convert HWPX when the fail-closed Inkspan control is activated", async () => { + const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith("/api/data/repository-assets/asset_mail_hwpx_recognized/preview")) { + return Promise.resolve(jsonResponse({ + asset_key: "asset_mail_hwpx_recognized", + asset_type: "email_attachment", + preview_state: "recognized", + parser_family: "hwpx", + paragraph_texts: ["Quarterly decision record", "Approve the next action."], + preview_text: "Quarterly decision record\n\nApprove the next action.", + next_action: "read_recognized_text", + error_code: null, + provider_write_executed: false, + edit_handoff: { + source_asset_key: "asset_mail_hwpx_recognized", + source_asset_type: "email_attachment", + parser_family: "hwpx", + handoff_state: "unavailable", + editor_capability_name: "inkspan_hangul_document_engine", + mutation_allowed: false, + converts_source_to_plain_text: false, + overwrites_original: false, + provider_write_executed: false, + next_action: "keep_reading_recognized_text", + error_code: "inkspan_hangul_capability_unavailable", + editable_document_payload: null, + }, + })); + } + throw new Error(`Unexpected fetch: ${url} ${init?.method ?? "GET"}`); + }); + vi.stubGlobal("fetch", fetchMock); + + renderPreview([recognizedAttachment]); + await act(async () => { + Array.from(container?.querySelectorAll("button") ?? []).find( + (button) => button.textContent?.includes("decision.hwpx"), + )?.click(); + }); + await flushAsyncWork(); + + const editButton = container?.querySelector( + '[aria-label="decision.hwpx Inkspan에서 편집"]', + ); + expect(editButton?.disabled).toBe(true); + await act(async () => { + editButton?.click(); + }); + await flushAsyncWork(); + + expect(fetchMock.mock.calls.every(([, init]) => !init || !init.method || init.method === "GET")).toBe(true); + expect(container?.textContent).toContain("Quarterly decision record"); + expect(container?.textContent).not.toContain("asset_mail_hwpx_recognized"); }); it("tells the buyer to wait when mail HWPX recognition is still pending", async () => { diff --git a/frontend/src/components/MailAttachmentPreview.tsx b/frontend/src/components/MailAttachmentPreview.tsx index e408294dc..4bbc87fc9 100644 --- a/frontend/src/components/MailAttachmentPreview.tsx +++ b/frontend/src/components/MailAttachmentPreview.tsx @@ -88,6 +88,7 @@ export function MailAttachmentPreview({ attachments }: MailAttachmentPreviewProp {selectedAttachment ? ( void openAttachment(selectedAttachment)} /> diff --git a/frontend/src/components/data-layout/RepositoryAssetPreviewPanel.test.tsx b/frontend/src/components/data-layout/RepositoryAssetPreviewPanel.test.tsx index ab54da6a9..4591a67ff 100644 --- a/frontend/src/components/data-layout/RepositoryAssetPreviewPanel.test.tsx +++ b/frontend/src/components/data-layout/RepositoryAssetPreviewPanel.test.tsx @@ -4,7 +4,25 @@ import { createRoot, type Root } from "react-dom/client"; import { afterEach, describe, expect, it } from "vitest"; import { RepositoryAssetPreviewPanel } from "./RepositoryAssetPreviewPanel"; -import type { RepositoryAssetPreview } from "./types"; +import type { InkspanEditHandoff, RepositoryAssetPreview } from "./types"; +import { getInkspanEditHandoffNextActionLabel } from "./utils"; + +function unavailableHwpxHandoff(): InkspanEditHandoff { + return { + source_asset_key: "asset_hwpx_recognized", + source_asset_type: "email_attachment", + parser_family: "hwpx", + handoff_state: "unavailable", + editor_capability_name: "inkspan_hangul_document_engine", + mutation_allowed: false, + converts_source_to_plain_text: false, + overwrites_original: false, + provider_write_executed: false, + next_action: "keep_reading_recognized_text", + error_code: "inkspan_hangul_capability_unavailable", + editable_document_payload: null, + }; +} function recognizedPreview(): RepositoryAssetPreview { return { @@ -17,6 +35,17 @@ function recognizedPreview(): RepositoryAssetPreview { next_action: "read_recognized_text", error_code: null, provider_write_executed: false, + edit_handoff: unavailableHwpxHandoff(), + }; +} + +function recognizedHwpxWithMismatchedHandoff(): RepositoryAssetPreview { + return { + ...recognizedPreview(), + edit_handoff: { + ...unavailableHwpxHandoff(), + parser_family: "pdf", + }, }; } @@ -95,6 +124,77 @@ describe("RepositoryAssetPreviewPanel", () => { expect(container?.textContent).toContain("content and thread evidence ready"); }); + it("offers a fail-closed Edit in Inkspan control for recognized HWPX", () => { + renderPanel({ + currentDetailText: "content and thread evidence ready", + fileName: "decision.hwpx", + preview: recognizedPreview(), + }); + + const editButton = container?.querySelector( + '[aria-label="decision.hwpx Inkspan에서 편집"]', + ); + expect(editButton).not.toBeNull(); + expect(editButton?.disabled).toBe(true); + expect(editButton?.getAttribute("aria-disabled")).toBe("true"); + expect(container?.textContent).toContain("Inkspan에서 편집"); + expect(container?.textContent).toContain( + "설치된 Inkspan에 HWPX 편집 기능이 없습니다. 인식된 본문을 계속 읽거나 다른 파일을 선택하세요.", + ); + expect(container?.textContent).not.toContain("asset_hwpx_recognized"); + expect(container?.textContent).not.toContain("inkspan_hangul_document_engine"); + expect(getInkspanEditHandoffNextActionLabel("keep_reading_recognized_text")).toBe( + "인식된 본문을 계속 읽거나 다른 파일을 선택하세요.", + ); + }); + + it("hides the Inkspan control on a pending HWPX preview even if a handoff is present", () => { + renderPanel({ + currentDetailText: "content extraction pending, canonical thread pending", + fileName: "pending.hwpx", + preview: { + ...pendingPreview(), + edit_handoff: { + ...unavailableHwpxHandoff(), + source_asset_key: "asset_hwpx_pending", + }, + }, + }); + + const panel = container?.querySelector('[aria-label="선택한 자산 본문 미리보기"]'); + expect(panel?.querySelector('[aria-label="pending.hwpx Inkspan에서 편집"]')).toBeNull(); + expect(container?.textContent).not.toContain("Inkspan에서 편집"); + expect(panel?.textContent).toContain("인식이 끝날 때까지 기다리거나 다른 파일을 선택하세요"); + }); + + it("hides the Inkspan control on a recognized non-HWPX preview even if a handoff is present", () => { + renderPanel({ + currentDetailText: "content and thread evidence ready", + fileName: "roadmap.pdf", + preview: { + asset_key: "asset_pdf_recognized", + asset_type: "email_attachment", + preview_state: "recognized", + parser_family: "pdf", + paragraph_texts: ["Extracted roadmap PDF text"], + preview_text: "Extracted roadmap PDF text", + next_action: "read_recognized_text", + error_code: null, + provider_write_executed: false, + edit_handoff: { + ...unavailableHwpxHandoff(), + source_asset_key: "asset_pdf_recognized", + parser_family: "pdf", + }, + }, + }); + + const panel = container?.querySelector('[aria-label="선택한 자산 본문 미리보기"]'); + expect(panel?.textContent).toContain("Extracted roadmap PDF text"); + expect(panel?.querySelector('[aria-label="roadmap.pdf Inkspan에서 편집"]')).toBeNull(); + expect(container?.textContent).not.toContain("Inkspan에서 편집"); + }); + it("tells the buyer to wait when HWPX recognition is still pending", () => { renderPanel({ currentDetailText: "content extraction pending, canonical thread pending", @@ -122,6 +222,18 @@ describe("RepositoryAssetPreviewPanel", () => { expect(container?.textContent).toContain("content extraction pending"); }); + it("hides a stale handoff whose parser family does not match the preview", () => { + renderPanel({ + currentDetailText: "recognized HWPX text", + preview: recognizedHwpxWithMismatchedHandoff(), + }); + + const panel = container?.querySelector('[aria-label="선택한 자산 본문 미리보기"]'); + expect(panel?.querySelector('[aria-label="asset_hwpx_recognized Inkspan에서 편집"]')) + .toBeNull(); + expect(container?.textContent).not.toContain("Inkspan에서 편집"); + }); + it("fails closed on unmatched preview 404 without replacing current content", () => { renderPanel({ currentDetailText: "document status: uploaded", diff --git a/frontend/src/components/data-layout/RepositoryAssetPreviewPanel.tsx b/frontend/src/components/data-layout/RepositoryAssetPreviewPanel.tsx index 537583ae1..d7d8c7eda 100644 --- a/frontend/src/components/data-layout/RepositoryAssetPreviewPanel.tsx +++ b/frontend/src/components/data-layout/RepositoryAssetPreviewPanel.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { toSafeReactText } from '@/lib/safe-text'; import type { RepositoryAssetPreview } from './types'; import { + getInkspanEditHandoffUnavailableReason, getRepositoryAssetPreviewCopy, isRecognizedRepositoryAssetPreview, } from './utils'; @@ -9,6 +10,7 @@ import { type RepositoryAssetPreviewPanelProps = { currentDetailText: string; preview: RepositoryAssetPreview | null; + fileName?: string; onRefreshPreview?: () => void; }; @@ -16,6 +18,7 @@ type RepositoryAssetPreviewPanelProps = { export function RepositoryAssetPreviewPanel({ currentDetailText, preview, + fileName, onRefreshPreview, }: RepositoryAssetPreviewPanelProps) { const copy = preview @@ -25,6 +28,14 @@ export function RepositoryAssetPreviewPanel({ status_label: '미리보기 확인', }; const recognized = isRecognizedRepositoryAssetPreview(preview); + const editHandoff = preview?.edit_handoff ?? null; + const showInkspanHandoff = Boolean( + editHandoff + && isRecognizedRepositoryAssetPreview(preview) + && preview.parser_family === 'hwpx' + && editHandoff.parser_family === 'hwpx', + ); + const handoffFileName = toSafeReactText(fileName || '선택한 파일'); return (
@@ -45,6 +56,27 @@ export function RepositoryAssetPreviewPanel({ {copy.next_action_label}

)} + {showInkspanHandoff && editHandoff ? ( +
+ +

+ {getInkspanEditHandoffUnavailableReason(editHandoff.error_code)} +

+
+ ) : null} {preview?.preview_state === 'pending' ? (