ceo-api/src/briefing/briefing.service.ts
valentinbvro 3bc0d65c46 feat: AI Gateway -- Context Manifest, model routing, cost audit, guardrails
- 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.
2026-07-28 22:34:36 +02:00

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;
}
}
}