Skip to content
Open
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
1 change: 1 addition & 0 deletions server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"migration:revert": "typeorm-ts-node-commonjs migration:revert -d src/data-source.ts",
"migration:show": "typeorm-ts-node-commonjs migration:show -d src/data-source.ts",
"seed:scenario": "ts-node -r tsconfig-paths/register src/seed/seed-test-scenario.ts",
"seed:attendance-demo": "ts-node -r tsconfig-paths/register src/seed/seed-attendance-demo.ts",
"migration:run:prod": "typeorm migration:run -d dist/data-source.js",
"migration:revert:prod": "typeorm migration:revert -d dist/data-source.js",
"migration:show:prod": "typeorm migration:show -d dist/data-source.js"
Expand Down
107 changes: 105 additions & 2 deletions server/src/contributions/contribution.client.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
/// <reference types="jest" />
import { ServiceUnavailableException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ContributionClient } from './contribution.client';
Expand Down Expand Up @@ -74,6 +73,7 @@ const RAW_MEETING: TeamPipelineRequest['meetings'][number] = {
team_settings: SETTINGS,
participant_user_ids: [1, 2],
absent_user_ids: [],
excused_late_user_ids: [],
utterances: MEETING_REQ.utterances,
presence_events: MEETING_REQ.presence_events,
anomaly_events: [],
Expand Down Expand Up @@ -131,6 +131,10 @@ const pipelineResponse = (
final: number;
weights_used: Record<string, number>;
}>;
meeting_scores?: Partial<{
attend_score: number | null;
absent: boolean;
}>[];
} = {},
) => ({
name: '1',
Expand Down Expand Up @@ -158,6 +162,9 @@ const pipelineResponse = (
leader_applied: false,
...over.final,
},
// computeMeetingScores()가 attendance_ratio로 가져다 쓰는 단일 회의 상세값.
// 기본값(1.0)은 기존 테스트의 "정시 입장·완전 참석" 가정과 맞춘다.
meeting_scores: over.meeting_scores ?? [{ attend_score: 1.0, absent: false }],
});

function mockPipeline(body: unknown): jest.SpyInstance {
Expand Down Expand Up @@ -204,7 +211,7 @@ describe('ContributionClient — 외부 기여도 API(/pipeline/score) 연동',
expect(res!.scores).toHaveLength(2);
const u1 = res!.scores.find((s) => s.user_id === 1)!;
expect(u1.meeting_score).toBe(0.94); // 회의 1건 누적 = 그 회의 점수
expect(u1.attendance_ratio).toBe(1.0); // 로컬 파생 (attend/total)
expect(u1.attendance_ratio).toBe(1.0); // 엔진 응답의 attend_score(meeting_scores[0])
expect(u1.confidence_level).toBeNull(); // pipeline 미제공
expect(u1.speech_ratio).toBeCloseTo(0.75); // 300/400 원시 비율
});
Expand Down Expand Up @@ -232,6 +239,102 @@ describe('ContributionClient — 외부 기여도 API(/pipeline/score) 연동',
expect(res!.scores[0].meeting_score).toBeNull();
});

it('①: punctuality_score는 하드코딩 5분이 아니라 팀 설정의 late_threshold_minutes를 따른다', async () => {
fetchMock = mockPipeline(pipelineResponse());
const client = makeClient('http://contrib.test');

// SETTINGS.late_threshold_minutes=5(300초). user 1은 4분(240초) 지각 → 기준 이내 → 1.0
const req: MeetingScoreRequest = {
...MEETING_REQ,
presence_events: [
{
user_id: 1,
event_type: 'join',
disconnect_classification: null,
timestamp_offset_ms: 240_000,
},
MEETING_REQ.presence_events[1],
],
};
const res = await client.computeMeetingScores(req);
const u1 = res!.scores.find((s) => s.user_id === 1)!;
expect(u1.punctuality_score).toBe(1.0);

// late_threshold_minutes를 1분으로 바꾸면 같은 4분 지각이 기준 초과로 바뀐다
const reqShortThreshold: MeetingScoreRequest = {
...req,
team_settings: { ...SETTINGS, late_threshold_minutes: 1 },
};
const res2 = await client.computeMeetingScores(reqShortThreshold);
const u1b = res2!.scores.find((s) => s.user_id === 1)!;
expect(u1b.punctuality_score).toBe(0.0);
});

it('①: attendance_ratio는 엔진의 attend_score를 그대로 쓴다 (지각해도 100%로 뜨지 않음)', async () => {
// 엔진이 지각 페널티를 반영해 attend_score=0.65를 줬다면, 화면 표시값인
// attendance_ratio도 그 값을 그대로 받아야 한다. 과거엔 actual_attend_sec
// (자발적 자리비움만 차감, 지각 시간은 차감하지 않음)을 직접 나눈 값을 써서
// 지각해도 100%로 표시되는 버그가 있었다.
fetchMock = mockPipeline(
pipelineResponse({
meeting_scores: [{ attend_score: 0.65, absent: false }],
}),
);
const client = makeClient('http://contrib.test');

const res = await client.computeMeetingScores(MEETING_REQ);
const u1 = res!.scores.find((s) => s.user_id === 1)!;
expect(u1.attendance_ratio).toBe(0.65);
});

