Spaces:
Sleeping
Sleeping
Download backend/services/memory_format.py from stylevoice/talk: direct link, hf CLI and curl.
- Browser
- Download file 56.8 kB
-
https://huggingface.co/spaces/stylevoice/talk/resolve/main/backend/services/memory_format.py
- Command line
-
hf download hf://spaces/stylevoice/talk/backend/services/memory_format.py
-
curl -L -o memory_format.py https://huggingface.co/spaces/stylevoice/talk/resolve/main/backend/services/memory_format.py
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 ํ๋กฌํํธ ์ง์์ ๊ฒฐ์ ์ ๋ฐฑ์คํฑ.