Skip to content

Repository files navigation

SphinX · 스FIN크스

금융상품 계약 직전에 서는 판매 게이트. 고객이 이해했는지를 재고, 이해가 서지 않으면 계약으로 넘어가지 않습니다.

2026 금융 AI Challenge · 4인 · 3주


무엇을 하는가

서명란에 이름을 받는 것과 고객이 이해했는지 확인하는 것은 다릅니다. 불완전판매 분쟁에서 지금 남는 증거는 대개 앞쪽입니다.

그래서 계약 직전에 한 단계를 넣습니다.

상품설명서 PDF          위험항목을 추출한다 (조건 문면 + 원문 페이지·오프셋)
      ↓
질문 생성              항목마다 매번 새로 만든다 — 고정 문항이면 사전에 확보된다
      ↓
고객이 자기 말로 답      "원금은 지켜지죠" · "낙인이요? 그게 무슨 말이에요?"
      ↓
채점                   등급(U1~U4) + 근거 발화 인용 + 루브릭 조항 + 신뢰도
      ↓
게이트 판정             통과 · 보완 필요 · 보류      ← 룰이 정한다. 모델이 아니다
      ↓
불변 기록              해시 체인에 append-only. 교부 문서를 그 기록에서 재생한다

두 가지를 갈라 둡니다

AI    이 발화가 항목을 이해한 것인가      →  측정값 (등급 · 근거 인용 · 신뢰도)
룰    이 측정값으로 계약을 열어도 되나     →  판정  (통과 · 보완 · 보류)

모델이 판정하지 않습니다. 게이트 판정은 선언적 룰 파일(gate_rules.yaml)이 만들고 모델 출력은 그 룰의 입력입니다. 그래서 "왜 막혔는가" 에 발화한 룰 이름으로 답할 수 있고(GateResult.ruleTrace), 같은 발화가 어제와 오늘 다른 등급을 받으면 그건 고칠 버그입니다.

근거 없는 판정은 아예 만들어지지 않습니다. 발화 인용과 루브릭 조항이 비면 생성자가 예외를 던집니다 — 판정 객체가 존재할 수 없습니다.

throw new EvidenceRequiredException("근거 없는 판정은 무효 (P4): evidence(발화 인용·루브릭 조항) 필수");

이 문장들은 코드가 강제합니다

원칙을 문서에만 적으면 다음 사람이 모르고 어깁니다. 그래서 어기는 변경이 빨강이 되게 해 뒀습니다.

무엇을 지키나 무엇이 잡나
모든 응답이 공통 봉투를 쓴다 EnvelopeContractTest
에러 코드 여섯 벌(핸들러·OpenAPI·CLAUDE.md·프론트 유니온·프론트 문면 표·명세서 §9)이 같다 ErrorCodeContractTest
모든 엔드포인트가 권한 action 에 속한다 AccessControlWiringTest
계약(OpenAPI)과 실물 권한이 양방향으로 맞는다 OpenApiPermissionSyncTest
개방 모드에서 주입되는 계정이 그 엔드포인트를 실제로 만족한다 DemoModeAccountMapTest
엔티티가 요구하는 테이블이 마이그레이션에 있다 SchemaMirrorsEntitiesTest
상품 실명이 응답으로 나가지 않는다 ProductDisplayNameTest
core/ 루트에 클래스가 늘지 않는다 CorePackageBoundaryTest

테스트 코드가 구현 코드와 비슷한 규모입니다.

절대 줄 수를 적지 않는 이유는 그 수가 세는 규칙에 따라 문장을 뒤집기 때문입니다. 선언적 룰 파일(gate_rules.yaml·rbac_policy.yaml)은 판정과 권한을 실제로 만드는 자리라 성격상 구현인데 코드 확장자로 세면 빠지고, 넣으면 문장이 반대가 됩니다. 채점 성능 평가(eval/)를 범위에 넣는지에 따라서도 갈립니다. 규칙을 고르면 어느 쪽으로든 참이 되는 수라서, 이 문단은 규모를 성질로만 말합니다. 검증은 위 표가 받습니다. 표의 테스트 이름은 커밋마다 낡지 않습니다.

조용히 깨지는 자리를 그물로 잡습니다

위 목록은 취향이 아니라 실제로 당한 것들입니다. 공통점은 하나입니다 — CI 가 초록인 채로 지나간다.

권한 정책이 네 층인데 대조가 세 층까지였습니다. 정책·컨트롤러·계약이 서로 맞고 CI 도 초록인데 배포에서만 403 이 났습니다. 개방 모드에서 nginx 가 경로별로 계정을 주입하는 층이 대조 밖이었습니다.

엔드포인트 → @PreAuthorize 의 action → nginx 가 실어 주는 계정
           → 그 계정의 역할 → 정책의 그랜트에 그 역할이 있는가

