Skip to content

Commit 47032ec

Browse files
authored
Merge pull request #213 from Team-StackUp/feat/markdown-contract
feat: AI 텍스트 포맷 계약 + 마크다운 렌더러 도입
2 parents 2916032 + d92972e commit 47032ec

29 files changed

Lines changed: 2120 additions & 92 deletions

ai/src/ai_server/chain/prompts/answer_coaching.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
"없는 경험을 지어내지 말고, 있는 내용을 구조(두괄식·근거·결과)와 구체성으로 보강. 답변이 비었거나 "
1414
"'모르겠다'면 '이렇게 접근했다면' 식으로 짧게.\n"
1515
"- **coaching_comment (한 줄 코칭)**: 이 답변에서 가장 중요한 보완점 하나를 한 문장으로.\n"
16+
"포맷: model_answer 와 answer_rewrite 는 필요하면 **GFM 마크다운**(굵게·리스트·코드 블록)을 "
17+
"사용해도 됩니다 — 화면이 마크다운으로 렌더합니다. coaching_comment 는 서식 기호 없는 "
18+
"일반 텍스트 한 문장으로.\n"
1619
"- **질문이 경험·행동·자소서 기반**('그때 무엇을 어떻게 했는가', 지원동기·성장·갈등 등)이면 "
1720
"model_answer 와 answer_rewrite 를 **STAR(상황·과제·행동·결과) 골격**으로 구성하고, 본인의 구체적 "
1821
"행동·정량적 결과·배운 점이 드러나게 코칭하세요. (기술 개념 질문이면 STAR 대신 정확성·깊이 우선.)\n"

ai/src/ai_server/chain/prompts/feedback_generation.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
" - logic_score: 논리·인과관계 명확성\n"
1111
" - communication_score: 답변의 명료성·구조화\n"
1212
"- 요약:\n"
13+
" - 모든 텍스트 필드는 마크다운 서식 기호 없이 일반 텍스트로 작성 (화면이 plain text 렌더).\n"
1314
" - strengths_summary: 가장 잘한 점 3가지 이내 (각 1~2문장).\n"
1415
" - weaknesses_summary: 가장 부족한 점 3가지 이내 (각 1~2문장).\n"
1516
" - improvement_keywords: 다음 면접에서 채울 키워드 5~10개 (짧은 명사구).\n"

ai/src/ai_server/chain/prompts/feedback_panel.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"- 점수를 매기기 전에 강점/약점 근거를 먼저 정리한 뒤 산정한다(즉흥 점수 금지).\n"
1515
"- strength/weakness 는 각각 한 줄(한국어, 구체적으로). keywords 는 이 축에서 보완할 "
1616
"개선 키워드 0~4개(짧은 명사구).\n"
17+
"- 모든 텍스트 필드는 마크다운 서식 기호 없이 일반 텍스트로 작성하세요 (화면이 plain text 렌더).\n"
1718
"- detail: 이 축에 대한 2~4문장 상세 평가. **답변의 구체적 부분을 인용/지목**하고 무엇이 "
1819
"왜 좋았는지/아쉬웠는지 근거를 들어 서술한다(추상적 총평 금지).\n"
1920
"- score_rationale: 그 점수를 준 핵심 근거(가점/감점 요인)를 한두 문장으로.\n"

ai/src/ai_server/chain/prompts/feedback_synthesis.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
"- improvement_keywords: 다음 면접에서 보완할 키워드 5~10개(짧은 명사구).\n"
1212
"- study_plan: 구체적 학습 방향/다음 단계 액션 아이템 3~6개. 각 항목은 '무엇을 어떻게'가 "
1313
"드러나는 실행 문장(예: 'Redis 분산 락의 SETNX·TTL 옵션을 직접 구현해보며 원자성 보장 원리 정리').\n"
14+
"모든 텍스트 필드는 **마크다운 서식 기호 없이 일반 텍스트**로 작성하세요(굵게 **, 리스트 -, "
15+
"코드 ` 금지) — 화면이 plain text 로 렌더하고, highlights 는 원문 부분 문자열 매칭에 쓰입니다.\n"
1416
"- highlights: 지원자가 꼭 기억해야 할 **가장 중요한 핵심 구절 3~6개**. 화면에서 강조 표시할 "
1517
"용도라, 반드시 위 strengths_summary·weaknesses_summary 본문에 **그대로 등장한 짧은 구절"
1618
"(각 2~12어절)을 글자 그대로 발췌**하세요(새로 짓거나 바꿔쓰지 말 것 — 부분 문자열 매칭에 사용). "

ai/src/ai_server/chain/prompts/question_generation.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"- 카테고리는 다음 중에서 선택: CS_FUNDAMENTAL, PROJECT_DEEP_DIVE, TECH_CHOICE, "
1717
"BEHAVIORAL.\n"
1818
"- 한국어로 작성하되 기술 용어는 영문 원어를 그대로 둡니다.\n"
19+
"- 질문·근거 텍스트에는 마크다운 서식 기호를 쓰지 마세요 (화면이 plain text 렌더, 스트리밍 표시).\n"
1920
"- 질문 문장은 **간결하게**: 한두 문장(대략 80자 이내)으로, 장황한 배경 설명이나 "
2021
"중복 수식 없이 핵심만 묻습니다. 면접관이 입으로 자연스럽게 말할 길이여야 합니다.\n"
2122
"- **직무 맞춤(타깃 회사/JD 제공 시)**: 먼저 채용공고에서 **핵심 요구 역량 5~8개를 뽑아** "

