#!/usr/bin/env python3 """Build a read-only investigation plan for a textual 1C development task.""" 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 build_brief_context # noqa: E402 from resolve_1c_object import canonical_kind, load_json, normalize # noqa: E402 from search_1c_object_context import build_search # noqa: E402 DEFAULT_KIND_HINTS = { "документ": "Document", "документа": "Document", "справочник": "Catalog", "справочника": "Catalog", "регистр": None, "регистрсведений": "InformationRegister", "регистрнакопления": "AccumulationRegister", "отчет": "Report", "обработка": "DataProcessor", "форма": "Form", } 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: str | None) -> str: return normalize(value or "") def top_objects(item: dict[str, Any]) -> list[dict[str, Any]]: return item.get("xml_top_objects") or [] def is_owner_top(top: dict[str, Any]) -> bool: rel = str(top.get("relative_path") or "") if "\\Forms\\" in rel or "\\Templates\\" in rel or "\\Commands\\" in rel: return False return bool(top.get("name") and top.get("xml_kind")) def source_from_top(top: dict[str, Any]) -> str: text = (str(top.get("relative_path") or "") + "\\" + str(top.get("path") or "")).casefold() return "extension" if "\\расширения\\" in text or "\\extensions\\" in text else "base" def index_objects(index: dict[str, Any]) -> list[dict[str, Any]]: result = [] for guid, item in (index.get("objects") or {}).items(): for top in top_objects(item): if not is_owner_top(top): continue kind = canonical_kind(str(top.get("xml_kind") or "")) result.append( { "guid": guid, "kind": kind, "name": top.get("name"), "synonym": top.get("synonym"), "source": source_from_top(top), "relative_path": top.get("relative_path"), "path": top.get("path"), } ) return result def kind_hint(text: str) -> str | None: text_norm = compact_text(text) for word, kind in DEFAULT_KIND_HINTS.items(): if compact_text(word) in text_norm: return kind return None def candidate_score(task_norm: str, obj: dict[str, Any], hinted_kind: str | None) -> tuple[int, list[str]]: score = 0 reasons = [] name_norm = compact_text(obj.get("name")) synonym_norm = compact_text(obj.get("synonym")) if name_norm and name_norm in task_norm: score += 100 reasons.append("name_in_task") if synonym_norm and synonym_norm in task_norm: score += 95 reasons.append("synonym_in_task") if hinted_kind and obj.get("kind") == hinted_kind: score += 55 reasons.append("kind_hint") elif hinted_kind and obj.get("kind") != hinted_kind: score -= 70 reasons.append("kind_mismatch") if obj.get("source") == "base": score += 12 else: score -= 8 return score, reasons def find_object_candidates(index: dict[str, Any], text: str, limit: int) -> list[dict[str, Any]]: task_norm = compact_text(text) hinted_kind = kind_hint(text) by_key: dict[tuple[str | None, str], dict[str, Any]] = {} for obj in index_objects(index): score, reasons = candidate_score(task_norm, obj, hinted_kind) if score <= 0: continue key = (obj.get("kind"), compact_text(obj.get("name"))) candidate = {**obj, "score": score, "reasons": reasons} previous = by_key.get(key) if previous is None: by_key[key] = candidate continue previous_rank = (previous.get("source") == "base", previous.get("score", 0)) candidate_rank = (candidate.get("source") == "base", candidate.get("score", 0)) if candidate_rank > previous_rank: by_key[key] = candidate candidates = list(by_key.values()) candidates.sort(key=lambda row: (-row["score"], row.get("source") != "base", row.get("kind") or "", row.get("name") or "")) return candidates[:limit] def quoted_phrases(text: str) -> list[str]: result = [] for pattern in (r'"([^"]{2,80})"', r"'([^']{2,80})'", r"«([^»]{2,80})»"): result.extend(match.group(1).strip() for match in re.finditer(pattern, text)) return result def identifier_terms(text: str) -> list[str]: raw = re.findall(r"[A-Za-zА-Яа-яЁё_][A-Za-zА-Яа-яЁё0-9_]{3,}", text) stop = { "нужно", "надо", "добавить", "сделать", "проверить", "показать", "изменить", "чтобы", "если", "тогда", "логика", "логику", "кнопка", "кнопку", "форма", "форму", "документе", "документ", "документа", "справочник", "реквизит", "реквизиты", } result = [] for item in raw: if item.casefold() in stop: continue if item not in result: result.append(item) return result def search_terms(text: str, object_candidates: list[dict[str, Any]], limit: int) -> list[str]: terms = quoted_phrases(text) + identifier_terms(text) object_names = {compact_text(row.get("name")) for row in object_candidates} object_synonyms = {compact_text(row.get("synonym")) for row in object_candidates} result = [] for term in terms: norm = compact_text(term) if not norm or norm in object_names or norm in object_synonyms: continue if term not in result: result.append(term) if text not in result: result.append(text) return result[:limit] def short_matches(search_result: dict[str, Any], max_matches: int) -> list[dict[str, Any]]: rows = [] for match in search_result.get("matches") or []: row = { "area": match.get("area"), "name": match.get("name"), "title": match.get("title"), "origin": match.get("origin"), "effective_action": match.get("effective_action"), "form": match.get("form"), "evidence": match.get("evidence"), } rows.append({key: value for key, value in row.items() if value not in (None, [], "")}) if len(rows) >= max_matches: break return rows def object_investigation( index: dict[str, Any], candidate: dict[str, Any], *, view: str, search_texts: list[str], max_matches: int, ) -> dict[str, Any]: kind = candidate.get("kind") name = candidate.get("name") brief = build_brief_context( index, kind=kind, name=name, view=view, extension=None, max_attributes=25, max_modules=20, max_forms=20, ) searches = [] for text in search_texts: search = build_search( index, kind=kind, name=name, query=text, view=view, extension=None, search_code=True, max_form_items=1200, limit=max_matches, ) if search.get("matches"): searches.append( { "text": text, "match_count": len(search.get("matches") or []), "matches": short_matches(search, max_matches=max_matches), } ) return { "candidate": candidate, "brief": { "object": brief.get("object"), "active_extensions": brief.get("active_extensions"), "counts": brief.get("counts"), "attributes_preview": brief.get("attributes", {}).get("items", [])[:10], "forms": brief.get("forms", {}).get("items", []), "modules": brief.get("modules", {}).get("items", []), }, "searches": searches, "recommended_reads": build_recommended_reads(kind, name, brief, searches), } def build_recommended_reads(kind: str, name: str, brief: dict[str, Any], searches: list[dict[str, Any]]) -> list[dict[str, Any]]: reads = [ { "purpose": "full_effective_metadata", "command": f"python scripts/get_1c_object_metadata.py --kind {kind} --name {name} --view effective", } ] seen_forms = set() seen_modules = set() for search in searches: for match in search.get("matches") or []: form = match.get("form") if form and form not in seen_forms: seen_forms.add(form) reads.append( { "purpose": "inspect_form", "form": form, "command": f"python scripts/get_1c_form_context.py --kind {kind} --name {name} --form {form} --view effective", } ) area = match.get("area") module_name = match.get("name") if area in {"module", "module.code"} and module_name and module_name not in seen_modules: seen_modules.add(module_name) reads.append( { "purpose": "inspect_module", "module": module_name, "command": f"python scripts/get_1c_module.py --kind {kind} --name {name} --module {module_name} --view effective", } ) if len(reads) == 1: for module in (brief.get("modules") or {}).get("items", []): module_name = module.get("name") if module_name in {"МодульОбъекта", "МодульМенеджера"}: reads.append( { "purpose": "inspect_likely_module", "module": module_name, "command": f"python scripts/get_1c_module.py --kind {kind} --name {name} --module {module_name} --view effective --max-chars 12000", } ) return reads[:10] def build_plan( index: dict[str, Any], *, text: str, view: str, max_objects: int, max_terms: int, max_matches: int, ) -> dict[str, Any]: candidates = find_object_candidates(index, text, limit=max_objects) terms = search_terms(text, candidates, limit=max_terms) investigations = [ object_investigation(index, candidate, view=view, search_texts=terms, max_matches=max_matches) for candidate in candidates ] return { "schema": "onec_task_context_plan.v1", "view": view, "task": {"text": text}, "object_candidates": candidates, "search_terms": terms, "investigations": investigations, "safety": { "mode": "read_only", "write_status": "blocked_until_write_gates", "write_contract": "docs/1c-write-path-safety.md", }, "counts": { "object_candidates": len(candidates), "search_terms": len(terms), "investigations": len(investigations), }, } def main() -> int: parser = argparse.ArgumentParser(description="Plan read-only 1C task context gathering.") parser.add_argument("--index", type=Path, required=True) parser.add_argument("--text") parser.add_argument("--text-b64") parser.add_argument("--view", choices=["effective", "base"], default="effective") parser.add_argument("--max-objects", type=int, default=5) parser.add_argument("--max-terms", type=int, default=8) parser.add_argument("--max-matches", type=int, default=8) parser.add_argument("--output", type=Path) args = parser.parse_args() text = decode_arg(args.text, args.text_b64) if not text: raise SystemExit("Use --text or --text-b64.") result = build_plan( load_json(args.index), text=text, view=args.view, max_objects=args.max_objects, max_terms=args.max_terms, max_matches=args.max_matches, ) 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"], "view": result["view"]}, ensure_ascii=False)) else: print(output) return 0 if __name__ == "__main__": raise SystemExit(main())