Skip to content
This repository was archived by the owner on Sep 24, 2026. It is now read-only.
Merged
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
9 changes: 8 additions & 1 deletion scripts/backtest-risk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -830,6 +830,9 @@ async function predict(report: WeeklyReport, prev: WeeklyReport | null, priorYea
observationAgeMinutes: latestObservation
? Math.max(0, Math.round((decisionAt.getTime() - latestObservation.observedAt.getTime()) / 60_000))
: null,
// 백테스트에는 관측소 거리 기록이 없다. null 이면 거리 벌점이 붙지 않아
// **과거 신뢰도를 실제보다 후하게** 본다 — 지표를 비교할 때 이 차이를 기억한다.
observationDistanceKm: null,
};

// ===== 프로덕션 도메인 코드 (재구현 없음) =====
Expand All @@ -839,7 +842,11 @@ async function predict(report: WeeklyReport, prev: WeeklyReport | null, priorYea
const variables = evaluateRiskVariables(bundle, ruleScore);
const reportWeights = evaluateReportWeights(bundle.verifiedReports, ruleScore);
const minLevelTriggers = deriveMinLevelTriggers(bundle.verifiedReports);
const confidence = deriveConfidence(variables.missing.length, bundle.observationAgeMinutes);
const confidence = deriveConfidence(
variables.missing,
bundle.observationAgeMinutes,
bundle.observationDistanceKm,
);
const result = RiskEngine.calculate({
variables: applyHorizon(variables.factors, 'now'),
reportWeights: applyHorizon(reportWeights, 'now'),
Expand Down
42 changes: 40 additions & 2 deletions src/contexts/risk/adapter/in/web/dto/beach-risk.response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,11 +169,30 @@ export class PublicRiskPointResponse {

@ApiProperty({
example: 'medium',
description: '데이터 신뢰도. 먼 시점일수록 예측 불확실성으로 한 단계씩 낮아진다.',
description: [
'**관측 자료 상태**(예측 정확도가 아니다).',
'',
'⚠️ 이름이 confidence 라서 "이 예측을 믿어도 된다" 로 읽히기 쉬운데 **그런 뜻이 아니다.**',
'이 값이 답하는 질문은 하나다 — 이 판정을 뒷받침할 **관측 자료가 충분한가.** 입력이',
'완벽해도 룰이 틀렸으면 예측은 틀리고, 이 값은 그것을 보지 않는다.',
'',
'**무엇을 보나** — 무엇이 비었나(수온 결측이 가장 무겁다) · 얼마나 오래됐나(3시간) ·',
'관측소가 얼마나 먼가(10km 초과 한 단계, 30km 초과 두 단계).',
'먼 시점일수록 예측 불확실성으로 한 단계씩 더 낮아진다.',
'',
'시민 화면에는 `dataConfidenceLabel` 을 쓴다 — 자료 이야기만 하는 문구다.',
].join(' '),
enum: ['high', 'medium', 'low'],
})
dataConfidence!: string;

@ApiProperty({
example: '관측 자료 일부 없음',
description:
'시민에게 보여줄 관측 자료 상태 문구(요청 언어로). "신뢰도" 라는 말을 쓰지 않는 이유는 dataConfidence 설명 참고.',
})
dataConfidenceLabel!: string;

@ApiProperty({ example: '2026-07-10T09:00:00.000Z', description: '산출 생성 일시' })
generatedAt!: string;

Expand Down Expand Up @@ -231,11 +250,30 @@ export class PublicBeachRiskResponse {

@ApiProperty({
example: 'high',
description: '데이터 신뢰도 — 현재 시점',
description: [
'**관측 자료 상태**(예측 정확도가 아니다).',
'',
'⚠️ 이름이 confidence 라서 "이 예측을 믿어도 된다" 로 읽히기 쉬운데 **그런 뜻이 아니다.**',
'이 값이 답하는 질문은 하나다 — 이 판정을 뒷받침할 **관측 자료가 충분한가.** 입력이',
'완벽해도 룰이 틀렸으면 예측은 틀리고, 이 값은 그것을 보지 않는다.',
'',
'**무엇을 보나** — 무엇이 비었나(수온 결측이 가장 무겁다) · 얼마나 오래됐나(3시간) ·',
'관측소가 얼마나 먼가(10km 초과 한 단계, 30km 초과 두 단계).',
'먼 시점일수록 예측 불확실성으로 한 단계씩 더 낮아진다.',
'',
'시민 화면에는 `dataConfidenceLabel` 을 쓴다 — 자료 이야기만 하는 문구다.',
].join(' '),
enum: ['high', 'medium', 'low'],
})
dataConfidence!: string;

@ApiProperty({
example: '관측 자료 일부 없음',
description:
'시민에게 보여줄 관측 자료 상태 문구(요청 언어로). "신뢰도" 라는 말을 쓰지 않는 이유는 dataConfidence 설명 참고.',
})
dataConfidenceLabel!: string;

@ApiProperty({
example: '2026-07-10T09:00:00.000Z',
description: '산출 생성 일시 — 현재 시점',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,11 @@ export class RiskInputKyselyQuery implements RiskInputPort {
? Math.max(0, Math.round((now - latestObservation.observedAt.getTime()) / 60000))
: null;

// 거리는 **해양** 관측소 것만 쓴다. 수온·파고·해류가 거기서 오고, 기상 관측소는 제주
// 전체에 둘뿐이라 거리로 벌점을 주면 모든 해변이 함께 내려갈 뿐이다(고칠 방법이 없다).
// 대표가 낡아 다른 해양관측소가 선택됐다면 **그 관측소의 거리**여야 한다.
const observationDistanceKm = marineRow?.distanceKm ?? null;

// 7일 평균 수온
const avgRow = await this.db
.selectFrom('observations as o')
Expand Down Expand Up @@ -239,6 +244,7 @@ export class RiskInputKyselyQuery implements RiskInputPort {
pastOccurrenceCount,
verifiedReports,
observationAgeMinutes,
observationDistanceKm,
forecasts,
};
}
Expand Down Expand Up @@ -416,6 +422,7 @@ export class RiskInputKyselyQuery implements RiskInputPort {
.where('m.beach_id', '=', beachId)
.where('m.station_type', '=', stationType)
.select([
'm.distance_km as distanceKm',
'o.observed_at as observedAt',
'o.water_temp as waterTemp',
'o.wave_height as waveHeight',
Expand All @@ -438,6 +445,7 @@ export class RiskInputKyselyQuery implements RiskInputPort {

return row
? {
distanceKm: numOrNull(row.distanceKm),
observedAt: new Date(row.observedAt),
waterTemp: numOrNull(row.waterTemp),
waveHeight: numOrNull(row.waveHeight),
Expand All @@ -464,7 +472,13 @@ const STALE_OBSERVATION_HOURS = 24;
type StationType = 'marine' | 'weather';

/** 관측 한 행 (유형별 최신본). */
type ObservationRow = ObservationInput;
/**
* 관측 한 행 + **그 값을 준 관측소까지의 거리.**
*
* 거리는 도메인 입력(ObservationInput)에 넣지 않는다 — 위험 요인 평가는 거리를 쓰지 않고,
* 신뢰도 판정만 쓴다. 평가 입력에 섞으면 룰이 거리를 보는 것처럼 읽힌다.
*/
type ObservationRow = ObservationInput & { distanceKm: number | null };

/** a ?? b — 유형별 담당 컬럼을 우선하되, 비면 다른 유형 행으로 보완한다. */
function pick(primary: number | null, fallback: number | null): number | null {
Expand Down
16 changes: 16 additions & 0 deletions src/contexts/risk/application/port/in/risk-use-cases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,14 @@ export interface PublicRiskPointView {
riskScore: number;
factors: PublicRiskFactorView[]; // 요약 원인 3~5개
dataConfidence: DataConfidence;
/**
* 시민에게 보여줄 **관측 자료 상태** 문구.
*
* ⚠️ `dataConfidence` 를 "신뢰도 높음" 으로 옮기면 *"이 예측을 믿어도 된다"* 로 읽힌다.
* 그건 측정한 적 없는 주장이다(해변별 정확도 표본 0건). 이 값이 답하는 질문은
* **"이 판정을 뒷받침할 관측 자료가 충분한가"** 뿐이라, 라벨도 자료 이야기만 한다.
*/
dataConfidenceLabel: string;
generatedAt: Date;
/**
* 이 지평의 단계가 운영자 수동 상향으로 올라간 것인가.
Expand Down Expand Up @@ -121,6 +129,14 @@ export interface PublicBeachRiskView {
factors: PublicRiskFactorView[]; // 요약 원인 3~5개
guideText: string;
dataConfidence: DataConfidence;
/**
* 시민에게 보여줄 **관측 자료 상태** 문구.
*
* ⚠️ `dataConfidence` 를 "신뢰도 높음" 으로 옮기면 *"이 예측을 믿어도 된다"* 로 읽힌다.
* 그건 측정한 적 없는 주장이다(해변별 정확도 표본 0건). 이 값이 답하는 질문은
* **"이 판정을 뒷받침할 관측 자료가 충분한가"** 뿐이라, 라벨도 자료 이야기만 한다.
*/
dataConfidenceLabel: string;
generatedAt: Date | null;
/**
* 이 단계가 **운영자가 손으로 올린 것**인가.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -220,8 +220,10 @@ export class CalculateRiskService implements CalculateRiskUseCase {
...deriveNearbyMinTriggers(bundle.nearbyAlert),
];
const baseConfidence = deriveConfidence(
variables.missing.length,
// 개수가 아니라 **코드**를 넘긴다 — 수온 결측은 다른 것보다 무겁다.
variables.missing,
bundle.observationAgeMinutes,
bundle.observationDistanceKm,
);

// 지평은 순차로 둔다. 같은 해변의 세 지평이 동시에 돌 이유가 없고(양이 적다),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
import { dataConfidenceLabelOf } from '../../domain/data-confidence-label';
import { Id } from '@shared/kernel/id';
import { NotFoundError } from '@shared/kernel/domain-error';
import { RiskHorizon, riskLevelLabelOf } from '@shared/kernel/risk-level';
Expand Down Expand Up @@ -100,6 +101,7 @@ export class GetBeachRiskDetailService implements GetBeachRiskDetailUseCase {
factors: [],
guideText: buildSafetyGuide('safe', locale),
dataConfidence: 'low',
dataConfidenceLabel: dataConfidenceLabelOf('low', locale),
generatedAt: null,
manuallyRaised: false,
riskTimeline: [],
Expand All @@ -116,6 +118,7 @@ export class GetBeachRiskDetailService implements GetBeachRiskDetailUseCase {
factors: primary.factors,
guideText: buildSafetyGuide(primary.riskLevel, locale),
dataConfidence: primary.dataConfidence,
dataConfidenceLabel: dataConfidenceLabelOf(primary.dataConfidence, locale),
generatedAt: primary.generatedAt,
// 최소 단계 보장이 걸렸고 그 근거가 **사람**일 때만 true. 제보·인근 출현 기반 보장
// (RISK-002)은 여전히 시스템 판단이므로 여기서 구분한다.
Expand Down Expand Up @@ -160,6 +163,7 @@ export class GetBeachRiskDetailService implements GetBeachRiskDetailUseCase {
scoreDelta: f.delta,
})),
dataConfidence: card.confidence,
dataConfidenceLabel: dataConfidenceLabelOf(card.confidence, locale),
generatedAt: card.generatedAt,
manuallyRaised: card.minLevelRuleCode === MANUAL_OVERRIDE_RULE_CODE,
});
Expand Down
49 changes: 49 additions & 0 deletions src/contexts/risk/domain/data-confidence-label.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { DataConfidence } from '@shared/kernel/risk-level';
import { Locale, LocaleText, text } from '@shared/i18n/locale';

/**
* 관측 자료 상태 라벨 (시민 화면용).
*
* ── ⚠️ 왜 '신뢰도' 라고 부르지 않나 ─────────────────────────────────────────────────
* 값 자체는 `high | medium | low` 이고 API 필드 이름도 `dataConfidence` 다. 그런데 그것을
* 시민에게 **"신뢰도 높음"** 으로 보여주면 *"이 예측을 믿어도 된다"* 로 읽힌다.
*
* 그건 우리가 **측정한 적 없는 주장**이다. 해변별 예측 정확도 표본은 현재 0건이고
* (docs/backtest.md, #83), 전체 정확도조차 시군구 단위 정답으로 거칠게 잰 값이다.
*
* deriveConfidence 가 실제로 답하는 질문은 하나다 — **"이 판정을 뒷받침할 관측 자료가
* 충분한가."** 입력이 완벽해도 룰이 틀렸으면 예측은 틀린다. 이 값은 그것을 보지 않는다.
*
* 이 서비스에서는 그 차이가 위험한 방향으로 어긋난다. `안전` + `신뢰도 높음` 을 본 사람은
* 물에 들어간다. 그런데 그 "높음" 이 실제로 보장하는 것은 **10km 안의 부이에서 3시간 안에
* 값이 다 들어왔다** 는 것뿐이다.
*
* 그래서 라벨은 **자료 이야기만** 한다. 판단은 위험 단계가 한다.
*/
const LABELS: Record<DataConfidence, LocaleText> = {
high: {
ko: '관측 자료 충분',
en: 'Full observation data',
zh: '观测数据充足',
ja: '観測データ十分',
},
medium: {
ko: '관측 자료 일부 없음',
en: 'Some observation data missing',
zh: '部分观测数据缺失',
ja: '観測データ一部なし',
},
low: {
// '부족' 이라고만 하면 "조금 모자라다" 로 읽힌다. low 는 수온을 한 값도 못 받았거나
// 관측이 하루 넘게 끊긴 상태라, 판정의 근거가 거의 없다는 뜻이다.
ko: '관측 자료 많이 부족',
en: 'Observation data largely unavailable',
zh: '观测数据严重不足',
ja: '観測データ大幅に不足',
},
};

/** 시민에게 보여줄 관측 자료 상태 문구. */
export function dataConfidenceLabelOf(confidence: DataConfidence, locale: Locale): string {
return text(LABELS[confidence], locale);
}
112 changes: 112 additions & 0 deletions src/contexts/risk/domain/data-confidence.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { THRESHOLDS, deriveConfidence } from './risk-assessment';
import { dataConfidenceLabelOf } from './data-confidence-label';

/**
* 관측 자료 상태 판정.
*
* ── 이 판정이 답하는 것과 답하지 않는 것 ─────────────────────────────────────────────
* 답하는 것: **이 판정을 뒷받침할 관측 자료가 충분한가.**
* 답하지 않는 것: **예측이 맞는가.** 입력이 완벽해도 룰이 틀렸으면 예측은 틀린다.
*
* 그 차이를 흐리면 이 서비스에서 위험한 방향으로 어긋난다 — `안전` + `높음` 을 본 사람은
* 물에 들어간다.
*/
describe('deriveConfidence', () => {
const FRESH = 10;
const NEAR = 3;

it('다 있고 신선하고 가까우면 high', () => {
expect(deriveConfidence([], FRESH, NEAR)).toBe('high');
});

it('관측이 아예 없으면 low', () => {
expect(deriveConfidence([], null, null)).toBe('low');
});

describe('⚠️ 결측은 개수가 아니라 무게로 센다', () => {
/**
* 이 블록이 **실제 결함**을 고정한다.
*
* 예전에는 개수만 셌고 low 기준이 3이었다. 그런데 수온이 없으면 TEMP_UP 과
* TEMP_7D_AVG 가 **함께** 빠져 개수가 2였다 — 그래서 수온을 한 값도 못 받은 해변이
* 'medium' 으로 나왔다. 입력의 절반이 없는데 "보통" 이라고 답한 셈이다.
*/
it('수온을 한 값도 못 받으면 low 다 — 예전에는 medium 이었다', () => {
expect(deriveConfidence(['TEMP_UP', 'TEMP_7D_AVG'], FRESH, NEAR)).toBe('low');
});

it('유향·유속만 없으면 medium — 제주 해변 대부분이 이 상태다', () => {
expect(deriveConfidence(['CURRENT_INFLOW'], FRESH, NEAR)).toBe('medium');
});

it('부차 요인 둘이 없어도 medium', () => {
expect(deriveConfidence(['CURRENT_INFLOW', 'WAVE_HIGH'], FRESH, NEAR)).toBe('medium');
});

it('부차 요인 셋이 겹치면 low', () => {
expect(deriveConfidence(['CURRENT_INFLOW', 'WAVE_HIGH', 'WIND_INFLOW'], FRESH, NEAR)).toBe(
'low',
);
});

it('표에 없는 코드도 1점으로 센다 — 룰이 늘어도 조용히 0점이 되지 않는다', () => {
expect(deriveConfidence(['NEW_RULE_A', 'NEW_RULE_B', 'NEW_RULE_C'], FRESH, NEAR)).toBe('low');
});
});

describe('⚠️ 관측소가 멀면 내린다 — 예전에는 거리를 아예 보지 않았다', () => {
it('대표 거리(10km) 안이면 그대로 high', () => {
expect(deriveConfidence([], FRESH, THRESHOLDS.representativeDistanceKm)).toBe('high');
});

it('10km 를 넘으면 한 단계 내린다 — 그 값은 이 해변의 것이라고 보기 어렵다', () => {
expect(deriveConfidence([], FRESH, THRESHOLDS.representativeDistanceKm + 0.1)).toBe('medium');
});

it('30km 를 넘으면 두 단계 — 엔진이 "인근" 으로도 치지 않는 거리다', () => {
expect(deriveConfidence([], FRESH, THRESHOLDS.farDistanceKm + 1)).toBe('low');
});

it('거리를 모르면 벌점이 없다 — 그 상황은 결측 무게가 이미 잡는다', () => {
expect(deriveConfidence([], FRESH, null)).toBe('high');
});

it('결측과 거리가 겹치면 함께 내려간다', () => {
// 유속 결측(medium) + 먼 관측소(한 단계) = low
expect(deriveConfidence(['CURRENT_INFLOW'], FRESH, 25)).toBe('low');
});
});

describe('신선도', () => {
it('3시간을 넘으면 high 를 주지 않는다', () => {
expect(deriveConfidence([], THRESHOLDS.freshObservationMinutes + 1, NEAR)).toBe('medium');
});

it('하루를 넘으면 low', () => {
expect(deriveConfidence([], THRESHOLDS.staleObservationMinutes + 1, NEAR)).toBe('low');
});
});
});

describe('dataConfidenceLabelOf', () => {
it('⚠️ 라벨에 "신뢰도" 라는 말을 쓰지 않는다', () => {
// "신뢰도 높음" 은 "이 예측을 믿어도 된다" 로 읽힌다. 그건 측정한 적 없는 주장이다
// (해변별 정확도 표본 0건). 라벨은 자료 이야기만 해야 한다.
for (const level of ['high', 'medium', 'low'] as const) {
expect(dataConfidenceLabelOf(level, 'ko')).not.toContain('신뢰');
}
});

it('자료 이야기를 한다', () => {
expect(dataConfidenceLabelOf('high', 'ko')).toContain('관측 자료');
expect(dataConfidenceLabelOf('low', 'ko')).toContain('부족');
});

it('지원 언어 넷을 모두 준다', () => {
for (const locale of ['ko', 'en', 'zh', 'ja'] as const) {
expect(dataConfidenceLabelOf('medium', locale).length).toBeGreaterThan(0);
}
// 언어별로 실제 다른 문구여야 한다 — ko 로 폴백되면 외국인에게 한국어가 나간다.
expect(dataConfidenceLabelOf('medium', 'en')).not.toBe(dataConfidenceLabelOf('medium', 'ko'));
});
});
1 change: 1 addition & 0 deletions src/contexts/risk/domain/risk-assessment.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ function bundle(nearbyAlert: NearbyAlertInput | null): RiskInputBundle {
pastOccurrenceCount: 0,
verifiedReports: [],
observationAgeMinutes: 10,
observationDistanceKm: 3,
forecasts: [],
};
}
Expand Down
Loading
Loading