feat: Faza A2 -- intelligence proxy, saved segments, research briefs, briefing

- IntelligenceModule: proxy tenant-scoped catre intelligence-api
  (/companies/search, /companies/{id}); ceo-web nu vorbeste niciodata
  direct cu intelligence-api (blueprint 10.3/16).
- SavedSegments: salveaza o cautare Apollo si o ruleaza din nou oricand.
- ResearchBriefs: dovezi curatate manual per companie, cu sursa Apollo
  implicita plus surse suplimentare -- explicit fara rezumat generat de
  AI (AI Gateway inca neconstruit, blueprint 14).
- BriefingService: /v1/briefing/today, agregare deterministica de
  taskuri restante/viitoare + activitate saptamanala; campul
  aiExplanation ramane null si vizibil in raspuns, nu simulat.
This commit is contained in:
valentinbvro 2026-07-28 22:01:17 +02:00
parent 0ea8e69233
commit 0f8390f31b
21 changed files with 1882 additions and 0 deletions

View file

@ -0,0 +1,22 @@
CREATE TABLE IF NOT EXISTS "research_briefs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tenant_id" uuid NOT NULL,
"created_by_user_id" uuid NOT NULL,
"organization_id" text NOT NULL,
"organization_name" text,
"title" text NOT NULL,
"summary" text NOT NULL,
"sources" jsonb DEFAULT '[]'::jsonb NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "saved_segments" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tenant_id" uuid NOT NULL,
"created_by_user_id" uuid NOT NULL,
"name" text NOT NULL,
"query" text NOT NULL,
"result_limit" integer DEFAULT 20 NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);

File diff suppressed because it is too large Load diff

View file

@ -15,6 +15,13 @@
"when": 1785264962186,
"tag": "0001_lovely_brood",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1785268609839,
"tag": "0002_burly_nightcrawler",
"breakpoints": true
}
]
}

View file

