Files
llm/scripts/propose_1c_task_changes.py

335 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Create a read-only 1C change proposal from a task evidence bundle."""
from __future__ import annotations
import argparse
import base64
import json
import re
from pathlib import Path
from typing import Any
def decode_arg(value: str | None, encoded: str | None) -> str | None:
if encoded:
return base64.b64decode(encoded).decode("utf-8")
return value
def load_json(path: Path) -> dict[str, Any]:
return json.loads(path.read_text(encoding="utf-8-sig"))
def norm(value: Any) -> str:
return re.sub(r"[\s._-]+", "", str(value or "")).casefold()
def words(text: str) -> set[str]:
return set(re.findall(r"[A-Za-zА-Яа-яЁё0-9_]{3,}", text.casefold()))
def infer_intents(text: str) -> list[str]:
w = words(text)
intents = []
if w & {"добавить", "создать", "вывести", "показать"}:
intents.append("add_or_expose")
if w & {"изменить", "исправить", "доработать", "поменять"}:
intents.append("modify")
if w & {"проверить", "проверка", "проанализировать"}:
intents.append("inspect_or_verify")
if w & {"кнопка", "кнопку", "команда", "команду"}:
intents.append("form_command")
if w & {"форма", "форму"}:
intents.append("form")
if w & {"реквизит", "реквизиты", "поле", "поля"}:
intents.append("attribute")
if w & {"проведение", "провести", "запись", "записать"}:
intents.append("object_lifecycle")
return intents or ["inspect_or_verify"]
def unique_list(values: list[Any]) -> list[Any]:
result = []
seen = set()
for value in values:
key = json.dumps(value, ensure_ascii=False, sort_keys=True)
if key in seen:
continue
seen.add(key)
result.append(value)
return result
def collect_search_findings(investigation: dict[str, Any]) -> list[dict[str, Any]]:
findings = []
for search in investigation.get("searches") or []:
for match in search.get("matches") or []:
evidence = match.get("evidence") or {}
findings.append(
{
"search_text": search.get("text"),
"area": match.get("area"),
"name": match.get("name"),
"title": match.get("title"),
"form": match.get("form"),
"origin": match.get("origin"),
"effective_action": match.get("effective_action"),
"path": evidence.get("path") or evidence.get("form_xml_path"),
"line": evidence.get("line"),
"id": evidence.get("id"),
"text": evidence.get("text"),
}
)
return unique_list(findings)
def collect_file_targets(investigation: dict[str, Any]) -> list[dict[str, Any]]:
targets = []
for form_context in investigation.get("forms") or []:
for form in form_context.get("forms") or []:
targets.append(
{
"kind": "form_xml",
"name": form.get("name"),
"origin": form.get("origin"),
"path": form.get("form_xml_path"),
"module_path": form.get("module_path"),
"reason": "selected_form_context",
}
)
for overlay in form.get("extension_overlays") or []:
targets.append(
{
"kind": "form_xml",
"name": overlay.get("name"),
"origin": overlay.get("origin"),
"path": overlay.get("form_xml_path"),
"module_path": overlay.get("module_path"),
"reason": "active_extension_form_overlay",
}
)
for module_context in investigation.get("modules") or []:
for module in module_context.get("modules") or []:
targets.append(
{
"kind": "bsl_module",
"name": module.get("name"),
"origin": module.get("origin"),
"path": module.get("path"),
"relative_path": module.get("relative_path"),
"reason": "selected_module_context",
}
)
for snippet in investigation.get("code_snippets") or []:
targets.append(
{
"kind": "bsl_module",
"name": snippet.get("module"),
"origin": snippet.get("origin"),
"path": snippet.get("path"),
"line": snippet.get("focus_line"),
"reason": f"code_search_hit:{snippet.get('search_text')}",
}
)
return unique_list([target for target in targets if target.get("path") or target.get("module_path")])
def extension_names_from_targets(targets: list[dict[str, Any]]) -> list[str]:
names = []
for target in targets:
origin = target.get("origin") or {}
if origin.get("layer") == "extension" and origin.get("extension"):
names.append(origin["extension"])
return sorted(set(names))
def choose_write_strategy(investigation: dict[str, Any], targets: list[dict[str, Any]]) -> dict[str, Any]:
extension_names = extension_names_from_targets(targets)
active_extensions = ((investigation.get("brief") or {}).get("active_extensions") or [])
if extension_names:
preferred = extension_names[0]
reason = "task evidence already points to an active extension overlay"
elif active_extensions:
preferred = active_extensions[0]
reason = "active extensions exist; extension-first write path is required"
else:
preferred = None
reason = "no active extension target found; create a new extension in a disposable validation loop"
return {
"mode": "extension_first_proposal",
"preferred_extension": preferred,
"reason": reason,
"forbidden": [
"direct SQL metadata/data updates",
"direct Config/ConfigSave/ConfigCAS writes",
"automatic production Designer update/apply",
],
}
def split_write_targets(targets: list[dict[str, Any]], preferred_extension: str | None) -> dict[str, list[dict[str, Any]]]:
write_candidates = []
references = []
for target in targets:
origin = target.get("origin") or {}
if origin.get("layer") == "extension" and (not preferred_extension or origin.get("extension") == preferred_extension):
write_candidates.append(target)
else:
references.append(target)
return {
"write_candidates": unique_list(write_candidates),
"read_only_reference_files": unique_list(references),
}
def summarize_existing_state(findings: list[dict[str, Any]], targets: list[dict[str, Any]]) -> dict[str, Any]:
form_commands = [row for row in findings if row.get("area") in {"form.command", "form.item"}]
code_hits = [row for row in findings if row.get("area") == "module.code"]
attributes = [row for row in findings if row.get("area") == "metadata.attribute"]
return {
"found_form_commands_or_items": form_commands,
"found_metadata_attributes": attributes,
"found_code_hits": code_hits[:20],
"target_files": targets,
}
def build_steps(task_text: str, intents: list[str], investigation: dict[str, Any], findings: list[dict[str, Any]], targets: list[dict[str, Any]]) -> list[dict[str, Any]]:
object_info = ((investigation.get("brief") or {}).get("object") or {})
steps = [
{
"order": 1,
"action": "confirm_target_object",
"object": object_info,
"evidence": investigation.get("candidate"),
},
{
"order": 2,
"action": "review_effective_metadata",
"purpose": "ensure extension-added attributes and modified types are included before code generation",
},
]
next_order = 3
if "form_command" in intents or any(row.get("area") in {"form.command", "form.item"} for row in findings):
steps.append(
{
"order": next_order,
"action": "review_form_command",
"purpose": "determine whether the command already exists or must be added",
"evidence": [row for row in findings if row.get("area") in {"form.command", "form.item"}],
}
)
next_order += 1
if any(row.get("area") == "module.code" for row in findings):
steps.append(
{
"order": next_order,
"action": "review_related_bsl",
"purpose": "inspect code snippets before proposing BSL changes",
"evidence": [row for row in findings if row.get("area") == "module.code"][:10],
}
)
next_order += 1
steps.append(
{
"order": next_order,
"action": "draft_extension_patch",
"purpose": "prepare a reviewable patch against extension source files only",
"candidate_targets": targets[:20],
}
)
steps.append(
{
"order": next_order + 1,
"action": "validate_in_disposable_base",
"purpose": "load/update extension, open affected forms, run smoke scenario, and only then ask for approval",
}
)
return steps
def build_checks(intents: list[str]) -> list[dict[str, Any]]:
checks = [
{"name": "syntax_check", "description": "Run 1C/BSL syntax validation for changed modules."},
{"name": "extension_load", "description": "Package/load the extension into a disposable base."},
{"name": "form_open", "description": "Open affected forms and verify command bindings and handlers."},
]
if "form_command" in intents:
checks.append({"name": "command_invocation", "description": "Invoke the affected form command and verify expected behavior."})
if "object_lifecycle" in intents:
checks.append({"name": "write_posting_smoke", "description": "Create/write/post a test document in a disposable base."})
return checks
def proposal_for_investigation(task_text: str, investigation: dict[str, Any]) -> dict[str, Any]:
intents = infer_intents(task_text)
findings = collect_search_findings(investigation)
targets = collect_file_targets(investigation)
strategy = choose_write_strategy(investigation, targets)
target_split = split_write_targets(targets, strategy.get("preferred_extension"))
return {
"object": (investigation.get("brief") or {}).get("object"),
"candidate": investigation.get("candidate"),
"intents": intents,
"write_strategy": strategy,
"existing_state": summarize_existing_state(findings, targets),
"target_policy": target_split,
"implementation_steps": build_steps(task_text, intents, investigation, findings, targets),
"validation_checks": build_checks(intents),
"open_questions": infer_open_questions(task_text, findings),
}
def infer_open_questions(task_text: str, findings: list[dict[str, Any]]) -> list[str]:
questions = []
if "добав" in task_text.casefold() and any(row.get("area") in {"form.command", "form.item"} for row in findings):
questions.append("The requested command/item appears to already exist; confirm whether the task is to modify existing behavior rather than add a duplicate.")
if not findings:
questions.append("No direct matches were found in the evidence bundle; broaden search terms or inspect more objects before generating a patch.")
return questions
def build_proposal(evidence: dict[str, Any]) -> dict[str, Any]:
task_text = ((evidence.get("task") or {}).get("text") or "")
proposals = [proposal_for_investigation(task_text, item) for item in evidence.get("investigations") or []]
return {
"schema": "onec_task_change_proposal.v1",
"task": evidence.get("task"),
"source_evidence_schema": evidence.get("schema"),
"view": evidence.get("view"),
"proposals": proposals,
"safety": {
"mode": "proposal_only",
"write_status": "blocked_until_write_gates",
"write_contract": "docs/1c-write-path-safety.md",
},
"counts": {
"proposals": len(proposals),
"target_files": sum(len((proposal.get("existing_state") or {}).get("target_files") or []) for proposal in proposals),
"write_candidates": sum(len((proposal.get("target_policy") or {}).get("write_candidates") or []) for proposal in proposals),
"open_questions": sum(len(proposal.get("open_questions") or []) for proposal in proposals),
},
}
def main() -> int:
parser = argparse.ArgumentParser(description="Create a read-only 1C change proposal from evidence.")
parser.add_argument("--evidence", type=Path, required=True)
parser.add_argument("--output", type=Path)
args = parser.parse_args()
result = build_proposal(load_json(args.evidence))
output = 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(output, encoding="utf-8")
print(json.dumps({"output": str(args.output), "counts": result["counts"]}, ensure_ascii=False))
else:
print(output)
return 0
if __name__ == "__main__":
raise SystemExit(main())