From 51d52ccf04c67daa1c5e09d789292436a408d1fe Mon Sep 17 00:00:00 2001 From: Mikhail Date: Fri, 22 May 2026 00:54:43 +0300 Subject: [PATCH] Handle UNC binary directories in AI structure flow --- .../html5_ai_structure_controller.py | 91 ++++++++++++------- services/api-server/src/api_server/main.py | 20 +++- services/api-server/tests/test_api.py | 57 ++++++++++++ 3 files changed, 134 insertions(+), 34 deletions(-) diff --git a/services/api-server/src/api_server/html5_ai_structure_controller.py b/services/api-server/src/api_server/html5_ai_structure_controller.py index 5bc6041..01d3e5e 100644 --- a/services/api-server/src/api_server/html5_ai_structure_controller.py +++ b/services/api-server/src/api_server/html5_ai_structure_controller.py @@ -62,37 +62,6 @@ async def html5_ai_structure_run( if should_save and save_credentials and username and password: save_credentials(project_id, {"username": username, "password": password, "domain": domain}) - binary_source = _detect_binary_input(input_path) - if binary_source is not None: - if start_binary_job is None or save_run_state is None: - return render_html5_ai_structure_error("Сервис подготовки CF/CFE через Windows Agent не подключен.") - try: - job = await start_binary_job(project_id=project_id, effective_project_id=effective_project_id, input_path=Path(input_path)) - except HTTPException as error: - return render_html5_ai_structure_error(str(error.detail)) - save_run_state( - job.job_id, - { - "project_id": project_id, - "effective_project_id": effective_project_id, - "input_path": input_path, - "output_path": output_path, - "username": username, - "password": password, - "domain": domain, - "display_input_path": input_path, - "display_output_path": output_path, - }, - ) - return render_html5_ai_structure_job( - project_id=project_id, - job_id=job.job_id, - status=_enum_text(job.status), - source=_enum_text(job.source), - message="Разбор CF/CFE запущен через Windows Agent", - logs=getattr(job, "logs", []), - ) - work_dir = work_root / f"{effective_project_id}-{uuid4().hex}" try: work_dir.mkdir(parents=True, exist_ok=True) @@ -106,6 +75,41 @@ async def html5_ai_structure_run( password=password, domain=domain or None, ) + binary_match = _detect_binary_tree(local_input) or _normalize_binary_match(_detect_binary_input(input_path)) + if binary_match is not None: + if start_binary_job is None or save_run_state is None: + return render_html5_ai_structure_error("Сервис подготовки CF/CFE через Windows Agent не подключен.") + try: + job = await start_binary_job( + project_id=project_id, + effective_project_id=effective_project_id, + input_path=input_path, + detected_binary_relative_path=binary_match.get("relative_path"), + ) + except HTTPException as error: + return render_html5_ai_structure_error(str(error.detail)) + save_run_state( + job.job_id, + { + "project_id": project_id, + "effective_project_id": effective_project_id, + "input_path": input_path, + "output_path": output_path, + "username": username, + "password": password, + "domain": domain, + "display_input_path": input_path, + "display_output_path": output_path, + }, + ) + return render_html5_ai_structure_job( + project_id=project_id, + job_id=job.job_id, + status=_enum_text(job.status), + source=_enum_text(job.source), + message="Разбор CF/CFE запущен через Windows Agent", + logs=getattr(job, "logs", []), + ) result = prepare( project_id=effective_project_id, input_path=local_input, @@ -244,5 +248,30 @@ def _detect_binary_input(raw_input_path: str) -> str | None: return binary_files[0].suffix.casefold() +def _detect_binary_tree(input_path: Path) -> dict[str, str] | None: + if not input_path.exists(): + return None + suffixes = {".cf", ".cfe"} + if input_path.is_file() and input_path.suffix.casefold() in suffixes: + return {"suffix": input_path.suffix.casefold(), "relative_path": input_path.name} + if not input_path.is_dir(): + return None + files = sorted(path for path in input_path.rglob("*") if path.is_file()) + parseable_files = any(path.suffix.casefold() in {".xml", ".mdo", ".bsl"} for path in files) + binary_files = [path for path in files if path.suffix.casefold() in suffixes] + if parseable_files or not binary_files: + return None + first = binary_files[0] + return {"suffix": first.suffix.casefold(), "relative_path": first.relative_to(input_path).as_posix()} + + +def _normalize_binary_match(value: str | dict[str, str] | None) -> dict[str, str] | None: + if value is None: + return None + if isinstance(value, dict): + return value + return {"suffix": value, "relative_path": ""} + + def _enum_text(value: object) -> str: return str(getattr(value, "value", value or "")) diff --git a/services/api-server/src/api_server/main.py b/services/api-server/src/api_server/main.py index 05e5ac4..bc853b4 100644 --- a/services/api-server/src/api_server/main.py +++ b/services/api-server/src/api_server/main.py @@ -4,6 +4,7 @@ import asyncio import base64 import hashlib import json +import ntpath import os import re import shutil @@ -581,9 +582,15 @@ def _agent_id_for_source(settings: "ProjectSettingsRequest", source: "ImportSour return str(agent.get("agent_id") or "").strip() -async def _start_ai_structure_agent_job(*, project_id: str, effective_project_id: str, input_path: Path) -> AgentImportJob: +async def _start_ai_structure_agent_job( + *, + project_id: str, + effective_project_id: str, + input_path: str, + detected_binary_relative_path: str | None = None, +) -> AgentImportJob: settings = _project_settings_or_404(project_id) - binary_files = _ai_structure_binary_files(input_path) + binary_files = _ai_structure_binary_files(input_path, detected_binary_relative_path=detected_binary_relative_path) if not binary_files: raise HTTPException(status_code=400, detail="Во входном пути не найдены файлы .cf или .cfe.") @@ -641,7 +648,14 @@ async def _start_ai_structure_agent_job(*, project_id: str, effective_project_id ) -def _ai_structure_binary_files(input_path: Path) -> list[Path]: +def _ai_structure_binary_files(raw_input_path: str, detected_binary_relative_path: str | None = None) -> list[Path]: + lowered = raw_input_path.strip().casefold() + if lowered.endswith(".cf") or lowered.endswith(".cfe"): + return [Path(raw_input_path)] + if detected_binary_relative_path: + windows_path = ntpath.join(raw_input_path, detected_binary_relative_path.replace("/", "\\")) + return [Path(windows_path)] + input_path = Path(raw_input_path) if input_path.is_file() and input_path.suffix.casefold() in {".cf", ".cfe"}: return [input_path] if not input_path.exists() or not input_path.is_dir(): diff --git a/services/api-server/tests/test_api.py b/services/api-server/tests/test_api.py index 93edd52..4a5e806 100644 --- a/services/api-server/tests/test_api.py +++ b/services/api-server/tests/test_api.py @@ -1,3 +1,4 @@ +import asyncio from pathlib import Path import json import re @@ -1961,6 +1962,62 @@ def test_html5_ai_structure_routes_binary_cfe_through_windows_agent(tmp_path: Pa assert (output / f"codex-1c-context-{project_id}" / "AGENTS.md").exists() +def test_html5_ai_structure_routes_unc_directory_with_cf_through_windows_agent(monkeypatch, tmp_path: Path): + from api_server import html5_ai_structure_controller as controller + + copied_root = tmp_path / "copied-unc" + copied_root.mkdir() + (copied_root / "base.cf").write_bytes(b"binary-cf") + + copied_targets: list[tuple[str, Path]] = [] + + def fake_copy_smb_tree_to_local(*, source: str, target: Path, username: str, password: str, domain: str | None = None) -> None: + copied_targets.append((source, target)) + target.mkdir(parents=True, exist_ok=True) + for item in copied_root.iterdir(): + if item.is_file(): + (target / item.name).write_bytes(item.read_bytes()) + + class FakeJob: + job_id = "agent-import-test" + status = "QUEUED" + source = "CF_FILE" + logs = ["queued"] + + started: dict[str, object] = {} + + async def fake_start_binary_job(**kwargs): + started.update(kwargs) + return FakeJob() + + saved_runs: dict[str, dict[str, object]] = {} + + monkeypatch.setattr(controller, "copy_smb_tree_to_local", fake_copy_smb_tree_to_local) + + html = asyncio.run( + controller.html5_ai_structure_run( + project_id="unc-demo", + form={ + "project_id": ["unc-demo"], + "input_path": [r"\\192.168.220.200\mst\1c\MARKA\CODEX\CF"], + "output_path": [r"\\192.168.220.200\mst\1c\MARKA\CODEX\CODEX"], + "smb_username": ["m"], + "smb_password": ["secret"], + }, + prepare=lambda **_: {}, + work_root=tmp_path / "work", + start_binary_job=fake_start_binary_job, + save_run_state=lambda job_id, payload: saved_runs.setdefault(job_id, payload), + ) + ) + + assert "Windows Agent" in html + assert copied_targets + assert started["input_path"] == r"\\192.168.220.200\mst\1c\MARKA\CODEX\CF" + assert started["detected_binary_relative_path"] == "base.cf" + assert "agent-import-test" in saved_runs + + def test_import_full_replace_replaces_current_normalized_project(tmp_path: Path): first = tmp_path / "first" second = tmp_path / "second"