@ -13,6 +13,10 @@ import { AuditModule } from './audit/audit.module';
import { OrganizationsModule } from './organizations/organizations.module';
import { TasksModule } from './tasks/tasks.module';
import { TenantsModule } from './tenants/tenants.module';
import { IntelligenceModule } from './intelligence/intelligence.module';
import { SegmentsModule } from './segments/segments.module';
import { ResearchBriefsModule } from './research-briefs/research-briefs.module';
import { BriefingModule } from './briefing/briefing.module';
import { SessionGuard } from './auth/session.guard';
import { TenantGuard } from './auth/tenant.guard';
@ -42,6 +46,10 @@ function parseRedisConnection(redisUrl: string | undefined) {
TenantsModule,
OrganizationsModule,
TasksModule,
IntelligenceModule,
SegmentsModule,
ResearchBriefsModule,
BriefingModule,
],
controllers: [HealthController],
providers: [

View file

@ -0,0 +1,14 @@
import { Controller, Get } from '@nestjs/common';
import { CurrentSession } from '../auth/session.decorator';
import type { AuthenticatedSession } from '../auth/tenant.guard';
import { BriefingService } from './briefing.service';
@Controller('briefing')
export class BriefingController {
constructor(private readonly briefingService: BriefingService) {}
@Get('today')
today(@CurrentSession() session: AuthenticatedSession) {
return this.briefingService.today(session);
}
}

View file

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { BriefingController } from './briefing.controller';
import { BriefingService } from './briefing.service';
@Module({
controllers: [BriefingController],
providers: [BriefingService],
})
export class BriefingModule {}

View file

@ -0,0 +1,73 @@
import { Injectable } 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 type { AuthenticatedSession } from '../auth/tenant.guard';
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
/**
* 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 {
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)),
}),
]);
return {
generatedAt: now.toISOString(),
overdueTasks,
upcomingTasks,
weekInReview: {
newOrganizations: newOrganizations.length,
newSegments: recentSegments.length,
newResearchBriefs: recentBriefs.length,
},
// camp explicit -- semnaleaza in UI ca lipseste inca stratul AI, nu-l ascunde
aiExplanation: null,
};
}
}

View file

@ -245,3 +245,33 @@ export const auditLog = pgTable('audit_log', {
correlationId: uuid('correlation_id'),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
// --- Intelligence Engine / Faza A2 (blueprint sectiunea 10.3, 17 "Lead Intelligence") ---
// Apollo/ClickHouse raman sursa de adevar pentru datele B2B (blueprint 9: "Private data !=
// B2B intelligence"); aici pastram doar referinte (organization_id extern) si curatoria
// tenantului -- cautari salvate si dovezi de research, nu o copie a datasetului.
export const savedSegments = pgTable('saved_segments', {
id: uuid('id').defaultRandom().primaryKey(),
tenantId: uuid('tenant_id').notNull(),
createdByUserId: uuid('created_by_user_id').notNull(),
name: text('name').notNull(),
query: text('query').notNull(),
resultLimit: integer('result_limit').notNull().default(20),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
// summary e curatat manual de user in absenta AI Gateway-ului (blueprint sectiunea 14);
// schema e gandita ca AI Gateway sa poata popula acelasi camp mai tarziu fara migrare
export const researchBriefs = pgTable('research_briefs', {
id: uuid('id').defaultRandom().primaryKey(),
tenantId: uuid('tenant_id').notNull(),
createdByUserId: uuid('created_by_user_id').notNull(),
organizationId: text('organization_id').notNull(),
organizationName: text('organization_name'),
title: text('title').notNull(),
summary: text('summary').notNull(),
sources: jsonb('sources').notNull().default([]),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});

14
src/intelligence/dto.ts Normal file
View file

@ -0,0 +1,14 @@
import { Type } from 'class-transformer';
import { IsInt, IsString, Length, Max, Min } from 'class-validator';
export class SearchCompaniesQueryDto {
@IsString()
@Length(2, 200)
q!: string;
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit: number = 20;
}

View file

@ -0,0 +1,18 @@
import { Controller, Get, Param, Query } from '@nestjs/common';
import { SearchCompaniesQueryDto } from './dto';
import { IntelligenceService } from './intelligence.service';
@Controller('intelligence/companies')
export class IntelligenceController {
constructor(private readonly intelligenceService: IntelligenceService) {}
@Get('search')
search(@Query() query: SearchCompaniesQueryDto) {
return this.intelligenceService.searchCompanies(query.q, query.limit);
}
@Get(':organizationId')
getCompany(@Param('organizationId') organizationId: string) {
return this.intelligenceService.getCompany(organizationId);
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { IntelligenceController } from './intelligence.controller';
import { IntelligenceService } from './intelligence.service';
@Module({
controllers: [IntelligenceController],
providers: [IntelligenceService],
exports: [IntelligenceService],
})
export class IntelligenceModule {}

View file

@ -0,0 +1,55 @@
import { BadGatewayException, Injectable, NotFoundException } from '@nestjs/common';
import type { CompanySearchResponse, CompanySearchResult } from './intelligence.types';
const REQUEST_TIMEOUT_MS = 8000;
/**
* Client subtire catre intelligence-api. Blueprint 10.3/16: "company search prin
* Intelligence API, nu SQL direct" -- ceo-web nu vorbeste niciodata direct cu
* intelligence-api, ca sa putem adauga aici audit, quotas si viitorul AI Gateway
* fara sa schimbam contractul catre frontend.
*/
@Injectable()
export class IntelligenceService {
private readonly baseUrl = process.env.INTELLIGENCE_API_URL ?? 'http://localhost:8000';
async searchCompanies(query: string, limit: number): Promise<CompanySearchResponse> {
const response = await this.request('/companies/search', {
method: 'POST',
body: JSON.stringify({ query, limit }),
});
return (await response.json()) as CompanySearchResponse;
}
async getCompany(organizationId: string): Promise<CompanySearchResult> {
const response = await this.request(`/companies/${encodeURIComponent(organizationId)}`);
const data = (await response.json()) as CompanySearchResult | { error: string };
if ('error' in data) {
throw new NotFoundException('Company not found in intelligence data');
}
return data;
}
private async request(path: string, init: RequestInit = {}): Promise<Response> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
const response = await fetch(`${this.baseUrl}${path}`, {
...init,
headers: { 'Content-Type': 'application/json', ...init.headers },
signal: controller.signal,
});
if (!response.ok) {
throw new BadGatewayException('intelligence-api request failed');
}
return response;
} catch (error) {
if (error instanceof BadGatewayException) {
throw error;
}
throw new BadGatewayException('intelligence-api unreachable');
} finally {
clearTimeout(timeout);
}
}
}

View file

