Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 52 additions & 34 deletions apps/web/app/(pages)/events/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ export default async function EventsPage() {
<Card>
<CardHeader
eyebrow="Event Explorer"
title="원문에서 클러스터까지"
description="원문 수집 시각, 게시 시각, 중복 제거 키, 신뢰도, 시장 반응, 에이전트 코멘터리를 한 흐름으로 추적합니다."
title="원문에서 클러스터와 반응까지"
description="수집 시각, 이벤트 점수, 미국 시장 반응, 에이전트 코멘터리를 한 화면에서 추적합니다."
/>
<div className="space-y-4">
{data.clusters.map((cluster) => (
Expand Down Expand Up @@ -45,43 +45,61 @@ export default async function EventsPage() {
<div className="mt-5 grid gap-4 lg:grid-cols-3">
<Card className="rounded-[18px] bg-[color:var(--surface-white)] p-4">
<p className="text-[14px] font-semibold">원문 출처</p>
<ul className="mt-3 space-y-2 text-[13px] leading-6 text-[color:var(--text-muted)]">
{cluster.sources.map((source) => (
<li key={source.url}>
<a
href={source.url}
target="_blank"
rel="noreferrer"
className="mistral-link font-medium text-white"
>
{source.name}
</a>
<span className="ml-2">수집 {source.fetchedAtLabel}</span>
</li>
))}
</ul>
{cluster.sources.length > 0 ? (
<ul className="mt-3 space-y-2 text-[13px] leading-6 text-[color:var(--text-muted)]">
{cluster.sources.map((source) => (
<li key={source.url}>
<a
href={source.url}
target="_blank"
rel="noreferrer"
className="mistral-link font-medium text-white"
>
{source.name}
</a>
<span className="ml-2">수집 {source.fetchedAtLabel}</span>
</li>
))}
</ul>
) : (
<p className="mt-3 text-[13px] leading-6 text-[color:var(--text-muted)]">
아직 연결된 원문 출처가 없습니다.
</p>
)}
</Card>
<Card className="rounded-[18px] bg-[color:var(--surface-white)] p-4">
<p className="text-[14px] font-semibold">미국 반응</p>
<ul className="mt-3 space-y-2 text-[13px] leading-6 text-[color:var(--text-muted)]">
{cluster.reactions.map((reaction) => (
<li key={reaction.label}>
{reaction.label}: {formatPct(reaction.movePct)} / {reaction.window}
</li>
))}
</ul>
<p className="text-[14px] font-semibold">미국 시장 반응</p>
{cluster.reactions.length > 0 ? (
<ul className="mt-3 space-y-2 text-[13px] leading-6 text-[color:var(--text-muted)]">
{cluster.reactions.map((reaction) => (
<li key={`${cluster.id}-${reaction.label}-${reaction.window}`}>
{reaction.label}: {formatPct(reaction.movePct)} / {reaction.window}
</li>
))}
</ul>
) : (
<p className="mt-3 text-[13px] leading-6 text-[color:var(--text-muted)]">
아직 시장 반응 데이터가 기록되지 않았습니다.
</p>
)}
</Card>
<Card className="rounded-[18px] bg-[color:var(--surface-white)] p-4">
<p className="text-[14px] font-semibold">에이전트 코멘터리</p>
<ul className="mt-3 space-y-2 text-[13px] leading-6 text-[color:var(--text-muted)]">
{cluster.agentNotes.map((note) => (
<li key={`${cluster.id}-${note.role}`}>
<span className="font-semibold text-[color:var(--text)]">{note.role}</span>
{" · "}
{note.note}
</li>
))}
</ul>
{cluster.agentNotes.length > 0 ? (
<ul className="mt-3 space-y-2 text-[13px] leading-6 text-[color:var(--text-muted)]">
{cluster.agentNotes.map((note) => (
<li key={`${cluster.id}-${note.role}`}>
<span className="font-semibold text-[color:var(--text)]">{note.role}</span>
{" : "}
{note.note}
</li>
))}
</ul>
) : (
<p className="mt-3 text-[13px] leading-6 text-[color:var(--text-muted)]">
아직 기록된 에이전트 코멘터리가 없습니다. 최신 분석을 한 번 더 실행해 주세요.
</p>
)}
</Card>
</div>
</div>
Expand Down
127 changes: 98 additions & 29 deletions services/api/app/repositories/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,24 @@
)
from app.services.replay.weekly_replay import WeeklyReplayService

ROLE_LABELS = {
"macro_analyst": "매크로 분석가",
"sector_analyst": "섹터 분석가",
"microstructure_analyst": "수급 분석가",
"skeptic_analyst": "리스크 분석가",
"korea_translator": "한국장 번역 분석가",
"final_judge": "최종 판단자",
}

ROLE_ORDER = {
"macro_analyst": 0,
"sector_analyst": 1,
"microstructure_analyst": 2,
"skeptic_analyst": 3,
"korea_translator": 4,
"final_judge": 5,
}


def _label_datetime(value: datetime | None) -> str:
if value is None:
Expand Down Expand Up @@ -101,7 +119,7 @@ async def fetch_dashboard(session: AsyncSession) -> DashboardResponse:
confidence=theme.confidence,
tradabilityScore=theme.tradability_score,
gapFadeRisk=theme.gap_fade_risk,
marketView="다음 영업일 시가 및 거래대금 확인 필요",
marketView="다음 영업일 시가와 거래대금 확인이 필요합니다.",
)
for theme in theme_predictions
]
Expand All @@ -126,7 +144,7 @@ async def fetch_dashboard(session: AsyncSession) -> DashboardResponse:
{
"label": item.symbol,
"movePct": item.move_pct,
"commentary": f"{item.window_label} 구간에서 {item.asset_class} 반응 확인",
"commentary": f"{item.window_label} 구간에서 {item.asset_class} 반응을 확인했습니다.",
}
for item in reactions
],
Expand All @@ -147,30 +165,41 @@ async def fetch_dashboard(session: AsyncSession) -> DashboardResponse:
themeBoard=theme_board,
leaderBoard=leader_board,
riskFlags=[
"시가 과열 추격 금지",
"미국 반응 미확인 이벤트는 우선순위 하향",
"정책성 이벤트는 후속 반응 동반 확인",
"시가 과열 추격은 피하고 거래대금 확인을 우선하세요.",
"미국 반응이 약한 이벤트는 우선순위를 낮게 보세요.",
"정책성 이벤트는 후속 기사와 선물 움직임을 함께 확인하세요.",
],
topTheme=theme_board[0] if theme_board else None,
topLeader=leader_board[0] if leader_board else None,
)


