#!/usr/bin/env python3 """Resolve a 1C fact against an explicit current-configuration source. This command is intentionally narrower than RAG: it verifies whether a named 1C object member exists in the provided configuration evidence and returns the source/provenance. It does not search examples unless a snapshot path is passed explicitly by the caller. """ from __future__ import annotations import argparse import base64 import json import re import sys from pathlib import Path from typing import Any sys.path.insert(0, str(Path(__file__).resolve().parent)) from get_1c_object_brief_context import view_forms, view_modules # noqa: E402 from get_1c_object_metadata import build_object_metadata # noqa: E402 from resolve_1c_object import canonical_kind, load_json, normalize, parse_query, resolve_object # noqa: E402 KIND_TO_RU = { "Catalog": "Справочник", "Document": "Документ", "Enum": "Перечисление", "InformationRegister": "РегистрСведений", "AccumulationRegister": "РегистрНакопления", "AccountingRegister": "РегистрБухгалтерии", "Report": "Отчет", "DataProcessor": "Обработка", "CommonModule": "ОбщийМодуль", "Form": "Форма", "Template": "Макет", "Command": "Команда", } SNAPSHOT_KIND_ALIASES = { "Catalog": {"catalog", "справочник"}, "Document": {"document", "документ"}, "Enum": {"enum", "перечисление"}, "InformationRegister": {"register", "information_register", "регистрсведений", "регистр сведений"}, "AccumulationRegister": {"register", "accumulation_register", "регистрнакопления", "регистр накопления"}, "Report": {"report", "отчет"}, "DataProcessor": {"processing", "processor", "обработка"}, "CommonModule": {"common_module", "общиймодуль", "общий модуль"}, "Form": {"form", "форма"}, } MEMBER_AREAS = { "attribute": {"attribute", "attr", "реквизит", "реквизиты"}, "tabular_section": {"tabular_section", "table", "табличнаячасть", "табличная часть", "тч"}, "tabular_section_attribute": {"tabular_section_attribute", "table_attribute", "реквизиттч", "реквизит табличной части"}, "form": {"form", "форма", "формы"}, "command": {"command", "команда", "команды"}, "module": {"module", "модуль", "модули"}, "any": {"any", "любой", "все"}, } def decode_arg(value: str | None, encoded: str | None) -> str | None: if encoded: return base64.b64decode(encoded).decode("utf-8") return value def compact_text(value: Any) -> str: return re.sub(r"[\s._-]+", "", str(value or "")).casefold() def member_area(value: str | None) -> str: if not value: return "any" wanted = compact_text(value) for area, aliases in MEMBER_AREAS.items(): if wanted in {compact_text(alias) for alias in aliases}: return area return value def split_fact_path(raw: str) -> tuple[str | None, str, str | None, str | None]: """Parse common 1C fact paths. Supported examples: - Справочник.Номенклатура - Справочник.Номенклатура.Артикул - Справочник.Номенклатура.Цены.Цена """ parts = [part.strip() for part in str(raw or "").split(".") if part.strip()] if len(parts) >= 2: kind = parts[0] object_name = parts[1] if len(parts) == 2: return kind, object_name, None, None if len(parts) == 3: return kind, object_name, None, parts[2] return kind, object_name, parts[2], parts[3] if len(parts) == 1: return None, parts[0], None, None raise ValueError("fact path is empty") def path_join(*parts: Any) -> str: return ".".join(str(part).strip() for part in parts if str(part or "").strip()) def object_canonical_path(kind: Any, name: Any) -> str: normalized_kind = canonical_kind(str(kind or "")) or str(kind or "") kind_text = KIND_TO_RU.get(normalized_kind, normalized_kind) return path_join(kind_text, name) def member_canonical_path(object_path: str, *, table_section: Any = None, member: Any = None) -> str: return path_join(object_path, table_section, member) def same_name(left: Any, right: Any) -> bool: return normalize(str(left or "")) == normalize(str(right or "")) def matches_name(item: dict[str, Any], wanted: str) -> bool: return same_name(item.get("name"), wanted) or same_name(item.get("synonym"), wanted) def nearest(items: list[dict[str, Any]], wanted: str, limit: int = 8) -> list[dict[str, Any]]: wanted_norm = normalize(wanted) result = [] for item in items: name = str(item.get("name") or "") synonym = str(item.get("synonym") or "") if wanted_norm and (wanted_norm in normalize(name) or wanted_norm in normalize(synonym) or normalize(name) in wanted_norm): result.append(compact_member(item)) return result[:limit] def compact_member(item: dict[str, Any], *, area: str | None = None) -> dict[str, Any]: payload = { "area": area, "name": item.get("name"), "synonym": item.get("synonym"), "type": item.get("type"), "types": item.get("types"), "canonical_types": item.get("canonical_types"), "origin": item.get("origin"), "effective_action": item.get("effective_action"), "uuid": item.get("uuid"), } return {key: value for key, value in payload.items() if value not in (None, [], "")} def with_path_fields( member_payload: dict[str, Any], *, object_path: str, path_kind: str, table_section: str | None = None, ) -> dict[str, Any]: payload = dict(member_payload) payload["canonical_path"] = member_canonical_path( object_path, table_section=table_section, member=payload.get("name"), ) payload["path_kind"] = path_kind if table_section: payload["context_path"] = path_join(table_section, payload.get("name")) return payload def result_base(*, query: dict[str, Any], source: dict[str, Any], view: str, extension: str | None) -> dict[str, Any]: return { "schema": "onec_fact_resolution.v1", "query": query, "source": source, "view": view, "extension": extension if view == "extension" else None, } def resolve_from_route_index( index: dict[str, Any], *, index_path: Path, kind: str | None, object_name: str, member: str | None, area: str, table_section: str | None, view: str, extension: str | None, ) -> dict[str, Any]: canonical_kind_value, parsed_name = parse_query(kind, object_name) query = { "kind": canonical_kind_value, "name": parsed_name, "member": member, "table_section": table_section, "area": area, } result = result_base( query=query, source={"kind": "route_index", "path": str(index_path)}, view=view, extension=extension, ) resolution = resolve_object(index, kind=canonical_kind_value, name=parsed_name, limit=50) canonical = resolution.get("canonical") if not canonical: result.update( { "exists": False, "confidence": "none", "reason": "object_not_found", "object": None, "nearest": resolution.get("matches") or [], } ) return result result["object"] = { "kind": canonical.get("kind"), "kind_ru": KIND_TO_RU.get(str(canonical.get("kind") or ""), canonical.get("kind")), "name": canonical.get("name"), "synonym": canonical.get("synonym"), "uuid": canonical.get("guid"), } object_path = object_canonical_path(result["object"].get("kind"), result["object"].get("name")) result["object"]["canonical_path"] = object_path result["object"]["path_kind"] = "metadata_object" result["active_extensions"] = sorted( { (overlay.get("origin") or {}).get("extension") or overlay.get("extension_name") for overlay in (resolution.get("extension_overlays") or []) if (overlay.get("origin") or {}).get("extension") or overlay.get("extension_name") } ) if not member and not table_section: result.update({"exists": True, "confidence": "verified", "area": "object", "match": result["object"]}) return result metadata = build_object_metadata(index, kind=canonical_kind_value or "", name=parsed_name, view=view, extension=extension, include_storage=False) metadata_extensions = set(result.get("active_extensions") or []) for item in metadata.get("attributes") or []: origin = item.get("origin") or {} if origin.get("extension"): metadata_extensions.add(origin["extension"]) for extension_name in origin.get("extensions") or []: if extension_name: metadata_extensions.add(extension_name) result["active_extensions"] = sorted(metadata_extensions) checks: list[tuple[str, list[dict[str, Any]]]] = [] if area in {"any", "attribute"} and member: checks.append(("attribute", metadata.get("attributes") or [])) if area in {"any", "tabular_section"} and not (area == "any" and table_section and member): wanted = table_section or member sections = metadata.get("tabular_sections") or [] if wanted: match = next((item for item in sections if matches_name(item, wanted)), None) if match: result.update( { "exists": True, "confidence": "verified", "area": "tabular_section", "match": with_path_fields( compact_member(match, area="tabular_section"), object_path=object_path, path_kind="metadata_member", ), } ) return result checks.append(("tabular_section", sections)) if area in {"any", "tabular_section_attribute"} and table_section and member: sections = metadata.get("tabular_sections") or [] section = next((item for item in sections if matches_name(item, table_section)), None) attrs = section.get("attributes") if section else [] if attrs: match = next((item for item in attrs if matches_name(item, member)), None) if match: result.update( { "exists": True, "confidence": "verified", "area": "tabular_section_attribute", "table_section": with_path_fields( compact_member(section, area="tabular_section"), object_path=object_path, path_kind="metadata_member", ), "match": with_path_fields( compact_member(match, area="tabular_section_attribute"), object_path=object_path, path_kind="metadata_member", table_section=str(section.get("name") or table_section), ), } ) return result checks.append(("tabular_section_attribute", attrs)) if area in {"any", "form"} and member: forms, _ = view_forms(canonical, resolution, view=view, extension=extension) checks.append(("form", forms)) if area in {"any", "module"} and member: modules, _ = view_modules(canonical, resolution, view=view, extension=extension) checks.append(("module", modules)) for check_area, items in checks: if not member: continue match = next((item for item in items if matches_name(item, member)), None) if match: result.update( { "exists": True, "confidence": "verified", "area": check_area, "match": with_path_fields( compact_member(match, area=check_area), object_path=object_path, path_kind="metadata_member" if check_area in {"attribute", "module", "form", "command"} else check_area, ), } ) return result near = [] wanted = member or table_section or "" for check_area, items in checks: for item in nearest(items, wanted): item["area"] = check_area near.append(item) result.update( { "exists": False, "confidence": "verified_absent", "reason": "member_not_found_in_current_source", "nearest": near[:10], "counts": { "attributes": len(metadata.get("attributes") or []), "tabular_sections": len(metadata.get("tabular_sections") or []), }, } ) return result def snapshot_kind_matches(actual: str, wanted: str | None) -> bool: if not wanted: return True canonical = canonical_kind(wanted) aliases = SNAPSHOT_KIND_ALIASES.get(canonical or wanted, {wanted}) return compact_text(actual) in {compact_text(alias) for alias in aliases} def resolve_from_snapshot( snapshot: dict[str, Any], *, snapshot_path: Path, kind: str | None, object_name: str, member: str | None, area: str, table_section: str | None, view: str, extension: str | None, ) -> dict[str, Any]: query = {"kind": canonical_kind(kind), "name": object_name, "member": member, "table_section": table_section, "area": area} result = result_base( query=query, source={"kind": "metadata_snapshot", "path": str(snapshot_path), "name": (snapshot.get("source") or {}).get("name")}, view=view, extension=extension, ) objects = snapshot.get("objects") or [] obj = next( ( item for item in objects if snapshot_kind_matches(str(item.get("kind") or ""), kind) and (same_name(item.get("name"), object_name) or same_name(item.get("full_name"), object_name) or same_name(item.get("synonym"), object_name)) ), None, ) if not obj: result.update({"exists": False, "confidence": "none", "reason": "object_not_found", "nearest": []}) return result result["object"] = {key: obj.get(key) for key in ("kind", "name", "full_name", "synonym") if obj.get(key)} object_path = str(obj.get("full_name") or object_canonical_path(obj.get("kind"), obj.get("name"))) result["object"]["canonical_path"] = object_path result["object"]["path_kind"] = "metadata_object" if not member and not table_section: result.update({"exists": True, "confidence": "verified", "area": "object", "match": result["object"]}) return result checks: list[tuple[str, list[dict[str, Any]]]] = [] if area in {"any", "attribute"} and member: checks.append(("attribute", obj.get("attributes") or [])) if area in {"any", "tabular_section"} and not (area == "any" and table_section and member): wanted = table_section or member sections = obj.get("tabular_sections") or [] if wanted: section = next((item for item in sections if matches_name(item, wanted)), None) if section: result.update( { "exists": True, "confidence": "verified", "area": "tabular_section", "match": with_path_fields( compact_member(section, area="tabular_section"), object_path=object_path, path_kind="metadata_member", ), } ) return result checks.append(("tabular_section", sections)) if area in {"any", "tabular_section_attribute"} and table_section and member: section = next((item for item in obj.get("tabular_sections") or [] if matches_name(item, table_section)), None) attrs = section.get("attributes") if section else [] checks.append(("tabular_section_attribute", attrs or [])) if area in {"any", "form"} and member: checks.append(("form", obj.get("forms") or [])) if area in {"any", "command"} and member: checks.append(("command", obj.get("commands") or [])) if area in {"any", "module"} and member: checks.append(("module", obj.get("modules") or [])) for check_area, items in checks: if member: match = next((item for item in items if matches_name(item, member)), None) if match: context_section = None if check_area == "tabular_section_attribute" and table_section: context_section = table_section result.update( { "exists": True, "confidence": "verified", "area": check_area, "match": with_path_fields( compact_member(match, area=check_area), object_path=object_path, path_kind="metadata_member", table_section=context_section, ), } ) return result near = [] wanted = member or table_section or "" for check_area, items in checks: for item in nearest(items, wanted): item["area"] = check_area near.append(item) result.update({"exists": False, "confidence": "verified_absent", "reason": "member_not_found_in_explicit_snapshot", "nearest": near[:10]}) return result def main() -> int: parser = argparse.ArgumentParser(description="Resolve a concrete 1C fact against an explicit configuration source.") source = parser.add_mutually_exclusive_group(required=True) source.add_argument("--index", type=Path, help="Unified object route index for current configuration.") source.add_argument("--snapshot", type=Path, help="Explicit metadata snapshot. Use only when this is the intended source.") parser.add_argument("--path", help="Fact path, for example Справочник.Номенклатура.Артикул") parser.add_argument("--path-b64") parser.add_argument("--kind") parser.add_argument("--name") parser.add_argument("--member") parser.add_argument("--table-section") parser.add_argument("--area", default="any") parser.add_argument("--view", choices=["effective", "base", "extension"], default="effective") parser.add_argument("--extension") parser.add_argument("--output", type=Path) args = parser.parse_args() fact_path = decode_arg(args.path, args.path_b64) if fact_path: path_kind, path_name, path_section, path_member = split_fact_path(fact_path) kind = args.kind or path_kind name = args.name or path_name table_section = args.table_section or path_section member = args.member or path_member else: kind = args.kind name = args.name table_section = args.table_section member = args.member if not name: raise SystemExit("Use --path or --kind/--name.") area = member_area(args.area) if args.index: result = resolve_from_route_index( load_json(args.index), index_path=args.index, kind=kind, object_name=name, member=member, area=area, table_section=table_section, view=args.view, extension=args.extension, ) else: result = resolve_from_snapshot( load_json(args.snapshot), snapshot_path=args.snapshot, kind=kind, object_name=name, member=member, area=area, table_section=table_section, view=args.view, extension=args.extension, ) text = json.dumps(result, ensure_ascii=False, indent=2) + "\n" if args.output: args.output.parent.mkdir(parents=True, exist_ok=True) args.output.write_text(text, encoding="utf-8") print(json.dumps({"output": str(args.output), "exists": result.get("exists"), "confidence": result.get("confidence")}, ensure_ascii=False)) else: print(text) return 0 if __name__ == "__main__": raise SystemExit(main())