diff --git a/backend/internal/application/billing/service.go b/backend/internal/application/billing/service.go
index 2371daa2..0f2e2b19 100644
--- a/backend/internal/application/billing/service.go
+++ b/backend/internal/application/billing/service.go
@@ -151,6 +151,8 @@ type UsagePricingInput struct {
CallCount int64
DurationBillable bool
DurationSeconds int64
+ MediaType string
+ InputImageCount int64
LatencyMS int64
ServerSideToolUsage map[string]int64
ServiceItems []ServiceUsageInput
@@ -1768,6 +1770,14 @@ func (s *Service) BuildUsageLedger(ctx context.Context, input UsagePricingInput)
"base_service_billed_nanousd": serviceBilledNanousd,
"service_items": usageServiceItemSnapshots(serviceItems),
}
+ if strings.EqualFold(strings.TrimSpace(input.MediaType), "video") {
+ inputImageCount := input.InputImageCount
+ if inputImageCount < 0 {
+ inputImageCount = 0
+ }
+ snapshot["media_type"] = "video"
+ snapshot["input_image_count"] = inputImageCount
+ }
if usageSource := strings.TrimSpace(input.UsageSource); usageSource != "" {
snapshot["usage_source"] = usageSource
}
diff --git a/backend/internal/application/billing/service_model_identity_test.go b/backend/internal/application/billing/service_model_identity_test.go
index 3d221627..ac23f70c 100644
--- a/backend/internal/application/billing/service_model_identity_test.go
+++ b/backend/internal/application/billing/service_model_identity_test.go
@@ -101,6 +101,8 @@ func TestBuildUsageLedgerBillsDurationOnlyWhenExplicitlyBillable(t *testing.T) {
PlatformModelName: "video-model",
DurationBillable: true,
DurationSeconds: 6,
+ MediaType: "video",
+ InputImageCount: 1,
})
if err != nil {
t.Fatalf("build video duration ledger: %v", err)
@@ -108,6 +110,13 @@ func TestBuildUsageLedgerBillsDurationOnlyWhenExplicitlyBillable(t *testing.T) {
if video.DurationSeconds != 6 || video.BilledNanousd != 18 {
t.Fatalf("unexpected video duration billing: %#v", video)
}
+ var snapshot map[string]interface{}
+ if err := json.Unmarshal([]byte(video.PricingSnapshotJSON), &snapshot); err != nil {
+ t.Fatalf("unmarshal video pricing snapshot: %v", err)
+ }
+ if snapshot["media_type"] != "video" || snapshot["input_image_count"] != float64(1) {
+ t.Fatalf("unexpected video media snapshot: %#v", snapshot)
+ }
}
func TestAuthorizeUsageRejectsLegacyDurationPricingForNonVideoModel(t *testing.T) {
diff --git a/backend/internal/application/conversation/service_billing.go b/backend/internal/application/conversation/service_billing.go
index 88cfd6cb..dfa90e4c 100644
--- a/backend/internal/application/conversation/service_billing.go
+++ b/backend/internal/application/conversation/service_billing.go
@@ -278,6 +278,13 @@ func (s *Service) buildSendMessageUsageLedger(ctx context.Context, input SendMes
if result == nil {
return nil, nil
}
+ isVideoGeneration := sendMessageResultIsVideoGeneration(result)
+ mediaType := ""
+ inputImageCount := int64(0)
+ if isVideoGeneration {
+ mediaType = "video"
+ inputImageCount, _ = countAttachmentKinds(result.UserMessage.Attachments)
+ }
latencyMS := result.LatencyMS
if latencyMS <= 0 {
latencyMS = result.AssistantMessage.LatencyMS
@@ -305,8 +312,10 @@ func (s *Service) buildSendMessageUsageLedger(ctx context.Context, input SendMes
OutputTokens: result.AssistantMessage.OutputTokens,
ReasoningTokens: result.AssistantMessage.ReasoningTokens,
CallCount: 1,
- DurationBillable: sendMessageResultIsVideoGeneration(result),
+ DurationBillable: isVideoGeneration,
DurationSeconds: sendMessageBillingDurationSeconds(result),
+ MediaType: mediaType,
+ InputImageCount: inputImageCount,
LatencyMS: latencyMS,
ServerSideToolUsage: result.ServerSideToolUsage,
RawUsageJSON: result.RawUsageJSON,
diff --git a/frontend/features/admin/components/sections/logs/admin-logs.tsx b/frontend/features/admin/components/sections/logs/admin-logs.tsx
index 9039f630..ccf39e7f 100644
--- a/frontend/features/admin/components/sections/logs/admin-logs.tsx
+++ b/frontend/features/admin/components/sections/logs/admin-logs.tsx
@@ -167,13 +167,20 @@ function formatUsageBalance(value: number | null | undefined, billingDisplay: Bi
return value === null || value === undefined ? "-" : formatBillingBalance(value, billingDisplay);
}
+function usageBillableOutputTokens(item: AdminUsageLogDTO): number {
+ return item.outputTokens + item.reasoningTokens;
+}
+
function usageTotalTokens(item: AdminUsageLogDTO): number {
- return item.inputTokens + item.cacheReadTokens + item.cacheWriteTokens + item.outputTokens + item.reasoningTokens;
+ return item.inputTokens + item.cacheReadTokens + item.cacheWriteTokens + usageBillableOutputTokens(item);
}
type UsagePricingSnapshot = {
pricing_mode?: "token" | "call" | "duration" | "tiered" | string;
provider_protocol?: string;
+ duration_billable?: boolean;
+ media_type?: string;
+ input_image_count?: number;
cache_timeout?: string;
fast_mode?: boolean;
billing_speed?: string;
@@ -494,7 +501,7 @@ function buildUsageBillingTooltipLines(
const outputRate = readUsageSnapshotNumber(snapshot, "output_nanousd_per_m_tokens");
const cacheReadRate = readUsageSnapshotNumber(snapshot, "cache_read_nanousd_per_m_tokens");
const cacheWriteRate = readUsageSnapshotNumber(snapshot, "cache_write_nanousd_per_m_tokens");
- const billedOutputTokens = item.outputTokens + item.reasoningTokens;
+ const billedOutputTokens = usageBillableOutputTokens(item);
const totalLine = usageTotalLine(item, labels, billingDisplay);
const cacheWriteLabel = cacheWriteBillingLabel(snapshot, labels.billingDisplay);
const cacheWriteNote = cacheWriteBillingNote(snapshot, labels.billingDisplay);
@@ -604,26 +611,84 @@ function UsageLogModelCell({ item, labels }: { item: AdminUsageLogDTO; labels: U
);
}
-function UsageLogTokenCell({ item, locale }: { item: AdminUsageLogDTO; locale: string }) {
+function UsageLogUsageCell({ item, locale }: { item: AdminUsageLogDTO; locale: string }) {
const t = useTranslations("adminLogs.usage.tokens");
+ const snapshot = parseUsagePricingSnapshot(item.pricingSnapshotJSON);
+ const isVideoUsage = snapshot.media_type === "video" || snapshot.duration_billable === true;
+ if (isVideoUsage) {
+ const inputImageCount = typeof snapshot.input_image_count === "number" && Number.isFinite(snapshot.input_image_count) && snapshot.input_image_count >= 0
+ ? Math.trunc(snapshot.input_image_count)
+ : null;
+ const mediaUsage = [
+ { label: t("input"), value: inputImageCount === null ? "—" : t("imageCount", { count: inputImageCount }) },
+ { label: t("output"), value: t("secondCount", { count: item.durationSeconds }) },
+ ];
+ return (
+
+ {mediaUsage.map((entry) => (
+
+ {entry.label}
+ {entry.value}
+
+ ))}
+
+ );
+ }
const tokens = [
{ label: t("inputShort"), value: item.inputTokens },
- { label: t("outputShort"), value: item.outputTokens },
+ {
+ label: t("outputShort"),
+ value: usageBillableOutputTokens(item),
+ breakdown: {
+ visible: item.outputTokens,
+ reasoning: item.reasoningTokens,
+ },
+ },
{ label: t("cacheReadShort"), value: item.cacheReadTokens },
{ label: t("cacheWriteShort"), value: item.cacheWriteTokens },
];
return (
- {tokens.map((token) => (
-
- {token.label}
- {formatCount(token.value, locale)}
-
- ))}
+ {tokens.map((token) => {
+ const badge = (
+
+ {token.label}
+ {formatCount(token.value, locale)}
+
+ );
+ if (!token.breakdown) {
+ return
{badge};
+ }
+ return (
+
+ {badge}
+
+
+
+ {t("output")}
+ {formatCount(token.breakdown.visible, locale)}
+
+
+ {t("reasoning")}
+ {formatCount(token.breakdown.reasoning, locale)}
+
+
+
+ {t("outputTotal")}
+ {formatCount(token.value, locale)}
+
+
+
+
+ );
+ })}
);
}
@@ -1318,7 +1383,7 @@ function UsageLogTable({
ID
{t("columns.caller")}
{t("columns.model")}
- Token
+ {t("columns.usage")}
{t("columns.billing")}
{t("columns.balanceAfter")}
{t("columns.latency")}
@@ -1340,7 +1405,7 @@ function UsageLogTable({
-
+
diff --git a/frontend/features/chat/components/message/message-bot.tsx b/frontend/features/chat/components/message/message-bot.tsx
index 7a7cb56a..36b8153b 100644
--- a/frontend/features/chat/components/message/message-bot.tsx
+++ b/frontend/features/chat/components/message/message-bot.tsx
@@ -685,21 +685,12 @@ function MessageInlineVideoPreview({
const resolveErrorMessage = useLocalizedErrorMessage();
const objectURLRef = React.useRef(null);
const [state, setState] = React.useState({ status: "loading" });
- const [detectedDurationSeconds, setDetectedDurationSeconds] = React.useState();
const fileID = attachment.fileID;
const fileName = attachment.fileName;
const mimeType = attachment.mimeType;
const detectedMime = attachment.detectedMime;
const previewURL = attachment.previewURL;
const sizeBytes = attachment.sizeBytes;
- const displayDurationSeconds = attachment.durationSeconds ?? detectedDurationSeconds;
-
- const handleDurationChange = React.useCallback((durationSeconds: number) => {
- setDetectedDurationSeconds(
- Number.isFinite(durationSeconds) && durationSeconds > 0 ? Math.ceil(durationSeconds) : undefined,
- );
- }, []);
-
const revokeObjectURL = React.useCallback(() => {
if (!objectURLRef.current) {
return;
@@ -711,7 +702,6 @@ function MessageInlineVideoPreview({
React.useEffect(() => {
let cancelled = false;
revokeObjectURL();
- setDetectedDurationSeconds(undefined);
if (previewURL) {
setState({
@@ -795,23 +785,14 @@ function MessageInlineVideoPreview({
}
return (
-
+
- {displayDurationSeconds && displayDurationSeconds > 0 ? (
-
- {displayDurationSeconds}s
-
- ) : null}
);
}
diff --git a/frontend/features/chat/hooks/use-chat-message-submit.ts b/frontend/features/chat/hooks/use-chat-message-submit.ts
index bb500e7d..b99d78d0 100644
--- a/frontend/features/chat/hooks/use-chat-message-submit.ts
+++ b/frontend/features/chat/hooks/use-chat-message-submit.ts
@@ -15,6 +15,7 @@ import type { ChatSubmitBlockReason } from "@/features/chat/model/chat-task";
import { resolveChatSubmitDecision } from "@/features/chat/model/chat-task";
import { useHiddenQueuedParentRuns } from "@/features/chat/hooks/use-hidden-queued-parent-runs";
import {
+ resolveAssistantInputSideUsageValue,
resolveDefaultSubmissionParentMessage,
resolvePersistedPublicID,
toPendingAttachments,
@@ -102,15 +103,6 @@ function streamEventErrorToApiError(
return new ApiError(event.message || fallback, 502, event.debug, event.errorCode);
}
-function resolveInputSideUsageValue(...values: Array
): number {
- for (const value of values) {
- if (typeof value === "number" && Number.isFinite(value) && value > 0) {
- return value;
- }
- }
- return 0;
-}
-
function resolveMediaStatusLabel(
status: string,
fallbackMessage: string,
@@ -1195,18 +1187,21 @@ export function useChatMessageSubmit({
assistantUpdatedAt: completed.assistantMessage.updatedAt,
assistantContentType: completed.assistantMessage.contentType || current.assistantContentType,
assistantAttachments: parseAttachments(completed.assistantMessage.attachments),
- assistantInputTokens: resolveInputSideUsageValue(
+ assistantInputTokens: resolveAssistantInputSideUsageValue(
+ assistantOnlyBranch,
completed.assistantMessage.inputTokens,
completed.userMessage.inputTokens,
current.assistantInputTokens,
),
assistantOutputTokens: completed.assistantMessage.outputTokens,
- assistantCacheReadTokens: resolveInputSideUsageValue(
+ assistantCacheReadTokens: resolveAssistantInputSideUsageValue(
+ assistantOnlyBranch,
completed.assistantMessage.cacheReadTokens,
completed.userMessage.cacheReadTokens,
current.assistantCacheReadTokens,
),
- assistantCacheWriteTokens: resolveInputSideUsageValue(
+ assistantCacheWriteTokens: resolveAssistantInputSideUsageValue(
+ assistantOnlyBranch,
completed.assistantMessage.cacheWriteTokens,
completed.userMessage.cacheWriteTokens,
current.assistantCacheWriteTokens,
diff --git a/frontend/features/chat/model/chat-thread.ts b/frontend/features/chat/model/chat-thread.ts
index 205c4ade..76f9766c 100644
--- a/frontend/features/chat/model/chat-thread.ts
+++ b/frontend/features/chat/model/chat-thread.ts
@@ -368,6 +368,12 @@ export function buildVisibleMessages(
if (item.role !== "assistant") {
return item;
}
+ // Assistant-only retries reuse the original user message, but own the
+ // prompt-side usage for their generation. A zero value is authoritative
+ // and must not fall back to the reused user's first-run usage.
+ if (item.branchReason === "retry" && item.sourcePublicID?.trim()) {
+ return item;
+ }
const previous = index > 0 ? withBranchNavigators[index - 1] : null;
if (!previous || previous.role !== "user") {
return item;
diff --git a/frontend/features/chat/model/message-submit.ts b/frontend/features/chat/model/message-submit.ts
index e659d488..2461c991 100644
--- a/frontend/features/chat/model/message-submit.ts
+++ b/frontend/features/chat/model/message-submit.ts
@@ -41,6 +41,25 @@ export function resolvePersistedPublicID(value: string | null | undefined): stri
return normalized;
}
+export function resolveAssistantInputSideUsageValue(
+ assistantOwnsUsage: boolean,
+ assistantValue: number | null | undefined,
+ userValue: number | null | undefined,
+ liveValue: number | null | undefined,
+): number {
+ if (assistantOwnsUsage) {
+ return typeof assistantValue === "number" && Number.isFinite(assistantValue) && assistantValue >= 0
+ ? assistantValue
+ : 0;
+ }
+ for (const value of [assistantValue, userValue, liveValue]) {
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) {
+ return value;
+ }
+ }
+ return 0;
+}
+
function isSuccessfulContextMessage(message: ChatAreaMessage): boolean {
const status = message.status?.trim().toLowerCase() || "success";
return (
diff --git a/frontend/i18n/messages/en-US/admin-logs.json b/frontend/i18n/messages/en-US/admin-logs.json
index 63038e4a..a4934e45 100644
--- a/frontend/i18n/messages/en-US/admin-logs.json
+++ b/frontend/i18n/messages/en-US/admin-logs.json
@@ -104,6 +104,7 @@
"message": "Message",
"caller": "Caller",
"model": "Model",
+ "usage": "Usage",
"billing": "Billing",
"balanceAfter": "Balance after settlement",
"latency": "Latency",
@@ -203,7 +204,13 @@
"inputShort": "In",
"outputShort": "Out",
"cacheReadShort": "Read",
- "cacheWriteShort": "Write"
+ "cacheWriteShort": "Write",
+ "input": "Input",
+ "output": "Output",
+ "reasoning": "Reasoning",
+ "outputTotal": "Output total",
+ "imageCount": "{count, plural, one {# image} other {# images}}",
+ "secondCount": "{count, plural, one {# second} other {# seconds}}"
},
"billing": {
"cacheWrite": "Cache write",
diff --git a/frontend/i18n/messages/en-US/chat.json b/frontend/i18n/messages/en-US/chat.json
index f1175c2f..e9633258 100644
--- a/frontend/i18n/messages/en-US/chat.json
+++ b/frontend/i18n/messages/en-US/chat.json
@@ -187,7 +187,6 @@
"contextCompressed": "Context was automatically compressed",
"scrollToBottom": "Scroll to bottom",
"processing": "Processing...",
- "videoDuration": "Video duration: {seconds} seconds",
"expandUserMessage": "Expand",
"collapseUserMessage": "Collapse",
"editCreatesBranch": "Saving creates a new branch in this conversation.",
diff --git a/frontend/i18n/messages/zh-CN/admin-logs.json b/frontend/i18n/messages/zh-CN/admin-logs.json
index 217bf8d9..3b0e7431 100644
--- a/frontend/i18n/messages/zh-CN/admin-logs.json
+++ b/frontend/i18n/messages/zh-CN/admin-logs.json
@@ -104,6 +104,7 @@
"message": "消息",
"caller": "调用人",
"model": "模型",
+ "usage": "用量",
"billing": "计费",
"balanceAfter": "结算后余额",
"latency": "延迟",
@@ -203,7 +204,13 @@
"inputShort": "入",
"outputShort": "出",
"cacheReadShort": "读",
- "cacheWriteShort": "写"
+ "cacheWriteShort": "写",
+ "input": "输入",
+ "output": "输出",
+ "reasoning": "推理",
+ "outputTotal": "输出合计",
+ "imageCount": "{count} 张",
+ "secondCount": "{count} 秒"
},
"billing": {
"cacheWrite": "缓存(写)",
diff --git a/frontend/i18n/messages/zh-CN/chat.json b/frontend/i18n/messages/zh-CN/chat.json
index 9647f9a3..733dd293 100644
--- a/frontend/i18n/messages/zh-CN/chat.json
+++ b/frontend/i18n/messages/zh-CN/chat.json
@@ -187,7 +187,6 @@
"contextCompressed": "上下文已自动压缩",
"scrollToBottom": "回到底部",
"processing": "正在处理…",
- "videoDuration": "视频时长:{seconds} 秒",
"expandUserMessage": "展开",
"collapseUserMessage": "收起",
"editCreatesBranch": "保存后会在当前 conversation 内创建新分支。",
diff --git a/frontend/package.json b/frontend/package.json
index 8a6c81cd..ba98f8a6 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -19,8 +19,7 @@
"check": "pnpm lint && pnpm typecheck",
"lint": "biome lint .",
"lint:fix": "biome lint --write .",
- "clean": "rm -rf .next out",
- "test": "node --no-warnings --test shared/components/markdown/markdown-table-analyzer.test.mjs"
+ "clean": "rm -rf .next out"
},
"dependencies": {
"@base-ui/react": "^1.4.1",
diff --git a/frontend/shared/components/file-preview/preview-media.tsx b/frontend/shared/components/file-preview/preview-media.tsx
index a495c110..e8232a50 100644
--- a/frontend/shared/components/file-preview/preview-media.tsx
+++ b/frontend/shared/components/file-preview/preview-media.tsx
@@ -17,7 +17,6 @@ type PreviewMediaProps = {
contentType?: string;
toolbarContainer?: HTMLElement | null;
inline?: boolean;
- onDurationChange?: (durationSeconds: number) => void;
};
const IMAGE_PREVIEW = {
@@ -78,7 +77,6 @@ export function PreviewMedia({
contentType,
toolbarContainer,
inline = false,
- onDurationChange,
}: PreviewMediaProps) {
const t = useTranslations("files.previewErrors");
const tPreview = useTranslations("files.preview");
@@ -306,8 +304,7 @@ export function PreviewMedia({
const nextDuration = media.duration || 0;
setDuration(nextDuration);
setCurrentTime(media.currentTime || 0);
- onDurationChange?.(nextDuration);
- }, [onDurationChange]);
+ }, []);
const handleMediaLoadedMetadata = React.useCallback((event: React.SyntheticEvent) => {
syncMediaMetrics(event.currentTarget);
diff --git a/frontend/shared/components/markdown/markdown-table-analyzer.test.mjs b/frontend/shared/components/markdown/markdown-table-analyzer.test.mjs
deleted file mode 100644
index eed6ff0e..00000000
--- a/frontend/shared/components/markdown/markdown-table-analyzer.test.mjs
+++ /dev/null
@@ -1,174 +0,0 @@
-import assert from "node:assert/strict";
-import test from "node:test";
-
-import {
- classifyColumn,
- classifyTableColumns,
- createColumnAnalyzerConfig,
- DEFAULT_COLUMN_ANALYZER_PATTERNS,
- getVisualLength,
- mergeColumnType,
-} from "./markdown-table-analyzer.ts";
-
-test("classifies a mixed table from values rather than column position", () => {
- const headers = ["编号", "Name", "説明", "Date"];
- const rows = [
- ["1", "Alpha", "这是一个较长的中文说明,用于解释问题发生的原因以及推荐的处理方式。", "2026-03-20"],
- ["2", "ベータ", "This description contains enough natural-language detail to remain readable on a phone.", "2026-03-21"],
- ["3", "Gamma", "複数の言語を含む長い説明で、列が狭くなりすぎないことを確認します。", "2026-03-22"],
- ];
-
- assert.deepEqual(classifyTableColumns(headers, rows), ["numeric", "normal", "content", "date"]);
-});
-
-test("counts CJK and other wide characters as two visual units", () => {
- assert.equal(getVisualLength("ab中文"), 6);
- assert.equal(getVisualLength("テスト"), 6);
-});
-
-test("classifies multiple long natural-language columns as content", () => {
- const types = classifyTableColumns(
- ["原因", "Recommendation", "詳細"],
- [
- [
- "因为移动端将所有列压缩到屏幕内,长文本被拆成了大量短行。",
- "Allow the table to grow naturally and keep horizontal scrolling inside its container.",
- "ユーザーが内容を読みやすいように、長文列には十分な最小幅を確保します。",
- ],
- [
- "第二个原因字段继续包含完整的自然语言句子,以提供稳定的统计样本。",
- "Use column-level metadata instead of fixed child indexes for unknown generated schemas.",
- "ストリーミング中は狭い列から広い列へのアップグレードだけを許可します。",
- ],
- ],
- );
-
- assert.deepEqual(types, ["content", "content", "content"]);
-});
-
-test("recognizes all-numeric values including money and percentages", () => {
- assert.deepEqual(
- classifyTableColumns(
- ["Count", "金额", "Ratio"],
- [
- ["1,200", "¥99.50", "12%"],
- ["3,400", "¥125.00", "8.5%"],
- ["5,600", "¥1,020.25", "100%"],
- ],
- ),
- ["numeric", "numeric", "numeric"],
- );
-});
-
-test("keeps long structured-column fallback text wrap-safe", () => {
- assert.equal(
- classifyColumn("Amount", [
- "1",
- "2",
- "3",
- "This value could not be calculated because the upstream response omitted pricing metadata.",
- ]),
- "normal",
- );
- assert.equal(
- classifyColumn("Date", [
- "2026-03-20",
- "2026-03-21",
- "2026-03-22",
- "The date is unavailable because this record predates the migration.",
- ]),
- "normal",
- );
- assert.equal(classifyColumn("Amount", ["1", "2", "3", "N/A"]), "numeric");
-});
-
-test("classifies URLs and long unbroken identifiers as code", () => {
- assert.deepEqual(
- classifyTableColumns(
- ["未知字段", "Token"],
- [
- ["https://example.com/a/very/long/path?query=mobile-table", "customer_session_identifier_01HZX4RTN8Q3YJ7K9MP2W6CVBX"],
- ["/srv/app/releases/2026-03-20/config.json", "customer_session_identifier_01HZX4RTN8Q3YJ7K9MP2W6CVBY"],
- ],
- ),
- ["code", "code"],
- );
-});
-
-test("supports multilingual and unknown headers without relying on hints", () => {
- assert.deepEqual(
- classifyTableColumns(
- ["名称", "Category", "更新日時", "Champ inconnu"],
- [
- ["Alpha", "Tool", "2026-03-20 09:30", "Medium value"],
- ["ベータ", "Library", "2026-03-21 10:45", "Another value"],
- ["Gamma", "Service", "2026-03-22 11:15", "Third value"],
- ],
- { headerRules: false },
- ),
- ["normal", "normal", "date", "normal"],
- );
-});
-
-test("ignores empty and missing cells while preserving uneven columns", () => {
- assert.deepEqual(
- classifyTableColumns(
- ["ID", "Name", "Notes"],
- [
- ["1", "Alpha Product"],
- ["2", "", ""],
- ["3", "Gamma Service", "This populated note is long enough to be handled as readable natural-language content."],
- [],
- ],
- ),
- ["numeric", "normal", "content"],
- );
-});
-
-test("header rules remain weak, replaceable hints", () => {
- const shortDescription = ["ok", "new", "done", "hold"];
- assert.notEqual(classifyColumn("Description", shortDescription), "content");
- assert.equal(
- classifyColumn("Arbitrary", ["x", "y", "z"], {
- headerRules: [{ pattern: /^Arbitrary$/, type: "date", weight: 0.2 }],
- }),
- "compact",
- );
-});
-test("normalizes complete pattern overrides with partial thresholds", () => {
- const patterns = Object.fromEntries(
- Object.entries(DEFAULT_COLUMN_ANALYZER_PATTERNS).map(([name, pattern]) => [name, new RegExp(pattern.source, pattern.flags)]),
- );
- assert.equal(
- classifyColumn("Value", ["1", "2", "3"], {
- patterns,
- thresholds: { numericRatio: 0.5 },
- }),
- "numeric",
- );
- assert.equal(classifyColumn("Value", ["1", "2", "3"], createColumnAnalyzerConfig()), "numeric");
-});
-
-test("treats whitespace-free CJK prose as natural language rather than code", () => {
- assert.equal(
- classifyColumn("未知", [
- "这是没有空格但依然属于自然语言的中文长文本内容",
- "これは空白がなくても自然言語として扱うべき日本語の長文です",
- ]),
- "content",
- );
-});
-
-test("upgrades columns without shrinking a previous streaming type", () => {
- const initial = classifyColumn("Value", ["Alpha Product", "Beta Service", "Gamma Library"]);
- const expanded = classifyColumn("Value", [
- "Alpha now includes a complete explanation with substantially more natural-language detail.",
- "Beta now includes a second detailed sentence because the streamed answer continued growing.",
- "Gamma now also contains enough descriptive prose to need a readable content-column width.",
- ]);
-
- assert.equal(initial, "normal");
- assert.equal(expanded, "content");
- assert.equal(mergeColumnType(initial, expanded), "content");
- assert.equal(mergeColumnType("content", "normal"), "content");
-});