Optimize XML AI structure package for Codex
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled

This commit is contained in:
2026-05-22 12:25:26 +03:00
parent 5a4e3c6d9d
commit 4c02e2f73a
3 changed files with 244 additions and 18 deletions
@@ -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}",
@@ -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
+61 -2
View File
@@ -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(
"""
<Configuration>
<Catalog name="Контрагенты" qualifiedName="Справочник.Контрагенты" />
</Configuration>
""",
encoding="utf-8",
)
(extension / "РасширениеCRM.mdo").write_text(
"""
<mdclass:ConfigurationExtension xmlns:mdclass="http://g5.1c.ru/v8/dt/metadata/mdclass/extension">
<name>CRM</name>
<version>1.0</version>
<catalogs>
<name>КонтрагентыCRM</name>
</catalogs>
</mdclass:ConfigurationExtension>
""",
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"