Files
llm/scripts/route_1c_question.py
T

225 lines
8.3 KiB
Python
Raw 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
"""Route a 1C user question to docs RAG, current-config fact checks, or both."""
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 resolve_1c_fact import resolve_from_route_index, split_fact_path # noqa: E402
from resolve_1c_object import canonical_kind, load_json # noqa: E402
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_INDEX = ROOT / "reports" / "1c-sql" / "upo" / "unified-object-route-index.json"
KIND_WORDS = {
"справочник": "Справочник",
"справочника": "Справочник",
"документ": "Документ",
"документа": "Документ",
"обработка": "Обработка",
"обработки": "Обработка",
"отчет": "Отчет",
"отчета": "Отчет",
"регистрсведений": "РегистрСведений",
"регистр сведений": "РегистрСведений",
"регистрнакопления": "РегистрНакопления",
"регистр накопления": "РегистрНакопления",
}
EXPLICIT_PATH_RE = re.compile(
r"\b(Справочник|Документ|Обработка|Отчет|РегистрСведений|РегистрНакопления|РегистрБухгалтерии|Перечисление|ОбщийМодуль)\."
r"([A-Za-zА-Яа-яЁё_][A-Za-zА-Яа-яЁё0-9_]*)"
r"(?:\.([A-Za-zА-Яа-яЁё_][A-Za-zА-Яа-яЁё0-9_]*))?"
r"(?:\.([A-Za-zА-Яа-яЁё_][A-Za-zА-Яа-яЁё0-9_]*))?"
)
IDENT_RE = r"([A-Za-zА-Яа-яЁё_][A-Za-zА-Яа-яЁё0-9_]{2,})"
def decode_arg(value: str | None, encoded: str | None) -> str | None:
if encoded:
return base64.b64decode(encoded).decode("utf-8")
return value
def unique_fact_paths(paths: list[str]) -> list[str]:
result = []
seen = set()
for path in paths:
key = path.casefold()
if key in seen:
continue
seen.add(key)
result.append(path)
return result
def detect_source_risk(text: str) -> list[dict[str, Any]]:
lowered = text.casefold()
risks = []
if any(marker in lowered for marker in ("пример rag", "metadata.example", "synthetic-example", "в примере")):
risks.append(
{
"code": "example_is_not_current_fact",
"severity": "warning",
"message": "Question mentions an example source; it cannot confirm current-base facts.",
}
)
return risks
def extract_explicit_paths(text: str) -> list[str]:
paths = []
for match in EXPLICIT_PATH_RE.finditer(text):
parts = [part for part in match.groups() if part]
paths.append(".".join(parts))
return paths
def extract_attribute_phrases(text: str) -> list[str]:
paths = []
kind_pattern = "|".join(sorted((re.escape(key) for key in KIND_WORDS), key=len, reverse=True))
for match in re.finditer(rf"\bреквизит\s+{IDENT_RE}\s+у\s+({kind_pattern})\s+{IDENT_RE}", text, flags=re.IGNORECASE):
member = match.group(1)
kind_word = match.group(2).casefold()
object_name = match.group(3)
kind = KIND_WORDS.get(kind_word)
if kind:
paths.append(f"{kind}.{object_name}.{member}")
for match in re.finditer(rf"\bу\s+({kind_pattern})\s+{IDENT_RE}[^.?!\n]{{0,80}}\bреквизит\s+{IDENT_RE}", text, flags=re.IGNORECASE):
kind_word = match.group(1).casefold()
object_name = match.group(2)
member = match.group(3)
kind = KIND_WORDS.get(kind_word)
if kind:
paths.append(f"{kind}.{object_name}.{member}")
return paths
def extract_fact_paths(text: str) -> list[str]:
return unique_fact_paths(extract_explicit_paths(text) + extract_attribute_phrases(text))
def classify(text: str, fact_paths: list[str], risks: list[dict[str, Any]]) -> dict[str, Any]:
lowered = text.casefold()
docs_markers = ("что такое", "как ", "пример", "документац", "событие", "при открытии", "приоткрытии")
current_markers = ("текущ", "в базе", "конфигурац", "есть ли", "реквизит", "форма", "команда", "модуль")
needs_fact = bool(fact_paths) or ("реквизит" in lowered and any(word in lowered for word in KIND_WORDS))
needs_docs = any(marker in lowered for marker in docs_markers)
if risks:
needs_fact = True
if needs_fact and needs_docs:
route = "mixed_docs_and_current_config"
elif needs_fact:
route = "current_config_fact"
elif needs_docs:
route = "docs_rag"
else:
route = "needs_clarification"
return {
"route": route,
"needs_docs_rag": needs_docs,
"needs_current_config": needs_fact,
"current_config_required_before_code": needs_fact,
"safe_rag_scope": "official_1c_docs" if needs_docs else None,
}
def resolve_facts(index_path: Path | None, fact_paths: list[str], *, view: str) -> list[dict[str, Any]]:
if not index_path or not index_path.exists():
return [
{
"path": path,
"status": "not_checked",
"reason": "route index is not available",
}
for path in fact_paths
]
index = load_json(index_path)
results = []
for path in fact_paths:
kind, name, table_section, member = split_fact_path(path)
result = resolve_from_route_index(
index,
index_path=index_path,
kind=kind,
object_name=name,
member=member,
table_section=table_section,
area="any",
view=view,
extension=None,
)
results.append({"path": path, "status": "checked", "result": result})
return results
def route_question(text: str, *, index_path: Path | None, view: str) -> dict[str, Any]:
risks = detect_source_risk(text)
fact_paths = extract_fact_paths(text)
decision = classify(text, fact_paths, risks)
fact_checks = resolve_facts(index_path, fact_paths, view=view) if fact_paths else []
return {
"schema": "onec_question_route.v1",
"question": text,
"decision": decision,
"source_risks": risks,
"fact_paths": fact_paths,
"fact_checks": fact_checks,
"recommended_next": recommended_next(decision, fact_checks),
}
def recommended_next(decision: dict[str, Any], fact_checks: list[dict[str, Any]]) -> list[dict[str, str]]:
steps = []
if decision.get("needs_current_config"):
steps.append({"tool": "fact_resolver", "purpose": "confirm current configuration facts before code"})
if decision.get("needs_docs_rag"):
steps.append({"tool": "docs_rag", "purpose": "retrieve official platform/documentation context"})
if fact_checks:
missing = [row for row in fact_checks if not (((row.get("result") or {}).get("exists")))]
if missing:
steps.append({"tool": "ask_or_adjust_task", "purpose": "do not write code for unresolved facts"})
return steps
def main() -> int:
parser = argparse.ArgumentParser(description="Route a 1C question to docs RAG or current-config adapter tools.")
parser.add_argument("--text")
parser.add_argument("--text-b64")
parser.add_argument("--index", type=Path, default=DEFAULT_INDEX)
parser.add_argument("--view", choices=["effective", "base"], default="effective")
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 = route_question(text, index_path=args.index, view=args.view)
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), "route": result["decision"]["route"], "fact_paths": len(result["fact_paths"])}, ensure_ascii=False))
else:
print(output)
return 0
if __name__ == "__main__":
raise SystemExit(main())