Explorar el Código

feat(api): 支持参考投书双目录查找

wangxi hace 2 semanas
padre
commit
bc09bf2e99
Se han modificado 5 ficheros con 137 adiciones y 9 borrados
  1. 2 0
      .env.example
  2. 14 0
      CHANGELOG.md
  3. 8 5
      README.md
  4. 94 0
      scripts/tests/test_api.py
  5. 19 4
      src/http_api.py

+ 2 - 0
.env.example

@@ -25,6 +25,8 @@ DEEPSEEK_MODEL=deepseek-v4-flash
  PROPOSA_WORK_DIR=/data/model/shenqin_data
 # HTTP API JSON 模式下参考投标文件相对路径的全局目录。
  REFERENCE_DIR=/data/shenqin/
+# 第二个参考投标文件目录;相对路径在 REFERENCE_DIR 未找到时继续检查此目录。
+ REFERENCE_DIR1=/data/dms/dms_upload
 # HTTP API JSON 模式下招标文件、采购需求和澄清公告相对路径的统一目录。
  TPC_DIR=/data/shenqin/
 # 单个上传文件的大小上限,单位 MB。

+ 14 - 0
CHANGELOG.md

@@ -2,6 +2,20 @@
 
 本文件记录 Proposa AI 的正式发布与上线代码基线。日期采用 Asia/Shanghai 时区。
 
+## 2026-08-31
+
+### 变更
+
+- JSON 请求的 `REFERENCE_IS_ABSOLUTE=false` 时,参考投书相对路径由单目录拼接改为依次在 `REFERENCE_DIR`、`REFERENCE_DIR1` 下检查文件是否存在。
+- 新增第二候选目录配置 `REFERENCE_DIR1=/data/dms/dms_upload`;首选目录不存在目标文件时自动回退到第二目录,两处都存在时仍优先使用 `REFERENCE_DIR`。
+- 两个候选目录均未配置或均不存在目标文件时,在创建任务和启动 worker 前返回 HTTP 400。
+- README、`.env.example` 和 API 开发文档已同步双目录配置与查找规则。
+
+### 验证记录
+
+- API 专项测试 30 项全部通过,覆盖首选目录、第二目录回退、两处同时存在和两处均缺失场景。
+- 完整快速回归 188 项全部通过。
+
 ## 2026-08-28
 
 发布基线:由 `wangxi` 提升至 `master`,并保持两个分支代码一致。

+ 8 - 5
README.md

@@ -77,7 +77,8 @@ DEEPSEEK_API_KEY=你的密钥
 | `PROPOSA_API_HOST` | 否 | `0.0.0.0` | HTTP API 监听地址;默认监听所有网卡。 |
 | `PROPOSA_API_PORT` | 否 | `8000` | HTTP API 监听端口。 |
 | `PROPOSA_WORK_DIR` | 否 | `output/api_jobs` | HTTP API 和 test_step1-6 默认工作目录;每个请求的内容放在 `PROPOSA_WORK_DIR/<request_id>/` 下,中间文件在 `work/`,最终 DOCX 在请求目录根。 |
-| `REFERENCE_DIR` | 否 | `/data/shenqin/` | JSON 模式下参考投标文件相对路径的全局目录。 |
+| `REFERENCE_DIR` | 否 | `/data/shenqin/` | JSON 模式下参考投标文件相对路径的首选目录。 |
+| `REFERENCE_DIR1` | 否 | `/data/dms/dms_upload` | JSON 模式下参考投标文件相对路径的第二候选目录。 |
 | `TPC_DIR` | 否 | 无 | JSON 模式下招标文件、采购需求和澄清公告相对路径的拼接前缀。 |
 | `PROPOSA_API_MAX_UPLOAD_MB` | 否 | `512` | 单个上传文件的大小上限,单位 MB。 |
 | `PROPOSA_API_TIMEOUT_SECONDS` | 否 | `28800` | Step1-6 后台子进程超时秒数,默认 8 小时。 |
@@ -102,7 +103,7 @@ cp .env.example .env
 ```
 
 编辑 `.env` 填写真实配置,至少设置 `DEEPSEEK_API_KEY`;`PROPOSA_API_PORT`、
-`PROPOSA_WORK_DIR`、`REFERENCE_DIR` 等可按需调整。
+`PROPOSA_WORK_DIR`、`REFERENCE_DIR`、`REFERENCE_DIR1` 等可按需调整。
 
 ### 执行部署
 
@@ -190,15 +191,17 @@ curl.exe -X POST "http://127.0.0.1:8000/api/v1/final-review" `
 | `CLA_FILE` | 否 | 澄清公告文件;当前仅接收并记录,不参与生成。 |
 | `TPC_IS_ABSOLUTE` | JSON 是 | 仅 JSON 路径模式生效,必须传布尔值;`false` 时 TENDER_FILE、PROCUREMENT_FILE、非空 CLA_FILE 与 `TPC_DIR` 拼接,`true` 时原样读取。接口不会根据路径文本自行判断。 |
 | `CLEAN_INTERMEDIATE` | 否 | 默认 `true`;设为 `false` 时保留中间文件并返回 `intermediateDir`。 |
