Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions docs/reference/review.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,31 @@ factlog reject Acme uses Datadog # pending → superseded (retired, kept for
factlog accept Acme uses FastAPI --dry-run
```

### Selecting reviewed facts by number

`factlog review` assigns stable numbers to the pending triples and prints a
full `sha256:` snapshot digest. After a person has reviewed that exact output,
they can select one or more items without retyping a triple:

```bash
factlog review
# [1] Acme / uses / FastAPI
# [2] Acme / uses / PostgreSQL
# snapshot: sha256:...
factlog accept --number 1 --number 2 --from sha256:...
factlog reject --number 2 --from sha256:... --dry-run
```

`--number` is repeatable and requires the digest printed by `review`. The
digest covers the complete normalized pending queue; if it is missing,
malformed, or stale, the command changes nothing and asks you to review again.
Only the default all-pending `factlog review` prints numbers and a digest;
`review --status ...` is a display filter and is not numeric approval evidence.
Numbers are only available with `--from`, so the existing positional triple
and `-` wildcard syntax remains unchanged and cannot be mixed with numbered
selection. A fresh snapshot proves that the human saw this queue; it is not an
authorization for a model to promote facts without a human decision.

`accept`/`reject` change **only pending rows**; a `confirmed`/`accepted`/
`superseded` match is reported and left untouched (use `factlog eject` to retire
a non-pending fact). Both recompile `accepted.dl`.
Expand Down
24 changes: 24 additions & 0 deletions docs/reference/review.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,30 @@ factlog reject Acme uses Datadog # pending → superseded (retired, kept for
factlog accept Acme uses FastAPI --dry-run
```

### 검토한 사실을 번호로 선택하기

`factlog review` 는 대기 트리플에 안정적인 번호를 붙이고 전체 큐의 `sha256:`
스냅샷 다이제스트를 출력합니다. 사람이 그 출력 자체를 검토한 뒤에는 트리플을
다시 입력하지 않고 하나 이상을 선택할 수 있습니다.

```bash
factlog review
# [1] Acme / uses / FastAPI
# [2] Acme / uses / PostgreSQL
# snapshot: sha256:...
factlog accept --number 1 --number 2 --from sha256:...
factlog reject --number 2 --from sha256:... --dry-run
```

`--number` 는 반복할 수 있으며 `review` 가 출력한 다이제스트가 반드시 필요합니다.
다이제스트는 정규화된 대기 큐 전체를 포함하므로, 없거나 형식이 잘못됐거나 큐가
바뀌었으면 명령은 아무것도 변경하지 않고 다시 검토하라고 안내합니다. 번호 선택은
기본 all-pending `factlog review` 에서만 번호와 다이제스트를 출력합니다. `review --status`
는 표시 필터이므로 번호 승인 근거로 사용할 수 없습니다. 번호 선택은
`--from` 과 함께만 가능하므로 기존 위치 트리플과 `-` 와일드카드 문법은 변하지 않으며
번호와 한 명령에서 섞을 수 없습니다. 새 스냅샷은 사람이 이 큐를 보았다는 근거이지,
모델이 사람의 결정 없이 사실을 승격할 권한은 아닙니다.

`accept`/`reject` 는 **대기(pending) 행만** 변경합니다. `confirmed`/`accepted`/
`superseded` 와 일치하는 항목은 보고만 되고 그대로 유지됩니다(대기 상태가 아닌
사실을 폐기하려면 `factlog eject` 를 사용). 둘 다 `accepted.dl` 을 재컴파일합니다.
Expand Down
127 changes: 119 additions & 8 deletions factlog/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -794,6 +794,40 @@ def _triple_filter(terms: list[str]) -> dict[str, str] | None:
return filt or None


def _review_queue(rows: list[dict[str, str]]) -> tuple[list[tuple[str, str, str]], str]:
"""Return stable pending-fact numbers and a digest of their full snapshot.

Numbers name unique NFC-normalized triples, sorted lexicographically. The
digest additionally covers every pending backing row (including source,
status, confidence and note), so accepting a number cannot silently act on
a queue that changed after it was reviewed.
"""
import hashlib
import json
import unicodedata

from factlog.common import REVIEW_STATUSES

