feat(agents): add admin-configurable evaluation gate for agent loop quality control
Add a configurable evaluation gate system that allows administrators to define quality rules per consulting stage. The gate checks are executed programmatically before the agent loop returns a response to the user. ## Architecture - **Zero-config safe**: Empty rules table = no checks = current behavior preserved - **Callback-based decoupling**: agent-loop.ts receives an optional callback, stays decoupled from database layer - **Max 1 retry**: On RETRY/SUPPLEMENT failure, recurse once without gate to prevent infinite loops - **Error-tolerant**: Gate exceptions are caught and logged, never block responses ## New files - `database/migrations/20260206_add_evaluation_rules.sql` — DB migration - `domain/entities/evaluation-rule.entity.ts` — Domain entity with 6 rule types (FIELD_COMPLETENESS, ASSESSMENT_QUALITY, RESPONSE_LENGTH, MUST_CONTAIN, STAGE_MIN_TURNS, CONVERSION_SIGNAL) and 4 failure actions (RETRY, SUPPLEMENT, WARN_AND_PASS, ESCALATE) - `domain/repositories/evaluation-rule.repository.interface.ts` — Repository contract - `infrastructure/database/postgres/entities/evaluation-rule.orm.ts` — TypeORM ORM entity - `infrastructure/database/postgres/repositories/evaluation-rule.repository.ts` — Repository impl - `infrastructure/agents/coordinator/evaluation-gate.service.ts` — Core evaluation engine with 5-minute rule cache, per-rule-type evaluators, severity-based action resolution, and feedback message builder for model retry - `application/dtos/evaluation-rule.dto.ts` — Create/Update/Test DTOs - `adapters/inbound/admin-evaluation-rule.controller.ts` — Admin CRUD API with 8 endpoints: list, get, create, update, delete, toggle, test (dry-run), clear cache ## Modified files - `agent.types.ts` — Add optional `evaluationGate` callback to `AgentLoopParams` - `stream.types.ts` — Add `EvaluationWarningEvent`, `'evaluating'` phase - `agent-loop.ts` — Insert gate check at termination point (line 315) - `coordinator-agent.service.ts` — Inject EvaluationGateService, build callback, handle `evaluation_warning` event in StreamChunk mapping - `agents.module.ts` — Register EvaluationRuleORM, repository, EvaluationGateService - `conversation.module.ts` — Register AdminEvaluationRuleController Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
714a674818
commit
00a0ac3820
|
|
@ -0,0 +1,26 @@
|
|||
-- Evaluation Rules: Admin-configurable quality gate rules per consulting stage
|
||||
-- Zero rows = zero checks = current behavior preserved
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS evaluation_rules (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id uuid,
|
||||
stage varchar(50) NOT NULL, -- consulting stage (greeting, info_collection, etc.) or '*' for all stages
|
||||
rule_type varchar(50) NOT NULL, -- FIELD_COMPLETENESS, ASSESSMENT_QUALITY, RESPONSE_LENGTH, MUST_CONTAIN, STAGE_MIN_TURNS, CONVERSION_SIGNAL
|
||||
name varchar(255) NOT NULL,
|
||||
description text,
|
||||
config jsonb NOT NULL DEFAULT '{}', -- rule-specific parameters
|
||||
enabled boolean DEFAULT true,
|
||||
priority integer DEFAULT 0, -- lower = run first
|
||||
failure_action varchar(20) NOT NULL DEFAULT 'WARN_AND_PASS', -- RETRY, SUPPLEMENT, WARN_AND_PASS, ESCALATE
|
||||
created_by uuid,
|
||||
updated_by uuid,
|
||||
created_at timestamptz DEFAULT now(),
|
||||
updated_at timestamptz DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_evaluation_rules_tenant ON evaluation_rules(tenant_id);
|
||||
CREATE INDEX idx_evaluation_rules_tenant_stage ON evaluation_rules(tenant_id, stage, enabled);
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -0,0 +1,289 @@
|
|||
/**
|
||||
* Admin Evaluation Rule Controller
|
||||
* 评估规则管理 API — 供 admin-client 使用
|
||||
*
|
||||
* 管理员可以:
|
||||
* 1. 为不同咨询阶段配置评估规则
|
||||
* 2. 开关规则
|
||||
* 3. 测试规则(dry-run)
|
||||
* 4. 清除规则缓存
|
||||
*/
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Param,
|
||||
Body,
|
||||
Query,
|
||||
Headers,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
UnauthorizedException,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { EvaluationGateService } from '../../infrastructure/agents/coordinator/evaluation-gate.service';
|
||||
import {
|
||||
IEvaluationRuleRepository,
|
||||
EVALUATION_RULE_REPOSITORY,
|
||||
} from '../../domain/repositories/evaluation-rule.repository.interface';
|
||||
import {
|
||||
EvaluationRuleEntity,
|
||||
EvaluationRuleType,
|
||||
EvaluationFailureAction,
|
||||
} from '../../domain/entities/evaluation-rule.entity';
|
||||
import {
|
||||
CreateEvaluationRuleDto,
|
||||
UpdateEvaluationRuleDto,
|
||||
TestEvaluationDto,
|
||||
} from '../../application/dtos/evaluation-rule.dto';
|
||||
import { Inject } from '@nestjs/common';
|
||||
|
||||
interface AdminPayload {
|
||||
id: string;
|
||||
username: string;
|
||||
role: string;
|
||||
tenantId?: string;
|
||||
}
|
||||
|
||||
@Controller('conversations/admin/evaluation-rules')
|
||||
export class AdminEvaluationRuleController {
|
||||
constructor(
|
||||
@Inject(EVALUATION_RULE_REPOSITORY)
|
||||
private readonly repo: IEvaluationRuleRepository,
|
||||
private readonly evaluationGate: EvaluationGateService,
|
||||
) {}
|
||||
|
||||
private verifyAdmin(authorization: string): AdminPayload {
|
||||
const token = authorization?.replace('Bearer ', '');
|
||||
if (!token) {
|
||||
throw new UnauthorizedException('Missing token');
|
||||
}
|
||||
try {
|
||||
const secret = process.env.JWT_SECRET || 'your-jwt-secret-key';
|
||||
return jwt.verify(token, secret) as AdminPayload;
|
||||
} catch {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
}
|
||||
|
||||
// GET /conversations/admin/evaluation-rules
|
||||
@Get()
|
||||
async listRules(
|
||||
@Headers('authorization') auth: string,
|
||||
@Query('stage') stage?: string,
|
||||
) {
|
||||
const admin = this.verifyAdmin(auth);
|
||||
const tenantId = admin.tenantId || null;
|
||||
|
||||
const allRules = await this.repo.findAllByTenant(tenantId);
|
||||
const filtered = stage
|
||||
? allRules.filter(r => r.stage === stage || r.stage === '*')
|
||||
: allRules;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items: filtered.map(r => this.toResponse(r)),
|
||||
total: filtered.length,
|
||||
ruleTypes: Object.values(EvaluationRuleType),
|
||||
failureActions: Object.values(EvaluationFailureAction),
|
||||
stages: ['*', 'greeting', 'needs_discovery', 'info_collection', 'assessment', 'recommendation', 'objection_handling', 'conversion', 'handoff'],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// GET /conversations/admin/evaluation-rules/:id
|
||||
@Get(':id')
|
||||
async getRule(
|
||||
@Headers('authorization') auth: string,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
this.verifyAdmin(auth);
|
||||
|
||||
const rule = await this.repo.findById(id);
|
||||
if (!rule) {
|
||||
return { success: false, error: 'Rule not found' };
|
||||
}
|
||||
|
||||
return { success: true, data: this.toResponse(rule) };
|
||||
}
|
||||
|
||||
// POST /conversations/admin/evaluation-rules
|
||||
@Post()
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
async createRule(
|
||||
@Headers('authorization') auth: string,
|
||||
@Body() dto: CreateEvaluationRuleDto,
|
||||
) {
|
||||
const admin = this.verifyAdmin(auth);
|
||||
const tenantId = admin.tenantId || null;
|
||||
|
||||
if (!dto.name || !dto.stage || !dto.ruleType || !dto.config) {
|
||||
throw new BadRequestException('name, stage, ruleType, and config are required');
|
||||
}
|
||||
|
||||
if (!Object.values(EvaluationRuleType).includes(dto.ruleType)) {
|
||||
throw new BadRequestException(`Invalid ruleType. Valid types: ${Object.values(EvaluationRuleType).join(', ')}`);
|
||||
}
|
||||
|
||||
if (dto.failureAction && !Object.values(EvaluationFailureAction).includes(dto.failureAction)) {
|
||||
throw new BadRequestException(`Invalid failureAction. Valid actions: ${Object.values(EvaluationFailureAction).join(', ')}`);
|
||||
}
|
||||
|
||||
const rule = EvaluationRuleEntity.create({
|
||||
id: uuidv4(),
|
||||
tenantId,
|
||||
stage: dto.stage,
|
||||
ruleType: dto.ruleType,
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
config: dto.config,
|
||||
priority: dto.priority,
|
||||
failureAction: dto.failureAction,
|
||||
createdBy: admin.id,
|
||||
});
|
||||
|
||||
const saved = await this.repo.save(rule);
|
||||
|
||||
// Invalidate cache
|
||||
this.evaluationGate.clearCache(tenantId || undefined);
|
||||
|
||||
return { success: true, data: this.toResponse(saved) };
|
||||
}
|
||||
|
||||
// PUT /conversations/admin/evaluation-rules/:id
|
||||
@Put(':id')
|
||||
async updateRule(
|
||||
@Headers('authorization') auth: string,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateEvaluationRuleDto,
|
||||
) {
|
||||
const admin = this.verifyAdmin(auth);
|
||||
|
||||
const rule = await this.repo.findById(id);
|
||||
if (!rule) {
|
||||
return { success: false, error: 'Rule not found' };
|
||||
}
|
||||
|
||||
if (dto.stage !== undefined) rule.stage = dto.stage;
|
||||
if (dto.ruleType !== undefined) rule.ruleType = dto.ruleType;
|
||||
if (dto.name !== undefined) rule.name = dto.name;
|
||||
if (dto.description !== undefined) rule.description = dto.description;
|
||||
if (dto.config !== undefined) rule.config = dto.config;
|
||||
if (dto.priority !== undefined) rule.priority = dto.priority;
|
||||
if (dto.failureAction !== undefined) rule.failureAction = dto.failureAction;
|
||||
if (dto.enabled !== undefined) rule.enabled = dto.enabled;
|
||||
rule.updatedBy = admin.id;
|
||||
rule.updatedAt = new Date();
|
||||
|
||||
const updated = await this.repo.update(rule);
|
||||
|
||||
// Invalidate cache
|
||||
this.evaluationGate.clearCache(rule.tenantId || undefined);
|
||||
|
||||
return { success: true, data: this.toResponse(updated) };
|
||||
}
|
||||
|
||||
// DELETE /conversations/admin/evaluation-rules/:id
|
||||
@Delete(':id')
|
||||
async deleteRule(
|
||||
@Headers('authorization') auth: string,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
this.verifyAdmin(auth);
|
||||
|
||||
const rule = await this.repo.findById(id);
|
||||
if (!rule) {
|
||||
return { success: false, error: 'Rule not found' };
|
||||
}
|
||||
|
||||
await this.repo.delete(id);
|
||||
|
||||
// Invalidate cache
|
||||
this.evaluationGate.clearCache(rule.tenantId || undefined);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// POST /conversations/admin/evaluation-rules/:id/toggle
|
||||
@Post(':id/toggle')
|
||||
async toggleRule(
|
||||
@Headers('authorization') auth: string,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
const admin = this.verifyAdmin(auth);
|
||||
|
||||
const rule = await this.repo.findById(id);
|
||||
if (!rule) {
|
||||
return { success: false, error: 'Rule not found' };
|
||||
}
|
||||
|
||||
rule.toggle();
|
||||
rule.updatedBy = admin.id;
|
||||
const updated = await this.repo.update(rule);
|
||||
|
||||
// Invalidate cache
|
||||
this.evaluationGate.clearCache(rule.tenantId || undefined);
|
||||
|
||||
return { success: true, data: this.toResponse(updated) };
|
||||
}
|
||||
|
||||
// POST /conversations/admin/evaluation-rules/test
|
||||
@Post('test')
|
||||
async testEvaluation(
|
||||
@Headers('authorization') auth: string,
|
||||
@Body() dto: TestEvaluationDto,
|
||||
) {
|
||||
const admin = this.verifyAdmin(auth);
|
||||
const tenantId = admin.tenantId || null;
|
||||
|
||||
if (!dto.stage || !dto.responseText) {
|
||||
throw new BadRequestException('stage and responseText are required');
|
||||
}
|
||||
|
||||
const result = await this.evaluationGate.evaluate(tenantId, {
|
||||
stage: dto.stage,
|
||||
collectedInfo: dto.collectedInfo || null,
|
||||
assessmentResult: dto.assessmentResult || null,
|
||||
responseText: dto.responseText,
|
||||
turnCount: dto.turnCount || 1,
|
||||
messageCount: dto.messageCount || 1,
|
||||
hasConverted: false,
|
||||
agentsUsed: [],
|
||||
});
|
||||
|
||||
return { success: true, data: result };
|
||||
}
|
||||
|
||||
// DELETE /conversations/admin/evaluation-rules/cache
|
||||
@Delete('cache')
|
||||
async clearCache(@Headers('authorization') auth: string) {
|
||||
this.verifyAdmin(auth);
|
||||
this.evaluationGate.clearCache();
|
||||
return { success: true, message: 'Cache cleared' };
|
||||
}
|
||||
|
||||
private toResponse(rule: EvaluationRuleEntity) {
|
||||
return {
|
||||
id: rule.id,
|
||||
tenantId: rule.tenantId,
|
||||
stage: rule.stage,
|
||||
ruleType: rule.ruleType,
|
||||
name: rule.name,
|
||||
description: rule.description,
|
||||
config: rule.config,
|
||||
enabled: rule.enabled,
|
||||
priority: rule.priority,
|
||||
failureAction: rule.failureAction,
|
||||
createdBy: rule.createdBy,
|
||||
updatedBy: rule.updatedBy,
|
||||
createdAt: rule.createdAt,
|
||||
updatedAt: rule.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import {
|
||||
EvaluationRuleTypeValue,
|
||||
EvaluationFailureActionValue,
|
||||
} from '../../domain/entities/evaluation-rule.entity';
|
||||
|
||||
export interface CreateEvaluationRuleDto {
|
||||
stage: string;
|
||||
ruleType: EvaluationRuleTypeValue;
|
||||
name: string;
|
||||
description?: string;
|
||||
config: Record<string, unknown>;
|
||||
priority?: number;
|
||||
failureAction?: EvaluationFailureActionValue;
|
||||
}
|
||||
|
||||
export interface UpdateEvaluationRuleDto {
|
||||
stage?: string;
|
||||
ruleType?: EvaluationRuleTypeValue;
|
||||
name?: string;
|
||||
description?: string;
|
||||
config?: Record<string, unknown>;
|
||||
priority?: number;
|
||||
failureAction?: EvaluationFailureActionValue;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface TestEvaluationDto {
|
||||
stage: string;
|
||||
responseText: string;
|
||||
collectedInfo?: Record<string, unknown>;
|
||||
assessmentResult?: {
|
||||
topRecommended: string[];
|
||||
suitabilityScore: number;
|
||||
};
|
||||
turnCount?: number;
|
||||
messageCount?: number;
|
||||
}
|
||||
|
|
@ -15,11 +15,12 @@ import { ConversationController } from '../adapters/inbound/conversation.control
|
|||
import { InternalConversationController } from '../adapters/inbound/internal.controller';
|
||||
import { AdminConversationController } from '../adapters/inbound/admin-conversation.controller';
|
||||
import { AdminMcpController } from '../adapters/inbound/admin-mcp.controller';
|
||||
import { AdminEvaluationRuleController } from '../adapters/inbound/admin-evaluation-rule.controller';
|
||||
import { ConversationGateway } from '../adapters/inbound/conversation.gateway';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([ConversationORM, MessageORM, TokenUsageORM, AgentExecutionORM])],
|
||||
controllers: [ConversationController, InternalConversationController, AdminConversationController, AdminMcpController],
|
||||
controllers: [ConversationController, InternalConversationController, AdminConversationController, AdminMcpController, AdminEvaluationRuleController],
|
||||
providers: [
|
||||
ConversationService,
|
||||
ConversationGateway,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,138 @@
|
|||
/**
|
||||
* Evaluation Rule Domain Entity
|
||||
* 评估规则 — 管理员可配置的质量门控规则
|
||||
*/
|
||||
|
||||
export const EvaluationRuleType = {
|
||||
FIELD_COMPLETENESS: 'FIELD_COMPLETENESS',
|
||||
ASSESSMENT_QUALITY: 'ASSESSMENT_QUALITY',
|
||||
RESPONSE_LENGTH: 'RESPONSE_LENGTH',
|
||||
MUST_CONTAIN: 'MUST_CONTAIN',
|
||||
STAGE_MIN_TURNS: 'STAGE_MIN_TURNS',
|
||||
CONVERSION_SIGNAL: 'CONVERSION_SIGNAL',
|
||||
} as const;
|
||||
|
||||
export type EvaluationRuleTypeValue =
|
||||
(typeof EvaluationRuleType)[keyof typeof EvaluationRuleType];
|
||||
|
||||
export const EvaluationFailureAction = {
|
||||
RETRY: 'RETRY',
|
||||
SUPPLEMENT: 'SUPPLEMENT',
|
||||
WARN_AND_PASS: 'WARN_AND_PASS',
|
||||
ESCALATE: 'ESCALATE',
|
||||
} as const;
|
||||
|
||||
export type EvaluationFailureActionValue =
|
||||
(typeof EvaluationFailureAction)[keyof typeof EvaluationFailureAction];
|
||||
|
||||
/** Severity ordering for resolving multiple failures */
|
||||
const ACTION_SEVERITY: Record<EvaluationFailureActionValue, number> = {
|
||||
[EvaluationFailureAction.WARN_AND_PASS]: 0,
|
||||
[EvaluationFailureAction.SUPPLEMENT]: 1,
|
||||
[EvaluationFailureAction.RETRY]: 2,
|
||||
[EvaluationFailureAction.ESCALATE]: 3,
|
||||
};
|
||||
|
||||
export function getHighestSeverityAction(
|
||||
actions: EvaluationFailureActionValue[],
|
||||
): EvaluationFailureActionValue {
|
||||
if (actions.length === 0) return EvaluationFailureAction.WARN_AND_PASS;
|
||||
return actions.reduce((highest, current) =>
|
||||
ACTION_SEVERITY[current] > ACTION_SEVERITY[highest] ? current : highest,
|
||||
);
|
||||
}
|
||||
|
||||
export class EvaluationRuleEntity {
|
||||
readonly id: string;
|
||||
tenantId: string | null;
|
||||
stage: string;
|
||||
ruleType: EvaluationRuleTypeValue;
|
||||
name: string;
|
||||
description: string | null;
|
||||
config: Record<string, unknown>;
|
||||
enabled: boolean;
|
||||
priority: number;
|
||||
failureAction: EvaluationFailureActionValue;
|
||||
createdBy: string | null;
|
||||
updatedBy: string | null;
|
||||
readonly createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
private constructor(props: {
|
||||
id: string;
|
||||
tenantId: string | null;
|
||||
stage: string;
|
||||
ruleType: EvaluationRuleTypeValue;
|
||||
name: string;
|
||||
description: string | null;
|
||||
config: Record<string, unknown>;
|
||||
enabled: boolean;
|
||||
priority: number;
|
||||
failureAction: EvaluationFailureActionValue;
|
||||
createdBy: string | null;
|
||||
updatedBy: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}) {
|
||||
Object.assign(this, props);
|
||||
}
|
||||
|
||||
static create(props: {
|
||||
id: string;
|
||||
tenantId: string | null;
|
||||
stage: string;
|
||||
ruleType: EvaluationRuleTypeValue;
|
||||
name: string;
|
||||
description?: string;
|
||||
config: Record<string, unknown>;
|
||||
priority?: number;
|
||||
failureAction?: EvaluationFailureActionValue;
|
||||
createdBy?: string;
|
||||
}): EvaluationRuleEntity {
|
||||
const now = new Date();
|
||||
return new EvaluationRuleEntity({
|
||||
id: props.id,
|
||||
tenantId: props.tenantId,
|
||||
stage: props.stage,
|
||||
ruleType: props.ruleType,
|
||||
name: props.name,
|
||||
description: props.description || null,
|
||||
config: props.config,
|
||||
enabled: true,
|
||||
priority: props.priority ?? 0,
|
||||
failureAction: props.failureAction ?? EvaluationFailureAction.WARN_AND_PASS,
|
||||
createdBy: props.createdBy || null,
|
||||
updatedBy: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
static fromPersistence(props: {
|
||||
id: string;
|
||||
tenantId: string | null;
|
||||
stage: string;
|
||||
ruleType: EvaluationRuleTypeValue;
|
||||
name: string;
|
||||
description: string | null;
|
||||
config: Record<string, unknown>;
|
||||
enabled: boolean;
|
||||
priority: number;
|
||||
failureAction: EvaluationFailureActionValue;
|
||||
createdBy: string | null;
|
||||
updatedBy: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}): EvaluationRuleEntity {
|
||||
return new EvaluationRuleEntity(props);
|
||||
}
|
||||
|
||||
toggle(): void {
|
||||
this.enabled = !this.enabled;
|
||||
this.updatedAt = new Date();
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
return this.enabled;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import { EvaluationRuleEntity } from '../entities/evaluation-rule.entity';
|
||||
|
||||
export interface IEvaluationRuleRepository {
|
||||
findByTenantAndStage(tenantId: string | null, stage: string): Promise<EvaluationRuleEntity[]>;
|
||||
findAllByTenant(tenantId: string | null): Promise<EvaluationRuleEntity[]>;
|
||||
findById(id: string): Promise<EvaluationRuleEntity | null>;
|
||||
save(rule: EvaluationRuleEntity): Promise<EvaluationRuleEntity>;
|
||||
update(rule: EvaluationRuleEntity): Promise<EvaluationRuleEntity>;
|
||||
delete(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
export const EVALUATION_RULE_REPOSITORY = Symbol('IEvaluationRuleRepository');
|
||||
|
|
@ -35,6 +35,12 @@ import { TokenUsageORM } from '../database/postgres/entities/token-usage.orm';
|
|||
// MCP Integration
|
||||
import { McpModule } from './mcp/mcp.module';
|
||||
|
||||
// Evaluation Gate
|
||||
import { EvaluationGateService } from './coordinator/evaluation-gate.service';
|
||||
import { EvaluationRuleORM } from '../database/postgres/entities/evaluation-rule.orm';
|
||||
import { EvaluationRulePostgresRepository } from '../database/postgres/repositories/evaluation-rule.repository';
|
||||
import { EVALUATION_RULE_REPOSITORY } from '../../domain/repositories/evaluation-rule.repository.interface';
|
||||
|
||||
/**
|
||||
* Anthropic Client Provider
|
||||
* 共享的 Anthropic SDK 实例,所有 Agent 共用
|
||||
|
|
@ -65,7 +71,7 @@ const AnthropicClientProvider = {
|
|||
imports: [
|
||||
ConfigModule,
|
||||
KnowledgeModule,
|
||||
TypeOrmModule.forFeature([TokenUsageORM]),
|
||||
TypeOrmModule.forFeature([TokenUsageORM, EvaluationRuleORM]),
|
||||
McpModule,
|
||||
],
|
||||
providers: [
|
||||
|
|
@ -89,11 +95,19 @@ const AnthropicClientProvider = {
|
|||
CaseAnalystService,
|
||||
MemoryManagerService,
|
||||
|
||||
// Evaluation gate
|
||||
{
|
||||
provide: EVALUATION_RULE_REPOSITORY,
|
||||
useClass: EvaluationRulePostgresRepository,
|
||||
},
|
||||
EvaluationGateService,
|
||||
|
||||
// Main coordinator
|
||||
CoordinatorAgentService,
|
||||
],
|
||||
exports: [
|
||||
CoordinatorAgentService,
|
||||
EvaluationGateService,
|
||||
// Export specialists for potential direct use
|
||||
PolicyExpertService,
|
||||
AssessmentExpertService,
|
||||
|
|
|
|||
|
|
@ -311,8 +311,79 @@ export async function* agentLoop(
|
|||
(b): b is Anthropic.ToolUseBlock => b.type === 'tool_use',
|
||||
);
|
||||
|
||||
// If no tool_use → conversation is done
|
||||
// If no tool_use → conversation is done (with optional evaluation gate)
|
||||
if (toolUseBlocks.length === 0 || finalMessage.stop_reason === 'end_turn') {
|
||||
// --- Evaluation Gate (optional, zero-config safe) ---
|
||||
if (params.evaluationGate) {
|
||||
try {
|
||||
const gateResult = await params.evaluationGate(
|
||||
currentTextContent,
|
||||
currentTurn + 1,
|
||||
agentsUsed,
|
||||
);
|
||||
|
||||
if (
|
||||
!gateResult.passed &&
|
||||
(gateResult.action === 'RETRY' || gateResult.action === 'SUPPLEMENT')
|
||||
) {
|
||||
// Safety: don't retry if near limits
|
||||
if (currentTurn + 1 < maxTurns && currentCost + turnCost < maxBudgetUsd) {
|
||||
logger.debug(
|
||||
`[Turn ${currentTurn + 1}] Evaluation gate: ${gateResult.action}, retrying...`,
|
||||
);
|
||||
|
||||
yield {
|
||||
type: 'coordinator_thinking',
|
||||
phase: 'evaluating',
|
||||
message: '质量检查未通过,正在重新生成...',
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
const feedbackMessage: ClaudeMessage = {
|
||||
role: 'user',
|
||||
content: gateResult.feedbackForModel || '[系统] 请重新生成回复。',
|
||||
};
|
||||
|
||||
const retryMessages: ClaudeMessage[] = [
|
||||
...messages,
|
||||
{ role: 'assistant', content: finalMessage.content as ContentBlock[] },
|
||||
feedbackMessage,
|
||||
];
|
||||
|
||||
// Recurse WITHOUT evaluationGate to prevent infinite loops (max 1 retry)
|
||||
yield* agentLoop(
|
||||
anthropicClient,
|
||||
{
|
||||
...params,
|
||||
messages: retryMessages,
|
||||
currentTurnCount: currentTurn + 1,
|
||||
currentCostUsd: currentCost + turnCost,
|
||||
evaluationGate: undefined,
|
||||
},
|
||||
toolExecutor,
|
||||
additionalTools,
|
||||
additionalConcurrencyMap,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// WARN_AND_PASS or ESCALATE: emit warning event and continue to 'end'
|
||||
if (!gateResult.passed) {
|
||||
yield {
|
||||
type: 'evaluation_warning',
|
||||
results: gateResult.results.filter(r => !r.passed),
|
||||
action: gateResult.action,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
} catch (gateError) {
|
||||
// Gate error is non-fatal — log and continue
|
||||
logger.error(`Evaluation gate error: ${gateError}`);
|
||||
}
|
||||
}
|
||||
// --- End Evaluation Gate ---
|
||||
|
||||
yield {
|
||||
type: 'end',
|
||||
totalTokens: {
|
||||
|
|
|
|||
|
|
@ -48,6 +48,9 @@ import { ConversationContext as OldConversationContext } from '../../claude/clau
|
|||
// MCP Integration
|
||||
import { McpClientService } from '../mcp/mcp-client.service';
|
||||
|
||||
// Evaluation Gate
|
||||
import { EvaluationGateService } from './evaluation-gate.service';
|
||||
|
||||
// ============================================================
|
||||
// Compatibility Types (与 ClaudeAgentServiceV2 的 StreamChunk 兼容)
|
||||
// ============================================================
|
||||
|
|
@ -65,7 +68,7 @@ export interface FileAttachment {
|
|||
/** 兼容旧版 StreamChunk 格式 */
|
||||
export interface StreamChunk {
|
||||
type: 'text' | 'tool_use' | 'tool_result' | 'end' | 'usage' | 'stage_change' | 'state_update' | 'error'
|
||||
| 'agent_start' | 'agent_progress' | 'agent_complete' | 'coordinator_thinking';
|
||||
| 'agent_start' | 'agent_progress' | 'agent_complete' | 'coordinator_thinking' | 'evaluation_warning';
|
||||
content?: string;
|
||||
toolName?: string;
|
||||
toolInput?: Record<string, unknown>;
|
||||
|
|
@ -83,6 +86,9 @@ export interface StreamChunk {
|
|||
phase?: string;
|
||||
durationMs?: number;
|
||||
success?: boolean;
|
||||
// Evaluation gate fields
|
||||
results?: Array<{ ruleId: string; ruleName: string; ruleType: string; passed: boolean; failureAction: string; message?: string }>;
|
||||
action?: string;
|
||||
}
|
||||
|
||||
/** 兼容旧版 ConversationContext */
|
||||
|
|
@ -133,6 +139,8 @@ export class CoordinatorAgentService implements OnModuleInit {
|
|||
private readonly memoryManager: MemoryManagerService,
|
||||
// MCP client
|
||||
private readonly mcpClient: McpClientService,
|
||||
// Evaluation gate
|
||||
private readonly evaluationGate: EvaluationGateService,
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
|
|
@ -205,7 +213,25 @@ export class CoordinatorAgentService implements OnModuleInit {
|
|||
// 4. Create abort controller
|
||||
const abortController = new AbortController();
|
||||
|
||||
// 5. Build agent loop params
|
||||
// 5. Build evaluation gate callback
|
||||
const evaluationGateCallback = async (
|
||||
responseText: string,
|
||||
turnCount: number,
|
||||
agentsUsedInLoop: string[],
|
||||
) => {
|
||||
return this.evaluationGate.evaluate(null, {
|
||||
stage: context.consultingState?.currentStageId || null,
|
||||
collectedInfo: context.consultingState?.collectedInfo || null,
|
||||
assessmentResult: context.consultingState?.assessmentResult || null,
|
||||
responseText,
|
||||
turnCount,
|
||||
messageCount: context.previousMessages?.length || 0,
|
||||
hasConverted: false,
|
||||
agentsUsed: agentsUsedInLoop,
|
||||
});
|
||||
};
|
||||
|
||||
// 6. Build agent loop params
|
||||
const loopParams: AgentLoopParams = {
|
||||
messages: enrichedMessages,
|
||||
systemPrompt,
|
||||
|
|
@ -217,6 +243,7 @@ export class CoordinatorAgentService implements OnModuleInit {
|
|||
abortSignal: abortController.signal,
|
||||
currentTurnCount: 0,
|
||||
currentCostUsd: 0,
|
||||
evaluationGate: evaluationGateCallback,
|
||||
};
|
||||
|
||||
// 6. Create tool executor
|
||||
|
|
@ -558,6 +585,13 @@ export class CoordinatorAgentService implements OnModuleInit {
|
|||
newState: event.state,
|
||||
};
|
||||
|
||||
case 'evaluation_warning':
|
||||
return {
|
||||
type: 'evaluation_warning',
|
||||
results: event.results,
|
||||
action: event.action,
|
||||
};
|
||||
|
||||
default:
|
||||
return { type: 'text', content: '' };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,400 @@
|
|||
/**
|
||||
* Evaluation Gate Service
|
||||
* 评估门控引擎 — 加载管理员配置的规则,在 agent loop 终止前执行检查
|
||||
*
|
||||
* 设计原则:
|
||||
* - 零规则 = 零检查 = 当前行为完全保留
|
||||
* - 规则从数据库加载,内存缓存 5 分钟
|
||||
* - 每条规则的评估器是纯函数,不访问数据库
|
||||
* - 多规则失败时取最高严重性动作
|
||||
*/
|
||||
|
||||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||
import {
|
||||
IEvaluationRuleRepository,
|
||||
EVALUATION_RULE_REPOSITORY,
|
||||
} from '../../../domain/repositories/evaluation-rule.repository.interface';
|
||||
import {
|
||||
EvaluationRuleEntity,
|
||||
EvaluationRuleType,
|
||||
EvaluationFailureAction,
|
||||
EvaluationFailureActionValue,
|
||||
getHighestSeverityAction,
|
||||
} from '../../../domain/entities/evaluation-rule.entity';
|
||||
|
||||
// ============================================================
|
||||
// Types
|
||||
// ============================================================
|
||||
|
||||
/** Context available for rule evaluation */
|
||||
export interface EvaluationContext {
|
||||
stage: string | null;
|
||||
collectedInfo: Record<string, unknown> | null;
|
||||
assessmentResult: {
|
||||
topRecommended: string[];
|
||||
suitabilityScore: number;
|
||||
} | null;
|
||||
responseText: string;
|
||||
turnCount: number;
|
||||
messageCount: number;
|
||||
hasConverted: boolean;
|
||||
agentsUsed: string[];
|
||||
}
|
||||
|
||||
/** Result of a single rule check */
|
||||
export interface RuleCheckResult {
|
||||
ruleId: string;
|
||||
ruleName: string;
|
||||
ruleType: string;
|
||||
passed: boolean;
|
||||
failureAction: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/** Aggregate gate result */
|
||||
export interface GateResult {
|
||||
passed: boolean;
|
||||
results: RuleCheckResult[];
|
||||
action: 'PASS' | 'RETRY' | 'SUPPLEMENT' | 'WARN_AND_PASS' | 'ESCALATE';
|
||||
feedbackForModel?: string;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Service
|
||||
// ============================================================
|
||||
|
||||
@Injectable()
|
||||
export class EvaluationGateService {
|
||||
private readonly logger = new Logger(EvaluationGateService.name);
|
||||
private cache = new Map<string, { rules: EvaluationRuleEntity[]; expiresAt: number }>();
|
||||
private readonly CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
constructor(
|
||||
@Inject(EVALUATION_RULE_REPOSITORY)
|
||||
private readonly repo: IEvaluationRuleRepository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Main entry: evaluate all applicable rules
|
||||
* Returns passed=true if no rules exist or all pass
|
||||
*/
|
||||
async evaluate(tenantId: string | null, context: EvaluationContext): Promise<GateResult> {
|
||||
const stage = context.stage || '*';
|
||||
const rules = await this.loadRules(tenantId, stage);
|
||||
|
||||
// Zero rules = zero checks
|
||||
if (rules.length === 0) {
|
||||
return { passed: true, results: [], action: 'PASS' };
|
||||
}
|
||||
|
||||
const results: RuleCheckResult[] = [];
|
||||
|
||||
for (const rule of rules) {
|
||||
const check = this.evaluateRule(rule, context);
|
||||
results.push({
|
||||
ruleId: rule.id,
|
||||
ruleName: rule.name,
|
||||
ruleType: rule.ruleType,
|
||||
passed: check.passed,
|
||||
failureAction: rule.failureAction,
|
||||
message: check.message,
|
||||
});
|
||||
}
|
||||
|
||||
const failedResults = results.filter(r => !r.passed);
|
||||
|
||||
if (failedResults.length === 0) {
|
||||
return { passed: true, results, action: 'PASS' };
|
||||
}
|
||||
|
||||
const failedActions = failedResults.map(
|
||||
r => r.failureAction as EvaluationFailureActionValue,
|
||||
);
|
||||
const highestAction = getHighestSeverityAction(failedActions);
|
||||
|
||||
const feedbackForModel = this.buildFeedback(failedResults, highestAction);
|
||||
|
||||
this.logger.warn(
|
||||
`Evaluation gate: ${failedResults.length}/${results.length} rules failed, action=${highestAction}, stage=${stage}`,
|
||||
);
|
||||
|
||||
return {
|
||||
passed: false,
|
||||
results,
|
||||
action: highestAction,
|
||||
feedbackForModel,
|
||||
};
|
||||
}
|
||||
|
||||
/** Clear cached rules (e.g., after admin updates) */
|
||||
clearCache(tenantId?: string): void {
|
||||
if (tenantId) {
|
||||
for (const key of this.cache.keys()) {
|
||||
if (key.startsWith(tenantId)) {
|
||||
this.cache.delete(key);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.cache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Rule Loading (with cache)
|
||||
// ============================================================
|
||||
|
||||
private async loadRules(
|
||||
tenantId: string | null,
|
||||
stage: string,
|
||||
): Promise<EvaluationRuleEntity[]> {
|
||||
const cacheKey = `${tenantId || 'global'}:${stage}`;
|
||||
const cached = this.cache.get(cacheKey);
|
||||
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.rules;
|
||||
}
|
||||
|
||||
try {
|
||||
const rules = await this.repo.findByTenantAndStage(tenantId, stage);
|
||||
this.cache.set(cacheKey, {
|
||||
rules,
|
||||
expiresAt: Date.now() + this.CACHE_TTL,
|
||||
});
|
||||
return rules;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to load evaluation rules: ${error}`);
|
||||
// On error, return empty = no checks = safe fallback
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Rule Evaluation (pure functions)
|
||||
// ============================================================
|
||||
|
||||
private evaluateRule(
|
||||
rule: EvaluationRuleEntity,
|
||||
context: EvaluationContext,
|
||||
): { passed: boolean; message?: string } {
|
||||
switch (rule.ruleType) {
|
||||
case EvaluationRuleType.FIELD_COMPLETENESS:
|
||||
return this.checkFieldCompleteness(rule.config, context);
|
||||
case EvaluationRuleType.ASSESSMENT_QUALITY:
|
||||
return this.checkAssessmentQuality(rule.config, context);
|
||||
case EvaluationRuleType.RESPONSE_LENGTH:
|
||||
return this.checkResponseLength(rule.config, context);
|
||||
case EvaluationRuleType.MUST_CONTAIN:
|
||||
return this.checkMustContain(rule.config, context);
|
||||
case EvaluationRuleType.STAGE_MIN_TURNS:
|
||||
return this.checkStageMinTurns(rule.config, context);
|
||||
case EvaluationRuleType.CONVERSION_SIGNAL:
|
||||
return this.checkConversionSignal(rule.config, context);
|
||||
default:
|
||||
this.logger.warn(`Unknown rule type: ${rule.ruleType}`);
|
||||
return { passed: true };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* FIELD_COMPLETENESS: Check if required fields exist in collectedInfo
|
||||
* config: { fields: string[], minRatio: number }
|
||||
*/
|
||||
private checkFieldCompleteness(
|
||||
config: Record<string, unknown>,
|
||||
context: EvaluationContext,
|
||||
): { passed: boolean; message?: string } {
|
||||
const fields = (config.fields as string[]) || [];
|
||||
const minRatio = (config.minRatio as number) ?? 1.0;
|
||||
|
||||
if (fields.length === 0) return { passed: true };
|
||||
|
||||
const info = context.collectedInfo || {};
|
||||
const presentFields: string[] = [];
|
||||
const missingFields: string[] = [];
|
||||
|
||||
for (const field of fields) {
|
||||
const value = info[field];
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
presentFields.push(field);
|
||||
} else {
|
||||
missingFields.push(field);
|
||||
}
|
||||
}
|
||||
|
||||
const ratio = presentFields.length / fields.length;
|
||||
const passed = ratio >= minRatio;
|
||||
|
||||
return {
|
||||
passed,
|
||||
message: passed
|
||||
? undefined
|
||||
: `信息完整度不足(${presentFields.length}/${fields.length}=${(ratio * 100).toFixed(0)}%,要求≥${(minRatio * 100).toFixed(0)}%),缺少: ${missingFields.join(', ')}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* ASSESSMENT_QUALITY: Check assessment result quality
|
||||
* config: { minCategories?: number, minConfidence?: number, minScore?: number }
|
||||
*/
|
||||
private checkAssessmentQuality(
|
||||
config: Record<string, unknown>,
|
||||
context: EvaluationContext,
|
||||
): { passed: boolean; message?: string } {
|
||||
const minCategories = (config.minCategories as number) ?? 1;
|
||||
const minScore = (config.minScore as number) ?? 0;
|
||||
|
||||
if (!context.assessmentResult) {
|
||||
return {
|
||||
passed: false,
|
||||
message: `尚未完成评估(要求至少评估 ${minCategories} 个类别)`,
|
||||
};
|
||||
}
|
||||
|
||||
const categoryCount = context.assessmentResult.topRecommended?.length || 0;
|
||||
if (categoryCount < minCategories) {
|
||||
return {
|
||||
passed: false,
|
||||
message: `推荐类别数不足(${categoryCount}/${minCategories})`,
|
||||
};
|
||||
}
|
||||
|
||||
if (context.assessmentResult.suitabilityScore < minScore) {
|
||||
return {
|
||||
passed: false,
|
||||
message: `适配分数过低(${context.assessmentResult.suitabilityScore}/${minScore})`,
|
||||
};
|
||||
}
|
||||
|
||||
return { passed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* RESPONSE_LENGTH: Check response text length
|
||||
* config: { min?: number, max?: number }
|
||||
*/
|
||||
private checkResponseLength(
|
||||
config: Record<string, unknown>,
|
||||
context: EvaluationContext,
|
||||
): { passed: boolean; message?: string } {
|
||||
const min = (config.min as number) ?? 0;
|
||||
const max = (config.max as number) ?? Infinity;
|
||||
const length = context.responseText.length;
|
||||
|
||||
if (length < min) {
|
||||
return {
|
||||
passed: false,
|
||||
message: `回复过短(${length}字,最少${min}字)`,
|
||||
};
|
||||
}
|
||||
|
||||
if (length > max) {
|
||||
return {
|
||||
passed: false,
|
||||
message: `回复过长(${length}字,最多${max}字)`,
|
||||
};
|
||||
}
|
||||
|
||||
return { passed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* MUST_CONTAIN: Check if response contains required keywords
|
||||
* config: { keywords: string[], mode: 'any' | 'all' }
|
||||
*/
|
||||
private checkMustContain(
|
||||
config: Record<string, unknown>,
|
||||
context: EvaluationContext,
|
||||
): { passed: boolean; message?: string } {
|
||||
const keywords = (config.keywords as string[]) || [];
|
||||
const mode = (config.mode as string) || 'any';
|
||||
|
||||
if (keywords.length === 0) return { passed: true };
|
||||
|
||||
const text = context.responseText;
|
||||
const foundKeywords = keywords.filter(kw => text.includes(kw));
|
||||
const missingKeywords = keywords.filter(kw => !text.includes(kw));
|
||||
|
||||
if (mode === 'all') {
|
||||
const passed = missingKeywords.length === 0;
|
||||
return {
|
||||
passed,
|
||||
message: passed
|
||||
? undefined
|
||||
: `回复缺少必需关键词: ${missingKeywords.join(', ')}`,
|
||||
};
|
||||
}
|
||||
|
||||
// mode === 'any'
|
||||
const passed = foundKeywords.length > 0;
|
||||
return {
|
||||
passed,
|
||||
message: passed
|
||||
? undefined
|
||||
: `回复未包含任何期望关键词: ${keywords.join(', ')}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* STAGE_MIN_TURNS: Check minimum conversation turns
|
||||
* config: { minTurns: number }
|
||||
*/
|
||||
private checkStageMinTurns(
|
||||
config: Record<string, unknown>,
|
||||
context: EvaluationContext,
|
||||
): { passed: boolean; message?: string } {
|
||||
const minTurns = (config.minTurns as number) ?? 1;
|
||||
const passed = context.turnCount >= minTurns;
|
||||
|
||||
return {
|
||||
passed,
|
||||
message: passed
|
||||
? undefined
|
||||
: `当前轮次不足(${context.turnCount}/${minTurns}轮)`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* CONVERSION_SIGNAL: Check for conversion-related content
|
||||
* config: { keywords?: string[] }
|
||||
*/
|
||||
private checkConversionSignal(
|
||||
config: Record<string, unknown>,
|
||||
context: EvaluationContext,
|
||||
): { passed: boolean; message?: string } {
|
||||
const defaultKeywords = ['下一步', '预约', '联系', '咨询', '费用', '付款', '签约'];
|
||||
const keywords = (config.keywords as string[]) || defaultKeywords;
|
||||
|
||||
const text = context.responseText;
|
||||
const found = keywords.some(kw => text.includes(kw));
|
||||
|
||||
return {
|
||||
passed: found,
|
||||
message: found
|
||||
? undefined
|
||||
: '回复中缺少转化引导内容(如下一步行动、预约、联系方式等)',
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Feedback Builder
|
||||
// ============================================================
|
||||
|
||||
private buildFeedback(
|
||||
failedResults: RuleCheckResult[],
|
||||
action: EvaluationFailureActionValue,
|
||||
): string {
|
||||
const lines = ['[系统评估] 回复未通过质量检查:'];
|
||||
|
||||
for (const result of failedResults) {
|
||||
lines.push(`- [${result.ruleName}] ${result.message || '未通过'}`);
|
||||
}
|
||||
|
||||
if (action === EvaluationFailureAction.RETRY) {
|
||||
lines.push('请根据以上反馈重新生成回复。');
|
||||
} else if (action === EvaluationFailureAction.SUPPLEMENT) {
|
||||
lines.push('请在回复中补充缺失的信息后再回复用户。');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
}
|
||||
|
|
@ -274,6 +274,12 @@ export interface AgentLoopParams {
|
|||
currentTurnCount?: number;
|
||||
/** 已经消耗的成本(递归时累加)*/
|
||||
currentCostUsd?: number;
|
||||
/** 可选评估门控回调。提供时,在 yield 'end' 前调用检查回复质量。 */
|
||||
evaluationGate?: (
|
||||
responseText: string,
|
||||
turnCount: number,
|
||||
agentsUsed: string[],
|
||||
) => Promise<import('../coordinator/evaluation-gate.service').GateResult>;
|
||||
}
|
||||
|
||||
/** Claude API 消息格式 */
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ export interface AgentCompleteEvent extends BaseStreamEvent {
|
|||
/** Coordinator 正在思考/编排 */
|
||||
export interface CoordinatorThinkingEvent extends BaseStreamEvent {
|
||||
type: 'coordinator_thinking';
|
||||
phase: 'analyzing' | 'orchestrating' | 'synthesizing';
|
||||
phase: 'analyzing' | 'orchestrating' | 'synthesizing' | 'evaluating';
|
||||
message?: string;
|
||||
}
|
||||
|
||||
|
|
@ -113,6 +113,20 @@ export interface EndStreamEvent extends BaseStreamEvent {
|
|||
agentsUsed: SpecialistAgentType[];
|
||||
}
|
||||
|
||||
/** 评估门控警告事件 */
|
||||
export interface EvaluationWarningEvent extends BaseStreamEvent {
|
||||
type: 'evaluation_warning';
|
||||
results: Array<{
|
||||
ruleId: string;
|
||||
ruleName: string;
|
||||
ruleType: string;
|
||||
passed: boolean;
|
||||
failureAction: string;
|
||||
message?: string;
|
||||
}>;
|
||||
action: string;
|
||||
}
|
||||
|
||||
/** 错误事件 */
|
||||
export interface ErrorStreamEvent extends BaseStreamEvent {
|
||||
type: 'error';
|
||||
|
|
@ -137,6 +151,7 @@ export type StreamEvent =
|
|||
| StateUpdateEvent
|
||||
| UsageEvent
|
||||
| EndStreamEvent
|
||||
| EvaluationWarningEvent
|
||||
| ErrorStreamEvent;
|
||||
|
||||
// ============================================================
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('evaluation_rules')
|
||||
@Index('idx_evaluation_rules_tenant', ['tenantId'])
|
||||
@Index('idx_evaluation_rules_tenant_stage', ['tenantId', 'stage', 'enabled'])
|
||||
export class EvaluationRuleORM {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid', nullable: true })
|
||||
tenantId: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 50 })
|
||||
stage: string;
|
||||
|
||||
@Column({ name: 'rule_type', type: 'varchar', length: 50 })
|
||||
ruleType: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255 })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description: string | null;
|
||||
|
||||
@Column({ type: 'jsonb', default: '{}' })
|
||||
config: Record<string, unknown>;
|
||||
|
||||
@Column({ type: 'boolean', default: true })
|
||||
enabled: boolean;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
priority: number;
|
||||
|
||||
@Column({ name: 'failure_action', type: 'varchar', length: 20, default: 'WARN_AND_PASS' })
|
||||
failureAction: string;
|
||||
|
||||
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||
createdBy: string | null;
|
||||
|
||||
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
|
||||
updatedBy: string | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, IsNull } from 'typeorm';
|
||||
import { EvaluationRuleORM } from '../entities/evaluation-rule.orm';
|
||||
import {
|
||||
IEvaluationRuleRepository,
|
||||
} from '../../../../domain/repositories/evaluation-rule.repository.interface';
|
||||
import {
|
||||
EvaluationRuleEntity,
|
||||
EvaluationRuleTypeValue,
|
||||
EvaluationFailureActionValue,
|
||||
} from '../../../../domain/entities/evaluation-rule.entity';
|
||||
|
||||
@Injectable()
|
||||
export class EvaluationRulePostgresRepository implements IEvaluationRuleRepository {
|
||||
constructor(
|
||||
@InjectRepository(EvaluationRuleORM)
|
||||
private readonly repo: Repository<EvaluationRuleORM>,
|
||||
) {}
|
||||
|
||||
async findByTenantAndStage(
|
||||
tenantId: string | null,
|
||||
stage: string,
|
||||
): Promise<EvaluationRuleEntity[]> {
|
||||
const orms = await this.repo
|
||||
.createQueryBuilder('r')
|
||||
.where('(r.tenant_id = :tenantId OR r.tenant_id IS NULL)', { tenantId })
|
||||
.andWhere('(r.stage = :stage OR r.stage = :wildcard)', { stage, wildcard: '*' })
|
||||
.andWhere('r.enabled = true')
|
||||
.orderBy('r.priority', 'ASC')
|
||||
.getMany();
|
||||
|
||||
return orms.map(orm => this.toEntity(orm));
|
||||
}
|
||||
|
||||
async findAllByTenant(tenantId: string | null): Promise<EvaluationRuleEntity[]> {
|
||||
const orms = await this.repo.find({
|
||||
where: { tenantId: tenantId ?? IsNull() },
|
||||
order: { stage: 'ASC', priority: 'ASC' },
|
||||
});
|
||||
return orms.map(orm => this.toEntity(orm));
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<EvaluationRuleEntity | null> {
|
||||
const orm = await this.repo.findOne({ where: { id } });
|
||||
return orm ? this.toEntity(orm) : null;
|
||||
}
|
||||
|
||||
async save(rule: EvaluationRuleEntity): Promise<EvaluationRuleEntity> {
|
||||
const orm = this.toORM(rule);
|
||||
const saved = await this.repo.save(orm);
|
||||
return this.toEntity(saved);
|
||||
}
|
||||
|
||||
async update(rule: EvaluationRuleEntity): Promise<EvaluationRuleEntity> {
|
||||
const orm = this.toORM(rule);
|
||||
const saved = await this.repo.save(orm);
|
||||
return this.toEntity(saved);
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.repo.delete(id);
|
||||
}
|
||||
|
||||
private toEntity(orm: EvaluationRuleORM): EvaluationRuleEntity {
|
||||
return EvaluationRuleEntity.fromPersistence({
|
||||
id: orm.id,
|
||||
tenantId: orm.tenantId,
|
||||
stage: orm.stage,
|
||||
ruleType: orm.ruleType as EvaluationRuleTypeValue,
|
||||
name: orm.name,
|
||||
description: orm.description,
|
||||
config: orm.config,
|
||||
enabled: orm.enabled,
|
||||
priority: orm.priority,
|
||||
failureAction: orm.failureAction as EvaluationFailureActionValue,
|
||||
createdBy: orm.createdBy,
|
||||
updatedBy: orm.updatedBy,
|
||||
createdAt: orm.createdAt,
|
||||
updatedAt: orm.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
private toORM(entity: EvaluationRuleEntity): EvaluationRuleORM {
|
||||
const orm = new EvaluationRuleORM();
|
||||
orm.id = entity.id;
|
||||
orm.tenantId = entity.tenantId;
|
||||
orm.stage = entity.stage;
|
||||
orm.ruleType = entity.ruleType;
|
||||
orm.name = entity.name;
|
||||
orm.description = entity.description;
|
||||
orm.config = entity.config;
|
||||
orm.enabled = entity.enabled;
|
||||
orm.priority = entity.priority;
|
||||
orm.failureAction = entity.failureAction;
|
||||
orm.createdBy = entity.createdBy;
|
||||
orm.updatedBy = entity.updatedBy;
|
||||
return orm;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue