From de8b0eb7950dead834eea49a9afa14192f3f87c1 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Fri, 22 May 2026 00:45:57 +0300 Subject: [PATCH] Support direct CF and CFE inputs in AI structure flow --- scripts/windows-agent/sfera-windows-agent.ps1 | 102 +++++++++++++++++- services/api-server/src/api_server/main.py | 30 +++--- services/api-server/tests/test_api.py | 74 +++++++++++++ 3 files changed, 187 insertions(+), 19 deletions(-) diff --git a/scripts/windows-agent/sfera-windows-agent.ps1 b/scripts/windows-agent/sfera-windows-agent.ps1 index 3ffb6f1..436b429 100644 --- a/scripts/windows-agent/sfera-windows-agent.ps1 +++ b/scripts/windows-agent/sfera-windows-agent.ps1 @@ -694,6 +694,100 @@ function Export-CfOrCfeFromInfobase { return $exportRoot } +function Convert-LocalCfOrCfeToMetadataExport { + param([object]$Job, [string[]]$PlatformBins) + $payloadPath = [string]$Job.local_path + if ([string]::IsNullOrWhiteSpace($payloadPath)) { + throw "local_path is required for direct CF/CFE conversion." + } + if (!(Test-Path -LiteralPath $payloadPath)) { + throw "Local CF/CFE path not found on agent machine: $payloadPath" + } + + $designerPath = Get-DesignerBinPath -Job $Job -PlatformBins $PlatformBins + $workRoot = Join-Path $env:TEMP "sfera-agent" + $exportRoot = Join-Path $workRoot "$($Job.job_id)-local-binary" + if (Test-Path -LiteralPath $exportRoot) { Remove-Item -LiteralPath $exportRoot -Recurse -Force } + New-Item -ItemType Directory -Force -Path $exportRoot | Out-Null + + $builderInfobase = Join-Path $exportRoot "builder-infobase" + $createLog = Join-Path $exportRoot "create-builder-infobase.log" + Invoke-1CCommand ` + -PlatformPath $designerPath ` + -Arguments @("CREATEINFOBASE", "File=$builderInfobase;") ` + -LogPath $createLog ` + -JobId $Job.job_id ` + -ActionTitle "1C CREATEINFOBASE for local CF/CFE conversion" ` + -TimeoutSeconds 180 + + $builderArgs = @("/F", $builderInfobase) + $sourceKind = [string]$Job.source + $fileName = [System.IO.Path]::GetFileName($payloadPath) + $artifactsRoot = Join-Path $exportRoot "artifacts" + New-Item -ItemType Directory -Force -Path $artifactsRoot | Out-Null + Copy-Item -LiteralPath $payloadPath -Destination (Join-Path $artifactsRoot $fileName) -Force + + if ($sourceKind -eq "CF_FILE") { + $loadLog = Join-Path $exportRoot "designer-loadcfg-local-cf.log" + Invoke-DesignerCommand ` + -DesignerPath $designerPath ` + -Arguments (@($builderArgs) + @("/LoadCfg", $payloadPath)) ` + -LogPath $loadLog ` + -JobId $Job.job_id ` + -ActionTitle "1C LoadCfg local CF" ` + -TimeoutSeconds 180 + + $metadataRoot = Join-Path $exportRoot "configuration" + New-Item -ItemType Directory -Force -Path $metadataRoot | Out-Null + $metadataLog = Join-Path $exportRoot "designer-dumpconfigtofiles-local-cf.log" + Invoke-DesignerCommand ` + -DesignerPath $designerPath ` + -Arguments (@($builderArgs) + @("/DumpConfigToFiles", $metadataRoot, "-Format", "Hierarchical")) ` + -LogPath $metadataLog ` + -JobId $Job.job_id ` + -ActionTitle "1C DumpConfigToFiles from local CF" ` + -TimeoutSeconds 180 + Copy-Item -LiteralPath $payloadPath -Destination (Join-Path $metadataRoot $fileName) -Force + Send-JobLogs -JobId $Job.job_id -Logs @("Local .cf converted to metadata export for server-side parsing.") + return $exportRoot + } + + if ($sourceKind -eq "CFE_FILE") { + $extensionName = Get-JobMetadataValue -Job $Job -Key "one_c_extension" + if ([string]::IsNullOrWhiteSpace($extensionName)) { + $extensionName = [System.IO.Path]::GetFileNameWithoutExtension($payloadPath) + } + if ([string]::IsNullOrWhiteSpace($extensionName)) { + throw "Extension name is required for local CFE conversion." + } + + $loadLog = Join-Path $exportRoot "designer-loadcfg-local-cfe.log" + Invoke-DesignerCommand ` + -DesignerPath $designerPath ` + -Arguments (@($builderArgs) + @("/LoadCfg", $payloadPath, "-Extension", $extensionName, "/UpdateDBCfg")) ` + -LogPath $loadLog ` + -JobId $Job.job_id ` + -ActionTitle "1C LoadCfg local CFE" ` + -TimeoutSeconds 180 + + $metadataRoot = Join-Path $exportRoot "extension" + New-Item -ItemType Directory -Force -Path $metadataRoot | Out-Null + $metadataLog = Join-Path $exportRoot "designer-dumpconfigtofiles-local-cfe.log" + Invoke-DesignerCommand ` + -DesignerPath $designerPath ` + -Arguments (@($builderArgs) + @("/DumpConfigToFiles", $metadataRoot, "-Format", "Hierarchical", "-Extension", $extensionName)) ` + -LogPath $metadataLog ` + -JobId $Job.job_id ` + -ActionTitle "1C DumpConfigToFiles from local CFE" ` + -TimeoutSeconds 180 + Copy-Item -LiteralPath $payloadPath -Destination (Join-Path $metadataRoot $fileName) -Force + Send-JobLogs -JobId $Job.job_id -Logs @("Local .cfe converted to metadata export for server-side parsing.") + return $exportRoot + } + + throw "Unsupported source for local CF/CFE conversion: $sourceKind" +} + function Install-SferaExtensionJob { param([object]$Job, [string[]]$PlatformBins) $workRoot = Join-Path $env:TEMP "sfera-agent" @@ -1138,8 +1232,12 @@ while ($true) { continue } $payloadPath = $job.local_path - if (($job.source -eq "CF_FILE") -or (($job.source -eq "CFE_FILE") -and [string]::IsNullOrWhiteSpace($payloadPath))) { - $payloadPath = Export-CfOrCfeFromInfobase -Job $job -PlatformBins $platformBins + if (($job.source -eq "CF_FILE") -or ($job.source -eq "CFE_FILE")) { + if (![string]::IsNullOrWhiteSpace($payloadPath)) { + $payloadPath = Convert-LocalCfOrCfeToMetadataExport -Job $job -PlatformBins $platformBins + } else { + $payloadPath = Export-CfOrCfeFromInfobase -Job $job -PlatformBins $platformBins + } } if ([string]::IsNullOrWhiteSpace($payloadPath)) { throw "Job does not contain local_path or enough 1C infobase settings for agent export." diff --git a/services/api-server/src/api_server/main.py b/services/api-server/src/api_server/main.py index b199064..05e5ac4 100644 --- a/services/api-server/src/api_server/main.py +++ b/services/api-server/src/api_server/main.py @@ -587,7 +587,14 @@ async def _start_ai_structure_agent_job(*, project_id: str, effective_project_id if not binary_files: raise HTTPException(status_code=400, detail="Во входном пути не найдены файлы .cf или .cfe.") - source = ImportSourceKind.CF_FILE if any(path.suffix.casefold() == ".cf" for path in binary_files) else ImportSourceKind.CFE_FILE + cf_files = [path for path in binary_files if path.suffix.casefold() == ".cf"] + cfe_files = [path for path in binary_files if path.suffix.casefold() == ".cfe"] + if cf_files and cfe_files: + raise HTTPException( + status_code=400, + detail="Во входной папке одновременно лежат .cf и .cfe. Укажите конкретный файл, который нужно подготовить для ИИ.", + ) + source = ImportSourceKind.CF_FILE if cf_files else ImportSourceKind.CFE_FILE agent_id = _agent_id_for_source(settings, ImportSourceKind.CF_FILE) if not agent_id: raise HTTPException(status_code=400, detail="В настройках проекта не выбран Windows Agent для CF/CFE.") @@ -603,26 +610,14 @@ async def _start_ai_structure_agent_job(*, project_id: str, effective_project_id local_path: str | None = None if source == ImportSourceKind.CF_FILE: - one_c_server = _agent_string_value(agent, "one_c_server") or _agent_string_value(agent, "published_1c_server") or _agent_string_value(agent, "published_server_url") - one_c_infobase = _agent_string_value(agent, "one_c_infobase") or _agent_string_value(agent, "published_infobase") - if one_c_server.startswith(("http://", "https://")): - one_c_server = urlsplit(one_c_server).hostname or one_c_server - if not one_c_server or not one_c_infobase: + if len(cf_files) != 1: raise HTTPException( status_code=400, - detail="Для разбора .cf нужен сервер 1С и имя информационной базы в настройках проекта. Сейчас они не заполнены.", + detail="Для прямого разбора .cf укажите один конкретный файл .cf, а не папку с несколькими конфигурациями.", ) - metadata.update( - { - "one_c_server": one_c_server, - "one_c_infobase": one_c_infobase, - "one_c_user": _agent_string_value(agent, "one_c_user") or None, - "one_c_password": _agent_string_value(agent, "one_c_password") or None, - "include_extensions": True, - } - ) + local_path = str(cf_files[0]) + metadata["input_mode"] = "cf_file" else: - cfe_files = [path for path in binary_files if path.suffix.casefold() == ".cfe"] if len(cfe_files) != 1: raise HTTPException( status_code=400, @@ -631,6 +626,7 @@ async def _start_ai_structure_agent_job(*, project_id: str, effective_project_id cfe_file = cfe_files[0] local_path = str(cfe_file) metadata["one_c_extension"] = cfe_file.stem + metadata["input_mode"] = "cfe_file" return await create_agent_import_job( effective_project_id, diff --git a/services/api-server/tests/test_api.py b/services/api-server/tests/test_api.py index 7311cfb..93edd52 100644 --- a/services/api-server/tests/test_api.py +++ b/services/api-server/tests/test_api.py @@ -1862,6 +1862,7 @@ def test_html5_ai_structure_routes_binary_cf_through_windows_agent(tmp_path: Pat assert claimed.status_code == 200 assert claimed.json()["job_id"] == job_id assert claimed.json()["source"] == "CF_FILE" + assert claimed.json()["local_path"] == str(cf_input) completed = client.post( f"/agent/jobs/{job_id}/result", @@ -1887,6 +1888,79 @@ def test_html5_ai_structure_routes_binary_cf_through_windows_agent(tmp_path: Pat assert (output / f"codex-1c-context-{project_id}" / "AGENTS.md").exists() +def test_html5_ai_structure_routes_binary_cfe_through_windows_agent(tmp_path: Path): + metadata_root = tmp_path / "extension" + metadata_root.mkdir() + (metadata_root / "metadata.xml").write_text( + """ + + + +""", + encoding="utf-8", + ) + cfe_input = tmp_path / "MyExtension.cfe" + cfe_input.write_bytes(b"binary-cfe") + output = tmp_path / "ai-out-cfe" + client = TestClient(app) + project_id = f"ai-agent-cfe-{uuid4()}" + agent_id = f"win-agent-{uuid4()}" + + indexed = client.post("/projects/index", json={"path": str(metadata_root), "project_id": project_id}) + assert indexed.status_code == 200 + settings = client.post( + f"/projects/{project_id}/settings", + json={ + "name": "AI Agent Extension Demo", + "structure_source": "CFE_FILE", + "agent": { + "cf_agent_id": agent_id, + }, + }, + ) + assert settings.status_code == 200 + heartbeat = client.post("/agent/heartbeat", json={"agent_id": agent_id, "host": "test-host"}) + assert heartbeat.status_code == 200 + + queued = client.post( + f"/html5/projects/{project_id}/ai-structure/run", + data={"project_id": project_id, "input_path": str(cfe_input), "output_path": str(output)}, + ) + assert queued.status_code == 200 + match = re.search(r"/html5/projects/[^/]+/ai-structure/jobs/([A-Za-z0-9-]+)", queued.text) + assert match is not None + job_id = match.group(1) + + claimed = client.get("/agent/jobs/next", params={"agent_id": agent_id}) + assert claimed.status_code == 200 + assert claimed.json()["job_id"] == job_id + assert claimed.json()["source"] == "CFE_FILE" + assert claimed.json()["local_path"] == str(cfe_input) + assert claimed.json()["metadata"]["one_c_extension"] == "MyExtension" + + completed = client.post( + f"/agent/jobs/{job_id}/result", + json={ + "status": "SUCCEEDED", + "server_path": str(metadata_root), + "logs": ["Выгрузка расширения завершена."], + }, + ) + assert completed.status_code == 200 + + deadline = time.monotonic() + 10 + fragment = "" + while time.monotonic() < deadline: + polled = client.get(f"/html5/projects/{project_id}/ai-structure/jobs/{job_id}") + assert polled.status_code == 200 + fragment = polled.text + if "готово" in fragment: + break + time.sleep(0.05) + assert "готово" in fragment + assert (output / f"codex-1c-context-{project_id}" / "AGENTS.md").exists() + + def test_import_full_replace_replaces_current_normalized_project(tmp_path: Path): first = tmp_path / "first" second = tmp_path / "second"