From 3d725d7b95925e1acdefd5c4a6e274b9483c8b21 Mon Sep 17 00:00:00 2001 From: Justin Kim Date: Sun, 26 Jul 2026 20:52:30 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=A1=B0=ED=9A=8C=20=EC=98=A4=ED=83=80?= =?UTF-8?q?=EC=97=90=20=EA=B7=BC=EC=A0=91=20=EC=9D=B4=EB=A6=84=EC=9D=84=20?= =?UTF-8?q?=EC=A0=9C=EC=95=88=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- factlog/cli.py | 24 +++++++++++++++++ factlog/common.py | 37 +++++++++++++++++++++++++ skills/factlog/SKILL.md | 8 +++++- tests/test_ask_router.sh | 22 +++++++++++++++ tests/test_provenance.sh | 16 +++++++++++ tests/unit/test_did_you_mean.py | 20 ++++++++++++++ tools/ask_router.py | 48 +++++++++++++++++++++++++++++++++ 7 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 tests/unit/test_did_you_mean.py diff --git a/factlog/cli.py b/factlog/cli.py index 43d86dc7..6e9c5da5 100644 --- a/factlog/cli.py +++ b/factlog/cli.py @@ -1249,6 +1249,9 @@ def cmd_provenance(args: argparse.Namespace) -> int: from pathlib import Path from factlog.common import ( + KbContext, + entity_set, + nearby_vocabulary, normalize_confidence, relation_aliases, source_file_refs, @@ -1321,6 +1324,27 @@ def _matches_extended(r: dict) -> bool: if not matched: shown = ", ".join(f"{k}={v}" for k, v in filt.items()) print(f"factlog provenance: no fact matches ({shown})", file=sys.stderr) + # Keep no-match's stderr/rc=1 contract. The optional notes are derived + # only from accepted engine vocabulary (never candidate/source text). + if (target / "facts" / "accepted.dl").is_file(): + accepted = KbContext.for_root(target).load_accepted_facts() + entities = entity_set(accepted) + relations = {row["relation"] for row in accepted if row["relation"]} | set(aliases) | set(aliases.values()) + for kind, term, vocabulary in ( + ("entity", filt.get("subject"), entities), + ("relation", filt.get("relation"), relations), + ("entity", filt.get("object"), entities), + ): + if term is None: + continue + if any(nfc(value).casefold() == nfc(term).casefold() for value in vocabulary): + continue + suggestions = nearby_vocabulary(term, vocabulary) + if suggestions: + print( + f"note: no accepted {kind} '{term}'. did you mean: {', '.join(suggestions)}?", + file=sys.stderr, + ) return 1 on_disk = source_file_refs(target) # NFC-normalised refs of files that exist diff --git a/factlog/common.py b/factlog/common.py index 677fd6ef..1473b481 100644 --- a/factlog/common.py +++ b/factlog/common.py @@ -1366,6 +1366,43 @@ def allowed_relations(facts: list[dict[str, str]] | None = None) -> set[str]: return {row["relation"] for row in selected if row["relation"]} +def nearby_vocabulary(term: str, vocabulary: set[str], *, limit: int = 3) -> list[str]: + """Return bounded, deterministic spelling suggestions without rewriting *term*. + + Comparison folds NFC and case; returned values retain the trusted vocabulary's + spelling. The small length-relative edit-distance threshold keeps unrelated + names out of a display-only did-you-mean hint. + """ + query = unicodedata.normalize("NFC", term).strip().casefold() + if len(query) < 2 or limit <= 0: + return [] + + def distance(left: str, right: str) -> int: + previous = list(range(len(right) + 1)) + for i, lch in enumerate(left, 1): + current = [i] + for j, rch in enumerate(right, 1): + current.append(min(previous[j] + 1, current[j - 1] + 1, + previous[j - 1] + (lch != rch))) + previous = current + return previous[-1] + + maximum = min(3, max(1, len(query) // 3)) + by_key: dict[str, str] = {} + for value in vocabulary: + shown = unicodedata.normalize("NFC", value).strip() + key = shown.casefold() + if not shown or key == query: + continue # exact/NFC/case-equivalent is never a suggestion + if key not in by_key or shown < by_key[key]: + by_key[key] = shown + scored = [(distance(query, key), shown) for key, shown in by_key.items()] + return [shown for dist, shown in sorted( + (item for item in scored if item[0] <= maximum), + key=lambda item: (item[0], item[1].casefold(), item[1]), + )[:limit]] + + def slugify(value: str) -> str: text = re.sub(r"[^0-9A-Za-z가-힣]+", "-", value.strip().lower()) return text.strip("-") or "item" diff --git a/skills/factlog/SKILL.md b/skills/factlog/SKILL.md index daa2081f..06a96f17 100644 --- a/skills/factlog/SKILL.md +++ b/skills/factlog/SKILL.md @@ -821,7 +821,7 @@ the relation, just not that object). Show it verbatim beneath the verdict block. ### Step 3b — Wiki exploration (UNVERIFIED) ```bash -"${CLAUDE_PLUGIN_ROOT}/tools/factlog_python.sh" "${CLAUDE_PLUGIN_ROOT}/tools/ask_router.py" wiki "" --reason "" --target "$FACTLOG_ROOT" +"${CLAUDE_PLUGIN_ROOT}/tools/factlog_python.sh" "${CLAUDE_PLUGIN_ROOT}/tools/ask_router.py" wiki "" --reason "" --draft "" --target "$FACTLOG_ROOT" ``` Show the `UNVERIFIED — wiki exploration` block verbatim (cited `sources/` / @@ -833,6 +833,12 @@ unverified excerpts cite only source text, never `facts/accepted.dl`. Do NOT present wiki excerpts as confirmed facts. Optionally record the unanswered question for later review (a non-engine-input sink, never `facts/query.dl`): +For a stable entity/relation vocabulary miss, keep the same validated `` +in the `wiki --draft` call. The optional `note: ... did you mean: ...?` line is +an accepted-vocabulary spelling hint only: show it verbatim, do not replace the +user's term, draft a corrected query, or retry automatically. It is absent for +verified negatives, malformed/variable drafts, exact matches, and distant names. + The wiki renderer applies the same explicit row cap to cited excerpts and engine-grounding rows. Its warning is printed before those rows, and `--all` returns every available excerpt and grounding fact for audit. diff --git a/tests/test_ask_router.sh b/tests/test_ask_router.sh index 367c28ad..e9e4829c 100755 --- a/tests/test_ask_router.sh +++ b/tests/test_ask_router.sh @@ -78,6 +78,26 @@ if router render 'relation("Acme API", "uses", V)?' | grep -qF "Acme API, uses, neg="$(router render 'relation("Acme API", "uses", "Postgres")?')" if printf '%s' "$neg" | grep -qF "VERIFIED — engine" && printf '%s' "$neg" | grep -qF "verified negative"; then ok "render verified-negative is engine-marked"; else bad "render verified-negative not engine-marked"; fi +# #273: accepted-vocabulary spelling hints decorate only the stable wiki miss; +# they neither change its route nor rewrite/retry the draft. +entity_directive="$(router render 'relation("Acme AP", "uses", V)?')" +if printf '%s' "$entity_directive" | "$PYTHON" -c "import json,sys; d=json.load(sys.stdin); raise SystemExit(0 if d['route']=='wiki' and d['did_you_mean']==[{'kind':'entity','term':'Acme AP','suggestions':['Acme API']}] else 1)"; then ok "entity typo keeps wiki route and carries deterministic hint"; else bad "entity typo directive/hint wrong: $entity_directive"; fi +entity_wiki="$(router wiki 'What does Acme AP use?' --reason 'entity not accepted' --draft 'relation("Acme AP", "uses", V)?')" +if printf '%s' "$entity_wiki" | grep -qF "note: no accepted entity 'Acme AP'. did you mean: Acme API?"; then ok "wiki answer appends entity did-you-mean without correction"; else bad "wiki entity hint missing"; fi +relation_directive="$(router render 'relation("Acme API", "use", V)?')" +if printf '%s' "$relation_directive" | "$PYTHON" -c "import json,sys; d=json.load(sys.stdin); raise SystemExit(0 if d['did_you_mean']==[{'kind':'relation','term':'use','suggestions':['uses']}] else 1)"; then ok "relation typo gets accepted-relation hint"; else bad "relation typo hint wrong: $relation_directive"; fi +if printf '%s' "$neg" | grep -qF 'did_you_mean\|did you mean'; then bad "verified negative must not get typo hint"; else ok "verified negative stays hint-free"; fi +distant="$(router render 'relation("Completely Distant", "uses", V)?')" +if printf '%s' "$distant" | "$PYTHON" -c "import json,sys; raise SystemExit(0 if not json.load(sys.stdin)['did_you_mean'] else 1)"; then ok "distant entity stays hint-free"; else bad "distant entity produced false-positive hint"; fi +case_only="$(router render 'relation("acme api", "uses", V)?')" +if printf '%s' "$case_only" | "$PYTHON" -c "import json,sys; raise SystemExit(0 if not json.load(sys.stdin)['did_you_mean'] else 1)"; then ok "case-only entity variant stays hint-free"; else bad "case-only entity variant produced hint"; fi +LKB="$(mktemp -d)/wiki" +"$PYTHON" -m factlog init --target "$LKB" >/dev/null +printf 'relation("Acme API", "year", "2024").\n' > "$LKB/facts/accepted.dl" +printf '%s\n' '- year' > "$LKB/policy/attribute-relations.md" +literal_miss="$("$PYTHON" "$ROUTER" render 'relation("Acme API", "year", "202")?' --target "$LKB")" +if printf '%s' "$literal_miss" | "$PYTHON" -c "import json,sys; raise SystemExit(0 if not json.load(sys.stdin)['did_you_mean'] else 1)"; then ok "attribute literal never becomes an entity spelling hint"; else bad "attribute literal leaked into entity hint"; fi + # --- path routing & verified-negative (renderable for any predicate) --- check_field "reachable path routes engine" validate 'path("Acme API", "FastAPI")?' route engine check_field "unreachable path = verified negative (engine)" validate 'path("Postgres", "FastAPI")?' route engine @@ -507,6 +527,8 @@ check_field_router() { # like check_field but uses arouter check_field_router "#227: canonical name routes engine (not wiki)" validate 'relation("논문A", "published_year", X)?' route engine check_field_router "#227: canonical name code=ok (positive, not fact_absent)" validate 'relation(S, "published_year", O)?' code ok check_field_router "#227: canonical query not flagged negative" validate 'relation(S, "published_year", O)?' negative False +alias_typo="$(arouter render 'relation("논문A", "publshed_year", O)?')" +if printf '%s' "$alias_typo" | "$PYTHON" -c "import json,sys; d=json.load(sys.stdin); raise SystemExit(0 if d['did_you_mean']==[{'kind':'relation','term':'publshed_year','suggestions':['published_year']}] else 1)"; then ok "#273: typo suggests declared canonical relation alias"; else bad "#273 alias suggestion missing/wrong: $alias_typo"; fi # 2. evaluate: canonical query returns BOTH surface-variant rows aeval_count="$(arouter evaluate 'relation(S, "published_year", O)?' | "$PYTHON" -c "import json,sys; print(json.load(sys.stdin)['count'])")" diff --git a/tests/test_provenance.sh b/tests/test_provenance.sh index 39f09959..5b244bde 100644 --- a/tests/test_provenance.sh +++ b/tests/test_provenance.sh @@ -38,6 +38,7 @@ printf '%s\n%s\n%s\n%s\n%s\n' "$H" \ 'X,rel,Y,sources/b.md,confirmed,0.95,from doc b' \ 'X,attr,2030,sources/a.md,superseded,0.80,retired value' \ 'G,rel,H,sources/gone.md,confirmed,0.70,cites a missing file' > "$KB/facts/candidates.csv" +printf 'relation("X", "rel", "Y").\nrelation("Acme API", "uses", "FastAPI").\n' > "$KB/facts/accepted.dl" # --- exact triple: all backing rows with path/status/conf/note ---------------- out="$("$PYTHON" -m factlog provenance X rel Y --target "$KB" 2>&1)" @@ -74,6 +75,21 @@ printf '%s' "$out" | grep -qF "1 stale row(s)" && ok "stale row counted in summa set +e; "$PYTHON" -m factlog provenance nope nope nope --target "$KB" >/dev/null 2>&1; rc=$?; set -e [ "$rc" -eq 1 ] && ok "no match exits rc 1" || bad "no-match rc wrong ($rc)" +# #273: preserve the existing stderr/rc=1 contract and add only accepted- +# vocabulary hints. Candidate rows must never become spelling candidates. +set +e; typo_out="$("$PYTHON" -m factlog provenance 'Acme AP' --target "$KB" 2>&1)"; rc=$?; set -e +[ "$rc" -eq 1 ] && printf '%s' "$typo_out" | grep -qF "factlog provenance: no fact matches" \ + && ok "typo keeps provenance no-match stderr/rc contract" || bad "typo changed provenance no-match contract" +printf '%s' "$typo_out" | grep -qF "note: no accepted entity 'Acme AP'. did you mean: Acme API?" \ + && ok "zero-result provenance suggests close accepted entity" || bad "provenance entity typo hint missing: $typo_out" +set +e; rel_typo="$("$PYTHON" -m factlog provenance X rell --target "$KB" 2>&1)"; rc=$?; set -e +[ "$rc" -eq 1 ] && printf '%s' "$rel_typo" | grep -qF "did you mean: rel?" \ + && ok "zero-result provenance suggests close accepted relation" || bad "provenance relation typo hint missing: $rel_typo" +printf '%s\n' 'Ghost Typo,unaccepted_relation,Elsewhere,sources/a.md,candidate,0.4,draft only' >> "$KB/facts/candidates.csv" +set +e; candidate_only="$("$PYTHON" -m factlog provenance 'Ghost Typ' --target "$KB" 2>&1)"; rc=$?; set -e +[ "$rc" -eq 1 ] && ! printf '%s' "$candidate_only" | grep -qF 'Ghost Typo' \ + && ok "candidate-only vocabulary never leaks into provenance hint" || bad "candidate-only name leaked into provenance hint: $candidate_only" + # --- all-wildcard (no constraint) -> rc 2 ------------------------------------ set +e; "$PYTHON" -m factlog provenance - - - --target "$KB" >/dev/null 2>&1; rc=$?; set -e [ "$rc" -eq 2 ] && ok "all-wildcard query errors rc 2" || bad "all-wildcard rc wrong ($rc)" diff --git a/tests/unit/test_did_you_mean.py b/tests/unit/test_did_you_mean.py new file mode 100644 index 00000000..5b13548a --- /dev/null +++ b/tests/unit/test_did_you_mean.py @@ -0,0 +1,20 @@ +"""Deterministic, trusted-vocabulary spelling hints (#273).""" + +from factlog.common import nearby_vocabulary + + +def test_nearby_vocabulary_is_nfc_case_exact_safe_sorted_and_capped(): + vocabulary = {"CATS", "catz", "cata", "catb", "catc", "가기업"} + + # Exact values, including case/NFC-equivalent spellings, are excluded from + # the candidate list even if a caller asks for nearby alternatives. + assert "CATS" not in nearby_vocabulary("cats", vocabulary) + assert nearby_vocabulary("가기업", vocabulary) == [] + # Distance ties have a stable lexical order and the public cap is three. + assert nearby_vocabulary("cat", vocabulary) == ["cata", "catb", "catc"] + + +def test_nearby_vocabulary_rejects_distant_and_one_character_terms(): + vocabulary = {"Acme API", "Postgres"} + assert nearby_vocabulary("Completely Distant", vocabulary) == [] + assert nearby_vocabulary("A", vocabulary) == [] diff --git a/tools/ask_router.py b/tools/ask_router.py index 32324bf9..062f81d8 100644 --- a/tools/ask_router.py +++ b/tools/ask_router.py @@ -65,8 +65,10 @@ ACCEPTED_DL, CANDIDATES_CSV, LOGIC_POLICY_DL, + QUERY_ENTITY_NOT_ACCEPTED, QUERY_FACT_ABSENT, QUERY_OK, + QUERY_RELATION_NOT_ACCEPTED, FactlogError, arg_value, canonical_value, @@ -83,6 +85,7 @@ load_facts, load_logic_policy, logic_policy_md_has_rules, + nearby_vocabulary, policy_predicates, relation_aliases, run_wirelog, @@ -361,6 +364,41 @@ def coverage_hint( ) +def did_you_mean_hints(draft: str, facts: list[dict[str, str]]) -> list[dict[str, object]]: + """Return display-only hints for validator-confirmed vocabulary misses (#273). + + Only concrete relation arguments on stable entity/relation-not-accepted + routes qualify. This deliberately excludes verified negatives, malformed + drafts, variables, review queries, candidate data, and source/wiki text. + """ + decision = classify(draft, facts) + if decision["code"] not in {QUERY_ENTITY_NOT_ACCEPTED, QUERY_RELATION_NOT_ACCEPTED}: + return [] + if _predicate_of(draft) != "relation": + return [] + args = query_args(draft) + if len(args) != 3: + return [] + aliases = relation_aliases() + entities = entity_set(facts) + relations = {row["relation"] for row in facts if row["relation"]} | set(aliases) | set(aliases.values()) + hints: list[dict[str, object]] = [] + for kind, arg, vocabulary in ( + ("entity", args[0], entities), + ("relation", args[1], relations), + ("entity", args[2], entities), + ): + if not is_quoted_string(arg): + continue + term = arg_value(arg) + if any(canonical_value(value).casefold() == canonical_value(term).casefold() for value in vocabulary): + continue + suggestions = nearby_vocabulary(term, vocabulary) + if suggestions: + hints.append({"kind": kind, "term": term, "suggestions": suggestions}) + return hints + + def _reachable_pairs(facts: list[dict[str, str]]) -> set[tuple[str, str]]: """Transitive closure of edge(S,O) :- relation(S, _, O), pure-python. @@ -794,6 +832,7 @@ def render_wiki_answer( reason: str, results: list[dict[str, object]], grounding: list[dict[str, str]] | None = None, + did_you_mean: list[dict[str, object]] | None = None, limit: int | None = DEFAULT_RENDER_ROW_LIMIT, total_results: int | None = None, ) -> str: @@ -836,6 +875,11 @@ def render_wiki_answer( truncation = _truncation_line(result_total, len(visible_results)) if truncation: lines.append(truncation) + for hint in did_you_mean or []: + suggestions = ", ".join(str(value) for value in hint["suggestions"]) + lines.append( + f"note: no accepted {hint['kind']} '{hint['term']}'. did you mean: {suggestions}?" + ) return "\n".join(lines) @@ -954,6 +998,7 @@ def cmd_render(args: argparse.Namespace) -> int: "route": "wiki", "reason": decision["reason"], "policy_uncompiled": decision["policy_uncompiled"], + "did_you_mean": did_you_mean_hints(args.draft, facts), }, ensure_ascii=False, )) @@ -988,11 +1033,13 @@ def cmd_wiki(args: argparse.Namespace) -> int: # Grounding: accepted facts about mentioned entities (empty if not compiled yet). accepted = load_accepted_facts() if ACCEPTED_DL.is_file() else [] grounding = grounding_facts(args.text, accepted) + hints = did_you_mean_hints(args.draft, accepted) if args.draft else [] print(render_wiki_answer( args.text, args.reason, results, grounding, + hints, limit=None if args.all else DEFAULT_RENDER_ROW_LIMIT, total_results=total_results, )) @@ -1034,6 +1081,7 @@ def build_parser() -> argparse.ArgumentParser: wiki_p = sub.add_parser("wiki", help="render the UNVERIFIED — wiki exploration answer") wiki_p.add_argument("text", help="the natural-language question") wiki_p.add_argument("--reason", default="not expressible over accepted facts", help="why the engine path did not apply") + wiki_p.add_argument("--draft", default=None, help="validated draft query; append display-only spelling hints when eligible") wiki_p.add_argument("--all", action="store_true", help="show every excerpt and grounding row") wiki_p.add_argument("--target", default=None, help="KB root (overrides FACTLOG_ROOT)") wiki_p.set_defaults(func=cmd_wiki)