Triage 요청과 워커
- 작성일: 2026-10-20
- 작성: W1 (Claude
claude-fable-5-1), 브랜치feature/m3-triage, WBS 3.2 - 관련 문서: 시스템 구조와 데이터 모델 (5.5.1 Triage, 4.2 감사 이벤트, 7.4 AI 입력), 분개 테스트 룰과 감사 용어 (4.1 트리아지 입력), 라운드 4 계약과 통합
목표
계약 5.5.1 의 Tickmark.Triage 컨텍스트와 Tickmark.Triage.TriageWorker 를 만든다. 사람이 finding 하나 또는 run 하나의 트리아지를 요청하면 Oban job 이 llm 큐에서 AI 클라이언트를 부르고, 제안을 ai_triages 에 저장한다. AI 는 제안만 하고 finding 의 status 는 건드리지 않는다. 모든 상태 변화는 감사 이벤트와 한 트랜잭션이고 브로드캐스트는 커밋 뒤에만 한다.
완료 조건은 MIX_TEST_PARTITION=2 mix precommit 통과와 mix.lock 무변경이다.
한 일
| 파일 | 역할 |
|---|---|
lib/tickmark/triage.ex |
request_finding_triage/2, request_triage/3, build_input/2, triage_finding/2, mark_failed/2, latest_triages/2 |
lib/tickmark/triage/triage_worker.ex |
llm 큐, max_attempts: 5, timeout 3분, backoff 10 * 2 ** attempt 초. triage_finding/1 결과를 job 결과로 바꾸는 handle_result/2 |
lib/tickmark/jobs/failure_handler.ex |
"Tickmark.Triage.TriageWorker" 분기 추가. 마지막 시도의 raise 와 timeout 을 mark_failed(id, :worker_crashed) 로 기록 |
test/tickmark/triage_test.exs |
23개 (sandbox 22개 + sandbox 밖 동시성 1개) |
test/tickmark/triage/triage_worker_test.exs |
9개 (반환표 5개, perform 2개, 옵션 1개, snooze 확인 1개) |
test/tickmark/jobs/failure_handler_test.exs |
2개 추가 (마지막 시도 / 재시도 예정) |
request_finding_triage/2 (계약 5.5.1 순서 그대로):
- id 형식 검사 -> finding 과 run 조회 ->
Engagements.authorize(scope, engagement_id, :request_triage). 셋 중 어디서 걸려도{:error, :forbidden}이라 존재 여부가 새지 않는다. - 트랜잭션: finding
FOR UPDATE->score < triage_threshold면:below_threshold-> 이미queued면:already_queued->triage_status_changeset(:queued)->Oban.insert->ai_triage.requested(actor user, payloadentry_number). - 커밋 뒤
{:finding_updated, %{finding_id, status, triage_status}}와audit_appended.
request_triage/3 (일괄):
- run 조회와 권한 확인.
- SELECT 로 후보를 고른다:
score >= triage_threshold,triage_status == :none, 정렬은 score desc,journal_entries.total_debitdesc,entry_numberasc, limit 은AI.config()[:max_findings_per_request](100).limit:옵션으로 바꿀 수 있다 (테스트용, 보고서에 기록). update_all이triage_status == :none을 다시 확인하며queued로 바꾸고 바뀐 id 만select로 돌려준다. 후보 순서를 유지해 Oban job 을insert_all하고, 1건 이상이면ai_triage.requested(subject test_run, payloadenqueued) 를Multi.merge로 붙인다.- 커밋 뒤
{:triage_requested, %{test_run_id, enqueued}}는 0건이어도 보내고, 이벤트가 있을 때만audit_appended를 보낸다. finding 별finding_updated는 보내지 않는다.
triage_finding/2 (워커 전용):
- finding (journal_entry 와 lines, test_run 과 engagement) 과
rule_results를 읽는다.queued가 아니면{:ok, :noop}, API 호출 없음. build_input/2->AI.input_hash/1->(finding_id, prompt_version, model, input_hash)로 기존 행을 찾는다. 있으면 API 호출 없이 재사용한다.- 없으면 트랜잭션 밖에서
client.triage(input)을 부르고System.monotonic_time/1로latency_ms를 잰다.{:error, e}는 DB 를 건드리지 않고 그대로 돌려준다. - 트랜잭션: finding
FOR UPDATE->queued아니면:noop->ai_triagesinsert (on_conflict: :nothing, 4컬럼 conflict_target, 충돌이면 키로 다시 읽어reused: true) ->:succeeded->ai_triage.completed(actor{:agent, "triage:" <> response_model}, payloadai_triage_id,risk_level,reused,entry_number). - 커밋 뒤
finding_updated,audit_appended.
mark_failed/2: FOR UPDATE -> queued 일 때만 :failed 와 ai_triage.failed (actor {:system, "triage.triage_worker"}, payload reason, entry_number). 아니면 아무것도 쓰지 않는다. 항상 :ok. reason 표는 계약 5.5.1 그대로다 (rate_limited, transient, invalid_output, refusal, not_configured, request_rejected, worker_crashed).
latest_triages/2: DISTINCT ON (finding_id) 에 inserted_at desc, id desc 로 finding 당 최신 1건. members 를 조인해 scope 사용자가 속한 engagement 의 finding 만 돌려준다. 형식이 틀린 id 는 건너뛴다. 쿼리 1개.
TriageWorker.handle_result/2 반환표:
triage_finding/1 결과 |
job 상태 | 반환 |
|---|---|---|
{:ok, _} |
:ok |
|
{:error, {:rate_limited, s}} |
meta["snoozed"] < 10 |
`{:snooze, s |
{:error, {:rate_limited, _}} |
snoozed 10 이상, 마지막 시도 아님 | {:error, :rate_limited} |
{:error, {:rate_limited, _}} |
snoozed 10 이상, 마지막 시도 | mark_failed 뒤 {:cancel, :rate_limited} |
{:error, {:retryable, r}} |
마지막 시도 아님 | {:error, r} |
{:error, {:retryable, r}} |
마지막 시도 (attempt >= max_attempts) |
mark_failed 뒤 {:cancel, r} |
{:error, {:fatal, r}} |
mark_failed 뒤 {:cancel, r} |
결정과 트레이드오프
입력자의 모집단 내 전표 수는 넣지 않는다 (코디네이터 결정)
- 도메인 4.1 의
context는 금액 대 수행중요성 비율, 계정쌍 모집단 빈도, 입력자의 모집단 내 전표 수 세 가지를 든다. 앞의 둘은 finding 과rule_results.details로 만들 수 있지만 입력자 전표 수는 어느 룰도 details 에 남기지 않아 별도 쿼리가 필요했다. orca orchestration ask로 물었고 답은 "omit" 이다. 이유: (1) 합성 데이터의 입력자는 acct01-05 와 sys_batch 뿐이고 입력자 빈도 이상 주입이 없어 신호가 없다. (2) 모집단 통계는 룰 단계에서 계산해 details 에 남기는 것이 현재 설계다. triage 에서 따로 세면 S5 에 보이는 근거에 없는 값이 AI 입력에 들어간다. (3) 계약 시그니처를 바꾸지 않는다.- 그래서
build_input/2는 finding (preload) 과 rule_results 만 받는 순수 함수다.context는entry_amount_to_materiality(총차변 / 수행중요성, 소수 4자리 문자열) 와account_pair_population(rare_account_pair details 의population_count와rare_pairs복사) 두 키다. rare_account_pair 가 걸리지 않은 전표는 그 키를null로 두어 context 모양을 고정한다. 도메인 4.1 과 계약 7.4 는 메인이 고친다.
반환표 테스트를 위해 handle_result/2 를 공개하고 client: 옵션을 둔다
- 테스트 환경의 AI 클라이언트는 Fake 고정이고 테스트 중 Application env 를 바꾸지 않는다는 규칙이 있다. 그러면 rate limit, transient, fatal 경로를
perform/1로는 만들 수 없다. - 선택:
perform/1은triage_finding/1결과를handle_result(result, job)에 넘기기만 하고, 반환표는handle_result/2를Oban.Testing.build_job/3으로 만든 job (attempt, max_attempts, meta) 과 함께 직접 부른다.triage_finding/2의client:옵션은 테스트 파일 안의ErrorClient(behaviour 구현, 항상{:retryable, :transport_error}) 로 "오류면 DB 무변경" 과 "재사용이면 API 호출 없음" 을 증명한다.ErrorClient.model/0은 Fake 와 같은 id 를 돌려줘 저장된 Fake 제안의 키와 맞는다. - 대안:
Mox추가. 의존성 변경은 메인 소유라 피했고, behaviour 하나에 구현 둘이면 함수 인자로 충분하다. 운영 코드는client:를 넘기지 않는다.
일괄 요청의 중복 방지는 SELECT 뒤 조건부 UPDATE
- 두 사람이 같은 run 에 동시에 일괄 요청하면 둘 다 같은 후보를 SELECT 한다.
update_all의 WHERE 에triage_status == :none을 다시 넣으면 PostgreSQL READ COMMITTED 에서 행 락을 기다린 UPDATE 가 커밋된 행으로 WHERE 를 다시 평가하므로, 먼저 커밋한 쪽이 가져간 finding 은 두 번째 쪽에서 0행이 된다. 돌아온 id 만 job 으로 만든다. - 후보 SELECT 의 순서를 유지하려고
update_all결과를 MapSet 으로 걸러 후보 순서대로 job 을 넣는다. llm 큐 동시성이 2 라 점수 높은 전표가 먼저 돈다. - 대안: 후보마다
FOR UPDATE SKIP LOCKED. 더 정확하지만 finding 100건을 한 건씩 잠그는 코드가 길어지고 설명이 어렵다. 계약이 정한 두 문장 방식이 충분하다. - 이벤트는 1건 이상일 때만 남기므로
Multi.merge로finding_ids결과를 보고Oban.insert_all과AuditTrail.append를 붙이거나 비운다.
재사용 판정은 트랜잭션 밖 조회 + on_conflict: :nothing
- API 호출 전에 4컬럼 키로 기존 행을 찾아 있으면 호출을 건너뛴다 (비용 절약이 목적이라 트랜잭션 밖 조회로 충분하다).
- 호출 뒤 저장할 때 같은 키가 그 사이 생겼을 수 있다 (같은 finding 의 job 이 둘 돌았을 때).
on_conflict: :nothing으로 넣고 키로 다시 읽어 id 가 다르면reused: true로 기록한다.binary_id는 애플리케이션이 미리 만들므로 충돌 시insert가 돌려주는 struct 의 id 는 DB 에 없는 값이고, 그래서 다시 읽어야 한다.
락은 자식 행보다 먼저
ai_triagesinsert 는 FK 검사로 finding 행에FOR KEY SHARE를 잡고 이것은FOR UPDATE와 충돌한다 (라운드 3 의 교착과 같은 모양). 그래서 모든 경로가 findingFOR UPDATE-> 자식 행 순서다. finding 만 잠그려고journal_entry는 조인이 아니라 별도 preload 로 읽는다.
snooze 상한은 meta["snoozed"]
- Oban 2.24 Basic 엔진의
snooze_job은state: scheduled,attempt - 1,meta.snoozed + 1을 한 UPDATE 로 한다. 즉 snooze 는 시도 횟수를 소진하지 않고, 무한히 snooze 될 수 있다. 그래서 계약대로meta["snoozed"]10 을 상한으로 두고 그 뒤는 retryable 처럼 취급한다. 테스트가 엔진 함수를 직접 불러 세 값을 고정한다 (아래 학습 포인트).
그 밖의 결정
mark_failed/2는 실제로 상태가 바뀔 때만Logger.error를 남긴다. 멱등 호출 (워커 뒤에 FailureHandler 가 또 부르는 경우) 은 조용히:ok다. payload 에는 분류된 reason 문자열만 들어가고 원본 오류는 로그에만 남는다.- timeout 3분은 클라이언트
receive_timeout120초보다 길다. 느린 응답은 Oban 이 프로세스를 죽이는 대신 클라이언트 오류로 돌아와 반환표를 탄다. backoff 20, 40, 80, 160초로 다섯 번 시도하면 약 5분 안에 끝난다. call_and_complete에서 changeset 이 invalid 면{:error, {:fatal, :invalid_output}}으로 돌려준다. 클라이언트가 behaviour 계약 (절차 1-4개 등) 을 어겼을 때MatchError로 죽는 대신invalid_output으로 기록되게 하는 방어선이다.request_triage/3는 run 의 status 를 보지 않는다. 계약의 "실행 중인 run 은 0건" 은 실행 중에는 finding 이 없다는 사실에 기댄다.
막힌 점과 해결
build_input/2의 "입력자 전표 수" 가 details 어디에도 없어 쿼리를 추가할지 물어야 했다. 백그라운드로 ask 를 띄워 두고 다른 읽기를 계속했고, 답 (omit) 이 온 뒤 구현을 시작해 기다리는 시간을 없앴다.- 동시성 테스트는 감사 이벤트를 남기면 뒷정리를 못 한다 (
audit_events는 트리거로 UPDATE/DELETE 금지, engagement FK 는 restrict). 그래서 "경쟁에서 진 쪽" 만 시험한다. 테스트 프로세스가 트랜잭션 안에서update_all로 finding 두 건을queued로 바꾸고 (이벤트 없음) 커밋하지 않은 채, Task 가 자기 연결로request_triage/3를 부른다. Task 의 SELECT 는 아직none을 보고 UPDATE 에서 행 락을 기다린다 (refute_receive :triage_requested, 500). 커밋하면 UPDATE 가 WHERE 를 다시 평가해 0행이고, 결과는enqueued: 0, job 없음, 이벤트 없음이다. "이긴 쪽" 의 job 과 이벤트는 sandbox 안의 순차 두 번 요청 테스트 (4건 뒤 0건) 가 확인한다. Decimal.round(4)는 0.01 을0.0100으로 만든다. 자릿수가 고정되는 편이 해시 안정성에 좋아 그대로 두고 테스트 기대값을 맞췄다.
검증
실제 출력 (마지막 줄):
$ MIX_TEST_PARTITION=2 mix precommit
Finished in 7.1 seconds (5.8s async, 1.2s sync)
Result: 423 passed
- 이번에 추가한 34개 (triage 23, triage_worker 9, failure_handler 2) 를 포함한다.
- 컴파일 경고 0,
mix.lock무변경 (git status에 없음),IO.inspect/dbg없음. - 동시성 테스트만 5번 반복:
Result: 1 passed, 22 excludedx 5. - 테스트 뒤
tickmark_test2의users,engagements,audit_events,findings,oban_jobs행 수 모두 0.
Elixir 학습 포인트
- Oban snooze 는 시도를 소진하지 않는다:
deps/oban/lib/oban/engines/basic.ex의snooze_job은state = scheduled,scheduled_at = now + seconds,inc: [attempt: -1],meta = jsonb_set(meta, '{snoozed}', coalesce(snoozed, 0) + 1)을 한 번에 한다. 테스트는testing: :manual로 DB 에 들어간 job 을executing,attempt: 1,attempted_at: now로 바꾼 뒤Oban.Engine.snooze_job(Oban.config(), job, 30)을 부르고state == "scheduled",attempt == 0,meta == %{"snoozed" => 1}을 확인한다.attempted_at을 같이 맞춰야 하는 이유는 엔진의 ack 쿼리가id,state in ["executing"],attempted_at세 조건으로 "내가 잡은 그 실행" 인지 확인하기 때문이다. Ecto.Multi.merge/2: 앞 단계 결과를 보고 뒤 단계를 붙일지 정할 때 쓴다. 넘긴 함수가 changes 를 받아 Multi 를 돌려주고, 빈Multi.new()를 돌려주면 아무것도 안 한다.request_triage/3가 0건이면 job 도 이벤트도 없이 커밋한다.- Multi 안의
Oban.insert/3:Oban.insert(multi, :job, fn changes -> changeset end)는 앞 단계 (갱신된 finding) 를 받아 job changeset 을 만든다.Oban.insert_all(multi, :jobs, list)는Multi.run으로 감싸져{:ok, jobs}를 changes 에 넣는다. on_conflict: :nothing과 미리 만든 id:@primary_key {:id, :binary_id, autogenerate: true}는 insert 전에 애플리케이션이 UUID 를 만든다. 충돌로 아무것도 안 넣어도{:ok, struct}가 돌아오고 그 id 는 DB 에 없다. 그래서 "정말 들어갔는지" 는 키로 다시 읽어 id 를 비교한다.DISTINCT ON을 Ecto 로:distinct: t.finding_id에order_by: [desc: t.inserted_at, desc: t.id]를 주면 Ecto 가ORDER BY t.finding_id, t.inserted_at DESC, t.id DESC로 바꿔 finding 당 첫 행 (최신) 만 남긴다. 쿼리 한 번으로 "각 finding 의 최신 제안" 을 가져온다.- READ COMMITTED 의 UPDATE 재평가: 행 락을 기다리던 UPDATE 는 락이 풀리면 상대가 커밋한 새 행으로 WHERE 를 다시 평가한다.
triage_status == :none을 WHERE 에 두는 것만으로 "먼저 가져간 쪽이 이긴다" 가 된다.update_all의set:은 스키마 타입으로 값을 dump 하므로Ecto.Enum필드에:queued원자를 그대로 쓸 수 있고,updated_at은 자동으로 갱신되지 않아 직접 넣는다. with의 else 생략:with {:ok, result} <- client.triage(input, []) do ... end에서 패턴이 안 맞으면 그 값 ({:error, e}) 이 그대로 반환값이 된다. "오류는 손대지 않고 그대로 돌려준다" 를 한 줄로 쓴다.- behaviour 로 클라이언트 주입:
Tickmark.AI.Client를 구현한 모듈을 함수 인자로 받으면 Application env 를 건드리지 않고 실패 경로를 시험할 수 있다.client.model(),client.triage(input, [])처럼 변수에 든 모듈로 함수를 부르는 것이 Elixir 의 다형성이다.
다음 할 일
- 메인: 도메인 4.1 과 계약 7.4 의
context설명을 이 문서의 결정대로 고친다 (입력자 전표 수 제외,account_pair_populationnull 허용). 계약 5.5.1 의 "확인 필요 (snooze)" 를 확인 완료로 바꾼다. - 화면 (W3, S5):
request_finding_triage/2와request_triage/3를 버튼에 연결하고,latest_triages/2로 목록에 최신 제안을 그린다.:finding_updated의triage_status와:triage_requested의enqueued로 화면을 갱신한다. - 실제 Anthropic 클라이언트로 한 번 돌려
latency_ms,stop_reason, 토큰 수가 들어오는지 본다 (이번 라운드는 키 없이 Fake 만 돌렸다). max_findings_per_request기본값 100 은 설정값이라 테스트가 없다. 필요하면 config 를 읽는 테스트 하나를 추가한다.
관련 커밋
8d1f488feat(triage): 트리아지 요청, 실행, 실패 기록 컨텍스트와 워커 구현044d1d0feat(jobs): FailureHandler 에 TriageWorker 분기 추가- 이 문서: docs(devlog): Triage 요청과 워커 기록