04번 문서의 그래프는 "분류 → 검색" 2단계뿐이었다. 이 문서는 그 위에 실제 tool-calling(함수 호출) 노드를 하나 추가하고, 그 결과 전체 파이프라인의 RAG 품질을 RAGAS로 실측한 과정을 다루다.

1. app/graph/tools.py — 규칙 기반 등급을 "도구"로 노출

ASSESS_THREAT_LEVEL_DECLARATION = genai.protos.FunctionDeclaration(
    name="assess_threat_level",
    description="표적 유형, 고도(m), 속도(km/h)를 입력받아 규칙 기반 위협 등급...",
    parameters=genai.protos.Schema(...),
)

def assess_threat_level(target_type: str, altitude: float, speed: float) -> str:
    if target_type.upper() == "MISSILE":
        return "CRITICAL"
    ...

assess_threat_level 자체는 target-tracking-service(Java)의 ThreatAnalysisService가 쓰는 것과 완전히 같은 규칙 테이블이다. 이걸 프롬프트 문자열에 규칙을 나열해서 "이 규칙대로 판단해"라고 시키는 대신, Gemini의 function-calling API에 실제 Python 함수로 등록했다. 차이가 크다:

2. call_with_tools — 모델이 실제로 결정한다

def call_with_tools(prompt: str) -> dict:
    response = _get_tool_model().generate_content(prompt)
    part = response.candidates[0].content.parts[0]
    function_call = getattr(part, "function_call", None)

    if function_call and function_call.name == "assess_threat_level":
        args = dict(function_call.args)
        result = assess_threat_level(**...)
        return {"tool_called": True, ...}
    return {"tool_called": False, ...}

_get_tool_model()은 _get_chat_model()과 별도의 GenerativeModel 인스턴스다 — tools=[THREAT_ASSESSMENT_TOOL]을 생성자에 넘겨야 하고 이 바인딩은 인스턴스 생성 시점에 고정되기 때문에, 도구가 필요 없는 일반 스트리밍 생성(generate_stream)과는 모델 객체를 분리했다.

핵심은 if function_call and ...다. 이 함수를 호출한다고 도구가 항상 실행되는 게 아니다 — Gemini가 입력 프롬프트를 보고 "이 질문은 정량 평가가 필요 없다"고 판단하면 function_call이 None이고 그냥 아무 결과 없이 넘어간다. 실제로 배포 후 확인해보니:

같은 그래프, 같은 노드인데 질문에 따라 실행 경로가 달라진다 — 그래프가 강제로 분기하는 게 아니라 모델의 판단이 실행을 좌우한다는 뜻이고, 이게 "hardcoded branch"와 "genuine tool-calling"의 차이다.

3. 그래프에 노드를 붙인 위치

graph.add_edge("pattern_search", "assess_threat")
graph.add_edge("assess_threat", END)
graph.add_edge("doc_rag", END)

assess_threat 노드는 pattern_search 브랜치에만 붙였다. doc_rag(교리 문서 검색)에는 "표적의 위협 등급"이라는 개념 자체가 적용이 안 되기 때문이다.

4. 이 과정에서 발견한 별개의 프로덕션 버그

① pattern_search의 metadata에 altitude/speed가 안 들어있었다. 값 자체는 이미 저장되어 있었지만 search_patterns()가 리턴하는 metadata를 조립할 때 빠뜨린 것.

② gemini-flash-latest가 이 계정에서는 gemini-3.6-flash로 연결되는데, 무료 tier 일일 한도가 20회뿐이었다.

5. RAGAS 평가 — 그리고 그 과정에서 잡아낸 진짜 장애