로또 종합 추론 번호 추천 기능 추가

5가지 통계 기법(빈도Z-score·조합지문·갭분석·공동출현·다양성)을
기법별 가중치(30/25/20/15/10%)로 투표 집계하여 최적 6개 번호 도출.
- 기법별 추천 번호 시각화 (최종 번호 하이라이트)
- 투표 참여 기법 수 점 표시 (최대 5개)
- 조합 품질 점수 5차원 바 차트
- 추천 이력 히스토리 누적 저장

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-25 08:40:35 +09:00
parent 22573909ec
commit 8fcfb6b000
3 changed files with 502 additions and 0 deletions

View File

@@ -5,6 +5,7 @@ import {
getPerformanceStats, getLatestReport, getReportHistory,
getPersonalAnalysis, getPurchases, getPurchaseStats,
addPurchase, updatePurchase, deletePurchase,
getCombinedRecommend, getCombinedHistory,
} from '../../api';
/* ─────────────────────────────────────────────
@@ -286,6 +287,179 @@ const ConfidenceRing = ({ score }) => {
);
};
/* ─────────────────────────────────────────────
종합 추론 추천 패널
───────────────────────────────────────────── */
const METHOD_META = {
frequency: { label: '빈도 Z-score', desc: '역대 출현 빈도가 기댓값보다 높은 번호', color: '#818cf8', icon: '📊' },
fingerprint: { label: '조합 지문', desc: '역대 당첨 조합의 합계·홀짝·구간 분포에 맞는 번호', color: '#fbbf24', icon: '🔏' },
gap: { label: '갭 분석', desc: '가장 오래 등장하지 않은 오버듀 번호', color: '#34d399', icon: '⏳' },
cooccur: { label: '공동 출현', desc: '역대에 함께 출현한 빈도가 높은 번호', color: '#f472b6', icon: '🔗' },
diversity: { label: '다양성', desc: '구간 커버리지와 번호 범위를 극대화한 번호', color: '#fb923c', icon: '🌈' },
};
const METHOD_ORDER = ['fingerprint', 'frequency', 'gap', 'cooccur', 'diversity'];
const SCORE_META = [
{ key: 'score_fingerprint', label: '조합 지문', color: '#fbbf24', weight: 30 },
{ key: 'score_frequency', label: '빈도 Z', color: '#818cf8', weight: 25 },
{ key: 'score_gap', label: '갭 분석', color: '#34d399', weight: 20 },
{ key: 'score_cooccur', label: '공동 출현', color: '#f472b6', weight: 15 },
{ key: 'score_diversity', label: '다양성', color: '#fb923c', weight: 10 },
];
const CombinedRecommendPanel = ({ combined, history, loading, histLoading, onRun, onCopy }) => {
const [histExpand, setHistExpand] = useState(false);
return (
<section className="lotto-panel lotto-panel--wide lotto-combined">
<div className="lotto-panel__head">
<div>
<p className="lotto-panel__eyebrow">AI · 종합 추론</p>
<h3>종합 추론 번호 추천</h3>
<p className="lotto-panel__sub">
5가지 통계 기법(빈도·지문··공동출현·다양성) 가중 투표로 합산해
최적 6 번호를 도출합니다.
</p>
</div>
<div className="lotto-panel__actions">
{loading && <span className="lotto-chip">분석 </span>}
<button className="button primary small" onClick={onRun} disabled={loading}>
{loading ? '추론 중…' : '🔮 종합 추론 실행'}
</button>
{history.length > 0 && (
<button className="button ghost small" onClick={() => setHistExpand(p => !p)}>
이력 {history.length} {histExpand ? '▲' : '▼'}
</button>
)}
</div>
</div>
{!combined && !loading && (
<p className="lotto-empty">버튼을 눌러 종합 추론을 실행하세요.</p>
)}
{combined && (
<>
{/* 기법별 추천 번호 */}
<div className="lotto-combined__methods">
{METHOD_ORDER.map((key) => {
const meta = METHOD_META[key];
const m = combined.methods?.[key];
if (!m) return null;
return (
<div key={key} className="lotto-combined__method">
<div className="lotto-combined__method-head">
<span className="lotto-combined__method-icon">{meta.icon}</span>
<div>
<p className="lotto-combined__method-name" style={{ color: meta.color }}>
{meta.label}
<span className="lotto-combined__method-weight"> ({m.weight_pct}%)</span>
</p>
<p className="lotto-combined__method-desc">{meta.desc}</p>
</div>
</div>
<div className="lotto-combined__method-nums">
{m.numbers.map((n) => {
const inFinal = combined.final_numbers.includes(n);
return (
<span
key={n}
className={`lotto-ball ${ballClass(n).replace('lotto-ball ', '')} ${inFinal ? 'is-final' : 'is-dim'}`}
>
{n}
</span>
);
})}
</div>
</div>
);
})}
</div>
{/* 최종 추론 결과 */}
<div className="lotto-combined__final">
<div className="lotto-combined__final-head">
<span className="lotto-combined__final-badge">종합 추론 결과</span>
{combined.deduped && (
<span className="lotto-chip lotto-chip--muted">중복 (이미 저장됨)</span>
)}
<button className="button ghost small" onClick={() => onCopy(combined.final_numbers)}>
복사
</button>
</div>
<div className="lotto-combined__final-balls">
{combined.final_numbers.map((n) => {
const votes = combined.vote_counts?.[String(n)] ?? 0;
return (
<div key={n} className="lotto-combined__final-ball-wrap">
<span className={ballClass(n)}>{n}</span>
<span className="lotto-combined__vote-dots">
{Array.from({ length: 5 }).map((_, i) => (
<span key={i} className={`lotto-combined__vote-dot ${i < votes ? 'is-on' : ''}`} />
))}
</span>
</div>
);
})}
</div>
<p className="lotto-combined__final-sub">
점은 해당 번호가 채택된 기법 (최대 5)
</p>
</div>
{/* 점수 바 */}
<div className="lotto-combined__scores">
<p className="lotto-combined__scores-title">조합 품질 점수</p>
{SCORE_META.map(({ key, label, color, weight }) => {
const val = combined.scores?.[key] ?? 0;
const pct = Math.round(val * 100);
return (
<div key={key} className="lotto-combined__score-row">
<span className="lotto-combined__score-label">{label}</span>
<span className="lotto-combined__score-weight">{weight}%</span>
<div className="lotto-combined__score-bar-wrap">
<div
className="lotto-combined__score-bar"
style={{ width: `${pct}%`, background: color }}
/>
</div>
<span className="lotto-combined__score-val">{pct}</span>
</div>
);
})}
<div className="lotto-combined__score-total">
종합 점수 <strong>{Math.round((combined.scores?.score_total ?? 0) * 100)}</strong> / 100
</div>
</div>
<p className="lotto-combined__disclaimer">
추천은 역대 통계 패턴 기반 참고 자료이며, 당첨을 보장하지 않습니다.
</p>
</>
)}
{/* 추천 이력 */}
{histExpand && (
<div className="lotto-combined__history">
<p className="lotto-combined__history-title">종합 추론 이력</p>
{histLoading && <p className="lotto-empty">로딩 </p>}
{history.map((item) => (
<div key={item.id} className="lotto-combined__history-item">
<div className="lotto-combined__history-meta">
<span>#{item.id}</span>
<span>{fmtKST(item.created_at)}</span>
<span>기준 {item.based_on_draw ?? '-'}</span>
</div>
<NumberRow nums={item.numbers} />
<button className="button ghost small" onClick={() => onCopy(item.numbers)}>복사</button>
</div>
))}
</div>
)}
</section>
);
};
/* 공략 리포트 패널 */
const ReportPanel = ({ report, history, loading, onRefresh, onSelectDrw }) => {
const [histExpand, setHistExpand] = useState(false);
@@ -710,6 +884,11 @@ export default function Functions() {
const [simResult, setSimResult] = useState(null);
// ── 신규 상태 ──────────────────────────────────────────────────────────────
const [combined, setCombined] = useState(null);
const [combinedLoading, setCombinedLoading] = useState(false);
const [combinedHistory, setCombinedHistory] = useState([]);
const [combinedHistLoading, setCombinedHistLoading] = useState(false);
const [perfStats, setPerfStats] = useState(null);
const [report, setReport] = useState(null);
const [reportHistory, setReportHistory] = useState([]);
@@ -811,6 +990,27 @@ export default function Functions() {
finally { setReportLoading(false); }
};
const runCombinedRecommend = async () => {
setCombinedLoading(true);
try {
const data = await getCombinedRecommend();
setCombined(data);
// 이력도 새로고침
const hist = await getCombinedHistory(30);
setCombinedHistory(hist?.items ?? []);
} catch (e) { setError(e?.message ?? String(e)); }
finally { setCombinedLoading(false); }
};
const loadCombinedHistory = async () => {
setCombinedHistLoading(true);
try {
const hist = await getCombinedHistory(30);
setCombinedHistory(hist?.items ?? []);
} catch {}
finally { setCombinedHistLoading(false); }
};
const refreshPersonalAnalysis = async () => {
setPersonalLoading(true);
try { setPersonalAnalysis(await getPersonalAnalysis()); }
@@ -943,6 +1143,7 @@ export default function Functions() {
refreshReport();
refreshPersonalAnalysis();
refreshPurchases();
loadCombinedHistory();
}, []);
useEffect(() => {
@@ -970,6 +1171,16 @@ export default function Functions() {
{/* ── 신뢰도 배너 ── */}
<PerformanceBanner perf={perfStats} />
{/* ── 종합 추론 번호 추천 ── */}
<CombinedRecommendPanel
combined={combined}
history={combinedHistory}
loading={combinedLoading}
histLoading={combinedHistLoading}
onRun={runCombinedRecommend}
onCopy={copyNumbers}
/>
{/* ── 최신 회차 + 시뮬레이션 추천 ── */}
<div className="lotto-grid">
{/* Latest Draw */}