Gate promotion on lint errors
This commit is contained in:
parent
066dd1d42f
commit
8d81b3ca24
18
README.md
18
README.md
|
|
@ -69,12 +69,30 @@ Lint the import:
|
||||||
groundrecall lint .groundrecall/imports/<import-id>
|
groundrecall lint .groundrecall/imports/<import-id>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Review significant imports:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
groundrecall review-server .groundrecall/imports/<import-id>
|
||||||
|
```
|
||||||
|
|
||||||
Promote the import into a canonical store:
|
Promote the import into a canonical store:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
groundrecall promote .groundrecall/imports/<import-id> .groundrecall/store --reviewer your-name
|
groundrecall promote .groundrecall/imports/<import-id> .groundrecall/store --reviewer your-name
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Promotion refuses imports with lint errors by default. Fix the source material,
|
||||||
|
adapter, or review data first. If you intentionally need to preserve a flawed
|
||||||
|
import for triage or recovery, use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
groundrecall promote .groundrecall/imports/<import-id> .groundrecall/store \
|
||||||
|
--reviewer your-name \
|
||||||
|
--allow-lint-errors
|
||||||
|
```
|
||||||
|
|
||||||
|
Warnings remain visible in the review queue but do not block promotion.
|
||||||
|
|
||||||
Inspect or query the store:
|
Inspect or query the store:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -56,12 +56,26 @@ Inspect the import outputs:
|
||||||
groundrecall lint imports/<import-id>
|
groundrecall lint imports/<import-id>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For anything non-trivial, open the review bundle before promotion:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
groundrecall review-server imports/<import-id>
|
||||||
|
```
|
||||||
|
|
||||||
Promote the imported review artifacts into a canonical store:
|
Promote the imported review artifacts into a canonical store:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
groundrecall promote imports/<import-id> store/
|
groundrecall promote imports/<import-id> store/
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Promotion is gated by lint errors. Warnings are retained for review, but errors
|
||||||
|
must be repaired before promotion unless you explicitly choose to keep the
|
||||||
|
import as triage material:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
groundrecall promote imports/<import-id> store/ --allow-lint-errors
|
||||||
|
```
|
||||||
|
|
||||||
## Query The Canonical Store
|
## Query The Canonical Store
|
||||||
|
|
||||||
Query a concept:
|
Query a concept:
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ from __future__ import annotations
|
||||||
from .inspect import inspect_store, summarize_store
|
from .inspect import inspect_store, summarize_store
|
||||||
from .ingest import ImportResult, build_parser as build_import_parser, main as import_main, run_groundrecall_import
|
from .ingest import ImportResult, build_parser as build_import_parser, main as import_main, run_groundrecall_import
|
||||||
from .models import * # noqa: F403
|
from .models import * # noqa: F403
|
||||||
from .promotion import build_parser as build_promotion_parser, main as promotion_main, promote_import_to_store
|
from .promotion import PromotionGateError, build_parser as build_promotion_parser, main as promotion_main, promote_import_to_store
|
||||||
from .query import (
|
from .query import (
|
||||||
build_parser as build_query_parser,
|
build_parser as build_query_parser,
|
||||||
build_query_bundle_for_concept,
|
build_query_bundle_for_concept,
|
||||||
|
|
@ -22,6 +22,7 @@ __all__ = [
|
||||||
"build_import_parser",
|
"build_import_parser",
|
||||||
"import_main",
|
"import_main",
|
||||||
"promote_import_to_store",
|
"promote_import_to_store",
|
||||||
|
"PromotionGateError",
|
||||||
"build_promotion_parser",
|
"build_promotion_parser",
|
||||||
"promotion_main",
|
"promotion_main",
|
||||||
"query_concept",
|
"query_concept",
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
|
import sys
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
@ -20,6 +21,14 @@ from .review_schema import ReviewSession
|
||||||
from .store import GroundRecallStore
|
from .store import GroundRecallStore
|
||||||
|
|
||||||
|
|
||||||
|
class PromotionGateError(RuntimeError):
|
||||||
|
"""Raised when an import is not eligible for promotion."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, payload: dict[str, Any]) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.payload = payload
|
||||||
|
|
||||||
|
|
||||||
def _read_json(path: Path) -> dict[str, Any]:
|
def _read_json(path: Path) -> dict[str, Any]:
|
||||||
return json.loads(path.read_text(encoding="utf-8"))
|
return json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
@ -76,13 +85,50 @@ def _review_candidate_rationale(item: dict[str, Any]) -> str:
|
||||||
return " | ".join(parts)
|
return " | ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_lint_payload(import_dir: Path) -> dict[str, Any]:
|
||||||
|
lint_path = import_dir / "lint_findings.json"
|
||||||
|
if not lint_path.exists():
|
||||||
|
return {
|
||||||
|
"summary": {"error_count": 0, "warning_count": 0},
|
||||||
|
"findings": [],
|
||||||
|
"missing_lint_file": True,
|
||||||
|
}
|
||||||
|
payload = _read_json(lint_path)
|
||||||
|
payload.setdefault("summary", {})
|
||||||
|
payload.setdefault("findings", [])
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _lint_error_payload(lint_payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
findings = [item for item in lint_payload.get("findings", []) if item.get("severity") == "error"]
|
||||||
|
return {
|
||||||
|
"error_count": int(lint_payload.get("summary", {}).get("error_count", len(findings))),
|
||||||
|
"warning_count": int(lint_payload.get("summary", {}).get("warning_count", 0)),
|
||||||
|
"errors": findings,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _enforce_promotion_gate(import_dir: Path, allow_lint_errors: bool) -> dict[str, Any]:
|
||||||
|
lint_payload = _load_lint_payload(import_dir)
|
||||||
|
gate_payload = _lint_error_payload(lint_payload)
|
||||||
|
if gate_payload["error_count"] > 0 and not allow_lint_errors:
|
||||||
|
raise PromotionGateError(
|
||||||
|
"Import has lint errors; review or repair the import before promotion, "
|
||||||
|
"or pass allow_lint_errors=True / --allow-lint-errors to override.",
|
||||||
|
gate_payload,
|
||||||
|
)
|
||||||
|
return gate_payload
|
||||||
|
|
||||||
|
|
||||||
def promote_import_to_store(
|
def promote_import_to_store(
|
||||||
import_dir: str | Path,
|
import_dir: str | Path,
|
||||||
store_dir: str | Path,
|
store_dir: str | Path,
|
||||||
reviewer: str | None = None,
|
reviewer: str | None = None,
|
||||||
snapshot_id: str | None = None,
|
snapshot_id: str | None = None,
|
||||||
|
allow_lint_errors: bool = False,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
base = Path(import_dir)
|
base = Path(import_dir)
|
||||||
|
gate_payload = _enforce_promotion_gate(base, allow_lint_errors=allow_lint_errors)
|
||||||
manifest = _read_json(base / "manifest.json")
|
manifest = _read_json(base / "manifest.json")
|
||||||
review_session = ReviewSession.model_validate_json((base / "review_session.json").read_text(encoding="utf-8"))
|
review_session = ReviewSession.model_validate_json((base / "review_session.json").read_text(encoding="utf-8"))
|
||||||
queue_payload = _read_json(base / "review_queue.json")
|
queue_payload = _read_json(base / "review_queue.json")
|
||||||
|
|
@ -244,6 +290,9 @@ def promote_import_to_store(
|
||||||
"promoted_concept_count": len(promoted_concept_ids),
|
"promoted_concept_count": len(promoted_concept_ids),
|
||||||
"promoted_claim_count": len(promoted_claim_ids),
|
"promoted_claim_count": len(promoted_claim_ids),
|
||||||
"promoted_relation_count": len(promoted_relation_ids),
|
"promoted_relation_count": len(promoted_relation_ids),
|
||||||
|
"lint_error_count": gate_payload["error_count"],
|
||||||
|
"lint_warning_count": gate_payload["warning_count"],
|
||||||
|
"lint_errors_allowed": allow_lint_errors,
|
||||||
"snapshot_id": built_snapshot.snapshot_id,
|
"snapshot_id": built_snapshot.snapshot_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -254,15 +303,25 @@ def build_parser() -> argparse.ArgumentParser:
|
||||||
parser.add_argument("store_dir")
|
parser.add_argument("store_dir")
|
||||||
parser.add_argument("--reviewer", default=None)
|
parser.add_argument("--reviewer", default=None)
|
||||||
parser.add_argument("--snapshot-id", default=None)
|
parser.add_argument("--snapshot-id", default=None)
|
||||||
|
parser.add_argument(
|
||||||
|
"--allow-lint-errors",
|
||||||
|
action="store_true",
|
||||||
|
help="Promote even when lint_findings.json contains errors. Warnings do not block promotion.",
|
||||||
|
)
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
args = build_parser().parse_args()
|
args = build_parser().parse_args()
|
||||||
payload = promote_import_to_store(
|
try:
|
||||||
import_dir=args.import_dir,
|
payload = promote_import_to_store(
|
||||||
store_dir=args.store_dir,
|
import_dir=args.import_dir,
|
||||||
reviewer=args.reviewer,
|
store_dir=args.store_dir,
|
||||||
snapshot_id=args.snapshot_id,
|
reviewer=args.reviewer,
|
||||||
)
|
snapshot_id=args.snapshot_id,
|
||||||
|
allow_lint_errors=args.allow_lint_errors,
|
||||||
|
)
|
||||||
|
except PromotionGateError as exc:
|
||||||
|
print(json.dumps({"ok": False, "error": str(exc), "gate": exc.payload}, indent=2), file=sys.stderr)
|
||||||
|
raise SystemExit(2) from exc
|
||||||
print(json.dumps(payload, indent=2))
|
print(json.dumps(payload, indent=2))
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ from pathlib import Path
|
||||||
from urllib.parse import parse_qs, urlparse
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
from .citation_support import materialize_citegeist_store
|
from .citation_support import materialize_citegeist_store
|
||||||
from .promotion import promote_import_to_store
|
from .promotion import PromotionGateError, promote_import_to_store
|
||||||
from .review_workspace import GroundRecallReviewWorkspace
|
from .review_workspace import GroundRecallReviewWorkspace
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -177,12 +177,17 @@ class GroundRecallReviewHandler(BaseHTTPRequestHandler):
|
||||||
if not store_dir:
|
if not store_dir:
|
||||||
_json_response(self, 400, {"ok": False, "error": "store_dir is required"})
|
_json_response(self, 400, {"ok": False, "error": "store_dir is required"})
|
||||||
return
|
return
|
||||||
result = promote_import_to_store(
|
try:
|
||||||
import_dir=self.workspace.import_dir,
|
result = promote_import_to_store(
|
||||||
store_dir=store_dir,
|
import_dir=self.workspace.import_dir,
|
||||||
reviewer=payload.get("reviewer"),
|
store_dir=store_dir,
|
||||||
snapshot_id=payload.get("snapshot_id"),
|
reviewer=payload.get("reviewer"),
|
||||||
)
|
snapshot_id=payload.get("snapshot_id"),
|
||||||
|
allow_lint_errors=bool(payload.get("allow_lint_errors")),
|
||||||
|
)
|
||||||
|
except PromotionGateError as exc:
|
||||||
|
_json_response(self, 409, {"ok": False, "error": str(exc), "gate": exc.payload})
|
||||||
|
return
|
||||||
_json_response(self, 200, {"ok": True, "promotion": result})
|
_json_response(self, 200, {"ok": True, "promotion": result})
|
||||||
return
|
return
|
||||||
if parsed.path == "/api/citations/verify":
|
if parsed.path == "/api/citations/verify":
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,10 @@ from __future__ import annotations
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from groundrecall.ingest import run_groundrecall_import
|
from groundrecall.ingest import run_groundrecall_import
|
||||||
from groundrecall.promotion import promote_import_to_store
|
from groundrecall.promotion import PromotionGateError, promote_import_to_store
|
||||||
from groundrecall.store import GroundRecallStore
|
from groundrecall.store import GroundRecallStore
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -121,3 +123,54 @@ def test_groundrecall_promotion_preserves_queue_graph_rationale(tmp_path: Path)
|
||||||
assert "bridge_concept" in bridge_candidate.finding_codes
|
assert "bridge_concept" in bridge_candidate.finding_codes
|
||||||
assert "lane=conflict_resolution" in bridge_candidate.rationale
|
assert "lane=conflict_resolution" in bridge_candidate.rationale
|
||||||
assert "graph=bridge_concept" in bridge_candidate.rationale
|
assert "graph=bridge_concept" in bridge_candidate.rationale
|
||||||
|
|
||||||
|
|
||||||
|
def test_groundrecall_promotion_blocks_lint_errors_by_default(tmp_path: Path) -> None:
|
||||||
|
root = tmp_path / "llmwiki"
|
||||||
|
(root / "wiki").mkdir(parents=True)
|
||||||
|
(root / "wiki" / "bad.md").write_text("# Bad\n\n- A claim with a synthetic lint error.\n", encoding="utf-8")
|
||||||
|
|
||||||
|
result = run_groundrecall_import(root, mode="quick", import_id="lint-gate-test")
|
||||||
|
lint_path = result.out_dir / "lint_findings.json"
|
||||||
|
lint_payload = json.loads(lint_path.read_text(encoding="utf-8"))
|
||||||
|
lint_payload["summary"]["error_count"] = 1
|
||||||
|
lint_payload["findings"].append(
|
||||||
|
{
|
||||||
|
"severity": "error",
|
||||||
|
"code": "claim_missing_observation",
|
||||||
|
"target_id": "clm_synthetic",
|
||||||
|
"message": "Synthetic test error.",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
lint_path.write_text(json.dumps(lint_payload, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
|
with pytest.raises(PromotionGateError) as excinfo:
|
||||||
|
promote_import_to_store(result.out_dir, tmp_path / "store", reviewer="R")
|
||||||
|
|
||||||
|
assert excinfo.value.payload["error_count"] == 1
|
||||||
|
assert excinfo.value.payload["errors"][0]["code"] == "claim_missing_observation"
|
||||||
|
|
||||||
|
|
||||||
|
def test_groundrecall_promotion_can_override_lint_error_gate(tmp_path: Path) -> None:
|
||||||
|
root = tmp_path / "llmwiki"
|
||||||
|
(root / "wiki").mkdir(parents=True)
|
||||||
|
(root / "wiki" / "override.md").write_text("# Override\n\n- A claim with an allowed lint error.\n", encoding="utf-8")
|
||||||
|
|
||||||
|
result = run_groundrecall_import(root, mode="quick", import_id="lint-gate-override-test")
|
||||||
|
lint_path = result.out_dir / "lint_findings.json"
|
||||||
|
lint_payload = json.loads(lint_path.read_text(encoding="utf-8"))
|
||||||
|
lint_payload["summary"]["error_count"] = 1
|
||||||
|
lint_payload["findings"].append(
|
||||||
|
{
|
||||||
|
"severity": "error",
|
||||||
|
"code": "relation_missing_source",
|
||||||
|
"target_id": "rel_synthetic",
|
||||||
|
"message": "Synthetic test error.",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
lint_path.write_text(json.dumps(lint_payload, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
|
payload = promote_import_to_store(result.out_dir, tmp_path / "store", reviewer="R", allow_lint_errors=True)
|
||||||
|
|
||||||
|
assert payload["lint_error_count"] == 1
|
||||||
|
assert payload["lint_errors_allowed"] is True
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue