diff --git a/.gitattributes b/.gitattributes index 8af972cd..7a9fd78d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,6 @@ /gradlew text eol=lf *.bat text eol=crlf *.jar binary + +# proovy-brain 계약 fixture 사본: manifest sha256과 byte 단위로 같아야 한다 +/src/test/resources/contracts/brain/** -text diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d037922c..cb80999c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,6 +64,24 @@ jobs: SPRING_DATASOURCE_PASSWORD: proovy123 SPRING_DATA_REDIS_HOST: localhost SPRING_DATA_REDIS_PORT: 6379 + # 실제 Postgres 제약·role·baseline 테스트: 테스트마다 격리 database를 만든다 (superuser 필요). + # CI에서는 미구성 시 skip이 아니라 실패한다. + PROOVY_TEST_PG_URL: jdbc:postgresql://localhost:5432/postgres + PROOVY_TEST_PG_USER: proovy + PROOVY_TEST_PG_PASSWORD: proovy123 + PROOVY_REQUIRE_DB_TESTS: "true" + + # 실제 Brain 연동(task 1.87)은 proovy-brain이 private이라 CI에서 실행하지 않는다(사용자 결정). 통과로 보이지 않게 skip을 드러낸다. + - name: Report real-Brain integration tests NOT EXECUTED + if: always() + run: | + shopt -s nullglob + files=(build/test-results/test/TEST-com.proovy.integration.brain.*.xml) + if [ ${#files[@]} -eq 0 ]; then echo "real-Brain integration: no result file"; exit 0; fi + for f in "${files[@]}"; do + summary=$(grep -o 'tests="[0-9]*" skipped="[0-9]*" failures="[0-9]*" errors="[0-9]*"' "$f" | head -1) + echo "::warning title=Real Brain integration NOT EXECUTED::$(basename "$f" .xml) $summary — run scripts/brain-integration/run-local.sh locally (README)" + done - name: Upload test report if: always() diff --git a/README.md b/README.md index 6984bb31..ccfb6c2c 100644 --- a/README.md +++ b/README.md @@ -151,12 +151,10 @@ proovy-api/ - AI 서버 연동 ### 7. Conversation 도메인 -- 사용 가능한 도구 목록 조회 -- 멘션용 파일 검색 (자동완성) +- Note가 유일한 대화 단위 (`conversationId = noteId`), 메시지는 Note에 직접 연결 - 캔버스 이미지 업로드 -- 대화 전송 (질문 + AI 응답, SSE 스트리밍) -- 대화 목록 조회 (검색) -- 대화 상세 조회 +- 대화 검색 +- 신규 run 접수·SSE·취소는 `POST /api/conversations`·`/api/ai-runs/*`(browser-run-v1). 구형 `/stream/v2`·ChatSession 경로는 없다 ### 8. Credit 도메인 - 크레딧 잔액 조회 @@ -165,9 +163,50 @@ proovy-api/ - 크레딧 비용 정보 조회 - 예상 크레딧 비용 조회 -### 9. Embedding 도메인 -- 노트 임베딩 저장/조회 -- 유사 노트 검색 +### 9. Run 도메인 (S07 저장 기반) +- `ai_runs`·`ai_run_events`·`run_asset_dependencies`, `verified_solutions`, `request_usage_*` ledger +- 상태 전이·불변 snapshot·Active Run 제약은 DB baseline에서 강제 + +## 데이터베이스 + +- 제품 schema는 빈 DB에서 `db/migration/V1__product_baseline.sql`로 생성한다. 과거 migration·데이터 이관은 없으며 이후 변경은 `V2__...`부터 versioned migration으로 추가한다. +- `baseline-on-migrate`는 꺼져 있어 과거 schema/history가 있는 DB에서는 기동이 실패한다. +- Server ↔ Brain DB role 경계는 `db/provisioning/role-boundary.sql`(Flyway 미실행, 운영자 적용)로 고정한다. + +### DB 통합 테스트 + +실제 Postgres에 테스트마다 격리 database를 만들어 제약·role·baseline을 검사한다 (superuser 접속 필요). + +```bash +docker run -d --name proovy-postgres-test -p 127.0.0.1:21432:5432 \ + -e POSTGRES_USER=proovy -e POSTGRES_PASSWORD=proovy123 postgres:15-alpine +PROOVY_TEST_PG_URL=jdbc:postgresql://127.0.0.1:21432/postgres \ +PROOVY_TEST_PG_USER=proovy PROOVY_TEST_PG_PASSWORD=proovy123 \ +PROOVY_REQUIRE_DB_TESTS=true ./gradlew test +``` + +환경변수가 없으면 DB 테스트는 skip(미실행)되고, `PROOVY_REQUIRE_DB_TESTS=true`(CI)이면 실패한다. + +**전용 일회용 Postgres 클러스터에서만 실행한다.** role 경계 테스트는 cluster 단위 role(`proovy_server`, `brain_*`)을 +만들고 비밀번호를 덮어쓰며, database를 지워도 role은 남는다. 공유·운영 클러스터를 `PROOVY_TEST_PG_URL`로 지정하지 않는다. + +### 실제 Brain 연동 테스트 (task 1.87) — **CI에서는 실행되지 않는다** + +`com.proovy.integration.brain.ServerBrainIntegrationTest`는 proovy-brain `testkit/`이 띄운 실제 Python 내부 API·runtime과 +Server 전체 경로를 연결해 spec 07 Verification을 검사한다. proovy-brain이 private이라 Server CI는 Brain을 checkout할 수 없고, +사용자 결정으로 **CI에서는 skip**한다. skip은 통과가 아니다 — 테스트 로그에 `NOT EXECUTED — real Brain integration …` 이유가 +남고 JUnit 보고서에는 skipped로 집계된다. 결과는 아래 로컬 명령으로 만들고 이슈·PR에 Server·Brain commit과 함께 기록한다. + +```bash +# proovy-brain checkout(uv sync --locked 완료)을 원하는 commit에 맞춘 뒤 +BRAIN_DIR=../proovy-brain PG_CONTAINER=proovy-postgres-brain-it scripts/brain-integration/run-local.sh +# JDK가 없으면 GRADLE_IN_DOCKER=1 (eclipse-temurin:21-jdk 임시 컨테이너, --network host) +``` + +스크립트는 격리 Postgres(tmpfs) → Brain testkit supervisor → Gradle(`PROOVY_REQUIRE_BRAIN_IT=true`라 미구성 시 실패) → 정리 순서로 +돌고, Brain fixture sha256이 `src/test/resources/contracts/brain/PIN*.json`과 같은지·Brain commit이 checkout과 같은지도 검사한다. +모델·Daytona는 offline fake seat이고 일부 결과(자료 부재 안내·scope 밖 출처·변조 envelope)는 testkit scripted다 — 수학 품질·실제 +provider·Cloud Run identity는 이 테스트가 인증하지 않는다(S14). ## API 문서 diff --git a/build.gradle b/build.gradle index e1122748..794cc0c5 100644 --- a/build.gradle +++ b/build.gradle @@ -53,6 +53,8 @@ dependencies { // Google Cloud Storage SDK implementation platform('com.google.cloud:libraries-bom:26.34.0') implementation 'com.google.cloud:google-cloud-storage' + // Brain 내부 API 서비스 인증: Cloud Run 서비스 계정 ID token (task 1.76, 버전은 libraries-bom) + implementation 'com.google.auth:google-auth-library-oauth2-http' // PDF/이미지 처리 (썸네일 생성용) implementation 'org.apache.pdfbox:pdfbox:3.0.6' @@ -73,6 +75,8 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' + // browser-run-v1 계약 schema(JSON Schema 2020-12) 소비 검사 — Brain fixture 사본으로만 쓴다 + testImplementation 'com.networknt:json-schema-validator:1.5.8' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } diff --git a/docs/contracts/brain-integration.md b/docs/contracts/brain-integration.md new file mode 100644 index 00000000..ad25f49c --- /dev/null +++ b/docs/contracts/brain-integration.md @@ -0,0 +1,70 @@ +# Server ↔ Brain 로컬 실행 연동 인수 (task 1.87, #226) + +S07 Server 구현 전체(1.74~1.86)를 **실제 Python Brain 내부 API·runtime**과 로컬에서 연결해 인수한다. +Brain 쪽 test bootstrap은 proovy-brain `testkit/`(PR #150, squash `7b8608ee345761526aee0c76186ca984b496f41e`)이고, +Server 쪽 검사는 `com.proovy.integration.brain.ServerBrainIntegrationTest`다. + +## 실행 + +```bash +BRAIN_DIR= \ +PG_CONTAINER=proovy-postgres-brain-it GRADLE_IN_DOCKER=1 scripts/brain-integration/run-local.sh +``` + +순서: Gradle `testClasses` → 격리 Postgres(tmpfs, `postgres:15-alpine`) → Brain testkit supervisor(`uv run --locked python -m testkit.integration supervise`) +→ warm-up child → `./gradlew test --tests 'com.proovy.integration.brain.*'`(`PROOVY_REQUIRE_BRAIN_IT=true`) → 컨테이너·프로세스 정리. + +- **Server CI는 실행하지 않는다**(사용자 결정: proovy-brain이 private, checkout token 없음). CI 로그에는 skip 이유(`NOT EXECUTED — real Brain integration …`)와 + `Report real-Brain integration tests NOT EXECUTED` step의 warning이 남는다. skip은 통과가 아니다. +- 한 database에 빈 제품 baseline(`proovy_server`가 Flyway 실행)과 Brain schema(`brain_migration`·`brain_runtime`, Brain bootstrap·migrate)를 두고 + `db/provisioning/role-boundary.sql`을 적용한다(운영 형태). Server datasource는 `proovy_server`다. +- 스크립트는 `test --rerun`으로 항상 실제 실행하고, 끝에 `TEST-com.proovy.integration.brain.*.xml`이 이번 실행에서 생겼고 tests > 0·skipped·failures·errors = 0인지 검사해 아니면 실패한다(UP-TO-DATE·skip 위장 방지). 정리 때 supervisor에 `/brain/stop`을 보내고 프로세스 그룹 전체를 끈다. +- 공용 호스트 부하 대응: supervisor의 child readiness 대기는 60초 고정이라, 스크립트가 child를 미리 한 번 띄워 page cache를 데우고 + Java client는 `did not become ready`일 때만 start·restart를 최대 3번 다시 보낸다(후속 메모: Brain readiness 대기 설정화). + +## S07 Verification 대응 + +| Verification (spec 07) | 테스트 | 실제로 거친 경로 | +| --- | --- | --- | +| 1. 빈 DB·ChatSession/thread 없음, noteId 없는 중복·같은 Note 동시 전송이 하나만 남고 거부는 메시지 없음 | `firstTextQuestion…`, `concurrentDuplicateNotelessRequests…`, `concurrentSameNoteRequests…` (+ 단계 3 `RunPathEndToEndPostgresTest`·`LegacyExecutionPathGuardTest`) | HTTP 접수 → coordinator → Brain graph 1회 → terminal transaction. 409 RUN4091 + activeRunId, 메시지·run·차감 없음 | +| 2. SSE 단절·재접속 seq·canonical 복원, replay/live 무유실 | `firstTextQuestion…`(POST live = GET replay 바이트), `browserDisconnectAndInternalSseLoss…`, `storedTerminalLostInDelivery…` | 브라우저 Last-Event-ID 재접속, Brain 내부 SSE 첫 frame 직전 abort → status executing → 같은 attempt·fence OBSERVE 재부착, terminal 전 abort → status terminal 회수 | +| 3. 완료·취소·deadline·이전 attempt 경합에서 확정과 보상이 동시에 일어나지 않음 | `runningCancelBlocks…`, `pendingCancel…`, `deadlineRace…`, `brainSideDeadline…`(fake clock), `staleFenceTerminal…`, `serverRestart…`, `brainLostBeforeStorage…` | terminal event 1개·보상 ≤ 1·재실행 0을 run마다 단언. deadline 경합은 승자를 고정하지 않는다(실측: Server sweep). Brain fake clock은 Brain timeout terminal 확정을 단언. `serverRestart`는 같은 JVM의 coordinator `stop()/start()`(프로세스 재시작 아님) | +| 4. clarification·unsupported_only·혼합·no-match·최종 실패·PENDING/RUNNING 취소 순사용량 | `outcomeMatrix…`, `explainFollowUp…`, 취소 2종 | 정상 solve·check·explain 1, 미검증 공개 1, clarification 0, unsupported_only 0, 혼합 1, 자료 부재(scripted) 1, verification_failed 0, provider_error 0, PENDING 취소 0, RUNNING 취소 1, deadline 0, 저장 전 소실 0 | +| 5. Java DTO·fingerprint·source 변환이 공용 fixture와 일치, scope/provenance 위반 거부 | `javaAndPythonAgree…`, `outOfScopeSource…`, `forgedRunEnvelope…`, `staleFenceTerminal…` | Brain record fingerprint = `ai_runs.request_fingerprint`, Brain fixture sha256 = Server PIN 전부, scope 밖 asset(scripted) → FAILED internal_error·출처/solution 없음, 다른 run envelope(stream+status, scripted) → FAILED, 이전 fence(stream, scripted) → 버리고 저장 terminal 회수 | + +보안 경계(`securityBoundariesHold`): Brain이 받은 호출은 전부 shared token이고 body에 사용자 JWT·`Bearer`·grant·`read_url`·서명 URL이 없다. +token 없음·사용자 JWT·틀린 token은 Brain이 401/403으로 거부한다. 브라우저 frame·대화 조회에 fence·grant·read URL·서비스 token이 없다. +같은 database에서 `brain_runtime` → `public.ai_runs` 42501, `proovy_server` → `brain_runtime.run_records` 42501. + +## 구분 — fake·scripted·미검증 + +- **fake provider**: Brain 모델·Daytona·page VLM은 offline seat다. 결과 분류(검증/미검증/실패)는 scripted seat 판정이고 수학 품질을 인증하지 않는다. +- **scripted terminal/fault (그래프 미실행 또는 전달 변조)**: `no_match_notice`(자료 부재 안내 — Brain runtime에 문서 근거 no-match 경로가 아직 없다), + `out_of_scope_source`, `rewrite_terminal`(+`rewrite_status`). 저장·전달은 실제 Brain store·SSE(PgRuntimeBackend)를 거친다. +- **미검증(S14)**: 실제 유료 모델 품질·비용, Cloud Run 서비스 계정 ID token·IAM, Supabase/GCS, 실제 자산 업로드·signed URL(자산 경로는 1.81 seeded 계약 자료로만 검수), + 부하·다중 인스턴스, 브라우저 화면. +- **Server CI**: skip(위). 결과는 이슈 #226·PR 본문에 Server head·Brain SHA와 함께 기록한다. + +## S08이 쓸 외부 계약 + +- 브라우저 wire는 browser-run-v1 그대로다(변경 없음): `POST /api/conversations`(fetch 기반 SSE reader, body·Authorization 필요), + `GET /api/ai-runs/{runId}/events`(Last-Event-ID 또는 `lastEventId` query), `GET /api/ai-runs/{runId}`, `POST /api/ai-runs/{runId}/cancel`(body 없음), + `GET /api/conversations/{noteId}/messages`. 재접속 cursor는 마지막 처리 frame `id`. +- 실제 Brain에서 나오는 frame 순서(정상 solve): `run.created` → `activity.started/completed`(입력 분석·풀이·검증) → `response.page.started` → + `response.page.completed` → `run.completed`. 취소는 `run.canceling` → `run.canceled`, 실패는 `run.failed`(보상된 run은 순사용량 0). +- 이월(계약 변경 필요, 이 태스크는 바꾸지 않음): superseded 재표현 표시 필드, 대화 조회 before/limit fixture, U+0000 RUN4001, `viewerFocus.page` 상한. + +## S13이 재사용할 복구 경계 + +- `RunClaimStore.findStale`·`takeOver`(같은 attempt·fence 인수), `RunExecution` RECOVER/FINAL_CHECK(status 먼저 → `settle(StoredTerminal)`·`failByServer`·`expireDeadline`), + `hasBrainEvents`(미시작 vs 실행 소실). 실제 Brain에서 확인한 결과: + - coordinator 정지 중(같은 JVM `stop()`) Brain이 저장한 terminal → `start()` 뒤 같은 epoch로 회수, graph 1회. Cloud Run 프로세스 교체는 S14. + - Brain이 terminal 저장 전 SIGKILL → 재시작한 Brain record는 `executing`(terminal 없음, 재개·재실행 0). Server는 같은 요청 OBSERVE·status 수습 뒤 + `provider_error`(infrastructure, 실행 소실)로 한 번 보상한다(단언). **Brain record는 executing으로 남는다** — Brain lease 만료 sweep·`cleanup` 호출은 S13. +- `runtime_cleanup_at` 예약까지가 Server 범위이고 Brain `cleanup` 호출·retention은 S13이다. + +## 후속 메모 + +- testkit release·clock route가 잘못된 JSON body에 500을 돌려준다(테스트 도구 한정, r187 r2). +- supervisor child readiness 대기(60s)를 설정값으로 — 공용 호스트 부하에서 재시도에 의존하지 않게(Brain 후속). +- `deploy.yml`의 `PROOVY_AI_HOST`·`INTERNAL_API_TOKEN` secret 주입은 읽는 코드가 없다(S14 정리 후보, 파일 미변경). diff --git a/docs/contracts/browser-run-sse.md b/docs/contracts/browser-run-sse.md new file mode 100644 index 00000000..7d02e06f --- /dev/null +++ b/docs/contracts/browser-run-sse.md @@ -0,0 +1,164 @@ +# 브라우저 run SSE·조회 — replay/live 무유실 합류 (task 1.86, #223) + +browser-run-v1 계약(`browser-run-v1.md`, pin된 Brain artifact가 판정 기준)의 public 경로 구현이다. 계약 의미는 바꾸지 않았다. + +코드: `com.proovy.domain.run.controller.BrowserRunController`(경로), `com.proovy.domain.run.stream`(연결·복원·조회). + +## 경로 + +| Method · Path | 구현 | 성공 | stream 전 오류 (JSON `ApiResponse`) | +|---|---|---|---| +| `POST /api/conversations` | `RunAdmissionService.admit`(1.79) → `RunStreamService.openAdmitted` | SSE seq 1 `run.created`부터. CREATED·REPLAYED 같은 처리 | 401 · RUN4001(구조·깨진 JSON·지원 안 하는 Content-Type) · RUN4002 · RUN4092 · RUN4041 · RUN4221 · RUN4091{activeRunId} · RUN4291(+Retry-After) · RUN4021 | +| `GET /api/ai-runs/{runId}/events` | `RunStreamService.openReplay` | SSE: cursor 없으면 seq 1부터, 있으면 seq+1부터 → terminal이 아니면 live | 401 · 404 RUN4041 · 400 RUN4003 · 400 RUN4004 | +| `GET /api/ai-runs/{runId}` | `RunReadService.status` | `RunStatusResponse` | 404 RUN4041 | +| `GET /api/conversations/{noteId}/messages?before&limit` | `RunReadService.conversation` | `ConversationPageResponse` | 404 RUN4041 · 400 RUN4001(before가 UUID 아님·limit 범위 밖) | + +- 취소 `POST /api/ai-runs/{runId}/cancel`은 1.84가 같은 controller에 추가한다. +- stream 전 오류는 `Accept: text/event-stream`이어도 `application/json`이다(`BrowserRunExceptionHandler`, 접수 거부는 `RunAdmissionExceptionHandler`). + 예상 밖 실패는 500 COMMON500 JSON(ERROR + stack 로그), Spring MVC 요청 오류(406 등)는 그 4xx 상태 + COMMON400 JSON이다. + 전역 `GlobalExceptionHandler`는 SSE Accept면 오류를 `text/event-stream`으로 감싸므로 이 controller에는 쓰지 않는다. +- 404 RUN4041은 없는 run, 타인 run, 삭제 중 Note, 대문자·형식이 다른 runId, 타인 Note를 구분하지 않는다(같은 body). +- cursor 판정 순서: 인증 → run 접근 RUN4041 → 형식·범위 RUN4003 → 다른 run RUN4004 (`cursors/cases.json` precedence, `RunEventCursor.resolve`). + header와 query가 둘 다 있고 다르면 RUN4003이다. 범위 판정은 연결 시점의 `ai_runs.last_event_seq`다. + +## SSE wire (확정 — S08·1.87 소비) + +frame 블록은 fixture `streams/valid/*.json`의 `sse`와 같은 바이트다(필드 순서 `event`·`id`·`data`, 콜론 뒤 공백 한 칸, data 한 줄): + +``` +event: run.created +id: 5118a0ec-5b14-5dc8-aead-81da16123ee1:1 +data: {"contractVersion":"brain-source-v1","runId":"5118a0ec-5b14-5dc8-aead-81da16123ee1","seq":1,"occurredAt":"2026-10-17T09:00:00Z","type":"run.created","payload":{"requestId":"e7380af3-85cb-521a-a30b-77761495827b","noteId":"123","userMessageId":"df777a3a-c744-59f5-bb8b-2bf64e874b9c","assistantMessageId":"c505a965-c220-58e1-9ec0-f48018f1262d","status":"PENDING","requestedTasks":["solve"],"requestedOutputs":["text"]}} + +``` + +heartbeat(15초, live 연결에서만): + +``` +: heartbeat + +``` + +- heartbeat는 comment다. `id`·`event`·`data`가 없어 cursor를 바꾸지 않고, event row·seq를 만들지 않는다(테스트로 행 수·`last_event_seq` 불변 확인). +- 연결은 terminal frame(`run.completed`|`run.failed`|`run.canceled`)을 보낸 직후 서버가 닫는다. terminal run에 마지막 seq cursor로 붙으면 + 0 frame 뒤 바로 닫는다. +- payload별 원천: activity.*·run.created·run.canceling·run.canceled·response.page.*·run.failed는 저장 payload 그대로, + `run.completed`는 저장 최소 요약 + canonical assistant message(`TerminalReplay.completedPayload`, 1.82). + `sourceReferences[].available`만 read-time(같은 Note asset이 지금 `ACTIVE`면 true)이고 label·page snapshot·ID·plainText는 불변이다. +- 전체 frame 예제: browser-run-v1 fixture `streams/valid/*.json`(10개 시나리오). 서버 출력이 그 형식과 S1~S7을 지키는지는 + `BrowserRunSsePostgresTest`가 실제 HTTP로 받은 frame에 `BrowserFrame` schema·S1~S7 검사기를 돌려 확인한다. + +### 브라우저(S08) 사용 메모 + +- POST SSE는 body와 `Authorization`이 필요하므로 `EventSource`가 아니라 fetch 기반 SSE reader를 쓴다. 재접속도 같은 reader로 + `Last-Event-ID` header 또는 `?lastEventId=` query(`{runId}:{seq}`, 마지막으로 받은 frame의 `id`)를 보낸다. +- 재접속 cursor는 **마지막으로 처리한 frame의 `id`**다. heartbeat는 cursor가 아니다. +- 연결이 terminal 없이 끝나면(네트워크 단절, 서버 최대 유지 시간 `proovy.run.sse.timeout` 30분, 서버 재시작, 서버 내부 오류) GET으로 + 재접속하되 **지수 backoff**를 둔다. 같은 seq에서 서버 오류가 반복되면 즉시 재접속은 루프가 된다. 재접속이 404 RUN4041이면 멈춘다(Note 삭제 등). + 단절은 실행 취소가 아니다 — 취소는 명시적 cancel API뿐이다. +- 409 RUN4091의 `activeRunId`로 `GET /api/ai-runs/{activeRunId}/events`에 붙으면 진행 중 run을 이어 볼 수 있다. + +## 무유실·무중복 (`RunEventStream`) + +1. 연결은 **신호 구독을 먼저** 걸고, 그다음 cursor 이후를 DB(`RunEventStore.findAfter`)에서 seq 순으로 읽는다. +2. 보낸 frame의 seq로 cursor를 올리고 `seq > cursor`만 보낸다. 1.80의 "commit 순서 = seq 순서"라 아직 commit되지 않은 앞 seq를 건너뛰지 않는다. +3. 신호(`RunEventSignals`)는 깨우기 힌트다. 신호가 유실되어도 `catch-up-interval`(기본 5초)마다 DB를 다시 보므로 그 안에 복구된다. + 다른 Server 인스턴스의 commit도 이 경로로 보인다(지연은 catch-up 주기). +4. 상태를 event보다 먼저 읽는다. 상태가 terminal이면 terminal event도 같은 transaction으로 이미 commit되어 이어지는 조회에 보인다. +5. 연결의 모든 쓰기(frame 전송과 heartbeat)는 쓰기 소유권 하나를 가진 쪽만 한다 → 연결당 쓰는 thread는 동시에 최대 하나다. 전송 중이면 + heartbeat는 건너뛴다(frame이 흐르고 있다). 전송 중 신호는 표시 하나로 합쳐진다 → 신호가 몰리거나 브라우저가 느려도 서버에 frame queue가 + 쌓이지 않고, 신호 listener(append commit thread)는 기다리지 않는다. + +연결은 저장 event를 읽고 구독할 뿐 run을 만들거나 dispatch·취소·차감하지 않는다. 재접속 반복이 run·worker·사용량을 늘리지 않는다(테스트로 행 수 확인). + +## 구독 해제 + +| 원인 | 동작 | +|---|---| +| terminal frame 전송 | 정상 종료(서버가 닫음) | +| run 소멸(Note 삭제 cascade) 또는 Note가 DELETING | 정상 종료, 그 뒤 frame 없음(새 GET·조회의 RUN4041과 같은 의미) | +| 브라우저 단절 | 다음 쓰기(heartbeat·frame)에서 감지 → 구독·예약 작업 해제. run 상태는 그대로 | +| 최대 유지 시간(`timeout`) | 연결만 닫음 | +| 복원·조회 실패(내부 오류) | 오류 종료(`completeWithError`, ERROR + stack 로그). terminal 없이 끊김 → 브라우저는 **backoff 뒤** 재접속 | + +Note 삭제와의 관계: Note가 DELETING이 되면 열린 연결은 다음 catch-up(≤ `catch-up-interval`)에서 닫힌다. 진행 중 run의 취소·terminal +(`run.canceled` 등)은 1.84 Note 삭제 cancel 흐름의 몫이다. 그 terminal이 commit돼도 DELETING Note의 연결에는 보내지 않는다 — 브라우저는 이미 +삭제 중인 Note를 떠난 상태이고, 재접속은 RUN4041이다. + +`RunStreamService.openStreams()`는 열린 연결 수다(테스트가 단절 뒤 0으로 돌아오는지 확인한다). +브라우저 단절의 `AsyncRequestNotUsableException`은 오류가 아니라 `BrowserRunExceptionHandler`가 조용히 끝낸다. + +## 대화 조회 + +- 메시지는 `createdAt` 오름차순, `before`(그 Note의 messageId)보다 오래된 것 중 최신 `limit`(1..100, 기본 50)개. + 더 오래된 메시지가 있고 페이지 맨 앞이 assistant면 그 assistant를 다음 페이지로 넘겨 한 turn이 갈리지 않게 한다. + `nextBefore`는 더 오래된 메시지가 있을 때 이 페이지 맨 앞 messageId, 없으면 null. +- run과 묶인 메시지만 나온다(`ai_runs`의 user/assistant message). 신규 baseline에서는 모든 메시지가 run에 묶인다. +- user: `text`·`latex`는 `user_input`, `requestedTasks`·`requestedOutputs`는 그 run의 `run.created` payload(stream과 같은 값), + `viewerFocus`는 run snapshot `viewer_context`(`noteAssetId` = Note Asset ID 문자열), `turnAttachments`는 `turn_attachments`(`displayName` = `display_label`). +- assistant: COMPLETED는 SSE `run.completed`와 같은 함수로 만든 `assistantResponse`·`verificationSummary`(조회·replay·live 동일, 차이는 `available`), + FAILED는 `run.failed` payload의 `{code, reason, message, recoverable}`, CANCELED는 `cancel_cause`의 고정 문구, PENDING·STREAMING은 본문 없는 placeholder. +- `activeRun`은 그 Note의 PENDING|RUNNING(RETRYING 포함)|CANCELING run과 `lastSeq`. 페이지와 무관하다. +- 상태 응답 `failure`도 `run.failed` payload에서 읽는다. `responseDisposition`은 COMPLETED일 때만. + +## 설정 (`proovy.run.sse.*`, 기본값은 `@DefaultValue` — application.yaml 변경 없음) + +| key | 기본 | 의미 | +|---|---|---| +| `heartbeat` | 15s | 계약 값(D10). 테스트만 줄인다 | +| `catch-up-interval` | 5s | 신호 없이 DB 재확인 주기(연결당 `ai_runs` 한 행 조회) | +| `timeout` | 30m | 연결 최대 유지 시간 | +| `batch-size` | 100 | catch-up 한 번 조회 수 | +| `threads` | 8 | 연결 공용 전송 thread(`runSseScheduler`) | + +`runSseScheduler`는 `Executor`·`ScheduledExecutorService`·`TaskScheduler` 타입 bean이 **아니다**. 그런 bean을 하나라도 등록하면 Spring Boot의 +기본 `applicationTaskExecutor`(MVC async)·`taskScheduler`(@Scheduled)가 back-off되거나 @Scheduled가 그 pool을 고르게 된다(공통 설정 영향). + +## 보안 설정 (SecurityConfig, 1.86 소유 규칙) + +run SSE 경로(`POST /api/conversations`, `GET /api/ai-runs/*/events`)의 **ASYNC dispatch**만 permitAll이다. SSE 완료 시 재dispatch에는 +JWT filter(OncePerRequestFilter)가 돌지 않아 인증이 없고, 규칙이 없으면 이미 commit된 응답에 401을 쓰려다 servlet 오류가 난다 +(규칙을 끈 probe에서 2개 테스트 실패·오류 로그 56건 확인). 최초 요청의 인증·권한은 그대로다. + +## 검증 + +- `RunEventStreamTest`(DB 없음): replay→live, cursor 재개, 0 frame 종료, 신호 유실 복구, 경계 append 무유실·무중복, 느린 구독(쓰기가 막힌 동안 + 신호 1000개가 즉시 반환·queue 증가 없음·다른 연결 진행), 느린 연결 thread 하나(2-thread pool에서 다른 연결 진행·동시 쓰기 1), + 복원 실패 오류 종료, 단절 해제, run 소멸, heartbeat. +- `BrowserRunSsePostgresTest`(실제 Postgres + 실제 HTTP client): 최초 POST live·완료 종료·같은 바이트 replay·조회/상태 일치와 schema, + 같은 requestId 재전송, 단절 뒤 Last-Event-ID·query 재접속(구독자 없을 때 commit=방송 유실 포함), 동시 append 중 임의 단절·재접속 2 client, + 신호 전부 유실 시 DB 복구, heartbeat 행·seq 불변, terminal commit 전 stream·상태·조회 비공개, 타인·없는 run 404 동일 body, cursor 판정표 전체, + stream 전 JSON 거부·RUN4091, 예상 밖 실패 COMMON500 JSON, Note DELETING 시 열린 연결 종료, asset 삭제 뒤 `available`만 변경, 대화 조회 페이지. +- cursor 판정표의 공백·줄바꿈 case(`trailing-newline`·`surrounding-space`)는 HTTP header가 앞뒤 공백을 잘라내고 줄바꿈을 담을 수 없어 + 같은 원문을 `lastEventId` query로 보낸다. + +## 범위 밖·인계 + +- **1.87**: 실제 coordinator(1.83) 실행과 연결해 end-to-end로 같은 stream을 확인한다. 이 태스크의 producer는 production 저장 경로 + (`appendActivity`·`RunTerminalService.settle`)를 직접 부르는 fake다. +- **S08**: reducer·renderer. 열린 Activity가 terminal 뒤 남는 경우의 표시(1.80 메모), SafeMarkdown `\r` 렌더 방어(1.81 메모), + superseded solution 재표현의 "정정된 풀이" 표시(1.81 결정). 마지막 항목은 현재 wire에 solution status가 없어 화면이 판단할 값이 없다 — + 필요하면 browser-run-v1 새 consumer_version으로 필드를 추가한다(계약 변경, 미착수). +- **S14**: 다중 인스턴스 신호(지금은 catch-up 주기로 복구), 프록시 buffering, 대규모 연결. 느린 브라우저의 쓰기는 pool thread 하나를 막는다 + (연결당 최대 하나). 한계: 수신을 멈춘 연결이 pool `threads`(기본 8)개 생기면 Tomcat write timeout까지 **모든 SSE 연결의 전송이 멈춘다**. + 쓰기 timeout·연결 수 상한·non-blocking 전송은 S14 결정이다. +- **SecurityConfig 공통 후속 제안**: 지금 ASYNC 규칙은 run SSE 경로 문자열만 본다(1.86 소유 범위). 1.84 cancel은 동기라 추가가 필요 없지만, 이후 + async endpoint마다 경로를 더해야 한다. Spring Security 권장 방식인 전역 `dispatcherTypeMatchers(DispatcherType.ASYNC).permitAll()` 전환은 + SecurityConfig 공통 담당에게 제안한다(ASYNC dispatch는 이미 인가된 요청에서만 생긴다). + +## 계약 후속 (browser-run-v1 다음 consumer_version, Brain fixture로 고정) + +이 PR은 계약을 바꾸지 않았다. 아래 두 가지는 Server 이슈에서 의미를 합의한 뒤 proovy-brain `tests/fixtures/contracts/browser-run-v1`의 +새 consumer_version으로 고정하고 Server·Front가 pin을 올린다(`browser-run-v1.md` "계약을 바꿀 때"). 담당: 1.87 또는 별도 Brain PR(오케스트레이터 결정). + +1. **대화 조회 `before`/`limit` 의미** — artifact에는 경로·응답 schema만 있다. 이 PR의 구현값: `before` = 그 Note의 messageId(UUID 아님 RUN4001, + 그 Note 메시지 아님 RUN4041), `limit` 1..100·기본 50(밖은 RUN4001), 더 오래된 메시지가 있으면 페이지 맨 앞 assistant를 다음 페이지로 + (turn 보존), `nextBefore` = 페이지 맨 앞 messageId. fixture case(경계·오류)로 고정한다. +2. **superseded solution 재표현의 "정정된 풀이" 표시**(1.81 결정, 1.86/S08) — 현재 wire(`run.completed`·대화 조회)에 solution status가 없어 + 화면이 판단할 값이 없다. 표시하려면 필드(예: block·summary의 solution 상태)를 additive로 추가해야 한다. replay/live 차이 규칙 + ("유일한 차이는 `available`")과의 관계(read-time 값인지)도 함께 정한다. + +## 효율 메모 + +- `run.completed` hydration은 Note ID를 한 번 더 조회한다(연결당 terminal 한 번). `TerminalReplay.CompletedMessage`(1.82)에 note_id를 싣는 것은 + 1.82 타입 변경이라 하지 않았다. diff --git a/docs/contracts/browser-run-v1.md b/docs/contracts/browser-run-v1.md new file mode 100644 index 00000000..a3bf52b6 --- /dev/null +++ b/docs/contracts/browser-run-v1.md @@ -0,0 +1,68 @@ +# 브라우저 run API 계약 browser-run-v1 (task 1.75, #214) + +이 문서는 인계용 안내다. 계약의 판정 기준은 아래 artifact이고, 이 문서나 OpenAPI가 아니다. + +## 어디에 무엇이 있나 + +| 무엇 | 위치 | +|---|---| +| 계약 원본(SSOT) — schema·정상/거부 예제·판정표·CATALOG | proovy-brain `tests/fixtures/contracts/browser-run-v1/` | +| Server가 pin한 version | `src/test/resources/contracts/brain/PIN.json` — commit `303a9e4`, `browser-run-fixtures-v1` | +| 검증된 사본과 갱신 절차 | `src/test/resources/contracts/brain/` (`README.md`) | +| Java 계약 타입 | `com.proovy.domain.run.contract` | +| 소비 검사 | `src/test/java/com/proovy/domain/run/contract/` | + +경로·오류 code·판정 순서·cursor·stream 규칙(S1~S7)은 Brain `CATALOG.md`가 정리한다. 단계 2에서 합의한 +결정 D1~D10은 그 artifact와 #214에 기록되어 있다. + +## Java 타입과 artifact 대응 + +| artifact | Java | +|---|---| +| `ConversationRunRequest` (closed) | `ConversationRunRequest` + `ConversationRunRequestValidator` (RUN4001/RUN4002) | +| 요청 fingerprint | `BrowserRequestFingerprint`, `CanonicalJson` (internal-run-v1 canonical JSON) | +| event id / cursor `{runId}:{seq}` | `RunEventCursor` (`resolve` → replay 시작점 또는 RUN4003/RUN4004) | +| durable frame / Server 소유 payload | `BrowserRunFrame`, `ServerFrameType`, `RunCreatedPayload` 등 | +| 상태·취소·대화 조회 응답 | `RunStatus`, `RunCancelResult`, `ConversationPage` 등 (`ApiResponse`로 감싼다) | +| 오류 envelope | `ErrorCode.RUN4001`~`RUN4291`, RUN4091만 `ApiResponse.failure(code, ActiveRunConflict)` | +| 공개 상태 투영 | `PublicRunStatus.from(AiRunStatus)` (RETRYING → RUNNING) | +| 고정 값(limits·heartbeat·frame version) | `BrowserRunContract` | + +## 아직 없는 것 + +이 태스크는 계약 타입과 소비 검사만 추가했다. 아래는 후속 태스크가 이 계약대로 구현한다. + +- controller·접수 transaction·SSE 연결·replay 저장소 (1.79·1.86 등) +- Brain 내부 client (1.76) +- Front 화면 (S08) + +OpenAPI에는 component schema만 등록했고 경로는 없다. + +> 반영(1.86): 접수 SSE·재접속 SSE·상태·대화 조회 경로 — [`browser-run-sse.md`](browser-run-sse.md). 취소 경로는 1.84. + +## 후속 메모 (PR #229 리뷰 r175-server) + +- **1.79 (접수·취소):** `admission/decisions.json`과 `cancel/cases.json` 판정표 fixture를 production admission·cancel + 판정기로 다시 돌리는 테스트를 둔다. 지금 `BrowserRunDecisionTableContractTest`는 테스트 안의 참조 판정이어서 + production 구현이 같은 순서로 판정하는지까지는 보장하지 않는다. +- **schema와 Java 판정이 갈리는 경계 (fixture 밖):** `viewerFocus.page`가 `2.0`처럼 정수값 실수이거나 int 범위를 넘으면 + schema는 통과하지만 Java 판정은 RUN4001이다. 브라우저(`JSON.stringify`)는 이런 값을 보내지 않는다. + 다음 consumer_version에서 schema에 `maximum: 2147483647`과 정수값 실수 정책을 넣고 fixture로 고정한다. +- **`RunStatus.cancelable`·`ActiveRunSummary.status`:** 상태에서 파생되는 값인데 생성자가 막지 않는다. 응답을 만드는 + 구현(1.79·1.81)이 `PublicRunStatus.isCancelable()`로 채우고, activeRun에는 terminal 상태를 넣지 않는다. 필요하면 factory로 강제한다. +- **OpenAPI 등록과 springdoc 비활성:** `BrowserRunContractOpenApi`는 `SpringDocConfigProperties` bean을 주입받는다. springdoc을 끄는 profile에서는 + bean이 없어 기동이 실패할 수 있다. 그런 설정을 도입할 때 `ObjectProvider` 또는 `@ConditionalOnBean`으로 바꾼다. +- **wire 값 원천 정리:** 아래는 같은 wire 값을 두 곳에서 정의한다. 계약 의미의 결함은 아니므로 후속 구현 태스크에서 enum 파생이나 + 대응 테스트로 묶는다. + - `ServerFrameType` ↔ `AiRunEventType`(serverOwned) + - `CancelCause` ↔ `RunCancelCause`(대응 테스트는 있음) + - 신규 계약 enum의 `WireValue` 미구현 + +## 계약을 바꿀 때 + +1. Server 이슈에서 의미 변경을 합의한다. 계약 의미의 소유자는 Server다. +2. proovy-brain artifact를 새 `consumer_version`과 `change_history` 항목으로 올리고 merge한다. +3. Server는 사본을 새 commit에서 다시 복사하고 `PIN.json`을 갱신한다(`README.md` 절차). Java 타입과 검사를 함께 고친다. +4. Front(S08)도 같은 commit과 version을 pin한다. + +pin을 올리지 않고 사본이나 Java 상수만 고치면 `BrainContractMirrorTest`가 실패한다. diff --git a/docs/contracts/internal-run-v1.md b/docs/contracts/internal-run-v1.md new file mode 100644 index 00000000..d9b20c44 --- /dev/null +++ b/docs/contracts/internal-run-v1.md @@ -0,0 +1,136 @@ +# internal-run-v1 — Server → Brain 내부 실행 client (task 1.76) + +Server가 Brain 내부 실행 API를 부르는 경계다. 계약 원본은 proovy-brain이고 Server는 사본을 pin해 소비한다. + +- **SSOT**: proovy-brain `src/proovy_brain/contracts/internal_run.py`·`internal_run_fingerprint.py`·`apps/api/app.py`·`apps/api/auth.py`, + fixture `tests/fixtures/contracts/internal-run-v1/` +- **pin**: commit `303a9e45943207cb22fc555452cc781fb8117d21`, `consumer_version` `internal-run-fixtures-v2` + (`src/test/resources/contracts/brain/PIN-internal-run-v1.json`, 갱신 절차는 같은 디렉터리 `README.md`) +- 이 문서는 Java 대응과 인계 메모다. 계약 의미를 새로 정하지 않는다. + +## Java 대응 + +| 패키지 | 내용 | +|---|---| +| `com.proovy.domain.run.brain` | wire DTO(record)·fingerprint·판정표·snake_case mapper. 후속 태스크는 이 패키지 타입을 그대로 쓴다. | +| `com.proovy.global.infra.brain` | HTTP client·서비스 인증·오류 분류·설정 | + +- 요청: `InternalRunRequest`(+ `RunInput`·`HistoryMessage`·`VerifiedSolutionRef(Claim)`·`TurnAttachmentRef`·`ViewerContext`(`ViewerSelection`)·`RetrievalScope`·`TurnAttachment`·`ExecutionOptions`(`ExecutionLimits`·`ExecutionUsage`)) +- 조회·취소·정리: `RunStatusQuery`→`RunStatusResponse`, `RunCancelRequest`→`RunCancelAck`, `RuntimeCleanupRequest`→`RuntimeCleanupResponse` +- source: `SourceEvent`(stream union: `activity.*`·`run.completed`·`run.failed`), `StoredTerminal`(status 전용: 위 terminal + 저장 전용 `run.canceled`) +- `InternalRunFingerprint`: Brain `request_fingerprint_document`와 같은 projection → `CanonicalJson`(1.75) → sha256. `RunStatusResponse.matches(fingerprint)`로 비교 +- `RunDecisions`: `decisions.json`(run-decisions-v2) 판정표와 binding override +- `InternalRunWire`: 이 wire 전용 Jackson 설정. Spring 기본 mapper(브라우저 camelCase)로 이 DTO를 (역)직렬화하지 않는다. + +record 생성자가 Brain과 같은 규칙으로 정규화·검증한다(Python `str.strip()` 집합 strip, code point 길이, 식별자·sha256·version 패턴, +MIME별 media 사실, conversation = note, scope note, history 중복·참조 무결성). 잘못된 요청은 만들어지지 않고 +`InternalRunContractViolation`(필드 이름만, 값 없음)이 난다. + +### 재전달·deadline·누적 예산 + +- 같은 run의 재전달은 `InternalRunRequest.forRetry(nextAttempt, nextFence, accumulatedUsage)`로만 만든다. + request_id·run_id·의미 snapshot·`request_deadline_at`·limits는 그대로이고, attempt는 커져야 하며 usage counter는 줄 수 없다. +- grant·read URL 재발급은 `withAssetAccess(...)`. fingerprint가 바뀌지 않는다. +- status는 본문 없는 GET(`attempt`·`fence_token` query)이라 실행을 시작할 수 없다. + +## client (`BrainRunClient`) + +| 메서드 | HTTP | 결과 | +|---|---|---| +| `stream(InternalRunRequest)` | `POST /internal/v1/runs/{run_id}/stream` | `Flux`. terminal을 낸 뒤 끝난다 | +| `status(RunStatusQuery)` | `GET /internal/v1/runs/{run_id}?attempt=&fence_token=` | `Mono` | +| `cancel(RunCancelRequest)` | `POST .../cancel` | `Mono` | +| `cleanup(RuntimeCleanupRequest)` | `POST .../cleanup` | `Mono` (terminal 저장 뒤에만) | + +- Authorization Bearer 하나만 보낸다. 사용자 JWT·쿠키·브라우저 header를 넘길 경로가 없다. +- 재시도·redirect 추적·run 생성을 하지 않는다. 판단은 호출자(coordinator)가 `BrainCallException.kind()`로 한다. +- stream의 attempt·fence는 요청과 다를 수 있다(Brain tail이 새 attempt를 따라간다). 현재 fence 판정은 소비자 몫이다. +- 구독을 취소하면 연결만 닫힌다. Brain 실행을 멈추려면 `cancel`을 부른다. + +### timeout·상한 + +| 설정 | 기본 | 의미 | +|---|---|---| +| `connect-timeout` | 5s | TCP 연결 | +| `request-timeout` | 10s | status·cancel·cleanup 전체 | +| `stream-idle-timeout` | 30s | frame·keep-alive 사이 공백(Brain keep-alive 10s) | +| `stream-deadline-grace` | 3m | `request_deadline_at` 뒤 stream 상한(Brain tail은 deadline + 4 × lease TTL 30s) | +| `max-response-size` | 4MB | JSON 응답·SSE frame 하나 | + +deadline + grace가 이미 지난 요청은 보내지 않는다(`DEADLINE_EXCEEDED`). grace는 stream 정리·status 확인 용도다: grace 안에서 회수한 terminal(`RETURN_TERMINAL`·`status`)도 deadline 이후면 1.82 terminal 확정이 `DEADLINE_PASSED`로 버리고 run은 `expireDeadline`의 timeout 실패로 끝난다(deadline은 도착 시각 기준 절대값 — 사용자 결정, r182). 같은 fingerprint 재전달이 새 실행을 만들지 않는다는 점은 그대로다. + +credential 발급(ID token)은 `request-timeout` 안에서 끝나야 하고 넘으면 요청 전 `CREDENTIALS_UNAVAILABLE`이다. stream idle·status 응답 시간에는 들어가지 않는다. + +### 실패 분류 (`BrainFailureKind`) + +범주는 셋이다: **status 조회로 수습**(`isRecoverableByStatusQuery()`, 요청이 닿았을 수 있음), **같은 요청 재전송 가능**(`isSafeToResend()`, Brain record에 닿지 않음), 둘 다 아니면 **영구 실패**. + +| kind | 원인 | 범주 | +|---|---|---| +| `CONNECT_FAILED` | 연결 수립 실패 | 재전송 | +| `TRANSPORT_LOST` | 응답·stream 중간 단절, 분류하지 못한 예외 | status | +| `TIMEOUT` | 응답·idle 상한 | status | +| `DEADLINE_EXCEEDED` | 고정 deadline + grace 초과 | status | +| `CAPACITY` | 503 `reason=capacity`, 429(Cloud Run 앞단) — record 생성 전 거부 | 재전송 | +| `UNAVAILABLE` | 그 밖의 5xx | status | +| `REQUEST_ID_BOUND_TO_OTHER_RUN` | 409 binding (`existingRunId()`) | 영구 (Server 버그) | +| `RUN_CONFLICT` | 409 contention (`conflictState()`) | 영구 | +| `INVALID_REQUEST` / `REQUEST_TOO_LARGE` | 400 / 413 | 영구 | +| `AUTH_REJECTED` | 401·403 | 영구 | +| `CREDENTIALS_UNAVAILABLE` | token 발급 실패·초과 | 재전송 | +| `UNEXPECTED_STATUS` | 그 밖의 status(3xx 포함) | 영구 | +| `MALFORMED_RESPONSE` / `MALFORMED_SOURCE` | 계약 밖 응답 / frame(`run.canceled`·다른 run_id 포함) | 영구 | +| `STREAM_ENDED_WITHOUT_TERMINAL` | terminal 없이 종료(취소된 run 등) | status | + +재전송 횟수·간격과 deadline 안 여부는 호출자가 정한다. client는 재시도하지 않는다. + +오류 message와 필드에는 kind·HTTP status·409의 state·existing_run_id만 들어간다(본문·token·grant·URL 없음, cause 없음). + +## 서비스 인증과 설정 + +```yaml +proovy: + brain: + base-url: ${BRAIN_BASE_URL:} # 비면 client bean을 만들지 않는다 + auth: + mode: ${BRAIN_AUTH_MODE:shared-token} # prod profile은 id-token으로 고정 + audience: ${BRAIN_ID_TOKEN_AUDIENCE:} # id-token: Brain 서비스 URL + shared-token: ${BRAIN_SHARED_TOKEN:} # local/test 전용 +``` + +- `id-token`: ADC(Cloud Run 서비스 계정)로 고정 audience의 Google ID token을 발급·캐시한다. base-url은 https만. + Brain은 audience와 서비스 계정 email을 검증한다(`internal_api_auth_mode=oidc`). +- `shared-token`: Brain `internal_api_shared_token`과 같은 값. prod profile에서는 기동이 실패한다. +- 구형 agent → Server 내부 API token(`internal.api.token`, `X-Internal-Token`)은 1.87에서 제거됐다(이 client와 무관). +- **미확인(S14)**: 실제 Cloud Run 서비스 계정의 ID token 발급, Brain의 audience·email 검증 통과, IAM invoker, ingress는 + 배포 환경에서 확인해야 한다. 이 태스크는 fake provider·fake Brain으로 동작만 검사했다. + +## 교체 경계 + +- 구형 `ProovyAiRequest.authToken`·thread ID·`/stream/v2` 호출 코드는 1.74(#213)에서 이미 제거됐다. adapter·dual-auth를 두지 않았다. +- 기존 `WebClientConfig.webClient` bean(10분 response timeout)은 바꾸지 않았다. Brain client는 자기 WebClient를 따로 만든다. + +## 후속 태스크 메모 + +- **1.78 (snapshot)**: 브라우저 `ConversationRunRequest` → `InternalRunRequest` 매핑. 첫 전달은 `ExecutionUsage.ZERO`. + history는 공개 Result Block plain text, 현재 input과 같은 user text를 넣지 않는다(생성자가 거부). +- **1.80 (coordinator)**: `stream` 실패 kind별 처리. `isRecoverableByStatusQuery()`면 같은 attempt·fence로 `status`, `isSafeToResend()`면 deadline 안에서 같은 요청 재전송. + 재전달은 `forRetry`. `RUN_CONFLICT`·`REQUEST_ID_BOUND_TO_OTHER_RUN`는 재전달하지 않는다. 현재 fence의 event만 저장한다. +- **1.81 (terminal)**: `SourceEvent`·`StoredTerminal`의 completed·failed payload는 JSON 그대로다. terminal-v2 계약 + (provenance·Verified Solution) 검사는 1.81이 terminal-v2 fixture를 pin해 한다. +- **1.83 (activity)**: activity payload도 JSON 그대로다. activity-v1 fixture pin과 의미 검사는 1.83 몫이다. +- `RunCancelCause`(1.74 entity enum)를 cancel 요청·`run.canceled` cause로 그대로 쓴다. 1.74 enum은 고치지 않고 + `InternalRunWire`의 mix-in으로 wire 철자를 쓴다. +- `withAssetAccess(grant, viewerReadUrl, urls)`의 null은 모두 "기존 값 유지"다. 자격증명을 지우는 API는 없다. +- 필수 배열(history·참조·첨부·claims·requested_*)에 JSON `null`이 오면 wire 읽기에서 거부한다(Brain closed model과 같다). + 코드로 만들 때 생성자의 null은 빈 목록이다. +- `request_deadline_at`은 µs로 자른다(1.75 `wireTime` 관례, Brain pydantic 보존 정밀도). + +## 리뷰 r176 후속 메모 + +- **Brain fixture 추가 요청 (다음 consumer_version)**: fingerprint fixture 27개가 모두 `latex`·`viewer_context`·`selection`을 가진다. + latex·viewer가 없는 가장 흔한 요청 형태의 canonical·sha256이 바이트 단위로 고정돼 있지 않다(`requests/minimal.json`에는 + expected fingerprint가 없다). 코드 대조로는 같지만, proovy-brain에 그 형태의 `fingerprint/` 케이스를 추가해 달라고 요청하고 다음 pin 때 소비한다. + Server 사본은 손으로 고치지 않는다. +- **credential 호출 비용 (1.80 이후 측정)**: shared token·캐시된 ID token도 호출마다 `boundedElastic`으로 옮기고 + `GoogleIdTokenCredentials`의 monitor를 잡는다. 정확성 문제는 없다. 동시 실행이 늘면 측정해서 줄인다. diff --git a/docs/contracts/run-admission.md b/docs/contracts/run-admission.md new file mode 100644 index 00000000..c4cf2a62 --- /dev/null +++ b/docs/contracts/run-admission.md @@ -0,0 +1,80 @@ +# run 접수 — 원자적 요청 접수와 requestId binding (task 1.79, #220) + +브라우저 `POST /api/conversations` body(browser-run-v1 `ConversationRunRequest`)를 판정하고, 승인되면 Note·메시지·PENDING run· +최초 차감·첫 이벤트를 한 transaction으로 저장한다. 계약 의미의 원본은 browser-run-v1 artifact(`docs/contracts/browser-run-v1.md`)다. + +## 타입 (`com.proovy.domain.run.admission`) + +| 타입 | 역할 | +|---|---| +| `RunAdmissionService.admit(userId, JsonNode body)` | 진입점. JSON tree 판정 → 접수 transaction(경합 시 같은 body로 재시도) → `RunAdmission` | +| `RunAdmissionTransaction` | transaction 하나의 판정·저장 | +| `RunAdmission` | commit된 결과: `outcome`(CREATED·REPLAYED)·runId·requestId·noteId·userMessageId·assistantMessageId·공개 status. `REPLAY_FROM_SEQ = 1`, `thenLive()` | +| `RunAdmissionRejectedException` | 스트림 전 거부. `activeRun()`(RUN4091만)·`retryAfterSeconds()`(RUN4291만) | +| `RunAdmissionExceptionHandler` | 거부 → JSON `ApiResponse`(Accept가 SSE여도 JSON), RUN4291은 `Retry-After` | +| `RunRequestBinding` (`run_request_bindings`, V2) | requestId → run, browser 요청 fingerprint 고정 | +| `RunAssetDeletionGuard` | asset hard delete 전 Note 잠금 + run 의존 이력 검사 → ASSET4092 | +| `RunAdmissionProperties` (`proovy.run.admission.*`) | rate limit(`rate-limit.max-requests` 20 / `rate-limit.window` 1m), `max-transactions` 3 | + +## 판정 순서 (`admission/decisions.json` precedence) + +`401(filter) → RUN4001/4002(JSON tree, DB 전) → existing(같은 사용자·같은 fingerprint면 replay, 아니면 RUN4092) +→ note RUN4041 → references RUN4221 → activeRun RUN4091{activeRunId} → rateLimited RUN4291 → balance RUN4021 → accept` + +- "같은 의미"는 browser-run-v1 `BrowserRequestFingerprint`(`browser-request-sha256-canonical-json-v1`)다. `ai_runs.request_fingerprint`는 + internal-run-v1 fingerprint(history·corpus 포함)라 재전송 body로 다시 계산할 수 없어서 `run_request_bindings`에 따로 고정한다. +- binding은 없는데 같은 requestId의 run이나 차감 row가 남아 있으면(Note 삭제로 run이 사라진 옛 ID) RUN4092다. +- RUN4092·RUN4041은 상대 run·Note의 존재·상태를 드러내지 않는 고정 body다. +- rate limit: 최근 window 안 사용자 run(상태·Note 무관, `ai_runs(user_id, created_at)`)이 상한 이상이면 거부. Retry-After는 상한번째로 최근 run이 + window를 벗어날 때까지(초, 올림, 최소 1). replay는 세지 않는다. +- 판정표 23 case 중 22개(인증 제외)를 `RunAdmissionPostgresTest`가 실제 Postgres에서 production 서비스로 돌리고 행 수·잔액을 검사한다. + +## 저장 순서와 잠금 (한 transaction) + +1. noteId가 있으면 소유자 조건으로 Note 행 `FOR NO KEY UPDATE`(asset 삭제와 같은 순서, Note를 참조하는 자식 insert는 막지 않음) → binding 조회 +2. Note 판정(RUN4041) → 잔액 행 잠금(`lockBalanceInCurrentTransaction`, 같은 사용자 접수 직렬화) → **잠금 뒤 `now`를 읽는다**(대기 시간이 created_at·deadline·rate-limit 창을 밀지 않게) +3. noteId가 없으면 Note 생성(제목 = strip 집합으로 공백 정리한 질문 앞 30 code point, 플랜 Note 개수 한도는 적용하지 않음 — 아래 결정 1) +4. `RunSnapshotFactory.create(runId, userId, noteId, request, now)` (1.78) → Active Run 조회 → rate limit +5. user message(`created_at = now`) → assistant placeholder(`now + 1µs`) → `save(toPendingRun(..., now))` +6. `run_asset_dependencies` insert → 그 asset이 아직 같은 Note의 ACTIVE이고 content_version이 snapshot 값과 같은지 `FOR SHARE`로 재검사, VIEWER_FOCUS ⊂ CORPUS 검사(RUN4221) +7. `RequestUsageService.chargeRequest` (1.77, 부족 RUN4021) +8. `run_request_bindings` insert +9. `RunEventStore.appendServerEvent(runId, RUN_CREATED, RunCreatedPayload, now)` (1.80) → seq 1, `last_event_seq = 1`. store가 managed AiRun을 refresh한다 + +어느 단계든 실패하면 전체 rollback이다. Brain 호출·네트워크 대기는 transaction 안에 없다. + +## 경합과 재시도 + +- 원인 사슬의 SQLState가 unique 위반(23505: `uq_ai_runs_request`·`ux_ai_runs_note_active`·binding PK)이나 transient + (40001·40P01·55P03·08xxx)면 rollback 뒤 **같은 body·같은 fingerprint**로 새 transaction을 연다(최대 `max-transactions`). + 번역되지 않은 JPA flush 예외도 같은 기준이다. CHECK·데이터 오류 같은 결정적 실패는 재시도하지 않고 바로 던진다. 새 시도는 먼저 commit된 binding을 찾아 replay하거나 + RUN4091/RUN4092로 답하므로 잘못된 재전송이 새 run이 되지 않는다. commit 여부를 모르는 실패 뒤 재시도도 binding 조회가 먼저다. +- noteId 없는 같은 요청 6건 동시 → Note·run·차감 1개, 모두 같은 식별자. 같은 Note 다른 요청 5건 동시 → 1건 접수, 4건 RUN4091(승자 runId), 메시지 2개. +- 접수와 asset 삭제(단건·일괄): 서로 Note 잠금을 기다린다. 삭제가 먼저면 접수 snapshot corpus에서 빠지고, 접수가 먼저면 삭제가 ASSET4092. + 일괄 삭제의 GCS 삭제는 단건처럼 commit 뒤(`afterCommit`)라 Note 잠금을 쥔 채 네트워크를 기다리지 않는다. + +## 후속 태스크 인계 + +- **1.86 (SSE transport·controller)**: controller는 body를 `JsonNode`로 받아 `RunAdmissionService.admit`을 부른다(바인딩·`@Valid` 금지 — 판정이 tree 기반). + 성공이면 `RunAdmission.runId()`의 event를 seq 1부터 replay(`RunEventStore.findAfter(runId, 0, ...)`)하고 `thenLive()`면 live로 잇는다. + CREATED·REPLAYED 모두 같은 처리다. 거부는 예외를 그대로 던지면 `RunAdmissionExceptionHandler`가 JSON으로 답한다. + 아직 `POST /api/conversations` 경로는 없다. +- **1.83 (dispatch)**: 접수는 PENDING run만 남긴다(attempt 0, fence 없음). dispatch 트리거·큐는 없다 — PENDING 조회(`idx_ai_runs_pending_dispatch`)로 가져간다. +- **1.84 (취소)**: `cancel/cases.json` 판정표를 production 취소 판정기로 다시 돌리는 테스트는 1.84 몫이다(이 PR은 admission만). +- **1.82**: 접수는 `RunCreatedPayload.requestedTasks/Outputs`에 요청 값을 그대로(없으면 `[]`) 싣는다. + +## 결정 (r179 사용자 결정) + +1. **자동 생성 Note에 플랜 Note 개수 한도를 적용하지 않는다(현행 유지).** FREE `noteLimit = 2`(STANDARD 10, PRO 20)를 noteId 없는 접수 + 경로가 우회한다 — S14 재검토. 적용하려면 browser-run-v1 계약 변경(새 code·fixture case)이 필요하다. +2. rate limit 기본값(1분 20건)은 임시값이다 — S14 운영 결정. Note 삭제로 run이 사라지면 그만큼 카운트에서 빠진다. +3. **run이 의존한 asset은 단건·일괄 삭제가 ASSET4092(409)다(임시 동작 수용).** 이전에는 같은 삭제가 commit 시 deferred FK 위반 500이었다. + 화면 문구는 S08, 정식 삭제(DELETING 전환으로 삭제 성공)는 S13으로 인계한다. +4. 자동 Note 제목은 질문 앞 30 code point(현행 유지). Gemini 제목 생성은 접수 transaction 밖이고, 후속 제목 생성 담당 태스크는 **미정**이다. + +## 후속 메모 + +- **1.75 계약 후속 (r179 R2)**: `ConversationRunRequestValidator`는 U+0000을 거부하지 않는다. Postgres text·jsonb가 받지 않아(22P05) 접수가 + 매핑되지 않은 500으로 끝난다(재시도는 하지 않음, 부작용 없음). 다음 consumer_version에서 NUL(필요하면 다른 제어 문자)을 RUN4001로 고정한다. +- **S09/S13 (r179 R6)**: 의존성 재검사가 content_version을 비교하므로, 재처리·교체로 version을 올리는 기능이 생기면 겹친 접수는 RUN4221로 거부된다. + version 변경 경로도 접수와 같은 Note 잠금 순서를 따를지 그때 정한다. diff --git a/docs/contracts/run-cancel.md b/docs/contracts/run-cancel.md new file mode 100644 index 00000000..a987b784 --- /dev/null +++ b/docs/contracts/run-cancel.md @@ -0,0 +1,139 @@ +# run 취소 — 명시적 cancel·CANCELED 확정·상태별 사용량 (task 1.84, #224) + +브라우저 `POST /api/ai-runs/{runId}/cancel`(browser-run-v1 `cancel/cases.json`)의 구현이다. 계약 의미는 바꾸지 않았다. +상태 조회 `GET /api/ai-runs/{runId}`는 1.86(`RunReadService.status`) 그대로다(`cancelable` = PENDING|RUNNING). + +코드: `com.proovy.domain.run.cancel` — `RunCancelService`(진입점), `RunCancelStore`(잠금·조건부 전환), `RunCancelFinalizer`(CANCELED 확정), +`RunCancelProperties`(`proovy.run.cancel.*`). 경로는 1.86 `BrowserRunController`에 추가했다. **schema 변경 없음**(V1 cancel 열·trigger 그대로, migration 추가 없음 — V5는 1.85 `recovery_count`). + +## 판정 순서 (`cancel/cases.json` precedence) + +`401(filter) → body 있음 RUN4001(run 조회 전, `{}`·어떤 필드든) → run 접근 RUN4041 → 상태` + +- body는 원문 `String`으로 받는다(Content-Type 무관). 비어 있지 않은 body면 거부한다. +- RUN4041: 없는 run·타인 run·대문자/형식이 다른 runId·Note DELETING을 구분하지 않는 같은 body(1.86 `accessibleRun` 재사용, 잠금 뒤 다시 확인). + +| 잠금 뒤 상태 | 응답 `{status, outcome}` | 같은 transaction의 쓰기 | +|---|---|---| +| PENDING | `CANCELED`, `accepted` | `PENDING∧attempt=0 → CANCELED`, message CANCELED, `run.canceled`, 보상 `PENDING_CANCELED` | +| RUNNING·RETRYING | `CANCELING`, `accepted` | `→ CANCELING`(현재 attempt·fence 조건, `cancel_requested_from` = 이전 상태, grant 폐기), `run.canceling` | +| CANCELING | `CANCELING`, `already_canceling` | 없음 (확정 확인을 한 번 깨운다) | +| COMPLETED·FAILED·CANCELED | 그 상태, `already_terminal` | 없음 | + +잠금: `notes FOR SHARE → ai_runs FOR UPDATE → credit_balance`(1.82와 같은 Note 우선 순서). 직렬화·교착·연결 실패만 최대 3번 다시 연다. +coordinator claim은 `SKIP LOCKED` + `status='PENDING' AND attempt=0`이라 PENDING 취소와 하나만 이긴다(claim이 이기면 RUNNING 취소 경로). + +## CANCELED 확정 (`RunCancelFinalizer`) + +취소 commit 직후 한 번, 그 뒤 `sweep-interval`마다 DB의 CANCELING run(오래된 요청부터 `batch-size`)을 확인한다. 재시작·다른 인스턴스의 취소도 +이어받고, 확정은 `status='CANCELING'` 조건부라 여러 인스턴스가 겹쳐도 하나만 이긴다. run별 동시 확인은 하나다. + +한 번의 확인 = 현재 attempt·fence·cause로 Brain `cancel`(멱등). 처음이면 취소 전달, 그 뒤로는 ack `state`가 정리 상태 조회다. + +| ack state | 판정 | +|---|---| +| `terminal` (run.canceled 저장, 또는 취소 전 끝난 결과) | CANCELED | +| `absent` (record 없음 = 미시작) | run lease가 만료됐고 이 인스턴스에서 실행 중이 아니면 CANCELED. 살아 있는 worker가 아직 stream을 보낼 수 있으면 기다린다. 확정 transaction에서 run을 잠근 뒤 lease 만료를 **다시** 본다(Brain 호출 동안 늦은 heartbeat가 갱신했으면 기다림) | +| `canceling`·`executing`·호출 실패(5xx·timeout·409 등)·Brain client 없음 | 기다린다 | +| (모든 경우) `cancel_requested_at + cleanup-timeout` 경과 | WARN 뒤 CANCELED (안전 timeout) | + +- timeout 시각은 **DB `now()` 기준**이다(`cancel_requested_at = now()`로 쓰고, 확인 SELECT가 남은 시간을 DB에서 계산). 인스턴스 사이 JVM 시계 차이가 확정 시점을 바꾸지 않는다. +- 이미 timeout이 지난 run은 **Brain을 부르지 않고** 확정한다. 남은 시간이 client `request-timeout`보다 짧으면 Brain 응답을 남은 시간까지만 기다린다(hang인 Brain이 확정을 늦추지 않음). + +확정 transaction(하나): `CANCELING → CANCELED`(lease·grant 해제), message CANCELED, `run.canceled{cause, message}`, 1.77 `settle(canceled(from, cause))`, +`RunClaimStore.reserveRuntimeCleanup`(attempt ≥ 1, 지연은 `proovy.run.coordinator.runtime-cleanup-delay`, coordinator가 꺼진 인스턴스는 0s). +cleanup 예약이 같은 transaction이라 commit 뒤 응답 유실·process 사망·예약 SQL 오류로 `runtime_cleanup_at`이 NULL로 남는 CANCELED는 없다(예약이 실패하면 확정 전체가 rollback되고 다시 확인한다). +commit 뒤 `RunCoordinator.release(runId)` — commit 여부를 모르는 재시도가 이미 CANCELED를 보더라도 부른다. coordinator 패키지는 고치지 않았다(공개 hook만). + +- **실패 행**: 확인이 예외로 끝난 run은 `failure-backoff`(기본 30s) 동안 주기 확인 SQL에서 빠진다(`run_id <> ALL(…)`) — 계속 실패하는 오래된 행이 + oldest-first batch를 막지 않는다. 일시 DB 오류는 WARN, 그 밖(불변조건 위반: 열린 assistant placeholder 없음·seq 불일치 등)은 ERROR, counter + `proovy.run.cancel.check_failures{kind=transient|invariant}`. 같은 run의 취소 API 재요청(`wake`)은 backoff와 무관하게 곧바로 확인한다. + 불변조건 위반으로 계속 실패하는 run은 timeout 확정도 같은 transaction을 타므로 CANCELING에 남고 슬롯·Note 자리를 쥔다 — **운영 수동 개입 대상**(ERROR·counter로 감지). +- counter `proovy.run.cancel.confirmed{reason=brain_terminal|brain_absent|cleanup_timeout}`. 주기 SELECT 자체가 실패하면 처음 한 번 WARN, 회복 시 INFO(DB 장애 중 로그 반복 없음). + +- 확정 전까지 run은 비terminal이라 **전역 슬롯(1.83 DB 수)과 Note Active Run 자리(`ux_ai_runs_note_active`)를 계속 차지한다**. 같은 Note 새 요청은 RUN4091. +- CANCELING 동안: Activity는 열린 Activity의 `activity.canceled`만(1.80), terminal·`failByServer`·`expireDeadline`은 NOT_ACCEPTING(1.82), deadline sweep 대상 아님. + worker는 stream을 마저 소비해 wind-down 표식을 저장하고 status `canceling`/`run.canceled`로 OBSOLETE로 끝난다(1.83). +- 스레드: 내부 executor 2개(`runCancel-*`), Spring `Executor`·`TaskScheduler` bean 아님. Brain 호출 중 transaction·row lock 없음. + stop 때 `inFlight`·backoff를 비워 stop → start(context restart) 뒤에도 run이 거부되지 않는다. +- lifecycle phase는 web server start/stop·graceful shutdown보다 앞(`DEFAULT_PHASE − 4096`): 요청을 받기 전에 시작하고 요청이 멈춘 뒤에 멈춘다. + 그 밖 구간(executor 없음)의 `wake`는 버려지고 주기 확인(다른 인스턴스·재시작 포함)이 줍는다. **`enabled=false`만으로 구성한 배포에서는 그런 run이 CANCELING에 남는다** — 점검·테스트 전용. + +## 사용량 (1.77 정책표 그대로) + +| 취소 | 순사용량 | +|---|---| +| PENDING에서 (어떤 cause든) | 0 — `PENDING_CANCELED` 한 번 | +| RUNNING·RETRYING에서 `user_requested` | 1 — 차감 유지(Brain 정리 확인·timeout 무관) | + +ledger PK(request_id)로 PENDING 취소 보상과 deadline·Server 실패 보상이 겹쳐도 한 번이다(경합 테스트). + +## 설정 (`proovy.run.cancel.*`, `@DefaultValue` — application.yaml 변경 없음) + +| key | 기본 | 의미 | +|---|---|---| +| `enabled` | true | 주기 확인. 끄면 취소 직후 한 번만 확인(점검·테스트용) | +| `sweep-interval` | 2s | CANCELING 재확인 주기(run당 Brain cancel 1회) | +| `cleanup-timeout` | 2m | Brain 정리 확인 상한. 근거: Brain wind-down 15s, 끊긴 wind-down sweep은 writer lease 만료 뒤, Brain tail 4 × lease TTL 30s. **S14 확정** | +| `batch-size` | 20 | 주기 한 번 확인 수 | +| `failure-backoff` | 30s | 확인이 실패한 run을 주기 확인에서 빼 두는 시간 | + +**부하(S14)**: 주기 확인은 leader·lease 게이팅 없이 모든 인스턴스가 돈다 — N 인스턴스 × batch × (1/`sweep-interval`)만큼 Brain cancel(멱등)이 나간다 +(기본 20행·2s면 인스턴스당 최대 초당 10회). 정확성 문제는 없고(확정은 조건부), coordinator lease 보유자 전용 sweep 또는 `FOR UPDATE SKIP LOCKED` 분할은 +S14 부하 인수에서 정한다. + +## 반복 요청 제한 (1.79 재사용 — 사용자 결정: 추가 상한은 후속 태스크) + +이 태스크 범위는 1.79 RUN4291(`proovy.run.admission.rate-limit`, 기본 20건/1분, 임시값) 재사용뿐이다. 테스트로 고정한 성질: +새 requestId마다 세고, **취소·실패·clarification으로 순사용량 0이 된 run도 센다**(취소로 우회 불가), 같은 requestId 재조회(replay)는 세지 않고, +다른 사용자에 영향 없음. + +**후속 태스크(S14 수치와 함께)** — task 84 문구 대비 남은 차이와 제안: + +1. 반복 실패/clarification 남용을 "구분"하는 별도 제한이 없다 — 모든 새 요청을 같은 창으로 센다. 무료 결과(보상된 run) 누적 상한(예: 1시간 N건)이 없어 + 20건/분 안에서는 무료 Brain 실행을 계속 만들 수 있다. +2. "입력·준비 상태 보완 요청" 구별이 없다 — 보완 요청도 같은 창으로 센다(더 엄격한 제한이 없으므로 부당하게 막히지도 않는다). +3. 제안: 보상된 run을 browser fingerprint(`run_request_bindings`) 기준으로 세는 추가 상한(같은 fingerprint 반복만 제한 → 보완 요청은 fingerprint가 달라 제외). + browser-run-v1 오류 code는 RUN4291 그대로 쓸 수 있다. 창·상한 수치는 기존 20건/1분과 함께 S14에서 정한다. + +## 1.85 회수 결정표 중 1.84 소유 항목 + +1.85(#225, `docs/contracts/run-coordinator.md` stale 회수 절)가 1.84에 넘긴 두 항목의 판정이다. + +- **`FINAL_CANCEL_PENDING`(Brain canceling인데 Server RUNNING|RETRYING)** — 1.84 흐름에서는 생기지 않는다. Brain `cancel`을 부르는 곳은 + `RunCancelFinalizer` 하나이고, 대상은 `status = 'CANCELING'` 행만 읽어 고른다(취소 API는 CANCELING을 commit한 **뒤** `wake`, sweep도 같은 조건). + CANCELING → RUNNING|RETRYING 전이는 없으므로 Server가 Brain보다 늦게 취소 상태가 되는 순서가 없다. 그래서 1.84는 이 경우를 수습하지 않는다 + (coordinator 패키지 변경 금지이기도 하다). 이 counter·WARN이 보이면 Server 밖에서 Brain cancel이 들어온 것(운영 수동 호출, S13 `asset_deleting` 연결 오류 등)이고, + run은 1.85 규칙대로 deadline sweep이 timeout으로 끝낸다. +- **`FINAL_CHECK`·재전달 소진 실패 직전 Brain cancel** — **이 PR에서 하지 않는다.** 실패시키는 곳이 `RunExecution`(coordinator 패키지, 이 태스크에서 수정 금지)이고, + internal-run-v1 `CancelCause`(`user_requested`·`asset_deleting`·`budget_exhausted`)에 Server 실패를 뜻하는 값이 없어 계약 추가가 먼저다. + 그동안 슬롯 over-commit은 1.85 r185-3대로 Brain 자체 상한에 기댄다. 후속(S13, Brain cleanup·lease 만료 sweep과 함께): coordinator가 실패 직전 + `BrainRunClient.cancel`(1.84가 쓰는 client 그대로, 멱등)을 best-effort로 부르고, 응답과 무관하게 `failByServer`한다 — 확정을 Brain 응답에 묶지 않는다. +- 1.85 merge 뒤 확인: 회수 후보·인수는 RUNNING|RETRYING만이고 heartbeat 조건은 CANCELING을 포함하므로, 인수 worker가 도는 동안 취소된 run은 + lease가 살아 있어 `absent` 확정이 기다리고(위 표), worker는 OBSOLETE로 끝난다. 1.84 코드 변경은 필요 없었다. + +## 검증 + +- `RunCancelPostgresTest`(실제 Postgres + 실제 HTTP + 실제 `BrainRunClient`·loopback 가짜 Brain, coordinator 끔): `cancel/cases.json` 9 case를 production + endpoint로(fixture body 일치, 404 body 동일), 대문자 ID·DELETING Note·401, PENDING 취소(payload = `canceled-pending` fixture, 보상 1회, Brain 미호출, 슬롯 해제), + 동시 cancel 6건, PENDING 취소 ↔ claim 8회, PENDING 취소 ↔ deadline·Server 실패, RUNNING 취소(늦은 terminal·Activity·deadline 차단, RUN4091, 슬롯 계산, + Brain cancel 인자, 확정 뒤 차감 유지·cleanup 예약), cleanup timeout(정리 미확인·Brain 503), absent + lease, 완료 ↔ 취소, deadline ↔ 취소, SSE 단절 ≠ 취소, + 반복 요청 제한, absent 확정의 잠금 뒤 lease 재확인(Brain 호출 중 heartbeat 갱신), timeout이 지난 run의 Brain 미호출·hang Brain의 남은 시간 상한, + 확정 실패 run의 backoff(확정·cleanup 예약 전체 rollback, 재요청 `wake`는 즉시 확인). 두 class는 `@DirtiesContext`로 끝난 context의 sweep을 닫는다. +- `RunCancelCoordinatorPostgresTest`(실제 coordinator worker, 슬롯 1): wind-down `activity.canceled` 저장, worker OBSOLETE 뒤에도 CANCELING이 슬롯을 잡아 + 다음 run이 PENDING, Brain 정리 확인 뒤 CANCELED → 다음 run dispatch, 취소 run 재전달 없음. event 순서는 `canceled-running` fixture와 같다. + +## 범위 밖·인계 + +- **1.87**: 경로 `POST /api/ai-runs/{runId}/cancel`(body 없음, `ApiResponse`). 실제 Brain cancel 왕복·wind-down 시간 실측, timeout 수치 확인. +- **1.85**: CANCELING run(lease 만료 포함)은 이 경로 소유다. 1.85 회수는 RUNNING|RETRYING만 다룬다. +- **S13**: `asset_deleting` cause 취소 연결(이 store의 `cancelPending`·`markCanceling`이 cause를 받는다), Brain runtime `cleanup` 호출, + `RunCancelStore.inTransactions`는 호출자 transaction이 열려 있으면 거기 참여하고 **재시도하지 않는다**(RunTerminalService와 같은 규칙) — 재시도가 필요하면 + S13 쪽 transaction 밖에서 부른다. commit 뒤 `RunCancelFinalizer.wake`도 S13이 부른다. + 1.85 `FINAL_CHECK`·재전달 소진 실패 직전 best-effort Brain cancel(위 1.85 절, `CancelCause` 계약 추가 필요). +- **후속 태스크(S14 수치와 함께)**: 보상된 run의 같은 fingerprint 반복 추가 상한(위 반복 요청 제한 절). 수치 확정 대상: `cleanup-timeout` 2m, + `sweep-interval` 2s, 1.79 rate limit 20건/1분, 추가 상한의 창·건수. +- **S08**: 정지 버튼·draft 유지(서버는 자동 재실행·queue·draft 전송을 만들지 않는다). +- Brain cancel 409(fence 불일치·다른 epoch)는 기다림으로 처리해 timeout 뒤 확정된다(새 attempt 금지 전제상 발생하지 않아야 한다). +- 후속 정리: 재시도 SQLSTATE 판정(`isRetryable`)이 RunTerminalService·RunAdmissionService·RunCancelStore 세 곳에 있다 — 공통 helper(추가형 파일)로 모으는 후속을 둔다. + `cancel_cause` 해석은 `RunCancelCause.Converter`를 재사용한다. diff --git a/docs/contracts/run-coordinator.md b/docs/contracts/run-coordinator.md new file mode 100644 index 00000000..0cc4a419 --- /dev/null +++ b/docs/contracts/run-coordinator.md @@ -0,0 +1,233 @@ +# run coordinator — 브라우저 독립 claim·전역 슬롯·bounded dispatch (task 1.83, #222) + +접수(1.79)가 commit한 PENDING run을 브라우저 연결과 무관하게 단일 Server coordinator가 claim해 전역 유한 슬롯 안에서 Brain에 위임하고, +Activity는 1.80 `RunEventStore.appendActivity`, terminal은 1.82 `RunTerminalService`로 넘긴다. 저장·사용량·event 처리기는 새로 만들지 않았다. + +코드: `com.proovy.domain.run.coordinator`. schema: `V4__run_coordinator_lease.sql`(`run_coordinator_leases`), `V5__run_recovery_count.sql`(`ai_runs.recovery_count`, 1.85). +stale 실행 회수(task 1.85, #225)는 아래 [stale 회수](#stale-회수-task-185-225) 절이다. + +## 타입 + +| 타입 | 역할 | +|---|---| +| `RunCoordinator` (SmartLifecycle) | coordinator lease, claim loop, 전역 슬롯, heartbeat, deadline sweep, stale 회수(1.85), 종료 정리. hook: `isExecuting`·`release`·`isLeader`·`slotsInUse` | +| `RunExecution` | run 하나의 worker(stream 소비·실패 kind 처리·status 수습). `Mode`: `DISPATCH`(claim 직후) / `RECOVER`·`FINAL_CHECK`(stale 인수, 1.85) | +| `RunClaimStore` | DB 경계(coordinator lease, claim, run lease, RETRYING 표시, lease 만료, cleanup 예약, deadline 조회, stale 조회·인수·Brain 시작 증거). 각 호출은 짧은 단일 문장 또는 자기 transaction | +| `ClaimedRun` | 배정된 `(runId, attempt, fence)` | +| `RunAssetAccessProvider` | 전달마다 grant·read URL 발급. 기본값은 `RunAssetAccess.NONE`(S09/S10이 교체) | +| `RunCoordinatorProperties` | `proovy.run.coordinator.*` | + +## 실행 설정 (`proovy.run.coordinator.*`) + +| 키 | 기본 | 의미 | +|---|---|---| +| `enabled` | `false` (`RUN_COORDINATOR_ENABLED`) | 켜면 Brain client(`proovy.brain.base-url`)가 필요하다. 없으면 기동 실패 | +| `instance-id` | host-무작위 | lease owner | +| `max-concurrent-runs` | 4 | 전역 실행 슬롯. `brain-capacity.instances × per-instance`(3×4)를 넘으면 기동 실패 | +| `poll-interval` | 1s | PENDING 조회 주기. 슬롯이 반환되면 곧바로 한 번 더 조회한다 | +| `heartbeat-interval` / `lease-ttl` | 15s / 90s | lease-ttl ≥ 3 × heartbeat (docs/plan/09 시작값) | +| `deadline-sweep-interval` | 5s | `expireDeadline` 주기 | +| `min-dispatch-remaining` | 5s | 남은 deadline이 이보다 짧으면 claim하지 않는다(sweep이 timeout으로 끝낸다) | +| `redelivery.max-resends` / `max-status-checks` | 6 / 3 | 같은 attempt 재전달 합계 / 연속 status 조회 실패 한도 | +| `redelivery.initial-backoff` / `max-backoff` | 1s / 15s | 지수 backoff | +| `recovery.interval` / `max-recoveries` / `batch` | 5s / 3 / 8 | stale 회수 주기 / run 하나의 인수 허용 횟수(`recovery_count` 누적) / 주기당 인수 시도 수 (1.85) | +| `runtime-cleanup-delay` | 0s | terminal 뒤 `runtime_cleanup_at` 예약 지연 | +| `shutdown-timeout` | 8s | 종료 전체 대기 예산(control·worker 합계). Cloud Run 종료 유예(10s) 안에 끝나야 한다 — S14 배포 값과 함께 확정 | + +스레드는 coordinator 내부 executor 두 개다(`runCoordinator-control-*` 2개, `runCoordinator-worker-*` 슬롯 수만큼; 대기열 길이는 semaphore가 0..max로 제한한다). +Spring `TaskScheduler`/`Executor` bean으로 노출하지 않는다. bean으로 두면 Boot 기본 `taskScheduler`·`applicationTaskExecutor`가 물러나 전역 기본값이 바뀐다. +SecurityConfig·SSE·hikari·`spring.task.*`·`server.*`는 바꾸지 않았다. DB connection은 heartbeat·claim·append마다 잠깐만 쓰고 stream 대기 중에는 쥐지 않는다. + +## 단일 coordinator와 전역 슬롯 + +- **coordinator lease**: heartbeat마다 `run_coordinator_leases('run')`을 조건부 upsert한다(같은 owner면 갱신, 만료됐으면 인수). 보유자만 claim·deadline sweep을 하고, + 나머지 인스턴스는 passive로 남아(WARN 로그, gauge `leader=0`) 주기마다 다시 시도한다. claim transaction이 lease를 `FOR SHARE`로 다시 확인하므로 + lease가 넘어간 뒤의 claim은 없다. 종료하면 worker 대기 전에 lease를 먼저 내놓는다. lease 갱신 자체가 실패하면 `leader=false`로 내려 claim을 멈추고, + run heartbeat는 따로 계속한다. **Cloud Run 다중 인스턴스에서도 coordinator는 하나만 활성이다.** + 다중 coordinator 동시 활성(공용 admission)은 S14 검증 범위이고 지금은 지원하지 않는다. +- **빈 슬롯** = min(로컬 semaphore, `max-concurrent-runs` − DB의 비terminal 실행 수(`attempt >= 1 AND status IN (RUNNING, RETRYING, CANCELING)`, **lease 무관**)). + 다른 인스턴스·이전 프로세스·heartbeat가 끊긴 실행도 Brain에서 아직 돌 수 있으므로 terminal(완료·deadline sweep·회수·취소 확정)이 될 때까지 슬롯을 차지한다. + 배포 교체 때도 새 coordinator가 Brain 동시 실행을 max 이상으로 올리지 않는다. 고아 run의 상한은 deadline sweep이 보장한다(CANCELING은 1.84 확정). + dispatch는 한 번에 하나만 돈다(주기 dispatch와 슬롯 반환 뒤 dispatch가 같은 DB 수로 각자 claim하지 않게). 이미 돌고 있으면 기다리지 않고 재실행만 표시해 control thread(heartbeat·sweep)를 묶지 않는다. + 슬롯이 없으면 run은 PENDING으로 남는다(무한 in-memory queue 없음). +- Brain 503 capacity·429는 슬롯의 대체물이 아니다. 같은 요청을 bounded 재전송하는 transient다. + +## claim과 attempt·fence + +```sql +-- coordinator lease FOR SHARE 확인 뒤 +WITH picked AS (SELECT run_id FROM ai_runs WHERE status = 'PENDING' AND request_deadline_at > now() + :minRemaining + ORDER BY created_at, run_id LIMIT 1 FOR UPDATE SKIP LOCKED) +UPDATE ai_runs SET status = 'RUNNING', attempt = 1, fence_token = :newFence, started_at = now(), lease_owner = :owner, + lease_expires_at = now() + :ttl, heartbeat_at = now() +FROM picked WHERE run_id = picked.run_id AND status = 'PENDING' AND attempt = 0 RETURNING run_id +``` + +- **attempt는 이 claim에서만 0 → 1로 오른다.** 재전달은 언제나 같은 attempt·fence·요청이다. Brain(run-decisions-v2)에서 같은 attempt 재전달은 + START(record 없음 = 미시작) / OBSERVE(같은 실행에 다시 붙기) / RETURN_TERMINAL만 만든다. 더 높은 attempt는 Brain이 새 graph epoch로 adopt하므로 보내지 않는다. + 그래서 소실된 graph를 새로 시작하지 않는다. +- 요청은 `RunSnapshotRestorer.delivery(run, access)`로만 만든다. 저장 snapshot, `request_deadline_at`, run 행 누적 usage(단조, DB trigger)를 그대로 쓴다. + 재전달·재조회가 deadline이나 예산을 초기화하지 않는다. 복원이 실패하면(`RunSnapshotIntegrityException`) `failByServer(INTERNAL_ERROR)`로 끝낸다. +- `SKIP LOCKED`이라 취소 transaction(1.84)이 잡은 PENDING run은 건너뛴다. 잠그는 것은 run 행 하나라 Note → run 순서와 교착하지 않는다. + +## worker (RunExecution) + +stream은 transaction 없이 소비한다(`toStream` + 멈춤 신호 `takeUntilOther`). frame마다 짧은 자기 transaction을 연다. + +| 수신 | 처리 | +|---|---| +| `activity.*` | `appendActivity`. `isStreamObsolete()`(STALE_FENCE·UNKNOWN_RUN)면 소비를 멈춘다. NOT_ACCEPTING(CANCELING 등)은 계속 소비하고, MALFORMED·OUT_OF_ORDER는 WARN만 남긴다 | +| `run.completed`·`run.failed` | `settle(SourceEvent)` → 아래 settlement 처리 | + +| settlement | 처리 | +|---|---| +| COMMITTED·DUPLICATE | `runtime_cleanup_at` 예약 → SETTLED | +| DEADLINE_PASSED | `expireDeadline` | +| REJECTED·SCOPE_NOT_READY | reason·field만 로그(payload 없음) → `failByServer(INTERNAL_ERROR, true)` | +| STALE_FENCE·NOT_ACCEPTING·UNKNOWN_RUN·CONFLICTING_DUPLICATE | OBSOLETE (다른 경로가 끝냈다) | + +실패 kind(1.76 `BrainFailureKind`) 처리: + +| 범주 | 처리 | +|---|---| +| `isSafeToResend` (CONNECT_FAILED·CAPACITY·CREDENTIALS_UNAVAILABLE) | `RUNNING|RETRYING → RETRYING`, `retry_count + 1` → backoff → 같은 요청. 다음 stream의 첫 frame에서 `RUNNING` | +| `isRecoverableByStatusQuery` (TRANSPORT_LOST·TIMEOUT·DEADLINE_EXCEEDED·UNAVAILABLE·STREAM_ENDED_WITHOUT_TERMINAL) | 같은 attempt·fence로 `status`: **absent**면 Brain 시작 증거가 없을 때만 같은 요청 재전송(#215), 증거가 있으면 실행 소실 → `failByServer(PROVIDER_ERROR)`(1.85, 아래), **같은 epoch executing**이면 같은 요청으로 다시 붙고, **terminal**이면 `settle(StoredTerminal)`, `run.canceled`·canceling·다른 epoch면 OBSOLETE, fingerprint 불일치는 `failByServer(INTERNAL_ERROR)`. status 실패는 `max-status-checks`까지 backoff | +| 그 밖(영구) | `failByServer(INTERNAL_ERROR, true)`. `REQUEST_ID_BOUND_TO_OTHER_RUN`은 ERROR 로그(Server 버그) | + +- 재전송·재연결은 `max-resends` 합계와 고정 deadline 안에서만 한다(다음 backoff 뒤 시각이 deadline을 넘으면 시작하지 않는다). 한도를 다 쓰면 deadline 전에는 + `failByServer(PROVIDER_ERROR, true)`, 지났으면 `expireDeadline`(timeout)이다. 둘 다 1.82가 보상을 한 번 처리한다. **transport 종료는 성공이 아니다.** +- **재전달마다 자격증명 재발급**: 첫 전달 뒤 모든 재전달(capacity·status 뒤 재연결) 전에 `RunAssetAccessProvider.accessFor`로 grant·read URL을 새로 받아 + `withAssetAccess`로 갈아 끼운다(fingerprint 불변). 이전 전달의 값을 다시 보내지 않는다. +- **일시 DB 오류**(Activity·terminal 저장 중 `TransientDataAccessException`·`RecoverableDataAccessException`·`DataAccessResourceFailureException`·`CannotCreateTransactionException`): + 그 stream을 끊고 같은 재전달 한도 안에서 backoff 뒤 status 수습에 합류한다. Brain은 같은 요청에 OBSERVE·RETURN_TERMINAL로 답하고 Activity는 source ID로 dedup된다. + 수습 중에도 DB를 쓸 수 없거나 다른 예외면 LOCAL_ERROR다. +- deadline sweep은 `PENDING|RUNNING|RETRYING AND request_deadline_at <= now`를 `expireDeadline`으로 끝내고(슬롯 대기 중 PENDING 포함), cleanup을 예약하고, + 로컬 worker를 멈춘다. run별로 예외를 잡아 한 run의 실패가 batch를 막지 않는다. + +## 슬롯 해제 조건 + +로컬 permit은 worker가 끝나면 **항상** 반환하고 곧바로 dispatch를 한 번 더 돌린다. 반환 직후 그 thread가 아직 비지 않았어도 다음 run은 거부되지 않고 +pool 대기열에서 잠깐 기다린다(대기열 길이는 permit 수가 막는다). pool이 종료 중이라 거부되면 run을 실패시키지 않고 lease를 만료시켜 회수 대상으로 남긴다. +**전역 슬롯은 permit이 아니라 DB 비terminal 수로 센다** — 아래 OBSOLETE·STOPPED·LOCAL_ERROR에서 run이 아직 비terminal이면 terminal까지 슬롯을 계속 차지한다. + +| 결과 | run 상태 | +|---|---| +| SETTLED | terminal(이 worker 또는 이미 같은 결과) | +| OBSOLETE | 다른 경로(취소·deadline·다른 fence)가 맡음. heartbeat를 멈추고 lease는 건드리지 않는다(TTL에 만료). CANCELING이면 슬롯은 1.84 확정까지 유지된다 | +| STOPPED | 종료(SHUTDOWN)·lease 상실(LEASE_LOST)·`release` 호출(FINISHED_ELSEWHERE). 진행 중으로 남는다. SHUTDOWN은 lease를 즉시 만료시킨다 | +| LOCAL_ERROR | 수습할 수 없는 로컬 오류(일시 DB 오류는 먼저 status 수습으로 합류, 아래). lease를 즉시 만료시킨다(회수 대상) | + +heartbeat가 0 row면(terminal·fence 교체·lease 인수) 그 worker를 LEASE_LOST로 멈춘다. heartbeat 조건은 `RUNNING|RETRYING|CANCELING` + attempt·fence·owner다. +종료할 때는 하나의 예산(`shutdown-timeout`) 안에서 control task(heartbeat·claim)가 끝나기를 기다리고, coordinator lease를 내놓은 뒤 worker를 멈춘다. 그래야 늦게 도착한 heartbeat가 만료된 lease를 되살리지 않는다. Brain 실행은 취소하지 않는다. + +## 관측 + +Micrometer(`proovy.run.coordinator.*`): gauge `slots.in_use`·`slots.max`·`leader`, counter `claims`·`redeliveries{kind}`·`status_checks{result}`· +`executions{result}`·`server_failures{code}`·`deadline_expired`·`heartbeat.lost`·`errors{stage}`. tag는 고정 enum 이름뿐이다. +로그에는 runId·attempt·kind·outcome·SQL 예외 class 이름만 남는다. payload·grant·URL·fence 외 요청 값은 남기지 않는다. + +## 1.84·1.85 인계 (hook·제약) + +- **1.84 취소** + - PENDING 취소: claim이 `SKIP LOCKED` + `status = 'PENDING' AND attempt = 0` 조건부라 취소 transaction과 겹쳐도 하나만 이긴다. + - RUNNING/RETRYING → CANCELING: worker는 계속 stream을 소비한다. `appendActivity`가 열린 Activity의 `activity.canceled`만 받고, terminal은 NOT_ACCEPTING → OBSOLETE로 worker가 끝난다. + Brain cancel 뒤 stream이 terminal 없이 끝나면 status가 canceling·`run.canceled`를 보여 OBSOLETE가 된다. + - **OBSOLETE 규칙(하나)**: worker가 끝나면 heartbeat가 멈추고 lease는 TTL에 만료된다. 그러나 CANCELING run은 비terminal이라 **전역 슬롯은 1.84가 CANCELED를 확정할 때까지 유지된다** + (Brain 정리 전 슬롯을 풀지 않음). 그래서 1.84는 coordinator heartbeat·lease에 의존하지 않는다. worker를 먼저 끝내려면 `RunCoordinator.release(runId)`를 부른다. + - Brain cancel 호출·CANCELED 확정·`run.canceled` 저장 terminal 처리는 1.84 몫이다. 1.84도 terminal 뒤 `RunClaimStore.reserveRuntimeCleanup`을 부른다. +- **1.85 회수** — 구현됨, 아래 [stale 회수](#stale-회수-task-185-225) 절. CANCELING run(lease 만료 포함)은 1.84 소유라 회수 대상이 아니다. +- **R7 자격증명**: S10 전에는 grant·viewer read URL을 넘기지 않는다(`RunAssetAccess.NONE`). Viewer Focus의 Page Understanding cache miss에서 Brain이 URL을 요구하면 + Brain terminal(실패)로 끝난다. S09/S10이 `RunAssetAccessProvider`를 교체하고 전달마다 새로 발급한다. +- **runtime cleanup**: `runtime_cleanup_at`(attempt ≥ 1인 terminal run)만 예약한다. Brain `cleanup` 호출·retention은 S13이다. + +## stale 회수 (task 1.85, #225) + +STOPPED(종료·lease 상실)·LOCAL_ERROR·이전 프로세스·다른 인스턴스가 남긴 run은 `RUNNING|RETRYING` + 만료 lease다. leader coordinator가 +`recovery.interval`마다 이런 run을 같은 attempt·fence로 인수해 **status부터** 수습한다. graph 자동 resume은 없다(ADR 0008). + +### 후보와 인수 + +```sql +-- 후보 (idx_ai_runs_stale_lease) +SELECT run_id FROM ai_runs WHERE status IN ('RUNNING','RETRYING') AND attempt >= 1 + AND lease_expires_at <= now() AND request_deadline_at > now() ORDER BY lease_expires_at LIMIT :batch +-- 인수: coordinator lease FOR SHARE 확인 뒤 (같은 transaction) +UPDATE ai_runs SET lease_owner = :owner, lease_expires_at = now() + :ttl, heartbeat_at = now(), recovery_count = recovery_count + 1 +WHERE run_id = :id AND status IN ('RUNNING','RETRYING') AND attempt >= 1 AND lease_expires_at <= now() AND request_deadline_at > now() +RETURNING attempt, fence_token, recovery_count +``` + +- **attempt·fence를 바꾸지 않는다**(새 attempt 금지 전제 유지). 동시 인수·heartbeat 재개·terminal·취소 전환과 겹치면 조건부 UPDATE 하나만 이긴다. +- 이 인스턴스에서 worker가 도는 run은 후보 SQL에서 빼고(`run_id <> ALL(…)`, 앞쪽 batch를 가리지 않게) 인수 직전에도 다시 확인한다(`isExecuting`, heartbeat만 늦음). + 인수가 종료와 겹치면(`running=false`) 방금 갱신한 lease를 즉시 만료시키고 permit을 돌려준다(`ABANDONED_STOPPING`). 인수 worker도 **로컬 permit**을 잡고(없으면 다음 주기), + 전역 슬롯은 그 run이 이미 비terminal 수로 차지하고 있어 새로 세지 않는다. 비PENDING run의 worker는 이 주기 작업만 띄우므로 같은 run의 로컬 worker가 겹치지 않는다. +- **CANCELING은 고르지 않는다**(1.84 소유). deadline이 지난 run은 deadline sweep이 timeout으로 끝낸다(회수 조회 없음). +- 인수한 worker는 일반 worker와 같아 heartbeat·`release`·deadline sweep 중단·종료 정리를 그대로 받는다. + +### 상태별 결정 (`RunExecution` `RECOVER`) + +| Brain status (같은 attempt·fence 조회) | 결정 | +|---|---| +| `terminal` (`run.completed`·`run.failed`), fingerprint = `ai_runs.request_fingerprint` | `settle(StoredTerminal)` — **새 stream·provider 실행 없음, snapshot 복원도 없음**. COMMITTED·DUPLICATE면 cleanup 예약, DEADLINE_PASSED면 `expireDeadline` | +| `terminal` `run.canceled`·`canceling` | OBSOLETE — 취소 수습은 1.84 소유라 회수는 어떤 모드에서도 전이하지 않는다. run은 그대로 | +| `executing` 다른 epoch | OBSOLETE (`FINAL_CHECK`에서는 실패 — Server는 새 attempt를 발급하지 않으므로 기다릴 대상이 없다) | +| `executing`, 같은 epoch | 저장 snapshot에서 같은 요청을 복원해 다시 붙는다(Brain OBSERVE). 이후 일반 worker 규칙. **재부착도 재전달 1회로 센다** — 누적 `retry_count`가 `max-resends`에 닿았으면 Brain이 executing이어도 실패·보상(아래 한도) | +| `absent`, Brain 시작 증거 없음 | **명확한 미시작**(Brain record는 Postgres 영속이라 absent = 등록된 적 없음) → 같은 요청이 Brain의 첫 START | +| `absent`, Brain 시작 증거 있음 | **실행 소실** → 재전송 없이 `failByServer(PROVIDER_ERROR)`(1.82가 한 번 보상). counter `execution_lost` | +| fingerprint 불일치 | `failByServer(INTERNAL_ERROR)` | +| status 실패 `max-status-checks`회 | 조회 한도 소진 → deadline 전 `failByServer(PROVIDER_ERROR)`, 지났으면 `expireDeadline` | + +- **Brain 시작 증거** = 이 worker가 stream frame을 받았거나 `ai_run_events`에 이 run·attempt의 Brain source event(`source_event_id IS NOT NULL`)가 있다. + 이 규칙은 1.83 worker의 status 수습에도 적용된다(frame을 받은 뒤의 absent에 같은 요청을 보내면 Brain이 새 graph를 START한다). +- **미시작 PENDING**(attempt 0)은 회수 대상이 아니다. claim만 첫 전달을 만든다. + +### 한도 (재시작·재조회가 초기화하지 않는다) + +| 한도 | 저장 위치 | 소진 시 | +|---|---|---| +| 요청 deadline | `request_deadline_at`(불변) | `expireDeadline` timeout. 회수 후보에서도 빠진다 | +| 같은 attempt 재전달 `max-resends` | run 행 `retry_count`(단조)부터 이어서 센다 | 실패·보상 | +| 연속 status 실패 `max-status-checks` | worker 단위(인수마다 새로 센다 — 인수 횟수가 상한) | 실패·보상 | +| 인수 `max-recoveries` | `recovery_count`(V5, 단조 trigger) | 초과한 인수는 `FINAL_CHECK`: status **조회 1회**(실패해도 재조회 없음)로 저장 terminal만 회수하고, executing(어느 epoch든)·absent·조회 실패면 재전송 없이 `failByServer(PROVIDER_ERROR)` | + +- **결정(r185-1)**: OBSERVE 재부착은 재전달 예산과 따로 세지 않는다. 한 run이 Brain에 보내는 같은 attempt 요청의 총량을 하나의 상한으로 묶기 위해서다. + 그래서 예산을 다 쓴 run은 정상 실행 중이어도 인수 뒤 실패한다(1.87은 이 결정표 그대로 검증한다). +- **예외(r185-4)**: stream 중 일시 DB 오류의 재전달은 `markRetrying`으로 `retry_count`에 남기려 하지만, DB가 아직 안 되면 메모리 횟수만 남는다. + 그 경우 인수 뒤 다시 세므로 상한은 `max-recoveries × max-resends`다(여전히 bounded). + +usage 예산(`used_*`)은 1.83과 같다(Brain 회신 계약 전까지 0, 새 attempt 금지 전제로 Brain record 단조 병합에 의존). +**반복 인수 규칙(r185-2)**: `canceling`·`run.canceled`처럼 OBSOLETE로 끝난 인수는 lease가 TTL에 만료되면 다시 인수되고 `recovery_count`가 오른다. +한도를 넘은 뒤에도 Brain이 취소 중이면 회수는 손대지 않고 `recoveries{result=FINAL_CANCEL_PENDING}`·WARN 로그로 드러내며, TTL마다 status 1회씩 deadline sweep까지 반복된다. +정상 흐름(1.84가 Server를 먼저 CANCELING으로 바꾼 뒤 Brain cancel)에서는 후보에서 빠지므로 생기지 않는다 — 생기면 1.84 쪽 불일치 신호다. + +### 경합 방어 (기존 조건부 경계 재사용) + +- 중복 recovery worker: 인수 UPDATE가 하나만 이긴다. 진 쪽(다른 인스턴스의 늦은 worker)은 owner가 바뀌어 heartbeat 0 row → LEASE_LOST로 멈춘다. + 같은 fence라 두 worker가 terminal을 동시에 내도 1.82 run 잠금·조건부 전환이 하나만 확정한다(DUPLICATE·NOT_ACCEPTING). +- deadline: 회수 후보·인수 조건에 deadline 전이 들어 있고, 회수한 terminal도 deadline 뒤면 1.82가 `DEADLINE_PASSED`로 버린다(사용자 결정 r182). +- cancel: CANCELING run은 후보가 아니고, 인수 뒤 1.84가 CANCELING으로 바꾸면 1.82가 terminal을 NOT_ACCEPTING → OBSOLETE. heartbeat 조건은 CANCELING도 포함하므로 lease는 1.84 확정 전까지 그대로다. +- 늦은 이전 attempt: attempt가 오르지 않으므로 없다. 끊긴 이전 stream의 frame은 구독이 끊겨 Server에 닿지 않고, 닿더라도 source ID dedup·조건부 terminal이다. + +### 관측 + +counter `recoveries{result}`(TAKEN·LIMIT_EXCEEDED·NOT_TAKEN·NO_PERMIT), `recoveries.finished{result}`(인수 worker 종료 = 로컬 permit 반환), `execution_lost`, +`status_checks{result}`, `errors{stage=recovery}`. 로그: `stale run taken over: runId, attempt, recoveryCount, mode`, `brain status: runId, attempt, mode, state`, +`server failure: … reason=execution_lost|recoveries_exhausted|status_*`. payload·grant·URL·fence는 남기지 않는다. + +### 1.87·S13 인계 + +- **재사용 경계**: `RunClaimStore.findStale`·`takeOver`(같은 attempt·fence 인수 + `recovery_count`), `RunExecution` `RECOVER`/`FINAL_CHECK`(status 먼저 → 1.82 `settle(StoredTerminal)`·`failByServer`·`expireDeadline`), + `hasBrainEvents`(미시작 vs 실행 소실 판정). 새 terminal 처리기를 만들지 말고 이 경계를 부른다. +- **S13**: 회수·실패로 끝난 run도 `runtime_cleanup_at`을 예약한다(attempt ≥ 1). Brain `cleanup` 호출·retention·자산 deletion 연동은 S13이다. + `FINAL_CHECK`·재전달 소진이 executing 상태에서 실패시킨 run은 Brain record가 executing으로 남을 수 있다(cleanup은 executing을 REJECT) — S13이 Brain lease 만료 sweep·cleanup 재시도와 함께 다뤄야 한다. +- **슬롯 over-commit (r185-3, S13·1.84 인계)**: 위처럼 Brain이 아직 도는 run을 Server가 FAILED로 확정하면 전역 슬롯(비terminal 수)이 풀려 다음 PENDING이 + claim된다. 그동안 Brain 실제 동시 실행이 `max-concurrent-runs`를 넘을 수 있고 Brain 자체 상한(503 capacity → 재전송)에 기댄다(1.83 재전달 소진과 같은 선례). + 1.84 Brain cancel client가 생기면 이 실패 직전에 cancel 요청을 보내는 것을 권장한다(S13·1.84 결정). +- **1.87**: 상태별 결정표·한도는 위와 같다. fault injection은 실제 Postgres + 실제 `BrainRunClient` + loopback 가짜 Brain(`RunRecoveryPostgresTest`)이다. + +## 검증하지 않은 부분 + +- 실제 Brain·Cloud Run 왕복, 다중 인스턴스 lease 인수 실측, 슬롯·timeout·회수 주기 수치(S14). 실제 Brain 프로세스 crash 뒤 Brain lease 만료 처리와 + status 응답(가짜 Brain은 absent·executing·terminal을 각본으로 준다), Cloud Run 종료 유예 안에서의 SIGTERM 회수(테스트는 같은 JVM의 `stop()`·`start()`). 테스트는 실제 `BrainRunClient` + loopback 가짜 Brain(HTTP/SSE) + 실제 Postgres다. +- **usage counter(`used_*`)**: Brain이 usage를 회신하는 계약(terminal·status·activity 어디에도 usage가 없다)이 생길 때까지 Server는 `used_*`를 0으로 둔다. + 누적 예산은 Brain record의 단조 병합에 의존하고, **새 attempt를 발급하지 않는다는 것**(1.83·1.85 공통 불변 전제)이 이를 지킨다. 회신 경로를 신설할지는 S07 밖 후속 결정이다. diff --git a/docs/contracts/run-event-store.md b/docs/contracts/run-event-store.md new file mode 100644 index 00000000..19b16198 --- /dev/null +++ b/docs/contracts/run-event-store.md @@ -0,0 +1,121 @@ +# run durable event 저장소 (task 1.80, #218) + +`ai_run_events`는 S07의 유일한 이벤트 저장소다. Brain Activity·Server 소유 event·terminal event와 replay가 모두 +`com.proovy.domain.run.event.RunEventStore`를 쓴다. 별도 coordinator나 두 번째 저장소는 없다. schema 변경은 없다(V1 그대로). + +## 타입 + +| 타입 | 역할 | +|---|---| +| `RunEventStore` | append(Activity·Server·terminal)·조회(`findAfter`·`lastSeq`) | +| `SourceActivities` | Brain source `activity.*` → activity-v1 검사 → browser camelCase payload (`DurableActivity`) | +| `ActivityAppendResult` | Activity 수락 결과(`Outcome`) | +| `TerminalRunEvent` | terminal transaction이 저장하는 page 경계·run.completed/failed/canceled | +| `StoredRunEvent` | commit된 event 하나. `toFrame()` → `BrowserRunFrame`, `cursor()` → `RunEventCursor` | +| `RunEventSignals` | commit 뒤 `(runId, lastSeq)` 깨우기 신호 (payload 없음) | + +## seq·순서·dedup 불변조건 + +- 모든 append는 `ai_runs` row를 `FOR UPDATE`로 잠근 뒤 `last_event_seq = last_event_seq + n ... RETURNING`으로 seq를 배정하고 + 같은 transaction에서 insert한다. run별 seq는 1부터 빈틈없이 +1이다. +- **commit 순서 = seq 순서.** 같은 run의 append가 row 잠금으로 직렬화되므로, seq N이 보이는 snapshot에는 N 이하가 모두 보인다. + `seq > cursor` catch-up은 아직 commit되지 않은 앞 seq를 건너뛰지 않는다. +- rollback되면 seq 증가도 되돌아간다(같은 seq가 다음 append에 다시 배정된다). +- seq는 Server 수락 순서다. Brain source 순서나 `occurred_at`과 같다고 가정하지 않는다. +- dedup 키는 `(run_id, source_event_id)`다. 같은 source event 재전달은 row·seq·`last_event_seq`를 늘리지 않는다(DUPLICATE). + 128자를 넘는 Brain ID(brain-source-v1은 256자까지 허용, V1 column은 128자)는 Activity·terminal 모두 결정적 `sha256:` 키로 + 저장한다. 키 공간이 겹치지 않도록 `sha256:`으로 시작하는 원래 ID는 거부한다(Activity는 MALFORMED, terminal은 `IllegalArgumentException`). +- frame `occurredAt`은 `ai_run_events.created_at`이다. Activity는 source `occurred_at`, Server·terminal event는 호출자가 넘긴 시각이며 + 모든 경로에서 µs로 자른다(`BrowserRunContract.wireTime`). + +## 영속성 context의 `AiRun` (r180 #1) + +- seq는 native `UPDATE ai_runs SET last_event_seq = ...`로 올린다. 같은 transaction에 그 run의 managed `AiRun`이 있으면 store가 append + 직후 `refresh`한다. 그러지 않으면 호출자가 이후 AiRun을 바꿨을 때 Hibernate 전체 컬럼 UPDATE가 옛 `last_event_seq`를 되써 + `ai_runs_guard_update`(단조 증가)가 transaction 전체를 rollback시킨다(1.79 접수·1.84 취소 흐름). +- refresh 전에 store가 flush하므로 append 전의 변경은 잃지 않는다. **append 뒤 AiRun 변경은 refresh된 값 위에서 하면 된다.** + 호출자가 terminal 전환 등 다른 컬럼을 native UPDATE로 바꾼 뒤 managed AiRun을 계속 쓰는 것은 store가 막을 수 없다 — 그 경우도 refresh하거나 + entity를 다시 읽는다. +- 근본 해결(`AiRun.lastEventSeq`를 `@Column(updatable = false)`로, 또는 `@DynamicUpdate`)은 1.74 엔티티 소유 범위라 이 PR에서 바꾸지 않았다. + +## Brain Activity 수락 — `appendActivity(SourceEvent)` (1.83 coordinator) + +자기 짧은 transaction(REQUIRED)에서 실행한다. Brain HTTP/SSE를 기다리는 동안 transaction을 열어 두지 않는다. 판정 순서: + +| 순서 | 조건 | Outcome | 저장 | +|---|---|---|---| +| 1 | activity-v1 계약 위반, run.completed/failed, run_id가 UUID 아님, 저장 한도 초과 | `MALFORMED` | 없음 | +| 2 | run 없음 | `UNKNOWN_RUN` | 없음 | +| 3 | event attempt·fence ≠ run 현재 값 | `STALE_FENCE` | 없음 | +| 4 | 같은 source_event_id 존재 | `DUPLICATE`(같은 내용)·`CONFLICTING_DUPLICATE` | 기존 row 유지, 기존 seq 반환 | +| 5 | 상태: RUNNING·RETRYING은 모두, CANCELING은 열린 Activity의 `activity.canceled`만, PENDING·terminal은 없음 | `NOT_ACCEPTING` | 없음 | +| 6 | started 없는 후속·두 번째 started·kind 불일치·끝난 Activity의 후속 (browser-run-v1 S6) | `OUT_OF_ORDER` | 없음 | +| - | 그 밖 | `APPENDED` | 새 seq | + +- `isStored()`: APPENDED·DUPLICATE. `isStreamObsolete()`: STALE_FENCE·UNKNOWN_RUN → 그 stream 소비를 멈추고 run을 다시 본다. +- 예외는 저장 불변조건 위반(일어나지 않아야 함)에서만 난다. +- payload는 pin된 Brain `contracts/activity.py`(303a9e4)와 같은 규칙으로 검사한다: (type, kind)별 닫힌 필드, kind별 label allowlist, + Python strip 집합·code point 길이, StrictInt, 측정 progress(`checked ≤ total`·`processed ≤ total`), target 중첩 금지, + `limited_pages_unresolved`·`no_relevant_content` page 규칙, message의 URL·code fence·traceback·제어 문자 금지. + code·stdout·step 판정·percent 같은 필드는 들어갈 자리가 없다. 저장·공개되는 값은 browser payload뿐이다. +- Brain parser와 차분 비교했다(fixture 변형 2,754건): 차이는 2^63 이상 정수를 Java가 거부하는 것뿐이다(fail-closed). + +## Server 소유 event — `appendServerEvent(runId, ServerFrameType, payload, occurredAt)` (MANDATORY) + +| type | 호출자 | 조건 | +|---|---|---| +| `RUN_CREATED` | 1.79 접수 transaction | PENDING이고 첫 event(seq 1). AiRun을 JPA로 저장했으면 store가 flush한다 | +| `RUN_CANCELING` | 1.84 취소 transaction | 같은 transaction에서 CANCELING으로 바꾼 뒤, run당 한 번 | + +조건이 맞지 않으면 `IllegalStateException`(호출자 transaction rollback). page 경계·run.canceled는 이 경로가 아니다. +payload는 1.75 record(`RunCreatedPayload` 등)나 JSON object이고 Spring ObjectMapper로 직렬화한다. +append 뒤 managed `AiRun`은 store가 refresh한다(위 "영속성 context의 `AiRun`"). seq는 반환값이나 `lastSeq`로 읽는다. + +## terminal event — `appendTerminalEvents(runId, lastEventSeq, events, occurredAt)` (MANDATORY, 1.82·1.84) + +terminal 뒤에는 `last_event_seq`를 바꿀 수 없으므로(DB trigger) 호출자의 terminal 전환 UPDATE 하나에서 seq를 함께 배정한다. + +```sql +UPDATE ai_runs SET status = 'COMPLETED', ..., last_event_seq = last_event_seq + :n +WHERE run_id = :runId AND attempt = :attempt AND fence_token = :fence AND status IN ('RUNNING', 'RETRYING') +RETURNING last_event_seq +``` + +그 반환값과 n개 `TerminalRunEvent`를 넘기면 `last-n+1 .. last`로 insert한다. 검사: + +- run이 terminal이고 `last_event_seq`가 넘긴 값과 같으며 앞 seq가 모두 저장되어 있다. +- 마지막은 상태에 맞는 terminal 하나(COMPLETED→run.completed, FAILED→run.failed, CANCELED→run.canceled). +- 그 앞에는 run.completed일 때만 `pageStarted`/`pageCompleted` 쌍이 pageIndex 0..k-1 순서로, 같은 `responsePageId`로 온다. +- page·run.completed payload와 canonical page의 일치, payload 최소 요약 구성은 1.82가 정한다(replay 1.86이 message로 채운다). +- run.completed·run.failed는 Brain terminal의 source_event_id·attempt를 남길 수 있다(`runCompleted(payload, sourceEventId, attempt)`). + +## 조회·알림 (1.86 replay/live) + +- `findAfter(runId, afterSeq, limit)`: commit된 event를 seq 오름차순으로. cursor 없는 replay는 `afterSeq = 0`. +- `lastSeq(runId)`: `RunEventCursor.resolve(runId, lastSeq, header, query)`의 범위 판정용. +- `RunEventSignals.subscribe(runId, listener)`: commit 뒤 `(runId, lastSeq)`. rollback·중복은 신호가 없다. 구독 추가·해제는 map entry의 + 원자 연산이라 같은 run의 마지막 해제와 새 구독이 경합해도 새 구독이 떨어져 나가지 않는다. + 신호는 유실될 수 있다 → **구독을 먼저 걸고** cursor 이후를 DB에서 catch-up하고, 신호마다 다시 cursor 이후를 읽는다. + listener는 commit thread에서 동기로 불리므로 빨리 반환한다. 다른 Server 인스턴스의 commit은 신호가 없다(다중 인스턴스는 S14). +- heartbeat는 저장 type이 없다. source comment는 1.76 client가 버리고 browser heartbeat는 1.86이 연결에서만 보낸다. + +- terminal `source_event_id`가 이미 다른 event(예: Activity)에 쓰인 키면 `IllegalStateException`으로 rollback한다(unique 위반 대신 원인을 + 드러낸다). Brain ID는 event kind를 포함해 결정되므로 일어나지 않아야 하고, 일어나면 1.82는 source_event_id 없이(null) 저장할 수 있다. + +## 소유 경계·문서 조율 인계 + +- **Activity 의미 검사 소유:** `internal-run-v1.md` 메모는 "activity-v1 pin과 의미 검사는 1.83"이라고 적었지만 원본 task 1.80이 + kind별 제한 payload와 fake source 검증을 요구해 이 저장소가 소유한다. 1.83은 `appendActivity`만 호출한다. +- **1.76 failure-kind 처리는 1.83 소유 (r180 #12):** `internal-run-v1.md`의 "1.80 (coordinator): stream 실패 kind별 처리 + (`isRecoverableByStatusQuery`·`isSafeToResend`)"와 #215 종료 코멘트(TRANSPORT_LOST 뒤 status absent면 deadline 안 재전송, 상한은 발급·응답 + 각각 request-timeout)는 stream 소비·transient 처리라 task 1.83 coordinator 범위다. 1.80은 저장소만 구현했다. + `internal-run-v1.md`의 "1.80 (coordinator)" 표기를 "1.83 (coordinator)"로 정정해야 한다(1.76 문서라 여기서는 고치지 않음). +- **CANCELING 예외 (r180 #11, 문서 조율 인계):** task 1.80 체크리스트는 "CANCELING/terminal 뒤 source를 폐기"라고 적지만 구현은 + CANCELING에서 **열린 Activity의 `activity.canceled`만** 받는다. browser-run-v1 `canceled-running`(canceling 뒤 진행 Activity만 canceled)· + S3, plan/21, Brain `cancellation.py` wind-down 표식과 맞춘 판단이다. task/spec 문구를 "CANCELING은 열린 Activity의 canceled 표식만 예외"로 + 정정하는 것은 문서 조율 담당(proovy-brain)에 인계한다. 표식이 유실되면(stream 단절) 열린 Activity가 남을 수 있고, replay에서 run terminal 뒤 + 열린 Activity를 끝난 것으로 그리는 것은 reducer(S08)·1.86에서 정한다. +- **공통 테스트 `BrainContractMirrorTest` (r180 #13):** 목록 밖 파일 검사는 모든 `PIN-*.json` 소유 경로를 제외하되 manifest는 필수이고, + manifest 없는 pin은 명시 목록(`PIN-activity-v1.json`)만 허용한다. 1.81(PR #233)도 PIN을 추가하므로 merge 순서상 뒤쪽이 충돌을 해결한다. +- **저장 한도:** payload `jsonb::text` 16384 byte를 넘는 Activity(예: 긴 asset ID 200 page)는 MALFORMED로 버린다. + Brain 쪽 상한과 맞출 필요가 생기면 다음 consumer_version에서 다룬다. diff --git a/docs/contracts/run-snapshot-v1.md b/docs/contracts/run-snapshot-v1.md new file mode 100644 index 00000000..5c12e82a --- /dev/null +++ b/docs/contracts/run-snapshot-v1.md @@ -0,0 +1,127 @@ +# run-snapshot-v1 — 실행 입력·history·참조·정책의 불변 snapshot (task 1.78, #217) + +새 run을 접수할 때 브라우저 `ConversationRunRequest`를 권한·범위 검사 후 1.76 `InternalRunRequest`로 바꿔 고정하고, +이후 모든 Brain 전달을 저장된 snapshot에서만 다시 만든다. 이 문서는 Java 대응과 후속 태스크 인계 메모다. +의미 원본은 proovy-brain `docs/plan/22`(internal-run-v1)·`docs/plan/10`이고 wire DTO·fingerprint는 1.76 그대로 쓴다. + +## Java 대응 (`com.proovy.domain.run.snapshot`) + +| 타입 | 역할 | +|---|---| +| `RunSnapshotFactory.create(runId, userId, noteId, request, now)` | 접수 시점 구성. `RunSnapshot`을 돌려주거나 `RunSnapshotRejectedException`(RUN4041·RUN4221) | +| `RunSnapshot` | 저장할 값. `toPendingRun(userMsgId, assistantMsgId, createdAt)` → `AiRun`, `assetDependencies(createdAt)` → `RunAssetDependency` 행 | +| `RunSnapshotRestorer.delivery(AiRun, RunAssetAccess)` | 저장 run에서 현재 attempt·fence·누적 usage로 전달 요청 복원. fingerprint·deadline·식별자를 저장 열과 대조 | +| `RunSnapshotCodec` | `request_snapshot` 문서 ↔ `InternalRunRequest` (control·자격증명 필드 제거/부착) | +| `RunAssetAccess` | 한 전달에만 쓰는 grant·read URL. 저장하지 않는다 | +| `HistoryBound` | history token 경계(Brain `bound_history`와 같은 추정) | +| `RunSnapshotSources` / `JdbcRunSnapshotSources` | Note 범위 projection 조회(전체 메시지·엔티티 그래프를 읽지 않는다) | +| `RunAssetFacts` / `StoredRunAssetFacts` | 전송 시점 Viewer Focus·현재 첨부 사실. S09/S10이 교체한다 | +| `RunSnapshotProperties` (`proovy.run.snapshot.*`) | 접수 시점에만 읽는 정책 값 | + +schema 변경은 없다. V1의 `ai_runs` snapshot 열과 `run_asset_dependencies`를 그대로 쓴다. + +## 저장 열 + +| 열 | 내용 | +|---|---| +| `request_snapshot` | internal-run-v1 wire(snake_case)에서 `attempt`·`fence_token`·`asset_access_grant`·`viewer_context.read_url`·`turn_attachments[].read_url`·`execution_options.usage`를 뺀 문서. 의미 snapshot·`request_deadline_at`·limits·budget version이 남는다 | +| `request_fingerprint` | 1.76 `InternalRunFingerprint` sha256 (Brain status 응답과 비교하는 값) | +| `policy_snapshot` | `snapshot_version`(run-snapshot-v1)·contract/consumer version·locale·budget version·limits·request_timeout·history 경계 기록(max_tokens·estimator·messages·estimated_tokens·truncated)·retrieval/asset version·첨부 한도 | +| `viewer_snapshot` | `viewer_context`(read_url 없음) + `content_version`. Viewer Focus가 없으면 NULL | +| `corpus_snapshot` | `retrieval_scope` + `assets[{asset_id, content_version}]` | +| `request_deadline_at`·`budget_policy_version` | `request_snapshot`의 값과 같다 | +| `run_asset_dependencies` | corpus 구성원마다 `CORPUS_SNAPSHOT`, Viewer Focus asset에 `VIEWER_FOCUS` (content version 포함) | + +JWT·credit·grant·signed URL·fence는 어느 열에도 없다. V1 trigger가 snapshot 열·deadline 변경과 usage 감소를 거부한다. + +## 구성 규칙 + +**history** +- Note의 `ai_runs.status = COMPLETED` run만, run의 `user_message_id`·`assistant_message_id`로 짝을 짓는다(#227 R6 결정 — 시각 정렬 짝이 아니다). + 실패·취소·진행 중 turn은 user 메시지까지 통째로 빠진다. 본문은 `chat_messages.plain_text`(공개 Result Block 파생)만 쓰고 + `response_pages`·Verified Solution 본문은 읽지 않으므로 숨긴 답·Candidate가 history로 들어갈 길이 없다. +- 최신 메시지부터 담는다. 추정 = strip한 본문 code point + 메시지당 8 (Brain `estimate_history_turn_tokens`와 같음). + 넘치는 메시지를 만나면 거기서 멈춘다(중간 절단·건너뛰기 없음). +- **soft 상한 (사용자 결정, r178 결정 1)**: Brain `bound_history`와 같다. 가장 최근 메시지 하나는 추정이 24,000을 넘어도 + 통째로 담고, 그 뒤 메시지는 추정 합이 24,000 이하일 때만 담는다. 그래서 직전 긴 답(≥ 24k code point)에 대한 후속 질문도 + 그 답을 history로 갖고, 그 답에 묶인 `verified_solution_refs`도 유지된다. 이때 `policy_snapshot.history.estimated_tokens`는 + 24,000을 넘을 수 있다. Brain이 같은 추정으로 다시 잘라도 아무것도 버리지 않는다. +- wire 상한은 hard다: 메시지 ≤ 100, 메시지당 ≤ 50,000자(최신 메시지도 넘으면 담지 않고 history가 빈다). + 합계 480,000자는 "최신 1개 ≤ 50,000 + 나머지 ≤ 24,000 tokens"라 넘지 않는다. +- 후보는 최신 100 turn(메시지 200개)이다. 101번째 turn이 있는데 상한에 닿기 전에 후보가 끝나면(건너뛴 후보가 많을 때) + 그래도 `truncated = true`로 기록한다. +- turn 순서는 run 생성 순서(`ai_runs.created_at, run_id`, `idx_ai_runs_note_created`)다. 접수가 user 메시지와 run을 한 + transaction에서 만들고 Note당 Active Run이 하나라 user 메시지 순서와 같다(1.79가 이 생성 순서를 지킨다). +- 공백만인 본문은 메시지로 치지 않는다. 현재 input과 strip 후 같은 user 본문은 넣지 않는다(Brain이 거부한다). 그 turn의 assistant 답은 남는다. + +**참조** +- Verified Solution Reference: 남은 history의 assistant 메시지에 묶인 solution만. 조회 시점 `status`(active·superseded)와 + `revises_solution_id`(정정 대상의 Brain ID)를 그대로 싣는다. 대상 생략 시 Active Solution 선택과 superseded 명시 비교는 + Brain이 이 값으로 구분한다(Server는 자연어 대상을 판정하지 않는다). 최신 50개. +- Brain neutral ID(`public_solution_id`)는 run 안에서만 unique다. 범위 안에서 겹치면 그 ID의 참조를 **모두 뺀다**(경고 로그, ID만). + 겹치면 Brain은 참조 없이 clarification으로 끝난다. +- `revises_solution_id`: 정정 대상의 Brain ID가 자기 ID와 같거나, 범위 안의 다른 solution이 같은 ID를 쓰면 정정 관계만 싣지 않는다 + (참조 자체는 남는다, fingerprint 밖 값). Brain이 엉뚱한 풀이를 정정 대상으로 읽지 않게 한다. +- **Brain 계약 전제 (확인 필요)**: 303a9e4 운영 경로는 `solution--001`(`text_run.py::_mint_id`)을 발급해 + Note 안에서 겹치지 않는다. V1 CHECK·Brain 정규식은 순번형 `solution-N`도 받으므로, Brain이 순번형을 운영에서 발급하면 + 풀이가 둘 이상인 Note의 후속 참조가 빈다. Brain 쪽에서 "운영 발급은 hash형만"을 계약으로 고정할지 확인한다. +- `problem_summary` = `problem_statement`를 strip 후 4,000 code point로, claim `statement`는 2,000으로 앞에서 자른다. + 저장 claims를 계약에 맞게 읽을 수 없는 solution은 빼고(경고 로그) 요청을 막지 않는다. +- Turn Attachment Reference: 남은 history의 user 메시지 첨부만. 최신 50개. +- 잘린 범위·다른 Note의 참조는 조회 단계에서 만들어지지 않고, 1.76 생성자가 history 밖 `source_message_id`를 다시 거부한다. + +**Viewer Focus·corpus·현재 첨부** +- `RunAssetFacts`가 같은 사용자·Note에서 지금 쓸 수 있는 자산만 받아들인다. 아니면 RUN4221. + 기본 구현(S09 전): Viewer Focus는 ACTIVE PDF asset의 저장 metadata(sha256·page_count·file_size), 적용 version은 정책 값. + 이미지 asset은 pixel 사실이 아직 없어 거부한다. 현재 Turn Attachment는 업로드 staging이 없어 비어 있지 않으면 거부한다. +- 현재 첨부 한도(정책): 3개·총 30MB, PDF당 20MB·50 pages, 이미지당 15MB·40MP. 넘거나 resolver가 요청 ID를 모두 돌려주지 않으면 RUN4221. +- `corpus_version` = `corpus-` + sha256(canonical JSON `[{asset_id, content_version}]`, ACTIVE asset, id 순). 빈 corpus도 고정 값. + Search Extraction·chunking·embedding version은 S10 전까지 정책 label이다. 준비 여부(corpus_not_ready)는 Brain이 판정한다. + +**정책** +- locale(기본 ko-KR)·budget version·limits·`request_deadline_at = now + request-timeout`(기본 3m, µs 절단)을 설정에서 한 번 읽는다. +- 첫 전달 usage는 `ExecutionUsage.ZERO`(run 행 counter 0). + +## 복원 규칙 (1.83·1.85) + +- `RunSnapshotRestorer.delivery(run, access)`: attempt·fence는 run 행(조건부 UPDATE로 배정된 값), usage는 run 행의 `used_*`, + 나머지는 `request_snapshot`. 현재 설정·history·viewer·corpus를 읽지 않는다. +- 복원 fingerprint ≠ `request_fingerprint`, deadline ≠ `request_deadline_at`, run/request/user/note ID 불일치, 저장 문서에 + control·자격증명 필드, 계약 위반, 현재 첨부 read URL 누락이면 `RunSnapshotIntegrityException`. 다시 만들지 않는다 — 실패로 수습한다. +- 살아 있는 실행의 재전달은 1.76 `forRetry`를 써도 같다. 이 경로는 메모리에 요청이 없을 때(재시작·다른 worker)용이다. +- grant·read URL은 전달마다 `RunAssetAccess`로 새로 넘긴다(S09/S10 발급). grant는 `ai_runs.grant_digest`(digest·만료)만 저장한다. + +## 인계 (다른 snapshot·history 정책을 만들지 않는다) + +- **1.79 접수 (읽기 경합, r178 R6)**: `create`는 Note 소유·Viewer asset·corpus를 잠금 없이 따로 읽는다. 1.79는 같은 tx에서 + Note 행을 잠근 뒤(asset 삭제 fencing과 같은 lock 순서) `create`를 부른다. 의존성 insert와 asset 삭제 요청(S13)의 경합은 + 조건부 insert(asset이 아직 ACTIVE일 때만) 또는 insert 뒤 재검사로 막는다. VIEWER_FOCUS는 있는데 corpus에서는 빠진 snapshot을 + 저장하지 않게 한다. +- **1.79 접수**: Note 권한(1.79 판정) 뒤, 같은 transaction에서 `create(...)` → `chat_messages` insert → + `aiRunRepository.save(snapshot.toPendingRun(...))` → flush → `dependencyRepository.saveAll(snapshot.assetDependencies(now))`. + `RunSnapshotRejectedException.getErrorCode()`가 browser-run-v1 오류(RUN4041·RUN4221)다. `create`는 Note 소유·ACTIVE를 다시 확인한다. + 같은 requestId 재전송(replay)은 `create`를 부르지 않는다. `now`는 run `created_at`과 같은 값을 넘긴다. + admission 판정표의 `references` 단계(RUN4221)가 이 예외다. +- **1.83 coordinator**: attempt·fence를 배정한 뒤 `RunSnapshotRestorer.delivery`로 요청을 만든다. deadline은 `request_deadline_at`에서만 읽는다. + usage를 누적한다면 run 행 counter를 올리는 것으로만 한다(감소는 DB가 거부, 다른 저장소 금지). 다만 Brain이 usage를 회신하는 계약이 생길 때까지 + Server는 `used_*`를 0으로 두고 Brain record의 단조 병합에 의존한다. 새 attempt를 발급하지 않는다는 것이 전제다(r183 판정, 회신 경로 신설은 S07 밖 후속 결정). +- **1.83 자격증명 요구 (r178 R7)**: 복원은 현재 turn 첨부의 read URL이 없을 때만 실패한다. Viewer Focus `read_url`과 corpus + grant는 wire에서 optional이라 `RunAssetAccess.NONE`이어도 통과한다. 1.83은 `viewer_context`가 있으면 read URL을 넘길지 + (Brain이 Page Understanding cache miss에서 필요), corpus 검색이 가능한 run에 grant를 넘길지를 S10 resolver 정책과 함께 정해 + 호출부에서 검사한다(지금 S10 전이라 grant 발급처가 없다). +- **1.81·1.82 terminal (r178 결정 2)**: history는 COMPLETED run만 읽는다. clarification-only 응답 run은 반드시 COMPLETED + (`response_disposition = clarification_only`)로 확정해야 다음 turn history에 남는다(docs/plan/10). FAILED로 저장하면 사라진다. +- **1.85 회수**: status 조회의 fingerprint 대조값은 `ai_runs.request_fingerprint`다(`RunStatusResponse.matches`). 회수 경로도 + snapshot을 다시 만들지 않는다. +- rate limit은 snapshot을 만들지 않는다(1.79 판정 순서에서 snapshot 전에 끝난다). + +## 미확인·후속 + +- **S09/S10**: 실제 업로드 staging·이미지 pixel metadata·asset별 적용 version·scoped resolver·grant/URL 발급은 없다. + `StoredRunAssetFacts`를 교체하고, viewer의 적용 version을 정책 값에서 asset별 값으로 바꿀지 그때 정한다. +- **Note 상세 페이지(#227 R11)**: 이 태스크의 history 조회는 projection + DB `LIMIT`이다. `NoteServiceImpl.getNoteDetail`의 + 전체 메시지 로딩은 이 PR에서 건드리지 않았다. 이관처: **미정, S07 이후**(대화 조회 API 태스크 — 번호는 문서 조율 담당이 정한다). + 그 태스크가 같은 run 기준 짝과 DB paging으로 바꾼다. +- **docs/plan/10 보강 제안(문서 조율 담당)**: "실패·취소 turn은 user 메시지도 history에서 제외"를 한 줄 추가한다. +- **Brain fixture**: 이 태스크는 새 fixture를 pin하지 않았다. pin된 fingerprint fixture 27개로 저장·복원 왕복을 검사한다. +- 같은 사용자가 이전과 똑같은 문장을 다시 보내면 이전 user 메시지는 history에서 빠지고 답만 남는다(Brain 중복 거부 규칙 때문). diff --git a/docs/contracts/terminal-acceptance.md b/docs/contracts/terminal-acceptance.md new file mode 100644 index 00000000..9bff0909 --- /dev/null +++ b/docs/contracts/terminal-acceptance.md @@ -0,0 +1,113 @@ +# terminal 수락 검증 (task 1.81) + +Brain terminal(`run.completed`·`run.failed`)을 저장하기 **전에** Server 기준으로 수락하거나 거부한다. +코드: `com.proovy.domain.run.terminal`. 저장·usage 확정·transaction 재확인은 1.82가 한다. + +- 계약 기준: proovy-brain `303a9e4`의 terminal-v2(`terminal-fixtures-v2`)·response-v1(`response-fixtures-v1`)· + publication-v1(`publication-fixtures-v1.2`). Brain `contracts/terminal.py`·`response.py`·`safe_markdown.py`· + `response_serialization.py`의 구조 규칙을 Java로 옮겼다. +- 이 검사는 **metadata·구조 검사**다. 인용 의미 일치(grounding)와 수학 정확성은 판정하지 않는다(Brain 책임). + provenance가 맞아도 답이 옳거나 그 page에 근거가 있다는 뜻이 아니다. + +## 호출 + +```java +AcceptedTerminal accepted = TerminalAcceptance.accept(storedTerminalOrSourceEvent, scope); // 거부면 TerminalRejection +``` + +`TerminalAcceptanceScope`는 호출자가 사전 조회로 만든다. + +| 값 | 원천 | +|---|---| +| `runId`·`attempt`·`fenceToken` | 현재 `ai_runs` (Brain에 보낸 wire run_id) | +| `userId`·`noteId` | run 소유자·Note | +| `assets[]` (`ScopedAsset`) | `run_asset_dependencies` + `assets`. `brainAssetId`는 1.78 snapshot과 같은 `String.valueOf(asset id)`다. `pageUnderstandingVersion`은 snapshot `viewer_context.page_understanding_version`(또는 corpus 적용 version)이다. `brainContentVersion`은 asset content sha256이다(아래 "검증하지 않은 부분", 사용자 결정 r182). `ScopedAsset.of(dependency, asset, …)`. PDF는 `page_count`가 있어야 한다(없으면 `of`가 `IllegalArgumentException`) | +| `solutions[]` (`ScopedSolution`) | 요청 `verified_solution_refs` + `verified_solutions` row. `ScopedSolution.of(row, publicId)`. 저장 claim이 손상됐으면(coverage가 direct·supporting 밖, claim_id 없음) `IllegalStateException` | +| `revisionLinks` | 같은 Note의 `solution_id → revises_solution_id` (순환 검사용, 아는 만큼) | + +source 없는 텍스트 run은 `TerminalAcceptanceScope.textOnly(...)`. + +## 검증된 저장 입력 + +`AcceptedCompletion` + +- `responsePages`: canonical `response_pages[{response_page_id, presentation, title?, result_blocks}]` + (Brain `model_dump(exclude_none)` 모양, 문자열 strip 정규화). +- `sourceReferences`: `[{source_ref_id, asset_id, page_start, page_end, display_label_snapshot}]`. + `asset_id`는 **Server Note Asset ID(정수)**. label은 Brain publication이 정답 숨기기 검사를 한 `source_labels` 값이다. +- `plainText`: page title + block body 렌더(Brain `derive_plain_text`, fixture browser `plainText`와 같다). + source label·artifact·summary는 넣지 않는다. 참고: plan 12는 "공개 block과 source label에서 파생"이라고 쓰지만 + Brain 구현·fixture는 label을 넣지 않는다. 이 구현은 fixture를 따랐다. +- `contentSha256`: `{response_pages, source_references, plain_text}`의 canonical JSON(`CanonicalJson`) sha256. + 이 태스크의 정의다. 1.82가 `chat_messages.content_sha256`으로 쓰거나 다른 정의를 정한다. +- `verificationSummary`: 공개 summary 배열(허용 문구만). +- `verifiedSolutions[]` (`VerifiedSolutionInput`): `verified_solutions` row 입력. `claims[{claim_id, statement, coverage}]` + (V1 claims CHECK와 맞음), claim별 direct/supporting과 `overallCoverage` 보존. `contentSha256`은 artifact canonical + JSON sha256. `revisesSolutionId`는 Server UUID로 해석된 값. +- `recheck` (`TerminalRecheck`): 1.82가 transaction 안에서 다시 확인할 항목(아래). + +`AcceptedFailure`: code·failure_class·reason(invalid_attachment만)·고정 안전 message·recoverable. + +## 거부 범주 (`TerminalRejectionReason`) + +message는 범주·필드 경로·위반 종류만 담고 payload 값은 담지 않는다. + +| 범주 | 예 | +|---|---| +| `NOT_PUBLICATION_TERMINAL` | activity event, 저장 전용 `run.canceled`(취소 경로가 처리) | +| `MALFORMED` | 알 수 없는 필드(code·stdout·metadata·video_target 등), 타입·길이·형식·enum(`inconclusive` coverage 포함) | +| `UNSAFE_MARKDOWN` | raw HTML·code·embed·위험 scheme·제어문자·math·marker 형식 | +| `RESPONSE_INTEGRITY` | ID 중복, main page 개수, phantom marker, anchor 범위·marker 분할, 같은 asset 범위 겹침 | +| `DISPOSITION_STRUCTURE` | clarification/unsupported에 block 추가·출처·summary, reason 짝, unsupported 고정 본문, 혼합 substantive의 clarification 위장 | +| `VERIFICATION_INTEGRITY` | 배지·mark·summary·artifact 불일치, 허용 밖 문구, reject(failed step)·summary 밖 claim이 든 artifact, **검증 배지 block이 공개하지 않은 artifact** | +| `SOURCE_CATALOG` | label과 catalog 불일치, artifact source ID가 catalog 밖 | +| `PROVENANCE` | 인용 범위 page provenance 누락(비연속 근거의 범위 합침 포함), 범위 밖·중복 provenance, asset version 불일치, guidance의 provenance | +| `FAILURE_PAYLOAD` | code·class·message·reason 불일치(내부 원문 message) | +| `FORBIDDEN_CONTENT` | 공개 텍스트·label·artifact의 signed URL·임시 자격증명 흔적(GCS V2·V4, S3 SigV2·SigV4·`X-Amz-Security-Token`). 자연어 secret 일반 탐지는 아니다 | +| `RUN_MISMATCH` | 다른 run_id·attempt·fence | +| `SOURCE_SCOPE` | dependency 밖 asset, 다른 user/Note, ACTIVE 아님, run 시작 뒤 content version 변경, page_count 초과, provenance version·sha256·understanding version 불일치 | +| `SOLUTION_REFERENCE_SCOPE` | 승인 안 된 existing reference, version·claim coverage 불일치, 다른 Note | +| `REVISION` | 승인 밖 대상, superseded 대상, 자기 자신, 같은 terminal artifact 대상, 한 terminal 안에서 한 대상 두 번 정정, 기존 이력의 순환(데이터 손상 탐지) | + +Brain보다 엄격한 Server 규칙은 두 가지다. 하나는 artifact 공개 요구다. Brain은 inconclusive 공개와 artifact가 함께 와도 거부하지 않는다. 다른 하나는 signed URL 검사다. +나머지는 fixture로 Brain 판정과 같음을 검사한다. 정수·bool은 JSON 정수·bool만 받는다(문자열 coercion 거부, fail-closed). +str 필드는 Brain `str_strip_whitespace`처럼 strip한다. enum·Literal 값은 strip하지 않고 원문으로 비교한다(Brain과 같다). + +## 정책 결정 + +- **superseded solution의 재표현 허용** (사용자 결정, 리뷰 r181 R5): `existing_solution_references`는 status를 보지 않는다. + run 요청이 active·superseded 참조를 모두 보내므로(1.78), "이전 풀이 설명"으로 superseded solution을 재표현할 수 있다. + 원래 coverage 배지가 그대로 따라오므로 "정정된 풀이"라는 표시는 **1.86/S08 화면**이 한다. + 정정 대상(`revises_solution_id`)은 계속 ACTIVE만 허용한다. +- **revision 순환**: 새 artifact는 아직 UUID가 없어 terminal 하나가 순환을 만들 수는 없다. `revisionLinks` 검사는 이미 손상된 + 기존 이력만 탐지한다. 같은 대상을 두 run이 동시에 정정하는 경합은 1.82의 조건부 UPDATE(`WHERE status='ACTIVE'`)가 막는다. + +## 1.82 인계 — transaction 재확인 (TOCTOU) + +> 반영(1.82): `RunTerminalService`가 아래 항목을 잠금 뒤 DB 값으로 다시 확인한다 — [`terminal-settlement.md`](terminal-settlement.md). + +수락 판정은 사전 조회 scope 기준이다. 이 태스크만으로 원자성을 보장하지 않는다. 저장 transaction에서 다음을 확인한다. + +1. 현재 fence 조건부 terminal UPDATE가 1 row인지(run이 아직 비terminal이고 CANCELING이 아닌지). +2. `recheck.citedAssets`의 asset이 아직 ACTIVE(DELETING 아님)이고 `content_version`이 같은지. 같은 Note인지는 FK가 보장한다. +3. `recheck.revisedSolutions`가 아직 ACTIVE인지 확인한다. 같은 transaction에서 superseded로 바꾼다(`superseded_by`·`superseded_at`). 동시에 두 run이 같은 대상을 정정하면 하나만 이겨야 한다(조건부 UPDATE `WHERE status='ACTIVE'`). +4. `recheck.referencedSolutions`가 같은 Note에 남아 있는지 확인한다(Note 삭제 경합). +5. 같은 `source_event_id`에 다른 outcome이 오면 거부한다(terminal-v2 `redelivery.json` `changed_outcome`). event dedup(1.80)과 함께 처리한다. + 같은 의미의 재전달은 같은 `AcceptedTerminal`(content hash 포함)을 만든다. +6. Verified Solution `public_solution_id`는 run 안에서만 unique다(`uq_verified_solutions_run_public`). + +## 검증하지 않은 부분 + +- 실제 S09 업로드·GCS·page metadata 경로: asset sha256·page_count·Page Understanding version은 seeded/fake 값으로만 검사했다. + page_count가 아직 없는 PDF는 scope를 만들 수 없다(`ScopedAsset.of` 거부). 1.82는 이 경우를 provenance 거부가 아니라 + scope 구성 실패(데이터 미준비)로 분류해야 한다. +- provenance `content_version` 문자열: **pin 구현과 같은 sha256으로 확정(사용자 결정, r182)**. pin 303a9e4 Brain은 선언 version이 + 없으면 `source_sha256`을 쓰고(`run_adapter.py`), Server는 정수 version을 보내지 않으므로 wire 값은 항상 asset content sha256이다. + 그래서 `brainContentVersion` = asset sha256이다(1.82 `TerminalScopeReader`). 정수 version 고정은 dependency·현재 version 비교가 따로 맡는다. +- SafeMarkdown CR(`\r`): Brain 규칙과 같이 `\r`을 허용하고 행 기준 검사(fenced code·link definition·indented code)는 `\n`만 본다. + CommonMark 렌더러는 `\r`을 줄바꿈으로 보므로 `x\r~~~` 같은 우회가 가능하다. Server 단독으로 막으면 Brain parity가 깨지므로 그대로 두었다. + **Brain 후속**: CR 금지 또는 CRLF 정규화를 계약에 넣는다. **1.86 방어**: browser 렌더러가 `\r`을 줄바꿈으로 해석하지 않게 한다 + (렌더 전에 `\r`을 공백으로 바꾸거나 제거). +- asset ID·content version의 Brain wire 철자: 1.78 snapshot 매핑에 달려 있고, 이 태스크는 scope가 그 값을 받는다고 가정한다. +- 인용 의미·정답 grounding(S11·Brain), 수학 재판정(Brain), browser projection(`available` hydration, 1.86). +- catalog entry를 아무 marker도 인용하지 않아도 수락한다(Brain과 같음). diff --git a/docs/contracts/terminal-settlement.md b/docs/contracts/terminal-settlement.md new file mode 100644 index 00000000..93b27de6 --- /dev/null +++ b/docs/contracts/terminal-settlement.md @@ -0,0 +1,140 @@ +# terminal 확정 — 응답·solution·사용량의 원자적 저장 (task 1.82, #221) + +현재 fence의 terminal 하나만 받아 run·assistant message·Verified Solution·page/완료 event·usage 보상을 **한 transaction**에서 +저장하고 commit 뒤에만 공개한다. 수락 검증은 1.81 `TerminalAcceptance`, event 저장은 1.80 `RunEventStore`, 보상은 1.77 +`RequestUsageService`를 그대로 쓴다. 새 coordinator·두 번째 usage 경로는 없다. + +코드: `com.proovy.domain.run.terminal` — `RunTerminalService`(진입점), `TerminalSettlement`(결과), `TerminalReplay`(복원), +`TerminalScopeReader`(transaction 안 scope). schema: `V3__terminal_settlement.sql`. + +## 진입점 + +| 메서드 | 호출자 | 조건부 전환 | +|---|---|---| +| `settle(SourceEvent)` | 1.83 coordinator (stream의 run.completed·run.failed) | `RUNNING|RETRYING` + 현재 attempt·fence + deadline 전 | +| `settle(StoredTerminal)` | 1.85 회수 (status 조회의 저장 terminal). `run.canceled`는 REJECTED(취소 경로 1.84) | 위와 같음 | +| `failByServer(runId, attempt, fence, code, recoverable)` | 1.83·1.85: 거부된 terminal, 실행 소실, 재시도 소진, dispatch 전 snapshot 손상 | `PENDING|RUNNING|RETRYING` + 현재 attempt·fence(PENDING은 `0, null`) | +| `expireDeadline(runId)` | deadline 조회(`idx_ai_runs_deadline`) 주기 작업 — 1.83/1.85 | `PENDING|RUNNING|RETRYING` + `request_deadline_at <= now` | + +호출자 transaction 없이 부른다(자기 transaction, 직렬화·교착·연결 실패만 최대 3번 다시 연다). commit 응답 전 연결이 끊겨 실제로는 +commit된 뒤 재시도해도 Brain terminal은 source·digest로, Server 실패는 같은 code(·attempt·fence)로 자기 결과를 알아보고 DUPLICATE를 돌려준다. +바깥 transaction 안에서 부르면 진입 때 EntityManager를 flush하고(JPA로 저장 중인 run을 JDBC 판정이 보게) 거기에 참여하며 재시도하지 않는다. + +## 조건표 (판정 순서, 모두 run 잠금 뒤 DB 값) + +| 순서 | 조건 | Outcome | 저장 | +|---|---|---|---| +| 1 | run_id가 소문자 UUID가 아님, run·Note 없음 | `UNKNOWN_RUN` | 없음 | +| 2 | run이 terminal, 같은 source_event_id(`terminal_source_event_id`)이고 `{type, attempt, fence_token, payload}` digest도 같음 | `DUPLICATE` (lastSeq 반환) | 없음 | +| 2 | run이 terminal, 같은 source_event_id로 다른 결과 (terminal-v2 `changed_outcome`) | `CONFLICTING_DUPLICATE` | 없음 | +| 2 | run이 terminal, 다른 source_event_id (예: deadline 실패 뒤 늦은 완료, 다른 attempt의 같은 고정 문구 실패) | `NOT_ACCEPTING` | 없음 | +| 2' | (Server 실패) 이미 같은 code의 Server 실패로 끝남 — `failByServer`는 같은 attempt·fence까지, `expireDeadline`은 timeout | `DUPLICATE` | 없음 | +| 3 | attempt·fence ≠ 현재 (이전 attempt, 아직 PENDING) | `STALE_FENCE` | 없음 | +| 4 | CANCELING | `NOT_ACCEPTING` | 없음 | +| 5 | `request_deadline_at <= now` | `DEADLINE_PASSED` → 호출자가 `expireDeadline` | 없음 | +| 6 | page_count 없는 PDF dependency, snapshot에 understanding version 없음 | `SCOPE_NOT_READY` (데이터 미준비) | 없음 | +| 7 | 1.81 수락 거부 또는 transaction 재확인 실패 | `REJECTED(reason, field)` | 없음 | +| - | 그 밖 | `COMMITTED` | 전부 | + +`isSettled()` = COMMITTED·DUPLICATE. `isObsolete()` = STALE_FENCE·UNKNOWN_RUN·NOT_ACCEPTING·CONFLICTING_DUPLICATE → 그 stream을 버리고 +run을 다시 본다. REJECTED·SCOPE_NOT_READY는 run이 그대로라 **호출자가 다음 단계를 정한다**(아래 1.83 인계). + +## transaction 경계와 잠금 순서 + +`notes FOR SHARE → ai_runs FOR UPDATE → assets FOR SHARE → verified_solutions FOR UPDATE(정정 대상) → credit_balance FOR UPDATE`. +접수(1.79: Note `FOR NO KEY UPDATE` → 잔액)·asset 삭제 guard와 같은 Note 우선 순서라 서로 기다릴 뿐 교착하지 않는다. Note를 잡은 +동안 Note 삭제 cascade가 solution·asset을 지우지 못한다. **1.84 취소 전환도 Note → run 순서를 따른다.** + +COMMITTED 한 transaction의 쓰기: + +1. 조건부 전환 `UPDATE ai_runs SET status, disposition/clarification 또는 failure_code/class, completed_at, lease·grant 해제, + terminal_source_sha256, last_event_seq = last_event_seq + n WHERE run_id AND <위 조건> RETURNING last_event_seq` — 0 row면 rollback. +2. assistant message `PENDING|STREAMING → COMPLETED|FAILED` (조건부, 1 row 필수). +3. 새 Verified Solution insert, 정정이면 대상을 `WHERE status = 'ACTIVE'`로 SUPERSEDED (1 row 필수). +4. `RunEventStore.appendTerminalEvents(runId, last, [page started/completed …, run.completed|run.failed], now)`. +5. `RequestUsageService.settle(userId, requestId, RunTerminalOutcome)` — 정책표(plan 05·07)의 보상 대상이면 한 번. + +어느 단계든 실패하면 전부 rollback이고 신호도 없다(실패 주입 테스트: 보상·event·solution insert 실패). + +### transaction 재확인 (1.81 `TerminalRecheck`, TOCTOU) + +- scope는 사전 조회가 아니라 **잠금 뒤 DB 값**으로 만든다(`TerminalScopeReader`). asset은 `FOR SHARE`라 판정부터 commit까지 + DELETING·version 변경이 끼어들 수 없다. 삭제 transaction이 asset을 잡고 있으면 terminal이 기다렸다가 DELETING을 보고 `SOURCE_SCOPE`로 거부한다. +- `citedAssets`·`referencedSolutions`: scope 자체가 잠금 뒤 같은 transaction 값이라 그것으로 충족된다. cited asset은 이미 읽은 값으로 + 방어적으로 한 번 더 비교한다(추가 쿼리 없음). +- `revisedSolutions`: `FOR UPDATE`로 잠그고 ACTIVE인지. 같은 대상의 동시 정정은 하나만 이긴다(Note당 Active Run 하나라 실제로는 겹치지 않는다). +- 다른 Note 참조: scope solution은 `(note_id, assistant_message_id, public_solution_id)`로만 찾으므로 다른 Note의 solution은 scope에 + 들어가지 않아 `SOLUTION_REFERENCE_SCOPE`로 거부된다. 다른 Note asset은 dependency가 아니라 `SOURCE_SCOPE`다. + +## 저장 형태 + +**assistant message (COMPLETED)** — `response_pages`·`source_references`·`plain_text`·`content_sha256`은 1.81 `AcceptedCompletion` +그대로(`content_sha256` = `{response_pages, source_references, plain_text}` canonical JSON sha256, 1.81 정의 채택). +`verification_summary`(V3)는 공개 summary 배열(snake_case). FAILED message는 본문이 없다(V1 CHECK). +source snapshot은 message JSON 안에만 있고 asset FK가 없어 rename·DELETING·삭제가 JSON·plain text·hash를 바꾸지 않는다(테스트). + +**ai_run_events** (page 경계·terminal은 browser-run-v1 frame payload 모양): + +| type | 저장 payload | +|---|---| +| `response.page.started` | `{assistantMessageId, responsePageId, presentation, title?, pageIndex}` | +| `response.page.completed` | `{assistantMessageId, responsePageId, pageIndex}` | +| `run.completed` | **최소 요약** `{assistantMessageId, responseDisposition, clarificationReason?, model, tier}` — 본문 없음 | +| `run.failed` | browser payload 그대로 `{code, reason?, message, recoverable, assistantMessageId}` | + +Brain terminal은 `source_event_id`·`attempt`를 남긴다(Server 실패는 없음). 같은 키가 이미 Activity에 쓰였으면 source ID 없이 저장한다(1.80 인계). +page 경계·terminal은 한 commit 시각(`occurredAt`)을 쓴다. + +**ai_runs.terminal_source_event_id · terminal_source_sha256** (V3) — 확정한 Brain terminal의 저장 source 키와 `{type, attempt, fence_token, payload}` +canonical JSON sha256. 재전달 판정용이고 Server 실패는 둘 다 NULL. run 행에 있으므로 event가 source ID 없이 저장된 경우에도 changed_outcome을 가른다. + +## 보상 (1.77 정책표 그대로) + +| 결과 | 순사용량 | 보상 사유 | +|---|---|---| +| COMPLETED substantive (혼합·inconclusive·자료 부재 안내 포함) | 1 | — | +| COMPLETED clarification_only / unsupported_only | 0 | CLARIFICATION_ONLY / UNSUPPORTED_ONLY | +| FAILED verification / budget / attachment / infrastructure (deadline timeout 포함) | 0 | VERIFICATION_FAILED / BUDGET_EXCEEDED / INVALID_ATTACHMENT / INFRASTRUCTURE_FAILURE | + +run 잠금으로 terminal 전환이 직렬화되므로 **답변 확정과 실패 보상이 함께 생기지 않는다**(완료↔Server 실패, 완료↔CANCELING, 완료↔deadline 경합 테스트). +ledger PK(request_id)가 마지막 방어다. clarification run은 COMPLETED라 다음 turn history에 남는다(1.78 결정 2). + +## 공개·복원 경계 + +- commit 전에는 다른 연결에 message 본문·page/완료 event·solution이 보이지 않고 신호가 없다. commit 뒤 `RunEventSignals`가 `(runId, lastSeq)`를 + 한 번 알린다(1.80). rollback은 신호가 없다. +- 신호 유실·broadcaster 장애 뒤: `RunEventStore.findAfter(runId, cursor, …)` + `TerminalReplay.findCompleted(runId)`로 같은 frame을 만든다. + `TerminalReplay.completedPayload(storedSummary, message, available)`가 terminal-v2 pair·browser-run-v1 stream의 `run.completed` payload와 + 같다(테스트가 모든 pair와 `substantive-completed` stream으로 비교). + +## 후속 인계 + +- **1.83 coordinator** + - terminal은 `settle(SourceEvent)`. Activity는 1.80 `appendActivity`. + - `REJECTED`·`SCOPE_NOT_READY`는 run이 그대로다. 권장: 같은 attempt·fence로 `failByServer(…, INTERNAL_ERROR, true)`(보상 포함). + 공개할 결과를 검증하지 못했으므로 답을 확정하지 않는다. REJECTED의 `rejection()`·`rejectionField()`만 로그에 남기고 payload는 남기지 않는다. + - `DEADLINE_PASSED`면 `expireDeadline`. deadline 조회 주기 작업도 `expireDeadline`이다(CANCELING은 대상이 아니다). + - deadline + grace(1.76 `stream-deadline-grace`)는 stream 정리·status 확인 용도다. grace 안에서 받은 terminal도 deadline 이후면 `DEADLINE_PASSED`다. + - 실행 소실·재시도 소진은 `failByServer`. `runtime_cleanup_at` 예약(Brain runtime cleanup)은 이 경로가 하지 않는다 — 1.83/1.85가 terminal 뒤 따로 기록한다. +- **1.84 취소**: CANCELING 전환과 CANCELED 확정은 1.84가 한다. 잠금 순서 Note → run을 따르고, `CANCELING`이면 이 서비스는 어떤 terminal도 + 받지 않는다(늦은 run.completed 차단). PENDING 취소 보상과 terminal 보상은 ledger PK로 한 번만이다. +- **1.85 회수**: `settle(StoredTerminal)` — 재전달이면 DUPLICATE. `run.canceled` 저장 terminal은 1.84 경로로 넘긴다. + grace 안에서 회수(`RETURN_TERMINAL`·status)한 terminal도 deadline 이후면 `DEADLINE_PASSED` → `expireDeadline`이다(Brain은 COMPLETED여도 Server는 timeout). +- **1.86 SSE·대화 조회**: page·run.failed는 저장 payload 그대로, run.completed는 `TerminalReplay.completedPayload`로 hydration한다. + `available`은 read-time(asset DELETING·DELETED면 false). 대화 조회의 `verificationSummary`는 `chat_messages.verification_summary`(NULL = `[]`), + 실패 message의 `failure{code, reason, message, recoverable}`는 그 run의 `run.failed` event payload, status 응답의 `failure`도 같은 payload에서 읽는다. + `ChatMessage` JPA 엔티티에는 V3 열을 매핑하지 않았다(1.74 소유, 필요하면 1.86이 추가형으로 매핑). + +## 검증하지 않은 부분·결정 + +- **provenance `content_version` = asset content sha256 (사용자 결정, r182)**: pin 303a9e4 Brain 구현과 같다. `TerminalScopeReader`가 + `brainContentVersion`에 asset sha256을 넣는다. sha256이 없는 asset은 `SCOPE_NOT_READY`다. 정수 version은 dependency·현재 version 비교가 맡는다. +- **Page Understanding version**: Viewer Focus asset은 `viewer_context.page_understanding_version`, 나머지는 `policy_snapshot.asset_versions` + 값(접수 시 고정). S09/S10의 asset별 version이 생기면 그때 바꾼다. +- **deadline은 도착 시각 기준 절대값 (사용자 결정, r182)**: deadline이 지난 뒤 도착한 완료는 저장하지 않는다(`DEADLINE_PASSED`, 유예 없음). + 경계에서는 run 잠금 순서가 승자를 정한다. 1.76 grace 서술도 같은 의미로 고쳤다(`internal-run-v1.md`·`BrainRunClient`). +- **V3 schema (1.82 점유, 후속은 V4부터)**: `chat_messages.verification_summary`(summary는 claim 수에 비례해 16KB event 상한을 넘을 수 있다)와 + `ai_runs.terminal_source_event_id`·`terminal_source_sha256`. +- **scope 규칙 중복**: page_count 없는 PDF 규칙·저장 claim coverage 해석은 1.81 `ScopedAsset.of`·`ScopedSolution.of`와 같은 규칙을 JDBC row에 + 다시 적었다(그 factory는 엔티티를 받는다). 규칙을 바꾸면 두 곳을 함께 바꾼다. +- 실제 Brain 왕복·S09 업로드 자산·다중 Server 인스턴스(S14)는 검증하지 않았다. 테스트는 fixture를 테스트 asset ID로 묶은 seeded run이다. diff --git a/scripts/brain-integration/run-local.sh b/scripts/brain-integration/run-local.sh new file mode 100755 index 00000000..d0159e46 --- /dev/null +++ b/scripts/brain-integration/run-local.sh @@ -0,0 +1,187 @@ +#!/usr/bin/env bash +# Server ↔ 실제 Python Brain 로컬 연동 인수 (task 1.87). +# +# 격리 Postgres 컨테이너 → proovy-brain testkit supervisor → Gradle 연동 테스트 → 정리. +# Server CI는 proovy-brain(private)을 checkout할 수 없어 이 테스트를 skip한다(사용자 결정). 이 스크립트가 유일한 실행 경로다. +# +# 사용: +# BRAIN_DIR= scripts/brain-integration/run-local.sh [gradle --tests 패턴...] +# +# 환경변수 (기본값): +# BRAIN_DIR 필수. uv 환경이 준비된 proovy-brain checkout +# PG_CONTAINER (proovy-postgres-brain-it) PG_PORT (26432) — 고정 compose 자원(15432 등)과 겹치지 않게 고른다 +# SUPERVISOR_PORT (26081) BRAIN_API_PORT (26080) +# GRADLE_IN_DOCKER (auto) 1이면 eclipse-temurin:21-jdk 임시 컨테이너(--network host)에서 Gradle을 돌린다 +# KEEP_RESOURCES (0) 1이면 끝나도 컨테이너·supervisor를 남긴다(디버깅용) +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +: "${BRAIN_DIR:?BRAIN_DIR must point at a proovy-brain checkout}" +PG_CONTAINER="${PG_CONTAINER:-proovy-postgres-brain-it}" +PG_PORT="${PG_PORT:-26432}" +SUPERVISOR_PORT="${SUPERVISOR_PORT:-26081}" +BRAIN_API_PORT="${BRAIN_API_PORT:-26080}" +PG_USER=brain_it_admin +PG_PASSWORD=brain-it-admin-dev-only +CONTROL_TOKEN="brain-it-control-$(od -An -N8 -tx1 /dev/urandom | tr -d ' \n')" +SHARED_TOKEN="brain-it-shared-$(od -An -N8 -tx1 /dev/urandom | tr -d ' \n')" +RUNTIME_PASSWORD=brain-it-runtime-dev-only +MIGRATION_PASSWORD=brain-it-migration-dev-only +SUPERVISOR_PID="" +LOG_DIR="$(mktemp -d)" + +cleanup() { + if [[ "${KEEP_RESOURCES:-0}" == "1" ]]; then + echo "[brain-it] KEEP_RESOURCES=1 — leaving $PG_CONTAINER and supervisor pid ${SUPERVISOR_PID:-none}" + return + fi + if [[ -n "$SUPERVISOR_PID" ]]; then + # Brain API child는 supervisor가 새 session으로 띄운다 — 먼저 supervisor에게 멈추게 하고, supervisor 프로세스 그룹 전체를 정리한다 + curl -s -m 20 -X POST -H "X-Testkit-Control-Token: $CONTROL_TOKEN" -H "Content-Type: application/json" \ + -d '{"signal": "kill"}' "http://127.0.0.1:$SUPERVISOR_PORT/brain/stop" >/dev/null 2>&1 || true + kill -- "-$SUPERVISOR_PID" 2>/dev/null || kill "$SUPERVISOR_PID" 2>/dev/null || true + wait "$SUPERVISOR_PID" 2>/dev/null || true + fi + pkill -f "testkit.integration serve --host 127.0.0.1 --port $BRAIN_API_PORT" 2>/dev/null || true + docker rm -f "$PG_CONTAINER" >/dev/null 2>&1 || true + rm -rf "${LOG_DIR:?}" +} +trap cleanup EXIT + +GRADLE_IN_DOCKER="${GRADLE_IN_DOCKER:-auto}" +if [[ "$GRADLE_IN_DOCKER" == "auto" ]]; then + if command -v javac >/dev/null 2>&1; then GRADLE_IN_DOCKER=0; else GRADLE_IN_DOCKER=1; fi +fi + +# gradle — 호출 시점의 TEST_ENV를 전달한다 +gradle() { + local env_args=() + if [[ "$GRADLE_IN_DOCKER" == "1" ]]; then + for kv in "${TEST_ENV[@]}"; do env_args+=(-e "$kv"); done + flock /tmp/proovy-s07-gradle.lock docker run --rm --network host --memory 1536m --cpus 2 \ + -u "$(id -u):$(id -g)" -e HOME=/home/gradle -e GRADLE_USER_HOME=/home/gradle/.gradle \ + -v "$HOME/.gradle:/home/gradle/.gradle" -v "$ROOT:$ROOT" -w "$ROOT" "${env_args[@]}" \ + eclipse-temurin:21-jdk sh ./gradlew "$@" --no-daemon --max-workers=2 -Dorg.gradle.jvmargs=-Xmx768m + else + (cd "$ROOT" && env "${TEST_ENV[@]}" flock /tmp/proovy-s07-gradle.lock \ + ./gradlew "$@" --no-daemon --max-workers=2 -Dorg.gradle.jvmargs=-Xmx768m) + fi +} +TEST_ENV=() + +SERVER_HEAD="$(git -C "$ROOT" rev-parse HEAD)" +BRAIN_HEAD="$(git -C "$BRAIN_DIR" rev-parse HEAD)" +BRAIN_DIRTY="$(git -C "$BRAIN_DIR" status --porcelain | head -1)" +echo "[brain-it] server=$SERVER_HEAD brain=$BRAIN_HEAD${BRAIN_DIRTY:+ (brain checkout DIRTY)}" + +# 컴파일을 먼저 끝낸다 — Brain child 기동(supervisor readiness 60s)이 컴파일 메모리 피크와 겹치지 않게 +echo "[brain-it] compiling test classes" +gradle testClasses + +echo "[brain-it] postgres $PG_CONTAINER on 127.0.0.1:$PG_PORT (tmpfs)" +docker run -d --name "$PG_CONTAINER" --tmpfs /var/lib/postgresql/data:rw,size=600m --memory 384m \ + -e POSTGRES_USER="$PG_USER" -e POSTGRES_PASSWORD="$PG_PASSWORD" -e POSTGRES_DB=postgres \ + -p "127.0.0.1:$PG_PORT:5432" postgres:15-alpine >/dev/null +wait_for() { # wait_for — 60초 안에 성공하지 못하면 실패 + local what=$1 + shift + for _ in $(seq 1 60); do + "$@" >/dev/null 2>&1 && return 0 + sleep 1 + done + echo "[brain-it] $what did not become ready in 60s" >&2 + return 1 +} +wait_for postgres docker exec "$PG_CONTAINER" pg_isready -U "$PG_USER" -d postgres + +echo "[brain-it] brain testkit supervisor 127.0.0.1:$SUPERVISOR_PORT (api $BRAIN_API_PORT)" +export PG_USER PG_PASSWORD PG_PORT CONTROL_TOKEN SHARED_TOKEN RUNTIME_PASSWORD MIGRATION_PASSWORD \ + SUPERVISOR_PORT BRAIN_API_PORT +setsid bash -c ' + cd "$0" + env -i PATH="$PATH" HOME="$HOME" \ + TESTKIT_PG_ADMIN_URL="postgresql://$PG_USER:$PG_PASSWORD@127.0.0.1:$PG_PORT/postgres" \ + TESTKIT_CONTROL_TOKEN="$CONTROL_TOKEN" TESTKIT_SHARED_TOKEN="$SHARED_TOKEN" \ + TESTKIT_BRAIN_RUNTIME_PASSWORD="$RUNTIME_PASSWORD" TESTKIT_BRAIN_MIGRATION_PASSWORD="$MIGRATION_PASSWORD" \ + uv run --locked python -m testkit.integration supervise \ + --port "$SUPERVISOR_PORT" --api-port "$BRAIN_API_PORT" +' "$BRAIN_DIR" >"$LOG_DIR/supervisor.log" 2>&1 & +SUPERVISOR_PID=$! # setsid → 이 PID가 프로세스 그룹 ID다 +wait_for supervisor curl -sf -H "X-Testkit-Control-Token: $CONTROL_TOKEN" \ + "http://127.0.0.1:$SUPERVISOR_PORT/brain/status" + +# Brain child 기동은 supervisor의 고정 readiness 대기(60s) 안에 끝나야 한다. 부하가 큰 호스트에서도 넘지 않게 +# import·bytecode를 미리 데운다(같은 checkout, 같은 venv). +echo "[brain-it] warming brain imports" +(cd "$BRAIN_DIR" && env -i PATH="$PATH" HOME="$HOME" uv run --locked python -c \ + 'import proovy_brain.apps.api, proovy_brain.features.solve_execution.text_run, testkit.integration.app') + +# 테스트 전에 child를 한 번 띄워 둔다(빈 warm-up database). 테스트의 /brain/start는 이 child를 죽이고 새로 띄우지만 +# page cache가 데워져 있어 테스트 JVM과 겹쳐도 readiness 안에 뜬다. +docker exec "$PG_CONTAINER" psql -q -U "$PG_USER" -d postgres -c "CREATE DATABASE brain_it_warm" >/dev/null +curl -sf -X POST -H "X-Testkit-Control-Token: $CONTROL_TOKEN" -H "Content-Type: application/json" \ + -d '{"database": "brain_it_warm", "lease_ttl_seconds": 3}' \ + "http://127.0.0.1:$SUPERVISOR_PORT/brain/start" >/dev/null && echo "[brain-it] warm child ready" + +TESTS=("$@") +if [[ ${#TESTS[@]} -eq 0 ]]; then + TESTS=("com.proovy.integration.brain.*") +fi +# --rerun: 환경변수(testkit 주소·token)는 test task 입력이 아니라서, 이전 결과(예: testkit 없이 돈 18 skipped)가 남아 있으면 +# UP-TO-DATE로 테스트 0건이 "통과"한다. 항상 실제로 실행하게 강제한다. +GRADLE_ARGS=(test --rerun) +for pattern in "${TESTS[@]}"; do + GRADLE_ARGS+=(--tests "$pattern") +done + +TEST_ENV=( + "PROOVY_TEST_PG_URL=jdbc:postgresql://127.0.0.1:$PG_PORT/postgres" + "PROOVY_TEST_PG_USER=$PG_USER" "PROOVY_TEST_PG_PASSWORD=$PG_PASSWORD" "PROOVY_REQUIRE_DB_TESTS=true" + "PROOVY_BRAIN_TESTKIT_URL=http://127.0.0.1:$SUPERVISOR_PORT" + "PROOVY_BRAIN_TESTKIT_CONTROL_TOKEN=$CONTROL_TOKEN" "PROOVY_BRAIN_TESTKIT_SHARED_TOKEN=$SHARED_TOKEN" + "PROOVY_BRAIN_TESTKIT_RUNTIME_PASSWORD=$RUNTIME_PASSWORD" + "PROOVY_BRAIN_EXPECTED_COMMIT=$BRAIN_HEAD" "PROOVY_REQUIRE_BRAIN_IT=true" +) + +STARTED_AT="$(date +%s)" +set +e +gradle "${GRADLE_ARGS[@]}" +STATUS=$? +set -e + +# 결과 검증: 이번 실행이 만든 연동 테스트 결과가 있고, 실제로 돌았고(tests > 0), skip이 0이어야 한다 +verify_results() { + local found=0 file tests skipped failures errors + shopt -s nullglob + for file in "$ROOT"/build/test-results/test/TEST-com.proovy.integration.brain.*.xml; do + if [[ "$(stat -c %Y "$file")" -lt "$STARTED_AT" ]]; then + echo "[brain-it] stale result (not produced by this run): $file" >&2 + return 1 + fi + tests=$(grep -o 'tests="[0-9]*"' "$file" | head -1 | tr -dc '0-9') + skipped=$(grep -o 'skipped="[0-9]*"' "$file" | head -1 | tr -dc '0-9') + failures=$(grep -o 'failures="[0-9]*"' "$file" | head -1 | tr -dc '0-9') + errors=$(grep -o 'errors="[0-9]*"' "$file" | head -1 | tr -dc '0-9') + echo "[brain-it] $(basename "$file" .xml): tests=$tests skipped=$skipped failures=$failures errors=$errors" + if [[ "${tests:-0}" -eq 0 || "${skipped:-1}" -ne 0 || "${failures:-1}" -ne 0 || "${errors:-1}" -ne 0 ]]; then + return 1 + fi + found=$((found + 1)) + done + if [[ $found -eq 0 ]]; then + echo "[brain-it] no real-Brain integration result was produced" >&2 + return 1 + fi +} +if [[ $STATUS -eq 0 ]] && ! verify_results; then + echo "[brain-it] FAILED: the real-Brain integration did not actually run green (skip or zero tests)" >&2 + STATUS=1 +fi + +echo "[brain-it] server=$SERVER_HEAD brain=$BRAIN_HEAD gradle exit=$STATUS" +echo "[brain-it] reports: $ROOT/build/reports/tests/test/index.html" +if [[ $STATUS -ne 0 ]]; then + echo "[brain-it] supervisor log tail:" + tail -n 40 "$LOG_DIR/supervisor.log" || true +fi +exit $STATUS diff --git a/src/main/java/com/proovy/domain/asset/dto/response/AssetDetailResponse.java b/src/main/java/com/proovy/domain/asset/dto/response/AssetDetailResponse.java index 92bec30a..81b57512 100644 --- a/src/main/java/com/proovy/domain/asset/dto/response/AssetDetailResponse.java +++ b/src/main/java/com/proovy/domain/asset/dto/response/AssetDetailResponse.java @@ -22,7 +22,7 @@ public class AssetDetailResponse { @Schema(description = "소속 노트 ID", example = "10") private Long noteId; - @Schema(description = "파일 출처 (upload: 업로드, ai_generated: AI 생성)", example = "upload") + @Schema(description = "파일 출처 (upload: 업로드)", example = "upload") private String source; @Schema(description = "파일명", example = "discrete_math_HW2.pdf") @@ -92,7 +92,7 @@ public static AssetDetailResponse from(Asset asset, String thumbnailUrl) { .fileSize(asset.getFileSize()) .mimeType(asset.getMimeType()) .thumbnailUrl(thumbnailUrl) - .totalPages(asset.getTotalPages()) + .totalPages(asset.getPageCount()) .ocrStatus(asset.getOcrStatus() != null ? asset.getOcrStatus().name() : null) .ocrProcessedAt(asset.getOcrProcessedAt()) .createdAt(asset.getCreatedAt()); diff --git a/src/main/java/com/proovy/domain/asset/entity/Asset.java b/src/main/java/com/proovy/domain/asset/entity/Asset.java index 8c9f1585..4cdf0668 100644 --- a/src/main/java/com/proovy/domain/asset/entity/Asset.java +++ b/src/main/java/com/proovy/domain/asset/entity/Asset.java @@ -9,7 +9,9 @@ import org.springframework.data.annotation.LastModifiedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; +import java.time.Instant; import java.time.LocalDateTime; +import java.util.UUID; @Entity @Table(name = "assets") @@ -45,15 +47,22 @@ public class Asset { @Column(nullable = false, length = 20) @Enumerated(EnumType.STRING) - private AssetSource source; // upload, ai_generated + private AssetSource source; // upload (Phase 1은 생성 파일 저장 없음) @Column(nullable = false, length = 20) @Enumerated(EnumType.STRING) - private AssetStatus status; // PENDING, UPLOADED, FAILED + private AssetStatus status; // PENDING, ACTIVE, FAILED, DELETING, DELETED private LocalDateTime uploadExpiresAt; // Presigned URL 만료 시간 - private Integer totalPages; // 총 페이지 수 (PDF/PPT) + @Column(name = "content_version", nullable = false) + private Integer contentVersion = 1; // run dependency·Source Reference가 고정하는 content version + + @Column(length = 64) + private String sha256; // 확정된 원본의 SHA-256 (S09에서 기록) + + @Column(name = "page_count") + private Integer pageCount; // PDF 총 페이지 수 @Column(length = 20) @Enumerated(EnumType.STRING) @@ -64,6 +73,19 @@ public class Asset { private LocalDateTime ocrProcessedAt; // OCR 처리 완료 시각 + // 삭제 operation (DELETING부터 새 run scope·source availability에서 제외, S13에서 사용) + @Column(name = "deletion_operation_id") + private UUID deletionOperationId; + + @Column(name = "deletion_requested_at") + private Instant deletionRequestedAt; + + @Column(name = "brain_cleanup_acked_at") + private Instant brainCleanupAckedAt; + + @Column(name = "storage_cleaned_at") + private Instant storageCleanedAt; + @CreatedDate @Column(nullable = false, updatable = false) private LocalDateTime createdAt; @@ -92,8 +114,7 @@ public Asset(Long userId, Long noteId, String fileName, Long fileSize, } public enum AssetSource { - upload, - ai_generated + upload } public enum OcrStatus { @@ -104,20 +125,20 @@ public enum OcrStatus { } /** - * 업로드 완료 상태로 변경 + * 업로드 확정 (PENDING → ACTIVE) */ public void markAsUploaded() { - this.status = AssetStatus.UPLOADED; + this.status = AssetStatus.ACTIVE; this.ocrStatus = OcrStatus.processing; } /** * OCR 처리 완료 */ - public void completeOcr(String ocrText, Integer totalPages) { + public void completeOcr(String ocrText, Integer pageCount) { this.ocrStatus = OcrStatus.completed; this.ocrText = ocrText; - this.totalPages = totalPages; + this.pageCount = pageCount; this.ocrProcessedAt = LocalDateTime.now(); } diff --git a/src/main/java/com/proovy/domain/asset/entity/AssetStatus.java b/src/main/java/com/proovy/domain/asset/entity/AssetStatus.java index adaaba60..2e34b95d 100644 --- a/src/main/java/com/proovy/domain/asset/entity/AssetStatus.java +++ b/src/main/java/com/proovy/domain/asset/entity/AssetStatus.java @@ -1,7 +1,9 @@ package com.proovy.domain.asset.entity; public enum AssetStatus { - PENDING, // Presigned URL 발급됨, 업로드 대기 - UPLOADED, // S3 업로드 완료 - FAILED // 업로드 실패 또는 만료 + PENDING, // 업로드 URL 발급됨, 업로드 대기 (staging) + ACTIVE, // 업로드 확정된 Note Asset + FAILED, // 업로드 실패 또는 만료 + DELETING, // 삭제 확인 직후, 새 run scope·source availability에서 제외 + DELETED // Brain cleanup·GCS 정리 완료 } diff --git a/src/main/java/com/proovy/domain/asset/repository/AssetRepository.java b/src/main/java/com/proovy/domain/asset/repository/AssetRepository.java index 1d9a4efb..4b0b98be 100644 --- a/src/main/java/com/proovy/domain/asset/repository/AssetRepository.java +++ b/src/main/java/com/proovy/domain/asset/repository/AssetRepository.java @@ -80,10 +80,10 @@ public interface AssetRepository extends JpaRepository { Long sumFileSizeByNoteIdAndStatus(@Param("noteId") Long noteId, @Param("status") AssetStatus status); /** - * 특정 사용자의 UPLOADED 상태 자산 파일 크기 합계 조회 + * 특정 사용자의 ACTIVE 상태 자산 파일 크기 합계 조회 * (PENDING, FAILED 상태는 실제 S3에 저장되지 않으므로 제외) */ - @Query("SELECT COALESCE(SUM(a.fileSize), 0) FROM Asset a WHERE a.userId = :userId AND a.status = 'UPLOADED'") + @Query("SELECT COALESCE(SUM(a.fileSize), 0) FROM Asset a WHERE a.userId = :userId AND a.status = com.proovy.domain.asset.entity.AssetStatus.ACTIVE") Long sumFileSizeByUserId(@Param("userId") Long userId); /** diff --git a/src/main/java/com/proovy/domain/asset/service/AssetsServiceImpl.java b/src/main/java/com/proovy/domain/asset/service/AssetsServiceImpl.java index 0070b32d..d83bac83 100644 --- a/src/main/java/com/proovy/domain/asset/service/AssetsServiceImpl.java +++ b/src/main/java/com/proovy/domain/asset/service/AssetsServiceImpl.java @@ -16,6 +16,7 @@ import com.proovy.global.infra.gcs.GcsService; import com.proovy.global.infra.thumbnail.ThumbnailService; import com.proovy.global.response.ErrorCode; +import com.proovy.domain.run.admission.RunAssetDeletionGuard; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -43,6 +44,7 @@ public class AssetsServiceImpl implements AssetsService { private final GcsService s3Service; private final UserPlanRepository userPlanRepository; private final ThumbnailService thumbnailService; + private final RunAssetDeletionGuard runAssetDeletionGuard; private static final int PRESIGNED_URL_DURATION_MINUTES = 15; private static final long BYTES_PER_MB = 1024L * 1024L; @@ -137,7 +139,7 @@ private void validateFileName(String fileName) { } private void validateStorageCapacity(Long noteId, Long fileSize) { - Long uploadedSize = assetRepository.sumFileSizeByNoteIdAndStatus(noteId, AssetStatus.UPLOADED); + Long uploadedSize = assetRepository.sumFileSizeByNoteIdAndStatus(noteId, AssetStatus.ACTIVE); Long pendingSize = assetRepository.sumFileSizeByNoteIdAndStatus(noteId, AssetStatus.PENDING); long currentUsage = (uploadedSize != null ? uploadedSize : 0L) + (pendingSize != null ? pendingSize : 0L); @@ -165,7 +167,7 @@ public DownloadUrlResponse generateDownloadUrl(Long userId, Long assetId) { } // 3. 업로드 완료 상태 검증 - if (asset.getStatus() != AssetStatus.UPLOADED) { + if (asset.getStatus() != AssetStatus.ACTIVE) { throw new BusinessException(ErrorCode.ASSET4006); } @@ -195,7 +197,7 @@ public AssetDetailResponse confirmUpload(Long userId, Long assetId) { } // 3. 이미 확인된 자산인지 확인 (중복 호출 방지) - if (asset.getStatus() == AssetStatus.UPLOADED) { + if (asset.getStatus() == AssetStatus.ACTIVE) { throw new BusinessException(ErrorCode.ASSET4091); } @@ -204,7 +206,7 @@ public AssetDetailResponse confirmUpload(Long userId, Long assetId) { throw new BusinessException(ErrorCode.ASSET4007); } - // 5. Asset 상태 업데이트 (PENDING → UPLOADED, ocrStatus → processing) + // 5. Asset 상태 업데이트 (PENDING → ACTIVE, ocrStatus → processing) // Optimistic Locking으로 동시 요청 처리 try { asset.markAsUploaded(); @@ -292,6 +294,9 @@ public void deleteAsset(Long userId, Long assetId) { throw new BusinessException(ErrorCode.ASSET4031); } + // run이 의존한 asset은 hard delete할 수 없다 (S13 DELETING 전환 전, 접수와 같은 Note 잠금 순서) + runAssetDeletionGuard.requireNoRunDependency(List.of(asset.getNoteId()), List.of(assetId)); + // GCS 키 저장 (트랜잭션 커밋 후 삭제를 위해) final String objectKey = asset.getObjectKey(); final String thumbnailObjectKey = asset.getThumbnailObjectKey(); diff --git a/src/main/java/com/proovy/domain/conversation/config/ChatMessageTsvBackfillRunner.java b/src/main/java/com/proovy/domain/conversation/config/ChatMessageTsvBackfillRunner.java deleted file mode 100644 index abc007db..00000000 --- a/src/main/java/com/proovy/domain/conversation/config/ChatMessageTsvBackfillRunner.java +++ /dev/null @@ -1,86 +0,0 @@ -package com.proovy.domain.conversation.config; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.boot.ApplicationArguments; -import org.springframework.boot.ApplicationRunner; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.stereotype.Component; -import org.springframework.transaction.support.TransactionTemplate; - -/** - * chat_messages.content_tsv 컬럼 백필을 위한 ApplicationRunner - * - * Flyway 트랜잭션 내에서 배치 처리 시 SKIP LOCKED가 무의미하므로 - * 서버 시작 시 autocommit 모드로 별도 실행 - */ -@Slf4j -@Component -@RequiredArgsConstructor -public class ChatMessageTsvBackfillRunner implements ApplicationRunner { - - private final JdbcTemplate jdbcTemplate; - private final TransactionTemplate transactionTemplate; - - private static final int BATCH_SIZE = 500; - private static final int SLEEP_MS = 50; - - @Override - public void run(ApplicationArguments args) { - backfillContentTsv(); - } - - private void backfillContentTsv() { - // 백필이 필요한 행이 있는지 먼저 확인 - Integer nullCount = jdbcTemplate.queryForObject( - "SELECT COUNT(*)::int FROM chat_messages WHERE content_tsv IS NULL", - Integer.class - ); - - if (nullCount == null || nullCount == 0) { - log.info("[ChatMessageTsvBackfill] No rows need backfill, skipping"); - return; - } - - log.info("[ChatMessageTsvBackfill] Starting backfill for {} rows", nullCount); - - int totalUpdated = 0; - - while (true) { - // 각 배치를 별도 트랜잭션으로 실행 (autocommit 효과) - Integer updated = transactionTemplate.execute(status -> { - return jdbcTemplate.update(""" - UPDATE chat_messages - SET content_tsv = to_tsvector('simple', COALESCE(content->>'text', '')) - WHERE chat_message_id IN ( - SELECT chat_message_id FROM chat_messages - WHERE content_tsv IS NULL - LIMIT ? - FOR UPDATE SKIP LOCKED - ) - """, BATCH_SIZE); - }); - - if (updated == null || updated == 0) { - break; - } - - totalUpdated += updated; - - if (totalUpdated % 5000 == 0) { - log.info("[ChatMessageTsvBackfill] Progress: {} rows updated", totalUpdated); - } - - // 다른 트랜잭션에 기회 제공 - try { - Thread.sleep(SLEEP_MS); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - log.warn("[ChatMessageTsvBackfill] Interrupted, stopping backfill"); - break; - } - } - - log.info("[ChatMessageTsvBackfill] Completed. Total updated: {} rows", totalUpdated); - } -} diff --git a/src/main/java/com/proovy/domain/conversation/controller/ConversationController.java b/src/main/java/com/proovy/domain/conversation/controller/ConversationController.java index da8b8fdc..5e73fa1b 100644 --- a/src/main/java/com/proovy/domain/conversation/controller/ConversationController.java +++ b/src/main/java/com/proovy/domain/conversation/controller/ConversationController.java @@ -1,23 +1,14 @@ package com.proovy.domain.conversation.controller; import com.proovy.domain.conversation.dto.request.CanvasImageUploadRequest; -import com.proovy.domain.conversation.dto.request.ConversationRequest; import com.proovy.domain.conversation.dto.response.CanvasImageUploadResponse; -import com.proovy.domain.conversation.dto.response.ConversationDetailResponse; -import com.proovy.domain.conversation.dto.response.ConversationResponse; import com.proovy.domain.conversation.dto.response.ConversationSearchResponse; -import com.proovy.domain.conversation.service.ChatService; import com.proovy.domain.conversation.service.ConversationQueryService; -import com.proovy.global.exception.BusinessException; import com.proovy.global.response.ApiResponse; -import com.proovy.global.response.ErrorCode; import com.proovy.global.security.UserPrincipal; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponses; -import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -25,142 +16,20 @@ import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.format.annotation.DateTimeFormat; -import org.springframework.http.MediaType; -import org.springframework.http.codec.ServerSentEvent; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -import reactor.core.publisher.Flux; -import java.time.Duration; import java.time.LocalDate; @Slf4j @RestController @RequestMapping("/api/conversations") @RequiredArgsConstructor -@Tag(name = "Conversation", description = "대화 API (Proovy-ai 연동)") +@Tag(name = "Conversation", description = "대화 조회 API (conversationId = noteId)") public class ConversationController { - private final ChatService chatService; private final ConversationQueryService conversationQueryService; - // 정상 응답은 text/event-stream 으로 보내되, - // 예외(GlobalExceptionHandler)는 application/json 으로 내려갈 수 있도록 JSON 도 허용한다. - @PostMapping(produces = {MediaType.TEXT_EVENT_STREAM_VALUE, MediaType.APPLICATION_JSON_VALUE}) - @Operation( - summary = "대화 생성 (SSE 스트리밍 또는 단건 응답)", - description = """ - 사용자 기반 대화를 Proovy-ai 에이전트에 전달하고 응답을 받습니다. - - - isStream=true: SSE 스트리밍으로 실시간 응답 (기본 구현) - - isStream=false: 최종 응답만 단건으로 반환 (향후 구현) - - 첨부 자산(mentionedAssetIds)은 S3 URL로 변환되어 전달됩니다. - 메시지는 ChatSession과 Note에 모두 연결되어 저장됩니다. - """ - ) - @ApiResponses({ - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "200", - description = "대화 생성 성공 (SSE 스트리밍)", - content = @Content(mediaType = "text/event-stream") - ), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "404", - description = "USER4041 - 사용자를 찾을 수 없습니다." - ), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "400", - description = "CONV4001 - 잘못된 기능 값이 포함되어 있습니다." - ), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "500", - description = "CONV5001/CONV5002 - Proovy-ai 통신 오류" - ) - }) - public Flux> createConversation( - @Parameter(description = "스트리밍 여부 (true: SSE, false: 단건 JSON)", example = "true") - @RequestParam(name = "isStream", defaultValue = "true") Boolean isStream, - - @Parameter(hidden = true) - @AuthenticationPrincipal UserPrincipal userPrincipal, - - @Parameter(hidden = true) - @RequestHeader(value = "Authorization", required = false) String authorizationHeader, - - @Valid @RequestBody ConversationRequest request - ) { - Long resolvedUserId = userPrincipal.getUserId(); - - // Authorization 헤더에서 Bearer 토큰 추출 - String accessToken = extractBearerToken(authorizationHeader); - - if (Boolean.FALSE.equals(isStream)) { - throw new UnsupportedOperationException("invoke 모드는 아직 구현되지 않았습니다. isStream=true 로만 호출해 주세요."); - } - - log.info("Create conversation - userId: {}, isStream: {}", resolvedUserId, isStream); - - return streamResponse(resolvedUserId, request, accessToken); - } - - /** - * Authorization 헤더에서 Bearer 토큰 추출 - */ - private String extractBearerToken(String authorizationHeader) { - if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) { - return authorizationHeader.substring(7); - } - return null; - } - - /** - * SSE 스트리밍 응답 생성 - */ - private Flux> streamResponse(Long userId, ConversationRequest request, String accessToken) { - return chatService.streamConversation(userId, request, accessToken) - .map(event -> { - try { - // V2: SSE 표준 event 필드를 사용하여 이벤트를 구분합니다. - // 프론트엔드에서는 addEventListener(eventType, ...)로 수신해야 합니다. - String eventType = event.getEvent(); - - // 데이터 Payload JSON 변환 - Object payload = event.getData() != null ? event.getData() : java.util.Collections.emptyMap(); - String dataJson = convertToJson(payload); - - return ServerSentEvent.builder() - .event(eventType) // SSE 표준 event 헤더 설정 - .data(dataJson) - .build(); - } catch (Exception e) { - log.error("Failed to build SSE event", e); - return ServerSentEvent.builder() - .event("error") - .data("{\"message\":\"Failed to process event\"}") - .build(); - } - }) - .doOnComplete(() -> log.info("SSE stream completed for userId: {}", userId)) - .doOnError(error -> log.error("SSE stream error for userId: {}", userId, error)); - } - - /** - * Map을 JSON 문자열로 변환 - */ - private String convertToJson(Object data) { - try { - if (data == null) { - return "{}"; - } - // 간단한 JSON 변환 (Jackson ObjectMapper 사용) - return new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(data); - } catch (Exception e) { - log.warn("Failed to convert to JSON", e); - return "{}"; - } - } - /** * 캔버스 이미지 업로드 */ @@ -205,7 +74,7 @@ public ApiResponse uploadCanvasImage( description = """ 사용자의 모든 노트에서 대화를 검색합니다. PostgreSQL Full-Text Search를 활용합니다. - 노트 제목만 매칭된 결과는 conversationId/userMessage/assistantMessage가 null일 수 있습니다. + 노트 제목만 매칭된 결과는 messageId/userMessage/assistantMessage가 null일 수 있습니다. """ ) @ApiResponses({ @@ -260,49 +129,4 @@ public ApiResponse searchConversations( ); return ApiResponse.success("조회에 성공했습니다.", response); } - - /** - * 대화 상세 조회 - */ - @GetMapping("/{conversationId}") - @Operation( - summary = "대화 상세 조회", - description = """ - 특정 메시지의 상세 내용을 조회합니다. - - **주의**: conversationId는 ChatMessage ID를 의미합니다. - 검색 결과나 노트 상세에서 반환된 conversationId를 사용하세요. - """ - ) - @ApiResponses({ - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "200", - description = "조회 성공" - ), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "401", - description = "AUTH4011 - 인증이 필요합니다." - ), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "403", - description = "CONV4031 - 해당 대화에 접근할 권한이 없습니다." - ), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "404", - description = "CONV4042 - 대화를 찾을 수 없습니다." - ) - }) - public ApiResponse getConversationDetail( - @Parameter(hidden = true) - @AuthenticationPrincipal UserPrincipal userPrincipal, - - @Parameter(description = "대화 ID", required = true) - @PathVariable Long conversationId - ) { - Long userId = userPrincipal.getUserId(); - log.info("Get conversation detail - userId: {}, conversationId: {}", userId, conversationId); - - ConversationDetailResponse response = conversationQueryService.getConversationDetail(userId, conversationId); - return ApiResponse.success("조회에 성공했습니다.", response); - } } diff --git a/src/main/java/com/proovy/domain/conversation/dto/request/ConversationRequest.java b/src/main/java/com/proovy/domain/conversation/dto/request/ConversationRequest.java deleted file mode 100644 index 3e071686..00000000 --- a/src/main/java/com/proovy/domain/conversation/dto/request/ConversationRequest.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.proovy.domain.conversation.dto.request; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotBlank; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import java.util.List; - -@Getter -@Builder -@NoArgsConstructor -@AllArgsConstructor -@Schema(description = "대화 생성 요청") -public class ConversationRequest { - - @Schema(description = "연결할 노트 ID (선택)", example = "123") - private Long noteId; - - @Schema(description = "사용자 질문/지시문", example = "이 문제를 풀어줘", required = true) - @NotBlank(message = "질문 내용은 필수입니다.") - private String text; - - @Schema(description = "LaTeX 수식 입력", example = "\\int_{0}^{1} x^2 dx") - private String latex; - - @Schema(description = "언급된 자산 ID 목록", example = "[1, 2, 3]") - private List mentionedAssetIds; - - @Schema(description = "선택된 기능 목록", example = "[\"Solve\", \"Check\"]") - private List chosenFeatures; - - @Schema(description = "캔버스 이미지 ID 목록", example = "[10, 20]") - private List canvasImageIds; -} diff --git a/src/main/java/com/proovy/domain/conversation/dto/request/ProovyAiRequest.java b/src/main/java/com/proovy/domain/conversation/dto/request/ProovyAiRequest.java deleted file mode 100644 index b5f4beae..00000000 --- a/src/main/java/com/proovy/domain/conversation/dto/request/ProovyAiRequest.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.proovy.domain.conversation.dto.request; - -import com.fasterxml.jackson.annotation.JsonProperty; -import lombok.Builder; -import lombok.Getter; - -import java.util.List; -import java.util.Map; - -/** - * Proovy-ai 에이전트에 전달할 요청 DTO - */ -@Getter -@Builder -public class ProovyAiRequest { - - // StreamInput(UserInput)와 동일한 필드 구조를 사용해야 한다. - - // 필수: message (이미 lowerCamelCase) - @JsonProperty("message") - private String message; - - // 선택: threadId (없으면 Proovy-ai에서 새 thread 생성) - @JsonProperty("threadId") - private String threadId; - - // 선택: userId (없으면 Proovy-ai에서 새 user 생성) - @JsonProperty("userId") - private String userId; - - // 선택: filesUrl - @JsonProperty("filesUrl") - private List filesUrl; - - // 선택: chosenFeatures - @JsonProperty("chosenFeatures") - private List chosenFeatures; - - // 선택: streamTokens (기본 True이지만 명시적으로 보낼 수 있음) - @JsonProperty("streamTokens") - private Boolean streamTokens; - - // 선택: agentConfig - 기존 metadata를 이 필드로 보낸다. - @JsonProperty("agentConfig") - private Map agentConfig; - - // 선택: authToken - Spring 백엔드 인증 토큰 (크레딧 API 호출용) - @JsonProperty("authToken") - private String authToken; -} diff --git a/src/main/java/com/proovy/domain/conversation/dto/response/ConversationDetailResponse.java b/src/main/java/com/proovy/domain/conversation/dto/response/ConversationDetailResponse.java deleted file mode 100644 index 8b04383e..00000000 --- a/src/main/java/com/proovy/domain/conversation/dto/response/ConversationDetailResponse.java +++ /dev/null @@ -1,115 +0,0 @@ -package com.proovy.domain.conversation.dto.response; - -import lombok.Builder; -import lombok.Getter; - -import java.time.LocalDateTime; -import java.util.List; - -@Getter -@Builder -public class ConversationDetailResponse { - - private Long conversationId; - private NoteInfo note; - private UserMessageDetail userMessage; - private AssistantMessageDetail assistantMessage; - private List aiRuns; - private CreditUsedInfo creditUsed; - private LocalDateTime createdAt; - private LocalDateTime updatedAt; - - @Getter - @Builder - public static class NoteInfo { - private Long noteId; - private String title; - } - - @Getter - @Builder - public static class UserMessageDetail { - private Long messageId; - private String text; - private String latex; - private List mentionedFiles; - private List mentionedTools; - private List canvasImages; - private LocalDateTime createdAt; - } - - @Getter - @Builder - public static class AssistantMessageDetail { - private Long messageId; - private String text; - private List sections; - private CodeExecutionInfo codeExecution; - private GeneratedProblemInfo generatedProblem; - private LocalDateTime createdAt; - } - - @Getter - @Builder - public static class MentionedFileDetail { - private Long assetId; - private String fileName; - private String thumbnailUrl; - } - - @Getter - @Builder - public static class CanvasImageInfo { - private Long assetId; - private String previewUrl; - } - - @Getter - @Builder - public static class SectionInfo { - private String type; - private String title; - private String content; - } - - @Getter - @Builder - public static class CodeExecutionInfo { - private String status; - private String message; - } - - @Getter - @Builder - public static class GeneratedProblemInfo { - private String title; - private String content; - private String latex; - } - - @Getter - @Builder - public static class AiRunInfo { - private Long aiRunId; - private String runType; - private String modelName; - private String status; - private Integer promptTokens; - private Integer completionTokens; - private Long latencyMs; - } - - @Getter - @Builder - public static class CreditUsedInfo { - private Integer amount; - private List breakdown; - } - - @Getter - @Builder - public static class CreditBreakdown { - private String reason; - private Integer amount; - } -} diff --git a/src/main/java/com/proovy/domain/conversation/dto/response/ConversationResponse.java b/src/main/java/com/proovy/domain/conversation/dto/response/ConversationResponse.java deleted file mode 100644 index 6940c8c8..00000000 --- a/src/main/java/com/proovy/domain/conversation/dto/response/ConversationResponse.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.proovy.domain.conversation.dto.response; - -import com.fasterxml.jackson.annotation.JsonProperty; -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import java.util.Map; - -/** - * 단건 응답용 DTO (isStream=false) - */ -@Getter -@Builder -@NoArgsConstructor -@AllArgsConstructor -@Schema(description = "대화 응답") -public class ConversationResponse { - - @Schema(description = "대화 ID", example = "1") - private Long conversationId; - - @Schema(description = "메시지 ID", example = "10") - private Long messageId; - - @Schema(description = "AI 응답 내용") - private String content; - - @Schema(description = "Proovy-ai 스레드 ID", example = "thread_abc123") - private String threadId; - - @Schema(description = "추가 메타데이터") - private Map metadata; -} diff --git a/src/main/java/com/proovy/domain/conversation/dto/response/ConversationSearchResponse.java b/src/main/java/com/proovy/domain/conversation/dto/response/ConversationSearchResponse.java index cb43cef1..769e3c21 100644 --- a/src/main/java/com/proovy/domain/conversation/dto/response/ConversationSearchResponse.java +++ b/src/main/java/com/proovy/domain/conversation/dto/response/ConversationSearchResponse.java @@ -22,18 +22,16 @@ public class ConversationSearchResponse { @Builder @JsonInclude(JsonInclude.Include.NON_NULL) public static class ConversationSearchItem { - @Schema(description = "대화 ID (ChatMessage ID). 제목만 매칭된 결과에서는 null", example = "150", nullable = true) - private Long conversationId; - - @Schema(description = "노트 ID", example = "10") + @Schema(description = "매칭된 메시지 ID (UUID). 제목만 매칭된 결과에서는 null", nullable = true) + private String messageId; + + @Schema(description = "노트 ID (= conversationId)", example = "10") private Long noteId; private String noteTitle; @Schema(description = "사용자 메시지 매칭 정보. 제목만 매칭된 결과에서는 null", nullable = true) private MessageInfo userMessage; @Schema(description = "어시스턴트 메시지 매칭 정보. 제목만 매칭된 결과에서는 null", nullable = true) private MessageInfo assistantMessage; - private List mentionedFiles; - private List mentionedTools; private Double relevance; @Schema(description = "결과 기준 시각. 제목만 매칭된 결과에서는 노트 생성 시각", nullable = true) private LocalDateTime createdAt; @@ -47,13 +45,6 @@ public static class MessageInfo { private String highlight; } - @Getter - @Builder - public static class MentionedFile { - private Long assetId; - private String fileName; - } - @Getter @Builder public static class PageInfo { diff --git a/src/main/java/com/proovy/domain/conversation/dto/response/ProovyAiStreamEvent.java b/src/main/java/com/proovy/domain/conversation/dto/response/ProovyAiStreamEvent.java deleted file mode 100644 index 60956cfd..00000000 --- a/src/main/java/com/proovy/domain/conversation/dto/response/ProovyAiStreamEvent.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.proovy.domain.conversation.dto.response; - -import com.fasterxml.jackson.annotation.JsonProperty; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import java.util.Map; - -/** - * Proovy-ai SSE 스트림 이벤트 DTO - */ -@Getter -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class ProovyAiStreamEvent { - - @JsonProperty("event") - private String event; - - @JsonProperty("data") - private Map data; -} diff --git a/src/main/java/com/proovy/domain/conversation/entity/ChatMessage.java b/src/main/java/com/proovy/domain/conversation/entity/ChatMessage.java index 0e5abba4..ae69a5ca 100644 --- a/src/main/java/com/proovy/domain/conversation/entity/ChatMessage.java +++ b/src/main/java/com/proovy/domain/conversation/entity/ChatMessage.java @@ -3,85 +3,97 @@ import com.fasterxml.jackson.databind.JsonNode; import com.proovy.domain.note.entity.Note; import io.hypersistence.utils.hibernate.type.json.JsonBinaryType; +import com.proovy.global.entity.AssignedIdEntity; import jakarta.persistence.*; import lombok.AccessLevel; -import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import org.hibernate.annotations.Type; -import org.springframework.data.annotation.CreatedDate; -import org.springframework.data.jpa.domain.support.AuditingEntityListener; -import java.time.LocalDateTime; +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; +/** + * Note에 직접 연결된 대화 메시지 (conversation_id = note_id). + * + *

assistant canonical 본문(response_pages·source_references·plain_text)은 terminal + * transaction(1.82)에서 한 번만 채워지며, 이후 DB trigger가 변경을 막는다. + * Source Reference catalog는 Note Asset FK에 묶이지 않은 snapshot이다. + */ @Entity @Table(name = "chat_messages") @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) -@EntityListeners(AuditingEntityListener.class) -public class ChatMessage { +public class ChatMessage extends AssignedIdEntity { @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "chat_message_id") - private Long id; + @Column(name = "message_id") + private UUID id; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "chat_session_id", nullable = false) - private ChatSession chatSession; + @JoinColumn(name = "note_id", nullable = false, updatable = false) + private Note note; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "note_id") - private Note note; // Note 기반 대화인 경우 연결 (nullable) + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 10, updatable = false) + private MessageRole role; @Enumerated(EnumType.STRING) - @Column(nullable = false, length = 10) - private MessageRole role; // USER, ASSISTANT, SYSTEM + @Column(nullable = false, length = 20) + private MessageStatus status; @Type(JsonBinaryType.class) - @Column(columnDefinition = "jsonb") - private JsonNode content; // JSONB로 유연한 데이터 저장 + @Column(name = "user_input", columnDefinition = "jsonb", updatable = false) + private JsonNode userInput; // USER: {"text": ..., "latex"?: ...} - @Column(name = "message_type", nullable = false, length = 50) - private String messageType; // text, image, code, etc. + @Type(JsonBinaryType.class) + @Column(name = "response_pages", columnDefinition = "jsonb") + private JsonNode responsePages; // ASSISTANT COMPLETED: canonical response_pages - @Enumerated(EnumType.STRING) - @Column(length = 20) - private MessageStatus status; // STREAMING, COMPLETED + @Type(JsonBinaryType.class) + @Column(name = "source_references", columnDefinition = "jsonb") + private JsonNode sourceReferences; // ASSISTANT COMPLETED: Source Reference snapshot catalog - @CreatedDate - @Column(name = "created_at", updatable = false) - private LocalDateTime createdAt; + @Column(name = "plain_text", columnDefinition = "TEXT") + private String plainText; // 공개 본문 파생 plain text (USER는 입력 text) - @Builder - public ChatMessage(ChatSession chatSession, Note note, MessageRole role, JsonNode content, String messageType, MessageStatus status) { - this.chatSession = chatSession; - this.note = note; - this.role = role; - this.content = content; - this.messageType = messageType != null ? messageType : "text"; - this.status = status; - } + @Column(name = "content_sha256", length = 64) + private String contentSha256; - public void updateContent(JsonNode content) { - this.content = content; - } + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "completed_at") + private Instant completedAt; - public void updateContentAndStatus(JsonNode content, MessageStatus status) { - this.content = content; + private ChatMessage(UUID id, Note note, MessageRole role, MessageStatus status, + JsonNode userInput, String plainText, Instant createdAt) { + this.id = Objects.requireNonNull(id); + this.note = Objects.requireNonNull(note); + this.role = role; this.status = status; + this.userInput = userInput; + this.plainText = plainText; + this.createdAt = Objects.requireNonNull(createdAt); } /** - * content에서 텍스트 추출 (검색용) + * 사용자 메시지. userInput은 반드시 문자열 text를 가진 object여야 한다. */ - public String getTextContent() { - if (content == null) { - return null; - } - if (content.has("text")) { - return content.get("text").asText(); + public static ChatMessage user(UUID id, Note note, JsonNode userInput, Instant createdAt) { + if (userInput == null || !userInput.isObject() || !userInput.path("text").isTextual()) { + throw new IllegalArgumentException("user message input must be an object with a text field"); } - return content.toString(); + return new ChatMessage(id, note, MessageRole.USER, MessageStatus.COMPLETED, + userInput, userInput.get("text").asText(), createdAt); + } + + /** + * 본문 없는 assistant placeholder (Active Run 진행 표시용). + */ + public static ChatMessage assistantPlaceholder(UUID id, Note note, Instant createdAt) { + return new ChatMessage(id, note, MessageRole.ASSISTANT, MessageStatus.PENDING, + null, null, createdAt); } } diff --git a/src/main/java/com/proovy/domain/conversation/entity/ChatSession.java b/src/main/java/com/proovy/domain/conversation/entity/ChatSession.java deleted file mode 100644 index b7e44340..00000000 --- a/src/main/java/com/proovy/domain/conversation/entity/ChatSession.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.proovy.domain.conversation.entity; - -import com.proovy.domain.user.entity.User; -import jakarta.persistence.*; -import lombok.AccessLevel; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; -import org.springframework.data.annotation.CreatedDate; -import org.springframework.data.jpa.domain.support.AuditingEntityListener; - -import java.time.LocalDateTime; - -@Entity -@Table(name = "chat_sessions") -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@EntityListeners(AuditingEntityListener.class) -public class ChatSession { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "chat_session_id") - private Long id; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id", nullable = false) - private User user; - - @Column(name = "external_thread_id", unique = true, length = 100) - private String externalThreadId; // Proovy-ai thread_id - - @Enumerated(EnumType.STRING) - @Column(length = 10) - private ChatSessionStatus status; // active, closed - - @CreatedDate - @Column(name = "created_at", updatable = false) - private LocalDateTime createdAt; - - @Column(name = "closed_at") - private LocalDateTime closedAt; - - @Builder - public ChatSession(User user, String externalThreadId, ChatSessionStatus status) { - this.user = user; - this.externalThreadId = externalThreadId; - this.status = status != null ? status : ChatSessionStatus.ACTIVE; - } - - public void updateExternalThreadId(String externalThreadId) { - this.externalThreadId = externalThreadId; - } - - public void close() { - this.status = ChatSessionStatus.CLOSED; - this.closedAt = LocalDateTime.now(); - } - - public void activate() { - this.status = ChatSessionStatus.ACTIVE; - this.closedAt = null; - } -} diff --git a/src/main/java/com/proovy/domain/conversation/entity/ChatSessionStatus.java b/src/main/java/com/proovy/domain/conversation/entity/ChatSessionStatus.java deleted file mode 100644 index cbe306d6..00000000 --- a/src/main/java/com/proovy/domain/conversation/entity/ChatSessionStatus.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.proovy.domain.conversation.entity; - -public enum ChatSessionStatus { - ACTIVE, - CLOSED -} diff --git a/src/main/java/com/proovy/domain/conversation/entity/MessageAsset.java b/src/main/java/com/proovy/domain/conversation/entity/MessageAsset.java deleted file mode 100644 index ffce9dfd..00000000 --- a/src/main/java/com/proovy/domain/conversation/entity/MessageAsset.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.proovy.domain.conversation.entity; - -import com.proovy.domain.asset.entity.Asset; -import jakarta.persistence.*; -import lombok.AccessLevel; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Entity -@Table(name = "message_assets") -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class MessageAsset { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "message_id", nullable = false) - private ChatMessage chatMessage; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "asset_id", nullable = false) - private Asset asset; - - @Builder - public MessageAsset(ChatMessage chatMessage, Asset asset) { - this.chatMessage = chatMessage; - this.asset = asset; - } -} - diff --git a/src/main/java/com/proovy/domain/conversation/entity/MessageAttachment.java b/src/main/java/com/proovy/domain/conversation/entity/MessageAttachment.java deleted file mode 100644 index 2d259545..00000000 --- a/src/main/java/com/proovy/domain/conversation/entity/MessageAttachment.java +++ /dev/null @@ -1,62 +0,0 @@ -package com.proovy.domain.conversation.entity; - -import com.fasterxml.jackson.databind.JsonNode; -import io.hypersistence.utils.hibernate.type.json.JsonBinaryType; -import jakarta.persistence.*; -import lombok.AccessLevel; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; -import org.hibernate.annotations.Type; -import org.springframework.data.annotation.CreatedDate; -import org.springframework.data.jpa.domain.support.AuditingEntityListener; - -import java.time.LocalDateTime; - -@Entity -@Table(name = "message_attachments") -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@EntityListeners(AuditingEntityListener.class) -public class MessageAttachment { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "message_attachment_id") - private Long id; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "chat_message_id", nullable = false) - private ChatMessage chatMessage; - - @Column(name = "file_name", length = 255) - private String fileName; - - @Column(name = "mime_type", length = 100) - private String mimeType; - - @Column(name = "storage_key", length = 500) - private String storageKey; - - @Column(nullable = false, length = 20) - private String source; // upload, ai_generated - - @Type(JsonBinaryType.class) - @Column(columnDefinition = "jsonb") - private JsonNode metadata; - - @CreatedDate - @Column(name = "created_at", updatable = false) - private LocalDateTime createdAt; - - @Builder - public MessageAttachment(ChatMessage chatMessage, String fileName, String mimeType, - String storageKey, String source, JsonNode metadata) { - this.chatMessage = chatMessage; - this.fileName = fileName; - this.mimeType = mimeType; - this.storageKey = storageKey; - this.source = source; - this.metadata = metadata; - } -} diff --git a/src/main/java/com/proovy/domain/conversation/entity/MessageStatus.java b/src/main/java/com/proovy/domain/conversation/entity/MessageStatus.java index bb0ba142..54bcd1a8 100644 --- a/src/main/java/com/proovy/domain/conversation/entity/MessageStatus.java +++ b/src/main/java/com/proovy/domain/conversation/entity/MessageStatus.java @@ -1,9 +1,17 @@ package com.proovy.domain.conversation.entity; /** - * Note 도메인 전용 MessageStatus enum + * 메시지 상태. USER 메시지는 항상 COMPLETED이고 ASSISTANT 메시지는 + * PENDING/STREAMING placeholder에서 terminal(COMPLETED/FAILED/CANCELED)로 한 번만 전환된다. */ public enum MessageStatus { + PENDING, STREAMING, - COMPLETED + COMPLETED, + FAILED, + CANCELED; + + public boolean isTerminal() { + return this == COMPLETED || this == FAILED || this == CANCELED; + } } diff --git a/src/main/java/com/proovy/domain/conversation/entity/MessageTool.java b/src/main/java/com/proovy/domain/conversation/entity/MessageTool.java deleted file mode 100644 index db176f17..00000000 --- a/src/main/java/com/proovy/domain/conversation/entity/MessageTool.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.proovy.domain.conversation.entity; - -import jakarta.persistence.*; -import lombok.AccessLevel; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Entity -@Table(name = "message_tools") -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class MessageTool { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "message_id", nullable = false) - private ChatMessage chatMessage; - - @Column(nullable = false, length = 50) - private String toolCode; - - @Builder - public MessageTool(ChatMessage chatMessage, String toolCode) { - this.chatMessage = chatMessage; - this.toolCode = toolCode; - } -} - diff --git a/src/main/java/com/proovy/domain/conversation/entity/TurnAttachment.java b/src/main/java/com/proovy/domain/conversation/entity/TurnAttachment.java new file mode 100644 index 00000000..b98c9bdb --- /dev/null +++ b/src/main/java/com/proovy/domain/conversation/entity/TurnAttachment.java @@ -0,0 +1,107 @@ +package com.proovy.domain.conversation.entity; + +import com.proovy.global.entity.AssignedIdEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Immutable; + +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +/** + * source user message의 직접 입력 첨부 (Turn Attachment). Note 삭제 시 함께 정리되고 + * 후속 message나 Note Corpus로 복제하지 않는다. 검증된 media metadata만 저장하며 insert-only다. + * 실제 업로드·검증 경로는 S09에서 구현한다. + */ +@Entity +@Immutable +@Table(name = "turn_attachments") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TurnAttachment extends AssignedIdEntity { + + @Id + @Column(name = "attachment_id") + private UUID id; + + @Column(name = "note_id", nullable = false) + private Long noteId; + + @Column(name = "source_message_id", nullable = false) + private UUID sourceMessageId; + + @Column(name = "attachment_index", nullable = false) + private Integer attachmentIndex; + + @Column(name = "object_key", nullable = false, length = 500) + private String objectKey; + + @Column(name = "thumbnail_object_key", length = 500) + private String thumbnailObjectKey; + + @Column(nullable = false, length = 64) + private String sha256; + + @Column(name = "mime_type", nullable = false, length = 100) + private String mimeType; + + @Column(name = "byte_size", nullable = false) + private Long byteSize; + + @Column(name = "page_count") + private Integer pageCount; + + @Column(name = "pixel_width") + private Integer pixelWidth; + + @Column(name = "pixel_height") + private Integer pixelHeight; + + @Column(name = "pixel_count") + private Long pixelCount; + + @Column(name = "upload_policy_version", nullable = false, length = 64) + private String uploadPolicyVersion; + + @Column(name = "page_understanding_version", nullable = false, length = 64) + private String pageUnderstandingVersion; + + @Column(name = "image_normalization_version", nullable = false, length = 64) + private String imageNormalizationVersion; + + @Column(name = "display_label", length = 200) + private String displayLabel; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @Builder + private TurnAttachment(UUID id, Long noteId, UUID sourceMessageId, int attachmentIndex, String objectKey, + String thumbnailObjectKey, String sha256, String mimeType, long byteSize, + Integer pageCount, Integer pixelWidth, Integer pixelHeight, Long pixelCount, + String uploadPolicyVersion, String pageUnderstandingVersion, + String imageNormalizationVersion, String displayLabel, Instant createdAt) { + this.id = Objects.requireNonNull(id); + this.noteId = Objects.requireNonNull(noteId); + this.sourceMessageId = Objects.requireNonNull(sourceMessageId); + this.attachmentIndex = attachmentIndex; + this.objectKey = Objects.requireNonNull(objectKey); + this.thumbnailObjectKey = thumbnailObjectKey; + this.sha256 = Objects.requireNonNull(sha256); + this.mimeType = Objects.requireNonNull(mimeType); + this.byteSize = byteSize; + this.pageCount = pageCount; + this.pixelWidth = pixelWidth; + this.pixelHeight = pixelHeight; + this.pixelCount = pixelCount; + this.uploadPolicyVersion = Objects.requireNonNull(uploadPolicyVersion); + this.pageUnderstandingVersion = Objects.requireNonNull(pageUnderstandingVersion); + this.imageNormalizationVersion = Objects.requireNonNull(imageNormalizationVersion); + this.displayLabel = displayLabel; + this.createdAt = Objects.requireNonNull(createdAt); + } +} diff --git a/src/main/java/com/proovy/domain/conversation/repository/ChatMessageRepository.java b/src/main/java/com/proovy/domain/conversation/repository/ChatMessageRepository.java index a360d952..944b792d 100644 --- a/src/main/java/com/proovy/domain/conversation/repository/ChatMessageRepository.java +++ b/src/main/java/com/proovy/domain/conversation/repository/ChatMessageRepository.java @@ -4,37 +4,20 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import java.time.LocalDate; import java.util.List; +import java.util.UUID; -public interface ChatMessageRepository extends JpaRepository { - - /** - * 특정 세션의 모든 메시지 조회 (시간순) - */ - List findByChatSessionIdOrderByCreatedAtAsc(Long chatSessionId); - - /** - * 특정 세션의 메시지 개수 조회 - */ - long countByChatSessionId(Long chatSessionId); - - @Modifying - @Query("DELETE FROM ChatMessage cm WHERE cm.chatSession.user.id = :userId") - void deleteAllByUserId(@Param("userId") Long userId); - - // ============================================= - // Note 기반 조회 - // ============================================= +public interface ChatMessageRepository extends JpaRepository { /** * 특정 노트의 모든 메시지 조회 (시간순) */ - List findByNoteIdOrderByCreatedAtAsc(Long noteId); + @Query("SELECT cm FROM ChatMessage cm WHERE cm.note.id = :noteId ORDER BY cm.createdAt ASC, cm.id ASC") + List findByNoteIdOrdered(@Param("noteId") Long noteId); /** * 특정 노트의 메시지 개수 조회 @@ -51,85 +34,33 @@ public interface ChatMessageRepository extends JpaRepository List countByNoteIdIn(@Param("noteIds") List noteIds); /** - * 여러 노트의 모든 메시지 조회 (시간순) - */ - @Query("SELECT cm FROM ChatMessage cm WHERE cm.note.id IN :noteIds ORDER BY cm.createdAt ASC") - List findByNoteIdInOrderByCreatedAtAsc(@Param("noteIds") List noteIds); - - /** - * 특정 노트의 모든 메시지 ID 조회 - */ - @Query("SELECT cm.id FROM ChatMessage cm WHERE cm.note.id = :noteId") - List findIdsByNoteId(@Param("noteId") Long noteId); - - /** - * 특정 노트의 모든 메시지 삭제 (벌크 삭제) - */ - @Modifying - @Query("DELETE FROM ChatMessage cm WHERE cm.note.id = :noteId") - void deleteByNoteIdInBulk(@Param("noteId") Long noteId); - - // ============================================= - // Full-Text Search (JSONB content 필드에서 text 추출하여 검색) - // ============================================= - - /** - * Full-Text Search: content_tsv 컬럼 기반 검색 (영문/숫자) - * content_tsv는 트리거에 의해 자동 업데이트됨 (V30 마이그레이션) - * COALESCE로 content_tsv가 NULL인 경우 to_tsvector 폴백 처리 - */ - @Query(value = """ - SELECT cm.* FROM chat_messages cm - JOIN notes n ON cm.note_id = n.note_id - WHERE n.user_id = :userId - AND COALESCE(cm.content_tsv, to_tsvector('simple', COALESCE(cm.content->>'text', ''))) @@ plainto_tsquery('simple', :query) - AND (CAST(:noteId AS BIGINT) IS NULL OR cm.note_id = CAST(:noteId AS BIGINT)) - AND (CAST(:startDate AS DATE) IS NULL OR DATE(cm.created_at) >= CAST(:startDate AS DATE)) - AND (CAST(:endDate AS DATE) IS NULL OR DATE(cm.created_at) <= CAST(:endDate AS DATE)) - ORDER BY cm.created_at DESC - """, - countQuery = """ - SELECT COUNT(*) FROM chat_messages cm - JOIN notes n ON cm.note_id = n.note_id - WHERE n.user_id = :userId - AND COALESCE(cm.content_tsv, to_tsvector('simple', COALESCE(cm.content->>'text', ''))) @@ plainto_tsquery('simple', :query) - AND (CAST(:noteId AS BIGINT) IS NULL OR cm.note_id = CAST(:noteId AS BIGINT)) - AND (CAST(:startDate AS DATE) IS NULL OR DATE(cm.created_at) >= CAST(:startDate AS DATE)) - AND (CAST(:endDate AS DATE) IS NULL OR DATE(cm.created_at) <= CAST(:endDate AS DATE)) - """, - nativeQuery = true) - Page searchByFullText( - @Param("userId") Long userId, - @Param("query") String query, - @Param("noteId") Long noteId, - @Param("startDate") LocalDate startDate, - @Param("endDate") LocalDate endDate, - Pageable pageable); - - /** - * Trigram Search: 메시지 내용 또는 노트 제목으로 검색 - * ILIKE: PostgreSQL case-insensitive LIKE (한글/영문 모두 지원) + * Trigram Search: 메시지 plain text 또는 노트 제목으로 검색 + * plain_text는 공개 본문에서 파생된 값이며 미완료 placeholder에는 없다. */ @Query(value = """ SELECT cm.* FROM chat_messages cm JOIN notes n ON cm.note_id = n.note_id WHERE n.user_id = :userId - AND (COALESCE(cm.content->>'text', '') ILIKE '%' || :query || '%' + AND n.status = 'ACTIVE' + AND cm.status = 'COMPLETED' + AND (cm.plain_text ILIKE '%' || :query || '%' OR n.title ILIKE '%' || :query || '%') AND (CAST(:noteId AS BIGINT) IS NULL OR cm.note_id = CAST(:noteId AS BIGINT)) - AND (CAST(:startDate AS DATE) IS NULL OR DATE(cm.created_at) >= CAST(:startDate AS DATE)) - AND (CAST(:endDate AS DATE) IS NULL OR DATE(cm.created_at) <= CAST(:endDate AS DATE)) - ORDER BY cm.created_at DESC + AND (CAST(:startDate AS DATE) IS NULL OR DATE(cm.created_at AT TIME ZONE 'Asia/Seoul') >= CAST(:startDate AS DATE)) + AND (CAST(:endDate AS DATE) IS NULL OR DATE(cm.created_at AT TIME ZONE 'Asia/Seoul') <= CAST(:endDate AS DATE)) + ORDER BY cm.created_at DESC, cm.message_id """, countQuery = """ SELECT COUNT(*) FROM chat_messages cm JOIN notes n ON cm.note_id = n.note_id WHERE n.user_id = :userId - AND (COALESCE(cm.content->>'text', '') ILIKE '%' || :query || '%' + AND n.status = 'ACTIVE' + AND cm.status = 'COMPLETED' + AND (cm.plain_text ILIKE '%' || :query || '%' OR n.title ILIKE '%' || :query || '%') AND (CAST(:noteId AS BIGINT) IS NULL OR cm.note_id = CAST(:noteId AS BIGINT)) - AND (CAST(:startDate AS DATE) IS NULL OR DATE(cm.created_at) >= CAST(:startDate AS DATE)) - AND (CAST(:endDate AS DATE) IS NULL OR DATE(cm.created_at) <= CAST(:endDate AS DATE)) + AND (CAST(:startDate AS DATE) IS NULL OR DATE(cm.created_at AT TIME ZONE 'Asia/Seoul') >= CAST(:startDate AS DATE)) + AND (CAST(:endDate AS DATE) IS NULL OR DATE(cm.created_at AT TIME ZONE 'Asia/Seoul') <= CAST(:endDate AS DATE)) """, nativeQuery = true) Page searchByTrigram( @@ -139,31 +70,4 @@ Page searchByTrigram( @Param("startDate") LocalDate startDate, @Param("endDate") LocalDate endDate, Pageable pageable); - - /** - * 검색어 하이라이트 추출 - */ - @Query(value = """ - SELECT ts_headline('simple', COALESCE(cm.content->>'text', ''), plainto_tsquery('simple', :query), - 'StartSel=, StopSel=, MaxWords=50, MinWords=20') - FROM chat_messages cm - WHERE cm.chat_message_id = :messageId - """, nativeQuery = true) - String getSearchHighlight(@Param("messageId") Long messageId, @Param("query") String query); - - /** - * Note 기반 대화 목록 조회 (USER 메시지와 그 다음 ASSISTANT 메시지를 쌍으로 그룹화) - * 페이징 지원 - */ - @Query(value = """ - SELECT cm.* FROM chat_messages cm - JOIN notes n ON cm.note_id = n.note_id - WHERE n.user_id = :userId - AND cm.note_id = :noteId - ORDER BY cm.created_at ASC - """, - nativeQuery = true) - List findByUserIdAndNoteId( - @Param("userId") Long userId, - @Param("noteId") Long noteId); } diff --git a/src/main/java/com/proovy/domain/conversation/repository/ChatSessionRepository.java b/src/main/java/com/proovy/domain/conversation/repository/ChatSessionRepository.java deleted file mode 100644 index ad8c85f7..00000000 --- a/src/main/java/com/proovy/domain/conversation/repository/ChatSessionRepository.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.proovy.domain.conversation.repository; - -import com.proovy.domain.conversation.entity.ChatSession; -import com.proovy.domain.conversation.entity.ChatSessionStatus; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Modifying; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; - -import java.util.List; -import java.util.Optional; - -public interface ChatSessionRepository extends JpaRepository { - - /** - * 특정 사용자의 활성 세션 목록 조회 - */ - List findByUserIdAndStatus(Long userId, ChatSessionStatus status); - - /** - * 특정 사용자의 가장 최근 활성 세션 조회 - */ - Optional findFirstByUserIdAndStatusOrderByCreatedAtDesc(Long userId, ChatSessionStatus status); - - /** - * 외부 스레드 ID로 세션 조회 - */ - Optional findByExternalThreadId(String externalThreadId); - - /** - * 특정 사용자의 모든 세션 개수 조회 - */ - long countByUserId(Long userId); - - @Modifying - @Query("DELETE FROM ChatSession cs WHERE cs.user.id = :userId") - void deleteAllByUserId(@Param("userId") Long userId); -} diff --git a/src/main/java/com/proovy/domain/conversation/repository/MessageAssetRepository.java b/src/main/java/com/proovy/domain/conversation/repository/MessageAssetRepository.java deleted file mode 100644 index b0f63bdc..00000000 --- a/src/main/java/com/proovy/domain/conversation/repository/MessageAssetRepository.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.proovy.domain.conversation.repository; - -import com.proovy.domain.conversation.entity.MessageAsset; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Modifying; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; - -import java.util.List; - -public interface MessageAssetRepository extends JpaRepository { - - /** - * 특정 메시지의 모든 자산 연결 조회 - */ - List findByChatMessageId(Long chatMessageId); - - /** - * 특정 자산을 참조하는 모든 MessageAsset 조회 - */ - List findByAssetId(Long assetId); - - /** - * 특정 자산들을 참조하는 모든 MessageAsset 조회 - */ - List findByAssetIdIn(List assetIds); - - /** - * 특정 자산들을 참조하는 모든 MessageAsset 삭제 (벌크 삭제) - */ - @Modifying - @Query("DELETE FROM MessageAsset ma WHERE ma.asset.id IN :assetIds") - void deleteByAssetIdInBulk(@Param("assetIds") List assetIds); - - /** - * 여러 메시지의 모든 자산 연결 삭제 (벌크 삭제) - */ - @Modifying - @Query("DELETE FROM MessageAsset ma WHERE ma.chatMessage.id IN :chatMessageIds") - void deleteByChatMessageIdInBulk(@Param("chatMessageIds") List chatMessageIds); - - List findByChatMessageIdIn(List chatMessageIds); - - /** - * 특정 메시지들에 연결된 Asset 목록 조회 - */ - @Query("SELECT ma.asset FROM MessageAsset ma WHERE ma.chatMessage.id IN :chatMessageIds") - List findAssetsByChatMessageIds(@Param("chatMessageIds") List chatMessageIds); -} - diff --git a/src/main/java/com/proovy/domain/conversation/repository/MessageAttachmentRepository.java b/src/main/java/com/proovy/domain/conversation/repository/MessageAttachmentRepository.java deleted file mode 100644 index 1f3ba940..00000000 --- a/src/main/java/com/proovy/domain/conversation/repository/MessageAttachmentRepository.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.proovy.domain.conversation.repository; - -import com.proovy.domain.conversation.entity.MessageAttachment; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Modifying; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; - -import java.util.List; - -public interface MessageAttachmentRepository extends JpaRepository { - - /** - * 특정 메시지의 첨부 파일 목록 조회 - */ - List findByChatMessageId(Long chatMessageId); - - @Modifying - @Query("DELETE FROM MessageAttachment ma WHERE ma.chatMessage.chatSession.user.id = :userId") - void deleteAllByUserId(@Param("userId") Long userId); -} diff --git a/src/main/java/com/proovy/domain/conversation/repository/MessageToolRepository.java b/src/main/java/com/proovy/domain/conversation/repository/MessageToolRepository.java deleted file mode 100644 index b94e24c6..00000000 --- a/src/main/java/com/proovy/domain/conversation/repository/MessageToolRepository.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.proovy.domain.conversation.repository; - -import com.proovy.domain.conversation.entity.MessageTool; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Modifying; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; - -import java.util.List; - -public interface MessageToolRepository extends JpaRepository { - - /** - * 특정 메시지의 모든 도구 연결 조회 - */ - List findByChatMessageId(Long chatMessageId); - - /** - * 여러 메시지의 모든 도구 연결 조회 - */ - List findByChatMessageIdIn(List chatMessageIds); - - /** - * 여러 메시지의 모든 도구 연결 삭제 (벌크 삭제) - */ - @Modifying - @Query("DELETE FROM MessageTool mt WHERE mt.chatMessage.id IN :chatMessageIds") - void deleteByChatMessageIdInBulk(@Param("chatMessageIds") List chatMessageIds); - - /** - * 특정 메시지들에 특정 도구 코드가 존재하는지 확인 - */ - @Query("SELECT COUNT(mt) > 0 FROM MessageTool mt WHERE mt.chatMessage.id IN :chatMessageIds AND mt.toolCode = :toolCode") - boolean existsByChatMessageIdInAndToolCode(@Param("chatMessageIds") List chatMessageIds, @Param("toolCode") String toolCode); - - /** - * 특정 메시지들의 도구 코드 목록 조회 - */ - @Query("SELECT DISTINCT mt.toolCode FROM MessageTool mt WHERE mt.chatMessage.id IN :chatMessageIds") - List findToolCodesByChatMessageIds(@Param("chatMessageIds") List chatMessageIds); -} - diff --git a/src/main/java/com/proovy/domain/conversation/repository/TurnAttachmentRepository.java b/src/main/java/com/proovy/domain/conversation/repository/TurnAttachmentRepository.java new file mode 100644 index 00000000..77f4c152 --- /dev/null +++ b/src/main/java/com/proovy/domain/conversation/repository/TurnAttachmentRepository.java @@ -0,0 +1,12 @@ +package com.proovy.domain.conversation.repository; + +import com.proovy.domain.conversation.entity.TurnAttachment; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.UUID; + +public interface TurnAttachmentRepository extends JpaRepository { + + List findBySourceMessageIdOrderByAttachmentIndexAsc(UUID sourceMessageId); +} diff --git a/src/main/java/com/proovy/domain/conversation/service/ChatService.java b/src/main/java/com/proovy/domain/conversation/service/ChatService.java deleted file mode 100644 index dd4acf30..00000000 --- a/src/main/java/com/proovy/domain/conversation/service/ChatService.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.proovy.domain.conversation.service; - -import com.proovy.domain.conversation.dto.request.ConversationRequest; -import com.proovy.domain.conversation.dto.response.ConversationResponse; -import com.proovy.domain.conversation.dto.response.ProovyAiStreamEvent; -import reactor.core.publisher.Flux; - -/** - * 채팅 세션 서비스 인터페이스 - */ -public interface ChatService { - - /** - * 스트리밍 대화 요청 (SSE) - * @param userId 사용자 ID - * @param request 대화 요청 - * @param accessToken 사용자 인증 토큰 (AI 서버에서 Spring API 호출 시 사용) - * @return SSE 스트림 - */ - Flux streamConversation(Long userId, ConversationRequest request, String accessToken); - - /** - * 단건 대화 요청 (향후 구현) - * @param userId 사용자 ID - * @param request 대화 요청 - * @param accessToken 사용자 인증 토큰 (AI 서버에서 Spring API 호출 시 사용) - * @return 대화 응답 - */ - ConversationResponse invokeConversation(Long userId, ConversationRequest request, String accessToken); -} diff --git a/src/main/java/com/proovy/domain/conversation/service/ChatServiceImpl.java b/src/main/java/com/proovy/domain/conversation/service/ChatServiceImpl.java deleted file mode 100644 index beaae32f..00000000 --- a/src/main/java/com/proovy/domain/conversation/service/ChatServiceImpl.java +++ /dev/null @@ -1,592 +0,0 @@ -package com.proovy.domain.conversation.service; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ObjectNode; -import com.proovy.domain.asset.entity.Asset; -import com.proovy.domain.asset.repository.AssetRepository; -import com.proovy.domain.conversation.dto.request.ConversationRequest; -import com.proovy.domain.conversation.dto.request.ProovyAiRequest; -import com.proovy.domain.credit.dto.request.CreditUseRequest; -import com.proovy.domain.credit.service.CreditUseService; -import com.proovy.domain.conversation.dto.response.ConversationResponse; -import com.proovy.domain.conversation.dto.response.ProovyAiStreamEvent; -import com.proovy.domain.note.entity.Note; -import com.proovy.domain.note.repository.NoteRepository; -import com.proovy.domain.conversation.entity.ChatMessage; -import com.proovy.domain.conversation.entity.ChatSession; -import com.proovy.domain.conversation.entity.ChatSessionStatus; -import com.proovy.domain.conversation.entity.MessageRole; -import com.proovy.domain.conversation.entity.MessageStatus; -import com.proovy.domain.conversation.entity.MessageAsset; -import com.proovy.domain.conversation.repository.ChatMessageRepository; -import com.proovy.domain.conversation.repository.ChatSessionRepository; -import com.proovy.domain.conversation.repository.MessageAssetRepository; -import com.proovy.domain.conversation.repository.MessageAttachmentRepository; -import com.proovy.domain.user.entity.User; -import com.proovy.domain.user.repository.UserRepository; -import com.proovy.global.exception.BusinessException; -import com.proovy.global.infra.gcs.GcsService; -import com.proovy.global.response.ErrorCode; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.MediaType; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.transaction.support.TransactionTemplate; -import org.springframework.web.reactive.function.client.WebClient; -import org.springframework.web.reactive.function.client.WebClientRequestException; -import org.springframework.core.ParameterizedTypeReference; -import org.springframework.http.codec.ServerSentEvent; -import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; -import reactor.core.scheduler.Schedulers; - -import java.net.ConnectException; -import java.time.Duration; -import java.util.*; -import java.util.stream.Collectors; - -@Slf4j -@Service -@RequiredArgsConstructor -public class ChatServiceImpl implements ChatService { - - private final ChatSessionRepository chatSessionRepository; - private final ChatMessageRepository chatMessageRepository; - private final MessageAssetRepository messageAssetRepository; - private final MessageAttachmentRepository messageAttachmentRepository; - private final UserRepository userRepository; - private final AssetRepository assetRepository; - private final NoteRepository noteRepository; - private final GcsService s3Service; - private final WebClient webClient; - private final ObjectMapper objectMapper; - private final CreditUseService creditUseService; - private final TransactionTemplate transactionTemplate; - - @Value("${proovy.ai.host}") - private String proovyAiHost; - - // 지원되는 기능 목록 - DB toolCode(대문자)와 AI feature명(PascalCase) 모두 허용 - private static final Set SUPPORTED_FEATURES = Set.of( - "Solve", "Check", "Explain", "Variant", "Practice", - "Solution", "SOLUTION", - "VARIATION", - "GRAPH", "CreateGraph" - ); - - @Override - public Flux streamConversation(Long userId, ConversationRequest request, String accessToken) { - log.info("[Chat] streamConversation 시작 - userId: {}, textLength: {}, features: {}, assetIds: {}", - userId, - request.getText() != null ? request.getText().length() : 0, - request.getChosenFeatures(), - request.getMentionedAssetIds()); - - // 초기화 작업을 트랜잭션 내에서 수행 - StreamInitData initData = transactionTemplate.execute(status -> { - // 1. 사용자 검증 - User user = userRepository.findById(userId) - .orElseThrow(() -> new BusinessException(ErrorCode.USER4041)); - - // 2. 기능 검증 - if (request.getChosenFeatures() != null) { - validateFeatures(request.getChosenFeatures()); - } - - // 3. Note 기반 threadId 조회 또는 ChatSession 사용 - Note note = null; - String threadIdToUse = null; - - if (request.getNoteId() != null) { - // Note가 지정된 경우 - note = noteRepository.findById(request.getNoteId()) - .orElseThrow(() -> new BusinessException(ErrorCode.NOTE4041)); - - // Note 소유자 검증 - if (!note.getUser().getId().equals(userId)) { - throw new BusinessException(ErrorCode.NOTE4031); - } - - threadIdToUse = note.getThreadId(); - log.debug("[Chat] Note 기반 대화 - noteId: {}, threadId: {}", note.getId(), threadIdToUse); - } - - // 4. ChatSession 조회 또는 생성 (Note 없이 대화하거나 메시지 저장용) - ChatSession chatSession = chatSessionRepository - .findFirstByUserIdAndStatusOrderByCreatedAtDesc(userId, ChatSessionStatus.ACTIVE) - .orElseGet(() -> { - ChatSession newSession = ChatSession.builder() - .user(user) - .status(ChatSessionStatus.ACTIVE) - .build(); - return chatSessionRepository.save(newSession); - }); - - // Note가 없는 경우 ChatSession의 threadId 사용 - if (threadIdToUse == null && note == null) { - threadIdToUse = chatSession.getExternalThreadId(); - } - - log.debug("[Chat] 사용 중인 ChatSession - id: {}, externalThreadId: {}, threadIdToUse: {}", - chatSession.getId(), chatSession.getExternalThreadId(), threadIdToUse); - - // 4. 사용자 메시지 저장 (ChatMessage with Note) - JsonNode userContentJson = buildUserContentJson(request); - ChatMessage userMessage = ChatMessage.builder() - .chatSession(chatSession) - .note(note) // Note 기반 대화인 경우 연결 - .role(MessageRole.USER) - .content(userContentJson) - .messageType("text") - .status(MessageStatus.COMPLETED) - .build(); - ChatMessage savedUserMessage = chatMessageRepository.save(userMessage); - chatMessageRepository.flush(); // ID 확보 후 MessageAsset 저장 가능 - - // 4-1. 메시지-자산 연결 저장 (mentionedAssetIds + canvasImageIds, 중복 제거) - List allAssetIds = mergeDistinctAssetIds(request.getMentionedAssetIds(), request.getCanvasImageIds()); - if (!allAssetIds.isEmpty()) { - List assets = assetRepository.findAllByIdInAndUserId(allAssetIds, userId); - if (assets.size() != allAssetIds.size()) { - log.warn("[Chat] 메시지 자산 중 일부가 사용자 소유가 아니거나 존재하지 않음 - 요청: {}, 조회됨: {}, userId: {}", - allAssetIds.size(), assets.size(), userId); - } - List messageAssets = assets.stream() - .map(asset -> MessageAsset.builder() - .chatMessage(savedUserMessage) - .asset(asset) - .build()) - .collect(Collectors.toList()); - messageAssetRepository.saveAll(messageAssets); - log.info("[Chat] MessageAsset 저장 완료 - messageId: {}, assetCount: {}", savedUserMessage.getId(), messageAssets.size()); - } - - // 5. AI 메시지 placeholder 생성 (ChatMessage with Note) - ObjectNode emptyContent = objectMapper.createObjectNode(); - emptyContent.put("text", ""); - - ChatMessage aiMessage = ChatMessage.builder() - .chatSession(chatSession) - .note(note) // Note 기반 대화인 경우 연결 - .role(MessageRole.ASSISTANT) - .content(emptyContent) - .messageType("text") - .status(MessageStatus.STREAMING) - .build(); - ChatMessage savedAiMessage = chatMessageRepository.save(aiMessage); - - // 6. 자산 URL 변환 (mentionedAssetIds + canvasImageIds 모두 포함, 중복 제거) - List filesUrl = convertAssetIdsToUrls(allAssetIds, userId); - log.info("[Chat] 자산 URL 변환 완료 - mentionedAssetIds: {}, canvasImageIds: {}, filesUrl count: {}", - request.getMentionedAssetIds(), request.getCanvasImageIds(), filesUrl.size()); - - return new StreamInitData(chatSession, note, savedAiMessage, threadIdToUse, filesUrl); - }); - - if (initData == null) { - throw new BusinessException(ErrorCode.CONV5002, "초기화 데이터 생성 실패"); - } - - final ChatSession chatSession = initData.chatSession(); - final Note finalNote = initData.note(); - final Long savedAiMessageId = initData.aiMessage().getId(); - final String finalThreadIdToUse = initData.threadId(); - final List filesUrl = initData.filesUrl(); - - // 6.5. Proovy-ai 서버 상태를 사전에 한 번 체크하고, 연결이 불가능하면 바로 CONV5001 비즈니스 예외를 던진다. - checkProovyAiHealth(); - - // 7. Proovy-ai 요청 생성 (Proovy-ai StreamInput 스키마에 맞게 구성) - ProovyAiRequest aiRequest = ProovyAiRequest.builder() - .message(request.getText()) - .threadId(finalThreadIdToUse) // Note 또는 ChatSession의 threadId - .userId(String.valueOf(userId)) - .filesUrl(filesUrl) - .chosenFeatures(request.getChosenFeatures()) - .streamTokens(true) - .agentConfig(buildMetadata(request)) - .authToken(accessToken) // Spring 인증 토큰 전달 - .build(); - - log.info("[Chat] Proovy-ai 요청 생성 - threadId: {}, filesUrl count: {}, message: {}", - finalThreadIdToUse, filesUrl.size(), request.getText()); - - // 8. SSE 스트리밍 호출 - final StringBuilder contentBuilder = new StringBuilder(); - log.info("[Chat] Proovy-ai 스트리밍 호출 준비 - sessionId: {}, userId: {}", - chatSession.getId(), userId); - - return callProovyAiStream(aiRequest) - // 블로킹 DB 작업을 이벤트 루프 스레드에서 분리하여 thread starvation 방지 - .publishOn(Schedulers.boundedElastic()) - .doOnNext(event -> { - Map data = event.getData(); - String eventType = event.getEvent(); - - // 에러 이벤트 처리 - if ("run.failed".equals(eventType) || "error".equals(eventType)) { - String errMsg = "Stream Error"; - if (data != null) { - errMsg = String.valueOf(data.getOrDefault("message", "Unknown error")); - } - throw new BusinessException(ErrorCode.CONV5002, "스트리밍 중 에러 발생: " + errMsg); - } - - // thread_id 업데이트 (payload 내부에 포함되는 경우) - if (data != null && data.containsKey("thread_id")) { - String newThreadId = (String) data.get("thread_id"); - - if (newThreadId != null) { - // 별도 트랜잭션에서 threadId 업데이트 - transactionTemplate.executeWithoutResult(status -> { - if (finalNote != null) { - // Note가 있으면 Note에 threadId 저장 - Note noteToUpdate = noteRepository.findById(finalNote.getId()).orElse(null); - if (noteToUpdate != null && noteToUpdate.getThreadId() == null) { - noteToUpdate.updateThreadId(newThreadId); - noteRepository.save(noteToUpdate); - log.debug("[Chat] Note에 threadId 저장 - noteId: {}, threadId: {}", noteToUpdate.getId(), newThreadId); - } - } else { - // Note가 없으면 ChatSession에 저장 (기존 로직) - ChatSession sessionToUpdate = chatSessionRepository.findById(chatSession.getId()).orElse(null); - if (sessionToUpdate != null && sessionToUpdate.getExternalThreadId() == null) { - sessionToUpdate.updateExternalThreadId(newThreadId); - chatSessionRepository.save(sessionToUpdate); - log.debug("[Chat] ChatSession에 threadId 저장 - sessionId: {}, threadId: {}", sessionToUpdate.getId(), newThreadId); - } - } - }); - } - } - - // 토큰 스트림(event = llm.token.delta) 기준으로 내용 누적 - // FinalResponse 노드에서 LLM이 생성하는 토큰을 실시간으로 수집 - if ("llm.token.delta".equals(eventType) && data != null) { - Object content = data.get("delta"); - if (content != null) { - contentBuilder.append(content.toString()); - log.trace("[Chat] 토큰 수신 - content: {}", content); - } - } - }) - .doOnComplete(() -> { - // 스트리밍 완료 시 최종 내용 저장 - 별도 트랜잭션에서 수행 - transactionTemplate.executeWithoutResult(status -> { - // 토큰 스트리밍으로 누적된 내용을 최종 메시지로 저장 - String finalText = contentBuilder.toString(); - - if (finalText.isEmpty()) { - log.warn("[Chat] 스트리밍 완료했으나 토큰 내용이 비어있음 - messageId: {}", savedAiMessageId); - } - - // DB에서 최신 상태로 다시 조회 - ChatMessage savedAiMessage = chatMessageRepository.findById(savedAiMessageId) - .orElseThrow(() -> new BusinessException(ErrorCode.CONV5002, "AI 메시지를 찾을 수 없습니다: " + savedAiMessageId)); - - ObjectNode finalContent = objectMapper.createObjectNode(); - finalContent.put("text", finalText); - savedAiMessage.updateContentAndStatus(finalContent, MessageStatus.COMPLETED); - chatMessageRepository.save(savedAiMessage); - - log.info("[Chat] AI 메시지 저장 완료 - messageId: {}, length: {}", - savedAiMessage.getId(), finalText.length()); - }); - - // 크레딧 차감 (별도 트랜잭션) - if (request.getChosenFeatures() != null && !request.getChosenFeatures().isEmpty()) { - for (String feature : request.getChosenFeatures()) { - try { - CreditUseRequest creditRequest = CreditUseRequest.builder() - .eventType("LLM_QUERY") - .featureName(feature) - .difficulty("easy") - .description(feature + " 실행") - .build(); - creditUseService.useCredit(userId, creditRequest); - log.info("크레딧 차감 완료: userId={}, feature={}", userId, feature); - } catch (Exception e) { - log.error("크레딧 차감 실패: userId={}, feature={}", userId, feature, e); - } - } - } - - log.info("Streaming completed for session: {}, message: {}", - chatSession.getId(), savedAiMessageId); - }) - .doOnError(error -> { - // 에러 발생 시 메시지 삭제 - 별도 트랜잭션에서 수행 - log.error("Streaming error for session: {}", chatSession.getId(), error); - transactionTemplate.executeWithoutResult(status -> { - chatMessageRepository.deleteById(savedAiMessageId); - }); - }) - .onErrorResume(error -> { - log.error("Proovy-ai streaming failed", error); - - // Proovy-ai 서버 연결 자체가 안 되는 경우 (예: Connection refused) - if (error instanceof WebClientRequestException webClientError) { - Throwable cause = webClientError.getCause(); - if (cause instanceof ConnectException) { - return Flux.error(new BusinessException( - ErrorCode.CONV5001, - "Proovy-ai 서버에 연결할 수 없습니다: " + cause.getMessage() - )); - } - } - - // 이미 비즈니스 예외로 래핑된 경우 그대로 전달 - if (error instanceof BusinessException businessException) { - return Flux.error(businessException); - } - - // 그 외 일반적인 스트리밍 오류는 CONV5002로 래핑 - return Flux.error(new BusinessException( - ErrorCode.CONV5002, - "스트리밍 중 오류 발생: " + error.getMessage() - )); - }); - } - - @Override - @Transactional - public ConversationResponse invokeConversation(Long userId, ConversationRequest request, String accessToken) { - // TODO: 향후 isStream=false 시 구현 - throw new UnsupportedOperationException("invoke 모드는 아직 구현되지 않았습니다."); - } - - /** - * Proovy-ai /stream 호출 - */ - /** - * Proovy-ai /stream/v2 호출 (SSE v2) - */ - private Flux callProovyAiStream(ProovyAiRequest request) { - // v2 엔드포인트 사용 - String streamUrl = proovyAiHost + "/stream/v2"; - - log.info("Calling Proovy-ai stream v2: {}", streamUrl); - log.debug("Request payload: {}", request); - - return webClient.post() - .uri(streamUrl) - .contentType(MediaType.APPLICATION_JSON) - .bodyValue(request) - .accept(MediaType.TEXT_EVENT_STREAM) - .retrieve() - // v2는 ServerSentEvent 객체로 받아서 이벤트 필드를 활용 - .bodyToFlux(new ParameterizedTypeReference>() {}) - .timeout(Duration.ofMinutes(5)) - .flatMap(this::parseSseEvent) - .doOnNext(event -> log.debug("Received event: {}", event.getEvent())) - // 완료 이벤트 체크 (run.completed 또는 run.failed) - .takeUntil(event -> "run.completed".equals(event.getEvent()) || "run.failed".equals(event.getEvent())); - } - - /** - * Proovy-ai /health 헬스 체크를 사전에 수행하여, 서버가 아예 죽어있는 경우에는 - * SSE 스트리밍을 시작하기 전에 즉시 CONV5001 비즈니스 예외를 발생시킨다. - */ - private void checkProovyAiHealth() { - String healthUrl = proovyAiHost + "/health"; - log.debug("[Chat] Proovy-ai 헬스 체크 호출: {}", healthUrl); - - try { - webClient.get() - .uri(healthUrl) - .accept(MediaType.APPLICATION_JSON) - .retrieve() - .toBodilessEntity() - .timeout(Duration.ofSeconds(2)) - .block(); - } catch (Exception e) { - log.error("[Chat] Proovy-ai 헬스 체크 실패", e); - - if (e instanceof WebClientRequestException webClientError) { - Throwable cause = webClientError.getCause(); - if (cause instanceof ConnectException) { - throw new BusinessException( - ErrorCode.CONV5001, - "Proovy-ai 서버에 연결할 수 없습니다: " + cause.getMessage() - ); - } - } - - throw new BusinessException( - ErrorCode.CONV5001, - "Proovy-ai 헬스 체크 중 오류가 발생했습니다: " + e.getMessage() - ); - } - } - - /** - * SSE 이벤트 파싱 - */ - /** - * SSE 이벤트 파싱 (v2: ServerSentEvent -> ProovyAiStreamEvent) - */ - private Mono parseSseEvent(ServerSentEvent sse) { - return Mono.fromCallable(() -> { - try { - String eventName = sse.event(); - String dataStr = sse.data(); - - if (dataStr == null || dataStr.trim().isEmpty()) { - // 데이터 없는 ping(heartbeat)이거나 payload 없는 이벤트 - return ProovyAiStreamEvent.builder() - .event(eventName != null ? eventName : "heartbeat") - .data(Collections.emptyMap()) - .build(); - } - - // JSON payload 파싱 - @SuppressWarnings("unchecked") - Map payload = objectMapper.readValue(dataStr, Map.class); - - log.debug("[Chat] SSE v2 이벤트 수신 - event: {}, keys: {}", eventName, payload.keySet()); - - return ProovyAiStreamEvent.builder() - .event(eventName != null ? eventName : "message") - .data(payload) - .build(); - - } catch (Exception e) { - log.warn("[Chat] SSE 이벤트 파싱 실패: {}", sse, e); - return ProovyAiStreamEvent.builder() - .event("error") - .data(Map.of("error", "Failed to parse event", "raw", String.valueOf(sse.data()))) - .build(); - } - }); - } - - private String toPersistableText(Object messageContent) { - if (messageContent instanceof String text) { - return text; - } - - try { - return objectMapper.writeValueAsString(messageContent); - } catch (Exception e) { - log.warn("[Chat] message content JSON 변환 실패, 문자열로 대체", e); - return String.valueOf(messageContent); - } - } - - /** - * 자산 ID → S3 URL 변환 - */ - private List convertAssetIdsToUrls(List assetIds, Long userId) { - if (assetIds == null || assetIds.isEmpty()) { - log.debug("[Chat] 자산 ID 없음 - assetIds: null or empty"); - return Collections.emptyList(); - } - - List distinctAssetIds = assetIds.stream() - .filter(Objects::nonNull) - .distinct() - .collect(Collectors.toList()); - - if (distinctAssetIds.size() != assetIds.size()) { - log.debug("[Chat] 자산 ID 중복/NULL 제거 - 원본: {}, 정제 후: {}", assetIds.size(), distinctAssetIds.size()); - } - - List assets = assetRepository.findAllByIdInAndUserId(distinctAssetIds, userId); - log.info("[Chat] 자산 조회 결과 - 요청: {}, 조회됨: {}, userId: {}", distinctAssetIds, assets.size(), userId); - - if (assets.size() != distinctAssetIds.size()) { - log.warn("[Chat] 일부 자산 조회 실패 - 요청: {}, 조회됨: {}", - distinctAssetIds.size(), assets.size()); - } - - List urls = assets.stream() - .map(asset -> { - // Presigned URL 생성 (15분 유효) - String url = s3Service.generatePresignedDownloadUrl( - asset.getObjectKey(), asset.getFileName(), 15); - log.debug("[Chat] Presigned URL 생성 - assetId: {}, objectKey: {}", - asset.getId(), asset.getObjectKey()); - return url; - }) - .collect(Collectors.toList()); - - return urls; - } - - private List mergeDistinctAssetIds(List mentionedAssetIds, List canvasImageIds) { - LinkedHashSet merged = new LinkedHashSet<>(); - if (mentionedAssetIds != null) { - merged.addAll(mentionedAssetIds); - } - if (canvasImageIds != null) { - merged.addAll(canvasImageIds); - } - merged.remove(null); - return new ArrayList<>(merged); - } - - /** - * 기능 검증 - */ - private void validateFeatures(List features) { - for (String feature : features) { - if (!SUPPORTED_FEATURES.contains(feature)) { - throw new BusinessException(ErrorCode.CONV4001, - "지원하지 않는 기능: " + feature); - } - } - } - - /** - * 사용자 메시지 내용을 JSONB 형태로 구성 - */ - private JsonNode buildUserContentJson(ConversationRequest request) { - ObjectNode content = objectMapper.createObjectNode(); - content.put("text", request.getText()); - - if (request.getLatex() != null && !request.getLatex().isEmpty()) { - content.put("latex", request.getLatex()); - } - - if (request.getChosenFeatures() != null && !request.getChosenFeatures().isEmpty()) { - content.set("features", objectMapper.valueToTree(request.getChosenFeatures())); - } - - if (request.getMentionedAssetIds() != null && !request.getMentionedAssetIds().isEmpty()) { - content.set("mentioned_assets", objectMapper.valueToTree(request.getMentionedAssetIds())); - } - - return content; - } - - /** - * 메타데이터 구성 - */ - private Map buildMetadata(ConversationRequest request) { - Map metadata = new HashMap<>(); - - if (request.getLatex() != null) { - metadata.put("latex", request.getLatex()); - } - - if (request.getCanvasImageIds() != null && !request.getCanvasImageIds().isEmpty()) { - metadata.put("canvas_image_ids", request.getCanvasImageIds()); - } - - return metadata; - } - - /** - * 스트리밍 초기화 데이터를 담는 레코드 - */ - private record StreamInitData( - ChatSession chatSession, - Note note, - ChatMessage aiMessage, - String threadId, - List filesUrl - ) {} -} diff --git a/src/main/java/com/proovy/domain/conversation/service/ConversationQueryService.java b/src/main/java/com/proovy/domain/conversation/service/ConversationQueryService.java index e42cb9a3..97aad534 100644 --- a/src/main/java/com/proovy/domain/conversation/service/ConversationQueryService.java +++ b/src/main/java/com/proovy/domain/conversation/service/ConversationQueryService.java @@ -2,7 +2,6 @@ import com.proovy.domain.conversation.dto.request.CanvasImageUploadRequest; import com.proovy.domain.conversation.dto.response.CanvasImageUploadResponse; -import com.proovy.domain.conversation.dto.response.ConversationDetailResponse; import com.proovy.domain.conversation.dto.response.ConversationSearchResponse; import org.springframework.data.domain.Pageable; @@ -27,9 +26,4 @@ ConversationSearchResponse searchConversations( LocalDate endDate, Pageable pageable ); - - /** - * 대화 상세 조회 - */ - ConversationDetailResponse getConversationDetail(Long userId, Long conversationId); } diff --git a/src/main/java/com/proovy/domain/conversation/service/ConversationQueryServiceImpl.java b/src/main/java/com/proovy/domain/conversation/service/ConversationQueryServiceImpl.java index 5c7f702c..69685c30 100644 --- a/src/main/java/com/proovy/domain/conversation/service/ConversationQueryServiceImpl.java +++ b/src/main/java/com/proovy/domain/conversation/service/ConversationQueryServiceImpl.java @@ -5,7 +5,6 @@ import com.proovy.domain.asset.repository.AssetRepository; import com.proovy.domain.conversation.dto.request.CanvasImageUploadRequest; import com.proovy.domain.conversation.dto.response.CanvasImageUploadResponse; -import com.proovy.domain.conversation.dto.response.ConversationDetailResponse; import com.proovy.domain.conversation.dto.response.ConversationSearchResponse; import com.proovy.domain.conversation.entity.ChatMessage; import com.proovy.domain.conversation.entity.MessageRole; @@ -24,6 +23,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.*; import java.util.stream.Collectors; @@ -39,6 +39,7 @@ public class ConversationQueryServiceImpl implements ConversationQueryService { private final GcsService s3Service; private static final int PRESIGNED_URL_DURATION_MINUTES = 15; + private static final ZoneId SERVICE_ZONE = ZoneId.of("Asia/Seoul"); private static final Set ALLOWED_CANVAS_MIME_TYPES = Set.of("image/png", "image/jpeg", "image/webp"); @Override @@ -229,7 +230,7 @@ private List buildSearchItems continue; } - String textContent = message.getTextContent(); + String textContent = message.getPlainText(); boolean contentMatches = textContent != null && !textContent.isBlank() && textContent.toLowerCase().contains(lowerQuery); boolean titleMatches = note.getTitle() != null @@ -253,13 +254,13 @@ private List buildSearchItems ConversationSearchResponse.ConversationSearchItem.ConversationSearchItemBuilder builder = ConversationSearchResponse.ConversationSearchItem.builder() - .conversationId(contentMatches ? message.getId() : null) + .messageId(contentMatches ? message.getId().toString() : null) .noteId(note.getId()) .noteTitle(note.getTitle()) - .mentionedFiles(Collections.emptyList()) - .mentionedTools(Collections.emptyList()) .relevance(contentMatches ? calculateRelevance(textContent, query) : 0.3) - .createdAt(contentMatches ? message.getCreatedAt() : note.getCreatedAt()); + .createdAt(contentMatches + ? LocalDateTime.ofInstant(message.getCreatedAt(), SERVICE_ZONE) + : note.getCreatedAt()); if (contentMatches) { if (message.getRole() == MessageRole.USER) { @@ -279,7 +280,7 @@ private List buildSearchItems * ChatMessage에서 MessageInfo 생성 */ private ConversationSearchResponse.MessageInfo buildMessageInfoFromChatMessage(ChatMessage message, String query) { - String textContent = message.getTextContent(); + String textContent = message.getPlainText(); if (textContent == null) { return null; } @@ -363,52 +364,4 @@ private ConversationSearchResponse buildEmptySearchResponse(String query, Pageab .build()) .build(); } - - @Override - public ConversationDetailResponse getConversationDetail(Long userId, Long chatMessageId) { - // 1. ChatMessage 조회 (이제 conversationId 대신 chatMessageId를 사용) - ChatMessage chatMessage = chatMessageRepository.findById(chatMessageId) - .orElseThrow(() -> new BusinessException(ErrorCode.CONV4042)); - - // 2. 권한 검증 (해당 대화가 사용자의 노트에 속하는지) - Note note = chatMessage.getNote(); - if (note == null) { - throw new BusinessException(ErrorCode.CONV4042); - } - - if (!note.getUser().getId().equals(userId)) { - throw new BusinessException(ErrorCode.CONV4031); - } - - String textContent = chatMessage.getTextContent(); - - // 3. 응답 생성 - ConversationDetailResponse.ConversationDetailResponseBuilder responseBuilder = - ConversationDetailResponse.builder() - .conversationId(chatMessage.getId()) - .note(ConversationDetailResponse.NoteInfo.builder() - .noteId(note.getId()) - .title(note.getTitle()) - .build()) - .createdAt(chatMessage.getCreatedAt()) - .updatedAt(chatMessage.getCreatedAt()); - - if (chatMessage.getRole() == MessageRole.USER) { - responseBuilder.userMessage(ConversationDetailResponse.UserMessageDetail.builder() - .messageId(chatMessage.getId()) - .text(textContent) - .mentionedFiles(Collections.emptyList()) - .mentionedTools(Collections.emptyList()) - .createdAt(chatMessage.getCreatedAt()) - .build()); - } else if (chatMessage.getRole() == MessageRole.ASSISTANT) { - responseBuilder.assistantMessage(ConversationDetailResponse.AssistantMessageDetail.builder() - .messageId(chatMessage.getId()) - .text(textContent) - .createdAt(chatMessage.getCreatedAt()) - .build()); - } - - return responseBuilder.build(); - } } diff --git a/src/main/java/com/proovy/domain/credit/controller/CreditController.java b/src/main/java/com/proovy/domain/credit/controller/CreditController.java index 13e9a8fe..c11227e5 100644 --- a/src/main/java/com/proovy/domain/credit/controller/CreditController.java +++ b/src/main/java/com/proovy/domain/credit/controller/CreditController.java @@ -1,11 +1,7 @@ package com.proovy.domain.credit.controller; -import com.proovy.domain.credit.dto.request.CreditUseRequest; import com.proovy.domain.credit.dto.response.CreditBalanceResponse; -import com.proovy.domain.credit.dto.response.CreditCostResponse; import com.proovy.domain.credit.dto.response.CreditHistoryResponse; -import com.proovy.domain.credit.dto.response.CreditUseResponse; -import com.proovy.domain.credit.service.CreditCostService; import com.proovy.domain.credit.service.CreditHistoryService; import com.proovy.domain.credit.service.CreditUseService; import com.proovy.global.response.ApiResponse; @@ -16,13 +12,16 @@ import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +/** + * 크레딧 잔액·내역 조회 API. 브라우저가 사용량을 직접 차감하는 API는 두지 않는다. + * 일반 AI 요청 사용량은 서버 접수 transaction의 {@code RequestUsageService}만 차감·보상한다(ADR 0015). + */ @Slf4j @RestController @RequestMapping("/api/credits") @@ -31,7 +30,6 @@ public class CreditController { private final CreditHistoryService creditHistoryService; - private final CreditCostService creditCostService; private final CreditUseService creditUseService; @GetMapping("/history") @@ -92,34 +90,6 @@ public ResponseEntity> getCreditHistory( return ResponseEntity.ok(ApiResponse.success(response)); } - @GetMapping("/costs") - @Operation( - operationId = "02_getCreditCosts", - summary = "크레딧 비용 정책 조회", - description = "각 기능(OCR, 코드 실행, AI 질의 등)의 현재 적용 중인 크레딧 소모 비용을 조회합니다." - ) - @ApiResponses({ - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "200", - description = "조회 성공", - content = @Content(schema = @Schema(implementation = CreditCostResponse.class)) - ), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "401", - description = "인증 실패", - content = @Content(schema = @Schema(example = "{\"isSuccess\":false,\"code\":\"AUTH4013\",\"message\":\"유효하지 않은 토큰입니다.\"}")) - ), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "500", - description = "서버 오류", - content = @Content(schema = @Schema(example = "{\"isSuccess\":false,\"code\":\"SERVER5001\",\"message\":\"서버 오류가 발생했습니다.\"}")) - ) - }) - public ResponseEntity> getCreditCosts() { - CreditCostResponse response = creditCostService.getCreditCosts(); - return ResponseEntity.ok(ApiResponse.success(response)); - } - @GetMapping("/balance") @Operation( operationId = "03_getCreditBalance", @@ -153,59 +123,4 @@ public ResponseEntity> getCreditBalance( CreditBalanceResponse response = creditUseService.getBalance(userId, checkCost); return ResponseEntity.ok(ApiResponse.success(response)); } - - @PostMapping("/use") - @Operation( - operationId = "04_useCredit", - summary = "크레딧 사용 (차감)", - description = "AI 기능 사용 시 크레딧을 차감합니다. 난이도에 따라 비용이 조정됩니다." - ) - @ApiResponses({ - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "200", - description = "처리 완료 (성공/실패 여부는 응답의 success 필드로 확인)", - content = @Content(schema = @Schema(implementation = CreditUseResponse.class)) - ), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "401", - description = "인증 실패", - content = @Content(schema = @Schema(example = "{\"isSuccess\":false,\"code\":\"AUTH4013\",\"message\":\"유효하지 않은 토큰입니다.\"}")) - ), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "404", - description = "사용자 없음", - content = @Content(schema = @Schema(example = "{\"isSuccess\":false,\"code\":\"USER4041\",\"message\":\"사용자를 찾을 수 없습니다.\"}")) - ) - }) - public ResponseEntity> useCredit( - @AuthenticationPrincipal UserPrincipal userPrincipal, - @Valid @RequestBody CreditUseRequest request - ) { - Long userId = userPrincipal.getUserId(); - CreditUseResponse response = creditUseService.useCredit(userId, request); - return ResponseEntity.ok(ApiResponse.success(response)); - } - - @GetMapping("/estimate") - @Operation( - operationId = "05_estimateCost", - summary = "예상 크레딧 비용 조회", - description = "특정 기능 실행 시 소모될 예상 크레딧을 조회합니다." - ) - @ApiResponses({ - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "200", - description = "조회 성공" - ) - }) - public ResponseEntity> estimateCost( - @Parameter(description = "기능 이름 (Solve, Explain, CreateGraph, Variant, Solution, Check)", example = "Solve") - @RequestParam String featureName, - - @Parameter(description = "문제 난이도 (easy, medium, hard)", example = "medium") - @RequestParam(required = false, defaultValue = "easy") String difficulty - ) { - int cost = creditUseService.getEstimatedCost(featureName, difficulty); - return ResponseEntity.ok(ApiResponse.success(cost)); - } } diff --git a/src/main/java/com/proovy/domain/credit/dto/request/CreditUseRequest.java b/src/main/java/com/proovy/domain/credit/dto/request/CreditUseRequest.java deleted file mode 100644 index 5e94853d..00000000 --- a/src/main/java/com/proovy/domain/credit/dto/request/CreditUseRequest.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.proovy.domain.credit.dto.request; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@Builder -@NoArgsConstructor -@AllArgsConstructor -@Schema(description = "크레딧 사용 요청") -public class CreditUseRequest { - - @NotBlank - @Schema(description = "이벤트 타입", example = "LLM_QUERY") - private String eventType; - - @Schema(description = "문제 난이도 (easy, medium, hard)", example = "medium") - private String difficulty; - - @Schema(description = "기능 이름", example = "Solve") - private String featureName; - - @Schema(description = "설명", example = "이차방정식 문제 풀이") - private String description; - - @Schema(description = "직접 지정할 크레딧 양 (null이면 자동 계산)", example = "10") - private Integer amount; -} diff --git a/src/main/java/com/proovy/domain/credit/dto/response/CreditCostResponse.java b/src/main/java/com/proovy/domain/credit/dto/response/CreditCostResponse.java deleted file mode 100644 index d0adfbf9..00000000 --- a/src/main/java/com/proovy/domain/credit/dto/response/CreditCostResponse.java +++ /dev/null @@ -1,66 +0,0 @@ -package com.proovy.domain.credit.dto.response; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; - -import java.util.List; -import java.util.Map; - -@Getter -@Builder -@AllArgsConstructor -@Schema(description = "크레딧 비용 조회 응답") -public class CreditCostResponse { - - @Schema(description = "비용 정책 목록") - private List costs; - - @Schema(description = "기능별 비용 목록") - private List featureCosts; - - @Schema(description = "난이도별 배율") - private Map difficultyMultipliers; - - @Getter - @Builder - @AllArgsConstructor - @Schema(description = "크레딧 비용 항목") - public static class CreditCostItemDto { - - @Schema(description = "이벤트 타입", example = "OCR") - private String eventType; - - @Schema(description = "기능 설명", example = "OCR 텍스트 추출") - private String description; - - @Schema(description = "비용 (null인 경우 사용량 기반)", example = "10", nullable = true) - private Integer costAmount; - - @Schema(description = "고정 비용 여부", example = "true") - private Boolean isFixed; - } - - @Getter - @Builder - @AllArgsConstructor - @Schema(description = "기능별 크레딧 비용") - public static class FeatureCostDto { - - @Schema(description = "기능 이름", example = "Solve") - private String featureName; - - @Schema(description = "기본 비용", example = "10") - private Integer baseCost; - - @Schema(description = "쉬운 난이도 비용", example = "10") - private Integer easyCost; - - @Schema(description = "중간 난이도 비용", example = "15") - private Integer mediumCost; - - @Schema(description = "어려운 난이도 비용", example = "20") - private Integer hardCost; - } -} diff --git a/src/main/java/com/proovy/domain/credit/dto/response/CreditUseResponse.java b/src/main/java/com/proovy/domain/credit/dto/response/CreditUseResponse.java deleted file mode 100644 index 86c79c62..00000000 --- a/src/main/java/com/proovy/domain/credit/dto/response/CreditUseResponse.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.proovy.domain.credit.dto.response; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; - -@Getter -@Builder -@AllArgsConstructor -@Schema(description = "크레딧 사용 응답") -public class CreditUseResponse { - - @Schema(description = "차감 성공 여부") - private Boolean success; - - @Schema(description = "실제 차감된 크레딧 양") - private Integer usedAmount; - - @Schema(description = "크레딧 잔액 정보") - private BalanceDto balance; - - @Schema(description = "크레딧 부족 여부 (true면 잔액 부족)") - private Boolean insufficientCredit; - - @Schema(description = "메시지") - private String message; - - @Getter - @Builder - @AllArgsConstructor - @Schema(description = "잔액 정보") - public static class BalanceDto { - - @Schema(description = "일일 무료 크레딧 잔액") - private Integer dailyFreeCredit; - - @Schema(description = "무료 크레딧 잔액") - private Integer freeCredit; - - @Schema(description = "유료 크레딧 잔액") - private Integer paidCredit; - - @Schema(description = "총 사용 가능 크레딧") - private Integer totalAvailable; - } -} diff --git a/src/main/java/com/proovy/domain/credit/entity/CreditEventType.java b/src/main/java/com/proovy/domain/credit/entity/CreditEventType.java index 2ad719ce..e842113e 100644 --- a/src/main/java/com/proovy/domain/credit/entity/CreditEventType.java +++ b/src/main/java/com/proovy/domain/credit/entity/CreditEventType.java @@ -10,9 +10,10 @@ public enum CreditEventType { MONTHLY_GRANT("구독 크레딧 지급"), MONTHLY_EXPIRE("유료 크레딧 소멸"), SIGNUP_BONUS("가입 보너스"), + /** 일반 AI 요청 사용량 차감 (request_id당 한 번, RequestUsageService) */ LLM_QUERY("AI 질의"), - OCR("OCR 처리"), - CODE_EXECUTION("코드 실행"); + /** 일반 AI 요청 사용량 보상 (request_id당 최대 한 번, RequestUsageService) */ + LLM_QUERY_COMPENSATION("AI 질의 사용량 보상"); private final String description; } diff --git a/src/main/java/com/proovy/domain/credit/repository/CreditBalanceRepository.java b/src/main/java/com/proovy/domain/credit/repository/CreditBalanceRepository.java index 74558555..9050b4f5 100644 --- a/src/main/java/com/proovy/domain/credit/repository/CreditBalanceRepository.java +++ b/src/main/java/com/proovy/domain/credit/repository/CreditBalanceRepository.java @@ -21,6 +21,27 @@ public interface CreditBalanceRepository extends JpaRepository findByUserIdForUpdate(@Param("userId") Long userId); + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT cb FROM CreditBalance cb WHERE cb.id = :id") + Optional findByIdForUpdate(@Param("id") Long id); + + /** + * 호출자 transaction 안에서 잔액 row를 만든다. 이미 있으면(동시 생성 포함) 아무것도 하지 않는다. + * REQUIRES_NEW로 따로 commit하지 않으므로 호출자 rollback 시 함께 사라진다. + * 초기값은 {@code CreditBalanceCreator#createInitialBalance}와 같다. credit_balance에 NOT NULL 컬럼을 + * 추가하면 이 SQL도 함께 갱신한다. + */ + @Modifying(flushAutomatically = true) + @Query(value = "INSERT INTO credit_balance (user_id, daily_free_credit, daily_free_limit, daily_expires_at, " + + "free_credit, paid_credit, created_at, updated_at) " + + "VALUES (:userId, :dailyLimit, :dailyLimit, :dailyExpiresAt, 0, 0, :now, :now) " + + "ON CONFLICT (user_id) DO NOTHING", + nativeQuery = true) + int insertInitialIfAbsent(@Param("userId") Long userId, + @Param("dailyLimit") int dailyLimit, + @Param("dailyExpiresAt") LocalDateTime dailyExpiresAt, + @Param("now") LocalDateTime now); + @Modifying @Query("DELETE FROM CreditBalance cb WHERE cb.user.id = :userId") void deleteAllByUserId(@Param("userId") Long userId); diff --git a/src/main/java/com/proovy/domain/credit/repository/CreditHistoryRepository.java b/src/main/java/com/proovy/domain/credit/repository/CreditHistoryRepository.java index d522b7dc..51fa80eb 100644 --- a/src/main/java/com/proovy/domain/credit/repository/CreditHistoryRepository.java +++ b/src/main/java/com/proovy/domain/credit/repository/CreditHistoryRepository.java @@ -1,6 +1,7 @@ package com.proovy.domain.credit.repository; import com.proovy.domain.credit.entity.CreditChangeType; +import com.proovy.domain.credit.entity.CreditEventType; import com.proovy.domain.credit.entity.CreditHistory; import com.proovy.domain.credit.entity.CreditType; import org.springframework.data.domain.Page; @@ -16,10 +17,11 @@ public interface CreditHistoryRepository extends JpaRepository, JpaSpecificationExecutor { - // 기간 통계 쿼리 + // 기간 통계 쿼리. 사용량 보상(EARN)은 적립이 아니라 지출 상계로 집계해 순사용량을 보여 준다. @Query("SELECT " + - "COALESCE(SUM(CASE WHEN ch.changeType = :earn THEN ch.amount ELSE 0 END), 0) as totalEarned, " + - "COALESCE(SUM(CASE WHEN ch.changeType = :spend THEN ch.amount ELSE 0 END), 0) as totalSpent, " + + "COALESCE(SUM(CASE WHEN ch.changeType = :earn AND ch.eventType <> :compensation THEN ch.amount ELSE 0 END), 0) as totalEarned, " + + "COALESCE(SUM(CASE WHEN ch.changeType = :spend THEN ch.amount " + + "WHEN ch.changeType = :earn AND ch.eventType = :compensation THEN -ch.amount ELSE 0 END), 0) as totalSpent, " + "COALESCE(SUM(CASE WHEN ch.changeType = :expire THEN ch.amount ELSE 0 END), 0) as totalExpired " + "FROM CreditHistory ch " + "WHERE ch.user.id = :userId " + @@ -30,6 +32,7 @@ CreditPeriodSummary calculatePeriodSummary( @Param("earn") CreditChangeType earn, @Param("spend") CreditChangeType spend, @Param("expire") CreditChangeType expire, + @Param("compensation") CreditEventType compensation, @Param("startDate") LocalDateTime startDate, @Param("endDate") LocalDateTime endDate ); diff --git a/src/main/java/com/proovy/domain/credit/service/CreditBalanceCreator.java b/src/main/java/com/proovy/domain/credit/service/CreditBalanceCreator.java index 2d94c9ee..b3d44af3 100644 --- a/src/main/java/com/proovy/domain/credit/service/CreditBalanceCreator.java +++ b/src/main/java/com/proovy/domain/credit/service/CreditBalanceCreator.java @@ -48,6 +48,22 @@ public CreditBalance createInitialBalance(Long userId, int freeCredit) { return creditBalanceRepository.save(balance); } + /** + * 호출자 transaction 안에서 초기 잔액 row를 만든다(가입 보너스 없음). 이미 있으면 그대로 둔다. + * 요청 사용량 차감처럼 독립 선commit이 허용되지 않는 경로에서 쓴다. + */ + @Transactional(propagation = Propagation.MANDATORY) + public void insertInitialBalanceIfAbsent(Long userId) { + if (!userRepository.existsById(userId)) { + throw new BusinessException(ErrorCode.USER4041); + } + PlanType planType = userPlanRepository.findActivePlanTypeByUserId(userId) + .orElse(PlanType.FREE); + // created_at/updated_at은 JPA auditing과 같은 JVM 기본 시계, 일일 만료는 과금 zone(Asia/Seoul) 기준이다. + creditBalanceRepository.insertInitialIfAbsent( + userId, planType.getDailyCreditLimit(), nextDailyResetAt(nowInBillingZone()), LocalDateTime.now()); + } + private LocalDateTime nowInBillingZone() { return ZonedDateTime.now(BILLING_ZONE).toLocalDateTime(); } diff --git a/src/main/java/com/proovy/domain/credit/service/CreditBalanceService.java b/src/main/java/com/proovy/domain/credit/service/CreditBalanceService.java index d29d3797..bdcf86f0 100644 --- a/src/main/java/com/proovy/domain/credit/service/CreditBalanceService.java +++ b/src/main/java/com/proovy/domain/credit/service/CreditBalanceService.java @@ -7,6 +7,7 @@ import com.proovy.domain.user.entity.User; import com.proovy.global.exception.BusinessException; import com.proovy.global.response.ErrorCode; +import jakarta.persistence.EntityManager; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.dao.DataIntegrityViolationException; @@ -14,6 +15,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionTemplate; @@ -34,9 +36,14 @@ public class CreditBalanceService { private final CreditBalanceCreator creditBalanceCreator; private final CreditHistoryRepository creditHistoryRepository; private final PlatformTransactionManager transactionManager; + private final EntityManager entityManager; - @Transactional - public CreditBalance getOrCreateBalance(Long userId) { + /** + * 조회용 잔액. 잠그지 않으므로 잔액 row에 쓰지 않는다. 만료된 일일 크레딧은 리셋된 값으로 계산만 하고 + * 실제 리셋은 잠금 경로(스케줄러·차감·지급)가 수행한다. + */ + @Transactional(readOnly = true) + public CreditBalanceSnapshot getBalanceSnapshot(Long userId) { CreditBalance balance = creditBalanceRepository.findByUserId(userId) .orElseGet(() -> { // 가입 보너스는 signup 경로에서만 지급됩니다. @@ -45,18 +52,41 @@ public CreditBalance getOrCreateBalance(Long userId) { .orElseThrow(() -> new BusinessException(ErrorCode.CREDIT4041)); }); - refreshDailyCreditIfExpired(balance, nowInBillingZone()); - return balance; + LocalDateTime now = nowInBillingZone(); + boolean dailyExpired = isDailyExpired(balance, now); + return new CreditBalanceSnapshot( + dailyExpired ? balance.getDailyFreeLimit() : balance.getDailyFreeCredit(), + balance.getDailyFreeLimit(), + dailyExpired ? nextDailyResetAt(now) : balance.getDailyExpiresAt(), + balance.getFreeCredit(), + balance.getPaidCredit(), + balance.getPaidExpiresAt()); } @Transactional public CreditBalance getOrCreateBalanceForUpdate(Long userId) { + return lockAndRefresh(userId); + } + + /** + * 호출자 transaction 안에서 잔액 row를 잠그고(없으면 같은 transaction에서 생성) 만료된 일일 크레딧을 갱신한다. + * 독립 commit이 없다. 요청 사용량 차감·보상({@code RequestUsageService})이 쓴다. + */ + @Transactional(propagation = Propagation.MANDATORY) + public CreditBalance lockBalanceInCurrentTransaction(Long userId) { + return lockAndRefresh(userId); + } + + private CreditBalance lockAndRefresh(Long userId) { CreditBalance balance = creditBalanceRepository.findByUserIdForUpdate(userId) .orElseGet(() -> { - createInitialBalanceSafely(userId, 0); + creditBalanceCreator.insertInitialBalanceIfAbsent(userId); return creditBalanceRepository.findByUserIdForUpdate(userId) .orElseThrow(() -> new BusinessException(ErrorCode.CREDIT4041)); }); + // 같은 persistence context에 이미 로드된 인스턴스면 잠금 조회도 캐시된 상태를 돌려준다. + // 잠근 뒤 다시 읽어 그 사이 commit된 차감·보상을 덮어쓰지 않게 한다. + entityManager.refresh(balance); refreshDailyCreditIfExpired(balance, nowInBillingZone()); return balance; @@ -194,7 +224,8 @@ private boolean resetDailyCreditInNewTransaction(Long creditBalanceId, LocalDate try { return Boolean.TRUE.equals(transactionTemplate.execute(status -> { - CreditBalance balance = creditBalanceRepository.findById(creditBalanceId) + // 잠그고 읽어야 리셋 UPDATE가 동시에 commit된 차감·보상을 덮어쓰지 않는다 + CreditBalance balance = creditBalanceRepository.findByIdForUpdate(creditBalanceId) .orElse(null); if (balance == null) { @@ -231,11 +262,17 @@ private LocalDateTime nextDailyResetAt(LocalDateTime now) { return now.toLocalDate().plusDays(1).atStartOfDay(); } - private void refreshDailyCreditIfExpired(CreditBalance balance, LocalDateTime now) { + private static boolean isDailyExpired(CreditBalance balance, LocalDateTime now) { LocalDateTime expiresAt = balance.getDailyExpiresAt(); - if (expiresAt == null || expiresAt.isAfter(now)) { + return expiresAt != null && !expiresAt.isAfter(now); + } + + // 잠금 경로에서만 호출한다 (row를 잠그지 않고 쓰면 lost update가 생긴다) + private void refreshDailyCreditIfExpired(CreditBalance balance, LocalDateTime now) { + if (!isDailyExpired(balance, now)) { return; } + LocalDateTime expiresAt = balance.getDailyExpiresAt(); LocalDateTime nextResetAt = nextDailyResetAt(now); balance.resetDailyCredit(nextResetAt); diff --git a/src/main/java/com/proovy/domain/credit/service/CreditBalanceSnapshot.java b/src/main/java/com/proovy/domain/credit/service/CreditBalanceSnapshot.java new file mode 100644 index 00000000..4984bf8e --- /dev/null +++ b/src/main/java/com/proovy/domain/credit/service/CreditBalanceSnapshot.java @@ -0,0 +1,19 @@ +package com.proovy.domain.credit.service; + +import java.time.LocalDateTime; + +/** + * 조회용 잔액 값. 만료된 일일 크레딧은 리셋된 값으로 계산해 보여 줄 뿐 DB에 쓰지 않는다. + * 잠그지 않은 조회가 잔액 row를 되돌려 써서 잠금 경로(차감·보상·지급)를 덮어쓰지 않게 하기 위해서다. + */ +public record CreditBalanceSnapshot(int dailyFreeCredit, + int dailyFreeLimit, + LocalDateTime dailyExpiresAt, + int freeCredit, + int paidCredit, + LocalDateTime paidExpiresAt) { + + public int totalAvailable() { + return dailyFreeCredit + freeCredit + paidCredit; + } +} diff --git a/src/main/java/com/proovy/domain/credit/service/CreditCostService.java b/src/main/java/com/proovy/domain/credit/service/CreditCostService.java deleted file mode 100644 index c911b51a..00000000 --- a/src/main/java/com/proovy/domain/credit/service/CreditCostService.java +++ /dev/null @@ -1,93 +0,0 @@ -package com.proovy.domain.credit.service; - -import com.proovy.domain.credit.dto.response.CreditCostResponse; -import com.proovy.domain.credit.entity.CreditEventType; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; - -@Slf4j -@Service -@RequiredArgsConstructor -public class CreditCostService { - - // TODO: DB에서 관리하도록 변경 - private static final int OCR_COST = 10; - private static final int CODE_EXECUTION_COST = 5; - - // 기능별 기본 비용 - private static final Map FEATURE_BASE_COST = Map.of( - "Solve", 10, - "Explain", 5, - "CreateGraph", 5, - "Variant", 5, - "Solution", 20, - "Check", 3 - ); - - // 난이도별 배율 - private static final Map DIFFICULTY_MULTIPLIER = Map.of( - "easy", 1.0, - "medium", 1.5, - "hard", 2.0 - ); - - /** - * 크레딧 비용 정책 조회 - * SPEND 타입 이벤트만 반환 - */ - public CreditCostResponse getCreditCosts() { - List costs = new ArrayList<>(); - - // OCR 비용 - costs.add(CreditCostResponse.CreditCostItemDto.builder() - .eventType(CreditEventType.OCR.name()) - .description(CreditEventType.OCR.getDescription()) - .costAmount(OCR_COST) - .isFixed(true) - .build()); - - // 코드 실행 비용 - costs.add(CreditCostResponse.CreditCostItemDto.builder() - .eventType(CreditEventType.CODE_EXECUTION.name()) - .description(CreditEventType.CODE_EXECUTION.getDescription()) - .costAmount(CODE_EXECUTION_COST) - .isFixed(true) - .build()); - - // LLM 질의 비용 (사용량 기반) - costs.add(CreditCostResponse.CreditCostItemDto.builder() - .eventType(CreditEventType.LLM_QUERY.name()) - .description(CreditEventType.LLM_QUERY.getDescription() + " (사용량 기반)") - .costAmount(null) - .isFixed(false) - .build()); - - log.info("크레딧 비용 정책 조회 완료, 총 {}개 항목", costs.size()); - return CreditCostResponse.builder() - .costs(costs) - .featureCosts(buildFeatureCosts()) - .difficultyMultipliers(DIFFICULTY_MULTIPLIER) - .build(); - } - - private List buildFeatureCosts() { - List featureCosts = new ArrayList<>(); - - for (Map.Entry entry : FEATURE_BASE_COST.entrySet()) { - featureCosts.add(CreditCostResponse.FeatureCostDto.builder() - .featureName(entry.getKey()) - .baseCost(entry.getValue()) - .easyCost(entry.getValue()) - .mediumCost((int) Math.ceil(entry.getValue() * 1.5)) - .hardCost(entry.getValue() * 2) - .build()); - } - - return featureCosts; - } -} diff --git a/src/main/java/com/proovy/domain/credit/service/CreditHistoryService.java b/src/main/java/com/proovy/domain/credit/service/CreditHistoryService.java index 85297bee..17ca2c32 100644 --- a/src/main/java/com/proovy/domain/credit/service/CreditHistoryService.java +++ b/src/main/java/com/proovy/domain/credit/service/CreditHistoryService.java @@ -1,7 +1,7 @@ package com.proovy.domain.credit.service; import com.proovy.domain.credit.dto.response.CreditHistoryResponse; -import com.proovy.domain.credit.entity.CreditBalance; +import com.proovy.domain.credit.entity.CreditEventType; import com.proovy.domain.credit.entity.CreditChangeType; import com.proovy.domain.credit.entity.CreditHistory; import com.proovy.domain.credit.entity.CreditType; @@ -67,7 +67,7 @@ public CreditHistoryResponse getCreditHistory( CreditType creditType = parseCreditType(creditTypeStr); // 4. 크레딧 잔액 조회 - CreditBalance balance = creditBalanceService.getOrCreateBalance(userId); + CreditBalanceSnapshot balance = creditBalanceService.getBalanceSnapshot(userId); // 5. 크레딧 내역 조회 Pageable pageable = PageRequest.of(validatedPage, validatedSize, Sort.by(Sort.Direction.DESC, "createdAt")); @@ -86,6 +86,7 @@ public CreditHistoryResponse getCreditHistory( CreditChangeType.EARN, CreditChangeType.SPEND, CreditChangeType.EXPIRE, + CreditEventType.LLM_QUERY_COMPENSATION, summaryStartDate, summaryEndDate ); @@ -160,7 +161,7 @@ private CreditType parseCreditType(String creditTypeStr) { } private CreditHistoryResponse buildResponse( - CreditBalance balance, + CreditBalanceSnapshot balance, Page historyPage, CreditHistoryRepository.CreditPeriodSummary periodSummary, String startDateStr, @@ -169,18 +170,18 @@ private CreditHistoryResponse buildResponse( // 크레딧 요약 CreditHistoryResponse.CreditSummaryDto creditSummary = CreditHistoryResponse.CreditSummaryDto.builder() .dailyFreeCredit(CreditHistoryResponse.DailyFreeCreditDto.builder() - .balance(balance.getDailyFreeCredit()) - .limit(balance.getDailyFreeLimit()) - .expiresAt(balance.getDailyExpiresAt()) + .balance(balance.dailyFreeCredit()) + .limit(balance.dailyFreeLimit()) + .expiresAt(balance.dailyExpiresAt()) .build()) .freeCredit(CreditHistoryResponse.FreeCreditDto.builder() - .balance(balance.getFreeCredit()) + .balance(balance.freeCredit()) .build()) .paidCredit(CreditHistoryResponse.PaidCreditDto.builder() - .balance(balance.getPaidCredit()) - .expiresAt(balance.getPaidExpiresAt()) + .balance(balance.paidCredit()) + .expiresAt(balance.paidExpiresAt()) .build()) - .totalAvailable(balance.getTotalAvailable()) + .totalAvailable(balance.totalAvailable()) .build(); // 내역 변환 diff --git a/src/main/java/com/proovy/domain/credit/service/CreditUseService.java b/src/main/java/com/proovy/domain/credit/service/CreditUseService.java index fa1b414e..ed612c41 100644 --- a/src/main/java/com/proovy/domain/credit/service/CreditUseService.java +++ b/src/main/java/com/proovy/domain/credit/service/CreditUseService.java @@ -1,226 +1,42 @@ package com.proovy.domain.credit.service; -import com.proovy.domain.credit.dto.request.CreditUseRequest; import com.proovy.domain.credit.dto.response.CreditBalanceResponse; -import com.proovy.domain.credit.dto.response.CreditUseResponse; -import com.proovy.domain.credit.entity.CreditBalance; -import com.proovy.domain.credit.entity.CreditChangeType; -import com.proovy.domain.credit.entity.CreditEventType; -import com.proovy.domain.credit.entity.CreditHistory; -import com.proovy.domain.credit.entity.CreditType; -import com.proovy.domain.credit.repository.CreditHistoryRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.Map; - +/** + * 브라우저용 잔액 조회. 구형 기능별 비용표·브라우저 임의 차감({@code POST /api/credits/use})은 제거했다. + * 일반 AI 요청의 차감·보상은 {@code com.proovy.domain.usage.service.RequestUsageService}만 수행한다. + */ @Slf4j @Service @RequiredArgsConstructor public class CreditUseService { private final CreditBalanceService creditBalanceService; - private final CreditHistoryRepository creditHistoryRepository; - - // 기능별 기본 비용 - private static final Map FEATURE_BASE_COST = Map.of( - "Solve", 10, - "Explain", 5, - "CreateGraph", 5, - "Variant", 5, - "Solution", 20, - "Check", 3 - ); - - // 난이도별 배율 - private static final Map DIFFICULTY_MULTIPLIER = Map.of( - "easy", 1.0, - "medium", 1.5, - "hard", 2.0 - ); - - // 이벤트 타입별 고정 비용 - private static final Map EVENT_TYPE_COST = Map.of( - CreditEventType.OCR, 10, - CreditEventType.CODE_EXECUTION, 5 - ); /** * 크레딧 잔액 조회 */ - @Transactional + @Transactional(readOnly = true) public CreditBalanceResponse getBalance(Long userId, Integer checkCost) { - CreditBalance balance = creditBalanceService.getOrCreateBalance(userId); + CreditBalanceSnapshot balance = creditBalanceService.getBalanceSnapshot(userId); - Integer totalAvailable = balance.getTotalAvailable(); + Integer totalAvailable = balance.totalAvailable(); Boolean canUse = checkCost == null || totalAvailable >= checkCost; return CreditBalanceResponse.builder() - .dailyFreeCredit(balance.getDailyFreeCredit()) - .dailyFreeLimit(balance.getDailyFreeLimit()) - .dailyExpiresAt(balance.getDailyExpiresAt()) - .freeCredit(balance.getFreeCredit()) - .paidCredit(balance.getPaidCredit()) - .paidExpiresAt(balance.getPaidExpiresAt()) + .dailyFreeCredit(balance.dailyFreeCredit()) + .dailyFreeLimit(balance.dailyFreeLimit()) + .dailyExpiresAt(balance.dailyExpiresAt()) + .freeCredit(balance.freeCredit()) + .paidCredit(balance.paidCredit()) + .paidExpiresAt(balance.paidExpiresAt()) .totalAvailable(totalAvailable) .canUse(canUse) .checkedCost(checkCost) .build(); } - - /** - * 크레딧 사용 (차감) - */ - @Transactional - public CreditUseResponse useCredit(Long userId, CreditUseRequest request) { - CreditBalance balance = creditBalanceService.getOrCreateBalanceForUpdate(userId); - - // 비용 계산 - int cost = calculateCost(request); - int totalBefore = balance.getTotalAvailable(); - - // 잔액 부족 체크 - if (totalBefore < cost) { - log.warn("크레딧 부족: userId={}, 필요={}, 잔액={}", userId, cost, totalBefore); - return CreditUseResponse.builder() - .success(false) - .usedAmount(0) - .balance(buildBalanceDto(balance)) - .insufficientCredit(true) - .message("크레딧이 부족합니다. 필요: " + cost + ", 잔액: " + totalBefore) - .build(); - } - - // 차감 전 잔액 기록 (히스토리용) - int dailyBefore = balance.getDailyFreeCredit(); - int freeBefore = balance.getFreeCredit(); - int paidBefore = balance.getPaidCredit(); - - // 크레딧 차감 - balance.deductCredit(cost); - - // 히스토리 기록 - saveCreditHistory(balance, request, dailyBefore, freeBefore, paidBefore); - - log.info("크레딧 사용 완료: userId={}, 사용={}, 잔액={}", - userId, cost, balance.getTotalAvailable()); - - return CreditUseResponse.builder() - .success(true) - .usedAmount(cost) - .balance(buildBalanceDto(balance)) - .insufficientCredit(false) - .message("크레딧이 차감되었습니다.") - .build(); - } - - /** - * 비용 계산 - */ - public int calculateCost(CreditUseRequest request) { - // 직접 지정된 금액이 있으면 사용 - if (request.getAmount() != null && request.getAmount() > 0) { - return request.getAmount(); - } - - // 이벤트 타입 기반 고정 비용 - String requestEventType = request.getEventType(); - if (requestEventType != null) { - try { - CreditEventType eventType = CreditEventType.valueOf(requestEventType); - if (EVENT_TYPE_COST.containsKey(eventType)) { - return EVENT_TYPE_COST.get(eventType); - } - } catch (IllegalArgumentException ignored) { - // 알 수 없는 이벤트 타입은 기능별 비용으로 계산 - } - } - - // 기능 이름 기반 비용 계산 (난이도 적용) - String featureName = request.getFeatureName(); - if (featureName != null && FEATURE_BASE_COST.containsKey(featureName)) { - int baseCost = FEATURE_BASE_COST.get(featureName); - String difficulty = request.getDifficulty(); - double multiplier = DIFFICULTY_MULTIPLIER.getOrDefault( - difficulty != null ? difficulty.toLowerCase() : "easy", - 1.0 - ); - return (int) Math.ceil(baseCost * multiplier); - } - - // 기본 비용 (LLM_QUERY 등) - return 5; - } - - /** - * 특정 기능의 예상 비용 조회 - */ - public int getEstimatedCost(String featureName, String difficulty) { - int baseCost = FEATURE_BASE_COST.getOrDefault(featureName, 5); - double multiplier = DIFFICULTY_MULTIPLIER.getOrDefault( - difficulty != null ? difficulty.toLowerCase() : "easy", - 1.0 - ); - return (int) Math.ceil(baseCost * multiplier); - } - - private CreditUseResponse.BalanceDto buildBalanceDto(CreditBalance balance) { - return CreditUseResponse.BalanceDto.builder() - .dailyFreeCredit(balance.getDailyFreeCredit()) - .freeCredit(balance.getFreeCredit()) - .paidCredit(balance.getPaidCredit()) - .totalAvailable(balance.getTotalAvailable()) - .build(); - } - - private void saveCreditHistory(CreditBalance balance, CreditUseRequest request, - int dailyBefore, int freeBefore, int paidBefore) { - // 어떤 크레딧에서 차감되었는지 판단 - int dailyUsed = dailyBefore - balance.getDailyFreeCredit(); - int freeUsed = freeBefore - balance.getFreeCredit(); - int paidUsed = paidBefore - balance.getPaidCredit(); - - CreditEventType eventType = CreditEventType.LLM_QUERY; - String requestEventType = request.getEventType(); - if (requestEventType != null) { - try { - eventType = CreditEventType.valueOf(requestEventType); - } catch (IllegalArgumentException ignored) { - // 기본값 유지 - } - } - - String eventName = request.getFeatureName() != null - ? request.getFeatureName() + " 실행" - : eventType.getDescription(); - - // 유형별로 분리 기록해 필터링 왜곡을 방지합니다. - saveHistoryItem(balance, eventType, eventName, request.getDescription(), dailyUsed, CreditType.DAILY); - saveHistoryItem(balance, eventType, eventName, request.getDescription(), freeUsed, CreditType.FREE); - saveHistoryItem(balance, eventType, eventName, request.getDescription(), paidUsed, CreditType.PAID); - } - - private void saveHistoryItem(CreditBalance balance, CreditEventType eventType, - String eventName, String description, int amount, CreditType creditType) { - if (amount <= 0) { - return; - } - - CreditHistory history = CreditHistory.builder() - .user(balance.getUser()) - .eventType(eventType) - .eventName(eventName) - .description(description) - .amount(amount) - .changeType(CreditChangeType.SPEND) - .creditType(creditType) - .balanceAfterDaily(balance.getDailyFreeCredit()) - .balanceAfterFree(balance.getFreeCredit()) - .balanceAfterPaid(balance.getPaidCredit()) - .build(); - - creditHistoryRepository.save(history); - } } diff --git a/src/main/java/com/proovy/domain/embedding/NoteEmbedding.java b/src/main/java/com/proovy/domain/embedding/NoteEmbedding.java deleted file mode 100644 index 7260abaa..00000000 --- a/src/main/java/com/proovy/domain/embedding/NoteEmbedding.java +++ /dev/null @@ -1,81 +0,0 @@ -package com.proovy.domain.embedding; - -import jakarta.persistence.Access; -import jakarta.persistence.AccessType; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.PrePersist; -import jakarta.persistence.PreUpdate; -import jakarta.persistence.Table; -import lombok.AccessLevel; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; -import org.hibernate.annotations.ColumnTransformer; - -import java.time.LocalDateTime; - -@Entity -@Table(name = "note_embeddings") -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class NoteEmbedding { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(name = "note_id", nullable = false) - private Long noteId; - - @Column(name = "user_id", nullable = false) - private Long userId; - - @Column(name = "content_hash", nullable = false, length = 64) - private String contentHash; - - @Column(name = "embedding_model", nullable = false, length = 50) - private String embeddingModel; - - @Access(AccessType.FIELD) - @Column(name = "vector", nullable = false, columnDefinition = "vector(1536)") - @ColumnTransformer(read = "vector::text", write = "?::vector") - private String vector; - - @Column(name = "created_at", nullable = false, updatable = false) - private LocalDateTime createdAt; - - @Column(name = "updated_at", nullable = false) - private LocalDateTime updatedAt; - - @PrePersist - protected void onCreate() { - LocalDateTime now = LocalDateTime.now(); - createdAt = now; - updatedAt = now; - } - - @PreUpdate - protected void onUpdate() { - updatedAt = LocalDateTime.now(); - } - - @Builder - public NoteEmbedding(Long noteId, Long userId, String contentHash, String embeddingModel, String vector) { - this.noteId = noteId; - this.userId = userId; - this.contentHash = contentHash; - this.embeddingModel = (embeddingModel == null || embeddingModel.isBlank()) - ? "text-embedding-3-small" : embeddingModel; - this.vector = vector; - } - - public void updateVector(String vector, String contentHash) { - this.vector = vector; - this.contentHash = contentHash; - this.updatedAt = LocalDateTime.now(); - } -} diff --git a/src/main/java/com/proovy/domain/embedding/NoteEmbeddingRepository.java b/src/main/java/com/proovy/domain/embedding/NoteEmbeddingRepository.java deleted file mode 100644 index 78d2f05e..00000000 --- a/src/main/java/com/proovy/domain/embedding/NoteEmbeddingRepository.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.proovy.domain.embedding; - -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -import java.util.Optional; - -@Repository -public interface NoteEmbeddingRepository extends JpaRepository { - - Optional findByNoteIdAndEmbeddingModel(Long noteId, String embeddingModel); - - void deleteByNoteId(Long noteId); -} diff --git a/src/main/java/com/proovy/domain/embedding/service/EmbeddingJobPublisher.java b/src/main/java/com/proovy/domain/embedding/service/EmbeddingJobPublisher.java deleted file mode 100644 index 90330d6e..00000000 --- a/src/main/java/com/proovy/domain/embedding/service/EmbeddingJobPublisher.java +++ /dev/null @@ -1,94 +0,0 @@ -package com.proovy.domain.embedding.service; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.data.redis.connection.stream.RecordId; -import org.springframework.data.redis.connection.stream.StreamRecords; -import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.stereotype.Service; -import org.springframework.transaction.support.TransactionSynchronization; -import org.springframework.transaction.support.TransactionSynchronizationManager; - -import java.time.Duration; -import java.util.Map; - -@Service -@RequiredArgsConstructor -@Slf4j -public class EmbeddingJobPublisher { - - private static final String DEFAULT_MODEL = "text-embedding-3-small"; - - private final StringRedisTemplate redisTemplate; - - @Value("${embedding.redis.stream.key}") - private String streamKey; - - @Value("${embedding.redis.lock.prefix}") - private String lockPrefix; - - @Value("${embedding.redis.lock.ttl}") - private long lockTtl; - - public void publishEmbeddingJob(Long noteId, String contentHash, String model) { - if (noteId == null) { - log.warn("임베딩 작업 발행 생략: noteId가 null입니다."); - return; - } - if (contentHash == null || contentHash.isBlank()) { - log.warn("임베딩 작업 발행 생략: contentHash가 비어 있습니다. noteId={}", noteId); - return; - } - - String resolvedModel = (model == null || model.isBlank()) ? DEFAULT_MODEL : model; - if (TransactionSynchronizationManager.isSynchronizationActive() - && TransactionSynchronizationManager.isActualTransactionActive()) { - TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { - @Override - public void afterCommit() { - publishNow(noteId, contentHash, resolvedModel); - } - }); - return; - } - - publishNow(noteId, contentHash, resolvedModel); - } - - private void publishNow(Long noteId, String contentHash, String model) { - String lockKey = String.format("%s:%d:%s", lockPrefix, noteId, contentHash); - boolean lockAcquired = false; - try { - lockAcquired = Boolean.TRUE.equals( - redisTemplate.opsForValue().setIfAbsent(lockKey, "1", Duration.ofSeconds(lockTtl)) - ); - if (!lockAcquired) { - log.info("임베딩 작업 중복 감지. 발행 생략: noteId={}, hash={}", noteId, contentHash); - return; - } - - Map message = Map.of( - "noteId", String.valueOf(noteId), - "contentHash", contentHash, - "model", model, - "timestamp", String.valueOf(System.currentTimeMillis()) - ); - - RecordId recordId = redisTemplate.opsForStream() - .add(StreamRecords.newRecord() - .ofStrings(message) - .withStreamKey(streamKey)); - if (recordId == null) { - throw new IllegalStateException("Redis stream recordId is null"); - } - - log.info("임베딩 작업 발행 완료: noteId={}, recordId={}", noteId, recordId); - } catch (Exception e) { - if (lockAcquired) { - redisTemplate.delete(lockKey); - } - log.error("임베딩 작업 발행 실패: noteId={}", noteId, e); - } - } -} diff --git a/src/main/java/com/proovy/domain/embedding/service/NoteEmbeddingService.java b/src/main/java/com/proovy/domain/embedding/service/NoteEmbeddingService.java deleted file mode 100644 index 6adfa481..00000000 --- a/src/main/java/com/proovy/domain/embedding/service/NoteEmbeddingService.java +++ /dev/null @@ -1,71 +0,0 @@ -package com.proovy.domain.embedding.service; - -import com.proovy.domain.embedding.NoteEmbedding; -import com.proovy.domain.embedding.NoteEmbeddingRepository; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; -import java.util.Objects; -import java.util.Optional; -import java.util.stream.Collectors; - -@Service -@RequiredArgsConstructor -@Transactional -@Slf4j -public class NoteEmbeddingService { - - private static final String DEFAULT_MODEL = "text-embedding-3-small"; - private static final int EMBEDDING_DIMENSION = 1536; - - private final NoteEmbeddingRepository embeddingRepository; - - public void upsertEmbedding(Long noteId, Long userId, String contentHash, String model, List vectorList) { - try { - validateVector(vectorList); - String resolvedModel = (model == null || model.isBlank()) ? DEFAULT_MODEL : model; - String vectorString = vectorList.stream() - .map(String::valueOf) - .collect(Collectors.joining(",", "[", "]")); - - Optional existing = embeddingRepository.findByNoteIdAndEmbeddingModel(noteId, resolvedModel); - - if (existing.isPresent()) { - NoteEmbedding embedding = existing.get(); - embedding.updateVector(vectorString, contentHash); - log.info("임베딩 업데이트 완료: noteId={}, model={}", noteId, resolvedModel); - return; - } - - NoteEmbedding embedding = NoteEmbedding.builder() - .noteId(noteId) - .userId(userId) - .contentHash(contentHash) - .embeddingModel(resolvedModel) - .vector(vectorString) - .build(); - embeddingRepository.save(embedding); - log.info("임베딩 신규 저장 완료: noteId={}, model={}", noteId, resolvedModel); - } catch (IllegalArgumentException e) { - throw e; - } catch (Exception e) { - log.error("임베딩 저장 실패: noteId={}", noteId, e); - throw new RuntimeException("임베딩 저장 실패", e); - } - } - - private void validateVector(List vectorList) { - if (vectorList == null || vectorList.isEmpty()) { - throw new IllegalArgumentException("벡터 데이터가 비어 있습니다."); - } - if (vectorList.size() != EMBEDDING_DIMENSION) { - throw new IllegalArgumentException("벡터 차원은 1536이어야 합니다."); - } - if (vectorList.stream().anyMatch(Objects::isNull)) { - throw new IllegalArgumentException("벡터에 null 값이 포함되어 있습니다."); - } - } -} diff --git a/src/main/java/com/proovy/domain/note/controller/InternalNoteController.java b/src/main/java/com/proovy/domain/note/controller/InternalNoteController.java deleted file mode 100644 index c49cfb45..00000000 --- a/src/main/java/com/proovy/domain/note/controller/InternalNoteController.java +++ /dev/null @@ -1,106 +0,0 @@ -package com.proovy.domain.note.controller; - -import com.proovy.domain.embedding.service.NoteEmbeddingService; -import com.proovy.domain.note.repository.NoteRepository; -import com.proovy.global.util.HashUtils; -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotEmpty; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; -import lombok.Builder; -import lombok.Data; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.server.ResponseStatusException; - -import java.util.List; - -@RestController -@RequestMapping("/internal/notes") -@RequiredArgsConstructor -@Slf4j -public class InternalNoteController { - - private static final String DEFAULT_MODEL = "text-embedding-3-small"; - private static final int EMBEDDING_DIMENSION = 1536; - - private final NoteRepository noteRepository; - private final NoteEmbeddingService embeddingService; - - @GetMapping("/{noteId}/embedding-source") - public ResponseEntity getEmbeddingSource( - @PathVariable Long noteId - ) { - NoteRepository.EmbeddingSourceProjection source = noteRepository.findEmbeddingSourceById(noteId) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Note not found")); - - String content = source.getContentMd() == null ? "" : source.getContentMd(); - String contentHash = HashUtils.sha256(content); - - return ResponseEntity.ok(EmbeddingSourceResponse.builder() - .noteId(source.getNoteId()) - .userId(source.getUserId()) - .content(content) - .contentHash(contentHash) - .model(DEFAULT_MODEL) - .build()); - } - - @PostMapping("/{noteId}/embedding") - public ResponseEntity upsertEmbedding( - @PathVariable Long noteId, - @RequestBody @Valid UpsertEmbeddingRequest request - ) { - NoteRepository.EmbeddingSourceProjection source = noteRepository.findEmbeddingSourceById(noteId) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Note not found")); - - String currentContent = source.getContentMd() == null ? "" : source.getContentMd(); - String currentHash = HashUtils.sha256(currentContent); - if (!currentHash.equals(request.getContentHash())) { - log.warn("노트 내용 변경 감지. 임베딩 저장 취소: noteId={}", noteId); - return ResponseEntity.status(HttpStatus.CONFLICT).build(); - } - - embeddingService.upsertEmbedding( - noteId, - source.getUserId(), - request.getContentHash(), - request.getModel(), - request.getVector() - ); - - return ResponseEntity.ok().build(); - } - - @Data - @Builder - public static class EmbeddingSourceResponse { - private Long noteId; - private Long userId; - private String content; - private String contentHash; - private String model; - } - - @Data - public static class UpsertEmbeddingRequest { - @NotBlank - private String model; - - @NotBlank - private String contentHash; - - @NotEmpty - @Size(min = EMBEDDING_DIMENSION, max = EMBEDDING_DIMENSION, message = "vector must have 1536 dimensions") - private List<@NotNull Double> vector; - } -} diff --git a/src/main/java/com/proovy/domain/note/dto/response/NoteDetailResponse.java b/src/main/java/com/proovy/domain/note/dto/response/NoteDetailResponse.java index d351d428..b8daaf8d 100644 --- a/src/main/java/com/proovy/domain/note/dto/response/NoteDetailResponse.java +++ b/src/main/java/com/proovy/domain/note/dto/response/NoteDetailResponse.java @@ -80,13 +80,10 @@ public static class AssetInfo { @Getter @Builder public static class ConversationInfo { - @Schema(description = "대화 ID (USER 메시지의 ChatMessage ID)", example = "100") - private Long conversationId; - @Schema(description = "사용자 메시지") private MessageInfo userMessage; - @Schema(description = "AI 응답 메시지") + @Schema(description = "AI 응답 메시지. 아직 placeholder이거나 없으면 null", nullable = true) private MessageInfo assistantMessage; @Schema(description = "대화 생성 시각") @@ -97,54 +94,19 @@ public static class ConversationInfo { @Builder @JsonInclude(JsonInclude.Include.NON_NULL) public static class MessageInfo { - @Schema(description = "메시지 ID", example = "200") - private Long messageId; - - @Schema(description = "메시지 내용", example = "1번 문제 풀어줘") - private String content; + @Schema(description = "메시지 ID (UUID)") + private String messageId; - @Schema(description = "멘션된 파일 목록") - private List mentionedAssets; + @Schema(description = "메시지 상태 (PENDING, STREAMING, COMPLETED, FAILED, CANCELED)", example = "COMPLETED") + private String status; - @Schema(description = "멘션된 도구 목록") - private List mentionedTools; - - @Schema(description = "사용된 도구 목록") - private List usedTools; - - @Schema(description = "생성된 파일 목록") - private List generatedFiles; + @Schema(description = "공개 본문 plain text (미완료 응답은 null)", example = "1번 문제 풀어줘", nullable = true) + private String content; @Schema(description = "메시지 생성 시각") private LocalDateTime createdAt; } - @Getter - @Builder - public static class MentionedAsset { - @Schema(description = "자산 ID", example = "3") - private Long assetId; - - @Schema(description = "파일명", example = "discrete_math_HW2.pdf") - private String fileName; - } - - @Getter - @Builder - public static class GeneratedFile { - @Schema(description = "파일 ID", example = "50") - private Long fileId; - - @Schema(description = "파일명", example = "solution_1.pdf") - private String fileName; - - @Schema(description = "파일 타입", example = "SOLUTION") - private String fileType; - - @Schema(description = "다운로드 URL", example = "https://s3.amazonaws.com/proovy/generated/solution_1.pdf") - private String downloadUrl; - } - @Getter @Builder public static class PageInfo { diff --git a/src/main/java/com/proovy/domain/note/entity/Note.java b/src/main/java/com/proovy/domain/note/entity/Note.java index 2907f0e2..94b338c4 100644 --- a/src/main/java/com/proovy/domain/note/entity/Note.java +++ b/src/main/java/com/proovy/domain/note/entity/Note.java @@ -7,6 +7,7 @@ import org.springframework.data.annotation.LastModifiedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; +import java.time.Instant; import java.time.LocalDateTime; @Entity @@ -28,8 +29,12 @@ public class Note { @Column(nullable = false, length = 200) private String title; - @Column(name = "content_md", columnDefinition = "TEXT") - private String contentMd; + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private NoteStatus status = NoteStatus.ACTIVE; // DELETING Note에는 새 run을 만들지 않는다 + + @Column(name = "deletion_requested_at") + private Instant deletionRequestedAt; @CreatedDate @Column(name = "created_at", updatable = false) @@ -39,25 +44,17 @@ public class Note { @Column(name = "updated_at") private LocalDateTime updatedAt; - @Column(name = "thread_id", unique = true, length = 100) - private String threadId; // Proovy-ai와 연동할 대화 맥락 식별자 - @Builder - public Note(User user, String title, String contentMd) { + public Note(User user, String title) { this.user = user; this.title = title; - this.contentMd = contentMd; } public void updateTitle(String title) { this.title = title; } - public void updateContent(String contentMd) { - this.contentMd = contentMd; - } - - public void updateThreadId(String threadId) { - this.threadId = threadId; + public boolean isActive() { + return status == NoteStatus.ACTIVE; } } diff --git a/src/main/java/com/proovy/domain/note/entity/NoteStatus.java b/src/main/java/com/proovy/domain/note/entity/NoteStatus.java new file mode 100644 index 00000000..f87a90cf --- /dev/null +++ b/src/main/java/com/proovy/domain/note/entity/NoteStatus.java @@ -0,0 +1,6 @@ +package com.proovy.domain.note.entity; + +public enum NoteStatus { + ACTIVE, + DELETING +} diff --git a/src/main/java/com/proovy/domain/note/repository/NoteRepository.java b/src/main/java/com/proovy/domain/note/repository/NoteRepository.java index dd68f728..4cc1e921 100644 --- a/src/main/java/com/proovy/domain/note/repository/NoteRepository.java +++ b/src/main/java/com/proovy/domain/note/repository/NoteRepository.java @@ -12,12 +12,6 @@ public interface NoteRepository extends JpaRepository { - interface EmbeddingSourceProjection { - Long getNoteId(); - Long getUserId(); - String getContentMd(); - } - List findByUserIdOrderByCreatedAtDesc(Long userId); long countByUserId(Long userId); @@ -46,13 +40,6 @@ interface EmbeddingSourceProjection { */ Page findByUserId(Long userId, Pageable pageable); - @Query(""" - SELECT n.id AS noteId, n.user.id AS userId, n.contentMd AS contentMd - FROM Note n - WHERE n.id = :noteId - """) - java.util.Optional findEmbeddingSourceById(@Param("noteId") Long noteId); - /** * 특정 사용자의 모든 노트 삭제 (회원 탈퇴용) */ diff --git a/src/main/java/com/proovy/domain/note/service/NoteServiceImpl.java b/src/main/java/com/proovy/domain/note/service/NoteServiceImpl.java index 186cf472..20c794e8 100644 --- a/src/main/java/com/proovy/domain/note/service/NoteServiceImpl.java +++ b/src/main/java/com/proovy/domain/note/service/NoteServiceImpl.java @@ -3,8 +3,9 @@ import com.proovy.domain.asset.entity.Asset; import com.proovy.domain.asset.entity.FileCategory; import com.proovy.domain.asset.repository.AssetRepository; -import com.proovy.domain.conversation.entity.*; -import com.proovy.domain.conversation.repository.*; +import com.proovy.domain.conversation.entity.ChatMessage; +import com.proovy.domain.conversation.entity.MessageRole; +import com.proovy.domain.conversation.repository.ChatMessageRepository; import com.proovy.domain.note.dto.request.CreateNoteRequest; import com.proovy.domain.note.dto.request.GenerateTitleRequest; import com.proovy.domain.note.dto.request.UpdateNoteTitleRequest; @@ -17,12 +18,10 @@ import com.proovy.global.infra.gemini.GeminiClient; import com.proovy.domain.note.entity.Note; import com.proovy.domain.note.repository.NoteRepository; -import com.proovy.domain.embedding.service.EmbeddingJobPublisher; import com.proovy.domain.user.entity.User; import com.proovy.domain.user.repository.UserRepository; import com.proovy.global.exception.BusinessException; import com.proovy.global.response.ErrorCode; -import com.proovy.global.util.HashUtils; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; @@ -33,6 +32,7 @@ import org.springframework.transaction.annotation.Transactional; import com.proovy.global.infra.gcs.GcsService; +import java.time.Instant; import java.time.LocalDateTime; import java.time.ZoneId; import java.time.format.DateTimeFormatter; @@ -48,14 +48,13 @@ public class NoteServiceImpl implements NoteService { private final NoteRepository noteRepository; private final UserRepository userRepository; private final ChatMessageRepository chatMessageRepository; - private final MessageAssetRepository messageAssetRepository; - private final MessageToolRepository messageToolRepository; private final AssetRepository assetRepository; private final com.proovy.domain.user.repository.UserPlanRepository userPlanRepository; private final GcsService s3Service; - private final EmbeddingJobPublisher embeddingJobPublisher; private final GeminiClient geminiClient; + private static final ZoneId SERVICE_ZONE = ZoneId.of("Asia/Seoul"); + @Override public CreateNoteResponse createNote(Long userId, CreateNoteRequest request) { log.info("노트 생성 요청 - userId: {}, title: {}", userId, request.title()); @@ -81,14 +80,10 @@ public CreateNoteResponse createNote(Long userId, CreateNoteRequest request) { Note note = Note.builder() .user(user) .title(resolvedTitle) - .contentMd("") .build(); note = noteRepository.save(note); log.info("노트 생성 완료 - noteId: {}", note.getId()); - String contentHash = HashUtils.sha256(note.getContentMd()); - embeddingJobPublisher.publishEmbeddingJob(note.getId(), contentHash, "text-embedding-3-small"); - // 4. 응답 생성 (대화/메시지는 생성하지 않음) int conversationLimit = 50; // TODO: PlanType에 대화 제한 수가 추가되면 해당 값 사용 String titleGeneratedBy = (request.title() == null || request.title().isBlank()) ? "SYSTEM" : "USER"; @@ -358,6 +353,7 @@ public DeleteNoteResponse deleteNote(Long userId, Long noteId) { long conversationCount = messageCount / 2; // 3. GCS 삭제를 위한 Asset 정보만 조회 (영속성 컨텍스트 오염 방지를 위해 별도 처리) + // TODO(S09/S13): turn_attachments object_key·thumbnail_object_key도 삭제 대상에 포함한다 (업로드 경로 도입 시). List assets = assetRepository.findAllByNoteId(noteId); int assetCount = assets.size(); @@ -374,48 +370,15 @@ public DeleteNoteResponse deleteNote(Long userId, Long noteId) { } } - // 4. Asset ID 목록 추출 (JPQL 벌크 삭제용) - List assetIds = assets.stream() - .map(Asset::getId) - .collect(Collectors.toList()); - - // 5. GCS 파일 삭제 + // 4. GCS 파일 삭제 if (!objectKeysToDelete.isEmpty()) { s3Service.deleteFiles(new ArrayList<>(objectKeysToDelete)); log.info("GCS 파일 삭제 완료 - {} 개 파일", objectKeysToDelete.size()); } - // 6. ChatMessage ID 목록 조회 - List chatMessageIds = chatMessageRepository.findIdsByNoteId(noteId); - - // ========== JPQL 벌크 삭제 시작 (영속성 컨텍스트를 거치지 않음) ========== - - // 7. MessageAsset 삭제 (Asset 기준 + ChatMessage 기준 모두) - if (!assetIds.isEmpty()) { - messageAssetRepository.deleteByAssetIdInBulk(assetIds); - log.info("Asset 관련 MessageAsset 삭제 완료"); - } - if (!chatMessageIds.isEmpty()) { - messageAssetRepository.deleteByChatMessageIdInBulk(chatMessageIds); - log.info("ChatMessage 관련 MessageAsset 삭제 완료"); - } - - // 8. MessageTool 삭제 - if (!chatMessageIds.isEmpty()) { - messageToolRepository.deleteByChatMessageIdInBulk(chatMessageIds); - log.info("MessageTool 삭제 완료"); - } - - // 9. ChatMessage 삭제 - chatMessageRepository.deleteByNoteIdInBulk(noteId); - log.info("ChatMessage 삭제 완료 - {} 개", messageCount); - - // 10. Asset 삭제 - assetRepository.deleteByNoteIdInBulk(noteId); - log.info("자산 삭제 완료 - {} 개", assetCount); - - // 11. Note 삭제 (이제 안전하게 삭제 가능) - noteRepository.deleteById(noteId); + // 5. Note 삭제: 메시지·run·event·asset·attachment·solution은 DB FK cascade로 함께 정리된다. + // TODO(S13): Active Run 취소·DELETING 상태·Brain cleanup ack 경로로 교체 + noteRepository.delete(note); log.info("노트 삭제 완료 - noteId: {}, conversations: {}, assets: {}, freedStorage: {} bytes", noteId, conversationCount, assetCount, freedStorageBytes); @@ -452,8 +415,8 @@ public NoteDetailResponse getNoteDetail(Long userId, Long noteId, int conversati long totalMessages = chatMessageRepository.countByNoteId(noteId); long totalConversations = totalMessages / 2; - // 5. 노트의 모든 ChatMessage 조회 (시간순) - List allMessages = chatMessageRepository.findByNoteIdOrderByCreatedAtAsc(noteId); + // 5. 노트의 모든 메시지 조회 (시간순) + List allMessages = chatMessageRepository.findByNoteIdOrdered(noteId); // 6. USER + ASSISTANT 쌍으로 대화 그룹화 List conversationPairs = groupMessagesIntoConversations(allMessages); @@ -465,38 +428,6 @@ public NoteDetailResponse getNoteDetail(Long userId, Long noteId, int conversati ? conversationPairs.subList(startIndex, endIndex) : List.of(); - // 8. 메시지 ID 목록 추출 - List chatMessageIds = pagedPairs.stream() - .flatMap(pair -> { - List ids = new ArrayList<>(); - if (pair.userMessage != null) ids.add(pair.userMessage.getId()); - if (pair.assistantMessage != null) ids.add(pair.assistantMessage.getId()); - return ids.stream(); - }) - .toList(); - - // 9. 메시지-자산 연결 조회 - Map> messageAssetMap = new HashMap<>(); - if (!chatMessageIds.isEmpty()) { - List messageAssets = messageAssetRepository.findByChatMessageIdIn(chatMessageIds); - messageAssetMap = messageAssets.stream() - .collect(Collectors.groupingBy( - ma -> ma.getChatMessage().getId(), - Collectors.mapping(MessageAsset::getAsset, Collectors.toList()) - )); - } - - // 10. 메시지-도구 연결 조회 - Map> messageToolMap = new HashMap<>(); - if (!chatMessageIds.isEmpty()) { - List messageTools = messageToolRepository.findByChatMessageIdIn(chatMessageIds); - messageToolMap = messageTools.stream() - .collect(Collectors.groupingBy( - mt -> mt.getChatMessage().getId(), - Collectors.mapping(MessageTool::getToolCode, Collectors.toList()) - )); - } - // 11. 노트의 모든 자산 조회 List noteAssets = assetRepository.findAllByNoteId(noteId); @@ -520,42 +451,14 @@ public NoteDetailResponse getNoteDetail(Long userId, Long noteId, int conversati }) .collect(Collectors.toList()); - // 13. 대화 정보 DTO 생성 (역순으로 정렬하여 최신 대화가 먼저 오도록) - final Map> finalMessageAssetMap = messageAssetMap; - final Map> finalMessageToolMap = messageToolMap; - + // 13. 대화 정보 DTO 생성 (최신 대화가 먼저 오도록) List conversationInfos = pagedPairs.stream() - .sorted((a, b) -> { - LocalDateTime aTime = a.userMessage != null ? a.userMessage.getCreatedAt() : LocalDateTime.MIN; - LocalDateTime bTime = b.userMessage != null ? b.userMessage.getCreatedAt() : LocalDateTime.MIN; - return bTime.compareTo(aTime); // 최신순 - }) - .map(pair -> { - Long conversationId = pair.userMessage != null ? pair.userMessage.getId() : - (pair.assistantMessage != null ? pair.assistantMessage.getId() : 0L); - LocalDateTime createdAt = pair.userMessage != null ? pair.userMessage.getCreatedAt() : - (pair.assistantMessage != null ? pair.assistantMessage.getCreatedAt() : LocalDateTime.now()); - - // 빈 MessageInfo (null 방지) - NoteDetailResponse.MessageInfo emptyMessageInfo = NoteDetailResponse.MessageInfo.builder() - .messageId(0L) - .content("") - .mentionedAssets(List.of()) - .mentionedTools(List.of()) - .usedTools(List.of()) - .generatedFiles(List.of()) - .createdAt(LocalDateTime.now()) - .build(); - - return NoteDetailResponse.ConversationInfo.builder() - .conversationId(conversationId) - .userMessage(pair.userMessage != null ? - buildChatMessageInfo(pair.userMessage, finalMessageAssetMap, finalMessageToolMap, true) : emptyMessageInfo) - .assistantMessage(pair.assistantMessage != null ? - buildChatMessageInfo(pair.assistantMessage, finalMessageAssetMap, finalMessageToolMap, false) : emptyMessageInfo) - .createdAt(createdAt) - .build(); - }) + .sorted(Comparator.comparing(ChatMessagePair::createdAt).reversed()) + .map(pair -> NoteDetailResponse.ConversationInfo.builder() + .userMessage(toMessageInfo(pair.userMessage())) + .assistantMessage(toMessageInfo(pair.assistantMessage())) + .createdAt(toServiceTime(pair.createdAt())) + .build()) .collect(Collectors.toList()); // 14. 사용량 정보 생성 @@ -603,23 +506,16 @@ private List groupMessagesIntoConversations(List m for (ChatMessage msg : messages) { if (msg.getRole() == MessageRole.USER) { - // 이전에 매칭되지 않은 USER 메시지가 있으면 단독으로 저장 if (pendingUserMessage != null) { pairs.add(new ChatMessagePair(pendingUserMessage, null)); } pendingUserMessage = msg; } else if (msg.getRole() == MessageRole.ASSISTANT) { - if (pendingUserMessage != null) { - pairs.add(new ChatMessagePair(pendingUserMessage, msg)); - pendingUserMessage = null; - } else { - // USER 없이 ASSISTANT만 있는 경우 - pairs.add(new ChatMessagePair(null, msg)); - } + pairs.add(new ChatMessagePair(pendingUserMessage, msg)); + pendingUserMessage = null; } } - // 마지막으로 남은 USER 메시지 처리 if (pendingUserMessage != null) { pairs.add(new ChatMessagePair(pendingUserMessage, null)); } @@ -628,69 +524,30 @@ private List groupMessagesIntoConversations(List m } /** - * USER + ASSISTANT 메시지 쌍 내부 클래스 + * USER + ASSISTANT 메시지 쌍 */ - private static class ChatMessagePair { - final ChatMessage userMessage; - final ChatMessage assistantMessage; - - ChatMessagePair(ChatMessage userMessage, ChatMessage assistantMessage) { - this.userMessage = userMessage; - this.assistantMessage = assistantMessage; + private record ChatMessagePair(ChatMessage userMessage, ChatMessage assistantMessage) { + Instant createdAt() { + return userMessage != null ? userMessage.getCreatedAt() : assistantMessage.getCreatedAt(); } } - private NoteDetailResponse.MessageInfo buildChatMessageInfo( - ChatMessage chatMessage, - Map> messageAssetMap, - Map> messageToolMap, - boolean isUserMessage) { - - List assets = messageAssetMap.getOrDefault(chatMessage.getId(), List.of()); - List tools = messageToolMap.getOrDefault(chatMessage.getId(), List.of()); - - // content에서 텍스트 추출 - String content = chatMessage.getTextContent(); - - // 멘션된 자산 정보 - List mentionedAssets = null; - if (isUserMessage && !assets.isEmpty()) { - mentionedAssets = assets.stream() - .map(asset -> NoteDetailResponse.MentionedAsset.builder() - .assetId(asset.getId()) - .fileName(asset.getFileName()) - .build()) - .collect(Collectors.toList()); + private NoteDetailResponse.MessageInfo toMessageInfo(ChatMessage message) { + if (message == null) { + return null; } - - // AI가 생성한 파일 정보 - List generatedFiles = null; - if (!isUserMessage) { - generatedFiles = assets.stream() - .filter(asset -> asset.getSource() == Asset.AssetSource.ai_generated) - .map(asset -> NoteDetailResponse.GeneratedFile.builder() - .fileId(asset.getId()) - .fileName(asset.getFileName()) - .fileType("SOLUTION") - .downloadUrl(s3Service.getFileUrl(asset.getObjectKey())) - .build()) - .collect(Collectors.toList()); - if (generatedFiles.isEmpty()) { - generatedFiles = null; - } - } - return NoteDetailResponse.MessageInfo.builder() - .messageId(chatMessage.getId()) - .content(content) - .mentionedAssets(mentionedAssets) - .mentionedTools(isUserMessage && !tools.isEmpty() ? tools : null) - .usedTools(!isUserMessage && !tools.isEmpty() ? tools : null) - .generatedFiles(generatedFiles) - .createdAt(chatMessage.getCreatedAt()) + .messageId(message.getId().toString()) + .status(message.getStatus().name()) + .content(message.getPlainText()) + .createdAt(toServiceTime(message.getCreatedAt())) .build(); } + private static LocalDateTime toServiceTime(Instant instant) { + return LocalDateTime.ofInstant(instant, SERVICE_ZONE); + } + @Override @Transactional(readOnly = true) public AssetListResponse getAssetList(Long userId, Long noteId, String query) { diff --git a/src/main/java/com/proovy/domain/run/admission/RunAdmission.java b/src/main/java/com/proovy/domain/run/admission/RunAdmission.java new file mode 100644 index 00000000..ff938f3c --- /dev/null +++ b/src/main/java/com/proovy/domain/run/admission/RunAdmission.java @@ -0,0 +1,51 @@ +package com.proovy.domain.run.admission; + +import com.proovy.domain.run.contract.PublicRunStatus; +import com.proovy.domain.run.entity.AiRun; +import java.util.Objects; +import java.util.UUID; + +/** + * commit된 접수 결과. 새 run이든 같은 requestId의 replay든 브라우저 stream은 seq 1 {@code run.created}부터 시작한다 + * (browser-run-v1 {@code admission/decisions.json}의 {@code replayFromSeq}). 접수는 Brain 실행·terminal 완료를 뜻하지 않는다 — + * 새 run은 PENDING이고 dispatch(1.83)는 별도다. + * + * @param outcome 이번 호출이 run을 만들었는지(CREATED), 기존 run을 돌려주는지(REPLAYED) + * @param status 조회 시점 공개 상태. replay에서 terminal이면 stream은 저장된 event replay 후 끝난다 + */ +public record RunAdmission( + Outcome outcome, + UUID runId, + String requestId, + long noteId, + UUID userMessageId, + UUID assistantMessageId, + PublicRunStatus status) { + + /** 새 run이든 replay든 첫 frame은 seq 1이다. */ + public static final long REPLAY_FROM_SEQ = 1; + + public enum Outcome { + CREATED, + REPLAYED + } + + public RunAdmission { + Objects.requireNonNull(outcome, "outcome"); + Objects.requireNonNull(runId, "runId"); + Objects.requireNonNull(requestId, "requestId"); + Objects.requireNonNull(userMessageId, "userMessageId"); + Objects.requireNonNull(assistantMessageId, "assistantMessageId"); + Objects.requireNonNull(status, "status"); + } + + static RunAdmission of(Outcome outcome, AiRun run) { + return new RunAdmission(outcome, run.getId(), run.getRequestId(), run.getNoteId(), run.getUserMessageId(), + run.getAssistantMessageId(), PublicRunStatus.from(run.getStatus())); + } + + /** 저장된 event를 다 보낸 뒤 live로 이어 붙일지(terminal이 아니면 true). */ + public boolean thenLive() { + return !status.isTerminal(); + } +} diff --git a/src/main/java/com/proovy/domain/run/admission/RunAdmissionConfig.java b/src/main/java/com/proovy/domain/run/admission/RunAdmissionConfig.java new file mode 100644 index 00000000..9f894628 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/admission/RunAdmissionConfig.java @@ -0,0 +1,9 @@ +package com.proovy.domain.run.admission; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableConfigurationProperties(RunAdmissionProperties.class) +public class RunAdmissionConfig { +} diff --git a/src/main/java/com/proovy/domain/run/admission/RunAdmissionExceptionHandler.java b/src/main/java/com/proovy/domain/run/admission/RunAdmissionExceptionHandler.java new file mode 100644 index 00000000..2e9b76cd --- /dev/null +++ b/src/main/java/com/proovy/domain/run/admission/RunAdmissionExceptionHandler.java @@ -0,0 +1,34 @@ +package com.proovy.domain.run.admission; + +import com.proovy.global.response.ApiResponse; +import com.proovy.global.response.ErrorCode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +/** + * 접수 거부를 browser-run-v1 오류 응답으로 바꾼다. 스트림 시작 전 오류는 {@code Accept: text/event-stream} 요청이어도 + * JSON {@link ApiResponse}다(CATALOG "스트림 시작 전 오류만 JSON"). RUN4091은 result에 activeRunId, RUN4291은 Retry-After. + * {@code GlobalExceptionHandler}의 BusinessException 처리보다 먼저 고른다. + */ +@Slf4j +@Order(Ordered.HIGHEST_PRECEDENCE) +@RestControllerAdvice +public class RunAdmissionExceptionHandler { + + @ExceptionHandler(RunAdmissionRejectedException.class) + public ResponseEntity> handle(RunAdmissionRejectedException e) { + ErrorCode code = e.getErrorCode(); + log.info("run admission rejected: {}", code.getCode()); + ResponseEntity.BodyBuilder response = ResponseEntity.status(code.getHttpStatus()) + .contentType(MediaType.APPLICATION_JSON); + e.retryAfterSeconds().ifPresent(seconds -> response.header(HttpHeaders.RETRY_AFTER, Long.toString(seconds))); + return response.body(e.activeRun().>map(conflict -> ApiResponse.failure(code, conflict)) + .orElseGet(() -> ApiResponse.failure(code))); + } +} diff --git a/src/main/java/com/proovy/domain/run/admission/RunAdmissionProperties.java b/src/main/java/com/proovy/domain/run/admission/RunAdmissionProperties.java new file mode 100644 index 00000000..d358eb2e --- /dev/null +++ b/src/main/java/com/proovy/domain/run/admission/RunAdmissionProperties.java @@ -0,0 +1,41 @@ +package com.proovy.domain.run.admission; + +import java.time.Duration; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.DefaultValue; + +/** + * {@code proovy.run.admission.*} — 새 run 접수 정책. + * + * @param rateLimit 사용자별 반복 요청 제한(RUN4291). 같은 requestId 재전송(replay)은 세지 않는다 + * @param maxTransactions unique 경합·lock 실패 뒤 같은 body로 접수 transaction을 다시 여는 최대 횟수(첫 시도 포함) + */ +@ConfigurationProperties(prefix = "proovy.run.admission") +public record RunAdmissionProperties( + @DefaultValue RateLimit rateLimit, + @DefaultValue("3") int maxTransactions) { + + public RunAdmissionProperties { + if (maxTransactions < 1 || maxTransactions > 10) { + throw new IllegalArgumentException("proovy.run.admission.max-transactions must be within 1..10"); + } + } + + /** + * 최근 {@code window} 안에 접수된 사용자 run이 {@code maxRequests}개 이상이면 새 접수를 거부한다. + * 기준은 {@code ai_runs(user_id, created_at)}이고 상태(terminal 포함)와 Note를 가리지 않는다. + */ + public record RateLimit( + @DefaultValue("20") int maxRequests, + @DefaultValue("1m") Duration window) { + + public RateLimit { + if (maxRequests < 1) { + throw new IllegalArgumentException("proovy.run.admission.rate-limit.max-requests must be positive"); + } + if (window == null || window.isNegative() || window.isZero() || window.compareTo(Duration.ofHours(1)) > 0) { + throw new IllegalArgumentException("proovy.run.admission.rate-limit.window must be within (0, 1h]"); + } + } + } +} diff --git a/src/main/java/com/proovy/domain/run/admission/RunAdmissionRejectedException.java b/src/main/java/com/proovy/domain/run/admission/RunAdmissionRejectedException.java new file mode 100644 index 00000000..e568fae3 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/admission/RunAdmissionRejectedException.java @@ -0,0 +1,51 @@ +package com.proovy.domain.run.admission; + +import com.proovy.domain.run.contract.ActiveRunConflict; +import com.proovy.global.exception.BusinessException; +import com.proovy.global.response.ErrorCode; +import java.util.Optional; +import java.util.OptionalLong; +import java.util.UUID; + +/** + * 스트림 시작 전에 거부된 접수 (browser-run-v1 오류 envelope). 거부된 접수는 Note·메시지·run·차감·event를 남기지 않는다 + * (접수 transaction 전체 rollback). message는 code 고정 문구만 쓴다. + *

    + *
  • RUN4091만 {@link ActiveRunConflict} result를 싣는다(상태 복구용).
  • + *
  • RUN4291은 {@code Retry-After}(초)를 싣는다.
  • + *
  • RUN4092·RUN4041은 기존 run·Note의 존재·상태를 드러내지 않는 같은 body다.
  • + *
+ */ +public class RunAdmissionRejectedException extends BusinessException { + + private final ActiveRunConflict activeRun; + private final Long retryAfterSeconds; + + private RunAdmissionRejectedException(ErrorCode errorCode, ActiveRunConflict activeRun, Long retryAfterSeconds) { + super(errorCode); + this.activeRun = activeRun; + this.retryAfterSeconds = retryAfterSeconds; + } + + static RunAdmissionRejectedException of(ErrorCode errorCode) { + return new RunAdmissionRejectedException(errorCode, null, null); + } + + static RunAdmissionRejectedException activeRun(UUID activeRunId) { + return new RunAdmissionRejectedException(ErrorCode.RUN4091, new ActiveRunConflict(activeRunId.toString()), null); + } + + static RunAdmissionRejectedException rateLimited(long retryAfterSeconds) { + return new RunAdmissionRejectedException(ErrorCode.RUN4291, null, retryAfterSeconds); + } + + /** RUN4091의 result. 다른 code에는 없다. */ + public Optional activeRun() { + return Optional.ofNullable(activeRun); + } + + /** RUN4291의 {@code Retry-After} 초. 다른 code에는 없다. */ + public OptionalLong retryAfterSeconds() { + return retryAfterSeconds == null ? OptionalLong.empty() : OptionalLong.of(retryAfterSeconds); + } +} diff --git a/src/main/java/com/proovy/domain/run/admission/RunAdmissionService.java b/src/main/java/com/proovy/domain/run/admission/RunAdmissionService.java new file mode 100644 index 00000000..778e2818 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/admission/RunAdmissionService.java @@ -0,0 +1,102 @@ +package com.proovy.domain.run.admission; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.proovy.domain.run.contract.BrowserRequestFingerprint; +import com.proovy.domain.run.contract.ConversationRunRequest; +import com.proovy.domain.run.contract.ConversationRunRequestValidator; +import com.proovy.global.response.ErrorCode; +import java.sql.SQLException; +import java.util.Optional; +import java.util.Set; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +/** + * 브라우저 대화 요청 접수 ({@code POST /api/conversations}, browser-run-v1). 인증은 security filter가 먼저 끝낸다(401). + *
    + *
  1. JSON tree 판정 — 구조 RUN4001, video RUN4002. DB를 보기 전이다.
  2. + *
  3. {@link RunAdmissionTransaction} — requestId replay·Note·참조·Active Run·rate limit·잔액 판정과 원자적 저장.
  4. + *
+ * unique 경합(같은 requestId 동시 전송·같은 Note 동시 접수)이나 직렬화·lock·연결 실패({@link #isRetryable})로 + * transaction이 실패하면 전부 rollback된 뒤 같은 body·같은 fingerprint로 새 transaction을 연다. 그 시도는 먼저 + * commit된 접수의 binding을 찾아 replay하거나 RUN4091/RUN4092로 답하므로, 재시도가 잘못된 재전송을 새 run으로 바꾸지 + * 않는다. commit 결과를 모르는 연결 실패 뒤의 재시도도 binding 조회가 먼저라 run·차감이 둘이 되지 않는다. CHECK·데이터 + * 오류 같은 결정적 실패는 재시도하지 않는다. + *

+ * 반환은 commit된 run 식별자다. stream 연결(seq 1 {@code run.created}부터 replay·live, 1.86)과 Brain dispatch(1.83)는 + * 이 결과를 받아 별도로 한다 — 접수 완료는 실행·terminal 완료가 아니다. + */ +@Slf4j +@Service +public class RunAdmissionService { + + /** unique_violation, serialization_failure, deadlock_detected, lock_not_available */ + private static final Set RETRYABLE_SQL_STATES = Set.of("23505", "40001", "40P01", "55P03"); + private static final String CONNECTION_EXCEPTION_CLASS = "08"; + + private final RunAdmissionTransaction transaction; + private final RunAdmissionProperties properties; + private final ObjectMapper objectMapper; + + public RunAdmissionService(RunAdmissionTransaction transaction, RunAdmissionProperties properties, + ObjectMapper objectMapper) { + this.transaction = transaction; + this.properties = properties; + this.objectMapper = objectMapper; + } + + /** + * @param userId 인증된 사용자 + * @param body 요청 body JSON(바인딩 전 tree) + * @throws RunAdmissionRejectedException browser-run-v1 거부(RUN4001·4002·4092·4041·4221·4091·4291·4021). + * 아무것도 저장하지 않았다 + */ + public RunAdmission admit(long userId, JsonNode body) { + Optional invalid = ConversationRunRequestValidator.validate(body); + if (invalid.isPresent()) { + throw RunAdmissionRejectedException.of(invalid.get()); + } + ConversationRunRequest request = bind(body); + BrowserRequestFingerprint fingerprint = BrowserRequestFingerprint.of(request); + + for (int attempt = 1; ; attempt++) { + try { + return transaction.admit(userId, request, fingerprint); + } catch (RunAdmissionRejectedException e) { + throw e; + } catch (RuntimeException e) { + if (attempt >= properties.maxTransactions() || !isRetryable(e)) { + throw e; + } + log.info("run admission transaction {} failed, retrying with the same request binding: requestId={}, " + + "cause={}", attempt, request.requestId(), e.getClass().getSimpleName()); + } + } + } + + /** + * 다시 열면 결과가 달라질 수 있는 실패만 재시도한다: unique 위반(경합의 승자가 commit됨)과 직렬화·deadlock·lock 실패·연결 실패. + * CHECK·데이터 오류처럼 결정적인 실패는 같은 body로 반복해도 같으므로 바로 던진다. 번역되지 않은 JPA flush 예외 + * ({@code PersistenceException})도 원인 사슬의 SQLState로 판정한다. + */ + static boolean isRetryable(Throwable failure) { + for (Throwable cause = failure; cause != null; cause = cause.getCause()) { + if (cause instanceof SQLException sql && sql.getSQLState() != null) { + String state = sql.getSQLState(); + return RETRYABLE_SQL_STATES.contains(state) || state.startsWith(CONNECTION_EXCEPTION_CLASS); + } + } + return false; + } + + private ConversationRunRequest bind(JsonNode body) { + try { + return objectMapper.treeToValue(body, ConversationRunRequest.class); + } catch (JsonProcessingException | IllegalArgumentException e) { + // 판정을 통과한 body는 바인딩되어야 한다. 실패는 판정기와 DTO가 어긋난 Server 버그다. + throw new IllegalStateException("validated conversation request could not be bound", e); + } + } +} diff --git a/src/main/java/com/proovy/domain/run/admission/RunAdmissionTransaction.java b/src/main/java/com/proovy/domain/run/admission/RunAdmissionTransaction.java new file mode 100644 index 00000000..3baf79f3 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/admission/RunAdmissionTransaction.java @@ -0,0 +1,336 @@ +package com.proovy.domain.run.admission; + +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.proovy.domain.conversation.entity.ChatMessage; +import com.proovy.domain.conversation.repository.ChatMessageRepository; +import com.proovy.domain.credit.service.CreditBalanceService; +import com.proovy.domain.note.entity.Note; +import com.proovy.domain.note.entity.NoteStatus; +import com.proovy.domain.run.contract.BrowserRequestFingerprint; +import com.proovy.domain.run.contract.BrowserRunContract; +import com.proovy.domain.run.contract.ConversationRunRequest; +import com.proovy.domain.run.contract.PublicRunStatus; +import com.proovy.domain.run.contract.RunCreatedPayload; +import com.proovy.domain.run.contract.ServerFrameType; +import com.proovy.domain.run.entity.AiRun; +import com.proovy.domain.run.entity.RunAssetDependency; +import com.proovy.domain.run.entity.RunAssetDependencyKind; +import com.proovy.domain.run.event.RunEventStore; +import com.proovy.domain.run.repository.AiRunRepository; +import com.proovy.domain.run.repository.RunAssetDependencyRepository; +import com.proovy.domain.run.snapshot.RunSnapshot; +import com.proovy.domain.run.snapshot.RunSnapshotFactory; +import com.proovy.domain.run.snapshot.RunSnapshotRejectedException; +import com.proovy.domain.usage.exception.InsufficientUsageBalanceException; +import com.proovy.domain.usage.exception.RequestUsageConflictException; +import com.proovy.domain.usage.service.RequestUsageService; +import com.proovy.domain.user.entity.User; +import com.proovy.global.response.ErrorCode; +import jakarta.persistence.EntityManager; +import java.sql.Timestamp; +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 접수 한 번(transaction 하나). {@link RunAdmissionService}가 요청 판정 뒤 호출하고, unique 경합·lock 실패면 같은 body로 + * 새 transaction을 다시 연다. 판정 순서는 browser-run-v1 {@code admission/decisions.json}의 precedence다: + *

+ * existing(같은 사용자·같은 의미 replay / 그 밖 RUN4092) → note(RUN4041) → references(RUN4221)
+ *   → activeRun(RUN4091{activeRunId}) → rateLimited(RUN4291) → balance(RUN4021) → accept
+ * 
+ * 승인되면 같은 transaction에서 (필요하면) Note → user message → assistant placeholder → PENDING run(불변 snapshot) → + * asset 의존성 → 최초 차감 → requestId binding → seq 1 {@code run.created}를 저장한다. 어느 단계든 실패하면 전부 + * rollback되어 메시지·차감·event가 남지 않는다. Brain 호출·네트워크 대기는 이 transaction 안에 없다. + * + *

잠금 순서

+ * Note 행({@code FOR NO KEY UPDATE}, asset 삭제와 같은 순서) → 사용자 잔액 행(rate limit·차감) → asset 행({@code FOR SHARE}) → + * run 행(event seq). 같은 Note의 동시 접수는 Note 잠금으로 직렬화되어 뒤 transaction이 앞 run을 Active Run으로 보고, + * 그래도 남는 경합(새 Note의 같은 requestId 등)은 {@code uq_ai_runs_request}·{@code ux_ai_runs_note_active}·binding PK가 + * 막는다(호출자가 재시도해 승자를 replay하거나 409로 답한다). + */ +@Slf4j +@Service +public class RunAdmissionTransaction { + + /** 자동 생성 Note 제목 상한(code point). 사용자 제목 규칙(NOTE4001, 30자)과 같다. */ + static final int AUTO_TITLE_MAX_CODE_POINTS = 30; + static final String AUTO_TITLE_FALLBACK = "새 노트"; + + // FOR NO KEY UPDATE: asset 삭제와는 서로 직렬화하되 Note를 참조하는 자식 insert(FK의 FOR KEY SHARE)는 막지 않는다. + private static final String LOCK_OWNED_NOTE = """ + SELECT status FROM notes WHERE note_id = ? AND user_id = ? FOR NO KEY UPDATE + """; + private static final String REQUEST_ID_USED = """ + SELECT EXISTS (SELECT 1 FROM ai_runs WHERE request_id = ?) + OR EXISTS (SELECT 1 FROM request_usage_charges WHERE request_id = ?) + """; + private static final String NTH_RECENT_RUN = """ + SELECT created_at FROM ai_runs WHERE user_id = ? AND created_at > ? + ORDER BY created_at DESC OFFSET ? LIMIT 1 + """; + private static final String LOCK_ACTIVE_ASSETS = """ + SELECT id, content_version FROM assets WHERE note_id = ? AND status = 'ACTIVE' AND id IN (%s) FOR SHARE + """; + + private final RunRequestBindingRepository bindingRepository; + private final AiRunRepository aiRunRepository; + private final RunAssetDependencyRepository dependencyRepository; + private final ChatMessageRepository chatMessageRepository; + private final RunSnapshotFactory snapshotFactory; + private final RequestUsageService usageService; + private final CreditBalanceService creditBalanceService; + private final RunEventStore eventStore; + private final RunAdmissionProperties properties; + private final JdbcTemplate jdbc; + private final EntityManager entityManager; + + public RunAdmissionTransaction(RunRequestBindingRepository bindingRepository, + AiRunRepository aiRunRepository, + RunAssetDependencyRepository dependencyRepository, + ChatMessageRepository chatMessageRepository, + RunSnapshotFactory snapshotFactory, + RequestUsageService usageService, + CreditBalanceService creditBalanceService, + RunEventStore eventStore, + RunAdmissionProperties properties, + JdbcTemplate jdbc, + EntityManager entityManager) { + this.bindingRepository = bindingRepository; + this.aiRunRepository = aiRunRepository; + this.dependencyRepository = dependencyRepository; + this.chatMessageRepository = chatMessageRepository; + this.snapshotFactory = snapshotFactory; + this.usageService = usageService; + this.creditBalanceService = creditBalanceService; + this.eventStore = eventStore; + this.properties = properties; + this.jdbc = jdbc; + this.entityManager = entityManager; + } + + /** + * @param request {@code ConversationRunRequestValidator}를 통과한 body + * @param fingerprint 그 body의 browser-run-v1 요청 fingerprint + * @throws RunAdmissionRejectedException 계약 오류(RUN4092·RUN4041·RUN4221·RUN4091·RUN4291·RUN4021) + * @throws org.springframework.dao.DataAccessException unique 경합·lock 실패(호출자가 새 transaction으로 재시도) + */ + @Transactional + public RunAdmission admit(long userId, ConversationRunRequest request, BrowserRequestFingerprint fingerprint) { + Long requestedNoteId = request.noteId() == null ? null : Long.valueOf(request.noteId()); + // Note를 먼저 잠근다: 같은 Note의 동시 재전송이 잠금을 기다린 뒤 아래 requestId 조회에서 승자를 본다. + // 소유자 조건을 잠금 쿼리에 넣어 남의 Note는 잠그지 않는다(없음·타인·삭제 중은 같은 RUN4041). + Optional lockedNote = requestedNoteId == null ? Optional.empty() + : lockOwnedNote(requestedNoteId, userId); + + Optional replay = existing(userId, request.requestId(), fingerprint); + if (replay.isPresent()) { + return replay.get(); + } + + if (requestedNoteId != null && lockedNote.filter(status -> status == NoteStatus.ACTIVE).isEmpty()) { + throw RunAdmissionRejectedException.of(ErrorCode.RUN4041); + } + // 잔액 행을 잠가 같은 사용자의 접수를 직렬화한다(rate limit 판정이 경합으로 새지 않게). 차감도 같은 잠금을 쓴다. + creditBalanceService.lockBalanceInCurrentTransaction(userId); + // 접수 시각은 잠금을 얻은 뒤에 읽는다: 대기 시간만큼 created_at·deadline·rate-limit 창이 과거로 밀리지 않게. + Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS); + + Note note = requestedNoteId == null ? createNote(userId, request.text()) + : entityManager.getReference(Note.class, requestedNoteId); + long noteId = note.getId(); + + UUID runId = UUID.randomUUID(); + RunSnapshot snapshot; + try { + snapshot = snapshotFactory.create(runId, userId, noteId, request, now); + } catch (RunSnapshotRejectedException e) { + // 판정표 note(RUN4041)·references(RUN4221) 단계 + throw RunAdmissionRejectedException.of(e.getErrorCode()); + } + + Optional active = aiRunRepository.findActiveByNoteId(noteId); + if (active.isPresent()) { + throw RunAdmissionRejectedException.activeRun(active.get().getId()); + } + requireWithinRateLimit(userId, now); + + UUID userMessageId = UUID.randomUUID(); + UUID assistantMessageId = UUID.randomUUID(); + chatMessageRepository.save(ChatMessage.user(userMessageId, note, userInput(request), now)); + // 같은 turn에서 assistant가 user보다 늦다(#227 R6). run 생성 순서 = user 메시지 순서(1.78 history 정렬 전제). + chatMessageRepository.save(ChatMessage.assistantPlaceholder(assistantMessageId, note, + now.plus(1, ChronoUnit.MICROS))); + AiRun run = aiRunRepository.saveAndFlush(snapshot.toPendingRun(userMessageId, assistantMessageId, now)); + + List dependencies = snapshot.assetDependencies(now); + dependencyRepository.saveAllAndFlush(dependencies); + requireDependenciesStillActive(noteId, dependencies); + + try { + usageService.chargeRequest(userId, request.requestId(), runId); + } catch (InsufficientUsageBalanceException e) { + throw RunAdmissionRejectedException.of(ErrorCode.RUN4021); + } catch (RequestUsageConflictException e) { + // run이 지워진(Note 삭제) 옛 requestId의 차감이 남아 있다 — 다른 요청의 ID 재사용이다. + throw RunAdmissionRejectedException.of(ErrorCode.RUN4092); + } + bindingRepository.saveAndFlush(new RunRequestBinding(request.requestId(), userId, runId, + BrowserRunContract.FINGERPRINT_ALGORITHM, fingerprint.sha256(), now)); + + eventStore.appendServerEvent(runId, ServerFrameType.RUN_CREATED, new RunCreatedPayload( + request.requestId(), String.valueOf(noteId), userMessageId.toString(), assistantMessageId.toString(), + PublicRunStatus.PENDING, orEmpty(request.requestedTasks()), orEmpty(request.requestedOutputs())), now); + + log.info("run admitted: runId={}, noteId={}, userId={}", runId, noteId, userId); + return RunAdmission.of(RunAdmission.Outcome.CREATED, run); + } + + /** + * 같은 requestId의 기존 접수. 같은 사용자·같은 의미면 그 run을 돌려주고(부작용 없음), 다른 사용자·다른 의미· + * binding 없이 차감만 남은 ID(run이 삭제됨)면 RUN4092다. RUN4092는 기존 run·Note의 상태를 드러내지 않는다. + */ + private Optional existing(long userId, String requestId, BrowserRequestFingerprint fingerprint) { + Optional binding = bindingRepository.findById(requestId); + if (binding.isEmpty()) { + if (Boolean.TRUE.equals(jdbc.queryForObject(REQUEST_ID_USED, Boolean.class, requestId, requestId))) { + throw RunAdmissionRejectedException.of(ErrorCode.RUN4092); + } + return Optional.empty(); + } + if (!binding.get().matches(userId, BrowserRunContract.FINGERPRINT_ALGORITHM, fingerprint.sha256())) { + throw RunAdmissionRejectedException.of(ErrorCode.RUN4092); + } + AiRun run = aiRunRepository.findById(binding.get().getRunId()) + .orElseThrow(() -> new IllegalStateException("request binding without run " + requestId)); + return Optional.of(RunAdmission.of(RunAdmission.Outcome.REPLAYED, run)); + } + + private Optional lockOwnedNote(long noteId, long userId) { + return jdbc.query(LOCK_OWNED_NOTE, (rs, row) -> NoteStatus.valueOf(rs.getString(1)), noteId, userId) + .stream().findFirst(); + } + + private Note createNote(long userId, String text) { + Note note = Note.builder() + .user(entityManager.getReference(User.class, userId)) + .title(autoTitle(text)) + .build(); + entityManager.persist(note); + entityManager.flush(); + return note; + } + + /** 첫 질문으로 만든 Note의 제목: strip·공백 정리한 질문 앞부분. Gemini 제목 생성은 접수 transaction 밖(후속)이다. */ + static String autoTitle(String text) { + String normalized = collapseWhitespace(BrowserRunContract.strip(text)); + if (normalized.isEmpty()) { + return AUTO_TITLE_FALLBACK; + } + if (normalized.codePointCount(0, normalized.length()) <= AUTO_TITLE_MAX_CODE_POINTS) { + return normalized; + } + return BrowserRunContract.strip(normalized.substring(0, + normalized.offsetByCodePoints(0, AUTO_TITLE_MAX_CODE_POINTS))); + } + + /** strip 집합(BrowserRunContract) 공백 연속을 공백 하나로 접는다. */ + private static String collapseWhitespace(String value) { + StringBuilder collapsed = new StringBuilder(value.length()); + boolean inWhitespace = false; + for (int i = 0; i < value.length(); ) { + int codePoint = value.codePointAt(i); + i += Character.charCount(codePoint); + if (BrowserRunContract.isStripWhitespace(codePoint)) { + if (!inWhitespace) { + collapsed.append(' '); + } + inWhitespace = true; + } else { + collapsed.appendCodePoint(codePoint); + inWhitespace = false; + } + } + return collapsed.toString(); + } + + /** + * 최근 window 안의 사용자 run이 상한 이상이면 RUN4291. Retry-After는 그중 상한번째로 최근 run이 window를 벗어날 때까지다. + * 같은 사용자의 접수는 잔액 잠금으로 직렬화된 뒤 여기에 온다. + */ + private void requireWithinRateLimit(long userId, Instant now) { + RunAdmissionProperties.RateLimit limit = properties.rateLimit(); + Instant windowStart = now.minus(limit.window()); + List boundary = jdbc.query(NTH_RECENT_RUN, (rs, row) -> rs.getTimestamp(1).toInstant(), + userId, Timestamp.from(windowStart), limit.maxRequests() - 1); + if (boundary.isEmpty()) { + return; + } + Duration wait = Duration.between(now, boundary.get(0).plus(limit.window())); + long seconds = Math.max(1, (wait.toNanos() + 999_999_999L) / 1_000_000_000L); + throw RunAdmissionRejectedException.rateLimited(seconds); + } + + /** + * snapshot은 asset을 잠그지 않고 읽었다(1.78). 의존성을 넣은 뒤 그 asset들이 아직 같은 Note의 ACTIVE이고 content version이 + * 같은지 행을 + * {@code FOR SHARE}로 잠가 확인한다 — 잠금은 commit까지 유지되어 그 사이 삭제·상태 변경이 끼지 못한다. + * Viewer Focus asset은 corpus 구성원이어야 한다(둘을 따로 읽는 사이 삭제되면 어긋난다). + */ + private void requireDependenciesStillActive(long noteId, List dependencies) { + if (dependencies.isEmpty()) { + return; + } + Set corpus = new HashSet<>(); + Map required = new HashMap<>(); + for (RunAssetDependency dependency : dependencies) { + required.put(dependency.getAssetId(), dependency.getContentVersion()); + if (dependency.getDependencyKind() == RunAssetDependencyKind.CORPUS_SNAPSHOT) { + corpus.add(dependency.getAssetId()); + } + } + if (!corpus.containsAll(required.keySet())) { + throw RunAdmissionRejectedException.of(ErrorCode.RUN4221); + } + List args = new ArrayList<>(); + args.add(noteId); + args.addAll(required.keySet()); + String placeholders = String.join(", ", Collections.nCopies(required.size(), "?")); + Map active = new HashMap<>(); + jdbc.query(LOCK_ACTIVE_ASSETS.formatted(placeholders), rs -> { + active.put(rs.getLong(1), rs.getInt(2)); + }, args.toArray()); + // snapshot이 고정한 content version과 지금 값이 같아야 한다(재처리·교체가 끼면 drift) + if (!active.equals(required)) { + throw RunAdmissionRejectedException.of(ErrorCode.RUN4221); + } + } + + private static ObjectNode userInput(ConversationRunRequest request) { + ObjectNode input = JsonNodeFactory.instance.objectNode(); + input.put("text", request.text()); + if (request.latex() != null) { + input.put("latex", request.latex()); + } + return input; + } + + private static List orEmpty(List values) { + return values == null ? List.of() : values; + } + +} diff --git a/src/main/java/com/proovy/domain/run/admission/RunAssetDeletionGuard.java b/src/main/java/com/proovy/domain/run/admission/RunAssetDeletionGuard.java new file mode 100644 index 00000000..6909ce00 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/admission/RunAssetDeletionGuard.java @@ -0,0 +1,60 @@ +package com.proovy.domain.run.admission; + +import com.proovy.global.exception.BusinessException; +import com.proovy.global.response.ErrorCode; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.TreeSet; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +/** + * Note Asset hard delete 전 검사 (#227 R5, S13 DELETING 전환 전까지). run이 한 번이라도 의존한 asset은 + * {@code fk_run_asset_dependencies_asset}(deferred) 때문에 commit에서 FK 위반이 되므로, 미리 막고 ASSET4092로 답한다. + *

+ * 접수와 같은 잠금 순서로 Note 행을 먼저 잠근다({@code FOR NO KEY UPDATE}, note_id 순 — 접수와는 서로 직렬화되고 + * Note를 참조하는 자식 insert는 막지 않는다). 그래서 진행 중인 접수가 있으면 그 commit을 + * 기다린 뒤 새 의존성을 보고 거부하고, 삭제가 먼저면 접수는 Note 잠금 뒤 asset이 없는 corpus로 snapshot을 만든다. + */ +@Component +public class RunAssetDeletionGuard { + + private static final String LOCK_NOTES = """ + SELECT note_id FROM notes WHERE note_id IN (%s) ORDER BY note_id FOR NO KEY UPDATE + """; + private static final String HAS_DEPENDENCY = "SELECT EXISTS (SELECT 1 FROM run_asset_dependencies WHERE asset_id IN (%s))"; + + private final JdbcTemplate jdbc; + + public RunAssetDeletionGuard(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + /** + * @param noteIds 삭제할 asset들이 속한 Note + * @param assetIds 삭제할 asset + * @throws BusinessException ASSET4092 — 어떤 run이 의존한 asset이 있다 + */ + @Transactional(propagation = Propagation.MANDATORY) + public void requireNoRunDependency(Collection noteIds, Collection assetIds) { + if (assetIds.isEmpty()) { + return; + } + List notes = new ArrayList<>(new TreeSet<>(noteIds)); + jdbc.queryForList(LOCK_NOTES.formatted(placeholders(notes.size())), Long.class, notes.toArray()); + List assets = List.copyOf(new TreeSet<>(assetIds)); + Boolean used = jdbc.queryForObject(HAS_DEPENDENCY.formatted(placeholders(assets.size())), Boolean.class, + assets.toArray()); + if (Boolean.TRUE.equals(used)) { + throw new BusinessException(ErrorCode.ASSET4092); + } + } + + private static String placeholders(int count) { + return String.join(", ", Collections.nCopies(count, "?")); + } +} diff --git a/src/main/java/com/proovy/domain/run/admission/RunRequestBinding.java b/src/main/java/com/proovy/domain/run/admission/RunRequestBinding.java new file mode 100644 index 00000000..641a050e --- /dev/null +++ b/src/main/java/com/proovy/domain/run/admission/RunRequestBinding.java @@ -0,0 +1,65 @@ +package com.proovy.domain.run.admission; + +import com.proovy.global.entity.AssignedIdEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +/** + * 브라우저 requestId → run binding (PK = request_id). 접수 시 browser-run-v1 요청 fingerprint를 고정해 + * 같은 requestId 재전송이 같은 사용자·같은 의미인지 판정한다. insert 뒤 바꾸지 않는다(DB trigger). + */ +@Entity +@Table(name = "run_request_bindings") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class RunRequestBinding extends AssignedIdEntity { + + @Id + @Column(name = "request_id", length = 128) + private String requestId; + + @Column(name = "user_id", nullable = false, updatable = false) + private Long userId; + + @Column(name = "run_id", nullable = false, updatable = false) + private UUID runId; + + @Column(name = "fingerprint_algorithm", nullable = false, length = 64, updatable = false) + private String fingerprintAlgorithm; + + @Column(name = "request_fingerprint", nullable = false, length = 64, updatable = false) + private String requestFingerprint; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + RunRequestBinding(String requestId, Long userId, UUID runId, String fingerprintAlgorithm, + String requestFingerprint, Instant createdAt) { + this.requestId = Objects.requireNonNull(requestId, "requestId"); + this.userId = Objects.requireNonNull(userId, "userId"); + this.runId = Objects.requireNonNull(runId, "runId"); + this.fingerprintAlgorithm = Objects.requireNonNull(fingerprintAlgorithm, "fingerprintAlgorithm"); + this.requestFingerprint = Objects.requireNonNull(requestFingerprint, "requestFingerprint"); + this.createdAt = Objects.requireNonNull(createdAt, "createdAt"); + } + + @Override + public String getId() { + return requestId; + } + + /** 같은 사용자·같은 fingerprint 알고리즘·같은 의미면 기존 run의 replay다. */ + boolean matches(long userId, String algorithm, String fingerprint) { + return this.userId == userId && fingerprintAlgorithm.equals(algorithm) + && requestFingerprint.equals(fingerprint); + } +} diff --git a/src/main/java/com/proovy/domain/run/admission/RunRequestBindingRepository.java b/src/main/java/com/proovy/domain/run/admission/RunRequestBindingRepository.java new file mode 100644 index 00000000..dcce5a8d --- /dev/null +++ b/src/main/java/com/proovy/domain/run/admission/RunRequestBindingRepository.java @@ -0,0 +1,6 @@ +package com.proovy.domain.run.admission; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface RunRequestBindingRepository extends JpaRepository { +} diff --git a/src/main/java/com/proovy/domain/run/brain/AssetMime.java b/src/main/java/com/proovy/domain/run/brain/AssetMime.java new file mode 100644 index 00000000..e1c0a2dc --- /dev/null +++ b/src/main/java/com/proovy/domain/run/brain/AssetMime.java @@ -0,0 +1,28 @@ +package com.proovy.domain.run.brain; + +import com.fasterxml.jackson.annotation.JsonValue; +import com.proovy.domain.run.entity.WireValue; + +/** internal-run-v1 asset MIME allowlist. */ +public enum AssetMime implements WireValue { + PDF("application/pdf"), + JPEG("image/jpeg"), + PNG("image/png"), + WEBP("image/webp"); + + private final String wireValue; + + AssetMime(String wireValue) { + this.wireValue = wireValue; + } + + public boolean isPdf() { + return this == PDF; + } + + @Override + @JsonValue + public String wireValue() { + return wireValue; + } +} diff --git a/src/main/java/com/proovy/domain/run/brain/CancelOutcome.java b/src/main/java/com/proovy/domain/run/brain/CancelOutcome.java new file mode 100644 index 00000000..4938f200 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/brain/CancelOutcome.java @@ -0,0 +1,24 @@ +package com.proovy.domain.run.brain; + +import com.fasterxml.jackson.annotation.JsonValue; +import com.proovy.domain.run.entity.WireValue; + +/** internal cancel acknowledgment 결과. 브라우저 계약의 {@code run.contract.CancelOutcome}과 다르다(unknown_run 포함). */ +public enum CancelOutcome implements WireValue { + ACCEPTED("accepted"), + ALREADY_CANCELING("already_canceling"), + ALREADY_TERMINAL("already_terminal"), + UNKNOWN_RUN("unknown_run"); + + private final String wireValue; + + CancelOutcome(String wireValue) { + this.wireValue = wireValue; + } + + @Override + @JsonValue + public String wireValue() { + return wireValue; + } +} diff --git a/src/main/java/com/proovy/domain/run/brain/CleanupOutcome.java b/src/main/java/com/proovy/domain/run/brain/CleanupOutcome.java new file mode 100644 index 00000000..0f0ff8a1 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/brain/CleanupOutcome.java @@ -0,0 +1,23 @@ +package com.proovy.domain.run.brain; + +import com.fasterxml.jackson.annotation.JsonValue; +import com.proovy.domain.run.entity.WireValue; + +/** runtime cleanup 결과. protected는 활성·정리 중 실행을 건드리지 않았다는 뜻이다. */ +public enum CleanupOutcome implements WireValue { + CLEANED("cleaned"), + NOOP("noop"), + PROTECTED("protected"); + + private final String wireValue; + + CleanupOutcome(String wireValue) { + this.wireValue = wireValue; + } + + @Override + @JsonValue + public String wireValue() { + return wireValue; + } +} diff --git a/src/main/java/com/proovy/domain/run/brain/ExecutionLimits.java b/src/main/java/com/proovy/domain/run/brain/ExecutionLimits.java new file mode 100644 index 00000000..031d69a3 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/brain/ExecutionLimits.java @@ -0,0 +1,26 @@ +package com.proovy.domain.run.brain; + +import static com.proovy.domain.run.brain.InternalRunContract.atLeast; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** 요청 시점에 고정한 호출·token·비용·page·problem·claim 상한. fingerprint에 포함된다. */ +public record ExecutionLimits( + @JsonProperty(required = true) long maxLlmCalls, + @JsonProperty(required = true) long maxToolCalls, + @JsonProperty(required = true) long maxOutputTokens, + @JsonProperty(required = true) long maxCostMicros, + @JsonProperty(required = true) long maxPageUnderstandings, + @JsonProperty(required = true) long maxProblems, + @JsonProperty(required = true) long maxClaims) { + + public ExecutionLimits { + atLeast(maxLlmCalls, 0, "limits.max_llm_calls"); + atLeast(maxToolCalls, 0, "limits.max_tool_calls"); + atLeast(maxOutputTokens, 0, "limits.max_output_tokens"); + atLeast(maxCostMicros, 0, "limits.max_cost_micros"); + atLeast(maxPageUnderstandings, 0, "limits.max_page_understandings"); + atLeast(maxProblems, 0, "limits.max_problems"); + atLeast(maxClaims, 0, "limits.max_claims"); + } +} diff --git a/src/main/java/com/proovy/domain/run/brain/ExecutionOptions.java b/src/main/java/com/proovy/domain/run/brain/ExecutionOptions.java new file mode 100644 index 00000000..974a9aa9 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/brain/ExecutionOptions.java @@ -0,0 +1,29 @@ +package com.proovy.domain.run.brain; + +import static com.proovy.domain.run.brain.InternalRunContract.require; +import static com.proovy.domain.run.brain.InternalRunContract.versionLabel; + +import com.fasterxml.jackson.annotation.JsonFormat; +import java.time.OffsetDateTime; +import java.time.temporal.ChronoUnit; + +/** + * versioned 실행 예산 snapshot과 누적 counter. + * {@code requestDeadlineAt}은 요청 접수 때 고정한 절대 시각(offset 필수)이고 fingerprint에서 제외된다. + * 재전달은 {@link InternalRunRequest#forRetry}로만 만들며 deadline을 바꾸지 않는다. + */ +public record ExecutionOptions( + String budgetPolicyVersion, + @JsonFormat(shape = JsonFormat.Shape.STRING) OffsetDateTime requestDeadlineAt, + ExecutionLimits limits, + ExecutionUsage usage) { + + public ExecutionOptions { + budgetPolicyVersion = versionLabel(budgetPolicyVersion, "execution_options.budget_policy_version"); + // Brain(pydantic)은 µs까지만 보존한다. 1.75 wireTime 관례와 같이 µs로 자른다. + requestDeadlineAt = require(requestDeadlineAt, "execution_options.request_deadline_at") + .truncatedTo(ChronoUnit.MICROS); + require(limits, "execution_options.limits"); + require(usage, "execution_options.usage"); + } +} diff --git a/src/main/java/com/proovy/domain/run/brain/ExecutionUsage.java b/src/main/java/com/proovy/domain/run/brain/ExecutionUsage.java new file mode 100644 index 00000000..badeb2a3 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/brain/ExecutionUsage.java @@ -0,0 +1,35 @@ +package com.proovy.domain.run.brain; + +import static com.proovy.domain.run.brain.InternalRunContract.atLeast; +import static com.proovy.domain.run.brain.InternalRunContract.violation; + +/** + * 같은 run의 이전 attempt가 누적한 사용량. fingerprint에서 제외되고 재시도·조회로 줄어들지 않는다. + * 첫 전달은 {@link #ZERO}다. + */ +public record ExecutionUsage( + long usedLlmCalls, + long usedToolCalls, + long usedOutputTokens, + long usedCostMicros, + long usedPageUnderstandings) { + + public static final ExecutionUsage ZERO = new ExecutionUsage(0, 0, 0, 0, 0); + + public ExecutionUsage { + atLeast(usedLlmCalls, 0, "usage.used_llm_calls"); + atLeast(usedToolCalls, 0, "usage.used_tool_calls"); + atLeast(usedOutputTokens, 0, "usage.used_output_tokens"); + atLeast(usedCostMicros, 0, "usage.used_cost_micros"); + atLeast(usedPageUnderstandings, 0, "usage.used_page_understandings"); + } + + /** 모든 counter가 {@code previous} 이상인지. 재전달이 누적 예산을 되돌리지 못하게 한다. */ + public void requireNotBelow(ExecutionUsage previous) { + if (usedLlmCalls < previous.usedLlmCalls || usedToolCalls < previous.usedToolCalls + || usedOutputTokens < previous.usedOutputTokens || usedCostMicros < previous.usedCostMicros + || usedPageUnderstandings < previous.usedPageUnderstandings) { + throw violation("execution_options.usage", "must not decrease across deliveries"); + } + } +} diff --git a/src/main/java/com/proovy/domain/run/brain/HistoryMessage.java b/src/main/java/com/proovy/domain/run/brain/HistoryMessage.java new file mode 100644 index 00000000..5085439c --- /dev/null +++ b/src/main/java/com/proovy/domain/run/brain/HistoryMessage.java @@ -0,0 +1,15 @@ +package com.proovy.domain.run.brain; + +import static com.proovy.domain.run.brain.InternalRunContract.MAX_HISTORY_MESSAGE_CHARS; +import static com.proovy.domain.run.brain.InternalRunContract.opaqueId; +import static com.proovy.domain.run.brain.InternalRunContract.require; + +/** 이전 turn 하나의 공개 plain text. Server가 공개 Result Block에서 만들고 시간 순서를 지킨다. */ +public record HistoryMessage(String messageId, HistoryRole role, String text) { + + public HistoryMessage { + messageId = opaqueId(messageId, "history.message_id"); + require(role, "history.role"); + text = InternalRunContract.text(text, "history.text", 1, MAX_HISTORY_MESSAGE_CHARS); + } +} diff --git a/src/main/java/com/proovy/domain/run/brain/HistoryRole.java b/src/main/java/com/proovy/domain/run/brain/HistoryRole.java new file mode 100644 index 00000000..2fce0d38 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/brain/HistoryRole.java @@ -0,0 +1,22 @@ +package com.proovy.domain.run.brain; + +import com.fasterxml.jackson.annotation.JsonValue; +import com.proovy.domain.run.entity.WireValue; + +/** history message의 화자. */ +public enum HistoryRole implements WireValue { + USER("user"), + ASSISTANT("assistant"); + + private final String wireValue; + + HistoryRole(String wireValue) { + this.wireValue = wireValue; + } + + @Override + @JsonValue + public String wireValue() { + return wireValue; + } +} diff --git a/src/main/java/com/proovy/domain/run/brain/InternalRunContract.java b/src/main/java/com/proovy/domain/run/brain/InternalRunContract.java new file mode 100644 index 00000000..3e405878 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/brain/InternalRunContract.java @@ -0,0 +1,180 @@ +package com.proovy.domain.run.brain; + +import com.proovy.domain.run.contract.BrowserRunContract; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.function.Function; +import java.util.regex.Pattern; + +/** + * internal-run-v1 (Server↔Brain 내부 실행 wire) 상수와 검증 도우미. + *

+ * 원본은 proovy-brain {@code contracts/internal_run.py}이고, 값은 pin된 fixture + * ({@code internal-run-fixtures-v2})로 검사한다. 이 패키지의 record는 생성 시점에 Brain과 같은 규칙으로 + * 정규화·검증하므로, 잘못된 요청은 Brain에 보내기 전에 만들어지지 않는다. + *

    + *
  • 문자열은 Python {@code str.strip()} 집합으로 양끝을 지운 뒤 검사한다(pydantic {@code str_strip_whitespace}).
  • + *
  • 길이는 code point 수다(Python {@code len}).
  • + *
  • 오류 message에는 필드 이름만 쓰고 입력 값은 넣지 않는다.
  • + *
+ */ +public final class InternalRunContract { + + public static final String CONTRACT_VERSION = "internal-run-v1"; + public static final String SOURCE_CONTRACT_VERSION = "brain-source-v1"; + public static final String FINGERPRINT_ALGORITHM = "sha256-canonical-json-v1"; + /** Server가 맞춰 둔 fixture 소비 버전. pin을 올리면 함께 올린다. */ + public static final String CONSUMER_VERSION = "internal-run-fixtures-v2"; + + public static final int MAX_HISTORY_MESSAGES = 100; + public static final int MAX_HISTORY_MESSAGE_CHARS = 50_000; + public static final int MAX_HISTORY_TOTAL_CHARS = 480_000; + public static final int MAX_VERIFIED_SOLUTION_REFS = 50; + public static final int MAX_TURN_ATTACHMENT_REFS = 50; + public static final int MAX_TURN_ATTACHMENTS = 10; + public static final int MAX_PROBLEM_SUMMARY_CHARS = 4_000; + public static final int MAX_CLAIM_STATEMENT_CHARS = 2_000; + public static final int MAX_REF_CLAIMS = 50; + public static final int MAX_DISPLAY_LABEL_CHARS = 200; + public static final int MAX_READ_URL_CHARS = 4_096; + public static final int MAX_GRANT_CHARS = 4_096; + public static final long MAX_ASSET_BYTES = 209_715_200L; + public static final int MAX_ASSET_PAGES = 1_000; + public static final int MAX_ASSET_PIXEL_EDGE = 65_535; + public static final long MAX_ASSET_PIXEL_COUNT = 500_000_000L; + public static final int MAX_IDENTIFIER_LENGTH = 128; + public static final int MAX_VERSION_LABEL_LENGTH = 64; + public static final int MAX_FENCE_TOKEN_LENGTH = 128; + public static final int MAX_REQUEST_TEXT_LENGTH = 100_000; + public static final int MAX_REQUEST_LATEX_LENGTH = 100_000; + public static final int MAX_REQUESTED_VALUES = 10; + public static final int MAX_SOURCE_EVENT_ID_LENGTH = 256; + + static final Pattern VERSION_LABEL = Pattern.compile("[A-Za-z0-9._-]+"); + static final Pattern SHA256_HEX = Pattern.compile("[0-9a-f]{64}"); + static final Pattern LOCALE = Pattern.compile("[a-zA-Z]{2,8}(-[a-zA-Z0-9]{1,8})*"); + static final Pattern SOLUTION_ID = neutral("solution"); + static final Pattern CLAIM_ID = neutral("claim"); + static final Pattern SOURCE_EVENT_ID = Pattern.compile("[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}"); + + private InternalRunContract() { + } + + private static Pattern neutral(String prefix) { + return Pattern.compile(prefix + "-(?:[1-9][0-9]*|[0-9a-f]{20}-[0-9]{3})"); + } + + /** null은 그대로, 문자열은 Python {@code str.strip()} 집합으로 양끝을 지운다. */ + static String strip(String value) { + return value == null ? null : BrowserRunContract.strip(value); + } + + static String require(String value, String field) { + if (value == null) { + throw violation(field, "is required"); + } + return value; + } + + static T require(T value, String field) { + if (value == null) { + throw violation(field, "is required"); + } + return value; + } + + /** strip 후 code point 길이가 [min, max]인 필수 문자열. */ + static String text(String value, String field, int min, int max) { + String stripped = strip(require(value, field)); + int length = stripped.codePointCount(0, stripped.length()); + if (length < min || length > max) { + throw violation(field, "length is out of range"); + } + return stripped; + } + + /** null 허용 문자열. 값이 있으면 {@link #text}와 같다. */ + static String optionalText(String value, String field, int min, int max) { + return value == null ? null : text(value, field, min, max); + } + + static String opaqueId(String value, String field) { + String stripped = strip(require(value, field)); + if (!BrowserRunContract.isOpaqueId(stripped)) { + throw violation(field, "must be an opaque identifier"); + } + return stripped; + } + + static String fenceToken(String value, String field) { + return opaqueId(text(value, field, 1, MAX_FENCE_TOKEN_LENGTH), field); + } + + static String matching(String value, String field, Pattern pattern, int maxLength) { + String stripped = text(value, field, 1, maxLength); + if (!pattern.matcher(stripped).matches()) { + throw violation(field, "has an invalid format"); + } + return stripped; + } + + static String versionLabel(String value, String field) { + return matching(value, field, VERSION_LABEL, MAX_VERSION_LABEL_LENGTH); + } + + static String sha256(String value, String field) { + return matching(value, field, SHA256_HEX, 64); + } + + static int atLeast(int value, int min, String field) { + if (value < min) { + throw violation(field, "is out of range"); + } + return value; + } + + static long atLeast(long value, long min, String field) { + if (value < min) { + throw violation(field, "is out of range"); + } + return value; + } + + static Integer optionalRange(Integer value, long min, long max, String field) { + if (value != null && (value < min || value > max)) { + throw violation(field, "is out of range"); + } + return value; + } + + static Long optionalRange(Long value, long min, long max, String field) { + if (value != null && (value < min || value > max)) { + throw violation(field, "is out of range"); + } + return value; + } + + /** null → 빈 목록, 상한 검사 후 불변 사본. */ + static List list(List values, int max, String field) { + List copy = values == null ? List.of() : List.copyOf(values); + if (copy.size() > max) { + throw violation(field, "has too many entries"); + } + return copy; + } + + static void unique(Collection values, Function identity, String field) { + Set seen = new HashSet<>(); + for (T value : values) { + if (!seen.add(identity.apply(value))) { + throw violation(field, "must be unique"); + } + } + } + + static InternalRunContractViolation violation(String field, String problem) { + return new InternalRunContractViolation(field + " " + problem); + } +} diff --git a/src/main/java/com/proovy/domain/run/brain/InternalRunContractViolation.java b/src/main/java/com/proovy/domain/run/brain/InternalRunContractViolation.java new file mode 100644 index 00000000..4665ba38 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/brain/InternalRunContractViolation.java @@ -0,0 +1,11 @@ +package com.proovy.domain.run.brain; + +/** + * internal-run-v1 계약 위반. message는 필드 이름과 위반 종류만 담고 입력 값(grant·URL·본문)은 담지 않는다. + */ +public class InternalRunContractViolation extends IllegalArgumentException { + + public InternalRunContractViolation(String message) { + super(message); + } +} diff --git a/src/main/java/com/proovy/domain/run/brain/InternalRunFingerprint.java b/src/main/java/com/proovy/domain/run/brain/InternalRunFingerprint.java new file mode 100644 index 00000000..7bc2b573 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/brain/InternalRunFingerprint.java @@ -0,0 +1,149 @@ +package com.proovy.domain.run.brain; + +import com.proovy.domain.run.contract.CanonicalJson; +import com.proovy.domain.run.contract.RequestedOutput; +import com.proovy.domain.run.contract.RequestedTask; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +/** + * internal-run-v1 요청 fingerprint ({@value InternalRunContract#FINGERPRINT_ALGORITHM}). + * Brain {@code request_fingerprint_document}와 같은 projection을 {@link CanonicalJson}으로 직렬화해 sha256한다. + *
    + *
  • 포함: contract_version·user/note/conversation id·input·history(message_id + sha256(role,text))· + * verified_solution_refs(solution_id + sha256(problem_summary, claims))·turn_attachment_refs·viewer_context· + * retrieval_scope·turn_attachments·execution(budget_policy_version·limits)
  • + *
  • 제외: request_id·run_id·attempt·fence_token·asset_access_grant·read_url·request_deadline_at·usage, + * 수명주기(status·revises_solution_id·참조의 source_message_id)와 표현 사실(mime·byte/page/pixel· + * upload/normalization version·display_label)
  • + *
  • 비순서 컬렉션은 식별자로 정렬하고 시간 순서인 history는 순서를 지킨다.
  • + *
+ * 문자열은 record 생성 때 이미 strip되어 있다. + * + * @param canonical canonical JSON 문서 + * @param sha256 canonical UTF-8 바이트의 sha256 소문자 hex. Brain status 응답의 {@code request_fingerprint}와 비교한다. + */ +public record InternalRunFingerprint(String canonical, String sha256) { + + public static InternalRunFingerprint of(InternalRunRequest request) { + String canonical = CanonicalJson.write(document(request)); + return new InternalRunFingerprint(canonical, sha256Hex(canonical)); + } + + static Map document(InternalRunRequest request) { + RunInput input = request.input(); + Map inputDocument = new LinkedHashMap<>(); + inputDocument.put("text", input.text()); + inputDocument.put("latex", input.latex()); + inputDocument.put("requested_tasks", input.requestedTasks().stream().map(RequestedTask::wireValue).sorted() + .toList()); + inputDocument.put("requested_outputs", input.requestedOutputs().stream().map(RequestedOutput::wireValue) + .sorted().toList()); + inputDocument.put("locale", input.locale()); + + Map document = new LinkedHashMap<>(); + document.put("contract_version", request.contractVersion()); + document.put("user_id", request.userId()); + document.put("note_id", request.noteId()); + document.put("conversation_id", request.conversationId()); + document.put("input", inputDocument); + document.put("history", request.history().stream() + .map(message -> Map.of( + "message_id", message.messageId(), + "content_sha256", contentSha256(Map.of( + "role", message.role().wireValue(), + "text", message.text())))) + .toList()); + document.put("verified_solution_refs", sorted(request.verifiedSolutionRefs(), VerifiedSolutionRef::solutionId) + .stream() + .map(ref -> Map.of( + "solution_id", ref.solutionId(), + "content_sha256", contentSha256(Map.of( + "problem_summary", ref.problemSummary(), + "claims", sorted(ref.claims(), VerifiedSolutionRefClaim::claimId).stream() + .map(claim -> Map.of( + "claim_id", claim.claimId(), + "statement", claim.statement(), + "coverage", claim.coverage().wireValue())) + .toList())))) + .toList()); + document.put("turn_attachment_refs", sorted(request.turnAttachmentRefs(), TurnAttachmentRef::attachmentId) + .stream() + .map(ref -> Map.of( + "attachment_id", ref.attachmentId(), + "source_message_id", ref.sourceMessageId(), + "sha256", ref.sha256(), + "page_understanding_version", ref.pageUnderstandingVersion())) + .toList()); + document.put("turn_attachments", sorted(request.turnAttachments(), TurnAttachment::attachmentId).stream() + .map(item -> Map.of( + "attachment_id", item.attachmentId(), + "sha256", item.sha256(), + "page_understanding_version", item.pageUnderstandingVersion())) + .toList()); + RetrievalScope scope = request.retrievalScope(); + document.put("retrieval_scope", Map.of( + "note_id", scope.noteId(), + "corpus_version", scope.corpusVersion(), + "search_extraction_version", scope.searchExtractionVersion(), + "chunking_version", scope.chunkingVersion(), + "embedding_version", scope.embeddingVersion())); + ExecutionLimits limits = request.executionOptions().limits(); + Map limitsDocument = new LinkedHashMap<>(); + limitsDocument.put("max_llm_calls", limits.maxLlmCalls()); + limitsDocument.put("max_tool_calls", limits.maxToolCalls()); + limitsDocument.put("max_output_tokens", limits.maxOutputTokens()); + limitsDocument.put("max_cost_micros", limits.maxCostMicros()); + limitsDocument.put("max_page_understandings", limits.maxPageUnderstandings()); + limitsDocument.put("max_problems", limits.maxProblems()); + limitsDocument.put("max_claims", limits.maxClaims()); + document.put("execution", Map.of( + "budget_policy_version", request.executionOptions().budgetPolicyVersion(), + "limits", limitsDocument)); + ViewerContext viewer = request.viewerContext(); + if (viewer != null) { + Map viewerDocument = new LinkedHashMap<>(); + viewerDocument.put("asset_id", viewer.assetId()); + viewerDocument.put("page", viewer.page()); + viewerDocument.put("sha256", viewer.sha256()); + viewerDocument.put("page_understanding_version", viewer.pageUnderstandingVersion()); + ViewerSelection selection = viewer.selection(); + if (selection != null) { + viewerDocument.put("selection", Map.of( + "left", selection.left(), + "top", selection.top(), + "right", selection.right(), + "bottom", selection.bottom())); + } + document.put("viewer_context", viewerDocument); + } + return document; + } + + private static List sorted(List values, Function identity) { + List copy = new ArrayList<>(values); + copy.sort(Comparator.comparing(identity)); + return copy; + } + + private static String contentSha256(Object document) { + return sha256Hex(CanonicalJson.write(document)); + } + + private static String sha256Hex(String canonical) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(canonical.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 is required by the JDK", e); + } + } +} diff --git a/src/main/java/com/proovy/domain/run/brain/InternalRunRequest.java b/src/main/java/com/proovy/domain/run/brain/InternalRunRequest.java new file mode 100644 index 00000000..16fe1bfe --- /dev/null +++ b/src/main/java/com/proovy/domain/run/brain/InternalRunRequest.java @@ -0,0 +1,159 @@ +package com.proovy.domain.run.brain; + +import static com.proovy.domain.run.brain.InternalRunContract.CONTRACT_VERSION; +import static com.proovy.domain.run.brain.InternalRunContract.MAX_GRANT_CHARS; +import static com.proovy.domain.run.brain.InternalRunContract.MAX_HISTORY_MESSAGES; +import static com.proovy.domain.run.brain.InternalRunContract.MAX_HISTORY_TOTAL_CHARS; +import static com.proovy.domain.run.brain.InternalRunContract.MAX_TURN_ATTACHMENTS; +import static com.proovy.domain.run.brain.InternalRunContract.MAX_TURN_ATTACHMENT_REFS; +import static com.proovy.domain.run.brain.InternalRunContract.MAX_VERIFIED_SOLUTION_REFS; +import static com.proovy.domain.run.brain.InternalRunContract.atLeast; +import static com.proovy.domain.run.brain.InternalRunContract.list; +import static com.proovy.domain.run.brain.InternalRunContract.opaqueId; +import static com.proovy.domain.run.brain.InternalRunContract.optionalText; +import static com.proovy.domain.run.brain.InternalRunContract.require; +import static com.proovy.domain.run.brain.InternalRunContract.unique; +import static com.proovy.domain.run.brain.InternalRunContract.violation; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * {@code POST /internal/v1/runs/{run_id}/stream} 본문(Brain {@code InternalRunRequestV1}). + *

+ * 의미 snapshot(input·history·참조·viewer·scope·첨부·실행 상한)과 전달마다 바뀌는 control stamp + * (attempt·fence_token·asset_access_grant·read_url·deadline·usage)를 함께 담는다. fingerprint는 의미 쪽만 해시한다 + * ({@link InternalRunFingerprint}). + *

    + *
  • 사용자 JWT·credit·metadata는 표현할 필드가 없다(Brain도 closed model로 거부).
  • + *
  • run_id·request_id·attempt·fence는 Server가 발급한다. 이 타입은 새 run을 만들지 않는다.
  • + *
  • 재전달은 {@link #forRetry}로만 만든다: 같은 run·request, 더 큰 attempt, 고정 deadline, 줄지 않는 usage.
  • + *
  • {@link #toString()}은 grant·URL을 쓰지 않는다.
  • + *
+ */ +public record InternalRunRequest( + String contractVersion, + String requestId, + String runId, + String userId, + String noteId, + String conversationId, + @JsonProperty(required = true) int attempt, + String fenceToken, + RunInput input, + @JsonSetter(nulls = Nulls.FAIL) @JsonProperty(required = true) List history, + @JsonSetter(nulls = Nulls.FAIL) @JsonProperty(required = true) List verifiedSolutionRefs, + @JsonSetter(nulls = Nulls.FAIL) @JsonProperty(required = true) List turnAttachmentRefs, + ViewerContext viewerContext, + RetrievalScope retrievalScope, + @JsonSetter(nulls = Nulls.FAIL) @JsonProperty(required = true) List turnAttachments, + String assetAccessGrant, + ExecutionOptions executionOptions) { + + public InternalRunRequest { + if (!CONTRACT_VERSION.equals(contractVersion)) { + throw violation("contract_version", "must be " + CONTRACT_VERSION); + } + requestId = opaqueId(requestId, "request_id"); + runId = opaqueId(runId, "run_id"); + userId = opaqueId(userId, "user_id"); + noteId = opaqueId(noteId, "note_id"); + conversationId = opaqueId(conversationId, "conversation_id"); + atLeast(attempt, 1, "attempt"); + fenceToken = InternalRunContract.fenceToken(fenceToken, "fence_token"); + require(input, "input"); + history = list(history, MAX_HISTORY_MESSAGES, "history"); + verifiedSolutionRefs = list(verifiedSolutionRefs, MAX_VERIFIED_SOLUTION_REFS, "verified_solution_refs"); + turnAttachmentRefs = list(turnAttachmentRefs, MAX_TURN_ATTACHMENT_REFS, "turn_attachment_refs"); + require(retrievalScope, "retrieval_scope"); + turnAttachments = list(turnAttachments, MAX_TURN_ATTACHMENTS, "turn_attachments"); + assetAccessGrant = optionalText(assetAccessGrant, "asset_access_grant", 1, MAX_GRANT_CHARS); + require(executionOptions, "execution_options"); + + if (!conversationId.equals(noteId)) { + throw violation("conversation_id", "must equal note_id in v1"); + } + if (!retrievalScope.noteId().equals(noteId)) { + throw violation("retrieval_scope.note_id", "must equal note_id"); + } + long historyChars = 0; + for (HistoryMessage message : history) { + if (message.role() == HistoryRole.USER && message.text().equals(input.text())) { + throw violation("history", "must not duplicate the current input"); + } + historyChars += message.text().codePointCount(0, message.text().length()); + } + if (historyChars > MAX_HISTORY_TOTAL_CHARS) { + throw violation("history", "exceeds the total character bound"); + } + unique(history, HistoryMessage::messageId, "history.message_id"); + unique(verifiedSolutionRefs, VerifiedSolutionRef::solutionId, "verified_solution_refs.solution_id"); + unique(turnAttachmentRefs, TurnAttachmentRef::attachmentId, "turn_attachment_refs.attachment_id"); + unique(turnAttachments, TurnAttachment::attachmentId, "turn_attachments.attachment_id"); + Set historyIds = new HashSet<>(); + history.forEach(message -> historyIds.add(message.messageId())); + for (VerifiedSolutionRef ref : verifiedSolutionRefs) { + if (!historyIds.contains(ref.sourceMessageId())) { + throw violation("verified_solution_refs.source_message_id", "must point inside the history"); + } + } + for (TurnAttachmentRef ref : turnAttachmentRefs) { + if (!historyIds.contains(ref.sourceMessageId())) { + throw violation("turn_attachment_refs.source_message_id", "must point inside the history"); + } + } + } + + /** 의미 fingerprint. 같은 run의 모든 재전달에서 같아야 한다. */ + public InternalRunFingerprint fingerprint() { + return InternalRunFingerprint.of(this); + } + + /** + * 같은 run의 다음 attempt 전달. request_id·run_id·의미 snapshot·{@code request_deadline_at}은 그대로이고, + * attempt는 커져야 하며 누적 usage는 줄 수 없다. grant·read URL은 {@link #withAssetAccess}로 따로 갱신한다. + */ + public InternalRunRequest forRetry(int nextAttempt, String nextFenceToken, ExecutionUsage accumulatedUsage) { + if (nextAttempt <= attempt) { + throw violation("attempt", "must increase across deliveries"); + } + require(accumulatedUsage, "execution_options.usage").requireNotBelow(executionOptions.usage()); + ExecutionOptions options = new ExecutionOptions(executionOptions.budgetPolicyVersion(), + executionOptions.requestDeadlineAt(), executionOptions.limits(), accumulatedUsage); + return new InternalRunRequest(contractVersion, requestId, runId, userId, noteId, conversationId, nextAttempt, + nextFenceToken, input, history, verifiedSolutionRefs, turnAttachmentRefs, viewerContext, + retrievalScope, turnAttachments, assetAccessGrant, options); + } + + /** + * 실행 자격증명만 바꾼 사본(grant와 짧은 read URL 재발급). 의미 필드가 그대로라 fingerprint가 같다. + * 모든 인자는 "주어진 것만 바꾸고 나머지는 유지"다. 자격증명을 지우는 경로는 없다. + * + * @param grant 새 Asset Access Grant. null이면 기존 값을 유지한다 + * @param viewerReadUrl viewer_context의 새 read URL. null이면 기존 값을 유지한다(viewer_context가 없으면 무시) + * @param turnAttachmentReadUrls attachment_id → 새 read URL. null이거나 없는 id는 기존 URL을 유지한다 + */ + public InternalRunRequest withAssetAccess(String grant, String viewerReadUrl, + Map turnAttachmentReadUrls) { + ViewerContext viewer = viewerContext == null || viewerReadUrl == null + ? viewerContext : viewerContext.withReadUrl(viewerReadUrl); + Map urls = turnAttachmentReadUrls == null ? Map.of() : turnAttachmentReadUrls; + List attachments = turnAttachments.stream() + .map(item -> item.withReadUrl(urls.getOrDefault(item.attachmentId(), item.readUrl()))) + .toList(); + return new InternalRunRequest(contractVersion, requestId, runId, userId, noteId, conversationId, attempt, + fenceToken, input, history, verifiedSolutionRefs, turnAttachmentRefs, viewer, retrievalScope, + attachments, grant == null ? assetAccessGrant : grant, executionOptions); + } + + @Override + public String toString() { + return "InternalRunRequest[requestId=" + requestId + ", runId=" + runId + ", attempt=" + attempt + + ", assetAccessGrant=" + (assetAccessGrant == null ? "absent" : "") + "]"; + } +} diff --git a/src/main/java/com/proovy/domain/run/brain/InternalRunWire.java b/src/main/java/com/proovy/domain/run/brain/InternalRunWire.java new file mode 100644 index 00000000..781013af --- /dev/null +++ b/src/main/java/com/proovy/domain/run/brain/InternalRunWire.java @@ -0,0 +1,151 @@ +package com.proovy.domain.run.brain; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonValue; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.StreamReadFeature; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.cfg.CoercionAction; +import com.fasterxml.jackson.databind.cfg.CoercionInputShape; +import com.fasterxml.jackson.databind.exc.InvalidFormatException; +import com.fasterxml.jackson.databind.exc.MismatchedInputException; +import com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException; +import com.fasterxml.jackson.databind.exc.ValueInstantiationException; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.databind.type.LogicalType; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.proovy.domain.run.entity.RunCancelCause; +import java.io.IOException; + +/** + * internal-run-v1·brain-source-v1 snake_case wire 전용 Jackson 설정. 브라우저 camelCase 모델(Spring 기본 mapper)과 + * 섞지 않으려고 이 클래스로만 (역)직렬화한다. + *
    + *
  • closed model: 알 수 없는 필드·중복 key·뒤따르는 token을 거부한다(Brain {@code extra="forbid"}).
  • + *
  • 타입 강제 변환 없음: 문자열↔숫자, 실수→정수, boolean→숫자를 거부한다(Brain {@code StrictInt}).
  • + *
  • null 값은 쓰지 않는다(Brain {@code exclude_none}). 시각은 offset 있는 ISO 문자열이고 offset을 바꾸지 않는다.
  • + *
  • 읽기 실패는 {@link InternalRunContractViolation}이며 message에 입력 값을 넣지 않는다.
  • + *
+ */ +public final class InternalRunWire { + + private static final ObjectMapper MAPPER = build(); + + private InternalRunWire() { + } + + /** entity enum({@link RunCancelCause})을 고치지 않고 wire 철자로 쓰기 위한 mix-in. */ + private interface WireValueMixIn { + @JsonValue + String wireValue(); + } + + private static ObjectMapper build() { + JsonMapper mapper = JsonMapper.builder() + .addModule(new JavaTimeModule()) + .propertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE) + .serializationInclusion(JsonInclude.Include.NON_NULL) + .addMixIn(RunCancelCause.class, WireValueMixIn.class) + .enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .enable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES) + .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS) + .enable(DeserializationFeature.FAIL_ON_READING_DUP_TREE_KEY) + .disable(DeserializationFeature.ACCEPT_FLOAT_AS_INT) + .disable(DeserializationFeature.ADJUST_DATES_TO_CONTEXT_TIME_ZONE) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + .disable(MapperFeature.ALLOW_COERCION_OF_SCALARS) + .enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION) + .disable(StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION) + .build(); + mapper.coercionConfigFor(LogicalType.Textual) + .setCoercion(CoercionInputShape.Integer, CoercionAction.Fail) + .setCoercion(CoercionInputShape.Float, CoercionAction.Fail) + .setCoercion(CoercionInputShape.Boolean, CoercionAction.Fail); + mapper.coercionConfigFor(LogicalType.Integer) + .setCoercion(CoercionInputShape.String, CoercionAction.Fail) + .setCoercion(CoercionInputShape.Float, CoercionAction.Fail) + .setCoercion(CoercionInputShape.Boolean, CoercionAction.Fail); + mapper.coercionConfigFor(LogicalType.Float) + .setCoercion(CoercionInputShape.String, CoercionAction.Fail) + .setCoercion(CoercionInputShape.Boolean, CoercionAction.Fail); + mapper.coercionConfigFor(LogicalType.Enum) + .setCoercion(CoercionInputShape.Integer, CoercionAction.Fail); + return mapper; + } + + public static byte[] write(Object message) { + try { + return MAPPER.writeValueAsBytes(message); + } catch (JsonProcessingException e) { + throw new IllegalStateException("internal run message is not serializable: " + + message.getClass().getSimpleName()); + } + } + + public static JsonNode tree(Object message) { + return MAPPER.valueToTree(message); + } + + public static T read(byte[] body, Class type) { + try { + return MAPPER.readValue(body, type); + } catch (IOException e) { + throw sanitized(type, e); + } + } + + public static T read(String body, Class type) { + try { + return MAPPER.readValue(body, type); + } catch (IOException e) { + throw sanitized(type, e); + } + } + + public static T read(JsonNode body, Class type) { + try { + return MAPPER.treeToValue(body, type); + } catch (IOException | IllegalArgumentException e) { + throw sanitized(type, e); + } + } + + /** 원문 JSON을 tree로만 읽는다(중복 key·뒤따르는 token 거부). */ + public static JsonNode readTree(String body) { + try { + return MAPPER.readTree(body); + } catch (IOException e) { + throw new InternalRunContractViolation("malformed JSON"); + } + } + + /** + * Jackson 오류를 값 없는 message로 바꾼다. 우리 검증 오류(필드 이름만 담음)는 그대로 쓰고 cause는 붙이지 않는다 + * (Jackson message에는 입력 값이 들어갈 수 있다). + */ + private static InternalRunContractViolation sanitized(Class type, Exception error) { + String target = type.getSimpleName(); + Throwable cause = error instanceof ValueInstantiationException ? error.getCause() : error; + if (cause instanceof InternalRunContractViolation violation) { + return new InternalRunContractViolation(target + ": " + violation.getMessage()); + } + String kind; + if (error instanceof UnrecognizedPropertyException) { + kind = "unknown field"; + } else if (error instanceof InvalidFormatException) { + kind = "invalid value"; + } else if (error instanceof MismatchedInputException) { + kind = "type mismatch or missing field"; + } else if (error instanceof JsonProcessingException processing && processing.getLocation() != null) { + kind = "malformed JSON"; + } else { + kind = "invalid value"; + } + return new InternalRunContractViolation(target + ": " + kind); + } +} diff --git a/src/main/java/com/proovy/domain/run/brain/RetrievalScope.java b/src/main/java/com/proovy/domain/run/brain/RetrievalScope.java new file mode 100644 index 00000000..e831807b --- /dev/null +++ b/src/main/java/com/proovy/domain/run/brain/RetrievalScope.java @@ -0,0 +1,21 @@ +package com.proovy.domain.run.brain; + +import static com.proovy.domain.run.brain.InternalRunContract.opaqueId; +import static com.proovy.domain.run.brain.InternalRunContract.versionLabel; + +/** run 생성 시 고정한 corpus 범위. note_id는 요청 Note와 같아야 한다. */ +public record RetrievalScope( + String noteId, + String corpusVersion, + String searchExtractionVersion, + String chunkingVersion, + String embeddingVersion) { + + public RetrievalScope { + noteId = opaqueId(noteId, "retrieval_scope.note_id"); + corpusVersion = opaqueId(corpusVersion, "retrieval_scope.corpus_version"); + searchExtractionVersion = versionLabel(searchExtractionVersion, "retrieval_scope.search_extraction_version"); + chunkingVersion = versionLabel(chunkingVersion, "retrieval_scope.chunking_version"); + embeddingVersion = versionLabel(embeddingVersion, "retrieval_scope.embedding_version"); + } +} diff --git a/src/main/java/com/proovy/domain/run/brain/RunCancelAck.java b/src/main/java/com/proovy/domain/run/brain/RunCancelAck.java new file mode 100644 index 00000000..a57be1d4 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/brain/RunCancelAck.java @@ -0,0 +1,19 @@ +package com.proovy.domain.run.brain; + +import static com.proovy.domain.run.brain.InternalRunContract.CONTRACT_VERSION; +import static com.proovy.domain.run.brain.InternalRunContract.opaqueId; +import static com.proovy.domain.run.brain.InternalRunContract.require; +import static com.proovy.domain.run.brain.InternalRunContract.violation; + +/** cancel acknowledgment. 정리(wind-down)는 Brain 쪽에서 계속되고 Server는 이후 status로 확인한다. */ +public record RunCancelAck(String contractVersion, String runId, CancelOutcome outcome, RunRecordState state) { + + public RunCancelAck { + if (!CONTRACT_VERSION.equals(contractVersion)) { + throw violation("contract_version", "must be " + CONTRACT_VERSION); + } + runId = opaqueId(runId, "run_id"); + require(outcome, "outcome"); + require(state, "state"); + } +} diff --git a/src/main/java/com/proovy/domain/run/brain/RunCancelRequest.java b/src/main/java/com/proovy/domain/run/brain/RunCancelRequest.java new file mode 100644 index 00000000..2e7ee1af --- /dev/null +++ b/src/main/java/com/proovy/domain/run/brain/RunCancelRequest.java @@ -0,0 +1,29 @@ +package com.proovy.domain.run.brain; + +import static com.proovy.domain.run.brain.InternalRunContract.CONTRACT_VERSION; +import static com.proovy.domain.run.brain.InternalRunContract.atLeast; +import static com.proovy.domain.run.brain.InternalRunContract.opaqueId; +import static com.proovy.domain.run.brain.InternalRunContract.require; +import static com.proovy.domain.run.brain.InternalRunContract.violation; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.proovy.domain.run.entity.RunCancelCause; + +/** {@code POST /internal/v1/runs/{run_id}/cancel} — 현재 fence의 명시적 취소. */ +public record RunCancelRequest(String contractVersion, String runId, @JsonProperty(required = true) int attempt, String fenceToken, + RunCancelCause cause) { + + public RunCancelRequest { + if (!CONTRACT_VERSION.equals(contractVersion)) { + throw violation("contract_version", "must be " + CONTRACT_VERSION); + } + runId = opaqueId(runId, "run_id"); + atLeast(attempt, 1, "attempt"); + fenceToken = InternalRunContract.fenceToken(fenceToken, "fence_token"); + require(cause, "cause"); + } + + public static RunCancelRequest of(String runId, int attempt, String fenceToken, RunCancelCause cause) { + return new RunCancelRequest(CONTRACT_VERSION, runId, attempt, fenceToken, cause); + } +} diff --git a/src/main/java/com/proovy/domain/run/brain/RunDecision.java b/src/main/java/com/proovy/domain/run/brain/RunDecision.java new file mode 100644 index 00000000..b4b16ba5 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/brain/RunDecision.java @@ -0,0 +1,29 @@ +package com.proovy.domain.run.brain; + +import com.fasterxml.jackson.annotation.JsonValue; +import com.proovy.domain.run.entity.WireValue; + +/** {@link RunDecisions} 판정 결과(Brain {@code decide_run_request}). */ +public enum RunDecision implements WireValue { + START("start"), + OBSERVE("observe"), + RETURN_TERMINAL("return_terminal"), + REPORT("report"), + ACCEPT_CANCEL("accept_cancel"), + ACK_CANCEL("ack_cancel"), + CLEAN("clean"), + NOOP("noop"), + REJECT("reject"); + + private final String wireValue; + + RunDecision(String wireValue) { + this.wireValue = wireValue; + } + + @Override + @JsonValue + public String wireValue() { + return wireValue; + } +} diff --git a/src/main/java/com/proovy/domain/run/brain/RunDecisions.java b/src/main/java/com/proovy/domain/run/brain/RunDecisions.java new file mode 100644 index 00000000..a0121663 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/brain/RunDecisions.java @@ -0,0 +1,51 @@ +package com.proovy.domain.run.brain; + +/** + * Brain {@code decide_run_request} contention 표(run-decisions-v2)의 Java 사본. Brain이 판정하고 Server는 + * 응답 해석과 coordinator 판단에 같은 표를 쓴다. + *
+ * | operation ↓ / record → | ABSENT | EXECUTING     | CANCELING  | TERMINAL        |
+ * | stream (fp match)      | START  | OBSERVE       | REJECT     | RETURN_TERMINAL |
+ * | stream (fp differs)    | START  | REJECT        | REJECT     | REJECT          |
+ * | status                 | REPORT | REPORT        | REPORT     | REPORT          |
+ * | cancel                 | REJECT | ACCEPT_CANCEL | ACK_CANCEL | REJECT          |
+ * | cleanup                | NOOP   | REJECT        | REJECT     | CLEAN           |
+ * 
+ * binding override: 다른 run_id에 묶인 request_id의 stream은 표보다 먼저 REJECT + * ({@code request_id_bound_to_other_run}, 라이브 record·cleanup tombstone 모두). + */ +public final class RunDecisions { + + public static final String REQUEST_ID_BOUND_TO_OTHER_RUN = "request_id_bound_to_other_run"; + + private RunDecisions() { + } + + public static RunDecision decide(RunOperation operation, RunRecordState state, boolean fingerprintMatch) { + return switch (operation) { + case STREAM -> switch (state) { + case ABSENT -> RunDecision.START; + case EXECUTING -> fingerprintMatch ? RunDecision.OBSERVE : RunDecision.REJECT; + case TERMINAL -> fingerprintMatch ? RunDecision.RETURN_TERMINAL : RunDecision.REJECT; + case CANCELING -> RunDecision.REJECT; + }; + case STATUS -> RunDecision.REPORT; + case CANCEL -> switch (state) { + case EXECUTING -> RunDecision.ACCEPT_CANCEL; + case CANCELING -> RunDecision.ACK_CANCEL; + case ABSENT, TERMINAL -> RunDecision.REJECT; + }; + case CLEANUP -> switch (state) { + case TERMINAL -> RunDecision.CLEAN; + case ABSENT -> RunDecision.NOOP; + case EXECUTING, CANCELING -> RunDecision.REJECT; + }; + }; + } + + /** request_id가 다른 run에 묶였는지 먼저 본 뒤 표를 적용한다. */ + public static RunDecision decideStream(boolean requestIdBoundToOtherRun, RunRecordState state, + boolean fingerprintMatch) { + return requestIdBoundToOtherRun ? RunDecision.REJECT : decide(RunOperation.STREAM, state, fingerprintMatch); + } +} diff --git a/src/main/java/com/proovy/domain/run/brain/RunInput.java b/src/main/java/com/proovy/domain/run/brain/RunInput.java new file mode 100644 index 00000000..8270e1a9 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/brain/RunInput.java @@ -0,0 +1,38 @@ +package com.proovy.domain.run.brain; + +import static com.proovy.domain.run.brain.InternalRunContract.LOCALE; +import static com.proovy.domain.run.brain.InternalRunContract.MAX_REQUESTED_VALUES; +import static com.proovy.domain.run.brain.InternalRunContract.MAX_REQUEST_LATEX_LENGTH; +import static com.proovy.domain.run.brain.InternalRunContract.MAX_REQUEST_TEXT_LENGTH; +import static com.proovy.domain.run.brain.InternalRunContract.list; +import static com.proovy.domain.run.brain.InternalRunContract.matching; +import static com.proovy.domain.run.brain.InternalRunContract.optionalText; +import static com.proovy.domain.run.brain.InternalRunContract.unique; + +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; +import com.proovy.domain.run.contract.RequestedOutput; +import com.proovy.domain.run.contract.RequestedTask; +import java.util.List; +import java.util.function.Function; + +/** + * 이번 turn의 의미 입력. requested 배열은 Server가 정규화한 값이고(중복 없음), Brain은 기본값을 채우지 않는다. + */ +public record RunInput( + String text, + String latex, + @JsonSetter(nulls = Nulls.FAIL) List requestedTasks, + @JsonSetter(nulls = Nulls.FAIL) List requestedOutputs, + String locale) { + + public RunInput { + text = InternalRunContract.text(text, "input.text", 1, MAX_REQUEST_TEXT_LENGTH); + latex = optionalText(latex, "input.latex", 1, MAX_REQUEST_LATEX_LENGTH); + requestedTasks = list(requestedTasks, MAX_REQUESTED_VALUES, "input.requested_tasks"); + requestedOutputs = list(requestedOutputs, MAX_REQUESTED_VALUES, "input.requested_outputs"); + unique(requestedTasks, Function.identity(), "input.requested_tasks"); + unique(requestedOutputs, Function.identity(), "input.requested_outputs"); + locale = matching(locale, "input.locale", LOCALE, 35); + } +} diff --git a/src/main/java/com/proovy/domain/run/brain/RunOperation.java b/src/main/java/com/proovy/domain/run/brain/RunOperation.java new file mode 100644 index 00000000..d9fdc3e1 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/brain/RunOperation.java @@ -0,0 +1,24 @@ +package com.proovy.domain.run.brain; + +import com.fasterxml.jackson.annotation.JsonValue; +import com.proovy.domain.run.entity.WireValue; + +/** Brain runtime record에 도착하는 operation. */ +public enum RunOperation implements WireValue { + STREAM("stream"), + STATUS("status"), + CANCEL("cancel"), + CLEANUP("cleanup"); + + private final String wireValue; + + RunOperation(String wireValue) { + this.wireValue = wireValue; + } + + @Override + @JsonValue + public String wireValue() { + return wireValue; + } +} diff --git a/src/main/java/com/proovy/domain/run/brain/RunRecordState.java b/src/main/java/com/proovy/domain/run/brain/RunRecordState.java new file mode 100644 index 00000000..1743616a --- /dev/null +++ b/src/main/java/com/proovy/domain/run/brain/RunRecordState.java @@ -0,0 +1,24 @@ +package com.proovy.domain.run.brain; + +import com.fasterxml.jackson.annotation.JsonValue; +import com.proovy.domain.run.entity.WireValue; + +/** Brain runtime record 상태. status·cancel·cleanup 응답과 409 conflict의 state. */ +public enum RunRecordState implements WireValue { + ABSENT("absent"), + EXECUTING("executing"), + CANCELING("canceling"), + TERMINAL("terminal"); + + private final String wireValue; + + RunRecordState(String wireValue) { + this.wireValue = wireValue; + } + + @Override + @JsonValue + public String wireValue() { + return wireValue; + } +} diff --git a/src/main/java/com/proovy/domain/run/brain/RunStatusQuery.java b/src/main/java/com/proovy/domain/run/brain/RunStatusQuery.java new file mode 100644 index 00000000..76ab3253 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/brain/RunStatusQuery.java @@ -0,0 +1,28 @@ +package com.proovy.domain.run.brain; + +import static com.proovy.domain.run.brain.InternalRunContract.CONTRACT_VERSION; +import static com.proovy.domain.run.brain.InternalRunContract.atLeast; +import static com.proovy.domain.run.brain.InternalRunContract.opaqueId; +import static com.proovy.domain.run.brain.InternalRunContract.violation; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * {@code GET /internal/v1/runs/{run_id}?attempt=&fence_token=} 조회. 관찰 전용이라 요청 본문이 없고 + * 실행을 시작하거나 attempt를 만들지 못한다. Server가 현재로 보는 attempt·fence를 함께 보낸다. + */ +public record RunStatusQuery(String contractVersion, String runId, @JsonProperty(required = true) int attempt, String fenceToken) { + + public RunStatusQuery { + if (!CONTRACT_VERSION.equals(contractVersion)) { + throw violation("contract_version", "must be " + CONTRACT_VERSION); + } + runId = opaqueId(runId, "run_id"); + atLeast(attempt, 1, "attempt"); + fenceToken = InternalRunContract.fenceToken(fenceToken, "fence_token"); + } + + public static RunStatusQuery of(String runId, int attempt, String fenceToken) { + return new RunStatusQuery(CONTRACT_VERSION, runId, attempt, fenceToken); + } +} diff --git a/src/main/java/com/proovy/domain/run/brain/RunStatusResponse.java b/src/main/java/com/proovy/domain/run/brain/RunStatusResponse.java new file mode 100644 index 00000000..7a297e0f --- /dev/null +++ b/src/main/java/com/proovy/domain/run/brain/RunStatusResponse.java @@ -0,0 +1,59 @@ +package com.proovy.domain.run.brain; + +import static com.proovy.domain.run.brain.InternalRunContract.CONTRACT_VERSION; +import static com.proovy.domain.run.brain.InternalRunContract.SHA256_HEX; +import static com.proovy.domain.run.brain.InternalRunContract.matching; +import static com.proovy.domain.run.brain.InternalRunContract.opaqueId; +import static com.proovy.domain.run.brain.InternalRunContract.require; +import static com.proovy.domain.run.brain.InternalRunContract.violation; + +/** + * status 응답. absent면 실행 사실이 없고, 그 밖에는 record의 attempt·fence·fingerprint를 담으며 + * terminal state일 때만 저장 terminal을 재전달한다(재계산 없음). + */ +public record RunStatusResponse( + String contractVersion, + String runId, + RunRecordState state, + Integer attempt, + String fenceToken, + String requestFingerprint, + StoredTerminal terminal) { + + public RunStatusResponse { + if (!CONTRACT_VERSION.equals(contractVersion)) { + throw violation("contract_version", "must be " + CONTRACT_VERSION); + } + runId = opaqueId(runId, "run_id"); + require(state, "state"); + if (attempt != null && attempt < 1) { + throw violation("attempt", "is out of range"); + } + if (fenceToken != null) { + fenceToken = InternalRunContract.fenceToken(fenceToken, "fence_token"); + } + if (requestFingerprint != null) { + requestFingerprint = matching(requestFingerprint, "request_fingerprint", SHA256_HEX, 64); + } + if (state == RunRecordState.ABSENT) { + if (attempt != null || fenceToken != null || requestFingerprint != null || terminal != null) { + throw violation("state", "absent record carries no execution facts"); + } + } else { + if (attempt == null || fenceToken == null || requestFingerprint == null) { + throw violation("state", "live record names its attempt, fence and fingerprint"); + } + if ((state == RunRecordState.TERMINAL) != (terminal != null)) { + throw violation("terminal", "is present exactly for a terminal record"); + } + } + if (terminal != null && !terminal.runId().equals(runId)) { + throw violation("terminal.run_id", "must equal run_id"); + } + } + + /** 이 record가 {@code fingerprint}의 요청으로 만들어졌는지. absent면 false. */ + public boolean matches(InternalRunFingerprint fingerprint) { + return fingerprint.sha256().equals(requestFingerprint); + } +} diff --git a/src/main/java/com/proovy/domain/run/brain/RuntimeCleanupRequest.java b/src/main/java/com/proovy/domain/run/brain/RuntimeCleanupRequest.java new file mode 100644 index 00000000..0e3dfa32 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/brain/RuntimeCleanupRequest.java @@ -0,0 +1,23 @@ +package com.proovy.domain.run.brain; + +import static com.proovy.domain.run.brain.InternalRunContract.CONTRACT_VERSION; +import static com.proovy.domain.run.brain.InternalRunContract.opaqueId; +import static com.proovy.domain.run.brain.InternalRunContract.violation; + +/** + * {@code POST /internal/v1/runs/{run_id}/cleanup}. Server가 terminal을 저장했다고 확인한 뒤에만 보낸다 + * (이 호출 자체가 저장 확인으로 기록된다). 멱등이고 활성 실행은 protected로 남는다. + */ +public record RuntimeCleanupRequest(String contractVersion, String runId) { + + public RuntimeCleanupRequest { + if (!CONTRACT_VERSION.equals(contractVersion)) { + throw violation("contract_version", "must be " + CONTRACT_VERSION); + } + runId = opaqueId(runId, "run_id"); + } + + public static RuntimeCleanupRequest of(String runId) { + return new RuntimeCleanupRequest(CONTRACT_VERSION, runId); + } +} diff --git a/src/main/java/com/proovy/domain/run/brain/RuntimeCleanupResponse.java b/src/main/java/com/proovy/domain/run/brain/RuntimeCleanupResponse.java new file mode 100644 index 00000000..58408135 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/brain/RuntimeCleanupResponse.java @@ -0,0 +1,20 @@ +package com.proovy.domain.run.brain; + +import static com.proovy.domain.run.brain.InternalRunContract.CONTRACT_VERSION; +import static com.proovy.domain.run.brain.InternalRunContract.opaqueId; +import static com.proovy.domain.run.brain.InternalRunContract.require; +import static com.proovy.domain.run.brain.InternalRunContract.violation; + +/** cleanup 결과와 판정 시점의 record state. */ +public record RuntimeCleanupResponse(String contractVersion, String runId, CleanupOutcome outcome, + RunRecordState state) { + + public RuntimeCleanupResponse { + if (!CONTRACT_VERSION.equals(contractVersion)) { + throw violation("contract_version", "must be " + CONTRACT_VERSION); + } + runId = opaqueId(runId, "run_id"); + require(outcome, "outcome"); + require(state, "state"); + } +} diff --git a/src/main/java/com/proovy/domain/run/brain/ScopedAssetFacts.java b/src/main/java/com/proovy/domain/run/brain/ScopedAssetFacts.java new file mode 100644 index 00000000..184ffa87 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/brain/ScopedAssetFacts.java @@ -0,0 +1,48 @@ +package com.proovy.domain.run.brain; + +import static com.proovy.domain.run.brain.InternalRunContract.MAX_ASSET_BYTES; +import static com.proovy.domain.run.brain.InternalRunContract.MAX_ASSET_PAGES; +import static com.proovy.domain.run.brain.InternalRunContract.MAX_ASSET_PIXEL_COUNT; +import static com.proovy.domain.run.brain.InternalRunContract.MAX_ASSET_PIXEL_EDGE; +import static com.proovy.domain.run.brain.InternalRunContract.optionalRange; +import static com.proovy.domain.run.brain.InternalRunContract.require; +import static com.proovy.domain.run.brain.InternalRunContract.violation; + +/** + * Brain {@code _ScopedAsset}의 media 사실 검사. {@link ViewerContext}·{@link TurnAttachment}가 공유한다. + * PDF는 page_count만, 이미지는 pixel 세 값만 가지고 pixel_count = width × height다. + */ +final class ScopedAssetFacts { + + private ScopedAssetFacts() { + } + + static void check(String field, AssetMime mime, Long byteSize, Integer pageCount, + Integer pixelWidth, Integer pixelHeight, Long pixelCount) { + require(mime, field + ".mime"); + require(byteSize, field + ".byte_size"); + optionalRange(byteSize, 1, MAX_ASSET_BYTES, field + ".byte_size"); + optionalRange(pageCount, 1, MAX_ASSET_PAGES, field + ".page_count"); + optionalRange(pixelWidth, 1, MAX_ASSET_PIXEL_EDGE, field + ".pixel_width"); + optionalRange(pixelHeight, 1, MAX_ASSET_PIXEL_EDGE, field + ".pixel_height"); + optionalRange(pixelCount, 1, MAX_ASSET_PIXEL_COUNT, field + ".pixel_count"); + if (mime.isPdf()) { + if (pageCount == null) { + throw violation(field + ".page_count", "is required for a pdf"); + } + if (pixelWidth != null || pixelHeight != null || pixelCount != null) { + throw violation(field, "pdf carries no pixel dimensions"); + } + return; + } + if (pageCount != null) { + throw violation(field + ".page_count", "is not allowed for an image"); + } + if (pixelWidth == null || pixelHeight == null || pixelCount == null) { + throw violation(field, "image requires pixel dimensions"); + } + if (pixelCount != (long) pixelWidth * pixelHeight) { + throw violation(field + ".pixel_count", "must equal width * height"); + } + } +} diff --git a/src/main/java/com/proovy/domain/run/brain/SolutionReferenceStatus.java b/src/main/java/com/proovy/domain/run/brain/SolutionReferenceStatus.java new file mode 100644 index 00000000..f2990c7a --- /dev/null +++ b/src/main/java/com/proovy/domain/run/brain/SolutionReferenceStatus.java @@ -0,0 +1,22 @@ +package com.proovy.domain.run.brain; + +import com.fasterxml.jackson.annotation.JsonValue; +import com.proovy.domain.run.entity.WireValue; + +/** Verified Solution 참조의 수명주기 상태(fingerprint 제외). */ +public enum SolutionReferenceStatus implements WireValue { + ACTIVE("active"), + SUPERSEDED("superseded"); + + private final String wireValue; + + SolutionReferenceStatus(String wireValue) { + this.wireValue = wireValue; + } + + @Override + @JsonValue + public String wireValue() { + return wireValue; + } +} diff --git a/src/main/java/com/proovy/domain/run/brain/SourceEnvelope.java b/src/main/java/com/proovy/domain/run/brain/SourceEnvelope.java new file mode 100644 index 00000000..724dc205 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/brain/SourceEnvelope.java @@ -0,0 +1,53 @@ +package com.proovy.domain.run.brain; + +import static com.proovy.domain.run.brain.InternalRunContract.MAX_SOURCE_EVENT_ID_LENGTH; +import static com.proovy.domain.run.brain.InternalRunContract.SOURCE_CONTRACT_VERSION; +import static com.proovy.domain.run.brain.InternalRunContract.SOURCE_EVENT_ID; +import static com.proovy.domain.run.brain.InternalRunContract.atLeast; +import static com.proovy.domain.run.brain.InternalRunContract.fenceToken; +import static com.proovy.domain.run.brain.InternalRunContract.matching; +import static com.proovy.domain.run.brain.InternalRunContract.opaqueId; +import static com.proovy.domain.run.brain.InternalRunContract.require; +import static com.proovy.domain.run.brain.InternalRunContract.violation; + +import com.fasterxml.jackson.databind.JsonNode; +import java.time.OffsetDateTime; + +/** brain-source-v1 envelope 공통 검사({@link SourceEvent}·{@link StoredTerminal}). */ +final class SourceEnvelope { + + private SourceEnvelope() { + } + + static void checkVersion(String contractVersion) { + if (!SOURCE_CONTRACT_VERSION.equals(contractVersion)) { + throw violation("contract_version", "must be " + SOURCE_CONTRACT_VERSION); + } + } + + static String runId(String runId) { + return opaqueId(runId, "run_id"); + } + + static void attempt(int attempt) { + atLeast(attempt, 1, "attempt"); + } + + static String fence(String fenceToken) { + return fenceToken(fenceToken, "fence_token"); + } + + static String sourceEventId(String sourceEventId) { + return matching(sourceEventId, "source_event_id", SOURCE_EVENT_ID, MAX_SOURCE_EVENT_ID_LENGTH); + } + + static void occurredAt(OffsetDateTime occurredAt) { + require(occurredAt, "occurred_at"); + } + + static void payload(JsonNode payload) { + if (payload == null || !payload.isObject()) { + throw violation("payload", "must be an object"); + } + } +} diff --git a/src/main/java/com/proovy/domain/run/brain/SourceEvent.java b/src/main/java/com/proovy/domain/run/brain/SourceEvent.java new file mode 100644 index 00000000..7626fc33 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/brain/SourceEvent.java @@ -0,0 +1,42 @@ +package com.proovy.domain.run.brain; + +import static com.proovy.domain.run.brain.InternalRunContract.require; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; +import java.time.OffsetDateTime; + +/** + * stream으로 받은 brain-source-v1 event({@code SourceEvent} union: {@code activity.*}·{@code run.completed}· + * {@code run.failed}). 저장 전용 {@code run.canceled}는 type 단계에서 거부된다({@link SourceEventType}). + *

+ * envelope는 typed로 검사하고, Brain 소유 payload(activity·terminal 본문)는 JSON 그대로 둔다. + * payload 의미 검사(terminal provenance·activity 규칙)와 현재 fence 판정은 소비자(1.80·1.81·1.83)의 몫이다. + * attempt·fence는 요청과 다를 수 있다 — Brain tail은 새 attempt가 record를 넘겨받으면 그 epoch를 따라간다. + */ +public record SourceEvent( + String contractVersion, + String runId, + @JsonProperty(required = true) int attempt, + String fenceToken, + String sourceEventId, + @JsonFormat(shape = JsonFormat.Shape.STRING) OffsetDateTime occurredAt, + SourceEventType type, + JsonNode payload) { + + public SourceEvent { + SourceEnvelope.checkVersion(contractVersion); + runId = SourceEnvelope.runId(runId); + SourceEnvelope.attempt(attempt); + fenceToken = SourceEnvelope.fence(fenceToken); + sourceEventId = SourceEnvelope.sourceEventId(sourceEventId); + SourceEnvelope.occurredAt(occurredAt); + require(type, "type"); + SourceEnvelope.payload(payload); + } + + public boolean isTerminal() { + return type.isTerminal(); + } +} diff --git a/src/main/java/com/proovy/domain/run/brain/SourceEventType.java b/src/main/java/com/proovy/domain/run/brain/SourceEventType.java new file mode 100644 index 00000000..e23afa65 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/brain/SourceEventType.java @@ -0,0 +1,34 @@ +package com.proovy.domain.run.brain; + +import com.fasterxml.jackson.annotation.JsonValue; +import com.proovy.domain.run.entity.WireValue; + +/** + * brain-source-v1 {@code SourceEvent} union의 type. stream으로 오는 값은 이것뿐이다. + * {@code run.canceled}는 저장 전용 variant라 여기 없고 {@link StoredTerminalType}에만 있다. + */ +public enum SourceEventType implements WireValue { + ACTIVITY_STARTED("activity.started"), + ACTIVITY_UPDATED("activity.updated"), + ACTIVITY_COMPLETED("activity.completed"), + ACTIVITY_FAILED("activity.failed"), + ACTIVITY_CANCELED("activity.canceled"), + RUN_COMPLETED("run.completed"), + RUN_FAILED("run.failed"); + + private final String wireValue; + + SourceEventType(String wireValue) { + this.wireValue = wireValue; + } + + public boolean isTerminal() { + return this == RUN_COMPLETED || this == RUN_FAILED; + } + + @Override + @JsonValue + public String wireValue() { + return wireValue; + } +} diff --git a/src/main/java/com/proovy/domain/run/brain/StoredTerminal.java b/src/main/java/com/proovy/domain/run/brain/StoredTerminal.java new file mode 100644 index 00000000..6b2f06eb --- /dev/null +++ b/src/main/java/com/proovy/domain/run/brain/StoredTerminal.java @@ -0,0 +1,60 @@ +package com.proovy.domain.run.brain; + +import static com.proovy.domain.run.brain.InternalRunContract.require; +import static com.proovy.domain.run.brain.InternalRunContract.violation; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; +import com.proovy.domain.run.entity.RunCancelCause; +import java.time.OffsetDateTime; + +/** + * status 응답의 저장 terminal({@code run.completed}·{@code run.failed}·저장 전용 {@code run.canceled}). + * stream union과 달리 {@code run.canceled}를 허용하고, 그 payload는 {@code {"cause": ...}}만 가진다. + * completed·failed payload는 JSON 그대로 두고 의미 검사는 terminal 소비자(1.81)가 한다. + */ +public record StoredTerminal( + String contractVersion, + String runId, + @JsonProperty(required = true) int attempt, + String fenceToken, + String sourceEventId, + @JsonFormat(shape = JsonFormat.Shape.STRING) OffsetDateTime occurredAt, + StoredTerminalType type, + JsonNode payload) { + + public StoredTerminal { + SourceEnvelope.checkVersion(contractVersion); + runId = SourceEnvelope.runId(runId); + SourceEnvelope.attempt(attempt); + fenceToken = SourceEnvelope.fence(fenceToken); + sourceEventId = SourceEnvelope.sourceEventId(sourceEventId); + SourceEnvelope.occurredAt(occurredAt); + require(type, "type"); + SourceEnvelope.payload(payload); + if (type == StoredTerminalType.RUN_CANCELED) { + if (payload.size() != 1 || cancelCause(payload) == null) { + throw violation("payload", "run.canceled carries exactly a known cause"); + } + } + } + + /** {@code run.canceled}의 원인. 다른 type이면 null. */ + public RunCancelCause canceledCause() { + return type == StoredTerminalType.RUN_CANCELED ? cancelCause(payload) : null; + } + + private static RunCancelCause cancelCause(JsonNode payload) { + JsonNode cause = payload.get("cause"); + if (cause == null || !cause.isTextual()) { + return null; + } + for (RunCancelCause candidate : RunCancelCause.values()) { + if (candidate.wireValue().equals(cause.textValue())) { + return candidate; + } + } + return null; + } +} diff --git a/src/main/java/com/proovy/domain/run/brain/StoredTerminalType.java b/src/main/java/com/proovy/domain/run/brain/StoredTerminalType.java new file mode 100644 index 00000000..59eb9bbd --- /dev/null +++ b/src/main/java/com/proovy/domain/run/brain/StoredTerminalType.java @@ -0,0 +1,25 @@ +package com.proovy.domain.run.brain; + +import com.fasterxml.jackson.annotation.JsonValue; +import com.proovy.domain.run.entity.WireValue; + +/** + * status 응답 {@code terminal}의 type. stream union과 달리 저장 전용 {@code run.canceled}를 포함한다. + */ +public enum StoredTerminalType implements WireValue { + RUN_COMPLETED("run.completed"), + RUN_FAILED("run.failed"), + RUN_CANCELED("run.canceled"); + + private final String wireValue; + + StoredTerminalType(String wireValue) { + this.wireValue = wireValue; + } + + @Override + @JsonValue + public String wireValue() { + return wireValue; + } +} diff --git a/src/main/java/com/proovy/domain/run/brain/SummaryCoverage.java b/src/main/java/com/proovy/domain/run/brain/SummaryCoverage.java new file mode 100644 index 00000000..1ba42440 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/brain/SummaryCoverage.java @@ -0,0 +1,22 @@ +package com.proovy.domain.run.brain; + +import com.fasterxml.jackson.annotation.JsonValue; +import com.proovy.domain.run.entity.WireValue; + +/** 참조 claim의 coverage. */ +public enum SummaryCoverage implements WireValue { + DIRECT("direct"), + SUPPORTING("supporting"); + + private final String wireValue; + + SummaryCoverage(String wireValue) { + this.wireValue = wireValue; + } + + @Override + @JsonValue + public String wireValue() { + return wireValue; + } +} diff --git a/src/main/java/com/proovy/domain/run/brain/TurnAttachment.java b/src/main/java/com/proovy/domain/run/brain/TurnAttachment.java new file mode 100644 index 00000000..79b743e1 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/brain/TurnAttachment.java @@ -0,0 +1,47 @@ +package com.proovy.domain.run.brain; + +import static com.proovy.domain.run.brain.InternalRunContract.MAX_READ_URL_CHARS; +import static com.proovy.domain.run.brain.InternalRunContract.opaqueId; +import static com.proovy.domain.run.brain.InternalRunContract.text; +import static com.proovy.domain.run.brain.InternalRunContract.versionLabel; + +/** + * 이번 turn의 직접 입력 첨부. {@code readUrl}은 필수지만 fingerprint·로그·{@link #toString()}에서 제외한다. + */ +public record TurnAttachment( + String attachmentId, + String sha256, + AssetMime mime, + Long byteSize, + Integer pageCount, + Integer pixelWidth, + Integer pixelHeight, + Long pixelCount, + String uploadPolicyVersion, + String pageUnderstandingVersion, + String imageNormalizationVersion, + String readUrl) { + + public TurnAttachment { + attachmentId = opaqueId(attachmentId, "turn_attachments.attachment_id"); + sha256 = InternalRunContract.sha256(sha256, "turn_attachments.sha256"); + ScopedAssetFacts.check("turn_attachments", mime, byteSize, pageCount, pixelWidth, pixelHeight, pixelCount); + uploadPolicyVersion = versionLabel(uploadPolicyVersion, "turn_attachments.upload_policy_version"); + pageUnderstandingVersion = versionLabel(pageUnderstandingVersion, + "turn_attachments.page_understanding_version"); + imageNormalizationVersion = versionLabel(imageNormalizationVersion, + "turn_attachments.image_normalization_version"); + readUrl = text(readUrl, "turn_attachments.read_url", 1, MAX_READ_URL_CHARS); + } + + /** URL을 바꾼 사본(재전달 시 짧은 read URL 재발급). 의미 필드는 그대로다. */ + public TurnAttachment withReadUrl(String newReadUrl) { + return new TurnAttachment(attachmentId, sha256, mime, byteSize, pageCount, pixelWidth, pixelHeight, + pixelCount, uploadPolicyVersion, pageUnderstandingVersion, imageNormalizationVersion, newReadUrl); + } + + @Override + public String toString() { + return "TurnAttachment[attachmentId=" + attachmentId + ", mime=" + mime + ", readUrl=]"; + } +} diff --git a/src/main/java/com/proovy/domain/run/brain/TurnAttachmentRef.java b/src/main/java/com/proovy/domain/run/brain/TurnAttachmentRef.java new file mode 100644 index 00000000..7bc8ebff --- /dev/null +++ b/src/main/java/com/proovy/domain/run/brain/TurnAttachmentRef.java @@ -0,0 +1,30 @@ +package com.proovy.domain.run.brain; + +import static com.proovy.domain.run.brain.InternalRunContract.MAX_DISPLAY_LABEL_CHARS; +import static com.proovy.domain.run.brain.InternalRunContract.opaqueId; +import static com.proovy.domain.run.brain.InternalRunContract.optionalText; +import static com.proovy.domain.run.brain.InternalRunContract.require; +import static com.proovy.domain.run.brain.InternalRunContract.versionLabel; + +/** 이전 turn Turn Attachment 참조. signed URL·원본 binary·전사본은 담지 않는다. */ +public record TurnAttachmentRef( + String attachmentId, + String sourceMessageId, + String sha256, + AssetMime mime, + String pageUnderstandingVersion, + String imageNormalizationVersion, + String displayLabel) { + + public TurnAttachmentRef { + attachmentId = opaqueId(attachmentId, "turn_attachment_refs.attachment_id"); + sourceMessageId = opaqueId(sourceMessageId, "turn_attachment_refs.source_message_id"); + sha256 = InternalRunContract.sha256(sha256, "turn_attachment_refs.sha256"); + require(mime, "turn_attachment_refs.mime"); + pageUnderstandingVersion = versionLabel(pageUnderstandingVersion, + "turn_attachment_refs.page_understanding_version"); + imageNormalizationVersion = versionLabel(imageNormalizationVersion, + "turn_attachment_refs.image_normalization_version"); + displayLabel = optionalText(displayLabel, "turn_attachment_refs.display_label", 1, MAX_DISPLAY_LABEL_CHARS); + } +} diff --git a/src/main/java/com/proovy/domain/run/brain/VerifiedSolutionRef.java b/src/main/java/com/proovy/domain/run/brain/VerifiedSolutionRef.java new file mode 100644 index 00000000..380ac833 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/brain/VerifiedSolutionRef.java @@ -0,0 +1,49 @@ +package com.proovy.domain.run.brain; + +import static com.proovy.domain.run.brain.InternalRunContract.MAX_IDENTIFIER_LENGTH; +import static com.proovy.domain.run.brain.InternalRunContract.MAX_PROBLEM_SUMMARY_CHARS; +import static com.proovy.domain.run.brain.InternalRunContract.MAX_REF_CLAIMS; +import static com.proovy.domain.run.brain.InternalRunContract.SOLUTION_ID; +import static com.proovy.domain.run.brain.InternalRunContract.list; +import static com.proovy.domain.run.brain.InternalRunContract.matching; +import static com.proovy.domain.run.brain.InternalRunContract.opaqueId; +import static com.proovy.domain.run.brain.InternalRunContract.require; +import static com.proovy.domain.run.brain.InternalRunContract.text; +import static com.proovy.domain.run.brain.InternalRunContract.unique; +import static com.proovy.domain.run.brain.InternalRunContract.violation; + +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; +import java.util.List; + +/** + * Server가 검증한 기존 Verified Solution 참조. fingerprint는 problem_summary·claims만 해시하고 + * status·revises_solution_id·source_message_id(수명주기 기록)는 제외한다. + */ +public record VerifiedSolutionRef( + String solutionId, + String sourceMessageId, + SolutionReferenceStatus status, + String revisesSolutionId, + String problemSummary, + @JsonSetter(nulls = Nulls.FAIL) List claims) { + + public VerifiedSolutionRef { + solutionId = matching(solutionId, "verified_solution_refs.solution_id", SOLUTION_ID, MAX_IDENTIFIER_LENGTH); + sourceMessageId = opaqueId(sourceMessageId, "verified_solution_refs.source_message_id"); + require(status, "verified_solution_refs.status"); + if (revisesSolutionId != null) { + revisesSolutionId = matching(revisesSolutionId, "verified_solution_refs.revises_solution_id", + SOLUTION_ID, MAX_IDENTIFIER_LENGTH); + } + problemSummary = text(problemSummary, "verified_solution_refs.problem_summary", 1, MAX_PROBLEM_SUMMARY_CHARS); + claims = list(claims, MAX_REF_CLAIMS, "verified_solution_refs.claims"); + if (claims.isEmpty()) { + throw violation("verified_solution_refs.claims", "must not be empty"); + } + unique(claims, VerifiedSolutionRefClaim::claimId, "verified_solution_refs.claims.claim_id"); + if (solutionId.equals(revisesSolutionId)) { + throw violation("verified_solution_refs.revises_solution_id", "cannot name the reference itself"); + } + } +} diff --git a/src/main/java/com/proovy/domain/run/brain/VerifiedSolutionRefClaim.java b/src/main/java/com/proovy/domain/run/brain/VerifiedSolutionRefClaim.java new file mode 100644 index 00000000..6207502e --- /dev/null +++ b/src/main/java/com/proovy/domain/run/brain/VerifiedSolutionRefClaim.java @@ -0,0 +1,18 @@ +package com.proovy.domain.run.brain; + +import static com.proovy.domain.run.brain.InternalRunContract.CLAIM_ID; +import static com.proovy.domain.run.brain.InternalRunContract.MAX_CLAIM_STATEMENT_CHARS; +import static com.proovy.domain.run.brain.InternalRunContract.MAX_IDENTIFIER_LENGTH; +import static com.proovy.domain.run.brain.InternalRunContract.matching; +import static com.proovy.domain.run.brain.InternalRunContract.require; +import static com.proovy.domain.run.brain.InternalRunContract.text; + +/** 참조 Verified Solution의 claim 발췌. */ +public record VerifiedSolutionRefClaim(String claimId, String statement, SummaryCoverage coverage) { + + public VerifiedSolutionRefClaim { + claimId = matching(claimId, "claim.claim_id", CLAIM_ID, MAX_IDENTIFIER_LENGTH); + statement = text(statement, "claim.statement", 1, MAX_CLAIM_STATEMENT_CHARS); + require(coverage, "claim.coverage"); + } +} diff --git a/src/main/java/com/proovy/domain/run/brain/ViewerContext.java b/src/main/java/com/proovy/domain/run/brain/ViewerContext.java new file mode 100644 index 00000000..deae41ed --- /dev/null +++ b/src/main/java/com/proovy/domain/run/brain/ViewerContext.java @@ -0,0 +1,57 @@ +package com.proovy.domain.run.brain; + +import static com.proovy.domain.run.brain.InternalRunContract.MAX_READ_URL_CHARS; +import static com.proovy.domain.run.brain.InternalRunContract.atLeast; +import static com.proovy.domain.run.brain.InternalRunContract.opaqueId; +import static com.proovy.domain.run.brain.InternalRunContract.optionalText; +import static com.proovy.domain.run.brain.InternalRunContract.require; +import static com.proovy.domain.run.brain.InternalRunContract.versionLabel; +import static com.proovy.domain.run.brain.InternalRunContract.violation; + +/** + * 요청 전송 시점에 고정한 Viewer Focus. {@code readUrl}은 fingerprint·로그·{@link #toString()}에서 제외한다. + */ +public record ViewerContext( + String assetId, + Integer page, + ViewerSelection selection, + String sha256, + AssetMime mime, + Long byteSize, + Integer pageCount, + Integer pixelWidth, + Integer pixelHeight, + Long pixelCount, + String uploadPolicyVersion, + String pageUnderstandingVersion, + String imageNormalizationVersion, + String readUrl) { + + public ViewerContext { + assetId = opaqueId(assetId, "viewer_context.asset_id"); + atLeast(require(page, "viewer_context.page"), 1, "viewer_context.page"); + sha256 = InternalRunContract.sha256(sha256, "viewer_context.sha256"); + ScopedAssetFacts.check("viewer_context", mime, byteSize, pageCount, pixelWidth, pixelHeight, pixelCount); + uploadPolicyVersion = versionLabel(uploadPolicyVersion, "viewer_context.upload_policy_version"); + pageUnderstandingVersion = versionLabel(pageUnderstandingVersion, "viewer_context.page_understanding_version"); + imageNormalizationVersion = versionLabel(imageNormalizationVersion, + "viewer_context.image_normalization_version"); + readUrl = optionalText(readUrl, "viewer_context.read_url", 1, MAX_READ_URL_CHARS); + if (mime.isPdf() ? page > pageCount : page != 1) { + throw violation("viewer_context.page", "is outside the asset"); + } + } + + /** URL을 바꾼 사본(재전달 시 짧은 read URL 재발급). 의미 필드는 그대로다. */ + public ViewerContext withReadUrl(String newReadUrl) { + return new ViewerContext(assetId, page, selection, sha256, mime, byteSize, pageCount, pixelWidth, + pixelHeight, pixelCount, uploadPolicyVersion, pageUnderstandingVersion, imageNormalizationVersion, + newReadUrl); + } + + @Override + public String toString() { + return "ViewerContext[assetId=" + assetId + ", page=" + page + ", mime=" + mime + + ", readUrl=" + (readUrl == null ? "absent" : "") + "]"; + } +} diff --git a/src/main/java/com/proovy/domain/run/brain/ViewerSelection.java b/src/main/java/com/proovy/domain/run/brain/ViewerSelection.java new file mode 100644 index 00000000..49479d1a --- /dev/null +++ b/src/main/java/com/proovy/domain/run/brain/ViewerSelection.java @@ -0,0 +1,22 @@ +package com.proovy.domain.run.brain; + +import static com.proovy.domain.run.brain.InternalRunContract.require; +import static com.proovy.domain.run.brain.InternalRunContract.violation; + +/** page-selection-v1 정규화 좌표(표시 기준 좌상단 원점). left·top ∈ [0,1), right·bottom ∈ (0,1]. */ +public record ViewerSelection(Double left, Double top, Double right, Double bottom) { + + public ViewerSelection { + require(left, "selection.left"); + require(top, "selection.top"); + require(right, "selection.right"); + require(bottom, "selection.bottom"); + if (!(left >= 0.0 && left < 1.0) || !(top >= 0.0 && top < 1.0) + || !(right > 0.0 && right <= 1.0) || !(bottom > 0.0 && bottom <= 1.0)) { + throw violation("selection", "is out of range"); + } + if (!(left < right) || !(top < bottom)) { + throw violation("selection", "requires left < right and top < bottom"); + } + } +} diff --git a/src/main/java/com/proovy/domain/run/cancel/RunCancelConfig.java b/src/main/java/com/proovy/domain/run/cancel/RunCancelConfig.java new file mode 100644 index 00000000..3e7f526a --- /dev/null +++ b/src/main/java/com/proovy/domain/run/cancel/RunCancelConfig.java @@ -0,0 +1,9 @@ +package com.proovy.domain.run.cancel; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableConfigurationProperties(RunCancelProperties.class) +public class RunCancelConfig { +} diff --git a/src/main/java/com/proovy/domain/run/cancel/RunCancelFinalizer.java b/src/main/java/com/proovy/domain/run/cancel/RunCancelFinalizer.java new file mode 100644 index 00000000..94c54920 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/cancel/RunCancelFinalizer.java @@ -0,0 +1,367 @@ +package com.proovy.domain.run.cancel; + +import com.proovy.domain.run.brain.InternalRunContractViolation; +import com.proovy.domain.run.brain.RunCancelAck; +import com.proovy.domain.run.brain.RunCancelRequest; +import com.proovy.domain.run.cancel.RunCancelStore.Canceled; +import com.proovy.domain.run.coordinator.RunClaimStore; +import com.proovy.domain.run.coordinator.RunCoordinator; +import com.proovy.domain.run.coordinator.RunCoordinatorProperties; +import com.proovy.domain.run.entity.AiRunStatus; +import com.proovy.domain.run.entity.RunCancelCause; +import com.proovy.global.infra.brain.BrainCallException; +import com.proovy.global.infra.brain.BrainRunClient; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.context.SmartLifecycle; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.support.TransactionTemplate; + +/** + * CANCELING run의 CANCELED 확정(task 1.84, docs/plan/05·11). 취소 API commit 직후 한 번, 그 뒤 {@code sweep-interval}마다 + * DB의 CANCELING run을 다시 확인한다 — 재시작·다른 인스턴스가 받은 취소도 이어받는다. 확정은 조건부 전환이라 여러 인스턴스가 + * 겹쳐도 하나만 이긴다. + *

한 번의 확인

+ * 현재 attempt·fence로 Brain {@code cancel}을 보낸다. 멱등이라 처음이면 취소 전달, 그 뒤로는 정리 상태 조회가 된다 + * (ack {@code state}). 판정: + *
    + *
  • {@code terminal}: Brain이 정리를 끝내고 저장했다({@code run.canceled}, 또는 취소 전에 끝난 결과) → CANCELED.
  • + *
  • {@code absent}: Brain record가 없다(실행 미시작). 그 run을 아직 보낼 수 있는 worker가 없을 때만 + * (run lease 만료·이 인스턴스에서 실행 중 아님) CANCELED. 살아 있는 worker가 아직 stream을 보낼 수 있으면 기다린다 — + * 보낸 뒤 확정하면 슬롯이 풀린 채 Brain에서 실행이 돈다.
  • + *
  • {@code canceling}·{@code executing}·호출 실패: 기다린다.
  • + *
+ * 어느 경우든 {@code cancel_requested_at + cleanup-timeout}이 지나면 확인 없이 CANCELED로 확정한다(안전 timeout). 시각은 DB + * {@code now()} 기준이고, 이미 지났으면 Brain을 부르지 않으며, 남은 시간이 client request-timeout보다 짧으면 호출을 그만큼만 기다린다. + * 확정 transaction 안에서 runtime cleanup을 예약하고({@link RunClaimStore#reserveRuntimeCleanup}) commit 뒤 이 인스턴스의 worker를 + * 멈춘다({@link RunCoordinator#release}). 그 전까지 run은 비terminal이라 전역 슬롯과 Note Active Run 자리를 계속 차지한다. + *

실패 행

+ * 확인이 예외로 끝난 run은 {@code failure-backoff} 동안 주기 확인에서 빼 오래된 실패 행이 batch를 막지 않게 한다(ERROR·metric). + * 확정이 불변조건 위반(열린 assistant placeholder 없음 등)으로 계속 실패하면 운영 수동 개입 대상이다. + *

스레드

+ * 내부 executor 하나({@code runCancel-*})이고 Spring {@code Executor}·{@code TaskScheduler} bean으로 노출하지 않는다(전역 기본 bean을 + * 바꾸지 않기 위해, 1.83·1.86과 같은 관례). run마다 동시에 확인 하나만 돈다. Brain 호출은 client의 request-timeout으로 끝나고 + * 그동안 DB transaction·row lock을 쥐지 않는다. lifecycle phase는 web server보다 앞이라 요청을 받기 전에 시작하고 요청이 멈춘 뒤에 + * 멈춘다. 그 밖의 구간(executor 없음)에 들어온 {@code wake}는 버려지고 주기 확인(다른 인스턴스·재시작 포함)이 줍는다 — + * 그래서 {@code enabled=false}로만 구성한 배포에서는 그 run이 CANCELING에 남는다(점검·테스트 전용 설정). + */ +@Slf4j +@Component +public class RunCancelFinalizer implements SmartLifecycle { + + private static final int THREADS = 2; + /** web server start/stop(Boot {@code DEFAULT_PHASE - 2048})·graceful shutdown(−1024)보다 앞. */ + private static final int PHASE = SmartLifecycle.DEFAULT_PHASE - 4096; + + enum BrainView { + TERMINAL, + ABSENT, + WINDING_DOWN, + UNKNOWN + } + + /** @param untilTimeout DB {@code now()} 기준 cleanup-timeout까지 남은 시간(지났으면 0) */ + private record CancelingRun(UUID runId, int attempt, String fenceToken, RunCancelCause cause, + Duration untilTimeout, boolean leaseExpired) { + } + + private enum Confirmation { + CONFIRMED, + ALREADY_DONE, + LEASE_LIVE + } + + private final RunCancelStore store; + private final RunCancelProperties properties; + private final JdbcTemplate jdbc; + private final ObjectProvider brain; + private final ObjectProvider coordinator; + private final ObjectProvider coordinatorProperties; + private final RunClaimStore claims; + private final MeterRegistry metrics; + private final Set inFlight = ConcurrentHashMap.newKeySet(); + /** 확인이 실패한 run → 주기 확인에 다시 넣을 시각(System.nanoTime). */ + private final Map backoff = new ConcurrentHashMap<>(); + private final AtomicBoolean sweepFailing = new AtomicBoolean(); + + private volatile ScheduledThreadPoolExecutor executor; + + public RunCancelFinalizer(RunCancelStore store, RunCancelProperties properties, JdbcTemplate jdbc, + TransactionTemplate tx, ObjectProvider brain, + ObjectProvider coordinator, + ObjectProvider coordinatorProperties, + ObjectProvider claims, ObjectProvider metrics) { + this.store = store; + this.properties = properties; + this.jdbc = jdbc; + this.brain = brain; + this.coordinator = coordinator; + this.coordinatorProperties = coordinatorProperties; + // coordinator가 꺼진 인스턴스에서도 같은 공개 hook(cleanup 예약 SQL)을 쓴다. + this.claims = claims.getIfAvailable(() -> new RunClaimStore(jdbc, tx)); + this.metrics = metrics.getIfAvailable(SimpleMeterRegistry::new); + } + + // ------------------------------------------------------------------ + // lifecycle + // ------------------------------------------------------------------ + + @Override + public synchronized void start() { + if (executor != null) { + return; + } + AtomicInteger sequence = new AtomicInteger(); + ScheduledThreadPoolExecutor pool = new ScheduledThreadPoolExecutor(THREADS, runnable -> { + Thread thread = new Thread(runnable, "runCancel-" + sequence.incrementAndGet()); + thread.setDaemon(true); + return thread; + }); + pool.setRemoveOnCancelPolicy(true); + if (properties.enabled()) { + long period = properties.sweepInterval().toMillis(); + pool.scheduleWithFixedDelay(this::sweep, period, period, TimeUnit.MILLISECONDS); + } + executor = pool; + log.info("run cancel finalizer started: sweep={}, sweepInterval={}, cleanupTimeout={}", properties.enabled(), + properties.sweepInterval(), properties.cleanupTimeout()); + } + + /** 확인 중인 Brain 호출은 request-timeout 안에 끝난다. 남은 CANCELING run은 다음 인스턴스의 sweep이 이어받는다. */ + @Override + public synchronized void stop() { + ScheduledThreadPoolExecutor pool = executor; + if (pool == null) { + return; + } + executor = null; + pool.shutdownNow(); + // shutdownNow가 버린 queue 작업은 finally를 돌지 않는다 — 다시 start하면 그 run이 영구히 거부되지 않게 비운다 + inFlight.clear(); + backoff.clear(); + try { + if (!pool.awaitTermination(5, TimeUnit.SECONDS)) { + log.warn("run cancel finalizer did not stop in time: inFlight={}", inFlight.size()); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + @Override + public boolean isRunning() { + return executor != null; + } + + @Override + public int getPhase() { + return PHASE; + } + + // ------------------------------------------------------------------ + // 진입점 + // ------------------------------------------------------------------ + + /** 취소 API가 CANCELING을 commit한 직후(또는 이미 CANCELING인 run에 다시 취소가 왔을 때) 곧바로 한 번 확인한다. */ + public void wake(UUID runId) { + submit(runId); + } + + /** DB의 CANCELING run을 오래된 취소 요청부터 확인한다(run별 동시 확인 하나, backoff 중인 실패 행 제외). */ + void sweep() { + try { + long now = System.nanoTime(); + backoff.values().removeIf(until -> until - now <= 0); + String excluded = backoff.keySet().stream().map(UUID::toString) + .collect(Collectors.joining(",", "{", "}")); + List canceling = jdbc.queryForList(""" + SELECT run_id FROM ai_runs WHERE status = 'CANCELING' AND run_id <> ALL (CAST(? AS uuid[])) + ORDER BY cancel_requested_at, run_id LIMIT ? + """, UUID.class, excluded, properties.batchSize()); + if (sweepFailing.compareAndSet(true, false)) { + log.info("cancel sweep recovered"); + } + canceling.forEach(this::submit); + } catch (RuntimeException e) { + // DB 장애 동안 주기마다 같은 WARN을 남기지 않는다(처음 한 번, 회복 시 INFO) + if (sweepFailing.compareAndSet(false, true)) { + log.warn("cancel sweep failed: cause={}", e.getClass().getSimpleName()); + } else { + log.debug("cancel sweep still failing: cause={}", e.getClass().getSimpleName()); + } + } + } + + private void submit(UUID runId) { + ScheduledThreadPoolExecutor pool = executor; + if (pool == null || !inFlight.add(runId)) { + return; + } + try { + pool.execute(() -> { + try { + check(runId); + } catch (RuntimeException e) { + failed(runId, e); + } finally { + inFlight.remove(runId); + } + }); + } catch (RejectedExecutionException e) { + inFlight.remove(runId); + } + } + + // ------------------------------------------------------------------ + // 확인·확정 + // ------------------------------------------------------------------ + + /** + * 실패한 확인: 예외 message에는 SQL·값이 들어갈 수 있어 class 이름만 남긴다. {@code failure-backoff} 동안 주기 확인에서 빼고 + * (취소 API의 {@code wake}는 그대로 확인한다), 잠금 뒤 불변조건 위반이면 ERROR다. + */ + private void failed(UUID runId, RuntimeException e) { + boolean transientFailure = RunCancelStore.isRetryable(e); + backoff.put(runId, System.nanoTime() + properties.failureBackoff().toNanos()); + metrics.counter("proovy.run.cancel.check_failures", "kind", transientFailure ? "transient" : "invariant") + .increment(); + if (transientFailure) { + log.warn("cancel check failed: runId={}, cause={}", runId, e.getClass().getSimpleName()); + } else { + log.error("cancel check failed, backing off: runId={}, cause={}", runId, e.getClass().getSimpleName()); + } + } + + /** 한 번 확인하고, 확정 조건이면 CANCELED로 바꾼다. 확정했으면 true. */ + boolean check(UUID runId) { + Optional found = jdbc.query(""" + SELECT attempt, fence_token, cancel_cause, + GREATEST(0, CEIL(EXTRACT(EPOCH FROM + (cancel_requested_at + make_interval(secs => ?) - now())) * 1000))::bigint, + lease_expires_at IS NULL OR lease_expires_at <= now() + FROM ai_runs WHERE run_id = ? AND status = 'CANCELING' + """, (rs, row) -> new CancelingRun(runId, rs.getInt(1), rs.getString(2), RunCancelStore.cause(rs.getString(3)), + Duration.ofMillis(rs.getLong(4)), rs.getBoolean(5)), + properties.cleanupTimeout().toMillis() / 1000.0, runId).stream().findFirst(); + if (found.isEmpty()) { + backoff.remove(runId); + return false; + } + CancelingRun run = found.get(); + long timeoutAt = System.nanoTime() + run.untilTimeout().toNanos(); + // timeout이 이미 지났으면 Brain 응답을 기다리지 않는다(hang인 Brain이 확정·다른 확인을 늦추지 않게) + BrainView view = run.untilTimeout().isZero() ? BrainView.UNKNOWN : askBrain(run, run.untilTimeout()); + String reason = switch (view) { + case TERMINAL -> "brain_terminal"; + case ABSENT -> run.leaseExpired() && !executingLocally(runId) ? "brain_absent" : null; + case WINDING_DOWN, UNKNOWN -> null; + }; + if (reason == null && timeoutAt - System.nanoTime() <= 0) { + log.warn("brain cleanup not confirmed before timeout, canceling anyway: runId={}, lastView={}", runId, + view); + reason = "cleanup_timeout"; + } + return reason != null && confirm(runId, reason); + } + + private BrainView askBrain(CancelingRun run, Duration wait) { + BrainRunClient client = brain.getIfAvailable(); + if (client == null) { + return BrainView.UNKNOWN; + } + try { + // client request-timeout 안에서, timeout까지 남은 시간보다 오래 기다리지 않는다 + RunCancelAck ack = client.cancel(RunCancelRequest.of(run.runId().toString(), run.attempt(), + run.fenceToken(), run.cause())).block(wait); + if (ack == null) { + return BrainView.UNKNOWN; + } + log.debug("brain cancel ack: runId={}, outcome={}, state={}", run.runId(), ack.outcome(), ack.state()); + return switch (ack.state()) { + case TERMINAL -> BrainView.TERMINAL; + case ABSENT -> BrainView.ABSENT; + case CANCELING, EXECUTING -> BrainView.WINDING_DOWN; + }; + } catch (BrainCallException e) { + log.info("brain cancel failed: runId={}, kind={}, httpStatus={}", run.runId(), e.kind(), e.httpStatus()); + return BrainView.UNKNOWN; + } catch (InternalRunContractViolation e) { + log.error("cancel request cannot be built from the run row: runId={}", run.runId()); + return BrainView.UNKNOWN; + } catch (IllegalStateException e) { + // block(wait) 초과: cleanup-timeout이 남은 시간 안에 왔다 + log.info("brain cancel did not answer before the cleanup timeout: runId={}", run.runId()); + return BrainView.UNKNOWN; + } + } + + /** + * CANCELING → CANCELED + runtime cleanup 예약을 한 transaction으로. {@code brain_absent}는 잠근 뒤 run lease 만료를 다시 본다 + * (Brain 호출 동안 늦은 heartbeat가 lease를 갱신했으면 그 worker가 아직 START를 보낼 수 있다). commit 여부를 모르는 재시도가 + * 이미 CANCELED를 보더라도 worker 정지는 한다. + */ + private boolean confirm(UUID runId, String reason) { + boolean requireExpiredLease = "brain_absent".equals(reason); + Duration cleanupDelay = cleanupDelay(); + Confirmation result = store.inTransactions(() -> { + Optional locked = store.lock(runId); + if (locked.isEmpty() || locked.get().status() != AiRunStatus.CANCELING) { + return Confirmation.ALREADY_DONE; + } + if (requireExpiredLease && !leaseExpired(runId)) { + return Confirmation.LEASE_LIVE; + } + Canceled canceled = store.confirmCanceled(locked.get()); + claims.reserveRuntimeCleanup(runId, cleanupDelay); + log.info("cancellation confirmed: runId={}, reason={}, lastSeq={}", runId, reason, canceled.lastSeq()); + return Confirmation.CONFIRMED; + }); + if (result == Confirmation.LEASE_LIVE) { + log.info("brain record absent but the run lease was renewed, waiting: runId={}", runId); + return false; + } + backoff.remove(runId); + RunCoordinator local = coordinator.getIfAvailable(); + if (local != null) { + local.release(runId); + } + if (result == Confirmation.CONFIRMED) { + metrics.counter("proovy.run.cancel.confirmed", "reason", reason).increment(); + return true; + } + return false; + } + + /** 잠근 run 행의 lease가 끝났는지(DB 시각). */ + private boolean leaseExpired(UUID runId) { + return Boolean.TRUE.equals(jdbc.queryForObject( + "SELECT lease_expires_at IS NULL OR lease_expires_at <= now() FROM ai_runs WHERE run_id = ?", + Boolean.class, runId)); + } + + private boolean executingLocally(UUID runId) { + RunCoordinator local = coordinator.getIfAvailable(); + return local != null && local.isExecuting(runId); + } + + private Duration cleanupDelay() { + RunCoordinatorProperties coordination = coordinatorProperties.getIfAvailable(); + return coordination == null ? Duration.ZERO : coordination.runtimeCleanupDelay(); + } +} diff --git a/src/main/java/com/proovy/domain/run/cancel/RunCancelProperties.java b/src/main/java/com/proovy/domain/run/cancel/RunCancelProperties.java new file mode 100644 index 00000000..94e0a71e --- /dev/null +++ b/src/main/java/com/proovy/domain/run/cancel/RunCancelProperties.java @@ -0,0 +1,41 @@ +package com.proovy.domain.run.cancel; + +import java.time.Duration; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.DefaultValue; + +/** + * {@code proovy.run.cancel.*} — RUNNING/RETRYING 취소의 CANCELED 확정(task 1.84). 기본값은 {@code @DefaultValue}다. + * + * @param enabled CANCELING run 주기 확정 작업을 돌린다. 끄면 취소 API 직후 한 번만 확인하고, 그 뒤 확정은 다른 인스턴스의 + * 주기 작업에 맡긴다(테스트·점검용. 운영은 켠다) + * @param sweepInterval CANCELING run을 다시 확인하는 주기. 매번 Brain {@code cancel}(멱등)을 다시 보내 그 ack state로 정리를 확인한다 + * @param cleanupTimeout {@code cancel_requested_at}부터 Brain 정리 확인을 기다리는 상한. 넘으면 확인 없이 CANCELED로 확정한다. + * 기본 2분 = Brain의 끊긴 wind-down 정리 상한(writer lease TTL 30s × 4, internal-run-v1 tail)보다 길게 — + * S14 배포 값과 함께 확정 + * @param batchSize 주기 한 번에 확인하는 CANCELING run 수(오래된 취소 요청부터) + * @param failureBackoff 확인이 예외로 끝난 run을 주기 확인에서 빼 두는 시간(실패 행이 batch를 막지 않게) + */ +@ConfigurationProperties(prefix = "proovy.run.cancel") +public record RunCancelProperties( + @DefaultValue("true") boolean enabled, + @DefaultValue("2s") Duration sweepInterval, + @DefaultValue("2m") Duration cleanupTimeout, + @DefaultValue("20") int batchSize, + @DefaultValue("30s") Duration failureBackoff) { + + public RunCancelProperties { + requirePositive(sweepInterval, "sweep-interval"); + requirePositive(cleanupTimeout, "cleanup-timeout"); + requirePositive(failureBackoff, "failure-backoff"); + if (batchSize < 1 || batchSize > 200) { + throw new IllegalArgumentException("proovy.run.cancel.batch-size must be within 1..200"); + } + } + + private static void requirePositive(Duration value, String name) { + if (value == null || value.isNegative() || value.isZero()) { + throw new IllegalArgumentException("proovy.run.cancel." + name + " must be positive"); + } + } +} diff --git a/src/main/java/com/proovy/domain/run/cancel/RunCancelService.java b/src/main/java/com/proovy/domain/run/cancel/RunCancelService.java new file mode 100644 index 00000000..40b45df3 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/cancel/RunCancelService.java @@ -0,0 +1,77 @@ +package com.proovy.domain.run.cancel; + +import com.proovy.domain.run.cancel.RunCancelStore.LockedRun; +import com.proovy.domain.run.contract.CancelOutcome; +import com.proovy.domain.run.contract.PublicRunStatus; +import com.proovy.domain.run.contract.RunCancelResult; +import com.proovy.domain.run.entity.RunCancelCause; +import com.proovy.domain.run.stream.RunReadService; +import com.proovy.global.exception.BusinessException; +import com.proovy.global.response.ErrorCode; +import java.util.UUID; +import org.springframework.stereotype.Service; + +/** + * 사용자 명시적 취소 {@code POST /api/ai-runs/{runId}/cancel}(task 1.84). 판정 순서는 browser-run-v1 {@code cancel/cases.json} + * precedence다: 인증(filter) → body 있음 RUN4001(run 조회 전) → run 접근 RUN4041 → 상태. + *
    + *
  • PENDING: 즉시 CANCELED + 한 번 보상, 같은 transaction. Brain을 부르지 않는다(claim과는 하나만 이긴다).
  • + *
  • RUNNING·RETRYING: CANCELING + {@code run.canceling}. commit 뒤 {@link RunCancelFinalizer}가 Brain cancel·정리 확인 또는 + * 안전 timeout 뒤 CANCELED로 확정한다. 차감은 유지된다.
  • + *
  • CANCELING: {@code already_canceling}, terminal: {@code already_terminal}(멱등, 아무것도 바꾸지 않는다).
  • + *
+ * 브라우저 연결 단절은 취소가 아니다 — 취소는 이 경로뿐이다. 취소 뒤 자동 재실행·서버 queue·draft 전송은 없다. + */ +@Service +public class RunCancelService { + + private final RunReadService reads; + private final RunCancelStore store; + private final RunCancelFinalizer finalizer; + + public RunCancelService(RunReadService reads, RunCancelStore store, RunCancelFinalizer finalizer) { + this.reads = reads; + this.store = store; + this.finalizer = finalizer; + } + + private record Decision(PublicRunStatus status, CancelOutcome outcome, boolean canceling) { + } + + /** + * @param body 요청 body 원문. 비어 있지 않은 body는 어떤 내용이든 RUN4001이다(cancel은 body가 없다, `{}`도 거부) + */ + public RunCancelResult cancel(long userId, String runId, String body) { + if (body != null) { + throw new BusinessException(ErrorCode.RUN4001); + } + // 공개 경로의 접근 규칙(1.86): 소문자 UUID·소유자·Note ACTIVE. 없음·타인·삭제 중은 같은 RUN4041이다. + if (reads.accessibleRun(userId, runId).isEmpty()) { + throw new BusinessException(ErrorCode.RUN4041); + } + UUID id = UUID.fromString(runId); + Decision decision = store.inTransactions(() -> { + // 사전 조회와 잠금 사이에 Note 삭제가 시작됐으면 없는 run과 같다 + LockedRun run = store.lock(id).filter(locked -> locked.userId() == userId && locked.noteActive()) + .orElseThrow(() -> new BusinessException(ErrorCode.RUN4041)); + return switch (run.status()) { + case PENDING -> { + store.cancelPending(run, RunCancelCause.USER_REQUESTED); + yield new Decision(PublicRunStatus.CANCELED, CancelOutcome.ACCEPTED, false); + } + case RUNNING, RETRYING -> { + store.markCanceling(run, RunCancelCause.USER_REQUESTED); + yield new Decision(PublicRunStatus.CANCELING, CancelOutcome.ACCEPTED, true); + } + case CANCELING -> new Decision(PublicRunStatus.CANCELING, CancelOutcome.ALREADY_CANCELING, true); + case COMPLETED, FAILED, CANCELED -> + new Decision(PublicRunStatus.from(run.status()), CancelOutcome.ALREADY_TERMINAL, false); + }; + }); + if (decision.canceling()) { + // commit 뒤: Brain 호출은 transaction 밖이다. 실패해도 주기 확인이 이어받는다. + finalizer.wake(id); + } + return new RunCancelResult(id.toString(), decision.status(), decision.outcome()); + } +} diff --git a/src/main/java/com/proovy/domain/run/cancel/RunCancelStore.java b/src/main/java/com/proovy/domain/run/cancel/RunCancelStore.java new file mode 100644 index 00000000..56a9c563 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/cancel/RunCancelStore.java @@ -0,0 +1,227 @@ +package com.proovy.domain.run.cancel; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.proovy.domain.run.contract.CancelCause; +import com.proovy.domain.run.contract.PublicRunStatus; +import com.proovy.domain.run.contract.RunCanceledPayload; +import com.proovy.domain.run.contract.RunCancelingPayload; +import com.proovy.domain.run.contract.ServerFrameType; +import com.proovy.domain.run.entity.AiRunStatus; +import com.proovy.domain.run.entity.RunCancelCause; +import com.proovy.domain.run.event.RunEventStore; +import com.proovy.domain.run.event.TerminalRunEvent; +import com.proovy.domain.usage.service.RequestUsageService; +import com.proovy.domain.usage.service.RunTerminalOutcome; +import com.proovy.domain.usage.service.UsageCompensationResult; +import java.sql.SQLException; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.function.Supplier; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.transaction.support.TransactionTemplate; + +/** + * 취소 전환의 DB 경계(task 1.84). 모든 쓰기는 호출자가 {@link #inTransactions} 안에서 {@link #lock}으로 잡은 행에 대한 + * 조건부 UPDATE이고, 0 row면 불변조건 위반으로 rollback한다. + *

+ * 잠금 순서는 1.82 terminal 확정과 같다: {@code notes FOR SHARE → ai_runs FOR UPDATE → (usage) credit_balance FOR UPDATE}. + * 접수(1.79)·asset 삭제와 같은 Note 우선 순서라 서로 기다릴 뿐 교착하지 않는다. coordinator claim은 {@code SKIP LOCKED}로 + * 이 transaction이 잡은 PENDING run을 건너뛰고, 조건 {@code status = 'PENDING' AND attempt = 0}이라 취소와 claim은 하나만 이긴다. + *

+ * PENDING             --cancel-----------------> CANCELED  (+ message CANCELED, run.canceled, 보상 PENDING_CANCELED)
+ * RUNNING|RETRYING    --cancel-----------------> CANCELING (+ run.canceling, grant 폐기. lease·슬롯은 유지)
+ * CANCELING           --Brain 정리 확인·timeout--> CANCELED  (+ message CANCELED, run.canceled, 차감 유지)
+ * 
+ */ +@Slf4j +@Component +public class RunCancelStore { + + /** serialization_failure, deadlock_detected, lock_not_available (+ 연결 실패 08xxx) */ + private static final Set RETRYABLE_SQL_STATES = Set.of("40001", "40P01", "55P03"); + private static final int MAX_TRANSACTIONS = 3; + + private static final String LOCK_NOTE = """ + SELECT n.status FROM notes n WHERE n.note_id = (SELECT note_id FROM ai_runs WHERE run_id = ?) FOR SHARE + """; + private static final String LOCK_RUN = """ + SELECT user_id, request_id, note_id, assistant_message_id, status, attempt, fence_token, cancel_cause, + cancel_requested_from, last_event_seq + FROM ai_runs WHERE run_id = ? FOR UPDATE + """; + private static final String CANCELED = """ + status = 'CANCELED', completed_at = ?, lease_owner = NULL, lease_expires_at = NULL, grant_digest = NULL, + grant_expires_at = NULL, last_event_seq = last_event_seq + 1 + """; + + private final JdbcTemplate jdbc; + private final TransactionTemplate tx; + private final ObjectMapper objectMapper; + private final RunEventStore events; + private final RequestUsageService usage; + + public RunCancelStore(JdbcTemplate jdbc, TransactionTemplate tx, ObjectMapper objectMapper, RunEventStore events, + RequestUsageService usage) { + this.jdbc = jdbc; + this.tx = tx; + this.objectMapper = objectMapper; + this.events = events; + this.usage = usage; + } + + /** + * 잠근 run. {@code noteActive}는 Note가 ACTIVE인지(DELETING이면 공개 경로는 없는 run과 같다). + * + * @param cancelRequestedFrom CANCELING·CANCELED일 때만 + */ + record LockedRun(UUID runId, long userId, String requestId, long noteId, boolean noteActive, + UUID assistantMessageId, AiRunStatus status, int attempt, String fenceToken, + RunCancelCause cancelCause, AiRunStatus cancelRequestedFrom, long lastEventSeq) { + } + + /** 결과 CANCELED 전환. */ + record Canceled(long lastSeq, UsageCompensationResult compensation) { + } + + /** Note → run 순서로 잠근다. run이나 Note가 없으면(삭제 cascade) empty. transaction 안에서만 부른다. */ + Optional lock(UUID runId) { + List note = jdbc.queryForList(LOCK_NOTE, String.class, runId); + if (note.isEmpty()) { + return Optional.empty(); + } + boolean noteActive = "ACTIVE".equals(note.get(0)); + return jdbc.query(LOCK_RUN, (rs, row) -> new LockedRun(runId, rs.getLong(1), rs.getString(2), rs.getLong(3), + noteActive, rs.getObject(4, UUID.class), AiRunStatus.valueOf(rs.getString(5)), rs.getInt(6), + rs.getString(7), cause(rs.getString(8)), status(rs.getString(9)), rs.getLong(10)), runId) + .stream().findFirst(); + } + + /** PENDING(attempt 0) → CANCELED + 보상. claim이 먼저 이겼으면 잠금 뒤 RUNNING이라 여기 오지 않는다. */ + Canceled cancelPending(LockedRun run, RunCancelCause cause) { + Instant now = now(); + long lastSeq = transition(run, "cancel_cause = ?, cancel_requested_from = 'PENDING', cancel_requested_at = ?, " + + CANCELED, "status = 'PENDING' AND attempt = 0", + cause.wireValue(), time(now), time(now)); + return writeCanceled(run, cause, AiRunStatus.PENDING, lastSeq, now); + } + + /** + * RUNNING|RETRYING(현재 attempt·fence) → CANCELING + {@code run.canceling}. 이후 이 fence의 Activity는 열린 Activity의 + * {@code activity.canceled}만, terminal은 하나도 받지 않는다(1.80·1.82). lease·heartbeat는 그대로라 worker가 stream을 마저 + * 소비하고, 비terminal이라 전역 슬롯·Note Active Run 자리를 계속 차지한다. 이전 grant는 폐기한다(plan 12). + * {@code cancel_requested_at}은 DB {@code now()}다 — cleanup-timeout을 다른 인스턴스가 DB 시각으로 비교한다. + */ + void markCanceling(LockedRun run, RunCancelCause cause) { + Instant now = now(); + int updated = jdbc.update(""" + UPDATE ai_runs SET status = 'CANCELING', cancel_cause = ?, cancel_requested_from = status, + cancel_requested_at = now(), grant_digest = NULL, grant_expires_at = NULL + WHERE run_id = ? AND status IN ('RUNNING', 'RETRYING') AND attempt = ? AND fence_token = ? + """, cause.wireValue(), run.runId(), run.attempt(), run.fenceToken()); + requireOne(updated, "canceling transition lost its guard", run); + events.appendServerEvent(run.runId(), ServerFrameType.RUN_CANCELING, + new RunCancelingPayload(run.assistantMessageId().toString(), PublicRunStatus.CANCELING), now); + } + + /** CANCELING → CANCELED. 사용량은 1.77 정책표대로(사용자 취소는 차감 유지). */ + Canceled confirmCanceled(LockedRun run) { + Instant now = now(); + long lastSeq = transition(run, CANCELED, "status = 'CANCELING'", time(now)); + return writeCanceled(run, run.cancelCause(), run.cancelRequestedFrom(), lastSeq, now); + } + + private Canceled writeCanceled(LockedRun run, RunCancelCause cause, AiRunStatus from, long lastSeq, + Instant now) { + int message = jdbc.update(""" + UPDATE chat_messages SET status = 'CANCELED', completed_at = ? + WHERE message_id = ? AND note_id = ? AND role = 'ASSISTANT' AND status IN ('PENDING', 'STREAMING') + """, time(now), run.assistantMessageId(), run.noteId()); + requireOne(message, "assistant message is not an open placeholder", run); + + JsonNode payload = objectMapper.valueToTree( + RunCanceledPayload.of(run.assistantMessageId().toString(), CancelCause.from(cause))); + events.appendTerminalEvents(run.runId(), lastSeq, List.of(TerminalRunEvent.runCanceled(payload)), now); + + UsageCompensationResult compensation = usage.settle(run.userId(), run.requestId(), + RunTerminalOutcome.canceled(from, cause)); + log.info("run canceled: runId={}, from={}, cause={}, compensation={}", run.runId(), from, cause.wireValue(), + compensation.outcome()); + return new Canceled(lastSeq, compensation); + } + + /** 조건부 terminal 전환. 잠금 뒤 판정과 같은 조건이라 0 row면 불변조건 위반이다(rollback). */ + private long transition(LockedRun run, String set, String guard, Object... setArgs) { + Object[] args = Arrays.copyOf(setArgs, setArgs.length + 1); + args[setArgs.length] = run.runId(); + List returned = jdbc.queryForList("UPDATE ai_runs SET " + set + " WHERE run_id = ? AND " + guard + + " RETURNING last_event_seq", Long.class, args); + if (returned.size() != 1) { + throw new IllegalStateException("cancel transition lost its guard for run " + run.runId()); + } + return returned.get(0); + } + + /** + * 자기 transaction에서 실행한다. 직렬화·교착·연결 실패만 같은 입력으로 새 transaction을 연다(판정을 잠금 뒤 다시 하므로 + * commit 여부를 모르는 실패 뒤에도 멱등이다 — 이미 바뀐 상태를 보고 ALREADY_*로 답한다). 호출자 transaction이 열려 있으면 + * 거기에 참여하고 재시도하지 않는다 — rollback-only가 된 바깥 transaction 안에서 다시 시도할 수 없다(RunTerminalService와 같은 규칙). + */ + T inTransactions(Supplier work) { + boolean outer = TransactionSynchronizationManager.isActualTransactionActive(); + for (int attempt = 1; ; attempt++) { + try { + return tx.execute(status -> work.get()); + } catch (RuntimeException e) { + if (outer || attempt >= MAX_TRANSACTIONS || !isRetryable(e)) { + throw e; + } + log.info("cancel transaction {} failed, retrying: cause={}", attempt, e.getClass().getSimpleName()); + } + } + } + + static boolean isRetryable(Throwable failure) { + for (Throwable cause = failure; cause != null; cause = cause.getCause()) { + if (cause instanceof SQLException sql && sql.getSQLState() != null) { + String state = sql.getSQLState(); + return RETRYABLE_SQL_STATES.contains(state) || state.startsWith("08"); + } + } + return false; + } + + private static void requireOne(int rows, String problem, LockedRun run) { + if (rows != 1) { + throw new IllegalStateException(problem + " (run " + run.runId() + ")"); + } + } + + private static final RunCancelCause.Converter CAUSES = new RunCancelCause.Converter(); + + static RunCancelCause cause(String wire) { + return CAUSES.convertToEntityAttribute(wire); + } + + private static AiRunStatus status(String value) { + return value == null ? null : AiRunStatus.valueOf(value); + } + + static Instant now() { + return Instant.now().truncatedTo(ChronoUnit.MICROS); + } + + private static OffsetDateTime time(Instant instant) { + return OffsetDateTime.ofInstant(instant, ZoneOffset.UTC); + } +} diff --git a/src/main/java/com/proovy/domain/run/contract/ActiveRunConflict.java b/src/main/java/com/proovy/domain/run/contract/ActiveRunConflict.java new file mode 100644 index 00000000..aca93f23 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/contract/ActiveRunConflict.java @@ -0,0 +1,5 @@ +package com.proovy.domain.run.contract; + +/** 409 RUN4091의 result. 다른 오류 응답에는 result가 없다. */ +public record ActiveRunConflict(String activeRunId) { +} diff --git a/src/main/java/com/proovy/domain/run/contract/ActiveRunSummary.java b/src/main/java/com/proovy/domain/run/contract/ActiveRunSummary.java new file mode 100644 index 00000000..f4a975ae --- /dev/null +++ b/src/main/java/com/proovy/domain/run/contract/ActiveRunSummary.java @@ -0,0 +1,5 @@ +package com.proovy.domain.run.contract; + +/** 대화 조회의 진행 중 run. status는 PENDING|RUNNING|CANCELING만. */ +public record ActiveRunSummary(String runId, PublicRunStatus status, long lastSeq) { +} diff --git a/src/main/java/com/proovy/domain/run/contract/AssistantCancel.java b/src/main/java/com/proovy/domain/run/contract/AssistantCancel.java new file mode 100644 index 00000000..e625e263 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/contract/AssistantCancel.java @@ -0,0 +1,8 @@ +package com.proovy.domain.run.contract; + +public record AssistantCancel(CancelCause cause, String message) { + + public static AssistantCancel of(CancelCause cause) { + return new AssistantCancel(cause, cause.message()); + } +} diff --git a/src/main/java/com/proovy/domain/run/contract/AssistantFailure.java b/src/main/java/com/proovy/domain/run/contract/AssistantFailure.java new file mode 100644 index 00000000..300c8dfe --- /dev/null +++ b/src/main/java/com/proovy/domain/run/contract/AssistantFailure.java @@ -0,0 +1,4 @@ +package com.proovy.domain.run.contract; + +public record AssistantFailure(String code, String reason, String message, boolean recoverable) { +} diff --git a/src/main/java/com/proovy/domain/run/contract/AssistantMessageContent.java b/src/main/java/com/proovy/domain/run/contract/AssistantMessageContent.java new file mode 100644 index 00000000..5e552983 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/contract/AssistantMessageContent.java @@ -0,0 +1,24 @@ +package com.proovy.domain.run.contract; + +import com.fasterxml.jackson.databind.JsonNode; +import java.util.List; + +/** + * assistant 메시지 본문. assistantResponse·verificationSummary는 Brain terminal-v2 계약 + * (AssistantResponseV1·SolutionVerificationSummary)을 그대로 전달하므로 JSON 그대로 둔다. + * PENDING/STREAMING placeholder도 이 객체를 두되 모든 값은 null, verificationSummary는 빈 배열이다 + * ({@link #placeholder()}) — Candidate 본문은 공개하지 않는다. + */ +public record AssistantMessageContent( + String responseDisposition, + String clarificationReason, + JsonNode assistantResponse, + List verificationSummary, + AssistantFailure failure, + AssistantCancel cancel +) { + + public static AssistantMessageContent placeholder() { + return new AssistantMessageContent(null, null, null, List.of(), null, null); + } +} diff --git a/src/main/java/com/proovy/domain/run/contract/BrowserRequestFingerprint.java b/src/main/java/com/proovy/domain/run/contract/BrowserRequestFingerprint.java new file mode 100644 index 00000000..d8e77a18 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/contract/BrowserRequestFingerprint.java @@ -0,0 +1,74 @@ +package com.proovy.domain.run.contract; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +/** + * requestId 재사용 판정용 의미 fingerprint ({@value BrowserRunContract#FINGERPRINT_ALGORITHM}). + *
    + *
  • requestId 제외, text·latex는 {@link BrowserRunContract#strip} 결과
  • + *
  • 비순서 배열은 정렬, 빈 배열·null은 부재와 같다
  • + *
  • noteId 부재와 존재는 다르다. 기본값(예: requestedOutputs [] → ["text"])을 채우지 않는다
  • + *
+ * 같은 사용자·같은 fingerprint면 기존 run replay, 아니면 409 RUN4092. + * + * @param canonical {@link CanonicalJson} 문서 + * @param sha256 canonical UTF-8 바이트의 sha256 소문자 hex + */ +public record BrowserRequestFingerprint(String canonical, String sha256) { + + public static BrowserRequestFingerprint of(ConversationRunRequest request) { + String canonical = CanonicalJson.write(document(request)); + return new BrowserRequestFingerprint(canonical, sha256Hex(canonical)); + } + + static Map document(ConversationRunRequest request) { + Map document = new LinkedHashMap<>(); + document.put("noteId", request.noteId()); + document.put("text", BrowserRunContract.strip(request.text())); + if (request.latex() != null) { + document.put("latex", BrowserRunContract.strip(request.latex())); + } + document.put("requestedTasks", sorted(request.requestedTasks(), RequestedTask::wireValue)); + document.put("requestedOutputs", sorted(request.requestedOutputs(), RequestedOutput::wireValue)); + document.put("turnAttachmentIds", sorted(request.turnAttachmentIds(), Function.identity())); + ViewerFocus focus = request.viewerFocus(); + if (focus != null) { + Map focusDocument = new LinkedHashMap<>(); + focusDocument.put("noteAssetId", focus.noteAssetId()); + focusDocument.put("page", focus.page()); + PageSelection selection = focus.selection(); + if (selection != null) { + focusDocument.put("selection", Map.of( + "left", selection.left(), + "top", selection.top(), + "right", selection.right(), + "bottom", selection.bottom())); + } + document.put("viewerFocus", focusDocument); + } + return document; + } + + private static List sorted(List values, Function wire) { + if (values == null || values.isEmpty()) { + return null; + } + return values.stream().map(wire).sorted().toList(); + } + + private static String sha256Hex(String canonical) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(canonical.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 is required by the JDK", e); + } + } +} diff --git a/src/main/java/com/proovy/domain/run/contract/BrowserRunContract.java b/src/main/java/com/proovy/domain/run/contract/BrowserRunContract.java new file mode 100644 index 00000000..3a0be080 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/contract/BrowserRunContract.java @@ -0,0 +1,109 @@ +package com.proovy.domain.run.contract; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.regex.Pattern; + +/** + * 브라우저 run API wire 계약(browser-run-v1, task 1.75)의 고정 값. + *

+ * 계약 의미는 Server가 소유하고 schema·정상/거부 예제는 proovy-brain + * {@code tests/fixtures/contracts/browser-run-v1}에 versioned artifact로 고정한다. + * 테스트가 pin된 사본(src/test/resources/contracts/brain)과 이 값들을 대조한다. + */ +public final class BrowserRunContract { + + public static final String CONTRACT_VERSION = "browser-run-v1"; + /** 모든 durable frame의 contractVersion (D1). */ + public static final String FRAME_CONTRACT_VERSION = "brain-source-v1"; + public static final String FINGERPRINT_ALGORITHM = "browser-request-sha256-canonical-json-v1"; + + /** text·latex 각각 strip 후 code point 상한 (D6). */ + public static final int TEXT_MAX_CODE_POINTS = 10_000; + public static final int LATEX_MAX_CODE_POINTS = 10_000; + /** turnAttachmentIds wire 상한. 실제 개수·용량 한도는 Server policy(RUN4221)다. */ + public static final int TURN_ATTACHMENT_IDS_WIRE_MAX = 10; + public static final int REQUESTED_TASKS_MAX = 3; + public static final int REQUESTED_OUTPUTS_MAX = 2; + /** live 연결 heartbeat 주기 (D10). id·data·seq가 없고 저장·replay되지 않는다. */ + public static final int HEARTBEAT_SECONDS = 15; + public static final String HEARTBEAT_COMMENT = ": heartbeat\n\n"; + /** seq 상한 2^53-1 (JS Number 안전 정수). */ + public static final long MAX_SEQ = 9_007_199_254_740_991L; + + public static final Pattern UUID = Pattern.compile( + "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"); + /** noteId: 선행 0 없는 10진 문자열, 1..Long.MAX_VALUE (범위는 {@link #isNoteId}가 판정). */ + public static final Pattern NOTE_ID = Pattern.compile("[1-9][0-9]{0,18}"); + public static final Pattern OPAQUE_ID = Pattern.compile("[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}"); + + private BrowserRunContract() { + } + + /** 패턴은 항상 입력 전체와 일치해야 한다(끝 줄바꿈 허용 금지). */ + public static boolean isUuid(String value) { + return value != null && UUID.matcher(value).matches(); + } + + public static boolean isNoteId(String value) { + if (value == null || !NOTE_ID.matcher(value).matches()) { + return false; + } + try { + Long.parseLong(value); + return true; + } catch (NumberFormatException overflow) { + return false; + } + } + + public static boolean isOpaqueId(String value) { + return value != null && OPAQUE_ID.matcher(value).matches(); + } + + /** + * internal-run-v1과 같은 strip 집합(Python {@code str.strip()})에 속하는지. + * Java {@link String#strip()}은 U+00A0·U+2007·U+202F를 남기므로 쓰지 않는다. + */ + public static boolean isStripWhitespace(int codePoint) { + return switch (codePoint) { + case '\t', '\n', 0x0B, '\f', '\r', 0x1C, 0x1D, 0x1E, 0x1F, ' ', + 0x85, 0xA0, 0x1680, 0x2028, 0x2029, 0x202F, 0x205F, 0x3000 -> true; + default -> codePoint >= 0x2000 && codePoint <= 0x200A; + }; + } + + /** 양끝의 strip 집합 문자를 제거한다. */ + public static String strip(String value) { + int start = 0; + int end = value.length(); + while (start < end) { + int codePoint = value.codePointAt(start); + if (!isStripWhitespace(codePoint)) { + break; + } + start += Character.charCount(codePoint); + } + while (end > start) { + int codePoint = value.codePointBefore(end); + if (!isStripWhitespace(codePoint)) { + break; + } + end -= Character.charCount(codePoint); + } + return value.substring(start, end); + } + + /** 길이는 UTF-16 단위가 아니라 code point로 센다. */ + public static int codePointLength(String value) { + return value.codePointCount(0, value.length()); + } + + /** + * wire 시각은 소수 6자리 이하({@code ...Z})다. 나노초 시각은 마이크로초로 절단한다. + * 시각을 가진 계약 record는 생성자에서 이 함수를 거치므로 호출자가 따로 부를 필요가 없다. null은 그대로 둔다. + */ + public static Instant wireTime(Instant instant) { + return instant == null ? null : instant.truncatedTo(ChronoUnit.MICROS); + } +} diff --git a/src/main/java/com/proovy/domain/run/contract/BrowserRunContractOpenApi.java b/src/main/java/com/proovy/domain/run/contract/BrowserRunContractOpenApi.java new file mode 100644 index 00000000..9350c2de --- /dev/null +++ b/src/main/java/com/proovy/domain/run/contract/BrowserRunContractOpenApi.java @@ -0,0 +1,50 @@ +package com.proovy.domain.run.contract; + +import io.swagger.v3.core.converter.AnnotatedType; +import io.swagger.v3.core.converter.ModelConverters; +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import java.util.List; +import org.springdoc.core.customizers.OpenApiCustomizer; +import org.springdoc.core.properties.SpringDocConfigProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * browser-run-v1 DTO를 OpenAPI component schema로 등록한다. 아직 controller가 없으므로 + * 경로는 만들지 않는다(실제 처리는 후속 태스크). 판정 기준은 OpenAPI가 아니라 + * proovy-brain browser-run-v1 schema·fixture다. + */ +@Configuration +public class BrowserRunContractOpenApi { + + static final List> SCHEMA_TYPES = List.of( + ConversationRunRequest.class, + ActiveRunConflict.class, + RunStatus.class, + RunCancelResult.class, + ConversationPage.class, + BrowserRunFrame.class, + RunCreatedPayload.class, + ResponsePageStartedPayload.class, + ResponsePageCompletedPayload.class, + RunCancelingPayload.class, + RunCanceledPayload.class); + + /** springdoc이 내보내는 OpenAPI 버전(기본 3.1)에 맞는 converter로 만든다 — 3.0 schema는 3.1 출력에서 type을 잃는다. */ + @Bean + public OpenApiCustomizer browserRunContractSchemas(SpringDocConfigProperties springDoc) { + boolean openapi31 = springDoc.isOpenapi31(); + return openApi -> addSchemas(openApi, openapi31); + } + + static void addSchemas(OpenAPI openApi, boolean openapi31) { + if (openApi.getComponents() == null) { + openApi.setComponents(new Components()); + } + Components components = openApi.getComponents(); + for (Class type : SCHEMA_TYPES) { + ModelConverters.getInstance(openapi31).readAll(new AnnotatedType(type)).forEach(components::addSchemas); + } + } +} diff --git a/src/main/java/com/proovy/domain/run/contract/BrowserRunFrame.java b/src/main/java/com/proovy/domain/run/contract/BrowserRunFrame.java new file mode 100644 index 00000000..0ca74e0a --- /dev/null +++ b/src/main/java/com/proovy/domain/run/contract/BrowserRunFrame.java @@ -0,0 +1,27 @@ +package com.proovy.domain.run.contract; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.databind.JsonNode; +import java.time.Instant; + +/** + * SSE data로 보내는 durable frame. SSE {@code id}는 {@link RunEventCursor} 형식 + * {@code {runId}:{seq}}이고 {@code event}는 type이다. + *

+ * Server 소유 type(run.created·response.page.*·run.canceling·run.canceled)의 payload는 + * {@link ServerFrameType}의 record로, activity.*·run.completed·run.failed는 Brain + * terminal-v2/activity-v1 payload를 그대로 전달한다. + */ +public record BrowserRunFrame( + String contractVersion, + String runId, + long seq, + @JsonFormat(shape = JsonFormat.Shape.STRING) Instant occurredAt, + String type, + JsonNode payload +) { + + public BrowserRunFrame { + occurredAt = BrowserRunContract.wireTime(occurredAt); + } +} diff --git a/src/main/java/com/proovy/domain/run/contract/CancelCause.java b/src/main/java/com/proovy/domain/run/contract/CancelCause.java new file mode 100644 index 00000000..8bdadb09 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/contract/CancelCause.java @@ -0,0 +1,36 @@ +package com.proovy.domain.run.contract; + +import com.fasterxml.jackson.annotation.JsonValue; +import com.proovy.domain.run.entity.RunCancelCause; + +/** run.canceled·대화 조회의 취소 원인과 cause별 고정 message. */ +public enum CancelCause { + USER_REQUESTED("user_requested", "답변 생성을 중단했습니다."), + ASSET_DELETING("asset_deleting", "참조한 파일이 삭제되어 답변 생성을 중단했습니다."), + BUDGET_EXHAUSTED("budget_exhausted", "처리 한도에 도달해 답변 생성을 중단했습니다."); + + private final String wireValue; + private final String message; + + CancelCause(String wireValue, String message) { + this.wireValue = wireValue; + this.message = message; + } + + public static CancelCause from(RunCancelCause cause) { + return switch (cause) { + case USER_REQUESTED -> USER_REQUESTED; + case ASSET_DELETING -> ASSET_DELETING; + case BUDGET_EXHAUSTED -> BUDGET_EXHAUSTED; + }; + } + + @JsonValue + public String wireValue() { + return wireValue; + } + + public String message() { + return message; + } +} diff --git a/src/main/java/com/proovy/domain/run/contract/CancelOutcome.java b/src/main/java/com/proovy/domain/run/contract/CancelOutcome.java new file mode 100644 index 00000000..c4571b11 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/contract/CancelOutcome.java @@ -0,0 +1,21 @@ +package com.proovy.domain.run.contract; + +import com.fasterxml.jackson.annotation.JsonValue; + +/** POST /api/ai-runs/{runId}/cancel 결과. cancel은 멱등이다. */ +public enum CancelOutcome { + ACCEPTED("accepted"), + ALREADY_CANCELING("already_canceling"), + ALREADY_TERMINAL("already_terminal"); + + private final String wireValue; + + CancelOutcome(String wireValue) { + this.wireValue = wireValue; + } + + @JsonValue + public String wireValue() { + return wireValue; + } +} diff --git a/src/main/java/com/proovy/domain/run/contract/CanonicalJson.java b/src/main/java/com/proovy/domain/run/contract/CanonicalJson.java new file mode 100644 index 00000000..30eadf40 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/contract/CanonicalJson.java @@ -0,0 +1,126 @@ +package com.proovy.domain.run.contract; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * internal-run-v1 canonical JSON (Brain {@code canonical_json_dumps}와 같은 규칙). + *

    + *
  • 공백 없음, object member는 key의 code point 순서, 값이 null인 member는 생략
  • + *
  • 문자열은 {@code \" \\ \b \f \n \r \t}, 그 밖의 U+0020 미만과 짝 없는 surrogate만 소문자 + * {@code \\uXXXX}로 escape하고 나머지(비ASCII 포함)는 그대로
  • + *
  • 정수는 10진수, 실수는 round-trip 최단 10진 표기를 지수 없이 쓰고 정수값 실수는 정수로({@code 1.0 → 1})
  • + *
+ * 지원 값: null·Boolean·정수형·Double/Float·String·List·Map<String, ?>. + */ +public final class CanonicalJson { + + private CanonicalJson() { + } + + public static String write(Object value) { + StringBuilder out = new StringBuilder(); + append(out, value); + return out.toString(); + } + + private static void append(StringBuilder out, Object value) { + if (value == null) { + out.append("null"); + } else if (value instanceof Boolean bool) { + out.append(bool ? "true" : "false"); + } else if (value instanceof Integer || value instanceof Long || value instanceof BigInteger) { + out.append(value); + } else if (value instanceof Double || value instanceof Float) { + out.append(number(((Number) value).doubleValue())); + } else if (value instanceof String text) { + quote(out, text); + } else if (value instanceof List list) { + out.append('['); + for (int i = 0; i < list.size(); i++) { + if (i > 0) { + out.append(','); + } + append(out, list.get(i)); + } + out.append(']'); + } else if (value instanceof Map map) { + List> members = new ArrayList<>(); + for (Map.Entry entry : map.entrySet()) { + if (entry.getValue() != null) { + members.add(Map.entry((String) entry.getKey(), entry.getValue())); + } + } + members.sort((a, b) -> compareCodePoints(a.getKey(), b.getKey())); + out.append('{'); + for (int i = 0; i < members.size(); i++) { + if (i > 0) { + out.append(','); + } + quote(out, members.get(i).getKey()); + out.append(':'); + append(out, members.get(i).getValue()); + } + out.append('}'); + } else { + throw new IllegalArgumentException("canonical JSON cannot represent " + value.getClass().getName()); + } + } + + static String number(double value) { + if (!Double.isFinite(value)) { + throw new IllegalArgumentException("canonical JSON cannot represent non-finite numbers"); + } + double normalized = value + 0.0; // -0.0 → 0.0 + if (normalized == Math.rint(normalized)) { + return new BigDecimal(normalized).toBigIntegerExact().toString(); + } + // JDK 19+ Double.toString은 round-trip 최단 자릿수다. 지수 표기만 평문으로 편다. + return new BigDecimal(Double.toString(normalized)).stripTrailingZeros().toPlainString(); + } + + private static void quote(StringBuilder out, String text) { + out.append('"'); + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + switch (c) { + case '"' -> out.append("\\\""); + case '\\' -> out.append("\\\\"); + case '\b' -> out.append("\\b"); + case '\f' -> out.append("\\f"); + case '\n' -> out.append("\\n"); + case '\r' -> out.append("\\r"); + case '\t' -> out.append("\\t"); + default -> { + if (Character.isHighSurrogate(c) && i + 1 < text.length() + && Character.isLowSurrogate(text.charAt(i + 1))) { + out.append(c).append(text.charAt(++i)); + } else if (c < 0x20 || Character.isSurrogate(c)) { + out.append(String.format("\\u%04x", (int) c)); + } else { + out.append(c); + } + } + } + } + out.append('"'); + } + + private static int compareCodePoints(String a, String b) { + int i = 0; + int j = 0; + while (i < a.length() && j < b.length()) { + int x = a.codePointAt(i); + int y = b.codePointAt(j); + if (x != y) { + return Integer.compare(x, y); + } + i += Character.charCount(x); + j += Character.charCount(y); + } + return Integer.compare(a.length() - i, b.length() - j); + } +} diff --git a/src/main/java/com/proovy/domain/run/contract/ConversationMessage.java b/src/main/java/com/proovy/domain/run/contract/ConversationMessage.java new file mode 100644 index 00000000..d8c78716 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/contract/ConversationMessage.java @@ -0,0 +1,21 @@ +package com.proovy.domain.run.contract; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonInclude; +import java.time.Instant; + +/** role이 user면 {@code user}만, assistant면 {@code assistant}만 있다. */ +public record ConversationMessage( + String messageId, + MessageRole role, + ConversationMessageStatus status, + String runId, + @JsonFormat(shape = JsonFormat.Shape.STRING) Instant createdAt, + @JsonInclude(JsonInclude.Include.NON_NULL) UserMessageContent user, + @JsonInclude(JsonInclude.Include.NON_NULL) AssistantMessageContent assistant +) { + + public ConversationMessage { + createdAt = BrowserRunContract.wireTime(createdAt); + } +} diff --git a/src/main/java/com/proovy/domain/run/contract/ConversationMessageStatus.java b/src/main/java/com/proovy/domain/run/contract/ConversationMessageStatus.java new file mode 100644 index 00000000..da38c9e0 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/contract/ConversationMessageStatus.java @@ -0,0 +1,10 @@ +package com.proovy.domain.run.contract; + +/** 대화 조회 메시지 상태. user 메시지는 항상 COMPLETED다. */ +public enum ConversationMessageStatus { + PENDING, + STREAMING, + COMPLETED, + FAILED, + CANCELED +} diff --git a/src/main/java/com/proovy/domain/run/contract/ConversationPage.java b/src/main/java/com/proovy/domain/run/contract/ConversationPage.java new file mode 100644 index 00000000..b51fa5e5 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/contract/ConversationPage.java @@ -0,0 +1,17 @@ +package com.proovy.domain.run.contract; + +import java.util.List; + +/** + * GET /api/conversations/{noteId}/messages?before&limit result. + * messages는 createdAt 오름차순, 최대 100개. + * + * @param nextBefore 더 오래된 메시지를 읽을 다음 before 값, 없으면 null + */ +public record ConversationPage( + String noteId, + ActiveRunSummary activeRun, + List messages, + String nextBefore +) { +} diff --git a/src/main/java/com/proovy/domain/run/contract/ConversationRunRequest.java b/src/main/java/com/proovy/domain/run/contract/ConversationRunRequest.java new file mode 100644 index 00000000..0825d229 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/contract/ConversationRunRequest.java @@ -0,0 +1,27 @@ +package com.proovy.domain.run.contract; + +import com.fasterxml.jackson.annotation.JsonInclude; +import java.util.List; + +/** + * POST /api/conversations body. closed: 알 수 없는/금지 필드는 RUN4001 + * ({@link ConversationRunRequestValidator}). 브라우저는 scope·usage·attempt·fence·history· + * 참조를 결정하지 않으므로 해당 필드가 없다. + * + * @param requestId 클라이언트가 만든 소문자 UUID. 같은 의미의 재전송은 기존 run을 replay한다. + * @param noteId 없으면 Server가 Note를 만든다. 재전송은 처음 보낸 body 그대로(noteId를 추가하면 다른 의미). + * @param requestedTasks 빈 배열·부재는 Brain이 문장에서 추론한다. + * @param requestedOutputs 빈 배열·부재는 추론. video는 RUN4002. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record ConversationRunRequest( + String requestId, + String noteId, + String text, + String latex, + List requestedTasks, + List requestedOutputs, + List turnAttachmentIds, + ViewerFocus viewerFocus +) { +} diff --git a/src/main/java/com/proovy/domain/run/contract/ConversationRunRequestValidator.java b/src/main/java/com/proovy/domain/run/contract/ConversationRunRequestValidator.java new file mode 100644 index 00000000..403207d5 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/contract/ConversationRunRequestValidator.java @@ -0,0 +1,154 @@ +package com.proovy.domain.run.contract; + +import com.fasterxml.jackson.databind.JsonNode; +import com.proovy.global.response.ErrorCode; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Iterator; +import java.util.Optional; +import java.util.Set; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +/** + * POST /api/conversations body의 wire 판정(browser-run-v1 schema + 계약 검사). + *

+ * Jackson 바인딩 전에 JSON 트리를 직접 검사한다 — 요청은 closed이고 타입 강제 변환(숫자 noteId 등)을 + * 허용하지 않는다. 구조 위반은 모두 RUN4001이고, 구조가 맞을 때만 video 출력이 RUN4002가 된다. + * Note·참조·Active Run·rate limit·잔액은 이 판정 뒤 Server 상태로 판정한다. + */ +public final class ConversationRunRequestValidator { + + private static final Set FIELDS = Set.of( + "requestId", "noteId", "text", "latex", + "requestedTasks", "requestedOutputs", "turnAttachmentIds", "viewerFocus"); + private static final Set VIEWER_FOCUS_FIELDS = Set.of("noteAssetId", "page", "selection"); + private static final Set SELECTION_FIELDS = Set.of("left", "top", "right", "bottom"); + private static final Set TASKS = Arrays.stream(RequestedTask.values()) + .map(RequestedTask::wireValue).collect(Collectors.toUnmodifiableSet()); + private static final Set OUTPUTS = Arrays.stream(RequestedOutput.values()) + .map(RequestedOutput::wireValue).collect(Collectors.toUnmodifiableSet()); + + private ConversationRunRequestValidator() { + } + + /** 접수 가능한 body면 empty, 아니면 RUN4001 또는 RUN4002. */ + public static Optional validate(JsonNode body) { + if (!isWellFormed(body)) { + return Optional.of(ErrorCode.RUN4001); + } + JsonNode outputs = body.get("requestedOutputs"); + if (outputs != null) { + for (JsonNode output : outputs) { + if (RequestedOutput.VIDEO.wireValue().equals(output.textValue())) { + return Optional.of(ErrorCode.RUN4002); + } + } + } + return Optional.empty(); + } + + private static boolean isWellFormed(JsonNode body) { + if (body == null || !body.isObject() || !hasOnly(body, FIELDS)) { + return false; + } + JsonNode requestId = body.get("requestId"); + if (requestId == null || !requestId.isTextual() || !BrowserRunContract.isUuid(requestId.textValue())) { + return false; + } + JsonNode noteId = body.get("noteId"); + if (noteId != null && !(noteId.isTextual() && BrowserRunContract.isNoteId(noteId.textValue()))) { + return false; + } + JsonNode text = body.get("text"); + if (text == null || !isText(text, BrowserRunContract.TEXT_MAX_CODE_POINTS)) { + return false; + } + JsonNode latex = body.get("latex"); + if (latex != null && !latex.isNull() && !isText(latex, BrowserRunContract.LATEX_MAX_CODE_POINTS)) { + return false; + } + return isUniqueArray(body.get("requestedTasks"), BrowserRunContract.REQUESTED_TASKS_MAX, + item -> item.isTextual() && TASKS.contains(item.textValue())) + && isUniqueArray(body.get("requestedOutputs"), BrowserRunContract.REQUESTED_OUTPUTS_MAX, + item -> item.isTextual() && OUTPUTS.contains(item.textValue())) + && isUniqueArray(body.get("turnAttachmentIds"), BrowserRunContract.TURN_ATTACHMENT_IDS_WIRE_MAX, + item -> item.isTextual() && BrowserRunContract.isUuid(item.textValue())) + && isViewerFocus(body.get("viewerFocus")); + } + + /** 비어 있지 않고, strip 집합 공백만이 아니며, strip 후 code point 수가 상한 이하. */ + private static boolean isText(JsonNode node, int maxCodePoints) { + if (!node.isTextual()) { + return false; + } + String stripped = BrowserRunContract.strip(node.textValue()); + return !stripped.isEmpty() && BrowserRunContract.codePointLength(stripped) <= maxCodePoints; + } + + private static boolean isUniqueArray(JsonNode node, int maxItems, Predicate item) { + if (node == null) { + return true; + } + if (!node.isArray() || node.size() > maxItems) { + return false; + } + Set seen = new HashSet<>(); + for (JsonNode element : node) { + if (!item.test(element) || !seen.add(element)) { + return false; + } + } + return true; + } + + private static boolean isViewerFocus(JsonNode node) { + if (node == null || node.isNull()) { + return true; + } + if (!node.isObject() || !hasOnly(node, VIEWER_FOCUS_FIELDS)) { + return false; + } + JsonNode assetId = node.get("noteAssetId"); + if (assetId == null || !assetId.isTextual() || !BrowserRunContract.isOpaqueId(assetId.textValue())) { + return false; + } + JsonNode page = node.get("page"); + if (page == null || !page.isIntegralNumber() || !page.canConvertToInt() || page.intValue() < 1) { + return false; + } + return isSelection(node.get("selection")); + } + + private static boolean isSelection(JsonNode node) { + if (node == null || node.isNull()) { + return true; + } + if (!node.isObject() || !hasOnly(node, SELECTION_FIELDS) || node.size() != SELECTION_FIELDS.size()) { + return false; + } + for (String field : SELECTION_FIELDS) { + if (!node.get(field).isNumber()) { + return false; + } + } + double left = node.get("left").doubleValue(); + double top = node.get("top").doubleValue(); + double right = node.get("right").doubleValue(); + double bottom = node.get("bottom").doubleValue(); + boolean inRange = left >= 0 && left < 1 && top >= 0 && top < 1 + && right > 0 && right <= 1 && bottom > 0 && bottom <= 1; + // left allowed) { + Iterator names = node.fieldNames(); + while (names.hasNext()) { + if (!allowed.contains(names.next())) { + return false; + } + } + return true; + } +} diff --git a/src/main/java/com/proovy/domain/run/contract/MessageRole.java b/src/main/java/com/proovy/domain/run/contract/MessageRole.java new file mode 100644 index 00000000..071ff7a0 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/contract/MessageRole.java @@ -0,0 +1,19 @@ +package com.proovy.domain.run.contract; + +import com.fasterxml.jackson.annotation.JsonValue; + +public enum MessageRole { + USER("user"), + ASSISTANT("assistant"); + + private final String wireValue; + + MessageRole(String wireValue) { + this.wireValue = wireValue; + } + + @JsonValue + public String wireValue() { + return wireValue; + } +} diff --git a/src/main/java/com/proovy/domain/run/contract/PageSelection.java b/src/main/java/com/proovy/domain/run/contract/PageSelection.java new file mode 100644 index 00000000..f3735d63 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/contract/PageSelection.java @@ -0,0 +1,5 @@ +package com.proovy.domain.run.contract; + +/** page-selection-v1 정규화 좌표. left<right, top<bottom, 모두 [0,1]. */ +public record PageSelection(Double left, Double top, Double right, Double bottom) { +} diff --git a/src/main/java/com/proovy/domain/run/contract/PublicRunStatus.java b/src/main/java/com/proovy/domain/run/contract/PublicRunStatus.java new file mode 100644 index 00000000..30f19c71 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/contract/PublicRunStatus.java @@ -0,0 +1,33 @@ +package com.proovy.domain.run.contract; + +import com.proovy.domain.run.entity.AiRunStatus; + +/** 브라우저에 노출하는 run 상태. 내부 RETRYING은 RUNNING으로 투영한다. */ +public enum PublicRunStatus { + PENDING, + RUNNING, + CANCELING, + COMPLETED, + FAILED, + CANCELED; + + public static PublicRunStatus from(AiRunStatus status) { + return switch (status) { + case PENDING -> PENDING; + case RUNNING, RETRYING -> RUNNING; + case CANCELING -> CANCELING; + case COMPLETED -> COMPLETED; + case FAILED -> FAILED; + case CANCELED -> CANCELED; + }; + } + + public boolean isTerminal() { + return this == COMPLETED || this == FAILED || this == CANCELED; + } + + /** 사용자 취소가 가능한 상태(PENDING|RUNNING). */ + public boolean isCancelable() { + return this == PENDING || this == RUNNING; + } +} diff --git a/src/main/java/com/proovy/domain/run/contract/RequestedOutput.java b/src/main/java/com/proovy/domain/run/contract/RequestedOutput.java new file mode 100644 index 00000000..b19a0e26 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/contract/RequestedOutput.java @@ -0,0 +1,20 @@ +package com.proovy.domain.run.contract; + +import com.fasterxml.jackson.annotation.JsonValue; + +/** video는 알려진 값이지만 Phase 1 미지원이다(RUN4002). */ +public enum RequestedOutput { + TEXT("text"), + VIDEO("video"); + + private final String wireValue; + + RequestedOutput(String wireValue) { + this.wireValue = wireValue; + } + + @JsonValue + public String wireValue() { + return wireValue; + } +} diff --git a/src/main/java/com/proovy/domain/run/contract/RequestedTask.java b/src/main/java/com/proovy/domain/run/contract/RequestedTask.java new file mode 100644 index 00000000..5979c48e --- /dev/null +++ b/src/main/java/com/proovy/domain/run/contract/RequestedTask.java @@ -0,0 +1,21 @@ +package com.proovy.domain.run.contract; + +import com.fasterxml.jackson.annotation.JsonValue; + +/** 브라우저가 명시할 수 있는 작업. 자연어 의도(예: 변형 문제)는 wire enum이 아니다. */ +public enum RequestedTask { + SOLVE("solve"), + CHECK("check"), + EXPLAIN("explain"); + + private final String wireValue; + + RequestedTask(String wireValue) { + this.wireValue = wireValue; + } + + @JsonValue + public String wireValue() { + return wireValue; + } +} diff --git a/src/main/java/com/proovy/domain/run/contract/ResponsePageCompletedPayload.java b/src/main/java/com/proovy/domain/run/contract/ResponsePageCompletedPayload.java new file mode 100644 index 00000000..30f94653 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/contract/ResponsePageCompletedPayload.java @@ -0,0 +1,4 @@ +package com.proovy.domain.run.contract; + +public record ResponsePageCompletedPayload(String assistantMessageId, String responsePageId, int pageIndex) { +} diff --git a/src/main/java/com/proovy/domain/run/contract/ResponsePageStartedPayload.java b/src/main/java/com/proovy/domain/run/contract/ResponsePageStartedPayload.java new file mode 100644 index 00000000..563285cb --- /dev/null +++ b/src/main/java/com/proovy/domain/run/contract/ResponsePageStartedPayload.java @@ -0,0 +1,18 @@ +package com.proovy.domain.run.contract; + +import com.fasterxml.jackson.annotation.JsonInclude; + +/** + * completed run에서만, run.completed 바로 앞에 page마다 started→completed 쌍으로 보낸다. + * + * @param presentation terminal-v2 PagePresentation wire 값 + * @param title 없으면 생략 + */ +public record ResponsePageStartedPayload( + String assistantMessageId, + String responsePageId, + String presentation, + @JsonInclude(JsonInclude.Include.NON_NULL) String title, + int pageIndex +) { +} diff --git a/src/main/java/com/proovy/domain/run/contract/RunCancelResult.java b/src/main/java/com/proovy/domain/run/contract/RunCancelResult.java new file mode 100644 index 00000000..87d429ba --- /dev/null +++ b/src/main/java/com/proovy/domain/run/contract/RunCancelResult.java @@ -0,0 +1,5 @@ +package com.proovy.domain.run.contract; + +/** POST /api/ai-runs/{runId}/cancel result. body 없는 요청만 받는다(있으면 RUN4001). */ +public record RunCancelResult(String runId, PublicRunStatus status, CancelOutcome outcome) { +} diff --git a/src/main/java/com/proovy/domain/run/contract/RunCanceledPayload.java b/src/main/java/com/proovy/domain/run/contract/RunCanceledPayload.java new file mode 100644 index 00000000..72238040 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/contract/RunCanceledPayload.java @@ -0,0 +1,13 @@ +package com.proovy.domain.run.contract; + +public record RunCanceledPayload( + String assistantMessageId, + PublicRunStatus status, + CancelCause cause, + String message +) { + + public static RunCanceledPayload of(String assistantMessageId, CancelCause cause) { + return new RunCanceledPayload(assistantMessageId, PublicRunStatus.CANCELED, cause, cause.message()); + } +} diff --git a/src/main/java/com/proovy/domain/run/contract/RunCancelingPayload.java b/src/main/java/com/proovy/domain/run/contract/RunCancelingPayload.java new file mode 100644 index 00000000..03857c65 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/contract/RunCancelingPayload.java @@ -0,0 +1,5 @@ +package com.proovy.domain.run.contract; + +/** RUNNING 취소 시 1회. 이후에는 진행 중 activity의 종료와 run.canceled만 온다. */ +public record RunCancelingPayload(String assistantMessageId, PublicRunStatus status) { +} diff --git a/src/main/java/com/proovy/domain/run/contract/RunCreatedPayload.java b/src/main/java/com/proovy/domain/run/contract/RunCreatedPayload.java new file mode 100644 index 00000000..ac346a7a --- /dev/null +++ b/src/main/java/com/proovy/domain/run/contract/RunCreatedPayload.java @@ -0,0 +1,19 @@ +package com.proovy.domain.run.contract; + +import java.util.List; + +/** + * seq 1 frame. requestedOutputs는 접수된 값이므로 text만 있다. + * + * @param status 항상 PENDING + */ +public record RunCreatedPayload( + String requestId, + String noteId, + String userMessageId, + String assistantMessageId, + PublicRunStatus status, + List requestedTasks, + List requestedOutputs +) { +} diff --git a/src/main/java/com/proovy/domain/run/contract/RunEventCursor.java b/src/main/java/com/proovy/domain/run/contract/RunEventCursor.java new file mode 100644 index 00000000..4d818ce9 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/contract/RunEventCursor.java @@ -0,0 +1,88 @@ +package com.proovy.domain.run.contract; + +import com.proovy.global.response.ErrorCode; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * durable event ID이자 재접속 cursor {@code {runId}:{seq}} (소문자 UUID, seq 1..2^53-1, 선행 0 없음). + * GET /api/ai-runs/{runId}/events는 {@code Last-Event-ID} header 또는 {@code lastEventId} query로 받는다. + * POST /api/conversations는 cursor를 받지 않는다(항상 seq 1부터). + */ +public record RunEventCursor(String runId, long seq) { + + private static final Pattern FORMAT = Pattern.compile( + "(" + BrowserRunContract.UUID.pattern() + "):([1-9][0-9]{0,15})"); + + /** 형식이 맞으면 cursor. 공백·끝 줄바꿈·대문자·선행 0·2^53-1 초과는 empty. */ + public static Optional parse(String raw) { + if (raw == null) { + return Optional.empty(); + } + Matcher matcher = FORMAT.matcher(raw); + if (!matcher.matches()) { + return Optional.empty(); + } + long seq = Long.parseLong(matcher.group(2)); + if (seq > BrowserRunContract.MAX_SEQ) { + return Optional.empty(); + } + return Optional.of(new RunEventCursor(matcher.group(1), seq)); + } + + /** + * 접근 권한(404 RUN4041) 확인 뒤의 replay 시작점 판정. + *

    + *
  1. header·query가 둘 다 있고 다르면 RUN4003
  2. + *
  3. cursor 없으면 seq 1부터
  4. + *
  5. 형식 위반 RUN4003 → 다른 run RUN4004 → 마지막 durable seq 초과 RUN4003
  6. + *
  7. 그 외 seq+1부터. 마지막 seq면 0 frame 뒤 terminal run은 종료, 진행 중이면 live 합류
  8. + *
+ */ + public static ReplayStart resolve(String runId, long lastDurableSeq, String header, String query) { + if (header != null && query != null && !header.equals(query)) { + return ReplayStart.rejected(ErrorCode.RUN4003); + } + String raw = header != null ? header : query; + if (raw == null) { + return ReplayStart.from(1); + } + Optional cursor = parse(raw); + if (cursor.isEmpty()) { + return ReplayStart.rejected(ErrorCode.RUN4003); + } + if (!cursor.get().runId().equals(runId)) { + return ReplayStart.rejected(ErrorCode.RUN4004); + } + if (cursor.get().seq() > lastDurableSeq) { + return ReplayStart.rejected(ErrorCode.RUN4003); + } + return ReplayStart.from(cursor.get().seq() + 1); + } + + /** SSE {@code id} 값. */ + @Override + public String toString() { + return runId + ":" + seq; + } + + /** + * @param fromSeq 이 seq부터 replay (error가 있으면 0) + * @param error RUN4003·RUN4004, 정상이면 null + */ + public record ReplayStart(long fromSeq, ErrorCode error) { + + static ReplayStart from(long seq) { + return new ReplayStart(seq, null); + } + + static ReplayStart rejected(ErrorCode error) { + return new ReplayStart(0, error); + } + + public boolean isRejected() { + return error != null; + } + } +} diff --git a/src/main/java/com/proovy/domain/run/contract/RunFailureSummary.java b/src/main/java/com/proovy/domain/run/contract/RunFailureSummary.java new file mode 100644 index 00000000..79d54651 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/contract/RunFailureSummary.java @@ -0,0 +1,8 @@ +package com.proovy.domain.run.contract; + +/** + * @param code terminal-v2 FailureCode wire 값 + * @param reason invalid_attachment일 때의 AttachmentFailureReason, 그 외 null + */ +public record RunFailureSummary(String code, String reason, boolean recoverable) { +} diff --git a/src/main/java/com/proovy/domain/run/contract/RunStatus.java b/src/main/java/com/proovy/domain/run/contract/RunStatus.java new file mode 100644 index 00000000..99335929 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/contract/RunStatus.java @@ -0,0 +1,32 @@ +package com.proovy.domain.run.contract; + +import com.fasterxml.jackson.annotation.JsonFormat; +import java.time.Instant; + +/** + * GET /api/ai-runs/{runId} result. attempt·fence·deadline·usage는 노출하지 않는다. + * + * @param lastSeq 그 시점 마지막 durable seq. 그대로 cursor로 보내면 그 뒤부터 replay한다. + * @param responseDisposition COMPLETED일 때만 값이 있다(terminal-v2 ResponseDisposition wire 값). + * @param failure FAILED일 때만 값이 있다. + */ +public record RunStatus( + String runId, + String requestId, + String noteId, + String userMessageId, + String assistantMessageId, + PublicRunStatus status, + boolean cancelable, + long lastSeq, + String responseDisposition, + RunFailureSummary failure, + @JsonFormat(shape = JsonFormat.Shape.STRING) Instant createdAt, + @JsonFormat(shape = JsonFormat.Shape.STRING) Instant completedAt +) { + + public RunStatus { + createdAt = BrowserRunContract.wireTime(createdAt); + completedAt = BrowserRunContract.wireTime(completedAt); + } +} diff --git a/src/main/java/com/proovy/domain/run/contract/ServerFrameType.java b/src/main/java/com/proovy/domain/run/contract/ServerFrameType.java new file mode 100644 index 00000000..2aab5007 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/contract/ServerFrameType.java @@ -0,0 +1,33 @@ +package com.proovy.domain.run.contract; + +import java.util.Arrays; +import java.util.Optional; + +/** Server가 직접 만드는 frame type과 payload 타입. */ +public enum ServerFrameType { + RUN_CREATED("run.created", RunCreatedPayload.class), + RESPONSE_PAGE_STARTED("response.page.started", ResponsePageStartedPayload.class), + RESPONSE_PAGE_COMPLETED("response.page.completed", ResponsePageCompletedPayload.class), + RUN_CANCELING("run.canceling", RunCancelingPayload.class), + RUN_CANCELED("run.canceled", RunCanceledPayload.class); + + private final String type; + private final Class payloadType; + + ServerFrameType(String type, Class payloadType) { + this.type = type; + this.payloadType = payloadType; + } + + public static Optional of(String type) { + return Arrays.stream(values()).filter(value -> value.type.equals(type)).findFirst(); + } + + public String type() { + return type; + } + + public Class payloadType() { + return payloadType; + } +} diff --git a/src/main/java/com/proovy/domain/run/contract/TurnAttachmentMime.java b/src/main/java/com/proovy/domain/run/contract/TurnAttachmentMime.java new file mode 100644 index 00000000..8dd003b4 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/contract/TurnAttachmentMime.java @@ -0,0 +1,21 @@ +package com.proovy.domain.run.contract; + +import com.fasterxml.jackson.annotation.JsonValue; + +public enum TurnAttachmentMime { + PDF("application/pdf"), + JPEG("image/jpeg"), + PNG("image/png"), + WEBP("image/webp"); + + private final String wireValue; + + TurnAttachmentMime(String wireValue) { + this.wireValue = wireValue; + } + + @JsonValue + public String wireValue() { + return wireValue; + } +} diff --git a/src/main/java/com/proovy/domain/run/contract/TurnAttachmentView.java b/src/main/java/com/proovy/domain/run/contract/TurnAttachmentView.java new file mode 100644 index 00000000..680120bf --- /dev/null +++ b/src/main/java/com/proovy/domain/run/contract/TurnAttachmentView.java @@ -0,0 +1,4 @@ +package com.proovy.domain.run.contract; + +public record TurnAttachmentView(String attachmentId, TurnAttachmentMime mime, String displayName) { +} diff --git a/src/main/java/com/proovy/domain/run/contract/UserMessageContent.java b/src/main/java/com/proovy/domain/run/contract/UserMessageContent.java new file mode 100644 index 00000000..86086a05 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/contract/UserMessageContent.java @@ -0,0 +1,13 @@ +package com.proovy.domain.run.contract; + +import java.util.List; + +public record UserMessageContent( + String text, + String latex, + List requestedTasks, + List requestedOutputs, + List turnAttachments, + ViewerFocus viewerFocus +) { +} diff --git a/src/main/java/com/proovy/domain/run/contract/ViewerFocus.java b/src/main/java/com/proovy/domain/run/contract/ViewerFocus.java new file mode 100644 index 00000000..ae543b99 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/contract/ViewerFocus.java @@ -0,0 +1,9 @@ +package com.proovy.domain.run.contract; + +/** + * 사용자가 보고 있던 Note asset page. selection이 null이면 page 전체다. + * + * @param page 1-based + */ +public record ViewerFocus(String noteAssetId, Integer page, PageSelection selection) { +} diff --git a/src/main/java/com/proovy/domain/run/controller/BrowserRunController.java b/src/main/java/com/proovy/domain/run/controller/BrowserRunController.java new file mode 100644 index 00000000..0bdbb0a0 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/controller/BrowserRunController.java @@ -0,0 +1,103 @@ +package com.proovy.domain.run.controller; + +import com.fasterxml.jackson.databind.JsonNode; +import com.proovy.domain.run.admission.RunAdmissionService; +import com.proovy.domain.run.cancel.RunCancelService; +import com.proovy.domain.run.contract.ConversationPage; +import com.proovy.domain.run.contract.RunCancelResult; +import com.proovy.domain.run.contract.RunStatus; +import com.proovy.domain.run.stream.RunReadService; +import com.proovy.domain.run.stream.RunStreamService; +import com.proovy.global.response.ApiResponse; +import com.proovy.global.security.UserPrincipal; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +/** + * 브라우저 run API (browser-run-v1). 경로·오류·판정 순서의 원본은 pin된 browser-run-v1 artifact다. + *

+ * 스트림 시작 전 거부는 Accept가 {@code text/event-stream}이어도 JSON {@code ApiResponse}다 + * ({@link BrowserRunExceptionHandler}, 접수 거부는 {@code RunAdmissionExceptionHandler}). 시작 뒤에는 terminal event로 끝난다. + * 취소({@code POST /api/ai-runs/{runId}/cancel})는 동기 JSON이다(1.84). 브라우저 연결 단절은 취소가 아니다. + */ +@RestController +@Tag(name = "Browser Run", description = "대화 요청 접수·SSE replay·run 상태·대화 조회 (browser-run-v1)") +public class BrowserRunController { + + /** GET 재접속 cursor header. 같은 값을 {@code lastEventId} query로도 받는다(fetch 기반 client·EventSource 공용). */ + static final String LAST_EVENT_ID = "Last-Event-ID"; + + private final RunAdmissionService admission; + private final RunStreamService streams; + private final RunReadService reads; + private final RunCancelService cancels; + + public BrowserRunController(RunAdmissionService admission, RunStreamService streams, RunReadService reads, + RunCancelService cancels) { + this.admission = admission; + this.streams = streams; + this.reads = reads; + this.cancels = cancels; + } + + @PostMapping("/api/conversations") + @Operation(summary = "대화 요청 접수 (SSE)", + description = "ConversationRunRequest를 접수하고 seq 1 run.created부터 SSE로 보낸다. 같은 requestId·같은 의미의 " + + "재전송은 기존 run을 seq 1부터 replay한다. terminal이 아니면 live로 이어진다.") + public SseEmitter create(@Parameter(hidden = true) @AuthenticationPrincipal UserPrincipal principal, + @RequestBody(required = false) JsonNode body) { + // body는 바인딩 전 tree로 판정한다(1.79). 없는 body도 같은 판정기가 RUN4001로 거부한다. + return streams.openAdmitted(admission.admit(principal.getUserId(), body)); + } + + @GetMapping("/api/ai-runs/{runId}/events") + @Operation(summary = "run event 재접속 (SSE)", + description = "cursor가 없으면 seq 1부터, Last-Event-ID header 또는 lastEventId query({runId}:{seq})가 있으면 " + + "그 다음 seq부터 replay한 뒤 live로 잇는다. heartbeat는 15초마다 `: heartbeat` comment다.") + public SseEmitter events(@Parameter(hidden = true) @AuthenticationPrincipal UserPrincipal principal, + @PathVariable String runId, + @RequestHeader(name = LAST_EVENT_ID, required = false) String lastEventIdHeader, + @RequestParam(name = "lastEventId", required = false) String lastEventIdQuery) { + return streams.openReplay(principal.getUserId(), runId, lastEventIdHeader, lastEventIdQuery); + } + + @GetMapping("/api/ai-runs/{runId}") + @Operation(summary = "run 상태", description = "lastSeq는 그 시점 마지막 durable seq다. 내부 RETRYING은 RUNNING이다.") + public ApiResponse status(@Parameter(hidden = true) @AuthenticationPrincipal UserPrincipal principal, + @PathVariable String runId) { + return ApiResponse.success(reads.status(principal.getUserId(), runId)); + } + + @PostMapping("/api/ai-runs/{runId}/cancel") + @Operation(summary = "run 취소", + description = "body 없이 보낸다(`{}`도 RUN4001). PENDING은 즉시 CANCELED(보상), RUNNING은 CANCELING 뒤 Brain 정리 확인 " + + "또는 안전 timeout에 CANCELED(차감 유지). 이미 CANCELING·terminal이면 같은 상태를 멱등으로 돌려준다.") + public ApiResponse cancel(@Parameter(hidden = true) @AuthenticationPrincipal UserPrincipal principal, + @PathVariable String runId, + @RequestBody(required = false) String body) { + // body는 원문으로 받는다: 내용·Content-Type과 무관하게 body가 있으면 run 조회 전에 RUN4001이다. + return ApiResponse.success(cancels.cancel(principal.getUserId(), runId, body)); + } + + @GetMapping("/api/conversations/{noteId}/messages") + @Operation(summary = "대화 조회", + description = "before(messageId)보다 오래된 메시지 중 최신 limit(1..100, 기본 50)개를 createdAt 오름차순으로. " + + "완료 assistant 본문은 SSE run.completed와 같은 canonical 응답이다.") + public ApiResponse messages( + @Parameter(hidden = true) @AuthenticationPrincipal UserPrincipal principal, + @PathVariable String noteId, + @RequestParam(required = false) String before, + @RequestParam(required = false) String limit) { + return ApiResponse.success(reads.conversation(principal.getUserId(), noteId, before, limit)); + } +} diff --git a/src/main/java/com/proovy/domain/run/controller/BrowserRunExceptionHandler.java b/src/main/java/com/proovy/domain/run/controller/BrowserRunExceptionHandler.java new file mode 100644 index 00000000..25cc55d3 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/controller/BrowserRunExceptionHandler.java @@ -0,0 +1,77 @@ +package com.proovy.domain.run.controller; + +import com.proovy.global.exception.BusinessException; +import com.proovy.global.response.ApiResponse; +import com.proovy.global.response.ErrorCode; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.ErrorResponse; +import org.springframework.web.HttpMediaTypeNotSupportedException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.async.AsyncRequestNotUsableException; + +/** + * {@link BrowserRunController}의 스트림 시작 전 거부를 JSON {@code ApiResponse}로 답한다. Content-Type을 미리 정하므로 + * {@code Accept: text/event-stream} 요청에도 JSON이다(browser-run-v1 "스트림 시작 전 오류만 JSON"). + * 전역 {@code GlobalExceptionHandler}는 SSE Accept면 오류 JSON을 {@code text/event-stream}으로 감싸므로 이 controller에는 쓰지 않는다. + * 접수 거부({@code RunAdmissionRejectedException})는 {@code RunAdmissionExceptionHandler}가 먼저 처리한다. + */ +@Slf4j +@Order(Ordered.HIGHEST_PRECEDENCE + 1) +@RestControllerAdvice(assignableTypes = BrowserRunController.class) +public class BrowserRunExceptionHandler { + + @ExceptionHandler(BusinessException.class) + public ResponseEntity> rejected(BusinessException e) { + log.info("browser run request rejected: {}", e.getErrorCode().getCode()); + return failure(e.getErrorCode()); + } + + /** 읽을 수 없는 JSON·지원하지 않는 Content-Type의 POST body는 구조 위반(RUN4001)이다. */ + @ExceptionHandler({HttpMessageNotReadableException.class, HttpMediaTypeNotSupportedException.class}) + public ResponseEntity> unreadable(Exception e) { + log.info("browser run request body unreadable: {}", e.getClass().getSimpleName()); + return failure(ErrorCode.RUN4001); + } + + /** + * 브라우저가 SSE 연결을 끊었다. 쓸 응답이 없고 오류도 아니다(구독은 stream이 이미 해제한다). 전역 catch-all이 ERROR로 + * 기록하고 {@code text/event-stream}에 JSON을 쓰려다 실패하지 않게 여기서 끝낸다. + */ + @ExceptionHandler(AsyncRequestNotUsableException.class) + public void clientGone(AsyncRequestNotUsableException e) { + log.debug("browser run SSE client disconnected"); + } + + /** + * 예상 밖 실패. stream 시작 전이면 Accept가 SSE여도 COMMON500 JSON이다(전역 catch-all은 Content-Type을 정하지 않아 + * {@code text/event-stream} 요청에서 빈 500이 된다). 이미 SSE 응답이 시작됐으면(stream 중 오류 종료) 쓸 수 없으니 기록만 한다 — + * 원인 stack은 stream이 남겼다. + */ + @ExceptionHandler(Exception.class) + public ResponseEntity> unexpected(Exception e, HttpServletResponse response) { + if (response.isCommitted()) { + log.debug("browser run SSE ended with error after the stream started: {}", e.getClass().getSimpleName()); + return null; + } + if (e instanceof ErrorResponse spring && spring.getStatusCode().is4xxClientError()) { + // Spring MVC의 요청 오류(406·필수 header 누락 등)는 상태를 유지한다 + log.info("browser run request rejected by MVC: {}", e.getClass().getSimpleName()); + return ResponseEntity.status(spring.getStatusCode()).contentType(MediaType.APPLICATION_JSON) + .body(ApiResponse.failure(ErrorCode.COMMON400)); + } + log.error("browser run request failed before the stream started", e); + return failure(ErrorCode.COMMON500); + } + + private static ResponseEntity> failure(ErrorCode code) { + return ResponseEntity.status(code.getHttpStatus()).contentType(MediaType.APPLICATION_JSON) + .body(ApiResponse.failure(code)); + } +} diff --git a/src/main/java/com/proovy/domain/run/coordinator/ClaimedRun.java b/src/main/java/com/proovy/domain/run/coordinator/ClaimedRun.java new file mode 100644 index 00000000..dbe8c7cb --- /dev/null +++ b/src/main/java/com/proovy/domain/run/coordinator/ClaimedRun.java @@ -0,0 +1,18 @@ +package com.proovy.domain.run.coordinator; + +import java.util.Objects; +import java.util.UUID; + +/** + * coordinator가 조건부 UPDATE로 배정한 run의 현재 attempt·fence. 이 값으로만 Brain에 전달하고 lease를 갱신한다. + */ +public record ClaimedRun(UUID runId, int attempt, String fenceToken) { + + public ClaimedRun { + Objects.requireNonNull(runId, "runId"); + Objects.requireNonNull(fenceToken, "fenceToken"); + if (attempt < 1) { + throw new IllegalArgumentException("a claimed run has attempt >= 1"); + } + } +} diff --git a/src/main/java/com/proovy/domain/run/coordinator/RunAssetAccessProvider.java b/src/main/java/com/proovy/domain/run/coordinator/RunAssetAccessProvider.java new file mode 100644 index 00000000..7e3b361b --- /dev/null +++ b/src/main/java/com/proovy/domain/run/coordinator/RunAssetAccessProvider.java @@ -0,0 +1,17 @@ +package com.proovy.domain.run.coordinator; + +import com.proovy.domain.run.entity.AiRun; +import com.proovy.domain.run.snapshot.RunAssetAccess; + +/** + * 전달 하나에만 쓰는 Asset Access Grant·read URL 발급(run-snapshot-v1 R7). 값은 저장하지 않고 로그에 남기지 않는다. + *

+ * S09/S10 전에는 발급처가 없어 기본 구현이 {@link RunAssetAccess#NONE}을 준다. 접수(1.78)가 현재 turn 첨부를 거부하므로 + * 복원이 실패하지 않고, Viewer Focus {@code read_url}·corpus grant는 wire에서 optional이다. resolver·signed URL이 생기면 + * S09/S10이 이 bean을 교체하고, 전달마다 새로 발급한다(재claim·재전달 때 이전 값을 재사용하지 않는다). + */ +@FunctionalInterface +public interface RunAssetAccessProvider { + + RunAssetAccess accessFor(AiRun run, ClaimedRun claim); +} diff --git a/src/main/java/com/proovy/domain/run/coordinator/RunClaimStore.java b/src/main/java/com/proovy/domain/run/coordinator/RunClaimStore.java new file mode 100644 index 00000000..60280730 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/coordinator/RunClaimStore.java @@ -0,0 +1,234 @@ +package com.proovy.domain.run.coordinator; + +import java.time.Duration; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.stream.Collectors; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.support.TransactionTemplate; + +/** + * coordinator의 DB 경계. 모든 메서드는 짧은 자기 transaction(또는 단일 문장)이고 Brain 호출 중에 열려 있지 않다. + * 시각은 DB {@code now()}를 쓴다(여러 Server 인스턴스가 같은 시계로 lease를 비교한다). + *

    + *
  • coordinator lease({@code run_coordinator_leases}, V4): 보유자 하나만 claim한다.
  • + *
  • claim: {@code PENDING → RUNNING}, attempt 0 → 1, 새 fence·run lease. attempt를 올리는 유일한 경로다.
  • + *
  • run lease·heartbeat·RETRYING 표시·종료 시 lease 만료·runtime cleanup 예약.
  • + *
  • stale 회수(1.85): lease가 만료된 {@code RUNNING|RETRYING} run의 lease만 같은 attempt·fence로 인수한다. + * attempt·fence를 바꾸지 않고 {@code recovery_count}(V5)만 1 올린다.
  • + *
+ * 잠금은 {@code ai_runs} 행 하나(claim은 {@code SKIP LOCKED})뿐이라 Note → run 순서의 terminal·취소 transaction과 + * 교착하지 않는다. + */ +public class RunClaimStore { + + static final String COORDINATOR = "run"; + + private static final String ACQUIRE_LEASE = """ + INSERT INTO run_coordinator_leases AS l (coordinator, owner, epoch, acquired_at, renewed_at, expires_at) + VALUES (?, ?, 1, now(), now(), now() + make_interval(secs => ?)) + ON CONFLICT (coordinator) DO UPDATE SET + owner = EXCLUDED.owner, + epoch = l.epoch + CASE WHEN l.owner = EXCLUDED.owner THEN 0 ELSE 1 END, + acquired_at = CASE WHEN l.owner = EXCLUDED.owner THEN l.acquired_at ELSE now() END, + renewed_at = now(), + expires_at = EXCLUDED.expires_at + WHERE l.owner = EXCLUDED.owner OR l.expires_at <= now() + RETURNING epoch + """; + private static final String HOLD_LEASE = """ + SELECT 1 FROM run_coordinator_leases WHERE coordinator = ? AND owner = ? AND expires_at > now() FOR SHARE + """; + private static final String CLAIM = """ + WITH picked AS ( + SELECT run_id FROM ai_runs + WHERE status = 'PENDING' AND request_deadline_at > now() + make_interval(secs => ?) + ORDER BY created_at, run_id + LIMIT 1 + FOR UPDATE SKIP LOCKED + ) + UPDATE ai_runs r + SET status = 'RUNNING', attempt = 1, fence_token = ?, started_at = now(), lease_owner = ?, + lease_expires_at = now() + make_interval(secs => ?), heartbeat_at = now() + FROM picked + WHERE r.run_id = picked.run_id AND r.status = 'PENDING' AND r.attempt = 0 + RETURNING r.run_id + """; + private static final String TAKE_OVER = """ + UPDATE ai_runs + SET lease_owner = ?, lease_expires_at = now() + make_interval(secs => ?), heartbeat_at = now(), + recovery_count = recovery_count + 1 + WHERE run_id = ? AND status IN ('RUNNING', 'RETRYING') AND attempt >= 1 + AND lease_expires_at <= now() AND request_deadline_at > now() + RETURNING attempt, fence_token, recovery_count + """; + private static final String OWNED = """ + run_id = ? AND attempt = ? AND fence_token = ? AND lease_owner = ? + """; + + private final JdbcTemplate jdbc; + private final TransactionTemplate tx; + + public RunClaimStore(JdbcTemplate jdbc, TransactionTemplate tx) { + this.jdbc = jdbc; + this.tx = tx; + } + + /** coordinator lease를 얻거나 갱신한다. 다른 살아 있는 보유자가 있으면 false(이 인스턴스는 claim하지 않는다). */ + public boolean acquireCoordinatorLease(String owner, Duration ttl) { + List epoch = jdbc.queryForList(ACQUIRE_LEASE, Long.class, COORDINATOR, owner, seconds(ttl)); + return !epoch.isEmpty(); + } + + /** 종료 시 자기 coordinator lease를 즉시 만료시킨다(다음 인스턴스가 TTL을 기다리지 않게). */ + public void releaseCoordinatorLease(String owner) { + jdbc.update(""" + UPDATE run_coordinator_leases SET expires_at = renewed_at + interval '1 microsecond' + WHERE coordinator = ? AND owner = ? + """, COORDINATOR, owner); + } + + /** + * 전역 슬롯을 차지하는 실행 수(모든 Server 인스턴스): Brain에 위임됐을 수 있는 비terminal run + * ({@code attempt >= 1 AND status IN (RUNNING, RETRYING, CANCELING)}). lease 상태와 무관하다 — 종료·crash·OBSOLETE로 + * heartbeat가 끊긴 run도 Brain에서 아직 실행 중일 수 있으므로, terminal(deadline sweep·회수·취소 확정)이 될 때까지 센다. + * Brain 인스턴스 상한을 전역 슬롯의 대체물로 쓰지 않기 위해서다(spec 07, docs/plan/09). + */ + public int countActiveExecutions() { + Integer count = jdbc.queryForObject(""" + SELECT count(*) FROM ai_runs + WHERE status IN ('RUNNING', 'RETRYING', 'CANCELING') AND attempt >= 1 + """, Integer.class); + return count == null ? 0 : count; + } + + /** + * coordinator lease를 쥔 채로 가장 오래된 PENDING run 하나를 claim한다. 다른 transaction이 잡은 run(취소 중 등)은 + * 건너뛴다. lease가 없으면(다른 인스턴스로 넘어감) 아무것도 claim하지 않는다. + */ + public Optional claimNext(String owner, String fenceToken, Duration leaseTtl, + Duration minRemaining) { + return tx.execute(status -> { + if (jdbc.queryForList(HOLD_LEASE, Integer.class, COORDINATOR, owner).isEmpty()) { + return Optional.empty(); + } + List claimed = jdbc.queryForList(CLAIM, UUID.class, seconds(minRemaining), fenceToken, owner, + seconds(leaseTtl)); + return claimed.stream().findFirst().map(runId -> new ClaimedRun(runId, 1, fenceToken)); + }); + } + + /** run lease·heartbeat 갱신. false면 이 attempt·fence·owner의 실행이 아니다(terminal·fence 교체·lease 인수). */ + public boolean heartbeat(ClaimedRun run, String owner, Duration leaseTtl) { + return jdbc.update(""" + UPDATE ai_runs SET heartbeat_at = now(), lease_expires_at = now() + make_interval(secs => ?) + WHERE status IN ('RUNNING', 'RETRYING', 'CANCELING') AND + """ + OWNED, seconds(leaseTtl), run.runId(), run.attempt(), run.fenceToken(), owner) == 1; + } + + /** 같은 attempt 재전달 전: {@code RUNNING|RETRYING → RETRYING}, retry_count + 1. false면 이 실행이 아니다. */ + public boolean markRetrying(ClaimedRun run, String owner) { + return jdbc.update(""" + UPDATE ai_runs SET status = 'RETRYING', retry_count = retry_count + 1 + WHERE status IN ('RUNNING', 'RETRYING') AND + """ + OWNED, run.runId(), run.attempt(), run.fenceToken(), owner) == 1; + } + + /** 재전달한 stream이 frame을 내기 시작했다: {@code RETRYING → RUNNING}. */ + public boolean markRunning(ClaimedRun run, String owner) { + return jdbc.update(""" + UPDATE ai_runs SET status = 'RUNNING' WHERE status = 'RETRYING' AND + """ + OWNED, run.runId(), run.attempt(), run.fenceToken(), owner) == 1; + } + + /** + * 종료(worker 정리)할 때 진행 중 run의 lease를 즉시 만료시킨다. run은 terminal이 아니라 그대로 RUNNING|RETRYING이고 + * stale 회수(1.85)의 대상이 된다. transport 종료를 성공으로 보지 않는다. + */ + public boolean expireLease(ClaimedRun run, String owner) { + return jdbc.update(""" + UPDATE ai_runs SET lease_expires_at = now() + WHERE status IN ('RUNNING', 'RETRYING', 'CANCELING') AND + """ + OWNED, run.runId(), run.attempt(), run.fenceToken(), owner) == 1; + } + + /** + * terminal run의 Brain runtime cleanup 예약(1.82 인계: terminal 확정 경로는 하지 않는다). Brain record가 있을 수 있는 + * run(attempt ≥ 1)만, 한 번만 기록한다. 실제 cleanup 호출·retention은 S13이다. + */ + public boolean reserveRuntimeCleanup(UUID runId, Duration delay) { + return jdbc.update(""" + UPDATE ai_runs SET runtime_cleanup_at = now() + make_interval(secs => ?) + WHERE run_id = ? AND status IN ('COMPLETED', 'FAILED', 'CANCELED') AND attempt >= 1 + AND runtime_cleanup_at IS NULL + """, seconds(delay), runId) == 1; + } + + /** 요청 deadline이 지난 진행 중 run({@code idx_ai_runs_deadline}). CANCELING은 취소 경로(1.84)가 끝낸다. */ + public List findDeadlinePassed(int limit) { + return jdbc.queryForList(""" + SELECT run_id FROM ai_runs + WHERE status IN ('PENDING', 'RUNNING', 'RETRYING') AND request_deadline_at <= now() + ORDER BY request_deadline_at + LIMIT ? + """, UUID.class, limit); + } + + /** + * 회수 후보: lease가 만료된 {@code RUNNING|RETRYING} run({@code idx_ai_runs_stale_lease}) 중 deadline 전인 것. + * deadline이 지난 run은 deadline sweep이 timeout으로 끝낸다. CANCELING은 취소 경로(1.84) 소유라 고르지 않는다. + */ + public List findStale(int limit) { + return findStale(limit, List.of()); + } + + /** {@link #findStale(int)}에서 {@code excluded}(이 인스턴스에서 worker가 도는 run)를 뺀다. */ + public List findStale(int limit, Collection excluded) { + String ids = excluded.stream().map(UUID::toString).collect(Collectors.joining(",", "{", "}")); + return jdbc.queryForList(""" + SELECT run_id FROM ai_runs + WHERE status IN ('RUNNING', 'RETRYING') AND attempt >= 1 AND lease_expires_at <= now() + AND request_deadline_at > now() AND run_id <> ALL (CAST(? AS uuid[])) + ORDER BY lease_expires_at + LIMIT ? + """, UUID.class, ids, limit); + } + + /** + * coordinator lease를 쥔 채로 stale run의 lease를 인수한다. attempt·fence는 그대로(새 attempt 금지)이고 + * {@code recovery_count}만 1 오른다. 조건부 UPDATE라 동시 인수·heartbeat 재개·terminal·취소 전환과 겹치면 하나만 이긴다. + * 비어 있으면 인수하지 않았다(이미 다른 worker·경로가 맡음, lease 보유자 아님). + */ + public Optional takeOver(UUID runId, String owner, Duration leaseTtl) { + return tx.execute(status -> { + if (jdbc.queryForList(HOLD_LEASE, Integer.class, COORDINATOR, owner).isEmpty()) { + return Optional.empty(); + } + List taken = jdbc.query(TAKE_OVER, (rs, i) -> new StaleRun( + new ClaimedRun(runId, rs.getInt("attempt"), rs.getString("fence_token")), + rs.getInt("recovery_count")), owner, seconds(leaseTtl), runId); + return taken.stream().findFirst(); + }); + } + + /** + * 이 attempt의 Brain source event(Activity·terminal)가 저장돼 있는지. 있으면 Brain이 그 요청을 등록·시작했던 것이다 — + * 그 뒤 status가 absent면 미시작이 아니라 실행 소실이다(같은 요청을 보내면 새 graph가 시작된다). + */ + public boolean hasBrainEvents(ClaimedRun run) { + Boolean exists = jdbc.queryForObject(""" + SELECT EXISTS (SELECT 1 FROM ai_run_events WHERE run_id = ? AND attempt = ? AND source_event_id IS NOT NULL) + """, Boolean.class, run.runId(), run.attempt()); + return Boolean.TRUE.equals(exists); + } + + /** 인수한 stale run과 인수 뒤 누적 회수 횟수. */ + public record StaleRun(ClaimedRun claim, int recoveryCount) { + } + + private static double seconds(Duration duration) { + return duration.toNanos() / 1_000_000_000.0; + } +} diff --git a/src/main/java/com/proovy/domain/run/coordinator/RunCoordinator.java b/src/main/java/com/proovy/domain/run/coordinator/RunCoordinator.java new file mode 100644 index 00000000..a9e58a12 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/coordinator/RunCoordinator.java @@ -0,0 +1,454 @@ +package com.proovy.domain.run.coordinator; + +import com.proovy.domain.run.coordinator.RunClaimStore.StaleRun; +import com.proovy.domain.run.coordinator.RunExecution.Mode; +import com.proovy.domain.run.coordinator.RunExecution.StopReason; +import com.proovy.domain.run.terminal.RunTerminalService; +import com.proovy.domain.run.terminal.TerminalSettlement; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.Semaphore; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.locks.ReentrantLock; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.SmartLifecycle; + +/** + * 단일 Server run coordinator(task 1.83, docs/plan/05·09). 브라우저 연결과 독립적으로 commit된 PENDING run을 claim해 + * 전역 유한 슬롯 안에서 Brain에 위임하고 결과를 1.80 event store·1.82 terminal 확정으로 넘긴다. + *

단일 coordinator

+ * DB coordinator lease({@code run_coordinator_leases})를 가진 인스턴스 하나만 claim·deadline sweep을 한다. lease가 없는 + * 인스턴스는 passive로 남아 heartbeat 주기마다 lease를 다시 시도한다. claim transaction은 lease를 {@code FOR SHARE}로 + * 다시 확인하므로 lease가 넘어간 뒤의 claim은 없다. + *

전역 슬롯

+ * 빈 슬롯 = min(로컬 semaphore, {@code maxConcurrentRuns} − DB의 비terminal 실행 수({@code attempt >= 1}, + * RUNNING·RETRYING·CANCELING, lease 무관)). 다른 인스턴스·이전 프로세스·heartbeat가 끊긴 실행도 terminal이 될 때까지 + * 슬롯을 차지한다(Brain에서 아직 돌 수 있다). dispatch는 lock으로 한 번에 하나만 돈다. 슬롯이 없으면 run은 PENDING으로 + * 남는다. worker pool의 대기열은 semaphore가 상한을 지키므로 길이가 0..max로 유한하다(슬롯 반환 직후 thread가 아직 + * 비지 않은 순간에도 다음 run을 거부하지 않는다). Brain의 503 capacity는 슬롯의 대체물이 아니라 재전송 대상이다. + *

슬롯 해제

+ * 로컬 permit은 worker가 끝날 때 항상 반환한다: terminal 확정(SETTLED), 이 attempt가 현재가 아님(OBSOLETE: 다른 terminal· + * CANCELING·fence 교체), 멈춤(STOPPED: 종료·lease 상실), 로컬 오류(LOCAL_ERROR). OBSOLETE·STOPPED·LOCAL_ERROR에서 run이 + * 아직 비terminal이면 heartbeat는 멈추지만 전역 슬롯은 DB 수로 계속 차지한다 — terminal이 되어야 풀린다. + *

stale 회수 (1.85)

+ * lease가 만료됐고 이 인스턴스에 worker가 없는 {@code RUNNING|RETRYING} run(이전 프로세스·종료·lease 상실·로컬 오류)을 + * 같은 attempt·fence로 인수해 status부터 수습한다({@link RunExecution.Mode#RECOVER}). 인수 worker도 로컬 permit을 잡는다. + * 전역 슬롯은 그 run이 이미 비terminal 수로 차지하고 있어 새로 세지 않는다. CANCELING은 취소 경로(1.84) 소유라 고르지 않는다. + *

스레드

+ * 내부 executor 두 개(control: claim·heartbeat·sweep, worker: run별)이며 Spring bean으로 노출하지 않는다 — 전역 기본 + * {@code taskScheduler}·{@code applicationTaskExecutor}를 바꾸지 않기 위해서다. 스레드 이름은 {@code runCoordinator-*}. + */ +@Slf4j +public class RunCoordinator implements SmartLifecycle { + + private static final int DEADLINE_BATCH = 50; + + private final RunCoordinatorProperties properties; + private final RunClaimStore claims; + private final RunTerminalService terminals; + private final RunExecution.Dependencies dependencies; + private final RunCoordinatorMetrics metrics; + private final String owner; + private final Semaphore slots; + private final Map executions = new ConcurrentHashMap<>(); + private final AtomicBoolean running = new AtomicBoolean(); + private final AtomicBoolean leader = new AtomicBoolean(); + private final AtomicBoolean dispatchQueued = new AtomicBoolean(); + private final ReentrantLock dispatchLock = new ReentrantLock(); + private final AtomicBoolean redispatch = new AtomicBoolean(); + + private ScheduledExecutorService control; + private ThreadPoolExecutor workers; + + RunCoordinator(RunCoordinatorProperties properties, RunClaimStore claims, RunTerminalService terminals, + RunExecution.Dependencies dependencies, RunCoordinatorMetrics metrics) { + this.properties = properties; + this.claims = claims; + this.terminals = terminals; + this.dependencies = dependencies; + this.metrics = metrics; + this.owner = dependencies.owner(); + this.slots = new Semaphore(properties.maxConcurrentRuns()); + metrics.gauges(executions::size, properties.maxConcurrentRuns(), () -> leader.get() ? 1 : 0); + } + + // ------------------------------------------------------------------ + // lifecycle + // ------------------------------------------------------------------ + + @Override + public void start() { + if (!running.compareAndSet(false, true)) { + return; + } + int max = properties.maxConcurrentRuns(); + control = Executors.newScheduledThreadPool(2, threads("runCoordinator-control-")); + workers = workerPool(max); + log.info("run coordinator starting: owner={}, maxConcurrentRuns={}, brainCapacity={}", owner, max, + properties.brainCapacity().total()); + control.scheduleWithFixedDelay(guarded("heartbeat", this::heartbeat), 0, + properties.heartbeatInterval().toMillis(), TimeUnit.MILLISECONDS); + control.scheduleWithFixedDelay(guarded("dispatch", this::dispatch), properties.pollInterval().toMillis(), + properties.pollInterval().toMillis(), TimeUnit.MILLISECONDS); + control.scheduleWithFixedDelay(guarded("deadline", this::sweepDeadlines), + properties.deadlineSweepInterval().toMillis(), properties.deadlineSweepInterval().toMillis(), + TimeUnit.MILLISECONDS); + control.scheduleWithFixedDelay(guarded("recovery", this::recoverStale), + properties.recovery().interval().toMillis(), properties.recovery().interval().toMillis(), + TimeUnit.MILLISECONDS); + } + + /** + * claim을 멈추고 coordinator lease를 내놓은 뒤 worker에 멈춤 신호를 보낸다. 전체 대기는 {@code shutdownTimeout} 하나로 + * 묶는다(Cloud Run 종료 유예 안). 진행 중 run은 terminal이 아니라 lease만 만료된 채 남는다(transport 종료를 성공으로 + * 보지 않는다, Brain 실행은 취소하지 않는다). 그 run은 terminal이 될 때까지 전역 슬롯을 차지하고 회수는 1.85다. + */ + @Override + public void stop() { + if (!running.compareAndSet(true, false)) { + return; + } + long deadline = System.nanoTime() + properties.shutdownTimeout().toNanos(); + control.shutdownNow(); + try { + // 진행 중인 heartbeat·claim이 끝난 뒤에 worker를 멈춘다 — 늦은 heartbeat가 만료시킨 lease를 되살리지 않게. + if (!control.awaitTermination(remaining(deadline) / 2, TimeUnit.NANOSECONDS)) { + log.warn("run coordinator control tasks did not stop in time"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + // worker 대기보다 먼저 내놓는다. 슬롯 상한은 DB 실행 수가 지키므로 다음 인스턴스가 바로 인수해도 된다. + leader.set(false); + try { + claims.releaseCoordinatorLease(owner); + } catch (RuntimeException e) { + log.warn("coordinator lease release failed: cause={}", e.getClass().getSimpleName()); + } + } + executions.values().forEach(execution -> execution.stop(StopReason.SHUTDOWN)); + workers.shutdown(); + try { + if (!workers.awaitTermination(remaining(deadline), TimeUnit.NANOSECONDS)) { + log.warn("run coordinator workers did not stop in time: remaining={}", executions.size()); + workers.shutdownNow(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + workers.shutdownNow(); + } + log.info("run coordinator stopped: owner={}", owner); + } + + private static long remaining(long deadline) { + return Math.max(0, deadline - System.nanoTime()); + } + + @Override + public boolean isRunning() { + return running.get(); + } + + // ------------------------------------------------------------------ + // 1.84·1.85 hook + // ------------------------------------------------------------------ + + /** 이 인스턴스에서 그 run의 worker가 돌고 있는지(1.85: 살아 있는 worker가 있는 run은 stale 회수 대상이 아니다). */ + public boolean isExecuting(UUID runId) { + return executions.containsKey(runId); + } + + /** + * 그 run의 로컬 stream 소비를 멈춘다(슬롯 반환, Brain 실행은 계속). 1.84가 CANCELING 뒤 cancel 확인을 직접 맡거나 + * 다른 경로가 terminal을 확정한 뒤 부른다. 부르지 않아도 worker는 heartbeat 실패(lease·fence·terminal)나 + * NOT_ACCEPTING terminal로 스스로 끝난다. + */ + public boolean release(UUID runId) { + RunExecution execution = executions.get(runId); + if (execution == null) { + return false; + } + execution.stop(StopReason.FINISHED_ELSEWHERE); + return true; + } + + /** 이 인스턴스가 coordinator lease를 가졌는지. */ + public boolean isLeader() { + return leader.get(); + } + + /** 지금 이 인스턴스가 쓰는 전역 슬롯 수. */ + public int slotsInUse() { + return properties.maxConcurrentRuns() - slots.availablePermits(); + } + + String owner() { + return owner; + } + + Collection executions() { + return executions.values(); + } + + // ------------------------------------------------------------------ + // 주기 작업 + // ------------------------------------------------------------------ + + /** coordinator lease 갱신 + 실행 중 run lease 갱신. lease를 잃은 run의 worker는 멈춘다. */ + void heartbeat() { + boolean held; + try { + held = claims.acquireCoordinatorLease(owner, properties.leaseTtl()); + } catch (RuntimeException e) { + // 갱신을 확인하지 못했으면 claim을 멈춘다. run lease 갱신은 아래에서 따로 계속한다. + held = false; + metrics.error("coordinator_lease"); + log.warn("coordinator lease renewal failed: cause={}", e.getClass().getSimpleName()); + } + if (leader.getAndSet(held) != held) { + if (held) { + log.info("run coordinator lease acquired: owner={}", owner); + } else { + log.warn("run coordinator lease held by another instance; this instance stays passive: owner={}", + owner); + } + } + for (RunExecution execution : executions.values()) { + if (execution.stopReason() != null) { + continue; + } + try { + if (!claims.heartbeat(execution.claim(), owner, properties.leaseTtl())) { + metrics.heartbeatLost(); + log.info("run lease no longer held: runId={}", execution.claim().runId()); + execution.stop(StopReason.LEASE_LOST); + } + } catch (RuntimeException e) { + // 한 번의 갱신 실패는 lease TTL이 흡수한다. 다음 주기에 다시 시도한다. + metrics.error("heartbeat"); + log.warn("run heartbeat failed: runId={}, cause={}", execution.claim().runId(), + e.getClass().getSimpleName()); + } + } + } + + /** 빈 슬롯만큼 PENDING run을 claim해 worker를 시작한다. */ + void dispatch() { + dispatchQueued.set(false); + if (!running.get() || !leader.get()) { + return; + } + // 주기 dispatch와 슬롯 반환 뒤 dispatch가 겹쳐 같은 DB 수로 각자 claim하면 전역 상한을 넘는다. 한 번에 하나만 돈다. + // 기다리지 않는다(control thread를 묶으면 heartbeat·sweep이 늦어진다): 이미 돌고 있으면 다시 돌라고 표시만 하고, + // 잡고 있는 쪽이 끝난 뒤 한 번 더 돈다. + do { + if (!dispatchLock.tryLock()) { + redispatch.set(true); + return; + } + try { + redispatch.set(false); + dispatchLocked(); + } finally { + dispatchLock.unlock(); + } + } while (redispatch.get() && running.get() && leader.get()); + } + + private void dispatchLocked() { + int free = Math.min(slots.availablePermits(), + properties.maxConcurrentRuns() - claims.countActiveExecutions()); + for (int i = 0; i < free && running.get(); i++) { + if (!slots.tryAcquire()) { + return; + } + Optional claimed; + try { + claimed = claims.claimNext(owner, newFence(), properties.leaseTtl(), + properties.minDispatchRemaining()); + } catch (RuntimeException e) { + slots.release(); + throw e; + } + if (claimed.isEmpty()) { + slots.release(); + return; + } + launch(claimed.get()); + } + } + + /** 요청 deadline이 지난 run을 timeout 실패·보상으로 끝낸다(PENDING 대기 중 포함). */ + void sweepDeadlines() { + if (!running.get() || !leader.get()) { + return; + } + List expired = claims.findDeadlinePassed(DEADLINE_BATCH); + for (UUID runId : expired) { + // 한 run의 실패가 batch를 막지 않게 run별로 처리한다(같은 run이 매 주기 맨 앞에 와도 나머지는 진행). + try { + TerminalSettlement settlement = terminals.expireDeadline(runId); + if (settlement.outcome() == TerminalSettlement.Outcome.COMMITTED) { + metrics.deadlineExpired(); + log.info("run deadline expired: runId={}", runId); + claims.reserveRuntimeCleanup(runId, properties.runtimeCleanupDelay()); + RunExecution execution = executions.get(runId); + if (execution != null) { + execution.stop(StopReason.FINISHED_ELSEWHERE); + } + } + } catch (RuntimeException e) { + metrics.error("deadline"); + log.warn("deadline expiry failed: runId={}, cause={}", runId, e.getClass().getSimpleName()); + } + } + } + + /** + * lease가 만료된 stale run을 같은 attempt·fence로 인수해 status 수습 worker를 띄운다. 살아 있는 로컬 worker가 있는 run + * (heartbeat만 늦은 경우)은 건너뛴다. 로컬 permit이 없으면 다음 주기로 미룬다. 인수 횟수가 한도를 넘은 run은 저장 + * terminal 확인 한 번만 한다({@link Mode#FINAL_CHECK}). 이 작업만 비PENDING run의 worker를 띄우므로 같은 run의 로컬 + * worker가 겹치지 않는다(주기 작업은 자기와 겹쳐 돌지 않는다). + */ + void recoverStale() { + if (!running.get() || !leader.get()) { + return; + } + RunCoordinatorProperties.Recovery policy = properties.recovery(); + // 이 인스턴스에서 아직 도는 worker의 run은 SQL에서 뺀다(앞쪽 batch가 그런 행으로 차서 뒤 후보가 가려지지 않게). + for (UUID runId : claims.findStale(policy.batch(), executions.keySet())) { + if (!running.get()) { + return; + } + if (executions.containsKey(runId)) { + continue; + } + if (!slots.tryAcquire()) { + metrics.recovery("NO_PERMIT"); + return; + } + Optional taken; + try { + taken = claims.takeOver(runId, owner, properties.leaseTtl()); + } catch (RuntimeException e) { + slots.release(); + metrics.error("recovery"); + log.warn("stale run takeover failed: runId={}, cause={}", runId, e.getClass().getSimpleName()); + continue; + } + if (taken.isEmpty()) { + // heartbeat 재개·terminal·취소·다른 인수자가 먼저였다. + slots.release(); + metrics.recovery("NOT_TAKEN"); + continue; + } + StaleRun stale = taken.get(); + if (!running.get()) { + // 종료와 겹쳤다: 방금 갱신한 lease를 TTL 동안 쥐지 않게 즉시 만료시켜 다음 인스턴스에 넘긴다. + slots.release(); + metrics.recovery("ABANDONED_STOPPING"); + try { + claims.expireLease(stale.claim(), owner); + } catch (RuntimeException e) { + log.warn("lease expiry failed: runId={}, cause={}", runId, e.getClass().getSimpleName()); + } + return; + } + Mode mode = stale.recoveryCount() > policy.maxRecoveries() ? Mode.FINAL_CHECK : Mode.RECOVER; + metrics.recovery(mode == Mode.RECOVER ? "TAKEN" : "LIMIT_EXCEEDED"); + log.info("stale run taken over: runId={}, attempt={}, recoveryCount={}, mode={}", runId, + stale.claim().attempt(), stale.recoveryCount(), mode); + launch(stale.claim(), mode); + } + } + + private void launch(ClaimedRun claim) { + metrics.claimed(); + log.info("run claimed: runId={}, attempt={}", claim.runId(), claim.attempt()); + launch(claim, Mode.DISPATCH); + } + + private void launch(ClaimedRun claim, Mode mode) { + RunExecution execution = new RunExecution(claim, mode, dependencies, this::finished); + executions.put(claim.runId(), execution); + try { + workers.execute(execution); + } catch (RejectedExecutionException e) { + // 대기열이 유한하지 않으므로 pool이 종료될 때(stop과 겹친 claim)만 일어난다. 정상 run을 실패시키지 않고 + // claim도 되돌리지 않는다: lease를 만료시켜 회수(1.85) 대상으로 남긴다. run은 terminal까지 슬롯을 차지한다. + executions.remove(claim.runId()); + slots.release(); + metrics.error("launch"); + if (mode != Mode.DISPATCH) { + metrics.recoveryFinished(RunExecution.Result.STOPPED); + } + log.warn("run worker not started (coordinator stopping): runId={}", claim.runId()); + try { + claims.expireLease(claim, owner); + } catch (RuntimeException expiry) { + log.warn("lease expiry failed: runId={}, cause={}", claim.runId(), expiry.getClass().getSimpleName()); + } + } + } + + private void finished(RunExecution execution) { + executions.remove(execution.claim().runId(), execution); + slots.release(); + if (execution.mode() != Mode.DISPATCH) { + metrics.recoveryFinished(execution.result()); + } + // 반환한 슬롯을 다음 PENDING run이 poll 주기를 기다리지 않고 쓰게 한다. + if (running.get() && dispatchQueued.compareAndSet(false, true)) { + try { + control.execute(guarded("dispatch", this::dispatch)); + } catch (RejectedExecutionException ignored) { + dispatchQueued.set(false); + } + } + } + + /** + * 고정 크기 worker pool. 대기열 길이는 semaphore(전역 슬롯)가 상한을 지킨다 — permit은 run 하나당 하나이고 worker가 + * 끝날 때 반환되므로, 반환 직후 thread가 아직 비지 않았을 때 들어온 run은 거부되지 않고 잠깐 대기열에서 기다린다. + */ + static ThreadPoolExecutor workerPool(int size) { + return new ThreadPoolExecutor(size, size, 0, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>(), + threads("runCoordinator-worker-")); + } + + private Runnable guarded(String stage, Runnable task) { + return () -> { + try { + task.run(); + } catch (RuntimeException e) { + metrics.error(stage); + log.warn("run coordinator {} failed: cause={}", stage, e.getClass().getSimpleName()); + } + }; + } + + private static String newFence() { + return "fence-" + UUID.randomUUID(); + } + + private static ThreadFactory threads(String prefix) { + AtomicInteger sequence = new AtomicInteger(); + return runnable -> { + Thread thread = new Thread(runnable, prefix + sequence.incrementAndGet()); + thread.setDaemon(true); + return thread; + }; + } +} diff --git a/src/main/java/com/proovy/domain/run/coordinator/RunCoordinatorConfig.java b/src/main/java/com/proovy/domain/run/coordinator/RunCoordinatorConfig.java new file mode 100644 index 00000000..93f245ed --- /dev/null +++ b/src/main/java/com/proovy/domain/run/coordinator/RunCoordinatorConfig.java @@ -0,0 +1,77 @@ +package com.proovy.domain.run.coordinator; + +import com.proovy.domain.run.event.RunEventStore; +import com.proovy.domain.run.repository.AiRunRepository; +import com.proovy.domain.run.snapshot.RunAssetAccess; +import com.proovy.domain.run.terminal.RunTerminalService; +import com.proovy.global.infra.brain.BrainRunClient; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import java.net.InetAddress; +import java.time.Clock; +import java.util.UUID; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.support.TransactionTemplate; + +/** + * {@code proovy.run.coordinator.enabled=true}일 때만 coordinator를 만든다. 켜졌는데 Brain client가 없으면 기동을 실패시킨다 + * (접수된 run이 조용히 PENDING에 머무르지 않게). 다중 인스턴스에서도 coordinator lease 보유자 하나만 claim한다. + */ +@Configuration +@ConditionalOnProperty(prefix = "proovy.run.coordinator", name = "enabled", havingValue = "true") +@EnableConfigurationProperties(RunCoordinatorProperties.class) +public class RunCoordinatorConfig { + + @Bean + public RunClaimStore runCoordinatorClaimStore(JdbcTemplate jdbc, TransactionTemplate tx) { + return new RunClaimStore(jdbc, tx); + } + + /** S09/S10 전 기본값: grant·read URL 없음(run-snapshot-v1 R7). 발급처가 생기면 이 bean을 교체한다. */ + @Bean + @ConditionalOnMissingBean + public RunAssetAccessProvider runCoordinatorAssetAccess() { + return (run, claim) -> RunAssetAccess.NONE; + } + + @Bean + public RunCoordinator runCoordinator(RunCoordinatorProperties properties, RunClaimStore claims, + ObjectProvider brain, RunEventStore events, RunTerminalService terminals, + AiRunRepository runs, RunAssetAccessProvider access, ObjectProvider registry) { + BrainRunClient client = brain.getIfAvailable(); + if (client == null) { + throw new IllegalStateException( + "proovy.run.coordinator.enabled=true requires the Brain client (proovy.brain.base-url)"); + } + RunCoordinatorMetrics metrics = new RunCoordinatorMetrics(registry.getIfAvailable(SimpleMeterRegistry::new)); + String owner = properties.instanceId() == null || properties.instanceId().isBlank() + ? defaultInstanceId() : properties.instanceId(); + RunExecution.Dependencies dependencies = new RunExecution.Dependencies(client, events, terminals, claims, runs, + access, properties, metrics, owner, Clock.systemUTC()); + return new RunCoordinator(properties, claims, terminals, dependencies, metrics); + } + + /** host 이름(허용 문자만, 60자) + 무작위 접미사. 재시작마다 달라 이전 프로세스의 lease와 구별된다. */ + static String defaultInstanceId() { + String host; + try { + host = InetAddress.getLocalHost().getHostName(); + } catch (Exception e) { + host = "server"; + } + host = host.replaceAll("[^A-Za-z0-9_.:-]", "-").replaceAll("^[^A-Za-z0-9]+", ""); + if (host.isEmpty()) { + host = "server"; + } + if (host.length() > 60) { + host = host.substring(0, 60); + } + return host + "-" + UUID.randomUUID().toString().substring(0, 8); + } +} diff --git a/src/main/java/com/proovy/domain/run/coordinator/RunCoordinatorMetrics.java b/src/main/java/com/proovy/domain/run/coordinator/RunCoordinatorMetrics.java new file mode 100644 index 00000000..92fbfa9a --- /dev/null +++ b/src/main/java/com/proovy/domain/run/coordinator/RunCoordinatorMetrics.java @@ -0,0 +1,86 @@ +package com.proovy.domain.run.coordinator; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.MeterRegistry; +import java.util.function.Supplier; + +/** + * coordinator 관측 지표(Micrometer). tag 값은 고정 enum 이름뿐이다 — run·request ID·payload를 label로 쓰지 않는다. + *
    + *
  • {@code proovy.run.coordinator.slots.in_use}·{@code .slots.max}·{@code .leader}(gauge)
  • + *
  • {@code proovy.run.coordinator.claims}: claim한 run
  • + *
  • {@code proovy.run.coordinator.redeliveries{kind}}: 같은 attempt 재전달 원인(BrainFailureKind 또는 status state)
  • + *
  • {@code proovy.run.coordinator.status_checks{result}}: status 조회 결과
  • + *
  • {@code proovy.run.coordinator.executions{result}}: worker 종료 사유({@link RunExecution.Result})
  • + *
  • {@code proovy.run.coordinator.server_failures{code}}: coordinator가 확정한 Server 실패
  • + *
  • {@code proovy.run.coordinator.deadline_expired}·{@code .heartbeat.lost}·{@code .errors{stage}}
  • + *
  • {@code proovy.run.coordinator.recoveries{result}}: stale run 인수 시도(TAKEN·LIMIT_EXCEEDED·NOT_TAKEN·NO_PERMIT·ABANDONED_STOPPING·FINAL_CANCEL_PENDING)
  • + *
  • {@code proovy.run.coordinator.recoveries.finished{result}}: 인수 worker 종료 사유(슬롯 반환 시점)
  • + *
  • {@code proovy.run.coordinator.execution_lost}: Brain이 시작했던 실행의 record가 사라져 실패 수습한 run
  • + *
+ */ +class RunCoordinatorMetrics { + + private static final String PREFIX = "proovy.run.coordinator."; + + private final MeterRegistry registry; + + RunCoordinatorMetrics(MeterRegistry registry) { + this.registry = registry; + } + + void gauges(Supplier inUse, int max, Supplier leader) { + Gauge.builder(PREFIX + "slots.in_use", inUse).register(registry); + Gauge.builder(PREFIX + "slots.max", () -> max).register(registry); + Gauge.builder(PREFIX + "leader", leader).register(registry); + } + + void claimed() { + counter("claims").increment(); + } + + void redelivery(String kind) { + counter("redeliveries", "kind", kind).increment(); + } + + void statusCheck(String result) { + counter("status_checks", "result", result).increment(); + } + + void execution(RunExecution.Result result) { + counter("executions", "result", result.name()).increment(); + } + + void serverFailure(String code) { + counter("server_failures", "code", code).increment(); + } + + void deadlineExpired() { + counter("deadline_expired").increment(); + } + + void heartbeatLost() { + counter("heartbeat.lost").increment(); + } + + void recovery(String result) { + counter("recoveries", "result", result).increment(); + } + + void recoveryFinished(RunExecution.Result result) { + counter("recoveries.finished", "result", result.name()).increment(); + } + + void executionLost() { + counter("execution_lost").increment(); + } + + void error(String stage) { + counter("errors", "stage", stage).increment(); + } + + private Counter counter(String name, String... tags) { + return Counter.builder(PREFIX + name).tags(tags).register(registry); + } +} diff --git a/src/main/java/com/proovy/domain/run/coordinator/RunCoordinatorProperties.java b/src/main/java/com/proovy/domain/run/coordinator/RunCoordinatorProperties.java new file mode 100644 index 00000000..657a23bc --- /dev/null +++ b/src/main/java/com/proovy/domain/run/coordinator/RunCoordinatorProperties.java @@ -0,0 +1,162 @@ +package com.proovy.domain.run.coordinator; + +import java.time.Duration; +import java.util.regex.Pattern; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.DefaultValue; + +/** + * {@code proovy.run.coordinator.*} — 브라우저와 독립적인 단일 Server run coordinator(task 1.83). + * 기동 시 한 번 검사하고 실행 중에 바꾸지 않는다. 값은 run의 deadline·예산을 바꾸지 않는다(그 값은 접수 snapshot이 고정한다). + * + * @param enabled coordinator를 켠다. 켜면 Brain client({@code proovy.brain.base-url})가 있어야 한다 + * @param instanceId lease owner 값. 비우면 host 이름과 무작위 접미사로 만든다 + * @param maxConcurrentRuns 전역 실행 슬롯(Server가 동시에 Brain에 위임하는 run 수) + * @param brainCapacity Brain 배포 상한. 전역 슬롯은 {@code instances × perInstance}를 넘을 수 없다 + * @param pollInterval PENDING run 조회 주기(빈 슬롯이 있을 때만 claim한다) + * @param heartbeatInterval run lease·coordinator lease 갱신 주기 + * @param leaseTtl heartbeat가 끊긴 뒤 stale로 보는 시간(docs/plan/09 시작값 90초) + * @param deadlineSweepInterval 요청 deadline이 지난 run을 timeout으로 끝내는 주기 + * @param minDispatchRemaining 남은 deadline이 이보다 짧으면 새로 claim하지 않는다(deadline sweep이 끝낸다) + * @param redelivery 살아 있는 실행의 같은 attempt 재전달·status 조회 한도 + * @param recovery lease가 만료된 stale 실행의 회수(1.85) 주기·한도 + * @param runtimeCleanupDelay terminal 확정 뒤 Brain runtime cleanup 예약 시각({@code runtime_cleanup_at}) 지연 + * @param shutdownTimeout 종료 전체 대기 예산(control·worker 합계, Cloud Run 종료 유예 10초 안) + */ +@ConfigurationProperties(prefix = "proovy.run.coordinator") +public record RunCoordinatorProperties( + @DefaultValue("false") boolean enabled, + String instanceId, + @DefaultValue("4") int maxConcurrentRuns, + @DefaultValue BrainCapacity brainCapacity, + @DefaultValue("1s") Duration pollInterval, + @DefaultValue("15s") Duration heartbeatInterval, + @DefaultValue("90s") Duration leaseTtl, + @DefaultValue("5s") Duration deadlineSweepInterval, + @DefaultValue("5s") Duration minDispatchRemaining, + @DefaultValue Redelivery redelivery, + @DefaultValue Recovery recovery, + @DefaultValue("0s") Duration runtimeCleanupDelay, + @DefaultValue("8s") Duration shutdownTimeout) { + + static final Pattern INSTANCE_ID = Pattern.compile("^[A-Za-z0-9][A-Za-z0-9_.:-]{0,99}$"); + private static final String PREFIX = "proovy.run.coordinator."; + + public RunCoordinatorProperties { + if (instanceId != null && !instanceId.isBlank() && !INSTANCE_ID.matcher(instanceId).matches()) { + throw new IllegalArgumentException(PREFIX + "instance-id must match " + INSTANCE_ID.pattern()); + } + if (maxConcurrentRuns < 1 || maxConcurrentRuns > 64) { + throw new IllegalArgumentException(PREFIX + "max-concurrent-runs must be within 1..64"); + } + // Brain 인스턴스 상한(503 capacity)은 마지막 방어선이지 전역 슬롯의 대체물이 아니다(docs/plan/09). + if (maxConcurrentRuns > brainCapacity.total()) { + throw new IllegalArgumentException(PREFIX + "max-concurrent-runs (" + maxConcurrentRuns + + ") must not exceed brain-capacity.instances x per-instance (" + brainCapacity.total() + ")"); + } + positive(pollInterval, "poll-interval"); + positive(heartbeatInterval, "heartbeat-interval"); + positive(leaseTtl, "lease-ttl"); + positive(deadlineSweepInterval, "deadline-sweep-interval"); + positive(shutdownTimeout, "shutdown-timeout"); + if (leaseTtl.compareTo(heartbeatInterval.multipliedBy(3)) < 0) { + throw new IllegalArgumentException(PREFIX + "lease-ttl must be at least 3 x heartbeat-interval"); + } + notNegative(minDispatchRemaining, "min-dispatch-remaining"); + notNegative(runtimeCleanupDelay, "runtime-cleanup-delay"); + } + + /** + * @param instances Brain Cloud Run 최대 인스턴스 수(MVP 3) + * @param perInstance Brain 인스턴스당 동시 실행 상한 {@code MAX_CONCURRENT_RUNS}(4) + */ + public record BrainCapacity( + @DefaultValue("3") int instances, + @DefaultValue("4") int perInstance) { + + public BrainCapacity { + if (instances < 1 || perInstance < 1) { + throw new IllegalArgumentException(PREFIX + "brain-capacity values must be positive"); + } + } + + public int total() { + return Math.multiplyExact(instances, perInstance); + } + } + + /** + * 같은 attempt·fence의 재전달 한도. 재전달은 Brain에서 미시작(START)·기존 실행 관찰(OBSERVE)·저장 terminal 재전달만 + * 만들고 새 graph epoch를 만들지 않는다. 모든 재전달·조회는 run의 고정 deadline 안에서만 한다. + * + * @param maxResends run 하나의 stream 재전달 최대 횟수(capacity·연결 실패·status 뒤 재연결 합계) + * @param maxStatusChecks 연속 status 조회 실패 허용 횟수 + * @param initialBackoff 첫 재전달 전 대기 + * @param maxBackoff 재전달 대기 상한(지수 증가) + */ + public record Redelivery( + @DefaultValue("6") int maxResends, + @DefaultValue("3") int maxStatusChecks, + @DefaultValue("1s") Duration initialBackoff, + @DefaultValue("15s") Duration maxBackoff) { + + public Redelivery { + if (maxResends < 0 || maxResends > 20) { + throw new IllegalArgumentException(PREFIX + "redelivery.max-resends must be within 0..20"); + } + if (maxStatusChecks < 1 || maxStatusChecks > 10) { + throw new IllegalArgumentException(PREFIX + "redelivery.max-status-checks must be within 1..10"); + } + positive(initialBackoff, "redelivery.initial-backoff"); + positive(maxBackoff, "redelivery.max-backoff"); + if (maxBackoff.compareTo(initialBackoff) < 0) { + throw new IllegalArgumentException(PREFIX + "redelivery.max-backoff must be >= initial-backoff"); + } + } + + /** {@code n}번째(1부터) 재시도 전 대기: initial × 2^(n-1), 상한 maxBackoff. */ + public Duration backoff(int n) { + Duration delay = initialBackoff; + for (int i = 1; i < n && delay.compareTo(maxBackoff) < 0; i++) { + delay = delay.multipliedBy(2); + } + return delay.compareTo(maxBackoff) > 0 ? maxBackoff : delay; + } + } + + /** + * stale 실행 회수(task 1.85). lease가 만료되고 이 인스턴스에 worker가 없는 {@code RUNNING|RETRYING} run을 같은 + * attempt·fence로 인수해 status부터 본다. 인수 횟수는 run 행({@code recovery_count})에 누적돼 재시작으로 초기화되지 않는다. + * + * @param interval 회수 후보 조회 주기 + * @param maxRecoveries run 하나의 인수 허용 횟수. 넘으면 status 한 번으로 저장 terminal만 확인하고, 없으면 실패·보상한다 + * @param batch 한 주기에 인수를 시도하는 최대 run 수(빈 로컬 permit 수가 먼저 막는다) + */ + public record Recovery( + @DefaultValue("5s") Duration interval, + @DefaultValue("3") int maxRecoveries, + @DefaultValue("8") int batch) { + + public Recovery { + positive(interval, "recovery.interval"); + if (maxRecoveries < 1 || maxRecoveries > 20) { + throw new IllegalArgumentException(PREFIX + "recovery.max-recoveries must be within 1..20"); + } + if (batch < 1 || batch > 64) { + throw new IllegalArgumentException(PREFIX + "recovery.batch must be within 1..64"); + } + } + } + + private static void positive(Duration value, String name) { + if (value == null || value.isZero() || value.isNegative()) { + throw new IllegalArgumentException(PREFIX + name + " must be positive"); + } + } + + private static void notNegative(Duration value, String name) { + if (value == null || value.isNegative()) { + throw new IllegalArgumentException(PREFIX + name + " must not be negative"); + } + } +} diff --git a/src/main/java/com/proovy/domain/run/coordinator/RunExecution.java b/src/main/java/com/proovy/domain/run/coordinator/RunExecution.java new file mode 100644 index 00000000..5d638e15 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/coordinator/RunExecution.java @@ -0,0 +1,539 @@ +package com.proovy.domain.run.coordinator; + +import com.proovy.domain.run.brain.InternalRunContractViolation; +import com.proovy.domain.run.brain.InternalRunRequest; +import com.proovy.domain.run.brain.RunStatusQuery; +import com.proovy.domain.run.brain.RunStatusResponse; +import com.proovy.domain.run.brain.SourceEvent; +import com.proovy.domain.run.brain.StoredTerminal; +import com.proovy.domain.run.brain.StoredTerminalType; +import com.proovy.domain.run.entity.AiRun; +import com.proovy.domain.run.entity.AiRunStatus; +import com.proovy.domain.run.entity.RunFailureCode; +import com.proovy.domain.run.event.ActivityAppendResult; +import com.proovy.domain.run.event.RunEventStore; +import com.proovy.domain.run.repository.AiRunRepository; +import com.proovy.domain.run.snapshot.RunAssetAccess; +import com.proovy.domain.run.snapshot.RunSnapshotIntegrityException; +import com.proovy.domain.run.snapshot.RunSnapshotRestorer; +import com.proovy.domain.run.terminal.RunTerminalService; +import com.proovy.domain.run.terminal.TerminalSettlement; +import com.proovy.global.infra.brain.BrainCallException; +import com.proovy.global.infra.brain.BrainFailureKind; +import com.proovy.global.infra.brain.BrainRunClient; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Iterator; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import java.util.stream.Stream; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataAccessResourceFailureException; +import org.springframework.dao.RecoverableDataAccessException; +import org.springframework.dao.TransientDataAccessException; +import org.springframework.transaction.CannotCreateTransactionException; +import reactor.core.publisher.Sinks; + +/** + * claim한 run 하나의 Brain 위임(worker thread 하나, 전역 슬롯 하나). 브라우저 연결과 무관하다. + *

규칙

+ *
    + *
  • 요청은 {@link RunSnapshotRestorer#delivery}로 저장 snapshot·run 행 attempt·fence·누적 usage에서만 만든다. + * 재전달도 같은 요청(같은 attempt·fence)이라 Brain은 미시작이면 START, 실행 중이면 OBSERVE, 끝났으면 + * RETURN_TERMINAL로만 답한다. 새 attempt(새 graph epoch)를 만들지 않으므로 소실된 graph를 다시 시작하지 않는다.
  • + *
  • stream 소비 중 transaction·row lock이 없다. Activity는 {@link RunEventStore#appendActivity}, terminal은 + * {@link RunTerminalService#settle}의 짧은 자기 transaction이다.
  • + *
  • stream이 terminal 없이 끝나면(transport 종료) 성공이 아니다 — client가 실패 kind로 알리고 status로 수습한다.
  • + *
  • 재전달·조회는 {@link RunCoordinatorProperties.Redelivery} 횟수와 run의 고정 {@code request_deadline_at} 안에서만 + * 한다. deadline이 지나면 {@link RunTerminalService#expireDeadline}(timeout)이다.
  • + *
+ *

실패 kind (1.76 {@link BrainFailureKind})

+ *
    + *
  • 재전송 가능(연결 실패·capacity·credential): RETRYING 표시 → 지수 backoff → 같은 요청.
  • + *
  • status로 수습(단절·timeout·5xx·terminal 없는 종료): 같은 attempt·fence로 status. 같은 epoch executing이면 같은 + * 요청 재전달(OBSERVE), terminal이면 저장 terminal을 {@link RunTerminalService#settle(StoredTerminal)}, fingerprint + * 불일치는 실패. absent는 Brain이 그 요청을 시작한 증거(이 worker가 받은 frame·저장된 이 attempt의 source event)가 + * 없을 때만 미시작으로 보고 같은 요청을 보낸다. 증거가 있으면 실행 소실이라 재전송하지 않고 실패·보상한다(새 graph 없음). + * 조회 한도 소진도 실패·보상이다.
  • + *
  • 영구(계약·인증·contention·binding): {@code failByServer(INTERNAL_ERROR)}.
  • + *
  • stream 중 일시 DB 오류(연결·timeout·직렬화, transaction 시작 시 연결 획득 실패): 같은 재전달 한도 안에서 backoff 뒤 status 수습. 그 밖의 예외와 + * 수습 중 DB 실패는 LOCAL_ERROR(lease 만료 → 회수 1.85·deadline sweep)다.
  • + *
+ *

stale 회수 (1.85, {@link Mode#RECOVER})

+ * lease가 만료된 run을 같은 attempt·fence로 인수한 worker는 stream이 아니라 status부터 본다. 저장 terminal은 새 provider + * 실행 없이 확정하고(snapshot 복원도 하지 않는다), 살아 있는 같은 epoch 실행이면 같은 요청으로 다시 붙는다. 재전달 한도는 + * run 행 {@code retry_count}부터 이어서 세므로 재시작·인수가 예산을 초기화하지 않는다. 인수 한도를 넘은 run + * ({@link Mode#FINAL_CHECK})은 status 한 번으로 저장 terminal만 확인하고, 없으면 재전송 없이 실패·보상한다. + */ +@Slf4j +final class RunExecution implements Runnable { + + /** worker가 끝난 이유. 슬롯은 모든 경우에 반환된다. */ + enum Result { + /** 이 worker가 terminal을 확정했거나 이미 확정된 것을 확인했다. */ + SETTLED, + /** 이 attempt·fence가 더 이상 현재가 아니다(다른 terminal·취소·fence 교체). run은 다른 경로가 끝낸다. */ + OBSOLETE, + /** 종료·lease 상실로 멈췄다. run은 진행 중 상태로 남고 stale 회수(1.85)·deadline sweep 대상이다. */ + STOPPED, + /** DB 오류 등 예상하지 못한 실패. lease를 만료시켜 회수 대상으로 넘긴다. */ + LOCAL_ERROR + } + + /** worker가 run을 맡은 경로. */ + enum Mode { + /** PENDING claim 직후: 첫 전달부터 stream을 연다. */ + DISPATCH, + /** stale run 인수: status부터 보고 필요할 때만 같은 요청을 보낸다. */ + RECOVER, + /** 인수 한도를 넘은 run: status 한 번으로 저장 terminal만 확인하고, 없으면 실패 수습한다. */ + FINAL_CHECK + } + + enum StopReason { + SHUTDOWN, + LEASE_LOST, + FINISHED_ELSEWHERE + } + + record Dependencies( + BrainRunClient brain, + RunEventStore events, + RunTerminalService terminals, + RunClaimStore claims, + AiRunRepository runs, + RunAssetAccessProvider access, + RunCoordinatorProperties properties, + RunCoordinatorMetrics metrics, + String owner, + Clock clock) { + } + + private static final int STREAM_PREFETCH = 8; + + private final ClaimedRun claim; + private final Mode mode; + private final Dependencies deps; + private final Consumer onFinish; + private final CountDownLatch stopLatch = new CountDownLatch(1); + private final Sinks.Empty stopSink = Sinks.empty(); + private volatile StopReason stopReason; + private volatile Result result; + private int resends; + private AiRun run; + private boolean retrying; + /** 이 worker가 Brain stream frame을 하나라도 받았다(Brain이 이 요청을 등록·시작했다). */ + private boolean brainSeen; + + RunExecution(ClaimedRun claim, Mode mode, Dependencies deps, Consumer onFinish) { + this.claim = claim; + this.mode = mode; + this.deps = deps; + this.onFinish = onFinish; + } + + ClaimedRun claim() { + return claim; + } + + Result result() { + return result; + } + + Mode mode() { + return mode; + } + + /** 멈춤 신호: 열린 stream 구독을 끊고(Brain 실행은 계속된다) backoff 대기를 깨운다. */ + void stop(StopReason reason) { + if (stopReason == null) { + stopReason = reason; + } + stopSink.tryEmitEmpty(); + stopLatch.countDown(); + } + + StopReason stopReason() { + return stopReason; + } + + @Override + public void run() { + Result outcome = Result.LOCAL_ERROR; + try { + outcome = execute(); + } catch (RuntimeException e) { + // 예외 message에는 SQL·값이 들어갈 수 있어 class 이름만 남긴다. + log.error("run execution failed locally: runId={}, attempt={}, cause={}", claim.runId(), claim.attempt(), + e.getClass().getSimpleName()); + deps.metrics().error("execution"); + outcome = Result.LOCAL_ERROR; + } finally { + result = outcome; + if (outcome == Result.LOCAL_ERROR || (outcome == Result.STOPPED && stopReason == StopReason.SHUTDOWN)) { + expireLease(); + } + deps.metrics().execution(outcome); + log.info("run execution finished: runId={}, attempt={}, mode={}, result={}, resends={}, stop={}", + claim.runId(), claim.attempt(), mode, outcome, resends, stopReason); + onFinish.accept(this); + } + } + + private Result execute() { + run = deps.runs().findById(claim.runId()).orElse(null); + if (run == null || run.getAttempt() != claim.attempt() || !claim.fenceToken().equals(run.getFenceToken()) + || (run.getStatus() != AiRunStatus.RUNNING && run.getStatus() != AiRunStatus.RETRYING)) { + log.info("run changed before execution: runId={}, mode={}", claim.runId(), mode); + return Result.OBSOLETE; + } + // 재전달 예산은 run 행 retry_count(단조)부터 이어서 센다 — 재시작·인수가 한도를 초기화하지 않는다. + resends = run.getRetryCount(); + retrying = run.getStatus() == AiRunStatus.RETRYING; + Instant deadline = run.getRequestDeadlineAt(); + if (mode != Mode.DISPATCH) { + // 인수한 run은 이전 전달이 Brain에 닿았는지 모른다: 보내기 전에 status로 확인한다(실행을 시작하지 않는 조회). + Result recovered = reconcile(deadline); + if (recovered != null) { + return recovered; + } + } + InternalRunRequest request = null; + while (true) { + if (stopped()) { + return Result.STOPPED; + } + try { + // 첫 전달은 저장 snapshot에서 복원한다. grant·read URL은 전달마다 새로 받는다(재전달에 이전 값을 쓰지 않는다). + request = request == null ? restore() : withFreshAccess(request); + } catch (RunSnapshotIntegrityException | InternalRunContractViolation e) { + // 저장 snapshot을 그대로 복원할 수 없다. 다시 만들지 않고 실패·보상으로 끝낸다(run-snapshot-v1). + log.error("run snapshot cannot be restored: runId={}, cause={}", claim.runId(), + e.getClass().getSimpleName()); + return failByServer(RunFailureCode.INTERNAL_ERROR, "snapshot_integrity"); + } + Result step; + try { + step = consume(request); + } catch (BrainCallException failure) { + step = afterFailure(failure, deadline); + } catch (TransientDataAccessException | RecoverableDataAccessException + | DataAccessResourceFailureException | CannotCreateTransactionException local) { + // Activity·terminal 저장 중 일시 DB 오류: 이 stream을 끊고 같은 attempt로 status 수습에 합류한다. + // Brain은 같은 요청 재전달에 OBSERVE·RETURN_TERMINAL로 답하고 Activity는 source ID로 dedup된다. + step = afterLocalFailure(local, deadline); + } + if (step != null) { + return step; + } + } + } + + private InternalRunRequest restore() { + return RunSnapshotRestorer.delivery(run, deps.access().accessFor(run, claim)); + } + + private InternalRunRequest withFreshAccess(InternalRunRequest request) { + RunAssetAccess access = deps.access().accessFor(run, claim); + return request.withAssetAccess(access.assetAccessGrant(), access.viewerReadUrl(), + access.turnAttachmentReadUrls()); + } + + private Result afterLocalFailure(RuntimeException failure, Instant deadline) { + deps.metrics().error("transient_db"); + log.warn("transient storage failure during stream: runId={}, attempt={}, cause={}", claim.runId(), + claim.attempt(), failure.getClass().getSimpleName()); + RunCoordinatorProperties.Redelivery policy = deps.properties().redelivery(); + if (resends >= policy.maxResends()) { + // DB를 쓸 수 없으니 실패 확정도 못 할 수 있다. 예외가 나면 LOCAL_ERROR로 회수(1.85)·deadline sweep에 넘긴다. + return exhausted(deadline, "resends_TRANSIENT_DB"); + } + resends++; + // 재전달 횟수를 run 행 retry_count에도 남겨 인수가 이 예산을 초기화하지 않게 한다. DB가 아직 안 되면 메모리 횟수만 + // 남는다(그 경우 상한은 max-recoveries × max-resends). + try { + if (!deps.claims().markRetrying(claim, deps.owner())) { + log.info("run no longer owned before redelivery: runId={}", claim.runId()); + return Result.OBSOLETE; + } + retrying = true; + } catch (RuntimeException unavailable) { + log.warn("retry count not persisted: runId={}, cause={}", claim.runId(), + unavailable.getClass().getSimpleName()); + } + if (await(policy.backoff(resends))) { + return Result.STOPPED; + } + return reconcile(deadline); + } + + /** + * stream 하나를 끝까지 소비한다. terminal을 처리하면 그 결과, 멈추면 STOPPED, stream이 쓸모없어지면 OBSOLETE. + * transport 실패는 {@link BrainCallException}으로 던진다(terminal 없는 종료 포함). + */ + private Result consume(InternalRunRequest request) { + try (Stream stream = deps.brain().stream(request) + .takeUntilOther(stopSink.asMono()) + .toStream(STREAM_PREFETCH)) { + Iterator events = stream.iterator(); + while (events.hasNext()) { + SourceEvent event = events.next(); + brainSeen = true; + if (retrying) { + retrying = false; + deps.claims().markRunning(claim, deps.owner()); + } + if (event.isTerminal()) { + return settled(deps.terminals().settle(event)); + } + ActivityAppendResult appended = deps.events().appendActivity(event); + if (appended.isStreamObsolete()) { + log.info("stream obsolete: runId={}, outcome={}", claim.runId(), appended.outcome()); + return Result.OBSOLETE; + } + if (!appended.isStored() && appended.outcome() != ActivityAppendResult.Outcome.NOT_ACCEPTING) { + log.warn("source activity not stored: runId={}, sourceEventId={}, outcome={}", claim.runId(), + event.sourceEventId(), appended.outcome()); + } + } + } + // takeUntilOther로 끊긴 경우만 여기 온다. 끊기지 않았는데 끝났다면 client가 이미 오류를 냈다. + return Result.STOPPED; + } + + private Result afterFailure(BrainCallException failure, Instant deadline) { + if (stopped()) { + return Result.STOPPED; + } + BrainFailureKind kind = failure.kind(); + log.info("brain stream failed: runId={}, attempt={}, kind={}, httpStatus={}", claim.runId(), claim.attempt(), + kind, failure.httpStatus()); + if (kind.isSafeToResend()) { + return resend(kind.name(), deadline); + } + if (kind.isRecoverableByStatusQuery()) { + return reconcile(deadline); + } + if (kind == BrainFailureKind.REQUEST_ID_BOUND_TO_OTHER_RUN) { + log.error("request_id bound to another Brain run (server bug): runId={}", claim.runId()); + } + return failByServer(RunFailureCode.INTERNAL_ERROR, kind.name()); + } + + /** 같은 요청 재전달 준비. null이면 다시 보낸다. */ + private Result resend(String reason, Instant deadline) { + RunCoordinatorProperties.Redelivery policy = deps.properties().redelivery(); + if (resends >= policy.maxResends()) { + return exhausted(deadline, "resends_" + reason); + } + Duration delay = policy.backoff(resends + 1); + if (!now().plus(delay).isBefore(deadline)) { + return exhausted(deadline, "deadline_" + reason); + } + if (!deps.claims().markRetrying(claim, deps.owner())) { + log.info("run no longer owned before redelivery: runId={}", claim.runId()); + return Result.OBSOLETE; + } + resends++; + retrying = true; + deps.metrics().redelivery(reason); + return await(delay) ? Result.STOPPED : null; + } + + /** + * status-recoverable 실패·stale 인수의 수습. 같은 attempt·fence로만 조회하고 실행을 시작하지 않는다. null이면 같은 + * 요청을 다시 보낸다. 대조 fingerprint는 run 행의 {@code request_fingerprint}다(snapshot을 다시 만들지 않는다). + */ + private Result reconcile(Instant deadline) { + RunCoordinatorProperties.Redelivery policy = deps.properties().redelivery(); + // 인수 한도를 넘은 run은 조회도 한 번뿐이다(응답이 없으면 더 기다리지 않고 실패 수습). + int maxChecks = mode == Mode.FINAL_CHECK ? 1 : policy.maxStatusChecks(); + for (int check = 1; ; check++) { + if (stopped()) { + return Result.STOPPED; + } + if (!now().isBefore(deadline)) { + return expire(); + } + RunStatusResponse status; + try { + status = deps.brain().status(RunStatusQuery.of(claim.runId().toString(), claim.attempt(), + claim.fenceToken())).block(); + } catch (BrainCallException failure) { + deps.metrics().statusCheck("FAILED_" + failure.kind().name()); + if (check >= maxChecks) { + return exhausted(deadline, "status_" + failure.kind().name()); + } + if (await(policy.backoff(check))) { + return Result.STOPPED; + } + continue; + } + if (status == null) { + throw new IllegalStateException("status returned no response"); + } + deps.metrics().statusCheck(status.state().name()); + log.info("brain status: runId={}, attempt={}, mode={}, state={}", claim.runId(), claim.attempt(), mode, + status.state()); + switch (status.state()) { + case ABSENT -> { + // Brain record는 영속이라 absent = 이 요청이 등록된 적 없다. 단 Brain이 시작했던 증거가 있으면 + // record가 사라진 실행 소실이다: 같은 요청을 보내면 새 graph가 시작되므로 재전송하지 않는다. + if (brainSeen || deps.claims().hasBrainEvents(claim)) { + deps.metrics().executionLost(); + return failByServer(RunFailureCode.PROVIDER_ERROR, "execution_lost"); + } + if (mode == Mode.FINAL_CHECK) { + return failByServer(RunFailureCode.PROVIDER_ERROR, "recoveries_exhausted"); + } + // 명확한 미시작: 같은 요청이 첫 START다(#215). + return resend("STATUS_ABSENT", deadline); + } + case EXECUTING -> { + if (!matchesRun(status)) { + return failByServer(RunFailureCode.INTERNAL_ERROR, "status_fingerprint_mismatch"); + } + if (mode == Mode.FINAL_CHECK) { + // 인수 한도를 넘었다: 살아 있다는 응답만으로 더 관찰하지 않는다(늦은 terminal은 1.82가 거부한다). + // 다른 epoch도 같다 — Server는 새 attempt를 발급하지 않으므로 반복 인수로 기다릴 대상이 없다. + return failByServer(RunFailureCode.PROVIDER_ERROR, "recoveries_exhausted"); + } + if (!sameEpoch(status)) { + log.warn("brain record follows another epoch: runId={}, attempt={}, brainAttempt={}", + claim.runId(), claim.attempt(), status.attempt()); + return Result.OBSOLETE; + } + // 같은 실행에 다시 붙는다(OBSERVE). 새 실행이 아니다. + return resend("STATUS_EXECUTING", deadline); + } + case TERMINAL -> { + if (!matchesRun(status)) { + return failByServer(RunFailureCode.INTERNAL_ERROR, "status_fingerprint_mismatch"); + } + StoredTerminal terminal = status.terminal(); + if (terminal.type() == StoredTerminalType.RUN_CANCELED) { + // 취소 terminal은 취소 경로(1.84)가 확정한다. + log.info("brain stored run.canceled: runId={}", claim.runId()); + return cancelOwned(); + } + return settled(deps.terminals().settle(terminal)); + } + case CANCELING -> { + return cancelOwned(); + } + default -> throw new IllegalStateException("unknown record state"); + } + } + } + + /** + * Brain이 취소 중·취소됨이다. 취소 수습은 1.84 소유라 회수 경로는 어떤 모드에서도 전이하지 않는다. 인수 한도를 넘었는데도 + * 여기 오면 Server가 아직 CANCELING이 아닌 불일치라 TTL마다 다시 인수된다 — 관측 counter로 드러내고 deadline sweep이 끝낸다. + */ + private Result cancelOwned() { + if (mode == Mode.FINAL_CHECK) { + deps.metrics().recovery("FINAL_CANCEL_PENDING"); + log.warn("stale run reached its recovery limit while brain is canceling; left to the cancel path: runId={}", + claim.runId()); + } + return Result.OBSOLETE; + } + + private boolean matchesRun(RunStatusResponse status) { + return run.getRequestFingerprint().equals(status.requestFingerprint()); + } + + private boolean sameEpoch(RunStatusResponse status) { + return status.attempt() != null && status.attempt() == claim.attempt() + && claim.fenceToken().equals(status.fenceToken()); + } + + private Result settled(TerminalSettlement settlement) { + return switch (settlement.outcome()) { + case COMMITTED, DUPLICATE -> { + reserveCleanup(); + yield Result.SETTLED; + } + case DEADLINE_PASSED -> expire(); + case REJECTED, SCOPE_NOT_READY -> { + // 공개할 결과를 검증하지 못했다. payload는 남기지 않는다(1.82 인계). + log.warn("brain terminal not accepted: runId={}, outcome={}, rejection={}, field={}", claim.runId(), + settlement.outcome(), settlement.rejection(), settlement.rejectionField()); + yield failByServer(RunFailureCode.INTERNAL_ERROR, "terminal_" + settlement.outcome().name()); + } + default -> { + log.info("terminal not applied: runId={}, outcome={}", claim.runId(), settlement.outcome()); + yield Result.OBSOLETE; + } + }; + } + + private Result exhausted(Instant deadline, String reason) { + if (!now().isBefore(deadline)) { + return expire(); + } + return failByServer(RunFailureCode.PROVIDER_ERROR, reason); + } + + private Result expire() { + TerminalSettlement settlement = deps.terminals().expireDeadline(claim.runId()); + if (settlement.isSettled()) { + deps.metrics().deadlineExpired(); + reserveCleanup(); + return Result.SETTLED; + } + return Result.OBSOLETE; + } + + private Result failByServer(RunFailureCode code, String reason) { + TerminalSettlement settlement = deps.terminals().failByServer(claim.runId(), claim.attempt(), + claim.fenceToken(), code, true); + log.warn("server failure: runId={}, attempt={}, code={}, reason={}, outcome={}", claim.runId(), + claim.attempt(), code.wireValue(), reason, settlement.outcome()); + if (settlement.isSettled()) { + deps.metrics().serverFailure(code.wireValue()); + reserveCleanup(); + return Result.SETTLED; + } + return Result.OBSOLETE; + } + + private void reserveCleanup() { + try { + deps.claims().reserveRuntimeCleanup(claim.runId(), deps.properties().runtimeCleanupDelay()); + } catch (RuntimeException e) { + log.warn("runtime cleanup reservation failed: runId={}, cause={}", claim.runId(), + e.getClass().getSimpleName()); + deps.metrics().error("cleanup_reservation"); + } + } + + private void expireLease() { + try { + deps.claims().expireLease(claim, deps.owner()); + } catch (RuntimeException e) { + log.warn("lease expiry failed: runId={}, cause={}", claim.runId(), e.getClass().getSimpleName()); + } + } + + /** true면 기다리는 중 멈춤 신호를 받았다. */ + private boolean await(Duration delay) { + try { + return stopLatch.await(delay.toMillis(), TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + stop(StopReason.SHUTDOWN); + return true; + } + } + + private boolean stopped() { + return stopReason != null; + } + + private Instant now() { + return deps.clock().instant(); + } +} diff --git a/src/main/java/com/proovy/domain/run/entity/AiRun.java b/src/main/java/com/proovy/domain/run/entity/AiRun.java new file mode 100644 index 00000000..73adab97 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/entity/AiRun.java @@ -0,0 +1,203 @@ +package com.proovy.domain.run.entity; + +import com.fasterxml.jackson.databind.JsonNode; +import io.hypersistence.utils.hibernate.type.json.JsonBinaryType; +import com.proovy.global.entity.AssignedIdEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Type; + +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +/** + * Brain 실행 하나의 Server 측 기록 (docs/plan/05·12, spec 07). + * + *

요청 식별·불변 snapshot·deadline 컬럼은 생성 시 고정되며 DB trigger가 변경을 거부한다. + * 상태 전이·lease·attempt/fence·누적 사용량은 엔티티 dirty checking이 아니라 후속 coordinator의 + * 조건부 UPDATE(WHERE status/attempt/fence ...)로만 바꾼다. 그래서 이 엔티티는 생성 factory와 + * 조회용 getter만 제공한다. + */ +@Entity +@Table(name = "ai_runs") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class AiRun extends AssignedIdEntity { + + @Id + @Column(name = "run_id") + private UUID id; + + @Column(name = "request_id", nullable = false, length = 128, updatable = false) + private String requestId; + + @Column(name = "user_id", nullable = false, updatable = false) + private Long userId; + + @Column(name = "note_id", nullable = false, updatable = false) + private Long noteId; + + @Column(name = "user_message_id", nullable = false, updatable = false) + private UUID userMessageId; + + @Column(name = "assistant_message_id", nullable = false, updatable = false) + private UUID assistantMessageId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private AiRunStatus status; + + // ===== 실행 제어 ===== + + @Column(nullable = false) + private Integer attempt; + + @Column(name = "fence_token", length = 128) + private String fenceToken; + + @Column(name = "retry_count", nullable = false) + private Integer retryCount; + + @Column(name = "lease_owner", length = 128) + private String leaseOwner; + + @Column(name = "lease_expires_at") + private Instant leaseExpiresAt; + + @Column(name = "heartbeat_at") + private Instant heartbeatAt; + + /** Asset Access Grant의 SHA-256 digest. grant 원문·signed URL은 저장하지 않는다. */ + @Column(name = "grant_digest", length = 64) + private String grantDigest; + + @Column(name = "grant_expires_at") + private Instant grantExpiresAt; + + @Column(name = "runtime_cleanup_at") + private Instant runtimeCleanupAt; + + /** run별 마지막 durable event seq. event append는 이 값을 조건부로 증가시켜 seq를 배정한다. */ + @Column(name = "last_event_seq", nullable = false) + private Long lastEventSeq; + + // ===== 불변 요청 ===== + + @Column(name = "request_fingerprint", nullable = false, length = 64, updatable = false) + private String requestFingerprint; + + @Type(JsonBinaryType.class) + @Column(name = "request_snapshot", columnDefinition = "jsonb", nullable = false, updatable = false) + private JsonNode requestSnapshot; + + @Type(JsonBinaryType.class) + @Column(name = "policy_snapshot", columnDefinition = "jsonb", nullable = false, updatable = false) + private JsonNode policySnapshot; + + @Column(name = "budget_policy_version", nullable = false, length = 64, updatable = false) + private String budgetPolicyVersion; + + @Type(JsonBinaryType.class) + @Column(name = "viewer_snapshot", columnDefinition = "jsonb", updatable = false) + private JsonNode viewerSnapshot; + + @Type(JsonBinaryType.class) + @Column(name = "corpus_snapshot", columnDefinition = "jsonb", updatable = false) + private JsonNode corpusSnapshot; + + @Column(name = "request_deadline_at", nullable = false, updatable = false) + private Instant requestDeadlineAt; + + // ===== 누적 사용 예산 (단조 증가) ===== + + @Column(name = "used_llm_calls", nullable = false) + private Long usedLlmCalls; + + @Column(name = "used_tool_calls", nullable = false) + private Long usedToolCalls; + + @Column(name = "used_output_tokens", nullable = false) + private Long usedOutputTokens; + + @Column(name = "used_cost_micros", nullable = false) + private Long usedCostMicros; + + @Column(name = "used_page_understandings", nullable = false) + private Long usedPageUnderstandings; + + // ===== 결과·실패·취소 ===== + + @Convert(converter = ResponseDisposition.Converter.class) + @Column(name = "response_disposition", length = 30) + private ResponseDisposition responseDisposition; + + @Convert(converter = ClarificationReason.Converter.class) + @Column(name = "clarification_reason", length = 40) + private ClarificationReason clarificationReason; + + @Convert(converter = RunFailureCode.Converter.class) + @Column(name = "failure_code", length = 40) + private RunFailureCode failureCode; + + @Convert(converter = RunFailureClass.Converter.class) + @Column(name = "failure_class", length = 20) + private RunFailureClass failureClass; + + @Convert(converter = RunCancelCause.Converter.class) + @Column(name = "cancel_cause", length = 30) + private RunCancelCause cancelCause; + + @Enumerated(EnumType.STRING) + @Column(name = "cancel_requested_from", length = 20) + private AiRunStatus cancelRequestedFrom; + + @Column(name = "cancel_requested_at") + private Instant cancelRequestedAt; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "started_at") + private Instant startedAt; + + @Column(name = "completed_at") + private Instant completedAt; + + /** + * 접수 transaction에서 저장할 PENDING run. Brain 미배정(attempt 0, fence 없음) 상태다. + */ + @Builder(builderMethodName = "pending") + private AiRun(UUID id, String requestId, Long userId, Long noteId, + UUID userMessageId, UUID assistantMessageId, + String requestFingerprint, JsonNode requestSnapshot, JsonNode policySnapshot, + String budgetPolicyVersion, JsonNode viewerSnapshot, JsonNode corpusSnapshot, + Instant requestDeadlineAt, Instant createdAt) { + this.id = Objects.requireNonNull(id, "id"); + this.requestId = Objects.requireNonNull(requestId, "requestId"); + this.userId = Objects.requireNonNull(userId, "userId"); + this.noteId = Objects.requireNonNull(noteId, "noteId"); + this.userMessageId = Objects.requireNonNull(userMessageId, "userMessageId"); + this.assistantMessageId = Objects.requireNonNull(assistantMessageId, "assistantMessageId"); + this.requestFingerprint = Objects.requireNonNull(requestFingerprint, "requestFingerprint"); + this.requestSnapshot = Objects.requireNonNull(requestSnapshot, "requestSnapshot"); + this.policySnapshot = Objects.requireNonNull(policySnapshot, "policySnapshot"); + this.budgetPolicyVersion = Objects.requireNonNull(budgetPolicyVersion, "budgetPolicyVersion"); + this.viewerSnapshot = viewerSnapshot; + this.corpusSnapshot = corpusSnapshot; + this.requestDeadlineAt = Objects.requireNonNull(requestDeadlineAt, "requestDeadlineAt"); + this.createdAt = Objects.requireNonNull(createdAt, "createdAt"); + this.status = AiRunStatus.PENDING; + this.attempt = 0; + this.retryCount = 0; + this.lastEventSeq = 0L; + this.usedLlmCalls = 0L; + this.usedToolCalls = 0L; + this.usedOutputTokens = 0L; + this.usedCostMicros = 0L; + this.usedPageUnderstandings = 0L; + } +} diff --git a/src/main/java/com/proovy/domain/run/entity/AiRunEvent.java b/src/main/java/com/proovy/domain/run/entity/AiRunEvent.java new file mode 100644 index 00000000..95b6d823 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/entity/AiRunEvent.java @@ -0,0 +1,75 @@ +package com.proovy.domain.run.entity; + +import com.fasterxml.jackson.databind.JsonNode; +import io.hypersistence.utils.hibernate.type.json.JsonBinaryType; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Immutable; +import org.hibernate.annotations.Type; + +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +/** + * replay 가능한 durable run event (append-only). {@code (run_id, seq)}와 + * {@code (run_id, source_event_id)}가 각각 unique이므로 같은 source event 재수신은 row·seq를 늘리지 않는다. + * canonical 응답 본문은 중복 저장하지 않고 replay 시 message에서 채운다. + */ +@Entity +@Immutable +@Table(name = "ai_run_events") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class AiRunEvent { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "event_id") + private Long id; + + @Column(name = "run_id", nullable = false) + private UUID runId; + + @Column(name = "note_id", nullable = false) + private Long noteId; + + @Column(nullable = false) + private Long seq; + + @Convert(converter = AiRunEventType.Converter.class) + @Column(name = "event_type", nullable = false, length = 40) + private AiRunEventType eventType; + + @Column(name = "source_event_id", length = 128) + private String sourceEventId; + + private Integer attempt; + + @Column(name = "activity_id", length = 128) + private String activityId; + + @Type(JsonBinaryType.class) + @Column(columnDefinition = "jsonb", nullable = false) + private JsonNode payload; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @Builder + private AiRunEvent(UUID runId, Long noteId, long seq, AiRunEventType eventType, String sourceEventId, + Integer attempt, String activityId, JsonNode payload, Instant createdAt) { + this.runId = Objects.requireNonNull(runId); + this.noteId = Objects.requireNonNull(noteId); + this.seq = seq; + this.eventType = Objects.requireNonNull(eventType); + this.sourceEventId = sourceEventId; + this.attempt = attempt; + this.activityId = activityId; + this.payload = Objects.requireNonNull(payload); + this.createdAt = Objects.requireNonNull(createdAt); + } +} diff --git a/src/main/java/com/proovy/domain/run/entity/AiRunEventType.java b/src/main/java/com/proovy/domain/run/entity/AiRunEventType.java new file mode 100644 index 00000000..36066db8 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/entity/AiRunEventType.java @@ -0,0 +1,45 @@ +package com.proovy.domain.run.entity; + +/** 영속 browser event type (docs/plan/20). */ +public enum AiRunEventType implements WireValue { + RUN_CREATED("run.created", true), + RUN_CANCELING("run.canceling", true), + RUN_CANCELED("run.canceled", true), + RUN_FAILED("run.failed", false), + RUN_COMPLETED("run.completed", false), + ACTIVITY_STARTED("activity.started", false), + ACTIVITY_UPDATED("activity.updated", false), + ACTIVITY_COMPLETED("activity.completed", false), + ACTIVITY_FAILED("activity.failed", false), + ACTIVITY_CANCELED("activity.canceled", false), + RESPONSE_PAGE_STARTED("response.page.started", true), + RESPONSE_PAGE_COMPLETED("response.page.completed", true); + + private final String wireValue; + private final boolean serverOwned; + + AiRunEventType(String wireValue, boolean serverOwned) { + this.wireValue = wireValue; + this.serverOwned = serverOwned; + } + + @Override + public String wireValue() { + return wireValue; + } + + /** Server 소유 event는 Brain source_event_id·attempt를 갖지 않는다 (ck_ai_run_events_server_owned). */ + public boolean isServerOwned() { + return serverOwned; + } + + public boolean isActivity() { + return wireValue.startsWith("activity."); + } + + public static class Converter extends WireValue.Converter { + public Converter() { + super(AiRunEventType.class); + } + } +} diff --git a/src/main/java/com/proovy/domain/run/entity/AiRunStatus.java b/src/main/java/com/proovy/domain/run/entity/AiRunStatus.java new file mode 100644 index 00000000..2736c176 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/entity/AiRunStatus.java @@ -0,0 +1,34 @@ +package com.proovy.domain.run.entity; + +import java.util.EnumSet; +import java.util.Set; + +/** + * run 상태 (docs/plan/05). 허용 전이는 DB trigger(ai_runs_guard_update)가 강제한다. + *

+ * PENDING   → RUNNING | CANCELED | FAILED
+ * RUNNING   → RETRYING | CANCELING | COMPLETED | FAILED
+ * RETRYING  → RUNNING | CANCELING | COMPLETED | FAILED
+ * CANCELING → CANCELED
+ * 
+ */ +public enum AiRunStatus { + PENDING, + RUNNING, + RETRYING, + CANCELING, + COMPLETED, + FAILED, + CANCELED; + + /** Note별 Active Run partial unique index(ux_ai_runs_note_active)가 포함하는 상태 */ + public static final Set ACTIVE = EnumSet.of(PENDING, RUNNING, RETRYING, CANCELING); + + public boolean isActive() { + return ACTIVE.contains(this); + } + + public boolean isTerminal() { + return this == COMPLETED || this == FAILED || this == CANCELED; + } +} diff --git a/src/main/java/com/proovy/domain/run/entity/ClarificationReason.java b/src/main/java/com/proovy/domain/run/entity/ClarificationReason.java new file mode 100644 index 00000000..670845f9 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/entity/ClarificationReason.java @@ -0,0 +1,28 @@ +package com.proovy.domain.run.entity; + +/** terminal-v2 clarification_reason (clarification_only일 때만). */ +public enum ClarificationReason implements WireValue { + MISSING_INPUT("missing_input"), + AMBIGUOUS_TARGET("ambiguous_target"), + LIMITED_PAGES("limited_pages"), + CORPUS_NOT_READY("corpus_not_ready"), + PAGE_RANGE_REQUIRED("page_range_required"), + PAGE_RANGE_UNREADABLE("page_range_unreadable"); + + private final String wireValue; + + ClarificationReason(String wireValue) { + this.wireValue = wireValue; + } + + @Override + public String wireValue() { + return wireValue; + } + + public static class Converter extends WireValue.Converter { + public Converter() { + super(ClarificationReason.class); + } + } +} diff --git a/src/main/java/com/proovy/domain/run/entity/ResponseDisposition.java b/src/main/java/com/proovy/domain/run/entity/ResponseDisposition.java new file mode 100644 index 00000000..c2ebacca --- /dev/null +++ b/src/main/java/com/proovy/domain/run/entity/ResponseDisposition.java @@ -0,0 +1,25 @@ +package com.proovy.domain.run.entity; + +/** terminal-v2 response_disposition. DB에는 wire 값(소문자)으로 저장한다. */ +public enum ResponseDisposition implements WireValue { + SUBSTANTIVE("substantive"), + CLARIFICATION_ONLY("clarification_only"), + UNSUPPORTED_ONLY("unsupported_only"); + + private final String wireValue; + + ResponseDisposition(String wireValue) { + this.wireValue = wireValue; + } + + @Override + public String wireValue() { + return wireValue; + } + + public static class Converter extends WireValue.Converter { + public Converter() { + super(ResponseDisposition.class); + } + } +} diff --git a/src/main/java/com/proovy/domain/run/entity/RunAssetDependency.java b/src/main/java/com/proovy/domain/run/entity/RunAssetDependency.java new file mode 100644 index 00000000..33c3e431 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/entity/RunAssetDependency.java @@ -0,0 +1,76 @@ +package com.proovy.domain.run.entity; + +import com.proovy.global.entity.AssignedIdEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +/** + * run 생성 시 고정한 Note Asset 의존성. 같은 Note의 asset만 가리키며(composite FK), + * run이 PENDING(attempt 0)일 때만 insert할 수 있고 이후 수정·추가하지 않는다(DB trigger). + */ +@Entity +@Table(name = "run_asset_dependencies") +@IdClass(RunAssetDependency.Key.class) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class RunAssetDependency extends AssignedIdEntity { + + @Id + @Column(name = "run_id") + private UUID runId; + + @Id + @Column(name = "asset_id") + private Long assetId; + + @Id + @Enumerated(EnumType.STRING) + @Column(name = "dependency_kind", length = 30) + private RunAssetDependencyKind dependencyKind; + + @Column(name = "note_id", nullable = false, updatable = false) + private Long noteId; + + @Column(name = "content_version", nullable = false, updatable = false) + private Integer contentVersion; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + public RunAssetDependency(UUID runId, Long noteId, Long assetId, RunAssetDependencyKind dependencyKind, + int contentVersion, Instant createdAt) { + this.runId = Objects.requireNonNull(runId); + this.noteId = Objects.requireNonNull(noteId); + this.assetId = Objects.requireNonNull(assetId); + this.dependencyKind = Objects.requireNonNull(dependencyKind); + this.contentVersion = contentVersion; + this.createdAt = Objects.requireNonNull(createdAt); + } + + @Override + public Key getId() { + return new Key(runId, assetId, dependencyKind); + } + + @EqualsAndHashCode + @NoArgsConstructor + public static class Key implements Serializable { + private UUID runId; + private Long assetId; + private RunAssetDependencyKind dependencyKind; + + public Key(UUID runId, Long assetId, RunAssetDependencyKind dependencyKind) { + this.runId = runId; + this.assetId = assetId; + this.dependencyKind = dependencyKind; + } + } +} diff --git a/src/main/java/com/proovy/domain/run/entity/RunAssetDependencyKind.java b/src/main/java/com/proovy/domain/run/entity/RunAssetDependencyKind.java new file mode 100644 index 00000000..1a9d46ce --- /dev/null +++ b/src/main/java/com/proovy/domain/run/entity/RunAssetDependencyKind.java @@ -0,0 +1,7 @@ +package com.proovy.domain.run.entity; + +/** run이 생성 시 고정한 Note Asset 의존 종류 (삭제 fencing 대상 결정). */ +public enum RunAssetDependencyKind { + VIEWER_FOCUS, + CORPUS_SNAPSHOT +} diff --git a/src/main/java/com/proovy/domain/run/entity/RunCancelCause.java b/src/main/java/com/proovy/domain/run/entity/RunCancelCause.java new file mode 100644 index 00000000..ea22dfb5 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/entity/RunCancelCause.java @@ -0,0 +1,25 @@ +package com.proovy.domain.run.entity; + +/** internal-run-v1 CancelCause. */ +public enum RunCancelCause implements WireValue { + USER_REQUESTED("user_requested"), + ASSET_DELETING("asset_deleting"), + BUDGET_EXHAUSTED("budget_exhausted"); + + private final String wireValue; + + RunCancelCause(String wireValue) { + this.wireValue = wireValue; + } + + @Override + public String wireValue() { + return wireValue; + } + + public static class Converter extends WireValue.Converter { + public Converter() { + super(RunCancelCause.class); + } + } +} diff --git a/src/main/java/com/proovy/domain/run/entity/RunFailureClass.java b/src/main/java/com/proovy/domain/run/entity/RunFailureClass.java new file mode 100644 index 00000000..8d154c25 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/entity/RunFailureClass.java @@ -0,0 +1,26 @@ +package com.proovy.domain.run.entity; + +/** terminal-v2 failure_class. code와의 대응은 DB CHECK(ck_ai_runs_failure_code_class)가 강제한다. */ +public enum RunFailureClass implements WireValue { + VERIFICATION("verification"), + BUDGET("budget"), + INFRASTRUCTURE("infrastructure"), + ATTACHMENT("attachment"); + + private final String wireValue; + + RunFailureClass(String wireValue) { + this.wireValue = wireValue; + } + + @Override + public String wireValue() { + return wireValue; + } + + public static class Converter extends WireValue.Converter { + public Converter() { + super(RunFailureClass.class); + } + } +} diff --git a/src/main/java/com/proovy/domain/run/entity/RunFailureCode.java b/src/main/java/com/proovy/domain/run/entity/RunFailureCode.java new file mode 100644 index 00000000..ee1fe06b --- /dev/null +++ b/src/main/java/com/proovy/domain/run/entity/RunFailureCode.java @@ -0,0 +1,29 @@ +package com.proovy.domain.run.entity; + +/** terminal-v2 run.failed code. */ +public enum RunFailureCode implements WireValue { + VERIFICATION_FAILED("verification_failed"), + BUDGET_EXCEEDED("budget_exceeded"), + PROVIDER_ERROR("provider_error"), + TIMEOUT("timeout"), + STORAGE_ERROR("storage_error"), + INTERNAL_ERROR("internal_error"), + INVALID_ATTACHMENT("invalid_attachment"); + + private final String wireValue; + + RunFailureCode(String wireValue) { + this.wireValue = wireValue; + } + + @Override + public String wireValue() { + return wireValue; + } + + public static class Converter extends WireValue.Converter { + public Converter() { + super(RunFailureCode.class); + } + } +} diff --git a/src/main/java/com/proovy/domain/run/entity/WireValue.java b/src/main/java/com/proovy/domain/run/entity/WireValue.java new file mode 100644 index 00000000..07902c9a --- /dev/null +++ b/src/main/java/com/proovy/domain/run/entity/WireValue.java @@ -0,0 +1,38 @@ +package com.proovy.domain.run.entity; + +import jakarta.persistence.AttributeConverter; + +/** + * Brain 계약 enum의 wire 철자(소문자 snake_case)를 그대로 DB에 저장하기 위한 공통 계약. + */ +public interface WireValue { + + String wireValue(); + + abstract class Converter & WireValue> implements AttributeConverter { + + private final Class type; + + protected Converter(Class type) { + this.type = type; + } + + @Override + public String convertToDatabaseColumn(E attribute) { + return attribute == null ? null : attribute.wireValue(); + } + + @Override + public E convertToEntityAttribute(String dbData) { + if (dbData == null) { + return null; + } + for (E constant : type.getEnumConstants()) { + if (constant.wireValue().equals(dbData)) { + return constant; + } + } + throw new IllegalArgumentException("unknown " + type.getSimpleName() + " value: " + dbData); + } + } +} diff --git a/src/main/java/com/proovy/domain/run/event/ActivityAppendResult.java b/src/main/java/com/proovy/domain/run/event/ActivityAppendResult.java new file mode 100644 index 00000000..89933f9d --- /dev/null +++ b/src/main/java/com/proovy/domain/run/event/ActivityAppendResult.java @@ -0,0 +1,47 @@ +package com.proovy.domain.run.event; + +/** + * source Activity 하나의 수락 결과. 어떤 결과든 저장 실패로 예외를 던지지 않으므로 호출자(coordinator)는 + * {@link Outcome}으로 다음 행동만 고른다. + * + * @param seq {@link Outcome#APPENDED}면 새 seq, {@link Outcome#DUPLICATE}·{@link Outcome#CONFLICTING_DUPLICATE}면 + * 이미 저장된 seq, 나머지는 0 + */ +public record ActivityAppendResult(Outcome outcome, long seq) { + + public enum Outcome { + /** 새 durable event로 저장했다. commit 뒤 구독자에게 알린다. */ + APPENDED, + /** 같은 {@code source_event_id}가 같은 내용으로 이미 있다. row·seq가 늘지 않았다. */ + DUPLICATE, + /** 같은 {@code source_event_id}가 다른 type·payload로 이미 있다(Brain ID 규칙 위반). 기존 row를 유지한다. */ + CONFLICTING_DUPLICATE, + /** 현재 run의 attempt·fence가 아니다(이전 attempt 또는 아직 배정되지 않은 attempt). */ + STALE_FENCE, + /** run 상태가 Activity를 받지 않는다(PENDING·terminal, CANCELING의 activity.canceled 외 이벤트). */ + NOT_ACCEPTING, + /** Activity 순서 위반: started 없는 후속, 이미 끝난 Activity, kind 불일치, 두 번째 started. */ + OUT_OF_ORDER, + /** run이 없다(Note 삭제 cascade 포함). */ + UNKNOWN_RUN, + /** activity-v1 계약 위반(필드 이름만 기록하고 payload는 버린다). */ + MALFORMED + } + + static ActivityAppendResult of(Outcome outcome) { + return new ActivityAppendResult(outcome, 0); + } + + /** durable 저장소에 이 source event가 있다(이번에 저장했거나 이미 있었다). */ + public boolean isStored() { + return outcome == Outcome.APPENDED || outcome == Outcome.DUPLICATE; + } + + /** + * 이 stream의 이후 Activity도 저장되지 않는다. coordinator는 이 stream 소비를 멈추고 run 상태·현재 fence를 다시 본다. + * (CANCELING의 activity.canceled처럼 상태별 예외가 있어 저장 판단 자체는 매 event마다 이 store가 한다.) + */ + public boolean isStreamObsolete() { + return outcome == Outcome.STALE_FENCE || outcome == Outcome.UNKNOWN_RUN; + } +} diff --git a/src/main/java/com/proovy/domain/run/event/ActivityContractViolation.java b/src/main/java/com/proovy/domain/run/event/ActivityContractViolation.java new file mode 100644 index 00000000..055e7c97 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/event/ActivityContractViolation.java @@ -0,0 +1,12 @@ +package com.proovy.domain.run.event; + +/** + * Brain source Activity가 activity-v1 계약을 어겼다. message는 필드 위치와 위반 종류만 담고 + * 입력 값(후보 본문·code·stdout·알 수 없는 key 이름)은 담지 않는다. + */ +public class ActivityContractViolation extends IllegalArgumentException { + + public ActivityContractViolation(String message) { + super(message); + } +} diff --git a/src/main/java/com/proovy/domain/run/event/ActivityKind.java b/src/main/java/com/proovy/domain/run/event/ActivityKind.java new file mode 100644 index 00000000..7ee28fc6 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/event/ActivityKind.java @@ -0,0 +1,48 @@ +package com.proovy.domain.run.event; + +import com.proovy.domain.run.entity.WireValue; +import java.util.Arrays; +import java.util.Optional; +import java.util.Set; + +/** + * Phase 1 Activity Kind 6종과 kind별 label allowlist (activity-v1, Brain {@code contracts/activity.py}). + *

+ * label은 Activity의 유일한 자유 표시면이라 kind마다 닫혀 있다. graph node 이름·추론 조각·판정처럼 보이는 문구는 + * 어떤 label에도 들어갈 수 없다. + */ +public enum ActivityKind implements WireValue { + INPUT_ANALYSIS("input_analysis", Set.of("analyzing_input", "reading_input")), + SOLUTION_GENERATION("solution_generation", Set.of("drafting_solution", "revising_solution")), + ASSET_UNDERSTANDING("asset_understanding", Set.of("reading_pages", "summarizing_pages")), + NOTE_RETRIEVAL("note_retrieval", Set.of("searching_notes")), + CODE_EXECUTION("code_execution", Set.of("running_calculation", "running_check_code")), + VERIFICATION("verification", Set.of("verifying_steps")); + + private final String wireValue; + private final Set labels; + + ActivityKind(String wireValue, Set labels) { + this.wireValue = wireValue; + this.labels = labels; + } + + @Override + public String wireValue() { + return wireValue; + } + + public boolean allowsLabel(String label) { + return labels.contains(label); + } + + /** code_execution·verification만 모든 phase에 method_label을 가진다(started 포함). */ + boolean hasMethodLabel() { + return this == CODE_EXECUTION || this == VERIFICATION; + } + + /** 정확히 일치하는 wire 값만 받는다(Brain {@code Literal} discriminator와 같다). */ + public static Optional of(String wireValue) { + return Arrays.stream(values()).filter(kind -> kind.wireValue.equals(wireValue)).findFirst(); + } +} diff --git a/src/main/java/com/proovy/domain/run/event/DurableActivity.java b/src/main/java/com/proovy/domain/run/event/DurableActivity.java new file mode 100644 index 00000000..bb76dbf8 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/event/DurableActivity.java @@ -0,0 +1,25 @@ +package com.proovy.domain.run.event; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.proovy.domain.run.entity.AiRunEventType; +import java.time.Instant; +import java.util.UUID; + +/** + * 검사를 통과한 source Activity 하나. {@link SourceActivities#toDurable}만 만든다. + * + * @param sourceEventId 저장 dedup 키({@link SourceActivities#storedSourceEventId}: 128자 초과 ID는 digest) + * @param payload browser camelCase payload(activityId·kind·kind별 허용 필드). 이 값만 저장·공개한다. + * @param occurredAt source {@code occurred_at}(µs). 저장 시 {@code ai_run_events.created_at}이 되고 frame {@code occurredAt}으로 나간다. + */ +public record DurableActivity( + UUID runId, + int attempt, + String fenceToken, + String sourceEventId, + AiRunEventType type, + ActivityKind kind, + String activityId, + ObjectNode payload, + Instant occurredAt) { +} diff --git a/src/main/java/com/proovy/domain/run/event/RunEventSignals.java b/src/main/java/com/proovy/domain/run/event/RunEventSignals.java new file mode 100644 index 00000000..1eef58fc --- /dev/null +++ b/src/main/java/com/proovy/domain/run/event/RunEventSignals.java @@ -0,0 +1,92 @@ +package com.proovy.domain.run.event; + +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArraySet; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +/** + * commit된 run event의 프로세스 내 깨우기 신호. 이벤트 저장소가 아니다. + *

    + *
  • 신호는 {@code (runId, 그 transaction이 저장한 마지막 seq)}뿐이고 payload를 싣지 않는다. 구독자는 항상 + * {@link RunEventStore#findAfter}로 DB에서 읽는다.
  • + *
  • append transaction이 commit된 뒤({@code afterCommit})에만 보낸다. rollback된 event는 신호가 없다.
  • + *
  • 신호는 유실될 수 있다(구독 전 commit, 다른 인스턴스, listener 예외). 구독자는 구독을 먼저 걸고 그다음 + * 자기 cursor 이후를 DB에서 catch-up하며, 신호를 받을 때마다 다시 cursor 이후를 읽는다. 그래서 신호의 seq는 + * 힌트이고 순서·중복은 cursor가 정한다.
  • + *
+ * listener는 commit한 thread에서 동기로 불리므로 빨리 반환해야 한다(예: SSE pump 깨우기). 예외는 삼키고 기록만 한다. + */ +@Slf4j +@Component +public class RunEventSignals { + + /** commit 뒤 신호를 받는다. */ + @FunctionalInterface + public interface Listener { + void committed(UUID runId, long lastSeq); + } + + /** {@link #close()}로 구독을 해제한다(브라우저 disconnect 등). 여러 번 불러도 된다. */ + public interface Subscription extends AutoCloseable { + @Override + void close(); + } + + private final Map> listeners = new ConcurrentHashMap<>(); + + /** + * 추가·제거를 모두 map entry의 원자 연산({@code compute}) 안에서 한다. 그래야 마지막 구독 해제가 entry를 지우는 순간 + * 새 구독이 map에서 떨어진 set에 붙어 신호를 영영 못 받는 경합이 없다. + */ + public Subscription subscribe(UUID runId, Listener listener) { + listeners.compute(runId, (key, set) -> { + Set target = set == null ? new CopyOnWriteArraySet<>() : set; + target.add(listener); + return target; + }); + return () -> listeners.computeIfPresent(runId, (key, set) -> { + set.remove(listener); + return set.isEmpty() ? null : set; + }); + } + + /** 현재 transaction이 commit되면 신호를 보낸다. transaction 밖에서 부르면 오류다(저장 경로는 항상 transaction 안이다). */ + void signalAfterCommit(UUID runId, long lastSeq) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + throw new IllegalStateException("run event signals require an active transaction"); + } + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + deliver(runId, lastSeq); + } + }); + } + + void deliver(UUID runId, long lastSeq) { + Set targets = listeners.get(runId); + if (targets == null) { + return; + } + for (Listener listener : targets) { + try { + listener.committed(runId, lastSeq); + } catch (RuntimeException e) { + // 신호 유실은 cursor catch-up으로 복구된다. 예외 message에는 payload가 없지만 원인 객체는 남기지 않는다. + log.warn("run event listener failed: runId={}, seq={}, error={}", runId, lastSeq, + e.getClass().getSimpleName()); + } + } + } + + int listenerCount(UUID runId) { + Set targets = listeners.get(runId); + return targets == null ? 0 : targets.size(); + } +} diff --git a/src/main/java/com/proovy/domain/run/event/RunEventStore.java b/src/main/java/com/proovy/domain/run/event/RunEventStore.java new file mode 100644 index 00000000..5ec85427 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/event/RunEventStore.java @@ -0,0 +1,462 @@ +package com.proovy.domain.run.event; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.proovy.domain.run.brain.SourceEvent; +import com.proovy.domain.run.contract.BrowserRunContract; +import com.proovy.domain.run.contract.ServerFrameType; +import com.proovy.domain.run.entity.AiRun; +import com.proovy.domain.run.entity.AiRunEventType; +import com.proovy.domain.run.entity.AiRunStatus; +import com.proovy.domain.run.event.ActivityAppendResult.Outcome; +import jakarta.persistence.EntityManager; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.OptionalLong; +import java.util.UUID; +import lombok.extern.slf4j.Slf4j; +import org.hibernate.Hibernate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +/** + * run별 durable event 저장소 ({@code ai_run_events}). S07의 유일한 이벤트 저장소이며 replay·terminal·Activity가 함께 쓴다. + * + *

seq 배정

+ * 모든 append는 먼저 {@code ai_runs} row를 {@code FOR UPDATE}로 잠그고 같은 transaction에서 + * {@code last_event_seq = last_event_seq + n ... RETURNING}으로 seq를 배정한 뒤 event를 insert한다. + *
    + *
  • 같은 run의 append는 row 잠금으로 직렬화되므로 seq는 1부터 빈틈없이 +1이고, commit 순서 = seq 순서다. + * 그래서 seq N이 보이는 snapshot에는 N 이하가 모두 보이고, {@code seq > cursor} catch-up은 아직 commit되지 않은 + * 앞 seq를 건너뛰지 않는다.
  • + *
  • rollback되면 seq 증가도 함께 되돌아간다. seq는 Brain source 순서가 아니라 Server 수락 순서다.
  • + *
  • 잠금은 append transaction 동안만 잡는다. 호출자는 Brain HTTP/SSE를 기다리는 동안 transaction을 열어 두지 않는다.
  • + *
+ * + *

source Activity 수락 ({@link #appendActivity})

+ * 잠금 뒤 다음 순서로 판정한다. 저장하지 않는 결과는 run·event를 바꾸지 않는다. + *
    + *
  1. activity-v1 계약 위반 → MALFORMED (DB 접근 전)
  2. + *
  3. run 없음 → UNKNOWN_RUN
  4. + *
  5. event attempt·fence가 run의 현재 값과 다름 → STALE_FENCE
  6. + *
  7. 같은 {@code (run_id, source_event_id)}가 있음 → DUPLICATE(같은 내용)·CONFLICTING_DUPLICATE. 기존 row 유지.
  8. + *
  9. 상태: RUNNING·RETRYING은 모두, CANCELING은 열린 Activity의 {@code activity.canceled}(Brain wind-down 표식)만 + * 받는다. PENDING·terminal은 NOT_ACCEPTING (terminal 뒤 seq 증가는 DB trigger도 거부한다).
  10. + *
  11. Activity 순서(browser-run-v1 S6): 후속 event는 같은 activityId·kind의 앞선 started가 있어야 하고 그 Activity가 + * 끝나지 않았어야 한다. started는 Activity당 하나다 → 위반은 OUT_OF_ORDER.
  12. + *
+ * + *

Server 소유·terminal event

+ * {@link #appendServerEvent}(run.created·run.canceling)와 {@link #appendTerminalEvents}(page 경계·run.completed· + * run.failed·run.canceled)는 호출자 transaction({@link Propagation#MANDATORY}) 안에서 상태 전환과 함께 commit된다. + * 불변조건 위반은 {@link IllegalStateException}으로 transaction을 rollback시킨다. + * + *

commit 후 알림

+ * 저장한 transaction이 commit된 뒤에만 {@link RunEventSignals}로 {@code (runId, lastSeq)}를 알린다. 알림은 힌트이고, + * 구독자는 {@link #findAfter}로 DB에서 읽는다. + * + *

JPA로 저장 중인 entity(예: 접수의 {@code AiRun})가 있으면 append 전에 flush한다. seq를 native UPDATE로 올린 뒤 + * 같은 영속성 context에 그 run의 {@code AiRun}이 있으면 refresh한다. 그러지 않으면 호출자가 같은 transaction에서 AiRun을 + * 바꿨을 때 Hibernate의 전체 컬럼 UPDATE가 옛 {@code last_event_seq}를 되써 {@code ai_runs_guard_update}(단조 증가)가 + * transaction을 rollback시킨다. refresh는 append 전 flush 뒤라 호출자의 변경을 잃지 않는다. + */ +@Slf4j +@Service +public class RunEventStore { + + private static final String LOCK_RUN = """ + SELECT note_id, status, attempt, fence_token, last_event_seq FROM ai_runs WHERE run_id = ? FOR UPDATE + """; + private static final String NEXT_SEQ = """ + UPDATE ai_runs SET last_event_seq = last_event_seq + 1 WHERE run_id = ? RETURNING last_event_seq + """; + private static final String INSERT_EVENT = """ + INSERT INTO ai_run_events (run_id, note_id, seq, event_type, source_event_id, attempt, activity_id, payload, + created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, CAST(? AS jsonb), ?) + ON CONFLICT DO NOTHING + """; + + private static final AiRunEventType.Converter EVENT_TYPES = new AiRunEventType.Converter(); + + private final JdbcTemplate jdbc; + private final EntityManager entityManager; + private final ObjectMapper objectMapper; + private final RunEventSignals signals; + + public RunEventStore(JdbcTemplate jdbc, EntityManager entityManager, ObjectMapper objectMapper, + RunEventSignals signals) { + this.jdbc = jdbc; + this.entityManager = entityManager; + this.objectMapper = objectMapper; + this.signals = signals; + } + + // ------------------------------------------------------------------ + // Brain source Activity + // ------------------------------------------------------------------ + + /** + * stream으로 받은 source event 하나를 검사해 수락되면 저장한다. 호출자 transaction이 없으면 짧은 transaction을 새로 연다. + * {@code run.completed}·{@code run.failed}는 MALFORMED다 — terminal은 1.81·1.82 경로로만 저장한다. + */ + @Transactional + public ActivityAppendResult appendActivity(SourceEvent event) { + DurableActivity activity; + try { + activity = SourceActivities.toDurable(event); + } catch (ActivityContractViolation violation) { + // violation message에는 필드 위치만 있다. payload·입력 값은 기록하지 않는다. + log.warn("source activity rejected: runId={}, sourceEventId={}, violation={}", event.runId(), + event.sourceEventId(), violation.getMessage()); + return ActivityAppendResult.of(Outcome.MALFORMED); + } + return append(activity); + } + + private ActivityAppendResult append(DurableActivity activity) { + entityManager.flush(); + UUID runId = activity.runId(); + Optional locked = lockIfExists(runId); + if (locked.isEmpty()) { + return ActivityAppendResult.of(Outcome.UNKNOWN_RUN); + } + RunRow run = locked.get(); + if (run.attempt() != activity.attempt() || !activity.fenceToken().equals(run.fenceToken())) { + return rejected(activity, Outcome.STALE_FENCE); + } + + String payload = json(activity.payload()); + List existing = jdbc.query(""" + SELECT seq, CASE WHEN event_type = ? AND activity_id = ? AND payload = CAST(? AS jsonb) + THEN 1 ELSE 0 END + FROM ai_run_events WHERE run_id = ? AND source_event_id = ? + """, (rs, row) -> new long[]{rs.getLong(1), rs.getInt(2)}, + activity.type().wireValue(), activity.activityId(), payload, runId, activity.sourceEventId()); + if (!existing.isEmpty()) { + boolean same = existing.get(0)[1] == 1; + if (!same) { + log.warn("source event id reused with different content: runId={}, sourceEventId={}", runId, + activity.sourceEventId()); + } + return new ActivityAppendResult(same ? Outcome.DUPLICATE : Outcome.CONFLICTING_DUPLICATE, + existing.get(0)[0]); + } + + if (!accepts(run.status(), activity.type())) { + return rejected(activity, Outcome.NOT_ACCEPTING); + } + if (!inOrder(activity)) { + return rejected(activity, Outcome.OUT_OF_ORDER); + } + + long seq = nextSeq(runId); + insert(runId, run.noteId(), seq, activity.type(), activity.sourceEventId(), activity.attempt(), + activity.activityId(), payload, activity.occurredAt()); + refreshManagedRun(runId); + signals.signalAfterCommit(runId, seq); + return new ActivityAppendResult(Outcome.APPENDED, seq); + } + + private static boolean accepts(AiRunStatus status, AiRunEventType type) { + return switch (status) { + case RUNNING, RETRYING -> true; + // 취소 수락 뒤 진행·완료 표시는 막고, Brain wind-down이 남기는 진행 Activity의 canceled 표식만 받는다. + case CANCELING -> type == AiRunEventType.ACTIVITY_CANCELED; + case PENDING, COMPLETED, FAILED, CANCELED -> false; + }; + } + + /** browser-run-v1 S6: started 하나 → 같은 kind의 후속 → terminal 하나, terminal 뒤 event 없음. */ + private boolean inOrder(DurableActivity activity) { + // run 잠금 아래에서 Activity 이력 전체가 아니라 첫·마지막 row만 읽는다. + List first = jdbc.query(""" + SELECT event_type, payload ->> 'kind' FROM ai_run_events + WHERE run_id = ? AND activity_id = ? ORDER BY seq LIMIT 1 + """, (rs, row) -> new String[]{rs.getString(1), rs.getString(2)}, + activity.runId(), activity.activityId()); + if (activity.type() == AiRunEventType.ACTIVITY_STARTED) { + return first.isEmpty(); + } + if (first.isEmpty() || eventType(first.get(0)[0]) != AiRunEventType.ACTIVITY_STARTED + || !activity.kind().wireValue().equals(first.get(0)[1])) { + return false; + } + String last = jdbc.queryForObject(""" + SELECT event_type FROM ai_run_events WHERE run_id = ? AND activity_id = ? ORDER BY seq DESC LIMIT 1 + """, String.class, activity.runId(), activity.activityId()); + return !isActivityTerminal(eventType(last)); + } + + private static boolean isActivityTerminal(AiRunEventType type) { + return type == AiRunEventType.ACTIVITY_COMPLETED || type == AiRunEventType.ACTIVITY_FAILED + || type == AiRunEventType.ACTIVITY_CANCELED; + } + + private static ActivityAppendResult rejected(DurableActivity activity, Outcome outcome) { + log.info("source activity not stored: runId={}, sourceEventId={}, outcome={}", activity.runId(), + activity.sourceEventId(), outcome); + return ActivityAppendResult.of(outcome); + } + + // ------------------------------------------------------------------ + // Server 소유 event (terminal 전) + // ------------------------------------------------------------------ + + /** + * Server 소유 non-terminal event를 호출자 transaction에 저장하고 seq를 돌려준다. + *

    + *
  • {@code run.created}: 접수 transaction(1.79)에서 PENDING run의 첫 event(seq 1)로만.
  • + *
  • {@code run.canceling}: 취소(1.84)가 같은 transaction에서 CANCELING으로 바꾼 뒤 한 번만.
  • + *
+ * page 경계·run.canceled는 {@link #appendTerminalEvents}로 저장한다. + * + * @param payload 1.75 payload record({@code RunCreatedPayload}·{@code RunCancelingPayload}) 또는 JSON object. + * Spring ObjectMapper(브라우저 camelCase)로 직렬화한다. + * @param occurredAt frame {@code occurredAt} — 보통 그 transaction의 상태 전환 시각 + * @throws IllegalStateException run이 없거나 상태·순서가 맞지 않는다(호출자 transaction은 rollback-only가 된다) + */ + @Transactional(propagation = Propagation.MANDATORY) + public long appendServerEvent(UUID runId, ServerFrameType type, Object payload, Instant occurredAt) { + Objects.requireNonNull(runId, "runId"); + Objects.requireNonNull(occurredAt, "occurredAt"); + AiRunEventType eventType = switch (type) { + case RUN_CREATED -> AiRunEventType.RUN_CREATED; + case RUN_CANCELING -> AiRunEventType.RUN_CANCELING; + case RESPONSE_PAGE_STARTED, RESPONSE_PAGE_COMPLETED, RUN_CANCELED -> + throw new IllegalArgumentException(type.type() + " is appended by the terminal transaction"); + }; + JsonNode body = objectMapper.valueToTree(payload); + if (body == null || !body.isObject()) { + throw new IllegalArgumentException("payload must serialize to a JSON object"); + } + entityManager.flush(); + RunRow run = lock(runId); + boolean allowed = eventType == AiRunEventType.RUN_CREATED + ? run.status() == AiRunStatus.PENDING && run.lastEventSeq() == 0 + : run.status() == AiRunStatus.CANCELING && count(runId, AiRunEventType.RUN_CANCELING) == 0; + if (!allowed) { + throw new IllegalStateException(eventType.wireValue() + " is not allowed for run " + runId + + " in status " + run.status()); + } + long seq = nextSeq(runId); + insert(runId, run.noteId(), seq, eventType, null, null, null, json(body), occurredAt); + refreshManagedRun(runId); + signals.signalAfterCommit(runId, seq); + return seq; + } + + // ------------------------------------------------------------------ + // terminal transaction + // ------------------------------------------------------------------ + + /** + * terminal 전환과 같은 transaction에서 page 경계·terminal event를 저장한다(1.82 완료·실패, 1.84 취소 완료). + *

+ * terminal 뒤에는 {@code last_event_seq}를 바꿀 수 없으므로(DB trigger) 호출자가 terminal 전환 UPDATE 하나에서 + * seq를 함께 배정한다: + *

+     * UPDATE ai_runs SET status = 'COMPLETED', ..., last_event_seq = last_event_seq + :n
+     * WHERE run_id = :runId AND attempt = :attempt AND fence_token = :fence AND status IN (...)
+     * RETURNING last_event_seq
+     * 
+ * 그 반환값과 n개 event를 넘기면 {@code last-n+1 .. last} seq로 순서대로 insert한다. + *
    + *
  • 마지막은 run 상태에 맞는 terminal 하나: COMPLETED→run.completed, FAILED→run.failed, CANCELED→run.canceled.
  • + *
  • 그 앞에는 run.completed일 때만 page 경계가 올 수 있고, pageIndex 0..k-1마다 started→completed 쌍이 + * 같은 responsePageId로 연속한다(S4). page·run.completed payload와 canonical page의 일치는 1.82가 검사한다.
  • + *
+ * + * @return 첫 event의 seq + * @throws IllegalStateException run이 terminal이 아니거나 배정된 seq·event 구성이 맞지 않는다 + */ + @Transactional(propagation = Propagation.MANDATORY) + public long appendTerminalEvents(UUID runId, long lastEventSeq, List events, + Instant occurredAt) { + Objects.requireNonNull(runId, "runId"); + Objects.requireNonNull(occurredAt, "occurredAt"); + List batch = List.copyOf(events); + entityManager.flush(); + RunRow run = lock(runId); + if (!run.status().isTerminal()) { + throw new IllegalStateException("run " + runId + " is not terminal (" + run.status() + ")"); + } + if (run.lastEventSeq() != lastEventSeq) { + throw new IllegalStateException("terminal seq " + lastEventSeq + " does not match run " + runId); + } + checkTerminalBatch(run.status(), batch); + long first = lastEventSeq - batch.size() + 1; + if (first < 1 || count(runId, null) != first - 1) { + throw new IllegalStateException("terminal seq range is not allocated for run " + runId); + } + for (int i = 0; i < batch.size(); i++) { + TerminalRunEvent event = batch.get(i); + String sourceEventId = event.sourceEventId() == null ? null + : SourceActivities.storedSourceEventId(event.sourceEventId()); + if (sourceEventId != null && jdbc.queryForObject( + "SELECT count(*) FROM ai_run_events WHERE run_id = ? AND source_event_id = ?", Long.class, + runId, sourceEventId) > 0) { + // Brain ID는 event kind를 포함해 결정되므로 일어나지 않아야 한다. 같은 키로 insert하면 unique 위반이라 + // 원인을 드러내고 rollback한다. 호출자는 source_event_id 없이(null) 다시 저장할 수 있다. + throw new IllegalStateException("terminal source_event_id is already used in run " + runId); + } + insert(runId, run.noteId(), first + i, event.type(), sourceEventId, event.attempt(), null, + json(event.payload()), occurredAt); + } + refreshManagedRun(runId); + signals.signalAfterCommit(runId, lastEventSeq); + return first; + } + + static void checkTerminalBatch(AiRunStatus status, List batch) { + if (batch.isEmpty()) { + throw new IllegalStateException("terminal batch is empty"); + } + AiRunEventType expected = switch (status) { + case COMPLETED -> AiRunEventType.RUN_COMPLETED; + case FAILED -> AiRunEventType.RUN_FAILED; + case CANCELED -> AiRunEventType.RUN_CANCELED; + default -> throw new IllegalStateException("not terminal"); + }; + if (batch.get(batch.size() - 1).type() != expected) { + throw new IllegalStateException("terminal batch must end with " + expected.wireValue()); + } + List pages = batch.subList(0, batch.size() - 1); + if (!pages.isEmpty() && expected != AiRunEventType.RUN_COMPLETED) { + throw new IllegalStateException("page boundaries belong to run.completed only"); + } + if (pages.size() % 2 != 0) { + throw new IllegalStateException("page boundaries must be started/completed pairs"); + } + for (int i = 0; i < pages.size(); i += 2) { + TerminalRunEvent started = pages.get(i); + TerminalRunEvent completed = pages.get(i + 1); + if (started.type() != AiRunEventType.RESPONSE_PAGE_STARTED + || completed.type() != AiRunEventType.RESPONSE_PAGE_COMPLETED) { + throw new IllegalStateException("page boundaries must be started/completed pairs"); + } + long index = i / 2; + if (pageIndex(started) != index || pageIndex(completed) != index) { + throw new IllegalStateException("page boundaries must follow pageIndex order"); + } + JsonNode startedId = started.payload().get("responsePageId"); + if (startedId == null || !startedId.isTextual() + || !startedId.equals(completed.payload().get("responsePageId"))) { + throw new IllegalStateException("page boundary pair must share responsePageId"); + } + } + } + + private static long pageIndex(TerminalRunEvent event) { + JsonNode index = event.payload().get("pageIndex"); + return index != null && index.isIntegralNumber() ? index.longValue() : -1; + } + + // ------------------------------------------------------------------ + // 조회 (replay·catch-up) + // ------------------------------------------------------------------ + + /** + * {@code afterSeq} 이후 commit된 event를 seq 오름차순으로 최대 {@code limit}개. cursor 없는 replay는 {@code afterSeq = 0}. + * 결과가 limit보다 적으면 그 시점의 끝이다. 같은 seq는 항상 같은 내용이다(append-only). + */ + @Transactional(readOnly = true) + public List findAfter(UUID runId, long afterSeq, int limit) { + if (limit < 1) { + throw new IllegalArgumentException("limit must be positive"); + } + return jdbc.query(""" + SELECT seq, event_type, activity_id, payload::text, created_at FROM ai_run_events + WHERE run_id = ? AND seq > ? ORDER BY seq LIMIT ? + """, (rs, row) -> new StoredRunEvent(runId, rs.getLong(1), eventType(rs.getString(2)), + rs.getString(3), readTree(rs.getString(4)), + rs.getObject(5, OffsetDateTime.class).toInstant()), + runId, afterSeq, limit); + } + + /** + * commit된 마지막 durable seq({@code ai_runs.last_event_seq}). run이 없으면 empty. + * {@code RunEventCursor.resolve(runId, lastSeq, header, query)}의 범위 판정에 쓴다. + */ + @Transactional(readOnly = true) + public OptionalLong lastSeq(UUID runId) { + List found = jdbc.queryForList("SELECT last_event_seq FROM ai_runs WHERE run_id = ?", Long.class, runId); + return found.isEmpty() ? OptionalLong.empty() : OptionalLong.of(found.get(0)); + } + + // ------------------------------------------------------------------ + + /** + * 영속성 context에 이미 있는 AiRun만 DB 값으로 다시 읽는다. 없으면 {@code getReference}는 SQL 없이 초기화되지 않은 + * proxy만 만들므로 dirty checking 대상이 생기지 않는다. + */ + private void refreshManagedRun(UUID runId) { + AiRun managed = entityManager.getReference(AiRun.class, runId); + if (Hibernate.isInitialized(managed)) { + entityManager.refresh(managed); + } + } + + private Optional lockIfExists(UUID runId) { + return jdbc.query(LOCK_RUN, (rs, row) -> new RunRow(rs.getLong(1), AiRunStatus.valueOf(rs.getString(2)), + rs.getInt(3), rs.getString(4), rs.getLong(5)), runId).stream().findFirst(); + } + + private RunRow lock(UUID runId) { + return lockIfExists(runId).orElseThrow(() -> new IllegalStateException("run " + runId + " does not exist")); + } + + private long nextSeq(UUID runId) { + return jdbc.queryForObject(NEXT_SEQ, Long.class, runId); + } + + private long count(UUID runId, AiRunEventType type) { + Long count = type == null + ? jdbc.queryForObject("SELECT count(*) FROM ai_run_events WHERE run_id = ?", Long.class, runId) + : jdbc.queryForObject("SELECT count(*) FROM ai_run_events WHERE run_id = ? AND event_type = ?", + Long.class, runId, type.wireValue()); + return count == null ? 0 : count; + } + + private void insert(UUID runId, long noteId, long seq, AiRunEventType type, String sourceEventId, + Integer attempt, String activityId, String payload, Instant occurredAt) { + // 모든 경로의 시각을 wire 정밀도(µs)로 맞춰 저장한다(Postgres 반올림이 호출자 값과 갈리지 않게). + OffsetDateTime createdAt = OffsetDateTime.ofInstant(BrowserRunContract.wireTime(occurredAt), ZoneOffset.UTC); + int inserted = jdbc.update(INSERT_EVENT, runId, noteId, seq, type.wireValue(), sourceEventId, attempt, + activityId, payload, createdAt); + if (inserted != 1) { + // run row 잠금 아래에서는 일어나지 않는다. 배정한 seq를 남기지 않도록 transaction을 되돌린다. + throw new IllegalStateException("event insert conflicted for run " + runId + " seq " + seq); + } + } + + private String json(JsonNode node) { + try { + return objectMapper.writeValueAsString(node); + } catch (JsonProcessingException e) { + throw new IllegalStateException("payload serialization failed"); + } + } + + private JsonNode readTree(String json) { + try { + return objectMapper.readTree(json); + } catch (JsonProcessingException e) { + throw new IllegalStateException("stored payload is not JSON"); + } + } + + private static AiRunEventType eventType(String wireValue) { + return EVENT_TYPES.convertToEntityAttribute(wireValue); + } + + private record RunRow(long noteId, AiRunStatus status, int attempt, String fenceToken, long lastEventSeq) { + } +} diff --git a/src/main/java/com/proovy/domain/run/event/SourceActivities.java b/src/main/java/com/proovy/domain/run/event/SourceActivities.java new file mode 100644 index 00000000..e27c03f6 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/event/SourceActivities.java @@ -0,0 +1,568 @@ +package com.proovy.domain.run.event; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.proovy.domain.run.brain.SourceEvent; +import com.proovy.domain.run.brain.SourceEventType; +import com.proovy.domain.run.contract.BrowserRunContract; +import com.proovy.domain.run.entity.AiRunEventType; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.HexFormat; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.regex.Pattern; + +/** + * Brain source Activity({@code activity.*})를 activity-v1 규칙으로 검사하고 durable browser payload로 바꾼다. + *

+ * 원본 규칙은 proovy-brain {@code contracts/activity.py}·{@code contracts/source_progress.py}(pin된 commit)이고, + * 값은 pin된 activity-v1 fixture로 대조한다. + *

    + *
  • payload는 (type, kind)마다 닫혀 있다. 알 수 없는 필드(code·stdout·step 판정·percent 등)는 거부한다.
  • + *
  • 문자열은 Python {@code str.strip()} 집합으로 양끝을 지운 값을 검사·저장한다(pydantic {@code str_strip_whitespace}). + * 길이는 code point 수다. enum·kind는 정확히 일치해야 한다.
  • + *
  • 정수는 JSON 정수만 받는다(StrictInt). 진행량은 측정값만 있고 추정 퍼센트 필드는 없다. Python과 달리 long 범위 + * (2^63) 밖 정수는 거부한다(fail-closed, 브라우저도 2^53 밖을 안전하게 다루지 못한다).
  • + *
  • 결과는 Brain {@code to_browser_event_payload}와 같은 camelCase payload다. null 필드는 빼고, note_retrieval 완료의 + * {@code pages}는 없으면 빈 배열로 채운다.
  • + *
  • 오류 message에는 필드 위치와 위반 종류만 쓰고 입력 값·알 수 없는 key 이름은 쓰지 않는다.
  • + *
+ * 의미 판단(답이 문장에 섞였는지)은 schema로 강제할 수 없고 Brain 공개 정책의 몫이다. 여기서는 구조와 안전한 문구 형식만 지킨다. + */ +public final class SourceActivities { + + /** Server가 맞춰 둔 activity fixture 소비 버전(pin {@code PIN-activity-v1.json}). pin을 올리면 함께 올린다. */ + public static final String CONSUMER_VERSION = "activity-fixtures-v1"; + public static final int MAX_IDENTIFIER_LENGTH = 128; + public static final int MAX_MESSAGE_LENGTH = 500; + public static final int MAX_METHOD_LABEL_LENGTH = 200; + public static final int MAX_TARGETS = 50; + public static final int MAX_PAGES = 200; + /** {@code ck_ai_run_events_payload}: {@code octet_length(payload::TEXT) <= 16384}. */ + public static final int MAX_STORED_PAYLOAD_BYTES = 16_384; + /** {@code ck_ai_run_events_source_event_id}는 128자다. brain-source-v1은 256자까지 허용한다. */ + public static final int MAX_STORED_SOURCE_EVENT_ID_LENGTH = 128; + private static final String DIGEST_PREFIX = "sha256:"; + + private static final Pattern ACTIVITY_ID = neutral("activity"); + private static final Pattern PAGE_ID = neutral("page"); + private static final Pattern CONTROL = Pattern.compile("[\\x00-\\x1f\\x7f-\\x9f]"); + // Python re.IGNORECASE(str 패턴)는 Unicode case folding을 쓴다. UNICODE_CASE 없이 두면 U+212A(Kelvin) 같은 값이 빠진다. + private static final Pattern URL_LIKE = Pattern.compile("(?:[a-z][a-z0-9+.-]*://|www\\.)", + Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE); + private static final Pattern CODE_FENCE = Pattern.compile("`{3,}|~{3,}"); + private static final String TRACEBACK_MARKER = "Traceback (most recent call last)"; + + private static final Set STATUSES = Set.of("in_progress", "waiting", "retrying"); + private static final Set FAILURE_CODES = Set.of("provider_error", "timeout", "storage_error", + "internal_error"); + private static final Set UNDERSTANDING_REASONS = Set.of("viewer_focus", "turn_attachment", + "retrieval_candidates", "page_range_summary"); + private static final Set UNDERSTANDING_OUTCOMES = Set.of("scope_required", "unreadable_pages"); + private static final String LIMITED_PAGES_UNRESOLVED = "limited_pages_unresolved"; + private static final String NO_RELEVANT_CONTENT = "no_relevant_content"; + private static final Set MATCH_STATUSES = Set.of("matched", NO_RELEVANT_CONTENT, "conflicting_content", + LIMITED_PAGES_UNRESOLVED); + + private SourceActivities() { + } + + private static Pattern neutral(String prefix) { + return Pattern.compile(prefix + "-(?:[1-9][0-9]*|[0-9a-f]{20}-[0-9]{3})"); + } + + /** + * source event 하나를 durable Activity로 바꾼다. envelope는 {@link SourceEvent} 생성자가 이미 검사했다. + * + * @throws ActivityContractViolation activity 타입이 아니거나 run_id가 Server run UUID가 아니거나 payload가 계약을 어겼다 + */ + public static DurableActivity toDurable(SourceEvent event) { + if (!BrowserRunContract.isUuid(event.runId())) { + throw violation("run_id", "is not a server run id"); + } + AiRunEventType type = eventType(event.type()); + if (isDigestKey(event.sourceEventId())) { + throw violation("source_event_id", "uses the reserved digest prefix"); + } + ObjectNode payload = browserPayload(event.type(), event.payload()); + if (storedLength(payload) > MAX_STORED_PAYLOAD_BYTES) { + throw violation("payload", "is too large to store"); + } + return new DurableActivity(UUID.fromString(event.runId()), event.attempt(), event.fenceToken(), + storedSourceEventId(event.sourceEventId()), type, ActivityKind.of(payload.get("kind").textValue()) + .orElseThrow(() -> violation("payload.kind", "is not a known activity kind")), + payload.get("activityId").textValue(), payload, + BrowserRunContract.wireTime(event.occurredAt().toInstant())); + } + + /** + * dedup 키로 저장할 source_event_id. 128자 이하는 그대로, 더 길면 같은 입력에 항상 같은 {@code sha256:}(71자)로 + * 줄인다. 두 키 공간이 겹치지 않도록 {@code sha256:}으로 시작하는 원래 ID는 받지 않는다({@link #isDigestKey}). + */ + public static String storedSourceEventId(String sourceEventId) { + if (sourceEventId.length() <= MAX_STORED_SOURCE_EVENT_ID_LENGTH) { + return sourceEventId; + } + try { + byte[] digest = MessageDigest.getInstance("SHA-256") + .digest(sourceEventId.getBytes(StandardCharsets.UTF_8)); + return DIGEST_PREFIX + HexFormat.of().formatHex(digest); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } + + /** digest 키 prefix로 시작하는 ID. Brain ID(run_id로 시작)는 해당하지 않으며, 오면 계약 위반으로 거부한다. */ + static boolean isDigestKey(String sourceEventId) { + return sourceEventId.startsWith(DIGEST_PREFIX); + } + + /** source payload(snake_case)를 검사해 browser payload(camelCase)로 바꾼다. */ + public static ObjectNode browserPayload(SourceEventType type, JsonNode source) { + AiRunEventType eventType = eventType(type); + Reader in = new Reader(source, "payload"); + ActivityKind kind = in.kind(); + ObjectNode out = JsonNodeFactory.instance.objectNode(); + out.put("activityId", in.identifier("activity_id", ACTIVITY_ID, MAX_IDENTIFIER_LENGTH, true)); + out.put("kind", kind.wireValue()); + switch (eventType) { + case ACTIVITY_STARTED -> started(in, kind, out); + case ACTIVITY_UPDATED -> updated(in, kind, out); + case ACTIVITY_COMPLETED -> completed(in, kind, out); + case ACTIVITY_FAILED -> failed(in, kind, out); + case ACTIVITY_CANCELED -> canceled(in, kind, out); + default -> throw new IllegalStateException(); + } + in.closed(); + return out; + } + + private static AiRunEventType eventType(SourceEventType type) { + return switch (type) { + case ACTIVITY_STARTED -> AiRunEventType.ACTIVITY_STARTED; + case ACTIVITY_UPDATED -> AiRunEventType.ACTIVITY_UPDATED; + case ACTIVITY_COMPLETED -> AiRunEventType.ACTIVITY_COMPLETED; + case ACTIVITY_FAILED -> AiRunEventType.ACTIVITY_FAILED; + case ACTIVITY_CANCELED -> AiRunEventType.ACTIVITY_CANCELED; + // run.completed·run.failed는 terminal transaction(1.81·1.82)만 저장한다. + case RUN_COMPLETED, RUN_FAILED -> throw violation("type", "is not an activity event"); + }; + } + + // ------------------------------------------------------------------ + // phase별 payload + // ------------------------------------------------------------------ + + private static void started(Reader in, ActivityKind kind, ObjectNode out) { + putIfPresent(out, "responsePageId", in.identifier("response_page_id", PAGE_ID, Integer.MAX_VALUE, false)); + String label = in.string("label", true); + if (!kind.allowsLabel(label)) { + throw violation("payload.label", "is not allowed for this kind"); + } + out.put("label", label); + if (kind == ActivityKind.ASSET_UNDERSTANDING) { + out.put("reason", in.member("reason", UNDERSTANDING_REASONS, true)); + out.set("targets", targets(in)); + } + if (kind.hasMethodLabel()) { + out.put("methodLabel", in.methodLabel()); + } + } + + private static void updated(Reader in, ActivityKind kind, ObjectNode out) { + putIfPresent(out, "status", in.member("status", STATUSES, false)); + putIfPresent(out, "message", in.message()); + switch (kind) { + case ASSET_UNDERSTANDING -> pageCounts(in, out); + case CODE_EXECUTION -> out.put("methodLabel", in.methodLabel()); + case VERIFICATION -> { + out.put("methodLabel", in.methodLabel()); + out.set("progress", progress(in)); + } + default -> { + } + } + } + + private static void completed(Reader in, ActivityKind kind, ObjectNode out) { + putIfPresent(out, "message", in.message()); + switch (kind) { + case ASSET_UNDERSTANDING -> { + pageCounts(in, out); + putIfPresent(out, "outcome", in.member("outcome", UNDERSTANDING_OUTCOMES, false)); + } + case NOTE_RETRIEVAL -> retrievalResult(in, out); + case CODE_EXECUTION -> { + out.put("methodLabel", in.methodLabel()); + Long duration = in.integer("duration_ms", 0, false); + if (duration != null) { + out.set("durationMs", number(duration)); + } + } + case VERIFICATION -> { + out.put("methodLabel", in.methodLabel()); + out.set("progress", progress(in)); + } + default -> { + } + } + } + + private static void failed(Reader in, ActivityKind kind, ObjectNode out) { + out.put("code", in.member("code", FAILURE_CODES, true)); + putIfPresent(out, "message", in.message()); + if (kind.hasMethodLabel()) { + out.put("methodLabel", in.methodLabel()); + } + } + + private static void canceled(Reader in, ActivityKind kind, ObjectNode out) { + putIfPresent(out, "message", in.message()); + if (kind.hasMethodLabel()) { + out.put("methodLabel", in.methodLabel()); + } + } + + // ------------------------------------------------------------------ + // kind별 구조 + // ------------------------------------------------------------------ + + /** 1..50개, 같은 target 중복 금지, 같은 asset의 page 범위는 겹칠 수 없다. */ + private static ArrayNode targets(Reader in) { + JsonNode raw = in.array("targets", true); + if (raw.isEmpty() || raw.size() > MAX_TARGETS) { + throw violation("payload.targets", "has an invalid number of entries"); + } + ArrayNode out = JsonNodeFactory.instance.arrayNode(); + Set seen = new HashSet<>(); + Map> rangesByAsset = new HashMap<>(); + for (int i = 0; i < raw.size(); i++) { + Reader target = new Reader(raw.get(i), "payload.targets[" + i + "]"); + String assetId = target.opaqueId("asset_id"); + long start = target.integer("page_start", 1, true); + long end = target.integer("page_end", 1, true); + target.closed(); + if (start > end) { + throw violation(target.path, "is a reversed page range"); + } + if (!seen.add(assetId + ":" + start + "-" + end)) { + throw violation("payload.targets", "must be unique"); + } + rangesByAsset.computeIfAbsent(assetId, key -> new ArrayList<>()).add(new long[]{start, end}); + ObjectNode item = out.addObject().put("assetId", assetId); + item.set("pageStart", number(start)); + item.set("pageEnd", number(end)); + } + for (List ranges : rangesByAsset.values()) { + ranges.sort((a, b) -> a[0] != b[0] ? Long.compare(a[0], b[0]) : Long.compare(a[1], b[1])); + for (int i = 1; i < ranges.size(); i++) { + if (ranges.get(i)[0] <= ranges.get(i - 1)[1]) { + throw violation("payload.targets", "must not overlap within an asset"); + } + } + } + return out; + } + + /** 측정된 processed_pages ≤ total_pages. */ + private static void pageCounts(Reader in, ObjectNode out) { + long processed = in.integer("processed_pages", 0, true); + long total = in.integer("total_pages", 1, true); + if (processed > total) { + throw violation("payload.processed_pages", "cannot exceed total_pages"); + } + out.set("processedPages", number(processed)); + out.set("totalPages", number(total)); + } + + /** 검증 라운드의 측정 진행량: checked ≤ total. 단계 내용·판정 필드는 없다. */ + private static ObjectNode progress(Reader in) { + Reader progress = new Reader(in.required("progress"), "payload.progress"); + long checked = progress.integer("checked", 0, true); + long total = progress.integer("total", 1, true); + progress.closed(); + if (checked > total) { + throw violation("payload.progress.checked", "cannot exceed total"); + } + ObjectNode out = JsonNodeFactory.instance.objectNode(); + out.set("checked", number(checked)); + out.set("total", number(total)); + return out; + } + + /** limited_pages_unresolved는 page를 반드시, no_relevant_content는 page를 하나도 갖지 않는다. */ + private static void retrievalResult(Reader in, ObjectNode out) { + String matchStatus = in.member("match_status", MATCH_STATUSES, true); + JsonNode raw = in.array("pages", false); + ArrayNode pages = JsonNodeFactory.instance.arrayNode(); + if (raw != null) { + if (raw.size() > MAX_PAGES) { + throw violation("payload.pages", "has too many entries"); + } + Set seen = new HashSet<>(); + for (int i = 0; i < raw.size(); i++) { + Reader page = new Reader(raw.get(i), "payload.pages[" + i + "]"); + String assetId = page.opaqueId("asset_id"); + long pageNumber = page.integer("page", 1, true); + page.closed(); + if (!seen.add(assetId + ":" + pageNumber)) { + throw violation("payload.pages", "must be unique"); + } + pages.addObject().put("assetId", assetId).set("page", number(pageNumber)); + } + } + if (LIMITED_PAGES_UNRESOLVED.equals(matchStatus) && pages.isEmpty()) { + throw violation("payload.pages", "must name the unresolved pages"); + } + if (NO_RELEVANT_CONTENT.equals(matchStatus) && !pages.isEmpty()) { + throw violation("payload.pages", "must be empty when nothing matched"); + } + out.put("matchStatus", matchStatus); + out.set("pages", pages); + } + + /** 파싱한 JSON tree와 같은 node 종류(int 범위면 IntNode)로 둔다. */ + private static JsonNode number(long value) { + return value == (int) value + ? JsonNodeFactory.instance.numberNode((int) value) + : JsonNodeFactory.instance.numberNode(value); + } + + private static void putIfPresent(ObjectNode out, String field, String value) { + if (value != null) { + out.put(field, value); + } + } + + // ------------------------------------------------------------------ + // 저장 크기 + // ------------------------------------------------------------------ + + /** + * Postgres {@code jsonb::text} 표현의 UTF-8 byte 길이({@code ", "}·{@code ": "} 구분자, 문자열 escape). + * DB CHECK에 걸리기 전에 같은 기준으로 거부해 seq를 배정하지 않는다. + */ + static long storedLength(JsonNode node) { + if (node.isObject()) { + long length = 2; + int index = 0; + for (Iterator> it = node.fields(); it.hasNext(); index++) { + Map.Entry entry = it.next(); + length += (index > 0 ? 2 : 0) + quotedLength(entry.getKey()) + 2 + storedLength(entry.getValue()); + } + return length; + } + if (node.isArray()) { + long length = 2; + for (int i = 0; i < node.size(); i++) { + length += (i > 0 ? 2 : 0) + storedLength(node.get(i)); + } + return length; + } + if (node.isTextual()) { + return quotedLength(node.textValue()); + } + return node.asText().getBytes(StandardCharsets.UTF_8).length; + } + + private static long quotedLength(String value) { + long length = 2; + for (int i = 0; i < value.length(); ) { + int codePoint = value.codePointAt(i); + i += Character.charCount(codePoint); + if (codePoint == '"' || codePoint == '\\' || codePoint == '\b' || codePoint == '\f' + || codePoint == '\n' || codePoint == '\r' || codePoint == '\t') { + length += 2; + } else if (codePoint < 0x20) { + length += 6; + } else { + length += new String(Character.toChars(codePoint)).getBytes(StandardCharsets.UTF_8).length; + } + } + return length; + } + + static ActivityContractViolation violation(String field, String problem) { + return new ActivityContractViolation(field + " " + problem); + } + + // ------------------------------------------------------------------ + // 닫힌 object 읽기 + // ------------------------------------------------------------------ + + /** 읽은 필드 이름을 기록하고 {@link #closed()}에서 나머지를 알 수 없는 필드로 거부한다. */ + private static final class Reader { + + private final JsonNode node; + private final String path; + private final Set known = new HashSet<>(); + + Reader(JsonNode node, String path) { + if (node == null || !node.isObject()) { + throw violation(path, "must be an object"); + } + this.node = node; + this.path = path; + } + + private String at(String field) { + return path + "." + field; + } + + /** 없거나 null이면 null. required면 거부한다(Python {@code X | None = None}과 필수 필드의 차이). */ + private JsonNode value(String field, boolean required) { + known.add(field); + JsonNode value = node.get(field); + if (value == null || value.isNull()) { + if (required) { + throw violation(at(field), "is required"); + } + return null; + } + return value; + } + + JsonNode required(String field) { + return value(field, true); + } + + ActivityKind kind() { + JsonNode value = required("kind"); + if (!value.isTextual()) { + throw violation(at("kind"), "is not a known activity kind"); + } + return ActivityKind.of(value.textValue()) + .orElseThrow(() -> violation(at("kind"), "is not a known activity kind")); + } + + /** strip 전 문자열 그대로(enum·label처럼 정확히 일치해야 하는 값). */ + String string(String field, boolean required) { + JsonNode value = value(field, required); + if (value == null) { + return null; + } + if (!value.isTextual()) { + throw violation(at(field), "must be a string"); + } + return value.textValue(); + } + + String member(String field, Set allowed, boolean required) { + String value = string(field, required); + if (value != null && !allowed.contains(value)) { + throw violation(at(field), "is not an allowed value"); + } + return value; + } + + /** strip 후 code point 길이 [1, max]. */ + private String text(String field, int max, boolean required) { + String value = string(field, required); + if (value == null) { + return null; + } + String stripped = BrowserRunContract.strip(value); + int length = BrowserRunContract.codePointLength(stripped); + if (length < 1 || length > max) { + throw violation(at(field), "length is out of range"); + } + return stripped; + } + + /** activity_id는 128자 상한이 있고 response_page_id는 형식만 있다(Brain과 같다). */ + String identifier(String field, Pattern pattern, int maxLength, boolean required) { + String value = text(field, maxLength, required); + if (value != null && !pattern.matcher(value).matches()) { + throw violation(at(field), "must use a neutral generated format"); + } + return value; + } + + String opaqueId(String field) { + String value = text(field, MAX_IDENTIFIER_LENGTH, true); + if (!BrowserRunContract.isOpaqueId(value)) { + throw violation(at(field), "must be an opaque identifier"); + } + return value; + } + + String methodLabel() { + String value = text("method_label", MAX_METHOD_LABEL_LENGTH, true); + if (CONTROL.matcher(value).find()) { + throw violation(at("method_label"), "cannot contain control characters"); + } + return value; + } + + /** 안전한 상태 문구: 제어 문자·URL·code fence·traceback 표식 금지. */ + String message() { + String value = text("message", MAX_MESSAGE_LENGTH, false); + if (value == null) { + return null; + } + if (CONTROL.matcher(value).find()) { + throw violation(at("message"), "cannot contain control characters"); + } + if (URL_LIKE.matcher(value).find()) { + throw violation(at("message"), "cannot contain a URL"); + } + if (CODE_FENCE.matcher(value).find()) { + throw violation(at("message"), "cannot contain a code fence"); + } + if (value.contains(TRACEBACK_MARKER)) { + throw violation(at("message"), "cannot contain a traceback marker"); + } + return value; + } + + /** JSON 정수만(bool·실수·문자열 거부), {@code >= min}. */ + Long integer(String field, long min, boolean required) { + JsonNode value = value(field, required); + if (value == null) { + return null; + } + if (!value.isIntegralNumber() || !value.canConvertToLong()) { + throw violation(at(field), "must be an integer"); + } + long number = value.longValue(); + if (number < min) { + throw violation(at(field), "is out of range"); + } + return number; + } + + /** 있으면 배열이어야 한다. 없음(기본값)과 명시적 null을 구분한다: null은 거부. */ + JsonNode array(String field, boolean required) { + known.add(field); + JsonNode value = node.get(field); + if (value == null) { + if (required) { + throw violation(at(field), "is required"); + } + return null; + } + if (!value.isArray()) { + throw violation(at(field), "must be an array"); + } + return value; + } + + void closed() { + for (Iterator names = node.fieldNames(); names.hasNext(); ) { + if (!known.contains(names.next())) { + throw violation(path, "has an unknown field"); + } + } + } + } +} diff --git a/src/main/java/com/proovy/domain/run/event/StoredRunEvent.java b/src/main/java/com/proovy/domain/run/event/StoredRunEvent.java new file mode 100644 index 00000000..c53959cf --- /dev/null +++ b/src/main/java/com/proovy/domain/run/event/StoredRunEvent.java @@ -0,0 +1,41 @@ +package com.proovy.domain.run.event; + +import com.fasterxml.jackson.databind.JsonNode; +import com.proovy.domain.run.contract.BrowserRunContract; +import com.proovy.domain.run.contract.BrowserRunFrame; +import com.proovy.domain.run.contract.RunEventCursor; +import com.proovy.domain.run.entity.AiRunEventType; +import java.time.Instant; +import java.util.UUID; + +/** + * commit된 durable event 하나(replay 조회 결과). source 전용 값(source_event_id·attempt·fence)은 담지 않는다. + * + * @param payload 저장된 payload. activity.*·run.created·run.canceling·run.canceled는 그대로 frame payload이고, + * response.page.*·run.completed·run.failed는 1.82가 저장한 최소 요약이라 replay(1.86)가 canonical + * message로 채운다. + * @param occurredAt frame {@code occurredAt} ({@code ai_run_events.created_at}) + */ +public record StoredRunEvent( + UUID runId, + long seq, + AiRunEventType type, + String activityId, + JsonNode payload, + Instant occurredAt) { + + /** SSE {@code id} 겸 재접속 cursor {@code {runId}:{seq}}. */ + public RunEventCursor cursor() { + return new RunEventCursor(runId.toString(), seq); + } + + /** 저장 payload 그대로의 durable frame. hydration이 필요한 type은 호출자가 payload를 바꿔 만든다. */ + public BrowserRunFrame toFrame() { + return toFrame(payload); + } + + public BrowserRunFrame toFrame(JsonNode framePayload) { + return new BrowserRunFrame(BrowserRunContract.FRAME_CONTRACT_VERSION, runId.toString(), seq, occurredAt, + type.wireValue(), framePayload); + } +} diff --git a/src/main/java/com/proovy/domain/run/event/TerminalRunEvent.java b/src/main/java/com/proovy/domain/run/event/TerminalRunEvent.java new file mode 100644 index 00000000..b49ec701 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/event/TerminalRunEvent.java @@ -0,0 +1,70 @@ +package com.proovy.domain.run.event; + +import com.fasterxml.jackson.databind.JsonNode; +import com.proovy.domain.run.entity.AiRunEventType; +import java.util.Objects; +import java.util.regex.Pattern; + +/** + * terminal transaction이 한꺼번에 저장하는 event 하나({@link RunEventStore#appendTerminalEvents}). + * page 경계와 완료 event는 이 경로로만 만든다. + * + * @param sourceEventId run.completed·run.failed가 Brain terminal의 source_event_id를 남길 때만(attempt와 함께). Server 소유 + * event(response.page.*·run.canceled)는 null이다(ck_ai_run_events_server_owned). brain-source-v1 형식 + * (256자까지)이고 저장할 때 Activity와 같은 규칙으로 128자 키로 줄인다. + */ +public record TerminalRunEvent(AiRunEventType type, JsonNode payload, String sourceEventId, Integer attempt) { + + /** brain-source-v1 {@code source_event_id} (1.76 {@code SourceEnvelope}와 같은 형식). */ + private static final Pattern SOURCE_EVENT_ID = Pattern.compile("[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}"); + + public TerminalRunEvent { + Objects.requireNonNull(type, "type"); + if (payload == null || !payload.isObject()) { + throw new IllegalArgumentException("payload must be an object"); + } + if ((sourceEventId == null) != (attempt == null)) { + throw new IllegalArgumentException("sourceEventId and attempt go together"); + } + if (sourceEventId != null && type != AiRunEventType.RUN_COMPLETED && type != AiRunEventType.RUN_FAILED) { + throw new IllegalArgumentException("only Brain terminals carry a source event id"); + } + if (sourceEventId != null && (!SOURCE_EVENT_ID.matcher(sourceEventId).matches() + || SourceActivities.isDigestKey(sourceEventId))) { + throw new IllegalArgumentException("sourceEventId must be a brain-source-v1 source event id"); + } + if (attempt != null && attempt < 1) { + throw new IllegalArgumentException("attempt must be positive"); + } + } + + /** {@code {assistantMessageId, responsePageId, presentation, title?, pageIndex}} */ + public static TerminalRunEvent pageStarted(JsonNode payload) { + return new TerminalRunEvent(AiRunEventType.RESPONSE_PAGE_STARTED, payload, null, null); + } + + /** {@code {assistantMessageId, responsePageId, pageIndex}} */ + public static TerminalRunEvent pageCompleted(JsonNode payload) { + return new TerminalRunEvent(AiRunEventType.RESPONSE_PAGE_COMPLETED, payload, null, null); + } + + public static TerminalRunEvent runCompleted(JsonNode payload) { + return new TerminalRunEvent(AiRunEventType.RUN_COMPLETED, payload, null, null); + } + + public static TerminalRunEvent runCompleted(JsonNode payload, String sourceEventId, int attempt) { + return new TerminalRunEvent(AiRunEventType.RUN_COMPLETED, payload, sourceEventId, attempt); + } + + public static TerminalRunEvent runFailed(JsonNode payload) { + return new TerminalRunEvent(AiRunEventType.RUN_FAILED, payload, null, null); + } + + public static TerminalRunEvent runFailed(JsonNode payload, String sourceEventId, int attempt) { + return new TerminalRunEvent(AiRunEventType.RUN_FAILED, payload, sourceEventId, attempt); + } + + public static TerminalRunEvent runCanceled(JsonNode payload) { + return new TerminalRunEvent(AiRunEventType.RUN_CANCELED, payload, null, null); + } +} diff --git a/src/main/java/com/proovy/domain/run/repository/AiRunEventRepository.java b/src/main/java/com/proovy/domain/run/repository/AiRunEventRepository.java new file mode 100644 index 00000000..3c3dd553 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/repository/AiRunEventRepository.java @@ -0,0 +1,15 @@ +package com.proovy.domain.run.repository; + +import com.proovy.domain.run.entity.AiRunEvent; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.UUID; + +public interface AiRunEventRepository extends JpaRepository { + + /** + * Last-Event-ID cursor 이후 durable event replay + */ + List findByRunIdAndSeqGreaterThanOrderBySeqAsc(UUID runId, long afterSeq); +} diff --git a/src/main/java/com/proovy/domain/run/repository/AiRunRepository.java b/src/main/java/com/proovy/domain/run/repository/AiRunRepository.java new file mode 100644 index 00000000..d9845901 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/repository/AiRunRepository.java @@ -0,0 +1,34 @@ +package com.proovy.domain.run.repository; + +import com.proovy.domain.run.entity.AiRun; +import com.proovy.domain.run.entity.AiRunStatus; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; +import java.util.UUID; + +public interface AiRunRepository extends JpaRepository { + + /** + * request_id는 전역 unique다. 멱등 재생 시 호출자(1.77)는 반드시 run의 user_id를 요청 사용자와 비교하고, + * 다르면 상세를 노출하지 않고 거부한다(1.75 계약 D7: 409 RUN4092). + */ + Optional findByRequestId(String requestId); + + /** + * Note의 Active Run (409 activeRunId 응답용). partial unique index로 최대 하나다. + */ + @Query(""" + SELECT r FROM AiRun r + WHERE r.noteId = :noteId + AND r.status IN (com.proovy.domain.run.entity.AiRunStatus.PENDING, + com.proovy.domain.run.entity.AiRunStatus.RUNNING, + com.proovy.domain.run.entity.AiRunStatus.RETRYING, + com.proovy.domain.run.entity.AiRunStatus.CANCELING) + """) + Optional findActiveByNoteId(@Param("noteId") Long noteId); + + long countByNoteIdAndStatus(Long noteId, AiRunStatus status); +} diff --git a/src/main/java/com/proovy/domain/run/repository/RunAssetDependencyRepository.java b/src/main/java/com/proovy/domain/run/repository/RunAssetDependencyRepository.java new file mode 100644 index 00000000..8d374ddd --- /dev/null +++ b/src/main/java/com/proovy/domain/run/repository/RunAssetDependencyRepository.java @@ -0,0 +1,14 @@ +package com.proovy.domain.run.repository; + +import com.proovy.domain.run.entity.RunAssetDependency; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.UUID; + +public interface RunAssetDependencyRepository extends JpaRepository { + + List findByRunId(UUID runId); + + List findByAssetId(Long assetId); +} diff --git a/src/main/java/com/proovy/domain/run/snapshot/HistoryBound.java b/src/main/java/com/proovy/domain/run/snapshot/HistoryBound.java new file mode 100644 index 00000000..22f0c3b8 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/snapshot/HistoryBound.java @@ -0,0 +1,87 @@ +package com.proovy.domain.run.snapshot; + +import static com.proovy.domain.run.brain.InternalRunContract.MAX_HISTORY_MESSAGES; +import static com.proovy.domain.run.brain.InternalRunContract.MAX_HISTORY_MESSAGE_CHARS; + +import com.proovy.domain.run.brain.HistoryMessage; +import com.proovy.domain.run.brain.HistoryRole; +import com.proovy.domain.run.contract.BrowserRunContract; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.UUID; + +/** + * 공개 history를 최신 메시지부터 token 상한 안으로 자른다. + *
    + *
  • 추정은 Brain {@code estimate_history_turn_tokens}와 같다: strip한 본문의 code point 수 + 메시지당 8.
  • + *
  • 메시지 중간을 자르지 않는다. 최신부터 연속으로 담고, 다음(더 오래된) 메시지가 넘치면 거기서 멈춘다.
  • + *
  • 상한은 Brain {@code bound_history}와 같은 soft 상한이다: 가장 최근 메시지 하나는 추정이 상한을 넘어도 통째로 담는다 + * (직전 긴 답을 가리키는 후속 질문이 history·참조를 잃지 않게). 그 뒤 메시지는 합이 상한 이하일 때만 담는다. + * Brain이 같은 추정으로 다시 잘라도 아무것도 버리지 않는다.
  • + *
  • wire 상한(메시지 100개, 메시지당 50,000자 — 합계 480,000자는 이 둘과 token 상한으로 넘지 않는다)은 hard다. + * 최신 메시지가 50,000자를 넘으면 담지 않고 거기서 멈춘다.
  • + *
  • 공백만인 본문은 공개 메시지가 아니므로 건너뛴다. 현재 입력과 같은 user 본문은 넣지 않는다(Brain이 거부).
  • + *
+ */ +public final class HistoryBound { + + /** Brain {@code _HISTORY_TURN_OVERHEAD}. */ + public static final int MESSAGE_OVERHEAD_TOKENS = 8; + + private HistoryBound() { + } + + /** history 후보 하나. {@code plainText}는 공개 본문에서 파생한 값이다. */ + public record Candidate(UUID messageId, HistoryRole role, String plainText) { + } + + /** + * @param messages 시간순 history(오래된 것부터) + * @param estimatedTokens 담은 메시지의 추정 token 합(최신 메시지 하나만 담겼을 때는 상한을 넘을 수 있다) + * @param truncated 더 오래된 공개 메시지를 상한 때문에 버렸는지 + */ + public record Result(List messages, int estimatedTokens, boolean truncated) { + + /** 후보 밖에 더 오래된 turn이 남아 있었다면 잘린 것으로 기록한다. */ + Result truncatedIf(boolean moreOlder) { + return moreOlder && !truncated ? new Result(messages, estimatedTokens, true) : this; + } + } + + public static int estimateTokens(String strippedText) { + return BrowserRunContract.codePointLength(strippedText) + MESSAGE_OVERHEAD_TOKENS; + } + + /** + * @param newestFirst 최신 메시지부터의 후보 + * @param maxTokens 추정 token 상한 + * @param currentText 현재 요청의 input text(strip 전이어도 된다) + */ + public static Result keepNewest(List newestFirst, int maxTokens, String currentText) { + String current = BrowserRunContract.strip(currentText); + List kept = new ArrayList<>(); + int used = 0; + boolean truncated = false; + for (Candidate candidate : newestFirst) { + String text = candidate.plainText() == null ? "" : BrowserRunContract.strip(candidate.plainText()); + if (text.isEmpty()) { + continue; + } + if (candidate.role() == HistoryRole.USER && text.equals(current)) { + continue; + } + int estimate = estimateTokens(text); + if (kept.size() == MAX_HISTORY_MESSAGES + || BrowserRunContract.codePointLength(text) > MAX_HISTORY_MESSAGE_CHARS + || (!kept.isEmpty() && used + estimate > maxTokens)) { + truncated = true; + break; + } + kept.add(new HistoryMessage(candidate.messageId().toString(), candidate.role(), text)); + used += estimate; + } + Collections.reverse(kept); + return new Result(List.copyOf(kept), used, truncated); + } +} diff --git a/src/main/java/com/proovy/domain/run/snapshot/JdbcRunSnapshotSources.java b/src/main/java/com/proovy/domain/run/snapshot/JdbcRunSnapshotSources.java new file mode 100644 index 00000000..39f0fdac --- /dev/null +++ b/src/main/java/com/proovy/domain/run/snapshot/JdbcRunSnapshotSources.java @@ -0,0 +1,119 @@ +package com.proovy.domain.run.snapshot; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.NullNode; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.stereotype.Repository; + +/** + * {@link RunSnapshotSources}의 SQL 구현. 필요한 열만 읽고(response_pages·solution_text 등 공개되지 않을 수 있는 본문은 + * 읽지 않는다) 개수 상한을 DB에서 건다. + */ +@Slf4j +@Repository +public class JdbcRunSnapshotSources implements RunSnapshotSources { + + private static final ObjectMapper JSON = new ObjectMapper(); + + private final NamedParameterJdbcTemplate jdbc; + + public JdbcRunSnapshotSources(NamedParameterJdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + @Override + public boolean isActiveNoteOwnedBy(long noteId, long userId) { + Boolean owned = jdbc.queryForObject(""" + SELECT EXISTS (SELECT 1 FROM notes WHERE note_id = :noteId AND user_id = :userId AND status = 'ACTIVE') + """, Map.of("noteId", noteId, "userId", userId), Boolean.class); + return Boolean.TRUE.equals(owned); + } + + /** + * turn 순서는 run 생성 순서다({@code idx_ai_runs_note_created}를 탄다). 접수(1.79)가 한 transaction에서 user 메시지와 + * run을 함께 만들고 Note당 Active Run이 하나라, run 생성 순서는 user 메시지 순서와 같다. + */ + @Override + public List completedTurnsNewestFirst(long noteId, int limit) { + return jdbc.query(""" + SELECT u.message_id AS user_message_id, u.plain_text AS user_text, + a.message_id AS assistant_message_id, a.plain_text AS assistant_text + FROM ai_runs r + JOIN chat_messages u ON u.message_id = r.user_message_id AND u.note_id = r.note_id + JOIN chat_messages a ON a.message_id = r.assistant_message_id AND a.note_id = r.note_id + WHERE r.note_id = :noteId AND r.status = 'COMPLETED' + AND u.status = 'COMPLETED' AND a.status = 'COMPLETED' + ORDER BY r.created_at DESC, r.run_id DESC + LIMIT :limit + """, Map.of("noteId", noteId, "limit", limit), (rs, i) -> new CompletedTurn( + rs.getObject("user_message_id", UUID.class), rs.getString("user_text"), + rs.getObject("assistant_message_id", UUID.class), rs.getString("assistant_text"))); + } + + @Override + public List solutionsForMessages(long noteId, Collection assistantMessageIds) { + if (assistantMessageIds.isEmpty()) { + return List.of(); + } + return jdbc.query(""" + SELECT s.solution_id, s.public_solution_id, s.assistant_message_id, s.problem_statement, + s.claims::text AS claims, s.revises_solution_id, + p.public_solution_id AS revises_public_solution_id, + s.status = 'SUPERSEDED' AS superseded + FROM verified_solutions s + LEFT JOIN verified_solutions p ON p.solution_id = s.revises_solution_id AND p.note_id = s.note_id + WHERE s.note_id = :noteId AND s.assistant_message_id IN (:messageIds) + ORDER BY s.created_at, s.solution_id + """, Map.of("noteId", noteId, "messageIds", assistantMessageIds), (rs, i) -> new SolutionRow( + rs.getObject("solution_id", UUID.class), rs.getString("public_solution_id"), + rs.getObject("assistant_message_id", UUID.class), rs.getString("problem_statement"), + claims(rs), rs.getObject("revises_solution_id", UUID.class), + rs.getString("revises_public_solution_id"), rs.getBoolean("superseded"))); + } + + @Override + public List attachmentsForMessages(long noteId, Collection userMessageIds) { + if (userMessageIds.isEmpty()) { + return List.of(); + } + return jdbc.query(""" + SELECT attachment_id, source_message_id, attachment_index, sha256, mime_type, + page_understanding_version, image_normalization_version, display_label + FROM turn_attachments + WHERE note_id = :noteId AND source_message_id IN (:messageIds) + ORDER BY created_at, attachment_index + """, Map.of("noteId", noteId, "messageIds", userMessageIds), (rs, i) -> new AttachmentRow( + rs.getObject("attachment_id", UUID.class), rs.getObject("source_message_id", UUID.class), + rs.getInt("attachment_index"), rs.getString("sha256"), rs.getString("mime_type"), + rs.getString("page_understanding_version"), rs.getString("image_normalization_version"), + rs.getString("display_label"))); + } + + @Override + public List activeAssets(long noteId) { + return jdbc.query(""" + SELECT id, content_version FROM assets WHERE note_id = :noteId AND status = 'ACTIVE' ORDER BY id + """, Map.of("noteId", noteId), + (rs, i) -> new CorpusAsset(rs.getLong("id"), rs.getInt("content_version"))); + } + + /** claims JSONB. 읽을 수 없으면 빈 값으로 두고 원인(solution ID만)을 남긴다 — 그 solution은 참조에서 빠진다. */ + private static JsonNode claims(ResultSet rs) throws SQLException { + String text = rs.getString("claims"); + try { + return text == null ? NullNode.getInstance() : JSON.readTree(text); + } catch (JsonProcessingException e) { + log.warn("verified solution {} claims are not readable JSON", rs.getObject("solution_id", UUID.class)); + return NullNode.getInstance(); + } + } +} diff --git a/src/main/java/com/proovy/domain/run/snapshot/RunAssetAccess.java b/src/main/java/com/proovy/domain/run/snapshot/RunAssetAccess.java new file mode 100644 index 00000000..e0051e55 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/snapshot/RunAssetAccess.java @@ -0,0 +1,28 @@ +package com.proovy.domain.run.snapshot; + +import java.util.Map; + +/** + * 한 번의 전달에만 쓰는 실행 자격증명(Asset Access Grant, read URL). snapshot에 저장하지 않고 fingerprint에도 들어가지 않는다. + * 전달마다 새로 발급해 넘긴다(S09/S10). {@link #toString()}은 값을 쓰지 않는다. + * + * @param assetAccessGrant Note Corpus 검색 grant. 없으면 null + * @param viewerReadUrl Viewer Focus asset read URL. 없으면 null + * @param turnAttachmentReadUrls 현재 turn 첨부 attachment_id → read URL. 현재 첨부마다 반드시 있어야 한다 + */ +public record RunAssetAccess(String assetAccessGrant, String viewerReadUrl, Map turnAttachmentReadUrls) { + + /** 텍스트·빈 자산 경로: 자격증명이 필요 없다. */ + public static final RunAssetAccess NONE = new RunAssetAccess(null, null, Map.of()); + + public RunAssetAccess { + turnAttachmentReadUrls = turnAttachmentReadUrls == null ? Map.of() : Map.copyOf(turnAttachmentReadUrls); + } + + @Override + public String toString() { + return "RunAssetAccess[grant=" + (assetAccessGrant == null ? "absent" : "") + + ", viewerReadUrl=" + (viewerReadUrl == null ? "absent" : "") + + ", turnAttachmentReadUrls=" + turnAttachmentReadUrls.size() + "]"; + } +} diff --git a/src/main/java/com/proovy/domain/run/snapshot/RunAssetFacts.java b/src/main/java/com/proovy/domain/run/snapshot/RunAssetFacts.java new file mode 100644 index 00000000..eaa8abf8 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/snapshot/RunAssetFacts.java @@ -0,0 +1,32 @@ +package com.proovy.domain.run.snapshot; + +import com.proovy.domain.run.brain.AssetMime; +import com.proovy.domain.run.brain.ViewerContext; +import com.proovy.domain.run.contract.ViewerFocus; +import java.util.List; +import java.util.UUID; + +/** + * 전송 시점 자산 사실을 확인하는 경계. 실제 업로드 확정·scoped resolver는 S09/S10이 이 인터페이스를 구현한다. + * 기본 구현은 {@link StoredRunAssetFacts}다. + *

+ * 두 메서드 모두 같은 사용자·Note에서 지금 쓸 수 있는 자산만 받아들이고, 아니면 + * {@link RunSnapshotRejectedException}(RUN4221)을 던진다. 돌려주는 값에는 read URL이 없다. + */ +public interface RunAssetFacts { + + /** Viewer Focus의 Note Asset 사실(readUrl 없음)과 의존성에 고정할 content version. */ + ViewerAsset viewerAsset(long userId, long noteId, ViewerFocus focus); + + /** 이번 turn에 직접 붙인 첨부의 검증된 media 사실. 입력 순서를 지킨다. */ + List currentAttachments(long userId, long noteId, List attachmentIds); + + record ViewerAsset(long assetId, int contentVersion, ViewerContext context) { + } + + /** 현재 turn 첨부의 사실. URL은 전달 직전에 {@link RunAssetAccess}로 붙는다. */ + record CurrentAttachment(UUID attachmentId, String sha256, AssetMime mime, long byteSize, Integer pageCount, + Integer pixelWidth, Integer pixelHeight, Long pixelCount, String uploadPolicyVersion, + String pageUnderstandingVersion, String imageNormalizationVersion) { + } +} diff --git a/src/main/java/com/proovy/domain/run/snapshot/RunSnapshot.java b/src/main/java/com/proovy/domain/run/snapshot/RunSnapshot.java new file mode 100644 index 00000000..5b8ecabb --- /dev/null +++ b/src/main/java/com/proovy/domain/run/snapshot/RunSnapshot.java @@ -0,0 +1,81 @@ +package com.proovy.domain.run.snapshot; + +import com.fasterxml.jackson.databind.JsonNode; +import com.proovy.domain.run.entity.AiRun; +import com.proovy.domain.run.entity.RunAssetDependency; +import com.proovy.domain.run.entity.RunAssetDependencyKind; +import java.time.Instant; +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +/** + * 접수 시점에 고정한 실행 입력 snapshot. {@link RunSnapshotFactory}가 만들고 접수 transaction(1.79)이 + * {@link #toPendingRun}·{@link #assetDependencies}로 저장한다. 저장 뒤에는 {@link RunSnapshotRestorer}만 읽는다. + *

+ * 열 대응: {@code request_snapshot}(의미 요청 문서, {@link RunSnapshotCodec}), {@code request_fingerprint} + * (internal-run-v1 fingerprint), {@code policy_snapshot}(고정 정책·history 경계 기록), {@code viewer_snapshot}· + * {@code corpus_snapshot}(삭제 fencing·감사용 사본), {@code request_deadline_at}, {@code budget_policy_version}. + * 어느 문서에도 grant·read URL·JWT가 없다. + * + * @param requestId 브라우저 requestId(= internal request_id) + * @param viewerSnapshot Viewer Focus가 없으면 null + */ +public record RunSnapshot( + UUID runId, + String requestId, + long userId, + long noteId, + String requestFingerprint, + JsonNode requestSnapshot, + JsonNode policySnapshot, + JsonNode viewerSnapshot, + JsonNode corpusSnapshot, + String budgetPolicyVersion, + Instant requestDeadlineAt, + List dependencies) { + + /** run이 고정한 Note Asset 의존성(삭제 fencing 대상). */ + public record AssetDependency(long assetId, RunAssetDependencyKind kind, int contentVersion) { + } + + public RunSnapshot { + Objects.requireNonNull(runId, "runId"); + Objects.requireNonNull(requestId, "requestId"); + Objects.requireNonNull(requestFingerprint, "requestFingerprint"); + Objects.requireNonNull(requestSnapshot, "requestSnapshot"); + Objects.requireNonNull(policySnapshot, "policySnapshot"); + Objects.requireNonNull(corpusSnapshot, "corpusSnapshot"); + Objects.requireNonNull(budgetPolicyVersion, "budgetPolicyVersion"); + Objects.requireNonNull(requestDeadlineAt, "requestDeadlineAt"); + dependencies = List.copyOf(dependencies); + } + + /** 접수 transaction에서 insert할 PENDING run. 메시지 ID와 생성 시각은 접수가 정한다. */ + public AiRun toPendingRun(UUID userMessageId, UUID assistantMessageId, Instant createdAt) { + return AiRun.pending() + .id(runId) + .requestId(requestId) + .userId(userId) + .noteId(noteId) + .userMessageId(userMessageId) + .assistantMessageId(assistantMessageId) + .requestFingerprint(requestFingerprint) + .requestSnapshot(requestSnapshot.deepCopy()) + .policySnapshot(policySnapshot.deepCopy()) + .budgetPolicyVersion(budgetPolicyVersion) + .viewerSnapshot(viewerSnapshot == null ? null : viewerSnapshot.deepCopy()) + .corpusSnapshot(corpusSnapshot.deepCopy()) + .requestDeadlineAt(requestDeadlineAt) + .createdAt(createdAt) + .build(); + } + + /** run insert 뒤 같은 transaction에서(PENDING·attempt 0일 때만) insert할 의존성 행. */ + public List assetDependencies(Instant createdAt) { + return dependencies.stream() + .map(dependency -> new RunAssetDependency(runId, noteId, dependency.assetId(), dependency.kind(), + dependency.contentVersion(), createdAt)) + .toList(); + } +} diff --git a/src/main/java/com/proovy/domain/run/snapshot/RunSnapshotCodec.java b/src/main/java/com/proovy/domain/run/snapshot/RunSnapshotCodec.java new file mode 100644 index 00000000..afc4f160 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/snapshot/RunSnapshotCodec.java @@ -0,0 +1,100 @@ +package com.proovy.domain.run.snapshot; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.proovy.domain.run.brain.ExecutionUsage; +import com.proovy.domain.run.brain.InternalRunContractViolation; +import com.proovy.domain.run.brain.InternalRunRequest; +import com.proovy.domain.run.brain.InternalRunWire; +import java.util.List; + +/** + * {@code ai_runs.request_snapshot} 문서 ↔ {@link InternalRunRequest}. + *

+ * 저장 문서는 internal-run-v1 wire(snake_case) 그대로에서 전달마다 바뀌는 control 값만 뺀 것이다: + * {@code attempt}·{@code fence_token}·{@code asset_access_grant}·{@code viewer_context.read_url}· + * {@code turn_attachments[].read_url}·{@code execution_options.usage}. 나머지(의미 snapshot, 고정 deadline·limits· + * budget version)는 그대로 남는다. 복원은 이 문서에 그 전달의 control 값을 붙여 1.76 DTO 생성자로 다시 검증한다. + */ +public final class RunSnapshotCodec { + + static final List CONTROL_FIELDS = List.of("attempt", "fence_token", "asset_access_grant"); + private static final String READ_URL = "read_url"; + private static final String USAGE = "usage"; + + private RunSnapshotCodec() { + } + + /** 저장할 문서. 자격증명·control 값이 없다. */ + public static ObjectNode strip(InternalRunRequest request) { + ObjectNode document = (ObjectNode) InternalRunWire.tree(request); + CONTROL_FIELDS.forEach(document::remove); + if (document.get("viewer_context") instanceof ObjectNode viewer) { + viewer.remove(READ_URL); + } + for (JsonNode attachment : document.withArray("turn_attachments")) { + ((ObjectNode) attachment).remove(READ_URL); + } + ((ObjectNode) document.get("execution_options")).remove(USAGE); + return document; + } + + /** + * 저장 문서 + 이번 전달의 control 값으로 요청을 다시 만든다. + * + * @throws RunSnapshotIntegrityException 저장 문서에 control·자격증명 필드가 있거나 계약에 맞지 않거나, + * 현재 첨부의 read URL이 없을 때 + */ + public static InternalRunRequest restore(JsonNode stored, int attempt, String fenceToken, ExecutionUsage usage, + RunAssetAccess access) { + if (!(stored instanceof ObjectNode storedObject)) { + throw new RunSnapshotIntegrityException("request snapshot is not an object"); + } + ObjectNode document = storedObject.deepCopy(); + requireStripped(document); + document.put("attempt", attempt); + document.put("fence_token", fenceToken); + if (access.assetAccessGrant() != null) { + document.put("asset_access_grant", access.assetAccessGrant()); + } + if (document.get("viewer_context") instanceof ObjectNode viewer && access.viewerReadUrl() != null) { + viewer.put(READ_URL, access.viewerReadUrl()); + } + for (JsonNode attachment : document.withArray("turn_attachments")) { + String url = access.turnAttachmentReadUrls().get(attachment.path("attachment_id").asText()); + if (url == null) { + throw new RunSnapshotIntegrityException("read URL is missing for a current turn attachment"); + } + ((ObjectNode) attachment).put(READ_URL, url); + } + ((ObjectNode) document.get("execution_options")).set(USAGE, InternalRunWire.tree(usage)); + try { + return InternalRunWire.read(document, InternalRunRequest.class); + } catch (InternalRunContractViolation e) { + throw new RunSnapshotIntegrityException("request snapshot does not satisfy internal-run-v1: " + + e.getMessage()); + } + } + + private static void requireStripped(ObjectNode document) { + boolean leaked = CONTROL_FIELDS.stream().anyMatch(document::has) + || document.path("viewer_context").has(READ_URL) + || !(document.get("turn_attachments") instanceof ArrayNode attachments) + || containsReadUrl(attachments) + || !(document.get("execution_options") instanceof ObjectNode options) + || options.has(USAGE); + if (leaked) { + throw new RunSnapshotIntegrityException("request snapshot carries control or credential fields"); + } + } + + private static boolean containsReadUrl(ArrayNode attachments) { + for (JsonNode attachment : attachments) { + if (!attachment.isObject() || attachment.has(READ_URL)) { + return true; + } + } + return false; + } +} diff --git a/src/main/java/com/proovy/domain/run/snapshot/RunSnapshotConfig.java b/src/main/java/com/proovy/domain/run/snapshot/RunSnapshotConfig.java new file mode 100644 index 00000000..ee3815f7 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/snapshot/RunSnapshotConfig.java @@ -0,0 +1,9 @@ +package com.proovy.domain.run.snapshot; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableConfigurationProperties(RunSnapshotProperties.class) +public class RunSnapshotConfig { +} diff --git a/src/main/java/com/proovy/domain/run/snapshot/RunSnapshotFactory.java b/src/main/java/com/proovy/domain/run/snapshot/RunSnapshotFactory.java new file mode 100644 index 00000000..c8b928a2 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/snapshot/RunSnapshotFactory.java @@ -0,0 +1,363 @@ +package com.proovy.domain.run.snapshot; + +import static com.proovy.domain.run.brain.InternalRunContract.MAX_CLAIM_STATEMENT_CHARS; +import static com.proovy.domain.run.brain.InternalRunContract.MAX_HISTORY_MESSAGES; +import static com.proovy.domain.run.brain.InternalRunContract.MAX_PROBLEM_SUMMARY_CHARS; +import static com.proovy.domain.run.brain.InternalRunContract.MAX_REF_CLAIMS; +import static com.proovy.domain.run.brain.InternalRunContract.MAX_TURN_ATTACHMENT_REFS; +import static com.proovy.domain.run.brain.InternalRunContract.MAX_VERIFIED_SOLUTION_REFS; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.proovy.domain.run.brain.AssetMime; +import com.proovy.domain.run.brain.ExecutionLimits; +import com.proovy.domain.run.brain.ExecutionOptions; +import com.proovy.domain.run.brain.ExecutionUsage; +import com.proovy.domain.run.brain.HistoryRole; +import com.proovy.domain.run.brain.InternalRunContract; +import com.proovy.domain.run.brain.InternalRunContractViolation; +import com.proovy.domain.run.brain.InternalRunRequest; +import com.proovy.domain.run.brain.InternalRunWire; +import com.proovy.domain.run.brain.RetrievalScope; +import com.proovy.domain.run.brain.RunInput; +import com.proovy.domain.run.brain.SolutionReferenceStatus; +import com.proovy.domain.run.brain.SummaryCoverage; +import com.proovy.domain.run.brain.TurnAttachment; +import com.proovy.domain.run.brain.TurnAttachmentRef; +import com.proovy.domain.run.brain.VerifiedSolutionRef; +import com.proovy.domain.run.brain.VerifiedSolutionRefClaim; +import com.proovy.domain.run.contract.BrowserRunContract; +import com.proovy.domain.run.contract.CanonicalJson; +import com.proovy.domain.run.contract.ConversationRunRequest; +import com.proovy.domain.run.entity.RunAssetDependencyKind; +import com.proovy.domain.run.snapshot.RunAssetFacts.CurrentAttachment; +import com.proovy.domain.run.snapshot.RunAssetFacts.ViewerAsset; +import com.proovy.domain.run.snapshot.RunSnapshotSources.AttachmentRow; +import com.proovy.domain.run.snapshot.RunSnapshotSources.CompletedTurn; +import com.proovy.domain.run.snapshot.RunSnapshotSources.CorpusAsset; +import com.proovy.domain.run.snapshot.RunSnapshotSources.SolutionRow; +import com.proovy.global.util.HashUtils; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.stream.Collectors; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * 브라우저 {@link ConversationRunRequest}를 권한·범위 검사 후 불변 {@link RunSnapshot}으로 고정한다. + * 접수 transaction(1.79) 안에서 Note 권한 검사 뒤, run insert 전에 한 번 부른다. 같은 requestId의 재전송(replay)은 + * 저장된 run을 쓰고 이 메서드를 다시 부르지 않는다. + *

    + *
  • history: COMPLETED run의 user/assistant 짝을 최신부터 {@link HistoryBound}로 자른 공개 plain text. + * 실패·취소·진행 중 turn과 Candidate·숨긴 답·Verified Solution 본문은 들어가지 않는다.
  • + *
  • Verified Solution Reference: 남은 history의 assistant 메시지에 묶인 solution만. 조회 시점 status(active· + * superseded)와 정정 관계를 그대로 실어 Brain이 대상 생략(Active Solution)과 명시 비교(superseded)를 구분하게 한다. + * Brain neutral ID가 이 범위에서 겹치면 그 ID의 참조는 모두 뺀다(모호한 참조를 보내지 않는다).
  • + *
  • Turn Attachment Reference: 남은 history의 user 메시지 첨부만.
  • + *
  • Viewer Focus·현재 첨부: {@link RunAssetFacts}가 같은 사용자·Note에서 확인한 전송 시점 사실. 자연어 scope는 판정하지 않는다.
  • + *
  • corpus: 지금 ACTIVE인 Note Asset 집합과 content version으로 {@code corpus_version}을 만들고 의존성으로 고정한다.
  • + *
  • 정책: locale·budget version·limits·절대 deadline(now + request-timeout)을 지금 설정에서 한 번만 읽는다.
  • + *
+ */ +@Slf4j +@Component +public class RunSnapshotFactory { + + public static final String SNAPSHOT_VERSION = "run-snapshot-v1"; + /** fingerprint 계산용 임시 control 값. 저장 문서에서 빠지고 전달에 쓰이지 않는다. */ + static final String UNISSUED_FENCE = "snapshot"; + static final String UNISSUED_READ_URL = "urn:proovy:read-url-not-issued"; + private static final String HISTORY_ESTIMATOR = "code-points+8"; + + private final RunSnapshotSources sources; + private final RunAssetFacts assetFacts; + private final RunSnapshotProperties properties; + + public RunSnapshotFactory(RunSnapshotSources sources, RunAssetFacts assetFacts, RunSnapshotProperties properties) { + this.sources = sources; + this.assetFacts = assetFacts; + this.properties = properties; + } + + /** + * @param runId 접수가 발급한 run ID + * @param userId 인증된 사용자 + * @param noteId 요청 Note(새 Note면 같은 transaction에서 방금 만든 ID) + * @param request {@code ConversationRunRequestValidator}를 통과한 body + * @param now 접수 시각. deadline의 기준이다 + * @throws RunSnapshotRejectedException RUN4041(Note) 또는 RUN4221(첨부·Viewer Focus) + */ + public RunSnapshot create(UUID runId, long userId, long noteId, ConversationRunRequest request, Instant now) { + if (!sources.isActiveNoteOwnedBy(noteId, userId)) { + throw RunSnapshotRejectedException.noteUnavailable(); + } + List attachmentIds = request.turnAttachmentIds() == null ? List.of() + : request.turnAttachmentIds().stream().map(UUID::fromString).toList(); + List current = assetFacts.currentAttachments(userId, noteId, attachmentIds); + requireCurrentAttachments(attachmentIds, current); + ViewerAsset viewer = request.viewerFocus() == null ? null + : assetFacts.viewerAsset(userId, noteId, request.viewerFocus()); + + HistoryBound.Result history = history(noteId, request.text()); + List solutionRefs = solutionRefs(noteId, history); + List attachmentRefs = attachmentRefs(noteId, history); + + List corpus = sources.activeAssets(noteId); + RunSnapshotProperties.Retrieval retrieval = properties.retrieval(); + String noteKey = String.valueOf(noteId); + RetrievalScope scope = new RetrievalScope(noteKey, corpusVersion(corpus), + retrieval.searchExtractionVersion(), retrieval.chunkingVersion(), retrieval.embeddingVersion()); + + Instant deadline = now.plus(properties.requestTimeout()).truncatedTo(ChronoUnit.MICROS); + ExecutionLimits limits = properties.budget().limits(); + ExecutionOptions options = new ExecutionOptions(properties.budget().policyVersion(), + deadline.atOffset(ZoneOffset.UTC), limits, ExecutionUsage.ZERO); + + InternalRunRequest internal; + try { + internal = new InternalRunRequest(InternalRunContract.CONTRACT_VERSION, request.requestId(), + runId.toString(), String.valueOf(userId), noteKey, noteKey, 1, UNISSUED_FENCE, + new RunInput(request.text(), request.latex(), request.requestedTasks(), + request.requestedOutputs(), properties.locale()), + history.messages(), solutionRefs, attachmentRefs, viewer == null ? null : viewer.context(), + scope, current.stream().map(RunSnapshotFactory::unissued).toList(), null, options); + } catch (InternalRunContractViolation e) { + // 검증된 브라우저 body와 Server가 고른 범위로는 생기지 않아야 한다(Server 버그). + throw new IllegalStateException("run snapshot violates internal-run-v1: " + e.getMessage()); + } + + ObjectNode requestSnapshot = RunSnapshotCodec.strip(internal); + List dependencies = new ArrayList<>(); + corpus.forEach(asset -> dependencies.add(new RunSnapshot.AssetDependency(asset.assetId(), + RunAssetDependencyKind.CORPUS_SNAPSHOT, asset.contentVersion()))); + if (viewer != null) { + dependencies.add(new RunSnapshot.AssetDependency(viewer.assetId(), RunAssetDependencyKind.VIEWER_FOCUS, + viewer.contentVersion())); + } + return new RunSnapshot(runId, request.requestId(), userId, noteId, internal.fingerprint().sha256(), + requestSnapshot, policySnapshot(limits, history), viewerSnapshot(requestSnapshot, viewer), + corpusSnapshot(scope, corpus), properties.budget().policyVersion(), deadline, dependencies); + } + + private HistoryBound.Result history(long noteId, String currentText) { + // 메시지 100개 상한이라 최신 100 turn(후보 200개)이면 충분하다. 하나 더 읽어 그보다 오래된 turn이 남았는지만 본다. + List turns = sources.completedTurnsNewestFirst(noteId, MAX_HISTORY_MESSAGES + 1); + List newestFirst = new ArrayList<>(); + for (CompletedTurn turn : turns.subList(0, Math.min(turns.size(), MAX_HISTORY_MESSAGES))) { + newestFirst.add(new HistoryBound.Candidate(turn.assistantMessageId(), HistoryRole.ASSISTANT, + turn.assistantText())); + newestFirst.add(new HistoryBound.Candidate(turn.userMessageId(), HistoryRole.USER, turn.userText())); + } + return HistoryBound.keepNewest(newestFirst, properties.historyMaxTokens(), currentText) + .truncatedIf(turns.size() > MAX_HISTORY_MESSAGES); + } + + private List solutionRefs(long noteId, HistoryBound.Result history) { + List assistantIds = history.messages().stream() + .filter(message -> message.role() == HistoryRole.ASSISTANT) + .map(message -> UUID.fromString(message.messageId())).toList(); + // 조회는 남은 history의 assistant 메시지로 한정된다(잘린 범위의 solution은 오지 않는다). + List rows = sources.solutionsForMessages(noteId, assistantIds); + Map> byPublicId = rows.stream().collect(Collectors.groupingBy( + SolutionRow::publicSolutionId, Collectors.mapping(SolutionRow::solutionId, Collectors.toList()))); + List refs = new ArrayList<>(); + for (SolutionRow row : rows) { + if (byPublicId.get(row.publicSolutionId()).size() > 1) { + log.warn("verified solution {} omitted from references: public id is ambiguous in this note", + row.solutionId()); + continue; + } + solutionRef(row, revisesId(row, byPublicId)).ifPresent(refs::add); + } + return newest(refs, MAX_VERIFIED_SOLUTION_REFS); + } + + /** + * 정정 대상의 Brain ID. 범위 안의 다른 solution이 같은 ID를 쓰면(자기 자신 포함) Brain이 엉뚱한 풀이를 정정 대상으로 + * 읽을 수 있으므로 정정 관계를 싣지 않는다(fingerprint 밖의 수명주기 정보다). + */ + private static String revisesId(SolutionRow row, Map> byPublicId) { + String revises = row.revisesPublicSolutionId(); + if (revises == null) { + return null; + } + List sameId = byPublicId.getOrDefault(revises, List.of()); + if (revises.equals(row.publicSolutionId()) || sameId.stream().anyMatch(id -> !id.equals( + row.revisesSolutionId()))) { + log.warn("verified solution {} sent without its revision link: revised public id is ambiguous", + row.solutionId()); + return null; + } + return revises; + } + + private static Optional solutionRef(SolutionRow row, String revises) { + try { + List claims = new ArrayList<>(); + for (JsonNode claim : row.claims()) { + if (claims.size() == MAX_REF_CLAIMS) { + break; + } + claims.add(new VerifiedSolutionRefClaim(claim.path("claim_id").asText(null), + clip(claim.path("statement").asText(null), MAX_CLAIM_STATEMENT_CHARS), + coverage(claim.path("coverage").asText(null)))); + } + return Optional.of(new VerifiedSolutionRef(row.publicSolutionId(), row.assistantMessageId().toString(), + row.superseded() ? SolutionReferenceStatus.SUPERSEDED : SolutionReferenceStatus.ACTIVE, + revises, clip(row.problemStatement(), MAX_PROBLEM_SUMMARY_CHARS), claims)); + } catch (InternalRunContractViolation e) { + log.warn("verified solution {} omitted from references: not representable ({})", row.solutionId(), + e.getMessage()); + return Optional.empty(); + } + } + + private List attachmentRefs(long noteId, HistoryBound.Result history) { + List userIds = history.messages().stream() + .filter(message -> message.role() == HistoryRole.USER) + .map(message -> UUID.fromString(message.messageId())).toList(); + List refs = new ArrayList<>(); + for (AttachmentRow row : sources.attachmentsForMessages(noteId, userIds)) { + Optional mime = StoredRunAssetFacts.mime(row.mimeType()); + try { + refs.add(new TurnAttachmentRef(row.attachmentId().toString(), row.sourceMessageId().toString(), + row.sha256(), mime.orElse(null), row.pageUnderstandingVersion(), + row.imageNormalizationVersion(), row.displayLabel())); + } catch (InternalRunContractViolation e) { + log.warn("turn attachment {} omitted from references: not representable ({})", row.attachmentId(), + e.getMessage()); + } + } + return newest(refs, MAX_TURN_ATTACHMENT_REFS); + } + + private void requireCurrentAttachments(List requested, List resolved) { + List resolvedIds = resolved.stream().map(CurrentAttachment::attachmentId).toList(); + if (!resolvedIds.equals(requested)) { + throw RunSnapshotRejectedException.referenceUnavailable(); + } + RunSnapshotProperties.TurnAttachmentPolicy policy = properties.turnAttachments(); + long total = 0; + for (CurrentAttachment attachment : resolved) { + total += attachment.byteSize(); + boolean withinItem = attachment.mime().isPdf() + ? attachment.byteSize() <= policy.maxPdfSize().toBytes() + && attachment.pageCount() != null && attachment.pageCount() <= policy.maxPdfPages() + : attachment.byteSize() <= policy.maxImageSize().toBytes() + && attachment.pixelCount() != null && attachment.pixelCount() <= policy.maxImagePixels(); + if (!withinItem) { + throw RunSnapshotRejectedException.referenceUnavailable(); + } + } + if (resolved.size() > policy.maxCount() || total > policy.maxTotalSize().toBytes()) { + throw RunSnapshotRejectedException.referenceUnavailable(); + } + } + + private static TurnAttachment unissued(CurrentAttachment attachment) { + return new TurnAttachment(attachment.attachmentId().toString(), attachment.sha256(), attachment.mime(), + attachment.byteSize(), attachment.pageCount(), attachment.pixelWidth(), attachment.pixelHeight(), + attachment.pixelCount(), attachment.uploadPolicyVersion(), attachment.pageUnderstandingVersion(), + attachment.imageNormalizationVersion(), UNISSUED_READ_URL); + } + + /** + * 전송 시점 ACTIVE asset 집합의 version. 집합이나 content version이 바뀌면 달라지고, 같은 집합이면 같다. + * 빈 Note Corpus도 고정 값을 가진다. + */ + static String corpusVersion(List corpus) { + List> members = corpus.stream() + .sorted((a, b) -> Long.compare(a.assetId(), b.assetId())) + .map(asset -> Map.of("asset_id", asset.assetId(), + "content_version", asset.contentVersion())) + .toList(); + return "corpus-" + HashUtils.sha256(CanonicalJson.write(members)); + } + + private ObjectNode policySnapshot(ExecutionLimits limits, HistoryBound.Result history) { + JsonNodeFactory json = JsonNodeFactory.instance; + ObjectNode policy = json.objectNode(); + policy.put("snapshot_version", SNAPSHOT_VERSION); + policy.put("contract_version", InternalRunContract.CONTRACT_VERSION); + policy.put("consumer_version", InternalRunContract.CONSUMER_VERSION); + policy.put("locale", properties.locale()); + policy.put("budget_policy_version", properties.budget().policyVersion()); + policy.set("limits", InternalRunWire.tree(limits)); + policy.put("request_timeout", properties.requestTimeout().toString()); + ObjectNode historyPolicy = policy.putObject("history"); + historyPolicy.put("max_tokens", properties.historyMaxTokens()); + historyPolicy.put("estimator", HISTORY_ESTIMATOR); + historyPolicy.put("messages", history.messages().size()); + historyPolicy.put("estimated_tokens", history.estimatedTokens()); + historyPolicy.put("truncated", history.truncated()); + RunSnapshotProperties.Retrieval retrieval = properties.retrieval(); + ObjectNode retrievalPolicy = policy.putObject("retrieval"); + retrievalPolicy.put("search_extraction_version", retrieval.searchExtractionVersion()); + retrievalPolicy.put("chunking_version", retrieval.chunkingVersion()); + retrievalPolicy.put("embedding_version", retrieval.embeddingVersion()); + RunSnapshotProperties.AssetVersions versions = properties.assetVersions(); + ObjectNode assetPolicy = policy.putObject("asset_versions"); + assetPolicy.put("upload_policy_version", versions.uploadPolicyVersion()); + assetPolicy.put("page_understanding_version", versions.pageUnderstandingVersion()); + assetPolicy.put("image_normalization_version", versions.imageNormalizationVersion()); + RunSnapshotProperties.TurnAttachmentPolicy attachments = properties.turnAttachments(); + ObjectNode attachmentPolicy = policy.putObject("turn_attachments"); + attachmentPolicy.put("max_count", attachments.maxCount()); + attachmentPolicy.put("max_total_bytes", attachments.maxTotalSize().toBytes()); + attachmentPolicy.put("max_pdf_bytes", attachments.maxPdfSize().toBytes()); + attachmentPolicy.put("max_pdf_pages", attachments.maxPdfPages()); + attachmentPolicy.put("max_image_bytes", attachments.maxImageSize().toBytes()); + attachmentPolicy.put("max_image_pixels", attachments.maxImagePixels()); + return policy; + } + + private static ObjectNode viewerSnapshot(ObjectNode requestSnapshot, ViewerAsset viewer) { + if (viewer == null) { + return null; + } + ObjectNode snapshot = ((ObjectNode) requestSnapshot.get("viewer_context")).deepCopy(); + snapshot.put("content_version", viewer.contentVersion()); + return snapshot; + } + + private static ObjectNode corpusSnapshot(RetrievalScope scope, List corpus) { + ObjectNode snapshot = (ObjectNode) InternalRunWire.tree(scope); + var assets = snapshot.putArray("assets"); + corpus.forEach(asset -> assets.addObject() + .put("asset_id", asset.assetId()) + .put("content_version", asset.contentVersion())); + return snapshot; + } + + private static SummaryCoverage coverage(String wire) { + return Arrays.stream(SummaryCoverage.values()).filter(c -> c.wireValue().equals(wire)).findFirst() + .orElse(null); + } + + /** strip 후 code point 기준으로 앞에서 자른다. 빈 값은 그대로 두어 생성자가 거부하게 한다. */ + static String clip(String value, int maxCodePoints) { + if (value == null) { + return null; + } + String stripped = BrowserRunContract.strip(value); + if (stripped.codePointCount(0, stripped.length()) <= maxCodePoints) { + return stripped; + } + return stripped.substring(0, stripped.offsetByCodePoints(0, maxCodePoints)); + } + + /** 생성 순서 목록에서 최신 {@code max}개(순서 유지). */ + private static List newest(List values, int max) { + return values.size() <= max ? List.copyOf(values) : List.copyOf(values.subList(values.size() - max, + values.size())); + } + +} diff --git a/src/main/java/com/proovy/domain/run/snapshot/RunSnapshotIntegrityException.java b/src/main/java/com/proovy/domain/run/snapshot/RunSnapshotIntegrityException.java new file mode 100644 index 00000000..27c578ac --- /dev/null +++ b/src/main/java/com/proovy/domain/run/snapshot/RunSnapshotIntegrityException.java @@ -0,0 +1,12 @@ +package com.proovy.domain.run.snapshot; + +/** + * 저장된 snapshot으로 원래 요청을 복원할 수 없다(문서 손상, fingerprint·deadline 불일치, 전달 자격증명 누락). + * 현재 설정으로 다시 만들지 않는다 — 호출자(coordinator)는 이 run을 실패로 수습한다. message에 값은 넣지 않는다. + */ +public class RunSnapshotIntegrityException extends IllegalStateException { + + RunSnapshotIntegrityException(String message) { + super(message); + } +} diff --git a/src/main/java/com/proovy/domain/run/snapshot/RunSnapshotProperties.java b/src/main/java/com/proovy/domain/run/snapshot/RunSnapshotProperties.java new file mode 100644 index 00000000..272bd29d --- /dev/null +++ b/src/main/java/com/proovy/domain/run/snapshot/RunSnapshotProperties.java @@ -0,0 +1,102 @@ +package com.proovy.domain.run.snapshot; + +import com.proovy.domain.run.brain.ExecutionLimits; +import java.time.Duration; +import java.util.regex.Pattern; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.DefaultValue; +import org.springframework.util.unit.DataSize; + +/** + * {@code proovy.run.snapshot.*} — 새 run을 접수할 때 snapshot에 고정하는 정책 값. + *

+ * 이 값은 접수 시점에만 읽는다. 저장된 run의 재전달·상태 조회·재시작 복원은 저장된 snapshot만 쓰므로, + * 설정을 바꿔도 이미 접수한 run의 의미·fingerprint·deadline은 바뀌지 않는다. + * + * @param locale 브라우저가 보내지 않는 input locale + * @param historyMaxTokens history 추정 token 상한(제품 계약 24,000. 더 크게 설정할 수 없다) + * @param requestTimeout 접수 시각부터 절대 {@code request_deadline_at}까지 + * @param budget budget policy version과 실행 상한 + * @param retrieval Note Corpus 검색 version(S10 전까지 고정 label) + * @param assetVersions Viewer Focus에 적용하는 upload policy·Page Understanding·image normalization version + * @param turnAttachments 이번 turn 직접 첨부 한도(초과는 RUN4221) + */ +@ConfigurationProperties(prefix = "proovy.run.snapshot") +public record RunSnapshotProperties( + @DefaultValue("ko-KR") String locale, + @DefaultValue("24000") int historyMaxTokens, + @DefaultValue("3m") Duration requestTimeout, + @DefaultValue Budget budget, + @DefaultValue Retrieval retrieval, + @DefaultValue AssetVersions assetVersions, + @DefaultValue TurnAttachmentPolicy turnAttachments) { + + /** 제품 계약의 history 상한(docs/plan/22·17). Brain {@code history_max_tokens}도 이 값을 넘을 수 없다. */ + public static final int CONTRACT_HISTORY_MAX_TOKENS = 24_000; + private static final Pattern VERSION_LABEL = Pattern.compile("[A-Za-z0-9._-]{1,64}"); + private static final Duration MAX_REQUEST_TIMEOUT = Duration.ofMinutes(30); + + public RunSnapshotProperties { + if (historyMaxTokens < 1 || historyMaxTokens > CONTRACT_HISTORY_MAX_TOKENS) { + throw new IllegalArgumentException("proovy.run.snapshot.history-max-tokens must be within 1..24000"); + } + if (requestTimeout == null || requestTimeout.isNegative() || requestTimeout.isZero() + || requestTimeout.compareTo(MAX_REQUEST_TIMEOUT) > 0) { + throw new IllegalArgumentException("proovy.run.snapshot.request-timeout must be within (0, 30m]"); + } + label(budget.policyVersion(), "budget.policy-version"); + label(retrieval.searchExtractionVersion(), "retrieval.search-extraction-version"); + label(retrieval.chunkingVersion(), "retrieval.chunking-version"); + label(retrieval.embeddingVersion(), "retrieval.embedding-version"); + label(assetVersions.uploadPolicyVersion(), "asset-versions.upload-policy-version"); + label(assetVersions.pageUnderstandingVersion(), "asset-versions.page-understanding-version"); + label(assetVersions.imageNormalizationVersion(), "asset-versions.image-normalization-version"); + // 생성자 검사(음수 거부)를 기동 시점에 한 번 돌린다. + budget.limits(); + } + + private static void label(String value, String field) { + if (value == null || !VERSION_LABEL.matcher(value).matches()) { + throw new IllegalArgumentException("proovy.run.snapshot." + field + " must be a version label"); + } + } + + /** 실행 상한. 0은 "그 자원을 쓰지 않음"이고 음수는 기동 실패다. */ + public record Budget( + @DefaultValue("budget-v1") String policyVersion, + @DefaultValue("40") long maxLlmCalls, + @DefaultValue("40") long maxToolCalls, + @DefaultValue("100000") long maxOutputTokens, + @DefaultValue("5000000") long maxCostMicros, + @DefaultValue("5") long maxPageUnderstandings, + @DefaultValue("10") long maxProblems, + @DefaultValue("50") long maxClaims) { + + public ExecutionLimits limits() { + return new ExecutionLimits(maxLlmCalls, maxToolCalls, maxOutputTokens, maxCostMicros, + maxPageUnderstandings, maxProblems, maxClaims); + } + } + + public record Retrieval( + @DefaultValue("se-v1") String searchExtractionVersion, + @DefaultValue("chunk-v1") String chunkingVersion, + @DefaultValue("emb-v1") String embeddingVersion) { + } + + public record AssetVersions( + @DefaultValue("upload-v1") String uploadPolicyVersion, + @DefaultValue("pu-v1") String pageUnderstandingVersion, + @DefaultValue("imgnorm-v1") String imageNormalizationVersion) { + } + + /** 초기 제품 한도(docs/plan/22): 3개·총 30MB, PDF당 20MB·50 pages, 이미지당 15MB·40 megapixels. */ + public record TurnAttachmentPolicy( + @DefaultValue("3") int maxCount, + @DefaultValue("30MB") DataSize maxTotalSize, + @DefaultValue("20MB") DataSize maxPdfSize, + @DefaultValue("50") int maxPdfPages, + @DefaultValue("15MB") DataSize maxImageSize, + @DefaultValue("40000000") long maxImagePixels) { + } +} diff --git a/src/main/java/com/proovy/domain/run/snapshot/RunSnapshotRejectedException.java b/src/main/java/com/proovy/domain/run/snapshot/RunSnapshotRejectedException.java new file mode 100644 index 00000000..b37246f4 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/snapshot/RunSnapshotRejectedException.java @@ -0,0 +1,23 @@ +package com.proovy.domain.run.snapshot; + +import com.proovy.global.exception.BusinessException; +import com.proovy.global.response.ErrorCode; + +/** + * snapshot을 만들 수 없는 요청. RUN4041(Note 없음·타인·삭제 중) 또는 RUN4221(첨부·참조 자료를 쓸 수 없음)이다. + * message는 code 고정 문구만 쓴다(대상 존재 여부를 드러내지 않는다). + */ +public class RunSnapshotRejectedException extends BusinessException { + + RunSnapshotRejectedException(ErrorCode errorCode) { + super(errorCode); + } + + static RunSnapshotRejectedException noteUnavailable() { + return new RunSnapshotRejectedException(ErrorCode.RUN4041); + } + + static RunSnapshotRejectedException referenceUnavailable() { + return new RunSnapshotRejectedException(ErrorCode.RUN4221); + } +} diff --git a/src/main/java/com/proovy/domain/run/snapshot/RunSnapshotRestorer.java b/src/main/java/com/proovy/domain/run/snapshot/RunSnapshotRestorer.java new file mode 100644 index 00000000..85022fc2 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/snapshot/RunSnapshotRestorer.java @@ -0,0 +1,62 @@ +package com.proovy.domain.run.snapshot; + +import com.fasterxml.jackson.databind.JsonNode; +import com.proovy.domain.run.brain.ExecutionUsage; +import com.proovy.domain.run.brain.InternalRunRequest; +import com.proovy.domain.run.entity.AiRun; +import java.time.Instant; +import java.time.temporal.ChronoUnit; + +/** + * 저장된 run에서 Brain 전달 요청을 다시 만든다. 현재 설정·history·viewer·corpus를 읽지 않는다. + *

    + *
  • 의미 snapshot·deadline·limits·budget version은 {@code request_snapshot}에서만 온다.
  • + *
  • attempt·fence는 run 행(coordinator가 조건부 UPDATE로 배정한 값), 누적 usage는 run 행의 단조 counter다. + * 그래서 재조회·재전달·재시작이 deadline을 늘리거나 usage를 0으로 되돌릴 수 없다.
  • + *
  • grant·read URL은 호출자가 이번 전달용으로 넘긴다({@link RunAssetAccess}). 저장하지 않는다.
  • + *
  • 복원한 요청의 fingerprint·deadline·식별자가 저장 열과 다르면 {@link RunSnapshotIntegrityException}이다.
  • + *
+ * 살아 있는 실행 안의 재전달은 1.76 {@link InternalRunRequest#forRetry}를 써도 같은 결과다. 이 클래스는 재시작 뒤처럼 + * 메모리에 요청이 없을 때의 경로다. + */ +public final class RunSnapshotRestorer { + + private RunSnapshotRestorer() { + } + + /** coordinator가 attempt·fence를 배정한(RUNNING·RETRYING) run의 현재 전달 요청. */ + public static InternalRunRequest delivery(AiRun run, RunAssetAccess access) { + if (run.getAttempt() == null || run.getAttempt() < 1 || run.getFenceToken() == null) { + throw new IllegalStateException("run has no assigned attempt/fence yet"); + } + ExecutionUsage usage = new ExecutionUsage(run.getUsedLlmCalls(), run.getUsedToolCalls(), + run.getUsedOutputTokens(), run.getUsedCostMicros(), run.getUsedPageUnderstandings()); + InternalRunRequest request = restore(run.getRequestSnapshot(), run.getRequestFingerprint(), + run.getRequestDeadlineAt(), run.getAttempt(), run.getFenceToken(), usage, access); + if (!request.runId().equals(run.getId().toString()) || !request.requestId().equals(run.getRequestId()) + || !request.userId().equals(String.valueOf(run.getUserId())) + || !request.noteId().equals(String.valueOf(run.getNoteId()))) { + throw new RunSnapshotIntegrityException("request snapshot identity does not match the run"); + } + return request; + } + + /** + * 저장 열에서 요청을 복원하고 저장 fingerprint·deadline과 대조한다. + * + * @param storedFingerprint {@code ai_runs.request_fingerprint} + * @param storedDeadline {@code ai_runs.request_deadline_at} + */ + public static InternalRunRequest restore(JsonNode requestSnapshot, String storedFingerprint, + Instant storedDeadline, int attempt, String fenceToken, ExecutionUsage usage, RunAssetAccess access) { + InternalRunRequest request = RunSnapshotCodec.restore(requestSnapshot, attempt, fenceToken, usage, access); + if (!request.fingerprint().sha256().equals(storedFingerprint)) { + throw new RunSnapshotIntegrityException("restored request fingerprint differs from the stored one"); + } + Instant deadline = request.executionOptions().requestDeadlineAt().toInstant(); + if (!deadline.equals(storedDeadline.truncatedTo(ChronoUnit.MICROS))) { + throw new RunSnapshotIntegrityException("restored request deadline differs from the stored one"); + } + return request; + } +} diff --git a/src/main/java/com/proovy/domain/run/snapshot/RunSnapshotSources.java b/src/main/java/com/proovy/domain/run/snapshot/RunSnapshotSources.java new file mode 100644 index 00000000..10c2ef95 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/snapshot/RunSnapshotSources.java @@ -0,0 +1,53 @@ +package com.proovy.domain.run.snapshot; + +import com.fasterxml.jackson.databind.JsonNode; +import java.util.Collection; +import java.util.List; +import java.util.UUID; + +/** + * snapshot 구성에 필요한 제품 데이터 조회. 모두 Note 범위로 걸러진 projection만 돌려주고 엔티티 그래프를 읽지 않는다. + * 구현은 {@link JdbcRunSnapshotSources}. + */ +public interface RunSnapshotSources { + + /** 사용자 소유의 ACTIVE Note인지. 없음·타인·삭제 중은 모두 false다(존재 비노출). */ + boolean isActiveNoteOwnedBy(long noteId, long userId); + + /** + * 결과가 확정된(COMPLETED run) turn을 최신(run 생성 순)부터 최대 {@code limit}개. user·assistant 짝은 run 기준이다 + * (run의 user_message_id·assistant_message_id). 실패·취소·진행 중 run의 메시지는 오지 않는다. + */ + List completedTurnsNewestFirst(long noteId, int limit); + + /** 주어진 assistant 메시지에 묶인 같은 Note의 Verified Solution. */ + List solutionsForMessages(long noteId, Collection assistantMessageIds); + + /** 주어진 user 메시지에 묶인 같은 Note의 Turn Attachment. */ + List attachmentsForMessages(long noteId, Collection userMessageIds); + + /** Note의 ACTIVE asset(Note Corpus 구성원). */ + List activeAssets(long noteId); + + record CompletedTurn(UUID userMessageId, String userText, UUID assistantMessageId, String assistantText) { + } + + /** + * @param publicSolutionId Brain neutral ID(run 안에서만 unique) + * @param revisesSolutionId 정정 대상의 Server ID(없으면 null) + * @param revisesPublicSolutionId 정정 대상의 Brain neutral ID(없으면 null) + * @param superseded 현재 SUPERSEDED인지(조회 시점 상태) + * @param claims {@code [{claim_id, statement, coverage}]} + */ + record SolutionRow(UUID solutionId, String publicSolutionId, UUID assistantMessageId, String problemStatement, + JsonNode claims, UUID revisesSolutionId, String revisesPublicSolutionId, boolean superseded) { + } + + record AttachmentRow(UUID attachmentId, UUID sourceMessageId, int attachmentIndex, String sha256, + String mimeType, String pageUnderstandingVersion, String imageNormalizationVersion, + String displayLabel) { + } + + record CorpusAsset(long assetId, int contentVersion) { + } +} diff --git a/src/main/java/com/proovy/domain/run/snapshot/StoredRunAssetFacts.java b/src/main/java/com/proovy/domain/run/snapshot/StoredRunAssetFacts.java new file mode 100644 index 00000000..449329cf --- /dev/null +++ b/src/main/java/com/proovy/domain/run/snapshot/StoredRunAssetFacts.java @@ -0,0 +1,98 @@ +package com.proovy.domain.run.snapshot; + +import com.proovy.domain.run.brain.AssetMime; +import com.proovy.domain.run.brain.InternalRunContractViolation; +import com.proovy.domain.run.brain.ViewerContext; +import com.proovy.domain.run.brain.ViewerSelection; +import com.proovy.domain.run.contract.PageSelection; +import com.proovy.domain.run.contract.ViewerFocus; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * S09 전 기본 자산 사실 구현. + *
    + *
  • Viewer Focus: {@code assets} 행의 확정 metadata(ACTIVE, 같은 사용자·Note, sha256, PDF page_count)만 쓴다. + * 적용 version은 접수 시점 정책 값이다. 이미지 asset은 pixel 사실이 아직 저장되지 않아 받지 않는다(RUN4221).
  • + *
  • 현재 Turn Attachment: 업로드 staging 경로가 아직 없으므로 비어 있지 않으면 RUN4221이다.
  • + *
+ * S09가 page-aware metadata와 첨부 staging을 만들면 이 구현을 교체한다. + */ +@Component +public class StoredRunAssetFacts implements RunAssetFacts { + + private final NamedParameterJdbcTemplate jdbc; + private final RunSnapshotProperties properties; + + public StoredRunAssetFacts(NamedParameterJdbcTemplate jdbc, RunSnapshotProperties properties) { + this.jdbc = jdbc; + this.properties = properties; + } + + private record AssetRow(long id, String mimeType, long fileSize, String sha256, Integer pageCount, + int contentVersion) { + } + + @Override + public ViewerAsset viewerAsset(long userId, long noteId, ViewerFocus focus) { + long assetId = parseAssetId(focus.noteAssetId()); + AssetRow asset = jdbc.query(""" + SELECT id, mime_type, file_size, sha256, page_count, content_version FROM assets + WHERE id = :assetId AND note_id = :noteId AND user_id = :userId AND status = 'ACTIVE' + """, Map.of("assetId", assetId, "noteId", noteId, "userId", userId), + (rs, i) -> new AssetRow(rs.getLong("id"), rs.getString("mime_type"), rs.getLong("file_size"), + rs.getString("sha256"), (Integer) rs.getObject("page_count"), rs.getInt("content_version"))) + .stream().findFirst() + .orElseThrow(RunSnapshotRejectedException::referenceUnavailable); + AssetMime mime = mime(asset.mimeType()).orElseThrow(RunSnapshotRejectedException::referenceUnavailable); + if (!mime.isPdf() || asset.sha256() == null || asset.pageCount() == null + || focus.page() > asset.pageCount()) { + throw RunSnapshotRejectedException.referenceUnavailable(); + } + RunSnapshotProperties.AssetVersions versions = properties.assetVersions(); + try { + ViewerContext context = new ViewerContext(String.valueOf(asset.id()), focus.page(), + selection(focus.selection()), asset.sha256(), mime, asset.fileSize(), asset.pageCount(), + null, null, null, versions.uploadPolicyVersion(), versions.pageUnderstandingVersion(), + versions.imageNormalizationVersion(), null); + return new ViewerAsset(asset.id(), asset.contentVersion(), context); + } catch (InternalRunContractViolation e) { + // 저장된 metadata가 wire 한도 밖이면(크기·page 수) 이 run에서 쓸 수 없는 자산이다. + throw RunSnapshotRejectedException.referenceUnavailable(); + } + } + + @Override + public List currentAttachments(long userId, long noteId, List attachmentIds) { + if (attachmentIds.isEmpty()) { + return List.of(); + } + throw RunSnapshotRejectedException.referenceUnavailable(); + } + + static ViewerSelection selection(PageSelection selection) { + return selection == null ? null + : new ViewerSelection(selection.left(), selection.top(), selection.right(), selection.bottom()); + } + + static Optional mime(String wire) { + return Arrays.stream(AssetMime.values()).filter(m -> m.wireValue().equals(wire)).findFirst(); + } + + private static long parseAssetId(String noteAssetId) { + // Note Asset ID는 assets.id(양의 10진수)다. 다른 형태의 opaque ID는 이 Note의 자산이 아니다. + if (noteAssetId == null || !noteAssetId.matches("[1-9][0-9]{0,18}")) { + throw RunSnapshotRejectedException.referenceUnavailable(); + } + try { + return Long.parseLong(noteAssetId); + } catch (NumberFormatException e) { + throw RunSnapshotRejectedException.referenceUnavailable(); + } + } +} diff --git a/src/main/java/com/proovy/domain/run/stream/OutboundFrame.java b/src/main/java/com/proovy/domain/run/stream/OutboundFrame.java new file mode 100644 index 00000000..af19d7fc --- /dev/null +++ b/src/main/java/com/proovy/domain/run/stream/OutboundFrame.java @@ -0,0 +1,24 @@ +package com.proovy.domain.run.stream; + +import com.proovy.domain.run.contract.BrowserRunContract; + +/** + * 브라우저로 보낼 durable frame 하나의 SSE 블록. + * + * @param seq durable seq. 연결 cursor는 마지막으로 보낸 frame의 seq다 + * @param terminal run.completed·run.failed·run.canceled — 보낸 뒤 연결을 닫는다 + * @param sse {@code event: \nid: :\ndata: \n\n} + */ +record OutboundFrame(long seq, boolean terminal, String sse) { + + /** heartbeat는 frame이 아니다: id·event·data가 없고 저장·replay되지 않는다. */ + static final String HEARTBEAT = BrowserRunContract.HEARTBEAT_COMMENT; + + /** browser-run-v1 fixture {@code sse}와 같은 필드 순서. data JSON은 한 줄이다(문자열 안 줄바꿈은 escape된다). */ + static String sse(String type, String id, String json) { + if (json.indexOf('\n') >= 0 || json.indexOf('\r') >= 0) { + throw new IllegalArgumentException("SSE data must be a single line"); + } + return "event: " + type + "\nid: " + id + "\ndata: " + json + "\n\n"; + } +} diff --git a/src/main/java/com/proovy/domain/run/stream/RunEventSink.java b/src/main/java/com/proovy/domain/run/stream/RunEventSink.java new file mode 100644 index 00000000..1ea1a5bc --- /dev/null +++ b/src/main/java/com/proovy/domain/run/stream/RunEventSink.java @@ -0,0 +1,16 @@ +package com.proovy.domain.run.stream; + +import java.io.IOException; + +/** 연결 하나의 출력. production 구현은 {@link SseEmitterSink}다. 호출은 {@link RunEventStream}이 직렬화한다. */ +interface RunEventSink { + + /** SSE 블록(frame 또는 heartbeat comment)을 그대로 쓴다. 연결이 끊겼으면 {@link IOException}. */ + void write(String block) throws IOException; + + /** 정상 종료(terminal frame을 보냈거나 run이 사라짐·접근할 수 없게 됨). */ + void complete(); + + /** 오류 종료(복원·조회 실패). 브라우저에는 terminal 없이 끊긴 stream이다. */ + void fail(Throwable error); +} diff --git a/src/main/java/com/proovy/domain/run/stream/RunEventStream.java b/src/main/java/com/proovy/domain/run/stream/RunEventStream.java new file mode 100644 index 00000000..1ef291d1 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/stream/RunEventStream.java @@ -0,0 +1,222 @@ +package com.proovy.domain.run.stream; + +import com.proovy.domain.run.event.RunEventSignals; +import java.io.IOException; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.atomic.AtomicBoolean; +import lombok.extern.slf4j.Slf4j; + +/** + * 브라우저 연결 하나의 durable replay와 live 합류. + * + *

무유실·무중복

+ *
    + *
  1. {@link #start()}는 신호 구독을 먼저 걸고 그다음 cursor 이후를 DB에서 읽는다. 구독 전 commit은 첫 catch-up이, + * 구독 뒤 commit은 신호가 깨운 catch-up이 읽는다.
  2. + *
  3. 보낼 frame은 항상 {@code seq > cursor}를 DB에서 seq 순으로 읽는다(1.80: commit 순서 = seq 순서라 아직 commit되지 않은 + * 앞 seq를 건너뛰지 않는다). 보낸 frame의 seq로 cursor를 올리므로 replay와 live 경계에서 같은 seq를 두 번 보내지 않는다.
  4. + *
  5. 신호는 깨우기 힌트일 뿐이다. 신호가 유실되어도 {@code catchUpInterval}마다 DB를 다시 보므로 그 주기 안에 복구된다.
  6. + *
+ * + *

연결당 전송 하나

+ * 연결의 모든 쓰기(frame 전송 drain과 heartbeat)는 {@code draining} 소유권 하나를 가진 쪽만 한다. 그래서 연결마다 쓰는 thread는 + * 동시에 최대 하나이고, 느린 브라우저는 pool thread 하나만 붙잡는다(heartbeat가 다른 thread에서 쓰기를 기다리지 않는다 — 전송 중이면 + * heartbeat는 필요 없어 건너뛴다). 전송 중 들어온 신호는 {@code pending} 표시 하나로 합쳐지고 끝나면 한 번 더 읽는다. 그래서 신호가 + * 몰리거나 브라우저가 느려도 서버에 연결별 frame queue가 쌓이지 않고, 신호 listener(append commit thread)는 기다리지 않는다. + * + *

종료

+ * terminal frame을 보내면(또는 terminal run의 cursor가 이미 끝이면) 연결을 정상 종료한다. 브라우저 단절·timeout은 + * {@link #close()} — 구독과 예약 작업만 해제하고 run 실행·상태는 건드리지 않는다. 복원·조회 실패는 오류 종료 + * ({@link RunEventSink#fail})라 브라우저는 terminal 없이 끝난 stream으로 보고 backoff 뒤 재접속한다. + */ +@Slf4j +final class RunEventStream { + + private final UUID runId; + private final RunFrameSource source; + private final RunEventSink sink; + private final RunEventSignals signals; + private final RunSseScheduler scheduler; + private final RunSseProperties properties; + private final Runnable onClose; + + private final AtomicBoolean draining = new AtomicBoolean(); + private final AtomicBoolean closed = new AtomicBoolean(); + private volatile boolean pending; + /** 마지막으로 보낸 frame의 seq. drain thread만 바꾼다. */ + private volatile long cursor; + + private RunEventSignals.Subscription subscription; + private ScheduledFuture heartbeatTask; + private ScheduledFuture catchUpTask; + + /** + * @param afterSeq 이 seq 다음부터 보낸다(cursor 없는 연결은 0) + * @param onClose 연결이 닫힐 때 한 번 불린다(열린 연결 수 집계) + */ + RunEventStream(UUID runId, long afterSeq, RunFrameSource source, RunEventSink sink, RunEventSignals signals, + RunSseScheduler scheduler, RunSseProperties properties, Runnable onClose) { + this.runId = runId; + this.cursor = afterSeq; + this.source = source; + this.sink = sink; + this.signals = signals; + this.scheduler = scheduler; + this.properties = properties; + this.onClose = onClose; + } + + void start() { + synchronized (this) { + subscription = signals.subscribe(runId, (id, lastSeq) -> { + if (lastSeq > cursor) { + wake(); + } + }); + heartbeatTask = scheduler.every(properties.heartbeat(), this::heartbeat); + catchUpTask = scheduler.every(properties.catchUpInterval(), this::wake); + } + if (closed.get()) { + // start 전에 연결이 이미 닫혔다(onCompletion이 먼저 옴) + release(); + return; + } + wake(); + } + + /** 신호·주기 catch-up. 빨리 반환한다(전송은 pool thread에서). */ + void wake() { + if (closed.get()) { + return; + } + pending = true; + if (draining.compareAndSet(false, true)) { + scheduler.execute(this::drain); + } + } + + private void drain() { + try { + while (pending && !closed.get()) { + pending = false; + pumpOnce(); + } + } catch (IOException e) { + log.debug("run SSE client gone: runId={}", runId); + close(); + } catch (RuntimeException e) { + // 같은 seq에서 반복될 수 있는 복원·조회 실패: 원인을 남기고 오류로 끝낸다(정상 종료로 보이면 즉시 재접속 루프가 된다) + log.error("run SSE stream failed: runId={}, cursor={}", runId, cursor, e); + if (close()) { + sink.fail(e); + } + } finally { + yieldWriter(); + } + } + + /** 쓰기 소유권을 놓는다. 해제와 새 신호 사이 경합: 표시가 남아 있으면 다시 맡는다. */ + private void yieldWriter() { + draining.set(false); + if (pending && !closed.get() && draining.compareAndSet(false, true)) { + scheduler.execute(this::drain); + } + } + + private void pumpOnce() throws IOException { + // 상태를 event보다 먼저 읽는다: 여기서 terminal이면 terminal event도 이미 commit되어 아래 조회에 보인다. + Optional progress = source.progress(runId); + if (progress.isEmpty()) { + finish(); + return; + } + if (progress.get().lastSeq() > cursor) { + while (!closed.get()) { + List frames = source.after(runId, cursor, properties.batchSize()); + for (OutboundFrame frame : frames) { + if (frame.seq() <= cursor) { + continue; + } + write(frame.sse()); + cursor = frame.seq(); + if (frame.terminal()) { + finish(); + return; + } + } + if (frames.size() < properties.batchSize()) { + break; + } + } + } + if (progress.get().terminal()) { + // terminal run에서 cursor가 이미 terminal 뒤다(0 frame 재접속) + finish(); + } + } + + /** 전송 중이면 건너뛴다(frame이 이미 흐르고 있고, 쓰기를 기다리느라 두 번째 thread를 붙잡지 않는다). */ + private void heartbeat() { + if (closed.get() || !draining.compareAndSet(false, true)) { + return; + } + try { + write(OutboundFrame.HEARTBEAT); + } catch (IOException e) { + close(); + } finally { + yieldWriter(); + } + } + + /** {@code draining} 소유자만 부른다. */ + private void write(String block) throws IOException { + if (closed.get()) { + throw new IOException("stream closed"); + } + sink.write(block); + } + + private void finish() { + if (close()) { + sink.complete(); + } + } + + /** + * 구독·예약 작업을 해제한다. 여러 번 불러도 된다. 실행 취소가 아니다. + * + * @return 이번 호출이 닫았으면 true + */ + boolean close() { + if (!closed.compareAndSet(false, true)) { + return false; + } + release(); + onClose.run(); + return true; + } + + private synchronized void release() { + if (subscription != null) { + subscription.close(); + } + if (heartbeatTask != null) { + heartbeatTask.cancel(false); + } + if (catchUpTask != null) { + catchUpTask.cancel(false); + } + } + + boolean isClosed() { + return closed.get(); + } + + long cursor() { + return cursor; + } +} diff --git a/src/main/java/com/proovy/domain/run/stream/RunFrameReader.java b/src/main/java/com/proovy/domain/run/stream/RunFrameReader.java new file mode 100644 index 00000000..fea12ddf --- /dev/null +++ b/src/main/java/com/proovy/domain/run/stream/RunFrameReader.java @@ -0,0 +1,91 @@ +package com.proovy.domain.run.stream; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.proovy.domain.run.entity.AiRunEventType; +import com.proovy.domain.run.entity.AiRunStatus; +import com.proovy.domain.run.event.RunEventStore; +import com.proovy.domain.run.event.StoredRunEvent; +import com.proovy.domain.run.terminal.TerminalReplay; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * 저장 event를 browser frame으로 복원한다. 최초 POST·GET 재접속·live가 모두 이 경로라 같은 seq는 같은 frame이다 + * (유일한 차이는 read-time {@code sourceReferences[].available}). + *
    + *
  • activity.*·run.created·run.canceling·run.canceled·response.page.*·run.failed: 저장 payload 그대로 + * (1.80·1.82가 browser payload 모양으로 저장한다).
  • + *
  • run.completed: 저장된 최소 요약 + canonical assistant message({@link TerminalReplay#completedPayload}). + * 본문은 event에 중복 저장되어 있지 않고 terminal commit 뒤에만 message에 있으므로 commit 전 본문은 나갈 수 없다.
  • + *
+ */ +@Component +public class RunFrameReader implements RunFrameSource { + + private final RunEventStore store; + private final TerminalReplay replay; + private final SourceAvailability availability; + private final JdbcTemplate jdbc; + private final ObjectMapper objectMapper; + + public RunFrameReader(RunEventStore store, TerminalReplay replay, SourceAvailability availability, + JdbcTemplate jdbc, ObjectMapper objectMapper) { + this.store = store; + this.replay = replay; + this.availability = availability; + this.jdbc = jdbc; + this.objectMapper = objectMapper; + } + + @Override + public Optional progress(UUID runId) { + // Note가 ACTIVE가 아니면(삭제 중) 새 GET·조회의 RUN4041과 같게 열린 연결도 더 보내지 않고 닫는다. + return jdbc.query(""" + SELECT r.status, r.last_event_seq FROM ai_runs r + JOIN notes n ON n.note_id = r.note_id AND n.user_id = r.user_id + WHERE r.run_id = ? AND n.status = 'ACTIVE' + """, + (rs, row) -> new Progress(AiRunStatus.valueOf(rs.getString(1)).isTerminal(), rs.getLong(2)), runId) + .stream().findFirst(); + } + + @Override + public List after(UUID runId, long afterSeq, int limit) { + List frames = new ArrayList<>(); + for (StoredRunEvent event : store.findAfter(runId, afterSeq, limit)) { + frames.add(outbound(event)); + } + return frames; + } + + OutboundFrame outbound(StoredRunEvent event) { + JsonNode payload = event.type() == AiRunEventType.RUN_COMPLETED ? completedPayload(event) : event.payload(); + String json; + try { + json = objectMapper.writeValueAsString(event.toFrame(payload)); + } catch (JsonProcessingException e) { + throw new IllegalStateException("run frame could not be serialized", e); + } + return new OutboundFrame(event.seq(), isTerminal(event.type()), + OutboundFrame.sse(event.type().wireValue(), event.cursor().toString(), json)); + } + + private JsonNode completedPayload(StoredRunEvent event) { + TerminalReplay.CompletedMessage message = replay.findCompleted(event.runId()).orElseThrow(() -> + new IllegalStateException("run.completed without a completed assistant message")); + long noteId = jdbc.queryForObject("SELECT note_id FROM ai_runs WHERE run_id = ?", Long.class, event.runId()); + return TerminalReplay.completedPayload(event.payload(), message, + availability.of(noteId, List.of(message.sourceReferences()))); + } + + static boolean isTerminal(AiRunEventType type) { + return type == AiRunEventType.RUN_COMPLETED || type == AiRunEventType.RUN_FAILED + || type == AiRunEventType.RUN_CANCELED; + } +} diff --git a/src/main/java/com/proovy/domain/run/stream/RunFrameSource.java b/src/main/java/com/proovy/domain/run/stream/RunFrameSource.java new file mode 100644 index 00000000..9fad5aa2 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/stream/RunFrameSource.java @@ -0,0 +1,23 @@ +package com.proovy.domain.run.stream; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +/** {@link RunEventStream}이 DB에서 읽는 것. production 구현은 {@link RunFrameReader}다. */ +interface RunFrameSource { + + /** 지금 run 상태. run이 사라졌거나(Note 삭제 cascade) Note가 삭제 중이면 empty — 연결을 닫는다. */ + Optional progress(UUID runId); + + /** {@code afterSeq} 이후 commit된 frame을 seq 오름차순으로 최대 {@code limit}개(hydration 끝난 SSE 블록). */ + List after(UUID runId, long afterSeq, int limit); + + /** + * @param terminal run이 terminal 상태인지. 이 값을 읽은 뒤의 {@link #after}에는 terminal event까지 모두 보인다 + * (terminal 전환과 terminal event는 한 transaction이다) + * @param lastSeq {@code ai_runs.last_event_seq} + */ + record Progress(boolean terminal, long lastSeq) { + } +} diff --git a/src/main/java/com/proovy/domain/run/stream/RunReadService.java b/src/main/java/com/proovy/domain/run/stream/RunReadService.java new file mode 100644 index 00000000..b8390d14 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/stream/RunReadService.java @@ -0,0 +1,368 @@ +package com.proovy.domain.run.stream; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.proovy.domain.run.contract.ActiveRunSummary; +import com.proovy.domain.run.contract.AssistantCancel; +import com.proovy.domain.run.contract.AssistantFailure; +import com.proovy.domain.run.contract.AssistantMessageContent; +import com.proovy.domain.run.contract.BrowserRunContract; +import com.proovy.domain.run.contract.CancelCause; +import com.proovy.domain.run.contract.ConversationMessage; +import com.proovy.domain.run.contract.ConversationMessageStatus; +import com.proovy.domain.run.contract.ConversationPage; +import com.proovy.domain.run.contract.MessageRole; +import com.proovy.domain.run.contract.PageSelection; +import com.proovy.domain.run.contract.PublicRunStatus; +import com.proovy.domain.run.contract.RequestedOutput; +import com.proovy.domain.run.contract.RequestedTask; +import com.proovy.domain.run.contract.RunFailureSummary; +import com.proovy.domain.run.contract.RunStatus; +import com.proovy.domain.run.contract.TurnAttachmentMime; +import com.proovy.domain.run.contract.TurnAttachmentView; +import com.proovy.domain.run.contract.UserMessageContent; +import com.proovy.domain.run.contract.ViewerFocus; +import com.proovy.domain.run.entity.AiRunStatus; +import com.proovy.domain.run.terminal.TerminalReplay; +import com.proovy.global.exception.BusinessException; +import com.proovy.global.response.ErrorCode; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.function.LongPredicate; +import java.util.regex.Pattern; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * run 상태({@code GET /api/ai-runs/{runId}})와 대화 조회({@code GET /api/conversations/{noteId}/messages}). + *

+ * 대화 조회의 완료 assistant 본문은 SSE {@code run.completed}와 같은 함수({@link TerminalReplay#completedPayload})로 만든다. + * 그래서 조회·replay·live가 같은 page/block/source ID·순서·plain text를 주고, 차이는 read-time {@code available}뿐이다. + * 진행 중 placeholder에는 본문이 없다(Candidate 비공개). 실패 정보는 그 run의 {@code run.failed} payload다. + *

+ * 접근: run·Note 소유자이고 Note가 ACTIVE일 때만. 없음·타인·삭제 중은 같은 RUN4041이다(존재 oracle 방지). + */ +@Service +public class RunReadService { + + static final int DEFAULT_LIMIT = 50; + static final int MAX_LIMIT = 100; + private static final Pattern LIMIT = Pattern.compile("[1-9][0-9]{0,2}"); + private static final List ACTIVE_STATUSES = AiRunStatus.ACTIVE.stream().map(Enum::name).toList(); + + private final JdbcTemplate jdbc; + private final ObjectMapper objectMapper; + private final SourceAvailability availability; + + public RunReadService(JdbcTemplate jdbc, ObjectMapper objectMapper, SourceAvailability availability) { + this.jdbc = jdbc; + this.objectMapper = objectMapper; + this.availability = availability; + } + + /** 공개 경로에서 접근 가능한 run. {@code runId}가 소문자 canonical UUID가 아니면 없는 run과 같다. */ + record RunRow(UUID runId, String requestId, long noteId, UUID userMessageId, UUID assistantMessageId, + AiRunStatus status, long lastSeq, String responseDisposition, Instant createdAt, + Instant completedAt) { + } + + @Transactional(readOnly = true) + public Optional accessibleRun(long userId, String runId) { + if (!BrowserRunContract.isUuid(runId)) { + return Optional.empty(); + } + return jdbc.query(""" + SELECT r.run_id, r.request_id, r.note_id, r.user_message_id, r.assistant_message_id, r.status, + r.last_event_seq, r.response_disposition, r.created_at, r.completed_at + FROM ai_runs r JOIN notes n ON n.note_id = r.note_id AND n.user_id = r.user_id + WHERE r.run_id = ? AND r.user_id = ? AND n.status = 'ACTIVE' + """, (rs, row) -> new RunRow(rs.getObject(1, UUID.class), rs.getString(2), rs.getLong(3), + rs.getObject(4, UUID.class), rs.getObject(5, UUID.class), AiRunStatus.valueOf(rs.getString(6)), + rs.getLong(7), rs.getString(8), instant(rs, 9), instant(rs, 10)), + UUID.fromString(runId), userId).stream().findFirst(); + } + + @Transactional(readOnly = true) + public RunStatus status(long userId, String runId) { + RunRow run = accessibleRun(userId, runId).orElseThrow(() -> new BusinessException(ErrorCode.RUN4041)); + PublicRunStatus status = PublicRunStatus.from(run.status()); + RunFailureSummary failure = null; + if (run.status() == AiRunStatus.FAILED) { + JsonNode failed = json(jdbc.queryForObject( + "SELECT payload::text FROM ai_run_events WHERE run_id = ? AND event_type = 'run.failed'", + String.class, run.runId())); + failure = new RunFailureSummary(failed.get("code").asText(), textOrNull(failed, "reason"), + failed.get("recoverable").asBoolean()); + } + return new RunStatus(run.runId().toString(), run.requestId(), String.valueOf(run.noteId()), + run.userMessageId().toString(), run.assistantMessageId().toString(), status, status.isCancelable(), + run.lastSeq(), run.status() == AiRunStatus.COMPLETED ? run.responseDisposition() : null, failure, + run.createdAt(), run.completedAt()); + } + + /** + * {@code before}(그 Note의 messageId)보다 오래된 메시지 중 최신 {@code limit}개를 createdAt 오름차순으로. + * 한 turn의 user·assistant가 페이지 경계로 갈리지 않게, 더 오래된 메시지가 있고 페이지 맨 앞이 assistant면 그 assistant는 + * 다음 페이지로 넘긴다. {@code nextBefore}는 더 오래된 메시지가 있을 때 이 페이지 맨 앞 messageId다. + * + * @param before 없으면 최신부터. UUID가 아니면 RUN4001, 이 Note의 메시지가 아니면 RUN4041 + * @param limit 1..100, 없으면 50. 그 밖은 RUN4001 + */ + @Transactional(readOnly = true) + public ConversationPage conversation(long userId, String noteIdValue, String before, String limit) { + if (!BrowserRunContract.isNoteId(noteIdValue)) { + throw new BusinessException(ErrorCode.RUN4041); + } + long noteId = Long.parseLong(noteIdValue); + int pageSize = pageSize(limit); + if (before != null && !BrowserRunContract.isUuid(before)) { + throw new BusinessException(ErrorCode.RUN4001); + } + Integer owned = jdbc.query("SELECT 1 FROM notes WHERE note_id = ? AND user_id = ? AND status = 'ACTIVE'", + (rs, row) -> 1, noteId, userId).stream().findFirst().orElse(null); + if (owned == null) { + throw new BusinessException(ErrorCode.RUN4041); + } + + List newestFirst = messages(noteId, before == null ? null : UUID.fromString(before), + pageSize + 1); + boolean older = newestFirst.size() > pageSize; + List page = new ArrayList<>(newestFirst.subList(0, Math.min(pageSize, newestFirst.size()))); + if (older && page.size() > 1 && page.get(page.size() - 1).role() == MessageRole.ASSISTANT) { + page.remove(page.size() - 1); + } + Collections.reverse(page); + + List messages = render(noteId, page); + String nextBefore = older ? page.get(0).messageId().toString() : null; + return new ConversationPage(String.valueOf(noteId), activeRun(noteId), messages, nextBefore); + } + + // ------------------------------------------------------------------ + + private record MessageRow(UUID messageId, MessageRole role, ConversationMessageStatus status, Instant createdAt, + JsonNode userInput, JsonNode responsePages, JsonNode sourceReferences, String plainText, + String contentSha256, JsonNode verificationSummary, UUID runId, JsonNode viewerContext, + String clarificationReason, String cancelCause) { + } + + private List messages(long noteId, UUID before, int limit) { + String select = """ + SELECT m.message_id, m.role, m.status, m.created_at, m.user_input::text, m.response_pages::text, + m.source_references::text, m.plain_text, m.content_sha256, m.verification_summary::text, + r.run_id, (r.request_snapshot -> 'viewer_context')::text, r.clarification_reason, r.cancel_cause + FROM chat_messages m + JOIN ai_runs r ON r.note_id = m.note_id + AND (r.user_message_id = m.message_id OR r.assistant_message_id = m.message_id) + WHERE m.note_id = ? + """; + String order = " ORDER BY m.created_at DESC, m.message_id DESC LIMIT ?"; + if (before == null) { + return jdbc.query(select + order, this::messageRow, noteId, limit); + } + List anchor = jdbc.queryForList( + "SELECT created_at FROM chat_messages WHERE note_id = ? AND message_id = ?", OffsetDateTime.class, + noteId, before); + if (anchor.isEmpty()) { + throw new BusinessException(ErrorCode.RUN4041); + } + return jdbc.query(select + " AND (m.created_at, m.message_id) < (?, ?)" + order, this::messageRow, noteId, + anchor.get(0), before, limit); + } + + private MessageRow messageRow(ResultSet rs, int row) throws SQLException { + return new MessageRow(rs.getObject(1, UUID.class), MessageRole.valueOf(rs.getString(2)), + ConversationMessageStatus.valueOf(rs.getString(3)), instant(rs, 4), json(rs.getString(5)), + json(rs.getString(6)), json(rs.getString(7)), rs.getString(8), rs.getString(9), + json(rs.getString(10)), rs.getObject(11, UUID.class), json(rs.getString(12)), rs.getString(13), + rs.getString(14)); + } + + private List render(long noteId, List page) { + List runIds = page.stream().map(MessageRow::runId).distinct().toList(); + Map> events = terminalAndCreated(runIds); + Map> attachments = attachments(noteId, page.stream() + .filter(m -> m.role() == MessageRole.USER).map(MessageRow::messageId).toList()); + LongPredicate available = availability.of(noteId, page.stream() + .filter(m -> m.status() == ConversationMessageStatus.COMPLETED && m.sourceReferences() != null) + .map(MessageRow::sourceReferences).toList()); + + List messages = new ArrayList<>(page.size()); + for (MessageRow message : page) { + Map runEvents = events.getOrDefault(message.runId(), Map.of()); + if (message.role() == MessageRole.USER) { + messages.add(new ConversationMessage(message.messageId().toString(), MessageRole.USER, + ConversationMessageStatus.COMPLETED, message.runId().toString(), message.createdAt(), + userContent(message, runEvents.get("run.created"), + attachments.getOrDefault(message.messageId(), List.of())), null)); + } else { + messages.add(new ConversationMessage(message.messageId().toString(), MessageRole.ASSISTANT, + message.status(), message.runId().toString(), message.createdAt(), null, + assistantContent(message, runEvents, available))); + } + } + return messages; + } + + private UserMessageContent userContent(MessageRow message, JsonNode created, + List attachments) { + // 요청 작업·출력은 run.created payload(접수 값 그대로)에서 읽어 stream과 같은 값을 준다. + List tasks = created == null ? List.of() + : objectMapper.convertValue(created.get("requestedTasks"), new TypeReference<>() { }); + List outputs = created == null ? List.of() + : objectMapper.convertValue(created.get("requestedOutputs"), new TypeReference<>() { }); + return new UserMessageContent(message.userInput().get("text").asText(), textOrNull(message.userInput(), "latex"), + tasks, outputs, attachments, viewerFocus(message.viewerContext())); + } + + private static ViewerFocus viewerFocus(JsonNode viewer) { + if (viewer == null || !viewer.isObject()) { + return null; + } + JsonNode selection = viewer.get("selection"); + PageSelection pageSelection = selection == null || selection.isNull() ? null + : new PageSelection(selection.get("left").asDouble(), selection.get("top").asDouble(), + selection.get("right").asDouble(), selection.get("bottom").asDouble()); + return new ViewerFocus(viewer.get("asset_id").asText(), viewer.get("page").asInt(), pageSelection); + } + + private AssistantMessageContent assistantContent(MessageRow message, Map runEvents, + LongPredicate available) { + return switch (message.status()) { + case COMPLETED -> { + ObjectNode payload = TerminalReplay.completedPayload(runEvents.get("run.completed"), + new TerminalReplay.CompletedMessage(message.messageId(), message.responsePages(), + message.sourceReferences(), message.plainText(), message.contentSha256(), + message.verificationSummary() == null ? JsonNodeFactory.instance.arrayNode() + : message.verificationSummary()), available); + List summary = new ArrayList<>(); + payload.get("verificationSummary").forEach(summary::add); + yield new AssistantMessageContent(payload.get("responseDisposition").asText(), + textOrNull(payload, "clarificationReason"), payload.get("assistantResponse"), summary, null, + null); + } + case FAILED -> { + JsonNode failed = runEvents.get("run.failed"); + yield new AssistantMessageContent(null, null, null, List.of(), + new AssistantFailure(failed.get("code").asText(), textOrNull(failed, "reason"), + failed.get("message").asText(), failed.get("recoverable").asBoolean()), null); + } + case CANCELED -> new AssistantMessageContent(null, null, null, List.of(), null, + AssistantCancel.of(cancelCause(message.cancelCause()))); + case PENDING, STREAMING -> AssistantMessageContent.placeholder(); + }; + } + + private static CancelCause cancelCause(String wire) { + return Arrays.stream(CancelCause.values()).filter(cause -> cause.wireValue().equals(wire)).findFirst() + .orElseThrow(() -> new IllegalStateException("canceled run without a known cancel cause")); + } + + private ActiveRunSummary activeRun(long noteId) { + return jdbc.query(connection -> { + var ps = connection.prepareStatement( + "SELECT run_id, status, last_event_seq FROM ai_runs WHERE note_id = ? AND status = ANY(?)"); + ps.setLong(1, noteId); + ps.setArray(2, connection.createArrayOf("varchar", ACTIVE_STATUSES.toArray())); + return ps; + }, (rs, row) -> new ActiveRunSummary(rs.getObject(1, UUID.class).toString(), + PublicRunStatus.from(AiRunStatus.valueOf(rs.getString(2))), rs.getLong(3))) + .stream().findFirst().orElse(null); + } + + /** run별 {@code run.created}·{@code run.completed}·{@code run.failed} 저장 payload. 각 type은 run당 최대 하나다. */ + private Map> terminalAndCreated(List runIds) { + Map> events = new HashMap<>(); + if (runIds.isEmpty()) { + return events; + } + jdbc.query(connection -> { + var ps = connection.prepareStatement(""" + SELECT run_id, event_type, payload::text FROM ai_run_events + WHERE run_id = ANY(?) AND event_type IN ('run.created', 'run.completed', 'run.failed') + """); + ps.setArray(1, connection.createArrayOf("uuid", runIds.toArray())); + return ps; + }, rs -> { + events.computeIfAbsent(rs.getObject(1, UUID.class), key -> new HashMap<>()) + .put(rs.getString(2), json(rs.getString(3))); + }); + return events; + } + + private Map> attachments(long noteId, List userMessageIds) { + Map> attachments = new HashMap<>(); + if (userMessageIds.isEmpty()) { + return attachments; + } + Set ids = new LinkedHashSet<>(userMessageIds); + jdbc.query(connection -> { + var ps = connection.prepareStatement(""" + SELECT source_message_id, attachment_id, mime_type, display_label FROM turn_attachments + WHERE note_id = ? AND source_message_id = ANY(?) + ORDER BY source_message_id, attachment_index + """); + ps.setLong(1, noteId); + ps.setArray(2, connection.createArrayOf("uuid", ids.toArray())); + return ps; + }, rs -> { + attachments.computeIfAbsent(rs.getObject(1, UUID.class), key -> new ArrayList<>()) + .add(new TurnAttachmentView(rs.getObject(2, UUID.class).toString(), mime(rs.getString(3)), + rs.getString(4))); + }); + return attachments; + } + + private static TurnAttachmentMime mime(String wire) { + return Arrays.stream(TurnAttachmentMime.values()).filter(mime -> mime.wireValue().equals(wire)).findFirst() + .orElseThrow(() -> new IllegalStateException("unknown turn attachment mime")); + } + + private static int pageSize(String limit) { + if (limit == null) { + return DEFAULT_LIMIT; + } + if (!LIMIT.matcher(limit).matches() || Integer.parseInt(limit) > MAX_LIMIT) { + throw new BusinessException(ErrorCode.RUN4001); + } + return Integer.parseInt(limit); + } + + private static String textOrNull(JsonNode node, String field) { + JsonNode value = node.get(field); + return value == null || value.isNull() ? null : value.asText(); + } + + private static Instant instant(ResultSet rs, int column) throws SQLException { + OffsetDateTime value = rs.getObject(column, OffsetDateTime.class); + return value == null ? null : value.toInstant(); + } + + private JsonNode json(String value) { + if (value == null) { + return null; + } + try { + return objectMapper.readTree(value); + } catch (JsonProcessingException e) { + throw new IllegalStateException("stored run data is not JSON", e); + } + } +} diff --git a/src/main/java/com/proovy/domain/run/stream/RunSseConfig.java b/src/main/java/com/proovy/domain/run/stream/RunSseConfig.java new file mode 100644 index 00000000..85e72ad8 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/stream/RunSseConfig.java @@ -0,0 +1,16 @@ +package com.proovy.domain.run.stream; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableConfigurationProperties(RunSseProperties.class) +public class RunSseConfig { + + /** run SSE 전용 pool. 전역 executor/scheduler 기본 bean과 겹치지 않는 자체 타입이다({@link RunSseScheduler}). */ + @Bean(destroyMethod = "close") + public RunSseScheduler runSseScheduler(RunSseProperties properties) { + return new RunSseScheduler(properties.threads()); + } +} diff --git a/src/main/java/com/proovy/domain/run/stream/RunSseProperties.java b/src/main/java/com/proovy/domain/run/stream/RunSseProperties.java new file mode 100644 index 00000000..ef29bd45 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/stream/RunSseProperties.java @@ -0,0 +1,49 @@ +package com.proovy.domain.run.stream; + +import com.proovy.domain.run.contract.BrowserRunContract; +import java.time.Duration; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.DefaultValue; + +/** + * {@code proovy.run.sse.*} — 브라우저 run SSE 연결(POST /api/conversations, GET /api/ai-runs/{runId}/events). + * + * @param heartbeat live 연결 heartbeat comment 주기. 계약 값은 15초(browser-run-v1 D10)이고 테스트만 줄인다 + * @param catchUpInterval 신호가 없어도 cursor 이후를 DB에서 다시 확인하는 주기. 신호 유실(다른 인스턴스 commit, + * listener 예외)에서 이 주기 안에 복구된다. 한 번은 {@code ai_runs} 한 행 조회다 + * @param timeout 연결 최대 유지 시간. 지나면 연결만 닫히고 브라우저는 마지막 event ID로 GET 재접속한다 + * @param batchSize DB catch-up 한 번에 읽는 event 수 + * @param threads replay·live 전송과 heartbeat를 하는 연결 공용 thread 수. 연결마다 동시에 최대 한 개만 쓴다 + */ +@ConfigurationProperties(prefix = "proovy.run.sse") +public record RunSseProperties( + @DefaultValue("15s") Duration heartbeat, + @DefaultValue("5s") Duration catchUpInterval, + @DefaultValue("30m") Duration timeout, + @DefaultValue("100") int batchSize, + @DefaultValue("8") int threads) { + + public RunSseProperties { + requirePositive(heartbeat, "heartbeat"); + requirePositive(catchUpInterval, "catch-up-interval"); + requirePositive(timeout, "timeout"); + if (batchSize < 1 || batchSize > 1000) { + throw new IllegalArgumentException("proovy.run.sse.batch-size must be within 1..1000"); + } + if (threads < 1 || threads > 64) { + throw new IllegalArgumentException("proovy.run.sse.threads must be within 1..64"); + } + } + + /** 계약 기본값(heartbeat 15초)을 쓰는 설정. */ + public static RunSseProperties defaults() { + return new RunSseProperties(Duration.ofSeconds(BrowserRunContract.HEARTBEAT_SECONDS), Duration.ofSeconds(5), + Duration.ofMinutes(30), 100, 8); + } + + private static void requirePositive(Duration value, String name) { + if (value == null || value.isNegative() || value.isZero()) { + throw new IllegalArgumentException("proovy.run.sse." + name + " must be positive"); + } + } +} diff --git a/src/main/java/com/proovy/domain/run/stream/RunSseScheduler.java b/src/main/java/com/proovy/domain/run/stream/RunSseScheduler.java new file mode 100644 index 00000000..9485befe --- /dev/null +++ b/src/main/java/com/proovy/domain/run/stream/RunSseScheduler.java @@ -0,0 +1,66 @@ +package com.proovy.domain.run.stream; + +import java.time.Duration; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import lombok.extern.slf4j.Slf4j; + +/** + * run SSE 연결 공용 thread pool. replay·live 전송, heartbeat, 주기 catch-up이 쓴다. + *

+ * {@code Executor}·{@code ScheduledExecutorService}·{@code TaskScheduler} 타입 bean으로 노출하지 않는다. 그런 bean이 하나라도 + * 있으면 Spring Boot의 기본 {@code applicationTaskExecutor}(MVC async)·{@code taskScheduler}(@Scheduled)가 생성되지 않거나 + * @Scheduled가 이 pool을 골라 쓰게 된다(전역 기본 bean은 공통 설정이라 바꾸지 않는다). + *

+ * 작업 queue는 연결 수에 비례한다: 연결마다 예약 작업 두 개(heartbeat·catch-up)와 실행 대기 전송 최대 한 개뿐이고, + * 신호가 몰려도 연결당 전송 작업은 늘지 않는다({@link RunEventStream}). + */ +@Slf4j +public class RunSseScheduler implements AutoCloseable { + + private final ScheduledThreadPoolExecutor executor; + + public RunSseScheduler(int threads) { + AtomicInteger sequence = new AtomicInteger(); + ThreadFactory factory = runnable -> { + Thread thread = new Thread(runnable, "run-sse-" + sequence.incrementAndGet()); + thread.setDaemon(true); + return thread; + }; + executor = new ScheduledThreadPoolExecutor(threads, factory); + // 연결이 닫히면 예약 작업을 취소한다. 취소된 작업이 queue에 남지 않게 한다. + executor.setRemoveOnCancelPolicy(true); + } + + public void execute(Runnable task) { + executor.execute(guarded(task)); + } + + public ScheduledFuture every(Duration period, Runnable task) { + long millis = period.toMillis(); + return executor.scheduleWithFixedDelay(guarded(task), millis, millis, TimeUnit.MILLISECONDS); + } + + /** 반복 작업이 예외로 멈추지 않게 한다. 연결 오류는 각 연결이 스스로 닫는다. */ + private static Runnable guarded(Runnable task) { + return () -> { + try { + task.run(); + } catch (RuntimeException e) { + log.warn("run SSE task failed: {}", e.getClass().getSimpleName()); + } + }; + } + + int queuedTasks() { + return executor.getQueue().size(); + } + + @Override + public void close() { + executor.shutdownNow(); + } +} diff --git a/src/main/java/com/proovy/domain/run/stream/RunStreamService.java b/src/main/java/com/proovy/domain/run/stream/RunStreamService.java new file mode 100644 index 00000000..4a128ae7 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/stream/RunStreamService.java @@ -0,0 +1,84 @@ +package com.proovy.domain.run.stream; + +import com.proovy.domain.run.admission.RunAdmission; +import com.proovy.domain.run.contract.RunEventCursor; +import com.proovy.domain.run.event.RunEventSignals; +import com.proovy.global.exception.BusinessException; +import com.proovy.global.response.ErrorCode; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import org.springframework.stereotype.Service; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +/** + * 브라우저 SSE 연결을 연다. 판정은 모두 stream 시작 전이라 거부는 JSON 오류다. + *

+ * 연결은 저장된 event를 읽고 신호를 구독할 뿐이다. run을 만들거나 dispatch·취소·차감하지 않으므로 몇 번을 다시 연결해도 + * run·worker·사용량은 늘지 않는다. 연결 종료(브라우저 단절·timeout)는 구독 해제이고 실행 취소가 아니다(취소는 명시적 cancel API). + */ +@Service +public class RunStreamService { + + private final RunReadService reads; + private final RunFrameSource frames; + private final RunEventSignals signals; + private final RunSseScheduler scheduler; + private final RunSseProperties properties; + private final AtomicInteger openStreams = new AtomicInteger(); + + public RunStreamService(RunReadService reads, RunFrameReader frames, RunEventSignals signals, + RunSseScheduler scheduler, RunSseProperties properties) { + this.reads = reads; + this.frames = frames; + this.signals = signals; + this.scheduler = scheduler; + this.properties = properties; + } + + /** POST /api/conversations 접수 결과: CREATED·REPLAYED 모두 seq 1 run.created부터, terminal이 아니면 live로 잇는다. */ + public SseEmitter openAdmitted(RunAdmission admission) { + return open(admission.runId(), RunAdmission.REPLAY_FROM_SEQ - 1); + } + + /** + * GET /api/ai-runs/{runId}/events. 판정 순서(cursors/cases.json precedence): run 접근 RUN4041 → cursor 형식·범위 RUN4003 → + * 다른 run RUN4004. 없는 run·타인 run·삭제 중 Note는 같은 404다. + */ + public SseEmitter openReplay(long userId, String runId, String lastEventIdHeader, String lastEventIdQuery) { + RunReadService.RunRow run = reads.accessibleRun(userId, runId) + .orElseThrow(() -> new BusinessException(ErrorCode.RUN4041)); + RunEventCursor.ReplayStart start = RunEventCursor.resolve(run.runId().toString(), run.lastSeq(), + lastEventIdHeader, lastEventIdQuery); + if (start.isRejected()) { + throw new BusinessException(start.error()); + } + return open(run.runId(), start.fromSeq() - 1); + } + + /** 지금 열린 SSE 연결 수. 브라우저 단절·terminal 종료·timeout 뒤에는 줄어든다. */ + public int openStreams() { + return openStreams.get(); + } + + private SseEmitter open(UUID runId, long afterSeq) { + SseEmitter emitter = new SseEmitter(properties.timeout().toMillis()); + openStreams.incrementAndGet(); + RunEventStream stream = new RunEventStream(runId, afterSeq, frames, new SseEmitterSink(emitter), signals, + scheduler, properties, openStreams::decrementAndGet); + emitter.onCompletion(stream::close); + emitter.onError(error -> stream.close()); + emitter.onTimeout(() -> { + // 최대 유지 시간: 연결만 닫는다. 브라우저는 마지막 event ID로 GET 재접속한다. + stream.close(); + emitter.complete(); + }); + try { + stream.start(); + } catch (RuntimeException e) { + // 예약 실패(shutdown 중 등): 이미 건 구독과 연결 수를 되돌린다 + stream.close(); + throw e; + } + return emitter; + } +} diff --git a/src/main/java/com/proovy/domain/run/stream/SourceAvailability.java b/src/main/java/com/proovy/domain/run/stream/SourceAvailability.java new file mode 100644 index 00000000..cd757901 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/stream/SourceAvailability.java @@ -0,0 +1,43 @@ +package com.proovy.domain.run.stream; + +import com.fasterxml.jackson.databind.JsonNode; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.function.LongPredicate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * Source Reference {@code available}의 read-time 판정(browser-run-v1: replay와 live의 유일한 허용 차이). + * 같은 Note의 asset이 지금 {@code ACTIVE}면 true, DELETING·DELETED·없음이면 false다. label·page snapshot은 판정과 무관하게 + * 저장값 그대로다. + */ +@Component +public class SourceAvailability { + + private final JdbcTemplate jdbc; + + public SourceAvailability(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + /** canonical {@code source_references}(snake_case) 배열들의 asset 중 지금 쓸 수 있는 것. */ + public LongPredicate of(long noteId, List sourceReferences) { + Set assetIds = new HashSet<>(); + for (JsonNode references : sourceReferences) { + references.forEach(reference -> assetIds.add(reference.get("asset_id").asLong())); + } + if (assetIds.isEmpty()) { + return assetId -> false; + } + Set active = new HashSet<>(jdbc.query(connection -> { + var ps = connection.prepareStatement( + "SELECT id FROM assets WHERE note_id = ? AND status = 'ACTIVE' AND id = ANY(?)"); + ps.setLong(1, noteId); + ps.setArray(2, connection.createArrayOf("bigint", assetIds.toArray(Long[]::new))); + return ps; + }, (rs, row) -> rs.getLong(1))); + return active::contains; + } +} diff --git a/src/main/java/com/proovy/domain/run/stream/SseEmitterSink.java b/src/main/java/com/proovy/domain/run/stream/SseEmitterSink.java new file mode 100644 index 00000000..4b39e280 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/stream/SseEmitterSink.java @@ -0,0 +1,93 @@ +package com.proovy.domain.run.stream; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Set; +import org.springframework.http.MediaType; +import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitter.DataWithMediaType; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +/** + * {@link SseEmitter}에 SSE 블록을 바이트 그대로 쓴다. Spring의 event builder는 {@code id:value}(콜론 뒤 공백 없음)· + * {@code :comment}로 쓰므로 browser-run-v1 fixture({@code event: …}, {@code : heartbeat})와 같은 바이트를 내려고 + * 직접 만든 블록을 UTF-8 text로 보낸다. Content-Type은 emitter가 {@code text/event-stream}으로 정한다. + */ +final class SseEmitterSink implements RunEventSink { + + private static final MediaType UTF8_TEXT = new MediaType("text", "plain", StandardCharsets.UTF_8); + + private final SseEmitter emitter; + + SseEmitterSink(SseEmitter emitter) { + this.emitter = emitter; + } + + @Override + public void write(String block) throws IOException { + try { + emitter.send(new RawBlock(block)); + } catch (IllegalStateException e) { + // 이미 끝난 emitter(단절·timeout과 경합)만 단절로 본다. 그 밖(변환 실패 등)은 stream 오류 경로로 보낸다. + if (e.getMessage() != null && e.getMessage().contains("already completed")) { + throw new IOException("emitter already completed", e); + } + throw e; + } + } + + @Override + public void fail(Throwable error) { + try { + emitter.completeWithError(error); + } catch (RuntimeException ignored) { + // 이미 끝난 연결 + } + } + + @Override + public void complete() { + try { + emitter.complete(); + } catch (RuntimeException ignored) { + // 이미 끝난 연결 + } + } + + private record RawBlock(String block) implements SseEmitter.SseEventBuilder { + + @Override + public SseEmitter.SseEventBuilder id(String id) { + throw new UnsupportedOperationException(); + } + + @Override + public SseEmitter.SseEventBuilder name(String eventName) { + throw new UnsupportedOperationException(); + } + + @Override + public SseEmitter.SseEventBuilder reconnectTime(long reconnectTimeMillis) { + throw new UnsupportedOperationException(); + } + + @Override + public SseEmitter.SseEventBuilder comment(String comment) { + throw new UnsupportedOperationException(); + } + + @Override + public SseEmitter.SseEventBuilder data(Object object) { + throw new UnsupportedOperationException(); + } + + @Override + public SseEmitter.SseEventBuilder data(Object object, MediaType mediaType) { + throw new UnsupportedOperationException(); + } + + @Override + public Set build() { + return Set.of(new DataWithMediaType(block, UTF8_TEXT)); + } + } +} diff --git a/src/main/java/com/proovy/domain/run/terminal/AcceptedCompletion.java b/src/main/java/com/proovy/domain/run/terminal/AcceptedCompletion.java new file mode 100644 index 00000000..8e9907c1 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/terminal/AcceptedCompletion.java @@ -0,0 +1,41 @@ +package com.proovy.domain.run.terminal; + +import com.fasterxml.jackson.databind.JsonNode; +import com.proovy.domain.run.entity.ClarificationReason; +import com.proovy.domain.run.entity.ResponseDisposition; +import java.util.List; + +/** + * 수락한 {@code run.completed}의 저장 입력. + *

    + *
  • {@code responsePages}: canonical {@code response_pages[{response_page_id, presentation, title?, result_blocks}]}
  • + *
  • {@code sourceReferences}: {@code [{source_ref_id, asset_id, page_start, page_end, display_label_snapshot}]}. + * {@code asset_id}는 Server Note Asset ID(정수), label은 Brain publication이 정답 숨기기 검사를 한 label이다.
  • + *
  • {@code plainText}: 공개 page title과 block body에서만 파생(Brain {@code derive_plain_text}). artifact·label·summary는 넣지 않는다.
  • + *
  • {@code contentSha256}: {@code {response_pages, source_references, plain_text}}의 canonical JSON sha256
  • + *
  • {@code verificationSummary}: 공개 summary 배열(허용 문구만)
  • + *
+ * 출처 검사는 metadata(소유·version·page 범위·provenance manifest) 대조일 뿐 인용 의미·정답을 보장하지 않는다. + * JsonNode 값은 이 객체가 소유하므로 호출자가 바꾸지 않는다. + */ +public record AcceptedCompletion( + String runId, + int attempt, + String fenceToken, + String sourceEventId, + ResponseDisposition disposition, + ClarificationReason clarificationReason, + JsonNode responsePages, + JsonNode sourceReferences, + String plainText, + String contentSha256, + JsonNode verificationSummary, + List verifiedSolutions, + String model, + String tier, + TerminalRecheck recheck) implements AcceptedTerminal { + + public AcceptedCompletion { + verifiedSolutions = List.copyOf(verifiedSolutions); + } +} diff --git a/src/main/java/com/proovy/domain/run/terminal/AcceptedFailure.java b/src/main/java/com/proovy/domain/run/terminal/AcceptedFailure.java new file mode 100644 index 00000000..75738660 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/terminal/AcceptedFailure.java @@ -0,0 +1,21 @@ +package com.proovy.domain.run.terminal; + +import com.proovy.domain.run.entity.RunFailureClass; +import com.proovy.domain.run.entity.RunFailureCode; + +/** + * 수락한 {@code run.failed}. code·class·reason·고정 안전 message가 서로 맞는 것만 수락한다(답 본문 없음). + * + * @param reason invalid_attachment일 때만 있다 + */ +public record AcceptedFailure( + String runId, + int attempt, + String fenceToken, + String sourceEventId, + RunFailureCode code, + RunFailureClass failureClass, + AttachmentFailureReason reason, + String message, + boolean recoverable) implements AcceptedTerminal { +} diff --git a/src/main/java/com/proovy/domain/run/terminal/AcceptedTerminal.java b/src/main/java/com/proovy/domain/run/terminal/AcceptedTerminal.java new file mode 100644 index 00000000..b66540f1 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/terminal/AcceptedTerminal.java @@ -0,0 +1,16 @@ +package com.proovy.domain.run.terminal; + +/** + * Server 기준으로 수락한 terminal — 저장 transaction(1.82)의 검증된 입력. 저장·usage 확정은 하지 않는다. + * envelope 값은 1.82의 현재 fence 조건부 UPDATE에 그대로 쓴다. + */ +public sealed interface AcceptedTerminal permits AcceptedCompletion, AcceptedFailure { + + String runId(); + + int attempt(); + + String fenceToken(); + + String sourceEventId(); +} diff --git a/src/main/java/com/proovy/domain/run/terminal/AttachmentFailureReason.java b/src/main/java/com/proovy/domain/run/terminal/AttachmentFailureReason.java new file mode 100644 index 00000000..057f8de5 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/terminal/AttachmentFailureReason.java @@ -0,0 +1,42 @@ +package com.proovy.domain.run.terminal; + +import com.proovy.domain.run.entity.WireValue; + +/** + * {@code run.failed}의 {@code invalid_attachment} reason sub-code(Brain {@code AttachmentFailureReason})와 + * reason별 고정 안전 message. 파일 이름·URL·측정값은 담지 않는다. + */ +public enum AttachmentFailureReason implements WireValue { + UNSUPPORTED_MEDIA_TYPE("unsupported_media_type", + "지원하지 않는 첨부 형식입니다. PDF, JPEG, PNG, WebP 파일로 다시 올려주세요."), + SIGNATURE_MISMATCH("signature_mismatch", "첨부 파일의 형식과 내용이 일치하지 않습니다. 원본 파일로 다시 올려주세요."), + FILE_TOO_LARGE("file_too_large", "첨부 파일의 용량이 한도를 초과했습니다. 용량을 줄여 다시 올려주세요."), + IMAGE_DECODE_FAILED("image_decode_failed", "손상됐거나 읽을 수 없는 이미지입니다. 다른 파일로 다시 올려주세요."), + ABNORMAL_DIMENSIONS("abnormal_dimensions", "이미지 크기가 비정상적입니다. 크기를 조정한 파일로 다시 올려주세요."), + PIXEL_LIMIT_EXCEEDED("pixel_limit_exceeded", "이미지 해상도가 한도를 초과했습니다. 해상도를 줄여 다시 올려주세요."), + PDF_ENCRYPTED("pdf_encrypted", "암호로 보호된 PDF는 처리할 수 없습니다. 암호를 해제한 PDF를 다시 올려주세요."), + PDF_CORRUPT("pdf_corrupt", "손상됐거나 읽을 수 없는 PDF입니다. 다른 파일로 다시 올려주세요."), + TOO_MANY_PAGES("too_many_pages", "PDF 페이지 수가 한도를 초과했습니다. 문서를 나누어 다시 올려주세요."), + PAGE_OUT_OF_RANGE("page_out_of_range", "요청한 페이지가 문서에 없습니다. 페이지 범위를 확인해 다시 요청해 주세요."), + BUNDLE_COUNT_EXCEEDED("bundle_count_exceeded", + "한 번에 첨부할 수 있는 파일 개수를 초과했습니다. 개수를 줄여 다시 올려주세요."), + BUNDLE_BYTES_EXCEEDED("bundle_bytes_exceeded", "첨부 파일의 총 용량이 한도를 초과했습니다. 용량을 줄여 다시 올려주세요."), + BUNDLE_EMPTY("bundle_empty", "첨부된 파일이 없습니다. 파일을 첨부해 다시 요청해 주세요."); + + private final String wireValue; + private final String message; + + AttachmentFailureReason(String wireValue, String message) { + this.wireValue = wireValue; + this.message = message; + } + + @Override + public String wireValue() { + return wireValue; + } + + public String message() { + return message; + } +} diff --git a/src/main/java/com/proovy/domain/run/terminal/PublicWording.java b/src/main/java/com/proovy/domain/run/terminal/PublicWording.java new file mode 100644 index 00000000..76de752e --- /dev/null +++ b/src/main/java/com/proovy/domain/run/terminal/PublicWording.java @@ -0,0 +1,63 @@ +package com.proovy.domain.run.terminal; + +import com.proovy.domain.run.entity.RunFailureClass; +import com.proovy.domain.run.entity.RunFailureCode; +import java.util.EnumMap; +import java.util.Map; +import java.util.Set; + +/** + * Brain {@code contracts/publication.py}·{@code terminal.py}의 고정 공개 문구 allowlist. + * 공개 summary·step 사유·미지원 안내·실패 message는 이 문구만 쓸 수 있다(내부 reason·provider·code 원문 금지). + */ +final class PublicWording { + + static final String FULL_STEP_VERIFICATION = "모든 단계를 실행 검사로 확인"; + static final String INDEPENDENT_ANSWER_CHECK = "독립 계산으로 답을 비교(과정 미검증)"; + static final Set METHOD_LABELS = Set.of( + FULL_STEP_VERIFICATION, + "일부 단계를 수치·보조 증거로 확인", + "일부 단계를 논리 수사 검토로 확인", + INDEPENDENT_ANSWER_CHECK); + + static final String LEGITIMATE_LOGICAL_MOVE = "실행 검사 대상이 아닌 논리 수사 단계로 확인됐다"; + static final Set STEP_REASONS = Set.of( + "이 단계가 성립하지 않는다고 확인됐다", + "이 단계를 확인할 실행 검사가 만들어지지 않았다", + "검사 실행 증거가 없다", + "검사가 이 단계를 판별하지 못했다", + "검사가 검토되지 않았다", + "검사가 검증 증거로 인정되지 않았다", + "검토가 이 검사의 판별을 확정하지 못했다", + LEGITIMATE_LOGICAL_MOVE, + "이 단계는 확인되지 않았다"); + + static final String UNSUPPORTED_RESPONSE_BODY = + "변형·연습문제 생성은 아직 지원하지 않는다. 기존 문제의 풀이·검산·설명을 요청해 주세요."; + + private static final Map FAILURE_MESSAGES = new EnumMap<>(Map.of( + RunFailureCode.VERIFICATION_FAILED, "풀이의 오류를 수정하지 못했습니다. 새 요청으로 다시 시도할 수 있습니다.", + RunFailureCode.BUDGET_EXCEEDED, "요청의 처리 한도 안에서 응답을 완성하지 못했습니다.", + RunFailureCode.PROVIDER_ERROR, "응답 처리 서비스를 사용할 수 없습니다.", + RunFailureCode.TIMEOUT, "응답 처리 시간이 초과됐습니다.", + RunFailureCode.STORAGE_ERROR, "응답을 저장하지 못했습니다.", + RunFailureCode.INTERNAL_ERROR, "응답 처리를 완료하지 못했습니다.")); + + private PublicWording() { + } + + /** code(와 invalid_attachment의 reason)가 가질 수 있는 유일한 안전 message. */ + static String failureMessage(RunFailureCode code, AttachmentFailureReason reason) { + return code == RunFailureCode.INVALID_ATTACHMENT ? reason.message() : FAILURE_MESSAGES.get(code); + } + + /** Brain {@code expected_failure_class}. */ + static RunFailureClass failureClass(RunFailureCode code) { + return switch (code) { + case VERIFICATION_FAILED -> RunFailureClass.VERIFICATION; + case BUDGET_EXCEEDED -> RunFailureClass.BUDGET; + case INVALID_ATTACHMENT -> RunFailureClass.ATTACHMENT; + default -> RunFailureClass.INFRASTRUCTURE; + }; + } +} diff --git a/src/main/java/com/proovy/domain/run/terminal/Python.java b/src/main/java/com/proovy/domain/run/terminal/Python.java new file mode 100644 index 00000000..b2da00f9 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/terminal/Python.java @@ -0,0 +1,48 @@ +package com.proovy.domain.run.terminal; + +/** + * Brain(pydantic·Python str)과 같은 문자열 의미. {@code str.isspace()}는 Java {@link Character#isWhitespace}에 + * non-breaking space(U+00A0·U+2007·U+202F)와 U+0085를 더한 집합이고, 길이는 code point 수다. + */ +final class Python { + + private Python() { + } + + static boolean isSpace(int codePoint) { + return Character.isWhitespace(codePoint) + || codePoint == 0x85 || codePoint == 0xA0 || codePoint == 0x2007 || codePoint == 0x202F; + } + + static String strip(String value) { + return rstrip(lstrip(value)); + } + + static String lstrip(String value) { + int start = 0; + while (start < value.length()) { + int codePoint = value.codePointAt(start); + if (!isSpace(codePoint)) { + break; + } + start += Character.charCount(codePoint); + } + return value.substring(start); + } + + static String rstrip(String value) { + int end = value.length(); + while (end > 0) { + int codePoint = value.codePointBefore(end); + if (!isSpace(codePoint)) { + break; + } + end -= Character.charCount(codePoint); + } + return value.substring(0, end); + } + + static int length(String value) { + return value.codePointCount(0, value.length()); + } +} diff --git a/src/main/java/com/proovy/domain/run/terminal/RunTerminalService.java b/src/main/java/com/proovy/domain/run/terminal/RunTerminalService.java new file mode 100644 index 00000000..950e4f66 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/terminal/RunTerminalService.java @@ -0,0 +1,592 @@ +package com.proovy.domain.run.terminal; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.proovy.domain.asset.entity.AssetStatus; +import com.proovy.domain.run.brain.SourceEvent; +import com.proovy.domain.run.brain.SourceEventType; +import com.proovy.domain.run.brain.StoredTerminal; +import com.proovy.domain.run.brain.StoredTerminalType; +import com.proovy.domain.run.contract.CanonicalJson; +import com.proovy.domain.run.entity.AiRunStatus; +import com.proovy.domain.run.entity.RunFailureClass; +import com.proovy.domain.run.entity.RunFailureCode; +import com.proovy.domain.run.event.RunEventStore; +import com.proovy.domain.run.event.SourceActivities; +import com.proovy.domain.run.event.TerminalRunEvent; +import com.proovy.domain.run.terminal.TerminalScopeReader.LockedAsset; +import com.proovy.domain.run.terminal.TerminalScopeReader.Scope; +import com.proovy.domain.run.terminal.TerminalScopeReader.ScopeNotReady; +import com.proovy.domain.run.terminal.TerminalSettlement.Outcome; +import com.proovy.domain.usage.service.RequestUsageService; +import com.proovy.domain.usage.service.RunTerminalOutcome; +import com.proovy.domain.usage.service.UsageCompensationResult; +import com.proovy.global.util.HashUtils; +import java.sql.SQLException; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.function.Function; +import java.util.function.Supplier; +import jakarta.persistence.EntityManager; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.transaction.support.TransactionTemplate; + +/** + * run terminal 확정의 유일한 경로(task 1.82). 현재 fence의 terminal 하나만 받아 run·assistant message·Verified Solution· + * page/완료 event·usage 보상을 한 transaction에서 저장하고, commit 뒤에만 {@code RunEventSignals}가 알린다. + *

+ * 진입점과 호출자: + *

    + *
  • {@link #settle(SourceEvent)}: Brain stream의 {@code run.completed}·{@code run.failed} (1.83 coordinator)
  • + *
  • {@link #settle(StoredTerminal)}: status 조회로 회수한 저장 terminal (1.85 회수). {@code run.canceled}는 취소 경로(1.84)다
  • + *
  • {@link #failByServer}: Brain terminal 없이 Server가 최종 실패로 끝낸다 — 거부된 terminal·실행 소실·재시도 소진 (1.83·1.85)
  • + *
  • {@link #expireDeadline}: 요청 deadline 만료 (1.83/1.85 deadline 조회 {@code idx_ai_runs_deadline})
  • + *
+ *

transaction 경계

+ * 잠금 순서는 {@code notes FOR SHARE → ai_runs FOR UPDATE → assets FOR SHARE → verified_solutions FOR UPDATE → + * credit_balance FOR UPDATE}(usage)다. 접수(1.79)·asset 삭제와 같은 Note 우선 순서라 서로 기다릴 뿐 교착하지 않는다. + * 1.84 취소 전환도 이 순서를 따른다. 판정(중복·fence·상태·deadline·수락·재확인)은 잠금 뒤 DB의 지금 값으로 하고, + * 거부면 아무것도 쓰지 않는다. 쓰기는 조건부 UPDATE({@code WHERE attempt·fence·status IN (...)})가 1 row일 때만 이어지고, + * 그 UPDATE가 {@code last_event_seq + n RETURNING}으로 terminal event seq를 함께 배정한다(1.80). 중간에 실패하면 전부 rollback이다. + * Brain 호출·네트워크 대기는 transaction 안에 없다. 직렬화·교착·연결 실패는 같은 입력으로 새 transaction을 연다(멱등). + *

상태 전이

+ *
+ * RUNNING|RETRYING --run.completed(현재 fence, deadline 전)--> COMPLETED (+ message COMPLETED, solution, 보상: clarification·unsupported)
+ * RUNNING|RETRYING --run.failed(현재 fence, deadline 전)-----> FAILED    (+ message FAILED, 보상: 모든 최종 실패)
+ * PENDING|RUNNING|RETRYING --failByServer(현재 attempt·fence)-> FAILED
+ * PENDING|RUNNING|RETRYING --expireDeadline(deadline 지남)----> FAILED timeout
+ * CANCELING --(1.84만)--> CANCELED. 여기서는 어떤 terminal도 받지 않는다(NOT_ACCEPTING)
+ * 
+ */ +@Slf4j +@Service +public class RunTerminalService { + + /** serialization_failure, deadlock_detected, lock_not_available (+ 연결 실패 08xxx) */ + private static final Set RETRYABLE_SQL_STATES = Set.of("40001", "40P01", "55P03"); + private static final int MAX_TRANSACTIONS = 3; + + private static final String LOCK_NOTE = """ + SELECT n.note_id FROM notes n WHERE n.note_id = (SELECT note_id FROM ai_runs WHERE run_id = ?) FOR SHARE + """; + private static final String LOCK_RUN = """ + SELECT request_id, user_id, note_id, assistant_message_id, status, attempt, fence_token, request_deadline_at, + terminal_source_sha256, last_event_seq, request_snapshot::text, policy_snapshot::text, + terminal_source_event_id, failure_code + FROM ai_runs WHERE run_id = ? FOR UPDATE + """; + private static final String RELEASE = """ + completed_at = ?, lease_owner = NULL, lease_expires_at = NULL, grant_digest = NULL, grant_expires_at = NULL, + terminal_source_event_id = ?, terminal_source_sha256 = ?, last_event_seq = last_event_seq + ? + """; + + private final JdbcTemplate jdbc; + private final TransactionTemplate tx; + private final ObjectMapper objectMapper; + private final RunEventStore eventStore; + private final RequestUsageService usageService; + private final TerminalScopeReader scopeReader; + private final EntityManager entityManager; + + public RunTerminalService(JdbcTemplate jdbc, TransactionTemplate tx, ObjectMapper objectMapper, + RunEventStore eventStore, RequestUsageService usageService, + EntityManager entityManager) { + this.jdbc = jdbc; + this.tx = tx; + this.objectMapper = objectMapper; + this.eventStore = eventStore; + this.usageService = usageService; + this.scopeReader = new TerminalScopeReader(jdbc, objectMapper); + this.entityManager = entityManager; + } + + // ------------------------------------------------------------------ + // 진입점 + // ------------------------------------------------------------------ + + /** Brain stream terminal. activity event는 {@link RunEventStore#appendActivity}로 보낸다. */ + public TerminalSettlement settle(SourceEvent event) { + if (!event.isTerminal()) { + throw new IllegalArgumentException("activity events are appended by RunEventStore"); + } + BrainTerminal terminal = new BrainTerminal(event.runId(), event.attempt(), event.fenceToken(), + event.sourceEventId(), event.type() == SourceEventType.RUN_COMPLETED, event.payload(), + scope -> TerminalAcceptance.accept(event, scope)); + return settleBrain(terminal); + } + + /** status 조회로 회수한 저장 terminal. {@code run.canceled}는 취소 경로(1.84)가 확정하므로 REJECTED다. */ + public TerminalSettlement settle(StoredTerminal stored) { + if (stored.type() == StoredTerminalType.RUN_CANCELED) { + return TerminalSettlement.rejected(parseRunId(stored.runId()).orElse(null), new TerminalRejection( + TerminalRejectionReason.NOT_PUBLICATION_TERMINAL, "type", + "run.canceled is settled by the cancellation path")); + } + BrainTerminal terminal = new BrainTerminal(stored.runId(), stored.attempt(), stored.fenceToken(), + stored.sourceEventId(), stored.type() == StoredTerminalType.RUN_COMPLETED, stored.payload(), + scope -> TerminalAcceptance.accept(stored, scope)); + return settleBrain(terminal); + } + + /** + * Brain terminal 없이 Server가 run을 최종 실패로 끝낸다(공개 message는 code별 고정 문구, 보상 포함). + * 현재 attempt·fence일 때만 이긴다 — PENDING(attempt 0)은 {@code fenceToken = null}로 부른다. + * + * @param code invalid_attachment는 Brain만 판정하므로 쓸 수 없다 + */ + public TerminalSettlement failByServer(UUID runId, int attempt, String fenceToken, RunFailureCode code, + boolean recoverable) { + Objects.requireNonNull(runId, "runId"); + Objects.requireNonNull(code, "code"); + if (code == RunFailureCode.INVALID_ATTACHMENT) { + throw new IllegalArgumentException("invalid_attachment is a Brain verdict"); + } + return inTransactions(() -> { + Optional locked = lock(runId); + if (locked.isEmpty()) { + return TerminalSettlement.of(Outcome.UNKNOWN_RUN, runId, null); + } + RunRow run = locked.get(); + if (run.isServerFailure(code) && run.attempt() == attempt && Objects.equals(run.fenceToken(), fenceToken)) { + // 같은 Server 실패가 이미 commit됐다(연결 실패 뒤 재시도·중복 호출) + return TerminalSettlement.duplicate(runId, run.status(), run.lastEventSeq()); + } + if (run.status().isTerminal() || run.status() == AiRunStatus.CANCELING) { + return TerminalSettlement.of(Outcome.NOT_ACCEPTING, runId, run.status()); + } + if (run.attempt() != attempt || !Objects.equals(run.fenceToken(), fenceToken)) { + return TerminalSettlement.of(Outcome.STALE_FENCE, runId, run.status()); + } + Instant now = now(); + Failure failure = new Failure(code, PublicWording.failureClass(code), null, + PublicWording.failureMessage(code, null), recoverable); + return writeFailure(run, failure, null, null, now, new Guard( + "attempt = ? AND fence_token IS NOT DISTINCT FROM ? AND status IN ('PENDING', 'RUNNING', 'RETRYING')", + attempt, fenceToken)); + }); + } + + /** + * 요청 deadline이 지난 진행 중 run을 {@code timeout} 최종 실패로 끝낸다(보상 포함). 완료 terminal과 경합하면 run 잠금으로 + * 직렬화되어 먼저 commit한 하나만 남는다. CANCELING은 취소 경로(1.84)가 CANCELED로 끝낸다. + */ + public TerminalSettlement expireDeadline(UUID runId) { + Objects.requireNonNull(runId, "runId"); + return inTransactions(() -> { + Optional locked = lock(runId); + if (locked.isEmpty()) { + return TerminalSettlement.of(Outcome.UNKNOWN_RUN, runId, null); + } + RunRow run = locked.get(); + if (run.isServerFailure(RunFailureCode.TIMEOUT)) { + return TerminalSettlement.duplicate(runId, run.status(), run.lastEventSeq()); + } + if (run.status().isTerminal() || run.status() == AiRunStatus.CANCELING) { + return TerminalSettlement.of(Outcome.NOT_ACCEPTING, runId, run.status()); + } + Instant now = now(); + if (run.deadline().isAfter(now)) { + return TerminalSettlement.of(Outcome.DEADLINE_NOT_REACHED, runId, run.status()); + } + RunFailureCode code = RunFailureCode.TIMEOUT; + Failure failure = new Failure(code, PublicWording.failureClass(code), null, + PublicWording.failureMessage(code, null), true); + return writeFailure(run, failure, null, null, now, new Guard( + "status IN ('PENDING', 'RUNNING', 'RETRYING') AND request_deadline_at <= ?", time(now))); + }); + } + + // ------------------------------------------------------------------ + // Brain terminal + // ------------------------------------------------------------------ + + private record BrainTerminal(String wireRunId, int attempt, String fenceToken, String sourceEventId, + boolean completed, JsonNode payload, + Function accept) { + } + + private TerminalSettlement settleBrain(BrainTerminal terminal) { + Optional parsed = parseRunId(terminal.wireRunId()); + if (parsed.isEmpty()) { + return TerminalSettlement.of(Outcome.UNKNOWN_RUN, null, null); + } + UUID runId = parsed.get(); + Source source = new Source(SourceActivities.storedSourceEventId(terminal.sourceEventId()), + sourceDigest(terminal)); + return inTransactions(() -> { + Optional locked = lock(runId); + if (locked.isEmpty()) { + return TerminalSettlement.of(Outcome.UNKNOWN_RUN, runId, null); + } + RunRow run = locked.get(); + if (run.status().isTerminal()) { + return redelivery(run, source); + } + if (run.attempt() != terminal.attempt() || !terminal.fenceToken().equals(run.fenceToken())) { + return TerminalSettlement.of(Outcome.STALE_FENCE, runId, run.status()); + } + if (run.status() != AiRunStatus.RUNNING && run.status() != AiRunStatus.RETRYING) { + // PENDING(아직 fence 없음)은 위에서 STALE_FENCE다. 여기는 CANCELING: 늦은 결과를 막는다. + return TerminalSettlement.of(Outcome.NOT_ACCEPTING, runId, run.status()); + } + Instant now = now(); + if (!run.deadline().isAfter(now)) { + return TerminalSettlement.of(Outcome.DEADLINE_PASSED, runId, run.status()); + } + Scope scope; + AcceptedTerminal accepted; + try { + scope = scopeReader.read(runId, terminal.wireRunId(), run.attempt(), run.fenceToken(), run.userId(), + run.noteId(), run.requestSnapshot(), run.policySnapshot()); + accepted = terminal.accept().apply(scope.acceptance()); + } catch (ScopeNotReady e) { + log.warn("terminal scope is not ready: runId={}, cause={}", runId, e.getMessage()); + return TerminalSettlement.of(Outcome.SCOPE_NOT_READY, runId, run.status()); + } catch (TerminalRejection e) { + log.warn("terminal rejected: runId={}, reason={}, field={}", runId, e.reason(), e.field()); + return TerminalSettlement.rejected(runId, e); + } + Guard guard = new Guard("attempt = ? AND fence_token = ? AND status IN ('RUNNING', 'RETRYING') " + + "AND request_deadline_at > ?", run.attempt(), run.fenceToken(), time(now)); + String eventSourceId = sourceIdAvailable(runId, source.key()) ? terminal.sourceEventId() : null; + if (accepted instanceof AcceptedCompletion completion) { + try { + recheck(run, completion, scope); + } catch (TerminalRejection e) { + log.warn("terminal recheck failed: runId={}, reason={}, field={}", runId, e.reason(), e.field()); + return TerminalSettlement.rejected(runId, e); + } + return writeCompletion(run, completion, source, eventSourceId, now, guard); + } + AcceptedFailure failed = (AcceptedFailure) accepted; + return writeFailure(run, new Failure(failed.code(), failed.failureClass(), failed.reason(), + failed.message(), failed.recoverable()), source, eventSourceId, now, guard); + }); + } + + /** + * 이미 terminal인 run에 다시 온 Brain terminal. 같은 source_event_id일 때만 같은 terminal로 본다 — 다른 attempt의 같은 + * 고정 문구 실패처럼 내용이 같아도 source가 다르면 다른 terminal이다(NOT_ACCEPTING). 확정 source는 run 행에 있어 + * event가 source ID 없이 저장된 경우에도 판정한다. + */ + private TerminalSettlement redelivery(RunRow run, Source source) { + boolean sameSource = source.key().equals(run.terminalSourceEventId()); + if (sameSource && source.digest().equals(run.terminalSourceSha256())) { + return TerminalSettlement.duplicate(run.runId(), run.status(), run.lastEventSeq()); + } + if (sameSource) { + log.warn("terminal redelivered with a different outcome: runId={}", run.runId()); + return TerminalSettlement.of(Outcome.CONFLICTING_DUPLICATE, run.runId(), run.status()); + } + return TerminalSettlement.of(Outcome.NOT_ACCEPTING, run.runId(), run.status()); + } + + /** + * 1.81 {@link TerminalRecheck} 재확인. scope는 Note·run 잠금 뒤 같은 transaction에서 만들었다(asset {@code FOR SHARE}, + * solution은 Note 잠금 아래 같은 Note에서만 조회). 그래서 cited asset·참조 solution 재확인은 그 자체로 충족되고, 아래 + * cited asset 비교는 이미 읽은 값의 방어적 확인일 뿐(추가 쿼리 없음)이다. 실제로 새로 잠그는 것은 정정 대상 + * ({@code FOR UPDATE}로 ACTIVE 확인 — 동시 정정은 하나만 이긴다)뿐이다. + */ + private void recheck(RunRow run, AcceptedCompletion completion, Scope scope) { + TerminalRecheck recheck = completion.recheck(); + for (TerminalRecheck.CitedAsset cited : recheck.citedAssets()) { + LockedAsset asset = scope.lockedAssets().get(cited.noteAssetId()); + if (asset == null || asset.noteId() != run.noteId() + || asset.status() != AssetStatus.ACTIVE + || asset.contentVersion() != cited.contentVersion()) { + throw new TerminalRejection(TerminalRejectionReason.SOURCE_SCOPE, "payload", + "cited asset changed before the terminal transaction"); + } + } + for (UUID target : recheck.revisedSolutions()) { + List status = jdbc.queryForList( + "SELECT status FROM verified_solutions WHERE solution_id = ? AND note_id = ? FOR UPDATE", + String.class, target, run.noteId()); + if (!status.equals(List.of("ACTIVE"))) { + throw new TerminalRejection(TerminalRejectionReason.REVISION, "payload.verified_solutions", + "revision target is no longer active"); + } + } + } + + // ------------------------------------------------------------------ + // 쓰기 (조건부 전환이 1 row일 때만) + // ------------------------------------------------------------------ + + private record Guard(String sql, Object... args) { + } + + /** Brain terminal의 확정 식별: 저장 source_event_id 키와 {type, attempt, fence_token, payload} digest. */ + private record Source(String key, String digest) { + } + + private record Failure(RunFailureCode code, RunFailureClass failureClass, AttachmentFailureReason reason, + String message, boolean recoverable) { + } + + private TerminalSettlement writeCompletion(RunRow run, AcceptedCompletion completion, Source source, + String eventSourceId, Instant now, Guard guard) { + JsonNode pages = completion.responsePages(); + int events = pages.size() * 2 + 1; + List args = Arrays.asList(completion.disposition().wireValue(), + completion.clarificationReason() == null ? null : completion.clarificationReason().wireValue(), + time(now), source.key(), source.digest(), events); + long lastSeq = transition(run, "status = 'COMPLETED', response_disposition = ?, clarification_reason = ?, " + + RELEASE, args, guard); + + int message = jdbc.update(""" + UPDATE chat_messages SET status = 'COMPLETED', response_pages = CAST(? AS jsonb), + source_references = CAST(? AS jsonb), plain_text = ?, content_sha256 = ?, + verification_summary = CAST(? AS jsonb), completed_at = ? + WHERE message_id = ? AND note_id = ? AND role = 'ASSISTANT' AND status IN ('PENDING', 'STREAMING') + """, json(pages), json(completion.sourceReferences()), completion.plainText(), + completion.contentSha256(), json(completion.verificationSummary()), time(now), + run.assistantMessageId(), run.noteId()); + requireOne(message, "assistant message is not an open placeholder", run); + + for (VerifiedSolutionInput solution : completion.verifiedSolutions()) { + insertSolution(run, solution, now); + } + + eventStore.appendTerminalEvents(run.runId(), lastSeq, completedEvents(run, completion, eventSourceId), now); + UsageCompensationResult compensation = usageService.settle(run.userId(), run.requestId(), + RunTerminalOutcome.completed(completion.disposition())); + log.info("run completed: runId={}, disposition={}, solutions={}, compensation={}", run.runId(), + completion.disposition(), completion.verifiedSolutions().size(), compensation.outcome()); + return TerminalSettlement.committed(run.runId(), AiRunStatus.COMPLETED, lastSeq, compensation); + } + + /** @param source Brain terminal이면 확정 source, Server 실패면 null */ + private TerminalSettlement writeFailure(RunRow run, Failure failure, Source source, String eventSourceId, + Instant now, Guard guard) { + List args = Arrays.asList(failure.code().wireValue(), failure.failureClass().wireValue(), time(now), + source == null ? null : source.key(), source == null ? null : source.digest(), 1); + long lastSeq = transition(run, "status = 'FAILED', failure_code = ?, failure_class = ?, " + RELEASE, args, + guard); + + int message = jdbc.update(""" + UPDATE chat_messages SET status = 'FAILED', completed_at = ? + WHERE message_id = ? AND note_id = ? AND role = 'ASSISTANT' AND status IN ('PENDING', 'STREAMING') + """, time(now), run.assistantMessageId(), run.noteId()); + requireOne(message, "assistant message is not an open placeholder", run); + + ObjectNode payload = objectMapper.createObjectNode(); + payload.put("code", failure.code().wireValue()); + if (failure.reason() != null) { + payload.put("reason", failure.reason().wireValue()); + } + payload.put("message", failure.message()); + payload.put("recoverable", failure.recoverable()); + payload.put("assistantMessageId", run.assistantMessageId().toString()); + TerminalRunEvent event = eventSourceId == null ? TerminalRunEvent.runFailed(payload) + : TerminalRunEvent.runFailed(payload, eventSourceId, run.attempt()); + eventStore.appendTerminalEvents(run.runId(), lastSeq, List.of(event), now); + + UsageCompensationResult compensation = usageService.settle(run.userId(), run.requestId(), + RunTerminalOutcome.failed(failure.failureClass())); + log.info("run failed: runId={}, code={}, compensation={}", run.runId(), failure.code().wireValue(), + compensation.outcome()); + return TerminalSettlement.committed(run.runId(), AiRunStatus.FAILED, lastSeq, compensation); + } + + /** 조건부 terminal 전환. 잠금 뒤 판정과 같은 조건이라 0 row면 불변조건 위반이다(rollback). */ + private long transition(RunRow run, String set, List setArgs, Guard guard) { + List args = new ArrayList<>(setArgs); + args.add(run.runId()); + args.addAll(Arrays.asList(guard.args())); + List returned = jdbc.queryForList("UPDATE ai_runs SET " + set + " WHERE run_id = ? AND " + guard.sql() + + " RETURNING last_event_seq", Long.class, args.toArray()); + if (returned.size() != 1) { + throw new IllegalStateException("terminal transition lost its guard for run " + run.runId()); + } + return returned.get(0); + } + + private void insertSolution(RunRow run, VerifiedSolutionInput solution, Instant now) { + UUID solutionId = UUID.randomUUID(); + jdbc.update(""" + INSERT INTO verified_solutions (solution_id, public_solution_id, note_id, source_run_id, + assistant_message_id, schema_version, solution_version, problem_statement, problem_conditions, + solution_text, claims, step_marks, verification_summary, verification_version, source_ref_ids, + overall_coverage, content_sha256, revises_solution_id, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS jsonb), ?, CAST(? AS jsonb), CAST(? AS jsonb), + CAST(? AS jsonb), ?, CAST(? AS jsonb), ?, ?, ?, ?) + """, solutionId, solution.publicSolutionId(), run.noteId(), run.runId(), run.assistantMessageId(), + solution.schemaVersion(), solution.solutionVersion(), solution.problemStatement(), + json(solution.problemConditions()), solution.solutionText(), json(solution.claims()), + json(solution.stepMarks()), json(solution.verificationSummary()), solution.verificationVersion(), + json(solution.sourceRefIds()), solution.overallCoverage().wireValue(), solution.contentSha256(), + solution.revisesSolutionId(), time(now)); + if (solution.revisesSolutionId() != null) { + // 이전 row는 content를 바꾸지 않고 상태·후속 ID만 기록한다(V1 trigger가 새 row의 revises 관계를 확인한다). + int superseded = jdbc.update(""" + UPDATE verified_solutions SET status = 'SUPERSEDED', superseded_by_solution_id = ?, superseded_at = ? + WHERE solution_id = ? AND note_id = ? AND status = 'ACTIVE' + """, solutionId, time(now), solution.revisesSolutionId(), run.noteId()); + requireOne(superseded, "revision target is no longer active", run); + } + } + + /** + * page마다 started→completed, 마지막에 run.completed. payload는 browser-run-v1 frame 모양이되 run.completed는 본문 없는 + * 최소 요약이다 — replay가 canonical message로 {@code assistantResponse}·{@code verificationSummary}를 채운다 + * ({@link TerminalReplay}). + */ + private List completedEvents(RunRow run, AcceptedCompletion completion, String eventSourceId) { + String messageId = run.assistantMessageId().toString(); + List events = new ArrayList<>(); + JsonNode pages = completion.responsePages(); + for (int i = 0; i < pages.size(); i++) { + JsonNode page = pages.get(i); + ObjectNode started = objectMapper.createObjectNode(); + started.put("assistantMessageId", messageId); + started.put("responsePageId", page.get("response_page_id").asText()); + started.put("presentation", page.get("presentation").asText()); + started.put("pageIndex", i); + if (page.hasNonNull("title")) { + started.put("title", page.get("title").asText()); + } + ObjectNode completed = objectMapper.createObjectNode(); + completed.put("assistantMessageId", messageId); + completed.put("responsePageId", page.get("response_page_id").asText()); + completed.put("pageIndex", i); + events.add(TerminalRunEvent.pageStarted(started)); + events.add(TerminalRunEvent.pageCompleted(completed)); + } + ObjectNode summary = TerminalReplay.completedSummary(messageId, completion.disposition(), + completion.clarificationReason(), completion.model(), completion.tier()); + events.add(eventSourceId == null ? TerminalRunEvent.runCompleted(summary) + : TerminalRunEvent.runCompleted(summary, eventSourceId, completion.attempt())); + return events; + } + + // ------------------------------------------------------------------ + + private record RunRow(UUID runId, String requestId, long userId, long noteId, UUID assistantMessageId, + AiRunStatus status, int attempt, String fenceToken, Instant deadline, + String terminalSourceSha256, long lastEventSeq, JsonNode requestSnapshot, + JsonNode policySnapshot, String terminalSourceEventId, String failureCode) { + + /** Server가 만든(Brain source 없는) 같은 code의 실패로 이미 끝났다. */ + boolean isServerFailure(RunFailureCode code) { + return status == AiRunStatus.FAILED && terminalSourceEventId == null + && code.wireValue().equals(failureCode); + } + } + + private Optional lock(UUID runId) { + if (jdbc.queryForList(LOCK_NOTE, Long.class, runId).isEmpty()) { + return Optional.empty(); + } + return jdbc.query(LOCK_RUN, (rs, row) -> new RunRow(runId, rs.getString(1), rs.getLong(2), rs.getLong(3), + rs.getObject(4, UUID.class), AiRunStatus.valueOf(rs.getString(5)), rs.getInt(6), rs.getString(7), + rs.getObject(8, OffsetDateTime.class).toInstant(), rs.getString(9), rs.getLong(10), + readTree(rs.getString(11)), readTree(rs.getString(12)), rs.getString(13), rs.getString(14)), runId) + .stream().findFirst(); + } + + /** 같은 키가 이미 다른 event(예: Activity)에 쓰였으면 terminal은 source ID 없이 저장한다(1.80 인계). */ + private boolean sourceIdAvailable(UUID runId, String storedSourceId) { + Long used = jdbc.queryForObject("SELECT count(*) FROM ai_run_events WHERE run_id = ? AND source_event_id = ?", + Long.class, runId, storedSourceId); + if (used != null && used > 0) { + log.warn("terminal source_event_id is already used by another event; storing without it: runId={}", runId); + return false; + } + return true; + } + + private String sourceDigest(BrainTerminal terminal) { + Object payload = objectMapper.convertValue(terminal.payload(), Object.class); + return HashUtils.sha256(CanonicalJson.write(Map.of( + "type", terminal.completed() ? "run.completed" : "run.failed", "attempt", terminal.attempt(), + "fence_token", terminal.fenceToken(), "payload", payload))); + } + + /** + * 자기 transaction에서 실행한다. 호출자 transaction이 열려 있으면 거기에 참여하고(commit 뒤 공개도 그 commit을 따른다) + * 재시도하지 않는다 — rollback-only가 된 바깥 transaction 안에서 다시 시도할 수 없기 때문이다. + */ + private TerminalSettlement inTransactions(Supplier work) { + boolean outer = TransactionSynchronizationManager.isActualTransactionActive(); + if (outer) { + // 호출자가 같은 transaction에서 바꾼 AiRun 등을 JDBC 잠금·읽기 전에 내보낸다(RunEventStore 관례). + // 그러지 않으면 아래 판정이 옛 값을 읽고, 이후 flush가 옛 상태로 전체 컬럼 UPDATE를 한다. + entityManager.flush(); + } + for (int attempt = 1; ; attempt++) { + try { + return tx.execute(status -> work.get()); + } catch (RuntimeException e) { + if (outer || attempt >= MAX_TRANSACTIONS || !isRetryable(e)) { + throw e; + } + log.info("terminal transaction {} failed, retrying: cause={}", attempt, e.getClass().getSimpleName()); + } + } + } + + static boolean isRetryable(Throwable failure) { + for (Throwable cause = failure; cause != null; cause = cause.getCause()) { + if (cause instanceof SQLException sql && sql.getSQLState() != null) { + String state = sql.getSQLState(); + return RETRYABLE_SQL_STATES.contains(state) || state.startsWith("08"); + } + } + return false; + } + + private static void requireOne(int rows, String problem, RunRow run) { + if (rows != 1) { + throw new IllegalStateException(problem + " (run " + run.runId() + ")"); + } + } + + private static Optional parseRunId(String wire) { + try { + UUID id = UUID.fromString(wire); + return id.toString().equals(wire) ? Optional.of(id) : Optional.empty(); + } catch (IllegalArgumentException e) { + return Optional.empty(); + } + } + + private static Instant now() { + return Instant.now().truncatedTo(ChronoUnit.MICROS); + } + + private static OffsetDateTime time(Instant instant) { + return OffsetDateTime.ofInstant(instant, ZoneOffset.UTC); + } + + private String json(JsonNode node) { + try { + return objectMapper.writeValueAsString(node); + } catch (JsonProcessingException e) { + throw new IllegalStateException(e); + } + } + + private JsonNode readTree(String json) { + try { + return objectMapper.readTree(json); + } catch (JsonProcessingException e) { + throw new IllegalStateException("stored run snapshot is not JSON", e); + } + } +} diff --git a/src/main/java/com/proovy/domain/run/terminal/SafeMarkdown.java b/src/main/java/com/proovy/domain/run/terminal/SafeMarkdown.java new file mode 100644 index 00000000..e6a695ec --- /dev/null +++ b/src/main/java/com/proovy/domain/run/terminal/SafeMarkdown.java @@ -0,0 +1,267 @@ +package com.proovy.domain.run.terminal; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * SafeMarkdown v1 (Brain {@code contracts/safe_markdown.py})의 구조 허용 목록·source marker·plain text 규칙. + *

+ * 문자열 위치는 Java UTF-16 index로 계산하고, 공개 좌표(anchor·길이)는 Brain과 같은 Unicode code point로 바꿔 쓴다. + * 정규식은 Python {@code re}의 str 의미에 맞춘다({@code .}·{@code ^}는 {@code \n}만 줄 끝, {@code \s}·{@code \b}는 Unicode). + * 이 검사는 구조만 본다 — 수학 정확성·자연어 secret·정답 누출은 판정하지 않는다. + */ +final class SafeMarkdown { + + static final int MAX_LENGTH = 20_000; + + private static final int FLAGS = Pattern.UNIX_LINES | Pattern.UNICODE_CHARACTER_CLASS; + private static final Pattern SOURCE_MARKER = + Pattern.compile("\\[\\[source:([A-Za-z0-9][A-Za-z0-9_.:-]{0,127})\\]\\]", FLAGS); + private static final Pattern CONTROL = Pattern.compile("[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f-\\x9f]"); + private static final Pattern FENCED_CODE = Pattern.compile("(?m)^[ \\t]{0,3}(?:`{3,}|~{3,})", FLAGS); + private static final Pattern HTML = + Pattern.compile(" 이다 diff --git a/src/test/resources/contracts/brain/response-v1/markdown/rejected/html-tag-newline.md b/src/test/resources/contracts/brain/response-v1/markdown/rejected/html-tag-newline.md new file mode 100644 index 00000000..65e71e47 --- /dev/null +++ b/src/test/resources/contracts/brain/response-v1/markdown/rejected/html-tag-newline.md @@ -0,0 +1,3 @@ + diff --git a/src/test/resources/contracts/brain/response-v1/markdown/rejected/html-tag.md b/src/test/resources/contracts/brain/response-v1/markdown/rejected/html-tag.md new file mode 100644 index 00000000..cfc2ceab --- /dev/null +++ b/src/test/resources/contracts/brain/response-v1/markdown/rejected/html-tag.md @@ -0,0 +1 @@ +굵게 강조 표시 diff --git a/src/test/resources/contracts/brain/response-v1/markdown/rejected/image-embed.md b/src/test/resources/contracts/brain/response-v1/markdown/rejected/image-embed.md new file mode 100644 index 00000000..eb7a0d80 --- /dev/null +++ b/src/test/resources/contracts/brain/response-v1/markdown/rejected/image-embed.md @@ -0,0 +1 @@ +![img](https://x.test/a.png) diff --git a/src/test/resources/contracts/brain/response-v1/markdown/rejected/indented-code.md b/src/test/resources/contracts/brain/response-v1/markdown/rejected/indented-code.md new file mode 100644 index 00000000..a4606c0f --- /dev/null +++ b/src/test/resources/contracts/brain/response-v1/markdown/rejected/indented-code.md @@ -0,0 +1,5 @@ +실행 결과는 다음과 같다. + + print(42) + +끝. diff --git a/src/test/resources/contracts/brain/response-v1/markdown/rejected/inline-code.md b/src/test/resources/contracts/brain/response-v1/markdown/rejected/inline-code.md new file mode 100644 index 00000000..a59bab97 --- /dev/null +++ b/src/test/resources/contracts/brain/response-v1/markdown/rejected/inline-code.md @@ -0,0 +1 @@ +`print(1)` 결과 diff --git a/src/test/resources/contracts/brain/response-v1/markdown/rejected/javascript-link.md b/src/test/resources/contracts/brain/response-v1/markdown/rejected/javascript-link.md new file mode 100644 index 00000000..54cc00f4 --- /dev/null +++ b/src/test/resources/contracts/brain/response-v1/markdown/rejected/javascript-link.md @@ -0,0 +1 @@ +[x](javascript:alert(1)) diff --git a/src/test/resources/contracts/brain/response-v1/markdown/rejected/link-definition.md b/src/test/resources/contracts/brain/response-v1/markdown/rejected/link-definition.md new file mode 100644 index 00000000..275b2a3a --- /dev/null +++ b/src/test/resources/contracts/brain/response-v1/markdown/rejected/link-definition.md @@ -0,0 +1,2 @@ +[ref]: https://example.com +본문 [ref] diff --git a/src/test/resources/contracts/brain/response-v1/markdown/rejected/marker-in-math.md b/src/test/resources/contracts/brain/response-v1/markdown/rejected/marker-in-math.md new file mode 100644 index 00000000..595b98ac --- /dev/null +++ b/src/test/resources/contracts/brain/response-v1/markdown/rejected/marker-in-math.md @@ -0,0 +1 @@ +$x=1$ 그리고 $[[source:sref-1]]$ diff --git a/src/test/resources/contracts/brain/response-v1/markdown/rejected/stray-brackets.md b/src/test/resources/contracts/brain/response-v1/markdown/rejected/stray-brackets.md new file mode 100644 index 00000000..d588c27e --- /dev/null +++ b/src/test/resources/contracts/brain/response-v1/markdown/rejected/stray-brackets.md @@ -0,0 +1 @@ +열린 괄호 [[source: 만 있음 diff --git a/src/test/resources/contracts/brain/response-v1/markdown/rejected/unclosed-math.md b/src/test/resources/contracts/brain/response-v1/markdown/rejected/unclosed-math.md new file mode 100644 index 00000000..7a2fc10e --- /dev/null +++ b/src/test/resources/contracts/brain/response-v1/markdown/rejected/unclosed-math.md @@ -0,0 +1 @@ +답은 $x=1 diff --git a/src/test/resources/contracts/brain/response-v1/valid/bare-claim.json b/src/test/resources/contracts/brain/response-v1/valid/bare-claim.json new file mode 100644 index 00000000..03e1fae6 --- /dev/null +++ b/src/test/resources/contracts/brain/response-v1/valid/bare-claim.json @@ -0,0 +1,21 @@ +{ + "response_pages": [ + { + "response_page_id": "page-1", + "presentation": "main", + "title": "답", + "result_blocks": [ + { + "kind": "solution", + "block_id": "block-1", + "body": "답은 $-\\frac{1}{6}$이다.", + "verification": "partially_verified", + "solution_id": "solution-2", + "claim_ids": [ + "claim-1" + ] + } + ] + } + ] +} diff --git a/src/test/resources/contracts/brain/response-v1/valid/check-block.json b/src/test/resources/contracts/brain/response-v1/valid/check-block.json new file mode 100644 index 00000000..8f5153a6 --- /dev/null +++ b/src/test/resources/contracts/brain/response-v1/valid/check-block.json @@ -0,0 +1,45 @@ +{ + "response_pages": [ + { + "response_page_id": "page-1", + "presentation": "main", + "title": "검산", + "result_blocks": [ + { + "kind": "check", + "block_id": "block-1", + "body": "제출한 풀이의 아래 구간이 처음 틀린 곳이다.\n\n> $\\int x^2 dx$를 $1/x^2$로 선택\n\n정적분값은 $\\int_1^2 x^{-2}dx=1/2$이다.", + "step_marks": [ + { + "step_id": "step-1", + "status": "verified", + "anchor": { + "start": 0, + "end": 24 + } + }, + { + "step_id": "step-2", + "status": "failed", + "reason": "원시함수를 잘못 선택했다", + "anchor": { + "start": 29, + "end": 55 + } + } + ], + "first_error_step_id": "step-2", + "quoted_spans": [ + { + "anchor": { + "start": 27, + "end": 55 + }, + "origin": "user_submission" + } + ] + } + ] + } + ] +} diff --git a/src/test/resources/contracts/brain/response-v1/valid/clarification.json b/src/test/resources/contracts/brain/response-v1/valid/clarification.json new file mode 100644 index 00000000..e447f270 --- /dev/null +++ b/src/test/resources/contracts/brain/response-v1/valid/clarification.json @@ -0,0 +1,16 @@ +{ + "response_pages": [ + { + "response_page_id": "page-1", + "presentation": "main", + "result_blocks": [ + { + "kind": "rich_text", + "block_id": "block-1", + "body": "요약할 페이지 범위를 알려 주세요. 예: 3~5쪽" + } + ] + } + ], + "source_references": [] +} diff --git a/src/test/resources/contracts/brain/response-v1/valid/inconclusive-unverified.json b/src/test/resources/contracts/brain/response-v1/valid/inconclusive-unverified.json new file mode 100644 index 00000000..349bce36 --- /dev/null +++ b/src/test/resources/contracts/brain/response-v1/valid/inconclusive-unverified.json @@ -0,0 +1,40 @@ +{ + "response_pages": [ + { + "response_page_id": "page-1", + "presentation": "main", + "result_blocks": [ + { + "kind": "solution", + "block_id": "block-1", + "body": "경우를 나누면 $x>0$일 때 성립한다. 남은 연결은 확인되지 않았다.", + "verification": "unverified", + "step_marks": [ + { + "step_id": "step-1", + "status": "verified", + "anchor": { + "start": 0, + "end": 21 + } + }, + { + "step_id": "step-2", + "status": "unconfirmed", + "reason": "논리 연결이 도구로 확인되지 않았다", + "anchor": { + "start": 23, + "end": 38 + } + }, + { + "step_id": "step-3", + "status": "unconfirmed", + "reason": "검사가 생성되지 않았다" + } + ] + } + ] + } + ] +} diff --git a/src/test/resources/contracts/brain/response-v1/valid/verified-solution.json b/src/test/resources/contracts/brain/response-v1/valid/verified-solution.json new file mode 100644 index 00000000..ce2a26e3 --- /dev/null +++ b/src/test/resources/contracts/brain/response-v1/valid/verified-solution.json @@ -0,0 +1,80 @@ +{ + "schema_version": "assistant-response-v1", + "response_pages": [ + { + "response_page_id": "page-1", + "presentation": "main", + "title": "풀이", + "result_blocks": [ + { + "kind": "solution", + "block_id": "block-1", + "body": "양변에 1을 더하면 $x+1=3$이므로 $x=2$를 얻는다 [[source:sref-1]]\n\n따라서 답은 $x=2$이다.", + "verification": "verified", + "solution_id": "solution-1", + "claim_ids": [ + "claim-1" + ], + "step_marks": [ + { + "step_id": "step-1", + "status": "verified", + "anchor": { + "start": 0, + "end": 32 + } + }, + { + "step_id": "step-2", + "status": "verified", + "anchor": { + "start": 52, + "end": 66 + } + } + ] + }, + { + "kind": "explanation", + "block_id": "block-2", + "body": "양변 정리만으로 답에 도달하는 풀이다.", + "solution_id": "solution-1", + "claim_ids": [ + "claim-1" + ] + }, + { + "kind": "notice", + "block_id": "block-3", + "body": "관련 연습문제 생성은 아직 지원하지 않는다." + } + ] + }, + { + "response_page_id": "page-2", + "presentation": "thread_like", + "title": "원문 근거", + "result_blocks": [ + { + "kind": "rich_text", + "block_id": "block-4", + "body": "원문 근거는 다음 구간에 있다 [[source:sref-2]]" + } + ] + } + ], + "source_references": [ + { + "source_ref_id": "sref-1", + "asset_id": "asset-9f8c", + "page_start": 3, + "page_end": 5 + }, + { + "source_ref_id": "sref-2", + "asset_id": "asset-9f8c", + "page_start": 7, + "page_end": 7 + } + ] +} diff --git a/src/test/resources/contracts/brain/terminal-v2/browser.schema.json b/src/test/resources/contracts/brain/terminal-v2/browser.schema.json new file mode 100644 index 00000000..48b04f7f --- /dev/null +++ b/src/test/resources/contracts/brain/terminal-v2/browser.schema.json @@ -0,0 +1,2750 @@ +{ + "$defs": { + "ActivityCanceledEvent": { + "additionalProperties": true, + "properties": { + "contractVersion": { + "const": "brain-source-v1", + "default": "brain-source-v1", + "title": "Contract Version", + "type": "string" + }, + "runId": { + "maxLength": 128, + "minLength": 1, + "title": "Run Id", + "type": "string" + }, + "occurredAt": { + "format": "date-time", + "title": "Occurred At", + "type": "string" + }, + "type": { + "const": "activity.canceled", + "default": "activity.canceled", + "title": "Type", + "type": "string" + }, + "payload": { + "oneOf": [ + { + "$ref": "#/$defs/InputAnalysisCanceled" + }, + { + "$ref": "#/$defs/SolutionGenerationCanceled" + }, + { + "$ref": "#/$defs/AssetUnderstandingCanceled" + }, + { + "$ref": "#/$defs/NoteRetrievalCanceled" + }, + { + "$ref": "#/$defs/CodeExecutionCanceled" + }, + { + "$ref": "#/$defs/VerificationCanceled" + } + ], + "title": "Payload" + }, + "seq": { + "type": "integer", + "minimum": 1 + } + }, + "required": [ + "runId", + "occurredAt", + "payload", + "type", + "seq", + "contractVersion" + ], + "title": "ActivityCanceledEvent", + "type": "object" + }, + "ActivityCompletedEvent": { + "additionalProperties": true, + "properties": { + "contractVersion": { + "const": "brain-source-v1", + "default": "brain-source-v1", + "title": "Contract Version", + "type": "string" + }, + "runId": { + "maxLength": 128, + "minLength": 1, + "title": "Run Id", + "type": "string" + }, + "occurredAt": { + "format": "date-time", + "title": "Occurred At", + "type": "string" + }, + "type": { + "const": "activity.completed", + "default": "activity.completed", + "title": "Type", + "type": "string" + }, + "payload": { + "oneOf": [ + { + "$ref": "#/$defs/InputAnalysisCompleted" + }, + { + "$ref": "#/$defs/SolutionGenerationCompleted" + }, + { + "$ref": "#/$defs/AssetUnderstandingCompleted" + }, + { + "$ref": "#/$defs/NoteRetrievalCompleted" + }, + { + "$ref": "#/$defs/CodeExecutionCompleted" + }, + { + "$ref": "#/$defs/VerificationCompleted" + } + ], + "title": "Payload" + }, + "seq": { + "type": "integer", + "minimum": 1 + } + }, + "required": [ + "runId", + "occurredAt", + "payload", + "type", + "seq", + "contractVersion" + ], + "title": "ActivityCompletedEvent", + "type": "object" + }, + "ActivityFailedEvent": { + "additionalProperties": true, + "properties": { + "contractVersion": { + "const": "brain-source-v1", + "default": "brain-source-v1", + "title": "Contract Version", + "type": "string" + }, + "runId": { + "maxLength": 128, + "minLength": 1, + "title": "Run Id", + "type": "string" + }, + "occurredAt": { + "format": "date-time", + "title": "Occurred At", + "type": "string" + }, + "type": { + "const": "activity.failed", + "default": "activity.failed", + "title": "Type", + "type": "string" + }, + "payload": { + "oneOf": [ + { + "$ref": "#/$defs/InputAnalysisFailed" + }, + { + "$ref": "#/$defs/SolutionGenerationFailed" + }, + { + "$ref": "#/$defs/AssetUnderstandingFailed" + }, + { + "$ref": "#/$defs/NoteRetrievalFailed" + }, + { + "$ref": "#/$defs/CodeExecutionFailed" + }, + { + "$ref": "#/$defs/VerificationFailed" + } + ], + "title": "Payload" + }, + "seq": { + "type": "integer", + "minimum": 1 + } + }, + "required": [ + "runId", + "occurredAt", + "payload", + "type", + "seq", + "contractVersion" + ], + "title": "ActivityFailedEvent", + "type": "object" + }, + "ActivityFailureCode": { + "description": "The allowlisted technical failure codes an ``activity.failed`` may\nreport. Normal judgments (no match, unreadable pages, missing scope)\nare completions, never failures.", + "enum": [ + "provider_error", + "timeout", + "storage_error", + "internal_error" + ], + "title": "ActivityFailureCode", + "type": "string" + }, + "ActivityStartedEvent": { + "additionalProperties": true, + "properties": { + "contractVersion": { + "const": "brain-source-v1", + "default": "brain-source-v1", + "title": "Contract Version", + "type": "string" + }, + "runId": { + "maxLength": 128, + "minLength": 1, + "title": "Run Id", + "type": "string" + }, + "occurredAt": { + "format": "date-time", + "title": "Occurred At", + "type": "string" + }, + "type": { + "const": "activity.started", + "default": "activity.started", + "title": "Type", + "type": "string" + }, + "payload": { + "oneOf": [ + { + "$ref": "#/$defs/InputAnalysisStarted" + }, + { + "$ref": "#/$defs/SolutionGenerationStarted" + }, + { + "$ref": "#/$defs/AssetUnderstandingStarted" + }, + { + "$ref": "#/$defs/NoteRetrievalStarted" + }, + { + "$ref": "#/$defs/CodeExecutionStarted" + }, + { + "$ref": "#/$defs/VerificationStarted" + } + ], + "title": "Payload" + }, + "seq": { + "type": "integer", + "minimum": 1 + } + }, + "required": [ + "runId", + "occurredAt", + "payload", + "type", + "seq", + "contractVersion" + ], + "title": "ActivityStartedEvent", + "type": "object" + }, + "ActivityStatus": { + "description": "The allowlisted sub-state of a still-running Activity.\n\nDistinct from the terminal outcome: an ``updated`` event may report the\nwork is ``in_progress``, ``waiting`` on a resource, or visibly\n``retrying`` after a semantic (non-transport) retry. Transient transport\nredelivery is not a status — it reuses the same event ID instead.", + "enum": [ + "in_progress", + "waiting", + "retrying" + ], + "title": "ActivityStatus", + "type": "string" + }, + "ActivityUpdatedEvent": { + "additionalProperties": true, + "properties": { + "contractVersion": { + "const": "brain-source-v1", + "default": "brain-source-v1", + "title": "Contract Version", + "type": "string" + }, + "runId": { + "maxLength": 128, + "minLength": 1, + "title": "Run Id", + "type": "string" + }, + "occurredAt": { + "format": "date-time", + "title": "Occurred At", + "type": "string" + }, + "type": { + "const": "activity.updated", + "default": "activity.updated", + "title": "Type", + "type": "string" + }, + "payload": { + "oneOf": [ + { + "$ref": "#/$defs/InputAnalysisUpdated" + }, + { + "$ref": "#/$defs/SolutionGenerationUpdated" + }, + { + "$ref": "#/$defs/AssetUnderstandingUpdated" + }, + { + "$ref": "#/$defs/NoteRetrievalUpdated" + }, + { + "$ref": "#/$defs/CodeExecutionUpdated" + }, + { + "$ref": "#/$defs/VerificationUpdated" + } + ], + "title": "Payload" + }, + "seq": { + "type": "integer", + "minimum": 1 + } + }, + "required": [ + "runId", + "occurredAt", + "payload", + "type", + "seq", + "contractVersion" + ], + "title": "ActivityUpdatedEvent", + "type": "object" + }, + "AssetUnderstandingCanceled": { + "additionalProperties": true, + "properties": { + "activityId": { + "maxLength": 128, + "minLength": 1, + "title": "Activity Id", + "type": "string" + }, + "message": { + "anyOf": [ + { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "kind": { + "const": "asset_understanding", + "default": "asset_understanding", + "title": "Kind", + "type": "string" + } + }, + "required": [ + "activityId", + "kind" + ], + "title": "AssetUnderstandingCanceled", + "type": "object" + }, + "AssetUnderstandingCompleted": { + "additionalProperties": true, + "description": "Completes with the final measured counts and an optional normal\njudgment. ``scope_required``/``unreadable_pages`` are completions —\ntechnical failures are ``failed`` events instead.", + "properties": { + "activityId": { + "maxLength": 128, + "minLength": 1, + "title": "Activity Id", + "type": "string" + }, + "message": { + "anyOf": [ + { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "kind": { + "const": "asset_understanding", + "default": "asset_understanding", + "title": "Kind", + "type": "string" + }, + "processedPages": { + "minimum": 0, + "title": "Processed Pages", + "type": "integer" + }, + "totalPages": { + "minimum": 1, + "title": "Total Pages", + "type": "integer" + }, + "outcome": { + "anyOf": [ + { + "$ref": "#/$defs/AssetUnderstandingOutcome" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "activityId", + "processedPages", + "totalPages", + "kind" + ], + "title": "AssetUnderstandingCompleted", + "type": "object" + }, + "AssetUnderstandingFailed": { + "additionalProperties": true, + "properties": { + "activityId": { + "maxLength": 128, + "minLength": 1, + "title": "Activity Id", + "type": "string" + }, + "code": { + "$ref": "#/$defs/ActivityFailureCode" + }, + "message": { + "anyOf": [ + { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "kind": { + "const": "asset_understanding", + "default": "asset_understanding", + "title": "Kind", + "type": "string" + } + }, + "required": [ + "activityId", + "code", + "kind" + ], + "title": "AssetUnderstandingFailed", + "type": "object" + }, + "AssetUnderstandingLabel": { + "enum": [ + "reading_pages", + "summarizing_pages" + ], + "title": "AssetUnderstandingLabel", + "type": "string" + }, + "AssetUnderstandingOutcome": { + "description": "Normal (non-failure) completion judgments of asset understanding.", + "enum": [ + "scope_required", + "unreadable_pages" + ], + "title": "AssetUnderstandingOutcome", + "type": "string" + }, + "AssetUnderstandingReason": { + "description": "Why an asset_understanding Activity runs (plan/20-21 allowlist).", + "enum": [ + "viewer_focus", + "turn_attachment", + "retrieval_candidates", + "page_range_summary" + ], + "title": "AssetUnderstandingReason", + "type": "string" + }, + "AssetUnderstandingStarted": { + "additionalProperties": true, + "description": "Starts one measured pass over declared targets. ``reason`` and\n``targets`` define the attempt: a different reason or page set is a\ndifferent Activity, not an update of this one.", + "properties": { + "activityId": { + "maxLength": 128, + "minLength": 1, + "title": "Activity Id", + "type": "string" + }, + "responsePageId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Response Page Id" + }, + "kind": { + "const": "asset_understanding", + "default": "asset_understanding", + "title": "Kind", + "type": "string" + }, + "label": { + "$ref": "#/$defs/AssetUnderstandingLabel" + }, + "reason": { + "$ref": "#/$defs/AssetUnderstandingReason" + }, + "targets": { + "items": { + "$ref": "#/$defs/PageTarget" + }, + "maxItems": 50, + "minItems": 1, + "title": "Targets", + "type": "array" + } + }, + "required": [ + "activityId", + "label", + "reason", + "targets", + "kind" + ], + "title": "AssetUnderstandingStarted", + "type": "object" + }, + "AssetUnderstandingUpdated": { + "additionalProperties": true, + "properties": { + "activityId": { + "maxLength": 128, + "minLength": 1, + "title": "Activity Id", + "type": "string" + }, + "status": { + "anyOf": [ + { + "$ref": "#/$defs/ActivityStatus" + }, + { + "type": "null" + } + ], + "default": null + }, + "message": { + "anyOf": [ + { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "kind": { + "const": "asset_understanding", + "default": "asset_understanding", + "title": "Kind", + "type": "string" + }, + "processedPages": { + "minimum": 0, + "title": "Processed Pages", + "type": "integer" + }, + "totalPages": { + "minimum": 1, + "title": "Total Pages", + "type": "integer" + } + }, + "required": [ + "activityId", + "processedPages", + "totalPages", + "kind" + ], + "title": "AssetUnderstandingUpdated", + "type": "object" + }, + "AssistantResponseV1": { + "additionalProperties": true, + "description": "A completed public answer: ordered 1-depth pages plus a source catalog.\n\nThe catalog lists every source reference inline markers may cite;\na marker naming a missing entry is a dangling reference and rejected,\nand page ranges of the same asset may not overlap. ``Candidate``\ncontent, verification internals, and Server-derived fields\n(``display_label_snapshot``, ``available``, ``plain_text``) are not\nrepresentable here.", + "properties": { + "responsePages": { + "items": { + "$ref": "#/$defs/ResponsePage" + }, + "maxItems": 20, + "minItems": 1, + "title": "Response Pages", + "type": "array" + }, + "sourceReferences": { + "default": [], + "items": { + "$ref": "#/$defs/SourceReference" + }, + "maxItems": 200, + "title": "Source References", + "type": "array" + }, + "plainText": { + "type": "string" + } + }, + "required": [ + "responsePages", + "plainText" + ], + "title": "AssistantResponseV1", + "type": "object" + }, + "AttachmentFailureReason": { + "description": "The public sub-code an ``invalid_attachment`` failure carries.\n\nThe literal set mirrors\n:class:`~proovy_brain.features.multimodal_input.validation.AttachmentRejectionReason`\nexactly — the same relationship ``RunCanceledPayload.cause`` keeps\nwith ``CancelCause`` — so every safe file-inspection refusal owns one\nwire spelling and one fixed message. The wire enum is its own\ndeclaration, not an import: an internal rename or a new rejection\nreason can never silently change the published vocabulary; extending\nit is a deliberate, fixture-versioned contract change.", + "enum": [ + "unsupported_media_type", + "signature_mismatch", + "file_too_large", + "image_decode_failed", + "abnormal_dimensions", + "pixel_limit_exceeded", + "pdf_encrypted", + "pdf_corrupt", + "too_many_pages", + "page_out_of_range", + "bundle_count_exceeded", + "bundle_bytes_exceeded", + "bundle_empty" + ], + "title": "AttachmentFailureReason", + "type": "string" + }, + "CheckBlock": { + "additionalProperties": true, + "description": "The check of a user-submitted solution.\n\n``step_marks`` carry the per-step verdict on the user's work —\n``failed`` marks are legitimate here — and ``first_error_step_id``\nhighlights the earliest failed step. ``quoted_spans`` mark which body\nregions quote the user's submission; Brain-generated candidate text may\nnever be quoted.", + "properties": { + "blockId": { + "maxLength": 128, + "minLength": 1, + "title": "Block Id", + "type": "string" + }, + "body": { + "maxLength": 20000, + "minLength": 1, + "title": "Body", + "type": "string" + }, + "kind": { + "const": "check", + "default": "check", + "title": "Kind", + "type": "string" + }, + "stepMarks": { + "default": [], + "items": { + "$ref": "#/$defs/StepMark" + }, + "maxItems": 200, + "title": "Step Marks", + "type": "array" + }, + "firstErrorStepId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "First Error Step Id" + }, + "quotedSpans": { + "default": [], + "items": { + "$ref": "#/$defs/QuotedSpan" + }, + "maxItems": 100, + "title": "Quoted Spans", + "type": "array" + } + }, + "required": [ + "blockId", + "body", + "kind" + ], + "title": "CheckBlock", + "type": "object" + }, + "ClaimVerificationSummary": { + "additionalProperties": true, + "description": "One claim's public coverage; never the internal report itself.", + "properties": { + "claimId": { + "maxLength": 128, + "minLength": 1, + "title": "Claim Id", + "type": "string" + }, + "coverage": { + "$ref": "#/$defs/SummaryCoverage" + }, + "methodLabel": { + "maxLength": 200, + "minLength": 1, + "title": "Method Label", + "type": "string" + }, + "unconfirmedSteps": { + "default": [], + "items": { + "$ref": "#/$defs/UnconfirmedStepSummary" + }, + "maxItems": 200, + "title": "Unconfirmed Steps", + "type": "array" + } + }, + "required": [ + "claimId", + "coverage", + "methodLabel" + ], + "title": "ClaimVerificationSummary", + "type": "object" + }, + "ClarificationReason": { + "description": "The allowlisted reasons a clarification-only response may carry.", + "enum": [ + "missing_input", + "ambiguous_target", + "limited_pages", + "corpus_not_ready", + "page_range_required", + "page_range_unreadable" + ], + "title": "ClarificationReason", + "type": "string" + }, + "CodeExecutionCanceled": { + "additionalProperties": true, + "properties": { + "activityId": { + "maxLength": 128, + "minLength": 1, + "title": "Activity Id", + "type": "string" + }, + "message": { + "anyOf": [ + { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "kind": { + "const": "code_execution", + "default": "code_execution", + "title": "Kind", + "type": "string" + }, + "methodLabel": { + "maxLength": 200, + "minLength": 1, + "title": "Method Label", + "type": "string" + } + }, + "required": [ + "activityId", + "methodLabel", + "kind" + ], + "title": "CodeExecutionCanceled", + "type": "object" + }, + "CodeExecutionCompleted": { + "additionalProperties": true, + "description": "A clean exit is an execution result only — never a claim that any\nmathematics was verified.", + "properties": { + "activityId": { + "maxLength": 128, + "minLength": 1, + "title": "Activity Id", + "type": "string" + }, + "message": { + "anyOf": [ + { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "kind": { + "const": "code_execution", + "default": "code_execution", + "title": "Kind", + "type": "string" + }, + "methodLabel": { + "maxLength": 200, + "minLength": 1, + "title": "Method Label", + "type": "string" + }, + "durationMs": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Duration Ms" + } + }, + "required": [ + "activityId", + "methodLabel", + "kind" + ], + "title": "CodeExecutionCompleted", + "type": "object" + }, + "CodeExecutionFailed": { + "additionalProperties": true, + "properties": { + "activityId": { + "maxLength": 128, + "minLength": 1, + "title": "Activity Id", + "type": "string" + }, + "code": { + "$ref": "#/$defs/ActivityFailureCode" + }, + "message": { + "anyOf": [ + { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "kind": { + "const": "code_execution", + "default": "code_execution", + "title": "Kind", + "type": "string" + }, + "methodLabel": { + "maxLength": 200, + "minLength": 1, + "title": "Method Label", + "type": "string" + } + }, + "required": [ + "activityId", + "code", + "methodLabel", + "kind" + ], + "title": "CodeExecutionFailed", + "type": "object" + }, + "CodeExecutionLabel": { + "enum": [ + "running_calculation", + "running_check_code" + ], + "title": "CodeExecutionLabel", + "type": "string" + }, + "CodeExecutionStarted": { + "additionalProperties": true, + "description": "A code execution outside a verification round. Inside verification,\nexecutions are reported as the verification Activity's status instead —\na separate code_execution Activity is never emitted there.", + "properties": { + "activityId": { + "maxLength": 128, + "minLength": 1, + "title": "Activity Id", + "type": "string" + }, + "responsePageId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Response Page Id" + }, + "kind": { + "const": "code_execution", + "default": "code_execution", + "title": "Kind", + "type": "string" + }, + "label": { + "$ref": "#/$defs/CodeExecutionLabel" + }, + "methodLabel": { + "maxLength": 200, + "minLength": 1, + "title": "Method Label", + "type": "string" + } + }, + "required": [ + "activityId", + "label", + "methodLabel", + "kind" + ], + "title": "CodeExecutionStarted", + "type": "object" + }, + "CodeExecutionUpdated": { + "additionalProperties": true, + "properties": { + "activityId": { + "maxLength": 128, + "minLength": 1, + "title": "Activity Id", + "type": "string" + }, + "status": { + "anyOf": [ + { + "$ref": "#/$defs/ActivityStatus" + }, + { + "type": "null" + } + ], + "default": null + }, + "message": { + "anyOf": [ + { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "kind": { + "const": "code_execution", + "default": "code_execution", + "title": "Kind", + "type": "string" + }, + "methodLabel": { + "maxLength": 200, + "minLength": 1, + "title": "Method Label", + "type": "string" + } + }, + "required": [ + "activityId", + "methodLabel", + "kind" + ], + "title": "CodeExecutionUpdated", + "type": "object" + }, + "ExplanationBlock": { + "additionalProperties": true, + "description": "An explanation; optionally a re-expression of a Verified Solution.", + "properties": { + "blockId": { + "maxLength": 128, + "minLength": 1, + "title": "Block Id", + "type": "string" + }, + "body": { + "maxLength": 20000, + "minLength": 1, + "title": "Body", + "type": "string" + }, + "kind": { + "const": "explanation", + "default": "explanation", + "title": "Kind", + "type": "string" + }, + "solutionId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Solution Id" + }, + "claimIds": { + "default": [], + "items": { + "type": "string" + }, + "maxItems": 50, + "title": "Claim Ids", + "type": "array" + } + }, + "required": [ + "blockId", + "body", + "kind" + ], + "title": "ExplanationBlock", + "type": "object" + }, + "FailureCode": { + "enum": [ + "verification_failed", + "budget_exceeded", + "provider_error", + "timeout", + "storage_error", + "internal_error", + "invalid_attachment" + ], + "title": "FailureCode", + "type": "string" + }, + "InputAnalysisCanceled": { + "additionalProperties": true, + "properties": { + "activityId": { + "maxLength": 128, + "minLength": 1, + "title": "Activity Id", + "type": "string" + }, + "message": { + "anyOf": [ + { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "kind": { + "const": "input_analysis", + "default": "input_analysis", + "title": "Kind", + "type": "string" + } + }, + "required": [ + "activityId", + "kind" + ], + "title": "InputAnalysisCanceled", + "type": "object" + }, + "InputAnalysisCompleted": { + "additionalProperties": true, + "properties": { + "activityId": { + "maxLength": 128, + "minLength": 1, + "title": "Activity Id", + "type": "string" + }, + "message": { + "anyOf": [ + { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "kind": { + "const": "input_analysis", + "default": "input_analysis", + "title": "Kind", + "type": "string" + } + }, + "required": [ + "activityId", + "kind" + ], + "title": "InputAnalysisCompleted", + "type": "object" + }, + "InputAnalysisFailed": { + "additionalProperties": true, + "properties": { + "activityId": { + "maxLength": 128, + "minLength": 1, + "title": "Activity Id", + "type": "string" + }, + "code": { + "$ref": "#/$defs/ActivityFailureCode" + }, + "message": { + "anyOf": [ + { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "kind": { + "const": "input_analysis", + "default": "input_analysis", + "title": "Kind", + "type": "string" + } + }, + "required": [ + "activityId", + "code", + "kind" + ], + "title": "InputAnalysisFailed", + "type": "object" + }, + "InputAnalysisLabel": { + "enum": [ + "analyzing_input", + "reading_input" + ], + "title": "InputAnalysisLabel", + "type": "string" + }, + "InputAnalysisStarted": { + "additionalProperties": true, + "properties": { + "activityId": { + "maxLength": 128, + "minLength": 1, + "title": "Activity Id", + "type": "string" + }, + "responsePageId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Response Page Id" + }, + "kind": { + "const": "input_analysis", + "default": "input_analysis", + "title": "Kind", + "type": "string" + }, + "label": { + "$ref": "#/$defs/InputAnalysisLabel" + } + }, + "required": [ + "activityId", + "label", + "kind" + ], + "title": "InputAnalysisStarted", + "type": "object" + }, + "InputAnalysisUpdated": { + "additionalProperties": true, + "properties": { + "activityId": { + "maxLength": 128, + "minLength": 1, + "title": "Activity Id", + "type": "string" + }, + "status": { + "anyOf": [ + { + "$ref": "#/$defs/ActivityStatus" + }, + { + "type": "null" + } + ], + "default": null + }, + "message": { + "anyOf": [ + { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "kind": { + "const": "input_analysis", + "default": "input_analysis", + "title": "Kind", + "type": "string" + } + }, + "required": [ + "activityId", + "kind" + ], + "title": "InputAnalysisUpdated", + "type": "object" + }, + "LimitedPage": { + "additionalProperties": true, + "description": "One named page in a bounded ``note_retrieval`` result list.", + "properties": { + "assetId": { + "maxLength": 128, + "minLength": 1, + "title": "Asset Id", + "type": "string" + }, + "page": { + "minimum": 1, + "title": "Page", + "type": "integer" + } + }, + "required": [ + "assetId", + "page" + ], + "title": "LimitedPage", + "type": "object" + }, + "ModelTier": { + "description": "Logical model tier selected by the caller.", + "enum": [ + "main", + "strong" + ], + "title": "ModelTier", + "type": "string" + }, + "NoteRetrievalCanceled": { + "additionalProperties": true, + "properties": { + "activityId": { + "maxLength": 128, + "minLength": 1, + "title": "Activity Id", + "type": "string" + }, + "message": { + "anyOf": [ + { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "kind": { + "const": "note_retrieval", + "default": "note_retrieval", + "title": "Kind", + "type": "string" + } + }, + "required": [ + "activityId", + "kind" + ], + "title": "NoteRetrievalCanceled", + "type": "object" + }, + "NoteRetrievalCompleted": { + "additionalProperties": true, + "description": "Completes with the allowlisted match status and a bounded page list.\n``limited_pages_unresolved`` must name the pages it could not resolve,\n``no_relevant_content`` must name none, and ``conflicting_content`` may\nname the conflicting pages (plan/21). A no-match or unresolved result\ncarries empty answer provenance on the terminal contract (owned by the\nresponse side, not this payload).", + "properties": { + "activityId": { + "maxLength": 128, + "minLength": 1, + "title": "Activity Id", + "type": "string" + }, + "message": { + "anyOf": [ + { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "kind": { + "const": "note_retrieval", + "default": "note_retrieval", + "title": "Kind", + "type": "string" + }, + "matchStatus": { + "$ref": "#/$defs/NoteRetrievalMatchStatus" + }, + "pages": { + "default": [], + "items": { + "$ref": "#/$defs/LimitedPage" + }, + "maxItems": 200, + "title": "Pages", + "type": "array" + } + }, + "required": [ + "activityId", + "matchStatus", + "kind" + ], + "title": "NoteRetrievalCompleted", + "type": "object" + }, + "NoteRetrievalFailed": { + "additionalProperties": true, + "properties": { + "activityId": { + "maxLength": 128, + "minLength": 1, + "title": "Activity Id", + "type": "string" + }, + "code": { + "$ref": "#/$defs/ActivityFailureCode" + }, + "message": { + "anyOf": [ + { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "kind": { + "const": "note_retrieval", + "default": "note_retrieval", + "title": "Kind", + "type": "string" + } + }, + "required": [ + "activityId", + "code", + "kind" + ], + "title": "NoteRetrievalFailed", + "type": "object" + }, + "NoteRetrievalLabel": { + "enum": [ + "searching_notes" + ], + "title": "NoteRetrievalLabel", + "type": "string" + }, + "NoteRetrievalMatchStatus": { + "description": "Normal (non-failure) retrieval outcomes; absence of a match or a\nconflict is a completed retrieval, not an error.", + "enum": [ + "matched", + "no_relevant_content", + "conflicting_content", + "limited_pages_unresolved" + ], + "title": "NoteRetrievalMatchStatus", + "type": "string" + }, + "NoteRetrievalStarted": { + "additionalProperties": true, + "properties": { + "activityId": { + "maxLength": 128, + "minLength": 1, + "title": "Activity Id", + "type": "string" + }, + "responsePageId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Response Page Id" + }, + "kind": { + "const": "note_retrieval", + "default": "note_retrieval", + "title": "Kind", + "type": "string" + }, + "label": { + "$ref": "#/$defs/NoteRetrievalLabel" + } + }, + "required": [ + "activityId", + "label", + "kind" + ], + "title": "NoteRetrievalStarted", + "type": "object" + }, + "NoteRetrievalUpdated": { + "additionalProperties": true, + "properties": { + "activityId": { + "maxLength": 128, + "minLength": 1, + "title": "Activity Id", + "type": "string" + }, + "status": { + "anyOf": [ + { + "$ref": "#/$defs/ActivityStatus" + }, + { + "type": "null" + } + ], + "default": null + }, + "message": { + "anyOf": [ + { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "kind": { + "const": "note_retrieval", + "default": "note_retrieval", + "title": "Kind", + "type": "string" + } + }, + "required": [ + "activityId", + "kind" + ], + "title": "NoteRetrievalUpdated", + "type": "object" + }, + "NoticeBlock": { + "additionalProperties": true, + "description": "A notice about a mixed or partially unsupported request.", + "properties": { + "blockId": { + "maxLength": 128, + "minLength": 1, + "title": "Block Id", + "type": "string" + }, + "body": { + "maxLength": 20000, + "minLength": 1, + "title": "Body", + "type": "string" + }, + "kind": { + "const": "notice", + "default": "notice", + "title": "Kind", + "type": "string" + } + }, + "required": [ + "blockId", + "body", + "kind" + ], + "title": "NoticeBlock", + "type": "object" + }, + "PagePresentation": { + "description": "How a response page attaches to the message; pages never nest.", + "enum": [ + "main", + "thread_like" + ], + "title": "PagePresentation", + "type": "string" + }, + "PageTarget": { + "additionalProperties": true, + "description": "One asset page range an ``asset_understanding`` Activity works on.\nPages are 1-based and inclusive.", + "properties": { + "assetId": { + "maxLength": 128, + "minLength": 1, + "title": "Asset Id", + "type": "string" + }, + "pageStart": { + "minimum": 1, + "title": "Page Start", + "type": "integer" + }, + "pageEnd": { + "minimum": 1, + "title": "Page End", + "type": "integer" + } + }, + "required": [ + "assetId", + "pageStart", + "pageEnd" + ], + "title": "PageTarget", + "type": "object" + }, + "QuoteOrigin": { + "description": "Where a quoted span comes from.\n\n``BRAIN_CANDIDATE`` exists so the publication assembly can classify its\ninternal spans; public blocks reject it — a rejected or uncommitted\nBrain candidate is never quotable content.", + "enum": [ + "user_submission", + "brain_candidate" + ], + "title": "QuoteOrigin", + "type": "string" + }, + "QuotedSpan": { + "additionalProperties": true, + "description": "A region of the block body quoted from an external origin.\n\nOnly ``user_submission`` may appear in a public response: a check block\nmay quote the user's own erroneous work, while Brain-generated candidate\ntext is never quotable.", + "properties": { + "anchor": { + "$ref": "#/$defs/TextAnchor" + }, + "origin": { + "$ref": "#/$defs/QuoteOrigin" + } + }, + "required": [ + "anchor", + "origin" + ], + "title": "QuotedSpan", + "type": "object" + }, + "ResponseDisposition": { + "description": "Terminal response classification; the typed input 1.26 validates.", + "enum": [ + "substantive", + "clarification_only", + "unsupported_only" + ], + "title": "ResponseDisposition", + "type": "string" + }, + "ResponsePage": { + "additionalProperties": true, + "description": "One ``main`` or ``thread_like`` section — 1-depth, never nested.", + "properties": { + "responsePageId": { + "maxLength": 128, + "minLength": 1, + "title": "Response Page Id", + "type": "string" + }, + "presentation": { + "$ref": "#/$defs/PagePresentation" + }, + "title": { + "anyOf": [ + { + "maxLength": 300, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Title" + }, + "resultBlocks": { + "items": { + "oneOf": [ + { + "$ref": "#/$defs/SolutionBlock" + }, + { + "$ref": "#/$defs/CheckBlock" + }, + { + "$ref": "#/$defs/ExplanationBlock" + }, + { + "$ref": "#/$defs/RichTextBlock" + }, + { + "$ref": "#/$defs/NoticeBlock" + } + ] + }, + "maxItems": 50, + "minItems": 1, + "title": "Result Blocks", + "type": "array" + } + }, + "required": [ + "responsePageId", + "presentation", + "resultBlocks" + ], + "title": "ResponsePage", + "type": "object" + }, + "RichTextBlock": { + "additionalProperties": true, + "description": "Bounded free text: notices, clarifications, unsupported guidance.", + "properties": { + "blockId": { + "maxLength": 128, + "minLength": 1, + "title": "Block Id", + "type": "string" + }, + "body": { + "maxLength": 20000, + "minLength": 1, + "title": "Body", + "type": "string" + }, + "kind": { + "const": "rich_text", + "default": "rich_text", + "title": "Kind", + "type": "string" + } + }, + "required": [ + "blockId", + "body", + "kind" + ], + "title": "RichTextBlock", + "type": "object" + }, + "RunCompletedEvent": { + "additionalProperties": true, + "properties": { + "contractVersion": { + "const": "brain-source-v1", + "default": "brain-source-v1", + "title": "Contract Version", + "type": "string" + }, + "runId": { + "maxLength": 128, + "minLength": 1, + "title": "Run Id", + "type": "string" + }, + "occurredAt": { + "format": "date-time", + "title": "Occurred At", + "type": "string" + }, + "type": { + "const": "run.completed", + "default": "run.completed", + "title": "Type", + "type": "string" + }, + "payload": { + "$ref": "#/$defs/RunCompletedPayload" + }, + "seq": { + "type": "integer", + "minimum": 1 + } + }, + "required": [ + "runId", + "occurredAt", + "payload", + "type", + "seq", + "contractVersion" + ], + "title": "RunCompletedEvent", + "type": "object" + }, + "RunCompletedPayload": { + "additionalProperties": true, + "properties": { + "responseDisposition": { + "$ref": "#/$defs/ResponseDisposition" + }, + "clarificationReason": { + "anyOf": [ + { + "$ref": "#/$defs/ClarificationReason" + }, + { + "type": "null" + } + ], + "default": null + }, + "assistantResponse": { + "$ref": "#/$defs/AssistantResponseV1" + }, + "model": { + "maxLength": 200, + "minLength": 1, + "pattern": "^[A-Za-z0-9][A-Za-z0-9_./:-]*$", + "title": "Model", + "type": "string" + }, + "tier": { + "$ref": "#/$defs/ModelTier" + }, + "verificationSummary": { + "default": [], + "items": { + "$ref": "#/$defs/SolutionVerificationSummary" + }, + "maxItems": 50, + "title": "Verification Summary", + "type": "array" + }, + "assistantMessageId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$" + } + }, + "required": [ + "responseDisposition", + "assistantResponse", + "model", + "tier", + "assistantMessageId" + ], + "title": "RunCompletedPayload", + "type": "object" + }, + "RunFailedEvent": { + "additionalProperties": true, + "properties": { + "contractVersion": { + "const": "brain-source-v1", + "default": "brain-source-v1", + "title": "Contract Version", + "type": "string" + }, + "runId": { + "maxLength": 128, + "minLength": 1, + "title": "Run Id", + "type": "string" + }, + "occurredAt": { + "format": "date-time", + "title": "Occurred At", + "type": "string" + }, + "type": { + "const": "run.failed", + "default": "run.failed", + "title": "Type", + "type": "string" + }, + "payload": { + "$ref": "#/$defs/RunFailedPayload" + }, + "seq": { + "type": "integer", + "minimum": 1 + } + }, + "required": [ + "runId", + "occurredAt", + "payload", + "type", + "seq", + "contractVersion" + ], + "title": "RunFailedEvent", + "type": "object" + }, + "RunFailedPayload": { + "additionalProperties": true, + "properties": { + "code": { + "$ref": "#/$defs/FailureCode" + }, + "reason": { + "anyOf": [ + { + "$ref": "#/$defs/AttachmentFailureReason" + }, + { + "type": "null" + } + ], + "default": null + }, + "message": { + "maxLength": 500, + "minLength": 1, + "title": "Message", + "type": "string" + }, + "recoverable": { + "title": "Recoverable", + "type": "boolean" + }, + "assistantMessageId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$" + } + }, + "required": [ + "code", + "message", + "recoverable", + "assistantMessageId" + ], + "title": "RunFailedPayload", + "type": "object" + }, + "SolutionBlock": { + "additionalProperties": true, + "description": "A Brain-authored solution with an explicit verification display.\n\n``solution_id`` references the immutable Verified Solution this block\npublishes. A ``verified``/``partially_verified`` block requires it; an\n``unverified`` (inconclusive) block forbids it — an unverified solution\nis never a Verified Solution. A published solution can never carry a\n``failed`` mark: a confirmed step failure means the candidate was\nrejected, and rejected candidates are not public.", + "properties": { + "blockId": { + "maxLength": 128, + "minLength": 1, + "title": "Block Id", + "type": "string" + }, + "body": { + "maxLength": 20000, + "minLength": 1, + "title": "Body", + "type": "string" + }, + "kind": { + "const": "solution", + "default": "solution", + "title": "Kind", + "type": "string" + }, + "verification": { + "$ref": "#/$defs/VerificationBadge" + }, + "solutionId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Solution Id" + }, + "claimIds": { + "default": [], + "items": { + "type": "string" + }, + "maxItems": 50, + "title": "Claim Ids", + "type": "array" + }, + "stepMarks": { + "default": [], + "items": { + "$ref": "#/$defs/StepMark" + }, + "maxItems": 200, + "title": "Step Marks", + "type": "array" + } + }, + "required": [ + "blockId", + "body", + "verification", + "kind" + ], + "title": "SolutionBlock", + "type": "object" + }, + "SolutionGenerationCanceled": { + "additionalProperties": true, + "properties": { + "activityId": { + "maxLength": 128, + "minLength": 1, + "title": "Activity Id", + "type": "string" + }, + "message": { + "anyOf": [ + { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "kind": { + "const": "solution_generation", + "default": "solution_generation", + "title": "Kind", + "type": "string" + } + }, + "required": [ + "activityId", + "kind" + ], + "title": "SolutionGenerationCanceled", + "type": "object" + }, + "SolutionGenerationCompleted": { + "additionalProperties": true, + "properties": { + "activityId": { + "maxLength": 128, + "minLength": 1, + "title": "Activity Id", + "type": "string" + }, + "message": { + "anyOf": [ + { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "kind": { + "const": "solution_generation", + "default": "solution_generation", + "title": "Kind", + "type": "string" + } + }, + "required": [ + "activityId", + "kind" + ], + "title": "SolutionGenerationCompleted", + "type": "object" + }, + "SolutionGenerationFailed": { + "additionalProperties": true, + "properties": { + "activityId": { + "maxLength": 128, + "minLength": 1, + "title": "Activity Id", + "type": "string" + }, + "code": { + "$ref": "#/$defs/ActivityFailureCode" + }, + "message": { + "anyOf": [ + { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "kind": { + "const": "solution_generation", + "default": "solution_generation", + "title": "Kind", + "type": "string" + } + }, + "required": [ + "activityId", + "code", + "kind" + ], + "title": "SolutionGenerationFailed", + "type": "object" + }, + "SolutionGenerationLabel": { + "enum": [ + "drafting_solution", + "revising_solution" + ], + "title": "SolutionGenerationLabel", + "type": "string" + }, + "SolutionGenerationStarted": { + "additionalProperties": true, + "properties": { + "activityId": { + "maxLength": 128, + "minLength": 1, + "title": "Activity Id", + "type": "string" + }, + "responsePageId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Response Page Id" + }, + "kind": { + "const": "solution_generation", + "default": "solution_generation", + "title": "Kind", + "type": "string" + }, + "label": { + "$ref": "#/$defs/SolutionGenerationLabel" + } + }, + "required": [ + "activityId", + "label", + "kind" + ], + "title": "SolutionGenerationStarted", + "type": "object" + }, + "SolutionGenerationUpdated": { + "additionalProperties": true, + "properties": { + "activityId": { + "maxLength": 128, + "minLength": 1, + "title": "Activity Id", + "type": "string" + }, + "status": { + "anyOf": [ + { + "$ref": "#/$defs/ActivityStatus" + }, + { + "type": "null" + } + ], + "default": null + }, + "message": { + "anyOf": [ + { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "kind": { + "const": "solution_generation", + "default": "solution_generation", + "title": "Kind", + "type": "string" + } + }, + "required": [ + "activityId", + "kind" + ], + "title": "SolutionGenerationUpdated", + "type": "object" + }, + "SolutionVerificationSummary": { + "additionalProperties": true, + "description": "One solution's public verification summary on the terminal payload.\n\n``overall_coverage`` is ``supporting`` whenever any claim is supporting —\nthe summary may never present partial confirmation as complete\nverification.", + "properties": { + "solutionId": { + "maxLength": 128, + "minLength": 1, + "title": "Solution Id", + "type": "string" + }, + "overallCoverage": { + "$ref": "#/$defs/SummaryCoverage" + }, + "claims": { + "items": { + "$ref": "#/$defs/ClaimVerificationSummary" + }, + "maxItems": 50, + "minItems": 1, + "title": "Claims", + "type": "array" + } + }, + "required": [ + "solutionId", + "overallCoverage", + "claims" + ], + "title": "SolutionVerificationSummary", + "type": "object" + }, + "SourceReference": { + "additionalProperties": true, + "description": "The Brain-owned half of a source catalog entry.\n\n``display_label_snapshot`` and ``available`` are Server-derived\npresentation values and deliberately absent here; the browser serializer\ninjects them explicitly. Pages are 1-based and inclusive.", + "properties": { + "sourceRefId": { + "maxLength": 128, + "minLength": 1, + "title": "Source Ref Id", + "type": "string" + }, + "assetId": { + "maxLength": 128, + "minLength": 1, + "title": "Asset Id", + "type": "string" + }, + "pageStart": { + "minimum": 1, + "title": "Page Start", + "type": "integer" + }, + "pageEnd": { + "minimum": 1, + "title": "Page End", + "type": "integer" + }, + "displayLabelSnapshot": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "available": { + "type": "boolean" + } + }, + "required": [ + "sourceRefId", + "assetId", + "pageStart", + "pageEnd", + "displayLabelSnapshot", + "available" + ], + "title": "SourceReference", + "type": "object" + }, + "StepMark": { + "additionalProperties": true, + "description": "One Solution Step's public status mark on a block.\n\n``anchor`` locates the step inside this block's public body; it is absent\nwhen the step's span is not publicly shown, so a mark can never point at\nhidden content. ``reason`` is a safe, bounded explanation — required for\n``unconfirmed`` so an unchecked step always says why.", + "properties": { + "stepId": { + "maxLength": 128, + "minLength": 1, + "title": "Step Id", + "type": "string" + }, + "status": { + "$ref": "#/$defs/StepMarkStatus" + }, + "reason": { + "anyOf": [ + { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reason" + }, + "anchor": { + "anyOf": [ + { + "$ref": "#/$defs/TextAnchor" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "stepId", + "status" + ], + "title": "StepMark", + "type": "object" + }, + "StepMarkStatus": { + "description": "The public per-step verification mark (wire values).", + "enum": [ + "verified", + "failed", + "unconfirmed" + ], + "title": "StepMarkStatus", + "type": "string" + }, + "StepProgress": { + "additionalProperties": true, + "description": "Measured progress of one verification round: ``checked`` of ``total``\nSolution Steps. Counts are caller-measured; this model never fabricates\na percentage and never carries step content or verdicts.", + "properties": { + "checked": { + "minimum": 0, + "title": "Checked", + "type": "integer" + }, + "total": { + "minimum": 1, + "title": "Total", + "type": "integer" + } + }, + "required": [ + "checked", + "total" + ], + "title": "StepProgress", + "type": "object" + }, + "SummaryCoverage": { + "description": "Coverage levels the public verification summary may report.", + "enum": [ + "direct", + "supporting" + ], + "title": "SummaryCoverage", + "type": "string" + }, + "TextAnchor": { + "additionalProperties": true, + "description": "A ``[start, end)`` code-point range into a block's stored public body.\n\nOffsets are deterministic over repeated text, math, and Unicode because\nthey index the stored string rather than searching for a substring.\nConsumers on UTF-16 platforms (Java/JavaScript) convert units\nthemselves. An anchor can only ever point at public body text — it\ncannot reference a hidden or internal span by construction.", + "properties": { + "start": { + "minimum": 0, + "title": "Start", + "type": "integer" + }, + "end": { + "minimum": 1, + "title": "End", + "type": "integer" + } + }, + "required": [ + "start", + "end" + ], + "title": "TextAnchor", + "type": "object" + }, + "UnconfirmedStepSummary": { + "additionalProperties": true, + "description": "A step the verifier could not confirm, with a safe reason.", + "properties": { + "stepId": { + "maxLength": 128, + "minLength": 1, + "title": "Step Id", + "type": "string" + }, + "reason": { + "maxLength": 500, + "minLength": 1, + "title": "Reason", + "type": "string" + } + }, + "required": [ + "stepId", + "reason" + ], + "title": "UnconfirmedStepSummary", + "type": "object" + }, + "VerificationBadge": { + "description": "The response's honest verification display (ADR 0011).\n\n``VERIFIED`` — every Answer-bearing Claim reached direct coverage.\n``PARTIALLY_VERIFIED`` — accepted with supporting coverage; some steps\nwere confirmed by numeric check or review instead of an exact check.\n``UNVERIFIED`` — inconclusive: published with the unverified display and\nunconfirmed marks, and never carries a Verified Solution reference.", + "enum": [ + "verified", + "partially_verified", + "unverified" + ], + "title": "VerificationBadge", + "type": "string" + }, + "VerificationCanceled": { + "additionalProperties": true, + "properties": { + "activityId": { + "maxLength": 128, + "minLength": 1, + "title": "Activity Id", + "type": "string" + }, + "message": { + "anyOf": [ + { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "kind": { + "const": "verification", + "default": "verification", + "title": "Kind", + "type": "string" + }, + "methodLabel": { + "maxLength": 200, + "minLength": 1, + "title": "Method Label", + "type": "string" + } + }, + "required": [ + "activityId", + "methodLabel", + "kind" + ], + "title": "VerificationCanceled", + "type": "object" + }, + "VerificationCompleted": { + "additionalProperties": true, + "properties": { + "activityId": { + "maxLength": 128, + "minLength": 1, + "title": "Activity Id", + "type": "string" + }, + "message": { + "anyOf": [ + { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "kind": { + "const": "verification", + "default": "verification", + "title": "Kind", + "type": "string" + }, + "methodLabel": { + "maxLength": 200, + "minLength": 1, + "title": "Method Label", + "type": "string" + }, + "progress": { + "$ref": "#/$defs/StepProgress" + } + }, + "required": [ + "activityId", + "methodLabel", + "progress", + "kind" + ], + "title": "VerificationCompleted", + "type": "object" + }, + "VerificationFailed": { + "additionalProperties": true, + "properties": { + "activityId": { + "maxLength": 128, + "minLength": 1, + "title": "Activity Id", + "type": "string" + }, + "code": { + "$ref": "#/$defs/ActivityFailureCode" + }, + "message": { + "anyOf": [ + { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "kind": { + "const": "verification", + "default": "verification", + "title": "Kind", + "type": "string" + }, + "methodLabel": { + "maxLength": 200, + "minLength": 1, + "title": "Method Label", + "type": "string" + } + }, + "required": [ + "activityId", + "code", + "methodLabel", + "kind" + ], + "title": "VerificationFailed", + "type": "object" + }, + "VerificationLabel": { + "enum": [ + "verifying_steps" + ], + "title": "VerificationLabel", + "type": "string" + }, + "VerificationStarted": { + "additionalProperties": true, + "description": "One verifier round. Per-step verdicts and step content are not\nrepresentable — they surface only as terminal Result Block marks.", + "properties": { + "activityId": { + "maxLength": 128, + "minLength": 1, + "title": "Activity Id", + "type": "string" + }, + "responsePageId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Response Page Id" + }, + "kind": { + "const": "verification", + "default": "verification", + "title": "Kind", + "type": "string" + }, + "label": { + "$ref": "#/$defs/VerificationLabel" + }, + "methodLabel": { + "maxLength": 200, + "minLength": 1, + "title": "Method Label", + "type": "string" + } + }, + "required": [ + "activityId", + "label", + "methodLabel", + "kind" + ], + "title": "VerificationStarted", + "type": "object" + }, + "VerificationUpdated": { + "additionalProperties": true, + "properties": { + "activityId": { + "maxLength": 128, + "minLength": 1, + "title": "Activity Id", + "type": "string" + }, + "status": { + "anyOf": [ + { + "$ref": "#/$defs/ActivityStatus" + }, + { + "type": "null" + } + ], + "default": null + }, + "message": { + "anyOf": [ + { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "kind": { + "const": "verification", + "default": "verification", + "title": "Kind", + "type": "string" + }, + "methodLabel": { + "maxLength": 200, + "minLength": 1, + "title": "Method Label", + "type": "string" + }, + "progress": { + "$ref": "#/$defs/StepProgress" + } + }, + "required": [ + "activityId", + "methodLabel", + "progress", + "kind" + ], + "title": "VerificationUpdated", + "type": "object" + } + }, + "oneOf": [ + { + "$ref": "#/$defs/ActivityStartedEvent" + }, + { + "$ref": "#/$defs/ActivityUpdatedEvent" + }, + { + "$ref": "#/$defs/ActivityCompletedEvent" + }, + { + "$ref": "#/$defs/ActivityFailedEvent" + }, + { + "$ref": "#/$defs/ActivityCanceledEvent" + }, + { + "$ref": "#/$defs/RunCompletedEvent" + }, + { + "$ref": "#/$defs/RunFailedEvent" + } + ] +} diff --git a/src/test/resources/contracts/brain/terminal-v2/build_fixtures.py b/src/test/resources/contracts/brain/terminal-v2/build_fixtures.py new file mode 100644 index 00000000..3f5fae72 --- /dev/null +++ b/src/test/resources/contracts/brain/terminal-v2/build_fixtures.py @@ -0,0 +1,676 @@ +from __future__ import annotations + +import json +from copy import deepcopy +from datetime import UTC, datetime +from pathlib import Path + +from proovy_brain.contracts.publication import parse_publication_request +from proovy_brain.contracts.source_progress import ( + SourceEventType, + derive_source_event_id, +) +from proovy_brain.contracts.terminal import ( + FAILURE_MESSAGES, + INVALID_ATTACHMENT_MESSAGES, + AttachmentFailureReason, + FailureCode, + browser_event_schema, + parse_source_event, + source_event_schema, + to_browser_event_payload, + to_source_event_payload, +) +from proovy_brain.features.response_publication.service import assemble_response +from proovy_brain.features.terminal_delivery.service import derive_terminal_event_id + +ROOT = Path(__file__).resolve().parent +PUBLICATION = ROOT.parent / "publication-v1/valid" +CONTEXT = { + "contract_version": "brain-source-v1", + "run_id": "run-7", + "attempt": 1, + "fence_token": "fence-9f8c", + "source_event_id": derive_terminal_event_id("run-7"), + "occurred_at": datetime(2026, 10, 1, 9, tzinfo=UTC) + .isoformat() + .replace("+00:00", "Z"), +} +NAMES = ( + "direct-verified", + "supporting-logical-move", + "zero-step-fallback", + "inconclusive-unverified", + "check-with-correction", + "hide-answer", + "clarification", + "unsupported-only", + "mixed-substantive-notice", + "multi-page", +) + + +def save(path: Path, data: dict) -> None: + path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n") + + +def completed_pair(name: str, request_raw: dict | None = None) -> dict: + if request_raw is None: + request_raw = json.loads((PUBLICATION / f"{name}.json").read_text()) + request = parse_publication_request(request_raw) + published = assemble_response(request) + artifacts = [] + for page in request.pages: + for block in page.blocks: + if block.kind != "solution" or block.solution_id is None: + continue + summary = next( + item + for item in published.verification_summary + if item.solution_id == block.solution_id + ) + artifacts.append( + { + "schema_version": "verified-solution-v1", + "solution_id": block.solution_id, + "version": 1, + "problem_statement": "주어진 명시 조건을 만족하는 결론을 구한다.", + "problem_conditions": [ + { + "condition_id": "condition-1", + "text": "문제의 명시 조건을 사용한다.", + } + ], + "solution_text": block.draft.text, + "claims": [ + { + "claim_id": claim.claim_id, + "statement": "최종 답은 $x=2$이다." + if name == "hide-answer" + else "명시 조건에서 제시한 결론이 성립한다.", + } + for claim in summary.claims + ], + "step_marks": [ + { + "step_id": step.step_id, + "status": step.status.value, + **( + { + "reason": next( + item.reason + for claim in summary.claims + for item in claim.unconfirmed_steps + if item.step_id == step.step_id + ) + } + if step.status.value == "unconfirmed" + else {} + ), + } + for step in block.report.steps + ], + "verification": summary.model_dump(mode="json", exclude_none=True), + "verification_version": "chain-verification-report-v3", + "source_ref_ids": [ + ref.source_ref_id for ref in request.source_references + ], + } + ) + provenance = [ + { + "asset_id": ref.asset_id, + "content_version": "content-v1", + "sha256": "a" * 64, + "page": page, + "understanding_version": "page-understanding-v1", + } + for ref in request.source_references + for page in range(ref.page_start, ref.page_end + 1) + ] + event = parse_source_event( + { + **CONTEXT, + "type": "run.completed", + "payload": { + "response_disposition": published.disposition.value, + "clarification_reason": published.clarification_reason, + "assistant_response": published.response.model_dump( + mode="json", exclude_none=True + ), + "verified_solutions": artifacts, + "existing_solution_references": [ + reference.model_copy(update={"verification": summary}).model_dump( + mode="json", exclude_none=True + ) + for summary in published.verification_summary + for reference in request.existing_solution_references + if reference.solution_id == summary.solution_id + ] + or None, + "selected_page_provenance": provenance, + "model": "fake/main-model", + "tier": "main", + "verification_summary": [ + item.model_dump(mode="json", exclude_none=True) + for item in published.verification_summary + ], + "source_labels": [ + item.model_dump(mode="json") for item in published.source_labels + ], + }, + } + ) + server = { + "assistant_message_id": "message-7", + "seq": 9, + "display_labels": published.display_labels, + "availability": {ref.source_ref_id: True for ref in request.source_references}, + } + return { + "publication_request": request_raw, + "source": to_source_event_payload(event), + "server": server, + "browser": to_browser_event_payload(event, **server), + } + + +def review_golden_pairs() -> dict[str, dict]: + summary = completed_pair("direct-verified")["source"]["payload"][ + "verification_summary" + ][0] + text = "양변에 같은 연산을 적용하는 이유를 설명합니다." + request = { + "disposition": "substantive", + "disclosure": {"scope": "full", "judgment_completed": True}, + "pages": [ + { + "presentation": "main", + "blocks": [ + { + "kind": "explanation", + "solution_id": "solution-1", + "claim_ids": ["claim-1"], + "draft": {"text": text}, + } + ], + } + ], + "existing_solution_references": [ + { + "solution_id": "solution-1", + "version": 1, + "claim_match_checked": True, + "verification": summary, + } + ], + } + result = {"explain-existing": completed_pair("explain-existing", request)} + hidden = deepcopy(request) + hidden["disclosure"]["scope"] = "hide_answer" + full_text = text + " 최종 답은 $x=2$입니다." + hidden["pages"][0]["blocks"][0]["draft"] = { + "text": full_text, + "withheld": [{"start": len(text), "end": len(full_text)}], + } + result["explain-existing-hide-answer"] = completed_pair( + "explain-existing-hide-answer", hidden + ) + conflict_pages = [ + {"asset_id": asset, "page": 1} for asset in ("asset-a", "asset-b") + ] + progress = [ + to_source_event_payload( + parse_source_event( + { + **CONTEXT, + "type": event_type.value, + "source_event_id": derive_source_event_id( + "run-7", "activity-1", revision, event_type + ), + "payload": { + "activity_id": "activity-1", + "kind": "note_retrieval", + **payload, + }, + } + ) + ) + for event_type, revision, payload in ( + (SourceEventType.ACTIVITY_STARTED, 0, {"label": "searching_notes"}), + ( + SourceEventType.ACTIVITY_COMPLETED, + 1, + {"match_status": "conflicting_content", "pages": conflict_pages}, + ), + ) + ] + clarification = { + "disposition": "clarification_only", + "disclosure": {"scope": "full", "judgment_completed": True}, + "clarification": { + "reason": "ambiguous_target", + "draft": {"text": "어떤 자료의 문제 조건을 기준으로 할까요?"}, + }, + } + substantive = { + "disposition": "substantive", + "disclosure": {"scope": "full", "judgment_completed": True}, + "pages": [ + { + "presentation": "main", + "blocks": [ + { + "kind": "rich_text", + "draft": { + "text": "첫 자료와 두 번째 자료의 조건이 다릅니다.[[source:sref-1]][[source:sref-2]]" + }, + } + ], + } + ], + "source_references": [ + { + "source_ref_id": f"sref-{index}", + "asset_id": asset, + "page_start": 1, + "page_end": 1, + } + for index, asset in enumerate(("asset-a", "asset-b"), 1) + ], + "source_labels": [ + {"source_ref_id": f"sref-{index}", "label": f"자료 {index}"} + for index in (1, 2) + ], + } + for name, request in ( + ("conflict-clarification", clarification), + ("conflict-substantive", substantive), + ): + result[name] = {**completed_pair(name, request), "progress_sources": progress} + return result + + +def build() -> None: + for name in NAMES: + save(ROOT / "pairs" / f"{name}.json", completed_pair(name)) + failure = parse_source_event( + { + **CONTEXT, + "type": "run.failed", + "payload": { + "code": "verification_failed", + "message": FAILURE_MESSAGES[FailureCode.VERIFICATION_FAILED], + "failure_class": "verification", + "recoverable": True, + }, + } + ) + server = {"assistant_message_id": "message-7", "seq": 9} + save( + ROOT / "pairs/failure.json", + { + "source": to_source_event_payload(failure), + "server": server, + "browser": to_browser_event_payload(failure, **server), + }, + ) + invalid_attachment = parse_source_event( + { + **CONTEXT, + "type": "run.failed", + "payload": { + "code": "invalid_attachment", + "reason": "pdf_encrypted", + "message": INVALID_ATTACHMENT_MESSAGES[ + AttachmentFailureReason.PDF_ENCRYPTED + ], + "failure_class": "attachment", + "recoverable": False, + }, + } + ) + save( + ROOT / "pairs/failure-invalid-attachment.json", + { + "source": to_source_event_payload(invalid_attachment), + "server": server, + "browser": to_browser_event_payload(invalid_attachment, **server), + }, + ) + for name, mutate in ( + ("reason-on-non-attachment-code", "stray_reason"), + ("attachment-missing-reason", "drop_reason"), + ("attachment-message-mismatch", "wrong_message"), + ("attachment-unknown-reason", "unknown_reason"), + ("attachment-recoverable", "retryable"), + ): + if name == "reason-on-non-attachment-code": + source = to_source_event_payload(failure) + browser = to_browser_event_payload(failure, **server) + source["payload"]["reason"] = "pdf_encrypted" + browser["payload"]["reason"] = "pdf_encrypted" + else: + source = to_source_event_payload(invalid_attachment) + browser = to_browser_event_payload(invalid_attachment, **server) + if mutate == "drop_reason": + del source["payload"]["reason"] + del browser["payload"]["reason"] + elif mutate == "retryable": + source["payload"]["recoverable"] = True + browser["payload"]["recoverable"] = True + elif mutate == "wrong_message": + source["payload"]["message"] = FAILURE_MESSAGES[ + FailureCode.BUDGET_EXCEEDED + ] + browser["payload"]["message"] = FAILURE_MESSAGES[ + FailureCode.BUDGET_EXCEEDED + ] + else: + source["payload"]["reason"] = "DO-NOT-LEAK-raw-payload" + browser["payload"]["reason"] = "DO-NOT-LEAK-raw-payload" + save( + ROOT / "invalid" / f"{name}.json", + { + "source": source, + "browser": browser, + "source_outcome": "reject", + "browser_outcome": "reject", + }, + ) + # failure_class is a source-only field: a class that disagrees with + # the code mutates only the source side; the browser projection of + # the unmutated event stays valid. + source = to_source_event_payload(invalid_attachment) + source["payload"]["failure_class"] = "infrastructure" + browser = to_browser_event_payload(invalid_attachment, **server) + save( + ROOT / "invalid" / "attachment-wrong-class.json", + { + "source": source, + "browser": browser, + "source_outcome": "reject", + "browser_outcome": "valid_public_projection", + "browser_expected": browser, + }, + ) + direct = completed_pair("direct-verified") + for name in ( + "phantom-solution", + "phantom-claim", + "overclaimed-coverage", + "kind-type-mismatch", + "failure-with-answer", + "raw-extra", + "raw-discriminator", + ): + pair = { + "source": deepcopy(direct["source"]), + "browser": deepcopy(direct["browser"]), + "source_outcome": "reject", + "browser_outcome": "reject", + } + source = pair["source"]["payload"] + browser = pair["browser"]["payload"] + if name in ("phantom-solution", "phantom-claim", "overclaimed-coverage"): + block = source["assistant_response"]["response_pages"][0]["result_blocks"][ + 0 + ] + browser_block = browser["assistantResponse"]["responsePages"][0][ + "resultBlocks" + ][0] + if name == "phantom-solution": + block["solution_id"] = "solution-99" + browser_block["solutionId"] = "solution-99" + elif name == "phantom-claim": + block["claim_ids"] = ["claim-99"] + browser_block["claimIds"] = ["claim-99"] + else: + block["verification"] = "partially_verified" + browser_block["verification"] = "partially_verified" + elif name == "kind-type-mismatch": + pair["source"]["type"] = "activity.updated" + pair["browser"]["type"] = "activity.updated" + elif name == "failure-with-answer": + pair["source"]["type"] = "run.failed" + pair["browser"]["type"] = "run.failed" + elif name == "raw-extra": + source["DO-NOT-LEAK-raw-payload"] = "DO-NOT-LEAK-raw-payload" + browser["DO-NOT-LEAK-raw-payload"] = "DO-NOT-LEAK-raw-payload" + pair["browser_outcome"] = "ignore_additive" + pair["browser_expected"] = direct["browser"] + else: + pair["source"]["type"] = "DO-NOT-LEAK-raw-payload" + pair["browser"]["type"] = "DO-NOT-LEAK-raw-payload" + save(ROOT / "invalid" / f"{name}.json", pair) + for name in ( + "phantom-source", + "duplicate-page", + "unverified-as-verified", + "guidance-with-sources", + "missing-selected-page", + "source-version-conflict", + "code", + "stdout", + "metadata", + "video_target", + "unsafe-failure-message", + ): + pair = { + "source": deepcopy(direct["source"]), + "browser": deepcopy(direct["browser"]), + "source_outcome": "reject", + "browser_outcome": "reject", + } + source = pair["source"]["payload"] + browser = pair["browser"]["payload"] + if name == "phantom-source": + source["assistant_response"]["response_pages"][0]["result_blocks"][0][ + "body" + ] += "[[source:sref-99]]" + browser["assistantResponse"]["responsePages"][0]["resultBlocks"][0][ + "body" + ] += "[[source:sref-99]]" + elif name == "duplicate-page": + source["assistant_response"]["response_pages"].append( + deepcopy(source["assistant_response"]["response_pages"][0]) + ) + browser["assistantResponse"]["responsePages"].append( + deepcopy(browser["assistantResponse"]["responsePages"][0]) + ) + elif name == "unverified-as-verified": + unverified = completed_pair("inconclusive-unverified") + pair["source"] = unverified["source"] + pair["browser"] = unverified["browser"] + pair["source"]["payload"]["assistant_response"]["response_pages"][0][ + "result_blocks" + ][0]["verification"] = "verified" + pair["browser"]["payload"]["assistantResponse"]["responsePages"][0][ + "resultBlocks" + ][0]["verification"] = "verified" + elif name == "guidance-with-sources": + guidance = completed_pair("clarification") + pair["source"] = guidance["source"] + pair["browser"] = guidance["browser"] + pair["source"]["payload"]["assistant_response"]["source_references"] = ( + source["assistant_response"]["source_references"] + ) + pair["browser"]["payload"]["assistantResponse"]["sourceReferences"] = ( + browser["assistantResponse"]["sourceReferences"] + ) + elif name == "unsafe-failure-message": + pair["source"] = to_source_event_payload(failure) + pair["browser"] = to_browser_event_payload(failure, **server) + pair["source"]["payload"]["message"] = "DO-NOT-LEAK-raw-payload" + pair["browser"]["payload"]["message"] = "DO-NOT-LEAK-raw-payload" + else: + if name == "missing-selected-page": + source["selected_page_provenance"] = source["selected_page_provenance"][ + :1 + ] + pair["browser_outcome"] = "valid_public_projection" + elif name == "source-version-conflict": + source["selected_page_provenance"][1]["content_version"] = ( + "content-other" + ) + pair["browser_outcome"] = "valid_public_projection" + else: + source[name] = "DO-NOT-LEAK-raw-payload" + browser[name] = "DO-NOT-LEAK-raw-payload" + pair["browser_outcome"] = "ignore_additive" + pair["browser_expected"] = direct["browser"] + save(ROOT / "invalid" / f"{name}.json", pair) + save( + ROOT / "redelivery.json", + { + "same_semantic": { + "first": direct["source"], + "retry": direct["source"], + "outcome": "same_reserved_event", + }, + "changed_outcome": { + "first": direct["source"], + "retry": to_source_event_payload(failure), + "outcome": "reject", + }, + }, + ) + review_pairs = review_golden_pairs() + for name, pair in review_pairs.items(): + save(ROOT / "pairs" / f"{name}.json", pair) + existing = review_pairs["explain-existing"] + for name in ( + "existing-phantom", + "existing-mismatched-claim", + "existing-coverage-mismatch", + "existing-unchecked", + "existing-full-answer-extra", + ): + pair = { + "source": deepcopy(existing["source"]), + "browser": deepcopy(existing["browser"]), + "source_outcome": "reject", + "browser_outcome": "reject", + } + source = pair["source"]["payload"] + browser = pair["browser"]["payload"] + if name == "existing-phantom": + source["assistant_response"]["response_pages"][0]["result_blocks"][0][ + "solution_id" + ] = "solution-99" + browser["assistantResponse"]["responsePages"][0]["resultBlocks"][0][ + "solutionId" + ] = "solution-99" + elif name == "existing-mismatched-claim": + source["assistant_response"]["response_pages"][0]["result_blocks"][0][ + "claim_ids" + ] = ["claim-99"] + browser["assistantResponse"]["responsePages"][0]["resultBlocks"][0][ + "claimIds" + ] = ["claim-99"] + elif name == "existing-coverage-mismatch": + source["verification_summary"][0]["claims"][0]["coverage"] = "supporting" + browser["verificationSummary"][0]["claims"][0]["coverage"] = "supporting" + else: + if name == "existing-unchecked": + source["existing_solution_references"][0]["claim_match_checked"] = False + pair["browser_outcome"] = "valid_public_projection" + else: + source["existing_solution_references"][0]["solution_text"] = ( + "DO-NOT-LEAK-raw-payload" + ) + browser["existingSolutionReferences"] = [ + {"solutionText": "DO-NOT-LEAK-raw-payload"} + ] + pair["browser_outcome"] = "ignore_additive" + pair["browser_expected"] = existing["browser"] + save(ROOT / "invalid" / f"{name}.json", pair) + for name in ("missing-activity-kind", "missing-block-kind"): + if name == "missing-activity-kind": + source = json.loads( + ( + ROOT.parent / "activity-v1/valid/input-analysis-started.json" + ).read_text() + ) + browser = to_browser_event_payload(parse_source_event(source), seq=1) + del source["payload"]["kind"] + del browser["payload"]["kind"] + else: + source, browser = deepcopy(direct["source"]), deepcopy(direct["browser"]) + del source["payload"]["assistant_response"]["response_pages"][0][ + "result_blocks" + ][0]["kind"] + del browser["payload"]["assistantResponse"]["responsePages"][0][ + "resultBlocks" + ][0]["kind"] + save( + ROOT / "invalid" / f"{name}.json", + { + "source": source, + "browser": browser, + "source_outcome": "reject", + "browser_outcome": "reject", + "schema_outcome": "reject", + }, + ) + save(ROOT / "source.schema.json", source_event_schema()) + save(ROOT / "browser.schema.json", browser_event_schema()) + save( + ROOT / "manifest.json", + { + "consumer_version": "terminal-fixtures-v2", + "source_contract_version": "brain-source-v1", + "assistant_response_version": "assistant-response-v1", + "artifact_version": "verified-solution-v1", + "original_path": "tests/fixtures/contracts/terminal-v2", + "change_reason": "Task 1.58: run.failed gains code invalid_attachment{reason} — attachment-inspection refusals (corrupt/forged/encrypted/oversized inputs) stop reporting budget_exceeded; failure_class=attachment, recoverable=false, reason is a fixed sub-code with a fixed safe message; Server consumes it for compensation and re-upload guidance.", + "consumption_policy": "Pin terminal-fixtures-v2 at original_path; changes require a new fixture version and reason.", + "ownership": { + "brain": [ + "snake_case source", + "verified_solutions", + "selected_page_provenance", + "checked source_labels", + ], + "server": [ + "assistantMessageId", + "seq", + "page boundary events", + "commit", + "availability", + "plainText storage", + ], + "browser": [ + "camelCase public response only", + "ignore unknown additive fields; validate known fields", + ], + }, + "boundaries": "Fake consumer only; no product commit, replay, permissions or content grounding guarantee.", + "pairs": [*NAMES, "failure", "failure-invalid-attachment", *review_pairs], + "change_history": [ + { + "date": "2026-10-01", + "revision": "218d2de", + "reason": "Initial task 1.28 source/browser freeze.", + }, + { + "date": "2026-10-01", + "reason": "PR #58 independent review: pre-release v1 correction distinguishes conflict clarification, adds bounded checked existing-solution re-expression references, and makes every discriminated union tag required in both schemas. Existing positive pairs remain unchanged.", + }, + { + "date": "2026-10-10", + "reason": "Task 1.58 terminal-fixtures-v2: run.failed adds FailureCode invalid_attachment, FailureClass attachment, and the reason sub-code (AttachmentFailureReason mirrors the file-inspection refusal reasons); invalid_attachment carries a per-reason fixed message and recoverable=false. Prior codes keep v1 meaning; completed/failure pairs are byte-identical.", + }, + ], + "existing_reference_policy": "Caller supplies checked ID/version/claim coverage only, never the existing full answer. Public summaries select referenced claims inside assemble_response and retain disclosure checks. Storage/authentication/semantic claim matching are caller responsibilities, not implemented here.", + "negative_pairs": sorted( + path.stem for path in (ROOT / "invalid").glob("*.json") + ), + "validation_scope": "JSON Schema validates wire structure; Python validators enforce reference/disposition/coverage integrity and the invalid_attachment reason↔message↔class agreement. Browser cannot validate omitted internal artifacts or selected provenance; unknown additions are ignored.", + "fixture_inputs": "Copied publication-v1 requests are embedded and frozen in each pair; no evals oracle or answer metadata is used.", + }, + ) + + +if __name__ == "__main__": + build() diff --git a/src/test/resources/contracts/brain/terminal-v2/invalid/attachment-message-mismatch.json b/src/test/resources/contracts/brain/terminal-v2/invalid/attachment-message-mismatch.json new file mode 100644 index 00000000..06d763c5 --- /dev/null +++ b/src/test/resources/contracts/brain/terminal-v2/invalid/attachment-message-mismatch.json @@ -0,0 +1,34 @@ +{ + "source": { + "contract_version": "brain-source-v1", + "run_id": "run-7", + "attempt": 1, + "fence_token": "fence-9f8c", + "source_event_id": "run-7:terminal:r0:b4c05df04575822cec85", + "occurred_at": "2026-10-01T09:00:00Z", + "type": "run.failed", + "payload": { + "code": "invalid_attachment", + "reason": "pdf_encrypted", + "message": "요청의 처리 한도 안에서 응답을 완성하지 못했습니다.", + "failure_class": "attachment", + "recoverable": false + } + }, + "browser": { + "contractVersion": "brain-source-v1", + "runId": "run-7", + "seq": 9, + "occurredAt": "2026-10-01T09:00:00Z", + "type": "run.failed", + "payload": { + "code": "invalid_attachment", + "reason": "pdf_encrypted", + "message": "요청의 처리 한도 안에서 응답을 완성하지 못했습니다.", + "recoverable": false, + "assistantMessageId": "message-7" + } + }, + "source_outcome": "reject", + "browser_outcome": "reject" +} diff --git a/src/test/resources/contracts/brain/terminal-v2/invalid/attachment-missing-reason.json b/src/test/resources/contracts/brain/terminal-v2/invalid/attachment-missing-reason.json new file mode 100644 index 00000000..64428be0 --- /dev/null +++ b/src/test/resources/contracts/brain/terminal-v2/invalid/attachment-missing-reason.json @@ -0,0 +1,32 @@ +{ + "source": { + "contract_version": "brain-source-v1", + "run_id": "run-7", + "attempt": 1, + "fence_token": "fence-9f8c", + "source_event_id": "run-7:terminal:r0:b4c05df04575822cec85", + "occurred_at": "2026-10-01T09:00:00Z", + "type": "run.failed", + "payload": { + "code": "invalid_attachment", + "message": "암호로 보호된 PDF는 처리할 수 없습니다. 암호를 해제한 PDF를 다시 올려주세요.", + "failure_class": "attachment", + "recoverable": false + } + }, + "browser": { + "contractVersion": "brain-source-v1", + "runId": "run-7", + "seq": 9, + "occurredAt": "2026-10-01T09:00:00Z", + "type": "run.failed", + "payload": { + "code": "invalid_attachment", + "message": "암호로 보호된 PDF는 처리할 수 없습니다. 암호를 해제한 PDF를 다시 올려주세요.", + "recoverable": false, + "assistantMessageId": "message-7" + } + }, + "source_outcome": "reject", + "browser_outcome": "reject" +} diff --git a/src/test/resources/contracts/brain/terminal-v2/invalid/attachment-recoverable.json b/src/test/resources/contracts/brain/terminal-v2/invalid/attachment-recoverable.json new file mode 100644 index 00000000..3a49af65 --- /dev/null +++ b/src/test/resources/contracts/brain/terminal-v2/invalid/attachment-recoverable.json @@ -0,0 +1,34 @@ +{ + "source": { + "contract_version": "brain-source-v1", + "run_id": "run-7", + "attempt": 1, + "fence_token": "fence-9f8c", + "source_event_id": "run-7:terminal:r0:b4c05df04575822cec85", + "occurred_at": "2026-10-01T09:00:00Z", + "type": "run.failed", + "payload": { + "code": "invalid_attachment", + "reason": "pdf_encrypted", + "message": "암호로 보호된 PDF는 처리할 수 없습니다. 암호를 해제한 PDF를 다시 올려주세요.", + "failure_class": "attachment", + "recoverable": true + } + }, + "browser": { + "contractVersion": "brain-source-v1", + "runId": "run-7", + "seq": 9, + "occurredAt": "2026-10-01T09:00:00Z", + "type": "run.failed", + "payload": { + "code": "invalid_attachment", + "reason": "pdf_encrypted", + "message": "암호로 보호된 PDF는 처리할 수 없습니다. 암호를 해제한 PDF를 다시 올려주세요.", + "recoverable": true, + "assistantMessageId": "message-7" + } + }, + "source_outcome": "reject", + "browser_outcome": "reject" +} diff --git a/src/test/resources/contracts/brain/terminal-v2/invalid/attachment-unknown-reason.json b/src/test/resources/contracts/brain/terminal-v2/invalid/attachment-unknown-reason.json new file mode 100644 index 00000000..2ded1350 --- /dev/null +++ b/src/test/resources/contracts/brain/terminal-v2/invalid/attachment-unknown-reason.json @@ -0,0 +1,34 @@ +{ + "source": { + "contract_version": "brain-source-v1", + "run_id": "run-7", + "attempt": 1, + "fence_token": "fence-9f8c", + "source_event_id": "run-7:terminal:r0:b4c05df04575822cec85", + "occurred_at": "2026-10-01T09:00:00Z", + "type": "run.failed", + "payload": { + "code": "invalid_attachment", + "reason": "DO-NOT-LEAK-raw-payload", + "message": "암호로 보호된 PDF는 처리할 수 없습니다. 암호를 해제한 PDF를 다시 올려주세요.", + "failure_class": "attachment", + "recoverable": false + } + }, + "browser": { + "contractVersion": "brain-source-v1", + "runId": "run-7", + "seq": 9, + "occurredAt": "2026-10-01T09:00:00Z", + "type": "run.failed", + "payload": { + "code": "invalid_attachment", + "reason": "DO-NOT-LEAK-raw-payload", + "message": "암호로 보호된 PDF는 처리할 수 없습니다. 암호를 해제한 PDF를 다시 올려주세요.", + "recoverable": false, + "assistantMessageId": "message-7" + } + }, + "source_outcome": "reject", + "browser_outcome": "reject" +} diff --git a/src/test/resources/contracts/brain/terminal-v2/invalid/attachment-wrong-class.json b/src/test/resources/contracts/brain/terminal-v2/invalid/attachment-wrong-class.json new file mode 100644 index 00000000..00405d40 --- /dev/null +++ b/src/test/resources/contracts/brain/terminal-v2/invalid/attachment-wrong-class.json @@ -0,0 +1,48 @@ +{ + "source": { + "contract_version": "brain-source-v1", + "run_id": "run-7", + "attempt": 1, + "fence_token": "fence-9f8c", + "source_event_id": "run-7:terminal:r0:b4c05df04575822cec85", + "occurred_at": "2026-10-01T09:00:00Z", + "type": "run.failed", + "payload": { + "code": "invalid_attachment", + "reason": "pdf_encrypted", + "message": "암호로 보호된 PDF는 처리할 수 없습니다. 암호를 해제한 PDF를 다시 올려주세요.", + "failure_class": "infrastructure", + "recoverable": false + } + }, + "browser": { + "contractVersion": "brain-source-v1", + "runId": "run-7", + "seq": 9, + "occurredAt": "2026-10-01T09:00:00Z", + "type": "run.failed", + "payload": { + "code": "invalid_attachment", + "reason": "pdf_encrypted", + "message": "암호로 보호된 PDF는 처리할 수 없습니다. 암호를 해제한 PDF를 다시 올려주세요.", + "recoverable": false, + "assistantMessageId": "message-7" + } + }, + "source_outcome": "reject", + "browser_outcome": "valid_public_projection", + "browser_expected": { + "contractVersion": "brain-source-v1", + "runId": "run-7", + "seq": 9, + "occurredAt": "2026-10-01T09:00:00Z", + "type": "run.failed", + "payload": { + "code": "invalid_attachment", + "reason": "pdf_encrypted", + "message": "암호로 보호된 PDF는 처리할 수 없습니다. 암호를 해제한 PDF를 다시 올려주세요.", + "recoverable": false, + "assistantMessageId": "message-7" + } + } +} diff --git a/src/test/resources/contracts/brain/terminal-v2/invalid/code.json b/src/test/resources/contracts/brain/terminal-v2/invalid/code.json new file mode 100644 index 00000000..91e22e4d --- /dev/null +++ b/src/test/resources/contracts/brain/terminal-v2/invalid/code.json @@ -0,0 +1,305 @@ +{ + "source": { + "contract_version": "brain-source-v1", + "run_id": "run-7", + "attempt": 1, + "fence_token": "fence-9f8c", + "source_event_id": "run-7:terminal:r0:b4c05df04575822cec85", + "occurred_at": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "response_disposition": "substantive", + "assistant_response": { + "schema_version": "assistant-response-v1", + "response_pages": [ + { + "response_page_id": "page-1", + "presentation": "main", + "title": "검증된 풀이", + "result_blocks": [ + { + "block_id": "block-1", + "body": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "kind": "solution", + "verification": "verified", + "solution_id": "solution-1", + "claim_ids": [ + "claim-1" + ], + "step_marks": [ + { + "step_id": "step-1", + "status": "verified", + "anchor": { + "start": 0, + "end": 24 + } + }, + { + "step_id": "step-2", + "status": "verified", + "anchor": { + "start": 25, + "end": 46 + } + } + ] + } + ] + } + ], + "source_references": [ + { + "source_ref_id": "sref-1", + "asset_id": "note-alpha", + "page_start": 3, + "page_end": 4 + } + ] + }, + "verified_solutions": [ + { + "schema_version": "verified-solution-v1", + "solution_id": "solution-1", + "version": 1, + "problem_statement": "주어진 명시 조건을 만족하는 결론을 구한다.", + "problem_conditions": [ + { + "condition_id": "condition-1", + "text": "문제의 명시 조건을 사용한다." + } + ], + "solution_text": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "claims": [ + { + "claim_id": "claim-1", + "statement": "명시 조건에서 제시한 결론이 성립한다." + } + ], + "step_marks": [ + { + "step_id": "step-1", + "status": "verified" + }, + { + "step_id": "step-2", + "status": "verified" + } + ], + "verification": { + "solution_id": "solution-1", + "overall_coverage": "direct", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "direct", + "method_label": "모든 단계를 실행 검사로 확인", + "unconfirmed_steps": [] + } + ] + }, + "verification_version": "chain-verification-report-v3", + "source_ref_ids": [ + "sref-1" + ] + } + ], + "selected_page_provenance": [ + { + "asset_id": "note-alpha", + "content_version": "content-v1", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "page": 3, + "understanding_version": "page-understanding-v1" + }, + { + "asset_id": "note-alpha", + "content_version": "content-v1", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "page": 4, + "understanding_version": "page-understanding-v1" + } + ], + "model": "fake/main-model", + "tier": "main", + "verification_summary": [ + { + "solution_id": "solution-1", + "overall_coverage": "direct", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "direct", + "method_label": "모든 단계를 실행 검사로 확인", + "unconfirmed_steps": [] + } + ] + } + ], + "source_labels": [ + { + "source_ref_id": "sref-1", + "label": "개념 노트 3–4쪽" + } + ], + "code": "DO-NOT-LEAK-raw-payload" + } + }, + "browser": { + "contractVersion": "brain-source-v1", + "runId": "run-7", + "seq": 9, + "occurredAt": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "responseDisposition": "substantive", + "assistantResponse": { + "responsePages": [ + { + "responsePageId": "page-1", + "presentation": "main", + "title": "검증된 풀이", + "resultBlocks": [ + { + "blockId": "block-1", + "body": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "kind": "solution", + "verification": "verified", + "solutionId": "solution-1", + "claimIds": [ + "claim-1" + ], + "stepMarks": [ + { + "stepId": "step-1", + "status": "verified", + "anchor": { + "start": 0, + "end": 24 + } + }, + { + "stepId": "step-2", + "status": "verified", + "anchor": { + "start": 25, + "end": 46 + } + } + ] + } + ] + } + ], + "sourceReferences": [ + { + "sourceRefId": "sref-1", + "assetId": "note-alpha", + "pageStart": 3, + "pageEnd": 4, + "displayLabelSnapshot": "개념 노트 3–4쪽", + "available": true + } + ], + "plainText": "검증된 풀이\n\n인수분해하면 (x-2)(x-3)=0이다. 따라서 x=2 또는 x=3이다." + }, + "model": "fake/main-model", + "tier": "main", + "verificationSummary": [ + { + "solutionId": "solution-1", + "overallCoverage": "direct", + "claims": [ + { + "claimId": "claim-1", + "coverage": "direct", + "methodLabel": "모든 단계를 실행 검사로 확인", + "unconfirmedSteps": [] + } + ] + } + ], + "assistantMessageId": "message-7", + "code": "DO-NOT-LEAK-raw-payload" + } + }, + "source_outcome": "reject", + "browser_outcome": "ignore_additive", + "browser_expected": { + "contractVersion": "brain-source-v1", + "runId": "run-7", + "seq": 9, + "occurredAt": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "responseDisposition": "substantive", + "assistantResponse": { + "responsePages": [ + { + "responsePageId": "page-1", + "presentation": "main", + "title": "검증된 풀이", + "resultBlocks": [ + { + "blockId": "block-1", + "body": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "kind": "solution", + "verification": "verified", + "solutionId": "solution-1", + "claimIds": [ + "claim-1" + ], + "stepMarks": [ + { + "stepId": "step-1", + "status": "verified", + "anchor": { + "start": 0, + "end": 24 + } + }, + { + "stepId": "step-2", + "status": "verified", + "anchor": { + "start": 25, + "end": 46 + } + } + ] + } + ] + } + ], + "sourceReferences": [ + { + "sourceRefId": "sref-1", + "assetId": "note-alpha", + "pageStart": 3, + "pageEnd": 4, + "displayLabelSnapshot": "개념 노트 3–4쪽", + "available": true + } + ], + "plainText": "검증된 풀이\n\n인수분해하면 (x-2)(x-3)=0이다. 따라서 x=2 또는 x=3이다." + }, + "model": "fake/main-model", + "tier": "main", + "verificationSummary": [ + { + "solutionId": "solution-1", + "overallCoverage": "direct", + "claims": [ + { + "claimId": "claim-1", + "coverage": "direct", + "methodLabel": "모든 단계를 실행 검사로 확인", + "unconfirmedSteps": [] + } + ] + } + ], + "assistantMessageId": "message-7" + } + } +} diff --git a/src/test/resources/contracts/brain/terminal-v2/invalid/duplicate-page.json b/src/test/resources/contracts/brain/terminal-v2/invalid/duplicate-page.json new file mode 100644 index 00000000..0f50ac65 --- /dev/null +++ b/src/test/resources/contracts/brain/terminal-v2/invalid/duplicate-page.json @@ -0,0 +1,296 @@ +{ + "source": { + "contract_version": "brain-source-v1", + "run_id": "run-7", + "attempt": 1, + "fence_token": "fence-9f8c", + "source_event_id": "run-7:terminal:r0:b4c05df04575822cec85", + "occurred_at": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "response_disposition": "substantive", + "assistant_response": { + "schema_version": "assistant-response-v1", + "response_pages": [ + { + "response_page_id": "page-1", + "presentation": "main", + "title": "검증된 풀이", + "result_blocks": [ + { + "block_id": "block-1", + "body": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "kind": "solution", + "verification": "verified", + "solution_id": "solution-1", + "claim_ids": [ + "claim-1" + ], + "step_marks": [ + { + "step_id": "step-1", + "status": "verified", + "anchor": { + "start": 0, + "end": 24 + } + }, + { + "step_id": "step-2", + "status": "verified", + "anchor": { + "start": 25, + "end": 46 + } + } + ] + } + ] + }, + { + "response_page_id": "page-1", + "presentation": "main", + "title": "검증된 풀이", + "result_blocks": [ + { + "block_id": "block-1", + "body": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "kind": "solution", + "verification": "verified", + "solution_id": "solution-1", + "claim_ids": [ + "claim-1" + ], + "step_marks": [ + { + "step_id": "step-1", + "status": "verified", + "anchor": { + "start": 0, + "end": 24 + } + }, + { + "step_id": "step-2", + "status": "verified", + "anchor": { + "start": 25, + "end": 46 + } + } + ] + } + ] + } + ], + "source_references": [ + { + "source_ref_id": "sref-1", + "asset_id": "note-alpha", + "page_start": 3, + "page_end": 4 + } + ] + }, + "verified_solutions": [ + { + "schema_version": "verified-solution-v1", + "solution_id": "solution-1", + "version": 1, + "problem_statement": "주어진 명시 조건을 만족하는 결론을 구한다.", + "problem_conditions": [ + { + "condition_id": "condition-1", + "text": "문제의 명시 조건을 사용한다." + } + ], + "solution_text": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "claims": [ + { + "claim_id": "claim-1", + "statement": "명시 조건에서 제시한 결론이 성립한다." + } + ], + "step_marks": [ + { + "step_id": "step-1", + "status": "verified" + }, + { + "step_id": "step-2", + "status": "verified" + } + ], + "verification": { + "solution_id": "solution-1", + "overall_coverage": "direct", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "direct", + "method_label": "모든 단계를 실행 검사로 확인", + "unconfirmed_steps": [] + } + ] + }, + "verification_version": "chain-verification-report-v3", + "source_ref_ids": [ + "sref-1" + ] + } + ], + "selected_page_provenance": [ + { + "asset_id": "note-alpha", + "content_version": "content-v1", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "page": 3, + "understanding_version": "page-understanding-v1" + }, + { + "asset_id": "note-alpha", + "content_version": "content-v1", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "page": 4, + "understanding_version": "page-understanding-v1" + } + ], + "model": "fake/main-model", + "tier": "main", + "verification_summary": [ + { + "solution_id": "solution-1", + "overall_coverage": "direct", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "direct", + "method_label": "모든 단계를 실행 검사로 확인", + "unconfirmed_steps": [] + } + ] + } + ], + "source_labels": [ + { + "source_ref_id": "sref-1", + "label": "개념 노트 3–4쪽" + } + ] + } + }, + "browser": { + "contractVersion": "brain-source-v1", + "runId": "run-7", + "seq": 9, + "occurredAt": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "responseDisposition": "substantive", + "assistantResponse": { + "responsePages": [ + { + "responsePageId": "page-1", + "presentation": "main", + "title": "검증된 풀이", + "resultBlocks": [ + { + "blockId": "block-1", + "body": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "kind": "solution", + "verification": "verified", + "solutionId": "solution-1", + "claimIds": [ + "claim-1" + ], + "stepMarks": [ + { + "stepId": "step-1", + "status": "verified", + "anchor": { + "start": 0, + "end": 24 + } + }, + { + "stepId": "step-2", + "status": "verified", + "anchor": { + "start": 25, + "end": 46 + } + } + ] + } + ] + }, + { + "responsePageId": "page-1", + "presentation": "main", + "title": "검증된 풀이", + "resultBlocks": [ + { + "blockId": "block-1", + "body": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "kind": "solution", + "verification": "verified", + "solutionId": "solution-1", + "claimIds": [ + "claim-1" + ], + "stepMarks": [ + { + "stepId": "step-1", + "status": "verified", + "anchor": { + "start": 0, + "end": 24 + } + }, + { + "stepId": "step-2", + "status": "verified", + "anchor": { + "start": 25, + "end": 46 + } + } + ] + } + ] + } + ], + "sourceReferences": [ + { + "sourceRefId": "sref-1", + "assetId": "note-alpha", + "pageStart": 3, + "pageEnd": 4, + "displayLabelSnapshot": "개념 노트 3–4쪽", + "available": true + } + ], + "plainText": "검증된 풀이\n\n인수분해하면 (x-2)(x-3)=0이다. 따라서 x=2 또는 x=3이다." + }, + "model": "fake/main-model", + "tier": "main", + "verificationSummary": [ + { + "solutionId": "solution-1", + "overallCoverage": "direct", + "claims": [ + { + "claimId": "claim-1", + "coverage": "direct", + "methodLabel": "모든 단계를 실행 검사로 확인", + "unconfirmedSteps": [] + } + ] + } + ], + "assistantMessageId": "message-7" + } + }, + "source_outcome": "reject", + "browser_outcome": "reject" +} diff --git a/src/test/resources/contracts/brain/terminal-v2/invalid/existing-coverage-mismatch.json b/src/test/resources/contracts/brain/terminal-v2/invalid/existing-coverage-mismatch.json new file mode 100644 index 00000000..4ddcee17 --- /dev/null +++ b/src/test/resources/contracts/brain/terminal-v2/invalid/existing-coverage-mismatch.json @@ -0,0 +1,123 @@ +{ + "source": { + "contract_version": "brain-source-v1", + "run_id": "run-7", + "attempt": 1, + "fence_token": "fence-9f8c", + "source_event_id": "run-7:terminal:r0:b4c05df04575822cec85", + "occurred_at": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "response_disposition": "substantive", + "assistant_response": { + "schema_version": "assistant-response-v1", + "response_pages": [ + { + "response_page_id": "page-1", + "presentation": "main", + "result_blocks": [ + { + "block_id": "block-1", + "body": "양변에 같은 연산을 적용하는 이유를 설명합니다.", + "kind": "explanation", + "solution_id": "solution-1", + "claim_ids": [ + "claim-1" + ] + } + ] + } + ], + "source_references": [] + }, + "verified_solutions": [], + "existing_solution_references": [ + { + "solution_id": "solution-1", + "version": 1, + "verification": { + "solution_id": "solution-1", + "overall_coverage": "direct", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "direct", + "method_label": "모든 단계를 실행 검사로 확인", + "unconfirmed_steps": [] + } + ] + }, + "claim_match_checked": true + } + ], + "selected_page_provenance": [], + "model": "fake/main-model", + "tier": "main", + "verification_summary": [ + { + "solution_id": "solution-1", + "overall_coverage": "direct", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "supporting", + "method_label": "모든 단계를 실행 검사로 확인", + "unconfirmed_steps": [] + } + ] + } + ], + "source_labels": [] + } + }, + "browser": { + "contractVersion": "brain-source-v1", + "runId": "run-7", + "seq": 9, + "occurredAt": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "responseDisposition": "substantive", + "assistantResponse": { + "responsePages": [ + { + "responsePageId": "page-1", + "presentation": "main", + "resultBlocks": [ + { + "blockId": "block-1", + "body": "양변에 같은 연산을 적용하는 이유를 설명합니다.", + "kind": "explanation", + "solutionId": "solution-1", + "claimIds": [ + "claim-1" + ] + } + ] + } + ], + "sourceReferences": [], + "plainText": "양변에 같은 연산을 적용하는 이유를 설명합니다." + }, + "model": "fake/main-model", + "tier": "main", + "verificationSummary": [ + { + "solutionId": "solution-1", + "overallCoverage": "direct", + "claims": [ + { + "claimId": "claim-1", + "coverage": "supporting", + "methodLabel": "모든 단계를 실행 검사로 확인", + "unconfirmedSteps": [] + } + ] + } + ], + "assistantMessageId": "message-7" + } + }, + "source_outcome": "reject", + "browser_outcome": "reject" +} diff --git a/src/test/resources/contracts/brain/terminal-v2/invalid/existing-full-answer-extra.json b/src/test/resources/contracts/brain/terminal-v2/invalid/existing-full-answer-extra.json new file mode 100644 index 00000000..37826bdb --- /dev/null +++ b/src/test/resources/contracts/brain/terminal-v2/invalid/existing-full-answer-extra.json @@ -0,0 +1,177 @@ +{ + "source": { + "contract_version": "brain-source-v1", + "run_id": "run-7", + "attempt": 1, + "fence_token": "fence-9f8c", + "source_event_id": "run-7:terminal:r0:b4c05df04575822cec85", + "occurred_at": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "response_disposition": "substantive", + "assistant_response": { + "schema_version": "assistant-response-v1", + "response_pages": [ + { + "response_page_id": "page-1", + "presentation": "main", + "result_blocks": [ + { + "block_id": "block-1", + "body": "양변에 같은 연산을 적용하는 이유를 설명합니다.", + "kind": "explanation", + "solution_id": "solution-1", + "claim_ids": [ + "claim-1" + ] + } + ] + } + ], + "source_references": [] + }, + "verified_solutions": [], + "existing_solution_references": [ + { + "solution_id": "solution-1", + "version": 1, + "verification": { + "solution_id": "solution-1", + "overall_coverage": "direct", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "direct", + "method_label": "모든 단계를 실행 검사로 확인", + "unconfirmed_steps": [] + } + ] + }, + "claim_match_checked": true, + "solution_text": "DO-NOT-LEAK-raw-payload" + } + ], + "selected_page_provenance": [], + "model": "fake/main-model", + "tier": "main", + "verification_summary": [ + { + "solution_id": "solution-1", + "overall_coverage": "direct", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "direct", + "method_label": "모든 단계를 실행 검사로 확인", + "unconfirmed_steps": [] + } + ] + } + ], + "source_labels": [] + } + }, + "browser": { + "contractVersion": "brain-source-v1", + "runId": "run-7", + "seq": 9, + "occurredAt": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "responseDisposition": "substantive", + "assistantResponse": { + "responsePages": [ + { + "responsePageId": "page-1", + "presentation": "main", + "resultBlocks": [ + { + "blockId": "block-1", + "body": "양변에 같은 연산을 적용하는 이유를 설명합니다.", + "kind": "explanation", + "solutionId": "solution-1", + "claimIds": [ + "claim-1" + ] + } + ] + } + ], + "sourceReferences": [], + "plainText": "양변에 같은 연산을 적용하는 이유를 설명합니다." + }, + "model": "fake/main-model", + "tier": "main", + "verificationSummary": [ + { + "solutionId": "solution-1", + "overallCoverage": "direct", + "claims": [ + { + "claimId": "claim-1", + "coverage": "direct", + "methodLabel": "모든 단계를 실행 검사로 확인", + "unconfirmedSteps": [] + } + ] + } + ], + "assistantMessageId": "message-7", + "existingSolutionReferences": [ + { + "solutionText": "DO-NOT-LEAK-raw-payload" + } + ] + } + }, + "source_outcome": "reject", + "browser_outcome": "ignore_additive", + "browser_expected": { + "contractVersion": "brain-source-v1", + "runId": "run-7", + "seq": 9, + "occurredAt": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "responseDisposition": "substantive", + "assistantResponse": { + "responsePages": [ + { + "responsePageId": "page-1", + "presentation": "main", + "resultBlocks": [ + { + "blockId": "block-1", + "body": "양변에 같은 연산을 적용하는 이유를 설명합니다.", + "kind": "explanation", + "solutionId": "solution-1", + "claimIds": [ + "claim-1" + ] + } + ] + } + ], + "sourceReferences": [], + "plainText": "양변에 같은 연산을 적용하는 이유를 설명합니다." + }, + "model": "fake/main-model", + "tier": "main", + "verificationSummary": [ + { + "solutionId": "solution-1", + "overallCoverage": "direct", + "claims": [ + { + "claimId": "claim-1", + "coverage": "direct", + "methodLabel": "모든 단계를 실행 검사로 확인", + "unconfirmedSteps": [] + } + ] + } + ], + "assistantMessageId": "message-7" + } + } +} diff --git a/src/test/resources/contracts/brain/terminal-v2/invalid/existing-mismatched-claim.json b/src/test/resources/contracts/brain/terminal-v2/invalid/existing-mismatched-claim.json new file mode 100644 index 00000000..0dce0e6d --- /dev/null +++ b/src/test/resources/contracts/brain/terminal-v2/invalid/existing-mismatched-claim.json @@ -0,0 +1,123 @@ +{ + "source": { + "contract_version": "brain-source-v1", + "run_id": "run-7", + "attempt": 1, + "fence_token": "fence-9f8c", + "source_event_id": "run-7:terminal:r0:b4c05df04575822cec85", + "occurred_at": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "response_disposition": "substantive", + "assistant_response": { + "schema_version": "assistant-response-v1", + "response_pages": [ + { + "response_page_id": "page-1", + "presentation": "main", + "result_blocks": [ + { + "block_id": "block-1", + "body": "양변에 같은 연산을 적용하는 이유를 설명합니다.", + "kind": "explanation", + "solution_id": "solution-1", + "claim_ids": [ + "claim-99" + ] + } + ] + } + ], + "source_references": [] + }, + "verified_solutions": [], + "existing_solution_references": [ + { + "solution_id": "solution-1", + "version": 1, + "verification": { + "solution_id": "solution-1", + "overall_coverage": "direct", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "direct", + "method_label": "모든 단계를 실행 검사로 확인", + "unconfirmed_steps": [] + } + ] + }, + "claim_match_checked": true + } + ], + "selected_page_provenance": [], + "model": "fake/main-model", + "tier": "main", + "verification_summary": [ + { + "solution_id": "solution-1", + "overall_coverage": "direct", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "direct", + "method_label": "모든 단계를 실행 검사로 확인", + "unconfirmed_steps": [] + } + ] + } + ], + "source_labels": [] + } + }, + "browser": { + "contractVersion": "brain-source-v1", + "runId": "run-7", + "seq": 9, + "occurredAt": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "responseDisposition": "substantive", + "assistantResponse": { + "responsePages": [ + { + "responsePageId": "page-1", + "presentation": "main", + "resultBlocks": [ + { + "blockId": "block-1", + "body": "양변에 같은 연산을 적용하는 이유를 설명합니다.", + "kind": "explanation", + "solutionId": "solution-1", + "claimIds": [ + "claim-99" + ] + } + ] + } + ], + "sourceReferences": [], + "plainText": "양변에 같은 연산을 적용하는 이유를 설명합니다." + }, + "model": "fake/main-model", + "tier": "main", + "verificationSummary": [ + { + "solutionId": "solution-1", + "overallCoverage": "direct", + "claims": [ + { + "claimId": "claim-1", + "coverage": "direct", + "methodLabel": "모든 단계를 실행 검사로 확인", + "unconfirmedSteps": [] + } + ] + } + ], + "assistantMessageId": "message-7" + } + }, + "source_outcome": "reject", + "browser_outcome": "reject" +} diff --git a/src/test/resources/contracts/brain/terminal-v2/invalid/existing-phantom.json b/src/test/resources/contracts/brain/terminal-v2/invalid/existing-phantom.json new file mode 100644 index 00000000..10a21508 --- /dev/null +++ b/src/test/resources/contracts/brain/terminal-v2/invalid/existing-phantom.json @@ -0,0 +1,123 @@ +{ + "source": { + "contract_version": "brain-source-v1", + "run_id": "run-7", + "attempt": 1, + "fence_token": "fence-9f8c", + "source_event_id": "run-7:terminal:r0:b4c05df04575822cec85", + "occurred_at": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "response_disposition": "substantive", + "assistant_response": { + "schema_version": "assistant-response-v1", + "response_pages": [ + { + "response_page_id": "page-1", + "presentation": "main", + "result_blocks": [ + { + "block_id": "block-1", + "body": "양변에 같은 연산을 적용하는 이유를 설명합니다.", + "kind": "explanation", + "solution_id": "solution-99", + "claim_ids": [ + "claim-1" + ] + } + ] + } + ], + "source_references": [] + }, + "verified_solutions": [], + "existing_solution_references": [ + { + "solution_id": "solution-1", + "version": 1, + "verification": { + "solution_id": "solution-1", + "overall_coverage": "direct", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "direct", + "method_label": "모든 단계를 실행 검사로 확인", + "unconfirmed_steps": [] + } + ] + }, + "claim_match_checked": true + } + ], + "selected_page_provenance": [], + "model": "fake/main-model", + "tier": "main", + "verification_summary": [ + { + "solution_id": "solution-1", + "overall_coverage": "direct", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "direct", + "method_label": "모든 단계를 실행 검사로 확인", + "unconfirmed_steps": [] + } + ] + } + ], + "source_labels": [] + } + }, + "browser": { + "contractVersion": "brain-source-v1", + "runId": "run-7", + "seq": 9, + "occurredAt": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "responseDisposition": "substantive", + "assistantResponse": { + "responsePages": [ + { + "responsePageId": "page-1", + "presentation": "main", + "resultBlocks": [ + { + "blockId": "block-1", + "body": "양변에 같은 연산을 적용하는 이유를 설명합니다.", + "kind": "explanation", + "solutionId": "solution-99", + "claimIds": [ + "claim-1" + ] + } + ] + } + ], + "sourceReferences": [], + "plainText": "양변에 같은 연산을 적용하는 이유를 설명합니다." + }, + "model": "fake/main-model", + "tier": "main", + "verificationSummary": [ + { + "solutionId": "solution-1", + "overallCoverage": "direct", + "claims": [ + { + "claimId": "claim-1", + "coverage": "direct", + "methodLabel": "모든 단계를 실행 검사로 확인", + "unconfirmedSteps": [] + } + ] + } + ], + "assistantMessageId": "message-7" + } + }, + "source_outcome": "reject", + "browser_outcome": "reject" +} diff --git a/src/test/resources/contracts/brain/terminal-v2/invalid/existing-unchecked.json b/src/test/resources/contracts/brain/terminal-v2/invalid/existing-unchecked.json new file mode 100644 index 00000000..315972f2 --- /dev/null +++ b/src/test/resources/contracts/brain/terminal-v2/invalid/existing-unchecked.json @@ -0,0 +1,171 @@ +{ + "source": { + "contract_version": "brain-source-v1", + "run_id": "run-7", + "attempt": 1, + "fence_token": "fence-9f8c", + "source_event_id": "run-7:terminal:r0:b4c05df04575822cec85", + "occurred_at": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "response_disposition": "substantive", + "assistant_response": { + "schema_version": "assistant-response-v1", + "response_pages": [ + { + "response_page_id": "page-1", + "presentation": "main", + "result_blocks": [ + { + "block_id": "block-1", + "body": "양변에 같은 연산을 적용하는 이유를 설명합니다.", + "kind": "explanation", + "solution_id": "solution-1", + "claim_ids": [ + "claim-1" + ] + } + ] + } + ], + "source_references": [] + }, + "verified_solutions": [], + "existing_solution_references": [ + { + "solution_id": "solution-1", + "version": 1, + "verification": { + "solution_id": "solution-1", + "overall_coverage": "direct", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "direct", + "method_label": "모든 단계를 실행 검사로 확인", + "unconfirmed_steps": [] + } + ] + }, + "claim_match_checked": false + } + ], + "selected_page_provenance": [], + "model": "fake/main-model", + "tier": "main", + "verification_summary": [ + { + "solution_id": "solution-1", + "overall_coverage": "direct", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "direct", + "method_label": "모든 단계를 실행 검사로 확인", + "unconfirmed_steps": [] + } + ] + } + ], + "source_labels": [] + } + }, + "browser": { + "contractVersion": "brain-source-v1", + "runId": "run-7", + "seq": 9, + "occurredAt": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "responseDisposition": "substantive", + "assistantResponse": { + "responsePages": [ + { + "responsePageId": "page-1", + "presentation": "main", + "resultBlocks": [ + { + "blockId": "block-1", + "body": "양변에 같은 연산을 적용하는 이유를 설명합니다.", + "kind": "explanation", + "solutionId": "solution-1", + "claimIds": [ + "claim-1" + ] + } + ] + } + ], + "sourceReferences": [], + "plainText": "양변에 같은 연산을 적용하는 이유를 설명합니다." + }, + "model": "fake/main-model", + "tier": "main", + "verificationSummary": [ + { + "solutionId": "solution-1", + "overallCoverage": "direct", + "claims": [ + { + "claimId": "claim-1", + "coverage": "direct", + "methodLabel": "모든 단계를 실행 검사로 확인", + "unconfirmedSteps": [] + } + ] + } + ], + "assistantMessageId": "message-7" + } + }, + "source_outcome": "reject", + "browser_outcome": "valid_public_projection", + "browser_expected": { + "contractVersion": "brain-source-v1", + "runId": "run-7", + "seq": 9, + "occurredAt": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "responseDisposition": "substantive", + "assistantResponse": { + "responsePages": [ + { + "responsePageId": "page-1", + "presentation": "main", + "resultBlocks": [ + { + "blockId": "block-1", + "body": "양변에 같은 연산을 적용하는 이유를 설명합니다.", + "kind": "explanation", + "solutionId": "solution-1", + "claimIds": [ + "claim-1" + ] + } + ] + } + ], + "sourceReferences": [], + "plainText": "양변에 같은 연산을 적용하는 이유를 설명합니다." + }, + "model": "fake/main-model", + "tier": "main", + "verificationSummary": [ + { + "solutionId": "solution-1", + "overallCoverage": "direct", + "claims": [ + { + "claimId": "claim-1", + "coverage": "direct", + "methodLabel": "모든 단계를 실행 검사로 확인", + "unconfirmedSteps": [] + } + ] + } + ], + "assistantMessageId": "message-7" + } + } +} diff --git a/src/test/resources/contracts/brain/terminal-v2/invalid/failure-with-answer.json b/src/test/resources/contracts/brain/terminal-v2/invalid/failure-with-answer.json new file mode 100644 index 00000000..22ba76ac --- /dev/null +++ b/src/test/resources/contracts/brain/terminal-v2/invalid/failure-with-answer.json @@ -0,0 +1,226 @@ +{ + "source": { + "contract_version": "brain-source-v1", + "run_id": "run-7", + "attempt": 1, + "fence_token": "fence-9f8c", + "source_event_id": "run-7:terminal:r0:b4c05df04575822cec85", + "occurred_at": "2026-10-01T09:00:00Z", + "type": "run.failed", + "payload": { + "response_disposition": "substantive", + "assistant_response": { + "schema_version": "assistant-response-v1", + "response_pages": [ + { + "response_page_id": "page-1", + "presentation": "main", + "title": "검증된 풀이", + "result_blocks": [ + { + "block_id": "block-1", + "body": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "kind": "solution", + "verification": "verified", + "solution_id": "solution-1", + "claim_ids": [ + "claim-1" + ], + "step_marks": [ + { + "step_id": "step-1", + "status": "verified", + "anchor": { + "start": 0, + "end": 24 + } + }, + { + "step_id": "step-2", + "status": "verified", + "anchor": { + "start": 25, + "end": 46 + } + } + ] + } + ] + } + ], + "source_references": [ + { + "source_ref_id": "sref-1", + "asset_id": "note-alpha", + "page_start": 3, + "page_end": 4 + } + ] + }, + "verified_solutions": [ + { + "schema_version": "verified-solution-v1", + "solution_id": "solution-1", + "version": 1, + "problem_statement": "주어진 명시 조건을 만족하는 결론을 구한다.", + "problem_conditions": [ + { + "condition_id": "condition-1", + "text": "문제의 명시 조건을 사용한다." + } + ], + "solution_text": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "claims": [ + { + "claim_id": "claim-1", + "statement": "명시 조건에서 제시한 결론이 성립한다." + } + ], + "step_marks": [ + { + "step_id": "step-1", + "status": "verified" + }, + { + "step_id": "step-2", + "status": "verified" + } + ], + "verification": { + "solution_id": "solution-1", + "overall_coverage": "direct", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "direct", + "method_label": "모든 단계를 실행 검사로 확인", + "unconfirmed_steps": [] + } + ] + }, + "verification_version": "chain-verification-report-v3", + "source_ref_ids": [ + "sref-1" + ] + } + ], + "selected_page_provenance": [ + { + "asset_id": "note-alpha", + "content_version": "content-v1", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "page": 3, + "understanding_version": "page-understanding-v1" + }, + { + "asset_id": "note-alpha", + "content_version": "content-v1", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "page": 4, + "understanding_version": "page-understanding-v1" + } + ], + "model": "fake/main-model", + "tier": "main", + "verification_summary": [ + { + "solution_id": "solution-1", + "overall_coverage": "direct", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "direct", + "method_label": "모든 단계를 실행 검사로 확인", + "unconfirmed_steps": [] + } + ] + } + ], + "source_labels": [ + { + "source_ref_id": "sref-1", + "label": "개념 노트 3–4쪽" + } + ] + } + }, + "browser": { + "contractVersion": "brain-source-v1", + "runId": "run-7", + "seq": 9, + "occurredAt": "2026-10-01T09:00:00Z", + "type": "run.failed", + "payload": { + "responseDisposition": "substantive", + "assistantResponse": { + "responsePages": [ + { + "responsePageId": "page-1", + "presentation": "main", + "title": "검증된 풀이", + "resultBlocks": [ + { + "blockId": "block-1", + "body": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "kind": "solution", + "verification": "verified", + "solutionId": "solution-1", + "claimIds": [ + "claim-1" + ], + "stepMarks": [ + { + "stepId": "step-1", + "status": "verified", + "anchor": { + "start": 0, + "end": 24 + } + }, + { + "stepId": "step-2", + "status": "verified", + "anchor": { + "start": 25, + "end": 46 + } + } + ] + } + ] + } + ], + "sourceReferences": [ + { + "sourceRefId": "sref-1", + "assetId": "note-alpha", + "pageStart": 3, + "pageEnd": 4, + "displayLabelSnapshot": "개념 노트 3–4쪽", + "available": true + } + ], + "plainText": "검증된 풀이\n\n인수분해하면 (x-2)(x-3)=0이다. 따라서 x=2 또는 x=3이다." + }, + "model": "fake/main-model", + "tier": "main", + "verificationSummary": [ + { + "solutionId": "solution-1", + "overallCoverage": "direct", + "claims": [ + { + "claimId": "claim-1", + "coverage": "direct", + "methodLabel": "모든 단계를 실행 검사로 확인", + "unconfirmedSteps": [] + } + ] + } + ], + "assistantMessageId": "message-7" + } + }, + "source_outcome": "reject", + "browser_outcome": "reject" +} diff --git a/src/test/resources/contracts/brain/terminal-v2/invalid/guidance-with-sources.json b/src/test/resources/contracts/brain/terminal-v2/invalid/guidance-with-sources.json new file mode 100644 index 00000000..213140e3 --- /dev/null +++ b/src/test/resources/contracts/brain/terminal-v2/invalid/guidance-with-sources.json @@ -0,0 +1,88 @@ +{ + "source": { + "contract_version": "brain-source-v1", + "run_id": "run-7", + "attempt": 1, + "fence_token": "fence-9f8c", + "source_event_id": "run-7:terminal:r0:b4c05df04575822cec85", + "occurred_at": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "response_disposition": "clarification_only", + "clarification_reason": "ambiguous_target", + "assistant_response": { + "schema_version": "assistant-response-v1", + "response_pages": [ + { + "response_page_id": "page-1", + "presentation": "main", + "result_blocks": [ + { + "block_id": "block-1", + "body": "어떤 문제의 풀이를 원하는지 알려 주세요.", + "kind": "rich_text" + } + ] + } + ], + "source_references": [ + { + "source_ref_id": "sref-1", + "asset_id": "note-alpha", + "page_start": 3, + "page_end": 4 + } + ] + }, + "verified_solutions": [], + "selected_page_provenance": [], + "model": "fake/main-model", + "tier": "main", + "verification_summary": [], + "source_labels": [] + } + }, + "browser": { + "contractVersion": "brain-source-v1", + "runId": "run-7", + "seq": 9, + "occurredAt": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "responseDisposition": "clarification_only", + "clarificationReason": "ambiguous_target", + "assistantResponse": { + "responsePages": [ + { + "responsePageId": "page-1", + "presentation": "main", + "resultBlocks": [ + { + "blockId": "block-1", + "body": "어떤 문제의 풀이를 원하는지 알려 주세요.", + "kind": "rich_text" + } + ] + } + ], + "sourceReferences": [ + { + "sourceRefId": "sref-1", + "assetId": "note-alpha", + "pageStart": 3, + "pageEnd": 4, + "displayLabelSnapshot": "개념 노트 3–4쪽", + "available": true + } + ], + "plainText": "어떤 문제의 풀이를 원하는지 알려 주세요." + }, + "model": "fake/main-model", + "tier": "main", + "verificationSummary": [], + "assistantMessageId": "message-7" + } + }, + "source_outcome": "reject", + "browser_outcome": "reject" +} diff --git a/src/test/resources/contracts/brain/terminal-v2/invalid/kind-type-mismatch.json b/src/test/resources/contracts/brain/terminal-v2/invalid/kind-type-mismatch.json new file mode 100644 index 00000000..a7b0e07e --- /dev/null +++ b/src/test/resources/contracts/brain/terminal-v2/invalid/kind-type-mismatch.json @@ -0,0 +1,226 @@ +{ + "source": { + "contract_version": "brain-source-v1", + "run_id": "run-7", + "attempt": 1, + "fence_token": "fence-9f8c", + "source_event_id": "run-7:terminal:r0:b4c05df04575822cec85", + "occurred_at": "2026-10-01T09:00:00Z", + "type": "activity.updated", + "payload": { + "response_disposition": "substantive", + "assistant_response": { + "schema_version": "assistant-response-v1", + "response_pages": [ + { + "response_page_id": "page-1", + "presentation": "main", + "title": "검증된 풀이", + "result_blocks": [ + { + "block_id": "block-1", + "body": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "kind": "solution", + "verification": "verified", + "solution_id": "solution-1", + "claim_ids": [ + "claim-1" + ], + "step_marks": [ + { + "step_id": "step-1", + "status": "verified", + "anchor": { + "start": 0, + "end": 24 + } + }, + { + "step_id": "step-2", + "status": "verified", + "anchor": { + "start": 25, + "end": 46 + } + } + ] + } + ] + } + ], + "source_references": [ + { + "source_ref_id": "sref-1", + "asset_id": "note-alpha", + "page_start": 3, + "page_end": 4 + } + ] + }, + "verified_solutions": [ + { + "schema_version": "verified-solution-v1", + "solution_id": "solution-1", + "version": 1, + "problem_statement": "주어진 명시 조건을 만족하는 결론을 구한다.", + "problem_conditions": [ + { + "condition_id": "condition-1", + "text": "문제의 명시 조건을 사용한다." + } + ], + "solution_text": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "claims": [ + { + "claim_id": "claim-1", + "statement": "명시 조건에서 제시한 결론이 성립한다." + } + ], + "step_marks": [ + { + "step_id": "step-1", + "status": "verified" + }, + { + "step_id": "step-2", + "status": "verified" + } + ], + "verification": { + "solution_id": "solution-1", + "overall_coverage": "direct", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "direct", + "method_label": "모든 단계를 실행 검사로 확인", + "unconfirmed_steps": [] + } + ] + }, + "verification_version": "chain-verification-report-v3", + "source_ref_ids": [ + "sref-1" + ] + } + ], + "selected_page_provenance": [ + { + "asset_id": "note-alpha", + "content_version": "content-v1", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "page": 3, + "understanding_version": "page-understanding-v1" + }, + { + "asset_id": "note-alpha", + "content_version": "content-v1", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "page": 4, + "understanding_version": "page-understanding-v1" + } + ], + "model": "fake/main-model", + "tier": "main", + "verification_summary": [ + { + "solution_id": "solution-1", + "overall_coverage": "direct", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "direct", + "method_label": "모든 단계를 실행 검사로 확인", + "unconfirmed_steps": [] + } + ] + } + ], + "source_labels": [ + { + "source_ref_id": "sref-1", + "label": "개념 노트 3–4쪽" + } + ] + } + }, + "browser": { + "contractVersion": "brain-source-v1", + "runId": "run-7", + "seq": 9, + "occurredAt": "2026-10-01T09:00:00Z", + "type": "activity.updated", + "payload": { + "responseDisposition": "substantive", + "assistantResponse": { + "responsePages": [ + { + "responsePageId": "page-1", + "presentation": "main", + "title": "검증된 풀이", + "resultBlocks": [ + { + "blockId": "block-1", + "body": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "kind": "solution", + "verification": "verified", + "solutionId": "solution-1", + "claimIds": [ + "claim-1" + ], + "stepMarks": [ + { + "stepId": "step-1", + "status": "verified", + "anchor": { + "start": 0, + "end": 24 + } + }, + { + "stepId": "step-2", + "status": "verified", + "anchor": { + "start": 25, + "end": 46 + } + } + ] + } + ] + } + ], + "sourceReferences": [ + { + "sourceRefId": "sref-1", + "assetId": "note-alpha", + "pageStart": 3, + "pageEnd": 4, + "displayLabelSnapshot": "개념 노트 3–4쪽", + "available": true + } + ], + "plainText": "검증된 풀이\n\n인수분해하면 (x-2)(x-3)=0이다. 따라서 x=2 또는 x=3이다." + }, + "model": "fake/main-model", + "tier": "main", + "verificationSummary": [ + { + "solutionId": "solution-1", + "overallCoverage": "direct", + "claims": [ + { + "claimId": "claim-1", + "coverage": "direct", + "methodLabel": "모든 단계를 실행 검사로 확인", + "unconfirmedSteps": [] + } + ] + } + ], + "assistantMessageId": "message-7" + } + }, + "source_outcome": "reject", + "browser_outcome": "reject" +} diff --git a/src/test/resources/contracts/brain/terminal-v2/invalid/metadata.json b/src/test/resources/contracts/brain/terminal-v2/invalid/metadata.json new file mode 100644 index 00000000..8d684931 --- /dev/null +++ b/src/test/resources/contracts/brain/terminal-v2/invalid/metadata.json @@ -0,0 +1,305 @@ +{ + "source": { + "contract_version": "brain-source-v1", + "run_id": "run-7", + "attempt": 1, + "fence_token": "fence-9f8c", + "source_event_id": "run-7:terminal:r0:b4c05df04575822cec85", + "occurred_at": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "response_disposition": "substantive", + "assistant_response": { + "schema_version": "assistant-response-v1", + "response_pages": [ + { + "response_page_id": "page-1", + "presentation": "main", + "title": "검증된 풀이", + "result_blocks": [ + { + "block_id": "block-1", + "body": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "kind": "solution", + "verification": "verified", + "solution_id": "solution-1", + "claim_ids": [ + "claim-1" + ], + "step_marks": [ + { + "step_id": "step-1", + "status": "verified", + "anchor": { + "start": 0, + "end": 24 + } + }, + { + "step_id": "step-2", + "status": "verified", + "anchor": { + "start": 25, + "end": 46 + } + } + ] + } + ] + } + ], + "source_references": [ + { + "source_ref_id": "sref-1", + "asset_id": "note-alpha", + "page_start": 3, + "page_end": 4 + } + ] + }, + "verified_solutions": [ + { + "schema_version": "verified-solution-v1", + "solution_id": "solution-1", + "version": 1, + "problem_statement": "주어진 명시 조건을 만족하는 결론을 구한다.", + "problem_conditions": [ + { + "condition_id": "condition-1", + "text": "문제의 명시 조건을 사용한다." + } + ], + "solution_text": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "claims": [ + { + "claim_id": "claim-1", + "statement": "명시 조건에서 제시한 결론이 성립한다." + } + ], + "step_marks": [ + { + "step_id": "step-1", + "status": "verified" + }, + { + "step_id": "step-2", + "status": "verified" + } + ], + "verification": { + "solution_id": "solution-1", + "overall_coverage": "direct", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "direct", + "method_label": "모든 단계를 실행 검사로 확인", + "unconfirmed_steps": [] + } + ] + }, + "verification_version": "chain-verification-report-v3", + "source_ref_ids": [ + "sref-1" + ] + } + ], + "selected_page_provenance": [ + { + "asset_id": "note-alpha", + "content_version": "content-v1", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "page": 3, + "understanding_version": "page-understanding-v1" + }, + { + "asset_id": "note-alpha", + "content_version": "content-v1", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "page": 4, + "understanding_version": "page-understanding-v1" + } + ], + "model": "fake/main-model", + "tier": "main", + "verification_summary": [ + { + "solution_id": "solution-1", + "overall_coverage": "direct", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "direct", + "method_label": "모든 단계를 실행 검사로 확인", + "unconfirmed_steps": [] + } + ] + } + ], + "source_labels": [ + { + "source_ref_id": "sref-1", + "label": "개념 노트 3–4쪽" + } + ], + "metadata": "DO-NOT-LEAK-raw-payload" + } + }, + "browser": { + "contractVersion": "brain-source-v1", + "runId": "run-7", + "seq": 9, + "occurredAt": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "responseDisposition": "substantive", + "assistantResponse": { + "responsePages": [ + { + "responsePageId": "page-1", + "presentation": "main", + "title": "검증된 풀이", + "resultBlocks": [ + { + "blockId": "block-1", + "body": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "kind": "solution", + "verification": "verified", + "solutionId": "solution-1", + "claimIds": [ + "claim-1" + ], + "stepMarks": [ + { + "stepId": "step-1", + "status": "verified", + "anchor": { + "start": 0, + "end": 24 + } + }, + { + "stepId": "step-2", + "status": "verified", + "anchor": { + "start": 25, + "end": 46 + } + } + ] + } + ] + } + ], + "sourceReferences": [ + { + "sourceRefId": "sref-1", + "assetId": "note-alpha", + "pageStart": 3, + "pageEnd": 4, + "displayLabelSnapshot": "개념 노트 3–4쪽", + "available": true + } + ], + "plainText": "검증된 풀이\n\n인수분해하면 (x-2)(x-3)=0이다. 따라서 x=2 또는 x=3이다." + }, + "model": "fake/main-model", + "tier": "main", + "verificationSummary": [ + { + "solutionId": "solution-1", + "overallCoverage": "direct", + "claims": [ + { + "claimId": "claim-1", + "coverage": "direct", + "methodLabel": "모든 단계를 실행 검사로 확인", + "unconfirmedSteps": [] + } + ] + } + ], + "assistantMessageId": "message-7", + "metadata": "DO-NOT-LEAK-raw-payload" + } + }, + "source_outcome": "reject", + "browser_outcome": "ignore_additive", + "browser_expected": { + "contractVersion": "brain-source-v1", + "runId": "run-7", + "seq": 9, + "occurredAt": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "responseDisposition": "substantive", + "assistantResponse": { + "responsePages": [ + { + "responsePageId": "page-1", + "presentation": "main", + "title": "검증된 풀이", + "resultBlocks": [ + { + "blockId": "block-1", + "body": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "kind": "solution", + "verification": "verified", + "solutionId": "solution-1", + "claimIds": [ + "claim-1" + ], + "stepMarks": [ + { + "stepId": "step-1", + "status": "verified", + "anchor": { + "start": 0, + "end": 24 + } + }, + { + "stepId": "step-2", + "status": "verified", + "anchor": { + "start": 25, + "end": 46 + } + } + ] + } + ] + } + ], + "sourceReferences": [ + { + "sourceRefId": "sref-1", + "assetId": "note-alpha", + "pageStart": 3, + "pageEnd": 4, + "displayLabelSnapshot": "개념 노트 3–4쪽", + "available": true + } + ], + "plainText": "검증된 풀이\n\n인수분해하면 (x-2)(x-3)=0이다. 따라서 x=2 또는 x=3이다." + }, + "model": "fake/main-model", + "tier": "main", + "verificationSummary": [ + { + "solutionId": "solution-1", + "overallCoverage": "direct", + "claims": [ + { + "claimId": "claim-1", + "coverage": "direct", + "methodLabel": "모든 단계를 실행 검사로 확인", + "unconfirmedSteps": [] + } + ] + } + ], + "assistantMessageId": "message-7" + } + } +} diff --git a/src/test/resources/contracts/brain/terminal-v2/invalid/missing-activity-kind.json b/src/test/resources/contracts/brain/terminal-v2/invalid/missing-activity-kind.json new file mode 100644 index 00000000..c14b33aa --- /dev/null +++ b/src/test/resources/contracts/brain/terminal-v2/invalid/missing-activity-kind.json @@ -0,0 +1,31 @@ +{ + "source": { + "contract_version": "brain-source-v1", + "run_id": "run-7", + "attempt": 1, + "fence_token": "fence-9f8c", + "source_event_id": "run-7:activity-1:r0:started", + "occurred_at": "2026-10-01T09:00:00Z", + "type": "activity.started", + "payload": { + "activity_id": "activity-1", + "label": "analyzing_input", + "response_page_id": "page-1" + } + }, + "browser": { + "contractVersion": "brain-source-v1", + "runId": "run-7", + "occurredAt": "2026-10-01T09:00:00Z", + "type": "activity.started", + "payload": { + "activityId": "activity-1", + "responsePageId": "page-1", + "label": "analyzing_input" + }, + "seq": 1 + }, + "source_outcome": "reject", + "browser_outcome": "reject", + "schema_outcome": "reject" +} diff --git a/src/test/resources/contracts/brain/terminal-v2/invalid/missing-block-kind.json b/src/test/resources/contracts/brain/terminal-v2/invalid/missing-block-kind.json new file mode 100644 index 00000000..1ed220d8 --- /dev/null +++ b/src/test/resources/contracts/brain/terminal-v2/invalid/missing-block-kind.json @@ -0,0 +1,225 @@ +{ + "source": { + "contract_version": "brain-source-v1", + "run_id": "run-7", + "attempt": 1, + "fence_token": "fence-9f8c", + "source_event_id": "run-7:terminal:r0:b4c05df04575822cec85", + "occurred_at": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "response_disposition": "substantive", + "assistant_response": { + "schema_version": "assistant-response-v1", + "response_pages": [ + { + "response_page_id": "page-1", + "presentation": "main", + "title": "검증된 풀이", + "result_blocks": [ + { + "block_id": "block-1", + "body": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "verification": "verified", + "solution_id": "solution-1", + "claim_ids": [ + "claim-1" + ], + "step_marks": [ + { + "step_id": "step-1", + "status": "verified", + "anchor": { + "start": 0, + "end": 24 + } + }, + { + "step_id": "step-2", + "status": "verified", + "anchor": { + "start": 25, + "end": 46 + } + } + ] + } + ] + } + ], + "source_references": [ + { + "source_ref_id": "sref-1", + "asset_id": "note-alpha", + "page_start": 3, + "page_end": 4 + } + ] + }, + "verified_solutions": [ + { + "schema_version": "verified-solution-v1", + "solution_id": "solution-1", + "version": 1, + "problem_statement": "주어진 명시 조건을 만족하는 결론을 구한다.", + "problem_conditions": [ + { + "condition_id": "condition-1", + "text": "문제의 명시 조건을 사용한다." + } + ], + "solution_text": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "claims": [ + { + "claim_id": "claim-1", + "statement": "명시 조건에서 제시한 결론이 성립한다." + } + ], + "step_marks": [ + { + "step_id": "step-1", + "status": "verified" + }, + { + "step_id": "step-2", + "status": "verified" + } + ], + "verification": { + "solution_id": "solution-1", + "overall_coverage": "direct", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "direct", + "method_label": "모든 단계를 실행 검사로 확인", + "unconfirmed_steps": [] + } + ] + }, + "verification_version": "chain-verification-report-v3", + "source_ref_ids": [ + "sref-1" + ] + } + ], + "selected_page_provenance": [ + { + "asset_id": "note-alpha", + "content_version": "content-v1", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "page": 3, + "understanding_version": "page-understanding-v1" + }, + { + "asset_id": "note-alpha", + "content_version": "content-v1", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "page": 4, + "understanding_version": "page-understanding-v1" + } + ], + "model": "fake/main-model", + "tier": "main", + "verification_summary": [ + { + "solution_id": "solution-1", + "overall_coverage": "direct", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "direct", + "method_label": "모든 단계를 실행 검사로 확인", + "unconfirmed_steps": [] + } + ] + } + ], + "source_labels": [ + { + "source_ref_id": "sref-1", + "label": "개념 노트 3–4쪽" + } + ] + } + }, + "browser": { + "contractVersion": "brain-source-v1", + "runId": "run-7", + "seq": 9, + "occurredAt": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "responseDisposition": "substantive", + "assistantResponse": { + "responsePages": [ + { + "responsePageId": "page-1", + "presentation": "main", + "title": "검증된 풀이", + "resultBlocks": [ + { + "blockId": "block-1", + "body": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "verification": "verified", + "solutionId": "solution-1", + "claimIds": [ + "claim-1" + ], + "stepMarks": [ + { + "stepId": "step-1", + "status": "verified", + "anchor": { + "start": 0, + "end": 24 + } + }, + { + "stepId": "step-2", + "status": "verified", + "anchor": { + "start": 25, + "end": 46 + } + } + ] + } + ] + } + ], + "sourceReferences": [ + { + "sourceRefId": "sref-1", + "assetId": "note-alpha", + "pageStart": 3, + "pageEnd": 4, + "displayLabelSnapshot": "개념 노트 3–4쪽", + "available": true + } + ], + "plainText": "검증된 풀이\n\n인수분해하면 (x-2)(x-3)=0이다. 따라서 x=2 또는 x=3이다." + }, + "model": "fake/main-model", + "tier": "main", + "verificationSummary": [ + { + "solutionId": "solution-1", + "overallCoverage": "direct", + "claims": [ + { + "claimId": "claim-1", + "coverage": "direct", + "methodLabel": "모든 단계를 실행 검사로 확인", + "unconfirmedSteps": [] + } + ] + } + ], + "assistantMessageId": "message-7" + } + }, + "source_outcome": "reject", + "browser_outcome": "reject", + "schema_outcome": "reject" +} diff --git a/src/test/resources/contracts/brain/terminal-v2/invalid/missing-selected-page.json b/src/test/resources/contracts/brain/terminal-v2/invalid/missing-selected-page.json new file mode 100644 index 00000000..a2f45ac1 --- /dev/null +++ b/src/test/resources/contracts/brain/terminal-v2/invalid/missing-selected-page.json @@ -0,0 +1,296 @@ +{ + "source": { + "contract_version": "brain-source-v1", + "run_id": "run-7", + "attempt": 1, + "fence_token": "fence-9f8c", + "source_event_id": "run-7:terminal:r0:b4c05df04575822cec85", + "occurred_at": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "response_disposition": "substantive", + "assistant_response": { + "schema_version": "assistant-response-v1", + "response_pages": [ + { + "response_page_id": "page-1", + "presentation": "main", + "title": "검증된 풀이", + "result_blocks": [ + { + "block_id": "block-1", + "body": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "kind": "solution", + "verification": "verified", + "solution_id": "solution-1", + "claim_ids": [ + "claim-1" + ], + "step_marks": [ + { + "step_id": "step-1", + "status": "verified", + "anchor": { + "start": 0, + "end": 24 + } + }, + { + "step_id": "step-2", + "status": "verified", + "anchor": { + "start": 25, + "end": 46 + } + } + ] + } + ] + } + ], + "source_references": [ + { + "source_ref_id": "sref-1", + "asset_id": "note-alpha", + "page_start": 3, + "page_end": 4 + } + ] + }, + "verified_solutions": [ + { + "schema_version": "verified-solution-v1", + "solution_id": "solution-1", + "version": 1, + "problem_statement": "주어진 명시 조건을 만족하는 결론을 구한다.", + "problem_conditions": [ + { + "condition_id": "condition-1", + "text": "문제의 명시 조건을 사용한다." + } + ], + "solution_text": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "claims": [ + { + "claim_id": "claim-1", + "statement": "명시 조건에서 제시한 결론이 성립한다." + } + ], + "step_marks": [ + { + "step_id": "step-1", + "status": "verified" + }, + { + "step_id": "step-2", + "status": "verified" + } + ], + "verification": { + "solution_id": "solution-1", + "overall_coverage": "direct", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "direct", + "method_label": "모든 단계를 실행 검사로 확인", + "unconfirmed_steps": [] + } + ] + }, + "verification_version": "chain-verification-report-v3", + "source_ref_ids": [ + "sref-1" + ] + } + ], + "selected_page_provenance": [ + { + "asset_id": "note-alpha", + "content_version": "content-v1", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "page": 3, + "understanding_version": "page-understanding-v1" + } + ], + "model": "fake/main-model", + "tier": "main", + "verification_summary": [ + { + "solution_id": "solution-1", + "overall_coverage": "direct", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "direct", + "method_label": "모든 단계를 실행 검사로 확인", + "unconfirmed_steps": [] + } + ] + } + ], + "source_labels": [ + { + "source_ref_id": "sref-1", + "label": "개념 노트 3–4쪽" + } + ] + } + }, + "browser": { + "contractVersion": "brain-source-v1", + "runId": "run-7", + "seq": 9, + "occurredAt": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "responseDisposition": "substantive", + "assistantResponse": { + "responsePages": [ + { + "responsePageId": "page-1", + "presentation": "main", + "title": "검증된 풀이", + "resultBlocks": [ + { + "blockId": "block-1", + "body": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "kind": "solution", + "verification": "verified", + "solutionId": "solution-1", + "claimIds": [ + "claim-1" + ], + "stepMarks": [ + { + "stepId": "step-1", + "status": "verified", + "anchor": { + "start": 0, + "end": 24 + } + }, + { + "stepId": "step-2", + "status": "verified", + "anchor": { + "start": 25, + "end": 46 + } + } + ] + } + ] + } + ], + "sourceReferences": [ + { + "sourceRefId": "sref-1", + "assetId": "note-alpha", + "pageStart": 3, + "pageEnd": 4, + "displayLabelSnapshot": "개념 노트 3–4쪽", + "available": true + } + ], + "plainText": "검증된 풀이\n\n인수분해하면 (x-2)(x-3)=0이다. 따라서 x=2 또는 x=3이다." + }, + "model": "fake/main-model", + "tier": "main", + "verificationSummary": [ + { + "solutionId": "solution-1", + "overallCoverage": "direct", + "claims": [ + { + "claimId": "claim-1", + "coverage": "direct", + "methodLabel": "모든 단계를 실행 검사로 확인", + "unconfirmedSteps": [] + } + ] + } + ], + "assistantMessageId": "message-7" + } + }, + "source_outcome": "reject", + "browser_outcome": "valid_public_projection", + "browser_expected": { + "contractVersion": "brain-source-v1", + "runId": "run-7", + "seq": 9, + "occurredAt": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "responseDisposition": "substantive", + "assistantResponse": { + "responsePages": [ + { + "responsePageId": "page-1", + "presentation": "main", + "title": "검증된 풀이", + "resultBlocks": [ + { + "blockId": "block-1", + "body": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "kind": "solution", + "verification": "verified", + "solutionId": "solution-1", + "claimIds": [ + "claim-1" + ], + "stepMarks": [ + { + "stepId": "step-1", + "status": "verified", + "anchor": { + "start": 0, + "end": 24 + } + }, + { + "stepId": "step-2", + "status": "verified", + "anchor": { + "start": 25, + "end": 46 + } + } + ] + } + ] + } + ], + "sourceReferences": [ + { + "sourceRefId": "sref-1", + "assetId": "note-alpha", + "pageStart": 3, + "pageEnd": 4, + "displayLabelSnapshot": "개념 노트 3–4쪽", + "available": true + } + ], + "plainText": "검증된 풀이\n\n인수분해하면 (x-2)(x-3)=0이다. 따라서 x=2 또는 x=3이다." + }, + "model": "fake/main-model", + "tier": "main", + "verificationSummary": [ + { + "solutionId": "solution-1", + "overallCoverage": "direct", + "claims": [ + { + "claimId": "claim-1", + "coverage": "direct", + "methodLabel": "모든 단계를 실행 검사로 확인", + "unconfirmedSteps": [] + } + ] + } + ], + "assistantMessageId": "message-7" + } + } +} diff --git a/src/test/resources/contracts/brain/terminal-v2/invalid/overclaimed-coverage.json b/src/test/resources/contracts/brain/terminal-v2/invalid/overclaimed-coverage.json new file mode 100644 index 00000000..a34b04a9 --- /dev/null +++ b/src/test/resources/contracts/brain/terminal-v2/invalid/overclaimed-coverage.json @@ -0,0 +1,226 @@ +{ + "source": { + "contract_version": "brain-source-v1", + "run_id": "run-7", + "attempt": 1, + "fence_token": "fence-9f8c", + "source_event_id": "run-7:terminal:r0:b4c05df04575822cec85", + "occurred_at": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "response_disposition": "substantive", + "assistant_response": { + "schema_version": "assistant-response-v1", + "response_pages": [ + { + "response_page_id": "page-1", + "presentation": "main", + "title": "검증된 풀이", + "result_blocks": [ + { + "block_id": "block-1", + "body": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "kind": "solution", + "verification": "partially_verified", + "solution_id": "solution-1", + "claim_ids": [ + "claim-1" + ], + "step_marks": [ + { + "step_id": "step-1", + "status": "verified", + "anchor": { + "start": 0, + "end": 24 + } + }, + { + "step_id": "step-2", + "status": "verified", + "anchor": { + "start": 25, + "end": 46 + } + } + ] + } + ] + } + ], + "source_references": [ + { + "source_ref_id": "sref-1", + "asset_id": "note-alpha", + "page_start": 3, + "page_end": 4 + } + ] + }, + "verified_solutions": [ + { + "schema_version": "verified-solution-v1", + "solution_id": "solution-1", + "version": 1, + "problem_statement": "주어진 명시 조건을 만족하는 결론을 구한다.", + "problem_conditions": [ + { + "condition_id": "condition-1", + "text": "문제의 명시 조건을 사용한다." + } + ], + "solution_text": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "claims": [ + { + "claim_id": "claim-1", + "statement": "명시 조건에서 제시한 결론이 성립한다." + } + ], + "step_marks": [ + { + "step_id": "step-1", + "status": "verified" + }, + { + "step_id": "step-2", + "status": "verified" + } + ], + "verification": { + "solution_id": "solution-1", + "overall_coverage": "direct", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "direct", + "method_label": "모든 단계를 실행 검사로 확인", + "unconfirmed_steps": [] + } + ] + }, + "verification_version": "chain-verification-report-v3", + "source_ref_ids": [ + "sref-1" + ] + } + ], + "selected_page_provenance": [ + { + "asset_id": "note-alpha", + "content_version": "content-v1", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "page": 3, + "understanding_version": "page-understanding-v1" + }, + { + "asset_id": "note-alpha", + "content_version": "content-v1", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "page": 4, + "understanding_version": "page-understanding-v1" + } + ], + "model": "fake/main-model", + "tier": "main", + "verification_summary": [ + { + "solution_id": "solution-1", + "overall_coverage": "direct", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "direct", + "method_label": "모든 단계를 실행 검사로 확인", + "unconfirmed_steps": [] + } + ] + } + ], + "source_labels": [ + { + "source_ref_id": "sref-1", + "label": "개념 노트 3–4쪽" + } + ] + } + }, + "browser": { + "contractVersion": "brain-source-v1", + "runId": "run-7", + "seq": 9, + "occurredAt": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "responseDisposition": "substantive", + "assistantResponse": { + "responsePages": [ + { + "responsePageId": "page-1", + "presentation": "main", + "title": "검증된 풀이", + "resultBlocks": [ + { + "blockId": "block-1", + "body": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "kind": "solution", + "verification": "partially_verified", + "solutionId": "solution-1", + "claimIds": [ + "claim-1" + ], + "stepMarks": [ + { + "stepId": "step-1", + "status": "verified", + "anchor": { + "start": 0, + "end": 24 + } + }, + { + "stepId": "step-2", + "status": "verified", + "anchor": { + "start": 25, + "end": 46 + } + } + ] + } + ] + } + ], + "sourceReferences": [ + { + "sourceRefId": "sref-1", + "assetId": "note-alpha", + "pageStart": 3, + "pageEnd": 4, + "displayLabelSnapshot": "개념 노트 3–4쪽", + "available": true + } + ], + "plainText": "검증된 풀이\n\n인수분해하면 (x-2)(x-3)=0이다. 따라서 x=2 또는 x=3이다." + }, + "model": "fake/main-model", + "tier": "main", + "verificationSummary": [ + { + "solutionId": "solution-1", + "overallCoverage": "direct", + "claims": [ + { + "claimId": "claim-1", + "coverage": "direct", + "methodLabel": "모든 단계를 실행 검사로 확인", + "unconfirmedSteps": [] + } + ] + } + ], + "assistantMessageId": "message-7" + } + }, + "source_outcome": "reject", + "browser_outcome": "reject" +} diff --git a/src/test/resources/contracts/brain/terminal-v2/invalid/phantom-claim.json b/src/test/resources/contracts/brain/terminal-v2/invalid/phantom-claim.json new file mode 100644 index 00000000..0abd4f81 --- /dev/null +++ b/src/test/resources/contracts/brain/terminal-v2/invalid/phantom-claim.json @@ -0,0 +1,226 @@ +{ + "source": { + "contract_version": "brain-source-v1", + "run_id": "run-7", + "attempt": 1, + "fence_token": "fence-9f8c", + "source_event_id": "run-7:terminal:r0:b4c05df04575822cec85", + "occurred_at": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "response_disposition": "substantive", + "assistant_response": { + "schema_version": "assistant-response-v1", + "response_pages": [ + { + "response_page_id": "page-1", + "presentation": "main", + "title": "검증된 풀이", + "result_blocks": [ + { + "block_id": "block-1", + "body": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "kind": "solution", + "verification": "verified", + "solution_id": "solution-1", + "claim_ids": [ + "claim-99" + ], + "step_marks": [ + { + "step_id": "step-1", + "status": "verified", + "anchor": { + "start": 0, + "end": 24 + } + }, + { + "step_id": "step-2", + "status": "verified", + "anchor": { + "start": 25, + "end": 46 + } + } + ] + } + ] + } + ], + "source_references": [ + { + "source_ref_id": "sref-1", + "asset_id": "note-alpha", + "page_start": 3, + "page_end": 4 + } + ] + }, + "verified_solutions": [ + { + "schema_version": "verified-solution-v1", + "solution_id": "solution-1", + "version": 1, + "problem_statement": "주어진 명시 조건을 만족하는 결론을 구한다.", + "problem_conditions": [ + { + "condition_id": "condition-1", + "text": "문제의 명시 조건을 사용한다." + } + ], + "solution_text": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "claims": [ + { + "claim_id": "claim-1", + "statement": "명시 조건에서 제시한 결론이 성립한다." + } + ], + "step_marks": [ + { + "step_id": "step-1", + "status": "verified" + }, + { + "step_id": "step-2", + "status": "verified" + } + ], + "verification": { + "solution_id": "solution-1", + "overall_coverage": "direct", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "direct", + "method_label": "모든 단계를 실행 검사로 확인", + "unconfirmed_steps": [] + } + ] + }, + "verification_version": "chain-verification-report-v3", + "source_ref_ids": [ + "sref-1" + ] + } + ], + "selected_page_provenance": [ + { + "asset_id": "note-alpha", + "content_version": "content-v1", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "page": 3, + "understanding_version": "page-understanding-v1" + }, + { + "asset_id": "note-alpha", + "content_version": "content-v1", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "page": 4, + "understanding_version": "page-understanding-v1" + } + ], + "model": "fake/main-model", + "tier": "main", + "verification_summary": [ + { + "solution_id": "solution-1", + "overall_coverage": "direct", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "direct", + "method_label": "모든 단계를 실행 검사로 확인", + "unconfirmed_steps": [] + } + ] + } + ], + "source_labels": [ + { + "source_ref_id": "sref-1", + "label": "개념 노트 3–4쪽" + } + ] + } + }, + "browser": { + "contractVersion": "brain-source-v1", + "runId": "run-7", + "seq": 9, + "occurredAt": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "responseDisposition": "substantive", + "assistantResponse": { + "responsePages": [ + { + "responsePageId": "page-1", + "presentation": "main", + "title": "검증된 풀이", + "resultBlocks": [ + { + "blockId": "block-1", + "body": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "kind": "solution", + "verification": "verified", + "solutionId": "solution-1", + "claimIds": [ + "claim-99" + ], + "stepMarks": [ + { + "stepId": "step-1", + "status": "verified", + "anchor": { + "start": 0, + "end": 24 + } + }, + { + "stepId": "step-2", + "status": "verified", + "anchor": { + "start": 25, + "end": 46 + } + } + ] + } + ] + } + ], + "sourceReferences": [ + { + "sourceRefId": "sref-1", + "assetId": "note-alpha", + "pageStart": 3, + "pageEnd": 4, + "displayLabelSnapshot": "개념 노트 3–4쪽", + "available": true + } + ], + "plainText": "검증된 풀이\n\n인수분해하면 (x-2)(x-3)=0이다. 따라서 x=2 또는 x=3이다." + }, + "model": "fake/main-model", + "tier": "main", + "verificationSummary": [ + { + "solutionId": "solution-1", + "overallCoverage": "direct", + "claims": [ + { + "claimId": "claim-1", + "coverage": "direct", + "methodLabel": "모든 단계를 실행 검사로 확인", + "unconfirmedSteps": [] + } + ] + } + ], + "assistantMessageId": "message-7" + } + }, + "source_outcome": "reject", + "browser_outcome": "reject" +} diff --git a/src/test/resources/contracts/brain/terminal-v2/invalid/phantom-solution.json b/src/test/resources/contracts/brain/terminal-v2/invalid/phantom-solution.json new file mode 100644 index 00000000..c2cac674 --- /dev/null +++ b/src/test/resources/contracts/brain/terminal-v2/invalid/phantom-solution.json @@ -0,0 +1,226 @@ +{ + "source": { + "contract_version": "brain-source-v1", + "run_id": "run-7", + "attempt": 1, + "fence_token": "fence-9f8c", + "source_event_id": "run-7:terminal:r0:b4c05df04575822cec85", + "occurred_at": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "response_disposition": "substantive", + "assistant_response": { + "schema_version": "assistant-response-v1", + "response_pages": [ + { + "response_page_id": "page-1", + "presentation": "main", + "title": "검증된 풀이", + "result_blocks": [ + { + "block_id": "block-1", + "body": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "kind": "solution", + "verification": "verified", + "solution_id": "solution-99", + "claim_ids": [ + "claim-1" + ], + "step_marks": [ + { + "step_id": "step-1", + "status": "verified", + "anchor": { + "start": 0, + "end": 24 + } + }, + { + "step_id": "step-2", + "status": "verified", + "anchor": { + "start": 25, + "end": 46 + } + } + ] + } + ] + } + ], + "source_references": [ + { + "source_ref_id": "sref-1", + "asset_id": "note-alpha", + "page_start": 3, + "page_end": 4 + } + ] + }, + "verified_solutions": [ + { + "schema_version": "verified-solution-v1", + "solution_id": "solution-1", + "version": 1, + "problem_statement": "주어진 명시 조건을 만족하는 결론을 구한다.", + "problem_conditions": [ + { + "condition_id": "condition-1", + "text": "문제의 명시 조건을 사용한다." + } + ], + "solution_text": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "claims": [ + { + "claim_id": "claim-1", + "statement": "명시 조건에서 제시한 결론이 성립한다." + } + ], + "step_marks": [ + { + "step_id": "step-1", + "status": "verified" + }, + { + "step_id": "step-2", + "status": "verified" + } + ], + "verification": { + "solution_id": "solution-1", + "overall_coverage": "direct", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "direct", + "method_label": "모든 단계를 실행 검사로 확인", + "unconfirmed_steps": [] + } + ] + }, + "verification_version": "chain-verification-report-v3", + "source_ref_ids": [ + "sref-1" + ] + } + ], + "selected_page_provenance": [ + { + "asset_id": "note-alpha", + "content_version": "content-v1", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "page": 3, + "understanding_version": "page-understanding-v1" + }, + { + "asset_id": "note-alpha", + "content_version": "content-v1", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "page": 4, + "understanding_version": "page-understanding-v1" + } + ], + "model": "fake/main-model", + "tier": "main", + "verification_summary": [ + { + "solution_id": "solution-1", + "overall_coverage": "direct", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "direct", + "method_label": "모든 단계를 실행 검사로 확인", + "unconfirmed_steps": [] + } + ] + } + ], + "source_labels": [ + { + "source_ref_id": "sref-1", + "label": "개념 노트 3–4쪽" + } + ] + } + }, + "browser": { + "contractVersion": "brain-source-v1", + "runId": "run-7", + "seq": 9, + "occurredAt": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "responseDisposition": "substantive", + "assistantResponse": { + "responsePages": [ + { + "responsePageId": "page-1", + "presentation": "main", + "title": "검증된 풀이", + "resultBlocks": [ + { + "blockId": "block-1", + "body": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "kind": "solution", + "verification": "verified", + "solutionId": "solution-99", + "claimIds": [ + "claim-1" + ], + "stepMarks": [ + { + "stepId": "step-1", + "status": "verified", + "anchor": { + "start": 0, + "end": 24 + } + }, + { + "stepId": "step-2", + "status": "verified", + "anchor": { + "start": 25, + "end": 46 + } + } + ] + } + ] + } + ], + "sourceReferences": [ + { + "sourceRefId": "sref-1", + "assetId": "note-alpha", + "pageStart": 3, + "pageEnd": 4, + "displayLabelSnapshot": "개념 노트 3–4쪽", + "available": true + } + ], + "plainText": "검증된 풀이\n\n인수분해하면 (x-2)(x-3)=0이다. 따라서 x=2 또는 x=3이다." + }, + "model": "fake/main-model", + "tier": "main", + "verificationSummary": [ + { + "solutionId": "solution-1", + "overallCoverage": "direct", + "claims": [ + { + "claimId": "claim-1", + "coverage": "direct", + "methodLabel": "모든 단계를 실행 검사로 확인", + "unconfirmedSteps": [] + } + ] + } + ], + "assistantMessageId": "message-7" + } + }, + "source_outcome": "reject", + "browser_outcome": "reject" +} diff --git a/src/test/resources/contracts/brain/terminal-v2/invalid/phantom-source.json b/src/test/resources/contracts/brain/terminal-v2/invalid/phantom-source.json new file mode 100644 index 00000000..fda07329 --- /dev/null +++ b/src/test/resources/contracts/brain/terminal-v2/invalid/phantom-source.json @@ -0,0 +1,226 @@ +{ + "source": { + "contract_version": "brain-source-v1", + "run_id": "run-7", + "attempt": 1, + "fence_token": "fence-9f8c", + "source_event_id": "run-7:terminal:r0:b4c05df04575822cec85", + "occurred_at": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "response_disposition": "substantive", + "assistant_response": { + "schema_version": "assistant-response-v1", + "response_pages": [ + { + "response_page_id": "page-1", + "presentation": "main", + "title": "검증된 풀이", + "result_blocks": [ + { + "block_id": "block-1", + "body": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]][[source:sref-99]]", + "kind": "solution", + "verification": "verified", + "solution_id": "solution-1", + "claim_ids": [ + "claim-1" + ], + "step_marks": [ + { + "step_id": "step-1", + "status": "verified", + "anchor": { + "start": 0, + "end": 24 + } + }, + { + "step_id": "step-2", + "status": "verified", + "anchor": { + "start": 25, + "end": 46 + } + } + ] + } + ] + } + ], + "source_references": [ + { + "source_ref_id": "sref-1", + "asset_id": "note-alpha", + "page_start": 3, + "page_end": 4 + } + ] + }, + "verified_solutions": [ + { + "schema_version": "verified-solution-v1", + "solution_id": "solution-1", + "version": 1, + "problem_statement": "주어진 명시 조건을 만족하는 결론을 구한다.", + "problem_conditions": [ + { + "condition_id": "condition-1", + "text": "문제의 명시 조건을 사용한다." + } + ], + "solution_text": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "claims": [ + { + "claim_id": "claim-1", + "statement": "명시 조건에서 제시한 결론이 성립한다." + } + ], + "step_marks": [ + { + "step_id": "step-1", + "status": "verified" + }, + { + "step_id": "step-2", + "status": "verified" + } + ], + "verification": { + "solution_id": "solution-1", + "overall_coverage": "direct", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "direct", + "method_label": "모든 단계를 실행 검사로 확인", + "unconfirmed_steps": [] + } + ] + }, + "verification_version": "chain-verification-report-v3", + "source_ref_ids": [ + "sref-1" + ] + } + ], + "selected_page_provenance": [ + { + "asset_id": "note-alpha", + "content_version": "content-v1", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "page": 3, + "understanding_version": "page-understanding-v1" + }, + { + "asset_id": "note-alpha", + "content_version": "content-v1", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "page": 4, + "understanding_version": "page-understanding-v1" + } + ], + "model": "fake/main-model", + "tier": "main", + "verification_summary": [ + { + "solution_id": "solution-1", + "overall_coverage": "direct", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "direct", + "method_label": "모든 단계를 실행 검사로 확인", + "unconfirmed_steps": [] + } + ] + } + ], + "source_labels": [ + { + "source_ref_id": "sref-1", + "label": "개념 노트 3–4쪽" + } + ] + } + }, + "browser": { + "contractVersion": "brain-source-v1", + "runId": "run-7", + "seq": 9, + "occurredAt": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "responseDisposition": "substantive", + "assistantResponse": { + "responsePages": [ + { + "responsePageId": "page-1", + "presentation": "main", + "title": "검증된 풀이", + "resultBlocks": [ + { + "blockId": "block-1", + "body": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]][[source:sref-99]]", + "kind": "solution", + "verification": "verified", + "solutionId": "solution-1", + "claimIds": [ + "claim-1" + ], + "stepMarks": [ + { + "stepId": "step-1", + "status": "verified", + "anchor": { + "start": 0, + "end": 24 + } + }, + { + "stepId": "step-2", + "status": "verified", + "anchor": { + "start": 25, + "end": 46 + } + } + ] + } + ] + } + ], + "sourceReferences": [ + { + "sourceRefId": "sref-1", + "assetId": "note-alpha", + "pageStart": 3, + "pageEnd": 4, + "displayLabelSnapshot": "개념 노트 3–4쪽", + "available": true + } + ], + "plainText": "검증된 풀이\n\n인수분해하면 (x-2)(x-3)=0이다. 따라서 x=2 또는 x=3이다." + }, + "model": "fake/main-model", + "tier": "main", + "verificationSummary": [ + { + "solutionId": "solution-1", + "overallCoverage": "direct", + "claims": [ + { + "claimId": "claim-1", + "coverage": "direct", + "methodLabel": "모든 단계를 실행 검사로 확인", + "unconfirmedSteps": [] + } + ] + } + ], + "assistantMessageId": "message-7" + } + }, + "source_outcome": "reject", + "browser_outcome": "reject" +} diff --git a/src/test/resources/contracts/brain/terminal-v2/invalid/raw-discriminator.json b/src/test/resources/contracts/brain/terminal-v2/invalid/raw-discriminator.json new file mode 100644 index 00000000..ec5009f7 --- /dev/null +++ b/src/test/resources/contracts/brain/terminal-v2/invalid/raw-discriminator.json @@ -0,0 +1,226 @@ +{ + "source": { + "contract_version": "brain-source-v1", + "run_id": "run-7", + "attempt": 1, + "fence_token": "fence-9f8c", + "source_event_id": "run-7:terminal:r0:b4c05df04575822cec85", + "occurred_at": "2026-10-01T09:00:00Z", + "type": "DO-NOT-LEAK-raw-payload", + "payload": { + "response_disposition": "substantive", + "assistant_response": { + "schema_version": "assistant-response-v1", + "response_pages": [ + { + "response_page_id": "page-1", + "presentation": "main", + "title": "검증된 풀이", + "result_blocks": [ + { + "block_id": "block-1", + "body": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "kind": "solution", + "verification": "verified", + "solution_id": "solution-1", + "claim_ids": [ + "claim-1" + ], + "step_marks": [ + { + "step_id": "step-1", + "status": "verified", + "anchor": { + "start": 0, + "end": 24 + } + }, + { + "step_id": "step-2", + "status": "verified", + "anchor": { + "start": 25, + "end": 46 + } + } + ] + } + ] + } + ], + "source_references": [ + { + "source_ref_id": "sref-1", + "asset_id": "note-alpha", + "page_start": 3, + "page_end": 4 + } + ] + }, + "verified_solutions": [ + { + "schema_version": "verified-solution-v1", + "solution_id": "solution-1", + "version": 1, + "problem_statement": "주어진 명시 조건을 만족하는 결론을 구한다.", + "problem_conditions": [ + { + "condition_id": "condition-1", + "text": "문제의 명시 조건을 사용한다." + } + ], + "solution_text": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "claims": [ + { + "claim_id": "claim-1", + "statement": "명시 조건에서 제시한 결론이 성립한다." + } + ], + "step_marks": [ + { + "step_id": "step-1", + "status": "verified" + }, + { + "step_id": "step-2", + "status": "verified" + } + ], + "verification": { + "solution_id": "solution-1", + "overall_coverage": "direct", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "direct", + "method_label": "모든 단계를 실행 검사로 확인", + "unconfirmed_steps": [] + } + ] + }, + "verification_version": "chain-verification-report-v3", + "source_ref_ids": [ + "sref-1" + ] + } + ], + "selected_page_provenance": [ + { + "asset_id": "note-alpha", + "content_version": "content-v1", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "page": 3, + "understanding_version": "page-understanding-v1" + }, + { + "asset_id": "note-alpha", + "content_version": "content-v1", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "page": 4, + "understanding_version": "page-understanding-v1" + } + ], + "model": "fake/main-model", + "tier": "main", + "verification_summary": [ + { + "solution_id": "solution-1", + "overall_coverage": "direct", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "direct", + "method_label": "모든 단계를 실행 검사로 확인", + "unconfirmed_steps": [] + } + ] + } + ], + "source_labels": [ + { + "source_ref_id": "sref-1", + "label": "개념 노트 3–4쪽" + } + ] + } + }, + "browser": { + "contractVersion": "brain-source-v1", + "runId": "run-7", + "seq": 9, + "occurredAt": "2026-10-01T09:00:00Z", + "type": "DO-NOT-LEAK-raw-payload", + "payload": { + "responseDisposition": "substantive", + "assistantResponse": { + "responsePages": [ + { + "responsePageId": "page-1", + "presentation": "main", + "title": "검증된 풀이", + "resultBlocks": [ + { + "blockId": "block-1", + "body": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "kind": "solution", + "verification": "verified", + "solutionId": "solution-1", + "claimIds": [ + "claim-1" + ], + "stepMarks": [ + { + "stepId": "step-1", + "status": "verified", + "anchor": { + "start": 0, + "end": 24 + } + }, + { + "stepId": "step-2", + "status": "verified", + "anchor": { + "start": 25, + "end": 46 + } + } + ] + } + ] + } + ], + "sourceReferences": [ + { + "sourceRefId": "sref-1", + "assetId": "note-alpha", + "pageStart": 3, + "pageEnd": 4, + "displayLabelSnapshot": "개념 노트 3–4쪽", + "available": true + } + ], + "plainText": "검증된 풀이\n\n인수분해하면 (x-2)(x-3)=0이다. 따라서 x=2 또는 x=3이다." + }, + "model": "fake/main-model", + "tier": "main", + "verificationSummary": [ + { + "solutionId": "solution-1", + "overallCoverage": "direct", + "claims": [ + { + "claimId": "claim-1", + "coverage": "direct", + "methodLabel": "모든 단계를 실행 검사로 확인", + "unconfirmedSteps": [] + } + ] + } + ], + "assistantMessageId": "message-7" + } + }, + "source_outcome": "reject", + "browser_outcome": "reject" +} diff --git a/src/test/resources/contracts/brain/terminal-v2/invalid/raw-extra.json b/src/test/resources/contracts/brain/terminal-v2/invalid/raw-extra.json new file mode 100644 index 00000000..b8c2d8f4 --- /dev/null +++ b/src/test/resources/contracts/brain/terminal-v2/invalid/raw-extra.json @@ -0,0 +1,305 @@ +{ + "source": { + "contract_version": "brain-source-v1", + "run_id": "run-7", + "attempt": 1, + "fence_token": "fence-9f8c", + "source_event_id": "run-7:terminal:r0:b4c05df04575822cec85", + "occurred_at": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "response_disposition": "substantive", + "assistant_response": { + "schema_version": "assistant-response-v1", + "response_pages": [ + { + "response_page_id": "page-1", + "presentation": "main", + "title": "검증된 풀이", + "result_blocks": [ + { + "block_id": "block-1", + "body": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "kind": "solution", + "verification": "verified", + "solution_id": "solution-1", + "claim_ids": [ + "claim-1" + ], + "step_marks": [ + { + "step_id": "step-1", + "status": "verified", + "anchor": { + "start": 0, + "end": 24 + } + }, + { + "step_id": "step-2", + "status": "verified", + "anchor": { + "start": 25, + "end": 46 + } + } + ] + } + ] + } + ], + "source_references": [ + { + "source_ref_id": "sref-1", + "asset_id": "note-alpha", + "page_start": 3, + "page_end": 4 + } + ] + }, + "verified_solutions": [ + { + "schema_version": "verified-solution-v1", + "solution_id": "solution-1", + "version": 1, + "problem_statement": "주어진 명시 조건을 만족하는 결론을 구한다.", + "problem_conditions": [ + { + "condition_id": "condition-1", + "text": "문제의 명시 조건을 사용한다." + } + ], + "solution_text": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "claims": [ + { + "claim_id": "claim-1", + "statement": "명시 조건에서 제시한 결론이 성립한다." + } + ], + "step_marks": [ + { + "step_id": "step-1", + "status": "verified" + }, + { + "step_id": "step-2", + "status": "verified" + } + ], + "verification": { + "solution_id": "solution-1", + "overall_coverage": "direct", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "direct", + "method_label": "모든 단계를 실행 검사로 확인", + "unconfirmed_steps": [] + } + ] + }, + "verification_version": "chain-verification-report-v3", + "source_ref_ids": [ + "sref-1" + ] + } + ], + "selected_page_provenance": [ + { + "asset_id": "note-alpha", + "content_version": "content-v1", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "page": 3, + "understanding_version": "page-understanding-v1" + }, + { + "asset_id": "note-alpha", + "content_version": "content-v1", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "page": 4, + "understanding_version": "page-understanding-v1" + } + ], + "model": "fake/main-model", + "tier": "main", + "verification_summary": [ + { + "solution_id": "solution-1", + "overall_coverage": "direct", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "direct", + "method_label": "모든 단계를 실행 검사로 확인", + "unconfirmed_steps": [] + } + ] + } + ], + "source_labels": [ + { + "source_ref_id": "sref-1", + "label": "개념 노트 3–4쪽" + } + ], + "DO-NOT-LEAK-raw-payload": "DO-NOT-LEAK-raw-payload" + } + }, + "browser": { + "contractVersion": "brain-source-v1", + "runId": "run-7", + "seq": 9, + "occurredAt": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "responseDisposition": "substantive", + "assistantResponse": { + "responsePages": [ + { + "responsePageId": "page-1", + "presentation": "main", + "title": "검증된 풀이", + "resultBlocks": [ + { + "blockId": "block-1", + "body": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "kind": "solution", + "verification": "verified", + "solutionId": "solution-1", + "claimIds": [ + "claim-1" + ], + "stepMarks": [ + { + "stepId": "step-1", + "status": "verified", + "anchor": { + "start": 0, + "end": 24 + } + }, + { + "stepId": "step-2", + "status": "verified", + "anchor": { + "start": 25, + "end": 46 + } + } + ] + } + ] + } + ], + "sourceReferences": [ + { + "sourceRefId": "sref-1", + "assetId": "note-alpha", + "pageStart": 3, + "pageEnd": 4, + "displayLabelSnapshot": "개념 노트 3–4쪽", + "available": true + } + ], + "plainText": "검증된 풀이\n\n인수분해하면 (x-2)(x-3)=0이다. 따라서 x=2 또는 x=3이다." + }, + "model": "fake/main-model", + "tier": "main", + "verificationSummary": [ + { + "solutionId": "solution-1", + "overallCoverage": "direct", + "claims": [ + { + "claimId": "claim-1", + "coverage": "direct", + "methodLabel": "모든 단계를 실행 검사로 확인", + "unconfirmedSteps": [] + } + ] + } + ], + "assistantMessageId": "message-7", + "DO-NOT-LEAK-raw-payload": "DO-NOT-LEAK-raw-payload" + } + }, + "source_outcome": "reject", + "browser_outcome": "ignore_additive", + "browser_expected": { + "contractVersion": "brain-source-v1", + "runId": "run-7", + "seq": 9, + "occurredAt": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "responseDisposition": "substantive", + "assistantResponse": { + "responsePages": [ + { + "responsePageId": "page-1", + "presentation": "main", + "title": "검증된 풀이", + "resultBlocks": [ + { + "blockId": "block-1", + "body": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "kind": "solution", + "verification": "verified", + "solutionId": "solution-1", + "claimIds": [ + "claim-1" + ], + "stepMarks": [ + { + "stepId": "step-1", + "status": "verified", + "anchor": { + "start": 0, + "end": 24 + } + }, + { + "stepId": "step-2", + "status": "verified", + "anchor": { + "start": 25, + "end": 46 + } + } + ] + } + ] + } + ], + "sourceReferences": [ + { + "sourceRefId": "sref-1", + "assetId": "note-alpha", + "pageStart": 3, + "pageEnd": 4, + "displayLabelSnapshot": "개념 노트 3–4쪽", + "available": true + } + ], + "plainText": "검증된 풀이\n\n인수분해하면 (x-2)(x-3)=0이다. 따라서 x=2 또는 x=3이다." + }, + "model": "fake/main-model", + "tier": "main", + "verificationSummary": [ + { + "solutionId": "solution-1", + "overallCoverage": "direct", + "claims": [ + { + "claimId": "claim-1", + "coverage": "direct", + "methodLabel": "모든 단계를 실행 검사로 확인", + "unconfirmedSteps": [] + } + ] + } + ], + "assistantMessageId": "message-7" + } + } +} diff --git a/src/test/resources/contracts/brain/terminal-v2/invalid/reason-on-non-attachment-code.json b/src/test/resources/contracts/brain/terminal-v2/invalid/reason-on-non-attachment-code.json new file mode 100644 index 00000000..883af347 --- /dev/null +++ b/src/test/resources/contracts/brain/terminal-v2/invalid/reason-on-non-attachment-code.json @@ -0,0 +1,34 @@ +{ + "source": { + "contract_version": "brain-source-v1", + "run_id": "run-7", + "attempt": 1, + "fence_token": "fence-9f8c", + "source_event_id": "run-7:terminal:r0:b4c05df04575822cec85", + "occurred_at": "2026-10-01T09:00:00Z", + "type": "run.failed", + "payload": { + "code": "verification_failed", + "message": "풀이의 오류를 수정하지 못했습니다. 새 요청으로 다시 시도할 수 있습니다.", + "failure_class": "verification", + "recoverable": true, + "reason": "pdf_encrypted" + } + }, + "browser": { + "contractVersion": "brain-source-v1", + "runId": "run-7", + "seq": 9, + "occurredAt": "2026-10-01T09:00:00Z", + "type": "run.failed", + "payload": { + "code": "verification_failed", + "message": "풀이의 오류를 수정하지 못했습니다. 새 요청으로 다시 시도할 수 있습니다.", + "recoverable": true, + "assistantMessageId": "message-7", + "reason": "pdf_encrypted" + } + }, + "source_outcome": "reject", + "browser_outcome": "reject" +} diff --git a/src/test/resources/contracts/brain/terminal-v2/invalid/source-version-conflict.json b/src/test/resources/contracts/brain/terminal-v2/invalid/source-version-conflict.json new file mode 100644 index 00000000..a187b58f --- /dev/null +++ b/src/test/resources/contracts/brain/terminal-v2/invalid/source-version-conflict.json @@ -0,0 +1,303 @@ +{ + "source": { + "contract_version": "brain-source-v1", + "run_id": "run-7", + "attempt": 1, + "fence_token": "fence-9f8c", + "source_event_id": "run-7:terminal:r0:b4c05df04575822cec85", + "occurred_at": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "response_disposition": "substantive", + "assistant_response": { + "schema_version": "assistant-response-v1", + "response_pages": [ + { + "response_page_id": "page-1", + "presentation": "main", + "title": "검증된 풀이", + "result_blocks": [ + { + "block_id": "block-1", + "body": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "kind": "solution", + "verification": "verified", + "solution_id": "solution-1", + "claim_ids": [ + "claim-1" + ], + "step_marks": [ + { + "step_id": "step-1", + "status": "verified", + "anchor": { + "start": 0, + "end": 24 + } + }, + { + "step_id": "step-2", + "status": "verified", + "anchor": { + "start": 25, + "end": 46 + } + } + ] + } + ] + } + ], + "source_references": [ + { + "source_ref_id": "sref-1", + "asset_id": "note-alpha", + "page_start": 3, + "page_end": 4 + } + ] + }, + "verified_solutions": [ + { + "schema_version": "verified-solution-v1", + "solution_id": "solution-1", + "version": 1, + "problem_statement": "주어진 명시 조건을 만족하는 결론을 구한다.", + "problem_conditions": [ + { + "condition_id": "condition-1", + "text": "문제의 명시 조건을 사용한다." + } + ], + "solution_text": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "claims": [ + { + "claim_id": "claim-1", + "statement": "명시 조건에서 제시한 결론이 성립한다." + } + ], + "step_marks": [ + { + "step_id": "step-1", + "status": "verified" + }, + { + "step_id": "step-2", + "status": "verified" + } + ], + "verification": { + "solution_id": "solution-1", + "overall_coverage": "direct", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "direct", + "method_label": "모든 단계를 실행 검사로 확인", + "unconfirmed_steps": [] + } + ] + }, + "verification_version": "chain-verification-report-v3", + "source_ref_ids": [ + "sref-1" + ] + } + ], + "selected_page_provenance": [ + { + "asset_id": "note-alpha", + "content_version": "content-v1", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "page": 3, + "understanding_version": "page-understanding-v1" + }, + { + "asset_id": "note-alpha", + "content_version": "content-other", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "page": 4, + "understanding_version": "page-understanding-v1" + } + ], + "model": "fake/main-model", + "tier": "main", + "verification_summary": [ + { + "solution_id": "solution-1", + "overall_coverage": "direct", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "direct", + "method_label": "모든 단계를 실행 검사로 확인", + "unconfirmed_steps": [] + } + ] + } + ], + "source_labels": [ + { + "source_ref_id": "sref-1", + "label": "개념 노트 3–4쪽" + } + ] + } + }, + "browser": { + "contractVersion": "brain-source-v1", + "runId": "run-7", + "seq": 9, + "occurredAt": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "responseDisposition": "substantive", + "assistantResponse": { + "responsePages": [ + { + "responsePageId": "page-1", + "presentation": "main", + "title": "검증된 풀이", + "resultBlocks": [ + { + "blockId": "block-1", + "body": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "kind": "solution", + "verification": "verified", + "solutionId": "solution-1", + "claimIds": [ + "claim-1" + ], + "stepMarks": [ + { + "stepId": "step-1", + "status": "verified", + "anchor": { + "start": 0, + "end": 24 + } + }, + { + "stepId": "step-2", + "status": "verified", + "anchor": { + "start": 25, + "end": 46 + } + } + ] + } + ] + } + ], + "sourceReferences": [ + { + "sourceRefId": "sref-1", + "assetId": "note-alpha", + "pageStart": 3, + "pageEnd": 4, + "displayLabelSnapshot": "개념 노트 3–4쪽", + "available": true + } + ], + "plainText": "검증된 풀이\n\n인수분해하면 (x-2)(x-3)=0이다. 따라서 x=2 또는 x=3이다." + }, + "model": "fake/main-model", + "tier": "main", + "verificationSummary": [ + { + "solutionId": "solution-1", + "overallCoverage": "direct", + "claims": [ + { + "claimId": "claim-1", + "coverage": "direct", + "methodLabel": "모든 단계를 실행 검사로 확인", + "unconfirmedSteps": [] + } + ] + } + ], + "assistantMessageId": "message-7" + } + }, + "source_outcome": "reject", + "browser_outcome": "valid_public_projection", + "browser_expected": { + "contractVersion": "brain-source-v1", + "runId": "run-7", + "seq": 9, + "occurredAt": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "responseDisposition": "substantive", + "assistantResponse": { + "responsePages": [ + { + "responsePageId": "page-1", + "presentation": "main", + "title": "검증된 풀이", + "resultBlocks": [ + { + "blockId": "block-1", + "body": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "kind": "solution", + "verification": "verified", + "solutionId": "solution-1", + "claimIds": [ + "claim-1" + ], + "stepMarks": [ + { + "stepId": "step-1", + "status": "verified", + "anchor": { + "start": 0, + "end": 24 + } + }, + { + "stepId": "step-2", + "status": "verified", + "anchor": { + "start": 25, + "end": 46 + } + } + ] + } + ] + } + ], + "sourceReferences": [ + { + "sourceRefId": "sref-1", + "assetId": "note-alpha", + "pageStart": 3, + "pageEnd": 4, + "displayLabelSnapshot": "개념 노트 3–4쪽", + "available": true + } + ], + "plainText": "검증된 풀이\n\n인수분해하면 (x-2)(x-3)=0이다. 따라서 x=2 또는 x=3이다." + }, + "model": "fake/main-model", + "tier": "main", + "verificationSummary": [ + { + "solutionId": "solution-1", + "overallCoverage": "direct", + "claims": [ + { + "claimId": "claim-1", + "coverage": "direct", + "methodLabel": "모든 단계를 실행 검사로 확인", + "unconfirmedSteps": [] + } + ] + } + ], + "assistantMessageId": "message-7" + } + } +} diff --git a/src/test/resources/contracts/brain/terminal-v2/invalid/stdout.json b/src/test/resources/contracts/brain/terminal-v2/invalid/stdout.json new file mode 100644 index 00000000..f49fe7fd --- /dev/null +++ b/src/test/resources/contracts/brain/terminal-v2/invalid/stdout.json @@ -0,0 +1,305 @@ +{ + "source": { + "contract_version": "brain-source-v1", + "run_id": "run-7", + "attempt": 1, + "fence_token": "fence-9f8c", + "source_event_id": "run-7:terminal:r0:b4c05df04575822cec85", + "occurred_at": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "response_disposition": "substantive", + "assistant_response": { + "schema_version": "assistant-response-v1", + "response_pages": [ + { + "response_page_id": "page-1", + "presentation": "main", + "title": "검증된 풀이", + "result_blocks": [ + { + "block_id": "block-1", + "body": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "kind": "solution", + "verification": "verified", + "solution_id": "solution-1", + "claim_ids": [ + "claim-1" + ], + "step_marks": [ + { + "step_id": "step-1", + "status": "verified", + "anchor": { + "start": 0, + "end": 24 + } + }, + { + "step_id": "step-2", + "status": "verified", + "anchor": { + "start": 25, + "end": 46 + } + } + ] + } + ] + } + ], + "source_references": [ + { + "source_ref_id": "sref-1", + "asset_id": "note-alpha", + "page_start": 3, + "page_end": 4 + } + ] + }, + "verified_solutions": [ + { + "schema_version": "verified-solution-v1", + "solution_id": "solution-1", + "version": 1, + "problem_statement": "주어진 명시 조건을 만족하는 결론을 구한다.", + "problem_conditions": [ + { + "condition_id": "condition-1", + "text": "문제의 명시 조건을 사용한다." + } + ], + "solution_text": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "claims": [ + { + "claim_id": "claim-1", + "statement": "명시 조건에서 제시한 결론이 성립한다." + } + ], + "step_marks": [ + { + "step_id": "step-1", + "status": "verified" + }, + { + "step_id": "step-2", + "status": "verified" + } + ], + "verification": { + "solution_id": "solution-1", + "overall_coverage": "direct", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "direct", + "method_label": "모든 단계를 실행 검사로 확인", + "unconfirmed_steps": [] + } + ] + }, + "verification_version": "chain-verification-report-v3", + "source_ref_ids": [ + "sref-1" + ] + } + ], + "selected_page_provenance": [ + { + "asset_id": "note-alpha", + "content_version": "content-v1", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "page": 3, + "understanding_version": "page-understanding-v1" + }, + { + "asset_id": "note-alpha", + "content_version": "content-v1", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "page": 4, + "understanding_version": "page-understanding-v1" + } + ], + "model": "fake/main-model", + "tier": "main", + "verification_summary": [ + { + "solution_id": "solution-1", + "overall_coverage": "direct", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "direct", + "method_label": "모든 단계를 실행 검사로 확인", + "unconfirmed_steps": [] + } + ] + } + ], + "source_labels": [ + { + "source_ref_id": "sref-1", + "label": "개념 노트 3–4쪽" + } + ], + "stdout": "DO-NOT-LEAK-raw-payload" + } + }, + "browser": { + "contractVersion": "brain-source-v1", + "runId": "run-7", + "seq": 9, + "occurredAt": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "responseDisposition": "substantive", + "assistantResponse": { + "responsePages": [ + { + "responsePageId": "page-1", + "presentation": "main", + "title": "검증된 풀이", + "resultBlocks": [ + { + "blockId": "block-1", + "body": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "kind": "solution", + "verification": "verified", + "solutionId": "solution-1", + "claimIds": [ + "claim-1" + ], + "stepMarks": [ + { + "stepId": "step-1", + "status": "verified", + "anchor": { + "start": 0, + "end": 24 + } + }, + { + "stepId": "step-2", + "status": "verified", + "anchor": { + "start": 25, + "end": 46 + } + } + ] + } + ] + } + ], + "sourceReferences": [ + { + "sourceRefId": "sref-1", + "assetId": "note-alpha", + "pageStart": 3, + "pageEnd": 4, + "displayLabelSnapshot": "개념 노트 3–4쪽", + "available": true + } + ], + "plainText": "검증된 풀이\n\n인수분해하면 (x-2)(x-3)=0이다. 따라서 x=2 또는 x=3이다." + }, + "model": "fake/main-model", + "tier": "main", + "verificationSummary": [ + { + "solutionId": "solution-1", + "overallCoverage": "direct", + "claims": [ + { + "claimId": "claim-1", + "coverage": "direct", + "methodLabel": "모든 단계를 실행 검사로 확인", + "unconfirmedSteps": [] + } + ] + } + ], + "assistantMessageId": "message-7", + "stdout": "DO-NOT-LEAK-raw-payload" + } + }, + "source_outcome": "reject", + "browser_outcome": "ignore_additive", + "browser_expected": { + "contractVersion": "brain-source-v1", + "runId": "run-7", + "seq": 9, + "occurredAt": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "responseDisposition": "substantive", + "assistantResponse": { + "responsePages": [ + { + "responsePageId": "page-1", + "presentation": "main", + "title": "검증된 풀이", + "resultBlocks": [ + { + "blockId": "block-1", + "body": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "kind": "solution", + "verification": "verified", + "solutionId": "solution-1", + "claimIds": [ + "claim-1" + ], + "stepMarks": [ + { + "stepId": "step-1", + "status": "verified", + "anchor": { + "start": 0, + "end": 24 + } + }, + { + "stepId": "step-2", + "status": "verified", + "anchor": { + "start": 25, + "end": 46 + } + } + ] + } + ] + } + ], + "sourceReferences": [ + { + "sourceRefId": "sref-1", + "assetId": "note-alpha", + "pageStart": 3, + "pageEnd": 4, + "displayLabelSnapshot": "개념 노트 3–4쪽", + "available": true + } + ], + "plainText": "검증된 풀이\n\n인수분해하면 (x-2)(x-3)=0이다. 따라서 x=2 또는 x=3이다." + }, + "model": "fake/main-model", + "tier": "main", + "verificationSummary": [ + { + "solutionId": "solution-1", + "overallCoverage": "direct", + "claims": [ + { + "claimId": "claim-1", + "coverage": "direct", + "methodLabel": "모든 단계를 실행 검사로 확인", + "unconfirmedSteps": [] + } + ] + } + ], + "assistantMessageId": "message-7" + } + } +} diff --git a/src/test/resources/contracts/brain/terminal-v2/invalid/unsafe-failure-message.json b/src/test/resources/contracts/brain/terminal-v2/invalid/unsafe-failure-message.json new file mode 100644 index 00000000..4abafe33 --- /dev/null +++ b/src/test/resources/contracts/brain/terminal-v2/invalid/unsafe-failure-message.json @@ -0,0 +1,32 @@ +{ + "source": { + "contract_version": "brain-source-v1", + "run_id": "run-7", + "attempt": 1, + "fence_token": "fence-9f8c", + "source_event_id": "run-7:terminal:r0:b4c05df04575822cec85", + "occurred_at": "2026-10-01T09:00:00Z", + "type": "run.failed", + "payload": { + "code": "verification_failed", + "message": "DO-NOT-LEAK-raw-payload", + "failure_class": "verification", + "recoverable": true + } + }, + "browser": { + "contractVersion": "brain-source-v1", + "runId": "run-7", + "seq": 9, + "occurredAt": "2026-10-01T09:00:00Z", + "type": "run.failed", + "payload": { + "code": "verification_failed", + "message": "DO-NOT-LEAK-raw-payload", + "recoverable": true, + "assistantMessageId": "message-7" + } + }, + "source_outcome": "reject", + "browser_outcome": "reject" +} diff --git a/src/test/resources/contracts/brain/terminal-v2/invalid/unverified-as-verified.json b/src/test/resources/contracts/brain/terminal-v2/invalid/unverified-as-verified.json new file mode 100644 index 00000000..263c53ec --- /dev/null +++ b/src/test/resources/contracts/brain/terminal-v2/invalid/unverified-as-verified.json @@ -0,0 +1,104 @@ +{ + "source": { + "contract_version": "brain-source-v1", + "run_id": "run-7", + "attempt": 1, + "fence_token": "fence-9f8c", + "source_event_id": "run-7:terminal:r0:b4c05df04575822cec85", + "occurred_at": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "response_disposition": "substantive", + "assistant_response": { + "schema_version": "assistant-response-v1", + "response_pages": [ + { + "response_page_id": "page-1", + "presentation": "main", + "result_blocks": [ + { + "block_id": "block-1", + "body": "인수분해는 맞다. 마지막 합산은 확인되지 않았다.", + "kind": "solution", + "verification": "verified", + "claim_ids": [], + "step_marks": [ + { + "step_id": "step-1", + "status": "verified", + "anchor": { + "start": 0, + "end": 9 + } + }, + { + "step_id": "step-2", + "status": "unconfirmed", + "reason": "검사 실행 증거가 없다" + } + ] + } + ] + } + ], + "source_references": [] + }, + "verified_solutions": [], + "selected_page_provenance": [], + "model": "fake/main-model", + "tier": "main", + "verification_summary": [], + "source_labels": [] + } + }, + "browser": { + "contractVersion": "brain-source-v1", + "runId": "run-7", + "seq": 9, + "occurredAt": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "responseDisposition": "substantive", + "assistantResponse": { + "responsePages": [ + { + "responsePageId": "page-1", + "presentation": "main", + "resultBlocks": [ + { + "blockId": "block-1", + "body": "인수분해는 맞다. 마지막 합산은 확인되지 않았다.", + "kind": "solution", + "verification": "verified", + "claimIds": [], + "stepMarks": [ + { + "stepId": "step-1", + "status": "verified", + "anchor": { + "start": 0, + "end": 9 + } + }, + { + "stepId": "step-2", + "status": "unconfirmed", + "reason": "검사 실행 증거가 없다" + } + ] + } + ] + } + ], + "sourceReferences": [], + "plainText": "인수분해는 맞다. 마지막 합산은 확인되지 않았다." + }, + "model": "fake/main-model", + "tier": "main", + "verificationSummary": [], + "assistantMessageId": "message-7" + } + }, + "source_outcome": "reject", + "browser_outcome": "reject" +} diff --git a/src/test/resources/contracts/brain/terminal-v2/invalid/video_target.json b/src/test/resources/contracts/brain/terminal-v2/invalid/video_target.json new file mode 100644 index 00000000..d62b8de0 --- /dev/null +++ b/src/test/resources/contracts/brain/terminal-v2/invalid/video_target.json @@ -0,0 +1,305 @@ +{ + "source": { + "contract_version": "brain-source-v1", + "run_id": "run-7", + "attempt": 1, + "fence_token": "fence-9f8c", + "source_event_id": "run-7:terminal:r0:b4c05df04575822cec85", + "occurred_at": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "response_disposition": "substantive", + "assistant_response": { + "schema_version": "assistant-response-v1", + "response_pages": [ + { + "response_page_id": "page-1", + "presentation": "main", + "title": "검증된 풀이", + "result_blocks": [ + { + "block_id": "block-1", + "body": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "kind": "solution", + "verification": "verified", + "solution_id": "solution-1", + "claim_ids": [ + "claim-1" + ], + "step_marks": [ + { + "step_id": "step-1", + "status": "verified", + "anchor": { + "start": 0, + "end": 24 + } + }, + { + "step_id": "step-2", + "status": "verified", + "anchor": { + "start": 25, + "end": 46 + } + } + ] + } + ] + } + ], + "source_references": [ + { + "source_ref_id": "sref-1", + "asset_id": "note-alpha", + "page_start": 3, + "page_end": 4 + } + ] + }, + "verified_solutions": [ + { + "schema_version": "verified-solution-v1", + "solution_id": "solution-1", + "version": 1, + "problem_statement": "주어진 명시 조건을 만족하는 결론을 구한다.", + "problem_conditions": [ + { + "condition_id": "condition-1", + "text": "문제의 명시 조건을 사용한다." + } + ], + "solution_text": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "claims": [ + { + "claim_id": "claim-1", + "statement": "명시 조건에서 제시한 결론이 성립한다." + } + ], + "step_marks": [ + { + "step_id": "step-1", + "status": "verified" + }, + { + "step_id": "step-2", + "status": "verified" + } + ], + "verification": { + "solution_id": "solution-1", + "overall_coverage": "direct", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "direct", + "method_label": "모든 단계를 실행 검사로 확인", + "unconfirmed_steps": [] + } + ] + }, + "verification_version": "chain-verification-report-v3", + "source_ref_ids": [ + "sref-1" + ] + } + ], + "selected_page_provenance": [ + { + "asset_id": "note-alpha", + "content_version": "content-v1", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "page": 3, + "understanding_version": "page-understanding-v1" + }, + { + "asset_id": "note-alpha", + "content_version": "content-v1", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "page": 4, + "understanding_version": "page-understanding-v1" + } + ], + "model": "fake/main-model", + "tier": "main", + "verification_summary": [ + { + "solution_id": "solution-1", + "overall_coverage": "direct", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "direct", + "method_label": "모든 단계를 실행 검사로 확인", + "unconfirmed_steps": [] + } + ] + } + ], + "source_labels": [ + { + "source_ref_id": "sref-1", + "label": "개념 노트 3–4쪽" + } + ], + "video_target": "DO-NOT-LEAK-raw-payload" + } + }, + "browser": { + "contractVersion": "brain-source-v1", + "runId": "run-7", + "seq": 9, + "occurredAt": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "responseDisposition": "substantive", + "assistantResponse": { + "responsePages": [ + { + "responsePageId": "page-1", + "presentation": "main", + "title": "검증된 풀이", + "resultBlocks": [ + { + "blockId": "block-1", + "body": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "kind": "solution", + "verification": "verified", + "solutionId": "solution-1", + "claimIds": [ + "claim-1" + ], + "stepMarks": [ + { + "stepId": "step-1", + "status": "verified", + "anchor": { + "start": 0, + "end": 24 + } + }, + { + "stepId": "step-2", + "status": "verified", + "anchor": { + "start": 25, + "end": 46 + } + } + ] + } + ] + } + ], + "sourceReferences": [ + { + "sourceRefId": "sref-1", + "assetId": "note-alpha", + "pageStart": 3, + "pageEnd": 4, + "displayLabelSnapshot": "개념 노트 3–4쪽", + "available": true + } + ], + "plainText": "검증된 풀이\n\n인수분해하면 (x-2)(x-3)=0이다. 따라서 x=2 또는 x=3이다." + }, + "model": "fake/main-model", + "tier": "main", + "verificationSummary": [ + { + "solutionId": "solution-1", + "overallCoverage": "direct", + "claims": [ + { + "claimId": "claim-1", + "coverage": "direct", + "methodLabel": "모든 단계를 실행 검사로 확인", + "unconfirmedSteps": [] + } + ] + } + ], + "assistantMessageId": "message-7", + "video_target": "DO-NOT-LEAK-raw-payload" + } + }, + "source_outcome": "reject", + "browser_outcome": "ignore_additive", + "browser_expected": { + "contractVersion": "brain-source-v1", + "runId": "run-7", + "seq": 9, + "occurredAt": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "responseDisposition": "substantive", + "assistantResponse": { + "responsePages": [ + { + "responsePageId": "page-1", + "presentation": "main", + "title": "검증된 풀이", + "resultBlocks": [ + { + "blockId": "block-1", + "body": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "kind": "solution", + "verification": "verified", + "solutionId": "solution-1", + "claimIds": [ + "claim-1" + ], + "stepMarks": [ + { + "stepId": "step-1", + "status": "verified", + "anchor": { + "start": 0, + "end": 24 + } + }, + { + "stepId": "step-2", + "status": "verified", + "anchor": { + "start": 25, + "end": 46 + } + } + ] + } + ] + } + ], + "sourceReferences": [ + { + "sourceRefId": "sref-1", + "assetId": "note-alpha", + "pageStart": 3, + "pageEnd": 4, + "displayLabelSnapshot": "개념 노트 3–4쪽", + "available": true + } + ], + "plainText": "검증된 풀이\n\n인수분해하면 (x-2)(x-3)=0이다. 따라서 x=2 또는 x=3이다." + }, + "model": "fake/main-model", + "tier": "main", + "verificationSummary": [ + { + "solutionId": "solution-1", + "overallCoverage": "direct", + "claims": [ + { + "claimId": "claim-1", + "coverage": "direct", + "methodLabel": "모든 단계를 실행 검사로 확인", + "unconfirmedSteps": [] + } + ] + } + ], + "assistantMessageId": "message-7" + } + } +} diff --git a/src/test/resources/contracts/brain/terminal-v2/manifest.json b/src/test/resources/contracts/brain/terminal-v2/manifest.json new file mode 100644 index 00000000..b0c277c9 --- /dev/null +++ b/src/test/resources/contracts/brain/terminal-v2/manifest.json @@ -0,0 +1,99 @@ +{ + "consumer_version": "terminal-fixtures-v2", + "source_contract_version": "brain-source-v1", + "assistant_response_version": "assistant-response-v1", + "artifact_version": "verified-solution-v1", + "original_path": "tests/fixtures/contracts/terminal-v2", + "change_reason": "Task 1.58: run.failed gains code invalid_attachment{reason} — attachment-inspection refusals (corrupt/forged/encrypted/oversized inputs) stop reporting budget_exceeded; failure_class=attachment, recoverable=false, reason is a fixed sub-code with a fixed safe message; Server consumes it for compensation and re-upload guidance.", + "consumption_policy": "Pin terminal-fixtures-v2 at original_path; changes require a new fixture version and reason.", + "ownership": { + "brain": [ + "snake_case source", + "verified_solutions", + "selected_page_provenance", + "checked source_labels" + ], + "server": [ + "assistantMessageId", + "seq", + "page boundary events", + "commit", + "availability", + "plainText storage" + ], + "browser": [ + "camelCase public response only", + "ignore unknown additive fields; validate known fields" + ] + }, + "boundaries": "Fake consumer only; no product commit, replay, permissions or content grounding guarantee.", + "pairs": [ + "direct-verified", + "supporting-logical-move", + "zero-step-fallback", + "inconclusive-unverified", + "check-with-correction", + "hide-answer", + "clarification", + "unsupported-only", + "mixed-substantive-notice", + "multi-page", + "failure", + "failure-invalid-attachment", + "explain-existing", + "explain-existing-hide-answer", + "conflict-clarification", + "conflict-substantive" + ], + "change_history": [ + { + "date": "2026-10-01", + "revision": "218d2de", + "reason": "Initial task 1.28 source/browser freeze." + }, + { + "date": "2026-10-01", + "reason": "PR #58 independent review: pre-release v1 correction distinguishes conflict clarification, adds bounded checked existing-solution re-expression references, and makes every discriminated union tag required in both schemas. Existing positive pairs remain unchanged." + }, + { + "date": "2026-10-10", + "reason": "Task 1.58 terminal-fixtures-v2: run.failed adds FailureCode invalid_attachment, FailureClass attachment, and the reason sub-code (AttachmentFailureReason mirrors the file-inspection refusal reasons); invalid_attachment carries a per-reason fixed message and recoverable=false. Prior codes keep v1 meaning; completed/failure pairs are byte-identical." + } + ], + "existing_reference_policy": "Caller supplies checked ID/version/claim coverage only, never the existing full answer. Public summaries select referenced claims inside assemble_response and retain disclosure checks. Storage/authentication/semantic claim matching are caller responsibilities, not implemented here.", + "negative_pairs": [ + "attachment-message-mismatch", + "attachment-missing-reason", + "attachment-recoverable", + "attachment-unknown-reason", + "attachment-wrong-class", + "code", + "duplicate-page", + "existing-coverage-mismatch", + "existing-full-answer-extra", + "existing-mismatched-claim", + "existing-phantom", + "existing-unchecked", + "failure-with-answer", + "guidance-with-sources", + "kind-type-mismatch", + "metadata", + "missing-activity-kind", + "missing-block-kind", + "missing-selected-page", + "overclaimed-coverage", + "phantom-claim", + "phantom-solution", + "phantom-source", + "raw-discriminator", + "raw-extra", + "reason-on-non-attachment-code", + "source-version-conflict", + "stdout", + "unsafe-failure-message", + "unverified-as-verified", + "video_target" + ], + "validation_scope": "JSON Schema validates wire structure; Python validators enforce reference/disposition/coverage integrity and the invalid_attachment reason↔message↔class agreement. Browser cannot validate omitted internal artifacts or selected provenance; unknown additions are ignored.", + "fixture_inputs": "Copied publication-v1 requests are embedded and frozen in each pair; no evals oracle or answer metadata is used." +} diff --git a/src/test/resources/contracts/brain/terminal-v2/pairs/check-with-correction.json b/src/test/resources/contracts/brain/terminal-v2/pairs/check-with-correction.json new file mode 100644 index 00000000..6c74e6e8 --- /dev/null +++ b/src/test/resources/contracts/brain/terminal-v2/pairs/check-with-correction.json @@ -0,0 +1,318 @@ +{ + "publication_request": { + "schema_version": "publication-v1", + "disposition": "substantive", + "disclosure": { + "scope": "full", + "judgment_completed": true + }, + "pages": [ + { + "presentation": "main", + "blocks": [ + { + "kind": "check", + "draft": { + "text": "세 번째 단계에서 부호가 틀렸다.", + "withheld": [] + }, + "report": { + "status": "reject", + "claims": [ + { + "claim_id": "claim-1", + "verdict": "reject", + "basis": "chain" + } + ], + "requested_claim_count": 1, + "steps": [ + { + "step_id": "step-1", + "status": "verified", + "level": "direct" + }, + { + "step_id": "step-2", + "status": "verified", + "level": "direct" + }, + { + "step_id": "step-3", + "status": "failed", + "reason": "step_refuted" + } + ], + "first_error_step_id": "step-3" + }, + "step_anchors": [ + { + "step_id": "step-3", + "span": { + "start": 10, + "end": 17 + } + } + ] + }, + { + "kind": "solution", + "draft": { + "text": "부호를 바로잡으면 $x=2$가 맞다.", + "withheld": [] + }, + "report": { + "status": "accepted", + "claims": [ + { + "claim_id": "claim-1", + "verdict": "accepted", + "basis": "chain", + "coverage": "direct" + } + ], + "requested_claim_count": 1, + "steps": [ + { + "step_id": "step-1", + "status": "verified", + "level": "direct" + } + ] + }, + "solution_id": "solution-3", + "step_anchors": [ + { + "step_id": "step-1", + "span": { + "start": 0, + "end": 9 + } + } + ] + } + ] + } + ] + }, + "source": { + "contract_version": "brain-source-v1", + "run_id": "run-7", + "attempt": 1, + "fence_token": "fence-9f8c", + "source_event_id": "run-7:terminal:r0:b4c05df04575822cec85", + "occurred_at": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "response_disposition": "substantive", + "assistant_response": { + "schema_version": "assistant-response-v1", + "response_pages": [ + { + "response_page_id": "page-1", + "presentation": "main", + "result_blocks": [ + { + "block_id": "block-1", + "body": "세 번째 단계에서 부호가 틀렸다.", + "kind": "check", + "step_marks": [ + { + "step_id": "step-1", + "status": "verified" + }, + { + "step_id": "step-2", + "status": "verified" + }, + { + "step_id": "step-3", + "status": "failed", + "reason": "이 단계가 성립하지 않는다고 확인됐다", + "anchor": { + "start": 10, + "end": 17 + } + } + ], + "first_error_step_id": "step-3", + "quoted_spans": [] + }, + { + "block_id": "block-2", + "body": "부호를 바로잡으면 $x=2$가 맞다.", + "kind": "solution", + "verification": "verified", + "solution_id": "solution-3", + "claim_ids": [ + "claim-1" + ], + "step_marks": [ + { + "step_id": "step-1", + "status": "verified", + "anchor": { + "start": 0, + "end": 9 + } + } + ] + } + ] + } + ], + "source_references": [] + }, + "verified_solutions": [ + { + "schema_version": "verified-solution-v1", + "solution_id": "solution-3", + "version": 1, + "problem_statement": "주어진 명시 조건을 만족하는 결론을 구한다.", + "problem_conditions": [ + { + "condition_id": "condition-1", + "text": "문제의 명시 조건을 사용한다." + } + ], + "solution_text": "부호를 바로잡으면 $x=2$가 맞다.", + "claims": [ + { + "claim_id": "claim-1", + "statement": "명시 조건에서 제시한 결론이 성립한다." + } + ], + "step_marks": [ + { + "step_id": "step-1", + "status": "verified" + } + ], + "verification": { + "solution_id": "solution-3", + "overall_coverage": "direct", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "direct", + "method_label": "모든 단계를 실행 검사로 확인", + "unconfirmed_steps": [] + } + ] + }, + "verification_version": "chain-verification-report-v3", + "source_ref_ids": [] + } + ], + "selected_page_provenance": [], + "model": "fake/main-model", + "tier": "main", + "verification_summary": [ + { + "solution_id": "solution-3", + "overall_coverage": "direct", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "direct", + "method_label": "모든 단계를 실행 검사로 확인", + "unconfirmed_steps": [] + } + ] + } + ], + "source_labels": [] + } + }, + "server": { + "assistant_message_id": "message-7", + "seq": 9, + "display_labels": {}, + "availability": {} + }, + "browser": { + "contractVersion": "brain-source-v1", + "runId": "run-7", + "seq": 9, + "occurredAt": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "responseDisposition": "substantive", + "assistantResponse": { + "responsePages": [ + { + "responsePageId": "page-1", + "presentation": "main", + "resultBlocks": [ + { + "blockId": "block-1", + "body": "세 번째 단계에서 부호가 틀렸다.", + "kind": "check", + "stepMarks": [ + { + "stepId": "step-1", + "status": "verified" + }, + { + "stepId": "step-2", + "status": "verified" + }, + { + "stepId": "step-3", + "status": "failed", + "reason": "이 단계가 성립하지 않는다고 확인됐다", + "anchor": { + "start": 10, + "end": 17 + } + } + ], + "firstErrorStepId": "step-3", + "quotedSpans": [] + }, + { + "blockId": "block-2", + "body": "부호를 바로잡으면 $x=2$가 맞다.", + "kind": "solution", + "verification": "verified", + "solutionId": "solution-3", + "claimIds": [ + "claim-1" + ], + "stepMarks": [ + { + "stepId": "step-1", + "status": "verified", + "anchor": { + "start": 0, + "end": 9 + } + } + ] + } + ] + } + ], + "sourceReferences": [], + "plainText": "세 번째 단계에서 부호가 틀렸다.\n\n부호를 바로잡으면 x=2가 맞다." + }, + "model": "fake/main-model", + "tier": "main", + "verificationSummary": [ + { + "solutionId": "solution-3", + "overallCoverage": "direct", + "claims": [ + { + "claimId": "claim-1", + "coverage": "direct", + "methodLabel": "모든 단계를 실행 검사로 확인", + "unconfirmedSteps": [] + } + ] + } + ], + "assistantMessageId": "message-7" + } + } +} diff --git a/src/test/resources/contracts/brain/terminal-v2/pairs/clarification.json b/src/test/resources/contracts/brain/terminal-v2/pairs/clarification.json new file mode 100644 index 00000000..588850fc --- /dev/null +++ b/src/test/resources/contracts/brain/terminal-v2/pairs/clarification.json @@ -0,0 +1,92 @@ +{ + "publication_request": { + "schema_version": "publication-v1", + "disposition": "clarification_only", + "disclosure": { + "scope": "full", + "judgment_completed": true + }, + "pages": [], + "clarification": { + "reason": "ambiguous_target", + "draft": { + "text": "어떤 문제의 풀이를 원하는지 알려 주세요.", + "withheld": [] + } + } + }, + "source": { + "contract_version": "brain-source-v1", + "run_id": "run-7", + "attempt": 1, + "fence_token": "fence-9f8c", + "source_event_id": "run-7:terminal:r0:b4c05df04575822cec85", + "occurred_at": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "response_disposition": "clarification_only", + "clarification_reason": "ambiguous_target", + "assistant_response": { + "schema_version": "assistant-response-v1", + "response_pages": [ + { + "response_page_id": "page-1", + "presentation": "main", + "result_blocks": [ + { + "block_id": "block-1", + "body": "어떤 문제의 풀이를 원하는지 알려 주세요.", + "kind": "rich_text" + } + ] + } + ], + "source_references": [] + }, + "verified_solutions": [], + "selected_page_provenance": [], + "model": "fake/main-model", + "tier": "main", + "verification_summary": [], + "source_labels": [] + } + }, + "server": { + "assistant_message_id": "message-7", + "seq": 9, + "display_labels": {}, + "availability": {} + }, + "browser": { + "contractVersion": "brain-source-v1", + "runId": "run-7", + "seq": 9, + "occurredAt": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "responseDisposition": "clarification_only", + "clarificationReason": "ambiguous_target", + "assistantResponse": { + "responsePages": [ + { + "responsePageId": "page-1", + "presentation": "main", + "resultBlocks": [ + { + "blockId": "block-1", + "body": "어떤 문제의 풀이를 원하는지 알려 주세요.", + "kind": "rich_text" + } + ] + } + ], + "sourceReferences": [], + "plainText": "어떤 문제의 풀이를 원하는지 알려 주세요." + }, + "model": "fake/main-model", + "tier": "main", + "verificationSummary": [], + "assistantMessageId": "message-7" + } + } +} diff --git a/src/test/resources/contracts/brain/terminal-v2/pairs/conflict-clarification.json b/src/test/resources/contracts/brain/terminal-v2/pairs/conflict-clarification.json new file mode 100644 index 00000000..fdfcfde6 --- /dev/null +++ b/src/test/resources/contracts/brain/terminal-v2/pairs/conflict-clarification.json @@ -0,0 +1,129 @@ +{ + "publication_request": { + "disposition": "clarification_only", + "disclosure": { + "scope": "full", + "judgment_completed": true + }, + "clarification": { + "reason": "ambiguous_target", + "draft": { + "text": "어떤 자료의 문제 조건을 기준으로 할까요?" + } + } + }, + "source": { + "contract_version": "brain-source-v1", + "run_id": "run-7", + "attempt": 1, + "fence_token": "fence-9f8c", + "source_event_id": "run-7:terminal:r0:b4c05df04575822cec85", + "occurred_at": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "response_disposition": "clarification_only", + "clarification_reason": "ambiguous_target", + "assistant_response": { + "schema_version": "assistant-response-v1", + "response_pages": [ + { + "response_page_id": "page-1", + "presentation": "main", + "result_blocks": [ + { + "block_id": "block-1", + "body": "어떤 자료의 문제 조건을 기준으로 할까요?", + "kind": "rich_text" + } + ] + } + ], + "source_references": [] + }, + "verified_solutions": [], + "selected_page_provenance": [], + "model": "fake/main-model", + "tier": "main", + "verification_summary": [], + "source_labels": [] + } + }, + "server": { + "assistant_message_id": "message-7", + "seq": 9, + "display_labels": {}, + "availability": {} + }, + "browser": { + "contractVersion": "brain-source-v1", + "runId": "run-7", + "seq": 9, + "occurredAt": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "responseDisposition": "clarification_only", + "clarificationReason": "ambiguous_target", + "assistantResponse": { + "responsePages": [ + { + "responsePageId": "page-1", + "presentation": "main", + "resultBlocks": [ + { + "blockId": "block-1", + "body": "어떤 자료의 문제 조건을 기준으로 할까요?", + "kind": "rich_text" + } + ] + } + ], + "sourceReferences": [], + "plainText": "어떤 자료의 문제 조건을 기준으로 할까요?" + }, + "model": "fake/main-model", + "tier": "main", + "verificationSummary": [], + "assistantMessageId": "message-7" + } + }, + "progress_sources": [ + { + "contract_version": "brain-source-v1", + "run_id": "run-7", + "attempt": 1, + "fence_token": "fence-9f8c", + "source_event_id": "run-7:activity-1:r0:started", + "occurred_at": "2026-10-01T09:00:00Z", + "type": "activity.started", + "payload": { + "activity_id": "activity-1", + "kind": "note_retrieval", + "label": "searching_notes" + } + }, + { + "contract_version": "brain-source-v1", + "run_id": "run-7", + "attempt": 1, + "fence_token": "fence-9f8c", + "source_event_id": "run-7:activity-1:r1:completed", + "occurred_at": "2026-10-01T09:00:00Z", + "type": "activity.completed", + "payload": { + "activity_id": "activity-1", + "kind": "note_retrieval", + "match_status": "conflicting_content", + "pages": [ + { + "asset_id": "asset-a", + "page": 1 + }, + { + "asset_id": "asset-b", + "page": 1 + } + ] + } + } + ] +} diff --git a/src/test/resources/contracts/brain/terminal-v2/pairs/conflict-substantive.json b/src/test/resources/contracts/brain/terminal-v2/pairs/conflict-substantive.json new file mode 100644 index 00000000..b3a162a7 --- /dev/null +++ b/src/test/resources/contracts/brain/terminal-v2/pairs/conflict-substantive.json @@ -0,0 +1,218 @@ +{ + "publication_request": { + "disposition": "substantive", + "disclosure": { + "scope": "full", + "judgment_completed": true + }, + "pages": [ + { + "presentation": "main", + "blocks": [ + { + "kind": "rich_text", + "draft": { + "text": "첫 자료와 두 번째 자료의 조건이 다릅니다.[[source:sref-1]][[source:sref-2]]" + } + } + ] + } + ], + "source_references": [ + { + "source_ref_id": "sref-1", + "asset_id": "asset-a", + "page_start": 1, + "page_end": 1 + }, + { + "source_ref_id": "sref-2", + "asset_id": "asset-b", + "page_start": 1, + "page_end": 1 + } + ], + "source_labels": [ + { + "source_ref_id": "sref-1", + "label": "자료 1" + }, + { + "source_ref_id": "sref-2", + "label": "자료 2" + } + ] + }, + "source": { + "contract_version": "brain-source-v1", + "run_id": "run-7", + "attempt": 1, + "fence_token": "fence-9f8c", + "source_event_id": "run-7:terminal:r0:b4c05df04575822cec85", + "occurred_at": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "response_disposition": "substantive", + "assistant_response": { + "schema_version": "assistant-response-v1", + "response_pages": [ + { + "response_page_id": "page-1", + "presentation": "main", + "result_blocks": [ + { + "block_id": "block-1", + "body": "첫 자료와 두 번째 자료의 조건이 다릅니다.[[source:sref-1]][[source:sref-2]]", + "kind": "rich_text" + } + ] + } + ], + "source_references": [ + { + "source_ref_id": "sref-1", + "asset_id": "asset-a", + "page_start": 1, + "page_end": 1 + }, + { + "source_ref_id": "sref-2", + "asset_id": "asset-b", + "page_start": 1, + "page_end": 1 + } + ] + }, + "verified_solutions": [], + "selected_page_provenance": [ + { + "asset_id": "asset-a", + "content_version": "content-v1", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "page": 1, + "understanding_version": "page-understanding-v1" + }, + { + "asset_id": "asset-b", + "content_version": "content-v1", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "page": 1, + "understanding_version": "page-understanding-v1" + } + ], + "model": "fake/main-model", + "tier": "main", + "verification_summary": [], + "source_labels": [ + { + "source_ref_id": "sref-1", + "label": "자료 1" + }, + { + "source_ref_id": "sref-2", + "label": "자료 2" + } + ] + } + }, + "server": { + "assistant_message_id": "message-7", + "seq": 9, + "display_labels": { + "sref-1": "자료 1", + "sref-2": "자료 2" + }, + "availability": { + "sref-1": true, + "sref-2": true + } + }, + "browser": { + "contractVersion": "brain-source-v1", + "runId": "run-7", + "seq": 9, + "occurredAt": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "responseDisposition": "substantive", + "assistantResponse": { + "responsePages": [ + { + "responsePageId": "page-1", + "presentation": "main", + "resultBlocks": [ + { + "blockId": "block-1", + "body": "첫 자료와 두 번째 자료의 조건이 다릅니다.[[source:sref-1]][[source:sref-2]]", + "kind": "rich_text" + } + ] + } + ], + "sourceReferences": [ + { + "sourceRefId": "sref-1", + "assetId": "asset-a", + "pageStart": 1, + "pageEnd": 1, + "displayLabelSnapshot": "자료 1", + "available": true + }, + { + "sourceRefId": "sref-2", + "assetId": "asset-b", + "pageStart": 1, + "pageEnd": 1, + "displayLabelSnapshot": "자료 2", + "available": true + } + ], + "plainText": "첫 자료와 두 번째 자료의 조건이 다릅니다." + }, + "model": "fake/main-model", + "tier": "main", + "verificationSummary": [], + "assistantMessageId": "message-7" + } + }, + "progress_sources": [ + { + "contract_version": "brain-source-v1", + "run_id": "run-7", + "attempt": 1, + "fence_token": "fence-9f8c", + "source_event_id": "run-7:activity-1:r0:started", + "occurred_at": "2026-10-01T09:00:00Z", + "type": "activity.started", + "payload": { + "activity_id": "activity-1", + "kind": "note_retrieval", + "label": "searching_notes" + } + }, + { + "contract_version": "brain-source-v1", + "run_id": "run-7", + "attempt": 1, + "fence_token": "fence-9f8c", + "source_event_id": "run-7:activity-1:r1:completed", + "occurred_at": "2026-10-01T09:00:00Z", + "type": "activity.completed", + "payload": { + "activity_id": "activity-1", + "kind": "note_retrieval", + "match_status": "conflicting_content", + "pages": [ + { + "asset_id": "asset-a", + "page": 1 + }, + { + "asset_id": "asset-b", + "page": 1 + } + ] + } + } + ] +} diff --git a/src/test/resources/contracts/brain/terminal-v2/pairs/direct-verified.json b/src/test/resources/contracts/brain/terminal-v2/pairs/direct-verified.json new file mode 100644 index 00000000..9ec013c6 --- /dev/null +++ b/src/test/resources/contracts/brain/terminal-v2/pairs/direct-verified.json @@ -0,0 +1,312 @@ +{ + "publication_request": { + "schema_version": "publication-v1", + "disposition": "substantive", + "disclosure": { + "scope": "full", + "judgment_completed": true + }, + "pages": [ + { + "presentation": "main", + "blocks": [ + { + "kind": "solution", + "draft": { + "text": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "withheld": [] + }, + "report": { + "status": "accepted", + "claims": [ + { + "claim_id": "claim-1", + "verdict": "accepted", + "basis": "chain", + "coverage": "direct" + } + ], + "requested_claim_count": 1, + "steps": [ + { + "step_id": "step-1", + "status": "verified", + "level": "direct" + }, + { + "step_id": "step-2", + "status": "verified", + "level": "direct" + } + ] + }, + "solution_id": "solution-1", + "step_anchors": [ + { + "step_id": "step-1", + "span": { + "start": 0, + "end": 24 + } + }, + { + "step_id": "step-2", + "span": { + "start": 25, + "end": 46 + } + } + ] + } + ], + "title": "검증된 풀이" + } + ], + "source_references": [ + { + "source_ref_id": "sref-1", + "asset_id": "note-alpha", + "page_start": 3, + "page_end": 4 + } + ], + "source_labels": [ + { + "source_ref_id": "sref-1", + "label": "개념 노트 3–4쪽" + } + ] + }, + "source": { + "contract_version": "brain-source-v1", + "run_id": "run-7", + "attempt": 1, + "fence_token": "fence-9f8c", + "source_event_id": "run-7:terminal:r0:b4c05df04575822cec85", + "occurred_at": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "response_disposition": "substantive", + "assistant_response": { + "schema_version": "assistant-response-v1", + "response_pages": [ + { + "response_page_id": "page-1", + "presentation": "main", + "title": "검증된 풀이", + "result_blocks": [ + { + "block_id": "block-1", + "body": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "kind": "solution", + "verification": "verified", + "solution_id": "solution-1", + "claim_ids": [ + "claim-1" + ], + "step_marks": [ + { + "step_id": "step-1", + "status": "verified", + "anchor": { + "start": 0, + "end": 24 + } + }, + { + "step_id": "step-2", + "status": "verified", + "anchor": { + "start": 25, + "end": 46 + } + } + ] + } + ] + } + ], + "source_references": [ + { + "source_ref_id": "sref-1", + "asset_id": "note-alpha", + "page_start": 3, + "page_end": 4 + } + ] + }, + "verified_solutions": [ + { + "schema_version": "verified-solution-v1", + "solution_id": "solution-1", + "version": 1, + "problem_statement": "주어진 명시 조건을 만족하는 결론을 구한다.", + "problem_conditions": [ + { + "condition_id": "condition-1", + "text": "문제의 명시 조건을 사용한다." + } + ], + "solution_text": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "claims": [ + { + "claim_id": "claim-1", + "statement": "명시 조건에서 제시한 결론이 성립한다." + } + ], + "step_marks": [ + { + "step_id": "step-1", + "status": "verified" + }, + { + "step_id": "step-2", + "status": "verified" + } + ], + "verification": { + "solution_id": "solution-1", + "overall_coverage": "direct", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "direct", + "method_label": "모든 단계를 실행 검사로 확인", + "unconfirmed_steps": [] + } + ] + }, + "verification_version": "chain-verification-report-v3", + "source_ref_ids": [ + "sref-1" + ] + } + ], + "selected_page_provenance": [ + { + "asset_id": "note-alpha", + "content_version": "content-v1", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "page": 3, + "understanding_version": "page-understanding-v1" + }, + { + "asset_id": "note-alpha", + "content_version": "content-v1", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "page": 4, + "understanding_version": "page-understanding-v1" + } + ], + "model": "fake/main-model", + "tier": "main", + "verification_summary": [ + { + "solution_id": "solution-1", + "overall_coverage": "direct", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "direct", + "method_label": "모든 단계를 실행 검사로 확인", + "unconfirmed_steps": [] + } + ] + } + ], + "source_labels": [ + { + "source_ref_id": "sref-1", + "label": "개념 노트 3–4쪽" + } + ] + } + }, + "server": { + "assistant_message_id": "message-7", + "seq": 9, + "display_labels": { + "sref-1": "개념 노트 3–4쪽" + }, + "availability": { + "sref-1": true + } + }, + "browser": { + "contractVersion": "brain-source-v1", + "runId": "run-7", + "seq": 9, + "occurredAt": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "responseDisposition": "substantive", + "assistantResponse": { + "responsePages": [ + { + "responsePageId": "page-1", + "presentation": "main", + "title": "검증된 풀이", + "resultBlocks": [ + { + "blockId": "block-1", + "body": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "kind": "solution", + "verification": "verified", + "solutionId": "solution-1", + "claimIds": [ + "claim-1" + ], + "stepMarks": [ + { + "stepId": "step-1", + "status": "verified", + "anchor": { + "start": 0, + "end": 24 + } + }, + { + "stepId": "step-2", + "status": "verified", + "anchor": { + "start": 25, + "end": 46 + } + } + ] + } + ] + } + ], + "sourceReferences": [ + { + "sourceRefId": "sref-1", + "assetId": "note-alpha", + "pageStart": 3, + "pageEnd": 4, + "displayLabelSnapshot": "개념 노트 3–4쪽", + "available": true + } + ], + "plainText": "검증된 풀이\n\n인수분해하면 (x-2)(x-3)=0이다. 따라서 x=2 또는 x=3이다." + }, + "model": "fake/main-model", + "tier": "main", + "verificationSummary": [ + { + "solutionId": "solution-1", + "overallCoverage": "direct", + "claims": [ + { + "claimId": "claim-1", + "coverage": "direct", + "methodLabel": "모든 단계를 실행 검사로 확인", + "unconfirmedSteps": [] + } + ] + } + ], + "assistantMessageId": "message-7" + } + } +} diff --git a/src/test/resources/contracts/brain/terminal-v2/pairs/explain-existing-hide-answer.json b/src/test/resources/contracts/brain/terminal-v2/pairs/explain-existing-hide-answer.json new file mode 100644 index 00000000..5fa4250d --- /dev/null +++ b/src/test/resources/contracts/brain/terminal-v2/pairs/explain-existing-hide-answer.json @@ -0,0 +1,176 @@ +{ + "publication_request": { + "disposition": "substantive", + "disclosure": { + "scope": "hide_answer", + "judgment_completed": true + }, + "pages": [ + { + "presentation": "main", + "blocks": [ + { + "kind": "explanation", + "solution_id": "solution-1", + "claim_ids": [ + "claim-1" + ], + "draft": { + "text": "양변에 같은 연산을 적용하는 이유를 설명합니다. 최종 답은 $x=2$입니다.", + "withheld": [ + { + "start": 26, + "end": 42 + } + ] + } + } + ] + } + ], + "existing_solution_references": [ + { + "solution_id": "solution-1", + "version": 1, + "claim_match_checked": true, + "verification": { + "solution_id": "solution-1", + "overall_coverage": "direct", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "direct", + "method_label": "모든 단계를 실행 검사로 확인", + "unconfirmed_steps": [] + } + ] + } + } + ] + }, + "source": { + "contract_version": "brain-source-v1", + "run_id": "run-7", + "attempt": 1, + "fence_token": "fence-9f8c", + "source_event_id": "run-7:terminal:r0:b4c05df04575822cec85", + "occurred_at": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "response_disposition": "substantive", + "assistant_response": { + "schema_version": "assistant-response-v1", + "response_pages": [ + { + "response_page_id": "page-1", + "presentation": "main", + "result_blocks": [ + { + "block_id": "block-1", + "body": "양변에 같은 연산을 적용하는 이유를 설명합니다.", + "kind": "explanation", + "solution_id": "solution-1", + "claim_ids": [ + "claim-1" + ] + } + ] + } + ], + "source_references": [] + }, + "verified_solutions": [], + "existing_solution_references": [ + { + "solution_id": "solution-1", + "version": 1, + "verification": { + "solution_id": "solution-1", + "overall_coverage": "direct", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "direct", + "method_label": "모든 단계를 실행 검사로 확인", + "unconfirmed_steps": [] + } + ] + }, + "claim_match_checked": true + } + ], + "selected_page_provenance": [], + "model": "fake/main-model", + "tier": "main", + "verification_summary": [ + { + "solution_id": "solution-1", + "overall_coverage": "direct", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "direct", + "method_label": "모든 단계를 실행 검사로 확인", + "unconfirmed_steps": [] + } + ] + } + ], + "source_labels": [] + } + }, + "server": { + "assistant_message_id": "message-7", + "seq": 9, + "display_labels": {}, + "availability": {} + }, + "browser": { + "contractVersion": "brain-source-v1", + "runId": "run-7", + "seq": 9, + "occurredAt": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "responseDisposition": "substantive", + "assistantResponse": { + "responsePages": [ + { + "responsePageId": "page-1", + "presentation": "main", + "resultBlocks": [ + { + "blockId": "block-1", + "body": "양변에 같은 연산을 적용하는 이유를 설명합니다.", + "kind": "explanation", + "solutionId": "solution-1", + "claimIds": [ + "claim-1" + ] + } + ] + } + ], + "sourceReferences": [], + "plainText": "양변에 같은 연산을 적용하는 이유를 설명합니다." + }, + "model": "fake/main-model", + "tier": "main", + "verificationSummary": [ + { + "solutionId": "solution-1", + "overallCoverage": "direct", + "claims": [ + { + "claimId": "claim-1", + "coverage": "direct", + "methodLabel": "모든 단계를 실행 검사로 확인", + "unconfirmedSteps": [] + } + ] + } + ], + "assistantMessageId": "message-7" + } + } +} diff --git a/src/test/resources/contracts/brain/terminal-v2/pairs/explain-existing.json b/src/test/resources/contracts/brain/terminal-v2/pairs/explain-existing.json new file mode 100644 index 00000000..25848c6e --- /dev/null +++ b/src/test/resources/contracts/brain/terminal-v2/pairs/explain-existing.json @@ -0,0 +1,170 @@ +{ + "publication_request": { + "disposition": "substantive", + "disclosure": { + "scope": "full", + "judgment_completed": true + }, + "pages": [ + { + "presentation": "main", + "blocks": [ + { + "kind": "explanation", + "solution_id": "solution-1", + "claim_ids": [ + "claim-1" + ], + "draft": { + "text": "양변에 같은 연산을 적용하는 이유를 설명합니다." + } + } + ] + } + ], + "existing_solution_references": [ + { + "solution_id": "solution-1", + "version": 1, + "claim_match_checked": true, + "verification": { + "solution_id": "solution-1", + "overall_coverage": "direct", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "direct", + "method_label": "모든 단계를 실행 검사로 확인", + "unconfirmed_steps": [] + } + ] + } + } + ] + }, + "source": { + "contract_version": "brain-source-v1", + "run_id": "run-7", + "attempt": 1, + "fence_token": "fence-9f8c", + "source_event_id": "run-7:terminal:r0:b4c05df04575822cec85", + "occurred_at": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "response_disposition": "substantive", + "assistant_response": { + "schema_version": "assistant-response-v1", + "response_pages": [ + { + "response_page_id": "page-1", + "presentation": "main", + "result_blocks": [ + { + "block_id": "block-1", + "body": "양변에 같은 연산을 적용하는 이유를 설명합니다.", + "kind": "explanation", + "solution_id": "solution-1", + "claim_ids": [ + "claim-1" + ] + } + ] + } + ], + "source_references": [] + }, + "verified_solutions": [], + "existing_solution_references": [ + { + "solution_id": "solution-1", + "version": 1, + "verification": { + "solution_id": "solution-1", + "overall_coverage": "direct", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "direct", + "method_label": "모든 단계를 실행 검사로 확인", + "unconfirmed_steps": [] + } + ] + }, + "claim_match_checked": true + } + ], + "selected_page_provenance": [], + "model": "fake/main-model", + "tier": "main", + "verification_summary": [ + { + "solution_id": "solution-1", + "overall_coverage": "direct", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "direct", + "method_label": "모든 단계를 실행 검사로 확인", + "unconfirmed_steps": [] + } + ] + } + ], + "source_labels": [] + } + }, + "server": { + "assistant_message_id": "message-7", + "seq": 9, + "display_labels": {}, + "availability": {} + }, + "browser": { + "contractVersion": "brain-source-v1", + "runId": "run-7", + "seq": 9, + "occurredAt": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "responseDisposition": "substantive", + "assistantResponse": { + "responsePages": [ + { + "responsePageId": "page-1", + "presentation": "main", + "resultBlocks": [ + { + "blockId": "block-1", + "body": "양변에 같은 연산을 적용하는 이유를 설명합니다.", + "kind": "explanation", + "solutionId": "solution-1", + "claimIds": [ + "claim-1" + ] + } + ] + } + ], + "sourceReferences": [], + "plainText": "양변에 같은 연산을 적용하는 이유를 설명합니다." + }, + "model": "fake/main-model", + "tier": "main", + "verificationSummary": [ + { + "solutionId": "solution-1", + "overallCoverage": "direct", + "claims": [ + { + "claimId": "claim-1", + "coverage": "direct", + "methodLabel": "모든 단계를 실행 검사로 확인", + "unconfirmedSteps": [] + } + ] + } + ], + "assistantMessageId": "message-7" + } + } +} diff --git a/src/test/resources/contracts/brain/terminal-v2/pairs/failure-invalid-attachment.json b/src/test/resources/contracts/brain/terminal-v2/pairs/failure-invalid-attachment.json new file mode 100644 index 00000000..0e8552b2 --- /dev/null +++ b/src/test/resources/contracts/brain/terminal-v2/pairs/failure-invalid-attachment.json @@ -0,0 +1,36 @@ +{ + "source": { + "contract_version": "brain-source-v1", + "run_id": "run-7", + "attempt": 1, + "fence_token": "fence-9f8c", + "source_event_id": "run-7:terminal:r0:b4c05df04575822cec85", + "occurred_at": "2026-10-01T09:00:00Z", + "type": "run.failed", + "payload": { + "code": "invalid_attachment", + "reason": "pdf_encrypted", + "message": "암호로 보호된 PDF는 처리할 수 없습니다. 암호를 해제한 PDF를 다시 올려주세요.", + "failure_class": "attachment", + "recoverable": false + } + }, + "server": { + "assistant_message_id": "message-7", + "seq": 9 + }, + "browser": { + "contractVersion": "brain-source-v1", + "runId": "run-7", + "seq": 9, + "occurredAt": "2026-10-01T09:00:00Z", + "type": "run.failed", + "payload": { + "code": "invalid_attachment", + "reason": "pdf_encrypted", + "message": "암호로 보호된 PDF는 처리할 수 없습니다. 암호를 해제한 PDF를 다시 올려주세요.", + "recoverable": false, + "assistantMessageId": "message-7" + } + } +} diff --git a/src/test/resources/contracts/brain/terminal-v2/pairs/failure.json b/src/test/resources/contracts/brain/terminal-v2/pairs/failure.json new file mode 100644 index 00000000..4ff71aab --- /dev/null +++ b/src/test/resources/contracts/brain/terminal-v2/pairs/failure.json @@ -0,0 +1,34 @@ +{ + "source": { + "contract_version": "brain-source-v1", + "run_id": "run-7", + "attempt": 1, + "fence_token": "fence-9f8c", + "source_event_id": "run-7:terminal:r0:b4c05df04575822cec85", + "occurred_at": "2026-10-01T09:00:00Z", + "type": "run.failed", + "payload": { + "code": "verification_failed", + "message": "풀이의 오류를 수정하지 못했습니다. 새 요청으로 다시 시도할 수 있습니다.", + "failure_class": "verification", + "recoverable": true + } + }, + "server": { + "assistant_message_id": "message-7", + "seq": 9 + }, + "browser": { + "contractVersion": "brain-source-v1", + "runId": "run-7", + "seq": 9, + "occurredAt": "2026-10-01T09:00:00Z", + "type": "run.failed", + "payload": { + "code": "verification_failed", + "message": "풀이의 오류를 수정하지 못했습니다. 새 요청으로 다시 시도할 수 있습니다.", + "recoverable": true, + "assistantMessageId": "message-7" + } + } +} diff --git a/src/test/resources/contracts/brain/terminal-v2/pairs/hide-answer.json b/src/test/resources/contracts/brain/terminal-v2/pairs/hide-answer.json new file mode 100644 index 00000000..c67bdf11 --- /dev/null +++ b/src/test/resources/contracts/brain/terminal-v2/pairs/hide-answer.json @@ -0,0 +1,288 @@ +{ + "publication_request": { + "schema_version": "publication-v1", + "disposition": "substantive", + "disclosure": { + "scope": "hide_answer", + "judgment_completed": true + }, + "pages": [ + { + "presentation": "main", + "blocks": [ + { + "kind": "check", + "draft": { + "text": "두 번째 단계의 변형이 틀렸다. 올바른 결론은 $x=2$이다.", + "withheld": [ + { + "start": 17, + "end": 34 + } + ] + }, + "report": { + "status": "reject", + "claims": [ + { + "claim_id": "claim-1", + "verdict": "reject", + "basis": "chain" + } + ], + "requested_claim_count": 1, + "steps": [ + { + "step_id": "step-1", + "status": "failed", + "reason": "step_refuted" + } + ], + "first_error_step_id": "step-1" + } + }, + { + "kind": "solution", + "draft": { + "text": "양변에 3을 더해 정리한다. 최종 답은 $x=2$이다.", + "withheld": [ + { + "start": 15, + "end": 30 + } + ] + }, + "report": { + "status": "accepted", + "claims": [ + { + "claim_id": "claim-1", + "verdict": "accepted", + "basis": "chain", + "coverage": "direct" + } + ], + "requested_claim_count": 1, + "steps": [ + { + "step_id": "step-1", + "status": "verified", + "level": "direct" + } + ] + }, + "solution_id": "solution-4", + "step_anchors": [ + { + "step_id": "step-1", + "span": { + "start": 0, + "end": 15 + } + } + ] + } + ], + "title": "오류 설명과 수정 방향" + } + ] + }, + "source": { + "contract_version": "brain-source-v1", + "run_id": "run-7", + "attempt": 1, + "fence_token": "fence-9f8c", + "source_event_id": "run-7:terminal:r0:b4c05df04575822cec85", + "occurred_at": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "response_disposition": "substantive", + "assistant_response": { + "schema_version": "assistant-response-v1", + "response_pages": [ + { + "response_page_id": "page-1", + "presentation": "main", + "title": "오류 설명과 수정 방향", + "result_blocks": [ + { + "block_id": "block-1", + "body": "두 번째 단계의 변형이 틀렸다.", + "kind": "check", + "step_marks": [ + { + "step_id": "step-1", + "status": "failed", + "reason": "이 단계가 성립하지 않는다고 확인됐다" + } + ], + "first_error_step_id": "step-1", + "quoted_spans": [] + }, + { + "block_id": "block-2", + "body": "양변에 3을 더해 정리한다.", + "kind": "solution", + "verification": "verified", + "solution_id": "solution-4", + "claim_ids": [ + "claim-1" + ], + "step_marks": [ + { + "step_id": "step-1", + "status": "verified", + "anchor": { + "start": 0, + "end": 15 + } + } + ] + } + ] + } + ], + "source_references": [] + }, + "verified_solutions": [ + { + "schema_version": "verified-solution-v1", + "solution_id": "solution-4", + "version": 1, + "problem_statement": "주어진 명시 조건을 만족하는 결론을 구한다.", + "problem_conditions": [ + { + "condition_id": "condition-1", + "text": "문제의 명시 조건을 사용한다." + } + ], + "solution_text": "양변에 3을 더해 정리한다. 최종 답은 $x=2$이다.", + "claims": [ + { + "claim_id": "claim-1", + "statement": "최종 답은 $x=2$이다." + } + ], + "step_marks": [ + { + "step_id": "step-1", + "status": "verified" + } + ], + "verification": { + "solution_id": "solution-4", + "overall_coverage": "direct", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "direct", + "method_label": "모든 단계를 실행 검사로 확인", + "unconfirmed_steps": [] + } + ] + }, + "verification_version": "chain-verification-report-v3", + "source_ref_ids": [] + } + ], + "selected_page_provenance": [], + "model": "fake/main-model", + "tier": "main", + "verification_summary": [ + { + "solution_id": "solution-4", + "overall_coverage": "direct", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "direct", + "method_label": "모든 단계를 실행 검사로 확인", + "unconfirmed_steps": [] + } + ] + } + ], + "source_labels": [] + } + }, + "server": { + "assistant_message_id": "message-7", + "seq": 9, + "display_labels": {}, + "availability": {} + }, + "browser": { + "contractVersion": "brain-source-v1", + "runId": "run-7", + "seq": 9, + "occurredAt": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "responseDisposition": "substantive", + "assistantResponse": { + "responsePages": [ + { + "responsePageId": "page-1", + "presentation": "main", + "title": "오류 설명과 수정 방향", + "resultBlocks": [ + { + "blockId": "block-1", + "body": "두 번째 단계의 변형이 틀렸다.", + "kind": "check", + "stepMarks": [ + { + "stepId": "step-1", + "status": "failed", + "reason": "이 단계가 성립하지 않는다고 확인됐다" + } + ], + "firstErrorStepId": "step-1", + "quotedSpans": [] + }, + { + "blockId": "block-2", + "body": "양변에 3을 더해 정리한다.", + "kind": "solution", + "verification": "verified", + "solutionId": "solution-4", + "claimIds": [ + "claim-1" + ], + "stepMarks": [ + { + "stepId": "step-1", + "status": "verified", + "anchor": { + "start": 0, + "end": 15 + } + } + ] + } + ] + } + ], + "sourceReferences": [], + "plainText": "오류 설명과 수정 방향\n\n두 번째 단계의 변형이 틀렸다.\n\n양변에 3을 더해 정리한다." + }, + "model": "fake/main-model", + "tier": "main", + "verificationSummary": [ + { + "solutionId": "solution-4", + "overallCoverage": "direct", + "claims": [ + { + "claimId": "claim-1", + "coverage": "direct", + "methodLabel": "모든 단계를 실행 검사로 확인", + "unconfirmedSteps": [] + } + ] + } + ], + "assistantMessageId": "message-7" + } + } +} diff --git a/src/test/resources/contracts/brain/terminal-v2/pairs/inconclusive-unverified.json b/src/test/resources/contracts/brain/terminal-v2/pairs/inconclusive-unverified.json new file mode 100644 index 00000000..bd77a5fe --- /dev/null +++ b/src/test/resources/contracts/brain/terminal-v2/pairs/inconclusive-unverified.json @@ -0,0 +1,162 @@ +{ + "publication_request": { + "schema_version": "publication-v1", + "disposition": "substantive", + "disclosure": { + "scope": "full", + "judgment_completed": true + }, + "pages": [ + { + "presentation": "main", + "blocks": [ + { + "kind": "solution", + "draft": { + "text": "인수분해는 맞다. 마지막 합산은 확인되지 않았다.", + "withheld": [] + }, + "report": { + "status": "inconclusive", + "claims": [ + { + "claim_id": "claim-1", + "verdict": "inconclusive", + "basis": "chain" + } + ], + "requested_claim_count": 1, + "steps": [ + { + "step_id": "step-1", + "status": "verified", + "level": "direct" + }, + { + "step_id": "step-2", + "status": "unconfirmed", + "reason": "no_evidence" + } + ] + }, + "step_anchors": [ + { + "step_id": "step-1", + "span": { + "start": 0, + "end": 9 + } + } + ] + } + ] + } + ] + }, + "source": { + "contract_version": "brain-source-v1", + "run_id": "run-7", + "attempt": 1, + "fence_token": "fence-9f8c", + "source_event_id": "run-7:terminal:r0:b4c05df04575822cec85", + "occurred_at": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "response_disposition": "substantive", + "assistant_response": { + "schema_version": "assistant-response-v1", + "response_pages": [ + { + "response_page_id": "page-1", + "presentation": "main", + "result_blocks": [ + { + "block_id": "block-1", + "body": "인수분해는 맞다. 마지막 합산은 확인되지 않았다.", + "kind": "solution", + "verification": "unverified", + "claim_ids": [], + "step_marks": [ + { + "step_id": "step-1", + "status": "verified", + "anchor": { + "start": 0, + "end": 9 + } + }, + { + "step_id": "step-2", + "status": "unconfirmed", + "reason": "검사 실행 증거가 없다" + } + ] + } + ] + } + ], + "source_references": [] + }, + "verified_solutions": [], + "selected_page_provenance": [], + "model": "fake/main-model", + "tier": "main", + "verification_summary": [], + "source_labels": [] + } + }, + "server": { + "assistant_message_id": "message-7", + "seq": 9, + "display_labels": {}, + "availability": {} + }, + "browser": { + "contractVersion": "brain-source-v1", + "runId": "run-7", + "seq": 9, + "occurredAt": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "responseDisposition": "substantive", + "assistantResponse": { + "responsePages": [ + { + "responsePageId": "page-1", + "presentation": "main", + "resultBlocks": [ + { + "blockId": "block-1", + "body": "인수분해는 맞다. 마지막 합산은 확인되지 않았다.", + "kind": "solution", + "verification": "unverified", + "claimIds": [], + "stepMarks": [ + { + "stepId": "step-1", + "status": "verified", + "anchor": { + "start": 0, + "end": 9 + } + }, + { + "stepId": "step-2", + "status": "unconfirmed", + "reason": "검사 실행 증거가 없다" + } + ] + } + ] + } + ], + "sourceReferences": [], + "plainText": "인수분해는 맞다. 마지막 합산은 확인되지 않았다." + }, + "model": "fake/main-model", + "tier": "main", + "verificationSummary": [], + "assistantMessageId": "message-7" + } + } +} diff --git a/src/test/resources/contracts/brain/terminal-v2/pairs/mixed-substantive-notice.json b/src/test/resources/contracts/brain/terminal-v2/pairs/mixed-substantive-notice.json new file mode 100644 index 00000000..ceb12d40 --- /dev/null +++ b/src/test/resources/contracts/brain/terminal-v2/pairs/mixed-substantive-notice.json @@ -0,0 +1,107 @@ +{ + "publication_request": { + "schema_version": "publication-v1", + "disposition": "substantive", + "disclosure": { + "scope": "full", + "judgment_completed": true + }, + "pages": [ + { + "presentation": "main", + "blocks": [ + { + "kind": "rich_text", + "draft": { + "text": "지원하는 부분의 풀이 결과다.", + "withheld": [] + } + } + ] + } + ], + "unsupported_notice": true + }, + "source": { + "contract_version": "brain-source-v1", + "run_id": "run-7", + "attempt": 1, + "fence_token": "fence-9f8c", + "source_event_id": "run-7:terminal:r0:b4c05df04575822cec85", + "occurred_at": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "response_disposition": "substantive", + "assistant_response": { + "schema_version": "assistant-response-v1", + "response_pages": [ + { + "response_page_id": "page-1", + "presentation": "main", + "result_blocks": [ + { + "block_id": "block-1", + "body": "지원하는 부분의 풀이 결과다.", + "kind": "rich_text" + }, + { + "block_id": "block-2", + "body": "요청 중 변형·연습문제 생성 부분은 아직 지원하지 않아 그 결과가 없다.", + "kind": "notice" + } + ] + } + ], + "source_references": [] + }, + "verified_solutions": [], + "selected_page_provenance": [], + "model": "fake/main-model", + "tier": "main", + "verification_summary": [], + "source_labels": [] + } + }, + "server": { + "assistant_message_id": "message-7", + "seq": 9, + "display_labels": {}, + "availability": {} + }, + "browser": { + "contractVersion": "brain-source-v1", + "runId": "run-7", + "seq": 9, + "occurredAt": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "responseDisposition": "substantive", + "assistantResponse": { + "responsePages": [ + { + "responsePageId": "page-1", + "presentation": "main", + "resultBlocks": [ + { + "blockId": "block-1", + "body": "지원하는 부분의 풀이 결과다.", + "kind": "rich_text" + }, + { + "blockId": "block-2", + "body": "요청 중 변형·연습문제 생성 부분은 아직 지원하지 않아 그 결과가 없다.", + "kind": "notice" + } + ] + } + ], + "sourceReferences": [], + "plainText": "지원하는 부분의 풀이 결과다.\n\n요청 중 변형·연습문제 생성 부분은 아직 지원하지 않아 그 결과가 없다." + }, + "model": "fake/main-model", + "tier": "main", + "verificationSummary": [], + "assistantMessageId": "message-7" + } + } +} diff --git a/src/test/resources/contracts/brain/terminal-v2/pairs/multi-page.json b/src/test/resources/contracts/brain/terminal-v2/pairs/multi-page.json new file mode 100644 index 00000000..75db6bfa --- /dev/null +++ b/src/test/resources/contracts/brain/terminal-v2/pairs/multi-page.json @@ -0,0 +1,246 @@ +{ + "publication_request": { + "schema_version": "publication-v1", + "disposition": "substantive", + "disclosure": { + "scope": "full", + "judgment_completed": true + }, + "pages": [ + { + "presentation": "main", + "blocks": [ + { + "kind": "solution", + "draft": { + "text": "본문 풀이가 여기 있다.", + "withheld": [] + }, + "report": { + "status": "accepted", + "claims": [ + { + "claim_id": "claim-1", + "verdict": "accepted", + "basis": "chain", + "coverage": "direct" + } + ], + "requested_claim_count": 1, + "steps": [ + { + "step_id": "step-1", + "status": "verified", + "level": "direct" + } + ] + }, + "solution_id": "solution-7" + } + ], + "title": "본문" + }, + { + "presentation": "thread_like", + "blocks": [ + { + "kind": "explanation", + "draft": { + "text": "스레드에 붙는 보조 설명이다.", + "withheld": [] + } + } + ], + "title": "보조" + } + ] + }, + "source": { + "contract_version": "brain-source-v1", + "run_id": "run-7", + "attempt": 1, + "fence_token": "fence-9f8c", + "source_event_id": "run-7:terminal:r0:b4c05df04575822cec85", + "occurred_at": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "response_disposition": "substantive", + "assistant_response": { + "schema_version": "assistant-response-v1", + "response_pages": [ + { + "response_page_id": "page-1", + "presentation": "main", + "title": "본문", + "result_blocks": [ + { + "block_id": "block-1", + "body": "본문 풀이가 여기 있다.", + "kind": "solution", + "verification": "verified", + "solution_id": "solution-7", + "claim_ids": [ + "claim-1" + ], + "step_marks": [ + { + "step_id": "step-1", + "status": "verified" + } + ] + } + ] + }, + { + "response_page_id": "page-2", + "presentation": "thread_like", + "title": "보조", + "result_blocks": [ + { + "block_id": "block-2", + "body": "스레드에 붙는 보조 설명이다.", + "kind": "explanation", + "claim_ids": [] + } + ] + } + ], + "source_references": [] + }, + "verified_solutions": [ + { + "schema_version": "verified-solution-v1", + "solution_id": "solution-7", + "version": 1, + "problem_statement": "주어진 명시 조건을 만족하는 결론을 구한다.", + "problem_conditions": [ + { + "condition_id": "condition-1", + "text": "문제의 명시 조건을 사용한다." + } + ], + "solution_text": "본문 풀이가 여기 있다.", + "claims": [ + { + "claim_id": "claim-1", + "statement": "명시 조건에서 제시한 결론이 성립한다." + } + ], + "step_marks": [ + { + "step_id": "step-1", + "status": "verified" + } + ], + "verification": { + "solution_id": "solution-7", + "overall_coverage": "direct", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "direct", + "method_label": "모든 단계를 실행 검사로 확인", + "unconfirmed_steps": [] + } + ] + }, + "verification_version": "chain-verification-report-v3", + "source_ref_ids": [] + } + ], + "selected_page_provenance": [], + "model": "fake/main-model", + "tier": "main", + "verification_summary": [ + { + "solution_id": "solution-7", + "overall_coverage": "direct", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "direct", + "method_label": "모든 단계를 실행 검사로 확인", + "unconfirmed_steps": [] + } + ] + } + ], + "source_labels": [] + } + }, + "server": { + "assistant_message_id": "message-7", + "seq": 9, + "display_labels": {}, + "availability": {} + }, + "browser": { + "contractVersion": "brain-source-v1", + "runId": "run-7", + "seq": 9, + "occurredAt": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "responseDisposition": "substantive", + "assistantResponse": { + "responsePages": [ + { + "responsePageId": "page-1", + "presentation": "main", + "title": "본문", + "resultBlocks": [ + { + "blockId": "block-1", + "body": "본문 풀이가 여기 있다.", + "kind": "solution", + "verification": "verified", + "solutionId": "solution-7", + "claimIds": [ + "claim-1" + ], + "stepMarks": [ + { + "stepId": "step-1", + "status": "verified" + } + ] + } + ] + }, + { + "responsePageId": "page-2", + "presentation": "thread_like", + "title": "보조", + "resultBlocks": [ + { + "blockId": "block-2", + "body": "스레드에 붙는 보조 설명이다.", + "kind": "explanation", + "claimIds": [] + } + ] + } + ], + "sourceReferences": [], + "plainText": "본문\n\n본문 풀이가 여기 있다.\n\n보조\n\n스레드에 붙는 보조 설명이다." + }, + "model": "fake/main-model", + "tier": "main", + "verificationSummary": [ + { + "solutionId": "solution-7", + "overallCoverage": "direct", + "claims": [ + { + "claimId": "claim-1", + "coverage": "direct", + "methodLabel": "모든 단계를 실행 검사로 확인", + "unconfirmedSteps": [] + } + ] + } + ], + "assistantMessageId": "message-7" + } + } +} diff --git a/src/test/resources/contracts/brain/terminal-v2/pairs/supporting-logical-move.json b/src/test/resources/contracts/brain/terminal-v2/pairs/supporting-logical-move.json new file mode 100644 index 00000000..d75bd464 --- /dev/null +++ b/src/test/resources/contracts/brain/terminal-v2/pairs/supporting-logical-move.json @@ -0,0 +1,272 @@ +{ + "publication_request": { + "schema_version": "publication-v1", + "disposition": "substantive", + "disclosure": { + "scope": "full", + "judgment_completed": true + }, + "pages": [ + { + "presentation": "main", + "blocks": [ + { + "kind": "solution", + "draft": { + "text": "√2=p/q라 가정한다. 대입하면 모순이다. 따라서 √2는 무리수이다.", + "withheld": [] + }, + "report": { + "status": "accepted", + "claims": [ + { + "claim_id": "claim-1", + "verdict": "accepted", + "basis": "chain", + "coverage": "supporting" + } + ], + "requested_claim_count": 1, + "steps": [ + { + "step_id": "step-1", + "status": "verified", + "level": "direct" + }, + { + "step_id": "step-2", + "status": "unconfirmed", + "reason": "legitimate_logical_move", + "settled_logical_move": true + } + ] + }, + "solution_id": "solution-2", + "step_anchors": [ + { + "step_id": "step-1", + "span": { + "start": 0, + "end": 13 + } + }, + { + "step_id": "step-2", + "span": { + "start": 14, + "end": 24 + } + } + ] + } + ] + } + ] + }, + "source": { + "contract_version": "brain-source-v1", + "run_id": "run-7", + "attempt": 1, + "fence_token": "fence-9f8c", + "source_event_id": "run-7:terminal:r0:b4c05df04575822cec85", + "occurred_at": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "response_disposition": "substantive", + "assistant_response": { + "schema_version": "assistant-response-v1", + "response_pages": [ + { + "response_page_id": "page-1", + "presentation": "main", + "result_blocks": [ + { + "block_id": "block-1", + "body": "√2=p/q라 가정한다. 대입하면 모순이다. 따라서 √2는 무리수이다.", + "kind": "solution", + "verification": "partially_verified", + "solution_id": "solution-2", + "claim_ids": [ + "claim-1" + ], + "step_marks": [ + { + "step_id": "step-1", + "status": "verified", + "anchor": { + "start": 0, + "end": 13 + } + }, + { + "step_id": "step-2", + "status": "unconfirmed", + "reason": "실행 검사 대상이 아닌 논리 수사 단계로 확인됐다", + "anchor": { + "start": 14, + "end": 24 + } + } + ] + } + ] + } + ], + "source_references": [] + }, + "verified_solutions": [ + { + "schema_version": "verified-solution-v1", + "solution_id": "solution-2", + "version": 1, + "problem_statement": "주어진 명시 조건을 만족하는 결론을 구한다.", + "problem_conditions": [ + { + "condition_id": "condition-1", + "text": "문제의 명시 조건을 사용한다." + } + ], + "solution_text": "√2=p/q라 가정한다. 대입하면 모순이다. 따라서 √2는 무리수이다.", + "claims": [ + { + "claim_id": "claim-1", + "statement": "명시 조건에서 제시한 결론이 성립한다." + } + ], + "step_marks": [ + { + "step_id": "step-1", + "status": "verified" + }, + { + "step_id": "step-2", + "status": "unconfirmed", + "reason": "실행 검사 대상이 아닌 논리 수사 단계로 확인됐다" + } + ], + "verification": { + "solution_id": "solution-2", + "overall_coverage": "supporting", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "supporting", + "method_label": "일부 단계를 논리 수사 검토로 확인", + "unconfirmed_steps": [ + { + "step_id": "step-2", + "reason": "실행 검사 대상이 아닌 논리 수사 단계로 확인됐다" + } + ] + } + ] + }, + "verification_version": "chain-verification-report-v3", + "source_ref_ids": [] + } + ], + "selected_page_provenance": [], + "model": "fake/main-model", + "tier": "main", + "verification_summary": [ + { + "solution_id": "solution-2", + "overall_coverage": "supporting", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "supporting", + "method_label": "일부 단계를 논리 수사 검토로 확인", + "unconfirmed_steps": [ + { + "step_id": "step-2", + "reason": "실행 검사 대상이 아닌 논리 수사 단계로 확인됐다" + } + ] + } + ] + } + ], + "source_labels": [] + } + }, + "server": { + "assistant_message_id": "message-7", + "seq": 9, + "display_labels": {}, + "availability": {} + }, + "browser": { + "contractVersion": "brain-source-v1", + "runId": "run-7", + "seq": 9, + "occurredAt": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "responseDisposition": "substantive", + "assistantResponse": { + "responsePages": [ + { + "responsePageId": "page-1", + "presentation": "main", + "resultBlocks": [ + { + "blockId": "block-1", + "body": "√2=p/q라 가정한다. 대입하면 모순이다. 따라서 √2는 무리수이다.", + "kind": "solution", + "verification": "partially_verified", + "solutionId": "solution-2", + "claimIds": [ + "claim-1" + ], + "stepMarks": [ + { + "stepId": "step-1", + "status": "verified", + "anchor": { + "start": 0, + "end": 13 + } + }, + { + "stepId": "step-2", + "status": "unconfirmed", + "reason": "실행 검사 대상이 아닌 논리 수사 단계로 확인됐다", + "anchor": { + "start": 14, + "end": 24 + } + } + ] + } + ] + } + ], + "sourceReferences": [], + "plainText": "√2=p/q라 가정한다. 대입하면 모순이다. 따라서 √2는 무리수이다." + }, + "model": "fake/main-model", + "tier": "main", + "verificationSummary": [ + { + "solutionId": "solution-2", + "overallCoverage": "supporting", + "claims": [ + { + "claimId": "claim-1", + "coverage": "supporting", + "methodLabel": "일부 단계를 논리 수사 검토로 확인", + "unconfirmedSteps": [ + { + "stepId": "step-2", + "reason": "실행 검사 대상이 아닌 논리 수사 단계로 확인됐다" + } + ] + } + ] + } + ], + "assistantMessageId": "message-7" + } + } +} diff --git a/src/test/resources/contracts/brain/terminal-v2/pairs/unsupported-only.json b/src/test/resources/contracts/brain/terminal-v2/pairs/unsupported-only.json new file mode 100644 index 00000000..b2a59e2b --- /dev/null +++ b/src/test/resources/contracts/brain/terminal-v2/pairs/unsupported-only.json @@ -0,0 +1,83 @@ +{ + "publication_request": { + "schema_version": "publication-v1", + "disposition": "unsupported_only", + "disclosure": { + "scope": "full", + "judgment_completed": true + }, + "pages": [] + }, + "source": { + "contract_version": "brain-source-v1", + "run_id": "run-7", + "attempt": 1, + "fence_token": "fence-9f8c", + "source_event_id": "run-7:terminal:r0:b4c05df04575822cec85", + "occurred_at": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "response_disposition": "unsupported_only", + "assistant_response": { + "schema_version": "assistant-response-v1", + "response_pages": [ + { + "response_page_id": "page-1", + "presentation": "main", + "result_blocks": [ + { + "block_id": "block-1", + "body": "변형·연습문제 생성은 아직 지원하지 않는다. 기존 문제의 풀이·검산·설명을 요청해 주세요.", + "kind": "rich_text" + } + ] + } + ], + "source_references": [] + }, + "verified_solutions": [], + "selected_page_provenance": [], + "model": "fake/main-model", + "tier": "main", + "verification_summary": [], + "source_labels": [] + } + }, + "server": { + "assistant_message_id": "message-7", + "seq": 9, + "display_labels": {}, + "availability": {} + }, + "browser": { + "contractVersion": "brain-source-v1", + "runId": "run-7", + "seq": 9, + "occurredAt": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "responseDisposition": "unsupported_only", + "assistantResponse": { + "responsePages": [ + { + "responsePageId": "page-1", + "presentation": "main", + "resultBlocks": [ + { + "blockId": "block-1", + "body": "변형·연습문제 생성은 아직 지원하지 않는다. 기존 문제의 풀이·검산·설명을 요청해 주세요.", + "kind": "rich_text" + } + ] + } + ], + "sourceReferences": [], + "plainText": "변형·연습문제 생성은 아직 지원하지 않는다. 기존 문제의 풀이·검산·설명을 요청해 주세요." + }, + "model": "fake/main-model", + "tier": "main", + "verificationSummary": [], + "assistantMessageId": "message-7" + } + } +} diff --git a/src/test/resources/contracts/brain/terminal-v2/pairs/zero-step-fallback.json b/src/test/resources/contracts/brain/terminal-v2/pairs/zero-step-fallback.json new file mode 100644 index 00000000..1f530ffc --- /dev/null +++ b/src/test/resources/contracts/brain/terminal-v2/pairs/zero-step-fallback.json @@ -0,0 +1,184 @@ +{ + "publication_request": { + "schema_version": "publication-v1", + "disposition": "substantive", + "disclosure": { + "scope": "full", + "judgment_completed": true + }, + "pages": [ + { + "presentation": "main", + "blocks": [ + { + "kind": "solution", + "draft": { + "text": "독립 계산으로 얻은 결과는 $5$이다.", + "withheld": [] + }, + "report": { + "status": "accepted", + "claims": [ + { + "claim_id": "claim-1", + "verdict": "accepted", + "basis": "answer_fallback", + "coverage": "supporting" + } + ], + "requested_claim_count": 1, + "steps": [], + "fallback": true + }, + "solution_id": "solution-5" + } + ] + } + ] + }, + "source": { + "contract_version": "brain-source-v1", + "run_id": "run-7", + "attempt": 1, + "fence_token": "fence-9f8c", + "source_event_id": "run-7:terminal:r0:b4c05df04575822cec85", + "occurred_at": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "response_disposition": "substantive", + "assistant_response": { + "schema_version": "assistant-response-v1", + "response_pages": [ + { + "response_page_id": "page-1", + "presentation": "main", + "result_blocks": [ + { + "block_id": "block-1", + "body": "독립 계산으로 얻은 결과는 $5$이다.", + "kind": "solution", + "verification": "partially_verified", + "solution_id": "solution-5", + "claim_ids": [ + "claim-1" + ], + "step_marks": [] + } + ] + } + ], + "source_references": [] + }, + "verified_solutions": [ + { + "schema_version": "verified-solution-v1", + "solution_id": "solution-5", + "version": 1, + "problem_statement": "주어진 명시 조건을 만족하는 결론을 구한다.", + "problem_conditions": [ + { + "condition_id": "condition-1", + "text": "문제의 명시 조건을 사용한다." + } + ], + "solution_text": "독립 계산으로 얻은 결과는 $5$이다.", + "claims": [ + { + "claim_id": "claim-1", + "statement": "명시 조건에서 제시한 결론이 성립한다." + } + ], + "step_marks": [], + "verification": { + "solution_id": "solution-5", + "overall_coverage": "supporting", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "supporting", + "method_label": "독립 계산으로 답을 비교(과정 미검증)", + "unconfirmed_steps": [] + } + ] + }, + "verification_version": "chain-verification-report-v3", + "source_ref_ids": [] + } + ], + "selected_page_provenance": [], + "model": "fake/main-model", + "tier": "main", + "verification_summary": [ + { + "solution_id": "solution-5", + "overall_coverage": "supporting", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "supporting", + "method_label": "독립 계산으로 답을 비교(과정 미검증)", + "unconfirmed_steps": [] + } + ] + } + ], + "source_labels": [] + } + }, + "server": { + "assistant_message_id": "message-7", + "seq": 9, + "display_labels": {}, + "availability": {} + }, + "browser": { + "contractVersion": "brain-source-v1", + "runId": "run-7", + "seq": 9, + "occurredAt": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "responseDisposition": "substantive", + "assistantResponse": { + "responsePages": [ + { + "responsePageId": "page-1", + "presentation": "main", + "resultBlocks": [ + { + "blockId": "block-1", + "body": "독립 계산으로 얻은 결과는 $5$이다.", + "kind": "solution", + "verification": "partially_verified", + "solutionId": "solution-5", + "claimIds": [ + "claim-1" + ], + "stepMarks": [] + } + ] + } + ], + "sourceReferences": [], + "plainText": "독립 계산으로 얻은 결과는 5이다." + }, + "model": "fake/main-model", + "tier": "main", + "verificationSummary": [ + { + "solutionId": "solution-5", + "overallCoverage": "supporting", + "claims": [ + { + "claimId": "claim-1", + "coverage": "supporting", + "methodLabel": "독립 계산으로 답을 비교(과정 미검증)", + "unconfirmedSteps": [] + } + ] + } + ], + "assistantMessageId": "message-7" + } + } +} diff --git a/src/test/resources/contracts/brain/terminal-v2/redelivery.json b/src/test/resources/contracts/brain/terminal-v2/redelivery.json new file mode 100644 index 00000000..d1abafa0 --- /dev/null +++ b/src/test/resources/contracts/brain/terminal-v2/redelivery.json @@ -0,0 +1,458 @@ +{ + "same_semantic": { + "first": { + "contract_version": "brain-source-v1", + "run_id": "run-7", + "attempt": 1, + "fence_token": "fence-9f8c", + "source_event_id": "run-7:terminal:r0:b4c05df04575822cec85", + "occurred_at": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "response_disposition": "substantive", + "assistant_response": { + "schema_version": "assistant-response-v1", + "response_pages": [ + { + "response_page_id": "page-1", + "presentation": "main", + "title": "검증된 풀이", + "result_blocks": [ + { + "block_id": "block-1", + "body": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "kind": "solution", + "verification": "verified", + "solution_id": "solution-1", + "claim_ids": [ + "claim-1" + ], + "step_marks": [ + { + "step_id": "step-1", + "status": "verified", + "anchor": { + "start": 0, + "end": 24 + } + }, + { + "step_id": "step-2", + "status": "verified", + "anchor": { + "start": 25, + "end": 46 + } + } + ] + } + ] + } + ], + "source_references": [ + { + "source_ref_id": "sref-1", + "asset_id": "note-alpha", + "page_start": 3, + "page_end": 4 + } + ] + }, + "verified_solutions": [ + { + "schema_version": "verified-solution-v1", + "solution_id": "solution-1", + "version": 1, + "problem_statement": "주어진 명시 조건을 만족하는 결론을 구한다.", + "problem_conditions": [ + { + "condition_id": "condition-1", + "text": "문제의 명시 조건을 사용한다." + } + ], + "solution_text": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "claims": [ + { + "claim_id": "claim-1", + "statement": "명시 조건에서 제시한 결론이 성립한다." + } + ], + "step_marks": [ + { + "step_id": "step-1", + "status": "verified" + }, + { + "step_id": "step-2", + "status": "verified" + } + ], + "verification": { + "solution_id": "solution-1", + "overall_coverage": "direct", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "direct", + "method_label": "모든 단계를 실행 검사로 확인", + "unconfirmed_steps": [] + } + ] + }, + "verification_version": "chain-verification-report-v3", + "source_ref_ids": [ + "sref-1" + ] + } + ], + "selected_page_provenance": [ + { + "asset_id": "note-alpha", + "content_version": "content-v1", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "page": 3, + "understanding_version": "page-understanding-v1" + }, + { + "asset_id": "note-alpha", + "content_version": "content-v1", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "page": 4, + "understanding_version": "page-understanding-v1" + } + ], + "model": "fake/main-model", + "tier": "main", + "verification_summary": [ + { + "solution_id": "solution-1", + "overall_coverage": "direct", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "direct", + "method_label": "모든 단계를 실행 검사로 확인", + "unconfirmed_steps": [] + } + ] + } + ], + "source_labels": [ + { + "source_ref_id": "sref-1", + "label": "개념 노트 3–4쪽" + } + ] + } + }, + "retry": { + "contract_version": "brain-source-v1", + "run_id": "run-7", + "attempt": 1, + "fence_token": "fence-9f8c", + "source_event_id": "run-7:terminal:r0:b4c05df04575822cec85", + "occurred_at": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "response_disposition": "substantive", + "assistant_response": { + "schema_version": "assistant-response-v1", + "response_pages": [ + { + "response_page_id": "page-1", + "presentation": "main", + "title": "검증된 풀이", + "result_blocks": [ + { + "block_id": "block-1", + "body": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "kind": "solution", + "verification": "verified", + "solution_id": "solution-1", + "claim_ids": [ + "claim-1" + ], + "step_marks": [ + { + "step_id": "step-1", + "status": "verified", + "anchor": { + "start": 0, + "end": 24 + } + }, + { + "step_id": "step-2", + "status": "verified", + "anchor": { + "start": 25, + "end": 46 + } + } + ] + } + ] + } + ], + "source_references": [ + { + "source_ref_id": "sref-1", + "asset_id": "note-alpha", + "page_start": 3, + "page_end": 4 + } + ] + }, + "verified_solutions": [ + { + "schema_version": "verified-solution-v1", + "solution_id": "solution-1", + "version": 1, + "problem_statement": "주어진 명시 조건을 만족하는 결론을 구한다.", + "problem_conditions": [ + { + "condition_id": "condition-1", + "text": "문제의 명시 조건을 사용한다." + } + ], + "solution_text": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "claims": [ + { + "claim_id": "claim-1", + "statement": "명시 조건에서 제시한 결론이 성립한다." + } + ], + "step_marks": [ + { + "step_id": "step-1", + "status": "verified" + }, + { + "step_id": "step-2", + "status": "verified" + } + ], + "verification": { + "solution_id": "solution-1", + "overall_coverage": "direct", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "direct", + "method_label": "모든 단계를 실행 검사로 확인", + "unconfirmed_steps": [] + } + ] + }, + "verification_version": "chain-verification-report-v3", + "source_ref_ids": [ + "sref-1" + ] + } + ], + "selected_page_provenance": [ + { + "asset_id": "note-alpha", + "content_version": "content-v1", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "page": 3, + "understanding_version": "page-understanding-v1" + }, + { + "asset_id": "note-alpha", + "content_version": "content-v1", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "page": 4, + "understanding_version": "page-understanding-v1" + } + ], + "model": "fake/main-model", + "tier": "main", + "verification_summary": [ + { + "solution_id": "solution-1", + "overall_coverage": "direct", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "direct", + "method_label": "모든 단계를 실행 검사로 확인", + "unconfirmed_steps": [] + } + ] + } + ], + "source_labels": [ + { + "source_ref_id": "sref-1", + "label": "개념 노트 3–4쪽" + } + ] + } + }, + "outcome": "same_reserved_event" + }, + "changed_outcome": { + "first": { + "contract_version": "brain-source-v1", + "run_id": "run-7", + "attempt": 1, + "fence_token": "fence-9f8c", + "source_event_id": "run-7:terminal:r0:b4c05df04575822cec85", + "occurred_at": "2026-10-01T09:00:00Z", + "type": "run.completed", + "payload": { + "response_disposition": "substantive", + "assistant_response": { + "schema_version": "assistant-response-v1", + "response_pages": [ + { + "response_page_id": "page-1", + "presentation": "main", + "title": "검증된 풀이", + "result_blocks": [ + { + "block_id": "block-1", + "body": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "kind": "solution", + "verification": "verified", + "solution_id": "solution-1", + "claim_ids": [ + "claim-1" + ], + "step_marks": [ + { + "step_id": "step-1", + "status": "verified", + "anchor": { + "start": 0, + "end": 24 + } + }, + { + "step_id": "step-2", + "status": "verified", + "anchor": { + "start": 25, + "end": 46 + } + } + ] + } + ] + } + ], + "source_references": [ + { + "source_ref_id": "sref-1", + "asset_id": "note-alpha", + "page_start": 3, + "page_end": 4 + } + ] + }, + "verified_solutions": [ + { + "schema_version": "verified-solution-v1", + "solution_id": "solution-1", + "version": 1, + "problem_statement": "주어진 명시 조건을 만족하는 결론을 구한다.", + "problem_conditions": [ + { + "condition_id": "condition-1", + "text": "문제의 명시 조건을 사용한다." + } + ], + "solution_text": "인수분해하면 $(x-2)(x-3)=0$이다. 따라서 $x=2$ 또는 $x=3$이다.[[source:sref-1]]", + "claims": [ + { + "claim_id": "claim-1", + "statement": "명시 조건에서 제시한 결론이 성립한다." + } + ], + "step_marks": [ + { + "step_id": "step-1", + "status": "verified" + }, + { + "step_id": "step-2", + "status": "verified" + } + ], + "verification": { + "solution_id": "solution-1", + "overall_coverage": "direct", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "direct", + "method_label": "모든 단계를 실행 검사로 확인", + "unconfirmed_steps": [] + } + ] + }, + "verification_version": "chain-verification-report-v3", + "source_ref_ids": [ + "sref-1" + ] + } + ], + "selected_page_provenance": [ + { + "asset_id": "note-alpha", + "content_version": "content-v1", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "page": 3, + "understanding_version": "page-understanding-v1" + }, + { + "asset_id": "note-alpha", + "content_version": "content-v1", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "page": 4, + "understanding_version": "page-understanding-v1" + } + ], + "model": "fake/main-model", + "tier": "main", + "verification_summary": [ + { + "solution_id": "solution-1", + "overall_coverage": "direct", + "claims": [ + { + "claim_id": "claim-1", + "coverage": "direct", + "method_label": "모든 단계를 실행 검사로 확인", + "unconfirmed_steps": [] + } + ] + } + ], + "source_labels": [ + { + "source_ref_id": "sref-1", + "label": "개념 노트 3–4쪽" + } + ] + } + }, + "retry": { + "contract_version": "brain-source-v1", + "run_id": "run-7", + "attempt": 1, + "fence_token": "fence-9f8c", + "source_event_id": "run-7:terminal:r0:b4c05df04575822cec85", + "occurred_at": "2026-10-01T09:00:00Z", + "type": "run.failed", + "payload": { + "code": "verification_failed", + "message": "풀이의 오류를 수정하지 못했습니다. 새 요청으로 다시 시도할 수 있습니다.", + "failure_class": "verification", + "recoverable": true + } + }, + "outcome": "reject" + } +} diff --git a/src/test/resources/contracts/brain/terminal-v2/source.schema.json b/src/test/resources/contracts/brain/terminal-v2/source.schema.json new file mode 100644 index 00000000..5ae27d9b --- /dev/null +++ b/src/test/resources/contracts/brain/terminal-v2/source.schema.json @@ -0,0 +1,3208 @@ +{ + "$defs": { + "ActivityCanceledEvent": { + "additionalProperties": false, + "properties": { + "contract_version": { + "const": "brain-source-v1", + "default": "brain-source-v1", + "title": "Contract Version", + "type": "string" + }, + "run_id": { + "maxLength": 128, + "minLength": 1, + "title": "Run Id", + "type": "string" + }, + "attempt": { + "minimum": 1, + "title": "Attempt", + "type": "integer" + }, + "fence_token": { + "maxLength": 128, + "minLength": 1, + "title": "Fence Token", + "type": "string" + }, + "source_event_id": { + "maxLength": 256, + "minLength": 1, + "title": "Source Event Id", + "type": "string" + }, + "occurred_at": { + "format": "date-time", + "title": "Occurred At", + "type": "string" + }, + "type": { + "const": "activity.canceled", + "default": "activity.canceled", + "title": "Type", + "type": "string" + }, + "payload": { + "discriminator": { + "mapping": { + "asset_understanding": "#/$defs/AssetUnderstandingCanceled", + "code_execution": "#/$defs/CodeExecutionCanceled", + "input_analysis": "#/$defs/InputAnalysisCanceled", + "note_retrieval": "#/$defs/NoteRetrievalCanceled", + "solution_generation": "#/$defs/SolutionGenerationCanceled", + "verification": "#/$defs/VerificationCanceled" + }, + "propertyName": "kind" + }, + "oneOf": [ + { + "$ref": "#/$defs/InputAnalysisCanceled" + }, + { + "$ref": "#/$defs/SolutionGenerationCanceled" + }, + { + "$ref": "#/$defs/AssetUnderstandingCanceled" + }, + { + "$ref": "#/$defs/NoteRetrievalCanceled" + }, + { + "$ref": "#/$defs/CodeExecutionCanceled" + }, + { + "$ref": "#/$defs/VerificationCanceled" + } + ], + "title": "Payload" + } + }, + "required": [ + "run_id", + "attempt", + "fence_token", + "source_event_id", + "occurred_at", + "payload", + "type" + ], + "title": "ActivityCanceledEvent", + "type": "object" + }, + "ActivityCompletedEvent": { + "additionalProperties": false, + "properties": { + "contract_version": { + "const": "brain-source-v1", + "default": "brain-source-v1", + "title": "Contract Version", + "type": "string" + }, + "run_id": { + "maxLength": 128, + "minLength": 1, + "title": "Run Id", + "type": "string" + }, + "attempt": { + "minimum": 1, + "title": "Attempt", + "type": "integer" + }, + "fence_token": { + "maxLength": 128, + "minLength": 1, + "title": "Fence Token", + "type": "string" + }, + "source_event_id": { + "maxLength": 256, + "minLength": 1, + "title": "Source Event Id", + "type": "string" + }, + "occurred_at": { + "format": "date-time", + "title": "Occurred At", + "type": "string" + }, + "type": { + "const": "activity.completed", + "default": "activity.completed", + "title": "Type", + "type": "string" + }, + "payload": { + "discriminator": { + "mapping": { + "asset_understanding": "#/$defs/AssetUnderstandingCompleted", + "code_execution": "#/$defs/CodeExecutionCompleted", + "input_analysis": "#/$defs/InputAnalysisCompleted", + "note_retrieval": "#/$defs/NoteRetrievalCompleted", + "solution_generation": "#/$defs/SolutionGenerationCompleted", + "verification": "#/$defs/VerificationCompleted" + }, + "propertyName": "kind" + }, + "oneOf": [ + { + "$ref": "#/$defs/InputAnalysisCompleted" + }, + { + "$ref": "#/$defs/SolutionGenerationCompleted" + }, + { + "$ref": "#/$defs/AssetUnderstandingCompleted" + }, + { + "$ref": "#/$defs/NoteRetrievalCompleted" + }, + { + "$ref": "#/$defs/CodeExecutionCompleted" + }, + { + "$ref": "#/$defs/VerificationCompleted" + } + ], + "title": "Payload" + } + }, + "required": [ + "run_id", + "attempt", + "fence_token", + "source_event_id", + "occurred_at", + "payload", + "type" + ], + "title": "ActivityCompletedEvent", + "type": "object" + }, + "ActivityFailedEvent": { + "additionalProperties": false, + "properties": { + "contract_version": { + "const": "brain-source-v1", + "default": "brain-source-v1", + "title": "Contract Version", + "type": "string" + }, + "run_id": { + "maxLength": 128, + "minLength": 1, + "title": "Run Id", + "type": "string" + }, + "attempt": { + "minimum": 1, + "title": "Attempt", + "type": "integer" + }, + "fence_token": { + "maxLength": 128, + "minLength": 1, + "title": "Fence Token", + "type": "string" + }, + "source_event_id": { + "maxLength": 256, + "minLength": 1, + "title": "Source Event Id", + "type": "string" + }, + "occurred_at": { + "format": "date-time", + "title": "Occurred At", + "type": "string" + }, + "type": { + "const": "activity.failed", + "default": "activity.failed", + "title": "Type", + "type": "string" + }, + "payload": { + "discriminator": { + "mapping": { + "asset_understanding": "#/$defs/AssetUnderstandingFailed", + "code_execution": "#/$defs/CodeExecutionFailed", + "input_analysis": "#/$defs/InputAnalysisFailed", + "note_retrieval": "#/$defs/NoteRetrievalFailed", + "solution_generation": "#/$defs/SolutionGenerationFailed", + "verification": "#/$defs/VerificationFailed" + }, + "propertyName": "kind" + }, + "oneOf": [ + { + "$ref": "#/$defs/InputAnalysisFailed" + }, + { + "$ref": "#/$defs/SolutionGenerationFailed" + }, + { + "$ref": "#/$defs/AssetUnderstandingFailed" + }, + { + "$ref": "#/$defs/NoteRetrievalFailed" + }, + { + "$ref": "#/$defs/CodeExecutionFailed" + }, + { + "$ref": "#/$defs/VerificationFailed" + } + ], + "title": "Payload" + } + }, + "required": [ + "run_id", + "attempt", + "fence_token", + "source_event_id", + "occurred_at", + "payload", + "type" + ], + "title": "ActivityFailedEvent", + "type": "object" + }, + "ActivityFailureCode": { + "description": "The allowlisted technical failure codes an ``activity.failed`` may\nreport. Normal judgments (no match, unreadable pages, missing scope)\nare completions, never failures.", + "enum": [ + "provider_error", + "timeout", + "storage_error", + "internal_error" + ], + "title": "ActivityFailureCode", + "type": "string" + }, + "ActivityStartedEvent": { + "additionalProperties": false, + "properties": { + "contract_version": { + "const": "brain-source-v1", + "default": "brain-source-v1", + "title": "Contract Version", + "type": "string" + }, + "run_id": { + "maxLength": 128, + "minLength": 1, + "title": "Run Id", + "type": "string" + }, + "attempt": { + "minimum": 1, + "title": "Attempt", + "type": "integer" + }, + "fence_token": { + "maxLength": 128, + "minLength": 1, + "title": "Fence Token", + "type": "string" + }, + "source_event_id": { + "maxLength": 256, + "minLength": 1, + "title": "Source Event Id", + "type": "string" + }, + "occurred_at": { + "format": "date-time", + "title": "Occurred At", + "type": "string" + }, + "type": { + "const": "activity.started", + "default": "activity.started", + "title": "Type", + "type": "string" + }, + "payload": { + "discriminator": { + "mapping": { + "asset_understanding": "#/$defs/AssetUnderstandingStarted", + "code_execution": "#/$defs/CodeExecutionStarted", + "input_analysis": "#/$defs/InputAnalysisStarted", + "note_retrieval": "#/$defs/NoteRetrievalStarted", + "solution_generation": "#/$defs/SolutionGenerationStarted", + "verification": "#/$defs/VerificationStarted" + }, + "propertyName": "kind" + }, + "oneOf": [ + { + "$ref": "#/$defs/InputAnalysisStarted" + }, + { + "$ref": "#/$defs/SolutionGenerationStarted" + }, + { + "$ref": "#/$defs/AssetUnderstandingStarted" + }, + { + "$ref": "#/$defs/NoteRetrievalStarted" + }, + { + "$ref": "#/$defs/CodeExecutionStarted" + }, + { + "$ref": "#/$defs/VerificationStarted" + } + ], + "title": "Payload" + } + }, + "required": [ + "run_id", + "attempt", + "fence_token", + "source_event_id", + "occurred_at", + "payload", + "type" + ], + "title": "ActivityStartedEvent", + "type": "object" + }, + "ActivityStatus": { + "description": "The allowlisted sub-state of a still-running Activity.\n\nDistinct from the terminal outcome: an ``updated`` event may report the\nwork is ``in_progress``, ``waiting`` on a resource, or visibly\n``retrying`` after a semantic (non-transport) retry. Transient transport\nredelivery is not a status — it reuses the same event ID instead.", + "enum": [ + "in_progress", + "waiting", + "retrying" + ], + "title": "ActivityStatus", + "type": "string" + }, + "ActivityUpdatedEvent": { + "additionalProperties": false, + "properties": { + "contract_version": { + "const": "brain-source-v1", + "default": "brain-source-v1", + "title": "Contract Version", + "type": "string" + }, + "run_id": { + "maxLength": 128, + "minLength": 1, + "title": "Run Id", + "type": "string" + }, + "attempt": { + "minimum": 1, + "title": "Attempt", + "type": "integer" + }, + "fence_token": { + "maxLength": 128, + "minLength": 1, + "title": "Fence Token", + "type": "string" + }, + "source_event_id": { + "maxLength": 256, + "minLength": 1, + "title": "Source Event Id", + "type": "string" + }, + "occurred_at": { + "format": "date-time", + "title": "Occurred At", + "type": "string" + }, + "type": { + "const": "activity.updated", + "default": "activity.updated", + "title": "Type", + "type": "string" + }, + "payload": { + "discriminator": { + "mapping": { + "asset_understanding": "#/$defs/AssetUnderstandingUpdated", + "code_execution": "#/$defs/CodeExecutionUpdated", + "input_analysis": "#/$defs/InputAnalysisUpdated", + "note_retrieval": "#/$defs/NoteRetrievalUpdated", + "solution_generation": "#/$defs/SolutionGenerationUpdated", + "verification": "#/$defs/VerificationUpdated" + }, + "propertyName": "kind" + }, + "oneOf": [ + { + "$ref": "#/$defs/InputAnalysisUpdated" + }, + { + "$ref": "#/$defs/SolutionGenerationUpdated" + }, + { + "$ref": "#/$defs/AssetUnderstandingUpdated" + }, + { + "$ref": "#/$defs/NoteRetrievalUpdated" + }, + { + "$ref": "#/$defs/CodeExecutionUpdated" + }, + { + "$ref": "#/$defs/VerificationUpdated" + } + ], + "title": "Payload" + } + }, + "required": [ + "run_id", + "attempt", + "fence_token", + "source_event_id", + "occurred_at", + "payload", + "type" + ], + "title": "ActivityUpdatedEvent", + "type": "object" + }, + "AssetUnderstandingCanceled": { + "additionalProperties": false, + "properties": { + "activity_id": { + "maxLength": 128, + "minLength": 1, + "title": "Activity Id", + "type": "string" + }, + "message": { + "anyOf": [ + { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "kind": { + "const": "asset_understanding", + "default": "asset_understanding", + "title": "Kind", + "type": "string" + } + }, + "required": [ + "activity_id", + "kind" + ], + "title": "AssetUnderstandingCanceled", + "type": "object" + }, + "AssetUnderstandingCompleted": { + "additionalProperties": false, + "description": "Completes with the final measured counts and an optional normal\njudgment. ``scope_required``/``unreadable_pages`` are completions —\ntechnical failures are ``failed`` events instead.", + "properties": { + "activity_id": { + "maxLength": 128, + "minLength": 1, + "title": "Activity Id", + "type": "string" + }, + "message": { + "anyOf": [ + { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "kind": { + "const": "asset_understanding", + "default": "asset_understanding", + "title": "Kind", + "type": "string" + }, + "processed_pages": { + "minimum": 0, + "title": "Processed Pages", + "type": "integer" + }, + "total_pages": { + "minimum": 1, + "title": "Total Pages", + "type": "integer" + }, + "outcome": { + "anyOf": [ + { + "$ref": "#/$defs/AssetUnderstandingOutcome" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "activity_id", + "processed_pages", + "total_pages", + "kind" + ], + "title": "AssetUnderstandingCompleted", + "type": "object" + }, + "AssetUnderstandingFailed": { + "additionalProperties": false, + "properties": { + "activity_id": { + "maxLength": 128, + "minLength": 1, + "title": "Activity Id", + "type": "string" + }, + "code": { + "$ref": "#/$defs/ActivityFailureCode" + }, + "message": { + "anyOf": [ + { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "kind": { + "const": "asset_understanding", + "default": "asset_understanding", + "title": "Kind", + "type": "string" + } + }, + "required": [ + "activity_id", + "code", + "kind" + ], + "title": "AssetUnderstandingFailed", + "type": "object" + }, + "AssetUnderstandingLabel": { + "enum": [ + "reading_pages", + "summarizing_pages" + ], + "title": "AssetUnderstandingLabel", + "type": "string" + }, + "AssetUnderstandingOutcome": { + "description": "Normal (non-failure) completion judgments of asset understanding.", + "enum": [ + "scope_required", + "unreadable_pages" + ], + "title": "AssetUnderstandingOutcome", + "type": "string" + }, + "AssetUnderstandingReason": { + "description": "Why an asset_understanding Activity runs (plan/20-21 allowlist).", + "enum": [ + "viewer_focus", + "turn_attachment", + "retrieval_candidates", + "page_range_summary" + ], + "title": "AssetUnderstandingReason", + "type": "string" + }, + "AssetUnderstandingStarted": { + "additionalProperties": false, + "description": "Starts one measured pass over declared targets. ``reason`` and\n``targets`` define the attempt: a different reason or page set is a\ndifferent Activity, not an update of this one.", + "properties": { + "activity_id": { + "maxLength": 128, + "minLength": 1, + "title": "Activity Id", + "type": "string" + }, + "response_page_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Response Page Id" + }, + "kind": { + "const": "asset_understanding", + "default": "asset_understanding", + "title": "Kind", + "type": "string" + }, + "label": { + "$ref": "#/$defs/AssetUnderstandingLabel" + }, + "reason": { + "$ref": "#/$defs/AssetUnderstandingReason" + }, + "targets": { + "items": { + "$ref": "#/$defs/PageTarget" + }, + "maxItems": 50, + "minItems": 1, + "title": "Targets", + "type": "array" + } + }, + "required": [ + "activity_id", + "label", + "reason", + "targets", + "kind" + ], + "title": "AssetUnderstandingStarted", + "type": "object" + }, + "AssetUnderstandingUpdated": { + "additionalProperties": false, + "properties": { + "activity_id": { + "maxLength": 128, + "minLength": 1, + "title": "Activity Id", + "type": "string" + }, + "status": { + "anyOf": [ + { + "$ref": "#/$defs/ActivityStatus" + }, + { + "type": "null" + } + ], + "default": null + }, + "message": { + "anyOf": [ + { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "kind": { + "const": "asset_understanding", + "default": "asset_understanding", + "title": "Kind", + "type": "string" + }, + "processed_pages": { + "minimum": 0, + "title": "Processed Pages", + "type": "integer" + }, + "total_pages": { + "minimum": 1, + "title": "Total Pages", + "type": "integer" + } + }, + "required": [ + "activity_id", + "processed_pages", + "total_pages", + "kind" + ], + "title": "AssetUnderstandingUpdated", + "type": "object" + }, + "AssistantResponseV1": { + "additionalProperties": false, + "description": "A completed public answer: ordered 1-depth pages plus a source catalog.\n\nThe catalog lists every source reference inline markers may cite;\na marker naming a missing entry is a dangling reference and rejected,\nand page ranges of the same asset may not overlap. ``Candidate``\ncontent, verification internals, and Server-derived fields\n(``display_label_snapshot``, ``available``, ``plain_text``) are not\nrepresentable here.", + "properties": { + "schema_version": { + "const": "assistant-response-v1", + "default": "assistant-response-v1", + "title": "Schema Version", + "type": "string" + }, + "response_pages": { + "items": { + "$ref": "#/$defs/ResponsePage" + }, + "maxItems": 20, + "minItems": 1, + "title": "Response Pages", + "type": "array" + }, + "source_references": { + "default": [], + "items": { + "$ref": "#/$defs/SourceReference" + }, + "maxItems": 200, + "title": "Source References", + "type": "array" + } + }, + "required": [ + "response_pages" + ], + "title": "AssistantResponseV1", + "type": "object" + }, + "AttachmentFailureReason": { + "description": "The public sub-code an ``invalid_attachment`` failure carries.\n\nThe literal set mirrors\n:class:`~proovy_brain.features.multimodal_input.validation.AttachmentRejectionReason`\nexactly — the same relationship ``RunCanceledPayload.cause`` keeps\nwith ``CancelCause`` — so every safe file-inspection refusal owns one\nwire spelling and one fixed message. The wire enum is its own\ndeclaration, not an import: an internal rename or a new rejection\nreason can never silently change the published vocabulary; extending\nit is a deliberate, fixture-versioned contract change.", + "enum": [ + "unsupported_media_type", + "signature_mismatch", + "file_too_large", + "image_decode_failed", + "abnormal_dimensions", + "pixel_limit_exceeded", + "pdf_encrypted", + "pdf_corrupt", + "too_many_pages", + "page_out_of_range", + "bundle_count_exceeded", + "bundle_bytes_exceeded", + "bundle_empty" + ], + "title": "AttachmentFailureReason", + "type": "string" + }, + "CheckBlock": { + "additionalProperties": false, + "description": "The check of a user-submitted solution.\n\n``step_marks`` carry the per-step verdict on the user's work —\n``failed`` marks are legitimate here — and ``first_error_step_id``\nhighlights the earliest failed step. ``quoted_spans`` mark which body\nregions quote the user's submission; Brain-generated candidate text may\nnever be quoted.", + "properties": { + "block_id": { + "maxLength": 128, + "minLength": 1, + "title": "Block Id", + "type": "string" + }, + "body": { + "maxLength": 20000, + "minLength": 1, + "title": "Body", + "type": "string" + }, + "kind": { + "const": "check", + "default": "check", + "title": "Kind", + "type": "string" + }, + "step_marks": { + "default": [], + "items": { + "$ref": "#/$defs/StepMark" + }, + "maxItems": 200, + "title": "Step Marks", + "type": "array" + }, + "first_error_step_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "First Error Step Id" + }, + "quoted_spans": { + "default": [], + "items": { + "$ref": "#/$defs/QuotedSpan" + }, + "maxItems": 100, + "title": "Quoted Spans", + "type": "array" + } + }, + "required": [ + "block_id", + "body", + "kind" + ], + "title": "CheckBlock", + "type": "object" + }, + "ClaimVerificationSummary": { + "additionalProperties": false, + "description": "One claim's public coverage; never the internal report itself.", + "properties": { + "claim_id": { + "maxLength": 128, + "minLength": 1, + "title": "Claim Id", + "type": "string" + }, + "coverage": { + "$ref": "#/$defs/SummaryCoverage" + }, + "method_label": { + "maxLength": 200, + "minLength": 1, + "title": "Method Label", + "type": "string" + }, + "unconfirmed_steps": { + "default": [], + "items": { + "$ref": "#/$defs/UnconfirmedStepSummary" + }, + "maxItems": 200, + "title": "Unconfirmed Steps", + "type": "array" + } + }, + "required": [ + "claim_id", + "coverage", + "method_label" + ], + "title": "ClaimVerificationSummary", + "type": "object" + }, + "ClarificationReason": { + "description": "The allowlisted reasons a clarification-only response may carry.", + "enum": [ + "missing_input", + "ambiguous_target", + "limited_pages", + "corpus_not_ready", + "page_range_required", + "page_range_unreadable" + ], + "title": "ClarificationReason", + "type": "string" + }, + "CodeExecutionCanceled": { + "additionalProperties": false, + "properties": { + "activity_id": { + "maxLength": 128, + "minLength": 1, + "title": "Activity Id", + "type": "string" + }, + "message": { + "anyOf": [ + { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "kind": { + "const": "code_execution", + "default": "code_execution", + "title": "Kind", + "type": "string" + }, + "method_label": { + "maxLength": 200, + "minLength": 1, + "title": "Method Label", + "type": "string" + } + }, + "required": [ + "activity_id", + "method_label", + "kind" + ], + "title": "CodeExecutionCanceled", + "type": "object" + }, + "CodeExecutionCompleted": { + "additionalProperties": false, + "description": "A clean exit is an execution result only — never a claim that any\nmathematics was verified.", + "properties": { + "activity_id": { + "maxLength": 128, + "minLength": 1, + "title": "Activity Id", + "type": "string" + }, + "message": { + "anyOf": [ + { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "kind": { + "const": "code_execution", + "default": "code_execution", + "title": "Kind", + "type": "string" + }, + "method_label": { + "maxLength": 200, + "minLength": 1, + "title": "Method Label", + "type": "string" + }, + "duration_ms": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Duration Ms" + } + }, + "required": [ + "activity_id", + "method_label", + "kind" + ], + "title": "CodeExecutionCompleted", + "type": "object" + }, + "CodeExecutionFailed": { + "additionalProperties": false, + "properties": { + "activity_id": { + "maxLength": 128, + "minLength": 1, + "title": "Activity Id", + "type": "string" + }, + "code": { + "$ref": "#/$defs/ActivityFailureCode" + }, + "message": { + "anyOf": [ + { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "kind": { + "const": "code_execution", + "default": "code_execution", + "title": "Kind", + "type": "string" + }, + "method_label": { + "maxLength": 200, + "minLength": 1, + "title": "Method Label", + "type": "string" + } + }, + "required": [ + "activity_id", + "code", + "method_label", + "kind" + ], + "title": "CodeExecutionFailed", + "type": "object" + }, + "CodeExecutionLabel": { + "enum": [ + "running_calculation", + "running_check_code" + ], + "title": "CodeExecutionLabel", + "type": "string" + }, + "CodeExecutionStarted": { + "additionalProperties": false, + "description": "A code execution outside a verification round. Inside verification,\nexecutions are reported as the verification Activity's status instead —\na separate code_execution Activity is never emitted there.", + "properties": { + "activity_id": { + "maxLength": 128, + "minLength": 1, + "title": "Activity Id", + "type": "string" + }, + "response_page_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Response Page Id" + }, + "kind": { + "const": "code_execution", + "default": "code_execution", + "title": "Kind", + "type": "string" + }, + "label": { + "$ref": "#/$defs/CodeExecutionLabel" + }, + "method_label": { + "maxLength": 200, + "minLength": 1, + "title": "Method Label", + "type": "string" + } + }, + "required": [ + "activity_id", + "label", + "method_label", + "kind" + ], + "title": "CodeExecutionStarted", + "type": "object" + }, + "CodeExecutionUpdated": { + "additionalProperties": false, + "properties": { + "activity_id": { + "maxLength": 128, + "minLength": 1, + "title": "Activity Id", + "type": "string" + }, + "status": { + "anyOf": [ + { + "$ref": "#/$defs/ActivityStatus" + }, + { + "type": "null" + } + ], + "default": null + }, + "message": { + "anyOf": [ + { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "kind": { + "const": "code_execution", + "default": "code_execution", + "title": "Kind", + "type": "string" + }, + "method_label": { + "maxLength": 200, + "minLength": 1, + "title": "Method Label", + "type": "string" + } + }, + "required": [ + "activity_id", + "method_label", + "kind" + ], + "title": "CodeExecutionUpdated", + "type": "object" + }, + "ExistingVerifiedSolutionReference": { + "additionalProperties": false, + "properties": { + "solution_id": { + "maxLength": 128, + "minLength": 1, + "title": "Solution Id", + "type": "string" + }, + "version": { + "minimum": 1, + "title": "Version", + "type": "integer" + }, + "verification": { + "$ref": "#/$defs/SolutionVerificationSummary" + }, + "claim_match_checked": { + "const": true, + "title": "Claim Match Checked", + "type": "boolean" + } + }, + "required": [ + "solution_id", + "version", + "verification", + "claim_match_checked" + ], + "title": "ExistingVerifiedSolutionReference", + "type": "object" + }, + "ExplanationBlock": { + "additionalProperties": false, + "description": "An explanation; optionally a re-expression of a Verified Solution.", + "properties": { + "block_id": { + "maxLength": 128, + "minLength": 1, + "title": "Block Id", + "type": "string" + }, + "body": { + "maxLength": 20000, + "minLength": 1, + "title": "Body", + "type": "string" + }, + "kind": { + "const": "explanation", + "default": "explanation", + "title": "Kind", + "type": "string" + }, + "solution_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Solution Id" + }, + "claim_ids": { + "default": [], + "items": { + "type": "string" + }, + "maxItems": 50, + "title": "Claim Ids", + "type": "array" + } + }, + "required": [ + "block_id", + "body", + "kind" + ], + "title": "ExplanationBlock", + "type": "object" + }, + "FailureClass": { + "enum": [ + "verification", + "budget", + "infrastructure", + "attachment" + ], + "title": "FailureClass", + "type": "string" + }, + "FailureCode": { + "enum": [ + "verification_failed", + "budget_exceeded", + "provider_error", + "timeout", + "storage_error", + "internal_error", + "invalid_attachment" + ], + "title": "FailureCode", + "type": "string" + }, + "InputAnalysisCanceled": { + "additionalProperties": false, + "properties": { + "activity_id": { + "maxLength": 128, + "minLength": 1, + "title": "Activity Id", + "type": "string" + }, + "message": { + "anyOf": [ + { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "kind": { + "const": "input_analysis", + "default": "input_analysis", + "title": "Kind", + "type": "string" + } + }, + "required": [ + "activity_id", + "kind" + ], + "title": "InputAnalysisCanceled", + "type": "object" + }, + "InputAnalysisCompleted": { + "additionalProperties": false, + "properties": { + "activity_id": { + "maxLength": 128, + "minLength": 1, + "title": "Activity Id", + "type": "string" + }, + "message": { + "anyOf": [ + { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "kind": { + "const": "input_analysis", + "default": "input_analysis", + "title": "Kind", + "type": "string" + } + }, + "required": [ + "activity_id", + "kind" + ], + "title": "InputAnalysisCompleted", + "type": "object" + }, + "InputAnalysisFailed": { + "additionalProperties": false, + "properties": { + "activity_id": { + "maxLength": 128, + "minLength": 1, + "title": "Activity Id", + "type": "string" + }, + "code": { + "$ref": "#/$defs/ActivityFailureCode" + }, + "message": { + "anyOf": [ + { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "kind": { + "const": "input_analysis", + "default": "input_analysis", + "title": "Kind", + "type": "string" + } + }, + "required": [ + "activity_id", + "code", + "kind" + ], + "title": "InputAnalysisFailed", + "type": "object" + }, + "InputAnalysisLabel": { + "enum": [ + "analyzing_input", + "reading_input" + ], + "title": "InputAnalysisLabel", + "type": "string" + }, + "InputAnalysisStarted": { + "additionalProperties": false, + "properties": { + "activity_id": { + "maxLength": 128, + "minLength": 1, + "title": "Activity Id", + "type": "string" + }, + "response_page_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Response Page Id" + }, + "kind": { + "const": "input_analysis", + "default": "input_analysis", + "title": "Kind", + "type": "string" + }, + "label": { + "$ref": "#/$defs/InputAnalysisLabel" + } + }, + "required": [ + "activity_id", + "label", + "kind" + ], + "title": "InputAnalysisStarted", + "type": "object" + }, + "InputAnalysisUpdated": { + "additionalProperties": false, + "properties": { + "activity_id": { + "maxLength": 128, + "minLength": 1, + "title": "Activity Id", + "type": "string" + }, + "status": { + "anyOf": [ + { + "$ref": "#/$defs/ActivityStatus" + }, + { + "type": "null" + } + ], + "default": null + }, + "message": { + "anyOf": [ + { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "kind": { + "const": "input_analysis", + "default": "input_analysis", + "title": "Kind", + "type": "string" + } + }, + "required": [ + "activity_id", + "kind" + ], + "title": "InputAnalysisUpdated", + "type": "object" + }, + "LimitedPage": { + "additionalProperties": false, + "description": "One named page in a bounded ``note_retrieval`` result list.", + "properties": { + "asset_id": { + "maxLength": 128, + "minLength": 1, + "title": "Asset Id", + "type": "string" + }, + "page": { + "minimum": 1, + "title": "Page", + "type": "integer" + } + }, + "required": [ + "asset_id", + "page" + ], + "title": "LimitedPage", + "type": "object" + }, + "ModelTier": { + "description": "Logical model tier selected by the caller.", + "enum": [ + "main", + "strong" + ], + "title": "ModelTier", + "type": "string" + }, + "NoteRetrievalCanceled": { + "additionalProperties": false, + "properties": { + "activity_id": { + "maxLength": 128, + "minLength": 1, + "title": "Activity Id", + "type": "string" + }, + "message": { + "anyOf": [ + { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "kind": { + "const": "note_retrieval", + "default": "note_retrieval", + "title": "Kind", + "type": "string" + } + }, + "required": [ + "activity_id", + "kind" + ], + "title": "NoteRetrievalCanceled", + "type": "object" + }, + "NoteRetrievalCompleted": { + "additionalProperties": false, + "description": "Completes with the allowlisted match status and a bounded page list.\n``limited_pages_unresolved`` must name the pages it could not resolve,\n``no_relevant_content`` must name none, and ``conflicting_content`` may\nname the conflicting pages (plan/21). A no-match or unresolved result\ncarries empty answer provenance on the terminal contract (owned by the\nresponse side, not this payload).", + "properties": { + "activity_id": { + "maxLength": 128, + "minLength": 1, + "title": "Activity Id", + "type": "string" + }, + "message": { + "anyOf": [ + { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "kind": { + "const": "note_retrieval", + "default": "note_retrieval", + "title": "Kind", + "type": "string" + }, + "match_status": { + "$ref": "#/$defs/NoteRetrievalMatchStatus" + }, + "pages": { + "default": [], + "items": { + "$ref": "#/$defs/LimitedPage" + }, + "maxItems": 200, + "title": "Pages", + "type": "array" + } + }, + "required": [ + "activity_id", + "match_status", + "kind" + ], + "title": "NoteRetrievalCompleted", + "type": "object" + }, + "NoteRetrievalFailed": { + "additionalProperties": false, + "properties": { + "activity_id": { + "maxLength": 128, + "minLength": 1, + "title": "Activity Id", + "type": "string" + }, + "code": { + "$ref": "#/$defs/ActivityFailureCode" + }, + "message": { + "anyOf": [ + { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "kind": { + "const": "note_retrieval", + "default": "note_retrieval", + "title": "Kind", + "type": "string" + } + }, + "required": [ + "activity_id", + "code", + "kind" + ], + "title": "NoteRetrievalFailed", + "type": "object" + }, + "NoteRetrievalLabel": { + "enum": [ + "searching_notes" + ], + "title": "NoteRetrievalLabel", + "type": "string" + }, + "NoteRetrievalMatchStatus": { + "description": "Normal (non-failure) retrieval outcomes; absence of a match or a\nconflict is a completed retrieval, not an error.", + "enum": [ + "matched", + "no_relevant_content", + "conflicting_content", + "limited_pages_unresolved" + ], + "title": "NoteRetrievalMatchStatus", + "type": "string" + }, + "NoteRetrievalStarted": { + "additionalProperties": false, + "properties": { + "activity_id": { + "maxLength": 128, + "minLength": 1, + "title": "Activity Id", + "type": "string" + }, + "response_page_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Response Page Id" + }, + "kind": { + "const": "note_retrieval", + "default": "note_retrieval", + "title": "Kind", + "type": "string" + }, + "label": { + "$ref": "#/$defs/NoteRetrievalLabel" + } + }, + "required": [ + "activity_id", + "label", + "kind" + ], + "title": "NoteRetrievalStarted", + "type": "object" + }, + "NoteRetrievalUpdated": { + "additionalProperties": false, + "properties": { + "activity_id": { + "maxLength": 128, + "minLength": 1, + "title": "Activity Id", + "type": "string" + }, + "status": { + "anyOf": [ + { + "$ref": "#/$defs/ActivityStatus" + }, + { + "type": "null" + } + ], + "default": null + }, + "message": { + "anyOf": [ + { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "kind": { + "const": "note_retrieval", + "default": "note_retrieval", + "title": "Kind", + "type": "string" + } + }, + "required": [ + "activity_id", + "kind" + ], + "title": "NoteRetrievalUpdated", + "type": "object" + }, + "NoticeBlock": { + "additionalProperties": false, + "description": "A notice about a mixed or partially unsupported request.", + "properties": { + "block_id": { + "maxLength": 128, + "minLength": 1, + "title": "Block Id", + "type": "string" + }, + "body": { + "maxLength": 20000, + "minLength": 1, + "title": "Body", + "type": "string" + }, + "kind": { + "const": "notice", + "default": "notice", + "title": "Kind", + "type": "string" + } + }, + "required": [ + "block_id", + "body", + "kind" + ], + "title": "NoticeBlock", + "type": "object" + }, + "PagePresentation": { + "description": "How a response page attaches to the message; pages never nest.", + "enum": [ + "main", + "thread_like" + ], + "title": "PagePresentation", + "type": "string" + }, + "PageTarget": { + "additionalProperties": false, + "description": "One asset page range an ``asset_understanding`` Activity works on.\nPages are 1-based and inclusive.", + "properties": { + "asset_id": { + "maxLength": 128, + "minLength": 1, + "title": "Asset Id", + "type": "string" + }, + "page_start": { + "minimum": 1, + "title": "Page Start", + "type": "integer" + }, + "page_end": { + "minimum": 1, + "title": "Page End", + "type": "integer" + } + }, + "required": [ + "asset_id", + "page_start", + "page_end" + ], + "title": "PageTarget", + "type": "object" + }, + "QuoteOrigin": { + "description": "Where a quoted span comes from.\n\n``BRAIN_CANDIDATE`` exists so the publication assembly can classify its\ninternal spans; public blocks reject it — a rejected or uncommitted\nBrain candidate is never quotable content.", + "enum": [ + "user_submission", + "brain_candidate" + ], + "title": "QuoteOrigin", + "type": "string" + }, + "QuotedSpan": { + "additionalProperties": false, + "description": "A region of the block body quoted from an external origin.\n\nOnly ``user_submission`` may appear in a public response: a check block\nmay quote the user's own erroneous work, while Brain-generated candidate\ntext is never quotable.", + "properties": { + "anchor": { + "$ref": "#/$defs/TextAnchor" + }, + "origin": { + "$ref": "#/$defs/QuoteOrigin" + } + }, + "required": [ + "anchor", + "origin" + ], + "title": "QuotedSpan", + "type": "object" + }, + "ResponseDisposition": { + "description": "Terminal response classification; the typed input 1.26 validates.", + "enum": [ + "substantive", + "clarification_only", + "unsupported_only" + ], + "title": "ResponseDisposition", + "type": "string" + }, + "ResponsePage": { + "additionalProperties": false, + "description": "One ``main`` or ``thread_like`` section — 1-depth, never nested.", + "properties": { + "response_page_id": { + "maxLength": 128, + "minLength": 1, + "title": "Response Page Id", + "type": "string" + }, + "presentation": { + "$ref": "#/$defs/PagePresentation" + }, + "title": { + "anyOf": [ + { + "maxLength": 300, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Title" + }, + "result_blocks": { + "items": { + "discriminator": { + "mapping": { + "check": "#/$defs/CheckBlock", + "explanation": "#/$defs/ExplanationBlock", + "notice": "#/$defs/NoticeBlock", + "rich_text": "#/$defs/RichTextBlock", + "solution": "#/$defs/SolutionBlock" + }, + "propertyName": "kind" + }, + "oneOf": [ + { + "$ref": "#/$defs/SolutionBlock" + }, + { + "$ref": "#/$defs/CheckBlock" + }, + { + "$ref": "#/$defs/ExplanationBlock" + }, + { + "$ref": "#/$defs/RichTextBlock" + }, + { + "$ref": "#/$defs/NoticeBlock" + } + ] + }, + "maxItems": 50, + "minItems": 1, + "title": "Result Blocks", + "type": "array" + } + }, + "required": [ + "response_page_id", + "presentation", + "result_blocks" + ], + "title": "ResponsePage", + "type": "object" + }, + "RichTextBlock": { + "additionalProperties": false, + "description": "Bounded free text: notices, clarifications, unsupported guidance.", + "properties": { + "block_id": { + "maxLength": 128, + "minLength": 1, + "title": "Block Id", + "type": "string" + }, + "body": { + "maxLength": 20000, + "minLength": 1, + "title": "Body", + "type": "string" + }, + "kind": { + "const": "rich_text", + "default": "rich_text", + "title": "Kind", + "type": "string" + } + }, + "required": [ + "block_id", + "body", + "kind" + ], + "title": "RichTextBlock", + "type": "object" + }, + "RunCompletedEvent": { + "additionalProperties": false, + "properties": { + "contract_version": { + "const": "brain-source-v1", + "default": "brain-source-v1", + "title": "Contract Version", + "type": "string" + }, + "run_id": { + "maxLength": 128, + "minLength": 1, + "title": "Run Id", + "type": "string" + }, + "attempt": { + "minimum": 1, + "title": "Attempt", + "type": "integer" + }, + "fence_token": { + "maxLength": 128, + "minLength": 1, + "title": "Fence Token", + "type": "string" + }, + "source_event_id": { + "maxLength": 256, + "minLength": 1, + "title": "Source Event Id", + "type": "string" + }, + "occurred_at": { + "format": "date-time", + "title": "Occurred At", + "type": "string" + }, + "type": { + "const": "run.completed", + "default": "run.completed", + "title": "Type", + "type": "string" + }, + "payload": { + "$ref": "#/$defs/RunCompletedPayload" + } + }, + "required": [ + "run_id", + "attempt", + "fence_token", + "source_event_id", + "occurred_at", + "payload", + "type" + ], + "title": "RunCompletedEvent", + "type": "object" + }, + "RunCompletedPayload": { + "additionalProperties": false, + "properties": { + "response_disposition": { + "$ref": "#/$defs/ResponseDisposition" + }, + "clarification_reason": { + "anyOf": [ + { + "$ref": "#/$defs/ClarificationReason" + }, + { + "type": "null" + } + ], + "default": null + }, + "assistant_response": { + "$ref": "#/$defs/AssistantResponseV1" + }, + "verified_solutions": { + "default": [], + "items": { + "$ref": "#/$defs/VerifiedSolution" + }, + "maxItems": 50, + "title": "Verified Solutions", + "type": "array" + }, + "existing_solution_references": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/ExistingVerifiedSolutionReference" + }, + "maxItems": 50, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Existing Solution References" + }, + "selected_page_provenance": { + "default": [], + "items": { + "$ref": "#/$defs/SelectedPageProvenance" + }, + "maxItems": 200, + "title": "Selected Page Provenance", + "type": "array" + }, + "model": { + "maxLength": 200, + "minLength": 1, + "pattern": "^[A-Za-z0-9][A-Za-z0-9_./:-]*$", + "title": "Model", + "type": "string" + }, + "tier": { + "$ref": "#/$defs/ModelTier" + }, + "verification_summary": { + "default": [], + "items": { + "$ref": "#/$defs/SolutionVerificationSummary" + }, + "maxItems": 50, + "title": "Verification Summary", + "type": "array" + }, + "source_labels": { + "default": [], + "items": { + "$ref": "#/$defs/SourceLabelPublication" + }, + "maxItems": 200, + "title": "Source Labels", + "type": "array" + } + }, + "required": [ + "response_disposition", + "assistant_response", + "model", + "tier" + ], + "title": "RunCompletedPayload", + "type": "object" + }, + "RunFailedEvent": { + "additionalProperties": false, + "properties": { + "contract_version": { + "const": "brain-source-v1", + "default": "brain-source-v1", + "title": "Contract Version", + "type": "string" + }, + "run_id": { + "maxLength": 128, + "minLength": 1, + "title": "Run Id", + "type": "string" + }, + "attempt": { + "minimum": 1, + "title": "Attempt", + "type": "integer" + }, + "fence_token": { + "maxLength": 128, + "minLength": 1, + "title": "Fence Token", + "type": "string" + }, + "source_event_id": { + "maxLength": 256, + "minLength": 1, + "title": "Source Event Id", + "type": "string" + }, + "occurred_at": { + "format": "date-time", + "title": "Occurred At", + "type": "string" + }, + "type": { + "const": "run.failed", + "default": "run.failed", + "title": "Type", + "type": "string" + }, + "payload": { + "$ref": "#/$defs/RunFailedPayload" + } + }, + "required": [ + "run_id", + "attempt", + "fence_token", + "source_event_id", + "occurred_at", + "payload", + "type" + ], + "title": "RunFailedEvent", + "type": "object" + }, + "RunFailedPayload": { + "additionalProperties": false, + "properties": { + "code": { + "$ref": "#/$defs/FailureCode" + }, + "reason": { + "anyOf": [ + { + "$ref": "#/$defs/AttachmentFailureReason" + }, + { + "type": "null" + } + ], + "default": null + }, + "message": { + "maxLength": 500, + "minLength": 1, + "title": "Message", + "type": "string" + }, + "failure_class": { + "$ref": "#/$defs/FailureClass" + }, + "recoverable": { + "title": "Recoverable", + "type": "boolean" + } + }, + "required": [ + "code", + "message", + "failure_class", + "recoverable" + ], + "title": "RunFailedPayload", + "type": "object" + }, + "SelectedPageProvenance": { + "additionalProperties": false, + "properties": { + "asset_id": { + "maxLength": 128, + "minLength": 1, + "title": "Asset Id", + "type": "string" + }, + "content_version": { + "maxLength": 128, + "minLength": 1, + "title": "Content Version", + "type": "string" + }, + "sha256": { + "pattern": "^[0-9a-f]{64}$", + "title": "Sha256", + "type": "string" + }, + "page": { + "minimum": 1, + "title": "Page", + "type": "integer" + }, + "understanding_version": { + "maxLength": 128, + "minLength": 1, + "title": "Understanding Version", + "type": "string" + } + }, + "required": [ + "asset_id", + "content_version", + "sha256", + "page", + "understanding_version" + ], + "title": "SelectedPageProvenance", + "type": "object" + }, + "SolutionBlock": { + "additionalProperties": false, + "description": "A Brain-authored solution with an explicit verification display.\n\n``solution_id`` references the immutable Verified Solution this block\npublishes. A ``verified``/``partially_verified`` block requires it; an\n``unverified`` (inconclusive) block forbids it — an unverified solution\nis never a Verified Solution. A published solution can never carry a\n``failed`` mark: a confirmed step failure means the candidate was\nrejected, and rejected candidates are not public.", + "properties": { + "block_id": { + "maxLength": 128, + "minLength": 1, + "title": "Block Id", + "type": "string" + }, + "body": { + "maxLength": 20000, + "minLength": 1, + "title": "Body", + "type": "string" + }, + "kind": { + "const": "solution", + "default": "solution", + "title": "Kind", + "type": "string" + }, + "verification": { + "$ref": "#/$defs/VerificationBadge" + }, + "solution_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Solution Id" + }, + "claim_ids": { + "default": [], + "items": { + "type": "string" + }, + "maxItems": 50, + "title": "Claim Ids", + "type": "array" + }, + "step_marks": { + "default": [], + "items": { + "$ref": "#/$defs/StepMark" + }, + "maxItems": 200, + "title": "Step Marks", + "type": "array" + } + }, + "required": [ + "block_id", + "body", + "verification", + "kind" + ], + "title": "SolutionBlock", + "type": "object" + }, + "SolutionGenerationCanceled": { + "additionalProperties": false, + "properties": { + "activity_id": { + "maxLength": 128, + "minLength": 1, + "title": "Activity Id", + "type": "string" + }, + "message": { + "anyOf": [ + { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "kind": { + "const": "solution_generation", + "default": "solution_generation", + "title": "Kind", + "type": "string" + } + }, + "required": [ + "activity_id", + "kind" + ], + "title": "SolutionGenerationCanceled", + "type": "object" + }, + "SolutionGenerationCompleted": { + "additionalProperties": false, + "properties": { + "activity_id": { + "maxLength": 128, + "minLength": 1, + "title": "Activity Id", + "type": "string" + }, + "message": { + "anyOf": [ + { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "kind": { + "const": "solution_generation", + "default": "solution_generation", + "title": "Kind", + "type": "string" + } + }, + "required": [ + "activity_id", + "kind" + ], + "title": "SolutionGenerationCompleted", + "type": "object" + }, + "SolutionGenerationFailed": { + "additionalProperties": false, + "properties": { + "activity_id": { + "maxLength": 128, + "minLength": 1, + "title": "Activity Id", + "type": "string" + }, + "code": { + "$ref": "#/$defs/ActivityFailureCode" + }, + "message": { + "anyOf": [ + { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "kind": { + "const": "solution_generation", + "default": "solution_generation", + "title": "Kind", + "type": "string" + } + }, + "required": [ + "activity_id", + "code", + "kind" + ], + "title": "SolutionGenerationFailed", + "type": "object" + }, + "SolutionGenerationLabel": { + "enum": [ + "drafting_solution", + "revising_solution" + ], + "title": "SolutionGenerationLabel", + "type": "string" + }, + "SolutionGenerationStarted": { + "additionalProperties": false, + "properties": { + "activity_id": { + "maxLength": 128, + "minLength": 1, + "title": "Activity Id", + "type": "string" + }, + "response_page_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Response Page Id" + }, + "kind": { + "const": "solution_generation", + "default": "solution_generation", + "title": "Kind", + "type": "string" + }, + "label": { + "$ref": "#/$defs/SolutionGenerationLabel" + } + }, + "required": [ + "activity_id", + "label", + "kind" + ], + "title": "SolutionGenerationStarted", + "type": "object" + }, + "SolutionGenerationUpdated": { + "additionalProperties": false, + "properties": { + "activity_id": { + "maxLength": 128, + "minLength": 1, + "title": "Activity Id", + "type": "string" + }, + "status": { + "anyOf": [ + { + "$ref": "#/$defs/ActivityStatus" + }, + { + "type": "null" + } + ], + "default": null + }, + "message": { + "anyOf": [ + { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "kind": { + "const": "solution_generation", + "default": "solution_generation", + "title": "Kind", + "type": "string" + } + }, + "required": [ + "activity_id", + "kind" + ], + "title": "SolutionGenerationUpdated", + "type": "object" + }, + "SolutionVerificationSummary": { + "additionalProperties": false, + "description": "One solution's public verification summary on the terminal payload.\n\n``overall_coverage`` is ``supporting`` whenever any claim is supporting —\nthe summary may never present partial confirmation as complete\nverification.", + "properties": { + "solution_id": { + "maxLength": 128, + "minLength": 1, + "title": "Solution Id", + "type": "string" + }, + "overall_coverage": { + "$ref": "#/$defs/SummaryCoverage" + }, + "claims": { + "items": { + "$ref": "#/$defs/ClaimVerificationSummary" + }, + "maxItems": 50, + "minItems": 1, + "title": "Claims", + "type": "array" + } + }, + "required": [ + "solution_id", + "overall_coverage", + "claims" + ], + "title": "SolutionVerificationSummary", + "type": "object" + }, + "SourceLabelPublication": { + "additionalProperties": false, + "description": "The public display label a catalog entry will carry — leak-checked.\n\nLabels themselves are Server-derived presentation values; the caller\ndeclares the labels it expects shown so the assembly can check them for\nwithheld-text leaks before they reach the browser wire.", + "properties": { + "source_ref_id": { + "maxLength": 128, + "minLength": 1, + "title": "Source Ref Id", + "type": "string" + }, + "label": { + "maxLength": 200, + "minLength": 1, + "title": "Label", + "type": "string" + } + }, + "required": [ + "source_ref_id", + "label" + ], + "title": "SourceLabelPublication", + "type": "object" + }, + "SourceReference": { + "additionalProperties": false, + "description": "The Brain-owned half of a source catalog entry.\n\n``display_label_snapshot`` and ``available`` are Server-derived\npresentation values and deliberately absent here; the browser serializer\ninjects them explicitly. Pages are 1-based and inclusive.", + "properties": { + "source_ref_id": { + "maxLength": 128, + "minLength": 1, + "title": "Source Ref Id", + "type": "string" + }, + "asset_id": { + "maxLength": 128, + "minLength": 1, + "title": "Asset Id", + "type": "string" + }, + "page_start": { + "minimum": 1, + "title": "Page Start", + "type": "integer" + }, + "page_end": { + "minimum": 1, + "title": "Page End", + "type": "integer" + } + }, + "required": [ + "source_ref_id", + "asset_id", + "page_start", + "page_end" + ], + "title": "SourceReference", + "type": "object" + }, + "StepMark": { + "additionalProperties": false, + "description": "One Solution Step's public status mark on a block.\n\n``anchor`` locates the step inside this block's public body; it is absent\nwhen the step's span is not publicly shown, so a mark can never point at\nhidden content. ``reason`` is a safe, bounded explanation — required for\n``unconfirmed`` so an unchecked step always says why.", + "properties": { + "step_id": { + "maxLength": 128, + "minLength": 1, + "title": "Step Id", + "type": "string" + }, + "status": { + "$ref": "#/$defs/StepMarkStatus" + }, + "reason": { + "anyOf": [ + { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reason" + }, + "anchor": { + "anyOf": [ + { + "$ref": "#/$defs/TextAnchor" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "step_id", + "status" + ], + "title": "StepMark", + "type": "object" + }, + "StepMarkStatus": { + "description": "The public per-step verification mark (wire values).", + "enum": [ + "verified", + "failed", + "unconfirmed" + ], + "title": "StepMarkStatus", + "type": "string" + }, + "StepProgress": { + "additionalProperties": false, + "description": "Measured progress of one verification round: ``checked`` of ``total``\nSolution Steps. Counts are caller-measured; this model never fabricates\na percentage and never carries step content or verdicts.", + "properties": { + "checked": { + "minimum": 0, + "title": "Checked", + "type": "integer" + }, + "total": { + "minimum": 1, + "title": "Total", + "type": "integer" + } + }, + "required": [ + "checked", + "total" + ], + "title": "StepProgress", + "type": "object" + }, + "SummaryCoverage": { + "description": "Coverage levels the public verification summary may report.", + "enum": [ + "direct", + "supporting" + ], + "title": "SummaryCoverage", + "type": "string" + }, + "TextAnchor": { + "additionalProperties": false, + "description": "A ``[start, end)`` code-point range into a block's stored public body.\n\nOffsets are deterministic over repeated text, math, and Unicode because\nthey index the stored string rather than searching for a substring.\nConsumers on UTF-16 platforms (Java/JavaScript) convert units\nthemselves. An anchor can only ever point at public body text — it\ncannot reference a hidden or internal span by construction.", + "properties": { + "start": { + "minimum": 0, + "title": "Start", + "type": "integer" + }, + "end": { + "minimum": 1, + "title": "End", + "type": "integer" + } + }, + "required": [ + "start", + "end" + ], + "title": "TextAnchor", + "type": "object" + }, + "UnconfirmedStepSummary": { + "additionalProperties": false, + "description": "A step the verifier could not confirm, with a safe reason.", + "properties": { + "step_id": { + "maxLength": 128, + "minLength": 1, + "title": "Step Id", + "type": "string" + }, + "reason": { + "maxLength": 500, + "minLength": 1, + "title": "Reason", + "type": "string" + } + }, + "required": [ + "step_id", + "reason" + ], + "title": "UnconfirmedStepSummary", + "type": "object" + }, + "VerificationBadge": { + "description": "The response's honest verification display (ADR 0011).\n\n``VERIFIED`` — every Answer-bearing Claim reached direct coverage.\n``PARTIALLY_VERIFIED`` — accepted with supporting coverage; some steps\nwere confirmed by numeric check or review instead of an exact check.\n``UNVERIFIED`` — inconclusive: published with the unverified display and\nunconfirmed marks, and never carries a Verified Solution reference.", + "enum": [ + "verified", + "partially_verified", + "unverified" + ], + "title": "VerificationBadge", + "type": "string" + }, + "VerificationCanceled": { + "additionalProperties": false, + "properties": { + "activity_id": { + "maxLength": 128, + "minLength": 1, + "title": "Activity Id", + "type": "string" + }, + "message": { + "anyOf": [ + { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "kind": { + "const": "verification", + "default": "verification", + "title": "Kind", + "type": "string" + }, + "method_label": { + "maxLength": 200, + "minLength": 1, + "title": "Method Label", + "type": "string" + } + }, + "required": [ + "activity_id", + "method_label", + "kind" + ], + "title": "VerificationCanceled", + "type": "object" + }, + "VerificationCompleted": { + "additionalProperties": false, + "properties": { + "activity_id": { + "maxLength": 128, + "minLength": 1, + "title": "Activity Id", + "type": "string" + }, + "message": { + "anyOf": [ + { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "kind": { + "const": "verification", + "default": "verification", + "title": "Kind", + "type": "string" + }, + "method_label": { + "maxLength": 200, + "minLength": 1, + "title": "Method Label", + "type": "string" + }, + "progress": { + "$ref": "#/$defs/StepProgress" + } + }, + "required": [ + "activity_id", + "method_label", + "progress", + "kind" + ], + "title": "VerificationCompleted", + "type": "object" + }, + "VerificationFailed": { + "additionalProperties": false, + "properties": { + "activity_id": { + "maxLength": 128, + "minLength": 1, + "title": "Activity Id", + "type": "string" + }, + "code": { + "$ref": "#/$defs/ActivityFailureCode" + }, + "message": { + "anyOf": [ + { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "kind": { + "const": "verification", + "default": "verification", + "title": "Kind", + "type": "string" + }, + "method_label": { + "maxLength": 200, + "minLength": 1, + "title": "Method Label", + "type": "string" + } + }, + "required": [ + "activity_id", + "code", + "method_label", + "kind" + ], + "title": "VerificationFailed", + "type": "object" + }, + "VerificationLabel": { + "enum": [ + "verifying_steps" + ], + "title": "VerificationLabel", + "type": "string" + }, + "VerificationStarted": { + "additionalProperties": false, + "description": "One verifier round. Per-step verdicts and step content are not\nrepresentable — they surface only as terminal Result Block marks.", + "properties": { + "activity_id": { + "maxLength": 128, + "minLength": 1, + "title": "Activity Id", + "type": "string" + }, + "response_page_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Response Page Id" + }, + "kind": { + "const": "verification", + "default": "verification", + "title": "Kind", + "type": "string" + }, + "label": { + "$ref": "#/$defs/VerificationLabel" + }, + "method_label": { + "maxLength": 200, + "minLength": 1, + "title": "Method Label", + "type": "string" + } + }, + "required": [ + "activity_id", + "label", + "method_label", + "kind" + ], + "title": "VerificationStarted", + "type": "object" + }, + "VerificationUpdated": { + "additionalProperties": false, + "properties": { + "activity_id": { + "maxLength": 128, + "minLength": 1, + "title": "Activity Id", + "type": "string" + }, + "status": { + "anyOf": [ + { + "$ref": "#/$defs/ActivityStatus" + }, + { + "type": "null" + } + ], + "default": null + }, + "message": { + "anyOf": [ + { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "kind": { + "const": "verification", + "default": "verification", + "title": "Kind", + "type": "string" + }, + "method_label": { + "maxLength": 200, + "minLength": 1, + "title": "Method Label", + "type": "string" + }, + "progress": { + "$ref": "#/$defs/StepProgress" + } + }, + "required": [ + "activity_id", + "method_label", + "progress", + "kind" + ], + "title": "VerificationUpdated", + "type": "object" + }, + "VerifiedClaim": { + "additionalProperties": false, + "properties": { + "claim_id": { + "maxLength": 128, + "title": "Claim Id", + "type": "string" + }, + "statement": { + "maxLength": 20000, + "minLength": 1, + "title": "Statement", + "type": "string" + } + }, + "required": [ + "claim_id", + "statement" + ], + "title": "VerifiedClaim", + "type": "object" + }, + "VerifiedProblemCondition": { + "additionalProperties": false, + "properties": { + "condition_id": { + "maxLength": 128, + "pattern": "^condition-[1-9][0-9]*$", + "title": "Condition Id", + "type": "string" + }, + "text": { + "maxLength": 20000, + "minLength": 1, + "title": "Text", + "type": "string" + } + }, + "required": [ + "condition_id", + "text" + ], + "title": "VerifiedProblemCondition", + "type": "object" + }, + "VerifiedSolution": { + "additionalProperties": false, + "properties": { + "schema_version": { + "const": "verified-solution-v1", + "default": "verified-solution-v1", + "title": "Schema Version", + "type": "string" + }, + "solution_id": { + "maxLength": 128, + "title": "Solution Id", + "type": "string" + }, + "version": { + "const": 1, + "default": 1, + "title": "Version", + "type": "integer" + }, + "problem_statement": { + "maxLength": 20000, + "minLength": 1, + "title": "Problem Statement", + "type": "string" + }, + "problem_conditions": { + "items": { + "$ref": "#/$defs/VerifiedProblemCondition" + }, + "maxItems": 200, + "minItems": 1, + "title": "Problem Conditions", + "type": "array" + }, + "solution_text": { + "maxLength": 20000, + "minLength": 1, + "title": "Solution Text", + "type": "string" + }, + "claims": { + "items": { + "$ref": "#/$defs/VerifiedClaim" + }, + "maxItems": 50, + "minItems": 1, + "title": "Claims", + "type": "array" + }, + "step_marks": { + "default": [], + "items": { + "$ref": "#/$defs/StepMark" + }, + "maxItems": 200, + "title": "Step Marks", + "type": "array" + }, + "verification": { + "$ref": "#/$defs/SolutionVerificationSummary" + }, + "verification_version": { + "const": "chain-verification-report-v3", + "default": "chain-verification-report-v3", + "title": "Verification Version", + "type": "string" + }, + "source_ref_ids": { + "default": [], + "items": { + "type": "string" + }, + "maxItems": 200, + "title": "Source Ref Ids", + "type": "array" + }, + "revises_solution_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Revises Solution Id" + } + }, + "required": [ + "solution_id", + "problem_statement", + "problem_conditions", + "solution_text", + "claims", + "verification" + ], + "title": "VerifiedSolution", + "type": "object" + } + }, + "discriminator": { + "mapping": { + "activity.canceled": "#/$defs/ActivityCanceledEvent", + "activity.completed": "#/$defs/ActivityCompletedEvent", + "activity.failed": "#/$defs/ActivityFailedEvent", + "activity.started": "#/$defs/ActivityStartedEvent", + "activity.updated": "#/$defs/ActivityUpdatedEvent", + "run.completed": "#/$defs/RunCompletedEvent", + "run.failed": "#/$defs/RunFailedEvent" + }, + "propertyName": "type" + }, + "oneOf": [ + { + "$ref": "#/$defs/ActivityStartedEvent" + }, + { + "$ref": "#/$defs/ActivityUpdatedEvent" + }, + { + "$ref": "#/$defs/ActivityCompletedEvent" + }, + { + "$ref": "#/$defs/ActivityFailedEvent" + }, + { + "$ref": "#/$defs/ActivityCanceledEvent" + }, + { + "$ref": "#/$defs/RunCompletedEvent" + }, + { + "$ref": "#/$defs/RunFailedEvent" + } + ] +} diff --git a/src/test/resources/db/seed/product-seed.sql b/src/test/resources/db/seed/product-seed.sql new file mode 100644 index 00000000..0375756d --- /dev/null +++ b/src/test/resources/db/seed/product-seed.sql @@ -0,0 +1,34 @@ +-- 재현 가능한 제품 테스트 seed (빈 DB + V1 baseline 위에서 실행). +-- 고정 ID를 사용하고 identity sequence를 seed 이후로 맞춘다. 과거 서비스 row를 흉내 내지 않는다. + +INSERT INTO users (user_id, nickname, provider, provider_user_id, email, created_at, updated_at) VALUES + (1001, 'alice', 'KAKAO', 'kakao-1001', 'alice@example.test', '2026-10-01 09:00:00', '2026-10-01 09:00:00'), + (1002, 'bob', 'NAVER', 'naver-1002', 'bob@example.test', '2026-10-01 09:00:00', '2026-10-01 09:00:00'); + +INSERT INTO user_plans (user_plan_id, user_id, plan_type, started_at, is_active) VALUES + (1101, 1001, 'FREE', '2026-10-01 09:00:00', TRUE), + (1102, 1002, 'FREE', '2026-10-01 09:00:00', TRUE); + +INSERT INTO credit_balance (id, user_id, daily_free_credit, daily_free_limit, free_credit, paid_credit, created_at, updated_at) VALUES + (1201, 1001, 100, 100, 50, 0, '2026-10-01 09:00:00', '2026-10-01 09:00:00'), + (1202, 1002, 100, 100, 0, 0, '2026-10-01 09:00:00', '2026-10-01 09:00:00'); + +INSERT INTO notes (note_id, user_id, title, created_at, updated_at) VALUES + (2001, 1001, 'alice note', '2026-10-01 09:10:00', '2026-10-01 09:10:00'), + (2002, 1002, 'bob note', '2026-10-01 09:10:00', '2026-10-01 09:10:00'), + (2003, 1001, 'alice second note', '2026-10-01 09:20:00', '2026-10-01 09:20:00'); + +INSERT INTO assets (id, user_id, note_id, file_name, file_size, mime_type, object_key, source, status, + content_version, sha256, page_count, created_at, updated_at) VALUES + (3001, 1001, 2001, 'calculus.pdf', 1048576, 'application/pdf', 'users/1001/notes/2001/calculus.pdf', 'upload', 'ACTIVE', + 1, 'a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1', 12, '2026-10-01 09:15:00', '2026-10-01 09:15:00'), + (3002, 1002, 2002, 'algebra.pdf', 524288, 'application/pdf', 'users/1002/notes/2002/algebra.pdf', 'upload', 'ACTIVE', + 1, 'b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2', 4, '2026-10-01 09:15:00', '2026-10-01 09:15:00'), + (3003, 1001, 2001, 'notes.png', 20480, 'image/png', 'users/1001/notes/2001/notes.png', 'upload', 'ACTIVE', + 1, 'c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3', NULL, '2026-10-01 09:16:00', '2026-10-01 09:16:00'); + +SELECT setval(pg_get_serial_sequence('users', 'user_id'), 10000); +SELECT setval(pg_get_serial_sequence('user_plans', 'user_plan_id'), 10000); +SELECT setval(pg_get_serial_sequence('credit_balance', 'id'), 10000); +SELECT setval(pg_get_serial_sequence('notes', 'note_id'), 10000); +SELECT setval(pg_get_serial_sequence('assets', 'id'), 10000);