it('①: 완전 결석이면 attendance_ratio는 0 (엔진이 attend_score=null을 줘도)', async () => {
fetchMock = mockPipeline(
pipelineResponse({
meeting_scores: [{ attend_score: null, absent: true }],
}),
);
const client = makeClient('http://contrib.test');

// user 1이 입장 기록이 전혀 없는 회의로 구성 — deriveMemberData가 absent=true로 파생
const req: MeetingScoreRequest = {
...MEETING_REQ,
presence_events: [MEETING_REQ.presence_events[1]], // user 1 join 제거
};
const res = await client.computeMeetingScores(req);
const u1 = res!.scores.find((s) => s.user_id === 1)!;
expect(u1.attendance_ratio).toBe(0);
});

it('①: excused_late_user_ids에 포함된 멤버는 엔진 호출 시 excused_late=true로 전달된다', async () => {
// ①(단일 회의) 계산 경로는 사유 지각 승인 여부를 알 길이 없었다 — 회의 종료
// 직후 자동 계산 시점엔 보통 사유 신청이 아직 없어 문제가 안 됐지만, 시드나
// 재계산처럼 승인까지 끝난 뒤에 ①을 다시 계산하면 면제가 반영 안 된 값이
// 저장되는 회귀가 있었다. excused_late_user_ids로 명시적으로 전달해야 한다.
fetchMock = mockPipeline(pipelineResponse());
const client = makeClient('http://contrib.test');

await client.computeMeetingScores({
...MEETING_REQ,
excused_late_user_ids: [1],
});

const body = callBody(fetchMock, 0);
expect(body.meetings[0].excused_late).toBe(true);
});

it('①: excused_late_user_ids에 없는 멤버는 excused_late=false로 전달된다', async () => {
fetchMock = mockPipeline(pipelineResponse());
const client = makeClient('http://contrib.test');

await client.computeMeetingScores({
...MEETING_REQ,
excused_late_user_ids: [999], // user 1은 해당 없음
});

const body = callBody(fetchMock, 0);
expect(body.meetings[0].excused_late).toBe(false);
});

