
    -ǚj:                    n   d Z ddlmZ ddlZddlZddlZddlmZmZm	Z	m
Z
mZ ddlmZmZ ddlmZ ddlmZ  ej*                  e      ZdZd	Zd
ZdZdZdZdZdZ G d de       Z! G d de"      Z# G d de       Z$ddZ%d dZ&d!dZ'd"dZ(d#dZ)	 	 	 	 	 	 	 	 	 	 d$dZ*	 	 	 	 	 	 	 	 	 	 d%dZ+d&dZ,d'dZ-d'dZ.y)(u-  엔티티 신원 계약 — `short_id` 발급과 수명 상태를 **한 곳**에서 정한다.

## 왜 이 모듈이 있나

2026-09-04 실측(골목 끝 `da049582`, 3화). 화마다 `short_id` 를 **위치 기준으로
`01` 부터** 다시 매기고 있었다 (`entity_steps._assign_short_ids`,
`outlook_steps`). `entity_canon` 의 유일성은 `(project_id, short_id)` 라
2화의 `C01` 이 1화의 `C01` **행을 찾아 덮었다** —

    1화 CP  C01=민수      2·3화 CP  C01=정임      지금 DB  C01=정임
    1화 CP  O01=남색작업복            3화 CP  O01=감색차장제복
    → DB O01 은 이름이 「감색차장제복」인데 붙어 있는 참조 이미지는
      **1화가 만든 남색작업복**이다. 그림과 이름이 갈렸다.

그래서 번호는 **모델도 스텝도 아니고 프로젝트 장부가 소유한다.** 한 번 쓴
번호는 다시 안 나온다.

## 이 모듈이 지키는 것

- 발급은 `project_short_id_counter` 의 **줄지 않는 값**에서 나온다.
- 첫 값(seed)은 DB 최대값**만으로는 안 된다** — 이미 지워진 번호를 다시
  발급하게 된다(실측: 1화 `O03 회색외투` 는 DB 에 없지만 1화 체크포인트에는
  살아 있다). **살아 있는 모든 에피소드 체크포인트**까지 같이 본다.
- `O00` 은 Null Outlook **예약값**이다. 발급기는 절대 내지 않는다.
- 상한은 999 다. 소비자 정규식 20여 곳이 ``[CLPO]\d{2,3}`` 로 세 자리까지만
  받는다. 넓히는 것은 별도 범위이고, 여기서는 **넘기 전에 선다.**
    )annotationsN)AnyDictIterableListOptional)or_text)Session)OWNER_PREFIXO00   i  activeorphanedshelved)short_id
outlook_idc                      e Zd ZdZy)SeedUnreadableu_   seed 를 정할 근거(체크포인트)를 못 읽었다. ★낮은 seed 로 가면 안 된다.N__name__
__module____qualname____doc__     N/Users/manta/Documents/Projects/TheRoad-I1/backend/app/core/entity_identity.pyr   r   G   s    ir   r   c                      e Zd ZdZy)ShortIdBadValueu[   앞 단계가 준 `short_id` 가 계약 밖이다. ★그대로 믿으면 남을 덮는다.Nr   r   r   r   r   r   K   s    er   r   c                      e Zd ZdZy)ShortIdExhaustedu\   발급 가능한 번호를 다 썼다. ★조용히 넘어가면 남의 번호를 덮는다.Nr   r   r   r   r!   r!   O   s    fr   r!   c           
         t        j                  |       }|t        d|       t        |cxk  r	t        k  s!n t        |  d| dt         dt         d      | |dS )uM   `("character", 7)` → `"C07"`. 100 부터는 자연히 세 자리가 된다.   모르는 갈래: u    번호 u    은 발급 범위 밖이다 (~u   ). 소비자 정규식이 세 자리까지만 받으므로 여기서 선다 — 넓히려면 그 정규식들을 먼저 고쳐야 한다.02d)r   get
