Skip to content

Commit b9ceaa4

Browse files
xuyushun441-sysos-zhuangclaude
authored
fix(service-ai): resolve current object for AI chat across languages (#1815)
* fix(service-ai): resolve current object for AI chat across languages The console assistant reported "can't find the X object" when asked to analyse the object on the current page — most visibly for non-English prompts. Three compounding gaps: - The keyword SchemaRetriever tokeniser dropped all CJK text, so a Chinese request yielded zero terms and "No matching objects". - describe_object/list_objects are cloud-only, leaving query_data's keyword match as the only object-discovery path in the open edition. - Nothing fed the current object's schema to the agent, so "this object" couldn't be resolved without a lucky keyword hit. Changes: - SchemaRetriever.tokenise() now emits CJK single-char + bigram terms. - AgentRuntime.buildContextSchemaMessages() injects the current object's schema into the system prompt; both chat routes call it. - ToolExecutionContext gains currentObjectName/currentViewName; routes thread them through and query_data falls back to the current object when keyword retrieval is empty. Tests cover CJK retrieval, schema injection (4 cases), and the query_data current-object fallback (2 cases). 81 service-ai tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(service-ai): add buildContextSchemaMessages to the quota-test mock runtime The mock AgentRuntime in agent-chat-quota.test.ts didn't implement the new buildContextSchemaMessages method, so the route's await on it threw and the handler returned 500 instead of 200. Mirror the real runtime in the mock. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e25705c commit b9ceaa4

10 files changed

Lines changed: 237 additions & 7 deletions

File tree

packages/services/service-ai/src/__tests__/agent-chat-quota.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ function mockAgentRuntime(): AgentRuntime {
120120
})),
121121
resolveActiveSkills: vi.fn(async () => []),
122122
buildSystemMessages: vi.fn(() => [{ role: 'system', content: 'sys' }]),
123+
buildContextSchemaMessages: vi.fn(async () => []),
123124
buildRequestOptions: vi.fn(() => ({})),
124125
listAgents: vi.fn(async () => []),
125126
} as unknown as AgentRuntime;

packages/services/service-ai/src/__tests__/chatbot-features.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -639,6 +639,38 @@ describe('AgentRuntime', () => {
639639
});
640640
});
641641

