orchestrata_ultimate.orchestrator.intent_classifier
Reference › orchestrata_ultimate › orchestrator › intent_classifier
This is where a human sentence becomes a typed work order. Concierge passes the user's text to the intent_broker (also reached from the gateway RPC layer and the intent_demo script), and the broker hands free text to the IntentEvaluator in this module. It is traced to spec 003 story US-001 and depends on nlp, intent_normalizer, intent_vocabulary, intent_types and the work-type tables of pipeline_router.
NLIntentClassifier is a two-tier cascade. classify first runs named-entity recognition to find a resource path and an assigned volk. If a Prolog bridge is wired, it loads intent_grammar.pl once from the Prolog directory (_ensure_grammar_loaded) and queries parse_intent_evidence with the sanitized text; a successful parse becomes a ClassifiedIntent with tier set to grammar. If the bridge is absent, the grammar file is missing, the query fails or no parse is found, _classify_via_vocabulary scores the tokens against the keyword registries and picks a work type, action, target and priority. classify_async puts the normalizer in front: the turn, plus the preceding turns as context, is rewritten to a clean sentence first, and the question flag is re-read from what the user actually typed. Normalization failure leaves the text unchanged. to_intent converts the result into an Intent.
WorkflowMapper maps the classified work type to a workflow (with overrides) and to its StageSpec list. IntentEvaluator.evaluate_async (and the synchronous evaluate) returns an EvaluationResult with the intent, work type, confidence, reasons and stages. A turn is marked as needing disambiguation, and gets no stages, when its confidence is below min_confidence or when it is a question: a question is answered, not dispatched as work (REQ-003-US-001-070). The reasons list records the work type, confidence, tier and the decisive-word evidence for the audit trail.
At a glance
| Source | src/orchestrata_ultimate/orchestrator/intent_classifier.py |
| Lines of code | 690 |
| Classes | 5 |
| Functions | 3 |
| Imports (internal) | orchestrata_ultimate.core.base.traces, orchestrata_ultimate.core.paths, orchestrata_ultimate.orchestrator.intent_normalizer, orchestrata_ultimate.orchestrator.intent_types, orchestrata_ultimate.orchestrator.intent_vocabulary, orchestrata_ultimate.orchestrator.nlp, orchestrata_ultimate.orchestrator.pipeline_router |
| Imported by | orchestrata_ultimate.gateway.rpc, orchestrata_ultimate.orchestrator.intent_broker, orchestrata_ultimate.orchestrator.intent_broker_factory, scripts.intent_demo |
| Tests that import it | tests/gateway/test_orchestrator_evaluator_ingress.py, tests/generated_orchestrator/test_intent_classifier_generated.py, tests/orchestrator/test_intent_classifier.py, tests/orchestrator/test_intent_normalizer.py, tests/orchestrator/test_intent_speech_acts.py |
Class diagram
Dependency diagram
API
Constants
| Name | Type | Value |
|---|---|---|
_WORK_TYPE_ACTION_MAP |
dict[WorkType, IntentAction] |
{WorkType.FIX: IntentAction.MODIFY, WorkType.FEATURE: IntentAction.CREATE, Work… |
_TARGET_ATOM_MAP |
dict[str, IntentTarget] |
{'file': IntentTarget.FILE, 'code': IntentTarget.CODE, 'database': IntentTarget… |
Classes
ClassifiedIntent (dataclass)
Decorators: traces(spec='003', story='US-001', reqs=['REQ-003-US-001-046', 'REQ-003-US-001-049']), dataclass · Line 87
Result of classifying a natural-language string.
| Attribute | Type | Default |
|---|---|---|
work_type |
WorkType |
|
action |
IntentAction |
|
target |
IntentTarget |
|
priority |
IntentPriority |
IntentPriority.NORMAL |
title |
str |
'' |
resource_path |
str |
'' |
assigned_volk |
str | None |
None |
entities |
list[NamedEntity] |
field(default_factory=list) |
confidence |
float |
0.0 |
scores |
dict[str, float] |
field(default_factory=dict) |
matched_keywords |
dict[str, list[str]] |
field(default_factory=dict) |
normalized_text |
str |
'' |
tier |
str |
'' |
decisive_hits |
int |
0 |
winning_hits |
int |
0 |
is_question |
bool |
False |
NLIntentClassifier
Decorators: traces(spec='003', story='US-001', reqs=['REQ-003-US-001-042', 'REQ-003-US-001-043', 'REQ-003-US-001-046', 'REQ-003-US-… · Line 252
Cascade NL classifier: Prolog DCG grammar tier, vocabulary fallback tier.
| Method | Signature | Summary |
|---|---|---|
__init__ |
__init__(vocabulary: IntentVocabulary | None = None, prolog_bridge: Any = None, tokenizer: Tokenizer | None = None, ner: NamedEntityRecognizer | None = None, normalizer: IntentNormalizer | None = None) -> None |
|
classify |
classify(text: str) -> ClassifiedIntent |
Classify natural-language text into a ClassifiedIntent. |
classify_async |
async classify_async(text: str, context: Sequence[str] | None = None) -> ClassifiedIntent |
Normalize the turn, then classify — the full production cascade. |
_ensure_grammar_loaded |
_ensure_grammar_loaded() -> bool |
Load intent_grammar.pl into the wired bridge (once). |
_classify_via_grammar |
_classify_via_grammar(text: str) -> ClassifiedIntent | None |
Try the DCG grammar tier. |
_prolog_term_to_classified |
_prolog_term_to_classified(text: str, solution: dict[str, Any]) -> ClassifiedIntent | None |
Convert a work_intent(WorkType, Target, Rest) solution to a ClassifiedIntent. |
_classify_via_vocabulary |
_classify_via_vocabulary(text: str) -> ClassifiedIntent |
|
_priority_from_vocabulary |
_priority_from_vocabulary(text: str, tokens: set[str] | None = None) -> IntentPriority |
|
_score staticmethod |
_score(tokens: set[str], registry: dict[Any, set[str]], default: Any) -> tuple[Any, dict[str, float], list[str]] |
Score each registry category by keyword hit count; argmax wins. |
_resource_path_from_entities |
_resource_path_from_entities(entities: list[NamedEntity]) -> str |
|
_assigned_volk_from_entities |
_assigned_volk_from_entities(entities: list[NamedEntity]) -> str | None |
|
to_intent |
to_intent(classified: ClassifiedIntent, requester: str = 'U:Ox') -> Intent |
Build a broker Intent from a ClassifiedIntent. |
WorkflowMapper
Decorators: traces(spec='003', story='US-001', reqs=['REQ-003-US-001-044']) · Line 540
Maps a ClassifiedIntent to a workflow WorkType + its PipelineRouter stage chain.
| Method | Signature | Summary |
|---|---|---|
__init__ |
__init__(overrides: dict[WorkType, WorkType] | None = None) -> None |
|
register |
register(category: WorkType, workflow: WorkType) -> None |
Register (or override) a category -> workflow mapping. |
workflow_for |
workflow_for(classified: ClassifiedIntent) -> WorkType |
Resolve the workflow WorkType for a classified intent. |
stages_for |
stages_for(work_type: WorkType, has_ui: bool = False) -> list[StageSpec] |
Resolve the real PipelineRouter stage chain for a work type. |
EvaluationResult (dataclass)
Decorators: traces(spec='003', story='US-001', reqs=['REQ-003-US-001-045']), dataclass · Line 570
Result of :meth:IntentEvaluator.evaluate.
| Attribute | Type | Default |
|---|---|---|
intent |
Intent |
|
work_type |
WorkType |
|
stages |
list[StageSpec] |
|
confidence |
float |
|
reasons |
list[str] |
field(default_factory=list) |
needs_disambiguation |
bool |
False |
classified |
ClassifiedIntent | None |
None |
IntentEvaluator
Decorators: traces(spec='003', story='US-001', reqs=['REQ-003-US-001-045']) · Line 586
The evaluator step: NL text -> confidence-gated EvaluationResult.
| Method | Signature | Summary |
|---|---|---|
__init__ |
__init__(vocabulary: IntentVocabulary | None = None, classifier: NLIntentClassifier | None = None, mapper: WorkflowMapper | None = None, min_confidence: float = 0.34) -> None |
|
evaluate_async |
async evaluate_async(text: str, requester: str = 'U:Ox', context: Sequence[str] | None = None) -> EvaluationResult |
Evaluate with the normalization stage in front — the path the broker uses in production. |
evaluate |
evaluate(text: str, requester: str = 'U:Ox') -> EvaluationResult |
Classify, map to a workflow, and gate on confidence. |
_evaluation_from |
_evaluation_from(classified: ClassifiedIntent, requester: str) -> EvaluationResult |
Shared tail of both evaluate paths — workflow mapping plus the confidence gate. |
Functions
| Function | Signature | Summary |
|---|---|---|
is_question traces |
is_question(text: str) -> bool |
Did the user ASK something rather than commission work? |
_as_int traces |
_as_int(value: object) -> int |
Coerce a janus-marshalled Prolog integer, defaulting to 0. |
_confidence_from_scores traces |
_confidence_from_scores(scores: dict[str, float]) -> float |
Map keyword-hit scores to a ~0.5-0.7 confidence band. |
Source
"""Extensible NL intent classifier + workflow mapper + evaluator.
Spec: 003-intent / US-001 extension (see plans/intent-nl-classifier-design.md).
Borrows the cascade shape of dr-hannah's ``NaturalLanguageQueryParser``
(~/workspace/dr-hannah/src/dr_hannah/nlq.py): a Prolog DCG grammar tier
(preferred, ``intent_grammar.pl``) falling back to vocabulary keyword
scoring (``intent_vocabulary.py``) when no Prolog bridge is wired or the
grammar returns ``unknown``. ``NamedEntityRecognizer`` (orchestrator/nlp.py)
supplies ``resource_path``/``assigned_volk``/entities in both tiers — never
fabricated.
Composed with a ``WorkflowMapper`` (category -> ``PipelineRouter`` stage
chain) and an ``IntentEvaluator`` (the evaluator step: NL text ->
confidence-gated ``EvaluationResult``).
"""
from __future__ import annotations
import logging
from collections.abc import Sequence
from dataclasses import dataclass, field
from typing import Any
from orchestrata_ultimate.core.base.traces import traces
from orchestrata_ultimate.core.paths import get_prolog_dir
from orchestrata_ultimate.orchestrator.intent_normalizer import IntentNormalizer
from orchestrata_ultimate.orchestrator.intent_types import (
Intent,
IntentAction,
IntentPriority,
IntentTarget,
)
from orchestrata_ultimate.orchestrator.intent_vocabulary import IntentVocabulary
from orchestrata_ultimate.orchestrator.nlp import (
EntityType,
NamedEntity,
NamedEntityRecognizer,
Tokenizer,
)
from orchestrata_ultimate.orchestrator.pipeline_router import (
PipelineRouter,
StageSpec,
WorkType,
)
logger = logging.getLogger(__name__)
_GRAMMAR_FILE = "intent_grammar.pl"
# WorkType (from the grammar/vocabulary tiers) -> a representative IntentAction.
_WORK_TYPE_ACTION_MAP: dict[WorkType, IntentAction] = {
WorkType.FIX: IntentAction.MODIFY,
WorkType.FEATURE: IntentAction.CREATE,
WorkType.REFACTOR: IntentAction.MODIFY,
WorkType.DOCUMENTATION: IntentAction.WRITE,
WorkType.TEST: IntentAction.EXECUTE,
WorkType.REVIEW: IntentAction.REVIEW,
WorkType.ANALYSIS: IntentAction.ANALYZE,
WorkType.PERFORMANCE: IntentAction.ANALYZE,
WorkType.DEPENDENCY: IntentAction.MODIFY,
WorkType.SPEC: IntentAction.PLAN,
}
# Grammar-tier target atom (lowercase, matches intent_grammar.pl's lexicon) -> IntentTarget.
_TARGET_ATOM_MAP: dict[str, IntentTarget] = {
"file": IntentTarget.FILE,
"code": IntentTarget.CODE,
"database": IntentTarget.DATABASE,
"api": IntentTarget.API,
"system": IntentTarget.SYSTEM,
"directory": IntentTarget.DIRECTORY,
"command": IntentTarget.COMMAND,
"git": IntentTarget.GIT,
"network": IntentTarget.NETWORK,
"impact": IntentTarget.IMPACT,
"architecture": IntentTarget.ARCHITECTURE,
}
@traces(
spec="003",
story="US-001",
reqs=["REQ-003-US-001-046", "REQ-003-US-001-049"],
)
@dataclass
class ClassifiedIntent:
"""Result of classifying a natural-language string."""
work_type: WorkType
action: IntentAction
target: IntentTarget
priority: IntentPriority = IntentPriority.NORMAL
title: str = ""
resource_path: str = ""
assigned_volk: str | None = None
entities: list[NamedEntity] = field(default_factory=list)
confidence: float = 0.0
scores: dict[str, float] = field(default_factory=dict)
matched_keywords: dict[str, list[str]] = field(default_factory=dict)
# The sentence the GRAMMAR actually read. Empty when classification ran
# on the raw text (sync path, or the normalizer degraded to passthrough).
# Stored so a verdict is reconstructable from the parser's input rather
# than from what the user typed — the two differ by design once the
# normalization stage is in front (REQ-003-US-001-062).
normalized_text: str = ""
# ── Evidence behind the verdict (REQ-003-US-001-068) ────────────────────
# WHY THESE EXIST. `confidence` above carries one bit and no more: with
# the grammar tier live it is 0.9 or 0.3 and nothing between, measured
# over all 185 turns in the three corpora. The 0.5-0.7 keyword band in
# `_confidence_from_scores` is unreachable while a Prolog bridge is
# present (see the proof in that function's docstring). So a consumer
# asking "how sure are we?" is told either `sure` or `not sure`, and any
# dispatch threshold in (0.30, 0.85] is the same threshold.
#
# These fields carry what the parse actually knew, so a graded confidence
# can be DERIVED later from production telemetry instead of stamped now
# from a hand-picked constant. They are deliberately not folded into
# `confidence` yet: the obvious candidate rule was measured on the corpus
# and does not replicate (plans/intent-dcg-accuracy-design.md §2.7).
#
# tier "grammar" | "vocabulary" — which tier decided
# decisive_hits decisive work-type words found in the sentence
# winning_hits how many of them voted for the winning category
#
# decisive_hits == 0 on a "grammar" verdict means the parse rested on a
# GENERIC VERB alone — 23 of 185 corpus turns — which today is
# indistinguishable from a 3-word unanimous parse in the confidence value.
tier: str = ""
decisive_hits: int = 0
winning_hits: int = 0
# ── Speech act (REQ-003-US-001-070) ──────────────────────────────────────
# Whether the user ASKED something rather than commissioned work.
#
# This is not cosmetic. Measured over 272 real user turns, 41% of the
# questions reach a CONFIDENT work verdict: "why did you stop in that
# state?" dispatches ANALYSIS at 0.9, "are you observing?" dispatches
# TEST. The grammar is behaving correctly on its own terms — `why` and
# `test` are decisive work-type words — but the turn is a question and
# answering it with a pipeline is the wrong act entirely.
#
# Detected from the RAW text, because the signal is the user's own
# punctuation and the normalizer rewrite may not preserve it.
is_question: bool = False
@traces(spec="003", story="US-001", reqs=["REQ-003-US-001-070"])
def is_question(text: str) -> bool:
"""Did the user ASK something rather than commission work?
A terminal ``?`` and nothing else. That is not a simplification for its own
sake — it is what measured best on 272 labelled real turns:
rule precision recall (held-out split)
ends with '?' 0.862 0.833
wh/modal opening word 0.762 0.533
either of the two 0.784 0.967
both together 0.923 0.400
Recall matters more than the last points of precision here, because the
cost of the two errors is asymmetric and not in the direction one first
assumes. Missing a question means dispatching a work pipeline at someone
who asked a question; over-firing means asking to confirm a request that
was already going to be confirmed by a human anyway.
Wh-word openings are deliberately NOT used. `why`, `what` and `how` are
already decisive work-type words in the lexicon (`why` -> analysis), so
matching on them would double-count the exact signal the grammar is
reading, and it measured worse on its own.
Applied to the corpus, this stops 35 currently-dispatching turns: 29 are
questions or statements (82.9%) and 6 are requests that will now ask for
confirmation rather than dispatch unattended.
"""
return text.strip().endswith("?")
@traces(spec="003", story="US-001", reqs=["REQ-003-US-001-068"])
def _as_int(value: object) -> int:
"""Coerce a janus-marshalled Prolog integer, defaulting to 0.
Prolog bindings arrive as whatever janus could convert; an evidence count
that failed to marshal must read as "no evidence" rather than crash a
classification that is otherwise fine.
"""
if isinstance(value, int): # bool is an int subclass; already the answer
return value
if isinstance(value, float):
return int(value)
if isinstance(value, str):
try:
return int(value)
except ValueError:
return 0
return 0
@traces(spec="003", story="US-001", reqs=["REQ-003-US-001-048"])
def _confidence_from_scores(scores: dict[str, float]) -> float:
"""Map keyword-hit scores to a ~0.5-0.7 confidence band.
``confidence = 0.5 + 0.2 * margin_ratio`` where ``margin_ratio`` is the
(top - runner-up) hit-count gap normalized by the total hits. A single
unambiguous category -> 0.7; a tie between categories -> 0.5; no
matches at all -> 0.3 (below the default IntentEvaluator threshold).
THE 0.5-0.7 BAND IS UNREACHABLE WHENEVER A PROLOG BRIDGE IS PRESENT, and
that is a property of the cascade, not an accident of the corpus. Proof:
1. Both tiers read the SAME work-type lexicon — ``intent_lexicon.gen.pl``
is generated from this vocabulary (``gen_intent_lexicon.py``, gated by
`lexicon-drift`), so a token is a decisive hit for the grammar exactly
when it scores for the keyword tier.
2. ``work_type_of/3`` succeeds on ANY decisive hit, and ``target_of/4``
ends in a catch-all clause that cannot fail, so ``classify_tokens/2``
returns a term whenever one decisive hit exists.
3. Therefore the keyword tier is reached only when there were no decisive
hits at all — in which case ``scores`` is empty and this returns 0.3.
Measured, consistent with the proof: 0 of 185 turns across the three
corpora landed between 0.3 and 0.9. Confidence carries one bit, so every
``IntentEvaluator`` threshold in (0.30, 0.85] behaves identically. Making
it graded is REQ-003-US-001-069; the evidence fields on ``ClassifiedIntent``
are step one of it.
The band is kept, not deleted: it is live and correct for the
bridge-less configuration (``prolog_bridge=None``), which is the sync
path several callers still use.
"""
if not scores:
return 0.3
ordered = sorted(scores.values(), reverse=True)
top = ordered[0]
runner_up = ordered[1] if len(ordered) > 1 else 0.0
total = sum(ordered)
margin_ratio = (top - runner_up) / total if total else 0.0
return round(0.5 + 0.2 * margin_ratio, 4)
@traces(
spec="003",
story="US-001",
reqs=[
"REQ-003-US-001-042",
"REQ-003-US-001-043",
"REQ-003-US-001-046",
"REQ-003-US-001-048",
],
)
class NLIntentClassifier:
"""Cascade NL classifier: Prolog DCG grammar tier, vocabulary fallback tier.
Mirrors dr-hannah's ``NaturalLanguageQueryParser(prolog_engine=None)``:
try the grammar backend first when a ``prolog_bridge`` is wired, fall
back gracefully to keyword scoring otherwise (or when the grammar
returns ``unknown``).
"""
def __init__(
self,
vocabulary: IntentVocabulary | None = None,
prolog_bridge: Any = None,
tokenizer: Tokenizer | None = None,
ner: NamedEntityRecognizer | None = None,
normalizer: IntentNormalizer | None = None,
) -> None:
self.vocabulary = vocabulary or IntentVocabulary.default()
self.prolog_bridge = prolog_bridge
self.tokenizer = tokenizer or Tokenizer()
self.ner = ner or NamedEntityRecognizer()
self.normalizer = normalizer or IntentNormalizer()
self._grammar_loaded = False
@traces(
spec="003", story="US-001", reqs=["REQ-003-US-001-043", "REQ-003-US-001-046"]
)
def classify(self, text: str) -> ClassifiedIntent:
"""Classify natural-language text into a ``ClassifiedIntent``.
Cascade: Prolog DCG grammar (confidence ~0.9) -> vocabulary keyword
scoring (confidence ~0.5-0.7). NER supplies resource_path/
assigned_volk/entities in both tiers — never fabricated.
"""
entities = self.ner.recognize(text)
resource_path = self._resource_path_from_entities(entities)
assigned_volk = self._assigned_volk_from_entities(entities)
if self.prolog_bridge is not None:
grammar_result = self._classify_via_grammar(text)
if grammar_result is not None:
grammar_result.entities = entities
grammar_result.resource_path = resource_path
grammar_result.assigned_volk = assigned_volk
grammar_result.is_question = is_question(text)
return grammar_result
result = self._classify_via_vocabulary(text)
result.entities = entities
result.resource_path = resource_path
result.assigned_volk = assigned_volk
result.is_question = is_question(text)
return result
@traces(
spec="003",
story="US-001",
reqs=["REQ-003-US-001-060", "REQ-003-US-001-062", "REQ-003-US-001-063"],
)
async def classify_async(
self, text: str, context: Sequence[str] | None = None
) -> ClassifiedIntent:
"""Normalize the turn, then classify — the full production cascade.
The grammar is a keyword parser and people do not type keywords.
Measured over 76 real user turns from this project's own history
(``tests/orchestrator/data/intent_corpus_raw_turns.json``), the grammar
scored 19/76 on the raw text and 29/76 once the same turns were
rewritten as plain sentences — the gap is spelling and phrasing, not
vocabulary. So the normalizer runs FIRST, unconditionally, and Prolog
only ever parses clean input (design:
plans/intent-dcg-accuracy-design.md §2.5).
The shipped prompt currently captures about 3 of those 10 points
(26/92 independently measured); closing the rest is open work, not a
solved problem.
``context`` is the preceding turns, oldest first, and is what makes
"it hangs again when I drain" resolvable at all — no amount of
vocabulary reaches a sentence whose subject is a pronoun.
Normalization degrades to passthrough on any failure, so this method
is never worse than :meth:`classify`; it is the same cascade with a
better input.
"""
normalized = await self.normalizer.normalize(text, context)
result = self.classify(normalized)
# Attribute the title to what the user actually said; record the
# parsed sentence separately.
result.normalized_text = normalized
# Re-read the speech act from what the USER typed. The normalizer is
# told to produce a declarative sentence for the parser, so it strips
# the question mark that is the whole signal — classifying the rewrite
# would lose it every time.
result.is_question = is_question(text)
if normalized != text:
result.title = text.strip()
return result
# ── Prolog grammar tier ─────────────────────────────────────────────
@traces(spec="003", story="US-001", reqs=["REQ-003-US-001-042"])
def _ensure_grammar_loaded(self) -> bool:
"""Load intent_grammar.pl into the wired bridge (once)."""
if self._grammar_loaded:
return True
if self.prolog_bridge is None:
return False
grammar_path = get_prolog_dir() / _GRAMMAR_FILE
if not grammar_path.exists():
return False
loaded: bool = self.prolog_bridge.load_rules(grammar_path)
self._grammar_loaded = loaded
return loaded
@traces(
spec="003", story="US-001", reqs=["REQ-003-US-001-042", "REQ-003-US-001-048"]
)
def _classify_via_grammar(self, text: str) -> ClassifiedIntent | None:
"""Try the DCG grammar tier. Returns None on unknown/unavailable."""
if not self._ensure_grammar_loaded():
return None
sanitized = text.replace("\\", "\\\\").replace("'", "\\'")
# parse_intent_evidence/4, not parse_intent_string/2: same parse, but
# it also returns the vote tally the grammar already computed and
# previously discarded. Proved term-for-term identical to the /2 form
# over all 185 corpus turns before the switch — the evidence rides
# along, it does not steer.
query_str = (
f"parse_intent_evidence('{sanitized}', "
"work_intent(WorkType, Target, Rest), Wins, Total)"
)
try:
result = self.prolog_bridge.query(query_str)
except Exception as exc: # pragma: no cover - defensive, mirrors dr-hannah
logger.warning("intent_grammar_query_failed: %s", exc)
return None
if not result.success or not result.solutions:
return None
return self._prolog_term_to_classified(text, result.solutions[0])
@traces(spec="003", story="US-001", reqs=["REQ-003-US-001-042"])
def _prolog_term_to_classified(
self, text: str, solution: dict[str, Any]
) -> ClassifiedIntent | None:
"""Convert a ``work_intent(WorkType, Target, Rest)`` solution to a
``ClassifiedIntent``. Mirrors dr-hannah's ``_prolog_term_to_query``.
"""
work_type_raw = str(solution.get("WorkType", ""))
target_raw = str(solution.get("Target", ""))
try:
work_type = WorkType(work_type_raw)
except ValueError:
return None
target = _TARGET_ATOM_MAP.get(target_raw, IntentTarget.CODE)
action = _WORK_TYPE_ACTION_MAP.get(work_type, IntentAction.MODIFY)
# Evidence is reported, never scored — `confidence` stays the flat 0.9
# this tier has always returned. Grading it is REQ-003-US-001-069 and
# needs production telemetry; the one candidate rule available from the
# corpus (single decisive hit predicts correctness) reverses direction
# on held-out data and was rejected.
return ClassifiedIntent(
work_type=work_type,
action=action,
target=target,
priority=self._priority_from_vocabulary(text),
title=text.strip(),
confidence=0.9,
scores={work_type.value: 1.0},
matched_keywords={"grammar": [work_type_raw]},
tier="grammar",
decisive_hits=_as_int(solution.get("Total")),
winning_hits=_as_int(solution.get("Wins")),
)
# ── Vocabulary tier ──────────────────────────────────────────────────
@traces(
spec="003", story="US-001", reqs=["REQ-003-US-001-043", "REQ-003-US-001-041"]
)
def _classify_via_vocabulary(self, text: str) -> ClassifiedIntent:
tokens = {t.normalized for t in self.tokenizer.tokenize(text)}
work_type, work_scores, work_matched = self._score(
tokens, self.vocabulary.work_type_keywords, WorkType.FEATURE
)
action, _, action_matched = self._score(
tokens, self.vocabulary.action_keywords, IntentAction.MODIFY
)
target, _, target_matched = self._score(
tokens, self.vocabulary.target_keywords, IntentTarget.CODE
)
priority = self._priority_from_vocabulary(text, tokens)
return ClassifiedIntent(
work_type=work_type,
action=action,
target=target,
priority=priority,
title=text.strip(),
confidence=_confidence_from_scores(work_scores),
scores=work_scores,
matched_keywords={
"work_type": work_matched,
"action": action_matched,
"target": target_matched,
},
tier="vocabulary",
# This tier has no vote to report: it scores hits, it does not
# tally them per category. `work_matched` is the raw evidence.
decisive_hits=len(work_matched),
winning_hits=len(work_matched),
)
@traces(spec="003", story="US-001", reqs=["REQ-003-US-001-041"])
def _priority_from_vocabulary(
self, text: str, tokens: set[str] | None = None
) -> IntentPriority:
toks = (
tokens
if tokens is not None
else {t.normalized for t in self.tokenizer.tokenize(text)}
)
priority: IntentPriority
priority, _, _ = self._score(
toks, self.vocabulary.priority_keywords, IntentPriority.NORMAL
)
return priority
@staticmethod
@traces(spec="003", story="US-001", reqs=["REQ-003-US-001-043"])
def _score(
tokens: set[str], registry: dict[Any, set[str]], default: Any
) -> tuple[Any, dict[str, float], list[str]]:
"""Score each registry category by keyword hit count; argmax wins."""
scores: dict[str, float] = {}
matched: dict[Any, list[str]] = {}
for category, keywords in registry.items():
hits = sorted(tokens & keywords)
if hits:
scores[category.value] = float(len(hits))
matched[category] = hits
if not matched:
return default, {}, []
best_category = max(matched, key=lambda c: len(matched[c]))
return best_category, scores, matched[best_category]
@traces(spec="003", story="US-001", reqs=["REQ-003-US-001-046"])
def _resource_path_from_entities(self, entities: list[NamedEntity]) -> str:
for entity in entities:
if entity.entity_type == EntityType.FILE_PATH:
return entity.text
return ""
@traces(spec="003", story="US-001", reqs=["REQ-003-US-001-046"])
def _assigned_volk_from_entities(self, entities: list[NamedEntity]) -> str | None:
for entity in entities:
if entity.entity_type == EntityType.AGENT:
return entity.text
return None
@traces(spec="003", story="US-001", reqs=["REQ-003-US-001-049"])
def to_intent(
self, classified: ClassifiedIntent, requester: str = "U:Ox"
) -> Intent:
"""Build a broker ``Intent`` from a ``ClassifiedIntent``."""
return Intent(
action=classified.action,
target=classified.target,
resource_path=classified.resource_path,
priority=classified.priority,
requester=requester,
parameters={
"work_type": classified.work_type.value,
"title": classified.title,
},
)
@traces(spec="003", story="US-001", reqs=["REQ-003-US-001-044"])
class WorkflowMapper:
"""Maps a ``ClassifiedIntent`` to a workflow ``WorkType`` + its
``PipelineRouter`` stage chain.
Extensible via :meth:`register` — callers can override the
category -> workflow mapping without code edits.
"""
def __init__(self, overrides: dict[WorkType, WorkType] | None = None) -> None:
self._overrides: dict[WorkType, WorkType] = dict(overrides or {})
self._router = PipelineRouter()
@traces(spec="003", story="US-001", reqs=["REQ-003-US-001-044"])
def register(self, category: WorkType, workflow: WorkType) -> None:
"""Register (or override) a category -> workflow mapping."""
self._overrides[category] = workflow
@traces(spec="003", story="US-001", reqs=["REQ-003-US-001-044"])
def workflow_for(self, classified: ClassifiedIntent) -> WorkType:
"""Resolve the workflow ``WorkType`` for a classified intent."""
return self._overrides.get(classified.work_type, classified.work_type)
@traces(spec="003", story="US-001", reqs=["REQ-003-US-001-044"])
def stages_for(self, work_type: WorkType, has_ui: bool = False) -> list[StageSpec]:
"""Resolve the real ``PipelineRouter`` stage chain for a work type."""
return self._router.route(work_type, has_ui=has_ui)
@traces(spec="003", story="US-001", reqs=["REQ-003-US-001-045"])
@dataclass
class EvaluationResult:
"""Result of :meth:`IntentEvaluator.evaluate`."""
intent: Intent
work_type: WorkType
stages: list[StageSpec]
confidence: float
reasons: list[str] = field(default_factory=list)
needs_disambiguation: bool = False
# The classification this verdict was built from, evidence included
# (REQ-003-US-001-068). Without it the evidence fields are unreachable from
# the only object callers actually receive, which makes them decoration.
classified: ClassifiedIntent | None = None
@traces(spec="003", story="US-001", reqs=["REQ-003-US-001-045"])
class IntentEvaluator:
"""The evaluator step: NL text -> confidence-gated ``EvaluationResult``.
Ties together ``IntentVocabulary`` (extensible keywords),
``NLIntentClassifier`` (the cascade), and ``WorkflowMapper``
(category -> workflow + stages). Below ``min_confidence``, the result
is flagged ``needs_disambiguation`` and stages are withheld.
"""
def __init__(
self,
vocabulary: IntentVocabulary | None = None,
classifier: NLIntentClassifier | None = None,
mapper: WorkflowMapper | None = None,
min_confidence: float = 0.34,
) -> None:
self.vocabulary = vocabulary or IntentVocabulary.default()
self.classifier = classifier or NLIntentClassifier(vocabulary=self.vocabulary)
self.mapper = mapper or WorkflowMapper()
self.min_confidence = min_confidence
@traces(
spec="003",
story="US-001",
reqs=["REQ-003-US-001-045", "REQ-003-US-001-063"],
)
async def evaluate_async(
self,
text: str,
requester: str = "U:Ox",
context: Sequence[str] | None = None,
) -> EvaluationResult:
"""Evaluate with the normalization stage in front — the path the
broker uses in production.
Identical to :meth:`evaluate` except the grammar reads a cleaned
sentence instead of raw typing. ``context`` is the preceding turns,
oldest first.
"""
classified = await self.classifier.classify_async(text, context)
return self._evaluation_from(classified, requester)
@traces(spec="003", story="US-001", reqs=["REQ-003-US-001-045"])
def evaluate(self, text: str, requester: str = "U:Ox") -> EvaluationResult:
"""Classify, map to a workflow, and gate on confidence.
The synchronous path: no normalization, so the grammar reads the raw
text. Retained for callers that have no event loop; prefer
:meth:`evaluate_async`.
"""
classified = self.classifier.classify(text)
return self._evaluation_from(classified, requester)
@traces(spec="003", story="US-001", reqs=["REQ-003-US-001-045"])
def _evaluation_from(
self, classified: ClassifiedIntent, requester: str
) -> EvaluationResult:
"""Shared tail of both evaluate paths — workflow mapping plus the
confidence gate."""
intent = self.classifier.to_intent(classified, requester=requester)
work_type = self.mapper.workflow_for(classified)
# A question does not auto-dispatch, however confident the parse.
# "why did you stop in that state?" parses as ANALYSIS at 0.9 because
# `why` is a decisive analysis word — the grammar is right and the
# dispatch is still wrong. Confidence cannot express this: the parse is
# good, the SPEECH ACT is not a work order (REQ-003-US-001-070).
needs_disambiguation = (
classified.confidence < self.min_confidence or classified.is_question
)
reasons = [
f"work_type={work_type.value}",
f"confidence={classified.confidence:.2f}",
# The evidence, in the human-readable trail, because "confidence
# 0.90" alone cannot distinguish a unanimous three-word parse from
# a generic-verb guess — and both occur.
f"tier={classified.tier}",
f"evidence={classified.winning_hits}/{classified.decisive_hits}"
" decisive words",
]
if classified.is_question:
# A distinct reason, because it is a distinct situation: the parse
# succeeded and the turn still must not become work. A caller that
# sees this should ANSWER, not ask the user to rephrase.
reasons.append(
"turn is a question — answer it, do not dispatch work for it"
)
elif needs_disambiguation:
reasons.append(
"confidence below min_confidence threshold — disambiguation needed"
)
stages: list[StageSpec] = (
[] if needs_disambiguation else self.mapper.stages_for(work_type)
)
return EvaluationResult(
intent=intent,
work_type=work_type,
stages=stages,
confidence=classified.confidence,
reasons=reasons,
needs_disambiguation=needs_disambiguation,
classified=classified,
)