- AiGatewayService: punct unic de acces la modele (LiteLLM -> OpenRouter). Clasele de actiuni din blueprint 14.3 sunt aplicate hard: high_risk blocat permanent, material blocat pana exista coada de aprobare + idempotency; doar read_only si draft ruleaza. - Spotlighting (design doc Strat 1): continutul untrusted (date Apollo) e impachetat in <untrusted-data> cu instructiune de sistem ca e DATE, nu comenzi -- plus redactie PII deterministica (email/telefon/CNP) inainte sa plece catre provider extern. - ai_requests: adaugat action_class + context_manifest complet (nu doar hash) pentru audit; cost_usd_minor_units (integer) inlocuit cu cost_usd numeric(12,6) -- costurile reale sunt fractiuni de cent. - research-briefs: POST /draft genereaza un rezumat AI din datele Apollo fara sa salveze nimic (clasa draft -- userul revizuieste, apoi salveaza). - briefing: aiExplanation devine narativ real peste prioritizarea determinista; esecul AI intoarce null, nu strica briefingul. - teste: clase de actiuni blocate, spotlighting, redactie PII.
131 lines
5 KiB
TypeScript
131 lines
5 KiB
TypeScript
import { Injectable, Logger } from '@nestjs/common';
|
|
import { and, asc, eq, gte, isNull, lt, ne } from 'drizzle-orm';
|
|
import { db } from '../db/client';
|
|
import { organizations, researchBriefs, savedSegments, tasks } from '../db/schema';
|
|
import { AiGatewayService } from '../ai-gateway/ai-gateway.service';
|
|
import type { AuthenticatedSession } from '../auth/tenant.guard';
|
|
|
|
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
|
const BRIEFING_TEMPLATE_VERSION = 'daily-briefing@v1';
|
|
|
|
/**
|
|
* Blueprint 17 "Daily Briefing": Scheduler -> collect tasks/deadlines -> deterministic
|
|
* priority -> AI explanation -> save -> notify. Aici implementam doar partea
|
|
* deterministica (colectare + prioritizare); explicatia AI vine cu AI Gateway-ul
|
|
* (blueprint 14), inca neconstruit -- nu simulam text generat de AI.
|
|
*/
|
|
@Injectable()
|
|
export class BriefingService {
|
|
private readonly logger = new Logger(BriefingService.name);
|
|
|
|
constructor(private readonly aiGateway: AiGatewayService) {}
|
|
|
|
async today(session: AuthenticatedSession) {
|
|
const now = new Date();
|
|
const weekAgo = new Date(now.getTime() - SEVEN_DAYS_MS);
|
|
const weekFromNow = new Date(now.getTime() + SEVEN_DAYS_MS);
|
|
|
|
const activeStatuses = [ne(tasks.status, 'done'), ne(tasks.status, 'cancelled')] as const;
|
|
|
|
const [overdueTasks, upcomingTasks, newOrganizations, recentSegments, recentBriefs] =
|
|
await Promise.all([
|
|
db.query.tasks.findMany({
|
|
where: and(
|
|
eq(tasks.tenantId, session.tenantId),
|
|
isNull(tasks.deletedAt),
|
|
...activeStatuses,
|
|
lt(tasks.dueAt, now),
|
|
),
|
|
orderBy: asc(tasks.dueAt),
|
|
}),
|
|
db.query.tasks.findMany({
|
|
where: and(
|
|
eq(tasks.tenantId, session.tenantId),
|
|
isNull(tasks.deletedAt),
|
|
...activeStatuses,
|
|
gte(tasks.dueAt, now),
|
|
lt(tasks.dueAt, weekFromNow),
|
|
),
|
|
orderBy: [asc(tasks.priority), asc(tasks.dueAt)],
|
|
}),
|
|
db.query.organizations.findMany({
|
|
where: and(
|
|
eq(organizations.tenantId, session.tenantId),
|
|
isNull(organizations.deletedAt),
|
|
gte(organizations.createdAt, weekAgo),
|
|
),
|
|
}),
|
|
db.query.savedSegments.findMany({
|
|
where: and(eq(savedSegments.tenantId, session.tenantId), gte(savedSegments.createdAt, weekAgo)),
|
|
}),
|
|
db.query.researchBriefs.findMany({
|
|
where: and(eq(researchBriefs.tenantId, session.tenantId), gte(researchBriefs.createdAt, weekAgo)),
|
|
}),
|
|
]);
|
|
|
|
const weekInReview = {
|
|
newOrganizations: newOrganizations.length,
|
|
newSegments: recentSegments.length,
|
|
newResearchBriefs: recentBriefs.length,
|
|
};
|
|
|
|
return {
|
|
generatedAt: now.toISOString(),
|
|
overdueTasks,
|
|
upcomingTasks,
|
|
weekInReview,
|
|
aiExplanation: await this.explain(session, overdueTasks, upcomingTasks, weekInReview),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Narativul AI peste datele deja prioritizate determinist (blueprint 17:
|
|
* "deterministic priority -> AI explanation"). AI-ul explica, nu decide
|
|
* ordinea. Esecul nu strica briefingul -- intoarce null, iar UI arata doar
|
|
* partea deterministica.
|
|
*/
|
|
private async explain(
|
|
session: AuthenticatedSession,
|
|
overdueTasks: { title: string; dueAt: Date | null }[],
|
|
upcomingTasks: { title: string; dueAt: Date | null }[],
|
|
weekInReview: { newOrganizations: number; newSegments: number; newResearchBriefs: number },
|
|
): Promise<string | null> {
|
|
if (overdueTasks.length === 0 && upcomingTasks.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const formatTask = (task: { title: string; dueAt: Date | null }) =>
|
|
`- ${task.title}${task.dueAt ? ` (termen ${task.dueAt.toISOString().slice(0, 10)})` : ''}`;
|
|
|
|
const state = [
|
|
`Restante (${overdueTasks.length}):`,
|
|
...overdueTasks.slice(0, 10).map(formatTask),
|
|
`Urmatoarele 7 zile (${upcomingTasks.length}):`,
|
|
...upcomingTasks.slice(0, 10).map(formatTask),
|
|
`Saptamana: ${weekInReview.newOrganizations} companii noi, ${weekInReview.newSegments} segmente, ${weekInReview.newResearchBriefs} research briefs.`,
|
|
].join('\n');
|
|
|
|
try {
|
|
const result = await this.aiGateway.complete({
|
|
tenantId: session.tenantId,
|
|
requestedByUserId: session.userId,
|
|
purpose: 'daily_briefing',
|
|
actionClass: 'read_only',
|
|
allowedCategories: ['tasks', 'deadlines', 'organizations'],
|
|
deniedCategories: ['health', 'identity_documents'],
|
|
model: 'gpt-4o-mini',
|
|
templateVersion: BRIEFING_TEMPLATE_VERSION,
|
|
systemPrompt:
|
|
'Esti chief of staff. Scrii 2-3 propozitii in romana, direct, fara introduceri. ' +
|
|
'Folosesti exclusiv datele primite; nu inventezi taskuri sau termene. ' +
|
|
'Spui ce merita atentie azi si de ce.',
|
|
userPrompt: `Rezuma situatia de azi pe baza starii de mai jos:\n\n${state}`,
|
|
maxCostUsd: 0.05,
|
|
});
|
|
return result.content;
|
|
} catch (error) {
|
|
this.logger.warn(`Daily briefing AI explanation unavailable: ${(error as Error).message}`);
|
|
return null;
|
|
}
|
|
}
|
|
}
|