사용자의 비서 기능은 “다음 7일”을 약속하며 daysAhead: 7을 전송하고, 조용히 사용자의 달력에 추가적인 날짜를 부여합니다. 그 답변은 자신감 있게 들립니다. 하지만 달력은 틀릴 수 있습니다.
Lab note 019: 에이전트가 데이터를 가져오기 전에 이러한 모호성을 제거할 수 있을까요?
네, 작은 어댑터만 있으면 가능합니다. 문제는 데이터 조회(fixture) 날짜 버킷에 완전히 명시된 시간대 계약(timezone contract)이 없다는 것이므로, 단순히 산술 연산만으로는 사용자의 로컬 달력 전체 범위를 확립할 수 없습니다.
daysAhead는 실제로 무엇을 계산하나요?
요청하는 달력 날짜가 N개일 경우, daysAhead를 N−1로 설정하고 사용자 시간대에서 해결된 명시적인 date를 제공해야 합니다.
Actor는 포함된 시작 날짜 이후의 추가 날짜를 계산합니다. 기본 daysAhead 값은 0이며, 허용 범위는 최대 31까지입니다. date를 생략하면 시작일은 오늘 UTC가 됩니다.
이것만으로도 추가적인 날짜에 대한 설명이 충분합니다. 하지만 여기서 “오늘”이 무엇을 의미하는지는 결정하지 못합니다.
저희 애플리케이션 계약은 로컬 시작 날짜, 달력 날짜의 개수, 그리고 IANA 시간대를 받습니다. 이는 로컬 자정 경계(local midnight boundaries)를 구성하고 이를 UTC로 변환한 다음, 두 가지 유형의 날짜 레이블을 모두 커버하는 후보 데이터 조회를 계획합니다:
import json
import math
import os
...
끝점은 배타적(exclusive)입니다. 다음 기간의 시작 시점에 정확히 위치한 데이터는 그 다음 기간에 속하므로, 인접한 달력 요청을 결합하기가 더 쉬워집니다.
두 자정 경계 모두를 별도로 구성하는 것은 일광 절약 시간제(daylight-saving changes) 변경 시 중요합니다. 달력은 로컬 날짜를 따르며, 고정된 경과 기간을 추가하면 다른 로컬 시간에 위치할 수 있습니다.
이 구현은 일반적인 로컬 자정 경계를 목표로 합니다. 만약 애플리케이션이 자정 시간 변화나 건너뛴 날짜가 있는 시간대를 지원한다면, 존재하지 않거나 모호한 경계에 대한 명시적 정책을 추가해야 합니다.
MCP를 달력 경계 뒤에 배치하기
MCP 연결은 Actor를 실행 도구로 사용할 수 있게 만듭니다.
APIFY_TOKEN이 호스트 환경에서 상속되는 예시 MCP 클라이언트 구성:
{
"mcpServers": {
"fixtures": {
...
이 연결은 Actor의 입력을 노출합니다. 어댑터를 설치하거나 애플리케이션의 캘린더 정책을 강제하지 않습니다.
이를 애플리케이션의 도구 경계(tool boundary) 뒤에 유지하십시오. 답변 에이전트에게 캘린더 의도(calendar intent)를 표현하는 인자(arguments)를 가진 더 좁은 fixture_window 도구를 제공한 다음, 애플리케이션 코드에서 가져오기 입력값(fetch inputs)을 생성하세요.
아래의 Python 구현은 실행을 위해 SDK를 사용합니다. 대신 MCP 호스트는 생성된 입력값을 연결된 Actor 도구로 보내고 반환되는 행(rows)에 동일한 필터링을 적용할 수 있습니다. 요청마다 실행 경로를 선택해야 합니다.
에이전트에게 “시간대(timezone)를 기억하도록” 프롬프팅하는 것은 유용한 힌트입니다. 시간대를 요구하는 래퍼(wrapper)는 강제 가능한 계약(enforceable contract)입니다.
에이전트가 의도를 선택하도록 하라
캘린더 비서(calendar assistant)는 창(window)을 선택해야 합니다. 경기 일정 조회(fixture lookup)를 경기 해설로 바꾸기 위해 허가받을 필요는 없습니다.
| 결정 | 에이전트 제어 | 애플리케이션 책임 |
|---|
| 로컬 시작 날짜 | 요청으로부터 제안 | 명시적인 시계에 대한 상대 언어 해결 |
| ... | | |
“오늘(today)”의 경우, 계정의 시간대를 사용하여 시간대 인식(timezone-aware)된 현재 순간으로부터 날짜를 파생해야 합니다. “이번 주(this week)”의 경우, 애플리케이션은 또한 주 시작 규약(week-start convention)이 필요합니다.
두 설정 모두 서버의 로컬 시계에서 추론하지 마십시오. 배포 지역(deployment region)은 사용자 선호도가 아닙니다.
이 예시에서는 축구(Football)가 고정되어 있습니다. 더 광범위한 애플리케이션은 에이전트에게 나머지 스키마를 제공하지 않으면서 허용되는 스포츠 선택 목록을 노출할 수 있습니다.
이 경로에서는 통계(statistics), 라인업(lineups), 사건(incidents), 해설(commentary)을 비활성화합니다. 경기 상세 정보는 사용자가 일정을 선택한 후 별도의 요청일 수 있습니다. 작업이 화요일 날짜를 찾는 것이라면, 포메이션은 비용이 많이 드는 방해 요소입니다.
상한선(cap) 역시 애플리케이션 정책입니다. maxItems: 0은 사용자 제한을 제거하지만 10,000이라는 하드캡(hard cap)은 유지하므로 무제한의 달력을 의미하지 않습니다.
로컬 날짜를 강제하기 전에 후보 날짜 가져오기
의도된 캘린더 창(intended calendar window)과 검색 창(retrieval window)은 서로 다른 목적을 수행합니다.
로컬 간격은 자체 로컬 날짜 레이블 밖에 있는 UTC 날짜에 닿을 수 있습니다. 플래너는 해당 레이블들의 합집합(union)을 커버하며, 요청당 경계(per-request bound)를 준수하기 위해 필요한 경우 후보 범위를 분할합니다.
예시 어댑터 흐름:
Local dates + explicit timezone
|
v
...
더 넓은 범위의 가져오기(fetch)는 방어적인 과잉 가져오기(defensive overfetching)입니다. 이는 업스트림 서비스가 어떻게 경기 일정(fixtures)을 날짜 버킷에 할당하는지 정의하지 않습니다.
완전한 로컬 캘린더를 약속하기 전에, 관련 자정 경계 근처의 알려진 경기 일정과 그 할당을 확인해야 합니다. 오후 한가운데에 편안하게 있는 경기 일정은 자정 근처 킥오프를 어떤 날짜 버킷이 소유하는지에 대해 거의 알려주지 않습니다.
이러한 구별은 매력적인 실수를 방지합니다: 깨끗하게 필터링된 결과를 검색이 간격을 커버했다는 증거로 취급하는 것입니다. 필터링은 원치 않는 일치를 제거할 수 있지만, 누락된 것을 불러올 수는 없습니다.
모호한 킥오프 시간 격리
사용 가능한 필드 계약(field contract)은 startTime과 startTimestamp를 모두 이름으로 가지고 있지만, 숫자 타임스탬프 단위를 명시하지 않습니다.
우리는 단위 추측을 캘린더 약속으로 만드는 것보다 모호한 킥오프 시간을 격리하는 것을 선호합니다.
파서는 시간대 인식(timezone-aware)된 startTime을 요구합니다. 숫자 타임스탬프는 해석이 해당 순간과 일치할 때만 초 또는 밀리초로 허용되며, 누락되었거나 비숫자형인 타임스탬프는 startTime으로 폴백(fall back)되어 계산됩니다.
그런 다음 가져오기 로직은 간격을 강제하고 eventId를 기준으로 중복 제거(deduplicate)합니다:
def fixture_instant(row):
try:
instant = datetime.fromisoformat(row["startTime"].replace("Z", "+00:00"))
...
중복 제거는 이벤트의 첫 번째 적격 관측값만 유지합니다. 별도로 시간 지정된 새로고침을 결합하는 경우, 변경되는 점수나 상태에 이 접근 방식을 적용하기 전에 신선도 정책(freshness policy)을 정의해야 합니다.
실패한 가져오기는 겉보기에 완전한 캘린더를 반환하는 대신 예외(raise)를 발생시킵니다. 빈 성공 결과와 실패한 검색은 서로 다른 에이전트 답변으로 이어져야 합니다.
마찬가지로, '도달한 제한(reached cap)'은 추가적인 경기(fixture)가 존재한다는 증거가 아니라 가능한 절단(truncation)에 대한 경고입니다. 이러한 불확실성을 명시적으로 유지하는 것이 단순히 조심하라는 모호한 지침보다 답변 에이전트에게 더 유용한 정보를 제공합니다.
컨텍스트 창에 커버리지를 보이게 유지하기
모델은 경기 식별자(fixture identity), 시작 시간(kickoff), 참가자, 상태를 필요로 합니다. 달력 질문에 답하기 위해 사용 가능한 모든 경기 필드를 필요로 하는 경우는 거의 없습니다.
컨텍스트 예산(context budget)을 적용하기 전 필드를 투영합니다. 누락된 소스 필드는 생략되므로, 래퍼는 값을 임의로 생성하는 대신 그러한 부재 자체를 보존합니다.
이 래퍼는 문자 예산이 나중 경기들을 생략할 때 전체 레코드와 보고서를 유지합니다:
def pack_calendar(rows, metadata, max_chars):
fields = (
"eventId", "sport", "startTime", "startTimestamp", "homeTeam",
...
그 메타데이터 키들은 액터(Actor) 기록에 속하는 것이 아니라 저희 래퍼에 속합니다.
문자 예산은 정확한 토큰 예산이 아니라 전송 가드(transport guard)입니다. 호스트는 답변 모델의 토크나이저를 사용하여 최종 도구 메시지를 계산하고 대화와 답변을 위한 공간을 확보해야 합니다.
기록이 시작 시간(kickoff) 순으로 정렬되어 있기 때문에, 패킹은 더 이른 경기를 선호합니다. 이는 “다음 것은 무엇인가”라는 질문에는 합리적이지만, 누락된 정보는 에이전트가 요청된 전체 기간을 설명할 수 없다는 것을 의미합니다.
달력 인터페이스의 경우, 전체 적격 컬렉션(eligible collection)을 모델 컨텍스트 외부로 유지해야 합니다. 인터페이스가 컬렉션을 렌더링하거나 페이지를 넘기는 동안 모델이 제한된 선택(bounded selection)을 요약하도록 하십시오.
답변 정책은 커버리지가 검증되지 않았거나, 검색이 제한에 도달했거나, 기록이 거부되었거나, 패킹이 이벤트를 생략하는 경우 “모든 경기(all fixtures)”라는 표현을 금지해야 합니다. “모든”이라는 단어는 확신이 아닌 증거를 필요로 합니다.
산술 계산은 애플리케이션 코드에 구현하기
작동하는 페처(worked fetcher)는 SofaScore 라이브 스코어, 순위 및 팀 검색 데이터입니다.
유용한 수정은 실행 전에 발생합니다. 캘린더 언어를 명시적인 경계로 해석한 다음, 액터(Actor) 입력을 생성하는 방식입니다.
사용자의 날짜 개수를 daysAhead에 직접 전달하는 방식은 작동하지 않습니다. "오늘"을 UTC 기본값으로 두는 것도 사용자의 현지 날짜를 표현하지 못합니다. 에이전트(agent)가 더 설득력 있는 답변을 작성하더라도 이 문제는 개선되지 않습니다.
우리는 어드APTER(adapter)를 배포하고, 상위 단계의 날짜 경계가 확인될 때까지 완전 커버리지 주장(complete-coverage claims) 기능을 비활성화할 것입니다. 산술 연산은 이제 강제 적용 가능하며, 커버리지는 별개의 문제입니다.
이러한 분리는 또한 실패 원인을 설명하기 쉽게 만듭니다. 사용자의 애플리케이션은 요청 거부, 가져오기 실패(failed fetch), 모호한 시작점(ambiguous kickoff), 그리고 "일치하는 항목 없음"으로 묶는 대신 컨텍스트를 위해 축약된 답변을 구별할 수 있습니다.
로컬 경계 재현하기 (Reproduce the local boundary)
- 호스트 환경에
APIFY_TOKEN을 설정하고 가져온 클라이언트 패키지를 설치합니다.
fixture_window("2026-10-28", 7, "America/New_York")을 호출합니다.
- 완전성 주장(completeness claims)을 허용하기 전에 생성된 요청과 래퍼 메타데이터(wrapper metadata)를 검사합니다.
- 이 예시 창 내부의 일광 절약 시간제 전환을 포함하여 로컬 자정 근처의 알려진 피처(fixtures)들을 교차 확인합니다.
해당 후보 날짜 가져오기(candidate-date fetch)에 대해 생성된 정확한 액터 입력 예시:
{
"mode": "scheduled",
"sports": ["football"],
...
}
여기서 더 넓은 범위의 가져오기(wider fetch)는 의도적입니다. 추가된 날짜가 로컬 간격의 UTC 끝부분을 커버합니다. 필터가 캘린더에 포함될 피처를 결정합니다.