← 태버지 목록
extract_telegram_events.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
#!/usr/bin/env python3
"""
telegram-live 메시지에서 '월별 주요 이벤트'를 자동 추출 → events.json(KST) 병합.

소스 : ~/sector-agents/agents/*/raw/telegram-live/{YYYY-MM-DD}-{channel}.md  (모든 섹터 채널)
출력 : ~/룬샤시_공시정리/태버지/events.json  ({"events":[{id,date,title,category,note}]})
LLM  : Codex OAuth gpt-5.6-terra (구독, EVENTS_LLM_MODEL로 오버라이드) — 이벤트 추출 + KST 변환
TZ   : 미국 ET(서머타임 자동) → KST. 코드에서 zoneinfo로 재검증.

usage:
  python extract_telegram_events.py                 # 최근 3일 .md → 병합
  python extract_telegram_events.py --days-back 7    # 최근 7일
  python extract_telegram_events.py --since 2026-05-14 --until 2026-05-20
  python extract_telegram_events.py --dry-run        # 저장 안 하고 추출만 출력
"""
from __future__ import annotations
import argparse, glob, hashlib, json, os, re, sys
from datetime import datetime, timedelta, date
from pathlib import Path
from urllib.parse import urlencode
from urllib.request import urlopen
from zoneinfo import ZoneInfo

ET = ZoneInfo("America/New_York")
KST = ZoneInfo("Asia/Seoul")
HOME = Path(os.path.expanduser("~"))
EVENTS_FILE = HOME / "룬샤시_공시정리" / "태버지" / "events.json"
TG_GLOB = str(HOME / "sector-agents" / "agents" / "*" / "raw" / "telegram-live" / "*.md")
MODEL = os.environ.get("EVENTS_LLM_MODEL", "gpt-5.6-terra")
DATE_RE = re.compile(r"(\d{4}-\d{2}-\d{2})")
SHARED_API = os.environ.get(
    "BLUEHOLE_TELEGRAM_API",
    "http://127.0.0.1:5077/api/telegram/search",
)

CATEGORIES = ["earnings", "macro", "conference", "product", "index", "policy", "etc"]

SYSTEM_PROMPT = """\
너는 한국 투자자용 '주요 이벤트 캘린더'를 만드는 추출기다.
입력은 여러 텔레그램 채널의 그날 메시지 모음(한국어, 해외/국내 주식 일정 포함)이다.
여기서 '미래에 일어날 구체적 일정성 이벤트'만 추출한다.

추출 대상(category):
- earnings : 기업 실적발표/컨콜
- macro    : 거시지표 발표 (FOMC, 고용보고서, CPI, GDP, 금리결정 등)
- conference: 투자자/산업 컨퍼런스, IR, 기술 발표회 (예: 구글 I/O, GTC)
- product  : 신제품 출시/공개
- index    : 지수 편입/편출, 리밸런싱
- policy   : 정책/규제/입법 일정
- etc      : 그 외 명확한 일정

제외: 단순 시황코멘트, 의견, 과거에 이미 일어난 일, 날짜 없는 막연한 언급.

⏰ 시간대 규칙 — 모든 date/시각은 반드시 한국시간(KST, UTC+9) 기준으로 변환한다:
- 미국 증시(NYSE/NASDAQ) 기준 ET는 서머타임에 따라 KST와 +13h(EDT, 3~11월) 또는 +14h(EST, 11~3월) 차이.
- 미국 실적 '장 마감 후(AMC, ~16:00 ET)' → KST 다음날 새벽(EDT 05:00 / EST 06:00). date는 KST 날짜(보통 +1일).
- 미국 실적 '장 시작 전(BMO, ~07:00 ET)' → KST 당일 저녁(EDT 20:00 / EST 21:00).
- 미국 매크로: FOMC 결정 14:00 ET→KST 익일 03:00(EDT)/04:00(EST); 고용 08:30 ET→KST 21:30/22:30; CPI 08:30 ET 동일.
- 한국 일정은 그대로 KST.
- 컨퍼런스/올데이(시각 불명) 이벤트는 현지 날짜를 KST 날짜로 쓰되, 미국 현지면 note에 '(미 현지 M/D)' 병기.
- 채널이 이미 한국시간 주간일정으로 정리한 경우, 그 날짜가 'KST 날짜'인지 'US 현지 날짜'인지 맥락(요일 등)으로 판단해 KST로 통일.

출력은 JSON: {"events":[{
  "date": "YYYY-MM-DD",        # KST 기준 날짜
  "title": "엔비디아 실적",      # 간결한 한국어 제목 (회사/지표명 + 유형)
  "category": "earnings",
  "ticker": "NVDA",            # 있으면, 없으면 ""
  "kst_time": "05/21 06:00",   # KST 시각 (알면), 모르면 ""
  "source_market": "US",       # US | KR | 기타
  "note": "엔비디아(NVDA) FY 실적, 장마감후 발표 | KST 05/21 06:00",
  "confidence": 0.0~1.0
}]}
확실한 것만(confidence>=0.6) 넣어라. 같은 이벤트 중복 금지. 설명/주석 없이 JSON만 출력."""