async def fetch_event_explorer(session: AsyncSession) -> EventExplorerResponse:
clusters = (
await session.execute(select(EventCluster).order_by(EventCluster.last_seen_at.desc()).limit(20))
).scalars().all()
latest_prediction_run_id = await _latest_prediction_run_id(session)
agent_runs = await _latest_agent_runs(session, latest_prediction_run_id)
analyzed_cluster_keys = sorted(
{
run.output_json.get("cluster_key")
for run in agent_runs
if isinstance(run.output_json, dict) and run.output_json.get("cluster_key")
}
)

cluster_statement = select(EventCluster).order_by(EventCluster.last_seen_at.desc())
if analyzed_cluster_keys:
cluster_statement = cluster_statement.where(EventCluster.cluster_key.in_(analyzed_cluster_keys))

clusters = (await session.execute(cluster_statement.limit(20))).scalars().all()
payload: list[EventClusterResponse] = []

for cluster in clusters:
sources = (
await session.execute(select(IngestedDocument).where(IngestedDocument.event_cluster_id == cluster.id))
).scalars().all()
reactions = (
await session.execute(select(MarketReaction).where(MarketReaction.cluster_id == cluster.id))
).scalars().all()
notes = (
await session.execute(select(AgentRun).order_by(AgentRun.created_at.desc()).limit(4))
).scalars().all()

payload.append(
EventClusterResponse(
id=str(cluster.id),
Expand Down Expand Up @@ -198,15 +227,10 @@ async def fetch_event_explorer(session: AsyncSession) -> EventExplorerResponse:
}
for reaction in reactions
],
agentNotes=[
{
"role": note.role,
"note": note.output_json.get("thesis", "에이전트 결과 없음"),
}
for note in notes
],
agentNotes=_agent_notes_for_cluster(agent_runs, cluster.cluster_key),
)
)

return EventExplorerResponse(clusters=payload)


