Route HTML5 AI structure CF/CFE through Windows Agent
This commit is contained in:
@@ -128,6 +128,34 @@ def render_html5_ai_structure_result(result: dict | None) -> str:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def render_html5_ai_structure_job(
|
||||||
|
*,
|
||||||
|
project_id: str,
|
||||||
|
job_id: str,
|
||||||
|
status: str,
|
||||||
|
source: str,
|
||||||
|
message: str,
|
||||||
|
logs: list[object] | None = None,
|
||||||
|
) -> str:
|
||||||
|
log_items = list(logs or [])
|
||||||
|
return f"""
|
||||||
|
<section
|
||||||
|
class="ai-structure-result"
|
||||||
|
data-html5-ai-structure-status="running"
|
||||||
|
hx-get="/html5/projects/{quote(project_id)}/ai-structure/jobs/{quote(job_id)}"
|
||||||
|
hx-trigger="every 2s"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
|
<div class="access-plan-head">
|
||||||
|
<span class="status-pill">выполняется</span>
|
||||||
|
<strong>{escape(message)}</strong>
|
||||||
|
</div>
|
||||||
|
<p class="object-summary">Задача агента: {escape(job_id)}. Источник: {escape(source)}. Текущий статус: {escape(status)}.</p>
|
||||||
|
<ul class="access-warnings">{''.join(f'<li>{escape(str(item))}</li>' for item in log_items[-8:]) or '<li>Ждем сообщения от Windows Agent.</li>'}</ul>
|
||||||
|
</section>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
def render_html5_ai_structure_error(message: str) -> str:
|
def render_html5_ai_structure_error(message: str) -> str:
|
||||||
return f"""
|
return f"""
|
||||||
<section class="ai-structure-result" data-html5-ai-structure-status="error">
|
<section class="ai-structure-result" data-html5-ai-structure-status="error">
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from fastapi import HTTPException
|
|||||||
|
|
||||||
from api_server.html5_ai_structure import (
|
from api_server.html5_ai_structure import (
|
||||||
render_html5_ai_structure_error,
|
render_html5_ai_structure_error,
|
||||||
|
render_html5_ai_structure_job,
|
||||||
render_html5_ai_structure_page,
|
render_html5_ai_structure_page,
|
||||||
render_html5_ai_structure_result,
|
render_html5_ai_structure_result,
|
||||||
)
|
)
|
||||||
@@ -32,12 +33,14 @@ def html5_ai_structure_page(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def html5_ai_structure_run(
|
async def html5_ai_structure_run(
|
||||||
*,
|
*,
|
||||||
project_id: str,
|
project_id: str,
|
||||||
form: dict[str, list[str]],
|
form: dict[str, list[str]],
|
||||||
prepare: Callable[..., dict[str, Any]],
|
prepare: Callable[..., dict[str, Any]],
|
||||||
work_root: Path,
|
work_root: Path,
|
||||||
|
start_binary_job: Callable[..., Any] | None = None,
|
||||||
|
save_run_state: Callable[[str, dict[str, Any]], None] | None = None,
|
||||||
load_credentials: Callable[[str], SmbCredentials | None] | None = None,
|
load_credentials: Callable[[str], SmbCredentials | None] | None = None,
|
||||||
save_credentials: Callable[[str, SmbCredentials], None] | None = None,
|
save_credentials: Callable[[str, SmbCredentials], None] | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
@@ -59,6 +62,37 @@ def html5_ai_structure_run(
|
|||||||
if should_save and save_credentials and username and password:
|
if should_save and save_credentials and username and password:
|
||||||
save_credentials(project_id, {"username": username, "password": password, "domain": domain})
|
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}"
|
work_dir = work_root / f"{effective_project_id}-{uuid4().hex}"
|
||||||
try:
|
try:
|
||||||
work_dir.mkdir(parents=True, exist_ok=True)
|
work_dir.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -99,3 +133,116 @@ def html5_ai_structure_run(
|
|||||||
if work_dir.exists():
|
if work_dir.exists():
|
||||||
remove_tree(work_dir, expected_parent=work_root)
|
remove_tree(work_dir, expected_parent=work_root)
|
||||||
return render_html5_ai_structure_result(result)
|
return render_html5_ai_structure_result(result)
|
||||||
|
|
||||||
|
|
||||||
|
def html5_ai_structure_job(
|
||||||
|
*,
|
||||||
|
project_id: str,
|
||||||
|
job_id: str,
|
||||||
|
prepare: Callable[..., dict[str, Any]],
|
||||||
|
work_root: Path,
|
||||||
|
load_run_state: Callable[[str], dict[str, Any] | None],
|
||||||
|
save_run_state: Callable[[str, dict[str, Any]], None],
|
||||||
|
load_job: Callable[[str], object | None],
|
||||||
|
current_project_source_root: Callable[[str], Path | None],
|
||||||
|
) -> str:
|
||||||
|
state = load_run_state(job_id)
|
||||||
|
if state is None:
|
||||||
|
return render_html5_ai_structure_error("Состояние подготовки для этой задачи не найдено. Запустите обработку заново.")
|
||||||
|
if state.get("result") is not None:
|
||||||
|
return render_html5_ai_structure_result(dict(state["result"]))
|
||||||
|
|
||||||
|
job = load_job(job_id)
|
||||||
|
if job is None or str(getattr(job, "project_id", "")) != project_id:
|
||||||
|
return render_html5_ai_structure_error(f"Задача агента не найдена: {job_id}")
|
||||||
|
|
||||||
|
status = _enum_text(getattr(job, "status", "UNKNOWN"))
|
||||||
|
source = _enum_text(getattr(job, "source", ""))
|
||||||
|
logs = list(getattr(job, "logs", []) or [])
|
||||||
|
if status in {"QUEUED", "RUNNING"}:
|
||||||
|
return render_html5_ai_structure_job(
|
||||||
|
project_id=project_id,
|
||||||
|
job_id=job_id,
|
||||||
|
status=status,
|
||||||
|
source=source,
|
||||||
|
message="Windows Agent выгружает структуру и передает ее на сервер",
|
||||||
|
logs=logs,
|
||||||
|
)
|
||||||
|
if status != "SUCCEEDED":
|
||||||
|
error = str(getattr(job, "error", "") or "Windows Agent завершил задачу с ошибкой.")
|
||||||
|
if logs:
|
||||||
|
error = f"{error} Последние сообщения: {' | '.join(str(item) for item in logs[-4:])}"
|
||||||
|
return render_html5_ai_structure_error(error)
|
||||||
|
|
||||||
|
source_root = current_project_source_root(str(state.get("effective_project_id") or project_id))
|
||||||
|
if source_root is None:
|
||||||
|
import_summary = getattr(job, "import_summary", None) or {}
|
||||||
|
source_path = str(import_summary.get("source_path") or "")
|
||||||
|
source_root = Path(source_path) if source_path else None
|
||||||
|
if source_root is None or not source_root.exists():
|
||||||
|
return render_html5_ai_structure_error("После выгрузки агентом сервер не нашел папку с XML/BSL-структурой для подготовки пакета.")
|
||||||
|
|
||||||
|
output_path = str(state.get("output_path") or "")
|
||||||
|
username = str(state.get("username") or "")
|
||||||
|
password = str(state.get("password") or "")
|
||||||
|
domain = str(state.get("domain") or "")
|
||||||
|
display_input_path = str(state.get("display_input_path") or source_root)
|
||||||
|
display_output_path = str(state.get("display_output_path") or output_path)
|
||||||
|
|
||||||
|
work_dir = work_root / f"{state.get('effective_project_id') or project_id}-{uuid4().hex}"
|
||||||
|
try:
|
||||||
|
work_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
local_output = work_dir / "output" if is_unc_path(output_path) else Path(output_path)
|
||||||
|
result = prepare(
|
||||||
|
project_id=str(state.get("effective_project_id") or project_id),
|
||||||
|
input_path=source_root,
|
||||||
|
output_path=local_output,
|
||||||
|
display_input_path=display_input_path,
|
||||||
|
display_output_path=display_output_path,
|
||||||
|
)
|
||||||
|
if is_unc_path(output_path):
|
||||||
|
copy_local_tree_to_smb(
|
||||||
|
source=local_output,
|
||||||
|
target=output_path,
|
||||||
|
username=username,
|
||||||
|
password=password,
|
||||||
|
domain=domain or None,
|
||||||
|
)
|
||||||
|
except FileNotFoundError as error:
|
||||||
|
return render_html5_ai_structure_error(str(error))
|
||||||
|
except PermissionError as error:
|
||||||
|
return render_html5_ai_structure_error(f"Нет доступа к папке: {error}")
|
||||||
|
except OSError as error:
|
||||||
|
return render_html5_ai_structure_error(f"Ошибка файловой системы: {error}")
|
||||||
|
except RuntimeError as error:
|
||||||
|
return render_html5_ai_structure_error(str(error))
|
||||||
|
finally:
|
||||||
|
if work_dir.exists():
|
||||||
|
remove_tree(work_dir, expected_parent=work_root)
|
||||||
|
|
||||||
|
state["result"] = result
|
||||||
|
save_run_state(job_id, state)
|
||||||
|
return render_html5_ai_structure_result(result)
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_binary_input(raw_input_path: str) -> str | None:
|
||||||
|
lowered = raw_input_path.strip().casefold()
|
||||||
|
if lowered.endswith(".cf"):
|
||||||
|
return ".cf"
|
||||||
|
if lowered.endswith(".cfe"):
|
||||||
|
return ".cfe"
|
||||||
|
input_path = Path(raw_input_path)
|
||||||
|
suffixes = {".cf", ".cfe"}
|
||||||
|
if input_path.is_file() and input_path.suffix.casefold() in suffixes:
|
||||||
|
return input_path.suffix.casefold()
|
||||||
|
if not input_path.exists() or not input_path.is_dir():
|
||||||
|
return None
|
||||||
|
binary_files = sorted(path for path in input_path.rglob("*") if path.is_file() and path.suffix.casefold() in suffixes)
|
||||||
|
parseable_files = any(path.suffix.casefold() in {".xml", ".mdo", ".bsl"} for path in input_path.rglob("*") if path.is_file())
|
||||||
|
if parseable_files or not binary_files:
|
||||||
|
return None
|
||||||
|
return binary_files[0].suffix.casefold()
|
||||||
|
|
||||||
|
|
||||||
|
def _enum_text(value: object) -> str:
|
||||||
|
return str(getattr(value, "value", value or ""))
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ from api_server.html5_access_controller import (
|
|||||||
)
|
)
|
||||||
from api_server.ai_structure_service import prepare_ai_structure as _prepare_ai_structure
|
from api_server.ai_structure_service import prepare_ai_structure as _prepare_ai_structure
|
||||||
from api_server.html5_ai_structure_controller import (
|
from api_server.html5_ai_structure_controller import (
|
||||||
|
html5_ai_structure_job as _html5_ai_structure_job,
|
||||||
html5_ai_structure_page as _html5_ai_structure_page,
|
html5_ai_structure_page as _html5_ai_structure_page,
|
||||||
html5_ai_structure_run as _html5_ai_structure_run,
|
html5_ai_structure_run as _html5_ai_structure_run,
|
||||||
)
|
)
|
||||||
@@ -318,6 +319,51 @@ def _save_ai_structure_smb_credentials(project_id: str, credentials: dict[str, s
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_ai_structure_agent_run(job_id: str) -> dict[str, Any] | None:
|
||||||
|
try:
|
||||||
|
payload = _storage.read_document("ai_structure_agent_runs", job_id)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return None
|
||||||
|
password_value = str(payload.get("password_b64") or "")
|
||||||
|
try:
|
||||||
|
password = base64.b64decode(password_value.encode("ascii")).decode("utf-8") if password_value else ""
|
||||||
|
except (ValueError, UnicodeDecodeError):
|
||||||
|
password = ""
|
||||||
|
result = payload.get("result")
|
||||||
|
return {
|
||||||
|
"project_id": str(payload.get("project_id") or ""),
|
||||||
|
"effective_project_id": str(payload.get("effective_project_id") or ""),
|
||||||
|
"input_path": str(payload.get("input_path") or ""),
|
||||||
|
"output_path": str(payload.get("output_path") or ""),
|
||||||
|
"display_input_path": str(payload.get("display_input_path") or ""),
|
||||||
|
"display_output_path": str(payload.get("display_output_path") or ""),
|
||||||
|
"username": str(payload.get("username") or ""),
|
||||||
|
"domain": str(payload.get("domain") or ""),
|
||||||
|
"password": password,
|
||||||
|
"result": result if isinstance(result, dict) else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _save_ai_structure_agent_run(job_id: str, payload: dict[str, Any]) -> None:
|
||||||
|
password = str(payload.get("password") or "")
|
||||||
|
_storage.write_document(
|
||||||
|
"ai_structure_agent_runs",
|
||||||
|
job_id,
|
||||||
|
{
|
||||||
|
"project_id": str(payload.get("project_id") or ""),
|
||||||
|
"effective_project_id": str(payload.get("effective_project_id") or ""),
|
||||||
|
"input_path": str(payload.get("input_path") or ""),
|
||||||
|
"output_path": str(payload.get("output_path") or ""),
|
||||||
|
"display_input_path": str(payload.get("display_input_path") or ""),
|
||||||
|
"display_output_path": str(payload.get("display_output_path") or ""),
|
||||||
|
"username": str(payload.get("username") or ""),
|
||||||
|
"domain": str(payload.get("domain") or ""),
|
||||||
|
"password_b64": base64.b64encode(password.encode("utf-8")).decode("ascii") if password else "",
|
||||||
|
"result": payload.get("result") if isinstance(payload.get("result"), dict) else None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
_ACCESS_TARGET_KINDS = {
|
_ACCESS_TARGET_KINDS = {
|
||||||
NodeKind.CATALOG,
|
NodeKind.CATALOG,
|
||||||
NodeKind.DOCUMENT,
|
NodeKind.DOCUMENT,
|
||||||
@@ -535,6 +581,78 @@ def _agent_id_for_source(settings: "ProjectSettingsRequest", source: "ImportSour
|
|||||||
return str(agent.get("agent_id") or "").strip()
|
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:
|
||||||
|
settings = _project_settings_or_404(project_id)
|
||||||
|
binary_files = _ai_structure_binary_files(input_path)
|
||||||
|
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
|
||||||
|
agent_id = _agent_id_for_source(settings, ImportSourceKind.CF_FILE)
|
||||||
|
if not agent_id:
|
||||||
|
raise HTTPException(status_code=400, detail="В настройках проекта не выбран Windows Agent для CF/CFE.")
|
||||||
|
agent_status = _agent_status_with_liveness(_agent_statuses.get(agent_id, AgentStatus(agent_id=agent_id)))
|
||||||
|
if agent_status.status != "online":
|
||||||
|
raise HTTPException(status_code=409, detail=f"Windows Agent {agent_id} сейчас офлайн. Запустите агент и повторите.")
|
||||||
|
|
||||||
|
agent = settings.agent if isinstance(settings.agent, dict) else {}
|
||||||
|
metadata: dict[str, Any] = {
|
||||||
|
"platform_version": settings.platform_version or None,
|
||||||
|
"compatibility_mode": settings.compatibility_mode or None,
|
||||||
|
}
|
||||||
|
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:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Для разбора .cf нужен сервер 1С и имя информационной базы в настройках проекта. Сейчас они не заполнены.",
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cfe_files = [path for path in binary_files if path.suffix.casefold() == ".cfe"]
|
||||||
|
if len(cfe_files) != 1:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Для прямого разбора расширения укажите один конкретный файл .cfe, а не папку с несколькими расширениями.",
|
||||||
|
)
|
||||||
|
cfe_file = cfe_files[0]
|
||||||
|
local_path = str(cfe_file)
|
||||||
|
metadata["one_c_extension"] = cfe_file.stem
|
||||||
|
|
||||||
|
return await create_agent_import_job(
|
||||||
|
effective_project_id,
|
||||||
|
source,
|
||||||
|
AgentImportJobRequest(
|
||||||
|
agent_id=agent_id,
|
||||||
|
source=source,
|
||||||
|
local_path=local_path,
|
||||||
|
mode=ImportMode.FULL_REPLACE,
|
||||||
|
metadata=metadata,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ai_structure_binary_files(input_path: Path) -> list[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():
|
||||||
|
return []
|
||||||
|
return sorted(path for path in input_path.rglob("*") if path.is_file() and path.suffix.casefold() in {".cf", ".cfe"})
|
||||||
|
|
||||||
|
|
||||||
def _cancel_stale_extension_install_jobs(project_id: str, selected_agent_id: str) -> None:
|
def _cancel_stale_extension_install_jobs(project_id: str, selected_agent_id: str) -> None:
|
||||||
now = _current_timestamp()
|
now = _current_timestamp()
|
||||||
for job in list(_agent_import_jobs.values()):
|
for job in list(_agent_import_jobs.values()):
|
||||||
@@ -1631,17 +1749,35 @@ async def html5_project_ai_structure(project_id: str) -> Response:
|
|||||||
async def html5_project_ai_structure_run(project_id: str, request: Request) -> Response:
|
async def html5_project_ai_structure_run(project_id: str, request: Request) -> Response:
|
||||||
form = await _html5_form_data(request)
|
form = await _html5_form_data(request)
|
||||||
return _html5_response(
|
return _html5_response(
|
||||||
_html5_ai_structure_run(
|
await _html5_ai_structure_run(
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
form=form,
|
form=form,
|
||||||
prepare=_prepare_ai_structure,
|
prepare=_prepare_ai_structure,
|
||||||
work_root=_storage.root / "ai_structure_work",
|
work_root=_storage.root / "ai_structure_work",
|
||||||
|
start_binary_job=_start_ai_structure_agent_job,
|
||||||
|
save_run_state=_save_ai_structure_agent_run,
|
||||||
load_credentials=_load_ai_structure_smb_credentials,
|
load_credentials=_load_ai_structure_smb_credentials,
|
||||||
save_credentials=_save_ai_structure_smb_credentials,
|
save_credentials=_save_ai_structure_smb_credentials,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/html5/projects/{project_id}/ai-structure/jobs/{job_id}")
|
||||||
|
async def html5_project_ai_structure_job(project_id: str, job_id: str) -> Response:
|
||||||
|
return _html5_response(
|
||||||
|
_html5_ai_structure_job(
|
||||||
|
project_id=project_id,
|
||||||
|
job_id=job_id,
|
||||||
|
prepare=_prepare_ai_structure,
|
||||||
|
work_root=_storage.root / "ai_structure_work",
|
||||||
|
load_run_state=_load_ai_structure_agent_run,
|
||||||
|
save_run_state=_save_ai_structure_agent_run,
|
||||||
|
load_job=lambda current_job_id: _agent_import_jobs.get(current_job_id),
|
||||||
|
current_project_source_root=_current_project_source_root,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/html5/projects/{project_id}/access/profiles/{profile_name}/plan")
|
@app.get("/html5/projects/{project_id}/access/profiles/{profile_name}/plan")
|
||||||
async def html5_project_access_profile_plan(project_id: str, profile_name: str) -> Response:
|
async def html5_project_access_profile_plan(project_id: str, profile_name: str) -> Response:
|
||||||
return _html5_response(
|
return _html5_response(
|
||||||
|
|||||||
@@ -1809,6 +1809,84 @@ def test_ai_structure_prepare_reports_cf_cfe_export_required(tmp_path: Path):
|
|||||||
assert "Статус: `export_required`" in (output / payload["codex_package_folder"] / "README.md").read_text(encoding="utf-8")
|
assert "Статус: `export_required`" in (output / payload["codex_package_folder"] / "README.md").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_html5_ai_structure_routes_binary_cf_through_windows_agent(tmp_path: Path):
|
||||||
|
metadata_root = tmp_path / "metadata"
|
||||||
|
metadata_root.mkdir()
|
||||||
|
(metadata_root / "metadata.xml").write_text(
|
||||||
|
"""
|
||||||
|
<Configuration>
|
||||||
|
<Catalog name="Контрагенты" qualifiedName="Справочник.Контрагенты" />
|
||||||
|
</Configuration>
|
||||||
|
""",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
cf_input = tmp_path / "demo.cf"
|
||||||
|
cf_input.write_bytes(b"binary-cf")
|
||||||
|
output = tmp_path / "ai-out"
|
||||||
|
client = TestClient(app)
|
||||||
|
project_id = f"ai-agent-{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 Demo",
|
||||||
|
"structure_source": "CF_FILE",
|
||||||
|
"agent": {
|
||||||
|
"cf_agent_id": agent_id,
|
||||||
|
"one_c_server": "192.168.200.95",
|
||||||
|
"one_c_infobase": "upo_test",
|
||||||
|
"one_c_user": "svc",
|
||||||
|
"one_c_password": "secret",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
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(cf_input), "output_path": str(output)},
|
||||||
|
)
|
||||||
|
assert queued.status_code == 200
|
||||||
|
assert "Windows Agent" in queued.text
|
||||||
|
assert "/ai-structure/jobs/" in queued.text
|
||||||
|
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"] == "CF_FILE"
|
||||||
|
|
||||||
|
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 "codex-1c-context" 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):
|
def test_import_full_replace_replaces_current_normalized_project(tmp_path: Path):
|
||||||
first = tmp_path / "first"
|
first = tmp_path / "first"
|
||||||
second = tmp_path / "second"
|
second = tmp_path / "second"
|
||||||
|
|||||||
Reference in New Issue
Block a user