AI 클라이언트
- 작성일: 2026-10-19
- 작성: 워커 D (Codex
gpt-6.1-sol, effort max), 브랜치feature/m3-ai-client, WBS 3.1 - 관련 문서: 시스템 구조와 데이터 모델 (12.1 라운드 3), WBS와 마일스톤
목표
WBS 3.1의 AI 클라이언트를 구현한다. 키가 없는 데모는 Fake로 실행하고, Anthropic 구현은 감사 판단 대신 구조화된 제안만 반환한다. 저장, 감사 로그, Oban 재시도와 판단 상태 전이는 다음 M3 3.2의 Triage 컨텍스트가 맡는다.
한 일
- AI 진입점:
client/0,config/0,triage/2, 결정적input_hash/1을 구현했다. 중첩 맵의 키를 JSON 문자열 기준으로 정렬하고, 배열 순서는 보존한다. Decimal과 날짜는 기존 Jason 인코더를 유지한다. - Client behaviour: 입력, 제안 결과,
rate_limited/retryable/fatal오류와triage/2콜백을 정의했다. - Anthropic: Req로 Messages API를 호출하고 설정의 모델, 토큰 한도, effort, API 키, 테스트 plug를 반영한다. Req 재시도는 끄고 응답 제한 시간은 120초로 고정했다. fallback은 설정을 켰을 때만 보낸다.
- Fake: 입력 해시의 첫 바이트를 3으로 나눈 나머지로 위험도를 정한다. 같은 입력에는 같은 제안, 토큰 0,
end_turn을 반환한다. - 프롬프트:
triage-v1버전, 한국어 시스템 지시문, 입력 JSON, 추가 필드를 금지하는 출력 스키마를 구현했다. 적요와 거래처의untrusted_text는 지시가 아닌 데이터라고 명시했다. - 테스트 4개 파일에 최초 27개, 후속 오탐 수정에서 2개를 추가했다. 진입점과 해시, Fake, Anthropic, 프롬프트를 검증한다.
결정과 트레이드오프
- Fake의
response_model은fake-triage-v1이다. 계약 7.2의fake와 도메인 4.2의 버전 표기가 달라 메인에 질문했고, 메인이 버전이 있는 값을 확정했다. 계약 문서는 메인이 고치며 이 브랜치에서는 변경하지 않았다. 요청 모델 설정도 덮어쓰지 않았다. - 입력 해시는 재귀 정렬 후
Jason.OrderedObject로 순서를 고정한 JSON의 SHA-256이다. 일반 맵의 순서나 Erlang 직렬화에 의존하는 대안은 감사 로그의 JSON 입력으로 재검증하기 어렵고 32키 초과 맵의 순서를 보장하지 않아 쓰지 않았다. - HTTP 상태를 먼저 분류하려고 Req의 자동 본문 디코딩을 끈다. 200만 JSON으로 읽으므로 HTML 형태의 5xx도 재시도 대상으로 남고, 잘못된 200 JSON은
fatal: invalid_output으로 거절한다. - 429의
retry-after는 양의 정수 초만 받는다. 누락, 음수, 소수, 잘못된 문자열은nil을 반환해 워커의 기본 대기 정책에 맡긴다. 클라이언트는 대기하거나 다시 호출하지 않는다. - 5xx, 전송 오류,
max_tokens는 재시도 대상으로, 4xx와refusal은 치명적 오류로 분류한다. 오류에 HTTP 본문이나 원본 예외를 붙이는 대신 상태 코드와 고정된 atom만 남겨 인증정보 노출을 막는다. 전송 오류의 상세 진단 정보는 줄어드는 선택이다. - 출력 검증은 Anthropic 경계에 둔다. 필수 키, 추가 키, 타입, enum, 절차 1-4개, 추가 정보 0-5개를 검사한다. 위험도는 고정된 맵으로 atom에 대응시키며 외부 문자열로 atom을 만들지 않는다.
- API 스키마에 지원하지 않는 길이 제약을 넣는 대신 설명과 프롬프트에 한도를 적고 앱에서 검증한다. 근거는 금지 표현 검사 후 600자로 자른다. 금지 표현이 잘리는 뒷부분에 있어도 거절한다. 배열 개수 초과와 잘못된 타입은 자르지 않고 거절한다.
- 금지 표현은 근거, 절차, 증빙, 추가 정보 전체에서 검사한다. Unicode 정규화 후 공백과 구두점을 제거하고 결론 단정, 승인 반려 의견, 부정 횡령 단정의 알려진 표현을 거절한다. 정상적인 "승인 이력" 요청은 허용한다.
- 오탐 수정:
없,필요/적절/타당을 접두어만으로 거절하던 검사를없음/없습니다/없다/없으므로,필요합니다/적절하다같은 단정형 검사로 좁혔다.다(?!면)으로 조건형을 구분하고대상/가능은 승인 의견 패턴에서 제외하여 "승인 대상 금액", "승인 가능 한도" 같은 감사절차를 허용한다. "확인한다"가 있으면 문장 전체를 허용하는 예외 대신 금지 패턴 자체를 좁혔으며, 명시적으로 금지된 "예외로 판단"과 기존 부정 횡령 단정 검사는 유지했다. - 스키마 검증 라이브러리 추가 대신 작은 출력 계약을 직접 검사했다. 의존성은 늘리지 않으면서 감사 업무의 금지 표현 검사와 실패 모양을 한곳에서 설명할 수 있다.
공식 문서 확인
2026-10-19에 다음 공식 문서를 확인했다. 테스트와 작업 중 실제 Messages API는 호출하지 않았다.
x-api-key와anthropic-version: 2023-06-01헤더, JSON 요청은 API overview에서 확인했다.messages의 user content와 응답의 text 블록,model,stop_reason,usage구조는 Using the Messages API에서 확인했다.output_config.format: {type: "json_schema", schema: ...}, text 블록의 JSON 응답, 객체의additionalProperties: false, 문자열 길이와 배열 상한의 제약은 Structured outputs에서 확인했다.output_config.effort와 Opus 5.5의 항상 켜진 adaptive thinking은 Effort에서 확인했다. 요청에thinking이나 assistant prefill을 보내지 않는다.fallbacks: "default",anthropic-beta: server-side-fallback-2026-07-01, 응답 최상위model, 거절에만 적용되는 fallback은 Refusals and fallback에서 확인했다.- 429, 500, 529 등 HTTP 오류 분류는 Claude API errors와 저장소 계약 5.5를 함께 확인했다.
막힌 점과 해결
- Fake 모델 ID의 문서 간 차이는 추측하지 않고 Orca
ask로 메인에 확인했다. 확정된fake-triage-v1을 구현과 테스트에 반영했다. - 전체 Messages API 레퍼런스는 웹 도구가 응답 크기 제한으로 읽지 못했다. 공식 API 개요, Messages 사용법, 구조화 출력, effort, fallback 문서에서 필요한 요청과 응답 필드를 나누어 확인했다.
- 최초 개별 시험은 27개가 통과했지만 Elixir 1.20의 타입 추론이 Fake 근거의
is_binary검사를 항상 참인 조건으로 경고했다. 이미 문자열 연산으로 검증하는 중복 조건을 제거했고, 이후 전체 검증에는 경고가 없었다. - 오탐 수정: 메인 검토에서 정상 절차의 "문제가 없는지", "승인이 적절한 권한자", "승인이 필요한 금액 기준"까지 거절되는 문제가 드러났다. 회귀 시험을 먼저 추가했을 때
Result: 15/17 passed,Failed: 2 tests로 재현했다. 패턴을 좁힌 뒤 같은 시험에서Result: 17 passed를 확인했다. 정상 사례를 빠뜨린 최초 검증을 보완하여 정상 문장 11개와 기존 금지 문장 15개를 모두 4개 출력 필드에서 대조한다.
검증
항상 MIX_TEST_PARTITION=5로 실행했다. Anthropic 시험은 Req.Test의 stub/expect/transport_error만 사용한다. 테스트 설정의 plug를 먼저 확인하므로 설정이 달라도 실 API로 넘어가지 않는다.
- 최초 개별 시험:
MIX_TEST_PARTITION=5 mix test test/tickmark/ai_test.exs test/tickmark/ai-Result: 27 passed. - 오탐 수정 개별 시험:
MIX_TEST_PARTITION=5 mix test test/tickmark/ai/anthropic_test.exs-Result: 17 passed. 정상 문장 11개 x 4필드, 기존 금지 문장 15개 x 4필드와 추가 단정형 5개 및 조건형 3개 x 4필드를 확인했다. - Fake의 결정성, 위험도 세 값, 스키마, 버전 모델 ID와 해시의 재귀 정렬, 40키 맵, Decimal, 날짜, 배열 순서를 확인했다.
- HTTP 200, 429의 헤더 유무와 잘못된 값, 400/401/403/404/413, 500/502/503/504/529, 전송 오류와 타임아웃을 확인했다.
max_tokens/refusal은 잘못된 JSON보다 먼저 분류한다. - 요청 모델, 토큰 한도, effort, 프롬프트, 구조화 출력, fallback opt-in, text 블록 선별, 응답 모델 보존을 확인했다. Req 자체 재시도가 꺼져 있는지도 expect 호출 횟수로 검증했다.
- 잘못된 스키마와 금지 표현, 600자 자르기 전 검사, API 키가 반사된 HTTP 본문과 TLS 오류에서도 반환값과 로그에 키가 없는 것을 확인했다.
- 최종 전체 검증:
MIX_TEST_PARTITION=5 mix precommit- 경고 0 컴파일, 미사용 의존성 정리, 포맷, 테스트 통과.mix.lock변경 없음. 실제 마지막 출력:
Finished in 4.4 seconds (4.3s async, 0.1s sync)
Result: 293 passed
Elixir 학습 포인트
@behaviour는 구현이 지켜야 할 함수와 결과 모양을 선언한다. 호출자는Tickmark.AI.triage/2만 쓰고 설정의 모듈이 Fake인지 Anthropic인지 몰라도 된다. 설정 선택은 실행 시점이라 테스트용 Fake와 데모용 Fake도 같은 인터페이스를 쓴다.Req.post/1은 HTTP 결과를 튜플로 반환한다.Req.Test는 같은 호출 경로의 HTTP 전송만 plug로 대체한다. expect와verify_on_exit!를 조합하면 요청 누락과 불필요한 중복 호출을 찾는다.- 함수 절의 패턴 매칭과 guard로 429, 5xx, 그 밖의 상태를 나눈다. 200 내부에서도
stop_reason을 먼저 맞추어 잘린 JSON과 거절을 일반 파싱 오류로 잃지 않는다. - 맵은 JSON 키 순서의 계약이 아니다. 재귀 정렬과
Jason.OrderedObject를 거쳐야 해시를 재현할 수 있다. struct는 맵의 한 종류이므로 Decimal과 Date를 일반 맵으로 펼치지 않도록 먼저 구분한다. - Anthropic 시험은 설정을 잠시 바꾸기 때문에 동기 실행하며
on_exit에서 원래 값을 돌린다. 다른 AI 시험은 설정을 바꾸지 않아 비동기로 실행한다. - 정규식의
(?!면)은 바로 다음 글자가 "면"이면 매칭하지 않는 부정 lookahead다. 앞의다와 함께 쓰면 "없다"는 차단하고 "없다면"은 허용한다. 키워드 존재와 실제 단정은 다르므로 허용 사례와 거절 사례를 같은 출력 경로에 넣어 경계를 시험한다.
다음 할 일과 확인 필요
- M3 3.2에서
Triage.build_input/1, 모델 ID와 프롬프트 버전 및 입력 해시 저장, Oban 재시도와 감사 로그를 연결한다. 이번 작업은 DB나 판단 상태를 바꾸지 않는다. - 확인 필요:
output_config.effort와format을 함께 지정한 요청의 실제 서버 수락, low effort의 분류 품질, 실제 fallback 모델과 토큰 사용량은 비용이 승인된 첫 실호출에서 확인해야 한다. 이번 결과는 모의 HTTP 검증이다. - 금지 표현 검사는 알려진 표현의 규칙 기반 검사다. 새 우회 표현이나 입력 사실과의 의미상 불일치 전체를 증명하지 않으므로 실제 출력 검토에서 발견한 표현은 프롬프트와 회귀 시험에 반영한다.
- 새 계약 변경 요청은 없다. 메인이 이미 확정한 계약 7.2의 Fake
response_model: "fake-triage-v1"정정과 docs 색인 갱신은 메인이 담당한다.
관련 커밋
e4c1cd1-feat(ai): 결정적 Fake와 Anthropic 트리아지 클라이언트 구현cd4497e-docs(ai): 클라이언트 설계 결정과 검증 결과 기록- 이 문서의 오탐 수정 기록을 포함하는
fix(ai)커밋.