From d3286d60fa20b2805ceb3205aaa28136f11a491b Mon Sep 17 00:00:00 2001 From: chowon442 Date: Sat, 17 Oct 2026 10:43:55 +0900 Subject: [PATCH 01/34] =?UTF-8?q?feat:=20=EC=8B=A0=EA=B7=9C=20=EC=A0=9C?= =?UTF-8?q?=ED=92=88=20DB=20baseline=EA=B3=BC=20Note=20=EC=A4=91=EC=8B=AC?= =?UTF-8?q?=20S07=20=EC=8B=A4=ED=96=89=20=EB=AA=A8=EB=8D=B8=20(#213)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 과거 V0~V32 migration을 V1__product_baseline.sql 하나로 교체 (빈 DB 기준, baseline-on-migrate 해제) - chat_messages를 Note에 직접 연결(UUID)하고 ChatSession/thread ID/구형 AI stream·embedding 경로 제거 - ai_runs·ai_run_events·run_asset_dependencies·turn_attachments·verified_solutions·request usage ledger 추가 - request unique·Note별 Active Run partial unique·event seq/dedup unique·보상 최대 1회·stale/deadline 인덱스 - 불변 snapshot·run 상태 전이·append-only event·insert-only solution revision을 trigger로 강제 - Server/Brain role 경계 provisioning SQL(운영자 적용, Flyway 미실행) - local/test ddl-auto를 validate로 통일 Co-Authored-By: Claude Opus 5.5 --- README.md | 34 +- .../dto/response/AssetDetailResponse.java | 4 +- .../com/proovy/domain/asset/entity/Asset.java | 39 +- .../domain/asset/entity/AssetStatus.java | 8 +- .../asset/repository/AssetRepository.java | 4 +- .../asset/service/AssetsServiceImpl.java | 8 +- .../config/ChatMessageTsvBackfillRunner.java | 86 -- .../controller/ConversationController.java | 180 +---- .../dto/request/ConversationRequest.java | 37 - .../dto/request/ProovyAiRequest.java | 50 -- .../response/ConversationDetailResponse.java | 115 --- .../dto/response/ConversationResponse.java | 36 - .../response/ConversationSearchResponse.java | 17 +- .../dto/response/ProovyAiStreamEvent.java | 25 - .../conversation/entity/ChatMessage.java | 105 +-- .../conversation/entity/ChatSession.java | 64 -- .../entity/ChatSessionStatus.java | 6 - .../conversation/entity/MessageAsset.java | 34 - .../entity/MessageAttachment.java | 62 -- .../conversation/entity/MessageStatus.java | 12 +- .../conversation/entity/MessageTool.java | 32 - .../conversation/entity/TurnAttachment.java | 106 +++ .../repository/ChatMessageRepository.java | 130 +-- .../repository/ChatSessionRepository.java | 38 - .../repository/MessageAssetRepository.java | 50 -- .../MessageAttachmentRepository.java | 21 - .../repository/MessageToolRepository.java | 42 - .../repository/TurnAttachmentRepository.java | 12 + .../conversation/service/ChatService.java | 30 - .../conversation/service/ChatServiceImpl.java | 592 -------------- .../service/ConversationQueryService.java | 6 - .../service/ConversationQueryServiceImpl.java | 63 +- .../domain/embedding/NoteEmbedding.java | 81 -- .../embedding/NoteEmbeddingRepository.java | 14 - .../service/EmbeddingJobPublisher.java | 94 --- .../service/NoteEmbeddingService.java | 71 -- .../controller/InternalNoteController.java | 106 --- .../note/dto/response/NoteDetailResponse.java | 52 +- .../com/proovy/domain/note/entity/Note.java | 23 +- .../proovy/domain/note/entity/NoteStatus.java | 6 + .../note/repository/NoteRepository.java | 13 - .../domain/note/service/NoteServiceImpl.java | 216 +---- .../com/proovy/domain/run/entity/AiRun.java | 202 +++++ .../proovy/domain/run/entity/AiRunEvent.java | 75 ++ .../domain/run/entity/AiRunEventType.java | 45 ++ .../proovy/domain/run/entity/AiRunStatus.java | 34 + .../run/entity/ClarificationReason.java | 28 + .../run/entity/ResponseDisposition.java | 25 + .../domain/run/entity/RunAssetDependency.java | 70 ++ .../run/entity/RunAssetDependencyKind.java | 7 + .../domain/run/entity/RunCancelCause.java | 25 + .../domain/run/entity/RunFailureClass.java | 26 + .../domain/run/entity/RunFailureCode.java | 29 + .../proovy/domain/run/entity/WireValue.java | 38 + .../run/repository/AiRunEventRepository.java | 15 + .../run/repository/AiRunRepository.java | 30 + .../RunAssetDependencyRepository.java | 14 + .../solution/entity/VerifiedSolution.java | 130 +++ .../entity/VerifiedSolutionStatus.java | 6 + .../VerifiedSolutionRepository.java | 14 + .../usage/entity/RequestUsageCharge.java | 64 ++ .../entity/RequestUsageCompensation.java | 57 ++ .../usage/entity/UsageCompensationReason.java | 15 + .../RequestUsageChargeRepository.java | 7 + .../RequestUsageCompensationRepository.java | 7 + .../domain/user/service/UserService.java | 14 +- src/main/resources/application.yaml | 22 +- .../migration/V0__bootstrap_users_table.sql | 14 - .../db/migration/V10__create_notes.sql | 10 - .../db/migration/V11__create_user_plans.sql | 10 - .../migration/V12__create_conversations.sql | 7 - .../db/migration/V13__create_messages.sql | 10 - .../migration/V14__create_message_tools.sql | 7 - .../migration/V15__create_message_assets.sql | 9 - .../db/migration/V16__create_tools.sql | 11 - .../migration/V17__create_note_embeddings.sql | 36 - .../migration/V18__add_thread_id_to_notes.sql | 8 - .../db/migration/V19__add_fulltext_search.sql | 68 -- .../migration/V1__create_credit_balance.sql | 14 - .../db/migration/V1__product_baseline.sql | 753 ++++++++++++++++++ ...0__fix_credit_balance_identity_default.sql | 46 -- .../db/migration/V21__create_users.sql | 14 - .../V22__enforce_single_active_user_plan.sql | 53 -- .../V23__enforce_unique_oauth_identity.sql | 7 - .../V24__add_canceled_at_to_user_plans.sql | 4 - .../V25__add_next_plan_type_to_user_plans.sql | 14 - .../V26__optimize_due_transition_index.sql | 6 - ...onthly_grant_plan_id_to_credit_balance.sql | 2 - .../V28__migrate_to_chat_messages.sql | 72 -- .../V29__add_message_id_to_message_assets.sql | 123 --- .../migration/V2__backfill_credit_balance.sql | 25 - ...V30__fix_chat_messages_fulltext_search.sql | 35 - ...drop_duplicate_chat_message_id_columns.sql | 77 -- .../V32__rename_s3_columns_to_object_keys.sql | 3 - .../migration/V3__create_credit_history.sql | 36 - .../db/migration/V4__create_chat_sessions.sql | 10 - .../db/migration/V5__create_chat_messages.sql | 10 - .../V6__create_message_attachments.sql | 12 - .../migration/V7__fix_credit_history_pk.sql | 42 - .../V8__rename_credit_balance_pk.sql | 34 - .../db/migration/V9__create_assets.sql | 23 - .../db/provisioning/role-boundary.sql | 54 ++ 102 files changed, 2122 insertions(+), 3260 deletions(-) delete mode 100644 src/main/java/com/proovy/domain/conversation/config/ChatMessageTsvBackfillRunner.java delete mode 100644 src/main/java/com/proovy/domain/conversation/dto/request/ConversationRequest.java delete mode 100644 src/main/java/com/proovy/domain/conversation/dto/request/ProovyAiRequest.java delete mode 100644 src/main/java/com/proovy/domain/conversation/dto/response/ConversationDetailResponse.java delete mode 100644 src/main/java/com/proovy/domain/conversation/dto/response/ConversationResponse.java delete mode 100644 src/main/java/com/proovy/domain/conversation/dto/response/ProovyAiStreamEvent.java delete mode 100644 src/main/java/com/proovy/domain/conversation/entity/ChatSession.java delete mode 100644 src/main/java/com/proovy/domain/conversation/entity/ChatSessionStatus.java delete mode 100644 src/main/java/com/proovy/domain/conversation/entity/MessageAsset.java delete mode 100644 src/main/java/com/proovy/domain/conversation/entity/MessageAttachment.java delete mode 100644 src/main/java/com/proovy/domain/conversation/entity/MessageTool.java create mode 100644 src/main/java/com/proovy/domain/conversation/entity/TurnAttachment.java delete mode 100644 src/main/java/com/proovy/domain/conversation/repository/ChatSessionRepository.java delete mode 100644 src/main/java/com/proovy/domain/conversation/repository/MessageAssetRepository.java delete mode 100644 src/main/java/com/proovy/domain/conversation/repository/MessageAttachmentRepository.java delete mode 100644 src/main/java/com/proovy/domain/conversation/repository/MessageToolRepository.java create mode 100644 src/main/java/com/proovy/domain/conversation/repository/TurnAttachmentRepository.java delete mode 100644 src/main/java/com/proovy/domain/conversation/service/ChatService.java delete mode 100644 src/main/java/com/proovy/domain/conversation/service/ChatServiceImpl.java delete mode 100644 src/main/java/com/proovy/domain/embedding/NoteEmbedding.java delete mode 100644 src/main/java/com/proovy/domain/embedding/NoteEmbeddingRepository.java delete mode 100644 src/main/java/com/proovy/domain/embedding/service/EmbeddingJobPublisher.java delete mode 100644 src/main/java/com/proovy/domain/embedding/service/NoteEmbeddingService.java delete mode 100644 src/main/java/com/proovy/domain/note/controller/InternalNoteController.java create mode 100644 src/main/java/com/proovy/domain/note/entity/NoteStatus.java create mode 100644 src/main/java/com/proovy/domain/run/entity/AiRun.java create mode 100644 src/main/java/com/proovy/domain/run/entity/AiRunEvent.java create mode 100644 src/main/java/com/proovy/domain/run/entity/AiRunEventType.java create mode 100644 src/main/java/com/proovy/domain/run/entity/AiRunStatus.java create mode 100644 src/main/java/com/proovy/domain/run/entity/ClarificationReason.java create mode 100644 src/main/java/com/proovy/domain/run/entity/ResponseDisposition.java create mode 100644 src/main/java/com/proovy/domain/run/entity/RunAssetDependency.java create mode 100644 src/main/java/com/proovy/domain/run/entity/RunAssetDependencyKind.java create mode 100644 src/main/java/com/proovy/domain/run/entity/RunCancelCause.java create mode 100644 src/main/java/com/proovy/domain/run/entity/RunFailureClass.java create mode 100644 src/main/java/com/proovy/domain/run/entity/RunFailureCode.java create mode 100644 src/main/java/com/proovy/domain/run/entity/WireValue.java create mode 100644 src/main/java/com/proovy/domain/run/repository/AiRunEventRepository.java create mode 100644 src/main/java/com/proovy/domain/run/repository/AiRunRepository.java create mode 100644 src/main/java/com/proovy/domain/run/repository/RunAssetDependencyRepository.java create mode 100644 src/main/java/com/proovy/domain/solution/entity/VerifiedSolution.java create mode 100644 src/main/java/com/proovy/domain/solution/entity/VerifiedSolutionStatus.java create mode 100644 src/main/java/com/proovy/domain/solution/repository/VerifiedSolutionRepository.java create mode 100644 src/main/java/com/proovy/domain/usage/entity/RequestUsageCharge.java create mode 100644 src/main/java/com/proovy/domain/usage/entity/RequestUsageCompensation.java create mode 100644 src/main/java/com/proovy/domain/usage/entity/UsageCompensationReason.java create mode 100644 src/main/java/com/proovy/domain/usage/repository/RequestUsageChargeRepository.java create mode 100644 src/main/java/com/proovy/domain/usage/repository/RequestUsageCompensationRepository.java delete mode 100644 src/main/resources/db/migration/V0__bootstrap_users_table.sql delete mode 100644 src/main/resources/db/migration/V10__create_notes.sql delete mode 100644 src/main/resources/db/migration/V11__create_user_plans.sql delete mode 100644 src/main/resources/db/migration/V12__create_conversations.sql delete mode 100644 src/main/resources/db/migration/V13__create_messages.sql delete mode 100644 src/main/resources/db/migration/V14__create_message_tools.sql delete mode 100644 src/main/resources/db/migration/V15__create_message_assets.sql delete mode 100644 src/main/resources/db/migration/V16__create_tools.sql delete mode 100644 src/main/resources/db/migration/V17__create_note_embeddings.sql delete mode 100644 src/main/resources/db/migration/V18__add_thread_id_to_notes.sql delete mode 100644 src/main/resources/db/migration/V19__add_fulltext_search.sql delete mode 100644 src/main/resources/db/migration/V1__create_credit_balance.sql create mode 100644 src/main/resources/db/migration/V1__product_baseline.sql delete mode 100644 src/main/resources/db/migration/V20__fix_credit_balance_identity_default.sql delete mode 100644 src/main/resources/db/migration/V21__create_users.sql delete mode 100644 src/main/resources/db/migration/V22__enforce_single_active_user_plan.sql delete mode 100644 src/main/resources/db/migration/V23__enforce_unique_oauth_identity.sql delete mode 100644 src/main/resources/db/migration/V24__add_canceled_at_to_user_plans.sql delete mode 100644 src/main/resources/db/migration/V25__add_next_plan_type_to_user_plans.sql delete mode 100644 src/main/resources/db/migration/V26__optimize_due_transition_index.sql delete mode 100644 src/main/resources/db/migration/V27__add_last_monthly_grant_plan_id_to_credit_balance.sql delete mode 100644 src/main/resources/db/migration/V28__migrate_to_chat_messages.sql delete mode 100644 src/main/resources/db/migration/V29__add_message_id_to_message_assets.sql delete mode 100644 src/main/resources/db/migration/V2__backfill_credit_balance.sql delete mode 100644 src/main/resources/db/migration/V30__fix_chat_messages_fulltext_search.sql delete mode 100644 src/main/resources/db/migration/V31__drop_duplicate_chat_message_id_columns.sql delete mode 100644 src/main/resources/db/migration/V32__rename_s3_columns_to_object_keys.sql delete mode 100644 src/main/resources/db/migration/V3__create_credit_history.sql delete mode 100644 src/main/resources/db/migration/V4__create_chat_sessions.sql delete mode 100644 src/main/resources/db/migration/V5__create_chat_messages.sql delete mode 100644 src/main/resources/db/migration/V6__create_message_attachments.sql delete mode 100644 src/main/resources/db/migration/V7__fix_credit_history_pk.sql delete mode 100644 src/main/resources/db/migration/V8__rename_credit_balance_pk.sql delete mode 100644 src/main/resources/db/migration/V9__create_assets.sql create mode 100644 src/main/resources/db/provisioning/role-boundary.sql diff --git a/README.md b/README.md index 6984bb31..fa6ed964 100644 --- a/README.md +++ b/README.md @@ -151,12 +151,10 @@ proovy-api/ - AI 서버 연동 ### 7. Conversation 도메인 -- 사용 가능한 도구 목록 조회 -- 멘션용 파일 검색 (자동완성) +- Note가 유일한 대화 단위 (`conversationId = noteId`), 메시지는 Note에 직접 연결 - 캔버스 이미지 업로드 -- 대화 전송 (질문 + AI 응답, SSE 스트리밍) -- 대화 목록 조회 (검색) -- 대화 상세 조회 +- 대화 검색 +- 신규 run 접수·SSE·취소는 S07 후속 작업 (구형 `/stream/v2` 경로는 제거됨) ### 8. Credit 도메인 - 크레딧 잔액 조회 @@ -165,9 +163,29 @@ 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)이면 실패한다. ## API 문서 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..c7421564 100644 --- a/src/main/java/com/proovy/domain/asset/service/AssetsServiceImpl.java +++ b/src/main/java/com/proovy/domain/asset/service/AssetsServiceImpl.java @@ -137,7 +137,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 +165,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 +195,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 +204,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(); 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..bdb52d39 100644 --- a/src/main/java/com/proovy/domain/conversation/entity/ChatMessage.java +++ b/src/main/java/com/proovy/domain/conversation/entity/ChatMessage.java @@ -5,83 +5,94 @@ 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; +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 { @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..d080526e --- /dev/null +++ b/src/main/java/com/proovy/domain/conversation/entity/TurnAttachment.java @@ -0,0 +1,106 @@ +package com.proovy.domain.conversation.entity; + +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 { + + @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/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..bf6361f7 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"; @@ -374,48 +369,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 +414,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 +427,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 +450,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 +505,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 +523,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/entity/AiRun.java b/src/main/java/com/proovy/domain/run/entity/AiRun.java new file mode 100644 index 00000000..33ce9447 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/entity/AiRun.java @@ -0,0 +1,202 @@ +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.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 { + + @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..2b1f9de1 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/entity/RunAssetDependency.java @@ -0,0 +1,70 @@ +package com.proovy.domain.run.entity; + +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 { + + @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); + } + + @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/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..7f7418d8 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/repository/AiRunRepository.java @@ -0,0 +1,30 @@ +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 { + + 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/solution/entity/VerifiedSolution.java b/src/main/java/com/proovy/domain/solution/entity/VerifiedSolution.java new file mode 100644 index 00000000..32450b60 --- /dev/null +++ b/src/main/java/com/proovy/domain/solution/entity/VerifiedSolution.java @@ -0,0 +1,130 @@ +package com.proovy.domain.solution.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 java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +/** + * 불변 Verified Solution (verified-solution-v1). 정정은 새 row를 insert하고 이전 row에는 + * SUPERSEDED 상태와 후속 ID만 기록한다(DB trigger). 모든 claim coverage는 direct|supporting이다. + * + *

{@code solutionId}는 Server identity(UUID)이고, Brain에 VerifiedSolutionRef로 다시 보내는 + * 값과 browser verificationSummary.solutionId는 {@code publicSolutionId}(Brain neutral ID)다. + */ +@Entity +@Table(name = "verified_solutions") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class VerifiedSolution { + + @Id + @Column(name = "solution_id") + private UUID id; + + @Column(name = "public_solution_id", nullable = false, length = 128, updatable = false) + private String publicSolutionId; + + @Column(name = "note_id", nullable = false, updatable = false) + private Long noteId; + + @Column(name = "source_run_id", nullable = false, updatable = false) + private UUID sourceRunId; + + @Column(name = "assistant_message_id", nullable = false, updatable = false) + private UUID assistantMessageId; + + @Column(name = "schema_version", nullable = false, length = 40, updatable = false) + private String schemaVersion; + + @Column(name = "solution_version", nullable = false, updatable = false) + private Integer solutionVersion; + + @Column(name = "problem_statement", nullable = false, columnDefinition = "TEXT", updatable = false) + private String problemStatement; + + @Type(JsonBinaryType.class) + @Column(name = "problem_conditions", columnDefinition = "jsonb", nullable = false, updatable = false) + private JsonNode problemConditions; + + @Column(name = "solution_text", nullable = false, columnDefinition = "TEXT", updatable = false) + private String solutionText; + + @Type(JsonBinaryType.class) + @Column(columnDefinition = "jsonb", nullable = false, updatable = false) + private JsonNode claims; + + @Type(JsonBinaryType.class) + @Column(name = "step_marks", columnDefinition = "jsonb", nullable = false, updatable = false) + private JsonNode stepMarks; + + @Type(JsonBinaryType.class) + @Column(name = "verification_summary", columnDefinition = "jsonb", nullable = false, updatable = false) + private JsonNode verificationSummary; + + @Column(name = "verification_version", nullable = false, length = 64, updatable = false) + private String verificationVersion; + + @Type(JsonBinaryType.class) + @Column(name = "source_ref_ids", columnDefinition = "jsonb", nullable = false, updatable = false) + private JsonNode sourceRefIds; + + @Column(name = "overall_coverage", nullable = false, length = 20, updatable = false) + private String overallCoverage; + + @Column(name = "content_sha256", nullable = false, length = 64, updatable = false) + private String contentSha256; + + @Column(name = "revises_solution_id", updatable = false) + private UUID revisesSolutionId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private VerifiedSolutionStatus status; + + @Column(name = "superseded_by_solution_id") + private UUID supersededBySolutionId; + + @Column(name = "superseded_at") + private Instant supersededAt; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Builder + private VerifiedSolution(UUID id, String publicSolutionId, Long noteId, UUID sourceRunId, + UUID assistantMessageId, String schemaVersion, int solutionVersion, + String problemStatement, JsonNode problemConditions, String solutionText, + JsonNode claims, JsonNode stepMarks, JsonNode verificationSummary, + String verificationVersion, JsonNode sourceRefIds, String overallCoverage, + String contentSha256, UUID revisesSolutionId, Instant createdAt) { + this.id = Objects.requireNonNull(id); + this.publicSolutionId = Objects.requireNonNull(publicSolutionId); + this.noteId = Objects.requireNonNull(noteId); + this.sourceRunId = Objects.requireNonNull(sourceRunId); + this.assistantMessageId = Objects.requireNonNull(assistantMessageId); + this.schemaVersion = Objects.requireNonNull(schemaVersion); + this.solutionVersion = solutionVersion; + this.problemStatement = Objects.requireNonNull(problemStatement); + this.problemConditions = Objects.requireNonNull(problemConditions); + this.solutionText = Objects.requireNonNull(solutionText); + this.claims = Objects.requireNonNull(claims); + this.stepMarks = Objects.requireNonNull(stepMarks); + this.verificationSummary = Objects.requireNonNull(verificationSummary); + this.verificationVersion = Objects.requireNonNull(verificationVersion); + this.sourceRefIds = Objects.requireNonNull(sourceRefIds); + this.overallCoverage = Objects.requireNonNull(overallCoverage); + this.contentSha256 = Objects.requireNonNull(contentSha256); + this.revisesSolutionId = revisesSolutionId; + this.status = VerifiedSolutionStatus.ACTIVE; + this.createdAt = Objects.requireNonNull(createdAt); + } +} diff --git a/src/main/java/com/proovy/domain/solution/entity/VerifiedSolutionStatus.java b/src/main/java/com/proovy/domain/solution/entity/VerifiedSolutionStatus.java new file mode 100644 index 00000000..7ba6db29 --- /dev/null +++ b/src/main/java/com/proovy/domain/solution/entity/VerifiedSolutionStatus.java @@ -0,0 +1,6 @@ +package com.proovy.domain.solution.entity; + +public enum VerifiedSolutionStatus { + ACTIVE, + SUPERSEDED +} diff --git a/src/main/java/com/proovy/domain/solution/repository/VerifiedSolutionRepository.java b/src/main/java/com/proovy/domain/solution/repository/VerifiedSolutionRepository.java new file mode 100644 index 00000000..283394c2 --- /dev/null +++ b/src/main/java/com/proovy/domain/solution/repository/VerifiedSolutionRepository.java @@ -0,0 +1,14 @@ +package com.proovy.domain.solution.repository; + +import com.proovy.domain.solution.entity.VerifiedSolution; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.UUID; + +public interface VerifiedSolutionRepository extends JpaRepository { + + List findByNoteIdOrderByCreatedAtAsc(Long noteId); + + List findByAssistantMessageId(UUID assistantMessageId); +} diff --git a/src/main/java/com/proovy/domain/usage/entity/RequestUsageCharge.java b/src/main/java/com/proovy/domain/usage/entity/RequestUsageCharge.java new file mode 100644 index 00000000..1b2e0a62 --- /dev/null +++ b/src/main/java/com/proovy/domain/usage/entity/RequestUsageCharge.java @@ -0,0 +1,64 @@ +package com.proovy.domain.usage.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +/** + * request_id당 한 번인 최초 일반 사용량 차감 (PK = request_id). 버킷별 차감량을 남겨 + * 보상 시 같은 양을 복구한다. Note 삭제로 run이 사라져도 row와 멱등 키는 남는다. + */ +@Entity +@Table(name = "request_usage_charges") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class RequestUsageCharge { + + @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", updatable = false) + private UUID runId; + + @Column(name = "usage_units", nullable = false, updatable = false) + private Integer usageUnits; + + @Column(name = "credit_amount", nullable = false, updatable = false) + private Integer creditAmount; + + @Column(name = "charged_daily", nullable = false, updatable = false) + private Integer chargedDaily; + + @Column(name = "charged_free", nullable = false, updatable = false) + private Integer chargedFree; + + @Column(name = "charged_paid", nullable = false, updatable = false) + private Integer chargedPaid; + + @Column(name = "charged_at", nullable = false, updatable = false) + private Instant chargedAt; + + @Builder + private RequestUsageCharge(String requestId, Long userId, UUID runId, int usageUnits, + int chargedDaily, int chargedFree, int chargedPaid, Instant chargedAt) { + this.requestId = Objects.requireNonNull(requestId); + this.userId = Objects.requireNonNull(userId); + this.runId = runId; + this.usageUnits = usageUnits; + this.chargedDaily = chargedDaily; + this.chargedFree = chargedFree; + this.chargedPaid = chargedPaid; + this.creditAmount = chargedDaily + chargedFree + chargedPaid; + this.chargedAt = Objects.requireNonNull(chargedAt); + } +} diff --git a/src/main/java/com/proovy/domain/usage/entity/RequestUsageCompensation.java b/src/main/java/com/proovy/domain/usage/entity/RequestUsageCompensation.java new file mode 100644 index 00000000..1167774a --- /dev/null +++ b/src/main/java/com/proovy/domain/usage/entity/RequestUsageCompensation.java @@ -0,0 +1,57 @@ +package com.proovy.domain.usage.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.Instant; +import java.util.Objects; + +/** + * request_id당 최대 한 번인 Usage Compensation (PK = request_id). 차감 row가 있어야 하고 + * 복구량은 차감량과 같다(composite FK). 경합하는 보상 사유는 PK 충돌로 하나만 남는다. + */ +@Entity +@Table(name = "request_usage_compensations") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class RequestUsageCompensation { + + @Id + @Column(name = "request_id", length = 128) + private String requestId; + + @Column(name = "credit_amount", nullable = false, updatable = false) + private Integer creditAmount; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 40, updatable = false) + private UsageCompensationReason reason; + + @Column(name = "restored_daily", nullable = false, updatable = false) + private Integer restoredDaily; + + @Column(name = "restored_free", nullable = false, updatable = false) + private Integer restoredFree; + + @Column(name = "restored_paid", nullable = false, updatable = false) + private Integer restoredPaid; + + @Column(name = "compensated_at", nullable = false, updatable = false) + private Instant compensatedAt; + + @Builder + private RequestUsageCompensation(String requestId, UsageCompensationReason reason, + int restoredDaily, int restoredFree, int restoredPaid, + Instant compensatedAt) { + this.requestId = Objects.requireNonNull(requestId); + this.reason = Objects.requireNonNull(reason); + this.restoredDaily = restoredDaily; + this.restoredFree = restoredFree; + this.restoredPaid = restoredPaid; + this.creditAmount = restoredDaily + restoredFree + restoredPaid; + this.compensatedAt = Objects.requireNonNull(compensatedAt); + } +} diff --git a/src/main/java/com/proovy/domain/usage/entity/UsageCompensationReason.java b/src/main/java/com/proovy/domain/usage/entity/UsageCompensationReason.java new file mode 100644 index 00000000..c42e5991 --- /dev/null +++ b/src/main/java/com/proovy/domain/usage/entity/UsageCompensationReason.java @@ -0,0 +1,15 @@ +package com.proovy.domain.usage.entity; + +/** + * request당 최대 한 번인 Usage Compensation의 사유 (docs/plan/07). + * RUNNING·RETRYING 이후 사용자 취소와 substantive 응답은 보상하지 않으므로 여기에 없다. + */ +public enum UsageCompensationReason { + PENDING_CANCELED, + CLARIFICATION_ONLY, + UNSUPPORTED_ONLY, + VERIFICATION_FAILED, + BUDGET_EXCEEDED, + INVALID_ATTACHMENT, + INFRASTRUCTURE_FAILURE +} diff --git a/src/main/java/com/proovy/domain/usage/repository/RequestUsageChargeRepository.java b/src/main/java/com/proovy/domain/usage/repository/RequestUsageChargeRepository.java new file mode 100644 index 00000000..d48561a7 --- /dev/null +++ b/src/main/java/com/proovy/domain/usage/repository/RequestUsageChargeRepository.java @@ -0,0 +1,7 @@ +package com.proovy.domain.usage.repository; + +import com.proovy.domain.usage.entity.RequestUsageCharge; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface RequestUsageChargeRepository extends JpaRepository { +} diff --git a/src/main/java/com/proovy/domain/usage/repository/RequestUsageCompensationRepository.java b/src/main/java/com/proovy/domain/usage/repository/RequestUsageCompensationRepository.java new file mode 100644 index 00000000..af6d22e0 --- /dev/null +++ b/src/main/java/com/proovy/domain/usage/repository/RequestUsageCompensationRepository.java @@ -0,0 +1,7 @@ +package com.proovy.domain.usage.repository; + +import com.proovy.domain.usage.entity.RequestUsageCompensation; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface RequestUsageCompensationRepository extends JpaRepository { +} diff --git a/src/main/java/com/proovy/domain/user/service/UserService.java b/src/main/java/com/proovy/domain/user/service/UserService.java index ea9bafad..b26ea741 100644 --- a/src/main/java/com/proovy/domain/user/service/UserService.java +++ b/src/main/java/com/proovy/domain/user/service/UserService.java @@ -3,9 +3,6 @@ import com.proovy.domain.asset.repository.AssetRepository; import com.proovy.domain.auth.repository.RefreshTokenRepository; import com.proovy.domain.auth.service.AccessTokenBlacklistService; -import com.proovy.domain.conversation.repository.ChatMessageRepository; -import com.proovy.domain.conversation.repository.ChatSessionRepository; -import com.proovy.domain.conversation.repository.MessageAttachmentRepository; import com.proovy.domain.credit.entity.CreditBalance; import com.proovy.domain.credit.repository.CreditBalanceRepository; import com.proovy.domain.credit.repository.CreditHistoryRepository; @@ -53,9 +50,6 @@ public class UserService { private final CreditBalanceRepository creditBalanceRepository; private final CreditHistoryRepository creditHistoryRepository; private final CreditBalanceService creditBalanceService; - private final MessageAttachmentRepository messageAttachmentRepository; - private final ChatMessageRepository chatMessageRepository; - private final ChatSessionRepository chatSessionRepository; private final GcsService s3Service; private final RefreshTokenRepository refreshTokenRepository; private final AccessTokenBlacklistService accessTokenBlacklistService; @@ -217,14 +211,10 @@ private void deleteUserData(Long userId) { } }); - // DB 데이터 삭제 (FK 자식 → 부모 순서) - messageAttachmentRepository.deleteAllByUserId(userId); - chatMessageRepository.deleteAllByUserId(userId); - chatSessionRepository.deleteAllByUserId(userId); + // DB 데이터 삭제: Note 삭제가 메시지·run·event·asset·solution을 FK cascade로 정리한다. + noteRepository.deleteAllByUserId(userId); creditHistoryRepository.deleteAllByUserId(userId); creditBalanceRepository.deleteAllByUserId(userId); - assetRepository.deleteAllByUserId(userId); - noteRepository.deleteAllByUserId(userId); userPlanRepository.deleteAllByUserId(userId); // 커밋 이후 S3 삭제 (best-effort) diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index e5dba73b..3d5e5053 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -18,9 +18,10 @@ spring: jdbc: time_zone: Asia/Seoul + # 신규 제품 baseline(V1)은 빈 DB에서만 시작한다. 과거 migration history가 있거나 + # history 없이 테이블이 있는 DB는 baseline하지 않고 기동을 실패시킨다 (ADR 0015). flyway: - baseline-on-migrate: true - baseline-version: '0' + baseline-on-migrate: false locations: classpath:db/migration data: @@ -108,12 +109,7 @@ jwt: refresh-token-expiration: 604800 # 7일 (초) signup-token-expiration: 600 # 10분 (초) -# =============================== -# Proovy-ai (FastAPI Agent) -# =============================== proovy: - ai: - host: ${PROOVY_AI_HOST:https://api.proovy.ai.kr/ai} api: public-base-url: ${PROOVY_API_BASE_URL:} @@ -122,15 +118,6 @@ internal: token: ${INTERNAL_API_TOKEN} port: 8081 -embedding: - redis: - stream: - key: embedding_jobs - consumer-group: spring-publisher - lock: - prefix: emb:lock - ttl: 3600 - # =============================== # OpenRouter API (Gemini 2.5 Flash 경유) # =============================== @@ -148,9 +135,10 @@ spring: activate: on-profile: local + # schema는 Flyway만 관리한다. Hibernate는 엔티티와 baseline 정합성만 검증한다. jpa: hibernate: - ddl-auto: update + ddl-auto: validate show-sql: true properties: hibernate: diff --git a/src/main/resources/db/migration/V0__bootstrap_users_table.sql b/src/main/resources/db/migration/V0__bootstrap_users_table.sql deleted file mode 100644 index b5855f96..00000000 --- a/src/main/resources/db/migration/V0__bootstrap_users_table.sql +++ /dev/null @@ -1,14 +0,0 @@ --- Legacy migrations (V1+) reference users FK before V21. --- Create users table upfront so fresh database migration order is valid. -CREATE TABLE IF NOT EXISTS users ( - user_id BIGSERIAL PRIMARY KEY, - name VARCHAR(50), - nickname VARCHAR(30) NOT NULL, - department VARCHAR(100), - referral_source VARCHAR(20), - provider VARCHAR(20), - provider_user_id VARCHAR(100), - email VARCHAR(255), - created_at TIMESTAMP, - updated_at TIMESTAMP -); diff --git a/src/main/resources/db/migration/V10__create_notes.sql b/src/main/resources/db/migration/V10__create_notes.sql deleted file mode 100644 index 0fed1e7a..00000000 --- a/src/main/resources/db/migration/V10__create_notes.sql +++ /dev/null @@ -1,10 +0,0 @@ -CREATE TABLE IF NOT EXISTS notes ( - note_id BIGSERIAL PRIMARY KEY, - user_id BIGINT NOT NULL, - title VARCHAR(200) NOT NULL, - content_md TEXT, - created_at TIMESTAMP, - updated_at TIMESTAMP, - CONSTRAINT fk_notes_user - FOREIGN KEY (user_id) REFERENCES users(user_id) -); diff --git a/src/main/resources/db/migration/V11__create_user_plans.sql b/src/main/resources/db/migration/V11__create_user_plans.sql deleted file mode 100644 index 4d14bbca..00000000 --- a/src/main/resources/db/migration/V11__create_user_plans.sql +++ /dev/null @@ -1,10 +0,0 @@ -CREATE TABLE IF NOT EXISTS user_plans ( - user_plan_id BIGSERIAL PRIMARY KEY, - user_id BIGINT NOT NULL, - plan_type VARCHAR(10) NOT NULL DEFAULT 'FREE', - started_at TIMESTAMP, - expired_at TIMESTAMP, - is_active BOOLEAN DEFAULT true, - CONSTRAINT fk_user_plans_user - FOREIGN KEY (user_id) REFERENCES users(user_id) -); diff --git a/src/main/resources/db/migration/V12__create_conversations.sql b/src/main/resources/db/migration/V12__create_conversations.sql deleted file mode 100644 index 26e5393a..00000000 --- a/src/main/resources/db/migration/V12__create_conversations.sql +++ /dev/null @@ -1,7 +0,0 @@ -CREATE TABLE IF NOT EXISTS conversations ( - conversation_id BIGSERIAL PRIMARY KEY, - note_id BIGINT NOT NULL, - created_at TIMESTAMP, - CONSTRAINT fk_conversations_note - FOREIGN KEY (note_id) REFERENCES notes(note_id) -); diff --git a/src/main/resources/db/migration/V13__create_messages.sql b/src/main/resources/db/migration/V13__create_messages.sql deleted file mode 100644 index 507b6e22..00000000 --- a/src/main/resources/db/migration/V13__create_messages.sql +++ /dev/null @@ -1,10 +0,0 @@ -CREATE TABLE IF NOT EXISTS messages ( - message_id BIGSERIAL PRIMARY KEY, - conversation_id BIGINT NOT NULL, - role VARCHAR(20) NOT NULL, - content TEXT, - status VARCHAR(20), - created_at TIMESTAMP, - CONSTRAINT fk_messages_conversation - FOREIGN KEY (conversation_id) REFERENCES conversations(conversation_id) -); diff --git a/src/main/resources/db/migration/V14__create_message_tools.sql b/src/main/resources/db/migration/V14__create_message_tools.sql deleted file mode 100644 index 75b918ce..00000000 --- a/src/main/resources/db/migration/V14__create_message_tools.sql +++ /dev/null @@ -1,7 +0,0 @@ -CREATE TABLE IF NOT EXISTS message_tools ( - id BIGSERIAL PRIMARY KEY, - message_id BIGINT NOT NULL, - tool_code VARCHAR(50) NOT NULL, - CONSTRAINT fk_message_tools_message - FOREIGN KEY (message_id) REFERENCES messages(message_id) -); diff --git a/src/main/resources/db/migration/V15__create_message_assets.sql b/src/main/resources/db/migration/V15__create_message_assets.sql deleted file mode 100644 index 3a2370de..00000000 --- a/src/main/resources/db/migration/V15__create_message_assets.sql +++ /dev/null @@ -1,9 +0,0 @@ -CREATE TABLE IF NOT EXISTS message_assets ( - id BIGSERIAL PRIMARY KEY, - message_id BIGINT NOT NULL, - asset_id BIGINT NOT NULL, - CONSTRAINT fk_message_assets_message - FOREIGN KEY (message_id) REFERENCES messages(message_id), - CONSTRAINT fk_message_assets_asset - FOREIGN KEY (asset_id) REFERENCES assets(id) -); diff --git a/src/main/resources/db/migration/V16__create_tools.sql b/src/main/resources/db/migration/V16__create_tools.sql deleted file mode 100644 index 629ef2f8..00000000 --- a/src/main/resources/db/migration/V16__create_tools.sql +++ /dev/null @@ -1,11 +0,0 @@ -CREATE TABLE IF NOT EXISTS tools ( - tool_id BIGSERIAL PRIMARY KEY, - tool_code VARCHAR(50) NOT NULL UNIQUE, - name VARCHAR(100) NOT NULL, - description TEXT, - icon_type VARCHAR(50), - is_active BOOLEAN NOT NULL DEFAULT true, - display_order INTEGER NOT NULL DEFAULT 0, - created_at TIMESTAMP, - updated_at TIMESTAMP -); diff --git a/src/main/resources/db/migration/V17__create_note_embeddings.sql b/src/main/resources/db/migration/V17__create_note_embeddings.sql deleted file mode 100644 index fe41b132..00000000 --- a/src/main/resources/db/migration/V17__create_note_embeddings.sql +++ /dev/null @@ -1,36 +0,0 @@ -CREATE EXTENSION IF NOT EXISTS vector; - -CREATE TABLE IF NOT EXISTS note_embeddings ( - id BIGSERIAL PRIMARY KEY, - note_id BIGINT NOT NULL, - user_id BIGINT NOT NULL, - content_hash VARCHAR(64) NOT NULL, - embedding_model VARCHAR(50) NOT NULL DEFAULT 'text-embedding-3-small', - vector vector(1536) NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT uk_note_embedding UNIQUE (note_id, embedding_model) -); - -CREATE INDEX IF NOT EXISTS idx_note_embeddings_note_id ON note_embeddings(note_id); -CREATE INDEX IF NOT EXISTS idx_note_embeddings_user_id ON note_embeddings(user_id); -CREATE INDEX IF NOT EXISTS idx_note_embeddings_hash ON note_embeddings(content_hash); - -CREATE INDEX IF NOT EXISTS idx_note_embeddings_vector_cosine -ON note_embeddings -USING hnsw (vector vector_cosine_ops) -WITH (m = 16, ef_construction = 64); - -CREATE OR REPLACE FUNCTION update_note_embedding_timestamp() -RETURNS TRIGGER AS $$ -BEGIN - NEW.updated_at = CURRENT_TIMESTAMP; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -DROP TRIGGER IF EXISTS trg_note_embedding_updated ON note_embeddings; -CREATE TRIGGER trg_note_embedding_updated -BEFORE UPDATE ON note_embeddings -FOR EACH ROW -EXECUTE FUNCTION update_note_embedding_timestamp(); diff --git a/src/main/resources/db/migration/V18__add_thread_id_to_notes.sql b/src/main/resources/db/migration/V18__add_thread_id_to_notes.sql deleted file mode 100644 index c4bfc125..00000000 --- a/src/main/resources/db/migration/V18__add_thread_id_to_notes.sql +++ /dev/null @@ -1,8 +0,0 @@ --- Note 테이블에 thread_id 컬럼 추가 (멀티턴 대화 지원) -ALTER TABLE notes - ADD COLUMN thread_id VARCHAR(100) UNIQUE; - --- 인덱스 생성 (thread_id로 조회 시 성능 향상) -CREATE INDEX idx_notes_thread_id ON notes(thread_id); - -COMMENT ON COLUMN notes.thread_id IS 'Proovy-ai LangGraph checkpointer와 연동할 대화 맥락 식별자'; diff --git a/src/main/resources/db/migration/V19__add_fulltext_search.sql b/src/main/resources/db/migration/V19__add_fulltext_search.sql deleted file mode 100644 index 4800a9c5..00000000 --- a/src/main/resources/db/migration/V19__add_fulltext_search.sql +++ /dev/null @@ -1,68 +0,0 @@ --- ============================================= --- V19: PostgreSQL Full-Text Search 최적화 --- messages 테이블에 tsvector 컬럼 및 GIN 인덱스 추가 --- ============================================= - --- pg_trgm 확장 설치 (한글 검색 지원) -CREATE EXTENSION IF NOT EXISTS pg_trgm; - --- messages 테이블에 search_vector 컬럼 추가 -ALTER TABLE messages ADD COLUMN IF NOT EXISTS search_vector tsvector; - --- GIN 인덱스 생성 (Full-Text Search용) -CREATE INDEX IF NOT EXISTS idx_messages_search_vector -ON messages USING GIN(search_vector); - --- pg_trgm GIN 인덱스 (한글 LIKE 검색 최적화) -CREATE INDEX IF NOT EXISTS idx_messages_content_trgm -ON messages USING GIN(content gin_trgm_ops); - --- tsvector 자동 업데이트 함수 -CREATE OR REPLACE FUNCTION messages_search_vector_update() -RETURNS TRIGGER AS $$ -BEGIN - -- 'simple' 설정: 언어 의존성 없이 토큰화 (한글 호환) - NEW.search_vector := to_tsvector('simple', COALESCE(NEW.content, '')); - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - --- 트리거 생성 (INSERT/UPDATE 시 자동 업데이트) -DROP TRIGGER IF EXISTS trg_messages_search_vector ON messages; -CREATE TRIGGER trg_messages_search_vector - BEFORE INSERT OR UPDATE OF content ON messages - FOR EACH ROW - EXECUTE FUNCTION messages_search_vector_update(); - --- 복합 인덱스: conversation_id + role (자주 사용되는 필터 조합) -CREATE INDEX IF NOT EXISTS idx_messages_conversation_role -ON messages(conversation_id, role); - --- ============================================= --- 기존 데이터 백필 (배치 처리로 테이블 락 방지) --- 1000건씩 배치로 처리하여 장시간 락 방지 --- ============================================= -DO $$ -DECLARE - batch_size INT := 1000; - updated_count INT; -BEGIN - LOOP - UPDATE messages - SET search_vector = to_tsvector('simple', COALESCE(content, '')) - WHERE message_id IN ( - SELECT message_id FROM messages - WHERE search_vector IS NULL - LIMIT batch_size - FOR UPDATE SKIP LOCKED - ); - - GET DIAGNOSTICS updated_count = ROW_COUNT; - - -- 더 이상 업데이트할 행이 없으면 종료 - EXIT WHEN updated_count = 0; - - -- 각 배치 후 짧은 대기 (다른 트랜잭션에 기회 제공) - PERFORM pg_sleep(0.1); - END LOOP; -END $$; diff --git a/src/main/resources/db/migration/V1__create_credit_balance.sql b/src/main/resources/db/migration/V1__create_credit_balance.sql deleted file mode 100644 index 3ec43971..00000000 --- a/src/main/resources/db/migration/V1__create_credit_balance.sql +++ /dev/null @@ -1,14 +0,0 @@ -CREATE TABLE IF NOT EXISTS credit_balance ( - id BIGSERIAL PRIMARY KEY, - user_id BIGINT NOT NULL UNIQUE, - daily_free_credit INTEGER NOT NULL DEFAULT 0, - daily_free_limit INTEGER NOT NULL DEFAULT 100, - daily_expires_at TIMESTAMP NULL, - free_credit INTEGER NOT NULL DEFAULT 0, - paid_credit INTEGER NOT NULL DEFAULT 0, - paid_expires_at TIMESTAMP NULL, - created_at TIMESTAMP NULL, - updated_at TIMESTAMP NULL, - CONSTRAINT fk_credit_balance_user - FOREIGN KEY (user_id) REFERENCES users(user_id) -); diff --git a/src/main/resources/db/migration/V1__product_baseline.sql b/src/main/resources/db/migration/V1__product_baseline.sql new file mode 100644 index 00000000..7d776c07 --- /dev/null +++ b/src/main/resources/db/migration/V1__product_baseline.sql @@ -0,0 +1,753 @@ +-- ===================================================================== +-- V1: Proovy 신규 오픈 제품 baseline (ADR 0015, docs/plan/07·10·12, spec 07) +-- +-- 빈 Postgres에서 직접 생성한다. 과거 V0~V32 migration 연쇄·데이터·ChatSession +-- 호환·thread ID를 전제로 하지 않는다. baseline 이후 schema 진화는 V2부터의 +-- versioned migration으로만 한다. +-- +-- 소유: 이 schema(public)는 Proovy-server 제품 테이블이며 Brain role은 접근하지 +-- 않는다. role 경계는 db/provisioning/role-boundary.sql(운영자 적용)에서 고정한다. +-- ===================================================================== + +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +-- --------------------------------------------------------------------- +-- 공통 trigger 함수 +-- --------------------------------------------------------------------- + +-- UPDATE를 금지하는 append-only/insert-only 테이블용. +CREATE FUNCTION proovy_reject_update() RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + RAISE EXCEPTION '% is immutable: UPDATE is not allowed', TG_TABLE_NAME + USING ERRCODE = 'integrity_constraint_violation'; +END; +$$; + +-- ===================================================================== +-- 사용자·플랜·잔액 (기존 인증/잔액 코드 재사용) +-- ===================================================================== + +CREATE TABLE users ( + user_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + name VARCHAR(50), + nickname VARCHAR(30) NOT NULL, + department VARCHAR(100), + referral_source VARCHAR(20), + provider VARCHAR(20) CONSTRAINT ck_users_provider CHECK (provider IN ('KAKAO', 'NAVER')), + provider_user_id VARCHAR(100), + email VARCHAR(255), + created_at TIMESTAMP, + updated_at TIMESTAMP +); + +CREATE UNIQUE INDEX idx_user_provider + ON users (provider, provider_user_id) + WHERE provider IS NOT NULL AND provider_user_id IS NOT NULL; + +CREATE TABLE user_plans ( + user_plan_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users (user_id) ON DELETE CASCADE, + plan_type VARCHAR(10) NOT NULL DEFAULT 'FREE' + CONSTRAINT ck_user_plans_plan_type CHECK (plan_type IN ('FREE', 'STANDARD', 'PRO')), + started_at TIMESTAMP, + expired_at TIMESTAMP, + canceled_at TIMESTAMP, + next_plan_type VARCHAR(10) + CONSTRAINT ck_user_plans_next_plan_type CHECK (next_plan_type IN ('FREE', 'STANDARD', 'PRO')), + is_active BOOLEAN NOT NULL DEFAULT TRUE +); + +CREATE UNIQUE INDEX ux_user_plans_active_user ON user_plans (user_id) WHERE is_active = TRUE; +CREATE INDEX idx_user_plans_due_transition + ON user_plans (expired_at, user_plan_id) + WHERE is_active = TRUE AND canceled_at IS NOT NULL; + +CREATE TABLE credit_balance ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL UNIQUE REFERENCES users (user_id) ON DELETE CASCADE, + daily_free_credit INTEGER NOT NULL DEFAULT 0 CONSTRAINT ck_credit_balance_daily CHECK (daily_free_credit >= 0), + daily_free_limit INTEGER NOT NULL DEFAULT 100 CONSTRAINT ck_credit_balance_daily_limit CHECK (daily_free_limit >= 0), + daily_expires_at TIMESTAMP, + free_credit INTEGER NOT NULL DEFAULT 0 CONSTRAINT ck_credit_balance_free CHECK (free_credit >= 0), + paid_credit INTEGER NOT NULL DEFAULT 0 CONSTRAINT ck_credit_balance_paid CHECK (paid_credit >= 0), + paid_expires_at TIMESTAMP, + last_monthly_grant_plan_id BIGINT, + created_at TIMESTAMP, + updated_at TIMESTAMP +); + +CREATE TABLE credit_history ( + history_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users (user_id) ON DELETE CASCADE, + event_type VARCHAR(30) NOT NULL, + event_name VARCHAR(100) NOT NULL, + description VARCHAR(500), + amount INTEGER NOT NULL, + change_type VARCHAR(10) NOT NULL CONSTRAINT ck_credit_history_change_type CHECK (change_type IN ('EARN', 'SPEND', 'EXPIRE')), + credit_type VARCHAR(10) NOT NULL CONSTRAINT ck_credit_history_credit_type CHECK (credit_type IN ('DAILY', 'FREE', 'PAID')), + balance_after_daily INTEGER NOT NULL, + balance_after_free INTEGER NOT NULL, + balance_after_paid INTEGER NOT NULL, + created_at TIMESTAMP +); + +CREATE INDEX idx_credit_history_user_created ON credit_history (user_id, created_at); +CREATE INDEX idx_credit_history_change_type ON credit_history (change_type); +CREATE INDEX idx_credit_history_credit_type ON credit_history (credit_type); + +-- ===================================================================== +-- Note (유일한 대화 aggregate root, conversation_id = note_id) +-- ===================================================================== + +CREATE TABLE notes ( + note_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users (user_id) ON DELETE CASCADE, + title VARCHAR(200) NOT NULL, + -- DELETING Note에는 새 run을 만들지 않는다 (docs/plan/10). + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' + CONSTRAINT ck_notes_status CHECK (status IN ('ACTIVE', 'DELETING')), + deletion_requested_at TIMESTAMPTZ, + created_at TIMESTAMP, + updated_at TIMESTAMP, + CONSTRAINT uq_notes_note_user UNIQUE (note_id, user_id), + CONSTRAINT ck_notes_deleting CHECK ((status = 'DELETING') = (deletion_requested_at IS NOT NULL)) +); + +CREATE INDEX idx_notes_user_updated ON notes (user_id, updated_at DESC); +CREATE INDEX idx_notes_title_trgm ON notes USING GIN (title gin_trgm_ops); + +-- ===================================================================== +-- Note Asset (S09 업로드·S10 색인·S13 삭제는 후속; 여기서는 S07이 소비할 +-- 소유/상태/version/page metadata 경계만 고정) +-- ===================================================================== + +CREATE TABLE assets ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + note_id BIGINT NOT NULL, + file_name VARCHAR(255) NOT NULL, + file_size BIGINT NOT NULL CONSTRAINT ck_assets_file_size CHECK (file_size >= 0), + mime_type VARCHAR(100) NOT NULL, + object_key VARCHAR(500) NOT NULL, + thumbnail_object_key VARCHAR(500), + source VARCHAR(20) NOT NULL CONSTRAINT ck_assets_source CHECK (source = 'upload'), + -- PENDING: upload staging, ACTIVE: 확정된 Note Asset, + -- DELETING: 삭제 확인 직후(새 run scope/availability에서 즉시 제외), DELETED: 정리 완료 + status VARCHAR(20) NOT NULL + CONSTRAINT ck_assets_status CHECK (status IN ('PENDING', 'ACTIVE', 'FAILED', 'DELETING', 'DELETED')), + upload_expires_at TIMESTAMP, + content_version INTEGER NOT NULL DEFAULT 1 CONSTRAINT ck_assets_content_version CHECK (content_version >= 1), + sha256 VARCHAR(64) CONSTRAINT ck_assets_sha256 CHECK (sha256 ~ '^[0-9a-f]{64}$'), + page_count INTEGER CONSTRAINT ck_assets_page_count CHECK (page_count >= 1), + -- 구형 업로드 골격의 썸네일/OCR 진행 표시. S09에서 page-aware metadata로 교체한다. + ocr_status VARCHAR(20) + CONSTRAINT ck_assets_ocr_status CHECK (ocr_status IN ('pending', 'processing', 'completed', 'failed')), + ocr_text TEXT, + ocr_processed_at TIMESTAMP, + deletion_operation_id UUID CONSTRAINT uq_assets_deletion_operation UNIQUE, + deletion_requested_at TIMESTAMPTZ, + brain_cleanup_acked_at TIMESTAMPTZ, + storage_cleaned_at TIMESTAMPTZ, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + version BIGINT, + -- asset 소유자는 Note 소유자와 같다. + CONSTRAINT fk_assets_note_owner FOREIGN KEY (note_id, user_id) + REFERENCES notes (note_id, user_id) ON DELETE CASCADE, + CONSTRAINT uq_assets_note_asset UNIQUE (note_id, id), + CONSTRAINT ck_assets_deletion CHECK ( + (status IN ('DELETING', 'DELETED')) = (deletion_operation_id IS NOT NULL AND deletion_requested_at IS NOT NULL) + ), + CONSTRAINT ck_assets_deleted_cleanup CHECK ( + status <> 'DELETED' OR (brain_cleanup_acked_at IS NOT NULL AND storage_cleaned_at IS NOT NULL) + ) +); + +CREATE INDEX idx_assets_user_id ON assets (user_id); +CREATE INDEX idx_assets_note_status ON assets (note_id, status); + +CREATE TABLE tools ( + tool_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + tool_code VARCHAR(50) NOT NULL UNIQUE, + name VARCHAR(100) NOT NULL, + description TEXT, + icon_type VARCHAR(50), + is_active BOOLEAN NOT NULL DEFAULT TRUE, + display_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP, + updated_at TIMESTAMP +); + +-- ===================================================================== +-- 대화 메시지 (Note 직접 연결, ChatSession/thread ID 없음) +-- ===================================================================== + +CREATE TABLE chat_messages ( + message_id UUID PRIMARY KEY, + note_id BIGINT NOT NULL REFERENCES notes (note_id) ON DELETE CASCADE, + role VARCHAR(10) NOT NULL CONSTRAINT ck_chat_messages_role CHECK (role IN ('USER', 'ASSISTANT')), + status VARCHAR(20) NOT NULL + CONSTRAINT ck_chat_messages_status CHECK (status IN ('PENDING', 'STREAMING', 'COMPLETED', 'FAILED', 'CANCELED')), + -- USER: 의미 입력 원문 {"text": ..., "latex"?: ...} + user_input JSONB, + -- ASSISTANT COMPLETED: canonical response_pages[{response_page_id, presentation, title?, result_blocks}] + response_pages JSONB, + -- ASSISTANT COMPLETED: Source Reference catalog snapshot + -- [{source_ref_id, asset_id, page_start, page_end, display_label_snapshot}]. + -- Note Asset FK에 묶지 않으므로 asset rename/삭제가 이 snapshot을 바꾸지 않는다. + source_references JSONB, + -- 공개 block·source label에서 파생한 plain text (USER는 입력 text). history·검색에 사용. + plain_text TEXT, + content_sha256 VARCHAR(64) CONSTRAINT ck_chat_messages_sha256 CHECK (content_sha256 ~ '^[0-9a-f]{64}$'), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + completed_at TIMESTAMPTZ, + CONSTRAINT uq_chat_messages_note_message_role UNIQUE (note_id, message_id, role), + CONSTRAINT ck_chat_messages_user CHECK ( + role <> 'USER' OR ( + status = 'COMPLETED' + AND jsonb_typeof(user_input) = 'object' + AND jsonb_typeof(user_input -> 'text') = 'string' + AND plain_text IS NOT NULL + AND response_pages IS NULL AND source_references IS NULL AND completed_at IS NULL + ) + ), + CONSTRAINT ck_chat_messages_assistant_input CHECK (role <> 'ASSISTANT' OR user_input IS NULL), + -- STREAMING placeholder는 Activity 진행만 뜻하고 영속 본문이 없다 (Candidate token 저장 금지). + CONSTRAINT ck_chat_messages_assistant_placeholder CHECK ( + role <> 'ASSISTANT' OR status = 'COMPLETED' + OR (response_pages IS NULL AND source_references IS NULL AND plain_text IS NULL AND content_sha256 IS NULL) + ), + CONSTRAINT ck_chat_messages_assistant_completed CHECK ( + role <> 'ASSISTANT' OR status <> 'COMPLETED' OR ( + jsonb_typeof(response_pages) = 'array' AND jsonb_array_length(response_pages) >= 1 + AND jsonb_typeof(source_references) = 'array' + AND plain_text IS NOT NULL + AND content_sha256 IS NOT NULL + ) + ), + CONSTRAINT ck_chat_messages_assistant_terminal CHECK ( + role <> 'ASSISTANT' OR (status IN ('COMPLETED', 'FAILED', 'CANCELED')) = (completed_at IS NOT NULL) + ) +); + +CREATE INDEX idx_chat_messages_note_created ON chat_messages (note_id, created_at, message_id); +CREATE INDEX idx_chat_messages_plain_text_trgm ON chat_messages USING GIN (plain_text gin_trgm_ops); + +-- 메시지 식별·소속·입력은 불변이고 terminal 메시지는 더 이상 바뀌지 않는다. +CREATE FUNCTION chat_messages_guard_update() RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + IF NEW.message_id <> OLD.message_id OR NEW.note_id <> OLD.note_id OR NEW.role <> OLD.role + OR NEW.user_input IS DISTINCT FROM OLD.user_input OR NEW.created_at <> OLD.created_at THEN + RAISE EXCEPTION 'chat_messages identity/input columns are immutable' + USING ERRCODE = 'integrity_constraint_violation'; + END IF; + IF OLD.status IN ('COMPLETED', 'FAILED', 'CANCELED') THEN + RAISE EXCEPTION 'terminal chat message % is immutable', OLD.message_id + USING ERRCODE = 'integrity_constraint_violation'; + END IF; + IF NEW.status <> OLD.status AND NOT ( + (OLD.status = 'PENDING' AND NEW.status IN ('STREAMING', 'COMPLETED', 'FAILED', 'CANCELED')) + OR (OLD.status = 'STREAMING' AND NEW.status IN ('COMPLETED', 'FAILED', 'CANCELED')) + ) THEN + RAISE EXCEPTION 'invalid chat message status transition % -> %', OLD.status, NEW.status + USING ERRCODE = 'integrity_constraint_violation'; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER trg_chat_messages_guard_update + BEFORE UPDATE ON chat_messages + FOR EACH ROW EXECUTE FUNCTION chat_messages_guard_update(); + +-- Turn Attachment: source user message의 직접 입력. Note Asset·후속 message로 복제하지 않는다. +CREATE TABLE turn_attachments ( + attachment_id UUID PRIMARY KEY, + note_id BIGINT NOT NULL, + source_message_id UUID NOT NULL, + source_message_role VARCHAR(10) NOT NULL DEFAULT 'USER' + CONSTRAINT ck_turn_attachments_source_role CHECK (source_message_role = 'USER'), + attachment_index INTEGER NOT NULL CONSTRAINT ck_turn_attachments_index CHECK (attachment_index BETWEEN 0 AND 9), + object_key VARCHAR(500) NOT NULL, + thumbnail_object_key VARCHAR(500), + sha256 VARCHAR(64) NOT NULL CONSTRAINT ck_turn_attachments_sha256 CHECK (sha256 ~ '^[0-9a-f]{64}$'), + mime_type VARCHAR(100) NOT NULL + CONSTRAINT ck_turn_attachments_mime CHECK (mime_type IN ('application/pdf', 'image/jpeg', 'image/png', 'image/webp')), + byte_size BIGINT NOT NULL CONSTRAINT ck_turn_attachments_byte_size CHECK (byte_size > 0), + page_count INTEGER, + pixel_width INTEGER, + pixel_height INTEGER, + pixel_count BIGINT, + upload_policy_version VARCHAR(64) NOT NULL, + page_understanding_version VARCHAR(64) NOT NULL, + image_normalization_version VARCHAR(64) NOT NULL, + display_label VARCHAR(200), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT fk_turn_attachments_source_message FOREIGN KEY (note_id, source_message_id, source_message_role) + REFERENCES chat_messages (note_id, message_id, role) ON DELETE CASCADE, + CONSTRAINT uq_turn_attachments_message_index UNIQUE (source_message_id, attachment_index), + -- internal-run-v1 _ScopedAsset media 규칙과 동일 + CONSTRAINT ck_turn_attachments_media CHECK ( + CASE WHEN mime_type = 'application/pdf' + THEN page_count >= 1 AND pixel_width IS NULL AND pixel_height IS NULL AND pixel_count IS NULL + ELSE page_count IS NULL AND pixel_width >= 1 AND pixel_height >= 1 + AND pixel_count = pixel_width::BIGINT * pixel_height + END + ) +); + +CREATE INDEX idx_turn_attachments_note ON turn_attachments (note_id); + +CREATE TRIGGER trg_turn_attachments_immutable + BEFORE UPDATE ON turn_attachments + FOR EACH ROW EXECUTE FUNCTION proovy_reject_update(); + +-- ===================================================================== +-- AI run (docs/plan/05·12) +-- ===================================================================== + +CREATE TABLE ai_runs ( + run_id UUID PRIMARY KEY, + request_id VARCHAR(128) NOT NULL + CONSTRAINT ck_ai_runs_request_id CHECK (request_id ~ '^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$'), + user_id BIGINT NOT NULL, + note_id BIGINT NOT NULL, + user_message_id UUID NOT NULL, + user_message_role VARCHAR(10) NOT NULL DEFAULT 'USER' + CONSTRAINT ck_ai_runs_user_message_role CHECK (user_message_role = 'USER'), + assistant_message_id UUID NOT NULL, + assistant_message_role VARCHAR(10) NOT NULL DEFAULT 'ASSISTANT' + CONSTRAINT ck_ai_runs_assistant_message_role CHECK (assistant_message_role = 'ASSISTANT'), + status VARCHAR(20) NOT NULL DEFAULT 'PENDING' + CONSTRAINT ck_ai_runs_status CHECK (status IN ('PENDING', 'RUNNING', 'RETRYING', 'CANCELING', 'COMPLETED', 'FAILED', 'CANCELED')), + + -- 실행 제어 (coordinator 조건부 update) + attempt INTEGER NOT NULL DEFAULT 0 CONSTRAINT ck_ai_runs_attempt CHECK (attempt >= 0), + fence_token VARCHAR(128) + CONSTRAINT ck_ai_runs_fence_token CHECK (fence_token ~ '^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$'), + retry_count INTEGER NOT NULL DEFAULT 0 CONSTRAINT ck_ai_runs_retry_count CHECK (retry_count >= 0), + lease_owner VARCHAR(128), + lease_expires_at TIMESTAMPTZ, + heartbeat_at TIMESTAMPTZ, + -- Asset Access Grant는 digest·만료만 저장한다. 원문·signed URL은 어디에도 저장하지 않는다. + grant_digest VARCHAR(64) CONSTRAINT ck_ai_runs_grant_digest CHECK (grant_digest ~ '^[0-9a-f]{64}$'), + grant_expires_at TIMESTAMPTZ, + runtime_cleanup_at TIMESTAMPTZ, + last_event_seq BIGINT NOT NULL DEFAULT 0 CONSTRAINT ck_ai_runs_last_event_seq CHECK (last_event_seq >= 0), + + -- 불변 요청 (접수 시 고정, 재시작 후 현재 설정/history로 재구성하지 않음) + request_fingerprint VARCHAR(64) NOT NULL + CONSTRAINT ck_ai_runs_fingerprint CHECK (request_fingerprint ~ '^[0-9a-f]{64}$'), + request_snapshot JSONB NOT NULL CONSTRAINT ck_ai_runs_request_snapshot CHECK (jsonb_typeof(request_snapshot) = 'object'), + policy_snapshot JSONB NOT NULL CONSTRAINT ck_ai_runs_policy_snapshot CHECK (jsonb_typeof(policy_snapshot) = 'object'), + budget_policy_version VARCHAR(64) NOT NULL + CONSTRAINT ck_ai_runs_budget_policy_version CHECK (budget_policy_version ~ '^[A-Za-z0-9._-]+$'), + viewer_snapshot JSONB CONSTRAINT ck_ai_runs_viewer_snapshot CHECK (viewer_snapshot IS NULL OR jsonb_typeof(viewer_snapshot) = 'object'), + corpus_snapshot JSONB CONSTRAINT ck_ai_runs_corpus_snapshot CHECK (corpus_snapshot IS NULL OR jsonb_typeof(corpus_snapshot) = 'object'), + request_deadline_at TIMESTAMPTZ NOT NULL, + + -- 누적 사용 예산 (단조 증가, 상태 조회·재전달로 초기화하지 않음) + used_llm_calls BIGINT NOT NULL DEFAULT 0 CONSTRAINT ck_ai_runs_used_llm_calls CHECK (used_llm_calls >= 0), + used_tool_calls BIGINT NOT NULL DEFAULT 0 CONSTRAINT ck_ai_runs_used_tool_calls CHECK (used_tool_calls >= 0), + used_output_tokens BIGINT NOT NULL DEFAULT 0 CONSTRAINT ck_ai_runs_used_output_tokens CHECK (used_output_tokens >= 0), + used_cost_micros BIGINT NOT NULL DEFAULT 0 CONSTRAINT ck_ai_runs_used_cost_micros CHECK (used_cost_micros >= 0), + used_page_understandings BIGINT NOT NULL DEFAULT 0 CONSTRAINT ck_ai_runs_used_page_understandings CHECK (used_page_understandings >= 0), + + -- 결과·실패·취소 분류 + response_disposition VARCHAR(30) + CONSTRAINT ck_ai_runs_disposition CHECK (response_disposition IN ('substantive', 'clarification_only', 'unsupported_only')), + clarification_reason VARCHAR(40) + CONSTRAINT ck_ai_runs_clarification_reason CHECK (clarification_reason IN ( + 'missing_input', 'ambiguous_target', 'limited_pages', 'corpus_not_ready', 'page_range_required', 'page_range_unreadable')), + failure_code VARCHAR(40) + CONSTRAINT ck_ai_runs_failure_code CHECK (failure_code IN ( + 'verification_failed', 'budget_exceeded', 'provider_error', 'timeout', 'storage_error', 'internal_error', 'invalid_attachment')), + failure_class VARCHAR(20) + CONSTRAINT ck_ai_runs_failure_class CHECK (failure_class IN ('verification', 'budget', 'infrastructure', 'attachment')), + cancel_cause VARCHAR(30) + CONSTRAINT ck_ai_runs_cancel_cause CHECK (cancel_cause IN ('user_requested', 'asset_deleting', 'budget_exhausted')), + cancel_requested_from VARCHAR(20) + CONSTRAINT ck_ai_runs_cancel_from CHECK (cancel_requested_from IN ('PENDING', 'RUNNING', 'RETRYING')), + cancel_requested_at TIMESTAMPTZ, + + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + + CONSTRAINT uq_ai_runs_request UNIQUE (request_id), + CONSTRAINT uq_ai_runs_user_message UNIQUE (user_message_id), + CONSTRAINT uq_ai_runs_assistant_message UNIQUE (assistant_message_id), + CONSTRAINT uq_ai_runs_note_run UNIQUE (note_id, run_id), + CONSTRAINT fk_ai_runs_note_owner FOREIGN KEY (note_id, user_id) + REFERENCES notes (note_id, user_id) ON DELETE CASCADE, + CONSTRAINT fk_ai_runs_user_message FOREIGN KEY (note_id, user_message_id, user_message_role) + REFERENCES chat_messages (note_id, message_id, role) ON DELETE CASCADE, + CONSTRAINT fk_ai_runs_assistant_message FOREIGN KEY (note_id, assistant_message_id, assistant_message_role) + REFERENCES chat_messages (note_id, message_id, role) ON DELETE CASCADE, + + CONSTRAINT ck_ai_runs_attempt_fence CHECK ( + (attempt = 0 AND fence_token IS NULL AND started_at IS NULL) + OR (attempt >= 1 AND fence_token IS NOT NULL AND started_at IS NOT NULL) + ), + CONSTRAINT ck_ai_runs_pending_unclaimed CHECK (status <> 'PENDING' OR (attempt = 0 AND lease_owner IS NULL)), + CONSTRAINT ck_ai_runs_executing_claimed CHECK (status NOT IN ('RUNNING', 'RETRYING', 'CANCELING') OR attempt >= 1), + CONSTRAINT ck_ai_runs_lease_pair CHECK ((lease_owner IS NULL) = (lease_expires_at IS NULL)), + CONSTRAINT ck_ai_runs_grant_pair CHECK ((grant_digest IS NULL) = (grant_expires_at IS NULL)), + CONSTRAINT ck_ai_runs_terminal_time CHECK ( + (status IN ('COMPLETED', 'FAILED', 'CANCELED')) = (completed_at IS NOT NULL) + ), + -- terminal 전환은 lease·grant를 폐기한다. + CONSTRAINT ck_ai_runs_terminal_released CHECK ( + status NOT IN ('COMPLETED', 'FAILED', 'CANCELED') OR (lease_owner IS NULL AND grant_digest IS NULL) + ), + CONSTRAINT ck_ai_runs_completed_disposition CHECK ((status = 'COMPLETED') = (response_disposition IS NOT NULL)), + CONSTRAINT ck_ai_runs_clarification_reason_pair CHECK ( + (response_disposition = 'clarification_only') = (clarification_reason IS NOT NULL) + ), + CONSTRAINT ck_ai_runs_failed_code CHECK ( + (status = 'FAILED') = (failure_code IS NOT NULL AND failure_class IS NOT NULL) + ), + -- terminal-v2 expected_failure_class 표와 동일 + CONSTRAINT ck_ai_runs_failure_code_class CHECK ( + failure_code IS NULL OR failure_class = CASE failure_code + WHEN 'verification_failed' THEN 'verification' + WHEN 'budget_exceeded' THEN 'budget' + WHEN 'invalid_attachment' THEN 'attachment' + ELSE 'infrastructure' + END + ), + CONSTRAINT ck_ai_runs_cancel_fields CHECK ( + (status IN ('CANCELING', 'CANCELED')) + = (cancel_cause IS NOT NULL AND cancel_requested_from IS NOT NULL AND cancel_requested_at IS NOT NULL) + ), + -- PENDING 취소는 CANCELING을 거치지 않는다. + CONSTRAINT ck_ai_runs_canceling_from_execution CHECK ( + status <> 'CANCELING' OR cancel_requested_from IN ('RUNNING', 'RETRYING') + ) +); + +-- 한 Note에 Active Run 하나 (cleanup 확인 전 CANCELING도 자리를 유지한다). +CREATE UNIQUE INDEX ux_ai_runs_note_active + ON ai_runs (note_id) + WHERE status IN ('PENDING', 'RUNNING', 'RETRYING', 'CANCELING'); +-- 미시작 dispatch 조회 +CREATE INDEX idx_ai_runs_pending_dispatch ON ai_runs (created_at) WHERE status = 'PENDING'; +-- stale claim(heartbeat lease 만료) 조회 +CREATE INDEX idx_ai_runs_stale_lease + ON ai_runs (lease_expires_at) + WHERE status IN ('RUNNING', 'RETRYING', 'CANCELING'); +-- 전체 deadline 만료 조회 +CREATE INDEX idx_ai_runs_deadline + ON ai_runs (request_deadline_at) + WHERE status IN ('PENDING', 'RUNNING', 'RETRYING', 'CANCELING'); +-- terminal 이후 Brain runtime cleanup 예약 조회 +CREATE INDEX idx_ai_runs_runtime_cleanup + ON ai_runs (runtime_cleanup_at) + WHERE runtime_cleanup_at IS NOT NULL AND status IN ('COMPLETED', 'FAILED', 'CANCELED'); +-- 사용자별 반복 요청 제한 +CREATE INDEX idx_ai_runs_user_created ON ai_runs (user_id, created_at DESC); +CREATE INDEX idx_ai_runs_note_created ON ai_runs (note_id, created_at); + +-- 불변 요청·단조 카운터·상태 전이를 DB에서 강제한다. +CREATE FUNCTION ai_runs_guard_update() RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + IF NEW.run_id <> OLD.run_id OR NEW.request_id <> OLD.request_id OR NEW.user_id <> OLD.user_id + OR NEW.note_id <> OLD.note_id OR NEW.user_message_id <> OLD.user_message_id + OR NEW.assistant_message_id <> OLD.assistant_message_id + OR NEW.request_fingerprint <> OLD.request_fingerprint + OR NEW.request_snapshot <> OLD.request_snapshot OR NEW.policy_snapshot <> OLD.policy_snapshot + OR NEW.budget_policy_version <> OLD.budget_policy_version + OR NEW.viewer_snapshot IS DISTINCT FROM OLD.viewer_snapshot + OR NEW.corpus_snapshot IS DISTINCT FROM OLD.corpus_snapshot + OR NEW.request_deadline_at <> OLD.request_deadline_at OR NEW.created_at <> OLD.created_at THEN + RAISE EXCEPTION 'ai_runs request identity/snapshot columns are immutable' + USING ERRCODE = 'integrity_constraint_violation'; + END IF; + + IF OLD.status IN ('COMPLETED', 'FAILED', 'CANCELED') THEN + -- terminal 뒤에는 Brain runtime cleanup 예약 시각만 기록할 수 있다. + IF (to_jsonb(NEW) - 'runtime_cleanup_at') <> (to_jsonb(OLD) - 'runtime_cleanup_at') THEN + RAISE EXCEPTION 'terminal run % is immutable', OLD.run_id + USING ERRCODE = 'integrity_constraint_violation'; + END IF; + RETURN NEW; + END IF; + + IF NEW.attempt < OLD.attempt OR NEW.retry_count < OLD.retry_count OR NEW.last_event_seq < OLD.last_event_seq + OR NEW.used_llm_calls < OLD.used_llm_calls OR NEW.used_tool_calls < OLD.used_tool_calls + OR NEW.used_output_tokens < OLD.used_output_tokens OR NEW.used_cost_micros < OLD.used_cost_micros + OR NEW.used_page_understandings < OLD.used_page_understandings THEN + RAISE EXCEPTION 'ai_runs attempt/seq/usage counters are monotonic' + USING ERRCODE = 'integrity_constraint_violation'; + END IF; + + -- 새 fence는 새 attempt에서만 발급한다. + IF NEW.fence_token IS DISTINCT FROM OLD.fence_token AND NEW.attempt = OLD.attempt THEN + RAISE EXCEPTION 'fence_token can only change with a new attempt' + USING ERRCODE = 'integrity_constraint_violation'; + END IF; + + IF NEW.status <> OLD.status AND NOT ( + (OLD.status = 'PENDING' AND NEW.status IN ('RUNNING', 'CANCELED', 'FAILED')) + OR (OLD.status = 'RUNNING' AND NEW.status IN ('RETRYING', 'CANCELING', 'COMPLETED', 'FAILED')) + OR (OLD.status = 'RETRYING' AND NEW.status IN ('RUNNING', 'CANCELING', 'COMPLETED', 'FAILED')) + OR (OLD.status = 'CANCELING' AND NEW.status = 'CANCELED') + ) THEN + RAISE EXCEPTION 'invalid run status transition % -> %', OLD.status, NEW.status + USING ERRCODE = 'integrity_constraint_violation'; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER trg_ai_runs_guard_update + BEFORE UPDATE ON ai_runs + FOR EACH ROW EXECUTE FUNCTION ai_runs_guard_update(); + +-- run 생성 시 고정한 Note Asset 의존성 (삭제 fencing 대상). 생성 뒤 추가·교체하지 않는다. +CREATE TABLE run_asset_dependencies ( + run_id UUID NOT NULL, + note_id BIGINT NOT NULL, + asset_id BIGINT NOT NULL, + dependency_kind VARCHAR(30) NOT NULL + CONSTRAINT ck_run_asset_dependencies_kind CHECK (dependency_kind IN ('VIEWER_FOCUS', 'CORPUS_SNAPSHOT')), + content_version INTEGER NOT NULL CONSTRAINT ck_run_asset_dependencies_version CHECK (content_version >= 1), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (run_id, asset_id, dependency_kind), + CONSTRAINT fk_run_asset_dependencies_run FOREIGN KEY (note_id, run_id) + REFERENCES ai_runs (note_id, run_id) ON DELETE CASCADE, + -- 같은 Note의 asset만 의존할 수 있다. 의존 중인 asset의 개별 hard delete는 commit 시 거부해 + -- DELETING 경로(S13)를 강제한다. Note 전체 삭제는 run cascade와 함께 정리되도록 deferred로 검사한다. + CONSTRAINT fk_run_asset_dependencies_asset FOREIGN KEY (note_id, asset_id) + REFERENCES assets (note_id, id) DEFERRABLE INITIALLY DEFERRED +); + +CREATE INDEX idx_run_asset_dependencies_asset ON run_asset_dependencies (asset_id); + +CREATE FUNCTION run_asset_dependencies_guard_insert() RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM ai_runs r WHERE r.run_id = NEW.run_id AND r.status = 'PENDING' AND r.attempt = 0 + ) THEN + RAISE EXCEPTION 'run asset dependencies are fixed at run creation (run % is no longer PENDING)', NEW.run_id + USING ERRCODE = 'integrity_constraint_violation'; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER trg_run_asset_dependencies_guard_insert + BEFORE INSERT ON run_asset_dependencies + FOR EACH ROW EXECUTE FUNCTION run_asset_dependencies_guard_insert(); + +CREATE TRIGGER trg_run_asset_dependencies_immutable + BEFORE UPDATE ON run_asset_dependencies + FOR EACH ROW EXECUTE FUNCTION proovy_reject_update(); + +-- 영속 browser 이벤트 (replay용, append-only). canonical 본문은 중복 저장하지 않는다. +CREATE TABLE ai_run_events ( + event_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + run_id UUID NOT NULL, + note_id BIGINT NOT NULL, + seq BIGINT NOT NULL CONSTRAINT ck_ai_run_events_seq CHECK (seq >= 1), + event_type VARCHAR(40) NOT NULL + CONSTRAINT ck_ai_run_events_type CHECK (event_type IN ( + 'run.created', 'run.canceling', 'run.canceled', 'run.failed', 'run.completed', + 'activity.started', 'activity.updated', 'activity.completed', 'activity.failed', 'activity.canceled', + 'response.page.started', 'response.page.completed')), + source_event_id VARCHAR(128) + CONSTRAINT ck_ai_run_events_source_event_id CHECK (source_event_id ~ '^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$'), + attempt INTEGER CONSTRAINT ck_ai_run_events_attempt CHECK (attempt >= 1), + activity_id VARCHAR(128), + payload JSONB NOT NULL DEFAULT '{}'::JSONB + CONSTRAINT ck_ai_run_events_payload CHECK (jsonb_typeof(payload) = 'object' AND octet_length(payload::TEXT) <= 16384), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT uq_ai_run_events_seq UNIQUE (run_id, seq), + CONSTRAINT uq_ai_run_events_source UNIQUE (run_id, source_event_id), + CONSTRAINT fk_ai_run_events_run FOREIGN KEY (note_id, run_id) + REFERENCES ai_runs (note_id, run_id) ON DELETE CASCADE, + -- Brain source Activity는 dedup 키·attempt·activity ID를 가진다. + CONSTRAINT ck_ai_run_events_activity CHECK ( + CASE WHEN event_type LIKE 'activity.%' + THEN source_event_id IS NOT NULL AND attempt IS NOT NULL AND activity_id IS NOT NULL + ELSE activity_id IS NULL + END + ), + -- Server 소유 browser event는 Brain source 식별자가 없다. + CONSTRAINT ck_ai_run_events_server_owned CHECK ( + event_type NOT IN ('run.created', 'run.canceling', 'run.canceled', 'response.page.started', 'response.page.completed') + OR (source_event_id IS NULL AND attempt IS NULL) + ) +); + +CREATE TRIGGER trg_ai_run_events_append_only + BEFORE UPDATE ON ai_run_events + FOR EACH ROW EXECUTE FUNCTION proovy_reject_update(); + +-- ===================================================================== +-- Verified Solution (insert-only 정정 체인) +-- ===================================================================== + +CREATE TABLE verified_solutions ( + solution_id UUID PRIMARY KEY, + -- Brain terminal의 neutral solution_id (Brain에 VerifiedSolutionRef로 다시 전달하는 값) + public_solution_id VARCHAR(128) NOT NULL + CONSTRAINT ck_verified_solutions_public_id CHECK (public_solution_id ~ '^solution-(?:[1-9][0-9]*|[0-9a-f]{20}-[0-9]{3})$'), + note_id BIGINT NOT NULL, + source_run_id UUID NOT NULL, + assistant_message_id UUID NOT NULL, + assistant_message_role VARCHAR(10) NOT NULL DEFAULT 'ASSISTANT' + CONSTRAINT ck_verified_solutions_message_role CHECK (assistant_message_role = 'ASSISTANT'), + schema_version VARCHAR(40) NOT NULL, + solution_version INTEGER NOT NULL CONSTRAINT ck_verified_solutions_version CHECK (solution_version >= 1), + problem_statement TEXT NOT NULL, + problem_conditions JSONB NOT NULL DEFAULT '[]'::JSONB + CONSTRAINT ck_verified_solutions_conditions CHECK (jsonb_typeof(problem_conditions) = 'array'), + solution_text TEXT NOT NULL, + -- 모든 claim은 accepted coverage(direct|supporting)만 가진다. inconclusive/reject는 저장 불가. + claims JSONB NOT NULL + CONSTRAINT ck_verified_solutions_claims CHECK ( + jsonb_typeof(claims) = 'array' AND jsonb_array_length(claims) >= 1 + AND NOT jsonb_path_exists(claims, '$[*] ? (!(@.coverage == "direct" || @.coverage == "supporting"))') + ), + step_marks JSONB NOT NULL DEFAULT '[]'::JSONB + CONSTRAINT ck_verified_solutions_step_marks CHECK (jsonb_typeof(step_marks) = 'array'), + verification_summary JSONB NOT NULL + CONSTRAINT ck_verified_solutions_summary CHECK (jsonb_typeof(verification_summary) = 'object'), + verification_version VARCHAR(64) NOT NULL, + source_ref_ids JSONB NOT NULL DEFAULT '[]'::JSONB + CONSTRAINT ck_verified_solutions_source_refs CHECK (jsonb_typeof(source_ref_ids) = 'array'), + overall_coverage VARCHAR(20) NOT NULL + CONSTRAINT ck_verified_solutions_overall CHECK (overall_coverage IN ('direct', 'supporting')), + content_sha256 VARCHAR(64) NOT NULL + CONSTRAINT ck_verified_solutions_sha256 CHECK (content_sha256 ~ '^[0-9a-f]{64}$'), + revises_solution_id UUID, + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' + CONSTRAINT ck_verified_solutions_status CHECK (status IN ('ACTIVE', 'SUPERSEDED')), + superseded_by_solution_id UUID, + superseded_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT uq_verified_solutions_note_solution UNIQUE (note_id, solution_id), + CONSTRAINT uq_verified_solutions_run_public UNIQUE (source_run_id, public_solution_id), + CONSTRAINT fk_verified_solutions_run FOREIGN KEY (note_id, source_run_id) + REFERENCES ai_runs (note_id, run_id) ON DELETE CASCADE, + CONSTRAINT fk_verified_solutions_message FOREIGN KEY (note_id, assistant_message_id, assistant_message_role) + REFERENCES chat_messages (note_id, message_id, role) ON DELETE CASCADE, + -- 정정 대상과 후속은 같은 Note 안에 있다. + CONSTRAINT fk_verified_solutions_revises FOREIGN KEY (note_id, revises_solution_id) + REFERENCES verified_solutions (note_id, solution_id), + CONSTRAINT fk_verified_solutions_superseded_by FOREIGN KEY (note_id, superseded_by_solution_id) + REFERENCES verified_solutions (note_id, solution_id), + CONSTRAINT ck_verified_solutions_not_self CHECK ( + revises_solution_id IS DISTINCT FROM solution_id AND superseded_by_solution_id IS DISTINCT FROM solution_id + ), + CONSTRAINT ck_verified_solutions_overall_matches CHECK ( + overall_coverage = CASE + WHEN jsonb_path_exists(claims, '$[*] ? (@.coverage == "supporting")') THEN 'supporting' + ELSE 'direct' + END + ), + CONSTRAINT ck_verified_solutions_superseded CHECK ( + (status = 'SUPERSEDED') = (superseded_by_solution_id IS NOT NULL AND superseded_at IS NOT NULL) + ) +); + +-- 하나의 solution은 최대 한 번 정정된다 (선형 revision chain). +CREATE UNIQUE INDEX ux_verified_solutions_single_revision + ON verified_solutions (revises_solution_id) + WHERE revises_solution_id IS NOT NULL; +CREATE INDEX idx_verified_solutions_note_created ON verified_solutions (note_id, created_at); +CREATE INDEX idx_verified_solutions_message ON verified_solutions (assistant_message_id); + +-- content는 불변이며 ACTIVE -> SUPERSEDED 기록만 허용한다. 후속은 이 row를 정정한 row여야 한다. +CREATE FUNCTION verified_solutions_guard_update() RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + IF (to_jsonb(NEW) - 'status' - 'superseded_by_solution_id' - 'superseded_at') + <> (to_jsonb(OLD) - 'status' - 'superseded_by_solution_id' - 'superseded_at') THEN + RAISE EXCEPTION 'verified solution % content is immutable', OLD.solution_id + USING ERRCODE = 'integrity_constraint_violation'; + END IF; + IF OLD.status <> 'ACTIVE' OR NEW.status <> 'SUPERSEDED' THEN + RAISE EXCEPTION 'verified solution % can only move ACTIVE -> SUPERSEDED once', OLD.solution_id + USING ERRCODE = 'integrity_constraint_violation'; + END IF; + IF NOT EXISTS ( + SELECT 1 FROM verified_solutions s + WHERE s.solution_id = NEW.superseded_by_solution_id AND s.revises_solution_id = OLD.solution_id + ) THEN + RAISE EXCEPTION 'superseded_by must be the solution that revises %', OLD.solution_id + USING ERRCODE = 'integrity_constraint_violation'; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER trg_verified_solutions_guard_update + BEFORE UPDATE ON verified_solutions + FOR EACH ROW EXECUTE FUNCTION verified_solutions_guard_update(); + +-- ===================================================================== +-- 요청별 usage ledger (request당 최초 차감 1회 + 최대 1회 보상) +-- ===================================================================== + +CREATE TABLE request_usage_charges ( + request_id VARCHAR(128) PRIMARY KEY + CONSTRAINT ck_request_usage_charges_request_id CHECK (request_id ~ '^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$'), + user_id BIGINT NOT NULL REFERENCES users (user_id) ON DELETE CASCADE, + -- Note 삭제로 run이 사라져도 차감 이력·request 멱등 키는 남는다. + run_id UUID CONSTRAINT uq_request_usage_charges_run UNIQUE + REFERENCES ai_runs (run_id) ON DELETE SET NULL, + usage_units INTEGER NOT NULL CONSTRAINT ck_request_usage_charges_units CHECK (usage_units > 0), + credit_amount INTEGER NOT NULL CONSTRAINT ck_request_usage_charges_amount CHECK (credit_amount > 0), + charged_daily INTEGER NOT NULL DEFAULT 0 CONSTRAINT ck_request_usage_charges_daily CHECK (charged_daily >= 0), + charged_free INTEGER NOT NULL DEFAULT 0 CONSTRAINT ck_request_usage_charges_free CHECK (charged_free >= 0), + charged_paid INTEGER NOT NULL DEFAULT 0 CONSTRAINT ck_request_usage_charges_paid CHECK (charged_paid >= 0), + charged_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT uq_request_usage_charges_amount UNIQUE (request_id, credit_amount), + CONSTRAINT ck_request_usage_charges_breakdown CHECK (charged_daily + charged_free + charged_paid = credit_amount) +); + +CREATE INDEX idx_request_usage_charges_user ON request_usage_charges (user_id, charged_at DESC); + +CREATE TABLE request_usage_compensations ( + -- PK = request_id: 취소·clarification·실패 보상 경합에서도 request당 최대 1회 + request_id VARCHAR(128) PRIMARY KEY, + credit_amount INTEGER NOT NULL, + reason VARCHAR(40) NOT NULL + CONSTRAINT ck_request_usage_compensations_reason CHECK (reason IN ( + 'PENDING_CANCELED', 'CLARIFICATION_ONLY', 'UNSUPPORTED_ONLY', + 'VERIFICATION_FAILED', 'BUDGET_EXCEEDED', 'INVALID_ATTACHMENT', 'INFRASTRUCTURE_FAILURE')), + restored_daily INTEGER NOT NULL DEFAULT 0 CONSTRAINT ck_request_usage_compensations_daily CHECK (restored_daily >= 0), + restored_free INTEGER NOT NULL DEFAULT 0 CONSTRAINT ck_request_usage_compensations_free CHECK (restored_free >= 0), + restored_paid INTEGER NOT NULL DEFAULT 0 CONSTRAINT ck_request_usage_compensations_paid CHECK (restored_paid >= 0), + compensated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + -- 차감이 있어야만 보상할 수 있고, 보상량은 차감량과 같다. + CONSTRAINT fk_request_usage_compensations_charge FOREIGN KEY (request_id, credit_amount) + REFERENCES request_usage_charges (request_id, credit_amount) ON DELETE CASCADE, + CONSTRAINT ck_request_usage_compensations_breakdown CHECK (restored_daily + restored_free + restored_paid = credit_amount) +); + +-- ledger는 수정하지 않는다. 단, run 삭제에 따른 run_id SET NULL만 허용한다. +CREATE FUNCTION request_usage_charges_guard_update() RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + IF NEW.run_id IS NULL AND (to_jsonb(NEW) - 'run_id') = (to_jsonb(OLD) - 'run_id') THEN + RETURN NEW; + END IF; + RAISE EXCEPTION 'request_usage_charges is immutable: UPDATE is not allowed' + USING ERRCODE = 'integrity_constraint_violation'; +END; +$$; + +CREATE TRIGGER trg_request_usage_charges_guard_update + BEFORE UPDATE ON request_usage_charges + FOR EACH ROW EXECUTE FUNCTION request_usage_charges_guard_update(); + +CREATE TRIGGER trg_request_usage_compensations_immutable + BEFORE UPDATE ON request_usage_compensations + FOR EACH ROW EXECUTE FUNCTION proovy_reject_update(); diff --git a/src/main/resources/db/migration/V20__fix_credit_balance_identity_default.sql b/src/main/resources/db/migration/V20__fix_credit_balance_identity_default.sql deleted file mode 100644 index e68e254f..00000000 --- a/src/main/resources/db/migration/V20__fix_credit_balance_identity_default.sql +++ /dev/null @@ -1,46 +0,0 @@ --- V20: PostgreSQL 18(IDENTITY) 환경에서 credit_balance.id DEFAULT 설정 분기 처리 --- 목적: --- 1) id가 IDENTITY면 DEFAULT 설정하면 안 됨 --- 2) id가 IDENTITY가 아니고 시퀀스가 있으면 DEFAULT nextval 설정 - -DO $$ -DECLARE -v_is_identity BOOLEAN := FALSE; -BEGIN - -- 테이블/컬럼 존재 확인 (id가 이미 있는 상태에서만) - IF to_regclass('public.credit_balance') IS NOT NULL - AND EXISTS ( - SELECT 1 - FROM information_schema.columns c - WHERE c.table_schema = 'public' - AND c.table_name = 'credit_balance' - AND c.column_name = 'id' - ) - THEN - -- 시퀀스 이름 정리(있을 때만) - IF to_regclass('public.credit_balance_id_seq') IS NULL - AND to_regclass('public.credit_balance_balance_id_seq') IS NOT NULL - THEN -ALTER SEQUENCE public.credit_balance_balance_id_seq RENAME TO credit_balance_id_seq; -END IF; - - -- IDENTITY 컬럼 여부 확인 -SELECT EXISTS ( - SELECT 1 - FROM information_schema.columns c - WHERE c.table_schema = 'public' - AND c.table_name = 'credit_balance' - AND c.column_name = 'id' - AND c.is_identity = 'YES' -) -INTO v_is_identity; - --- IDENTITY가 아닐 때만 DEFAULT 설정 -IF NOT v_is_identity - AND to_regclass('public.credit_balance_id_seq') IS NOT NULL - THEN -ALTER TABLE public.credit_balance - ALTER COLUMN id SET DEFAULT nextval('public.credit_balance_id_seq'); -END IF; -END IF; -END $$; diff --git a/src/main/resources/db/migration/V21__create_users.sql b/src/main/resources/db/migration/V21__create_users.sql deleted file mode 100644 index 2b9e1979..00000000 --- a/src/main/resources/db/migration/V21__create_users.sql +++ /dev/null @@ -1,14 +0,0 @@ -CREATE TABLE IF NOT EXISTS users ( - user_id BIGSERIAL PRIMARY KEY, - name VARCHAR(50), - nickname VARCHAR(30) NOT NULL, - department VARCHAR(100), - referral_source VARCHAR(20), - provider VARCHAR(20), - provider_user_id VARCHAR(100), - email VARCHAR(255), - created_at TIMESTAMP, - updated_at TIMESTAMP -); - -CREATE INDEX IF NOT EXISTS idx_user_provider ON users(provider, provider_user_id); diff --git a/src/main/resources/db/migration/V22__enforce_single_active_user_plan.sql b/src/main/resources/db/migration/V22__enforce_single_active_user_plan.sql deleted file mode 100644 index 75de20fa..00000000 --- a/src/main/resources/db/migration/V22__enforce_single_active_user_plan.sql +++ /dev/null @@ -1,53 +0,0 @@ --- 사용자별 활성 플랜은 최대 1개만 허용한다. --- 1) 기존 데이터 중복 정리 --- 2) 부분 유니크 인덱스로 정합성 강제 - -UPDATE user_plans -SET is_active = false -WHERE is_active IS NULL; - -DO $$ -DECLARE - batch_size INT := 5000; - updated_count INT; -BEGIN - LOOP - WITH duplicate_active AS ( - SELECT user_plan_id - FROM ( - SELECT - user_plan_id, - ROW_NUMBER() OVER ( - PARTITION BY user_id - ORDER BY - CASE WHEN started_at IS NULL THEN 1 ELSE 0 END, - started_at DESC, - user_plan_id DESC - ) AS rn - FROM user_plans - WHERE is_active = true - ) ranked - WHERE rn > 1 - LIMIT batch_size - ) - UPDATE user_plans up - SET is_active = false - WHERE up.user_plan_id IN (SELECT user_plan_id FROM duplicate_active); - - GET DIAGNOSTICS updated_count = ROW_COUNT; - EXIT WHEN updated_count = 0; - - -- 대량 데이터에서 장시간 락 점유를 줄이기 위해 짧게 양보 - PERFORM pg_sleep(0.05); - END LOOP; -END $$; - -ALTER TABLE user_plans - ALTER COLUMN is_active SET NOT NULL; - -ALTER TABLE user_plans - ALTER COLUMN is_active SET DEFAULT true; - -CREATE UNIQUE INDEX IF NOT EXISTS ux_user_plans_active_user - ON user_plans (user_id) - WHERE is_active = true; diff --git a/src/main/resources/db/migration/V23__enforce_unique_oauth_identity.sql b/src/main/resources/db/migration/V23__enforce_unique_oauth_identity.sql deleted file mode 100644 index 60f885d7..00000000 --- a/src/main/resources/db/migration/V23__enforce_unique_oauth_identity.sql +++ /dev/null @@ -1,7 +0,0 @@ --- provider/provider_user_id must uniquely identify an OAuth user. --- Replace non-unique index with a unique index for non-null OAuth identities. -DROP INDEX IF EXISTS idx_user_provider; - -CREATE UNIQUE INDEX IF NOT EXISTS idx_user_provider - ON users(provider, provider_user_id) - WHERE provider IS NOT NULL AND provider_user_id IS NOT NULL; diff --git a/src/main/resources/db/migration/V24__add_canceled_at_to_user_plans.sql b/src/main/resources/db/migration/V24__add_canceled_at_to_user_plans.sql deleted file mode 100644 index 4f3f68dd..00000000 --- a/src/main/resources/db/migration/V24__add_canceled_at_to_user_plans.sql +++ /dev/null @@ -1,4 +0,0 @@ --- 구독 취소 시각 저장 컬럼 추가 -ALTER TABLE user_plans -ADD COLUMN IF NOT EXISTS canceled_at TIMESTAMP; - diff --git a/src/main/resources/db/migration/V25__add_next_plan_type_to_user_plans.sql b/src/main/resources/db/migration/V25__add_next_plan_type_to_user_plans.sql deleted file mode 100644 index ead00976..00000000 --- a/src/main/resources/db/migration/V25__add_next_plan_type_to_user_plans.sql +++ /dev/null @@ -1,14 +0,0 @@ --- 예약 다운그레이드(만료 시 플랜 전환) 타깃 플랜 저장 컬럼 추가 -ALTER TABLE user_plans -ADD COLUMN IF NOT EXISTS next_plan_type VARCHAR(10); - --- 기존 취소 데이터는 기본적으로 FREE 전환으로 간주 -UPDATE user_plans -SET next_plan_type = 'FREE' -WHERE canceled_at IS NOT NULL - AND next_plan_type IS NULL; - --- 만료된 예약 전환 대상 조회 최적화 -CREATE INDEX IF NOT EXISTS idx_user_plans_due_transition - ON user_plans (expired_at) - WHERE is_active = true AND canceled_at IS NOT NULL; diff --git a/src/main/resources/db/migration/V26__optimize_due_transition_index.sql b/src/main/resources/db/migration/V26__optimize_due_transition_index.sql deleted file mode 100644 index 1da8a2b1..00000000 --- a/src/main/resources/db/migration/V26__optimize_due_transition_index.sql +++ /dev/null @@ -1,6 +0,0 @@ --- 만료 전환 배치 조회 최적화: ORDER BY(expired_at, user_plan_id)와 동일한 키 순서 사용 -DROP INDEX IF EXISTS idx_user_plans_due_transition; - -CREATE INDEX IF NOT EXISTS idx_user_plans_due_transition - ON user_plans (expired_at, user_plan_id) - WHERE is_active = true AND canceled_at IS NOT NULL; diff --git a/src/main/resources/db/migration/V27__add_last_monthly_grant_plan_id_to_credit_balance.sql b/src/main/resources/db/migration/V27__add_last_monthly_grant_plan_id_to_credit_balance.sql deleted file mode 100644 index 2ee53c3d..00000000 --- a/src/main/resources/db/migration/V27__add_last_monthly_grant_plan_id_to_credit_balance.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE credit_balance - ADD COLUMN IF NOT EXISTS last_monthly_grant_plan_id BIGINT; diff --git a/src/main/resources/db/migration/V28__migrate_to_chat_messages.sql b/src/main/resources/db/migration/V28__migrate_to_chat_messages.sql deleted file mode 100644 index b043d9d8..00000000 --- a/src/main/resources/db/migration/V28__migrate_to_chat_messages.sql +++ /dev/null @@ -1,72 +0,0 @@ --- ========================================================== --- V28: Conversation/Message 테이블을 ChatMessage로 통합 --- ========================================================== - --- 1. chat_messages 테이블에 note_id 컬럼 추가 (노트와 직접 연결) -ALTER TABLE chat_messages -ADD COLUMN IF NOT EXISTS note_id BIGINT; - --- note_id FK 제약조건 추가 -ALTER TABLE chat_messages -ADD CONSTRAINT fk_chat_messages_note -FOREIGN KEY (note_id) REFERENCES notes(note_id) ON DELETE CASCADE; - --- note_id 인덱스 추가 -CREATE INDEX IF NOT EXISTS idx_chat_messages_note_id -ON chat_messages(note_id); - -CREATE INDEX IF NOT EXISTS idx_chat_messages_note_id_created_at -ON chat_messages(note_id, created_at); - --- 2. chat_messages 테이블에 status 컬럼 추가 -ALTER TABLE chat_messages -ADD COLUMN IF NOT EXISTS status VARCHAR(20) DEFAULT 'completed'; - --- 3. message_assets 테이블 변경 (message_id -> chat_message_id) --- 기존 FK 제약조건 삭제 -ALTER TABLE message_assets -DROP CONSTRAINT IF EXISTS fk_message_assets_message; - --- 컬럼명 변경 -ALTER TABLE message_assets -RENAME COLUMN message_id TO chat_message_id; - --- 새 FK 제약조건 추가 (chat_messages 참조) -ALTER TABLE message_assets -ADD CONSTRAINT fk_message_assets_chat_message -FOREIGN KEY (chat_message_id) REFERENCES chat_messages(chat_message_id) ON DELETE CASCADE; - --- 4. message_tools 테이블 변경 (message_id -> chat_message_id) --- 기존 FK 제약조건 삭제 -ALTER TABLE message_tools -DROP CONSTRAINT IF EXISTS fk_message_tools_message; - --- 컬럼명 변경 -ALTER TABLE message_tools -RENAME COLUMN message_id TO chat_message_id; - --- 새 FK 제약조건 추가 (chat_messages 참조) -ALTER TABLE message_tools -ADD CONSTRAINT fk_message_tools_chat_message -FOREIGN KEY (chat_message_id) REFERENCES chat_messages(chat_message_id) ON DELETE CASCADE; - --- 5. Full-Text Search를 위한 tsvector 컬럼 추가 (content JSONB에서 텍스트 추출) -ALTER TABLE chat_messages -ADD COLUMN IF NOT EXISTS content_tsv tsvector; - --- tsvector 인덱스 생성 -CREATE INDEX IF NOT EXISTS idx_chat_messages_content_tsv -ON chat_messages USING GIN(content_tsv); - --- pg_trgm 확장 활성화 (한글 검색용) -CREATE EXTENSION IF NOT EXISTS pg_trgm; - --- 6. 기존 테이블 삭제 (데이터 이관이 완료된 경우에만 실행) --- 주의: 기존 데이터가 있는 경우 먼저 데이터를 chat_messages로 이관해야 합니다. --- 아래는 참조 관계로 인해 messages -> conversations 순서로 삭제해야 합니다. - --- messages 테이블 삭제 -DROP TABLE IF EXISTS messages CASCADE; - --- conversations 테이블 삭제 -DROP TABLE IF EXISTS conversations CASCADE; diff --git a/src/main/resources/db/migration/V29__add_message_id_to_message_assets.sql b/src/main/resources/db/migration/V29__add_message_id_to_message_assets.sql deleted file mode 100644 index adc1d3af..00000000 --- a/src/main/resources/db/migration/V29__add_message_id_to_message_assets.sql +++ /dev/null @@ -1,123 +0,0 @@ --- ========================================================== --- V29: message_assets / message_tools FK 컬럼 정합성 복구 --- 문제: --- - V28에서 message_id -> chat_message_id로 변경되었지만 JPA 엔티티는 message_id를 사용 중 --- - 기존 V29는 chat_messages.id를 참조해 마이그레이션 실패 --- 해결: --- - 두 테이블을 message_id 기준으로 복구 --- - FK는 chat_messages(chat_message_id)로 연결 --- ========================================================== - --- 1) message_assets 정리 -ALTER TABLE message_assets -DROP CONSTRAINT IF EXISTS fk_message_assets_chat_message; - -ALTER TABLE message_assets -DROP CONSTRAINT IF EXISTS fk_message_assets_message; - -DO $$ -DECLARE - has_chat_message_id BOOLEAN; - has_message_id BOOLEAN; -BEGIN - SELECT EXISTS ( - SELECT 1 - FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = 'message_assets' - AND column_name = 'chat_message_id' - ) INTO has_chat_message_id; - - SELECT EXISTS ( - SELECT 1 - FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = 'message_assets' - AND column_name = 'message_id' - ) INTO has_message_id; - - IF has_chat_message_id AND has_message_id THEN - EXECUTE 'UPDATE message_assets SET message_id = chat_message_id WHERE message_id IS NULL'; - EXECUTE 'ALTER TABLE message_assets DROP COLUMN chat_message_id'; - ELSIF has_chat_message_id THEN - EXECUTE 'ALTER TABLE message_assets RENAME COLUMN chat_message_id TO message_id'; - ELSIF NOT has_message_id THEN - EXECUTE 'ALTER TABLE message_assets ADD COLUMN message_id BIGINT'; - END IF; -END $$; - -DO $$ -BEGIN - IF EXISTS ( - SELECT 1 - FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = 'message_assets' - AND column_name = 'message_id' - ) AND NOT EXISTS ( - SELECT 1 FROM message_assets WHERE message_id IS NULL - ) THEN - ALTER TABLE message_assets ALTER COLUMN message_id SET NOT NULL; - END IF; -END $$; - -ALTER TABLE message_assets -ADD CONSTRAINT fk_message_assets_message -FOREIGN KEY (message_id) REFERENCES chat_messages(chat_message_id) ON DELETE CASCADE; - --- 2) message_tools 정리 (엔티티와 스키마 불일치 동시 해결) -ALTER TABLE message_tools -DROP CONSTRAINT IF EXISTS fk_message_tools_chat_message; - -ALTER TABLE message_tools -DROP CONSTRAINT IF EXISTS fk_message_tools_message; - -DO $$ -DECLARE - has_chat_message_id BOOLEAN; - has_message_id BOOLEAN; -BEGIN - SELECT EXISTS ( - SELECT 1 - FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = 'message_tools' - AND column_name = 'chat_message_id' - ) INTO has_chat_message_id; - - SELECT EXISTS ( - SELECT 1 - FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = 'message_tools' - AND column_name = 'message_id' - ) INTO has_message_id; - - IF has_chat_message_id AND has_message_id THEN - EXECUTE 'UPDATE message_tools SET message_id = chat_message_id WHERE message_id IS NULL'; - EXECUTE 'ALTER TABLE message_tools DROP COLUMN chat_message_id'; - ELSIF has_chat_message_id THEN - EXECUTE 'ALTER TABLE message_tools RENAME COLUMN chat_message_id TO message_id'; - ELSIF NOT has_message_id THEN - EXECUTE 'ALTER TABLE message_tools ADD COLUMN message_id BIGINT'; - END IF; -END $$; - -DO $$ -BEGIN - IF EXISTS ( - SELECT 1 - FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = 'message_tools' - AND column_name = 'message_id' - ) AND NOT EXISTS ( - SELECT 1 FROM message_tools WHERE message_id IS NULL - ) THEN - ALTER TABLE message_tools ALTER COLUMN message_id SET NOT NULL; - END IF; -END $$; - -ALTER TABLE message_tools -ADD CONSTRAINT fk_message_tools_message -FOREIGN KEY (message_id) REFERENCES chat_messages(chat_message_id) ON DELETE CASCADE; diff --git a/src/main/resources/db/migration/V2__backfill_credit_balance.sql b/src/main/resources/db/migration/V2__backfill_credit_balance.sql deleted file mode 100644 index 5110d20c..00000000 --- a/src/main/resources/db/migration/V2__backfill_credit_balance.sql +++ /dev/null @@ -1,25 +0,0 @@ -INSERT INTO credit_balance ( - user_id, - daily_free_credit, - daily_free_limit, - daily_expires_at, - free_credit, - paid_credit, - created_at, - updated_at -) -SELECT - u.user_id, - 100, - 100, - date_trunc('day', now()) + interval '1 day', - 0, - 0, - now(), - now() -FROM users u -WHERE NOT EXISTS ( - SELECT 1 - FROM credit_balance cb - WHERE cb.user_id = u.user_id -); diff --git a/src/main/resources/db/migration/V30__fix_chat_messages_fulltext_search.sql b/src/main/resources/db/migration/V30__fix_chat_messages_fulltext_search.sql deleted file mode 100644 index f9050831..00000000 --- a/src/main/resources/db/migration/V30__fix_chat_messages_fulltext_search.sql +++ /dev/null @@ -1,35 +0,0 @@ --- ========================================================== --- V30: chat_messages Full-Text Search 수정 --- 문제: content_tsv 트리거 누락, 인덱스 미활용 --- ========================================================== - --- 1. content_tsv 자동 업데이트 트리거 함수 생성 --- JSONB content에서 'text' 필드를 추출하여 tsvector 생성 -CREATE OR REPLACE FUNCTION chat_messages_content_tsv_update() -RETURNS TRIGGER AS $$ -BEGIN - -- content JSONB에서 'text' 필드 추출 후 tsvector 생성 - -- 'simple' 설정: 언어 의존성 없이 토큰화 (한글 호환) - NEW.content_tsv := to_tsvector('simple', COALESCE(NEW.content->>'text', '')); - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - --- 2. 트리거 생성 (INSERT/UPDATE 시 자동 업데이트) -DROP TRIGGER IF EXISTS trg_chat_messages_content_tsv ON chat_messages; -CREATE TRIGGER trg_chat_messages_content_tsv - BEFORE INSERT OR UPDATE OF content ON chat_messages - FOR EACH ROW - EXECUTE FUNCTION chat_messages_content_tsv_update(); - --- 3. pg_trgm 인덱스 추가 (한글 ILIKE 검색 최적화) --- content->>'text'에 대한 표현식 인덱스 -CREATE INDEX IF NOT EXISTS idx_chat_messages_content_text_trgm -ON chat_messages USING GIN((content->>'text') gin_trgm_ops); - --- 4. note_id가 있는 메시지에 대한 content_tsv GIN 인덱스 (검색 시 note_id IS NOT NULL 조건 최적화) -CREATE INDEX IF NOT EXISTS idx_chat_messages_note_id_content_tsv -ON chat_messages USING GIN(content_tsv) WHERE note_id IS NOT NULL; - --- NOTE: 기존 데이터 백필은 ChatMessageTsvBackfillRunner에서 autocommit 모드로 별도 실행됨 --- Flyway 트랜잭션 내에서 배치 처리 시 SKIP LOCKED가 무의미하므로 분리함 diff --git a/src/main/resources/db/migration/V31__drop_duplicate_chat_message_id_columns.sql b/src/main/resources/db/migration/V31__drop_duplicate_chat_message_id_columns.sql deleted file mode 100644 index 06b69b29..00000000 --- a/src/main/resources/db/migration/V31__drop_duplicate_chat_message_id_columns.sql +++ /dev/null @@ -1,77 +0,0 @@ --- ========================================================== --- V31: message_assets / message_tools 중복 chat_message_id 컬럼 제거 --- 문제: --- - V29에서 chat_message_id → message_id로 정리해야 했으나 --- 두 컬럼이 동시에 남아있는 환경이 존재 --- - JPA 엔티티는 message_id를 사용하지만, chat_message_id에 --- NOT NULL 제약이 걸려있어 INSERT 시 에러 발생 --- 해결: --- - chat_message_id 컬럼이 아직 남아있으면 데이터를 message_id로 --- 복사한 뒤 drop --- ========================================================== - --- 1) message_assets 정리 -DO $$ -BEGIN - IF EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = 'message_assets' - AND column_name = 'chat_message_id' - ) THEN - -- message_id가 null인 행에 chat_message_id 값 복사 - UPDATE message_assets - SET message_id = chat_message_id - WHERE message_id IS NULL AND chat_message_id IS NOT NULL; - - -- FK 제약 제거 (있으면) - ALTER TABLE message_assets - DROP CONSTRAINT IF EXISTS fk_message_assets_chat_message; - - -- 중복 컬럼 제거 - ALTER TABLE message_assets DROP COLUMN chat_message_id; - - RAISE NOTICE 'message_assets: chat_message_id 컬럼 제거 완료'; - END IF; -END $$; - --- 2) message_tools 정리 -DO $$ -BEGIN - IF EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = 'message_tools' - AND column_name = 'chat_message_id' - ) THEN - UPDATE message_tools - SET message_id = chat_message_id - WHERE message_id IS NULL AND chat_message_id IS NOT NULL; - - ALTER TABLE message_tools - DROP CONSTRAINT IF EXISTS fk_message_tools_chat_message; - - ALTER TABLE message_tools DROP COLUMN chat_message_id; - - RAISE NOTICE 'message_tools: chat_message_id 컬럼 제거 완료'; - END IF; -END $$; - --- 3) message_id NOT NULL 보장 -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM message_assets WHERE message_id IS NULL - ) THEN - EXECUTE 'ALTER TABLE message_assets ALTER COLUMN message_id SET NOT NULL'; - END IF; -END $$; - -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM message_tools WHERE message_id IS NULL - ) THEN - EXECUTE 'ALTER TABLE message_tools ALTER COLUMN message_id SET NOT NULL'; - END IF; -END $$; diff --git a/src/main/resources/db/migration/V32__rename_s3_columns_to_object_keys.sql b/src/main/resources/db/migration/V32__rename_s3_columns_to_object_keys.sql deleted file mode 100644 index 01f328f4..00000000 --- a/src/main/resources/db/migration/V32__rename_s3_columns_to_object_keys.sql +++ /dev/null @@ -1,3 +0,0 @@ --- V32: Rename S3 column names to generic object key names for GCS compatibility -ALTER TABLE assets RENAME COLUMN s3_key TO object_key; -ALTER TABLE assets RENAME COLUMN thumbnail_s3_key TO thumbnail_object_key; \ No newline at end of file diff --git a/src/main/resources/db/migration/V3__create_credit_history.sql b/src/main/resources/db/migration/V3__create_credit_history.sql deleted file mode 100644 index 1b4f4b75..00000000 --- a/src/main/resources/db/migration/V3__create_credit_history.sql +++ /dev/null @@ -1,36 +0,0 @@ --- 구버전 credit_history(change_type 컬럼 없는)가 있으면 DROP 후 재생성 -DO $$ -BEGIN - IF to_regclass('public.credit_history') IS NOT NULL - AND NOT EXISTS ( - SELECT 1 - FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = 'credit_history' - AND column_name = 'change_type' - ) - THEN - DROP TABLE public.credit_history CASCADE; - END IF; -END $$; - -CREATE TABLE IF NOT EXISTS credit_history ( - history_id BIGSERIAL PRIMARY KEY, - user_id BIGINT NOT NULL, - event_type VARCHAR(30) NOT NULL, - event_name VARCHAR(100) NOT NULL, - description VARCHAR(500), - amount INTEGER NOT NULL, - change_type VARCHAR(10) NOT NULL, - credit_type VARCHAR(10) NOT NULL, - balance_after_daily INTEGER NOT NULL, - balance_after_free INTEGER NOT NULL, - balance_after_paid INTEGER NOT NULL, - created_at TIMESTAMP NULL, - CONSTRAINT fk_credit_history_user - FOREIGN KEY (user_id) REFERENCES users(user_id) -); - -CREATE INDEX IF NOT EXISTS idx_credit_history_user_created ON credit_history(user_id, created_at); -CREATE INDEX IF NOT EXISTS idx_credit_history_change_type ON credit_history(change_type); -CREATE INDEX IF NOT EXISTS idx_credit_history_credit_type ON credit_history(credit_type); diff --git a/src/main/resources/db/migration/V4__create_chat_sessions.sql b/src/main/resources/db/migration/V4__create_chat_sessions.sql deleted file mode 100644 index 58f483c9..00000000 --- a/src/main/resources/db/migration/V4__create_chat_sessions.sql +++ /dev/null @@ -1,10 +0,0 @@ -CREATE TABLE IF NOT EXISTS chat_sessions ( - chat_session_id BIGSERIAL PRIMARY KEY, - user_id BIGINT NOT NULL, - external_thread_id VARCHAR(100) UNIQUE, - status VARCHAR(10), - created_at TIMESTAMP NULL, - closed_at TIMESTAMP NULL, - CONSTRAINT fk_chat_sessions_user - FOREIGN KEY (user_id) REFERENCES users(user_id) -); diff --git a/src/main/resources/db/migration/V5__create_chat_messages.sql b/src/main/resources/db/migration/V5__create_chat_messages.sql deleted file mode 100644 index 646664fb..00000000 --- a/src/main/resources/db/migration/V5__create_chat_messages.sql +++ /dev/null @@ -1,10 +0,0 @@ -CREATE TABLE IF NOT EXISTS chat_messages ( - chat_message_id BIGSERIAL PRIMARY KEY, - chat_session_id BIGINT NOT NULL, - role VARCHAR(10) NOT NULL, - content JSONB, - message_type VARCHAR(50) NOT NULL, - created_at TIMESTAMP NULL, - CONSTRAINT fk_chat_messages_session - FOREIGN KEY (chat_session_id) REFERENCES chat_sessions(chat_session_id) -); diff --git a/src/main/resources/db/migration/V6__create_message_attachments.sql b/src/main/resources/db/migration/V6__create_message_attachments.sql deleted file mode 100644 index 95c6a997..00000000 --- a/src/main/resources/db/migration/V6__create_message_attachments.sql +++ /dev/null @@ -1,12 +0,0 @@ -CREATE TABLE IF NOT EXISTS message_attachments ( - message_attachment_id BIGSERIAL PRIMARY KEY, - chat_message_id BIGINT NOT NULL, - file_name VARCHAR(255), - mime_type VARCHAR(100), - storage_key VARCHAR(500), - source VARCHAR(20) NOT NULL, - metadata JSONB, - created_at TIMESTAMP NULL, - CONSTRAINT fk_message_attachments_message - FOREIGN KEY (chat_message_id) REFERENCES chat_messages(chat_message_id) -); diff --git a/src/main/resources/db/migration/V7__fix_credit_history_pk.sql b/src/main/resources/db/migration/V7__fix_credit_history_pk.sql deleted file mode 100644 index e6a0554c..00000000 --- a/src/main/resources/db/migration/V7__fix_credit_history_pk.sql +++ /dev/null @@ -1,42 +0,0 @@ --- credit_history PK/컬럼 불일치 시 재생성 -DO $$ -BEGIN - IF to_regclass('public.credit_history') IS NOT NULL - AND (NOT EXISTS ( - SELECT 1 - FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = 'credit_history' - AND column_name = 'change_type' - ) OR NOT EXISTS ( - SELECT 1 - FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = 'credit_history' - AND column_name = 'history_id' - )) - THEN - DROP TABLE public.credit_history CASCADE; - END IF; -END $$; - -CREATE TABLE IF NOT EXISTS credit_history ( - history_id BIGSERIAL PRIMARY KEY, - user_id BIGINT NOT NULL, - event_type VARCHAR(30) NOT NULL, - event_name VARCHAR(100) NOT NULL, - description VARCHAR(500), - amount INTEGER NOT NULL, - change_type VARCHAR(10) NOT NULL, - credit_type VARCHAR(10) NOT NULL, - balance_after_daily INTEGER NOT NULL, - balance_after_free INTEGER NOT NULL, - balance_after_paid INTEGER NOT NULL, - created_at TIMESTAMP NULL, - CONSTRAINT fk_credit_history_user - FOREIGN KEY (user_id) REFERENCES users(user_id) -); - -CREATE INDEX IF NOT EXISTS idx_credit_history_user_created ON credit_history(user_id, created_at); -CREATE INDEX IF NOT EXISTS idx_credit_history_change_type ON credit_history(change_type); -CREATE INDEX IF NOT EXISTS idx_credit_history_credit_type ON credit_history(credit_type); diff --git a/src/main/resources/db/migration/V8__rename_credit_balance_pk.sql b/src/main/resources/db/migration/V8__rename_credit_balance_pk.sql deleted file mode 100644 index 43846b56..00000000 --- a/src/main/resources/db/migration/V8__rename_credit_balance_pk.sql +++ /dev/null @@ -1,34 +0,0 @@ --- credit_balance PK 컬럼 불일치 시 id로 정리 -DO $$ -BEGIN - IF to_regclass('public.credit_balance') IS NOT NULL - AND EXISTS ( - SELECT 1 - FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = 'credit_balance' - AND column_name = 'balance_id' - ) - AND NOT EXISTS ( - SELECT 1 - FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = 'credit_balance' - AND column_name = 'id' - ) - THEN - ALTER TABLE public.credit_balance RENAME COLUMN balance_id TO id; - - -- 시퀀스 이름 정리(있을 때만) - IF to_regclass('public.credit_balance_id_seq') IS NULL - AND to_regclass('public.credit_balance_balance_id_seq') IS NOT NULL - THEN - ALTER SEQUENCE public.credit_balance_balance_id_seq RENAME TO credit_balance_id_seq; - END IF; - - -- 기본 시퀀스 지정(있을 때만) - IF to_regclass('public.credit_balance_id_seq') IS NOT NULL THEN - ALTER TABLE public.credit_balance ALTER COLUMN id SET DEFAULT nextval('public.credit_balance_id_seq'); - END IF; - END IF; -END $$; diff --git a/src/main/resources/db/migration/V9__create_assets.sql b/src/main/resources/db/migration/V9__create_assets.sql deleted file mode 100644 index c156a670..00000000 --- a/src/main/resources/db/migration/V9__create_assets.sql +++ /dev/null @@ -1,23 +0,0 @@ -CREATE TABLE IF NOT EXISTS assets ( - id BIGSERIAL PRIMARY KEY, - user_id BIGINT NOT NULL, - note_id BIGINT NOT NULL, - file_name VARCHAR(255) NOT NULL, - file_size BIGINT NOT NULL, - mime_type VARCHAR(100) NOT NULL, - s3_key VARCHAR(500) NOT NULL, - thumbnail_s3_key VARCHAR(500), - source VARCHAR(20) NOT NULL, - status VARCHAR(20) NOT NULL, - upload_expires_at TIMESTAMP, - total_pages INTEGER, - ocr_status VARCHAR(20), - ocr_text TEXT, - ocr_processed_at TIMESTAMP, - created_at TIMESTAMP NOT NULL, - updated_at TIMESTAMP NOT NULL, - version BIGINT -); - -CREATE INDEX IF NOT EXISTS idx_assets_user_id ON assets(user_id); -CREATE INDEX IF NOT EXISTS idx_assets_note_id ON assets(note_id); diff --git a/src/main/resources/db/provisioning/role-boundary.sql b/src/main/resources/db/provisioning/role-boundary.sql new file mode 100644 index 00000000..cd4d87bc --- /dev/null +++ b/src/main/resources/db/provisioning/role-boundary.sql @@ -0,0 +1,54 @@ +-- ===================================================================== +-- Server ↔ Brain DB role 경계 (docs/plan/07: 하나의 DB에서 public 제품 schema와 +-- brain_runtime·brain_retrieval schema를 role로 격리) +-- +-- Flyway가 실행하지 않는다. 운영자가 DB 관리자 권한으로 role 생성·Server Flyway +-- 최초 실행 전후 한 번 적용한다(재실행해도 같은 결과). 실제 운영 DB 적용은 S14 +-- 인수 작업에서 별도 승인 후 수행한다. +-- +-- 전제 role +-- proovy_server : Server 런타임 + Flyway 실행 role, public 제품 테이블 소유자 +-- brain_migration : Brain schema 소유/migration role (proovy-brain 0001 참고) +-- brain_runtime : Brain 런타임 role +-- 결과 +-- - brain_* role은 public schema 자체를 사용할 수 없어 제품 테이블을 읽거나 쓰지 못한다. +-- - proovy_server는 brain_runtime·brain_retrieval schema를 사용할 수 없다. +-- ===================================================================== + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'proovy_server') THEN + RAISE EXCEPTION 'role proovy_server must exist before applying the role boundary'; + END IF; +END $$; + +-- 제품 schema: PUBLIC 기본 USAGE를 걷어내고 Server role에만 부여한다. +REVOKE ALL ON SCHEMA public FROM PUBLIC; +GRANT USAGE, CREATE ON SCHEMA public TO proovy_server; +REVOKE ALL ON ALL TABLES IN SCHEMA public FROM PUBLIC; +REVOKE ALL ON ALL SEQUENCES IN SCHEMA public FROM PUBLIC; +ALTER DEFAULT PRIVILEGES FOR ROLE proovy_server IN SCHEMA public REVOKE ALL ON TABLES FROM PUBLIC; +ALTER DEFAULT PRIVILEGES FOR ROLE proovy_server IN SCHEMA public REVOKE ALL ON SEQUENCES FROM PUBLIC; + +DO $$ +DECLARE + brain_role TEXT; + brain_schema TEXT; +BEGIN + FOREACH brain_role IN ARRAY ARRAY['brain_migration', 'brain_runtime'] LOOP + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = brain_role) THEN + EXECUTE format('REVOKE ALL ON SCHEMA public FROM %I', brain_role); + EXECUTE format('REVOKE ALL ON ALL TABLES IN SCHEMA public FROM %I', brain_role); + EXECUTE format('REVOKE ALL ON ALL SEQUENCES IN SCHEMA public FROM %I', brain_role); + END IF; + END LOOP; + + -- Brain schema: Server role의 접근을 제거한다 (PUBLIC 권한은 Brain 0001 migration이 이미 제거). + FOREACH brain_schema IN ARRAY ARRAY['brain_runtime', 'brain_retrieval'] LOOP + IF EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = brain_schema) THEN + EXECUTE format('REVOKE ALL ON SCHEMA %I FROM PUBLIC, proovy_server', brain_schema); + EXECUTE format('REVOKE ALL ON ALL TABLES IN SCHEMA %I FROM PUBLIC, proovy_server', brain_schema); + EXECUTE format('REVOKE ALL ON ALL SEQUENCES IN SCHEMA %I FROM PUBLIC, proovy_server', brain_schema); + END IF; + END LOOP; +END $$; From e7d9009f6194f5a4e1bbfcaf2f9a27e6dfc58244 Mon Sep 17 00:00:00 2001 From: chowon442 Date: Sat, 17 Oct 2026 10:43:55 +0900 Subject: [PATCH 02/34] =?UTF-8?q?test:=20=EA=B2=A9=EB=A6=AC=20Postgres?= =?UTF-8?q?=EC=97=90=EC=84=9C=20baseline=C2=B7=EC=A0=9C=EC=95=BD=C2=B7role?= =?UTF-8?q?=20=EA=B2=BD=EA=B3=84=C2=B7context=20=EA=B2=80=EC=A6=9D=20(#213?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 테스트마다 새 database를 만들어 Flyway baseline·재현 seed·Spring context(validate) 검증 - unique/partial unique(동시 접수 경합 포함)·FK/소유 경계·cascade·rollback·불변성·인덱스 사용 검사 - proovy_server ↔ brain_* role 접근 거부 검사 - CI는 DB 테스트 미구성 시 실패(PROOVY_REQUIRE_DB_TESTS) Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 6 + .../ProductBaselineMigrationPostgresTest.java | 77 ++ .../db/ProductConstraintsPostgresTest.java | 699 ++++++++++++++++++ .../db/ProductSchemaContextPostgresTest.java | 236 ++++++ .../proovy/db/RoleBoundaryPostgresTest.java | 114 +++ .../support/db/PostgresTestDatabase.java | 153 ++++ .../com/proovy/support/db/ProductRows.java | 113 +++ src/test/resources/application-test.yaml | 3 +- src/test/resources/db/seed/product-seed.sql | 34 + 9 files changed, 1434 insertions(+), 1 deletion(-) create mode 100644 src/test/java/com/proovy/db/ProductBaselineMigrationPostgresTest.java create mode 100644 src/test/java/com/proovy/db/ProductConstraintsPostgresTest.java create mode 100644 src/test/java/com/proovy/db/ProductSchemaContextPostgresTest.java create mode 100644 src/test/java/com/proovy/db/RoleBoundaryPostgresTest.java create mode 100644 src/test/java/com/proovy/support/db/PostgresTestDatabase.java create mode 100644 src/test/java/com/proovy/support/db/ProductRows.java create mode 100644 src/test/resources/db/seed/product-seed.sql diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d037922c..01fcb9be 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,6 +64,12 @@ 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" - name: Upload test report if: always() diff --git a/src/test/java/com/proovy/db/ProductBaselineMigrationPostgresTest.java b/src/test/java/com/proovy/db/ProductBaselineMigrationPostgresTest.java new file mode 100644 index 00000000..5b0f1ae8 --- /dev/null +++ b/src/test/java/com/proovy/db/ProductBaselineMigrationPostgresTest.java @@ -0,0 +1,77 @@ +package com.proovy.db; + +import com.proovy.support.db.PostgresTestDatabase; +import org.flywaydb.core.Flyway; +import org.flywaydb.core.api.FlywayException; +import org.flywaydb.core.api.MigrationInfo; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.sql.Connection; +import java.sql.Statement; + +import static com.proovy.support.db.ProductRows.count; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * 빈 DB에서 baseline·seed만으로 제품 schema가 재현되고, 과거 schema가 있는 DB에는 + * baseline하지 않고 실패하는지 검사한다. + */ +@Tag("postgres") +class ProductBaselineMigrationPostgresTest { + + @Test + void emptyDatabaseMigratesToBaselineAndSeedIsReproducible() throws Exception { + try (PostgresTestDatabase db = PostgresTestDatabase.createEmpty()) { + Flyway flyway = db.flyway(PostgresTestDatabase.adminUser(), PostgresTestDatabase.adminPassword()); + assertThat(flyway.migrate().migrationsExecuted).isGreaterThanOrEqualTo(1); + + MigrationInfo[] applied = flyway.info().applied(); + assertThat(applied[0].getVersion().getVersion()).isEqualTo("1"); + assertThat(applied[0].getDescription()).isEqualTo("product baseline"); + // 재실행은 no-op + assertThat(flyway.migrate().migrationsExecuted).isZero(); + + db.runScript("db/seed/product-seed.sql"); + try (Connection c = db.connect()) { + assertThat(count(c, "SELECT count(*) FROM users")).isEqualTo(2); + assertThat(count(c, "SELECT count(*) FROM notes")).isEqualTo(3); + assertThat(count(c, "SELECT count(*) FROM chat_messages")).isZero(); + assertThat(count(c, "SELECT count(*) FROM ai_runs")).isZero(); + } + } + } + + @Test + void databaseWithLegacySchemaIsNotBaselined() throws Exception { + try (PostgresTestDatabase db = PostgresTestDatabase.createEmpty()) { + try (Connection c = db.connect(); Statement st = c.createStatement()) { + st.execute("CREATE TABLE chat_sessions (chat_session_id BIGSERIAL PRIMARY KEY)"); + } + Flyway flyway = db.flyway(PostgresTestDatabase.adminUser(), PostgresTestDatabase.adminPassword()); + assertThatThrownBy(flyway::migrate) + .isInstanceOf(FlywayException.class) + .hasMessageContaining("non-empty schema"); + } + } + + @Test + void databaseWithLegacyMigrationHistoryFailsValidation() throws Exception { + try (PostgresTestDatabase db = PostgresTestDatabase.createEmpty()) { + Flyway flyway = db.flyway(PostgresTestDatabase.adminUser(), PostgresTestDatabase.adminPassword()); + flyway.migrate(); + // 과거 서비스 DB history를 흉내 낸다: 같은 버전 1이 다른 스크립트(create credit balance)로 기록돼 있다 + try (Connection c = db.connect(); Statement st = c.createStatement()) { + st.execute(""" + UPDATE flyway_schema_history + SET description = 'create credit balance', script = 'V1__create_credit_balance.sql', checksum = 12345 + WHERE version = '1' + """); + } + assertThatThrownBy(flyway::migrate) + .isInstanceOf(FlywayException.class) + .hasMessageContaining("checksum mismatch"); + } + } +} diff --git a/src/test/java/com/proovy/db/ProductConstraintsPostgresTest.java b/src/test/java/com/proovy/db/ProductConstraintsPostgresTest.java new file mode 100644 index 00000000..9320c62d --- /dev/null +++ b/src/test/java/com/proovy/db/ProductConstraintsPostgresTest.java @@ -0,0 +1,699 @@ +package com.proovy.db; + +import com.proovy.support.db.PostgresTestDatabase; +import com.proovy.support.db.ProductRows.Turn; +import org.junit.jupiter.api.*; + +import java.sql.*; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.*; + +import static com.proovy.support.db.ProductRows.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; + +/** + * 실제 Postgres에서 S07 저장 구조의 DB 제약을 검사한다 (task 1.74). + * + *

각 테스트는 autocommit을 끈 연결에서 실행하고 끝에 rollback한다. 위반 기대 구문은 + * savepoint로 감싸 같은 transaction에서 이어 검사한다. 동시성·rollback 테스트만 별도 연결로 commit한다. + */ +@Tag("postgres") +class ProductConstraintsPostgresTest { + + private static final String UNIQUE_VIOLATION = "23505"; + private static final String FK_VIOLATION = "23503"; + private static final String CHECK_VIOLATION = "23514"; + private static final String TRIGGER_VIOLATION = "23000"; + + private static PostgresTestDatabase db; + private Connection c; + + @BeforeAll + static void createDatabase() { + db = PostgresTestDatabase.createMigrated(); + db.runScript("db/seed/product-seed.sql"); + } + + @AfterAll + static void dropDatabase() { + if (db != null) { + db.close(); + } + } + + @BeforeEach + void open() throws SQLException { + c = db.connect(); + c.setAutoCommit(false); + } + + @AfterEach + void rollback() throws SQLException { + c.rollback(); + c.close(); + } + + // ------------------------------------------------------------------ + // baseline 형태 + // ------------------------------------------------------------------ + + @Test + void baselineHasNoChatSessionOrThreadId() throws SQLException { + assertThat(count(c, "SELECT count(*) FROM information_schema.tables WHERE table_schema='public' AND table_name IN ('chat_sessions','message_assets','message_attachments','message_tools','note_embeddings')")) + .isZero(); + assertThat(count(c, "SELECT count(*) FROM information_schema.columns WHERE table_schema='public' AND column_name IN ('chat_session_id','thread_id','external_thread_id')")) + .isZero(); + // 메시지는 Note에 직접 연결된다 + assertThat(count(c, """ + SELECT count(*) FROM information_schema.table_constraints tc + JOIN information_schema.constraint_column_usage ccu ON tc.constraint_name = ccu.constraint_name + WHERE tc.table_name = 'chat_messages' AND tc.constraint_type = 'FOREIGN KEY' AND ccu.table_name = 'notes' + """)).isEqualTo(1); + } + + // ------------------------------------------------------------------ + // request unique · Active Run partial unique + // ------------------------------------------------------------------ + + @Test + void requestIdIsUnique() throws SQLException { + insertRunWithTurn(c, "req-unique-1", ALICE, ALICE_NOTE); + expectViolation(UNIQUE_VIOLATION, "uq_ai_runs_request", + () -> insertRunWithTurn(c, "req-unique-1", ALICE, ALICE_SECOND_NOTE)); + } + + @Test + void oneActiveRunPerNoteIncludingCanceling() throws SQLException { + UUID first = insertRunWithTurn(c, "req-active-1", ALICE, ALICE_NOTE); + expectViolation(UNIQUE_VIOLATION, "ux_ai_runs_note_active", + () -> insertRunWithTurn(c, "req-active-2", ALICE, ALICE_NOTE)); + + // 다른 Note는 독립적이다 + insertRunWithTurn(c, "req-active-other-note", ALICE, ALICE_SECOND_NOTE); + + // CANCELING은 cleanup 확인 전까지 자리를 유지한다 + claim(c, first); + update(c, """ + UPDATE ai_runs SET status = 'CANCELING', cancel_cause = 'user_requested', + cancel_requested_from = 'RUNNING', cancel_requested_at = now() + WHERE run_id = ? + """, first); + expectViolation(UNIQUE_VIOLATION, "ux_ai_runs_note_active", + () -> insertRunWithTurn(c, "req-active-3", ALICE, ALICE_NOTE)); + + update(c, """ + UPDATE ai_runs SET status = 'CANCELED', completed_at = now(), lease_owner = NULL, lease_expires_at = NULL + WHERE run_id = ? + """, first); + insertRunWithTurn(c, "req-active-4", ALICE, ALICE_NOTE); + } + + @Test + void concurrentAdmissionOnSameNoteLeavesOneRun() throws Exception { + long noteId; + try (Connection setup = db.connect()) { + noteId = insertNote(setup, ALICE, "race note"); + } + int contenders = 6; + ExecutorService pool = Executors.newFixedThreadPool(contenders); + CyclicBarrier barrier = new CyclicBarrier(contenders); + List> results = new ArrayList<>(); + for (int i = 0; i < contenders; i++) { + String requestId = "req-race-" + i; + results.add(pool.submit(() -> { + try (Connection tx = db.connect()) { + tx.setAutoCommit(false); + Turn turn = insertTurn(tx, noteId); + barrier.await(10, TimeUnit.SECONDS); + try { + insertPendingRun(tx, requestId, ALICE, noteId, turn); + tx.commit(); + return "committed"; + } catch (SQLException e) { + tx.rollback(); + return e.getSQLState(); + } + } + })); + } + List outcomes = new ArrayList<>(); + for (Future f : results) { + outcomes.add(f.get(30, TimeUnit.SECONDS)); + } + pool.shutdown(); + + assertThat(outcomes).filteredOn("committed"::equals).hasSize(1); + assertThat(outcomes).filteredOn(UNIQUE_VIOLATION::equals).hasSize(contenders - 1); + try (Connection check = db.connect()) { + assertThat(count(check, "SELECT count(*) FROM ai_runs WHERE note_id = ?", noteId)).isEqualTo(1); + // 거부된 요청은 메시지를 남기지 않는다 (같은 transaction rollback) + assertThat(count(check, "SELECT count(*) FROM chat_messages WHERE note_id = ?", noteId)).isEqualTo(2); + update(check, "DELETE FROM notes WHERE note_id = ?", noteId); + } + } + + // ------------------------------------------------------------------ + // 접수 transaction rollback + // ------------------------------------------------------------------ + + @Test + void failedAdmissionRollsBackNoteMessagesRunAndCharge() throws SQLException { + try (Connection tx = db.connect()) { + tx.setAutoCommit(false); + long noteId = insertNote(tx, ALICE, "auto note"); + Turn turn = insertTurn(tx, noteId); + UUID runId = insertPendingRun(tx, "req-rollback-1", ALICE, noteId, turn); + insertCharge(tx, "req-rollback-1", ALICE, runId, 1, 0, 0); + try { + // 같은 transaction의 후속 저장(잘못된 차감 분배)이 실패하면 접수 전체가 rollback된다 + insertChargeWithAmount(tx, "req-rollback-2", ALICE, 5, 1, 0, 0); + fail("expected a check violation"); + } catch (SQLException expected) { + assertThat(expected.getSQLState()).isEqualTo(CHECK_VIOLATION); + tx.rollback(); + } + } + try (Connection check = db.connect()) { + assertThat(count(check, "SELECT count(*) FROM notes WHERE title = 'auto note'")).isZero(); + assertThat(count(check, "SELECT count(*) FROM ai_runs WHERE request_id LIKE 'req-rollback-%'")).isZero(); + assertThat(count(check, "SELECT count(*) FROM request_usage_charges WHERE request_id LIKE 'req-rollback-%'")).isZero(); + } + } + + // ------------------------------------------------------------------ + // 소유/FK 경계 + // ------------------------------------------------------------------ + + @Test + void runMustBelongToNoteOwnerAndMessagesOfSameNote() throws SQLException { + Turn bobTurn = insertTurn(c, BOB_NOTE); + // alice가 bob의 Note에 run을 만들 수 없다 + expectViolation(FK_VIOLATION, "fk_ai_runs_note_owner", + () -> insertPendingRun(c, "req-owner-1", ALICE, BOB_NOTE, bobTurn)); + // 다른 Note의 메시지를 run에 연결할 수 없다 + expectViolation(FK_VIOLATION, "fk_ai_runs_user_message", + () -> insertPendingRun(c, "req-owner-2", ALICE, ALICE_NOTE, bobTurn)); + // user/assistant 역할을 뒤바꿀 수 없다 + Turn aliceTurn = insertTurn(c, ALICE_NOTE); + expectViolation(FK_VIOLATION, "fk_ai_runs_user_message", () -> insertPendingRun(c, "req-owner-3", ALICE, ALICE_NOTE, + new Turn(aliceTurn.assistantMessageId(), aliceTurn.userMessageId()))); + // asset 소유자는 Note 소유자와 같아야 한다 + expectViolation(FK_VIOLATION, "fk_assets_note_owner", () -> update(c, """ + INSERT INTO assets (user_id, note_id, file_name, file_size, mime_type, object_key, source, status, created_at, updated_at) + VALUES (?, ?, 'x.pdf', 1, 'application/pdf', 'k', 'upload', 'PENDING', now(), now()) + """, ALICE, BOB_NOTE)); + } + + @Test + void assetDependenciesStayInsideTheRunNoteAndAreFixedAtCreation() throws SQLException { + UUID runId = insertRunWithTurn(c, "req-dep-1", ALICE, ALICE_NOTE); + insertDependency(c, runId, ALICE_NOTE, ALICE_PDF, "VIEWER_FOCUS"); + insertDependency(c, runId, ALICE_NOTE, ALICE_PDF, "CORPUS_SNAPSHOT"); + + // 다른 Note의 asset 의존은 거부된다 (deferred FK: commit 또는 IMMEDIATE 전환 시점에 검사) + expectViolation(FK_VIOLATION, "fk_run_asset_dependencies_asset", () -> { + insertDependency(c, runId, ALICE_NOTE, BOB_PDF, "CORPUS_SNAPSHOT"); + update(c, "SET CONSTRAINTS fk_run_asset_dependencies_asset IMMEDIATE"); + }); + expectViolation(TRIGGER_VIOLATION, "immutable", + () -> update(c, "UPDATE run_asset_dependencies SET content_version = 2 WHERE run_id = ?", runId)); + + claim(c, runId); + expectViolation(TRIGGER_VIOLATION, "fixed at run creation", + () -> insertDependency(c, runId, ALICE_NOTE, 3003L, "CORPUS_SNAPSHOT")); + // 의존 중인 asset은 hard delete할 수 없다 (DELETING 경로 강제). deferred FK라 commit 시점에 검사된다. + expectViolation(FK_VIOLATION, "fk_run_asset_dependencies_asset", () -> { + update(c, "DELETE FROM assets WHERE id = ?", ALICE_PDF); + update(c, "SET CONSTRAINTS fk_run_asset_dependencies_asset IMMEDIATE"); + }); + } + + @Test + void assetDeletionStateRequiresOperationAndCleanup() throws SQLException { + expectViolation(CHECK_VIOLATION, "ck_assets_deletion", + () -> update(c, "UPDATE assets SET status = 'DELETING' WHERE id = ?", ALICE_PDF)); + update(c, """ + UPDATE assets SET status = 'DELETING', deletion_operation_id = gen_random_uuid(), deletion_requested_at = now() + WHERE id = ? + """, ALICE_PDF); + expectViolation(CHECK_VIOLATION, "ck_assets_deleted_cleanup", + () -> update(c, "UPDATE assets SET status = 'DELETED' WHERE id = ?", ALICE_PDF)); + update(c, """ + UPDATE assets SET status = 'DELETED', brain_cleanup_acked_at = now(), storage_cleaned_at = now() + WHERE id = ? + """, ALICE_PDF); + } + + // ------------------------------------------------------------------ + // Note 삭제 cascade · source snapshot 분리 + // ------------------------------------------------------------------ + + @Test + void noteDeletionCascadesRunDataOnlyForThatNoteAndKeepsLedger() throws SQLException { + UUID aliceRun = insertRunWithTurn(c, "req-cascade-1", ALICE, ALICE_NOTE); + insertDependency(c, aliceRun, ALICE_NOTE, ALICE_PDF, "VIEWER_FOCUS"); + insertServerEvent(c, aliceRun, ALICE_NOTE, 1, "run.created"); + insertCharge(c, "req-cascade-1", ALICE, aliceRun, 1, 0, 0); + UUID bobRun = insertRunWithTurn(c, "req-cascade-2", BOB, BOB_NOTE); + insertServerEvent(c, bobRun, BOB_NOTE, 1, "run.created"); + + update(c, "DELETE FROM notes WHERE note_id = ?", ALICE_NOTE); + + assertThat(count(c, "SELECT count(*) FROM ai_runs WHERE note_id = ?", ALICE_NOTE)).isZero(); + assertThat(count(c, "SELECT count(*) FROM ai_run_events WHERE note_id = ?", ALICE_NOTE)).isZero(); + assertThat(count(c, "SELECT count(*) FROM chat_messages WHERE note_id = ?", ALICE_NOTE)).isZero(); + assertThat(count(c, "SELECT count(*) FROM assets WHERE note_id = ?", ALICE_NOTE)).isZero(); + assertThat(count(c, "SELECT count(*) FROM run_asset_dependencies WHERE run_id = ?", aliceRun)).isZero(); + // 다른 Note는 영향이 없다 + assertThat(count(c, "SELECT count(*) FROM ai_runs WHERE run_id = ?", bobRun)).isEqualTo(1); + assertThat(count(c, "SELECT count(*) FROM ai_run_events WHERE run_id = ?", bobRun)).isEqualTo(1); + // 차감 이력·request 멱등 키는 남고 run 참조만 끊긴다 + assertThat(count(c, "SELECT count(*) FROM request_usage_charges WHERE request_id = 'req-cascade-1' AND run_id IS NULL")) + .isEqualTo(1); + } + + @Test + void sourceReferenceSnapshotSurvivesAssetDeletion() throws SQLException { + Turn turn = insertTurn(c, ALICE_NOTE); + UUID runId = insertPendingRun(c, "req-source-1", ALICE, ALICE_NOTE, turn); + claim(c, runId); + String sources = "[{\"source_ref_id\":\"sref-1\",\"asset_id\":\"3003\",\"page_start\":1,\"page_end\":1,\"display_label_snapshot\":\"notes.png\"}]"; + completeAssistant(c, turn.assistantMessageId(), sources); + completeSubstantive(c, runId); + + update(c, "DELETE FROM assets WHERE id = 3003"); + + try (PreparedStatement ps = c.prepareStatement( + "SELECT source_references::text, content_sha256 FROM chat_messages WHERE message_id = ?")) { + ps.setObject(1, turn.assistantMessageId()); + try (ResultSet rs = ps.executeQuery()) { + assertThat(rs.next()).isTrue(); + assertThat(rs.getString(1)).contains("\"asset_id\": \"3003\"").contains("notes.png"); + assertThat(rs.getString(2)).isEqualTo("e".repeat(64)); + } + } + } + + // ------------------------------------------------------------------ + // 메시지 구조·불변성 + // ------------------------------------------------------------------ + + @Test + void assistantPlaceholderHasNoBodyAndCompletedMessageIsImmutable() throws SQLException { + Turn turn = insertTurn(c, ALICE_NOTE); + // STREAMING placeholder에 Candidate 본문을 저장할 수 없다 + expectViolation(CHECK_VIOLATION, "ck_chat_messages_assistant_placeholder", () -> update(c, + "UPDATE chat_messages SET status = 'STREAMING', plain_text = 'candidate' WHERE message_id = ?", + turn.assistantMessageId())); + // 본문 없는 COMPLETED는 거부된다 + expectViolation(CHECK_VIOLATION, "ck_chat_messages_assistant_completed", () -> update(c, + "UPDATE chat_messages SET status = 'COMPLETED', completed_at = now() WHERE message_id = ?", + turn.assistantMessageId())); + + completeAssistant(c, turn.assistantMessageId(), "[]"); + expectViolation(TRIGGER_VIOLATION, "immutable", () -> update(c, + "UPDATE chat_messages SET plain_text = 'edited' WHERE message_id = ?", turn.assistantMessageId())); + expectViolation(TRIGGER_VIOLATION, "immutable", () -> update(c, + "UPDATE chat_messages SET user_input = '{\"text\":\"edited\"}' WHERE message_id = ?", turn.userMessageId())); + } + + // ------------------------------------------------------------------ + // run 불변 snapshot · 상태 전이 · fence + // ------------------------------------------------------------------ + + @Test + void runSnapshotsAreImmutableAndTransitionsAreFenced() throws SQLException { + UUID runId = insertRunWithTurn(c, "req-run-1", ALICE, ALICE_NOTE); + + expectViolation(TRIGGER_VIOLATION, "immutable", () -> update(c, + "UPDATE ai_runs SET request_snapshot = '{\"input\":{\"text\":\"changed\"}}' WHERE run_id = ?", runId)); + expectViolation(TRIGGER_VIOLATION, "immutable", () -> update(c, + "UPDATE ai_runs SET request_deadline_at = request_deadline_at + interval '1 hour' WHERE run_id = ?", runId)); + // PENDING에서 바로 COMPLETED는 허용하지 않는다 + expectViolation(TRIGGER_VIOLATION, "invalid run status transition", () -> update(c, + "UPDATE ai_runs SET status = 'COMPLETED', response_disposition = 'substantive', completed_at = now() WHERE run_id = ?", runId)); + + claim(c, runId); + update(c, "UPDATE ai_runs SET used_llm_calls = 3, last_event_seq = 4 WHERE run_id = ?", runId); + expectViolation(TRIGGER_VIOLATION, "monotonic", + () -> update(c, "UPDATE ai_runs SET used_llm_calls = 1 WHERE run_id = ?", runId)); + expectViolation(TRIGGER_VIOLATION, "monotonic", + () -> update(c, "UPDATE ai_runs SET attempt = 0, fence_token = NULL, started_at = NULL WHERE run_id = ?", runId)); + // 같은 attempt에서 fence를 바꿀 수 없다 + expectViolation(TRIGGER_VIOLATION, "new attempt", + () -> update(c, "UPDATE ai_runs SET fence_token = 'forged' WHERE run_id = ?", runId)); + + // 이전 fence 조건부 update는 0 row + claim(c, runId); + assertThat(update(c, """ + UPDATE ai_runs SET status = 'COMPLETED', response_disposition = 'substantive', completed_at = now(), + lease_owner = NULL, lease_expires_at = NULL + WHERE run_id = ? AND attempt = 1 AND fence_token = 'fence-1' + """, runId)).isZero(); + + // terminal은 lease·grant를 남길 수 없다 + expectViolation(CHECK_VIOLATION, "ck_ai_runs_terminal_released", () -> update(c, + "UPDATE ai_runs SET status = 'COMPLETED', response_disposition = 'substantive', completed_at = now() WHERE run_id = ?", runId)); + completeSubstantive(c, runId); + + // terminal 뒤에는 runtime cleanup 예약만 바꿀 수 있다 + update(c, "UPDATE ai_runs SET runtime_cleanup_at = now() + interval '1 day' WHERE run_id = ?", runId); + expectViolation(TRIGGER_VIOLATION, "terminal run", () -> update(c, + "UPDATE ai_runs SET used_llm_calls = 99 WHERE run_id = ?", runId)); + } + + @Test + void terminalClassificationConstraints() throws SQLException { + UUID runId = insertRunWithTurn(c, "req-class-1", ALICE, ALICE_NOTE); + claim(c, runId); + String release = ", completed_at = now(), lease_owner = NULL, lease_expires_at = NULL "; + expectViolation(CHECK_VIOLATION, "ck_ai_runs_clarification_reason_pair", () -> update(c, + "UPDATE ai_runs SET status = 'COMPLETED', response_disposition = 'clarification_only'" + release + "WHERE run_id = ?", runId)); + expectViolation(CHECK_VIOLATION, "ck_ai_runs_clarification_reason_pair", () -> update(c, + "UPDATE ai_runs SET status = 'COMPLETED', response_disposition = 'unsupported_only', clarification_reason = 'missing_input'" + release + "WHERE run_id = ?", runId)); + expectViolation(CHECK_VIOLATION, "ck_ai_runs_failure_code_class", () -> update(c, + "UPDATE ai_runs SET status = 'FAILED', failure_code = 'timeout', failure_class = 'verification'" + release + "WHERE run_id = ?", runId)); + // PENDING 취소가 아닌 CANCELING은 실행 상태에서만 시작한다 + UUID pending = insertRunWithTurn(c, "req-class-2", ALICE, ALICE_SECOND_NOTE); + expectViolation(TRIGGER_VIOLATION, "invalid run status transition", () -> update(c, """ + UPDATE ai_runs SET status = 'CANCELING', cancel_cause = 'user_requested', cancel_requested_from = 'PENDING', + cancel_requested_at = now() WHERE run_id = ?""", pending)); + update(c, """ + UPDATE ai_runs SET status = 'CANCELED', cancel_cause = 'user_requested', cancel_requested_from = 'PENDING', + cancel_requested_at = now(), completed_at = now() WHERE run_id = ?""", pending); + update(c, "UPDATE ai_runs SET status = 'COMPLETED', response_disposition = 'clarification_only', clarification_reason = 'corpus_not_ready'" + + release + "WHERE run_id = ?", runId); + } + + // ------------------------------------------------------------------ + // event seq · dedup · append-only + // ------------------------------------------------------------------ + + @Test + void eventSeqAndSourceEventAreUniquePerRun() throws SQLException { + UUID runId = insertRunWithTurn(c, "req-event-1", ALICE, ALICE_NOTE); + insertServerEvent(c, runId, ALICE_NOTE, 1, "run.created"); + expectViolation(UNIQUE_VIOLATION, "uq_ai_run_events_seq", + () -> insertServerEvent(c, runId, ALICE_NOTE, 1, "run.canceling")); + + assertThat(insertActivity(c, runId, 2, "src-evt-1")).isEqualTo(1); + // 같은 source event 재수신은 row도 seq도 늘리지 않는다 + assertThat(insertActivity(c, runId, 3, "src-evt-1")).isZero(); + assertThat(count(c, "SELECT count(*) FROM ai_run_events WHERE run_id = ?", runId)).isEqualTo(2); + assertThat(count(c, "SELECT max(seq) FROM ai_run_events WHERE run_id = ?", runId)).isEqualTo(2); + + // 다른 run은 같은 seq·source id를 쓸 수 있다 + UUID other = insertRunWithTurn(c, "req-event-2", ALICE, ALICE_SECOND_NOTE); + insertServerEvent(c, other, ALICE_SECOND_NOTE, 1, "run.created"); + + expectViolation(TRIGGER_VIOLATION, "immutable", + () -> update(c, "UPDATE ai_run_events SET payload = '{}' WHERE run_id = ?", runId)); + // Activity는 dedup 키·attempt가 필요하고 server 소유 event는 source id를 가질 수 없다 + expectViolation(CHECK_VIOLATION, "ck_ai_run_events_activity", () -> update(c, """ + INSERT INTO ai_run_events (run_id, note_id, seq, event_type, activity_id) VALUES (?, ?, 9, 'activity.updated', 'activity-1') + """, runId, ALICE_NOTE)); + expectViolation(CHECK_VIOLATION, "ck_ai_run_events_server_owned", () -> update(c, """ + INSERT INTO ai_run_events (run_id, note_id, seq, event_type, source_event_id, attempt) VALUES (?, ?, 9, 'run.created', 'src-x', 1) + """, runId, ALICE_NOTE)); + // event는 run과 같은 Note에 속한다 + expectViolation(FK_VIOLATION, "fk_ai_run_events_run", + () -> insertServerEvent(c, runId, BOB_NOTE, 10, "run.canceling")); + } + + // ------------------------------------------------------------------ + // usage ledger: 차감 1회 · 보상 최대 1회 + // ------------------------------------------------------------------ + + @Test + void chargeOncePerRequestAndCompensateAtMostOnce() throws SQLException { + UUID runId = insertRunWithTurn(c, "req-usage-1", ALICE, ALICE_NOTE); + insertCharge(c, "req-usage-1", ALICE, runId, 1, 0, 0); + expectViolation(UNIQUE_VIOLATION, "request_usage_charges_pkey", + () -> insertCharge(c, "req-usage-1", ALICE, null, 1, 0, 0)); + + // 차감 없는 보상·차감량과 다른 보상은 거부된다 + expectViolation(FK_VIOLATION, "fk_request_usage_compensations_charge", + () -> insertCompensation(c, "req-usage-unknown", "INFRASTRUCTURE_FAILURE", 1)); + expectViolation(FK_VIOLATION, "fk_request_usage_compensations_charge", + () -> insertCompensation(c, "req-usage-1", "INFRASTRUCTURE_FAILURE", 2)); + + insertCompensation(c, "req-usage-1", "PENDING_CANCELED", 1); + // 취소·clarification·인프라 실패 보상이 경합해도 하나만 남는다 + expectViolation(UNIQUE_VIOLATION, "request_usage_compensations_pkey", + () -> insertCompensation(c, "req-usage-1", "CLARIFICATION_ONLY", 1)); + assertThat(update(c, """ + INSERT INTO request_usage_compensations (request_id, credit_amount, reason, restored_daily) + VALUES ('req-usage-1', 1, 'INFRASTRUCTURE_FAILURE', 1) ON CONFLICT (request_id) DO NOTHING + """)).isZero(); + + expectViolation(TRIGGER_VIOLATION, "immutable", + () -> update(c, "UPDATE request_usage_charges SET charged_daily = 0, charged_free = 1 WHERE request_id = 'req-usage-1'")); + expectViolation(TRIGGER_VIOLATION, "immutable", + () -> update(c, "UPDATE request_usage_compensations SET reason = 'BUDGET_EXCEEDED' WHERE request_id = 'req-usage-1'")); + } + + @Test + void concurrentCompensationAppliesOnce() throws Exception { + String requestId = "req-usage-race"; + try (Connection setup = db.connect()) { + insertCharge(setup, requestId, BOB, null, 1, 0, 0); + } + String[] reasons = {"PENDING_CANCELED", "CLARIFICATION_ONLY", "INFRASTRUCTURE_FAILURE", "VERIFICATION_FAILED"}; + ExecutorService pool = Executors.newFixedThreadPool(reasons.length); + CyclicBarrier barrier = new CyclicBarrier(reasons.length); + List> results = new ArrayList<>(); + for (String reason : reasons) { + results.add(pool.submit(() -> { + try (Connection tx = db.connect()) { + barrier.await(10, TimeUnit.SECONDS); + return update(tx, """ + INSERT INTO request_usage_compensations (request_id, credit_amount, reason, restored_daily) + VALUES (?, 1, ?, 1) ON CONFLICT (request_id) DO NOTHING + """, requestId, reason); + } + })); + } + int applied = 0; + for (Future f : results) { + applied += f.get(30, TimeUnit.SECONDS); + } + pool.shutdown(); + assertThat(applied).isEqualTo(1); + try (Connection check = db.connect()) { + assertThat(count(check, "SELECT count(*) FROM request_usage_compensations WHERE request_id = ?", requestId)).isEqualTo(1); + update(check, "DELETE FROM request_usage_charges WHERE request_id = ?", requestId); + } + } + + // ------------------------------------------------------------------ + // Verified Solution: insert-only · accepted coverage · 선형 revision + // ------------------------------------------------------------------ + + @Test + void verifiedSolutionsAreInsertOnlyWithAcceptedCoverageAndLinearRevisions() throws SQLException { + Turn turn = insertTurn(c, ALICE_NOTE); + UUID runId = insertPendingRun(c, "req-solution-1", ALICE, ALICE_NOTE, turn); + String direct = "[{\"claim_id\":\"claim-1\",\"coverage\":\"direct\"}]"; + String supporting = "[{\"claim_id\":\"claim-1\",\"coverage\":\"direct\"},{\"claim_id\":\"claim-2\",\"coverage\":\"supporting\"}]"; + + UUID first = insertSolution(c, runId, turn.assistantMessageId(), "solution-1", direct, "direct", null); + expectViolation(CHECK_VIOLATION, "ck_verified_solutions_claims", () -> insertSolution(c, runId, + turn.assistantMessageId(), "solution-2", "[{\"claim_id\":\"claim-1\",\"coverage\":\"inconclusive\"}]", "direct", null)); + expectViolation(CHECK_VIOLATION, "ck_verified_solutions_claims", () -> insertSolution(c, runId, + turn.assistantMessageId(), "solution-2", "[]", "direct", null)); + expectViolation(CHECK_VIOLATION, "ck_verified_solutions_overall_matches", () -> insertSolution(c, runId, + turn.assistantMessageId(), "solution-2", supporting, "direct", null)); + + expectViolation(TRIGGER_VIOLATION, "immutable", () -> update(c, + "UPDATE verified_solutions SET solution_text = 'changed' WHERE solution_id = ?", first)); + + UUID second = insertSolution(c, runId, turn.assistantMessageId(), "solution-2", supporting, "supporting", first); + // 같은 solution을 두 번 정정할 수 없다 (분기·순환 방지) + expectViolation(UNIQUE_VIOLATION, "ux_verified_solutions_single_revision", () -> insertSolution(c, runId, + turn.assistantMessageId(), "solution-3", direct, "direct", first)); + // 후속이 아닌 row로 supersede할 수 없다 + expectViolation(TRIGGER_VIOLATION, "superseded_by", () -> update(c, + "UPDATE verified_solutions SET status = 'SUPERSEDED', superseded_by_solution_id = ?, superseded_at = now() WHERE solution_id = ?", + first, second)); + update(c, "UPDATE verified_solutions SET status = 'SUPERSEDED', superseded_by_solution_id = ?, superseded_at = now() WHERE solution_id = ?", + second, first); + expectViolation(TRIGGER_VIOLATION, "ACTIVE -> SUPERSEDED", () -> update(c, + "UPDATE verified_solutions SET status = 'ACTIVE', superseded_by_solution_id = NULL, superseded_at = NULL WHERE solution_id = ?", first)); + // 다른 Note의 solution을 정정할 수 없다 + Turn bobTurn = insertTurn(c, BOB_NOTE); + UUID bobRun = insertPendingRun(c, "req-solution-2", BOB, BOB_NOTE, bobTurn); + expectViolation(FK_VIOLATION, "fk_verified_solutions_revises", () -> insertSolution(c, bobRun, + bobTurn.assistantMessageId(), "solution-1", direct, "direct", second)); + } + + // ------------------------------------------------------------------ + // Turn Attachment media 규칙 + // ------------------------------------------------------------------ + + @Test + void turnAttachmentsBelongToUserMessagesWithValidatedMedia() throws SQLException { + Turn turn = insertTurn(c, ALICE_NOTE); + insertAttachment(c, turn.userMessageId(), 0, "application/pdf", 3, null, null, null); + insertAttachment(c, turn.userMessageId(), 1, "image/png", null, 20, 10, 200L); + expectViolation(CHECK_VIOLATION, "ck_turn_attachments_media", + () -> insertAttachment(c, turn.userMessageId(), 2, "image/png", null, 20, 10, 999L)); + expectViolation(CHECK_VIOLATION, "ck_turn_attachments_mime", + () -> insertAttachment(c, turn.userMessageId(), 2, "image/heic", null, 20, 10, 200L)); + expectViolation(UNIQUE_VIOLATION, "uq_turn_attachments_message_index", + () -> insertAttachment(c, turn.userMessageId(), 0, "application/pdf", 1, null, null, null)); + // assistant 메시지에는 첨부를 달 수 없다 + expectViolation(FK_VIOLATION, "fk_turn_attachments_source_message", + () -> insertAttachment(c, turn.assistantMessageId(), 0, "application/pdf", 1, null, null, null)); + } + + // ------------------------------------------------------------------ + // stale/deadline 조회 인덱스 + // ------------------------------------------------------------------ + + @Test + void staleLeaseDeadlineAndDispatchQueriesUsePartialIndexes() throws SQLException { + try (Statement st = c.createStatement()) { + st.execute("SET LOCAL enable_seqscan = off"); + } + assertThat(explain("SELECT run_id FROM ai_runs WHERE status IN ('RUNNING','RETRYING','CANCELING') AND lease_expires_at < now()")) + .contains("idx_ai_runs_stale_lease"); + assertThat(explain("SELECT run_id FROM ai_runs WHERE status IN ('PENDING','RUNNING','RETRYING','CANCELING') AND request_deadline_at < now()")) + .contains("idx_ai_runs_deadline"); + assertThat(explain("SELECT run_id FROM ai_runs WHERE status = 'PENDING' ORDER BY created_at LIMIT 10")) + .contains("idx_ai_runs_pending_dispatch"); + assertThat(explain("SELECT run_id FROM ai_runs WHERE note_id = 2001 AND status IN ('PENDING','RUNNING','RETRYING','CANCELING')")) + .contains("ux_ai_runs_note_active"); + assertThat(explain("SELECT seq FROM ai_run_events WHERE run_id = '00000000-0000-0000-0000-000000000000' AND seq > 3 ORDER BY seq")) + .contains("uq_ai_run_events_seq"); + } + + // ------------------------------------------------------------------ + // helpers + // ------------------------------------------------------------------ + + @FunctionalInterface + private interface SqlAction { + void run() throws SQLException; + } + + private void expectViolation(String sqlState, String messageFragment, SqlAction action) throws SQLException { + Savepoint sp = c.setSavepoint(); + try { + action.run(); + fail("expected SQLSTATE " + sqlState + " (" + messageFragment + ")"); + } catch (SQLException e) { + assertThat(e.getSQLState()).as(e.getMessage()).isEqualTo(sqlState); + assertThat(e.getMessage()).contains(messageFragment); + } finally { + c.rollback(sp); + } + } + + private String explain(String sql) throws SQLException { + StringBuilder plan = new StringBuilder(); + try (Statement st = c.createStatement(); ResultSet rs = st.executeQuery("EXPLAIN " + sql)) { + while (rs.next()) { + plan.append(rs.getString(1)).append('\n'); + } + } + return plan.toString(); + } + + private static long insertNote(Connection conn, long userId, String title) throws SQLException { + try (PreparedStatement ps = conn.prepareStatement( + "INSERT INTO notes (user_id, title, created_at, updated_at) VALUES (?, ?, now(), now()) RETURNING note_id")) { + ps.setLong(1, userId); + ps.setString(2, title); + try (ResultSet rs = ps.executeQuery()) { + rs.next(); + return rs.getLong(1); + } + } + } + + private static void completeAssistant(Connection conn, UUID messageId, String sourceReferences) throws SQLException { + update(conn, """ + UPDATE chat_messages SET status = 'COMPLETED', completed_at = now(), + response_pages = '[{"response_page_id":"page-1","presentation":"main","result_blocks":[]}]', + source_references = ?::jsonb, plain_text = 'answer', content_sha256 = ? + WHERE message_id = ? + """, sourceReferences, "e".repeat(64), messageId); + } + + private static void insertDependency(Connection conn, UUID runId, long noteId, long assetId, String kind) + throws SQLException { + update(conn, """ + INSERT INTO run_asset_dependencies (run_id, note_id, asset_id, dependency_kind, content_version) + VALUES (?, ?, ?, ?, 1) + """, runId, noteId, assetId, kind); + } + + private static void insertServerEvent(Connection conn, UUID runId, long noteId, long seq, String type) + throws SQLException { + update(conn, "INSERT INTO ai_run_events (run_id, note_id, seq, event_type) VALUES (?, ?, ?, ?)", + runId, noteId, seq, type); + } + + private static int insertActivity(Connection conn, UUID runId, long seq, String sourceEventId) throws SQLException { + return update(conn, """ + INSERT INTO ai_run_events (run_id, note_id, seq, event_type, source_event_id, attempt, activity_id, payload) + SELECT ?, note_id, ?, 'activity.started', ?, 1, 'activity-1', '{"kind":"input_analysis"}' + FROM ai_runs WHERE run_id = ? + ON CONFLICT (run_id, source_event_id) DO NOTHING + """, runId, seq, sourceEventId, runId); + } + + private static void insertCharge(Connection conn, String requestId, long userId, UUID runId, + int daily, int free, int paid) throws SQLException { + update(conn, """ + INSERT INTO request_usage_charges (request_id, user_id, run_id, usage_units, credit_amount, + charged_daily, charged_free, charged_paid) + VALUES (?, ?, ?, 1, ?, ?, ?, ?) + """, requestId, userId, runId, daily + free + paid, daily, free, paid); + } + + private static void insertChargeWithAmount(Connection conn, String requestId, long userId, int amount, + int daily, int free, int paid) throws SQLException { + update(conn, """ + INSERT INTO request_usage_charges (request_id, user_id, usage_units, credit_amount, + charged_daily, charged_free, charged_paid) + VALUES (?, ?, 1, ?, ?, ?, ?) + """, requestId, userId, amount, daily, free, paid); + } + + private static void insertCompensation(Connection conn, String requestId, String reason, int amount) + throws SQLException { + update(conn, """ + INSERT INTO request_usage_compensations (request_id, credit_amount, reason, restored_daily) + VALUES (?, ?, ?, ?) + """, requestId, amount, reason, amount); + } + + private static UUID insertSolution(Connection conn, UUID runId, UUID assistantMessageId, String publicId, + String claims, String overall, UUID revises) throws SQLException { + UUID id = UUID.randomUUID(); + update(conn, """ + INSERT INTO verified_solutions (solution_id, public_solution_id, note_id, source_run_id, assistant_message_id, + schema_version, solution_version, problem_statement, solution_text, claims, verification_summary, + verification_version, overall_coverage, content_sha256, revises_solution_id) + SELECT ?, ?, note_id, ?, ?, 'verified-solution-v1', 1, 'x^2 = 4', 'x = ±2', ?::jsonb, '{}', + 'chain-verification-report-v3', ?, ?, ? + FROM ai_runs WHERE run_id = ? + """, id, publicId, runId, assistantMessageId, claims, overall, "d".repeat(64), revises, runId); + return id; + } + + private static void insertAttachment(Connection conn, UUID messageId, int index, String mime, Integer pages, + Integer width, Integer height, Long pixels) throws SQLException { + update(conn, """ + INSERT INTO turn_attachments (attachment_id, note_id, source_message_id, attachment_index, object_key, sha256, + mime_type, byte_size, page_count, pixel_width, pixel_height, pixel_count, + upload_policy_version, page_understanding_version, image_normalization_version) + SELECT gen_random_uuid(), note_id, ?, ?, 'turn/key', ?, ?, 100, ?, ?, ?, ?, 'upload-v1', 'pu-v1', 'img-v1' + FROM chat_messages WHERE message_id = ? + """, messageId, index, "a".repeat(64), mime, pages, width, height, pixels, messageId); + } +} diff --git a/src/test/java/com/proovy/db/ProductSchemaContextPostgresTest.java b/src/test/java/com/proovy/db/ProductSchemaContextPostgresTest.java new file mode 100644 index 00000000..49ed1d21 --- /dev/null +++ b/src/test/java/com/proovy/db/ProductSchemaContextPostgresTest.java @@ -0,0 +1,236 @@ +package com.proovy.db; + +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.entity.AssetStatus; +import com.proovy.domain.asset.repository.AssetRepository; +import com.proovy.domain.conversation.entity.ChatMessage; +import com.proovy.domain.conversation.entity.MessageStatus; +import com.proovy.domain.conversation.entity.TurnAttachment; +import com.proovy.domain.conversation.repository.ChatMessageRepository; +import com.proovy.domain.conversation.repository.TurnAttachmentRepository; +import com.proovy.domain.note.entity.Note; +import com.proovy.domain.note.repository.NoteRepository; +import com.proovy.domain.run.entity.*; +import com.proovy.domain.run.repository.AiRunEventRepository; +import com.proovy.domain.run.repository.AiRunRepository; +import com.proovy.domain.run.repository.RunAssetDependencyRepository; +import com.proovy.domain.solution.entity.VerifiedSolution; +import com.proovy.domain.solution.entity.VerifiedSolutionStatus; +import com.proovy.domain.solution.repository.VerifiedSolutionRepository; +import com.proovy.domain.usage.entity.RequestUsageCharge; +import com.proovy.domain.usage.entity.RequestUsageCompensation; +import com.proovy.domain.usage.entity.UsageCompensationReason; +import com.proovy.domain.usage.repository.RequestUsageChargeRepository; +import com.proovy.domain.usage.repository.RequestUsageCompensationRepository; +import com.proovy.domain.user.entity.User; +import com.proovy.domain.user.repository.UserRepository; +import com.proovy.support.db.PostgresTestDatabase; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * 빈 Postgres에서 Flyway baseline만으로 애플리케이션 context가 기동하고(Hibernate ddl-auto=validate), + * 신규 Note 중심 엔티티가 ChatSession 없이 저장·조회되는지 검사한다. + */ +@Tag("postgres") +@ActiveProfiles("test") +@SpringBootTest(properties = { + "gcs.project-id=proovy-test", + "gcs.endpoint=http://127.0.0.1:1", + "jwt.secret=test-jwt-secret-test-jwt-secret-0123456789", + "oauth.kakao.client-id=test", "oauth.kakao.client-secret=test", + "oauth.naver.client-id=test", "oauth.naver.client-secret=test", + "openrouter.api.key=test", + "spring.data.redis.host=127.0.0.1", "spring.data.redis.port=1", + "spring.task.scheduling.pool.size=1", + "proovy.subscription.plan-transition-fixed-delay-ms=3600000", + "proovy.subscription.auto-renew-fixed-delay-ms=3600000" +}) +class ProductSchemaContextPostgresTest { + + private static PostgresTestDatabase db; + + @BeforeAll + static void createDatabase() { + // 빈 database만 만든다. schema는 애플리케이션 기동 시 Flyway가 생성한다. + db = PostgresTestDatabase.createEmpty(); + } + + @DynamicPropertySource + static void datasource(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", () -> db.jdbcUrl()); + registry.add("spring.datasource.username", PostgresTestDatabase::adminUser); + registry.add("spring.datasource.password", PostgresTestDatabase::adminPassword); + } + + @AfterAll + static void dropDatabase() { + if (db != null) { + db.close(); + } + } + + @Autowired UserRepository userRepository; + @Autowired NoteRepository noteRepository; + @Autowired AssetRepository assetRepository; + @Autowired ChatMessageRepository chatMessageRepository; + @Autowired TurnAttachmentRepository turnAttachmentRepository; + @Autowired AiRunRepository aiRunRepository; + @Autowired RunAssetDependencyRepository runAssetDependencyRepository; + @Autowired AiRunEventRepository aiRunEventRepository; + @Autowired VerifiedSolutionRepository verifiedSolutionRepository; + @Autowired RequestUsageChargeRepository chargeRepository; + @Autowired RequestUsageCompensationRepository compensationRepository; + @Autowired TransactionTemplate tx; + @Autowired ObjectMapper objectMapper; + + @Test + void noteCentricModelRoundTripsWithoutChatSession() { + Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS); + UUID userMessageId = UUID.randomUUID(); + UUID assistantMessageId = UUID.randomUUID(); + UUID runId = UUID.randomUUID(); + String requestId = UUID.randomUUID().toString(); + + Long noteId = tx.execute(status -> { + User user = userRepository.save(User.builder().nickname("context").build()); + Note note = noteRepository.save(Note.builder().user(user).title("새 노트").build()); + + ObjectNode input = objectMapper.createObjectNode().put("text", "x^2=4를 풀어줘"); + chatMessageRepository.save(ChatMessage.user(userMessageId, note, input, now)); + chatMessageRepository.save(ChatMessage.assistantPlaceholder(assistantMessageId, note, now.plusMillis(1))); + + aiRunRepository.save(AiRun.pending() + .id(runId).requestId(requestId).userId(user.getId()).noteId(note.getId()) + .userMessageId(userMessageId).assistantMessageId(assistantMessageId) + .requestFingerprint("0".repeat(64)) + .requestSnapshot(objectMapper.createObjectNode().set("input", input)) + .policySnapshot(objectMapper.createObjectNode().put("budget_policy_version", "budget-v1")) + .budgetPolicyVersion("budget-v1") + .requestDeadlineAt(now.plusSeconds(600)).createdAt(now) + .build()); + aiRunEventRepository.save(AiRunEvent.builder() + .runId(runId).noteId(note.getId()).seq(1).eventType(AiRunEventType.RUN_CREATED) + .payload(objectMapper.createObjectNode().put("status", "PENDING")).createdAt(now) + .build()); + turnAttachmentRepository.save(TurnAttachment.builder() + .id(UUID.randomUUID()).noteId(note.getId()).sourceMessageId(userMessageId).attachmentIndex(0) + .objectKey("turn/1").sha256("a".repeat(64)).mimeType("image/png").byteSize(100) + .pixelWidth(10).pixelHeight(10).pixelCount(100L) + .uploadPolicyVersion("upload-v1").pageUnderstandingVersion("pu-v1").imageNormalizationVersion("img-v1") + .createdAt(now).build()); + chargeRepository.save(RequestUsageCharge.builder() + .requestId(requestId).userId(user.getId()).runId(runId).usageUnits(1) + .chargedDaily(1).chargedFree(0).chargedPaid(0).chargedAt(now).build()); + return note.getId(); + }); + + AiRun run = aiRunRepository.findActiveByNoteId(noteId).orElseThrow(); + assertThat(run.getId()).isEqualTo(runId); + assertThat(run.getStatus()).isEqualTo(AiRunStatus.PENDING); + assertThat(run.getAttempt()).isZero(); + assertThat(aiRunRepository.findByRequestId(requestId)).isPresent(); + assertThat(chatMessageRepository.findByNoteIdOrdered(noteId)) + .extracting(ChatMessage::getId).containsExactly(userMessageId, assistantMessageId); + assertThat(chatMessageRepository.findById(assistantMessageId).orElseThrow().getStatus()) + .isEqualTo(MessageStatus.PENDING); + assertThat(aiRunEventRepository.findByRunIdAndSeqGreaterThanOrderBySeqAsc(runId, 0)) + .extracting(AiRunEvent::getEventType).containsExactly(AiRunEventType.RUN_CREATED); + assertThat(turnAttachmentRepository.findBySourceMessageIdOrderByAttachmentIndexAsc(userMessageId)).hasSize(1); + + // 같은 Note의 두 번째 Active Run은 DB가 거부한다 + assertThatThrownBy(() -> tx.executeWithoutResult(status -> { + Note note = noteRepository.findById(noteId).orElseThrow(); + UUID um = UUID.randomUUID(); + UUID am = UUID.randomUUID(); + chatMessageRepository.save(ChatMessage.user(um, note, objectMapper.createObjectNode().put("text", "다음 질문"), now)); + chatMessageRepository.save(ChatMessage.assistantPlaceholder(am, note, now)); + aiRunRepository.saveAndFlush(AiRun.pending() + .id(UUID.randomUUID()).requestId(UUID.randomUUID().toString()) + .userId(note.getUser().getId()).noteId(noteId).userMessageId(um).assistantMessageId(am) + .requestFingerprint("1".repeat(64)).requestSnapshot(objectMapper.createObjectNode()) + .policySnapshot(objectMapper.createObjectNode()).budgetPolicyVersion("budget-v1") + .requestDeadlineAt(now.plusSeconds(600)).createdAt(now).build()); + })).isInstanceOf(DataIntegrityViolationException.class); + // rollback으로 두 번째 요청의 메시지도 남지 않는다 + assertThat(chatMessageRepository.countByNoteId(noteId)).isEqualTo(2); + + // 보상 1회 · wire enum converter 왕복 + compensationRepository.saveAndFlush(RequestUsageCompensation.builder() + .requestId(requestId).reason(UsageCompensationReason.PENDING_CANCELED) + .restoredDaily(1).restoredFree(0).restoredPaid(0).compensatedAt(now).build()); + assertThat(compensationRepository.findById(requestId).orElseThrow().getCreditAmount()).isEqualTo(1); + } + + @Test + void verifiedSolutionAndDependencyEntitiesMapToBaseline() { + Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS); + UUID runId = UUID.randomUUID(); + UUID assistantMessageId = UUID.randomUUID(); + UUID solutionId = UUID.randomUUID(); + + tx.executeWithoutResult(status -> { + User user = userRepository.save(User.builder().nickname("solution").build()); + Note note = noteRepository.save(Note.builder().user(user).title("solution note").build()); + UUID userMessageId = UUID.randomUUID(); + chatMessageRepository.save(ChatMessage.user(userMessageId, note, + objectMapper.createObjectNode().put("text", "풀어줘"), now)); + chatMessageRepository.save(ChatMessage.assistantPlaceholder(assistantMessageId, note, now)); + aiRunRepository.save(AiRun.pending() + .id(runId).requestId("req-" + runId).userId(user.getId()).noteId(note.getId()) + .userMessageId(userMessageId).assistantMessageId(assistantMessageId) + .requestFingerprint("2".repeat(64)).requestSnapshot(objectMapper.createObjectNode()) + .policySnapshot(objectMapper.createObjectNode()).budgetPolicyVersion("budget-v1") + .requestDeadlineAt(now.plusSeconds(600)).createdAt(now).build()); + Asset asset = assetRepository.save(Asset.builder() + .userId(user.getId()).noteId(note.getId()).fileName("calc.pdf").fileSize(10L) + .mimeType("application/pdf").objectKey("users/x/calc.pdf").source(Asset.AssetSource.upload) + .status(AssetStatus.ACTIVE).build()); + aiRunRepository.flush(); + assetRepository.flush(); + runAssetDependencyRepository.save(new RunAssetDependency(runId, note.getId(), asset.getId(), + RunAssetDependencyKind.VIEWER_FOCUS, asset.getContentVersion(), now)); + verifiedSolutionRepository.save(VerifiedSolution.builder() + .id(solutionId).publicSolutionId("solution-1").noteId(note.getId()).sourceRunId(runId) + .assistantMessageId(assistantMessageId).schemaVersion("verified-solution-v1").solutionVersion(1) + .problemStatement("x^2=4").problemConditions(objectMapper.createArrayNode()) + .solutionText("x=±2") + .claims(objectMapper.createArrayNode().add(objectMapper.createObjectNode() + .put("claim_id", "claim-1").put("coverage", "direct"))) + .stepMarks(objectMapper.createArrayNode()) + .verificationSummary(objectMapper.createObjectNode().put("overall_coverage", "direct")) + .verificationVersion("chain-verification-report-v3") + .sourceRefIds(objectMapper.createArrayNode()).overallCoverage("direct") + .contentSha256("3".repeat(64)).createdAt(now).build()); + }); + + VerifiedSolution saved = verifiedSolutionRepository.findById(solutionId).orElseThrow(); + assertThat(saved.getStatus()).isEqualTo(VerifiedSolutionStatus.ACTIVE); + assertThat(saved.getClaims().get(0).get("coverage").asText()).isEqualTo("direct"); + assertThat(verifiedSolutionRepository.findByAssistantMessageId(assistantMessageId)).hasSize(1); + assertThat(runAssetDependencyRepository.findByRunId(runId)) + .singleElement() + .satisfies(dep -> { + assertThat(dep.getDependencyKind()).isEqualTo(RunAssetDependencyKind.VIEWER_FOCUS); + assertThat(dep.getContentVersion()).isEqualTo(1); + }); + } +} diff --git a/src/test/java/com/proovy/db/RoleBoundaryPostgresTest.java b/src/test/java/com/proovy/db/RoleBoundaryPostgresTest.java new file mode 100644 index 00000000..ed936cef --- /dev/null +++ b/src/test/java/com/proovy/db/RoleBoundaryPostgresTest.java @@ -0,0 +1,114 @@ +package com.proovy.db; + +import com.proovy.support.db.PostgresTestDatabase; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.Statement; + +import static com.proovy.support.db.ProductRows.count; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Server와 Brain DB role의 schema 접근 경계 (docs/plan/07). + * + *

격리 Postgres에 role을 만들고, Server Flyway를 proovy_server로 실행한 뒤 Brain schema를 + * brain_migration으로 만들어 Brain 0001 권한을 재현하고 provisioning 스크립트를 적용한다. + * 외부 운영 DB 권한은 변경하지 않는다. + */ +@Tag("postgres") +class RoleBoundaryPostgresTest { + + private static final String PERMISSION_DENIED = "42501"; + private static final String PASSWORD = "role-boundary-test"; + + private static PostgresTestDatabase db; + + @BeforeAll + static void provision() throws SQLException { + PostgresTestDatabase.requireConfigured(); + for (String role : new String[]{"proovy_server", "brain_migration", "brain_runtime"}) { + PostgresTestDatabase.executeAsAdminOnServer(""" + DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '%1$s') THEN + CREATE ROLE %1$s LOGIN PASSWORD '%2$s'; + ELSE + ALTER ROLE %1$s LOGIN PASSWORD '%2$s'; + END IF; + END $$; + """.formatted(role, PASSWORD)); + } + db = PostgresTestDatabase.createEmpty(); + try (Connection admin = db.connect(); Statement st = admin.createStatement()) { + // pg_trgm은 superuser가 미리 설치하고 Server role은 public schema 소유 작업만 한다. + st.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm"); + st.execute("GRANT USAGE, CREATE ON SCHEMA public TO proovy_server"); + // Brain migration이 만드는 schema를 재현한다 (proovy-brain 0001_brain_schema_permissions.sql) + st.execute("CREATE SCHEMA brain_runtime AUTHORIZATION brain_migration"); + st.execute("CREATE SCHEMA brain_retrieval AUTHORIZATION brain_migration"); + } + db.migrate("proovy_server", PASSWORD); + try (Connection brain = db.connect("brain_migration", PASSWORD); Statement st = brain.createStatement()) { + st.execute("CREATE TABLE brain_runtime.run_records (run_id TEXT PRIMARY KEY)"); + st.execute("CREATE TABLE brain_retrieval.chunks (chunk_id TEXT PRIMARY KEY)"); + st.execute("REVOKE ALL ON SCHEMA brain_runtime FROM PUBLIC"); + st.execute("REVOKE ALL ON SCHEMA brain_retrieval FROM PUBLIC"); + st.execute("GRANT USAGE ON SCHEMA brain_runtime, brain_retrieval TO brain_runtime"); + st.execute("GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA brain_runtime TO brain_runtime"); + st.execute("GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA brain_retrieval TO brain_runtime"); + } + db.runScript("db/provisioning/role-boundary.sql"); + // 재적용해도 같은 결과다 + db.runScript("db/provisioning/role-boundary.sql"); + } + + @AfterAll + static void drop() { + if (db != null) { + db.close(); + } + } + + @Test + void brainRolesCannotReadOrWriteProductTables() throws SQLException { + for (String role : new String[]{"brain_runtime", "brain_migration"}) { + try (Connection brain = db.connect(role, PASSWORD); Statement st = brain.createStatement()) { + assertDenied(st, "SELECT count(*) FROM public.notes"); + assertDenied(st, "SELECT count(*) FROM public.ai_runs"); + assertDenied(st, "INSERT INTO public.ai_run_events (run_id, note_id, seq, event_type) " + + "VALUES (gen_random_uuid(), 1, 1, 'run.created')"); + assertDenied(st, "UPDATE public.chat_messages SET plain_text = 'x'"); + assertDenied(st, "CREATE TABLE public.brain_shadow (id INT)"); + } + } + // Brain 자체 schema는 계속 사용할 수 있다 + try (Connection brain = db.connect("brain_runtime", PASSWORD); Statement st = brain.createStatement()) { + st.execute("INSERT INTO brain_runtime.run_records VALUES ('run-1')"); + st.execute("SELECT count(*) FROM brain_retrieval.chunks"); + } + } + + @Test + void serverRoleCannotAccessBrainSchemas() throws SQLException { + try (Connection server = db.connect("proovy_server", PASSWORD); Statement st = server.createStatement()) { + assertDenied(st, "SELECT count(*) FROM brain_runtime.run_records"); + assertDenied(st, "SELECT count(*) FROM brain_retrieval.chunks"); + assertDenied(st, "CREATE TABLE brain_runtime.server_shadow (id INT)"); + // 제품 schema는 소유자로서 정상 사용한다 + st.execute("INSERT INTO users (nickname) VALUES ('server-role')"); + assertThat(count(server, "SELECT count(*) FROM users")).isEqualTo(1); + } + } + + private static void assertDenied(Statement st, String sql) { + assertThatThrownBy(() -> st.execute(sql)) + .as(sql) + .isInstanceOfSatisfying(SQLException.class, + e -> assertThat(e.getSQLState()).isEqualTo(PERMISSION_DENIED)); + } +} diff --git a/src/test/java/com/proovy/support/db/PostgresTestDatabase.java b/src/test/java/com/proovy/support/db/PostgresTestDatabase.java new file mode 100644 index 00000000..9717802f --- /dev/null +++ b/src/test/java/com/proovy/support/db/PostgresTestDatabase.java @@ -0,0 +1,153 @@ +package com.proovy.support.db; + +import org.flywaydb.core.Flyway; +import org.junit.jupiter.api.Assumptions; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.UUID; + +/** + * 실제 Postgres 위에 테스트마다 새 database를 만들어 주는 격리 지원 도구. + * + *

관리자(superuser) 접속 정보는 환경변수로 받는다. 고정 compose 자원(proovy-postgres:15432)을 + * 공유하지 않도록 기본값을 두지 않는다. + *

    + *
  • {@code PROOVY_TEST_PG_URL} — 예: {@code jdbc:postgresql://127.0.0.1:21432/postgres}
  • + *
  • {@code PROOVY_TEST_PG_USER}, {@code PROOVY_TEST_PG_PASSWORD}
  • + *
  • {@code PROOVY_REQUIRE_DB_TESTS=true} — 미구성 시 skip이 아니라 실패 (CI)
  • + *
+ */ +public final class PostgresTestDatabase implements AutoCloseable { + + private static final String ADMIN_URL = System.getenv("PROOVY_TEST_PG_URL"); + private static final String ADMIN_USER = System.getenv("PROOVY_TEST_PG_USER"); + private static final String ADMIN_PASSWORD = System.getenv("PROOVY_TEST_PG_PASSWORD"); + private static final boolean REQUIRED = Boolean.parseBoolean(System.getenv("PROOVY_REQUIRE_DB_TESTS")); + + private final String databaseName; + + private PostgresTestDatabase(String databaseName) { + this.databaseName = databaseName; + } + + /** + * 빈 database를 만든다. 환경이 없으면 skip(로컬) 또는 실패(CI). + */ + public static PostgresTestDatabase createEmpty() { + requireConfigured(); + String name = "proovy_t_" + UUID.randomUUID().toString().replace("-", "").substring(0, 12); + try (Connection admin = DriverManager.getConnection(ADMIN_URL, ADMIN_USER, ADMIN_PASSWORD); + Statement st = admin.createStatement()) { + st.execute("CREATE DATABASE " + name); + } catch (SQLException e) { + throw new IllegalStateException("failed to create isolated test database", e); + } + return new PostgresTestDatabase(name); + } + + /** + * 빈 database + 제품 baseline migration. + */ + public static PostgresTestDatabase createMigrated() { + PostgresTestDatabase db = createEmpty(); + db.migrate(ADMIN_USER, ADMIN_PASSWORD); + return db; + } + + public static void requireConfigured() { + boolean configured = ADMIN_URL != null && ADMIN_USER != null && ADMIN_PASSWORD != null; + if (!configured && REQUIRED) { + throw new IllegalStateException( + "PROOVY_REQUIRE_DB_TESTS=true but PROOVY_TEST_PG_URL/USER/PASSWORD are not set"); + } + Assumptions.assumeTrue(configured, + "Postgres DB tests are not configured (PROOVY_TEST_PG_URL/USER/PASSWORD) — NOT EXECUTED"); + } + + public Flyway flyway(String user, String password) { + return Flyway.configure() + .dataSource(jdbcUrl(), user, password) + .locations("classpath:db/migration") + .baselineOnMigrate(false) + .cleanDisabled(true) + .load(); + } + + public void migrate(String user, String password) { + flyway(user, password).migrate(); + } + + public String name() { + return databaseName; + } + + public String jdbcUrl() { + int slash = ADMIN_URL.lastIndexOf('/'); + int query = ADMIN_URL.indexOf('?', slash); + String suffix = query >= 0 ? ADMIN_URL.substring(query) : ""; + return ADMIN_URL.substring(0, slash + 1) + databaseName + suffix; + } + + public static String adminUser() { + return ADMIN_USER; + } + + public static String adminPassword() { + return ADMIN_PASSWORD; + } + + public Connection connect() throws SQLException { + return DriverManager.getConnection(jdbcUrl(), ADMIN_USER, ADMIN_PASSWORD); + } + + public Connection connect(String user, String password) throws SQLException { + return DriverManager.getConnection(jdbcUrl(), user, password); + } + + /** classpath SQL 스크립트를 관리자 권한으로 실행한다. */ + public void runScript(String classpathResource) { + try (Connection c = connect(); Statement st = c.createStatement()) { + st.execute(readResource(classpathResource)); + } catch (SQLException e) { + throw new IllegalStateException("failed to run " + classpathResource, e); + } + } + + public static String readResource(String classpathResource) { + try (InputStream in = PostgresTestDatabase.class.getClassLoader().getResourceAsStream(classpathResource)) { + if (in == null) { + throw new IllegalArgumentException("missing classpath resource " + classpathResource); + } + return new String(in.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + /** 관리자 database 연결에서 실행한다 (role 생성 등 cluster 단위 작업). */ + public static void executeAsAdminOnServer(String sql) { + try (Connection admin = DriverManager.getConnection(ADMIN_URL, ADMIN_USER, ADMIN_PASSWORD); + Statement st = admin.createStatement()) { + st.execute(sql); + } catch (SQLException e) { + throw new IllegalStateException("admin statement failed", e); + } + } + + @Override + public void close() { + try (Connection admin = DriverManager.getConnection(ADMIN_URL, ADMIN_USER, ADMIN_PASSWORD); + Statement st = admin.createStatement()) { + st.execute("DROP DATABASE IF EXISTS " + databaseName + " WITH (FORCE)"); + } catch (SQLException e) { + throw new IllegalStateException("failed to drop isolated test database " + databaseName, e); + } + } +} diff --git a/src/test/java/com/proovy/support/db/ProductRows.java b/src/test/java/com/proovy/support/db/ProductRows.java new file mode 100644 index 00000000..c60a9dc0 --- /dev/null +++ b/src/test/java/com/proovy/support/db/ProductRows.java @@ -0,0 +1,113 @@ +package com.proovy.support.db; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.UUID; + +/** + * 제품 테이블 row를 SQL로 직접 만드는 테스트 도구. 애플리케이션 서비스 없이 DB 제약만 검사할 때 쓴다. + */ +public final class ProductRows { + + public static final long ALICE = 1001L; + public static final long BOB = 1002L; + public static final long ALICE_NOTE = 2001L; + public static final long BOB_NOTE = 2002L; + public static final long ALICE_SECOND_NOTE = 2003L; + public static final long ALICE_PDF = 3001L; + public static final long BOB_PDF = 3002L; + + public static final String FINGERPRINT = "f".repeat(64); + + private ProductRows() { + } + + public record Turn(UUID userMessageId, UUID assistantMessageId) { + } + + public static Turn insertTurn(Connection c, long noteId) throws SQLException { + Turn turn = new Turn(UUID.randomUUID(), UUID.randomUUID()); + try (PreparedStatement ps = c.prepareStatement(""" + INSERT INTO chat_messages (message_id, note_id, role, status, user_input, plain_text) + VALUES (?, ?, 'USER', 'COMPLETED', '{"text":"1번 문제 풀어줘"}', '1번 문제 풀어줘'), + (?, ?, 'ASSISTANT', 'PENDING', NULL, NULL) + """)) { + ps.setObject(1, turn.userMessageId()); + ps.setLong(2, noteId); + ps.setObject(3, turn.assistantMessageId()); + ps.setLong(4, noteId); + ps.executeUpdate(); + } + return turn; + } + + public static UUID insertPendingRun(Connection c, String requestId, long userId, long noteId, Turn turn) + throws SQLException { + UUID runId = UUID.randomUUID(); + try (PreparedStatement ps = c.prepareStatement(""" + INSERT INTO ai_runs (run_id, request_id, user_id, note_id, user_message_id, assistant_message_id, + request_fingerprint, request_snapshot, policy_snapshot, budget_policy_version, + request_deadline_at) + VALUES (?, ?, ?, ?, ?, ?, ?, '{"input":{"text":"1번 문제 풀어줘"}}', '{"limits":{}}', 'budget-v1', + now() + interval '10 minutes') + """)) { + ps.setObject(1, runId); + ps.setString(2, requestId); + ps.setLong(3, userId); + ps.setLong(4, noteId); + ps.setObject(5, turn.userMessageId()); + ps.setObject(6, turn.assistantMessageId()); + ps.setString(7, FINGERPRINT); + ps.executeUpdate(); + } + return runId; + } + + /** 새 turn + PENDING run. */ + public static UUID insertRunWithTurn(Connection c, String requestId, long userId, long noteId) + throws SQLException { + return insertPendingRun(c, requestId, userId, noteId, insertTurn(c, noteId)); + } + + /** PENDING → RUNNING (attempt 1, lease 보유). */ + public static void claim(Connection c, UUID runId) throws SQLException { + update(c, """ + UPDATE ai_runs SET status = 'RUNNING', attempt = attempt + 1, fence_token = 'fence-' || (attempt + 1), + started_at = now(), lease_owner = 'coordinator-1', lease_expires_at = now() + interval '30 seconds', + heartbeat_at = now() + WHERE run_id = ? + """, runId); + } + + /** RUNNING → COMPLETED substantive (lease 해제). */ + public static void completeSubstantive(Connection c, UUID runId) throws SQLException { + update(c, """ + UPDATE ai_runs SET status = 'COMPLETED', response_disposition = 'substantive', completed_at = now(), + lease_owner = NULL, lease_expires_at = NULL + WHERE run_id = ? + """, runId); + } + + public static int update(Connection c, String sql, Object... args) throws SQLException { + try (PreparedStatement ps = c.prepareStatement(sql)) { + for (int i = 0; i < args.length; i++) { + ps.setObject(i + 1, args[i]); + } + return ps.executeUpdate(); + } + } + + public static long count(Connection c, String sql, Object... args) throws SQLException { + try (PreparedStatement ps = c.prepareStatement(sql)) { + for (int i = 0; i < args.length; i++) { + ps.setObject(i + 1, args[i]); + } + try (ResultSet rs = ps.executeQuery()) { + rs.next(); + return rs.getLong(1); + } + } + } +} diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml index 80b09327..470d401c 100644 --- a/src/test/resources/application-test.yaml +++ b/src/test/resources/application-test.yaml @@ -5,9 +5,10 @@ spring: redis: timeout: 2000 + # 테스트도 Flyway baseline으로 schema를 만들고 Hibernate는 검증만 한다. jpa: hibernate: - ddl-auto: create-drop + ddl-auto: validate properties: hibernate: format_sql: true 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); From 3aa406d80f068b44d4febb6461c97c5fdf3e9230 Mon Sep 17 00:00:00 2001 From: chowon442 Date: Sat, 17 Oct 2026 13:49:34 +0900 Subject: [PATCH 03/34] =?UTF-8?q?fix:=20=EB=A6=AC=EB=B7=B0=20r174=20?= =?UTF-8?q?=EB=B0=98=EC=98=81=20=E2=80=94=20=ED=95=A0=EB=8B=B9=ED=98=95=20?= =?UTF-8?q?ID=20save=20=EC=9D=98=EB=AF=B8=C2=B7NULL=20=ED=86=B5=EA=B3=BC?= =?UTF-8?q?=20CHECK=C2=B7ledger/event=20=EB=AC=B4=EA=B2=B0=EC=84=B1=20(#21?= =?UTF-8?q?3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - B1: 할당형 ID 엔티티(AiRun·ChatMessage·TurnAttachment·RunAssetDependency·VerifiedSolution· RequestUsageCharge·RequestUsageCompensation)에 AssignedIdEntity(Persistable) 적용. 새 인스턴스 save는 persist로 가서 중복 ID가 DataIntegrityViolationException으로 드러난다. - B2: NULL이면 통과하던 CHECK 수정 (USER user_input, COMPLETED assistant 본문, turn attachment media, clarification 짝, failure code/class 짝) - R1: 차감 row를 run의 (run_id, request_id, user_id)에 composite FK로 묶음 (ON DELETE SET NULL (run_id)) - R3: ai_run_events·usage ledger 직접 DELETE 거부 (상위 cascade만 허용) - R4: claims coverage는 문자열 direct|supporting만 허용 - R9: event seq는 last_event_seq로 배정된 값만, attempt는 run attempt 이하만 insert 허용 - R7/R8/R10: TODO·README·repository 계약 주석 Co-Authored-By: Claude Opus 5.5 --- README.md | 3 + .../conversation/entity/ChatMessage.java | 3 +- .../conversation/entity/TurnAttachment.java | 3 +- .../domain/note/service/NoteServiceImpl.java | 1 + .../com/proovy/domain/run/entity/AiRun.java | 3 +- .../domain/run/entity/RunAssetDependency.java | 8 +- .../run/repository/AiRunRepository.java | 4 + .../solution/entity/VerifiedSolution.java | 3 +- .../usage/entity/RequestUsageCharge.java | 8 +- .../entity/RequestUsageCompensation.java | 8 +- .../domain/user/service/UserService.java | 1 + .../global/entity/AssignedIdEntity.java | 33 +++++++ .../db/migration/V1__product_baseline.sql | 87 ++++++++++++++++--- .../db/ProductConstraintsPostgresTest.java | 81 ++++++++++++++++- .../db/ProductSchemaContextPostgresTest.java | 75 ++++++++++++++++ 15 files changed, 301 insertions(+), 20 deletions(-) create mode 100644 src/main/java/com/proovy/global/entity/AssignedIdEntity.java diff --git a/README.md b/README.md index fa6ed964..c5f6338a 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,9 @@ 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`로 지정하지 않는다. + ## API 문서 - **Swagger UI**: https://api.proovy.ai.kr/swagger-ui/index.html#/ 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 bdb52d39..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,6 +3,7 @@ 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.Getter; @@ -24,7 +25,7 @@ @Table(name = "chat_messages") @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) -public class ChatMessage { +public class ChatMessage extends AssignedIdEntity { @Id @Column(name = "message_id") diff --git a/src/main/java/com/proovy/domain/conversation/entity/TurnAttachment.java b/src/main/java/com/proovy/domain/conversation/entity/TurnAttachment.java index d080526e..b98c9bdb 100644 --- a/src/main/java/com/proovy/domain/conversation/entity/TurnAttachment.java +++ b/src/main/java/com/proovy/domain/conversation/entity/TurnAttachment.java @@ -1,5 +1,6 @@ package com.proovy.domain.conversation.entity; +import com.proovy.global.entity.AssignedIdEntity; import jakarta.persistence.*; import lombok.AccessLevel; import lombok.Builder; @@ -21,7 +22,7 @@ @Table(name = "turn_attachments") @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) -public class TurnAttachment { +public class TurnAttachment extends AssignedIdEntity { @Id @Column(name = "attachment_id") 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 bf6361f7..20c794e8 100644 --- a/src/main/java/com/proovy/domain/note/service/NoteServiceImpl.java +++ b/src/main/java/com/proovy/domain/note/service/NoteServiceImpl.java @@ -353,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(); diff --git a/src/main/java/com/proovy/domain/run/entity/AiRun.java b/src/main/java/com/proovy/domain/run/entity/AiRun.java index 33ce9447..73adab97 100644 --- a/src/main/java/com/proovy/domain/run/entity/AiRun.java +++ b/src/main/java/com/proovy/domain/run/entity/AiRun.java @@ -2,6 +2,7 @@ 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; @@ -25,7 +26,7 @@ @Table(name = "ai_runs") @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) -public class AiRun { +public class AiRun extends AssignedIdEntity { @Id @Column(name = "run_id") diff --git a/src/main/java/com/proovy/domain/run/entity/RunAssetDependency.java b/src/main/java/com/proovy/domain/run/entity/RunAssetDependency.java index 2b1f9de1..33c3e431 100644 --- a/src/main/java/com/proovy/domain/run/entity/RunAssetDependency.java +++ b/src/main/java/com/proovy/domain/run/entity/RunAssetDependency.java @@ -1,5 +1,6 @@ package com.proovy.domain.run.entity; +import com.proovy.global.entity.AssignedIdEntity; import jakarta.persistence.*; import lombok.AccessLevel; import lombok.EqualsAndHashCode; @@ -20,7 +21,7 @@ @IdClass(RunAssetDependency.Key.class) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) -public class RunAssetDependency { +public class RunAssetDependency extends AssignedIdEntity { @Id @Column(name = "run_id") @@ -54,6 +55,11 @@ public RunAssetDependency(UUID runId, Long noteId, Long assetId, RunAssetDepende this.createdAt = Objects.requireNonNull(createdAt); } + @Override + public Key getId() { + return new Key(runId, assetId, dependencyKind); + } + @EqualsAndHashCode @NoArgsConstructor public static class Key implements Serializable { diff --git a/src/main/java/com/proovy/domain/run/repository/AiRunRepository.java b/src/main/java/com/proovy/domain/run/repository/AiRunRepository.java index 7f7418d8..d9845901 100644 --- a/src/main/java/com/proovy/domain/run/repository/AiRunRepository.java +++ b/src/main/java/com/proovy/domain/run/repository/AiRunRepository.java @@ -11,6 +11,10 @@ public interface AiRunRepository extends JpaRepository { + /** + * request_id는 전역 unique다. 멱등 재생 시 호출자(1.77)는 반드시 run의 user_id를 요청 사용자와 비교하고, + * 다르면 상세를 노출하지 않고 거부한다(1.75 계약 D7: 409 RUN4092). + */ Optional findByRequestId(String requestId); /** diff --git a/src/main/java/com/proovy/domain/solution/entity/VerifiedSolution.java b/src/main/java/com/proovy/domain/solution/entity/VerifiedSolution.java index 32450b60..c8b0f5f5 100644 --- a/src/main/java/com/proovy/domain/solution/entity/VerifiedSolution.java +++ b/src/main/java/com/proovy/domain/solution/entity/VerifiedSolution.java @@ -2,6 +2,7 @@ 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; @@ -24,7 +25,7 @@ @Table(name = "verified_solutions") @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) -public class VerifiedSolution { +public class VerifiedSolution extends AssignedIdEntity { @Id @Column(name = "solution_id") diff --git a/src/main/java/com/proovy/domain/usage/entity/RequestUsageCharge.java b/src/main/java/com/proovy/domain/usage/entity/RequestUsageCharge.java index 1b2e0a62..8fd060c5 100644 --- a/src/main/java/com/proovy/domain/usage/entity/RequestUsageCharge.java +++ b/src/main/java/com/proovy/domain/usage/entity/RequestUsageCharge.java @@ -1,5 +1,6 @@ package com.proovy.domain.usage.entity; +import com.proovy.global.entity.AssignedIdEntity; import jakarta.persistence.*; import lombok.AccessLevel; import lombok.Builder; @@ -18,7 +19,7 @@ @Table(name = "request_usage_charges") @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) -public class RequestUsageCharge { +public class RequestUsageCharge extends AssignedIdEntity { @Id @Column(name = "request_id", length = 128) @@ -61,4 +62,9 @@ private RequestUsageCharge(String requestId, Long userId, UUID runId, int usageU this.creditAmount = chargedDaily + chargedFree + chargedPaid; this.chargedAt = Objects.requireNonNull(chargedAt); } + + @Override + public String getId() { + return requestId; + } } diff --git a/src/main/java/com/proovy/domain/usage/entity/RequestUsageCompensation.java b/src/main/java/com/proovy/domain/usage/entity/RequestUsageCompensation.java index 1167774a..36af2a38 100644 --- a/src/main/java/com/proovy/domain/usage/entity/RequestUsageCompensation.java +++ b/src/main/java/com/proovy/domain/usage/entity/RequestUsageCompensation.java @@ -1,5 +1,6 @@ package com.proovy.domain.usage.entity; +import com.proovy.global.entity.AssignedIdEntity; import jakarta.persistence.*; import lombok.AccessLevel; import lombok.Builder; @@ -17,7 +18,7 @@ @Table(name = "request_usage_compensations") @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) -public class RequestUsageCompensation { +public class RequestUsageCompensation extends AssignedIdEntity { @Id @Column(name = "request_id", length = 128) @@ -54,4 +55,9 @@ private RequestUsageCompensation(String requestId, UsageCompensationReason reaso this.creditAmount = restoredDaily + restoredFree + restoredPaid; this.compensatedAt = Objects.requireNonNull(compensatedAt); } + + @Override + public String getId() { + return requestId; + } } diff --git a/src/main/java/com/proovy/domain/user/service/UserService.java b/src/main/java/com/proovy/domain/user/service/UserService.java index b26ea741..41cd900c 100644 --- a/src/main/java/com/proovy/domain/user/service/UserService.java +++ b/src/main/java/com/proovy/domain/user/service/UserService.java @@ -201,6 +201,7 @@ public void afterCommit() { private void deleteUserData(Long userId) { // GCS 키를 먼저 수집 (원본 + 썸네일, 중복 제거) + // TODO(S09/S13): turn_attachments object_key·thumbnail_object_key도 수집한다 (업로드 경로 도입 시). Set objectKeys = new java.util.HashSet<>(); assetRepository.findAllByUserId(userId).forEach(asset -> { if (asset.getObjectKey() != null) { diff --git a/src/main/java/com/proovy/global/entity/AssignedIdEntity.java b/src/main/java/com/proovy/global/entity/AssignedIdEntity.java new file mode 100644 index 00000000..552f3b12 --- /dev/null +++ b/src/main/java/com/proovy/global/entity/AssignedIdEntity.java @@ -0,0 +1,33 @@ +package com.proovy.global.entity; + +import jakarta.persistence.MappedSuperclass; +import jakarta.persistence.PostLoad; +import jakarta.persistence.PostPersist; +import jakarta.persistence.Transient; +import org.springframework.data.domain.Persistable; + +/** + * 애플리케이션이 ID를 직접 할당하는 엔티티의 공통 부모. + * + *

할당형 ID에 {@code @Version}이 없으면 Spring Data {@code save()}가 이를 기존 row로 보고 + * {@code merge}(SELECT 후 병합)를 호출한다. 그러면 이미 있는 ID에 대한 저장이 SQL 없이 조용히 + * 성공해 request 멱등 키·ledger PK 같은 DB 유일성 제약이 우회된다. 새로 만든 인스턴스는 + * 항상 {@code persist}(INSERT)로 가게 하여 중복이 {@code DataIntegrityViolationException}으로 드러나게 한다. + */ +@MappedSuperclass +public abstract class AssignedIdEntity implements Persistable { + + @Transient + private boolean newEntity = true; + + @Override + public boolean isNew() { + return newEntity; + } + + @PostLoad + @PostPersist + void markNotNew() { + this.newEntity = false; + } +} diff --git a/src/main/resources/db/migration/V1__product_baseline.sql b/src/main/resources/db/migration/V1__product_baseline.sql index 7d776c07..0a7dbd2b 100644 --- a/src/main/resources/db/migration/V1__product_baseline.sql +++ b/src/main/resources/db/migration/V1__product_baseline.sql @@ -24,6 +24,18 @@ BEGIN END; $$; +-- append-only/ledger row는 상위 row 삭제 cascade(FK 내부 trigger 아래, depth > 1)로만 지운다. +CREATE FUNCTION proovy_reject_direct_delete() RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + IF pg_trigger_depth() <= 1 THEN + RAISE EXCEPTION '% rows are removed only by parent cascade: direct DELETE is not allowed', TG_TABLE_NAME + USING ERRCODE = 'integrity_constraint_violation'; + END IF; + RETURN OLD; +END; +$$; + -- ===================================================================== -- 사용자·플랜·잔액 (기존 인증/잔액 코드 재사용) -- ===================================================================== @@ -206,7 +218,8 @@ CREATE TABLE chat_messages ( CONSTRAINT ck_chat_messages_user CHECK ( role <> 'USER' OR ( status = 'COMPLETED' - AND jsonb_typeof(user_input) = 'object' + AND user_input IS NOT NULL AND jsonb_typeof(user_input) = 'object' + AND user_input -> 'text' IS NOT NULL AND jsonb_typeof(user_input -> 'text') = 'string' AND plain_text IS NOT NULL AND response_pages IS NULL AND source_references IS NULL AND completed_at IS NULL @@ -220,7 +233,8 @@ CREATE TABLE chat_messages ( ), CONSTRAINT ck_chat_messages_assistant_completed CHECK ( role <> 'ASSISTANT' OR status <> 'COMPLETED' OR ( - jsonb_typeof(response_pages) = 'array' AND jsonb_array_length(response_pages) >= 1 + response_pages IS NOT NULL AND source_references IS NOT NULL + AND jsonb_typeof(response_pages) = 'array' AND jsonb_array_length(response_pages) >= 1 AND jsonb_typeof(source_references) = 'array' AND plain_text IS NOT NULL AND content_sha256 IS NOT NULL @@ -289,12 +303,14 @@ CREATE TABLE turn_attachments ( REFERENCES chat_messages (note_id, message_id, role) ON DELETE CASCADE, CONSTRAINT uq_turn_attachments_message_index UNIQUE (source_message_id, attachment_index), -- internal-run-v1 _ScopedAsset media 규칙과 동일 - CONSTRAINT ck_turn_attachments_media CHECK ( + CONSTRAINT ck_turn_attachments_media CHECK (COALESCE( CASE WHEN mime_type = 'application/pdf' - THEN page_count >= 1 AND pixel_width IS NULL AND pixel_height IS NULL AND pixel_count IS NULL - ELSE page_count IS NULL AND pixel_width >= 1 AND pixel_height >= 1 + THEN page_count IS NOT NULL AND page_count >= 1 + AND pixel_width IS NULL AND pixel_height IS NULL AND pixel_count IS NULL + ELSE page_count IS NULL AND pixel_width IS NOT NULL AND pixel_height IS NOT NULL AND pixel_count IS NOT NULL + AND pixel_width >= 1 AND pixel_height >= 1 AND pixel_count = pixel_width::BIGINT * pixel_height - END + END, FALSE) ) ); @@ -380,6 +396,8 @@ CREATE TABLE ai_runs ( CONSTRAINT uq_ai_runs_user_message UNIQUE (user_message_id), CONSTRAINT uq_ai_runs_assistant_message UNIQUE (assistant_message_id), CONSTRAINT uq_ai_runs_note_run UNIQUE (note_id, run_id), + -- 차감 row가 같은 request·사용자의 run만 가리키도록 composite FK 대상으로 쓴다. + CONSTRAINT uq_ai_runs_run_request_user UNIQUE (run_id, request_id, user_id), CONSTRAINT fk_ai_runs_note_owner FOREIGN KEY (note_id, user_id) REFERENCES notes (note_id, user_id) ON DELETE CASCADE, CONSTRAINT fk_ai_runs_user_message FOREIGN KEY (note_id, user_message_id, user_message_role) @@ -404,19 +422,20 @@ CREATE TABLE ai_runs ( ), CONSTRAINT ck_ai_runs_completed_disposition CHECK ((status = 'COMPLETED') = (response_disposition IS NOT NULL)), CONSTRAINT ck_ai_runs_clarification_reason_pair CHECK ( - (response_disposition = 'clarification_only') = (clarification_reason IS NOT NULL) + COALESCE(response_disposition = 'clarification_only', FALSE) = (clarification_reason IS NOT NULL) ), CONSTRAINT ck_ai_runs_failed_code CHECK ( (status = 'FAILED') = (failure_code IS NOT NULL AND failure_class IS NOT NULL) ), -- terminal-v2 expected_failure_class 표와 동일 + CONSTRAINT ck_ai_runs_failure_pair CHECK ((failure_code IS NULL) = (failure_class IS NULL)), CONSTRAINT ck_ai_runs_failure_code_class CHECK ( - failure_code IS NULL OR failure_class = CASE failure_code + failure_code IS NULL OR COALESCE(failure_class = CASE failure_code WHEN 'verification_failed' THEN 'verification' WHEN 'budget_exceeded' THEN 'budget' WHEN 'invalid_attachment' THEN 'attachment' ELSE 'infrastructure' - END + END, FALSE) ), CONSTRAINT ck_ai_runs_cancel_fields CHECK ( (status IN ('CANCELING', 'CANCELED')) @@ -588,6 +607,38 @@ CREATE TRIGGER trg_ai_run_events_append_only BEFORE UPDATE ON ai_run_events FOR EACH ROW EXECUTE FUNCTION proovy_reject_update(); +-- seq는 ai_runs.last_event_seq 증가(UPDATE ... RETURNING)로 먼저 배정한 값만 쓸 수 있고, +-- Brain Activity attempt는 run이 이미 시작한 attempt를 넘을 수 없다. +CREATE FUNCTION ai_run_events_guard_insert() RETURNS trigger +LANGUAGE plpgsql AS $$ +DECLARE + run_last_seq BIGINT; + run_attempt INTEGER; +BEGIN + SELECT r.last_event_seq, r.attempt INTO run_last_seq, run_attempt FROM ai_runs r WHERE r.run_id = NEW.run_id; + IF NOT FOUND THEN + RETURN NEW; -- FK가 거부한다 + END IF; + IF NEW.seq > run_last_seq THEN + RAISE EXCEPTION 'event seq % is not allocated for run % (last_event_seq %)', NEW.seq, NEW.run_id, run_last_seq + USING ERRCODE = 'integrity_constraint_violation'; + END IF; + IF NEW.attempt > run_attempt THEN + RAISE EXCEPTION 'event attempt % exceeds run % attempt %', NEW.attempt, NEW.run_id, run_attempt + USING ERRCODE = 'integrity_constraint_violation'; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER trg_ai_run_events_guard_insert + BEFORE INSERT ON ai_run_events + FOR EACH ROW EXECUTE FUNCTION ai_run_events_guard_insert(); + +CREATE TRIGGER trg_ai_run_events_no_direct_delete + BEFORE DELETE ON ai_run_events + FOR EACH ROW EXECUTE FUNCTION proovy_reject_direct_delete(); + -- ===================================================================== -- Verified Solution (insert-only 정정 체인) -- ===================================================================== @@ -612,7 +663,8 @@ CREATE TABLE verified_solutions ( claims JSONB NOT NULL CONSTRAINT ck_verified_solutions_claims CHECK ( jsonb_typeof(claims) = 'array' AND jsonb_array_length(claims) >= 1 - AND NOT jsonb_path_exists(claims, '$[*] ? (!(@.coverage == "direct" || @.coverage == "supporting"))') + AND NOT jsonb_path_exists(claims, + '$[*] ? (!(@.coverage.type() == "string" && (@.coverage == "direct" || @.coverage == "supporting")))') ), step_marks JSONB NOT NULL DEFAULT '[]'::JSONB CONSTRAINT ck_verified_solutions_step_marks CHECK (jsonb_typeof(step_marks) = 'array'), @@ -700,8 +752,7 @@ CREATE TABLE request_usage_charges ( CONSTRAINT ck_request_usage_charges_request_id CHECK (request_id ~ '^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$'), user_id BIGINT NOT NULL REFERENCES users (user_id) ON DELETE CASCADE, -- Note 삭제로 run이 사라져도 차감 이력·request 멱등 키는 남는다. - run_id UUID CONSTRAINT uq_request_usage_charges_run UNIQUE - REFERENCES ai_runs (run_id) ON DELETE SET NULL, + run_id UUID CONSTRAINT uq_request_usage_charges_run UNIQUE, usage_units INTEGER NOT NULL CONSTRAINT ck_request_usage_charges_units CHECK (usage_units > 0), credit_amount INTEGER NOT NULL CONSTRAINT ck_request_usage_charges_amount CHECK (credit_amount > 0), charged_daily INTEGER NOT NULL DEFAULT 0 CONSTRAINT ck_request_usage_charges_daily CHECK (charged_daily >= 0), @@ -709,6 +760,9 @@ CREATE TABLE request_usage_charges ( charged_paid INTEGER NOT NULL DEFAULT 0 CONSTRAINT ck_request_usage_charges_paid CHECK (charged_paid >= 0), charged_at TIMESTAMPTZ NOT NULL DEFAULT now(), CONSTRAINT uq_request_usage_charges_amount UNIQUE (request_id, credit_amount), + -- run은 같은 request_id·user_id여야 한다. run 삭제 시 run_id만 끊는다(PG15+ column list). + CONSTRAINT fk_request_usage_charges_run FOREIGN KEY (run_id, request_id, user_id) + REFERENCES ai_runs (run_id, request_id, user_id) ON DELETE SET NULL (run_id), CONSTRAINT ck_request_usage_charges_breakdown CHECK (charged_daily + charged_free + charged_paid = credit_amount) ); @@ -751,3 +805,12 @@ CREATE TRIGGER trg_request_usage_charges_guard_update CREATE TRIGGER trg_request_usage_compensations_immutable BEFORE UPDATE ON request_usage_compensations FOR EACH ROW EXECUTE FUNCTION proovy_reject_update(); + +-- ledger row를 지워 차감·보상을 다시 하는 것을 막는다. users 삭제 cascade만 허용한다. +CREATE TRIGGER trg_request_usage_charges_no_direct_delete + BEFORE DELETE ON request_usage_charges + FOR EACH ROW EXECUTE FUNCTION proovy_reject_direct_delete(); + +CREATE TRIGGER trg_request_usage_compensations_no_direct_delete + BEFORE DELETE ON request_usage_compensations + FOR EACH ROW EXECUTE FUNCTION proovy_reject_direct_delete(); diff --git a/src/test/java/com/proovy/db/ProductConstraintsPostgresTest.java b/src/test/java/com/proovy/db/ProductConstraintsPostgresTest.java index 9320c62d..f95d8fa2 100644 --- a/src/test/java/com/proovy/db/ProductConstraintsPostgresTest.java +++ b/src/test/java/com/proovy/db/ProductConstraintsPostgresTest.java @@ -313,6 +313,23 @@ void assistantPlaceholderHasNoBodyAndCompletedMessageIsImmutable() throws SQLExc "UPDATE chat_messages SET status = 'COMPLETED', completed_at = now() WHERE message_id = ?", turn.assistantMessageId())); + // response_pages·source_references 중 하나라도 NULL이면 COMPLETED가 될 수 없다 + expectViolation(CHECK_VIOLATION, "ck_chat_messages_assistant_completed", () -> update(c, """ + UPDATE chat_messages SET status = 'COMPLETED', completed_at = now(), source_references = '[]', + plain_text = 'x', content_sha256 = ? WHERE message_id = ?""", "e".repeat(64), turn.assistantMessageId())); + expectViolation(CHECK_VIOLATION, "ck_chat_messages_assistant_completed", () -> update(c, """ + UPDATE chat_messages SET status = 'COMPLETED', completed_at = now(), + response_pages = '[{"response_page_id":"page-1","presentation":"main","result_blocks":[]}]', + plain_text = 'x', content_sha256 = ? WHERE message_id = ?""", "e".repeat(64), turn.assistantMessageId())); + // USER 메시지는 입력 원문 없이 저장할 수 없다 + expectViolation(CHECK_VIOLATION, "ck_chat_messages_user", () -> update(c, """ + INSERT INTO chat_messages (message_id, note_id, role, status, plain_text) VALUES (?, ?, 'USER', 'COMPLETED', 'x') + """, UUID.randomUUID(), ALICE_NOTE)); + expectViolation(CHECK_VIOLATION, "ck_chat_messages_user", () -> update(c, """ + INSERT INTO chat_messages (message_id, note_id, role, status, user_input, plain_text) + VALUES (?, ?, 'USER', 'COMPLETED', '{"latex":"x"}', 'x') + """, UUID.randomUUID(), ALICE_NOTE)); + completeAssistant(c, turn.assistantMessageId(), "[]"); expectViolation(TRIGGER_VIOLATION, "immutable", () -> update(c, "UPDATE chat_messages SET plain_text = 'edited' WHERE message_id = ?", turn.assistantMessageId())); @@ -376,6 +393,14 @@ void terminalClassificationConstraints() throws SQLException { "UPDATE ai_runs SET status = 'COMPLETED', response_disposition = 'unsupported_only', clarification_reason = 'missing_input'" + release + "WHERE run_id = ?", runId)); expectViolation(CHECK_VIOLATION, "ck_ai_runs_failure_code_class", () -> update(c, "UPDATE ai_runs SET status = 'FAILED', failure_code = 'timeout', failure_class = 'verification'" + release + "WHERE run_id = ?", runId)); + // disposition·class가 NULL인 진행 중 run에도 분류 짝 규칙이 적용된다 + expectViolation(CHECK_VIOLATION, "ck_ai_runs_clarification_reason_pair", () -> update(c, + "UPDATE ai_runs SET clarification_reason = 'missing_input' WHERE run_id = ?", runId)); + // class 없는 code는 code-class 대응 검사(COALESCE)와 짝 검사 중 먼저 평가되는 쪽이 거부한다 + expectViolation(CHECK_VIOLATION, "ck_ai_runs_failure_", () -> update(c, + "UPDATE ai_runs SET failure_code = 'timeout' WHERE run_id = ?", runId)); + expectViolation(CHECK_VIOLATION, "ck_ai_runs_failure_pair", () -> update(c, + "UPDATE ai_runs SET failure_class = 'budget' WHERE run_id = ?", runId)); // PENDING 취소가 아닌 CANCELING은 실행 상태에서만 시작한다 UUID pending = insertRunWithTurn(c, "req-class-2", ALICE, ALICE_SECOND_NOTE); expectViolation(TRIGGER_VIOLATION, "invalid run status transition", () -> update(c, """ @@ -399,6 +424,9 @@ void eventSeqAndSourceEventAreUniquePerRun() throws SQLException { expectViolation(UNIQUE_VIOLATION, "uq_ai_run_events_seq", () -> insertServerEvent(c, runId, ALICE_NOTE, 1, "run.canceling")); + // Brain Activity는 run이 시작한 attempt 안에서만 온다 + expectViolation(TRIGGER_VIOLATION, "exceeds run", () -> insertActivity(c, runId, 2, "src-evt-1")); + claim(c, runId); assertThat(insertActivity(c, runId, 2, "src-evt-1")).isEqualTo(1); // 같은 source event 재수신은 row도 seq도 늘리지 않는다 assertThat(insertActivity(c, runId, 3, "src-evt-1")).isZero(); @@ -411,6 +439,14 @@ void eventSeqAndSourceEventAreUniquePerRun() throws SQLException { expectViolation(TRIGGER_VIOLATION, "immutable", () -> update(c, "UPDATE ai_run_events SET payload = '{}' WHERE run_id = ?", runId)); + // append-only: 직접 DELETE는 거부한다 (run/Note 삭제 cascade만 허용) + expectViolation(TRIGGER_VIOLATION, "direct DELETE", + () -> update(c, "DELETE FROM ai_run_events WHERE run_id = ?", runId)); + // last_event_seq로 배정하지 않은 seq는 쓸 수 없다 + expectViolation(TRIGGER_VIOLATION, "not allocated", () -> update(c, + "INSERT INTO ai_run_events (run_id, note_id, seq, event_type) VALUES (?, ?, 99, 'run.canceling')", + runId, ALICE_NOTE)); + allocateSeq(c, runId, 10); // Activity는 dedup 키·attempt가 필요하고 server 소유 event는 source id를 가질 수 없다 expectViolation(CHECK_VIOLATION, "ck_ai_run_events_activity", () -> update(c, """ INSERT INTO ai_run_events (run_id, note_id, seq, event_type, activity_id) VALUES (?, ?, 9, 'activity.updated', 'activity-1') @@ -433,6 +469,12 @@ void chargeOncePerRequestAndCompensateAtMostOnce() throws SQLException { insertCharge(c, "req-usage-1", ALICE, runId, 1, 0, 0); expectViolation(UNIQUE_VIOLATION, "request_usage_charges_pkey", () -> insertCharge(c, "req-usage-1", ALICE, null, 1, 0, 0)); + // 차감은 같은 request_id·user의 run만 가리킨다 + UUID otherRun = insertRunWithTurn(c, "req-usage-2", ALICE, ALICE_SECOND_NOTE); + expectViolation(FK_VIOLATION, "fk_request_usage_charges_run", + () -> insertCharge(c, "req-usage-other", ALICE, otherRun, 1, 0, 0)); + expectViolation(FK_VIOLATION, "fk_request_usage_charges_run", + () -> insertCharge(c, "req-usage-2", BOB, otherRun, 1, 0, 0)); // 차감 없는 보상·차감량과 다른 보상은 거부된다 expectViolation(FK_VIOLATION, "fk_request_usage_compensations_charge", @@ -453,6 +495,25 @@ INSERT INTO request_usage_compensations (request_id, credit_amount, reason, rest () -> update(c, "UPDATE request_usage_charges SET charged_daily = 0, charged_free = 1 WHERE request_id = 'req-usage-1'")); expectViolation(TRIGGER_VIOLATION, "immutable", () -> update(c, "UPDATE request_usage_compensations SET reason = 'BUDGET_EXCEEDED' WHERE request_id = 'req-usage-1'")); + // ledger row를 지워 다시 보상·차감할 수 없다 + expectViolation(TRIGGER_VIOLATION, "direct DELETE", + () -> update(c, "DELETE FROM request_usage_compensations WHERE request_id = 'req-usage-1'")); + expectViolation(TRIGGER_VIOLATION, "direct DELETE", + () -> update(c, "DELETE FROM request_usage_charges WHERE request_id = 'req-usage-1'")); + } + + @Test + void userDeletionCascadesLedgerAndRunEvents() throws SQLException { + UUID runId = insertRunWithTurn(c, "req-user-delete", BOB, BOB_NOTE); + insertServerEvent(c, runId, BOB_NOTE, 1, "run.created"); + insertCharge(c, "req-user-delete", BOB, runId, 1, 0, 0); + insertCompensation(c, "req-user-delete", "PENDING_CANCELED", 1); + + update(c, "DELETE FROM users WHERE user_id = ?", BOB); + + assertThat(count(c, "SELECT count(*) FROM ai_run_events WHERE run_id = ?", runId)).isZero(); + assertThat(count(c, "SELECT count(*) FROM request_usage_charges WHERE user_id = ?", BOB)).isZero(); + assertThat(count(c, "SELECT count(*) FROM request_usage_compensations WHERE request_id = 'req-user-delete'")).isZero(); } @Test @@ -484,7 +545,6 @@ INSERT INTO request_usage_compensations (request_id, credit_amount, reason, rest assertThat(applied).isEqualTo(1); try (Connection check = db.connect()) { assertThat(count(check, "SELECT count(*) FROM request_usage_compensations WHERE request_id = ?", requestId)).isEqualTo(1); - update(check, "DELETE FROM request_usage_charges WHERE request_id = ?", requestId); } } @@ -504,6 +564,11 @@ void verifiedSolutionsAreInsertOnlyWithAcceptedCoverageAndLinearRevisions() thro turn.assistantMessageId(), "solution-2", "[{\"claim_id\":\"claim-1\",\"coverage\":\"inconclusive\"}]", "direct", null)); expectViolation(CHECK_VIOLATION, "ck_verified_solutions_claims", () -> insertSolution(c, runId, turn.assistantMessageId(), "solution-2", "[]", "direct", null)); + // coverage는 문자열 direct|supporting이어야 한다 (숫자·누락·비객체 claim 거부) + for (String claims : List.of("[{\"claim_id\":\"claim-1\",\"coverage\":1}]", "[{\"claim_id\":\"claim-1\"}]", "[1]")) { + expectViolation(CHECK_VIOLATION, "ck_verified_solutions_claims", () -> insertSolution(c, runId, + turn.assistantMessageId(), "solution-2", claims, "direct", null)); + } expectViolation(CHECK_VIOLATION, "ck_verified_solutions_overall_matches", () -> insertSolution(c, runId, turn.assistantMessageId(), "solution-2", supporting, "direct", null)); @@ -540,6 +605,13 @@ void turnAttachmentsBelongToUserMessagesWithValidatedMedia() throws SQLException insertAttachment(c, turn.userMessageId(), 1, "image/png", null, 20, 10, 200L); expectViolation(CHECK_VIOLATION, "ck_turn_attachments_media", () -> insertAttachment(c, turn.userMessageId(), 2, "image/png", null, 20, 10, 999L)); + // NULL metadata로 media 규칙을 우회할 수 없다 + expectViolation(CHECK_VIOLATION, "ck_turn_attachments_media", + () -> insertAttachment(c, turn.userMessageId(), 2, "application/pdf", null, null, null, null)); + expectViolation(CHECK_VIOLATION, "ck_turn_attachments_media", + () -> insertAttachment(c, turn.userMessageId(), 2, "image/png", null, 20, 10, null)); + expectViolation(CHECK_VIOLATION, "ck_turn_attachments_media", + () -> insertAttachment(c, turn.userMessageId(), 2, "image/png", null, null, 10, 200L)); expectViolation(CHECK_VIOLATION, "ck_turn_attachments_mime", () -> insertAttachment(c, turn.userMessageId(), 2, "image/heic", null, 20, 10, 200L)); expectViolation(UNIQUE_VIOLATION, "uq_turn_attachments_message_index", @@ -631,13 +703,20 @@ INSERT INTO run_asset_dependencies (run_id, note_id, asset_id, dependency_kind, """, runId, noteId, assetId, kind); } + /** 1.80 규약대로 event seq를 ai_runs.last_event_seq에서 먼저 배정한다. */ + private static void allocateSeq(Connection conn, UUID runId, long seq) throws SQLException { + update(conn, "UPDATE ai_runs SET last_event_seq = GREATEST(last_event_seq, ?) WHERE run_id = ?", seq, runId); + } + private static void insertServerEvent(Connection conn, UUID runId, long noteId, long seq, String type) throws SQLException { + allocateSeq(conn, runId, seq); update(conn, "INSERT INTO ai_run_events (run_id, note_id, seq, event_type) VALUES (?, ?, ?, ?)", runId, noteId, seq, type); } private static int insertActivity(Connection conn, UUID runId, long seq, String sourceEventId) throws SQLException { + allocateSeq(conn, runId, seq); return update(conn, """ INSERT INTO ai_run_events (run_id, note_id, seq, event_type, source_event_id, attempt, activity_id, payload) SELECT ?, note_id, ?, 'activity.started', ?, 1, 'activity-1', '{"kind":"input_analysis"}' diff --git a/src/test/java/com/proovy/db/ProductSchemaContextPostgresTest.java b/src/test/java/com/proovy/db/ProductSchemaContextPostgresTest.java index 49ed1d21..0bd8894e 100644 --- a/src/test/java/com/proovy/db/ProductSchemaContextPostgresTest.java +++ b/src/test/java/com/proovy/db/ProductSchemaContextPostgresTest.java @@ -34,6 +34,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; @@ -100,6 +101,7 @@ static void dropDatabase() { @Autowired RequestUsageChargeRepository chargeRepository; @Autowired RequestUsageCompensationRepository compensationRepository; @Autowired TransactionTemplate tx; + @Autowired JdbcTemplate jdbc; @Autowired ObjectMapper objectMapper; @Test @@ -127,6 +129,11 @@ void noteCentricModelRoundTripsWithoutChatSession() { .budgetPolicyVersion("budget-v1") .requestDeadlineAt(now.plusSeconds(600)).createdAt(now) .build()); + aiRunRepository.flush(); + // event seq는 1.80 규약대로 last_event_seq 증가로 먼저 배정한다 + assertThat(jdbc.queryForObject( + "UPDATE ai_runs SET last_event_seq = last_event_seq + 1 WHERE run_id = ? RETURNING last_event_seq", + Long.class, runId)).isEqualTo(1L); aiRunEventRepository.save(AiRunEvent.builder() .runId(runId).noteId(note.getId()).seq(1).eventType(AiRunEventType.RUN_CREATED) .payload(objectMapper.createObjectNode().put("status", "PENDING")).createdAt(now) @@ -180,6 +187,74 @@ void noteCentricModelRoundTripsWithoutChatSession() { assertThat(compensationRepository.findById(requestId).orElseThrow().getCreditAmount()).isEqualTo(1); } + @Test + void assignedIdSaveInsertsAndRejectsDuplicatesInsteadOfMerging() { + Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS); + UUID userMessageId = UUID.randomUUID(); + UUID assistantMessageId = UUID.randomUUID(); + UUID runId = UUID.randomUUID(); + String requestId = UUID.randomUUID().toString(); + ObjectNode input = objectMapper.createObjectNode().put("text", "중복 저장"); + + Long noteId = tx.execute(status -> { + User user = userRepository.save(User.builder().nickname("assigned").build()); + Note note = noteRepository.save(Note.builder().user(user).title("assigned note").build()); + chatMessageRepository.save(ChatMessage.user(userMessageId, note, input, now)); + chatMessageRepository.save(ChatMessage.assistantPlaceholder(assistantMessageId, note, now.plusMillis(1))); + aiRunRepository.save(pendingRun(runId, requestId, user.getId(), note.getId(), userMessageId, assistantMessageId, now)); + chargeRepository.save(charge(requestId, user.getId(), runId, now)); + return note.getId(); + }); + Long userId = noteRepository.findById(noteId).orElseThrow().getUser().getId(); + + // 같은 할당형 ID로 새 인스턴스를 save하면 merge로 흡수되지 않고 PK 위반이 드러난다 + assertThatThrownBy(() -> chargeRepository.saveAndFlush(charge(requestId, userId, null, now))) + .isInstanceOf(DataIntegrityViolationException.class); + compensationRepository.saveAndFlush(compensation(requestId, UsageCompensationReason.PENDING_CANCELED, now)); + assertThatThrownBy(() -> compensationRepository.saveAndFlush( + compensation(requestId, UsageCompensationReason.INFRASTRUCTURE_FAILURE, now))) + .isInstanceOf(DataIntegrityViolationException.class); + assertThat(compensationRepository.findById(requestId).orElseThrow().getReason()) + .isEqualTo(UsageCompensationReason.PENDING_CANCELED); + assertThatThrownBy(() -> tx.executeWithoutResult(status -> chatMessageRepository.saveAndFlush( + ChatMessage.user(userMessageId, noteRepository.findById(noteId).orElseThrow(), input, now)))) + .isInstanceOf(DataIntegrityViolationException.class); + assertThatThrownBy(() -> aiRunRepository.saveAndFlush( + pendingRun(runId, UUID.randomUUID().toString(), userId, noteId, userMessageId, assistantMessageId, now))) + .isInstanceOf(DataIntegrityViolationException.class); + + // 조회한 엔티티는 기존 row로 취급되어 다시 save해도 INSERT하지 않는다 + tx.executeWithoutResult(status -> { + ChatMessage loaded = chatMessageRepository.findById(assistantMessageId).orElseThrow(); + assertThat(loaded.isNew()).isFalse(); + chatMessageRepository.saveAndFlush(loaded); + }); + assertThat(chatMessageRepository.countByNoteId(noteId)).isEqualTo(2); + assertThat(chargeRepository.findById(requestId).orElseThrow().getRunId()).isEqualTo(runId); + } + + private AiRun pendingRun(UUID runId, String requestId, Long userId, Long noteId, + UUID userMessageId, UUID assistantMessageId, Instant now) { + return AiRun.pending() + .id(runId).requestId(requestId).userId(userId).noteId(noteId) + .userMessageId(userMessageId).assistantMessageId(assistantMessageId) + .requestFingerprint("4".repeat(64)).requestSnapshot(objectMapper.createObjectNode()) + .policySnapshot(objectMapper.createObjectNode()).budgetPolicyVersion("budget-v1") + .requestDeadlineAt(now.plusSeconds(600)).createdAt(now).build(); + } + + private static RequestUsageCharge charge(String requestId, Long userId, UUID runId, Instant now) { + return RequestUsageCharge.builder() + .requestId(requestId).userId(userId).runId(runId).usageUnits(1) + .chargedDaily(1).chargedFree(0).chargedPaid(0).chargedAt(now).build(); + } + + private static RequestUsageCompensation compensation(String requestId, UsageCompensationReason reason, Instant now) { + return RequestUsageCompensation.builder() + .requestId(requestId).reason(reason) + .restoredDaily(1).restoredFree(0).restoredPaid(0).compensatedAt(now).build(); + } + @Test void verifiedSolutionAndDependencyEntitiesMapToBaseline() { Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS); From 8e7fe822dc372cf8e1fd88d9dfe650144d3679da Mon Sep 17 00:00:00 2001 From: chowon442 Date: Sat, 17 Oct 2026 15:49:38 +0900 Subject: [PATCH 04/34] =?UTF-8?q?feat:=20=EC=9A=94=EC=B2=AD=EB=B3=84=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EB=9F=89=20=EC=84=A0=EC=B0=A8=EA=B0=90=C2=B7?= =?UTF-8?q?=EB=A9=B1=EB=93=B1=20=EB=B3=B4=EC=83=81=20ledger=20=EC=97=B0?= =?UTF-8?q?=EC=82=B0=EA=B3=BC=20=ED=98=B8=EC=B6=9C=20=EA=B2=BD=EA=B3=84=20?= =?UTF-8?q?(#216)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RequestUsageService: chargeRequest / compensate / settle(Terminal) — 모두 MANDATORY로 호출자 transaction에 참여하고 독립 선commit이 없다. 잔액 row FOR UPDATE → ledger 순서로 잠근다. - 보상 복구 분배는 restored_* = charged_* (1.74 R2 인계). - UsageCompensationPolicy: disposition·failure_class·취소 시점/원인별 결정표. - 잔액 row가 없으면 REQUIRES_NEW 대신 같은 transaction에서 ON CONFLICT DO NOTHING으로 생성. - 차감/보상을 credit_history에 버킷별 SPEND/EARN으로 같은 transaction에서 기록. - 브라우저 임의 차감 POST /api/credits/use와 구형 기능별 비용표(/costs, /estimate, CreditCostService, useCredit/calculateCost)를 제거 (ADR 0015). Co-Authored-By: Claude Opus 5.5 --- .../credit/controller/CreditController.java | 93 +------- .../credit/dto/request/CreditUseRequest.java | 33 --- .../dto/response/CreditCostResponse.java | 66 ------ .../dto/response/CreditUseResponse.java | 47 ---- .../domain/credit/entity/CreditEventType.java | 5 +- .../repository/CreditBalanceRepository.java | 15 ++ .../credit/service/CreditBalanceCreator.java | 16 ++ .../credit/service/CreditBalanceService.java | 19 ++ .../credit/service/CreditCostService.java | 93 -------- .../credit/service/CreditUseService.java | 191 +-------------- .../usage/entity/UsageCompensationReason.java | 23 +- .../InsufficientUsageBalanceException.java | 20 ++ .../RequestUsageConflictException.java | 12 + .../usage/service/RequestUsageService.java | 222 ++++++++++++++++++ .../usage/service/RunTerminalOutcome.java | 71 ++++++ .../usage/service/UsageChargeResult.java | 10 + .../service/UsageCompensationPolicy.java | 60 +++++ .../service/UsageCompensationResult.java | 29 +++ 18 files changed, 501 insertions(+), 524 deletions(-) delete mode 100644 src/main/java/com/proovy/domain/credit/dto/request/CreditUseRequest.java delete mode 100644 src/main/java/com/proovy/domain/credit/dto/response/CreditCostResponse.java delete mode 100644 src/main/java/com/proovy/domain/credit/dto/response/CreditUseResponse.java delete mode 100644 src/main/java/com/proovy/domain/credit/service/CreditCostService.java create mode 100644 src/main/java/com/proovy/domain/usage/exception/InsufficientUsageBalanceException.java create mode 100644 src/main/java/com/proovy/domain/usage/exception/RequestUsageConflictException.java create mode 100644 src/main/java/com/proovy/domain/usage/service/RequestUsageService.java create mode 100644 src/main/java/com/proovy/domain/usage/service/RunTerminalOutcome.java create mode 100644 src/main/java/com/proovy/domain/usage/service/UsageChargeResult.java create mode 100644 src/main/java/com/proovy/domain/usage/service/UsageCompensationPolicy.java create mode 100644 src/main/java/com/proovy/domain/usage/service/UsageCompensationResult.java 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..ecb9776f 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,21 @@ public interface CreditBalanceRepository extends JpaRepository findByUserIdForUpdate(@Param("userId") Long userId); + /** + * 호출자 transaction 안에서 잔액 row를 만든다. 이미 있으면(동시 생성 포함) 아무것도 하지 않는다. + * REQUIRES_NEW로 따로 commit하지 않으므로 호출자 rollback 시 함께 사라진다. + */ + @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/service/CreditBalanceCreator.java b/src/main/java/com/proovy/domain/credit/service/CreditBalanceCreator.java index 2d94c9ee..c5f9a7ac 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); + LocalDateTime now = nowInBillingZone(); + creditBalanceRepository.insertInitialIfAbsent( + userId, planType.getDailyCreditLimit(), nextDailyResetAt(now), 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..dce32489 100644 --- a/src/main/java/com/proovy/domain/credit/service/CreditBalanceService.java +++ b/src/main/java/com/proovy/domain/credit/service/CreditBalanceService.java @@ -14,6 +14,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; @@ -62,6 +63,24 @@ public CreditBalance getOrCreateBalanceForUpdate(Long userId) { return balance; } + /** + * 호출자 transaction 안에서 잔액 row를 잠그고(없으면 같은 transaction에서 생성) 만료된 일일 크레딧을 갱신한다. + * {@link #getOrCreateBalanceForUpdate}와 달리 REQUIRES_NEW 생성을 쓰지 않아 독립 commit이 없다. + * 요청 사용량 차감·보상({@code RequestUsageService})이 쓴다. + */ + @Transactional(propagation = Propagation.MANDATORY) + public CreditBalance lockBalanceInCurrentTransaction(Long userId) { + CreditBalance balance = creditBalanceRepository.findByUserIdForUpdate(userId) + .orElseGet(() -> { + creditBalanceCreator.insertInitialBalanceIfAbsent(userId); + return creditBalanceRepository.findByUserIdForUpdate(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.CREDIT4041)); + }); + + refreshDailyCreditIfExpired(balance, nowInBillingZone()); + return balance; + } + @Transactional public CreditBalance createSignupBalance(User user) { // FREE 플랜의 dailyCreditLimit 사용 (100) 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/CreditUseService.java b/src/main/java/com/proovy/domain/credit/service/CreditUseService.java index fa1b414e..87683d51 100644 --- a/src/main/java/com/proovy/domain/credit/service/CreditUseService.java +++ b/src/main/java/com/proovy/domain/credit/service/CreditUseService.java @@ -1,51 +1,22 @@ 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 - ); /** * 크레딧 잔액 조회 @@ -69,158 +40,4 @@ public CreditBalanceResponse getBalance(Long userId, Integer checkCost) { .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/usage/entity/UsageCompensationReason.java b/src/main/java/com/proovy/domain/usage/entity/UsageCompensationReason.java index c42e5991..2a749b1c 100644 --- a/src/main/java/com/proovy/domain/usage/entity/UsageCompensationReason.java +++ b/src/main/java/com/proovy/domain/usage/entity/UsageCompensationReason.java @@ -1,15 +1,24 @@ package com.proovy.domain.usage.entity; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + /** * request당 최대 한 번인 Usage Compensation의 사유 (docs/plan/07). * RUNNING·RETRYING 이후 사용자 취소와 substantive 응답은 보상하지 않으므로 여기에 없다. + * 결정표는 {@code com.proovy.domain.usage.service.UsageCompensationPolicy}에 있다. */ +@Getter +@RequiredArgsConstructor public enum UsageCompensationReason { - PENDING_CANCELED, - CLARIFICATION_ONLY, - UNSUPPORTED_ONLY, - VERIFICATION_FAILED, - BUDGET_EXCEEDED, - INVALID_ATTACHMENT, - INFRASTRUCTURE_FAILURE + PENDING_CANCELED("시작 전 취소"), + CLARIFICATION_ONLY("추가 정보 요청 응답"), + UNSUPPORTED_ONLY("지원하지 않는 요청 안내"), + VERIFICATION_FAILED("검증 실패"), + BUDGET_EXCEEDED("처리 한도 초과"), + INVALID_ATTACHMENT("첨부 검증 실패"), + INFRASTRUCTURE_FAILURE("일시적 처리 실패"); + + /** 사용자 크레딧 내역에 보이는 보상 사유 */ + private final String description; } diff --git a/src/main/java/com/proovy/domain/usage/exception/InsufficientUsageBalanceException.java b/src/main/java/com/proovy/domain/usage/exception/InsufficientUsageBalanceException.java new file mode 100644 index 00000000..2f994689 --- /dev/null +++ b/src/main/java/com/proovy/domain/usage/exception/InsufficientUsageBalanceException.java @@ -0,0 +1,20 @@ +package com.proovy.domain.usage.exception; + +import lombok.Getter; + +/** + * 잔액이 요청 한 단위보다 적다. 차감·ledger row를 만들지 않았고, 호출자 transaction은 rollback-only가 된다. + * 접수 경로(1.79)가 브라우저 계약의 잔액 부족 오류로 변환한다. + */ +@Getter +public class InsufficientUsageBalanceException extends RuntimeException { + + private final int required; + private final int available; + + public InsufficientUsageBalanceException(int required, int available) { + super("insufficient usage balance: required=" + required + ", available=" + available); + this.required = required; + this.available = available; + } +} diff --git a/src/main/java/com/proovy/domain/usage/exception/RequestUsageConflictException.java b/src/main/java/com/proovy/domain/usage/exception/RequestUsageConflictException.java new file mode 100644 index 00000000..a8fe9619 --- /dev/null +++ b/src/main/java/com/proovy/domain/usage/exception/RequestUsageConflictException.java @@ -0,0 +1,12 @@ +package com.proovy.domain.usage.exception; + +/** + * request_id의 기존 차감이 다른 사용자 또는 다른 run에 속한다. request_id는 전역 unique이므로 + * 호출자는 상세를 노출하지 않고 거부한다(1.75 D7: 409 RUN4092). + */ +public class RequestUsageConflictException extends RuntimeException { + + public RequestUsageConflictException(String message) { + super(message); + } +} diff --git a/src/main/java/com/proovy/domain/usage/service/RequestUsageService.java b/src/main/java/com/proovy/domain/usage/service/RequestUsageService.java new file mode 100644 index 00000000..5068929e --- /dev/null +++ b/src/main/java/com/proovy/domain/usage/service/RequestUsageService.java @@ -0,0 +1,222 @@ +package com.proovy.domain.usage.service; + +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 com.proovy.domain.credit.service.CreditBalanceService; +import com.proovy.domain.run.entity.AiRun; +import com.proovy.domain.usage.entity.RequestUsageCharge; +import com.proovy.domain.usage.entity.RequestUsageCompensation; +import com.proovy.domain.usage.entity.UsageCompensationReason; +import com.proovy.domain.usage.exception.InsufficientUsageBalanceException; +import com.proovy.domain.usage.exception.RequestUsageConflictException; +import com.proovy.domain.usage.repository.RequestUsageChargeRepository; +import com.proovy.domain.usage.repository.RequestUsageCompensationRepository; +import com.proovy.domain.usage.service.UsageCompensationResult.Outcome; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; + +/** + * 일반 AI 요청의 사용량 ledger 연산 (request_id당 최초 한 번 차감, 최대 한 번 보상). + * + *

transaction 참여 규칙: 모든 연산은 {@link Propagation#MANDATORY}다. 호출자(접수 1.79·terminal 1.82· + * 취소 1.84)가 연 transaction 안에서 run/message 전환과 함께 commit되거나 함께 rollback되며, 이 서비스가 + * 독립적으로 먼저 commit하는 경로는 없다. 예외를 던지면 호출자 transaction은 rollback-only가 된다. + * + *

잠금 순서: 사용자 잔액 row({@code credit_balance FOR UPDATE})를 잡은 뒤 ledger를 조회·기록한다. + * 같은 사용자의 차감·보상은 잔액 잠금으로 직렬화되고, ledger PK(request_id)가 최종 중복 방어다. + * 호출자는 run 조건부 UPDATE 등 다른 row 잠금을 먼저 잡고 이 연산을 transaction 마지막 쪽에서 호출한다. + * + *

호출 경계: 일반 AI 요청 차감은 접수 transaction의 {@link #chargeRequest}만 수행한다. + * 구형 기능별 사후 차감·브라우저 임의 차감 API는 제거했고, 모델 승급·살아 있는 실행 재시도·상태 조회는 + * 이 서비스를 호출하지 않는다. provider 비용 정산(ai_runs.used_cost_micros 등)은 제품 사용량과 무관하다. + */ +@Slf4j +@Service +public class RequestUsageService { + + /** 일반 요청은 request당 한 단위 (docs/plan/07) */ + public static final int GENERAL_REQUEST_UNITS = 1; + + private final CreditBalanceService creditBalanceService; + private final RequestUsageChargeRepository chargeRepository; + private final RequestUsageCompensationRepository compensationRepository; + private final CreditHistoryRepository creditHistoryRepository; + private final int creditsPerUnit; + + public RequestUsageService(CreditBalanceService creditBalanceService, + RequestUsageChargeRepository chargeRepository, + RequestUsageCompensationRepository compensationRepository, + CreditHistoryRepository creditHistoryRepository, + @Value("${proovy.usage.credits-per-unit:10}") int creditsPerUnit) { + if (creditsPerUnit <= 0) { + throw new IllegalArgumentException("proovy.usage.credits-per-unit must be positive"); + } + this.creditBalanceService = creditBalanceService; + this.chargeRepository = chargeRepository; + this.compensationRepository = compensationRepository; + this.creditHistoryRepository = creditHistoryRepository; + this.creditsPerUnit = creditsPerUnit; + } + + /** + * 접수 transaction에서 request 한 단위를 차감한다. run row는 같은 transaction에서 먼저 insert되어 있어야 한다 + * (차감 row의 run composite FK). 같은 request의 차감이 이미 있으면 잔액을 바꾸지 않고 기존 차감을 돌려준다. + * + * @throws InsufficientUsageBalanceException 잔액 부족. 차감·ledger·내역을 만들지 않는다. + * @throws RequestUsageConflictException 기존 차감이 다른 사용자·run에 속한다. + */ + @Transactional(propagation = Propagation.MANDATORY) + public UsageChargeResult chargeRequest(Long userId, String requestId, UUID runId) { + Objects.requireNonNull(userId, "userId"); + Objects.requireNonNull(requestId, "requestId"); + Objects.requireNonNull(runId, "runId"); + + CreditBalance balance = creditBalanceService.lockBalanceInCurrentTransaction(userId); + + Optional existing = chargeRepository.findById(requestId); + if (existing.isPresent()) { + RequestUsageCharge charge = existing.get(); + if (!userId.equals(charge.getUserId()) || !runId.equals(charge.getRunId())) { + throw new RequestUsageConflictException("request usage belongs to another user or run"); + } + return new UsageChargeResult(false, charge); + } + + int amount = GENERAL_REQUEST_UNITS * creditsPerUnit; + int available = balance.getTotalAvailable(); + if (available < amount) { + throw new InsufficientUsageBalanceException(amount, available); + } + + int dailyBefore = balance.getDailyFreeCredit(); + int freeBefore = balance.getFreeCredit(); + int paidBefore = balance.getPaidCredit(); + balance.deductCredit(amount); + + RequestUsageCharge charge = chargeRepository.saveAndFlush(RequestUsageCharge.builder() + .requestId(requestId) + .userId(userId) + .runId(runId) + .usageUnits(GENERAL_REQUEST_UNITS) + .chargedDaily(dailyBefore - balance.getDailyFreeCredit()) + .chargedFree(freeBefore - balance.getFreeCredit()) + .chargedPaid(paidBefore - balance.getPaidCredit()) + .chargedAt(now()) + .build()); + + saveHistory(balance, CreditEventType.LLM_QUERY, CreditChangeType.SPEND, null, + charge.getChargedDaily(), charge.getChargedFree(), charge.getChargedPaid()); + log.info("request usage charged: userId={}, requestId={}, amount={}", userId, requestId, amount); + return new UsageChargeResult(true, charge); + } + + /** + * terminal 전환이 반영된 run의 분류로 보상 여부를 정하고, 대상이면 한 번 보상한다. + * 호출자는 같은 transaction에서 run을 terminal로 바꾼 뒤(조건부 UPDATE가 이긴 경우에만) 호출한다. + */ + @Transactional(propagation = Propagation.MANDATORY) + public UsageCompensationResult settleTerminal(AiRun run) { + return settle(run.getUserId(), run.getRequestId(), RunTerminalOutcome.of(run)); + } + + /** {@link #settleTerminal(AiRun)}과 같고, run 엔티티 대신 terminal 분류를 직접 받는다. */ + @Transactional(propagation = Propagation.MANDATORY) + public UsageCompensationResult settle(Long userId, String requestId, RunTerminalOutcome outcome) { + Optional reason = UsageCompensationPolicy.decide(outcome); + if (reason.isEmpty()) { + return UsageCompensationResult.of(Outcome.NOT_COMPENSABLE, null); + } + return compensate(userId, requestId, reason.get()); + } + + /** + * request의 차감을 한 번 보상한다. 버킷별 복구량은 차감량과 같다(restored_* = charged_*). + * 이미 보상되었으면(다른 사유 포함) 잔액을 바꾸지 않고 먼저 적용된 사유를 돌려준다. + * + * @throws RequestUsageConflictException 차감이 다른 사용자에 속한다. + */ + @Transactional(propagation = Propagation.MANDATORY) + public UsageCompensationResult compensate(Long userId, String requestId, UsageCompensationReason reason) { + Objects.requireNonNull(userId, "userId"); + Objects.requireNonNull(requestId, "requestId"); + Objects.requireNonNull(reason, "reason"); + + Optional found = chargeRepository.findById(requestId); + if (found.isEmpty()) { + return UsageCompensationResult.of(Outcome.NOT_CHARGED, null); + } + RequestUsageCharge charge = found.get(); + if (!userId.equals(charge.getUserId())) { + throw new RequestUsageConflictException("request usage belongs to another user"); + } + + CreditBalance balance = creditBalanceService.lockBalanceInCurrentTransaction(userId); + + Optional existing = compensationRepository.findById(requestId); + if (existing.isPresent()) { + return UsageCompensationResult.of(Outcome.ALREADY_COMPENSATED, existing.get().getReason()); + } + + balance.addDailyCredit(charge.getChargedDaily()); + balance.addFreeCredit(charge.getChargedFree()); + balance.addPaidCredit(charge.getChargedPaid()); + + compensationRepository.saveAndFlush(RequestUsageCompensation.builder() + .requestId(requestId) + .reason(reason) + .restoredDaily(charge.getChargedDaily()) + .restoredFree(charge.getChargedFree()) + .restoredPaid(charge.getChargedPaid()) + .compensatedAt(now()) + .build()); + + saveHistory(balance, CreditEventType.LLM_QUERY_COMPENSATION, CreditChangeType.EARN, reason.getDescription(), + charge.getChargedDaily(), charge.getChargedFree(), charge.getChargedPaid()); + log.info("request usage compensated: userId={}, requestId={}, reason={}", userId, requestId, reason); + return UsageCompensationResult.of(Outcome.COMPENSATED, reason); + } + + // 버킷별로 분리 기록해 내역 필터링이 왜곡되지 않게 한다 (기존 CreditHistory 규칙) + private void saveHistory(CreditBalance balance, CreditEventType eventType, CreditChangeType changeType, + String description, int daily, int free, int paid) { + saveHistoryItem(balance, eventType, changeType, description, daily, CreditType.DAILY); + saveHistoryItem(balance, eventType, changeType, description, free, CreditType.FREE); + saveHistoryItem(balance, eventType, changeType, description, paid, CreditType.PAID); + } + + private void saveHistoryItem(CreditBalance balance, CreditEventType eventType, CreditChangeType changeType, + String description, int amount, CreditType creditType) { + if (amount <= 0) { + return; + } + creditHistoryRepository.save(CreditHistory.builder() + .user(balance.getUser()) + .eventType(eventType) + .eventName(eventType.getDescription()) + .description(description) + .amount(amount) + .changeType(changeType) + .creditType(creditType) + .balanceAfterDaily(balance.getDailyFreeCredit()) + .balanceAfterFree(balance.getFreeCredit()) + .balanceAfterPaid(balance.getPaidCredit()) + .build()); + } + + private static Instant now() { + return Instant.now().truncatedTo(ChronoUnit.MICROS); + } +} diff --git a/src/main/java/com/proovy/domain/usage/service/RunTerminalOutcome.java b/src/main/java/com/proovy/domain/usage/service/RunTerminalOutcome.java new file mode 100644 index 00000000..eec7af63 --- /dev/null +++ b/src/main/java/com/proovy/domain/usage/service/RunTerminalOutcome.java @@ -0,0 +1,71 @@ +package com.proovy.domain.usage.service; + +import com.proovy.domain.run.entity.AiRun; +import com.proovy.domain.run.entity.AiRunStatus; +import com.proovy.domain.run.entity.ResponseDisposition; +import com.proovy.domain.run.entity.RunCancelCause; +import com.proovy.domain.run.entity.RunFailureClass; + +import java.util.Objects; + +/** + * 사용량 보상 결정에 필요한 run terminal 분류. ai_runs의 terminal CHECK와 같은 짝 규칙을 따른다. + * + * @param status COMPLETED | FAILED | CANCELED + * @param disposition COMPLETED일 때만 + * @param failureClass FAILED일 때만 + * @param cancelRequestedFrom CANCELED일 때만 (PENDING | RUNNING | RETRYING) + * @param cancelCause CANCELED일 때만 + */ +public record RunTerminalOutcome(AiRunStatus status, + ResponseDisposition disposition, + RunFailureClass failureClass, + AiRunStatus cancelRequestedFrom, + RunCancelCause cancelCause) { + + public RunTerminalOutcome { + Objects.requireNonNull(status, "status"); + switch (status) { + case COMPLETED -> require(disposition != null && failureClass == null && cancelRequestedFrom == null + && cancelCause == null, "COMPLETED requires only disposition"); + case FAILED -> require(failureClass != null && disposition == null && cancelRequestedFrom == null + && cancelCause == null, "FAILED requires only failureClass"); + case CANCELED -> require(cancelRequestedFrom != null && cancelCause != null && disposition == null + && failureClass == null, "CANCELED requires cancelRequestedFrom and cancelCause"); + default -> throw new IllegalArgumentException("run is not terminal: " + status); + } + if (status == AiRunStatus.CANCELED) { + require(cancelRequestedFrom == AiRunStatus.PENDING || cancelRequestedFrom == AiRunStatus.RUNNING + || cancelRequestedFrom == AiRunStatus.RETRYING, "invalid cancelRequestedFrom: " + cancelRequestedFrom); + } + } + + public static RunTerminalOutcome completed(ResponseDisposition disposition) { + return new RunTerminalOutcome(AiRunStatus.COMPLETED, disposition, null, null, null); + } + + public static RunTerminalOutcome failed(RunFailureClass failureClass) { + return new RunTerminalOutcome(AiRunStatus.FAILED, null, failureClass, null, null); + } + + public static RunTerminalOutcome canceled(AiRunStatus cancelRequestedFrom, RunCancelCause cancelCause) { + return new RunTerminalOutcome(AiRunStatus.CANCELED, null, null, cancelRequestedFrom, cancelCause); + } + + /** terminal 전환이 반영된 run에서 분류를 읽는다. 진행 중 run이면 IllegalArgumentException. */ + public static RunTerminalOutcome of(AiRun run) { + AiRunStatus status = run.getStatus(); + return switch (status) { + case COMPLETED -> completed(run.getResponseDisposition()); + case FAILED -> failed(run.getFailureClass()); + case CANCELED -> canceled(run.getCancelRequestedFrom(), run.getCancelCause()); + default -> throw new IllegalArgumentException("run is not terminal: " + status); + }; + } + + private static void require(boolean condition, String message) { + if (!condition) { + throw new IllegalArgumentException(message); + } + } +} diff --git a/src/main/java/com/proovy/domain/usage/service/UsageChargeResult.java b/src/main/java/com/proovy/domain/usage/service/UsageChargeResult.java new file mode 100644 index 00000000..32678b6d --- /dev/null +++ b/src/main/java/com/proovy/domain/usage/service/UsageChargeResult.java @@ -0,0 +1,10 @@ +package com.proovy.domain.usage.service; + +import com.proovy.domain.usage.entity.RequestUsageCharge; + +/** + * @param newlyCharged 이번 호출이 차감했으면 true, 같은 request의 기존 차감을 돌려주면 false + * @param charge request의 차감 row (새 row 또는 기존 row) + */ +public record UsageChargeResult(boolean newlyCharged, RequestUsageCharge charge) { +} diff --git a/src/main/java/com/proovy/domain/usage/service/UsageCompensationPolicy.java b/src/main/java/com/proovy/domain/usage/service/UsageCompensationPolicy.java new file mode 100644 index 00000000..c3bb85cc --- /dev/null +++ b/src/main/java/com/proovy/domain/usage/service/UsageCompensationPolicy.java @@ -0,0 +1,60 @@ +package com.proovy.domain.usage.service; + +import com.proovy.domain.run.entity.AiRunStatus; +import com.proovy.domain.run.entity.RunCancelCause; +import com.proovy.domain.usage.entity.UsageCompensationReason; + +import java.util.Optional; + +/** + * 일반 요청 사용량 보상 결정표 (docs/plan/05·07·12, spec 07). 접수 시 한 단위를 선차감하고, + * terminal 분류가 아래 보상 대상이면 같은 terminal transaction에서 한 번 보상한다. + * + *

+ * terminal   분류                                         순사용량  보상 사유
+ * COMPLETED  substantive (혼합 결과·자료 부재 안내 포함)      1        —
+ * COMPLETED  clarification_only (모든 clarification_reason)  0        CLARIFICATION_ONLY
+ * COMPLETED  unsupported_only                            0        UNSUPPORTED_ONLY
+ * FAILED     verification                                0        VERIFICATION_FAILED
+ * FAILED     budget                                      0        BUDGET_EXCEEDED
+ * FAILED     attachment                                  0        INVALID_ATTACHMENT
+ * FAILED     infrastructure (provider·timeout·storage·internal) 0  INFRASTRUCTURE_FAILURE
+ * CANCELED   PENDING에서 취소 (user_requested·asset_deleting·budget_exhausted) 0  PENDING_CANCELED
+ * CANCELED   RUNNING·RETRYING에서 user_requested·asset_deleting                 1  —
+ * CANCELED   RUNNING·RETRYING에서 budget_exhausted                              0  BUDGET_EXCEEDED
+ * 
+ * + *

FAILED terminal은 substantive 답을 저장하지 않으므로(terminal-v2) 모든 최종 실패가 보상 대상이다. + * budget_exhausted 취소는 사용자 취소가 아니라 공개 후보 없이 요청 예산이 소진된 것이므로 예산 실패와 같이 보상한다. + */ +public final class UsageCompensationPolicy { + + private UsageCompensationPolicy() { + } + + /** 보상 대상이면 사유, 차감을 유지하면 empty. */ + public static Optional decide(RunTerminalOutcome outcome) { + return Optional.ofNullable(switch (outcome.status()) { + case COMPLETED -> switch (outcome.disposition()) { + case SUBSTANTIVE -> null; + case CLARIFICATION_ONLY -> UsageCompensationReason.CLARIFICATION_ONLY; + case UNSUPPORTED_ONLY -> UsageCompensationReason.UNSUPPORTED_ONLY; + }; + case FAILED -> switch (outcome.failureClass()) { + case VERIFICATION -> UsageCompensationReason.VERIFICATION_FAILED; + case BUDGET -> UsageCompensationReason.BUDGET_EXCEEDED; + case ATTACHMENT -> UsageCompensationReason.INVALID_ATTACHMENT; + case INFRASTRUCTURE -> UsageCompensationReason.INFRASTRUCTURE_FAILURE; + }; + case CANCELED -> { + if (outcome.cancelRequestedFrom() == AiRunStatus.PENDING) { + yield UsageCompensationReason.PENDING_CANCELED; + } + yield outcome.cancelCause() == RunCancelCause.BUDGET_EXHAUSTED + ? UsageCompensationReason.BUDGET_EXCEEDED + : null; + } + default -> throw new IllegalArgumentException("run is not terminal: " + outcome.status()); + }); + } +} diff --git a/src/main/java/com/proovy/domain/usage/service/UsageCompensationResult.java b/src/main/java/com/proovy/domain/usage/service/UsageCompensationResult.java new file mode 100644 index 00000000..c8e70e06 --- /dev/null +++ b/src/main/java/com/proovy/domain/usage/service/UsageCompensationResult.java @@ -0,0 +1,29 @@ +package com.proovy.domain.usage.service; + +import com.proovy.domain.usage.entity.UsageCompensationReason; + +/** + * @param outcome 보상 처리 결과 + * @param reason COMPENSATED면 이번 사유, ALREADY_COMPENSATED면 먼저 적용된 사유, 그 외 null + */ +public record UsageCompensationResult(Outcome outcome, UsageCompensationReason reason) { + + public enum Outcome { + /** 이번 호출이 보상 row를 만들고 잔액을 복구했다 */ + COMPENSATED, + /** 같은 request가 이미 보상되었다(사유 경합 포함). 잔액은 바뀌지 않았다 */ + ALREADY_COMPENSATED, + /** 정책상 차감을 유지한다 (substantive, RUNNING·RETRYING 이후 사용자 취소) */ + NOT_COMPENSABLE, + /** 차감 row가 없어 보상할 것이 없다 */ + NOT_CHARGED + } + + static UsageCompensationResult of(Outcome outcome, UsageCompensationReason reason) { + return new UsageCompensationResult(outcome, reason); + } + + public boolean compensatedNow() { + return outcome == Outcome.COMPENSATED; + } +} From dec5ddaffe718106a5cd377ed9d25a817d094923 Mon Sep 17 00:00:00 2001 From: chowon442 Date: Sat, 17 Oct 2026 15:49:38 +0900 Subject: [PATCH 05/34] =?UTF-8?q?test:=20=EC=A0=95=EC=B1=85=ED=91=9C=C2=B7?= =?UTF-8?q?=EC=A4=91=EB=B3=B5/=EB=8F=99=EC=8B=9C=20=EC=B0=A8=EA=B0=90?= =?UTF-8?q?=C2=B7=EB=B3=B4=EC=83=81=20=EA=B2=BD=ED=95=A9=C2=B7=EC=83=81?= =?UTF-8?q?=EC=9C=84=20rollback=EC=9D=84=20=EA=B2=A9=EB=A6=AC=20Postgres?= =?UTF-8?q?=EB=A1=9C=20=EA=B2=80=EC=A6=9D=20(#216)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../RequestUsageServicePostgresTest.java | 605 ++++++++++++++++++ .../service/UsageCompensationPolicyTest.java | 70 ++ 2 files changed, 675 insertions(+) create mode 100644 src/test/java/com/proovy/domain/usage/service/RequestUsageServicePostgresTest.java create mode 100644 src/test/java/com/proovy/domain/usage/service/UsageCompensationPolicyTest.java diff --git a/src/test/java/com/proovy/domain/usage/service/RequestUsageServicePostgresTest.java b/src/test/java/com/proovy/domain/usage/service/RequestUsageServicePostgresTest.java new file mode 100644 index 00000000..91b7dbc8 --- /dev/null +++ b/src/test/java/com/proovy/domain/usage/service/RequestUsageServicePostgresTest.java @@ -0,0 +1,605 @@ +package com.proovy.domain.usage.service; + +import com.proovy.domain.run.repository.AiRunRepository; +import com.proovy.domain.usage.entity.UsageCompensationReason; +import com.proovy.domain.usage.exception.InsufficientUsageBalanceException; +import com.proovy.domain.usage.exception.RequestUsageConflictException; +import com.proovy.domain.usage.service.UsageCompensationResult.Outcome; +import com.proovy.support.db.PostgresTestDatabase; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.transaction.IllegalTransactionStateException; +import org.springframework.transaction.UnexpectedRollbackException; +import org.springframework.transaction.support.TransactionTemplate; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.Callable; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +import static com.proovy.support.db.ProductRows.ALICE; +import static com.proovy.support.db.ProductRows.ALICE_NOTE; +import static com.proovy.support.db.ProductRows.BOB; +import static com.proovy.support.db.ProductRows.BOB_NOTE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * 실제 Postgres에서 요청 사용량 ledger 연산을 검사한다: request당 1회 차감, 잔액 부족 무부작용, + * 최대 1회 보상, 동시 차감·보상·사유 경합, 상위 transaction rollback, 정책표별 순사용량. + */ +@Tag("postgres") +@ActiveProfiles("test") +@SpringBootTest(properties = { + "gcs.project-id=proovy-test", + "gcs.endpoint=http://127.0.0.1:1", + "jwt.secret=test-jwt-secret-test-jwt-secret-0123456789", + "oauth.kakao.client-id=test", "oauth.kakao.client-secret=test", + "oauth.naver.client-id=test", "oauth.naver.client-secret=test", + "openrouter.api.key=test", + "spring.data.redis.host=127.0.0.1", "spring.data.redis.port=1", + "spring.task.scheduling.pool.size=1", + "proovy.subscription.plan-transition-fixed-delay-ms=3600000", + "proovy.subscription.auto-renew-fixed-delay-ms=3600000", + "proovy.usage.credits-per-unit=10" +}) +class RequestUsageServicePostgresTest { + + private static final int UNIT = 10; + private static final long CAROL = 1003L; + private static final AtomicLong REQUEST_SEQ = new AtomicLong(); + + private static PostgresTestDatabase db; + + @BeforeAll + static void createDatabase() { + db = PostgresTestDatabase.createMigrated(); + db.runScript("db/seed/product-seed.sql"); + } + + @DynamicPropertySource + static void datasource(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", () -> db.jdbcUrl()); + registry.add("spring.datasource.username", PostgresTestDatabase::adminUser); + registry.add("spring.datasource.password", PostgresTestDatabase::adminPassword); + } + + @AfterAll + static void dropDatabase() { + if (db != null) { + db.close(); + } + } + + @Autowired RequestUsageService usageService; + @Autowired AiRunRepository aiRunRepository; + @Autowired TransactionTemplate tx; + @Autowired JdbcTemplate jdbc; + + @BeforeEach + void resetBalances() { + // 다음 일일 리셋이 테스트 중 일어나지 않도록 만료 시각을 미래로 둔다 + jdbc.update(""" + UPDATE credit_balance SET daily_free_credit = 100, daily_free_limit = 100, free_credit = 0, + paid_credit = 1000, daily_expires_at = now() + interval '1 day' + """); + } + + // ------------------------------------------------------------------ + // 차감 + // ------------------------------------------------------------------ + + @Test + void chargesOneUnitOnceAndReplaysWithoutSecondDeduction() { + String requestId = nextRequestId(); + Balance before = balance(ALICE); + + UUID runId = tx.execute(status -> { + UUID run = insertPendingRun(requestId, ALICE, ALICE_NOTE); + UsageChargeResult first = usageService.chargeRequest(ALICE, requestId, run); + assertThat(first.newlyCharged()).isTrue(); + assertThat(first.charge().getUsageUnits()).isEqualTo(1); + assertThat(first.charge().getCreditAmount()).isEqualTo(UNIT); + // 같은 transaction 안에서 다시 호출해도 추가 차감이 없다 + assertThat(usageService.chargeRequest(ALICE, requestId, run).newlyCharged()).isFalse(); + return run; + }); + + // 재전송·재시도: 새 transaction에서 같은 request + UsageChargeResult replay = tx.execute(status -> usageService.chargeRequest(ALICE, requestId, runId)); + assertThat(replay.newlyCharged()).isFalse(); + assertThat(replay.charge().getRunId()).isEqualTo(runId); + + assertThat(balance(ALICE)).isEqualTo(before.minusDaily(UNIT)); + assertThat(count("SELECT count(*) FROM request_usage_charges WHERE request_id = ?", requestId)).isEqualTo(1); + assertThat(jdbc.queryForMap(""" + SELECT charged_daily, charged_free, charged_paid, credit_amount FROM request_usage_charges WHERE request_id = ? + """, requestId)).isEqualTo(Map.of("charged_daily", UNIT, "charged_free", 0, "charged_paid", 0, + "credit_amount", UNIT)); + assertThat(count(""" + SELECT count(*) FROM credit_history WHERE user_id = ? AND event_type = 'LLM_QUERY' + AND change_type = 'SPEND' AND credit_type = 'DAILY' AND amount = ? + """, ALICE, UNIT)).isGreaterThanOrEqualTo(1); + finishRun(runId); + } + + @Test + void operationsRequireCallerTransaction() { + assertThatThrownBy(() -> usageService.chargeRequest(ALICE, nextRequestId(), UUID.randomUUID())) + .isInstanceOf(IllegalTransactionStateException.class); + assertThatThrownBy(() -> usageService.compensate(ALICE, nextRequestId(), UsageCompensationReason.PENDING_CANCELED)) + .isInstanceOf(IllegalTransactionStateException.class); + } + + @Test + void insufficientBalanceRejectsWithoutAnySideEffect() { + jdbc.update("UPDATE credit_balance SET daily_free_credit = 4, free_credit = 3, paid_credit = 2 WHERE user_id = ?", BOB); + String requestId = nextRequestId(); + long runsBefore = count("SELECT count(*) FROM ai_runs WHERE user_id = ?", BOB); + long historyBefore = count("SELECT count(*) FROM credit_history WHERE user_id = ?", BOB); + + assertThatThrownBy(() -> tx.executeWithoutResult(status -> { + UUID run = insertPendingRun(requestId, BOB, BOB_NOTE); + usageService.chargeRequest(BOB, requestId, run); + })).isInstanceOf(InsufficientUsageBalanceException.class) + .satisfies(e -> { + InsufficientUsageBalanceException ex = (InsufficientUsageBalanceException) e; + assertThat(ex.getRequired()).isEqualTo(UNIT); + assertThat(ex.getAvailable()).isEqualTo(9); + }); + + // 호출자가 예외를 삼켜도 transaction은 rollback-only라 commit되지 않는다 + assertThatThrownBy(() -> tx.executeWithoutResult(status -> { + UUID run = insertPendingRun(requestId, BOB, BOB_NOTE); + try { + usageService.chargeRequest(BOB, requestId, run); + } catch (InsufficientUsageBalanceException ignored) { + // 삼킨다 + } + })).isInstanceOf(UnexpectedRollbackException.class); + + assertThat(balance(BOB)).isEqualTo(new Balance(4, 3, 2)); + assertThat(count("SELECT count(*) FROM request_usage_charges WHERE request_id = ?", requestId)).isZero(); + assertThat(count("SELECT count(*) FROM ai_runs WHERE user_id = ?", BOB)).isEqualTo(runsBefore); + assertThat(count("SELECT count(*) FROM credit_history WHERE user_id = ?", BOB)).isEqualTo(historyBefore); + } + + @Test + void chargesBucketsInPriorityOrderAndRestoresExactlyTheSameBuckets() { + jdbc.update("UPDATE credit_balance SET daily_free_credit = 4, free_credit = 3, paid_credit = 50 WHERE user_id = ?", BOB); + String requestId = nextRequestId(); + + UUID runId = tx.execute(status -> { + UUID run = insertPendingRun(requestId, BOB, BOB_NOTE); + usageService.chargeRequest(BOB, requestId, run); + return run; + }); + assertThat(balance(BOB)).isEqualTo(new Balance(0, 0, 47)); + assertThat(jdbc.queryForMap("SELECT charged_daily, charged_free, charged_paid FROM request_usage_charges WHERE request_id = ?", + requestId)).isEqualTo(Map.of("charged_daily", 4, "charged_free", 3, "charged_paid", 3)); + + UsageCompensationResult result = tx.execute(status -> + usageService.compensate(BOB, requestId, UsageCompensationReason.INFRASTRUCTURE_FAILURE)); + assertThat(result.outcome()).isEqualTo(Outcome.COMPENSATED); + assertThat(balance(BOB)).isEqualTo(new Balance(4, 3, 50)); + assertThat(jdbc.queryForMap("SELECT restored_daily, restored_free, restored_paid, credit_amount FROM request_usage_compensations WHERE request_id = ?", + requestId)).isEqualTo(Map.of("restored_daily", 4, "restored_free", 3, "restored_paid", 3, "credit_amount", UNIT)); + assertThat(count(""" + SELECT count(*) FROM credit_history WHERE user_id = ? AND event_type = 'LLM_QUERY_COMPENSATION' + AND change_type = 'EARN' AND description = ? + """, BOB, UsageCompensationReason.INFRASTRUCTURE_FAILURE.getDescription())).isEqualTo(3); + finishRun(runId); + } + + @Test + void missingBalanceRowIsCreatedInsideCallerTransactionOnly() { + jdbc.update("INSERT INTO users (user_id, nickname, provider, provider_user_id) VALUES (?, 'carol', 'KAKAO', 'kakao-1003') ON CONFLICT DO NOTHING", CAROL); + jdbc.update("INSERT INTO notes (note_id, user_id, title) VALUES (2004, ?, 'carol note') ON CONFLICT DO NOTHING", CAROL); + jdbc.update("DELETE FROM credit_balance WHERE user_id = ?", CAROL); + String requestId = nextRequestId(); + + // 상위 transaction rollback: 잔액 row도 독립 commit되지 않는다 + tx.executeWithoutResult(status -> { + UUID run = insertPendingRun(requestId, CAROL, 2004L); + assertThat(usageService.chargeRequest(CAROL, requestId, run).newlyCharged()).isTrue(); + status.setRollbackOnly(); + }); + assertThat(count("SELECT count(*) FROM credit_balance WHERE user_id = ?", CAROL)).isZero(); + assertThat(count("SELECT count(*) FROM request_usage_charges WHERE request_id = ?", requestId)).isZero(); + + UUID runId = tx.execute(status -> { + UUID run = insertPendingRun(requestId, CAROL, 2004L); + usageService.chargeRequest(CAROL, requestId, run); + return run; + }); + // FREE 플랜 일일 한도로 만들어진 뒤 한 단위 차감 + assertThat(balance(CAROL)).isEqualTo(new Balance(100 - UNIT, 0, 0)); + finishRun(runId); + } + + @Test + void conflictingOwnerOrRunIsRejected() { + String requestId = nextRequestId(); + UUID runId = tx.execute(status -> { + UUID run = insertPendingRun(requestId, ALICE, ALICE_NOTE); + usageService.chargeRequest(ALICE, requestId, run); + return run; + }); + Balance aliceBefore = balance(ALICE); + Balance bobBefore = balance(BOB); + + assertThatThrownBy(() -> tx.executeWithoutResult(status -> usageService.chargeRequest(BOB, requestId, runId))) + .isInstanceOf(RequestUsageConflictException.class); + assertThatThrownBy(() -> tx.executeWithoutResult(status -> + usageService.chargeRequest(ALICE, requestId, UUID.randomUUID()))) + .isInstanceOf(RequestUsageConflictException.class); + assertThatThrownBy(() -> tx.executeWithoutResult(status -> + usageService.compensate(BOB, requestId, UsageCompensationReason.PENDING_CANCELED))) + .isInstanceOf(RequestUsageConflictException.class); + + assertThat(balance(ALICE)).isEqualTo(aliceBefore); + assertThat(balance(BOB)).isEqualTo(bobBefore); + assertThat(count("SELECT count(*) FROM request_usage_compensations WHERE request_id = ?", requestId)).isZero(); + finishRun(runId); + } + + // ------------------------------------------------------------------ + // 보상 + // ------------------------------------------------------------------ + + @Test + void compensationWithoutChargeIsNoop() { + Balance before = balance(ALICE); + UsageCompensationResult result = tx.execute(status -> + usageService.compensate(ALICE, nextRequestId(), UsageCompensationReason.CLARIFICATION_ONLY)); + assertThat(result.outcome()).isEqualTo(Outcome.NOT_CHARGED); + assertThat(balance(ALICE)).isEqualTo(before); + } + + @Test + void compensatesAtMostOnceAndKeepsTheFirstReason() { + String requestId = nextRequestId(); + UUID runId = chargedPendingRun(requestId, ALICE, ALICE_NOTE); + Balance charged = balance(ALICE); + + UsageCompensationResult first = tx.execute(status -> + usageService.compensate(ALICE, requestId, UsageCompensationReason.PENDING_CANCELED)); + UsageCompensationResult second = tx.execute(status -> + usageService.compensate(ALICE, requestId, UsageCompensationReason.INFRASTRUCTURE_FAILURE)); + + assertThat(first).isEqualTo(new UsageCompensationResult(Outcome.COMPENSATED, UsageCompensationReason.PENDING_CANCELED)); + assertThat(second).isEqualTo(new UsageCompensationResult(Outcome.ALREADY_COMPENSATED, UsageCompensationReason.PENDING_CANCELED)); + assertThat(balance(ALICE)).isEqualTo(charged.plusDaily(UNIT)); + assertThat(count("SELECT count(*) FROM request_usage_compensations WHERE request_id = ?", requestId)).isEqualTo(1); + finishRun(runId); + } + + @Test + void callerRollbackUndoesCompensationSoItCanBeAppliedLater() { + String requestId = nextRequestId(); + UUID runId = chargedPendingRun(requestId, ALICE, ALICE_NOTE); + Balance charged = balance(ALICE); + + // terminal transaction이 다른 이유로 실패하면 보상도 남지 않는다 + tx.executeWithoutResult(status -> { + assertThat(usageService.compensate(ALICE, requestId, UsageCompensationReason.VERIFICATION_FAILED) + .compensatedNow()).isTrue(); + status.setRollbackOnly(); + }); + assertThat(balance(ALICE)).isEqualTo(charged); + assertThat(count("SELECT count(*) FROM request_usage_compensations WHERE request_id = ?", requestId)).isZero(); + + UsageCompensationResult retried = tx.execute(status -> + usageService.compensate(ALICE, requestId, UsageCompensationReason.VERIFICATION_FAILED)); + assertThat(retried.outcome()).isEqualTo(Outcome.COMPENSATED); + assertThat(balance(ALICE)).isEqualTo(charged.plusDaily(UNIT)); + finishRun(runId); + } + + // ------------------------------------------------------------------ + // 경합 + // ------------------------------------------------------------------ + + @Test + void concurrentDuplicateChargesDeductOnce() throws Exception { + String requestId = nextRequestId(); + UUID runId = tx.execute(status -> insertPendingRun(requestId, ALICE, ALICE_NOTE)); + Balance before = balance(ALICE); + + List results = race(4, i -> () -> + tx.execute(status -> usageService.chargeRequest(ALICE, requestId, runId))); + + assertThat(results).filteredOn(UsageChargeResult::newlyCharged).hasSize(1); + assertThat(balance(ALICE)).isEqualTo(before.minusDaily(UNIT)); + assertThat(count("SELECT count(*) FROM request_usage_charges WHERE request_id = ?", requestId)).isEqualTo(1); + finishRun(runId); + } + + @Test + void concurrentCompensationReasonsApplyExactlyOnce() throws Exception { + String requestId = nextRequestId(); + UUID runId = chargedPendingRun(requestId, ALICE, ALICE_NOTE); + Balance charged = balance(ALICE); + long historyBefore = count("SELECT count(*) FROM credit_history WHERE user_id = ? AND event_type = 'LLM_QUERY_COMPENSATION'", ALICE); + UsageCompensationReason[] reasons = { + UsageCompensationReason.PENDING_CANCELED, UsageCompensationReason.CLARIFICATION_ONLY, + UsageCompensationReason.INFRASTRUCTURE_FAILURE, UsageCompensationReason.BUDGET_EXCEEDED}; + + List results = race(reasons.length, i -> () -> + tx.execute(status -> usageService.compensate(ALICE, requestId, reasons[i]))); + + List winners = results.stream().filter(UsageCompensationResult::compensatedNow).toList(); + assertThat(winners).hasSize(1); + UsageCompensationReason winner = winners.get(0).reason(); + assertThat(results).filteredOn(r -> !r.compensatedNow()) + .allSatisfy(r -> assertThat(r).isEqualTo(new UsageCompensationResult(Outcome.ALREADY_COMPENSATED, winner))); + assertThat(balance(ALICE)).isEqualTo(charged.plusDaily(UNIT)); + assertThat(jdbc.queryForObject("SELECT reason FROM request_usage_compensations WHERE request_id = ?", String.class, requestId)) + .isEqualTo(winner.name()); + assertThat(count("SELECT count(*) FROM credit_history WHERE user_id = ? AND event_type = 'LLM_QUERY_COMPENSATION'", ALICE)) + .isEqualTo(historyBefore + 1); + finishRun(runId); + } + + @Test + void concurrentChargesOfDifferentRequestsKeepBalanceConsistent() throws Exception { + // 같은 사용자의 서로 다른 request 동시 차감: 잔액 잠금으로 직렬화되어 lost update가 없다 + jdbc.update("UPDATE credit_balance SET daily_free_credit = 15, free_credit = 0, paid_credit = 10 WHERE user_id = ?", BOB); + long[] notes = {BOB_NOTE, insertNote(BOB, "bob race 1"), insertNote(BOB, "bob race 2")}; + String[] requests = {nextRequestId(), nextRequestId(), nextRequestId()}; + UUID[] runs = new UUID[3]; + for (int i = 0; i < 3; i++) { + int n = i; + runs[i] = tx.execute(status -> insertPendingRun(requests[n], BOB, notes[n])); + } + + List results = race(3, i -> () -> { + try { + return tx.execute(status -> usageService.chargeRequest(BOB, requests[i], runs[i])); + } catch (InsufficientUsageBalanceException e) { + return e; + } + }); + + // 잔액 25 = 두 요청만 차감 가능 + assertThat(results).filteredOn(r -> r instanceof UsageChargeResult).hasSize(2); + assertThat(results).filteredOn(r -> r instanceof InsufficientUsageBalanceException).hasSize(1); + assertThat(balance(BOB).total()).isEqualTo(5); + assertThat(jdbc.queryForObject("SELECT COALESCE(sum(credit_amount), 0) FROM request_usage_charges WHERE request_id = ANY (?)", + Integer.class, (Object) requests)).isEqualTo(2 * UNIT); + for (UUID run : runs) { + finishRun(run); + } + } + + // ------------------------------------------------------------------ + // 정책표: terminal 전환과 같은 transaction에서 settle + // ------------------------------------------------------------------ + + private record Scenario(String name, String transitionSql, int expectedNetUnits) { + } + + private static final String RELEASE = ", completed_at = now(), lease_owner = NULL, lease_expires_at = NULL "; + + private static List scenarios() { + List list = new ArrayList<>(); + String completed = "UPDATE ai_runs SET status = 'COMPLETED', response_disposition = "; + list.add(new Scenario("substantive (혼합·자료 부재 안내 포함)", CLAIM + completed + "'substantive'" + RELEASE + WHERE, 1)); + for (String reason : new String[]{"missing_input", "ambiguous_target", "limited_pages", "corpus_not_ready", + "page_range_required", "page_range_unreadable"}) { + list.add(new Scenario("clarification_only/" + reason, + CLAIM + completed + "'clarification_only', clarification_reason = '" + reason + "'" + RELEASE + WHERE, 0)); + } + list.add(new Scenario("unsupported_only", CLAIM + completed + "'unsupported_only'" + RELEASE + WHERE, 0)); + String[][] failures = {{"verification_failed", "verification"}, {"budget_exceeded", "budget"}, + {"invalid_attachment", "attachment"}, {"provider_error", "infrastructure"}, {"timeout", "infrastructure"}, + {"storage_error", "infrastructure"}, {"internal_error", "infrastructure"}}; + for (String[] f : failures) { + list.add(new Scenario("failed/" + f[0], CLAIM + "UPDATE ai_runs SET status = 'FAILED', failure_code = '" + f[0] + + "', failure_class = '" + f[1] + "'" + RELEASE + WHERE, 0)); + } + // PENDING에서 바로 최종 실패 (dispatch 전 인프라 실패) + list.add(new Scenario("failed/from-pending", "UPDATE ai_runs SET status = 'FAILED', failure_code = 'internal_error'," + + " failure_class = 'infrastructure', completed_at = now()" + WHERE, 0)); + for (String cause : new String[]{"user_requested", "asset_deleting", "budget_exhausted"}) { + list.add(new Scenario("canceled/PENDING/" + cause, cancel("PENDING", cause), 0)); + } + for (String from : new String[]{"RUNNING", "RETRYING"}) { + list.add(new Scenario("canceled/" + from + "/user_requested", cancel(from, "user_requested"), 1)); + list.add(new Scenario("canceled/" + from + "/asset_deleting", cancel(from, "asset_deleting"), 1)); + list.add(new Scenario("canceled/" + from + "/budget_exhausted", cancel(from, "budget_exhausted"), 0)); + } + return list; + } + + private static final String WHERE = " WHERE run_id = ?;"; + private static final String CLAIM = """ + UPDATE ai_runs SET status = 'RUNNING', attempt = attempt + 1, fence_token = 'fence-' || (attempt + 1), + started_at = now(), lease_owner = 'coordinator-1', lease_expires_at = now() + interval '30 seconds' + WHERE run_id = ?;"""; + private static final String RETRY = "UPDATE ai_runs SET status = 'RETRYING', retry_count = retry_count + 1 WHERE run_id = ?;"; + + private static String cancel(String from, String cause) { + String fields = "cancel_cause = '" + cause + "', cancel_requested_from = '" + from + "', cancel_requested_at = now()"; + if (from.equals("PENDING")) { + return "UPDATE ai_runs SET status = 'CANCELED', " + fields + ", completed_at = now()" + WHERE; + } + String toExecution = from.equals("RETRYING") ? CLAIM + RETRY : CLAIM; + return toExecution + + "UPDATE ai_runs SET status = 'CANCELING', " + fields + WHERE + + "UPDATE ai_runs SET status = 'CANCELED'" + RELEASE + WHERE; + } + + @Test + void netUsageMatchesPolicyTableForEveryTerminal() { + List mismatches = new ArrayList<>(); + for (Scenario scenario : scenarios()) { + String requestId = nextRequestId(); + Balance before = balance(ALICE); + UUID runId = chargedPendingRun(requestId, ALICE, ALICE_NOTE); + + UsageCompensationResult result = tx.execute(status -> { + for (String sql : scenario.transitionSql().split(";")) { + if (!sql.isBlank()) { + assertThat(jdbc.update(sql, runId)).as(scenario.name() + ": " + sql).isEqualTo(1); + } + } + return usageService.settleTerminal(aiRunRepository.findById(runId).orElseThrow()); + }); + // terminal 처리 재전달·중복 settle은 추가 보상을 만들지 않는다 + UsageCompensationResult again = tx.execute(status -> + usageService.settleTerminal(aiRunRepository.findById(runId).orElseThrow())); + + int netCredits = before.total() - balance(ALICE).total(); + int expectedCredits = scenario.expectedNetUnits() * UNIT; + Outcome expectedFirst = scenario.expectedNetUnits() == 1 ? Outcome.NOT_COMPENSABLE : Outcome.COMPENSATED; + Outcome expectedAgain = scenario.expectedNetUnits() == 1 ? Outcome.NOT_COMPENSABLE : Outcome.ALREADY_COMPENSATED; + if (netCredits != expectedCredits || result.outcome() != expectedFirst || again.outcome() != expectedAgain) { + mismatches.add(scenario.name() + ": net=" + netCredits + " first=" + result.outcome() + " again=" + again.outcome()); + } + } + assertThat(mismatches).isEmpty(); + } + + @Test + void promotionRetriesAndStatusReadsDoNotChargeAgain() { + String requestId = nextRequestId(); + UUID runId = chargedPendingRun(requestId, ALICE, ALICE_NOTE); + Balance charged = balance(ALICE); + + // 살아 있는 실행의 재시도·모델 승급(새 attempt/fence)·누적 예산 증가·상태 조회 + tx.executeWithoutResult(status -> { + jdbc.update(CLAIM.replace(";", ""), runId); + jdbc.update(RETRY.replace(";", ""), runId); + jdbc.update(CLAIM.replace(";", ""), runId); + jdbc.update("UPDATE ai_runs SET used_llm_calls = 5, used_cost_micros = 12345 WHERE run_id = ?", runId); + }); + aiRunRepository.findByRequestId(requestId).orElseThrow(); + // 접수 재전송이 같은 연산을 다시 불러도 기존 차감을 돌려준다 + assertThat(tx.execute(status -> usageService.chargeRequest(ALICE, requestId, runId)).newlyCharged()).isFalse(); + + assertThat(aiRunRepository.findById(runId).orElseThrow().getAttempt()).isEqualTo(2); + assertThat(balance(ALICE)).isEqualTo(charged); + assertThat(count("SELECT count(*) FROM request_usage_charges WHERE run_id = ?", runId)).isEqualTo(1); + tx.executeWithoutResult(status -> jdbc.update( + "UPDATE ai_runs SET status = 'COMPLETED', response_disposition = 'substantive'" + RELEASE + "WHERE run_id = ?", runId)); + } + + // ------------------------------------------------------------------ + // helpers + // ------------------------------------------------------------------ + + private record Balance(int daily, int free, int paid) { + int total() { + return daily + free + paid; + } + + Balance minusDaily(int amount) { + return new Balance(daily - amount, free, paid); + } + + Balance plusDaily(int amount) { + return new Balance(daily + amount, free, paid); + } + } + + private Balance balance(long userId) { + return jdbc.queryForObject( + "SELECT daily_free_credit, free_credit, paid_credit FROM credit_balance WHERE user_id = ?", + (rs, n) -> new Balance(rs.getInt(1), rs.getInt(2), rs.getInt(3)), userId); + } + + private long count(String sql, Object... args) { + Long value = jdbc.queryForObject(sql, Long.class, args); + return value == null ? 0 : value; + } + + private static String nextRequestId() { + return "req-usage-" + REQUEST_SEQ.incrementAndGet() + "-" + UUID.randomUUID().toString().substring(0, 8); + } + + private long insertNote(long userId, String title) { + Long id = jdbc.queryForObject("INSERT INTO notes (user_id, title) VALUES (?, ?) RETURNING note_id", Long.class, userId, title); + return id == null ? 0 : id; + } + + /** 접수 transaction의 run 부분 (user message·placeholder·PENDING run). */ + private UUID insertPendingRun(String requestId, long userId, long noteId) { + UUID userMessageId = UUID.randomUUID(); + UUID assistantMessageId = UUID.randomUUID(); + UUID runId = UUID.randomUUID(); + jdbc.update(""" + INSERT INTO chat_messages (message_id, note_id, role, status, user_input, plain_text, created_at) + VALUES (?, ?, 'USER', 'COMPLETED', '{"text":"1번 문제 풀어줘"}', '1번 문제 풀어줘', clock_timestamp()), + (?, ?, 'ASSISTANT', 'PENDING', NULL, NULL, clock_timestamp() + interval '1 millisecond') + """, userMessageId, noteId, assistantMessageId, noteId); + jdbc.update(""" + INSERT INTO ai_runs (run_id, request_id, user_id, note_id, user_message_id, assistant_message_id, + request_fingerprint, request_snapshot, policy_snapshot, budget_policy_version, + request_deadline_at) + VALUES (?, ?, ?, ?, ?, ?, ?, '{"input":{"text":"1번 문제 풀어줘"}}', '{"limits":{}}', 'budget-v1', + now() + interval '10 minutes') + """, runId, requestId, userId, noteId, userMessageId, assistantMessageId, "f".repeat(64)); + return runId; + } + + private UUID chargedPendingRun(String requestId, long userId, long noteId) { + return tx.execute(status -> { + UUID run = insertPendingRun(requestId, userId, noteId); + assertThat(usageService.chargeRequest(userId, requestId, run).newlyCharged()).isTrue(); + return run; + }); + } + + /** Note의 Active Run 자리를 비운다 (다음 테스트가 같은 Note를 쓴다). 사용량에는 영향이 없다. */ + private void finishRun(UUID runId) { + tx.executeWithoutResult(status -> jdbc.update(""" + UPDATE ai_runs SET status = 'CANCELED', cancel_cause = 'user_requested', cancel_requested_from = 'PENDING', + cancel_requested_at = now(), completed_at = now() + WHERE run_id = ? AND status = 'PENDING' + """, runId)); + } + + private interface IndexedTask { + Callable task(int index); + } + + @SuppressWarnings("unchecked") + private static List race(int threads, IndexedTask factory) throws Exception { + ExecutorService pool = Executors.newFixedThreadPool(threads); + CyclicBarrier barrier = new CyclicBarrier(threads); + try { + List> futures = new ArrayList<>(); + for (int i = 0; i < threads; i++) { + Callable task = factory.task(i); + futures.add(pool.submit(() -> { + barrier.await(10, TimeUnit.SECONDS); + return (T) task.call(); + })); + } + List results = new ArrayList<>(); + for (Future f : futures) { + results.add(f.get(60, TimeUnit.SECONDS)); + } + return results; + } finally { + pool.shutdownNow(); + } + } +} diff --git a/src/test/java/com/proovy/domain/usage/service/UsageCompensationPolicyTest.java b/src/test/java/com/proovy/domain/usage/service/UsageCompensationPolicyTest.java new file mode 100644 index 00000000..a0e5bb11 --- /dev/null +++ b/src/test/java/com/proovy/domain/usage/service/UsageCompensationPolicyTest.java @@ -0,0 +1,70 @@ +package com.proovy.domain.usage.service; + +import com.proovy.domain.run.entity.AiRunStatus; +import com.proovy.domain.run.entity.ResponseDisposition; +import com.proovy.domain.run.entity.RunCancelCause; +import com.proovy.domain.run.entity.RunFailureClass; +import com.proovy.domain.usage.entity.UsageCompensationReason; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.stream.Stream; + +import static com.proovy.domain.usage.entity.UsageCompensationReason.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class UsageCompensationPolicyTest { + + /** docs/plan/05·07·12의 정책표. reason이 null이면 차감 유지(순사용량 1). */ + static Stream policyTable() { + return Stream.of( + Arguments.of(RunTerminalOutcome.completed(ResponseDisposition.SUBSTANTIVE), null), + Arguments.of(RunTerminalOutcome.completed(ResponseDisposition.CLARIFICATION_ONLY), CLARIFICATION_ONLY), + Arguments.of(RunTerminalOutcome.completed(ResponseDisposition.UNSUPPORTED_ONLY), UNSUPPORTED_ONLY), + Arguments.of(RunTerminalOutcome.failed(RunFailureClass.VERIFICATION), VERIFICATION_FAILED), + Arguments.of(RunTerminalOutcome.failed(RunFailureClass.BUDGET), BUDGET_EXCEEDED), + Arguments.of(RunTerminalOutcome.failed(RunFailureClass.ATTACHMENT), INVALID_ATTACHMENT), + Arguments.of(RunTerminalOutcome.failed(RunFailureClass.INFRASTRUCTURE), INFRASTRUCTURE_FAILURE), + Arguments.of(RunTerminalOutcome.canceled(AiRunStatus.PENDING, RunCancelCause.USER_REQUESTED), PENDING_CANCELED), + Arguments.of(RunTerminalOutcome.canceled(AiRunStatus.PENDING, RunCancelCause.ASSET_DELETING), PENDING_CANCELED), + Arguments.of(RunTerminalOutcome.canceled(AiRunStatus.PENDING, RunCancelCause.BUDGET_EXHAUSTED), PENDING_CANCELED), + Arguments.of(RunTerminalOutcome.canceled(AiRunStatus.RUNNING, RunCancelCause.USER_REQUESTED), null), + Arguments.of(RunTerminalOutcome.canceled(AiRunStatus.RETRYING, RunCancelCause.USER_REQUESTED), null), + Arguments.of(RunTerminalOutcome.canceled(AiRunStatus.RUNNING, RunCancelCause.ASSET_DELETING), null), + Arguments.of(RunTerminalOutcome.canceled(AiRunStatus.RETRYING, RunCancelCause.ASSET_DELETING), null), + Arguments.of(RunTerminalOutcome.canceled(AiRunStatus.RUNNING, RunCancelCause.BUDGET_EXHAUSTED), BUDGET_EXCEEDED), + Arguments.of(RunTerminalOutcome.canceled(AiRunStatus.RETRYING, RunCancelCause.BUDGET_EXHAUSTED), BUDGET_EXCEEDED) + ); + } + + @ParameterizedTest + @MethodSource("policyTable") + void decidesCompensationPerPolicyTable(RunTerminalOutcome outcome, UsageCompensationReason expected) { + assertThat(UsageCompensationPolicy.decide(outcome)).isEqualTo(java.util.Optional.ofNullable(expected)); + } + + @Test + void everyCompensationReasonIsReachable() { + assertThat(policyTable().map(a -> a.get()[1]).filter(java.util.Objects::nonNull).distinct()) + .containsExactlyInAnyOrder((Object[]) UsageCompensationReason.values()); + } + + @Test + void rejectsNonTerminalOrInconsistentOutcomes() { + assertThatThrownBy(() -> new RunTerminalOutcome(AiRunStatus.RUNNING, null, null, null, null)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new RunTerminalOutcome(AiRunStatus.CANCELING, null, null, + AiRunStatus.RUNNING, RunCancelCause.USER_REQUESTED)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> RunTerminalOutcome.completed(null)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> RunTerminalOutcome.failed(null)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> RunTerminalOutcome.canceled(AiRunStatus.CANCELING, RunCancelCause.USER_REQUESTED)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new RunTerminalOutcome(AiRunStatus.FAILED, ResponseDisposition.SUBSTANTIVE, + RunFailureClass.INFRASTRUCTURE, null, null)) + .isInstanceOf(IllegalArgumentException.class); + } +} From 7b682af4b617e4da706eb93f4153be0ed124f0ba Mon Sep 17 00:00:00 2001 From: chowon442 Date: Sat, 17 Oct 2026 15:57:33 +0900 Subject: [PATCH 06/34] =?UTF-8?q?feat:=20=EB=B8=8C=EB=9D=BC=EC=9A=B0?= =?UTF-8?q?=EC=A0=80=20run=20API=20=EA=B3=84=EC=95=BD=20browser-run-v1=20p?= =?UTF-8?q?in=C2=B7Java=20DTO=20=EC=86=8C=EB=B9=84=20=EA=B2=80=EC=82=AC?= =?UTF-8?q?=C2=B7OpenAPI=20(#214)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - proovy-brain 303a9e4의 browser-run-v1 fixture(+terminal-v2 browser schema)를 검증된 사본으로 복사하고 PIN.json(commit·consumer_version·manifest sha256)과 sha256 일치 테스트를 추가 (D9, token 없음) - com.proovy.domain.run.contract: 요청·상태·취소·대화 조회·frame DTO, 요청 판정기(RUN4001/4002), canonical JSON 요청 fingerprint, cursor 판정(RUN4003/4004), 공개 상태 투영, 계약 상수 - ErrorCode RUN4001~RUN4291, ApiResponse.failure(code, result) 추가형 변경 - 계약 DTO를 OpenAPI component schema로 등록(경로 없음, SwaggerConfig 변경 없음) - 소비 검사: 요청 valid/invalid·fingerprint·cursor·stream S1~S7·응답 왕복·오류 envelope·판정표를 Java로 재현하고 JSON Schema 2020-12(networknt, testImplementation)로 교차 검증 - 초안 문서는 인계용 안내(docs/contracts/browser-run-v1.md)로 대체 Closes #214 Co-Authored-By: Claude Opus 5.5 --- .gitattributes | 3 + build.gradle | 2 + docs/contracts/browser-run-v1.md | 48 + .../run/contract/ActiveRunConflict.java | 5 + .../domain/run/contract/ActiveRunSummary.java | 5 + .../domain/run/contract/AssistantCancel.java | 8 + .../domain/run/contract/AssistantFailure.java | 4 + .../run/contract/AssistantMessageContent.java | 19 + .../contract/BrowserRequestFingerprint.java | 74 + .../run/contract/BrowserRunContract.java | 106 + .../contract/BrowserRunContractOpenApi.java | 47 + .../domain/run/contract/BrowserRunFrame.java | 23 + .../domain/run/contract/CancelCause.java | 36 + .../domain/run/contract/CancelOutcome.java | 21 + .../domain/run/contract/CanonicalJson.java | 126 + .../run/contract/ConversationMessage.java | 17 + .../contract/ConversationMessageStatus.java | 10 + .../domain/run/contract/ConversationPage.java | 17 + .../run/contract/ConversationRunRequest.java | 27 + .../ConversationRunRequestValidator.java | 150 + .../domain/run/contract/MessageRole.java | 19 + .../domain/run/contract/PageSelection.java | 5 + .../domain/run/contract/PublicRunStatus.java | 33 + .../domain/run/contract/RequestedOutput.java | 20 + .../domain/run/contract/RequestedTask.java | 21 + .../ResponsePageCompletedPayload.java | 4 + .../contract/ResponsePageStartedPayload.java | 18 + .../domain/run/contract/RunCancelResult.java | 5 + .../run/contract/RunCanceledPayload.java | 13 + .../run/contract/RunCancelingPayload.java | 5 + .../run/contract/RunCreatedPayload.java | 19 + .../domain/run/contract/RunEventCursor.java | 88 + .../run/contract/RunFailureSummary.java | 8 + .../proovy/domain/run/contract/RunStatus.java | 27 + .../domain/run/contract/ServerFrameType.java | 33 + .../run/contract/TurnAttachmentMime.java | 21 + .../run/contract/TurnAttachmentView.java | 4 + .../run/contract/UserMessageContent.java | 13 + .../domain/run/contract/ViewerFocus.java | 9 + .../proovy/global/response/ApiResponse.java | 5 + .../com/proovy/global/response/ErrorCode.java | 13 + .../run/contract/BrainContractMirrorTest.java | 85 + .../BrowserRunContractOpenApiTest.java | 40 + .../BrowserRunContractTestSupport.java | 106 + .../BrowserRunDecisionTableContractTest.java | 136 + .../BrowserRunResponseContractTest.java | 167 + .../BrowserRunStreamContractTest.java | 276 ++ .../ConversationRunRequestContractTest.java | 112 + .../contract/RunEventCursorContractTest.java | 83 + src/test/resources/contracts/brain/PIN.json | 8 + src/test/resources/contracts/brain/README.md | 39 + .../contracts/brain/browser-run-v1/CATALOG.md | 366 ++ .../browser-run-v1/admission/decisions.json | 540 +++ .../brain/browser-run-v1/build_fixtures.py | 3306 +++++++++++++++++ .../brain/browser-run-v1/cancel/cases.json | 105 + .../brain/browser-run-v1/cursors/cases.json | 307 ++ .../fingerprint/different-attachment.json | 61 + .../different-inner-whitespace.json | 60 + .../fingerprint/different-latex.json | 60 + .../fingerprint/different-note-id-added.json | 17 + ...ifferent-outputs-explicit-vs-inferred.json | 58 + .../fingerprint/different-selection.json | 60 + .../fingerprint/different-tasks.json | 59 + .../fingerprint/different-text.json | 60 + .../fingerprint/different-viewer-page.json | 60 + .../fingerprint/same-array-order.json | 60 + .../same-empty-array-vs-absent.json | 21 + .../fingerprint/same-non-java-whitespace.json | 60 + .../fingerprint/same-null-selection.json | 27 + .../fingerprint/same-null-vs-absent.json | 20 + .../fingerprint/same-other-request-id.json | 60 + .../same-surrounding-whitespace.json | 60 + .../browser-run-v1/heartbeat-invalid.json | 20 + .../brain/browser-run-v1/heartbeat.txt | 2 + .../brain/browser-run-v1/manifest.json | 200 + .../requests/invalid/attachment-not-uuid.json | 15 + .../requests/invalid/blank-text.json | 12 + .../requests/invalid/duplicate-task.json | 16 + .../requests/invalid/empty-latex.json | 13 + .../requests/invalid/forbidden-attempt.json | 13 + .../invalid/forbidden-auth-token.json | 13 + .../requests/invalid/forbidden-budget.json | 13 + .../invalid/forbidden-conversation-id.json | 13 + .../invalid/forbidden-corpus-version.json | 13 + .../requests/invalid/forbidden-credit.json | 13 + .../requests/invalid/forbidden-deadline.json | 13 + .../invalid/forbidden-fence-token.json | 13 + .../requests/invalid/forbidden-history.json | 13 + .../invalid/forbidden-input-scope.json | 13 + .../requests/invalid/forbidden-locale.json | 13 + .../invalid/forbidden-retrieval-scope.json | 13 + .../requests/invalid/forbidden-run-id.json | 13 + .../requests/invalid/forbidden-scope.json | 13 + .../forbidden-turn-attachment-refs.json | 13 + .../requests/invalid/forbidden-usage.json | 13 + .../requests/invalid/forbidden-user-id.json | 13 + .../forbidden-verified-solution-refs.json | 13 + .../requests/invalid/latex-too-long.json | 13 + .../requests/invalid/missing-request-id.json | 11 + .../requests/invalid/missing-text.json | 11 + .../requests/invalid/non-uuid-request-id.json | 12 + .../note-asset-id-trailing-newline.json | 16 + .../invalid/note-id-leading-zero.json | 13 + .../requests/invalid/note-id-number.json | 13 + .../invalid/note-id-over-long-max.json | 13 + .../invalid/note-id-trailing-newline.json | 13 + .../invalid/request-id-trailing-newline.json | 12 + .../requests/invalid/selection-inverted.json | 22 + .../invalid/selection-out-of-range.json | 22 + .../invalid/text-too-long-astral.json | 12 + .../requests/invalid/text-too-long.json | 12 + .../invalid/too-many-attachments-wire.json | 25 + .../requests/invalid/unknown-field.json | 13 + .../invalid/unknown-task-variant.json | 15 + .../invalid/uppercase-request-id.json | 12 + .../requests/invalid/video-output.json | 16 + .../invalid/viewer-focus-missing-page.json | 15 + .../requests/invalid/viewer-page-zero.json | 16 + .../requests/valid/empty-arrays.json | 13 + .../requests/valid/existing-note-full.json | 31 + .../requests/valid/first-no-note.json | 9 + .../latex-at-limit-surrounded-by-blanks.json | 10 + .../valid/natural-language-variant.json | 9 + .../requests/valid/note-id-long-max.json | 10 + .../requests/valid/text-at-limit-astral.json | 9 + .../text-at-limit-surrounded-by-blanks.json | 9 + .../requests/valid/text-at-limit.json | 9 + .../valid/viewer-focus-no-selection.json | 15 + .../invalid/cancel-outcome-mismatch.json | 14 + .../invalid/cancel-with-fence-token.json | 15 + .../conversation-active-run-deadline.json | 20 + ...nversation-active-run-terminal-status.json | 19 + ...nversation-completed-without-response.json | 31 + .../invalid/conversation-failure-class.json | 37 + .../conversation-streaming-with-response.json | 84 + .../error-active-run-without-result.json | 9 + .../invalid/error-message-mismatch.json | 9 + .../error-result-on-request-id-conflict.json | 12 + .../status-completed-without-disposition.json | 23 + .../status-failed-without-failure.json | 23 + .../responses/invalid/status-retrying.json | 23 + .../status-running-not-cancelable.json | 23 + .../invalid/status-with-attempt.json | 24 + .../responses/invalid/status-with-usage.json | 26 + .../valid/cancel-accepted-pending.json | 14 + .../valid/cancel-accepted-running.json | 14 + .../valid/cancel-already-canceling.json | 14 + .../valid/cancel-already-terminal.json | 14 + .../valid/conversation-active-run.json | 170 + ...rsation-clarification-failed-canceled.json | 172 + .../valid/conversation-completed.json | 122 + .../responses/valid/error-auth4010.json | 9 + .../responses/valid/error-auth4012.json | 9 + .../responses/valid/error-auth4013.json | 9 + .../responses/valid/error-run4001.json | 9 + .../responses/valid/error-run4002.json | 9 + .../responses/valid/error-run4003.json | 9 + .../responses/valid/error-run4004.json | 9 + .../responses/valid/error-run4021.json | 9 + .../responses/valid/error-run4041.json | 9 + .../responses/valid/error-run4091.json | 12 + .../responses/valid/error-run4092.json | 9 + .../responses/valid/error-run4221.json | 9 + .../responses/valid/error-run4291.json | 9 + .../responses/valid/status-canceled.json | 24 + .../responses/valid/status-canceling.json | 23 + .../valid/status-completed-clarification.json | 24 + .../valid/status-completed-substantive.json | 24 + .../status-failed-invalid-attachment.json | 28 + .../responses/valid/status-failed.json | 28 + .../responses/valid/status-pending.json | 23 + .../responses/valid/status-running.json | 23 + .../schemas/browser-run.schema.json | 1998 ++++++++++ .../invalid/activity-without-start.json | 241 ++ .../assistant-message-id-mismatch.json | 254 ++ .../streams/invalid/attempt-in-frame.json | 255 ++ .../invalid/cancel-message-mismatch.json | 111 + .../streams/invalid/cancel-unknown-cause.json | 111 + .../streams/invalid/event-after-terminal.json | 267 ++ .../invalid/failure-class-in-run-failed.json | 115 + .../invalid/fence-token-in-payload.json | 255 ++ .../invalid/first-not-run-created.json | 234 ++ .../streams/invalid/non-uuid-run-id.json | 111 + .../streams/invalid/page-after-canceling.json | 125 + .../invalid/page-id-not-in-completed.json | 254 ++ .../streams/invalid/page-in-failed-run.json | 128 + .../streams/invalid/page-order-mismatch.json | 254 ++ .../streams/invalid/page-unpaired.json | 242 ++ .../retrying-status-in-run-created.json | 254 ++ .../invalid/run-created-missing-note-id.json | 253 ++ .../streams/invalid/run-id-mismatch.json | 254 ++ .../streams/invalid/seq-gap.json | 254 ++ .../invalid/source-event-id-in-frame.json | 255 ++ .../streams/invalid/two-terminals.json | 267 ++ .../valid/canceled-asset-deleting.json | 90 + .../streams/valid/canceled-pending.json | 42 + .../streams/valid/canceled-running.json | 113 + .../valid/clarification-completed.json | 121 + .../valid/failed-invalid-attachment.json | 43 + .../streams/valid/failed-verification.json | 116 + .../valid/replay-source-unavailable.json | 470 +++ .../streams/valid/substantive-completed.json | 256 ++ .../valid/substantive-with-source.json | 238 ++ .../valid/unsupported-only-completed.json | 120 + .../brain/terminal-v2/browser.schema.json | 2750 ++++++++++++++ 205 files changed, 20633 insertions(+) create mode 100644 docs/contracts/browser-run-v1.md create mode 100644 src/main/java/com/proovy/domain/run/contract/ActiveRunConflict.java create mode 100644 src/main/java/com/proovy/domain/run/contract/ActiveRunSummary.java create mode 100644 src/main/java/com/proovy/domain/run/contract/AssistantCancel.java create mode 100644 src/main/java/com/proovy/domain/run/contract/AssistantFailure.java create mode 100644 src/main/java/com/proovy/domain/run/contract/AssistantMessageContent.java create mode 100644 src/main/java/com/proovy/domain/run/contract/BrowserRequestFingerprint.java create mode 100644 src/main/java/com/proovy/domain/run/contract/BrowserRunContract.java create mode 100644 src/main/java/com/proovy/domain/run/contract/BrowserRunContractOpenApi.java create mode 100644 src/main/java/com/proovy/domain/run/contract/BrowserRunFrame.java create mode 100644 src/main/java/com/proovy/domain/run/contract/CancelCause.java create mode 100644 src/main/java/com/proovy/domain/run/contract/CancelOutcome.java create mode 100644 src/main/java/com/proovy/domain/run/contract/CanonicalJson.java create mode 100644 src/main/java/com/proovy/domain/run/contract/ConversationMessage.java create mode 100644 src/main/java/com/proovy/domain/run/contract/ConversationMessageStatus.java create mode 100644 src/main/java/com/proovy/domain/run/contract/ConversationPage.java create mode 100644 src/main/java/com/proovy/domain/run/contract/ConversationRunRequest.java create mode 100644 src/main/java/com/proovy/domain/run/contract/ConversationRunRequestValidator.java create mode 100644 src/main/java/com/proovy/domain/run/contract/MessageRole.java create mode 100644 src/main/java/com/proovy/domain/run/contract/PageSelection.java create mode 100644 src/main/java/com/proovy/domain/run/contract/PublicRunStatus.java create mode 100644 src/main/java/com/proovy/domain/run/contract/RequestedOutput.java create mode 100644 src/main/java/com/proovy/domain/run/contract/RequestedTask.java create mode 100644 src/main/java/com/proovy/domain/run/contract/ResponsePageCompletedPayload.java create mode 100644 src/main/java/com/proovy/domain/run/contract/ResponsePageStartedPayload.java create mode 100644 src/main/java/com/proovy/domain/run/contract/RunCancelResult.java create mode 100644 src/main/java/com/proovy/domain/run/contract/RunCanceledPayload.java create mode 100644 src/main/java/com/proovy/domain/run/contract/RunCancelingPayload.java create mode 100644 src/main/java/com/proovy/domain/run/contract/RunCreatedPayload.java create mode 100644 src/main/java/com/proovy/domain/run/contract/RunEventCursor.java create mode 100644 src/main/java/com/proovy/domain/run/contract/RunFailureSummary.java create mode 100644 src/main/java/com/proovy/domain/run/contract/RunStatus.java create mode 100644 src/main/java/com/proovy/domain/run/contract/ServerFrameType.java create mode 100644 src/main/java/com/proovy/domain/run/contract/TurnAttachmentMime.java create mode 100644 src/main/java/com/proovy/domain/run/contract/TurnAttachmentView.java create mode 100644 src/main/java/com/proovy/domain/run/contract/UserMessageContent.java create mode 100644 src/main/java/com/proovy/domain/run/contract/ViewerFocus.java create mode 100644 src/test/java/com/proovy/domain/run/contract/BrainContractMirrorTest.java create mode 100644 src/test/java/com/proovy/domain/run/contract/BrowserRunContractOpenApiTest.java create mode 100644 src/test/java/com/proovy/domain/run/contract/BrowserRunContractTestSupport.java create mode 100644 src/test/java/com/proovy/domain/run/contract/BrowserRunDecisionTableContractTest.java create mode 100644 src/test/java/com/proovy/domain/run/contract/BrowserRunResponseContractTest.java create mode 100644 src/test/java/com/proovy/domain/run/contract/BrowserRunStreamContractTest.java create mode 100644 src/test/java/com/proovy/domain/run/contract/ConversationRunRequestContractTest.java create mode 100644 src/test/java/com/proovy/domain/run/contract/RunEventCursorContractTest.java create mode 100644 src/test/resources/contracts/brain/PIN.json create mode 100644 src/test/resources/contracts/brain/README.md create mode 100644 src/test/resources/contracts/brain/browser-run-v1/CATALOG.md create mode 100644 src/test/resources/contracts/brain/browser-run-v1/admission/decisions.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/build_fixtures.py create mode 100644 src/test/resources/contracts/brain/browser-run-v1/cancel/cases.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/cursors/cases.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/fingerprint/different-attachment.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/fingerprint/different-inner-whitespace.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/fingerprint/different-latex.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/fingerprint/different-note-id-added.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/fingerprint/different-outputs-explicit-vs-inferred.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/fingerprint/different-selection.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/fingerprint/different-tasks.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/fingerprint/different-text.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/fingerprint/different-viewer-page.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/fingerprint/same-array-order.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/fingerprint/same-empty-array-vs-absent.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/fingerprint/same-non-java-whitespace.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/fingerprint/same-null-selection.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/fingerprint/same-null-vs-absent.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/fingerprint/same-other-request-id.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/fingerprint/same-surrounding-whitespace.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/heartbeat-invalid.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/heartbeat.txt create mode 100644 src/test/resources/contracts/brain/browser-run-v1/manifest.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/requests/invalid/attachment-not-uuid.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/requests/invalid/blank-text.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/requests/invalid/duplicate-task.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/requests/invalid/empty-latex.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-attempt.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-auth-token.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-budget.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-conversation-id.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-corpus-version.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-credit.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-deadline.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-fence-token.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-history.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-input-scope.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-locale.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-retrieval-scope.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-run-id.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-scope.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-turn-attachment-refs.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-usage.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-user-id.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-verified-solution-refs.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/requests/invalid/latex-too-long.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/requests/invalid/missing-request-id.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/requests/invalid/missing-text.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/requests/invalid/non-uuid-request-id.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/requests/invalid/note-asset-id-trailing-newline.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/requests/invalid/note-id-leading-zero.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/requests/invalid/note-id-number.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/requests/invalid/note-id-over-long-max.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/requests/invalid/note-id-trailing-newline.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/requests/invalid/request-id-trailing-newline.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/requests/invalid/selection-inverted.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/requests/invalid/selection-out-of-range.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/requests/invalid/text-too-long-astral.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/requests/invalid/text-too-long.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/requests/invalid/too-many-attachments-wire.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/requests/invalid/unknown-field.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/requests/invalid/unknown-task-variant.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/requests/invalid/uppercase-request-id.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/requests/invalid/video-output.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/requests/invalid/viewer-focus-missing-page.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/requests/invalid/viewer-page-zero.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/requests/valid/empty-arrays.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/requests/valid/existing-note-full.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/requests/valid/first-no-note.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/requests/valid/latex-at-limit-surrounded-by-blanks.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/requests/valid/natural-language-variant.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/requests/valid/note-id-long-max.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/requests/valid/text-at-limit-astral.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/requests/valid/text-at-limit-surrounded-by-blanks.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/requests/valid/text-at-limit.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/requests/valid/viewer-focus-no-selection.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/responses/invalid/cancel-outcome-mismatch.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/responses/invalid/cancel-with-fence-token.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/responses/invalid/conversation-active-run-deadline.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/responses/invalid/conversation-active-run-terminal-status.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/responses/invalid/conversation-completed-without-response.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/responses/invalid/conversation-failure-class.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/responses/invalid/conversation-streaming-with-response.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/responses/invalid/error-active-run-without-result.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/responses/invalid/error-message-mismatch.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/responses/invalid/error-result-on-request-id-conflict.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/responses/invalid/status-completed-without-disposition.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/responses/invalid/status-failed-without-failure.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/responses/invalid/status-retrying.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/responses/invalid/status-running-not-cancelable.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/responses/invalid/status-with-attempt.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/responses/invalid/status-with-usage.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/responses/valid/cancel-accepted-pending.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/responses/valid/cancel-accepted-running.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/responses/valid/cancel-already-canceling.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/responses/valid/cancel-already-terminal.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/responses/valid/conversation-active-run.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/responses/valid/conversation-clarification-failed-canceled.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/responses/valid/conversation-completed.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/responses/valid/error-auth4010.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/responses/valid/error-auth4012.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/responses/valid/error-auth4013.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/responses/valid/error-run4001.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/responses/valid/error-run4002.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/responses/valid/error-run4003.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/responses/valid/error-run4004.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/responses/valid/error-run4021.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/responses/valid/error-run4041.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/responses/valid/error-run4091.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/responses/valid/error-run4092.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/responses/valid/error-run4221.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/responses/valid/error-run4291.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/responses/valid/status-canceled.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/responses/valid/status-canceling.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/responses/valid/status-completed-clarification.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/responses/valid/status-completed-substantive.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/responses/valid/status-failed-invalid-attachment.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/responses/valid/status-failed.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/responses/valid/status-pending.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/responses/valid/status-running.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/schemas/browser-run.schema.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/streams/invalid/activity-without-start.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/streams/invalid/assistant-message-id-mismatch.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/streams/invalid/attempt-in-frame.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/streams/invalid/cancel-message-mismatch.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/streams/invalid/cancel-unknown-cause.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/streams/invalid/event-after-terminal.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/streams/invalid/failure-class-in-run-failed.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/streams/invalid/fence-token-in-payload.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/streams/invalid/first-not-run-created.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/streams/invalid/non-uuid-run-id.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/streams/invalid/page-after-canceling.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/streams/invalid/page-id-not-in-completed.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/streams/invalid/page-in-failed-run.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/streams/invalid/page-order-mismatch.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/streams/invalid/page-unpaired.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/streams/invalid/retrying-status-in-run-created.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/streams/invalid/run-created-missing-note-id.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/streams/invalid/run-id-mismatch.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/streams/invalid/seq-gap.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/streams/invalid/source-event-id-in-frame.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/streams/invalid/two-terminals.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/streams/valid/canceled-asset-deleting.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/streams/valid/canceled-pending.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/streams/valid/canceled-running.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/streams/valid/clarification-completed.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/streams/valid/failed-invalid-attachment.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/streams/valid/failed-verification.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/streams/valid/replay-source-unavailable.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/streams/valid/substantive-completed.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/streams/valid/substantive-with-source.json create mode 100644 src/test/resources/contracts/brain/browser-run-v1/streams/valid/unsupported-only-completed.json create mode 100644 src/test/resources/contracts/brain/terminal-v2/browser.schema.json 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/build.gradle b/build.gradle index e1122748..a3a7a0c1 100644 --- a/build.gradle +++ b/build.gradle @@ -73,6 +73,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/browser-run-v1.md b/docs/contracts/browser-run-v1.md new file mode 100644 index 00000000..08e03018 --- /dev/null +++ b/docs/contracts/browser-run-v1.md @@ -0,0 +1,48 @@ +# 브라우저 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. 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/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..76dd7432 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/contract/AssistantMessageContent.java @@ -0,0 +1,19 @@ +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는 본문이 없다(Candidate 비공개). + */ +public record AssistantMessageContent( + String responseDisposition, + String clarificationReason, + JsonNode assistantResponse, + List verificationSummary, + AssistantFailure failure, + AssistantCancel cancel +) { +} 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..8d40302f --- /dev/null +++ b/src/main/java/com/proovy/domain/run/contract/BrowserRunContract.java @@ -0,0 +1,106 @@ +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})다. 나노초 시각은 마이크로초로 절단해 쓴다. */ + public static Instant wireTime(Instant instant) { + return 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..3006ec59 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/contract/BrowserRunContractOpenApi.java @@ -0,0 +1,47 @@ +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.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); + + @Bean + public OpenApiCustomizer browserRunContractSchemas() { + return BrowserRunContractOpenApi::addSchemas; + } + + static void addSchemas(OpenAPI openApi) { + if (openApi.getComponents() == null) { + openApi.setComponents(new Components()); + } + Components components = openApi.getComponents(); + for (Class type : SCHEMA_TYPES) { + ModelConverters.getInstance().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..d4af40d8 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/contract/BrowserRunFrame.java @@ -0,0 +1,23 @@ +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 +) { +} 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..b367773b --- /dev/null +++ b/src/main/java/com/proovy/domain/run/contract/ConversationMessage.java @@ -0,0 +1,17 @@ +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 +) { +} 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..bc2e1bb5 --- /dev/null +++ b/src/main/java/com/proovy/domain/run/contract/ConversationRunRequestValidator.java @@ -0,0 +1,150 @@ +package com.proovy.domain.run.contract; + +import com.fasterxml.jackson.databind.JsonNode; +import com.proovy.global.response.ErrorCode; +import java.util.HashSet; +import java.util.Iterator; +import java.util.Optional; +import java.util.Set; +import java.util.function.Predicate; + +/** + * 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 = Set.of("solve", "check", "explain"); + private static final Set OUTPUTS = Set.of("text", "video"); + + 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..0cea51ac --- /dev/null +++ b/src/main/java/com/proovy/domain/run/contract/RunStatus.java @@ -0,0 +1,27 @@ +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 +) { +} 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/global/response/ApiResponse.java b/src/main/java/com/proovy/global/response/ApiResponse.java index c4c009f5..b884ea71 100644 --- a/src/main/java/com/proovy/global/response/ApiResponse.java +++ b/src/main/java/com/proovy/global/response/ApiResponse.java @@ -53,4 +53,9 @@ public static ApiResponse failure(String code, String message) { public static ApiResponse failure(ErrorCode errorCode) { return new ApiResponse<>(false, errorCode.getCode(), errorCode.getMessage(), null); } + + // 실패 응답 (ErrorCode + 복구용 result, 예: RUN4091 activeRunId) + public static ApiResponse failure(ErrorCode errorCode, T result) { + return new ApiResponse<>(false, errorCode.getCode(), errorCode.getMessage(), result); + } } diff --git a/src/main/java/com/proovy/global/response/ErrorCode.java b/src/main/java/com/proovy/global/response/ErrorCode.java index 2b6192a4..56106429 100644 --- a/src/main/java/com/proovy/global/response/ErrorCode.java +++ b/src/main/java/com/proovy/global/response/ErrorCode.java @@ -68,6 +68,19 @@ public enum ErrorCode { CONV4001("CONV4001", "잘못된 기능 값이 포함되어 있습니다.", HttpStatus.BAD_REQUEST), CONV5001("CONV5001", "Proovy-ai 통신 중 오류가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), CONV5002("CONV5002", "Proovy-ai 스트리밍 중 오류가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), + + // Run (브라우저 run API browser-run-v1 — 문구는 계약 fixture와 같아야 한다) + RUN4001("RUN4001", "요청 형식이 올바르지 않습니다.", HttpStatus.BAD_REQUEST), + RUN4002("RUN4002", "지원하지 않는 요청 항목이 포함되어 있습니다.", HttpStatus.BAD_REQUEST), + RUN4003("RUN4003", "이벤트 재개 위치가 올바르지 않습니다.", HttpStatus.BAD_REQUEST), + RUN4004("RUN4004", "다른 실행의 이벤트 재개 위치입니다.", HttpStatus.BAD_REQUEST), + RUN4021("RUN4021", "사용 가능한 잔여 사용량이 부족합니다.", HttpStatus.PAYMENT_REQUIRED), + RUN4041("RUN4041", "대상을 찾을 수 없습니다.", HttpStatus.NOT_FOUND), + RUN4091("RUN4091", "이 노트에서 진행 중인 답변이 있습니다.", HttpStatus.CONFLICT), + RUN4092("RUN4092", "이미 사용된 요청 ID입니다.", HttpStatus.CONFLICT), + RUN4221("RUN4221", "첨부 또는 참조한 자료를 사용할 수 없습니다.", HttpStatus.UNPROCESSABLE_ENTITY), + RUN4291("RUN4291", "요청이 너무 많습니다. 잠시 후 다시 시도해 주세요.", HttpStatus.TOO_MANY_REQUESTS), + // Note NOTE4001("NOTE4001", "노트 제목은 30자 이내로 입력해주세요.", HttpStatus.BAD_REQUEST), NOTE4002("NOTE4002", "노트 제목을 입력해주세요.", HttpStatus.BAD_REQUEST), diff --git a/src/test/java/com/proovy/domain/run/contract/BrainContractMirrorTest.java b/src/test/java/com/proovy/domain/run/contract/BrainContractMirrorTest.java new file mode 100644 index 00000000..dd9d5601 --- /dev/null +++ b/src/test/java/com/proovy/domain/run/contract/BrainContractMirrorTest.java @@ -0,0 +1,85 @@ +package com.proovy.domain.run.contract; + +import static com.proovy.domain.run.contract.BrowserRunContractTestSupport.EXPECTED_CONSUMER_VERSION; +import static com.proovy.domain.run.contract.BrowserRunContractTestSupport.MIRROR; +import static com.proovy.domain.run.contract.BrowserRunContractTestSupport.load; +import static com.proovy.domain.run.contract.BrowserRunContractTestSupport.read; +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.JsonNode; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HashSet; +import java.util.HexFormat; +import java.util.Set; +import java.util.stream.Stream; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** D9: Server 사본은 pin된 Brain commit의 검증된 mirror여야 한다. */ +class BrainContractMirrorTest { + + private static final JsonNode PIN = read(MIRROR.resolve("PIN.json")); + private static final JsonNode MANIFEST = load("manifest.json"); + + @Test + @DisplayName("pin의 manifest sha256·consumer_version이 사본 manifest와 같다") + void pinMatchesManifest() throws Exception { + assertThat(PIN.get("repository").asText()).isEqualTo("Team-Proovy/proovy-brain"); + assertThat(PIN.get("commit").asText()).matches("[0-9a-f]{40}"); + assertThat(sha256(MIRROR.resolve(PIN.get("manifest").asText()))) + .isEqualTo(PIN.get("manifest_sha256").asText()); + assertThat(MANIFEST.get("consumer_version").asText()) + .isEqualTo(PIN.get("consumer_version").asText()) + .isEqualTo(EXPECTED_CONSUMER_VERSION); + assertThat(MANIFEST.get("original_path").asText()) + .isEqualTo(PIN.get("source_path").asText() + "/browser-run-v1"); + } + + @Test + @DisplayName("manifest에 적힌 모든 파일의 sha256이 사본과 같고 목록 밖 파일이 없다") + void everyFileMatchesManifestDigest() throws Exception { + Set listed = new HashSet<>(); + for (String section : new String[]{"files", "dependencies"}) { + for (var entry : MANIFEST.get(section).properties()) { + assertThat(sha256(MIRROR.resolve(entry.getKey()))) + .as(entry.getKey()) + .isEqualTo(entry.getValue().asText()); + listed.add(entry.getKey()); + } + } + listed.add("browser-run-v1/manifest.json"); + listed.add("PIN.json"); + listed.add("README.md"); + try (Stream files = Files.walk(MIRROR)) { + Set onDisk = new HashSet<>(); + files.filter(Files::isRegularFile) + .forEach(path -> onDisk.add(MIRROR.relativize(path).toString().replace('\\', '/'))); + assertThat(onDisk).isEqualTo(listed); + } + } + + @Test + @DisplayName("Java 계약 상수가 manifest의 version·limits와 같다") + void constantsMatchManifest() { + JsonNode limits = MANIFEST.get("limits"); + assertThat(MANIFEST.get("contract_version").asText()).isEqualTo(BrowserRunContract.CONTRACT_VERSION); + assertThat(MANIFEST.get("frame_contract_version").asText()) + .isEqualTo(BrowserRunContract.FRAME_CONTRACT_VERSION); + assertThat(MANIFEST.get("fingerprint_algorithm").asText()) + .isEqualTo(BrowserRunContract.FINGERPRINT_ALGORITHM); + assertThat(limits.get("text_max_code_points").asInt()).isEqualTo(BrowserRunContract.TEXT_MAX_CODE_POINTS); + assertThat(limits.get("latex_max_code_points").asInt()).isEqualTo(BrowserRunContract.LATEX_MAX_CODE_POINTS); + assertThat(limits.get("turn_attachment_ids_wire_max").asInt()) + .isEqualTo(BrowserRunContract.TURN_ATTACHMENT_IDS_WIRE_MAX); + assertThat(limits.get("heartbeat_seconds").asInt()).isEqualTo(BrowserRunContract.HEARTBEAT_SECONDS); + assertThat(limits.get("max_seq").asLong()).isEqualTo(BrowserRunContract.MAX_SEQ); + } + + private static String sha256(Path path) throws IOException, NoSuchAlgorithmException { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(path))); + } +} diff --git a/src/test/java/com/proovy/domain/run/contract/BrowserRunContractOpenApiTest.java b/src/test/java/com/proovy/domain/run/contract/BrowserRunContractOpenApiTest.java new file mode 100644 index 00000000..65ac99ce --- /dev/null +++ b/src/test/java/com/proovy/domain/run/contract/BrowserRunContractOpenApiTest.java @@ -0,0 +1,40 @@ +package com.proovy.domain.run.contract; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.media.Schema; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class BrowserRunContractOpenApiTest { + + @Test + @DisplayName("계약 DTO가 경로 없이 component schema로 등록되고 enum은 wire 값으로 표기된다") + void registersComponentSchemas() { + OpenAPI openApi = new OpenAPI(); + new BrowserRunContractOpenApi().browserRunContractSchemas().customise(openApi); + + Map schemas = openApi.getComponents().getSchemas(); + assertThat(schemas).containsKeys( + "ConversationRunRequest", "ViewerFocus", "PageSelection", "ActiveRunConflict", "RunStatus", + "RunFailureSummary", "RunCancelResult", "ConversationPage", "ConversationMessage", + "BrowserRunFrame", "RunCreatedPayload", "RunCanceledPayload"); + assertThat(openApi.getPaths()).isNull(); + Schema request = schemas.get("ConversationRunRequest"); + assertThat(request.getProperties()).containsOnlyKeys( + "requestId", "noteId", "text", "latex", "requestedTasks", "requestedOutputs", + "turnAttachmentIds", "viewerFocus"); + Schema tasks = (Schema) request.getProperties().get("requestedTasks"); + assertThat(enumValues(tasks.getItems())).containsExactly("solve", "check", "explain"); + Schema cancel = schemas.get("RunCancelResult"); + assertThat(enumValues((Schema) cancel.getProperties().get("outcome"))) + .containsExactly("accepted", "already_canceling", "already_terminal"); + } + + private static List enumValues(Schema schema) { + return schema.getEnum().stream().map(String::valueOf).toList(); + } +} diff --git a/src/test/java/com/proovy/domain/run/contract/BrowserRunContractTestSupport.java b/src/test/java/com/proovy/domain/run/contract/BrowserRunContractTestSupport.java new file mode 100644 index 00000000..0ea9313e --- /dev/null +++ b/src/test/java/com/proovy/domain/run/contract/BrowserRunContractTestSupport.java @@ -0,0 +1,106 @@ +package com.proovy.domain.run.contract; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.networknt.schema.JsonSchema; +import com.networknt.schema.JsonSchemaFactory; +import com.networknt.schema.SpecVersion; +import com.networknt.schema.ValidationMessage; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.URISyntaxException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Stream; +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; + +/** + * pin된 proovy-brain 계약 사본(src/test/resources/contracts/brain)을 읽는 테스트 도우미. + * 사본 자체의 무결성은 {@link BrainContractMirrorTest}가 검사한다. + */ +final class BrowserRunContractTestSupport { + + /** Java 쪽이 맞춰 둔 consumer_version. 사본을 새 version으로 갱신하면 함께 올린다. */ + static final String EXPECTED_CONSUMER_VERSION = "browser-run-fixtures-v1"; + static final String SCHEMA_BASE = "https://proovy.invalid/contracts/"; + static final String SCHEMA_ID = SCHEMA_BASE + "browser-run-v1/schemas/browser-run.schema.json"; + + /** Spring Boot 기본 ObjectMapper와 같은 설정(ISO 시각, 알 수 없는 필드 무시). */ + static final ObjectMapper MAPPER = Jackson2ObjectMapperBuilder.json().build(); + static final Path MIRROR = mirrorRoot(); + static final Path FIXTURES = MIRROR.resolve("browser-run-v1"); + + private static final JsonSchemaFactory SCHEMAS = JsonSchemaFactory.getInstance( + SpecVersion.VersionFlag.V202012, + builder -> builder.schemaMappers(mappers -> mappers.mapPrefix(SCHEMA_BASE, "classpath:contracts/brain/"))); + private static final Map DEFINITIONS = new ConcurrentHashMap<>(); + + private BrowserRunContractTestSupport() { + } + + private static Path mirrorRoot() { + try { + return Path.of(BrowserRunContractTestSupport.class.getClassLoader() + .getResource("contracts/brain").toURI()); + } catch (URISyntaxException e) { + throw new IllegalStateException(e); + } + } + + static JsonNode load(String relative) { + return read(FIXTURES.resolve(relative)); + } + + static JsonNode read(Path path) { + try { + return MAPPER.readTree(path.toFile()); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + static String text(String relative) { + try { + return Files.readString(FIXTURES.resolve(relative)); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + /** 디렉터리의 *.json fixture를 이름순으로. */ + static List> entries(String directory) { + try (Stream files = Files.list(FIXTURES.resolve(directory))) { + return files.filter(path -> path.toString().endsWith(".json")) + .sorted() + .map(path -> Map.entry(path.getFileName().toString().replace(".json", ""), read(path))) + .toList(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + /** browser-run-v1 schema의 {@code $defs/}으로 검증한 오류 목록. */ + static Set schemaErrors(String definition, JsonNode value) { + JsonSchema schema = DEFINITIONS.computeIfAbsent(definition, name -> SCHEMAS.getSchema( + "{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\"," + + "\"$ref\":\"" + SCHEMA_ID + "#/$defs/" + name + "\"}")); + return schema.validate(value); + } + + static boolean schemaOk(String definition, JsonNode value) { + return schemaErrors(definition, value).isEmpty(); + } + + /** DTO를 실제 wire 텍스트로 직렬화한 뒤 다시 읽는다(숫자 노드 타입 차이 없이 fixture와 비교). */ + static JsonNode toTree(Object value) { + try { + return MAPPER.readTree(MAPPER.writeValueAsString(value)); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/src/test/java/com/proovy/domain/run/contract/BrowserRunDecisionTableContractTest.java b/src/test/java/com/proovy/domain/run/contract/BrowserRunDecisionTableContractTest.java new file mode 100644 index 00000000..372259be --- /dev/null +++ b/src/test/java/com/proovy/domain/run/contract/BrowserRunDecisionTableContractTest.java @@ -0,0 +1,136 @@ +package com.proovy.domain.run.contract; + +import static com.proovy.domain.run.contract.BrowserRunContractTestSupport.load; +import static com.proovy.domain.run.contract.BrowserRunContractTestSupport.toTree; +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.JsonNode; +import com.proovy.global.response.ErrorCode; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * admission/decisions.json·cancel/cases.json 판정표를 Java 참조 판정으로 재현한다. + * 실제 접수·취소 처리는 후속 태스크(1.79 등)가 이 판정 순서대로 구현한다. + */ +class BrowserRunDecisionTableContractTest { + + private static final Set TERMINAL = Set.of("COMPLETED", "FAILED", "CANCELED"); + + @Test + @DisplayName("POST 접수 판정은 precedence 순서를 따르고 각 오류 code가 ErrorCode와 같다") + void admissionDecisions() { + JsonNode decisions = load("admission/decisions.json"); + assertThat(toTree(List.of( + "authenticated", "request:schema_invalid", "request:unsupported", + "existing:same_user_same_fingerprint", "existing:other", "note", "references", + "activeRun", "rateLimited", "balance", "accept"))) + .isEqualTo(decisions.get("precedence")); + for (JsonNode testCase : decisions.get("cases")) { + String name = testCase.get("name").asText(); + assertThat(toTree(judgeAdmission(testCase.get("state")))).as(name).isEqualTo(testCase.get("expected")); + } + } + + @Test + @DisplayName("cancel 판정은 body 검사 → run 접근 → 결과 순서이고 결과는 응답 fixture와 같다") + void cancelCases() { + JsonNode cancel = load("cancel/cases.json"); + for (JsonNode testCase : cancel.get("cases")) { + String name = testCase.get("name").asText(); + JsonNode rawBody = testCase.get("rawBody"); + Map actual = judgeCancel(rawBody.isNull() ? null : rawBody.asText(), + testCase.get("run").asText()); + assertThat(toTree(actual)).as(name).isEqualTo(testCase.get("expected")); + if (actual.containsKey("fixture")) { + JsonNode response = load(actual.get("fixture") + ".json"); + assertThat(response.at("/body/result/status").asText()).as(name).isEqualTo(actual.get("status")); + } + } + } + + private static Map judgeAdmission(JsonNode state) { + if (!state.get("authenticated").asBoolean()) { + return error(ErrorCode.AUTH4010); + } + String request = state.get("request").asText(); + if (request.equals("schema_invalid")) { + return error(ErrorCode.RUN4001); + } + if (request.equals("unsupported")) { + return error(ErrorCode.RUN4002); + } + JsonNode existing = state.get("existing"); + if (!existing.isNull()) { + if (existing.get("sameUser").asBoolean() && existing.get("sameFingerprint").asBoolean()) { + Map replay = new LinkedHashMap<>(); + replay.put("http", 200); + replay.put("outcome", "stream_replay"); + replay.put("replayFromSeq", 1); + replay.put("thenLive", !TERMINAL.contains(existing.get("runStatus").asText())); + replay.put("sideEffects", "none"); + return replay; + } + return error(ErrorCode.RUN4092); + } + if (Set.of("missing", "other_user", "deleting").contains(state.get("note").asText())) { + return error(ErrorCode.RUN4041); + } + if (!state.get("references").asText().equals("ok")) { + return error(ErrorCode.RUN4221); + } + if (!state.get("activeRun").isNull()) { + Map conflict = error(ErrorCode.RUN4091); + conflict.put("result", new ActiveRunConflict(state.at("/activeRun/runId").asText())); + return conflict; + } + if (state.get("rateLimited").asBoolean()) { + Map limited = error(ErrorCode.RUN4291); + limited.put("headers", Map.of("Retry-After", "30")); + return limited; + } + if (!state.get("balance").asText().equals("sufficient")) { + return error(ErrorCode.RUN4021); + } + return new LinkedHashMap<>(Map.of( + "http", 200, "outcome", "stream_new", "replayFromSeq", 1, "sideEffects", "created")); + } + + private static Map judgeCancel(String rawBody, String run) { + if (rawBody != null) { + return codeOnly(ErrorCode.RUN4001); + } + if (run.equals("missing") || run.equals("other_user")) { + return codeOnly(ErrorCode.RUN4041); + } + String[] outcome = switch (run) { + case "pending" -> new String[]{"accepted-pending", "CANCELED"}; + case "running" -> new String[]{"accepted-running", "CANCELING"}; + case "canceling" -> new String[]{"already-canceling", "CANCELING"}; + case "completed" -> new String[]{"already-terminal", "COMPLETED"}; + default -> throw new IllegalArgumentException(run); + }; + Map result = new LinkedHashMap<>(); + result.put("http", 200); + result.put("fixture", "responses/valid/cancel-" + outcome[0]); + result.put("status", outcome[1]); + return result; + } + + private static Map error(ErrorCode code) { + Map error = new LinkedHashMap<>(); + error.put("http", code.getHttpStatus().value()); + error.put("outcome", "error"); + error.put("code", code.getCode()); + error.put("sideEffects", "none"); + return error; + } + + private static Map codeOnly(ErrorCode code) { + return new LinkedHashMap<>(Map.of("http", code.getHttpStatus().value(), "code", code.getCode())); + } +} diff --git a/src/test/java/com/proovy/domain/run/contract/BrowserRunResponseContractTest.java b/src/test/java/com/proovy/domain/run/contract/BrowserRunResponseContractTest.java new file mode 100644 index 00000000..781efc72 --- /dev/null +++ b/src/test/java/com/proovy/domain/run/contract/BrowserRunResponseContractTest.java @@ -0,0 +1,167 @@ +package com.proovy.domain.run.contract; + +import static com.proovy.domain.run.contract.BrowserRunContractTestSupport.MAPPER; +import static com.proovy.domain.run.contract.BrowserRunContractTestSupport.entries; +import static com.proovy.domain.run.contract.BrowserRunContractTestSupport.schemaErrors; +import static com.proovy.domain.run.contract.BrowserRunContractTestSupport.schemaOk; +import static com.proovy.domain.run.contract.BrowserRunContractTestSupport.toTree; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fasterxml.jackson.databind.JsonNode; +import com.proovy.domain.run.entity.AiRunStatus; +import com.proovy.domain.run.entity.ClarificationReason; +import com.proovy.domain.run.entity.ResponseDisposition; +import com.proovy.domain.run.entity.RunCancelCause; +import com.proovy.domain.run.entity.RunFailureCode; +import com.proovy.domain.run.entity.WireValue; +import com.proovy.global.response.ApiResponse; +import com.proovy.global.response.ErrorCode; +import java.time.Instant; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** responses/ fixture를 Java DTO·ApiResponse·ErrorCode로 재현한다. */ +class BrowserRunResponseContractTest { + + private static final Map> RESULT_TYPES = Map.of( + "RunStatusResponse", RunStatus.class, + "RunCancelResponse", RunCancelResult.class, + "ConversationPageResponse", ConversationPage.class); + + @Test + @DisplayName("정상 성공 응답은 DTO로 읽고 ApiResponse로 다시 쓰면 같은 JSON이며 schema를 통과한다") + void successResponsesRoundTrip() throws Exception { + int checked = 0; + for (Map.Entry entry : entries("responses/valid")) { + JsonNode fixture = entry.getValue(); + Class type = RESULT_TYPES.get(fixture.get("schema").asText()); + if (type == null) { + continue; + } + Object result = MAPPER.treeToValue(fixture.at("/body/result"), type); + JsonNode written = toTree(ApiResponse.success(result)); + assertThat(written).as(entry.getKey()).isEqualTo(fixture.get("body")); + assertThat(schemaErrors(fixture.get("schema").asText(), written)).as(entry.getKey()).isEmpty(); + checked++; + } + assertThat(checked).isEqualTo(15); + } + + @Test + @DisplayName("오류 응답은 ErrorCode의 code·HTTP·고정 message와 같고 RUN4091만 activeRunId를 담는다") + void errorResponsesMatchErrorCode() { + Set codes = new HashSet<>(); + for (Map.Entry entry : entries("responses/valid")) { + JsonNode fixture = entry.getValue(); + if (!fixture.get("schema").asText().equals("ErrorResponse")) { + continue; + } + JsonNode body = fixture.get("body"); + ErrorCode code = ErrorCode.valueOf(body.get("code").asText()); + assertThat(code.getHttpStatus().value()).as(entry.getKey()).isEqualTo(fixture.get("http").asInt()); + ApiResponse response = code == ErrorCode.RUN4091 + ? ApiResponse.failure(code, new ActiveRunConflict(body.at("/result/activeRunId").asText())) + : ApiResponse.failure(code); + assertThat(toTree(response)).as(entry.getKey()).isEqualTo(body); + codes.add(code.getCode()); + } + assertThat(codes).containsExactlyInAnyOrder( + "AUTH4010", "AUTH4012", "AUTH4013", "RUN4001", "RUN4002", "RUN4003", "RUN4004", + "RUN4021", "RUN4041", "RUN4091", "RUN4092", "RUN4221", "RUN4291"); + } + + @Test + @DisplayName("거부 응답은 schema가 거부하고, 내부 RETRYING 노출은 DTO도 읽지 않는다") + void invalidResponsesAreRejected() { + for (Map.Entry entry : entries("responses/invalid")) { + JsonNode fixture = entry.getValue(); + assertThat(schemaOk(fixture.get("schema").asText(), fixture.get("body"))).as(entry.getKey()).isFalse(); + } + JsonNode retrying = BrowserRunContractTestSupport.load("responses/invalid/status-retrying.json"); + assertThatThrownBy(() -> MAPPER.treeToValue(retrying.at("/body/result"), RunStatus.class)) + .hasMessageContaining("RETRYING"); + } + + @Test + @DisplayName("공개 상태는 내부 상태를 투영한다 — RETRYING은 RUNNING, cancelable은 PENDING·RUNNING") + void publicStatusProjection() { + for (AiRunStatus status : AiRunStatus.values()) { + PublicRunStatus projected = PublicRunStatus.from(status); + assertThat(projected.isTerminal()).as(status.name()).isEqualTo(status.isTerminal()); + } + assertThat(PublicRunStatus.from(AiRunStatus.RETRYING)).isEqualTo(PublicRunStatus.RUNNING); + for (Map.Entry entry : entries("responses/valid")) { + JsonNode result = entry.getValue().at("/body/result"); + if (entry.getValue().get("schema").asText().equals("RunStatusResponse")) { + PublicRunStatus status = PublicRunStatus.valueOf(result.get("status").asText()); + assertThat(result.get("cancelable").asBoolean()).as(entry.getKey()).isEqualTo(status.isCancelable()); + } + } + } + + @Test + @DisplayName("cancel cause는 1.74 RunCancelCause와 1:1이고 고정 message가 fixture와 같다") + void cancelCauseMapping() { + for (RunCancelCause cause : RunCancelCause.values()) { + assertThat(CancelCause.from(cause).wireValue()).isEqualTo(cause.wireValue()); + } + assertThat(toTree(AssistantCancel.of(CancelCause.USER_REQUESTED))) + .isEqualTo(BrowserRunContractTestSupport.load( + "responses/valid/conversation-clarification-failed-canceled.json") + .at("/body/result/messages/5/assistant/cancel")); + } + + @Test + @DisplayName("응답 fixture의 terminal-v2 enum 값은 1.74 엔티티 enum의 wire 값 안에 있다") + void terminalEnumValuesMatchEntities() { + Set dispositions = wireValues(ResponseDisposition.values()); + Set reasons = wireValues(ClarificationReason.values()); + Set failureCodes = wireValues(RunFailureCode.values()); + int seen = 0; + for (Map.Entry entry : entries("responses/valid")) { + for (JsonNode node : entry.getValue().findValues("responseDisposition")) { + if (!node.isNull()) { + assertThat(dispositions).as(entry.getKey()).contains(node.asText()); + seen++; + } + } + for (JsonNode node : entry.getValue().findValues("clarificationReason")) { + if (!node.isNull()) { + assertThat(reasons).as(entry.getKey()).contains(node.asText()); + } + } + for (JsonNode failure : entry.getValue().findValues("failure")) { + if (!failure.isNull()) { + assertThat(failureCodes).as(entry.getKey()).contains(failure.get("code").asText()); + } + } + } + assertThat(seen).isPositive(); + } + + @Test + @DisplayName("Server가 만든 시각은 마이크로초로 절단하면 schema의 OccurredAt 형식을 지킨다") + void wireTimeFitsSchema() { + Instant nanos = Instant.parse("2026-10-17T09:00:00.123456789Z"); + assertThat(schemaOk("OccurredAt", occurredAt(nanos))).isFalse(); + assertThat(occurredAt(BrowserRunContract.wireTime(nanos)).asText()).isEqualTo("2026-10-17T09:00:00.123456Z"); + assertThat(schemaOk("OccurredAt", occurredAt(BrowserRunContract.wireTime(nanos)))).isTrue(); + } + + /** DTO가 시각을 mapper 설정과 무관하게 ISO 문자열로 쓰는지까지 함께 본다. */ + private static JsonNode occurredAt(Instant instant) { + BrowserRunFrame frame = new BrowserRunFrame( + BrowserRunContract.FRAME_CONTRACT_VERSION, "r", 1, instant, "run.created", null); + return toTree(frame).get("occurredAt"); + } + + private static & WireValue> Set wireValues(E[] values) { + return Arrays.stream(values).map(WireValue::wireValue).collect(Collectors.toSet()); + } +} diff --git a/src/test/java/com/proovy/domain/run/contract/BrowserRunStreamContractTest.java b/src/test/java/com/proovy/domain/run/contract/BrowserRunStreamContractTest.java new file mode 100644 index 00000000..bc2466d3 --- /dev/null +++ b/src/test/java/com/proovy/domain/run/contract/BrowserRunStreamContractTest.java @@ -0,0 +1,276 @@ +package com.proovy.domain.run.contract; + +import static com.proovy.domain.run.contract.BrowserRunContractTestSupport.MAPPER; +import static com.proovy.domain.run.contract.BrowserRunContractTestSupport.entries; +import static com.proovy.domain.run.contract.BrowserRunContractTestSupport.load; +import static com.proovy.domain.run.contract.BrowserRunContractTestSupport.schemaOk; +import static com.proovy.domain.run.contract.BrowserRunContractTestSupport.text; +import static com.proovy.domain.run.contract.BrowserRunContractTestSupport.toTree; +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.regex.Pattern; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** streams/·heartbeat fixture를 Java DTO와 stream 규칙 S1~S7 검사기로 재현한다. */ +class BrowserRunStreamContractTest { + + private static final Set TERMINAL_TYPES = Set.of("run.completed", "run.failed", "run.canceled"); + private static final Set AFTER_CANCELING = Set.of( + "activity.completed", "activity.failed", "activity.canceled", "run.canceled"); + + @Test + @DisplayName("정상 stream은 S1~S7을 모두 지키고 frame·Server 소유 payload가 DTO로 왕복된다") + void validStreams() throws Exception { + List> cases = entries("streams/valid"); + Set finals = new HashSet<>(); + for (Map.Entry entry : cases) { + String name = entry.getKey(); + JsonNode frames = entry.getValue().get("frames"); + assertThat(violations(frames)).as(name).isEmpty(); + finals.add(entry.getValue().get("finalStatus").asText()); + for (JsonNode frame : frames) { + BrowserRunFrame dto = MAPPER.treeToValue(frame, BrowserRunFrame.class); + assertThat(toTree(dto)).as(name + " seq " + dto.seq()).isEqualTo(frame); + assertThat(dto.contractVersion()).isEqualTo(BrowserRunContract.FRAME_CONTRACT_VERSION); + Optional serverType = ServerFrameType.of(dto.type()); + if (serverType.isPresent()) { + Object payload = MAPPER.treeToValue(dto.payload(), serverType.get().payloadType()); + assertThat(toTree(payload)).as(name + " " + dto.type()).isEqualTo(dto.payload()); + } + } + assertSseMatchesFrames(name, entry.getValue().get("sse").asText(), frames); + } + assertThat(finals).containsExactlyInAnyOrder("COMPLETED", "FAILED", "CANCELED"); + } + + @Test + @DisplayName("거부 stream은 적힌 규칙을 위반하고 schema 거부 여부도 fixture와 같다") + void invalidStreams() { + Set rules = new HashSet<>(); + for (Map.Entry entry : entries("streams/invalid")) { + JsonNode fixture = entry.getValue(); + String rule = fixture.get("violates").asText(); + rules.add(rule); + assertThat(violations(fixture.get("frames"))).as(entry.getKey()).contains(rule); + boolean schemaFails = false; + for (JsonNode frame : fixture.get("frames")) { + schemaFails |= !schemaOk("BrowserFrame", frame); + } + assertThat(schemaFails).as(entry.getKey()).isEqualTo(fixture.get("schema_rejects").asBoolean()); + } + assertThat(rules).containsExactlyInAnyOrder("S1", "S2", "S3", "S4", "S5", "S6", "S7"); + } + + @Test + @DisplayName("Server 소유 payload factory가 cause별 고정 message를 쓴다") + void canceledPayloadFactory() { + JsonNode frames = load("streams/valid/canceled-asset-deleting.json").get("frames"); + JsonNode last = frames.get(frames.size() - 1).get("payload"); + RunCanceledPayload payload = RunCanceledPayload.of( + last.get("assistantMessageId").asText(), CancelCause.ASSET_DELETING); + assertThat(toTree(payload)).isEqualTo(last); + } + + @Test + @DisplayName("replay와 live의 차이는 read-time sourceReferences[].available뿐이다") + void replayDiffersOnlyInAvailability() { + JsonNode fixture = load("streams/valid/replay-source-unavailable.json"); + JsonNode replay = fixture.get("frames").deepCopy(); + JsonNode references = replay.get(replay.size() - 1).at("/payload/assistantResponse/sourceReferences"); + assertThat(references.size()).isPositive(); + for (JsonNode reference : references) { + assertThat(reference.get("available").asBoolean()).isFalse(); + ((ObjectNode) reference).put("available", true); + } + assertThat(replay).isEqualTo(fixture.get("live")); + } + + @Test + @DisplayName("heartbeat는 15초 주기의 comment이고 id·data·event가 없다") + void heartbeat() { + assertThat(text("heartbeat.txt")).isEqualTo(BrowserRunContract.HEARTBEAT_COMMENT); + JsonNode invalid = load("heartbeat-invalid.json"); + assertThat(invalid.get("valid").asText()).isEqualTo(BrowserRunContract.HEARTBEAT_COMMENT); + Pattern frameField = Pattern.compile("^(id|data|event):", Pattern.MULTILINE); + assertThat(frameField.matcher(BrowserRunContract.HEARTBEAT_COMMENT).find()).isFalse(); + for (JsonNode testCase : invalid.get("cases")) { + assertThat(frameField.matcher(testCase.get("sse").asText()).find()).as(testCase.get("name").asText()) + .isTrue(); + } + } + + private static void assertSseMatchesFrames(String name, String sse, JsonNode frames) throws Exception { + List blocks = new ArrayList<>(); + for (String block : sse.split("\n\n")) { + if (!block.isEmpty()) { + blocks.add(block); + } + } + assertThat(blocks).as(name).hasSize(frames.size()); + for (int i = 0; i < blocks.size(); i++) { + Map fields = new HashMap<>(); + for (String line : blocks.get(i).split("\n")) { + String[] parts = line.split(": ", 2); + fields.put(parts[0], parts[1]); + } + JsonNode frame = frames.get(i); + assertThat(fields).as(name).containsOnlyKeys("event", "id", "data"); + assertThat(fields.get("event")).isEqualTo(frame.get("type").asText()); + assertThat(fields.get("id")) + .isEqualTo(new RunEventCursor(frame.get("runId").asText(), frame.get("seq").asLong()).toString()); + assertThat(MAPPER.readTree(fields.get("data"))).isEqualTo(frame); + } + } + + /** manifest stream_rules S1~S7 검사기. */ + static Set violations(JsonNode frames) { + Set found = new HashSet<>(); + int size = frames.size(); + if (size == 0 || !type(frames.get(0)).equals("run.created")) { + found.add("S1"); + } + for (int i = 0; i < size; i++) { + JsonNode seq = frames.get(i).get("seq"); + if (seq == null || seq.asLong() != i + 1) { + found.add("S1"); + } + } + List terminals = new ArrayList<>(); + List cancelings = new ArrayList<>(); + List pagePositions = new ArrayList<>(); + for (int i = 0; i < size; i++) { + String type = type(frames.get(i)); + if (TERMINAL_TYPES.contains(type)) { + terminals.add(i); + } + if (type.equals("run.canceling")) { + cancelings.add(i); + } + if (type.startsWith("response.page.")) { + pagePositions.add(i); + } + } + if (terminals.size() != 1 || terminals.get(0) != size - 1) { + found.add("S2"); + } + JsonNode last = size == 0 ? null : frames.get(size - 1); + if (!cancelings.isEmpty()) { + if (cancelings.size() > 1 || !type(last).equals("run.canceled")) { + found.add("S3"); + } + for (int i = cancelings.get(0) + 1; i < size; i++) { + if (!AFTER_CANCELING.contains(type(frames.get(i)))) { + found.add("S3"); + } + } + } + if (!pagePositions.isEmpty()) { + if (!type(last).equals("run.completed") || !pagesMatchCompleted(frames, pagePositions.size())) { + found.add("S4"); + } + } else if (last != null && type(last).equals("run.completed")) { + found.add("S4"); + } + Set runIds = new HashSet<>(); + for (JsonNode frame : frames) { + runIds.add(frame.path("runId").asText(null)); + } + if (runIds.size() != 1) { + found.add("S5"); + } + if (size > 0 && type(frames.get(0)).equals("run.created")) { + String messageId = frames.get(0).at("/payload/assistantMessageId").asText(null); + for (int i = 1; i < size; i++) { + JsonNode payloadId = frames.get(i).at("/payload/assistantMessageId"); + if (!payloadId.isMissingNode() && !payloadId.asText().equals(messageId)) { + found.add("S5"); + } + } + } + if (!activitiesAreOrdered(frames)) { + found.add("S6"); + } + for (JsonNode frame : frames) { + if (!schemaOk("BrowserFrame", frame)) { + found.add("S7"); + } + } + return found; + } + + private static boolean pagesMatchCompleted(JsonNode frames, int pageFrameCount) { + int size = frames.size(); + JsonNode pages = frames.get(size - 1).at("/payload/assistantResponse/responsePages"); + int blockSize = pages.size() * 2; + if (pageFrameCount != blockSize || size - 1 - blockSize < 0) { + return false; + } + for (int index = 0; index < pages.size(); index++) { + JsonNode started = frames.get(size - 1 - blockSize + 2 * index); + JsonNode completed = frames.get(size - blockSize + 2 * index); + JsonNode page = pages.get(index); + if (!type(started).equals("response.page.started") || !type(completed).equals("response.page.completed")) { + return false; + } + JsonNode startedPayload = started.get("payload"); + JsonNode completedPayload = completed.get("payload"); + String pageId = page.get("responsePageId").asText(); + if (startedPayload.get("pageIndex").asInt() != index + || completedPayload.get("pageIndex").asInt() != index + || !startedPayload.get("responsePageId").asText().equals(pageId) + || !completedPayload.get("responsePageId").asText().equals(pageId) + || !startedPayload.get("presentation").equals(page.get("presentation")) + || !Objects.equals(nullableText(startedPayload.get("title")), nullableText(page.get("title")))) { + return false; + } + } + return true; + } + + private static boolean activitiesAreOrdered(JsonNode frames) { + Map openKinds = new HashMap<>(); + Set closed = new HashSet<>(); + for (JsonNode frame : frames) { + String type = type(frame); + if (!type.startsWith("activity.")) { + continue; + } + String id = frame.at("/payload/activityId").asText(); + String kind = frame.at("/payload/kind").asText(); + if (type.equals("activity.started")) { + if (openKinds.containsKey(id) || closed.contains(id)) { + return false; + } + openKinds.put(id, kind); + continue; + } + if (!kind.equals(openKinds.get(id))) { + return false; + } + if (!type.equals("activity.updated")) { + openKinds.remove(id); + closed.add(id); + } + } + return true; + } + + private static String type(JsonNode frame) { + return frame.path("type").asText(""); + } + + private static String nullableText(JsonNode node) { + return node == null || node.isNull() ? null : node.asText(); + } +} diff --git a/src/test/java/com/proovy/domain/run/contract/ConversationRunRequestContractTest.java b/src/test/java/com/proovy/domain/run/contract/ConversationRunRequestContractTest.java new file mode 100644 index 00000000..832bc850 --- /dev/null +++ b/src/test/java/com/proovy/domain/run/contract/ConversationRunRequestContractTest.java @@ -0,0 +1,112 @@ +package com.proovy.domain.run.contract; + +import static com.proovy.domain.run.contract.BrowserRunContractTestSupport.MAPPER; +import static com.proovy.domain.run.contract.BrowserRunContractTestSupport.entries; +import static com.proovy.domain.run.contract.BrowserRunContractTestSupport.schemaOk; +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.JsonNode; +import com.proovy.global.response.ErrorCode; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +/** requests/·fingerprint/ fixture를 Java 판정·DTO·fingerprint로 재현한다. */ +class ConversationRunRequestContractTest { + + @Test + @DisplayName("정상 요청은 접수되고 DTO로 바인딩되며 fingerprint가 fixture와 같다") + void validRequestsAreAccepted() throws Exception { + List> cases = entries("requests/valid"); + assertThat(cases).hasSizeGreaterThanOrEqualTo(10); + for (Map.Entry entry : cases) { + JsonNode request = entry.getValue().get("request"); + assertThat(ConversationRunRequestValidator.validate(request)).as(entry.getKey()).isEmpty(); + assertThat(schemaOk("ConversationRunRequest", request)).as(entry.getKey()).isTrue(); + BrowserRequestFingerprint fingerprint = BrowserRequestFingerprint.of(bind(request)); + assertThat(fingerprint.canonical()).as(entry.getKey()) + .isEqualTo(entry.getValue().get("expected_canonical").asText()); + assertThat(fingerprint.sha256()).as(entry.getKey()) + .isEqualTo(entry.getValue().get("expected_fingerprint").asText()); + } + } + + @Test + @DisplayName("거부 요청은 기대 code로 거부되고 schema 판정도 fixture와 같다") + void invalidRequestsAreRejected() { + List> cases = entries("requests/invalid"); + assertThat(cases).hasSizeGreaterThanOrEqualTo(40); + for (Map.Entry entry : cases) { + JsonNode fixture = entry.getValue(); + JsonNode request = fixture.get("request"); + ErrorCode expected = ErrorCode.valueOf(fixture.at("/expected/code").asText()); + assertThat(expected.getHttpStatus().value()).isEqualTo(fixture.at("/expected/http").asInt()); + assertThat(ConversationRunRequestValidator.validate(request)).as(entry.getKey()) + .isEqualTo(Optional.of(expected)); + // Java 정규식 엔진도 Python과 같은 schema 판정을 내려야 한다(끝 줄바꿈·seq/noteId 상한 등). + assertThat(schemaOk("ConversationRunRequest", request)).as(entry.getKey()) + .isEqualTo(!fixture.get("schema_rejects").asBoolean()); + } + } + + @Test + @DisplayName("fingerprint 쌍의 same/different 관계와 canonical이 fixture와 같다") + void fingerprintRelations() throws Exception { + List> cases = entries("fingerprint"); + assertThat(cases).hasSizeGreaterThanOrEqualTo(16); + for (Map.Entry entry : cases) { + JsonNode fixture = entry.getValue(); + BrowserRequestFingerprint left = BrowserRequestFingerprint.of(bind(fixture.get("left"))); + BrowserRequestFingerprint right = BrowserRequestFingerprint.of(bind(fixture.get("right"))); + assertThat(left.canonical()).as(entry.getKey()) + .isEqualTo(fixture.get("expected_canonical_left").asText()); + assertThat(right.canonical()).as(entry.getKey()) + .isEqualTo(fixture.get("expected_canonical_right").asText()); + assertThat(left.sha256()).isEqualTo(fixture.get("expected_fingerprint_left").asText()); + assertThat(right.sha256()).isEqualTo(fixture.get("expected_fingerprint_right").asText()); + assertThat(left.equals(right)).as(entry.getKey()) + .isEqualTo("same".equals(fixture.get("relation").asText())); + } + } + + @ParameterizedTest + @DisplayName("canonical 실수 표기는 Brain 규칙(최단 round-trip, 지수 없음, 정수값은 정수)과 같다") + @CsvSource({ + "0.1, 0.1", + "0.5, 0.5", + "1.0, 1", + "-0.0, 0", + "1e-5, 0.00001", + "1.5e-7, 0.00000015", + "123456789.5, 123456789.5", + "1e16, 10000000000000000", + "0.30000000000000004, 0.30000000000000004" + }) + void canonicalNumbers(double value, String expected) { + assertThat(CanonicalJson.number(value)).isEqualTo(expected); + } + + @Test + @DisplayName("canonical 문자열은 제어문자·짝 없는 surrogate만 escape하고 나머지는 그대로 쓴다") + void canonicalStrings() { + assertThat(CanonicalJson.write(Map.of("b", "가\u0001\"\\\n𝑥", "a", List.of(1, "\ud800")))) + .isEqualTo("{\"a\":[1,\"\\ud800\"],\"b\":\"가\\u0001\\\"\\\\\\n𝑥\"}"); + } + + @Test + @DisplayName("strip 집합은 Python str.strip()과 같다 — Java String.strip()이 남기는 공백도 제거한다") + void stripSet() { + String blanks = "\u00a0\u0085\u202f\u2007\u3000\u001c \t"; + assertThat(BrowserRunContract.strip(blanks + "가 나" + blanks)).isEqualTo("가 나"); + assertThat(("\u00a0x\u202f").strip()).isNotEqualTo("x"); + assertThat(BrowserRunContract.strip("\u200bx")).isEqualTo("\u200bx"); + } + + private static ConversationRunRequest bind(JsonNode request) throws Exception { + return MAPPER.treeToValue(request, ConversationRunRequest.class); + } +} diff --git a/src/test/java/com/proovy/domain/run/contract/RunEventCursorContractTest.java b/src/test/java/com/proovy/domain/run/contract/RunEventCursorContractTest.java new file mode 100644 index 00000000..f972a004 --- /dev/null +++ b/src/test/java/com/proovy/domain/run/contract/RunEventCursorContractTest.java @@ -0,0 +1,83 @@ +package com.proovy.domain.run.contract; + +import static com.proovy.domain.run.contract.BrowserRunContractTestSupport.load; +import static com.proovy.domain.run.contract.BrowserRunContractTestSupport.schemaOk; +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.TextNode; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** cursors/cases.json을 {@link RunEventCursor#resolve}로 재현한다. */ +class RunEventCursorContractTest { + + private static final JsonNode CURSORS = load("cursors/cases.json"); + + @Test + @DisplayName("cursor 판정·replay 시작점·live 합류 여부가 fixture와 같다") + void cursorCases() { + JsonNode cases = CURSORS.get("cases"); + assertThat(cases.size()).isGreaterThanOrEqualTo(20); + for (JsonNode testCase : cases) { + String name = testCase.get("name").asText(); + JsonNode run = CURSORS.at("/runs/" + testCase.get("run").asText()); + String header = textOrNull(testCase.get("lastEventIdHeader")); + String query = textOrNull(testCase.get("lastEventIdQuery")); + RunEventCursor.ReplayStart start = RunEventCursor.resolve( + run.get("runId").asText(), run.get("lastDurableSeq").asLong(), header, query); + JsonNode expected = testCase.get("expected"); + if (expected.has("code")) { + assertThat(start.error()).as(name).isNotNull(); + assertThat(start.error().getCode()).as(name).isEqualTo(expected.get("code").asText()); + assertThat(start.error().getHttpStatus().value()).isEqualTo(expected.get("http").asInt()); + } else { + assertThat(start.isRejected()).as(name).isFalse(); + assertThat(start.fromSeq()).as(name).isEqualTo(expected.get("replayFromSeq").asLong()); + boolean live = !PublicRunStatus.valueOf(run.get("status").asText()).isTerminal(); + assertThat(live).as(name).isEqualTo(expected.get("thenLive").asBoolean()); + if (expected.has("frameCount")) { + assertThat(start.fromSeq() - 1).as(name).isEqualTo(run.get("lastDurableSeq").asLong()); + } + } + List raws = new ArrayList<>(); + if (header != null) { + raws.add(header); + } + if (query != null) { + raws.add(query); + } + for (String raw : raws) { + boolean schemaRejects = testCase.get("schemaRejects").asBoolean(); + assertThat(schemaOk("EventId", TextNode.valueOf(raw))).as(name).isEqualTo(!schemaRejects); + assertThat(RunEventCursor.parse(raw).isPresent()).as(name).isEqualTo(!schemaRejects); + } + } + } + + @Test + @DisplayName("run 접근 거부는 cursor보다 먼저인 404 RUN4041이다") + void accessCases() { + for (JsonNode testCase : CURSORS.get("accessCases")) { + assertThat(testCase.at("/expected/code").asText()).isEqualTo("RUN4041"); + assertThat(testCase.at("/expected/http").asInt()).isEqualTo(404); + } + assertThat(CURSORS.get("precedence").get(1).asText()).startsWith("run access"); + } + + @Test + @DisplayName("cursor 문자열은 SSE id 형식 {runId}:{seq}로 되돌아간다") + void cursorRoundTrip() { + String raw = CURSORS.at("/runs/terminal/runId").asText() + ":14"; + assertThat(RunEventCursor.parse(raw)).hasValueSatisfying(cursor -> { + assertThat(cursor.seq()).isEqualTo(14); + assertThat(cursor.toString()).isEqualTo(raw); + }); + } + + private static String textOrNull(JsonNode node) { + return node == null || node.isNull() ? null : node.asText(); + } +} diff --git a/src/test/resources/contracts/brain/PIN.json b/src/test/resources/contracts/brain/PIN.json new file mode 100644 index 00000000..e28f16be --- /dev/null +++ b/src/test/resources/contracts/brain/PIN.json @@ -0,0 +1,8 @@ +{ + "repository": "Team-Proovy/proovy-brain", + "commit": "303a9e45943207cb22fc555452cc781fb8117d21", + "source_path": "tests/fixtures/contracts", + "consumer_version": "browser-run-fixtures-v1", + "manifest": "browser-run-v1/manifest.json", + "manifest_sha256": "9b508756f55422b9b21668ea373871008aaf11dfa3229113e9fe178e3e37c2ea" +} diff --git a/src/test/resources/contracts/brain/README.md b/src/test/resources/contracts/brain/README.md new file mode 100644 index 00000000..618c2ce1 --- /dev/null +++ b/src/test/resources/contracts/brain/README.md @@ -0,0 +1,39 @@ +# proovy-brain 계약 fixture 사본 (검증된 mirror) + +이 디렉터리는 **두 번째 SSOT가 아니다.** 브라우저 run API wire 계약(browser-run-v1, task 1.75)의 +schema·정상/거부 예제의 원본은 proovy-brain `tests/fixtures/contracts/`이고, 여기에는 `PIN.json`의 +commit에서 복사한 파일만 둔다(단계 2 합의 D9 — 복사본 + 해시 검증, 다운로드 token 없음). + +| 경로 | 원본 | +|---|---| +| `browser-run-v1/**` | `tests/fixtures/contracts/browser-run-v1/**` (manifest·CATALOG·builder 포함 전체) | +| `terminal-v2/browser.schema.json` | `tests/fixtures/contracts/terminal-v2/browser.schema.json` (schema `$ref` 의존) | + +- `BrainContractMirrorTest`가 다음을 검사한다. + - `PIN.json`의 `manifest_sha256`·`consumer_version`이 사본 manifest와 같다. + - manifest `files`·`dependencies`의 모든 sha256이 사본과 같다. + - 목록 밖 파일이 없다(`PIN.json`·`README.md` 제외). +- **손으로 고치지 않는다.** fixture가 틀렸으면 proovy-brain에서 고치고(새 `consumer_version`·`change_history`) 다시 pin한다. +- 계약 의미(경로·판정·오류)는 Server가 소유한다. 바꿀 때는 Server 이슈에서 합의한 뒤 Brain artifact를 새 version으로 올리고, + Server(이 사본)와 Front가 그 version을 pin한다. + +## 갱신 절차 + +1. proovy-brain에서 바뀐 artifact가 `dev`에 merge된 commit SHA를 확인한다(squash commit). +2. 이 디렉터리의 사본을 지우고 그 commit에서 다시 복사한다. + + ```bash + SHA= + DEST=src/test/resources/contracts/brain + rm -rf "$DEST/browser-run-v1" "$DEST/terminal-v2" + git -C fetch origin dev + git -C archive "$SHA" \ + tests/fixtures/contracts/browser-run-v1 \ + tests/fixtures/contracts/terminal-v2/browser.schema.json \ + | tar -x --strip-components=3 -C "$DEST" + ``` + +3. `PIN.json`의 `commit`, `consumer_version`(manifest 값), `manifest_sha256`(`sha256sum $DEST/browser-run-v1/manifest.json`)을 갱신한다. +4. Java 상수·DTO가 새 계약과 맞는지 `./gradlew test --tests 'com.proovy.domain.run.contract.*'`로 확인한다. + consumer_version이 바뀌었는데 Java 쪽을 고치지 않았으면 `BrainContractMirrorTest`가 실패하도록 + `BrowserRunContractTestSupport.EXPECTED_CONSUMER_VERSION`도 함께 올린다. diff --git a/src/test/resources/contracts/brain/browser-run-v1/CATALOG.md b/src/test/resources/contracts/brain/browser-run-v1/CATALOG.md new file mode 100644 index 00000000..34113720 --- /dev/null +++ b/src/test/resources/contracts/brain/browser-run-v1/CATALOG.md @@ -0,0 +1,366 @@ +# browser-run-v1 fixture catalog + +> 이 파일은 `build_fixtures.py`가 생성한다 — 직접 편집하지 않는다. + +브라우저 run API(접수·재조회·SSE replay·상태·취소·대화 조회)의 wire 계약 artifact다 +(task 1.75, Team-Proovy/Proovy-server#214). **계약 의미는 Proovy-server가 소유**하고 +Brain은 versioned artifact만 보관한다 — `src/`는 이 디렉터리를 import하지 않으며 +제품 API·제품 DB 소유권을 Brain으로 가져오지 않는다. 평가 정답·oracle과 무관하다. + +## 버전 + +- `consumer_version`: `browser-run-fixtures-v1` (2026-10-17 최초 고정, 단계 2 합의 D1~D10) +- `contract_version`: `browser-run-v1` +- durable frame `contractVersion`: `brain-source-v1` (D1 — terminal-v2 browser schema의 `const`와 같은 값을 Server 소유 frame에도 사용) +- 참조 계약: terminal-fixtures-v2 · activity-fixtures-v1 · assistant-response-v1 · internal-run-fixtures-v2(canonical JSON) · page-selection-v1 +- 요청 fingerprint: `browser-request-sha256-canonical-json-v1` + +## 소비 규칙 + +- 소비자(Server Java DTO 검사, Front reducer/MSW)는 **merge된 proovy-brain commit + `consumer_version`**을 pin한다. +- 사본은 검증된 mirror로만 허용한다: `manifest.json`의 `files`(이 디렉터리 전체)와 `dependencies` + (`terminal-v2/browser.schema.json`)의 sha256이 모두 일치해야 하고, 소비자는 pin에 manifest 자체의 + sha256도 기록한다. 사본을 손으로 고치지 않는다 — SSOT는 Brain이다. +- 변경: 의미가 바뀌면 새 `consumer_version`(새 디렉터리 또는 버전업)과 `change_history` 항목을 남긴다. + 이 CATALOG를 포함한 모든 파일은 `build_fixtures.py`가 생성한다 — builder를 고친 뒤 + `uv run python tests/fixtures/contracts/browser-run-v1/build_fixtures.py`로 재생성한다. + `tests/contracts/test_browser_run_fixtures.py`가 builder 출력·digest 불일치에서 실패한다. +- schema 검증은 `manifest.schema_registry`처럼 `https://proovy.invalid/contracts/terminal-v2/browser.schema.json`을 + `terminal-v2/browser.schema.json`에 매핑한 registry로 한다(`$ref: ../../terminal-v2/browser.schema.json`). +- 요청 body는 **closed**(알 수 없는/금지 필드 = `RUN4001`). 응답·이벤트는 additive-tolerant(알 수 없는 추가 필드 무시, + 알려진 필드 검증)이지만, 내부 키(`attempt`·`fenceToken`·`sourceEventId`·`failureClass`·`requestFingerprint`·`usage`· + `deadline`과 snake_case 형)는 frame·payload와 Server 소유 응답 객체 모두에서 schema가 거부한다. + +## 경로와 규칙 요약 + +| Method · Path | 성공 | 주요 오류 | +|---|---|---| +| `POST /api/conversations` (body `ConversationRunRequest`, `Accept: text/event-stream`) | 200 SSE seq 1 `run.created`부터. 같은 requestId·같은 사용자·같은 의미는 기존 run을 seq 1부터 replay | 401 · 400 `RUN4001`/`RUN4002` · 409 `RUN4092` · 404 `RUN4041` · 422 `RUN4221` · 409 `RUN4091{activeRunId}` · 429 `RUN4291`(+`Retry-After`) · 402 `RUN4021` | +| `GET /api/ai-runs/{runId}/events` (`Last-Event-ID` header 또는 `lastEventId` query) | 200 SSE: cursor 없으면 seq 1부터, 있으면 seq+1부터 replay 후 live | 404 `RUN4041` · 400 `RUN4003`(형식·범위·header/query 불일치) · 400 `RUN4004`(다른 run) | +| `GET /api/ai-runs/{runId}` | `RunStatusResponse` | 404 `RUN4041` | +| `POST /api/ai-runs/{runId}/cancel` (body 없음, 멱등) | `RunCancelResponse` (accepted·already_canceling·already_terminal) | 400 `RUN4001`(body 있음) · 404 `RUN4041` | +| `GET /api/conversations/{noteId}/messages?before&limit` | `ConversationPageResponse` | 404 `RUN4041` | + +- 판정 순서: `admission/decisions.json`·`cancel/cases.json`·`cursors/cases.json`의 `precedence`. 같은 의미 재전송(replay)은 + Note·Active Run·rate limit·잔액보다 먼저다. 구조 검사는 대상 조회보다 먼저다. +- `RUN4092`·`RUN4041`은 다른 사용자/다른 의미, 없음/타인/삭제 중을 구분하지 않는 동일 body다(존재 oracle 방지). +- 스트림 시작 전 오류만 JSON `ApiResponse`(`isSuccess/code/message/result`), 시작 후에는 terminal 이벤트로 끝난다. code별 message는 고정이다. +- 공개 run status는 `PENDING|RUNNING|CANCELING|COMPLETED|FAILED|CANCELED` — 내부 `RETRYING`은 `RUNNING`으로 투영한다. +- 브라우저가 결정하지 못하는 값(scope·usage·attempt·fence·history·참조·corpus·conversationId·authToken·locale 등)은 요청 필드가 없다. +- 상태 응답 `lastSeq`는 그 시점 마지막 durable seq다. terminal 상태 fixture는 `stream`에 적은 `streams/valid` fixture와 같은 run이고 + `lastSeq`가 그 stream의 frame 수와 같다(그대로 cursor로 보내면 0 frame 후 종료). +- heartbeat는 15초마다 `: heartbeat` comment(id·data·event·seq·저장 없음, `heartbeat.txt`; 위반 예제 `heartbeat-invalid.json`). +- **runId = 시나리오.** 같은 run을 설명하는 fixture(stream·상태·취소·대화 조회·cursor `runs`)는 runId·requestId· + userMessageId·assistantMessageId를 공유하고, 다른 시나리오는 다른 runId를 쓴다. 따라서 소비자가 valid fixture를 runId로 + 교차 색인해도 충돌하지 않는다(`test_fixtures_describing_one_run_agree_by_run_id`). 진행 중 run은 시점 snapshot이라 + non-terminal 상태가 서로 다를 수 있다(예: `status-running` RUNNING → `cancel-accepted-running` CANCELING). invalid fixture는 색인 대상이 아니다. +- stream 규칙 S1~S7: `manifest.stream_rules`. replay와 live의 유일한 허용 차이는 read-time `sourceReferences[].available`. + +## 형식·경계 판정 + +- **패턴은 입력 전체와 일치해야 한다.** schema 패턴은 `$` 대신 절대 끝 `(?![\s\S])`로 끝난다 — Python `re`·Java의 `$`는 + 끝 줄바꿈 하나 앞에서도 매치되므로 `"\n"`을 통과시킨다. 소비자 구현은 full match(Java `matches()`, Python `fullmatch`)로 한다. +- ID: `requestId`·`runId`·`messageId`·`attachmentId`는 소문자 canonical UUID. `noteId`는 선행 0 없는 10진 문자열 1..9223372036854775807 + (Java `Long.MAX_VALUE`, 초과는 404가 아니라 `RUN4001`). `noteAssetId`는 opaque `[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}`. +- event id/cursor: `{runId}:{seq}` — 소문자 UUID, seq 1..9007199254740991(2^53-1), 선행 0 없음. 상한은 schema 패턴 자체에 들어 있다. + `cursors/cases.json`의 `schemaRejects`가 true인 값은 `EventId` schema가 거부하고, false인 400은 범위(마지막 seq 초과)·header/query 불일치다. + `thenLive`는 `runs..status`가 terminal이 아닐 때만 true다(`thenLiveRule`). +- **길이(D6)**: `text`·`latex` 각각 **strip 후 1~10,000 code point**(합의 초안 §3.2 문구). 원문 기준이 아니다 — 양끝 공백이 붙어 원문이 + 10,000을 넘어도 strip 후 10,000이면 접수한다(`text-at-limit-surrounded-by-blanks`). schema는 비어 있지 않음·공백만이 아님만 검사하고 + 상한은 계약 검사(`RUN4001`, `schema_rejects: false`)다. 단위는 code point다 — Java `String.length()`·JS `.length`(UTF-16 단위)로 + 세면 astral 문자 fixture(`text-at-limit-astral`·`text-too-long-astral`)에서 틀린다. +- **strip 집합**: internal-run-v1과 같은 Python `str.strip()` 집합 — `\t \n \v \f \r U+001C~U+001F space U+0085 U+00A0 U+1680 + U+2000~U+200A U+2028 U+2029 U+202F U+205F U+3000`. Java `String.strip()`/`trim()`은 U+00A0·U+202F 등을 남기므로 이 집합을 직접 구현한다 + (`fingerprint/same-non-java-whitespace`). +- **request fingerprint**: `requestId` 제외, `text`·`latex` strip, 비순서 배열 정렬, `[]`·`null`은 부재와 같음, `noteId` 부재/존재는 다름 + → internal-run-v1 `canonical_json` → sha256. **fingerprint 전에 기본값을 채우지 않는다** — `requestedOutputs: []`(Brain 추론)를 + `["text"]`로 바꿔 정규화하면 안 된다(`fingerprint/different-outputs-explicit-vs-inferred`). +- `turnAttachmentIds` wire 상한 10(실제 개수·용량은 Server policy → `RUN4221`). + +## 기존 계약과의 일관성 + +- `streams/`의 activity frame은 activity-v1 source fixture를 Brain `to_browser_event_payload`로 변환한 값이고, + `run.completed`/`run.failed` payload는 terminal-v2 `pairs/*.json`의 browser payload와 `assistantMessageId`만 다르다(테스트가 byte 비교). + terminal-v2 pair는 publication-v1 valid에서 조립되므로 clarification/unsupported_only/substantive의 공개 구조도 같다. +- 대화 조회의 `assistantResponse`는 terminal-v2 `AssistantResponseV1`을 그대로 `$ref`한다. 대화 조회 예제는 메시지가 `createdAt` + 오름차순이고 assistant마다 run이 다르며, 각 assistant 앞에 같은 run의 user 메시지가 있다. +- 기존 fixture의 의미·파일은 바꾸지 않았다. 참고: terminal-v2 browser schema의 opaque ID 패턴은 `$`로 끝난다(이 artifact 밖, + terminal-v2 변경 대상). + +## 합의 초안 §11.1 예제와의 대응 + +- `replay-from-cursor` → `cursors/cases.json`(`valid-middle`·`valid-active` 등 — stream 사본을 두지 않는다). +- `retrying-projected-running` → `responses/invalid/status-retrying`(RETRYING 노출 거부)과 `status-running`. +- `events/invalid` → `streams/invalid`. `heartbeat-with-id` → `heartbeat-invalid.json`(frame이 아니라 SSE comment라 따로 둔다). +- cancel body 있음 → `cancel/cases.json`(`empty-object-body`·`reason-body`, 400 `RUN4001`). + +## 파일 +### requests/valid + +| 파일 | 판정 근거 | +|---|---| +| `empty-arrays` | 빈 requestedTasks/Outputs·turnAttachmentIds — Brain이 문장에서 추론한다. | +| `existing-note-full` | 모든 선택 필드를 채운 기존 Note 요청. | +| `first-no-note` | noteId 없는 홈 첫 질문 — Server가 Note를 자동 생성한다. | +| `latex-at-limit-surrounded-by-blanks` | latex도 strip 후 10000 code point로 판정한다. | +| `natural-language-variant` | 자연어 미지원 의도(변형 문제 생성)는 wire 오류가 아니다 — 접수되어 unsupported_only로 COMPLETED 된다(streams/unsupported-only-completed). | +| `note-id-long-max` | noteId 상한 = Java Long.MAX_VALUE(BIGINT). | +| `text-at-limit-astral` | astral 문자(U+1D465) 10000개 — 길이는 UTF-16 단위가 아니라 code point. | +| `text-at-limit-surrounded-by-blanks` | strip 후 10000 code point — 상한은 strip 후 길이로 판정한다(원문은 상한 초과). | +| `text-at-limit` | text 10000 code point 경계값. | +| `viewer-focus-no-selection` | selection 없는 Viewer Focus(page 전체). | + +### requests/invalid + +| 파일 | 기대 | schema 거부 | 근거 | +|---|---|---|---| +| `attachment-not-uuid` | 400 `RUN4001` | 예 | turnAttachmentIds 항목은 UUID. | +| `blank-text` | 400 `RUN4001` | 예 | strip 집합 공백만 있는 text. | +| `duplicate-task` | 400 `RUN4001` | 예 | requestedTasks 중복. | +| `empty-latex` | 400 `RUN4001` | 예 | latex는 있으면 비어 있을 수 없다(null은 부재와 같음). | +| `forbidden-attempt` | 400 `RUN4001` | 예 | 브라우저는 attempt를 결정·전달하지 않는다. | +| `forbidden-auth-token` | 400 `RUN4001` | 예 | 브라우저는 authToken를 결정·전달하지 않는다. | +| `forbidden-budget` | 400 `RUN4001` | 예 | 브라우저는 budget를 결정·전달하지 않는다. | +| `forbidden-conversation-id` | 400 `RUN4001` | 예 | 브라우저는 conversationId를 결정·전달하지 않는다. | +| `forbidden-corpus-version` | 400 `RUN4001` | 예 | 브라우저는 corpusVersion를 결정·전달하지 않는다. | +| `forbidden-credit` | 400 `RUN4001` | 예 | 브라우저는 credit를 결정·전달하지 않는다. | +| `forbidden-deadline` | 400 `RUN4001` | 예 | 브라우저는 deadline를 결정·전달하지 않는다. | +| `forbidden-fence-token` | 400 `RUN4001` | 예 | 브라우저는 fenceToken를 결정·전달하지 않는다. | +| `forbidden-history` | 400 `RUN4001` | 예 | 브라우저는 history를 결정·전달하지 않는다. | +| `forbidden-input-scope` | 400 `RUN4001` | 예 | 브라우저는 inputScope를 결정·전달하지 않는다. | +| `forbidden-locale` | 400 `RUN4001` | 예 | 브라우저는 locale를 결정·전달하지 않는다. | +| `forbidden-retrieval-scope` | 400 `RUN4001` | 예 | 브라우저는 retrievalScope를 결정·전달하지 않는다. | +| `forbidden-run-id` | 400 `RUN4001` | 예 | 브라우저는 runId를 결정·전달하지 않는다. | +| `forbidden-scope` | 400 `RUN4001` | 예 | 브라우저는 scope를 결정·전달하지 않는다. | +| `forbidden-turn-attachment-refs` | 400 `RUN4001` | 예 | 브라우저는 turnAttachmentRefs를 결정·전달하지 않는다. | +| `forbidden-usage` | 400 `RUN4001` | 예 | 브라우저는 usage를 결정·전달하지 않는다. | +| `forbidden-user-id` | 400 `RUN4001` | 예 | 브라우저는 userId를 결정·전달하지 않는다. | +| `forbidden-verified-solution-refs` | 400 `RUN4001` | 예 | 브라우저는 verifiedSolutionRefs를 결정·전달하지 않는다. | +| `latex-too-long` | 400 `RUN4001` | 아니오(계약 검사) | latex strip 후 10001 code point — schema는 통과, 계약 검사(RUN4001). | +| `missing-request-id` | 400 `RUN4001` | 예 | requestId는 body 필수. | +| `missing-text` | 400 `RUN4001` | 예 | text 필수. | +| `non-uuid-request-id` | 400 `RUN4001` | 예 | UUID가 아닌 requestId. | +| `note-asset-id-trailing-newline` | 400 `RUN4001` | 예 | viewerFocus.noteAssetId 끝 줄바꿈. | +| `note-id-leading-zero` | 400 `RUN4001` | 예 | noteId 선행 0 금지. | +| `note-id-number` | 400 `RUN4001` | 예 | noteId는 JSON number가 아니라 10진 문자열. | +| `note-id-over-long-max` | 400 `RUN4001` | 예 | noteId가 Java Long.MAX_VALUE 초과 — 404가 아니라 형식 오류. | +| `note-id-trailing-newline` | 400 `RUN4001` | 예 | noteId 끝 줄바꿈. | +| `request-id-trailing-newline` | 400 `RUN4001` | 예 | requestId 끝 줄바꿈 — 패턴은 입력 끝까지 고정한다($의 줄바꿈 허용 금지). | +| `selection-inverted` | 400 `RUN4001` | 아니오(계약 검사) | left str: + """Regex for a canonical decimal (no leading zero) in 1..limit.""" + + digits = str(limit) + width = len(digits) + branches = [f"[1-9][0-9]{{0,{width - 2}}}"] + for index, char in enumerate(digits): + low = 1 if index == 0 else 0 + if int(char) > low: + high = int(char) - 1 + head = str(low) if low == high else f"[{low}-{high}]" + rest = width - index - 1 + tail = {0: "", 1: "[0-9]"}.get(rest, f"[0-9]{{{rest}}}") + branches.append(f"{digits[:index]}{head}{tail}") + branches.append(digits) + return "(?:" + "|".join(branches) + ")" + + +# `$` alone also matches before one trailing "\n" (Python re, Java), so every +# anchored pattern ends with an absolute end-of-input lookahead instead. +END = "(?![\\s\\S])" +UUID_BODY = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" +UUID_PATTERN = f"^{UUID_BODY}{END}" +NOTE_ID_PATTERN = f"^{_decimal_at_most(MAX_NOTE_ID)}{END}" +OPAQUE_ID_PATTERN = f"^[A-Za-z0-9][A-Za-z0-9_.:-]{{0,127}}{END}" +CURSOR_PATTERN = f"^{UUID_BODY}:{_decimal_at_most(MAX_SEQ)}{END}" +OCCURRED_AT_PATTERN = ( + "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,6})?Z" + END +) +# Any code point outside the internal-run-v1 strip set (Python str.strip()). +NON_BLANK_PATTERN = ( + "[^\\t\\n\\u000b\\f\\r\\u001c-\\u001f \\u0085\\u00a0\\u1680" + "\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000]" +) +FORBIDDEN_PUBLIC_KEYS = ( + "attempt", + "fenceToken", + "fence_token", + "sourceEventId", + "source_event_id", + "failureClass", + "failure_class", + "requestFingerprint", + "usage", + "deadline", +) +# Server-owned response objects stay additive-tolerant but never carry an +# internal key (same list as frames). +PUBLIC_RESPONSE_OBJECTS = ( + "ErrorResponse", + "ActiveRunConflict", + "RunFailureSummary", + "RunStatus", + "RunCancelResult", + "TurnAttachmentView", + "UserMessageContent", + "AssistantFailure", + "AssistantCancel", + "AssistantMessageContent", + "ConversationMessage", + "ActiveRunSummary", + "ConversationPage", +) +STRIPPED_LENGTH_NOTE = ( + f"1..{TEXT_MAX} code points after stripping the internal-run-v1 strip set " + "(Python str.strip()); the upper bound is a contract check (RUN4001) " + "beyond this schema because the raw value may carry surrounding blanks." +) +FORBIDDEN_REQUEST_KEYS = ( + "inputScope", + "scope", + "usage", + "credit", + "attempt", + "fenceToken", + "history", + "verifiedSolutionRefs", + "turnAttachmentRefs", + "retrievalScope", + "corpusVersion", + "conversationId", + "userId", + "authToken", + "deadline", + "budget", + "runId", + "locale", +) + +PUBLIC_RUN_STATUSES = ( + "PENDING", + "RUNNING", + "CANCELING", + "COMPLETED", + "FAILED", + "CANCELED", +) +TERMINAL_TYPES = ("run.completed", "run.failed", "run.canceled") +SERVER_FRAME_TYPES = ( + "run.created", + "response.page.started", + "response.page.completed", + "run.canceling", + "run.canceled", +) +CANCEL_MESSAGES = { + "user_requested": "답변 생성을 중단했습니다.", + "asset_deleting": "참조한 파일이 삭제되어 답변 생성을 중단했습니다.", + "budget_exhausted": "처리 한도에 도달해 답변 생성을 중단했습니다.", +} +ERRORS = { + "AUTH4010": (401, "인증 토큰이 필요합니다."), + "AUTH4012": (401, "토큰이 만료되었습니다."), + "AUTH4013": (401, "유효하지 않은 토큰입니다."), + "RUN4001": (400, "요청 형식이 올바르지 않습니다."), + "RUN4002": (400, "지원하지 않는 요청 항목이 포함되어 있습니다."), + "RUN4003": (400, "이벤트 재개 위치가 올바르지 않습니다."), + "RUN4004": (400, "다른 실행의 이벤트 재개 위치입니다."), + "RUN4021": (402, "사용 가능한 잔여 사용량이 부족합니다."), + "RUN4041": (404, "대상을 찾을 수 없습니다."), + "RUN4091": (409, "이 노트에서 진행 중인 답변이 있습니다."), + "RUN4092": (409, "이미 사용된 요청 ID입니다."), + "RUN4221": (422, "첨부 또는 참조한 자료를 사용할 수 없습니다."), + "RUN4291": (429, "요청이 너무 많습니다. 잠시 후 다시 시도해 주세요."), +} +SUCCESS_MESSAGE = "요청에 성공했습니다." + + +def fixed_uuid(name: str) -> str: + return str(uuid.uuid5(uuid.NAMESPACE_URL, f"https://proovy.invalid/{name}")) + + +REQUEST_ID = fixed_uuid("request/primary") +OTHER_REQUEST_ID = fixed_uuid("request/other") +RUN_ID = fixed_uuid("run/primary") +ACTIVE_RUN_ID = fixed_uuid("run/active") +OTHER_RUN_ID = fixed_uuid("run/other") +NOTE_ID = "123" +USER_MESSAGE_ID = fixed_uuid("message/user") +ASSISTANT_MESSAGE_ID = fixed_uuid("message/assistant") +ATTACHMENT_ID = fixed_uuid("attachment/1") +ATTACHMENT_ID_2 = fixed_uuid("attachment/2") +NOTE_ASSET_ID = "asset-9f8c" +ASTRAL = "\U0001d465" # MATHEMATICAL ITALIC SMALL X: 1 code point, 2 UTF-16 units +START = datetime(2026, 10, 17, 9, tzinfo=UTC) + + +def scenario_ids(key: str) -> dict[str, str]: + """IDs of one run scenario; every fixture describing that run reuses them.""" + + if key == "primary": + return { + "runId": RUN_ID, + "requestId": REQUEST_ID, + "userMessageId": USER_MESSAGE_ID, + "assistantMessageId": ASSISTANT_MESSAGE_ID, + } + return { + "runId": fixed_uuid(f"run/{key}"), + "requestId": fixed_uuid(f"request/{key}"), + "userMessageId": fixed_uuid(f"message/user/{key}"), + "assistantMessageId": fixed_uuid(f"message/assistant/{key}"), + } + + +ACTIVE = scenario_ids("active") +assert ACTIVE["runId"] == ACTIVE_RUN_ID + + +def iso(moment: datetime) -> str: + return moment.isoformat().replace("+00:00", "Z") + + +# -------------------------------------------------------------------------- +# schema +# -------------------------------------------------------------------------- + + +def _ref(name: str) -> dict[str, str]: + return {"$ref": f"#/$defs/{name}"} + + +def _terminal(name: str) -> dict[str, str]: + return {"$ref": f"{TERMINAL_REF}#/$defs/{name}"} + + +def _nullable(schema: dict[str, Any]) -> dict[str, Any]: + return {"anyOf": [schema, {"type": "null"}]} + + +def _forbid(keys: tuple[str, ...]) -> dict[str, Any]: + return {"not": {"anyOf": [{"required": [key]} for key in keys]}} + + +def _frame(event_type: str, payload: str) -> dict[str, Any]: + return { + "type": "object", + "additionalProperties": True, + "required": [ + "contractVersion", + "runId", + "seq", + "occurredAt", + "type", + "payload", + ], + "properties": { + "contractVersion": {"const": FRAME_CONTRACT_VERSION}, + "runId": _ref("Uuid"), + "seq": _ref("Seq"), + "occurredAt": _ref("OccurredAt"), + "type": {"const": event_type}, + "payload": _ref(payload), + }, + } + + +def _success(result: str) -> dict[str, Any]: + return { + "type": "object", + "additionalProperties": True, + "required": ["isSuccess", "code", "message", "result"], + "properties": { + "isSuccess": {"const": True}, + "code": {"const": "COMMON200"}, + "message": {"const": SUCCESS_MESSAGE}, + "result": _ref(result), + }, + } + + +def _when(prop: str, values: list[str], then: dict[str, Any]) -> dict[str, Any]: + return { + "if": {"required": [prop], "properties": {prop: {"enum": values}}}, + "then": then, + } + + +def schema() -> dict[str, Any]: + string_array = {"type": "array", "uniqueItems": True} + defs: dict[str, Any] = { + "Uuid": {"type": "string", "pattern": UUID_PATTERN}, + "NoteId": {"type": "string", "pattern": NOTE_ID_PATTERN}, + "OpaqueId": {"type": "string", "pattern": OPAQUE_ID_PATTERN}, + "Seq": {"type": "integer", "minimum": 1, "maximum": MAX_SEQ}, + "EventId": {"type": "string", "pattern": CURSOR_PATTERN}, + "OccurredAt": {"type": "string", "pattern": OCCURRED_AT_PATTERN}, + "PublicRunStatus": {"enum": list(PUBLIC_RUN_STATUSES)}, + "ActiveRunStatus": {"enum": ["PENDING", "RUNNING", "CANCELING"]}, + "RequestedTask": {"enum": ["solve", "check", "explain"]}, + "RequestedOutput": { + "description": "video is a known value but Phase-1 gated: RUN4002.", + "enum": ["text", "video"], + }, + "CancelCause": {"enum": list(CANCEL_MESSAGES)}, + "AttachmentMime": { + "enum": ["application/pdf", "image/jpeg", "image/png", "image/webp"] + }, + "PageSelection": { + "description": "page-selection-v1; left dict[str, Any]: + """The semantic document compared for request-ID reuse (requestId dropped).""" + + document: dict[str, Any] = {} + if request.get("noteId") is not None: + document["noteId"] = request["noteId"] + document["text"] = request["text"].strip() + if request.get("latex") is not None: + document["latex"] = request["latex"].strip() + for key in ("requestedTasks", "requestedOutputs", "turnAttachmentIds"): + values = sorted(request.get(key) or []) + if values: + document[key] = values + focus = request.get("viewerFocus") + if focus is not None: + document["viewerFocus"] = { + key: value for key, value in focus.items() if value is not None + } + return document + + +def fingerprint(request: dict[str, Any]) -> tuple[str, str]: + canonical = canonical_json_dumps(normalized_request(request)) + return canonical, hashlib.sha256(canonical.encode()).hexdigest() + + +FULL_REQUEST = { + "requestId": REQUEST_ID, + "noteId": NOTE_ID, + "text": "이 문제를 풀고 검산해줘", + "latex": "\\int_0^1 x^2\\,dx", + "requestedTasks": ["solve", "check"], + "requestedOutputs": ["text"], + "turnAttachmentIds": [ATTACHMENT_ID], + "viewerFocus": { + "noteAssetId": NOTE_ASSET_ID, + "page": 3, + "selection": {"left": 0.1, "top": 0.2, "right": 0.6, "bottom": 0.5}, + }, +} +MINIMAL_REQUEST = {"requestId": REQUEST_ID, "text": "이 문제를 풀어줘"} + + +def _with(base: dict[str, Any], **changes: Any) -> dict[str, Any]: + value = deepcopy(base) + for key, item in changes.items(): + if item is _DROP: + value.pop(key, None) + else: + value[key] = item + return value + + +_DROP = object() + + +def valid_requests() -> dict[str, dict[str, Any]]: + cases = { + "first-no-note": ( + "noteId 없는 홈 첫 질문 — Server가 Note를 자동 생성한다.", + MINIMAL_REQUEST, + ), + "existing-note-full": ("모든 선택 필드를 채운 기존 Note 요청.", FULL_REQUEST), + "empty-arrays": ( + "빈 requestedTasks/Outputs·turnAttachmentIds — Brain이 문장에서 추론한다.", + _with( + MINIMAL_REQUEST, + noteId=NOTE_ID, + requestedTasks=[], + requestedOutputs=[], + turnAttachmentIds=[], + ), + ), + "viewer-focus-no-selection": ( + "selection 없는 Viewer Focus(page 전체).", + _with( + MINIMAL_REQUEST, + noteId=NOTE_ID, + viewerFocus={ + "noteAssetId": NOTE_ASSET_ID, + "page": 1, + "selection": None, + }, + ), + ), + "natural-language-variant": ( + "자연어 미지원 의도(변형 문제 생성)는 wire 오류가 아니다 — 접수되어 " + "unsupported_only로 COMPLETED 된다(streams/unsupported-only-completed).", + _with(MINIMAL_REQUEST, text="이 문제와 비슷한 변형 문제를 만들어줘"), + ), + "text-at-limit": ( + f"text {TEXT_MAX} code point 경계값.", + _with(MINIMAL_REQUEST, text="가" * TEXT_MAX), + ), + "text-at-limit-astral": ( + f"astral 문자(U+1D465) {TEXT_MAX}개 — 길이는 UTF-16 단위가 아니라 code point.", + _with(MINIMAL_REQUEST, text=ASTRAL * TEXT_MAX), + ), + "text-at-limit-surrounded-by-blanks": ( + f"strip 후 {TEXT_MAX} code point — 상한은 strip 후 길이로 판정한다" + "(원문은 상한 초과).", + _with(MINIMAL_REQUEST, text="\u3000 " + "가" * TEXT_MAX + " \u00a0"), + ), + "latex-at-limit-surrounded-by-blanks": ( + f"latex도 strip 후 {LATEX_MAX} code point로 판정한다.", + _with(MINIMAL_REQUEST, latex="\n" + "x" * LATEX_MAX + "\n"), + ), + "note-id-long-max": ( + "noteId 상한 = Java Long.MAX_VALUE(BIGINT).", + _with(MINIMAL_REQUEST, noteId=str(MAX_NOTE_ID)), + ), + } + result = {} + for name, (rationale, request) in cases.items(): + canonical, digest = fingerprint(request) + result[name] = { + "rationale": rationale, + "request": request, + "expected_canonical": canonical, + "expected_fingerprint": digest, + } + return result + + +def invalid_requests() -> dict[str, dict[str, Any]]: + selection = FULL_REQUEST["viewerFocus"] + cases: dict[str, tuple[str, dict[str, Any], bool, str]] = { + "missing-request-id": ( + "requestId는 body 필수.", + _with(MINIMAL_REQUEST, requestId=_DROP), + True, + "RUN4001", + ), + "uppercase-request-id": ( + "requestId는 소문자 canonical UUID만 허용.", + _with(MINIMAL_REQUEST, requestId=REQUEST_ID.upper()), + True, + "RUN4001", + ), + "non-uuid-request-id": ( + "UUID가 아닌 requestId.", + _with(MINIMAL_REQUEST, requestId="req-1"), + True, + "RUN4001", + ), + "note-id-number": ( + "noteId는 JSON number가 아니라 10진 문자열.", + _with(MINIMAL_REQUEST, noteId=123), + True, + "RUN4001", + ), + "note-id-leading-zero": ( + "noteId 선행 0 금지.", + _with(MINIMAL_REQUEST, noteId="0123"), + True, + "RUN4001", + ), + "missing-text": ( + "text 필수.", + _with(MINIMAL_REQUEST, text=_DROP), + True, + "RUN4001", + ), + "blank-text": ( + "strip 집합 공백만 있는 text.", + _with(MINIMAL_REQUEST, text=" \t   "), + True, + "RUN4001", + ), + "text-too-long": ( + f"text strip 후 {TEXT_MAX + 1} code point — schema는 통과, 계약 검사(RUN4001).", + _with(MINIMAL_REQUEST, text="가" * (TEXT_MAX + 1)), + False, + "RUN4001", + ), + "text-too-long-astral": ( + f"astral 문자 {TEXT_MAX + 1}개 — UTF-16 길이로 세면 잘못 판정한다.", + _with(MINIMAL_REQUEST, text=ASTRAL * (TEXT_MAX + 1)), + False, + "RUN4001", + ), + "request-id-trailing-newline": ( + "requestId 끝 줄바꿈 — 패턴은 입력 끝까지 고정한다($의 줄바꿈 허용 금지).", + _with(MINIMAL_REQUEST, requestId=REQUEST_ID + "\n"), + True, + "RUN4001", + ), + "note-id-trailing-newline": ( + "noteId 끝 줄바꿈.", + _with(MINIMAL_REQUEST, noteId=NOTE_ID + "\n"), + True, + "RUN4001", + ), + "note-id-over-long-max": ( + "noteId가 Java Long.MAX_VALUE 초과 — 404가 아니라 형식 오류.", + _with(MINIMAL_REQUEST, noteId=str(MAX_NOTE_ID + 1)), + True, + "RUN4001", + ), + "note-asset-id-trailing-newline": ( + "viewerFocus.noteAssetId 끝 줄바꿈.", + _with( + MINIMAL_REQUEST, + viewerFocus={"noteAssetId": NOTE_ASSET_ID + "\n", "page": 1}, + ), + True, + "RUN4001", + ), + "empty-latex": ( + "latex는 있으면 비어 있을 수 없다(null은 부재와 같음).", + _with(MINIMAL_REQUEST, latex=""), + True, + "RUN4001", + ), + "latex-too-long": ( + f"latex strip 후 {LATEX_MAX + 1} code point — schema는 통과, 계약 검사(RUN4001).", + _with(MINIMAL_REQUEST, latex="x" * (LATEX_MAX + 1)), + False, + "RUN4001", + ), + "unknown-field": ( + "요청은 closed — 알 수 없는 필드.", + _with(MINIMAL_REQUEST, isStream=True), + True, + "RUN4001", + ), + "duplicate-task": ( + "requestedTasks 중복.", + _with(MINIMAL_REQUEST, requestedTasks=["solve", "solve"]), + True, + "RUN4001", + ), + "unknown-task-variant": ( + "variant는 v1 wire enum이 아니다(자연어 의도와 구분).", + _with(MINIMAL_REQUEST, requestedTasks=["variant"]), + True, + "RUN4001", + ), + "attachment-not-uuid": ( + "turnAttachmentIds 항목은 UUID.", + _with(MINIMAL_REQUEST, turnAttachmentIds=["att-1"]), + True, + "RUN4001", + ), + "too-many-attachments-wire": ( + f"wire 상한 {ATTACHMENT_WIRE_MAX}개 초과(policy 한도와 별개).", + _with( + MINIMAL_REQUEST, + turnAttachmentIds=[ + fixed_uuid(f"attachment/many/{index}") + for index in range(ATTACHMENT_WIRE_MAX + 1) + ], + ), + True, + "RUN4001", + ), + "viewer-page-zero": ( + "page는 1-based.", + _with( + MINIMAL_REQUEST, viewerFocus={"noteAssetId": NOTE_ASSET_ID, "page": 0} + ), + True, + "RUN4001", + ), + "viewer-focus-missing-page": ( + "viewerFocus.page 필수.", + _with(MINIMAL_REQUEST, viewerFocus={"noteAssetId": NOTE_ASSET_ID}), + True, + "RUN4001", + ), + "selection-out-of-range": ( + "selection 좌표는 [0,1].", + _with( + MINIMAL_REQUEST, + viewerFocus={ + **selection, + "selection": {"left": 0.1, "top": 0.2, "right": 1.2, "bottom": 0.5}, + }, + ), + True, + "RUN4001", + ), + "selection-inverted": ( + "left dict[str, dict[str, Any]]: + minimal = _with(MINIMAL_REQUEST, noteId=NOTE_ID) + pairs = { + "same-other-request-id": ( + "same", + "requestId는 의미 문서에서 제외된다.", + FULL_REQUEST, + _with(FULL_REQUEST, requestId=OTHER_REQUEST_ID), + ), + "same-surrounding-whitespace": ( + "text·latex 양끝 공백은 strip된다.", + FULL_REQUEST, + _with( + FULL_REQUEST, + text=" 이 문제를 풀고 검산해줘 ", + latex="\\int_0^1 x^2\\,dx\n", + ), + ), + "same-non-java-whitespace": ( + "same", + "U+00A0·U+0085·U+202F 양끝 공백도 strip된다 — Java String.strip()은 " + "U+00A0·U+202F를 남기므로 strip 집합을 직접 구현해야 한다.", + FULL_REQUEST, + _with( + FULL_REQUEST, + text="\u00a0이 문제를 풀고 검산해줘\u0085\u202f", + latex="\u202f\\int_0^1 x^2\\,dx\u00a0", + ), + ), + "different-inner-whitespace": ( + "different", + "strip은 양끝만 — 내부 공백은 의미 차이다.", + FULL_REQUEST, + _with(FULL_REQUEST, text="이 문제를 풀고 검산해줘"), + ), + "same-array-order": ( + "same", + "비순서 배열은 정렬된다.", + FULL_REQUEST, + _with( + FULL_REQUEST, + requestedTasks=["check", "solve"], + turnAttachmentIds=[ATTACHMENT_ID], + ), + ), + "same-empty-array-vs-absent": ( + "same", + "빈 배열과 부재는 같다.", + minimal, + _with( + minimal, requestedTasks=[], requestedOutputs=[], turnAttachmentIds=[] + ), + ), + "same-null-vs-absent": ( + "same", + "null과 부재는 같다.", + minimal, + _with(minimal, latex=None, viewerFocus=None), + ), + "same-null-selection": ( + "same", + "selection null과 부재는 같다.", + _with(minimal, viewerFocus={"noteAssetId": NOTE_ASSET_ID, "page": 2}), + _with( + minimal, + viewerFocus={ + "noteAssetId": NOTE_ASSET_ID, + "page": 2, + "selection": None, + }, + ), + ), + "different-text": ( + "different", + "text 변경.", + FULL_REQUEST, + _with(FULL_REQUEST, text="이 문제를 설명해줘"), + ), + "different-latex": ( + "different", + "latex 변경.", + FULL_REQUEST, + _with(FULL_REQUEST, latex="x^2"), + ), + "different-tasks": ( + "different", + "requestedTasks 변경.", + FULL_REQUEST, + _with(FULL_REQUEST, requestedTasks=["solve"]), + ), + "different-outputs-explicit-vs-inferred": ( + "different", + "['text'] 명시와 [](추론)은 다른 의미다 — fingerprint 전에 기본값을 " + "채우지 않는다.", + FULL_REQUEST, + _with(FULL_REQUEST, requestedOutputs=[]), + ), + "different-attachment": ( + "different", + "첨부 변경.", + FULL_REQUEST, + _with(FULL_REQUEST, turnAttachmentIds=[ATTACHMENT_ID, ATTACHMENT_ID_2]), + ), + "different-viewer-page": ( + "different", + "Viewer Focus page 변경.", + FULL_REQUEST, + _with(FULL_REQUEST, viewerFocus={**FULL_REQUEST["viewerFocus"], "page": 4}), + ), + "different-selection": ( + "different", + "selection 변경.", + FULL_REQUEST, + _with( + FULL_REQUEST, + viewerFocus={ + **FULL_REQUEST["viewerFocus"], + "selection": {"left": 0.1, "top": 0.2, "right": 0.7, "bottom": 0.5}, + }, + ), + ), + "different-note-id-added": ( + "different", + "noteId 부재와 존재는 다르다 — 받은 run.created.noteId를 넣어 재전송하면 RUN4092.", + MINIMAL_REQUEST, + _with(MINIMAL_REQUEST, noteId=NOTE_ID), + ), + } + result = {} + for name, spec in pairs.items(): + if len(spec) == 3: + rationale, left, right = spec + relation = "same" + else: + relation, rationale, left, right = spec + left_canonical, left_digest = fingerprint(left) + right_canonical, right_digest = fingerprint(right) + assert (left_digest == right_digest) == (relation == "same"), name + result[name] = { + "relation": relation, + "rationale": rationale, + "left": left, + "right": right, + "expected_canonical_left": left_canonical, + "expected_canonical_right": right_canonical, + "expected_fingerprint_left": left_digest, + "expected_fingerprint_right": right_digest, + } + return result + + +# -------------------------------------------------------------------------- +# admission decisions +# -------------------------------------------------------------------------- + + +def _state(**overrides: Any) -> dict[str, Any]: + state = { + "authenticated": True, + "request": "valid", + "existing": None, + "note": "owned", + "references": "ok", + "activeRun": None, + "rateLimited": False, + "balance": "sufficient", + } + state.update(overrides) + return state + + +def _error(code: str, **extra: Any) -> dict[str, Any]: + http, _ = ERRORS[code] + return { + "http": http, + "outcome": "error", + "code": code, + "sideEffects": "none", + **extra, + } + + +def admission_decisions() -> dict[str, Any]: + stream_new = { + "http": 200, + "outcome": "stream_new", + "replayFromSeq": 1, + "sideEffects": "created", + } + replay_live = { + "http": 200, + "outcome": "stream_replay", + "replayFromSeq": 1, + "thenLive": True, + "sideEffects": "none", + } + replay_closed = { + "http": 200, + "outcome": "stream_replay", + "replayFromSeq": 1, + "thenLive": False, + "sideEffects": "none", + } + same = {"sameUser": True, "sameFingerprint": True} + cases = [ + ( + "new-accepted", + "정상 신규 접수 — seq 1 run.created부터.", + _state(), + stream_new, + ), + ( + "new-without-note", + "noteId 없는 첫 요청 — Note 자동 생성.", + _state(note="absent"), + stream_new, + ), + ( + "duplicate-same-user-running", + "같은 사용자·같은 의미 재전송 — 기존 run replay 후 live 합류.", + _state(existing={**same, "runStatus": "RUNNING"}), + replay_live, + ), + ( + "duplicate-same-user-completed", + "terminal run 재전송 — terminal까지 replay 후 종료.", + _state(existing={**same, "runStatus": "COMPLETED"}), + replay_closed, + ), + ( + "duplicate-different-meaning", + "같은 사용자, 다른 의미의 requestId 재사용.", + _state( + existing={ + "sameUser": True, + "sameFingerprint": False, + "runStatus": "COMPLETED", + } + ), + _error("RUN4092"), + ), + ( + "duplicate-other-user", + "다른 사용자의 requestId — 상태·runId·noteId 비노출, 다른 의미와 동일 body.", + _state( + existing={ + "sameUser": False, + "sameFingerprint": True, + "runStatus": "RUNNING", + } + ), + _error("RUN4092"), + ), + ( + "duplicate-with-note-id-added", + "run.created.noteId를 추가해 재전송 — 다른 의미.", + _state( + existing={ + "sameUser": True, + "sameFingerprint": False, + "runStatus": "RUNNING", + } + ), + _error("RUN4092"), + ), + ( + "duplicate-wins-over-active-run", + "이미 접수된 요청의 재전송은 자기 자신 때문에 409 Active Run을 받지 않는다.", + _state( + existing={**same, "runStatus": "PENDING"}, + activeRun={"runId": RUN_ID, "status": "PENDING"}, + ), + replay_live, + ), + ( + "duplicate-not-rate-limited", + "재조회는 rate limit·잔액 대상이 아니다.", + _state( + existing={**same, "runStatus": "COMPLETED"}, + rateLimited=True, + balance="insufficient", + ), + replay_closed, + ), + ( + "active-run-conflict", + "같은 Note에 RUNNING run — activeRunId가 담긴 409.", + _state(activeRun={"runId": ACTIVE_RUN_ID, "status": "RUNNING"}), + _error("RUN4091", result={"activeRunId": ACTIVE_RUN_ID}), + ), + ( + "active-run-canceling-conflict", + "CANCELING도 Active Run이다.", + _state(activeRun={"runId": ACTIVE_RUN_ID, "status": "CANCELING"}), + _error("RUN4091", result={"activeRunId": ACTIVE_RUN_ID}), + ), + ("note-missing", "없는 Note.", _state(note="missing"), _error("RUN4041")), + ( + "note-other-user", + "타인 Note — 존재 비노출.", + _state(note="other_user"), + _error("RUN4041"), + ), + ("note-deleting", "삭제 중 Note.", _state(note="deleting"), _error("RUN4041")), + ( + "note-check-before-active-run", + "Note 권한 검사가 Active Run보다 먼저 — 타인 Note의 activeRunId를 노출하지 않는다.", + _state( + note="other_user", + activeRun={"runId": ACTIVE_RUN_ID, "status": "RUNNING"}, + ), + _error("RUN4041"), + ), + ( + "attachment-other-note", + "다른 Note의 첨부.", + _state(references="invalid"), + _error("RUN4221"), + ), + ( + "attachment-policy-exceeded", + "policy 개수/용량 한도 초과.", + _state(references="policy_exceeded"), + _error("RUN4221"), + ), + ( + "rate-limited", + "사용자별 반복 요청 제한.", + _state(rateLimited=True), + _error("RUN4291", headers={"Retry-After": "30"}), + ), + ( + "active-run-before-rate-limit", + "Active Run 409가 rate limit보다 먼저(상태 복구에 필요).", + _state( + activeRun={"runId": ACTIVE_RUN_ID, "status": "RUNNING"}, + rateLimited=True, + ), + _error("RUN4091", result={"activeRunId": ACTIVE_RUN_ID}), + ), + ( + "insufficient-balance", + "잔액 부족.", + _state(balance="insufficient"), + _error("RUN4021"), + ), + ( + "schema-invalid", + "구조 위반은 requestId 조회보다 먼저.", + _state(request="schema_invalid", existing={**same, "runStatus": "RUNNING"}), + _error("RUN4001"), + ), + ( + "unsupported-output", + "video output.", + _state(request="unsupported"), + _error("RUN4002"), + ), + ( + "unauthenticated", + "인증 실패가 가장 먼저.", + _state(authenticated=False, request="schema_invalid"), + { + "http": 401, + "outcome": "error", + "code": "AUTH4010", + "sideEffects": "none", + }, + ), + ] + return { + "precedence": [ + "authenticated", + "request:schema_invalid", + "request:unsupported", + "existing:same_user_same_fingerprint", + "existing:other", + "note", + "references", + "activeRun", + "rateLimited", + "balance", + "accept", + ], + "cases": [ + {"name": name, "rationale": rationale, "state": state, "expected": expected} + for name, rationale, state, expected in cases + ], + } + + +# -------------------------------------------------------------------------- +# streams +# -------------------------------------------------------------------------- + + +class StreamBuilder: + def __init__(self, scenario: str = "primary") -> None: + self.ids = scenario_ids(scenario) + self.run_id = self.ids["runId"] + self.frames: list[dict[str, Any]] = [] + self.revisions: dict[str, int] = {} + + def _next(self) -> tuple[int, str]: + seq = len(self.frames) + 1 + return seq, iso(START + timedelta(seconds=seq - 1)) + + def server(self, event_type: str, payload: dict[str, Any]) -> StreamBuilder: + seq, occurred = self._next() + self.frames.append( + { + "contractVersion": FRAME_CONTRACT_VERSION, + "runId": self.run_id, + "seq": seq, + "occurredAt": occurred, + "type": event_type, + "payload": payload, + } + ) + return self + + def created(self, tasks: list[str] | None = None) -> StreamBuilder: + return self.server( + "run.created", + { + "requestId": self.ids["requestId"], + "noteId": NOTE_ID, + "userMessageId": self.ids["userMessageId"], + "assistantMessageId": self.ids["assistantMessageId"], + "status": "PENDING", + "requestedTasks": tasks if tasks is not None else ["solve"], + "requestedOutputs": ["text"], + }, + ) + + def activity(self, source: dict[str, Any]) -> StreamBuilder: + """Append an activity frame produced by Brain's own browser serializer.""" + + seq, occurred = self._next() + activity_id = source["payload"]["activity_id"] + revision = self.revisions.get(activity_id, 0) + self.revisions[activity_id] = revision + 1 + event = deepcopy(source) + event.update( + run_id=self.run_id, + occurred_at=occurred, + source_event_id=derive_source_event_id( + self.run_id, activity_id, revision, SourceEventType(event["type"]) + ), + ) + browser = to_browser_event_payload(parse_source_event(event)) + self.frames.append({**browser, "seq": seq}) + return self + + def terminal_pair(self, name: str) -> StreamBuilder: + """Append page boundaries + the terminal-v2 browser frame of ``name``.""" + + pair = json.loads((TERMINAL / "pairs" / f"{name}.json").read_text()) + browser = deepcopy(pair["browser"]) + payload = browser["payload"] + payload["assistantMessageId"] = self.ids["assistantMessageId"] + if browser["type"] == "run.completed": + pages = payload["assistantResponse"]["responsePages"] + for index, page in enumerate(pages): + started = { + "assistantMessageId": self.ids["assistantMessageId"], + "responsePageId": page["responsePageId"], + "presentation": page["presentation"], + "pageIndex": index, + } + if page.get("title") is not None: + started["title"] = page["title"] + self.server("response.page.started", started) + self.server( + "response.page.completed", + { + "assistantMessageId": self.ids["assistantMessageId"], + "responsePageId": page["responsePageId"], + "pageIndex": index, + }, + ) + seq, occurred = self._next() + browser.update(runId=self.run_id, seq=seq, occurredAt=occurred) + self.frames.append(browser) + return self + + def canceling(self) -> StreamBuilder: + return self.server( + "run.canceling", + { + "assistantMessageId": self.ids["assistantMessageId"], + "status": "CANCELING", + }, + ) + + def canceled(self, cause: str = "user_requested") -> StreamBuilder: + return self.server( + "run.canceled", + { + "assistantMessageId": self.ids["assistantMessageId"], + "status": "CANCELED", + "cause": cause, + "message": CANCEL_MESSAGES[cause], + }, + ) + + +def _activity_file(name: str) -> dict[str, Any]: + return json.loads((ACTIVITY / f"{name}.json").read_text()) + + +def _activity( + kind: str, activity_id: str, event_type: str, **payload: Any +) -> dict[str, Any]: + return { + "contract_version": FRAME_CONTRACT_VERSION, + "run_id": RUN_ID, + "attempt": 1, + "fence_token": "fence-1", + "source_event_id": "placeholder", + "occurred_at": iso(START), + "type": event_type, + "payload": {"kind": kind, "activity_id": activity_id, **payload}, + } + + +METHOD_LABEL = _activity_file("verification-updated")["payload"]["method_label"] + + +def _analysis(builder: StreamBuilder) -> StreamBuilder: + return ( + builder.activity(_activity_file("input-analysis-started")) + .activity(_activity_file("input-analysis-updated")) + .activity(_activity("input_analysis", "activity-1", "activity.completed")) + ) + + +def _solve(builder: StreamBuilder) -> StreamBuilder: + return ( + builder.activity( + _activity( + "solution_generation", + "activity-2", + "activity.started", + label="drafting_solution", + ) + ) + .activity(_activity("solution_generation", "activity-2", "activity.completed")) + .activity( + _activity( + "verification", + "activity-7", + "activity.started", + label="verifying_steps", + method_label=METHOD_LABEL, + ) + ) + .activity(_activity_file("verification-updated")) + .activity(_activity_file("verification-completed")) + ) + + +SSE_NOTE = ( + "event/id 줄은 byte 비교, data 줄은 JSON 의미 비교(키 순서·공백은 구현 자유)." +) + + +def sse_text(frames: list[dict[str, Any]]) -> str: + return "".join( + f"event: {frame['type']}\nid: {frame['runId']}:{frame['seq']}\n" + f"data: {json.dumps(frame, ensure_ascii=False, separators=(',', ':'))}\n\n" + for frame in frames + ) + + +def streams() -> dict[str, dict[str, Any]]: + specs: dict[str, tuple[str, str, StreamBuilder]] = { + "substantive-completed": ( + "COMPLETED", + "substantive 2-page 완료 — page 경계 2쌍 뒤 run.completed(terminal-v2 multi-page).", + _solve(_analysis(StreamBuilder().created())).terminal_pair("multi-page"), + ), + "substantive-with-source": ( + "COMPLETED", + "source chip이 있는 substantive(terminal-v2 direct-verified, available=true).", + _solve(_analysis(StreamBuilder("source").created())).terminal_pair( + "direct-verified" + ), + ), + "clarification-completed": ( + "COMPLETED", + "clarification_only — 1 page, source 없음, completed(보상은 Server 정책).", + _analysis(StreamBuilder("clarification").created(tasks=[])).terminal_pair( + "clarification" + ), + ), + "unsupported-only-completed": ( + "COMPLETED", + "자연어 변형 요청의 unsupported_only 완료.", + _analysis(StreamBuilder("unsupported").created(tasks=[])).terminal_pair( + "unsupported-only" + ), + ), + "failed-verification": ( + "FAILED", + "verification_failed — page 이벤트·풀이 없음.", + _analysis(StreamBuilder("failed").created()) + .activity( + _activity( + "solution_generation", + "activity-2", + "activity.started", + label="drafting_solution", + ) + ) + .activity(_activity_file("solution-generation-updated")) + .activity(_activity_file("solution-generation-failed")) + .terminal_pair("failure"), + ), + "failed-invalid-attachment": ( + "FAILED", + "invalid_attachment — reason sub-code만 추가.", + StreamBuilder("invalid-attachment") + .created() + .terminal_pair("failure-invalid-attachment"), + ), + "canceled-pending": ( + "CANCELED", + "PENDING 취소 — run.canceling 없이 run.canceled.", + StreamBuilder("canceled").created().canceled(), + ), + "canceled-running": ( + "CANCELED", + "RUNNING 취소 — canceling 뒤 진행 Activity만 canceled.", + _analysis(StreamBuilder("canceled-running").created()) + .activity( + _activity( + "note_retrieval", + "activity-8", + "activity.started", + label="searching_notes", + ) + ) + .canceling() + .activity(_activity_file("note-retrieval-canceled")) + .canceled(), + ), + "canceled-asset-deleting": ( + "CANCELED", + "의존 asset 삭제로 인한 취소 — cause=asset_deleting.", + _analysis(StreamBuilder("asset-deleting").created()) + .canceling() + .canceled("asset_deleting"), + ), + } + result = {} + for name, (final_status, rationale, builder) in specs.items(): + result[name] = { + "rationale": rationale, + "finalStatus": final_status, + "runId": builder.run_id, + "frames": builder.frames, + "sse": sse_text(builder.frames), + "sseNote": SSE_NOTE, + } + live = result["substantive-with-source"]["frames"] + replay = deepcopy(live) + for reference in replay[-1]["payload"]["assistantResponse"]["sourceReferences"]: + reference["available"] = False + result["replay-source-unavailable"] = { + "rationale": "같은 run을 asset 삭제 후 replay — read-time available만 다르고 " + "id·seq·label snapshot·plainText는 같다.", + "finalStatus": "COMPLETED", + "runId": result["substantive-with-source"]["runId"], + "frames": replay, + "live": live, + "allowedDifference": "payload.assistantResponse.sourceReferences[].available", + "sse": sse_text(replay), + "sseNote": SSE_NOTE, + } + return result + + +def invalid_streams(valid: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]: + completed = valid["substantive-completed"]["frames"] + canceled = valid["canceled-running"]["frames"] + failed = valid["failed-verification"]["frames"] + + def mutate(frames: list[dict[str, Any]], change) -> list[dict[str, Any]]: + value = deepcopy(frames) + change(value) + return value + + def renumber(frames: list[dict[str, Any]]) -> None: + for index, frame in enumerate(frames, start=1): + frame["seq"] = index + + def drop_first_page_completed(frames: list[dict[str, Any]]) -> None: + index = next( + i for i, f in enumerate(frames) if f["type"] == "response.page.completed" + ) + del frames[index] + renumber(frames) + + def page_in_failed(frames: list[dict[str, Any]]) -> None: + frames.insert( + len(frames) - 1, + { + **deepcopy(completed[-5]), + "runId": frames[0]["runId"], + }, + ) + renumber(frames) + + def page_after_canceling(frames: list[dict[str, Any]]) -> None: + frames.insert(len(frames) - 1, deepcopy(completed[-5])) + renumber(frames) + + def swap_pages(frames: list[dict[str, Any]]) -> None: + pages = [ + i for i, f in enumerate(frames) if f["type"].startswith("response.page.") + ] + first, second = pages[0:2], pages[2:4] + block_a = [frames[i] for i in first] + block_b = [frames[i] for i in second] + for i, frame in zip(first + second, block_b + block_a, strict=True): + frames[i] = deepcopy(frame) + renumber(frames) + + def activity_without_start(frames: list[dict[str, Any]]) -> None: + index = next(i for i, f in enumerate(frames) if f["type"] == "activity.started") + del frames[index] + renumber(frames) + + cases: dict[str, tuple[str, str, list[dict[str, Any]], bool]] = { + "seq-gap": ( + "S1", + "seq 빈틈.", + mutate(completed, lambda f: f[3].update(seq=40)), + False, + ), + "first-not-run-created": ( + "S1", + "첫 frame이 run.created가 아님.", + mutate(completed, lambda f: (f.pop(0), renumber(f))), + False, + ), + "two-terminals": ( + "S2", + "terminal 두 개.", + mutate( + completed, + lambda f: ( + f.append({**deepcopy(failed[-1]), "runId": f[0]["runId"]}), + renumber(f), + ), + ), + False, + ), + "event-after-terminal": ( + "S2", + "terminal 뒤 activity.", + mutate(completed, lambda f: (f.append(deepcopy(f[1])), renumber(f))), + False, + ), + "page-after-canceling": ( + "S3", + "run.canceling 뒤 page 경계.", + mutate(canceled, page_after_canceling), + False, + ), + "page-in-failed-run": ( + "S4", + "failed run에 page 경계.", + mutate(failed, page_in_failed), + False, + ), + "page-unpaired": ( + "S4", + "page.started만 있고 completed 없음.", + mutate(completed, drop_first_page_completed), + False, + ), + "page-order-mismatch": ( + "S4", + "pageIndex 순서가 run.completed pages와 다름.", + mutate(completed, swap_pages), + False, + ), + "page-id-not-in-completed": ( + "S4", + "page 경계 ID가 run.completed에 없음.", + mutate( + completed, + lambda f: f[-3].__setitem__( + "payload", {**f[-3]["payload"], "responsePageId": "page-x"} + ), + ), + False, + ), + "assistant-message-id-mismatch": ( + "S5", + "assistantMessageId가 run.created와 다름.", + mutate( + completed, + lambda f: f[-1]["payload"].update( + assistantMessageId=fixed_uuid("message/other") + ), + ), + False, + ), + "run-id-mismatch": ( + "S5", + "frame runId가 섞임.", + mutate(completed, lambda f: f[2].update(runId=OTHER_RUN_ID)), + False, + ), + "activity-without-start": ( + "S6", + "started 없는 activity 후속 이벤트.", + mutate(completed, activity_without_start), + False, + ), + "attempt-in-frame": ( + "S7", + "frame에 attempt.", + mutate(completed, lambda f: f[1].update(attempt=1)), + True, + ), + "fence-token-in-payload": ( + "S7", + "payload에 fenceToken.", + mutate(completed, lambda f: f[1]["payload"].update(fenceToken="fence-1")), + True, + ), + "source-event-id-in-frame": ( + "S7", + "frame에 sourceEventId.", + mutate(completed, lambda f: f[1].update(sourceEventId="x")), + True, + ), + "failure-class-in-run-failed": ( + "S7", + "run.failed에 failureClass.", + mutate( + failed, lambda f: f[-1]["payload"].update(failureClass="verification") + ), + True, + ), + "retrying-status-in-run-created": ( + "S7", + "내부 RETRYING/비PENDING status 노출.", + mutate(completed, lambda f: f[0]["payload"].update(status="RETRYING")), + True, + ), + "run-created-missing-note-id": ( + "S7", + "run.created에 noteId 누락.", + mutate(completed, lambda f: f[0]["payload"].pop("noteId")), + True, + ), + "cancel-unknown-cause": ( + "S7", + "허용되지 않은 cancel cause.", + mutate(canceled, lambda f: f[-1]["payload"].update(cause="disconnect")), + True, + ), + "cancel-message-mismatch": ( + "S7", + "cause별 고정 message와 다름.", + mutate(canceled, lambda f: f[-1]["payload"].update(message="중단됨")), + True, + ), + "non-uuid-run-id": ( + "S7", + "Server 소유 frame runId는 UUID.", + mutate(canceled, lambda f: [frame.update(runId="run-7") for frame in f]), + True, + ), + } + return { + name: { + "rationale": rationale, + "violates": rule, + "schema_rejects": schema_rejects, + "frames": frames, + } + for name, (rule, rationale, frames, schema_rejects) in sorted(cases.items()) + } + + +STREAM_RULES = { + "S1": "seq 1은 run.created이고 seq는 1부터 빈틈없이 +1.", + "S2": "terminal(run.completed|run.failed|run.canceled)은 정확히 하나이며 마지막 frame.", + "S3": "run.canceling은 최대 1회이고 run.canceled 앞. 그 뒤에는 activity.completed|failed|canceled와 run.canceled만.", + "S4": "response.page.*는 completed run에만, 모든 page의 started→completed 쌍이 pageIndex 0..n-1 순서로 run.completed 바로 앞에 연속하고 ID·presentation·title이 run.completed pages와 일치.", + "S5": "모든 frame runId가 같고 모든 assistantMessageId가 run.created의 값과 같다.", + "S6": "activity 후속 이벤트는 같은 activityId·kind의 앞선 activity.started를 가리키고 activity terminal 뒤에는 그 activity의 이벤트가 없다.", + "S7": "각 frame은 BrowserFrame schema를 통과한다(내부 필드 금지·Server 소유 payload).", +} + + +# -------------------------------------------------------------------------- +# cursors +# -------------------------------------------------------------------------- + + +def cursor_cases(valid_streams: dict[str, dict[str, Any]]) -> dict[str, Any]: + terminal_last = len(valid_streams["substantive-completed"]["frames"]) + active_last = ACTIVE_LAST_SEQ + ok = lambda seq, live: {"http": 200, "replayFromSeq": seq, "thenLive": live} # noqa: E731 + err = lambda code: {"http": ERRORS[code][0], "code": code} # noqa: E731 + cases = [ + ("no-cursor", "terminal", None, None, ok(1, False)), + ("no-cursor-active", "active", None, None, ok(1, True)), + ("valid-middle", "terminal", f"{RUN_ID}:3", None, ok(4, False)), + ("valid-active", "active", f"{ACTIVE_RUN_ID}:2", None, ok(3, True)), + ("valid-query", "active", None, f"{ACTIVE_RUN_ID}:2", ok(3, True)), + ( + "header-and-query-equal", + "active", + f"{ACTIVE_RUN_ID}:2", + f"{ACTIVE_RUN_ID}:2", + ok(3, True), + ), + ( + "last-seq-terminal", + "terminal", + f"{RUN_ID}:{terminal_last}", + None, + {**ok(terminal_last + 1, False), "frameCount": 0}, + ), + ( + "last-seq-active", + "active", + f"{ACTIVE_RUN_ID}:{active_last}", + None, + {**ok(active_last + 1, True), "frameCount": 0}, + ), + ("uppercase-uuid", "terminal", f"{RUN_ID.upper()}:3", None, err("RUN4003")), + ("seq-zero", "terminal", f"{RUN_ID}:0", None, err("RUN4003")), + ("seq-leading-zero", "terminal", f"{RUN_ID}:03", None, err("RUN4003")), + ("seq-negative", "terminal", f"{RUN_ID}:-1", None, err("RUN4003")), + ("seq-over-2-53", "terminal", f"{RUN_ID}:{MAX_SEQ + 1}", None, err("RUN4003")), + ( + "seq-16-digit-max", + "terminal", + f"{RUN_ID}:9999999999999999", + None, + err("RUN4003"), + ), + ( + "seq-at-max-beyond-last", + "terminal", + f"{RUN_ID}:{MAX_SEQ}", + None, + err("RUN4003"), + ), + ("trailing-newline", "terminal", f"{RUN_ID}:3\n", None, err("RUN4003")), + ("no-separator", "terminal", RUN_ID, None, err("RUN4003")), + ("surrounding-space", "terminal", f" {RUN_ID}:3", None, err("RUN4003")), + ("seq-only", "terminal", "3", None, err("RUN4003")), + ( + "beyond-last-seq", + "terminal", + f"{RUN_ID}:{terminal_last + 1}", + None, + err("RUN4003"), + ), + ( + "header-query-mismatch", + "active", + f"{ACTIVE_RUN_ID}:2", + f"{ACTIVE_RUN_ID}:3", + err("RUN4003"), + ), + ("other-run", "terminal", f"{OTHER_RUN_ID}:3", None, err("RUN4004")), + ("other-run-query", "active", None, f"{OTHER_RUN_ID}:1", err("RUN4004")), + ] + access_cases = [ + ("run-missing", "missing", err("RUN4041")), + ("run-other-user", "other_user", err("RUN4041")), + ] + # Cursor values that fail the EventId schema itself (format, not range). + schema_rejects = { + "uppercase-uuid", + "seq-zero", + "seq-leading-zero", + "seq-negative", + "seq-over-2-53", + "seq-16-digit-max", + "no-separator", + "surrounding-space", + "seq-only", + "trailing-newline", + } + return { + "runs": { + "terminal": { + "runId": RUN_ID, + "status": "COMPLETED", + "lastDurableSeq": terminal_last, + "stream": "substantive-completed", + }, + "active": { + "runId": ACTIVE_RUN_ID, + "status": "RUNNING", + "lastDurableSeq": active_last, + }, + }, + "thenLiveRule": "thenLive is true exactly when runs..status is not " + "terminal (COMPLETED|FAILED|CANCELED).", + "cases": [ + { + "name": name, + "run": run, + "lastEventIdHeader": header, + "lastEventIdQuery": query, + "schemaRejects": name in schema_rejects, + "expected": expected, + } + for name, run, header, query, expected in cases + ], + "accessCases": [ + { + "name": name, + "access": access, + "lastEventIdHeader": f"{RUN_ID}:3", + "expected": expected, + } + for name, access, expected in access_cases + ], + "precedence": [ + "authenticated", + "run access (RUN4041)", + "cursor format/range (RUN4003)", + "cursor run (RUN4004)", + ], + } + + +def cancel_cases() -> dict[str, Any]: + ok = lambda outcome, status: { # noqa: E731 + "http": 200, + "fixture": f"responses/valid/cancel-{outcome}", + "status": status, + } + err = lambda code: {"http": ERRORS[code][0], "code": code} # noqa: E731 + cases = [ + ( + "no-body", + "body 없음 — 정상 취소.", + None, + "running", + ok("accepted-running", "CANCELING"), + ), + ( + "empty-object-body", + "cancel은 body가 없다 — `{}`도 closed 위반.", + "{}", + "running", + err("RUN4001"), + ), + ( + "reason-body", + "취소 사유 등 어떤 필드도 받지 않는다(무시하지 않는다).", + '{"reason":"user"}', + "running", + err("RUN4001"), + ), + ( + "repeat", + "멱등 — 이미 CANCELING이면 already_canceling.", + None, + "canceling", + ok("already-canceling", "CANCELING"), + ), + ( + "terminal", + "terminal run은 already_terminal.", + None, + "completed", + ok("already-terminal", "COMPLETED"), + ), + ( + "pending", + "PENDING은 즉시 CANCELED(run.canceling 없이 run.canceled).", + None, + "pending", + ok("accepted-pending", "CANCELED"), + ), + ("run-missing", "없는 run.", None, "missing", err("RUN4041")), + ( + "run-other-user", + "타인 run — 없는 run과 같은 404 body(존재 비노출).", + None, + "other_user", + err("RUN4041"), + ), + ( + "body-before-run-lookup", + "body 구조 검사가 run 조회보다 먼저(admission과 같은 원칙).", + "{}", + "missing", + err("RUN4001"), + ), + ] + return { + "path": "POST /api/ai-runs/{runId}/cancel", + "precedence": [ + "authenticated", + "body absent (RUN4001)", + "run access (RUN4041)", + "outcome", + ], + "cases": [ + { + "name": name, + "rationale": rationale, + "rawBody": body, + "run": run, + "expected": expected, + } + for name, rationale, body, run, expected in cases + ], + } + + +def heartbeat_invalid() -> dict[str, Any]: + cases = [ + ( + "heartbeat-with-id", + "heartbeat는 id가 없다 — cursor를 전진시키면 안 된다.", + f": heartbeat\nid: {RUN_ID}:3\n\n", + ), + ( + "heartbeat-with-data", + "heartbeat는 data가 없다 — frame으로 저장·replay되지 않는다.", + ": heartbeat\ndata: {}\n\n", + ), + ( + "heartbeat-as-event", + "heartbeat는 event가 아니라 comment다.", + "event: heartbeat\ndata: {}\n\n", + ), + ] + return { + "valid": HEARTBEAT, + "cases": [ + {"name": name, "rationale": rationale, "sse": text} + for name, rationale, text in cases + ], + } + + +# -------------------------------------------------------------------------- +# HTTP responses +# -------------------------------------------------------------------------- + + +def _ok(result: dict[str, Any]) -> dict[str, Any]: + return { + "isSuccess": True, + "code": "COMMON200", + "message": SUCCESS_MESSAGE, + "result": result, + } + + +def _status( + status: str, last_seq: int, ids: dict[str, str] | None = None, **extra: Any +) -> dict[str, Any]: + ids = ids or scenario_ids("primary") + body = { + "runId": ids["runId"], + "requestId": ids["requestId"], + "noteId": NOTE_ID, + "userMessageId": ids["userMessageId"], + "assistantMessageId": ids["assistantMessageId"], + "status": status, + "cancelable": status in ("PENDING", "RUNNING"), + "lastSeq": last_seq, + "responseDisposition": None, + "failure": None, + "createdAt": iso(START), + "completedAt": None, + } + body.update(extra) + return body + + +def responses( + valid_streams: dict[str, dict[str, Any]], +) -> tuple[dict[str, Any], dict[str, Any]]: + done = iso(START + timedelta(seconds=30)) + completed = valid_streams["substantive-with-source"]["frames"][-1]["payload"] + clarification = valid_streams["clarification-completed"]["frames"][-1]["payload"] + failed = valid_streams["failed-verification"]["frames"][-1]["payload"] + + def last_seq(stream: str) -> int: + return len(valid_streams[stream]["frames"]) + + def ids(stream: str) -> dict[str, str]: + frames = valid_streams[stream]["frames"] + created = frames[0]["payload"] + return { + "runId": frames[0]["runId"], + "requestId": created["requestId"], + "userMessageId": created["userMessageId"], + "assistantMessageId": created["assistantMessageId"], + } + + source = ids("substantive-with-source") + pending = scenario_ids("pending") + canceling = scenario_ids("canceling") + + def at(seconds: int) -> str: + return iso(START + timedelta(seconds=seconds)) + + def user_message( + message_id: str, run_id: str, text: str, created: str + ) -> dict[str, Any]: + return { + "messageId": message_id, + "role": "user", + "status": "COMPLETED", + "runId": run_id, + "createdAt": created, + "user": { + "text": text, + "latex": None, + "requestedTasks": ["solve"], + "requestedOutputs": ["text"], + "turnAttachments": [ + { + "attachmentId": ATTACHMENT_ID, + "mime": "image/png", + "displayName": "문제.png", + } + ], + "viewerFocus": { + "noteAssetId": NOTE_ASSET_ID, + "page": 3, + "selection": None, + }, + }, + } + + def assistant( + status: str, + run_id: str, + message_id: str, + created: str = iso(START + timedelta(seconds=1)), + **content: Any, + ) -> dict[str, Any]: + body = { + "responseDisposition": None, + "clarificationReason": None, + "assistantResponse": None, + "verificationSummary": [], + "failure": None, + "cancel": None, + } + body.update(content) + return { + "messageId": message_id, + "role": "assistant", + "status": status, + "runId": run_id, + "createdAt": created, + "assistant": body, + } + + completed_assistant = assistant( + "COMPLETED", + source["runId"], + source["assistantMessageId"], + responseDisposition=completed["responseDisposition"], + clarificationReason=completed.get("clarificationReason"), + assistantResponse=completed["assistantResponse"], + verificationSummary=completed.get("verificationSummary", []), + ) + # Terminal status responses describe the run of a streams/valid fixture: + # lastSeq is that stream's length (see test_terminal_status_matches_stream). + status_streams = { + "status-completed-clarification": "clarification-completed", + "status-completed-substantive": "substantive-completed", + "status-failed": "failed-verification", + "status-failed-invalid-attachment": "failed-invalid-attachment", + "status-canceled": "canceled-pending", + } + valid = { + "status-pending": _ok(_status("PENDING", 1, pending)), + "status-running": _ok(_status("RUNNING", ACTIVE_LAST_SEQ, ACTIVE)), + "status-canceling": _ok(_status("CANCELING", 9, canceling)), + "status-completed-clarification": _ok( + _status( + "COMPLETED", + last_seq("clarification-completed"), + ids("clarification-completed"), + responseDisposition="clarification_only", + completedAt=done, + ) + ), + "status-completed-substantive": _ok( + _status( + "COMPLETED", + last_seq("substantive-completed"), + ids("substantive-completed"), + responseDisposition="substantive", + completedAt=done, + ) + ), + "status-failed": _ok( + _status( + "FAILED", + last_seq("failed-verification"), + ids("failed-verification"), + failure={ + "code": failed["code"], + "reason": None, + "recoverable": failed["recoverable"], + }, + completedAt=done, + ) + ), + "status-failed-invalid-attachment": _ok( + _status( + "FAILED", + last_seq("failed-invalid-attachment"), + ids("failed-invalid-attachment"), + failure={ + "code": "invalid_attachment", + "reason": valid_streams["failed-invalid-attachment"]["frames"][-1][ + "payload" + ]["reason"], + "recoverable": False, + }, + completedAt=done, + ) + ), + "status-canceled": _ok( + _status( + "CANCELED", + last_seq("canceled-pending"), + ids("canceled-pending"), + completedAt=done, + ) + ), + "cancel-accepted-pending": _ok( + {"runId": pending["runId"], "status": "CANCELED", "outcome": "accepted"} + ), + "cancel-accepted-running": _ok( + {"runId": ACTIVE_RUN_ID, "status": "CANCELING", "outcome": "accepted"} + ), + "cancel-already-canceling": _ok( + { + "runId": canceling["runId"], + "status": "CANCELING", + "outcome": "already_canceling", + } + ), + "cancel-already-terminal": _ok( + {"runId": RUN_ID, "status": "COMPLETED", "outcome": "already_terminal"} + ), + "conversation-completed": _ok( + { + "noteId": NOTE_ID, + "activeRun": None, + "messages": [ + user_message( + source["userMessageId"], + source["runId"], + "이 문제를 풀어줘", + at(0), + ), + completed_assistant, + ], + "nextBefore": None, + } + ), + "conversation-active-run": _ok( + { + "noteId": NOTE_ID, + "activeRun": { + "runId": ACTIVE_RUN_ID, + "status": "RUNNING", + "lastSeq": ACTIVE_LAST_SEQ, + }, + "messages": [ + user_message( + source["userMessageId"], + source["runId"], + "이 문제를 풀어줘", + at(0), + ), + completed_assistant, + user_message( + ACTIVE["userMessageId"], + ACTIVE_RUN_ID, + "다른 방법으로도 풀어줘", + at(60), + ), + assistant( + "STREAMING", + ACTIVE_RUN_ID, + ACTIVE["assistantMessageId"], + at(61), + ), + ], + "nextBefore": fixed_uuid("message/older"), + } + ), + "conversation-clarification-failed-canceled": _ok( + { + "noteId": NOTE_ID, + "activeRun": None, + "messages": [ + user_message( + ids("clarification-completed")["userMessageId"], + ids("clarification-completed")["runId"], + "이거 풀어줘", + at(0), + ), + assistant( + "COMPLETED", + ids("clarification-completed")["runId"], + ids("clarification-completed")["assistantMessageId"], + at(1), + responseDisposition="clarification_only", + clarificationReason=clarification["clarificationReason"], + assistantResponse=clarification["assistantResponse"], + ), + user_message( + ids("failed-verification")["userMessageId"], + ids("failed-verification")["runId"], + "첨부한 문제를 풀어줘", + at(60), + ), + assistant( + "FAILED", + ids("failed-verification")["runId"], + ids("failed-verification")["assistantMessageId"], + at(61), + failure={ + "code": failed["code"], + "reason": None, + "message": failed["message"], + "recoverable": failed["recoverable"], + }, + ), + user_message( + ids("canceled-pending")["userMessageId"], + ids("canceled-pending")["runId"], + "다시 풀어줘", + at(120), + ), + assistant( + "CANCELED", + ids("canceled-pending")["runId"], + ids("canceled-pending")["assistantMessageId"], + at(121), + cancel={ + "cause": "user_requested", + "message": CANCEL_MESSAGES["user_requested"], + }, + ), + ], + "nextBefore": None, + } + ), + } + schema_names = { + "status": "RunStatusResponse", + "cancel": "RunCancelResponse", + "conversation": "ConversationPageResponse", + } + valid_files: dict[str, Any] = { + name: {"schema": schema_names[name.split("-")[0]], "http": 200, "body": body} + for name, body in valid.items() + } + for name, stream in status_streams.items(): + valid_files[name]["stream"] = stream + for code, (http, message) in ERRORS.items(): + body: dict[str, Any] = {"isSuccess": False, "code": code, "message": message} + if code == "RUN4091": + body["result"] = {"activeRunId": ACTIVE_RUN_ID} + valid_files[f"error-{code.lower()}"] = { + "schema": "ErrorResponse", + "http": http, + "body": body, + } + + invalid = { + "status-retrying": ( + "RunStatusResponse", + "내부 RETRYING은 RUNNING으로 투영해야 한다.", + _ok(_status("RETRYING", 7)), + ), + "status-completed-without-disposition": ( + "RunStatusResponse", + "COMPLETED는 responseDisposition 필수.", + _ok(_status("COMPLETED", 6, completedAt=done)), + ), + "status-running-not-cancelable": ( + "RunStatusResponse", + "RUNNING은 cancelable=true.", + _ok(_status("RUNNING", 7, cancelable=False)), + ), + "status-failed-without-failure": ( + "RunStatusResponse", + "FAILED는 failure 필수.", + _ok(_status("FAILED", 8, completedAt=done)), + ), + "cancel-outcome-mismatch": ( + "RunCancelResponse", + "already_terminal인데 CANCELING.", + _ok( + {"runId": RUN_ID, "status": "CANCELING", "outcome": "already_terminal"} + ), + ), + "error-active-run-without-result": ( + "ErrorResponse", + "RUN4091은 activeRunId 필수.", + {"isSuccess": False, "code": "RUN4091", "message": ERRORS["RUN4091"][1]}, + ), + "error-result-on-request-id-conflict": ( + "ErrorResponse", + "RUN4092는 기존 run 정보를 노출하지 않는다.", + { + "isSuccess": False, + "code": "RUN4092", + "message": ERRORS["RUN4092"][1], + "result": {"activeRunId": ACTIVE_RUN_ID}, + }, + ), + "error-message-mismatch": ( + "ErrorResponse", + "code별 고정 message와 다름.", + { + "isSuccess": False, + "code": "RUN4041", + "message": "노트 123은 다른 사용자의 노트입니다.", + }, + ), + "conversation-completed-without-response": ( + "ConversationPageResponse", + "COMPLETED assistant는 assistantResponse 필수.", + _ok( + { + "noteId": NOTE_ID, + "activeRun": None, + "messages": [assistant("COMPLETED", RUN_ID, ASSISTANT_MESSAGE_ID)], + "nextBefore": None, + } + ), + ), + "conversation-streaming-with-response": ( + "ConversationPageResponse", + "STREAMING placeholder는 본문이 없다(Candidate 비공개).", + _ok( + { + "noteId": NOTE_ID, + "activeRun": {"runId": RUN_ID, "status": "RUNNING", "lastSeq": 4}, + "messages": [ + assistant( + "STREAMING", + RUN_ID, + ASSISTANT_MESSAGE_ID, + assistantResponse=completed["assistantResponse"], + ) + ], + "nextBefore": None, + } + ), + ), + "status-with-usage": ( + "RunStatusResponse", + "상태 응답에 내부 usage — 응답도 additive-tolerant이지만 내부 키는 금지.", + _ok(_status("RUNNING", 7, usage={"inputTokens": 10})), + ), + "status-with-attempt": ( + "RunStatusResponse", + "상태 응답에 attempt.", + _ok(_status("RUNNING", 7, attempt=2)), + ), + "cancel-with-fence-token": ( + "RunCancelResponse", + "취소 응답에 fenceToken.", + _ok( + { + "runId": RUN_ID, + "status": "CANCELING", + "outcome": "accepted", + "fenceToken": 3, + } + ), + ), + "conversation-failure-class": ( + "ConversationPageResponse", + "assistant failure에 failureClass.", + _ok( + { + "noteId": NOTE_ID, + "activeRun": None, + "messages": [ + assistant( + "FAILED", + RUN_ID, + ASSISTANT_MESSAGE_ID, + failure={ + "code": failed["code"], + "reason": None, + "message": failed["message"], + "recoverable": failed["recoverable"], + "failureClass": "provider_timeout", + }, + ) + ], + "nextBefore": None, + } + ), + ), + "conversation-active-run-deadline": ( + "ConversationPageResponse", + "activeRun에 deadline.", + _ok( + { + "noteId": NOTE_ID, + "activeRun": { + "runId": RUN_ID, + "status": "RUNNING", + "lastSeq": 4, + "deadline": iso(START), + }, + "messages": [], + "nextBefore": None, + } + ), + ), + "conversation-active-run-terminal-status": ( + "ConversationPageResponse", + "activeRun은 PENDING|RUNNING|CANCELING만.", + _ok( + { + "noteId": NOTE_ID, + "activeRun": {"runId": RUN_ID, "status": "COMPLETED", "lastSeq": 4}, + "messages": [], + "nextBefore": None, + } + ), + ), + } + invalid_files = { + name: {"schema": schema_name, "rationale": rationale, "body": body} + for name, (schema_name, rationale, body) in invalid.items() + } + return valid_files, invalid_files + + +HEARTBEAT = ": heartbeat\n\n" + + +# -------------------------------------------------------------------------- +# write +# -------------------------------------------------------------------------- + + +def _dump(value: Any) -> str: + return json.dumps(value, ensure_ascii=False, indent=2) + "\n" + + +def generated_files() -> dict[str, str]: + """Every generated file (relative to ROOT) except manifest.json.""" + + files: dict[str, str] = {"schemas/browser-run.schema.json": _dump(schema())} + for name, value in valid_requests().items(): + files[f"requests/valid/{name}.json"] = _dump(value) + for name, value in invalid_requests().items(): + files[f"requests/invalid/{name}.json"] = _dump(value) + for name, value in fingerprint_cases().items(): + files[f"fingerprint/{name}.json"] = _dump(value) + files["admission/decisions.json"] = _dump(admission_decisions()) + valid = streams() + for name, value in valid.items(): + files[f"streams/valid/{name}.json"] = _dump(value) + for name, value in invalid_streams(valid).items(): + files[f"streams/invalid/{name}.json"] = _dump(value) + files["cursors/cases.json"] = _dump(cursor_cases(valid)) + valid_responses, invalid_responses = responses(valid) + for name, value in valid_responses.items(): + files[f"responses/valid/{name}.json"] = _dump(value) + for name, value in invalid_responses.items(): + files[f"responses/invalid/{name}.json"] = _dump(value) + files["cancel/cases.json"] = _dump(cancel_cases()) + files["heartbeat.txt"] = HEARTBEAT + files["heartbeat-invalid.json"] = _dump(heartbeat_invalid()) + files["CATALOG.md"] = catalog(files) + return files + + +# -------------------------------------------------------------------------- +# catalog (generated — the tables come from the same data as the fixtures) +# -------------------------------------------------------------------------- + +CATALOG_HEAD = """\ +# browser-run-v1 fixture catalog + +> 이 파일은 `build_fixtures.py`가 생성한다 — 직접 편집하지 않는다. + +브라우저 run API(접수·재조회·SSE replay·상태·취소·대화 조회)의 wire 계약 artifact다 +(task 1.75, Team-Proovy/Proovy-server#214). **계약 의미는 Proovy-server가 소유**하고 +Brain은 versioned artifact만 보관한다 — `src/`는 이 디렉터리를 import하지 않으며 +제품 API·제품 DB 소유권을 Brain으로 가져오지 않는다. 평가 정답·oracle과 무관하다. + +## 버전 + +- `consumer_version`: `browser-run-fixtures-v1` (2026-10-17 최초 고정, 단계 2 합의 D1~D10) +- `contract_version`: `browser-run-v1` +- durable frame `contractVersion`: `brain-source-v1` (D1 — terminal-v2 browser schema의 `const`와 같은 값을 Server 소유 frame에도 사용) +- 참조 계약: terminal-fixtures-v2 · activity-fixtures-v1 · assistant-response-v1 · internal-run-fixtures-v2(canonical JSON) · page-selection-v1 +- 요청 fingerprint: `browser-request-sha256-canonical-json-v1` + +## 소비 규칙 + +- 소비자(Server Java DTO 검사, Front reducer/MSW)는 **merge된 proovy-brain commit + `consumer_version`**을 pin한다. +- 사본은 검증된 mirror로만 허용한다: `manifest.json`의 `files`(이 디렉터리 전체)와 `dependencies` + (`terminal-v2/browser.schema.json`)의 sha256이 모두 일치해야 하고, 소비자는 pin에 manifest 자체의 + sha256도 기록한다. 사본을 손으로 고치지 않는다 — SSOT는 Brain이다. +- 변경: 의미가 바뀌면 새 `consumer_version`(새 디렉터리 또는 버전업)과 `change_history` 항목을 남긴다. + 이 CATALOG를 포함한 모든 파일은 `build_fixtures.py`가 생성한다 — builder를 고친 뒤 + `uv run python tests/fixtures/contracts/browser-run-v1/build_fixtures.py`로 재생성한다. + `tests/contracts/test_browser_run_fixtures.py`가 builder 출력·digest 불일치에서 실패한다. +- schema 검증은 `manifest.schema_registry`처럼 `https://proovy.invalid/contracts/terminal-v2/browser.schema.json`을 + `terminal-v2/browser.schema.json`에 매핑한 registry로 한다(`$ref: ../../terminal-v2/browser.schema.json`). +- 요청 body는 **closed**(알 수 없는/금지 필드 = `RUN4001`). 응답·이벤트는 additive-tolerant(알 수 없는 추가 필드 무시, + 알려진 필드 검증)이지만, 내부 키(`attempt`·`fenceToken`·`sourceEventId`·`failureClass`·`requestFingerprint`·`usage`· + `deadline`과 snake_case 형)는 frame·payload와 Server 소유 응답 객체 모두에서 schema가 거부한다. + +## 경로와 규칙 요약 + +| Method · Path | 성공 | 주요 오류 | +|---|---|---| +| `POST /api/conversations` (body `ConversationRunRequest`, `Accept: text/event-stream`) | 200 SSE seq 1 `run.created`부터. 같은 requestId·같은 사용자·같은 의미는 기존 run을 seq 1부터 replay | 401 · 400 `RUN4001`/`RUN4002` · 409 `RUN4092` · 404 `RUN4041` · 422 `RUN4221` · 409 `RUN4091{activeRunId}` · 429 `RUN4291`(+`Retry-After`) · 402 `RUN4021` | +| `GET /api/ai-runs/{runId}/events` (`Last-Event-ID` header 또는 `lastEventId` query) | 200 SSE: cursor 없으면 seq 1부터, 있으면 seq+1부터 replay 후 live | 404 `RUN4041` · 400 `RUN4003`(형식·범위·header/query 불일치) · 400 `RUN4004`(다른 run) | +| `GET /api/ai-runs/{runId}` | `RunStatusResponse` | 404 `RUN4041` | +| `POST /api/ai-runs/{runId}/cancel` (body 없음, 멱등) | `RunCancelResponse` (accepted·already_canceling·already_terminal) | 400 `RUN4001`(body 있음) · 404 `RUN4041` | +| `GET /api/conversations/{noteId}/messages?before&limit` | `ConversationPageResponse` | 404 `RUN4041` | + +- 판정 순서: `admission/decisions.json`·`cancel/cases.json`·`cursors/cases.json`의 `precedence`. 같은 의미 재전송(replay)은 + Note·Active Run·rate limit·잔액보다 먼저다. 구조 검사는 대상 조회보다 먼저다. +- `RUN4092`·`RUN4041`은 다른 사용자/다른 의미, 없음/타인/삭제 중을 구분하지 않는 동일 body다(존재 oracle 방지). +- 스트림 시작 전 오류만 JSON `ApiResponse`(`isSuccess/code/message/result`), 시작 후에는 terminal 이벤트로 끝난다. code별 message는 고정이다. +- 공개 run status는 `PENDING|RUNNING|CANCELING|COMPLETED|FAILED|CANCELED` — 내부 `RETRYING`은 `RUNNING`으로 투영한다. +- 브라우저가 결정하지 못하는 값(scope·usage·attempt·fence·history·참조·corpus·conversationId·authToken·locale 등)은 요청 필드가 없다. +- 상태 응답 `lastSeq`는 그 시점 마지막 durable seq다. terminal 상태 fixture는 `stream`에 적은 `streams/valid` fixture와 같은 run이고 + `lastSeq`가 그 stream의 frame 수와 같다(그대로 cursor로 보내면 0 frame 후 종료). +- heartbeat는 15초마다 `: heartbeat` comment(id·data·event·seq·저장 없음, `heartbeat.txt`; 위반 예제 `heartbeat-invalid.json`). +- **runId = 시나리오.** 같은 run을 설명하는 fixture(stream·상태·취소·대화 조회·cursor `runs`)는 runId·requestId· + userMessageId·assistantMessageId를 공유하고, 다른 시나리오는 다른 runId를 쓴다. 따라서 소비자가 valid fixture를 runId로 + 교차 색인해도 충돌하지 않는다(`test_fixtures_describing_one_run_agree_by_run_id`). 진행 중 run은 시점 snapshot이라 + non-terminal 상태가 서로 다를 수 있다(예: `status-running` RUNNING → `cancel-accepted-running` CANCELING). invalid fixture는 색인 대상이 아니다. +- stream 규칙 S1~S7: `manifest.stream_rules`. replay와 live의 유일한 허용 차이는 read-time `sourceReferences[].available`. + +## 형식·경계 판정 + +- **패턴은 입력 전체와 일치해야 한다.** schema 패턴은 `$` 대신 절대 끝 `(?![\\s\\S])`로 끝난다 — Python `re`·Java의 `$`는 + 끝 줄바꿈 하나 앞에서도 매치되므로 `"\\n"`을 통과시킨다. 소비자 구현은 full match(Java `matches()`, Python `fullmatch`)로 한다. +- ID: `requestId`·`runId`·`messageId`·`attachmentId`는 소문자 canonical UUID. `noteId`는 선행 0 없는 10진 문자열 1..9223372036854775807 + (Java `Long.MAX_VALUE`, 초과는 404가 아니라 `RUN4001`). `noteAssetId`는 opaque `[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}`. +- event id/cursor: `{runId}:{seq}` — 소문자 UUID, seq 1..9007199254740991(2^53-1), 선행 0 없음. 상한은 schema 패턴 자체에 들어 있다. + `cursors/cases.json`의 `schemaRejects`가 true인 값은 `EventId` schema가 거부하고, false인 400은 범위(마지막 seq 초과)·header/query 불일치다. + `thenLive`는 `runs..status`가 terminal이 아닐 때만 true다(`thenLiveRule`). +- **길이(D6)**: `text`·`latex` 각각 **strip 후 1~10,000 code point**(합의 초안 §3.2 문구). 원문 기준이 아니다 — 양끝 공백이 붙어 원문이 + 10,000을 넘어도 strip 후 10,000이면 접수한다(`text-at-limit-surrounded-by-blanks`). schema는 비어 있지 않음·공백만이 아님만 검사하고 + 상한은 계약 검사(`RUN4001`, `schema_rejects: false`)다. 단위는 code point다 — Java `String.length()`·JS `.length`(UTF-16 단위)로 + 세면 astral 문자 fixture(`text-at-limit-astral`·`text-too-long-astral`)에서 틀린다. +- **strip 집합**: internal-run-v1과 같은 Python `str.strip()` 집합 — `\\t \\n \\v \\f \\r U+001C~U+001F space U+0085 U+00A0 U+1680 + U+2000~U+200A U+2028 U+2029 U+202F U+205F U+3000`. Java `String.strip()`/`trim()`은 U+00A0·U+202F 등을 남기므로 이 집합을 직접 구현한다 + (`fingerprint/same-non-java-whitespace`). +- **request fingerprint**: `requestId` 제외, `text`·`latex` strip, 비순서 배열 정렬, `[]`·`null`은 부재와 같음, `noteId` 부재/존재는 다름 + → internal-run-v1 `canonical_json` → sha256. **fingerprint 전에 기본값을 채우지 않는다** — `requestedOutputs: []`(Brain 추론)를 + `["text"]`로 바꿔 정규화하면 안 된다(`fingerprint/different-outputs-explicit-vs-inferred`). +- `turnAttachmentIds` wire 상한 10(실제 개수·용량은 Server policy → `RUN4221`). + +## 기존 계약과의 일관성 + +- `streams/`의 activity frame은 activity-v1 source fixture를 Brain `to_browser_event_payload`로 변환한 값이고, + `run.completed`/`run.failed` payload는 terminal-v2 `pairs/*.json`의 browser payload와 `assistantMessageId`만 다르다(테스트가 byte 비교). + terminal-v2 pair는 publication-v1 valid에서 조립되므로 clarification/unsupported_only/substantive의 공개 구조도 같다. +- 대화 조회의 `assistantResponse`는 terminal-v2 `AssistantResponseV1`을 그대로 `$ref`한다. 대화 조회 예제는 메시지가 `createdAt` + 오름차순이고 assistant마다 run이 다르며, 각 assistant 앞에 같은 run의 user 메시지가 있다. +- 기존 fixture의 의미·파일은 바꾸지 않았다. 참고: terminal-v2 browser schema의 opaque ID 패턴은 `$`로 끝난다(이 artifact 밖, + terminal-v2 변경 대상). + +## 합의 초안 §11.1 예제와의 대응 + +- `replay-from-cursor` → `cursors/cases.json`(`valid-middle`·`valid-active` 등 — stream 사본을 두지 않는다). +- `retrying-projected-running` → `responses/invalid/status-retrying`(RETRYING 노출 거부)과 `status-running`. +- `events/invalid` → `streams/invalid`. `heartbeat-with-id` → `heartbeat-invalid.json`(frame이 아니라 SSE comment라 따로 둔다). +- cancel body 있음 → `cancel/cases.json`(`empty-object-body`·`reason-body`, 400 `RUN4001`). + +## 파일 +""" + + +def _cell(text: Any) -> str: + return str(text).replace("|", "\\|").replace("\n", "\\n") + + +def _table(header: list[str], rows: list[list[Any]]) -> str: + lines = ["| " + " | ".join(header) + " |", "|" + "---|" * len(header)] + lines += ["| " + " | ".join(_cell(cell) for cell in row) + " |" for row in rows] + return "\n".join(lines) + "\n" + + +def _code(value: Any) -> str: + return f"`{value}`" + + +def catalog(files: dict[str, str]) -> str: + def group(prefix: str) -> list[tuple[str, Any]]: + return [ + (path[len(prefix) : -len(".json")], json.loads(text)) + for path, text in sorted(files.items()) + if path.startswith(prefix) and path.endswith(".json") + ] + + def expected(value: dict[str, Any]) -> str: + if value.get("code"): + return f"{value['http']} {value['code']}" + return f"{value['http']} {value.get('outcome', 'OK')}" + + def rejects(value: bool, why: str) -> str: + return "예" if value else f"아니오({why})" + + def cursor(raw: str | None, run_id: str) -> str: + return "–" if raw is None else _code(raw.replace(run_id, "{runId}")) + + sections = [CATALOG_HEAD] + sections.append("### requests/valid\n\n") + sections.append( + _table( + ["파일", "판정 근거"], + [[_code(n), c["rationale"]] for n, c in group("requests/valid/")], + ) + ) + sections.append("\n### requests/invalid\n\n") + sections.append( + _table( + ["파일", "기대", "schema 거부", "근거"], + [ + [ + _code(n), + f"{c['expected']['http']} `{c['expected']['code']}`", + rejects(c["schema_rejects"], "계약 검사"), + c["rationale"], + ] + for n, c in group("requests/invalid/") + ], + ) + ) + sections.append("\n### fingerprint\n\n") + sections.append( + _table( + ["파일", "관계", "근거"], + [ + [_code(n), c["relation"], c["rationale"]] + for n, c in group("fingerprint/") + ], + ) + ) + decisions = json.loads(files["admission/decisions.json"]) + sections.append("\n### admission/decisions.json cases\n\n") + sections.append( + _table( + ["case", "기대", "근거"], + [ + [_code(c["name"]), expected(c["expected"]), c["rationale"]] + for c in decisions["cases"] + ], + ) + ) + sections.append("\n### streams/valid\n\n") + sections.append( + _table( + ["파일", "최종 상태", "frame 수", "내용"], + [ + [_code(n), c["finalStatus"], len(c["frames"]), c["rationale"]] + for n, c in group("streams/valid/") + ], + ) + ) + sections.append("\n### streams/invalid\n\n") + sections.append( + _table( + ["파일", "위반 규칙", "schema 거부", "근거"], + [ + [ + _code(n), + c["violates"], + rejects(c["schema_rejects"], "순서 규칙"), + c["rationale"], + ] + for n, c in group("streams/invalid/") + ], + ) + ) + cursors = json.loads(files["cursors/cases.json"]) + run_ids = {name: run["runId"] for name, run in cursors["runs"].items()} + sections.append("\n### cursors/cases.json\n\n") + runs = ", ".join( + f"`{name}` = `{run['runId']}` {run['status']}, " + f"lastDurableSeq {run['lastDurableSeq']}" + for name, run in cursors["runs"].items() + ) + sections.append(f"runs: {runs}\n\n") + sections.append( + _table( + ["case", "run", "header", "query", "schema 거부", "기대"], + [ + [ + _code(c["name"]), + c["run"], + cursor(c["lastEventIdHeader"], run_ids[c["run"]]), + cursor(c["lastEventIdQuery"], run_ids[c["run"]]), + "예" if c["schemaRejects"] else "아니오", + expected(c["expected"]) + if "code" in c["expected"] + else f"200 replay seq {c['expected']['replayFromSeq']}~ " + + ("+ live" if c["expected"]["thenLive"] else "후 종료") + + (" (0 frame)" if c["expected"].get("frameCount") == 0 else ""), + ] + for c in cursors["cases"] + ] + + [ + [ + _code(c["name"]), + c["access"], + cursor(c["lastEventIdHeader"], RUN_ID), + "–", + "아니오", + expected(c["expected"]), + ] + for c in cursors["accessCases"] + ], + ) + ) + cancel = json.loads(files["cancel/cases.json"]) + sections.append("\n### cancel/cases.json\n\n") + sections.append( + _table( + ["case", "body", "run", "기대", "근거"], + [ + [ + _code(c["name"]), + "–" if c["rawBody"] is None else _code(c["rawBody"]), + c["run"], + expected(c["expected"]), + c["rationale"], + ] + for c in cancel["cases"] + ], + ) + ) + sections.append("\n### responses/valid\n\n") + sections.append( + _table( + ["파일", "schema", "HTTP", "같은 run의 stream"], + [ + [ + _code(n), + _code(c["schema"]), + c["http"], + _code(c["stream"]) if "stream" in c else "–", + ] + for n, c in group("responses/valid/") + ], + ) + ) + sections.append("\n### responses/invalid\n\n") + sections.append( + _table( + ["파일", "schema", "근거"], + [ + [_code(n), _code(c["schema"]), c["rationale"]] + for n, c in group("responses/invalid/") + ], + ) + ) + heartbeat = json.loads(files["heartbeat-invalid.json"]) + sections.append("\n### heartbeat-invalid.json\n\n") + sections.append( + _table( + ["case", "근거"], + [[_code(c["name"]), c["rationale"]] for c in heartbeat["cases"]], + ) + ) + sections.append("\n### 기타\n\n") + sections.append( + _table( + ["파일", "내용"], + [ + [ + "`schemas/browser-run.schema.json`", + "모든 요청·응답·Server 소유 frame·`BrowserFrame` 정의", + ], + ["`heartbeat.txt`", "live heartbeat comment 원문"], + ["`manifest.json`", "버전·registry·limits·stream_rules·파일 digest"], + ["`build_fixtures.py`", "이 CATALOG를 포함한 모든 fixture 생성기"], + ], + ) + ) + return "".join(sections) + + +def _sha256(text: str | bytes) -> str: + data = text.encode() if isinstance(text, str) else text + return hashlib.sha256(data).hexdigest() + + +def manifest(files: dict[str, str]) -> dict[str, Any]: + builder = (ROOT / "build_fixtures.py").read_bytes() + digests = {f"browser-run-v1/{path}": _sha256(text) for path, text in files.items()} + digests["browser-run-v1/build_fixtures.py"] = _sha256(builder) + terminal_schema = (TERMINAL / "browser.schema.json").read_bytes() + return { + "consumer_version": CONSUMER_VERSION, + "contract_version": CONTRACT_VERSION, + "original_path": "tests/fixtures/contracts/browser-run-v1", + "meaning_owner": "Proovy-server (task 1.75, Team-Proovy/Proovy-server#214)", + "frame_contract_version": FRAME_CONTRACT_VERSION, + "fingerprint_algorithm": FINGERPRINT_ALGORITHM, + "references": { + "terminal": "terminal-fixtures-v2", + "activity": "activity-fixtures-v1", + "response": "assistant-response-v1", + "internal_run": "internal-run-fixtures-v2", + "page_selection": "page-selection-v1", + }, + "schema_registry": { + SCHEMA_ID: "browser-run-v1/schemas/browser-run.schema.json", + TERMINAL_SCHEMA_ID: "terminal-v2/browser.schema.json", + }, + "limits": { + "text_max_code_points": TEXT_MAX, + "latex_max_code_points": LATEX_MAX, + "turn_attachment_ids_wire_max": ATTACHMENT_WIRE_MAX, + "heartbeat_seconds": HEARTBEAT_SECONDS, + "max_seq": MAX_SEQ, + }, + "stream_rules": STREAM_RULES, + "consumption_policy": ( + "Consumers pin a merged proovy-brain commit + consumer_version. A copy " + "is allowed only as a verified mirror: every file listed in `files` " + "must match its sha256, and the pinned manifest sha256 must match. " + "Brain stays the single source of truth; changes require a new " + "consumer_version with a change_history entry." + ), + "change_history": [ + { + "date": "2026-10-17", + "consumer_version": CONSUMER_VERSION, + "reason": "Task 1.75 initial freeze of the browser run API wire contract " + "(admission/replay/status/cancel/conversation, cursor, Server-owned " + "frames) agreed at the stage-2 gate (D1-D10).", + } + ], + "files": dict(sorted(digests.items())), + "dependencies": {"terminal-v2/browser.schema.json": _sha256(terminal_schema)}, + } + + +def write_all() -> None: + files = generated_files() + for directory in ( + "schemas", + "requests", + "fingerprint", + "admission", + "streams", + "cursors", + "cancel", + "responses", + ): + target = ROOT / directory + if target.exists(): + for stale in sorted(target.rglob("*.json"), reverse=True): + stale.unlink() + for path, text in files.items(): + target = ROOT / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(text) + (ROOT / "manifest.json").write_text(_dump(manifest(files))) + + +if __name__ == "__main__": + write_all() diff --git a/src/test/resources/contracts/brain/browser-run-v1/cancel/cases.json b/src/test/resources/contracts/brain/browser-run-v1/cancel/cases.json new file mode 100644 index 00000000..f2e5a18c --- /dev/null +++ b/src/test/resources/contracts/brain/browser-run-v1/cancel/cases.json @@ -0,0 +1,105 @@ +{ + "path": "POST /api/ai-runs/{runId}/cancel", + "precedence": [ + "authenticated", + "body absent (RUN4001)", + "run access (RUN4041)", + "outcome" + ], + "cases": [ + { + "name": "no-body", + "rationale": "body 없음 — 정상 취소.", + "rawBody": null, + "run": "running", + "expected": { + "http": 200, + "fixture": "responses/valid/cancel-accepted-running", + "status": "CANCELING" + } + }, + { + "name": "empty-object-body", + "rationale": "cancel은 body가 없다 — `{}`도 closed 위반.", + "rawBody": "{}", + "run": "running", + "expected": { + "http": 400, + "code": "RUN4001" + } + }, + { + "name": "reason-body", + "rationale": "취소 사유 등 어떤 필드도 받지 않는다(무시하지 않는다).", + "rawBody": "{\"reason\":\"user\"}", + "run": "running", + "expected": { + "http": 400, + "code": "RUN4001" + } + }, + { + "name": "repeat", + "rationale": "멱등 — 이미 CANCELING이면 already_canceling.", + "rawBody": null, + "run": "canceling", + "expected": { + "http": 200, + "fixture": "responses/valid/cancel-already-canceling", + "status": "CANCELING" + } + }, + { + "name": "terminal", + "rationale": "terminal run은 already_terminal.", + "rawBody": null, + "run": "completed", + "expected": { + "http": 200, + "fixture": "responses/valid/cancel-already-terminal", + "status": "COMPLETED" + } + }, + { + "name": "pending", + "rationale": "PENDING은 즉시 CANCELED(run.canceling 없이 run.canceled).", + "rawBody": null, + "run": "pending", + "expected": { + "http": 200, + "fixture": "responses/valid/cancel-accepted-pending", + "status": "CANCELED" + } + }, + { + "name": "run-missing", + "rationale": "없는 run.", + "rawBody": null, + "run": "missing", + "expected": { + "http": 404, + "code": "RUN4041" + } + }, + { + "name": "run-other-user", + "rationale": "타인 run — 없는 run과 같은 404 body(존재 비노출).", + "rawBody": null, + "run": "other_user", + "expected": { + "http": 404, + "code": "RUN4041" + } + }, + { + "name": "body-before-run-lookup", + "rationale": "body 구조 검사가 run 조회보다 먼저(admission과 같은 원칙).", + "rawBody": "{}", + "run": "missing", + "expected": { + "http": 400, + "code": "RUN4001" + } + } + ] +} diff --git a/src/test/resources/contracts/brain/browser-run-v1/cursors/cases.json b/src/test/resources/contracts/brain/browser-run-v1/cursors/cases.json new file mode 100644 index 00000000..3ed9c2a7 --- /dev/null +++ b/src/test/resources/contracts/brain/browser-run-v1/cursors/cases.json @@ -0,0 +1,307 @@ +{ + "runs": { + "terminal": { + "runId": "5e186280-06ec-5099-8087-8de875b2b83b", + "status": "COMPLETED", + "lastDurableSeq": 14, + "stream": "substantive-completed" + }, + "active": { + "runId": "eeff258f-0aa4-5728-a27a-3c05ac927a57", + "status": "RUNNING", + "lastDurableSeq": 4 + } + }, + "thenLiveRule": "thenLive is true exactly when runs..status is not terminal (COMPLETED|FAILED|CANCELED).", + "cases": [ + { + "name": "no-cursor", + "run": "terminal", + "lastEventIdHeader": null, + "lastEventIdQuery": null, + "schemaRejects": false, + "expected": { + "http": 200, + "replayFromSeq": 1, + "thenLive": false + } + }, + { + "name": "no-cursor-active", + "run": "active", + "lastEventIdHeader": null, + "lastEventIdQuery": null, + "schemaRejects": false, + "expected": { + "http": 200, + "replayFromSeq": 1, + "thenLive": true + } + }, + { + "name": "valid-middle", + "run": "terminal", + "lastEventIdHeader": "5e186280-06ec-5099-8087-8de875b2b83b:3", + "lastEventIdQuery": null, + "schemaRejects": false, + "expected": { + "http": 200, + "replayFromSeq": 4, + "thenLive": false + } + }, + { + "name": "valid-active", + "run": "active", + "lastEventIdHeader": "eeff258f-0aa4-5728-a27a-3c05ac927a57:2", + "lastEventIdQuery": null, + "schemaRejects": false, + "expected": { + "http": 200, + "replayFromSeq": 3, + "thenLive": true + } + }, + { + "name": "valid-query", + "run": "active", + "lastEventIdHeader": null, + "lastEventIdQuery": "eeff258f-0aa4-5728-a27a-3c05ac927a57:2", + "schemaRejects": false, + "expected": { + "http": 200, + "replayFromSeq": 3, + "thenLive": true + } + }, + { + "name": "header-and-query-equal", + "run": "active", + "lastEventIdHeader": "eeff258f-0aa4-5728-a27a-3c05ac927a57:2", + "lastEventIdQuery": "eeff258f-0aa4-5728-a27a-3c05ac927a57:2", + "schemaRejects": false, + "expected": { + "http": 200, + "replayFromSeq": 3, + "thenLive": true + } + }, + { + "name": "last-seq-terminal", + "run": "terminal", + "lastEventIdHeader": "5e186280-06ec-5099-8087-8de875b2b83b:14", + "lastEventIdQuery": null, + "schemaRejects": false, + "expected": { + "http": 200, + "replayFromSeq": 15, + "thenLive": false, + "frameCount": 0 + } + }, + { + "name": "last-seq-active", + "run": "active", + "lastEventIdHeader": "eeff258f-0aa4-5728-a27a-3c05ac927a57:4", + "lastEventIdQuery": null, + "schemaRejects": false, + "expected": { + "http": 200, + "replayFromSeq": 5, + "thenLive": true, + "frameCount": 0 + } + }, + { + "name": "uppercase-uuid", + "run": "terminal", + "lastEventIdHeader": "5E186280-06EC-5099-8087-8DE875B2B83B:3", + "lastEventIdQuery": null, + "schemaRejects": true, + "expected": { + "http": 400, + "code": "RUN4003" + } + }, + { + "name": "seq-zero", + "run": "terminal", + "lastEventIdHeader": "5e186280-06ec-5099-8087-8de875b2b83b:0", + "lastEventIdQuery": null, + "schemaRejects": true, + "expected": { + "http": 400, + "code": "RUN4003" + } + }, + { + "name": "seq-leading-zero", + "run": "terminal", + "lastEventIdHeader": "5e186280-06ec-5099-8087-8de875b2b83b:03", + "lastEventIdQuery": null, + "schemaRejects": true, + "expected": { + "http": 400, + "code": "RUN4003" + } + }, + { + "name": "seq-negative", + "run": "terminal", + "lastEventIdHeader": "5e186280-06ec-5099-8087-8de875b2b83b:-1", + "lastEventIdQuery": null, + "schemaRejects": true, + "expected": { + "http": 400, + "code": "RUN4003" + } + }, + { + "name": "seq-over-2-53", + "run": "terminal", + "lastEventIdHeader": "5e186280-06ec-5099-8087-8de875b2b83b:9007199254740992", + "lastEventIdQuery": null, + "schemaRejects": true, + "expected": { + "http": 400, + "code": "RUN4003" + } + }, + { + "name": "seq-16-digit-max", + "run": "terminal", + "lastEventIdHeader": "5e186280-06ec-5099-8087-8de875b2b83b:9999999999999999", + "lastEventIdQuery": null, + "schemaRejects": true, + "expected": { + "http": 400, + "code": "RUN4003" + } + }, + { + "name": "seq-at-max-beyond-last", + "run": "terminal", + "lastEventIdHeader": "5e186280-06ec-5099-8087-8de875b2b83b:9007199254740991", + "lastEventIdQuery": null, + "schemaRejects": false, + "expected": { + "http": 400, + "code": "RUN4003" + } + }, + { + "name": "trailing-newline", + "run": "terminal", + "lastEventIdHeader": "5e186280-06ec-5099-8087-8de875b2b83b:3\n", + "lastEventIdQuery": null, + "schemaRejects": true, + "expected": { + "http": 400, + "code": "RUN4003" + } + }, + { + "name": "no-separator", + "run": "terminal", + "lastEventIdHeader": "5e186280-06ec-5099-8087-8de875b2b83b", + "lastEventIdQuery": null, + "schemaRejects": true, + "expected": { + "http": 400, + "code": "RUN4003" + } + }, + { + "name": "surrounding-space", + "run": "terminal", + "lastEventIdHeader": " 5e186280-06ec-5099-8087-8de875b2b83b:3", + "lastEventIdQuery": null, + "schemaRejects": true, + "expected": { + "http": 400, + "code": "RUN4003" + } + }, + { + "name": "seq-only", + "run": "terminal", + "lastEventIdHeader": "3", + "lastEventIdQuery": null, + "schemaRejects": true, + "expected": { + "http": 400, + "code": "RUN4003" + } + }, + { + "name": "beyond-last-seq", + "run": "terminal", + "lastEventIdHeader": "5e186280-06ec-5099-8087-8de875b2b83b:15", + "lastEventIdQuery": null, + "schemaRejects": false, + "expected": { + "http": 400, + "code": "RUN4003" + } + }, + { + "name": "header-query-mismatch", + "run": "active", + "lastEventIdHeader": "eeff258f-0aa4-5728-a27a-3c05ac927a57:2", + "lastEventIdQuery": "eeff258f-0aa4-5728-a27a-3c05ac927a57:3", + "schemaRejects": false, + "expected": { + "http": 400, + "code": "RUN4003" + } + }, + { + "name": "other-run", + "run": "terminal", + "lastEventIdHeader": "fc745f72-47d7-5710-9d85-8ec947f08e4e:3", + "lastEventIdQuery": null, + "schemaRejects": false, + "expected": { + "http": 400, + "code": "RUN4004" + } + }, + { + "name": "other-run-query", + "run": "active", + "lastEventIdHeader": null, + "lastEventIdQuery": "fc745f72-47d7-5710-9d85-8ec947f08e4e:1", + "schemaRejects": false, + "expected": { + "http": 400, + "code": "RUN4004" + } + } + ], + "accessCases": [ + { + "name": "run-missing", + "access": "missing", + "lastEventIdHeader": "5e186280-06ec-5099-8087-8de875b2b83b:3", + "expected": { + "http": 404, + "code": "RUN4041" + } + }, + { + "name": "run-other-user", + "access": "other_user", + "lastEventIdHeader": "5e186280-06ec-5099-8087-8de875b2b83b:3", + "expected": { + "http": 404, + "code": "RUN4041" + } + } + ], + "precedence": [ + "authenticated", + "run access (RUN4041)", + "cursor format/range (RUN4003)", + "cursor run (RUN4004)" + ] +} diff --git a/src/test/resources/contracts/brain/browser-run-v1/fingerprint/different-attachment.json b/src/test/resources/contracts/brain/browser-run-v1/fingerprint/different-attachment.json new file mode 100644 index 00000000..50c16dff --- /dev/null +++ b/src/test/resources/contracts/brain/browser-run-v1/fingerprint/different-attachment.json @@ -0,0 +1,61 @@ +{ + "relation": "different", + "rationale": "첨부 변경.", + "left": { + "requestId": "7f460a7b-bf54-570e-a3af-b63a26435627", + "noteId": "123", + "text": "이 문제를 풀고 검산해줘", + "latex": "\\int_0^1 x^2\\,dx", + "requestedTasks": [ + "solve", + "check" + ], + "requestedOutputs": [ + "text" + ], + "turnAttachmentIds": [ + "d99f9829-981e-5da9-a3fe-b3bed3b7439e" + ], + "viewerFocus": { + "noteAssetId": "asset-9f8c", + "page": 3, + "selection": { + "left": 0.1, + "top": 0.2, + "right": 0.6, + "bottom": 0.5 + } + } + }, + "right": { + "requestId": "7f460a7b-bf54-570e-a3af-b63a26435627", + "noteId": "123", + "text": "이 문제를 풀고 검산해줘", + "latex": "\\int_0^1 x^2\\,dx", + "requestedTasks": [ + "solve", + "check" + ], + "requestedOutputs": [ + "text" + ], + "turnAttachmentIds": [ + "d99f9829-981e-5da9-a3fe-b3bed3b7439e", + "beadbc32-ada6-589a-814d-20a3b05a9821" + ], + "viewerFocus": { + "noteAssetId": "asset-9f8c", + "page": 3, + "selection": { + "left": 0.1, + "top": 0.2, + "right": 0.6, + "bottom": 0.5 + } + } + }, + "expected_canonical_left": "{\"latex\":\"\\\\int_0^1 x^2\\\\,dx\",\"noteId\":\"123\",\"requestedOutputs\":[\"text\"],\"requestedTasks\":[\"check\",\"solve\"],\"text\":\"이 문제를 풀고 검산해줘\",\"turnAttachmentIds\":[\"d99f9829-981e-5da9-a3fe-b3bed3b7439e\"],\"viewerFocus\":{\"noteAssetId\":\"asset-9f8c\",\"page\":3,\"selection\":{\"bottom\":0.5,\"left\":0.1,\"right\":0.6,\"top\":0.2}}}", + "expected_canonical_right": "{\"latex\":\"\\\\int_0^1 x^2\\\\,dx\",\"noteId\":\"123\",\"requestedOutputs\":[\"text\"],\"requestedTasks\":[\"check\",\"solve\"],\"text\":\"이 문제를 풀고 검산해줘\",\"turnAttachmentIds\":[\"beadbc32-ada6-589a-814d-20a3b05a9821\",\"d99f9829-981e-5da9-a3fe-b3bed3b7439e\"],\"viewerFocus\":{\"noteAssetId\":\"asset-9f8c\",\"page\":3,\"selection\":{\"bottom\":0.5,\"left\":0.1,\"right\":0.6,\"top\":0.2}}}", + "expected_fingerprint_left": "7ed6e78fa8f0186b65aab8b6440ece969e79f374b6fcd5092b9bec4014f97cdc", + "expected_fingerprint_right": "415a2d868f2f15052ace6cd277a568434aeaea487eb940f49d5d87874e9f7875" +} diff --git a/src/test/resources/contracts/brain/browser-run-v1/fingerprint/different-inner-whitespace.json b/src/test/resources/contracts/brain/browser-run-v1/fingerprint/different-inner-whitespace.json new file mode 100644 index 00000000..aaa6c892 --- /dev/null +++ b/src/test/resources/contracts/brain/browser-run-v1/fingerprint/different-inner-whitespace.json @@ -0,0 +1,60 @@ +{ + "relation": "different", + "rationale": "strip은 양끝만 — 내부 공백은 의미 차이다.", + "left": { + "requestId": "7f460a7b-bf54-570e-a3af-b63a26435627", + "noteId": "123", + "text": "이 문제를 풀고 검산해줘", + "latex": "\\int_0^1 x^2\\,dx", + "requestedTasks": [ + "solve", + "check" + ], + "requestedOutputs": [ + "text" + ], + "turnAttachmentIds": [ + "d99f9829-981e-5da9-a3fe-b3bed3b7439e" + ], + "viewerFocus": { + "noteAssetId": "asset-9f8c", + "page": 3, + "selection": { + "left": 0.1, + "top": 0.2, + "right": 0.6, + "bottom": 0.5 + } + } + }, + "right": { + "requestId": "7f460a7b-bf54-570e-a3af-b63a26435627", + "noteId": "123", + "text": "이 문제를 풀고 검산해줘", + "latex": "\\int_0^1 x^2\\,dx", + "requestedTasks": [ + "solve", + "check" + ], + "requestedOutputs": [ + "text" + ], + "turnAttachmentIds": [ + "d99f9829-981e-5da9-a3fe-b3bed3b7439e" + ], + "viewerFocus": { + "noteAssetId": "asset-9f8c", + "page": 3, + "selection": { + "left": 0.1, + "top": 0.2, + "right": 0.6, + "bottom": 0.5 + } + } + }, + "expected_canonical_left": "{\"latex\":\"\\\\int_0^1 x^2\\\\,dx\",\"noteId\":\"123\",\"requestedOutputs\":[\"text\"],\"requestedTasks\":[\"check\",\"solve\"],\"text\":\"이 문제를 풀고 검산해줘\",\"turnAttachmentIds\":[\"d99f9829-981e-5da9-a3fe-b3bed3b7439e\"],\"viewerFocus\":{\"noteAssetId\":\"asset-9f8c\",\"page\":3,\"selection\":{\"bottom\":0.5,\"left\":0.1,\"right\":0.6,\"top\":0.2}}}", + "expected_canonical_right": "{\"latex\":\"\\\\int_0^1 x^2\\\\,dx\",\"noteId\":\"123\",\"requestedOutputs\":[\"text\"],\"requestedTasks\":[\"check\",\"solve\"],\"text\":\"이 문제를 풀고 검산해줘\",\"turnAttachmentIds\":[\"d99f9829-981e-5da9-a3fe-b3bed3b7439e\"],\"viewerFocus\":{\"noteAssetId\":\"asset-9f8c\",\"page\":3,\"selection\":{\"bottom\":0.5,\"left\":0.1,\"right\":0.6,\"top\":0.2}}}", + "expected_fingerprint_left": "7ed6e78fa8f0186b65aab8b6440ece969e79f374b6fcd5092b9bec4014f97cdc", + "expected_fingerprint_right": "f81aed50772e512163d33c9dacecc418d8103c43135110f45b4844498ff8d628" +} diff --git a/src/test/resources/contracts/brain/browser-run-v1/fingerprint/different-latex.json b/src/test/resources/contracts/brain/browser-run-v1/fingerprint/different-latex.json new file mode 100644 index 00000000..1753f2aa --- /dev/null +++ b/src/test/resources/contracts/brain/browser-run-v1/fingerprint/different-latex.json @@ -0,0 +1,60 @@ +{ + "relation": "different", + "rationale": "latex 변경.", + "left": { + "requestId": "7f460a7b-bf54-570e-a3af-b63a26435627", + "noteId": "123", + "text": "이 문제를 풀고 검산해줘", + "latex": "\\int_0^1 x^2\\,dx", + "requestedTasks": [ + "solve", + "check" + ], + "requestedOutputs": [ + "text" + ], + "turnAttachmentIds": [ + "d99f9829-981e-5da9-a3fe-b3bed3b7439e" + ], + "viewerFocus": { + "noteAssetId": "asset-9f8c", + "page": 3, + "selection": { + "left": 0.1, + "top": 0.2, + "right": 0.6, + "bottom": 0.5 + } + } + }, + "right": { + "requestId": "7f460a7b-bf54-570e-a3af-b63a26435627", + "noteId": "123", + "text": "이 문제를 풀고 검산해줘", + "latex": "x^2", + "requestedTasks": [ + "solve", + "check" + ], + "requestedOutputs": [ + "text" + ], + "turnAttachmentIds": [ + "d99f9829-981e-5da9-a3fe-b3bed3b7439e" + ], + "viewerFocus": { + "noteAssetId": "asset-9f8c", + "page": 3, + "selection": { + "left": 0.1, + "top": 0.2, + "right": 0.6, + "bottom": 0.5 + } + } + }, + "expected_canonical_left": "{\"latex\":\"\\\\int_0^1 x^2\\\\,dx\",\"noteId\":\"123\",\"requestedOutputs\":[\"text\"],\"requestedTasks\":[\"check\",\"solve\"],\"text\":\"이 문제를 풀고 검산해줘\",\"turnAttachmentIds\":[\"d99f9829-981e-5da9-a3fe-b3bed3b7439e\"],\"viewerFocus\":{\"noteAssetId\":\"asset-9f8c\",\"page\":3,\"selection\":{\"bottom\":0.5,\"left\":0.1,\"right\":0.6,\"top\":0.2}}}", + "expected_canonical_right": "{\"latex\":\"x^2\",\"noteId\":\"123\",\"requestedOutputs\":[\"text\"],\"requestedTasks\":[\"check\",\"solve\"],\"text\":\"이 문제를 풀고 검산해줘\",\"turnAttachmentIds\":[\"d99f9829-981e-5da9-a3fe-b3bed3b7439e\"],\"viewerFocus\":{\"noteAssetId\":\"asset-9f8c\",\"page\":3,\"selection\":{\"bottom\":0.5,\"left\":0.1,\"right\":0.6,\"top\":0.2}}}", + "expected_fingerprint_left": "7ed6e78fa8f0186b65aab8b6440ece969e79f374b6fcd5092b9bec4014f97cdc", + "expected_fingerprint_right": "80c93105b1f0de5aa05834fe42c0eacb57db1f196f70a7a35e31fcaedbd46379" +} diff --git a/src/test/resources/contracts/brain/browser-run-v1/fingerprint/different-note-id-added.json b/src/test/resources/contracts/brain/browser-run-v1/fingerprint/different-note-id-added.json new file mode 100644 index 00000000..23efc8c5 --- /dev/null +++ b/src/test/resources/contracts/brain/browser-run-v1/fingerprint/different-note-id-added.json @@ -0,0 +1,17 @@ +{ + "relation": "different", + "rationale": "noteId 부재와 존재는 다르다 — 받은 run.created.noteId를 넣어 재전송하면 RUN4092.", + "left": { + "requestId": "7f460a7b-bf54-570e-a3af-b63a26435627", + "text": "이 문제를 풀어줘" + }, + "right": { + "requestId": "7f460a7b-bf54-570e-a3af-b63a26435627", + "text": "이 문제를 풀어줘", + "noteId": "123" + }, + "expected_canonical_left": "{\"text\":\"이 문제를 풀어줘\"}", + "expected_canonical_right": "{\"noteId\":\"123\",\"text\":\"이 문제를 풀어줘\"}", + "expected_fingerprint_left": "8f8f695c8620b653d591c5098edc3ca99899d8cd7818ef5e7a1ca6866d2f8a7f", + "expected_fingerprint_right": "3a0a7f634a91d0faa3359ab4f7a4df58677f25a195c2c9c95c67fb20f538fbb2" +} diff --git a/src/test/resources/contracts/brain/browser-run-v1/fingerprint/different-outputs-explicit-vs-inferred.json b/src/test/resources/contracts/brain/browser-run-v1/fingerprint/different-outputs-explicit-vs-inferred.json new file mode 100644 index 00000000..dbb792ba --- /dev/null +++ b/src/test/resources/contracts/brain/browser-run-v1/fingerprint/different-outputs-explicit-vs-inferred.json @@ -0,0 +1,58 @@ +{ + "relation": "different", + "rationale": "['text'] 명시와 [](추론)은 다른 의미다 — fingerprint 전에 기본값을 채우지 않는다.", + "left": { + "requestId": "7f460a7b-bf54-570e-a3af-b63a26435627", + "noteId": "123", + "text": "이 문제를 풀고 검산해줘", + "latex": "\\int_0^1 x^2\\,dx", + "requestedTasks": [ + "solve", + "check" + ], + "requestedOutputs": [ + "text" + ], + "turnAttachmentIds": [ + "d99f9829-981e-5da9-a3fe-b3bed3b7439e" + ], + "viewerFocus": { + "noteAssetId": "asset-9f8c", + "page": 3, + "selection": { + "left": 0.1, + "top": 0.2, + "right": 0.6, + "bottom": 0.5 + } + } + }, + "right": { + "requestId": "7f460a7b-bf54-570e-a3af-b63a26435627", + "noteId": "123", + "text": "이 문제를 풀고 검산해줘", + "latex": "\\int_0^1 x^2\\,dx", + "requestedTasks": [ + "solve", + "check" + ], + "requestedOutputs": [], + "turnAttachmentIds": [ + "d99f9829-981e-5da9-a3fe-b3bed3b7439e" + ], + "viewerFocus": { + "noteAssetId": "asset-9f8c", + "page": 3, + "selection": { + "left": 0.1, + "top": 0.2, + "right": 0.6, + "bottom": 0.5 + } + } + }, + "expected_canonical_left": "{\"latex\":\"\\\\int_0^1 x^2\\\\,dx\",\"noteId\":\"123\",\"requestedOutputs\":[\"text\"],\"requestedTasks\":[\"check\",\"solve\"],\"text\":\"이 문제를 풀고 검산해줘\",\"turnAttachmentIds\":[\"d99f9829-981e-5da9-a3fe-b3bed3b7439e\"],\"viewerFocus\":{\"noteAssetId\":\"asset-9f8c\",\"page\":3,\"selection\":{\"bottom\":0.5,\"left\":0.1,\"right\":0.6,\"top\":0.2}}}", + "expected_canonical_right": "{\"latex\":\"\\\\int_0^1 x^2\\\\,dx\",\"noteId\":\"123\",\"requestedTasks\":[\"check\",\"solve\"],\"text\":\"이 문제를 풀고 검산해줘\",\"turnAttachmentIds\":[\"d99f9829-981e-5da9-a3fe-b3bed3b7439e\"],\"viewerFocus\":{\"noteAssetId\":\"asset-9f8c\",\"page\":3,\"selection\":{\"bottom\":0.5,\"left\":0.1,\"right\":0.6,\"top\":0.2}}}", + "expected_fingerprint_left": "7ed6e78fa8f0186b65aab8b6440ece969e79f374b6fcd5092b9bec4014f97cdc", + "expected_fingerprint_right": "1441d9d3e1c1f922a17147374d2c7a238c89dc31fd03bef71d49f54bd51dfa4e" +} diff --git a/src/test/resources/contracts/brain/browser-run-v1/fingerprint/different-selection.json b/src/test/resources/contracts/brain/browser-run-v1/fingerprint/different-selection.json new file mode 100644 index 00000000..8c796c23 --- /dev/null +++ b/src/test/resources/contracts/brain/browser-run-v1/fingerprint/different-selection.json @@ -0,0 +1,60 @@ +{ + "relation": "different", + "rationale": "selection 변경.", + "left": { + "requestId": "7f460a7b-bf54-570e-a3af-b63a26435627", + "noteId": "123", + "text": "이 문제를 풀고 검산해줘", + "latex": "\\int_0^1 x^2\\,dx", + "requestedTasks": [ + "solve", + "check" + ], + "requestedOutputs": [ + "text" + ], + "turnAttachmentIds": [ + "d99f9829-981e-5da9-a3fe-b3bed3b7439e" + ], + "viewerFocus": { + "noteAssetId": "asset-9f8c", + "page": 3, + "selection": { + "left": 0.1, + "top": 0.2, + "right": 0.6, + "bottom": 0.5 + } + } + }, + "right": { + "requestId": "7f460a7b-bf54-570e-a3af-b63a26435627", + "noteId": "123", + "text": "이 문제를 풀고 검산해줘", + "latex": "\\int_0^1 x^2\\,dx", + "requestedTasks": [ + "solve", + "check" + ], + "requestedOutputs": [ + "text" + ], + "turnAttachmentIds": [ + "d99f9829-981e-5da9-a3fe-b3bed3b7439e" + ], + "viewerFocus": { + "noteAssetId": "asset-9f8c", + "page": 3, + "selection": { + "left": 0.1, + "top": 0.2, + "right": 0.7, + "bottom": 0.5 + } + } + }, + "expected_canonical_left": "{\"latex\":\"\\\\int_0^1 x^2\\\\,dx\",\"noteId\":\"123\",\"requestedOutputs\":[\"text\"],\"requestedTasks\":[\"check\",\"solve\"],\"text\":\"이 문제를 풀고 검산해줘\",\"turnAttachmentIds\":[\"d99f9829-981e-5da9-a3fe-b3bed3b7439e\"],\"viewerFocus\":{\"noteAssetId\":\"asset-9f8c\",\"page\":3,\"selection\":{\"bottom\":0.5,\"left\":0.1,\"right\":0.6,\"top\":0.2}}}", + "expected_canonical_right": "{\"latex\":\"\\\\int_0^1 x^2\\\\,dx\",\"noteId\":\"123\",\"requestedOutputs\":[\"text\"],\"requestedTasks\":[\"check\",\"solve\"],\"text\":\"이 문제를 풀고 검산해줘\",\"turnAttachmentIds\":[\"d99f9829-981e-5da9-a3fe-b3bed3b7439e\"],\"viewerFocus\":{\"noteAssetId\":\"asset-9f8c\",\"page\":3,\"selection\":{\"bottom\":0.5,\"left\":0.1,\"right\":0.7,\"top\":0.2}}}", + "expected_fingerprint_left": "7ed6e78fa8f0186b65aab8b6440ece969e79f374b6fcd5092b9bec4014f97cdc", + "expected_fingerprint_right": "0e395232afa485f4497e1fd6ca0f48eac6353a4b7ed732519f76205491096764" +} diff --git a/src/test/resources/contracts/brain/browser-run-v1/fingerprint/different-tasks.json b/src/test/resources/contracts/brain/browser-run-v1/fingerprint/different-tasks.json new file mode 100644 index 00000000..da492f17 --- /dev/null +++ b/src/test/resources/contracts/brain/browser-run-v1/fingerprint/different-tasks.json @@ -0,0 +1,59 @@ +{ + "relation": "different", + "rationale": "requestedTasks 변경.", + "left": { + "requestId": "7f460a7b-bf54-570e-a3af-b63a26435627", + "noteId": "123", + "text": "이 문제를 풀고 검산해줘", + "latex": "\\int_0^1 x^2\\,dx", + "requestedTasks": [ + "solve", + "check" + ], + "requestedOutputs": [ + "text" + ], + "turnAttachmentIds": [ + "d99f9829-981e-5da9-a3fe-b3bed3b7439e" + ], + "viewerFocus": { + "noteAssetId": "asset-9f8c", + "page": 3, + "selection": { + "left": 0.1, + "top": 0.2, + "right": 0.6, + "bottom": 0.5 + } + } + }, + "right": { + "requestId": "7f460a7b-bf54-570e-a3af-b63a26435627", + "noteId": "123", + "text": "이 문제를 풀고 검산해줘", + "latex": "\\int_0^1 x^2\\,dx", + "requestedTasks": [ + "solve" + ], + "requestedOutputs": [ + "text" + ], + "turnAttachmentIds": [ + "d99f9829-981e-5da9-a3fe-b3bed3b7439e" + ], + "viewerFocus": { + "noteAssetId": "asset-9f8c", + "page": 3, + "selection": { + "left": 0.1, + "top": 0.2, + "right": 0.6, + "bottom": 0.5 + } + } + }, + "expected_canonical_left": "{\"latex\":\"\\\\int_0^1 x^2\\\\,dx\",\"noteId\":\"123\",\"requestedOutputs\":[\"text\"],\"requestedTasks\":[\"check\",\"solve\"],\"text\":\"이 문제를 풀고 검산해줘\",\"turnAttachmentIds\":[\"d99f9829-981e-5da9-a3fe-b3bed3b7439e\"],\"viewerFocus\":{\"noteAssetId\":\"asset-9f8c\",\"page\":3,\"selection\":{\"bottom\":0.5,\"left\":0.1,\"right\":0.6,\"top\":0.2}}}", + "expected_canonical_right": "{\"latex\":\"\\\\int_0^1 x^2\\\\,dx\",\"noteId\":\"123\",\"requestedOutputs\":[\"text\"],\"requestedTasks\":[\"solve\"],\"text\":\"이 문제를 풀고 검산해줘\",\"turnAttachmentIds\":[\"d99f9829-981e-5da9-a3fe-b3bed3b7439e\"],\"viewerFocus\":{\"noteAssetId\":\"asset-9f8c\",\"page\":3,\"selection\":{\"bottom\":0.5,\"left\":0.1,\"right\":0.6,\"top\":0.2}}}", + "expected_fingerprint_left": "7ed6e78fa8f0186b65aab8b6440ece969e79f374b6fcd5092b9bec4014f97cdc", + "expected_fingerprint_right": "b1de923abe76344ba83606f4bd2d1ad0abe4e7fea566195c652b0fe1d9b17dc1" +} diff --git a/src/test/resources/contracts/brain/browser-run-v1/fingerprint/different-text.json b/src/test/resources/contracts/brain/browser-run-v1/fingerprint/different-text.json new file mode 100644 index 00000000..844019b1 --- /dev/null +++ b/src/test/resources/contracts/brain/browser-run-v1/fingerprint/different-text.json @@ -0,0 +1,60 @@ +{ + "relation": "different", + "rationale": "text 변경.", + "left": { + "requestId": "7f460a7b-bf54-570e-a3af-b63a26435627", + "noteId": "123", + "text": "이 문제를 풀고 검산해줘", + "latex": "\\int_0^1 x^2\\,dx", + "requestedTasks": [ + "solve", + "check" + ], + "requestedOutputs": [ + "text" + ], + "turnAttachmentIds": [ + "d99f9829-981e-5da9-a3fe-b3bed3b7439e" + ], + "viewerFocus": { + "noteAssetId": "asset-9f8c", + "page": 3, + "selection": { + "left": 0.1, + "top": 0.2, + "right": 0.6, + "bottom": 0.5 + } + } + }, + "right": { + "requestId": "7f460a7b-bf54-570e-a3af-b63a26435627", + "noteId": "123", + "text": "이 문제를 설명해줘", + "latex": "\\int_0^1 x^2\\,dx", + "requestedTasks": [ + "solve", + "check" + ], + "requestedOutputs": [ + "text" + ], + "turnAttachmentIds": [ + "d99f9829-981e-5da9-a3fe-b3bed3b7439e" + ], + "viewerFocus": { + "noteAssetId": "asset-9f8c", + "page": 3, + "selection": { + "left": 0.1, + "top": 0.2, + "right": 0.6, + "bottom": 0.5 + } + } + }, + "expected_canonical_left": "{\"latex\":\"\\\\int_0^1 x^2\\\\,dx\",\"noteId\":\"123\",\"requestedOutputs\":[\"text\"],\"requestedTasks\":[\"check\",\"solve\"],\"text\":\"이 문제를 풀고 검산해줘\",\"turnAttachmentIds\":[\"d99f9829-981e-5da9-a3fe-b3bed3b7439e\"],\"viewerFocus\":{\"noteAssetId\":\"asset-9f8c\",\"page\":3,\"selection\":{\"bottom\":0.5,\"left\":0.1,\"right\":0.6,\"top\":0.2}}}", + "expected_canonical_right": "{\"latex\":\"\\\\int_0^1 x^2\\\\,dx\",\"noteId\":\"123\",\"requestedOutputs\":[\"text\"],\"requestedTasks\":[\"check\",\"solve\"],\"text\":\"이 문제를 설명해줘\",\"turnAttachmentIds\":[\"d99f9829-981e-5da9-a3fe-b3bed3b7439e\"],\"viewerFocus\":{\"noteAssetId\":\"asset-9f8c\",\"page\":3,\"selection\":{\"bottom\":0.5,\"left\":0.1,\"right\":0.6,\"top\":0.2}}}", + "expected_fingerprint_left": "7ed6e78fa8f0186b65aab8b6440ece969e79f374b6fcd5092b9bec4014f97cdc", + "expected_fingerprint_right": "0529dea2b6a7fc14d6a1b98340835dcf62a14c0efc2ff642b95770b19915e846" +} diff --git a/src/test/resources/contracts/brain/browser-run-v1/fingerprint/different-viewer-page.json b/src/test/resources/contracts/brain/browser-run-v1/fingerprint/different-viewer-page.json new file mode 100644 index 00000000..a990ae8b --- /dev/null +++ b/src/test/resources/contracts/brain/browser-run-v1/fingerprint/different-viewer-page.json @@ -0,0 +1,60 @@ +{ + "relation": "different", + "rationale": "Viewer Focus page 변경.", + "left": { + "requestId": "7f460a7b-bf54-570e-a3af-b63a26435627", + "noteId": "123", + "text": "이 문제를 풀고 검산해줘", + "latex": "\\int_0^1 x^2\\,dx", + "requestedTasks": [ + "solve", + "check" + ], + "requestedOutputs": [ + "text" + ], + "turnAttachmentIds": [ + "d99f9829-981e-5da9-a3fe-b3bed3b7439e" + ], + "viewerFocus": { + "noteAssetId": "asset-9f8c", + "page": 3, + "selection": { + "left": 0.1, + "top": 0.2, + "right": 0.6, + "bottom": 0.5 + } + } + }, + "right": { + "requestId": "7f460a7b-bf54-570e-a3af-b63a26435627", + "noteId": "123", + "text": "이 문제를 풀고 검산해줘", + "latex": "\\int_0^1 x^2\\,dx", + "requestedTasks": [ + "solve", + "check" + ], + "requestedOutputs": [ + "text" + ], + "turnAttachmentIds": [ + "d99f9829-981e-5da9-a3fe-b3bed3b7439e" + ], + "viewerFocus": { + "noteAssetId": "asset-9f8c", + "page": 4, + "selection": { + "left": 0.1, + "top": 0.2, + "right": 0.6, + "bottom": 0.5 + } + } + }, + "expected_canonical_left": "{\"latex\":\"\\\\int_0^1 x^2\\\\,dx\",\"noteId\":\"123\",\"requestedOutputs\":[\"text\"],\"requestedTasks\":[\"check\",\"solve\"],\"text\":\"이 문제를 풀고 검산해줘\",\"turnAttachmentIds\":[\"d99f9829-981e-5da9-a3fe-b3bed3b7439e\"],\"viewerFocus\":{\"noteAssetId\":\"asset-9f8c\",\"page\":3,\"selection\":{\"bottom\":0.5,\"left\":0.1,\"right\":0.6,\"top\":0.2}}}", + "expected_canonical_right": "{\"latex\":\"\\\\int_0^1 x^2\\\\,dx\",\"noteId\":\"123\",\"requestedOutputs\":[\"text\"],\"requestedTasks\":[\"check\",\"solve\"],\"text\":\"이 문제를 풀고 검산해줘\",\"turnAttachmentIds\":[\"d99f9829-981e-5da9-a3fe-b3bed3b7439e\"],\"viewerFocus\":{\"noteAssetId\":\"asset-9f8c\",\"page\":4,\"selection\":{\"bottom\":0.5,\"left\":0.1,\"right\":0.6,\"top\":0.2}}}", + "expected_fingerprint_left": "7ed6e78fa8f0186b65aab8b6440ece969e79f374b6fcd5092b9bec4014f97cdc", + "expected_fingerprint_right": "a23680869107754c0b847d1dff5dbf4f870cb9ddb78747b4dd82cb037a43c1c2" +} diff --git a/src/test/resources/contracts/brain/browser-run-v1/fingerprint/same-array-order.json b/src/test/resources/contracts/brain/browser-run-v1/fingerprint/same-array-order.json new file mode 100644 index 00000000..1f4d0a64 --- /dev/null +++ b/src/test/resources/contracts/brain/browser-run-v1/fingerprint/same-array-order.json @@ -0,0 +1,60 @@ +{ + "relation": "same", + "rationale": "비순서 배열은 정렬된다.", + "left": { + "requestId": "7f460a7b-bf54-570e-a3af-b63a26435627", + "noteId": "123", + "text": "이 문제를 풀고 검산해줘", + "latex": "\\int_0^1 x^2\\,dx", + "requestedTasks": [ + "solve", + "check" + ], + "requestedOutputs": [ + "text" + ], + "turnAttachmentIds": [ + "d99f9829-981e-5da9-a3fe-b3bed3b7439e" + ], + "viewerFocus": { + "noteAssetId": "asset-9f8c", + "page": 3, + "selection": { + "left": 0.1, + "top": 0.2, + "right": 0.6, + "bottom": 0.5 + } + } + }, + "right": { + "requestId": "7f460a7b-bf54-570e-a3af-b63a26435627", + "noteId": "123", + "text": "이 문제를 풀고 검산해줘", + "latex": "\\int_0^1 x^2\\,dx", + "requestedTasks": [ + "check", + "solve" + ], + "requestedOutputs": [ + "text" + ], + "turnAttachmentIds": [ + "d99f9829-981e-5da9-a3fe-b3bed3b7439e" + ], + "viewerFocus": { + "noteAssetId": "asset-9f8c", + "page": 3, + "selection": { + "left": 0.1, + "top": 0.2, + "right": 0.6, + "bottom": 0.5 + } + } + }, + "expected_canonical_left": "{\"latex\":\"\\\\int_0^1 x^2\\\\,dx\",\"noteId\":\"123\",\"requestedOutputs\":[\"text\"],\"requestedTasks\":[\"check\",\"solve\"],\"text\":\"이 문제를 풀고 검산해줘\",\"turnAttachmentIds\":[\"d99f9829-981e-5da9-a3fe-b3bed3b7439e\"],\"viewerFocus\":{\"noteAssetId\":\"asset-9f8c\",\"page\":3,\"selection\":{\"bottom\":0.5,\"left\":0.1,\"right\":0.6,\"top\":0.2}}}", + "expected_canonical_right": "{\"latex\":\"\\\\int_0^1 x^2\\\\,dx\",\"noteId\":\"123\",\"requestedOutputs\":[\"text\"],\"requestedTasks\":[\"check\",\"solve\"],\"text\":\"이 문제를 풀고 검산해줘\",\"turnAttachmentIds\":[\"d99f9829-981e-5da9-a3fe-b3bed3b7439e\"],\"viewerFocus\":{\"noteAssetId\":\"asset-9f8c\",\"page\":3,\"selection\":{\"bottom\":0.5,\"left\":0.1,\"right\":0.6,\"top\":0.2}}}", + "expected_fingerprint_left": "7ed6e78fa8f0186b65aab8b6440ece969e79f374b6fcd5092b9bec4014f97cdc", + "expected_fingerprint_right": "7ed6e78fa8f0186b65aab8b6440ece969e79f374b6fcd5092b9bec4014f97cdc" +} diff --git a/src/test/resources/contracts/brain/browser-run-v1/fingerprint/same-empty-array-vs-absent.json b/src/test/resources/contracts/brain/browser-run-v1/fingerprint/same-empty-array-vs-absent.json new file mode 100644 index 00000000..cc74c98e --- /dev/null +++ b/src/test/resources/contracts/brain/browser-run-v1/fingerprint/same-empty-array-vs-absent.json @@ -0,0 +1,21 @@ +{ + "relation": "same", + "rationale": "빈 배열과 부재는 같다.", + "left": { + "requestId": "7f460a7b-bf54-570e-a3af-b63a26435627", + "text": "이 문제를 풀어줘", + "noteId": "123" + }, + "right": { + "requestId": "7f460a7b-bf54-570e-a3af-b63a26435627", + "text": "이 문제를 풀어줘", + "noteId": "123", + "requestedTasks": [], + "requestedOutputs": [], + "turnAttachmentIds": [] + }, + "expected_canonical_left": "{\"noteId\":\"123\",\"text\":\"이 문제를 풀어줘\"}", + "expected_canonical_right": "{\"noteId\":\"123\",\"text\":\"이 문제를 풀어줘\"}", + "expected_fingerprint_left": "3a0a7f634a91d0faa3359ab4f7a4df58677f25a195c2c9c95c67fb20f538fbb2", + "expected_fingerprint_right": "3a0a7f634a91d0faa3359ab4f7a4df58677f25a195c2c9c95c67fb20f538fbb2" +} diff --git a/src/test/resources/contracts/brain/browser-run-v1/fingerprint/same-non-java-whitespace.json b/src/test/resources/contracts/brain/browser-run-v1/fingerprint/same-non-java-whitespace.json new file mode 100644 index 00000000..564412c6 --- /dev/null +++ b/src/test/resources/contracts/brain/browser-run-v1/fingerprint/same-non-java-whitespace.json @@ -0,0 +1,60 @@ +{ + "relation": "same", + "rationale": "U+00A0·U+0085·U+202F 양끝 공백도 strip된다 — Java String.strip()은 U+00A0·U+202F를 남기므로 strip 집합을 직접 구현해야 한다.", + "left": { + "requestId": "7f460a7b-bf54-570e-a3af-b63a26435627", + "noteId": "123", + "text": "이 문제를 풀고 검산해줘", + "latex": "\\int_0^1 x^2\\,dx", + "requestedTasks": [ + "solve", + "check" + ], + "requestedOutputs": [ + "text" + ], + "turnAttachmentIds": [ + "d99f9829-981e-5da9-a3fe-b3bed3b7439e" + ], + "viewerFocus": { + "noteAssetId": "asset-9f8c", + "page": 3, + "selection": { + "left": 0.1, + "top": 0.2, + "right": 0.6, + "bottom": 0.5 + } + } + }, + "right": { + "requestId": "7f460a7b-bf54-570e-a3af-b63a26435627", + "noteId": "123", + "text": " 이 문제를 풀고 검산해줘… ", + "latex": " \\int_0^1 x^2\\,dx ", + "requestedTasks": [ + "solve", + "check" + ], + "requestedOutputs": [ + "text" + ], + "turnAttachmentIds": [ + "d99f9829-981e-5da9-a3fe-b3bed3b7439e" + ], + "viewerFocus": { + "noteAssetId": "asset-9f8c", + "page": 3, + "selection": { + "left": 0.1, + "top": 0.2, + "right": 0.6, + "bottom": 0.5 + } + } + }, + "expected_canonical_left": "{\"latex\":\"\\\\int_0^1 x^2\\\\,dx\",\"noteId\":\"123\",\"requestedOutputs\":[\"text\"],\"requestedTasks\":[\"check\",\"solve\"],\"text\":\"이 문제를 풀고 검산해줘\",\"turnAttachmentIds\":[\"d99f9829-981e-5da9-a3fe-b3bed3b7439e\"],\"viewerFocus\":{\"noteAssetId\":\"asset-9f8c\",\"page\":3,\"selection\":{\"bottom\":0.5,\"left\":0.1,\"right\":0.6,\"top\":0.2}}}", + "expected_canonical_right": "{\"latex\":\"\\\\int_0^1 x^2\\\\,dx\",\"noteId\":\"123\",\"requestedOutputs\":[\"text\"],\"requestedTasks\":[\"check\",\"solve\"],\"text\":\"이 문제를 풀고 검산해줘\",\"turnAttachmentIds\":[\"d99f9829-981e-5da9-a3fe-b3bed3b7439e\"],\"viewerFocus\":{\"noteAssetId\":\"asset-9f8c\",\"page\":3,\"selection\":{\"bottom\":0.5,\"left\":0.1,\"right\":0.6,\"top\":0.2}}}", + "expected_fingerprint_left": "7ed6e78fa8f0186b65aab8b6440ece969e79f374b6fcd5092b9bec4014f97cdc", + "expected_fingerprint_right": "7ed6e78fa8f0186b65aab8b6440ece969e79f374b6fcd5092b9bec4014f97cdc" +} diff --git a/src/test/resources/contracts/brain/browser-run-v1/fingerprint/same-null-selection.json b/src/test/resources/contracts/brain/browser-run-v1/fingerprint/same-null-selection.json new file mode 100644 index 00000000..2630318c --- /dev/null +++ b/src/test/resources/contracts/brain/browser-run-v1/fingerprint/same-null-selection.json @@ -0,0 +1,27 @@ +{ + "relation": "same", + "rationale": "selection null과 부재는 같다.", + "left": { + "requestId": "7f460a7b-bf54-570e-a3af-b63a26435627", + "text": "이 문제를 풀어줘", + "noteId": "123", + "viewerFocus": { + "noteAssetId": "asset-9f8c", + "page": 2 + } + }, + "right": { + "requestId": "7f460a7b-bf54-570e-a3af-b63a26435627", + "text": "이 문제를 풀어줘", + "noteId": "123", + "viewerFocus": { + "noteAssetId": "asset-9f8c", + "page": 2, + "selection": null + } + }, + "expected_canonical_left": "{\"noteId\":\"123\",\"text\":\"이 문제를 풀어줘\",\"viewerFocus\":{\"noteAssetId\":\"asset-9f8c\",\"page\":2}}", + "expected_canonical_right": "{\"noteId\":\"123\",\"text\":\"이 문제를 풀어줘\",\"viewerFocus\":{\"noteAssetId\":\"asset-9f8c\",\"page\":2}}", + "expected_fingerprint_left": "f8e7d2fa5fa677bb5a4535bc166e8540cb92d82f9cc2825c52e633efee9d7004", + "expected_fingerprint_right": "f8e7d2fa5fa677bb5a4535bc166e8540cb92d82f9cc2825c52e633efee9d7004" +} diff --git a/src/test/resources/contracts/brain/browser-run-v1/fingerprint/same-null-vs-absent.json b/src/test/resources/contracts/brain/browser-run-v1/fingerprint/same-null-vs-absent.json new file mode 100644 index 00000000..8a0417b9 --- /dev/null +++ b/src/test/resources/contracts/brain/browser-run-v1/fingerprint/same-null-vs-absent.json @@ -0,0 +1,20 @@ +{ + "relation": "same", + "rationale": "null과 부재는 같다.", + "left": { + "requestId": "7f460a7b-bf54-570e-a3af-b63a26435627", + "text": "이 문제를 풀어줘", + "noteId": "123" + }, + "right": { + "requestId": "7f460a7b-bf54-570e-a3af-b63a26435627", + "text": "이 문제를 풀어줘", + "noteId": "123", + "latex": null, + "viewerFocus": null + }, + "expected_canonical_left": "{\"noteId\":\"123\",\"text\":\"이 문제를 풀어줘\"}", + "expected_canonical_right": "{\"noteId\":\"123\",\"text\":\"이 문제를 풀어줘\"}", + "expected_fingerprint_left": "3a0a7f634a91d0faa3359ab4f7a4df58677f25a195c2c9c95c67fb20f538fbb2", + "expected_fingerprint_right": "3a0a7f634a91d0faa3359ab4f7a4df58677f25a195c2c9c95c67fb20f538fbb2" +} diff --git a/src/test/resources/contracts/brain/browser-run-v1/fingerprint/same-other-request-id.json b/src/test/resources/contracts/brain/browser-run-v1/fingerprint/same-other-request-id.json new file mode 100644 index 00000000..beecb45a --- /dev/null +++ b/src/test/resources/contracts/brain/browser-run-v1/fingerprint/same-other-request-id.json @@ -0,0 +1,60 @@ +{ + "relation": "same", + "rationale": "requestId는 의미 문서에서 제외된다.", + "left": { + "requestId": "7f460a7b-bf54-570e-a3af-b63a26435627", + "noteId": "123", + "text": "이 문제를 풀고 검산해줘", + "latex": "\\int_0^1 x^2\\,dx", + "requestedTasks": [ + "solve", + "check" + ], + "requestedOutputs": [ + "text" + ], + "turnAttachmentIds": [ + "d99f9829-981e-5da9-a3fe-b3bed3b7439e" + ], + "viewerFocus": { + "noteAssetId": "asset-9f8c", + "page": 3, + "selection": { + "left": 0.1, + "top": 0.2, + "right": 0.6, + "bottom": 0.5 + } + } + }, + "right": { + "requestId": "b5c598bb-1c24-5d4c-818b-ea112d8ffb69", + "noteId": "123", + "text": "이 문제를 풀고 검산해줘", + "latex": "\\int_0^1 x^2\\,dx", + "requestedTasks": [ + "solve", + "check" + ], + "requestedOutputs": [ + "text" + ], + "turnAttachmentIds": [ + "d99f9829-981e-5da9-a3fe-b3bed3b7439e" + ], + "viewerFocus": { + "noteAssetId": "asset-9f8c", + "page": 3, + "selection": { + "left": 0.1, + "top": 0.2, + "right": 0.6, + "bottom": 0.5 + } + } + }, + "expected_canonical_left": "{\"latex\":\"\\\\int_0^1 x^2\\\\,dx\",\"noteId\":\"123\",\"requestedOutputs\":[\"text\"],\"requestedTasks\":[\"check\",\"solve\"],\"text\":\"이 문제를 풀고 검산해줘\",\"turnAttachmentIds\":[\"d99f9829-981e-5da9-a3fe-b3bed3b7439e\"],\"viewerFocus\":{\"noteAssetId\":\"asset-9f8c\",\"page\":3,\"selection\":{\"bottom\":0.5,\"left\":0.1,\"right\":0.6,\"top\":0.2}}}", + "expected_canonical_right": "{\"latex\":\"\\\\int_0^1 x^2\\\\,dx\",\"noteId\":\"123\",\"requestedOutputs\":[\"text\"],\"requestedTasks\":[\"check\",\"solve\"],\"text\":\"이 문제를 풀고 검산해줘\",\"turnAttachmentIds\":[\"d99f9829-981e-5da9-a3fe-b3bed3b7439e\"],\"viewerFocus\":{\"noteAssetId\":\"asset-9f8c\",\"page\":3,\"selection\":{\"bottom\":0.5,\"left\":0.1,\"right\":0.6,\"top\":0.2}}}", + "expected_fingerprint_left": "7ed6e78fa8f0186b65aab8b6440ece969e79f374b6fcd5092b9bec4014f97cdc", + "expected_fingerprint_right": "7ed6e78fa8f0186b65aab8b6440ece969e79f374b6fcd5092b9bec4014f97cdc" +} diff --git a/src/test/resources/contracts/brain/browser-run-v1/fingerprint/same-surrounding-whitespace.json b/src/test/resources/contracts/brain/browser-run-v1/fingerprint/same-surrounding-whitespace.json new file mode 100644 index 00000000..9fbefa85 --- /dev/null +++ b/src/test/resources/contracts/brain/browser-run-v1/fingerprint/same-surrounding-whitespace.json @@ -0,0 +1,60 @@ +{ + "relation": "same", + "rationale": "text·latex 양끝 공백은 strip된다.", + "left": { + "requestId": "7f460a7b-bf54-570e-a3af-b63a26435627", + "noteId": "123", + "text": "이 문제를 풀고 검산해줘", + "latex": "\\int_0^1 x^2\\,dx", + "requestedTasks": [ + "solve", + "check" + ], + "requestedOutputs": [ + "text" + ], + "turnAttachmentIds": [ + "d99f9829-981e-5da9-a3fe-b3bed3b7439e" + ], + "viewerFocus": { + "noteAssetId": "asset-9f8c", + "page": 3, + "selection": { + "left": 0.1, + "top": 0.2, + "right": 0.6, + "bottom": 0.5 + } + } + }, + "right": { + "requestId": "7f460a7b-bf54-570e-a3af-b63a26435627", + "noteId": "123", + "text": " 이 문제를 풀고 검산해줘 ", + "latex": "\\int_0^1 x^2\\,dx\n", + "requestedTasks": [ + "solve", + "check" + ], + "requestedOutputs": [ + "text" + ], + "turnAttachmentIds": [ + "d99f9829-981e-5da9-a3fe-b3bed3b7439e" + ], + "viewerFocus": { + "noteAssetId": "asset-9f8c", + "page": 3, + "selection": { + "left": 0.1, + "top": 0.2, + "right": 0.6, + "bottom": 0.5 + } + } + }, + "expected_canonical_left": "{\"latex\":\"\\\\int_0^1 x^2\\\\,dx\",\"noteId\":\"123\",\"requestedOutputs\":[\"text\"],\"requestedTasks\":[\"check\",\"solve\"],\"text\":\"이 문제를 풀고 검산해줘\",\"turnAttachmentIds\":[\"d99f9829-981e-5da9-a3fe-b3bed3b7439e\"],\"viewerFocus\":{\"noteAssetId\":\"asset-9f8c\",\"page\":3,\"selection\":{\"bottom\":0.5,\"left\":0.1,\"right\":0.6,\"top\":0.2}}}", + "expected_canonical_right": "{\"latex\":\"\\\\int_0^1 x^2\\\\,dx\",\"noteId\":\"123\",\"requestedOutputs\":[\"text\"],\"requestedTasks\":[\"check\",\"solve\"],\"text\":\"이 문제를 풀고 검산해줘\",\"turnAttachmentIds\":[\"d99f9829-981e-5da9-a3fe-b3bed3b7439e\"],\"viewerFocus\":{\"noteAssetId\":\"asset-9f8c\",\"page\":3,\"selection\":{\"bottom\":0.5,\"left\":0.1,\"right\":0.6,\"top\":0.2}}}", + "expected_fingerprint_left": "7ed6e78fa8f0186b65aab8b6440ece969e79f374b6fcd5092b9bec4014f97cdc", + "expected_fingerprint_right": "7ed6e78fa8f0186b65aab8b6440ece969e79f374b6fcd5092b9bec4014f97cdc" +} diff --git a/src/test/resources/contracts/brain/browser-run-v1/heartbeat-invalid.json b/src/test/resources/contracts/brain/browser-run-v1/heartbeat-invalid.json new file mode 100644 index 00000000..3c4bf982 --- /dev/null +++ b/src/test/resources/contracts/brain/browser-run-v1/heartbeat-invalid.json @@ -0,0 +1,20 @@ +{ + "valid": ": heartbeat\n\n", + "cases": [ + { + "name": "heartbeat-with-id", + "rationale": "heartbeat는 id가 없다 — cursor를 전진시키면 안 된다.", + "sse": ": heartbeat\nid: 5e186280-06ec-5099-8087-8de875b2b83b:3\n\n" + }, + { + "name": "heartbeat-with-data", + "rationale": "heartbeat는 data가 없다 — frame으로 저장·replay되지 않는다.", + "sse": ": heartbeat\ndata: {}\n\n" + }, + { + "name": "heartbeat-as-event", + "rationale": "heartbeat는 event가 아니라 comment다.", + "sse": "event: heartbeat\ndata: {}\n\n" + } + ] +} diff --git a/src/test/resources/contracts/brain/browser-run-v1/heartbeat.txt b/src/test/resources/contracts/brain/browser-run-v1/heartbeat.txt new file mode 100644 index 00000000..080f6624 --- /dev/null +++ b/src/test/resources/contracts/brain/browser-run-v1/heartbeat.txt @@ -0,0 +1,2 @@ +: heartbeat + diff --git a/src/test/resources/contracts/brain/browser-run-v1/manifest.json b/src/test/resources/contracts/brain/browser-run-v1/manifest.json new file mode 100644 index 00000000..16bb1af4 --- /dev/null +++ b/src/test/resources/contracts/brain/browser-run-v1/manifest.json @@ -0,0 +1,200 @@ +{ + "consumer_version": "browser-run-fixtures-v1", + "contract_version": "browser-run-v1", + "original_path": "tests/fixtures/contracts/browser-run-v1", + "meaning_owner": "Proovy-server (task 1.75, Team-Proovy/Proovy-server#214)", + "frame_contract_version": "brain-source-v1", + "fingerprint_algorithm": "browser-request-sha256-canonical-json-v1", + "references": { + "terminal": "terminal-fixtures-v2", + "activity": "activity-fixtures-v1", + "response": "assistant-response-v1", + "internal_run": "internal-run-fixtures-v2", + "page_selection": "page-selection-v1" + }, + "schema_registry": { + "https://proovy.invalid/contracts/browser-run-v1/schemas/browser-run.schema.json": "browser-run-v1/schemas/browser-run.schema.json", + "https://proovy.invalid/contracts/terminal-v2/browser.schema.json": "terminal-v2/browser.schema.json" + }, + "limits": { + "text_max_code_points": 10000, + "latex_max_code_points": 10000, + "turn_attachment_ids_wire_max": 10, + "heartbeat_seconds": 15, + "max_seq": 9007199254740991 + }, + "stream_rules": { + "S1": "seq 1은 run.created이고 seq는 1부터 빈틈없이 +1.", + "S2": "terminal(run.completed|run.failed|run.canceled)은 정확히 하나이며 마지막 frame.", + "S3": "run.canceling은 최대 1회이고 run.canceled 앞. 그 뒤에는 activity.completed|failed|canceled와 run.canceled만.", + "S4": "response.page.*는 completed run에만, 모든 page의 started→completed 쌍이 pageIndex 0..n-1 순서로 run.completed 바로 앞에 연속하고 ID·presentation·title이 run.completed pages와 일치.", + "S5": "모든 frame runId가 같고 모든 assistantMessageId가 run.created의 값과 같다.", + "S6": "activity 후속 이벤트는 같은 activityId·kind의 앞선 activity.started를 가리키고 activity terminal 뒤에는 그 activity의 이벤트가 없다.", + "S7": "각 frame은 BrowserFrame schema를 통과한다(내부 필드 금지·Server 소유 payload)." + }, + "consumption_policy": "Consumers pin a merged proovy-brain commit + consumer_version. A copy is allowed only as a verified mirror: every file listed in `files` must match its sha256, and the pinned manifest sha256 must match. Brain stays the single source of truth; changes require a new consumer_version with a change_history entry.", + "change_history": [ + { + "date": "2026-10-17", + "consumer_version": "browser-run-fixtures-v1", + "reason": "Task 1.75 initial freeze of the browser run API wire contract (admission/replay/status/cancel/conversation, cursor, Server-owned frames) agreed at the stage-2 gate (D1-D10)." + } + ], + "files": { + "browser-run-v1/CATALOG.md": "d08fd43fa68ce44676478dad90d4c4bcba5be4a1d1ba695f351862b87da4bd0a", + "browser-run-v1/admission/decisions.json": "e1a2e635bd5ef5b5679c91fbf07c9986d4524b895e943aafbe54cba523ee3f17", + "browser-run-v1/build_fixtures.py": "de1aefadcafd92c1701385a340fe123e86638c649f02f65a29a363c6438ab014", + "browser-run-v1/cancel/cases.json": "5922e570198fbc1fe5590a232f005eb53a97e12c267942a8ade4ca0a3f30f5d1", + "browser-run-v1/cursors/cases.json": "84ca79c922bf40b82ab354ae65db8f030001e6704ac84096034d9920f5ab5232", + "browser-run-v1/fingerprint/different-attachment.json": "d62b651eb90be126c8edefa8b57fb743407713527f10a85e3bf3bae2b378076d", + "browser-run-v1/fingerprint/different-inner-whitespace.json": "f26a88d159e3f994595b1afc5b923a668d4db2b8ca95b6014a1a574bd7539fae", + "browser-run-v1/fingerprint/different-latex.json": "7f4826e224c55928a08208e84901be2dee972211c4c010803ac945b480448d4b", + "browser-run-v1/fingerprint/different-note-id-added.json": "5a3ec3be132bb83f3e71015392ee2d8f8dbb64eb4150d8803569fe36e837926e", + "browser-run-v1/fingerprint/different-outputs-explicit-vs-inferred.json": "eddb9f46559d44c853b9e749b7bd822a710017b3dff90d7532ace896bc980e8e", + "browser-run-v1/fingerprint/different-selection.json": "341766d7ad1243a510badf9b92be975924f1f2c368ad1bead15386c28e7d3691", + "browser-run-v1/fingerprint/different-tasks.json": "fea2665d13e2d12178c14c4d31486d89ecd9dd1c75895b5de9e7aad4b7ad9d2e", + "browser-run-v1/fingerprint/different-text.json": "59f4ee6a5bbcac7cd61e12830e33af64977579ed8251b4c3c18f899b0dcfa098", + "browser-run-v1/fingerprint/different-viewer-page.json": "e15ac0761f62d3ca678313969f39b57532b41232f42df5e35752c2ed9eaad14c", + "browser-run-v1/fingerprint/same-array-order.json": "dec8e08241c19f823f55665df5c1cd059110c613a0afe81a2f310525c5c800a5", + "browser-run-v1/fingerprint/same-empty-array-vs-absent.json": "651abf414268023cc4a6fc00b7fea7a8d69d771e926ca6461eb42cb87d2c5cc0", + "browser-run-v1/fingerprint/same-non-java-whitespace.json": "fb01934f58937fd2c5203c14475a3fdc810eb3bbaae7312a29aef4a2e8dd7f54", + "browser-run-v1/fingerprint/same-null-selection.json": "707f51a806bf8caf654aea1fa139b9d306d629df463bdf42d2644ff23a099a34", + "browser-run-v1/fingerprint/same-null-vs-absent.json": "2ced2b630384d69f3a3d7506794845bd29312aed7e48debd5312f15df1ce2945", + "browser-run-v1/fingerprint/same-other-request-id.json": "2624b6c1bf4a5941879cd2cdc79126f51f95d21c13ca14004e9970667b44a2e3", + "browser-run-v1/fingerprint/same-surrounding-whitespace.json": "4d45c98e97f04c9c3b29f165c357004dbb082160484c701f54133eb7b85f718e", + "browser-run-v1/heartbeat-invalid.json": "5ba6142531685b73bb1eadf177c48b25d87151f02b5616b494ac660f85e88e0c", + "browser-run-v1/heartbeat.txt": "20636d5878dba44e65c292353163c272897bdabc9f5bb21af7abd2b60eead596", + "browser-run-v1/requests/invalid/attachment-not-uuid.json": "92ed1da1fbd50600c643ca8e172cb962d088464c71e1fbe507e76eece81df4f8", + "browser-run-v1/requests/invalid/blank-text.json": "9aeadf2ded2584e86dde5a5b6e32316647369070c75af4d05d46361dd6678edc", + "browser-run-v1/requests/invalid/duplicate-task.json": "4ded74e8be86ad1d0d75521c746f4d960f086368e8b4967617109a46220376ff", + "browser-run-v1/requests/invalid/empty-latex.json": "195c9ca41a2af1b31fd92ed351485187f6da467068a8f046cc150cc0945e80a8", + "browser-run-v1/requests/invalid/forbidden-attempt.json": "b1f3ae4ab98278fe9a1c37cc140f9743b6cd8e22e712adfa6a7519b25886a579", + "browser-run-v1/requests/invalid/forbidden-auth-token.json": "33bc39d52ba0ed8d45e1e8a004f13c425e60a30a00f201306f43ccf43bcac4ca", + "browser-run-v1/requests/invalid/forbidden-budget.json": "6fedebd1dddd5a0bba322a12d657090bf850f3c2a989d054a9f450e8f26c4a6d", + "browser-run-v1/requests/invalid/forbidden-conversation-id.json": "bba64a25783d87b82806ebf81b45a09a271daed288490f3f6b377456aa3842cc", + "browser-run-v1/requests/invalid/forbidden-corpus-version.json": "898c5ed57884b16004857080c6ec50d992b023cb61471a78e9a06bc66ac5b67d", + "browser-run-v1/requests/invalid/forbidden-credit.json": "a4be12c4c08a27a430409007a125c381265b2d6f486744c595a36d4bdf8afdda", + "browser-run-v1/requests/invalid/forbidden-deadline.json": "ec4681be800ddae0f58cd00f0be5be62f3432afc3f991ebd8ba3f1cf537b87d2", + "browser-run-v1/requests/invalid/forbidden-fence-token.json": "be4112f1d2d3d60d7a588e2f2f82f587df1819ce4ea66bc48a5a015f12cd25ef", + "browser-run-v1/requests/invalid/forbidden-history.json": "19861e2c0e05b198dd8653b64d1672b6b6f6d3e75c37e172b209396c7227955d", + "browser-run-v1/requests/invalid/forbidden-input-scope.json": "96ec7351c923dfa95a19c05f5f05c8f49d8256c9546f74eedbcadad017e9f971", + "browser-run-v1/requests/invalid/forbidden-locale.json": "a1273f983be3591f0a15cc0eacb1750f3fa41610378a299e8ede43c9634eeaea", + "browser-run-v1/requests/invalid/forbidden-retrieval-scope.json": "a8b525d80c770538ee51f17d8946f71cc8766d220bcadfb702115b3e7300a143", + "browser-run-v1/requests/invalid/forbidden-run-id.json": "35e72eb45dee38a5d5b601a352298e997d9030c214c3e2e241dda3611d785752", + "browser-run-v1/requests/invalid/forbidden-scope.json": "0c7c766f9e2c53546dc69c2d912872c3e2a7d883a6ae4a721a42eeb86aa55480", + "browser-run-v1/requests/invalid/forbidden-turn-attachment-refs.json": "81c1f8240a9b1dc458c53890c86732a2a527d496b420d77a8b8b0f508e696581", + "browser-run-v1/requests/invalid/forbidden-usage.json": "2962962ea9861ffbff844e10bd9175c3eba2dd8719abf18624f6e37c461ec160", + "browser-run-v1/requests/invalid/forbidden-user-id.json": "d1d3705895799cd373c7f5b79013bd44936ad6a5d0f0df31247f88477efcbded", + "browser-run-v1/requests/invalid/forbidden-verified-solution-refs.json": "7c58bb7c99cfac0e2d82fee2e37de24948169fbf873ac48ac3937e593c78d39b", + "browser-run-v1/requests/invalid/latex-too-long.json": "74d6fa2faa49d38be3d85a265bf59437e9e055c63d2c881a06a0643d72e16f61", + "browser-run-v1/requests/invalid/missing-request-id.json": "ecea56134597c20ffa44de77f111668c9ab6ea46eb72d3d76618ec1a04269581", + "browser-run-v1/requests/invalid/missing-text.json": "88e6120c5bcee4c44923b43c7b7617fe7e343d4600d79b07c5eeff16d47010f8", + "browser-run-v1/requests/invalid/non-uuid-request-id.json": "72c726c84c5929c472ddcaad95fa1411591a4d3572c4b256ecbf857701f042fe", + "browser-run-v1/requests/invalid/note-asset-id-trailing-newline.json": "2dfe04de7e5113c0bd93300ea9fd5e6bc9a86d40c6bbb94689aba4b961814bdd", + "browser-run-v1/requests/invalid/note-id-leading-zero.json": "c5895f10d3eb24600f3e634a955a95a2f220473ef1a8e22863231cf59ea11dd0", + "browser-run-v1/requests/invalid/note-id-number.json": "cbb31adb76cf269a9e1d3492dca80b68020a14b2f148cba0578779631fb1d2a9", + "browser-run-v1/requests/invalid/note-id-over-long-max.json": "7dacaf4cbbf49dd8d7d12edf79454a75a01b72201c60631a66f946548165a849", + "browser-run-v1/requests/invalid/note-id-trailing-newline.json": "f49607ad5a761280f2da90d4c0ce561cbfff53d7dd1b777f1173765f392f03ea", + "browser-run-v1/requests/invalid/request-id-trailing-newline.json": "b1ff5fb0fba0819c22e8cc85307402278022f997447dd5159fe313f571829f82", + "browser-run-v1/requests/invalid/selection-inverted.json": "858ddd40f29e92e5975eb31e2461a80348604608e0dc92f437ef6743711b8385", + "browser-run-v1/requests/invalid/selection-out-of-range.json": "251ebcb709a4579c9db1347d9107e8f59edb0228abc66b902b830071050de051", + "browser-run-v1/requests/invalid/text-too-long-astral.json": "133e5e07b22f4549d444f6f8e90d83eb6f8bc44fbe313e1f8d41455e48f34602", + "browser-run-v1/requests/invalid/text-too-long.json": "78f4587c2b4eb36f402ff4a628aa5112a67bc953bc5dd98e7c1aa05589fd0331", + "browser-run-v1/requests/invalid/too-many-attachments-wire.json": "c437f7eb8f2d83da7fa5d125fbf2e14ba338fdfd6d94d401d701a0b488810911", + "browser-run-v1/requests/invalid/unknown-field.json": "e3ef6c58a7400269bb196f1a4de43051c18a33106238d524d30a7bb9865a5195", + "browser-run-v1/requests/invalid/unknown-task-variant.json": "5cd0c66ea2337ef4dfefce3d5527eeb961e3a2a475c6ddec230f4d7286747a2c", + "browser-run-v1/requests/invalid/uppercase-request-id.json": "095a77f23dbc799a76d68e210d8b1d928b517960f3e21d314b4c7c1661439a5e", + "browser-run-v1/requests/invalid/video-output.json": "d4ee0c0f2ce5792584358025d426c7c9ee7e5ef2eb3c2cf8fd7abc026f02545e", + "browser-run-v1/requests/invalid/viewer-focus-missing-page.json": "8c8289f21d15a45888f47c115dda3b4693f72cce1269b443986c385ce6f9d369", + "browser-run-v1/requests/invalid/viewer-page-zero.json": "a1baa0cccc6fa91ed13f091bd4d5129fc441372ceb2b524d3febba7f5477313d", + "browser-run-v1/requests/valid/empty-arrays.json": "7781af32deca50f22027c7e5b6c1cc52bdbedb45bc247cdda9b12bce0ce58602", + "browser-run-v1/requests/valid/existing-note-full.json": "26cb9244f282395dd1c52d847010849e11e8e8690fe42a3b4a1bf3ce4fbeb2ab", + "browser-run-v1/requests/valid/first-no-note.json": "0b569199c8663f3f149bfd1bfb673df119b62efc35361e8d815b44c3fbee0298", + "browser-run-v1/requests/valid/latex-at-limit-surrounded-by-blanks.json": "983d1e8fd5b8b2070c10be5c94f32938fefab31898ea4a5149ef7026cef0bcf4", + "browser-run-v1/requests/valid/natural-language-variant.json": "39d8995d7b7f616f5a86a456a7ab0f8f8cf1a2baba7ecb01e70d9d9f56f65608", + "browser-run-v1/requests/valid/note-id-long-max.json": "5c007e461054e72406bf3f3973f576a3ad62b123cbcccfb2090a49870f78dbc8", + "browser-run-v1/requests/valid/text-at-limit-astral.json": "f86591616a01b30d238628ff354f3b71b2819ba07e5e0844cc35293e52fd6f0f", + "browser-run-v1/requests/valid/text-at-limit-surrounded-by-blanks.json": "0cc8261dc183132a46d4650fcf3258f2868aea8af712fbebe5b0196773be361c", + "browser-run-v1/requests/valid/text-at-limit.json": "2755b7c6eb46cb633e8a5c64548ceb879398496e81d440c367666b3760898f48", + "browser-run-v1/requests/valid/viewer-focus-no-selection.json": "b46390558322bfdda202063e810c82a973e3007784902cc7110820bee746b453", + "browser-run-v1/responses/invalid/cancel-outcome-mismatch.json": "06ed12ca5746a526f6876cca03505c75be1f1d0c319848e6aa7c33cf5e4250cd", + "browser-run-v1/responses/invalid/cancel-with-fence-token.json": "19dc51bb8b1ca338512ecb6bb861fc5205b77031f651c3da6c43e14e6d188a19", + "browser-run-v1/responses/invalid/conversation-active-run-deadline.json": "c02c7378fbb9051db65d811bffeaa814b538ed162156c46e2f661823d73602ff", + "browser-run-v1/responses/invalid/conversation-active-run-terminal-status.json": "e3a3834f6d3f076884f633d561bb4a46796fa1e61039998004ba8eacfa2881ce", + "browser-run-v1/responses/invalid/conversation-completed-without-response.json": "cd9af5da6b5efbd9d56608c426c6a452fd7d7996b9ba4631a27fb38f70e864c5", + "browser-run-v1/responses/invalid/conversation-failure-class.json": "2f1044f292554b1ce875abb577bbe7a05d80bb8c024ad99dab0855d846d80a4e", + "browser-run-v1/responses/invalid/conversation-streaming-with-response.json": "ad60f461d7ede3ed5560d3a2e2cd895a211fa9a7bf143380da002d0364aa250d", + "browser-run-v1/responses/invalid/error-active-run-without-result.json": "ba4e39bba92fcd457c5679dccff6141d864079346a80ec533d00c4f89566a17e", + "browser-run-v1/responses/invalid/error-message-mismatch.json": "1fd1c528dc8df63986be448d36cde9a73e1a42c3db1c056d6324d9eba6f25d45", + "browser-run-v1/responses/invalid/error-result-on-request-id-conflict.json": "0af995527e1e810d00a6d8139e9f34ef7c4d7b008685c12b57b8babef7b7a8e6", + "browser-run-v1/responses/invalid/status-completed-without-disposition.json": "43e9b009d3b0216ac05fd86c5a4f15c8a1e7f238b8368200e37d1351d75bf4bf", + "browser-run-v1/responses/invalid/status-failed-without-failure.json": "9275b2daf5ef70a907f38572ab57ab3cb2e8b9b6fcfa0a446ad839247d9da78d", + "browser-run-v1/responses/invalid/status-retrying.json": "261fc733acda5f6857fae9daa680f927e591eabc638d49f843c3bd5a2aaeee88", + "browser-run-v1/responses/invalid/status-running-not-cancelable.json": "f48abe4142f71c12cf995fd34b454c04c8af541ec05d023ed685031523498cfd", + "browser-run-v1/responses/invalid/status-with-attempt.json": "8e25e7cf2e4c92bb25e32a1f6c155475e325359b74d7c799586a3e732c45b613", + "browser-run-v1/responses/invalid/status-with-usage.json": "66e633b3beae3a8131f0f0138f23185250f0f85c28c78577c9ca64c90521edf5", + "browser-run-v1/responses/valid/cancel-accepted-pending.json": "2c15831abd13efbab70091d79c609d16a63031986f0507df1b644973887dcc83", + "browser-run-v1/responses/valid/cancel-accepted-running.json": "b9561e2f95f2f159b51b44cfd64eb6978fea6001b279597e59e0ce207bf54dc9", + "browser-run-v1/responses/valid/cancel-already-canceling.json": "d1a0e24bda3cdfba1369467413ecfcce4aa9e22300c80af6582d94e222e9a273", + "browser-run-v1/responses/valid/cancel-already-terminal.json": "923887ec159561397c1b22872df70f5a2c9657e689aaeed5eca33df1586181fd", + "browser-run-v1/responses/valid/conversation-active-run.json": "be1245a9904bd3f3943e96f00d1d2fefc79911491614ed82e7d9af46a0adbaa9", + "browser-run-v1/responses/valid/conversation-clarification-failed-canceled.json": "8cdb0df31aef7aa6159d27d4718d5c5410c71dc6f46d3afee326e05cbead57bb", + "browser-run-v1/responses/valid/conversation-completed.json": "7861253413aaedd7cd74ba03c53effe8a6b10077473b236c418bd100ed5a7c40", + "browser-run-v1/responses/valid/error-auth4010.json": "a5e185c0fa489fd496206a52c8e9fabd604fa47d15787c9b842e219899523076", + "browser-run-v1/responses/valid/error-auth4012.json": "705433869250f733ecf0814a27acf8822fc1ec412d1ec40ce6e6d242971022ad", + "browser-run-v1/responses/valid/error-auth4013.json": "408622d94debc914cb939521132bf6dd72e67e97eb24954d17ed017571990b0d", + "browser-run-v1/responses/valid/error-run4001.json": "36b771d79e435b5e48eb77e43612abdfacf3373b37df550c9c8e9230672ff8ab", + "browser-run-v1/responses/valid/error-run4002.json": "911dfbceb125d97bd18aa6f7baae9993655efc7d37a7b7956050ef56d9d790b0", + "browser-run-v1/responses/valid/error-run4003.json": "faa2d9ae72be01cda9f5c06bccbfb62845647d1c7d686a4e0ae831fc7cd80253", + "browser-run-v1/responses/valid/error-run4004.json": "790da68d8d3876ec4c2cb4d1aa128325d0b363126d109ae5365909f916127eab", + "browser-run-v1/responses/valid/error-run4021.json": "2ae5cba6dc5e9a508269839676d643922914631b943a55d8163133ac4ad99d57", + "browser-run-v1/responses/valid/error-run4041.json": "d0aba1158f2138dbb8640b691ef4c27f011bbc3a810d197a7ac11e679bbb9dbc", + "browser-run-v1/responses/valid/error-run4091.json": "c9505ee8c5bbf11f6c242c5ba21060222213e69c266e3b80884987ba8901cfb4", + "browser-run-v1/responses/valid/error-run4092.json": "8565343bab892651af4ac139ca7c7fb67b7edf69e87c5df5ef762c18f46afa6d", + "browser-run-v1/responses/valid/error-run4221.json": "c929cc894dd7019756da7264779356ed322488533d20b24ab58d93b7e3388161", + "browser-run-v1/responses/valid/error-run4291.json": "ba3c2c3d362ae8a3599e69b38551ca2bdfe1eef14278483fc49afc15b7d6a7af", + "browser-run-v1/responses/valid/status-canceled.json": "a29004393921d10078c3dfe53d7475a08dc5cfdd82fafca5735f1abd0f360568", + "browser-run-v1/responses/valid/status-canceling.json": "a8d991c9162105a80e76372727ba0cdf26a97d0ff90f7d2c008db649e872ba52", + "browser-run-v1/responses/valid/status-completed-clarification.json": "f3d4d3162e46cc2255e9a5679030f3fd46455d188eaf84cbcc5dbd7517e10a5e", + "browser-run-v1/responses/valid/status-completed-substantive.json": "fdadde03f2a4d7291e8f9583cf8a4232a8d1b016978eaa1b61b7920af1bc88bb", + "browser-run-v1/responses/valid/status-failed-invalid-attachment.json": "1d6c44b8dfe55087a6b64e17cd886212f6b60371bc7a7a4e1796b404c9a2af28", + "browser-run-v1/responses/valid/status-failed.json": "2ca351b211c4169b983680d79e5f5cd7758ac001bcd4faf8662a776a6eb03111", + "browser-run-v1/responses/valid/status-pending.json": "31800c303144fdc60b687cff9a3255eb5c50eb3159630d141e439517453f9540", + "browser-run-v1/responses/valid/status-running.json": "27ea69d644229aace5a10dfc213abdaadeb644664906b301be9a6cb15f951244", + "browser-run-v1/schemas/browser-run.schema.json": "88a1b53b6e1e662f30c8eef2e8b7cb4031fab830eb19cd693df861963888d748", + "browser-run-v1/streams/invalid/activity-without-start.json": "c7d7ccc5e3adeed4abbaafcc0424761ac5c0e437aa76952216d02ed19f347d6d", + "browser-run-v1/streams/invalid/assistant-message-id-mismatch.json": "ef17a27a6d3fb52ea03d8cd58e715897472fcb7c398afec58bdfee30e87b98e0", + "browser-run-v1/streams/invalid/attempt-in-frame.json": "44e5965ccc6d2e5cef5f80eb9d2e7ae657ac4ab20fa27964946863efb7134678", + "browser-run-v1/streams/invalid/cancel-message-mismatch.json": "8810faabeb01e3ad82d2df56f693831d493027da94eba23ae85e7b641cecf254", + "browser-run-v1/streams/invalid/cancel-unknown-cause.json": "b41e8cee73c34f7d8dc629b787159c1926dc643eb2b75607e409fce0125f6f55", + "browser-run-v1/streams/invalid/event-after-terminal.json": "7136c96c648eace52eefec6323ec2927edbe4622284793179625aa407351ec2f", + "browser-run-v1/streams/invalid/failure-class-in-run-failed.json": "0f482044fda0371a22f6c41cddae0ecffb2bbb48ab427f3cc9a8fe5249ec779d", + "browser-run-v1/streams/invalid/fence-token-in-payload.json": "dd1246536878388263947d5acd9e167c0aa4eacd72e4bf4f4e2aded800a328df", + "browser-run-v1/streams/invalid/first-not-run-created.json": "ffd394b64238a6104a8800b9ae705efc8c4625db4b211f93b04d55d22e48c72b", + "browser-run-v1/streams/invalid/non-uuid-run-id.json": "849dfa732bcb7ad89e42877aa9548962acd26aee392cca0d7f24e9a225c7f796", + "browser-run-v1/streams/invalid/page-after-canceling.json": "4bd7868dc53a246f319243dddbc6feaee49266cea7905dea235a0b7d7c496b0f", + "browser-run-v1/streams/invalid/page-id-not-in-completed.json": "ec5a27b4969d9f119dd02b0112c725b09977e2b6ae21c6d5fcb3efc9a7ddaa68", + "browser-run-v1/streams/invalid/page-in-failed-run.json": "99ec7b7c5614a08dcfe3af0b9badfb4c08f71e49405233a6128dc965bad1885b", + "browser-run-v1/streams/invalid/page-order-mismatch.json": "ded9f8bea6d407c5324383ba5a8625159cf327a9f0cdca792e684d661605880e", + "browser-run-v1/streams/invalid/page-unpaired.json": "9a32a880d3d7abb1eb82ef94af4158f8da17258a9016a754dc6db7d63235304d", + "browser-run-v1/streams/invalid/retrying-status-in-run-created.json": "ea4e64b453ef6d70ca690238cace00fb563e015c3add4d990bad94e8c9bac8ee", + "browser-run-v1/streams/invalid/run-created-missing-note-id.json": "b9aae1dd7840faf5b3bb3aa35924d9e0bc3bb1d1d3d9e10ac6e99435f36f3ea9", + "browser-run-v1/streams/invalid/run-id-mismatch.json": "7e115d5e8cd8b28e3be82d2ce7f4068393fee4e534d345f0d47882a07a3f7e8c", + "browser-run-v1/streams/invalid/seq-gap.json": "86ce7da481a22e901ba07099cd3fe75e58883a0f85fad314b3140423468eb002", + "browser-run-v1/streams/invalid/source-event-id-in-frame.json": "0768008e191715fe700daa742a4f83261d7f3a107b70c3cf89c0a51995e4b59f", + "browser-run-v1/streams/invalid/two-terminals.json": "c3a9fb3f47f6b085a3ec5bd3a3f6e02c508b095859964e28e4e4aee892796b81", + "browser-run-v1/streams/valid/canceled-asset-deleting.json": "37cf1c5353f81236bf46ea51b5068e6e85547371ebb2233322a8c7a93117261b", + "browser-run-v1/streams/valid/canceled-pending.json": "104f74aaf4f743e6b0c79eacb178ae0d95070550f923907bf83f110ea8758275", + "browser-run-v1/streams/valid/canceled-running.json": "44d14b95f66a4b27b6884025bcad1bf63ecfad6d176feecbb24cb9a5a04a3ff7", + "browser-run-v1/streams/valid/clarification-completed.json": "dd460403e8f533949b674c8b748b4b5962cb9c5db8683b8f9079a0d2c7aa67ab", + "browser-run-v1/streams/valid/failed-invalid-attachment.json": "bb57570cdf9362cec603f511a71698a5ff74c643e5d08d22ef76d30024a19846", + "browser-run-v1/streams/valid/failed-verification.json": "b2dd18f9cd53e47646cb7bdb140e1b509f6f8b47f026e4d6ca5aacd0c33dda96", + "browser-run-v1/streams/valid/replay-source-unavailable.json": "c097b5ff341f5b94caccbcb48c4367835aaed37f08d2aa7ab67244593f68bbd4", + "browser-run-v1/streams/valid/substantive-completed.json": "1e65b881ed4c7ad4e0213ba33685d50f1febe02105d391c4aca2a14252198d24", + "browser-run-v1/streams/valid/substantive-with-source.json": "5a2348ebec89ba31960f87379b523e8414017b42e1d293d8a93d6cad476646a5", + "browser-run-v1/streams/valid/unsupported-only-completed.json": "8211d97c96f2956a2c1c6a0b8021bffefa461e2056a8276fa20031957cf4bbe4" + }, + "dependencies": { + "terminal-v2/browser.schema.json": "b0e906e24c85ec8faa25fda7fb7d44198a71c0c60d2244651f6732bbc0c0284d" + } +} diff --git a/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/attachment-not-uuid.json b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/attachment-not-uuid.json new file mode 100644 index 00000000..714593bf --- /dev/null +++ b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/attachment-not-uuid.json @@ -0,0 +1,15 @@ +{ + "rationale": "turnAttachmentIds 항목은 UUID.", + "request": { + "requestId": "7f460a7b-bf54-570e-a3af-b63a26435627", + "text": "이 문제를 풀어줘", + "turnAttachmentIds": [ + "att-1" + ] + }, + "schema_rejects": true, + "expected": { + "http": 400, + "code": "RUN4001" + } +} diff --git a/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/blank-text.json b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/blank-text.json new file mode 100644 index 00000000..ff534969 --- /dev/null +++ b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/blank-text.json @@ -0,0 +1,12 @@ +{ + "rationale": "strip 집합 공백만 있는 text.", + "request": { + "requestId": "7f460a7b-bf54-570e-a3af-b63a26435627", + "text": " \t   " + }, + "schema_rejects": true, + "expected": { + "http": 400, + "code": "RUN4001" + } +} diff --git a/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/duplicate-task.json b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/duplicate-task.json new file mode 100644 index 00000000..453eade8 --- /dev/null +++ b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/duplicate-task.json @@ -0,0 +1,16 @@ +{ + "rationale": "requestedTasks 중복.", + "request": { + "requestId": "7f460a7b-bf54-570e-a3af-b63a26435627", + "text": "이 문제를 풀어줘", + "requestedTasks": [ + "solve", + "solve" + ] + }, + "schema_rejects": true, + "expected": { + "http": 400, + "code": "RUN4001" + } +} diff --git a/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/empty-latex.json b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/empty-latex.json new file mode 100644 index 00000000..95da351a --- /dev/null +++ b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/empty-latex.json @@ -0,0 +1,13 @@ +{ + "rationale": "latex는 있으면 비어 있을 수 없다(null은 부재와 같음).", + "request": { + "requestId": "7f460a7b-bf54-570e-a3af-b63a26435627", + "text": "이 문제를 풀어줘", + "latex": "" + }, + "schema_rejects": true, + "expected": { + "http": 400, + "code": "RUN4001" + } +} diff --git a/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-attempt.json b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-attempt.json new file mode 100644 index 00000000..8d715dc8 --- /dev/null +++ b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-attempt.json @@ -0,0 +1,13 @@ +{ + "rationale": "브라우저는 attempt를 결정·전달하지 않는다.", + "request": { + "requestId": "7f460a7b-bf54-570e-a3af-b63a26435627", + "text": "이 문제를 풀어줘", + "attempt": "x" + }, + "schema_rejects": true, + "expected": { + "http": 400, + "code": "RUN4001" + } +} diff --git a/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-auth-token.json b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-auth-token.json new file mode 100644 index 00000000..9d1e962b --- /dev/null +++ b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-auth-token.json @@ -0,0 +1,13 @@ +{ + "rationale": "브라우저는 authToken를 결정·전달하지 않는다.", + "request": { + "requestId": "7f460a7b-bf54-570e-a3af-b63a26435627", + "text": "이 문제를 풀어줘", + "authToken": "x" + }, + "schema_rejects": true, + "expected": { + "http": 400, + "code": "RUN4001" + } +} diff --git a/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-budget.json b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-budget.json new file mode 100644 index 00000000..ba961858 --- /dev/null +++ b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-budget.json @@ -0,0 +1,13 @@ +{ + "rationale": "브라우저는 budget를 결정·전달하지 않는다.", + "request": { + "requestId": "7f460a7b-bf54-570e-a3af-b63a26435627", + "text": "이 문제를 풀어줘", + "budget": "x" + }, + "schema_rejects": true, + "expected": { + "http": 400, + "code": "RUN4001" + } +} diff --git a/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-conversation-id.json b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-conversation-id.json new file mode 100644 index 00000000..c0ee5c2f --- /dev/null +++ b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-conversation-id.json @@ -0,0 +1,13 @@ +{ + "rationale": "브라우저는 conversationId를 결정·전달하지 않는다.", + "request": { + "requestId": "7f460a7b-bf54-570e-a3af-b63a26435627", + "text": "이 문제를 풀어줘", + "conversationId": "x" + }, + "schema_rejects": true, + "expected": { + "http": 400, + "code": "RUN4001" + } +} diff --git a/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-corpus-version.json b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-corpus-version.json new file mode 100644 index 00000000..6b833bfd --- /dev/null +++ b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-corpus-version.json @@ -0,0 +1,13 @@ +{ + "rationale": "브라우저는 corpusVersion를 결정·전달하지 않는다.", + "request": { + "requestId": "7f460a7b-bf54-570e-a3af-b63a26435627", + "text": "이 문제를 풀어줘", + "corpusVersion": "x" + }, + "schema_rejects": true, + "expected": { + "http": 400, + "code": "RUN4001" + } +} diff --git a/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-credit.json b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-credit.json new file mode 100644 index 00000000..fdf42d2e --- /dev/null +++ b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-credit.json @@ -0,0 +1,13 @@ +{ + "rationale": "브라우저는 credit를 결정·전달하지 않는다.", + "request": { + "requestId": "7f460a7b-bf54-570e-a3af-b63a26435627", + "text": "이 문제를 풀어줘", + "credit": "x" + }, + "schema_rejects": true, + "expected": { + "http": 400, + "code": "RUN4001" + } +} diff --git a/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-deadline.json b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-deadline.json new file mode 100644 index 00000000..af96552a --- /dev/null +++ b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-deadline.json @@ -0,0 +1,13 @@ +{ + "rationale": "브라우저는 deadline를 결정·전달하지 않는다.", + "request": { + "requestId": "7f460a7b-bf54-570e-a3af-b63a26435627", + "text": "이 문제를 풀어줘", + "deadline": "x" + }, + "schema_rejects": true, + "expected": { + "http": 400, + "code": "RUN4001" + } +} diff --git a/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-fence-token.json b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-fence-token.json new file mode 100644 index 00000000..ed9a261f --- /dev/null +++ b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-fence-token.json @@ -0,0 +1,13 @@ +{ + "rationale": "브라우저는 fenceToken를 결정·전달하지 않는다.", + "request": { + "requestId": "7f460a7b-bf54-570e-a3af-b63a26435627", + "text": "이 문제를 풀어줘", + "fenceToken": "x" + }, + "schema_rejects": true, + "expected": { + "http": 400, + "code": "RUN4001" + } +} diff --git a/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-history.json b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-history.json new file mode 100644 index 00000000..3da94d42 --- /dev/null +++ b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-history.json @@ -0,0 +1,13 @@ +{ + "rationale": "브라우저는 history를 결정·전달하지 않는다.", + "request": { + "requestId": "7f460a7b-bf54-570e-a3af-b63a26435627", + "text": "이 문제를 풀어줘", + "history": "x" + }, + "schema_rejects": true, + "expected": { + "http": 400, + "code": "RUN4001" + } +} diff --git a/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-input-scope.json b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-input-scope.json new file mode 100644 index 00000000..47c476dc --- /dev/null +++ b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-input-scope.json @@ -0,0 +1,13 @@ +{ + "rationale": "브라우저는 inputScope를 결정·전달하지 않는다.", + "request": { + "requestId": "7f460a7b-bf54-570e-a3af-b63a26435627", + "text": "이 문제를 풀어줘", + "inputScope": "x" + }, + "schema_rejects": true, + "expected": { + "http": 400, + "code": "RUN4001" + } +} diff --git a/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-locale.json b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-locale.json new file mode 100644 index 00000000..8819a424 --- /dev/null +++ b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-locale.json @@ -0,0 +1,13 @@ +{ + "rationale": "브라우저는 locale를 결정·전달하지 않는다.", + "request": { + "requestId": "7f460a7b-bf54-570e-a3af-b63a26435627", + "text": "이 문제를 풀어줘", + "locale": "x" + }, + "schema_rejects": true, + "expected": { + "http": 400, + "code": "RUN4001" + } +} diff --git a/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-retrieval-scope.json b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-retrieval-scope.json new file mode 100644 index 00000000..faf4c906 --- /dev/null +++ b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-retrieval-scope.json @@ -0,0 +1,13 @@ +{ + "rationale": "브라우저는 retrievalScope를 결정·전달하지 않는다.", + "request": { + "requestId": "7f460a7b-bf54-570e-a3af-b63a26435627", + "text": "이 문제를 풀어줘", + "retrievalScope": "x" + }, + "schema_rejects": true, + "expected": { + "http": 400, + "code": "RUN4001" + } +} diff --git a/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-run-id.json b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-run-id.json new file mode 100644 index 00000000..70d1bb6f --- /dev/null +++ b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-run-id.json @@ -0,0 +1,13 @@ +{ + "rationale": "브라우저는 runId를 결정·전달하지 않는다.", + "request": { + "requestId": "7f460a7b-bf54-570e-a3af-b63a26435627", + "text": "이 문제를 풀어줘", + "runId": "x" + }, + "schema_rejects": true, + "expected": { + "http": 400, + "code": "RUN4001" + } +} diff --git a/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-scope.json b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-scope.json new file mode 100644 index 00000000..7dfdd754 --- /dev/null +++ b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-scope.json @@ -0,0 +1,13 @@ +{ + "rationale": "브라우저는 scope를 결정·전달하지 않는다.", + "request": { + "requestId": "7f460a7b-bf54-570e-a3af-b63a26435627", + "text": "이 문제를 풀어줘", + "scope": "x" + }, + "schema_rejects": true, + "expected": { + "http": 400, + "code": "RUN4001" + } +} diff --git a/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-turn-attachment-refs.json b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-turn-attachment-refs.json new file mode 100644 index 00000000..00c207a8 --- /dev/null +++ b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-turn-attachment-refs.json @@ -0,0 +1,13 @@ +{ + "rationale": "브라우저는 turnAttachmentRefs를 결정·전달하지 않는다.", + "request": { + "requestId": "7f460a7b-bf54-570e-a3af-b63a26435627", + "text": "이 문제를 풀어줘", + "turnAttachmentRefs": "x" + }, + "schema_rejects": true, + "expected": { + "http": 400, + "code": "RUN4001" + } +} diff --git a/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-usage.json b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-usage.json new file mode 100644 index 00000000..f386c983 --- /dev/null +++ b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-usage.json @@ -0,0 +1,13 @@ +{ + "rationale": "브라우저는 usage를 결정·전달하지 않는다.", + "request": { + "requestId": "7f460a7b-bf54-570e-a3af-b63a26435627", + "text": "이 문제를 풀어줘", + "usage": "x" + }, + "schema_rejects": true, + "expected": { + "http": 400, + "code": "RUN4001" + } +} diff --git a/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-user-id.json b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-user-id.json new file mode 100644 index 00000000..f23b6fc7 --- /dev/null +++ b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-user-id.json @@ -0,0 +1,13 @@ +{ + "rationale": "브라우저는 userId를 결정·전달하지 않는다.", + "request": { + "requestId": "7f460a7b-bf54-570e-a3af-b63a26435627", + "text": "이 문제를 풀어줘", + "userId": "x" + }, + "schema_rejects": true, + "expected": { + "http": 400, + "code": "RUN4001" + } +} diff --git a/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-verified-solution-refs.json b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-verified-solution-refs.json new file mode 100644 index 00000000..47354fc8 --- /dev/null +++ b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/forbidden-verified-solution-refs.json @@ -0,0 +1,13 @@ +{ + "rationale": "브라우저는 verifiedSolutionRefs를 결정·전달하지 않는다.", + "request": { + "requestId": "7f460a7b-bf54-570e-a3af-b63a26435627", + "text": "이 문제를 풀어줘", + "verifiedSolutionRefs": "x" + }, + "schema_rejects": true, + "expected": { + "http": 400, + "code": "RUN4001" + } +} diff --git a/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/latex-too-long.json b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/latex-too-long.json new file mode 100644 index 00000000..e749253e --- /dev/null +++ b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/latex-too-long.json @@ -0,0 +1,13 @@ +{ + "rationale": "latex strip 후 10001 code point — schema는 통과, 계약 검사(RUN4001).", + "request": { + "requestId": "7f460a7b-bf54-570e-a3af-b63a26435627", + "text": "이 문제를 풀어줘", + "latex": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + }, + "schema_rejects": false, + "expected": { + "http": 400, + "code": "RUN4001" + } +} diff --git a/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/missing-request-id.json b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/missing-request-id.json new file mode 100644 index 00000000..1ba9424c --- /dev/null +++ b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/missing-request-id.json @@ -0,0 +1,11 @@ +{ + "rationale": "requestId는 body 필수.", + "request": { + "text": "이 문제를 풀어줘" + }, + "schema_rejects": true, + "expected": { + "http": 400, + "code": "RUN4001" + } +} diff --git a/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/missing-text.json b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/missing-text.json new file mode 100644 index 00000000..9abd9641 --- /dev/null +++ b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/missing-text.json @@ -0,0 +1,11 @@ +{ + "rationale": "text 필수.", + "request": { + "requestId": "7f460a7b-bf54-570e-a3af-b63a26435627" + }, + "schema_rejects": true, + "expected": { + "http": 400, + "code": "RUN4001" + } +} diff --git a/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/non-uuid-request-id.json b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/non-uuid-request-id.json new file mode 100644 index 00000000..264bb15d --- /dev/null +++ b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/non-uuid-request-id.json @@ -0,0 +1,12 @@ +{ + "rationale": "UUID가 아닌 requestId.", + "request": { + "requestId": "req-1", + "text": "이 문제를 풀어줘" + }, + "schema_rejects": true, + "expected": { + "http": 400, + "code": "RUN4001" + } +} diff --git a/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/note-asset-id-trailing-newline.json b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/note-asset-id-trailing-newline.json new file mode 100644 index 00000000..a1db6a5a --- /dev/null +++ b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/note-asset-id-trailing-newline.json @@ -0,0 +1,16 @@ +{ + "rationale": "viewerFocus.noteAssetId 끝 줄바꿈.", + "request": { + "requestId": "7f460a7b-bf54-570e-a3af-b63a26435627", + "text": "이 문제를 풀어줘", + "viewerFocus": { + "noteAssetId": "asset-9f8c\n", + "page": 1 + } + }, + "schema_rejects": true, + "expected": { + "http": 400, + "code": "RUN4001" + } +} diff --git a/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/note-id-leading-zero.json b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/note-id-leading-zero.json new file mode 100644 index 00000000..c3f3085e --- /dev/null +++ b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/note-id-leading-zero.json @@ -0,0 +1,13 @@ +{ + "rationale": "noteId 선행 0 금지.", + "request": { + "requestId": "7f460a7b-bf54-570e-a3af-b63a26435627", + "text": "이 문제를 풀어줘", + "noteId": "0123" + }, + "schema_rejects": true, + "expected": { + "http": 400, + "code": "RUN4001" + } +} diff --git a/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/note-id-number.json b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/note-id-number.json new file mode 100644 index 00000000..e11a96b2 --- /dev/null +++ b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/note-id-number.json @@ -0,0 +1,13 @@ +{ + "rationale": "noteId는 JSON number가 아니라 10진 문자열.", + "request": { + "requestId": "7f460a7b-bf54-570e-a3af-b63a26435627", + "text": "이 문제를 풀어줘", + "noteId": 123 + }, + "schema_rejects": true, + "expected": { + "http": 400, + "code": "RUN4001" + } +} diff --git a/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/note-id-over-long-max.json b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/note-id-over-long-max.json new file mode 100644 index 00000000..46fdd96e --- /dev/null +++ b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/note-id-over-long-max.json @@ -0,0 +1,13 @@ +{ + "rationale": "noteId가 Java Long.MAX_VALUE 초과 — 404가 아니라 형식 오류.", + "request": { + "requestId": "7f460a7b-bf54-570e-a3af-b63a26435627", + "text": "이 문제를 풀어줘", + "noteId": "9223372036854775808" + }, + "schema_rejects": true, + "expected": { + "http": 400, + "code": "RUN4001" + } +} diff --git a/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/note-id-trailing-newline.json b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/note-id-trailing-newline.json new file mode 100644 index 00000000..7baf1d90 --- /dev/null +++ b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/note-id-trailing-newline.json @@ -0,0 +1,13 @@ +{ + "rationale": "noteId 끝 줄바꿈.", + "request": { + "requestId": "7f460a7b-bf54-570e-a3af-b63a26435627", + "text": "이 문제를 풀어줘", + "noteId": "123\n" + }, + "schema_rejects": true, + "expected": { + "http": 400, + "code": "RUN4001" + } +} diff --git a/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/request-id-trailing-newline.json b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/request-id-trailing-newline.json new file mode 100644 index 00000000..6beec17c --- /dev/null +++ b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/request-id-trailing-newline.json @@ -0,0 +1,12 @@ +{ + "rationale": "requestId 끝 줄바꿈 — 패턴은 입력 끝까지 고정한다($의 줄바꿈 허용 금지).", + "request": { + "requestId": "7f460a7b-bf54-570e-a3af-b63a26435627\n", + "text": "이 문제를 풀어줘" + }, + "schema_rejects": true, + "expected": { + "http": 400, + "code": "RUN4001" + } +} diff --git a/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/selection-inverted.json b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/selection-inverted.json new file mode 100644 index 00000000..c600dc56 --- /dev/null +++ b/src/test/resources/contracts/brain/browser-run-v1/requests/invalid/selection-inverted.json @@ -0,0 +1,22 @@ +{ + "rationale": "left