@tipp/ui 2.9.0 → 2.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app/index.js +17 -17
- package/dist/app/platform/edit-coaching-time.js +17 -17
- package/dist/app/platform/edit-service-type.js +17 -17
- package/dist/app/platform/goal-manage-card-edit.js +17 -17
- package/dist/app/platform/on-offline-radio-card.js +17 -17
- package/dist/app/platform/reservation-card.js +17 -17
- package/dist/app/platform/session-card.js +17 -17
- package/dist/app/roleplay/chart/session-chart.cjs +351 -0
- package/dist/app/roleplay/chart/session-chart.cjs.map +1 -0
- package/dist/app/roleplay/chart/session-chart.d.cts +16 -0
- package/dist/app/roleplay/chart/session-chart.d.ts +16 -0
- package/dist/app/roleplay/chart/session-chart.js +14 -0
- package/dist/app/roleplay/chart/session-chart.js.map +1 -0
- package/dist/app/roleplay/index.cjs +5706 -0
- package/dist/app/roleplay/index.cjs.map +1 -0
- package/dist/app/roleplay/index.d.cts +4 -0
- package/dist/app/roleplay/index.d.ts +4 -0
- package/dist/app/roleplay/index.js +182 -0
- package/dist/app/roleplay/index.js.map +1 -0
- package/dist/app/roleplay/parts/analysis-section.cjs +2178 -0
- package/dist/app/roleplay/parts/analysis-section.cjs.map +1 -0
- package/dist/app/roleplay/parts/analysis-section.d.cts +17 -0
- package/dist/app/roleplay/parts/analysis-section.d.ts +17 -0
- package/dist/app/roleplay/parts/analysis-section.js +97 -0
- package/dist/app/roleplay/parts/analysis-section.js.map +1 -0
- package/dist/app/roleplay/parts/coaching-panel.cjs +2191 -0
- package/dist/app/roleplay/parts/coaching-panel.cjs.map +1 -0
- package/dist/app/roleplay/parts/coaching-panel.d.cts +8 -0
- package/dist/app/roleplay/parts/coaching-panel.d.ts +8 -0
- package/dist/app/roleplay/parts/coaching-panel.js +95 -0
- package/dist/app/roleplay/parts/coaching-panel.js.map +1 -0
- package/dist/app/roleplay/parts/content.cjs +4683 -0
- package/dist/app/roleplay/parts/content.cjs.map +1 -0
- package/dist/app/roleplay/parts/content.d.cts +15 -0
- package/dist/app/roleplay/parts/content.d.ts +15 -0
- package/dist/app/roleplay/parts/content.js +174 -0
- package/dist/app/roleplay/parts/content.js.map +1 -0
- package/dist/app/roleplay/parts/header.cjs +2300 -0
- package/dist/app/roleplay/parts/header.cjs.map +1 -0
- package/dist/app/roleplay/parts/header.d.cts +13 -0
- package/dist/app/roleplay/parts/header.d.ts +13 -0
- package/dist/app/roleplay/parts/header.js +96 -0
- package/dist/app/roleplay/parts/header.js.map +1 -0
- package/dist/app/roleplay/parts/score-summary.cjs +2276 -0
- package/dist/app/roleplay/parts/score-summary.cjs.map +1 -0
- package/dist/app/roleplay/parts/score-summary.d.cts +12 -0
- package/dist/app/roleplay/parts/score-summary.d.ts +12 -0
- package/dist/app/roleplay/parts/score-summary.js +96 -0
- package/dist/app/roleplay/parts/score-summary.js.map +1 -0
- package/dist/app/roleplay/parts/score-trend-section.cjs +3686 -0
- package/dist/app/roleplay/parts/score-trend-section.cjs.map +1 -0
- package/dist/app/roleplay/parts/score-trend-section.d.cts +14 -0
- package/dist/app/roleplay/parts/score-trend-section.d.ts +14 -0
- package/dist/app/roleplay/parts/score-trend-section.js +168 -0
- package/dist/app/roleplay/parts/score-trend-section.js.map +1 -0
- package/dist/app/roleplay/parts/script-transcript.cjs +2288 -0
- package/dist/app/roleplay/parts/script-transcript.cjs.map +1 -0
- package/dist/app/roleplay/parts/script-transcript.d.cts +15 -0
- package/dist/app/roleplay/parts/script-transcript.d.ts +15 -0
- package/dist/app/roleplay/parts/script-transcript.js +96 -0
- package/dist/app/roleplay/parts/script-transcript.js.map +1 -0
- package/dist/app/roleplay/parts/section-nav.cjs +2433 -0
- package/dist/app/roleplay/parts/section-nav.cjs.map +1 -0
- package/dist/app/roleplay/parts/section-nav.d.cts +15 -0
- package/dist/app/roleplay/parts/section-nav.d.ts +15 -0
- package/dist/app/roleplay/parts/section-nav.js +96 -0
- package/dist/app/roleplay/parts/section-nav.js.map +1 -0
- package/dist/app/roleplay/parts/session-replay-dialog.cjs +2598 -0
- package/dist/app/roleplay/parts/session-replay-dialog.cjs.map +1 -0
- package/dist/app/roleplay/parts/session-replay-dialog.d.cts +14 -0
- package/dist/app/roleplay/parts/session-replay-dialog.d.ts +14 -0
- package/dist/app/roleplay/parts/session-replay-dialog.js +97 -0
- package/dist/app/roleplay/parts/session-replay-dialog.js.map +1 -0
- package/dist/app/roleplay/parts/step-detail.cjs +2311 -0
- package/dist/app/roleplay/parts/step-detail.cjs.map +1 -0
- package/dist/app/roleplay/parts/step-detail.d.cts +9 -0
- package/dist/app/roleplay/parts/step-detail.d.ts +9 -0
- package/dist/app/roleplay/parts/step-detail.js +96 -0
- package/dist/app/roleplay/parts/step-detail.js.map +1 -0
- package/dist/app/roleplay/parts/step-radar.cjs +463 -0
- package/dist/app/roleplay/parts/step-radar.cjs.map +1 -0
- package/dist/app/roleplay/parts/step-radar.d.cts +8 -0
- package/dist/app/roleplay/parts/step-radar.d.ts +8 -0
- package/dist/app/roleplay/parts/step-radar.js +14 -0
- package/dist/app/roleplay/parts/step-radar.js.map +1 -0
- package/dist/app/roleplay/parts/step-summary.cjs +2651 -0
- package/dist/app/roleplay/parts/step-summary.cjs.map +1 -0
- package/dist/app/roleplay/parts/step-summary.d.cts +8 -0
- package/dist/app/roleplay/parts/step-summary.d.ts +8 -0
- package/dist/app/roleplay/parts/step-summary.js +101 -0
- package/dist/app/roleplay/parts/step-summary.js.map +1 -0
- package/dist/app/roleplay/rp-session-detail.cjs +5703 -0
- package/dist/app/roleplay/rp-session-detail.cjs.map +1 -0
- package/dist/app/roleplay/rp-session-detail.d.cts +17 -0
- package/dist/app/roleplay/rp-session-detail.d.ts +17 -0
- package/dist/app/roleplay/rp-session-detail.js +179 -0
- package/dist/app/roleplay/rp-session-detail.js.map +1 -0
- package/dist/app/roleplay/types.cjs +19 -0
- package/dist/app/roleplay/types.cjs.map +1 -0
- package/dist/app/roleplay/types.d.cts +42 -0
- package/dist/app/roleplay/types.d.ts +42 -0
- package/dist/app/roleplay/types.js +2 -0
- package/dist/app/roleplay/types.js.map +1 -0
- package/dist/app/roleplay/utils/report-parse.cjs +285 -0
- package/dist/app/roleplay/utils/report-parse.cjs.map +1 -0
- package/dist/app/roleplay/utils/report-parse.d.cts +57 -0
- package/dist/app/roleplay/utils/report-parse.d.ts +57 -0
- package/dist/app/roleplay/utils/report-parse.js +8 -0
- package/dist/app/roleplay/utils/report-parse.js.map +1 -0
- package/dist/app/roleplay/utils/report-parse.test.cjs +491 -0
- package/dist/app/roleplay/utils/report-parse.test.cjs.map +1 -0
- package/dist/app/roleplay/utils/report-parse.test.d.cts +2 -0
- package/dist/app/roleplay/utils/report-parse.test.d.ts +2 -0
- package/dist/app/roleplay/utils/report-parse.test.js +237 -0
- package/dist/app/roleplay/utils/report-parse.test.js.map +1 -0
- package/dist/app/roleplay/utils/session-utils.cjs +204 -0
- package/dist/app/roleplay/utils/session-utils.cjs.map +1 -0
- package/dist/app/roleplay/utils/session-utils.d.cts +38 -0
- package/dist/app/roleplay/utils/session-utils.d.ts +38 -0
- package/dist/app/roleplay/utils/session-utils.js +46 -0
- package/dist/app/roleplay/utils/session-utils.js.map +1 -0
- package/dist/chunk-2TOASXPK.js +131 -0
- package/dist/chunk-2TOASXPK.js.map +1 -0
- package/dist/chunk-3UGBMKSQ.js +166 -0
- package/dist/chunk-3UGBMKSQ.js.map +1 -0
- package/dist/chunk-4YAA25AV.js +161 -0
- package/dist/chunk-4YAA25AV.js.map +1 -0
- package/dist/chunk-5QIT4URV.js +87 -0
- package/dist/chunk-5QIT4URV.js.map +1 -0
- package/dist/chunk-6YIKOYBQ.js +185 -0
- package/dist/chunk-6YIKOYBQ.js.map +1 -0
- package/dist/chunk-7MXY3D3Z.js +84 -0
- package/dist/chunk-7MXY3D3Z.js.map +1 -0
- package/dist/chunk-EBFQFOUQ.js +138 -0
- package/dist/chunk-EBFQFOUQ.js.map +1 -0
- package/dist/chunk-EHDUKUML.js +183 -0
- package/dist/chunk-EHDUKUML.js.map +1 -0
- package/dist/{chunk-NEJCZABQ.js → chunk-FJEDSUGU.js} +7 -7
- package/dist/chunk-GA6W62DO.js +101 -0
- package/dist/chunk-GA6W62DO.js.map +1 -0
- package/dist/chunk-KJPBBBVK.js +142 -0
- package/dist/chunk-KJPBBBVK.js.map +1 -0
- package/dist/{chunk-TQTCT7DC.js → chunk-LRNO4W2P.js} +5 -5
- package/dist/{chunk-6DWUGXSY.js → chunk-N242VJOP.js} +2 -2
- package/dist/chunk-NKW6BO5R.js +264 -0
- package/dist/chunk-NKW6BO5R.js.map +1 -0
- package/dist/{chunk-R5UDNKV2.js → chunk-O332K3SW.js} +2 -2
- package/dist/chunk-OUP3VFXG.js +1 -0
- package/dist/chunk-OUP3VFXG.js.map +1 -0
- package/dist/chunk-QMAINJ4G.js +190 -0
- package/dist/chunk-QMAINJ4G.js.map +1 -0
- package/dist/{chunk-KLJVIEFW.js → chunk-SOKGYD5B.js} +7 -7
- package/dist/chunk-T7LKMJVA.js +142 -0
- package/dist/chunk-T7LKMJVA.js.map +1 -0
- package/dist/chunk-TFJJKM3W.js +261 -0
- package/dist/chunk-TFJJKM3W.js.map +1 -0
- package/dist/{chunk-KQ3ME6XY.js → chunk-U2EAXN3Y.js} +4 -4
- package/dist/chunk-UXAEOCOH.js +150 -0
- package/dist/chunk-UXAEOCOH.js.map +1 -0
- package/dist/chunk-XM4GWPXC.js +236 -0
- package/dist/chunk-XM4GWPXC.js.map +1 -0
- package/dist/index.css +76 -0
- package/dist/index.js +32 -32
- package/dist/molecules/curriculumV2/CurriculumContents/CurriculumReviewSteps/ReviewQuestion/index.js +5 -5
- package/dist/molecules/curriculumV2/CurriculumContents/CurriculumReviewSteps/ReviewQuestion/review-question-body.js +2 -2
- package/dist/molecules/curriculumV2/CurriculumContents/CurriculumReviewSteps/index.js +7 -7
- package/dist/molecules/curriculumV2/CurriculumContents/curriculum-review.js +8 -8
- package/dist/molecules/curriculumV2/CurriculumContents/index.js +9 -9
- package/dist/molecules/curriculumV2/index.js +11 -11
- package/dist/molecules/dynamic-form-editor.js +2 -2
- package/dist/molecules/index.js +29 -29
- package/package.json +28 -2
- package/src/app/roleplay/chart/session-chart.tsx +185 -0
- package/src/app/roleplay/index.ts +8 -0
- package/src/app/roleplay/parts/analysis-section.tsx +98 -0
- package/src/app/roleplay/parts/coaching-panel.tsx +101 -0
- package/src/app/roleplay/parts/content.tsx +146 -0
- package/src/app/roleplay/parts/header.tsx +168 -0
- package/src/app/roleplay/parts/score-summary.tsx +158 -0
- package/src/app/roleplay/parts/score-trend-section.tsx +255 -0
- package/src/app/roleplay/parts/script-transcript.tsx +190 -0
- package/src/app/roleplay/parts/section-nav.tsx +299 -0
- package/src/app/roleplay/parts/session-replay-dialog.tsx +168 -0
- package/src/app/roleplay/parts/step-detail.tsx +173 -0
- package/src/app/roleplay/parts/step-radar.tsx +80 -0
- package/src/app/roleplay/parts/step-summary.tsx +149 -0
- package/src/app/roleplay/rp-session-detail.tsx +176 -0
- package/src/app/roleplay/types.ts +61 -0
- package/src/app/roleplay/utils/report-parse.test.ts +268 -0
- package/src/app/roleplay/utils/report-parse.ts +443 -0
- package/src/app/roleplay/utils/session-utils.ts +215 -0
- package/src/constrant/trainingLog.dummy.json +1 -0
- /package/dist/{chunk-NEJCZABQ.js.map → chunk-FJEDSUGU.js.map} +0 -0
- /package/dist/{chunk-TQTCT7DC.js.map → chunk-LRNO4W2P.js.map} +0 -0
- /package/dist/{chunk-6DWUGXSY.js.map → chunk-N242VJOP.js.map} +0 -0
- /package/dist/{chunk-R5UDNKV2.js.map → chunk-O332K3SW.js.map} +0 -0
- /package/dist/{chunk-KLJVIEFW.js.map → chunk-SOKGYD5B.js.map} +0 -0
- /package/dist/{chunk-KQ3ME6XY.js.map → chunk-U2EAXN3Y.js.map} +0 -0
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
// 롤플레잉 평가 리포트(reportData) 제네릭 파서.
|
|
2
|
+
//
|
|
3
|
+
// 보고서 타입(reportType)은 계속 추가되며 그때마다 프론트를 수정할 수 없다.
|
|
4
|
+
// 따라서 프레임워크명 분기 없이 reportData 의 "키 패턴"만으로 해석한다.
|
|
5
|
+
//
|
|
6
|
+
// 입력 형태(프레임워크 공통, outputSchema 실측):
|
|
7
|
+
// reportData = { output_text: { todayDate, summary, {name}_items:[{...}], skills:[{...}],
|
|
8
|
+
// comprehensive_analysis? }, ai_traininglog_id }
|
|
9
|
+
// - {name}_items[0] 안에 모든 단계가 `${step}_score|_basis|_issue|_improve|_example|_quality` 평면 필드로 존재
|
|
10
|
+
// - 단계 라벨은 guideline.value[i].title (길이 일치 가드, 없으면 키 humanize 폴백)
|
|
11
|
+
|
|
12
|
+
/* ───────────────────────── 출력 타입 ───────────────────────── */
|
|
13
|
+
|
|
14
|
+
const QUALITIES = ['충족', '부분충족', '미흡'] as const;
|
|
15
|
+
export type RpQuality = (typeof QUALITIES)[number];
|
|
16
|
+
|
|
17
|
+
export interface RpReportStep {
|
|
18
|
+
key: string; // 단계 prefix (예: 'own', 'nurturing_org')
|
|
19
|
+
label: string; // displayMeta.steps.displayName → guideline 제목 → humanize 된 키
|
|
20
|
+
initial: string | null; // displayMeta.steps.initial. 없으면 null → session-utils stepCode 휴리스틱 폴백
|
|
21
|
+
description: string | null; // displayMeta.steps.description → guideline 단계 설명
|
|
22
|
+
idealExample: string | null; // guideline 모범 예시(있을 때)
|
|
23
|
+
score: number | null;
|
|
24
|
+
maxScore: number | null; // 신버전 리포트만 제공(구버전은 null) → 있으면 score/maxScore 표시
|
|
25
|
+
quality: RpQuality | null; // 표준 등급(충족/부분충족/미흡)일 때만. 색·정규화 비율 산출용
|
|
26
|
+
qualityLabel: string | null; // 원문 등급 문자열 그대로(예: 'X 매우 미흡'). 표시용
|
|
27
|
+
basis: string | null;
|
|
28
|
+
issue: string | null;
|
|
29
|
+
improve: string | null;
|
|
30
|
+
example: string | null; // reportData 의 단계별 예시 스크립트
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface RpReportAnalysisItem {
|
|
34
|
+
title: string;
|
|
35
|
+
detail: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface RpReportSkill {
|
|
39
|
+
title: string;
|
|
40
|
+
description: string;
|
|
41
|
+
example: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface RpReport {
|
|
45
|
+
frameworkTitle: string | null; // displayMeta.title — 프레임워크 제목(지표와 별개). 없으면 null
|
|
46
|
+
frameworkDescription: string | null; // displayMeta.description — 프레임워크 설명문. 없으면 null
|
|
47
|
+
todayDate: string | null;
|
|
48
|
+
totalScore: number | null;
|
|
49
|
+
comment: string | null;
|
|
50
|
+
deduction: { score: number; basis: string } | null;
|
|
51
|
+
strengths: RpReportAnalysisItem[];
|
|
52
|
+
improvements: RpReportAnalysisItem[];
|
|
53
|
+
steps: RpReportStep[];
|
|
54
|
+
skills: RpReportSkill[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/* ───────────────────────── displayMeta (framework 표시 메타) ───────────────────────── */
|
|
58
|
+
|
|
59
|
+
// framework.displayMeta = { title?, description?, steps:[{order,key,initial,displayName,maxValue,description}] }
|
|
60
|
+
// title/description은 프레임워크 자체(지표와 별개). steps는 지표별 표시 메타.
|
|
61
|
+
// 없거나 형식 불일치면 폴백(기존 동작).
|
|
62
|
+
export interface RpDisplayMetaStep {
|
|
63
|
+
order: number;
|
|
64
|
+
key: string; // = outputSchema {prefix}_score 의 prefix
|
|
65
|
+
initial: string;
|
|
66
|
+
displayName: string;
|
|
67
|
+
maxValue: number;
|
|
68
|
+
description: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface RpDisplayMeta {
|
|
72
|
+
title?: string;
|
|
73
|
+
description?: string;
|
|
74
|
+
steps: RpDisplayMetaStep[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/* ───────────────────────── 원시 헬퍼 ───────────────────────── */
|
|
78
|
+
|
|
79
|
+
function isObject(v: unknown): v is Record<string, unknown> {
|
|
80
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Array.isArray 는 any[] 로 좁혀 unsafe 접근을 유발한다. unknown[] 로 좁히는 가드.
|
|
84
|
+
function isUnknownArray(v: unknown): v is unknown[] {
|
|
85
|
+
return Array.isArray(v);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function asString(v: unknown): string | null {
|
|
89
|
+
if (typeof v !== 'string') return null;
|
|
90
|
+
const t = v.trim();
|
|
91
|
+
return t.length > 0 ? t : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function asNumber(v: unknown): number | null {
|
|
95
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function asQuality(v: unknown): RpQuality | null {
|
|
99
|
+
const t = asString(v);
|
|
100
|
+
if (t === null) return null;
|
|
101
|
+
return QUALITIES.find((q) => q === t) ?? null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// guideline 의 example 은 string | string[] 모두 가능. '-' 플레이스홀더는 무시.
|
|
105
|
+
function asGuidelineText(v: unknown): string | null {
|
|
106
|
+
if (isUnknownArray(v)) {
|
|
107
|
+
const parts = v
|
|
108
|
+
.map(asString)
|
|
109
|
+
.filter((s): s is string => s !== null && s !== '-');
|
|
110
|
+
return parts.length > 0 ? parts.join('\n') : null;
|
|
111
|
+
}
|
|
112
|
+
const t = asString(v);
|
|
113
|
+
return t !== null && t !== '-' ? t : null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function humanize(key: string): string {
|
|
117
|
+
return key
|
|
118
|
+
.split('_')
|
|
119
|
+
.filter((p) => p.length > 0)
|
|
120
|
+
.map((p) => p.charAt(0).toUpperCase() + p.slice(1))
|
|
121
|
+
.join(' ');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/* ───────────────────────── 단계 추출 ───────────────────────── */
|
|
125
|
+
|
|
126
|
+
// ⚠ 순서 중요: 더 긴/구체 suffix를 먼저 둬야 한다.
|
|
127
|
+
// 예) 'action_max_score' 는 '_score'보다 '_max_score' 가 먼저 매칭돼야
|
|
128
|
+
// prefix='action_max'(오인) 가 아니라 prefix='action', suffix='max_score' 로 잡힌다.
|
|
129
|
+
const STEP_SUFFIXES = [
|
|
130
|
+
'max_score',
|
|
131
|
+
'maxscore',
|
|
132
|
+
'max',
|
|
133
|
+
'score',
|
|
134
|
+
'basis',
|
|
135
|
+
'issue',
|
|
136
|
+
'improve',
|
|
137
|
+
'example',
|
|
138
|
+
'quality',
|
|
139
|
+
] as const;
|
|
140
|
+
type StepSuffix = (typeof STEP_SUFFIXES)[number];
|
|
141
|
+
|
|
142
|
+
function splitStepKey(
|
|
143
|
+
key: string
|
|
144
|
+
): { prefix: string; suffix: StepSuffix } | null {
|
|
145
|
+
for (const suffix of STEP_SUFFIXES) {
|
|
146
|
+
const tail = `_${suffix}`;
|
|
147
|
+
if (key.endsWith(tail) && key.length > tail.length) {
|
|
148
|
+
return { prefix: key.slice(0, -tail.length), suffix };
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
interface GuidelineEntry {
|
|
155
|
+
title: string | null;
|
|
156
|
+
description: string | null;
|
|
157
|
+
example: string | null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// guideline = { value: [{title, example, description}], version }. 미작성/길이불일치면 빈 배열 취급(=폴백).
|
|
161
|
+
function readGuidelineEntries(guideline: unknown): GuidelineEntry[] {
|
|
162
|
+
if (!isObject(guideline) || !isUnknownArray(guideline.value)) return [];
|
|
163
|
+
const entries: GuidelineEntry[] = guideline.value.map((v) => {
|
|
164
|
+
if (!isObject(v)) return { title: null, description: null, example: null };
|
|
165
|
+
const title = asString(v.title);
|
|
166
|
+
return {
|
|
167
|
+
title: title === '-' ? null : title,
|
|
168
|
+
description: asGuidelineText(v.description),
|
|
169
|
+
example: asGuidelineText(v.example),
|
|
170
|
+
};
|
|
171
|
+
});
|
|
172
|
+
// 전부 플레이스홀더면 라벨 소스로 쓰지 않는다.
|
|
173
|
+
if (entries.every((e) => e.title === null)) return [];
|
|
174
|
+
return entries;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// reportData item 객체에서 prefix→(suffix→값) 맵과 첫 등장 순서(order)를 만든다.
|
|
178
|
+
function buildFieldsMap(itemObj: Record<string, unknown>): {
|
|
179
|
+
order: string[];
|
|
180
|
+
fields: Map<string, Map<StepSuffix, unknown>>;
|
|
181
|
+
} {
|
|
182
|
+
const order: string[] = [];
|
|
183
|
+
const fields = new Map<string, Map<StepSuffix, unknown>>();
|
|
184
|
+
|
|
185
|
+
for (const [key, value] of Object.entries(itemObj)) {
|
|
186
|
+
const split = splitStepKey(key);
|
|
187
|
+
if (!split) continue;
|
|
188
|
+
if (!fields.has(split.prefix)) {
|
|
189
|
+
fields.set(split.prefix, new Map());
|
|
190
|
+
order.push(split.prefix);
|
|
191
|
+
}
|
|
192
|
+
fields.get(split.prefix)?.set(split.suffix, value);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return { order, fields };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// 단일 단계(step) 조립. 라벨/이니셜/설명/만점은 호출부(displayMeta 또는 폴백)가 결정해 주입.
|
|
199
|
+
function buildStep(
|
|
200
|
+
key: string,
|
|
201
|
+
f: Map<StepSuffix, unknown>,
|
|
202
|
+
meta: {
|
|
203
|
+
label: string;
|
|
204
|
+
initial: string | null;
|
|
205
|
+
description: string | null;
|
|
206
|
+
idealExample: string | null;
|
|
207
|
+
maxScoreFallback: number | null; // reportData 에 max 가 없을 때 쓸 만점(displayMeta.maxValue)
|
|
208
|
+
}
|
|
209
|
+
): RpReportStep {
|
|
210
|
+
const qualityRaw = f.get('quality');
|
|
211
|
+
return {
|
|
212
|
+
key,
|
|
213
|
+
label: meta.label,
|
|
214
|
+
initial: meta.initial,
|
|
215
|
+
description: meta.description,
|
|
216
|
+
idealExample: meta.idealExample,
|
|
217
|
+
score: asNumber(f.get('score')),
|
|
218
|
+
// reportData 의 실제 만점이 우선, 없으면 displayMeta.maxValue 폴백.
|
|
219
|
+
maxScore:
|
|
220
|
+
asNumber(f.get('max_score') ?? f.get('maxscore') ?? f.get('max')) ??
|
|
221
|
+
meta.maxScoreFallback,
|
|
222
|
+
quality: asQuality(qualityRaw),
|
|
223
|
+
qualityLabel: asString(qualityRaw),
|
|
224
|
+
basis: asString(f.get('basis')),
|
|
225
|
+
issue: asString(f.get('issue')),
|
|
226
|
+
improve: asString(f.get('improve')),
|
|
227
|
+
example: asString(f.get('example')),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function extractSteps(
|
|
232
|
+
itemObj: Record<string, unknown>,
|
|
233
|
+
guideline: unknown,
|
|
234
|
+
displaySteps: RpDisplayMetaStep[]
|
|
235
|
+
): RpReportStep[] {
|
|
236
|
+
const { order, fields } = buildFieldsMap(itemObj);
|
|
237
|
+
const entries = readGuidelineEntries(guideline);
|
|
238
|
+
|
|
239
|
+
// ── displayMeta.steps 우선: order 정렬된 steps 를 key 로 reportData 필드에 매칭.
|
|
240
|
+
if (displaySteps.length > 0) {
|
|
241
|
+
// guideline 의 단계 설명/모범예시 보강은 길이 일치 시에만 인덱스 매핑(기존 가드와 동일 정신).
|
|
242
|
+
const useGuideline = entries.length === displaySteps.length;
|
|
243
|
+
return displaySteps.map((ds, i) => {
|
|
244
|
+
const f = fields.get(ds.key) ?? new Map<StepSuffix, unknown>();
|
|
245
|
+
const entry = useGuideline ? entries[i] : null;
|
|
246
|
+
// 지표 설명: displayMeta.steps.description 우선, 없으면 guideline.
|
|
247
|
+
const dsDescription =
|
|
248
|
+
ds.description.trim().length > 0 ? ds.description : null;
|
|
249
|
+
return buildStep(ds.key, f, {
|
|
250
|
+
label: ds.displayName,
|
|
251
|
+
initial: ds.initial.trim().length > 0 ? ds.initial : null,
|
|
252
|
+
description: dsDescription ?? entry?.description ?? null,
|
|
253
|
+
idealExample: entry?.example ?? null,
|
|
254
|
+
maxScoreFallback: ds.maxValue > 0 ? ds.maxValue : null,
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ── 폴백: reportData 키 등장 순서 + guideline 길이일치 인덱스 매칭 + humanize.
|
|
260
|
+
const useGuideline = entries.length === order.length;
|
|
261
|
+
return order.map((prefix, i) => {
|
|
262
|
+
const f = fields.get(prefix) ?? new Map<StepSuffix, unknown>();
|
|
263
|
+
const entry = useGuideline ? entries[i] : null;
|
|
264
|
+
return buildStep(prefix, f, {
|
|
265
|
+
label: entry?.title ?? humanize(prefix),
|
|
266
|
+
initial: null,
|
|
267
|
+
description: entry?.description ?? null,
|
|
268
|
+
idealExample: entry?.example ?? null,
|
|
269
|
+
maxScoreFallback: null,
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// framework.displayMeta(unknown) 를 견고하게 정규화.
|
|
275
|
+
// title/description(프레임워크 자체)과 steps(지표 배열)는 독립 — steps 가 비어도 title/description 은 살린다.
|
|
276
|
+
function readDisplayMeta(raw: unknown): {
|
|
277
|
+
title: string | null;
|
|
278
|
+
description: string | null;
|
|
279
|
+
steps: RpDisplayMetaStep[];
|
|
280
|
+
} {
|
|
281
|
+
if (!isObject(raw)) return { title: null, description: null, steps: [] };
|
|
282
|
+
|
|
283
|
+
const steps: RpDisplayMetaStep[] = [];
|
|
284
|
+
if (isUnknownArray(raw.steps)) {
|
|
285
|
+
for (const s of raw.steps) {
|
|
286
|
+
if (!isObject(s)) continue;
|
|
287
|
+
const key = asString(s.key);
|
|
288
|
+
if (key === null) continue; // key 없는 항목은 매칭 불가 → 제외
|
|
289
|
+
steps.push({
|
|
290
|
+
order: asNumber(s.order) ?? steps.length,
|
|
291
|
+
key,
|
|
292
|
+
initial: asString(s.initial) ?? '',
|
|
293
|
+
displayName: asString(s.displayName) ?? humanize(key),
|
|
294
|
+
maxValue: asNumber(s.maxValue) ?? 0,
|
|
295
|
+
description: asString(s.description) ?? '',
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
steps.sort((a, b) => a.order - b.order);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return {
|
|
302
|
+
title: asString(raw.title),
|
|
303
|
+
description: asString(raw.description),
|
|
304
|
+
steps,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/* ───────────────────────── 강점/개선점 (numbered-pair 평탄화) ───────────────────────── */
|
|
309
|
+
|
|
310
|
+
// 객체에서 `${titleBase}${n}` / `${detailBase}${n}` 쌍을 n=1.. 순으로 수집.
|
|
311
|
+
function flattenNumberedPairs(
|
|
312
|
+
obj: Record<string, unknown>,
|
|
313
|
+
titleBase: string,
|
|
314
|
+
detailBase: string
|
|
315
|
+
): RpReportAnalysisItem[] {
|
|
316
|
+
const out: RpReportAnalysisItem[] = [];
|
|
317
|
+
for (let n = 1; ; n += 1) {
|
|
318
|
+
const title = asString(obj[`${titleBase}${n}`]);
|
|
319
|
+
if (title === null) break;
|
|
320
|
+
out.push({ title, detail: asString(obj[`${detailBase}${n}`]) ?? '' });
|
|
321
|
+
}
|
|
322
|
+
return out;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// 후보 (titleBase, detailBase) 쌍을 순서대로 시도, 첫 매치를 사용. (구·신 스키마 모두 대응)
|
|
326
|
+
const ANALYSIS_PAIR_CANDIDATES: readonly (readonly [string, string])[] = [
|
|
327
|
+
['title', 'detail'],
|
|
328
|
+
['improvement_title', 'improvement_action'],
|
|
329
|
+
['issue_title', 'issue_detail'],
|
|
330
|
+
];
|
|
331
|
+
|
|
332
|
+
function flattenAnalysis(source: unknown): RpReportAnalysisItem[] {
|
|
333
|
+
if (!isUnknownArray(source)) return [];
|
|
334
|
+
const out: RpReportAnalysisItem[] = [];
|
|
335
|
+
for (const obj of source) {
|
|
336
|
+
if (!isObject(obj)) continue;
|
|
337
|
+
for (const [titleBase, detailBase] of ANALYSIS_PAIR_CANDIDATES) {
|
|
338
|
+
const items = flattenNumberedPairs(obj, titleBase, detailBase);
|
|
339
|
+
if (items.length > 0) {
|
|
340
|
+
out.push(...items);
|
|
341
|
+
break;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return out;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/* ───────────────────────── skills 평탄화 ───────────────────────── */
|
|
349
|
+
|
|
350
|
+
function flattenSkills(source: unknown): RpReportSkill[] {
|
|
351
|
+
if (!isUnknownArray(source)) return [];
|
|
352
|
+
const out: RpReportSkill[] = [];
|
|
353
|
+
for (const obj of source) {
|
|
354
|
+
if (!isObject(obj)) continue;
|
|
355
|
+
for (let n = 1; ; n += 1) {
|
|
356
|
+
const title = asString(obj[`skill${n}`]);
|
|
357
|
+
if (title === null) break;
|
|
358
|
+
out.push({
|
|
359
|
+
title,
|
|
360
|
+
description: asString(obj[`description${n}`]) ?? '',
|
|
361
|
+
example: asString(obj[`example${n}`]) ?? '',
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return out;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/* ───────────────────────── 종합/감점 ───────────────────────── */
|
|
369
|
+
|
|
370
|
+
function readDeduction(
|
|
371
|
+
summary: Record<string, unknown> | null
|
|
372
|
+
): { score: number; basis: string } | null {
|
|
373
|
+
if (!summary || !isObject(summary.deduction)) return null;
|
|
374
|
+
const score = asNumber(summary.deduction.score);
|
|
375
|
+
const basis = asString(summary.deduction.basis);
|
|
376
|
+
if (score === null || basis === null) return null;
|
|
377
|
+
return { score, basis };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/* ───────────────────────── 메인 파서 ───────────────────────── */
|
|
381
|
+
|
|
382
|
+
// airflow 가 output_text 를 dict 로 정규화해 저장하므로(ai_traininglog_request_clova.py),
|
|
383
|
+
// 래퍼는 객체. 래퍼가 없으면(일부 프레임워크) reportData 자체를 루트로 사용.
|
|
384
|
+
function unwrapOutputText(reportData: unknown): Record<string, unknown> {
|
|
385
|
+
if (!isObject(reportData)) return {};
|
|
386
|
+
return isObject(reportData.output_text) ? reportData.output_text : reportData;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function findItemsArray(
|
|
390
|
+
root: Record<string, unknown>
|
|
391
|
+
): Record<string, unknown> | null {
|
|
392
|
+
for (const [key, value] of Object.entries(root)) {
|
|
393
|
+
if (!key.endsWith('_items') || !isUnknownArray(value)) continue;
|
|
394
|
+
const first = value[0];
|
|
395
|
+
if (isObject(first)) return first;
|
|
396
|
+
}
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export function parseReportData(
|
|
401
|
+
reportData: unknown,
|
|
402
|
+
guideline: unknown,
|
|
403
|
+
displayMeta?: unknown
|
|
404
|
+
): RpReport {
|
|
405
|
+
const root = unwrapOutputText(reportData);
|
|
406
|
+
const meta = readDisplayMeta(displayMeta);
|
|
407
|
+
|
|
408
|
+
const summary = isObject(root.summary) ? root.summary : null;
|
|
409
|
+
const comprehensive = isObject(root.comprehensive_analysis)
|
|
410
|
+
? root.comprehensive_analysis
|
|
411
|
+
: null;
|
|
412
|
+
|
|
413
|
+
// 종합 코멘트: 신(comprehensive_analysis) → 구(summary) 순.
|
|
414
|
+
const comment =
|
|
415
|
+
asString(comprehensive?.comment) ??
|
|
416
|
+
asString(summary?.comment) ??
|
|
417
|
+
asString(summary?.basis);
|
|
418
|
+
|
|
419
|
+
// 강점: comprehensive_analysis.strengths(신) → summary.good_point(구)
|
|
420
|
+
const strengths = flattenAnalysis(
|
|
421
|
+
comprehensive?.strengths ?? summary?.good_point
|
|
422
|
+
);
|
|
423
|
+
// 개선점: comprehensive_analysis.improvements(신) → summary.improvement(구)
|
|
424
|
+
const improvements = flattenAnalysis(
|
|
425
|
+
comprehensive?.improvements ?? summary?.improvement
|
|
426
|
+
);
|
|
427
|
+
|
|
428
|
+
const itemObj = findItemsArray(root);
|
|
429
|
+
const steps = itemObj ? extractSteps(itemObj, guideline, meta.steps) : [];
|
|
430
|
+
|
|
431
|
+
return {
|
|
432
|
+
frameworkTitle: meta.title,
|
|
433
|
+
frameworkDescription: meta.description,
|
|
434
|
+
todayDate: asString(root.todayDate),
|
|
435
|
+
totalScore: asNumber(summary?.total_score),
|
|
436
|
+
comment,
|
|
437
|
+
deduction: readDeduction(summary),
|
|
438
|
+
strengths,
|
|
439
|
+
improvements,
|
|
440
|
+
steps,
|
|
441
|
+
skills: flattenSkills(root.skills),
|
|
442
|
+
};
|
|
443
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// 롤플레잉 세션 상세 순수 유틸 함수 모음.
|
|
2
|
+
// 원본: website/src/modules/customer/roleplay/pages/RpSessionDetail/RpSessionDetail.tsx
|
|
3
|
+
// 'use client' 없음 — 순수 함수, 클라이언트 전용 아님.
|
|
4
|
+
|
|
5
|
+
import { format } from 'date-fns';
|
|
6
|
+
import type { RpSessionScriptLine } from '../types';
|
|
7
|
+
import type { RpQuality, RpReportStep } from './report-parse';
|
|
8
|
+
|
|
9
|
+
/* ───────────────────────── 타입 ───────────────────────── */
|
|
10
|
+
|
|
11
|
+
export interface SpeechShare { userPct: number; partnerPct: number }
|
|
12
|
+
|
|
13
|
+
export type QColor = 'green' | 'amber' | 'red' | 'gray';
|
|
14
|
+
|
|
15
|
+
/* ───────────────────────── 색 변수 ───────────────────────── */
|
|
16
|
+
|
|
17
|
+
export const COLOR_VARS: Record<QColor, { fg: string; bg: string; border: string }> = {
|
|
18
|
+
green: {
|
|
19
|
+
fg: 'var(--green-11)',
|
|
20
|
+
bg: 'var(--green-a3)',
|
|
21
|
+
border: 'var(--green-a6)',
|
|
22
|
+
},
|
|
23
|
+
amber: {
|
|
24
|
+
fg: 'var(--amber-11)',
|
|
25
|
+
bg: 'var(--amber-a3)',
|
|
26
|
+
border: 'var(--amber-a6)',
|
|
27
|
+
},
|
|
28
|
+
red: { fg: 'var(--red-11)', bg: 'var(--red-a3)', border: 'var(--red-a6)' },
|
|
29
|
+
gray: { fg: 'var(--gray-9)', bg: 'var(--gray-a3)', border: 'var(--gray-5)' },
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/* ───────────────────────── 등급 색 ───────────────────────── */
|
|
33
|
+
|
|
34
|
+
export function qualityColor(quality: RpQuality | null): QColor {
|
|
35
|
+
switch (quality) {
|
|
36
|
+
case '충족':
|
|
37
|
+
return 'green';
|
|
38
|
+
case '부분충족':
|
|
39
|
+
return 'amber';
|
|
40
|
+
case '미흡':
|
|
41
|
+
return 'red';
|
|
42
|
+
default:
|
|
43
|
+
return 'gray';
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const QUALITY_RATIO: Record<RpQuality, number> = {
|
|
48
|
+
충족: 1,
|
|
49
|
+
부분충족: 0.6,
|
|
50
|
+
미흡: 0.3,
|
|
51
|
+
} as const;
|
|
52
|
+
|
|
53
|
+
export function qualityRatio(quality: RpQuality | null): number | null {
|
|
54
|
+
return quality === null ? null : QUALITY_RATIO[quality];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// 표준 등급이 아닌 자유 등급 문자열에서 색만 키워드로 추정(비율은 산출하지 않음).
|
|
58
|
+
export function gradeColorFromLabel(label: string | null): QColor {
|
|
59
|
+
if (!label) return 'gray';
|
|
60
|
+
if (
|
|
61
|
+
label.includes('미흡') ||
|
|
62
|
+
label.includes('부족') ||
|
|
63
|
+
label.includes('미달')
|
|
64
|
+
)
|
|
65
|
+
return 'red';
|
|
66
|
+
if (label.includes('부분') || label.includes('보통')) return 'amber';
|
|
67
|
+
if (
|
|
68
|
+
label.includes('충족') ||
|
|
69
|
+
label.includes('우수') ||
|
|
70
|
+
label.includes('충분')
|
|
71
|
+
)
|
|
72
|
+
return 'green';
|
|
73
|
+
return 'gray';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/* ───────────────────────── 단계(Step) 헬퍼 ───────────────────────── */
|
|
77
|
+
|
|
78
|
+
// 단계 색: 표준 등급 우선, 없으면 라벨 키워드 추정.
|
|
79
|
+
export function stepColor(step: RpReportStep): QColor {
|
|
80
|
+
return step.quality !== null
|
|
81
|
+
? qualityColor(step.quality)
|
|
82
|
+
: gradeColorFromLabel(step.qualityLabel);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function stepVars(step: RpReportStep): { fg: string; bg: string; border: string } {
|
|
86
|
+
return COLOR_VARS[stepColor(step)];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// progress/radar 정규화 비율(0~1). score/maxScore 우선, 없으면 표준 등급 비율, 둘 다 없으면 null.
|
|
90
|
+
export function stepRatio(step: RpReportStep): number | null {
|
|
91
|
+
if (step.maxScore !== null && step.maxScore > 0 && step.score !== null) {
|
|
92
|
+
return Math.max(0, Math.min(1, step.score / step.maxScore));
|
|
93
|
+
}
|
|
94
|
+
return qualityRatio(step.quality);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 점수 텍스트: max 있으면 'score/max', 없으면 'score', score 없으면 null.
|
|
98
|
+
export function stepScoreText(step: RpReportStep): string | null {
|
|
99
|
+
if (step.score === null) return null;
|
|
100
|
+
return step.maxScore !== null
|
|
101
|
+
? `${step.score}/${step.maxScore}`
|
|
102
|
+
: `${step.score}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 표시할 등급 라벨(자유 문자열 포함). 없으면 null.
|
|
106
|
+
export function stepGradeLabel(step: RpReportStep): string | null {
|
|
107
|
+
return step.qualityLabel;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// 단계에 표시할 평가 정보가 하나라도 있는지(점수 또는 등급).
|
|
111
|
+
export function hasStepSignal(step: RpReportStep): boolean {
|
|
112
|
+
return step.score !== null || step.qualityLabel !== null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// radar 축/요약 라벨용 짧은 코드.
|
|
116
|
+
// displayMeta.steps.initial 이 지정돼 있으면 최우선(이니셜 충돌 해소).
|
|
117
|
+
// 없으면 기존 휴리스틱: acronym 프레임워크는 선두 글자, 아니면 키 약식.
|
|
118
|
+
const LETTER_RE = /[A-Za-z]/;
|
|
119
|
+
|
|
120
|
+
export function stepCode(step: RpReportStep): string {
|
|
121
|
+
if (step.initial !== null && step.initial.trim().length > 0) {
|
|
122
|
+
return step.initial.trim();
|
|
123
|
+
}
|
|
124
|
+
const label = step.label.trim();
|
|
125
|
+
const first = label.charAt(0);
|
|
126
|
+
const second = label.charAt(1);
|
|
127
|
+
// "O — Opening" / "O - Opening" / "O Opening" 형태
|
|
128
|
+
if (
|
|
129
|
+
LETTER_RE.test(first) &&
|
|
130
|
+
(second === ' ' || second === '—' || second === '-')
|
|
131
|
+
) {
|
|
132
|
+
return first.toUpperCase();
|
|
133
|
+
}
|
|
134
|
+
// "[I] Introduction" 형태
|
|
135
|
+
if (first === '[' && LETTER_RE.test(second) && label.charAt(2) === ']') {
|
|
136
|
+
return second.toUpperCase();
|
|
137
|
+
}
|
|
138
|
+
return step.key.split('_')[0].slice(0, 4).toUpperCase();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/* ───────────────────────── 포맷 유틸 ───────────────────────── */
|
|
142
|
+
|
|
143
|
+
export function formatDuration(seconds: number | null): string {
|
|
144
|
+
if (seconds === null) return '—';
|
|
145
|
+
const m = Math.floor(seconds / 60);
|
|
146
|
+
const s = seconds % 60;
|
|
147
|
+
return `${m}분 ${String(s).padStart(2, '0')}초`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function formatDate(iso: string): string {
|
|
151
|
+
const d = new Date(iso);
|
|
152
|
+
if (Number.isNaN(d.getTime())) return iso;
|
|
153
|
+
return format(d, 'M/d HH:mm');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function formatTimestamp(ms: number): string {
|
|
157
|
+
const total = Math.floor(ms / 1000);
|
|
158
|
+
const m = Math.floor(total / 60);
|
|
159
|
+
const s = total % 60;
|
|
160
|
+
return `${m}:${String(s).padStart(2, '0')}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/* ───────────────────────── 미디어 싱크 ───────────────────────── */
|
|
164
|
+
|
|
165
|
+
// 미디어 길이와 STT 길이가 이 비율 이내로 가까우면 싱크(클릭 seek/재생 하이라이트) 활성.
|
|
166
|
+
export const SYNC_TOLERANCE = 0.05;
|
|
167
|
+
|
|
168
|
+
export function isLengthClose(mediaSec: number | null, sttSec: number): boolean {
|
|
169
|
+
if (!mediaSec || mediaSec <= 0 || sttSec <= 0) {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
return (
|
|
173
|
+
Math.abs(mediaSec - sttSec) / Math.max(mediaSec, sttSec) <= SYNC_TOLERANCE
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/* ───────────────────────── 발화량 ───────────────────────── */
|
|
178
|
+
|
|
179
|
+
// 내 발화량이 이 비율을 넘으면 과다 발화로 간주(빨간 막대 + 주의 문구).
|
|
180
|
+
export const SPEECH_OVERTALK_THRESHOLD = 40;
|
|
181
|
+
|
|
182
|
+
// 화자별 발화량 비율. 기준은 공백·개행 제거 글자 수(실제 말한 양에 근접, 공백 패턴 왜곡 방지).
|
|
183
|
+
// 발화 글자가 전혀 없으면 null → 미표시.
|
|
184
|
+
export function computeSpeechShare(lines: RpSessionScriptLine[]): SpeechShare | null {
|
|
185
|
+
let userChars = 0;
|
|
186
|
+
let partnerChars = 0;
|
|
187
|
+
for (const line of lines) {
|
|
188
|
+
const count = line.text.replace(/\s/g, '').length;
|
|
189
|
+
if (line.speaker === 'user') {
|
|
190
|
+
userChars += count;
|
|
191
|
+
} else {
|
|
192
|
+
partnerChars += count;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const total = userChars + partnerChars;
|
|
196
|
+
if (total === 0) {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
const userPct = Math.round((userChars / total) * 100);
|
|
200
|
+
return { userPct, partnerPct: 100 - userPct };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/* ───────────────────────── 세션 상태 ───────────────────────── */
|
|
204
|
+
|
|
205
|
+
const STATUS_LABEL: Record<string, string> = {
|
|
206
|
+
complete: '분석 완료',
|
|
207
|
+
completed: '분석 완료',
|
|
208
|
+
analyzing: 'AI 분석 중',
|
|
209
|
+
partial: '일부 산출물 없음',
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
export function statusLabelOf(status: string | null): string {
|
|
213
|
+
if (!status) return '세션';
|
|
214
|
+
return STATUS_LABEL[status] ?? status;
|
|
215
|
+
}
|