Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,11 +217,13 @@ ts-node apps/backend/scripts/langsmith-coverage-check.ts \
- `kind`:固定为 `agent-run`
- `outcome`:`clarification | executionResult | rejected | failed`
- `run`:完整运行结果(含 `trace` 与可选 `llmRaw`)
- 请求体支持可选 `contextEnvelope`(`metricDefinition/timeRange/entityMappings/mustIncludeTables/mustExcludeTables/businessConstraints`)
- `agent`:聚合元信息(provider/model、是否有 SQL、是否有工具调用、是否有错误)
- SSE 事件类型:`start`、`text-delta`、`tool-call`、`tool-result`、`tool-error`、`state`、`finish`、`error`。
- SSE 事件必填字段:`type`、`runId`、`sessionId`、`at`、`data`;其中 `data` 为结构化对象,不再混用字符串载荷。
- 当前 Tool Calling 基础能力默认启用,首个工具为 `runReadOnlySql`(只读 SQL 执行,含输入校验与安全守卫)。
- SQL 运行时提示词模板命中证据通过 `run.trace.promptTemplate` 与 `run.delivery.evidence.promptTemplate` 暴露(字段:`templateId/scene/scope/version/fallbackReason`)。
- 上下文生效证据通过 `run.trace.effectiveContextSummary/conflictHint` 与 `run.delivery.evidence.effectiveContextSummary/conflictHint` 双层暴露,前端可区分用户显式上下文与系统上下文来源。

