A reader has a card and an origin has a label by WaylandYang · Pull Request #722 · deeplethe/utopia · GitHub
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
30 changes: 30 additions & 0 deletions web/src/api.ts
29 changes: 28 additions & 1 deletion web/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ export const en = {
},
"document.needs_reader": {
title: "A file needs a model to be read",
hint: "Scans and images need a document-reading service, and recordings need a transcription model that labels speakers. Neither is configured on this deployment. The file is kept, and nothing was read from it.",
hint: "Scans and images need a document-reading service, and recordings need a transcription model that labels speakers. Each line says what was missing. The file is kept and nothing was read from it yet; it is read as soon as the reader is saved under Administration → Models.",
},
"governance.tripped": {
title: "The agent stopped deciding on its own",
Expand Down Expand Up @@ -991,6 +991,18 @@ export const en = {
play: "Play timeline",
pause: "Pause",
},
origin: {
ocr: (page: number | null) => (page === null ? "OCR" : `OCR · p. ${page}`),
transcribed: (span: string | null, speakers: string[]) =>
["Transcribed", span, speakers.length > 0 ? speakers.join(", ") : null]
.filter(Boolean)
.join(" · "),
described: "Described by a model",
ocrHint: "Read from a scan or image. A character or digit may be misread.",
transcribedHint: "Transcribed from a recording. A name may be misheard.",
describedHint: "A model's description of an image. Nobody wrote or said these words.",
readBy: (model: string) => `Read by ${model}.`,
},
doc: {
backToLibrary: "← Back to Library",
sections: "sections",
Expand Down Expand Up @@ -1159,6 +1171,21 @@ export const en = {
ok: (reply: string) => `Reachable and authenticated (${reply})`,
okDim: (dim: number) => `Reachable and authenticated (dim ${dim})`,
unsaved: "Unsaved changes. Save this card to test them.",
readersTitle: "Reading scans and recordings",
readersIntro:
"Scanned PDFs, images and recordings have no text to parse, so each needs its own reader. They are set apart from chat so that sensitive files can stay on your own servers. A file that arrives before its reader waits, and the message center says so; saving the reader reads it.",
ocrService: "Document reading (OCR)",
ocrHint:
"A MinerU service (mineru-api). It reads each page's layout first, so every passage keeps its page and position.",
serviceUrl: "Service URL",
backend: "Backend (optional)",
transcribeModel: "Transcription",
transcribeHint:
"An OpenAI-compatible endpoint that labels speakers (diarized_json), such as gpt-4o-transcribe-diarize. A transcript that cannot say who spoke is not used.",
okVersion: (version: string) => `Reachable (MinerU ${version})`,
okReachable: "Reachable and authenticated",
savedRequeued: (n: number) =>
`Saved. ${n} waiting ${n === 1 ? "file is" : "files are"} being read.`,
},
ontology: {
title: "Ontology",
Expand Down
27 changes: 26 additions & 1 deletion web/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ export const zh: Strings = {
},
"document.needs_reader": {
title: "有文件要靠模型才读得出来",
hint: "扫描件和图片要配文档识别服务,录音要配能分出说话人的转写模型,这个部署都还没配。文件已经留着,但里面什么都没读出来。",
hint: "扫描件和图片要配文档识别服务,录音要配能分出说话人的转写模型;每一行写着缺的是什么。文件已经留着,还没读出任何内容;到「管理 > 模型」存好读取模型,就会自动读。",
},
"governance.tripped": {
title: "agent 停止自动裁决了",
Expand Down Expand Up @@ -885,6 +885,18 @@ export const zh: Strings = {
play: "播放时间线",
pause: "暂停",
},
origin: {
ocr: (page: number | null) => (page === null ? "OCR 识别" : `OCR 识别 · 第 ${page} 页`),
transcribed: (span: string | null, speakers: string[]) =>
["转写", span, speakers.length > 0 ? speakers.join("、") : null]
.filter(Boolean)
.join(" · "),
described: "模型描述",
ocrHint: "从扫描件或图片上识别出来的文字,个别字或数字可能认错。",
transcribedHint: "从录音转写出来的文字,人名可能听错。",
describedHint: "模型对一张图的描述,没有人这样写过或说过。",
readBy: (model: string) => `读取模型:${model}。`,
},
doc: {
backToLibrary: "← 返回文库",
sections: "段",
Expand Down Expand Up @@ -1038,6 +1050,19 @@ export const zh: Strings = {
ok: (reply: string) => `已连通,认证通过(${reply})`,
okDim: (dim: number) => `已连通,认证通过(维度 ${dim})`,
unsaved: "有未保存的修改。先保存这张卡,再测试。",
readersTitle: "读扫描件与录音",
readersIntro:
"扫描件、图片和录音没有可以直接解析的文字,各要一个读取模型。它们和对话模型分开配置,敏感文件可以留在自己的服务器上。读取模型配好之前传上来的文件会先等着,消息中心会提示;存好之后自动读。",
ocrService: "文档识别(OCR)",
ocrHint: "MinerU 服务(mineru-api)。先识别每页版面再认字,每段文字都记着所在的页和位置。",
serviceUrl: "服务地址",
backend: "后端(可选)",
transcribeModel: "录音转写",
transcribeHint:
"会标注说话人的 OpenAI 兼容转写接口(diarized_json),例如 gpt-4o-transcribe-diarize。分不出谁说的转写不会采用。",
okVersion: (version: string) => `已连通(MinerU ${version})`,
okReachable: "已连通,认证通过",
savedRequeued: (n: number) => `已保存。${n} 个等待中的文件开始读取。`,
},
ontology: {
title: "本体",
Expand Down
41 changes: 41 additions & 0 deletions web/src/origin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { clock, originHint, originLabel } from "./origin";

describe("originLabel", () => {
it("says nothing for words the file itself holds", () => {
expect(originLabel("stated", null)).toBeNull();
expect(originLabel(undefined, null)).toBeNull();
});

it("names the page a scan was read from", () => {
expect(originLabel("ocr", { page: 2, bbox: [1, 2, 3, 4] })).toBe("OCR · p. 2");
expect(originLabel("ocr", {})).toBe("OCR");
});

it("names the stretch of the recording and who spoke", () => {
expect(
originLabel("transcribed", { start_ms: 6400, end_ms: 9750, speaker: ["A", "B"] }),
).toBe("Transcribed · 0:06–0:09 · A, B");
expect(originLabel("transcribed", { start_ms: 3_725_000, end_ms: 3_731_000, speaker: "A" })).toBe(
"Transcribed · 1:02:05–1:02:11 · A",
);
});

it("marks a description as one", () => {
expect(originLabel("described", {})).toBe("Described by a model");
});
});

describe("originHint", () => {
it("adds the model that read it", () => {
expect(originHint("ocr", "mineru 2.5.4 vlm-auto-engine")).toContain("mineru 2.5.4 vlm-auto-engine");
expect(originHint("stated", "x")).toBeUndefined();
});
});

describe("clock", () => {
it("pads minutes and seconds", () => {
expect(clock(0)).toBe("0:00");
expect(clock(65_000)).toBe("1:05");
});
});
59 changes: 59 additions & 0 deletions web/src/origin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { S } from "./i18n";

/** 一块文字从哪来(0040)。原文是常态,常态不标;只有模型读出来、转写出来、描述出来的
* 那几种才在证据和文档里写一句——人据此知道这句话可能认错一个字、听错一个名字,
* 或者根本没有人这么写过 */
export type Origin = "stated" | "ocr" | "transcribed" | "described";
export type Anchor = Record<string, unknown> | null;

const num = (v: unknown): number | null =>
typeof v === "number" && Number.isFinite(v) ? v : null;

/** 毫秒写成录音里的钟点:一小时以内 m:ss,超过 h:mm:ss */
export function clock(ms: number): string {
const total = Math.floor(ms / 1000);
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
const pad = (n: number) => String(n).padStart(2, "0");
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
}

/** 出处的一句短话:「OCR · p. 2」「Transcribed · 0:06–0:09 · A, B」。原文返回 null */
export function originLabel(origin?: string | null, anchor?: Anchor): string | null {
switch (origin) {
case "ocr":
return S.origin.ocr(num(anchor?.page));
case "transcribed": {
const start = num(anchor?.start_ms);
const end = num(anchor?.end_ms);
const speakers = Array.isArray(anchor?.speaker)
? anchor.speaker.filter((s): s is string => typeof s === "string")
: typeof anchor?.speaker === "string"
? [anchor.speaker]
: [];
return S.origin.transcribed(
start !== null && end !== null ? `${clock(start)}–${clock(end)}` : null,
speakers,
);
}
case "described":
return S.origin.described;
default:
return null;
}
}

/** 悬停说明:这种出处要当心什么,以及是哪个模型读的 */
export function originHint(origin?: string | null, model?: string | null): string | undefined {
const why =
origin === "ocr"
? S.origin.ocrHint
: origin === "transcribed"
? S.origin.transcribedHint
: origin === "described"
? S.origin.describedHint
: null;
if (!why) return undefined;
return model ? `${why} ${S.origin.readBy(model)}` : why;
}
8 changes: 8 additions & 0 deletions web/src/pages/DocViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Link, useNavigate, useParams, useSearch } from "@tanstack/react-router"
import { api, type ChunkFact } from "../api";
import { S } from "../i18n";
import { useKbId } from "../kb";
import { originHint, originLabel } from "../origin";
import { GroupLabel, PageHeader, Pager, pageSlice } from "../ui";
import { SourcesRail } from "./SourcesRail";

Expand Down Expand Up @@ -111,6 +112,13 @@ export function DocViewer() {
>
<div className="mb-2 text-small text-ink-2">
{S.doc.section} {c.seq + 1}
{/* 认出来、转写出来的字标一句出处:可能认错一个数、听错一个名字 */}
{originLabel(c.origin, c.anchor) && (
<span title={originHint(c.origin, c.origin_model)}>
{" · "}
{originLabel(c.origin, c.anchor)}
</span>
)}
{hit && (
<span
className={`u-fade-slow ml-2 text-accent ${
Expand Down
10 changes: 10 additions & 0 deletions web/src/pages/Graph.tsx
Loading