ValueErrorSHORT_ID_MINSHORT_ID_MAXr!   )ownernumprefixs      r   format_short_idr-   S   s    e$F~-eY788C/</gXcU #~Q|n -jkl 	l Xc#Yr   c                Z    ddl m}  |t        |xs d            }|y|\  }}|| k(  r|S dS )u   `("character", "C07")` → `7`. 그 갈래의 것이 아니면 `None`.

    ★`split_final_id` 를 쓴다 — 접두표가 prefix-free 가 아니라서
     (`L` 과 `LP`) 직접 자르면 `LP01` 을 `L` 것으로 읽는다.
    r   )split_final_id N).app.modules.pipeline.grounding_entity_contractr/   str)r*   r   r/   got	got_ownerr+   s         r   parse_short_idr5   `   s>     N
X^,
-C
{NIsu$3.$.r   c                   t        | t              rQ| j                         D ]=  \  }}|t        v r$t        |t              r|r|j                  |       2t        ||       ? yt        | t              r| D ]  }t        ||        yy)uD   JSON 을 훑어 `short_id`/`outlook_id` **키의 값**만 모은다.N)
isinstancedictitems_SHORT_ID_KEYSr2   append_walk_short_idslist)nodeoutkvs       r   r<   r<   o   sm    $JJLDAqN"z!S'9a

13'	 !
 
D$	AAs#  
 r   c           
        ddl m} t        j                  |j                        | z  dz  dz  }|j                         syd}g }|j                  d      D ]g  }	 t        j                  |j                  d            }g }t        |j                  d      |       |D ]  }	t        ||	      }
|
t!        ||
      } i |r t#        d
t%        |       d| d|d	d        |S # t        $ r |j                  t        |             Y w xY w)u/  살아 있는 **모든 에피소드 체크포인트**에서 그 갈래의 최대 번호.

    ★없으면 0. 읽다 깨진 파일은 건너뛰되 **조용히 넘어가지 않는다** — 못 읽은
     파일이 있으면 그만큼 seed 가 낮아져 남의 번호를 다시 발급할 수 있다.
    r   )settingscheckpointsepisodesz*/*/manifest.jsonzutf-8)encodingdataNu   살아 있는 체크포인트 u   개를 못 읽어 `uw   ` 번호 seed 를 못 정한다 — 낮은 seed 로 발급하면 이미 쓴 번호를 다시 낸다. 먼저 고쳐라:    )app.core.configrC   pathlibPathprojects_diris_dirglobjsonloads	read_text	Exceptionr;   r2   r<   r&   r5   maxr   len)
project_idr*   rC   rootbest
unreadablemfrG   foundsidr+   s              r   checkpoint_high_waterr\   |   s    )<<--.;mKjXD;;=DJii+,	::bllGl<=D (%0C ,C4~  -  ,S_,==PQVPW X!!+BQ 023 	3 K#  	c"g&	s   %C###D	D	c                    | j                  t        d      d|i      j                         }d}|D ]  \  }t        ||      }|t	        ||      }! |S )uJ   `entity_canon` 에 이미 있는 그 갈래의 최대 번호. 없으면 0.zRSELECT short_id FROM entity_canon WHERE project_id = :pid AND short_id IS NOT NULLpidr   )executesql_textfetchallr5   rS   )dbrU   r*   rowsrW   r[   r+   s          r   db_high_waterrd      sh    ::h	; 	z %HJ 	 DUC(?tS>D  Kr   c           
        |dk  rg S |t         vrt        d|      t        t        | ||      t	        ||            }| j                  t        d      |||d       | j                  t        d      ||||d      j                         }|t        d| d| d	      t        |d         }||z
  d
z   }|t        kD  rt        | dt         d| d| d      t        ||d
z         D cg c]  }t        ||       c}S c c}w )u2  그 갈래의 다음 번호 `count` 개를 **원자적으로** 발급한다.

    ★조회와 발급이 따로면 동시에 도는 둘이 같은 번호를 받는다. 한 문장으로
     올리고 그 반환값을 쓴다 — 락을 따로 잡지 않아도 PostgreSQL 이 이 행을
     직렬화한다.

    ★첫 호출에서 seed 를 넣는다. seed = max(DB, 살아 있는 체크포인트).
     둘이 나란히 seed 를 넣어도 `ON CONFLICT DO NOTHING` 으로 하나만 들어가고,
     둘이 같은 값을 계산했으므로 결과가 같다.
    r   r#   zINSERT INTO project_short_id_counter (project_id, owner, high_water) VALUES (:pid, :owner, :seed) ON CONFLICT (project_id, owner) DO NOTHING)r^   r*   seedzUPDATE project_short_id_counter SET high_water = GREATEST(high_water, :seed) + :count WHERE project_id = :pid AND owner = :owner RETURNING high_water)r^   r*   rf   countu*   short_id 장부를 못 올렸다 (project=z, owner=)r   u    번호가 u    를 넘는다 (요청 u	   개, 끝 u^   ). 소비자 정규식이 세 자리까지만 받는다 — 넓히는 것은 별도 범위다.)r   r'   rS   rd   r\   r_   r`   fetchoneRuntimeErrorintr)   r!   ranger-   )	rb   rU   r*   rg   rf   rowendstartns	            r   reserve_short_idsrq      sW    z	L -eY788}RU3$Z79DJJx	R E48: **X	
 E4%HJ
 KS(*  {8HUGSTUW 	W
c!f+C%K!OE
\g[.EeWIVYUZ [j kl 	l 05UC!G/DE/D!OE1%/DEEEs   +Dc                   t        |      }i }t        |      D ]  \  }}t        |j                  d      xs d      j	                         }|s5|t
        k(  rt        d| d| d      t        ||      }	|	t        d| d| d| d	      t        |	cxk  r	t        k  sn t        d| d
