225 lines
8.3 KiB
Python
225 lines
8.3 KiB
Python
#!/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())
|