AI 화면에서 중지 버튼이 사라졌다는 사실만으로 원하는 결과가 끝났다고 말하고 싶지 않았다. 북극성의 실제 waitAnswer와 관련 시험을 읽고, 새 응답과 준비된 매체를 구분하는 작은 합성 관찰기를 만들었다. 요청 전송 없이 전달된 관찰 배열만 단계별로 읽는 예제다.

시작할 때 이미 있던 응답을 기준으로 남긴다

baseline에는 scope와 기존 answer ID, 준비된 이미지 개수와 ordinal을 둔다. 이후 관찰에서는 그 ID에 없던 새 응답 중 마지막 항목을 찾는다. 예전 답변이 길고 완성돼 보여도 새 요청의 결과로 채택하지 않는다. scope는 자료를 나누는 합성 라벨이며 로그인이나 권한을 인증하지 않는다.

관찰의 시각은 반드시 증가해야 한다. 같은 snapshot을 반복 전달해 안정 횟수를 올리지 않으려는 예제의 조건이다. 원래 제품의 모든 관찰 시스템을 복사한 것은 아니다. 실제 적용에서는 요청 시작 당시의 기준과 현재 페이지를 신뢰할 수 있게 묶는 호출부가 필요하다. 이 예제는 그 연결을 제공하지 않는다.

안정 상태는 처음 관찰과 두 번의 반복으로 본다

busy이면 생성 중으로 남기고 안정 지문과 횟수를 지운다. 새 답변이 없거나 필요한 본문·매체가 없을 때도 기다릴 상태를 돌려준다. 조건이 맞으면 처음 지문을 저장하고 같은 지문이 두 번 더 이어져야 observed_complete다. 총 두 번만 보면 끝나는 계약이 아니라 세 번의 적격 관찰이 필요하다.

예제의 지문에는 응답 ID도 넣었다. 같은 글자를 가진 다른 새 응답으로 바뀌어도 안정 횟수가 이어지지 않도록 한 독립 구현의 선택이다. 원래 함수의 모든 지문과 동일하다고 말하지 않는다. 관찰 간격과 타임아웃까지 이 코드가 실제로 기다리는 것은 아니며 호출자가 관찰을 전달해야 한다.

이미지와 영상은 같은 응답에 준비돼 있어야 한다

text 목표는 비지 않은 본문을 요구한다. image와 video 목표는 같은 새 answer ID에 속한 해당 종류의 ready 매체가 필요하며 글자가 없어도 된다. 다른 응답의 영상이나 로딩 중인 이미지는 성공으로 채택하지 않는다. 매체 종류도 맞아야 하므로 사진이 있다고 영상 요청이 끝난 것으로 보지 않는다.

별도 assistant 제목이 없는 이미지 상황을 위해 선택 가능한 page 이미지 예외도 뒀다. image 정책에서만 명시적으로 켤 수 있고 새 응답이 없을 때, 기존보다 개수가 늘고 ordinal이 새로우며 준비된 이름 있는 page 이미지가 정확히 하나여야 한다. 모호한 후보는 더 편한 쪽을 골라 주지 않는다.

코드와 구현 살펴보기

독립 합성 전체 구현

"""Independent observer of declared synthetic snapshots. Never sends requests."""
import json
from copy import deepcopy


def fail(code):
    raise ValueError(code)


def label(value):
    if not isinstance(value, str) or not value or len(value)>100 or not all(c.isascii() and (c.isalnum() or c in "-_") for c in value):
        fail("LABEL")


def shape(value, keys):
    if not isinstance(value, dict) or set(value)!=set(keys):
        fail("SNAPSHOT_SHAPE")


