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:
|
||||
return f"""
|
||||
<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 (
|
||||
render_html5_ai_structure_error,
|
||||
render_html5_ai_structure_job,
|
||||
render_html5_ai_structure_page,
|
||||
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,
|
||||
form: dict[str, list[str]],
|
||||
prepare: Callable[..., dict[str, Any]],
|
||||
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,
|
||||
save_credentials: Callable[[str, SmbCredentials], None] | None = None,
|
||||
) -> str:
|
||||
@@ -59,6 +62,37 @@ 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)
|
||||
@@ -99,3 +133,116 @@ def html5_ai_structure_run(
|
||||
if work_dir.exists():
|
||||
remove_tree(work_dir, expected_parent=work_root)
|
||||
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.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_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 = {
|
||||
NodeKind.CATALOG,
|
||||
NodeKind.DOCUMENT,
|
||||
@@ -535,6 +581,78 @@ 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:
|
||||
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:
|
||||
now = _current_timestamp()
|
||||
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:
|
||||
form = await _html5_form_data(request)
|
||||
return _html5_response(
|
||||
_html5_ai_structure_run(
|
||||
await _html5_ai_structure_run(
|
||||
project_id=project_id,
|
||||
form=form,
|
||||
prepare=_prepare_ai_structure,
|
||||
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,
|
||||
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")
|
||||
async def html5_project_access_profile_plan(project_id: str, profile_name: str) -> 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")
|
||||
|
||||
|
||||
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):
|
||||
first = tmp_path / "first"
|
||||
second = tmp_path / "second"
|
||||
|
||||
Reference in New Issue
Block a user