feat: 升级结构化计分可信度
This commit is contained in:
+172
-82
@@ -1,37 +1,53 @@
|
||||
import { ScoreMetric, ScoreSummary } from '@/lib/assessment-types';
|
||||
import { ScoreDirection, ScoreMetric, ScoreSummary, ScoreThreshold } from '@/lib/assessment-types';
|
||||
import { getScoringMetadata, MetricDefinition, QuestionnaireScoringMetadata } from '@/lib/scoring-metadata';
|
||||
import {
|
||||
calculateBigFiveResults,
|
||||
calculateIpipNeoResults,
|
||||
ipipNeoItemsByVersion,
|
||||
} from '@/components/questionnaire/test/private/BigFiveCalculator';
|
||||
import { calculateHEXACOResults } from '@/components/questionnaire/test/private/HEXACOCalculator';
|
||||
import { calculateRIASECResults, riasecTypes } from '@/components/questionnaire/test/private/RIASECCalculator';
|
||||
import { calculateRIASECResults } from '@/components/questionnaire/test/private/RIASECCalculator';
|
||||
import {
|
||||
calculateSchwartzResults,
|
||||
higherOrderNames,
|
||||
} from '@/components/questionnaire/test/private/SchwartzCalculator';
|
||||
|
||||
const levels: Record<string, string> = {
|
||||
minimal: '极轻或无',
|
||||
mild: '轻度',
|
||||
moderate: '中度',
|
||||
moderately_severe: '中重度',
|
||||
severe: '重度',
|
||||
normal: '正常范围',
|
||||
extremely_severe: '极重度',
|
||||
low: '较低',
|
||||
high: '较高',
|
||||
subthreshold: '亚阈值',
|
||||
no_insomnia: '无明显失眠',
|
||||
const fallbackMetadata: QuestionnaireScoringMetadata = {
|
||||
questionnaireVersion: 'unknown-questionnaire-v1',
|
||||
scoreVersion: 'mindscope-raw-v1',
|
||||
min: 0,
|
||||
reverseItems: [],
|
||||
direction: 'trait',
|
||||
highScoreMeaning: '高分代表原始作答总和更高,具体含义需要结合量表说明查看。',
|
||||
thresholds: [],
|
||||
note: '该量表暂以结构化原始分保存,用于保留完整记录与后续复核,不替代量表结果页中的正式解释。',
|
||||
};
|
||||
|
||||
const metric = (key: string, label: string, value: number, max?: number, level?: string): ScoreMetric => ({
|
||||
key,
|
||||
label,
|
||||
value,
|
||||
max,
|
||||
level: level ? levels[level] || level : undefined,
|
||||
});
|
||||
const labelMap: Record<string, string> = {
|
||||
extraversion: '外向性',
|
||||
agreeableness: '宜人性',
|
||||
conscientiousness: '尽责性',
|
||||
emotionalStability: '情绪稳定性',
|
||||
openness: '开放性',
|
||||
neuroticism: '神经质',
|
||||
honestyHumility: '诚实谦逊',
|
||||
emotionality: '情绪性',
|
||||
depression: '抑郁',
|
||||
anxiety: '焦虑',
|
||||
stress: '压力',
|
||||
opennessToChange: '开放变化',
|
||||
conservation: '保守稳定',
|
||||
selfEnhancement: '自我提升',
|
||||
selfTranscendence: '自我超越',
|
||||
R: '现实型',
|
||||
I: '研究型',
|
||||
A: '艺术型',
|
||||
S: '社会型',
|
||||
E: '企业型',
|
||||
C: '常规型',
|
||||
total: '总分',
|
||||
raw: '原始作答总和',
|
||||
};
|
||||
|
||||
function sum(answers: string[]) {
|
||||
return answers.reduce((total, answer) => total + (Number(answer) || 0), 0);
|
||||
@@ -41,106 +57,180 @@ function keyed(answers: string[]) {
|
||||
return Object.fromEntries(answers.map((answer, index) => [index + 1, answer]));
|
||||
}
|
||||
|
||||
function severity(score: number, thresholds: Array<[number, string]>) {
|
||||
return thresholds.reduce((current, [minimum, label]) => score >= minimum ? label : current, thresholds[0][1]);
|
||||
function metadataFor(questionnaireId: string) {
|
||||
return getScoringMetadata(questionnaireId) || {
|
||||
...fallbackMetadata,
|
||||
questionnaireVersion: `${questionnaireId || 'unknown'}-v1`,
|
||||
};
|
||||
}
|
||||
|
||||
function thresholdLabel(value: number, thresholds: ScoreThreshold[]) {
|
||||
if (!thresholds.length) return undefined;
|
||||
return thresholds.reduce((current, threshold) => (
|
||||
value >= threshold.min ? threshold.label : current
|
||||
), thresholds[0].label);
|
||||
}
|
||||
|
||||
function metricDefinition(meta: QuestionnaireScoringMetadata, key: string): MetricDefinition | undefined {
|
||||
return meta.metrics?.find((item) => item.key === key);
|
||||
}
|
||||
|
||||
function makeMetric(
|
||||
meta: QuestionnaireScoringMetadata,
|
||||
key: string,
|
||||
label: string,
|
||||
value: number,
|
||||
overrides: Partial<ScoreMetric> = {},
|
||||
): ScoreMetric {
|
||||
const definition = metricDefinition(meta, key);
|
||||
const min = overrides.min ?? definition?.min ?? meta.min;
|
||||
const max = overrides.max ?? definition?.max ?? meta.max;
|
||||
const thresholds = overrides.thresholds ?? definition?.thresholds ?? meta.thresholds;
|
||||
return {
|
||||
key,
|
||||
label,
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
direction: overrides.direction ?? definition?.direction ?? meta.direction,
|
||||
highScoreMeaning: overrides.highScoreMeaning ?? definition?.highScoreMeaning ?? meta.highScoreMeaning,
|
||||
thresholds,
|
||||
level: overrides.level ?? thresholdLabel(value, thresholds),
|
||||
};
|
||||
}
|
||||
|
||||
function finalize(
|
||||
questionnaireId: string,
|
||||
metrics: ScoreMetric[],
|
||||
primary?: ScoreMetric,
|
||||
options: { note?: string; scoringStatus?: ScoreSummary['scoringStatus'] } = {},
|
||||
): ScoreSummary {
|
||||
const meta = metadataFor(questionnaireId);
|
||||
return {
|
||||
primary,
|
||||
metrics,
|
||||
note: options.note ?? meta.note,
|
||||
questionnaireVersion: meta.questionnaireVersion,
|
||||
scoreVersion: meta.scoreVersion,
|
||||
min: meta.min,
|
||||
max: meta.max,
|
||||
reverseItems: meta.reverseItems,
|
||||
direction: meta.direction,
|
||||
highScoreMeaning: meta.highScoreMeaning,
|
||||
thresholds: meta.thresholds,
|
||||
scoringStatus: options.scoringStatus ?? 'structured',
|
||||
};
|
||||
}
|
||||
|
||||
export function buildScoreSummary(questionnaireId: string, answers: string[]): ScoreSummary {
|
||||
const meta = metadataFor(questionnaireId);
|
||||
|
||||
if (questionnaireId === 'bigfive') {
|
||||
const result = calculateBigFiveResults(answers);
|
||||
const names: Record<string, string> = {
|
||||
extraversion: '外向性',
|
||||
agreeableness: '宜人性',
|
||||
conscientiousness: '尽责性',
|
||||
emotionalStability: '情绪稳定性',
|
||||
openness: '开放性',
|
||||
};
|
||||
return { metrics: Object.entries(result).map(([key, value]) => metric(key, names[key], value.score, 50)) };
|
||||
const metrics = Object.entries(result).map(([key, value]) => (
|
||||
makeMetric(meta, key, labelMap[key] || key, value.score, { min: 10, max: 50 })
|
||||
));
|
||||
return finalize(questionnaireId, metrics);
|
||||
}
|
||||
|
||||
if (questionnaireId === 'bigfive-120' || questionnaireId === 'bigfive-300') {
|
||||
const version = questionnaireId === 'bigfive-120' ? 120 : 300;
|
||||
const result = calculateIpipNeoResults(answers, [...ipipNeoItemsByVersion[version]]);
|
||||
const names: Record<string, string> = {
|
||||
neuroticism: '神经质',
|
||||
extraversion: '外向性',
|
||||
openness: '开放性',
|
||||
agreeableness: '宜人性',
|
||||
conscientiousness: '尽责性',
|
||||
};
|
||||
const max = version === 120 ? 120 : 300;
|
||||
return { metrics: Object.entries(result.domains).map(([key, value]) => metric(key, names[key], value.score, max)) };
|
||||
const perDomainMax = version === 120 ? 120 : 300;
|
||||
const metrics = Object.entries(result.domains).map(([key, value]) => (
|
||||
makeMetric(meta, key, labelMap[key] || key, value.score, {
|
||||
min: value.itemCount,
|
||||
max: perDomainMax,
|
||||
})
|
||||
));
|
||||
return finalize(questionnaireId, metrics);
|
||||
}
|
||||
|
||||
if (questionnaireId === 'hexaco') {
|
||||
const names: Record<string, string> = {
|
||||
honestyHumility: '诚实谦逊',
|
||||
emotionality: '情绪性',
|
||||
extraversion: '外向性',
|
||||
agreeableness: '宜人性',
|
||||
conscientiousness: '尽责性',
|
||||
openness: '开放性',
|
||||
};
|
||||
return { metrics: Object.entries(calculateHEXACOResults(answers)).map(([key, value]) => metric(key, names[key], Number(value.toFixed(2)), 5)) };
|
||||
const metrics = Object.entries(calculateHEXACOResults(answers)).map(([key, value]) => (
|
||||
makeMetric(meta, key, labelMap[key] || key, Number(value.toFixed(2)), { min: 1, max: 5 })
|
||||
));
|
||||
return finalize(questionnaireId, metrics);
|
||||
}
|
||||
|
||||
if (questionnaireId === 'riasec') {
|
||||
const result = calculateRIASECResults(answers);
|
||||
return {
|
||||
primary: metric('holland', `霍兰德代码 ${result.hollandCode}`, result.ranking[0][1].score, 40),
|
||||
metrics: Object.entries(result.scores).map(([key, value]) => metric(key, riasecTypes[key as keyof typeof riasecTypes].name, value.score, 40)),
|
||||
};
|
||||
const metrics = Object.entries(result.scores).map(([key, value]) => (
|
||||
makeMetric(meta, key, labelMap[key] || key, value.score, { min: 0, max: 40 })
|
||||
));
|
||||
const primary = makeMetric(meta, 'holland', `霍兰德代码:${result.hollandCode}`, result.ranking[0][1].score, {
|
||||
min: 0,
|
||||
max: 40,
|
||||
});
|
||||
return finalize(questionnaireId, metrics, primary);
|
||||
}
|
||||
|
||||
if (questionnaireId === 'schwartz') {
|
||||
const result = calculateSchwartzResults(answers);
|
||||
return { metrics: Object.entries(result.higherOrderScores).map(([key, value]) => metric(key, higherOrderNames[key as keyof typeof higherOrderNames], Number(value.toFixed(2)), 5)) };
|
||||
}
|
||||
|
||||
const raw = sum(answers);
|
||||
const single: Record<string, { label: string; max: number; score?: number; level?: string }> = {
|
||||
phq9: { label: 'PHQ-9 总分', max: 27, level: severity(raw, [[0, 'minimal'], [5, 'mild'], [10, 'moderate'], [15, 'moderately_severe'], [20, 'severe']]) },
|
||||
gad7: { label: 'GAD-7 总分', max: 21, level: severity(raw, [[0, 'minimal'], [5, 'mild'], [10, 'moderate'], [15, 'severe']]) },
|
||||
isi: { label: 'ISI 总分', max: 28, level: severity(raw, [[0, 'no_insomnia'], [8, 'subthreshold'], [15, 'moderate'], [22, 'severe']]) },
|
||||
bdi2: { label: 'BDI-II 总分', max: 63, level: severity(raw, [[0, 'minimal'], [14, 'mild'], [20, 'moderate'], [29, 'severe']]) },
|
||||
who5: { label: 'WHO-5 百分制得分', max: 100, score: raw * 4 },
|
||||
};
|
||||
|
||||
if (single[questionnaireId]) {
|
||||
const item = single[questionnaireId];
|
||||
const primary = metric('total', item.label, item.score ?? raw, item.max, item.level);
|
||||
return { primary, metrics: [primary] };
|
||||
const metrics = Object.entries(result.higherOrderScores).map(([key, value]) => (
|
||||
makeMetric(meta, key, higherOrderNames[key as keyof typeof higherOrderNames] || labelMap[key] || key, Number(value.toFixed(2)), {
|
||||
min: 1,
|
||||
max: 5,
|
||||
})
|
||||
));
|
||||
return finalize(questionnaireId, metrics);
|
||||
}
|
||||
|
||||
if (questionnaireId === 'dass21') {
|
||||
const answerMap = keyed(answers);
|
||||
const groups = {
|
||||
depression: [3, 5, 10, 13, 16, 17, 21],
|
||||
anxiety: [2, 4, 7, 9, 15, 19, 20],
|
||||
stress: [1, 6, 8, 11, 12, 14, 18],
|
||||
};
|
||||
const names = { depression: '抑郁', anxiety: '焦虑', stress: '压力' };
|
||||
return { metrics: Object.entries(groups).map(([key, ids]) => metric(key, names[key as keyof typeof names], ids.reduce((total, id) => total + Number(keyed(answers)[id] || 0), 0) * 2, 42)) };
|
||||
const metrics = Object.entries(groups).map(([key, ids]) => {
|
||||
const value = ids.reduce((total, id) => total + Number(answerMap[id] || 0), 0) * 2;
|
||||
return makeMetric(meta, key, labelMap[key] || key, value, { min: 0, max: 42 });
|
||||
});
|
||||
return finalize(questionnaireId, metrics);
|
||||
}
|
||||
|
||||
if (questionnaireId === 'pss10') {
|
||||
const reverse = new Set([4, 5, 7, 8]);
|
||||
const score = answers.reduce((total, answer, index) => total + (reverse.has(index + 1) ? 4 - Number(answer) : Number(answer)), 0);
|
||||
const primary = metric('total', 'PSS-10 总分', score, 40, severity(score, [[0, 'low'], [14, 'moderate'], [27, 'high']]));
|
||||
return { primary, metrics: [primary] };
|
||||
const score = answers.reduce((total, answer, index) => {
|
||||
const value = Number(answer) || 0;
|
||||
return total + (reverse.has(index + 1) ? 4 - value : value);
|
||||
}, 0);
|
||||
const primary = makeMetric(meta, 'total', 'PSS-10 总分', score, { min: 0, max: 40 });
|
||||
return finalize(questionnaireId, [primary], primary);
|
||||
}
|
||||
|
||||
if (questionnaireId === 'sds') {
|
||||
const reverse = new Set([2, 5, 6, 11, 12, 14, 16, 17, 18, 20]);
|
||||
const original = answers.reduce((total, answer, index) => total + (reverse.has(index + 1) ? 5 - Number(answer) : Number(answer)), 0);
|
||||
const original = answers.reduce((total, answer, index) => {
|
||||
const value = Number(answer) || 0;
|
||||
return total + (reverse.has(index + 1) ? 5 - value : value);
|
||||
}, 0);
|
||||
const score = Math.round(original * 1.25);
|
||||
const primary = metric('total', 'SDS 标准分', score, 100, severity(score, [[0, 'normal'], [53, 'mild'], [63, 'moderate'], [73, 'severe']]));
|
||||
return { primary, metrics: [primary] };
|
||||
const primary = makeMetric(meta, 'total', 'SDS 标准分', score, { min: 25, max: 100 });
|
||||
return finalize(questionnaireId, [primary], primary);
|
||||
}
|
||||
|
||||
const primary = metric('raw', '原始作答总和', raw);
|
||||
return {
|
||||
primary,
|
||||
metrics: [primary],
|
||||
note: '该数值仅用于保存和核对作答,不替代量表结果页中的正式解释。',
|
||||
const raw = sum(answers);
|
||||
const single: Record<string, { label: string; value: number; max?: number }> = {
|
||||
phq9: { label: 'PHQ-9 总分', value: raw, max: 27 },
|
||||
gad7: { label: 'GAD-7 总分', value: raw, max: 21 },
|
||||
isi: { label: 'ISI 总分', value: raw, max: 28 },
|
||||
bdi2: { label: 'BDI-II 总分', value: raw, max: 63 },
|
||||
who5: { label: 'WHO-5 百分制得分', value: raw * 4, max: 100 },
|
||||
crt: { label: 'CRT 答对题数', value: raw, max: 7 },
|
||||
};
|
||||
|
||||
if (single[questionnaireId]) {
|
||||
const item = single[questionnaireId];
|
||||
const primary = makeMetric(meta, 'total', item.label, item.value, { min: meta.min, max: item.max ?? meta.max });
|
||||
return finalize(questionnaireId, [primary], primary);
|
||||
}
|
||||
|
||||
const primary = makeMetric(meta, 'raw', '原始作答总和', raw, {
|
||||
min: meta.min,
|
||||
max: meta.max,
|
||||
direction: meta.direction as ScoreDirection,
|
||||
});
|
||||
return finalize(questionnaireId, [primary], primary, { scoringStatus: 'raw' });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user