Files
hyungi_document_server/app/policy/routing.py
T
Hyungi Ahn 99672292d3 fix(policy): use container-compatible imports (drop app. prefix)
프로덕션 컨테이너는 /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>
2026-04-24 09:42:24 +09:00

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,
)