feat: casan chat optz

This commit is contained in:
thanhnv
2026-07-19 12:14:12 +07:00
parent 709b6cccd6
commit f462079435
16 changed files with 486 additions and 90 deletions
@@ -1,5 +1,6 @@
import { BadRequestException, ForbiddenException, Injectable, InternalServerErrorException } from '@nestjs/common';
import { execFileSync, spawn } from 'node:child_process';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import type { Response } from 'express';
import { APP_ROOT } from '../common/app-root.js';
@@ -27,6 +28,25 @@ export interface ChatDefaultModelInput {
model: string;
}
export interface ChatGovernanceVerdict {
outcome: 'allow' | 'degraded' | 'deny' | 'not_run';
gate: string;
reason_code: string;
summary: string;
remediation: string[];
categories: string[];
security_trace_id: string;
side_effect_allowed: boolean;
}
interface ChatCliResponse {
success: boolean;
decision: string;
answer: string;
governance?: ChatGovernanceVerdict;
[key: string]: unknown;
}
interface CommandResult {
status: number;
stdout: string;
@@ -40,6 +60,7 @@ const AGENT_CLI = join(HARNESS_BIN, 'chat-agent-resolver.py');
const REPLAY_CLI = join(HARNESS_BIN, 'chat-replay.py');
const RBAC_CLI = join(HARNESS_BIN, 'rbac-check.py');
const CONNECTIONS_CLI = join(HARNESS_BIN, 'model-connections.py');
const PYTHON_BIN = process.env.CASAN_PYTHON_BIN || (existsSync('/usr/bin/python3') ? '/usr/bin/python3' : 'python3');
const CONNECTION_POLICY_PROVIDER: Record<string, string> = {
openai: 'cloud-openai',
@@ -50,7 +71,7 @@ const CONNECTION_POLICY_PROVIDER: Record<string, string> = {
function runPython(script: string, args: string[], extraEnv: NodeJS.ProcessEnv = {}, input?: string): CommandResult {
try {
const stdout = execFileSync('python3', [script, ...args], {
const stdout = execFileSync(PYTHON_BIN, [script, ...args], {
cwd: APP_ROOT,
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
@@ -58,11 +79,12 @@ function runPython(script: string, args: string[], extraEnv: NodeJS.ProcessEnv =
input,
});
return { status: 0, stdout: stdout.trim(), stderr: '' };
} catch (err: any) {
} catch (err: unknown) {
const failure = err as { status?: number; stdout?: string | Buffer; stderr?: string | Buffer };
return {
status: Number(err?.status ?? 1),
stdout: String(err?.stdout ?? '').trim(),
stderr: String(err?.stderr ?? '').trim(),
status: Number(failure.status ?? 1),
stdout: String(failure.stdout ?? '').trim(),
stderr: String(failure.stderr ?? '').trim(),
};
}
}
@@ -79,15 +101,13 @@ function parseJson<T>(raw: string): T | null {
@Injectable()
export class ChatService {
ask(input: ChatAskInput, actor: SettingsActor) {
if (!input.message || !input.message.trim()) {
throw new ForbiddenException('CHAT_DENY message required');
}
const safeInput = this.validateAskInput(input);
this.requireRead(actor);
const args = [
'ask',
'--message',
input.message,
safeInput.message,
'--actor',
actor.actor,
'--role',
@@ -95,17 +115,17 @@ export class ChatService {
'--project',
actor.project,
'--chat-id',
input.chatId || 'chat-default',
safeInput.chatId || 'chat-default',
'--tenant',
actor.tenant,
];
if (input.agentId) args.push('--agent', input.agentId);
if (input.skillId) args.push('--skill', input.skillId);
const runtime = this.modelRuntime(input, actor);
if (safeInput.agentId) args.push('--agent', safeInput.agentId);
if (safeInput.skillId) args.push('--skill', safeInput.skillId);
const runtime = this.modelRuntime(safeInput, actor);
if (runtime.policyProvider) args.push('--model-provider', runtime.policyProvider);
if (input.delegationLevel !== undefined) args.push('--delegation-level', String(input.delegationLevel));
if (safeInput.delegationLevel !== undefined) args.push('--delegation-level', String(safeInput.delegationLevel));
const res = runPython(CHAT_CLI, args, runtime.env);
const parsed = parseJson<Record<string, any>>(res.stdout);
const parsed = parseJson<ChatCliResponse>(res.stdout);
if (parsed) {
return { ...parsed, actor, audit_verify: this.verifyAudit(actor) };
}
@@ -139,16 +159,14 @@ export class ChatService {
* spawn is read-only (no side effect), so it is safe to stream the draft.
*/
streamAsk(input: ChatAskInput, actor: SettingsActor, res: Response) {
if (!input.message || !input.message.trim()) {
throw new ForbiddenException('CHAT_DENY message required');
}
const safeInput = this.validateAskInput(input);
this.requireRead(actor);
const args = [
CHAT_CLI,
'ask',
'--stream',
'--message',
input.message,
safeInput.message,
'--actor',
actor.actor,
'--role',
@@ -156,19 +174,19 @@ export class ChatService {
'--project',
actor.project,
'--chat-id',
input.chatId || 'chat-default',
safeInput.chatId || 'chat-default',
'--tenant',
actor.tenant,
];
if (input.agentId) args.push('--agent', input.agentId);
if (input.skillId) args.push('--skill', input.skillId);
const runtime = this.modelRuntime(input, actor);
if (safeInput.agentId) args.push('--agent', safeInput.agentId);
if (safeInput.skillId) args.push('--skill', safeInput.skillId);
const runtime = this.modelRuntime(safeInput, actor);
if (runtime.policyProvider) args.push('--model-provider', runtime.policyProvider);
if (input.delegationLevel !== undefined) args.push('--delegation-level', String(input.delegationLevel));
if (safeInput.delegationLevel !== undefined) args.push('--delegation-level', String(safeInput.delegationLevel));
res.setHeader('Content-Type', 'application/x-ndjson; charset=utf-8');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('X-Accel-Buffering', 'no');
const child = spawn('python3', args, { cwd: APP_ROOT, env: { ...process.env, ...runtime.env } });
const child = spawn(PYTHON_BIN, args, { cwd: APP_ROOT, env: { ...process.env, ...runtime.env } });
child.stdout.on('data', (chunk) => res.write(chunk));
child.on('error', () => {
if (!res.headersSent) res.status(500);
@@ -273,6 +291,20 @@ export class ChatService {
return { policyProvider, env: { CASAN_TENANT_ID: actor.tenant || 'default', ...parsed.env } };
}
private validateAskInput(input: ChatAskInput): ChatAskInput & { message: string } {
const message = typeof input.message === 'string' ? input.message.trim() : '';
if (!message) throw new BadRequestException('CHAT_MESSAGE_REQUIRED');
if (message.length > 32_000) throw new BadRequestException('CHAT_MESSAGE_TOO_LARGE');
const identifier = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
for (const [name, value] of [['chatId', input.chatId], ['agentId', input.agentId], ['skillId', input.skillId]] as const) {
if (value && !identifier.test(value)) throw new BadRequestException(`CHAT_INVALID_${name.toUpperCase()}`);
}
if (input.delegationLevel !== undefined && (!Number.isInteger(input.delegationLevel) || input.delegationLevel < 0 || input.delegationLevel > 5)) {
throw new BadRequestException('CHAT_INVALID_DELEGATION_LEVEL');
}
return { ...input, message };
}
private tenantEnv(actor: SettingsActor): NodeJS.ProcessEnv {
return { CASAN_TENANT_ID: actor.tenant || 'default' };
}