Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,8 @@ dependencyManagement {

dependencies {
implementation("org.springframework.boot:spring-boot-starter-webmvc")
implementation("org.springframework.boot:spring-boot-starter-validation")

// OpenAPI 문서 노출(/v3/api-docs, UI 없음). 계약의 원문은 docs/api-contract.md.
// OpenAPI 문서 노출(/v3/api-docs, UI 없음). 계약 정본은 infra 의 contracts/.
// Spring Boot 4 / Jackson 3 호환 라인 — core 와 동일.
implementation("org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.3")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ public static ExtractionResult from(ProductSnapshot snapshot) {
result.setFinalUrl(snapshot.finalUrl().value().toString());
}
if (snapshot.method() != null) {
// 이름이 어긋나면 여기서 터진다 - 계약 대조 테스트가 먼저 잡는다.
// 이름이 어긋나면 여기서 터진다 - ExtractionMethodContractTest 가 먼저 잡는다.
result.setMethod(ExtractionMethod.valueOf(snapshot.method().name()));
}
return result.build();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

// 값을 만들어낸 추출 경로. 호출자(core)가 snapshot 출처를 구분 저장하는 근거다(core#825 결정 4) —
// LLM 경로는 같은 페이지를 재추출해도 값이 달라질 수 있어(비결정성 실측), 이후 "LLM 추출분은 가격 변동
// 알림 제외" 같은 신뢰 정책이 이 구분 위에서 갈린다. 응답에는 enum 이름 문자열로 나간다(계약 §2).
// 알림 제외" 같은 신뢰 정책이 이 구분 위에서 갈린다. 계약 enum(extraction.proto 의 ExtractionMethod)과 이름이 1:1 이어야 한다.
public enum ExtractionMethod {
// 구조화 데이터(JSON-LD·OpenGraph) 직접 파싱 — 결정론적.
STRUCTURED,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public class GeminiHtmlExtractor {

private final GeminiClient geminiClient;

/** @param model 호출자가 지정한 모델(없으면 null). 판정·대체는 클라이언트가 지므로 여기서는 그대로 넘긴다. */
/** @param model 호출자가 지정한 모델(없으면 빈 문자열). 판정·대체는 클라이언트가 지므로 여기서는 그대로 넘긴다. */
public ProductSnapshot extract(Document document, ProductLink link, String model) {
String html = sanitize(document);
GeminiExtractionRequest request = GeminiExtractionRequest.forHtmlExtraction(link.value(), html);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,8 @@
* @param readTimeout 렌더 서비스 내부 최악(goto + 렌더 정착 대기)을 다 기다리지는 않는다 — 호출자 read 예산 안에
* LLM fallback 몫을 남겨야 하기 때문이다. 층별 예산의 정본은 TeamPiKi/infra 의 contracts/extraction-api.md
* "타임아웃 예산" 절. 상한을 넘긴 렌더는 일시 실패로 떨어져 호출자 재시도가 진다.
* @param compress 응답 zstd 압축 전송 요청(서버간 전송량 절감). 해제는 응답 헤더({@code X-Encoding}) 기준이라
* compress 필드를 모르는 구버전 renderer(무시하고 plain JSON 으로 답한다)와도 호환된다 — 켜 둔 채로 배포
* 순서와 무관하게 안전하고, 이 스위치는 압축 경로에 문제가 생겼을 때 끄는 kill-switch 다.
* @param compress 응답 zstd 압축 전송 요청(서버간 전송량 절감). 해제는 응답 헤더({@code X-Encoding}) 기준이고,
* 이 스위치는 압축 경로에 문제가 생겼을 때 끄는 kill-switch 다.
* @param zstdDictDir zstd 학습 사전 디렉토리(파일명 = 사전ID, renderer {@code compress.py} 의 DICT_ID 규약).
* 빈값이면 사전 없음. 롤아웃 순서: 사전 파일을 여기 먼저 배포한 뒤 renderer 쪽 사전 경로를 켠다 — 순서가
* 뒤집히면 미보유 사전ID 를 받아 해제 불가(일시 실패)가 된다.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ public class HtmlSnapshotPipeline {
* @param timing 호출 전략이 채우는 로그 조각({@code "fetch=123ms"} / {@code "render=5534ms"}) — HTML 획득 비용을
* extract 원장 로그 한 줄에 함께 남긴다. 전략 무관 파이프라인이 유일하게 전략을 아는 지점이라 해석하지 않는
* 문자열로만 받는다.
* @param model 호출자가 지정한 LLM 모델(없으면 null). 구조화 파싱으로 끝나면 쓰이지 않고, LLM fallback 으로
* @param model 호출자가 지정한 LLM 모델(없으면 빈 문자열). 구조화 파싱으로 끝나면 쓰이지 않고, LLM fallback 으로
* 내려갈 때만 소비된다.
*/
public ProductSnapshot extract(PageContent page, String timing, String model) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public interface LinkExtractionStrategy {
* @param authorized 이 대상이 허락을 받았는가. 정적 fetch 전략에는 우회 개념이 없어 무시되고, 헤드리스
* 전략만 렌더 서비스로 전달한다. 두 전략이 같은 시그니처를 갖는 편이 호출부(Fallback)가 분기를
* 들고 있는 것보다 낫다 — 전략이 늘어도 호출부가 안 바뀐다.
* @param model 호출자가 지정한 LLM 모델(없으면 null). 전략은 해석하지 않고 파이프라인까지 흘려보낸다.
* @param model 호출자가 지정한 LLM 모델(없으면 빈 문자열). 전략은 해석하지 않고 파이프라인까지 흘려보낸다.
*/
ProductSnapshot extract(ProductLink link, boolean authorized, String model);
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ public interface ProductLinkExtractor {
* 진실은 호출자(core)에 있고, 무상태인 이 서비스는 요청 단위로만 받는다. 누락은 false 로 정규화되는
* fail-safe 다(proto3 의 기본값 규칙).
* @param model 호출자가 지정한 LLM 모델 힌트. authorized 와 같은 성질이다 — 정책의 단일 진실은 호출자 쪽
* 동적 설정(DB, 백오피스)이고 무상태인 이 서비스는 요청 단위로만 받는다. null 이면 기본 모델을 쓰며,
* 동적 설정(DB, 백오피스)이고 무상태인 이 서비스는 요청 단위로만 받는다. 빈 문자열(null 도 같다)이면 기본 모델을 쓰며,
* 지정 모델이 사라졌으면 기본 모델로 대체된다.
*/
ProductSnapshot extract(ProductLink link, boolean authorized, String model);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,9 @@
* <p>내부 주소 판정은 renderer 가 브라우저의 실제 연결 IP 로 하고 {@code internal_address_hit} 로 알린다. 여기서 홉 URL 을
* 다시 이름 해석하면 렌더와 다른 시점의 답이라 브라우저가 실제로 간 IP 와 어긋날 수 있어 하지 않는다.
*
* <p>전송: 요청 compress=true 면 응답이 zstd raw 바이트로 온다. 해제 분기는 요청이 아니라 응답 헤더
* ({@code X-Encoding: zstd}) 기준 — compress 를 모르는 구버전 renderer 는 plain JSON 을 주므로, 헤더 분기여야
* renderer 와 어느 쪽이 먼저 배포되든 안전하다. 사전({@code X-Zstd-Dict})은 {@code ZstdDictionaries} 의
* 롤아웃 규약 참조.
* <p>전송: 요청 compress=true 면 응답이 zstd raw 바이트로 온다. 압축 여부의 판별은 응답 헤더
* ({@code X-Encoding: zstd}) 기준(render.proto 의 compress 주석). 사전({@code X-Zstd-Dict})은
* {@code ZstdDictionaries} 의 롤아웃 규약 참조.
*/
@Slf4j
@RequiredArgsConstructor
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ public class ImageExtractionService {
private final ImageCropper imageCropper;
private final ImageStorage imageStorage;

/** @param model 호출자가 지정한 LLM 모델(없으면 null). 해석·대체는 Gemini 클라이언트가 지므로 그대로 넘긴다. */
/** @param model 호출자가 지정한 LLM 모델(없으면 빈 문자열). 해석·대체는 Gemini 클라이언트가 지므로 그대로 넘긴다. */
public ProductSnapshot extract(String bucket, String key, String model) {
StoredImage stored = imageStorage.download(bucket, key);
// download 가 S3 content-type 메타를 못 주면(메타 유실 등) 등록 때 key 에 박은 확장자로 mimeType 을 복원한다 —
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ void unsetFieldsAreOmitted() throws Exception {
}

@Test
@DisplayName("image 요청의 model null 은 생략과 같게 읽힌다 - 전환 전 호출자는 미지정을 null 로 보낸다")
@DisplayName("image 요청의 model null 은 생략과 같게 읽힌다 - 호출자가 null 을 보내도 깨지지 않는다")
void nullModelReadsAsUnset() throws Exception {
ImageExtractionRequest.Builder builder = ImageExtractionRequest.newBuilder();
PARSER.merge("{\"bucket\": \"b\", \"key\": \"items/raw/a.png\", \"model\": null}", builder);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
import org.springframework.web.context.WebApplicationContext;

/**
* {@code POST /internal/extractions/image} 의 HTTP 계약(docs/api-contract.md §2)을 실제 파이프라인
* {@code POST /internal/extractions/image} 의 HTTP 계약(infra extraction-api.md §2)을 실제 파이프라인
* (download→OCR→crop→upload)으로 검증한다. 외부 경계인 S3({@code ImageStorage})·GeminiClient 만 stub 이다.
*/
class ExtractionImageIntegrationTest extends IntegrationTestSupport {
Expand Down Expand Up @@ -68,7 +68,7 @@ void imageSuccess() throws Exception {
.andExpect(jsonPath("$.currentPrice").value(89000))
.andExpect(jsonPath("$.currency").value("KRW"))
.andExpect(jsonPath("$.imageUrl").value(StubImageStorage.UPLOADED_URL))
// additive 계약(core#825): 이미지 경로는 원본 URL 이 없어 finalUrl 이 명시적 null 이고, 추출은 Gemini 라 method=LLM.
// additive 계약(core#825): 이미지 경로는 원본 URL 이 없어 finalUrl 은 생략되고, 추출은 Gemini 라 method=LLM.
.andExpect(jsonPath("$.finalUrl").doesNotExist())
.andExpect(jsonPath("$.method").value("LLM"));
}
Expand Down Expand Up @@ -109,7 +109,7 @@ void boundingBoxCropIsWiredToUpload() throws Exception {
.content(body("items/raw/crop.png")))
.andExpect(status().isOk())
.andExpect(jsonPath("$.imageUrl").value(StubImageStorage.UPLOADED_URL))
// additive 계약(core#825): 이미지 경로는 원본 URL 이 없어 finalUrl 이 명시적 null 이고, 추출은 Gemini 라 method=LLM.
// additive 계약(core#825): 이미지 경로는 원본 URL 이 없어 finalUrl 은 생략되고, 추출은 Gemini 라 method=LLM.
.andExpect(jsonPath("$.finalUrl").doesNotExist())
.andExpect(jsonPath("$.method").value("LLM"));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,28 @@ void escalatableMatchesFactories() {
}
}

@Test
@DisplayName("계약의 probe_only 는 모델 프로브 예외가 만드는 code 에만 붙는다")
void probeOnlyMatchesFactories() {
// 프로브 code 가 추출 응답으로 새면 호출자가 bucket 없는 확정 실패를 받는다 - 축이 섞이지 않게 양방향으로 묶는다.
List<String> mismatches = new ArrayList<>();
for (FactoryCase factoryCase : factoryCases()) {
ExtractionException exception = factoryCase.exception();
boolean declared = com.depromeet.piki.contracts.extraction.v1.ExtractionErrorCode
.valueOf(exception.code().name())
.getValueDescriptor()
.getOptions()
.getExtension(ExtractionProto.probeOnly);
boolean actual = exception instanceof ModelProbeException;
if (declared != actual) {
mismatches.add(exception.code() + " probe_only: 계약=" + declared + " / " + factoryCase.name() + "=" + actual);
}
}
if (!mismatches.isEmpty()) {
fail("계약의 probe_only 와 예외 팩토리가 어긋난다.\n " + String.join("\n ", mismatches));
}
}

/** 이름이 계약에 없으면 여기서 터진다 - 목록 어긋남은 enumMatchesContract 가 먼저 지목한다. */
private static Disposition declaredDisposition(String code) {
return com.depromeet.piki.contracts.extraction.v1.ExtractionErrorCode.valueOf(code)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.depromeet.piki.extractor.domain;

import static org.junit.jupiter.api.Assertions.assertEquals;

import java.util.Arrays;
import java.util.Set;
import java.util.TreeSet;
import java.util.stream.Collectors;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

/** 응답 매퍼가 도메인 이름으로 계약 enum 을 찾으므로, 목록이 어긋나면 그 경로의 성공 응답이 500 으로 샌다. */
class ExtractionMethodContractTest {

@Test
@DisplayName("ExtractionMethod 상수 집합은 계약 enum 의 값 집합과 정확히 일치한다")
void enumMatchesContract() {
Set<String> contract = Arrays.stream(com.depromeet.piki.contracts.extraction.v1.ExtractionMethod.values())
.filter(value -> value != com.depromeet.piki.contracts.extraction.v1.ExtractionMethod.UNRECOGNIZED)
.filter(value -> value.getNumber() != 0)
.map(Enum::name)
.collect(Collectors.toCollection(TreeSet::new));
Set<String> domain = Arrays.stream(ExtractionMethod.values())
.map(Enum::name)
.collect(Collectors.toCollection(TreeSet::new));

assertEquals(contract, domain);
}
}
Loading