From f7cc8dea6878b6ae6907e085bd6800be79c0fc59 Mon Sep 17 00:00:00 2001 From: liumingyang <1950307156@qq.com> Date: Sun, 16 Aug 2026 21:17:07 +0800 Subject: [PATCH 1/9] fix(api): distinguish candidate tokens from confirmed task tokens --- tests/unit/test_client.py | 23 ++++++++++++++++++++++- uniparser_tools/api/clients.py | 20 +++++++++++++------- uniparser_tools/api/transport.py | 14 ++++++++++++++ 3 files changed, 49 insertions(+), 8 deletions(-) diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index bcb4353..efffa47 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -127,7 +127,9 @@ def test_trigger_file_returns_error_dict_on_request_failure(self, tmp_path) -> N result = c.trigger_file(file_path=str(p)) assert isinstance(result, dict) assert result.get("status") == "error" - assert "token" in result + assert "token" not in result + assert result["candidate_token"] == c.to_token(str(p)) + assert result["candidate_token_recoverable"] is False class TestHTTPTransport: @@ -178,6 +180,25 @@ def test_http_error_preserves_json_body(self) -> None: assert result["description"] == "rate limited" assert result["http_status"] == 429 + @pytest.mark.parametrize("status_code", [200, 409]) + def test_trigger_error_keeps_candidate_token_separate(self, tmp_path, status_code: int) -> None: + path = tmp_path / "document.pdf" + path.write_bytes(b"%PDF-1.4 tiny") + session = FakeSession( + response=FakeResponse( + status_code=status_code, + payload={"status": "error", "description": "Token is duplicated"}, + reason="Conflict", + ) + ) + client = UniParserClient(host="https://example.com", api_key="k", session=session) + + result = client.trigger_file(str(path)) + + assert "token" not in result + assert result["candidate_token"] == client.to_token(str(path)) + assert result["candidate_token_recoverable"] is False + def test_client_context_closes_owned_session(self, monkeypatch) -> None: session = FakeSession() monkeypatch.setattr("uniparser_tools.api.transport.requests.Session", lambda: session) diff --git a/uniparser_tools/api/clients.py b/uniparser_tools/api/clients.py index f75e324..e6b38bb 100644 --- a/uniparser_tools/api/clients.py +++ b/uniparser_tools/api/clients.py @@ -307,16 +307,19 @@ def trigger_file( data=data, timeout=self._trigger_http_timeout(sync, http_timeout), error_message="trigger file failed", - token=token, + candidate_token=token, ) except OSError as exc: - return { + result = { "status": StatusFlag.Error, - "token": token, "message": "trigger file failed", "description": str(exc), "error_type": type(exc).__name__, } + if token is not None: + result["candidate_token"] = token + result["candidate_token_recoverable"] = False + return result def trigger_snip( self, @@ -377,16 +380,19 @@ def trigger_snip( data=data, timeout=self._trigger_http_timeout(sync, http_timeout), error_message="trigger snip failed", - token=token, + candidate_token=token, ) except (OSError, ValueError) as exc: - return { + result = { "status": StatusFlag.Error, - "token": token, "message": "trigger snip failed", "description": str(exc), "error_type": type(exc).__name__, } + if token is not None: + result["candidate_token"] = token + result["candidate_token_recoverable"] = False + return result def trigger_url( self, @@ -444,7 +450,7 @@ def trigger_url( json=data, timeout=self._trigger_http_timeout(sync, http_timeout), error_message="trigger url failed", - token=token, + candidate_token=token, ) def request_tos_upload_links( diff --git a/uniparser_tools/api/transport.py b/uniparser_tools/api/transport.py index 9fa71ce..7730d4c 100644 --- a/uniparser_tools/api/transport.py +++ b/uniparser_tools/api/transport.py @@ -65,6 +65,7 @@ def request( expect_json: bool = True, error_message: str = "request failed", token: Optional[str] = None, + candidate_token: Optional[str] = None, **kwargs, ) -> Any: headers = dict(kwargs.pop("headers", {}) or {}) @@ -89,6 +90,9 @@ def request( } if token is not None: payload["token"] = token + if candidate_token is not None: + payload["candidate_token"] = candidate_token + payload["candidate_token_recoverable"] = False return payload try: @@ -110,6 +114,9 @@ def request( result["http_status"] = response.status_code if token is not None: result.setdefault("token", token) + if candidate_token is not None: + result.setdefault("candidate_token", candidate_token) + result.setdefault("candidate_token_recoverable", False) return result if not expect_json: @@ -118,6 +125,10 @@ def request( "http_status": response.status_code, } + if isinstance(payload, dict) and payload.get("status") == StatusFlag.Error and candidate_token is not None: + payload.setdefault("candidate_token", candidate_token) + payload.setdefault("candidate_token_recoverable", False) + if payload is not None: return payload @@ -129,6 +140,9 @@ def request( } if token is not None: result["token"] = token + if candidate_token is not None: + result["candidate_token"] = candidate_token + result["candidate_token_recoverable"] = False return result def close(self) -> None: From 6ed28c59b50c7f238bada5e60c4a7808dd0ac55c Mon Sep 17 00:00:00 2001 From: liumingyang <1950307156@qq.com> Date: Sun, 16 Aug 2026 21:17:22 +0800 Subject: [PATCH 2/9] fix(cli): add resilient uploads and bound undefined polling --- tests/unit/test_cli.py | 134 +++++++++++++++++++- tests/unit/test_cli_pipeline.py | 173 ++++++++++++++++++++++++++ uniparser_tools/cli/commands/parse.py | 9 ++ uniparser_tools/cli/core/defaults.py | 5 +- uniparser_tools/cli/core/errors.py | 34 ++++- uniparser_tools/cli/core/pipeline.py | 134 ++++++++++++++++++-- 6 files changed, 468 insertions(+), 21 deletions(-) create mode 100644 tests/unit/test_cli_pipeline.py diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 2482963..d2a33eb 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -69,9 +69,12 @@ def test_parse_with_config_file_api_key( pdf = tmp_path / "paper.pdf" pdf.write_bytes(b"%PDF-1.4") + mock_client.upload_files_to_tos.return_value = {"status": "error", "description": "stop early"} out = tmp_path / "out" result = runner.invoke(cli, ["parse", str(pdf), "-o", str(out)]) assert result.exit_code == 1 + mock_client.upload_files_to_tos.assert_called_once_with([str(pdf.resolve())]) + mock_client.trigger_url.assert_not_called() mock_client.trigger_file.assert_called_once() def test_fetch_without_api_key(self, runner: CliRunner, env_without_api_key: None, no_config_file: None) -> None: @@ -139,7 +142,11 @@ def test_parse_success_writes_trigger_meta_and_json_token( pdf.write_bytes(b"%PDF-1.4") mock_client = MagicMock() - mock_client.trigger_file.return_value = {"status": "success", "token": "tok-parse-1"} + mock_client.upload_files_to_tos.return_value = { + "status": "success", + "files": [{"source_url": "tos://bucket/paper.pdf", "uploaded": True}], + } + mock_client.trigger_url.return_value = {"status": "success", "token": "tok-parse-1"} mock_client.get_result.side_effect = [ {"status": "processing"}, {"status": "success", "pages_tree": {}}, @@ -148,6 +155,7 @@ def test_parse_success_writes_trigger_meta_and_json_token( mock_client.get_formatted.return_value = {"status": "success", "content": "# Hi"} monkeypatch.setattr("uniparser_tools.cli.commands.parse.make_client", lambda ctx: (mock_client, None)) + monkeypatch.setattr("uniparser_tools.cli.core.pipeline.time.sleep", lambda _: None) out = tmp_path / "out" out.mkdir() @@ -171,6 +179,10 @@ def test_parse_success_writes_trigger_meta_and_json_token( assert meta["trigger_kwargs"]["sync"] is True assert "preset" not in meta assert (actual_out / "paper.md").is_file() + mock_client.trigger_file.assert_not_called() + _, trigger_kwargs = mock_client.trigger_url.call_args + assert trigger_kwargs["pdf_url"] == "tos://bucket/paper.pdf" + assert trigger_kwargs["server_generated_token"] is True def test_parse_default_trigger_kwargs( self, @@ -183,7 +195,11 @@ def test_parse_default_trigger_kwargs( pdf.write_bytes(b"%PDF-1.4") mock_client = MagicMock() - mock_client.trigger_file.return_value = {"status": "error", "description": "stop"} + mock_client.upload_files_to_tos.return_value = { + "status": "success", + "files": [{"source_url": "tos://bucket/paper.pdf", "uploaded": True}], + } + mock_client.trigger_url.return_value = {"status": "error", "description": "stop"} monkeypatch.setattr("uniparser_tools.cli.commands.parse.make_client", lambda ctx: (mock_client, None)) out = tmp_path / "out" @@ -193,7 +209,8 @@ def test_parse_default_trigger_kwargs( env={**os.environ, "UNIPARSER_API_KEY": "test-key"}, ) - _, call_kwargs = mock_client.trigger_file.call_args + _, call_kwargs = mock_client.trigger_url.call_args + assert call_kwargs["server_generated_token"] is True assert call_kwargs["sync"] is True assert call_kwargs["textual"] is ParseModeTextual.OCRHighQuality assert call_kwargs["equation"] is ParseMode.OCRHighQuality @@ -214,7 +231,11 @@ def test_parse_molecule_disable_override( pdf.write_bytes(b"%PDF-1.4") mock_client = MagicMock() - mock_client.trigger_file.return_value = {"status": "error", "description": "stop"} + mock_client.upload_files_to_tos.return_value = { + "status": "success", + "files": [{"source_url": "tos://bucket/paper.pdf", "uploaded": True}], + } + mock_client.trigger_url.return_value = {"status": "error", "description": "stop"} monkeypatch.setattr("uniparser_tools.cli.commands.parse.make_client", lambda ctx: (mock_client, None)) out = tmp_path / "out" @@ -224,10 +245,112 @@ def test_parse_molecule_disable_override( env={**os.environ, "UNIPARSER_API_KEY": "test-key"}, ) - _, call_kwargs = mock_client.trigger_file.call_args + _, call_kwargs = mock_client.trigger_url.call_args assert call_kwargs["molecule"] is ParseMode.Disable assert call_kwargs["table"] is ParseMode.OCRHighQuality + def test_parse_transport_failure_does_not_write_recoverable_token( + self, + runner: CliRunner, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ) -> None: + monkeypatch.setenv("UNIPARSER_API_KEY", "test-key") + pdf = tmp_path / "paper.pdf" + pdf.write_bytes(b"%PDF-1.4") + mock_client = MagicMock() + mock_client.upload_files_to_tos.return_value = { + "status": "success", + "files": [{"source_url": "tos://bucket/paper.pdf", "uploaded": True}], + } + mock_client.trigger_url.return_value = { + "status": "error", + "description": "The write operation timed out", + "candidate_token": "local-candidate", + "candidate_token_recoverable": False, + } + monkeypatch.setattr("uniparser_tools.cli.commands.parse.make_client", lambda ctx: (mock_client, None)) + + out = tmp_path / "out" + result = runner.invoke( + cli, + ["--json", "parse", str(pdf), "-o", str(out)], + env={**os.environ, "UNIPARSER_API_KEY": "test-key"}, + ) + + assert result.exit_code == 1 + payload = json.loads(result.stderr.strip().splitlines()[-1]) + assert payload["error"]["code"] == "PARSE_ERROR" + assert payload["candidate_token"] == "local-candidate" + assert payload["candidate_token_recoverable"] is False + assert "recoverable_token" not in payload + assert not (out / "trigger_meta.json").exists() + saved_error = json.loads((out / "trigger_error.json").read_text(encoding="utf-8")) + assert saved_error["candidate_token"] == "local-candidate" + + def test_parse_tos_upload_failure_uses_upload_error( + self, + runner: CliRunner, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ) -> None: + monkeypatch.setenv("UNIPARSER_API_KEY", "test-key") + pdf = tmp_path / "paper.pdf" + pdf.write_bytes(b"%PDF-1.4") + mock_client = MagicMock() + mock_client.upload_files_to_tos.return_value = { + "status": "error", + "description": "TOS upload failed", + } + mock_client.trigger_file.return_value = { + "status": "error", + "description": "Direct upload failed", + } + monkeypatch.setattr("uniparser_tools.cli.commands.parse.make_client", lambda ctx: (mock_client, None)) + + result = runner.invoke( + cli, + ["--json", "parse", str(pdf), "-o", str(tmp_path / "out")], + env={**os.environ, "UNIPARSER_API_KEY": "test-key"}, + ) + + assert result.exit_code == 1 + payload = json.loads(result.stderr.strip().splitlines()[-1]) + assert payload["error"]["code"] == "UPLOAD_ERROR" + assert payload["error"]["stage"] == "trigger_file_fallback" + mock_client.trigger_url.assert_not_called() + _, fallback_kwargs = mock_client.trigger_file.call_args + assert fallback_kwargs["server_generated_token"] is True + assert fallback_kwargs["http_timeout"] == (60.0, 1860.0) + + def test_parse_direct_upload_mode_skips_tos( + self, + runner: CliRunner, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ) -> None: + monkeypatch.setenv("UNIPARSER_API_KEY", "test-key") + pdf = tmp_path / "paper.pdf" + pdf.write_bytes(b"%PDF-1.4") + mock_client = MagicMock() + mock_client.trigger_file.return_value = { + "status": "error", + "description": "stop", + } + monkeypatch.setattr("uniparser_tools.cli.commands.parse.make_client", lambda ctx: (mock_client, None)) + + result = runner.invoke( + cli, + ["parse", str(pdf), "--upload-mode", "direct", "-o", str(tmp_path / "out")], + env={**os.environ, "UNIPARSER_API_KEY": "test-key"}, + ) + + assert result.exit_code == 1 + mock_client.upload_files_to_tos.assert_not_called() + _, direct_kwargs = mock_client.trigger_file.call_args + assert direct_kwargs["server_generated_token"] is True + assert direct_kwargs["http_timeout"] == (60.0, 1860.0) + def test_parse_invalid_equation_mode_rejected( self, runner: CliRunner, @@ -339,6 +462,7 @@ def test_parse_help(self, runner: CliRunner) -> None: result = runner.invoke(cli, ["parse", "--help"]) assert result.exit_code == 0 assert "--output-dir" in result.stdout + assert "--upload-mode" in result.stdout assert "--async" in result.stdout assert "--textual" in result.stdout assert "--molecule" in result.stdout diff --git a/tests/unit/test_cli_pipeline.py b/tests/unit/test_cli_pipeline.py new file mode 100644 index 0000000..888135a --- /dev/null +++ b/tests/unit/test_cli_pipeline.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import MagicMock + +from uniparser_tools.cli.core.input import InputKind, ResolvedInput +from uniparser_tools.cli.core.pipeline import ( + annotate_recoverable_duplicate, + poll_until_success, + trigger_input, +) + + +def _local_pdf(path: Path) -> ResolvedInput: + return ResolvedInput( + kind=InputKind.FILE, + source_stem=path.stem, + raw=str(path), + path=path, + ) + + +def test_local_pdf_uploads_to_tos_then_uses_server_token(tmp_path: Path) -> None: + pdf = tmp_path / "paper.pdf" + pdf.write_bytes(b"%PDF-1.4") + client = MagicMock() + client.upload_files_to_tos.return_value = { + "status": "success", + "files": [{"source_url": "tos://bucket/paper.pdf", "uploaded": True}], + } + client.trigger_url.return_value = {"status": "success", "token": "server-token"} + + result, stage = trigger_input(client, _local_pdf(pdf), trigger_kwargs={"sync": True}) + + assert result == {"status": "success", "token": "server-token"} + assert stage == "trigger_url" + client.upload_files_to_tos.assert_called_once_with([str(pdf)]) + client.trigger_file.assert_not_called() + client.trigger_url.assert_called_once_with( + pdf_url="tos://bucket/paper.pdf", + server_generated_token=True, + sync=True, + ) + + +def test_tos_upload_failure_falls_back_to_direct_upload(tmp_path: Path) -> None: + pdf = tmp_path / "paper.pdf" + pdf.write_bytes(b"%PDF-1.4") + client = MagicMock() + client.upload_files_to_tos.return_value = { + "status": "error", + "message": "TOS upload failed", + } + client.trigger_file.return_value = {"status": "success", "token": "server-token"} + + result, stage = trigger_input(client, _local_pdf(pdf), trigger_kwargs={"sync": True}) + + assert result == {"status": "success", "token": "server-token"} + assert stage == "trigger_file_fallback" + client.trigger_url.assert_not_called() + client.trigger_file.assert_called_once_with( + file_path=str(pdf), + server_generated_token=True, + http_timeout=(60.0, 1860.0), + sync=True, + ) + + +def test_direct_upload_mode_skips_tos(tmp_path: Path) -> None: + pdf = tmp_path / "paper.pdf" + pdf.write_bytes(b"%PDF-1.4") + client = MagicMock() + client.trigger_file.return_value = {"status": "success", "token": "server-token"} + + result, stage = trigger_input( + client, + _local_pdf(pdf), + trigger_kwargs={"sync": False}, + upload_mode="direct", + ) + + assert result == {"status": "success", "token": "server-token"} + assert stage == "trigger_file_direct" + client.upload_files_to_tos.assert_not_called() + client.trigger_file.assert_called_once_with( + file_path=str(pdf), + server_generated_token=True, + http_timeout=(60.0, 60.0), + sync=False, + ) + + +def test_tos_upload_without_source_url_falls_back_to_direct_upload(tmp_path: Path) -> None: + pdf = tmp_path / "paper.pdf" + pdf.write_bytes(b"%PDF-1.4") + client = MagicMock() + client.upload_files_to_tos.return_value = {"status": "success", "files": [{}]} + client.trigger_file.return_value = {"status": "error", "message": "Direct upload failed"} + + result, stage = trigger_input(client, _local_pdf(pdf), trigger_kwargs={"sync": True}) + + assert result["status"] == "error" + assert result["message"] == "Direct upload failed" + assert result["tos_upload_error"]["message"] == "TOS upload response missing source_url" + assert stage == "trigger_file_fallback" + client.trigger_url.assert_not_called() + + +def test_duplicate_candidate_becomes_recoverable_only_when_confirmed() -> None: + client = MagicMock() + client.get_result.return_value = {"status": "waiting"} + trigger = { + "status": "error", + "description": "Token is duplicated", + "candidate_token": "candidate", + "candidate_token_recoverable": False, + } + + result = annotate_recoverable_duplicate(client, trigger) + + assert result["recoverable_token"] == "candidate" + assert "candidate_token" not in result + assert result["token_status"] == "waiting" + + +def test_duplicate_candidate_stays_unrecoverable_when_undefined(monkeypatch) -> None: + client = MagicMock() + client.get_result.return_value = {"status": "undefined"} + monkeypatch.setattr("uniparser_tools.cli.core.pipeline.time.sleep", lambda _: None) + trigger = { + "status": "error", + "description": "Token is duplicated", + "token": "candidate", + } + + result = annotate_recoverable_duplicate(client, trigger) + + assert "recoverable_token" not in result + assert "token" not in result + assert result["candidate_token"] == "candidate" + assert result["candidate_token_recoverable"] is False + assert result["token_status"] == "undefined" + assert client.get_result.call_count == 3 + + +def test_undefined_token_stops_after_bounded_checks(monkeypatch, capsys) -> None: + client = MagicMock() + client.get_result.return_value = {"status": "undefined"} + monkeypatch.setattr("uniparser_tools.cli.core.pipeline.time.sleep", lambda _: None) + + result = poll_until_success(client, "missing-token") + + assert result == 1 + assert client.get_result.call_count == 3 + payload = json.loads(capsys.readouterr().err) + assert payload["error"]["code"] == "TOKEN_NOT_FOUND" + assert payload["unrecognized_token"] == "missing-token" + + +def test_pending_statuses_still_reach_success(monkeypatch) -> None: + client = MagicMock() + client.get_result.side_effect = [ + {"status": "waiting"}, + {"status": "processing"}, + {"status": "success"}, + ] + monkeypatch.setattr("uniparser_tools.cli.core.pipeline.time.sleep", lambda _: None) + + result = poll_until_success(client, "valid-token") + + assert result == {"status": "success"} + assert client.get_result.call_count == 3 diff --git a/uniparser_tools/cli/commands/parse.py b/uniparser_tools/cli/commands/parse.py index 3f58b8c..e6ecfb9 100644 --- a/uniparser_tools/cli/commands/parse.py +++ b/uniparser_tools/cli/commands/parse.py @@ -26,6 +26,13 @@ def _parse_mode_option(name: str, help_text: str): "-o", help="Preferred output directory; an available suffixed sibling is used if it exists", ) +@click.option( + "--upload-mode", + type=click.Choice(["auto", "tos", "direct"], case_sensitive=False), + default="auto", + show_default=True, + help="Local PDF upload path: TOS-first auto fallback, TOS only, or direct only", +) @click.option("--async", "async_mode", is_flag=True, help="Submit with sync=false and poll until success") @click.option( "--textual", @@ -44,6 +51,7 @@ def parse_cmd( ctx: click.Context, source: str, output_dir: str | None, + upload_mode: str, async_mode: bool, textual: str | None, equation: str | None, @@ -85,6 +93,7 @@ def parse_cmd( resolved, out_dir=out_dir, trigger_kwargs=trigger_kwargs, + upload_mode=upload_mode, ) if isinstance(code, int): raise SystemExit(code) diff --git a/uniparser_tools/cli/core/defaults.py b/uniparser_tools/cli/core/defaults.py index 986e8bd..ff2061e 100644 --- a/uniparser_tools/cli/core/defaults.py +++ b/uniparser_tools/cli/core/defaults.py @@ -2,7 +2,10 @@ POLL_INTERVAL_SEC = 3 POLL_TIMEOUT_SEC = 1800 +UNDEFINED_MAX_POLLS = 3 +DIRECT_UPLOAD_REQUEST_TIMEOUT = (60.0, 60.0) +DIRECT_SYNC_UPLOAD_REQUEST_TIMEOUT = (60.0, 1860.0) -PENDING_STATUSES = frozenset({"undefined", "waiting", "processing"}) +PENDING_STATUSES = frozenset({"waiting", "processing"}) IMAGE_SUFFIXES = frozenset({".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tif", ".tiff"}) diff --git a/uniparser_tools/cli/core/errors.py b/uniparser_tools/cli/core/errors.py index 571fc0a..09c4e62 100644 --- a/uniparser_tools/cli/core/errors.py +++ b/uniparser_tools/cli/core/errors.py @@ -31,16 +31,42 @@ def missing_token_error() -> int: return 1 +def _operation_error(code: str, stage: str, result: dict) -> int: + payload = { + "ok": False, + "error": { + "code": code, + "message": result.get("description") or result.get("message") or str(result), + "stage": stage, + }, + } + if result.get("recoverable_token"): + payload["recoverable_token"] = result["recoverable_token"] + if result.get("candidate_token"): + payload["candidate_token"] = result["candidate_token"] + payload["candidate_token_recoverable"] = False + emit_json_stderr(payload) + return 1 + + def parse_error(stage: str, result: dict) -> int: + return _operation_error("PARSE_ERROR", stage, result) + + +def upload_error(stage: str, result: dict) -> int: + return _operation_error("UPLOAD_ERROR", stage, result) + + +def token_not_found_error(token: str, *, attempts: int) -> int: emit_json_stderr( { "ok": False, "error": { - "code": "PARSE_ERROR", - "message": result.get("description") or result.get("message") or str(result), - "stage": stage, + "code": "TOKEN_NOT_FOUND", + "message": f"The service did not recognize this token after {attempts} checks.", + "stage": "get_result_poll", }, - "token": result.get("token"), + "unrecognized_token": token, } ) return 1 diff --git a/uniparser_tools/cli/core/pipeline.py b/uniparser_tools/cli/core/pipeline.py index 91d95a9..7b97862 100644 --- a/uniparser_tools/cli/core/pipeline.py +++ b/uniparser_tools/cli/core/pipeline.py @@ -5,8 +5,15 @@ from pathlib import Path from typing import Any -from uniparser_tools.cli.core.defaults import PENDING_STATUSES, POLL_INTERVAL_SEC, POLL_TIMEOUT_SEC -from uniparser_tools.cli.core.errors import parse_error +from uniparser_tools.cli.core.defaults import ( + DIRECT_SYNC_UPLOAD_REQUEST_TIMEOUT, + DIRECT_UPLOAD_REQUEST_TIMEOUT, + PENDING_STATUSES, + POLL_INTERVAL_SEC, + POLL_TIMEOUT_SEC, + UNDEFINED_MAX_POLLS, +) +from uniparser_tools.cli.core.errors import parse_error, token_not_found_error, upload_error from uniparser_tools.cli.core.input import InputKind, ResolvedInput, display_label_for_input from uniparser_tools.cli.core.output import print_parsing_status, save_parse_results, write_trigger_meta from uniparser_tools.cli.core.parse_options import resolve_trigger_kwargs, serialize_trigger_kwargs @@ -16,23 +23,82 @@ def scientific_paper_trigger_kwargs(*, sync: bool = True) -> dict: return resolve_trigger_kwargs(sync=sync, overrides={}) -def trigger_input(client, resolved: ResolvedInput, *, trigger_kwargs: dict) -> tuple[dict, str]: +def trigger_input( + client, + resolved: ResolvedInput, + *, + trigger_kwargs: dict, + upload_mode: str = "auto", +) -> tuple[dict, str]: kwargs = trigger_kwargs if resolved.kind is InputKind.FILE: - trigger = client.trigger_file(file_path=str(resolved.path), **kwargs) - return trigger, "trigger_file" + upload = None + if upload_mode != "direct": + upload = client.upload_files_to_tos([str(resolved.path)]) + if not isinstance(upload, dict): + upload = { + "status": "error", + "message": "TOS upload returned an invalid response", + } + + if upload.get("status") == "success": + uploaded_files = upload.get("files") + uploaded_file = uploaded_files[0] if isinstance(uploaded_files, list) and uploaded_files else None + source_url = uploaded_file.get("source_url") if isinstance(uploaded_file, dict) else None + if isinstance(source_url, str) and source_url: + trigger = client.trigger_url( + pdf_url=source_url, + server_generated_token=True, + **kwargs, + ) + return trigger, "trigger_url" + upload = { + "status": "error", + "message": "TOS upload response missing source_url", + } + + if upload_mode == "tos": + return upload, "upload_tos" + + trigger = client.trigger_file( + file_path=str(resolved.path), + server_generated_token=True, + http_timeout=( + DIRECT_SYNC_UPLOAD_REQUEST_TIMEOUT if kwargs.get("sync", True) else DIRECT_UPLOAD_REQUEST_TIMEOUT + ), + **kwargs, + ) + if not isinstance(trigger, dict): + trigger = { + "status": "error", + "message": "Direct upload returned an invalid response", + } + if trigger.get("status") != "success": + if upload is not None: + trigger["tos_upload_error"] = upload + stage = "trigger_file_direct" if upload_mode == "direct" else "trigger_file_fallback" + return trigger, stage if resolved.kind is InputKind.IMAGE: - trigger = client.trigger_snip(snip_path=str(resolved.path), **kwargs) + trigger = client.trigger_snip( + snip_path=str(resolved.path), + server_generated_token=True, + **kwargs, + ) return trigger, "trigger_snip" - trigger = client.trigger_url(pdf_url=resolved.raw, **kwargs) + trigger = client.trigger_url( + pdf_url=resolved.raw, + server_generated_token=True, + **kwargs, + ) return trigger, "trigger_url" def poll_until_success(client, token: str) -> dict | int: - deadline = time.time() + POLL_TIMEOUT_SEC + deadline = time.monotonic() + POLL_TIMEOUT_SEC last: dict[str, Any] = {} + undefined_polls = 0 - while time.time() < deadline: + while time.monotonic() < deadline: last = client.get_result( token, content=False, @@ -45,7 +111,14 @@ def poll_until_success(client, token: str) -> dict | int: return last if status == "error": return parse_error("get_result_poll", last) - if status in PENDING_STATUSES or status is None: + if status == "undefined": + undefined_polls += 1 + if undefined_polls >= UNDEFINED_MAX_POLLS: + return token_not_found_error(token, attempts=undefined_polls) + time.sleep(POLL_INTERVAL_SEC) + continue + if status in PENDING_STATUSES: + undefined_polls = 0 time.sleep(POLL_INTERVAL_SEC) continue return parse_error("get_result_poll", last) @@ -82,6 +155,41 @@ def save_stage_error(out_dir: Path, filename: str, payload: dict) -> None: (out_dir / filename).write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8") +def annotate_recoverable_duplicate(client, trigger: dict) -> dict: + diagnostic = " ".join(str(trigger.get(key, "")) for key in ("message", "description")).casefold() + if "duplicat" not in diagnostic: + return trigger + + candidate = trigger.get("token") or trigger.get("candidate_token") + if not candidate: + return trigger + + status = None + for attempt in range(UNDEFINED_MAX_POLLS): + probe = client.get_result( + candidate, + content=False, + objects=False, + pages_dict=False, + pages_tree=False, + ) + status = probe.get("status") if isinstance(probe, dict) else None + if status != "undefined" or attempt == UNDEFINED_MAX_POLLS - 1: + break + time.sleep(POLL_INTERVAL_SEC) + trigger["token_status"] = status + if status == "success" or status in PENDING_STATUSES: + trigger["recoverable_token"] = candidate + trigger.pop("candidate_token", None) + trigger.pop("candidate_token_recoverable", None) + return trigger + + trigger.pop("token", None) + trigger["candidate_token"] = candidate + trigger["candidate_token_recoverable"] = False + return trigger + + def complete_fetch( client, token: str, @@ -119,11 +227,15 @@ def run_parse( *, out_dir: Path, trigger_kwargs: dict, + upload_mode: str = "auto", ) -> dict[str, Any] | int: print_parsing_status(display_label_for_input(resolved)) - trigger, stage = trigger_input(client, resolved, trigger_kwargs=trigger_kwargs) + trigger, stage = trigger_input(client, resolved, trigger_kwargs=trigger_kwargs, upload_mode=upload_mode) if trigger.get("status") != "success": + trigger = annotate_recoverable_duplicate(client, trigger) save_stage_error(out_dir, "trigger_error.json", trigger) + if stage in {"upload_tos", "trigger_file_direct", "trigger_file_fallback"}: + return upload_error(stage, trigger) return parse_error(stage, trigger) token = trigger.get("token") From 357bfbfa4cfb7f72c6c551df776aaeb9a8a1de51 Mon Sep 17 00:00:00 2001 From: liumingyang <1950307156@qq.com> Date: Sun, 16 Aug 2026 21:17:37 +0800 Subject: [PATCH 3/9] docs(skill): restrict token recovery to confirmed tasks --- README.md | 2 ++ skills/UniParser-Tools/SKILL.md | 17 ++++++++++++----- tests/unit/test_notebook_hygiene.py | 9 +++++++++ uniparser_tools/cli/README.md | 22 ++++++++++++++++++++-- 4 files changed, 43 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index fea1eb1..4b8a1ed 100644 --- a/README.md +++ b/README.md @@ -555,6 +555,8 @@ export UNIPARSER_API_KEY="your-api-key" Agent 会回复 Markdown 路径,并在需要版面结构时提供 `pages_tree.json`。大文档可能耗时数分钟至十余分钟;中断或重复任务可按 Skill 说明用 `trigger_meta.json` 中的 token 执行 `uniparser fetch`。 +CLI 解析本地 PDF 时默认先上传到 TOS,再通过 `trigger_url(server_generated_token=True)` 创建任务;TOS 不可达时会用更长上传窗口执行一次直传回退。传输失败时的 `candidate_token` 未经服务端确认,不能用于 `fetch`;只有成功输出、`trigger_meta.json` 或明确的 `recoverable_token` 才能恢复。 + Agent 实现细节、CLI 命令与错误恢复见 [SKILL.md](./skills/UniParser-Tools/SKILL.md)。 ## MCP Server diff --git a/skills/UniParser-Tools/SKILL.md b/skills/UniParser-Tools/SKILL.md index 0dd4b99..37da782 100644 --- a/skills/UniParser-Tools/SKILL.md +++ b/skills/UniParser-Tools/SKILL.md @@ -77,7 +77,7 @@ $env:UNIPARSER_API_KEY="your-api-key" ### CLI reference -**Pipeline:** `submit → poll get_result until success → fetch pages_tree + Markdown → save`. Default `sync=true` (trigger blocks until server done; CLI still polls and fetches). `--async` uses `sync=false` on submit only. +**Pipeline:** local PDFs first use `request TOS upload → PUT file → trigger_url with a server-generated token`. If the TOS host is unreachable, the CLI makes one direct-upload fallback with a longer upload window and a server-generated token. Images use `trigger_snip`; public URLs use `trigger_url`. The CLI then polls `get_result` until success, fetches `pages_tree` + Markdown, and saves them. Default `sync=true`; `--async` uses `sync=false` on the trigger only. **Input:** one positional `INPUT` per run—the CLI detects type by path suffix or `http(s)://` URL: @@ -96,15 +96,18 @@ Optional flags: ```bash uniparser parse "./paper.pdf" -o "./results" uniparser parse "./paper.pdf" --async +uniparser parse "./paper.pdf" --upload-mode direct ``` +Use the default `--upload-mode auto` unless TOS connectivity is known to be blocked. `direct` skips TOS; `tos` disables the direct fallback. + Recovery (existing server job—see **Common issues**): ```bash uniparser fetch --token "TASK_TOKEN_FROM_PRIOR_RUN" ``` -Token sources: stdout JSON from a prior `uniparser --json parse …`, `trigger_meta.json` under the output directory, or the `token` field in a failed parse stderr JSON. +Valid recovery tokens come only from successful `uniparser --json parse …` stdout, `trigger_meta.json` written after a successful trigger, or a failed command's explicit `recoverable_token` field. Never pass `candidate_token` to `fetch`; it is diagnostic only and has not been confirmed by the service. **Default output for** `fetch` (when `-o` / `--output-dir` is omitted): `~/Uni-Parser-Skill/token_/`, where `` is the first 8 characters of the token (e.g. `~/Uni-Parser-Skill/token_a1b2c3d4/token_a1b2c3d4.md`). Passing a prior `parse` directory with `-o` treats it only as the preferred path; because that directory already exists, `fetch` writes to an available sibling such as `paper_1`. Always use the returned `output_dir`. @@ -184,7 +187,9 @@ uniparser parse paper.pdf --json # wrong If the preferred output directory already exists, the CLI creates an available sibling such as `results_1` or `results_2`. Existing paths are never reused or deleted; use the returned `output_dir`. -**Common error codes** (stderr JSON): `CONFIG_ERROR`, `INPUT_ERROR`, `PARSE_ERROR`. +Failure JSON may include `recoverable_token` when the service confirms an existing task. A `candidate_token` is never recoverable. + +**Common error codes** (stderr JSON): `CONFIG_ERROR`, `INPUT_ERROR`, `UPLOAD_ERROR`, `PARSE_ERROR`, `TOKEN_NOT_FOUND`. ## Common issues @@ -194,13 +199,15 @@ On failure, show stderr JSON `error.message`. Do not substitute vision-only read | Problem | Cause | Solution | | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `CONFIG_ERROR` | No API key or `uniparser` not installed | **Configuration** + `pip install "git+https://github.com/dptech-corp/UniParser-Tools.git"`; `uniparser auth --verify` | -| `Token is duplicated` | Job for this API key + exact input already exists | Do **not** re-run `uniparser parse`. Read `token` from stderr JSON or `trigger_meta.json`; run `uniparser fetch --token TOKEN` | +| `UPLOAD_ERROR` / write timeout | Both TOS-first upload and the direct fallback failed; no parse task was confirmed | Re-run `uniparser parse`; do not use `candidate_token` with `fetch` | +| `Token is duplicated` + `recoverable_token` | The service confirmed an existing task | Do **not** re-run `parse`; run `uniparser fetch --token RECOVERABLE_TOKEN` | +| `TOKEN_NOT_FOUND` / `status: undefined` | The service did not recognize the token after three checks | Stop fetching. If this came from `candidate_token`, re-run `parse`; otherwise verify that the saved token is current | | Job not done / long wait / CLI interrupted / `processing` / poll timeout | Sync or poll still running; or local process stopped while server job continues | Wait; do **not** start a second `uniparser parse` for the same input. Use saved `token` with `uniparser fetch --token TOKEN`; files appear only after exit 0 | | `502 Bad Gateway` on URL input | Server failed fetching or processing remote PDF | Retry `uniparser parse "same url"` once; or download and `uniparser parse local.pdf`; or `uniparser fetch --token TOKEN` if a prior job exists | | `PARSE_ERROR` | Server `status: error` at trigger / poll / fetch | Read `error.message` and `stage`; match rows above; check `trigger_error.json` / `pages_tree_error.json` / `formatted_error.json` under output dir if present | -**Limits:** large PDFs may take 10–20+ minutes; public service ≤5 concurrent requests; PDF URLs must be publicly accessible. Parsing can be inaccurate, so verify critical content against the source. Results are retained for only 24 hours—fetch and save them promptly. See [Important notes](./references/notes.md). Save `token` from success JSON or `trigger_meta.json` for recovery after interrupt or duplicate-token errors. +**Limits:** large PDFs may take 10–20+ minutes; public service ≤5 concurrent requests; PDF URLs must be publicly accessible. Parsing can be inaccurate, so verify critical content against the source. Results are retained for only 24 hours—fetch and save them promptly. See [Important notes](./references/notes.md). Save `token` from success JSON or `trigger_meta.json`; use an error token only when it is named `recoverable_token`. ## Advanced diff --git a/tests/unit/test_notebook_hygiene.py b/tests/unit/test_notebook_hygiene.py index e3173ca..cc9281d 100644 --- a/tests/unit/test_notebook_hygiene.py +++ b/tests/unit/test_notebook_hygiene.py @@ -53,3 +53,12 @@ def test_customer_docs_include_result_retention_notice() -> None: assert "24 小时" in readme assert "retained for only 24 hours" in skill assert "retained for only **24 hours**" in notes + + +def test_skill_only_recovers_service_confirmed_tokens() -> None: + skill = (REPO_ROOT / "skills" / "UniParser-Tools" / "SKILL.md").read_text(encoding="utf-8") + + assert "recoverable_token" in skill + assert "candidate_token" in skill + assert "Never pass `candidate_token` to `fetch`" in skill + assert "the `token` field in a failed parse stderr JSON" not in skill diff --git a/uniparser_tools/cli/README.md b/uniparser_tools/cli/README.md index 0c2df24..680a0e3 100644 --- a/uniparser_tools/cli/README.md +++ b/uniparser_tools/cli/README.md @@ -47,6 +47,8 @@ uniparser auth uniparser parse /path/to/report.pdf ``` +本地 PDF 默认先上传到 TOS,再通过 `trigger_url` 使用服务端生成的 token 创建任务,避免大文件直接 multipart 上传受短 socket 写入窗口影响。若当前网络无法连接 TOS,CLI 会使用更长上传窗口做一次直传回退;两条链路都只接受服务端成功响应中的 token。上传成功本身不会启动解析;只有后续 trigger 成功才会记录可恢复 token。 + 默认把结果保存到: ```text @@ -183,6 +185,7 @@ uniparser parse https://example.com/paper.pdf | 选项 | 说明 | |------|------| | `-o` / `--output-dir DIR` | 首选输出目录(默认 `~/Uni-Parser-Skill/<文件名>/`);已存在时自动使用同级后缀目录 | +| `--upload-mode auto|tos|direct` | 本地 PDF 上传路径;默认 `auto` 为 TOS 优先并直传回退,`direct` 跳过 TOS,`tos` 禁用回退 | | `--async` | 异步提交任务(适合较大文档) | ### 解析配置(7 类语义) @@ -341,7 +344,7 @@ uniparser parse paper.pdf -o ./out/paper uniparser fetch --token YOUR_TOKEN ``` -token 可在上次 `parse` 输出目录的 `trigger_meta.json` 中找到。 +token 可在成功 `parse` 的 JSON 输出或 `trigger_meta.json` 中找到。失败输出只有明确标记为 `recoverable_token` 的值才能用于 `fetch`;`candidate_token` 只是本地候选值,禁止用于恢复。 | 选项 | 说明 | |------|------| @@ -433,7 +436,10 @@ Parsing... report.pdf uniparser --json parse paper.pdf 2>/dev/null | python3 -c "import sys,json; print(json.load(sys.stdin)['markdown_path'])" ``` -失败时仍为 **stderr 单行 JSON**(`ok: false`),见上文 parse「常见错误」;exit code 为 1。 +失败时仍为 **stderr 单行 JSON**(`ok: false`),见下方「常见问题」;exit code 为 1。失败 JSON 可能包含: + +- `recoverable_token`:服务端已确认存在,可用于 `fetch`。 +- `candidate_token`:服务端未确认,仅供诊断,不能用于 `fetch`。 --- @@ -459,6 +465,18 @@ uniparser --json parse paper.pdf 2>/dev/null | python3 -c "import sys,json; prin 属正常现象;请等待 `Parsing...` 出现后保持终端不要关闭。若中断,用 `trigger_meta.json` 中的 token 执行 `uniparser fetch`。 +**提示 `UPLOAD_ERROR` / write timeout** + +TOS 优先上传与一次直传回退都失败,且没有确认创建解析任务,可以重新执行 `parse`。错误中的 `candidate_token` 不能交给 `fetch`。 + +**提示 `TOKEN_NOT_FOUND` 或持续 `status: undefined`** + +CLI 会在三次检查后停止,不会继续等待 1800 秒。若该值来自 `candidate_token`,请重新执行 `parse`;若来自历史 `trigger_meta.json`,请确认任务结果是否仍在 24 小时保留期内。 + +**提示 `Token is duplicated`** + +只有失败 JSON 含 `recoverable_token` 时才运行 `uniparser fetch --token RECOVERABLE_TOKEN`。没有该字段时不要尝试恢复。 + --- ## 推荐使用流程 From 81c5d7ba37056af1be03c08a2a88bc9ad2917cb3 Mon Sep 17 00:00:00 2001 From: liumingyang <1950307156@qq.com> Date: Sun, 16 Aug 2026 22:54:45 +0800 Subject: [PATCH 4/9] fix(cli): use direct uploads for local PDFs --- tests/unit/test_cli.py | 90 +++++++-------------------- tests/unit/test_cli_pipeline.py | 60 ++---------------- uniparser_tools/cli/commands/parse.py | 9 --- uniparser_tools/cli/core/pipeline.py | 40 +----------- 4 files changed, 30 insertions(+), 169 deletions(-) diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index d2a33eb..7bcb82a 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -69,13 +69,11 @@ def test_parse_with_config_file_api_key( pdf = tmp_path / "paper.pdf" pdf.write_bytes(b"%PDF-1.4") - mock_client.upload_files_to_tos.return_value = {"status": "error", "description": "stop early"} out = tmp_path / "out" result = runner.invoke(cli, ["parse", str(pdf), "-o", str(out)]) assert result.exit_code == 1 - mock_client.upload_files_to_tos.assert_called_once_with([str(pdf.resolve())]) - mock_client.trigger_url.assert_not_called() mock_client.trigger_file.assert_called_once() + mock_client.trigger_url.assert_not_called() def test_fetch_without_api_key(self, runner: CliRunner, env_without_api_key: None, no_config_file: None) -> None: result = runner.invoke(cli, ["fetch", "--token", "abc123"]) @@ -142,11 +140,7 @@ def test_parse_success_writes_trigger_meta_and_json_token( pdf.write_bytes(b"%PDF-1.4") mock_client = MagicMock() - mock_client.upload_files_to_tos.return_value = { - "status": "success", - "files": [{"source_url": "tos://bucket/paper.pdf", "uploaded": True}], - } - mock_client.trigger_url.return_value = {"status": "success", "token": "tok-parse-1"} + mock_client.trigger_file.return_value = {"status": "success", "token": "tok-parse-1"} mock_client.get_result.side_effect = [ {"status": "processing"}, {"status": "success", "pages_tree": {}}, @@ -179,10 +173,11 @@ def test_parse_success_writes_trigger_meta_and_json_token( assert meta["trigger_kwargs"]["sync"] is True assert "preset" not in meta assert (actual_out / "paper.md").is_file() - mock_client.trigger_file.assert_not_called() - _, trigger_kwargs = mock_client.trigger_url.call_args - assert trigger_kwargs["pdf_url"] == "tos://bucket/paper.pdf" + _, trigger_kwargs = mock_client.trigger_file.call_args + assert trigger_kwargs["file_path"] == str(pdf.resolve()) assert trigger_kwargs["server_generated_token"] is True + assert trigger_kwargs["http_timeout"] == (60.0, 1860.0) + mock_client.trigger_url.assert_not_called() def test_parse_default_trigger_kwargs( self, @@ -195,11 +190,7 @@ def test_parse_default_trigger_kwargs( pdf.write_bytes(b"%PDF-1.4") mock_client = MagicMock() - mock_client.upload_files_to_tos.return_value = { - "status": "success", - "files": [{"source_url": "tos://bucket/paper.pdf", "uploaded": True}], - } - mock_client.trigger_url.return_value = {"status": "error", "description": "stop"} + mock_client.trigger_file.return_value = {"status": "error", "description": "stop"} monkeypatch.setattr("uniparser_tools.cli.commands.parse.make_client", lambda ctx: (mock_client, None)) out = tmp_path / "out" @@ -209,8 +200,9 @@ def test_parse_default_trigger_kwargs( env={**os.environ, "UNIPARSER_API_KEY": "test-key"}, ) - _, call_kwargs = mock_client.trigger_url.call_args + _, call_kwargs = mock_client.trigger_file.call_args assert call_kwargs["server_generated_token"] is True + assert call_kwargs["http_timeout"] == (60.0, 1860.0) assert call_kwargs["sync"] is True assert call_kwargs["textual"] is ParseModeTextual.OCRHighQuality assert call_kwargs["equation"] is ParseMode.OCRHighQuality @@ -231,11 +223,7 @@ def test_parse_molecule_disable_override( pdf.write_bytes(b"%PDF-1.4") mock_client = MagicMock() - mock_client.upload_files_to_tos.return_value = { - "status": "success", - "files": [{"source_url": "tos://bucket/paper.pdf", "uploaded": True}], - } - mock_client.trigger_url.return_value = {"status": "error", "description": "stop"} + mock_client.trigger_file.return_value = {"status": "error", "description": "stop"} monkeypatch.setattr("uniparser_tools.cli.commands.parse.make_client", lambda ctx: (mock_client, None)) out = tmp_path / "out" @@ -245,7 +233,7 @@ def test_parse_molecule_disable_override( env={**os.environ, "UNIPARSER_API_KEY": "test-key"}, ) - _, call_kwargs = mock_client.trigger_url.call_args + _, call_kwargs = mock_client.trigger_file.call_args assert call_kwargs["molecule"] is ParseMode.Disable assert call_kwargs["table"] is ParseMode.OCRHighQuality @@ -259,15 +247,10 @@ def test_parse_transport_failure_does_not_write_recoverable_token( pdf = tmp_path / "paper.pdf" pdf.write_bytes(b"%PDF-1.4") mock_client = MagicMock() - mock_client.upload_files_to_tos.return_value = { - "status": "success", - "files": [{"source_url": "tos://bucket/paper.pdf", "uploaded": True}], - } - mock_client.trigger_url.return_value = { + mock_client.trigger_file.return_value = { "status": "error", "description": "The write operation timed out", - "candidate_token": "local-candidate", - "candidate_token_recoverable": False, + "error_type": "WriteTimeout", } monkeypatch.setattr("uniparser_tools.cli.commands.parse.make_client", lambda ctx: (mock_client, None)) @@ -280,15 +263,15 @@ def test_parse_transport_failure_does_not_write_recoverable_token( assert result.exit_code == 1 payload = json.loads(result.stderr.strip().splitlines()[-1]) - assert payload["error"]["code"] == "PARSE_ERROR" - assert payload["candidate_token"] == "local-candidate" - assert payload["candidate_token_recoverable"] is False + assert payload["error"]["code"] == "UPLOAD_ERROR" + assert payload["error"]["stage"] == "trigger_file" + assert "candidate_token" not in payload assert "recoverable_token" not in payload assert not (out / "trigger_meta.json").exists() saved_error = json.loads((out / "trigger_error.json").read_text(encoding="utf-8")) - assert saved_error["candidate_token"] == "local-candidate" + assert saved_error["error_type"] == "WriteTimeout" - def test_parse_tos_upload_failure_uses_upload_error( + def test_parse_direct_upload_failure_uses_upload_error( self, runner: CliRunner, monkeypatch: pytest.MonkeyPatch, @@ -298,13 +281,10 @@ def test_parse_tos_upload_failure_uses_upload_error( pdf = tmp_path / "paper.pdf" pdf.write_bytes(b"%PDF-1.4") mock_client = MagicMock() - mock_client.upload_files_to_tos.return_value = { - "status": "error", - "description": "TOS upload failed", - } mock_client.trigger_file.return_value = { "status": "error", "description": "Direct upload failed", + "error_type": "ConnectTimeout", } monkeypatch.setattr("uniparser_tools.cli.commands.parse.make_client", lambda ctx: (mock_client, None)) @@ -317,36 +297,8 @@ def test_parse_tos_upload_failure_uses_upload_error( assert result.exit_code == 1 payload = json.loads(result.stderr.strip().splitlines()[-1]) assert payload["error"]["code"] == "UPLOAD_ERROR" - assert payload["error"]["stage"] == "trigger_file_fallback" + assert payload["error"]["stage"] == "trigger_file" mock_client.trigger_url.assert_not_called() - _, fallback_kwargs = mock_client.trigger_file.call_args - assert fallback_kwargs["server_generated_token"] is True - assert fallback_kwargs["http_timeout"] == (60.0, 1860.0) - - def test_parse_direct_upload_mode_skips_tos( - self, - runner: CliRunner, - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, - ) -> None: - monkeypatch.setenv("UNIPARSER_API_KEY", "test-key") - pdf = tmp_path / "paper.pdf" - pdf.write_bytes(b"%PDF-1.4") - mock_client = MagicMock() - mock_client.trigger_file.return_value = { - "status": "error", - "description": "stop", - } - monkeypatch.setattr("uniparser_tools.cli.commands.parse.make_client", lambda ctx: (mock_client, None)) - - result = runner.invoke( - cli, - ["parse", str(pdf), "--upload-mode", "direct", "-o", str(tmp_path / "out")], - env={**os.environ, "UNIPARSER_API_KEY": "test-key"}, - ) - - assert result.exit_code == 1 - mock_client.upload_files_to_tos.assert_not_called() _, direct_kwargs = mock_client.trigger_file.call_args assert direct_kwargs["server_generated_token"] is True assert direct_kwargs["http_timeout"] == (60.0, 1860.0) @@ -462,7 +414,7 @@ def test_parse_help(self, runner: CliRunner) -> None: result = runner.invoke(cli, ["parse", "--help"]) assert result.exit_code == 0 assert "--output-dir" in result.stdout - assert "--upload-mode" in result.stdout + assert "--upload-mode" not in result.stdout assert "--async" in result.stdout assert "--textual" in result.stdout assert "--molecule" in result.stdout diff --git a/tests/unit/test_cli_pipeline.py b/tests/unit/test_cli_pipeline.py index 888135a..a60ac64 100644 --- a/tests/unit/test_cli_pipeline.py +++ b/tests/unit/test_cli_pipeline.py @@ -21,89 +21,41 @@ def _local_pdf(path: Path) -> ResolvedInput: ) -def test_local_pdf_uploads_to_tos_then_uses_server_token(tmp_path: Path) -> None: +def test_local_pdf_uses_direct_upload_with_server_token(tmp_path: Path) -> None: pdf = tmp_path / "paper.pdf" pdf.write_bytes(b"%PDF-1.4") client = MagicMock() - client.upload_files_to_tos.return_value = { - "status": "success", - "files": [{"source_url": "tos://bucket/paper.pdf", "uploaded": True}], - } - client.trigger_url.return_value = {"status": "success", "token": "server-token"} - - result, stage = trigger_input(client, _local_pdf(pdf), trigger_kwargs={"sync": True}) - - assert result == {"status": "success", "token": "server-token"} - assert stage == "trigger_url" - client.upload_files_to_tos.assert_called_once_with([str(pdf)]) - client.trigger_file.assert_not_called() - client.trigger_url.assert_called_once_with( - pdf_url="tos://bucket/paper.pdf", - server_generated_token=True, - sync=True, - ) - - -def test_tos_upload_failure_falls_back_to_direct_upload(tmp_path: Path) -> None: - pdf = tmp_path / "paper.pdf" - pdf.write_bytes(b"%PDF-1.4") - client = MagicMock() - client.upload_files_to_tos.return_value = { - "status": "error", - "message": "TOS upload failed", - } client.trigger_file.return_value = {"status": "success", "token": "server-token"} result, stage = trigger_input(client, _local_pdf(pdf), trigger_kwargs={"sync": True}) assert result == {"status": "success", "token": "server-token"} - assert stage == "trigger_file_fallback" - client.trigger_url.assert_not_called() + assert stage == "trigger_file" client.trigger_file.assert_called_once_with( file_path=str(pdf), server_generated_token=True, http_timeout=(60.0, 1860.0), sync=True, ) + client.trigger_url.assert_not_called() -def test_direct_upload_mode_skips_tos(tmp_path: Path) -> None: +def test_async_local_pdf_uses_direct_upload_timeout(tmp_path: Path) -> None: pdf = tmp_path / "paper.pdf" pdf.write_bytes(b"%PDF-1.4") client = MagicMock() client.trigger_file.return_value = {"status": "success", "token": "server-token"} - result, stage = trigger_input( - client, - _local_pdf(pdf), - trigger_kwargs={"sync": False}, - upload_mode="direct", - ) + result, stage = trigger_input(client, _local_pdf(pdf), trigger_kwargs={"sync": False}) assert result == {"status": "success", "token": "server-token"} - assert stage == "trigger_file_direct" - client.upload_files_to_tos.assert_not_called() + assert stage == "trigger_file" client.trigger_file.assert_called_once_with( file_path=str(pdf), server_generated_token=True, http_timeout=(60.0, 60.0), sync=False, ) - - -def test_tos_upload_without_source_url_falls_back_to_direct_upload(tmp_path: Path) -> None: - pdf = tmp_path / "paper.pdf" - pdf.write_bytes(b"%PDF-1.4") - client = MagicMock() - client.upload_files_to_tos.return_value = {"status": "success", "files": [{}]} - client.trigger_file.return_value = {"status": "error", "message": "Direct upload failed"} - - result, stage = trigger_input(client, _local_pdf(pdf), trigger_kwargs={"sync": True}) - - assert result["status"] == "error" - assert result["message"] == "Direct upload failed" - assert result["tos_upload_error"]["message"] == "TOS upload response missing source_url" - assert stage == "trigger_file_fallback" client.trigger_url.assert_not_called() diff --git a/uniparser_tools/cli/commands/parse.py b/uniparser_tools/cli/commands/parse.py index e6ecfb9..3f58b8c 100644 --- a/uniparser_tools/cli/commands/parse.py +++ b/uniparser_tools/cli/commands/parse.py @@ -26,13 +26,6 @@ def _parse_mode_option(name: str, help_text: str): "-o", help="Preferred output directory; an available suffixed sibling is used if it exists", ) -@click.option( - "--upload-mode", - type=click.Choice(["auto", "tos", "direct"], case_sensitive=False), - default="auto", - show_default=True, - help="Local PDF upload path: TOS-first auto fallback, TOS only, or direct only", -) @click.option("--async", "async_mode", is_flag=True, help="Submit with sync=false and poll until success") @click.option( "--textual", @@ -51,7 +44,6 @@ def parse_cmd( ctx: click.Context, source: str, output_dir: str | None, - upload_mode: str, async_mode: bool, textual: str | None, equation: str | None, @@ -93,7 +85,6 @@ def parse_cmd( resolved, out_dir=out_dir, trigger_kwargs=trigger_kwargs, - upload_mode=upload_mode, ) if isinstance(code, int): raise SystemExit(code) diff --git a/uniparser_tools/cli/core/pipeline.py b/uniparser_tools/cli/core/pipeline.py index 7b97862..b612a15 100644 --- a/uniparser_tools/cli/core/pipeline.py +++ b/uniparser_tools/cli/core/pipeline.py @@ -28,38 +28,9 @@ def trigger_input( resolved: ResolvedInput, *, trigger_kwargs: dict, - upload_mode: str = "auto", ) -> tuple[dict, str]: kwargs = trigger_kwargs if resolved.kind is InputKind.FILE: - upload = None - if upload_mode != "direct": - upload = client.upload_files_to_tos([str(resolved.path)]) - if not isinstance(upload, dict): - upload = { - "status": "error", - "message": "TOS upload returned an invalid response", - } - - if upload.get("status") == "success": - uploaded_files = upload.get("files") - uploaded_file = uploaded_files[0] if isinstance(uploaded_files, list) and uploaded_files else None - source_url = uploaded_file.get("source_url") if isinstance(uploaded_file, dict) else None - if isinstance(source_url, str) and source_url: - trigger = client.trigger_url( - pdf_url=source_url, - server_generated_token=True, - **kwargs, - ) - return trigger, "trigger_url" - upload = { - "status": "error", - "message": "TOS upload response missing source_url", - } - - if upload_mode == "tos": - return upload, "upload_tos" - trigger = client.trigger_file( file_path=str(resolved.path), server_generated_token=True, @@ -73,11 +44,7 @@ def trigger_input( "status": "error", "message": "Direct upload returned an invalid response", } - if trigger.get("status") != "success": - if upload is not None: - trigger["tos_upload_error"] = upload - stage = "trigger_file_direct" if upload_mode == "direct" else "trigger_file_fallback" - return trigger, stage + return trigger, "trigger_file" if resolved.kind is InputKind.IMAGE: trigger = client.trigger_snip( snip_path=str(resolved.path), @@ -227,14 +194,13 @@ def run_parse( *, out_dir: Path, trigger_kwargs: dict, - upload_mode: str = "auto", ) -> dict[str, Any] | int: print_parsing_status(display_label_for_input(resolved)) - trigger, stage = trigger_input(client, resolved, trigger_kwargs=trigger_kwargs, upload_mode=upload_mode) + trigger, stage = trigger_input(client, resolved, trigger_kwargs=trigger_kwargs) if trigger.get("status") != "success": trigger = annotate_recoverable_duplicate(client, trigger) save_stage_error(out_dir, "trigger_error.json", trigger) - if stage in {"upload_tos", "trigger_file_direct", "trigger_file_fallback"}: + if stage == "trigger_file" and trigger.get("error_type"): return upload_error(stage, trigger) return parse_error(stage, trigger) From fd8c2c88613d7e1289760f770ddee45a0640c73f Mon Sep 17 00:00:00 2001 From: liumingyang <1950307156@qq.com> Date: Sun, 16 Aug 2026 22:55:03 +0800 Subject: [PATCH 5/9] docs(skill): use direct upload workflow --- README.md | 2 +- skills/UniParser-Tools/SKILL.md | 7 ++----- tests/unit/test_notebook_hygiene.py | 2 ++ uniparser_tools/cli/README.md | 5 ++--- 4 files changed, 7 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 4b8a1ed..1362ac1 100644 --- a/README.md +++ b/README.md @@ -555,7 +555,7 @@ export UNIPARSER_API_KEY="your-api-key" Agent 会回复 Markdown 路径,并在需要版面结构时提供 `pages_tree.json`。大文档可能耗时数分钟至十余分钟;中断或重复任务可按 Skill 说明用 `trigger_meta.json` 中的 token 执行 `uniparser fetch`。 -CLI 解析本地 PDF 时默认先上传到 TOS,再通过 `trigger_url(server_generated_token=True)` 创建任务;TOS 不可达时会用更长上传窗口执行一次直传回退。传输失败时的 `candidate_token` 未经服务端确认,不能用于 `fetch`;只有成功输出、`trigger_meta.json` 或明确的 `recoverable_token` 才能恢复。 +CLI 解析本地 PDF 时使用 60 秒上传窗口直传,并通过 `trigger_file(server_generated_token=True)` 创建任务。传输失败时的 `candidate_token` 未经服务端确认,不能用于 `fetch`;只有成功输出、`trigger_meta.json` 或明确的 `recoverable_token` 才能恢复。 Agent 实现细节、CLI 命令与错误恢复见 [SKILL.md](./skills/UniParser-Tools/SKILL.md)。 diff --git a/skills/UniParser-Tools/SKILL.md b/skills/UniParser-Tools/SKILL.md index 37da782..dd67eeb 100644 --- a/skills/UniParser-Tools/SKILL.md +++ b/skills/UniParser-Tools/SKILL.md @@ -77,7 +77,7 @@ $env:UNIPARSER_API_KEY="your-api-key" ### CLI reference -**Pipeline:** local PDFs first use `request TOS upload → PUT file → trigger_url with a server-generated token`. If the TOS host is unreachable, the CLI makes one direct-upload fallback with a longer upload window and a server-generated token. Images use `trigger_snip`; public URLs use `trigger_url`. The CLI then polls `get_result` until success, fetches `pages_tree` + Markdown, and saves them. Default `sync=true`; `--async` uses `sync=false` on the trigger only. +**Pipeline:** local PDFs use direct multipart upload with a 60-second upload window and a server-generated token. Images use `trigger_snip`; public URLs use `trigger_url`. The CLI then polls `get_result` until success, fetches `pages_tree` + Markdown, and saves them. Default `sync=true`; `--async` uses `sync=false` on the trigger only. **Input:** one positional `INPUT` per run—the CLI detects type by path suffix or `http(s)://` URL: @@ -96,11 +96,8 @@ Optional flags: ```bash uniparser parse "./paper.pdf" -o "./results" uniparser parse "./paper.pdf" --async -uniparser parse "./paper.pdf" --upload-mode direct ``` -Use the default `--upload-mode auto` unless TOS connectivity is known to be blocked. `direct` skips TOS; `tos` disables the direct fallback. - Recovery (existing server job—see **Common issues**): ```bash @@ -199,7 +196,7 @@ On failure, show stderr JSON `error.message`. Do not substitute vision-only read | Problem | Cause | Solution | | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `CONFIG_ERROR` | No API key or `uniparser` not installed | **Configuration** + `pip install "git+https://github.com/dptech-corp/UniParser-Tools.git"`; `uniparser auth --verify` | -| `UPLOAD_ERROR` / write timeout | Both TOS-first upload and the direct fallback failed; no parse task was confirmed | Re-run `uniparser parse`; do not use `candidate_token` with `fetch` | +| `UPLOAD_ERROR` / write timeout | Direct file upload failed; no parse task was confirmed | Re-run `uniparser parse`; do not use `candidate_token` with `fetch` | | `Token is duplicated` + `recoverable_token` | The service confirmed an existing task | Do **not** re-run `parse`; run `uniparser fetch --token RECOVERABLE_TOKEN` | | `TOKEN_NOT_FOUND` / `status: undefined` | The service did not recognize the token after three checks | Stop fetching. If this came from `candidate_token`, re-run `parse`; otherwise verify that the saved token is current | | Job not done / long wait / CLI interrupted / `processing` / poll timeout | Sync or poll still running; or local process stopped while server job continues | Wait; do **not** start a second `uniparser parse` for the same input. Use saved `token` with `uniparser fetch --token TOKEN`; files appear only after exit 0 | diff --git a/tests/unit/test_notebook_hygiene.py b/tests/unit/test_notebook_hygiene.py index cc9281d..b8770ff 100644 --- a/tests/unit/test_notebook_hygiene.py +++ b/tests/unit/test_notebook_hygiene.py @@ -62,3 +62,5 @@ def test_skill_only_recovers_service_confirmed_tokens() -> None: assert "candidate_token" in skill assert "Never pass `candidate_token` to `fetch`" in skill assert "the `token` field in a failed parse stderr JSON" not in skill + assert "--upload-mode" not in skill + assert "TOS" not in skill diff --git a/uniparser_tools/cli/README.md b/uniparser_tools/cli/README.md index 680a0e3..ac1560c 100644 --- a/uniparser_tools/cli/README.md +++ b/uniparser_tools/cli/README.md @@ -47,7 +47,7 @@ uniparser auth uniparser parse /path/to/report.pdf ``` -本地 PDF 默认先上传到 TOS,再通过 `trigger_url` 使用服务端生成的 token 创建任务,避免大文件直接 multipart 上传受短 socket 写入窗口影响。若当前网络无法连接 TOS,CLI 会使用更长上传窗口做一次直传回退;两条链路都只接受服务端成功响应中的 token。上传成功本身不会启动解析;只有后续 trigger 成功才会记录可恢复 token。 +本地 PDF 使用 multipart 直传,上传窗口为 60 秒,并要求服务端生成 token。只有服务端成功接收文件并创建任务后,CLI 才会记录可恢复 token。 默认把结果保存到: @@ -185,7 +185,6 @@ uniparser parse https://example.com/paper.pdf | 选项 | 说明 | |------|------| | `-o` / `--output-dir DIR` | 首选输出目录(默认 `~/Uni-Parser-Skill/<文件名>/`);已存在时自动使用同级后缀目录 | -| `--upload-mode auto|tos|direct` | 本地 PDF 上传路径;默认 `auto` 为 TOS 优先并直传回退,`direct` 跳过 TOS,`tos` 禁用回退 | | `--async` | 异步提交任务(适合较大文档) | ### 解析配置(7 类语义) @@ -467,7 +466,7 @@ uniparser --json parse paper.pdf 2>/dev/null | python3 -c "import sys,json; prin **提示 `UPLOAD_ERROR` / write timeout** -TOS 优先上传与一次直传回退都失败,且没有确认创建解析任务,可以重新执行 `parse`。错误中的 `candidate_token` 不能交给 `fetch`。 +本次直传没有确认创建解析任务,可以重新执行 `parse`。错误中的 `candidate_token` 不能交给 `fetch`。 **提示 `TOKEN_NOT_FOUND` 或持续 `status: undefined`** From 29bf5a0b5bb25349133335ed7d4d398feb179d97 Mon Sep 17 00:00:00 2001 From: liumingyang <1950307156@qq.com> Date: Tue, 18 Aug 2026 10:31:31 +0800 Subject: [PATCH 6/9] fix(mcp): align direct upload token recovery --- mcp_server/README.md | 11 ++- mcp_server/tests/test_pipeline_parse.py | 94 ++++++++++++++++++++- mcp_server/tests/test_pipeline_poll.py | 39 +++++++++ mcp_server/uniparser_mcp/defaults.py | 5 +- mcp_server/uniparser_mcp/errors.py | 30 ++++++- mcp_server/uniparser_mcp/instructions.py | 4 +- mcp_server/uniparser_mcp/pipeline/parse.py | 97 +++++++++++++++++----- mcp_server/uniparser_mcp/pipeline/poll.py | 18 ++-- mcp_server/uniparser_mcp/schemas.py | 5 +- 9 files changed, 264 insertions(+), 39 deletions(-) create mode 100644 mcp_server/tests/test_pipeline_poll.py diff --git a/mcp_server/README.md b/mcp_server/README.md index 3ab9d90..eaa4785 100644 --- a/mcp_server/README.md +++ b/mcp_server/README.md @@ -21,10 +21,17 @@ 成功返回 JSON(Pydantic):`markdown_path`、`pages_tree_path`、`content_preview`(默认 2000 字)、`message` 等。 调用方应以返回的 `output_dir` 为准;服务不会复用或删除已有目录。 +本地 PDF 固定通过 `trigger_file` 直接 multipart 上传,不使用 TOS 或自动回退。MCP 要求服务端生成 +token;同步请求使用 `(60, 1860)`,异步请求使用 `(60, 60)`,只有服务端确认的 token 才能进入结果轮询。 + > 出于安全原因,根目录、HOME、当前工作目录及 Git 元数据目录不能作为首选输出目录。 健康检查、版本查询、按 token 手动恢复请使用 CLI:`uniparser health`、`uniparser version`、`uniparser fetch`。 +失败返回的 `error.code` 可能为 `CONFIG_ERROR`、`INPUT_ERROR`、`UPLOAD_ERROR`、`PARSE_ERROR` 或 +`TOKEN_NOT_FOUND`。重复任务只会在服务端确认 token 存在后内部恢复;未经确认的 `candidate_token` +仅供诊断,不能交给 `uniparser fetch`。`undefined` 最多检查三次,不会持续等待 1800 秒。 + ## 环境变量 | 变量 | 说明 | 默认 | @@ -81,5 +88,5 @@ uv run pytest tests/ -v `UNIPARSER_BASE_URL` 可省略(默认云服务);本地自托管时设置为 `http://127.0.0.1:40001` 等。 -同步解析使用 `UniParserClient.sync_request_timeout`,轮询和结果获取使用 -`request_timeout`;二者均为客户端 HTTP 超时,与服务端解析预算相互独立。 +本地 PDF 同步直传显式使用 `(60, 1860)`,异步直传显式使用 `(60, 60)`;轮询和结果获取使用 +`UniParserClient.request_timeout`。这些都是客户端 HTTP 超时,与服务端解析预算相互独立。 diff --git a/mcp_server/tests/test_pipeline_parse.py b/mcp_server/tests/test_pipeline_parse.py index a88fdaf..27e17a2 100644 --- a/mcp_server/tests/test_pipeline_parse.py +++ b/mcp_server/tests/test_pipeline_parse.py @@ -1,7 +1,9 @@ import asyncio +import json from pathlib import Path from unittest.mock import MagicMock +import pytest from uniparser_mcp.pipeline.parse import run_parse from uniparser_mcp.schemas import ParseRequest @@ -11,13 +13,14 @@ def test_duplicate_triggers_internal_complete(tmp_path: Path): pdf.write_bytes(b"%PDF-1.4") client = MagicMock() - client.to_token.return_value = "tok123" client.trigger_file.return_value = { "status": "error", "message": "Token is duplicated", - "token": "tok123", + "candidate_token": "tok123", + "candidate_token_recoverable": False, } client.get_result.side_effect = [ + {"status": "waiting"}, {"status": "success"}, {"status": "success", "pages_tree": True}, ] @@ -34,6 +37,41 @@ def test_duplicate_triggers_internal_complete(tmp_path: Path): assert result.trigger_meta_path is None assert Path(result.markdown_path).is_file() client.trigger_file.assert_called_once() + client.to_token.assert_not_called() + + +def test_duplicate_candidate_stays_unrecoverable_when_undefined( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + pdf = tmp_path / "paper.pdf" + pdf.write_bytes(b"%PDF-1.4") + + async def no_sleep(_seconds: float) -> None: + return None + + monkeypatch.setattr("uniparser_mcp.pipeline.parse.asyncio.sleep", no_sleep) + client = MagicMock() + client.trigger_file.return_value = { + "status": "error", + "message": "Token is duplicated", + "candidate_token": "missing-token", + "candidate_token_recoverable": False, + } + client.get_result.return_value = {"status": "undefined"} + + out = tmp_path / "out" + result = asyncio.run(run_parse(client, ParseRequest(file_path=str(pdf), output_dir=str(out)))) + + assert result.ok is False + assert result.error.code == "PARSE_ERROR" + assert result.candidate_token == "missing-token" + assert result.candidate_token_recoverable is False + assert result.recoverable_token is None + assert client.get_result.call_count == 3 + client.to_token.assert_not_called() + saved_error = json.loads((out / "trigger_error.json").read_text(encoding="utf-8")) + assert saved_error["candidate_token"] == "missing-token" def test_success_parse_writes_trigger_meta(tmp_path: Path): @@ -58,6 +96,58 @@ def test_success_parse_writes_trigger_meta(tmp_path: Path): assert result.ok is True assert result.trigger_meta_path is not None assert Path(result.trigger_meta_path).is_file() + trigger_args, trigger_kwargs = client.trigger_file.call_args + assert trigger_args == (str(pdf.resolve()),) + assert trigger_kwargs["server_generated_token"] is True + assert trigger_kwargs["http_timeout"] == (60.0, 1860.0) + + +def test_async_local_pdf_uses_direct_upload_timeout(tmp_path: Path): + pdf = tmp_path / "paper.pdf" + pdf.write_bytes(b"%PDF-1.4") + client = MagicMock() + client.trigger_file.return_value = {"status": "error", "description": "stop"} + + req = ParseRequest(file_path=str(pdf), output_dir=str(tmp_path / "out"), async_mode=True) + result = asyncio.run(run_parse(client, req)) + + assert result.ok is False + _, trigger_kwargs = client.trigger_file.call_args + assert trigger_kwargs["server_generated_token"] is True + assert trigger_kwargs["http_timeout"] == (60.0, 60.0) + assert trigger_kwargs["sync"] is False + + +def test_direct_upload_transport_failure_uses_upload_error(tmp_path: Path): + pdf = tmp_path / "paper.pdf" + pdf.write_bytes(b"%PDF-1.4") + client = MagicMock() + client.trigger_file.return_value = { + "status": "error", + "description": "The write operation timed out", + "error_type": "WriteTimeout", + } + + req = ParseRequest(file_path=str(pdf), output_dir=str(tmp_path / "out")) + result = asyncio.run(run_parse(client, req)) + + assert result.ok is False + assert result.error.code == "UPLOAD_ERROR" + assert result.error.stage == "trigger_file" + assert result.candidate_token is None + assert result.recoverable_token is None + + +def test_url_trigger_uses_server_generated_token(tmp_path: Path): + client = MagicMock() + client.trigger_url.return_value = {"status": "error", "description": "stop"} + + req = ParseRequest(pdf_url="https://example.com/paper.pdf", output_dir=str(tmp_path / "out")) + result = asyncio.run(run_parse(client, req)) + + assert result.ok is False + _, trigger_kwargs = client.trigger_url.call_args + assert trigger_kwargs["server_generated_token"] is True def test_existing_output_is_preserved_and_result_uses_suffixed_sibling(tmp_path: Path): diff --git a/mcp_server/tests/test_pipeline_poll.py b/mcp_server/tests/test_pipeline_poll.py new file mode 100644 index 0000000..f0803cd --- /dev/null +++ b/mcp_server/tests/test_pipeline_poll.py @@ -0,0 +1,39 @@ +import asyncio +from unittest.mock import MagicMock + +import pytest +from uniparser_mcp.pipeline.poll import poll_until_success + + +def test_undefined_token_stops_after_bounded_checks(monkeypatch: pytest.MonkeyPatch): + async def no_sleep(_seconds: float) -> None: + return None + + monkeypatch.setattr("uniparser_mcp.pipeline.poll.asyncio.sleep", no_sleep) + client = MagicMock() + client.get_result.return_value = {"status": "undefined"} + + result = asyncio.run(poll_until_success(client, "missing-token")) + + assert result.ok is False + assert result.error.code == "TOKEN_NOT_FOUND" + assert result.unrecognized_token == "missing-token" + assert client.get_result.call_count == 3 + + +def test_pending_statuses_still_reach_success(monkeypatch: pytest.MonkeyPatch): + async def no_sleep(_seconds: float) -> None: + return None + + monkeypatch.setattr("uniparser_mcp.pipeline.poll.asyncio.sleep", no_sleep) + client = MagicMock() + client.get_result.side_effect = [ + {"status": "waiting"}, + {"status": "processing"}, + {"status": "success"}, + ] + + result = asyncio.run(poll_until_success(client, "valid-token")) + + assert result == {"status": "success"} + assert client.get_result.call_count == 3 diff --git a/mcp_server/uniparser_mcp/defaults.py b/mcp_server/uniparser_mcp/defaults.py index d4f254d..7f180fe 100644 --- a/mcp_server/uniparser_mcp/defaults.py +++ b/mcp_server/uniparser_mcp/defaults.py @@ -2,8 +2,11 @@ POLL_INTERVAL_SEC = 3 POLL_TIMEOUT_SEC = 1800 +UNDEFINED_MAX_POLLS = 3 +DIRECT_UPLOAD_REQUEST_TIMEOUT = (60.0, 60.0) +DIRECT_SYNC_UPLOAD_REQUEST_TIMEOUT = (60.0, 1860.0) -PENDING_STATUSES = frozenset({"undefined", "waiting", "processing"}) +PENDING_STATUSES = frozenset({"waiting", "processing"}) IMAGE_SUFFIXES = frozenset({".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tif", ".tiff"}) diff --git a/mcp_server/uniparser_mcp/errors.py b/mcp_server/uniparser_mcp/errors.py index 0b0333e..55e769a 100644 --- a/mcp_server/uniparser_mcp/errors.py +++ b/mcp_server/uniparser_mcp/errors.py @@ -13,12 +13,34 @@ def input_error(message: str) -> ErrorResult: return ErrorResult(error=ErrorDetail(code="INPUT_ERROR", message=message)) -def parse_error(stage: str, result: dict) -> ErrorResult: +def _operation_error(code: str, stage: str, result: dict) -> ErrorResult: + candidate_token = result.get("candidate_token") return ErrorResult( error=ErrorDetail( - code="PARSE_ERROR", + code=code, message=result.get("description") or result.get("message") or str(result), stage=stage, - token=result.get("token"), - ) + ), + recoverable_token=result.get("recoverable_token"), + candidate_token=candidate_token, + candidate_token_recoverable=False if candidate_token else None, + ) + + +def parse_error(stage: str, result: dict) -> ErrorResult: + return _operation_error("PARSE_ERROR", stage, result) + + +def upload_error(stage: str, result: dict) -> ErrorResult: + return _operation_error("UPLOAD_ERROR", stage, result) + + +def token_not_found_error(token: str, *, attempts: int) -> ErrorResult: + return ErrorResult( + error=ErrorDetail( + code="TOKEN_NOT_FOUND", + message=f"The service did not recognize this token after {attempts} checks.", + stage="get_result_poll", + ), + unrecognized_token=token, ) diff --git a/mcp_server/uniparser_mcp/instructions.py b/mcp_server/uniparser_mcp/instructions.py index 0e3765a..5899029 100644 --- a/mcp_server/uniparser_mcp/instructions.py +++ b/mcp_server/uniparser_mcp/instructions.py @@ -21,5 +21,7 @@ ## After calling uniparser_parse -Always include the `message` field from the tool response in your reply to the user. +On success, always include the `message` field from the tool response in your reply to the user. +On failure, report `error.message`. Never pass `candidate_token` to another request or to `uniparser fetch`; +it has not been confirmed by the service. Stop retrying when the error code is `TOKEN_NOT_FOUND`. """ diff --git a/mcp_server/uniparser_mcp/pipeline/parse.py b/mcp_server/uniparser_mcp/pipeline/parse.py index 76e617b..fd2ba10 100644 --- a/mcp_server/uniparser_mcp/pipeline/parse.py +++ b/mcp_server/uniparser_mcp/pipeline/parse.py @@ -7,7 +7,14 @@ from mcp.server.fastmcp import Context -from uniparser_mcp.errors import input_error, parse_error +from uniparser_mcp.defaults import ( + DIRECT_SYNC_UPLOAD_REQUEST_TIMEOUT, + DIRECT_UPLOAD_REQUEST_TIMEOUT, + PENDING_STATUSES, + POLL_INTERVAL_SEC, + UNDEFINED_MAX_POLLS, +) +from uniparser_mcp.errors import input_error, parse_error, upload_error from uniparser_mcp.input import InputKind, ResolvedInput, display_label, resolve_request from uniparser_mcp.parse_options import resolve_trigger_kwargs, serialize_trigger_kwargs from uniparser_mcp.pipeline.output import ( @@ -20,14 +27,7 @@ from uniparser_mcp.schemas import ErrorResult, ParseRequest, ParseResult from uniparser_mcp.tools.response import build_parse_success from uniparser_tools.api.clients import UniParserClient -from uniparser_tools.common.constant import ErrorFlag, FormatFlag - - -def _is_token_duplicated(trigger: dict[str, Any]) -> bool: - if trigger.get("status") == "success": - return False - message = str(trigger.get("message") or trigger.get("description") or "") - return message == ErrorFlag.Token_Duplicated or "Token is duplicated" in message +from uniparser_tools.common.constant import FormatFlag async def _ctx_info(ctx: Context | None, message: str) -> None: @@ -47,27 +47,75 @@ async def _trigger_input( trigger = await asyncio.to_thread( client.trigger_file, str(resolved.path), - token=None, + server_generated_token=True, + http_timeout=( + DIRECT_SYNC_UPLOAD_REQUEST_TIMEOUT + if trigger_kwargs.get("sync", True) + else DIRECT_UPLOAD_REQUEST_TIMEOUT + ), **trigger_kwargs, ) + if not isinstance(trigger, dict): + trigger = { + "status": "error", + "message": "Direct upload returned an invalid response", + } return trigger, "trigger_file" if resolved.kind is InputKind.IMAGE: trigger = await asyncio.to_thread( client.trigger_snip, str(resolved.path), - token=None, + server_generated_token=True, **trigger_kwargs, ) return trigger, "trigger_snip" trigger = await asyncio.to_thread( client.trigger_url, resolved.raw, - token=None, + server_generated_token=True, **trigger_kwargs, ) return trigger, "trigger_url" +async def _annotate_recoverable_duplicate(client: UniParserClient, trigger: dict[str, Any]) -> dict[str, Any]: + diagnostic = " ".join(str(trigger.get(key, "")) for key in ("message", "description")).casefold() + if "duplicat" not in diagnostic: + return trigger + + candidate = trigger.get("token") or trigger.get("candidate_token") + if not candidate: + return trigger + + status = None + for attempt in range(UNDEFINED_MAX_POLLS): + probe = await asyncio.to_thread( + client.get_result, + candidate, + content=False, + objects=False, + pages_dict=False, + pages_tree=False, + ) + status = probe.get("status") if isinstance(probe, dict) else None + if status != "undefined" or attempt == UNDEFINED_MAX_POLLS - 1: + break + await asyncio.sleep(POLL_INTERVAL_SEC) + + trigger["token_status"] = status + if status == "success" or status in PENDING_STATUSES: + trigger["recoverable_token"] = candidate + trigger.pop("token", None) + trigger.pop("candidate_token", None) + trigger.pop("candidate_token_recoverable", None) + return trigger + + trigger.pop("token", None) + trigger["candidate_token"] = candidate + trigger["candidate_token_recoverable"] = False + return trigger + + async def _fetch_pages_tree(client: UniParserClient, token: str) -> dict[str, Any]: return await asyncio.to_thread( client.get_result, @@ -156,19 +204,22 @@ async def run_parse(client: UniParserClient, req: ParseRequest, ctx: Context | N await _ctx_info(ctx, f"Parsing {display_label(resolved)}") trigger, stage = await _trigger_input(client, resolved, trigger_kwargs=trigger_kwargs) - if _is_token_duplicated(trigger): - token = trigger.get("token") or client.to_token(resolved.token_seed) - return await _complete_by_token( - client, - token, - resolved=resolved, - out_dir=out_dir, - ctx=ctx, - write_trigger_meta_file=False, - ) - if trigger.get("status") != "success": + trigger = await _annotate_recoverable_duplicate(client, trigger) + recoverable_token = trigger.get("recoverable_token") + if recoverable_token: + return await _complete_by_token( + client, + recoverable_token, + resolved=resolved, + out_dir=out_dir, + ctx=ctx, + write_trigger_meta_file=False, + ) + save_stage_error(out_dir, "trigger_error.json", trigger) + if stage == "trigger_file" and trigger.get("error_type"): + return upload_error(stage, trigger) return parse_error(stage, trigger) token = trigger.get("token") diff --git a/mcp_server/uniparser_mcp/pipeline/poll.py b/mcp_server/uniparser_mcp/pipeline/poll.py index 4bccf81..3478d8f 100644 --- a/mcp_server/uniparser_mcp/pipeline/poll.py +++ b/mcp_server/uniparser_mcp/pipeline/poll.py @@ -6,17 +6,18 @@ import time from typing import Any -from uniparser_mcp.defaults import PENDING_STATUSES, POLL_INTERVAL_SEC, POLL_TIMEOUT_SEC -from uniparser_mcp.errors import parse_error +from uniparser_mcp.defaults import PENDING_STATUSES, POLL_INTERVAL_SEC, POLL_TIMEOUT_SEC, UNDEFINED_MAX_POLLS +from uniparser_mcp.errors import parse_error, token_not_found_error from uniparser_mcp.schemas import ErrorResult from uniparser_tools.api.clients import UniParserClient async def poll_until_success(client: UniParserClient, token: str) -> dict[str, Any] | ErrorResult: - deadline = time.time() + POLL_TIMEOUT_SEC + deadline = time.monotonic() + POLL_TIMEOUT_SEC last: dict[str, Any] = {} + undefined_polls = 0 - while time.time() < deadline: + while time.monotonic() < deadline: last = await asyncio.to_thread( client.get_result, token, @@ -30,7 +31,14 @@ async def poll_until_success(client: UniParserClient, token: str) -> dict[str, A return last if status == "error": return parse_error("get_result_poll", last) - if status in PENDING_STATUSES or status is None: + if status == "undefined": + undefined_polls += 1 + if undefined_polls >= UNDEFINED_MAX_POLLS: + return token_not_found_error(token, attempts=undefined_polls) + await asyncio.sleep(POLL_INTERVAL_SEC) + continue + if status in PENDING_STATUSES: + undefined_polls = 0 await asyncio.sleep(POLL_INTERVAL_SEC) continue return parse_error("get_result_poll", last) diff --git a/mcp_server/uniparser_mcp/schemas.py b/mcp_server/uniparser_mcp/schemas.py index 24aadde..387117c 100644 --- a/mcp_server/uniparser_mcp/schemas.py +++ b/mcp_server/uniparser_mcp/schemas.py @@ -70,7 +70,6 @@ class ErrorDetail(BaseModel): message: str stage: str | None = None output_dir: str | None = None - token: str | None = None class ParseSuccess(BaseModel): @@ -91,6 +90,10 @@ class ParseSuccess(BaseModel): class ErrorResult(BaseModel): ok: Literal[False] = False error: ErrorDetail + recoverable_token: str | None = None + candidate_token: str | None = None + candidate_token_recoverable: Literal[False] | None = None + unrecognized_token: str | None = None ParseResult = ParseSuccess | ErrorResult From feb4f8c0d76057779cfc86ffe2706464e53ba8fe Mon Sep 17 00:00:00 2001 From: liumingyang <1950307156@qq.com> Date: Tue, 18 Aug 2026 10:31:51 +0800 Subject: [PATCH 7/9] docs(skill): align MCP token recovery guidance --- skills/UniParser-Tools/SKILL.md | 2 ++ skills/UniParser-Tools/references/api-reference.md | 3 +++ skills/UniParser-Tools/references/notes.md | 10 ++++++++-- tests/unit/test_notebook_hygiene.py | 3 +++ 4 files changed, 16 insertions(+), 2 deletions(-) diff --git a/skills/UniParser-Tools/SKILL.md b/skills/UniParser-Tools/SKILL.md index dd67eeb..723aaff 100644 --- a/skills/UniParser-Tools/SKILL.md +++ b/skills/UniParser-Tools/SKILL.md @@ -213,6 +213,8 @@ For callbacks, custom `ParseMode`, or SDK examples, see [Common patterns](./refe Full CLI reference (flags, examples, JSON details): [CLI README](../../uniparser_tools/cli/README.md) in this repository. Optional MCP server setup is in the [UniParser-Tools GitHub repo](https://github.com/dptech-corp/UniParser-Tools); it is separate from this CLI workflow. +Its `uniparser_parse` tool uses the same direct local-PDF upload, server-generated token, confirmed-duplicate +recovery, and three-check `undefined` limit as the CLI. ## Reference documents diff --git a/skills/UniParser-Tools/references/api-reference.md b/skills/UniParser-Tools/references/api-reference.md index 8423634..3bc3eb0 100644 --- a/skills/UniParser-Tools/references/api-reference.md +++ b/skills/UniParser-Tools/references/api-reference.md @@ -89,6 +89,9 @@ trigger endpoints, including `trigger_url`. ### TOS presigned upload +This is an advanced SDK-only path. The CLI and MCP local-PDF workflows use direct `trigger_file` uploads and do not +call TOS or perform automatic upload fallback. + ```python uploaded = parser.upload_files_to_tos(["./document.pdf"]) source_url = uploaded["files"][0]["source_url"] diff --git a/skills/UniParser-Tools/references/notes.md b/skills/UniParser-Tools/references/notes.md index 8210df5..366027a 100644 --- a/skills/UniParser-Tools/references/notes.md +++ b/skills/UniParser-Tools/references/notes.md @@ -55,15 +55,21 @@ All API methods return a dict with consistent structure: ... } -# Error +# Transport or trigger error with an unconfirmed deterministic token { "status": "error", - "token": "abc123...", + "candidate_token": "abc123...", + "candidate_token_recoverable": False, "message": "Error description", "description": "Detailed traceback (optional)" } ``` +Only a successful trigger response contains a confirmed task `token`. SDK error responses can contain a +`candidate_token` when the caller uses the backward-compatible deterministic-token mode; do not poll or fetch it +unless a separate service status check confirms that the task exists. CLI and MCP default to server-generated tokens; +the CLI exposes `recoverable_token` only after confirmation, while MCP resumes a confirmed duplicate internally. + ## Common Error Messages CLI workflow errors (config, duplicate token, 502, etc.) are documented in SKILL.md **Common issues**. This table covers additional SDK/API messages when calling the client directly: diff --git a/tests/unit/test_notebook_hygiene.py b/tests/unit/test_notebook_hygiene.py index b8770ff..e3c5066 100644 --- a/tests/unit/test_notebook_hygiene.py +++ b/tests/unit/test_notebook_hygiene.py @@ -57,6 +57,7 @@ def test_customer_docs_include_result_retention_notice() -> None: def test_skill_only_recovers_service_confirmed_tokens() -> None: skill = (REPO_ROOT / "skills" / "UniParser-Tools" / "SKILL.md").read_text(encoding="utf-8") + notes = (REPO_ROOT / "skills" / "UniParser-Tools" / "references" / "notes.md").read_text(encoding="utf-8") assert "recoverable_token" in skill assert "candidate_token" in skill @@ -64,3 +65,5 @@ def test_skill_only_recovers_service_confirmed_tokens() -> None: assert "the `token` field in a failed parse stderr JSON" not in skill assert "--upload-mode" not in skill assert "TOS" not in skill + assert '"candidate_token": "abc123..."' in notes + assert '"candidate_token_recoverable": False' in notes From 6920e3bb6bd18a10a91fa0e1677c0a5659a5feb3 Mon Sep 17 00:00:00 2001 From: liumingyang <1950307156@qq.com> Date: Wed, 19 Aug 2026 15:42:06 +0800 Subject: [PATCH 8/9] fix(project): use server tokens in CLI MCP and Skill --- README.md | 2 - mcp_server/README.md | 4 +- mcp_server/tests/test_pipeline_parse.py | 65 ++++------------------ mcp_server/uniparser_mcp/errors.py | 6 +- mcp_server/uniparser_mcp/instructions.py | 4 +- mcp_server/uniparser_mcp/pipeline/parse.py | 54 +----------------- mcp_server/uniparser_mcp/schemas.py | 3 - skills/UniParser-Tools/SKILL.md | 15 +++-- skills/UniParser-Tools/references/notes.md | 12 ++-- tests/unit/test_cli.py | 7 ++- tests/unit/test_cli_pipeline.py | 43 +------------- tests/unit/test_client.py | 23 +------- tests/unit/test_notebook_hygiene.py | 14 +++-- uniparser_tools/api/clients.py | 20 +++---- uniparser_tools/api/transport.py | 14 ----- uniparser_tools/cli/README.md | 15 ++--- uniparser_tools/cli/core/errors.py | 5 -- uniparser_tools/cli/core/pipeline.py | 37 +----------- 18 files changed, 55 insertions(+), 288 deletions(-) diff --git a/README.md b/README.md index 1362ac1..fea1eb1 100644 --- a/README.md +++ b/README.md @@ -555,8 +555,6 @@ export UNIPARSER_API_KEY="your-api-key" Agent 会回复 Markdown 路径,并在需要版面结构时提供 `pages_tree.json`。大文档可能耗时数分钟至十余分钟;中断或重复任务可按 Skill 说明用 `trigger_meta.json` 中的 token 执行 `uniparser fetch`。 -CLI 解析本地 PDF 时使用 60 秒上传窗口直传,并通过 `trigger_file(server_generated_token=True)` 创建任务。传输失败时的 `candidate_token` 未经服务端确认,不能用于 `fetch`;只有成功输出、`trigger_meta.json` 或明确的 `recoverable_token` 才能恢复。 - Agent 实现细节、CLI 命令与错误恢复见 [SKILL.md](./skills/UniParser-Tools/SKILL.md)。 ## MCP Server diff --git a/mcp_server/README.md b/mcp_server/README.md index eaa4785..c4e5d9a 100644 --- a/mcp_server/README.md +++ b/mcp_server/README.md @@ -29,8 +29,8 @@ token;同步请求使用 `(60, 1860)`,异步请求使用 `(60, 60)`,只有 健康检查、版本查询、按 token 手动恢复请使用 CLI:`uniparser health`、`uniparser version`、`uniparser fetch`。 失败返回的 `error.code` 可能为 `CONFIG_ERROR`、`INPUT_ERROR`、`UPLOAD_ERROR`、`PARSE_ERROR` 或 -`TOKEN_NOT_FOUND`。重复任务只会在服务端确认 token 存在后内部恢复;未经确认的 `candidate_token` -仅供诊断,不能交给 `uniparser fetch`。`undefined` 最多检查三次,不会持续等待 1800 秒。 +`TOKEN_NOT_FOUND`。触发失败不会返回任务 token,也不会进入恢复流程;`undefined` 最多检查三次, +不会持续等待 1800 秒。 ## 环境变量 diff --git a/mcp_server/tests/test_pipeline_parse.py b/mcp_server/tests/test_pipeline_parse.py index 27e17a2..d3502a2 100644 --- a/mcp_server/tests/test_pipeline_parse.py +++ b/mcp_server/tests/test_pipeline_parse.py @@ -3,75 +3,33 @@ from pathlib import Path from unittest.mock import MagicMock -import pytest from uniparser_mcp.pipeline.parse import run_parse from uniparser_mcp.schemas import ParseRequest -def test_duplicate_triggers_internal_complete(tmp_path: Path): +def test_failed_trigger_does_not_attempt_token_recovery(tmp_path: Path): pdf = tmp_path / "paper.pdf" pdf.write_bytes(b"%PDF-1.4") client = MagicMock() client.trigger_file.return_value = { "status": "error", - "message": "Token is duplicated", - "candidate_token": "tok123", - "candidate_token_recoverable": False, + "token": "must-not-leak", + "message": "trigger failed", } - client.get_result.side_effect = [ - {"status": "waiting"}, - {"status": "success"}, - {"status": "success", "pages_tree": True}, - ] - client.get_formatted.return_value = { - "status": "success", - "content": "# Hello", - } - - req = ParseRequest(file_path=str(pdf), output_dir=str(tmp_path / "out")) - result = asyncio.run(run_parse(client, req)) - - assert result.ok is True - assert result.token == "tok123" - assert result.trigger_meta_path is None - assert Path(result.markdown_path).is_file() - client.trigger_file.assert_called_once() - client.to_token.assert_not_called() - - -def test_duplicate_candidate_stays_unrecoverable_when_undefined( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -): - pdf = tmp_path / "paper.pdf" - pdf.write_bytes(b"%PDF-1.4") - - async def no_sleep(_seconds: float) -> None: - return None - - monkeypatch.setattr("uniparser_mcp.pipeline.parse.asyncio.sleep", no_sleep) - client = MagicMock() - client.trigger_file.return_value = { - "status": "error", - "message": "Token is duplicated", - "candidate_token": "missing-token", - "candidate_token_recoverable": False, - } - client.get_result.return_value = {"status": "undefined"} out = tmp_path / "out" - result = asyncio.run(run_parse(client, ParseRequest(file_path=str(pdf), output_dir=str(out)))) + req = ParseRequest(file_path=str(pdf), output_dir=str(out)) + result = asyncio.run(run_parse(client, req)) assert result.ok is False assert result.error.code == "PARSE_ERROR" - assert result.candidate_token == "missing-token" - assert result.candidate_token_recoverable is False - assert result.recoverable_token is None - assert client.get_result.call_count == 3 - client.to_token.assert_not_called() + assert "token" not in result.model_dump() saved_error = json.loads((out / "trigger_error.json").read_text(encoding="utf-8")) - assert saved_error["candidate_token"] == "missing-token" + assert "token" not in saved_error + client.trigger_file.assert_called_once() + client.get_result.assert_not_called() + client.get_formatted.assert_not_called() def test_success_parse_writes_trigger_meta(tmp_path: Path): @@ -134,8 +92,7 @@ def test_direct_upload_transport_failure_uses_upload_error(tmp_path: Path): assert result.ok is False assert result.error.code == "UPLOAD_ERROR" assert result.error.stage == "trigger_file" - assert result.candidate_token is None - assert result.recoverable_token is None + assert "token" not in result.model_dump() def test_url_trigger_uses_server_generated_token(tmp_path: Path): diff --git a/mcp_server/uniparser_mcp/errors.py b/mcp_server/uniparser_mcp/errors.py index 55e769a..e1f787e 100644 --- a/mcp_server/uniparser_mcp/errors.py +++ b/mcp_server/uniparser_mcp/errors.py @@ -14,16 +14,12 @@ def input_error(message: str) -> ErrorResult: def _operation_error(code: str, stage: str, result: dict) -> ErrorResult: - candidate_token = result.get("candidate_token") return ErrorResult( error=ErrorDetail( code=code, message=result.get("description") or result.get("message") or str(result), stage=stage, - ), - recoverable_token=result.get("recoverable_token"), - candidate_token=candidate_token, - candidate_token_recoverable=False if candidate_token else None, + ) ) diff --git a/mcp_server/uniparser_mcp/instructions.py b/mcp_server/uniparser_mcp/instructions.py index 5899029..95b91b7 100644 --- a/mcp_server/uniparser_mcp/instructions.py +++ b/mcp_server/uniparser_mcp/instructions.py @@ -22,6 +22,6 @@ ## After calling uniparser_parse On success, always include the `message` field from the tool response in your reply to the user. -On failure, report `error.message`. Never pass `candidate_token` to another request or to `uniparser fetch`; -it has not been confirmed by the service. Stop retrying when the error code is `TOKEN_NOT_FOUND`. +On failure, report `error.message`. A failed trigger does not provide a task token, so do not attempt recovery. +Stop retrying when the error code is `TOKEN_NOT_FOUND`. """ diff --git a/mcp_server/uniparser_mcp/pipeline/parse.py b/mcp_server/uniparser_mcp/pipeline/parse.py index fd2ba10..936cabe 100644 --- a/mcp_server/uniparser_mcp/pipeline/parse.py +++ b/mcp_server/uniparser_mcp/pipeline/parse.py @@ -10,9 +10,6 @@ from uniparser_mcp.defaults import ( DIRECT_SYNC_UPLOAD_REQUEST_TIMEOUT, DIRECT_UPLOAD_REQUEST_TIMEOUT, - PENDING_STATUSES, - POLL_INTERVAL_SEC, - UNDEFINED_MAX_POLLS, ) from uniparser_mcp.errors import input_error, parse_error, upload_error from uniparser_mcp.input import InputKind, ResolvedInput, display_label, resolve_request @@ -78,44 +75,6 @@ async def _trigger_input( return trigger, "trigger_url" -async def _annotate_recoverable_duplicate(client: UniParserClient, trigger: dict[str, Any]) -> dict[str, Any]: - diagnostic = " ".join(str(trigger.get(key, "")) for key in ("message", "description")).casefold() - if "duplicat" not in diagnostic: - return trigger - - candidate = trigger.get("token") or trigger.get("candidate_token") - if not candidate: - return trigger - - status = None - for attempt in range(UNDEFINED_MAX_POLLS): - probe = await asyncio.to_thread( - client.get_result, - candidate, - content=False, - objects=False, - pages_dict=False, - pages_tree=False, - ) - status = probe.get("status") if isinstance(probe, dict) else None - if status != "undefined" or attempt == UNDEFINED_MAX_POLLS - 1: - break - await asyncio.sleep(POLL_INTERVAL_SEC) - - trigger["token_status"] = status - if status == "success" or status in PENDING_STATUSES: - trigger["recoverable_token"] = candidate - trigger.pop("token", None) - trigger.pop("candidate_token", None) - trigger.pop("candidate_token_recoverable", None) - return trigger - - trigger.pop("token", None) - trigger["candidate_token"] = candidate - trigger["candidate_token_recoverable"] = False - return trigger - - async def _fetch_pages_tree(client: UniParserClient, token: str) -> dict[str, Any]: return await asyncio.to_thread( client.get_result, @@ -205,18 +164,7 @@ async def run_parse(client: UniParserClient, req: ParseRequest, ctx: Context | N trigger, stage = await _trigger_input(client, resolved, trigger_kwargs=trigger_kwargs) if trigger.get("status") != "success": - trigger = await _annotate_recoverable_duplicate(client, trigger) - recoverable_token = trigger.get("recoverable_token") - if recoverable_token: - return await _complete_by_token( - client, - recoverable_token, - resolved=resolved, - out_dir=out_dir, - ctx=ctx, - write_trigger_meta_file=False, - ) - + trigger.pop("token", None) save_stage_error(out_dir, "trigger_error.json", trigger) if stage == "trigger_file" and trigger.get("error_type"): return upload_error(stage, trigger) diff --git a/mcp_server/uniparser_mcp/schemas.py b/mcp_server/uniparser_mcp/schemas.py index 387117c..745d61e 100644 --- a/mcp_server/uniparser_mcp/schemas.py +++ b/mcp_server/uniparser_mcp/schemas.py @@ -90,9 +90,6 @@ class ParseSuccess(BaseModel): class ErrorResult(BaseModel): ok: Literal[False] = False error: ErrorDetail - recoverable_token: str | None = None - candidate_token: str | None = None - candidate_token_recoverable: Literal[False] | None = None unrecognized_token: str | None = None diff --git a/skills/UniParser-Tools/SKILL.md b/skills/UniParser-Tools/SKILL.md index 723aaff..c69f9b4 100644 --- a/skills/UniParser-Tools/SKILL.md +++ b/skills/UniParser-Tools/SKILL.md @@ -104,7 +104,7 @@ Recovery (existing server job—see **Common issues**): uniparser fetch --token "TASK_TOKEN_FROM_PRIOR_RUN" ``` -Valid recovery tokens come only from successful `uniparser --json parse …` stdout, `trigger_meta.json` written after a successful trigger, or a failed command's explicit `recoverable_token` field. Never pass `candidate_token` to `fetch`; it is diagnostic only and has not been confirmed by the service. +Valid recovery tokens come only from successful `uniparser --json parse …` stdout or `trigger_meta.json` written after a successful trigger. A failed trigger does not provide a task token and cannot be resumed with `fetch`. **Default output for** `fetch` (when `-o` / `--output-dir` is omitted): `~/Uni-Parser-Skill/token_/`, where `` is the first 8 characters of the token (e.g. `~/Uni-Parser-Skill/token_a1b2c3d4/token_a1b2c3d4.md`). Passing a prior `parse` directory with `-o` treats it only as the preferred path; because that directory already exists, `fetch` writes to an available sibling such as `paper_1`. Always use the returned `output_dir`. @@ -184,7 +184,7 @@ uniparser parse paper.pdf --json # wrong If the preferred output directory already exists, the CLI creates an available sibling such as `results_1` or `results_2`. Existing paths are never reused or deleted; use the returned `output_dir`. -Failure JSON may include `recoverable_token` when the service confirms an existing task. A `candidate_token` is never recoverable. +Failure JSON does not contain a task token. Use `fetch` only with a token from successful parse output or `trigger_meta.json`. **Common error codes** (stderr JSON): `CONFIG_ERROR`, `INPUT_ERROR`, `UPLOAD_ERROR`, `PARSE_ERROR`, `TOKEN_NOT_FOUND`. @@ -196,15 +196,14 @@ On failure, show stderr JSON `error.message`. Do not substitute vision-only read | Problem | Cause | Solution | | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `CONFIG_ERROR` | No API key or `uniparser` not installed | **Configuration** + `pip install "git+https://github.com/dptech-corp/UniParser-Tools.git"`; `uniparser auth --verify` | -| `UPLOAD_ERROR` / write timeout | Direct file upload failed; no parse task was confirmed | Re-run `uniparser parse`; do not use `candidate_token` with `fetch` | -| `Token is duplicated` + `recoverable_token` | The service confirmed an existing task | Do **not** re-run `parse`; run `uniparser fetch --token RECOVERABLE_TOKEN` | -| `TOKEN_NOT_FOUND` / `status: undefined` | The service did not recognize the token after three checks | Stop fetching. If this came from `candidate_token`, re-run `parse`; otherwise verify that the saved token is current | +| `UPLOAD_ERROR` / write timeout | Direct file upload failed without returning a server task token | Re-run `uniparser parse`; the failed trigger cannot be resumed with `fetch` | +| `TOKEN_NOT_FOUND` / `status: undefined` | The service did not recognize the token after three checks | Stop fetching; verify that the token came from successful output or `trigger_meta.json` and is still within the retention period | | Job not done / long wait / CLI interrupted / `processing` / poll timeout | Sync or poll still running; or local process stopped while server job continues | Wait; do **not** start a second `uniparser parse` for the same input. Use saved `token` with `uniparser fetch --token TOKEN`; files appear only after exit 0 | | `502 Bad Gateway` on URL input | Server failed fetching or processing remote PDF | Retry `uniparser parse "same url"` once; or download and `uniparser parse local.pdf`; or `uniparser fetch --token TOKEN` if a prior job exists | | `PARSE_ERROR` | Server `status: error` at trigger / poll / fetch | Read `error.message` and `stage`; match rows above; check `trigger_error.json` / `pages_tree_error.json` / `formatted_error.json` under output dir if present | -**Limits:** large PDFs may take 10–20+ minutes; public service ≤5 concurrent requests; PDF URLs must be publicly accessible. Parsing can be inaccurate, so verify critical content against the source. Results are retained for only 24 hours—fetch and save them promptly. See [Important notes](./references/notes.md). Save `token` from success JSON or `trigger_meta.json`; use an error token only when it is named `recoverable_token`. +**Limits:** large PDFs may take 10–20+ minutes; public service ≤5 concurrent requests; PDF URLs must be publicly accessible. Parsing can be inaccurate, so verify critical content against the source. Results are retained for only 24 hours—fetch and save them promptly. See [Important notes](./references/notes.md). Save `token` only from success JSON or `trigger_meta.json`. ## Advanced @@ -213,8 +212,8 @@ For callbacks, custom `ParseMode`, or SDK examples, see [Common patterns](./refe Full CLI reference (flags, examples, JSON details): [CLI README](../../uniparser_tools/cli/README.md) in this repository. Optional MCP server setup is in the [UniParser-Tools GitHub repo](https://github.com/dptech-corp/UniParser-Tools); it is separate from this CLI workflow. -Its `uniparser_parse` tool uses the same direct local-PDF upload, server-generated token, confirmed-duplicate -recovery, and three-check `undefined` limit as the CLI. +Its `uniparser_parse` tool uses the same direct local-PDF upload, server-generated token, failure-without-token +contract, and three-check `undefined` limit as the CLI. ## Reference documents diff --git a/skills/UniParser-Tools/references/notes.md b/skills/UniParser-Tools/references/notes.md index 366027a..8440755 100644 --- a/skills/UniParser-Tools/references/notes.md +++ b/skills/UniParser-Tools/references/notes.md @@ -55,20 +55,18 @@ All API methods return a dict with consistent structure: ... } -# Transport or trigger error with an unconfirmed deterministic token +# SDK error { "status": "error", - "candidate_token": "abc123...", - "candidate_token_recoverable": False, + "token": "abc123...", "message": "Error description", "description": "Detailed traceback (optional)" } ``` -Only a successful trigger response contains a confirmed task `token`. SDK error responses can contain a -`candidate_token` when the caller uses the backward-compatible deterministic-token mode; do not poll or fetch it -unless a separate service status check confirms that the task exists. CLI and MCP default to server-generated tokens; -the CLI exposes `recoverable_token` only after confirmation, while MCP resumes a confirmed duplicate internally. +The low-level SDK retains its deterministic-token behavior for backward compatibility and may include that local value +in an error response. This is not part of the Agent Skill workflow. CLI and MCP explicitly request server-generated +tokens and only persist the `token` from a successful trigger response; never use an SDK error token with CLI `fetch`. ## Common Error Messages diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 7bcb82a..4a7b209 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -237,7 +237,7 @@ def test_parse_molecule_disable_override( assert call_kwargs["molecule"] is ParseMode.Disable assert call_kwargs["table"] is ParseMode.OCRHighQuality - def test_parse_transport_failure_does_not_write_recoverable_token( + def test_parse_transport_failure_does_not_write_task_token( self, runner: CliRunner, monkeypatch: pytest.MonkeyPatch, @@ -249,6 +249,7 @@ def test_parse_transport_failure_does_not_write_recoverable_token( mock_client = MagicMock() mock_client.trigger_file.return_value = { "status": "error", + "token": "must-not-leak", "description": "The write operation timed out", "error_type": "WriteTimeout", } @@ -265,11 +266,11 @@ def test_parse_transport_failure_does_not_write_recoverable_token( payload = json.loads(result.stderr.strip().splitlines()[-1]) assert payload["error"]["code"] == "UPLOAD_ERROR" assert payload["error"]["stage"] == "trigger_file" - assert "candidate_token" not in payload - assert "recoverable_token" not in payload + assert "token" not in payload assert not (out / "trigger_meta.json").exists() saved_error = json.loads((out / "trigger_error.json").read_text(encoding="utf-8")) assert saved_error["error_type"] == "WriteTimeout" + assert "token" not in saved_error def test_parse_direct_upload_failure_uses_upload_error( self, diff --git a/tests/unit/test_cli_pipeline.py b/tests/unit/test_cli_pipeline.py index a60ac64..a0533bf 100644 --- a/tests/unit/test_cli_pipeline.py +++ b/tests/unit/test_cli_pipeline.py @@ -5,11 +5,7 @@ from unittest.mock import MagicMock from uniparser_tools.cli.core.input import InputKind, ResolvedInput -from uniparser_tools.cli.core.pipeline import ( - annotate_recoverable_duplicate, - poll_until_success, - trigger_input, -) +from uniparser_tools.cli.core.pipeline import poll_until_success, trigger_input def _local_pdf(path: Path) -> ResolvedInput: @@ -59,43 +55,6 @@ def test_async_local_pdf_uses_direct_upload_timeout(tmp_path: Path) -> None: client.trigger_url.assert_not_called() -def test_duplicate_candidate_becomes_recoverable_only_when_confirmed() -> None: - client = MagicMock() - client.get_result.return_value = {"status": "waiting"} - trigger = { - "status": "error", - "description": "Token is duplicated", - "candidate_token": "candidate", - "candidate_token_recoverable": False, - } - - result = annotate_recoverable_duplicate(client, trigger) - - assert result["recoverable_token"] == "candidate" - assert "candidate_token" not in result - assert result["token_status"] == "waiting" - - -def test_duplicate_candidate_stays_unrecoverable_when_undefined(monkeypatch) -> None: - client = MagicMock() - client.get_result.return_value = {"status": "undefined"} - monkeypatch.setattr("uniparser_tools.cli.core.pipeline.time.sleep", lambda _: None) - trigger = { - "status": "error", - "description": "Token is duplicated", - "token": "candidate", - } - - result = annotate_recoverable_duplicate(client, trigger) - - assert "recoverable_token" not in result - assert "token" not in result - assert result["candidate_token"] == "candidate" - assert result["candidate_token_recoverable"] is False - assert result["token_status"] == "undefined" - assert client.get_result.call_count == 3 - - def test_undefined_token_stops_after_bounded_checks(monkeypatch, capsys) -> None: client = MagicMock() client.get_result.return_value = {"status": "undefined"} diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index efffa47..bcb4353 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -127,9 +127,7 @@ def test_trigger_file_returns_error_dict_on_request_failure(self, tmp_path) -> N result = c.trigger_file(file_path=str(p)) assert isinstance(result, dict) assert result.get("status") == "error" - assert "token" not in result - assert result["candidate_token"] == c.to_token(str(p)) - assert result["candidate_token_recoverable"] is False + assert "token" in result class TestHTTPTransport: @@ -180,25 +178,6 @@ def test_http_error_preserves_json_body(self) -> None: assert result["description"] == "rate limited" assert result["http_status"] == 429 - @pytest.mark.parametrize("status_code", [200, 409]) - def test_trigger_error_keeps_candidate_token_separate(self, tmp_path, status_code: int) -> None: - path = tmp_path / "document.pdf" - path.write_bytes(b"%PDF-1.4 tiny") - session = FakeSession( - response=FakeResponse( - status_code=status_code, - payload={"status": "error", "description": "Token is duplicated"}, - reason="Conflict", - ) - ) - client = UniParserClient(host="https://example.com", api_key="k", session=session) - - result = client.trigger_file(str(path)) - - assert "token" not in result - assert result["candidate_token"] == client.to_token(str(path)) - assert result["candidate_token_recoverable"] is False - def test_client_context_closes_owned_session(self, monkeypatch) -> None: session = FakeSession() monkeypatch.setattr("uniparser_tools.api.transport.requests.Session", lambda: session) diff --git a/tests/unit/test_notebook_hygiene.py b/tests/unit/test_notebook_hygiene.py index e3c5066..6b633ea 100644 --- a/tests/unit/test_notebook_hygiene.py +++ b/tests/unit/test_notebook_hygiene.py @@ -55,15 +55,17 @@ def test_customer_docs_include_result_retention_notice() -> None: assert "retained for only **24 hours**" in notes -def test_skill_only_recovers_service_confirmed_tokens() -> None: +def test_skill_only_uses_tokens_from_successful_triggers() -> None: skill = (REPO_ROOT / "skills" / "UniParser-Tools" / "SKILL.md").read_text(encoding="utf-8") notes = (REPO_ROOT / "skills" / "UniParser-Tools" / "references" / "notes.md").read_text(encoding="utf-8") - assert "recoverable_token" in skill - assert "candidate_token" in skill - assert "Never pass `candidate_token` to `fetch`" in skill + assert "A failed trigger does not provide a task token" in skill + assert "Save `token` only from success JSON or `trigger_meta.json`" in skill + assert "candidate_token" not in skill + assert "recoverable_token" not in skill assert "the `token` field in a failed parse stderr JSON" not in skill assert "--upload-mode" not in skill assert "TOS" not in skill - assert '"candidate_token": "abc123..."' in notes - assert '"candidate_token_recoverable": False' in notes + assert "only persist the `token` from a successful trigger response" in notes + assert "candidate_token" not in notes + assert "recoverable_token" not in notes diff --git a/uniparser_tools/api/clients.py b/uniparser_tools/api/clients.py index e6b38bb..f75e324 100644 --- a/uniparser_tools/api/clients.py +++ b/uniparser_tools/api/clients.py @@ -307,19 +307,16 @@ def trigger_file( data=data, timeout=self._trigger_http_timeout(sync, http_timeout), error_message="trigger file failed", - candidate_token=token, + token=token, ) except OSError as exc: - result = { + return { "status": StatusFlag.Error, + "token": token, "message": "trigger file failed", "description": str(exc), "error_type": type(exc).__name__, } - if token is not None: - result["candidate_token"] = token - result["candidate_token_recoverable"] = False - return result def trigger_snip( self, @@ -380,19 +377,16 @@ def trigger_snip( data=data, timeout=self._trigger_http_timeout(sync, http_timeout), error_message="trigger snip failed", - candidate_token=token, + token=token, ) except (OSError, ValueError) as exc: - result = { + return { "status": StatusFlag.Error, + "token": token, "message": "trigger snip failed", "description": str(exc), "error_type": type(exc).__name__, } - if token is not None: - result["candidate_token"] = token - result["candidate_token_recoverable"] = False - return result def trigger_url( self, @@ -450,7 +444,7 @@ def trigger_url( json=data, timeout=self._trigger_http_timeout(sync, http_timeout), error_message="trigger url failed", - candidate_token=token, + token=token, ) def request_tos_upload_links( diff --git a/uniparser_tools/api/transport.py b/uniparser_tools/api/transport.py index 7730d4c..9fa71ce 100644 --- a/uniparser_tools/api/transport.py +++ b/uniparser_tools/api/transport.py @@ -65,7 +65,6 @@ def request( expect_json: bool = True, error_message: str = "request failed", token: Optional[str] = None, - candidate_token: Optional[str] = None, **kwargs, ) -> Any: headers = dict(kwargs.pop("headers", {}) or {}) @@ -90,9 +89,6 @@ def request( } if token is not None: payload["token"] = token - if candidate_token is not None: - payload["candidate_token"] = candidate_token - payload["candidate_token_recoverable"] = False return payload try: @@ -114,9 +110,6 @@ def request( result["http_status"] = response.status_code if token is not None: result.setdefault("token", token) - if candidate_token is not None: - result.setdefault("candidate_token", candidate_token) - result.setdefault("candidate_token_recoverable", False) return result if not expect_json: @@ -125,10 +118,6 @@ def request( "http_status": response.status_code, } - if isinstance(payload, dict) and payload.get("status") == StatusFlag.Error and candidate_token is not None: - payload.setdefault("candidate_token", candidate_token) - payload.setdefault("candidate_token_recoverable", False) - if payload is not None: return payload @@ -140,9 +129,6 @@ def request( } if token is not None: result["token"] = token - if candidate_token is not None: - result["candidate_token"] = candidate_token - result["candidate_token_recoverable"] = False return result def close(self) -> None: diff --git a/uniparser_tools/cli/README.md b/uniparser_tools/cli/README.md index ac1560c..4195e5f 100644 --- a/uniparser_tools/cli/README.md +++ b/uniparser_tools/cli/README.md @@ -343,7 +343,7 @@ uniparser parse paper.pdf -o ./out/paper uniparser fetch --token YOUR_TOKEN ``` -token 可在成功 `parse` 的 JSON 输出或 `trigger_meta.json` 中找到。失败输出只有明确标记为 `recoverable_token` 的值才能用于 `fetch`;`candidate_token` 只是本地候选值,禁止用于恢复。 +token 只能从成功 `parse` 的 JSON 输出或 `trigger_meta.json` 中获取。失败的触发请求不会提供可用于恢复的 token。 | 选项 | 说明 | |------|------| @@ -435,10 +435,7 @@ Parsing... report.pdf uniparser --json parse paper.pdf 2>/dev/null | python3 -c "import sys,json; print(json.load(sys.stdin)['markdown_path'])" ``` -失败时仍为 **stderr 单行 JSON**(`ok: false`),见下方「常见问题」;exit code 为 1。失败 JSON 可能包含: - -- `recoverable_token`:服务端已确认存在,可用于 `fetch`。 -- `candidate_token`:服务端未确认,仅供诊断,不能用于 `fetch`。 +失败时仍为 **stderr 单行 JSON**(`ok: false`),见下方「常见问题」;exit code 为 1。触发失败时不会输出任务 token。 --- @@ -466,15 +463,11 @@ uniparser --json parse paper.pdf 2>/dev/null | python3 -c "import sys,json; prin **提示 `UPLOAD_ERROR` / write timeout** -本次直传没有确认创建解析任务,可以重新执行 `parse`。错误中的 `candidate_token` 不能交给 `fetch`。 +本次直传没有成功返回服务端任务 token,不能执行 `fetch`,可以重新运行 `parse`。 **提示 `TOKEN_NOT_FOUND` 或持续 `status: undefined`** -CLI 会在三次检查后停止,不会继续等待 1800 秒。若该值来自 `candidate_token`,请重新执行 `parse`;若来自历史 `trigger_meta.json`,请确认任务结果是否仍在 24 小时保留期内。 - -**提示 `Token is duplicated`** - -只有失败 JSON 含 `recoverable_token` 时才运行 `uniparser fetch --token RECOVERABLE_TOKEN`。没有该字段时不要尝试恢复。 +CLI 会在三次检查后停止,不会继续等待 1800 秒。请确认 token 来自成功输出或 `trigger_meta.json`,并检查任务结果是否仍在 24 小时保留期内。 --- diff --git a/uniparser_tools/cli/core/errors.py b/uniparser_tools/cli/core/errors.py index 09c4e62..aef4c94 100644 --- a/uniparser_tools/cli/core/errors.py +++ b/uniparser_tools/cli/core/errors.py @@ -40,11 +40,6 @@ def _operation_error(code: str, stage: str, result: dict) -> int: "stage": stage, }, } - if result.get("recoverable_token"): - payload["recoverable_token"] = result["recoverable_token"] - if result.get("candidate_token"): - payload["candidate_token"] = result["candidate_token"] - payload["candidate_token_recoverable"] = False emit_json_stderr(payload) return 1 diff --git a/uniparser_tools/cli/core/pipeline.py b/uniparser_tools/cli/core/pipeline.py index b612a15..7544c83 100644 --- a/uniparser_tools/cli/core/pipeline.py +++ b/uniparser_tools/cli/core/pipeline.py @@ -122,41 +122,6 @@ def save_stage_error(out_dir: Path, filename: str, payload: dict) -> None: (out_dir / filename).write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8") -def annotate_recoverable_duplicate(client, trigger: dict) -> dict: - diagnostic = " ".join(str(trigger.get(key, "")) for key in ("message", "description")).casefold() - if "duplicat" not in diagnostic: - return trigger - - candidate = trigger.get("token") or trigger.get("candidate_token") - if not candidate: - return trigger - - status = None - for attempt in range(UNDEFINED_MAX_POLLS): - probe = client.get_result( - candidate, - content=False, - objects=False, - pages_dict=False, - pages_tree=False, - ) - status = probe.get("status") if isinstance(probe, dict) else None - if status != "undefined" or attempt == UNDEFINED_MAX_POLLS - 1: - break - time.sleep(POLL_INTERVAL_SEC) - trigger["token_status"] = status - if status == "success" or status in PENDING_STATUSES: - trigger["recoverable_token"] = candidate - trigger.pop("candidate_token", None) - trigger.pop("candidate_token_recoverable", None) - return trigger - - trigger.pop("token", None) - trigger["candidate_token"] = candidate - trigger["candidate_token_recoverable"] = False - return trigger - - def complete_fetch( client, token: str, @@ -198,7 +163,7 @@ def run_parse( print_parsing_status(display_label_for_input(resolved)) trigger, stage = trigger_input(client, resolved, trigger_kwargs=trigger_kwargs) if trigger.get("status") != "success": - trigger = annotate_recoverable_duplicate(client, trigger) + trigger.pop("token", None) save_stage_error(out_dir, "trigger_error.json", trigger) if stage == "trigger_file" and trigger.get("error_type"): return upload_error(stage, trigger) From a11028562b219f447f6e8139963ff070c4d3aad9 Mon Sep 17 00:00:00 2001 From: liumingyang <1950307156@qq.com> Date: Wed, 19 Aug 2026 16:22:28 +0800 Subject: [PATCH 9/9] fix(project): preserve diagnostic tokens without recovery --- mcp_server/README.md | 9 +++++---- mcp_server/tests/test_pipeline_parse.py | 11 +++++++---- mcp_server/tests/test_pipeline_poll.py | 2 +- mcp_server/uniparser_mcp/errors.py | 3 ++- mcp_server/uniparser_mcp/instructions.py | 3 ++- mcp_server/uniparser_mcp/pipeline/parse.py | 4 +++- mcp_server/uniparser_mcp/schemas.py | 2 +- skills/UniParser-Tools/SKILL.md | 12 ++++++------ skills/UniParser-Tools/references/notes.md | 5 +++-- tests/unit/test_cli.py | 12 ++++++++---- tests/unit/test_cli_pipeline.py | 4 +++- tests/unit/test_notebook_hygiene.py | 2 +- uniparser_tools/cli/README.md | 9 ++++++--- uniparser_tools/cli/core/errors.py | 3 ++- uniparser_tools/cli/core/pipeline.py | 4 +++- 15 files changed, 53 insertions(+), 32 deletions(-) diff --git a/mcp_server/README.md b/mcp_server/README.md index c4e5d9a..d9f315a 100644 --- a/mcp_server/README.md +++ b/mcp_server/README.md @@ -21,16 +21,17 @@ 成功返回 JSON(Pydantic):`markdown_path`、`pages_tree_path`、`content_preview`(默认 2000 字)、`message` 等。 调用方应以返回的 `output_dir` 为准;服务不会复用或删除已有目录。 -本地 PDF 固定通过 `trigger_file` 直接 multipart 上传,不使用 TOS 或自动回退。MCP 要求服务端生成 -token;同步请求使用 `(60, 1860)`,异步请求使用 `(60, 60)`,只有服务端确认的 token 才能进入结果轮询。 +本地 PDF 固定通过 `trigger_file` 直接 multipart 上传,不使用 TOS 或自动回退。MCP 触发时固定发送 +`token=None` 和 `server_generated_token=True`;同步请求使用 `(60, 1860)`,异步请求使用 `(60, 60)`, +只有服务端确认的 token 才能进入结果轮询。 > 出于安全原因,根目录、HOME、当前工作目录及 Git 元数据目录不能作为首选输出目录。 健康检查、版本查询、按 token 手动恢复请使用 CLI:`uniparser health`、`uniparser version`、`uniparser fetch`。 失败返回的 `error.code` 可能为 `CONFIG_ERROR`、`INPUT_ERROR`、`UPLOAD_ERROR`、`PARSE_ERROR` 或 -`TOKEN_NOT_FOUND`。触发失败不会返回任务 token,也不会进入恢复流程;`undefined` 最多检查三次, -不会持续等待 1800 秒。 +`TOKEN_NOT_FOUND`。触发失败时可能保留标准 `token` 供诊断,但 MCP 不会使用它或进入恢复流程;只有 +成功触发返回的 token 才会被轮询。`undefined` 最多检查三次,不会持续等待 1800 秒。 ## 环境变量 diff --git a/mcp_server/tests/test_pipeline_parse.py b/mcp_server/tests/test_pipeline_parse.py index d3502a2..76b7b13 100644 --- a/mcp_server/tests/test_pipeline_parse.py +++ b/mcp_server/tests/test_pipeline_parse.py @@ -14,7 +14,7 @@ def test_failed_trigger_does_not_attempt_token_recovery(tmp_path: Path): client = MagicMock() client.trigger_file.return_value = { "status": "error", - "token": "must-not-leak", + "token": "diagnostic-token", "message": "trigger failed", } @@ -24,9 +24,9 @@ def test_failed_trigger_does_not_attempt_token_recovery(tmp_path: Path): assert result.ok is False assert result.error.code == "PARSE_ERROR" - assert "token" not in result.model_dump() + assert result.error.token == "diagnostic-token" saved_error = json.loads((out / "trigger_error.json").read_text(encoding="utf-8")) - assert "token" not in saved_error + assert saved_error["token"] == "diagnostic-token" client.trigger_file.assert_called_once() client.get_result.assert_not_called() client.get_formatted.assert_not_called() @@ -56,6 +56,7 @@ def test_success_parse_writes_trigger_meta(tmp_path: Path): assert Path(result.trigger_meta_path).is_file() trigger_args, trigger_kwargs = client.trigger_file.call_args assert trigger_args == (str(pdf.resolve()),) + assert trigger_kwargs["token"] is None assert trigger_kwargs["server_generated_token"] is True assert trigger_kwargs["http_timeout"] == (60.0, 1860.0) @@ -71,6 +72,7 @@ def test_async_local_pdf_uses_direct_upload_timeout(tmp_path: Path): assert result.ok is False _, trigger_kwargs = client.trigger_file.call_args + assert trigger_kwargs["token"] is None assert trigger_kwargs["server_generated_token"] is True assert trigger_kwargs["http_timeout"] == (60.0, 60.0) assert trigger_kwargs["sync"] is False @@ -92,7 +94,7 @@ def test_direct_upload_transport_failure_uses_upload_error(tmp_path: Path): assert result.ok is False assert result.error.code == "UPLOAD_ERROR" assert result.error.stage == "trigger_file" - assert "token" not in result.model_dump() + assert result.error.token is None def test_url_trigger_uses_server_generated_token(tmp_path: Path): @@ -104,6 +106,7 @@ def test_url_trigger_uses_server_generated_token(tmp_path: Path): assert result.ok is False _, trigger_kwargs = client.trigger_url.call_args + assert trigger_kwargs["token"] is None assert trigger_kwargs["server_generated_token"] is True diff --git a/mcp_server/tests/test_pipeline_poll.py b/mcp_server/tests/test_pipeline_poll.py index f0803cd..8105df3 100644 --- a/mcp_server/tests/test_pipeline_poll.py +++ b/mcp_server/tests/test_pipeline_poll.py @@ -17,7 +17,7 @@ async def no_sleep(_seconds: float) -> None: assert result.ok is False assert result.error.code == "TOKEN_NOT_FOUND" - assert result.unrecognized_token == "missing-token" + assert result.error.token == "missing-token" assert client.get_result.call_count == 3 diff --git a/mcp_server/uniparser_mcp/errors.py b/mcp_server/uniparser_mcp/errors.py index e1f787e..69dcec5 100644 --- a/mcp_server/uniparser_mcp/errors.py +++ b/mcp_server/uniparser_mcp/errors.py @@ -19,6 +19,7 @@ def _operation_error(code: str, stage: str, result: dict) -> ErrorResult: code=code, message=result.get("description") or result.get("message") or str(result), stage=stage, + token=result.get("token"), ) ) @@ -37,6 +38,6 @@ def token_not_found_error(token: str, *, attempts: int) -> ErrorResult: code="TOKEN_NOT_FOUND", message=f"The service did not recognize this token after {attempts} checks.", stage="get_result_poll", + token=token, ), - unrecognized_token=token, ) diff --git a/mcp_server/uniparser_mcp/instructions.py b/mcp_server/uniparser_mcp/instructions.py index 95b91b7..b24c6c5 100644 --- a/mcp_server/uniparser_mcp/instructions.py +++ b/mcp_server/uniparser_mcp/instructions.py @@ -22,6 +22,7 @@ ## After calling uniparser_parse On success, always include the `message` field from the tool response in your reply to the user. -On failure, report `error.message`. A failed trigger does not provide a task token, so do not attempt recovery. +On failure, report `error.message`. Never poll a token from a failed trigger; it is diagnostic only. Only a +token returned with `status=success` may be polled. Do not attempt recovery from a failed trigger. Stop retrying when the error code is `TOKEN_NOT_FOUND`. """ diff --git a/mcp_server/uniparser_mcp/pipeline/parse.py b/mcp_server/uniparser_mcp/pipeline/parse.py index 936cabe..1d9bdfb 100644 --- a/mcp_server/uniparser_mcp/pipeline/parse.py +++ b/mcp_server/uniparser_mcp/pipeline/parse.py @@ -44,6 +44,7 @@ async def _trigger_input( trigger = await asyncio.to_thread( client.trigger_file, str(resolved.path), + token=None, server_generated_token=True, http_timeout=( DIRECT_SYNC_UPLOAD_REQUEST_TIMEOUT @@ -62,6 +63,7 @@ async def _trigger_input( trigger = await asyncio.to_thread( client.trigger_snip, str(resolved.path), + token=None, server_generated_token=True, **trigger_kwargs, ) @@ -69,6 +71,7 @@ async def _trigger_input( trigger = await asyncio.to_thread( client.trigger_url, resolved.raw, + token=None, server_generated_token=True, **trigger_kwargs, ) @@ -164,7 +167,6 @@ async def run_parse(client: UniParserClient, req: ParseRequest, ctx: Context | N trigger, stage = await _trigger_input(client, resolved, trigger_kwargs=trigger_kwargs) if trigger.get("status") != "success": - trigger.pop("token", None) save_stage_error(out_dir, "trigger_error.json", trigger) if stage == "trigger_file" and trigger.get("error_type"): return upload_error(stage, trigger) diff --git a/mcp_server/uniparser_mcp/schemas.py b/mcp_server/uniparser_mcp/schemas.py index 745d61e..24aadde 100644 --- a/mcp_server/uniparser_mcp/schemas.py +++ b/mcp_server/uniparser_mcp/schemas.py @@ -70,6 +70,7 @@ class ErrorDetail(BaseModel): message: str stage: str | None = None output_dir: str | None = None + token: str | None = None class ParseSuccess(BaseModel): @@ -90,7 +91,6 @@ class ParseSuccess(BaseModel): class ErrorResult(BaseModel): ok: Literal[False] = False error: ErrorDetail - unrecognized_token: str | None = None ParseResult = ParseSuccess | ErrorResult diff --git a/skills/UniParser-Tools/SKILL.md b/skills/UniParser-Tools/SKILL.md index c69f9b4..8e60a64 100644 --- a/skills/UniParser-Tools/SKILL.md +++ b/skills/UniParser-Tools/SKILL.md @@ -77,7 +77,7 @@ $env:UNIPARSER_API_KEY="your-api-key" ### CLI reference -**Pipeline:** local PDFs use direct multipart upload with a 60-second upload window and a server-generated token. Images use `trigger_snip`; public URLs use `trigger_url`. The CLI then polls `get_result` until success, fetches `pages_tree` + Markdown, and saves them. Default `sync=true`; `--async` uses `sync=false` on the trigger only. +**Pipeline:** every trigger sends `token=None` with `server_generated_token=True`. Local PDFs use direct multipart upload with a 60-second upload window. Images use `trigger_snip`; public URLs use `trigger_url`. The CLI then polls `get_result` only with the token from a successful trigger, fetches `pages_tree` + Markdown, and saves them. Default `sync=true`; `--async` uses `sync=false` on the trigger only. **Input:** one positional `INPUT` per run—the CLI detects type by path suffix or `http(s)://` URL: @@ -104,7 +104,7 @@ Recovery (existing server job—see **Common issues**): uniparser fetch --token "TASK_TOKEN_FROM_PRIOR_RUN" ``` -Valid recovery tokens come only from successful `uniparser --json parse …` stdout or `trigger_meta.json` written after a successful trigger. A failed trigger does not provide a task token and cannot be resumed with `fetch`. +Valid recovery tokens come only from successful `uniparser --json parse …` stdout or `trigger_meta.json` written after a successful trigger. Never use a token from a failed trigger: it is diagnostic only and cannot be resumed with `fetch`. **Default output for** `fetch` (when `-o` / `--output-dir` is omitted): `~/Uni-Parser-Skill/token_/`, where `` is the first 8 characters of the token (e.g. `~/Uni-Parser-Skill/token_a1b2c3d4/token_a1b2c3d4.md`). Passing a prior `parse` directory with `-o` treats it only as the preferred path; because that directory already exists, `fetch` writes to an available sibling such as `paper_1`. Always use the returned `output_dir`. @@ -184,7 +184,7 @@ uniparser parse paper.pdf --json # wrong If the preferred output directory already exists, the CLI creates an available sibling such as `results_1` or `results_2`. Existing paths are never reused or deleted; use the returned `output_dir`. -Failure JSON does not contain a task token. Use `fetch` only with a token from successful parse output or `trigger_meta.json`. +Failure JSON may carry the standard `token` field for diagnostics. Never pass that value to `fetch`; use `fetch` only with a token from successful parse output or `trigger_meta.json`. **Common error codes** (stderr JSON): `CONFIG_ERROR`, `INPUT_ERROR`, `UPLOAD_ERROR`, `PARSE_ERROR`, `TOKEN_NOT_FOUND`. @@ -196,7 +196,7 @@ On failure, show stderr JSON `error.message`. Do not substitute vision-only read | Problem | Cause | Solution | | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `CONFIG_ERROR` | No API key or `uniparser` not installed | **Configuration** + `pip install "git+https://github.com/dptech-corp/UniParser-Tools.git"`; `uniparser auth --verify` | -| `UPLOAD_ERROR` / write timeout | Direct file upload failed without returning a server task token | Re-run `uniparser parse`; the failed trigger cannot be resumed with `fetch` | +| `UPLOAD_ERROR` / write timeout | Direct file upload failed; any token in the error is diagnostic only | Re-run `uniparser parse`; the failed trigger cannot be resumed with `fetch` | | `TOKEN_NOT_FOUND` / `status: undefined` | The service did not recognize the token after three checks | Stop fetching; verify that the token came from successful output or `trigger_meta.json` and is still within the retention period | | Job not done / long wait / CLI interrupted / `processing` / poll timeout | Sync or poll still running; or local process stopped while server job continues | Wait; do **not** start a second `uniparser parse` for the same input. Use saved `token` with `uniparser fetch --token TOKEN`; files appear only after exit 0 | | `502 Bad Gateway` on URL input | Server failed fetching or processing remote PDF | Retry `uniparser parse "same url"` once; or download and `uniparser parse local.pdf`; or `uniparser fetch --token TOKEN` if a prior job exists | @@ -212,8 +212,8 @@ For callbacks, custom `ParseMode`, or SDK examples, see [Common patterns](./refe Full CLI reference (flags, examples, JSON details): [CLI README](../../uniparser_tools/cli/README.md) in this repository. Optional MCP server setup is in the [UniParser-Tools GitHub repo](https://github.com/dptech-corp/UniParser-Tools); it is separate from this CLI workflow. -Its `uniparser_parse` tool uses the same direct local-PDF upload, server-generated token, failure-without-token -contract, and three-check `undefined` limit as the CLI. +Its `uniparser_parse` tool uses the same direct local-PDF upload, server-generated token, successful-trigger-only +token use, and three-check `undefined` limit as the CLI. ## Reference documents diff --git a/skills/UniParser-Tools/references/notes.md b/skills/UniParser-Tools/references/notes.md index 8440755..c2916ff 100644 --- a/skills/UniParser-Tools/references/notes.md +++ b/skills/UniParser-Tools/references/notes.md @@ -65,8 +65,9 @@ All API methods return a dict with consistent structure: ``` The low-level SDK retains its deterministic-token behavior for backward compatibility and may include that local value -in an error response. This is not part of the Agent Skill workflow. CLI and MCP explicitly request server-generated -tokens and only persist the `token` from a successful trigger response; never use an SDK error token with CLI `fetch`. +in an error response. This is not part of the Agent Skill workflow. CLI and MCP always send `token=None` with +`server_generated_token=True` and only persist the `token` from a successful trigger response; never use an SDK error +token with CLI `fetch`. ## Common Error Messages diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 4a7b209..b3751e5 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -175,6 +175,7 @@ def test_parse_success_writes_trigger_meta_and_json_token( assert (actual_out / "paper.md").is_file() _, trigger_kwargs = mock_client.trigger_file.call_args assert trigger_kwargs["file_path"] == str(pdf.resolve()) + assert trigger_kwargs["token"] is None assert trigger_kwargs["server_generated_token"] is True assert trigger_kwargs["http_timeout"] == (60.0, 1860.0) mock_client.trigger_url.assert_not_called() @@ -201,6 +202,7 @@ def test_parse_default_trigger_kwargs( ) _, call_kwargs = mock_client.trigger_file.call_args + assert call_kwargs["token"] is None assert call_kwargs["server_generated_token"] is True assert call_kwargs["http_timeout"] == (60.0, 1860.0) assert call_kwargs["sync"] is True @@ -237,7 +239,7 @@ def test_parse_molecule_disable_override( assert call_kwargs["molecule"] is ParseMode.Disable assert call_kwargs["table"] is ParseMode.OCRHighQuality - def test_parse_transport_failure_does_not_write_task_token( + def test_parse_transport_failure_does_not_recover_error_token( self, runner: CliRunner, monkeypatch: pytest.MonkeyPatch, @@ -249,7 +251,7 @@ def test_parse_transport_failure_does_not_write_task_token( mock_client = MagicMock() mock_client.trigger_file.return_value = { "status": "error", - "token": "must-not-leak", + "token": "diagnostic-token", "description": "The write operation timed out", "error_type": "WriteTimeout", } @@ -266,11 +268,12 @@ def test_parse_transport_failure_does_not_write_task_token( payload = json.loads(result.stderr.strip().splitlines()[-1]) assert payload["error"]["code"] == "UPLOAD_ERROR" assert payload["error"]["stage"] == "trigger_file" - assert "token" not in payload + assert payload["token"] == "diagnostic-token" assert not (out / "trigger_meta.json").exists() saved_error = json.loads((out / "trigger_error.json").read_text(encoding="utf-8")) assert saved_error["error_type"] == "WriteTimeout" - assert "token" not in saved_error + assert saved_error["token"] == "diagnostic-token" + mock_client.get_result.assert_not_called() def test_parse_direct_upload_failure_uses_upload_error( self, @@ -301,6 +304,7 @@ def test_parse_direct_upload_failure_uses_upload_error( assert payload["error"]["stage"] == "trigger_file" mock_client.trigger_url.assert_not_called() _, direct_kwargs = mock_client.trigger_file.call_args + assert direct_kwargs["token"] is None assert direct_kwargs["server_generated_token"] is True assert direct_kwargs["http_timeout"] == (60.0, 1860.0) diff --git a/tests/unit/test_cli_pipeline.py b/tests/unit/test_cli_pipeline.py index a0533bf..ccc1e53 100644 --- a/tests/unit/test_cli_pipeline.py +++ b/tests/unit/test_cli_pipeline.py @@ -29,6 +29,7 @@ def test_local_pdf_uses_direct_upload_with_server_token(tmp_path: Path) -> None: assert stage == "trigger_file" client.trigger_file.assert_called_once_with( file_path=str(pdf), + token=None, server_generated_token=True, http_timeout=(60.0, 1860.0), sync=True, @@ -48,6 +49,7 @@ def test_async_local_pdf_uses_direct_upload_timeout(tmp_path: Path) -> None: assert stage == "trigger_file" client.trigger_file.assert_called_once_with( file_path=str(pdf), + token=None, server_generated_token=True, http_timeout=(60.0, 60.0), sync=False, @@ -66,7 +68,7 @@ def test_undefined_token_stops_after_bounded_checks(monkeypatch, capsys) -> None assert client.get_result.call_count == 3 payload = json.loads(capsys.readouterr().err) assert payload["error"]["code"] == "TOKEN_NOT_FOUND" - assert payload["unrecognized_token"] == "missing-token" + assert payload["token"] == "missing-token" def test_pending_statuses_still_reach_success(monkeypatch) -> None: diff --git a/tests/unit/test_notebook_hygiene.py b/tests/unit/test_notebook_hygiene.py index 6b633ea..4523d70 100644 --- a/tests/unit/test_notebook_hygiene.py +++ b/tests/unit/test_notebook_hygiene.py @@ -59,7 +59,7 @@ def test_skill_only_uses_tokens_from_successful_triggers() -> None: skill = (REPO_ROOT / "skills" / "UniParser-Tools" / "SKILL.md").read_text(encoding="utf-8") notes = (REPO_ROOT / "skills" / "UniParser-Tools" / "references" / "notes.md").read_text(encoding="utf-8") - assert "A failed trigger does not provide a task token" in skill + assert "Never use a token from a failed trigger" in skill assert "Save `token` only from success JSON or `trigger_meta.json`" in skill assert "candidate_token" not in skill assert "recoverable_token" not in skill diff --git a/uniparser_tools/cli/README.md b/uniparser_tools/cli/README.md index 4195e5f..76c1333 100644 --- a/uniparser_tools/cli/README.md +++ b/uniparser_tools/cli/README.md @@ -343,7 +343,9 @@ uniparser parse paper.pdf -o ./out/paper uniparser fetch --token YOUR_TOKEN ``` -token 只能从成功 `parse` 的 JSON 输出或 `trigger_meta.json` 中获取。失败的触发请求不会提供可用于恢复的 token。 +CLI 触发时固定发送 `token=None` 和 `server_generated_token=True`。可用于恢复的 token 只能从成功 +`parse` 的 JSON 输出或 `trigger_meta.json` 中获取。失败 JSON 可能保留标准 `token` 字段用于诊断,但不得 +将它传给 `fetch`。 | 选项 | 说明 | |------|------| @@ -435,7 +437,8 @@ Parsing... report.pdf uniparser --json parse paper.pdf 2>/dev/null | python3 -c "import sys,json; print(json.load(sys.stdin)['markdown_path'])" ``` -失败时仍为 **stderr 单行 JSON**(`ok: false`),见下方「常见问题」;exit code 为 1。触发失败时不会输出任务 token。 +失败时仍为 **stderr 单行 JSON**(`ok: false`),见下方「常见问题」;exit code 为 1。失败 JSON 中的 +`token` 仅用于诊断;只有成功 `parse` 输出或 `trigger_meta.json` 中的 token 可用于 `fetch`。 --- @@ -463,7 +466,7 @@ uniparser --json parse paper.pdf 2>/dev/null | python3 -c "import sys,json; prin **提示 `UPLOAD_ERROR` / write timeout** -本次直传没有成功返回服务端任务 token,不能执行 `fetch`,可以重新运行 `parse`。 +本次直传失败。请重新运行 `parse`;错误结果中的 token 即使存在也仅供诊断,不能用于 `fetch`。 **提示 `TOKEN_NOT_FOUND` 或持续 `status: undefined`** diff --git a/uniparser_tools/cli/core/errors.py b/uniparser_tools/cli/core/errors.py index aef4c94..2c5a3b3 100644 --- a/uniparser_tools/cli/core/errors.py +++ b/uniparser_tools/cli/core/errors.py @@ -34,6 +34,7 @@ def missing_token_error() -> int: def _operation_error(code: str, stage: str, result: dict) -> int: payload = { "ok": False, + "token": result.get("token"), "error": { "code": code, "message": result.get("description") or result.get("message") or str(result), @@ -56,12 +57,12 @@ def token_not_found_error(token: str, *, attempts: int) -> int: emit_json_stderr( { "ok": False, + "token": token, "error": { "code": "TOKEN_NOT_FOUND", "message": f"The service did not recognize this token after {attempts} checks.", "stage": "get_result_poll", }, - "unrecognized_token": token, } ) return 1 diff --git a/uniparser_tools/cli/core/pipeline.py b/uniparser_tools/cli/core/pipeline.py index 7544c83..227a647 100644 --- a/uniparser_tools/cli/core/pipeline.py +++ b/uniparser_tools/cli/core/pipeline.py @@ -33,6 +33,7 @@ def trigger_input( if resolved.kind is InputKind.FILE: trigger = client.trigger_file( file_path=str(resolved.path), + token=None, server_generated_token=True, http_timeout=( DIRECT_SYNC_UPLOAD_REQUEST_TIMEOUT if kwargs.get("sync", True) else DIRECT_UPLOAD_REQUEST_TIMEOUT @@ -48,12 +49,14 @@ def trigger_input( if resolved.kind is InputKind.IMAGE: trigger = client.trigger_snip( snip_path=str(resolved.path), + token=None, server_generated_token=True, **kwargs, ) return trigger, "trigger_snip" trigger = client.trigger_url( pdf_url=resolved.raw, + token=None, server_generated_token=True, **kwargs, ) @@ -163,7 +166,6 @@ def run_parse( print_parsing_status(display_label_for_input(resolved)) trigger, stage = trigger_input(client, resolved, trigger_kwargs=trigger_kwargs) if trigger.get("status") != "success": - trigger.pop("token", None) save_stage_error(out_dir, "trigger_error.json", trigger) if stage == "trigger_file" and trigger.get("error_type"): return upload_error(stage, trigger)