-| `REFERENCE_IS_ABSOLUTE` | JSON 是 | 仅 JSON 路径模式生效,必须传布尔值;`false` 时 `REFERENCE_BID` 与 `REFERENCE_DIR` 拼接,`true` 时原样读取。接口不会根据路径文本自行判断。 |
+| `REFERENCE_IS_ABSOLUTE` | JSON 是 | 仅 JSON 路径模式生效,必须传布尔值;`false` 时依次在 `REFERENCE_DIR`、`REFERENCE_DIR1` 下查找 `REFERENCE_BID`,`true` 时原样读取。接口不会根据路径文本自行判断。 |
 
 ### REFERENCE_BID 路径处理
 
 JSON 模式下:
 
 - `REFERENCE_BID` 为绝对路径时直接使用。
-- `REFERENCE_BID` 为相对路径时,设置 `"REFERENCE_IS_ABSOLUTE": false`,接口按
-  `REFERENCE_DIR + REFERENCE_BID` 拼接为绝对路径,再复制到隔离任务目录。
+- `REFERENCE_BID` 为相对路径时,设置 `"REFERENCE_IS_ABSOLUTE": false`,接口依次按
+  `REFERENCE_DIR + REFERENCE_BID`、`REFERENCE_DIR1 + REFERENCE_BID` 拼接并检查文件;
+  首选实际存在的文件,再复制到隔离任务目录。两处都存在时优先使用 `REFERENCE_DIR`。
+- 两个候选目录均未配置,或配置后均不存在目标文件时,接口在启动 worker 前返回 400。
 - 接口只按 `REFERENCE_IS_ABSOLUTE` 的布尔值处理,不根据路径是否以 `/` 开头自行判断;
   相对模式下会先去掉文件值开头的路径分隔符再与配置目录拼接。
 

+ 94 - 0
scripts/tests/test_api.py

@@ -425,6 +425,100 @@ class FinalReviewApiTests(unittest.TestCase):
             worker.assert_not_called()
             shutil.rmtree(root / "jobs", ignore_errors=True)
 