def fld(row: dict[str, str], key: str) -> str:
return unicodedata.normalize("NFC", (row.get(key) or "").strip())

pending_rows = [row for row in rows if (row.get("status") or "").strip() in REVIEW_STATUSES]
triples = sorted({(fld(row, "subject"), fld(row, "relation"), fld(row, "object")) for row in pending_rows})
snapshot_rows = sorted(
(
fld(row, "subject"), fld(row, "relation"), fld(row, "object"),
fld(row, "source"), fld(row, "status"), fld(row, "confidence"), fld(row, "note"),
)
for row in pending_rows
)
payload = json.dumps(
{"domain": "factlog-review-snapshot-v1", "rows": snapshot_rows},
ensure_ascii=False,
separators=(",", ":"),
).encode("utf-8")
return triples, "sha256:" + hashlib.sha256(payload).hexdigest()


def cmd_review(args: argparse.Namespace) -> int:
"""List facts awaiting a human decision (status candidate/needs_review).

Expand Down Expand Up @@ -821,9 +855,12 @@ def nfc(s: str) -> str:
if csv_path.is_file():
with csv_path.open(newline="", encoding="utf-8") as f:
rows = list(csv.DictReader(f))
queue, digest = _review_queue(rows)
pending = [r for r in rows if (r.get("status") or "").strip() in want]
if not pending:
print(f"factlog review (KB: {target}): no pending facts ({'/'.join(sorted(want))})")
if args.status is None:
print(f" snapshot: {digest}")
return 0

def fld(r: dict, k: str) -> str:
Expand All @@ -833,9 +870,12 @@ def fld(r: dict, k: str) -> str:
for r in pending:
groups.setdefault((fld(r, "subject"), fld(r, "relation"), fld(r, "object")), []).append(r)

number = {triple: index for index, triple in enumerate(queue, start=1)}
print(f"factlog review (KB: {target}): {len(groups)} pending fact(s), {len(pending)} row(s)")
for (s, rel, o), grp in groups.items():
print(f" {s} / {rel} / {o}")
for (s, rel, o) in sorted(groups):
grp = groups[(s, rel, o)]
prefix = f"[{number[(s, rel, o)]}] " if args.status is None else ""
print(f" {prefix}{s} / {rel} / {o}")
for r in sorted(grp, key=lambda r: fld(r, "source")):
src = fld(r, "source")
status = (r.get("status") or "").strip()
Expand All @@ -845,6 +885,9 @@ def fld(r: dict, k: str) -> str:
if note:
print(f" note: {note}")
print(" decide with: factlog accept <subject> <relation> <object> (or: factlog reject ...)")
if args.status is None:
print(f" snapshot: {digest}")
print(f" or by reviewed number: factlog accept --number 1 --from {digest}")
return 0


Expand All @@ -869,15 +912,42 @@ def nfc(s: str) -> str:
target = Path(target_str)
if not _require_kb(target, verb):
return 1
if len(args.terms) > 3:
numbers = list(args.numbers or [])
numbered = bool(numbers)
if numbered:
import re

if args.terms:
print(
f"factlog {verb}: do not mix --number with a triple selector",
file=sys.stderr,
)
return 2
if len(set(numbers)) != len(numbers):
print(f"factlog {verb}: duplicate --number value; give each review number once", file=sys.stderr)
return 2
if any(number < 1 for number in numbers):
print(f"factlog {verb}: --number must be a positive review number", file=sys.stderr)
return 2
if args.from_digest is None or not re.fullmatch(r"sha256:[0-9a-f]{64}", args.from_digest):
print(
f"factlog {verb}: numeric selection needs the current review snapshot; no changes made. "
"Run factlog review again.",
file=sys.stderr,
)
return 1
elif args.from_digest is not None:
print(f"factlog {verb}: --from is only valid with one or more --number selectors", file=sys.stderr)
return 2
elif len(args.terms) > 3:
print(
f"factlog {verb}: too many terms — give at most SUBJECT RELATION OBJECT "
"(quote a value that contains spaces)",
file=sys.stderr,
)
return 2
filt = _triple_filter(args.terms)
if filt is None:
filt = None if numbered else _triple_filter(args.terms)
if not numbered and filt is None:
print(
f"factlog {verb}: give at least one of SUBJECT RELATION OBJECT "
"(use '-' to wildcard a position)",
Expand All @@ -897,7 +967,29 @@ def nfc(s: str) -> str:
def fld(r: dict, k: str) -> str:
return nfc((r.get(k) or "").strip())

matched = [r for r in rows if all(fld(r, k) == v for k, v in filt.items())]
selected_numbers: set[int] = set()
selected_triples: set[tuple[str, str, str]] = set()
if numbered:
queue, actual_digest = _review_queue(rows)
selected_numbers = set(numbers)
invalid = sorted(number for number in selected_numbers if number > len(queue))
if invalid:
print(
f"factlog {verb}: review number(s) out of range: {', '.join(map(str, invalid))}; no changes made. "
"Run factlog review again.",
file=sys.stderr,
)
return 2
if args.from_digest != actual_digest:
print(
f"factlog {verb}: review snapshot is stale; no changes made. Run factlog review again.",
file=sys.stderr,
)
return 1
selected_triples = {queue[number - 1] for number in selected_numbers}
matched = [r for r in rows if (fld(r, "subject"), fld(r, "relation"), fld(r, "object")) in selected_triples]
else:
matched = [r for r in rows if all(fld(r, k) == v for k, v in filt.items())]
if not matched:
shown = ", ".join(f"{k}={v}" for k, v in filt.items())
print(f"factlog {verb}: no fact matches ({shown})", file=sys.stderr)
Expand Down Expand Up @@ -929,7 +1021,11 @@ def fld(r: dict, k: str) -> str:
out_fields = [*out_fields, "status"]
changed = 0
for r in rows:
if all(fld(r, k) == v for k, v in filt.items()) and (r.get("status") or "").strip() in REVIEW_STATUSES:
is_selected = (
(fld(r, "subject"), fld(r, "relation"), fld(r, "object")) in selected_triples
if numbered else all(fld(r, k) == v for k, v in filt.items())
)
if is_selected and (r.get("status") or "").strip() in REVIEW_STATUSES:
r["status"] = new_status
changed += 1
_atomic_write_csv(csv_path, rows, out_fields)
Expand Down Expand Up @@ -2761,10 +2857,25 @@ def build_parser() -> argparse.ArgumentParser:
)
_p.add_argument(
"terms",
nargs="+",
nargs="*",
metavar="TERM",
help="SUBJECT [RELATION [OBJECT]] prefix; use '-' to wildcard a position",
)
_p.add_argument(
"--number",
dest="numbers",
action="append",
type=int,
metavar="N",
help="select reviewed pending fact number N (repeatable; requires --from)",
)
_p.add_argument(
"--from",
dest="from_digest",
default=None,
metavar="SNAPSHOT",
help="select reviewed numeric item(s) only if this review snapshot digest still matches",
)
_p.add_argument("--dry-run", action="store_true", help="print the planned changes without modifying anything")
_p.add_argument("--target", default=None, help="KB root (default: the active KB; see `factlog where`)")
_p.set_defaults(func=_func)
Expand Down
9 changes: 8 additions & 1 deletion skills/factlog/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -542,7 +542,14 @@ without hand-editing `candidates.csv`, use the review CLI: `factlog review`
lists the pending queue, `factlog accept <subject> <relation> <object>` sets
matching pending rows to `accepted`, and `factlog reject ...` sets them to
`superseded` (both recompile `accepted.dl`; `-` wildcards a position). To
correct a fact's value, `factlog amend <subject> <relation> <object>
select facts a human has just reviewed without retyping a triple, copy the
`sha256:` snapshot printed by `factlog review` into
`factlog accept --number N --from sha256:...` (repeat `--number` as needed).
The snapshot must still match and is evidence of the human's explicit choice;
it never authorizes the model to promote a fact on its own. Keep using the
triple form for any decision the human has not explicitly made.

To correct a fact's value, `factlog amend <subject> <relation> <object>
--set-object ... [--set-subject/--set-relation/--set-note] [--accept]` rewrites
it durably (updates both `candidates.csv` and the backing `runs/*.json`). These
human decisions are preserved across re-merge.
Expand Down
Loading