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
54 changes: 54 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,54 @@
import { describe, expect, it } from "vitest";
import {
composeConflictRationale,
composeContentLocaleInstruction,
normalizeComposeContentLocale,
readContentLocaleFromSessionMetadata,
resolveComposeContentLocale,
resolveSessionContentLocale,
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({}, "zh-CN,en-US;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 },
});
expect(stripContentLocaleFromGraphInput(null)).toBeNull();
expect(stripContentLocaleFromGraphInput(undefined)).toBeUndefined();
});

it("persists locale via session metadata resolution", () => {
expect(readContentLocaleFromSessionMetadata({ contentLocale: "en" })).toBe("en");
expect(
resolveSessionContentLocale({ contentLocale: "en" }, { contentLocale: "ja" }, "ja-JP"),
).toBe("en");
expect(resolveSessionContentLocale(null, { contentLocale: "ja" }, "en-US")).toBe("ja");
});

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");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ function fakeContext(threadId: string): GraphContext {
db: {} as Database,
feature: "ingest_graph:test",
userEmail: null,
contentLocale: "ja",
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ describe("planIngest model resolution", () => {
sessionId: "t1",
userId: "user-1",
userEmail: null,
contentLocale: "ja",
pageId: "",
graphId: "ingest-planner",
backend: "user_openai",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ function fakeContext(threadId: string): GraphContext {
db: {} as Database,
feature: "wiki_compose:test",
userEmail: null,
contentLocale: "ja",
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ function fakeContext(threadId: string): GraphContext {
db: {} as Database,
feature: "wiki_maintenance:test",
userEmail: null,
contentLocale: "ja",
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ function fakeContext(): GraphContext {
db: {} as Database,
feature: "test",
userEmail: null,
contentLocale: "ja",
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ function fakeContext(): GraphContext {
db: {} as Database,
feature: "wiki_compose:research",
userEmail: null,
contentLocale: "ja",
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ function fakeContext(): GraphContext {
db: {} as Database,
feature: "wiki_compose:research",
userEmail: null,
contentLocale: "ja",
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ function fakeContext(graphId: string): GraphContext {
db: {} as Database,
feature: "wiki_compose:research",
userEmail: null,
contentLocale: "ja",
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ function fakeContext(): GraphContext {
db: {} as Database,
feature: "wiki_compose:research",
userEmail: null,
contentLocale: "ja",
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ function fakeContext(): GraphContext {
db: {} as Database,
feature: "wiki_compose:research",
userEmail: null,
contentLocale: "ja",
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ function ctxConfig(): { configurable: Record<string, unknown> } {
db: {} as Database,
feature: "wiki_compose:research",
userEmail: null,
contentLocale: "ja",
} satisfies GraphContext,
},
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ function ctxConfig(overrides: Partial<GraphContext> = {}): {
db: {} as Database,
feature: "wiki_compose:research",
userEmail: "alice@example.com",
contentLocale: "ja",
...overrides,
} satisfies GraphContext,
},
Expand Down
128 changes: 128 additions & 0 deletions server/api/src/agents/core/composeLocale.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/**
* Content locale for Wiki Compose LLM outputs (#950).
* Wiki Compose の生成言語(ユーザー向けテキスト)を表す。
*/
import { resolveLocaleFromAcceptLanguage } from "../../services/invitationService.js";

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;
}
const fromHeader = resolveLocaleFromAcceptLanguage(acceptLanguage);
if (fromHeader) return fromHeader;
return fallback;
}

/**
* Read persisted `contentLocale` from a compose session metadata blob.
* compose セッション metadata に保存された `contentLocale` を読む。
*/
export function readContentLocaleFromSessionMetadata(
metadata: unknown,
): ComposeContentLocale | null {
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return null;
return normalizeComposeContentLocale((metadata as Record<string, unknown>).contentLocale);
}

/**
* Resolve locale for a session: metadata (first run) → input → Accept-Language → fallback.
* セッション用ロケール解決: metadata(初回 run で固定)→ input → Accept-Language。
*/
export function resolveSessionContentLocale(
metadata: unknown,
input: unknown,
acceptLanguage: string | undefined | null,
fallback: ComposeContentLocale = "ja",
): ComposeContentLocale {
const persisted = readContentLocaleFromSessionMetadata(metadata);
if (persisted) return persisted;
return resolveComposeContentLocale(input, acceptLanguage, fallback);
}

/**
* Strip `contentLocale` before passing input to LangGraph (not a state channel).
* LangGraph に渡す前に `contentLocale` を除去する(state チャネルではない)。
*/
export function stripContentLocaleFromGraphInput(raw: unknown): unknown {
if (raw === null || raw === undefined) return raw;
if (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
Loading
Loading