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
14 changes: 9 additions & 5 deletions src/context_compiler/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -436,18 +436,22 @@ def _parse_hard_negative(normalized_message: str) -> list[str] | None:

payload = msg[len(rule.starter) :].strip()
if rule.strip_leading_use:
payload = re.sub(r"^use\s+", "", payload, count=1)
payload = re.sub(r"^use(?:\s+|$)", "", payload, count=1)
return _split_items(payload)

return None


def _parse_hard_positive(user_input: str, normalized_message: str) -> str | None:
if normalized_message == "use":
return ""

normalized_candidate = re.sub(r"^please\s+", "", normalized_message, count=1)
if not (
normalized_message.startswith("use ")
or normalized_message.startswith("set ")
or normalized_message.startswith("i am using ")
or normalized_message.startswith("i'm using ")
normalized_candidate.startswith("use ")
or normalized_candidate.startswith("set ")
or normalized_candidate.startswith("i am using ")
or normalized_candidate.startswith("i'm using ")
):
return None

Expand Down
108 changes: 108 additions & 0 deletions tests/test_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,15 @@ def test_correction_replaces_most_recent_exclusive_fact() -> None:
assert engine.state["facts"]["focus.primary"] == "Nord Stage 4"


def test_please_use_directive_updates_fact() -> None:
engine = create_engine()

decision = engine.step("please use Nord Stage 4")

assert decision["kind"] == "update"
assert engine.state["facts"]["focus.primary"] == "Nord Stage 4"


def test_correction_without_prior_exclusive_fact_requires_clarification() -> None:
engine = create_engine()

Expand Down Expand Up @@ -430,6 +439,15 @@ def test_pending_reprompts_on_non_yes_no() -> None:
assert engine.state == before


def test_ambiguous_policy_add_clarification_uses_expected_prompt_text() -> None:
engine = create_engine()

decision = engine.step("no use docker")

assert decision["kind"] == "clarify"
assert decision["prompt_to_user"] == "Did you mean to prohibit 'docker'?"


def test_pending_clarification_reprompt_keeps_state_unchanged() -> None:
engine = create_engine()

Expand Down Expand Up @@ -672,3 +690,93 @@ def test_hard_negative_dont_without_use_still_adds_policy() -> None:

assert decision["kind"] == "update"
assert engine.state["policies"]["prohibit"] == ["docker"]


def test_correction_with_multiple_values_clarifies_and_keeps_state() -> None:
engine = create_engine()
engine.step("use Nord Stage 4")
before = engine.state

decision = engine.step("actually macbook and linux")

assert decision["kind"] == "clarify"
assert engine.state == before


def test_empty_hard_negative_clarifies_with_prohibit_prompt() -> None:
engine = create_engine()
before = engine.state

decision = engine.step("don't use")

assert decision["kind"] == "clarify"
assert decision["prompt_to_user"] == "What should I prohibit?"
assert engine.state == before


def test_hard_positive_empty_payload_clarifies_with_use_prompt() -> None:
engine = create_engine()
before = engine.state

decision = engine.step("use")

assert decision["kind"] == "clarify"
assert decision["prompt_to_user"] == "What should I use?"
assert engine.state == before


def test_hard_positive_multi_value_payload_clarifies_with_single_value_prompt() -> None:
engine = create_engine()
before = engine.state

decision = engine.step("use macbook and linux")

assert decision["kind"] == "clarify"
assert decision["prompt_to_user"] == "Please provide a single value to use."
assert engine.state == before


def test_pending_clarification_rejected_by_explicit_no_is_passthrough() -> None:
engine = create_engine()
before = engine.state

first = engine.step("no use docker")
assert first["kind"] == "clarify"

second = engine.step("no")
assert second["kind"] == "passthrough"
assert engine.state == before


@pytest.mark.parametrize(
("path", "bad_state"),
[
("constructor", []),
("setter", "not-a-dict"),
("constructor", {"facts": [], "policies": {"prohibit": []}, "version": 1}),
("setter", {"facts": {"focus.primary": None}, "policies": [], "version": 1}),
(
"constructor",
{"facts": {"focus.primary": 123}, "policies": {"prohibit": []}, "version": 1},
),
],
)
def test_object_state_paths_reject_malformed_state_inputs(path: str, bad_state: object) -> None:
if path == "constructor":
with pytest.raises(ValueError):
Engine(state=bad_state)
return

engine = create_engine()
with pytest.raises(ValueError):
engine.state = bad_state


def test_allow_suffix_removes_existing_prohibition() -> None:
engine = create_engine()
engine.step("don't use docker")

decision = engine.step("docker is fine")

assert decision["kind"] == "update"
assert engine.state["policies"]["prohibit"] == []
7 changes: 3 additions & 4 deletions tests/test_properties.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,16 +137,15 @@ def test_soft_negative_phrases_do_not_mutate_state(text: str) -> None:


@given(st.from_regex(r"[A-Za-z0-9][A-Za-z0-9 ]{0,20}", fullmatch=True))
def test_please_use_is_not_a_supported_positive_directive(value: str) -> None:
def test_please_use_is_a_supported_positive_directive(value: str) -> None:
assume("," not in value)
assume(" and " not in value.lower())

engine = create_engine()
before = engine.state
decision = engine.step(f"please use {value}")

assert decision["kind"] != "update"
assert engine.state == before
assert decision["kind"] == "update"
assert engine.state["facts"]["focus.primary"] == _clean(value)


@given(st.text(max_size=80))
Expand Down
Loading