Expand Down Expand Up @@ -253,24 +277,23 @@ async def fetch_replay(session: AsyncSession, date_label: str) -> ReplayResponse
prompt_version = await session.scalar(
select(PromptVersion.version).order_by(PromptVersion.created_at.desc()).limit(1)
)
latest_prediction_run_id = await _latest_prediction_run_id(session)
clusters = (
await session.execute(select(EventCluster).order_by(EventCluster.last_seen_at.desc()).limit(5))
).scalars().all()
notes = (
await session.execute(select(AgentRun).order_by(AgentRun.created_at.desc()).limit(6))
).scalars().all()
notes = await _latest_agent_runs(session, latest_prediction_run_id, limit=6)
return ReplayResponse(
asOfLabel=date_label if date_label != "latest" else _label_datetime(datetime.now(UTC)),
promptVersion=prompt_version or "baseline-v1",
evidencePackHash="latest-evidence-pack",
predictionSummary="미국 검증 이벤트를 기준으로 다음 한국장 테마를 재구성합니다.",
actualSummary="실제 결과는 차후 리플레이 데이터 적재 후 연결됩니다.",
outcomeSummary="현재는 초기 상태라 리플레이 기록이 제한적입니다.",
predictionSummary="미국 검증 이벤트를 기반으로 다음 한국장 테마와 주도주를 재구성했습니다.",
actualSummary="실제 결과는 같은 페이지의 리플레이/평가 데이터와 함께 비교합니다.",
outcomeSummary="불확실성은 에이전트별 주장과 확신도 차이로 확인할 수 있습니다.",
evidenceItems=[{"title": cluster.title, "summary": cluster.summary[:200]} for cluster in clusters],
agentDebate=[
{
"role": note.role,
"thesis": note.output_json.get("thesis", "기록 없음"),
"role": ROLE_LABELS.get(note.role, note.role),
"thesis": _extract_agent_note(note.output_json),
"confidence": float(note.output_json.get("confidence", 0.4)),
}
for note in notes
Expand Down Expand Up @@ -320,7 +343,7 @@ async def fetch_evaluation_summary(session: AsyncSession) -> EvaluationSummaryRe
promptLeaderboard=[
{
"version": "baseline-v1",
"description": "규칙 기반 번역 + 멀티 에이전트 기본 조합",
"description": "규칙 기반 번역과 멀티 에이전트 판단을 결합한 기본 설정입니다.",
"score": 0.62,
"themeHitRate": theme_hit_rate,
"leaderHitRate": leader_hit_rate,
Expand Down Expand Up @@ -389,19 +412,47 @@ def _summarize_job_result(run: JobRun) -> str | None:
if ingestion and analysis:
return (
f"문서 {ingestion.get('inserted', 0)}건 수집, "
f"클러스터 {analysis.get('created_clusters', 0)}건, "
f"클러스터 {analysis.get('created_clusters', 0)}건 "
f"테마 {analysis.get('created_themes', 0)}건 생성"
)
if "inserted" in run.result_json:
return f"문서 {run.result_json.get('inserted', 0)}건 수집"
if "created_clusters" in run.result_json:
return (
f"클러스터 {run.result_json.get('created_clusters', 0)}건, "
f"클러스터 {run.result_json.get('created_clusters', 0)}건 "
f"테마 {run.result_json.get('created_themes', 0)}건 생성"
)
return None


def _extract_agent_note(output_json: dict) -> str:
for key in ("thesis", "summary", "decision", "rationale"):
value = output_json.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
return "의견이 아직 기록되지 않았습니다."


def _agent_notes_for_cluster(agent_runs: list[AgentRun], cluster_key: str) -> list[dict[str, str]]:
cluster_runs = [
run
for run in agent_runs
if isinstance(run.output_json, dict) and run.output_json.get("cluster_key") == cluster_key
]
cluster_runs.sort(key=lambda run: (ROLE_ORDER.get(run.role, 99), run.created_at))

notes_by_role: dict[str, dict[str, str]] = {}
for run in cluster_runs:
if run.role in notes_by_role:
continue
notes_by_role[run.role] = {
"role": ROLE_LABELS.get(run.role, run.role),
"note": _extract_agent_note(run.output_json),
}

return list(notes_by_role.values())


async def _latest_prediction_run_id(session: AsyncSession) -> UUID | None:
return await session.scalar(
select(PredictionRun.id)
Expand Down Expand Up @@ -454,3 +505,21 @@ async def _latest_stock_candidates(
deduped[stock.ticker] = stock

return sorted(deduped.values(), key=lambda item: (item.tier != "leader", -item.score))[:limit]


async def _latest_agent_runs(
session: AsyncSession,
prediction_run_id: UUID | None,
limit: int = 40,
) -> list[AgentRun]:
if prediction_run_id is None:
return []

return (
await session.execute(
select(AgentRun)
.where(AgentRun.prediction_run_id == prediction_run_id)
.order_by(AgentRun.created_at.desc())
.limit(limit)
)
).scalars().all()
12 changes: 7 additions & 5 deletions services/api/app/services/agents/openai_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,10 @@ async def run_role(
role=role,
model=model,
content={
"thesis": f"{role} 로컬 대체 분석: API 키가 없어 결정론적 규칙 기반 결과를 반환합니다.",
"thesis": (
f"{role} 로컬 대체 분석: API 키가 없어 "
"결정론적 규칙 기반 결과를 반환합니다."
),
"confidence": 0.45,
"evidence": evidence_pack,
"input_hash": input_hash,
Expand All @@ -72,9 +75,9 @@ async def run_role(
{
"type": "input_text",
"text": (
"당신은 개인용 크로스마켓 리서치 시스템의 전문 분석가다. "
"증거가 부족하면 확신을 낮추고 불확실성을 명시한다. "
"반드시 JSON 문자열만 반환한다."
"당신은 개인용 크로스마켓 리서치 시스템의 전문 분석가입니다. "
"증거가 부족하면 확신을 낮추고 불확실성을 명시하세요. "
"반드시 JSON 객체만 반환하세요."
),
}
],
Expand Down Expand Up @@ -115,4 +118,3 @@ async def run_role(
cached_input_tokens=cached_tokens,
cost_usd=0.0,
)

Loading