diff --git a/.github/workflows/dsh-plugin-smoke.yml b/.github/workflows/dsh-plugin-smoke.yml new file mode 100644 index 0000000..6dc0e6e --- /dev/null +++ b/.github/workflows/dsh-plugin-smoke.yml @@ -0,0 +1,97 @@ +name: DSH plugin macOS smoke + +on: + pull_request: + paths: + - 'plugins/dsh-invoice-downloader/**' + - '.github/workflows/dsh-plugin-smoke.yml' + push: + branches: [main] + paths: + - 'plugins/dsh-invoice-downloader/**' + - '.github/workflows/dsh-plugin-smoke.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + package-and-runtime: + name: Package, runtime, and DSH load + runs-on: macos-14 + timeout-minutes: 40 + steps: + - uses: actions/checkout@v4 + + - uses: actions/checkout@v4 + with: + repository: deepseek-ai/deepseek-harness + ref: master + path: dsh + + - uses: actions/setup-node@v4 + with: + node-version: 24 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Build the publishable tarball + working-directory: plugins/dsh-invoice-downloader + run: | + npm ci + mkdir -p "$GITHUB_WORKSPACE/.runtime/dsh-plugin-artifact" + npm test + npm pack --pack-destination "$GITHUB_WORKSPACE/.runtime/dsh-plugin-artifact" + + - name: Install the tarball into an isolated DSH Web profile + working-directory: dsh + env: + DSH_HOME: ${{ github.workspace }}/.runtime/dsh-plugin-home + run: | + corepack enable + pnpm install --frozen-lockfile + pnpm run build + package_tgz="$(find "$GITHUB_WORKSPACE/.runtime/dsh-plugin-artifact" -maxdepth 1 -name '*.tgz' -print -quit)" + test -n "$package_tgz" + pnpm dsh plugin --profile web add "file:$package_tgz" + pnpm dsh --profile web --dump-config | grep --fixed-strings '@ethanyoq/dsh-invoice-downloader' + + - name: Install and check the DSH profile-state runtime + working-directory: dsh + env: + DSH_HOME: ${{ github.workspace }}/.runtime/dsh-plugin-home + run: | + plugin_dir="$(cd "$DSH_HOME/profiles/web" && node -p "require('node:path').dirname(require.resolve('@ethanyoq/dsh-invoice-downloader/package.json'))")" + runtime_root="$DSH_HOME/invoice-downloader/runtime" + python3 "$plugin_dir/runtime/install.py" \ + --venv "$runtime_root/.venv" \ + --requirements "$plugin_dir/runtime/requirements-headless.txt" \ + --engine-root "$plugin_dir/runtime/engine" \ + --health-check "$plugin_dir/runtime/health_check.py" + "$runtime_root/.venv/bin/python" "$plugin_dir/runtime/health_check.py" --engine-root "$plugin_dir/runtime/engine" + test ! -e "$plugin_dir/runtime/.venv" + + - name: Serve and load the package client bundle + working-directory: dsh + env: + DSH_HOME: ${{ github.workspace }}/.runtime/dsh-plugin-home + run: | + pnpm dsh --profile web --port 3192 > "$RUNNER_TEMP/dsh-web.log" 2>&1 & + dsh_pid=$! + cleanup() { + status=$? + kill "$dsh_pid" 2>/dev/null || true + wait "$dsh_pid" 2>/dev/null || true + if [ "$status" -ne 0 ]; then cat "$RUNNER_TEMP/dsh-web.log"; fi + exit "$status" + } + trap cleanup EXIT + for attempt in {1..30}; do + if curl --fail --silent --show-error 'http://127.0.0.1:3192/plugins/@ethanyoq/dsh-invoice-downloader/client.js' > "$RUNNER_TEMP/invoice-client.js"; then + break + fi + sleep 1 + done + test -s "$RUNNER_TEMP/invoice-client.js" diff --git a/.gitignore b/.gitignore index 35d2e41..877fa26 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ tmp/ .codegraph/ .codex/ .runtime/ +.playwright-mcp/ dist/ build/work/ build/runtime/ diff --git a/README.en.md b/README.en.md index e972edf..9b81fcc 100644 --- a/README.en.md +++ b/README.en.md @@ -33,6 +33,21 @@ Download: [latest Windows installer / portable package and macOS (Apple Silicon) --- +## DeepSeek Harness Plugin + +`@ethanyoq/dsh-invoice-downloader` brings local IMAP invoice download, OCR, archiving, and Excel summaries to a right-side DeepSeek Harness panel. It is intended for reimbursement workflows that automate repetitive organizing while keeping a human review step. + +![Invoice Downloader in DeepSeek Harness: open the right-side panel, choose an output directory, check the local engine, and complete a redacted scan](https://github.com/EthanYoQ/Invoice-Downloader/blob/dsh-invoice-downloader-assets/invoice-downloader-dsh-demo-v1.gif?raw=true) + +```sh +dsh plugin --profile web add @ethanyoq/dsh-invoice-downloader +dsh web --profile web +``` + +Open **Invoice Downloader** from the right-side entry, choose an output directory, install the local engine once, and enter a QQ Mail or 163 Mail IMAP authorization code. Invoice files are OCRed locally; OCR text is sent to the currently selected DeepSeek model for field extraction. The plugin supports Windows x64 and macOS Apple Silicon. The DSH credential service stores authorization codes, never plugin settings or scan logs. + +--- + ## 🎬 Video Introduction https://github.com/user-attachments/assets/ae945367-35d3-4412-9fa0-c3bde80e2de5 diff --git a/README.md b/README.md index 7ab0410..e053c48 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,21 @@ --- +## DeepSeek Harness 插件 + +`@ethanyoq/dsh-invoice-downloader` 将本地 IMAP 发票下载、OCR、归档和 Excel 汇总带到 DeepSeek Harness 的右侧边栏。适用于希望把重复整理工作交给本地自动化、同时保留人工复核的报销场景。 + +![DeepSeek Harness 中的 Invoice Downloader:从右侧入口打开面板、选择保存位置、检查本地引擎并完成脱敏扫描](https://github.com/EthanYoQ/Invoice-Downloader/blob/dsh-invoice-downloader-assets/invoice-downloader-dsh-demo-v1.gif?raw=true) + +```powershell +dsh plugin --profile web add @ethanyoq/dsh-invoice-downloader +dsh web --profile web +``` + +打开右侧的“发票下载”入口,选择保存位置,安装一次本地引擎后填写 QQ 或 163 邮箱的 IMAP 授权码。发票文件在本地 OCR;OCR 文本发送给当前选择的 DeepSeek 模型提取字段。插件支持 Windows x64 和 macOS Apple Silicon,授权码由 DSH 凭据服务保存,不写入插件设置或扫描日志。 + +--- + ## 🎬 视频介绍 https://github.com/user-attachments/assets/ae945367-35d3-4412-9fa0-c3bde80e2de5 diff --git a/plugins/dsh-invoice-downloader/.gitignore b/plugins/dsh-invoice-downloader/.gitignore new file mode 100644 index 0000000..edc6a12 --- /dev/null +++ b/plugins/dsh-invoice-downloader/.gitignore @@ -0,0 +1,7 @@ +lib/ +node_modules/ +*.tgz +.runtime/ +runtime/.venv/ +runtime/engine/ +runtime/engine-* diff --git a/plugins/dsh-invoice-downloader/.npmignore b/plugins/dsh-invoice-downloader/.npmignore new file mode 100644 index 0000000..da5a31d --- /dev/null +++ b/plugins/dsh-invoice-downloader/.npmignore @@ -0,0 +1,5 @@ +.runtime/ +node_modules/ +*.tgz +runtime/.venv/ +runtime/engine/.venv/ diff --git a/plugins/dsh-invoice-downloader/LICENSE b/plugins/dsh-invoice-downloader/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/plugins/dsh-invoice-downloader/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/plugins/dsh-invoice-downloader/NOTICE b/plugins/dsh-invoice-downloader/NOTICE new file mode 100644 index 0000000..2e47b9b --- /dev/null +++ b/plugins/dsh-invoice-downloader/NOTICE @@ -0,0 +1,5 @@ +Project: Invoice Downloader +Copyright 2026 EthanYoQ / Yong Qi +Original repository: https://github.com/EthanYoQ/Invoice-Downloader + +This product includes software developed by EthanYoQ. diff --git a/plugins/dsh-invoice-downloader/README.md b/plugins/dsh-invoice-downloader/README.md new file mode 100644 index 0000000..7b7b99a --- /dev/null +++ b/plugins/dsh-invoice-downloader/README.md @@ -0,0 +1,48 @@ +# Invoice Downloader for DeepSeek Harness + +中文 | [English](#english) + +在 DeepSeek Harness 的右侧边栏中运行本地 IMAP 发票下载:读取 QQ 邮箱或 163 邮箱、下载发票附件、在本地 OCR、按规则归档并生成 Excel 汇总。 + +## 安装 + +```powershell +dsh plugin --profile web add @ethanyoq/dsh-invoice-downloader +``` + +启动 DSH Web,打开右侧的“发票下载”入口,先点击“安装本地引擎”,再选择保存位置并完成 IMAP 配置。运行时安装仅在本机创建 Python 虚拟环境;不会在安装包或插件设置中保存邮箱授权码或 API 密钥。 + +## 支持的平台 + +- Windows x64 +- macOS Apple Silicon + +首次运行需要可用的 Python 3.10+。插件将可变的本地运行时安装在自己的 DSH profile 状态目录中;安装包内的引擎资源保持只读,扫描结果只写入你选择的输出目录。 + +## 数据处理 + +默认链路在本地对发票文件进行 OCR,并将 OCR 文本提交给当前已选择的 DeepSeek 模型以提取字段。启用可选 GLM 路径时,图像会发送给 GLM 服务。邮箱授权码和 GLM 密钥由 DSH 凭据服务管理,不写入插件设置或扫描日志。 + +扫描只读访问 IMAP 邮箱。请在报销、入账、税务或合规使用前人工核验所有结果。 + +## 开发与打包 + +```powershell +npm install +npm test +npm pack +``` + +`npm pack` 会从仓库根目录的 Python 源码生成包内的只读引擎副本。构建产物不含发票、邮箱地址、凭据、输出目录、DSH profile 或缓存。 + +## English + +Run local IMAP invoice download, OCR, archive, and Excel summary workflows from a right-side DeepSeek Harness panel. Install the bundle with: + +```sh +dsh plugin --profile web add @ethanyoq/dsh-invoice-downloader +``` + +The package supports Windows x64 and macOS Apple Silicon. Use the panel to install its local Python runtime, choose an output directory, and configure IMAP. Credentials are stored through the DSH credential service rather than plugin settings. + +The default flow OCRs invoice files locally and sends OCR text to the selected DeepSeek model for field extraction. Optional GLM mode can send images to GLM. Review every invoice result before using it for reimbursement, accounting, tax, or compliance work. diff --git a/plugins/dsh-invoice-downloader/THIRD_PARTY_NOTICES.md b/plugins/dsh-invoice-downloader/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..ee06cda --- /dev/null +++ b/plugins/dsh-invoice-downloader/THIRD_PARTY_NOTICES.md @@ -0,0 +1,7 @@ +# Third-party notices + +This bundle is distributed under Apache-2.0 together with the source project notice in [NOTICE](NOTICE). + +At first-run installation, the local Python runtime installs the pinned dependencies named in `runtime/requirements-headless.txt`. Those projects retain their own licenses and notices. The runtime also uses the DeepSeek Harness APIs supplied by the host installation; DeepSeek Harness is not bundled into this package. + +No invoice, mailbox content, credential, DSH profile, browser profile, or generated scan output is included in the npm package. diff --git a/plugins/dsh-invoice-downloader/cordis.patch.yml b/plugins/dsh-invoice-downloader/cordis.patch.yml new file mode 100644 index 0000000..fb3def9 --- /dev/null +++ b/plugins/dsh-invoice-downloader/cordis.patch.yml @@ -0,0 +1,14 @@ +- insert: + - id: invoice-downloader + name: '@ethanyoq/dsh-invoice-downloader' + inject: ['subprocess', 'connection', 'sessions', 'llm', 'agentDefaultModel', 'credentials'] + config: + savePath: '' + runtimeRoot: '' + pythonPath: '' + engineRoot: '' + bootstrapPython: '' + deadlineMs: 600000 + graceMs: 2000 + imapFetchTimeoutSeconds: 15 + profileDir: '' diff --git a/plugins/dsh-invoice-downloader/engine-adapter/dsh_runner.py b/plugins/dsh-invoice-downloader/engine-adapter/dsh_runner.py new file mode 100644 index 0000000..5fc01b8 --- /dev/null +++ b/plugins/dsh-invoice-downloader/engine-adapter/dsh_runner.py @@ -0,0 +1,78 @@ +"""DSH-owned NDJSON entry point for the frozen Invoice Downloader engine.""" + +from __future__ import annotations + +import json +import sys +import uuid +from typing import Any + +from invoice_engine.ipc.protocol import ExtractionClient, IpcChannel + +from dsh_scan import run_scan + + +def run_engine(config_json: str) -> None: + """Build the DSH recognition chain and run one adapter-owned scan.""" + channel = IpcChannel() + job_id = "unknown" + try: + config: dict[str, Any] = json.loads(config_json) + job_id = str(config.get("jobId", uuid.uuid4())) + safe_config = { + key: value + for key, value in config.items() + if key not in ("authCode", "apiKey", "glmApiKey") + } + channel.send_job_event(job_id, "engine_started", {"config": safe_config}) + + email = str(config.get("email") or "") + auth_code = str(config.get("authCode") or "") + date_from = str(config.get("dateFrom") or "") + date_to = str(config.get("dateTo") or "") + save_path = str(config.get("savePath") or "") + if not email or not auth_code: + raise ValueError("email and authCode are required") + if not date_from or not date_to: + raise ValueError("dateFrom and dateTo are required") + if not save_path: + raise ValueError("savePath is required") + + from recognition_chain import RecognitionChain + from deepseek_extractor import DeepSeekExtractor + import local_ocr + from glm_fallback import create_glm_fallback + + extraction_client = ExtractionClient(channel, job_id) + extractor = DeepSeekExtractor(extraction_client=extraction_client) + glm_api_key = str(config.get("glmApiKey") or "") + chain = RecognitionChain( + extractor=extractor, + local_ocr_module=local_ocr, + glm_runtime=create_glm_fallback(api_key=glm_api_key) if glm_api_key else None, + ) + channel.send_job_event(job_id, "scan_started", { + "dateFrom": date_from, + "dateTo": date_to, + "company": str(config.get("company") or ""), + "ocrProvider": str(config.get("ocrProvider") or "local"), + }) + channel.send_job_event(job_id, "scan_progress", {"message": "Starting mailbox scan..."}) + result = run_scan(config=config, recognition_chain=chain) + channel.send_job_event(job_id, "scan_progress", { + "message": f"Scan completed: {result['invoicesProcessed']} invoices processed", + "invoicesProcessed": result["invoicesProcessed"], + }) + channel.send_job_result(job_id, result) + except Exception as exc: + channel.send_job_error(job_id, "ENGINE_ERROR", str(exc)) + finally: + channel.close() + + +if __name__ == "__main__": + first_line = sys.stdin.readline() + if not first_line: + print(json.dumps({"type": "job.error", "errorCode": "NO_CONFIG", "errorMessage": "no config provided on stdin"})) + raise SystemExit(1) + run_engine(first_line.strip()) diff --git a/plugins/dsh-invoice-downloader/engine-adapter/dsh_scan.py b/plugins/dsh-invoice-downloader/engine-adapter/dsh_scan.py new file mode 100644 index 0000000..6af67c1 --- /dev/null +++ b/plugins/dsh-invoice-downloader/engine-adapter/dsh_scan.py @@ -0,0 +1,292 @@ +"""DSH-owned bridge from the frozen invoice engine to its current coordinator.""" + +from __future__ import annotations + +import os +import socket +import threading +import uuid +from datetime import date, timedelta +from pathlib import Path +from typing import Any + +from invoice_extractor import InvoiceExtractor + + +class ImapFetchTimeout(RuntimeError): + """Report a bounded IMAP read that cannot safely provide a complete mailbox scope.""" + + reason_code = "IMAP_FETCH_TIMEOUT" + user_message = "邮箱读取超时,本次扫描已停止;请稍后重试。" + + def __init__(self, message: str) -> None: + super().__init__(f"{self.reason_code}: {message}") + + +def _positive_timeout(config: dict[str, Any]) -> float: + """Read the DSH-owned IMAP read limit from the IPC job configuration.""" + try: + seconds = float(config["imapFetchTimeoutSeconds"]) + except (KeyError, TypeError, ValueError) as exc: + raise ValueError("imapFetchTimeoutSeconds must be a positive number") from exc + if seconds <= 0: + raise ValueError("imapFetchTimeoutSeconds must be a positive number") + return seconds + + +def _server_search_window(date_from: str, date_to: str) -> tuple[str, str]: + """Return an IMAP date window that keeps a one-day margin for local date filtering.""" + first_day = date.fromisoformat(date_from) + last_day = date.fromisoformat(date_to) + if last_day < first_day: + raise ValueError("dateTo must not be earlier than dateFrom") + return ( + (first_day - timedelta(days=1)).strftime("%d-%b-%Y"), + (last_day + timedelta(days=2)).strftime("%d-%b-%Y"), + ) + + +def _bound_imap_reads( + fetcher: Any, + timeout_seconds: float, + date_from: str, + date_to: str, +) -> dict[str, bool]: + """Bound IMAP reads and replace all-mail search with a server-narrowed candidate window.""" + mail = getattr(fetcher, "mail", None) + socket_handle = getattr(mail, "sock", None) + set_timeout = getattr(socket_handle, "settimeout", None) + uid = getattr(mail, "uid", None) + if not callable(set_timeout) or not callable(uid): + raise ImapFetchTimeout("IMAP read timeout cannot be configured") + try: + set_timeout(timeout_seconds) + except OSError as exc: + raise ImapFetchTimeout("IMAP read timeout cannot be configured") from exc + + state = {"timed_out": False} + search_start, search_end = _server_search_window(date_from, date_to) + + def close_on_deadline() -> None: + state["timed_out"] = True + shutdown = getattr(socket_handle, "shutdown", None) + if callable(shutdown): + try: + shutdown(socket.SHUT_RDWR) + except OSError: + pass + close = getattr(socket_handle, "close", None) + if callable(close): + try: + close() + except OSError: + pass + + def bounded_uid(*args: Any, **kwargs: Any) -> Any: + if state["timed_out"]: + raise ImapFetchTimeout("IMAP read timed out") + if args == ("SEARCH", None, "ALL"): + args = ("SEARCH", None, "SINCE", search_start, "BEFORE", search_end) + deadline = threading.Timer(timeout_seconds, close_on_deadline) + deadline.daemon = True + deadline.start() + try: + return uid(*args, **kwargs) + except (socket.timeout, TimeoutError) as exc: + state["timed_out"] = True + raise ImapFetchTimeout("IMAP read timed out") from exc + except OSError as exc: + if state["timed_out"]: + raise ImapFetchTimeout("IMAP read timed out") from exc + raise + finally: + deadline.cancel() + + mail.uid = bounded_uid + return state + + +class DshRecognitionExtractor(InvoiceExtractor): + """Present DSH's recognition chain through the engine's extraction and archive interface.""" + + def __init__(self, recognition_chain: Any, output_dir: str) -> None: + self._recognition_chain = recognition_chain + self.output_dir = os.path.abspath(output_dir) + os.makedirs(self.output_dir, exist_ok=True) + self.processed_records_file = os.path.join(self.output_dir, "processed_records.json") + self.glm_runtime = None + self.last_extraction_trace: dict[str, Any] = {} + self.last_route_trace: dict[str, Any] = {} + self.last_timing_trace: dict[str, Any] = {} + + def pdf_to_base64_image(self, pdf_path: str) -> list[str]: + """Supply a truthy placeholder because the DSH chain reads the source path directly.""" + return [pdf_path] if os.path.isfile(pdf_path) else [] + + def extract_remote_only( + self, + _prepared_source: Any, + custom_rules: str = "", + pdf_path: str | None = None, + document_context: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Run local OCR followed by the DSH DeepSeek extractor for one artifact.""" + del custom_rules + if not pdf_path: + raise RuntimeError("document path is required for DSH extraction") + result = self._recognition_chain.extract(pdf_path, document_context or {}) + self.last_extraction_trace = { + "engine": "local_ocr_dsh_deepseek", + "reason_code": "", + } + self.last_timing_trace = {} + return result + + def close(self) -> None: + """Match the engine extractor lifecycle without owning a GLM runtime.""" + return None + + +def _run_processing_loop( + app: Any, + recognition_chain: Any, + attachments: list[dict[str, Any]], + save_path: str, + date_from: str, + date_to: str, +) -> Any: + """Reuse the engine's candidate, archive, and reporting implementation with DSH extraction.""" + owner = DshRecognitionExtractor(recognition_chain, save_path) + return app._run_processing_loop_with_extractor( + attachments, + "dsh-managed", + save_path, + date_from, + date_to, + _extractor=owner, + _worker_extractor_factory=lambda _runtime: DshRecognitionExtractor( + recognition_chain, save_path + ), + ) + + +def run_scan(*, config: dict[str, Any], recognition_chain: Any) -> dict[str, int | str]: + """Execute one DSH scan with the current engine coordinator.""" + from app_api import InvoiceAppAPI + from run_coordinator import RunCoordinator, RunRequest + from run_lifecycle import RunLifecycle, RunState + + job_id = str(config["jobId"]) + email = str(config["email"]) + auth_code = str(config["authCode"]) + date_from = str(config["dateFrom"]) + date_to = str(config["dateTo"]) + company = str(config.get("company") or "") + save_path = str(Path(str(config["savePath"])).resolve()) + imap_fetch_timeout_seconds = _positive_timeout(config) + staging_dir = Path(save_path) / ".dsh-staging" / f"{job_id}-{uuid.uuid4().hex}" + + app = InvoiceAppAPI() + lifecycle = RunLifecycle() + handle = lifecycle.begin(job_id, staging_dir) + app._run_lifecycle = lifecycle + app._active_run_handle = handle + app._run_context = { + "enabled": False, + "run_id": job_id, + "run_root": save_path, + "staging_dir": str(staging_dir), + } + app._current_run_id = job_id + app._requested_save_path = save_path + app._effective_save_path = save_path + app._effective_date_from = date_from + app._effective_date_to = date_to + app._active_run_config = { + "company": company, + "save_path": save_path, + "date_from": date_from, + "date_to": date_to, + } + + request = RunRequest( + run_id=job_id, + date_from=date_from, + date_to=date_to, + save_path=save_path, + rules_text="", + account_id="dsh", + channel_id="dsh-ipc", + before_exclusive=date_to, + target_identifier=company, + run_root=save_path, + ) + + def processing_loop( + attachments: list[dict[str, Any]], + _legacy_api_key: str, + output_dir: str, + since_date: str, + before_date: str, + _rules_text: str, + ) -> Any: + return _run_processing_loop( + app, + recognition_chain, + attachments, + output_dir, + since_date, + before_date, + ) + + app._run_processing_loop = processing_loop + dependencies = app._build_run_dependencies( + request, + email_address=email, + auth_code=auth_code, + api_key="dsh-managed", + ) + connect = dependencies.connect + scan = dependencies.scan + imap_state: dict[str, bool] | None = None + + def bounded_connect(run_request: RunRequest) -> Any: + nonlocal imap_state + fetcher = connect(run_request) + imap_state = _bound_imap_reads( + fetcher, + imap_fetch_timeout_seconds, + date_from, + date_to, + ) + return fetcher + + def bounded_scan(fetcher: Any, run_request: RunRequest) -> Any: + result = scan(fetcher, run_request) + if imap_state is not None and imap_state["timed_out"]: + raise ImapFetchTimeout("IMAP read timed out") + return result + + dependencies.connect = bounded_connect + dependencies.scan = bounded_scan + result = RunCoordinator(lifecycle, app._run_state_store, dependencies).run( + request, handle=handle + ) + app._active_run_handle = None + if result.state is not RunState.COMPLETED: + reason_code = str(result.reason_code or "PROCESSING_FAILED") + raise RuntimeError(f"invoice scan failed: {reason_code}") + + report = result.archive_report + completed = { + "status": "completed", + "invoicesProcessed": int(result.outcome_count), + "successCount": int(getattr(report, "archived_count", 0)), + "retainedCount": int(getattr(report, "retained_count", 0)), + "manualReviewCount": int(getattr(report, "manual_count", 0)), + } + export_result = app.export_run_summary(save_path) + export_path = export_result.get("path") + if export_result.get("success") and isinstance(export_path, str) and Path(export_path).is_file(): + completed["exportPath"] = export_path + return completed diff --git a/plugins/dsh-invoice-downloader/engine-adapter/engine-src/deepseek_extractor.py b/plugins/dsh-invoice-downloader/engine-adapter/engine-src/deepseek_extractor.py new file mode 100644 index 0000000..2376c34 --- /dev/null +++ b/plugins/dsh-invoice-downloader/engine-adapter/engine-src/deepseek_extractor.py @@ -0,0 +1,196 @@ +""" +DeepSeek text-extraction engine for invoice field extraction. + +Consumes OCR text (from local_ocr.py or GLM-OCR) and produces structured invoice fields +via the DSH LLM (DeepSeek). The output is adapted to the existing _adapt_result path +through InvoiceRecord.from_legacy(). +""" + +import json +import logging +import os +from typing import Optional + +logger = logging.getLogger(__name__) + +# System prompt for DeepSeek extraction +EXTRACTION_SYSTEM_PROMPT = """You are an invoice field extractor for Chinese invoices. Extract the following fields from OCR text and return ONLY valid JSON, no markdown, no explanation: + +Required fields: +- is_invoice: boolean (true if this is an invoice, false otherwise) +- Date: string, invoice date in YYYY-MM-DD format +- Purchaser: string, buyer company name in original Chinese +- Seller: string, seller company name in original Chinese +- Amount: string, total amount including tax as a decimal number string (e.g. "9.10") +- InvoiceNumber: string, invoice number +- InvoiceCode: string, invoice code (may be empty) +- Type: string, one of: 餐饮, 火车票, 打车, 住宿发票, 住宿水单, 机票, 行程单, 差旅服务费, 其他, 非目标公司发票 + +Optional fields (include only if clearly present): +- Departure_Date: string, departure date in YYYY-MM-DD format (for train/flight tickets) +- Departure_City: string, departure city (for train/flight tickets) +- Destination_City: string, destination city (for train/flight tickets) + +Rules: +- Preserve original Chinese names for Purchaser and Seller — do NOT translate +- Amount must be the total including tax (价税合计) +- Date must be the invoice issue date (开票日期), not departure/travel date +- If a field is not found, use empty string "" +- Return ONLY the JSON object, no other text""" + +# JSON Schema for validation +EXTRACTION_SCHEMA = { + "type": "object", + "required": ["is_invoice", "Date", "Purchaser", "Seller", "Amount", "InvoiceNumber", "Type"], + "properties": { + "is_invoice": {"type": "boolean"}, + "Date": {"type": "string", "pattern": r"^\d{4}-\d{2}-\d{2}$"}, + "Purchaser": {"type": "string"}, + "Seller": {"type": "string"}, + "Amount": {"type": "string"}, + "InvoiceNumber": {"type": "string"}, + "InvoiceCode": {"type": "string"}, + "Type": {"type": "string", "enum": ["餐饮", "火车票", "打车", "住宿发票", "住宿水单", "机票", "行程单", "差旅服务费", "其他", "非目标公司发票"]}, + "Departure_Date": {"type": "string"}, + "Departure_City": {"type": "string"}, + "Destination_City": {"type": "string"}, + }, +} + +# Config field for retry policy (can be overridden via cordis.yml config) +DEFAULT_MAX_RETRIES = 2 + + +def validate_extraction(payload: dict) -> list[str]: + """Validate extraction output against schema. Returns list of errors (empty if valid).""" + errors = [] + if not isinstance(payload, dict): + return ["payload is not a dict"] + + for field_name in EXTRACTION_SCHEMA["required"]: + if field_name not in payload: + errors.append(f"missing required field: {field_name}") + + if "Date" in payload and payload["Date"]: + import re + if not re.match(r"^\d{4}-\d{2}-\d{2}$", payload["Date"]): + errors.append(f"Date format invalid: {payload['Date']}") + + if "Type" in payload and payload["Type"]: + valid_types = EXTRACTION_SCHEMA["properties"]["Type"]["enum"] + if payload["Type"] not in valid_types: + errors.append(f"Type not in enum: {payload['Type']}") + + return errors + + +def build_extraction_prompt(ocr_text: str, document_type_hint: str = None) -> str: + """Build the user prompt for DeepSeek extraction.""" + prompt = f"Extract invoice fields from this OCR text:\n\n{ocr_text}" + if document_type_hint: + prompt += f"\n\nDocument type hint: {document_type_hint}" + return prompt + + +def parse_extraction_response(response_text: str) -> dict: + """Parse DeepSeek response text into a dict. Handles markdown-wrapped JSON.""" + text = response_text.strip() + # Remove markdown code block if present + if text.startswith("```"): + lines = text.split("\n") + # Remove first and last lines (```json and ```) + if lines[0].startswith("```"): + lines = lines[1:] + if lines and lines[-1].strip() == "```": + lines = lines[:-1] + text = "\n".join(lines) + return json.loads(text) + + +class DeepSeekExtractor: + """ + DeepSeek-based invoice field extractor. + + Uses the DSH LLM (via IPC extraction.request) to extract structured fields + from OCR text. The output is validated against EXTRACTION_SCHEMA and + adapted to the legacy _adapt_result path. + """ + + def __init__(self, extraction_client=None): + """ + Args: + extraction_client: ExtractionClient from ipc.protocol for DSH LLM calls. + If None, extraction requests will fail. + """ + self._client = extraction_client + + def extract(self, ocr_text: str, document_type_hint: str = None) -> dict: + """ + Extract invoice fields from OCR text. + + Returns a dict matching the legacy _adapt_result shape: + {is_invoice, Date, Purchaser, Seller, Amount, InvoiceNumber, InvoiceCode, Type, ...} + + Raises RuntimeError if extraction fails after retries. + """ + if not self._client: + raise RuntimeError("no extraction client configured") + + prompt = build_extraction_prompt(ocr_text, document_type_hint) + last_errors = [] + + for attempt in range(DEFAULT_MAX_RETRIES + 1): + try: + # Send extraction request via IPC + result = self._client.extract(prompt, document_type_hint) + + # Parse if result is a string (raw model output) + if isinstance(result, str): + result = parse_extraction_response(result) + + # Validate + errors = validate_extraction(result) + if not errors: + logger.info(f"extraction succeeded on attempt {attempt + 1}") + return result + + last_errors = errors + logger.warning(f"extraction validation failed on attempt {attempt + 1}: {errors}") + + # On retry, include validation errors in prompt + if attempt < DEFAULT_MAX_RETRIES: + prompt = build_extraction_prompt(ocr_text, document_type_hint) + prompt += f"\n\nPrevious attempt had errors: {'; '.join(errors)}. Please fix and return valid JSON." + + except json.JSONDecodeError as e: + last_errors = [f"JSON parse error: {e}"] + logger.warning(f"extraction JSON parse failed on attempt {attempt + 1}: {e}") + except Exception as e: + last_errors = [f"extraction error: {e}"] + logger.warning(f"extraction failed on attempt {attempt + 1}: {e}") + + raise RuntimeError(f"extraction failed after {DEFAULT_MAX_RETRIES + 1} attempts: {'; '.join(last_errors)}") + + def extract_and_adapt(self, ocr_text: str, pdf_path: str = None, document_context: dict = None) -> dict: + """ + Extract fields and adapt through the existing _adapt_result path. + + This is the main entry point that matches the existing engine's interface. + """ + result = self.extract(ocr_text, document_type_hint=document_context.get("type_hint") if document_context else None) + + # Import here to avoid circular dependency + from invoice_domain import DocumentIdentity, InvoiceRecord + + context = document_context or {} + source_locator = os.path.abspath(pdf_path) if pdf_path else "" + source_filename = str(context.get("original_filename") or (os.path.basename(pdf_path) if pdf_path else "")) + identity = DocumentIdentity( + document_id=str(context.get("document_id") or source_locator or source_filename or "deepseek-extraction"), + source_message_uid=str(context.get("email_id") or context.get("source_email_id") or ""), + source_filename=source_filename, + source_locator=source_locator, + source_kind=str(context.get("source_kind") or "deepseek_extraction"), + provider_group_key=str(context.get("provider_group_key") or ""), + ) + return InvoiceRecord.from_legacy(result, identity).to_legacy() diff --git a/plugins/dsh-invoice-downloader/engine-adapter/engine-src/glm_fallback.py b/plugins/dsh-invoice-downloader/engine-adapter/engine-src/glm_fallback.py new file mode 100644 index 0000000..f7b18b6 --- /dev/null +++ b/plugins/dsh-invoice-downloader/engine-adapter/engine-src/glm_fallback.py @@ -0,0 +1,114 @@ +""" +GLM vision fallback integration. + +Wires the existing glm_runtime.py into the RecognitionChain as an optional +vision fallback when the user supplies a GLM API key. +""" + +import logging +import os +from typing import Optional + +logger = logging.getLogger(__name__) + + +class GlmFallbackExtractor: + """ + GLM vision fallback extractor. + + Uses the existing GlmRuntime and extract_info_via_llm from the vendored engine. + Only active when the user supplies a GLM API key. + """ + + def __init__(self, api_key: str, settings: dict = None): + """ + Args: + api_key: GLM API key (from credential store via T7). + settings: Optional GLM settings dict. + """ + self._api_key = api_key + self._settings = settings or {} + self._runtime = None + self._extractor = None + + def _get_runtime(self): + """Lazy-initialize GLM runtime.""" + if self._runtime is None: + from glm_runtime import GlmRuntime + self._runtime = GlmRuntime(self._api_key, settings=self._settings) + return self._runtime + + def _get_extractor(self): + """Lazy-initialize the GLM extractor (InvoiceExtractor with GLM runtime).""" + if self._extractor is None: + from invoice_extractor import InvoiceExtractor + runtime = self._get_runtime() + self._extractor = InvoiceExtractor( + api_key=self._api_key, + glm_runtime=runtime, + close_glm_runtime=False, + ) + return self._extractor + + def extract(self, pdf_path: str, document_context: dict = None) -> dict: + """ + Extract invoice fields using GLM vision (Track B) or GLM OCR + LLM (Track A). + + Uses the existing dual-track logic from the vendored engine. + Returns the extraction result dict matching the legacy _adapt_result shape. + """ + extractor = self._get_extractor() + + # Convert PDF to base64 images (existing engine method) + base64_images = extractor.pdf_to_base64_image(pdf_path) + if not base64_images: + raise RuntimeError(f"failed to convert PDF to images: {pdf_path}") + + # Call the existing dual-track extraction + result = extractor.extract_info_via_llm( + base64_images, + pdf_path=pdf_path, + document_context=document_context, + ) + return result + + def close(self): + """Close the GLM runtime.""" + if self._runtime: + try: + self._runtime.close() + except Exception: + pass + self._runtime = None + self._extractor = None + + +def create_glm_fallback(api_key: str = None, credential_store = None) -> Optional[GlmFallbackExtractor]: + """ + Create a GLM fallback extractor if a GLM API key is available. + + Args: + api_key: Direct GLM API key (takes precedence). + credential_store: CredentialStore from T7 to retrieve glmApiKeyRef. + + Returns: + GlmFallbackExtractor if key available, None otherwise. + """ + key = api_key + if not key and credential_store: + # Try to retrieve from credential store + import asyncio + try: + # Check for glm-api-key reference + key = asyncio.get_event_loop().run_until_complete( + credential_store.retrieve('glm-api-key') + ) + except Exception: + pass + + if not key: + logger.info("No GLM API key available — GLM fallback disabled") + return None + + logger.info("GLM fallback enabled") + return GlmFallbackExtractor(api_key=key) diff --git a/plugins/dsh-invoice-downloader/engine-adapter/engine-src/local_ocr.py b/plugins/dsh-invoice-downloader/engine-adapter/engine-src/local_ocr.py new file mode 100644 index 0000000..8f14361 --- /dev/null +++ b/plugins/dsh-invoice-downloader/engine-adapter/engine-src/local_ocr.py @@ -0,0 +1,95 @@ +""" +Local OCR adapter using RapidOCR (ONNX Runtime with PP-OCRv4 models). + +This adapter produces raw text from invoice images/PDFs for consumption by the DSH LLM extractor. +No field understanding — text output only. +""" + +import logging +import os +from typing import Optional + +logger = logging.getLogger(__name__) + +# Lazy import to avoid loading ONNX runtime at module import time +_ocr_instance = None + + +def _get_ocr(): + """Lazy-initialize RapidOCR instance.""" + global _ocr_instance + if _ocr_instance is None: + try: + from rapidocr_onnxruntime import RapidOCR + _ocr_instance = RapidOCR() + logger.info("RapidOCR initialized successfully") + except ImportError: + logger.error("rapidocr-onnxruntime not installed") + raise RuntimeError("rapidocr-onnxruntime is required for local OCR but not installed") + return _ocr_instance + + +# Config fields for OCR quality knobs (can be overridden via cordis.yml config) +DEFAULT_MAX_PAGES = 3 +DEFAULT_DPI = 300 + + +def ocr_pdf(pdf_path: str, max_pages: int = None, dpi: int = None) -> str: + """Extract text from a PDF invoice using local OCR.""" + max_pages = max_pages or DEFAULT_MAX_PAGES + dpi = dpi or DEFAULT_DPI + + if not os.path.exists(pdf_path): + raise FileNotFoundError(f"PDF not found: {pdf_path}") + + import fitz # PyMuPDF + + ocr = _get_ocr() + texts = [] + + doc = fitz.open(pdf_path) + try: + num_pages = min(len(doc), max_pages) + for page_num in range(num_pages): + page = doc[page_num] + pix = page.get_pixmap(dpi=dpi) + img_bytes = pix.tobytes("png") + result, elapse = ocr(img_bytes) + if result: + page_text = "\n".join([line[1] for line in result]) + texts.append(page_text) + logger.debug(f"OCR page {page_num + 1}: {len(result)} lines, elapse={elapse}") + finally: + doc.close() + + return "\n\n".join(texts) + + +def ocr_image(image_path: str) -> str: + """Extract text from an image file using local OCR.""" + if not os.path.exists(image_path): + raise FileNotFoundError(f"Image not found: {image_path}") + + ocr = _get_ocr() + result, elapse = ocr(image_path) + if not result: + return "" + return "\n".join([line[1] for line in result]) + + +def probe_local_ocr(pdf_path: str) -> dict: + """Probe local OCR on a PDF and return metadata about the extraction.""" + try: + text = ocr_pdf(pdf_path, max_pages=2) + return { + "status": "ok", + "text_length": len(text), + "line_count": text.count("\n") + 1 if text else 0, + "text": text, + } + except Exception as e: + return { + "status": "error", + "error": str(e), + "text": "", + } diff --git a/plugins/dsh-invoice-downloader/engine-adapter/engine-src/recognition_chain.py b/plugins/dsh-invoice-downloader/engine-adapter/engine-src/recognition_chain.py new file mode 100644 index 0000000..4931389 --- /dev/null +++ b/plugins/dsh-invoice-downloader/engine-adapter/engine-src/recognition_chain.py @@ -0,0 +1,77 @@ +""" +Recognition chain arbitration. + +Precedence: deterministic parser > local OCR + DeepSeek > optional GLM. +""" + +import logging +import os +from typing import Optional + +logger = logging.getLogger(__name__) + + +class RecognitionChain: + """ + Orchestrates the recognition chain for invoice extraction. + + Chain order: + 1. Deterministic parser (existing probe_local_only) + 2. Local OCR + DeepSeek text extraction (new default) + 3. GLM vision fallback (optional, user-keyed) + """ + + def __init__(self, extractor, local_ocr_module=None, glm_runtime=None): + """ + Args: + extractor: DeepSeekExtractor instance + local_ocr_module: local_ocr module (for probe_local_ocr) + glm_runtime: GlmRuntime instance (optional, for GLM fallback) + """ + self._extractor = extractor + self._local_ocr = local_ocr_module + self._glm = glm_runtime + + def extract(self, pdf_path: str, document_context: dict = None) -> dict: + """ + Run the recognition chain on a PDF invoice. + + Returns the extraction result dict matching the legacy _adapt_result shape. + Raises RuntimeError if all chains fail. + """ + context = document_context or {} + + # Chain 1: Deterministic parser (existing, not modified) + # This is handled by the existing engine code before calling this chain. + # If we reach here, deterministic parsing already failed. + + # Chain 2: Local OCR + DeepSeek + try: + logger.info("Trying local OCR + DeepSeek extraction") + ocr_result = self._local_ocr.probe_local_ocr(pdf_path) + if ocr_result["status"] == "ok" and ocr_result["text_length"] > 50: + result = self._extractor.extract_and_adapt( + ocr_result["text"], + pdf_path=pdf_path, + document_context=context, + ) + logger.info("Local OCR + DeepSeek extraction succeeded") + return result + else: + logger.warning(f"Local OCR probe insufficient: {ocr_result}") + except Exception as e: + logger.warning(f"Local OCR + DeepSeek extraction failed: {e}") + + # Chain 3: GLM vision fallback (optional) + if self._glm: + try: + logger.info("Falling back to GLM vision extraction") + # Use existing GLM dual-track logic + # This is a simplified call — the full GLM path is preserved in the existing engine + result = self._glm.extract(pdf_path, document_context=context) + logger.info("GLM vision extraction succeeded") + return result + except Exception as e: + logger.warning(f"GLM vision extraction failed: {e}") + + raise RuntimeError("all recognition chains failed") diff --git a/plugins/dsh-invoice-downloader/engine-adapter/ipc/protocol.py b/plugins/dsh-invoice-downloader/engine-adapter/ipc/protocol.py new file mode 100644 index 0000000..2ecbd13 --- /dev/null +++ b/plugins/dsh-invoice-downloader/engine-adapter/ipc/protocol.py @@ -0,0 +1,305 @@ +""" +Invoice Engine IPC Protocol v1 — engine entry point. + +stdin NDJSON protocol for DSH plugin <-> Python engine communication. +""" + +import json +import sys +import threading +import queue +import uuid +import time +import os +from dataclasses import dataclass, field +from typing import Optional, Callable, Any +from enum import Enum + +PROTOCOL_VERSION = 1 + +class FrameType(str, Enum): + JOB_START = "job.start" + JOB_EVENT = "job.event" + JOB_RESULT = "job.result" + JOB_ERROR = "job.error" + EXTRACTION_REQUEST = "extraction.request" + EXTRACTION_RESPONSE = "extraction.response" + EXTRACTION_ERROR = "extraction.error" + + +@dataclass +class Frame: + type: str + protocol_version: int = PROTOCOL_VERSION + job_id: str = "" + timestamp: float = field(default_factory=time.time) + payload: dict = field(default_factory=dict) + + def to_json(self) -> str: + return json.dumps({ + "type": self.type, + "protocolVersion": self.protocol_version, + "jobId": self.job_id, + "timestamp": self.timestamp, + **self.payload, + }, ensure_ascii=False) + + @classmethod + def from_json(cls, line: str) -> "Frame": + data = json.loads(line.strip()) + frame_type = data.pop("type") + protocol_version = data.pop("protocolVersion", 0) + job_id = data.pop("jobId", "") + timestamp = data.pop("timestamp", 0.0) + if protocol_version > PROTOCOL_VERSION: + raise ValueError(f"unsupported protocol version: {protocol_version}") + return cls(type=frame_type, protocol_version=protocol_version, job_id=job_id, timestamp=timestamp, payload=data) + + +@dataclass +class ExtractionRequest: + request_id: str + ocr_text: str + document_type_hint: Optional[str] = None + model_preference: Optional[str] = None + + def to_frame(self, job_id: str) -> Frame: + return Frame( + type=FrameType.EXTRACTION_REQUEST, + job_id=job_id, + payload={ + "requestId": self.request_id, + "ocrText": self.ocr_text, + "documentTypeHint": self.document_type_hint, + "modelPreference": self.model_preference, + }, + ) + + +@dataclass +class ExtractionResponse: + request_id: str + extracted_fields: dict + confidence_scores: Optional[dict] = None + raw_model_output: Optional[str] = None + + @classmethod + def from_frame(cls, frame: Frame) -> "ExtractionResponse": + return cls( + request_id=frame.payload.get("requestId", ""), + extracted_fields=frame.payload.get("extractedFields", {}), + confidence_scores=frame.payload.get("confidenceScores"), + raw_model_output=frame.payload.get("rawModelOutput"), + ) + + +@dataclass +class ExtractionError: + request_id: str + error_code: str + error_message: str + retryable: bool = False + + @classmethod + def from_frame(cls, frame: Frame) -> "ExtractionError": + return cls( + request_id=frame.payload.get("requestId", ""), + error_code=frame.payload.get("errorCode", "UNKNOWN"), + error_message=frame.payload.get("errorMessage", ""), + retryable=frame.payload.get("retryable", False), + ) + + +class IpcChannel: + """Bidirectional NDJSON channel over stdin/stdout.""" + + def __init__(self, stdin=None, stdout=None): + self._stdin = stdin or sys.stdin + self._stdout = stdout or sys.stdout + self._lock = threading.Lock() + self._closed = False + + def send(self, frame: Frame) -> None: + with self._lock: + if self._closed: + raise RuntimeError("channel is closed") + self._stdout.write(frame.to_json() + "\n") + self._stdout.flush() + + def send_job_event(self, job_id: str, event: str, data: dict = None) -> None: + self.send(Frame( + type=FrameType.JOB_EVENT, + job_id=job_id, + payload={"event": event, **(data or {})}, + )) + + def send_job_result(self, job_id: str, result: dict) -> None: + self.send(Frame( + type=FrameType.JOB_RESULT, + job_id=job_id, + payload={"result": result}, + )) + + def send_job_error(self, job_id: str, error_code: str, error_message: str) -> None: + self.send(Frame( + type=FrameType.JOB_ERROR, + job_id=job_id, + payload={"errorCode": error_code, "errorMessage": error_message}, + )) + + def send_extraction_request(self, job_id: str, request: ExtractionRequest) -> str: + self.send(request.to_frame(job_id)) + return request.request_id + + def read_frame(self, timeout: Optional[float] = None) -> Optional[Frame]: + """Read one frame from stdin. Returns None on EOF.""" + line = self._stdin.readline() + if not line: + return None + return Frame.from_json(line) + + def close(self) -> None: + with self._lock: + self._closed = True + + +class ExtractionClient: + """Client for requesting LLM extraction from DSH.""" + + def __init__(self, channel: IpcChannel, job_id: str): + self._channel = channel + self._job_id = job_id + + def extract(self, ocr_text: str, document_type_hint: str = None, timeout: float = 120.0) -> dict: + """Send extraction request and wait for response. Returns extracted fields.""" + request = ExtractionRequest( + request_id=str(uuid.uuid4()), + ocr_text=ocr_text, + document_type_hint=document_type_hint, + ) + self._channel.send_extraction_request(self._job_id, request) + + deadline = time.time() + timeout + while time.time() < deadline: + frame = self._channel.read_frame(timeout=deadline - time.time()) + if frame is None: + raise TimeoutError("extraction response timeout") + if frame.type == FrameType.EXTRACTION_RESPONSE: + resp = ExtractionResponse.from_frame(frame) + if resp.request_id == request.request_id: + return resp.extracted_fields + elif frame.type == FrameType.EXTRACTION_ERROR: + err = ExtractionError.from_frame(frame) + if err.request_id == request.request_id: + raise RuntimeError(f"extraction failed: {err.error_code}: {err.error_message}") + + raise TimeoutError("extraction response timeout") + + +def run_engine(config_json: str) -> None: + """Main entry point for the engine subprocess.""" + channel = IpcChannel() + job_id = "unknown" + try: + config = json.loads(config_json) + job_id = config.get("jobId", str(uuid.uuid4())) + + safe_config = {k: v for k, v in config.items() if k not in ("authCode", "apiKey", "glmApiKey")} + channel.send_job_event(job_id, "engine_started", {"config": safe_config}) + + email = config.get("email", "") + auth_code = config.get("authCode", "") + date_from = config.get("dateFrom", "") + date_to = config.get("dateTo", "") + company = config.get("company", "") + save_path = config.get("savePath", "") + ocr_provider = config.get("ocrProvider", "local") + glm_api_key = config.get("glmApiKey", "") + + if not email or not auth_code: + raise ValueError("email and authCode are required") + if not date_from or not date_to: + raise ValueError("dateFrom and dateTo are required") + if not save_path: + raise ValueError("savePath is required") + + extraction_client = ExtractionClient(channel, job_id) + + from recognition_chain import RecognitionChain + from deepseek_extractor import DeepSeekExtractor + import local_ocr + from glm_fallback import create_glm_fallback + + extractor = DeepSeekExtractor(extraction_client=extraction_client) + glm_fallback = create_glm_fallback(api_key=glm_api_key) if glm_api_key else None + chain = RecognitionChain( + extractor=extractor, + local_ocr_module=local_ocr, + glm_runtime=glm_fallback, + ) + + channel.send_job_event(job_id, "scan_started", { + "dateFrom": date_from, + "dateTo": date_to, + "company": company, + "ocrProvider": ocr_provider, + }) + + invoices_processed = 0 + success_count = 0 + retained_count = 0 + manual_review_count = 0 + + from mailbox_scanner import MailboxScanner + from email_fetcher import EmailFetcher + from invoice_extractor import InvoiceExtractor + from run_coordinator import RunCoordinator + from user_settings import UserSettings + + settings = UserSettings() + settings.email = email + settings.auth_code = auth_code + settings.date_from = date_from + settings.date_to = date_to + settings.company = company + settings.save_path = save_path + + coordinator = RunCoordinator(settings) + + channel.send_job_event(job_id, "scan_progress", {"message": "Starting mailbox scan..."}) + + try: + result = coordinator.run() + invoices_processed = result.get("invoices_processed", 0) + success_count = result.get("success_count", 0) + retained_count = result.get("retained_count", 0) + manual_review_count = result.get("manual_review_count", 0) + + channel.send_job_event(job_id, "scan_progress", { + "message": f"Scan completed: {invoices_processed} invoices processed", + "invoicesProcessed": invoices_processed, + }) + except Exception as scan_err: + channel.send_job_event(job_id, "scan_error", {"message": str(scan_err)}) + raise + + channel.send_job_result(job_id, { + "status": "completed", + "invoicesProcessed": invoices_processed, + "successCount": success_count, + "retainedCount": retained_count, + "manualReviewCount": manual_review_count, + }) + + except Exception as e: + channel.send_job_error(job_id, "ENGINE_ERROR", str(e)) + finally: + channel.close() + + +if __name__ == "__main__": + first_line = sys.stdin.readline() + if not first_line: + print(json.dumps({"type": "job.error", "errorCode": "NO_CONFIG", "errorMessage": "no config provided on stdin"}), file=sys.stdout) + sys.exit(1) + run_engine(first_line.strip()) diff --git a/plugins/dsh-invoice-downloader/package-lock.json b/plugins/dsh-invoice-downloader/package-lock.json new file mode 100644 index 0000000..e729f9f --- /dev/null +++ b/plugins/dsh-invoice-downloader/package-lock.json @@ -0,0 +1,242 @@ +{ + "name": "@ethanyoq/dsh-invoice-downloader", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@ethanyoq/dsh-invoice-downloader", + "version": "0.1.0", + "cpu": [ + "x64", + "arm64" + ], + "license": "Apache-2.0", + "os": [ + "win32", + "darwin" + ], + "devDependencies": { + "@types/node": "^22.20.0", + "typescript": "^5.9.3" + }, + "engines": { + "node": "^22.19.0 || >=24", + "npm": ">=10" + }, + "peerDependencies": { + "@deepseek-ai/cordis": ">=4.0.1", + "@deepseek-ai/dsh-credentials": ">=0.1.0-rc.5", + "@deepseek-ai/dsh-llm": ">=0.1.0-rc.5", + "@deepseek-ai/dsh-session": ">=0.1.0-rc.5", + "@deepseek-ai/dsh-subprocess": ">=0.1.0-rc.5" + } + }, + "node_modules/@deepseek-ai/cordis": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@deepseek-ai/cordis/-/cordis-4.0.1.tgz", + "integrity": "sha512-YBdskTU2Po1kru3GgcUWUbkTsPMA9LkSQDAY8rBkFJeajdgcQad3QPJZE26JyK99Xb6HaASvoXg2DSUTeN/0Nw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@deepseek-ai/cosmokit": "^1.8.2", + "@standard-schema/spec": "^1.1.0" + }, + "bin": { + "cordis": "bin.js" + }, + "peerDependencies": { + "@deepseek-ai/cordis-plugin-include": "^1.0.6", + "@deepseek-ai/cordis-plugin-loader": "^1.0.2" + }, + "peerDependenciesMeta": { + "@deepseek-ai/cordis-plugin-include": { + "optional": true + }, + "@deepseek-ai/cordis-plugin-loader": { + "optional": true + } + } + }, + "node_modules/@deepseek-ai/cosmokit": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/cosmokit/-/cosmokit-1.8.2.tgz", + "integrity": "sha512-muBOKtSrUKU5m/xpq8ZXWL6hQ/jgd4PhU2PqH97bcxIiLEJfNwZOGQEx4t/aS/GgxRAR+ra9pMHPMtTHU4sqqA==", + "license": "MIT", + "peer": true + }, + "node_modules/@deepseek-ai/dsh-attachment": { + "version": "0.1.0-rc.8", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-attachment/-/dsh-attachment-0.1.0-rc.8.tgz", + "integrity": "sha512-cCrg4WWiav7pGtbdU8dJTpAG28cPnkWVB2YPOrlBCgBbhQziGcG9Dv67Ti6L3PI4OQKC55gYBFinQghFO/7FGg==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-brand": "^0.1.0-rc.8", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.8" + } + }, + "node_modules/@deepseek-ai/dsh-brand": { + "version": "0.1.0-rc.8", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-brand/-/dsh-brand-0.1.0-rc.8.tgz", + "integrity": "sha512-402aUAfHxIZJrArBV4gDf0I/A/69A/By26FX6HTIOyFmCnB4wUBKh8jnglz1CorKWdZy8AKldAhh8HQhQEbGOQ==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.8" + } + }, + "node_modules/@deepseek-ai/dsh-credentials": { + "version": "0.1.0-rc.8", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-credentials/-/dsh-credentials-0.1.0-rc.8.tgz", + "integrity": "sha512-VopOhtUEa/fSmJr3fyKbsEG043kX1oGuBrmELAbkhmuDigl1cN88g0VPb9rMG2/dJ1h08wg+h0RJP+Ex4LjRWw==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-brand": "^0.1.0-rc.8", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.8" + } + }, + "node_modules/@deepseek-ai/dsh-invariants": { + "version": "0.1.0-rc.8", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-invariants/-/dsh-invariants-0.1.0-rc.8.tgz", + "integrity": "sha512-u0lYqyxOYwfsVnbsfGXZos5vFvA4cqFnBEW3/ezgljNwkYwzeUP/Y5wjPnQjP+ZzBn3CnVeIF6s2N2Vk3iA5mQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1" + } + }, + "node_modules/@deepseek-ai/dsh-llm": { + "version": "0.1.0-rc.8", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-llm/-/dsh-llm-0.1.0-rc.8.tgz", + "integrity": "sha512-hf6RmX2Stn1UtzpktSHQLkIktQkBm9i/3yFfJlbGSkwyw5Cv9sTYJbyoHzsSnnh6i8nRRcMcBhvMgm23I00oIw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-attachment": "^0.1.0-rc.8", + "@deepseek-ai/dsh-brand": "^0.1.0-rc.8", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.8", + "@deepseek-ai/dsh-timeout": "^0.1.0-rc.8" + } + }, + "node_modules/@deepseek-ai/dsh-scope": { + "version": "0.1.0-rc.8", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-scope/-/dsh-scope-0.1.0-rc.8.tgz", + "integrity": "sha512-/o+PxkVV2zUy9lYlMFr4TWSb+ESW4DE+fI891yIbsLzOJdFgfbmmLpGrlWgnz8/unIGJZxjeXdwyppV04Z277Q==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.8" + } + }, + "node_modules/@deepseek-ai/dsh-session": { + "version": "0.1.0-rc.8", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-session/-/dsh-session-0.1.0-rc.8.tgz", + "integrity": "sha512-Qs5Mgn32WPPVB4rKHjsZBgLOkh0TQurUAcc9GaDVudK4dUkpno2HDh++qu/PrwRA3CZ/iwPAoFHCpB3AMMaoLg==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-brand": "^0.1.0-rc.8", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.8", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.8", + "@deepseek-ai/dsh-scope": "^0.1.0-rc.8", + "@deepseek-ai/dsh-typert-protocol": "^0.1.0-rc.8" + } + }, + "node_modules/@deepseek-ai/dsh-subprocess": { + "version": "0.1.0-rc.8", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-subprocess/-/dsh-subprocess-0.1.0-rc.8.tgz", + "integrity": "sha512-rTWwWCq/WrBTjaXhApu7gaho6UHwMZq0+l7nZ06YMUreNZoCHfg67pAXopMqTgr/iotphmXQdChrLgBabzL1gA==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.8" + } + }, + "node_modules/@deepseek-ai/dsh-timeout": { + "version": "0.1.0-rc.8", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-timeout/-/dsh-timeout-0.1.0-rc.8.tgz", + "integrity": "sha512-RwOC/qHribE6b+LStAO7aAgweefLE5sqDO7Uz6/mnTDyJxCt7HNQnE/Op8sAvRa33d752aUkPfO9S0mgzVAoFQ==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.8" + } + }, + "node_modules/@deepseek-ai/dsh-typert-protocol": { + "version": "0.1.0-rc.8", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-typert-protocol/-/dsh-typert-protocol-0.1.0-rc.8.tgz", + "integrity": "sha512-scwEAefRBL7gRpaXiEdiHRMvlr8dLmUbiEvy0LOAX/8X3FZBd/5tHQ/8lCppAQNTDZMI/LSiSMpu5XrFId+7cQ==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.8" + } + }, + "node_modules/@deepseek-ai/schemastery": { + "version": "3.18.1", + "resolved": "https://registry.npmjs.org/@deepseek-ai/schemastery/-/schemastery-3.18.1.tgz", + "integrity": "sha512-Qn0FCSwCQnpnj6SB31I6i2sIKgKWnkbJM8O0EU91Gv2UsYVvtZTl6IA0sCwk2e2MZf5S8w5hpq9QkeVvK9qwxg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@deepseek-ai/cosmokit": "^1.8.2", + "@standard-schema/spec": "^1.1.0" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT", + "peer": true + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/plugins/dsh-invoice-downloader/package.json b/plugins/dsh-invoice-downloader/package.json new file mode 100644 index 0000000..1a8f4a1 --- /dev/null +++ b/plugins/dsh-invoice-downloader/package.json @@ -0,0 +1,96 @@ +{ + "name": "@ethanyoq/dsh-invoice-downloader", + "version": "0.1.0", + "description": "Local IMAP invoice download, OCR, archive, and Excel summary bundle for DeepSeek Harness", + "license": "Apache-2.0", + "type": "module", + "main": "./lib/index.js", + "types": "./lib/index.d.ts", + "repository": { + "type": "git", + "url": "git+https://github.com/EthanYoQ/Invoice-Downloader.git", + "directory": "plugins/dsh-invoice-downloader" + }, + "bugs": { + "url": "https://github.com/EthanYoQ/Invoice-Downloader/issues" + }, + "homepage": "https://github.com/EthanYoQ/Invoice-Downloader#deepseek-harness-plugin", + "keywords": [ + "deepseek-harness", + "dsh-plugin", + "invoice", + "imap", + "ocr", + "excel" + ], + "files": [ + "lib/**/*.js", + "lib/**/*.d.ts", + "src/client-sidebar.js", + "engine-adapter/**", + "runtime/install.py", + "runtime/health_check.py", + "runtime/requirements-headless.txt", + "runtime/engine/**", + "cordis.patch.yml", + "README.md", + "LICENSE", + "NOTICE", + "THIRD_PARTY_NOTICES.md" + ], + "exports": { + ".": { + "types": "./lib/index.d.ts", + "default": "./lib/index.js" + }, + "./client": "./src/client-sidebar.js", + "./cordis.patch.yml": "./cordis.patch.yml", + "./package.json": "./package.json" + }, + "cordis": { + "plugin": "./lib/index.js" + }, + "dsh": { + "bundle": { + "patch": "./cordis.patch.yml" + }, + "client": { + "inject": [ + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-connection", + "@deepseek-ai/dsh-client-ui-layout", + "@deepseek-ai/dsh-client-ui-sidebar", + "@deepseek-ai/dsh-client-ui-workspace" + ], + "platform": "web" + } + }, + "peerDependencies": { + "@deepseek-ai/cordis": ">=4.0.1", + "@deepseek-ai/dsh-credentials": ">=0.1.0-rc.5", + "@deepseek-ai/dsh-llm": ">=0.1.0-rc.5", + "@deepseek-ai/dsh-session": ">=0.1.0-rc.5", + "@deepseek-ai/dsh-subprocess": ">=0.1.0-rc.5" + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "clean": "node scripts/clean.mjs", + "prepare-engine": "node scripts/prepare-engine.mjs", + "pretest": "npm run build", + "test": "node --test tests/*.test.mjs", + "pretest:package": "npm run prepare-engine", + "test:package": "node scripts/verify-package.mjs", + "prepack": "npm run build && npm run test:package" + }, + "devDependencies": { + "@types/node": "^22.20.0", + "typescript": "^5.9.3" + }, + "engines": { + "node": "^22.19.0 || >=24", + "npm": ">=10" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/plugins/dsh-invoice-downloader/runtime/health_check.py b/plugins/dsh-invoice-downloader/runtime/health_check.py new file mode 100644 index 0000000..6114b35 --- /dev/null +++ b/plugins/dsh-invoice-downloader/runtime/health_check.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Check the package-owned headless runtime without touching mailbox data.""" + +from __future__ import annotations + +import argparse +import importlib +import json +import sys +from pathlib import Path + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument('--engine-root', required=True) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + engine_root = Path(args.engine_root).resolve() + source_root = engine_root / 'src' + engine_source = source_root / 'invoice_engine' + adapter_root = Path(__file__).resolve().parent.parent / 'engine-adapter' + sys.path.insert(0, str(source_root)) + sys.path.insert(0, str(engine_source)) + sys.path.insert(0, str(adapter_root)) + + checks: list[dict[str, object]] = [] + for name, module in [ + ('engine', 'invoice_engine'), + ('host adapter', 'dsh_runner'), + ('scan adapter', 'dsh_scan'), + ('engine API', 'app_api'), + ('RapidOCR', 'rapidocr_onnxruntime'), + ('PyMuPDF', 'fitz'), + ('Playwright', 'playwright'), + ]: + try: + importlib.import_module(module) + checks.append({'name': name, 'ok': True}) + except Exception as exc: + checks.append({'name': name, 'ok': False, 'error': type(exc).__name__}) + + payload = {'ok': all(check['ok'] for check in checks), 'checks': checks} + print(json.dumps(payload, ensure_ascii=False)) + return 0 if payload['ok'] else 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/plugins/dsh-invoice-downloader/runtime/install.py b/plugins/dsh-invoice-downloader/runtime/install.py new file mode 100644 index 0000000..05e3e8e --- /dev/null +++ b/plugins/dsh-invoice-downloader/runtime/install.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Install the explicit, package-local Invoice Downloader runtime.""" + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +from pathlib import Path + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument('--venv', required=True) + parser.add_argument('--requirements', required=True) + parser.add_argument('--engine-root', required=True) + parser.add_argument('--health-check', required=True) + return parser.parse_args() + + +def venv_executable(venv_dir: Path, name: str) -> Path: + if os.name == 'nt': + return venv_dir / 'Scripts' / f'{name}.exe' + return venv_dir / 'bin' / name + + +def run(command: list[str], *, env: dict[str, str] | None = None) -> None: + subprocess.check_call(command, env=env) + + +def main() -> int: + args = parse_args() + venv_dir = Path(args.venv).resolve() + requirements = Path(args.requirements).resolve() + engine_root = Path(args.engine_root).resolve() + health_check = Path(args.health_check).resolve() + source_dir = engine_root / 'src' / 'invoice_engine' + + if not requirements.is_file() or not source_dir.is_dir() or not health_check.is_file(): + raise RuntimeError('runtime package files are incomplete; reinstall the npm package') + + if not venv_dir.exists(): + print('Creating local Python environment...') + run([sys.executable, '-m', 'venv', str(venv_dir)]) + + pip = venv_executable(venv_dir, 'pip') + python = venv_executable(venv_dir, 'python') + if not pip.is_file(): + print('Bootstrapping pip in the local Python environment...') + run([str(python), '-m', 'ensurepip', '--upgrade']) + print('Installing local invoice runtime dependencies...') + run([str(pip), 'install', '--disable-pip-version-check', '-r', str(requirements)]) + print('Installing Chromium for invoice-link recovery...') + run([str(python), '-m', 'playwright', 'install', 'chromium']) + + environment = {**os.environ, 'PYTHONPATH': str(source_dir)} + print('Checking the local invoice runtime...') + run( + [ + str(python), + str(health_check), + '--engine-root', + str(engine_root), + ], + env=environment, + ) + print('Invoice runtime is ready.') + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/plugins/dsh-invoice-downloader/runtime/requirements-headless.txt b/plugins/dsh-invoice-downloader/runtime/requirements-headless.txt new file mode 100644 index 0000000..283afd8 --- /dev/null +++ b/plugins/dsh-invoice-downloader/runtime/requirements-headless.txt @@ -0,0 +1,12 @@ +beautifulsoup4==4.14.3 +keyring==25.7.0 +openpyxl==3.1.5 +pillow==12.1.1 +playwright==1.58.0 +PyMuPDF==1.27.1 +python-dotenv==1.2.2 +pyzbar==0.1.9 +requests==2.32.5 +tenacity==9.1.4 +rapidocr-onnxruntime==1.2.3 +tzdata>=2024.1 diff --git a/plugins/dsh-invoice-downloader/scripts/clean.mjs b/plugins/dsh-invoice-downloader/scripts/clean.mjs new file mode 100644 index 0000000..416f160 --- /dev/null +++ b/plugins/dsh-invoice-downloader/scripts/clean.mjs @@ -0,0 +1,16 @@ +import { existsSync, rmSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const targets = [ + resolve(packageRoot, 'lib'), + resolve(packageRoot, 'runtime', 'engine'), +] + +for (const target of targets) { + if (!target.startsWith(`${packageRoot}\\`) && !target.startsWith(`${packageRoot}/`)) { + throw new Error(`refusing to clean outside package: ${target}`) + } + if (existsSync(target)) rmSync(target, { recursive: true, force: true }) +} diff --git a/plugins/dsh-invoice-downloader/scripts/prepare-engine.mjs b/plugins/dsh-invoice-downloader/scripts/prepare-engine.mjs new file mode 100644 index 0000000..d3a6375 --- /dev/null +++ b/plugins/dsh-invoice-downloader/scripts/prepare-engine.mjs @@ -0,0 +1,114 @@ +import { cpSync, existsSync, lstatSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { dirname, join, relative, resolve, sep } from 'node:path' +import { execFileSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const projectRoot = resolve(packageRoot, '..', '..') +const runtimeRoot = resolve(packageRoot, 'runtime') +const engineRoot = resolve(runtimeRoot, 'engine') +const sourceRoot = projectRoot +const extensionRoot = resolve(packageRoot, 'engine-adapter', 'engine-src') +const engineSourceFiles = [ + 'app_api.py', + 'app_archive_adapter.py', + 'archive_pairing_service.py', + 'archive_pairing.py', + 'archive_service.py', + 'artifact_verifier.py', + 'audit_email_truth.py', + 'batch_validation.py', + 'bounded_url_recovery.py', + 'build_identity.py', + 'build_truth_dataset.py', + 'candidate_pipeline.py', + 'company_rules.py', + 'deferred_url_recovery.py', + 'document_acceptance.py', + 'document_types.py', + 'email_body_receipts.py', + 'email_channel.py', + 'email_fetcher.py', + 'extraction_pipeline.py', + 'frontend_run_context.py', + 'glm_runtime.py', + 'invoice_domain.py', + 'invoice_extractor.py', + 'mailbox_scanner.py', + 'main.py', + 'model_calibration.py', + 'pairing_engine.py', + 'pdf_converter.py', + 'pinned_http.py', + 'provider_baiwang.py', + 'provider_direct_invoice.py', + 'report_service.py', + 'run_coordinator.py', + 'run_evidence.py', + 'run_lifecycle.py', + 'run_state_store.py', + 'strict_truth_audit.py', + 'truth_contracts.py', + 'url_recovery_worker.py', + 'url_security.py', + 'url_trace_sanitizer.py', + 'user_settings.py', +] +const extensionSourceFiles = [ + 'deepseek_extractor.py', + 'glm_fallback.py', + 'local_ocr.py', + 'recognition_chain.py', +] + +function requireInside(parent, child) { + const rel = relative(parent, child) + if (rel === '' || rel === '..' || rel.startsWith(`..${sep}`) || rel.includes(`${sep}..${sep}`)) { + throw new Error(`path must be inside ${parent}: ${child}`) + } +} + +function copyFile(source, destination) { + const stat = lstatSync(source) + if (!stat.isFile()) throw new Error(`expected file: ${source}`) + mkdirSync(dirname(destination), { recursive: true }) + cpSync(source, destination, { force: true }) +} + +requireInside(packageRoot, runtimeRoot) +requireInside(runtimeRoot, engineRoot) +if (!existsSync(join(sourceRoot, 'app_api.py'))) { + throw new Error(`Invoice Downloader source is unavailable at ${sourceRoot}`) +} +if (!existsSync(extensionRoot)) { + throw new Error(`DSH adapter sources are unavailable at ${extensionRoot}`) +} + +rmSync(engineRoot, { recursive: true, force: true }) +mkdirSync(join(engineRoot, 'src', 'invoice_engine'), { recursive: true }) + +for (const file of engineSourceFiles) { + copyFile(join(sourceRoot, file), join(engineRoot, 'src', 'invoice_engine', file)) +} + +for (const file of extensionSourceFiles) { + copyFile(join(extensionRoot, file), join(engineRoot, 'src', 'invoice_engine', file)) +} + +copyFile( + join(packageRoot, 'engine-adapter', 'ipc', 'protocol.py'), + join(engineRoot, 'src', 'invoice_engine', 'ipc', 'protocol.py'), +) +writeFileSync(join(engineRoot, 'src', 'invoice_engine', '__init__.py'), '"""Bundled Invoice Downloader engine source."""\n') + +const sourceSha = execFileSync('git', ['-C', sourceRoot, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim() +writeFileSync( + join(engineRoot, 'MANIFEST.json'), + `${JSON.stringify({ + sourceRepository: 'https://github.com/EthanYoQ/Invoice-Downloader', + sourceSha, + generatedBy: '@ethanyoq/dsh-invoice-downloader', + generatedAt: new Date().toISOString(), + sourceFiles: [...engineSourceFiles, ...extensionSourceFiles, 'ipc/protocol.py'].sort(), + }, null, 2)}\n`, +) diff --git a/plugins/dsh-invoice-downloader/scripts/verify-package.mjs b/plugins/dsh-invoice-downloader/scripts/verify-package.mjs new file mode 100644 index 0000000..6a52328 --- /dev/null +++ b/plugins/dsh-invoice-downloader/scripts/verify-package.mjs @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict' +import { existsSync, readFileSync, readdirSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const required = [ + 'lib/index.js', + 'lib/activate.js', + 'src/client-sidebar.js', + 'cordis.patch.yml', + 'runtime/install.py', + 'runtime/health_check.py', + 'runtime/requirements-headless.txt', + 'runtime/engine/MANIFEST.json', + 'runtime/engine/src/invoice_engine/app_api.py', + 'engine-adapter/dsh_runner.py', + 'engine-adapter/dsh_scan.py', + 'runtime/engine/src/invoice_engine/ipc/protocol.py', + 'LICENSE', + 'NOTICE', +] + +for (const file of required) { + assert.ok(existsSync(join(packageRoot, file)), `missing package file: ${file}`) +} + +const manifest = JSON.parse(readFileSync(join(packageRoot, 'runtime/engine/MANIFEST.json'), 'utf8')) +assert.equal(typeof manifest.sourceSha, 'string') +assert.match(manifest.sourceSha, /^[0-9a-f]{40}$/) +assert.equal(manifest.sourceRepository, 'https://github.com/EthanYoQ/Invoice-Downloader') +assert.ok(Array.isArray(manifest.sourceFiles)) + +function collectPythonFiles(root, prefix = '') { + return readdirSync(root, { withFileTypes: true }).flatMap(entry => { + const path = join(root, entry.name) + const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name + if (entry.isDirectory()) return collectPythonFiles(path, relativePath) + return entry.isFile() && entry.name.endsWith('.py') ? [relativePath] : [] + }) +} + +function collectJavaScriptFiles(root, prefix = '') { + return readdirSync(root, { withFileTypes: true }).flatMap(entry => { + const path = join(root, entry.name) + const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name + if (entry.isDirectory()) return collectJavaScriptFiles(path, relativePath) + return entry.isFile() && entry.name.endsWith('.js') ? [relativePath] : [] + }) +} + +const bundledPythonFiles = collectPythonFiles(join(packageRoot, 'runtime', 'engine', 'src', 'invoice_engine')) + .filter(file => file !== '__init__.py') + .sort() +assert.deepEqual(bundledPythonFiles, manifest.sourceFiles) + +const packageManifest = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')) +assert.ok(packageManifest.files.includes('lib/**/*.js')) +assert.ok(packageManifest.files.includes('lib/**/*.d.ts')) +assert.ok(!packageManifest.files.includes('lib/**'), 'publish only runtime JavaScript and declarations') +assert.equal(packageManifest.os, undefined) +assert.equal(packageManifest.cpu, undefined) + +const directDshImports = collectJavaScriptFiles(join(packageRoot, 'lib')) + .filter(file => /(?:from|import\()\s*['"]@deepseek-ai\//.test(readFileSync(join(packageRoot, 'lib', file), 'utf8'))) +assert.deepEqual(directDshImports, []) + +const stagedPaths = [ + 'runtime/.venv', + 'runtime/engine/.venv', + '.runtime', +] +for (const path of stagedPaths) { + assert.ok(!existsSync(join(packageRoot, path)), `runtime residue must not be packaged: ${path}`) +} + +console.log('package layout and privacy exclusions: ok') diff --git a/plugins/dsh-invoice-downloader/src/activate.ts b/plugins/dsh-invoice-downloader/src/activate.ts new file mode 100644 index 0000000..f8f2eb8 --- /dev/null +++ b/plugins/dsh-invoice-downloader/src/activate.ts @@ -0,0 +1,81 @@ +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import type { CredentialProvider } from '@deepseek-ai/dsh-credentials' +import type { LlmRuntime } from '@deepseek-ai/dsh-llm' +import type { SessionStore } from '@deepseek-ai/dsh-session' +import type { SubprocessRuntime } from '@deepseek-ai/dsh-subprocess' +import { ArtifactRegistry } from './file-security.js' +import { InvoiceScanner, type LlmContext } from './invoice-scanner.js' +import { createInvoiceRpcHandler } from './rpc-handler.js' +import { resolveRuntimeLayout, type RuntimeConfig } from './runtime.js' +import { ScanSessionManager } from './storage.js' + +export interface PluginContext { + effect(callback: () => void | (() => void), description?: string): void + subprocess: SubprocessRuntime + connection: { + rpc: { + handle( + channel: string, + handler: (endpoint: string, payload: unknown, signal: AbortSignal) => Promise, + options: { authority: 'loopback' }, + ): void + } + } + sessions: SessionStore + llm: Pick + agentDefaultModel: LlmContext['agentDefaultModel'] + credentials: CredentialProvider +} + +export interface PluginConfig extends RuntimeConfig { + savePath?: string + deadlineMs?: number + graceMs?: number + imapFetchTimeoutSeconds?: number + profileDir?: string +} + +export const name = 'invoice-downloader' +export const inject = ['subprocess', 'connection', 'sessions', 'llm', 'agentDefaultModel', 'credentials'] + +export function apply(context: PluginContext, config: PluginConfig): void { + const runtime = resolveRuntimeLayout(config) + const profileDir = config.profileDir?.trim() + || path.join(process.env.DSH_HOME || path.join(os.homedir(), '.dsh'), 'invoice-downloader') + const savePath = config.savePath?.trim() || '' + fs.mkdirSync(profileDir, { recursive: true }) + fs.mkdirSync(runtime.runtimeRoot, { recursive: true }) + + const scanner = new InvoiceScanner(context.subprocess, { + pythonPath: runtime.pythonPath, + engineRoot: runtime.engineRoot, + adapterRoot: runtime.adapterRoot, + deadlineMs: config.deadlineMs ?? 600_000, + graceMs: config.graceMs ?? 2_000, + imapFetchTimeoutSeconds: config.imapFetchTimeoutSeconds ?? 15, + }) + scanner.setLlmContext({ + llm: context.llm, + agentDefaultModel: context.agentDefaultModel, + }) + + const sessions = new ScanSessionManager() + sessions.setContext({ sessions: context.sessions }) + const handler = createInvoiceRpcHandler({ + savePath, + profileDir, + graceMs: config.graceMs ?? 2_000, + subprocess: context.subprocess, + runtime, + scanner, + sessions, + credentials: context.credentials, + artifacts: new ArtifactRegistry(), + }) + context.effect( + () => context.connection.rpc.handle('/invoice', handler, { authority: 'loopback' }), + 'invoice-downloader: loopback RPC channel', + ) +} diff --git a/plugins/dsh-invoice-downloader/src/client-sidebar.js b/plugins/dsh-invoice-downloader/src/client-sidebar.js new file mode 100644 index 0000000..9ca1245 --- /dev/null +++ b/plugins/dsh-invoice-downloader/src/client-sidebar.js @@ -0,0 +1,531 @@ +window.__ModuleLoader__.load({ + id: '@ethanyoq/dsh-invoice-downloader', + factory: require => { + const module = { exports: {} } + const exports = module.exports + const React = require('react') + const e = React.createElement + + const name = 'invoice-downloader-client' + const inject = ['slots', 'connection', 'workspaces'] + let panelOpen = false + const panelSubscribers = new Set() + + const styles = { + panel: { + position: 'fixed', + zIndex: 50, + top: '16px', + right: '16px', + bottom: '16px', + left: 'auto', + width: 'min(440px, calc(100vw - 32px))', + height: 'auto', + margin: 0, + boxSizing: 'border-box', + display: 'flex', + flexDirection: 'column', + overflow: 'hidden', + background: 'var(--dsw-alias-bg-layer-1)', + color: 'var(--dsw-alias-label-primary)', + border: '1px solid var(--dsw-alias-border-l2)', + borderRadius: '14px', + boxShadow: '0 18px 48px rgba(0, 0, 0, 0.42)', + }, + header: { + display: 'flex', + alignItems: 'flex-start', + justifyContent: 'space-between', + gap: '12px', + padding: '16px 18px', + borderBottom: '1px solid var(--dsw-alias-border-l1)', + }, + title: { fontSize: '16px', fontWeight: 650, lineHeight: 1.35 }, + subtitle: { marginTop: '3px', fontSize: '12px', color: 'var(--dsw-alias-label-tertiary)', lineHeight: 1.45 }, + close: { + minHeight: '32px', + padding: '6px 10px', + border: '1px solid var(--dsw-alias-border-l2)', + borderRadius: '8px', + background: 'transparent', + color: 'var(--dsw-alias-label-primary)', + cursor: 'pointer', + }, + body: { flex: 1, minHeight: 0, overflowY: 'auto', padding: '18px' }, + form: { display: 'flex', flexDirection: 'column', gap: '18px', minWidth: 0 }, + section: { display: 'flex', flexDirection: 'column', gap: '9px', minWidth: 0 }, + sectionTitle: { + fontSize: '12px', + fontWeight: 650, + letterSpacing: '0.06em', + color: 'var(--dsw-alias-label-tertiary)', + }, + field: { display: 'flex', flexDirection: 'column', gap: '6px', minWidth: 0 }, + label: { fontSize: '13px', color: 'var(--dsw-alias-label-primary)' }, + input: { + boxSizing: 'border-box', + width: '100%', + minWidth: 0, + minHeight: '36px', + padding: '8px 10px', + border: '1px solid var(--dsw-alias-border-l2)', + borderRadius: '8px', + background: 'var(--dsw-alias-bg-layer-2)', + color: 'var(--dsw-alias-label-primary)', + outline: 'none', + }, + select: { + boxSizing: 'border-box', + width: '100%', + minHeight: '36px', + padding: '8px 10px', + border: '1px solid var(--dsw-alias-border-l2)', + borderRadius: '8px', + background: 'var(--dsw-alias-bg-layer-2)', + color: 'var(--dsw-alias-label-primary)', + }, + row: { display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: '8px', minWidth: 0 }, + grow: { flex: '1 1 190px', minWidth: 0 }, + dates: { display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: '10px', minWidth: 0 }, + button: { + minHeight: '36px', + padding: '8px 12px', + border: '1px solid var(--dsw-alias-border-l2)', + borderRadius: '8px', + background: 'var(--dsw-alias-button-elevated-fill)', + color: 'var(--dsw-alias-label-primary)', + cursor: 'pointer', + fontSize: '13px', + }, + primary: { + background: 'var(--dsw-alias-button-floating-hover)', + borderColor: 'var(--dsw-alias-border-l2)', + }, + disabled: { opacity: 0.5, cursor: 'not-allowed' }, + help: { fontSize: '12px', color: 'var(--dsw-alias-label-tertiary)', lineHeight: 1.45 }, + notice: { + padding: '10px 12px', + border: '1px solid var(--dsw-alias-border-l1)', + borderRadius: '8px', + background: 'var(--dsw-alias-bg-layer-2)', + color: 'var(--dsw-alias-label-secondary)', + fontSize: '12px', + lineHeight: 1.5, + }, + status: { padding: '8px 10px', borderRadius: '8px', fontSize: '12px', lineHeight: 1.45 }, + success: { background: 'rgba(78, 214, 143, 0.12)', color: '#8ce3b1' }, + error: { background: 'rgba(255, 124, 130, 0.12)', color: '#ff9a9e' }, + working: { background: 'rgba(240, 179, 95, 0.12)', color: '#f0b35f' }, + footerAction: { + width: '100%', + minHeight: '36px', + padding: '8px 12px', + border: 'none', + borderRadius: '8px', + background: 'transparent', + color: 'var(--dsw-alias-label-secondary)', + textAlign: 'left', + cursor: 'pointer', + overflow: 'hidden', + whiteSpace: 'nowrap', + textOverflow: 'ellipsis', + }, + } + + function usePanelOpen() { + const [open, setOpen] = React.useState(panelOpen) + React.useEffect(() => { + panelSubscribers.add(setOpen) + return () => panelSubscribers.delete(setOpen) + }, []) + const update = React.useCallback(value => { + panelOpen = value + panelSubscribers.forEach(listener => listener(value)) + }, []) + return [open, update] + } + + function Status({ value }) { + if (!value || value.kind === 'idle') return null + const color = value.kind === 'success' ? styles.success : value.kind === 'error' ? styles.error : styles.working + return e('div', { style: { ...styles.status, ...color }, role: 'status' }, value.message) + } + + async function chooseOutputDirectory(workspaces, update, setPathStatus) { + setPathStatus({ kind: 'idle', message: '' }) + if (!workspaces || typeof workspaces.pickDirectory !== 'function') { + setPathStatus({ kind: 'error', message: '当前 DSH Host 不支持目录选择。' }) + return + } + try { + const selected = await workspaces.pickDirectory() + if (typeof selected === 'string' && selected) { + update('savePath', selected) + setPathStatus({ kind: 'success', message: '已选择保存位置;保存设置后会重新校验。' }) + } + } catch { + setPathStatus({ kind: 'error', message: '无法打开目录选择器。' }) + } + } + + function InvoicePanel({ call, workspaces, onClose }) { + const [settings, setSettings] = React.useState({ + email: '', + authCode: '', + glmApiKey: '', + dateFrom: '', + dateTo: '', + company: '', + savePath: '', + ocrProvider: 'local', + }) + const [runtime, setRuntime] = React.useState({ installed: false, enginePresent: false, platform: '' }) + const [loaded, setLoaded] = React.useState(false) + const [hasAuthCode, setHasAuthCode] = React.useState(false) + const [hasGlmApiKey, setHasGlmApiKey] = React.useState(false) + const [runtimeStatus, setRuntimeStatus] = React.useState({ kind: 'idle', message: '' }) + const [emailStatus, setEmailStatus] = React.useState({ kind: 'idle', message: '' }) + const [pathStatus, setPathStatus] = React.useState({ kind: 'idle', message: '' }) + const [saveStatus, setSaveStatus] = React.useState({ kind: 'idle', message: '' }) + const [scanStatus, setScanStatus] = React.useState({ kind: 'idle', message: '' }) + const [scan, setScan] = React.useState({ running: false, jobId: '', progress: '' }) + const [privacy, setPrivacy] = React.useState('') + + const update = (key, value) => setSettings(previous => ({ ...previous, [key]: value })) + const refreshRuntime = React.useCallback(async () => { + const result = await call('getRuntimeStatus') + if (result.ok) setRuntime(result.value) + }, [call]) + + React.useEffect(() => { + let alive = true + Promise.all([ + call('getSettings'), + call('getRuntimeStatus'), + call('getPrivacyDisclosure'), + ]).then(([saved, runtimeResult, privacyResult]) => { + if (!alive) return + if (saved.ok) { + setSettings(previous => ({ ...previous, ...saved.value, authCode: '', glmApiKey: '' })) + setHasAuthCode(Boolean(saved.value.hasAuthCode)) + setHasGlmApiKey(Boolean(saved.value.hasGlmApiKey)) + } + if (runtimeResult.ok) setRuntime(runtimeResult.value) + if (privacyResult.ok) setPrivacy(privacyResult.value.defaultChain || '') + setLoaded(true) + }).catch(() => { + if (alive) setLoaded(true) + }) + return () => { alive = false } + }, [call]) + + React.useEffect(() => { + if (!scan.running || !scan.jobId) return undefined + const timer = setInterval(() => { + call('getScanStatus', { jobId: scan.jobId }).then(result => { + if (!result.ok) return + const next = result.value + setScan(previous => ({ + running: Boolean(next.running), + jobId: next.jobId || previous.jobId, + progress: next.running ? (next.progress || previous.progress) : (next.status || next.progress || previous.progress), + })) + if (!next.running) { + setScanStatus({ kind: next.status === 'completed' ? 'success' : 'error', message: next.status === 'completed' ? '扫描完成。' : '扫描已停止或未完成。' }) + } + }).catch(() => {}) + }, 1500) + return () => clearInterval(timer) + }, [call, scan.jobId, scan.running]) + + const installRuntime = async () => { + setRuntimeStatus({ kind: 'working', message: '正在安装本地引擎;首次安装会下载 Python 依赖和 Chromium。' }) + try { + const result = await call('installRuntime') + await refreshRuntime() + setRuntimeStatus(result.ok + ? { kind: 'success', message: '本地引擎已准备就绪。' } + : { kind: 'error', message: result.error?.message || '本地引擎安装失败。' }) + } catch { + setRuntimeStatus({ kind: 'error', message: '本地引擎安装失败。' }) + } + } + + const testRuntime = async () => { + setRuntimeStatus({ kind: 'working', message: '正在检查本地引擎。' }) + try { + const result = await call('testConnection') + setRuntimeStatus(result.ok + ? { kind: 'success', message: '本地引擎连接正常。' } + : { kind: 'error', message: result.error?.message || '本地引擎连接失败。' }) + } catch { + setRuntimeStatus({ kind: 'error', message: '本地引擎连接失败。' }) + } + } + + const chooseDirectory = async () => { + await chooseOutputDirectory(workspaces, update, setPathStatus) + } + + const save = async () => { + setSaveStatus({ kind: 'working', message: '正在保存设置。' }) + try { + const result = await call('saveSettings', settings) + if (result.ok) { + setHasAuthCode(Boolean(result.value.hasAuthCode) || hasAuthCode) + setHasGlmApiKey(Boolean(result.value.hasGlmApiKey) || hasGlmApiKey) + if (settings.authCode) update('authCode', '') + if (settings.glmApiKey) update('glmApiKey', '') + } + setSaveStatus(result.ok + ? { kind: 'success', message: '设置已保存。' } + : { kind: 'error', message: result.error?.message || '设置保存失败。' }) + } catch { + setSaveStatus({ kind: 'error', message: '设置保存失败。' }) + } + } + + const testEmail = async () => { + setEmailStatus({ kind: 'working', message: '正在测试 IMAP 连接。' }) + try { + const result = await call('testEmailAuth', { email: settings.email, authCode: settings.authCode }) + const body = result.ok ? result.value : { ok: false, message: result.error?.message } + setEmailStatus({ kind: body.ok ? 'success' : 'error', message: body.message || '邮箱连接失败。' }) + } catch { + setEmailStatus({ kind: 'error', message: '邮箱连接失败。' }) + } + } + + const startScan = async () => { + setScanStatus({ kind: 'working', message: '正在启动扫描。' }) + try { + const result = await call('startScan', settings) + if (!result.ok) { + setScanStatus({ kind: 'error', message: result.error?.message || '扫描未启动。' }) + return + } + setScan({ running: true, jobId: result.value.jobId, progress: 'starting' }) + setScanStatus({ kind: 'success', message: '扫描已启动。' }) + } catch { + setScanStatus({ kind: 'error', message: '扫描未启动。' }) + } + } + + const stopScan = async () => { + try { + await call('stopScan') + setScanStatus({ kind: 'working', message: '已请求停止扫描。' }) + } catch { + setScanStatus({ kind: 'error', message: '无法停止扫描。' }) + } + } + + const disabled = !loaded + const canScan = runtime.installed && Boolean( + settings.email + && (settings.authCode || hasAuthCode) + && settings.company + && settings.savePath + && settings.dateFrom + && settings.dateTo, + ) && !scan.running + + return e('aside', { + style: styles.panel, + role: 'dialog', + 'aria-label': '发票下载侧边栏', + }, + e('div', { style: styles.header }, + e('div', null, + e('div', { style: styles.title }, '发票下载'), + e('div', { style: styles.subtitle }, '本地 IMAP、OCR、归档与 Excel 汇总'), + ), + e('button', { type: 'button', style: styles.close, onClick: onClose }, '关闭'), + ), + e('div', { style: styles.body }, + e('div', { style: styles.form }, + e('div', { style: styles.section }, + e('div', { style: styles.sectionTitle }, '本地引擎'), + e('div', { style: styles.help }, runtime.installed ? '已安装并可进行健康检查。' : '首次使用需要安装本地引擎。'), + e('div', { style: styles.row }, + e('button', { + type: 'button', + style: { ...styles.button, ...(!runtime.enginePresent ? styles.disabled : {}) }, + disabled: !runtime.enginePresent || runtimeStatus.kind === 'working', + onClick: installRuntime, + }, runtimeStatus.kind === 'working' ? '安装中…' : '安装本地引擎'), + e('button', { + type: 'button', + style: { ...styles.button, ...(!runtime.installed ? styles.disabled : {}) }, + disabled: !runtime.installed || runtimeStatus.kind === 'working', + onClick: testRuntime, + }, '测试引擎连接'), + ), + e(Status, { value: runtimeStatus }), + ), + e('div', { style: styles.section }, + e('div', { style: styles.sectionTitle }, '邮箱配置'), + e('div', { style: styles.field }, + e('label', { style: styles.label }, '邮箱地址'), + e('input', { + style: styles.input, + type: 'email', + placeholder: 'name@qq.com 或 name@163.com', + disabled, + value: settings.email, + onInput: event => update('email', event.target.value), + }), + ), + e('div', { style: styles.field }, + e('label', { style: styles.label }, 'IMAP 授权码'), + e('input', { + style: styles.input, + type: 'password', + placeholder: hasAuthCode ? '已保存;重新输入可替换' : '请输入邮箱授权码', + disabled, + value: settings.authCode, + onInput: event => update('authCode', event.target.value), + }), + ), + e('div', { style: styles.row }, + e('button', { + type: 'button', + style: { ...styles.button, ...(disabled || emailStatus.kind === 'working' ? styles.disabled : {}) }, + disabled: disabled || emailStatus.kind === 'working', + onClick: testEmail, + }, emailStatus.kind === 'working' ? '测试中…' : '测试邮箱连接'), + ), + e(Status, { value: emailStatus }), + ), + e('div', { style: styles.section }, + e('div', { style: styles.sectionTitle }, '扫描范围'), + e('div', { style: styles.dates }, + e('div', { style: styles.field }, + e('label', { style: styles.label }, '开始日期'), + e('input', { style: styles.input, type: 'date', disabled, value: settings.dateFrom, onInput: event => update('dateFrom', event.target.value) }), + ), + e('div', { style: styles.field }, + e('label', { style: styles.label }, '结束日期'), + e('input', { style: styles.input, type: 'date', disabled, value: settings.dateTo, onInput: event => update('dateTo', event.target.value) }), + ), + ), + e('div', { style: styles.field }, + e('label', { style: styles.label }, '公司名称'), + e('input', { + style: styles.input, + type: 'text', + placeholder: '用于匹配购买方', + disabled, + value: settings.company, + onInput: event => update('company', event.target.value), + }), + ), + ), + e('div', { style: styles.section }, + e('div', { style: styles.sectionTitle }, '保存位置'), + e('div', { style: styles.row }, + e('input', { + style: { ...styles.input, ...styles.grow }, + type: 'text', + placeholder: '绝对保存路径', + disabled, + value: settings.savePath, + onInput: event => update('savePath', event.target.value), + }), + e('button', { + type: 'button', + style: { ...styles.button, ...(disabled ? styles.disabled : {}) }, + disabled, + onClick: chooseDirectory, + }, '选择保存位置'), + ), + e('div', { style: styles.help }, '也可手动输入绝对路径;保存时由 Host 创建并校验该目录。'), + e(Status, { value: pathStatus }), + ), + e('div', { style: styles.section }, + e('div', { style: styles.sectionTitle }, '识别方式'), + e('select', { + style: styles.select, + disabled, + value: settings.ocrProvider, + onChange: event => update('ocrProvider', event.target.value), + }, + e('option', { value: 'local' }, '本地 RapidOCR + 当前模型'), + e('option', { value: 'glm' }, 'GLM 视觉识别(可选)'), + ), + settings.ocrProvider === 'glm' + ? e('div', { style: styles.field }, + e('label', { style: styles.label }, 'GLM API Key'), + e('input', { + style: styles.input, + type: 'password', + placeholder: hasGlmApiKey ? '已保存;重新输入可替换' : '可选', + disabled, + value: settings.glmApiKey, + onInput: event => update('glmApiKey', event.target.value), + }), + ) + : null, + ), + privacy ? e('div', { style: styles.notice }, privacy) : null, + e('div', { style: styles.row }, + e('button', { + type: 'button', + style: { ...styles.button, ...(disabled || saveStatus.kind === 'working' ? styles.disabled : {}) }, + disabled: disabled || saveStatus.kind === 'working', + onClick: save, + }, saveStatus.kind === 'working' ? '保存中…' : '保存设置'), + e('button', { + type: 'button', + style: { ...styles.button, ...styles.primary, ...(!canScan ? styles.disabled : {}) }, + disabled: !canScan, + onClick: startScan, + }, '开始扫描'), + scan.running ? e('button', { type: 'button', style: styles.button, onClick: stopScan }, '停止扫描') : null, + ), + e(Status, { value: saveStatus }), + scan.progress ? e('div', { style: styles.help }, `当前状态:${scan.progress}`) : null, + e(Status, { value: scanStatus }), + ), + ), + ) + } + + function FooterAction({ wide }) { + const [open, setOpen] = usePanelOpen() + return e('button', { + type: 'button', + title: '打开发票下载', + 'aria-label': '打开发票下载', + 'aria-pressed': open, + style: { ...styles.footerAction, ...(open ? styles.primary : {}) }, + onClick: () => setOpen(true), + }, wide ? '发票下载' : '发票') + } + + function Overlay({ call, workspaces }) { + const [open, setOpen] = usePanelOpen() + return open ? e(InvoicePanel, { call, workspaces, onClose: () => setOpen(false) }) : null + } + + function apply(ctx) { + const connection = ctx.get('connection') + const workspaces = ctx.workspaces + const call = (endpoint, payload) => connection.rpc.call('/invoice', endpoint, payload ?? {}) + ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register( + { name: 'sidebar.footer.action', id: 'invoice-downloader', order: 95, label: '发票下载' }, + props => e(FooterAction, { wide: Boolean(props && props.wide) }), + )) + ctx.slots.inject('shell.overlay', () => ctx.slots.register( + { name: 'shell.overlay', id: 'invoice-downloader-panel', order: 95, label: '发票下载' }, + () => e(Overlay, { call, workspaces }), + )) + } + + exports.apply = apply + exports.chooseOutputDirectory = chooseOutputDirectory + exports.inject = inject + exports.name = name + return module.exports + }, +}) diff --git a/plugins/dsh-invoice-downloader/src/credentials.ts b/plugins/dsh-invoice-downloader/src/credentials.ts new file mode 100644 index 0000000..4913e2c --- /dev/null +++ b/plugins/dsh-invoice-downloader/src/credentials.ts @@ -0,0 +1,25 @@ +import type { CredentialProvider, CredentialRef } from '@deepseek-ai/dsh-credentials' + +function credentialRef(value: string): CredentialRef { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) { + throw new TypeError(`credential ref "${value}" must use a shell identifier`) + } + return value as CredentialRef +} + +export const IMAP_AUTH_CODE_REF = credentialRef('DSH_INVOICE_IMAP_AUTH_CODE') +export const GLM_API_KEY_REF = credentialRef('DSH_INVOICE_GLM_API_KEY') + +export async function resolveCredential( + service: CredentialProvider, + ref: CredentialRef, +): Promise { + return (await service.resolve(ref))?.value ?? '' +} + +export async function isCredentialConfigured( + service: CredentialProvider, + ref: CredentialRef, +): Promise { + return (await service.describe(ref)).configured +} diff --git a/plugins/dsh-invoice-downloader/src/file-security.ts b/plugins/dsh-invoice-downloader/src/file-security.ts new file mode 100644 index 0000000..b954d88 --- /dev/null +++ b/plugins/dsh-invoice-downloader/src/file-security.ts @@ -0,0 +1,87 @@ +import * as crypto from 'node:crypto' +import * as fs from 'node:fs' +import * as path from 'node:path' + +export interface ArtifactRef { + artifactId: string + jobId: string + fileName: string + category: 'excel-export' + sha256: string + size: number +} + +interface StoredArtifact extends ArtifactRef { + absolutePath: string + outputRoot: string +} + +export class ArtifactRegistry { + private readonly artifacts = new Map() + + validateSavePath(inputPath: string): { ok: true; canonicalPath: string } | { ok: false; errorMessage: string } { + const trimmed = inputPath.trim() + if (!trimmed) return { ok: false, errorMessage: '请选择或输入绝对保存路径。' } + if (!path.isAbsolute(trimmed)) return { ok: false, errorMessage: '保存路径必须是绝对路径。' } + try { + fs.mkdirSync(trimmed, { recursive: true }) + const stat = fs.statSync(trimmed) + if (!stat.isDirectory()) return { ok: false, errorMessage: '保存路径不是目录。' } + return { ok: true, canonicalPath: fs.realpathSync(trimmed) } + } catch { + return { ok: false, errorMessage: '无法创建或访问保存路径。' } + } + } + + registerArtifact(jobId: string, filePath: string, outputRoot: string): ArtifactRef { + const canonicalRoot = fs.realpathSync(outputRoot) + const absolutePath = fs.realpathSync(filePath) + if (!isWithinRoot(canonicalRoot, absolutePath)) { + throw new Error('engine reported an artifact outside the authorized output directory') + } + const content = fs.readFileSync(absolutePath) + const ref: StoredArtifact = { + artifactId: `artifact-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`, + jobId, + fileName: path.basename(absolutePath), + category: 'excel-export', + sha256: crypto.createHash('sha256').update(content).digest('hex'), + size: content.length, + absolutePath, + outputRoot: canonicalRoot, + } + this.artifacts.set(ref.artifactId, ref) + return withoutPath(ref) + } + + listArtifacts(jobId: string): ArtifactRef[] { + return [...this.artifacts.values()] + .filter(artifact => artifact.jobId === jobId) + .map(withoutPath) + } + + readArtifact(artifactId: string, jobId: string): { ok: true; content: Buffer; fileName: string } | { ok: false } { + const artifact = this.artifacts.get(artifactId) + if (!artifact || artifact.jobId !== jobId) return { ok: false } + try { + const current = fs.realpathSync(artifact.absolutePath) + if (!isWithinRoot(artifact.outputRoot, current)) return { ok: false } + return { ok: true, content: fs.readFileSync(current), fileName: artifact.fileName } + } catch { + return { ok: false } + } + } +} + +function withoutPath({ absolutePath: _absolutePath, outputRoot: _outputRoot, ...artifact }: StoredArtifact): ArtifactRef { + return artifact +} + +function isWithinRoot(root: string, target: string): boolean { + const relativePath = path.relative(root, target) + return relativePath === '' || ( + relativePath !== '..' + && !relativePath.startsWith(`..${path.sep}`) + && !path.isAbsolute(relativePath) + ) +} diff --git a/plugins/dsh-invoice-downloader/src/index.ts b/plugins/dsh-invoice-downloader/src/index.ts new file mode 100644 index 0000000..2a5a316 --- /dev/null +++ b/plugins/dsh-invoice-downloader/src/index.ts @@ -0,0 +1,7 @@ +export { apply, inject, name } from './activate.js' +export type { PluginConfig, PluginContext } from './activate.js' +export { createInvoiceRpcHandler } from './rpc-handler.js' +export { InvoiceScanner } from './invoice-scanner.js' +export { EngineIpcClient } from './ipc-client.js' +export { ArtifactRegistry } from './file-security.js' +export { resolveRuntimeLayout, getRuntimeStatus } from './runtime.js' diff --git a/plugins/dsh-invoice-downloader/src/invoice-scanner.ts b/plugins/dsh-invoice-downloader/src/invoice-scanner.ts new file mode 100644 index 0000000..adc3e26 --- /dev/null +++ b/plugins/dsh-invoice-downloader/src/invoice-scanner.ts @@ -0,0 +1,273 @@ +import type { + LlmCallConfig, + LlmRuntime, + Message, + StreamChunk, +} from '@deepseek-ai/dsh-llm' +import type { SubprocessRuntime } from '@deepseek-ai/dsh-subprocess' +import { + EngineIpcClient, + type ExtractionError, + type ExtractionRequest, + type ExtractionResponse, + type JobConfig, + type JobResult, +} from './ipc-client.js' +import type { ModelVisibleEvent } from './storage.js' + +export interface InvoiceScannerConfig { + pythonPath: string + engineRoot: string + adapterRoot: string + deadlineMs: number + graceMs: number + imapFetchTimeoutSeconds: number +} + +export interface ScanRequest { + jobId: string + email: string + authCode: string + dateFrom: string + dateTo: string + company: string + savePath: string + ocrProvider: 'local' | 'glm' + glmApiKey?: string +} + +export interface ScanProgress { + event: string +} + +export interface ScanResult { + status: 'completed' | 'failed' | 'cancelled' + invoicesProcessed: number + successCount: number + retainedCount: number + manualReviewCount: number + exportPath?: string +} + +export interface LlmContext { + llm: Pick + agentDefaultModel: { + currentSelection(): { provider: string; model: string; reasoningEffort?: LlmCallConfig['reasoningEffort'] } | null + } +} + +/** Collects the text-only stream result used by invoice field extraction. */ +class TextResponseAssembler { + private readonly textByIndex = new Map() + private readonly closed = new Set() + private finishKind: string | undefined + + push(chunk: StreamChunk): void { + switch (chunk.type) { + case 'text-delta': + if (!this.closed.has(chunk.index)) { + this.textByIndex.set(chunk.index, `${this.textByIndex.get(chunk.index) || ''}${chunk.text}`) + } + return + case 'block-end': + if (!this.closed.has(chunk.index) && chunk.block.type === 'text') { + this.textByIndex.set(chunk.index, chunk.block.text) + this.closed.add(chunk.index) + } + return + case 'finish': + this.finishKind = chunk.reason.kind + return + default: + return + } + } + + failed(): boolean { + return this.finishKind === 'error' || this.finishKind === 'aborted' + } + + text(): string { + return [...this.textByIndex.entries()] + .sort(([left], [right]) => left - right) + .map(([, text]) => text) + .join('\n') + } +} + +export class InvoiceScanner { + private llmContext: LlmContext | null = null + private client: EngineIpcClient | null = null + private running = false + private cancelled = false + private progressHandler: ((progress: ScanProgress) => void) | null = null + + constructor( + private readonly subprocess: SubprocessRuntime, + private readonly config: InvoiceScannerConfig, + ) {} + + setLlmContext(context: LlmContext): void { + this.llmContext = context + } + + setProgressHandler(handler: (progress: ScanProgress) => void): void { + this.progressHandler = handler + } + + get isRunning(): boolean { + return this.running + } + + async startScan(request: ScanRequest, logModelVisible: (event: ModelVisibleEvent) => Message): Promise { + if (this.running) throw new Error('an invoice scan is already running') + this.running = true + this.cancelled = false + this.client = new EngineIpcClient( + this.subprocess, + this.config.pythonPath, + this.config.engineRoot, + this.config.adapterRoot, + this.config.graceMs, + ) + this.client.onEvent(event => this.progressHandler?.({ event: event.event })) + this.client.onExtraction(request => this.extractWithModel(request, logModelVisible)) + + try { + const result = await this.withDeadline(this.client.startJob({ + jobId: request.jobId, + email: request.email, + authCode: request.authCode, + dateFrom: request.dateFrom, + dateTo: request.dateTo, + company: request.company, + savePath: request.savePath, + ocrProvider: request.ocrProvider, + imapFetchTimeoutSeconds: this.config.imapFetchTimeoutSeconds, + ...(request.glmApiKey ? { glmApiKey: request.glmApiKey } : {}), + })) + return toScanResult(result) + } catch { + if (this.cancelled) return emptyResult('cancelled') + return emptyResult('failed') + } finally { + this.client = null + this.running = false + } + } + + stopScan(): void { + this.cancelled = true + this.client?.stopJob() + } + + private async extractWithModel( + request: ExtractionRequest, + logModelVisible: (event: ModelVisibleEvent) => Message, + ): Promise { + const selection = this.llmContext?.agentDefaultModel.currentSelection() + if (!selection || !this.llmContext) { + return { + requestId: request.requestId, + errorCode: 'MODEL_NOT_CONFIGURED', + errorMessage: '请选择可用模型后再开始扫描。', + retryable: false, + } + } + + try { + const prepared = await this.llmContext.llm.prepareCall({ + provider: selection.provider, + model: selection.model, + temperature: 0, + maxTokens: 500, + ...(selection.reasoningEffort === undefined ? {} : { reasoningEffort: selection.reasoningEffort }), + }) + const message = logModelVisible({ + inputText: JSON.stringify({ + type: 'invoice.field-extraction', + requestId: request.requestId, + instruction: '从 OCR 文本提取发票字段,并仅返回合法 JSON。', + modelConfig: prepared.config, + ocrText: request.ocrText, + }), + }) + const assembler = new TextResponseAssembler() + for await (const chunk of prepared.stream({ ...prepared.config, messages: [message] })) assembler.push(chunk) + if (assembler.failed()) { + return { + requestId: request.requestId, + errorCode: 'LLM_ERROR', + errorMessage: '模型提取失败。', + retryable: false, + } + } + const responseText = assembler.text() + if (!responseText) { + return { + requestId: request.requestId, + errorCode: 'LLM_EMPTY_RESPONSE', + errorMessage: '模型未返回可用文本。', + retryable: false, + } + } + return { + requestId: request.requestId, + extractedFields: extractJson(responseText), + rawModelOutput: responseText, + } + } catch { + return { + requestId: request.requestId, + errorCode: 'LLM_ERROR', + errorMessage: '模型提取失败。', + retryable: false, + } + } + } + + private async withDeadline(result: Promise): Promise { + let timer: NodeJS.Timeout | undefined + try { + return await new Promise((resolve, reject) => { + timer = setTimeout(() => { + this.client?.stopJob() + reject(new Error('invoice scan deadline exceeded')) + }, this.config.deadlineMs) + void result.then(resolve, reject) + }) + } finally { + if (timer) clearTimeout(timer) + } + } +} + +function extractJson(responseText: string): Record { + try { + const match = responseText.match(/\{[\s\S]*\}/) + return match ? JSON.parse(match[0]) as Record : { raw: responseText } + } catch { + return { raw: responseText } + } +} + +function toScanResult(result: JobResult): ScanResult { + return { + status: 'completed', + invoicesProcessed: result.invoicesProcessed || 0, + successCount: result.successCount || 0, + retainedCount: result.retainedCount || 0, + manualReviewCount: result.manualReviewCount || 0, + ...(typeof result.exportPath === 'string' ? { exportPath: result.exportPath } : {}), + } +} + +function emptyResult(status: 'failed' | 'cancelled'): ScanResult { + return { + status, + invoicesProcessed: 0, + successCount: 0, + retainedCount: 0, + manualReviewCount: 0, + } +} diff --git a/plugins/dsh-invoice-downloader/src/ipc-client.ts b/plugins/dsh-invoice-downloader/src/ipc-client.ts new file mode 100644 index 0000000..afdc0a2 --- /dev/null +++ b/plugins/dsh-invoice-downloader/src/ipc-client.ts @@ -0,0 +1,217 @@ +import type { SubprocessHandle, SubprocessRuntime } from '@deepseek-ai/dsh-subprocess' +import { join } from 'node:path' + +export interface JobConfig { + jobId: string + email: string + authCode: string + dateFrom: string + dateTo: string + company: string + savePath: string + ocrProvider: 'local' | 'glm' + imapFetchTimeoutSeconds: number + glmApiKey?: string +} + +export interface JobEvent { + event: string + [key: string]: unknown +} + +export interface JobResult { + status: 'completed' + invoicesProcessed: number + successCount?: number + retainedCount?: number + manualReviewCount?: number + exportPath?: string +} + +export interface ExtractionRequest { + requestId: string + ocrText: string + documentTypeHint?: string +} + +export interface ExtractionResponse { + requestId: string + extractedFields: Record + rawModelOutput?: string +} + +export interface ExtractionError { + requestId: string + errorCode: string + errorMessage: string + retryable: boolean +} + +export type ExtractionHandler = (request: ExtractionRequest) => Promise + +interface Frame { + type: string + jobId?: string + event?: string + requestId?: string + ocrText?: string + documentTypeHint?: string + result?: JobResult +} + +export class EngineIpcClient { + private handle: SubprocessHandle | null = null + private buffer = '' + private extractionHandler: ExtractionHandler | null = null + private eventHandler: ((event: JobEvent) => void) | null = null + private resultResolve: ((result: JobResult) => void) | null = null + private resultReject: ((error: Error) => void) | null = null + + constructor( + private readonly subprocess: SubprocessRuntime, + private readonly pythonPath: string, + private readonly engineRoot: string, + private readonly adapterRoot: string, + private readonly graceMs: number, + ) {} + + onExtraction(handler: ExtractionHandler): void { + this.extractionHandler = handler + } + + onEvent(handler: (event: JobEvent) => void): void { + this.eventHandler = handler + } + + async startJob(config: JobConfig): Promise { + const handle = this.subprocess.spawn({ + argv: [ + this.pythonPath, + '-c', + 'import runpy, sys; sys.path.insert(0, sys.argv[1]); sys.path.insert(0, sys.argv[2]); sys.path.insert(0, sys.argv[3]); runpy.run_module(sys.argv[4], run_name="__main__")', + join(this.engineRoot, 'src'), + join(this.engineRoot, 'src', 'invoice_engine'), + this.adapterRoot, + 'dsh_runner', + ], + cwd: this.engineRoot, + stdio: { stdin: 'pipe', stdout: 'pipe', stderr: { maxBytes: 1024 * 1024 } }, + graceMs: this.graceMs, + }) + if (!handle.stdin || !handle.stdout) { + handle.terminate() + throw new Error('invoice engine did not provide required standard I/O') + } + this.handle = handle + handle.stdin.write(`${JSON.stringify({ + type: 'job.start', + protocolVersion: 1, + timestamp: Date.now(), + ...config, + })}\n`) + + void this.readLoop(handle, handle.stdout).then( + async () => { + const outcome = await handle.done + this.rejectIfPending(new Error(`invoice engine exited before returning a result (${String(outcome.exitCode)})`)) + }, + () => this.rejectIfPending(new Error('invoice engine communication failed')), + ) + + return new Promise((resolve, reject) => { + this.resultResolve = resolve + this.resultReject = reject + }) + } + + stopJob(): void { + this.handle?.terminate() + } + + private async readLoop(handle: SubprocessHandle, stdout: NonNullable): Promise { + for await (const chunk of stdout) { + this.buffer += chunk + const lines = this.buffer.split('\n') + this.buffer = lines.pop() ?? '' + for (const line of lines) { + if (!line.trim()) continue + try { + await this.handleFrame(JSON.parse(line) as Frame, handle) + } catch { + continue + } + } + } + } + + private async handleFrame(frame: Frame, handle: SubprocessHandle): Promise { + switch (frame.type) { + case 'job.event': + if (typeof frame.event === 'string') this.eventHandler?.(frame as unknown as JobEvent) + return + case 'job.result': + if (!frame.result) return + this.resolveResult(frame.result) + return + case 'job.error': + this.rejectIfPending(new Error('invoice engine reported a failure')) + return + case 'extraction.request': + await this.respondToExtraction(frame, handle) + return + default: + return + } + } + + private async respondToExtraction(frame: Frame, handle: SubprocessHandle): Promise { + const requestId = frame.requestId + if (!requestId || !this.extractionHandler) return + try { + const response = await this.extractionHandler({ + requestId, + ocrText: frame.ocrText ?? '', + documentTypeHint: frame.documentTypeHint, + }) + this.sendFrame(handle, { + type: 'extraction.response', + protocolVersion: 1, + jobId: frame.jobId, + timestamp: Date.now(), + ...response, + }) + } catch { + this.sendFrame(handle, { + type: 'extraction.error', + protocolVersion: 1, + jobId: frame.jobId, + timestamp: Date.now(), + requestId, + errorCode: 'HANDLER_ERROR', + errorMessage: 'model extraction failed', + retryable: false, + }) + } + } + + private sendFrame(handle: SubprocessHandle, frame: Record): void { + if (!handle.stdin) throw new Error('invoice engine input stream is unavailable') + handle.stdin.write(`${JSON.stringify(frame)}\n`) + } + + private resolveResult(result: JobResult): void { + const resolve = this.resultResolve + this.resultResolve = null + this.resultReject = null + this.handle = null + resolve?.(result) + } + + private rejectIfPending(error: Error): void { + const reject = this.resultReject + this.resultResolve = null + this.resultReject = null + this.handle = null + reject?.(error) + } +} diff --git a/plugins/dsh-invoice-downloader/src/rpc-handler.ts b/plugins/dsh-invoice-downloader/src/rpc-handler.ts new file mode 100644 index 0000000..9c95135 --- /dev/null +++ b/plugins/dsh-invoice-downloader/src/rpc-handler.ts @@ -0,0 +1,424 @@ +import * as path from 'node:path' +import type { CredentialProvider } from '@deepseek-ai/dsh-credentials' +import type { SubprocessHandle, SubprocessRuntime } from '@deepseek-ai/dsh-subprocess' +import { GLM_API_KEY_REF, IMAP_AUTH_CODE_REF, isCredentialConfigured, resolveCredential } from './credentials.js' +import { ArtifactRegistry } from './file-security.js' +import { InvoiceScanner, type ScanRequest } from './invoice-scanner.js' +import { getRuntimeStatus, type RuntimeLayout } from './runtime.js' +import { readSettings, type InvoiceSettings, writeSettings } from './settings-store.js' +import { PRIVACY_DISCLOSURE, ScanSessionManager } from './storage.js' + +export interface InvoiceRpcContext { + savePath: string + profileDir: string + graceMs: number + subprocess: SubprocessRuntime + runtime: RuntimeLayout + scanner: InvoiceScanner + sessions: ScanSessionManager + credentials: CredentialProvider + artifacts: ArtifactRegistry +} + +export interface RpcResult { + ok: boolean + value?: unknown + error?: { + code: 'bad-request' | 'internal' + message: string + details: { issues: [] } | Record + } +} + +interface RuntimeHealth { + ok: boolean +} + +const ok = (value: unknown): RpcResult => ({ ok: true, value }) +const badRequest = (message: string): RpcResult => ({ + ok: false, + error: { code: 'bad-request', message, details: { issues: [] } }, +}) +const internal = (message: string): RpcResult => ({ + ok: false, + error: { code: 'internal', message, details: {} }, +}) + +export function createInvoiceRpcHandler( + context: InvoiceRpcContext, +): (endpoint: string, payload: unknown, signal: AbortSignal) => Promise { + let installation: Promise | null = null + + return async (endpoint, payload, signal) => { + try { + switch (endpoint) { + case 'getRuntimeStatus': + return ok(getRuntimeStatus(context.runtime)) + case 'installRuntime': + if (!installation) { + installation = installRuntime(context, signal).finally(() => { + installation = null + }) + } + return await installation + case 'testConnection': + return await testConnection(context, signal) + case 'testEmailAuth': + return await testEmailAuth(context, payload, signal) + case 'getSettings': + return await getSettings(context) + case 'saveSettings': + return await saveSettings(context, payload) + case 'startScan': + return await startScan(context, payload) + case 'stopScan': + context.scanner.stopScan() + return ok({ stopped: true }) + case 'getScanStatus': + return ok(getScanStatus(context, payload)) + case 'getPrivacyDisclosure': + return ok(PRIVACY_DISCLOSURE) + case 'listArtifacts': + return listArtifacts(context, payload) + case 'downloadArtifact': + return downloadArtifact(context, payload) + default: + return badRequest('未知的发票插件请求。') + } + } catch { + return internal('发票插件请求未完成。') + } + } +} + +async function installRuntime(context: InvoiceRpcContext, signal: AbortSignal): Promise { + const status = getRuntimeStatus(context.runtime) + if (status.platform === 'unsupported') { + return badRequest('此版本仅支持 Windows x64 和 macOS Apple Silicon。') + } + if (!status.enginePresent) { + return internal('插件运行时不完整,请重新安装 npm 包。') + } + const bootstrapArgv = await resolveBootstrapArgv(context, signal) + const result = await runCommand( + context, + [ + ...bootstrapArgv, + context.runtime.installerPath, + '--venv', + context.runtime.venvRoot, + '--requirements', + context.runtime.requirementsPath, + '--engine-root', + context.runtime.engineRoot, + '--health-check', + context.runtime.healthCheckPath, + ], + signal, + ) + if (result.exitCode !== 0) { + return internal('本地引擎安装失败。请确认 Python 3.10+ 可用后重试。') + } + return testConnection(context, signal) +} + +/** Chooses an installed, supported CPython version when Windows exposes the Python Launcher. */ +async function resolveBootstrapArgv(context: InvoiceRpcContext, signal: AbortSignal): Promise { + if (process.platform !== 'win32' || context.runtime.bootstrapPython) return context.runtime.bootstrapArgv + const launcher = await runCommand(context, ['py', '-0p'], signal) + if (launcher.exitCode !== 0) return context.runtime.bootstrapArgv + const versions = [...launcher.stdout.matchAll(/-V:3\.(10|11|12|13)\b/g)] + .map(match => Number(match[1])) + const minor = versions.length > 0 ? Math.max(...versions) : undefined + return minor ? ['py', `-3.${minor}`] : context.runtime.bootstrapArgv +} + +async function testConnection(context: InvoiceRpcContext, signal: AbortSignal): Promise { + const status = getRuntimeStatus(context.runtime) + if (status.platform === 'unsupported') { + return badRequest('此版本仅支持 Windows x64 和 macOS Apple Silicon。') + } + if (!status.installed) { + return badRequest('本地引擎尚未安装。') + } + const result = await runCommand( + context, + [ + context.runtime.pythonPath, + context.runtime.healthCheckPath, + '--engine-root', + context.runtime.engineRoot, + ], + signal, + ) + if (result.exitCode !== 0) return internal('本地引擎健康检查失败。') + const health = parseHealth(result.stdout) + return health.ok + ? ok({ engineVersion: '0.1.0', ocrAvailable: true }) + : internal('本地引擎健康检查失败。') +} + +async function testEmailAuth( + context: InvoiceRpcContext, + payload: unknown, + signal: AbortSignal, +): Promise { + const request = asObject(payload) + const email = stringValue(request.email) + const authCode = stringValue(request.authCode) || await resolveCredential(context.credentials, IMAP_AUTH_CODE_REF) + const server = imapServer(email) + if (!server || !authCode) return badRequest('请填写 QQ 或 163 邮箱与 IMAP 授权码。') + const status = getRuntimeStatus(context.runtime) + if (!status.installed) return badRequest('请先安装本地引擎。') + + const script = [ + 'import json, sys', + 'sys.path.insert(0, sys.argv[1])', + 'sys.path.insert(0, sys.argv[2])', + 'from invoice_engine.email_fetcher import EmailFetcher', + 'cfg = json.loads(sys.stdin.readline())', + 'try:', + ' fetcher = EmailFetcher(cfg["email"], cfg["authCode"], imap_server=cfg["server"])', + ' fetcher.connect()', + ' fetcher.disconnect()', + ' print(json.dumps({"ok": True}))', + 'except Exception:', + ' print(json.dumps({"ok": False}))', + ].join('\n') + const result = await runPython( + context, + script, + JSON.stringify({ email, authCode, server }), + signal, + ) + const success = result.exitCode === 0 && parseHealth(result.stdout).ok + return ok({ ok: success, message: success ? '邮箱连接成功。' : '邮箱连接失败,请检查授权码和 IMAP 设置。' }) +} + +async function getSettings(context: InvoiceRpcContext): Promise { + const settings = readSettings(context.profileDir) + return ok({ + ...settings, + savePath: settings.savePath || context.savePath, + hasAuthCode: await isCredentialConfigured(context.credentials, IMAP_AUTH_CODE_REF), + hasGlmApiKey: await isCredentialConfigured(context.credentials, GLM_API_KEY_REF), + }) +} + +async function saveSettings(context: InvoiceRpcContext, payload: unknown): Promise { + const request = asObject(payload) + const current = readSettings(context.profileDir) + const settings = parseSettings(request, current) + if (!settings) return badRequest('设置字段格式不正确。') + if (settings.savePath) { + const validated = context.artifacts.validateSavePath(settings.savePath) + if (!validated.ok) return badRequest(validated.errorMessage) + settings.savePath = validated.canonicalPath + } + const authCode = stringValue(request.authCode) + const glmApiKey = stringValue(request.glmApiKey) + if (authCode) await context.credentials.set(IMAP_AUTH_CODE_REF, authCode) + if (glmApiKey) await context.credentials.set(GLM_API_KEY_REF, glmApiKey) + writeSettings(context.profileDir, settings) + return ok({ + saved: true, + hasAuthCode: await isCredentialConfigured(context.credentials, IMAP_AUTH_CODE_REF), + hasGlmApiKey: await isCredentialConfigured(context.credentials, GLM_API_KEY_REF), + }) +} + +async function startScan(context: InvoiceRpcContext, payload: unknown): Promise { + if (context.scanner.isRunning) return badRequest('已有扫描正在进行。') + if (!getRuntimeStatus(context.runtime).installed) return badRequest('请先安装本地引擎。') + const request = asObject(payload) + const settings = parseSettings(request, readSettings(context.profileDir)) + if (!settings || !settings.email || !settings.company || !settings.savePath) { + return badRequest('请填写邮箱、公司名称和保存位置。') + } + if (!isValidDateRange(settings.dateFrom, settings.dateTo)) { + return badRequest('请选择有效且按时间顺序排列的日期范围。') + } + const server = imapServer(settings.email) + if (!server) return badRequest('目前仅支持 QQ 邮箱和 163 邮箱。') + void server + const output = context.artifacts.validateSavePath(settings.savePath) + if (!output.ok) return badRequest(output.errorMessage) + const authCode = stringValue(request.authCode) || await resolveCredential(context.credentials, IMAP_AUTH_CODE_REF) + const glmApiKey = stringValue(request.glmApiKey) || await resolveCredential(context.credentials, GLM_API_KEY_REF) + if (!authCode) return badRequest('请填写并保存 IMAP 授权码。') + + const jobId = `scan-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + const session = context.sessions.createSession(jobId) + context.scanner.setProgressHandler(progress => context.sessions.recordProgress(jobId, progress.event)) + const scanRequest: ScanRequest = { + jobId, + email: settings.email, + authCode, + dateFrom: settings.dateFrom, + dateTo: settings.dateTo, + company: settings.company, + savePath: output.canonicalPath, + ocrProvider: settings.ocrProvider, + ...(settings.ocrProvider === 'glm' && glmApiKey ? { glmApiKey } : {}), + } + + void context.scanner.startScan(scanRequest, event => context.sessions.logModelVisible(session.sessionId, event)) + .then(result => { + if (result.status === 'cancelled') { + context.sessions.cancel(jobId) + return + } + if (result.status !== 'completed') { + context.sessions.fail(jobId) + return + } + let exportArtifactId: string | undefined + if (result.exportPath) { + try { + exportArtifactId = context.artifacts.registerArtifact(jobId, result.exportPath, output.canonicalPath).artifactId + } catch { + exportArtifactId = undefined + } + } + context.sessions.complete(jobId, { + invoicesProcessed: result.invoicesProcessed, + successCount: result.successCount, + retainedCount: result.retainedCount, + manualReviewCount: result.manualReviewCount, + ...(exportArtifactId ? { exportArtifactId } : {}), + }) + }) + .catch(() => context.sessions.fail(jobId)) + + return ok({ jobId }) +} + +function getScanStatus(context: InvoiceRpcContext, payload: unknown): Record { + const jobId = stringValue(asObject(payload).jobId) + const projection = jobId ? context.sessions.getProjection(jobId) : undefined + return { + running: context.scanner.isRunning, + ...(jobId ? { jobId } : {}), + ...(projection ? { status: projection.status, progress: projection.lastProgress ?? projection.status } : {}), + } +} + +function listArtifacts(context: InvoiceRpcContext, payload: unknown): RpcResult { + const jobId = stringValue(asObject(payload).jobId) + if (!jobId) return badRequest('缺少扫描任务标识。') + return ok({ artifacts: context.artifacts.listArtifacts(jobId) }) +} + +function downloadArtifact(context: InvoiceRpcContext, payload: unknown): RpcResult { + const request = asObject(payload) + const artifactId = stringValue(request.artifactId) + const jobId = stringValue(request.jobId) + if (!artifactId || !jobId) return badRequest('缺少文件标识或扫描任务标识。') + const artifact = context.artifacts.readArtifact(artifactId, jobId) + if (!artifact.ok) return badRequest('文件不可用。') + return ok({ + fileName: artifact.fileName, + content: artifact.content.toString('base64'), + }) +} + +async function runPython( + context: InvoiceRpcContext, + script: string, + input: string, + signal: AbortSignal, +): Promise<{ exitCode: number | null; stdout: string }> { + const handle = context.subprocess.spawn({ + argv: [ + context.runtime.pythonPath, + '-c', + script, + path.join(context.runtime.engineRoot, 'src'), + path.join(context.runtime.engineRoot, 'src', 'invoice_engine'), + ], + cwd: context.runtime.engineRoot, + stdio: { stdin: 'pipe', stdout: 'pipe', stderr: { maxBytes: 1024 * 1024 } }, + graceMs: context.graceMs, + signal, + }) + if (!handle.stdin || !handle.stdout) { + handle.terminate() + throw new Error('invoice helper did not provide required standard I/O') + } + handle.stdin.write(`${input}\n`) + let stdout = '' + for await (const chunk of handle.stdout) stdout += chunk + return { exitCode: (await handle.done).exitCode, stdout } +} + +async function runCommand( + context: InvoiceRpcContext, + argv: readonly string[], + signal: AbortSignal, +): Promise<{ exitCode: number | null; stdout: string }> { + const handle = context.subprocess.spawn({ + argv, + cwd: context.runtime.runtimeRoot, + stdio: { stdin: 'ignore', stdout: 'pipe', stderr: { maxBytes: 1024 * 1024 } }, + graceMs: context.graceMs, + signal, + }) + return collectOutput(handle) +} + +async function collectOutput(handle: SubprocessHandle): Promise<{ exitCode: number | null; stdout: string }> { + if (!handle.stdout) { + handle.terminate() + throw new Error('runtime command did not provide output') + } + let stdout = '' + for await (const chunk of handle.stdout) stdout += chunk + return { exitCode: (await handle.done).exitCode, stdout } +} + +function parseHealth(stdout: string): RuntimeHealth { + const last = stdout.trim().split('\n').at(-1) + if (!last) return { ok: false } + try { + const result = JSON.parse(last) as RuntimeHealth + return { ok: result.ok === true } + } catch { + return { ok: false } + } +} + +function parseSettings(value: Record, fallback: InvoiceSettings): InvoiceSettings | null { + const next = (key: keyof InvoiceSettings): string => Object.hasOwn(value, key) + ? stringValue(value[key]) + : fallback[key] + const ocrProvider = next('ocrProvider') || 'local' + if (ocrProvider !== 'local' && ocrProvider !== 'glm') return null + return { + email: next('email'), + dateFrom: next('dateFrom'), + dateTo: next('dateTo'), + company: next('company'), + savePath: next('savePath'), + ocrProvider, + } +} + +function asObject(value: unknown): Record { + return value !== null && typeof value === 'object' ? value as Record : {} +} + +function stringValue(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +function imapServer(email: string): string | null { + const domain = email.toLowerCase().split('@')[1] + if (domain === 'qq.com') return 'imap.qq.com' + if (domain === '163.com') return 'imap.163.com' + return null +} + +function isValidDateRange(first: string, last: string): boolean { + if (!/^\d{4}-\d{2}-\d{2}$/.test(first) || !/^\d{4}-\d{2}-\d{2}$/.test(last)) return false + return first <= last +} diff --git a/plugins/dsh-invoice-downloader/src/runtime.ts b/plugins/dsh-invoice-downloader/src/runtime.ts new file mode 100644 index 0000000..049aac5 --- /dev/null +++ b/plugins/dsh-invoice-downloader/src/runtime.ts @@ -0,0 +1,74 @@ +import * as fs from 'node:fs' +import * as path from 'node:path' +import { fileURLToPath } from 'node:url' + +export interface RuntimeConfig { + runtimeRoot?: string + engineRoot?: string + pythonPath?: string + bootstrapPython?: string +} + +export interface RuntimeLayout { + runtimeRoot: string + engineRoot: string + pythonPath: string + bootstrapPython?: string + venvRoot: string + adapterRoot: string + installerPath: string + healthCheckPath: string + requirementsPath: string + bootstrapArgv: readonly string[] +} + +export interface RuntimeStatus { + installed: boolean + enginePresent: boolean + platform: 'windows-x64' | 'macos-arm64' | 'unsupported' +} + +function packageRoot(): string { + return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +} + +function defaultBootstrapArgv(): readonly string[] { + if (process.platform === 'win32') return ['py', '-3'] + return ['python3'] +} + +export function resolveRuntimeLayout(config: RuntimeConfig): RuntimeLayout { + const root = packageRoot() + const packageRuntimeRoot = path.join(root, 'runtime') + const runtimeRoot = config.runtimeRoot?.trim() + || path.join(process.env.DSH_HOME || path.join(process.env.USERPROFILE || process.env.HOME || '.', '.dsh'), 'invoice-downloader', 'runtime') + const venvRoot = path.join(runtimeRoot, '.venv') + const venvPython = process.platform === 'win32' + ? path.join(venvRoot, 'Scripts', 'python.exe') + : path.join(venvRoot, 'bin', 'python') + const bootstrapPython = config.bootstrapPython?.trim() + + return { + runtimeRoot, + engineRoot: config.engineRoot?.trim() || path.join(packageRuntimeRoot, 'engine'), + pythonPath: config.pythonPath?.trim() || venvPython, + ...(bootstrapPython ? { bootstrapPython } : {}), + venvRoot, + adapterRoot: path.join(root, 'engine-adapter'), + installerPath: path.join(packageRuntimeRoot, 'install.py'), + healthCheckPath: path.join(packageRuntimeRoot, 'health_check.py'), + requirementsPath: path.join(packageRuntimeRoot, 'requirements-headless.txt'), + bootstrapArgv: bootstrapPython ? [bootstrapPython] : defaultBootstrapArgv(), + } +} + +export function getRuntimeStatus(layout: RuntimeLayout): RuntimeStatus { + const enginePresent = fs.existsSync(path.join(layout.engineRoot, 'src', 'invoice_engine', 'app_api.py')) + const installed = enginePresent && fs.existsSync(layout.pythonPath) + const platform = process.platform === 'win32' && process.arch === 'x64' + ? 'windows-x64' + : process.platform === 'darwin' && process.arch === 'arm64' + ? 'macos-arm64' + : 'unsupported' + return { installed, enginePresent, platform } +} diff --git a/plugins/dsh-invoice-downloader/src/settings-store.ts b/plugins/dsh-invoice-downloader/src/settings-store.ts new file mode 100644 index 0000000..8b7cd5c --- /dev/null +++ b/plugins/dsh-invoice-downloader/src/settings-store.ts @@ -0,0 +1,45 @@ +import * as fs from 'node:fs' +import * as path from 'node:path' + +export interface InvoiceSettings { + email: string + dateFrom: string + dateTo: string + company: string + savePath: string + ocrProvider: 'local' | 'glm' +} + +const DEFAULT_SETTINGS: InvoiceSettings = { + email: '', + dateFrom: '', + dateTo: '', + company: '', + savePath: '', + ocrProvider: 'local', +} + +function settingsPath(profileDir: string): string { + return path.join(profileDir, 'settings.json') +} + +export function readSettings(profileDir: string): InvoiceSettings { + try { + const value = JSON.parse(fs.readFileSync(settingsPath(profileDir), 'utf8')) as Partial + return { + email: typeof value.email === 'string' ? value.email : '', + dateFrom: typeof value.dateFrom === 'string' ? value.dateFrom : '', + dateTo: typeof value.dateTo === 'string' ? value.dateTo : '', + company: typeof value.company === 'string' ? value.company : '', + savePath: typeof value.savePath === 'string' ? value.savePath : '', + ocrProvider: value.ocrProvider === 'glm' ? 'glm' : 'local', + } + } catch { + return { ...DEFAULT_SETTINGS } + } +} + +export function writeSettings(profileDir: string, settings: InvoiceSettings): void { + fs.mkdirSync(profileDir, { recursive: true }) + fs.writeFileSync(settingsPath(profileDir), `${JSON.stringify(settings, null, 2)}\n`, 'utf8') +} diff --git a/plugins/dsh-invoice-downloader/src/storage.ts b/plugins/dsh-invoice-downloader/src/storage.ts new file mode 100644 index 0000000..d1c7b36 --- /dev/null +++ b/plugins/dsh-invoice-downloader/src/storage.ts @@ -0,0 +1,118 @@ +import type { Message, UserMessage } from '@deepseek-ai/dsh-llm' +import type { Session, SessionStore } from '@deepseek-ai/dsh-session' + +export interface ModelVisibleEvent { + inputText: string +} + +export interface RunProjection { + jobId: string + sessionId: string + status: 'active' | 'completed' | 'failed' | 'cancelled' + startedAt: string + completedAt?: string + invoicesProcessed: number + successCount: number + retainedCount: number + manualReviewCount: number + exportArtifactId?: string + lastProgress?: string +} + +export interface ScanSessionManagerContext { + sessions: SessionStore +} + +function createInvoiceUserMessage(inputText: string): UserMessage { + const content = Object.freeze([Object.freeze({ type: 'text', text: inputText })]) + return Object.freeze({ + id: crypto.randomUUID(), + role: 'user', + content, + source: { kind: 'plugin', plugin: 'invoice-downloader' }, + }) as unknown as UserMessage +} + +export const PRIVACY_DISCLOSURE = { + defaultChain: '发票文件在本地 OCR;OCR 文本会发送到当前选择的模型以提取字段,并记录在 DSH 会话中。', + glmChain: '启用 GLM 时,发票图像会发送到 GLM 服务。', + credentials: '邮箱授权码和 GLM API Key 由 DSH 凭据服务管理,不会写入插件设置。', + localOnly: '此插件只支持运行在本机的 DSH。', +} as const + +export class ScanSessionManager { + private context: ScanSessionManagerContext | null = null + private readonly sessions = new Map() + private readonly projections = new Map() + + setContext(context: ScanSessionManagerContext): void { + this.context = context + } + + createSession(jobId: string): { sessionId: string; jobId: string } { + if (!this.context) throw new Error('DSH session service is unavailable') + const sessionId = `invoice-scan-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + const session = this.context.sessions.create(sessionId as Parameters[0]) + if (session.id !== sessionId) throw new Error('DSH session service returned a different session id') + this.sessions.set(sessionId, session) + this.projections.set(jobId, { + jobId, + sessionId, + status: 'active', + startedAt: new Date().toISOString(), + invoicesProcessed: 0, + successCount: 0, + retainedCount: 0, + manualReviewCount: 0, + }) + return { sessionId, jobId } + } + + logModelVisible(sessionId: string, event: ModelVisibleEvent): Message { + const message = createInvoiceUserMessage(event.inputText) + const session = this.sessions.get(sessionId) + session?.append('user/message', message, { surfaceOp: 'append' }) + return message + } + + recordProgress(jobId: string, event: string): void { + const projection = this.projections.get(jobId) + if (projection) projection.lastProgress = event + } + + complete(jobId: string, result: { + invoicesProcessed: number + successCount: number + retainedCount: number + manualReviewCount: number + exportArtifactId?: string + }): void { + const projection = this.projections.get(jobId) + if (!projection) return + projection.status = 'completed' + projection.completedAt = new Date().toISOString() + projection.invoicesProcessed = result.invoicesProcessed + projection.successCount = result.successCount + projection.retainedCount = result.retainedCount + projection.manualReviewCount = result.manualReviewCount + projection.exportArtifactId = result.exportArtifactId + } + + fail(jobId: string): void { + const projection = this.projections.get(jobId) + if (!projection) return + projection.status = 'failed' + projection.completedAt = new Date().toISOString() + } + + cancel(jobId: string): void { + const projection = this.projections.get(jobId) + if (!projection) return + projection.status = 'cancelled' + projection.completedAt = new Date().toISOString() + } + + getProjection(jobId: string): RunProjection | undefined { + return this.projections.get(jobId) + } +} diff --git a/plugins/dsh-invoice-downloader/tests/client-sidebar.test.mjs b/plugins/dsh-invoice-downloader/tests/client-sidebar.test.mjs new file mode 100644 index 0000000..82e041c --- /dev/null +++ b/plugins/dsh-invoice-downloader/tests/client-sidebar.test.mjs @@ -0,0 +1,90 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import test from 'node:test' +import { runInNewContext } from 'node:vm' + +const source = readFileSync(join(import.meta.dirname, '..', 'src', 'client-sidebar.js'), 'utf8') + +function loadPluginExports() { + let definition + runInNewContext(source, { + window: { + __ModuleLoader__: { + load(value) { definition = value }, + }, + }, + }) + assert.ok(definition) + return definition.factory(name => { + if (name === 'react') return { createElement() {} } + throw new Error(`unexpected client dependency: ${name}`) + }) +} + +test('sidebar is explicitly anchored on the right and uses the DSH directory picker', () => { + assert.match(source, /right: '16px'/) + assert.match(source, /left: 'auto'/) + assert.match(source, /width: 'min\(440px, calc\(100vw - 32px\)\)'/) + assert.match(source, /workspaces\.pickDirectory\(\)/) + assert.match(source, /const workspaces = ctx\.workspaces/) + assert.match(source, /'选择保存位置'/) +}) + +test('sidebar preserves every pending field update during one render batch', () => { + assert.match(source, /setSettings\(previous => \(\{ \.\.\.previous, \[key\]: value \}\)\)/) +}) + +test('sidebar handles text and date input events from the DSH web host', () => { + assert.match(source, /type: 'date', disabled, value: settings\.dateFrom, onInput: event => update\('dateFrom', event\.target\.value\)/) + assert.match(source, /type: 'date', disabled, value: settings\.dateTo, onInput: event => update\('dateTo', event\.target\.value\)/) +}) + +test('sidebar replaces the last progress event with the terminal scan status', () => { + assert.match(source, /progress: next\.running \? \(next\.progress \|\| previous\.progress\) : \(next\.status \|\| next\.progress \|\| previous\.progress\)/) +}) + +test('directory picker applies a selected DSH path and reports success', async () => { + const { chooseOutputDirectory } = loadPluginExports() + const updates = [] + const statuses = [] + await chooseOutputDirectory( + { async pickDirectory() { return 'C:\\invoice-output' } }, + (...args) => updates.push(args), + value => statuses.push([value.kind, value.message]), + ) + assert.deepEqual(updates, [['savePath', 'C:\\invoice-output']]) + assert.deepEqual(statuses, [ + ['idle', ''], + ['success', '已选择保存位置;保存设置后会重新校验。'], + ]) +}) + +test('directory picker leaves settings unchanged when the native chooser is cancelled', async () => { + const { chooseOutputDirectory } = loadPluginExports() + const updates = [] + const statuses = [] + await chooseOutputDirectory( + { async pickDirectory() { return undefined } }, + (...args) => updates.push(args), + value => statuses.push([value.kind, value.message]), + ) + assert.deepEqual(updates, []) + assert.deepEqual(statuses, [['idle', '']]) +}) + +test('directory picker reports a Host error without changing settings', async () => { + const { chooseOutputDirectory } = loadPluginExports() + const updates = [] + const statuses = [] + await chooseOutputDirectory( + { async pickDirectory() { throw new Error('native dialog unavailable') } }, + (...args) => updates.push(args), + value => statuses.push([value.kind, value.message]), + ) + assert.deepEqual(updates, []) + assert.deepEqual(statuses, [ + ['idle', ''], + ['error', '无法打开目录选择器。'], + ]) +}) diff --git a/plugins/dsh-invoice-downloader/tests/model-bridge.test.mjs b/plugins/dsh-invoice-downloader/tests/model-bridge.test.mjs new file mode 100644 index 0000000..0921995 --- /dev/null +++ b/plugins/dsh-invoice-downloader/tests/model-bridge.test.mjs @@ -0,0 +1,76 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import test from 'node:test' + +const scanner = readFileSync(join(import.meta.dirname, '..', 'src', 'invoice-scanner.ts'), 'utf8') +const storage = readFileSync(join(import.meta.dirname, '..', 'src', 'storage.ts'), 'utf8') + +test('model extraction uses a prepared DSH stream and records its exact input', () => { + assert.match(scanner, /const prepared = await this\.llmContext\.llm\.prepareCall\(/) + assert.match(scanner, /prepared\.stream\(\{ \.\.\.prepared\.config, messages: \[message\] \}\)/) + assert.match(scanner, /new TextResponseAssembler\(\)/) + assert.doesNotMatch(scanner, /\.stream\(\{\}\)/) + assert.match(storage, /createInvoiceUserMessage\(/) + assert.match(storage, /session\?\.append\('user\/message', message, \{ surfaceOp: 'append' \}\)/) +}) + +test('model-visible scan text uses the DSH user-message wire fields without a private runtime import', async () => { + const { ScanSessionManager } = await import('../lib/storage.js') + const appended = [] + const manager = new ScanSessionManager() + manager.setContext({ + sessions: { + create(id) { + return { + id, + append(type, message, options) { appended.push({ type, message, options }) }, + } + }, + }, + }) + const created = manager.createSession('fixture-job') + const message = manager.logModelVisible(created.sessionId, { inputText: 'fixture OCR text' }) + assert.equal(message.role, 'user') + assert.equal(message.source.kind, 'plugin') + assert.equal(message.source.plugin, 'invoice-downloader') + assert.deepEqual(message.content, [{ type: 'text', text: 'fixture OCR text' }]) + assert.equal(appended.length, 1) + assert.equal(appended[0].type, 'user/message') + assert.deepEqual(appended[0].options, { surfaceOp: 'append' }) +}) + +test('model extraction collects streamed text without importing a private DSH runtime package', async () => { + const { InvoiceScanner } = await import('../lib/invoice-scanner.js') + const scanner = new InvoiceScanner({ spawn() { throw new Error('not used') } }, { + pythonPath: 'fixture-python', + engineRoot: 'fixture-engine', + adapterRoot: 'fixture-adapter', + deadlineMs: 1_000, + graceMs: 1, + imapFetchTimeoutSeconds: 1, + }) + scanner.setLlmContext({ + agentDefaultModel: { currentSelection() { return { provider: 'fixture', model: 'fixture-model' } } }, + llm: { + async prepareCall() { + return { + config: {}, + async *stream() { + yield { type: 'text-delta', index: 0, text: '{"seller":"Fixture"}' } + yield { type: 'finish', reason: { kind: 'stop' } } + }, + } + }, + }, + }) + const response = await scanner.extractWithModel( + { requestId: 'fixture-request', ocrText: 'fixture OCR' }, + () => ({ id: 'fixture-message', role: 'user', content: [], source: { kind: 'plugin', plugin: 'invoice-downloader' } }), + ) + assert.deepEqual(response, { + requestId: 'fixture-request', + extractedFields: { seller: 'Fixture' }, + rawModelOutput: '{"seller":"Fixture"}', + }) +}) diff --git a/plugins/dsh-invoice-downloader/tests/subprocess-contract.test.mjs b/plugins/dsh-invoice-downloader/tests/subprocess-contract.test.mjs new file mode 100644 index 0000000..88cff02 --- /dev/null +++ b/plugins/dsh-invoice-downloader/tests/subprocess-contract.test.mjs @@ -0,0 +1,274 @@ +import assert from 'node:assert/strict' +import { existsSync, mkdtempSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { PassThrough, Readable } from 'node:stream' +import test from 'node:test' +import { ArtifactRegistry } from '../lib/file-security.js' +import { EngineIpcClient } from '../lib/ipc-client.js' +import { createInvoiceRpcHandler } from '../lib/rpc-handler.js' +import { resolveRuntimeLayout } from '../lib/runtime.js' + +function handle(stdoutText, exitCode = 0) { + return { + pid: 1, + stdin: new PassThrough(), + stdout: Readable.from([stdoutText]), + stderr: Readable.from([]), + done: Promise.resolve({ exitCode, signal: null }), + terminate() {}, + } +} + +function temporaryRuntime() { + const root = mkdtempSync(join(tmpdir(), 'invoice-dsh-test-')) + const engineRoot = join(root, 'engine') + const pythonPath = join(root, process.platform === 'win32' ? 'python.exe' : 'python') + mkdirSync(join(engineRoot, 'src', 'invoice_engine'), { recursive: true }) + writeFileSync(join(engineRoot, 'src', 'invoice_engine', 'app_api.py'), '') + writeFileSync(pythonPath, '') + return { + root, + engineRoot, + pythonPath, + runtime: { + runtimeRoot: root, + engineRoot, + pythonPath, + bootstrapPython: undefined, + venvRoot: join(root, '.venv'), + adapterRoot: join(root, 'adapter'), + installerPath: join(root, 'install.py'), + healthCheckPath: join(root, 'health.py'), + requirementsPath: join(root, 'requirements.txt'), + bootstrapArgv: ['python'], + }, + } +} + +test('engine subprocess always includes the DSH grace period', async () => { + const calls = [] + const client = new EngineIpcClient( + { + spawn(spec) { + calls.push(spec) + return handle('{"type":"job.result","result":{"status":"completed","invoicesProcessed":1}}\n') + }, + }, + 'C:/runtime/python.exe', + 'C:/runtime/engine', + 'C:/runtime/adapter', + 2_000, + ) + + const result = await client.startJob({ + jobId: 'fixture-job', + email: 'fixture@example.test', + authCode: 'fixture-only', + dateFrom: '2026-01-01', + dateTo: '2026-01-01', + company: 'Fixture', + savePath: 'C:/fixture-output', + ocrProvider: 'local', + imapFetchTimeoutSeconds: 15, + }) + + assert.deepEqual(result, { status: 'completed', invoicesProcessed: 1 }) + assert.equal(calls.length, 1) + assert.equal(calls[0].graceMs, 2_000) + assert.deepEqual(calls[0].stdio, { + stdin: 'pipe', + stdout: 'pipe', + stderr: { maxBytes: 1024 * 1024 }, + }) + assert.deepEqual(calls[0].argv.slice(0, 3), [ + 'C:/runtime/python.exe', + '-c', + 'import runpy, sys; sys.path.insert(0, sys.argv[1]); sys.path.insert(0, sys.argv[2]); sys.path.insert(0, sys.argv[3]); runpy.run_module(sys.argv[4], run_name="__main__")', + ]) +}) + +test('health checks use the same explicit subprocess contract', async () => { + const { root, runtime } = temporaryRuntime() + const calls = [] + const handler = createInvoiceRpcHandler({ + savePath: join(root, 'output'), + profileDir: join(root, 'profile'), + graceMs: 2_000, + runtime, + subprocess: { + spawn(spec) { + calls.push(spec) + return handle('{"ok":true,"checks":[]}\n') + }, + }, + scanner: { isRunning: false, stopScan() {} }, + sessions: {}, + credentials: {}, + artifacts: new ArtifactRegistry(), + }) + + const result = await handler('testConnection', {}, new AbortController().signal) + assert.deepEqual(result, { + ok: true, + value: { engineVersion: '0.1.0', ocrAvailable: true }, + }) + assert.equal(calls.length, 1) + assert.equal(calls[0].graceMs, 2_000) + assert.deepEqual(calls[0].stdio, { + stdin: 'ignore', + stdout: 'pipe', + stderr: { maxBytes: 1024 * 1024 }, + }) +}) + +test('runtime installation selects the newest supported Windows Python Launcher entry', { skip: process.platform !== 'win32' }, async () => { + const { root, runtime } = temporaryRuntime() + const calls = [] + const replies = [ + ' -V:3.14 * C:\\Python314\\python.exe\n -V:3.12 C:\\Python312\\python.exe\n -V:3.13 C:\\Python313\\python.exe\n', + 'installed\n', + '{"ok":true,"checks":[]}\n', + ] + const handler = createInvoiceRpcHandler({ + savePath: join(root, 'output'), + profileDir: join(root, 'profile'), + graceMs: 2_000, + runtime, + subprocess: { + spawn(spec) { + calls.push(spec) + return handle(replies.shift() || '') + }, + }, + scanner: { isRunning: false, stopScan() {} }, + sessions: {}, + credentials: {}, + artifacts: new ArtifactRegistry(), + }) + + const result = await handler('installRuntime', {}, new AbortController().signal) + assert.equal(result.ok, true) + assert.deepEqual(calls[0].argv, ['py', '-0p']) + assert.deepEqual(calls[1].argv.slice(0, 2), ['py', '-3.13']) +}) + +test('runtime state stays outside the package while engine assets remain package-owned', () => { + const layout = resolveRuntimeLayout({ runtimeRoot: 'C:/invoice-dsh-state' }) + assert.equal(layout.runtimeRoot, 'C:/invoice-dsh-state') + assert.match(layout.venvRoot, /invoice-dsh-state[\\/]\.venv$/) + assert.match(layout.engineRoot, /runtime[\\/]engine$/) + assert.doesNotMatch(layout.engineRoot, /invoice-dsh-state/) + assert.doesNotMatch(layout.installerPath, /invoice-dsh-state/) +}) + +test('the default runtime state resolves under the active DSH profile home', () => { + const previous = process.env.DSH_HOME + process.env.DSH_HOME = 'C:/dsh-profile-home' + try { + const layout = resolveRuntimeLayout({}) + assert.match(layout.runtimeRoot, /dsh-profile-home[\\/]invoice-downloader[\\/]runtime$/) + assert.match(layout.venvRoot, /dsh-profile-home[\\/]invoice-downloader[\\/]runtime[\\/]\.venv$/) + } finally { + if (previous === undefined) delete process.env.DSH_HOME + else process.env.DSH_HOME = previous + } +}) + +test('runtime installation repairs a virtual environment that lacks pip', () => { + const installer = readFileSync(join(import.meta.dirname, '..', 'runtime', 'install.py'), 'utf8') + assert.match(installer, /if not pip\.is_file\(\):/) + assert.match(installer, /ensurepip', '--upgrade'/) +}) + +test('settings persist a selected output directory without persisting the credential value', async () => { + const { root, runtime } = temporaryRuntime() + const output = join(root, 'output') + const stored = [] + const handler = createInvoiceRpcHandler({ + savePath: output, + profileDir: join(root, 'profile'), + graceMs: 2_000, + runtime, + subprocess: { spawn() { throw new Error('not used') } }, + scanner: { isRunning: false, stopScan() {} }, + sessions: {}, + credentials: { + async set(ref, value) { stored.push({ ref, value }) }, + async describe() { return { configured: true, writable: true } }, + }, + artifacts: new ArtifactRegistry(), + }) + + const saved = await handler('saveSettings', { + email: 'fixture@example.test', + authCode: 'fixture-only', + dateFrom: '2026-01-01', + dateTo: '2026-01-01', + company: 'Fixture', + savePath: output, + ocrProvider: 'local', + }, new AbortController().signal) + + assert.deepEqual(saved, { + ok: true, + value: { saved: true, hasAuthCode: true, hasGlmApiKey: true }, + }) + assert.equal(stored.length, 1) + const settingsText = readFileSync(join(root, 'profile', 'settings.json'), 'utf8') + assert.match(settingsText, /fixture@example\.test/) + assert.doesNotMatch(settingsText, /fixture-only/) + const reloaded = await handler('getSettings', {}, new AbortController().signal) + assert.equal(reloaded.ok, true) + assert.equal(reloaded.value.savePath, realpathSync(output)) + assert.equal(reloaded.value.hasAuthCode, true) + assert.equal(reloaded.value.authCode, undefined) +}) + +test('settings reject a relative output directory before writing anything', async () => { + const { root, runtime } = temporaryRuntime() + const profileDir = join(root, 'profile') + const handler = createInvoiceRpcHandler({ + savePath: '', + profileDir, + graceMs: 2_000, + runtime, + subprocess: { spawn() { throw new Error('not used') } }, + scanner: { isRunning: false, stopScan() {} }, + sessions: {}, + credentials: {}, + artifacts: new ArtifactRegistry(), + }) + + const saved = await handler('saveSettings', { + email: 'fixture@example.test', + savePath: 'relative-output', + }, new AbortController().signal) + + assert.deepEqual(saved, { + ok: false, + error: { + code: 'bad-request', + message: '保存路径必须是绝对路径。', + details: { issues: [] }, + }, + }) + assert.equal(existsSync(join(profileDir, 'settings.json')), false) +}) + +test('artifact access is bound to the selected output root', () => { + const root = mkdtempSync(join(tmpdir(), 'invoice-dsh-artifact-')) + const output = join(root, 'output') + const outside = join(root, 'outside') + mkdirSync(output) + mkdirSync(outside) + const exportPath = join(output, 'summary.xlsx') + const outsidePath = join(outside, 'summary.xlsx') + writeFileSync(exportPath, 'fixture') + writeFileSync(outsidePath, 'fixture') + const artifacts = new ArtifactRegistry() + const artifact = artifacts.registerArtifact('fixture-job', exportPath, output) + assert.equal(artifacts.readArtifact(artifact.artifactId, 'fixture-job').ok, true) + assert.equal(artifacts.readArtifact(artifact.artifactId, 'another-job').ok, false) + assert.throws(() => artifacts.registerArtifact('fixture-job', outsidePath, output), /authorized output directory/) +}) diff --git a/plugins/dsh-invoice-downloader/tsconfig.json b/plugins/dsh-invoice-downloader/tsconfig.json new file mode 100644 index 0000000..7f93758 --- /dev/null +++ b/plugins/dsh-invoice-downloader/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noImplicitAny": true, + "skipLibCheck": true, + "outDir": "./lib", + "rootDir": "./src", + "declaration": true, + "declarationMap": false, + "sourceMap": false + }, + "include": ["src/**/*.ts", "src/**/*.d.ts"], + "exclude": ["node_modules", "lib"] +}