Initial import NDC_1C
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
"""Router and orchestration decision helpers for accounting analytics flows."""
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import Any
|
||||
|
||||
from router.query_classifier import RouteDecisionFlags
|
||||
from router.route_selector import RouteSelectionResult
|
||||
from router.store_sufficiency import StoreSufficiencyResult
|
||||
|
||||
|
||||
@dataclass
|
||||
class RouteDecisionLog:
|
||||
question_id: str
|
||||
question_text: str
|
||||
parsed_class: str
|
||||
decision_flags: dict[str, Any]
|
||||
sufficiency_snapshot: dict[str, Any]
|
||||
candidate_routes: list[str]
|
||||
rejected_routes: dict[str, str]
|
||||
chosen_route: str
|
||||
execution_mode: str
|
||||
batch_job_id: str | None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def build_route_decision_log(
|
||||
*,
|
||||
question_id: str,
|
||||
question_text: str,
|
||||
parsed_class: str,
|
||||
flags: RouteDecisionFlags,
|
||||
suff: StoreSufficiencyResult,
|
||||
selection: RouteSelectionResult,
|
||||
execution_mode: str,
|
||||
batch_job_id: str | None,
|
||||
) -> RouteDecisionLog:
|
||||
return RouteDecisionLog(
|
||||
question_id=question_id,
|
||||
question_text=question_text,
|
||||
parsed_class=parsed_class,
|
||||
decision_flags=flags.to_dict(),
|
||||
sufficiency_snapshot=suff.to_dict(),
|
||||
candidate_routes=selection.candidate_routes,
|
||||
rejected_routes=selection.rejected_routes,
|
||||
chosen_route=selection.chosen_route,
|
||||
execution_mode=execution_mode,
|
||||
batch_job_id=batch_job_id,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
|
||||
ACCOUNT_TOKEN_RE = re.compile(r"\b\d{2}(?:\.\d{2})?\b")
|
||||
|
||||
|
||||
@dataclass
|
||||
class RouteDecisionFlags:
|
||||
needs_exact_object_trace: bool
|
||||
needs_causal_chain: bool
|
||||
needs_cross_entity_join: bool
|
||||
needs_full_period_aggregation: bool
|
||||
needs_ranking: bool
|
||||
needs_anomaly_summary: bool
|
||||
needs_runtime_truth: bool
|
||||
freshness_sensitive: bool
|
||||
ambiguous_object_scope: bool
|
||||
store_sufficiency_confident: bool
|
||||
precomputed_aggregate_available: bool
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def _norm(text: str) -> str:
|
||||
return text.lower().strip()
|
||||
|
||||
|
||||
def _contains_any(text: str, tokens: list[str]) -> bool:
|
||||
return any(token in text for token in tokens)
|
||||
|
||||
|
||||
def _has_account_token(text: str) -> bool:
|
||||
return bool(ACCOUNT_TOKEN_RE.search(text))
|
||||
|
||||
|
||||
def _aggregate_available_for_shape(
|
||||
*,
|
||||
available: set[str],
|
||||
needs_ranking: bool,
|
||||
needs_anomaly_summary: bool,
|
||||
needs_full_period_aggregation: bool,
|
||||
text: str,
|
||||
) -> bool:
|
||||
if needs_ranking:
|
||||
ranking_tokens = {
|
||||
"risk_account_ranking",
|
||||
"risk_counterparty_ranking",
|
||||
"risk_ranking",
|
||||
}
|
||||
return bool(available.intersection(ranking_tokens))
|
||||
|
||||
if needs_anomaly_summary:
|
||||
anomaly_tokens = {
|
||||
"company_anomaly_summary",
|
||||
}
|
||||
return bool(available.intersection(anomaly_tokens))
|
||||
|
||||
if needs_full_period_aggregation:
|
||||
if "baseline" in text:
|
||||
return "baseline_period_summary" in available
|
||||
return "full_period_aggregation" in available
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def classify_query_for_route(
|
||||
question_text: str,
|
||||
parsed_intent: dict[str, Any],
|
||||
store_metadata: dict[str, Any],
|
||||
) -> RouteDecisionFlags:
|
||||
text = _norm(question_text)
|
||||
question_class = str(parsed_intent.get("question_class", "")).strip().lower()
|
||||
|
||||
exact_markers = [
|
||||
"документ по номеру",
|
||||
"source-of-record",
|
||||
"источник",
|
||||
"цепочка",
|
||||
"почему",
|
||||
"subconto3",
|
||||
"субконто3",
|
||||
]
|
||||
causal_markers = [
|
||||
"свяжи",
|
||||
"цепочка",
|
||||
"через",
|
||||
"объясни",
|
||||
"почему",
|
||||
"источник",
|
||||
"регистр",
|
||||
"первич",
|
||||
]
|
||||
cross_markers = [
|
||||
"свяжи",
|
||||
"документ",
|
||||
"провод",
|
||||
"контрагент",
|
||||
"договор",
|
||||
"регистр",
|
||||
]
|
||||
ranking_markers = ["рейтинг", "ranking", "топ", "top"]
|
||||
anomaly_markers = ["аномал", "summary", "срез", "risk-slice", "риск-срез"]
|
||||
|
||||
needs_exact_object_trace = _contains_any(text, exact_markers) and (
|
||||
question_class in {"drilldown_explain", "simple_factual", "cross_entity"}
|
||||
)
|
||||
if question_class == "simple_factual" and "документ по номеру" in text:
|
||||
needs_exact_object_trace = True
|
||||
|
||||
needs_causal_chain = _contains_any(text, causal_markers) and question_class in {
|
||||
"drilldown_explain",
|
||||
"cross_entity",
|
||||
}
|
||||
needs_cross_entity_join = (
|
||||
question_class == "cross_entity"
|
||||
or (_contains_any(text, cross_markers) and " и " in text and "->" not in text)
|
||||
)
|
||||
|
||||
needs_ranking = _contains_any(text, ranking_markers) and question_class in {
|
||||
"heavy_analytical",
|
||||
"period_trend",
|
||||
"anomaly_control",
|
||||
}
|
||||
needs_anomaly_summary = _contains_any(text, anomaly_markers)
|
||||
is_heavy = question_class == "heavy_analytical"
|
||||
is_baseline_heavy = is_heavy and "baseline" in text
|
||||
needs_full_period_aggregation = is_heavy and not is_baseline_heavy
|
||||
|
||||
needs_runtime_truth = needs_exact_object_trace or _contains_any(
|
||||
text, ["runtime", "source-of-record", "источник регистра"]
|
||||
)
|
||||
freshness_sensitive = question_class in {
|
||||
"period_trend",
|
||||
"anomaly_control",
|
||||
"heavy_analytical",
|
||||
}
|
||||
ambiguous_object_scope = question_class == "ambiguous_fuzzy"
|
||||
if ambiguous_object_scope and _has_account_token(text):
|
||||
# Ambiguous account prompts should avoid hard downcast into canonical-only answers.
|
||||
needs_runtime_truth = True
|
||||
|
||||
available_aggregates = {
|
||||
str(item).strip().lower() for item in store_metadata.get("precomputed_aggregates", [])
|
||||
}
|
||||
precomputed_aggregate_available = _aggregate_available_for_shape(
|
||||
available=available_aggregates,
|
||||
needs_ranking=needs_ranking,
|
||||
needs_anomaly_summary=needs_anomaly_summary,
|
||||
needs_full_period_aggregation=needs_full_period_aggregation,
|
||||
text=text,
|
||||
)
|
||||
|
||||
store_sufficiency_confident = (
|
||||
question_class == "simple_factual"
|
||||
and not needs_runtime_truth
|
||||
and not needs_causal_chain
|
||||
and not needs_cross_entity_join
|
||||
)
|
||||
|
||||
return RouteDecisionFlags(
|
||||
needs_exact_object_trace=needs_exact_object_trace,
|
||||
needs_causal_chain=needs_causal_chain,
|
||||
needs_cross_entity_join=needs_cross_entity_join,
|
||||
needs_full_period_aggregation=needs_full_period_aggregation,
|
||||
needs_ranking=needs_ranking,
|
||||
needs_anomaly_summary=needs_anomaly_summary,
|
||||
needs_runtime_truth=needs_runtime_truth,
|
||||
freshness_sensitive=freshness_sensitive,
|
||||
ambiguous_object_scope=ambiguous_object_scope,
|
||||
store_sufficiency_confident=store_sufficiency_confident,
|
||||
precomputed_aggregate_available=precomputed_aggregate_available,
|
||||
)
|
||||
@@ -0,0 +1,113 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import Any
|
||||
|
||||
from router.query_classifier import RouteDecisionFlags
|
||||
from router.store_sufficiency import StoreSufficiencyResult
|
||||
|
||||
|
||||
ALL_ROUTES = [
|
||||
"live_mcp_drilldown",
|
||||
"store_canonical",
|
||||
"store_feature_risk",
|
||||
"hybrid_store_plus_live",
|
||||
"batch_refresh_then_store",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class RouteSelectionResult:
|
||||
chosen_route: str
|
||||
candidate_routes: list[str]
|
||||
rejected_routes: dict[str, str]
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def choose_route(
|
||||
flags: RouteDecisionFlags,
|
||||
suff: StoreSufficiencyResult,
|
||||
*,
|
||||
parsed_as_trend_or_risk: bool,
|
||||
) -> RouteSelectionResult:
|
||||
rejected: dict[str, str] = {}
|
||||
|
||||
if flags.needs_exact_object_trace:
|
||||
rejected["store_canonical"] = "exact_trace_requires_live"
|
||||
rejected["store_feature_risk"] = "exact_trace_requires_live"
|
||||
rejected["hybrid_store_plus_live"] = "exact_trace_prefers_direct_live"
|
||||
rejected["batch_refresh_then_store"] = "exact_trace_not_batch"
|
||||
return RouteSelectionResult(
|
||||
chosen_route="live_mcp_drilldown",
|
||||
candidate_routes=list(ALL_ROUTES),
|
||||
rejected_routes=rejected,
|
||||
)
|
||||
|
||||
heavy_shape = (
|
||||
flags.needs_full_period_aggregation
|
||||
or flags.needs_ranking
|
||||
or (flags.needs_anomaly_summary and not parsed_as_trend_or_risk)
|
||||
)
|
||||
if heavy_shape:
|
||||
aggregate_ok = (
|
||||
flags.precomputed_aggregate_available
|
||||
and suff.freshness_ok
|
||||
and suff.aggregate_level_ok
|
||||
and (not flags.needs_ranking or suff.ranking_ready)
|
||||
)
|
||||
if not aggregate_ok:
|
||||
rejected["store_feature_risk"] = "aggregate_not_sufficient"
|
||||
rejected["store_canonical"] = "wrong_query_shape"
|
||||
rejected["live_mcp_drilldown"] = "heavy_query_not_live"
|
||||
return RouteSelectionResult(
|
||||
chosen_route="batch_refresh_then_store",
|
||||
candidate_routes=list(ALL_ROUTES),
|
||||
rejected_routes=rejected,
|
||||
)
|
||||
|
||||
if flags.needs_cross_entity_join and flags.needs_causal_chain:
|
||||
if not suff.explanation_ready:
|
||||
rejected["store_canonical"] = "cross_entity_causal_needs_live_stitching"
|
||||
return RouteSelectionResult(
|
||||
chosen_route="hybrid_store_plus_live",
|
||||
candidate_routes=list(ALL_ROUTES),
|
||||
rejected_routes=rejected,
|
||||
)
|
||||
|
||||
if parsed_as_trend_or_risk:
|
||||
if suff.feature_sufficient and not flags.needs_runtime_truth:
|
||||
rejected["store_canonical"] = "trend_risk_query_prefers_feature"
|
||||
return RouteSelectionResult(
|
||||
chosen_route="store_feature_risk",
|
||||
candidate_routes=list(ALL_ROUTES),
|
||||
rejected_routes=rejected,
|
||||
)
|
||||
rejected["store_feature_risk"] = "feature_or_freshness_not_sufficient"
|
||||
|
||||
if (
|
||||
suff.canonical_sufficient
|
||||
and not flags.needs_causal_chain
|
||||
and not flags.ambiguous_object_scope
|
||||
and not flags.needs_runtime_truth
|
||||
):
|
||||
if flags.needs_cross_entity_join and not suff.explanation_ready:
|
||||
rejected["store_canonical"] = "cross_entity_needs_explanation_stitching"
|
||||
else:
|
||||
return RouteSelectionResult(
|
||||
chosen_route="store_canonical",
|
||||
candidate_routes=list(ALL_ROUTES),
|
||||
rejected_routes=rejected,
|
||||
)
|
||||
|
||||
if not suff.canonical_sufficient:
|
||||
rejected["store_canonical"] = "canonical_not_sufficient"
|
||||
if flags.ambiguous_object_scope:
|
||||
rejected["store_canonical"] = "ambiguous_scope_requires_hybrid_or_feature"
|
||||
|
||||
return RouteSelectionResult(
|
||||
chosen_route="hybrid_store_plus_live",
|
||||
candidate_routes=list(ALL_ROUTES),
|
||||
rejected_routes=rejected,
|
||||
)
|
||||
@@ -0,0 +1,111 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import Any
|
||||
|
||||
from router.query_classifier import RouteDecisionFlags
|
||||
|
||||
|
||||
@dataclass
|
||||
class StoreSufficiencyResult:
|
||||
canonical_sufficient: bool
|
||||
feature_sufficient: bool
|
||||
risk_sufficient: bool
|
||||
freshness_ok: bool
|
||||
aggregate_level_ok: bool
|
||||
ranking_ready: bool
|
||||
explanation_ready: bool
|
||||
reason_codes: list[str]
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def _to_float(value: Any, default: float = 0.0) -> float:
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def check_store_sufficiency(
|
||||
question_shape: RouteDecisionFlags,
|
||||
store_metadata: dict[str, Any],
|
||||
) -> StoreSufficiencyResult:
|
||||
reason_codes: list[str] = []
|
||||
|
||||
freshness_threshold_hours = _to_float(store_metadata.get("freshness_threshold_hours", 6.0), default=6.0)
|
||||
refresh_age = _to_float(store_metadata.get("refresh_age_hours", 0.0), default=0.0)
|
||||
feature_age = _to_float(store_metadata.get("feature_age_hours", refresh_age), default=refresh_age)
|
||||
risk_age = _to_float(store_metadata.get("risk_age_hours", refresh_age), default=refresh_age)
|
||||
|
||||
canonical_semantic_coverage = _to_float(store_metadata.get("canonical_semantic_coverage", 0.0), default=0.0)
|
||||
canonical_relation_types = int(store_metadata.get("canonical_relation_types", 0))
|
||||
canonical_links_total = int(store_metadata.get("canonical_links_total", 0))
|
||||
canonical_entities_total = int(store_metadata.get("canonical_entities_total", 0))
|
||||
|
||||
feature_ready = bool(store_metadata.get("feature_ready", False))
|
||||
risk_ready = bool(store_metadata.get("risk_ready", False))
|
||||
ranking_ready = bool(store_metadata.get("ranking_ready", False))
|
||||
aggregate_ready = bool(store_metadata.get("aggregate_ready", False))
|
||||
|
||||
freshness_ok = refresh_age <= freshness_threshold_hours
|
||||
if question_shape.freshness_sensitive and not freshness_ok:
|
||||
reason_codes.append("refresh_stale")
|
||||
|
||||
canonical_sufficient = (
|
||||
canonical_entities_total > 0
|
||||
and canonical_links_total > 0
|
||||
and canonical_semantic_coverage >= 0.85
|
||||
and canonical_relation_types >= 10
|
||||
and (freshness_ok or not question_shape.freshness_sensitive)
|
||||
)
|
||||
if not canonical_sufficient:
|
||||
reason_codes.append("canonical_not_sufficient")
|
||||
|
||||
feature_sufficient = feature_ready and (
|
||||
feature_age <= freshness_threshold_hours or not question_shape.freshness_sensitive
|
||||
)
|
||||
if not feature_sufficient and question_shape.needs_anomaly_summary:
|
||||
reason_codes.append("feature_not_sufficient")
|
||||
|
||||
risk_sufficient = risk_ready and (
|
||||
risk_age <= freshness_threshold_hours or not question_shape.freshness_sensitive
|
||||
)
|
||||
if not risk_sufficient and (question_shape.needs_anomaly_summary or question_shape.needs_ranking):
|
||||
reason_codes.append("risk_not_sufficient")
|
||||
|
||||
if question_shape.needs_ranking:
|
||||
if not ranking_ready:
|
||||
reason_codes.append("ranking_not_ready")
|
||||
aggregate_level_ok = aggregate_ready and (
|
||||
(not question_shape.needs_ranking or ranking_ready)
|
||||
) and question_shape.precomputed_aggregate_available
|
||||
if not aggregate_level_ok and (
|
||||
question_shape.needs_full_period_aggregation
|
||||
or question_shape.needs_ranking
|
||||
or question_shape.needs_anomaly_summary
|
||||
):
|
||||
reason_codes.append("aggregate_not_sufficient")
|
||||
|
||||
explanation_ready = (
|
||||
canonical_sufficient
|
||||
and canonical_semantic_coverage >= 0.90
|
||||
and canonical_relation_types >= 20
|
||||
and not question_shape.needs_runtime_truth
|
||||
and not (question_shape.needs_cross_entity_join and question_shape.needs_causal_chain)
|
||||
)
|
||||
if not explanation_ready and (question_shape.needs_causal_chain or question_shape.needs_cross_entity_join):
|
||||
reason_codes.append("explanation_not_ready")
|
||||
|
||||
return StoreSufficiencyResult(
|
||||
canonical_sufficient=canonical_sufficient,
|
||||
feature_sufficient=feature_sufficient,
|
||||
risk_sufficient=risk_sufficient,
|
||||
freshness_ok=freshness_ok,
|
||||
aggregate_level_ok=aggregate_level_ok,
|
||||
ranking_ready=ranking_ready,
|
||||
explanation_ready=explanation_ready,
|
||||
reason_codes=reason_codes,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user