def snapshot(value):
    shape(value,{"at","scope","busy","answers","media"})
    label(value["scope"])
    if type(value["at"]) is not int or value["at"]<0 or type(value["busy"]) is not bool:
        fail("SNAPSHOT_CLOCK_OR_BUSY")
    if not isinstance(value["answers"],list) or len(value["answers"])>100 or not isinstance(value["media"],list) or len(value["media"])>100:
        fail("SNAPSHOT_ROWS")
    ids=set()
    for a in value["answers"]:
        shape(a,{"id","text","waiting","failed","unavailable"}); label(a["id"])
        if a["id"]=="page" or a["id"] in ids:
            fail("ANSWER_ID")
        ids.add(a["id"])
        if not isinstance(a["text"],str) or len(a["text"])>20000 or any(type(a[k]) is not bool for k in ["waiting","failed","unavailable"]):
            fail("ANSWER_VALUE")
    ids=set()
    for m in value["media"]:
        shape(m,{"id","answer","kind","ordinal","ready","named"}); label(m["id"]); label(m["answer"])
        if m["id"] in ids:
            fail("MEDIA_ID")
        ids.add(m["id"])
        if m["kind"] not in ["image","video"] or type(m["ordinal"]) is not int or m["ordinal"]<0 or type(m["ready"]) is not bool or type(m["named"]) is not bool:
            fail("MEDIA_VALUE")
    return deepcopy(value)


class Observer:
    def __init__(self, baseline, *, target="text", timeout_ms=1000, allow_page_image=False):
        base=snapshot(baseline)
        if target not in ["text","image","video"] or type(timeout_ms) is not int or not 1<=timeout_ms<=600000 or type(allow_page_image) is not bool:
            fail("POLICY")
        if allow_page_image and target!="image":
            fail("PAGE_IMAGE_POLICY")
        self.scope=base["scope"]; self.target=target; self.allow_page_image=allow_page_image
        self.before={a["id"] for a in base["answers"]}
        self.before_image_count=sum(m["kind"]=="image" and m["ready"] for m in base["media"])
        self.before_image_ordinal=max([-1]+[m["ordinal"] for m in base["media"] if m["kind"]=="image" and m["ready"]])
        self.deadline=base["at"]+timeout_ms; self.last_at=base["at"]
        self.last_signature=None; self.repeats=0; self._terminal=None

    def _result(self, state, at, *, terminal=False, answer=None):
        value={"state":state,"at":at,"same_signature_repeats":self.repeats,
               "request_sent":False,"business_success_verified":False,
               "evidence":"caller-declared synthetic observations"}
        if answer is not None: value["answer"]=answer
        if terminal: self._terminal=deepcopy(value)
        return value

    def _reset(self):
        self.last_signature=None; self.repeats=0

    def _stable(self, value):
        sig=json.dumps(value,ensure_ascii=False,sort_keys=True,separators=(",",":"))
        if sig==self.last_signature: self.repeats+=1
        else: self.last_signature=sig; self.repeats=0
        return self.repeats>=2

    def result(self):
        return deepcopy(self._terminal)

    def observe(self, raw):
        if self._terminal is not None: fail("TERMINAL_OBSERVER")
        s=snapshot(raw)
        if s["scope"]!=self.scope: fail("SCOPE_CHANGED")
        if s["at"]<=self.last_at: fail("NON_INCREASING_CLOCK")
        self.last_at=s["at"]
        if s["at"]>=self.deadline:
            return self._result("timed_out",s["at"],terminal=True)
        if s["busy"]:
            self._reset(); return self._result("generating",s["at"])
        new=[a for a in s["answers"] if a["id"] not in self.before]
        answer=new[-1] if new else None
        if answer is None:
            if self.allow_page_image:
                images=[m for m in s["media"] if m["kind"]=="image" and m["ready"]]
                fresh=[m for m in images if m["answer"]=="page" and m["named"] and m["ordinal"]>self.before_image_ordinal]
                if len(images)>self.before_image_count and len(fresh)==1:
                    done=self._stable(["page-image",fresh[0]["id"],fresh[0]["ordinal"]])
                    return self._result("observed_complete" if done else "stabilizing",s["at"],terminal=done)
            self._reset(); return self._result("waiting_for_new_answer",s["at"])
        key=answer["id"]
        if answer["waiting"]:
            self._reset(); return self._result("waiting_for_user",s["at"],terminal=True,answer=key)
        if answer["failed"]:
            self._reset(); return self._result("response_failed",s["at"],terminal=True,answer=key)
        ready=[m for m in s["media"] if m["answer"]==key and m["kind"]==self.target and m["ready"]]
        if self.target!="text" and not ready:
            if answer["unavailable"]:
                done=self._stable(["unavailable",key,answer["text"].strip()])
                return self._result("media_unavailable" if done else "stabilizing_refusal",s["at"],terminal=done,answer=key)
            self._reset(); return self._result("waiting_for_media",s["at"],answer=key)
        if self.target=="text" and not answer["text"].strip():
            self._reset(); return self._result("waiting_for_text",s["at"],answer=key)
        # Include identity, unlike a text-only signature spanning two answer groups.
        done=self._stable([key,answer["text"].strip(),[(m["id"],m["kind"],m["ordinal"]) for m in ready]])
        return self._result("observed_complete" if done else "stabilizing",s["at"],terminal=done,answer=key)