엔티티를 고치고 마이그레이션을 안 내면 로컬에서는 아무 신호가 없습니다. 로컬은 ddl-auto: update 라 알아서 만들어 주고, 배포는 validate 라 기동을 거부합니다. 로컬만 보면 초록입니다.

머지 가드와 리뷰 라벨이 같은 판정을 각자 구현하고 있었습니다. 한쪽만 고치면 가드는 머지를 막는데 라벨은 비어 있는 상태가 됩니다. 이 어긋남으로 네 번 깨진 뒤 한 파일이 사실을 한 번만 만들게 합쳤습니다 — 두 표현이 갈릴 자리가 구조적으로 없어졌습니다.

설문 선택지 문면이 바뀌었는데 채점 픽스처가 열흘 동안 죽은 문면을 들고 있었습니다. 문항 ID 도 문항 텍스트도 안 바뀌었으니 기존 대조 셋이 전부 초록이었습니다 — 선택지는 아무도 안 봤습니다.

그래서 리뷰에서 이렇게 합니다.

  • 고친 자리에 변이를 넣어 그물이 실제로 우는지 확인한다
  • 0건을 검사하고도 통과하는 그물을 따로 잡는다 — 검사가 눈을 감은 채 초록인 것이 검사가 없는 것보다 나쁘다
  • "그럴 것이다" 는 근거가 아니다. 숫자를 들면 재현 경로를 같이 든다

역이용을 코드가 막습니다

집계된 이해도 데이터는 "설득하기 쉬운 고객" 목록이 될 수 있습니다. 그래서 그것을 소비할 수 있는 역할을 아예 만들지 않았습니다(ADR-001).

권한을 주지 않는 것과 줄 수 있는 대상이 없는 것은 운영 압박이 들어왔을 때 다르게 작동합니다. 역할이 없으면 부여하려면 코드를 고쳐야 하고, 그 변경은 PR 에 남습니다.

같은 이유로 채점 기준(루브릭)은 판매 직원에게 열지 않습니다. 공개 의무가 요구하는 것은 "기준이 문서로 존재하고 감사·심사가 볼 수 있다" 이지 "판매자가 세션 중에 본다" 가 아닙니다.

왜 그렇게 했는지가 남아 있습니다

  • docs/decision-log.md — 배선·계약 결정이 전수로 모여 있습니다. 자기 영역을 고치기 전에 해당 절을 먼저 읽습니다
  • docs/adr/ — 원칙급 결정 8건. 결정이 바뀌면 새 기록을 얹고 기존 문서는 상태만 갱신합니다. 삭제·수정하지 않습니다

레포 구조와 소유권

