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
40 changes: 17 additions & 23 deletions inputguard/analyzer.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
from __future__ import annotations

from typing import List
from typing import List, Set

from inputguard.detector import detect_intent
import inputguard.rules # noqa: F401 — importing registers the coding domain and the 19 built-in rules
from inputguard.detector import detect_intent, normalize
from inputguard.recommender import get_recommendations
from inputguard.rules.coding import run_coding_rules
from inputguard.rules.debug import run_debug_rules
from inputguard.rules.optimization import run_optimization_rules
from inputguard.rules.explanation import run_explanation_rules
from inputguard.rules.feature import run_feature_rules
from inputguard.registry import REGISTRY
from inputguard.scorer import calculate_score, get_status
from inputguard.types import AnalysisResult, RuleFinding

Expand Down Expand Up @@ -36,29 +33,26 @@ def analyze(self, user_input: str, domain: str = "coding") -> AnalysisResult:
)
if not user_input.strip():
raise ValueError("user_input must be a non-empty, non-whitespace string.")
if domain != "coding":
raise ValueError(
f"Unsupported domain: {domain!r}. Phase 1 only supports 'coding'."
)

detected_intent = detect_intent(user_input)
domain_signals = REGISTRY.get_domain_signals(domain)

detected_intent = detect_intent(user_input, domain_signals)
normalized = normalize(user_input)

if detected_intent == "debug":
findings: List[RuleFinding] = run_debug_rules(user_input)
elif detected_intent == "optimization":
findings = run_optimization_rules(user_input)
elif detected_intent == "explanation":
findings = run_explanation_rules(user_input)
elif detected_intent == "feature":
findings = run_feature_rules(user_input)
else:
findings = run_coding_rules(user_input)
findings: List[RuleFinding] = []
seen_codes: Set[str] = set()
for rule in REGISTRY.rules_for_intent(detected_intent):
finding = rule.check(normalized, detected_intent)
if finding is None or finding.code in seen_codes:
continue
seen_codes.add(finding.code)
findings.append(finding)

score = calculate_score(findings)
status = get_status(score, self.mode)

gaps: List[str] = []
seen = set()
seen: Set[str] = set()
for f in findings:
if f.gap is not None and f.gap not in seen:
gaps.append(f.gap)
Expand Down
56 changes: 43 additions & 13 deletions inputguard/detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import re

from typing import Iterable, Mapping, Optional, Tuple

DEBUG_SIGNALS = {
"error", "exception", "traceback", "not working", "isn't working",
Expand Down Expand Up @@ -50,15 +51,31 @@
}


def _normalize(text: str) -> str:
# The coding intent chain: priority-ordered (intent, terms) pairs. The single
# intent with empty terms ("build") is the fallback. This is the single source
# of truth for detection — the rule modules key their gates off the same sets,
# and the coding domain registers this chain in the rule registry.
INTENT_SIGNALS = (
("debug", DEBUG_SIGNALS),
("optimization", OPTIMIZATION_SIGNALS),
("explanation", EXPLANATION_SIGNALS),
("feature", FEATURE_SIGNALS),
("build", ()),
)


def normalize(text: str) -> str:
return re.sub(r"\s+", " ", text.strip().lower())


def _contains_any(text: str, terms) -> bool:
return any(term in text for term in terms)


def detect_intent(text: str) -> str:
def detect_intent(
text: str,
signals: Optional[Iterable[Tuple[str, Iterable[str]]]] = None,
) -> str:
"""
Detect the intent type of a coding input.

Expand All @@ -70,15 +87,28 @@ def detect_intent(text: str) -> str:
feature beats build. Build is the fallback.

Ambiguous inputs always resolve to the highest-priority match.

Pass ``signals`` to run the same chain over a domain's own
priority-ordered ``(intent, terms)`` pairs — the single intent with
empty terms is the fallback. Defaults to :data:`INTENT_SIGNALS`, the
built-in coding chain.
"""
normalized = _normalize(text)

if _contains_any(normalized, DEBUG_SIGNALS):
return "debug"
if _contains_any(normalized, OPTIMIZATION_SIGNALS):
return "optimization"
if _contains_any(normalized, EXPLANATION_SIGNALS):
return "explanation"
if _contains_any(normalized, FEATURE_SIGNALS):
return "feature"
return "build"
normalized = normalize(text)

chain = INTENT_SIGNALS if signals is None else tuple(
signals.items() if isinstance(signals, Mapping) else signals
)

fallback: Optional[str] = None
for intent, terms in chain:
if not terms:
fallback = intent
elif _contains_any(normalized, terms):
return intent

if fallback is not None:
return fallback
raise ValueError(
"detect_intent requires exactly one fallback intent (an entry with "
"empty signal terms); none was provided."
)
24 changes: 22 additions & 2 deletions inputguard/recommender.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,9 +115,29 @@
}


def _fallback_recommendation(gap: str) -> dict:
"""Generic four-key advice for a gap with no curated entry.

The documented fallback (spec art_bTvdPdJS §6): unknown gaps keep their
advice complete instead of vanishing silently from the recommendations
list — the v0.2 behavior that let rule authors ship empty advice.
"""
return {
"gap": gap,
"what_is_missing": f"You haven't provided the {gap} needed to act on this request.",
"what_to_provide": f"Describe the {gap} explicitly — one or two concrete sentences is enough.",
"why_it_matters": f"Without the {gap}, the request can be read several ways and the answer may miss what you actually need.",
}


def get_recommendations(gaps: List[str]) -> List[dict]:
"""Build one four-key recommendation per gap, in input order.

A gap without a curated entry gets the documented fallback
(see :func:`_fallback_recommendation`) — never a silent drop.
"""
out: List[dict] = []
for gap in gaps:
if gap in _RECOMMENDATIONS:
out.append(dict(_RECOMMENDATIONS[gap]))
entry = _RECOMMENDATIONS.get(gap)
out.append(dict(entry) if entry is not None else _fallback_recommendation(gap))
return out
Loading