프롬프트 변수 시스템 — instruction-variables 코드레벨 동작 흐름
마지막 업데이트 2026-07-22
TL;DR
AI 지시문(activity.instruction)에 박힌 변수 토큰을 실제 값으로 치환해 OpenAI Realtime에 보내는 시스템. 네 가지 문법이 공존한다 — #{}(프롬프트 변수, DB 전역), @{}(프리셋/정체성 변수, 아동·핑퐁이 이름 + 한국어 조사 자동), ${}(커스텀 변수, 활동별), {{}}(대화 흐름 사용자 변수). 런타임 펼침은 replaceAllVariables가 #{} → @{}/${} → {{}} 3단으로 처리한다.
핵심 묘미는 한국어 조사 처리다 — @{아동명아}는 받침 유무를 보고 "지우야" vs "현우아"를 만든다. 그리고 미해결 변수는 원문 그대로 남는다(매핑 실패 디버깅의 단서). 1,000줄 넘는 instruction-variables.ts가 파싱·조사·특수변수·치환을 담당한다.
네 가지 변수 문법
| 문법 | type | 값 출처 | 예 |
|---|---|---|---|
#{name} | prompt | DB 전역 프롬프트 변수(getPromptVariables) → content | #{공통규칙} |
@{name} | preset | 아동·핑퐁이 정체성(getPresetVariables) + 특수 조사 변수 | @{아동명} · @{아동명아} |
${name} | custom | 활동별 사용자 변수(activity.variables) | ${목표단어} |
{{name}} | 대화 흐름 | conversation flow 사용자 변수 | {{지난수업}} |
런타임 펼침 — replaceAllVariables
수업 진입 시 openai-create-call.ts가 지시문을 펼쳐 OpenAI Realtime call의 instruction으로 보낸다. lib/openai-create-call.ts:64-79:
const promptVars = await getPromptVariables(); // DB #{}
const promptVarMap = Object.fromEntries(promptVars.map(v => [v.name, v.content]));
const presetVars = getPresetVariables(user, avatar, subAvatars); // @{} 정체성
const userVars = activity.variables || {}; // ${}
finalInstruction = replaceAllVariables(activity.instruction, promptVarMap, presetVars, userVars);
resolvePromptVariables — #{}#{name}을 promptVarMap[name](DB content)로 치환. 전역 재사용 블록을 먼저 펼친다.replaceInstructionVariables — @{} · ${}@{})와 커스텀 변수(${})를 치환. 한국어 조사·특수변수 맵을 적용.replaceConversationFlowUserVariables — {{}}{{}})를 마지막으로 치환.파싱 — parseInstructionVariables
instruction-variables.ts:25-67. 세 정규식을 각각 돌려 토큰을 수집하고 위치순 정렬한다.
const presetVarRegex = /@\{([^}]+)\}/g; // type: "preset"
const userVarRegex = /\$\{([^}]+)\}/g; // type: "custom"
const promptVarRegex = /#\{([^}]+)\}/g; // type: "prompt"
// 각 토큰: { type, name, fullMatch, startIndex, endIndex } → startIndex 기준 정렬
preset 이름은 normalizeVariableName으로 정규화돼 한글·영문 별칭이 모두 동작한다(@{아동명} == @{childName}).
★ 한국어 조사 자동 — getKoreanParticle
핵심 기능. 이름 끝 글자의 받침(종성) 유무로 조사를 고른다. instruction-variables.ts:69-91:
const lastCharCode = name[name.length - 1].charCodeAt(0);
// 한글 음절 영역(0xAC00~0xD7A3) 밖이면 기본값(야/랑)
const hasBatchim = (lastCharCode - 0xac00) % 28 !== 0; // 받침 있음?
return particleType === "아"
? (hasBatchim ? "아" : "야") // 현우+아, 지우+야
: (hasBatchim ? "이랑" : "랑"); // 현우+이랑, 지우+랑
특수 변수(@{아동명아}·@{아동+는/이는}·@{핑퐁+야/이야} 등)는 buildSpecialVarMap이 프리셋 값으로 조합한다. 단, 의존 프리셋이 비어 있으면 만들지 않는다(:215-220) — 그 경우 토큰은 원문 그대로 남는다.
치환 메커니즘 — replaceInstructionVariables
- 커스텀 변수 먼저 전처리:
userVars값 안에 들어 있는@{}프리셋을 먼저 펼친 뒤(:260-284), instruction 본문을 치환. (중첩 한 단계 지원.) - 끝→앞(역순) 치환: 토큰을
endIndex큰 것부터 바꾼다(:290). 정방향으로 바꾸면 뒤 토큰의startIndex가 어긋나기 때문. - 미해결은 원문 유지:
getReplacement는 프리셋/특수 맵에 값이 없거나 빈 문자열이면fullMatch(원래@{...})를 그대로 돌려준다(:226-244).
프롬프트 변수 CRUD (#{})
/api/prompt-variables — DynamoDB 전역 변수. app/main/prompt-variable 관리 페이지.
- GET
withAuthMember, POST/수정/삭제withAuthAdminOrDeveloper. - 중복 이름 금지: 같은
name이 있으면 409DUPLICATE_NAME. - 중첩 금지: content에
#{}가 들어 있으면 400NESTED_PROMPT_VARIABLE_NOT_ALLOWED(prompt-variables/route.ts:55) — 무한 펼침 방지. /[id]/usage: 이 변수를 참조하는 곳 추적.
하이라이팅 — highlightInstructionVariables
lib/instruction-highlight.ts. 편집기에서 변수 토큰을 색으로 구분(preset/custom/prompt/text). 유효하지 않은 프리셋 이름(getValidPresetVariableNames에 없음)은 일반 텍스트로 표시해, 오타를 변수처럼 강조하지 않는다.
코드 맵 — 파일별 역할
| 파일 | 역할 |
|---|---|
| lib/instruction-variables.ts (~1,048L) | 파싱·조사·특수변수·치환·replaceAllVariables 통합 엔진 |
| lib/instruction-highlight.ts | 편집기 변수 하이라이팅 세그먼트 |
| types/instruction-variables.types.ts | PRESET_VAR_KEYS·PRESET_VAR_EN(한↔영 별칭)·SPECIAL_VAR_MAP_KEYS |
| lib/openai-create-call.ts | 런타임 펼침 — getPromptVariables+getPresetVariables → replaceAllVariables → finalInstruction |
| app/api/prompt-variables/* | #{} 전역 변수 CRUD + /usage (중복·중첩 검증) |
| app/main/prompt-variable · app/main/prompt-test | 변수 관리 / 프롬프트 테스트 UI |
읽을 때 주의할 함정
1. 네 문법을 혼동하지 말 것. #{}(DB 전역)·@{}(정체성+조사)·${}(활동 커스텀)·{{}}(대화 흐름)은 출처도 처리 단계도 다르다. 처리 순서는 #{} → @{}/${} → {{}}(:1034-1036). 같은 토큰이 안 펼쳐지면 어느 단계/출처인지부터 확인.
2. 미해결 변수는 원문 그대로 출력된다. 매핑이 없거나 의존 프리셋이 비면 @{아동명}이 그대로 instruction에 남는다(:240). 최종 instruction에 @{}/#{}가 남아 있으면 = 매핑 실패. AI가 "@{아동명}"을 그대로 읽는 증상의 원인.
3. 한국어 조사는 받침으로 결정. (charCode − 0xAC00) % 28 !== 0이면 받침 있음 → "아/이랑", 없으면 "야/랑"(:84). 한글 음절 영역 밖(영문·숫자)이면 기본값(야/랑). 이름이 영문이면 조사가 어색할 수 있다.
4. 치환은 반드시 끝→앞. 토큰을 startIndex 작은 것부터 바꾸면 뒤 토큰들의 인덱스가 밀려 깨진다. 역순(endIndex 큰 것부터)이라 인덱스가 유지된다(:290). 이 로직을 만질 때 방향을 바꾸면 안 된다.
5. 중첩은 한 단계만. 커스텀 변수 값 안의 @{}는 펼쳐지지만(:263), #{} 프롬프트 변수는 content에 #{}를 넣는 것 자체가 저장 단계에서 금지된다(NESTED_PROMPT_VARIABLE_NOT_ALLOWED). 깊은 재귀 펼침은 설계상 없다.
6. #{} 표기가 알림톡과 겹치지만 별개 엔진. 알림톡도 #{key}를 쓰지만 거긴 단순 replaceAll이다. 이 문서의 프롬프트 변수는 DB 조회 + 파싱 엔진을 거친다. 같은 표기, 다른 시스템.