프롬프트 하나로 오늘과 내일을 가르다 — 아이벤트 AI 브리핑의 컷오프 로직

아이벤트의 AI 브리핑은 매일 아침과 저녁, 부모가 앱을 열기 전에 자녀의 오늘/내일 일정과 알림장 내용을 한 문단으로 요약해준다. 이 기능을 구현하면서 가장 까다로웠던 부분은 모델 선택이나 API 연동이 아니라 “지금이 오늘 브리핑을 줄 시점인가, 내일 브리핑을 줄 시점인가”를 판단하는 로직과, 그 판단에 맞춰 프롬프트를 정확히 분기하는 일이었다.

고정 시각 cron이 아니라 동적 컷오프

AiBriefingJobcron_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를 로그에 남기고 명시적 예외 발생

결국 프롬프트 엔지니어링의 절반은 모델에게 무엇을 시킬지가 아니라, 모델에게 넘기기 전에 얼마나 정돈된 데이터를 만들어주는가에 달려 있었다.

글쓴이

admin

https://ivent-admin.storyqbe.top/ 를 운영하고 있는 강프로입니다.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다