@ -0,0 +1,17 @@
// Forma exacta intoarsa de intelligence-api (app/routers/companies.py SEARCH_COLUMNS) --
// tinuta sincronizata manual, nu e generata; intelligence-api nu publica un schema versionat.
export interface CompanySearchResult {
organization_id: string;
organization_name: string;
normalized_domain: string;
hq_city: string;
hq_country: string;
industries: string[];
num_current_employees: number | null;
revenue_in_thousands: number | null;
}
export interface CompanySearchResponse {
results: CompanySearchResult[];
count: number;
}

View file

@ -0,0 +1,36 @@
import { Type } from 'class-transformer';
import { IsArray, IsOptional, IsString, IsUrl, Length, ValidateNested } from 'class-validator';
export class SourceDto {
@IsString()
@Length(1, 200)
label!: string;
@IsUrl()
url!: string;
}
export class CreateResearchBriefDto {
@IsString()
@Length(1, 128)
organizationId!: string;
@IsOptional()
@IsString()
@Length(1, 250)
organizationName?: string;
@IsString()
@Length(1, 250)
title!: string;
@IsString()
@Length(1, 5000)
summary!: string;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => SourceDto)
sources?: SourceDto[];
}

View file

@ -0,0 +1,30 @@
import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Post } from '@nestjs/common';
import { CurrentSession } from '../auth/session.decorator';
import type { AuthenticatedSession } from '../auth/tenant.guard';
import { CreateResearchBriefDto } from './dto';
import { ResearchBriefsService } from './research-briefs.service';
@Controller('research-briefs')
export class ResearchBriefsController {
constructor(private readonly researchBriefsService: ResearchBriefsService) {}
@Get()
list(@CurrentSession() session: AuthenticatedSession) {
return this.researchBriefsService.list(session);
}
@Get(':id')
getById(@CurrentSession() session: AuthenticatedSession, @Param('id', ParseUUIDPipe) id: string) {
return this.researchBriefsService.getById(session, id);
}
@Post()
create(@CurrentSession() session: AuthenticatedSession, @Body() dto: CreateResearchBriefDto) {
return this.researchBriefsService.create(session, dto);
}
@Delete(':id')
remove(@CurrentSession() session: AuthenticatedSession, @Param('id', ParseUUIDPipe) id: string) {
return this.researchBriefsService.remove(session, id);
}
}

View file

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { ResearchBriefsController } from './research-briefs.controller';
import { ResearchBriefsService } from './research-briefs.service';
@Module({
controllers: [ResearchBriefsController],
providers: [ResearchBriefsService],
})
export class ResearchBriefsModule {}

View file

@ -0,0 +1,71 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { and, desc, eq } from 'drizzle-orm';
import { db } from '../db/client';
import { researchBriefs } from '../db/schema';
import { AuditService } from '../audit/audit.service';
import type { AuthenticatedSession } from '../auth/tenant.guard';
import type { CreateResearchBriefDto } from './dto';
@Injectable()
export class ResearchBriefsService {
constructor(private readonly audit: AuditService) {}
async list(session: AuthenticatedSession) {
return db.query.researchBriefs.findMany({
where: eq(researchBriefs.tenantId, session.tenantId),
orderBy: desc(researchBriefs.createdAt),
});
}
async getById(session: AuthenticatedSession, id: string) {
const brief = await db.query.researchBriefs.findFirst({
where: and(eq(researchBriefs.id, id), eq(researchBriefs.tenantId, session.tenantId)),
});
if (!brief) {
throw new NotFoundException('Research brief not found');
}
return brief;
}
async create(session: AuthenticatedSession, dto: CreateResearchBriefDto) {
// Provenance obligatoriu (blueprint 9.1 Evidence & Provenance Fabric): chiar in
// versiunea curatata manual, fiecare brief indica cel putin sursa Apollo/intelligence-api.
const sources = [
{ label: 'Apollo / intelligence-api', url: 'https://intelligence-api.boardmind.dev' },
...(dto.sources ?? []),
];
const [row] = await db
.insert(researchBriefs)
.values({
tenantId: session.tenantId,
createdByUserId: session.userId,
organizationId: dto.organizationId,
organizationName: dto.organizationName,
title: dto.title,
summary: dto.summary,
sources,
})
.returning();
await this.audit.record(null, {
tenantId: session.tenantId,
actorId: session.userId,
action: 'research_brief.created',
resource: `research_brief:${row.id}`,
});
return row;
}
async remove(session: AuthenticatedSession, id: string) {
const brief = await this.getById(session, id);
await db.delete(researchBriefs).where(eq(researchBriefs.id, brief.id));
await this.audit.record(null, {
tenantId: session.tenantId,
actorId: session.userId,
action: 'research_brief.deleted',
resource: `research_brief:${id}`,
});
return { deleted: true };
}
}

