Initial SQL-only 1C adapter baseline

This commit is contained in:
2026-07-22 03:03:47 +03:00
commit e2503b77e7
545 changed files with 184711 additions and 0 deletions
+78
View File
@@ -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"
+222
View File
@@ -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 {"))
+55
View File
@@ -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
+59
View File
@@ -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"
+64
View File
@@ -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]
+69
View File
@@ -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")
+44
View File
@@ -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"]
+61
View File
@@ -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()
+54
View File
@@ -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",
]
+72
View File
@@ -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]
+130
View File
@@ -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
+65
View File
@@ -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"
+61
View File
@@ -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"
+683
View File
@@ -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
+89
View File
@@ -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
+99
View File
@@ -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
+60
View File
@@ -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"] == []
+205
View File
@@ -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)
+71
View File
@@ -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
+228
View File
@@ -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
+62
View File
@@ -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"
+53
View File
@@ -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
+126
View File
@@ -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() == []