diff --git a/tests/python/test_findings.py b/tests/python/test_findings.py index 569f9d3c..f940eb57 100644 --- a/tests/python/test_findings.py +++ b/tests/python/test_findings.py @@ -235,3 +235,68 @@ def test_the_commit_is_checked_in_the_ledgers_own_repository(paths: tuple[Path, ) run(ledger, "add", "A.", "--commit", own, "--topic=x", "--ola", AGREED) assert [e["commit"] for e in entries(ledger)] == [own] + + +LINE = "Two delays in series slow the response twice over." + + +def asked(paths: tuple[Path, Path], text: str, words: str | None) -> str: + """Store a question in the store beside the search record, answered with `words`.""" + record = {"id": "q7", "kind": "question", "text": text, "default": "keep it", + "choices": [], "item": None, "source": "main", "state": "open", "words": None, + "at": STAMP} # fmt: skip + store = paths[1].with_name("questions.jsonl") + lines = [record] + ([{**record, "state": "answered", "words": words}] if words else []) + store.parent.mkdir(parents=True, exist_ok=True) + store.write_text("".join(json.dumps(r) + "\n" for r in lines)) + return "q7" + + +def by_question(paths: tuple[Path, Path], qid: str, text: str = LINE) -> list[str]: + return run(paths, "add", text, "--commit", HEAD, "--topic=delay", "--question", qid) + + +def test_an_answered_agreeing_question_lets_the_line_in(paths: tuple[Path, Path]) -> None: + qid = asked(paths, f'May the ledger say: "{LINE}"?', "Yes") + by_question(paths, qid) + [entry] = entries(paths) + assert entry["text"] == LINE and "q7" in str(entry["ola"]) and "Yes" in str(entry["ola"]) + + +def test_an_unanswered_question_is_refused(paths: tuple[Path, Path]) -> None: + qid = asked(paths, f'May the ledger say: "{LINE}"?', None) + with pytest.raises(findings.RefusedError, match="not answered"): + by_question(paths, qid) + assert not paths[0].exists() + + +@pytest.mark.parametrize("words", ["Change it", "No", "Yes, but say three delays"]) +def test_an_answer_other_than_agreement_is_refused(paths: tuple[Path, Path], words: str) -> None: + qid = asked(paths, f'May the ledger say: "{LINE}"?', words) + with pytest.raises(findings.RefusedError, match="agree"): + by_question(paths, qid) + assert not paths[0].exists() + + +def test_a_line_the_question_does_not_hold_is_refused(paths: tuple[Path, Path]) -> None: + qid = asked(paths, f'May the ledger say: "{LINE}"?', "Yes") + with pytest.raises(findings.RefusedError, match="does not hold"): + by_question(paths, qid, "Two delays in series slow the response.") + assert not paths[0].exists() + + +def test_an_unknown_question_is_refused(paths: tuple[Path, Path]) -> None: + with pytest.raises(findings.RefusedError, match="no question"): + by_question(paths, "q9") + + +def test_replace_takes_an_answered_question_too(paths: tuple[Path, Path]) -> None: + add(paths, "First result.", "a") + qid = asked(paths, f'May the ledger say: "{LINE}"?', "Agreed.") + run(paths, "replace", "1", LINE, "--question", qid) + assert [e["text"] for e in entries(paths)] == [LINE] + + +def test_ola_and_question_together_are_refused(paths: tuple[Path, Path]) -> None: + with pytest.raises(SystemExit): + run(paths, "add", LINE, "--commit", HEAD, "--topic=x", "--ola", AGREED, "--question", "q7") diff --git a/tools/findings.py b/tools/findings.py index e0fc5ef5..970b01b3 100644 --- a/tools/findings.py +++ b/tools/findings.py @@ -11,8 +11,10 @@ JSON entry per fact; `--commit` names a commit of the repository holding the ledger, which may be private. A missing file is an empty ledger, and git history keeps what `replace` and `drop` remove. -`add` and `replace` need `--ola`: Ola's agreement to the wording, found in this -session's transcript as `brief.py --ola` finds it. Each `search` appends its time and +`add` and `replace` need Ola's agreement to the wording: `--ola`, words found in +this session's transcript as `brief.py --ola` finds them, or `--question `, a +question `questions.py` stored whose text holds the line and whose answer is +one of `AGREE`. Each `search` appends its time and words to `/findings-searches.jsonl`, untracked; `questions.py add` and design or probe briefs (`brief.py`) refuse through `unsearched` until a search ran in the last hour. Standard library only. @@ -37,6 +39,8 @@ #: Chosen: one line of a result, as questions.py caps a question at two. LIMIT = 160 WINDOW = timedelta(hours=1) +#: Chosen: whole answers that agree; anything else, "Yes, but ..." too, is refused. +AGREE = {"yes", "agree", "agreed", "ok", "approve", "approved"} class RefusedError(Exception): @@ -70,14 +74,32 @@ def _line(n: int, e: dict[str, Any]) -> str: return f"{n}. {e['text']} (commit {e['commit'][:8]}, {e['date']}; {', '.join(e['topics'])})" -def _worded(args: argparse.Namespace) -> str: +def _answered(args: argparse.Namespace, store: Path) -> str: + """Ola's agreeing answer to question `args.question`, whose text holds the line.""" + import questions + + qid = f"q{args.question}" if args.question.isdigit() else args.question + record = next((r for r in questions.load(store)[0] if r["id"] == qid), None) + if record is None: + raise RefusedError(f"there is no question {qid}") + if record["state"] != "answered": + raise RefusedError(f"question {qid} is not answered") + if record["words"].strip(" .!").casefold() not in AGREE: + raise RefusedError(f'Ola did not agree to question {qid}: "{record["words"]}"') + if " ".join(args.text.split()) not in " ".join(record["text"].split()): + raise RefusedError(f"question {qid} does not hold this line") + return f'"{record["words"]}" (question {qid}, {record["at"]})' + + +def _worded(args: argparse.Namespace, searches: Path) -> str: """Ola's agreement with the wording, as brief.py quotes it, once the wording is plain.""" import brief # here, not at the top: these import this module import questions try: questions._plain(args.text, set(args.term or ()), False) - quoted = brief._ola([args.ola])[1].strip() + quoted = _answered(args, searches.with_name("questions.jsonl")) if args.question \ + else brief._ola([args.ola])[1].strip() # fmt: skip except (questions.RefusedError, brief.BriefError) as error: raise RefusedError(str(error)) from error if len(args.text) > LIMIT: @@ -91,7 +113,7 @@ def command(args: argparse.Namespace, ledger: Path, searches: Path, now: datetim if args.cmd in ("replace", "drop") and not 0 < args.n <= len(entries): raise RefusedError(f"the ledger has no entry {args.n}") if args.cmd == "add": - ola = _worded(args) + ola = _worded(args, searches) home = next(d for d in ledger.parents if d.is_dir()) # the ledger's own repository shown = subprocess.run(["git", "-C", str(home), "show", "-s", "--format=%H %cs", f"{args.commit}^{{commit}}"], capture_output=True, text=True, @@ -101,7 +123,7 @@ def command(args: argparse.Namespace, ledger: Path, searches: Path, now: datetim entries.append({"text": args.text, "commit": shown[0], "date": shown[1], "topics": args.topic, "ola": ola}) # fmt: skip elif args.cmd == "replace": - entries[args.n - 1].update(text=args.text, ola=_worded(args)) + entries[args.n - 1].update(text=args.text, ola=_worded(args, searches)) elif args.cmd == "drop": del entries[args.n - 1] if args.cmd in ("add", "replace", "drop"): @@ -130,7 +152,9 @@ def parser() -> argparse.ArgumentParser: r.add_argument("n", type=int) for s in (a, r): s.add_argument("text") - s.add_argument("--ola", required=True, help="Ola's words agreeing to the wording") + agreed = s.add_mutually_exclusive_group(required=True) + agreed.add_argument("--ola", help="Ola's words agreeing to the wording") + agreed.add_argument("--question", help="an answered question holding the line, as q7") s.add_argument("--term", action="append", help="a proper name read as an abbreviation") a.add_argument("--commit", required=True, help="the commit that settled it") a.add_argument("--topic", action="append", required=True, help="a word to find it by")