t         dt         d      ||v rt        d| d||    d| d      |||<    |D cg c]1  }t        |j                  d      xs d      j	                         r0|3 }
}|
r.t        |
t        | ||t        |
                  D ]
  \  }}||d<    |S c c}w )u   `short_id` 가 **없는** 것에만 프로젝트 번호를 채운다.

    ★이미 있는 것은 건드리지 않는다 — 앞 화에서 물려받은 신원이다.
    r   r0   u
   예약값 u.    이 일반 신원으로 들어왔다 (자리 rh   `u   ` 는 `u"   ` 의 신원이 아니다 (자리 u0   ) — 갈래가 섞였거나 형식이 틀렸다u   ` 가 발급 범위 밖이다 (r$   u'   ` 를 두 줄이 갖고 있다 (자리    ·uC   ) — 한 판 안에서 신원이 겹치면 뒤가 앞을 덮는다)r=   	enumerater2   r&   stripNULL_OUTLOOK_SHORT_IDr   r5   r(   r)   ziprq   rT   )rb   rU   r*   entitiesrc   seenier[   r+   needs              r   assign_short_idsr~      s    >D
 D$1!%%
#)r*002''!SE!OPQsRSTV VUC(;!C5w&H L= >? ? 3|3!C57~Q|nTUVX X$;!C5?S	{"QC PP QR R S	'  ( Ht!3quuZ'8'>B#?#E#E#GAtDH$ 1"j%T STFAsAjM UK	 Is   %1EEc                   ddl m} | j                  |j                        j	                  |j
                  |k(  |j                  |k(  |j                  t        k7        j                         }|D cg c]  }|d   	 c}S c c}w )u  **이 화에서 살아 있는** canon id — 보류(`shelved`)를 뺀 것.

    ★★★이것이 「이 화에 무엇이 나오나」의 **정본**이다. 소비자마다 링크를
     직접 읽으면 보류 제외가 한 곳만 붙고 나머지는 샌다 — 실측 2026-09-04:
     이 화 링크를 읽는 자리가 **21곳**인데 거르는 곳은 **1곳**이었다.

    ★쓰는 쪽(sync 서비스)은 이것을 **안 쓴다.** 그쪽은 보류 링크도 봐야
     `active` 로 되살릴 수 있다.
    r   )EntityEpisodeLink)