16
src/segments/dto.ts Normal file
View file

@ -0,0 +1,16 @@
import { IsInt, IsString, Length, Max, Min } from 'class-validator';
export class CreateSegmentDto {
@IsString()
@Length(1, 200)
name!: string;
@IsString()
@Length(2, 200)
query!: string;
@IsInt()
@Min(1)
@Max(100)
resultLimit: number = 20;
}

View file

@ -0,0 +1,30 @@
import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Post } from '@nestjs/common';
import { CurrentSession } from '../auth/session.decorator';
import type { AuthenticatedSession } from '../auth/tenant.guard';
import { CreateSegmentDto } from './dto';
import { SegmentsService } from './segments.service';
@Controller('segments')
export class SegmentsController {
constructor(private readonly segmentsService: SegmentsService) {}
@Get()
list(@CurrentSession() session: AuthenticatedSession) {
return this.segmentsService.list(session);
}
@Post()
create(@CurrentSession() session: AuthenticatedSession, @Body() dto: CreateSegmentDto) {
return this.segmentsService.create(session, dto);
}
@Get(':id/run')
run(@CurrentSession() session: AuthenticatedSession, @Param('id', ParseUUIDPipe) id: string) {
return this.segmentsService.run(session, id);
}
@Delete(':id')
remove(@CurrentSession() session: AuthenticatedSession, @Param('id', ParseUUIDPipe) id: string) {
return this.segmentsService.remove(session, id);
}
}

View file

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { IntelligenceModule } from '../intelligence/intelligence.module';
import { SegmentsController } from './segments.controller';
import { SegmentsService } from './segments.service';
@Module({
imports: [IntelligenceModule],
controllers: [SegmentsController],
providers: [SegmentsService],
})
export class SegmentsModule {}

View file

@ -0,0 +1,72 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { and, desc, eq } from 'drizzle-orm';
import { db } from '../db/client';
import { savedSegments } from '../db/schema';
import { AuditService } from '../audit/audit.service';
import { IntelligenceService } from '../intelligence/intelligence.service';
import type { AuthenticatedSession } from '../auth/tenant.guard';
import type { CreateSegmentDto } from './dto';
@Injectable()
export class SegmentsService {
constructor(
private readonly audit: AuditService,
private readonly intelligence: IntelligenceService,
) {}
async list(session: AuthenticatedSession) {
return db.query.savedSegments.findMany({
where: eq(savedSegments.tenantId, session.tenantId),
orderBy: desc(savedSegments.createdAt),
});
}
async create(session: AuthenticatedSession, dto: CreateSegmentDto) {
const [row] = await db
.insert(savedSegments)
.values({
tenantId: session.tenantId,
createdByUserId: session.userId,
name: dto.name,
query: dto.query,
resultLimit: dto.resultLimit,
})
.returning();
await this.audit.record(null, {
tenantId: session.tenantId,
actorId: session.userId,
action: 'segment.created',
resource: `segment:${row.id}`,
});
return row;
}
/** Re-ruleaza cautarea salvata contra intelligence-api si intoarce rezultate proaspete. */
async run(session: AuthenticatedSession, id: string) {
const segment = await this.getById(session, id);
const results = await this.intelligence.searchCompanies(segment.query, segment.resultLimit);
return { segment, ...results };
}
async remove(session: AuthenticatedSession, id: string) {
const segment = await this.getById(session, id);
await db.delete(savedSegments).where(eq(savedSegments.id, segment.id));
await this.audit.record(null, {
tenantId: session.tenantId,
actorId: session.userId,
action: 'segment.deleted',
resource: `segment:${id}`,
});
return { deleted: true };
}
private async getById(session: AuthenticatedSession, id: string) {
const segment = await db.query.savedSegments.findFirst({
where: and(eq(savedSegments.id, id), eq(savedSegments.tenantId, session.tenantId)),
});
if (!segment) {
throw new NotFoundException('Segment not found');
}
return segment;
}
}