Capstone Hands-on Lab D / Chapter 1-5 통합

Mission: Grounded, 근거로만 답하는 사내 사서를 고용하라

Claude Code Deep Dive Workshop, Capstone D - 지식 검색 풀스택, RAG의 원리 (135분 목표 / 150분 상한)

이번 미션의 정체성은 "기억하는 Claude", 엔터프라이즈의 단골 요구인 사내 지식 서비스입니다. 문서를 임베딩해 서가(DynamoDB)에 꽂고, 질문이 오면 코사인 검색으로 근거를 찾아 출처 [번호]를 인용해 답하며, 근거가 없으면 정직하게 거절합니다. 매니지드 벡터 DB 없이 원리를 손으로 만지는 게 목적입니다. 그리고 이 랩의 서명은 사서 자격시험: 함정 질문에 환각하면 exit 1로 떨어지는, CI에 그대로 물릴 수 있는 환각 회귀 게이트입니다.

환경 Claude Code on EC2 + Admin 계정 리전 ap-northeast-2 모델 Titan Embed v2 + Sonnet 4.6 Update 2026.07
MISSION BRIEFING

서가, 열람실, 그리고 정직

3-tier입니다. 프론트는 서가/입고/질문 3패널 단일 페이지(S3 + CloudFront), 백엔드는 입고(청킹 + Titan 임베딩)와 질의응답(검색 + 인용 생성) Lambda 2종, DB는 청크와 256차원 벡터를 그대로 품는 DynamoDB. 검색은 Lambda 안의 코사인 top-3이고, 답변 모델에게는 단 하나의 계약을 강제합니다: 근거 없으면 "서가에 없는 내용입니다." 한 문장으로 거절. 이 고정 문구가 있어야 M4의 자격시험이 기계로 채점됩니다.

아키텍처
브라우저 ── CloudFront ── S3 ── index.html (서가 / 입고 / 질문 3패널)
    │
    └ fetch ── HTTP API ─┬─ Lambda ingest ── 청킹(240자) ─ Titan Embed v2(256차원) ─┐
                         │      POST /ingest, GET /docs                            ├─ DynamoDB
                         └─ Lambda ask ──── 질문 임베딩 ─ 코사인 top-3 ────────────┘   (shelf + chunk#vector)
                                └─ Converse (Sonnet 4.6)
                                    시스템 계약: 인용 [번호] 필수, 근거 없으면 정확히 "서가에 없는 내용입니다."
규칙 1 - 코드는 Claude에게 시킨다, 답변은 출처 없이는 인정하지 않는다 규칙 2 - 각 미션은 Definition of Done으로만 판정한다 규칙 3 - 막히면 "막힐 때 열어보기" 완성본으로 복구하고 전진한다

A/B/C를 마친 계정이라면 M0은 확인만으로 통과합니다. 90분 시점에 M3 미착수면 부록 컷라인을 적용하세요.

MISSION 00

발사 준비, 계획 승인까지

15분

스캐폴드와 superpowers를 준비하고 4-Phase 계획을 승인합니다.

1점검과 스캐폴드
Terminal, 전체 복사
aws sts get-caller-identity --query Account --output text
node -v && jq --version && npx cdk --version

Bedrock 호출(임베딩과 답변)은 다른 계정에서 발급받은 API 키(Bearer)로 인증합니다. 다른 캡스톤에서 이미 만들었다면 ~/capstone/.env가 있으니 1/3은 건너뛰고 2/3부터 하세요.

Terminal 1/3, .env에 Bearer 토큰 저장
mkdir -p ~/capstone
cat > ~/capstone/.env << 'ENVEOF'
AWS_BEARER_TOKEN_BEDROCK=<발급받은 Bedrock API 키>
ENVEOF
Terminal 2/3, 셸에 로드
set -a; source ~/capstone/.env; set +a
Terminal 3/3, Converse 스모크
aws bedrock-runtime converse \
  --region ap-northeast-2 \
  --model-id global.anthropic.claude-sonnet-4-6 \
  --messages '[{"role":"user","content":[{"text":"ping"}]}]' \
  --inference-config '{"maxTokens":30}' \
  --query 'output.message.content[0].text' --output text
Terminal, 스캐폴드 (재실행 안전)
mkdir -p ~/capstone/librarian && cd ~/capstone/librarian
[ -f cdk.json ] || npx cdk init app --language typescript
(npx cdk bootstrap > /tmp/bootstrap.log 2>&1 &)
grep -qxF '.env' .gitignore 2>/dev/null || echo '.env' >> .gitignore
git config user.name "capstone" && git config user.email "capstone@lab.local"
git add -A && git commit -m "chore: cdk scaffold" 2>/dev/null || true

superpowers는 /plugin으로 확인, 없으면 /plugin install superpowers@claude-plugins-official 후 세션 재시작입니다.

2브레인스토밍과 계획
Claude 세션 입력, 전체 복사
/superpowers:brainstorming 다음 요구사항으로 "사내 사서(지식 검색) 서비스"를 설계하자.
1) DB: DynamoDB 단일 테이블(pk/sk). 청크는 pk=chunk(sk=docId#순번, docId/title/text/vector 숫자배열),
   서가 목록은 pk=shelf(sk=docId, title/chunks/at)
2) 순수 로직은 lib 모듈로 분리: chunkText(빈 줄 문단 병합, 기본 240자), cosine, rankChunks(top-k).
   AWS SDK 무의존으로 단위 테스트 가능해야 함
3) 백엔드 Lambda 2종(Node 22) + HTTP API(CORS):
   - ingest: POST /ingest {title, text} 를 청킹 후 청크마다 Titan Embed v2로 임베딩
     (env EMBED_MODEL_ID 기본 amazon.titan-embed-text-v2:0, body에 dimensions 256과 normalize true)
     해서 저장, GET /docs 는 서가 목록
   - ask: POST /ask {question} 을 같은 설정으로 임베딩, pk=chunk 전량 조회 후 코사인 top-3,
     번호 붙인 자료로 Converse 호출(env ANSWER_MODEL_ID 기본 global.anthropic.claude-sonnet-4-6, maxTokens 600).
     시스템 프롬프트 계약: 자료 근거 문장 끝 [번호] 인용, 근거 없으면 정확히 "서가에 없는 내용입니다." 한 문장.
     응답에 answer, refused(그 문장으로 시작하는지), sources(n/title/score/preview 80자) 포함