def gather_md(since: date, until: date) -> list[tuple[str, str]]:
    """기간 내 telegram-live .md 파일 (파일명 날짜 기준)."""
    out = []
    for p in sorted(glob.glob(TG_GLOB)):
        m = DATE_RE.search(os.path.basename(p))
        if not m:
            continue
        try:
            d = datetime.strptime(m.group(1), "%Y-%m-%d").date()
        except ValueError:
            continue
        if since <= d <= until:
            try:
                txt = Path(p).read_text(encoding="utf-8", errors="replace")
            except Exception:
                continue
            if txt.strip():
                out.append((os.path.basename(p), txt))
    return out


def gather_shared(since: date, until: date) -> list[tuple[str, str]]:
    """Read all live account dialogs, grouped by channel for the LLM."""
    since_dt = datetime.combine(since, datetime.min.time(), tzinfo=KST)
    until_dt = datetime.combine(until + timedelta(days=1), datetime.min.time(), tzinfo=KST)
    hours = max(24, int((datetime.now(KST) - since_dt).total_seconds() // 3600) + 24)
    params = urlencode([
        ("q", ""), ("hours", str(hours)), ("limit", "10000"),
        ("source", "telegram_live"),
    ])
    with urlopen(f"{SHARED_API}?{params}", timeout=20) as response:
        payload = json.loads(response.read().decode("utf-8"))
    if not payload.get("success"):
        raise RuntimeError(payload.get("error") or "shared Telegram API failed")
    grouped: dict[str, list[str]] = {}
    for row in reversed(payload.get("messages") or []):
        if row.get("source") != "telegram_live":
            continue
        try:
            timestamp = datetime.fromisoformat(
                str(row.get("message_date") or "").replace("Z", "+00:00")
            ).astimezone(KST)
        except ValueError:
            continue
        if not since_dt <= timestamp < until_dt:
            continue
        text = str(row.get("text") or "").strip()
        if not text:
            continue
        channel = str(row.get("chat_title") or row.get("chat_username") or row.get("chat_key") or "Telegram")
        grouped.setdefault(channel, []).append(
            f"### {timestamp.strftime('%Y-%m-%d %H:%M')}\n\n{text}\n\n---"
        )
    return [(f"shared:{channel}", "\n\n".join(rows)) for channel, rows in sorted(grouped.items())]


EVENTS_SCHEMA = {
    "type": "object",
    "additionalProperties": False,
    "properties": {
        "events": {
            "type": "array",
            "items": {
                "type": "object",
                "additionalProperties": False,
                "properties": {
                    "date": {"type": "string", "description": "KST 기준 YYYY-MM-DD"},
                    "title": {"type": "string"},
                    "category": {"type": "string"},
                    "ticker": {"type": "string"},
                    "kst_time": {"type": "string"},
                    "source_market": {"type": "string"},
                    "note": {"type": "string"},
                    "confidence": {"type": "number"},
                },
                "required": ["date", "title", "category", "ticker", "kst_time",
                             "source_market", "note", "confidence"],
            },
        }
    },
    "required": ["events"],
}


def _codex_client():
    sa = str(HOME / "sector-agents")
    if sa not in sys.path:
        sys.path.insert(0, sa)
    from pipeline.codex_oauth_responses import CodexOAuthResponsesClient
    helper = os.environ.get("CODEX_OAUTH_HELPER_PATH") or str(
        HOME / "openai-codex-oauth-helper" / "codex_oauth.py")
    client = CodexOAuthResponsesClient(helper)
    if not client.available():
        raise RuntimeError("codex OAuth 자격증명 없음 — codex_oauth.py login 필요")
    return client


def call_llm(blocks: list[tuple[str, str]]) -> list[dict]:
    client = _codex_client()
    # 채널 메시지를 청크로 (너무 크면 분할)
    chunks, cur, size = [], [], 0
    for name, txt in blocks:
        piece = f"\n\n===== {name} =====\n{txt[:8000]}"
        if size + len(piece) > 60000 and cur:
            chunks.append("".join(cur)); cur, size = [], 0
        cur.append(piece); size += len(piece)
    if cur:
        chunks.append("".join(cur))

    today_kst = datetime.now(KST).strftime("%Y-%m-%d")
    events: list[dict] = []
    for i, chunk in enumerate(chunks):
        user = (f"오늘(KST): {today_kst}\n아래는 텔레그램 채널들의 메시지다. "
                f"미래 일정성 이벤트만 KST 기준으로 추출하라:\n{chunk}")
        try:
            payload = client.create_structured_response(
                model=MODEL,
                system_prompt=SYSTEM_PROMPT,
                user_prompt=user,
                schema_name="telegram_events",
                schema=EVENTS_SCHEMA,
            )
            evs = _extract_events(payload)
            print(f"  LLM chunk {i+1}/{len(chunks)}: {len(evs)}건", flush=True)
            events.extend(evs)
        except Exception as e:
            print(f"  LLM chunk {i+1} 실패: {type(e).__name__}: {e}", flush=True)
    return events


def _extract_events(payload) -> list[dict]:
    """codex 응답 payload에서 events 리스트 추출 (output_text JSON 또는 dict)."""
    if isinstance(payload, dict) and isinstance(payload.get("events"), list):
        return payload["events"]
    # output_text 형태
    text = ""
    if isinstance(payload, dict):
        text = payload.get("output_text") or ""
        if not text:
            for item in payload.get("output", []) or []:
                for c in (item.get("content") or []):
                    if isinstance(c, dict) and c.get("text"):
                        text += c["text"]
    elif isinstance(payload, str):
        text = payload
    text = text.strip()
    if not text:
        return []
    try:
        d = json.loads(text)
        return d.get("events", []) if isinstance(d, dict) else (d if isinstance(d, list) else [])
    except Exception:
        return []


def normalize(ev: dict) -> dict | None:
    date_str = str(ev.get("date", "")).strip()
    try:
        datetime.strptime(date_str, "%Y-%m-%d")
    except ValueError:
        return None
    title = str(ev.get("title", "")).strip()
    if not title:
        return None
    cat = str(ev.get("category", "etc")).strip().lower()
    if cat not in CATEGORIES:
        cat = "etc"
    note = str(ev.get("note", "")).strip()
    kst_time = str(ev.get("kst_time", "")).strip()
    if kst_time and "KST" not in note:
        note = (note + f" | KST {kst_time}").strip(" |")
    if float(ev.get("confidence", 1) or 0) < 0.6:
        return None
    return {"date": date_str, "title": title, "category": cat, "note": note,
            "_ticker": str(ev.get("ticker", "")).strip()}


def make_id(date_str: str, title: str) -> str:
    return hashlib.sha1(f"{date_str}|{title}".encode()).hexdigest()[:16]


def norm_key(date_str: str, title: str) -> str:
    t = re.sub(r"[\s\(\)\[\]·,.\-_/]", "", title).lower()
    t = re.sub(r"(실적|발표|컨콜|개최|공개|예정)", "", t)
    return f"{date_str}|{t}"


_GENERIC_EVENT_TOKENS = {
    "실적", "발표", "예정", "관련", "승인", "개최", "공개", "시행", "발효",
    "컨퍼런스콜", "컨콜", "제품", "상품", "분기", "1분기", "2분기", "3분기", "4분기",
}


def _title_tokens(title: str) -> set[str]:
    return {
        token.casefold() for token in re.findall(r"[0-9A-Za-z가-힣-]+", title)
        if token.casefold() not in _GENERIC_EVENT_TOKENS and len(token) >= 2
    }


def same_event(left: dict, right: dict) -> bool:
    """Catch cross-channel paraphrases that survive exact normalized-key dedupe."""
    if left.get("date") != right.get("date") or left.get("category") != right.get("category"):
        return False
    a, b = _title_tokens(str(left.get("title") or "")), _title_tokens(str(right.get("title") or ""))
    shared = len(a & b)
    return shared >= 3 and (2 * shared / max(1, len(a) + len(b))) >= 0.65


def load_events() -> list[dict]:
    if not EVENTS_FILE.exists():
        return []
    try:
        d = json.loads(EVENTS_FILE.read_text(encoding="utf-8"))
        return d.get("events", []) if isinstance(d, dict) else (d if isinstance(d, list) else [])
    except Exception:
        return []


def save_events(events: list[dict]) -> None:
    tmp = EVENTS_FILE.with_suffix(".json.tmp")
    tmp.write_text(json.dumps({"events": events}, ensure_ascii=False, indent=2), encoding="utf-8")
    tmp.replace(EVENTS_FILE)


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--days-back", type=int, default=3)
    ap.add_argument("--since")
    ap.add_argument("--until")
    ap.add_argument("--dry-run", action="store_true")
    args = ap.parse_args()

    until = datetime.strptime(args.until, "%Y-%m-%d").date() if args.until else datetime.now(KST).date()
    since = datetime.strptime(args.since, "%Y-%m-%d").date() if args.since else until - timedelta(days=args.days_back)

    try:
        blocks = gather_shared(since, until)
        scope = "all_account_dialogs"
    except (OSError, TimeoutError, ValueError, RuntimeError, json.JSONDecodeError) as exc:
        print(f"[gather] 공용 인덱스 실패, 레거시 파일 폴백: {exc}", flush=True)
        blocks = gather_md(since, until)
        scope = "legacy_sector_files"
    print(f"[gather] {since}~{until}: {scope} 소스 {len(blocks)}개", flush=True)
    if not blocks:
        print("처리할 메시지 없음."); return

    raw = call_llm(blocks)
    new = [n for n in (normalize(e) for e in raw) if n]
    print(f"[extract] 정규화된 이벤트 {len(new)}건", flush=True)

    existing = load_events()
    seen = {norm_key(e.get("date", ""), e.get("title", "")) for e in existing}
    added = []
    for n in new:
        k = norm_key(n["date"], n["title"])
        if k in seen or any(same_event(n, row) for row in [*existing, *added]):
            continue
        seen.add(k)
        src = n.pop("_ticker", "")
        added.append({"id": make_id(n["date"], n["title"]), **n})

    print(f"[merge] 기존 {len(existing)}건 + 신규 {len(added)}건", flush=True)
    for a in sorted(added, key=lambda x: x["date"])[:40]:
        print(f"   + {a['date']} [{a['category']}] {a['title']} — {a['note'][:70]}", flush=True)

    if args.dry_run:
        print("[dry-run] 저장 안 함."); return
    if added:
        save_events(existing + added)
        print(f"[saved] events.json → 총 {len(existing)+len(added)}건", flush=True)
    else:
        print("[saved] 신규 없음 — 변경 없음")


if __name__ == "__main__":
    main()
노트북 실행 중...
extract_telegram_events.py
0.0초
Papermill로 모든 셀을 다시 실행하고 있습니다.
수십 초에서 몇 분 걸릴 수 있습니다. 창을 닫지 마세요.