## 测试
```bash
Expand Down
31 changes: 29 additions & 2 deletions apps/backend/src/modules/conversation/agent/graph/agent.types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import type { ClarificationPrompt, ExecutionTrace } from "@text2sql/shared-types";
import type { DatasourceType } from "@text2sql/shared-types";
import type {
ClarificationPrompt,
ContextEnvelope,
DatasourceType,
ExecutionTrace
} from "@text2sql/shared-types";
import type { SqlTableAccessContext } from "../../../platform/data/query/index";

export interface GraphTraceContext {
Expand All @@ -17,11 +21,34 @@ export interface GraphInput {
datasourceId: string;
datasourceType?: DatasourceType;
modelCatalogId?: string;
contextEnvelope?: ContextEnvelope;
planningScaffoldEnabled?: boolean;
traceContext?: GraphTraceContext;
accessContext?: SqlTableAccessContext;
}

export interface GraphEffectiveContextSummary {
sourcePriority: "user_explicit_over_system";
userEnvelope: {
metricDefinitionProvided: boolean;
timeRangeProvided: boolean;
entityMappingCount: number;
includeTableCount: number;
excludeTableCount: number;
businessConstraintCount: number;
};
retrievalContext?: {
status?: "ready" | "degraded";
selectedContextCount?: number;
};
}

export interface GraphContextConflictHint {
hasConflict: boolean;
preferredSource: "user_explicit";
reasonCodes?: string[];
}

export interface GraphState extends GraphInput {
provider: string;
model?: string;
Expand Down
181 changes: 178 additions & 3 deletions apps/backend/src/modules/conversation/agent/graph/graph.builder.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
import { Injectable } from "@nestjs/common";
import type { ExecutionTraceStep, SqlRun } from "@text2sql/shared-types";
import type {
ContextEnvelope,
ExecutionTraceStep,
SqlRun
} from "@text2sql/shared-types";
import type {
LlmGatewayStreamEvent,
LlmGatewayToolDefinition
} from "../../../llm/llm-gateway.interface";
import type { GraphInput } from "./agent.types";
import type {
GraphContextConflictHint,
GraphEffectiveContextSummary,
GraphInput
} from "./agent.types";
import {
createInitialLangGraphState,
normalizeTraceContext,
Expand All @@ -22,6 +30,13 @@ export interface GraphRunOptions {
onStep?: (event: LangGraphSpanEvent) => Promise<void> | void;
}

interface GraphContextEvidence {
effectiveContextSummary?: GraphEffectiveContextSummary;
conflictHint?: GraphContextConflictHint;
}

const CONTEXT_CONFLICT_REGEX = /conflict|mismatch|contradict|inconsistent|冲突/i;

@Injectable()
export class GraphBuilderService {
constructor(
Expand Down Expand Up @@ -155,6 +170,9 @@ export class GraphBuilderService {
}

private toRun(state: LangGraphState, status: SqlRun["status"]): SqlRun {
const normalizedTrace = this.normalizeTrace(state.trace, state.runId, state.provider);
const contextEvidence = this.buildContextEvidence(state);
const traceWithContext = this.withContextEvidence(normalizedTrace, contextEvidence);
return {
runId: state.runId,
sessionId: state.sessionId,
Expand All @@ -169,7 +187,7 @@ export class GraphBuilderService {
columns: state.columns,
error: state.error,
clarification: state.clarification,
trace: this.normalizeTrace(state.trace, state.runId, state.provider),
trace: traceWithContext,
llmRaw: state.llmRaw ?? null,
createdAt: new Date().toISOString()
};
Expand Down Expand Up @@ -215,4 +233,161 @@ export class GraphBuilderService {
Object.entries(payload).filter(([, value]) => value !== undefined)
);
}

private withContextEvidence(
trace: SqlRun["trace"],
contextEvidence: GraphContextEvidence
): SqlRun["trace"] {
if (!contextEvidence.effectiveContextSummary && !contextEvidence.conflictHint) {
return trace;
}
return {
...trace,
...(contextEvidence.effectiveContextSummary
? {
effectiveContextSummary: contextEvidence.effectiveContextSummary
}
: {}),
...(contextEvidence.conflictHint
? {
conflictHint: contextEvidence.conflictHint
}
: {})
} as SqlRun["trace"];
}

private buildContextEvidence(state: LangGraphState): GraphContextEvidence {
const envelope = state.contextEnvelope;
if (!this.hasContextEnvelope(envelope)) {
return {};
}

const includeTableCount = this.countNonEmptyStrings(envelope?.mustIncludeTables);
const excludeTableCount = this.countNonEmptyStrings(envelope?.mustExcludeTables);
const entityMappingCount = this.countEntityMappings(envelope?.entityMappings);
const effectiveContextSummary: GraphEffectiveContextSummary = {
sourcePriority: "user_explicit_over_system",
userEnvelope: {
metricDefinitionProvided: this.hasNonEmptyString(envelope?.metricDefinition),
timeRangeProvided: this.hasTimeRange(envelope?.timeRange),
entityMappingCount,
includeTableCount,
excludeTableCount,
businessConstraintCount: this.countNonEmptyStrings(envelope?.businessConstraints)
},
retrievalContext: {
status: state.retrievalBundle?.status,
selectedContextCount: state.retrievalBundle?.selected_context?.length
}
};

const reasonCodes = this.collectConflictReasons(state, includeTableCount, excludeTableCount);
const conflictHint: GraphContextConflictHint = {
hasConflict: reasonCodes.length > 0,
preferredSource: "user_explicit",
...(reasonCodes.length > 0 ? { reasonCodes } : {})
};

return {
effectiveContextSummary,
conflictHint
};
}

private collectConflictReasons(
state: LangGraphState,
includeTableCount: number,
excludeTableCount: number
): string[] {
const reasonCodes: string[] = [];
if (includeTableCount > 0 && excludeTableCount > 0 && this.hasTableOverlap(state)) {
reasonCodes.push("user_envelope_include_exclude_overlap");
}
if (
(state.retrievalBundle?.risk_tags ?? []).some((tag) => CONTEXT_CONFLICT_REGEX.test(tag))
) {
reasonCodes.push("retrieval_context_conflict_risk");
}
if ((state.planningWarnings ?? []).some((warning) => CONTEXT_CONFLICT_REGEX.test(warning))) {
reasonCodes.push("planning_conflict_warning");
}
return Array.from(new Set(reasonCodes));
}

private hasTableOverlap(state: LangGraphState): boolean {
const include = new Set(
(state.contextEnvelope?.mustIncludeTables ?? [])
.map((item) => item.trim().toLowerCase())
.filter(Boolean)
);
if (include.size === 0) {
return false;
}
return (state.contextEnvelope?.mustExcludeTables ?? [])
.map((item) => item.trim().toLowerCase())
.filter(Boolean)
.some((item) => include.has(item));
}

private hasContextEnvelope(input: ContextEnvelope | undefined): boolean {
if (!input) {
return false;
}
return (
this.hasNonEmptyString(input.metricDefinition) ||
this.hasTimeRange(input.timeRange) ||
this.countEntityMappings(input.entityMappings) > 0 ||
this.countNonEmptyStrings(input.mustIncludeTables) > 0 ||
this.countNonEmptyStrings(input.mustExcludeTables) > 0 ||
this.countNonEmptyStrings(input.businessConstraints) > 0
);
}

private hasTimeRange(value: ContextEnvelope["timeRange"] | undefined): boolean {
if (!value || typeof value !== "object") {
return false;
}
const timeRange = value as {
from?: string;
to?: string;
timezone?: string;
};
return (
this.hasNonEmptyString(timeRange.from) ||
this.hasNonEmptyString(timeRange.to) ||
this.hasNonEmptyString(timeRange.timezone)
);
}

private countEntityMappings(
value: ContextEnvelope["entityMappings"] | undefined
): number {
if (!Array.isArray(value)) {
return 0;
}
return value.reduce((count, item) => {
if (!item || typeof item !== "object") {
return count;
}
const candidate = item as {
entity?: string;
mappedTo?: string;
};
if (this.hasNonEmptyString(candidate.entity) || this.hasNonEmptyString(candidate.mappedTo)) {
return count + 1;
}
return count;
}, 0);
}

private countNonEmptyStrings(values: string[] | undefined): number {
if (!values) {
return 0;
}
return values.filter((item) => this.hasNonEmptyString(item)).length;
}

private hasNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const LangGraphStateAnnotation = Annotation.Root({
datasourceId: Annotation<string>(),
datasourceType: Annotation<DatasourceType | undefined>(),
modelCatalogId: Annotation<string | undefined>(),
contextEnvelope: Annotation<LangGraphState["contextEnvelope"]>(),
accessContext: Annotation<LangGraphState["accessContext"]>(),
planningScaffoldEnabled: Annotation<boolean | undefined>(),
traceContext: Annotation<LangGraphState["traceContext"]>(),
Expand Down Expand Up @@ -170,7 +171,10 @@ export const createLangGraphRuntime = (deps: LangGraphNodeDependencies) => {
config,
buildRunningStep(state, "clarify", startedAt, "正在理解问题")
);
const clarification = deps.clarifyNode.run(state.question);
const clarification = deps.clarifyNode.run(
state.question,
state.contextEnvelope
);
const endedAt = new Date().toISOString();
if (clarification) {
const outputs = {
Expand Down Expand Up @@ -640,7 +644,9 @@ export const createLangGraphRuntime = (deps: LangGraphNodeDependencies) => {
modelCatalogId: generated.modelCatalogId,
sql: generated.sql,
rawText: generated.rawText,
promptTemplate: generated.promptTemplate
promptTemplate: generated.promptTemplate,
retryCount: generated.retryCount ?? 0,
semanticIntent: generated.semanticIntent
};
const trace = appendStep(state, {
step: {
Expand All @@ -661,6 +667,7 @@ export const createLangGraphRuntime = (deps: LangGraphNodeDependencies) => {
trace: {
...trace.trace,
provider: generated.provider,
retryCount: generated.retryCount ?? trace.trace.retryCount ?? 0,
...(generated.promptTemplate
? {
promptTemplate: generated.promptTemplate
Expand Down
36 changes: 16 additions & 20 deletions apps/backend/src/modules/conversation/agent/nodes/clarify.node.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,27 @@
import { Injectable } from "@nestjs/common";
import type { ClarificationPrompt } from "@text2sql/shared-types";
import type { ClarificationPrompt, ContextEnvelope } from "@text2sql/shared-types";
import { decideSlotFilling } from "./slot-filling-context";

@Injectable()
export class ClarifyNode {
private readonly ambiguousPatterns = [
/这个/,
/那个/,
/帮我查一下/,
/看看情况/,
/看看.*情况/,
/看看/
];

run(question: string): ClarificationPrompt | undefined {
const trimmed = question.trim();
if (trimmed.length < 6) {
run(
question: string,
contextEnvelope?: ContextEnvelope
): ClarificationPrompt | undefined {
try {
const decision = decideSlotFilling(question, contextEnvelope);
if (!decision.shouldClarify) {
return undefined;
}
return {
reason: "问题信息不足",
question: "请补充时间范围和分析对象,例如“近30天退款金额最高的商家有哪些?”"
reason: decision.reason,
question: decision.question
};
}
if (this.ambiguousPatterns.some((regex) => regex.test(trimmed))) {
} catch {
return {
reason: "问题语义不明确",
question: "请说明你希望查看的指标、时间范围和维度。"
reason: "槽位解析异常",
question: "请补充分析对象、指标口径和时间范围后重试。"
};
}
return undefined;
}
}
Loading
Loading