4) 프론트 단일 페이지(S3+CloudFront): 서가 목록, 문서 입고 폼, 질문 폼과 답변,
   출처 카드에는 유사도 점수 막대 표시, 거절 답변은 보라색으로 구분
5) CDK TypeScript 단일 스택(LibraryStack), ap-northeast-2, DESTROY,
   Outputs: ApiUrl/SiteUrl/TableName
6) 구현 순서 Phase 1(lib+ingest+docs) → Phase 2(프론트) → Phase 3(ask 질의응답) → Phase 4(자격시험과 스킬)
7) 시간 제약 2시간: 단위 테스트는 lib 모듈만, 나머지는 smoke curl로 갈음
8) Bedrock 인증은 IAM이 아니라 발급받은 API 키를 쓴다: 배포 셸의 AWS_BEARER_TOKEN_BEDROCK 값을
   CDK가 두 Lambda(ingest, ask) 환경변수로 주입하고, Bedrock용 IAM 정책은 만들지 않는다
결정이 필요하면 이 범위 안에서 최소한으로만 물어봐.
자동화 옵션, auto mode 세션에 auto mode on을 선언해 두면 superpowers가 brainstorming → write-plan → execute-plan의 단계 전환과 승인 대기를 자동으로 이어 갑니다. 시간이 빠듯할 때 유용합니다. 단계마다 직접 검토하려면 기본(수동)을 유지하고, 되돌리려면 auto mode off입니다.
worktree 순간이동 superpowers는 계획 후 git worktree로 이동합니다. deploy와 shelve, quiz는 전부 pwd로 확인한 그 경로에서 실행하세요.
DEFINITION OF DONE
MISSION 01

입고 라인, 임베딩을 실물로 본다

30분

Phase 1을 배포하고 가상 회사 한빛제과의 문서 3편을 서가에 입고합니다. 마지막에 DynamoDB에서 256개의 숫자가 된 문장을 직접 꺼내 봅니다, 임베딩이 실물로 보이는 순간입니다.

1Phase 1 실행과 배포
Claude 세션 입력
/superpowers:execute-plan Phase 1만 구현하고 멈춰줘. lib 모듈에는 요구사항의 단위 테스트(청킹 병합과 분할, 코사인 1과 0, top-k 정렬)를 포함하고, npx cdk synth --quiet 검증까지 보고해.
스택은 Bedrock IAM 정책 없이, ingest Lambda environment에
AWS_BEARER_TOKEN_BEDROCK: process.env.AWS_BEARER_TOKEN_BEDROCK ?? "" 를 주입해.
Terminal, worktree에서
set -a; source ~/capstone/.env; set +a   # Bearer 키 로드
npx cdk deploy --require-approval never --outputs-file cdk-outputs.json
API=$(jq -r '.LibraryStack.ApiUrl' cdk-outputs.json) && export LIB_API="$API"
TABLE=$(jq -r '.LibraryStack.TableName' cdk-outputs.json)
이후의 모든 deploy는 .env 로드가 선행 스택이 배포 셸의 AWS_BEARER_TOKEN_BEDROCK을 읽어 두 Lambda에 넣으므로, 새 터미널에서는 항상 로드 라인을 먼저 실행하세요. 빈 값으로 배포되면 임베딩과 답변이 401을 반환합니다.
2한빛제과 문서 3편 배치
Terminal, 전체 복사 (worktree에서)
mkdir -p docs
cat > docs/hanbit-history.md << 'HISTEOF'
한빛제과 회사 연혁

한빛제과는 1987년 서울 마포의 작은 공방에서 창업자 한복순이 설립했다.
초기에는 수제 캐러멜만 만들었으나, 1994년 대표 제품인 달고나 파이를 출시하며 전국 유통을 시작했다.
출시 첫해 달고나 파이는 300만 상자가 팔리며 회사의 간판이 되었다.

2003년 대전에 제1공장을 준공해 생산량을 열 배로 늘렸고,
2011년에는 일본 수출을 시작하며 해외 시장에 처음 진출했다.
2019년에는 비건 라인 "순한빛"을 론칭해 제품군을 확장했다.

회사 마스코트는 달 모양 캐릭터 "달코"이며 2015년 공모전으로 탄생했다.
현재 임직원은 약 340명이고 본사는 여전히 서울 마포에 있다.
HISTEOF

cat > docs/hanbit-product.md << 'PRODEOF'
달고나 파이 제품 규격서

달고나 파이 1개의 중량은 34g이며, 12개입 한 상자로 판매한다.
반죽은 12시간 저온 숙성을 거치고, 굽기 온도는 187도를 유지한다.
낱개 포장은 질소 충전 방식으로 바삭함을 지킨다.

유통기한은 제조일로부터 9개월이다.
1개당 당류는 11g이며 포장 전면에 표기한다.
알레르기 유발 성분은 밀과 대두이고, 견과류 혼입 가능 설비에서 생산된다.

비건 라인 순한빛 파이는 버터 대신 코코넛 오일을 사용한다.
순한빛 파이의 굽기 온도는 일반 제품보다 5도 낮은 182도다.
PRODEOF

cat > docs/hanbit-rules.md << 'RULEEOF'
한빛제과 사규 발췌

사훈은 "정직한 단맛"이다. 모든 원료 원산지는 포장에 표기한다.
근무는 유연근무제로 코어타임은 11시부터 16시까지다.
연차와 별도로 3년 근속마다 리프레시 휴가 5일을 부여한다.

신제품 시식 품질회의는 매주 수요일 오후에 열린다.
시식 평가는 5점 척도이며 평균 4.2점 이상만 출시 후보가 된다.

