@gmickel/gno 1.27.0 → 1.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/assets/skill/SKILL.md +26 -1
- package/browser-extension/artifacts/{gno-browser-clipper-v1.27.0.zip → gno-browser-clipper-v1.28.0.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.28.0.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +2 -2
- package/spec/cli.md +41 -4
- package/spec/db/schema.sql +12 -0
- package/spec/mcp.md +5 -0
- package/spec/output-schemas/ask.schema.json +125 -0
- package/spec/output-schemas/context-capsule-v1.schema.json +94 -1
- package/spec/output-schemas/get.schema.json +94 -0
- package/spec/output-schemas/mcp-job-status.schema.json +28 -0
- package/spec/output-schemas/multi-get.schema.json +128 -0
- package/spec/output-schemas/record-import.schema.json +193 -0
- package/spec/output-schemas/search-result.schema.json +94 -0
- package/spec/output-schemas/search-results.schema.json +125 -0
- package/spec/project-profile.schema.json +66 -0
- package/src/app/context-format.ts +1 -1
- package/src/cli/commands/get.ts +12 -2
- package/src/cli/commands/index-cmd.ts +13 -0
- package/src/cli/commands/multi-get.ts +9 -2
- package/src/cli/commands/shared.ts +28 -0
- package/src/cli/commands/update.ts +5 -0
- package/src/cli/program.ts +4 -0
- package/src/config/project-profile.ts +2 -0
- package/src/config/types.ts +16 -0
- package/src/converters/adapters/browser-export/adapter.ts +199 -0
- package/src/converters/adapters/browser-export/formats.ts +358 -0
- package/src/converters/adapters/email/adapter.ts +429 -0
- package/src/converters/adapters/email/html.ts +162 -0
- package/src/converters/adapters/email/mime.ts +454 -0
- package/src/converters/adapters/email/parameters.ts +77 -0
- package/src/converters/adapters/ical/adapter.ts +475 -0
- package/src/converters/adapters/ical/recurrence.ts +186 -0
- package/src/converters/adapters/jsonl/adapter.ts +238 -0
- package/src/converters/adapters/jsonl/config.ts +105 -0
- package/src/converters/adapters/shared/html-text.ts +165 -0
- package/src/converters/adapters/shared/record-utils.ts +79 -0
- package/src/converters/adapters/shared/utf8-lines.ts +141 -0
- package/src/converters/adapters/transcript/adapter.ts +295 -0
- package/src/converters/adapters/transcript/json.ts +184 -0
- package/src/converters/adapters/transcript/model.ts +171 -0
- package/src/converters/adapters/transcript/text.ts +58 -0
- package/src/converters/adapters/transcript/timed.ts +152 -0
- package/src/converters/index.ts +11 -1
- package/src/converters/mime.ts +8 -0
- package/src/converters/pipeline.ts +15 -1
- package/src/converters/registry.ts +37 -1
- package/src/converters/types.ts +136 -0
- package/src/core/context-capsule-schema.ts +87 -0
- package/src/core/context-capsule.ts +20 -0
- package/src/core/context-evidence.ts +7 -1
- package/src/core/document-capabilities.ts +12 -0
- package/src/core/project-profile-apply-state.ts +5 -0
- package/src/core/project-profile.ts +4 -0
- package/src/core/record-metadata.ts +49 -0
- package/src/ingestion/record-adapter-canonical.ts +433 -0
- package/src/ingestion/record-adapter.ts +437 -0
- package/src/ingestion/record-container.ts +688 -0
- package/src/ingestion/record-path.ts +20 -0
- package/src/ingestion/record-sync.ts +70 -0
- package/src/ingestion/sync.ts +228 -36
- package/src/ingestion/types.ts +69 -1
- package/src/ingestion/walker.ts +31 -11
- package/src/mcp/tools/get.ts +10 -2
- package/src/mcp/tools/multi-get.ts +9 -2
- package/src/mcp/tools/workspace-write.ts +2 -0
- package/src/pipeline/filters.ts +1 -1
- package/src/pipeline/graph-retrieval.ts +7 -4
- package/src/pipeline/hybrid.ts +7 -4
- package/src/pipeline/result-context.ts +12 -4
- package/src/pipeline/search.ts +11 -4
- package/src/pipeline/types.ts +3 -0
- package/src/pipeline/vsearch.ts +26 -8
- package/src/sdk/documents.ts +13 -5
- package/src/serve/browse-tree.ts +4 -2
- package/src/serve/routes/api.ts +63 -65
- package/src/store/migrations/022-record-export-lineage.ts +42 -0
- package/src/store/migrations/index.ts +2 -0
- package/src/store/sqlite/adapter.ts +114 -7
- package/src/store/types.ts +40 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.27.0.zip.sha256 +0 -1
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
export type Utf8LineResult =
|
|
2
|
+
| {
|
|
3
|
+
ok: true;
|
|
4
|
+
lineNumber: number;
|
|
5
|
+
text: string;
|
|
6
|
+
terminated: boolean;
|
|
7
|
+
}
|
|
8
|
+
| {
|
|
9
|
+
ok: false;
|
|
10
|
+
lineNumber: number;
|
|
11
|
+
reason: "invalid_utf8" | "line_too_large";
|
|
12
|
+
terminated: boolean;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const BYTE_LF = 0x0a;
|
|
16
|
+
const BYTE_CR = 0x0d;
|
|
17
|
+
const UTF8_BOM = new Uint8Array([0xef, 0xbb, 0xbf]);
|
|
18
|
+
|
|
19
|
+
const concatenate = (
|
|
20
|
+
parts: readonly Uint8Array[],
|
|
21
|
+
byteLength: number
|
|
22
|
+
): Uint8Array => {
|
|
23
|
+
if (parts.length === 1) return parts[0] ?? new Uint8Array();
|
|
24
|
+
const bytes = new Uint8Array(byteLength);
|
|
25
|
+
let offset = 0;
|
|
26
|
+
for (const part of parts) {
|
|
27
|
+
bytes.set(part, offset);
|
|
28
|
+
offset += part.byteLength;
|
|
29
|
+
}
|
|
30
|
+
return bytes;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const stripLineEnvelope = (
|
|
34
|
+
bytes: Uint8Array,
|
|
35
|
+
lineNumber: number
|
|
36
|
+
): Uint8Array => {
|
|
37
|
+
let start = 0;
|
|
38
|
+
let end = bytes.byteLength;
|
|
39
|
+
if (
|
|
40
|
+
lineNumber === 1 &&
|
|
41
|
+
end >= UTF8_BOM.byteLength &&
|
|
42
|
+
UTF8_BOM.every((byte, index) => bytes[index] === byte)
|
|
43
|
+
) {
|
|
44
|
+
start = UTF8_BOM.byteLength;
|
|
45
|
+
}
|
|
46
|
+
if (end > start && bytes[end - 1] === BYTE_CR) end -= 1;
|
|
47
|
+
return bytes.subarray(start, end);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const decodeLine = (
|
|
51
|
+
parts: readonly Uint8Array[],
|
|
52
|
+
byteLength: number,
|
|
53
|
+
lineNumber: number,
|
|
54
|
+
terminated: boolean
|
|
55
|
+
): Utf8LineResult => {
|
|
56
|
+
const bytes = stripLineEnvelope(concatenate(parts, byteLength), lineNumber);
|
|
57
|
+
try {
|
|
58
|
+
return {
|
|
59
|
+
ok: true,
|
|
60
|
+
lineNumber,
|
|
61
|
+
text: new TextDecoder("utf-8", { fatal: true }).decode(bytes),
|
|
62
|
+
terminated,
|
|
63
|
+
};
|
|
64
|
+
} catch {
|
|
65
|
+
return {
|
|
66
|
+
ok: false,
|
|
67
|
+
lineNumber,
|
|
68
|
+
reason: "invalid_utf8",
|
|
69
|
+
terminated,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Split an async byte source without retaining an unbounded physical line.
|
|
76
|
+
* Invalid UTF-8 and oversized lines are isolated so later siblings can proceed.
|
|
77
|
+
*/
|
|
78
|
+
export async function* readBoundedUtf8Lines(
|
|
79
|
+
source: AsyncIterable<Uint8Array>,
|
|
80
|
+
maxLineBytes: number
|
|
81
|
+
): AsyncGenerator<Utf8LineResult> {
|
|
82
|
+
const safeMaxLineBytes = Math.max(1, Math.floor(maxLineBytes));
|
|
83
|
+
let parts: Uint8Array[] = [];
|
|
84
|
+
let lineBytes = 0;
|
|
85
|
+
let lineNumber = 1;
|
|
86
|
+
let oversized = false;
|
|
87
|
+
|
|
88
|
+
const reset = (): void => {
|
|
89
|
+
parts = [];
|
|
90
|
+
lineBytes = 0;
|
|
91
|
+
oversized = false;
|
|
92
|
+
lineNumber += 1;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
for await (const chunk of source) {
|
|
96
|
+
let segmentStart = 0;
|
|
97
|
+
for (let index = 0; index < chunk.byteLength; index += 1) {
|
|
98
|
+
if (chunk[index] !== BYTE_LF) continue;
|
|
99
|
+
const segment = chunk.subarray(segmentStart, index);
|
|
100
|
+
if (!oversized && lineBytes + segment.byteLength <= safeMaxLineBytes) {
|
|
101
|
+
if (segment.byteLength > 0) parts.push(segment.slice());
|
|
102
|
+
lineBytes += segment.byteLength;
|
|
103
|
+
} else {
|
|
104
|
+
oversized = true;
|
|
105
|
+
}
|
|
106
|
+
if (oversized) {
|
|
107
|
+
yield {
|
|
108
|
+
ok: false,
|
|
109
|
+
lineNumber,
|
|
110
|
+
reason: "line_too_large",
|
|
111
|
+
terminated: true,
|
|
112
|
+
};
|
|
113
|
+
} else {
|
|
114
|
+
yield decodeLine(parts, lineBytes, lineNumber, true);
|
|
115
|
+
}
|
|
116
|
+
reset();
|
|
117
|
+
segmentStart = index + 1;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const remainder = chunk.subarray(segmentStart);
|
|
121
|
+
if (!oversized && lineBytes + remainder.byteLength <= safeMaxLineBytes) {
|
|
122
|
+
if (remainder.byteLength > 0) parts.push(remainder.slice());
|
|
123
|
+
lineBytes += remainder.byteLength;
|
|
124
|
+
} else if (remainder.byteLength > 0) {
|
|
125
|
+
oversized = true;
|
|
126
|
+
parts = [];
|
|
127
|
+
lineBytes = 0;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (oversized) {
|
|
132
|
+
yield {
|
|
133
|
+
ok: false,
|
|
134
|
+
lineNumber,
|
|
135
|
+
reason: "line_too_large",
|
|
136
|
+
terminated: false,
|
|
137
|
+
};
|
|
138
|
+
} else if (lineBytes > 0) {
|
|
139
|
+
yield decodeLine(parts, lineBytes, lineNumber, false);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
RecordAdapter,
|
|
3
|
+
RecordAdapterEvent,
|
|
4
|
+
RecordAdapterInput,
|
|
5
|
+
RecordAdapterRecord,
|
|
6
|
+
} from "../../types";
|
|
7
|
+
import type {
|
|
8
|
+
TranscriptAdapterOptions,
|
|
9
|
+
TranscriptFormat,
|
|
10
|
+
TranscriptParseEvent,
|
|
11
|
+
TranscriptSegment,
|
|
12
|
+
} from "./model";
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
adapterLineByteLimit,
|
|
16
|
+
canonicalJson,
|
|
17
|
+
hashRecordValue,
|
|
18
|
+
safeInlineText,
|
|
19
|
+
sourceNamespace,
|
|
20
|
+
} from "../shared/record-utils";
|
|
21
|
+
import {
|
|
22
|
+
readBoundedUtf8Lines,
|
|
23
|
+
type Utf8LineResult,
|
|
24
|
+
} from "../shared/utf8-lines";
|
|
25
|
+
import { parseJsonTranscript } from "./json";
|
|
26
|
+
import { parseTranscriptAdapterOptions } from "./model";
|
|
27
|
+
import { parseTextTranscript } from "./text";
|
|
28
|
+
import { parseTimedTranscript } from "./timed";
|
|
29
|
+
|
|
30
|
+
const ADAPTER_ID = "adapter/transcript";
|
|
31
|
+
const ADAPTER_VERSION = "1.0.0";
|
|
32
|
+
const MAX_JSON_TRANSCRIPT_CHARS = 16 * 1024 * 1024;
|
|
33
|
+
|
|
34
|
+
const resolvedFormat = (
|
|
35
|
+
input: Pick<RecordAdapterInput, "ext" | "mime">,
|
|
36
|
+
requested: TranscriptFormat
|
|
37
|
+
): Exclude<TranscriptFormat, "auto"> | undefined => {
|
|
38
|
+
if (requested !== "auto") return requested;
|
|
39
|
+
if (input.mime === "text/vtt" || input.ext === ".vtt") return "vtt";
|
|
40
|
+
if (
|
|
41
|
+
input.mime === "application/x-subrip" ||
|
|
42
|
+
input.mime === "text/srt" ||
|
|
43
|
+
input.ext === ".srt"
|
|
44
|
+
) {
|
|
45
|
+
return "srt";
|
|
46
|
+
}
|
|
47
|
+
return undefined;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const failureEvent = (
|
|
51
|
+
event: Extract<TranscriptParseEvent, { ok: false }>
|
|
52
|
+
): RecordAdapterEvent => ({
|
|
53
|
+
type: "failure",
|
|
54
|
+
failure: {
|
|
55
|
+
code: event.tooLarge ? "RECORD_TOO_LARGE" : "MALFORMED_RECORD",
|
|
56
|
+
message: "Transcript record could not be converted.",
|
|
57
|
+
retryable: event.retryable,
|
|
58
|
+
sourceLocator: event.sourceLocator,
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const fileTitle = (relativePath: string): string => {
|
|
63
|
+
const name = relativePath.replaceAll("\\", "/").split("/").at(-1);
|
|
64
|
+
return (name?.replace(/\.[^.]+$/u, "") || "Transcript").normalize("NFC");
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const localIdentity = (
|
|
68
|
+
segment: TranscriptSegment,
|
|
69
|
+
occurrences: Map<string, number>
|
|
70
|
+
): string => {
|
|
71
|
+
if (segment.externalId) {
|
|
72
|
+
return `external:${hashRecordValue(
|
|
73
|
+
"gno-transcript-external-id-v1",
|
|
74
|
+
segment.externalId
|
|
75
|
+
)}`;
|
|
76
|
+
}
|
|
77
|
+
if (segment.start) {
|
|
78
|
+
const key = canonicalJson({
|
|
79
|
+
end: segment.end,
|
|
80
|
+
speaker: segment.speaker,
|
|
81
|
+
start: segment.start,
|
|
82
|
+
});
|
|
83
|
+
const count = (occurrences.get(key) ?? 0) + 1;
|
|
84
|
+
occurrences.set(key, count);
|
|
85
|
+
return `time:${hashRecordValue("gno-transcript-time-id-v1", key)}:${count}`;
|
|
86
|
+
}
|
|
87
|
+
if (segment.anchorKind === "record") {
|
|
88
|
+
return `record:${hashRecordValue(
|
|
89
|
+
"gno-transcript-record-id-v1",
|
|
90
|
+
segment.anchorValue
|
|
91
|
+
)}`;
|
|
92
|
+
}
|
|
93
|
+
return `content:${hashRecordValue(
|
|
94
|
+
"gno-transcript-content-id-v1",
|
|
95
|
+
canonicalJson({
|
|
96
|
+
speaker: segment.speaker,
|
|
97
|
+
text: segment.text,
|
|
98
|
+
})
|
|
99
|
+
)}`;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const segmentRecord = (
|
|
103
|
+
segment: TranscriptSegment,
|
|
104
|
+
input: RecordAdapterInput,
|
|
105
|
+
occurrences: Map<string, number>
|
|
106
|
+
): RecordAdapterRecord => {
|
|
107
|
+
const sessionTitle = segment.sessionTitle ?? fileTitle(input.relativePath);
|
|
108
|
+
const label = segment.speaker
|
|
109
|
+
? `${sessionTitle} — ${segment.speaker}`
|
|
110
|
+
: sessionTitle;
|
|
111
|
+
const details: string[] = [];
|
|
112
|
+
if (segment.speaker)
|
|
113
|
+
details.push(`**Speaker:** ${safeInlineText(segment.speaker)}`);
|
|
114
|
+
if (segment.start) {
|
|
115
|
+
details.push(
|
|
116
|
+
`**Time:** ${segment.start}${segment.end ? ` → ${segment.end}` : ""}`
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
const markdown = [
|
|
120
|
+
`# ${safeInlineText(label)}`,
|
|
121
|
+
...details,
|
|
122
|
+
segment.text,
|
|
123
|
+
].join("\n\n");
|
|
124
|
+
const participants = [
|
|
125
|
+
...(segment.participants ?? []),
|
|
126
|
+
...(segment.speaker ? [segment.speaker] : []),
|
|
127
|
+
];
|
|
128
|
+
const uniqueParticipants = [
|
|
129
|
+
...new Set(participants.map((value) => value.normalize("NFC").trim())),
|
|
130
|
+
].filter(Boolean);
|
|
131
|
+
const anchors: RecordAdapterRecord["anchors"] = [
|
|
132
|
+
{
|
|
133
|
+
kind: segment.anchorKind,
|
|
134
|
+
value: segment.anchorValue,
|
|
135
|
+
endValue: segment.endAnchorValue,
|
|
136
|
+
},
|
|
137
|
+
];
|
|
138
|
+
if (segment.start) {
|
|
139
|
+
anchors.push({
|
|
140
|
+
kind: "timestamp",
|
|
141
|
+
value: segment.start,
|
|
142
|
+
endValue: segment.end,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
stableId: `transcript:${sourceNamespace(input)}:${localIdentity(
|
|
147
|
+
segment,
|
|
148
|
+
occurrences
|
|
149
|
+
)}`,
|
|
150
|
+
sourceLocator: segment.sourceLocator,
|
|
151
|
+
sourceHash: hashRecordValue(
|
|
152
|
+
"gno-transcript-segment-source-v1",
|
|
153
|
+
canonicalJson(segment)
|
|
154
|
+
),
|
|
155
|
+
title: label,
|
|
156
|
+
markdown,
|
|
157
|
+
metadata: {
|
|
158
|
+
author: segment.speaker,
|
|
159
|
+
participants:
|
|
160
|
+
uniqueParticipants.length > 0 ? uniqueParticipants : undefined,
|
|
161
|
+
categories: ["transcript"],
|
|
162
|
+
dateFields: segment.dateFields,
|
|
163
|
+
sessionId: segment.sessionId,
|
|
164
|
+
},
|
|
165
|
+
anchors,
|
|
166
|
+
};
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const jsonEvents = async function* (
|
|
170
|
+
input: RecordAdapterInput
|
|
171
|
+
): AsyncGenerator<TranscriptParseEvent> {
|
|
172
|
+
const parts: string[] = [];
|
|
173
|
+
let characters = 0;
|
|
174
|
+
let lastLine = 0;
|
|
175
|
+
const maxCharacters = Math.min(
|
|
176
|
+
MAX_JSON_TRANSCRIPT_CHARS,
|
|
177
|
+
input.limits.maxTotalChars
|
|
178
|
+
);
|
|
179
|
+
for await (const line of readBoundedUtf8Lines(
|
|
180
|
+
input.open(),
|
|
181
|
+
adapterLineByteLimit(input)
|
|
182
|
+
)) {
|
|
183
|
+
lastLine = line.lineNumber;
|
|
184
|
+
if (!line.ok) {
|
|
185
|
+
yield {
|
|
186
|
+
ok: false,
|
|
187
|
+
sourceLocator: `line:${line.lineNumber}`,
|
|
188
|
+
retryable: !line.terminated,
|
|
189
|
+
tooLarge: line.reason === "line_too_large",
|
|
190
|
+
};
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
characters += line.text.length + 1;
|
|
194
|
+
if (characters > maxCharacters) {
|
|
195
|
+
yield {
|
|
196
|
+
ok: false,
|
|
197
|
+
sourceLocator: `lines:1-${line.lineNumber}`,
|
|
198
|
+
retryable: false,
|
|
199
|
+
tooLarge: true,
|
|
200
|
+
};
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
parts.push(line.text);
|
|
204
|
+
}
|
|
205
|
+
if (parts.length === 0) {
|
|
206
|
+
yield {
|
|
207
|
+
ok: false,
|
|
208
|
+
sourceLocator: lastLine > 0 ? `lines:1-${lastLine}` : "record:root",
|
|
209
|
+
retryable: false,
|
|
210
|
+
};
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
yield* parseJsonTranscript(parts.join("\n"));
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const parseEvents = (
|
|
217
|
+
input: RecordAdapterInput,
|
|
218
|
+
format: Exclude<TranscriptFormat, "auto">
|
|
219
|
+
): AsyncIterable<TranscriptParseEvent> => {
|
|
220
|
+
if (format === "json") return jsonEvents(input);
|
|
221
|
+
const lines: AsyncIterable<Utf8LineResult> = readBoundedUtf8Lines(
|
|
222
|
+
input.open(),
|
|
223
|
+
adapterLineByteLimit(input)
|
|
224
|
+
);
|
|
225
|
+
if (format === "text") return parseTextTranscript(lines);
|
|
226
|
+
return parseTimedTranscript(lines, format);
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
export const createTranscriptAdapter = (
|
|
230
|
+
options: TranscriptAdapterOptions = {}
|
|
231
|
+
): RecordAdapter => {
|
|
232
|
+
const { format: requested } = parseTranscriptAdapterOptions(options);
|
|
233
|
+
return {
|
|
234
|
+
id: ADAPTER_ID,
|
|
235
|
+
version: ADAPTER_VERSION,
|
|
236
|
+
configurationFingerprint: hashRecordValue(
|
|
237
|
+
"gno-transcript-config-v1",
|
|
238
|
+
requested
|
|
239
|
+
),
|
|
240
|
+
canHandle: (mime, ext) => {
|
|
241
|
+
if (requested === "json")
|
|
242
|
+
return mime === "application/json" || ext === ".json";
|
|
243
|
+
if (requested === "text") return mime === "text/plain" || ext === ".txt";
|
|
244
|
+
if (requested === "vtt") return mime === "text/vtt" || ext === ".vtt";
|
|
245
|
+
if (requested === "srt")
|
|
246
|
+
return (
|
|
247
|
+
mime === "application/x-subrip" ||
|
|
248
|
+
mime === "text/srt" ||
|
|
249
|
+
ext === ".srt"
|
|
250
|
+
);
|
|
251
|
+
return Boolean(resolvedFormat({ mime, ext }, requested));
|
|
252
|
+
},
|
|
253
|
+
records: async function* (
|
|
254
|
+
input: RecordAdapterInput
|
|
255
|
+
): AsyncGenerator<RecordAdapterEvent> {
|
|
256
|
+
const format = resolvedFormat(input, requested);
|
|
257
|
+
if (!format) {
|
|
258
|
+
yield failureEvent({ ok: false, retryable: false });
|
|
259
|
+
yield { type: "snapshot", state: "partial" };
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const occurrences = new Map<string, number>();
|
|
263
|
+
let hadFailure = false;
|
|
264
|
+
try {
|
|
265
|
+
for await (const event of parseEvents(input, format)) {
|
|
266
|
+
if (!event.ok) {
|
|
267
|
+
hadFailure = true;
|
|
268
|
+
yield failureEvent(event);
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
yield {
|
|
272
|
+
type: "record",
|
|
273
|
+
record: segmentRecord(event.segment, input, occurrences),
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
} catch {
|
|
277
|
+
hadFailure = true;
|
|
278
|
+
yield {
|
|
279
|
+
type: "failure",
|
|
280
|
+
failure: {
|
|
281
|
+
code: "ADAPTER_FAILURE",
|
|
282
|
+
message: "Transcript source could not be read completely.",
|
|
283
|
+
retryable: true,
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
yield {
|
|
288
|
+
type: "snapshot",
|
|
289
|
+
state: hadFailure ? "partial" : "complete",
|
|
290
|
+
};
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
export const transcriptAdapter = createTranscriptAdapter();
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { scalarList, scalarText } from "../shared/record-utils";
|
|
2
|
+
import {
|
|
3
|
+
cleanTranscriptText,
|
|
4
|
+
parseTranscriptTimestamp,
|
|
5
|
+
type TranscriptParseEvent,
|
|
6
|
+
type TranscriptSegment,
|
|
7
|
+
} from "./model";
|
|
8
|
+
|
|
9
|
+
const ownValue = (
|
|
10
|
+
record: Record<string, unknown>,
|
|
11
|
+
names: readonly string[]
|
|
12
|
+
): unknown => {
|
|
13
|
+
for (const name of names) {
|
|
14
|
+
if (Object.hasOwn(record, name)) return record[name];
|
|
15
|
+
}
|
|
16
|
+
return undefined;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const objectValue = (value: unknown): Record<string, unknown> | undefined =>
|
|
20
|
+
value && typeof value === "object" && !Array.isArray(value)
|
|
21
|
+
? (value as Record<string, unknown>)
|
|
22
|
+
: undefined;
|
|
23
|
+
|
|
24
|
+
const personText = (value: unknown): string | undefined => {
|
|
25
|
+
const scalar = scalarText(value);
|
|
26
|
+
if (scalar) return scalar;
|
|
27
|
+
const object = objectValue(value);
|
|
28
|
+
return object
|
|
29
|
+
? scalarText(ownValue(object, ["name", "displayName", "label", "id"]))
|
|
30
|
+
: undefined;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const peopleList = (value: unknown): string[] | undefined => {
|
|
34
|
+
if (!Array.isArray(value)) return scalarList(value);
|
|
35
|
+
const people = value
|
|
36
|
+
.map(personText)
|
|
37
|
+
.filter((person): person is string => Boolean(person));
|
|
38
|
+
return people.length > 0 ? people : undefined;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const segmentArray = (
|
|
42
|
+
root: unknown
|
|
43
|
+
):
|
|
44
|
+
| {
|
|
45
|
+
segments: unknown[];
|
|
46
|
+
pointerName: string;
|
|
47
|
+
session: Record<string, unknown>;
|
|
48
|
+
}
|
|
49
|
+
| undefined => {
|
|
50
|
+
if (Array.isArray(root)) {
|
|
51
|
+
return { segments: root, pointerName: "", session: {} };
|
|
52
|
+
}
|
|
53
|
+
const rootObject = objectValue(root);
|
|
54
|
+
if (!rootObject) return undefined;
|
|
55
|
+
const nested = objectValue(ownValue(rootObject, ["transcript"]));
|
|
56
|
+
const session = nested ?? rootObject;
|
|
57
|
+
for (const name of ["segments", "utterances", "items"] as const) {
|
|
58
|
+
const value = ownValue(session, [name]);
|
|
59
|
+
if (Array.isArray(value)) {
|
|
60
|
+
return {
|
|
61
|
+
segments: value,
|
|
62
|
+
pointerName: nested ? `transcript/${name}` : name,
|
|
63
|
+
session,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return undefined;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const sessionDateFields = (
|
|
71
|
+
session: Record<string, unknown>
|
|
72
|
+
): Record<string, string> | undefined => {
|
|
73
|
+
const fields: Record<string, string> = {};
|
|
74
|
+
for (const [name, aliases] of [
|
|
75
|
+
["recorded", ["recordedAt", "recorded_at", "date"]],
|
|
76
|
+
["created", ["createdAt", "created_at"]],
|
|
77
|
+
] as const) {
|
|
78
|
+
const value = scalarText(ownValue(session, aliases));
|
|
79
|
+
if (value) fields[name] = value;
|
|
80
|
+
}
|
|
81
|
+
return Object.keys(fields).length > 0 ? fields : undefined;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export function* parseJsonTranscript(
|
|
85
|
+
text: string
|
|
86
|
+
): Generator<TranscriptParseEvent> {
|
|
87
|
+
let parsed: unknown;
|
|
88
|
+
try {
|
|
89
|
+
parsed = JSON.parse(text);
|
|
90
|
+
} catch {
|
|
91
|
+
const finalCharacter = text.trimEnd().at(-1);
|
|
92
|
+
yield {
|
|
93
|
+
ok: false,
|
|
94
|
+
retryable: finalCharacter !== "}" && finalCharacter !== "]",
|
|
95
|
+
};
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const container = segmentArray(parsed);
|
|
99
|
+
if (!container) {
|
|
100
|
+
yield { ok: false, retryable: false };
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const sessionId = scalarText(
|
|
105
|
+
ownValue(container.session, ["sessionId", "session_id", "id"])
|
|
106
|
+
);
|
|
107
|
+
const sessionTitle = scalarText(
|
|
108
|
+
ownValue(container.session, ["title", "name"])
|
|
109
|
+
);
|
|
110
|
+
const participants = peopleList(
|
|
111
|
+
ownValue(container.session, ["participants", "speakers"])
|
|
112
|
+
);
|
|
113
|
+
const dateFields = sessionDateFields(container.session);
|
|
114
|
+
|
|
115
|
+
for (const [index, candidate] of container.segments.entries()) {
|
|
116
|
+
const pointer = `/${container.pointerName ? `${container.pointerName}/` : ""}${index}`;
|
|
117
|
+
const sourceLocator = `record:${pointer.slice(1) || index}`;
|
|
118
|
+
const record = objectValue(candidate);
|
|
119
|
+
if (!record) {
|
|
120
|
+
yield { ok: false, sourceLocator, retryable: false };
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const rawText = scalarText(
|
|
124
|
+
ownValue(record, ["text", "content", "transcript"])
|
|
125
|
+
);
|
|
126
|
+
if (!rawText) {
|
|
127
|
+
yield { ok: false, sourceLocator, retryable: false };
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const cleaned = cleanTranscriptText(rawText);
|
|
131
|
+
if (!cleaned.text) {
|
|
132
|
+
yield { ok: false, sourceLocator, retryable: false };
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const rawStart = ownValue(record, [
|
|
137
|
+
"start",
|
|
138
|
+
"startTime",
|
|
139
|
+
"start_time",
|
|
140
|
+
"startSeconds",
|
|
141
|
+
]);
|
|
142
|
+
const rawEnd = ownValue(record, [
|
|
143
|
+
"end",
|
|
144
|
+
"endTime",
|
|
145
|
+
"end_time",
|
|
146
|
+
"endSeconds",
|
|
147
|
+
]);
|
|
148
|
+
const start =
|
|
149
|
+
rawStart === undefined ? undefined : parseTranscriptTimestamp(rawStart);
|
|
150
|
+
const end =
|
|
151
|
+
rawEnd === undefined ? undefined : parseTranscriptTimestamp(rawEnd);
|
|
152
|
+
if (
|
|
153
|
+
(rawStart !== undefined && !start) ||
|
|
154
|
+
(rawEnd !== undefined && !end) ||
|
|
155
|
+
(start && end && end.milliseconds < start.milliseconds)
|
|
156
|
+
) {
|
|
157
|
+
yield { ok: false, sourceLocator, retryable: false };
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const speaker =
|
|
162
|
+
personText(
|
|
163
|
+
ownValue(record, ["speaker", "speakerName", "speaker_name", "author"])
|
|
164
|
+
) ?? cleaned.speaker;
|
|
165
|
+
const externalId = scalarText(
|
|
166
|
+
ownValue(record, ["id", "segmentId", "segment_id", "utteranceId"])
|
|
167
|
+
);
|
|
168
|
+
const segment: TranscriptSegment = {
|
|
169
|
+
externalId,
|
|
170
|
+
text: cleaned.text,
|
|
171
|
+
speaker,
|
|
172
|
+
start: start?.text,
|
|
173
|
+
end: end?.text,
|
|
174
|
+
sourceLocator,
|
|
175
|
+
anchorKind: "record",
|
|
176
|
+
anchorValue: pointer,
|
|
177
|
+
sessionId,
|
|
178
|
+
sessionTitle,
|
|
179
|
+
participants,
|
|
180
|
+
dateFields,
|
|
181
|
+
};
|
|
182
|
+
yield { ok: true, segment };
|
|
183
|
+
}
|
|
184
|
+
}
|