talk / backend /services /memory_format.py
user
Deploy StyleVoiceChat backend
aa9892b
Raw History Blame Contribute Delete
56.8 kB
"""์‚ฌ์šฉ์ž ๊ธฐ์–ต ํฌ๋งท ์œ ํ‹ธ โ€” ์˜๋ฏธ๊ธฐ์–ต(ํŒจ์‹ฏ) / ์ผํ™”๊ธฐ์–ต(๋‚ ์งœ ๋ผ์ธ) ์ˆœ์ˆ˜ ํ•จ์ˆ˜. I/O ์—†์Œ(ํ…Œ์ŠคํŠธ ์šฉ์ด). chat.py ์˜ ๋ณ‘ํ•ฉ-์ €์žฅ ๊ฒฝ๋กœ์™€ ์ฃผ์ž… ๊ฒฝ๋กœ, ๊ทธ๋ฆฌ๊ณ  Phase 2 ์ปจํ…์ŠคํŠธ ๋ผ์šฐํ„ฐ๊ฐ€ ๊ณต์œ ํ•œ๋‹ค. ์„ค๊ณ„(req_user_memory_redesign ยง1ยทยง3 + req_memory_situational_layers ยง4ยทยง5): - ์˜๋ฏธ๊ธฐ์–ต = timeless ์‚ฌ์‹ค. ํŒจ์‹ฏ ํ—ค๋”([๊ธฐ๋ณธ]/[์ทจํ–ฅยท์Šต๊ด€]/[์ด์ƒํ˜•]/[์„ฑ์ ยทํŒจํ‹ฐ์‰ฌ]/[AI ์ •๋ณด]). - ์ผํ™”๊ธฐ์–ต = ์‹œ์  ์žˆ๋Š” ์‚ฌ๊ฑด/์ƒํƒœ. ์‹  ํฌ๋งท "[MM-DD HH:MM] ์‚ฌ๊ฑด" ํ•œ ์ค„์”ฉ(๊ตฌ "[YYYY-MM-DD]" ๋„ graceful ํŒŒ์‹ฑ), ์ˆœ๊ฐ„์ƒํƒœ๋Š” ๋์— "(์ƒํƒœ)". * ์‹œ์ ์€ ์„œ๋ฒ„๊ฐ€ KST ๋กœ ์Šคํƒฌํ”„(LLM ์€ '์ง€๊ธˆ'์„ ๋ชจ๋ฆ„) โ€” ์‹  ํฌ๋งท์€ ๋ถ„ ๋‹จ์œ„๊นŒ์ง€. * ์ €์žฅ: โ‰ค60์ผ / โ‰ค30์ค„ (์˜ค๋ž˜๋œ ๊ฒƒ๋ถ€ํ„ฐ ๋ฒ„๋ฆผ). * ์ฃผ์ž…: ์ผ๋ฐ˜ โ‰ค13์ผ / ์ˆœ๊ฐ„์ƒํƒœ โ‰ค1์ผ ๋งŒ, ์ ˆ๋Œ€์‹œ์  โ†’ ์ƒ๋Œ€์‹œ๊ฐ„("๋ฐฉ๊ธˆ/3์‹œ๊ฐ„ ์ „/์–ด์ œ/์ง€๋‚œ์ฃผ") ๋ Œ๋”. - ์ €์žฅ ์ง์ „ ํ›„์ฒ˜๋ฆฌ ์ •๊ทœํ™”(ยง5, LLM ์ค€์ˆ˜์œจ ๋น„์˜์กด, ๊ฒฐ์ •์ ): ๋นˆ์นธยทํ”Œ๋ ˆ์ด์Šคํ™€๋” ์ œ๊ฑฐ ยท ํ—ค๋”์—†๋Š” ์„ ๋‘ ์‚ฌ์‹ค [๊ธฐ๋ณธ] ๋ผ์šฐํŒ… ยท ์˜๋ฏธโ†”์ผํ™” ๋ˆ„์ˆ˜(์ˆœ๊ฐ„์ƒํƒœ/์ผํšŒ์„ฑ ์‚ฌ๊ฑด) ์ผํ™”๋กœ relocate. """ from __future__ import annotations import re from datetime import date, datetime, timezone # --- ์ผํ™”๊ธฐ์–ต ----------------------------------------------------------------- EPISODE_MERGE_MARKER = "===์ผํ™”===" # ๋ณ‘ํ•ฉ ์ถœ๋ ฅ ์˜๋ฏธ๊ธฐ์–ต โ†” ์ผํ™” ๊ตฌ๋ถ„ DIRECTIVE_MERGE_MARKER = "===์ง€์นจ===" # ๋ณ‘ํ•ฉ ์ถœ๋ ฅ ์ผํ™” โ†” ์ ˆ์ฐจ์  ์ง€์นจ(proposed) ๊ตฌ๋ถ„ _MOMENTARY_TAG = "(์ƒํƒœ)" # ์ˆœ๊ฐ„์ƒํƒœ(๊ฐ๊ธฐยทํ”ผ๊ณค ๋“ฑ) ํƒœ๊ทธ โ€” ์งง์€ ๋””์ผ€์ด # ์‹  ํฌ๋งท `[MM-DD HH:MM @๋™๋„ค]` + ๊ตฌ ํฌ๋งท `[YYYY-MM-DD]`/`[YYYY-MM-DD HH:MM]`(์žฅ์†Œ ์—†์Œ) # ๋ชจ๋‘ ํŒŒ์‹ฑ(graceful). YYYY ์ƒ๋žต(์‹  ํฌ๋งท)์€ ๊ธฐ์ค€์—ฐ๋„๋กœ ์ถ”๋ก , ๊ฒฐ๊ณผ๊ฐ€ ๋ฏธ๋ž˜๋ฉด ์—ฐ๋งโ†”์—ฐ์ดˆ ๊ฒฝ๊ณ„์—์„œ # ์ „๋…„์œผ๋กœ ๋กค์˜ค๋ฒ„. `@๋™๋„ค` ๋Š” ๊ทธ ์‚ฌ๊ฑด ๋‹น์‹œ ์‚ฌ์šฉ์ž๊ฐ€ ์žˆ๋˜ ๋™(์„ ํƒ) โ€” ์„œ๋ฒ„๊ฐ€ ์Šคํƒฌํ”„(LLM ๋ฏธ๊ธฐ์žฌ). _DATED_RE = re.compile( r"^\[(?:(\d{4})-)?(\d{1,2})-(\d{1,2})(?:[ T](\d{1,2}):(\d{2}))?" r"(?:\s*@\s*([^\]]+?))?\s*\]\s*(.*)$" ) EPISODE_STORE_MAX_LINES = 30 EPISODE_STORE_MAX_AGE_DAYS = 60 EPISODE_INJECT_GENERAL_DAYS = 13 # ์ผ๋ฐ˜ ์ผํ™”: ์ด ์ผ์ˆ˜ ์ดˆ๊ณผ ์‹œ ์ฃผ์ž… ์ œ์™ธ(=14์ผ ๋””์ผ€์ด) EPISODE_INJECT_MOMENTARY_DAYS = 1 # ์ˆœ๊ฐ„์ƒํƒœ: ์ด ์ผ์ˆ˜ ์ดˆ๊ณผ ์‹œ ์ฃผ์ž… ์ œ์™ธ(=1~2์ผ ๋””์ผ€์ด) EPISODE_HOUR_JOIN = " ยท " # ๊ฐ™์€ 1์‹œ๊ฐ„ ๋ธ”๋ก ๋ผ์ธ ๊ฒฐํ•ฉ ๊ตฌ๋ถ„์ž(์„ธ๊ทธ๋จผํŠธ dedupe ํ‚ค) def _coerce_now(ref: "date | datetime") -> "tuple[datetime, bool]": """ref(date|datetime) โ†’ (๊ธฐ์ค€ datetime, ์‹œ๊ฐ์ •๋ณด ๋ณด์œ  ์—ฌ๋ถ€). date ๋งŒ ์ฃผ๋ฉด ์‹œ๊ฐ ์—†์Œ(=๋‚ ์งœ ๋‹จ์œ„). datetime ์ด๋ฉด ๋ถ„ ๋‹จ์œ„๊นŒ์ง€ ์‚ฌ์šฉ. ํ˜ธ์ถœ์ธก์ด KST ์˜ '์ง€๊ธˆ'(๊ถŒ์žฅ: datetime) ๋˜๋Š” '์˜ค๋Š˜'(date, ํ•˜์œ„ํ˜ธํ™˜)์„ ๋„˜๊ธด๋‹ค. """ if isinstance(ref, datetime): return ref, True return datetime(ref.year, ref.month, ref.day), False def _parse_episode_line( line: str, now: "datetime | None" = None ) -> "tuple[datetime | None, bool, str, str]": """'[ํƒ€์ž„์Šคํƒฌํ”„ @๋™๋„ค] ๋ณธ๋ฌธ' โ†’ (datetime, ์‹œ๊ฐ๋ณด์œ , ๋™๋„ค, ๋ณธ๋ฌธ). ์‹œ์  ์—†์œผ๋ฉด (None, False, "", ์›๋ฌธ). - ๊ตฌ `[YYYY-MM-DD]`/์‹  `[MM-DD HH:MM]`/์žฅ์†Œํฌํ•จ `[MM-DD HH:MM @๊ณ ๊ธฐ๋™]` ๋ชจ๋‘ ํ—ˆ์šฉ. - `@๋™๋„ค` ๋Š” ์„ ํƒ โ€” ์—†์œผ๋ฉด "". ๊ทธ ์‚ฌ๊ฑด ๋‹น์‹œ ์‚ฌ์šฉ์ž๊ฐ€ ์žˆ๋˜ ๋™(์„œ๋ฒ„ ์Šคํƒฌํ”„). - YYYY ์ƒ๋žต ์‹œ now ์—ฐ๋„๋กœ ์ถ”๋ก , ์ถ”๋ก ๊ฐ’์ด now ๋ณด๋‹ค ๋ฏธ๋ž˜๋ฉด 60์ผ ์œˆ๋„์šฐ ์•ˆ์ผ ๋•Œ๋งŒ ์ „๋…„ ๋กค์˜ค๋ฒ„. - now ์˜ tzinfo ๋ฅผ ๊ฒฐ๊ณผ datetime ์— ๋ถ€์—ฌ(naive/aware ํ˜ผํ•ฉ ๋น„๊ต ๋ฐฉ์ง€). now ๋ฏธ์ „๋‹ฌ ์‹œ ํŒŒ์‹ฑ๋งŒ. """ s = line.strip() if not s: return None, False, "", "" m = _DATED_RE.match(s) if not m: return None, False, "", s year, mon, day, hh, mm, place, body = m.groups() has_time = hh is not None place = (place or "").strip() tz = now.tzinfo if now is not None else None base_year = int(year) if year is not None else (now.year if now is not None else date.today().year) try: dt = datetime( base_year, int(mon), int(day), int(hh) if has_time else 0, int(mm) if has_time else 0, tzinfo=tz, ) except ValueError: return None, False, "", s if now is not None and year is None and dt > now: # ์—ฐ๋„ ์ถ”๋ก  ๊ฒฐ๊ณผ๊ฐ€ ๋ฏธ๋ž˜ โ†’ ์ตœ๊ทผ ์œˆ๋„์šฐ ์•ˆ์ด๋ฉด ์ „๋…„(์—ฐ๋งโ†”์—ฐ์ดˆ ๊ฒฝ๊ณ„), ์•„๋‹ˆ๋ฉด ๊ทธ๋Œ€๋กœ # ๋‘๊ณ  ํด๋žจํ”„๋Š” ํ˜ธ์ถœ์ธก(stamp_and_trim)์— ๋งก๊ธด๋‹ค. prev = dt.replace(year=dt.year - 1) if (now - prev).days <= EPISODE_STORE_MAX_AGE_DAYS + 1: dt = prev return dt, has_time, place, (body or "").strip() def _fmt_stamp(dt: datetime, has_time: bool, place: str = "") -> str: """์ €์žฅ ํฌ๋งท โ€” ์‹  `[MM-DD HH:MM @๋™๋„ค]`. ์‹œ๊ฐ ์—†์œผ๋ฉด `[MM-DD @๋™๋„ค]`, ์žฅ์†Œ ์—†์œผ๋ฉด `@` ์ƒ๋žต.""" place = (place or "").strip() p = f" @{place}" if place else "" if has_time: return f"[{dt.month:02d}-{dt.day:02d} {dt.hour:02d}:{dt.minute:02d}{p}]" return f"[{dt.month:02d}-{dt.day:02d}{p}]" # ์„ฑ์  ๋Œ€ํ™” ์ผํ™” ์ฐจ๋‹จ (2026-07-14 ์‚ฌ์šฉ์ž ์ง€์‹œ) โ€” ์„ฑ์  ๋‚ด์šฉ์€ ์˜๋ฏธ๊ธฐ์–ต [์„ฑ์ ] ํŒจ์‹ฏ์œผ๋กœ๋งŒ, # ์ผํ™”([์ตœ๊ทผ ๋Œ€ํ™”] ํžˆ์Šคํ† ๋ฆฌ)์—๋Š” ๋‚จ๊ธฐ์ง€ ์•Š๋Š”๋‹ค. ๋ณ‘ํ•ฉ LLM ํ”„๋กฌํ”„ํŠธ ์ง€์‹œ์˜ ๊ฒฐ์ •์  ๋ฐฑ์Šคํ†ฑ. # โš ๏ธ ๊ณ ์ •๋ฐ€ ๊ฒฐํ•ฉํ˜•๋งŒ โ€” '์„ฑ์ ' ๋‹จ๋…์€ ํ•™๊ต ์„ฑ์ ยท์„ฑ์ ํ‘œ์™€ ์ถฉ๋Œ, '์‚ฝ์ž…'์€ ๊ฐœ๋ฐœ ๋Œ€ํ™” # (๋ฐ์ดํ„ฐ ์‚ฝ์ž…), '์‚ฌ์ •'์€ ์ผ์ƒ์–ด(์‚ฌ์ •์ด ์ƒ๊ฒจ์„œ)์™€ ์ถฉ๋Œํ•˜๋ฏ€๋กœ ๋„ฃ์ง€ ์•Š๋Š”๋‹ค. _SEXUAL_EPISODE_RE = re.compile( r"์„ฑ์ [ ]?(๋ฌ˜์‚ฌ|ํ–‰์œ„|์ธ|์œผ๋กœ|์ทจํ–ฅ|๊ธด์žฅ|์žฅ๋ฉด|๋Œ€ํ™”|๋†๋‹ด|์ƒ์ƒ|์š•๊ตฌ|๋งค๋ ฅ|์ ‘์ด‰|ํŒํƒ€์ง€)" r"|์„น์Šค|์„ฑํ–‰์œ„|์„ฑ๊ด€๊ณ„|์Œ๋ž€|์•ผํ•œ|์•ผํ•˜๊ฒŒ|ํŒจํ‹ฐ์‰ฌ|ํŽ˜ํ‹ฐ์‹œ|์ž์œ„|์‹ ์Œ|์• ๋ฌด|์ „ํฌ" r"|์˜ค๋ฅด๊ฐ€์ฆ˜|์•Œ๋ชธ|๋‚˜์ฒด|์„ฑ๊ธฐ|์Œ๋ถ€|์œ ๋‘|๊ฐ€์Šด์„ ๋งŒ์ง€|์—‰๋ฉ์ด๋ฅผ ๋งŒ์ง€" ) _STAMP_PREFIX_RE = re.compile(r"^(\[[^\]]*\])\s*(.*)$") def strip_sexual_episode_segments(raw: str) -> str: """์„ฑ์  ๋‚ด์šฉ ์„ธ๊ทธ๋จผํŠธ/๋ผ์ธ ์ œ๊ฑฐ โ€” ์Šคํƒฌํ”„๋Š” ๋‚จ์€ ์„ธ๊ทธ๋จผํŠธ์— ๋ณด์กด. ํ•œ ์‹œ๊ฐ„ ๋ธ”๋ก ๋ผ์ธ(` ยท ` ๊ฒฐํ•ฉ)์— ์ผ๋ฐ˜ ํ™”์ œ์™€ ์„ฑ์  ์„ธ๊ทธ๋จผํŠธ๊ฐ€ ์„ž์—ฌ ์žˆ์œผ๋ฉด ์„ฑ์  ์„ธ๊ทธ๋จผํŠธ๋งŒ ๊ฑท์–ด๋‚ด๊ณ  ๋‚˜๋จธ์ง€๋Š” ์œ ์ง€ํ•œ๋‹ค. ๋ผ์ธ ์ „์ฒด๊ฐ€ ์„ฑ์ ์ด๋ฉด ๋ผ์ธ ๋“œ๋ž. """ out: list[str] = [] for line in (raw or "").splitlines(): s = line.strip() if not s: continue m = _STAMP_PREFIX_RE.match(s) stamp, body = (m.group(1), m.group(2)) if m else ("", s) kept = [ g.strip() for g in body.split(EPISODE_HOUR_JOIN) if g.strip() and not _SEXUAL_EPISODE_RE.search(g) ] if not kept: continue rebuilt = EPISODE_HOUR_JOIN.join(kept) out.append(f"{stamp} {rebuilt}".strip() if stamp else rebuilt) return "\n".join(out) def _norm_seg(s: str) -> str: """์„ธ๊ทธ๋จผํŠธ ์ •๊ทœํ™” โ€” ๊ณต๋ฐฑยท๋ง๋ฏธ ๊ตฌ๋‘์  ๋ฌด์‹œ(์ค‘๋ณต ํŒ์ •์šฉ ํ‚ค).""" return re.sub(r"\s+", "", s).rstrip(".,!?~") def _seg_bigrams(s: str) -> set: return {s[i:i + 2] for i in range(len(s) - 1)} if len(s) >= 2 else {s} def _seg_near_dup(a: str, b: str) -> bool: """์„ธ๊ทธ๋จผํŠธ ๊ทผ์‚ฌ-๋™์ผ ํŒ์ • โ€” ๋ณด์ˆ˜์ (2026-07-14 ์‹ค๋ฐ์ดํ„ฐ ์บ˜๋ฆฌ๋ธŒ๋ ˆ์ด์…˜). ๊ฐ™์€ ์‚ฌ๊ฑด์˜ ์žฌ์„œ์ˆ (๋ณ‘ํ•ฉ LLM ์ด ํ‘œํ˜„๋งŒ ๋ฐ”๊ฟ” ๋‹ค์‹œ ์“ด ๊ฒƒ)๋งŒ ํก์ˆ˜ํ•œ๋‹ค: โ‘  ์ •๊ทœํ™” ํ›„ ๋™์ผ โ‘ก ํ•œ์ชฝ์ด ๋‹ค๋ฅธ์ชฝ์„ ํฌํ•จ(๊ฐ™์€ ๋‚ด์šฉ + ์„ธ๋ถ€ ์ถ”๊ฐ€) โ‘ข ๋ฌธ์ž ๋ฐ”์ด๊ทธ๋žจ Jaccard โ‰ฅ 0.75(๊ฑฐ์˜ ๋™์ผ ๋ฌธ์žฅ). โš ๏ธ ์ž„๊ณ„๋ฅผ ๋‚ฎ์ถ”๋ฉด ๋ฐ˜๋Œ€ ์˜๋ฏธ๊ฐ€ ํ•ฉ์ณ์ง„๋‹ค โ€” "์•ผ๊ทผ ์•ˆ ํ•˜๊ณ  ์ผ์ฐ ํ‡ด๊ทผ" vs "์•ผ๊ทผ ๋•Œ๋ฌธ์— ๋Šฆ๊ฒŒ ํ‡ด๊ทผ" = 0.458, "์‹ ๋ผ๋ฉด์— ๊ณ„๋ž€" vs "๊น€์น˜์ฐŒ๊ฐœ์— ๊ณ„๋ž€" = 0.609 ์‹ค์ธก. 0.75 ๋ฏธ๋งŒ์˜ ์œ ์‚ฌ ์ค‘๋ณต์€ ๋ฐœ์ƒ์› ์ฐจ๋‹จ(๋ณ‘ํ•ฉ ๋ฒ ์ด์Šค๋ผ์ธ + ํ”„๋กฌํ”„ํŠธ ์žฌ์„œ์ˆ  ๊ธˆ์ง€)์ด ๋‹ด๋‹นํ•œ๋‹ค. """ na, nb = _norm_seg(a), _norm_seg(b) if not na or not nb: return False if na == nb: return True # ํฌํ•จ ๊ด€๊ณ„ = ๊ฐ™์€ ์ง„์ˆ  + ์„ธ๋ถ€ ์ถ”๊ฐ€ (์˜ˆ: "๋ผ๋ฉด ๋จน์Œ" โŠ‚ "๋ผ๋ฉด ๋จน์Œ. ๊ณ„๋ž€ ๋„ฃ์Œ"). # ์ตœ์†Œ 8์ž โ€” ๋” ์งง์œผ๋ฉด ๋ฌด๊ด€ํ•œ ๊ธด ๋ฌธ์žฅ์— ์šฐ์—ฐํžˆ ํฌํ•จ๋  ์œ„ํ—˜. if len(na) >= 8 and len(nb) >= 8 and (na in nb or nb in na): return True ba, bb = _seg_bigrams(na), _seg_bigrams(nb) union = len(ba | bb) return union > 0 and len(ba & bb) / union >= 0.75 def _merge_segs(existing: "list[str]", incoming: "list[str]") -> "list[str]": """์„ธ๊ทธ๋จผํŠธ ๋ณ‘ํ•ฉ โ€” ๊ทผ์‚ฌ-๋™์ผ์€ 1๊ฐœ๋งŒ(๋” ๊ธด ์ชฝ ์œ ์ง€ = ์„ธ๋ถ€ ์ •๋ณด ๋ณด์กด).""" out = list(existing) for seg in incoming: for i, have in enumerate(out): if _seg_near_dup(have, seg): if len(seg) > len(have): out[i] = seg break else: out.append(seg) return out def _coalesce_hour_blocks( parsed: "list[tuple[datetime, bool, str, str]]", ) -> "list[tuple[datetime, bool, str, str]]": """๊ฐ™์€ (๋‚ ์งœยท์‹œ(hour)) ๋ผ์ธ์„ ํ•œ ์ค„๋กœ ๊ฒฐํ•ฉ โ€” "1์‹œ๊ฐ„ ๋ธ”๋ก = ํ•œ ์ค„" ์ €์žฅ ๊ทœ์น™. ๋ณ‘ํ•ฉ LLM ์ด ๋ฐœํ™” ๋‹จ์œ„๋กœ ์ž˜๊ฒŒ ์ชผ๊ฐ  ๋ผ์ธ(๊ฐ™์€ ์‹œ๊ฐ 8์ค„ ๋“ฑ)์„ ๊ฒฐ์ •์ ์œผ๋กœ ํก์ˆ˜ํ•œ๋‹ค (LLM ์ค€์ˆ˜์œจ ๋น„์˜์กด). ์‹œ์ ยท๋™๋„ค๋Š” ๊ทธ ๋ธ”๋ก ์ฒซ(๊ฐ€์žฅ ์ด๋ฅธ) ๋ผ์ธ ๊ฒƒ์„ ์œ ์ง€ โ€” ์ž…๋ ฅ์ด ์‹œ์  ์˜ค๋ฆ„์ฐจ์ˆœ ์ •๋ ฌ๋ผ ์žˆ๋‹ค๊ณ  ๊ฐ€์ •. ์‹œ๊ฐ ์—†๋Š”(๊ตฌ [MM-DD]) ๋ผ์ธ์€ ๊ฒฐํ•ฉํ•˜์ง€ ์•Š๋Š”๋‹ค (ํ•˜์œ„ํ˜ธํ™˜). ๋ณธ๋ฌธ์€ EPISODE_HOUR_JOIN ์œผ๋กœ ์ด์–ด ๋ถ™์ด๋˜ ๊ทผ์‚ฌ-๋™์ผ ์„ธ๊ทธ๋จผํŠธ๋Š” 1๊ฐœ๋งŒ(์žฌ๊ฒฐํ•ฉ ๋ฉฑ๋“ฑ). โš ๏ธ 2026-07-14 ๋ณ€๊ฒฝ: ๊ฒฐํ•ฉ ํ‚ค์—์„œ ๋™๋„ค ์ œ๊ฑฐ โ€” ์šด์ „ ์ค‘์—” ๋™๋„ค๊ฐ€ ๋ช‡ ๋ถ„๋งˆ๋‹ค ๋ฐ”๋€Œ์–ด "1์‹œ๊ฐ„ = ํ•œ ์ค„"์ด ์‚ฌ์‹ค์ƒ ์ž‘๋™ํ•˜์ง€ ์•Š์•˜๋‹ค(๊ฐ™์€ ๋“œ๋ผ์ด๋ธŒ๊ฐ€ 2๋ถ„ ๊ฐ„๊ฒฉ ์—ฌ๋Ÿฌ ์ค„๋กœ ํŒŒํŽธํ™”, ์‚ฌ์šฉ์ž ๋ณด๊ณ ). ๋™๋„ค ๊ถค์ ์€ ์‹œ๊ฐ„ ๋ธ”๋ก ๋‹จ์œ„(๋ธ”๋ก ์ฒซ ์œ„์น˜)๋กœ ๋‚จ๋Š”๋‹ค. """ out: list[tuple[datetime, bool, str, str]] = [] index: dict[tuple[int, int, int, int], int] = {} for dt, has_time, loc, body in parsed: if not has_time: out.append((dt, has_time, loc, body)) continue key = (dt.year, dt.month, dt.day, dt.hour) i = index.get(key) if i is None: index[key] = len(out) out.append((dt, has_time, loc, body)) continue pdt, pht, ploc, pbody = out[i] segs = [s.strip() for s in pbody.split(EPISODE_HOUR_JOIN) if s.strip()] incoming = [s.strip() for s in body.split(EPISODE_HOUR_JOIN) if s.strip()] merged = _merge_segs(segs, incoming) if merged != segs: out[i] = (pdt, pht, ploc, EPISODE_HOUR_JOIN.join(merged)) return out def stamp_and_trim_episodes(raw: str, ref: "date | datetime", place: str = "") -> str: """์ƒˆ ์ผํ™”์— KST '์ง€๊ธˆ'(ref)๊ณผ ํ˜„์žฌ ์žฅ์†Œ(place)๋ฅผ ์Šคํƒฌํ”„ํ•˜๊ณ  ์ €์žฅ ํ•œ๋„(โ‰ค60์ผยทโ‰ค30์ค„)๋กœ ์ •๋ฆฌ. - ์‹œ์ ์ด ์žˆ๋Š” ์ค„์€ ๊ทธ ์‹œ์ ยท์žฅ์†Œ ์œ ์ง€(๊ธฐ์กด ๋ผ์ธ ๋ณด์กด), ์‹  ํฌ๋งท์œผ๋กœ ์ •๊ทœํ™”. - '[์˜ค๋Š˜]' ๋ฆฌํ„ฐ๋Ÿด ๋˜๋Š” ์‹œ์  ์—†๋Š” ์ค„(=์ด๋ฒˆ ์„ธ์…˜ ์ƒˆ ์‚ฌ๊ฑด)์€ ref ๋กœ ์Šคํƒฌํ”„ํ•˜๊ณ , place ๊ฐ€ ์žˆ์œผ๋ฉด ๊ทธ ๋™๋„ค๋ฅผ `@๋™๋„ค` ๋กœ ํ•จ๊ป˜ ๋ฐ•์ œ(๊ทธ ์‚ฌ๊ฑด ๋‹น์‹œ ์‚ฌ์šฉ์ž๊ฐ€ ์žˆ๋˜ ๊ณณ). - ref ๋ณด๋‹ค ๋ฏธ๋ž˜ ์‹œ์ ์€ ref ๋กœ ํด๋žจํ”„(LLM ํ™˜๊ฐ ๋ฐฉ์ง€). - ๊ฐ™์€ (๋‚ ์งœยท์‹œยท๋™๋„ค) ๋ผ์ธ์€ ํ•œ ์ค„๋กœ ๊ฒฐํ•ฉ(_coalesce_hour_blocks) โ€” "1์‹œ๊ฐ„ ๋ธ”๋ก = ํ•œ ์ค„". - 60์ผ ์ดˆ๊ณผ ์ œ๊ฑฐ, 30์ค„ ์ดˆ๊ณผ ์‹œ ์ตœ์‹ ๋ถ€ํ„ฐ 30์ค„ ์œ ์ง€. ๊ฐ™์€(์‹œ์ ยท๋ณธ๋ฌธ) ์ค‘๋ณต 1๊ฐœ๋งŒ. ๋ฐ˜ํ™˜์€ ์‹œ์  ์˜ค๋ฆ„์ฐจ์ˆœ(๊ณผ๊ฑฐโ†’ํ˜„์žฌ) ์ •๊ทœํ™” ํ…์ŠคํŠธ. ref ๊ฐ€ datetime ์ด๋ฉด ์ƒˆ ์Šคํƒฌํ”„์— ์‹œ๊ฐ ํฌํ•จ. place ๋Š” ๋™ ํ† ํฐ๋งŒ(์˜ˆ: '๊ณ ๊ธฐ๋™') ๋„˜๊ธฐ๋Š” ๊ฑธ ๊ถŒ์žฅ โ€” ํ˜ธ์ถœ์ธก์ด ์ถ•์•ฝํ•ด ์ „๋‹ฌ. """ if not (raw or "").strip(): return "" # ์„ฑ์  ๋Œ€ํ™” ์ผํ™” ์ฐจ๋‹จ (2026-07-14) โ€” ์ €์žฅ ๊ฒฝ๋กœ ๋‹จ์ผ ์ง€์ ์—์„œ ๊ฒฐ์ •์ ์œผ๋กœ ํ•„ํ„ฐ. # ๊ธฐ์กด ์ €์žฅ๋ถ„์— ๋‚จ์€ ์„ฑ์  ๋ผ์ธ๋„ ๋‹ค์Œ ๋ณ‘ํ•ฉ ํ†ต๊ณผ ์‹œ ํ•จ๊ป˜ ์ •๋ฆฌ๋œ๋‹ค(์†Œ๊ธ‰ ์ •ํ™”). raw = strip_sexual_episode_segments(raw) if not raw.strip(): return "" now, now_has_time = _coerce_now(ref) new_place = (place or "").strip() parsed: list[tuple[datetime, bool, str, str]] = [] seen: set[tuple[str, str]] = set() for line in raw.splitlines(): s = line.strip() if not s: continue if s.startswith("[์˜ค๋Š˜]"): body = s[len("[์˜ค๋Š˜]"):].strip() dt, has_time, loc = now, now_has_time, new_place else: dt, has_time, loc, body = _parse_episode_line(s, now) if dt is None: # ์‹œ์  ์—†๋Š” ์ƒˆ ์ค„(relocate ๋ณธ๋ฌธ ๋“ฑ) โ†’ ์ง€๊ธˆยทํ˜„์žฌ ์žฅ์†Œ๋กœ ์Šคํƒฌํ”„. dt, has_time, loc, body = now, now_has_time, new_place, (body or s) if dt > now: dt, has_time = now, now_has_time # ๋ฏธ๋ž˜ ์Šคํƒฌํ”„ ํด๋žจํ”„ if not body: continue key = (dt.replace(microsecond=0).isoformat(), body) if key in seen: continue seen.add(key) parsed.append((dt, has_time, loc, body)) if not parsed: return "" # 60์ผ ์ดˆ๊ณผ ์ œ๊ฑฐ parsed = [t for t in parsed if (now - t[0]).days <= EPISODE_STORE_MAX_AGE_DAYS] # ์‹œ์  ์˜ค๋ฆ„์ฐจ์ˆœ(๋™์ผ ์‹œ์ ์€ ์ž…๋ ฅ ์ˆœ์„œ ๋ณด์กด โ€” stable sort) parsed.sort(key=lambda t: t[0]) # ๊ฐ™์€ 1์‹œ๊ฐ„ ๋ธ”๋ก(๋‚ ์งœยท์‹œยท๋™๋„ค) ๊ฒฐํ•ฉ โ€” ๋ฐœํ™” ๋‹จ์œ„ ๋‚˜์—ด์„ ํ•œ ์ค„ ํžˆ์Šคํ† ๋ฆฌ๋กœ. parsed = _coalesce_hour_blocks(parsed) # 30์ค„ ์ดˆ๊ณผ ์‹œ ์ตœ์‹ (๋’ค์ชฝ)๋ถ€ํ„ฐ 30์ค„ ์œ ์ง€ if len(parsed) > EPISODE_STORE_MAX_LINES: parsed = parsed[-EPISODE_STORE_MAX_LINES:] return "\n".join(f"{_fmt_stamp(dt, ht, loc)} {b}" for (dt, ht, loc, b) in parsed) def _relative_label(now: datetime, dt: datetime, has_time: bool) -> str: """๊ฒฝ๊ณผ ์‹œ์  โ†’ ํ•œ๊ตญ์–ด ์ƒ๋Œ€ํ‘œํ˜„. ๊ฐ™์€ ๋‚  & ์‹œ๊ฐ์ •๋ณด ์žˆ์œผ๋ฉด ์‹œ๊ฐ„ ๋‹จ์œ„("๋ฐฉ๊ธˆ/3์‹œ๊ฐ„ ์ „"), ์•„๋‹ˆ๋ฉด ์ผ ๋‹จ์œ„("์˜ค๋Š˜/์–ด์ œ/3์ผ ์ „โ€ฆ"). ์ผ ๊ฒฝ๊ณ„๋Š” 24h ๊ฐ€ ์•„๋‹ˆ๋ผ ๋‹ฌ๋ ฅ ์ผ์ฐจ(day_diff)๋กœ ํŒ๋‹จ โ€” ์ž์ • ๋„˜์œผ๋ฉด '์–ด์ œ'. """ day_diff = (now.date() - dt.date()).days if day_diff <= 0: if has_time: hours = (now - dt).total_seconds() / 3600.0 if hours < 1: return "๋ฐฉ๊ธˆ" return f"{int(hours)}์‹œ๊ฐ„ ์ „" return "์˜ค๋Š˜" if day_diff == 1: return "์–ด์ œ" if day_diff <= 6: return f"{day_diff}์ผ ์ „" if day_diff <= 13: return "์ง€๋‚œ์ฃผ" if day_diff <= 20: return "2์ฃผ ์ „" if day_diff <= 29: return "3์ฃผ ์ „" if day_diff <= 59: return "์ง€๋‚œ๋‹ฌ" return "์˜ค๋ž˜์ „" def _beat_line(now: datetime, dt: datetime, has_time: bool, place: str, body: str) -> str: """์ฃผ์ž…์šฉ ํ•œ ์ค„ โ€” "- 3์‹œ๊ฐ„ ์ „ ยท ๋Œ€์ „: ๋ณธ๋ฌธ". ์žฅ์†Œ ์—†์œผ๋ฉด " ยท ์žฅ์†Œ" ์ƒ๋žต.""" loc = f" ยท {place}" if (place or "").strip() else "" return f"- {_relative_label(now, dt, has_time)}{loc}: {body}" def render_episodes(raw: str, ref: "date | datetime") -> str: """์ฃผ์ž…์šฉ ์ผํ™” ๋ Œ๋” โ€” ๋””์ผ€์ด ํ•„ํ„ฐ + ์ ˆ๋Œ€์‹œ์  โ†’ ์ƒ๋Œ€์‹œ๊ฐ„(+ ๊ทธ๋•Œ ์žˆ๋˜ ๋™๋„ค). ์ผ๋ฐ˜ ์ผํ™”๋Š” โ‰ค13์ผ, ์ˆœ๊ฐ„์ƒํƒœ(์ƒํƒœ) ํƒœ๊ทธ๋Š” โ‰ค1์ผ ๋งŒ ํ†ต๊ณผ. ํ†ต๊ณผํ•œ ๋ผ์ธ์„ "- ์–ด์ œ ยท ๊ณ ๊ธฐ๋™: ์ฝ”์ŠคํŠธ์ฝ” ๋‹ค๋…€์˜ด" / "- 3์‹œ๊ฐ„ ์ „: โ€ฆ" ํ˜•ํƒœ๋กœ. ํ†ต๊ณผ ๋ผ์ธ ์—†์œผ๋ฉด ๋นˆ ๋ฌธ์ž์—ด. ref ๊ฐ€ datetime ์ด๊ณ  ๋ผ์ธ์— ์‹œ๊ฐ์ด ์žˆ์œผ๋ฉด ๊ฐ™์€ ๋‚ ์€ ์‹œ๊ฐ„ ๋‹จ์œ„๋กœ ๋ Œ๋”. """ if not (raw or "").strip(): return "" now, ref_has_time = _coerce_now(ref) out: list[tuple[datetime, bool, str, str]] = [] for line in raw.splitlines(): dt, has_time, place, body = _parse_episode_line(line, now) if dt is None or not body: continue day_diff = (now.date() - dt.date()).days momentary = _MOMENTARY_TAG in body limit = EPISODE_INJECT_MOMENTARY_DAYS if momentary else EPISODE_INJECT_GENERAL_DAYS if day_diff > limit: continue clean = body.replace(_MOMENTARY_TAG, "").strip() if not clean: continue out.append((dt, has_time and ref_has_time, place, clean)) if not out: return "" out.sort(key=lambda t: t[0]) # ๊ณผ๊ฑฐ โ†’ ํ˜„์žฌ return "\n".join(_beat_line(now, dt, ht, loc, b) for (dt, ht, loc, b) in out) # --- ์˜๋ฏธ๊ธฐ์–ต ํŒจ์‹ฏ ๋ผ์šฐํŒ… (Phase 2 ์ปจํ…์ŠคํŠธ ๋ผ์šฐํ„ฐ) ------------------------- # ํŒจ์‹ฏ ํ—ค๋”๋ณ„ ๊ฒŒ์ดํŠธ. ์•Œ ์ˆ˜ ์—†๋Š” ํ—ค๋”(์˜› [์‚ฌ์šฉ์ž ์ •๋ณด]/[์ตœ๊ทผ ์šฐ๋ฆฌ ์–˜๊ธฐ] ๋“ฑ)๋Š” ํ•ญ์ƒ ์ฃผ์ž… # (graceful โ€” ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์ „ ๋ธ”๋กญ์€ ํ†ต์งธ ์ฃผ์ž…๋˜๋‹ค ๋‹ค์Œ ๋ณ‘ํ•ฉ ๋•Œ ํŒจ์‹ฏ์œผ๋กœ ์žฌ๋ฐฐ์น˜๋œ๋‹ค). FACET_ALWAYS = ("๊ธฐ๋ณธ", "์ทจํ–ฅยท์Šต๊ด€", "AI ์ •๋ณด") # ํ•ญ์ƒ ์ฃผ์ž…(+ [์ตœ๊ทผ๋Œ€ํ™”] ๋ณ„๋„ ๊ฒฝ๋กœ) FACET_BASE = FACET_ALWAYS # ํ•˜์œ„ํ˜ธํ™˜ ๋ณ„์นญ # --- ์บ๋ฆญํ„ฐ๋ณ„ ๊ด€๊ณ„ ๊ธฐ์–ต ํ‚ค์ž‰ (req_character_relation_memory ยงP2) -------------- # ํ™œ์„ฑ ์บ๋ฆญํ„ฐ ์นด๋“œ id ๊ธฐ์ค€์œผ๋กœ AI ๊ด€๊ณ„ ์„น์…˜์„ ๋ถ„๋ฆฌ: ํ‚ค ''(๊ธฐ๋ณธ/๋ฌด์นด๋“œ) = ๊ธฐ์กด # [AI ์ •๋ณด] ๊ทธ๋Œ€๋กœ(ํ•˜์œ„ํ˜ธํ™˜ 100%), ํ‚ค X = [AI:X]. ์ฃผ์ž…ยท๋ณ‘ํ•ฉ ๋ชจ๋‘ "ํ™œ์„ฑ ๊ฒƒ ํ•˜๋‚˜๋งŒ". # โš ๏ธ "์•Œ ์ˆ˜ ์—†๋Š” ํ—ค๋” ํ•ญ์ƒ ์ฃผ์ž…" ๊ทœ์น™์— [AI: prefix ๋งŒ ์˜ˆ์™ธ โ€” ๊ทธ ์™ธ unknown ๋™์ž‘ ๋ถˆ๋ณ€. AI_BASE_HEADER = "AI ์ •๋ณด" AI_KEYED_PREFIX = "AI:" def ai_header_for_key(active_ai_key: str) -> str: """ํ™œ์„ฑ ์บ๋ฆญํ„ฐ ํ‚ค โ†’ AI ๊ด€๊ณ„ ์„น์…˜ ํ—ค๋”(๋Œ€๊ด„ํ˜ธ ์ œ์™ธ). '' = ๊ธฐ์กด [AI ์ •๋ณด].""" k = (active_ai_key or "").strip() return f"{AI_KEYED_PREFIX}{k}" if k else AI_BASE_HEADER def is_ai_header(header: str) -> bool: """AI ๊ด€๊ณ„ ์„น์…˜ ํ—ค๋”์ธ๊ฐ€ โ€” ๊ธฐ๋ณธ [AI ์ •๋ณด] ๋˜๋Š” ์บ๋ฆญํ„ฐ ํ‚ค [AI:*].""" h = (header or "").strip() return h == AI_BASE_HEADER or h.startswith(AI_KEYED_PREFIX) def sanitize_active_ai_key(key, characters) -> str: """persona ์˜ active_character_id ์ •ํ•ฉ์„ฑ โ€” characters ๋ชฉ๋ก์— ์—†๋Š” id ๋Š” ''(๊ธฐ๋ณธ). ๊ตฌ ํด๋ผ์ด์–ธํŠธ/๊ตฌ ๋ฐ์ดํ„ฐ(ํ‚ค ๋ถ€์žฌ)๋Š” '' ์ทจ๊ธ‰(๊ธฐ์กด [AI ์ •๋ณด] ๊ฒฝ๋กœ, ํšŒ๊ท€ 0). """ k = str(key or "").strip() if not k: return "" for c in characters or []: if isinstance(c, dict) and str(c.get("id") or "").strip() == k: return k return "" # ์ƒํ™ฉ๋ณ„(๊ฒŒ์ดํŒ…) ๋ฒ„ํ‚ท โ€” ํ™œ์„ฑ ์ง‘ํ•ฉ์— ์žˆ์„ ๋•Œ๋งŒ ์ฃผ์ž…(req ยง2 ํƒ์†Œ๋…ธ๋ฏธ). FACET_TIME = ("์•„์นจ", "๋ฐค") # ์‹œ๊ฐ ํœด๋ฆฌ์Šคํ‹ฑ FACET_ACTIVITY = ("ํšŒ์‚ฌยท์ผ", "ํ‡ด๊ทผํ›„", "์‹์‚ฌ") # ์‹œ๊ฐ+์š”์ผ ํœด๋ฆฌ์Šคํ‹ฑ(์šด์ „์€ ๋ณ„๋„ ๋ผ์šฐํŠธ) FACET_DRIVING = ("์šด์ „",) # ์šด์ „๋ชจ๋“œ(stream_tts) ๋ผ์šฐํŠธ FACET_AFFECTION = ("์ด์ƒํ˜•", "์• ์ •") # ์—ฐ์• ๋งฅ๋ฝ(spiceโ‰ฅ์ค‘๊ฐ„)๋งŒ. ์• ์ • ์ถ”๊ฐ€(P2) # spice==hot ๋งŒ. ์‹  ํ—ค๋” [์„ฑ์ ](req ยง2) + ์˜› [์„ฑ์ ยทํŒจํ‹ฐ์‰ฌ] ๋‘˜ ๋‹ค ์ธ์‹ โ†’ ๋ฏธ์žฌ๋ณ‘ํ•ฉ ๋ฐ์ดํ„ฐ๋„ # ๊ฒŒ์ดํŠธยทlazy ์ฃผ์ž… ์ •์ƒ(ํšŒ๊ท€ 0). ์‹ ๊ทœ ์ถœ๋ ฅ/์ •๊ทœํ™”๋Š” [์„ฑ์ ] ์œผ๋กœ ์ˆ˜๋ ด. FACET_HOT = ("์„ฑ์ ", "์„ฑ์ ยทํŒจํ‹ฐ์‰ฌ") # ๋ชจ๋“  ๊ฒŒ์ดํŒ… ๋ฒ„ํ‚ท โ€” ํ™œ์„ฑ ์ง‘ํ•ฉ์— ์—†์œผ๋ฉด ์ฃผ์ž… ์ œ์™ธ(+ ์Šค์ฝ”ํ”„ ๋ณ‘ํ•ฉ ์‹œ held-out ๋Œ€์ƒ). _ALL_GATED = ( set(FACET_TIME) | set(FACET_ACTIVITY) | set(FACET_DRIVING) | set(FACET_AFFECTION) | set(FACET_HOT) ) _GATED_FACETS = _ALL_GATED # ํ•˜์œ„ํ˜ธํ™˜ ๋ณ„์นญ # ํ—ค๋” ๋ณ„์นญ โ€” ์˜› ํ‘œ๊ธฐ๋ฅผ ์‹  ์ •๊ทœ ํ—ค๋”๋กœ ๊ทธ๋ž˜์ด์Šคํ’€ ํก์ˆ˜(normalize ์—์„œ ์ ์šฉ). ๋ณธ๋ฌธ์€ ๋ณด์กด. _FACET_HEADER_ALIASES = {"์„ฑ์ ยทํŒจํ‹ฐ์‰ฌ": "์„ฑ์ ", "์„ฑ์ ยทํŒจํ‹ฐ์‹œ": "์„ฑ์ "} # [์„ฑ์ ] 6 ํ•˜์œ„ํ•„๋“œ(req ยง2). ๋นˆ ์นธ์€ P0 normalize ์˜ ํ”Œ๋ ˆ์ด์Šคํ™€๋” ์ œ๊ฑฐ๊ฐ€ ์ •๋ฆฌ. SEXUAL_SUBFIELDS = ( "๋Œ€์ƒยท์ทจํ–ฅ", "์ž์„ธยทํ–‰์œ„", "์ƒํ™ฉยท์žฅ๋ฉด", "๋Œ€ํ™”ํ†คยท์ง„ํ–‰", "ํ˜ธ์นญยท์–ดํœ˜", "์ˆ˜์œ„ยท๊ฒฝ๊ณ„", ) # --- ๊ด€๊ณ„ ์ด์ •ํ‘œ (req_character_relation_memory ยงP3-2) ------------------------- # AI ๊ด€๊ณ„ ์„น์…˜ ๋์˜ `์ด์ •ํ‘œ:` ํ•˜์œ„ ๋ฆฌ์ŠคํŠธ โ€” ๊ด€๊ณ„ ์ „ํ™˜์ (์ฒซ ๋งŒ๋‚จ/ํ˜ธ์นญ ๋ณ€ํ™”/์•ฝ์† ๋“ฑ)์˜ # append-only ๋กœ๊ทธ. ๋ณ‘ํ•ฉ LLM ์€ ๊ธฐ์กด ์ค„ ์ˆ˜์ •ยท์‚ญ์ œ ๊ธˆ์ง€, ์ƒˆ ์ค„์€ `[์˜ค๋Š˜]` โ†’ ์„œ๋ฒ„๊ฐ€ # [MM-DD] ๋‚ ์งœ ์Šคํƒฌํ”„(์ผํ™” ์Šคํƒฌํ”„ ํŒจํ„ด ์žฌ์‚ฌ์šฉ). ์ƒํ•œ 10์ค„(์ดˆ๊ณผ backstop = ์˜ค๋ž˜๋œ ๊ฒƒ๋ถ€ํ„ฐ). MILESTONE_LABEL = "์ด์ •ํ‘œ:" MILESTONE_MAX_LINES = 10 def _split_milestone_block(body: str) -> "tuple[list[str], list[str]]": """AI ์„น์…˜ ๋ณธ๋ฌธ โ†’ (์ด์ •ํ‘œ ์ด์ „ ๋ผ์ธ๋“ค, ์ด์ •ํ‘œ ๋ผ์ธ๋“ค). ๋ผ๋ฒจ ์—†์œผ๋ฉด (์ „์ฒด, []).""" lines = body.splitlines() for i, ln in enumerate(lines): if _strip_bullet(ln.strip()) == MILESTONE_LABEL: head = [l for l in lines[:i]] tail = [l for l in lines[i + 1:] if l.strip()] return head, tail return lines, [] def milestone_lines(body: str) -> "list[str]": """AI ์„น์…˜ ๋ณธ๋ฌธ์˜ ์ด์ •ํ‘œ ๋ผ์ธ(strip ์ •๊ทœํ™”). ํšŒ๊ณ  ๊ฒ€์ฆยท๊ฒ€์‚ฌ์šฉ.""" return [ln.strip() for ln in _split_milestone_block(body)[1]] def stamp_milestones(semantic: str, ref: "date | datetime") -> str: """AI ๊ด€๊ณ„ ์„น์…˜ ์ด์ •ํ‘œ์˜ ์ƒˆ ์ค„(`[์˜ค๋Š˜]`/๋ฌดํ‘œ๊ธฐ)์— [MM-DD] ์Šคํƒฌํ”„ + ์ƒํ•œ 10์ค„ backstop. ๊ธฐ์กด ์Šคํƒฌํ”„๊ฐ€ ์žˆ๋Š” ์ค„์€ **์›๋ฌธ ๊ทธ๋Œ€๋กœ**(append-only โ€” ํ‘œ๊ธฐ ์žฌ์ž‘์„ฑ ๊ธˆ์ง€). ์ƒํ•œ ์ดˆ๊ณผ๋Š” ์˜ค๋ž˜๋œ(์œ„์ชฝ) ์ค„๋ถ€ํ„ฐ ๋“œ๋กญ โ€” "๋œ ์ค‘์š”ํ•œ ์ค„ ์ œ๊ฑฐ" ํ๋ ˆ์ด์…˜์€ ๋ณ‘ํ•ฉ LLM ๋ชซ, ์ด๊ฑด ๊ฒฐ์ •์  ์•ˆ์ „๋ง. """ now, _ = _coerce_now(ref) order: list[str] = [] out: dict[str, str] = {} for header, body in split_facets(semantic).items(): if is_ai_header(header): head, tail = _split_milestone_block(body) if tail: stamped: list[str] = [] for ln in tail: core = _strip_bullet(ln.strip()) if core.startswith("[์˜ค๋Š˜]"): rest = core[len("[์˜ค๋Š˜]"):].strip() if rest: stamped.append(f"- {_fmt_stamp(now, False)} {rest}") continue dt, _ht, _loc, _b = _parse_episode_line(core, now) if dt is not None: stamped.append(ln) # ๊ธฐ์กด ์Šคํƒฌํ”„ ์ค„ = ์›๋ฌธ ๋ณด์กด else: stamped.append(f"- {_fmt_stamp(now, False)} {core}") if len(stamped) > MILESTONE_MAX_LINES: stamped = stamped[-MILESTONE_MAX_LINES:] parts = [l for l in head if l.strip()] if stamped: parts += [MILESTONE_LABEL, *stamped] body = "\n".join(parts).strip() if body.strip(): out[header] = body order.append(header) return "\n".join( (f"[{h}]\n{out[h]}" if h else out[h]) for h in order ).strip() _FACET_HEADER_RE = re.compile(r"^\[([^\]]+)\]\s*$") def _canonical_facet_header(h: str) -> str: """ํ—ค๋” ํ‘œ๊ธฐ ์ •๊ทœํ™” โ€” ๋ณ„์นญ([์„ฑ์ ยทํŒจํ‹ฐ์‰ฌ]โ†’[์„ฑ์ ]) ํก์ˆ˜.""" h = (h or "").strip() return _FACET_HEADER_ALIASES.get(h, h) def split_facets(semantic: str) -> "dict[str, str]": """ํŒจ์‹ฏ ๋ธ”๋กญ โ†’ {ํ—ค๋”(๋Œ€๊ด„ํ˜ธ ์ œ์™ธ): ๋ณธ๋ฌธ}. ์ž…๋ ฅ ์ˆœ์„œ ๋ณด์กด. ํ—ค๋” ์—†๋Š” ์„ ๋‘ ๋ผ์ธ์€ ํ‚ค ''.""" sections: dict[str, list[str]] = {} order: list[str] = [] cur = "" for line in (semantic or "").splitlines(): m = _FACET_HEADER_RE.match(line.strip()) if m: cur = m.group(1).strip() if cur not in sections: sections[cur] = [] order.append(cur) continue if cur not in sections: sections[cur] = [] order.append(cur) sections[cur].append(line) return {k: "\n".join(sections[k]).strip() for k in order} def render_facets(semantic: str, *, affection: bool, hot: bool) -> str: """๊ฒŒ์ดํŠธ์— ๋งž๋Š” ํŒจ์‹ฏ๋งŒ ๊ณจ๋ผ ์žฌ์กฐ๋ฆฝ. base([๊ธฐ๋ณธ]/[์ทจํ–ฅยท์Šต๊ด€]/[AI ์ •๋ณด]) + ์•Œ ์ˆ˜ ์—†๋Š” ํ—ค๋”๋Š” ํ•ญ์ƒ. [์ด์ƒํ˜•] ์€ affection, [์„ฑ์ ยทํŒจํ‹ฐ์‰ฌ] ๋Š” hot ์ผ ๋•Œ๋งŒ. ๋นˆ ๋ณธ๋ฌธ ์„น์…˜์€ ์ƒ๋žต. """ out: list[str] = [] for header, body in split_facets(semantic).items(): if not body.strip(): continue if header in FACET_HOT and not hot: continue if header in FACET_AFFECTION and not affection: continue out.append(f"[{header}]\n{body}" if header else body) return "\n".join(out).strip() def render_facets_subset(semantic: str, headers) -> str: """์ฃผ์–ด์ง„ ํ—ค๋” ์ง‘ํ•ฉ์˜ ํŒจ์‹ฏ๋งŒ ์žฌ์กฐ๋ฆฝ (spice ์ƒ์Šน ์‹œ lazy ์ฃผ์ž…์šฉ). ๋นˆ ์„น์…˜ ์ƒ๋žต.""" want = set(headers) out: list[str] = [] for header, body in split_facets(semantic).items(): if header in want and body.strip(): out.append(f"[{header}]\n{body}") return "\n".join(out).strip() def render_facets_gated(semantic: str, active_buckets, active_ai_key: str = "") -> str: """FACET_ALWAYS + ํ™œ์„ฑ ์ƒํ™ฉ ๋ฒ„ํ‚ท๋งŒ ์ฃผ์ž…(req ยง3 ๋ผ์šฐํ„ฐ). ๋น„ํ™œ์„ฑ ๊ฒŒ์ดํŠธ ๋ฒ„ํ‚ท์€ ์ œ์™ธ. ๋ฏธ์ •์˜(์˜› [์‚ฌ์šฉ์ž ์ •๋ณด] ๋“ฑ) ํ—ค๋”๋Š” ํ•ญ์ƒ ์ฃผ์ž…(graceful โ€” ๋‹ค์Œ ๋ณ‘ํ•ฉ ๋•Œ ์žฌ๋ฐฐ์น˜). ๋นˆ ์„น์…˜ ์ƒ๋žต. active_buckets = ์ด๋ฒˆ ์ƒํ™ฉ์˜ ํ™œ์„ฑ ๋ฒ„ํ‚ท ํ—ค๋” ์ง‘ํ•ฉ(์‹œ๊ฐ/ํ™œ๋™/์šด์ „/spice ๋กœ ์‚ฐ์ถœ). active_ai_key = ํ™œ์„ฑ ์บ๋ฆญํ„ฐ ์นด๋“œ id โ€” AI ๊ด€๊ณ„ ์„น์…˜([AI ์ •๋ณด]ยท[AI:*])์€ ํ™œ์„ฑ ๊ฒƒ ํ•˜๋‚˜๋งŒ ์ฃผ์ž…(์บ๋ฆญํ„ฐ๋ณ„ ๊ด€๊ณ„ ๊ธฐ์–ต, req_character_relation_memory ยงP2). '' = [AI ์ •๋ณด]. """ want = set(active_buckets or ()) active_ai = ai_header_for_key(active_ai_key) out: list[str] = [] for header, body in split_facets(semantic).items(): if not body.strip(): continue if is_ai_header(header) and header != active_ai: continue # ๋น„ํ™œ์„ฑ ์บ๋ฆญํ„ฐ ๊ด€๊ณ„ ๊ธฐ์–ต ์œ ์ถœ ์ฐจ๋‹จ ([AI: prefix ๋งŒ unknown ์˜ˆ์™ธ) if header in _ALL_GATED and header not in want: continue out.append(f"[{header}]\n{body}" if header else body) return "\n".join(out).strip() def active_time_activity_buckets(now: datetime) -> "list[str]": """์‹œ๊ฐยท์š”์ผ ํœด๋ฆฌ์Šคํ‹ฑ์œผ๋กœ ํ™œ์„ฑ ์‹œ๊ฐ„/ํ™œ๋™ ๋ฒ„ํ‚ท(req ยง3). GPS ํ•™์Šต(P3) ์ „ ํœด๋ฆฌ์Šคํ‹ฑ๋งŒ. - ์‹œ๊ฐ„๋Œ€: [์•„์นจ] 05โ€“10์‹œ ยท [๋ฐค] 22โ€“05์‹œ (์˜คํ›„/์ €๋…์€ ๋ฒ„ํ‚ท ์—†์Œ). - ํ™œ๋™(ํ‰์ผ): [ํšŒ์‚ฌยท์ผ] 09โ€“18์‹œ ยท [ํ‡ด๊ทผํ›„] 17:30โ€“19์‹œ. ๊ทธ ์™ธ(์ง‘)=๋ฒ„ํ‚ท ์—†์Œ. - [์‹์‚ฌ]: ์ ์‹ฌ 11:30โ€“13:30 / ์ €๋… 18โ€“20์‹œ(์š”์ผ ๋ฌด๊ด€, ๋…ธ์ด์ฆˆ ๊ฐ€๋Šฅ โ€” ์‹ค์‚ฌ์šฉ ํ›„ on/off ยง10). ์šด์ „ ๋ฒ„ํ‚ท์€ ๋ผ์šฐํŠธ(stream_tts)๋กœ๋งŒ โ€” ์—ฌ๊ธฐ ํฌํ•จ ์•ˆ ํ•จ. """ out: list[str] = [] h, mins = now.hour, now.hour * 60 + now.minute weekday = now.weekday() < 5 if 5 <= h < 10: out.append("์•„์นจ") if h >= 22 or h < 5: out.append("๋ฐค") if weekday: if 9 * 60 <= mins < 18 * 60: out.append("ํšŒ์‚ฌยท์ผ") if 17 * 60 + 30 <= mins < 19 * 60: out.append("ํ‡ด๊ทผํ›„") if (11 * 60 + 30 <= mins < 13 * 60 + 30) or (18 * 60 <= mins < 20 * 60): out.append("์‹์‚ฌ") return out RECENT_BEATS_CAP = 20 def render_recent_beats(raw: str, ref: "date | datetime", *, cap: int = RECENT_BEATS_CAP) -> str: """[์ตœ๊ทผ๋Œ€ํ™”] โ€” ๋””์ผ€์ด ๋Œ€์‹  ์ตœ๊ทผ cap ๊ฐœ ๋น„ํŠธ ํ•ญ์ƒ ์ฃผ์ž…(req ยง4). render_episodes ์™€ ๋‹ฌ๋ฆฌ ๋””์ผ€์ด ํ•„ํ„ฐ ์—†์Œ โ€” ์‹œ์  ์ตœ์‹ ์ˆœ cap ๊ฐœ๋ฅผ ๊ณจ๋ผ ๊ณผ๊ฑฐโ†’ํ˜„์žฌ๋กœ ๋ Œ๋”. ์ ˆ๋Œ€์‹œ์  โ†’ ์ƒ๋Œ€์‹œ๊ฐ„("๋ฐฉ๊ธˆ/3์‹œ๊ฐ„ ์ „/์–ด์ œ/์ง€๋‚œ์ฃผ") + ๊ทธ๋•Œ ์žˆ๋˜ ๋™๋„ค(" ยท ๋Œ€์ „"). (์ƒํƒœ) ํƒœ๊ทธ๋Š” ํ‘œ์‹œ์—์„œ ์ œ๊ฑฐ(ํ˜„์žฌ ๋‹จ์ • ๋ง๋ผ๋Š” ๊ฐ€์ด๋“œ๋Š” ์ฃผ์ž… wrapper ๊ฐ€ ๋‹ด๋‹น). ํ†ต๊ณผ ๋ผ์ธ ์—†์œผ๋ฉด ๋นˆ ๋ฌธ์ž์—ด. """ if not (raw or "").strip(): return "" now, ref_has_time = _coerce_now(ref) parsed: list[tuple[datetime, bool, str, str]] = [] for line in raw.splitlines(): dt, has_time, place, body = _parse_episode_line(line, now) if dt is None or not body: continue clean = body.replace(_MOMENTARY_TAG, "").strip() if not clean: continue parsed.append((dt, has_time and ref_has_time, place, clean)) if not parsed: return "" parsed.sort(key=lambda t: t[0]) # ๊ณผ๊ฑฐ โ†’ ํ˜„์žฌ parsed = parsed[-cap:] # ์ตœ๊ทผ cap ๊ฐœ return "\n".join(_beat_line(now, dt, ht, loc, b) for (dt, ht, loc, b) in parsed) # --- GPS ๋™๋„ค ํ•™์Šต (req_memory_situational_layers ยง3ยทP3) ---------------------- # ์‹œ๊ฐยท์š”์ผ ํœด๋ฆฌ์Šคํ‹ฑ์„ GPS ํ•™์Šต์œผ๋กœ ๋ณด์ •/override. "์ง‘ ๋™๋„ค/ํšŒ์‚ฌ ๋™๋„ค"๋ฅผ ๋นˆ๋„๋กœ ์ถ”๋ก ํ•ด, # ํ‰์ผ์ด๋ผ๋„ ์ง‘ ๋™๋„ค๋ฉด [์ง‘](ํ™œ๋™ ๋ฒ„ํ‚ท ์—†์Œ), ์ฃผ๋ง์ด๋ผ๋„ ํšŒ์‚ฌ ๋™๋„ค๋ฉด [ํšŒ์‚ฌยท์ผ]. # GPS ์—†์Œ/ํ•™์Šต ๋ถ€์กฑ/opt-out ์ด๋ฉด active_time_activity_buckets ํœด๋ฆฌ์Šคํ‹ฑ ๊ทธ๋Œ€๋กœ(graceful). LEARN_MIN_OBS = 3 # ์ง‘/ํšŒ์‚ฌ๋กœ ์ธ์ •ํ•  ์ตœ์†Œ ๊ด€์ธก ์ˆ˜(์ฝœ๋“œ์Šคํƒ€ํŠธ ์˜ค์ธ ๋ฐฉ์ง€) def classify_learn_bucket(now: datetime) -> "str | None": """๊ด€์ธก ์‹œ๊ฐ โ†’ ํ•™์Šต ์‹œ๊ฐ„๋Œ€('home'/'work'/None). geo/report ๋ˆ„์ ์šฉ. ํ‰์ผ 09โ€“18 = work, ํ‰์ผ ์ €๋…ยท์ƒˆ๋ฒฝ(19โ€“07) + ์ฃผ๋ง = home, ํ‰์ผ ํ†ต๊ทผ(07โ€“09/18โ€“19) = ๋ชจํ˜ธ skip. ์•ฑ ์‚ฌ์šฉ์ด ์ง‘์ค‘๋˜๋Š” ์ €๋…ยท์ฃผ๋ง(์ง‘)๊ณผ ํ‰์ผ ์ฝ”์–ด(ํšŒ์‚ฌ)์—์„œ๋งŒ ์‹ ํ˜ธ๋ฅผ ๋ชจ์€๋‹ค. """ h = now.hour weekday = now.weekday() < 5 if weekday and 9 <= h < 18: return "work" if (weekday and (h >= 19 or h < 7)) or (not weekday): return "home" return None def _place_token(s: str) -> str: """๋™ ๋‹จ์œ„ ๋น„๊ต ํ‚ค โ€” ์—ญ์ง€์˜ค์ฝ”๋”ฉ ๋ฌธ์ž์—ด์˜ ๊ฐ€์žฅ ๊ตฌ์ฒด์ (๋งˆ์ง€๋ง‰) ํ† ํฐ(์˜ˆ: '๊ณ ๊ธฐ๋™').""" s = (s or "").strip() return s.split()[-1] if s else "" def _same_place(a: str, b: str) -> bool: """๋‘ ์œ„์น˜๊ฐ€ ๊ฐ™์€ ๋™์ธ๊ฐ€ โ€” ์ „์ฒด ์ผ์น˜ ๋˜๋Š” ๋™ ํ† ํฐ ์ผ์น˜(์ ‘๋‘ ๋ฏธ์„ธ๋ณ€ํ˜• ํก์ˆ˜).""" a, b = (a or "").strip(), (b or "").strip() if not a or not b: return False if a == b: return True ta, tb = _place_token(a), _place_token(b) return bool(ta) and ta == tb def infer_home_work(learned: "dict | None") -> "tuple[str | None, str | None]": """ํ•™์Šต ๋ฐ์ดํ„ฐ โ†’ (์ง‘ ๋™๋„ค, ํšŒ์‚ฌ ๋™๋„ค) ์ „์ฒด ๋ฌธ์ž์—ด. ์ตœ์†Œ ๊ด€์ธก ๋ฏธ๋‹ฌ์ด๋ฉด None. ์ง‘ = home ์ตœ๋‹ค, ํšŒ์‚ฌ = work ์ตœ๋‹ค(์ง‘๊ณผ ๋‹ค๋ฅธ ๋™). ๋™๋ฅ ยท๋ฏธ๋‹ฌ์€ None(ํœด๋ฆฌ์Šคํ‹ฑ ํด๋ฐฑ). """ dongs = ((learned or {}).get("dongs") or {}) if not isinstance(dongs, dict) or not dongs: return None, None def _best(field: str, exclude: "str | None") -> "str | None": best, best_n = None, 0 for place, counts in dongs.items(): if exclude is not None and _same_place(place, exclude): continue n = int((counts or {}).get(field, 0) or 0) if n >= LEARN_MIN_OBS and n > best_n: best, best_n = place, n return best home = _best("home", None) work = _best("work", home) # ํšŒ์‚ฌ๋Š” ์ง‘๊ณผ ๋‹ค๋ฅธ ๋™ return home, work def resolve_situation_buckets( now: datetime, current_place: str, learned: "dict | None" ) -> "list[str]": """ํ˜„์žฌ GPS ๋™ + ํ•™์Šต์œผ๋กœ P2 ์‹œ๊ฐํœด๋ฆฌ์Šคํ‹ฑ์„ ๋ณด์ •ํ•œ ํ™œ์„ฑ ์ƒํ™ฉ ๋ฒ„ํ‚ท. - ํ˜„์žฌ ๋™์ด ํšŒ์‚ฌ ๋™๋„ค โ†’ [ํšŒ์‚ฌยท์ผ](+ํ‡ด๊ทผ์‹œ๊ฐ„๋Œ€๋ฉด [ํ‡ด๊ทผํ›„]). ์‹œ๊ฐ„/์‹์‚ฌ ๋ฒ„ํ‚ท์€ ์‹œ๊ณ„ ๊ทธ๋Œ€๋กœ. - ํ˜„์žฌ ๋™์ด ์ง‘ ๋™๋„ค โ†’ ํ™œ๋™ ๋ฒ„ํ‚ท ์—†์Œ(์ง‘). ์‹œ๊ฐ„(์•„์นจ/๋ฐค)ยท์‹์‚ฌ๋งŒ. - ๋งค์นญ ์—†์Œ/GPS ์—†์Œ/ํ•™์Šต ๋ถ€์กฑ โ†’ active_time_activity_buckets ํœด๋ฆฌ์Šคํ‹ฑ ๊ทธ๋Œ€๋กœ(graceful). """ base = active_time_activity_buckets(now) current_place = (current_place or "").strip() if not current_place: return base home, work = infer_home_work(learned) if work is None and home is None: return base # ์‹œ๊ฐ„/์‹์‚ฌ ๋ฒ„ํ‚ท(์‹œ๊ณ„ ํ™•์ •) ์€ override ํ›„์—๋„ ์œ ์ง€, ํ™œ๋™(ํšŒ์‚ฌยท์ผ/ํ‡ด๊ทผํ›„) ๋งŒ GPS ๊ฐ€ ์ •์ •. time_meal = [b for b in base if b in ("์•„์นจ", "๋ฐค", "์‹์‚ฌ")] if work and _same_place(current_place, work): act = ["ํšŒ์‚ฌยท์ผ"] mins = now.hour * 60 + now.minute if 17 * 60 + 30 <= mins < 19 * 60: act.append("ํ‡ด๊ทผํ›„") return time_meal + act if home and _same_place(current_place, home): return time_meal # ์ง‘ = ํ™œ๋™ ๋ฒ„ํ‚ท ์—†์Œ return base def record_observation(learned: "dict | None", place: str, now: datetime, *, max_dongs: int = 12) -> "dict | None": """ํ•™์Šต ๊ฐ์ฒด์— ๊ด€์ธก 1๊ฑด ๋ˆ„์ (์ˆœ์ˆ˜ํ•จ์ˆ˜) โ†’ ๊ฐฑ์‹ ๋ณธ ๋ฐ˜ํ™˜. ๋ณ€ํ™” ์—†์œผ๋ฉด None. place(๋™ ๋ฌธ์ž์—ด)ยทnow(KST) ์˜ ์‹œ๊ฐ„๋Œ€(classify_learn_bucket)์— ์นด์šดํŠธ +1. top max_dongs ๋™๋งŒ ๋ณด๊ด€(์ด๋นˆ๋„ ๋‚ฎ์€ ๋™ prune). ๋ชจํ˜ธ ์‹œ๊ฐ„๋Œ€/๋นˆ place ๋Š” ๋ฌด์‹œ(None). ์นด์šดํŠธ๋Š” ์•ˆ์ „ ์ƒํ•œ(1e6)์œผ๋กœ ํด๋žจํ”„. updated_at ๊ฐฑ์‹ . """ place = (place or "").strip() bucket = classify_learn_bucket(now) if not place or bucket is None: return None out = dict(learned or {}) dongs = dict(out.get("dongs") or {}) # ๋™ ํ† ํฐ์œผ๋กœ ๊ธฐ์กด ํ‚ค ๋ณ‘ํ•ฉ(์ ‘๋‘ ๋ฏธ์„ธ๋ณ€ํ˜• ํก์ˆ˜) โ€” ๊ฐ™์€ ๋™์ด๋ฉด ๊ธฐ์กด ํ‚ค ์žฌ์‚ฌ์šฉ. key = place for existing in dongs: if _same_place(existing, place): key = existing break counts = dict(dongs.get(key) or {}) counts[bucket] = min(int(counts.get(bucket, 0) or 0) + 1, 1_000_000) counts.setdefault("home", int(counts.get("home", 0) or 0)) counts.setdefault("work", int(counts.get("work", 0) or 0)) dongs[key] = counts # top N prune โ€” ์ด๋นˆ๋„(home+work) ๋‚ฎ์€ ๋™ ์ œ๊ฑฐ. if len(dongs) > max_dongs: ranked = sorted( dongs.items(), key=lambda kv: (kv[1].get("home", 0) + kv[1].get("work", 0)), reverse=True, ) dongs = dict(ranked[:max_dongs]) out["dongs"] = dongs out["updated_at"] = now.astimezone(timezone.utc).isoformat() return out def parse_merge_sections(text: str) -> tuple[str, str, str]: """๋ณ‘ํ•ฉ LLM ์ถœ๋ ฅ โ†’ (์˜๋ฏธ๊ธฐ์–ต, ์ผํ™” raw, ์ง€์นจ raw). ๊ธฐ๋Œ€ ์ˆœ์„œ: ์˜๋ฏธ๊ธฐ์–ต โ†’ ===์ผํ™”=== โ†’ ์ผํ™” โ†’ ===์ง€์นจ=== โ†’ ์ง€์นจ. ๋งˆ์ปค ๋ถ€์žฌ๋Š” graceful (ํ•ด๋‹น ์„น์…˜ ๋นˆ ๋ฌธ์ž์—ด). ์˜๋ฏธ๊ธฐ์–ต์€ ํ•ญ์ƒ ๊ฐ€์žฅ ์•ž ๋งˆ์ปค ์ด์ „ = ์ง€์นจ ํŒŒ์‹ฑ ์‹คํŒจ๊ฐ€ ์˜๋ฏธ๊ธฐ์–ต์„ ์˜ค์—ผ์‹œํ‚ค์ง€ ๋ชปํ•œ๋‹ค(ํšŒ๊ท€ ๊ฒฉ๋ฆฌ). """ t = (text or "").strip() if not t: return "", "", "" ep = t.find(EPISODE_MERGE_MARKER) dr = t.find(DIRECTIVE_MERGE_MARKER) present = [i for i in (ep, dr) if i >= 0] sem_end = min(present) if present else len(t) semantic = t[:sem_end].strip() episodes = "" directives = "" if ep >= 0: ep_start = ep + len(EPISODE_MERGE_MARKER) ep_end = dr if (dr >= 0 and dr > ep) else len(t) episodes = t[ep_start:ep_end].strip() if dr >= 0: directives = t[dr + len(DIRECTIVE_MERGE_MARKER):].strip() return semantic, episodes, directives def parse_merge_output(text: str) -> tuple[str, str]: """(์˜๋ฏธ๊ธฐ์–ต, ์ผํ™” raw) โ€” parse_merge_sections ์˜ ์•ž 2๊ฐœ(ํ•˜์œ„ํ˜ธํ™˜).""" semantic, episodes, _ = parse_merge_sections(text) return semantic, episodes # --- ์ €์žฅ ์ง์ „ ํ›„์ฒ˜๋ฆฌ ์ •๊ทœํ™” (req_memory_situational_layers ยง5) ---------------- # LLM ์ค€์ˆ˜์œจ์— ๋น„์˜์กดํ•˜๋Š” ๊ฒฐ์ •์  ๊ฐ€๋“œ. **์‚ญ์ œ๊ฐ€ ์•„๋‹ˆ๋ผ ์ •๋ฆฌ/์ด๋™**(additiveยทgraceful): # ํšŒ๊ท€ ๊ฐ€๋“œ(ยง7) โ€” ์˜๋ฏธ๊ธฐ์–ต ์‚ฌ์‹ค์„ ์ž„์˜ ์‚ญ์ œํ•˜์ง€ ์•Š๋Š”๋‹ค. ๋ˆ„์ˆ˜๋Š” ์ผํ™”๋กœ relocate(๋””์ผ€์ด๋กœ # ์†Œ๋ฉธํ•˜๋˜ ์˜์†์ด๋ฉด ๋‹ค์Œ ๋ณ‘ํ•ฉ์ด ์žฌํ•™์Šต). ์‚ฌ์šฉ์žโ†”AI ๋ถ„๋ฆฌ๋Š” ๋ณ‘ํ•ฉ ํ”„๋กฌํ”„ํŠธ ๊ฐ€๋“œ์™€ ๋ณ‘ํ–‰. # ๋นˆ์นธ/ํ”Œ๋ ˆ์ด์Šคํ™€๋” โ€” ์ •๋ณด ์—†๋Š” ์ค„(์ €์žฅ ๋…ธ์ด์ฆˆ)ยท"(์—†์Œ)" ๋ฅ˜ ์ œ๊ฑฐ์šฉ. _PLACEHOLDER_VALUES = { "", "์—†์Œ", "๋น„๊ณต๊ฐœ", "๋ฏธ์ƒ", "๋ชจ๋ฆ„", "ํ•ด๋‹น์—†์Œ", "ํ•ด๋‹น ์—†์Œ", "์ •๋ณด์—†์Œ", "์ •๋ณด ์—†์Œ", "n/a", "na", "tbd", "?", "??", "...", "โ€ฆ", "-", "โ€”", "x", } # [๊ธฐ๋ณธ]์˜ ์ •๊ทœ ๋ผ๋ฒจ โ€” ์˜์† ์‚ฌ์‹ค์ด๋ฏ€๋กœ ๋ˆ„์ˆ˜ relocate ๋Œ€์ƒ์—์„œ ๋ณดํ˜ธ(์ˆœ๊ฐ„์ƒํƒœ ํ‚ค์›Œ๋“œ๊ฐ€ ์„ž์—ฌ๋„ # ํ†ต์งธ๋กœ ์˜ฎ๊ธฐ์ง€ ์•Š๋Š”๋‹ค). ๋ผ๋ฒจ ํ‘œ๊ธฐ ๋ณ€ํ˜• ํก์ˆ˜์šฉ. _BASE_CANONICAL_LABELS = { "์ด๋ฆ„", "ํ˜ธ์นญ", "ํ˜ธ์นญ/๋งํˆฌ", "๋งํˆฌ", "์‹ ์ฒด", "๊ฐ€์กฑ", "์‚ฌ๋žŒ", "๊ฐ€์กฑ/์‚ฌ๋žŒ", "์ง์—…", "์ง์—…/์ผ", "์‚ฌ๋Š” ๊ณณ", "์‚ฌ๋Š”๊ณณ", "๊ฑฐ์ฃผ์ง€", "์‚ฌ๋Š” ๊ณณ/์ง์žฅ", } # ์ˆœ๊ฐ„์ƒํƒœ ํ‚ค์›Œ๋“œ โ€” ์˜๋ฏธ๊ธฐ์–ต์— ๋ˆ„์ˆ˜๋œ ์ผ์‹œ์  ์‹ ์ฒด/์ปจ๋””์…˜(์ผํ™”๋กœ ์ด๋™, (์ƒํƒœ) ํƒœ๊ทธ ๋ถ€์—ฌ). # ์งง์€ ํ† ํฐ์€ ์˜คํƒ(์˜ˆ: "์•„ํ”„"โŠ‚"์•„ํ”„๋ฆฌ์นด")์„ ํ”ผํ•ด ๊ตฌ์ฒดํ˜•์œผ๋กœ. _MOMENTARY_HINTS = ( "๋ป๊ทผ", "๊ฒฐ๋ฆฌ", "์‘ค์‹œ", "์•„ํŒŒ", "์•„ํ”„๋‹ค", "์•„ํ”ˆ๋ฐ", "์•„ํ””", "์•„ํŒ ", "๋”ฐ๋”", "๋ถ€์—ˆ", "๋ถ“๊ธฐ", "๊ฐ๊ธฐ ๊ธฐ์šด", "๊ฐ๊ธฐ ๊ฑธ", "๋ชธ์‚ด", "์—ด๋‚˜", "๋ฏธ์—ด", "ํ”ผ๊ณค", "์กธ๋ฆฌ", "์กธ๋ ค", "๋ฐฐ๊ณ ํ”„", "๋ฐฐ๋ถˆ", "์ทจํ–ˆ", "์ˆ™์ทจ", "๋”๋ถ€๋ฃฉ", "๋ฉ”์Šค๊บผ", "์–ด์ง€๋Ÿฌ", "์ปจ๋””์…˜ ๋‚œ์กฐ", "๋ชธ์ด ์•ˆ", ) # ์ผํšŒ์„ฑ ์‚ฌ๊ฑด ํ‚ค์›Œ๋“œ โ€” ์˜๋ฏธ๊ธฐ์–ต์— ๋ˆ„์ˆ˜๋œ ๊ณผ๊ฑฐ ์‚ฌ๊ฑด(์ผํ™”๋กœ ์ด๋™). ์Šต๊ด€(์ข‹์•„ํ•จ/ํƒ/์”€/๋‹ค๋‹˜ ๋“ฑ)์€ # ๋น„ํ•ด๋‹น โ€” ๊ณผ๊ฑฐ์‹œ์ œ ํ–‰์œ„ ์–ด๋ฏธ๋งŒ ๋งค์นญํ•ด ์˜์† ์ทจํ–ฅ ์˜ค์ธ ์ตœ์†Œํ™”. _EVENT_HINTS = ( "๋จน์—ˆ", "๋งˆ์…จ", "๋‹ค๋…€์™”", "๋‹ค๋…€์˜ด", "๊ฐ”๋‹ค์™”", "๊ฐ”๋‹ค ์™”", "๊ตฌ๊ฒฝํ–ˆ", "๋“ค๋ €", "๊ตฌ๋งคํ–ˆ", "์ฃผ๋ฌธํ–ˆ", "๋ณด๋Ÿฌ ๊ฐ”", "๋งŒ๋‚˜๊ณ  ์™”", "๋ณด๊ณ  ์™”", ) _LEAK_SCANNED_FACETS = ("๊ธฐ๋ณธ", "์ทจํ–ฅยท์Šต๊ด€") _BULLET_CHARS = "-โ€ขยท*โˆ™โ–ช \t" def _strip_bullet(s: str) -> str: return s.lstrip(_BULLET_CHARS).strip() def _is_placeholder_value(val: str) -> bool: v = val.strip() # ํ…œํ”Œ๋ฆฟ ํžŒํŠธ echo ๋ฐฉ์ง€ โ€” ๊ฐ’ ์ „์ฒด๊ฐ€ ๊ด„ํ˜ธ๋กœ ๊ฐ์‹ธ์ธ ์•ˆ๋‚ด๋ฌธ(์˜ˆ: "(์—ฐ์•  ์ƒ๋Œ€ ์„ ํ˜ธ)", # "(๋ฌด์—‡/๋ˆ„๊ตฌ์—๊ฒŒ ๋Œ๋ฆฌ๋Š”์ง€)", "(ํ•œ ์ค„์”ฉ)")์€ ์‹ค์ œ ๋‚ด์šฉ์ด ์•„๋‹ˆ๋‹ค. ์‹ค์‚ฌ์‹ค์€ ๋‹จ์ผ ๊ด„ํ˜ธ๋กœ # ํ†ต์งธ ๊ฐ์‹ธ ์“ฐ์ง€ ์•Š๋Š”๋‹ค("๋ˆ„๋‚˜ (์ฃผ๋กœ)" ์ฒ˜๋Ÿผ ๋ถ€๋ถ„ ๊ด„ํ˜ธ๋Š” ์‹ค๊ฐ’ โ†’ ๋ณด์กด). if len(v) >= 2 and v[0] == "(" and v[-1] == ")": return True return v.strip("()[]").strip().lower() in _PLACEHOLDER_VALUES def _clean_facet_body(body: str) -> str: """๋นˆ/ํ”Œ๋ ˆ์ด์Šคํ™€๋” ๋ถˆ๋ฆฟ ์ œ๊ฑฐ. 'label: value' ๋Š” value ๊ฐ€, ๋‹จ๋… ๋ถˆ๋ฆฟ์€ ๋ณธ๋ฌธ์ด ๊ธฐ์ค€. ์›๋ž˜ ๋“ค์—ฌ์“ฐ๊ธฐ/๋ถˆ๋ฆฟ ํ‘œ๊ธฐ๋Š” ๋ณด์กด(์ฒซ strip ํ›„ ๊ทธ๋Œ€๋กœ ์œ ์ง€)ํ•ด ํฌ๋งท ๋ณ€ํ˜• ์ตœ์†Œํ™”. '์ด์ •ํ‘œ:' ๋ผ๋ฒจ ์ค„์€ ๊ฐ’์ด ์—†์–ด๋„ ๋ณด์กด(ํ•˜์œ„ ๋ฆฌ์ŠคํŠธ์˜ ์ปจํ…Œ์ด๋„ˆ) โ€” ๋‹จ, ์•„๋ž˜์— ์ด์ •ํ‘œ๊ฐ€ ํ•œ ์ค„๋„ ์—†์œผ๋ฉด ๋งˆ์ง€๋ง‰์— ์ •๋ฆฌ(๋นˆ ํ—ค๋” ์ €์žฅ ์ฐจ๋‹จ๊ณผ ๋™์ผ ์›์น™). """ kept: list[str] = [] for line in body.splitlines(): s = line.strip() if not s: continue core = _strip_bullet(s) if not core: continue if core == MILESTONE_LABEL: kept.append(s) # ์ด์ •ํ‘œ ์ปจํ…Œ์ด๋„ˆ ๋ผ๋ฒจ โ€” ํ”Œ๋ ˆ์ด์Šคํ™€๋” ํŒ์ • ์ œ์™ธ continue if ":" in core: _, _, val = core.partition(":") if _is_placeholder_value(val): continue elif _is_placeholder_value(core): continue kept.append(s) # ํ•˜์œ„ ์ด์ •ํ‘œ๊ฐ€ ์ „๋ถ€ ์ •๋ฆฌ๋ผ ๋ผ๋ฒจ๋งŒ ๋‚จ์œผ๋ฉด ๋ผ๋ฒจ๋„ ์ œ๊ฑฐ. if kept and _strip_bullet(kept[-1]) == MILESTONE_LABEL: kept.pop() return "\n".join(kept).strip() def normalize_semantic(semantic: str) -> str: """์˜๋ฏธ๊ธฐ์–ต ๊ฒฐ์ •์  ์ •๋ฆฌ โ€” ํ—ค๋” ๋ณ„์นญยทํ—ค๋”์—†๋Š” ์„ ๋‘ [๊ธฐ๋ณธ] ๋ผ์šฐํŒ… + ๋นˆ/ํ”Œ๋ ˆ์ด์Šคํ™€๋” ์ œ๊ฑฐ. - ํ—ค๋” ๋ณ„์นญ ์ •๊ทœํ™”([์„ฑ์ ยทํŒจํ‹ฐ์‰ฌ]โ†’[์„ฑ์ ]). ๋ณ„์นญ ์ถฉ๋Œ ์‹œ ๋ณธ๋ฌธ ๋ณ‘ํ•ฉ(์‚ญ์ œ ๊ธˆ์ง€). - split_facets ์˜ ํ—ค๋”์—†๋Š” ์„ ๋‘('' ํ‚ค)๋Š” [๊ธฐ๋ณธ] ์œผ๋กœ ํก์ˆ˜(์—†์œผ๋ฉด ์‹ ์„ค, ์žˆ์œผ๋ฉด ์•ž์— ๋ณ‘ํ•ฉ). - ๋นˆ ๋ณธ๋ฌธ์ด ๋œ ํŒจ์‹ฏ(ํ—ค๋”๋งŒ ๋‚จ๋Š”)์€ ์ƒ๋žต โ†’ "[์ด์ƒํ˜•] (์—†์Œ)" ๊ฐ™์€ ํ”Œ๋ ˆ์ด์Šคํ™€๋” ์ €์žฅ ์ฐจ๋‹จ. - ๋ฏธ์ •์˜(์˜› [์‚ฌ์šฉ์ž ์ •๋ณด] ๋“ฑ) ํ—ค๋”๋Š” ๋ณด์กด โ€” graceful ์žฌ๋ฐฐ์น˜๋Š” ๋ณ‘ํ•ฉ LLM ์˜ ๋ชซ(ํšŒ๊ท€ ๊ฐ€๋“œ). """ facets = split_facets(semantic) lead = facets.get("", "").strip() order: list[str] = [] cleaned: dict[str, str] = {} for header, body in facets.items(): if header == "": continue canon = _canonical_facet_header(header) cb = _clean_facet_body(body) if not cb: continue if canon in cleaned: cleaned[canon] = cleaned[canon] + "\n" + cb # ๋ณ„์นญ ์ถฉ๋Œ ๋ณ‘ํ•ฉ else: cleaned[canon] = cb order.append(canon) if lead: lead_clean = _clean_facet_body(lead) if lead_clean: if "๊ธฐ๋ณธ" in cleaned: cleaned["๊ธฐ๋ณธ"] = lead_clean + "\n" + cleaned["๊ธฐ๋ณธ"] else: cleaned["๊ธฐ๋ณธ"] = lead_clean order.insert(0, "๊ธฐ๋ณธ") return "\n".join(f"[{h}]\n{cleaned[h]}" for h in order).strip() def _relocate_leaked_episodes(semantic: str) -> "tuple[str, list[str]]": """[๊ธฐ๋ณธ]/[์ทจํ–ฅยท์Šต๊ด€] ์— ๋ˆ„์ˆ˜๋œ ์ˆœ๊ฐ„์ƒํƒœยท์ผํšŒ์„ฑ ์‚ฌ๊ฑด์„ ๋ถ„๋ฆฌ. ๋ฐ˜ํ™˜: (์ •๋ฆฌ๋œ ์˜๋ฏธ๊ธฐ์–ต, ์ผํ™”๋กœ ์˜ฎ๊ธธ ๋ณธ๋ฌธ ๋ฆฌ์ŠคํŠธ). **์‚ญ์ œ๊ฐ€ ์•„๋‹ˆ๋ผ ์ด๋™** โ€” ํ˜ธ์ถœ์ธก์ด ๋ฆฌ์ŠคํŠธ๋ฅผ ์ผํ™”๋กœ ์Šคํƒฌํ”„ํ•œ๋‹ค. [๊ธฐ๋ณธ] ์˜ ์ •๊ทœ ๋ผ๋ฒจ(์ด๋ฆ„ยท์‹ ์ฒดยท๊ฐ€์กฑ ๋“ฑ ์˜์†)์€ ๋ณดํ˜ธ. ์ˆœ๊ฐ„์ƒํƒœ๋Š” ๋์— (์ƒํƒœ) ํƒœ๊ทธ๋ฅผ ๋ถ™์—ฌ ์งง์€ ๋””์ผ€์ด๊ฐ€ ๊ฑธ๋ฆฌ๊ฒŒ ํ•œ๋‹ค. """ facets = split_facets(semantic) leaked: list[str] = [] order: list[str] = [] out: dict[str, str] = {} for header, body in facets.items(): if header not in _LEAK_SCANNED_FACETS: out[header] = body order.append(header) continue kept: list[str] = [] for line in body.splitlines(): s = line.strip() if not s: continue core = _strip_bullet(s) label, value = "", core if ":" in core: label, _, value = core.partition(":") label, value = label.strip(), value.strip() # [๊ธฐ๋ณธ] ์ •๊ทœ ๋ผ๋ฒจ์€ ์˜์† ์‚ฌ์‹ค โ†’ ๋ณดํ˜ธ(๋ˆ„์ˆ˜ ํŒ์ • ์ œ์™ธ). if header == "๊ธฐ๋ณธ" and label in _BASE_CANONICAL_LABELS: kept.append(s) continue momentary = any(k in core for k in _MOMENTARY_HINTS) event = any(k in core for k in _EVENT_HINTS) if momentary or event: txt = value or core if momentary and _MOMENTARY_TAG not in txt: txt = f"{txt} {_MOMENTARY_TAG}" leaked.append(txt) else: kept.append(s) kb = "\n".join(kept).strip() if kb: out[header] = kb order.append(header) sem2 = "\n".join( (f"[{h}]\n{out[h]}" if h else out[h]) for h in order if out.get(h, "").strip() ).strip() return sem2, leaked def normalize_merge_result(semantic: str, episodes_raw: str) -> "tuple[str, str]": """์ €์žฅ ์ง์ „ ํ›„์ฒ˜๋ฆฌ ์ข…ํ•ฉ โ€” ์˜๋ฏธ๊ธฐ์–ต ์ •๋ฆฌ + ์˜๋ฏธโ†”์ผํ™” ๋ˆ„์ˆ˜ relocate. ์ˆœ์ˆ˜ํ•จ์ˆ˜. ๋ฐ˜ํ™˜๋œ episodes_raw ์˜ ์ถ”๊ฐ€ ๋ผ์ธ์€ ์‹œ์ ์ด ์—†์–ด ํ˜ธ์ถœ์ธก stamp_and_trim_episodes ๊ฐ€ '์ง€๊ธˆ'์œผ๋กœ ์Šคํƒฌํ”„(๋ฏธ๋ž˜ ํด๋žจํ”„ยทโ‰ค60์ผ ํŠธ๋ฆผ๋„ ๊ฑฐ๊ธฐ์„œ ์ผ๊ด„). ์‚ฌ์šฉ์žโ†”AI ๋ถ„๋ฆฌ๋Š” ๋ณ‘ํ•ฉ ํ”„๋กฌํ”„ํŠธ ๊ฐ€๋“œ์™€ ๋ณ‘ํ–‰ โ€” ์—ฌ๊ธฐ์„œ๋Š” ๊ฒฐ์ •์ ์œผ๋กœ ์•ˆ์ „ํ•œ ํ•ญ๋ชฉ๋งŒ ์ •๋ฆฌ(์˜์† ์‚ฌ์‹ค ์ž„์˜ ์‚ญ์ œ ๊ธˆ์ง€, ํšŒ๊ท€ ยง7). """ sem = normalize_semantic(semantic) sem, leaked = _relocate_leaked_episodes(sem) ep = (episodes_raw or "").strip() if leaked: extra = "\n".join(leaked) ep = (ep + "\n" + extra).strip() if ep else extra return sem, ep # --- ์ž๋™ ์บก โ€” ๋ฒ„ํ‚ท ์œ ํ˜•๋ณ„ ์†Œํ”„ํŠธ ์˜ˆ์‚ฐ (req_memory_situational_layers ยง5) --------- # ์ „์—ญ ์ƒํ•œ ์ œ๊ฑฐ โ†’ ๋ฒ„ํ‚ท๋ณ„ ์†Œํ”„ํŠธ ์˜ˆ์‚ฐ. ์ดˆ๊ณผ ์‹œ ์•ฝํ•œ/์˜ค๋ž˜๋œ(=๋’ค์ชฝ) ์ค„๋ถ€ํ„ฐ whole-line ํŠธ๋ฆผ. # ์ €์žฅ ์ด๋Ÿ‰์€ ํ‚ค์šฐ๋˜(์ƒํ™ฉ๋ณ„ ๊ฒŒ์ดํŒ…์œผ๋กœ 1ํšŒ ์ฃผ์ž…๋Ÿ‰์€ ๋ณ„๋„ ์ œ์–ด) ๋ฌดํ•œ ์„ฑ์žฅ์€ ๋ง‰๋Š”๋‹ค(ยง1). # โš ๏ธ ์บก์€ ๊ธธ์ด ๊ฐ€๋“œ์ผ ๋ฟ โ€” ์ ˆ๋Œ€ ์ค„์„ ์ชผ๊ฐœ ์ž๋ฅด์ง€ ์•Š๋Š”๋‹ค(๋ถˆ๋ฆฟ ๋‹จ์œ„). ์•ˆ์ „(ํ•˜๋“œ๋ฆฌ๋ฐ‹) ๋ผ์ธ ๋ณดํ˜ธ. FACET_SOFT_BUDGET = { "๊ธฐ๋ณธ": 400, "AI ์ •๋ณด": 600, # 400โ†’600 (๊ด€๊ณ„ ๋‹จ๊ณ„ + ์ด์ •ํ‘œ ํฌํ•จ, req_character_relation ยงP3-3) "์ทจํ–ฅยท์Šต๊ด€": 700, "์ด์ƒํ˜•": 500, "์„ฑ์ ": 1000, "์„ฑ์ ยทํŒจํ‹ฐ์‰ฌ": 1000, # ๋ณ„์นญ ์•ˆ์ „๋ง(์ •๊ทœํ™” ์ „ ์ž”์กด ๋ฐ์ดํ„ฐ) } FACET_DEFAULT_BUDGET = 700 # ๋ฏธ์ •์˜(์˜›/์ƒํ™ฉ) ํ—ค๋” โ€” ๋„ˆ๊ทธ๋Ÿฝ๊ฒŒ # ํŠธ๋ฆผ์—์„œ ๋ณดํ˜ธํ•  ๋ผ๋ฒจ โ€” ์„ฑ์  ํ•˜๋“œ๋ฆฌ๋ฐ‹/๊ธˆ์ง€์„ ์€ ์•ˆ์ „์ƒ ๋งˆ์ง€๋ง‰๊นŒ์ง€ ์œ ์ง€. _CAP_PROTECTED_LABELS = {"์ˆ˜์œ„ยท๊ฒฝ๊ณ„", "์ˆ˜์œ„", "๊ฒฝ๊ณ„", "ํ•˜๋“œ๋ฆฌ๋ฐ‹", "ํ•˜๋“œ ๋ฆฌ๋ฐ‹", "๊ธˆ์ง€์„ ", "์ ˆ๋Œ€ ๊ธˆ์ง€"} def facet_budget(header: str) -> int: """ํŒจ์‹ฏ ์†Œํ”„ํŠธ ์˜ˆ์‚ฐ โ€” AI ๊ด€๊ณ„ ์„น์…˜([AI ์ •๋ณด]ยท[AI:*])์€ ๊ณตํ†ต 600, ๊ทธ ์™ธ ํ‘œ ์ฐธ์กฐ.""" if is_ai_header(header): return FACET_SOFT_BUDGET[AI_BASE_HEADER] return FACET_SOFT_BUDGET.get(header, FACET_DEFAULT_BUDGET) def _line_label(line: str) -> str: core = _strip_bullet(line) return core.split(":", 1)[0].strip() if ":" in core else "" def _cap_facet_body(body: str, budget: int, *, protect_milestones: bool = False) -> str: """๋ณธ๋ฌธ์ด budget(์ž) ์ดˆ๊ณผ๋ฉด ๋น„๋ณดํ˜ธ ๋ผ์ธ์„ ๋’ค(์•ฝํ•œ/์ตœ์‹ )์—์„œ๋ถ€ํ„ฐ whole-line ๋“œ๋กญ. ๋ณดํ˜ธ ๋ผ๋ฒจ(์ˆ˜์œ„ยท๊ฒฝ๊ณ„ ๋“ฑ ์•ˆ์ „์„ )์€ ๋งˆ์ง€๋ง‰๊นŒ์ง€ ์œ ์ง€. ๋ชจ๋“  ๋ผ์ธ์ด ๋ณดํ˜ธ ๋ผ์ธ์ด๋ฉด ๊ทธ๋Œ€๋กœ ๋‘”๋‹ค (์•ˆ์ „ ์šฐ์œ„ โ€” ๊ธธ์ด๋ณด๋‹ค ํ•˜๋“œ๋ฆฌ๋ฐ‹ ๋ณด์กด ์šฐ์„ ). protect_milestones(AI ๊ด€๊ณ„ ์„น์…˜)์ด๋ฉด `์ด์ •ํ‘œ:` ๋ธ”๋ก์€ ํ†ต์ƒ ํŠธ๋ฆผ์—์„œ ์ œ์™ธ โ€” ๋น„๋ณดํ˜ธ ๋ผ์ธ์ด ์†Œ์ง„๋์„ ๋•Œ๋งŒ ์˜ค๋ž˜๋œ ์ด์ •ํ‘œ๋ถ€ํ„ฐ ๋งˆ์ง€๋ง‰ ์ˆ˜๋‹จ์œผ๋กœ ๋“œ๋กญ(append-only ์šฐ์„  ๋ณด์กด). """ if len(body) <= budget: return body lines = [ln for ln in body.splitlines() if ln.strip()] ms: set[int] = set() if protect_milestones: for i, ln in enumerate(lines): if _strip_bullet(ln.strip()) == MILESTONE_LABEL: ms = set(range(i, len(lines))) # ๋ผ๋ฒจ + ์ด์ •ํ‘œ ์ „๋ถ€ ๋ณดํ˜ธ break while len("\n".join(lines)) > budget: drop_idx = None for i in range(len(lines) - 1, -1, -1): if i in ms: continue if _line_label(lines[i]) not in _CAP_PROTECTED_LABELS: drop_idx = i break if drop_idx is None: ms_sorted = sorted(ms) if len(ms_sorted) >= 2: drop_idx = ms_sorted[1] # [0]=๋ผ๋ฒจ โ€” ๊ฐ€์žฅ ์˜ค๋ž˜๋œ ์ด์ •ํ‘œ๋ถ€ํ„ฐ else: break # ๋ณดํ˜ธ ๋ผ์ธ๋งŒ ๋‚จ์Œ โ†’ ๋” ๋ชป ์ค„์ž„ del lines[drop_idx] ms = {i - 1 if i > drop_idx else i for i in ms if i != drop_idx} # ์ด์ •ํ‘œ๊ฐ€ ์ „๋ถ€ ๋“œ๋กญ๋ผ ๋ผ๋ฒจ๋งŒ ๋‚จ์œผ๋ฉด ๋ผ๋ฒจ๋„ ์ •๋ฆฌ. if lines and _strip_bullet(lines[-1].strip()) == MILESTONE_LABEL: lines.pop() return "\n".join(lines).strip() def cap_facets(semantic: str) -> str: """ํŒจ์‹ฏ๋ณ„ ์†Œํ”„ํŠธ ์˜ˆ์‚ฐ ์ ์šฉ(์ „์—ญ ์ƒํ•œ ๋Œ€์ฒด). ๋นˆ ํŒจ์‹ฏ ์ƒ๋žต. ํ—ค๋” ์ˆœ์„œ ๋ณด์กด. ์˜ˆ์‚ฐ์€ facet_budget(AI ๊ด€๊ณ„ ์„น์…˜ ๊ณตํ†ต 600, ๋ฏธ์ •์˜ ํ—ค๋” FACET_DEFAULT_BUDGET). ์ˆœ์ˆ˜ํ•จ์ˆ˜. """ order: list[str] = [] out: dict[str, str] = {} for header, body in split_facets(semantic).items(): capped = _cap_facet_body( body, facet_budget(header), protect_milestones=is_ai_header(header) ) if capped.strip(): out[header] = capped order.append(header) return "\n".join((f"[{h}]\n{out[h]}" if h else out[h]) for h in order).strip() # --- ์Šค์ฝ”ํ”„ ๋ณ‘ํ•ฉ (req_memory_situational_layers ยง5) ---------------------------- # ํ™œ์„ฑ ๋ฒ„ํ‚ท๋งŒ ์žฌ๋ณ‘ํ•ฉ โ€” ์ž…๋ ฅยท์ถœ๋ ฅ์„ ํ™œ์„ฑ ๋ฒ„ํ‚ท์œผ๋กœ ํ•œ์ •ํ•ด 16K์ž ์ „์ฒด ์žฌemit(๋น„์šฉยท๋“œ๋ฆฌํ”„ํŠธ) # ํšŒํ”ผ. ๋น„ํ™œ์„ฑ ๊ฒŒ์ดํŠธ ๋ฒ„ํ‚ท(์˜ˆ: ๋น„-์„ฑ์  ์„ธ์…˜์˜ [์„ฑ์ ])์€ held-out โ†’ LLM ์— ์•ˆ ๋ณด์—ฌ # ์žฌ์ž‘์„ฑ ๋ถˆ๊ฐ€, ๋ณ‘ํ•ฉ ํ›„ verbatim ์žฌ๋ถ€์ฐฉ(append-only โ€” ์ƒˆ ์‚ฌ์‹ค๋งŒ ๋ณดํƒœ๊ณ  ๊ธฐ์กด ์ค„์€ ์ ˆ๋Œ€ ์†์‹ค X). def _norm_line(line: str) -> str: return _strip_bullet(line).strip() def split_active_inactive(semantic: str, active_buckets) -> "tuple[str, dict[str, str]]": """(editable ์˜๋ฏธ๊ธฐ์–ต, held_out ํŒจ์‹ฏ dict). editable = FACET_ALWAYS + ํ™œ์„ฑ ๊ฒŒ์ดํŠธ ๋ฒ„ํ‚ท + ๋ฏธ์ •์˜(์˜›) ํ—ค๋”(graceful ์žฌ๋ฐฐ์น˜ ๋Œ€์ƒ). held_out = ๊ฒŒ์ดํŠธ ๋ฒ„ํ‚ท ์ค‘ ๋น„ํ™œ์„ฑ = LLM ์— ์•ˆ ๋ณด๋ƒ„(๋“œ๋ฆฌํ”„ํŠธ ์ฐจ๋‹จ), ๋ณ‘ํ•ฉ ํ›„ ์žฌ๋ถ€์ฐฉ. """ want = set(FACET_ALWAYS) | set(active_buckets or ()) order: list[str] = [] editable: dict[str, str] = {} held: dict[str, str] = {} for header, body in split_facets(semantic).items(): if header in _ALL_GATED and header not in want: held[header] = body else: editable[header] = body order.append(header) editable_text = "\n".join( (f"[{h}]\n{editable[h]}" if h else editable[h]) for h in order if editable.get(h, "").strip() ).strip() return editable_text, held def recombine_scoped(merged_semantic: str, held_out: "dict[str, str]") -> str: """๋ณ‘ํ•ฉ๋œ ํ™œ์„ฑ ์˜๋ฏธ๊ธฐ์–ต + held_out(๋น„ํ™œ์„ฑ, verbatim) ์žฌ๊ฒฐํ•ฉ. **append-only**. held_out ๋ผ์ธ์€ ์ ˆ๋Œ€ ์žƒ์ง€ ์•Š๋Š”๋‹ค. merged ๊ฐ€ held_out ํ—ค๋”์— ์ƒˆ ๋ผ์ธ์„ emit ํ•œ ๊ฒฝ์šฐ (LLM ์ด ๋น„ํ™œ์„ฑ ๋ฒ„ํ‚ท ์‚ฌ์‹ค์„ ์ถ”๋ก ) ์ค‘๋ณต ์•„๋‹Œ ์ค„๋งŒ ๋ณดํƒ ๋‹ค. ํ—ค๋” ๋ณ„์นญ ์ •๊ทœํ™” ์ ์šฉ. """ order: list[str] = [] out: dict[str, str] = {} def _add(header: str, body: str) -> None: h = _canonical_facet_header(header) body = (body or "").strip() if not body: return if h in out: seen = {_norm_line(ln) for ln in out[h].splitlines() if ln.strip()} extra = [ln for ln in body.splitlines() if ln.strip() and _norm_line(ln) not in seen] if extra: out[h] = (out[h] + "\n" + "\n".join(extra)).strip() else: out[h] = body order.append(h) for header, body in split_facets(merged_semantic).items(): _add(header, body) for header, body in (held_out or {}).items(): _add(header, body) return "\n".join((f"[{h}]\n{out[h]}" if h else out[h]) for h in order).strip() # --- ์บ๋ฆญํ„ฐ๋ณ„ AI ๊ด€๊ณ„ ์„น์…˜ ๋ถ„๋ฆฌ/์ •๋ฆฌ (req_character_relation_memory ยงP2) -------- def split_ai_sections(semantic: str, active_ai_key: str = "") -> "tuple[str, dict[str, str]]": """(ํ™œ์„ฑ AI ์„น์…˜๋งŒ ๋‚จ๊ธด ์˜๋ฏธ๊ธฐ์–ต, held_out ๋น„ํ™œ์„ฑ AI ์„น์…˜ dict). split_active_inactive ์™€ ๋™์ผ ํŒจํ„ด โ€” ๋น„ํ™œ์„ฑ AI ๊ด€๊ณ„ ์„น์…˜([AI ์ •๋ณด] ํฌํ•จ, ํ™œ์„ฑ ํ—ค๋” ์ œ์™ธ ์ „๋ถ€)์€ ๋ณ‘ํ•ฉ LLM ์— ์•ˆ ๋ณด์—ฌ์ฃผ๊ณ (๋“œ๋ฆฌํ”„ํŠธ ์ฐจ๋‹จ) ๋ณ‘ํ•ฉ ํ›„ verbatim ์žฌ๋ถ€์ฐฉํ•œ๋‹ค. """ active_h = ai_header_for_key(active_ai_key) order: list[str] = [] editable: dict[str, str] = {} held: dict[str, str] = {} for header, body in split_facets(semantic).items(): if is_ai_header(header) and header != active_h: held[header] = body else: editable[header] = body order.append(header) text = "\n".join( (f"[{h}]\n{editable[h]}" if h else editable[h]) for h in order if editable.get(h, "").strip() ).strip() return text, held def remove_facet_sections(semantic: str, headers) -> str: """์ฃผ์–ด์ง„ ํ—ค๋” ์„น์…˜์„ ๋ธ”๋กญ์—์„œ ์ œ๊ฑฐ โ€” ์นด๋“œ ์‚ญ์ œ ์‹œ [AI:id] ๊ด€๊ณ„ ๊ธฐ์–ต ์‚ญ์ œ์šฉ.""" drop = {(h or "").strip() for h in (headers or ())} order: list[str] = [] out: dict[str, str] = {} for header, body in split_facets(semantic).items(): if header in drop: continue if body.strip(): out[header] = body order.append(header) return "\n".join((f"[{h}]\n{out[h]}" if h else out[h]) for h in order).strip() def strip_keyed_ai_sections(semantic: str) -> str: """[AI:*] ์บ๋ฆญํ„ฐ ๊ด€๊ณ„ ์„น์…˜๋งŒ ์ œ๊ฑฐ โ€” ๋ผ๋””์˜ค DJ ๋“ฑ ๋น„๋Œ€ํ™” ๊ฒฝ๋กœ ๊ต์ฐจ ์˜ค์—ผ ์ฐจ๋‹จ. ๊ธฐ์กด [AI ์ •๋ณด] ๋Š” ์œ ์ง€(ํ˜„ํ–‰ ๋™์ž‘ ๋ถˆ๋ณ€). ์บ๋ฆญํ„ฐ๋ณ„ ๊ด€๊ณ„ ๊ธฐ์–ต์€ ์ž์œ ๋Œ€ํ™”ยท์šด์ „ persona ๊ฒฝ๋กœ ์ „์šฉ(โ›” _dj_base_persona ๊ต์ฐจ ์˜ค์—ผ ๊ธˆ์ง€, req ยง5). """ order: list[str] = [] out: dict[str, str] = {} for header, body in split_facets(semantic).items(): if header.startswith(AI_KEYED_PREFIX): continue if body.strip(): out[header] = body order.append(header) return "\n".join((f"[{h}]\n{out[h]}" if h else out[h]) for h in order).strip() # --- ์••๋ ฅ ๊ธฐ๋ฐ˜ ํšŒ๊ณ  (req_character_relation_memory ยงP3-4) ---------------------- # ํŠธ๋ฆฌ๊ฑฐ = "์ด๋ฒˆ ๋ณ‘ํ•ฉ์œผ๋กœ ๊ธธ์ด๊ฐ€ ์‹ค์ œ ์ฆ๊ฐ€ && ์บก์˜ 80% ์ดˆ๊ณผ"์ธ ํŒจ์‹ฏ๋งŒ(์ž…๋ ฅ ์••๋ ฅ ๊ธฐ๋ฐ˜, # ์‹œ๊ฐ„ ํฌ๋ก  ์—†์Œ). ์ฆ๊ฐ€ ์—†์œผ๋ฉด ์Šคํ‚ต โ€” ์••์ถ• ๋ถˆ๊ฐ€๋Šฅํ•œ ํŒจ์‹ฏ์˜ ๋งค๋ณ‘ํ•ฉ ์žฌ๋ฐœ๋™ ๋ฐฉ์ง€. # ํšŒ๊ณ ๋Š” ์–ธ์ œ๋‚˜ "์—†์–ด๋„ ๋˜๋Š” ์ตœ์ ํ™”" โ€” ๊ฒ€์ฆ ์‹คํŒจ = ๊ฒฐ๊ณผ ํ๊ธฐยท์›๋ณธ ์œ ์ง€(๊ธฐ์–ต ์†์‹ค ๊ธˆ์ง€). REFLECT_TRIGGER_RATIO = 0.8 def facets_needing_reflection(old_semantic: str, new_semantic: str) -> "list[str]": """ํšŒ๊ณ  ๋Œ€์ƒ ํŒจ์‹ฏ ํ—ค๋” โ€” ๋ณ‘ํ•ฉ ์ „๋ณด๋‹ค ๊ธธ์ด๊ฐ€ ์‹ค์ œ ์ฆ๊ฐ€ํ–ˆ๊ณ  ์บก 80% ๋ฅผ ๋„˜์€ ๊ฒƒ๋งŒ.""" old = split_facets(old_semantic or "") out: list[str] = [] for header, body in split_facets(new_semantic or "").items(): if not header or not body.strip(): continue old_len = len((old.get(header) or "").strip()) new_len = len(body.strip()) if new_len > old_len and new_len > facet_budget(header) * REFLECT_TRIGGER_RATIO: out.append(header) return out def facets_over_budget(semantic: str, ratio: float = REFLECT_TRIGGER_RATIO) -> "list[str]": """์บก ๋Œ€๋น„ ratio ์ดˆ๊ณผ ํŒจ์‹ฏ(์„ฑ์žฅ ์กฐ๊ฑด ์—†์Œ) โ€” ์‹œ๊ฐ„ ๊ธฐ๋ฐ˜ ํšŒ๊ณ  ํฌ๋ก ์šฉ. 2026-07-10 ์‚ฌ์šฉ์ž ํ™•์ •์œผ๋กœ ์› ์„ค๊ณ„(ยง2 '์••๋ ฅ ๊ธฐ๋ฐ˜๋งŒ')์— ๋งค์ผ 1ํšŒ ํฌ๋ก ์„ ๋ณด์™„: ๋ณ‘ํ•ฉ์œผ๋กœ๋Š” ๋” ์•ˆ ์ž๋ผ(์••๋ ฅ ํŠธ๋ฆฌ๊ฑฐ๊ฐ€ ์•ˆ ๊ฑธ๋ฆฌ๋Š”) ๊ณผ๋ฐ€ ํŒจ์‹ฏ๋„ ํ•˜๋ฃจ 1ํšŒ ํšŒ๊ณ  ๋Œ€์ƒ. ๊ฒ€์ฆยทํ๊ธฐ ์•ˆ์ „์žฅ์น˜(validate_reflection)๋Š” ๋™์ผํ•˜๊ฒŒ ์ ์šฉ๋œ๋‹ค. """ out: list[str] = [] for header, body in split_facets(semantic or "").items(): b = (body or "").strip() if header and b and len(b) > facet_budget(header) * ratio: out.append(header) return out def validate_reflection(header: str, old_body: str, reflected_text: str) -> "tuple[bool, str]": """ํšŒ๊ณ  LLM ์ถœ๋ ฅ ๊ฒ€์ฆ โ†’ (ํ†ต๊ณผ ์—ฌ๋ถ€, ์ •์ œ๋œ ์ƒˆ ๋ณธ๋ฌธ). ์ฝ”๋“œ ๋ ˆ๋ฒจ ๊ฒ€์‚ฌ(ยงP3-4): โ‘  ํ—ค๋” ๋ณด์กด(๊ทธ ํ—ค๋” ํ•˜๋‚˜๋งŒ) โ‘ก ๊ธฐ์กด ์ด์ •ํ‘œ ์ค„ ์ „๋ถ€ ์›๋ฌธ ํฌํ•จ โ‘ข ์••์ถ•๋งŒ ํ—ˆ์šฉ(๋ณธ๋ฌธ ๊ธธ์ด ์ฆ๊ฐ€ ๊ธˆ์ง€). ํ•˜๋‚˜๋ผ๋„ ์‹คํŒจ = (False, "") โ€” ํ˜ธ์ถœ์ธก์ด ์›๋ณธ ์œ ์ง€. """ t = (reflected_text or "").strip() if not t: return False, "" facets = split_facets(t) named = [h for h in facets if h] if named != [header]: return False, "" # ํ—ค๋” ์†Œ์‹ค/๋ณ€๊ฒฝ/๋‹ค๋ฅธ ํ—ค๋” ๋ฐœ๋ช… if (facets.get("") or "").strip(): return False, "" # ํ—ค๋” ๋ฐ– ์„ ๋‘ ๋ณธ๋ฌธ = ํ˜•์‹ ์œ„๋ฐ˜ new_body = (facets.get(header) or "").strip() if not new_body: return False, "" old_body = (old_body or "").strip() if len(new_body) > len(old_body): return False, "" # ์••์ถ•์ด ์•„๋‹ˆ๋ผ ์ฆ์‹ โ€” ์ƒˆ ์‚ฌ์‹ค ์ƒ์„ฑ ์˜์‹ฌ old_ms = milestone_lines(old_body) if old_ms: new_lines = {ln.strip() for ln in new_body.splitlines() if ln.strip()} if any(m not in new_lines for m in old_ms): return False, "" # ์ด์ •ํ‘œ ์›๋ฌธ ๋ˆ„๋ฝ = ํ๊ธฐ return True, new_body def replace_facet_body(semantic: str, header: str, new_body: str) -> str: """ํ•œ ํŒจ์‹ฏ์˜ ๋ณธ๋ฌธ๋งŒ ๊ต์ฒด(ํ—ค๋” ์ˆœ์„œ ๋ณด์กด) โ€” ํšŒ๊ณ  ๊ฒฐ๊ณผ ๋ฐ˜์˜์šฉ.""" order: list[str] = [] out: dict[str, str] = {} for h, body in split_facets(semantic).items(): b = new_body if h == header else body if b.strip(): out[h] = b.strip() order.append(h) return "\n".join((f"[{h}]\n{out[h]}" if h else out[h]) for h in order).strip()
"""์‚ฌ์šฉ์ž ๊ธฐ์–ต ํฌ๋งท ์œ ํ‹ธ โ€” ์˜๋ฏธ๊ธฐ์–ต(ํŒจ์‹ฏ) / ์ผํ™”๊ธฐ์–ต(๋‚ ์งœ ๋ผ์ธ) ์ˆœ์ˆ˜ ํ•จ์ˆ˜.
I/O ์—†์Œ(ํ…Œ์ŠคํŠธ ์šฉ์ด). chat.py ์˜ ๋ณ‘ํ•ฉ-์ €์žฅ ๊ฒฝ๋กœ์™€ ์ฃผ์ž… ๊ฒฝ๋กœ, ๊ทธ๋ฆฌ๊ณ  Phase 2
์ปจํ…์ŠคํŠธ ๋ผ์šฐํ„ฐ๊ฐ€ ๊ณต์œ ํ•œ๋‹ค.
์„ค๊ณ„(req_user_memory_redesign ยง1ยทยง3 + req_memory_situational_layers ยง4ยทยง5):
- ์˜๋ฏธ๊ธฐ์–ต = timeless ์‚ฌ์‹ค. ํŒจ์‹ฏ ํ—ค๋”([๊ธฐ๋ณธ]/[์ทจํ–ฅยท์Šต๊ด€]/[์ด์ƒํ˜•]/[์„ฑ์ ยทํŒจํ‹ฐ์‰ฌ]/[AI ์ •๋ณด]).
- ์ผํ™”๊ธฐ์–ต = ์‹œ์  ์žˆ๋Š” ์‚ฌ๊ฑด/์ƒํƒœ. ์‹  ํฌ๋งท "[MM-DD HH:MM] ์‚ฌ๊ฑด" ํ•œ ์ค„์”ฉ(๊ตฌ "[YYYY-MM-DD]"
๋„ graceful ํŒŒ์‹ฑ), ์ˆœ๊ฐ„์ƒํƒœ๋Š” ๋์— "(์ƒํƒœ)".
* ์‹œ์ ์€ ์„œ๋ฒ„๊ฐ€ KST ๋กœ ์Šคํƒฌํ”„(LLM ์€ '์ง€๊ธˆ'์„ ๋ชจ๋ฆ„) โ€” ์‹  ํฌ๋งท์€ ๋ถ„ ๋‹จ์œ„๊นŒ์ง€.
* ์ €์žฅ: โ‰ค60์ผ / โ‰ค30์ค„ (์˜ค๋ž˜๋œ ๊ฒƒ๋ถ€ํ„ฐ ๋ฒ„๋ฆผ).
* ์ฃผ์ž…: ์ผ๋ฐ˜ โ‰ค13์ผ / ์ˆœ๊ฐ„์ƒํƒœ โ‰ค1์ผ ๋งŒ, ์ ˆ๋Œ€์‹œ์  โ†’ ์ƒ๋Œ€์‹œ๊ฐ„("๋ฐฉ๊ธˆ/3์‹œ๊ฐ„ ์ „/์–ด์ œ/์ง€๋‚œ์ฃผ") ๋ Œ๋”.
- ์ €์žฅ ์ง์ „ ํ›„์ฒ˜๋ฆฌ ์ •๊ทœํ™”(ยง5, LLM ์ค€์ˆ˜์œจ ๋น„์˜์กด, ๊ฒฐ์ •์ ): ๋นˆ์นธยทํ”Œ๋ ˆ์ด์Šคํ™€๋” ์ œ๊ฑฐ ยท
ํ—ค๋”์—†๋Š” ์„ ๋‘ ์‚ฌ์‹ค [๊ธฐ๋ณธ] ๋ผ์šฐํŒ… ยท ์˜๋ฏธโ†”์ผํ™” ๋ˆ„์ˆ˜(์ˆœ๊ฐ„์ƒํƒœ/์ผํšŒ์„ฑ ์‚ฌ๊ฑด) ์ผํ™”๋กœ relocate.
"""
from __future__ import annotations
import re
from datetime import date, datetime, timezone
# --- ์ผํ™”๊ธฐ์–ต -----------------------------------------------------------------
EPISODE_MERGE_MARKER = "===์ผํ™”===" # ๋ณ‘ํ•ฉ ์ถœ๋ ฅ ์˜๋ฏธ๊ธฐ์–ต โ†” ์ผํ™” ๊ตฌ๋ถ„
DIRECTIVE_MERGE_MARKER = "===์ง€์นจ===" # ๋ณ‘ํ•ฉ ์ถœ๋ ฅ ์ผํ™” โ†” ์ ˆ์ฐจ์  ์ง€์นจ(proposed) ๊ตฌ๋ถ„
_MOMENTARY_TAG = "(์ƒํƒœ)" # ์ˆœ๊ฐ„์ƒํƒœ(๊ฐ๊ธฐยทํ”ผ๊ณค ๋“ฑ) ํƒœ๊ทธ โ€” ์งง์€ ๋””์ผ€์ด
# ์‹  ํฌ๋งท `[MM-DD HH:MM @๋™๋„ค]` + ๊ตฌ ํฌ๋งท `[YYYY-MM-DD]`/`[YYYY-MM-DD HH:MM]`(์žฅ์†Œ ์—†์Œ)
# ๋ชจ๋‘ ํŒŒ์‹ฑ(graceful). YYYY ์ƒ๋žต(์‹  ํฌ๋งท)์€ ๊ธฐ์ค€์—ฐ๋„๋กœ ์ถ”๋ก , ๊ฒฐ๊ณผ๊ฐ€ ๋ฏธ๋ž˜๋ฉด ์—ฐ๋งโ†”์—ฐ์ดˆ ๊ฒฝ๊ณ„์—์„œ
# ์ „๋…„์œผ๋กœ ๋กค์˜ค๋ฒ„. `@๋™๋„ค` ๋Š” ๊ทธ ์‚ฌ๊ฑด ๋‹น์‹œ ์‚ฌ์šฉ์ž๊ฐ€ ์žˆ๋˜ ๋™(์„ ํƒ) โ€” ์„œ๋ฒ„๊ฐ€ ์Šคํƒฌํ”„(LLM ๋ฏธ๊ธฐ์žฌ).
_DATED_RE = re.compile(
r"^\[(?:(\d{4})-)?(\d{1,2})-(\d{1,2})(?:[ T](\d{1,2}):(\d{2}))?"
r"(?:\s*@\s*([^\]]+?))?\s*\]\s*(.*)$"
)
EPISODE_STORE_MAX_LINES = 30
EPISODE_STORE_MAX_AGE_DAYS = 60
EPISODE_INJECT_GENERAL_DAYS = 13 # ์ผ๋ฐ˜ ์ผํ™”: ์ด ์ผ์ˆ˜ ์ดˆ๊ณผ ์‹œ ์ฃผ์ž… ์ œ์™ธ(=14์ผ ๋””์ผ€์ด)
EPISODE_INJECT_MOMENTARY_DAYS = 1 # ์ˆœ๊ฐ„์ƒํƒœ: ์ด ์ผ์ˆ˜ ์ดˆ๊ณผ ์‹œ ์ฃผ์ž… ์ œ์™ธ(=1~2์ผ ๋””์ผ€์ด)
EPISODE_HOUR_JOIN = " ยท " # ๊ฐ™์€ 1์‹œ๊ฐ„ ๋ธ”๋ก ๋ผ์ธ ๊ฒฐํ•ฉ ๊ตฌ๋ถ„์ž(์„ธ๊ทธ๋จผํŠธ dedupe ํ‚ค)
def _coerce_now(ref: "date | datetime") -> "tuple[datetime, bool]":
"""ref(date|datetime) โ†’ (๊ธฐ์ค€ datetime, ์‹œ๊ฐ์ •๋ณด ๋ณด์œ  ์—ฌ๋ถ€).
date ๋งŒ ์ฃผ๋ฉด ์‹œ๊ฐ ์—†์Œ(=๋‚ ์งœ ๋‹จ์œ„). datetime ์ด๋ฉด ๋ถ„ ๋‹จ์œ„๊นŒ์ง€ ์‚ฌ์šฉ. ํ˜ธ์ถœ์ธก์ด KST ์˜
'์ง€๊ธˆ'(๊ถŒ์žฅ: datetime) ๋˜๋Š” '์˜ค๋Š˜'(date, ํ•˜์œ„ํ˜ธํ™˜)์„ ๋„˜๊ธด๋‹ค.
"""
if isinstance(ref, datetime):
return ref, True
return datetime(ref.year, ref.month, ref.day), False
def _parse_episode_line(
line: str, now: "datetime | None" = None
) -> "tuple[datetime | None, bool, str, str]":
"""'[ํƒ€์ž„์Šคํƒฌํ”„ @๋™๋„ค] ๋ณธ๋ฌธ' โ†’ (datetime, ์‹œ๊ฐ๋ณด์œ , ๋™๋„ค, ๋ณธ๋ฌธ). ์‹œ์  ์—†์œผ๋ฉด (None, False, "", ์›๋ฌธ).
- ๊ตฌ `[YYYY-MM-DD]`/์‹  `[MM-DD HH:MM]`/์žฅ์†Œํฌํ•จ `[MM-DD HH:MM @๊ณ ๊ธฐ๋™]` ๋ชจ๋‘ ํ—ˆ์šฉ.
- `@๋™๋„ค` ๋Š” ์„ ํƒ โ€” ์—†์œผ๋ฉด "". ๊ทธ ์‚ฌ๊ฑด ๋‹น์‹œ ์‚ฌ์šฉ์ž๊ฐ€ ์žˆ๋˜ ๋™(์„œ๋ฒ„ ์Šคํƒฌํ”„).
- YYYY ์ƒ๋žต ์‹œ now ์—ฐ๋„๋กœ ์ถ”๋ก , ์ถ”๋ก ๊ฐ’์ด now ๋ณด๋‹ค ๋ฏธ๋ž˜๋ฉด 60์ผ ์œˆ๋„์šฐ ์•ˆ์ผ ๋•Œ๋งŒ ์ „๋…„ ๋กค์˜ค๋ฒ„.
- now ์˜ tzinfo ๋ฅผ ๊ฒฐ๊ณผ datetime ์— ๋ถ€์—ฌ(naive/aware ํ˜ผํ•ฉ ๋น„๊ต ๋ฐฉ์ง€). now ๋ฏธ์ „๋‹ฌ ์‹œ ํŒŒ์‹ฑ๋งŒ.
"""
s = line.strip()
if not s:
return None, False, "", ""
m = _DATED_RE.match(s)
if not m:
return None, False, "", s
year, mon, day, hh, mm, place, body = m.groups()
has_time = hh is not None
place = (place or "").strip()
tz = now.tzinfo if now is not None else None
base_year = int(year) if year is not None else (now.year if now is not None else date.today().year)
try:
dt = datetime(
base_year, int(mon), int(day),
int(hh) if has_time else 0, int(mm) if has_time else 0,
tzinfo=tz,
)
except ValueError:
return None, False, "", s
if now is not None and year is None and dt > now:
# ์—ฐ๋„ ์ถ”๋ก  ๊ฒฐ๊ณผ๊ฐ€ ๋ฏธ๋ž˜ โ†’ ์ตœ๊ทผ ์œˆ๋„์šฐ ์•ˆ์ด๋ฉด ์ „๋…„(์—ฐ๋งโ†”์—ฐ์ดˆ ๊ฒฝ๊ณ„), ์•„๋‹ˆ๋ฉด ๊ทธ๋Œ€๋กœ
# ๋‘๊ณ  ํด๋žจํ”„๋Š” ํ˜ธ์ถœ์ธก(stamp_and_trim)์— ๋งก๊ธด๋‹ค.
prev = dt.replace(year=dt.year - 1)
if (now - prev).days <= EPISODE_STORE_MAX_AGE_DAYS + 1:
dt = prev
return dt, has_time, place, (body or "").strip()
def _fmt_stamp(dt: datetime, has_time: bool, place: str = "") -> str:
"""์ €์žฅ ํฌ๋งท โ€” ์‹  `[MM-DD HH:MM @๋™๋„ค]`. ์‹œ๊ฐ ์—†์œผ๋ฉด `[MM-DD @๋™๋„ค]`, ์žฅ์†Œ ์—†์œผ๋ฉด `@` ์ƒ๋žต."""
place = (place or "").strip()
p = f" @{place}" if place else ""
if has_time:
return f"[{dt.month:02d}-{dt.day:02d} {dt.hour:02d}:{dt.minute:02d}{p}]"
return f"[{dt.month:02d}-{dt.day:02d}{p}]"
# ์„ฑ์  ๋Œ€ํ™” ์ผํ™” ์ฐจ๋‹จ (2026-07-14 ์‚ฌ์šฉ์ž ์ง€์‹œ) โ€” ์„ฑ์  ๋‚ด์šฉ์€ ์˜๋ฏธ๊ธฐ์–ต [์„ฑ์ ] ํŒจ์‹ฏ์œผ๋กœ๋งŒ,
# ์ผํ™”([์ตœ๊ทผ ๋Œ€ํ™”] ํžˆ์Šคํ† ๋ฆฌ)์—๋Š” ๋‚จ๊ธฐ์ง€ ์•Š๋Š”๋‹ค. ๋ณ‘ํ•ฉ LLM ํ”„๋กฌํ”„ํŠธ ์ง€์‹œ์˜ ๊ฒฐ์ •์  ๋ฐฑ์Šคํ†ฑ.