From 97f403a74e37112203f26a2bf0e4a7af5fa54a5e Mon Sep 17 00:00:00 2001 From: mikim <1441941+mikim@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:48:49 +0900 Subject: [PATCH] =?UTF-8?q?why-moved:=2024=EC=8B=9C=EA=B0=84=20KST=C2=B7?= =?UTF-8?q?=EC=82=AC=ED=9B=84=20=EA=B2=80=EC=A6=9D=20=EB=85=B8=EC=B6=9C?= =?UTF-8?q?=C2=B7=EC=A0=9C=EB=AA=A9=20=EA=B3=A0=EC=A0=95=C2=B73~5=20?= =?UTF-8?q?=ED=8F=AC=EC=9D=B8=ED=8A=B8=C2=B7=EC=A1=B0=EC=9A=A9=ED=95=9C=20?= =?UTF-8?q?=EB=82=A0=EC=9D=80=20=EA=B8=B0=EC=82=AC=20=EC=97=86=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 콘텐츠 검토(2026-08-19). 숫자는 전부 정확했지만: - 12시간제(toLocaleTimeString ko-KR → "오전 12:20")를 받은 모델이 "12:20 KST" 라 썼다 (실제 00:20). 24시간 KST 로. - pulse summary 안의 UTC ISO 스탬프("2026-08-18T16:11:59Z 기준")가 그대로 들어가 모델이 시차·경과 시간을 계산했다 ("4시간 시차" — 실제 14분). KST 로 치환하고 문장 경계에서 자른다. 규칙: 자료의 시각은 전부 KST, 시차 계산 금지. - p.confidence 와 verifiedSummary(사후 검증) 를 한 번도 안 보여줬다 — 데이터에서 가장 정직한 문장인데. 그래서 검증이 "미확인" 이라 한 원인을 기사가 단정했다. 둘 다 넣고, 규칙: 사후 검증이 부정·미확인이면 결론에서 단정하지 않는다. - 제목을 모델이 지었다 → "GLD, 6% 국채금리 급등에 0.4% 하락" (두 구간 합산 + 인과 단정, 사후 검증은 미확인). 제목은 코드가 정한다: "오늘 {자산}는 왜 움직였나? — {date}". 그게 의도된 SEO 형식이기도 하다. - points 를 정확히 5개 강제 → 사실 하나뿐인 날엔 슬롯을 지어냈다. 3~5. - 출처에 발행 시각(KST)·발췌 추가. - 게이트: 어떤 자산에 pulse 가 하나라도 있으면 그날 기사를 만들었다. SignalMap 적응형 임계값은 조용한 날 0.2% 에도 발화하므로 "0.19% 5분 변동은 왜 움직였나" 가 quality 0.85 로 색인됐다. articleWorthy(): 단일 0.5% 또는 하루 순변화 1% 이상. 최근 7일 GLD·QQQ·SPY 의 0.2% 날은 걸러지고 코스피 0.6~0.7% 날은 통과. 걸러진 수는 로그에. - 페이지: PulseCard 를 compact 로 그려 사후 검증 블록이 숨겨졌다 — 인과가 주제인 페이지에서. non-compact 로. datePublished 는 URL 날짜 자정이 아니라 실제 생성 시각. "5분 윈도우 1% 이상만 감지" 문구는 사실이 아니라 정정. - PROMPT_VERSION why-moved-v2. 실측 (서버 DB 사본, GLD 2026-08-19): 옛: 제목 "GLD, 6% 국채금리 급등에 0.4% 하락" / "12:20 KST 0.20% 하락" 새: "오늘 GLD는 왜 움직였나?" / "GLD 0.20~0.22% 하락, 국채금리·중동 인과는 미확인" / "00:20 KST" / points 4개 / "사후 보도 16건 중 금리-금 직접 기사 없음" Co-Authored-By: Claude Opus 5 --- app/asset/[symbol]/why-moved/[date]/page.tsx | 13 ++- lib/why-moved.ts | 92 ++++++++++++++++---- scripts/generate-why-moved.ts | 16 +++- 3 files changed, 96 insertions(+), 25 deletions(-) diff --git a/app/asset/[symbol]/why-moved/[date]/page.tsx b/app/asset/[symbol]/why-moved/[date]/page.tsx index 541473c..e263c4d 100644 --- a/app/asset/[symbol]/why-moved/[date]/page.tsx +++ b/app/asset/[symbol]/why-moved/[date]/page.tsx @@ -83,8 +83,9 @@ export default async function WhyMovedPage({ params }: Props) { {symbol.toUpperCase()} — {date} 데이터 없음

- 이 날에 추적된 가격 시그널이 없습니다. signalmap이 매 5분 윈도우 1% - 이상 변동만 감지합니다. + 이 날에 추적된 가격 시그널이 없습니다. signalmap 은 5분 윈도우 변동을 + 자산별 적응형 임계값(조용한 장에서는 0.2% 안팎부터)으로 감지하고, Alpha 는 + 그중 의미 있는 움직임(단일 0.5% 또는 하루 순변화 1% 이상)이 있는 날만 기사로 씁니다.

@@ -118,7 +119,9 @@ export default async function WhyMovedPage({ params }: Props) { "@context": "https://schema.org", "@type": "NewsArticle", headline: article.title, - datePublished: date + "T00:00:00+09:00", + // When the article was actually written — not the URL date at midnight, + // which was a constant unrelated to anything that happened. + datePublished: article.generatedAt, dateModified: article.generatedAt, author: { "@type": "Organization", name: "Alpha by Mossland" }, publisher: { "@type": "Organization", name: "Mossland" }, @@ -193,9 +196,11 @@ export default async function WhyMovedPage({ params }: Props) {

가격 시그널 ({article.pulses.length})

+ {/* Not compact: this is the one page whose point is causation, and + PulseCard hides the 사후 검증 block in compact mode. */}
{article.pulses.map((p) => ( - + ))}
diff --git a/lib/why-moved.ts b/lib/why-moved.ts index 9765ccf..b1f605d 100644 --- a/lib/why-moved.ts +++ b/lib/why-moved.ts @@ -21,9 +21,53 @@ import { type Pulse, } from "./mic"; -const PROMPT_VERSION = "why-moved-v1"; +// v2 (2026-08-19): 24-hour KST clock, UTC stamps inside summaries rewritten, +// confidence + 사후 검증 shown, title fixed, 3–5 points. Bumped so v1 rows are +// not replayed from cache. +const PROMPT_VERSION = "why-moved-v2"; const KST_OFFSET_MS = 9 * 3600_000; +/** HH:MM in KST, 24-hour. toLocaleTimeString("ko-KR") gave "오전 12:20" for + * 00:20 and the model wrote "12:20 KST" in the article. */ +function kstHm(iso: string): string { + const t = Date.parse(iso); + return Number.isFinite(t) ? new Date(t + KST_OFFSET_MS).toISOString().slice(11, 16) : "--:--"; +} +/** Flatten free text for a prompt line: SignalMap writes raw UTC ISO stamps + * ("2026-08-18T16:11:59.999Z 기준 …") into pulse summaries; rewrite them as + * KST so the model is not handed two clocks. Cut on a sentence boundary. */ +function promptText(text: string | null | undefined, max: number): string { + const flat = (text ?? "") + .replace(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?Z/g, (m) => `${kstHm(m)} KST`) + .replace(/\s+/g, " ") + .trim(); + if (flat.length <= max) return flat; + const cut = flat.slice(0, max); + const at = Math.max(cut.lastIndexOf("다."), cut.lastIndexOf(". "), cut.lastIndexOf(".")); + return at > max * 0.5 ? cut.slice(0, at + 1) : cut; +} + +/** + * Is this day's pulse set worth an article? + * + * The cron used to build an article for every asset that had ANY pulse, and + * SignalMap's adaptive floor fires on ~0.2% moves on quiet days — so the site + * published indexed NewsArticles titled "왜 움직였나" about a 0.19% five-minute + * blip whose own text said the cause was unconfirmed. Require a real move. + */ +export const ARTICLE_MIN_SINGLE_MOVE_PCT = 0.5; +export const ARTICLE_MIN_NET_MOVE_PCT = 1.0; +export function articleWorthy(pulses: Pulse[]): boolean { + if (!pulses.length) return false; + const maxAbs = Math.max(...pulses.map((p) => Math.abs(p.magnitudePct))); + if (maxAbs >= ARTICLE_MIN_SINGLE_MOVE_PCT) return true; + const sorted = [...pulses].sort((a, b) => Date.parse(a.detectedAt) - Date.parse(b.detectedAt)); + const first = sorted.find((p) => Number.isFinite(p.priceFrom))?.priceFrom; + const last = [...sorted].reverse().find((p) => Number.isFinite(p.priceTo))?.priceTo; + if (first && last) return Math.abs(((last - first) / first) * 100) >= ARTICLE_MIN_NET_MOVE_PCT; + return false; +} + export type WhyMovedArticle = { asset: string; date: string; @@ -225,15 +269,18 @@ function parseWhyMovedResponse(content: string): ParsedWhyMovedResponse { throw new Error("response must be an object"); } const value = candidate as Record; + // The title is no longer the model's to write (see generateWhyMoved) — + // it authored "GLD, 6% 국채금리 급등에 0.4% 하락" over a pulse whose own + // 사후 검증 called the cause unconfirmed. Points: 3–5, so a one-fact day + // does not force two invented slots. if ( - typeof value.title !== "string" || - !value.title.trim() || typeof value.oneLine !== "string" || !value.oneLine.trim() || typeof value.why !== "string" || !value.why.trim() || !Array.isArray(value.points) || - value.points.length !== 5 || + value.points.length < 3 || + value.points.length > 5 || !value.points.every( (point) => typeof point === "string" && point.trim().length > 0 ) @@ -241,7 +288,7 @@ function parseWhyMovedResponse(content: string): ParsedWhyMovedResponse { throw new Error("response does not match the why-moved schema"); } return { - title: value.title, + title: typeof value.title === "string" ? value.title : "", oneLine: value.oneLine, why: value.why, points: value.points as string[], @@ -303,22 +350,25 @@ export async function generateWhyMoved( const pulseLines = pulses .map((p) => { const dirSign = p.direction === "up" ? "+" : "-"; - const t = new Date(p.detectedAt); - const timeStr = t.toLocaleTimeString("ko-KR", { - hour: "2-digit", - minute: "2-digit", - timeZone: "Asia/Seoul", - }); // State the unit. A bare "90,557,000 → 90,735,000" for a KRW-quoted // pulse reads as dollars to the model, and roughly a third of active // pulses are KRW-quoted (BTC-KRW, USDKRW). const money = (n?: number) => (n == null ? "?" : fmtPulsePrice(n, p.priceUnit)); - return `- ${timeStr} KST: ${dirSign}${Math.abs(p.magnitudePct).toFixed(2)}% (${money(p.priceFrom)} → ${money(p.priceTo)}) — ${p.summary.slice(0, 200)}`; + const head = `- ${kstHm(p.detectedAt)} KST: ${dirSign}${Math.abs(p.magnitudePct).toFixed(2)}% (${money(p.priceFrom)} → ${money(p.priceTo)}) [신뢰도: ${p.confidence}] — ${promptText(p.summary, 320)}`; + // The verifier's verdict is the most honest sentence in the data and + // was never shown to the model — so the article asserted causes the + // 사후 검증 had already called unconfirmed. + const verified = p.verifiedSummary ? `\n 사후 검증: ${promptText(p.verifiedSummary, 320)}` : ""; + return head + verified; }) .join("\n"); const sourceLines = uniqueSources - .map((s) => `- ${s.title || s.url} (${s.publisher || "source"})`) + .map((s) => { + const when = s.publishedAt ? ` · 발행 ${kstHm(s.publishedAt)} KST` : ""; + const excerpt = s.excerpt ? ` · 발췌: ${promptText(s.excerpt, 160)}` : ""; + return `- ${s.title || s.url} (${s.publisher || "source"})${when}${excerpt}`; + }) .join("\n"); const prompt = `${date} 한국 시장 시각으로 "${assetLabel}이 왜 움직였나" 정리. @@ -334,21 +384,24 @@ ${sourceLines} 스키마: { - "title": "오늘 ${assetLabel}는 왜 움직였나? — ${date} (≤60자)", "oneLine": "한 줄 결론 (≤80자, 사실 + 인사이트)", "why": "왜 중요한가 (≤120자)", "points": [ "확인된 사실 (≤50자)", - "가능한 원인 (≤50자)", - "다른 해석 (≤50자)", - "연결된 자산/매크로 (≤50자)", + "가능한 원인 (≤50자) — 자료에 있을 때만", + "다른 해석 (≤50자) — 자료에 있을 때만", + "연결된 자산/매크로 (≤50자) — 자료에 있을 때만", "아직 불확실한 것 (≤50자)" ] } +points 는 3~5개. 해당 근거가 자료에 없는 슬롯은 생략하고, 슬롯끼리 같은 말을 반복하지 않는다. 규칙: - 한국어 출력 - 가격 권유 X · 정치 비방 X · 단정 X +- 위 자료의 시각은 전부 KST 다. 시차·경과 시간을 계산하지 말고 표기된 시각만 쓴다. +- 변동률·가격은 위 시그널 줄의 수치 그대로. 여러 건을 합산하지 않는다. +- 사후 검증이 인과를 부정하거나 미확인으로 두면 한 줄 결론에서 그 원인을 단정하지 않는다. speculative 시그널은 원인 서술 대신 미확인으로 쓴다. - pulse summary와 보도 내용 *구체적*으로 인용 - generic 문구 X - "...로 보임", "~가능성" 어휘`; @@ -375,7 +428,10 @@ ${sourceLines} const article: WhyMovedArticle = { asset, date, - title: parsed.title, + // Deterministic — the intended SEO form (scripts/audit-baseline.ts Q1), + // and it takes the whole class of model-authored causal titles off the + // table. + title: `오늘 ${assetLabel}는 왜 움직였나? — ${date}`, oneLine: parsed.oneLine, why: parsed.why, points: parsed.points, diff --git a/scripts/generate-why-moved.ts b/scripts/generate-why-moved.ts index 7c21c94..372cd8b 100644 --- a/scripts/generate-why-moved.ts +++ b/scripts/generate-why-moved.ts @@ -142,6 +142,9 @@ async function main(parsed: ParsedArgs) { kstDateForTimestamp, kstDayBounds, listAllWhyMoved, + articleWorthy, + ARTICLE_MIN_SINGLE_MOVE_PCT, + ARTICLE_MIN_NET_MOVE_PCT, } = await import("../lib/why-moved"); const { formatPulseLoadDiagnostics, getAllPulses, getPulseLoadDiagnostics } = await import("../lib/mic"); @@ -178,7 +181,7 @@ async function main(parsed: ParsedArgs) { } const combos = new Map< string, - { asset: string; date: string; pulseIds: string[] } + { asset: string; date: string; pulseIds: string[]; pulses: typeof pulses } >(); for (const p of pulses) { // getAllPulses() already rejects pulses without a parseable detectedAt. @@ -187,9 +190,16 @@ async function main(parsed: ParsedArgs) { const asset = p.asset.toLowerCase(); const key = `${asset}|${date}`; const combo = combos.get(key); - if (combo) combo.pulseIds.push(p.id); - else combos.set(key, { asset, date, pulseIds: [p.id] }); + if (combo) { combo.pulseIds.push(p.id); combo.pulses.push(p); } + else combos.set(key, { asset, date, pulseIds: [p.id], pulses: [p] }); } + // A day whose biggest five-minute move is 0.2% does not get a "왜 움직였나" + // article — see articleWorthy(). Counted, not silent. + let filteredQuiet = 0; + for (const [key, combo] of [...combos.entries()]) { + if (!articleWorthy(combo.pulses)) { combos.delete(key); filteredQuiet++; } + } + console.log(`Asset-days: ${combos.size} worth an article, ${filteredQuiet} too quiet (max move < ${ARTICLE_MIN_SINGLE_MOVE_PCT}% and net < ${ARTICLE_MIN_NET_MOVE_PCT}%)`); const samePulseIds = ( stored: { id: string }[],