642+
describe('buildContextSchemaMessages', () => {
643+
it('injects the current object schema so "this object" resolves without a tool', async () => {
644+
(metadataService.getObject as any).mockResolvedValue({
645+
name: 'showcase_task',
646+
label: 'Task',
647+
fields: { id: { type: 'text' }, subject: { type: 'text', label: 'Subject' } },
648+
});
649+
const messages = await runtime.buildContextSchemaMessages({ objectName: 'showcase_task' });
650+
expect(metadataService.getObject).toHaveBeenCalledWith('showcase_task');
651+
expect(messages).toHaveLength(1);
652+
expect(messages[0].role).toBe('system');
653+
expect(messages[0].content).toContain('currently viewing the "showcase_task" object');
654+
expect(messages[0].content).toContain('### showcase_task');
655+
expect(messages[0].content).toContain('subject: text');
656+
});
657+
658+
it('returns nothing when no object is in context', async () => {
659+
expect(await runtime.buildContextSchemaMessages(undefined)).toEqual([]);
660+
expect(await runtime.buildContextSchemaMessages({})).toEqual([]);
661+
});
662+
663+
it('returns nothing when the object cannot be resolved', async () => {
664+
(metadataService.getObject as any).mockResolvedValue(undefined);
665+
expect(await runtime.buildContextSchemaMessages({ objectName: 'nope' })).toEqual([]);
666+
});
667+
668+
it('degrades gracefully when metadata lookup throws', async () => {
669+
(metadataService.getObject as any).mockRejectedValue(new Error('boom'));
670+
expect(await runtime.buildContextSchemaMessages({ objectName: 'showcase_task' })).toEqual([]);
671+
});
672+
});
673+
642674
describe('buildRequestOptions', () => {
643675
it('should derive model config from agent', () => {
644676
const options = runtime.buildRequestOptions(DATA_CHAT_AGENT, []);

packages/services/service-ai/src/__tests__/schema-retriever.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,18 @@ describe('SchemaRetriever', () => {
6868
expect(hits).toEqual([]);
6969
});
7070

71+
it('tokenises CJK queries so a Chinese label still matches', async () => {
72+
const cjkTask: ObjectShape = {
73+
name: 'showcase_task',
74+
label: '任务',
75+
pluralLabel: '任务',
76+
fields: { id: { type: 'text' }, 标题: { type: 'text', label: '标题' } },
77+
};
78+
const r = new SchemaRetriever(mockMetadata([cjkTask, accountObject]));
79+
const hits = await r.retrieve('帮我分析任务对象');
80+
expect(hits[0]?.object.name).toBe('showcase_task');
81+
});
82+
7183
it('respects limit option', async () => {
7284
const r = new SchemaRetriever(
7385
mockMetadata([taskObject, accountObject, unrelatedObject]),

packages/services/service-ai/src/agent-runtime.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type {
99
import type { Agent, Skill } from '@objectstack/spec/ai';
1010
import { AgentSchema } from '@objectstack/spec/ai';
1111
import { SkillRegistry, type SkillContext } from './skill-registry.js';
12+
import { SchemaRetriever, type ObjectShape } from './schema-retriever.js';
1213
import { DEFAULT_DATA_AGENT_NAME } from './agents/index.js';
1314

1415
/**
@@ -191,6 +192,49 @@ export class AgentRuntime {
191192
return [{ role: 'system' as const, content: parts.join('\n') }];
192193
}
193194

195+
/**
196+
* Build an extra system message carrying the schema of the object the user
197+
* is currently viewing ({@link AgentChatContext.objectName}).
198+
*
199+
* `buildSystemMessages` only states the object's *name*; it can't fetch the
200+
* schema because it is synchronous. This async companion looks the object up
201+
* and renders a compact field snippet so the agent can answer "analyse /
202+
* describe this object" questions with ZERO tool calls — important on the
203+
* open-framework deployment where `describe_object` isn't registered, and
204+
* for non-English prompts that the keyword-based {@link SchemaRetriever}
205+
* can't otherwise resolve to an English-named object.
206+
*
207+
* Returns an empty array when there is no current object or it can't be
208+
* resolved — callers append the result, so a miss simply injects nothing.
209+
*/
210+
async buildContextSchemaMessages(context?: AgentChatContext): Promise<ModelMessage[]> {
211+
const objectName = context?.objectName;
212+
if (!objectName) return [];
213+
214+
let raw: unknown;
215+
try {
216+
raw = await this.metadataService.getObject(objectName);
217+
} catch {
218+
return [];
219+
}
220+
if (!raw || typeof raw !== 'object' || !(raw as ObjectShape).name) return [];
221+
222+
const snippet = SchemaRetriever.renderSnippet([{ object: raw as ObjectShape, score: 1 }]);
223+
if (!snippet) return [];
224+
225+
return [
226+
{
227+
role: 'system' as const,
228+
content:
229+
`The user is currently viewing the "${objectName}" object. When they refer to ` +
230+
'"this object", "the current object", or its label without naming another one, ' +
231+
`assume they mean "${objectName}". Use the schema below to answer questions about ` +
232+
'its structure directly, and as the target object name for data-query tools.\n\n' +
233+
snippet,
234+
},
235+
];
236+
}
237+
194238
/**
195239
* Derive {@link AIRequestOptions} from an agent definition.
196240
*

packages/services/service-ai/src/routes/agent-routes.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,11 @@ export function buildAgentRoutes(
189189
// Build system messages from agent instructions + UI context + skills
190190
const systemMessages = agentRuntime.buildSystemMessages(agent, chatContext, activeSkills);
191191

192+
// Inject the schema of the object the user is currently viewing so
193+
// "analyse / describe this object" works without a lookup tool and
194+
// regardless of the prompt's language.
195+
systemMessages.push(...(await agentRuntime.buildContextSchemaMessages(chatContext)));
196+
192197
// Resolve agent model/tools + skill tools → request options
193198
const agentOptions = agentRuntime.buildRequestOptions(
194199
agent,
@@ -234,6 +239,13 @@ export function buildAgentRoutes(
234239
typeof chatContext?.environmentId === 'string'
235240
? chatContext.environmentId
236241
: undefined,
242+
// The object/view the user has open — lets built-in data
243+
// tools fall back to "this object" when the request doesn't
244+
// name one (ADR-aligned with the schema injection above).
245+
currentObjectName:
246+
typeof chatContext?.objectName === 'string' ? chatContext.objectName : undefined,
247+
currentViewName:
248+
typeof chatContext?.viewName === 'string' ? chatContext.viewName : undefined,
237249
}
238250
: undefined,
239251
};

packages/services/service-ai/src/routes/assistant-routes.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,9 @@ export function buildAssistantRoutes(
197197
}
198198

199199
const systemMessages = agentRuntime.buildSystemMessages(agent, context, activeSkills);
200+
// Inject the current object's schema so "describe this object" works
201+
// without a lookup tool and regardless of prompt language.
202+
systemMessages.push(...(await agentRuntime.buildContextSchemaMessages(context)));
200203
const agentOptions = agentRuntime.buildRequestOptions(
201204
agent,
202205
aiService.toolRegistry.getAll(),
@@ -237,6 +240,10 @@ export function buildAssistantRoutes(
237240
typeof body.conversationId === 'string' ? body.conversationId : undefined,
238241
environmentId:
239242
typeof context.environmentId === 'string' ? context.environmentId : undefined,
243+
currentObjectName:
244+
typeof context.objectName === 'string' ? context.objectName : undefined,
245+
currentViewName:
246+
typeof context.viewName === 'string' ? context.viewName : undefined,
240247
}
241248
: undefined,
242249
};

packages/services/service-ai/src/schema-retriever.ts

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -181,12 +181,33 @@ export interface FieldShape {
181181

182182
// ── internal helpers ──────────────────────────────────────────────
183183

184-
/** Lower-case alphanumeric tokens of length ≥ 2 (English stop-words excluded). */
184+
/**
185+
* Tokenise a query into match terms.
186+
*
187+
* Latin/digit runs split on any non-alphanumeric (including underscores) so
188+
* `todo_task` tokenises to ['todo', 'task'] and matches snake_case names.
189+
*
190+
* CJK text carries no word boundaries, so a `[a-z0-9]+` scan drops it entirely
191+
* — a question like "分析任务对象" would yield zero terms and surface a
192+
* misleading "no matching objects". To keep CJK queries scoreable against
193+
* CJK object/field labels, every ideograph is emitted as a single-char term
194+
* plus each adjacent bigram (so "任务" matches a label containing "任务").
195+
*/
185196
function tokenise(query: string): string[] {
186-
// Split on any non-alphanumeric (including underscores) so `todo_task`
187-
// tokenises to ['todo', 'task'] and matches snake_case object names.
188-
const raw = query.toLowerCase().match(/[a-z0-9]+/g) ?? [];
189-
return raw.filter(t => t.length >= 2 && !STOPWORDS.has(t));
197+
const lower = query.toLowerCase();
198+
const latin = (lower.match(/[a-z0-9]+/g) ?? []).filter(
199+
t => t.length >= 2 && !STOPWORDS.has(t),
200+
);
201+
const tokens = [...latin];
202+
// CJK Unified Ideographs (+ Ext-A, compatibility) and Japanese kana.
203+
const cjkRuns = lower.match(/[--䶿-鿿-﫿]+/g) ?? [];
204+
for (const run of cjkRuns) {
205+
for (let i = 0; i < run.length; i++) {
206+
tokens.push(run[i]);
207+
if (i + 1 < run.length) tokens.push(run.slice(i, i + 2));
208+
}
209+
}
210+
return tokens;
190211
}
191212

192213
const STOPWORDS = new Set([

packages/services/service-ai/src/tools/query-data.tool.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,61 @@ describe('query_data — federated query timeout (P4)', () => {
9595
expect(out.records[0].id).toBe('o1');
9696
});
9797

98+
it('falls back to the current object when keyword retrieval finds nothing', async () => {
99+
const currentObject = {
100+
name: 'showcase_task',
101+
label: 'Task',
102+
fields: { id: { type: 'text' }, subject: { type: 'text' } },
103+
};
104+
let findObject: string | undefined;
105+
const ctx: QueryDataToolContext = {
106+
ai: {
107+
generateObject: async () => ({
108+
object: {
109+
objectName: 'showcase_task',
110+
whereJson: null,
111+
fields: null,
112+
orderBy: null,
113+
limit: null,
114+
} satisfies QueryPlan,
115+
}),
116+
} as never,
117+
metadata: {
118+
// Catalogue is non-empty, but a Chinese request tokenises to terms
119+
// that don't match the English name/label → zero keyword hits.
120+
listObjects: async () => [currentObject],
121+
getObject: async (name: string) => (name === 'showcase_task' ? currentObject : undefined),
122+
} as never,
123+
dataEngine: {
124+
find: async (objectName: string) => {
125+
findObject = objectName;
126+
return [{ id: 't1', subject: 'Build the thing' }];
127+
},
128+
} as never,
129+
};
130+
const handler = createQueryDataHandler(ctx);
131+
const out = JSON.parse(
132+
(await handler({ request: '分析这个对象的数据' }, { currentObjectName: 'showcase_task' } as never)) as string,
133+
);
134+
expect(out.error).toBeUndefined();
135+
expect(findObject).toBe('showcase_task');
136+
expect(out.count).toBe(1);
137+
});
138+
139+
it('still errors when retrieval is empty and no current object is supplied', async () => {
140+
const ctx: QueryDataToolContext = {
141+
ai: { generateObject: async () => ({ object: {} as QueryPlan }) } as never,
142+
metadata: {
143+
listObjects: async () => [{ name: 'account', label: 'Account', fields: {} }],
144+
getObject: async () => undefined,
145+
} as never,
146+
dataEngine: { find: async () => [] } as never,
147+
};
148+
const handler = createQueryDataHandler(ctx);
149+
const out = JSON.parse((await handler({ request: '分析这个对象' })) as string);
150+
expect(out.error).toMatch(/No matching objects in metadata/);
151+
});
152+
98153
it('does not wrap managed (non-external) objects in a timeout', async () => {
99154
const managedObject = {
100155
name: 'task',

packages/services/service-ai/src/tools/query-data.tool.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import type {
99
ModelMessage,
1010
} from '@objectstack/spec/contracts';
1111
import type { ExecutionContext } from '@objectstack/spec/kernel';
12-
import { SchemaRetriever } from '../schema-retriever.js';
12+
import { SchemaRetriever, type SchemaHit } from '../schema-retriever.js';
1313
import type { ToolHandler, ToolRegistry, ToolExecutionContext } from './tool-registry.js';
1414

1515
/** See `data-tools.ts#buildEngineContext` — duplicated here to keep
@@ -161,6 +161,35 @@ export function createQueryDataHandler(ctx: QueryDataToolContext): ToolHandler {
161161
const maxLimit = ctx.maxLimit ?? 100;
162162
const externalTimeoutFallback = ctx.externalQueryTimeoutMs ?? 30_000;
163163

164+
/** Load a single object definition by exact name, mirroring the dual-source
165+
* lookup (MetadataManager → ObjectQL SchemaRegistry via protocol) so the
166+
* current-object fallback can see system objects too. Never throws. */
167+
const loadObjectByName = async (name: string): Promise<SchemaHit['object'] | undefined> => {
168+
try {
169+
const direct = await ctx.metadata.getObject?.(name);
170+
if (direct && typeof direct === 'object' && (direct as { name?: string }).name) {
171+
return direct as SchemaHit['object'];
172+
}
173+
} catch {
174+
// fall through to protocol enumeration
175+
}
176+
if (ctx.protocol?.getMetaItems) {
177+
try {
178+
const all = await ctx.protocol.getMetaItems({ type: 'object' });
179+
const arr = Array.isArray(all)
180+
? all
181+
: (all && typeof all === 'object' && Array.isArray((all as { items?: unknown }).items)
182+
? (all as { items: unknown[] }).items
183+
: []);
184+
const found = (arr as Array<{ name?: string }>).find(o => o?.name === name);
185+
if (found) return found as SchemaHit['object'];
186+
} catch {
187+
// ignore — caller treats undefined as "not found"
188+
}
189+
}
190+
return undefined;
191+
};
192+
164193
/** Resolve a federated object's per-query timeout (datasource-declared,
165194
* else the tool fallback). Never throws — degrades to the fallback. */
166195
const resolveExternalTimeout = async (datasource: string): Promise<number> => {
@@ -190,7 +219,14 @@ export function createQueryDataHandler(ctx: QueryDataToolContext): ToolHandler {
190219
}
191220

192221
// 1. Schema retrieval
193-
const hits = await retriever.retrieve(request);
222+
let hits = await retriever.retrieve(request);
223+
// Fallback: when keyword retrieval finds nothing (e.g. a non-English
224+
// request, or one that says "this object" without naming it), target the
225+
// object the user is currently viewing if the UI supplied one.
226+
if (hits.length === 0 && execCtx?.currentObjectName) {
227+
const current = await loadObjectByName(execCtx.currentObjectName);
228+
if (current) hits = [{ object: current, score: 1 }];
229+
}
194230
if (hits.length === 0) {
195231
return JSON.stringify({
196232
error:

packages/spec/src/contracts/ai-service.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,16 @@ export interface ToolExecutionContext {
402402
messageId?: string;
403403
/** Active environment (multi-tenant project) id, if known. */
404404
environmentId?: string;
405+
/**
406+
* Object the user is currently viewing in the UI (e.g. the list/detail
407+
* page they have open). Built-in data tools use this as a fallback target
408+
* when the user refers to "this object" / "the current object" and the
409+
* free-text request doesn't name one explicitly — so a question phrased in
410+
* any language still resolves to the right object without a keyword match.
411+
*/
412+
currentObjectName?: string;
413+
/** View the user is currently viewing, if known. */
414+
currentViewName?: string;
405415
/** Distributed-trace id for cross-service correlation. */
406416
traceId?: string;
407417
/**

0 commit comments

Comments
 (0)