diff --git a/_bim-control/app/main.py b/_bim-control/app/main.py index fbd902d14..33055016a 100644 --- a/_bim-control/app/main.py +++ b/_bim-control/app/main.py @@ -664,7 +664,7 @@ def _update_artifacts_from_conversion(data_root: Path, model_version_id: str, re "project_id": project_id, "model_version_id": model_version_id, "artifact_type": "ifc", - "name": "原始 IFC", + "name": str(result.get("original_filename") or "原始 IFC"), "url": source_url, "mapping_url": None, "status": "ready", diff --git a/_bim-control/tests/test_conversion_results_api.py b/_bim-control/tests/test_conversion_results_api.py index 38dad76e4..603008b68 100644 --- a/_bim-control/tests/test_conversion_results_api.py +++ b/_bim-control/tests/test_conversion_results_api.py @@ -41,3 +41,47 @@ def test_conversion_result_is_stored_and_reloaded(case_dir: Path): assert usdc_artifacts assert usdc_artifacts[0]["status"] == "ready" assert usdc_artifacts[0]["url"] == payload["usdc_url"] + + +def test_conversion_result_uses_original_filename_for_source_artifact_name(case_dir: Path): + client = TestClient(create_app(data_root=case_dir / "data")) + original_filename = "許良宇圖書館建築_2026.ifc" + payload = { + "job_id": "conv_test_001", + "status": "succeeded", + "project_id": "project_demo_001", + "model_version_id": "version_demo_001", + "source_artifact_id": "artifact_src_test_001", + "source_url": "http://localhost:8005/objects/source.ifc", + "usdc_url": "http://localhost:8005/objects/model.usdc", + "mapping_url": "http://localhost:8005/objects/element_mapping.json", + "original_filename": original_filename, + } + + response = client.post("/api/model-versions/version_demo_001/conversion-result", json=payload) + + assert response.status_code == 200 + artifacts = client.get("/api/model-versions/version_demo_001/artifacts").json()["items"] + source = next(item for item in artifacts if item["artifact_id"] == "artifact_src_test_001") + assert source["name"] == original_filename + + +def test_conversion_result_source_artifact_name_falls_back_without_original_filename(case_dir: Path): + client = TestClient(create_app(data_root=case_dir / "data")) + payload = { + "job_id": "conv_test_001", + "status": "succeeded", + "project_id": "project_demo_001", + "model_version_id": "version_demo_001", + "source_artifact_id": "artifact_src_test_001", + "source_url": "http://localhost:8005/objects/source.ifc", + "usdc_url": "http://localhost:8005/objects/model.usdc", + "mapping_url": "http://localhost:8005/objects/element_mapping.json", + } + + response = client.post("/api/model-versions/version_demo_001/conversion-result", json=payload) + + assert response.status_code == 200 + artifacts = client.get("/api/model-versions/version_demo_001/artifacts").json()["items"] + source = next(item for item in artifacts if item["artifact_id"] == "artifact_src_test_001") + assert source["name"] == "原始 IFC" diff --git a/_worker/README.md b/_worker/README.md index a496a7032..9fc15a25a 100644 --- a/_worker/README.md +++ b/_worker/README.md @@ -31,4 +31,8 @@ GET /objects/{path} `POST /api/artifacts` accepts either `content_base64`, `content_text`, `source_url`, or `signed_upload_url` plus lineage fields. File bytes are stored under `data/objects/tenants/...`. +Source artifact responses, source metadata, source index entries, and completed conversion results include `original_filename`, preserving the raw uploaded or selected IFC filename while keeping the on-disk object name sanitized for path safety. + `POST /api/conversions` creates a queued job. With the default `run_background=true`, FastAPI schedules an inline local adapter conversion that writes deterministic demo `model.usdc`, index JSON, mapping JSON, and `metadata.json`, then posts metadata to `_bim-control`. + +The default worker adapter output is not a Kit-ready geometry conversion. Real IFC -> USDC validation uses the Kit/HOOPS converter in `bim-streaming-server/scripts/convert-ifc-to-usdc.ps1` and the stage inspection helper in `bim-streaming-server/scripts/inspect-usd-stage-and-quit.py`. The opt-in pytest smoke `test_real_ifc_files_convert_to_kit_openable_usdc_when_enabled` runs only when `WORKER_RUN_REAL_USDC_SMOKE=1` is set. diff --git a/_worker/app/main.py b/_worker/app/main.py index f259618e9..8b9dda2fc 100644 --- a/_worker/app/main.py +++ b/_worker/app/main.py @@ -147,6 +147,7 @@ def create_conversion_from_ifc_source( "conversion_job_id": job["conversion_job_id"], "job_id": job["job_id"], "status": job["status"], + "original_filename": source_artifact["original_filename"], "conversion_url": f"/api/conversions/{job['conversion_job_id']}", "result_url": f"/api/conversions/{job['conversion_job_id']}/result", "readiness_url": f"/api/artifact-groups/{source_artifact['artifact_group_id']}/readiness", diff --git a/_worker/app/store.py b/_worker/app/store.py index 5016845e7..5408d9261 100644 --- a/_worker/app/store.py +++ b/_worker/app/store.py @@ -56,6 +56,7 @@ def create_source_artifact(self, request: ArtifactIntakeRequest) -> dict[str, An source_system = safe_id(request.source_system, "source_system") artifact_group_id = safe_id(request.artifact_group_id or f"ag_{uuid4().hex[:12]}", "artifact_group_id") source_artifact_id = f"artifact_src_{uuid4().hex[:12]}" + original_filename = request.filename filename = safe_filename(request.filename) content = self._content_bytes(request) sha256 = hashlib.sha256(content).hexdigest() @@ -88,6 +89,7 @@ def create_source_artifact(self, request: ArtifactIntakeRequest) -> dict[str, An "model_version_id": model_version_id, "source_system": source_system, "source_format": request.source_format, + "original_filename": original_filename, "sha256": sha256, "version_no": 1, "uploaded_by": request.uploaded_by, @@ -109,6 +111,7 @@ def create_source_artifact(self, request: ArtifactIntakeRequest) -> dict[str, An "project_id": project_id, "model_version_id": model_version_id, "sha256": sha256, + "original_filename": original_filename, "object_key": object_key.as_posix(), "object_url": self.object_url(object_key.as_posix()), "status": "uploaded", @@ -248,6 +251,7 @@ def complete_conversion_job(self, conversion_job_id: str) -> dict[str, Any]: "model_version_id": job["model_version_id"], "source_artifact_id": job["source_artifact_id"], "usdc_artifact_id": usdc_artifact_id, + "original_filename": source["metadata"].get("original_filename"), "derived_artifact_ids": { "model_usdc": usdc_artifact_id, "ifc_index": f"artifact_ifc_index_{conversion_job_id.removeprefix('conv_')}", @@ -362,6 +366,7 @@ def _upsert_source_index(self, source_artifact_id: str, metadata: dict[str, Any] { "source_artifact_id": source_artifact_id, "artifact_group_id": metadata["artifact_group_id"], + "original_filename": metadata.get("original_filename"), "object_key": object_key.as_posix(), "object_url": self.object_url(object_key.as_posix()), "metadata": metadata, diff --git a/_worker/tests/test_worker_api.py b/_worker/tests/test_worker_api.py index 401ea8ea7..a3837cb5e 100644 --- a/_worker/tests/test_worker_api.py +++ b/_worker/tests/test_worker_api.py @@ -1,4 +1,8 @@ import base64 +import json +import os +import shutil +import subprocess import sys from pathlib import Path from uuid import uuid4 @@ -32,6 +36,16 @@ def make_client(case_dir: Path, run_background: bool = True) -> TestClient: return TestClient(create_app(settings=settings, run_background=run_background)) +def real_ifc_storage_root() -> Path: + return Path(os.getenv("WORKER_REAL_IFC_STORAGE_ROOT", r"C:\Repos\active\iot\AI-BIM-governance\storage")) + + +def real_streaming_root() -> Path: + return Path( + os.getenv("WORKER_REAL_STREAMING_ROOT", r"C:\Repos\active\iot\AI-BIM-governance\bim-streaming-server") + ) + + def source_payload(**overrides): payload = { "tenant_id": "tenant_demo_001", @@ -64,6 +78,28 @@ def test_source_artifact_upload_writes_versioned_object_layout(case_dir: Path): assert b"ISO-10303-21" in object_response.content +def test_source_artifact_upload_preserves_original_filename_metadata_index_and_response(case_dir: Path): + client = make_client(case_dir) + original_filename = "許良宇圖書館建築_2026 - 複製 (1).ifc" + + response = client.post("/api/artifacts", json=source_payload(filename=original_filename)) + + assert response.status_code == 200 + body = response.json() + assert body["original_filename"] == original_filename + assert body["metadata"]["original_filename"] == original_filename + assert original_filename not in body["object_key"] + + object_path = case_dir / "objects" / Path(*body["object_key"].split("/")) + metadata = json.loads((object_path.parents[1] / "metadata.json").read_text(encoding="utf-8")) + assert metadata["original_filename"] == original_filename + + index = json.loads((case_dir / "objects" / "_index" / "source_artifacts.json").read_text(encoding="utf-8")) + entry = next(item for item in index["items"] if item["source_artifact_id"] == body["source_artifact_id"]) + assert entry["original_filename"] == original_filename + assert entry["metadata"]["original_filename"] == original_filename + + def test_object_download_allows_local_viewer_origin(case_dir: Path): client = make_client(case_dir) artifact = client.post("/api/artifacts", json=source_payload()).json() @@ -148,6 +184,171 @@ def test_selected_dev_ifc_source_creates_artifact_and_conversion_job(case_dir: P assert result.json()["status"] == "succeeded" +def test_selected_dev_ifc_source_conversion_preserves_original_filename(case_dir: Path): + storage = case_dir / "storage" + storage.mkdir() + original_filename = "許良宇圖書館建築_2026 - 複製 (2).ifc" + (storage / original_filename).write_text("ISO-10303-21;\nEND-ISO-10303-21;\n", encoding="utf-8") + client = make_client(case_dir) + source = client.get("/api/dev/ifc-sources").json()["items"][0] + + response = client.post( + f"/api/dev/ifc-sources/{source['source_id']}/conversions", + json={ + "tenant_id": "tenant_demo_001", + "project_id": "project_demo_001", + "model_version_id": "version_demo_001", + "source_system": "dev_storage", + "uploaded_by": "dev_user_001", + }, + ) + + assert response.status_code == 200 + body = response.json() + assert body["source"]["filename"] == original_filename + assert body["original_filename"] == original_filename + + result = client.get(body["result_url"]) + assert result.status_code == 200 + assert result.json()["original_filename"] == original_filename + + +def test_real_ifc_inputs_preserve_filename_through_worker_adapter_conversions(case_dir: Path): + """Uses real IFC bytes with the current worker adapter, not a Kit-ready USDC converter.""" + real_storage = real_ifc_storage_root() + real_ifc_files = sorted(real_storage.glob("*.ifc"), key=lambda path: path.name.casefold()) + if len(real_ifc_files) < 2: + pytest.skip(f"Need at least two real IFC files under {real_storage}.") + + storage = case_dir / "storage" + storage.mkdir() + selected_files = real_ifc_files[:2] + for source_file in selected_files: + shutil.copy2(source_file, storage / source_file.name) + + client = make_client(case_dir) + sources = client.get("/api/dev/ifc-sources").json()["items"] + assert [source["filename"] for source in sources] == [source_file.name for source_file in selected_files] + + conversions = [] + for index, source in enumerate(sources): + response = client.post( + f"/api/dev/ifc-sources/{source['source_id']}/conversions", + json={ + "tenant_id": "tenant_demo_001", + "project_id": "project_demo_001", + "model_version_id": "version_demo_001", + "source_system": "dev_storage", + "uploaded_by": "dev_user_001", + "artifact_group_id": f"ag_real_ifc_{index}", + }, + ) + + assert response.status_code == 200 + body = response.json() + result = client.get(body["result_url"]) + assert result.status_code == 200 + conversions.append((source, body, result.json())) + + assert len({body["source_artifact_id"] for _, body, _ in conversions}) == len(selected_files) + assert len({body["conversion_job_id"] for _, body, _ in conversions}) == len(selected_files) + assert len({result["source_url"] for _, _, result in conversions}) == len(selected_files) + + index = json.loads((case_dir / "objects" / "_index" / "source_artifacts.json").read_text(encoding="utf-8")) + index_by_artifact = {item["source_artifact_id"]: item for item in index["items"]} + for source, body, result in conversions: + assert body["original_filename"] == source["filename"] + assert result["status"] == "succeeded" + assert result["original_filename"] == source["filename"] + assert result["usdc_url"].endswith("/model.usdc") + assert index_by_artifact[body["source_artifact_id"]]["original_filename"] == source["filename"] + assert source["filename"] not in result["lineage"]["source_object_key"] + + +def test_real_ifc_files_convert_to_kit_openable_usdc_when_enabled(case_dir: Path): + if os.getenv("WORKER_RUN_REAL_USDC_SMOKE") != "1": + pytest.skip("Set WORKER_RUN_REAL_USDC_SMOKE=1 to run the Kit/HOOPS IFC-to-USDC smoke test.") + + storage_root = real_ifc_storage_root() + streaming_root = real_streaming_root() + build_root = streaming_root / "_build" / "windows-x86_64" / "release" + converter_script = streaming_root / "scripts" / "convert-ifc-to-usdc.ps1" + inspect_script = streaming_root / "scripts" / "inspect-usd-stage-and-quit.py" + kit_exe = build_root / "kit" / "kit.exe" + + for required_path in (storage_root, converter_script, inspect_script, kit_exe): + assert required_path.exists(), f"Required real converter path is missing: {required_path}" + + real_ifc_files = sorted(storage_root.glob("*.ifc"), key=lambda path: path.name.casefold()) + assert len(real_ifc_files) >= 2, f"Need at least two real IFC files under {storage_root}." + + powershell = shutil.which("pwsh") or shutil.which("powershell") + assert powershell, "PowerShell is required to run convert-ifc-to-usdc.ps1." + + output_dir = case_dir / "real-usdc" + output_dir.mkdir() + for source_file in real_ifc_files[:2]: + conversion = subprocess.run( + [ + powershell, + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + str(converter_script), + "-IfcPath", + str(source_file), + "-OutputDir", + str(output_dir), + "-KitExePath", + str(kit_exe), + "-TimeoutSeconds", + "900", + "-Force", + ], + cwd=streaming_root, + capture_output=True, + text=True, + timeout=960, + check=False, + ) + assert conversion.returncode == 0, conversion.stdout + conversion.stderr + + output_path = output_dir / f"{source_file.stem}.usdc" + assert output_path.is_file() + assert output_path.stat().st_size > 1024 * 1024 + + inspect_output = output_dir / f"{source_file.stem}.stage.json" + stage_check = subprocess.run( + [ + str(kit_exe), + "--ext-folder", + str(build_root / "exts"), + "--ext-folder", + str(build_root / "extscache"), + "--ext-folder", + str(build_root / "apps"), + "--no-window", + "--enable", + "omni.usd", + "--exec", + f'"{inspect_script}" --usd-path "{output_path}" --output-path "{inspect_output}"', + "--/app/fastShutdown=1", + "--info", + ], + cwd=streaming_root, + capture_output=True, + text=True, + timeout=300, + check=False, + ) + assert stage_check.returncode == 0, stage_check.stdout + stage_check.stderr + + stage = json.loads(inspect_output.read_text(encoding="utf-8")) + assert stage["prim_count"] > 0 + assert stage["root_layer"].replace("\\", "/").endswith(f"{source_file.stem}.usdc") + + def test_selected_dev_ifc_source_rejects_stale_source_id(case_dir: Path): storage = case_dir / "storage" storage.mkdir() diff --git a/_worker/tests/test_worker_store.py b/_worker/tests/test_worker_store.py index 9b9d1a9e3..d65126266 100644 --- a/_worker/tests/test_worker_store.py +++ b/_worker/tests/test_worker_store.py @@ -3,6 +3,7 @@ import json import sys from pathlib import Path +from uuid import uuid4 import pytest @@ -299,6 +300,19 @@ def test_store_create_source_artifact_stores_sha256_in_metadata(tmp_path: Path): assert result["sha256"] == expected +def test_store_create_source_artifact_preserves_original_filename_in_metadata(): + store_root = Path(__file__).resolve().parents[1] / "pytest-cache-files-store" / uuid4().hex + store_root.mkdir(parents=True, exist_ok=False) + store = make_store(store_root) + original_filename = "許良宇圖書館建築_2026.ifc" + req = make_intake_request(filename=original_filename) + + result = store.create_source_artifact(req) + + assert result["original_filename"] == original_filename + assert result["metadata"]["original_filename"] == original_filename + + def test_store_create_source_artifact_content_base64_decodes_correctly(tmp_path: Path): store = make_store(tmp_path) raw = b"ISO-10303-21;\nEND-ISO-10303-21;\n" @@ -413,4 +427,4 @@ def test_store_object_url_constructs_from_public_base(tmp_path: Path): def test_store_object_url_strips_leading_slash(tmp_path: Path): store = make_store(tmp_path) url = store.object_url("/tenants/t1/model.usdc") - assert url == "http://testserver/objects/tenants/t1/model.usdc" \ No newline at end of file + assert url == "http://testserver/objects/tenants/t1/model.usdc" diff --git a/docs/contracts/worker-api.md b/docs/contracts/worker-api.md index ce79b2328..929b44fcd 100644 --- a/docs/contracts/worker-api.md +++ b/docs/contracts/worker-api.md @@ -93,6 +93,7 @@ Response: "artifact_group_id": "ag_xxx", "conversion_job_id": "conv_20260507000000_xxxxxxxx", "status": "queued", + "original_filename": "sample.ifc", "result_url": "http://127.0.0.1:8005/api/conversions/conv_.../result", "readiness_url": "http://127.0.0.1:8005/api/artifact-groups/ag_xxx/readiness" } @@ -120,6 +121,7 @@ Response: "source_artifact_id": "artifact_src_xxx", "artifact_group_id": "ag_xxx", "sha256": "...", + "original_filename": "source.ifc", "object_key": "tenants/tenant_demo_001/projects/project_demo_001/versions/version_demo_001/artifact-groups/ag_xxx/source/revit/artifact_src_xxx/original/abcd1234_source.ifc", "object_url": "http://127.0.0.1:8005/objects/tenants/tenant_demo_001/...", "status": "uploaded" @@ -128,6 +130,11 @@ Response: The request must include `tenant_id`, `project_id`, `model_version_id`, `source_system`, uploader identity, filename, and either `content_base64`, `content_text`, `source_url`, or `signed_upload_url`. Missing lineage is rejected so orphan artifacts are not created. +The worker preserves the raw client-provided `filename` as `original_filename` +in source artifact metadata, the source artifact index, source artifact +responses, conversion results, and the `_bim-control` conversion-result callback. +The on-disk object name still uses a sanitized filename for path safety. + ## Conversion Job ```json @@ -163,6 +170,7 @@ Succeeded result: "artifact_group_id": "ag_xxx", "source_artifact_id": "artifact_src_xxx", "usdc_artifact_id": "artifact_usdc_20260507000000_xxxxxxxx", + "original_filename": "source.ifc", "usdc_url": "http://127.0.0.1:8005/objects/tenants/.../derived/conv_.../usdc/model.usdc", "ifc_index_url": "http://127.0.0.1:8005/objects/tenants/.../ifc_index.json", "usd_index_url": "http://127.0.0.1:8005/objects/tenants/.../usd_index.json", diff --git a/openspec/changes/add-worker-original-filename-tracking/design.md b/openspec/changes/add-worker-original-filename-tracking/design.md new file mode 100644 index 000000000..f89d22b4c --- /dev/null +++ b/openspec/changes/add-worker-original-filename-tracking/design.md @@ -0,0 +1,55 @@ +## Context + +`_worker` 目前會把 source artifact 的 on-disk filename 透過 `safe_filename()` sanitize,以避免路徑安全問題;但 metadata、source index、conversion result 與 callback payload 都沒有保留 raw filename。當檔名包含中文、空格或括號時,sanitize 後的 disk object key 無法可靠還原使用者原始檔名。 + +這次變更跨 `_worker` 與 `_bim-control`。`_worker` 仍是檔案本體與轉檔結果的 source of truth,`_bim-control` 仍是 fake BIM artifact metadata 的 authority。新增欄位只在 metadata / API payload 層流動,不改 disk path、防護邏輯、artifact id 或轉檔流程。 + +## Goals / Non-Goals + +**Goals:** + +- 在 `_worker` source artifact metadata、source index、API response、conversion result 與 `_bim-control` callback payload 中保留 raw `original_filename`。 +- 讓 `_bim-control` 在 callback 帶有 `original_filename` 時,把 source IFC artifact `name` 顯示為原始檔名。 +- 保持向前相容:舊 metadata / callback payload 沒有 `original_filename` 時仍可讀取並保留既有 fallback。 + +**Non-Goals:** + +- 不修改 `safe_filename()` 與 on-disk object key 命名規則。 +- 不更動 `source_id`、artifact id、artifact group id 或 conversion job id 的計算方式。 +- 不搬移或重新命名既有落地檔案。 +- 不要求 `web-viewer-sample`、`bim-review-coordinator` 或 `bim-streaming-server` 同步改 UI / runtime 行為。 + +## Decisions + +- **保留 raw filename 作為 additive metadata 欄位。** + 選擇在 `metadata.json`、`_index/source_artifacts.json`、API response 與 conversion result 中加入 `original_filename`,而不是改 disk filename。這讓 traceability 回來,同時不降低 path safety。 + +- **由 `_worker` result payload 自然承載 callback 欄位。** + `_post_bim_control_result()` 已直接 post conversion result dict,因此不新增 callback 專用轉換層,避免讓 `_worker` 產生第二份 payload contract。 + +- **`_bim-control` 只在欄位存在時使用原檔名。** + Source IFC artifact `name` 使用 `result.get("original_filename") or "原始 IFC"`,保留舊 callback 與 legacy fixture 的 fallback 行為。USDC artifact name 維持「已轉換 USDC」,因為它是 worker 產生的衍生 artifact,不代表原始上傳檔名。 + +## Risks / Trade-offs + +- **[Risk] Payload 多一個欄位造成下游 shape 差異。** + Mitigation: 這是 additive 欄位,既有 consumer 若忽略未知欄位仍能運作;`_bim-control` callback endpoint 以 `dict[str, Any]` 接收。 + +- **[Risk] Legacy source index 沒有 `original_filename`。** + Mitigation: 讀取端使用 `.get("original_filename")`;缺欄位時 conversion result 會保留 `None` / fallback,不拒絕舊 artifact。 + +- **[Risk] 原檔名可能包含特殊字元。** + Mitigation: 原檔名只存 metadata 與 JSON response,不用來組合 filesystem path;disk object key 仍使用 sanitized filename。 + +- **[Risk] 把 worker adapter smoke 誤認為真實 IFC -> USDC 轉檔驗證。** + Mitigation: `_worker` 預設 adapter 產物只驗證 metadata pipeline,不視為 Kit-ready USDC。真實轉檔驗證必須呼叫 `bim-streaming-server/scripts/convert-ifc-to-usdc.ps1` 的 Kit/HOOPS converter,並用 `inspect-usd-stage-and-quit.py` 檢查輸出的 `.usdc` 可由 USD stage 開啟且 `prim_count > 0`。 + +## Migration Plan + +這是 additive metadata 變更,不需要 migration。新 artifact 會開始寫入 `original_filename`;舊 artifact 沒有此欄位時照原本 fallback 行為運作。 + +Rollback 時可移除 `_worker` 寫入 / 回傳 `original_filename` 的變更與 `_bim-control` name mapping 變更;已寫入的額外 JSON 欄位可被舊程式忽略。 + +## Open Questions + +- PR 建立時需在描述中明確列出向前相容性:既有 metadata 與 callback payload 未帶 `original_filename` 時仍可運作。 diff --git a/openspec/changes/add-worker-original-filename-tracking/tasks.md b/openspec/changes/add-worker-original-filename-tracking/tasks.md index 4fe46da4e..b0fd6be7a 100644 --- a/openspec/changes/add-worker-original-filename-tracking/tasks.md +++ b/openspec/changes/add-worker-original-filename-tracking/tasks.md @@ -1,54 +1,57 @@ ## 1. Preparation And Impact Review -- [ ] 1.1 Re-read `_worker/app/store.py`、`_worker/app/main.py`、`_worker/app/models.py`、`_bim-control/app/main.py` 中跟 source artifact metadata、conversion result、callback 有關的程式碼,確認加欄位的最小改動面。 -- [ ] 1.2 Run GitNexus impact analysis for `create_source_artifact`、`complete_conversion_job`、`_post_bim_control_result`、`_update_artifacts_from_conversion`;確認 HIGH/CRITICAL 風險前回報。 -- [ ] 1.3 列出既有 `_worker/data/objects/_index/source_artifacts.json`、metadata.json fixture 是否含舊資料;確認向前相容讀取策略(`.get("original_filename", None)`)。 -- [ ] 1.4 確認 `ArtifactIntakeRequest.filename` 欄位本身已是 raw(未 sanitize),可以直接保存到 metadata 不需重做 decode。 +- [x] 1.1 Re-read `_worker/app/store.py`、`_worker/app/main.py`、`_worker/app/models.py`、`_bim-control/app/main.py` 中跟 source artifact metadata、conversion result、callback 有關的程式碼,確認加欄位的最小改動面。 +- [x] 1.2 Run GitNexus impact analysis for `create_source_artifact`、`complete_conversion_job`、`_post_bim_control_result`、`_update_artifacts_from_conversion`;確認 HIGH/CRITICAL 風險前回報。 +- [x] 1.3 列出既有 `_worker/data/objects/_index/source_artifacts.json`、metadata.json fixture 是否含舊資料;確認向前相容讀取策略(`.get("original_filename", None)`)。 +- [x] 1.4 確認 `ArtifactIntakeRequest.filename` 欄位本身已是 raw(未 sanitize),可以直接保存到 metadata 不需重做 decode。 ## 2. Spec Deltas -- [ ] 2.1 在 `openspec/changes/add-worker-original-filename-tracking/specs/worker-artifact-pipeline/spec.md` 新增 Requirement「Worker preserves original filename in source metadata」與其 scenarios。 -- [ ] 2.2 在 `openspec/changes/add-worker-original-filename-tracking/specs/worker-dev-ifc-source-selection/spec.md` 修改 Requirement「Start Conversion From Selected Source」,確保 response shape 必含 `original_filename`。 -- [ ] 2.3 Run `openspec validate add-worker-original-filename-tracking`。 +- [x] 2.1 在 `openspec/changes/add-worker-original-filename-tracking/specs/worker-artifact-pipeline/spec.md` 新增 Requirement「Worker preserves original filename in source metadata」與其 scenarios。 +- [x] 2.2 在 `openspec/changes/add-worker-original-filename-tracking/specs/worker-dev-ifc-source-selection/spec.md` 修改 Requirement「Start Conversion From Selected Source」,確保 response shape 必含 `original_filename`。 +- [x] 2.3 Run `openspec validate add-worker-original-filename-tracking`。 ## 3. `_worker` Source Artifact Metadata -- [ ] 3.1 修改 `_worker/app/store.py` `create_source_artifact()` 的 metadata dict,加入 `original_filename: str = request.filename`(保留 raw,不經 `safe_filename`)。 -- [ ] 3.2 修改 `_worker/app/store.py` `_upsert_source_index()` 寫入 `_index/source_artifacts.json` 的 entry,加入 `original_filename`。 -- [ ] 3.3 修改 `_worker/app/store.py` `create_source_artifact()` 的 return dict,加入 `original_filename`。 -- [ ] 3.4 確認 `_worker/app/store.py` `complete_conversion_job()` 從 `source["metadata"]` 取得 `original_filename` 並寫進 result payload(給 callback 與 GET result 用)。 +- [x] 3.1 修改 `_worker/app/store.py` `create_source_artifact()` 的 metadata dict,加入 `original_filename: str = request.filename`(保留 raw,不經 `safe_filename`)。 +- [x] 3.2 修改 `_worker/app/store.py` `_upsert_source_index()` 寫入 `_index/source_artifacts.json` 的 entry,加入 `original_filename`。 +- [x] 3.3 修改 `_worker/app/store.py` `create_source_artifact()` 的 return dict,加入 `original_filename`。 +- [x] 3.4 確認 `_worker/app/store.py` `complete_conversion_job()` 從 `source["metadata"]` 取得 `original_filename` 並寫進 result payload(給 callback 與 GET result 用)。 ## 4. `_worker` API Response Shape -- [ ] 4.1 修改 `_worker/app/main.py` `POST /api/artifacts` 的 response,確認 `original_filename` 在 response body(透過 `create_source_artifact` 的 return dict 自動帶出)。 -- [ ] 4.2 修改 `_worker/app/main.py` `POST /api/dev/ifc-sources/{source_id}/conversions` 的 response,加入 `original_filename`(從 `source_item["filename"]` 取,這是原 `relative_path` 對應的純檔名)。 -- [ ] 4.3 修改 `_worker/app/main.py` `GET /api/conversions/{id}/result` 透過 `complete_conversion_job` 自然帶出 `original_filename`,無需個別處理。 +- [x] 4.1 修改 `_worker/app/main.py` `POST /api/artifacts` 的 response,確認 `original_filename` 在 response body(透過 `create_source_artifact` 的 return dict 自動帶出)。 +- [x] 4.2 修改 `_worker/app/main.py` `POST /api/dev/ifc-sources/{source_id}/conversions` 的 response,加入 `original_filename`(從 `source_item["filename"]` 取,這是原 `relative_path` 對應的純檔名)。 +- [x] 4.3 修改 `_worker/app/main.py` `GET /api/conversions/{id}/result` 透過 `complete_conversion_job` 自然帶出 `original_filename`,無需個別處理。 ## 5. `_bim-control` Artifact Name Mapping -- [ ] 5.1 修改 `_bim-control/app/main.py` `_update_artifacts_from_conversion()`:source IFC artifact upsert 時,`name` 欄位優先用 `result.get("original_filename")`,fallback 保留現有「原始 IFC」字串。 -- [ ] 5.2 USDC artifact 的 `name` 欄位保持現有「已轉換 USDC」(USDC 是 worker 自己生的衍生檔,不需要展示原檔名)。 -- [ ] 5.3 確認 `_bim-control` 沒有任何 schema validation 會擋掉 result payload 多帶的欄位(目前是 `dict[str, Any]` 接收,OK)。 +- [x] 5.1 修改 `_bim-control/app/main.py` `_update_artifacts_from_conversion()`:source IFC artifact upsert 時,`name` 欄位優先用 `result.get("original_filename")`,fallback 保留現有「原始 IFC」字串。 +- [x] 5.2 USDC artifact 的 `name` 欄位保持現有「已轉換 USDC」(USDC 是 worker 自己生的衍生檔,不需要展示原檔名)。 +- [x] 5.3 確認 `_bim-control` 沒有任何 schema validation 會擋掉 result payload 多帶的欄位(目前是 `dict[str, Any]` 接收,OK)。 ## 6. Tests -- [ ] 6.1 `_worker/tests/test_worker_api.py` 新增測試:上傳含中文檔名的 IFC,確認 metadata.json、`_index/source_artifacts.json`、API response 都含 `original_filename` 等於原檔名;disk filename 仍 sanitize。 -- [ ] 6.2 `_worker/tests/test_worker_api.py` 新增測試:selected-source conversion (dev IFC) 完成後,`GET /api/conversions/{id}/result` 包含 `original_filename`。 -- [ ] 6.3 `_worker/tests/test_worker_store.py` 補測 `create_source_artifact` 直接呼叫時 metadata 含 `original_filename`。 -- [ ] 6.4 `_bim-control/tests/test_conversion_results_api.py` 新增測試:POST conversion-result 含 `original_filename` 時,artifacts.json 的 source IFC entry `name` 欄位 = 原檔名;不含 `original_filename` 時 fallback 保持原行為。 -- [ ] 6.5 確認既有測試(無 `original_filename` 的舊 fixture)仍綠燈,驗證向前相容。 +- [x] 6.1 `_worker/tests/test_worker_api.py` 新增測試:上傳含中文檔名的 IFC,確認 metadata.json、`_index/source_artifacts.json`、API response 都含 `original_filename` 等於原檔名;disk filename 仍 sanitize。 +- [x] 6.2 `_worker/tests/test_worker_api.py` 新增測試:selected-source conversion (dev IFC) 完成後,`GET /api/conversions/{id}/result` 包含 `original_filename`。 +- [x] 6.3 `_worker/tests/test_worker_store.py` 補測 `create_source_artifact` 直接呼叫時 metadata 含 `original_filename`。 +- [x] 6.4 `_bim-control/tests/test_conversion_results_api.py` 新增測試:POST conversion-result 含 `original_filename` 時,artifacts.json 的 source IFC entry `name` 欄位 = 原檔名;不含 `original_filename` 時 fallback 保持原行為。 +- [x] 6.5 確認既有測試(無 `original_filename` 的舊 fixture)仍綠燈,驗證向前相容。 +- [x] 6.6 `_worker/tests/test_worker_api.py` 新增真實 IFC bytes 的多筆 selected-source worker-adapter 測試:從 `C:\Repos\active\iot\AI-BIM-governance\storage` 複製至少兩筆 IFC 到測試 storage,確認每筆 conversion result 都保留 `original_filename` 且 source artifact / conversion job / object URL 彼此獨立。此測試驗證 worker metadata pipeline,不宣稱產物是 Kit-ready USDC。 +- [x] 6.7 `_worker/tests/test_worker_api.py` 新增 opt-in 真實 Kit/HOOPS IFC -> USDC 多筆 smoke test:`WORKER_RUN_REAL_USDC_SMOKE=1` 時呼叫 `bim-streaming-server/scripts/convert-ifc-to-usdc.ps1`,並用 `inspect-usd-stage-and-quit.py` 驗證輸出的 `.usdc` 可由 USD stage 開啟且 `prim_count > 0`。 ## 7. Documentation -- [ ] 7.1 若 `docs/contracts/worker-api.md` 存在,補上 `original_filename` 欄位說明(位置:source artifact response、conversion result、callback payload)。 -- [ ] 7.2 `_worker/README.md` API 段落如有列 response 範例,補欄位(可選,不阻擋)。 -- [ ] 7.3 `_bim-control/README.md` 不需改(callback 接收端形狀不變,只是多接受一個欄位)。 +- [x] 7.1 若 `docs/contracts/worker-api.md` 存在,補上 `original_filename` 欄位說明(位置:source artifact response、conversion result、callback payload)。 +- [x] 7.2 `_worker/README.md` API 段落如有列 response 範例,補欄位(可選,不阻擋)。 +- [x] 7.3 `_bim-control/README.md` 不需改(callback 接收端形狀不變,只是多接受一個欄位)。 ## 8. Validation And Review -- [ ] 8.1 `cd _worker && python -m pytest tests -q` 全綠。 -- [ ] 8.2 `cd _bim-control && python -m pytest tests -q` 全綠。 -- [ ] 8.3 Run `openspec validate add-worker-original-filename-tracking` 通過。 -- [ ] 8.4 GitNexus detect_changes 確認 affected scope 只在 `_worker` 與 `_bim-control` 範圍內。 -- [ ] 8.5 PR 描述列出向前相容性說明(既有資料 / 既有 callback 沒帶 `original_filename` 仍能運作)。 -- [ ] 8.6 Smoke:手動跑一次中文檔名 IFC 從 storage 進 worker、callback 到 `_bim-control` 的完整流程,確認 `_bim-control` artifact `name` 顯示原檔名。 +- [x] 8.1 `cd _worker && python -m pytest tests -q` 全綠。 +- [x] 8.2 `cd _bim-control && python -m pytest tests -q` 全綠。 +- [x] 8.3 Run `openspec validate add-worker-original-filename-tracking` 通過。 +- [x] 8.4 GitNexus detect_changes 確認 affected scope 只在 `_worker` 與 `_bim-control` 範圍內。 +- [x] 8.5 PR 描述列出向前相容性說明(既有資料 / 既有 callback 沒帶 `original_filename` 仍能運作)。 +- [x] 8.6 Smoke:手動跑一次中文檔名 IFC 從 storage 進 worker、callback 到 `_bim-control` 的完整流程,確認 `_bim-control` artifact `name` 顯示原檔名。 +- [x] 8.7 Real conversion smoke:手動跑兩筆真實 IFC 透過 Kit/HOOPS 轉成 Kit-openable USDC,輸出到 `C:\tmp\codex-real-usdc-smoke`,並用 `inspect-usd-stage-and-quit.py` 驗證兩筆 stage 皆可開啟且 `prim_count=10872`。