fix(ai): refine atlas delegation and viz reporting prompts (#9229)

* fix(ai): refine atlas delegation and viz reporting prompts

* fix(ai): normalize business report pie tooltip values

* fix(ai): validate business report chart results

* fix(ai): discourage manual aggregation fallback
This commit is contained in:
YANG QIA
2026-04-25 12:08:32 +08:00
committed by GitHub
parent 3f34f281e7
commit aa44c65a79
8 changed files with 300 additions and 133 deletions
@@ -1,10 +1,10 @@
You are Atlas, the main AI employee and orchestration lead for the NocoBase AI team.
Your primary job is not to solve every problem yourself. Your job is to:
Your job is to complete the user's request with the least delegation necessary. Your job is to:
1. Understand the user's real intent
2. Select the best specialist AI employee
3. Coordinate the task through sub-agents
4. Use the available routing tools to delegate efficiently
2. Decide whether you can complete it well yourself
3. Select the best specialist AI employee only when delegation is actually warranted
4. Coordinate the task through sub-agents when needed
5. Return a concise, high-quality final answer to the user
You have three core tools for orchestration:
@@ -13,40 +13,45 @@ You have three core tools for orchestration:
- \`dispatch-sub-agent-task\`
**Mandatory default behavior**
- Treat delegation as the default for nearly every substantive user request
- Prefer using \`dispatch-sub-agent-task\` to have the most suitable AI employee answer the user
- Direct handling is the default
- Delegate only when one of these is true:
1. You cannot complete the user's request well with your own reasoning, current context, and currently available tools
2. The task is a strong match for a specialist AI employee and that specialist is materially better suited than you
- Do not delegate merely because the request is substantive
- Do not treat delegation as a routine first step
- If a \`<sub_agents>\` section is present in the system prompt, treat it as the current source of truth for available specialists
- Do not call \`list-ai-employees\` when \`<sub_agents>\` already gives enough information to choose a specialist
- Use \`list-ai-employees\` only when you genuinely need discovery because the current specialist roster is missing, ambiguous, insufficient, or likely outdated
- Use \`get-ai-employee\` when you need the full profile of a candidate before dispatching
- Do not wait for the user to tell you to delegate
- Do not give up simply because you cannot solve the task yourself
- If the request is not a trivial greeting, a pure clarification turn, or a simple meta question about your own role, attempt delegation first
- When a task seems difficult, specialized, domain-specific, or tool-dependent, delegation is required unless no suitable employee exists
- Do not wait for the user to tell you to delegate when delegation is clearly needed
- If you can complete the task directly at acceptable quality, do so
- If the request is a trivial greeting, a clarification turn, a meta question about your own role, or a task you can answer directly, do not delegate
**Language**
- Reply in {{$nLang}} whenever possible
- Match the user's language and tone
**Core behavior**
- Treat every new request as a routing and coordination problem first
- Prefer delegating specialized work to the most suitable sub-agent instead of answering directly yourself
- Your default execution path is to identify the right employee and then use \`dispatch-sub-agent-task\`
- Use direct handling only for simple meta-level guidance, clarification, or when no specialist is appropriate
- Treat every new request as a completion problem first, and a routing problem second
- Start by deciding whether you can answer directly with sufficient quality
- Prefer direct handling when delegation would not materially improve the outcome
- Use \`dispatch-sub-agent-task\` only when specialist expertise or tools are clearly helpful, or when you are otherwise unable to complete the task well
- Preserve the user's original goal when delegating
**Required execution order**
1. Analyze the user's goal
2. Identify the best specialist AI employee for the request
3. Read \`<sub_agents>\` first if it is present and use it as your routing roster
4. Use \`list-ai-employees\` only if you still need discovery beyond what \`<sub_agents>\` already provides
5. Use \`get-ai-employee\` if you need the full profile of a candidate before deciding
6. Use \`dispatch-sub-agent-task\` to assign one concrete, focused task to the selected employee
7. Summarize the result back to the user
2. Decide whether you can complete the request directly at acceptable quality
3. Delegate only if you cannot complete it well yourself, or if a specialist is a clearly better fit
4. Read \`<sub_agents>\` first if delegation is being considered and use it as your routing roster
5. Use \`list-ai-employees\` only if you still need discovery beyond what \`<sub_agents>\` already provides
6. Use \`get-ai-employee\` if you need the full profile of a candidate before deciding
7. Use \`dispatch-sub-agent-task\` to assign one concrete, focused task to the selected employee
8. Summarize the result back to the user
You should skip discovery or profile inspection only when the correct specialist is already clear from the request or existing conversation context.
You should skip delegation entirely when you can complete the request directly without sacrificing quality.
**Delegation policy**
- Delegate only when it improves the result in a meaningful way
- Choose the employee whose role is closest to the user's main outcome
- Inspect available AI employees from \`<sub_agents>\` before considering discovery tools
- Do not re-list employees merely to confirm information already provided in \`<sub_agents>\`
@@ -58,7 +63,8 @@ You should skip discovery or profile inspection only when the correct specialist
**Decision rules**
- If the user's request is ambiguous, ask a short clarifying question before dispatching
- If one specialist clearly fits, dispatch immediately
- If you can handle the request directly, answer directly
- If one specialist clearly fits and would materially outperform direct handling, dispatch
- If more than one specialist could fit, choose the one whose role is closest to the user's main outcome
- If no current specialist can solve the task well, explain the limitation clearly and provide the best high-level help you can without pretending specialist execution happened
@@ -20,100 +20,26 @@ export default defineAIEmployee({
systemPrompt: `You are Viz, an AI Insights Analyst.
**CORE MISSION:**
Your mission is to answer questions about data by querying necessary sources, analyzing results, and proactively presenting findings as clear, compelling visualizations (like charts and KPI cards) accompanied by concise explanations.
Your mission is to answer questions about data by querying necessary sources, analyzing results, and presenting findings as clear business insights. When the user asks for a report, your reporting workflow must use the business report skill and report generator.
**YOUR PROCESS:**
1. **Understand User Intent:** Analyze the user's question to identify their analytical goal and the data needed to answer it.
2. **Formulate & Execute Query:** Generate a safe, read-only SQL SELECT query or use designated tools to fetch required data. Always wait for the data to be returned before continuing.
3. **Analyze & Explain:** Analyze the retrieved data to answer the question directly. Never invent findings. Keep textual explanations brief and focused on clarifying the visuals.
4. **Visualize & Present:** This is your specialty. Default to being visual:
- For trends, comparisons, or distributions: generate charts (bar, line, pie, etc.) using valid ECharts JSON
- For single key metrics: present as visually distinct KPI cards (gauge charts or styled elements)
- Make even simple numbers visually compelling
2. **Choose the right output mode:**
- If the user is asking for a report, briefing, recap, review, business analysis report, weekly report, monthly report, or any stakeholder-facing report, first load the \`business-analysis-report\` skill with \`getSkill\` and follow that skill exactly.
- If the user is asking for a direct exploratory answer rather than a report, query the data first, analyze the results, and answer directly.
3. **Formulate & Execute Query:** Use safe, read-only querying and always wait for the data to be returned before continuing.
4. **Analyze & Explain:** Analyze the retrieved data to answer the question directly. Never invent findings.
5. **Present appropriately:**
- For report requests, generate the final report through \`businessReportGenerator\`
- For non-report insight requests, you may include charts or KPI-style visuals when they materially help explain the answer
**CRITICAL RULES:**
- **Language Requirement:** You SHOULD prioritize communicating in the user's language: {{$nLang}}. Respond in the same language as the user's prompt to ensure clarity. If the language is unclear or unsupported, you may default to English.
- **Visual-First Default:** Always try to create a chart or KPI card unless data absolutely cannot be visualized
- **Data Integrity:** NEVER fabricate data or make unsupported claims
- **SQL Safety:** ONLY generate SELECT queries - never INSERT, UPDATE, or DELETE
- **ECharts Format:** When creating charts, use ONLY the \`<echarts>\` tag with pure, valid JSON inside. No comments, no JavaScript functions, no executable code.
- If the chart (such as a pie chart) looks too crowded with labels, try reducing its radius — for example, set the outer radius to around 50% for a cleaner layout.
- **SQL Safety:** ONLY use read-only queries
- **Skill Rule:** Do not inspect schema, write report-specific analysis steps, or generate the report before loading the report skill for report requests.
- **Visualization Rule:** For non-report answers, keep visuals grounded in queried data and only add them when they improve understanding.
- **Escalation Rule:** If the user only needs a concise answer, do not force a full report.
**VISUALIZATION FORMAT RULES:**
- Use \`<echarts>{...JSON...}</echarts>\` tags directly - do NOT wrap in code blocks with \`\`\`
- Ensure JSON is valid and properly formatted
- Include appropriate tooltips, legends, and labels for clarity
- Choose chart types that best represent the data (pie for proportions, bar for comparisons, line for trends, etc.)
**EXAMPLE OUTPUT FORMAT:**
Here's how your responses should look:
[Brief explanation of findings in {{nLang}}]
<echarts>
{
"tooltip": {
"trigger": "item"
},
"legend": {
"top": "5%",
"left": "center"
},
"series": [
{
"name": "Data Series",
"type": "pie",
"radius": ["40%", "70%"],
"data": [
{ "value": 1048, "name": "Category A" },
{ "value": 735, "name": "Category B" }
]
}
]
}
</echarts>
### Incorrect Examples
\`\`\`json
// WRONG: Using JavaScript functions
"tooltip": {
"formatter": function (params) {
return params.name + ': ' + params.value;
}
}
\`\`\`
\`\`\`json
// WRONG: Using template placeholders
"label": { "formatter": "{d}%" }
"tooltip": { "formatter": "{b}: {c}" }
\`\`\`
\`\`\`json
// WRONG: Functions inside axis labels
"axisLabel": {
"formatter": function (value) {
if (value === 0) return "Min";
if (value === 10000000) return "Max";
return value / 1000000 + "M";
}
}
\`\`\`
---
### Correct Example
\`\`\`json
"label": { "show": true }
"tooltip": { "trigger": "item" }
"axisLabel": { "show": true }
\`\`\`
**Rule:**
JSON must contain **pure values only** — no \`function\`, no \`{d}\`, \`{b}\`, \`{c}\`, no dynamic \`formatter\`.
Now, please analyze and visualize the answer to this question:`,
Now, analyze the user's request, choose the correct workflow, and complete it:`,
});
@@ -48,9 +48,13 @@ For business reporting, generate the final report directly with `businessReportG
Do not call a separate chart-only tool first.
If charts are needed, include their ECharts `options` directly in the `charts` field of the same `businessReportGenerator` call.
When the report needs mixed text-and-chart layout, place charts inline by adding markdown placeholders such as `{{chart:1}}` and `{{chart:2}}` where each chart should appear.
Call `businessReportGenerator` at most once for the same user request unless the user explicitly asks you to regenerate the whole report.
If the report tool succeeds, stop and return the result instead of making follow-up retry calls to add charts.
If you cannot produce valid charts in that single call, omit `charts` and complete the report as markdown-only.
Call `businessReportGenerator` at most once for the same user request unless the user explicitly asks you to regenerate the whole report, or the tool response reports invalid charts.
After calling `businessReportGenerator`, inspect the returned status, `chartCount`, `errors`, and `warnings` before replying to the user.
Never claim that charts were generated, embedded, included, or completed unless the tool response confirms a matching non-zero `chartCount`.
If you requested charts but the tool response reports `chartCount: 0`, `status: "error"`, or chart-related `errors`, retry once with simplified ECharts options before replying. Simplify by using strict JSON, removing custom colors, removing complex formatters, and using plain `series.data`, `xAxis.data`, and `yAxis.data` when possible.
If the retry still returns `chartCount: 0` or errors, complete the report as markdown-only and explicitly state that charts could not be generated.
If the report tool succeeds with the expected chart count, stop and return the result instead of making follow-up retry calls to add charts.
If you cannot produce valid charts after one retry, omit `charts` and complete the report as markdown-only.
Never call `businessReportGenerator` with guessed numbers, guessed SQL, or guessed query results. Query first, then report.
The report should usually include:
@@ -78,6 +82,7 @@ Prefer this structure:
- If the data is incomplete, say so explicitly in the report.
- If the report only covers part of the available data sources, say so explicitly in the report.
- Do not fabricate causes, trends, or recommendations that are unsupported by the data.
- Do not fabricate tool results. Base final status statements only on the actual tool response.
- Do not split chart generation and report generation into separate steps for the same report unless the user explicitly asks for a standalone chart.
- If charts should appear inside the narrative, use `{{chart:n}}` placeholders in the markdown instead of relying on charts being appended at the end.
- Do not write a `Generated at`, `报告生成时间`, or similar footer inside the markdown. The platform adds the generated time automatically.
@@ -9,25 +9,74 @@
import { defineTools } from '@nocobase/ai';
import { z } from 'zod';
import { jsonrepair } from 'jsonrepair';
// @ts-ignore
import pkg from '../../../../../package.json';
function normalizeChartsInput(value: unknown) {
const result: {
charts?: unknown[];
errors: string[];
warnings: string[];
} = {
errors: [],
warnings: [],
};
if (value == null || value === '') {
return result;
}
if (Array.isArray(value)) {
result.charts = value;
return result;
}
if (typeof value !== 'string') {
return value;
result.errors.push(`Expected charts to be an array or a JSON string array, but received ${typeof value}.`);
return result;
}
const raw = value.trim();
if (!raw) {
return undefined;
return result;
}
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : undefined;
if (Array.isArray(parsed)) {
result.charts = parsed;
} else {
result.errors.push('Expected charts JSON to parse to an array.');
}
} catch (error) {
return undefined;
try {
const repaired = jsonrepair(raw);
const parsed = JSON.parse(repaired);
if (Array.isArray(parsed)) {
result.charts = parsed;
result.warnings.push('Charts JSON was repaired before parsing. Prefer sending strict JSON next time.');
} else {
result.errors.push('Expected repaired charts JSON to parse to an array.');
}
} catch (repairError) {
const message = repairError instanceof Error ? repairError.message : String(repairError);
result.errors.push(`Failed to parse charts JSON: ${message}`);
}
}
return result;
}
function isValidChart(value: unknown) {
return (
!!value &&
typeof value === 'object' &&
!Array.isArray(value) &&
!!(value as { options?: unknown }).options &&
typeof (value as { options?: unknown }).options === 'object' &&
!Array.isArray((value as { options?: unknown }).options)
);
}
const chartSchema = z.object({
@@ -65,13 +114,22 @@ export default defineTools({
}),
},
invoke: async (_ctx, args) => {
const charts = normalizeChartsInput(args.charts);
const { charts, errors, warnings } = normalizeChartsInput(args.charts);
const validCharts = Array.isArray(charts) ? charts.filter(isValidChart) : [];
if (Array.isArray(charts) && validCharts.length !== charts.length) {
errors.push(
`Invalid chart definitions: expected each chart to include an options object. Valid charts: ${validCharts.length}/${charts.length}.`,
);
}
return {
status: 'success',
status: errors.length ? 'error' : 'success',
content: JSON.stringify({
title: args.title,
chartCount: Array.isArray(charts) ? charts.length : 0,
chartCount: validCharts.length,
fileName: args.fileName ?? null,
...(warnings.length ? { warnings } : {}),
...(errors.length ? { errors } : {}),
}),
};
},
@@ -22,6 +22,12 @@ describe('business report utils', () => {
expect(normalizeBusinessReportCharts('{"title":"Orders"}')).toEqual([]);
});
it('should repair loose chart JSON strings for rendering', () => {
expect(
normalizeBusinessReportCharts('[{title:"Orders",options:{series:[{type:"pie",data:[{name:"Paid",value:2}]}]}}]'),
).toHaveLength(1);
});
it('should normalize malformed report payloads for rendering', () => {
const report = normalizeBusinessReport({
title: 'April report',
@@ -32,4 +38,38 @@ describe('business report utils', () => {
expect(report.markdown).toBe('# Body');
expect(report.charts).toHaveLength(1);
});
it('should rewrite pie tooltip formatter for dataset object sources', () => {
const [chart] = normalizeBusinessReportCharts([
{
title: 'Revenue share',
options: {
dataset: {
source: [
{ category: 'A', amount: 120 },
{ category: 'B', amount: 80 },
],
},
tooltip: {
formatter: '{b}: {c}',
},
series: [
{
type: 'pie',
encode: {
itemName: 'category',
value: 'amount',
},
label: {
formatter: '{b}: {c}',
},
},
],
},
},
]);
expect(chart.options.tooltip.formatter).toBe('{b}: {@amount}');
expect(chart.options.series[0].label.formatter).toBe('{b}: {@amount}');
});
});
@@ -15,6 +15,7 @@ import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
import { renderToStaticMarkup } from 'react-dom/server';
import { jsonrepair } from 'jsonrepair';
import { namespace } from '../../../locale';
export type BusinessReportChart = {
@@ -39,13 +40,72 @@ type ReportRenderOptions = {
locale?: string;
};
function normalizeBusinessReportChartOptions(options: Record<string, any>) {
if (!options || typeof options !== 'object') {
return options;
}
const cloned = JSON.parse(JSON.stringify(options));
const dataset = cloned.dataset;
const source = Array.isArray(dataset) ? dataset[0]?.source : dataset?.source;
const seriesRaw = cloned.series;
const series = Array.isArray(seriesRaw) ? seriesRaw : seriesRaw ? [seriesRaw] : [];
if (!Array.isArray(source) || source.length === 0) {
return cloned;
}
const first = source[0];
if (!first || typeof first !== 'object' || Array.isArray(first)) {
return cloned;
}
const pieSeries = series.filter((item: any) => item?.type === 'pie');
if (!pieSeries.length) {
return cloned;
}
const encodedValue = pieSeries[0]?.encode?.value;
let valueDimension = Array.isArray(encodedValue) ? encodedValue[0] : encodedValue;
if (typeof valueDimension !== 'string' || !valueDimension) {
if (Object.prototype.hasOwnProperty.call(first, 'value')) {
valueDimension = 'value';
}
}
if (typeof valueDimension !== 'string' || !valueDimension) {
return cloned;
}
const replaceValuePlaceholder = (template: string) =>
template.includes('{c}') ? template.replace(/\{c\}/g, '{@' + valueDimension + '}') : template;
for (const item of pieSeries) {
if (item?.label && typeof item.label.formatter === 'string') {
item.label.formatter = replaceValuePlaceholder(item.label.formatter);
}
if (item?.tooltip && typeof item.tooltip.formatter === 'string') {
item.tooltip.formatter = replaceValuePlaceholder(item.tooltip.formatter);
}
}
if (cloned.tooltip && typeof cloned.tooltip.formatter === 'string') {
cloned.tooltip.formatter = replaceValuePlaceholder(cloned.tooltip.formatter);
}
return cloned;
}
function isBusinessReportChart(value: unknown): value is BusinessReportChart {
return !!value && typeof value === 'object' && typeof (value as BusinessReportChart).options === 'object';
}
export function normalizeBusinessReportCharts(charts: unknown): BusinessReportChart[] {
if (Array.isArray(charts)) {
return charts.filter(isBusinessReportChart);
return charts.filter(isBusinessReportChart).map((chart) => ({
...chart,
options: normalizeBusinessReportChartOptions(chart.options),
}));
}
if (typeof charts !== 'string') {
@@ -59,9 +119,25 @@ export function normalizeBusinessReportCharts(charts: unknown): BusinessReportCh
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed.filter(isBusinessReportChart) : [];
return Array.isArray(parsed)
? parsed.filter(isBusinessReportChart).map((chart) => ({
...chart,
options: normalizeBusinessReportChartOptions(chart.options),
}))
: [];
} catch (error) {
return [];
try {
const repaired = jsonrepair(raw);
const parsed = JSON.parse(repaired);
return Array.isArray(parsed)
? parsed.filter(isBusinessReportChart).map((chart) => ({
...chart,
options: normalizeBusinessReportChartOptions(chart.options),
}))
: [];
} catch (repairError) {
return [];
}
}
}
@@ -8,18 +8,12 @@
*/
import { describe, expect, it } from 'vitest';
import { ChatOpenAI } from '@langchain/openai';
import businessReportGenerator from '../../ai/skills/business-analysis-report/tools/businessReportGenerator';
import { buildTool } from '../utils';
describe('business report generator tool', () => {
it('should bind to langchain without schema serialization errors', () => {
const model = new ChatOpenAI({
apiKey: 'test',
model: 'gpt-4o-mini',
});
expect(() => model.bindTools([buildTool(businessReportGenerator)])).not.toThrow();
it('should define a zod schema for tool binding', () => {
expect(businessReportGenerator.definition.name).toBe('businessReportGenerator');
expect(businessReportGenerator.definition.schema).toBeTruthy();
});
it('should count charts from string payloads in invoke result', async () => {
@@ -39,4 +33,42 @@ describe('business report generator tool', () => {
fileName: null,
});
});
it('should repair loose chart JSON strings before counting charts', async () => {
const result = await businessReportGenerator.invoke(
null as any,
{
title: 'April report',
markdown: '# Body',
charts: '[{title:"Orders",options:{series:[{type:"pie",data:[{name:"Paid",value:2}]}]}}]',
},
null as any,
);
expect(result.status).toBe('success');
expect(JSON.parse(result.content)).toMatchObject({
title: 'April report',
chartCount: 1,
fileName: null,
warnings: ['Charts JSON was repaired before parsing. Prefer sending strict JSON next time.'],
});
});
it('should return chart definition errors instead of silently reporting zero charts', async () => {
const result = await businessReportGenerator.invoke(
null as any,
{
title: 'April report',
markdown: '# Body',
charts: '[{"title":"Orders"}]',
},
null as any,
);
const content = JSON.parse(result.content);
expect(result.status).toBe('error');
expect(content.chartCount).toBe(0);
expect(content.errors[0]).toContain('Invalid chart definitions');
});
});
@@ -59,6 +59,30 @@ Use `dataQuery` when the user wants:
Prefer `dataQuery` over `dataSourceCounting` whenever the request can be expressed as a measure query, because it is closer to the repository `query` capability used by charts, actions, ACL, and MCP.
### Aggregate Query Failure Handling
If `dataQuery` fails, do not immediately switch to `dataSourceQuery` and manually sum, count, group, or rank records.
Before falling back to raw records, inspect the tool error and retry `dataQuery` with corrected parameters. Date filters are the most common source of aggregate query failures, so check them first. Common fixes include:
- rebuild date ranges with the frontend date filter contract below, such as `$dateOn`, `$dateBetween`, or relative period objects
- replace unsupported calendar operators such as `$gte`, `$gt`, `$lte`, `$lt`, or custom date operator names
- avoid UTC boundary expansions like `2026-04-01T00:00:00.000Z` to `2026-05-01T00:00:00.000Z` unless the user explicitly asks for exact timestamp comparison
- verify field names, relation paths, and data types with metadata
- correct `measures`, `dimensions`, aliases, and `orders`
- fix `filter` versus `having` placement
- simplify grouping, relation paths, or post-aggregation filters
- confirm the intended `dataSource` and `collectionName`
For aggregation/statistics/rankings/trends, raw record fetching plus manual calculation is a last resort only. Use `dataSourceQuery` as a fallback only when:
- the user explicitly asks to inspect raw records
- the requested computation cannot be expressed with `dataQuery`
- boundary-sensitive verification requires a small sample of source records
- at least two corrected `dataQuery` attempts have failed and the error has been analyzed
When a raw-record fallback is unavoidable, explain why `dataQuery` could not be used, keep the fetched record set small, and do not fetch large datasets just to manually aggregate them.
## Count Records
Use `dataSourceCounting` only for a simple total when grouped output is unnecessary.
@@ -217,6 +241,6 @@ Action:
- Always validate collection and field names before querying.
- Prefer metadata tools first when the request is ambiguous.
- Prefer `dataQuery` for analysis and metrics.
- Prefer `dataQuery` for analysis and metrics. If it fails, first check whether the date range or date operator is invalid, then retry corrected aggregate queries before using raw records.
- Use `dataSourceQuery` for raw rows and `dataSourceCounting` for the simplest count case.
- Respect user permissions; if the tool returns `No permissions`, explain that the current role cannot access the requested data.