Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
41 changes: 41 additions & 0 deletions server/api/src/__tests__/agents/core/composeLocale.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import {
composeConflictRationale,
composeContentLocaleInstruction,
normalizeComposeContentLocale,
resolveComposeContentLocale,
stripContentLocaleFromGraphInput,
structureDialogueFallbackOutline,
} from "../../../agents/core/composeLocale.js";

describe("composeLocale", () => {
it("normalizes supported locales only", () => {
expect(normalizeComposeContentLocale("ja")).toBe("ja");
expect(normalizeComposeContentLocale("en")).toBe("en");
expect(normalizeComposeContentLocale("fr")).toBeNull();
});

it("resolves from input then Accept-Language", () => {
expect(resolveComposeContentLocale({ contentLocale: "en" }, "ja-JP")).toBe("en");
expect(resolveComposeContentLocale({}, "ja-JP,en;q=0.8")).toBe("ja");
expect(resolveComposeContentLocale({}, "en-US,en;q=0.9")).toBe("en");
expect(resolveComposeContentLocale({}, null, "ja")).toBe("ja");
});

it("strips contentLocale from graph input", () => {
expect(stripContentLocaleFromGraphInput({ contentLocale: "ja", chatSeed: { x: 1 } })).toEqual({
chatSeed: { x: 1 },
});
});

it("includes Japanese in locale instruction when ja", () => {
expect(composeContentLocaleInstruction("ja")).toMatch(/Japanese/);
expect(composeContentLocaleInstruction("en")).toMatch(/English/);
});

it("provides localized conflict rationale and fallback outline", () => {
expect(composeConflictRationale("ja")).toMatch(/却下/);
expect(structureDialogueFallbackOutline("ja")[0]?.heading).toBe("概要");
expect(structureDialogueFallbackOutline("en")[0]?.heading).toBe("Overview");
});
});
102 changes: 102 additions & 0 deletions server/api/src/agents/core/composeLocale.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/**
* Content locale for Wiki Compose LLM outputs (#950).
* Wiki Compose の生成言語(ユーザー向けテキスト)を表す。
*/
export type ComposeContentLocale = "ja" | "en";

/**
* Normalize an arbitrary value to a supported compose content locale.
* 任意の入力をサポートされる compose 用ロケールに正規化する。
*/
export function normalizeComposeContentLocale(raw: unknown): ComposeContentLocale | null {
if (raw === "ja" || raw === "en") return raw;
return null;
}