it('②③④ 멤버별 /pipeline/score 1회 — 원시 회의 행 + 액션 동봉, is_leader 전달', async () => {
fetchMock = mockPipeline(pipelineResponse());
const client = makeClient('http://contrib.test');
Expand Down
68 changes: 31 additions & 37 deletions server/src/contributions/contribution.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,45 +54,46 @@ export class ContributionClient {
`[회의 산정] meeting_id=${payload.meeting.id} 참여자=${payload.participant_user_ids.length}명`,
);
const cfg = mapTeamSettings(payload.team_settings);
const thresholdSec = payload.team_settings.late_threshold_minutes * 60;
const maxSec =
payload.team_settings.late_max_minutes > 0
? payload.team_settings.late_max_minutes * 60
: null;
const excusedLateSet = new Set(payload.excused_late_user_ids ?? []);
// ① 표시용 punctuality_score 의 "지각" 기준을 실제 점수 산정에 쓰이는
// late_threshold_sec(팀 설정의 지각 기준)과 일치시킨다. 과거엔 5분(300초)을
// 하드코딩해서, 팀이 지각 기준을 다르게 설정해도 화면 표시는 항상 5분 기준으로
// 나와 실제 점수와 어긋나는 문제가 있었다.
const lateThresholdSec = cfg.late_threshold_sec ?? 300;
const scores = await Promise.all(
payload.participant_user_ids.map(async (uid) => {
const { data, rawSpeechRatio } = deriveMemberData(payload, uid);
const isExcusedLate = excusedLateSet.has(uid);
const excusedLate =
payload.excused_late_user_ids?.includes(uid) ?? false;
const { data, rawSpeechRatio } = deriveMemberData(
payload,
uid,
excusedLate,
);
// 비정규 회의도 ① 점수는 산출해야 하므로 official 로 보낸다
// (officialness 는 누적(②) 포함 여부에만 쓰이고, ②는 별도 호출에서 반영).
const ext = await this.pipeline(
[{ ...data, is_official: true }],
false,
cfg,
);
// attendance_ratio는 "참여 시간 비율"이 아니라 엔진이 계산한 attend_score
// (출석비율 + 지각 페널티, 사유 지각 면제 포함)를 저장한다. actual_attend_sec은
// 자발적 자리비움만 차감하고 지각 시간은 차감하지 않아, 그 값을 그대로 쓰면
// 지각해도 "참석 100%"로 표시되는 문제가 있었다 — 화면(회의 상세·리포트)에
// 노출되는 이름이 "참석/출석"이므로 실제로 지각이 반영된 값을 보여줘야 한다.
const meetingScore = ext.meeting_scores[0];
const attendanceRatio = data.absent
? 0
: (meetingScore?.attend_score ?? null);
return {
user_id: uid,
speech_ratio: rawSpeechRatio,
speech_consistency: null,
attendance_ratio:
data.absent || (maxSec !== null && data.late_sec > maxSec)
? 0
: data.meeting_total_sec > 0
? Math.max(
0,
data.actual_attend_sec -
(!isExcusedLate && data.late_sec > thresholdSec
? data.late_sec
: 0),
) / data.meeting_total_sec
: null,
punctuality_score:
data.absent || (maxSec !== null && data.late_sec > maxSec)
? null
: isExcusedLate || data.late_sec <= thresholdSec
? 1.0
: 0.0,
attendance_ratio: attendanceRatio,
punctuality_score: data.absent
? null
: data.late_sec > lateThresholdSec
? 0.0
: 1.0,
// 포함 0건(최소시간 미만 등) = 측정 불가 → null.
// 무단 결석은 엔진이 0점으로 포함시키므로 0 이 저장된다.
meeting_score:
Expand Down Expand Up @@ -138,18 +139,10 @@ export class ContributionClient {
mt.absent_user_ids.includes(m.user_id),
)
.map((mt) => {
const { data } = deriveMemberData(mt, m.user_id);
// 지각 사유 승인자는 late_sec 를 0 으로 보내 외부 엔진도 패널티 없이 계산
const isExcusedLate = (mt.excused_late_user_ids ?? []).includes(
m.user_id,
);
const adjustedData = isExcusedLate
? { ...data, late_sec: 0 }
: data;
const excusedLate = mt.excused_late_user_ids.includes(m.user_id);
const { data } = deriveMemberData(mt, m.user_id, excusedLate);
// 무효 처리된 회의는 비정규로 보내 누적에서 제외시킨다
return mt.is_invalidated
? { ...adjustedData, is_official: false }
: adjustedData;
return mt.is_invalidated ? { ...data, is_official: false } : data;
});
if (rows.length === 0 && actions.length === 0) {
return {
Expand Down Expand Up @@ -262,6 +255,7 @@ function taskCarrierRow(userId: number): ExternalMemberMeetingData {
audio_loss_pct: 0,
speech_confidence: 1,
excused_absence: true,
excused_late: false,
absent: true,
is_official: false,
};
Expand Down
34 changes: 34 additions & 0 deletions server/src/contributions/contribution.mapper.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ describe('mapTeamSettings — 우리 설정 → 외부 TeamSettingsSchema', () =
weight_attend_in_meeting: 0.4,
weight_task_in_final: 0.5,
punctuality_grace_ratio: 0.1,
late_threshold_sec: 300, // 5분 → 초
late_max_sec: null, // late_max_minutes=0 → 상한 없음(null)
absence_grace_sec: 30,
leader_bonus: 0,
action_chars_limit: 500,
Expand All @@ -49,6 +51,25 @@ describe('mapTeamSettings — 우리 설정 → 외부 TeamSettingsSchema', () =
).toBe(0);
});

it('late_threshold_minutes/late_max_minutes(분) → late_threshold_sec/late_max_sec(초)로 변환한다', () => {
const result = mapTeamSettings({
...SETTINGS,
late_threshold_minutes: 5,
late_max_minutes: 10,
});
expect(result.late_threshold_sec).toBe(300);
expect(result.late_max_sec).toBe(600);
});

it('late_max_minutes=0(상한 없음)이면 late_max_sec=null로 변환한다', () => {
const result = mapTeamSettings({
...SETTINGS,
late_threshold_minutes: 5,
late_max_minutes: 0,
});
expect(result.late_max_sec).toBeNull();
});

it('lenient/strict 마감 모드는 그대로 전달한다', () => {
expect(
mapTeamSettings({ ...SETTINGS, deadline_penalty_curve: 'lenient' })
Expand Down Expand Up @@ -203,6 +224,19 @@ describe('deriveMemberData — 원시 이벤트 → 외부 MemberMeetingData', (
});
expect(deriveMemberData(req, 1).data.is_official).toBe(false);
});

it('excusedLate 인자를 안 넘기면 excused_late=false', () => {
const req = baseMeetingReq({ presence_events: [join(1, 300_000)] });
expect(deriveMemberData(req, 1).data.excused_late).toBe(false);
});

it('excusedLate=true 를 넘기면 그대로 excused_late=true 로 전달된다', () => {
const req = baseMeetingReq({ presence_events: [join(1, 300_000)] });
const { data } = deriveMemberData(req, 1, true);
expect(data.excused_late).toBe(true);
// 사유 지각이어도 late_sec 자체는 그대로 — 면제 여부 판단은 엔진이 한다
expect(data.late_sec).toBe(300);
});
});

describe('absentUnexcusedIds — 무단결석(0점·누적 포함) 멤버 판정', () => {
Expand Down
Loading
Loading