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: 3 additions & 0 deletions ai/src/ai_server/chain/prompts/answer_coaching.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
"없는 경험을 지어내지 말고, 있는 내용을 구조(두괄식·근거·결과)와 구체성으로 보강. 답변이 비었거나 "
"'모르겠다'면 '이렇게 접근했다면' 식으로 짧게.\n"
"- **coaching_comment (한 줄 코칭)**: 이 답변에서 가장 중요한 보완점 하나를 한 문장으로.\n"
"포맷: model_answer 와 answer_rewrite 는 필요하면 **GFM 마크다운**(굵게·리스트·코드 블록)을 "
"사용해도 됩니다 — 화면이 마크다운으로 렌더합니다. coaching_comment 는 서식 기호 없는 "
"일반 텍스트 한 문장으로.\n"
"- **질문이 경험·행동·자소서 기반**('그때 무엇을 어떻게 했는가', 지원동기·성장·갈등 등)이면 "
"model_answer 와 answer_rewrite 를 **STAR(상황·과제·행동·결과) 골격**으로 구성하고, 본인의 구체적 "
"행동·정량적 결과·배운 점이 드러나게 코칭하세요. (기술 개념 질문이면 STAR 대신 정확성·깊이 우선.)\n"
Expand Down
1 change: 1 addition & 0 deletions ai/src/ai_server/chain/prompts/feedback_generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
" - logic_score: 논리·인과관계 명확성\n"
" - communication_score: 답변의 명료성·구조화\n"
"- 요약:\n"
" - 모든 텍스트 필드는 마크다운 서식 기호 없이 일반 텍스트로 작성 (화면이 plain text 렌더).\n"
" - strengths_summary: 가장 잘한 점 3가지 이내 (각 1~2문장).\n"
" - weaknesses_summary: 가장 부족한 점 3가지 이내 (각 1~2문장).\n"
" - improvement_keywords: 다음 면접에서 채울 키워드 5~10개 (짧은 명사구).\n"
Expand Down
1 change: 1 addition & 0 deletions ai/src/ai_server/chain/prompts/feedback_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"- 점수를 매기기 전에 강점/약점 근거를 먼저 정리한 뒤 산정한다(즉흥 점수 금지).\n"
"- strength/weakness 는 각각 한 줄(한국어, 구체적으로). keywords 는 이 축에서 보완할 "
"개선 키워드 0~4개(짧은 명사구).\n"
"- 모든 텍스트 필드는 마크다운 서식 기호 없이 일반 텍스트로 작성하세요 (화면이 plain text 렌더).\n"
"- detail: 이 축에 대한 2~4문장 상세 평가. **답변의 구체적 부분을 인용/지목**하고 무엇이 "
"왜 좋았는지/아쉬웠는지 근거를 들어 서술한다(추상적 총평 금지).\n"
"- score_rationale: 그 점수를 준 핵심 근거(가점/감점 요인)를 한두 문장으로.\n"
Expand Down
2 changes: 2 additions & 0 deletions ai/src/ai_server/chain/prompts/feedback_synthesis.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
"- improvement_keywords: 다음 면접에서 보완할 키워드 5~10개(짧은 명사구).\n"
"- study_plan: 구체적 학습 방향/다음 단계 액션 아이템 3~6개. 각 항목은 '무엇을 어떻게'가 "
"드러나는 실행 문장(예: 'Redis 분산 락의 SETNX·TTL 옵션을 직접 구현해보며 원자성 보장 원리 정리').\n"
"모든 텍스트 필드는 **마크다운 서식 기호 없이 일반 텍스트**로 작성하세요(굵게 **, 리스트 -, "
"코드 ` 금지) — 화면이 plain text 로 렌더하고, highlights 는 원문 부분 문자열 매칭에 쓰입니다.\n"
"- highlights: 지원자가 꼭 기억해야 할 **가장 중요한 핵심 구절 3~6개**. 화면에서 강조 표시할 "
"용도라, 반드시 위 strengths_summary·weaknesses_summary 본문에 **그대로 등장한 짧은 구절"
"(각 2~12어절)을 글자 그대로 발췌**하세요(새로 짓거나 바꿔쓰지 말 것 — 부분 문자열 매칭에 사용). "
Expand Down
1 change: 1 addition & 0 deletions ai/src/ai_server/chain/prompts/question_generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"- 카테고리는 다음 중에서 선택: CS_FUNDAMENTAL, PROJECT_DEEP_DIVE, TECH_CHOICE, "
"BEHAVIORAL.\n"
"- 한국어로 작성하되 기술 용어는 영문 원어를 그대로 둡니다.\n"
"- 질문·근거 텍스트에는 마크다운 서식 기호를 쓰지 마세요 (화면이 plain text 렌더, 스트리밍 표시).\n"
"- 질문 문장은 **간결하게**: 한두 문장(대략 80자 이내)으로, 장황한 배경 설명이나 "
"중복 수식 없이 핵심만 묻습니다. 면접관이 입으로 자연스럽게 말할 길이여야 합니다.\n"
"- **직무 맞춤(타깃 회사/JD 제공 시)**: 먼저 채용공고에서 **핵심 요구 역량 5~8개를 뽑아** "
Expand Down
63 changes: 63 additions & 0 deletions backend/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -2842,6 +2842,69 @@
}
}
},
"/api/documents/{documentId}/content" : {
"get" : {
"tags" : [ "Documents" ],
"summary" : "분석 원문(마크다운) 프록시",
"description" : "presigned URL 은 내부(MinIO) 호스트라 브라우저가 직접 접근할 수 없어 Core 가 원문 바이트를 중계한다 (TTS 오디오 프록시와 동일 패턴).",
"operationId" : "getAnalyzedDocumentContent",
"parameters" : [ {
"name" : "documentId",
"in" : "path",
"required" : true,
"schema" : {
"type" : "integer",
"format" : "int64"
}
} ],
"responses" : {
"200" : {
"description" : "분석 원문 (text/markdown)",
"content" : {
"*/*" : {
"schema" : {
"type" : "string",
"format" : "binary"
}
}
}
},
"401" : {
"description" : "인증 실패",
"content" : {
"*/*" : {
"schema" : {
"type" : "string",
"format" : "binary"
}
}
}
},
"404" : {
"description" : "분석 문서 없음",
"content" : {
"*/*" : {
"schema" : {
"type" : "string",
"format" : "binary"
}
}
}
},
"422" : {
"description" : "아직 분석 산출물이 없음",
"content" : {
"*/*" : {
"schema" : {
"type" : "string",
"format" : "binary"
}
}
}
}
}
}
},
"/api/auth/google/callback" : {
"get" : {
"tags" : [ "Auth" ],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,17 @@ public AnalyzedDocumentResult getForUser(Long userId, Long documentId) {
return AnalyzedDocumentResult.of(doc, parseTechStack(doc.getTechStack()), downloadUrl);
}

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

private List<String> parseTechStack(String json) {
if (json == null || json.isBlank()) {
return List.of();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,14 @@
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.time.Duration;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.core.io.InputStreamResource;
import org.springframework.core.io.Resource;
import org.springframework.http.CacheControl;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
Expand Down Expand Up @@ -61,4 +67,27 @@ public AnalyzedDocumentResponse get(
) {
return AnalyzedDocumentResponse.from(queryService.getForUser(principal.userId(), documentId));
}

@Operation(
operationId = "getAnalyzedDocumentContent",
summary = "분석 원문(마크다운) 프록시",
description = "presigned URL 은 내부(MinIO) 호스트라 브라우저가 직접 접근할 수 없어 "
+ "Core 가 원문 바이트를 중계한다 (TTS 오디오 프록시와 동일 패턴)."
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "분석 원문 (text/markdown)"),
@ApiResponse(responseCode = "401", description = "인증 실패"),
@ApiResponse(responseCode = "404", description = "분석 문서 없음"),
@ApiResponse(responseCode = "422", description = "아직 분석 산출물이 없음")
})
@GetMapping("/{documentId}/content")
public ResponseEntity<Resource> content(
@AuthenticationPrincipal UserPrincipal principal,
@PathVariable Long documentId
) {
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType("text/markdown; charset=utf-8"))
.cacheControl(CacheControl.maxAge(Duration.ofMinutes(10)).cachePrivate())
.body(new InputStreamResource(queryService.getContentForUser(principal.userId(), documentId)));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package com.stackup.stackup.document.application;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

import com.stackup.stackup.common.exception.ApiErrorCode;
import com.stackup.stackup.common.exception.DomainException;
import com.stackup.stackup.common.storage.ObjectStorageClient;
import com.stackup.stackup.document.domain.AnalyzedDocument;
import com.stackup.stackup.document.domain.AnalyzedDocumentRepository;
import java.io.ByteArrayInputStream;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

@ExtendWith(MockitoExtension.class)
class AnalyzedDocumentQueryServiceTest {

@Mock AnalyzedDocumentRepository documentRepository;
@Mock ObjectStorageClient storage;
@InjectMocks AnalyzedDocumentQueryService service;

// 분석 원문 프록시(A5) — presigned URL 은 내부 호스트라 브라우저 직접 접근 불가,
// Core 가 소유권 검증 후 바이트를 중계한다.
@Test
void getContentForUser_streamsMarkdownFromStorage() {
AnalyzedDocument doc = mock(AnalyzedDocument.class);
when(doc.getDocumentPath()).thenReturn("analyzed/resume/42/summary.md");
when(documentRepository.findActiveByIdAndOwner(42L, 1L)).thenReturn(Optional.of(doc));
when(storage.get("analyzed/resume/42/summary.md"))
.thenReturn(new ByteArrayInputStream("## 개요".getBytes()));

assertThat(service.getContentForUser(1L, 42L)).isNotNull();
}

@Test
void getContentForUser_throwsNotFoundForOthersDocument() {
when(documentRepository.findActiveByIdAndOwner(42L, 1L)).thenReturn(Optional.empty());

assertThatThrownBy(() -> service.getContentForUser(1L, 42L))
.isInstanceOfSatisfying(DomainException.class,
e -> assertThat(e.getErrorCode()).isEqualTo(ApiErrorCode.DOC_NOT_FOUND));
}

@Test
void getContentForUser_throwsWhenNoDocumentPathYet() {
AnalyzedDocument doc = mock(AnalyzedDocument.class);
when(doc.getDocumentPath()).thenReturn(null);
when(documentRepository.findActiveByIdAndOwner(42L, 1L)).thenReturn(Optional.of(doc));

assertThatThrownBy(() -> service.getContentForUser(1L, 42L))
.isInstanceOfSatisfying(DomainException.class,
e -> assertThat(e.getErrorCode()).isEqualTo(ApiErrorCode.DOC_NOT_ANALYZED));
}
}
1 change: 1 addition & 0 deletions docs/api-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ POST /api/resumes/{id}/reanalyze 재분석

GET /api/documents 분석 문서 목록
GET /api/documents/{id} 분석 문서 상세 (S3 URL 포함)
GET /api/documents/{id}/content 분석 원문(마크다운) 프록시 — presigned 는 내부 호스트라 Core 중계
```

### 2.4 면접 세션
Expand Down
16 changes: 15 additions & 1 deletion docs/design-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,20 @@ Tailwind v4 기본 `--spacing: 0.25rem` (= 4px) 사용. `p-4` = `16px`.
**Breakpoints** — Tailwind v4 default 사용:
- `sm: 640px`, `md: 768px`, `lg: 1024px`, `xl: 1280px`, `2xl: 1536px`.

### 2.14 아이콘
### 2.14 프로즈(마크다운) 렌더링 (A5)

GFM 마크다운 계약 필드([`frontend-types.md §6.5`](./frontend-types.md))는 `shared/ui/Markdown` 으로만
렌더한다 — react-markdown + remark-gfm + rehype-sanitize ([`security.md §4.4`](./security.md)),
렌더러 본체는 lazy 청크(메인 번들 미가산).

- 본문: `text-body` + `leading-relaxed` (기존 AI 텍스트 표면 관례 승계), 수직 리듬 `space-y-2`
(global.css 가 p/heading margin 을 0 으로 리셋하므로 컴포넌트가 자체 정의)
- 헤딩: 디스플레이 스케일이 아니라 카드 관례 — h1/h2 → 18px bold, h3 이하 → `text-body` bold
- 코드: `--font-mono`, 인라인은 `bg-surface-raised`, 블록은 `bg-surface` + `overflow-x-auto`
- 링크: `text-primary-fg` underline, `target=_blank rel=noreferrer`. 표는 가로 스크롤 컨테이너
- 컨테이너 폭은 사용처가 `max-w-readable`(65ch) 로 제한

### 2.15 아이콘

- 라이브러리 — **Lucide Icons** (`lucide-react` 도입 예정, 트리쉐이킹 지원).
- 크기 — `16 / 20 / 24 px` (line-height 와 정렬).
Expand Down Expand Up @@ -289,6 +302,7 @@ Tailwind v4 기본 `--spacing: 0.25rem` (= 4px) 사용. `p-4` = `16px`.
- `Skeleton` — 로딩 placeholder (4-state §5).
- `EmptyState` — 빈 목록 안내, 아이콘 + 설명 + CTA (4-state §5).
- `Card` — `header / body / footer` slot.
- `Markdown` — GFM 마크다운 계약 필드 전용 프로즈 렌더러 (스타일 §2.14, sanitize 포함, lazy 청크).

### Feedback
- `Toast` — 4종 (success / info / warning / error), 우상단 stack, 4초 자동 dismiss, `z-toast`.
Expand Down
11 changes: 11 additions & 0 deletions docs/frontend-types.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,17 @@ class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {

---

## 6.5 AI 텍스트 필드 포맷 계약 (A5)

서버가 주는 AI 생성 텍스트는 필드별로 포맷이 계약돼 있다 (정본: [`messaging.md §5.4·§5.11`](./messaging.md)):

| 포맷 | 필드 | 렌더 |
|---|---|---|
| **GFM 마크다운** | 분석 산출물(`documentPath` 문서), `answerCoaching[].modelAnswer`/`answerRewrite` | `shared/ui/Markdown` (lazy + sanitize) |
| **일반 텍스트** | 피드백 요약·`highlights[]`·`studyPlan[]`·패널 `detail`·질문 텍스트·`coachingComment` 등 나머지 전부 | plain (`whitespace-pre-wrap`) — `HighlightedText` 부분 문자열 매칭·델타 스트리밍과의 충돌을 막기 위한 계약 |

사용자 입력(내 답변 등)은 어떤 경우에도 마크다운으로 렌더하지 않는다.

## 7. 안티패턴

- ❌ `XxxResponse` 를 함수 반환 타입으로 사용 — `XxxResult` 로.
Expand Down
9 changes: 9 additions & 0 deletions docs/messaging.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,10 @@
}
```

- **포맷 계약 (A5)**: `documentPath` 가 가리키는 분석 산출물은 **GFM 마크다운**(프롬프트가 `##`
헤딩 구조를 지시 — 프론트는 상세 모달 "분석 원문 보기"에서 `shared/ui/Markdown` 으로 렌더).
`summary` 는 마크다운 서식 없는 일반 텍스트(2~4문장).

### 5.5 `callback.analysis` (실패)
```json
{
Expand Down Expand Up @@ -524,6 +528,11 @@
}
```

- **텍스트 필드 포맷 계약 (A5)**: `strengthsSummary`·`weaknessesSummary`·`studyPlan[]`·`highlights[]`·
`panelBreakdown[].detail`·`answerCoaching[].coachingComment` 는 **마크다운 서식 기호 없는 일반
텍스트**다(프롬프트로 강제 — highlights 는 원문 부분 문자열 매칭에 쓰이므로 특히). 예외:
`answerCoaching[].modelAnswer`/`answerRewrite` 는 **GFM 마크다운 허용** — 프론트가
`shared/ui/Markdown`(sanitize 포함)으로 렌더한다.
- 피드백 생성의 부분 실패(패널·부가 평가위원)는 AI 서버 내부에서 폴백(빈 결과/생략)으로 흡수되어
성공 콜백으로 나간다 — FAILED 는 그 방어망 밖의 **예상 못 한 예외** 전용이다. `errorCode` 는
questions/followup 과 동일 분류: `TypeError`(LLM 출력 스키마 불일치)면 `GENERATION_SCHEMA_INVALID`
Expand Down
1 change: 1 addition & 0 deletions frontend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
| Styling | Tailwind CSS v4 (`@theme` 토큰) | 4.x (도입) |
| API 타입 | openapi-typescript → `shared/api/generated.ts` | (도입, §7.1) |
| 테스트 | Vitest + Testing Library (jsdom) | (도입) |
| 마크다운 렌더 | react-markdown + remark-gfm + rehype-sanitize (`shared/ui/Markdown`, lazy) | (도입, A5 — 계약: docs/frontend-types.md §6.5) |

### 미정 (도입 시점에 결정)
- Form: React Hook Form + Zod (현재 controlled `useState` 검증으로 충분 — 폼 복잡도 증가 시 도입)
Expand Down
Loading
Loading