아이벤트의 AI 브리핑은 매일 아침과 저녁, 부모가 앱을 열기 전에 자녀의 오늘/내일 일정과 알림장 내용을 한 문단으로 요약해준다. 이 기능을 구현하면서 가장 까다로웠던 부분은 모델 선택이나 API 연동이 아니라 “지금이 오늘 브리핑을 줄 시점인가, 내일 브리핑을 줄 시점인가”를 판단하는 로직과, 그 판단에 맞춰 프롬프트를 정확히 분기하는 일이었다.
고정 시각 cron이 아니라 동적 컷오프
AiBriefingJob은 cron_expression = "0 3,21 * * *"로 새벽 3시와 밤 9시에 두 번 실행된다. 하지만 이 두 시각이 그대로 “오늘 모드”/“내일 모드”를 결정하지는 않는다. 자녀마다 하교 시각과 학원 스케줄이 다르기 때문에, 그룹별로 그날 일정이 끝나는 시각을 계산해서 컷오프를 정한다.
def _max_schedule_end_minutes(self, db, children, today):
"""그룹 내 모든 자녀의 학교 하교 + 학원 종료 시각 중 MAX (분 단위). 없으면 None."""
max_mins = None
for child in children:
dismissal = getattr(child, DISMISSAL_FIELDS[weekday], None)
if dismissal:
h, m = map(int, dismissal.split(":"))
mins = h * 60 + m
if max_mins is None or mins > max_mins:
max_mins = mins
for slot in academy_slots(child):
end_mins = slot.start_hour * 60 + slot.start_minute + slot.duration_minutes
if max_mins is None or end_mins > max_mins:
max_mins = end_mins
return max_mins
이렇게 구한 schedule_end와 기본값 18시(DEFAULT_CUTOFF_MINUTES) 중 더 이른 시각을 컷오프로 쓴다.
cutoff = min(schedule_end, DEFAULT_CUTOFF_MINUTES) if schedule_end else DEFAULT_CUTOFF_MINUTES
is_tomorrow = current_mins >= cutoff
학원이 저녁 7시에 끝나는 아이라면 컷오프는 18시(기본값)가 아니라 더 이른 시각으로 당겨질 수 있고, 반대로 학원이 없는 날은 18시가 그대로 컷오프가 된다. 밤 9시 실행분은 대부분 “내일 모드”로 떨어지지만, 하루 일과가 유난히 늦게 끝나는 그룹이면 여전히 “오늘 모드”로 처리될 수 있다는 뜻이다.
시스템 프롬프트를 통째로 두 벌 둔 이유
SYSTEM_PROMPT(오늘용)와 SYSTEM_PROMPT_TOMORROW(내일용)는 조건문으로 문구 몇 개만 바꾸는 대신 처음부터 별개의 상수로 분리되어 있다. 두 프롬프트의 규칙은 9개 중 8개가 사실상 동일하고, 차이는 이 정도다.
- 특이사항이 없을 때 출력하는 문장:
오늘은 특별한 일정이 없어요vs내일은 특별한 준비사항이 없어요 - 최우선 처리 태그:
[오늘마감!]vs[내일마감!] - 내일 프롬프트에만 있는 10번째 규칙:
"내일"이라는 단어를 사용하여 내일 일정임을 명확히 할 것
한 프롬프트에 {if is_tomorrow} 같은 분기를 넣지 않고 통째로 복제한 이유는, 모델이 조건부 지시보다 확정된 문장을 훨씬 안정적으로 따르기 때문이다. “내일 모드일 때는 다음 문장을 써라” 같은 메타 지시는 프롬프트가 길어질수록 누락되기 쉽지만, 애초에 그 모드용 프롬프트를 통째로 보내면 모델이 헷갈릴 여지가 없다.
DB 로우를 LLM이 읽을 텍스트로 직렬화하기
시스템 프롬프트가 “어떻게 요약할지”를 정한다면, _build_user_message는 “무엇을 요약할지”를 만든다. 일정 테이블과 알림장 테이블을 조인해서 사람이 읽기 쉬운 텍스트 블록으로 펼친다.
오늘 날짜: 2026-07-01 (수요일)
자녀 수: 2명 (민준, 서연)
[오늘 일정]
< 민준 >
- 하교: 15:20
- 태권도 (학원): 16:00-17:00 (서초동)
[최근 알림장 (7일 이내)]
* 민준 | 07/01 학교
[숙제] 수학 받아쓰기: 교과서 42페이지 [오늘마감!]
[준비물] 찰흙 (~07/03)
알림장은 최근 7일치를 모두 넘기되, due_date가 오늘(또는 내일)과 일치하는 항목에만 [오늘마감!]/[내일마감!] 태그를 붙인다. 시스템 프롬프트의 6번 규칙이 “이 태그가 붙은 항목을 반드시 첫 번째로 언급하라”고 못 박아두었기 때문에, 마감 임박 항목이 요약 중간에 묻히는 일이 없다. 우선순위 판단을 모델의 자체 추론에 맡기지 않고, 데이터 준비 단계에서 태그로 미리 표시해 넘기는 편이 훨씬 안정적이었다.
첫 줄만 골라 푸시로 보낸다
모델 응답은 앱 안에서 전체 브리핑으로 보여주고, 푸시 알림에는 그중 한 줄만 노출한다. 별도로 요약을 다시 요청하지 않고, 프롬프트 규칙 자체에 “최우선 항목을 첫 줄에 써라”를 넣어둔 뒤 파싱 단계에서 첫 줄만 잘라 쓴다.
def _parse_response(self, response: str) -> tuple[str, str]:
briefing_text = response.strip()
lines = [line.strip() for line in briefing_text.splitlines() if line.strip()]
push_text = lines[0] if lines else briefing_text[:100]
push_text = push_text.replace("**", "")[:100]
return briefing_text, push_text
본문에는 굵게 표시를 위해 마크다운 **가 남아있지만, 푸시 텍스트에는 이를 제거한다. 별도 LLM 호출 없이 규칙 순서 + 문자열 슬라이싱만으로 앱 알림과 인앱 문구를 동시에 해결한 셈이다.
thinking_budget이 다 타버리면 응답이 비어있다
LocalLLMClient는 OpenAI 호환 /chat/completions를 호출하면서 reasoning 모델을 위한 thinking.budget_tokens를 함께 넘긴다. 처음 이 기능을 붙였을 때 종종 content가 빈 문자열로 오는 케이스가 있었는데, 원인은 모델이 추론(thinking) 토큰을 예산만큼 다 쓰고 정작 최종 답변을 낼 토큰이 남지 않은 경우였다.
if not content.strip():
finish_reason = data["choices"][0].get("finish_reason", "unknown")
reasoning_tokens = data.get("usage", {}).get("completion_tokens_details", {}).get("reasoning_tokens", 0)
raise ValueError(
f"LLM returned empty content (finish_reason={finish_reason}, reasoning_tokens={reasoning_tokens}). "
"max_tokens가 너무 작거나 thinking_budget이 전체 토큰을 소진했을 수 있습니다."
)
이 예외를 넣고 나서야 실패 원인을 바로 알 수 있게 됐다. 그 전에는 그냥 “빈 브리핑이 생성됨”으로만 보여서, 프롬프트가 잘못됐는지 파싱이 잘못됐는지 한참을 헤맸다. reasoning_tokens 값을 로그에 남겨두면 max_tokens를 늘려야 하는지 thinking_budget을 줄여야 하는지 바로 판단할 수 있다.
정리
| 문제 | 해결 방식 |
|---|---|
| 오늘/내일 모드 전환 | 고정 시각이 아니라 그룹별 하교·학원 종료 시각 기반 동적 컷오프 |
| 모드별 지시 누락 방지 | 조건부 문구 대신 시스템 프롬프트 자체를 통째로 분리 |
| 마감 임박 항목 우선순위 | 모델 추론에 맡기지 않고 데이터 단계에서 태그로 표시 |
| 푸시 문구 생성 | 추가 LLM 호출 없이 첫 줄 추출 + 마크다운 제거 |
| reasoning 모델 빈 응답 | finish_reason·reasoning_tokens를 로그에 남기고 명시적 예외 발생 |
결국 프롬프트 엔지니어링의 절반은 모델에게 무엇을 시킬지가 아니라, 모델에게 넘기기 전에 얼마나 정돈된 데이터를 만들어주는가에 달려 있었다.