351 lines
16 KiB
Python
351 lines
16 KiB
Python
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
MARKER_RE = re.compile(r"^(\d+)-(\d+)$")
|
|
|
|
|
|
def read_json(path: Path) -> dict[str, Any]:
|
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
|
|
|
|
def as_int(value: Any) -> int | None:
|
|
try:
|
|
return int(value)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def tree_index(value: Any) -> int:
|
|
text = str(value or "")
|
|
if text.startswith("$."):
|
|
text = text[2:]
|
|
first = text.split(".", 1)[0]
|
|
return as_int(first) if as_int(first) is not None else 10**9
|
|
|
|
|
|
def marker_rows_from_payload(payload: dict[str, Any]) -> list[dict[str, Any]]:
|
|
if isinstance(payload.get("rows"), list):
|
|
return [row for row in payload["rows"] if isinstance(row, dict)]
|
|
probe = payload.get("probe") if isinstance(payload.get("probe"), dict) else {}
|
|
rows = probe.get("marker_matrix") if isinstance(probe.get("marker_matrix"), list) else []
|
|
result: list[dict[str, Any]] = []
|
|
for row in rows:
|
|
if not isinstance(row, dict):
|
|
continue
|
|
styles = row.get("styles") if isinstance(row.get("styles"), list) else []
|
|
style = styles[0] if styles and isinstance(styles[0], dict) else {}
|
|
next_record = style.get("next_moxel_record") if isinstance(style.get("next_moxel_record"), dict) else {}
|
|
result.append(
|
|
{
|
|
"text": row.get("text"),
|
|
"expected_row": row.get("expected_row"),
|
|
"expected_col": row.get("expected_col"),
|
|
"decoded_row": row.get("decoded_row"),
|
|
"decoded_col": row.get("decoded_col"),
|
|
"style_tree_position": style.get("tree_position"),
|
|
"next_value": next_record.get("value") or next_record.get("head"),
|
|
"preceding": style.get("last_7_preceding_values") or style.get("immediate_preceding_values"),
|
|
"row_ok": row.get("row_ok"),
|
|
"col_ok": row.get("col_ok"),
|
|
}
|
|
)
|
|
return result
|
|
|
|
|
|
def last_preceding_int(row: dict[str, Any]) -> int | None:
|
|
preceding = row.get("preceding") if isinstance(row.get("preceding"), list) else []
|
|
for value in reversed(preceding):
|
|
parsed = as_int(value)
|
|
if parsed is not None:
|
|
return parsed
|
|
return None
|
|
|
|
|
|
def analyze_marker_matrix(payloads: list[dict[str, Any]]) -> dict[str, Any]:
|
|
rows: list[dict[str, Any]] = []
|
|
for payload in payloads:
|
|
rows.extend(marker_rows_from_payload(payload))
|
|
marker_rows = [row for row in rows if MARKER_RE.match(str(row.get("text") or ""))]
|
|
total = len(marker_rows)
|
|
row_ok = sum(1 for row in marker_rows if row.get("row_ok") is True or row.get("decoded_row") == row.get("expected_row"))
|
|
col_ok = sum(1 for row in marker_rows if row.get("col_ok") is True or row.get("decoded_col") == row.get("expected_col"))
|
|
|
|
last_preceding_predictions = []
|
|
for row in marker_rows:
|
|
expected_col = as_int(row.get("expected_col"))
|
|
last = last_preceding_int(row)
|
|
if expected_col is None or last is None:
|
|
continue
|
|
predicted = last + 1
|
|
last_preceding_predictions.append(
|
|
{
|
|
"text": row.get("text"),
|
|
"style_tree_position": row.get("style_tree_position"),
|
|
"expected_col": expected_col,
|
|
"predicted_col": predicted,
|
|
"ok": predicted == expected_col,
|
|
"preceding_last": last,
|
|
"current_decoded_col": row.get("decoded_col"),
|
|
}
|
|
)
|
|
|
|
prediction_total = len(last_preceding_predictions)
|
|
prediction_ok = sum(1 for item in last_preceding_predictions if item.get("ok") is True)
|
|
rows_by_expected_row: dict[int, list[dict[str, Any]]] = {}
|
|
for row in marker_rows:
|
|
expected_row = as_int(row.get("expected_row"))
|
|
if expected_row is None:
|
|
continue
|
|
rows_by_expected_row.setdefault(expected_row, []).append(row)
|
|
row_runs = []
|
|
for expected_row, run_rows in sorted(rows_by_expected_row.items()):
|
|
sorted_rows = sorted(run_rows, key=lambda item: tree_index(item.get("style_tree_position")))
|
|
expected_cols = [as_int(row.get("expected_col")) for row in sorted_rows]
|
|
predicted_cols = [(last_preceding_int(row) + 1) if last_preceding_int(row) is not None else None for row in sorted_rows]
|
|
row_runs.append(
|
|
{
|
|
"expected_row": expected_row,
|
|
"texts": [row.get("text") for row in sorted_rows],
|
|
"style_positions": [row.get("style_tree_position") for row in sorted_rows],
|
|
"expected_columns": expected_cols,
|
|
"predicted_columns": predicted_cols,
|
|
"sequence_ok": predicted_cols == expected_cols,
|
|
}
|
|
)
|
|
|
|
confidence = "none"
|
|
if prediction_total and prediction_ok == prediction_total and prediction_total >= 3:
|
|
confidence = "high"
|
|
elif prediction_total and prediction_ok / prediction_total >= 0.8:
|
|
confidence = "medium"
|
|
elif prediction_total:
|
|
confidence = "low"
|
|
|
|
return {
|
|
"marker_rows": total,
|
|
"current_decoder": {
|
|
"row_accuracy": {"ok": row_ok, "total": total},
|
|
"column_accuracy": {"ok": col_ok, "total": total},
|
|
},
|
|
"candidate_rules": [
|
|
{
|
|
"id": "inline_text_column_from_last_preceding_scalar_plus_one",
|
|
"target": "moxel.inline_text_cell.column",
|
|
"expression": "one_based_column = int(last_numeric(preceding_scalars)) + 1",
|
|
"confidence": confidence,
|
|
"evidence": {"ok": prediction_ok, "total": prediction_total},
|
|
"samples": last_preceding_predictions[:20],
|
|
}
|
|
],
|
|
"row_runs": row_runs,
|
|
}
|
|
|
|
|
|
def raw_scalar_diffs(before: list[Any], after: list[Any]) -> list[int]:
|
|
return [index for index, pair in enumerate(zip(before, after)) if pair[0] != pair[1]]
|
|
|
|
|
|
def infer_named_range_rules(history_matrix: dict[str, Any], property_candidates: dict[str, Any]) -> dict[str, Any]:
|
|
coordinate_fields: dict[str, set[int]] = {"left": set(), "right": set(), "top": set(), "bottom": set()}
|
|
evidence: list[dict[str, Any]] = []
|
|
transitions: list[dict[str, Any]] = []
|
|
transitions.extend([item for item in history_matrix.get("transitions") or [] if isinstance(item, dict)])
|
|
transitions.extend([item for item in property_candidates.get("classified_transitions") or [] if isinstance(item, dict)])
|
|
|
|
for transition in transitions:
|
|
named = transition.get("named_range_changes") if isinstance(transition.get("named_range_changes"), dict) else {}
|
|
if "named_range_diff" in transition and isinstance(transition["named_range_diff"], dict):
|
|
named = {
|
|
"one_based": {
|
|
"before": transition["named_range_diff"].get("before_coords") or {},
|
|
"after": transition["named_range_diff"].get("after_coords") or {},
|
|
},
|
|
"raw_scalars": {
|
|
"before": transition["named_range_diff"].get("before_raw") or [],
|
|
"after": transition["named_range_diff"].get("after_raw") or [],
|
|
},
|
|
}
|
|
coords = named.get("one_based") if isinstance(named.get("one_based"), dict) else {}
|
|
raw = named.get("raw_scalars") if isinstance(named.get("raw_scalars"), dict) else {}
|
|
before_coords = coords.get("before") if isinstance(coords.get("before"), dict) else {}
|
|
after_coords = coords.get("after") if isinstance(coords.get("after"), dict) else {}
|
|
before_raw = raw.get("before") if isinstance(raw.get("before"), list) else []
|
|
after_raw = raw.get("after") if isinstance(raw.get("after"), list) else []
|
|
changed = raw_scalar_diffs(before_raw, after_raw)
|
|
if not changed:
|
|
continue
|
|
changed_coord_names = [
|
|
name
|
|
for name in ("left", "right", "top", "bottom", "row_start", "row_end", "column_start", "column_end")
|
|
if before_coords.get(name) != after_coords.get(name)
|
|
]
|
|
for name in changed_coord_names:
|
|
if name in {"left", "column_start"}:
|
|
coordinate_fields["left"].update(changed)
|
|
elif name in {"right", "column_end"}:
|
|
coordinate_fields["right"].update(changed)
|
|
elif name in {"top", "row_start"}:
|
|
coordinate_fields["top"].update(changed)
|
|
elif name in {"bottom", "row_end"}:
|
|
coordinate_fields["bottom"].update(changed)
|
|
evidence.append(
|
|
{
|
|
"from_file": transition.get("from_file"),
|
|
"to_file": transition.get("to_file"),
|
|
"changed_raw_indexes": changed,
|
|
"changed_coordinates": changed_coord_names,
|
|
}
|
|
)
|
|
|
|
rules = []
|
|
for target, indexes in coordinate_fields.items():
|
|
if not indexes:
|
|
continue
|
|
rules.append(
|
|
{
|
|
"target": f"moxel.named_range.{target}",
|
|
"raw_scalar_indexes": sorted(indexes),
|
|
"expression": "one_based = int(raw_scalar) + 1",
|
|
"confidence": "high" if len(indexes) == 1 else "medium",
|
|
}
|
|
)
|
|
return {"rules": rules, "evidence": evidence}
|
|
|
|
|
|
def summarize_property_candidates(property_candidates: dict[str, Any]) -> dict[str, Any]:
|
|
classified = [item for item in property_candidates.get("classified_transitions") or [] if isinstance(item, dict)]
|
|
label_counts: dict[str, int] = {}
|
|
for item in classified:
|
|
for label in item.get("labels") or []:
|
|
label_counts[str(label)] = label_counts.get(str(label), 0) + 1
|
|
unmapped = [
|
|
{
|
|
"from_file": item.get("from_file"),
|
|
"to_file": item.get("to_file"),
|
|
"labels": item.get("labels") or [],
|
|
"reason": "No stable property field yet; capture one-property-per-save snapshots on the same cell.",
|
|
}
|
|
for item in classified
|
|
if "uncategorized" in (item.get("labels") or []) or "tracked_signature_unchanged" in (item.get("labels") or [])
|
|
]
|
|
return {
|
|
"label_counts": [{"label": key, "count": label_counts[key]} for key in sorted(label_counts)],
|
|
"unmapped_transitions": unmapped,
|
|
"recommended_next_steps": property_candidates.get("recommended_next_steps") or [],
|
|
}
|
|
|
|
|
|
def build_registry(
|
|
marker_payloads: list[dict[str, Any]],
|
|
history_matrix: dict[str, Any],
|
|
property_candidates: dict[str, Any],
|
|
sources: dict[str, list[str] | str | None],
|
|
) -> dict[str, Any]:
|
|
marker_analysis = analyze_marker_matrix(marker_payloads)
|
|
named_range = infer_named_range_rules(history_matrix, property_candidates)
|
|
properties = summarize_property_candidates(property_candidates)
|
|
rules = []
|
|
rules.extend(marker_analysis.get("candidate_rules") or [])
|
|
rules.extend(named_range.get("rules") or [])
|
|
return {
|
|
"schema": "codex_1c_moxel_schema_discovery.v1",
|
|
"sources": sources,
|
|
"status": "ok",
|
|
"rules": rules,
|
|
"analysis": {
|
|
"inline_text_cells": marker_analysis,
|
|
"named_ranges": named_range,
|
|
"properties": properties,
|
|
},
|
|
"next_automation": [
|
|
"Capture one-property-per-save probes for alignment, font, border, fill, protection, wrapping, merge, row height, and column width.",
|
|
"Promote high-confidence rules into the adapter decoder only after fixture tests and a disposable 1C validation run.",
|
|
"For write support, require a separate round-trip proof for every scalar path before enabling mutation.",
|
|
],
|
|
}
|
|
|
|
|
|
def render_markdown(payload: dict[str, Any]) -> str:
|
|
lines: list[str] = []
|
|
lines.append("# 1C MOXCEL schema discovery")
|
|
lines.append("")
|
|
lines.append(f"- Status: `{payload.get('status')}`")
|
|
lines.append(f"- Rules: `{len(payload.get('rules') or [])}`")
|
|
inline = ((payload.get("analysis") or {}).get("inline_text_cells") or {})
|
|
current = inline.get("current_decoder") or {}
|
|
row_acc = current.get("row_accuracy") or {}
|
|
col_acc = current.get("column_accuracy") or {}
|
|
lines.append(f"- Inline row accuracy: `{row_acc.get('ok')}/{row_acc.get('total')}`")
|
|
lines.append(f"- Inline column accuracy: `{col_acc.get('ok')}/{col_acc.get('total')}`")
|
|
lines.append("")
|
|
lines.append("## Rules")
|
|
lines.append("")
|
|
lines.append("| Rule | Target | Confidence | Evidence |")
|
|
lines.append("| --- | --- | --- | --- |")
|
|
for rule in payload.get("rules") or []:
|
|
evidence = rule.get("evidence") or {}
|
|
evidence_text = f"{evidence.get('ok')}/{evidence.get('total')}" if evidence else ", ".join(map(str, rule.get("raw_scalar_indexes") or []))
|
|
lines.append(f"| `{rule.get('id') or rule.get('target')}` | `{rule.get('target')}` | `{rule.get('confidence')}` | `{evidence_text}` |")
|
|
lines.append("")
|
|
lines.append("## Property Gaps")
|
|
lines.append("")
|
|
properties = ((payload.get("analysis") or {}).get("properties") or {})
|
|
for item in properties.get("unmapped_transitions") or []:
|
|
lines.append(f"- `{item.get('from_file')}` -> `{item.get('to_file')}`: `{', '.join(item.get('labels') or [])}`")
|
|
if not properties.get("unmapped_transitions"):
|
|
lines.append("- none")
|
|
lines.append("")
|
|
lines.append("## Next Automation")
|
|
lines.append("")
|
|
for step in payload.get("next_automation") or []:
|
|
lines.append(f"- {step}")
|
|
lines.append("")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description="Infer 1C MOXCEL decoder/property rules from captured template probes.")
|
|
parser.add_argument("--marker-matrix", action="append", default=[], help="Marker matrix JSON. Repeatable.")
|
|
parser.add_argument("--probe-snapshot", action="append", default=[], help="Probe snapshot JSON from capture_1c_template_probe.py. Repeatable.")
|
|
parser.add_argument("--history-matrix", help="History matrix JSON from analyze_1c_template_history_matrix.py.")
|
|
parser.add_argument("--property-candidates", help="Property candidate JSON from infer_1c_template_property_candidates.py.")
|
|
parser.add_argument("--output-json", default="reports/1c-template-baselines/Primer3_moxel_schema_discovery.json")
|
|
parser.add_argument("--output-markdown", default="reports/1c-template-baselines/Primer3_moxel_schema_discovery.md")
|
|
args = parser.parse_args()
|
|
|
|
marker_paths = [Path(path) for path in args.marker_matrix]
|
|
probe_paths = [Path(path) for path in args.probe_snapshot]
|
|
marker_payloads = [read_json(path) for path in [*marker_paths, *probe_paths]]
|
|
history_matrix = read_json(Path(args.history_matrix)) if args.history_matrix else {}
|
|
property_candidates = read_json(Path(args.property_candidates)) if args.property_candidates else {}
|
|
payload = build_registry(
|
|
marker_payloads,
|
|
history_matrix,
|
|
property_candidates,
|
|
{
|
|
"marker_matrix": [str(path) for path in marker_paths],
|
|
"probe_snapshot": [str(path) for path in probe_paths],
|
|
"history_matrix": args.history_matrix,
|
|
"property_candidates": args.property_candidates,
|
|
},
|
|
)
|
|
|
|
json_path = Path(args.output_json)
|
|
md_path = Path(args.output_markdown)
|
|
json_path.parent.mkdir(parents=True, exist_ok=True)
|
|
md_path.parent.mkdir(parents=True, exist_ok=True)
|
|
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
md_path.write_text(render_markdown(payload), encoding="utf-8")
|
|
print(json.dumps({"status": "ok", "json": str(json_path), "markdown": str(md_path), "rules": len(payload["rules"])}, ensure_ascii=False, indent=2))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|