АДРЕСНЫЙ РЕЖИМ -ADDRESS:Шаг 1 - ЛЛМ ФЕРСТ + feat(address): стабилизация wave1 dynamic resolver контрагентов, follow-up carryover и актуализация docs/tests
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
@dataclass
|
||||
class CheckItem:
|
||||
name: str
|
||||
status: str
|
||||
details: str
|
||||
|
||||
|
||||
def read_json(path: Path) -> dict[str, Any] | list[Any] | None:
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def check_file_exists(path: Path, name: str) -> CheckItem:
|
||||
if path.exists():
|
||||
return CheckItem(name=name, status="PASS", details=str(path))
|
||||
return CheckItem(name=name, status="FAIL", details=f"missing: {path}")
|
||||
|
||||
|
||||
def check_stress_baseline(path: Path) -> CheckItem:
|
||||
payload = read_json(path)
|
||||
if not isinstance(payload, dict):
|
||||
return CheckItem("baseline_stress_102", "FAIL", f"invalid_json: {path}")
|
||||
totals = payload.get("totals")
|
||||
if not isinstance(totals, dict):
|
||||
return CheckItem("baseline_stress_102", "FAIL", "totals_missing")
|
||||
total = int(totals.get("questions_total", -1))
|
||||
strict = int(totals.get("strict_pass_count", -1))
|
||||
route = int(totals.get("route_pass_count", -1))
|
||||
if total == 102 and strict == 102 and route == 102:
|
||||
return CheckItem(
|
||||
"baseline_stress_102",
|
||||
"PASS",
|
||||
f"strict={strict}/{total}, route={route}/{total}",
|
||||
)
|
||||
return CheckItem(
|
||||
"baseline_stress_102",
|
||||
"FAIL",
|
||||
f"expected 102/102, got strict={strict}/{total}, route={route}/{total}",
|
||||
)
|
||||
|
||||
|
||||
def check_followup_baseline(path: Path) -> CheckItem:
|
||||
payload = read_json(path)
|
||||
if not isinstance(payload, dict):
|
||||
return CheckItem("baseline_followup_25", "FAIL", f"invalid_json: {path}")
|
||||
totals = payload.get("totals")
|
||||
if not isinstance(totals, dict):
|
||||
return CheckItem("baseline_followup_25", "FAIL", "totals_missing")
|
||||
total = int(totals.get("questions_total", -1))
|
||||
strict = int(totals.get("strict_pass_count", -1))
|
||||
route = int(totals.get("route_pass_count", -1))
|
||||
if total == 25 and strict == 25 and route == 25:
|
||||
return CheckItem(
|
||||
"baseline_followup_25",
|
||||
"PASS",
|
||||
f"strict={strict}/{total}, route={route}/{total}",
|
||||
)
|
||||
return CheckItem(
|
||||
"baseline_followup_25",
|
||||
"FAIL",
|
||||
f"expected 25/25, got strict={strict}/{total}, route={route}/{total}",
|
||||
)
|
||||
|
||||
|
||||
def check_nightly(path: Path) -> CheckItem:
|
||||
payload = read_json(path)
|
||||
if not isinstance(payload, dict):
|
||||
return CheckItem("nightly_regression_green", "FAIL", f"invalid_json: {path}")
|
||||
overall_ok = bool(payload.get("overall_ok"))
|
||||
packs = payload.get("packs")
|
||||
if not isinstance(packs, list):
|
||||
return CheckItem("nightly_regression_green", "FAIL", "packs_missing")
|
||||
bad = []
|
||||
for pack in packs:
|
||||
if not isinstance(pack, dict):
|
||||
continue
|
||||
name = str(pack.get("pack") or "unknown")
|
||||
if not bool(pack.get("runner_ok")) or not bool(pack.get("validator_ok")) or not bool(pack.get("comparator_ok")):
|
||||
bad.append(name)
|
||||
if overall_ok and not bad:
|
||||
return CheckItem("nightly_regression_green", "PASS", f"overall_ok=true, packs={len(packs)}")
|
||||
return CheckItem("nightly_regression_green", "FAIL", f"overall_ok={overall_ok}, failed_packs={bad}")
|
||||
|
||||
|
||||
def compute_ready(items: list[CheckItem]) -> bool:
|
||||
return all(item.status == "PASS" for item in items)
|
||||
|
||||
|
||||
def write_report(path: Path, items: list[CheckItem], ready: bool) -> None:
|
||||
now = dt.datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
lines: list[str] = []
|
||||
lines.append("# Wave-1 Batch-1 Readiness Report")
|
||||
lines.append("")
|
||||
lines.append(f"- Generated at: `{now}`")
|
||||
lines.append(f"- Decision: **{'READY_FOR_PHASE_A' if ready else 'NOT_READY'}**")
|
||||
lines.append("")
|
||||
lines.append("## Checks")
|
||||
for item in items:
|
||||
mark = "PASS" if item.status == "PASS" else "FAIL"
|
||||
lines.append(f"- `{item.name}`: **{mark}** — {item.details}")
|
||||
lines.append("")
|
||||
lines.append("## Next Action")
|
||||
if ready:
|
||||
lines.append("- Start/continue Phase A for Batch-1 (domain card + acceptance set + implementation backlog).")
|
||||
else:
|
||||
lines.append("- Fix failed items above before coding Batch-1 runtime intents.")
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Check readiness for Step-4 Wave-1 Batch-1.")
|
||||
parser.add_argument(
|
||||
"--out",
|
||||
default=str(
|
||||
REPO_ROOT
|
||||
/ "docs"
|
||||
/ "ADDRESS"
|
||||
/ "address_query"
|
||||
/ "wave1_batch1_readiness_report_2026-04-02.md"
|
||||
),
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
path_global = REPO_ROOT / "docs" / "ADDRESS" / "address_query" / "global_execution_checklist_v1.md"
|
||||
path_step0 = REPO_ROOT / "docs" / "ADDRESS" / "address_query" / "step0_closeout_2026-04-02.md"
|
||||
path_plan = REPO_ROOT / "docs" / "ADDRESS" / "address_query" / "domain_expansion_implementation_plan_v1.md"
|
||||
path_general = REPO_ROOT / "docs" / "ADDRESS" / "address_query" / "general_domain_questions_analysis_plan_v1_2026-04-02.md"
|
||||
path_probe = REPO_ROOT / "docs" / "ADDRESS" / "address_query" / "management_route_probe_report_g1_2026-04-02.md"
|
||||
path_complex = REPO_ROOT / "docs" / "ADDRESS" / "address_query" / "complex_questions_status_and_reuse_map_2026-04-02.md"
|
||||
path_stress = (
|
||||
REPO_ROOT
|
||||
/ "docs"
|
||||
/ "ADDRESS"
|
||||
/ "runs"
|
||||
/ "2026-04-02_Address_Slang_Live_Stress_2026-04-02_12-57-27"
|
||||
/ "run_summary.json"
|
||||
)
|
||||
path_followup = (
|
||||
REPO_ROOT
|
||||
/ "docs"
|
||||
/ "ADDRESS"
|
||||
/ "runs"
|
||||
/ "2026-04-02_Address_Followup_Context_Chains_2026-04-02_19-15-Run5"
|
||||
/ "run_summary.json"
|
||||
)
|
||||
path_nightly = (
|
||||
REPO_ROOT
|
||||
/ "docs"
|
||||
/ "ADDRESS"
|
||||
/ "runs"
|
||||
/ "2026-04-02_Address_Nightly_Regression_2026-04-02_17-35-00"
|
||||
/ "nightly_summary.json"
|
||||
)
|
||||
|
||||
checks = [
|
||||
check_file_exists(path_global, "master_checklist_exists"),
|
||||
check_file_exists(path_step0, "step0_closeout_exists"),
|
||||
check_file_exists(path_plan, "step4_plan_exists"),
|
||||
check_file_exists(path_general, "general_domain_analysis_exists"),
|
||||
check_file_exists(path_probe, "group1_probe_report_exists"),
|
||||
check_file_exists(path_complex, "complex_status_map_exists"),
|
||||
check_stress_baseline(path_stress),
|
||||
check_followup_baseline(path_followup),
|
||||
check_nightly(path_nightly),
|
||||
]
|
||||
|
||||
ready = compute_ready(checks)
|
||||
out_path = Path(args.out)
|
||||
write_report(out_path, checks, ready)
|
||||
print(f"[ok] readiness_report={out_path}")
|
||||
print(f"[ok] decision={'READY_FOR_PHASE_A' if ready else 'NOT_READY'}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,510 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Batch-0 live probe for GENERAL DOMAIN Group-1 (Q1..Q5).
|
||||
|
||||
Runs deterministic 1C queries against MCP execute endpoint and generates
|
||||
a markdown report with route-level verdicts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import json
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib import request
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def decode_mojibake(value: str) -> str:
|
||||
text = str(value or "")
|
||||
if not text:
|
||||
return text
|
||||
candidates = [text]
|
||||
|
||||
try:
|
||||
candidates.append(text.encode("latin1").decode("utf-8"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
candidates.append(text.encode("cp1251").decode("utf-8"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def score(s: str) -> int:
|
||||
cyr = len(re.findall(r"[А-Яа-яЁё]", s))
|
||||
bad = len(re.findall(r"(?:Ð.|Ñ.|Р.|С.)", s))
|
||||
return cyr * 2 - bad
|
||||
|
||||
return max(candidates, key=score)
|
||||
|
||||
|
||||
def parse_number(cell: str) -> int | float | str:
|
||||
source = str(cell).strip()
|
||||
if source == "":
|
||||
return ""
|
||||
# Preserve leading-zero codes like "01", "002", "010" for account dictionaries.
|
||||
if re.fullmatch(r"-?\d+", source) and not re.fullmatch(r"-?0\d+", source):
|
||||
return int(source)
|
||||
if re.fullmatch(r"-?\d+[.,]\d+", source):
|
||||
return float(source.replace(",", "."))
|
||||
return source
|
||||
|
||||
|
||||
def parse_text_table(data_text: str) -> list[dict[str, Any]]:
|
||||
text = decode_mojibake(data_text).replace("\r", "").strip()
|
||||
if not text:
|
||||
return []
|
||||
|
||||
header_match = re.search(r"\{([^}]*)\}:", text)
|
||||
if not header_match:
|
||||
return []
|
||||
|
||||
raw_cols = header_match.group(1)
|
||||
columns = [
|
||||
decode_mojibake(part.strip().strip('"'))
|
||||
for part in raw_cols.split(",")
|
||||
if part.strip()
|
||||
]
|
||||
body = text[header_match.end() :].strip()
|
||||
if not body:
|
||||
return []
|
||||
|
||||
rows: list[dict[str, Any]] = []
|
||||
for line in body.split("\n"):
|
||||
raw = line.strip()
|
||||
if not raw:
|
||||
continue
|
||||
|
||||
parts: list[str] = []
|
||||
token = []
|
||||
in_quotes = False
|
||||
i = 0
|
||||
while i < len(raw):
|
||||
ch = raw[i]
|
||||
if ch == '"':
|
||||
if in_quotes and i + 1 < len(raw) and raw[i + 1] == '"':
|
||||
token.append('"')
|
||||
i += 2
|
||||
continue
|
||||
in_quotes = not in_quotes
|
||||
i += 1
|
||||
continue
|
||||
if ch == "," and not in_quotes:
|
||||
parts.append("".join(token).strip())
|
||||
token = []
|
||||
i += 1
|
||||
continue
|
||||
token.append(ch)
|
||||
i += 1
|
||||
parts.append("".join(token).strip())
|
||||
|
||||
row: dict[str, Any] = {}
|
||||
for idx, col in enumerate(columns):
|
||||
cell = decode_mojibake(parts[idx] if idx < len(parts) else "")
|
||||
row[col] = parse_number(cell)
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
@dataclass
|
||||
class QueryResult:
|
||||
rows: list[dict[str, Any]]
|
||||
error: str | None
|
||||
|
||||
|
||||
def execute_query(endpoint: str, channel: str, query: str, limit: int) -> QueryResult:
|
||||
url = f"{endpoint.rstrip('/')}/api/execute_query?channel={channel}"
|
||||
payload = {"query": query, "limit": limit}
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
req = request.Request(
|
||||
url,
|
||||
data=body,
|
||||
headers={"content-type": "application/json; charset=utf-8"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with request.urlopen(req, timeout=90) as resp:
|
||||
content = resp.read().decode("utf-8", errors="replace")
|
||||
except Exception as exc:
|
||||
return QueryResult(rows=[], error=f"http_error: {exc}")
|
||||
|
||||
try:
|
||||
payload_obj = json.loads(content)
|
||||
except Exception:
|
||||
return QueryResult(rows=[], error=f"invalid_json_response: {content[:300]}")
|
||||
|
||||
if payload_obj.get("success") is not True:
|
||||
return QueryResult(rows=[], error=decode_mojibake(str(payload_obj.get("error") or "unknown_error")))
|
||||
|
||||
data = payload_obj.get("data")
|
||||
if isinstance(data, list):
|
||||
rows = [{decode_mojibake(str(k)): v for k, v in row.items()} for row in data if isinstance(row, dict)]
|
||||
return QueryResult(rows=rows, error=None)
|
||||
if isinstance(data, str):
|
||||
return QueryResult(rows=parse_text_table(data), error=None)
|
||||
if isinstance(data, dict) and isinstance(data.get("rows"), list):
|
||||
rows = [{decode_mojibake(str(k)): v for k, v in row.items()} for row in data["rows"] if isinstance(row, dict)]
|
||||
return QueryResult(rows=rows, error=None)
|
||||
return QueryResult(rows=[], error=None)
|
||||
|
||||
|
||||
def pick(row: dict[str, Any], *keys: str) -> Any:
|
||||
for key in keys:
|
||||
if key in row:
|
||||
return row[key]
|
||||
return None
|
||||
|
||||
|
||||
def as_int(value: Any) -> int:
|
||||
if isinstance(value, bool):
|
||||
return int(value)
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
if isinstance(value, float):
|
||||
return int(value)
|
||||
try:
|
||||
return int(str(value).replace(",", "."))
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def normalize_ym(value: Any) -> str:
|
||||
text = str(value or "").strip()
|
||||
m = re.match(r"^(\d{4}-\d{2})-\d{2}", text)
|
||||
if m:
|
||||
return m.group(1)
|
||||
return text
|
||||
|
||||
|
||||
def normalize_account_code(value: Any) -> str:
|
||||
code = str(value or "").strip().strip('"')
|
||||
return code
|
||||
|
||||
|
||||
def section_from_code(code: str) -> str | None:
|
||||
m = re.match(r"^(\d{2})", code)
|
||||
if not m:
|
||||
return None
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def top_rows(rows: list[dict[str, Any]], key: str, n: int = 10) -> list[dict[str, Any]]:
|
||||
return sorted(rows, key=lambda r: as_int(r.get(key)), reverse=True)[:n]
|
||||
|
||||
|
||||
def build_report(
|
||||
out_path: Path,
|
||||
endpoint: str,
|
||||
channel: str,
|
||||
run_ts: str,
|
||||
q1_minmax: QueryResult,
|
||||
q1_year_ops: QueryResult,
|
||||
q2_year_docs: QueryResult,
|
||||
q3_month_ops: QueryResult,
|
||||
q4_doc_types: QueryResult,
|
||||
q5_dt: QueryResult,
|
||||
q5_kt: QueryResult,
|
||||
q5_chart: QueryResult,
|
||||
) -> None:
|
||||
errors = [
|
||||
("Q1.minmax", q1_minmax.error),
|
||||
("Q1.year_ops", q1_year_ops.error),
|
||||
("Q2.year_docs", q2_year_docs.error),
|
||||
("Q3.month_ops", q3_month_ops.error),
|
||||
("Q4.doc_types", q4_doc_types.error),
|
||||
("Q5.dt_accounts", q5_dt.error),
|
||||
("Q5.kt_accounts", q5_kt.error),
|
||||
("Q5.chart", q5_chart.error),
|
||||
]
|
||||
hard_errors = [(name, err) for name, err in errors if err]
|
||||
|
||||
q1_row = q1_minmax.rows[0] if q1_minmax.rows else {}
|
||||
min_period = pick(q1_row, "МинПериод", "MinPeriod")
|
||||
max_period = pick(q1_row, "МаксПериод", "MaxPeriod")
|
||||
total_ops = as_int(pick(q1_row, "КоличествоОпераций", "Количество", "Count"))
|
||||
|
||||
q1_year_top = []
|
||||
years_present: list[int] = []
|
||||
for row in q1_year_ops.rows:
|
||||
year = as_int(pick(row, "Год", "Year"))
|
||||
count = as_int(pick(row, "КоличествоОпераций", "Количество", "Count"))
|
||||
if year > 0:
|
||||
years_present.append(year)
|
||||
q1_year_top.append((year, count))
|
||||
q1_year_top = sorted(q1_year_top, key=lambda x: x[1], reverse=True)
|
||||
|
||||
q2_top = []
|
||||
for row in q2_year_docs.rows:
|
||||
year = as_int(pick(row, "Год", "Year"))
|
||||
docs = as_int(pick(row, "КоличествоДокументов", "Количество", "Count"))
|
||||
if year > 0:
|
||||
q2_top.append((year, docs))
|
||||
q2_top = sorted(q2_top, key=lambda x: x[1], reverse=True)
|
||||
|
||||
q3_top = []
|
||||
for row in q3_month_ops.rows:
|
||||
ym = normalize_ym(pick(row, "Месяц", "Month"))
|
||||
ops = as_int(pick(row, "КоличествоОпераций", "Количество", "Count"))
|
||||
if ym:
|
||||
q3_top.append((ym, ops))
|
||||
q3_top = sorted(q3_top, key=lambda x: x[1], reverse=True)
|
||||
|
||||
q4_top = []
|
||||
for row in q4_doc_types.rows:
|
||||
doc_type = str(pick(row, "ТипДокумента", "DocumentType") or "").strip()
|
||||
docs = as_int(pick(row, "КоличествоДокументов", "Количество", "Count"))
|
||||
if doc_type:
|
||||
q4_top.append((doc_type, docs))
|
||||
q4_top = sorted(q4_top, key=lambda x: x[1], reverse=True)
|
||||
|
||||
section_totals: dict[str, int] = defaultdict(int)
|
||||
for source in (q5_dt.rows, q5_kt.rows):
|
||||
for row in source:
|
||||
code = normalize_account_code(pick(row, "КодСчета", "Код", "AccountCode"))
|
||||
count = as_int(pick(row, "КоличествоПроводок", "Количество", "Count"))
|
||||
section = section_from_code(code)
|
||||
if section is None:
|
||||
continue
|
||||
section_totals[section] += count
|
||||
|
||||
section_names: dict[str, str] = {}
|
||||
for row in q5_chart.rows:
|
||||
code = normalize_account_code(pick(row, "Код", "Code"))
|
||||
name = str(pick(row, "Наименование", "Name") or "").strip()
|
||||
if re.fullmatch(r"\d{2}", code):
|
||||
section_names[code] = name
|
||||
|
||||
section_rows = sorted(section_totals.items(), key=lambda x: x[1], reverse=True)
|
||||
section_top10 = section_rows[:10]
|
||||
section_bottom10 = list(reversed(section_rows[-10:])) if section_rows else []
|
||||
|
||||
q1_verdict = "PASS" if q1_year_top and min_period and max_period else "PARTIAL"
|
||||
q2_verdict = "PASS" if q2_top else "PARTIAL"
|
||||
q3_verdict = "PASS" if q3_top else "PARTIAL"
|
||||
q4_verdict = "PASS" if q4_top else "PARTIAL"
|
||||
q5_verdict = "PASS" if section_top10 else "PARTIAL"
|
||||
|
||||
lines: list[str] = []
|
||||
lines.append("# Management Route Probe Report — General Domain Group 1 (Q1–Q5)")
|
||||
lines.append("")
|
||||
lines.append(f"- Дата/время запуска: `{run_ts}`")
|
||||
lines.append(f"- Endpoint: `{endpoint}`")
|
||||
lines.append(f"- Channel: `{channel}`")
|
||||
lines.append("- Контур: `question_mode=address_query`, Batch-0 route probes")
|
||||
lines.append("")
|
||||
if hard_errors:
|
||||
lines.append("## Ошибки probe")
|
||||
for name, err in hard_errors:
|
||||
lines.append(f"- `{name}`: {err}")
|
||||
lines.append("")
|
||||
lines.append("## Вердикт по вопросам группы 1")
|
||||
lines.append(f"- Q1 (покрытие периодов): **{q1_verdict}**")
|
||||
lines.append(f"- Q2 (самый активный год по документам): **{q2_verdict}**")
|
||||
lines.append(f"- Q3 (самый активный месяц по операциям): **{q3_verdict}**")
|
||||
lines.append(f"- Q4 (наиболее частые типы документов): **{q4_verdict}**")
|
||||
lines.append(f"- Q5 (наиболее/наименее заполненные разделы учета): **{q5_verdict}**")
|
||||
lines.append("")
|
||||
lines.append("## Q1 — Покрытие базы и активность по годам")
|
||||
lines.append(f"- Мин период: `{min_period}`")
|
||||
lines.append(f"- Макс период: `{max_period}`")
|
||||
lines.append(f"- Всего операций в регистре: `{total_ops}`")
|
||||
if years_present:
|
||||
lines.append(f"- Годы с данными: `{min(years_present)}..{max(years_present)}` (уникальных лет: `{len(set(years_present))}`)")
|
||||
lines.append("- Топ годов по количеству операций:")
|
||||
for year, cnt in q1_year_top[:8]:
|
||||
lines.append(f" - `{year}`: `{cnt}`")
|
||||
lines.append("")
|
||||
lines.append("## Q2 — Самый активный год по количеству документов")
|
||||
lines.append("- Метрика: `COUNT(DISTINCT Регистратор)` по годам на `РегистрБухгалтерии.Хозрасчетный`.")
|
||||
lines.append("- Топ годов:")
|
||||
for year, cnt in q2_top[:8]:
|
||||
lines.append(f" - `{year}`: `{cnt}`")
|
||||
lines.append("- Вывод: route дает корректный ranking по документной активности в контуре движений.")
|
||||
lines.append("")
|
||||
lines.append("## Q3 — Самый активный месяц по количеству операций")
|
||||
lines.append("- Метрика: `COUNT(*)` по `НАЧАЛОПЕРИОДА(Период, МЕСЯЦ)`.")
|
||||
lines.append("- Топ месяцев:")
|
||||
for ym, cnt in q3_top[:12]:
|
||||
lines.append(f" - `{ym}`: `{cnt}`")
|
||||
lines.append("")
|
||||
lines.append("## Q4 — Наиболее частые типы документов")
|
||||
lines.append("- Метрика: `COUNT(DISTINCT Регистратор)` по `ПРЕДСТАВЛЕНИЕ(ТИПЗНАЧЕНИЯ(Регистратор))`.")
|
||||
lines.append("- Топ типов:")
|
||||
for doc_type, cnt in q4_top[:12]:
|
||||
lines.append(f" - `{doc_type}`: `{cnt}`")
|
||||
lines.append("")
|
||||
lines.append("## Q5 — Заполненность разделов учета")
|
||||
lines.append("- Метод: агрегирование по первым двум цифрам кода счета (дебет + кредит).")
|
||||
lines.append("- Топ разделов:")
|
||||
for section, cnt in section_top10:
|
||||
name = section_names.get(section, "(наименование не найдено в плане счетов)")
|
||||
lines.append(f" - `{section}` `{name}`: `{cnt}`")
|
||||
lines.append("- Разделы с минимальной активностью (среди использованных):")
|
||||
for section, cnt in section_bottom10:
|
||||
name = section_names.get(section, "(наименование не найдено в плане счетов)")
|
||||
lines.append(f" - `{section}` `{name}`: `{cnt}`")
|
||||
lines.append("")
|
||||
lines.append("## Что подтверждено для продуктового плана")
|
||||
lines.append("- `R01 period_coverage_profile`: подтвержден (Q1/Q3).")
|
||||
lines.append("- `R02 document_type_usage_profile`: подтвержден (Q2/Q4).")
|
||||
lines.append("- `Q5` закрывается route-контрактом через account-section aggregation; нужна фиксация правила для \"почти не используются\" (порог/квантиль).")
|
||||
lines.append("")
|
||||
lines.append("## Ограничения и требования к точности")
|
||||
lines.append("- Q2/Q4 измеряются по `Регистратор` в движениях; это нужно явно закрепить как `movement-based document activity`.")
|
||||
lines.append("- Для Q5 нельзя опираться только на raw счета: обязателен post-processing `section = account_code[:2]`.")
|
||||
lines.append("- Есть записи с редкими/системными кодами (например off-balance); требуется whitelist/normalization policy для бизнес-отчета.")
|
||||
lines.append("")
|
||||
lines.append("## Следующий шаг Batch-0")
|
||||
lines.append("- Зафиксировать route contracts для `R01` и `R02` в runtime docs.")
|
||||
lines.append("- Добавить acceptance-вопросы Q1..Q5 в domain pack с жесткой проверкой метрик и сортировки.")
|
||||
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Probe Group-1 routes for general management domain.")
|
||||
parser.add_argument("--endpoint", default="http://127.0.0.1:6003")
|
||||
parser.add_argument("--channel", default="default")
|
||||
parser.add_argument(
|
||||
"--out",
|
||||
default=str(
|
||||
REPO_ROOT
|
||||
/ "docs"
|
||||
/ "ADDRESS"
|
||||
/ "address_query"
|
||||
/ "management_route_probe_report_g1_2026-04-02.md"
|
||||
),
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
q1_minmax_query = """
|
||||
ВЫБРАТЬ
|
||||
МИНИМУМ(Движения.Период) КАК МинПериод,
|
||||
МАКСИМУМ(Движения.Период) КАК МаксПериод,
|
||||
КОЛИЧЕСТВО(*) КАК КоличествоОпераций
|
||||
ИЗ
|
||||
РегистрБухгалтерии.Хозрасчетный КАК Движения
|
||||
""".strip()
|
||||
|
||||
q1_year_ops_query = """
|
||||
ВЫБРАТЬ
|
||||
ГОД(Движения.Период) КАК Год,
|
||||
КОЛИЧЕСТВО(*) КАК КоличествоОпераций
|
||||
ИЗ
|
||||
РегистрБухгалтерии.Хозрасчетный КАК Движения
|
||||
СГРУППИРОВАТЬ ПО
|
||||
ГОД(Движения.Период)
|
||||
УПОРЯДОЧИТЬ ПО
|
||||
КоличествоОпераций УБЫВ
|
||||
""".strip()
|
||||
|
||||
q2_year_docs_query = """
|
||||
ВЫБРАТЬ
|
||||
ГОД(Движения.Период) КАК Год,
|
||||
КОЛИЧЕСТВО(РАЗЛИЧНЫЕ Движения.Регистратор) КАК КоличествоДокументов
|
||||
ИЗ
|
||||
РегистрБухгалтерии.Хозрасчетный КАК Движения
|
||||
СГРУППИРОВАТЬ ПО
|
||||
ГОД(Движения.Период)
|
||||
УПОРЯДОЧИТЬ ПО
|
||||
КоличествоДокументов УБЫВ
|
||||
""".strip()
|
||||
|
||||
q3_month_ops_query = """
|
||||
ВЫБРАТЬ
|
||||
НАЧАЛОПЕРИОДА(Движения.Период, МЕСЯЦ) КАК Месяц,
|
||||
КОЛИЧЕСТВО(*) КАК КоличествоОпераций
|
||||
ИЗ
|
||||
РегистрБухгалтерии.Хозрасчетный КАК Движения
|
||||
СГРУППИРОВАТЬ ПО
|
||||
НАЧАЛОПЕРИОДА(Движения.Период, МЕСЯЦ)
|
||||
УПОРЯДОЧИТЬ ПО
|
||||
КоличествоОпераций УБЫВ
|
||||
""".strip()
|
||||
|
||||
q4_doc_types_query = """
|
||||
ВЫБРАТЬ
|
||||
ПРЕДСТАВЛЕНИЕ(ТИПЗНАЧЕНИЯ(Движения.Регистратор)) КАК ТипДокумента,
|
||||
КОЛИЧЕСТВО(РАЗЛИЧНЫЕ Движения.Регистратор) КАК КоличествоДокументов
|
||||
ИЗ
|
||||
РегистрБухгалтерии.Хозрасчетный КАК Движения
|
||||
СГРУППИРОВАТЬ ПО
|
||||
ПРЕДСТАВЛЕНИЕ(ТИПЗНАЧЕНИЯ(Движения.Регистратор))
|
||||
УПОРЯДОЧИТЬ ПО
|
||||
КоличествоДокументов УБЫВ
|
||||
""".strip()
|
||||
|
||||
q5_dt_query = """
|
||||
ВЫБРАТЬ
|
||||
Движения.СчетДт.Код КАК КодСчета,
|
||||
КОЛИЧЕСТВО(*) КАК КоличествоПроводок
|
||||
ИЗ
|
||||
РегистрБухгалтерии.Хозрасчетный КАК Движения
|
||||
СГРУППИРОВАТЬ ПО
|
||||
Движения.СчетДт.Код
|
||||
УПОРЯДОЧИТЬ ПО
|
||||
КоличествоПроводок УБЫВ
|
||||
""".strip()
|
||||
|
||||
q5_kt_query = """
|
||||
ВЫБРАТЬ
|
||||
Движения.СчетКт.Код КАК КодСчета,
|
||||
КОЛИЧЕСТВО(*) КАК КоличествоПроводок
|
||||
ИЗ
|
||||
РегистрБухгалтерии.Хозрасчетный КАК Движения
|
||||
СГРУППИРОВАТЬ ПО
|
||||
Движения.СчетКт.Код
|
||||
УПОРЯДОЧИТЬ ПО
|
||||
КоличествоПроводок УБЫВ
|
||||
""".strip()
|
||||
|
||||
q5_chart_query = """
|
||||
ВЫБРАТЬ
|
||||
Счета.Код КАК Код,
|
||||
Счета.Наименование КАК Наименование
|
||||
ИЗ
|
||||
ПланСчетов.Хозрасчетный КАК Счета
|
||||
УПОРЯДОЧИТЬ ПО
|
||||
Код
|
||||
""".strip()
|
||||
|
||||
q1_minmax = execute_query(args.endpoint, args.channel, q1_minmax_query, limit=10)
|
||||
q1_year_ops = execute_query(args.endpoint, args.channel, q1_year_ops_query, limit=200)
|
||||
q2_year_docs = execute_query(args.endpoint, args.channel, q2_year_docs_query, limit=200)
|
||||
q3_month_ops = execute_query(args.endpoint, args.channel, q3_month_ops_query, limit=240)
|
||||
q4_doc_types = execute_query(args.endpoint, args.channel, q4_doc_types_query, limit=120)
|
||||
q5_dt = execute_query(args.endpoint, args.channel, q5_dt_query, limit=300)
|
||||
q5_kt = execute_query(args.endpoint, args.channel, q5_kt_query, limit=300)
|
||||
q5_chart = execute_query(args.endpoint, args.channel, q5_chart_query, limit=600)
|
||||
|
||||
run_ts = dt.datetime.now(dt.timezone.utc).astimezone().isoformat(timespec="seconds")
|
||||
out_path = Path(args.out)
|
||||
build_report(
|
||||
out_path=out_path,
|
||||
endpoint=args.endpoint,
|
||||
channel=args.channel,
|
||||
run_ts=run_ts,
|
||||
q1_minmax=q1_minmax,
|
||||
q1_year_ops=q1_year_ops,
|
||||
q2_year_docs=q2_year_docs,
|
||||
q3_month_ops=q3_month_ops,
|
||||
q4_doc_types=q4_doc_types,
|
||||
q5_dt=q5_dt,
|
||||
q5_kt=q5_kt,
|
||||
q5_chart=q5_chart,
|
||||
)
|
||||
print(f"[ok] report written: {out_path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -242,6 +242,18 @@ def main() -> None:
|
||||
else:
|
||||
policy_pass = route_pass
|
||||
strict_pass = bool(policy_pass and reply_match)
|
||||
predecompose_contract = debug.get("llm_predecompose_contract")
|
||||
if not isinstance(predecompose_contract, dict):
|
||||
predecompose_contract = {}
|
||||
tool_gate_decision = debug.get("tool_gate_decision") or debug.get("address_tool_gate_decision")
|
||||
tool_gate_reason = debug.get("tool_gate_reason") or debug.get("address_tool_gate_reason")
|
||||
llm_decomposition_reason = debug.get("llm_decomposition_reason")
|
||||
llm_decomposition_attempted = debug.get("llm_decomposition_attempted")
|
||||
llm_canonical_candidate_detected = debug.get("llm_canonical_candidate_detected")
|
||||
if llm_decomposition_attempted is None:
|
||||
llm_decomposition_attempted = debug.get("address_llm_predecompose_attempted")
|
||||
if llm_canonical_candidate_detected is None:
|
||||
llm_canonical_candidate_detected = debug.get("address_llm_canonical_candidate_detected")
|
||||
|
||||
row = {
|
||||
"index": index,
|
||||
@@ -277,9 +289,16 @@ def main() -> None:
|
||||
"rows_matched": debug.get("rows_matched"),
|
||||
"mcp_call_status": debug.get("mcp_call_status"),
|
||||
"limited_reason_category": debug.get("limited_reason_category"),
|
||||
"llm_decomposition_attempted": llm_decomposition_attempted,
|
||||
"llm_decomposition_applied": debug.get("llm_decomposition_applied"),
|
||||
"llm_decomposition_reason": debug.get("llm_decomposition_reason"),
|
||||
"llm_decomposition_reason": llm_decomposition_reason,
|
||||
"llm_canonical_candidate_detected": llm_canonical_candidate_detected,
|
||||
"fallback_rule_hit": debug.get("fallback_rule_hit"),
|
||||
"tool_gate_decision": tool_gate_decision,
|
||||
"tool_gate_reason": tool_gate_reason,
|
||||
"predecompose_contract_intent": predecompose_contract.get("intent"),
|
||||
"predecompose_contract_aggregation_profile": predecompose_contract.get("aggregation_profile"),
|
||||
"predecompose_contract_period_scope": ((predecompose_contract.get("period") or {}) if isinstance(predecompose_contract.get("period"), dict) else {}).get("scope"),
|
||||
"debug_payload": debug,
|
||||
"error_code": body.get("error", {}).get("code") if isinstance(body, dict) and isinstance(body.get("error"), dict) else None,
|
||||
"error_message": body.get("error", {}).get("message") if isinstance(body, dict) and isinstance(body.get("error"), dict) else None,
|
||||
@@ -297,15 +316,57 @@ def main() -> None:
|
||||
mcp_counter = Counter(str(r.get("mcp_call_status")) for r in rows)
|
||||
limited_counter = Counter(str(r.get("limited_reason_category")) for r in rows if r.get("limited_reason_category") is not None)
|
||||
route_health_counter = Counter(str(r.get("route_health")) for r in rows)
|
||||
tool_gate_counter = Counter(str(r.get("tool_gate_decision")) for r in rows if r.get("tool_gate_decision") is not None)
|
||||
tool_gate_reason_counter = Counter(str(r.get("tool_gate_reason")) for r in rows if r.get("tool_gate_reason") is not None)
|
||||
|
||||
semantic_pass_count = sum(1 for r in rows if r.get("semantic_pass"))
|
||||
route_pass_count = sum(1 for r in rows if r.get("route_pass"))
|
||||
strict_pass_count = sum(1 for r in rows if r.get("strict_pass"))
|
||||
factual_count = sum(1 for r in rows if r.get("reply_type") == "factual")
|
||||
ok_200_count = sum(1 for r in rows if r.get("status_code") == 200 and r.get("ok"))
|
||||
llm_decomposition_attempted_count = sum(1 for r in rows if r.get("llm_decomposition_attempted") is True)
|
||||
llm_decomposition_applied_count = sum(1 for r in rows if r.get("llm_decomposition_applied") is True)
|
||||
llm_fallback_count = sum(
|
||||
1 for r in rows if str(r.get("llm_decomposition_reason") or "").startswith("fallback_rule_applied")
|
||||
)
|
||||
tool_gate_blocked_count = sum(1 for r in rows if r.get("tool_gate_decision") == "skip_address_lane")
|
||||
avg_elapsed = round(statistics.mean(elapsed_values), 1) if elapsed_values else 0.0
|
||||
|
||||
predecompose_intent_metrics: dict[str, dict[str, Any]] = {}
|
||||
for row in rows:
|
||||
intent_key = (
|
||||
str(row.get("actual_intent")).strip()
|
||||
if row.get("actual_intent") not in {None, "", "None"}
|
||||
else str(row.get("predecompose_contract_intent") or "unknown").strip()
|
||||
)
|
||||
if not intent_key:
|
||||
intent_key = "unknown"
|
||||
bucket = predecompose_intent_metrics.setdefault(
|
||||
intent_key,
|
||||
{
|
||||
"total": 0,
|
||||
"llm_attempted": 0,
|
||||
"llm_applied": 0,
|
||||
"fallback_used": 0,
|
||||
"tool_gate_blocked": 0,
|
||||
},
|
||||
)
|
||||
bucket["total"] += 1
|
||||
if row.get("llm_decomposition_attempted") is True:
|
||||
bucket["llm_attempted"] += 1
|
||||
if row.get("llm_decomposition_applied") is True:
|
||||
bucket["llm_applied"] += 1
|
||||
if str(row.get("llm_decomposition_reason") or "").startswith("fallback_rule_applied"):
|
||||
bucket["fallback_used"] += 1
|
||||
if row.get("tool_gate_decision") == "skip_address_lane":
|
||||
bucket["tool_gate_blocked"] += 1
|
||||
|
||||
for bucket in predecompose_intent_metrics.values():
|
||||
attempted = int(bucket["llm_attempted"])
|
||||
total = int(bucket["total"])
|
||||
bucket["fallback_rate"] = round((bucket["fallback_used"] / attempted), 4) if attempted > 0 else 0.0
|
||||
bucket["gate_block_rate"] = round((bucket["tool_gate_blocked"] / total), 4) if total > 0 else 0.0
|
||||
|
||||
summary = {
|
||||
"run_id": run_id,
|
||||
"generated_at": datetime.now().isoformat(timespec="seconds"),
|
||||
@@ -328,7 +389,14 @@ def main() -> None:
|
||||
"partial_coverage_count": sum(1 for r in rows if r.get("reply_type") == "partial_coverage"),
|
||||
"clarification_required_count": sum(1 for r in rows if r.get("reply_type") == "clarification_required"),
|
||||
"http_error_count": sum(1 for r in rows if r.get("status_code") != 200),
|
||||
"llm_decomposition_attempted_count": llm_decomposition_attempted_count,
|
||||
"llm_decomposition_applied_count": llm_decomposition_applied_count,
|
||||
"llm_fallback_count": llm_fallback_count,
|
||||
"llm_fallback_rate": round(llm_fallback_count / llm_decomposition_attempted_count, 4)
|
||||
if llm_decomposition_attempted_count > 0
|
||||
else 0.0,
|
||||
"tool_gate_blocked_count": tool_gate_blocked_count,
|
||||
"tool_gate_blocked_rate": round(tool_gate_blocked_count / len(rows), 4) if rows else 0.0,
|
||||
"avg_elapsed_ms": avg_elapsed,
|
||||
},
|
||||
"distributions": {
|
||||
@@ -338,6 +406,21 @@ def main() -> None:
|
||||
"mcp_call_status": dict(mcp_counter),
|
||||
"limited_reason_category": dict(limited_counter),
|
||||
"route_health": dict(route_health_counter),
|
||||
"tool_gate_decision": dict(tool_gate_counter),
|
||||
"tool_gate_reason": dict(tool_gate_reason_counter),
|
||||
},
|
||||
"address_llm_predecompose_metrics": {
|
||||
"overall": {
|
||||
"llm_attempted": llm_decomposition_attempted_count,
|
||||
"llm_applied": llm_decomposition_applied_count,
|
||||
"fallback_used": llm_fallback_count,
|
||||
"fallback_rate": round(llm_fallback_count / llm_decomposition_attempted_count, 4)
|
||||
if llm_decomposition_attempted_count > 0
|
||||
else 0.0,
|
||||
"tool_gate_blocked": tool_gate_blocked_count,
|
||||
"gate_block_rate": round(tool_gate_blocked_count / len(rows), 4) if rows else 0.0,
|
||||
},
|
||||
"by_intent": predecompose_intent_metrics,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -379,7 +462,12 @@ def main() -> None:
|
||||
f"- partial_coverage_count: {summary['totals']['partial_coverage_count']}",
|
||||
f"- clarification_required_count: {summary['totals']['clarification_required_count']}",
|
||||
f"- http_error_count: {summary['totals']['http_error_count']}",
|
||||
f"- llm_decomposition_attempted_count: {summary['totals']['llm_decomposition_attempted_count']}",
|
||||
f"- llm_decomposition_applied_count: {summary['totals']['llm_decomposition_applied_count']}",
|
||||
f"- llm_fallback_count: {summary['totals']['llm_fallback_count']}",
|
||||
f"- llm_fallback_rate: {summary['totals']['llm_fallback_rate']}",
|
||||
f"- tool_gate_blocked_count: {summary['totals']['tool_gate_blocked_count']}",
|
||||
f"- tool_gate_blocked_rate: {summary['totals']['tool_gate_blocked_rate']}",
|
||||
f"- avg_elapsed_ms: {summary['totals']['avg_elapsed_ms']}",
|
||||
"",
|
||||
"## Files",
|
||||
|
||||
Reference in New Issue
Block a user