/**
* Resolve locale from graph run input (`contentLocale`) with a server default.
* graph の run input(`contentLocale`)からロケールを解決する。
*/
export function resolveComposeContentLocale(
input: unknown,
acceptLanguage: string | undefined | null,
fallback: ComposeContentLocale = "ja",
): ComposeContentLocale {
if (input && typeof input === "object" && "contentLocale" in input) {
const fromInput = normalizeComposeContentLocale(
(input as { contentLocale?: unknown }).contentLocale,
);
if (fromInput) return fromInput;
}
if (acceptLanguage) {
const primary = acceptLanguage.split(",")[0]?.trim().toLowerCase() ?? "";
if (primary.startsWith("ja")) return "ja";
if (primary.startsWith("en")) return "en";
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current Accept-Language resolution only checks the very first language tag in the header. If a user has an unsupported primary language but a supported secondary language (e.g., Accept-Language: zh-CN,en-US;q=0.9), the parser will fail to match zh-CN and fall back to the default (ja), ignoring their preference for en.\n\nWe can improve this by splitting the header and checking each language tag in order of preference until a supported language is found.

  if (acceptLanguage) {
    const locales = acceptLanguage
      .split(",")
      .map((part) => part.split(";")[0]?.trim().toLowerCase() ?? "");
    for (const locale of locales) {
      if (locale.startsWith("ja")) return "ja";
      if (locale.startsWith("en")) return "en";
    }
  }

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
return fallback;
}

/**
* Strip `contentLocale` before passing input to LangGraph (not a state channel).
* LangGraph に渡す前に `contentLocale` を除去する(state チャネルではない)。
*/
export function stripContentLocaleFromGraphInput(raw: unknown): unknown {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return raw ?? {};
const { contentLocale: _removed, ...rest } = raw as Record<string, unknown>;
return rest;
}
Comment on lines +68 to +73

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current implementation of stripContentLocaleFromGraphInput uses !raw and raw ?? {} which silently coerces falsy values like null or undefined into empty objects {}.\n\nTo make this utility function more robust and prevent unexpected type coercions, we should return the input as-is if it is not a non-null object or array.

Suggested change
export function stripContentLocaleFromGraphInput(raw: unknown): unknown {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return raw ?? {};
const { contentLocale: _removed, ...rest } = raw as Record<string, unknown>;
return rest;
}
export function stripContentLocaleFromGraphInput(raw: unknown): unknown {
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return raw;
const { contentLocale: _removed, ...rest } = raw as Record<string, unknown>;
return rest;
}


/**
* Instruction appended to system prompts so questions, outlines, and drafts
* match the user's UI language.
* 質問・アウトライン・本文が UI 言語と一致するよう system prompt に付与する。
*/
export function composeContentLocaleInstruction(locale: ComposeContentLocale): string {
if (locale === "ja") {
return (
"\n\nLanguage: Write all user-facing text (questions, option labels, rationales, " +
"outline headings and intents, evaluation rationales, missing aspects, and article " +
"body) in Japanese. Use clear, natural Japanese suitable for a wiki article."
);
}
return (
"\n\nLanguage: Write all user-facing text (questions, option labels, rationales, " +
"outline headings and intents, evaluation rationales, missing aspects, and article " +
"body) in English."
);
}

/**
* Localized conflict-resolution rationale shown in the interrupt UI.
* interrupt UI に表示する矛盾解消用の説明文(ロケール別)。
*/
export function composeConflictRationale(locale: ComposeContentLocale): string {
if (locale === "ja") {
return (
"却下したソースと採用したソースが混在しています。採用したソースのセットで" +
"アウトライン生成に進んでよいか確認してください。"
);
}
return (
"Multiple sources were rejected while others were kept. Confirm you want to proceed " +
"with the approved set before generating the outline."
);
}

/** Default outline rows when structure LLM fails (locale-specific headings). */
Comment thread
coderabbitai[bot] marked this conversation as resolved.
export function structureDialogueFallbackOutline(
locale: ComposeContentLocale,
): Array<{ heading: string; depth: 1; intent: string }> {
if (locale === "ja") {
return [
{ heading: "概要", depth: 1, intent: "トピックの簡潔な導入。" },
{ heading: "要点", depth: 1, intent: "主要な事実と背景。" },
{ heading: "参考", depth: 1, intent: "出典と関連情報。" },
];
}
return [
{ heading: "Overview", depth: 1, intent: "Brief introduction to the topic." },
{ heading: "Key points", depth: 1, intent: "Main facts and context." },
{ heading: "References", depth: 1, intent: "Sources and further reading." },
];
}
4 changes: 4 additions & 0 deletions server/api/src/agents/core/types/graphContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
* identifiers required by `ZediChatModel` for usage attribution.
*/
import type { Database, UserTier } from "../../../types/index.js";
import type { ComposeContentLocale } from "../composeLocale.js";
import type { ExecutionBackend } from "./executionBackend.js";

/**
Expand All @@ -31,6 +32,8 @@ import type { ExecutionBackend } from "./executionBackend.js";
* Executing user's email; used by `wikiSearchService` to
* apply the `note_domain_access` predicate without an
* extra DB roundtrip per tool call.
* @property contentLocale 生成テキストの言語(`ja` | `en`)。UI / Accept-Language から解決。
* Language for LLM-generated user-facing text (`ja` | `en`).
*/
export interface GraphContext {
threadId: string;
Expand All @@ -43,6 +46,7 @@ export interface GraphContext {
db: Database;
feature: string;
userEmail: string | null;
contentLocale: ComposeContentLocale;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import type { LangGraphRunnableConfig } from "@langchain/langgraph";
import { randomUUID } from "node:crypto";
import { z } from "zod";
import { composeContentLocaleInstruction } from "../../../core/composeLocale.js";
import { createZediChatModel } from "../../../core/llm/modelFactory.js";
import { resolveComposeModelId } from "../../../core/llm/resolveComposeModelId.js";
import { getGraphContext } from "../../../subgraphs/research/nodes/shared/getGraphContext.js";
Expand Down Expand Up @@ -135,7 +136,10 @@ export async function briefDialogue(
let briefDegraded = false;
try {
raw = await structured.invoke([
{ role: "system", content: SYSTEM_PROMPT },
{
role: "system",
content: SYSTEM_PROMPT + composeContentLocaleInstruction(ctx.contentLocale),
},
{
role: "user",
content: buildUserPrompt(snapshot.title, snapshot.body, state.chatSeed),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,24 @@
*/
import type { LangGraphRunnableConfig } from "@langchain/langgraph";
import { interrupt } from "@langchain/langgraph";
import {
composeConflictRationale,
type ComposeContentLocale,
} from "../../../core/composeLocale.js";
import { getGraphContext } from "../../../subgraphs/research/nodes/shared/getGraphContext.js";
import { conflictResumeSchema } from "../resumeSchemas.js";
import type { WikiComposeStateType, WikiComposeStateUpdate } from "../state.js";
import type { ResearchConflictSummary, WikiComposeInterruptPayload } from "../types.js";
import { shouldResolveResearchConflicts } from "../routing.js";

function buildConflictSummary(state: WikiComposeStateType): ResearchConflictSummary {
function buildConflictSummary(
state: WikiComposeStateType,
locale: ComposeContentLocale,
): ResearchConflictSummary {
return {
approved: state.approvedResearch.map((s) => ({ id: s.id, title: s.title })),
rejected: state.rejectedResearch.map((s) => ({ id: s.id, title: s.title })),
rationale:
"Multiple sources were rejected while others were kept. Confirm you want to proceed " +
"with the approved set before generating the outline.",
rationale: composeConflictRationale(locale),
};
}

Expand All @@ -29,15 +35,16 @@ function buildConflictSummary(state: WikiComposeStateType): ResearchConflictSumm
*/
export async function conflictResolution(
state: WikiComposeStateType,
_config: LangGraphRunnableConfig,
config: LangGraphRunnableConfig,
): Promise<WikiComposeStateUpdate> {
if (!shouldResolveResearchConflicts(state)) {
return { phase: "conflict:skipped" };
}

const ctx = getGraphContext(config);
const payload: WikiComposeInterruptPayload = {
kind: "conflict_resolution",
conflicts: buildConflictSummary(state),
conflicts: buildConflictSummary(state, ctx.contentLocale),
};
const resumeValue: unknown = interrupt(payload);
conflictResumeSchema.parse(resumeValue);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
* paint into the EditorPane.
*/
import type { LangGraphRunnableConfig } from "@langchain/langgraph";
import { composeContentLocaleInstruction } from "../../../core/composeLocale.js";
import { createZediChatModel } from "../../../core/llm/modelFactory.js";
import { resolveComposeModelId } from "../../../core/llm/resolveComposeModelId.js";
import { getGraphContext } from "../../../subgraphs/research/nodes/shared/getGraphContext.js";
Expand Down Expand Up @@ -158,7 +159,10 @@ export async function draftSections(
let body = "";
try {
const stream = await model.stream([
{ role: "system", content: SECTION_SYSTEM_PROMPT },
{
role: "system",
content: SECTION_SYSTEM_PROMPT + composeContentLocaleInstruction(ctx.contentLocale),
},
{
role: "user",
content: buildSectionPrompt({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
import type { LangGraphRunnableConfig } from "@langchain/langgraph";
import { randomUUID } from "node:crypto";
import { z } from "zod";
import {
composeContentLocaleInstruction,
structureDialogueFallbackOutline,
} from "../../../core/composeLocale.js";
import { createZediChatModel } from "../../../core/llm/modelFactory.js";
import { resolveComposeModelId } from "../../../core/llm/resolveComposeModelId.js";
import { getGraphContext } from "../../../subgraphs/research/nodes/shared/getGraphContext.js";
Expand Down Expand Up @@ -99,21 +103,18 @@ export async function structureDialogue(
let raw: z.input<typeof outlineProposalSchema>;
try {
raw = await structured.invoke([
{ role: "system", content: SYSTEM_PROMPT },
{
role: "system",
content: SYSTEM_PROMPT + composeContentLocaleInstruction(ctx.contentLocale),
},
{ role: "user", content: buildUserPrompt(state) },
]);
} catch {
// Defensive fallback: emit a minimal 3-section outline so the user can
// edit-rather-than-blank-out when the LLM fails (rare). Heading text
// intentionally generic so the user is prompted to rename.
// LLM 失敗時は 3 セクションの仮アウトラインを返してフローを止めない。
raw = {
sections: [
{ heading: "Overview", depth: 1, intent: "Brief introduction to the topic." },
{ heading: "Key points", depth: 1, intent: "Main facts and context." },
{ heading: "References", depth: 1, intent: "Sources and further reading." },
],
};
raw = { sections: structureDialogueFallbackOutline(ctx.contentLocale) };
}

const outline: OutlineSection[] = raw.sections.map((s) => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
*/
import type { LangGraphRunnableConfig } from "@langchain/langgraph";
import { z } from "zod";
import { composeContentLocaleInstruction } from "../../../core/composeLocale.js";
import { createZediChatModel } from "../../../core/llm/modelFactory.js";
import { resolveComposeModelId } from "../../../core/llm/resolveComposeModelId.js";
import { getGraphContext } from "./shared/getGraphContext.js";
Expand Down Expand Up @@ -88,7 +89,10 @@ export async function evaluateSufficiency(
name: "research_evaluation",
});
const parsed = await structured.invoke([
{ role: "system", content: SYSTEM_PROMPT },
{
role: "system",
content: SYSTEM_PROMPT + composeContentLocaleInstruction(ctx.contentLocale),
},
{ role: "user", content: buildUserPrompt(state) },
]);
const evaluation: Evaluation = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import type { LangGraphRunnableConfig } from "@langchain/langgraph";
import { randomUUID } from "node:crypto";
import { z } from "zod";
import { composeContentLocaleInstruction } from "../../../core/composeLocale.js";
import { createZediChatModel } from "../../../core/llm/modelFactory.js";
import { resolveComposeModelId } from "../../../core/llm/resolveComposeModelId.js";
import { getGraphContext } from "./shared/getGraphContext.js";
Expand Down Expand Up @@ -112,7 +113,10 @@ export async function planQueries(
});
const structured = model.withStructuredOutput(planQueriesSchema, { name: "plan_queries" });
const planned = await structured.invoke([
{ role: "system", content: SYSTEM_PROMPT },
{
role: "system",
content: SYSTEM_PROMPT + composeContentLocaleInstruction(ctx.contentLocale),
},
{ role: "user", content: brief || "(no brief provided; produce 2 broad coverage queries)" },
]);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
*/
import type { LangGraphRunnableConfig } from "@langchain/langgraph";
import { randomUUID } from "node:crypto";
import { composeContentLocaleInstruction } from "../../../core/composeLocale.js";
import { createZediChatModel } from "../../../core/llm/modelFactory.js";
import { resolveComposeModelId } from "../../../core/llm/resolveComposeModelId.js";
import { getGraphContext } from "./shared/getGraphContext.js";
Expand Down Expand Up @@ -74,7 +75,10 @@ export async function refineQueries(
});
const structured = model.withStructuredOutput(planQueriesSchema, { name: "refine_queries" });
const planned = await structured.invoke([
{ role: "system", content: SYSTEM_PROMPT },
{
role: "system",
content: SYSTEM_PROMPT + composeContentLocaleInstruction(ctx.contentLocale),
},
{ role: "user", content: buildUserPrompt(state) },
]);
const queries: PlannedQuery[] = planned.queries.map((q) => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
* で誤って忘れたケースを早期に検出するため throw する。
*/
import type { LangGraphRunnableConfig } from "@langchain/langgraph";
import { normalizeComposeContentLocale } from "../../../../core/composeLocale.js";
import {
GRAPH_CONTEXT_CONFIG_KEY,
type GraphContext,
Expand Down Expand Up @@ -44,5 +45,9 @@ export function getGraphContext(config: LangGraphRunnableConfig | undefined): Gr
"Check GraphRunner.buildConfig.",
);
}
return ctx as GraphContext;
const contentLocale =
normalizeComposeContentLocale(ctx.contentLocale) ??
normalizeComposeContentLocale((ctx as { locale?: unknown }).locale) ??
"en";
return { ...(ctx as GraphContext), contentLocale };
Comment on lines +48 to +52

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

There is an inconsistency in the default fallback locale. In resolveComposeContentLocale and the route handlers, the fallback locale defaults to "ja". However, here in getGraphContext, it defaults to "en".\n\nTo ensure consistent behavior across the application when the locale cannot be resolved, we should use "ja" as the fallback here as well.

Suggested change
const contentLocale =
normalizeComposeContentLocale(ctx.contentLocale) ??
normalizeComposeContentLocale((ctx as { locale?: unknown }).locale) ??
"en";
return { ...(ctx as GraphContext), contentLocale };
const contentLocale =
normalizeComposeContentLocale(ctx.contentLocale) ??
normalizeComposeContentLocale((ctx as { locale?: unknown }).locale) ??
"ja";
return { ...(ctx as GraphContext), contentLocale };

}
Loading
Loading