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
24 changes: 24 additions & 0 deletions factlog/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions factlog/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
8 changes: 7 additions & 1 deletion skills/factlog/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<question>" --reason "<why>" --target "$FACTLOG_ROOT"
"${CLAUDE_PLUGIN_ROOT}/tools/factlog_python.sh" "${CLAUDE_PLUGIN_ROOT}/tools/ask_router.py" wiki "<question>" --reason "<why>" --draft "<draft>" --target "$FACTLOG_ROOT"
```

Show the `UNVERIFIED — wiki exploration` block verbatim (cited `sources/` /
Expand All @@ -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 `<draft>`
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.
Expand Down
22 changes: 22 additions & 0 deletions tests/test_ask_router.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'])")"
Expand Down
16 changes: 16 additions & 0 deletions tests/test_provenance.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down Expand Up @@ -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)"
Expand Down
20 changes: 20 additions & 0 deletions tests/unit/test_did_you_mean.py
Original file line number Diff line number Diff line change
@@ -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) == []
48 changes: 48 additions & 0 deletions tools/ask_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -83,6 +85,7 @@
load_facts,
load_logic_policy,
logic_policy_md_has_rules,
nearby_vocabulary,
policy_predicates,
relation_aliases,
run_wirelog,
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)


Expand Down Expand Up @@ -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,
))
Expand Down Expand Up @@ -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,
))
Expand Down Expand Up @@ -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)
Expand Down