Initial SQL-only 1C adapter baseline
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "plugins" / "1c" / "connector"))
|
||||
|
||||
import adapter_1c_server as adapter # noqa: E402
|
||||
|
||||
|
||||
def test_sql_admin_save_load_and_redact_password(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
path = tmp_path / "bases.json"
|
||||
monkeypatch.delenv("ONEC_SQL_BASES_JSON", raising=False)
|
||||
monkeypatch.setenv("ONEC_SQL_BASES_JSON_FILE", str(path))
|
||||
config = {"upo_test": {"server": "10.0.0.2", "database": "upo", "user": "reader", "password": "secret"}}
|
||||
|
||||
adapter.sql_admin_save(config)
|
||||
|
||||
assert adapter.sql_admin_load() == config
|
||||
public = adapter.sql_admin_public(config)[0]
|
||||
assert public["has_password"] is True
|
||||
assert "password" not in public
|
||||
assert "secret" not in json.dumps(public)
|
||||
|
||||
|
||||
def test_sql_admin_rejects_inline_environment_config(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("ONEC_SQL_BASES_JSON", '{"base": {}}')
|
||||
with pytest.raises(ValueError, match="ONEC_SQL_BASES_JSON_FILE"):
|
||||
adapter.sql_admin_load()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("base_id", ["", "кириллица", "space id", "../escape"])
|
||||
def test_sql_admin_rejects_unsafe_base_id(base_id: str) -> None:
|
||||
with pytest.raises(ValueError, match="base_id"):
|
||||
adapter.sql_admin_validate({"base_id": base_id, "server": "s", "database": "d", "user": "u"})
|
||||
|
||||
|
||||
def test_sql_admin_accepts_password_environment_reference() -> None:
|
||||
base_id, item = adapter.sql_admin_validate({
|
||||
"base_id": "accounting-prod",
|
||||
"server": "sql.internal",
|
||||
"database": "Accounting",
|
||||
"user": "reader",
|
||||
"password": "ignored",
|
||||
"password_env": "ONEC_SQL_PASSWORD_ACCOUNTING",
|
||||
})
|
||||
assert base_id == "accounting-prod"
|
||||
assert item["password_env"] == "ONEC_SQL_PASSWORD_ACCOUNTING"
|
||||
assert "password" not in item
|
||||
|
||||
|
||||
def test_sql_admin_preserves_repository_user_from_settings() -> None:
|
||||
_, item = adapter.sql_admin_validate({
|
||||
"base_id": "upo_test",
|
||||
"server": "sql.internal",
|
||||
"database": "upo_test",
|
||||
"user": "reader",
|
||||
"repository": {
|
||||
"backend": "karman_bridge",
|
||||
"lock_mode": "manual",
|
||||
"runner_url": "http://runner:8121",
|
||||
"repository_user": "adm",
|
||||
"repository_password_env": "REPO_PASSWORD",
|
||||
"infobase_user": "designer-user",
|
||||
"infobase_password_env": "IB_PASSWORD",
|
||||
},
|
||||
})
|
||||
|
||||
assert item["repository"]["repository_user"] == "adm"
|
||||
assert item["repository"]["lock_mode"] == "manual"
|
||||
assert item["repository"]["repository_password_env"] == "REPO_PASSWORD"
|
||||
assert item["repository"]["infobase_user"] == "designer-user"
|
||||
assert item["repository"]["infobase_password_env"] == "IB_PASSWORD"
|
||||
@@ -0,0 +1,222 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
import scripts.audit_1c_adapter_coverage as coverage # noqa: E402
|
||||
|
||||
|
||||
def test_record_ref_from_public_reference_value() -> None:
|
||||
ref = "0123456789ABCDEF0123456789ABCDEF"
|
||||
assert coverage.record_ref_from_row({"ref": ref}) == ref
|
||||
assert coverage.record_ref_from_row({"ref": {"type": "reference", "hex": ref}}) == ref
|
||||
assert coverage.record_ref_from_row({"period": "2026-01-01"}) is None
|
||||
|
||||
|
||||
def test_audit_data_kind_runs_complete_read_chain() -> None:
|
||||
calls: list[tuple[str, dict[str, Any]]] = []
|
||||
ref = "0123456789ABCDEF0123456789ABCDEF"
|
||||
|
||||
def fake_rpc(_url: str, _token: str, method: str, payload: dict[str, Any], _timeout: float) -> dict[str, Any]:
|
||||
calls.append((method, payload))
|
||||
responses = {
|
||||
"metadata.objects.list": {"status": "ok", "objects": [{"kind": "Catalog", "name": "Товары", "ref": "Catalog.Товары"}]},
|
||||
"data.schema": {"status": "ok", "table": {"name": "_Reference1"}, "fields": [{"name": "ref"}]},
|
||||
"data.list": {"status": "ok", "rows": [{"ref": {"type": "reference", "hex": ref}}]},
|
||||
"data.count": {"status": "ok", "count": 7},
|
||||
"data.get": {"status": "ok", "rows": [{"ref": ref}]},
|
||||
}
|
||||
return responses[method]
|
||||
|
||||
result = coverage.audit_data_kind(
|
||||
"http://adapter",
|
||||
"upo_test",
|
||||
"secret",
|
||||
"Catalog",
|
||||
10,
|
||||
True,
|
||||
fake_rpc,
|
||||
)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
assert [method for method, _ in calls] == [
|
||||
"metadata.objects.list",
|
||||
"data.schema",
|
||||
"data.list",
|
||||
"data.count",
|
||||
"data.get",
|
||||
]
|
||||
assert calls[-1][1]["record_ref"] == ref
|
||||
assert result["operations"]["data.count"]["count"] == 7
|
||||
|
||||
|
||||
def test_audit_data_kind_marks_get_not_applicable_for_register_row() -> None:
|
||||
def fake_rpc(_url: str, _token: str, method: str, _payload: dict[str, Any], _timeout: float) -> dict[str, Any]:
|
||||
responses = {
|
||||
"metadata.objects.list": {"status": "ok", "objects": [{"kind": "InformationRegister", "name": "Курсы"}]},
|
||||
"data.schema": {"status": "ok", "table": {"name": "_InfoRg1"}, "fields": [{"name": "period"}]},
|
||||
"data.list": {"status": "ok", "rows": [{"period": "2026-01-01"}]},
|
||||
"data.count": {"status": "ok", "count": 1},
|
||||
}
|
||||
return responses[method]
|
||||
|
||||
result = coverage.audit_data_kind(
|
||||
"http://adapter", "upo_test", "secret", "InformationRegister", 10, True, fake_rpc
|
||||
)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
assert result["operations"]["data.get"] == {
|
||||
"status": "not_applicable",
|
||||
"reason": "object_has_no_reference_key",
|
||||
"duration_ms": 0,
|
||||
}
|
||||
|
||||
|
||||
def test_audit_data_kind_does_not_mark_get_not_applicable_when_list_failed() -> None:
|
||||
def fake_rpc(_url: str, _token: str, method: str, _payload: dict[str, Any], _timeout: float) -> dict[str, Any]:
|
||||
responses = {
|
||||
"metadata.objects.list": {"status": "ok", "objects": [{"kind": "Document", "name": "Продажа"}]},
|
||||
"data.schema": {"status": "ok", "table": {"name": "_Document1"}, "fields": [{"name": "ref"}]},
|
||||
"data.list": {"status": "transport_error", "diagnostics": {"message": "timed out"}},
|
||||
"data.count": {"status": "ok", "count": 3},
|
||||
}
|
||||
return responses[method]
|
||||
|
||||
result = coverage.audit_data_kind(
|
||||
"http://adapter", "upo_test", "secret", "Document", 10, True, fake_rpc
|
||||
)
|
||||
|
||||
assert result["status"] == "degraded"
|
||||
assert result["operations"]["data.get"]["status"] == "blocked"
|
||||
assert result["operations"]["data.get"]["reason"] == "data_list_failed"
|
||||
|
||||
|
||||
def test_run_data_checks_saves_and_resumes_checkpoint(tmp_path: Path) -> None:
|
||||
checkpoint = tmp_path / "coverage.checkpoint.json"
|
||||
calls: list[str] = []
|
||||
|
||||
def fake_rpc(_url: str, _token: str, method: str, payload: dict[str, Any], _timeout: float) -> dict[str, Any]:
|
||||
calls.append(f"{method}:{payload.get('kind', '')}")
|
||||
if method == "metadata.objects.list":
|
||||
return {"status": "ok", "objects": [{"kind": payload["kind"], "name": payload["kind"]}]}
|
||||
if method == "data.schema":
|
||||
return {"status": "ok", "table": {"name": "_Data1"}, "fields": []}
|
||||
raise AssertionError(method)
|
||||
|
||||
first, resumed = coverage.run_data_checks(
|
||||
"http://adapter", "upo_test", "secret", ["Catalog", "Document"], 10, False, 2, checkpoint, False, False, fake_rpc
|
||||
)
|
||||
assert resumed == 0
|
||||
assert set(first) == {"Catalog", "Document"}
|
||||
saved = json.loads(checkpoint.read_text(encoding="utf-8"))
|
||||
assert saved["include_reads"] is False
|
||||
assert set(saved["checks"]) == {"Catalog", "Document"}
|
||||
|
||||
calls.clear()
|
||||
second, resumed = coverage.run_data_checks(
|
||||
"http://adapter", "upo_test", "secret", ["Catalog", "Document"], 10, False, 2, checkpoint, True, False, fake_rpc
|
||||
)
|
||||
assert resumed == 2
|
||||
assert second == first
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_run_data_checks_can_retry_only_degraded_entries(tmp_path: Path) -> None:
|
||||
checkpoint = tmp_path / "coverage.checkpoint.json"
|
||||
checkpoint.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema": "onec_adapter_data_audit_checkpoint.v1",
|
||||
"base_url": "http://adapter",
|
||||
"base_id": "upo_test",
|
||||
"include_reads": False,
|
||||
"checks": {
|
||||
"Catalog": {"kind": "Catalog", "status": "ok"},
|
||||
"Document": {"kind": "Document", "status": "degraded"},
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
calls: list[str] = []
|
||||
|
||||
def fake_rpc(_url: str, _token: str, method: str, payload: dict[str, Any], _timeout: float) -> dict[str, Any]:
|
||||
calls.append(method)
|
||||
if method == "metadata.objects.list":
|
||||
return {"status": "ok", "objects": [{"kind": payload["kind"], "name": "Продажа"}]}
|
||||
if method == "data.schema":
|
||||
return {"status": "ok", "table": {"name": "_Document1"}, "fields": []}
|
||||
raise AssertionError(method)
|
||||
|
||||
checks, resumed = coverage.run_data_checks(
|
||||
"http://adapter", "upo_test", "secret", ["Catalog", "Document"], 10, False, 1, checkpoint, True, True, fake_rpc
|
||||
)
|
||||
|
||||
assert resumed == 1
|
||||
assert checks["Catalog"]["status"] == "ok"
|
||||
assert checks["Document"]["status"] == "ok"
|
||||
assert calls == ["metadata.objects.list", "data.schema"]
|
||||
|
||||
|
||||
def test_checkpoint_rejects_schema_only_resume_for_full_reads(tmp_path: Path) -> None:
|
||||
checkpoint = tmp_path / "coverage.checkpoint.json"
|
||||
checkpoint.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema": "onec_adapter_data_audit_checkpoint.v1",
|
||||
"base_url": "http://adapter",
|
||||
"base_id": "upo_test",
|
||||
"include_reads": False,
|
||||
"checks": {},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with pytest.raises(ValueError, match="another data audit mode"):
|
||||
coverage.load_checkpoint(checkpoint, "http://adapter", "upo_test", True, True)
|
||||
|
||||
|
||||
def test_build_report_does_not_treat_unchecked_non_data_kind_as_list_failure() -> None:
|
||||
def fake_rpc(_url: str, _token: str, method: str, payload: dict[str, Any], _timeout: float) -> dict[str, Any]:
|
||||
if method == "metadata.adapter.audit":
|
||||
return {
|
||||
"status": "ok",
|
||||
"optional_deep_reads": [
|
||||
{"kind": "DocumentJournal", "property": "column_types", "flag": "include_column_types=true"}
|
||||
],
|
||||
"metadata_kinds": [
|
||||
{"kind": "Catalog", "kind_ru": "Справочник", "count": 1, "capabilities": ["list"]},
|
||||
{"kind": "CommonModule", "kind_ru": "ОбщийМодуль", "count": 1, "capabilities": ["list"]},
|
||||
],
|
||||
}
|
||||
if method == "metadata.objects.list":
|
||||
return {"status": "ok", "objects": [{"kind": payload["kind"], "name": "Товары"}]}
|
||||
if method == "data.schema":
|
||||
return {"status": "ok", "table": {"name": "_Reference1"}, "fields": []}
|
||||
raise AssertionError(method)
|
||||
|
||||
report = coverage.build_report(
|
||||
"http://adapter",
|
||||
"upo_test",
|
||||
"secret",
|
||||
10,
|
||||
True,
|
||||
True,
|
||||
workers=1,
|
||||
rpc_call=fake_rpc,
|
||||
)
|
||||
|
||||
assert report["status"] == "ok"
|
||||
assert report["list_failures"] == []
|
||||
assert report["counts"]["data_kinds_checked"] == 1
|
||||
assert report["optional_deep_reads"][0]["property"] == "column_types"
|
||||
common_module = next(row for row in report["matrix"] if row["kind"] == "CommonModule")
|
||||
assert "list_status" not in common_module
|
||||
@@ -0,0 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def test_rest_deploy_refuses_to_drop_service_authentication() -> None:
|
||||
source = (ROOT / "scripts" / "deploy_1c_adapter_stack.ps1").read_text(encoding="utf-8-sig")
|
||||
assert "function Ensure-RestServiceToken" in source
|
||||
assert "ONEC_ADAPTER_ALLOW_UNAUTHENTICATED_ADMIN" in source
|
||||
assert "Reusing the existing REST service token without printing or persisting it" in source
|
||||
assert "Refusing to deploy an unauthenticated adapter" in source
|
||||
assert source.index("Ensure-RestServiceToken") < source.index("Invoke-ComposeUp `", source.index("try {"))
|
||||
@@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
|
||||
import report_failed_turns as failed_turns_script # noqa: E402
|
||||
|
||||
|
||||
def write_jsonl(path: Path, records: list[dict]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("w", encoding="utf-8") as handle:
|
||||
for record in records:
|
||||
handle.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||
|
||||
|
||||
def test_report_failed_turns_filters_non_success(tmp_path: Path) -> None:
|
||||
root = tmp_path / "reports" / "observability" / "onec-agent" / "turn_audit" / "20260705.jsonl"
|
||||
write_jsonl(
|
||||
root,
|
||||
[
|
||||
{
|
||||
"timestamp": "2026-07-05T10:00:00+00:00",
|
||||
"turn_id": "ok-1",
|
||||
"plugin": "1c",
|
||||
"outcome": "success",
|
||||
"failure_type": "none",
|
||||
"user_text": "ok",
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-07-05T11:00:00+00:00",
|
||||
"turn_id": "bad-1",
|
||||
"plugin": "1c",
|
||||
"project_id": "p1",
|
||||
"chat_id": "c1",
|
||||
"request_id": "r1",
|
||||
"outcome": "failure",
|
||||
"failure_type": "adapter_error",
|
||||
"error_code": "adapter_error",
|
||||
"error_message": "missing base_id",
|
||||
"user_text": "Проверь сохраненное состояние",
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
records = failed_turns_script.iter_turn_audit(tmp_path / "reports" / "observability")
|
||||
failed = failed_turns_script.filter_failed(records, limit=10)
|
||||
|
||||
assert len(failed) == 1
|
||||
assert failed[0]["turn_id"] == "bad-1"
|
||||
assert failed[0]["failure_type"] == "adapter_error"
|
||||
assert failed[0]["error_code"] == "adapter_error"
|
||||
@@ -0,0 +1,69 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
|
||||
import generate_1c_training_data as gen # noqa: E402
|
||||
|
||||
|
||||
def test_verified_write_records_capture_safe_route() -> None:
|
||||
registry = {
|
||||
"base_id": "upo_test",
|
||||
"table": "ConfigSave",
|
||||
"file_name": "form.0",
|
||||
"patterns": [
|
||||
{
|
||||
"effective_section": "items",
|
||||
"canonical_property": "title",
|
||||
"presentation": "Заголовок",
|
||||
"value_type": "string",
|
||||
"write_path": "1.2.3",
|
||||
"source_kind": "local",
|
||||
"verification": "source_aware_readback",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
records = gen.verified_write_records(registry, "registry-smoke", max_records=5)
|
||||
|
||||
assert len(records) == 1
|
||||
record = records[0]
|
||||
assert record["id"] == "registry-smoke-verified-write-001"
|
||||
assert record["metadata"]["task"] == "metadata-write-verified"
|
||||
assistant = record["messages"][-1]["content"]
|
||||
assert "base_id: upo_test" in assistant
|
||||
assert "apply_and_rollback" in assistant
|
||||
assert "Не расширяй allowlist" in assistant
|
||||
|
||||
|
||||
def test_write_learning_plan_records_keep_cases_manual() -> None:
|
||||
plan = {
|
||||
"workflow": ["capture_before", "capture_after", "diff", "smoke_rule"],
|
||||
"cases": [
|
||||
{
|
||||
"id": "learn-001",
|
||||
"action": "run_before_after_learning_for_parameter",
|
||||
"property": "параметр 14",
|
||||
"value_type": "integer_atom",
|
||||
"write_path": "1.22.14",
|
||||
"current_value": "2",
|
||||
"manual_step": {
|
||||
"target": "ФормаКоманднаяПанель",
|
||||
"instruction": "Измени свойство в тестовой базе",
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
records = gen.write_learning_plan_records(plan, "plan-smoke", max_records=1)
|
||||
|
||||
assert len(records) == 1
|
||||
record = records[0]
|
||||
assert record["metadata"]["task"] == "metadata-write-learning-plan"
|
||||
assistant = record["messages"][-1]["content"]
|
||||
assert "Это не готовый allowlist" in assistant
|
||||
assert "capture_before" in assistant
|
||||
assert "smoke-проверки" in assistant
|
||||
@@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from argparse import Namespace
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SCRIPT = ROOT / "scripts" / "run_1c_moxel_discovery_pipeline.py"
|
||||
SPEC = importlib.util.spec_from_file_location("run_1c_moxel_discovery_pipeline", SCRIPT)
|
||||
assert SPEC and SPEC.loader
|
||||
pipeline = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(pipeline)
|
||||
|
||||
|
||||
def args(**overrides) -> Namespace:
|
||||
values = {
|
||||
"probe": str(pipeline.DEFAULT_PROBE),
|
||||
"baseline_discovery": str(pipeline.DEFAULT_BASELINE_DISCOVERY),
|
||||
"history_matrix": str(pipeline.DEFAULT_HISTORY_MATRIX),
|
||||
"property_candidates": str(pipeline.DEFAULT_PROPERTY_CANDIDATES),
|
||||
"property_manifest": str(pipeline.DEFAULT_PROPERTY_MANIFEST),
|
||||
"capture_live": False,
|
||||
"base_id": "upo_test",
|
||||
"label": "pipeline",
|
||||
"file_name": None,
|
||||
"scan_limit": 30,
|
||||
"max_cells": 800,
|
||||
}
|
||||
values.update(overrides)
|
||||
return Namespace(**values)
|
||||
|
||||
|
||||
def test_build_commands_orders_discovery_before_registry() -> None:
|
||||
commands = pipeline.build_commands(args())
|
||||
names = [name for name, _ in commands]
|
||||
|
||||
assert names == [
|
||||
"discover_schema",
|
||||
"analyze_named_range_rules",
|
||||
"analyze_property_experiments",
|
||||
"build_registry",
|
||||
"check_registry",
|
||||
"verify_registry",
|
||||
"plan_next_experiments",
|
||||
"get_next_action",
|
||||
"check_next_action",
|
||||
"status",
|
||||
]
|
||||
|
||||
|
||||
def test_build_commands_can_plan_live_capture_first() -> None:
|
||||
commands = pipeline.build_commands(args(capture_live=True, file_name="abc"))
|
||||
|
||||
assert commands[0][0] == "capture_live_probe"
|
||||
assert "--file-name" in commands[0][1]
|
||||
assert "--output-json" in commands[0][1]
|
||||
output_index = commands[0][1].index("--output-json") + 1
|
||||
assert commands[0][1][output_index] == "reports\\1c-template-probes\\latest-live-probe.json"
|
||||
@@ -0,0 +1,64 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SCRIPT = ROOT / "scripts" / "analyze_1c_moxel_named_range_rules.py"
|
||||
SPEC = importlib.util.spec_from_file_location("analyze_1c_moxel_named_range_rules", SCRIPT)
|
||||
assert SPEC and SPEC.loader
|
||||
named_rules = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(named_rules)
|
||||
|
||||
|
||||
def probe(raw_scalars: list[str], one_based: dict[str, int], name: str = "RANGE_RECT_TEST") -> dict:
|
||||
return {
|
||||
"probe": {
|
||||
"named_ranges": [
|
||||
{
|
||||
"name": name,
|
||||
"kind": "named_cell_or_range",
|
||||
"range": {"one_based": one_based},
|
||||
"raw_scalars": raw_scalars,
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def test_single_cell_named_range_stays_ambiguous() -> None:
|
||||
result = named_rules.analyze(
|
||||
[
|
||||
probe(
|
||||
["1", "3", "3", "7", "3", "7", "00000000-0000-0000-0000-000000000000", "0"],
|
||||
{"left": 4, "right": 4, "top": 8, "bottom": 8},
|
||||
name="R7C2_TEST",
|
||||
)
|
||||
],
|
||||
"R7C2_TEST",
|
||||
)
|
||||
rules = {rule["target"]: rule for rule in result["rules"]}
|
||||
|
||||
assert rules["moxel.named_range.left"]["confidence"] == "medium"
|
||||
assert rules["moxel.named_range.left"]["raw_scalar_indexes"] == [2, 4]
|
||||
assert rules["moxel.named_range.top"]["raw_scalar_indexes"] == [3, 5]
|
||||
|
||||
|
||||
def test_rectangular_named_range_promotes_unique_indexes() -> None:
|
||||
result = named_rules.analyze(
|
||||
[
|
||||
probe(
|
||||
["1", "0", "3", "7", "6", "9", "00000000-0000-0000-0000-000000000000", "0"],
|
||||
{"left": 4, "top": 8, "right": 7, "bottom": 10},
|
||||
)
|
||||
],
|
||||
"RANGE_RECT_TEST",
|
||||
)
|
||||
rules = {rule["target"]: rule for rule in result["rules"]}
|
||||
|
||||
assert rules["moxel.named_range.left"]["confidence"] == "high"
|
||||
assert rules["moxel.named_range.left"]["raw_scalar_indexes"] == [2]
|
||||
assert rules["moxel.named_range.top"]["raw_scalar_indexes"] == [3]
|
||||
assert rules["moxel.named_range.right"]["raw_scalar_indexes"] == [4]
|
||||
assert rules["moxel.named_range.bottom"]["raw_scalar_indexes"] == [5]
|
||||
@@ -0,0 +1,69 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SCRIPT = ROOT / "scripts" / "get_1c_moxel_next_action.py"
|
||||
SPEC = importlib.util.spec_from_file_location("get_1c_moxel_next_action", SCRIPT)
|
||||
assert SPEC and SPEC.loader
|
||||
next_action = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(next_action)
|
||||
|
||||
|
||||
def test_choose_action_prefers_rectangular_named_range() -> None:
|
||||
plan = {
|
||||
"experiments": [
|
||||
{"id": "cell_vertical_align", "target": "cell_or_layout_property"},
|
||||
{"id": "named_range_rectangular_area", "target": "named_range"},
|
||||
]
|
||||
}
|
||||
|
||||
action = next_action.choose_action(plan)
|
||||
|
||||
assert action["id"] == "named_range_rectangular_area"
|
||||
|
||||
|
||||
def test_choose_action_prefers_presence_probe_over_coordinate_split() -> None:
|
||||
plan = {
|
||||
"experiments": [
|
||||
{"id": "named_range_rectangular_area", "target": "named_range"},
|
||||
{"id": "named_range_presence_probe", "target": "named_range"},
|
||||
]
|
||||
}
|
||||
|
||||
action = next_action.choose_action(plan)
|
||||
|
||||
assert action["id"] == "named_range_presence_probe"
|
||||
|
||||
|
||||
def test_render_markdown_adds_run_pipeline_after() -> None:
|
||||
markdown = next_action.render_markdown(
|
||||
{
|
||||
"id": "cell_vertical_align",
|
||||
"target": "cell_or_layout_property",
|
||||
"goal": "Map vertical alignment.",
|
||||
"manual_action": "Change vertical alignment.",
|
||||
"capture_command": "python scripts/watch_1c_moxel_property_experiment.py --property ВертикальноеПоложение",
|
||||
"expected_signal": "One local path changes.",
|
||||
}
|
||||
)
|
||||
|
||||
assert "--run-pipeline-after" in markdown
|
||||
assert "Change vertical alignment." in markdown
|
||||
|
||||
|
||||
def test_build_payload_exposes_machine_readable_command() -> None:
|
||||
payload = next_action.build_payload(
|
||||
{
|
||||
"id": "named_range_rectangular_area",
|
||||
"target": "named_range",
|
||||
"capture_command": "python scripts/watch_1c_moxel_property_experiment.py --property NamedRangeRectangle",
|
||||
}
|
||||
)
|
||||
|
||||
assert payload["schema"] == "codex_1c_moxel_next_action.v1"
|
||||
assert payload["status"] == "ok"
|
||||
assert payload["action"]["id"] == "named_range_rectangular_area"
|
||||
assert payload["action"]["capture_command_with_pipeline"].endswith("--run-pipeline-after")
|
||||
@@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SCRIPT = ROOT / "scripts" / "check_1c_moxel_next_action.py"
|
||||
SPEC = importlib.util.spec_from_file_location("check_1c_moxel_next_action", SCRIPT)
|
||||
assert SPEC and SPEC.loader
|
||||
check = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(check)
|
||||
|
||||
|
||||
def plan() -> dict:
|
||||
return {"experiments": [{"id": "named_range_rectangular_area", "target": "named_range"}]}
|
||||
|
||||
|
||||
def action(command: str) -> dict:
|
||||
return {
|
||||
"schema": "codex_1c_moxel_next_action.v1",
|
||||
"status": "ok",
|
||||
"action": {
|
||||
"id": "named_range_rectangular_area",
|
||||
"target": "named_range",
|
||||
"manual_action": "Create range.",
|
||||
"expected_signal": "Distinct raw scalars.",
|
||||
"capture_command_with_pipeline": command,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_check_next_action_accepts_valid_action() -> None:
|
||||
report = check.check_next_action(plan(), action("python watcher.py --run-pipeline-after"))
|
||||
|
||||
assert report["status"] == "ok"
|
||||
assert report["failures"] == []
|
||||
|
||||
|
||||
def test_check_next_action_requires_pipeline_flag() -> None:
|
||||
report = check.check_next_action(plan(), action("python watcher.py"))
|
||||
|
||||
assert report["status"] == "failed"
|
||||
assert "capture_command_with_pipeline must include --run-pipeline-after" in report["failures"]
|
||||
@@ -0,0 +1,61 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SCRIPT = ROOT / "scripts" / "plan_1c_moxel_next_experiments.py"
|
||||
SPEC = importlib.util.spec_from_file_location("plan_1c_moxel_next_experiments", SCRIPT)
|
||||
assert SPEC and SPEC.loader
|
||||
planner = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(planner)
|
||||
|
||||
|
||||
def test_build_plan_includes_named_range_experiments_for_candidate_rules() -> None:
|
||||
registry = {
|
||||
"rules": [
|
||||
{"target": "moxel.inline_text_cell.column", "read_status": "verified_read"},
|
||||
{"target": "moxel.named_range.left", "read_status": "candidate_read"},
|
||||
{"target": "moxel.named_range.right", "read_status": "candidate_read"},
|
||||
{"target": "moxel.named_range.top", "read_status": "candidate_read"},
|
||||
{"target": "moxel.named_range.bottom", "read_status": "candidate_read"},
|
||||
]
|
||||
}
|
||||
|
||||
plan = planner.build_plan(registry)
|
||||
ids = {item["id"] for item in plan["experiments"]}
|
||||
|
||||
assert "named_range_rectangular_area" in ids
|
||||
assert "named_range_horizontal_resize" in ids
|
||||
assert "named_range_vertical_resize" in ids
|
||||
assert plan["counts"]["named_range_experiments"] == 3
|
||||
|
||||
|
||||
def test_build_plan_keeps_property_experiments_even_without_named_range_gaps() -> None:
|
||||
plan = planner.build_plan({"rules": [{"target": "moxel.inline_text_cell.column", "read_status": "verified_read"}]})
|
||||
|
||||
assert plan["counts"]["named_range_experiments"] == 0
|
||||
assert plan["counts"]["property_experiments"] >= 1
|
||||
|
||||
|
||||
def test_build_plan_prioritizes_named_range_presence_when_verification_has_no_samples() -> None:
|
||||
registry = {
|
||||
"rules": [
|
||||
{"target": "moxel.named_range.left", "read_status": "candidate_read"},
|
||||
{"target": "moxel.named_range.right", "read_status": "candidate_read"},
|
||||
]
|
||||
}
|
||||
verification = {
|
||||
"results": [
|
||||
{
|
||||
"target": "moxel.named_range.left",
|
||||
"probes": [{"evidence": {"ok": 0, "total": 0}}],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
plan = planner.build_plan(registry, verification)
|
||||
|
||||
assert plan["experiments"][0]["id"] == "named_range_presence_probe"
|
||||
assert "named_ranges" in plan["experiments"][0]["expected_signal"]
|
||||
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SCRIPT = ROOT / "scripts" / "analyze_1c_moxel_property_experiments.py"
|
||||
SPEC = importlib.util.spec_from_file_location("analyze_1c_moxel_property_experiments", SCRIPT)
|
||||
assert SPEC and SPEC.loader
|
||||
experiments = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(experiments)
|
||||
|
||||
|
||||
def test_analyze_experiment_ranks_target_style_path(tmp_path: Path) -> None:
|
||||
before = {
|
||||
"structure": {
|
||||
"counts": {"cell_style_candidates": 1},
|
||||
"cell_style_candidates": [
|
||||
{
|
||||
"text": "Ячейка 7 - 2",
|
||||
"tree_position": "$.39",
|
||||
"style_evidence": {"last_7_preceding_values": ["1", "2", "7", "0", "3", "2", "3"]},
|
||||
"next_moxel_record": {"type": "atom", "value": "4", "tree_position": "$.40"},
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
after = {
|
||||
"structure": {
|
||||
"counts": {"cell_style_candidates": 1},
|
||||
"cell_style_candidates": [
|
||||
{
|
||||
"text": "Ячейка 7 - 2",
|
||||
"tree_position": "$.39",
|
||||
"style_evidence": {"last_7_preceding_values": ["1", "2", "7", "0", "3", "2", "3"]},
|
||||
"next_moxel_record": {"type": "atom", "value": "5", "tree_position": "$.40"},
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
before_path = tmp_path / "before.json"
|
||||
after_path = tmp_path / "after.json"
|
||||
before_path.write_text(json.dumps(before, ensure_ascii=False), encoding="utf-8")
|
||||
after_path.write_text(json.dumps(after, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
result = experiments.analyze_experiment(
|
||||
{
|
||||
"property": "ВертикальноеПоложение",
|
||||
"target_text": "Ячейка 7 - 2",
|
||||
"before": str(before_path),
|
||||
"after": str(after_path),
|
||||
},
|
||||
tmp_path,
|
||||
)
|
||||
|
||||
assert result["confidence"] in {"medium", "high"}
|
||||
assert result["candidate_paths"]
|
||||
assert "target_cell_styles" in result["candidate_paths"][0]["path"]
|
||||
assert result["candidate_paths"][0]["after"] == "5"
|
||||
|
||||
|
||||
def test_default_probe_plan_covers_core_cell_and_layout_properties() -> None:
|
||||
properties = {item["property"] for item in experiments.default_probe_plan()}
|
||||
|
||||
assert "ГоризонтальноеПоложение" in properties
|
||||
assert "ВертикальноеПоложение" in properties
|
||||
assert "ШиринаКолонки" in properties
|
||||
assert "Объединение" in properties
|
||||
|
||||
|
||||
def test_resolve_manifest_path_accepts_project_relative_paths() -> None:
|
||||
path = experiments.resolve_manifest_path(
|
||||
"tests/1c/test_moxel_property_experiments.py",
|
||||
ROOT / "reports" / "1c-template-baselines",
|
||||
)
|
||||
|
||||
assert path == Path(__file__).resolve()
|
||||
@@ -0,0 +1,54 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SCRIPT = ROOT / "scripts" / "watch_1c_moxel_property_experiment.py"
|
||||
SPEC = importlib.util.spec_from_file_location("watch_1c_moxel_property_experiment", SCRIPT)
|
||||
assert SPEC and SPEC.loader
|
||||
watch = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(watch)
|
||||
|
||||
|
||||
def test_append_manifest_experiment_creates_relative_paths(tmp_path: Path) -> None:
|
||||
manifest_path = tmp_path / "experiments.json"
|
||||
before = tmp_path / "before.json"
|
||||
after = tmp_path / "after.json"
|
||||
before.write_text("{}", encoding="utf-8")
|
||||
after.write_text("{}", encoding="utf-8")
|
||||
|
||||
experiment = watch.append_manifest_experiment(
|
||||
manifest_path,
|
||||
property_name="ВертикальноеПоложение",
|
||||
operation="manual_one_property_save",
|
||||
target_text="Ячейка 7 - 2",
|
||||
target_name="R7C2_TEST",
|
||||
before_path=before,
|
||||
after_path=after,
|
||||
)
|
||||
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
assert experiment["before"] == "before.json"
|
||||
assert experiment["after"] == "after.json"
|
||||
assert manifest["experiments"] == [experiment]
|
||||
|
||||
|
||||
def test_snapshot_identity_uses_stable_change_fields() -> None:
|
||||
identity = watch.snapshot_identity({"file_name": "abc", "modified": "4026-01-01", "bytes": 123, "label": "ignored"})
|
||||
|
||||
assert identity == {"file_name": "abc", "modified": "4026-01-01", "bytes": 123}
|
||||
|
||||
|
||||
def test_pipeline_command_uses_after_probe_and_manifest() -> None:
|
||||
command = watch.pipeline_command(Path("after.json"), Path("manifest.json"))
|
||||
|
||||
assert command[1:] == [
|
||||
"scripts/run_1c_moxel_discovery_pipeline.py",
|
||||
"--probe",
|
||||
"after.json",
|
||||
"--property-manifest",
|
||||
"manifest.json",
|
||||
]
|
||||
@@ -0,0 +1,72 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SCRIPT = ROOT / "scripts" / "discover_1c_moxel_schema.py"
|
||||
SPEC = importlib.util.spec_from_file_location("discover_1c_moxel_schema", SCRIPT)
|
||||
assert SPEC and SPEC.loader
|
||||
discovery = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(discovery)
|
||||
|
||||
|
||||
def test_analyze_marker_matrix_infers_inline_text_column_from_last_preceding_scalar() -> None:
|
||||
payload = {
|
||||
"schema": "codex_1c_template_marker_matrix.v1",
|
||||
"rows": [
|
||||
{"text": "10-2", "expected_row": 10, "expected_col": 2, "decoded_row": 10, "decoded_col": 3, "preceding": ["9", "0", "3", "1"]},
|
||||
{"text": "10-3", "expected_row": 10, "expected_col": 3, "decoded_row": 10, "decoded_col": 4, "preceding": ["2"]},
|
||||
{"text": "10-4", "expected_row": 10, "expected_col": 4, "decoded_row": 10, "decoded_col": 11, "preceding": ["3"]},
|
||||
],
|
||||
}
|
||||
|
||||
analysis = discovery.analyze_marker_matrix([payload])
|
||||
rule = analysis["candidate_rules"][0]
|
||||
|
||||
assert analysis["current_decoder"]["row_accuracy"] == {"ok": 3, "total": 3}
|
||||
assert analysis["current_decoder"]["column_accuracy"] == {"ok": 0, "total": 3}
|
||||
assert rule["target"] == "moxel.inline_text_cell.column"
|
||||
assert rule["confidence"] == "high"
|
||||
assert rule["evidence"] == {"ok": 3, "total": 3}
|
||||
assert analysis["row_runs"][0]["sequence_ok"] is True
|
||||
|
||||
|
||||
def test_infer_named_range_rules_from_history_matrix_moves() -> None:
|
||||
history_matrix = {
|
||||
"transitions": [
|
||||
{
|
||||
"named_range_changes": {
|
||||
"one_based": {
|
||||
"before": {"row_start": 7, "row_end": 7, "column_start": 3, "column_end": 3},
|
||||
"after": {"row_start": 8, "row_end": 8, "column_start": 3, "column_end": 3},
|
||||
},
|
||||
"raw_scalars": {
|
||||
"before": ["1", "3", "2", "6", "2", "6", "00000000-0000-0000-0000-000000000000", "0"],
|
||||
"after": ["1", "3", "2", "7", "2", "7", "00000000-0000-0000-0000-000000000000", "0"],
|
||||
},
|
||||
}
|
||||
},
|
||||
{
|
||||
"named_range_changes": {
|
||||
"one_based": {
|
||||
"before": {"row_start": 8, "row_end": 8, "column_start": 3, "column_end": 3},
|
||||
"after": {"row_start": 8, "row_end": 8, "column_start": 4, "column_end": 4},
|
||||
},
|
||||
"raw_scalars": {
|
||||
"before": ["1", "3", "2", "7", "2", "7", "00000000-0000-0000-0000-000000000000", "0"],
|
||||
"after": ["1", "3", "3", "7", "3", "7", "00000000-0000-0000-0000-000000000000", "0"],
|
||||
},
|
||||
}
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
result = discovery.infer_named_range_rules(history_matrix, {})
|
||||
rules_by_target = {rule["target"]: rule for rule in result["rules"]}
|
||||
|
||||
assert rules_by_target["moxel.named_range.top"]["raw_scalar_indexes"] == [3, 5]
|
||||
assert rules_by_target["moxel.named_range.bottom"]["raw_scalar_indexes"] == [3, 5]
|
||||
assert rules_by_target["moxel.named_range.left"]["raw_scalar_indexes"] == [2, 4]
|
||||
assert rules_by_target["moxel.named_range.right"]["raw_scalar_indexes"] == [2, 4]
|
||||
@@ -0,0 +1,130 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def load_script(name: str):
|
||||
path = ROOT / "scripts" / name
|
||||
spec = importlib.util.spec_from_file_location(path.stem, path)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
builder = load_script("build_1c_moxel_schema_registry.py")
|
||||
checker = load_script("check_1c_moxel_schema_registry.py")
|
||||
|
||||
|
||||
def test_build_registry_promotes_complete_high_confidence_rule_to_verified_read() -> None:
|
||||
registry = builder.build_registry(
|
||||
{
|
||||
"rules": [
|
||||
{
|
||||
"id": "inline_text_column_from_last_preceding_scalar_plus_one",
|
||||
"target": "moxel.inline_text_cell.column",
|
||||
"expression": "one_based_column = int(last_numeric(preceding_scalars)) + 1",
|
||||
"confidence": "high",
|
||||
"evidence": {"ok": 16, "total": 16},
|
||||
},
|
||||
{
|
||||
"target": "moxel.named_range.left",
|
||||
"confidence": "medium",
|
||||
"raw_scalar_indexes": [2, 4],
|
||||
},
|
||||
]
|
||||
},
|
||||
["discovery.json"],
|
||||
)
|
||||
|
||||
rules = {rule["target"]: rule for rule in registry["rules"]}
|
||||
assert rules["moxel.inline_text_cell.column"]["read_status"] == "verified_read"
|
||||
assert rules["moxel.inline_text_cell.column"]["write_status"] == "blocked_until_roundtrip"
|
||||
assert rules["moxel.named_range.left"]["read_status"] == "candidate_read"
|
||||
assert registry["counts"]["verified_read"] == 1
|
||||
assert registry["counts"]["write_enabled"] == 0
|
||||
|
||||
|
||||
def test_build_registry_promotes_named_range_analysis_over_discovery_candidate() -> None:
|
||||
registry = builder.build_registry(
|
||||
{
|
||||
"rules": [
|
||||
{
|
||||
"id": "inline_text_column_from_last_preceding_scalar_plus_one",
|
||||
"target": "moxel.inline_text_cell.column",
|
||||
"confidence": "high",
|
||||
"evidence": {"ok": 1, "total": 1},
|
||||
},
|
||||
{
|
||||
"target": "moxel.named_range.left",
|
||||
"confidence": "medium",
|
||||
"raw_scalar_indexes": [2, 4],
|
||||
},
|
||||
]
|
||||
},
|
||||
["discovery.json", "named.json"],
|
||||
{
|
||||
"rules": [
|
||||
{
|
||||
"target": "moxel.named_range.left",
|
||||
"confidence": "high",
|
||||
"raw_scalar_indexes": [2],
|
||||
"expression": "one_based = int(raw_scalar) + 1",
|
||||
"evidence": {"samples": 1, "distinct_rectangular_samples": 1},
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
rules = {rule["target"]: rule for rule in registry["rules"]}
|
||||
assert rules["moxel.named_range.left"]["raw_scalar_indexes"] == [2]
|
||||
assert rules["moxel.named_range.left"]["read_status"] == "verified_read"
|
||||
assert rules["moxel.named_range.left"]["write_status"] == "blocked_until_roundtrip"
|
||||
|
||||
|
||||
def test_check_registry_rejects_write_enabled_rules() -> None:
|
||||
registry = builder.build_registry(
|
||||
{
|
||||
"rules": [
|
||||
{
|
||||
"id": "inline_text_column_from_last_preceding_scalar_plus_one",
|
||||
"target": "moxel.inline_text_cell.column",
|
||||
"confidence": "high",
|
||||
"evidence": {"ok": 1, "total": 1},
|
||||
}
|
||||
]
|
||||
},
|
||||
["discovery.json"],
|
||||
)
|
||||
registry["rules"][0]["write_status"] = "verified_roundtrip"
|
||||
|
||||
report = checker.check_registry(registry)
|
||||
|
||||
assert report["status"] == "failed"
|
||||
assert any("write rules must stay disabled" in failure for failure in report["failures"])
|
||||
|
||||
|
||||
def test_check_registry_rejects_inconsistent_counts() -> None:
|
||||
registry = builder.build_registry(
|
||||
{
|
||||
"rules": [
|
||||
{
|
||||
"id": "inline_text_column_from_last_preceding_scalar_plus_one",
|
||||
"target": "moxel.inline_text_cell.column",
|
||||
"confidence": "high",
|
||||
"evidence": {"ok": 1, "total": 1},
|
||||
}
|
||||
]
|
||||
},
|
||||
["discovery.json"],
|
||||
)
|
||||
registry["counts"]["rules"] = 99
|
||||
|
||||
report = checker.check_registry(registry)
|
||||
|
||||
assert report["status"] == "failed"
|
||||
assert "counts.rules must be 1, got 99" in report["failures"]
|
||||
@@ -0,0 +1,66 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SCRIPT = ROOT / "scripts" / "verify_1c_moxel_schema_registry.py"
|
||||
SPEC = importlib.util.spec_from_file_location("verify_1c_moxel_schema_registry", SCRIPT)
|
||||
assert SPEC and SPEC.loader
|
||||
verify = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(verify)
|
||||
|
||||
|
||||
def registry() -> dict:
|
||||
return {
|
||||
"schema": "codex_1c_moxel_schema_registry.v1",
|
||||
"rules": [
|
||||
{
|
||||
"id": "inline_text_column_from_last_preceding_scalar_plus_one",
|
||||
"target": "moxel.inline_text_cell.column",
|
||||
"read_status": "verified_read",
|
||||
},
|
||||
{
|
||||
"id": "named_left",
|
||||
"target": "moxel.named_range.left",
|
||||
"read_status": "candidate_read",
|
||||
"raw_scalar_indexes": [2, 4],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def probe(column: int = 2) -> dict:
|
||||
return {
|
||||
"probe": {
|
||||
"cells": [
|
||||
{"text": "10-2", "one_based": {"row": 10, "column": column}},
|
||||
],
|
||||
"named_ranges": [
|
||||
{
|
||||
"name": "R7C2_TEST",
|
||||
"kind": "named_cell_or_range",
|
||||
"range": {"one_based": {"left": 4, "right": 4, "top": 8, "bottom": 8}},
|
||||
"raw_scalars": ["1", "3", "3", "7", "3", "7", "00000000-0000-0000-0000-000000000000", "0"],
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def test_verify_registry_accepts_matching_verified_read_probe() -> None:
|
||||
report = verify.verify_registry(registry(), [probe()])
|
||||
|
||||
assert report["status"] == "ok"
|
||||
inline = next(item for item in report["results"] if item["target"] == "moxel.inline_text_cell.column")
|
||||
assert inline["verification_status"] == "ok"
|
||||
named = next(item for item in report["results"] if item["target"] == "moxel.named_range.left")
|
||||
assert named["verification_status"] == "ok"
|
||||
|
||||
|
||||
def test_verify_registry_fails_when_verified_read_probe_mismatches() -> None:
|
||||
report = verify.verify_registry(registry(), [probe(column=99)])
|
||||
|
||||
assert report["status"] == "failed"
|
||||
assert report["counts"]["verified_read_failures"] == 1
|
||||
@@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SCRIPT = ROOT / "scripts" / "status_1c_moxel.py"
|
||||
SPEC = importlib.util.spec_from_file_location("status_1c_moxel", SCRIPT)
|
||||
assert SPEC and SPEC.loader
|
||||
status_mod = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(status_mod)
|
||||
|
||||
|
||||
def write_json(path: Path, payload: dict) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
|
||||
def test_build_status_reports_blockers_and_next_action(tmp_path: Path) -> None:
|
||||
write_json(
|
||||
tmp_path / "plugins/1c/metadata/moxel-schema-registry.json",
|
||||
{
|
||||
"counts": {"rules": 2, "verified_read": 1, "candidate_read": 1, "write_enabled": 0},
|
||||
"rules": [
|
||||
{"target": "moxel.inline_text_cell.column", "read_status": "verified_read"},
|
||||
{"target": "moxel.named_range.left", "read_status": "candidate_read"},
|
||||
],
|
||||
},
|
||||
)
|
||||
write_json(tmp_path / "reports/1c-template-baselines/moxel-schema-registry-verification.json", {"status": "ok", "counts": {}})
|
||||
write_json(tmp_path / "reports/1c-template-baselines/moxel-discovery-pipeline.json", {"status": "ok", "steps": []})
|
||||
write_json(
|
||||
tmp_path / "reports/1c-template-baselines/moxel-next-action.json",
|
||||
{
|
||||
"status": "ok",
|
||||
"action": {
|
||||
"id": "named_range_rectangular_area",
|
||||
"target": "named_range",
|
||||
"manual_action": "Create rectangle.",
|
||||
"capture_command_with_pipeline": "python watcher.py --run-pipeline-after",
|
||||
},
|
||||
},
|
||||
)
|
||||
write_json(tmp_path / "reports/1c-template-baselines/moxel-next-action-check.json", {"status": "ok", "counts": {}})
|
||||
|
||||
status = status_mod.build_status(tmp_path)
|
||||
|
||||
assert status["status"] == "ok"
|
||||
assert status["next_action"]["id"] == "named_range_rectangular_area"
|
||||
assert "moxel.named_range.left" in status["registry"]["candidate_read_targets"]
|
||||
assert any("candidate_read rules remain" in blocker for blocker in status["blockers"])
|
||||
|
||||
|
||||
def test_resolve_output_path_keeps_absolute_paths(tmp_path: Path) -> None:
|
||||
absolute = tmp_path / "out.json"
|
||||
|
||||
assert status_mod.resolve_output_path(tmp_path / "root", str(absolute)) == absolute
|
||||
|
||||
|
||||
def test_resolve_output_path_anchors_relative_paths(tmp_path: Path) -> None:
|
||||
root = tmp_path / "root"
|
||||
|
||||
assert status_mod.resolve_output_path(root, "reports/out.json") == root / "reports/out.json"
|
||||
@@ -0,0 +1,61 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
|
||||
import summarize_observability_reports as summary_script # noqa: E402
|
||||
|
||||
|
||||
def write_jsonl(path: Path, records: list[dict]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("w", encoding="utf-8") as handle:
|
||||
for record in records:
|
||||
handle.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||
|
||||
|
||||
def test_summarize_observability_reports_groups_records(tmp_path: Path) -> None:
|
||||
root = tmp_path / "reports" / "observability" / "onec-agent"
|
||||
write_jsonl(
|
||||
root / "access_events" / "20260705.jsonl",
|
||||
[
|
||||
{"event_type": "access_events", "status_code": 200},
|
||||
{"event_type": "access_events", "status_code": 400},
|
||||
],
|
||||
)
|
||||
write_jsonl(
|
||||
root / "turn_audit" / "20260705.jsonl",
|
||||
[
|
||||
{"event_type": "turn_audit", "plugin": "1c", "outcome": "success", "failure_type": "none"},
|
||||
{"event_type": "turn_audit", "plugin": "1c", "outcome": "failure", "failure_type": "adapter_error"},
|
||||
],
|
||||
)
|
||||
write_jsonl(
|
||||
root / "model_calls" / "20260705.jsonl",
|
||||
[
|
||||
{"event_type": "model_calls", "route_name": "1c", "latency_ms": 10},
|
||||
{"event_type": "model_calls", "route_name": "1c", "latency_ms": 20},
|
||||
],
|
||||
)
|
||||
write_jsonl(
|
||||
root / "tool_calls" / "20260705.jsonl",
|
||||
[
|
||||
{"event_type": "tool_calls", "tool_name": "metadata.saved_state.forms.search", "duration_ms": 7},
|
||||
],
|
||||
)
|
||||
|
||||
records = summary_script.iter_records(tmp_path / "reports" / "observability")
|
||||
summary = summary_script.summarize(records)
|
||||
|
||||
assert summary["records"] == 7
|
||||
assert summary["by_event_type"]["access_events"] == 2
|
||||
assert summary["http_status_counts"]["200"] == 1
|
||||
assert summary["turns_by_plugin"]["1c"]["success"] == 1
|
||||
assert summary["turns_by_plugin"]["1c"]["failure"] == 1
|
||||
assert summary["failure_type_counts"]["adapter_error"] == 1
|
||||
assert summary["model_routes"][0]["route_name"] == "1c"
|
||||
assert summary["model_routes"][0]["avg_latency_ms"] == 15
|
||||
assert summary["tool_calls"][0]["tool_name"] == "metadata.saved_state.forms.search"
|
||||
@@ -0,0 +1,683 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import threading
|
||||
from http.server import ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.error import HTTPError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "plugins" / "1c" / "agent"))
|
||||
|
||||
import agent_server as onec_agent # noqa: E402
|
||||
from core.observability import JsonlAuditStore # noqa: E402
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ModuleNotFoundError:
|
||||
yaml = None
|
||||
|
||||
|
||||
def request_json(
|
||||
method: str,
|
||||
*,
|
||||
url: str,
|
||||
path: str,
|
||||
payload: dict[str, Any] | None = None,
|
||||
request_id: str | None = None,
|
||||
) -> tuple[int, dict[str, Any], bool]:
|
||||
data = json.dumps(payload or {}).encode("utf-8") if payload is not None else None
|
||||
req = Request(
|
||||
f"{url}{path}",
|
||||
data=data,
|
||||
method=method,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
if request_id:
|
||||
req.add_header("X-Request-Id", request_id)
|
||||
try:
|
||||
with urlopen(req, timeout=3) as response:
|
||||
body = response.read().decode("utf-8")
|
||||
return response.status, json.loads(body) if body else {}, False
|
||||
except HTTPError as exc:
|
||||
body = exc.read().decode("utf-8")
|
||||
return exc.code, json.loads(body) if body else {}, True
|
||||
|
||||
|
||||
def request_raw(method: str, *, url: str, path: str) -> tuple[int, str]:
|
||||
req = Request(f"{url}{path}", method=method)
|
||||
try:
|
||||
with urlopen(req, timeout=3) as response:
|
||||
return response.status, response.read().decode("utf-8")
|
||||
except HTTPError as exc:
|
||||
return exc.code, exc.read().decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def onec_agent_server(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(
|
||||
onec_agent,
|
||||
"route_for_model",
|
||||
lambda model_id=None, *, plugin=onec_agent.DEFAULT_PLUGIN: {
|
||||
"plugin": plugin,
|
||||
"model": {"id": model_id or "stub-model", "task": ["1c"]},
|
||||
"base_url": "http://127.0.0.1:8000",
|
||||
"served_model_name": "stub-model",
|
||||
"container_name": None,
|
||||
"host": None,
|
||||
"role": None,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
onec_agent,
|
||||
"call_model",
|
||||
lambda provider, model, messages, temperature=0.2, max_tokens=1200, base_url=None: {
|
||||
"text": f"Stub answer for {model} via {provider.name}",
|
||||
"raw": {"model": model, "provider": provider.name, "messages": messages},
|
||||
"latency_ms": 0,
|
||||
"provider_base_url": base_url or provider.base_url,
|
||||
},
|
||||
)
|
||||
providers = {
|
||||
"default": onec_agent.ProviderConfig(
|
||||
name="default",
|
||||
data={
|
||||
"type": "openai-compatible",
|
||||
"base_url": "http://127.0.0.1:8000",
|
||||
"model": "stub-model",
|
||||
},
|
||||
),
|
||||
}
|
||||
store = onec_agent.Store(tmp_path / "onec-agent.db")
|
||||
audit_store = JsonlAuditStore(tmp_path / "reports" / "observability" / "onec-agent", service="onec-agent")
|
||||
|
||||
def _handler(*args: Any, **kwargs: Any) -> None:
|
||||
onec_agent.AgentHandler(
|
||||
*args,
|
||||
store=store,
|
||||
providers=providers,
|
||||
system_prompt="Ты помощник по 1С",
|
||||
audit_store=audit_store,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), _handler)
|
||||
thread = threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.05}, daemon=True)
|
||||
thread.start()
|
||||
|
||||
try:
|
||||
yield {
|
||||
"url": f"http://127.0.0.1:{server.server_address[1]}",
|
||||
"audit_root": str(tmp_path / "reports" / "observability" / "onec-agent"),
|
||||
}
|
||||
finally:
|
||||
server.shutdown()
|
||||
thread.join(timeout=3)
|
||||
server.server_close()
|
||||
|
||||
|
||||
def _assert_has_trace(payload: dict[str, Any], expected: str | None = None) -> None:
|
||||
trace = payload.get("trace_id")
|
||||
assert isinstance(trace, str)
|
||||
if expected is not None:
|
||||
assert trace == expected
|
||||
return
|
||||
assert re.fullmatch(r"[0-9a-f]{32}", trace) is not None
|
||||
|
||||
|
||||
def test_health_is_alive(onec_agent_server: dict[str, str]) -> None:
|
||||
status, payload, is_error = request_json("GET", url=onec_agent_server["url"], path="/v1/health")
|
||||
assert not is_error
|
||||
assert status == 200
|
||||
assert payload["status"] == "ok"
|
||||
_assert_has_trace(payload)
|
||||
|
||||
|
||||
def test_ui_served(onec_agent_server: dict[str, str]) -> None:
|
||||
url = onec_agent_server["url"]
|
||||
status, body = request_raw("GET", url=url, path="/")
|
||||
assert status == 200
|
||||
assert "<title>1С Agent</title>" in body
|
||||
|
||||
status, js_body = request_raw("GET", url=url, path="/ui/assets/app.js")
|
||||
assert status == 200
|
||||
assert "sendTurn" in js_body
|
||||
|
||||
|
||||
def test_state_endpoint(onec_agent_server: dict[str, str]) -> None:
|
||||
url = onec_agent_server["url"]
|
||||
|
||||
request_json("POST", url=url, path="/v1/projects", payload={"name": "Состояние"})
|
||||
request_json("POST", url=url, path="/v1/projects", payload={"name": "Состояние-2"})
|
||||
status, listing, _ = request_json("GET", url=url, path="/v1/projects")
|
||||
project_id = listing["projects"][0]["id"]
|
||||
status, got_project, _ = request_json("GET", url=url, path=f"/v1/projects/{project_id}")
|
||||
assert status == 200
|
||||
assert got_project["project"]["id"] == project_id
|
||||
request_json("POST", url=url, path=f"/v1/projects/{project_id}/chats", payload={"title": "СтатусДиалог"})
|
||||
|
||||
status, state, is_error = request_json("GET", url=url, path="/v1/state")
|
||||
assert not is_error
|
||||
assert status == 200
|
||||
assert state["service"] == "onec-agent"
|
||||
assert state["state"]["projects"] >= 2
|
||||
assert state["state"]["chats"] >= 1
|
||||
assert "started_at" in state
|
||||
assert "uptime_seconds" in state
|
||||
assert "default" in state["providers"]["ids"]
|
||||
_assert_has_trace(state)
|
||||
|
||||
|
||||
def test_openapi_contract_smoke() -> None:
|
||||
contract = ROOT / "plugins" / "1c" / "agent" / "openapi.yaml"
|
||||
assert contract.exists()
|
||||
if yaml is None:
|
||||
return
|
||||
|
||||
with contract.open("r", encoding="utf-8") as stream:
|
||||
data = yaml.safe_load(stream)
|
||||
|
||||
assert data["openapi"].startswith("3.")
|
||||
paths = set(data.get("paths", {}).keys())
|
||||
expected = {
|
||||
"/v1/health",
|
||||
"/v1/state",
|
||||
"/v1/projects",
|
||||
"/v1/projects/{project_id}",
|
||||
"/v1/projects/{project_id}/chats",
|
||||
"/v1/projects/{project_id}/chats/{chat_id}",
|
||||
"/v1/projects/{project_id}/chats/{chat_id}/messages",
|
||||
"/v1/projects/{project_id}/chats/{chat_id}/runtime",
|
||||
"/v1/projects/{project_id}/chats/{chat_id}/turn",
|
||||
"/v1/projects/{project_id}/chats/{chat_id}/onec-tool",
|
||||
"/v1/models",
|
||||
"/v1/providers",
|
||||
}
|
||||
assert expected.issubset(paths)
|
||||
|
||||
|
||||
def test_project_chat_crud_flow(onec_agent_server: dict[str, str]) -> None:
|
||||
url = onec_agent_server["url"]
|
||||
|
||||
status, created, _ = request_json(
|
||||
"POST",
|
||||
url=url,
|
||||
path="/v1/projects",
|
||||
payload={"name": "Проект", "description": "Исходный", "metadata": {"owner": "test"}},
|
||||
request_id="req-1",
|
||||
)
|
||||
assert status == 201
|
||||
project_id = created["project"]["id"]
|
||||
_assert_has_trace(created, expected="req-1")
|
||||
|
||||
status, listing, _ = request_json("GET", url=url, path="/v1/projects")
|
||||
assert status == 200
|
||||
assert [p["id"] for p in listing["projects"]] == [project_id]
|
||||
|
||||
status, updated, _ = request_json("PATCH", url=url, path=f"/v1/projects/{project_id}", payload={"description": "Обновленный"})
|
||||
assert status == 200
|
||||
assert updated["project"]["description"] == "Обновленный"
|
||||
|
||||
status, chat_created, _ = request_json(
|
||||
"POST",
|
||||
url=url,
|
||||
path=f"/v1/projects/{project_id}/chats",
|
||||
payload={"title": "Диалог", "temperature": 0.12, "max_tokens": 512},
|
||||
)
|
||||
assert status == 201
|
||||
chat_id = chat_created["chat"]["id"]
|
||||
status, got_chat, _ = request_json("GET", url=url, path=f"/v1/projects/{project_id}/chats/{chat_id}")
|
||||
assert status == 200
|
||||
assert got_chat["chat"]["id"] == chat_id
|
||||
|
||||
status, messages_before, _ = request_json(
|
||||
"GET",
|
||||
url=url,
|
||||
path=f"/v1/projects/{project_id}/chats/{chat_id}/messages",
|
||||
)
|
||||
assert status == 200
|
||||
assert messages_before["messages"] == []
|
||||
|
||||
status, _msg, _ = request_json(
|
||||
"POST",
|
||||
url=url,
|
||||
path=f"/v1/projects/{project_id}/chats/{chat_id}/messages",
|
||||
payload={"role": "user", "content": "Проверка"},
|
||||
)
|
||||
assert status == 201
|
||||
|
||||
status, messages, _ = request_json(
|
||||
"GET",
|
||||
url=url,
|
||||
path=f"/v1/projects/{project_id}/chats/{chat_id}/messages?limit=10",
|
||||
)
|
||||
assert status == 200
|
||||
assert len(messages["messages"]) == 1
|
||||
assert messages["messages"][0]["content"] == "Проверка"
|
||||
|
||||
status, chat, _ = request_json(
|
||||
"PATCH",
|
||||
url=url,
|
||||
path=f"/v1/projects/{project_id}/chats/{chat_id}",
|
||||
payload={"title": "Диалог 2", "rag_profile": "official", "temperature": 0.5},
|
||||
)
|
||||
assert status == 200
|
||||
assert chat["chat"]["title"] == "Диалог 2"
|
||||
assert chat["chat"]["temperature"] == 0.5
|
||||
|
||||
status, bad, _is_error = request_json(
|
||||
"PATCH",
|
||||
url=url,
|
||||
path=f"/v1/projects/{project_id}/chats/{chat_id}",
|
||||
payload={"metadata": []},
|
||||
)
|
||||
assert status == 400
|
||||
assert bad["error"]["code"] == "invalid_argument"
|
||||
_assert_has_trace(bad)
|
||||
|
||||
|
||||
def test_delete_and_validation_paths(onec_agent_server: dict[str, str]) -> None:
|
||||
url = onec_agent_server["url"]
|
||||
|
||||
status, created, _ = request_json("POST", url=url, path="/v1/projects", payload={"name": "ДляУдаления"})
|
||||
project_id = created["project"]["id"]
|
||||
|
||||
status, chat_created, _ = request_json(
|
||||
"POST",
|
||||
url=url,
|
||||
path=f"/v1/projects/{project_id}/chats",
|
||||
payload={"title": "Удаляемый"},
|
||||
)
|
||||
chat_id = chat_created["chat"]["id"]
|
||||
|
||||
status, bad, is_error = request_json(
|
||||
"GET",
|
||||
url=url,
|
||||
path=f"/v1/projects/{project_id}/chats/{chat_id}/messages?limit=abc",
|
||||
)
|
||||
assert status == 400
|
||||
assert is_error
|
||||
assert bad["error"]["code"] == "invalid_argument"
|
||||
|
||||
status, bad_limit, is_error = request_json(
|
||||
"GET",
|
||||
url=url,
|
||||
path=f"/v1/projects/{project_id}/chats/{chat_id}/messages?limit=0",
|
||||
)
|
||||
assert status == 400
|
||||
assert is_error
|
||||
assert bad_limit["error"]["code"] == "invalid_argument"
|
||||
|
||||
status, _, _ = request_json("DELETE", url=url, path=f"/v1/projects/{project_id}/chats/{chat_id}")
|
||||
assert status == 204
|
||||
|
||||
status, _, is_error = request_json(
|
||||
"GET",
|
||||
url=url,
|
||||
path=f"/v1/projects/{project_id}/chats/{chat_id}",
|
||||
)
|
||||
assert status == 404
|
||||
assert is_error
|
||||
|
||||
status, _, _ = request_json("DELETE", url=url, path=f"/v1/projects/{project_id}")
|
||||
assert status == 204
|
||||
|
||||
status, _, is_error = request_json(
|
||||
"GET",
|
||||
url=url,
|
||||
path=f"/v1/projects/{project_id}",
|
||||
)
|
||||
assert status == 404
|
||||
assert is_error
|
||||
|
||||
|
||||
def test_turn_runtime_question_fastpath(onec_agent_server: dict[str, str]) -> None:
|
||||
url = onec_agent_server["url"]
|
||||
|
||||
status, project_data, _ = request_json("POST", url=url, path="/v1/projects", payload={"name": "Проверка runtime вопроса"})
|
||||
assert status == 201
|
||||
project_id = project_data["project"]["id"]
|
||||
|
||||
status, chat_data, _ = request_json(
|
||||
"POST",
|
||||
url=url,
|
||||
path=f"/v1/projects/{project_id}/chats",
|
||||
payload={"title": "Runtime"},
|
||||
)
|
||||
assert status == 201
|
||||
chat_id = chat_data["chat"]["id"]
|
||||
|
||||
status, turn_data, is_error = request_json(
|
||||
"POST",
|
||||
url=url,
|
||||
path=f"/v1/projects/{project_id}/chats/{chat_id}/turn",
|
||||
payload={"message": "Какая модель ии подключена? Какой адаптер и rag?"},
|
||||
)
|
||||
assert status == 200
|
||||
assert not is_error
|
||||
assert "runtime-конфигурация" in turn_data["assistant_message"]["content"]
|
||||
assert turn_data["assistant_message"]["payload"]["guardrail"] == {"type": "runtime_direct_answer"}
|
||||
assert turn_data["assistant_message"]["payload"]["raw"]["reason"] == "runtime_question_fastpath"
|
||||
_assert_has_trace(turn_data)
|
||||
|
||||
|
||||
def test_turn_persists_full_payload_journal(onec_agent_server: dict[str, str]) -> None:
|
||||
url = onec_agent_server["url"]
|
||||
|
||||
status, project_data, _ = request_json("POST", url=url, path="/v1/projects", payload={"name": "Журнал-чата"})
|
||||
assert status == 201
|
||||
project_id = project_data["project"]["id"]
|
||||
|
||||
status, chat_data, _ = request_json(
|
||||
"POST",
|
||||
url=url,
|
||||
path=f"/v1/projects/{project_id}/chats",
|
||||
payload={"title": "Смотрим payload"},
|
||||
)
|
||||
assert status == 201
|
||||
chat_id = chat_data["chat"]["id"]
|
||||
|
||||
status, turn_data, _ = request_json(
|
||||
"POST",
|
||||
url=url,
|
||||
path=f"/v1/projects/{project_id}/chats/{chat_id}/turn",
|
||||
payload={"message": "Опиши коротко, как проверить проблему в 1С.", "use_rag": True},
|
||||
)
|
||||
assert status == 200
|
||||
assistant_payload = turn_data["assistant_message"]["payload"]
|
||||
assert assistant_payload["transport"]["outbound"]["provider_id"]
|
||||
assert assistant_payload["transport"]["inbound"]["provider_response"]
|
||||
assert assistant_payload["transport"]["outbound"]["model"]["id"]
|
||||
user_payload = turn_data["user_message"]["payload"]
|
||||
assert user_payload["transport"]["outbound"]["runtime"]["history_limit"]
|
||||
|
||||
status, all_messages, _ = request_json(
|
||||
"GET",
|
||||
url=url,
|
||||
path=f"/v1/projects/{project_id}/chats/{chat_id}/messages?limit=50",
|
||||
)
|
||||
assert status == 200
|
||||
assert len(all_messages["messages"]) == 2
|
||||
assert "transport" in all_messages["messages"][0]["payload"]
|
||||
assert "transport" in all_messages["messages"][1]["payload"]
|
||||
|
||||
|
||||
def test_turn_writes_audit_jsonl(onec_agent_server: dict[str, str]) -> None:
|
||||
url = onec_agent_server["url"]
|
||||
|
||||
status, project_data, _ = request_json("POST", url=url, path="/v1/projects", payload={"name": "Audit log"}, request_id="req-audit-1")
|
||||
assert status == 201
|
||||
project_id = project_data["project"]["id"]
|
||||
|
||||
status, chat_data, _ = request_json(
|
||||
"POST",
|
||||
url=url,
|
||||
path=f"/v1/projects/{project_id}/chats",
|
||||
payload={"title": "Audit chat"},
|
||||
request_id="req-audit-2",
|
||||
)
|
||||
assert status == 201
|
||||
chat_id = chat_data["chat"]["id"]
|
||||
|
||||
status, turn_data, is_error = request_json(
|
||||
"POST",
|
||||
url=url,
|
||||
path=f"/v1/projects/{project_id}/chats/{chat_id}/turn",
|
||||
payload={"message": "Опиши коротко, как проверить проблему в 1С.", "use_rag": False},
|
||||
request_id="req-audit-3",
|
||||
)
|
||||
assert status == 200
|
||||
assert not is_error
|
||||
assert turn_data["turn_id"]
|
||||
|
||||
audit_root = Path(onec_agent_server["audit_root"])
|
||||
access_files = sorted((audit_root / "access_events").glob("*.jsonl"))
|
||||
turn_files = sorted((audit_root / "turn_audit").glob("*.jsonl"))
|
||||
model_files = sorted((audit_root / "model_calls").glob("*.jsonl"))
|
||||
assert access_files
|
||||
assert turn_files
|
||||
assert model_files
|
||||
|
||||
access_records = [json.loads(line) for line in access_files[-1].read_text(encoding="utf-8").splitlines() if line.strip()]
|
||||
turn_records = [json.loads(line) for line in turn_files[-1].read_text(encoding="utf-8").splitlines() if line.strip()]
|
||||
model_records = [json.loads(line) for line in model_files[-1].read_text(encoding="utf-8").splitlines() if line.strip()]
|
||||
|
||||
assert any(record["request_id"] == "req-audit-3" and record["status_code"] == 200 for record in access_records)
|
||||
assert any(record["turn_id"] == turn_data["turn_id"] and record["outcome"] == "success" for record in turn_records)
|
||||
assert any(record["turn_id"] == turn_data["turn_id"] and record["served_model_name"] == "stub-model" for record in model_records)
|
||||
|
||||
|
||||
def test_turn_builds_agent_context_with_rag_tools_and_order(
|
||||
onec_agent_server: dict[str, str],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
url = onec_agent_server["url"]
|
||||
|
||||
monkeypatch.setattr(
|
||||
onec_agent,
|
||||
"build_rag_prompt",
|
||||
lambda question, profile_name="auto", limit=None: {
|
||||
"prompt": f"RAG PROMPT profile={profile_name} limit={limit}: {question}",
|
||||
"profile": profile_name,
|
||||
"context_count": 1,
|
||||
"sources": [{"source_path": "stub.md", "title": "Stub", "chunk_index": 0, "score": 1.0}],
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
onec_agent,
|
||||
"call_adapter",
|
||||
lambda method, params, base_url=None: {"ok": True, "method": method, "params": params},
|
||||
)
|
||||
|
||||
status, project_data, _ = request_json("POST", url=url, path="/v1/projects", payload={"name": "Контекст turn"})
|
||||
assert status == 201
|
||||
project_id = project_data["project"]["id"]
|
||||
|
||||
status, chat_data, _ = request_json(
|
||||
"POST",
|
||||
url=url,
|
||||
path=f"/v1/projects/{project_id}/chats",
|
||||
payload={"title": "context", "system_prompt": "Chat-only policy", "rag_profile": "official", "rag_limit": 3},
|
||||
)
|
||||
assert status == 201
|
||||
chat_id = chat_data["chat"]["id"]
|
||||
|
||||
request_json("POST", url=url, path=f"/v1/projects/{project_id}/chats/{chat_id}/messages", payload={"role": "user", "content": "first"})
|
||||
request_json("POST", url=url, path=f"/v1/projects/{project_id}/chats/{chat_id}/messages", payload={"role": "assistant", "content": "second"})
|
||||
|
||||
status, turn_data, is_error = request_json(
|
||||
"POST",
|
||||
url=url,
|
||||
path=f"/v1/projects/{project_id}/chats/{chat_id}/turn",
|
||||
payload={
|
||||
"message": "Найди реквизит документа",
|
||||
"adapter_calls": [{"method": "tool.echo", "params": {"value": 7}}],
|
||||
"use_rag": True,
|
||||
},
|
||||
)
|
||||
assert status == 200
|
||||
assert not is_error
|
||||
assert turn_data["rag"]["profile"] == "official"
|
||||
assert turn_data["tools"][0]["method"] == "tool.echo"
|
||||
|
||||
messages = turn_data["assistant_message"]["payload"]["raw"]["messages"]
|
||||
assert messages[0]["role"] == "system"
|
||||
assert "Ты помощник по 1С" in messages[0]["content"]
|
||||
assert "Chat-only policy" in messages[0]["content"]
|
||||
assert messages[1]["role"] == "system"
|
||||
assert "tool.echo" in messages[1]["content"]
|
||||
assert messages[2]["role"] == "system"
|
||||
assert "RAG PROMPT profile=official limit=3" in messages[2]["content"]
|
||||
assert [(item["role"], item["content"]) for item in messages[-3:]] == [
|
||||
("user", "first"),
|
||||
("assistant", "second"),
|
||||
("user", "Найди реквизит документа"),
|
||||
]
|
||||
|
||||
audit_root = Path(onec_agent_server["audit_root"])
|
||||
retrieval_files = sorted((audit_root / "retrieval_events").glob("*.jsonl"))
|
||||
tool_files = sorted((audit_root / "tool_calls").glob("*.jsonl"))
|
||||
assert retrieval_files
|
||||
assert tool_files
|
||||
|
||||
retrieval_records = [json.loads(line) for line in retrieval_files[-1].read_text(encoding="utf-8").splitlines() if line.strip()]
|
||||
tool_records = [json.loads(line) for line in tool_files[-1].read_text(encoding="utf-8").splitlines() if line.strip()]
|
||||
|
||||
assert any(record["profile"] == "official" and record["plugin"] == "1c" for record in retrieval_records)
|
||||
assert any(record["tool_name"] == "tool.echo" and record["target_service"] == "1c-adapter" for record in tool_records)
|
||||
|
||||
|
||||
|
||||
def test_adapter_live_methods_require_base_id(onec_agent_server: dict[str, str]) -> None:
|
||||
onec_agent.validate_adapter_call("help.methods", {})
|
||||
onec_agent.validate_adapter_call("metadata.saved_state.forms.search", {"base_id": "upo_test"})
|
||||
with pytest.raises(ValueError, match="requires params.base_id"):
|
||||
onec_agent.validate_adapter_call("metadata.saved_state.forms.search", {})
|
||||
with pytest.raises(ValueError, match="requires params.base_id"):
|
||||
onec_agent.validate_adapter_call("storage.files.list", {"table": "ConfigSave"})
|
||||
|
||||
url = onec_agent_server["url"]
|
||||
status, project_data, _ = request_json("POST", url=url, path="/v1/projects", payload={"name": "base-id guard"})
|
||||
assert status == 201
|
||||
project_id = project_data["project"]["id"]
|
||||
status, chat_data, _ = request_json("POST", url=url, path=f"/v1/projects/{project_id}/chats", payload={"title": "guard"})
|
||||
assert status == 201
|
||||
chat_id = chat_data["chat"]["id"]
|
||||
|
||||
status, turn_data, is_error = request_json(
|
||||
"POST",
|
||||
url=url,
|
||||
path=f"/v1/projects/{project_id}/chats/{chat_id}/turn",
|
||||
payload={
|
||||
"message": "Проверь сохраненное состояние",
|
||||
"adapter_calls": [{"method": "metadata.saved_state.forms.search", "params": {"table": "ConfigSave"}}],
|
||||
},
|
||||
)
|
||||
assert status == 400
|
||||
assert is_error
|
||||
assert turn_data["error"]["code"] == "adapter_error"
|
||||
assert "requires params.base_id" in turn_data["error"]["message"]
|
||||
|
||||
audit_root = Path(onec_agent_server["audit_root"])
|
||||
turn_files = sorted((audit_root / "turn_audit").glob("*.jsonl"))
|
||||
assert turn_files
|
||||
turn_records = [json.loads(line) for line in turn_files[-1].read_text(encoding="utf-8").splitlines() if line.strip()]
|
||||
assert any(
|
||||
record["outcome"] == "failure"
|
||||
and record["failure_type"] == "adapter_error"
|
||||
and record["error_code"] == "adapter_error"
|
||||
and "requires params.base_id" in str(record["error_message"])
|
||||
for record in turn_records
|
||||
)
|
||||
|
||||
def test_get_chat_runtime(onec_agent_server: dict[str, str]) -> None:
|
||||
url = onec_agent_server["url"]
|
||||
|
||||
status, project_data, _ = request_json(
|
||||
"POST",
|
||||
url=url,
|
||||
path="/v1/projects",
|
||||
payload={"name": "runtime-check"},
|
||||
)
|
||||
assert status == 201
|
||||
project_id = project_data["project"]["id"]
|
||||
|
||||
status, chat_data, _ = request_json(
|
||||
"POST",
|
||||
url=url,
|
||||
path=f"/v1/projects/{project_id}/chats",
|
||||
payload={"title": "runtime", "model_id": "stub-model", "provider_id": "default", "temperature": 0.2, "max_tokens": 100},
|
||||
)
|
||||
assert status == 201
|
||||
chat_id = chat_data["chat"]["id"]
|
||||
|
||||
status, runtime_data, _ = request_json(
|
||||
"GET",
|
||||
url=url,
|
||||
path=f"/v1/projects/{project_id}/chats/{chat_id}/runtime",
|
||||
)
|
||||
assert status == 200
|
||||
assert runtime_data["project_id"] == project_id
|
||||
assert runtime_data["chat_id"] == chat_id
|
||||
runtime = runtime_data["runtime"]
|
||||
assert runtime["provider"]["id"] == "default"
|
||||
assert runtime["project"]["id"] == project_id
|
||||
assert runtime["chat"]["id"] == chat_id
|
||||
assert runtime["model"]["selected_model_id"] == "stub-model"
|
||||
assert runtime["model"]["chat_model_id"] == "stub-model"
|
||||
|
||||
|
||||
def test_runtime_question_does_not_fire_on_general_1c_question(onec_agent_server: dict[str, str]) -> None:
|
||||
url = onec_agent_server["url"]
|
||||
|
||||
status, project_data, _ = request_json(
|
||||
"POST",
|
||||
url=url,
|
||||
path="/v1/projects",
|
||||
payload={"name": "runtime-false-positive-check"},
|
||||
)
|
||||
assert status == 201
|
||||
project_id = project_data["project"]["id"]
|
||||
|
||||
status, chat_data, _ = request_json(
|
||||
"POST",
|
||||
url=url,
|
||||
path=f"/v1/projects/{project_id}/chats",
|
||||
payload={"title": "test"},
|
||||
)
|
||||
assert status == 201
|
||||
chat_id = chat_data["chat"]["id"]
|
||||
|
||||
status, turn_data, _ = request_json(
|
||||
"POST",
|
||||
url=url,
|
||||
path=f"/v1/projects/{project_id}/chats/{chat_id}/turn",
|
||||
payload={"message": "что такое 1С?", "use_rag": False},
|
||||
)
|
||||
assert status == 200
|
||||
assert "runtime-конфигурация" not in str(turn_data["assistant_message"]["content"]).lower()
|
||||
assert "Stub answer for stub-model via default" in turn_data["assistant_message"]["content"]
|
||||
|
||||
|
||||
def test_validation_paths_return_client_errors(onec_agent_server: dict[str, str]) -> None:
|
||||
url = onec_agent_server["url"]
|
||||
|
||||
status, bad_project, is_error = request_json("POST", url=url, path="/v1/projects", payload={"name": "Bad", "metadata": []})
|
||||
assert status == 400
|
||||
assert is_error
|
||||
assert bad_project["error"]["code"] == "invalid_argument"
|
||||
|
||||
status, project_data, _ = request_json("POST", url=url, path="/v1/projects", payload={"name": "Validation"})
|
||||
assert status == 201
|
||||
project_id = project_data["project"]["id"]
|
||||
|
||||
status, bad_chat, is_error = request_json(
|
||||
"POST",
|
||||
url=url,
|
||||
path=f"/v1/projects/{project_id}/chats",
|
||||
payload={"title": "bad provider", "provider_id": "missing"},
|
||||
)
|
||||
assert status == 400
|
||||
assert is_error
|
||||
assert bad_chat["error"]["code"] == "invalid_argument"
|
||||
|
||||
status, chat_data, _ = request_json("POST", url=url, path=f"/v1/projects/{project_id}/chats", payload={"title": "ok"})
|
||||
assert status == 201
|
||||
chat_id = chat_data["chat"]["id"]
|
||||
|
||||
status, bad_turn, is_error = request_json(
|
||||
"POST",
|
||||
url=url,
|
||||
path=f"/v1/projects/{project_id}/chats/{chat_id}/turn",
|
||||
payload={"message": "Привет", "history_limit": "bad"},
|
||||
)
|
||||
assert status == 400
|
||||
assert is_error
|
||||
assert bad_turn["error"]["code"] == "invalid_argument"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,89 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
|
||||
import report_quality_metrics as metrics_script # noqa: E402
|
||||
|
||||
|
||||
def write_jsonl(path: Path, records: list[dict]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("w", encoding="utf-8") as handle:
|
||||
for record in records:
|
||||
handle.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||
|
||||
|
||||
def test_report_quality_metrics_summarizes_failures_and_breakdowns(tmp_path: Path) -> None:
|
||||
root = tmp_path / "reports" / "observability" / "onec-agent"
|
||||
write_jsonl(
|
||||
root / "turn_audit" / "20260705.jsonl",
|
||||
[
|
||||
{
|
||||
"event_type": "turn_audit",
|
||||
"timestamp": "2026-07-01T10:00:00+00:00",
|
||||
"outcome": "success",
|
||||
"failure_type": "none",
|
||||
"user_text": "ok",
|
||||
},
|
||||
{
|
||||
"event_type": "turn_audit",
|
||||
"timestamp": "2026-07-02T10:00:00+00:00",
|
||||
"outcome": "failure",
|
||||
"failure_type": "adapter_error",
|
||||
"user_text": "Повтори проверку",
|
||||
},
|
||||
{
|
||||
"event_type": "turn_audit",
|
||||
"timestamp": "2026-07-03T10:00:00+00:00",
|
||||
"outcome": "failure",
|
||||
"failure_type": "adapter_error",
|
||||
"user_text": "Повтори проверку",
|
||||
},
|
||||
],
|
||||
)
|
||||
write_jsonl(
|
||||
root / "model_calls" / "20260705.jsonl",
|
||||
[
|
||||
{"event_type": "model_calls", "route_name": "1c", "served_model_name": "stub-model", "latency_ms": 40},
|
||||
{"event_type": "model_calls", "route_name": "1c", "served_model_name": "stub-model", "latency_ms": 60},
|
||||
],
|
||||
)
|
||||
write_jsonl(
|
||||
root / "tool_calls" / "20260705.jsonl",
|
||||
[
|
||||
{"event_type": "tool_calls", "tool_name": "tool.echo", "duration_ms": 7},
|
||||
{"event_type": "tool_calls", "tool_name": "tool.echo", "duration_ms": 9},
|
||||
],
|
||||
)
|
||||
write_jsonl(
|
||||
root / "retrieval_events" / "20260705.jsonl",
|
||||
[
|
||||
{"event_type": "retrieval_events", "plugin": "1c", "profile": "official", "sources_json": [{"a": 1}, {"a": 2}]},
|
||||
],
|
||||
)
|
||||
|
||||
records = metrics_script.iter_records(tmp_path / "reports" / "observability")
|
||||
summary = metrics_script.summarize(records, limit=10)
|
||||
|
||||
assert summary["records"] == 8
|
||||
assert summary["weekly_turns"][0]["turns"] == 3
|
||||
assert summary["weekly_turns"][0]["failure"] == 2
|
||||
assert summary["top_failure_types"][0] == {"key": "adapter_error", "count": 2}
|
||||
assert summary["top_repeated_failed_prompts"][0] == {"key": "Повтори проверку", "count": 2}
|
||||
assert summary["model_breakdown"][0]["key"] == "1c :: stub-model"
|
||||
assert summary["model_breakdown"][0]["avg_latency_ms"] == 50
|
||||
assert summary["tool_breakdown"][0]["key"] == "tool.echo"
|
||||
assert summary["tool_breakdown"][0]["avg_duration_ms"] == 8
|
||||
assert summary["rag_breakdown"][0]["key"] == "1c :: official"
|
||||
assert summary["rag_breakdown"][0]["avg_sources"] == 2.0
|
||||
|
||||
|
||||
def test_report_quality_metrics_writes_output_file(tmp_path: Path) -> None:
|
||||
output = tmp_path / "quality-report.json"
|
||||
payload = {"records": 1, "weekly_turns": []}
|
||||
metrics_script.write_report(output, payload)
|
||||
assert json.loads(output.read_text(encoding="utf-8"))["records"] == 1
|
||||
@@ -0,0 +1,99 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
|
||||
import export_quality_snapshot as snapshot_script # noqa: E402
|
||||
import report_failed_turns as failed_turns_script # noqa: E402
|
||||
|
||||
|
||||
def write_jsonl(path: Path, records: list[dict]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("w", encoding="utf-8") as handle:
|
||||
for record in records:
|
||||
handle.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||
|
||||
|
||||
def test_report_failed_turns_writes_output_file(tmp_path: Path) -> None:
|
||||
output = tmp_path / "failed-turns.json"
|
||||
payload = {"records": 3, "failed_turns": []}
|
||||
failed_turns_script.write_report(output, payload)
|
||||
assert json.loads(output.read_text(encoding="utf-8"))["records"] == 3
|
||||
|
||||
|
||||
def test_export_quality_snapshot_writes_dated_artifacts_and_index(tmp_path: Path) -> None:
|
||||
root = tmp_path / "reports" / "observability" / "onec-agent"
|
||||
write_jsonl(
|
||||
root / "turn_audit" / "20260705.jsonl",
|
||||
[
|
||||
{
|
||||
"event_type": "turn_audit",
|
||||
"timestamp": "2026-07-02T10:00:00+00:00",
|
||||
"turn_id": "bad-1",
|
||||
"request_id": "req-1",
|
||||
"outcome": "failure",
|
||||
"failure_type": "adapter_error",
|
||||
"error_code": "adapter_error",
|
||||
"error_message": "missing base_id",
|
||||
"user_text": "Проверь сохраненное состояние",
|
||||
},
|
||||
{
|
||||
"event_type": "turn_audit",
|
||||
"timestamp": "2026-07-03T10:00:00+00:00",
|
||||
"turn_id": "bad-2",
|
||||
"request_id": "req-2",
|
||||
"outcome": "failure",
|
||||
"failure_type": "adapter_error",
|
||||
"error_code": "adapter_error",
|
||||
"error_message": "missing base_id",
|
||||
"user_text": "Проверь сохраненное состояние",
|
||||
},
|
||||
],
|
||||
)
|
||||
write_jsonl(
|
||||
root / "model_calls" / "20260705.jsonl",
|
||||
[
|
||||
{"event_type": "model_calls", "route_name": "1c", "served_model_name": "stub-model", "latency_ms": 20},
|
||||
],
|
||||
)
|
||||
write_jsonl(
|
||||
root / "tool_calls" / "20260705.jsonl",
|
||||
[
|
||||
{"event_type": "tool_calls", "tool_name": "tool.echo", "duration_ms": 5},
|
||||
],
|
||||
)
|
||||
write_jsonl(
|
||||
root / "retrieval_events" / "20260705.jsonl",
|
||||
[
|
||||
{"event_type": "retrieval_events", "plugin": "1c", "profile": "official", "sources_json": [{"a": 1}]},
|
||||
],
|
||||
)
|
||||
|
||||
output_dir = tmp_path / "reports" / "observability" / "quality"
|
||||
payload = snapshot_script.build_snapshot(
|
||||
report_dir=tmp_path / "reports" / "observability",
|
||||
output_dir=output_dir,
|
||||
days=0,
|
||||
limit=10,
|
||||
min_repeat_count=2,
|
||||
failed_limit=100,
|
||||
)
|
||||
|
||||
quality_path = Path(payload["paths"]["quality"])
|
||||
repeated_path = Path(payload["paths"]["repeated_failures"])
|
||||
failed_path = Path(payload["paths"]["failed_turns"])
|
||||
index_path = Path(payload["paths"]["index"])
|
||||
|
||||
assert quality_path.exists()
|
||||
assert repeated_path.exists()
|
||||
assert failed_path.exists()
|
||||
assert index_path.exists()
|
||||
|
||||
index = json.loads(index_path.read_text(encoding="utf-8"))
|
||||
assert index["snapshots"]
|
||||
assert index["snapshots"][0]["summary"]["failed_turns"] == 2
|
||||
assert index["snapshots"][0]["summary"]["repeated_failures"] == 1
|
||||
@@ -0,0 +1,60 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
|
||||
from check_1c_rag_freshness import compare_manifest # noqa: E402
|
||||
|
||||
|
||||
def test_rag_freshness_uses_official_front_matter_source_type(tmp_path: Path) -> None:
|
||||
source_dir = tmp_path / "sources"
|
||||
source_dir.mkdir()
|
||||
source = source_dir / "official.md"
|
||||
source.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"---",
|
||||
'source: "official_1c_its"',
|
||||
'source_type: "official_1c_its_glossary"',
|
||||
"---",
|
||||
"# Глоссарий",
|
||||
"",
|
||||
"Термин платформы 1С.",
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
manifest = tmp_path / "rag_manifest.json"
|
||||
manifest.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"sources": [
|
||||
{
|
||||
"source_path": "official.md",
|
||||
"source_type": "official_1c_its_glossary",
|
||||
"file_type": "md",
|
||||
"content_hash": "unused",
|
||||
}
|
||||
]
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
current = compare_manifest(source_dir, manifest)
|
||||
manifest_data = json.loads(manifest.read_text(encoding="utf-8"))
|
||||
manifest_data["sources"][0]["content_hash"] = current["changed"] and "wrong"
|
||||
from check_1c_rag_freshness import source_state
|
||||
|
||||
manifest_data["sources"][0]["content_hash"] = source_state(source_dir)["official.md"]["content_hash"]
|
||||
manifest.write_text(json.dumps(manifest_data, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
report = compare_manifest(source_dir, manifest)
|
||||
|
||||
assert report["status"] == "fresh"
|
||||
assert report["type_changed"] == []
|
||||
@@ -0,0 +1,205 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
|
||||
from build_1c_rag_index import main as _unused_lexical_main # noqa: F401,E402
|
||||
from build_1c_rag_vector_index import build_vector_index # noqa: E402
|
||||
from check_1c_rag_vector_freshness import check_vector_freshness # noqa: E402
|
||||
from common import build_lexical_index, write_json # noqa: E402
|
||||
from search_1c_rag_hybrid import hybrid_search # noqa: E402
|
||||
from search_1c_rag_vector import search_vector_index # noqa: E402
|
||||
|
||||
|
||||
class EmbeddingHandler(BaseHTTPRequestHandler):
|
||||
calls: list[dict] = []
|
||||
|
||||
def do_POST(self) -> None: # noqa: N802
|
||||
length = int(self.headers.get("Content-Length") or "0")
|
||||
payload = json.loads(self.rfile.read(length).decode("utf-8"))
|
||||
EmbeddingHandler.calls.append({"path": self.path, "payload": payload, "authorization": self.headers.get("Authorization")})
|
||||
inputs = payload.get("input") if isinstance(payload.get("input"), list) else [payload.get("input")]
|
||||
data = []
|
||||
for index, text in enumerate(inputs):
|
||||
text_value = str(text or "").lower()
|
||||
if "номенклатура" in text_value or "реквизит" in text_value:
|
||||
vector = [1.0, 0.0, 0.0]
|
||||
elif "передзаписью" in text_value:
|
||||
vector = [0.0, 1.0, 0.0]
|
||||
else:
|
||||
vector = [0.0, 0.0, 1.0]
|
||||
data.append({"object": "embedding", "index": index, "embedding": vector})
|
||||
body = json.dumps({"object": "list", "data": data}).encode("utf-8")
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, format: str, *args: object) -> None:
|
||||
return
|
||||
|
||||
|
||||
def run_embedding_server() -> tuple[ThreadingHTTPServer, str]:
|
||||
EmbeddingHandler.calls = []
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), EmbeddingHandler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
return server, f"http://127.0.0.1:{server.server_port}"
|
||||
|
||||
|
||||
def write_jsonl(path: Path, records: list[dict]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("".join(json.dumps(record, ensure_ascii=False) + "\n" for record in records), encoding="utf-8")
|
||||
|
||||
|
||||
def sample_records() -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"id": "doc-bsl-0",
|
||||
"document_id": "doc-bsl",
|
||||
"source_path": "bsl.md",
|
||||
"title": "ПередЗаписью",
|
||||
"chunk_index": 0,
|
||||
"content": "Процедура ПередЗаписью проверяет заполнение Наименование и ставит Отказ.",
|
||||
"metadata": {"source_type": "bsl", "heading": "ПередЗаписью"},
|
||||
},
|
||||
{
|
||||
"id": "doc-meta-0",
|
||||
"document_id": "doc-meta",
|
||||
"source_path": "metadata.md",
|
||||
"title": "Реквизиты справочника",
|
||||
"chunk_index": 0,
|
||||
"content": "Справочник Номенклатура содержит реквизиты Артикул и ВидНоменклатуры.",
|
||||
"metadata": {"source_type": "metadata", "heading": "Реквизиты"},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_build_and_search_vector_index(tmp_path: Path) -> None:
|
||||
corpus = tmp_path / "rag_corpus.jsonl"
|
||||
index = tmp_path / "rag_vector_index.sqlite"
|
||||
write_jsonl(corpus, sample_records())
|
||||
|
||||
result = build_vector_index(corpus, index, dimensions=64, embedding_model="local-hashing-v1")
|
||||
search = search_vector_index(index, "реквизиты номенклатура", limit=2, source_types=["metadata"], corpus_path=corpus)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
assert result["doc_count"] == 2
|
||||
assert index.exists()
|
||||
assert search["status"] == "ok"
|
||||
assert search["freshness"]["status"] == "fresh"
|
||||
assert search["meta"]["embedding_model"] == "local-hashing-v1"
|
||||
assert search["results"]
|
||||
assert search["results"][0]["document"]["source_path"] == "metadata.md"
|
||||
|
||||
|
||||
def test_vector_index_reports_stale_corpus(tmp_path: Path) -> None:
|
||||
corpus = tmp_path / "rag_corpus.jsonl"
|
||||
index = tmp_path / "rag_vector_index.sqlite"
|
||||
records = sample_records()
|
||||
write_jsonl(corpus, records)
|
||||
build_vector_index(corpus, index, dimensions=64, embedding_model="local-hashing-v1")
|
||||
records.append(
|
||||
{
|
||||
"id": "doc-query-0",
|
||||
"document_id": "doc-query",
|
||||
"source_path": "query.md",
|
||||
"title": "Запрос",
|
||||
"chunk_index": 0,
|
||||
"content": "ВЫБРАТЬ первые записи из регистра.",
|
||||
"metadata": {"source_type": "query"},
|
||||
}
|
||||
)
|
||||
write_jsonl(corpus, records)
|
||||
|
||||
search = search_vector_index(index, "запрос выбрать", limit=2, corpus_path=corpus)
|
||||
|
||||
assert search["freshness"]["status"] == "stale"
|
||||
|
||||
|
||||
def test_check_vector_freshness_reports_missing_and_fresh(tmp_path: Path) -> None:
|
||||
corpus = tmp_path / "rag_corpus.jsonl"
|
||||
index = tmp_path / "rag_vector_index.sqlite"
|
||||
write_jsonl(corpus, sample_records())
|
||||
|
||||
missing = check_vector_freshness(index, corpus)
|
||||
build_vector_index(corpus, index, dimensions=64, embedding_model="local-hashing-v1")
|
||||
fresh = check_vector_freshness(index, corpus)
|
||||
|
||||
assert missing["status"] == "missing"
|
||||
assert fresh["status"] == "fresh"
|
||||
assert fresh["embedding_model"] == "local-hashing-v1"
|
||||
assert fresh["doc_count"] == 2
|
||||
|
||||
|
||||
def test_hybrid_search_combines_lexical_and_vector(tmp_path: Path) -> None:
|
||||
corpus = tmp_path / "rag_corpus.jsonl"
|
||||
lexical_index = tmp_path / "rag_index.json"
|
||||
vector_index = tmp_path / "rag_vector_index.sqlite"
|
||||
records = sample_records()
|
||||
write_jsonl(corpus, records)
|
||||
write_json(lexical_index, build_lexical_index(records))
|
||||
build_vector_index(corpus, vector_index, dimensions=64, embedding_model="local-hashing-v1")
|
||||
|
||||
result = hybrid_search(
|
||||
"проверка перед записью",
|
||||
lexical_index_path=lexical_index,
|
||||
vector_index_path=vector_index,
|
||||
corpus_path=corpus,
|
||||
limit=2,
|
||||
candidate_limit=10,
|
||||
source_types=None,
|
||||
)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
assert result["vector_freshness"]["status"] == "fresh"
|
||||
assert result["results"]
|
||||
assert "lexical" in result["results"][0]["channels"] or "vector" in result["results"][0]["channels"]
|
||||
|
||||
|
||||
def test_openai_compatible_embedding_provider_builds_and_searches(tmp_path: Path, monkeypatch) -> None:
|
||||
corpus = tmp_path / "rag_corpus.jsonl"
|
||||
index = tmp_path / "rag_vector_index.sqlite"
|
||||
write_jsonl(corpus, sample_records())
|
||||
server, base_url = run_embedding_server()
|
||||
monkeypatch.setenv("TEST_EMBEDDING_KEY", "secret-test-key")
|
||||
try:
|
||||
result = build_vector_index(
|
||||
corpus,
|
||||
index,
|
||||
dimensions=3,
|
||||
embedding_model="test-embedding-model",
|
||||
embedding_provider="openai-compatible",
|
||||
embedding_base_url=base_url,
|
||||
embedding_api_key_env="TEST_EMBEDDING_KEY",
|
||||
batch_size=2,
|
||||
)
|
||||
search = search_vector_index(
|
||||
index,
|
||||
"реквизиты номенклатура",
|
||||
limit=2,
|
||||
corpus_path=corpus,
|
||||
embedding_api_key_env="TEST_EMBEDDING_KEY",
|
||||
)
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
assert result["embedding_provider"] == "openai-compatible"
|
||||
assert result["embedding_dimensions"] == 3
|
||||
assert search["status"] == "ok"
|
||||
assert search["meta"]["embedding_provider"] == "openai-compatible"
|
||||
assert search["meta"].get("embedding_api_key") is None
|
||||
assert search["results"][0]["document"]["source_path"] == "metadata.md"
|
||||
assert EmbeddingHandler.calls
|
||||
assert all(call["payload"]["model"] == "test-embedding-model" for call in EmbeddingHandler.calls)
|
||||
assert any(call["authorization"] == "Bearer secret-test-key" for call in EmbeddingHandler.calls)
|
||||
@@ -0,0 +1,71 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
|
||||
import report_repeated_failures as repeated_script # noqa: E402
|
||||
|
||||
|
||||
def write_jsonl(path: Path, records: list[dict]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("w", encoding="utf-8") as handle:
|
||||
for record in records:
|
||||
handle.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||
|
||||
|
||||
def test_report_repeated_failures_groups_by_failure_type_and_prompt(tmp_path: Path) -> None:
|
||||
root = tmp_path / "reports" / "observability" / "onec-agent" / "turn_audit" / "20260705.jsonl"
|
||||
write_jsonl(
|
||||
root,
|
||||
[
|
||||
{
|
||||
"timestamp": "2026-07-01T10:00:00+00:00",
|
||||
"turn_id": "a1",
|
||||
"request_id": "r1",
|
||||
"outcome": "failure",
|
||||
"failure_type": "adapter_error",
|
||||
"error_code": "adapter_error",
|
||||
"error_message": "missing base_id",
|
||||
"user_text": "Проверь сохраненное состояние",
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-07-02T10:00:00+00:00",
|
||||
"turn_id": "a2",
|
||||
"request_id": "r2",
|
||||
"outcome": "failure",
|
||||
"failure_type": "adapter_error",
|
||||
"error_code": "adapter_error",
|
||||
"error_message": "missing base_id",
|
||||
"user_text": "Проверь сохраненное состояние",
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-07-03T10:00:00+00:00",
|
||||
"turn_id": "ok1",
|
||||
"request_id": "r3",
|
||||
"outcome": "success",
|
||||
"failure_type": "none",
|
||||
"user_text": "ok",
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
records = repeated_script.iter_turn_audit(tmp_path / "reports" / "observability")
|
||||
groups = repeated_script.group_repeated_failures(records, min_count=2, limit=10)
|
||||
|
||||
assert len(groups) == 1
|
||||
assert groups[0]["count"] == 2
|
||||
assert groups[0]["failure_type"] == "adapter_error"
|
||||
assert groups[0]["normalized_user_text"] == "Проверь сохраненное состояние"
|
||||
assert groups[0]["first_seen"] == "2026-07-01T10:00:00+00:00"
|
||||
assert groups[0]["last_seen"] == "2026-07-02T10:00:00+00:00"
|
||||
|
||||
|
||||
def test_report_repeated_failures_writes_output_file(tmp_path: Path) -> None:
|
||||
output = tmp_path / "repeated-failures.json"
|
||||
payload = {"records": 2, "repeated_failures": []}
|
||||
repeated_script.write_report(output, payload)
|
||||
assert json.loads(output.read_text(encoding="utf-8"))["records"] == 2
|
||||
@@ -0,0 +1,228 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "plugins" / "1c" / "connector"))
|
||||
|
||||
import repository_control # noqa: E402
|
||||
import adapter_1c_server # noqa: E402
|
||||
|
||||
|
||||
def configured_base(monkeypatch, tmp_path: Path, backend: str = "karman_bridge") -> None:
|
||||
config = {
|
||||
"base": {
|
||||
"server": "sql",
|
||||
"database": "db",
|
||||
"repository": {
|
||||
"backend": backend,
|
||||
"designer_path": "designer.exe",
|
||||
"endpoint": "tcp://configured.example:15420/configured-repository",
|
||||
"bridge_id": "configured-bridge",
|
||||
"infobase": {"server": "onec/ib"},
|
||||
"repository_user": "repo-user",
|
||||
"repository_password_env": "TEST_REPOSITORY_PASSWORD",
|
||||
},
|
||||
}
|
||||
}
|
||||
monkeypatch.setenv("ONEC_SQL_BASES_JSON", json.dumps(config))
|
||||
monkeypatch.setenv("TEST_REPOSITORY_PASSWORD", "secret-value")
|
||||
monkeypatch.setenv("ONEC_REPOSITORY_STATE_FILE", str(tmp_path / "locks.json"))
|
||||
|
||||
|
||||
def test_repository_backend_and_endpoint_come_only_from_base_settings(monkeypatch, tmp_path: Path) -> None:
|
||||
configured_base(monkeypatch, tmp_path)
|
||||
config, error = repository_control.repository_config("base")
|
||||
assert error is None
|
||||
assert config["backend"] == "karman_bridge"
|
||||
assert config["endpoint"] == "tcp://configured.example:15420/configured-repository"
|
||||
public = repository_control.status({"base_id": "base"})
|
||||
assert public["repository"]["bridge_id"] == "configured-bridge"
|
||||
assert "secret-value" not in json.dumps(public)
|
||||
|
||||
|
||||
def test_http_runner_profile_does_not_require_local_designer_or_credentials(monkeypatch, tmp_path: Path) -> None:
|
||||
config = {"base": {"repository": {"backend": "karman_bridge", "runner": {"kind": "http", "url": "http://runner:8121", "token_env": "TOKEN"}}}}
|
||||
monkeypatch.setenv("ONEC_SQL_BASES_JSON", json.dumps(config))
|
||||
configured, error = repository_control.repository_config("base")
|
||||
assert error is None
|
||||
assert configured["runner"]["kind"] == "http"
|
||||
assert "designer_path" not in configured
|
||||
|
||||
|
||||
def test_lock_plan_maps_child_metadata_to_development_owner() -> None:
|
||||
result = repository_control.lock_plan({"object": "РегистрСведений.Настройки.Реквизит.Код"})
|
||||
assert result["status"] == "ready"
|
||||
assert result["lock_objects"] == ["РегистрСведений.Настройки"]
|
||||
|
||||
|
||||
def test_manual_lock_confirmation_is_scoped_and_not_reported_as_verified(monkeypatch, tmp_path: Path) -> None:
|
||||
configured_base(monkeypatch, tmp_path)
|
||||
values = json.loads(__import__("os").environ["ONEC_SQL_BASES_JSON"])
|
||||
values["base"]["repository"]["lock_mode"] = "manual"
|
||||
monkeypatch.setenv("ONEC_SQL_BASES_JSON", json.dumps(values))
|
||||
|
||||
planned = repository_control.lock_plan({"base_id": "base", "object": "Справочник.Товары.МодульОбъекта"})
|
||||
assert planned["workflow"] == "manual"
|
||||
assert planned["user_action"]["objects"] == ["Справочник.Товары"]
|
||||
requested = repository_control.create_lock_request({"base_id": "base", "object": "Справочник.Товары"})
|
||||
confirmed = repository_control.confirm_manual_lock({"base_id": "base", "request_id": requested["request_id"], "user_confirmed_locked": True})
|
||||
assert confirmed["status"] == "manual_confirmed"
|
||||
assert confirmed["automatically_verified"] is False
|
||||
verified = repository_control.verify({"lock_session_id": confirmed["lock_session_id"]})
|
||||
assert verified["status"] == "manual_confirmation_unverified"
|
||||
assert repository_control.write_gate({"base_id": "base", "lock_session_id": confirmed["lock_session_id"], "repository_object": "Справочник.Товары"})["allowed"] is True
|
||||
assert repository_control.write_gate({"base_id": "base", "lock_session_id": confirmed["lock_session_id"], "repository_object": "Справочник.Другой"})["allowed"] is False
|
||||
|
||||
|
||||
def test_manual_lock_request_stays_pending_until_user_confirms_exact_saved_scope(monkeypatch, tmp_path: Path) -> None:
|
||||
configured_base(monkeypatch, tmp_path)
|
||||
values = json.loads(__import__("os").environ["ONEC_SQL_BASES_JSON"])
|
||||
values["base"]["repository"]["lock_mode"] = "manual"
|
||||
monkeypatch.setenv("ONEC_SQL_BASES_JSON", json.dumps(values))
|
||||
|
||||
requested = repository_control.create_lock_request({"base_id": "base", "object": "РегистрСведений.Настройки.Реквизит.Код"})
|
||||
assert requested["status"] == "pending_user_lock"
|
||||
assert requested["objects"] == ["РегистрСведений.Настройки"]
|
||||
assert requested["automatically_locked"] is False
|
||||
pending = repository_control.lock_request_status({"request_id": requested["request_id"]})
|
||||
assert pending["status"] == "pending_user_lock"
|
||||
|
||||
confirmed = repository_control.confirm_manual_lock({
|
||||
"base_id": "base",
|
||||
"request_id": requested["request_id"],
|
||||
"objects": ["Справочник.Подмена"],
|
||||
"user_confirmed_locked": True,
|
||||
})
|
||||
assert confirmed["objects"] == ["РегистрСведений.Настройки"]
|
||||
assert confirmed["automatically_verified"] is False
|
||||
completed = repository_control.lock_request_status({"request_id": requested["request_id"]})
|
||||
assert completed["status"] == "confirmed_by_user"
|
||||
|
||||
closed = repository_control.close_manual_lock({"lock_session_id": confirmed["lock_session_id"], "user_confirmed_released": True})
|
||||
assert closed["status"] == "closed"
|
||||
assert repository_control.write_gate({"base_id": "base", "lock_session_id": confirmed["lock_session_id"], "repository_object": "РегистрСведений.Настройки"})["allowed"] is False
|
||||
|
||||
|
||||
def test_pending_request_can_be_cancelled_and_old_state_expires(monkeypatch, tmp_path: Path) -> None:
|
||||
configured_base(monkeypatch, tmp_path)
|
||||
values = json.loads(__import__("os").environ["ONEC_SQL_BASES_JSON"])
|
||||
values["base"]["repository"]["lock_mode"] = "manual"
|
||||
monkeypatch.setenv("ONEC_SQL_BASES_JSON", json.dumps(values))
|
||||
request = repository_control.create_lock_request({"base_id": "base", "object": "Справочник.Товары"})
|
||||
cancelled = repository_control.cancel_lock_request({"request_id": request["request_id"], "confirm_cancel": True})
|
||||
assert cancelled["status"] == "cancelled"
|
||||
|
||||
old = repository_control.create_lock_request({"base_id": "base", "object": "Справочник.Склады"})
|
||||
state = repository_control._read_state()
|
||||
state["requests"][old["request_id"]]["created_at"] = 0
|
||||
repository_control._write_state(state)
|
||||
monkeypatch.setenv("ONEC_REPOSITORY_REQUEST_TTL_SECONDS", "60")
|
||||
assert repository_control.lock_request_status({"request_id": old["request_id"]})["status"] == "expired"
|
||||
|
||||
|
||||
def test_adapter_resolves_lock_request_object_against_live_sql(monkeypatch) -> None:
|
||||
calls = []
|
||||
|
||||
def fake_get_object(kind, name, **kwargs):
|
||||
calls.append((kind, name, kwargs["base_id"]))
|
||||
return {"status": "ok", "object": {"kind_ru": "РегистрСведений", "name": name, "guid": "guid"}}
|
||||
|
||||
monkeypatch.setattr(adapter_1c_server, "get_object", fake_get_object)
|
||||
normalized, error = adapter_1c_server.validate_repository_request_objects_sql({
|
||||
"base_id": "upo_test",
|
||||
"object": "РегистрСведений.Настройки.Реквизит.Код",
|
||||
})
|
||||
assert error is None
|
||||
assert calls == [("InformationRegister", "Настройки", "upo_test")]
|
||||
assert normalized["objects"] == ["РегистрСведений.Настройки"]
|
||||
|
||||
|
||||
def test_structural_lock_plan_requires_confirmation() -> None:
|
||||
result = repository_control.lock_plan({"operation": "delete", "object": "Справочник.Склады"})
|
||||
assert result["status"] == "needs_confirmation"
|
||||
|
||||
|
||||
def test_write_gate_requires_adapter_lock_for_configured_base(monkeypatch, tmp_path: Path) -> None:
|
||||
configured_base(monkeypatch, tmp_path)
|
||||
assert repository_control.write_gate({"base_id": "base"})["status"] == "needs_repository_lock"
|
||||
|
||||
|
||||
def test_adapter_dispatch_exposes_repository_status(monkeypatch, tmp_path: Path) -> None:
|
||||
configured_base(monkeypatch, tmp_path, backend="direct")
|
||||
result = adapter_1c_server.call_method("repository.status", {"base_id": "base"})
|
||||
assert result["status"] == "configured"
|
||||
assert result["repository"]["backend"] == "direct"
|
||||
|
||||
|
||||
def test_write_preflight_reports_repository_lock_requirement(monkeypatch, tmp_path: Path) -> None:
|
||||
configured_base(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(
|
||||
adapter_1c_server,
|
||||
"metadata_write_plan",
|
||||
lambda payload: {
|
||||
"allowed": True,
|
||||
"status": "ready",
|
||||
"path_resolution": {},
|
||||
"route": {"apply_method": "metadata.module.write_apply", "write_surface": "saved_state"},
|
||||
"required_guards": [],
|
||||
"problems": [],
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(adapter_1c_server, "metadata_write_preflight_saved_target", lambda payload, plan: {})
|
||||
result = adapter_1c_server.metadata_write_preflight({"base_id": "base"})
|
||||
assert result["allowed"] is False
|
||||
assert result["status"] == "needs_repository_lock"
|
||||
assert result["repository"]["backend"] == "karman_bridge"
|
||||
|
||||
|
||||
def test_direct_apply_method_is_repository_gated(monkeypatch, tmp_path: Path) -> None:
|
||||
configured_base(monkeypatch, tmp_path)
|
||||
result = adapter_1c_server.metadata_module_write_apply({"base_id": "base", "execution_mode": "apply"})
|
||||
assert result["status"] == "blocked"
|
||||
assert result["error"] == "needs_repository_lock"
|
||||
|
||||
|
||||
def test_lock_and_commit_use_configured_designer_endpoint(monkeypatch, tmp_path: Path) -> None:
|
||||
configured_base(monkeypatch, tmp_path)
|
||||
monkeypatch.setenv("ONEC_ADAPTER_ENABLE_EXTERNAL_1C", "true")
|
||||
calls = []
|
||||
|
||||
def fake_run(config, operation, timeout_seconds):
|
||||
calls.append((dict(config), list(operation), timeout_seconds))
|
||||
return {"status": "ok", "exit_code": 0, "duration_ms": 1, "output": ""}
|
||||
|
||||
monkeypatch.setattr(repository_control, "_run_designer", fake_run)
|
||||
locked = repository_control.lock({"base_id": "base", "object": "Справочник.Товары", "allow_repository_lock": True})
|
||||
assert locked["status"] == "acquired"
|
||||
assert calls[0][0]["endpoint"] == "tcp://configured.example:15420/configured-repository"
|
||||
assert calls[0][1][0] == "/ConfigurationRepositoryLock"
|
||||
assert repository_control.write_gate({"base_id": "base", "lock_session_id": locked["lock_session_id"], "repository_object": "Справочник.Товары"})["allowed"] is True
|
||||
mismatch = repository_control.write_gate({"base_id": "base", "lock_session_id": locked["lock_session_id"], "repository_object": "Справочник.Другой"})
|
||||
assert mismatch["status"] == "blocked_repository_scope_mismatch"
|
||||
|
||||
blocked = repository_control.commit({"lock_session_id": locked["lock_session_id"], "comment": "test"})
|
||||
assert blocked["error"] == "explicit_repository_commit_required"
|
||||
committed = repository_control.commit({"lock_session_id": locked["lock_session_id"], "comment": "test", "allow_repository_commit": True})
|
||||
assert committed["status"] == "committed"
|
||||
assert calls[1][1][0] == "/ConfigurationRepositoryCommit"
|
||||
|
||||
|
||||
def test_sql_only_mode_never_calls_external_repository_runner(monkeypatch, tmp_path: Path) -> None:
|
||||
configured_base(monkeypatch, tmp_path)
|
||||
monkeypatch.delenv("ONEC_ADAPTER_ENABLE_EXTERNAL_1C", raising=False)
|
||||
called = False
|
||||
|
||||
def forbidden(*args, **kwargs):
|
||||
nonlocal called
|
||||
called = True
|
||||
return {"status": "ok"}
|
||||
|
||||
monkeypatch.setattr(repository_control, "_run_designer", forbidden)
|
||||
result = repository_control.lock({"base_id": "base", "object": "Справочник.Товары", "allow_repository_lock": True})
|
||||
assert result["status"] == "blocked"
|
||||
assert result["execution"]["status"] == "external_1c_disabled"
|
||||
assert called is False
|
||||
@@ -0,0 +1,62 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
|
||||
from resolve_1c_bsl_symbol import load_json, resolve_symbol # noqa: E402
|
||||
|
||||
|
||||
METADATA = ROOT / "plugins" / "1c" / "metadata" / "examples" / "metadata-v2.example.json"
|
||||
MODULES = ROOT / "plugins" / "1c" / "metadata" / "examples" / "bsl-modules.example.json"
|
||||
|
||||
|
||||
def resolve(expression: str, **kwargs: str) -> dict:
|
||||
return resolve_symbol(
|
||||
load_json(METADATA),
|
||||
load_json(MODULES),
|
||||
expression=expression,
|
||||
object_kind="catalog",
|
||||
object_name="Номенклатура",
|
||||
module_id="catalog.Номенклатура.object",
|
||||
routine_name="ПередЗаписью",
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
def test_full_metadata_path_resolves_as_metadata() -> None:
|
||||
result = resolve("Справочник.Номенклатура.Артикул")
|
||||
|
||||
assert result["status"] == "resolved"
|
||||
assert result["resolution_kind"] == "metadata_path"
|
||||
assert result["canonical_path"] == "Справочник.Номенклатура.Артикул"
|
||||
assert result["safe_as_metadata_path"] is True
|
||||
|
||||
|
||||
def test_object_module_attribute_resolves_from_context() -> None:
|
||||
result = resolve("Наименование")
|
||||
|
||||
assert result["status"] == "resolved"
|
||||
assert result["resolution_kind"] == "context_metadata_member"
|
||||
assert result["canonical_path"] == "Справочник.Номенклатура.Наименование"
|
||||
assert result["context_path"] == "Наименование"
|
||||
|
||||
|
||||
def test_routine_parameter_wins_over_metadata_guess() -> None:
|
||||
result = resolve("Отказ.Код")
|
||||
|
||||
assert result["status"] == "resolved"
|
||||
assert result["resolution_kind"] == "parameter"
|
||||
assert result["context_path"] == "Отказ.Код"
|
||||
assert result["safe_as_metadata_path"] is False
|
||||
|
||||
|
||||
def test_short_object_name_is_candidate_not_metadata_path() -> None:
|
||||
result = resolve("Номенклатура.ЕдИзмерение.Код")
|
||||
|
||||
assert result["status"] == "unresolved"
|
||||
assert result["safe_as_metadata_path"] is False
|
||||
assert result["candidates"][0]["canonical_path"] == "Справочник.Номенклатура"
|
||||
assert result["candidates"][0]["reason"] == "short_object_name_requires_kind"
|
||||
@@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
|
||||
from resolve_1c_fact import load_json, resolve_from_snapshot # noqa: E402
|
||||
|
||||
|
||||
SNAPSHOT = ROOT / "plugins" / "1c" / "metadata" / "examples" / "metadata.example.json"
|
||||
|
||||
|
||||
def resolve(member: str | None = None, *, table_section: str | None = None) -> dict:
|
||||
return resolve_from_snapshot(
|
||||
load_json(SNAPSHOT),
|
||||
snapshot_path=SNAPSHOT,
|
||||
kind="Справочник",
|
||||
object_name="Номенклатура",
|
||||
member=member,
|
||||
area="any",
|
||||
table_section=table_section,
|
||||
view="effective",
|
||||
extension=None,
|
||||
)
|
||||
|
||||
|
||||
def test_object_result_has_canonical_path() -> None:
|
||||
result = resolve()
|
||||
|
||||
assert result["exists"] is True
|
||||
assert result["object"]["canonical_path"] == "Справочник.Номенклатура"
|
||||
assert result["match"]["canonical_path"] == "Справочник.Номенклатура"
|
||||
assert result["match"]["path_kind"] == "metadata_object"
|
||||
|
||||
|
||||
def test_attribute_result_has_canonical_path() -> None:
|
||||
result = resolve("Артикул")
|
||||
|
||||
assert result["exists"] is True
|
||||
assert result["match"]["canonical_path"] == "Справочник.Номенклатура.Артикул"
|
||||
assert result["match"]["path_kind"] == "metadata_member"
|
||||
|
||||
|
||||
def test_tabular_section_attribute_has_context_path() -> None:
|
||||
result = resolve("Цена", table_section="Цены")
|
||||
|
||||
assert result["exists"] is True
|
||||
assert result["match"]["canonical_path"] == "Справочник.Номенклатура.Цены.Цена"
|
||||
assert result["match"]["context_path"] == "Цены.Цена"
|
||||
assert result["match"]["path_kind"] == "metadata_member"
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
|
||||
import embed_1c_semantic_cache as worker # noqa: E402
|
||||
|
||||
|
||||
def test_embed_pending_semantic_cache_upserts_with_precondition(monkeypatch) -> None:
|
||||
calls: list[tuple[str, dict]] = []
|
||||
|
||||
def fake_adapter_call(adapter_url: str, method: str, payload: dict, *, timeout_seconds: int = 180) -> dict:
|
||||
calls.append((method, payload))
|
||||
if method == "semantic.cache.pending":
|
||||
return {
|
||||
"status": "ok",
|
||||
"documents": [
|
||||
{
|
||||
"document_id": "doc-1",
|
||||
"content_sha1": "a" * 40,
|
||||
"text": "ОбластьШапка макета",
|
||||
}
|
||||
],
|
||||
}
|
||||
if method == "semantic.cache.embedding.upsert":
|
||||
assert payload["document_id"] == "doc-1"
|
||||
assert payload["content_sha1"] == "a" * 40
|
||||
assert payload["embedding_model"] == "local-hashing-v1"
|
||||
assert isinstance(payload["embedding"], list)
|
||||
assert len(payload["embedding"]) == 16
|
||||
return {"status": "ok", "dimensions": len(payload["embedding"])}
|
||||
raise AssertionError(method)
|
||||
|
||||
monkeypatch.setattr(worker, "adapter_call", fake_adapter_call)
|
||||
|
||||
result = worker.embed_pending_semantic_cache(
|
||||
adapter_url="http://adapter/rpc",
|
||||
base_id="upo_test",
|
||||
limit=1,
|
||||
dimensions=16,
|
||||
)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
assert result["counts"] == {"pending": 1, "processed": 1, "stored": 1, "conflicts": 0, "skipped": 0, "errors": 0}
|
||||
assert [method for method, _ in calls] == ["semantic.cache.pending", "semantic.cache.embedding.upsert"]
|
||||
|
||||
|
||||
def test_embed_pending_semantic_cache_dry_run_does_not_upsert(monkeypatch) -> None:
|
||||
calls: list[str] = []
|
||||
|
||||
def fake_adapter_call(adapter_url: str, method: str, payload: dict, *, timeout_seconds: int = 180) -> dict:
|
||||
calls.append(method)
|
||||
return {
|
||||
"status": "ok",
|
||||
"documents": [{"document_id": "doc-1", "content_sha1": "b" * 40, "text": "Реквизиты"}],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(worker, "adapter_call", fake_adapter_call)
|
||||
|
||||
result = worker.embed_pending_semantic_cache(
|
||||
adapter_url="http://adapter/rpc",
|
||||
base_id="upo_test",
|
||||
dimensions=8,
|
||||
dry_run=True,
|
||||
)
|
||||
|
||||
assert result["counts"]["processed"] == 1
|
||||
assert result["counts"]["stored"] == 0
|
||||
assert result["upserts"][0]["status"] == "dry_run"
|
||||
assert calls == ["semantic.cache.pending"]
|
||||
@@ -0,0 +1,89 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
|
||||
import search_1c_semantic_cache as semantic_search # noqa: E402
|
||||
|
||||
|
||||
def test_search_semantic_cache_sends_query_embedding_and_validation(monkeypatch) -> None:
|
||||
seen: dict = {}
|
||||
|
||||
def fake_adapter_call(adapter_url: str, method: str, payload: dict, *, timeout_seconds: int = 180) -> dict:
|
||||
seen["adapter_url"] = adapter_url
|
||||
seen["method"] = method
|
||||
seen["payload"] = payload
|
||||
return {
|
||||
"status": "ok",
|
||||
"matches": [
|
||||
{
|
||||
"document_id": "doc-1",
|
||||
"score": 0.9,
|
||||
"match_by": "vector_embedding",
|
||||
"object": {"kind": "Template", "name": "ПФ_MXL"},
|
||||
"cache": {"embedding_model": "local-hashing-v1", "vector_status": "embedded"},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(semantic_search, "adapter_call", fake_adapter_call)
|
||||
|
||||
result = semantic_search.search_semantic_cache(
|
||||
adapter_url="http://adapter/rpc",
|
||||
base_id="upo_test",
|
||||
query="ОбластьШапка",
|
||||
kind="Template",
|
||||
limit=3,
|
||||
dimensions=12,
|
||||
validate_candidates=True,
|
||||
validation_limit=2,
|
||||
)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
assert result["client_embedding"]["dimensions"] == 12
|
||||
assert seen["method"] == "semantic.cache.search"
|
||||
assert seen["payload"]["base_id"] == "upo_test"
|
||||
assert seen["payload"]["query"] == "ОбластьШапка"
|
||||
assert seen["payload"]["kind"] == "Template"
|
||||
assert seen["payload"]["limit"] == 3
|
||||
assert seen["payload"]["validate_candidates"] is True
|
||||
assert seen["payload"]["validation_limit"] == 2
|
||||
assert isinstance(seen["payload"]["query_embedding"], list)
|
||||
assert len(seen["payload"]["query_embedding"]) == 12
|
||||
|
||||
|
||||
def test_search_semantic_cache_can_embed_pending_before_search(monkeypatch) -> None:
|
||||
calls: list[str] = []
|
||||
|
||||
def fake_embed_pending_semantic_cache(**kwargs) -> dict:
|
||||
calls.append("embed")
|
||||
assert kwargs["base_id"] == "upo_test"
|
||||
assert kwargs["kind"] == "Template"
|
||||
assert kwargs["limit"] == 7
|
||||
assert kwargs["batch_size"] == 3
|
||||
return {"status": "ok", "counts": {"pending": 1, "processed": 1, "stored": 1}, "embedding": {"model": "local-hashing-v1"}}
|
||||
|
||||
def fake_adapter_call(adapter_url: str, method: str, payload: dict, *, timeout_seconds: int = 180) -> dict:
|
||||
calls.append(method)
|
||||
return {"status": "ok", "matches": []}
|
||||
|
||||
monkeypatch.setattr(semantic_search, "embed_pending_semantic_cache", fake_embed_pending_semantic_cache)
|
||||
monkeypatch.setattr(semantic_search, "adapter_call", fake_adapter_call)
|
||||
|
||||
result = semantic_search.search_semantic_cache(
|
||||
adapter_url="http://adapter/rpc",
|
||||
base_id="upo_test",
|
||||
query="ОбластьШапка",
|
||||
kind="Template",
|
||||
dimensions=8,
|
||||
embed_pending=True,
|
||||
embed_limit=7,
|
||||
embed_batch_size=3,
|
||||
)
|
||||
|
||||
assert calls == ["embed", "semantic.cache.search"]
|
||||
assert result["embedding_refresh"]["counts"]["stored"] == 1
|
||||
@@ -0,0 +1,126 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
CONNECTOR = ROOT / "plugins" / "1c" / "connector"
|
||||
|
||||
|
||||
def load_adapter():
|
||||
path = CONNECTOR / "adapter_1c_server.py"
|
||||
spec = importlib.util.spec_from_file_location("adapter_1c_policy_test", path)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_sql_base_access_policy_contract() -> None:
|
||||
policy = yaml.safe_load((CONNECTOR / "policies" / "sql-base-access-policy.yaml").read_text(encoding="utf-8"))
|
||||
assert policy["status"] == "active"
|
||||
assert policy["base_settings"]["selector"] == "base_id"
|
||||
assert set(policy["base_settings"]["required_fields"]) == {"server", "database", "user"}
|
||||
assert policy["read_scope"]["application_data"] == "read_only"
|
||||
assert policy["read_scope"]["metadata_structure"] == "read_only"
|
||||
assert set(policy["write_scope"]["allowed"].values()) == {"ConfigSave", "ConfigCASSave"}
|
||||
assert policy["sql_identity_management"]["mode"] == "forbidden"
|
||||
|
||||
|
||||
def test_xml_is_offline_decoder_evidence_and_runtime_is_sql_only() -> None:
|
||||
policy = yaml.safe_load((CONNECTOR / "policies" / "xml-decoding-reference-policy.yaml").read_text(encoding="utf-8"))
|
||||
service = yaml.safe_load((CONNECTOR / "service.yaml").read_text(encoding="utf-8"))
|
||||
|
||||
assert policy["status"] == "active"
|
||||
assert policy["offline_analysis"]["allowed"] is True
|
||||
assert policy["runtime"]["source"] == "sql_only"
|
||||
assert policy["runtime"]["xml_mount_required"] is False
|
||||
assert "policies/xml-decoding-reference-policy.yaml" in service["contracts"]["policies"]
|
||||
|
||||
|
||||
def test_designer_sql_decoding_policy_keeps_adapter_read_only() -> None:
|
||||
policy = yaml.safe_load((CONNECTOR / "policies" / "designer-sql-decoding-policy.yaml").read_text(encoding="utf-8"))
|
||||
service = yaml.safe_load((CONNECTOR / "service.yaml").read_text(encoding="utf-8"))
|
||||
|
||||
assert policy["scope"]["default_base_id"] == "upo_test"
|
||||
assert policy["scope"]["adapter_role"] == "sql_observer_and_decoder"
|
||||
assert policy["credentials"]["persistence"] == "forbidden_in_repository"
|
||||
assert policy["sql_observation"]["adapter_access"] == "read_only"
|
||||
assert "direct_application_data_write" in policy["sql_observation"]["forbidden"]
|
||||
assert policy["xml"]["role"] == "offline_schema_reference_only"
|
||||
assert "policies/designer-sql-decoding-policy.yaml" in service["contracts"]["policies"]
|
||||
|
||||
|
||||
def test_runtime_rejects_xml_sources_at_any_payload_depth() -> None:
|
||||
adapter = load_adapter()
|
||||
|
||||
direct = adapter.call_method_impl(
|
||||
"metadata.object.get",
|
||||
{"base_id": "upo_test", "ref": "Catalog.Test", "xml_path": "Configuration.xml"},
|
||||
)
|
||||
nested = adapter.call_method_impl(
|
||||
"metadata.write.plan",
|
||||
{"base_id": "upo_test", "target": {"form_xml_path": "Forms/Test/Ext/Form.xml"}},
|
||||
)
|
||||
|
||||
assert direct["status"] == "invalid_argument"
|
||||
assert direct["argument"] == "payload.xml_path"
|
||||
assert nested["status"] == "invalid_argument"
|
||||
assert nested["argument"] == "payload.target.form_xml_path"
|
||||
assert "SQL-only" in direct["diagnostics"]["message"]
|
||||
|
||||
|
||||
def test_adapter_has_no_xml_runtime_environment_configuration() -> None:
|
||||
source = (CONNECTOR / "adapter_1c_server.py").read_text(encoding="utf-8")
|
||||
assert "ONEC_XML" not in source
|
||||
|
||||
|
||||
def test_sql_connection_requires_explicit_base_entry(monkeypatch) -> None:
|
||||
adapter = load_adapter()
|
||||
for name in (
|
||||
"ONEC_SQL_BASES_JSON",
|
||||
"ONEC_SQL_CONNECTIONS_JSON",
|
||||
"ONEC_SQL_BASES_JSON_FILE",
|
||||
"ONEC_SQL_CONNECTIONS_JSON_FILE",
|
||||
):
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
monkeypatch.setenv("ONEC_SQL_SERVER", "must-not-be-used")
|
||||
monkeypatch.setenv("ONEC_SQL_USER", "must-not-be-used")
|
||||
monkeypatch.setenv("ONEC_SQL_PASSWORD", "must-not-be-used")
|
||||
|
||||
config, error = adapter.sql_config_for_base("upo_test")
|
||||
|
||||
assert config is None
|
||||
assert error and error["status"] == "not_configured"
|
||||
|
||||
|
||||
def test_sql_connection_does_not_infer_database_name(monkeypatch) -> None:
|
||||
adapter = load_adapter()
|
||||
monkeypatch.setenv(
|
||||
"ONEC_SQL_BASES_JSON",
|
||||
'{"upo_test":{"server":"sql-host","user":"login","password":"secret"}}',
|
||||
)
|
||||
|
||||
config, error = adapter.sql_config_for_base("upo_test")
|
||||
|
||||
assert config is None
|
||||
assert error and "database" in error["message"]
|
||||
|
||||
|
||||
def test_sql_connection_ignores_legacy_connection_map(monkeypatch) -> None:
|
||||
adapter = load_adapter()
|
||||
monkeypatch.delenv("ONEC_SQL_BASES_JSON", raising=False)
|
||||
monkeypatch.delenv("ONEC_SQL_BASES_JSON_FILE", raising=False)
|
||||
monkeypatch.setenv(
|
||||
"ONEC_SQL_CONNECTIONS_JSON",
|
||||
'{"upo_test":{"server":"must-not-be-used","database":"must-not-be-used","user":"must-not-be-used","password":"must-not-be-used"}}',
|
||||
)
|
||||
|
||||
config, error = adapter.sql_config_for_base("upo_test")
|
||||
|
||||
assert config is None
|
||||
assert error and error["status"] == "not_configured"
|
||||
assert adapter.sql_configured_base_ids() == []
|
||||
Reference in New Issue
Block a user