Add AI structure agent path check
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user