first commit
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
/** Hand-rolled single-series SVG line chart with crosshair + tooltip hover. */
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
const W = 920;
|
||||
const H = 260;
|
||||
const PAD = { top: 14, right: 16, bottom: 26, left: 58 };
|
||||
|
||||
interface Point {
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
function svgEl<K extends keyof SVGElementTagNameMap>(
|
||||
tag: K,
|
||||
attrs: Record<string, string | number> = {},
|
||||
): SVGElementTagNameMap[K] {
|
||||
const el = document.createElementNS(SVG_NS, tag);
|
||||
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, String(v));
|
||||
return el;
|
||||
}
|
||||
|
||||
function niceTicks(min: number, max: number, count = 4): number[] {
|
||||
if (min === max) {
|
||||
const pad = Math.abs(min) * 0.01 || 1;
|
||||
min -= pad;
|
||||
max += pad;
|
||||
}
|
||||
const span = max - min;
|
||||
const step = 10 ** Math.floor(Math.log10(span / count));
|
||||
const err = span / count / step;
|
||||
const mult = err >= 7.5 ? 10 : err >= 3.5 ? 5 : err >= 1.5 ? 2 : 1;
|
||||
const s = step * mult;
|
||||
const first = Math.ceil(min / s) * s;
|
||||
const ticks: number[] = [];
|
||||
for (let v = first; v <= max + s * 1e-9; v += s) ticks.push(Number(v.toFixed(10)));
|
||||
return ticks;
|
||||
}
|
||||
|
||||
export function formatValue(v: number): string {
|
||||
const decimals = Number.isInteger(v) ? 0 : Math.abs(v) >= 10 ? 1 : 2;
|
||||
return v.toLocaleString("ko-KR", { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
|
||||
}
|
||||
|
||||
/** Render the chart into `host` (cleared first). Null values break the line.
|
||||
* `labels` are ISO weeks; the axis shows their short form ("W36"). */
|
||||
export function renderLineChart(
|
||||
host: HTMLElement,
|
||||
labels: string[],
|
||||
values: (number | null)[],
|
||||
seriesLabel: string,
|
||||
): void {
|
||||
host.textContent = "";
|
||||
const points: (Point | null)[] = labels.map((label, i) => {
|
||||
const v = values[i];
|
||||
return v === null || v === undefined ? null : { label, value: v };
|
||||
});
|
||||
const present = points.filter((p): p is Point => p !== null);
|
||||
if (present.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "chart-empty";
|
||||
empty.textContent = "표시할 데이터가 없습니다.";
|
||||
host.append(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
const lo = Math.min(0, ...present.map((p) => p.value));
|
||||
const hi = Math.max(...present.map((p) => p.value));
|
||||
const span = hi - lo || Math.abs(hi) * 0.02 || 1;
|
||||
const yMin = lo;
|
||||
const yMax = hi + span * 0.08;
|
||||
|
||||
const innerW = W - PAD.left - PAD.right;
|
||||
const innerH = H - PAD.top - PAD.bottom;
|
||||
const x = (i: number) => PAD.left + (labels.length === 1 ? innerW / 2 : (i / (labels.length - 1)) * innerW);
|
||||
const y = (v: number) => PAD.top + (1 - (v - yMin) / (yMax - yMin)) * innerH;
|
||||
|
||||
const svg = svgEl("svg", { viewBox: `0 0 ${W} ${H}`, role: "img" });
|
||||
const title = svgEl("title");
|
||||
title.textContent = seriesLabel;
|
||||
svg.append(title);
|
||||
|
||||
for (const tick of niceTicks(yMin, yMax)) {
|
||||
svg.append(svgEl("line", { class: "gridline", x1: PAD.left, x2: W - PAD.right, y1: y(tick), y2: y(tick) }));
|
||||
const label = svgEl("text", { class: "axis-label", x: PAD.left - 8, y: y(tick) + 4, "text-anchor": "end" });
|
||||
label.textContent = formatValue(tick);
|
||||
svg.append(label);
|
||||
}
|
||||
svg.append(
|
||||
svgEl("line", { class: "baseline", x1: PAD.left, x2: W - PAD.right, y1: H - PAD.bottom, y2: H - PAD.bottom }),
|
||||
);
|
||||
|
||||
const maxLabels = Math.max(2, Math.floor(innerW / 90));
|
||||
const stride = Math.max(1, Math.ceil(labels.length / maxLabels));
|
||||
labels.forEach((d, i) => {
|
||||
if (i % stride !== 0 && i !== labels.length - 1) return;
|
||||
const label = svgEl("text", { class: "axis-label", x: x(i), y: H - 8, "text-anchor": "middle" });
|
||||
label.textContent = d.slice(5); // "2026-W36" -> "W36"
|
||||
svg.append(label);
|
||||
});
|
||||
|
||||
// polyline per contiguous run so nulls read as gaps, not interpolation
|
||||
let run: string[] = [];
|
||||
const flush = () => {
|
||||
if (run.length >= 2) svg.append(svgEl("polyline", { class: "series-line", points: run.join(" ") }));
|
||||
else if (run.length === 1) {
|
||||
const [px = "", py = ""] = (run[0] ?? "").split(",");
|
||||
svg.append(svgEl("circle", { class: "hover-dot", cx: px, cy: py, r: 3 }));
|
||||
}
|
||||
run = [];
|
||||
};
|
||||
points.forEach((pt, i) => {
|
||||
if (pt === null) flush();
|
||||
else run.push(`${x(i)},${y(pt.value)}`);
|
||||
});
|
||||
flush();
|
||||
|
||||
// hover layer: crosshair + dot + tooltip on the nearest present point
|
||||
const crosshair = svgEl("line", {
|
||||
class: "crosshair",
|
||||
y1: PAD.top,
|
||||
y2: H - PAD.bottom,
|
||||
x1: -10,
|
||||
x2: -10,
|
||||
visibility: "hidden",
|
||||
});
|
||||
const dot = svgEl("circle", { class: "hover-dot", r: 4.5, visibility: "hidden" });
|
||||
svg.append(crosshair, dot);
|
||||
|
||||
const tooltip = document.createElement("div");
|
||||
tooltip.className = "chart-tooltip";
|
||||
tooltip.hidden = true;
|
||||
host.append(svg, tooltip);
|
||||
|
||||
const hide = () => {
|
||||
crosshair.setAttribute("visibility", "hidden");
|
||||
dot.setAttribute("visibility", "hidden");
|
||||
tooltip.hidden = true;
|
||||
};
|
||||
svg.addEventListener("pointerleave", hide);
|
||||
svg.addEventListener("pointermove", (ev) => {
|
||||
const rect = svg.getBoundingClientRect();
|
||||
const relX = ((ev.clientX - rect.left) / rect.width) * W;
|
||||
let best: { i: number; pt: Point } | null = null;
|
||||
points.forEach((pt, i) => {
|
||||
if (pt === null) return;
|
||||
if (best === null || Math.abs(x(i) - relX) < Math.abs(x(best.i) - relX)) best = { i, pt };
|
||||
});
|
||||
if (best === null) {
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
const { i, pt } = best as { i: number; pt: Point };
|
||||
const cx = x(i);
|
||||
const cy = y(pt.value);
|
||||
crosshair.setAttribute("x1", String(cx));
|
||||
crosshair.setAttribute("x2", String(cx));
|
||||
crosshair.setAttribute("visibility", "visible");
|
||||
dot.setAttribute("cx", String(cx));
|
||||
dot.setAttribute("cy", String(cy));
|
||||
dot.setAttribute("visibility", "visible");
|
||||
tooltip.innerHTML = "";
|
||||
const labelEl = document.createElement("div");
|
||||
labelEl.textContent = pt.label;
|
||||
const valueEl = document.createElement("div");
|
||||
valueEl.className = "tt-value";
|
||||
valueEl.textContent = `${formatValue(pt.value)}편`;
|
||||
tooltip.append(labelEl, valueEl);
|
||||
tooltip.hidden = false;
|
||||
tooltip.style.left = `${(cx / W) * rect.width}px`;
|
||||
tooltip.style.top = `${(cy / H) * rect.height}px`;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
import DOMPurify from "dompurify";
|
||||
import { marked } from "marked";
|
||||
|
||||
import type {
|
||||
DigestResult,
|
||||
DigestSummary,
|
||||
ErrorLine,
|
||||
HealthResponse,
|
||||
StatsResponse,
|
||||
} from "../shared/types";
|
||||
import { formatValue, renderLineChart } from "./chart";
|
||||
|
||||
function el<K extends keyof HTMLElementTagNameMap>(
|
||||
tag: K,
|
||||
className?: string,
|
||||
text?: string,
|
||||
): HTMLElementTagNameMap[K] {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text !== undefined) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
|
||||
function byId<T extends HTMLElement>(id: string): T {
|
||||
const node = document.getElementById(id);
|
||||
if (!node) throw new Error(`missing #${id}`);
|
||||
return node as T;
|
||||
}
|
||||
|
||||
async function getJson<T>(url: string): Promise<T> {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`${url} -> ${res.status}`);
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ ISO week helpers
|
||||
|
||||
const WEEK_RE = /^(\d{4})-W(\d{2})$/;
|
||||
|
||||
/** Monday (UTC midnight) of an ISO week id. */
|
||||
function weekToMonday(week: string): Date {
|
||||
const m = WEEK_RE.exec(week);
|
||||
if (!m) throw new Error(`bad week ${week}`);
|
||||
const year = Number(m[1]);
|
||||
const wk = Number(m[2]);
|
||||
const jan4 = new Date(Date.UTC(year, 0, 4)); // Jan 4 is always in ISO week 1
|
||||
const week1Monday = new Date(jan4);
|
||||
week1Monday.setUTCDate(jan4.getUTCDate() - ((jan4.getUTCDay() + 6) % 7));
|
||||
const monday = new Date(week1Monday);
|
||||
monday.setUTCDate(week1Monday.getUTCDate() + (wk - 1) * 7);
|
||||
return monday;
|
||||
}
|
||||
|
||||
/** ISO week id of a UTC date. */
|
||||
function dateToWeek(d: Date): string {
|
||||
const thu = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
|
||||
thu.setUTCDate(thu.getUTCDate() - ((thu.getUTCDay() + 6) % 7) + 3); // Thursday decides the ISO year
|
||||
const isoYear = thu.getUTCFullYear();
|
||||
const jan4 = new Date(Date.UTC(isoYear, 0, 4));
|
||||
const week1Monday = new Date(jan4);
|
||||
week1Monday.setUTCDate(jan4.getUTCDate() - ((jan4.getUTCDay() + 6) % 7));
|
||||
const wk = Math.floor((thu.getTime() - week1Monday.getTime()) / (7 * 864e5)) + 1;
|
||||
return `${isoYear}-W${String(wk).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function addWeeks(week: string, delta: number): string {
|
||||
const monday = weekToMonday(week);
|
||||
monday.setUTCDate(monday.getUTCDate() + delta * 7);
|
||||
return dateToWeek(monday);
|
||||
}
|
||||
|
||||
/** Weeks are lexicographically ordered only within a year; compare via their Mondays. */
|
||||
function weekLt(a: string, b: string): boolean {
|
||||
return weekToMonday(a).getTime() < weekToMonday(b).getTime();
|
||||
}
|
||||
|
||||
/** The ISO week containing today (Seoul calendar date). */
|
||||
function kstCurrentWeek(): string {
|
||||
const today = new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Seoul" }).format(new Date());
|
||||
return dateToWeek(new Date(`${today}T00:00:00Z`));
|
||||
}
|
||||
|
||||
function fmtDateTime(iso: string): string {
|
||||
return new Date(iso).toLocaleString("ko-KR", { dateStyle: "medium", timeStyle: "short" });
|
||||
}
|
||||
|
||||
/** Live Seoul clock in the top bar. */
|
||||
function startClock(): void {
|
||||
const host = byId<HTMLElement>("clocks");
|
||||
const seoul = el("span", "clock");
|
||||
host.append(seoul);
|
||||
const tick = () => {
|
||||
seoul.textContent = `서울 ${new Intl.DateTimeFormat("ko-KR", {
|
||||
timeZone: "Asia/Seoul",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
weekday: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
}).format(new Date())}`;
|
||||
};
|
||||
tick();
|
||||
setInterval(tick, 1000);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ stat tiles
|
||||
|
||||
function verificationBadge(result: DigestResult): { text: string; cls: string } {
|
||||
const v = result.verification;
|
||||
if (result.degraded) return { text: "요약 없음", cls: "badge-warn" };
|
||||
if (!v) return { text: "검증 안 함", cls: "" };
|
||||
if (v.ok) return { text: v.issues.length > 0 ? `통과 (minor ${v.issues.length})` : "통과", cls: "badge-ok" };
|
||||
return { text: `이슈 ${v.issues.length}`, cls: "badge-error" };
|
||||
}
|
||||
|
||||
function renderTiles(result: DigestResult): void {
|
||||
const host = byId<HTMLElement>("tiles");
|
||||
host.textContent = "";
|
||||
const venues = new Set(result.payload.papers.map((p) => p.venue || "(미상)"));
|
||||
const tiles: Array<[string, string]> = [
|
||||
["논문", `${result.payload.papers.length}편`],
|
||||
["저널", `${venues.size}곳`],
|
||||
["하이라이트", `${result.digest?.highlights.length ?? 0}편`],
|
||||
["주제 그룹", `${result.digest?.by_topic.length ?? 0}개`],
|
||||
];
|
||||
for (const [label, value] of tiles) {
|
||||
const tile = el("div", "tile");
|
||||
tile.append(el("div", "tile-label", label));
|
||||
tile.append(el("div", "tile-value", value));
|
||||
host.append(tile);
|
||||
}
|
||||
const tile = el("div", "tile");
|
||||
tile.append(el("div", "tile-label", "검증"));
|
||||
const { text, cls } = verificationBadge(result);
|
||||
const value = el("div", "tile-value tile-value-small", text);
|
||||
if (cls) value.classList.add(cls.replace("badge-", "tone-"));
|
||||
tile.append(value);
|
||||
host.append(tile);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ digest list + body
|
||||
|
||||
function badgeFor(s: DigestSummary): HTMLElement {
|
||||
if (s.error) return el("span", "badge badge-error", "오류");
|
||||
if (s.degraded) return el("span", "badge badge-warn", "축소");
|
||||
return el("span", "badge badge-ok", "정상");
|
||||
}
|
||||
|
||||
function missingRow(week: string, currentWeek: string): HTMLElement {
|
||||
const label = week === currentWeek ? "이번 주 · 다음 주 월요일 생성 예정" : "다이제스트 없음";
|
||||
const item = el("li", "week-missing");
|
||||
item.append(el("span", "digest-week", week), el("span", undefined, label));
|
||||
return item;
|
||||
}
|
||||
|
||||
function renderList(
|
||||
summaries: DigestSummary[],
|
||||
selected: string,
|
||||
onSelect: (week: string) => void,
|
||||
): void {
|
||||
const list = byId<HTMLUListElement>("digest-list");
|
||||
list.textContent = "";
|
||||
const byWeek = new Map(summaries.map((s) => [s.week, s]));
|
||||
const current = kstCurrentWeek();
|
||||
const newest = summaries[0]?.week ?? current;
|
||||
const oldest = summaries[summaries.length - 1]?.week ?? current;
|
||||
const start = weekLt(newest, current) ? current : newest;
|
||||
// walk week by week so gaps (missed runs) are visible, not silent
|
||||
for (let w = start; !weekLt(w, oldest); w = addWeeks(w, -1)) {
|
||||
const s = byWeek.get(w);
|
||||
if (!s) {
|
||||
list.append(missingRow(w, current));
|
||||
continue;
|
||||
}
|
||||
const item = el("li");
|
||||
const button = el("button");
|
||||
button.type = "button";
|
||||
button.setAttribute("aria-current", String(s.week === selected));
|
||||
button.append(el("span", "digest-week", s.week));
|
||||
const line = el("span", "digest-line");
|
||||
line.append(`${s.week_start} ~ ${s.week_end}`, badgeFor(s));
|
||||
line.append(el("span", "badge", `${s.paper_count}편`));
|
||||
if (s.data_gaps.length > 0) line.append(el("span", "badge badge-warn", `결측 ${s.data_gaps.length}`));
|
||||
button.append(line);
|
||||
if (s.overview) button.append(el("span", "digest-line digest-overview", s.overview));
|
||||
button.addEventListener("click", () => onSelect(s.week));
|
||||
item.append(button);
|
||||
list.append(item);
|
||||
}
|
||||
}
|
||||
|
||||
function metaChips(result: DigestResult): HTMLElement[] {
|
||||
const p = result.payload;
|
||||
const chips: HTMLElement[] = [
|
||||
el("span", undefined, `${p.week} (${p.week_start} ~ ${p.week_end})`),
|
||||
el("span", undefined, `생성 ${fmtDateTime(p.generated_at)}`),
|
||||
];
|
||||
if (result.model_used) chips.push(el("span", undefined, `모델 ${result.model_used}`));
|
||||
if (result.degraded) chips.push(el("span", "badge badge-warn", "요약 없음"));
|
||||
const { text, cls } = verificationBadge(result);
|
||||
if (result.verification) chips.push(el("span", `badge ${cls}`.trim(), `검증 ${text}`));
|
||||
for (const gap of p.data_gaps) chips.push(el("span", "badge badge-warn", gap));
|
||||
if (result.error) chips.push(el("span", "badge badge-error", result.error));
|
||||
return chips;
|
||||
}
|
||||
|
||||
/** Compact "핵심 요약" card: overview + worth-reading picks, above the full markdown. */
|
||||
function renderSummaryCard(result: DigestResult): void {
|
||||
const host = byId<HTMLElement>("digest-summary");
|
||||
host.textContent = "";
|
||||
const digest = result.digest;
|
||||
if (!digest) return;
|
||||
const card = el("section", "summary-card");
|
||||
card.setAttribute("aria-label", "핵심 요약");
|
||||
if (digest.overview) card.append(el("p", "summary-overview", digest.overview));
|
||||
if (digest.worth_reading.length > 0) {
|
||||
const block = el("div", "summary-block");
|
||||
block.append(el("h3", undefined, "읽어볼 논문"));
|
||||
const list = el("ul");
|
||||
const papers = new Map(result.payload.papers.map((p) => [p.doi, p]));
|
||||
// 제목 우선 매칭: LLM이 DOI를 뒤섞어도 링크는 (독자가 보는) 제목을 따라간다 (렌더러와 동일 규칙)
|
||||
const normTitle = (s: string) => s.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim();
|
||||
const byTitle = new Map<string, (typeof result.payload.papers)[number] | null>();
|
||||
for (const p of result.payload.papers) {
|
||||
const key = normTitle(p.title);
|
||||
byTitle.set(key, byTitle.has(key) ? null : p);
|
||||
}
|
||||
for (const pick of digest.worth_reading) {
|
||||
const item = el("li");
|
||||
const paper = byTitle.get(normTitle(pick.title)) ?? papers.get(pick.doi);
|
||||
const url = paper?.oa_url ?? `https://doi.org/${pick.doi}`;
|
||||
const link = el("a", undefined, pick.title || pick.doi);
|
||||
link.setAttribute("href", url);
|
||||
link.setAttribute("target", "_blank");
|
||||
link.setAttribute("rel", "noopener noreferrer");
|
||||
item.append(link);
|
||||
if (pick.reason) item.append(` — ${pick.reason}`);
|
||||
list.append(item);
|
||||
}
|
||||
block.append(list);
|
||||
card.append(block);
|
||||
}
|
||||
host.append(card);
|
||||
}
|
||||
|
||||
async function showDigest(week: string): Promise<void> {
|
||||
const result = await getJson<DigestResult>(`/api/digests/${week}`);
|
||||
renderTiles(result);
|
||||
const meta = byId<HTMLElement>("digest-meta");
|
||||
meta.textContent = "";
|
||||
meta.append(...metaChips(result));
|
||||
renderSummaryCard(result);
|
||||
const body = byId<HTMLElement>("digest-body");
|
||||
// Paper titles/abstract prose come from external sources - sanitize the rendered HTML.
|
||||
body.innerHTML = DOMPurify.sanitize(marked.parse(result.markdown, { async: false }));
|
||||
for (const link of body.querySelectorAll("a")) {
|
||||
link.setAttribute("target", "_blank");
|
||||
link.setAttribute("rel", "noopener noreferrer");
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ chart section
|
||||
|
||||
function setupChart(stats: StatsResponse): void {
|
||||
const chartHost = byId<HTMLElement>("chart");
|
||||
const tableHost = byId<HTMLElement>("chart-table");
|
||||
const toggle = byId<HTMLButtonElement>("table-toggle");
|
||||
|
||||
if (stats.weeks.length === 0) {
|
||||
chartHost.append(el("p", "chart-empty", "다이제스트가 쌓이면 주차별 논문 수가 표시됩니다."));
|
||||
toggle.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
renderLineChart(chartHost, stats.weeks, stats.paper_counts, "주차별 수집 논문 수");
|
||||
|
||||
const table = el("table", "data-table");
|
||||
const head = el("tr");
|
||||
head.append(el("th", undefined, "주차"), el("th", undefined, "논문 수"));
|
||||
table.append(head);
|
||||
for (let i = stats.weeks.length - 1; i >= 0; i--) {
|
||||
const row = el("tr");
|
||||
const v = stats.paper_counts[i];
|
||||
row.append(
|
||||
el("td", undefined, stats.weeks[i] ?? ""),
|
||||
el("td", undefined, v === null || v === undefined ? "—" : formatValue(v)),
|
||||
);
|
||||
table.append(row);
|
||||
}
|
||||
tableHost.append(table);
|
||||
|
||||
toggle.addEventListener("click", () => {
|
||||
const showTable = tableHost.hidden;
|
||||
tableHost.hidden = !showTable;
|
||||
toggle.setAttribute("aria-pressed", String(showTable));
|
||||
toggle.textContent = showTable ? "표 숨기기" : "표로 보기";
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ errors + health
|
||||
|
||||
function renderErrors(errors: ErrorLine[]): void {
|
||||
if (errors.length === 0) return;
|
||||
const panel = byId<HTMLElement>("errors-panel");
|
||||
panel.hidden = false;
|
||||
byId<HTMLElement>("errors-count").textContent = String(errors.length);
|
||||
const list = byId<HTMLUListElement>("errors-list");
|
||||
for (const e of errors.slice(0, 30)) {
|
||||
const item = el("li");
|
||||
item.append(
|
||||
el("span", "err-ts", e.ts),
|
||||
el("span", "err-subject", e.subject),
|
||||
el("span", "err-detail", e.detail),
|
||||
);
|
||||
list.append(item);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ boot
|
||||
|
||||
async function main(): Promise<void> {
|
||||
startClock();
|
||||
const health = await getJson<HealthResponse>("/api/health");
|
||||
byId<HTMLElement>("health").textContent =
|
||||
health.digest_count > 0
|
||||
? `다이제스트 ${health.digest_count}건 · 최신 ${health.latest_week}`
|
||||
: "아직 쌓인 다이제스트가 없습니다";
|
||||
|
||||
const [summaries, stats, errors] = await Promise.all([
|
||||
getJson<DigestSummary[]>("/api/digests"),
|
||||
getJson<StatsResponse>("/api/stats"),
|
||||
getJson<ErrorLine[]>("/api/errors"),
|
||||
]);
|
||||
|
||||
renderErrors(errors);
|
||||
setupChart(stats);
|
||||
|
||||
if (summaries.length === 0) {
|
||||
byId<HTMLElement>("digest-body").append(
|
||||
el("p", "chart-empty", "다이제스트가 아직 없습니다. 다음 주 월요일 아침 첫 다이제스트가 생성됩니다."),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let selected = summaries[0]!.week;
|
||||
const select = async (week: string) => {
|
||||
selected = week;
|
||||
renderList(summaries, selected, (w) => void select(w));
|
||||
await showDigest(week);
|
||||
};
|
||||
await select(selected);
|
||||
}
|
||||
|
||||
void main().catch((err) => {
|
||||
console.error(err);
|
||||
byId<HTMLElement>("health").textContent = "대시보드 로드 실패 — 서버 로그를 확인하세요";
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
/** Read-only dashboard API + static file server.
|
||||
*
|
||||
* The single source of truth is the reports directory the sebrief daemon archives into
|
||||
* (reports/YYYY-Wnn.{md,json} + errors.log). Nothing here touches the SQLite DB, so the
|
||||
* dashboard container never contends with the daemon's WAL over virtiofs.
|
||||
*/
|
||||
|
||||
import { createServer, type ServerResponse } from "node:http";
|
||||
import { readFile, readdir, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import type {
|
||||
DigestResult,
|
||||
DigestSummary,
|
||||
ErrorLine,
|
||||
HealthResponse,
|
||||
StatsResponse,
|
||||
} from "./shared/types";
|
||||
|
||||
const REPORTS_DIR = process.env.SEBRIEF_REPORTS_DIR ?? "/data/reports";
|
||||
const PORT = Number(process.env.PORT ?? 8091);
|
||||
const PUBLIC_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "public");
|
||||
|
||||
const WEEK_RE = /^\d{4}-W\d{2}$/;
|
||||
const JSON_FILE_RE = /^\d{4}-W\d{2}\.json$/;
|
||||
|
||||
// ------------------------------------------------------------------ digest loading
|
||||
|
||||
/** mtime+size keyed cache so repeated requests do not re-parse unchanged files. */
|
||||
const cache = new Map<string, { mtimeMs: number; size: number; data: DigestResult }>();
|
||||
|
||||
async function loadDigest(week: string): Promise<DigestResult | null> {
|
||||
const file = path.join(REPORTS_DIR, `${week}.json`);
|
||||
let st;
|
||||
try {
|
||||
st = await stat(file);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const cached = cache.get(file);
|
||||
if (cached && cached.mtimeMs === st.mtimeMs && cached.size === st.size) return cached.data;
|
||||
try {
|
||||
const data = JSON.parse(await readFile(file, "utf8")) as DigestResult;
|
||||
cache.set(file, { mtimeMs: st.mtimeMs, size: st.size, data });
|
||||
return data;
|
||||
} catch (err) {
|
||||
console.error(`unparseable digest ${file}:`, err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function listWeeks(): Promise<string[]> {
|
||||
let names: string[];
|
||||
try {
|
||||
names = await readdir(REPORTS_DIR);
|
||||
} catch {
|
||||
return []; // directory not mounted yet / no digest produced yet
|
||||
}
|
||||
return names
|
||||
.filter((n) => JSON_FILE_RE.test(n))
|
||||
.map((n) => n.slice(0, 8))
|
||||
.sort()
|
||||
.reverse();
|
||||
}
|
||||
|
||||
function summarize(week: string, r: DigestResult): DigestSummary {
|
||||
return {
|
||||
week,
|
||||
week_start: r.payload.week_start,
|
||||
week_end: r.payload.week_end,
|
||||
generated_at: r.payload.generated_at,
|
||||
model_used: r.model_used,
|
||||
degraded: r.degraded,
|
||||
error: r.error,
|
||||
overview: r.digest?.overview ?? null,
|
||||
paper_count: r.payload.papers.length,
|
||||
highlight_count: r.digest?.highlights.length ?? 0,
|
||||
topic_count: r.digest?.by_topic.length ?? 0,
|
||||
verification_ok: r.verification?.ok ?? null,
|
||||
data_gaps: r.payload.data_gaps,
|
||||
};
|
||||
}
|
||||
|
||||
async function allDigests(): Promise<Array<{ week: string; result: DigestResult }>> {
|
||||
const out: Array<{ week: string; result: DigestResult }> = [];
|
||||
for (const week of await listWeeks()) {
|
||||
const result = await loadDigest(week);
|
||||
if (result) out.push({ week, result });
|
||||
}
|
||||
return out; // newest first (listWeeks is descending)
|
||||
}
|
||||
|
||||
async function buildStats(): Promise<StatsResponse> {
|
||||
const digests = (await allDigests()).reverse(); // ascending for the time series
|
||||
const weeks: string[] = [];
|
||||
const paper_counts: (number | null)[] = [];
|
||||
for (const { week, result } of digests) {
|
||||
if (weeks.includes(week)) continue;
|
||||
weeks.push(week);
|
||||
paper_counts.push(result.payload.papers.length);
|
||||
}
|
||||
return { weeks, paper_counts };
|
||||
}
|
||||
|
||||
async function readErrors(limit = 100): Promise<ErrorLine[]> {
|
||||
let text: string;
|
||||
try {
|
||||
text = await readFile(path.join(REPORTS_DIR, "errors.log"), "utf8");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const lines = text.split("\n").filter((l) => l.trim().length > 0);
|
||||
return lines
|
||||
.slice(-limit)
|
||||
.reverse()
|
||||
.map((line) => {
|
||||
const [ts = "", subject = "", ...rest] = line.split("\t");
|
||||
return { ts, subject, detail: rest.join("\t") };
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ http plumbing
|
||||
|
||||
function sendJson(res: ServerResponse, status: number, body: unknown): void {
|
||||
const payload = JSON.stringify(body);
|
||||
res.writeHead(status, {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"cache-control": "no-cache",
|
||||
});
|
||||
res.end(payload);
|
||||
}
|
||||
|
||||
const STATIC_TYPES: Record<string, string> = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".svg": "image/svg+xml",
|
||||
};
|
||||
|
||||
async function sendStatic(res: ServerResponse, urlPath: string): Promise<void> {
|
||||
const rel = urlPath === "/" ? "index.html" : urlPath.replace(/^\/+/, "");
|
||||
const file = path.resolve(PUBLIC_DIR, rel);
|
||||
if (!file.startsWith(PUBLIC_DIR + path.sep) && file !== path.join(PUBLIC_DIR, "index.html")) {
|
||||
sendJson(res, 404, { error: "not found" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const body = await readFile(file);
|
||||
res.writeHead(200, {
|
||||
"content-type": STATIC_TYPES[path.extname(file)] ?? "application/octet-stream",
|
||||
});
|
||||
res.end(body);
|
||||
} catch {
|
||||
sendJson(res, 404, { error: "not found" });
|
||||
}
|
||||
}
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
const url = new URL(req.url ?? "/", "http://localhost");
|
||||
const p = url.pathname;
|
||||
try {
|
||||
if (req.method !== "GET" && req.method !== "HEAD") {
|
||||
sendJson(res, 405, { error: "method not allowed" });
|
||||
return;
|
||||
}
|
||||
if (p === "/api/health") {
|
||||
const weeks = await listWeeks();
|
||||
const body: HealthResponse = {
|
||||
ok: true,
|
||||
digest_count: weeks.length,
|
||||
reports_dir: REPORTS_DIR,
|
||||
latest_week: weeks[0] ?? null,
|
||||
};
|
||||
sendJson(res, 200, body);
|
||||
return;
|
||||
}
|
||||
if (p === "/api/digests") {
|
||||
const digests = await allDigests();
|
||||
sendJson(res, 200, digests.map(({ week, result }) => summarize(week, result)));
|
||||
return;
|
||||
}
|
||||
const detail = p.match(/^\/api\/digests\/(\d{4}-W\d{2})$/);
|
||||
if (detail) {
|
||||
const week = detail[1]!;
|
||||
if (!WEEK_RE.test(week)) {
|
||||
sendJson(res, 400, { error: "bad week" });
|
||||
return;
|
||||
}
|
||||
const result = await loadDigest(week);
|
||||
if (!result) {
|
||||
sendJson(res, 404, { error: `no digest for ${week}` });
|
||||
return;
|
||||
}
|
||||
sendJson(res, 200, result);
|
||||
return;
|
||||
}
|
||||
if (p === "/api/stats") {
|
||||
sendJson(res, 200, await buildStats());
|
||||
return;
|
||||
}
|
||||
if (p === "/api/errors") {
|
||||
sendJson(res, 200, await readErrors());
|
||||
return;
|
||||
}
|
||||
if (p.startsWith("/api/")) {
|
||||
sendJson(res, 404, { error: "not found" });
|
||||
return;
|
||||
}
|
||||
await sendStatic(res, p);
|
||||
} catch (err) {
|
||||
console.error(`request ${p} failed:`, err);
|
||||
sendJson(res, 500, { error: "internal error" });
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(PORT, "0.0.0.0", () => {
|
||||
console.log(`sebrief dashboard listening on :${PORT} (reports: ${REPORTS_DIR})`);
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
/** Mirrors sebrief/models.py (pydantic model_dump_json → snake_case). */
|
||||
|
||||
export interface PaperSummary {
|
||||
tldr: string;
|
||||
problem: string;
|
||||
approach: string;
|
||||
findings: string;
|
||||
implication: string;
|
||||
}
|
||||
|
||||
export interface Paper {
|
||||
doi: string;
|
||||
title: string;
|
||||
abstract: string;
|
||||
authors: string[];
|
||||
venue: string;
|
||||
published_date: string | null;
|
||||
oa_url: string | null;
|
||||
pdf_url: string | null;
|
||||
topics: string[];
|
||||
source: string;
|
||||
/** Absent/null until the per-paper summarisation step has run for this DOI. */
|
||||
summary?: PaperSummary | null;
|
||||
}
|
||||
|
||||
export interface WeekPayload {
|
||||
week: string;
|
||||
week_start: string;
|
||||
week_end: string;
|
||||
generated_at: string;
|
||||
papers: Paper[];
|
||||
data_gaps: string[];
|
||||
}
|
||||
|
||||
export interface Highlight {
|
||||
doi: string;
|
||||
title: string;
|
||||
problem: string;
|
||||
approach: string;
|
||||
findings: string;
|
||||
implication: string;
|
||||
}
|
||||
|
||||
export interface TopicPaper {
|
||||
doi: string;
|
||||
title: string;
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface TopicGroup {
|
||||
topic: string;
|
||||
papers: TopicPaper[];
|
||||
}
|
||||
|
||||
export interface Pick {
|
||||
doi: string;
|
||||
title: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface Digest {
|
||||
overview: string;
|
||||
highlights: Highlight[];
|
||||
by_topic: TopicGroup[];
|
||||
worth_reading: Pick[];
|
||||
data_gaps: string[];
|
||||
}
|
||||
|
||||
export interface VerificationIssue {
|
||||
claim: string;
|
||||
problem: string;
|
||||
severity: "minor" | "major";
|
||||
}
|
||||
|
||||
export interface Verification {
|
||||
ok: boolean;
|
||||
issues: VerificationIssue[];
|
||||
}
|
||||
|
||||
export interface DigestResult {
|
||||
payload: WeekPayload;
|
||||
digest: Digest | null;
|
||||
markdown: string;
|
||||
model_used: string | null;
|
||||
degraded: boolean;
|
||||
error: string | null;
|
||||
/** Absent/null: verification not run (disabled, degraded, or the verifier failed). */
|
||||
verification?: Verification | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- API shapes
|
||||
|
||||
export interface DigestSummary {
|
||||
week: string;
|
||||
week_start: string;
|
||||
week_end: string;
|
||||
generated_at: string;
|
||||
model_used: string | null;
|
||||
degraded: boolean;
|
||||
error: string | null;
|
||||
overview: string | null;
|
||||
paper_count: number;
|
||||
highlight_count: number;
|
||||
topic_count: number;
|
||||
/** null: verification not run. */
|
||||
verification_ok: boolean | null;
|
||||
data_gaps: string[];
|
||||
}
|
||||
|
||||
export interface StatsResponse {
|
||||
/** ISO weeks, ascending. */
|
||||
weeks: string[];
|
||||
/** papers per week, aligned with `weeks`. */
|
||||
paper_counts: (number | null)[];
|
||||
}
|
||||
|
||||
export interface ErrorLine {
|
||||
ts: string;
|
||||
subject: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface HealthResponse {
|
||||
ok: boolean;
|
||||
digest_count: number;
|
||||
reports_dir: string;
|
||||
latest_week: string | null;
|
||||
}
|
||||
Reference in New Issue
Block a user