Add AI structure agent path check
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled

This commit is contained in:
2026-05-22 04:46:27 +03:00
parent b3689b1d9e
commit 2e86d25205
4 changed files with 197 additions and 0 deletions
@@ -33,6 +33,7 @@ def render_html5_ai_structure_page(
<div class="panel-title">Подготовка структуры</div> <div class="panel-title">Подготовка структуры</div>
{render_html5_ai_structure_agent_panel(project_id, agent_info=agent_info)} {render_html5_ai_structure_agent_panel(project_id, agent_info=agent_info)}
{render_html5_ai_structure_form(project_id, saved_credentials=saved_credentials)} {render_html5_ai_structure_form(project_id, saved_credentials=saved_credentials)}
<div data-html5-ai-structure-path-check>{render_html5_ai_structure_path_check(None)}</div>
<p class="object-summary">Пути должны быть доступны серверу SFERA/API. Для docker-test используйте папку, смонтированную или доступную внутри контейнера; локальные диски Windows и закрытые SMB-папки без учетных данных сервер не увидит.</p> <p class="object-summary">Пути должны быть доступны серверу SFERA/API. Для docker-test используйте папку, смонтированную или доступную внутри контейнера; локальные диски Windows и закрытые SMB-папки без учетных данных сервер не увидит.</p>
<div data-html5-ai-structure-result>{render_html5_ai_structure_result(result)}</div> <div data-html5-ai-structure-result>{render_html5_ai_structure_result(result)}</div>
</section> </section>
@@ -117,6 +118,14 @@ def render_html5_ai_structure_form(project_id: str, *, saved_credentials: dict[s
<input name="save_smb_credentials" type="checkbox" value="1" checked /> <input name="save_smb_credentials" type="checkbox" value="1" checked />
<span>Сохранить</span> <span>Сохранить</span>
</label> </label>
<button
class="button ai-structure-submit"
type="button"
hx-post="/html5/projects/{quote(project_id)}/ai-structure/check-path"
hx-include="closest form"
hx-target="[data-html5-ai-structure-path-check]"
hx-swap="innerHTML"
>Проверить путь у агента</button>
<button class="primary ai-structure-submit" type="submit">Подготовить для ИИ</button> <button class="primary ai-structure-submit" type="submit">Подготовить для ИИ</button>
</form> </form>
<section class="ai-structure-progress" data-ai-structure-progress hidden aria-live="polite"> <section class="ai-structure-progress" data-ai-structure-progress hidden aria-live="polite">
@@ -136,6 +145,32 @@ def render_html5_ai_structure_form(project_id: str, *, saved_credentials: dict[s
""" """
def render_html5_ai_structure_path_check(result: dict | None) -> str:
if result is None:
return '<p class="muted padded">Сначала можно проверить, видит ли Windows Agent входной путь и файл .cf/.cfe.</p>'
status = str(result.get("status") or "info")
title_map = {
"ok": "Путь доступен",
"error": "Путь недоступен",
"info": "Проверка пути",
}
title = title_map.get(status, "Проверка пути")
message = str(result.get("message") or "")
details = list(result.get("details") or [])
return f"""
<section class="ai-structure-result" data-html5-ai-structure-status="{escape(status)}">
<div class="access-plan-head">
<span class="status-pill">{escape(title.lower())}</span>
<strong>{escape(title)}</strong>
</div>
<ul class="access-warnings">
<li>{escape(message)}</li>
{''.join(f'<li>{escape(str(item))}</li>' for item in details)}
</ul>
</section>
"""
def render_html5_ai_structure_result(result: dict | None) -> str: def render_html5_ai_structure_result(result: dict | None) -> str:
if result is None: if result is None:
return '<p class="muted padded">Укажите входную и выходную папку. Файлы будут созданы сервером в указанном каталоге.</p>' return '<p class="muted padded">Укажите входную и выходную папку. Файлы будут созданы сервером в указанном каталоге.</p>'
@@ -10,6 +10,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_job,
render_html5_ai_structure_path_check,
render_html5_ai_structure_page, render_html5_ai_structure_page,
render_html5_ai_structure_result, render_html5_ai_structure_result,
) )
@@ -144,6 +145,19 @@ async def html5_ai_structure_run(
return render_html5_ai_structure_result(result) return render_html5_ai_structure_result(result)
async def html5_ai_structure_check_path(
*,
project_id: str,
form: dict[str, list[str]],
check_path: Callable[..., dict[str, Any]],
) -> str:
input_path = form_value(form, "input_path")
if not input_path:
return render_html5_ai_structure_path_check({"status": "error", "message": "Сначала укажите входной путь."})
result = await check_path(project_id=project_id, input_path=input_path)
return render_html5_ai_structure_path_check(result)
def html5_ai_structure_job( def html5_ai_structure_job(
*, *,
project_id: str, project_id: str,
+106
View File
@@ -66,6 +66,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_check_path as _html5_ai_structure_check_path,
html5_ai_structure_job as _html5_ai_structure_job, 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,
@@ -708,6 +709,99 @@ async def _start_ai_structure_agent_job(
) )
async def _check_ai_structure_agent_path(*, project_id: str, input_path: str) -> dict[str, Any]:
normalized_input = str(input_path or "").strip()
if not normalized_input:
return {"status": "error", "message": "Путь не указан."}
settings = _project_settings_or_404(project_id)
agent_id = _agent_id_for_source(settings, ImportSourceKind.CF_FILE)
if not agent_id:
return {
"status": "error",
"message": "В настройках проекта не выбран 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":
details = []
if agent_status.last_seen_at:
details.append(f"Последний heartbeat: {agent_status.last_seen_at}")
return {
"status": "error",
"message": f"Windows Agent {agent_id} сейчас офлайн.",
"details": details,
}
network_roots = [str(item).strip() for item in getattr(agent_status, "network_roots", []) if str(item).strip()]
if is_unc_path(normalized_input) and network_roots and not any(
_unc_path_matches_root(normalized_input, root) for root in network_roots
):
return {
"status": "error",
"message": _ai_structure_agent_root_mismatch_detail(agent_id, normalized_input, network_roots),
}
if not is_unc_path(normalized_input):
return {
"status": "info",
"message": "Это не UNC-путь. Проверка у Windows Agent нужна только для сетевых путей или путей на машине агента.",
"details": [f"Текущий путь: {normalized_input}"],
}
browse_path = normalized_input
target_name = ""
lowered = normalized_input.casefold()
if lowered.endswith(".cf") or lowered.endswith(".cfe"):
browse_path = ntpath.dirname(normalized_input) or normalized_input
target_name = ntpath.basename(normalized_input)
request = await create_agent_browse_request(AgentBrowseRequestCreate(agent_id=agent_id, path=browse_path))
for _ in range(20):
current = _agent_browse_requests.get(request.request_id)
if current is None:
break
if current.status not in {AgentBrowseRequestStatus.QUEUED, AgentBrowseRequestStatus.RUNNING}:
request = current
break
await asyncio.sleep(0.5)
else:
return {
"status": "info",
"message": f"Windows Agent {agent_id} еще проверяет путь {browse_path}. Повторите через пару секунд.",
}
if request.status == AgentBrowseRequestStatus.FAILED:
details = [str(request.error)] if request.error else []
return {
"status": "error",
"message": f"Windows Agent {agent_id} не смог открыть путь {browse_path}.",
"details": details,
}
if request.status != AgentBrowseRequestStatus.SUCCEEDED:
return {
"status": "info",
"message": f"Проверка пути {browse_path} еще не завершилась.",
}
entries = list(request.entries or [])
if target_name:
names = {str(item.name) for item in entries}
if target_name not in names:
return {
"status": "error",
"message": f"Папка {browse_path} доступна, но файл {target_name} в ней не найден.",
"details": [f"Найдено элементов: {len(entries)}"],
}
return {
"status": "ok",
"message": f"Windows Agent {agent_id} видит файл {target_name} в папке {browse_path}.",
"details": [f"Элементов в папке: {len(entries)}"],
}
names = [str(item.name) for item in entries[:8]]
return {
"status": "ok",
"message": f"Windows Agent {agent_id} видит путь {browse_path}.",
"details": [f"Элементов найдено: {len(entries)}"] + ([f"Первые элементы: {', '.join(names)}"] if names else []),
}
def _ai_structure_binary_files( def _ai_structure_binary_files(
raw_input_path: str, raw_input_path: str,
detected_binary_relative_path: str | None = None, detected_binary_relative_path: str | None = None,
@@ -1869,6 +1963,18 @@ async def html5_project_ai_structure_run(project_id: str, request: Request) -> R
) )
@app.post("/html5/projects/{project_id}/ai-structure/check-path")
async def html5_project_ai_structure_check_path(project_id: str, request: Request) -> Response:
form = await _html5_form_data(request)
return _html5_response(
await _html5_ai_structure_check_path(
project_id=project_id,
form=form,
check_path=_check_ai_structure_agent_path,
)
)
@app.get("/html5/projects/{project_id}/ai-structure/jobs/{job_id}") @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: async def html5_project_ai_structure_job(project_id: str, job_id: str) -> Response:
return _html5_response( return _html5_response(
+42
View File
@@ -2118,6 +2118,20 @@ def test_html5_ai_structure_page_shows_missing_agent_hint():
assert "Укажите его в настройках проекта" in page.text assert "Укажите его в настройках проекта" in page.text
def test_html5_ai_structure_check_path_button_present():
client = TestClient(app)
project_id = f"ai-path-button-{uuid4()}"
settings = client.post(
f"/projects/{project_id}/settings",
json={"name": "AI Path Button", "structure_source": "CF_FILE"},
)
assert settings.status_code == 200
page = client.get(f"/html5/projects/{project_id}/ai-structure")
assert page.status_code == 200
assert "Проверить путь у агента" in page.text
assert "/ai-structure/check-path" in page.text
def test_html5_ai_structure_reports_unc_root_mismatch_for_online_agent(tmp_path: Path): def test_html5_ai_structure_reports_unc_root_mismatch_for_online_agent(tmp_path: Path):
cf_input = r"\\192.168.220.200\mst\1c\MARKA\CODEX\CF\demo.cf" cf_input = r"\\192.168.220.200\mst\1c\MARKA\CODEX\CF\demo.cf"
client = TestClient(app) client = TestClient(app)
@@ -2154,6 +2168,34 @@ def test_html5_ai_structure_reports_unc_root_mismatch_for_online_agent(tmp_path:
assert r"\\192.168.220.220\mst" in queued.text assert r"\\192.168.220.220\mst" in queued.text
def test_html5_ai_structure_check_path_reports_root_mismatch():
client = TestClient(app)
project_id = f"ai-path-check-{uuid4()}"
agent_id = f"win-agent-{uuid4()}"
settings = client.post(
f"/projects/{project_id}/settings",
json={"name": "AI Path Check", "structure_source": "CF_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",
"network_roots": [r"\\192.168.220.220\mst"],
},
)
assert heartbeat.status_code == 200
checked = client.post(
f"/html5/projects/{project_id}/ai-structure/check-path",
data={"input_path": r"\\192.168.220.200\mst\1c\MARKA\CODEX\CF\demo.cf"},
)
assert checked.status_code == 200
assert "Путь недоступен" in checked.text
assert "доступны только корни" in checked.text
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"