확인 대기와 거절을 완료 성공에 섞지 않는다

새 응답이 사용자 확인을 기다리면 waiting_for_user, 실패 표시가 있으면 response_failed로 끝난다. 매체가 없는 상태에서 생성 불가 선언이 안정되면 media_unavailable다. 자연어를 이 예제가 판정하지는 않는다. 실제 어댑터가 읽은 분류를 합성 boolean으로 전달하는 계약이다.

거절 문장도 text 목표에서는 완료된 응답일 수 있다. 그러나 원하는 이미지가 생겼다는 뜻은 아니다. 그래서 모든 observed_complete에도 business_success_verified=False를 남긴다. 응답 관찰 완료와 고객의 업무 목적 달성을 나누려는 것이다. 서비스가 할 수 없다고 답한 내용을 자동화 오류나 새 생성물로 바꾸어 기록하지 않는다.

32개 시험은 멈춰야 할 관찰도 확인한다

기존 응답만 남은 경우, 중지 표시만 사라진 경우, 본문이나 응답 ID가 바뀐 경우의 안정 초기화를 시험했다. 같은 응답의 준비 매체와 다른 응답·미완성·다른 종류의 매체를 구분한다. page 이미지 예외의 이름·개수·ordinal 조건도 각각 거절되는지 본다. 잘못된 scope와 시각·형태는 받지 않는다.

원본32개 시험과 ZIP 추출본32개 시험을 수행한다. 다섯 기록은 실제 로컬 함수 호출로 만들었고 아래 선택기는 그 결과만 읽는다. 원래 브라우저나 AI 서비스를 새로 실행하지 않았으며 원본 제품의 기존 시험도 다시 돌리지 않았다. 누가 선언한 어떤 관찰을 비교했는지까지 근거에 남겼다.

합성 로컬 기록의 읽기 뷰어

시간 초과 뒤에도 새 요청을 보내지 않는다

선언한 deadline에 도달하면 timed_out으로 끝난다. 실제 시계나 백그라운드 타이머가 없으므로 호출이 오지 않으면 알아서 깨지 않는 예제다. 종료한 관찰기는 새 입력을 거절하고 결과 사본만 읽게 한다. 영속 원장이나 재개 프로토콜, 다운로드 기능도 없으며 request_sent는 항상 False다.

코드와 시험·기록 생성기·설명은 수정하고 재사용할 수 있다. 운영에서는 실제 사이트 관찰기와 요청 원장, 결과 파일의 확인을 연결해야 한다. 나는 늦어지는 답변을 재전송으로 해결하기 전에 지금까지 무엇을 확인했는지 읽을 수 있게 하는 작은 상태 예제로 공유한다.

코드·시험·실행 기록 ZIP 받기
Python 표준 라이브러리 · 합성 자료 · 수정과 재사용 가능