디렉토리 소유자 내용
contracts/ 강희진 인터페이스 계약(JSON Schema, OpenAPI). 소유자 승인 없이 변경 금지
server/ (Spring Boot) 강희진(주)·정세현 API·세션 상태머신·게이트 룰엔진·PII 게이트웨이(강희진), 시뮬레이터·집계·증거계층(정세현)
server/.../evidence/ 정세현 정규화 직렬화·해시 체인·append-only 저장 위에 리포트(F-GTE-004)·감사 로그(F-CMN-002)
server/.../security/ 정세현·강희진 역할·정책(정세현) / 필터체인·어노테이션 부착(강희진) — 경계는 AccessPolicy 주석
ai-service/ (FastAPI) 윤지석(주)·정세현 LLM 파이프라인: 추출·질문생성·채점·오해/모순·재설명(윤지석), PDF 파서(정세현)
web/ 오준서 프론트엔드 전체 (S-01 ~ S-08)
data/ 정세현 지수 시계열, 오해 라이브러리, 수집 문서 — 전부 git 추적(#30). CI 가 checkout 만으로 테스트를 돌릴 수 있는 근거다
eval/ 정세현 채점 성능 평가 파이프라인 (라벨링: 정세현+강희진 — 조건은 eval/labeling/guideline.md §5)
docs/ 정세현 기획서·기능명세서·역할분담표·adr/(설계 결정 기록)

개발 규칙

  1. main 브랜치 보호. 작업은 feat/<기능ID>-설명 브랜치 → PR → 해당 디렉토리 소유자 리뷰 후 머지.
  2. contracts/ 변경은 강희진 승인 + 영향받는 사람 전원 멘션.
  3. AI는 측정, 룰은 결정: app/ai의 출력이 게이트 판정·금액 계산에 직접 쓰이면 안 된다 (명세서 P1).
  4. 고객 텍스트가 ai-service로 나가는 유일한 경로는 Spring의 PiiGateway.mask()AiServiceClient (P3). ai-service는 내부망 전용 — 브라우저에 직접 노출 금지.
  5. 시뮬레이터·게이트는 순수 함수 + 단위 테스트 필수 (P2).
  6. 해시·직렬화는 evidence/CanonicalJson·HashChain만 쓴다. 리포트와 감사 로그의 해시가 교차 검증돼야 하므로 여기서 갈라지면 안 된다.
  7. 권한 규칙은 rbac_policy.yaml이 유일한 근거(정세현). 컨트롤러 어노테이션(강희진)은 action 이름만 참조하고 규칙을 하드코딩하지 않는다. 감사 로그는 컨트롤러가 아니라 AuditInterceptor가 남긴다.
  8. 역이용 방지(기획서 7-4)는 역할 부재 + 범위 분리 두 층이다 (명세서 0.4, ADR-001). 본부 영업·마케팅 역할을 만들지 않고, 집계(aggregate:*)는 COMPL(전체)·MGR(자기 지점)만 접근한다. Role에 역할을 추가하거나 aggregate:*에 역할을 붙이는 PR은 기획서 7-4 재검토 대상이다.
  9. 설계 결정은 docs/adr/에 남긴다. 결정이 바뀌면 새 ADR을 추가하고 기존 문서는 상태만 갱신한다 — 삭제·수정 금지.

개발 환경

CI 가 고정하는 값이다(.github/workflows/ci.yml). 로컬도 같은 버전을 쓰면 "러너에서만 깨진다"가 없어진다.

모듈 런타임 고정 위치
server/ JDK 17 build.gradletoolchain { languageVersion = 17 } — 여기가 원본이고 CI 는 따라간다. 올리려면 둘을 함께 올린다
ai-service/ Python 3.11 requirements.txt 에 상한이 없어 런타임을 따로 고정한다. pdfplumber·reportlab 이 새 파이썬에서 휠이 늦다
web/ Node 20 package.jsonengines 가 없다. 의존성 설치는 npm ci(락파일 준수)

macOS 함정 — Homebrew openjdk@17 은 keg-only 라 Gradle 툴체인 자동 탐지가 실패한다. JAVA_HOME=/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home 을 걸고 돌린다.

테스트

cd server     && ./gradlew test        # JUnit
cd ai-service && pip install -r requirements-dev.txt && pytest
cd web        && npm ci && npx tsc --noEmit

세 명령이 PR 에서 자동으로 돈다. CI 는 skip 을 실패로 본다data/ 가 전부 커밋돼 있으므로 러너에서 건너뛴 테스트는 "검산이 안 돌았다"는 뜻이고, 그런데도 로그는 초록으로 남기 때문이다(이슈 #73). 로컬에서 skip 이 나오면 데이터 파일이 없는 것이니 python3 scripts/fetch_timeseries.py 부터 확인한다.

실행

# backend (Spring Boot, :8000)
cd server && ./gradlew bootRun
# ai-service (FastAPI, :8100 — 내부 전용)
cd ai-service && pip install -r requirements.txt && uvicorn app.main:app --port 8100 --reload
# frontend (:5173 → /api 프록시 → :8000)
cd web && npm install && npm run dev

서버는 계약(contracts/) 기준의 목 응답을 반환하도록 초기화돼 있어, 프론트는 첫날부터 실제 엔드포인트로 개발 가능.

화면을 눌러 보려면 — / 에서 세션을 만든다

화면 8개 중 5개가 세션ID를 경로에 받는다(/interview/:sid 등). 주소만으로는 못 닿으므로 S-02(/)에서 세션을 하나 만들고, 거기서 이어지는 링크로 들어간다. 세션 없이 열리는 것은 S-01·S-02·S-08 셋뿐이다.

처음 여는 사람은 사용 가이드(/guide) 를 먼저 본다 — 화면마다 무엇을 하는 자리인지를 캡처와 같이 적어 뒀다. 들어가는 링크는 S-02 제목 아래 한 개뿐이다. 브랜드 바에 달지 않는 이유는 그게 전 화면에 뜨고 그중 S-03은 고객이 답변 도중이라, 습관적으로 눌렀다가 세션 밖으로 나가는 사고가 정확히 BrandBar가 로고에 링크를 안 거는 이유이기 때문이다.

심사·시연용 화면 목차(S-00, /admin) 는 지웠다. 가이드가 「어느 화면이 무엇을 하는가」를 대신 말하고, 목차가 하던 나머지 일(세션ID를 들고 다섯 화면의 링크를 채우는 것)은 제품 흐름의 링크가 이미 한다. 목차는 권한을 판단하지 않는 화면이기도 했다 — 링크가 있다는 것이 그 화면의 API를 부를 권한이 있다는 뜻이 아니어서 그 문장을 화면에 따로 적어 둬야 했다. 역이용 방지(기획서 7-4) 시연은 지금도 화면이 아니라 API로 한다.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages