From 4c02e2f73adee782e9053060b9b22cf41a4ec97f Mon Sep 17 00:00:00 2001 From: Mikhail Date: Fri, 22 May 2026 12:25:26 +0300 Subject: [PATCH] Optimize XML AI structure package for Codex --- .../src/api_server/ai_structure_service.py | 198 ++++++++++++++++-- services/api-server/src/api_server/main.py | 1 + services/api-server/tests/test_api.py | 63 +++++- 3 files changed, 244 insertions(+), 18 deletions(-) diff --git a/services/api-server/src/api_server/ai_structure_service.py b/services/api-server/src/api_server/ai_structure_service.py index ee400f1..df0d55b 100644 --- a/services/api-server/src/api_server/ai_structure_service.py +++ b/services/api-server/src/api_server/ai_structure_service.py @@ -15,6 +15,7 @@ _PARSEABLE_SUFFIXES = {".xml", ".mdo", ".bsl"} _BINARY_1C_SUFFIXES = {".cf", ".cfe"} _CODEX_SOURCE_SUFFIXES = {".xml", ".mdo", ".bsl", ".json", ".txt"} _MAX_CODEX_SOURCE_FILE_BYTES = 2_000_000 +_ROOT_XML_NAMES = {"metadata.xml", "configuration.xml"} def prepare_ai_structure( @@ -60,6 +61,7 @@ def prepare_ai_structure( normalized, diagnostics, binaries, + _source_layout_summary(input_path), ) _write_json(output_path / "manifest.json", manifest) _write_json(output_path / "source_inventory.json", {"files": files}) @@ -70,6 +72,9 @@ def prepare_ai_structure( _write_json(output_path / "ai_edges.json", [edge.model_dump(mode="json") for edge in snapshot.edges]) if normalized is not None: _write_json(output_path / "normalized_project.json", normalized.model_dump(mode="json")) + _write_json(output_path / "project_layout.json", manifest.get("source_layout") or {}) + _write_json(output_path / "compact_objects.json", _compact_objects(normalized)) + _write_json(output_path / "compact_modules.json", _compact_modules(normalized)) _write_text(output_path / "ai_context.md", _ai_context_markdown(manifest, snapshot, normalized)) _write_text(output_path / "export_plan.md", _export_plan_markdown(project_id, input_path, output_path, binaries, parseable)) _write_codex_package(codex_root, input_path, manifest, files, snapshot, normalized, binaries, parseable) @@ -98,6 +103,7 @@ def _manifest( normalized: NormalizedProject | None, diagnostics: list[str], binaries: list[dict[str, Any]], + source_layout: dict[str, Any], ) -> dict[str, Any]: return { "version": AI_STRUCTURE_VERSION, @@ -109,6 +115,7 @@ def _manifest( "status": "ready" if snapshot is not None or normalized is not None else "export_required", "files_count": len(files), "binary_1c_files": binaries, + "source_layout": source_layout, "artifacts": _artifacts(snapshot, normalized), "snapshot": None if snapshot is None @@ -134,7 +141,16 @@ def _manifest( def _artifacts(snapshot: SirSnapshot | None, normalized: NormalizedProject | None) -> list[str]: - artifacts = ["manifest.json", "source_inventory.json", "ai_context.md", "export_plan.md", "codex_package"] + artifacts = [ + "manifest.json", + "source_inventory.json", + "ai_context.md", + "export_plan.md", + "codex_package", + "project_layout.json", + "compact_objects.json", + "compact_modules.json", + ] if snapshot is not None: artifacts.extend(["sir_snapshot.json", "ai_objects.json", "ai_modules.json", "ai_edges.json"]) if normalized is not None: @@ -170,16 +186,25 @@ def _write_codex_package( (root / "objects").mkdir(parents=True, exist_ok=True) (root / "modules").mkdir(parents=True, exist_ok=True) (root / "raw").mkdir(parents=True, exist_ok=True) + (root / "compact").mkdir(parents=True, exist_ok=True) source_map = _copy_codex_sources(input_path, root / "source") + compact_objects = _compact_objects(normalized) + compact_modules = _compact_modules(normalized) _write_text(root / "AGENTS.md", _codex_agents_markdown(manifest)) _write_text(root / "README.md", _codex_readme_markdown(manifest)) _write_text(root / "context" / "CODEX_START_HERE.md", _codex_start_here_markdown(manifest)) _write_text(root / "context" / "project-overview.md", _ai_context_markdown(manifest, snapshot, normalized)) + _write_text(root / "context" / "project-brief.md", _project_brief_markdown(manifest, compact_objects, compact_modules)) _write_text(root / "context" / "export-plan.md", _export_plan_markdown(manifest["project_id"], Path(manifest["input_path"]), root, binaries, parseable)) _write_json(root / "indexes" / "manifest.json", manifest) _write_json(root / "indexes" / "codex-navigation.json", _codex_navigation(manifest, source_map)) _write_json(root / "indexes" / "source-inventory.json", {"files": files}) _write_json(root / "indexes" / "source-map.json", {"files": source_map}) + _write_json(root / "indexes" / "project-layout.json", manifest.get("source_layout") or {}) + _write_json(root / "indexes" / "objects-compact.json", compact_objects) + _write_json(root / "indexes" / "modules-compact.json", compact_modules) + _write_json(root / "compact" / "objects.json", compact_objects) + _write_json(root / "compact" / "modules.json", compact_modules) if snapshot is not None: (root / "raw" / "sir_snapshot.json").write_bytes(snapshot_to_json(snapshot)) source_lookup = _source_lookup(source_map) @@ -203,7 +228,7 @@ def _copy_codex_sources(input_path: Path, target: Path) -> list[dict[str, Any]]: for path in source_files: suffix = path.suffix.casefold() relative = path.name if input_path.is_file() else path.relative_to(input_path).as_posix() - if suffix not in _CODEX_SOURCE_SUFFIXES: + if not _should_copy_codex_source(path, input_path): continue size = path.stat().st_size if size > _MAX_CODEX_SOURCE_FILE_BYTES: @@ -236,6 +261,20 @@ def _copy_codex_sources(input_path: Path, target: Path) -> list[dict[str, Any]]: return copied +def _should_copy_codex_source(path: Path, root: Path) -> bool: + suffix = path.suffix.casefold() + if suffix == ".bsl" or suffix == ".mdo": + return True + if suffix not in _CODEX_SOURCE_SUFFIXES: + return False + if suffix == ".xml": + if path.name.casefold() in _ROOT_XML_NAMES: + return True + parts = [part.casefold() for part in path.relative_to(root).parts[:-1]] if root.is_dir() else [] + return any(part in {"forms", "configuration", "конфигурация"} for part in parts) + return suffix in {".json", ".txt"} + + def _source_lookup(source_map: list[dict[str, Any]]) -> dict[str, str]: lookup: dict[str, str] = {} for item in source_map: @@ -262,9 +301,10 @@ def _codex_agents_markdown(manifest: dict[str, Any]) -> str: ## Как использовать эту папку - Используйте пакет как контекст только для чтения для проекта `{manifest['project_id']}`. -- Начинайте с `README.md` и `context/project-overview.md`. -- Для точной навигации используйте `indexes/objects.json`, `indexes/modules.json` и `indexes/edges.json`. -- Для текста BSL/XML/MDO используйте локальную папку `source/`. Не опирайтесь на абсолютный путь исходников на машине, где пакет был сгенерирован. +- Начинайте с `README.md`, `context/project-brief.md` и `context/project-overview.md`. +- Для быстрой навигации сначала используйте `indexes/objects-compact.json`, `indexes/modules-compact.json` и `indexes/project-layout.json`. +- К тяжелым файлам `indexes/objects.json`, `indexes/modules.json`, `raw/normalized_project.json` и `source/` переходите только когда компактной сводки уже не хватает. +- Для текста BSL/XML/MDO используйте локальную папку `source/`. Это выборочная копия нужных исходников, а не полный дубликат всей выгрузки. - Используйте `indexes/source-map.json`, чтобы сопоставлять исходные пути с локальными путями `source/...`. - Если есть `raw/normalized_project.json`, считайте его основной моделью метаданных 1С. - Модули, формы, команды, реквизиты, табличные части и права являются частями объектов 1С-владельцев. Не рассматривайте модуль формы как отдельный независимый файл. @@ -273,10 +313,11 @@ def _codex_agents_markdown(manifest: dict[str, Any]) -> str: ## Важные файлы -- `context/project-overview.md` - краткий контекст для человека. +- `context/project-brief.md` - короткая сводка для быстрого старта Codex. +- `context/project-overview.md` - расширенный контекст для человека. - `context/metadata-tree.md` - дерево метаданных из NormalizedProject. -- `indexes/*.json` - машиночитаемые индексы для поиска и рассуждений Codex. -- `source/` - локальные UTF-8 копии файлов BSL/XML/MDO. +- `indexes/*.json` - машиночитаемые индексы; сначала используйте compact-варианты. +- `source/` - выборочные UTF-8 копии BSL/MDO и ключевых XML. - `objects/*.md` - карточки объектов. - `modules/*.md` - карточки модулей. - `raw/*.json` - полная сырая модель SFERA. @@ -295,10 +336,21 @@ def _codex_readme_markdown(manifest: dict[str, Any]) -> str: f"- Узлов SIR: {snapshot.get('nodes', 0)}", f"- Связей SIR: {snapshot.get('edges', 0)}", f"- Нормализованных объектов: {normalized.get('objects', 0)}", + f"- Расширений: {normalized.get('extensions', 0)}", "", "Перенесите эту папку целиком в проект Codex, когда хотите, чтобы Codex писал код для этой конфигурации 1С.", - "Пакет включает локальную папку `source/`, поэтому Codex сможет читать BSL/XML/MDO после переноса папки.", + "Для экономии токенов сначала используйте compact-индексы и brief-контекст, а к `source/` и `raw/` переходите только при необходимости.", ] + layout = manifest.get("source_layout") or {} + if layout: + lines.extend( + [ + "", + "## Структура выгрузки", + f"- Основная конфигурация: `{layout.get('main_configuration_root') or 'не определена'}`", + f"- Папок расширений: {len(layout.get('extension_roots') or [])}", + ] + ) if manifest.get("diagnostics"): lines.extend(["", "## Диагностика"]) lines.extend(f"- {item}" for item in manifest["diagnostics"]) @@ -315,18 +367,22 @@ def _codex_start_here_markdown(manifest: dict[str, Any]) -> str: 1. `AGENTS.md` 2. `README.md` -3. `context/project-overview.md` -4. `context/metadata-tree.md` -5. `indexes/objects.json` -6. `indexes/modules.json` -7. `indexes/edges.json` -8. `source/` +3. `context/project-brief.md` +4. `context/project-overview.md` +5. `indexes/project-layout.json` +6. `indexes/objects-compact.json` +7. `indexes/modules-compact.json` +8. `context/metadata-tree.md` +9. `indexes/objects.json` +10. `indexes/modules.json` +11. `indexes/edges.json` +12. `source/` При генерации кода: - Сначала найдите объект 1С-владельца. - Затем изучите контекст его модуля, формы и команды. -- Для точного текста исходника предпочитайте локальные копии в `source/`. +- Для точного текста исходника предпочитайте локальные копии в `source/`, но открывайте их только когда compact-индекса уже недостаточно. - Используйте `raw/normalized_project.json`, когда структура объекта важнее, чем сырой XML. - Используйте `indexes/source-map.json`, если нужно сопоставить ссылки SFERA с локальными путями пакета. """ @@ -340,6 +396,10 @@ def _codex_navigation(manifest: dict[str, Any], source_map: list[dict[str, Any]] "start_here": "context/CODEX_START_HERE.md", "instructions": "AGENTS.md", "overview": "context/project-overview.md", + "brief": "context/project-brief.md", + "project_layout": "indexes/project-layout.json", + "compact_objects_index": "indexes/objects-compact.json", + "compact_modules_index": "indexes/modules-compact.json", "metadata_tree": "context/metadata-tree.md", "objects_index": "indexes/objects.json", "modules_index": "indexes/modules.json", @@ -506,6 +566,7 @@ def _ai_context_markdown(manifest: dict[str, Any], snapshot: SirSnapshot | None, "", "## Как ИИ должен использовать этот пакет", "- Используйте `normalized_project.json` как основную модель объектов 1С.", + "- Для экономии токенов начинайте с `project-brief.md`, `project-layout.json`, `objects-compact.json` и `modules-compact.json`.", "- Используйте `sir_snapshot.json`, `ai_objects.json`, `ai_modules.json` и `ai_edges.json` для навигации по коду и анализа влияния.", "- Рассматривайте модули, формы и команды как части объектов 1С-владельцев, а не как отдельные текстовые файлы.", ] @@ -513,6 +574,111 @@ def _ai_context_markdown(manifest: dict[str, Any], snapshot: SirSnapshot | None, return "\n".join(lines) + "\n" +def _source_layout_summary(root: Path) -> dict[str, Any]: + if root.is_file(): + return {"kind": "file", "main_configuration_root": root.name, "extension_roots": []} + children = [path for path in sorted(root.iterdir()) if path.is_dir()] + config_dir = next((path for path in children if path.name.casefold() in {"configuration", "конфигурация"}), None) + extension_roots = [ + path.name + for path in children + if path != config_dir and any(item.suffix.casefold() in {".xml", ".mdo", ".bsl"} for item in path.rglob("*") if item.is_file()) + ] + kind = "configuration_with_extensions" if config_dir else "flat_or_mixed" + return { + "kind": kind, + "main_configuration_root": config_dir.name if config_dir else root.name, + "extension_roots": extension_roots, + } + + +def _compact_objects(normalized: NormalizedProject | None) -> list[dict[str, Any]]: + if normalized is None: + return [] + items: list[dict[str, Any]] = [] + for group in normalized.configuration.groups: + for obj in group.objects: + items.append(_compact_object_entry(obj, group.name, extension_name=None)) + for extension in normalized.configuration.extensions: + for group in extension.groups: + for obj in group.objects: + items.append(_compact_object_entry(obj, group.name, extension_name=extension.name)) + return items + + +def _compact_object_entry(obj: Any, group_name: str, extension_name: str | None) -> dict[str, Any]: + return { + "qualified_name": obj.qualified_name, + "name": obj.name, + "object_kind": obj.object_kind, + "group": group_name, + "extension": extension_name, + "source_path": obj.source_path, + "forms": len(obj.forms), + "commands": len(obj.commands), + "modules": len(obj.modules), + "attributes": len(obj.attributes), + "tabular_sections": len(obj.tabular_sections), + "layouts": len(obj.layouts), + "rights": len(obj.rights), + } + + +def _compact_modules(normalized: NormalizedProject | None) -> list[dict[str, Any]]: + if normalized is None: + return [] + items: list[dict[str, Any]] = [] + for group in normalized.configuration.groups: + for obj in group.objects: + for module in obj.modules: + items.append(_compact_module_entry(obj, module, extension_name=None)) + for extension in normalized.configuration.extensions: + for group in extension.groups: + for obj in group.objects: + for module in obj.modules: + items.append(_compact_module_entry(obj, module, extension_name=extension.name)) + return items + + +def _compact_module_entry(owner: Any, module: Any, extension_name: str | None) -> dict[str, Any]: + return { + "qualified_name": module.qualified_name or module.name, + "name": module.name, + "module_kind": module.module_kind, + "owner": owner.qualified_name, + "owner_kind": owner.object_kind, + "extension": extension_name, + "source_path": module.source_path, + } + + +def _project_brief_markdown(manifest: dict[str, Any], compact_objects: list[dict[str, Any]], compact_modules: list[dict[str, Any]]) -> str: + layout = manifest.get("source_layout") or {} + top_objects = compact_objects[:40] + top_modules = compact_modules[:30] + lines = [ + f"# Brief: {manifest['project_id']}", + "", + f"- Структура выгрузки: `{layout.get('kind') or 'unknown'}`", + f"- Основная конфигурация: `{layout.get('main_configuration_root') or 'не определена'}`", + f"- Расширения: {', '.join(layout.get('extension_roots') or []) or 'нет'}", + f"- Объектов в compact-индексе: {len(compact_objects)}", + f"- Модулей в compact-индексе: {len(compact_modules)}", + "", + "## Первые объекты", + ] + lines.extend( + f"- `{item['qualified_name']}` [{item['object_kind']}] forms={item['forms']} modules={item['modules']} extension={item.get('extension') or 'main'}" + for item in top_objects + ) + lines.extend(["", "## Первые модули"]) + lines.extend( + f"- `{item['qualified_name']}` owner=`{item['owner']}` extension={item.get('extension') or 'main'}" + for item in top_modules + ) + return "\n".join(lines) + "\n" + + def _export_plan_markdown(project_id: str, input_path: Path, output_path: Path, binaries: list[dict[str, Any]], parseable: bool) -> str: lines = [ f"# План выгрузки 1С для {project_id}", diff --git a/services/api-server/src/api_server/main.py b/services/api-server/src/api_server/main.py index 60ce3ca..b133e10 100644 --- a/services/api-server/src/api_server/main.py +++ b/services/api-server/src/api_server/main.py @@ -1408,6 +1408,7 @@ class AiStructurePrepareResponse(BaseModel): status: str files_count: int = 0 binary_1c_files: list[dict] = Field(default_factory=list) + source_layout: dict = Field(default_factory=dict) artifacts: list[str] = Field(default_factory=list) snapshot: dict | None = None normalized: dict | None = None diff --git a/services/api-server/tests/test_api.py b/services/api-server/tests/test_api.py index 83021a1..2be8624 100644 --- a/services/api-server/tests/test_api.py +++ b/services/api-server/tests/test_api.py @@ -1730,11 +1730,18 @@ def test_ai_structure_prepare_writes_ai_ready_package(tmp_path: Path): assert (output / "manifest.json").exists() assert (output / "normalized_project.json").exists() assert (output / "sir_snapshot.json").exists() + assert (output / "project_layout.json").exists() + assert (output / "compact_objects.json").exists() + assert (output / "compact_modules.json").exists() assert (codex_package / "AGENTS.md").exists() assert (codex_package / "README.md").exists() assert (codex_package / "context" / "CODEX_START_HERE.md").exists() + assert (codex_package / "context" / "project-brief.md").exists() assert (codex_package / "context" / "project-overview.md").exists() assert (codex_package / "indexes" / "codex-navigation.json").exists() + assert (codex_package / "indexes" / "project-layout.json").exists() + assert (codex_package / "indexes" / "objects-compact.json").exists() + assert (codex_package / "indexes" / "modules-compact.json").exists() assert (codex_package / "indexes" / "objects.json").exists() assert (codex_package / "indexes" / "source-map.json").exists() assert (codex_package / "raw" / "normalized_project.json").exists() @@ -1745,9 +1752,13 @@ def test_ai_structure_prepare_writes_ai_ready_package(tmp_path: Path): module_docs = "\n".join(path.read_text(encoding="utf-8") for path in (codex_package / "modules").glob("*.md")) assert "Локальный исходник: `source/Интеграция.bsl`" in module_docs assert "Эта папка сгенерирована SFERA для Codex" in (codex_package / "AGENTS.md").read_text(encoding="utf-8") - assert "локальную папку `source/`" in (codex_package / "AGENTS.md").read_text(encoding="utf-8") - assert "Перенесите эту папку целиком в проект Codex" in (codex_package / "README.md").read_text(encoding="utf-8") + assert "выборочная копия нужных исходников" in (codex_package / "AGENTS.md").read_text(encoding="utf-8") + assert "compact-индексы" in (codex_package / "README.md").read_text(encoding="utf-8") assert "Рассматривайте модули, формы и команды как части объектов 1С-владельцев" in (output / "ai_context.md").read_text(encoding="utf-8") + compact_objects = json.loads((codex_package / "indexes" / "objects-compact.json").read_text(encoding="utf-8")) + assert any(item["qualified_name"] == "Справочник.Контрагенты" for item in compact_objects) + brief = (codex_package / "context" / "project-brief.md").read_text(encoding="utf-8") + assert "Brief: ai-demo" in brief page = client.get("/html5/projects/ai-demo/ai-structure") assert_html5_response_contract( @@ -1788,6 +1799,54 @@ def test_ai_structure_prepare_writes_ai_ready_package(tmp_path: Path): assert_html5_response_contract(html5_smb_without_credentials, "ошибка", "логин и пароль SMB") +def test_ai_structure_prepare_understands_configuration_and_extension_folders(tmp_path: Path): + source = tmp_path / "xml-export" + config = source / "Конфигурация" + extension = source / "CRM" + config.mkdir(parents=True) + extension.mkdir(parents=True) + output = tmp_path / "ai-out-layout" + (config / "metadata.xml").write_text( + """ + + + +""", + encoding="utf-8", + ) + (extension / "РасширениеCRM.mdo").write_text( + """ + + CRM + 1.0 + + КонтрагентыCRM + + +""", + encoding="utf-8", + ) + client = TestClient(app) + + response = client.post( + "/ai-structure/prepare", + json={"project_id": "ai-layout", "input_path": str(source), "output_path": str(output)}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["status"] == "ready" + assert payload["source_layout"]["main_configuration_root"] == "Конфигурация" + assert payload["source_layout"]["extension_roots"] == ["CRM"] + assert payload["normalized"]["extensions"] == 1 + codex_package = output / payload["codex_package_folder"] + layout = json.loads((codex_package / "indexes" / "project-layout.json").read_text(encoding="utf-8")) + assert layout["kind"] == "configuration_with_extensions" + assert layout["main_configuration_root"] == "Конфигурация" + compact_objects = json.loads((codex_package / "indexes" / "objects-compact.json").read_text(encoding="utf-8")) + assert any(item["extension"] == "CRM" for item in compact_objects) + + def test_ai_structure_prepare_reports_cf_cfe_export_required(tmp_path: Path): source = tmp_path / "cf-source" output = tmp_path / "cf-out"