+    def test_relative_reference_bid_prefers_reference_dir_when_both_exist(self):
+        with tempfile.TemporaryDirectory() as temp_dir:
+            root = Path(temp_dir)
+            reference_dir = root / "reference_root"
+            reference_dir1 = root / "dms_upload"
+            reference_dir.mkdir()
+            reference_dir1.mkdir()
+            primary = reference_dir / "reference.docx"
+            secondary = reference_dir1 / "reference.docx"
+            primary.write_bytes(_docx_bytes("primary"))
+            secondary.write_bytes(_docx_bytes("secondary"))
+
+            with patch.object(http_api, "REFERENCE_DIR", str(reference_dir)), patch.object(
+                http_api, "REFERENCE_DIR1", str(reference_dir1)
+            ):
+                resolved = http_api._resolve_reference_path(
+                    "/reference.docx", is_absolute=False
+                )
+
+            self.assertEqual(Path(resolved), primary)
+
+    def test_json_relative_reference_bid_falls_back_to_reference_dir1(self):
+        with tempfile.TemporaryDirectory() as temp_dir:
+            root = Path(temp_dir)
+            reference_dir = root / "reference_root"
+            reference_dir1 = root / "dms_upload"
+            reference_dir.mkdir()
+            reference_dir1.mkdir()
+            reference_file = reference_dir1 / "参考投书.docx"
+            reference_file.write_bytes(_docx_bytes("reference-dir1"))
+            tender = root / "招标文件.pdf"
+            procurement = root / "采购需求.docx"
+            tender.write_bytes(_pdf_bytes())
+            procurement.write_bytes(_docx_bytes("procurement"))
+            executor = RecordingExecutor()
+            payload = {
+                "CALLBACK_URL": "http://127.0.0.1:9999/callback",
+                "txbId": 1,
+                "TENDER_FILE": str(tender),
+                "PROCUREMENT_FILE": str(procurement),
+                "TPC_IS_ABSOLUTE": True,
+                "REFERENCE_BID": "/参考投书.docx",
+                "REFERENCE_IS_ABSOLUTE": False,
+                "CLA_FILE": "",
+            }
+            with patch.object(http_api, "REFERENCE_DIR", str(reference_dir)), patch.object(
+                http_api, "REFERENCE_DIR1", str(reference_dir1)
+            ), patch.object(http_api, "API_WORK_ROOT", root / "jobs"), patch.object(
+                http_api, "_JOB_EXECUTOR", executor
+            ), patch.object(http_api, "_run_pipeline_worker") as worker:
+                response = TestClient(http_api.app).post(
+                    "/api/v1/final-review",
+                    json=payload,
+                )
+                function, args = executor.submitted[0]
+                job = args[0]
+
+            self.assertEqual(response.status_code, 202)
+            self.assertEqual(
+                job["files"]["REFERENCE_BID"]["filename"], reference_file.name
+            )
+            worker.assert_not_called()
+            shutil.rmtree(root / "jobs", ignore_errors=True)
+
+    def test_json_relative_reference_bid_rejects_when_both_candidates_missing(self):
+        with tempfile.TemporaryDirectory() as temp_dir:
+            root = Path(temp_dir)
+            reference_dir = root / "reference_root"
+            reference_dir1 = root / "dms_upload"
+            reference_dir.mkdir()
+            reference_dir1.mkdir()
+            with patch.object(http_api, "REFERENCE_DIR", str(reference_dir)), patch.object(
+                http_api, "REFERENCE_DIR1", str(reference_dir1)
+            ), patch.object(http_api, "_JOB_EXECUTOR") as executor:
+                response = TestClient(http_api.app).post(
+                    "/api/v1/final-review",
+                    json={
+                        "CALLBACK_URL": "http://127.0.0.1:9999/callback",
+                        "txbId": "missing-reference",
+                        "TENDER_FILE": "tender.pdf",
+                        "PROCUREMENT_FILE": "procurement.docx",
+                        "TPC_IS_ABSOLUTE": True,
+                        "REFERENCE_BID": "/missing.docx",
+                        "REFERENCE_IS_ABSOLUTE": False,
+                    },
+                )
+
+            self.assertEqual(response.status_code, 400)
+            self.assertEqual(
+                response.json()["detail"],
+                "REFERENCE_BID 在 REFERENCE_DIR 和 REFERENCE_DIR1 下均不存在: missing.docx",
+            )
+            executor.submit.assert_not_called()
+
     def test_invalid_tpc_is_absolute_value_is_rejected(self):
         with patch.object(http_api, "_JOB_EXECUTOR") as executor:
             response = TestClient(http_api.app).post(

+ 19 - 4
src/http_api.py

@@ -38,6 +38,7 @@ if not API_WORK_ROOT.is_absolute():
 else:
     API_WORK_ROOT = API_WORK_ROOT.resolve()
 REFERENCE_DIR = os.getenv("REFERENCE_DIR", "").strip()
+REFERENCE_DIR1 = os.getenv("REFERENCE_DIR1", "").strip()
 TPC_DIR = os.getenv("TPC_DIR", "").strip()
 
 MAX_UPLOAD_BYTES = int(os.getenv("PROPOSA_API_MAX_UPLOAD_MB", "512")) * 1024 * 1024
@@ -208,15 +209,29 @@ def _parse_path_is_absolute(value, field_name: str) -> bool:
 
 
 def _resolve_reference_path(reference_value: str, is_absolute: bool) -> str:
-    """严格按 REFERENCE_IS_ABSOLUTE 决定是否拼接 REFERENCE_DIR。"""
+    """按配置顺序查找相对参考投书,绝对路径模式原样返回。"""
     if is_absolute:
         return reference_value
-    if not REFERENCE_DIR:
+    reference_dirs = [
+        directory for directory in (REFERENCE_DIR, REFERENCE_DIR1) if directory
+    ]
+    if not reference_dirs:
         raise HTTPException(
             status_code=400,
-            detail="REFERENCE_BID 为相对路径,但未配置 REFERENCE_DIR",
+            detail="REFERENCE_BID 为相对路径,但未配置 REFERENCE_DIR 或 REFERENCE_DIR1",
         )
-    return os.path.join(REFERENCE_DIR, reference_value.lstrip("/\\"))
+    relative_value = reference_value.lstrip("/\\")
+    candidates = [Path(directory) / relative_value for directory in reference_dirs]
+    for candidate in candidates:
+        if candidate.is_file():
+            return str(candidate)
+    raise HTTPException(
+        status_code=400,
+        detail=(
+            "REFERENCE_BID 在 REFERENCE_DIR 和 REFERENCE_DIR1 下均不存在: "
+            f"{relative_value}"
+        ),
+    )
 
 
 def _resolve_tpc_path(path_value: str, is_absolute: bool, field_name: str) -> str: