FN-8070: preserve task title input language
Ensure auto-generated task titles use the task description's language. - Detect description language and add a confident language hint to title prompts - Cover multilingual title generation and stale-model fallback behavior - Document the language-preserving title-summary setting and add a patch changeset Files changed: .changeset/fn-8070-title-summary-input-language.md | 7 ++ docs/settings-reference.md | 2 +- packages/core/src/__tests__/ai-summarize.test.ts | 87 ++++++++++++++++++---- packages/core/src/ai-summarize.ts | 18 +++++ 4 files changed, 98 insertions(+), 16 deletions(-) Fusion-Task-Id: FN-8070 Fusion-Task-Lineage: e003e2e0-eb0c-487e-83de-8a9b37ee1c02 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8070-title-summary-input-language.md
Normal file
7
.changeset/fn-8070-title-summary-input-language.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Auto-summarized task titles now match the language of the task description.
|
||||||
|
category: fix
|
||||||
|
dev: The existing ai-summarize prompt uses a synchronous input-language hint with no extra model calls.
|
||||||
@@ -674,7 +674,7 @@ GitLab configuration examples: leave both URL fields blank for GitLab.com (`http
|
|||||||
| `memoryBackupRetention` | `number` | `14` | Number of memory backups to retain. |
|
| `memoryBackupRetention` | `number` | `14` | Number of memory backups to retain. |
|
||||||
| `memoryBackupDir` | `string` | `".fusion/backups/memory"` | Relative memory backup directory path. |
|
| `memoryBackupDir` | `string` | `".fusion/backups/memory"` | Relative memory backup directory path. |
|
||||||
| `memoryBackupScope` | `"project" \| "agents" \| "all"` | `"all"` | Backup scope: project memory, agent memory, or both. |
|
| `memoryBackupScope` | `"project" \| "agents" \| "all"` | `"all"` | Backup scope: project memory, agent memory, or both. |
|
||||||
| `autoSummarizeTitles` | `boolean` | `false` | Auto-generate titles for long untitled descriptions across dashboard/API task creation. Agent-created tasks from `fn_task_create` and `fn_delegate_task` always request summarization for untitled tasks, regardless of this setting. |
|
| `autoSummarizeTitles` | `boolean` | `false` | Auto-generate titles for long untitled descriptions across dashboard/API task creation. Generated titles match the operator's input language from the task description. Agent-created tasks from `fn_task_create` and `fn_delegate_task` always request summarization for untitled tasks, regardless of this setting. |
|
||||||
| `useAiMergeCommitSummary` | `boolean` | `true` | Use AI-generated merge commit summaries (subject + bullet body + diff-stat) instead of raw step-commit subject lists. |
|
| `useAiMergeCommitSummary` | `boolean` | `true` | Use AI-generated merge commit summaries (subject + bullet body + diff-stat) instead of raw step-commit subject lists. |
|
||||||
| `titleSummarizerProvider` | `string` | `undefined` | Provider for title summarization. |
|
| `titleSummarizerProvider` | `string` | `undefined` | Provider for title summarization. |
|
||||||
| `titleSummarizerModelId` | `string` | `undefined` | Model ID for title summarization. |
|
| `titleSummarizerModelId` | `string` | `undefined` | Model ID for title summarization. |
|
||||||
|
|||||||
@@ -48,7 +48,8 @@ describe("ai-summarize", () => {
|
|||||||
// ── Constants ──────────────────────────────────────────────────────────────
|
// ── Constants ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("constants", () => {
|
describe("constants", () => {
|
||||||
it("should have correct system prompt", () => {
|
it("should require titles in the description language", () => {
|
||||||
|
expect(SUMMARIZE_SYSTEM_PROMPT).toContain("SAME language as the task description");
|
||||||
expect(SUMMARIZE_SYSTEM_PROMPT).toContain("max 60 characters");
|
expect(SUMMARIZE_SYSTEM_PROMPT).toContain("max 60 characters");
|
||||||
expect(SUMMARIZE_SYSTEM_PROMPT).toContain("title summarization");
|
expect(SUMMARIZE_SYSTEM_PROMPT).toContain("title summarization");
|
||||||
});
|
});
|
||||||
@@ -168,6 +169,7 @@ describe("ai-summarize", () => {
|
|||||||
it("should return null for descriptions <= 200 characters", async () => {
|
it("should return null for descriptions <= 200 characters", async () => {
|
||||||
const result = await summarizeTitle("Short description", "/tmp");
|
const result = await summarizeTitle("Short description", "/tmp");
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
|
expect(getFnAgentMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw AiServiceError when AI service cannot process request", async () => {
|
it("should throw AiServiceError when AI service cannot process request", async () => {
|
||||||
@@ -187,6 +189,50 @@ describe("ai-summarize", () => {
|
|||||||
).rejects.toThrow(AiServiceError);
|
).rejects.toThrow(AiServiceError);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["French", "Nous devons améliorer la création des tâches afin que les opérateurs puissent suivre leurs demandes plus facilement. ".repeat(3), "Français"],
|
||||||
|
["Spanish", "Necesitamos mejorar la creación de tareas para que los operadores puedan seguir sus solicitudes con mayor facilidad. ".repeat(3), "Español"],
|
||||||
|
["Chinese", "我们需要改进任务创建流程,让操作员能够更轻松地跟踪他们的请求并完成日常工作。".repeat(8), "简体中文"],
|
||||||
|
["Korean", "운영자가 요청을 더 쉽게 추적하고 일상 업무를 완료할 수 있도록 작업 생성 과정을 개선해야 합니다. ".repeat(4), "한국어"],
|
||||||
|
])("instructs %s descriptions to retain their language", async (_language, description, languageHint) => {
|
||||||
|
const prompt = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const createFnAgent = vi.fn().mockResolvedValue({
|
||||||
|
session: {
|
||||||
|
prompt,
|
||||||
|
dispose: vi.fn(),
|
||||||
|
state: {
|
||||||
|
messages: [{ role: "assistant", content: "Generated task title" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
getFnAgentMock.mockResolvedValue(createFnAgent);
|
||||||
|
|
||||||
|
await expect(summarizeTitle(description, "/tmp")).resolves.toBe("Generated task title");
|
||||||
|
|
||||||
|
const agentOptions = createFnAgent.mock.calls[0][0] as { systemPrompt: string };
|
||||||
|
const wrappedPrompt = prompt.mock.calls[0][0] as string;
|
||||||
|
expect(agentOptions.systemPrompt).toContain("SAME language as the task description");
|
||||||
|
expect(wrappedPrompt).toContain("SAME language as the task description");
|
||||||
|
expect(wrappedPrompt).toContain(`Likely language: ${languageHint}`);
|
||||||
|
expect(wrappedPrompt).toContain("<description>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps English descriptions in English without forcing another language", async () => {
|
||||||
|
const prompt = vi.fn().mockResolvedValue(undefined);
|
||||||
|
getFnAgentMock.mockResolvedValue(() => Promise.resolve({
|
||||||
|
session: {
|
||||||
|
prompt,
|
||||||
|
dispose: vi.fn(),
|
||||||
|
state: { messages: [{ role: "assistant", content: "Improve task creation" }] },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
const description = "We need to improve task creation so operators can follow requests more easily during their daily work. ".repeat(3);
|
||||||
|
|
||||||
|
await expect(summarizeTitle(description, "/tmp")).resolves.toBe("Improve task creation");
|
||||||
|
expect(SUMMARIZE_SYSTEM_PROMPT).toContain("SAME language as the task description");
|
||||||
|
expect(prompt.mock.calls[0][0]).toContain("Likely language: English");
|
||||||
|
});
|
||||||
|
|
||||||
it("returns sanitized title when AI responds cleanly", async () => {
|
it("returns sanitized title when AI responds cleanly", async () => {
|
||||||
const prompt = vi.fn().mockResolvedValue(undefined);
|
const prompt = vi.fn().mockResolvedValue(undefined);
|
||||||
getFnAgentMock.mockResolvedValue(() =>
|
getFnAgentMock.mockResolvedValue(() =>
|
||||||
@@ -207,24 +253,24 @@ describe("ai-summarize", () => {
|
|||||||
// Verify wrapped prompt was sent (prompt-injection mitigation)
|
// Verify wrapped prompt was sent (prompt-injection mitigation)
|
||||||
expect(prompt).toHaveBeenCalledTimes(1);
|
expect(prompt).toHaveBeenCalledTimes(1);
|
||||||
expect(prompt.mock.calls[0][0]).toContain("<description>");
|
expect(prompt.mock.calls[0][0]).toContain("<description>");
|
||||||
|
expect(prompt.mock.calls[0][0]).toContain("SAME language as the task description");
|
||||||
expect(prompt.mock.calls[0][0]).toContain("Do not call any tools");
|
expect(prompt.mock.calls[0][0]).toContain("Do not call any tools");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("summarizes long descriptions with bounded prompt input", async () => {
|
it("summarizes long descriptions with bounded prompt input", async () => {
|
||||||
const prompt = vi.fn().mockResolvedValue(undefined);
|
const prompt = vi.fn().mockResolvedValue(undefined);
|
||||||
getFnAgentMock.mockResolvedValue(() =>
|
const createFnAgent = vi.fn().mockResolvedValue({
|
||||||
Promise.resolve({
|
session: {
|
||||||
session: {
|
prompt,
|
||||||
prompt,
|
dispose: vi.fn(),
|
||||||
dispose: vi.fn(),
|
state: {
|
||||||
state: {
|
messages: [
|
||||||
messages: [
|
{ role: "assistant", content: "Summarize long description" },
|
||||||
{ role: "assistant", content: "Summarize long description" },
|
],
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
})
|
},
|
||||||
);
|
});
|
||||||
|
getFnAgentMock.mockResolvedValue(createFnAgent);
|
||||||
const description = "a".repeat(MAX_TITLE_SUMMARIZE_INPUT_LENGTH) + "tail".repeat(250);
|
const description = "a".repeat(MAX_TITLE_SUMMARIZE_INPUT_LENGTH) + "tail".repeat(250);
|
||||||
|
|
||||||
const title = await summarizeTitle(description, "/tmp");
|
const title = await summarizeTitle(description, "/tmp");
|
||||||
@@ -234,6 +280,9 @@ describe("ai-summarize", () => {
|
|||||||
const promptText = prompt.mock.calls[0][0] as string;
|
const promptText = prompt.mock.calls[0][0] as string;
|
||||||
expect(promptText).toContain("…(truncated)");
|
expect(promptText).toContain("…(truncated)");
|
||||||
expect(promptText).not.toContain("tail");
|
expect(promptText).not.toContain("tail");
|
||||||
|
expect(promptText).toContain("SAME language as the task description");
|
||||||
|
expect((createFnAgent.mock.calls[0][0] as { systemPrompt: string }).systemPrompt)
|
||||||
|
.toContain("SAME language as the task description");
|
||||||
expect(promptText.length).toBeLessThanOrEqual(MAX_TITLE_SUMMARIZE_INPUT_LENGTH + 250);
|
expect(promptText.length).toBeLessThanOrEqual(MAX_TITLE_SUMMARIZE_INPUT_LENGTH + 250);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -289,6 +338,7 @@ describe("ai-summarize", () => {
|
|||||||
|
|
||||||
it("retries stale configured model ids with automatic resolution and logs the stale id", async () => {
|
it("retries stale configured model ids with automatic resolution and logs the stale id", async () => {
|
||||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
const fallbackPrompt = vi.fn().mockResolvedValue(undefined);
|
||||||
const createFnAgent = vi
|
const createFnAgent = vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockRejectedValueOnce(new Error(
|
.mockRejectedValueOnce(new Error(
|
||||||
@@ -297,7 +347,7 @@ describe("ai-summarize", () => {
|
|||||||
))
|
))
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
session: {
|
session: {
|
||||||
prompt: vi.fn().mockResolvedValue(undefined),
|
prompt: fallbackPrompt,
|
||||||
dispose: vi.fn(),
|
dispose: vi.fn(),
|
||||||
state: {
|
state: {
|
||||||
messages: [{ role: "assistant", content: "Fix pi upgrade regressions" }],
|
messages: [{ role: "assistant", content: "Fix pi upgrade regressions" }],
|
||||||
@@ -305,9 +355,10 @@ describe("ai-summarize", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
getFnAgentMock.mockResolvedValue(createFnAgent);
|
getFnAgentMock.mockResolvedValue(createFnAgent);
|
||||||
|
const description = "Nous devons corriger les régressions de mise à niveau afin que les opérateurs puissent poursuivre leur travail sans interruption. ".repeat(3);
|
||||||
|
|
||||||
const title = await summarizeTitle(
|
const title = await summarizeTitle(
|
||||||
"a".repeat(201),
|
description,
|
||||||
"/tmp",
|
"/tmp",
|
||||||
"fireworksai",
|
"fireworksai",
|
||||||
"accounts/fireworks/routers/kimi-k2p5-turbo",
|
"accounts/fireworks/routers/kimi-k2p5-turbo",
|
||||||
@@ -322,6 +373,12 @@ describe("ai-summarize", () => {
|
|||||||
defaultProvider: expect.any(String),
|
defaultProvider: expect.any(String),
|
||||||
defaultModelId: expect.any(String),
|
defaultModelId: expect.any(String),
|
||||||
}));
|
}));
|
||||||
|
const primaryOptions = createFnAgent.mock.calls[0][0] as { systemPrompt: string };
|
||||||
|
const fallbackOptions = createFnAgent.mock.calls[1][0] as { systemPrompt: string };
|
||||||
|
expect(primaryOptions.systemPrompt).toContain("SAME language as the task description");
|
||||||
|
expect(fallbackOptions.systemPrompt).toContain("SAME language as the task description");
|
||||||
|
expect(fallbackPrompt.mock.calls[0][0]).toContain("SAME language as the task description");
|
||||||
|
expect(fallbackPrompt.mock.calls[0][0]).toContain("Likely language: Français");
|
||||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("fireworksai/accounts/fireworks/routers/kimi-k2p5-turbo"));
|
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("fireworksai/accounts/fireworks/routers/kimi-k2p5-turbo"));
|
||||||
warnSpy.mockRestore();
|
warnSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { getFnAgent, type AgentMessage } from "./ai-engine-loader.js";
|
import { getFnAgent, type AgentMessage } from "./ai-engine-loader.js";
|
||||||
|
import { detectContentLanguage, localeDisplayName } from "./detect-content-language.js";
|
||||||
import { DANGLING_TAIL_STOPWORDS, stripDanglingTail, stripEmptyPlaceholders } from "./task-title-id-drift.js";
|
import { DANGLING_TAIL_STOPWORDS, stripDanglingTail, stripEmptyPlaceholders } from "./task-title-id-drift.js";
|
||||||
|
|
||||||
// ── Constants ───────────────────────────────────────────────────────────────
|
// ── Constants ───────────────────────────────────────────────────────────────
|
||||||
@@ -29,6 +30,7 @@ Your ONLY job is to create a concise title (max 60 characters) that summarizes t
|
|||||||
|
|
||||||
## Style
|
## Style
|
||||||
- Clear, descriptive, actionable, professional
|
- Clear, descriptive, actionable, professional
|
||||||
|
- Write the title in the SAME language as the task description content. For example, a French description requires a French title.
|
||||||
- Maximum 60 characters
|
- Maximum 60 characters
|
||||||
- Focus on the main goal or deliverable of the task`;
|
- Focus on the main goal or deliverable of the task`;
|
||||||
|
|
||||||
@@ -218,6 +220,21 @@ function formatConfiguredModel(provider?: string, modelId?: string): string {
|
|||||||
return provider && modelId ? `${provider}/${modelId}` : "unknown configured model";
|
return provider && modelId ? `${provider}/${modelId}` : "unknown configured model";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* FNXC:TitleSummaryInputLanguage 2026-07-16-00:00:
|
||||||
|
* When existing autoSummarizeTitles or per-request summarize behavior invokes this shared summarizer,
|
||||||
|
* the generated title must match the operator's description language without another model call.
|
||||||
|
* Detection only adds a medium-or-higher confidence hint; matching the description remains the rule.
|
||||||
|
*/
|
||||||
|
function buildTitleLanguageInstruction(description: string): string {
|
||||||
|
const detected = detectContentLanguage(description);
|
||||||
|
if (detected.locale !== "unknown" && detected.confidence !== "low") {
|
||||||
|
return `Write the title in the SAME language as the task description. Likely language: ${localeDisplayName(detected.locale)}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Write the title in the SAME language as the task description.";
|
||||||
|
}
|
||||||
|
|
||||||
async function runTitleSummarizer(
|
async function runTitleSummarizer(
|
||||||
createFnAgent: NonNullable<Awaited<ReturnType<typeof getFnAgent>>>,
|
createFnAgent: NonNullable<Awaited<ReturnType<typeof getFnAgent>>>,
|
||||||
description: string,
|
description: string,
|
||||||
@@ -264,6 +281,7 @@ async function runTitleSummarizer(
|
|||||||
: description;
|
: description;
|
||||||
const wrappedPrompt =
|
const wrappedPrompt =
|
||||||
"Summarize the following task description into a title (≤60 chars). " +
|
"Summarize the following task description into a title (≤60 chars). " +
|
||||||
|
buildTitleLanguageInstruction(description) + " " +
|
||||||
"Output ONLY the title text on a single line. Do not call any tools.\n\n" +
|
"Output ONLY the title text on a single line. Do not call any tools.\n\n" +
|
||||||
"<description>\n" +
|
"<description>\n" +
|
||||||
truncatedDescription +
|
truncatedDescription +
|
||||||
|
|||||||
Reference in New Issue
Block a user