레시피 문서는 보안등급 L3로 분류되며 연구소 밖 반출을 금지한다.
사내 카페 이름은 "한스푼"이고 임직원에게 아메리카노를 무료로 제공한다.
RULEEOF
wc -l docs/*.md

일부러 심어 둔 사실들입니다: 창립 1987, 굽기 187도, 사훈 "정직한 단맛", 비건 라인 순한빛, 유통기한 9개월. 그리고 일부러 없는 것도 있습니다: 상장 여부. M4의 함정 문항이 됩니다.

3서가 입고와 벡터 실물 확인
Terminal, 전체 복사 (worktree에서)
cat > shelve.sh << 'SHELVEEOF'
#!/bin/bash
# 사용: ./shelve.sh  - docs/*.md 전부를 사서에게 입고
set -euo pipefail
API="${LIB_API:?LIB_API 필요 (사서 ApiUrl)}"

for f in docs/*.md; do
  T=$(head -1 "$f")
  echo "입고: $T"
  curl -s -X POST "$API/ingest" -H 'Content-Type: application/json' \
    -d "$(jq -n --arg t "$T" --arg x "$(cat "$f")" '{title:$t, text:$x}')" \
    | jq '{title, chunks}'
done
echo "[shelve] 서가 입고 완료. /docs로 확인하세요."
SHELVEEOF
chmod +x shelve.sh
./shelve.sh
curl -s "$API/docs" | jq '.docs[].title'
Terminal, 문장이 숫자 256개가 된 현장
aws dynamodb query --table-name "$TABLE" \
  --key-condition-expression 'pk = :p' \
  --expression-attribute-values '{":p":{"S":"chunk"}}' \
  --max-items 1 \
  --query 'Items[0].{title: title.S, vector_dim: length(vector.L), preview: text.S}'
출력 예시
{
  "title": "한빛제과 회사 연혁",
  "vector_dim": 256,
  "preview": "한빛제과 회사 연혁\n\n한빛제과는 1987년 서울 마포의..."
}
막힐 때 열어보기, 완성본 (스택 + Lambda 3파일, Phase 3까지 반영된 최종형)

뒤처졌다면 아래 5개 파일과 M2의 프론트를 배치하고 한 번에 deploy해 복구하세요. ask.mjs는 M3에서 다룹니다.

lib/library-stack.ts
import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import * as dynamodb from "aws-cdk-lib/aws-dynamodb";
import * as lambda from "aws-cdk-lib/aws-lambda";
import * as apigwv2 from "aws-cdk-lib/aws-apigatewayv2";
import * as integrations from "aws-cdk-lib/aws-apigatewayv2-integrations";
import * as s3 from "aws-cdk-lib/aws-s3";
import * as s3deploy from "aws-cdk-lib/aws-s3-deployment";
import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
import * as origins from "aws-cdk-lib/aws-cloudfront-origins";
import * as iam from "aws-cdk-lib/aws-iam";

export class LibraryStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const answerModelId = this.node.tryGetContext("answerModelId") ?? "global.anthropic.claude-sonnet-4-6";
    const embedModelId = this.node.tryGetContext("embedModelId") ?? "amazon.titan-embed-text-v2:0";

    const table = new dynamodb.Table(this, "LibraryTable", {
      partitionKey: { name: "pk", type: dynamodb.AttributeType.STRING },
      sortKey: { name: "sk", type: dynamodb.AttributeType.STRING },
      billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
      removalPolicy: cdk.RemovalPolicy.DESTROY,
    });

    const ingestFn = new lambda.Function(this, "IngestFn", {
      runtime: lambda.Runtime.NODEJS_22_X,
      handler: "ingest.handler",
      code: lambda.Code.fromAsset("lambda"),
      environment: {
        TABLE_NAME: table.tableName,
        EMBED_MODEL_ID: embedModelId,
        // 발급 API 키 인증, 배포 셸에서 주입 (Bedrock IAM 정책 불필요)
        AWS_BEARER_TOKEN_BEDROCK: process.env.AWS_BEARER_TOKEN_BEDROCK ?? "",
      },
      timeout: cdk.Duration.seconds(29),
      memorySize: 512,
    });
    table.grantReadWriteData(ingestFn);

    const askFn = new lambda.Function(this, "AskFn", {
      runtime: lambda.Runtime.NODEJS_22_X,
      handler: "ask.handler",
      code: lambda.Code.fromAsset("lambda"),
      environment: {
        TABLE_NAME: table.tableName,
        EMBED_MODEL_ID: embedModelId,
        ANSWER_MODEL_ID: answerModelId,
        AWS_BEARER_TOKEN_BEDROCK: process.env.AWS_BEARER_TOKEN_BEDROCK ?? "",
      },
      timeout: cdk.Duration.seconds(29),
      memorySize: 512,
    });
    table.grantReadData(askFn);

    const api = new apigwv2.HttpApi(this, "LibraryApi", {
      corsPreflight: {
        allowOrigins: ["*"],
        allowMethods: [apigwv2.CorsHttpMethod.GET, apigwv2.CorsHttpMethod.POST, apigwv2.CorsHttpMethod.OPTIONS],
        allowHeaders: ["Content-Type"],
      },
    });
    const ingestInt = new integrations.HttpLambdaIntegration("IngestInt", ingestFn);
    const askInt = new integrations.HttpLambdaIntegration("AskInt", askFn);
    api.addRoutes({ path: "/ingest", methods: [apigwv2.HttpMethod.POST], integration: ingestInt });
    api.addRoutes({ path: "/docs", methods: [apigwv2.HttpMethod.GET], integration: ingestInt });
    api.addRoutes({ path: "/ask", methods: [apigwv2.HttpMethod.POST], integration: askInt });

    const siteBucket = new s3.Bucket(this, "SiteBucket", {
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      removalPolicy: cdk.RemovalPolicy.DESTROY,
      autoDeleteObjects: true,
    });
    const dist = new cloudfront.Distribution(this, "SiteDist", {
      defaultBehavior: {
        origin: origins.S3BucketOrigin.withOriginAccessControl(siteBucket),
        viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
      },
      defaultRootObject: "index.html",
    });
    new s3deploy.BucketDeployment(this, "DeploySite", {
      sources: [s3deploy.Source.asset("frontend")],
      destinationBucket: siteBucket,
      distribution: dist,
      distributionPaths: ["/*"],
    });

    new cdk.CfnOutput(this, "ApiUrl", { value: api.apiEndpoint });
    new cdk.CfnOutput(this, "SiteUrl", { value: `https://${dist.distributionDomainName}` });
    new cdk.CfnOutput(this, "TableName", { value: table.tableName });
  }
}
bin/librarian.ts (cdk init 산출물 교체)
import * as cdk from "aws-cdk-lib";
import { LibraryStack } from "./library-stack";

const app = new cdk.App();
new LibraryStack(app, "LibraryStack", {
  env: { region: process.env.CDK_DEFAULT_REGION ?? "ap-northeast-2" },
  tags: { project: "capstone-librarian" },
});
lambda/lib.mjs
// 청킹과 검색 랭킹. 임베딩 벡터는 밖에서 주입받는 순수 로직이다.
export function chunkText(text, max = 600) {
  const paras = String(text).split(/\n\s*\n/).map((s) => s.trim()).filter(Boolean);
  const chunks = [];
  let buf = "";
  for (const p of paras) {
    if (buf && (buf.length + p.length + 2) > max) {
      chunks.push(buf);
      buf = p;
    } else {
      buf = buf ? buf + "\n\n" + p : p;
    }
  }
  if (buf) chunks.push(buf);
  return chunks;
}

export function cosine(a, b) {
  let dot = 0, na = 0, nb = 0;
  for (let i = 0; i < a.length; i++) {
    dot += a[i] * b[i];
    na += a[i] * a[i];
    nb += b[i] * b[i];
  }
  return dot / (Math.sqrt(na) * Math.sqrt(nb) || 1);
}

export function rankChunks(chunks, qvec, k = 3) {
  return chunks
    .map((c) => ({ ...c, score: cosine(c.vector, qvec) }))
    .sort((x, y) => y.score - x.score)
    .slice(0, k);
}
lambda/ingest.mjs
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, PutCommand, QueryCommand } from "@aws-sdk/lib-dynamodb";
import { BedrockRuntimeClient, InvokeModelCommand } from "@aws-sdk/client-bedrock-runtime";
import { chunkText } from "./lib.mjs";

const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const brt = new BedrockRuntimeClient({});
const TABLE = process.env.TABLE_NAME;
const EMBED_MODEL_ID = process.env.EMBED_MODEL_ID ?? "amazon.titan-embed-text-v2:0";
const CHUNK_MAX = Number(process.env.CHUNK_MAX ?? 240);
const CORS = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Headers": "Content-Type",
  "Access-Control-Allow-Methods": "GET,POST,OPTIONS",
};

export async function embed(text) {
  const res = await brt.send(new InvokeModelCommand({
    modelId: EMBED_MODEL_ID,
    contentType: "application/json",
    body: JSON.stringify({ inputText: String(text).slice(0, 8000), dimensions: 256, normalize: true }),
  }));
  return JSON.parse(new TextDecoder().decode(res.body)).embedding;
}

export const handler = async (event) => {
  const method = event.requestContext?.http?.method ?? "GET";
  const path = event.rawPath ?? "/";
  if (method === "OPTIONS") return { statusCode: 204, headers: CORS };

  try {
    if (path.endsWith("/docs")) {
      const res = await ddb.send(new QueryCommand({
        TableName: TABLE,
        KeyConditionExpression: "pk = :p",
        ExpressionAttributeValues: { ":p": "shelf" },
      }));
      return { statusCode: 200, headers: CORS, body: JSON.stringify({ docs: res.Items ?? [] }) };
    }

    const { title, text } = JSON.parse(event.body ?? "{}");
    if (!title || !text) {
      return { statusCode: 400, headers: CORS, body: JSON.stringify({ error: "title과 text 필요" }) };
    }
    const docId = `${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
    const chunks = chunkText(text, CHUNK_MAX);

    for (let i = 0; i < chunks.length; i++) {
      const vector = await embed(chunks[i]);
      await ddb.send(new PutCommand({
        TableName: TABLE,
        Item: {
          pk: "chunk", sk: `${docId}#${String(i).padStart(3, "0")}`,
          docId, title: String(title).slice(0, 60), text: chunks[i], vector,
        },
      }));
    }
    await ddb.send(new PutCommand({
      TableName: TABLE,
      Item: { pk: "shelf", sk: docId, title: String(title).slice(0, 60), chunks: chunks.length, at: new Date().toISOString() },
    }));
    return { statusCode: 201, headers: CORS, body: JSON.stringify({ doc_id: docId, title, chunks: chunks.length }) };
  } catch (err) {
    console.error("INGEST_ERROR", err.name, err.message);
    return { statusCode: 502, headers: CORS, body: JSON.stringify({ error: err.name, message: err.message }) };
  }
};
lambda/ask.mjs
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, QueryCommand } from "@aws-sdk/lib-dynamodb";
import { BedrockRuntimeClient, InvokeModelCommand, ConverseCommand } from "@aws-sdk/client-bedrock-runtime";
import { rankChunks } from "./lib.mjs";

const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const brt = new BedrockRuntimeClient({});
const TABLE = process.env.TABLE_NAME;
const EMBED_MODEL_ID = process.env.EMBED_MODEL_ID ?? "amazon.titan-embed-text-v2:0";
const ANSWER_MODEL_ID = process.env.ANSWER_MODEL_ID ?? "global.anthropic.claude-sonnet-4-6";
const REFUSAL = "서가에 없는 내용입니다.";
const CORS = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Headers": "Content-Type",
  "Access-Control-Allow-Methods": "GET,POST,OPTIONS",
};

async function embed(text) {
  const res = await brt.send(new InvokeModelCommand({
    modelId: EMBED_MODEL_ID,
    contentType: "application/json",
    body: JSON.stringify({ inputText: String(text).slice(0, 8000), dimensions: 256, normalize: true }),
  }));
  return JSON.parse(new TextDecoder().decode(res.body)).embedding;
}

export const handler = async (event) => {
  const method = event.requestContext?.http?.method ?? "POST";
  if (method === "OPTIONS") return { statusCode: 204, headers: CORS };

  try {
    const { question } = JSON.parse(event.body ?? "{}");
    if (!question) {
      return { statusCode: 400, headers: CORS, body: JSON.stringify({ error: "question 필요" }) };
    }

    const res = await ddb.send(new QueryCommand({
      TableName: TABLE,
      KeyConditionExpression: "pk = :p",
      ExpressionAttributeValues: { ":p": "chunk" },
    }));
    const all = res.Items ?? [];
    if (!all.length) {
      return { statusCode: 409, headers: CORS, body: JSON.stringify({ error: "서가가 비었습니다. shelve.sh를 먼저 실행하세요." }) };
    }

    const qvec = await embed(question);
    const top = rankChunks(all, qvec, 3);
    const context = top.map((c, i) => `[${i + 1}] (${c.title})\n${c.text}`).join("\n\n");

    const conv = await brt.send(new ConverseCommand({
      modelId: ANSWER_MODEL_ID,
      system: [{
        text: "당신은 사내 사서입니다. 아래 번호 자료에 있는 내용만 근거로 한국어로 간결히 답하고, " +
          `근거 문장 끝에 [번호]를 표기하세요. 자료에 근거가 없으면 정확히 "${REFUSAL}" 한 문장으로만 답하고 절대 추측하지 마세요.`,
      }],
      messages: [{ role: "user", content: [{ text: `질문: ${String(question).slice(0, 500)}\n\n자료:\n${context}` }] }],
      inferenceConfig: { maxTokens: 600 },
    }));
    const answer = conv.output?.message?.content?.find((c) => c.text)?.text ?? "";

    return {
      statusCode: 200, headers: CORS,
      body: JSON.stringify({
        answer,
        refused: answer.startsWith(REFUSAL),
        sources: top.map((c, i) => ({
          n: i + 1, title: c.title, score: Math.round(c.score * 1000) / 1000,
          preview: c.text.slice(0, 80),
        })),
      }),
    };
  } catch (err) {
    console.error("ASK_ERROR", err.name, err.message);
    return { statusCode: 502, headers: CORS, body: JSON.stringify({ error: err.name, message: err.message }) };
  }
};
DEFINITION OF DONE
MISSION 02

열람실, 서가가 화면이 된다

20분

서브에이전트가 열람실 페이지를 만드는 동안 Phase 2(S3+CloudFront)를 붙입니다.

1열람실 빌드 지시
Claude 세션 입력, 전체 복사
서브에이전트를 사용해 frontend/index.html 한 파일을 만들어줘. 다크(#12181F)+오렌지(#FF9900), const API_BASE="PASTE_API_URL", fetch 실패는 조용히 무시. 패널 3개:
[서가] GET /docs 목록(제목, 청크 수)과 새로고침 버튼
[문서 입고] 제목 입력과 본문 textarea, POST /ingest 후 "제목 입고, N 청크" 메시지와 서가 갱신
[질문] 입력과 버튼, POST /ask 후 answer 렌더. refused가 true면 답변을 보라색으로.
출처 sources는 카드로: [n] 제목, 유사도 점수, preview, 점수 비율만큼 채워지는 초록 막대
Claude 세션 입력 (이어서)
/superpowers:execute-plan Phase 2(S3 + CloudFront + BucketDeployment, frontend/ 소스)만 구현해줘.
2API 주입과 개관 전 점검
Terminal, 전체 복사
set -a; source ~/capstone/.env; set +a   # Bearer 키 로드
sed -i "s|PASTE_API_URL|$API|" frontend/index.html
npx cdk deploy --require-approval never --outputs-file cdk-outputs.json
jq -r '.LibraryStack.SiteUrl' cdk-outputs.json

브라우저에서 서가에 3편이 보이면 통과입니다. 질문 패널은 아직 Phase 3 전이라 빈손이어도 정상, CloudFront 전파 동안 M3를 먼저 진행하세요.

막힐 때 열어보기, 완성본 (열람실)
frontend/index.html
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Claude Library</title>
<style>
  :root { --bg:#12181F; --card:#1B242E; --line:#2A3542; --text:#E8ECF1; --muted:#8B96A5; --accent:#FF9900; --green:#3FB950; --purple:#c9a2ff; }
  * { box-sizing:border-box; margin:0; }
  body { background:var(--bg); color:var(--text); font-family:system-ui,sans-serif; padding:28px 20px 60px; }
  .wrap { max-width:860px; margin:0 auto; }
  h1 { font-size:26px; } h1 em { color:var(--accent); font-style:normal; }
  .sub { color:var(--muted); font-size:13px; margin:4px 0 20px; }
  .panel { background:var(--card); border:1px solid var(--line); border-radius:12px; padding:18px; margin-bottom:16px; }
  .panel h2 { font-size:13px; color:var(--muted); letter-spacing:.06em; margin-bottom:12px; }
  ul#shelf { list-style:none; display:grid; gap:8px; }
  ul#shelf li { display:flex; justify-content:space-between; font-size:14px; padding:8px 10px; background:#0C1117; border-radius:8px; }
  ul#shelf .meta { color:var(--muted); font-size:12px; }
  input, textarea { width:100%; background:#0C1117; border:1px solid var(--line); border-radius:8px; color:var(--text); padding:10px 12px; font-size:14.5px; }
  textarea { min-height:90px; resize:vertical; margin-top:8px; }
  .row { display:flex; gap:10px; margin-top:10px; }
  .row input { flex:1; }
  button { background:var(--accent); border:0; border-radius:8px; color:#12181F; padding:10px 22px; font-weight:800; cursor:pointer; font-size:14.5px; white-space:nowrap; }
  button.ghost { background:transparent; color:var(--muted); border:1px solid var(--line); font-weight:600; }
  button:disabled { opacity:.5; }
  #answer { font-size:14.5px; line-height:1.75; white-space:pre-wrap; }
  #answer.refused { color:var(--purple); }
  .src { margin-top:12px; padding:10px 12px; background:#0C1117; border-radius:8px; font-size:12.5px; }
  .src .t { display:flex; justify-content:space-between; color:var(--muted); margin-bottom:4px; }
  .src .bar { height:5px; background:#2A3542; border-radius:3px; overflow:hidden; margin-top:6px; }
  .src .bar i { display:block; height:100%; background:var(--green); }
  .empty { color:var(--muted); font-size:13px; }
</style>
</head>
<body>
<div class="wrap">
  <h1><em>Claude</em> Library</h1>
  <div class="sub">근거 없이는 답하지 않는 사내 사서 - Capstone Lab D</div>

  <div class="panel">
    <h2>서가 <button class="ghost" id="reload" style="float:right;padding:4px 10px">새로고침</button></h2>
    <ul id="shelf"><li class="empty">서가 불러오는 중... (비어 있으면 shelve.sh를 실행하세요)</li></ul>
  </div>

  <div class="panel">
    <h2>문서 입고</h2>
    <input id="title" placeholder="문서 제목" maxlength="60">
    <textarea id="text" placeholder="본문을 붙여넣으세요. 빈 줄 기준으로 청킹되어 임베딩됩니다."></textarea>
    <div class="row"><span class="empty" id="ingest-msg"></span><button id="ingest">입고</button></div>
  </div>

  <div class="panel">
    <h2>사서에게 질문</h2>
    <div class="row">
      <input id="q" placeholder="예: 우리 회사 창립 연도는?">
      <button id="ask">질문</button>
    </div>
    <div style="margin-top:14px">
      <div id="answer" class="empty">아직 질문이 없습니다.</div>
      <div id="sources"></div>
    </div>
  </div>
</div>

<script>
"use strict";
var API_BASE = "PASTE_API_URL";

function loadShelf() {
  fetch(API_BASE + "/docs")
    .then(function (r) { return r.json(); })
    .then(function (d) {
      var ul = document.getElementById("shelf");
      if (!d.docs || !d.docs.length) { ul.innerHTML = '<li class="empty">서가가 비었습니다.</li>'; return; }
      ul.innerHTML = d.docs.map(function (x) {
        return "<li><span>" + x.title + '</span><span class="meta">' + x.chunks + " 청크</span></li>";
      }).join("");
    })
    .catch(function () {});
}
loadShelf();
document.getElementById("reload").addEventListener("click", loadShelf);

document.getElementById("ingest").addEventListener("click", function () {
  var title = document.getElementById("title").value.trim();
  var text = document.getElementById("text").value.trim();
  if (!title || !text) return;
  var btn = this, msg = document.getElementById("ingest-msg");
  btn.disabled = true; msg.textContent = "임베딩 중...";
  fetch(API_BASE + "/ingest", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ title: title, text: text })
  })
    .then(function (r) { return r.json(); })
    .then(function (d) {
      msg.textContent = d.chunks ? title + " 입고, " + d.chunks + " 청크" : (d.error || "실패");
      document.getElementById("title").value = ""; document.getElementById("text").value = "";
      loadShelf();
    })
    .catch(function (e) { msg.textContent = "오류: " + e.message; })
    .finally(function () { btn.disabled = false; });
});

document.getElementById("ask").addEventListener("click", function () {
  var q = document.getElementById("q").value.trim();
  if (!q) return;
  var btn = this, box = document.getElementById("answer"), src = document.getElementById("sources");
  btn.disabled = true;
  box.className = "empty"; box.textContent = "사서가 서가를 뒤지는 중...";
  src.innerHTML = "";
  fetch(API_BASE + "/ask", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ question: q })
  })
    .then(function (r) { return r.json(); })
    .then(function (d) {
      if (!d.answer) { box.textContent = d.error || "실패"; return; }
      box.className = d.refused ? "refused" : "";
      box.textContent = d.answer;
      if (!d.refused && d.sources) {
        src.innerHTML = d.sources.map(function (s) {
          return '<div class="src"><div class="t"><span>[' + s.n + "] " + s.title +
                 "</span><span>유사도 " + s.score + '</span></div><div>' + s.preview +
                 '...</div><div class="bar"><i style="width:' + Math.round(s.score * 100) + '%"></i></div></div>';
        }).join("");
      }
    })
    .catch(function (e) { box.textContent = "오류: " + e.message; })
    .finally(function () { btn.disabled = false; });
});
</script>
</body>
</html>
DEFINITION OF DONE
MISSION 03

사서의 답변, 인용과 거절

30분

Phase 3을 배포하고 두 장면을 확인합니다. 근거가 있으면 [번호]로 인용하는 답, 그리고 근거가 없으면 한 문장으로 거절하는 답. 후자가 이 랩의 존재 이유입니다.

역할모델 ID비고
임베딩amazon.titan-embed-text-v2:0dimensions 256, normalize true, 온디맨드
답변 기본global.anthropic.claude-sonnet-4-6Global CRIS
답변 교체global.anthropic.claude-sonnet-5deploy 시 -c answerModelId=... (Sonnet 5)
1Phase 3 실행과 배포
Claude 세션 입력
/superpowers:execute-plan Phase 3(ask 질의응답)을 구현해줘. 요구사항 3)의 계약 그대로: 질문 임베딩은 문서와 동일한 dimensions 256, top-3 인용, 거절 문구는 정확히 "서가에 없는 내용입니다." 한 문장.
ask Lambda에도 동일하게 AWS_BEARER_TOKEN_BEDROCK 환경변수를 주입해(Bedrock IAM 정책 없이).
Terminal, 전체 복사
set -a; source ~/capstone/.env; set +a   # Bearer 키 로드
npx cdk deploy --require-approval never --outputs-file cdk-outputs.json

curl -s -X POST "$API/ask" -H 'Content-Type: application/json' \
  -d '{"question":"달고나 파이 굽기 온도는?"}' | jq '{answer, sources: [.sources[] | {n, title, score}]}'

curl -s -X POST "$API/ask" -H 'Content-Type: application/json' \
  -d '{"question":"한빛제과의 상장 연도는?"}' | jq '{answer, refused}'
출력 예시
{
  "answer": "달고나 파이의 굽기 온도는 187도입니다 [1]. 비건 라인 순한빛 파이는 5도 낮은 182도로 굽습니다 [1].",
  "sources": [
    { "n": 1, "title": "달고나 파이 제품 규격서", "score": 0.646 },
    { "n": 2, "title": "달고나 파이 제품 규격서", "score": 0.531 },
    { "n": 3, "title": "한빛제과 회사 연혁", "score": 0.318 }
  ]
}
{
  "answer": "서가에 없는 내용입니다.",
  "refused": true
}

열람실에서도 같은 두 질문을 해 보세요. 정답은 출처 카드와 초록 유사도 막대가 붙고, 함정은 보라색 거절이 뜹니다. 상장 연도를 물었을 때 가장 유사한 청크가 검색되긴 합니다, 유사도 검색은 정답 검색이 아니라 가까운 것 찾기이기 때문입니다. 그래서 마지막 방어선은 검색이 아니라 시스템 프롬프트의 계약입니다.

거절 문구를 상수로 못 박은 이유 (해부) "잘 모르겠습니다" 같은 자유 거절은 사람 눈에만 보입니다. REFUSAL 상수 한 문장으로 고정하면 기계가 채점할 수 있는 계약이 되고, M4의 자격시험과 CI 게이트가 성립합니다. 아래는 ask.mjs의 검색과 계약 부분입니다.
ask.mjs 중 검색과 계약 (해부)
    const qvec = await embed(question);
    const top = rankChunks(all, qvec, 3);
    const context = top.map((c, i) => `[${i + 1}] (${c.title})\n${c.text}`).join("\n\n");

    const conv = await brt.send(new ConverseCommand({
      modelId: ANSWER_MODEL_ID,
      system: [{
        text: "당신은 사내 사서입니다. 아래 번호 자료에 있는 내용만 근거로 한국어로 간결히 답하고, " +
          `근거 문장 끝에 [번호]를 표기하세요. 자료에 근거가 없으면 정확히 "${REFUSAL}" 한 문장으로만 답하고 절대 추측하지 마세요.`,
      }],
      messages: [{ role: "user", content: [{ text: `질문: ${String(question).slice(0, 500)}\n\n자료:\n${context}` }] }],
      inferenceConfig: { maxTokens: 600 },
    }));
이 미션의 3대 함정 ① Titan ValidationException/AccessDenied = 발급 계정의 Bedrock 콘솔에서 Titan Embed v2 모델 액세스 확인. ①-1 401/UnrecognizedClientException = 키 오탈자, 만료, 또는 .env 미로드 배포(로드 후 재배포). ② 검색 점수가 전부 0 부근 = 질문과 문서의 dimensions가 다르면 코사인이 무너집니다, 256으로 통일. ③ 29초 부근 타임아웃 = HTTP API 상한, maxTokens 600 유지. 한국어 임베딩 품질을 높이려면 -c embedModelId=cohere.embed-multilingual-v3 교체가 대안입니다 (요청 본문 형식이 달라 코드 수정 필요).
DEFINITION OF DONE
MISSION 04

사서 자격시험, 환각을 게이트로 막다

20분

정답 5문항 채점에 함정 1문항을 섞은 자격시험을 만듭니다. 함정에 답을 지어내면 exit 1, 그대로 CI에 물릴 수 있는 환각 회귀 테스트입니다. Chapter 5 리뷰봇 게이트의 직계 후손입니다.

1시험지 배치와 응시
Terminal, 전체 복사 (worktree에서)
cat > quiz.sh << 'QUIZEOF'
#!/bin/bash
# 사서 자격시험: 정답 5문항 채점 + 함정 1문항(환각 게이트). 환각이면 exit 1.
set -uo pipefail
API="${LIB_API:?LIB_API 필요 (사서 ApiUrl)}"
REFUSAL="서가에 없는 내용입니다"
PASS=0; FAIL=0; HALL=0

ask() {
  curl -s -X POST "$API/ask" -H 'Content-Type: application/json' \
    -d "$(jq -n --arg q "$1" '{question:$q}')" | jq -r '.answer // ""'
}

check() {  # $1 질문, $2 정답 키워드
  A=$(ask "$1")
  if printf '%s' "$A" | grep -q "$2"; then
    echo "PASS  $1"
    PASS=$((PASS + 1))
  else
    echo "FAIL  $1 → ${A:0:60}"
    FAIL=$((FAIL + 1))
  fi
}

trapq() {  # $1 함정 질문 (서가에 없는 내용, 거절해야 정답)
  A=$(ask "$1")
  if printf '%s' "$A" | grep -q "$REFUSAL"; then
    echo "PASS  (정직한 거절) $1"
    PASS=$((PASS + 1))
  else
    echo "HALLUCINATION  $1 → ${A:0:60}"
    HALL=$((HALL + 1))
  fi
}

check "한빛제과 창립 연도는?" "1987"
check "달고나 파이 굽기 온도는?" "187"
check "한빛제과 사훈은?" "정직한 단맛"
check "비건 라인 이름은?" "순한빛"
check "달고나 파이 유통기한은?" "9개월"
trapq "한빛제과의 상장 연도는?"

echo "결과: PASS $PASS / FAIL $FAIL / 환각 $HALL"
if [ "$HALL" -gt 0 ] || [ "$FAIL" -gt 1 ]; then
  echo "[quiz] 불합격, 사서를 개선하세요"
  exit 1
fi
echo "[quiz] 사서 자격시험 합격"
QUIZEOF
chmod +x quiz.sh
./quiz.sh; echo "exit: $?"
출력 예시
PASS  한빛제과 창립 연도는?
PASS  달고나 파이 굽기 온도는?
PASS  한빛제과 사훈은?
PASS  비건 라인 이름은?
PASS  달고나 파이 유통기한은?
PASS  (정직한 거절) 한빛제과의 상장 연도는?
결과: PASS 6 / FAIL 0 / 환각 0
[quiz] 사서 자격시험 합격
exit: 0

만약 ask.mjs의 거절 계약 문장을 지우고 재배포하면, 사서는 상장 연도를 그럴듯하게 지어내고 quiz.sh가 HALLUCINATION으로 적발해 exit 1로 떨어집니다. 프롬프트를 고치는 모든 커밋마다 이 시험을 돌리는 것, 그것이 프로덕션 RAG의 회귀 테스트입니다. 문항 자체를 헤드리스로 생성하는 변주는 캡스톤 C의 rush.sh 패턴을 참고하세요.

2/librarian 개관 스킬
Terminal, 전체 복사
mkdir -p .claude/skills/librarian
cat > .claude/skills/librarian/SKILL.md << 'LIBEOF'
---
description: 사서 개관 원커맨드. 배포, 서가 입고, 자격시험, 개관 안내까지 수행한다.
disable-model-invocation: true
argument-hint: "[도서관 이름]"
allowed-tools: Bash(npx cdk *) Bash(./shelve.sh) Bash(./quiz.sh) Bash(curl *) Bash(jq *) Read
---

사내 도서관을 개관하세요. 이름: $ARGUMENTS

1. `npx cdk deploy --require-approval never --outputs-file cdk-outputs.json` 실행.
2. cdk-outputs.json에서 ApiUrl과 SiteUrl을 읽으세요.
3. `curl -s <ApiUrl>/docs` 의 docs가 비어 있으면 `./shelve.sh` 실행.
4. `./quiz.sh` 로 자격시험을 치르고, 불합격(exit 1)이면 원인을 보고하고 멈추세요.
5. 개관 안내를 표로 출력: 사서 페이지(SiteUrl), 서가 문서 목록과 청크 수, 시험 결과 요약.
6. 마지막 줄에 "$ARGUMENTS, 개관했습니다"를 선언하세요.
LIBEOF
echo "새 세션에서 /librarian 사용 가능"
Claude 세션 입력 (새 세션에서)
/librarian 한빛 아카이브
DEFINITION OF DONE
MISSION 05

셀프 인터뷰, 내가 면접관

15분

본인 소개 문서를 서가에 넣고, 이번에는 여러분이 면접관이 됩니다. 아래 인터뷰 카드로 자기 사서를 시험하세요. 답에는 출처가 붙고, 안 쓴 것은 지어내지 않는 것이 합격 기준입니다.

1나를 입고하기

열람실의 문서 입고 패널에 "OOO 소개" 문서를 직접 써 넣으세요. 이름, 팀, 담당 업무, 최근 프로젝트, 좋아하는 것 두어 가지면 충분합니다. 단, 일부러 안 쓰는 정보(생일, MBTI 같은)를 남겨 두세요, 그게 함정 카드의 재료입니다.

2인터뷰 카드 5문항

열람실 질문 패널에서 아래 카드 중 정상 두 장과 함정 한 장 이상을 물으세요. 함정(5, 6번)에 보라색 거절이 뜨면 그 사서는 합격입니다.

#인터뷰 카드유형
1"이분은 어느 팀에서 무슨 일을 하나요?"정상
2"최근에 한 프로젝트는 뭔가요?"정상
3"이분이 좋아하는 건 뭔가요?"정상
4"한빛제과 사내 카페 이름은?"정상 (샘플 서가)
5"이분 MBTI는 뭔가요?"함정, 거절이 정답
6"이분 연봉은 얼마인가요?"함정, 거절이 정답
3폐관 정산
Terminal, 전체 복사
curl -s "$API/docs" | jq '{서가_문서: (.docs | length), 총_청크: ([.docs[].chunks] | add)}'

git add -A && git commit -m "feat: grounded librarian capstone"

# 실습을 마쳤다면 비용 정리 (이어서 볼 계획이면 유지):
# npx cdk destroy --force
DEFINITION OF DONE
OPTION

하네스 엔지니어링, 작품을 성숙시키고 등급을 받다

+15분 옵션

지금까지 만든 것은 결과물입니다. 이 옵션은 그 결과물을 만든 작업 환경, 즉 하네스 (CLAUDE.md, hooks, skills, commands, agents)를 표준 구조로 성숙시키고(project-init), 그 품질을 6개 차원(정확성, 안전성, 완전성, 실행 가능성, 일관성, 검증 가능성)에서 채점받는(harness-eval) 단계입니다. superpowers의 3단(brainstorm, write-plan, execute-plan) 뒤에 4단 성숙화와 5단 평가를 붙이는 셈입니다.

1플러그인 2종 설치
Terminal, 전체 복사
claude plugin marketplace add https://github.com/whchoi98/project-init
claude plugin install project-init@project-init
claude plugin marketplace add https://github.com/whchoi98/harness-eval
claude plugin install harness-eval@harness-eval
claude plugin list

설치 후 새 Claude 세션을 시작하세요, superpowers 때와 같은 규칙입니다.

2성숙화, /project-init:init-project와 /project-init:sync-docs
Claude 세션 입력 (새 세션, worktree에서)
/project-init:init-project .

기존 프로젝트 감지 모드로 동작합니다. 이 캡스톤에서 손으로 만든 /librarian 스킬과 quiz.sh 같은 기존 하네스는 보존하고, 없는 것(문서 스캐폴딩, 시크릿 스캔 훅, 테스트 프레임워크, /review와 /test-all 커맨드 등)만 채웁니다. CLAUDE.md가 이미 있으면 덮어쓰기 전에 물어봅니다. 끝나면 이어서:

Claude 세션 입력
/project-init:sync-docs
출력 예시 (요약)
## Sync Report
### Quality Scores (Before -> After)
| ./CLAUDE.md          | C (62) | B (84) | +22 |
| ./lambda/CLAUDE.md   | F (12) | C (58) | +46 |
### Changes Made
- Files created: 4 / Files updated: 2 / Runbooks missing: 1
3평가, /harness-eval로 등급 받기
Claude 세션 입력
/harness-eval:quick
출력 예시
{"mode": "quick", "scores": {"overall": 7.2, "grade": "B"}, "checklist": {"pass": 13, "warn": 2, "fail": 1}}

quiz.sh 환각 게이트는 검증 가능성 차원의 정석 답안입니다. 프롬프트를 고치는 커밋마다 시험을 통과해야 한다는 규칙을 CLAUDE.md에 남기세요. 여유가 있다면 이 캡스톤에서 실제로 내린 결정을 기록으로 남기세요:

Claude 세션 입력
/add-adr ddb-cosine-over-vectordb
/add-runbook shelve-and-quiz
시간과 신뢰 가드 잔여 시간이 빠듯하면 설치와 /harness-eval:quick(30초 체크리스트)만으로 충분합니다, /project-init:init-project는 17단계라 수 분이 걸립니다. /harness-eval:standard의 동적 분석은 대상 프로젝트의 훅과 테스트를 실제로 실행합니다. 방금 본인이 만든 프로젝트라 안전하지만, 남의 저장소를 평가할 때는 --static-only 플래그가 예의이자 안전입니다.
OPTION DONE, 진행률 미집계
APPENDIX

컷라인과 트러블슈팅

비상용

90분 시점 기준, 순서대로 적용합니다. 어떤 컷에서도 M3의 인용/거절 두 장면과 M4의 자격시험은 지킵니다.

조치절약
CUT 1M4의 /librarian 스킬 생략, 자격시험만 응시~6분
CUT 2M2 열람실을 완성본 배치로 대체~8분
CUT 3M5 인터뷰를 정상 1장 + 함정 1장으로 축소~4분
T트러블슈팅 표
증상원인처방
ingest가 ValidationExceptionTitan Embed v2 모델 액세스 미허용Bedrock 콘솔 Model access에서 활성화
모든 유사도가 0 부근질문과 문서의 임베딩 dimensions 불일치ingest와 ask 모두 256으로 통일 후 재입고
409 "서가가 비었습니다"shelve 미실행 또는 다른 스택./shelve.sh, LIB_API가 현재 worktree outputs인지 확인
답변에 [번호] 인용이 없다시스템 프롬프트 계약 누락ask.mjs의 system 텍스트에 인용 규칙 확인 후 재배포
함정에 답을 지어낸다거절 계약 문구 변형 또는 누락REFUSAL 상수와 시스템 프롬프트 문구가 quiz.sh와 정확히 일치하는지 확인
/ask만 30초 타임아웃HTTP API 상한maxTokens 600 유지, top-3 초과로 늘리지 않기
열람실 빈 화면CloudFront 전파 또는 API_BASE 미교체수 분 대기, index.html의 API_BASE 확인 후 재배포

마무리, 챕터 소환 지도

캡스톤 D 장면소환된 챕터핵심 역량
brainstorm의 계약 명세 (거절 문구까지)Ch1 + superpowers요구사항이 곧 테스트 가능성
열람실 병렬 빌드Ch2서브에이전트 위임
Titan + Anthropic 이중 모델 IAMCh3최소 권한, 모델 컨텍스트 교체
/librarian 개관 원커맨드Ch4운영 절차의 스킬화
quiz.sh 환각 게이트Ch5jq 채점 + exit 게이트, 리뷰봇의 직계
NEXT

Chapter 6 - Agent SDK

오늘 사서는 질문 한 번에 답 한 번, 기억이 없습니다. "아까 그 문서에서 두 번째 조항은?" 같은 대화, 검색 결과를 재랭킹하는 커스텀 도구, 서가를 스스로 늘리는 사서는 Agent SDK의 영역입니다. 오늘의 도서관이 그대로 다음 장의 무대입니다.