app.models.projectr   querycanon_idfilterrU   
episode_idpresence_statusPRESENCE_SHELVEDall)rb   rU   r   r   rc   rs         r   active_episode_canon_idsr     s|     588%../66$$
2$$
2))-== 
ce	 	
 $QAaD$s   0A?c                   ddl m} | j                  |      j                  |j                  |k(  |j
                  |k(        j                         }|r|S | j                  |      j                  |j                  |k(  |j
                  j                  d            j                         }|sg S t        t        | ||            }|D cg c]   }|j                  |v r|j                  |v r|" c}S c c}w )u  **이 화의** 인물↔아웃룩 배정 행.

    ★★★`character_outlook` 은 2026-09-04 (alembic 014) 전까지 화 범위가
     없었다. 그래서 같은 인물이 1화에 O01, 2화에 O02 를 입으면 **2화가 둘 다**
     생성·검증·프롬프트 재료로 봤고, 한 화의 `O00` 이 다른 화 판단까지
     오염시켰다 (Codex BLOCK 2026-09-04).

    ## legacy(`episode_id IS NULL`) 를 어떻게 다루나

    ★★앞 판은 exact 와 **모든 NULL 행을 OR 로 무조건 합쳤다.** 그런데 014 는
     기존 행을 전부 NULL 로 남긴다 — 그러면 손상 복구 대상인 **다중 화 legacy
     프로젝트에서 과거 옷과 `O00` 이 모든 화로 다시 들어온다.** 고치려던 것이
     그대로 돌아오는 셈이다 (Codex BLOCK 2026-09-04 재지적).

    그래서 계약을 좁힌다 —

        이 화의 exact 행이 **하나라도** 있으면 → **exact 만**
        하나도 없으면 → NULL 행 중 **이 화의 active 링크 양 끝**
                       (인물·아웃룩 **둘 다**)에 걸린 것만

    ★양 끝을 요구하는 까닭: 한쪽만 걸린 행은 「이 화의 배정」이라고 볼 근거가
     없다. 모호하면 넓히지 않는다.
    r   )CharacterOutlookN)r   r   r   r   rU   r   r   is_setr   character_idr   )rb   rU   r   r   exactlegacyr   r   s           r   episode_outlook_rowsr     s    0 4HH%&--##z1##z1 
ce 
 XX&'..##z1##''- 
ce  	)"j*EFF Dv!~~'ALLF,B v D D Ds   %C/c                n    t        | ||      D cg c]  }|j                  |j                  f c}S c c}w )u>   `(character_id, outlook_id)` 쌍 — 위와 **같은 범위**.)r   r   r   )rb   rU   r   r   s       r   episode_outlook_pairsr   D  sC     *"j*EGE ^^Q\\*EG G Gs   2)r*   r2   r+   rk   returnr2   )r*   r2   r   r2   r   zOptional[int])r>   r   r?   	List[str]r   None)rU   r2   r*   r2   r   rk   )rb   
OrmSessionrU   r2   r*   r2   r   rk   )
rb   r   rU   r2   r*   r2   rg   rk   r   r   )
rb   r   rU   r2   r*   r2   ry   zIterable[Dict]r   z
List[Dict])rU   r2   r   r2   r   r   )rU   r2   r   r2   r   z	List[Any])/r   
__future__r   rO   loggingrJ   typingr   r   r   r   r   
sqlalchemyr	   r
   r`   sqlalchemy.ormr   r   r1   r   	getLoggerr   loggerrw   r(   r)   CANON_STATUS_ACTIVECANON_STATUS_ORPHANEDPRESENCE_ACTIVEr   r:   rj   r   r'   r   r!   r-   r5   r<   r\   rd   rq   r~   r   r   r   r   r   r   <module>r      s)  6 #    6 6 , 0 G			8	$   
   " 
   ,j\ jfj fg| g
 /
$"J*F*F #*F,/*F8;*F*FZ%% #%,/%;I%%P ()DXGr   