99672292d3
프로덕션 컨테이너는 /app 을 cwd 로 실행하고 import 는 `from api...`, `from core...`, `from workers...` 처럼 무접두 스타일을 사용한다. PR-A 내부 import 가 `from app.policy...`, `from app.ai.envelope` 로 되어 있어서 컨테이너에서 ModuleNotFoundError 발생. 변경: - app/policy/*.py: `from app.policy.X` → `from policy.X` - app/services/prompt_versions.py: lazy import 도 `from policy.prompt_render` - app/ai/envelope.py: 영향 없음 (내부 import 없음) - tests/policy/*.py: 모두 `from policy.X` / `from ai.envelope` 로 통일 - tests/policy/conftest.py: 로컬 pytest 용 sys.path.insert(app/) 추가 (MacBook 에서 repo-root 기준 실행 시 app/ 를 package root 로 취급) CI: pytest tests/policy/ -q → 98 passed (로컬, 동일 결과) 프로덕션: docker exec fastapi python -c "from policy.loader import load_policy" → OK Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
179 lines
6.6 KiB
Python
179 lines
6.6 KiB
Python
"""Routing engine — 4B 출력 + 상황을 받아 26B 에스컬레이션 여부를 결정.
|
|
|
|
6 invariants (모두 deterministic, code-level HARD rules):
|
|
|
|
INV-1 self_declare_add_only
|
|
deterministic_high_impact=True AND self_declare=False → high_impact_task=True
|
|
(self_declare 는 ADD only; OFF 불가)
|
|
|
|
INV-2 risk_flag_requires_26b_forces_escalation
|
|
any(flag where policy.risk_flags[flag].requires_26b) → escalate=True
|
|
|
|
INV-3 context_cap_forces_escalation
|
|
content_chars > policy.escalation.context_char_cap_4b → escalate=True, reason="long_context"
|
|
|
|
INV-4 multi_doc_forces_escalation
|
|
evidence_doc_count >= policy.escalation.escalate_on_multi_doc_count
|
|
→ escalate=True, reason="multi_doc", add "multi_doc_dependency" to risk_flags
|
|
|
|
INV-5 risk_flags_union
|
|
final risk_flags = UNION(domain.default_risk_flags, self_declared, derived)
|
|
self_declared 는 ADD only; default 있어도 self 가 추가 flag 붙이면 합집합
|
|
|
|
INV-6 fallback_domain for unknown
|
|
subject_domain not in policy.subject_domains → use policy.fallback_domain
|
|
(routing 이 None/undefined 로 빠지는 edge case 0)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from typing import Iterable
|
|
|
|
from policy.loader import load_policy
|
|
from policy.schema import DomainPolicy, SubjectDomain, FallbackDomain
|
|
|
|
|
|
# --- Reason 문자열 상수 (tests 에서 참조) -----------------------------------
|
|
REASON_HIGH_IMPACT = "high_impact"
|
|
REASON_RISK_FLAG = "risk_flag_requires_26b"
|
|
REASON_LOW_CONFIDENCE = "low_confidence"
|
|
REASON_LONG_CONTEXT = "long_context"
|
|
REASON_MULTI_DOC = "multi_doc"
|
|
REASON_FALLBACK_DOMAIN = "fallback_domain"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RoutingDecision:
|
|
escalate_to_26b: bool
|
|
escalation_reasons: tuple[str, ...]
|
|
risk_flags: tuple[str, ...]
|
|
high_impact_task: bool
|
|
synthesis_directives: tuple[str, ...]
|
|
subject_domain_used: str # 실제 적용된 도메인 이름 (fallback 인 경우 fallback_domain.name)
|
|
used_fallback: bool = False
|
|
|
|
|
|
def _resolve_domain(
|
|
policy: DomainPolicy, subject_domain: str
|
|
) -> tuple[SubjectDomain | FallbackDomain, str, bool]:
|
|
"""INV-6 — 매칭 실패 시 fallback_domain."""
|
|
spec = policy.subject_domains.get(subject_domain)
|
|
if spec is not None:
|
|
return spec, subject_domain, False
|
|
return policy.fallback_domain, policy.fallback_domain.name, True
|
|
|
|
|
|
def decide_routing(
|
|
*,
|
|
subject_domain: str,
|
|
content_chars: int,
|
|
deterministic_keyword_hits: Iterable[str] = (),
|
|
self_declared_high_impact: bool = False,
|
|
self_declared_risk_flags: Iterable[str] = (),
|
|
confidence: float = 1.0,
|
|
evidence_doc_count: int = 0,
|
|
policy: DomainPolicy | None = None,
|
|
) -> RoutingDecision:
|
|
"""Pure function — yaml 과 입력만으로 결정론적 결과.
|
|
|
|
Parameters
|
|
----------
|
|
subject_domain: upstream (keyword/source_channel 매칭) 이 정한 도메인 이름.
|
|
content_chars: 4B 에 들어간 본문 문자 수.
|
|
deterministic_keyword_hits: upstream 의 keyword 매칭 결과 (비어있어도 domain.high_impact
|
|
가 True 면 INV 는 그대로 작동).
|
|
self_declared_high_impact: 4B 출력의 high_impact_self_declared 필드.
|
|
self_declared_risk_flags: 4B 출력의 risk_flags 자기선언.
|
|
confidence: 4B 출력의 confidence (0.0~1.0).
|
|
evidence_doc_count: /ask 경로 등에서 합성 대상 문서 수.
|
|
policy: 주입용 (테스트). None 이면 loader.load_policy().
|
|
"""
|
|
if policy is None:
|
|
policy = load_policy()
|
|
|
|
domain_spec, domain_name, used_fallback = _resolve_domain(policy, subject_domain)
|
|
|
|
reasons: list[str] = []
|
|
flags: set[str] = set()
|
|
|
|
# --- INV-1: high_impact (deterministic → self_declare 는 ADD only) -----
|
|
deterministic_high_impact = (
|
|
bool(list(deterministic_keyword_hits))
|
|
or domain_spec.high_impact
|
|
)
|
|
high_impact = deterministic_high_impact
|
|
if self_declared_high_impact:
|
|
high_impact = True # ADD only — False 로 되돌릴 수 없음
|
|
|
|
if high_impact:
|
|
reasons.append(REASON_HIGH_IMPACT)
|
|
|
|
# --- INV-5: risk_flags UNION merge -------------------------------------
|
|
# (a) domain 기본
|
|
flags.update(domain_spec.default_risk_flags)
|
|
# (b) 4B 자기선언 (ADD only)
|
|
flags.update(self_declared_risk_flags)
|
|
|
|
# --- INV-3: long_context (derived flag 추가 전에 판정) ----------------
|
|
if content_chars > policy.escalation.context_char_cap_4b:
|
|
reasons.append(REASON_LONG_CONTEXT)
|
|
|
|
# --- INV-4: multi_doc (derived flag 추가) -----------------------------
|
|
if evidence_doc_count >= policy.escalation.escalate_on_multi_doc_count:
|
|
reasons.append(REASON_MULTI_DOC)
|
|
flags.add("multi_doc_dependency")
|
|
|
|
# --- low_confidence (derived flag 추가) --------------------------------
|
|
if confidence < policy.escalation.confidence_threshold:
|
|
reasons.append(REASON_LOW_CONFIDENCE)
|
|
flags.add("low_confidence_reasoning")
|
|
|
|
# --- INV-2: risk_flag_requires_26b -------------------------------------
|
|
requires_26b_flag = any(
|
|
policy.risk_flags[f].requires_26b
|
|
for f in flags
|
|
if f in policy.risk_flags and policy.risk_flags[f].requires_26b
|
|
)
|
|
if requires_26b_flag:
|
|
reasons.append(REASON_RISK_FLAG)
|
|
|
|
# --- INV-6: fallback 사용 사실 기록 -----------------------------------
|
|
if used_fallback:
|
|
# 에스컬레이션 자체를 강제하진 않지만 visibility 위해 reason 에 추가
|
|
reasons.append(REASON_FALLBACK_DOMAIN)
|
|
|
|
# --- synthesis directives 수집 (26B 에 전달될 규칙) -------------------
|
|
directives: list[str] = []
|
|
for f in sorted(flags):
|
|
rf = policy.risk_flags.get(f)
|
|
if rf is not None and rf.synthesis_directive:
|
|
directives.append(rf.synthesis_directive)
|
|
|
|
# --- 최종 escalate 판정 ---------------------------------------------
|
|
escalate = (
|
|
high_impact
|
|
or requires_26b_flag
|
|
or content_chars > policy.escalation.context_char_cap_4b
|
|
or evidence_doc_count >= policy.escalation.escalate_on_multi_doc_count
|
|
or confidence < policy.escalation.confidence_threshold
|
|
)
|
|
|
|
# 중복 reason 제거 (순서 유지)
|
|
seen: set[str] = set()
|
|
dedup_reasons: list[str] = []
|
|
for r in reasons:
|
|
if r not in seen:
|
|
seen.add(r)
|
|
dedup_reasons.append(r)
|
|
|
|
return RoutingDecision(
|
|
escalate_to_26b=escalate,
|
|
escalation_reasons=tuple(dedup_reasons),
|
|
risk_flags=tuple(sorted(flags)),
|
|
high_impact_task=high_impact,
|
|
synthesis_directives=tuple(directives),
|
|
subject_domain_used=domain_name,
|
|
used_fallback=used_fallback,
|
|
)
|