backend/openapi.json

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2842,6 +2842,69 @@
28422842
}
28432843
}
28442844
},
2845+
"/api/documents/{documentId}/content" : {
2846+
"get" : {
2847+
"tags" : [ "Documents" ],
2848+
"summary" : "분석 원문(마크다운) 프록시",
2849+
"description" : "presigned URL 은 내부(MinIO) 호스트라 브라우저가 직접 접근할 수 없어 Core 가 원문 바이트를 중계한다 (TTS 오디오 프록시와 동일 패턴).",
2850+
"operationId" : "getAnalyzedDocumentContent",
2851+
"parameters" : [ {
2852+
"name" : "documentId",
2853+
"in" : "path",
2854+
"required" : true,
2855+
"schema" : {
2856+
"type" : "integer",
2857+
"format" : "int64"
2858+
}
2859+
} ],
2860+
"responses" : {
2861+
"200" : {
2862+
"description" : "분석 원문 (text/markdown)",
2863+
"content" : {
2864+
"*/*" : {
2865+
"schema" : {
2866+
"type" : "string",
2867+
"format" : "binary"
2868+
}
2869+
}
2870+
}
2871+
},
2872+
"401" : {
2873+
"description" : "인증 실패",
2874+
"content" : {
2875+
"*/*" : {
2876+
"schema" : {
2877+
"type" : "string",
2878+
"format" : "binary"
2879+
}
2880+
}
2881+
}
2882+
},
2883+
"404" : {
2884+
"description" : "분석 문서 없음",
2885+
"content" : {
2886+
"*/*" : {
2887+
"schema" : {
2888+
"type" : "string",
2889+
"format" : "binary"
2890+
}
2891+
}
2892+
}
2893+
},
2894+
"422" : {
2895+
"description" : "아직 분석 산출물이 없음",
2896+
"content" : {
2897+
"*/*" : {
2898+
"schema" : {
2899+
"type" : "string",
2900+
"format" : "binary"
2901+
}
2902+
}
2903+
}
2904+
}
2905+
}
2906+
}
2907+
},
28452908
"/api/auth/google/callback" : {
28462909
"get" : {
28472910
"tags" : [ "Auth" ],

backend/src/main/java/com/stackup/stackup/document/application/AnalyzedDocumentQueryService.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,17 @@ public AnalyzedDocumentResult getForUser(Long userId, Long documentId) {
5353
return AnalyzedDocumentResult.of(doc, parseTechStack(doc.getTechStack()), downloadUrl);
5454
}
5555

56+
// 분석 원문(마크다운) 프록시 — presigned URL 은 내부(MinIO) 호스트라 브라우저가 직접 접근할
57+
// 수 없다(TTS 오디오 프록시와 동일 이유). 소유권 검증 후 Core 가 바이트를 중계한다.
58+
public java.io.InputStream getContentForUser(Long userId, Long documentId) {
59+
AnalyzedDocument doc = documentRepository.findActiveByIdAndOwner(documentId, userId)
60+
.orElseThrow(() -> new DomainException(ApiErrorCode.DOC_NOT_FOUND));
61+
if (doc.getDocumentPath() == null || doc.getDocumentPath().isBlank()) {
62+
throw new DomainException(ApiErrorCode.DOC_NOT_ANALYZED);
63+
}
64+
return storage.get(doc.getDocumentPath());
65+
}
66+
5667
private List<String> parseTechStack(String json) {
5768
if (json == null || json.isBlank()) {
5869
return List.of();

backend/src/main/java/com/stackup/stackup/document/presentation/AnalyzedDocumentController.java

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,14 @@
77
import io.swagger.v3.oas.annotations.responses.ApiResponse;
88
import io.swagger.v3.oas.annotations.responses.ApiResponses;
99
import io.swagger.v3.oas.annotations.tags.Tag;
10+
import java.time.Duration;
1011
import java.util.List;
1112
import lombok.RequiredArgsConstructor;
13+
import org.springframework.core.io.InputStreamResource;
14+
import org.springframework.core.io.Resource;
15+
import org.springframework.http.CacheControl;
16+
import org.springframework.http.MediaType;
17+
import org.springframework.http.ResponseEntity;
1218
import org.springframework.security.core.annotation.AuthenticationPrincipal;
1319
import org.springframework.web.bind.annotation.GetMapping;
1420
import org.springframework.web.bind.annotation.PathVariable;
@@ -61,4 +67,27 @@ public AnalyzedDocumentResponse get(
6167
) {
6268
return AnalyzedDocumentResponse.from(queryService.getForUser(principal.userId(), documentId));
6369
}
70+
71+
@Operation(
72+
operationId = "getAnalyzedDocumentContent",
73+
summary = "분석 원문(마크다운) 프록시",
74+
description = "presigned URL 은 내부(MinIO) 호스트라 브라우저가 직접 접근할 수 없어 "
75+
+ "Core 가 원문 바이트를 중계한다 (TTS 오디오 프록시와 동일 패턴)."
76+
)
77+
@ApiResponses({
78+
@ApiResponse(responseCode = "200", description = "분석 원문 (text/markdown)"),
79+
@ApiResponse(responseCode = "401", description = "인증 실패"),
80+
@ApiResponse(responseCode = "404", description = "분석 문서 없음"),
81+
@ApiResponse(responseCode = "422", description = "아직 분석 산출물이 없음")
82+
})
83+
@GetMapping("/{documentId}/content")
84+
public ResponseEntity<Resource> content(
85+
@AuthenticationPrincipal UserPrincipal principal,
86+
@PathVariable Long documentId
87+
) {
88+
return ResponseEntity.ok()
89+
.contentType(MediaType.parseMediaType("text/markdown; charset=utf-8"))
90+
.cacheControl(CacheControl.maxAge(Duration.ofMinutes(10)).cachePrivate())
91+
.body(new InputStreamResource(queryService.getContentForUser(principal.userId(), documentId)));
92+
}
6493
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package com.stackup.stackup.document.application;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
5+
import static org.mockito.Mockito.mock;
6+
import static org.mockito.Mockito.when;
7+
8+
import com.stackup.stackup.common.exception.ApiErrorCode;
9+
import com.stackup.stackup.common.exception.DomainException;
10+
import com.stackup.stackup.common.storage.ObjectStorageClient;
11+
import com.stackup.stackup.document.domain.AnalyzedDocument;
12+
import com.stackup.stackup.document.domain.AnalyzedDocumentRepository;
13+
import java.io.ByteArrayInputStream;
14+
import java.util.Optional;
15+
import org.junit.jupiter.api.Test;
16+
import org.junit.jupiter.api.extension.ExtendWith;
17+
import org.mockito.InjectMocks;
18+
import org.mockito.Mock;
19+
import org.mockito.junit.jupiter.MockitoExtension;
20+
21+
@ExtendWith(MockitoExtension.class)
22+
class AnalyzedDocumentQueryServiceTest {
23+
24+
@Mock AnalyzedDocumentRepository documentRepository;
25+
@Mock ObjectStorageClient storage;
26+
@InjectMocks AnalyzedDocumentQueryService service;
27+
28+
// 분석 원문 프록시(A5) — presigned URL 은 내부 호스트라 브라우저 직접 접근 불가,
29+
// Core 가 소유권 검증 후 바이트를 중계한다.
30+
@Test
31+
void getContentForUser_streamsMarkdownFromStorage() {
32+
AnalyzedDocument doc = mock(AnalyzedDocument.class);
33+
when(doc.getDocumentPath()).thenReturn("analyzed/resume/42/summary.md");
34+
when(documentRepository.findActiveByIdAndOwner(42L, 1L)).thenReturn(Optional.of(doc));
35+
when(storage.get("analyzed/resume/42/summary.md"))
36+
.thenReturn(new ByteArrayInputStream("## 개요".getBytes()));
37+
38+
assertThat(service.getContentForUser(1L, 42L)).isNotNull();
39+
}
40+
41+
@Test
42+
void getContentForUser_throwsNotFoundForOthersDocument() {
43+
when(documentRepository.findActiveByIdAndOwner(42L, 1L)).thenReturn(Optional.empty());
44+
45+
assertThatThrownBy(() -> service.getContentForUser(1L, 42L))
46+
.isInstanceOfSatisfying(DomainException.class,
47+
e -> assertThat(e.getErrorCode()).isEqualTo(ApiErrorCode.DOC_NOT_FOUND));
48+
}
49+
50+
@Test
51+
void getContentForUser_throwsWhenNoDocumentPathYet() {
52+
AnalyzedDocument doc = mock(AnalyzedDocument.class);
53+
when(doc.getDocumentPath()).thenReturn(null);
54+
when(documentRepository.findActiveByIdAndOwner(42L, 1L)).thenReturn(Optional.of(doc));
55+
56+
assertThatThrownBy(() -> service.getContentForUser(1L, 42L))
57+
.isInstanceOfSatisfying(DomainException.class,
58+
e -> assertThat(e.getErrorCode()).isEqualTo(ApiErrorCode.DOC_NOT_ANALYZED));
59+
}
60+
}

docs/api-conventions.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ POST /api/resumes/{id}/reanalyze 재분석
6767
6868
GET /api/documents 분석 문서 목록
6969
GET /api/documents/{id} 분석 문서 상세 (S3 URL 포함)
70+
GET /api/documents/{id}/content 분석 원문(마크다운) 프록시 — presigned 는 내부 호스트라 Core 중계
7071
```
7172

7273
### 2.4 면접 세션

0 commit comments

Comments
 (0)