Commit 838d26b9 by luoqi

feat(persona): 标签覆盖率日报 — 每天 08:00 企微,显著下跌升级 warning

标签质量运营第一道防线:规则标签会静默腐烂(上游字段改名/枚举漂移/断供时悄悄变空)。
- 每天北京 08:00(增量+重算之后)按租户算 16 标签覆盖人数/占比,对比 24h 前;
- "昨天覆盖率"从画像版本流(computed_at/superseded_at)直接反推 — 零快照表零迁移;
- 显著下跌(相对 ≥10% 且绝对 ≥20 人)→ warning 并在标题点名,平时 info 日报;
- 走现有 AlertService(企微 webhook);PAC_COVERAGE_CRON 可调。

验证:本地库 SQL 实跑(16 标签覆盖与设计样本一致,24h 反推出数);
真实格式日报已发企微测试 errcode=0。service tsc 0。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
parent f3277ba4
import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { Prisma } from '@prisma/client';
import { PERSONA_FEATURE_SPECS } from '@pac/types';
import { PrismaService } from '../../prisma/prisma.service';
import { AlertService } from '../../common/alerting/alert.service';
/** 相对降幅 ≥10% 且绝对减少 ≥20 人才算"显著下跌"(避免小基数抖动刷屏) */
const DROP_RATIO = 0.1;
const DROP_MIN_ABS = 20;
/**
* 画像标签覆盖率日报 — 标签质量运营的第一道防线。
*
* 动机:规则标签会"静默腐烂"(上游字段改名/枚举漂移/摄入断供时,标签悄悄变空,
* 没人会发现)。每天早上把 16 标签覆盖率与 24h 前对比,显著下跌发企微 warning,
* 平时发 info 日报。
*
* 实现取巧:画像是版本流(computed_at / superseded_at),"昨天此刻的覆盖率"直接
* 从版本流反推(当时 active 的版本),无需建快照表、无迁移。
*/
@Injectable()
export class PersonaCoverageMonitorService {
private readonly logger = new Logger(PersonaCoverageMonitorService.name);
constructor(
private readonly prisma: PrismaService,
private readonly alert: AlertService,
) {}
/** 默认每天北京时间 08:00(增量 02:30 + 画像重算之后,上班之前) */
@Cron(process.env.PAC_COVERAGE_CRON || '0 8 * * *', {
name: 'persona-coverage-daily',
timeZone: 'Asia/Shanghai',
})
async runDaily(): Promise<void> {
try {
await this.reportAllTenants();
} catch (err) {
this.logger.error(`coverage report failed: ${err instanceof Error ? err.message : err}`);
}
}
async reportAllTenants(): Promise<void> {
const tenants = await this.prisma.$queryRaw<Array<{ host_id: string; tenant_id: string }>>(
Prisma.sql`SELECT DISTINCT host_id, tenant_id FROM personas`,
);
for (const t of tenants) {
await this.reportTenant(t.host_id, t.tenant_id);
}
}
private async reportTenant(hostId: string, tenantId: string): Promise<void> {
const now = new Date();
const dayAgo = new Date(now.getTime() - 24 * 3600_000);
const [curTotal, prevTotal, curByKey, prevByKey] = await Promise.all([
this.totalAt(hostId, tenantId, now),
this.totalAt(hostId, tenantId, dayAgo),
this.coverageAt(hostId, tenantId, now),
this.coverageAt(hostId, tenantId, dayAgo),
]);
if (curTotal === 0) return; // 租户尚无画像,不发
const drops: string[] = [];
const lines: string[] = [];
for (const spec of Object.values(PERSONA_FEATURE_SPECS)) {
const cur = curByKey.get(spec.key) ?? 0;
const prev = prevByKey.get(spec.key) ?? 0;
const pct = ((cur / curTotal) * 100).toFixed(1);
const delta = cur - prev;
const deltaStr = delta === 0 ? '—' : delta > 0 ? `+${delta}` : `${delta}`;
const dropped = prev > 0 && -delta >= DROP_MIN_ABS && -delta / prev >= DROP_RATIO;
if (dropped) drops.push(`${spec.nameZh} ${prev}${cur}`);
lines.push(`${dropped ? '⚠️ ' : ''}${spec.nameZh}: ${cur} (${pct}%) ${deltaStr}`);
}
const totalDelta = curTotal - prevTotal;
await this.alert.send({
level: drops.length > 0 ? 'warning' : 'info',
title:
drops.length > 0
? `画像覆盖率显著下跌:${drops.join(';')}`
: `画像标签覆盖率日报(${tenantId})`,
body: [
`租户 ${tenantId} · 画像患者 ${curTotal}(${totalDelta >= 0 ? '+' : ''}${totalDelta} vs 昨日)`,
'',
...lines,
].join('\n'),
context: { kind: 'persona-coverage', hostId, tenantId },
});
}
/** 时刻 at 的画像患者总数(版本流反推:当时 active 的版本) */
private async totalAt(hostId: string, tenantId: string, at: Date): Promise<number> {
const rows = await this.prisma.$queryRaw<Array<{ total: number }>>(Prisma.sql`
SELECT COUNT(DISTINCT p.patient_id)::int AS total
FROM personas p
WHERE p.host_id = ${hostId}::uuid AND p.tenant_id = ${tenantId}
AND p.computed_at <= ${at}
AND (p.superseded_at IS NULL OR p.superseded_at > ${at})
`);
return rows[0]?.total ?? 0;
}
/** 时刻 at 各标签覆盖人数 */
private async coverageAt(hostId: string, tenantId: string, at: Date): Promise<Map<string, number>> {
const rows = await this.prisma.$queryRaw<Array<{ key: string; cnt: number }>>(Prisma.sql`
SELECT pf.key AS key, COUNT(DISTINCT p.patient_id)::int AS cnt
FROM personas p
JOIN persona_features pf ON pf.persona_id = p.id
WHERE p.host_id = ${hostId}::uuid AND p.tenant_id = ${tenantId}
AND p.computed_at <= ${at}
AND (p.superseded_at IS NULL OR p.superseded_at > ${at})
GROUP BY pf.key
`);
return new Map(rows.map((r) => [r.key, r.cnt]));
}
}
import { Module } from '@nestjs/common';
import { PersonaController } from './persona.controller';
import { PersonaService } from './persona.service';
import { PersonaCoverageMonitorService } from './persona-coverage-monitor.service';
import { FeatureRegistry } from './features/feature.registry';
import { EntitlementStatusFeatureExtractor } from './features/entitlement-status.feature';
import { RfmFeatureExtractor } from './features/rfm.feature';
......@@ -25,6 +26,7 @@ import { ClinicalGapModule } from '../clinical-gap/clinical-gap.module';
controllers: [PersonaController],
providers: [
PersonaService,
PersonaCoverageMonitorService,
FeatureRegistry,
// W7:rfm 统一了旧 value/recall_risk/treatment_chain_status,三个旧 extractor 已摘除。
RfmFeatureExtractor,
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment