@fayz-ai/plugin-scribe 0.10.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/LICENSE +21 -0
- package/dist/components/DraftPanel.d.ts +14 -0
- package/dist/components/DraftPanel.d.ts.map +1 -0
- package/dist/components/ScribeConsentDialog.d.ts +12 -0
- package/dist/components/ScribeConsentDialog.d.ts.map +1 -0
- package/dist/components/ScribeRecordingPill.d.ts +16 -0
- package/dist/components/ScribeRecordingPill.d.ts.map +1 -0
- package/dist/components/ScribeRecoveryBanner.d.ts +8 -0
- package/dist/components/ScribeRecoveryBanner.d.ts.map +1 -0
- package/dist/components/ScribeSessionPage.d.ts +13 -0
- package/dist/components/ScribeSessionPage.d.ts.map +1 -0
- package/dist/components/ScribeShellMount.d.ts +19 -0
- package/dist/components/ScribeShellMount.d.ts.map +1 -0
- package/dist/components/TranscriptPanel.d.ts +10 -0
- package/dist/components/TranscriptPanel.d.ts.map +1 -0
- package/dist/data/supabase.d.ts +63 -0
- package/dist/data/supabase.d.ts.map +1 -0
- package/dist/data/tables.d.ts +31 -0
- package/dist/data/tables.d.ts.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2910 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/config.d.ts +19 -0
- package/dist/lib/config.d.ts.map +1 -0
- package/dist/lib/config.test.d.ts +2 -0
- package/dist/lib/config.test.d.ts.map +1 -0
- package/dist/lib/drain.d.ts +13 -0
- package/dist/lib/drain.d.ts.map +1 -0
- package/dist/lib/generate.d.ts +23 -0
- package/dist/lib/generate.d.ts.map +1 -0
- package/dist/lib/prompt.d.ts +39 -0
- package/dist/lib/prompt.d.ts.map +1 -0
- package/dist/lib/prompt.test.d.ts +2 -0
- package/dist/lib/prompt.test.d.ts.map +1 -0
- package/dist/lib/transport.d.ts +59 -0
- package/dist/lib/transport.d.ts.map +1 -0
- package/dist/locales/en.d.ts +2 -0
- package/dist/locales/en.d.ts.map +1 -0
- package/dist/locales/index.d.ts +2 -0
- package/dist/locales/index.d.ts.map +1 -0
- package/dist/locales/pt-BR.d.ts +2 -0
- package/dist/locales/pt-BR.d.ts.map +1 -0
- package/dist/migrations/index.d.ts +6 -0
- package/dist/migrations/index.d.ts.map +1 -0
- package/dist/runtime/capture.d.ts +84 -0
- package/dist/runtime/capture.d.ts.map +1 -0
- package/dist/runtime/idb.d.ts +121 -0
- package/dist/runtime/idb.d.ts.map +1 -0
- package/dist/runtime/index.d.ts +48 -0
- package/dist/runtime/index.d.ts.map +1 -0
- package/dist/runtime/pump.d.ts +11 -0
- package/dist/runtime/pump.d.ts.map +1 -0
- package/dist/store.d.ts +25 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/types.d.ts +266 -0
- package/dist/types.d.ts.map +1 -0
- package/functions/scribe-generate/index.ts +255 -0
- package/functions/scribe-transcribe/index.ts +455 -0
- package/package.json +58 -0
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SERVER-side session state. Advances only on durable facts: a segment that
|
|
3
|
+
* reached Storage, a transcript that came back. Client state, which can die at
|
|
4
|
+
* any moment, lives separately in `CaptureState`.
|
|
5
|
+
*/
|
|
6
|
+
export type SessionStatus = 'recording' | 'paused' | 'interrupted' | 'uploading' | 'transcribing' | 'ready' | 'generating' | 'completed' | 'abandoned' | 'failed';
|
|
7
|
+
/** States where the session is still someone's — used by the partial index and recovery. */
|
|
8
|
+
export declare const OPEN_SESSION_STATUSES: SessionStatus[];
|
|
9
|
+
/**
|
|
10
|
+
* CLIENT-side capture state. `interrupted` is the honest state between "the mic
|
|
11
|
+
* is gone" and "the user decided what to do" — collapsing it into `paused` would
|
|
12
|
+
* lose the distinction between intent and accident, which the transcript needs
|
|
13
|
+
* in order to record the hole.
|
|
14
|
+
*/
|
|
15
|
+
export type CaptureState = 'idle' | 'consent' | 'requesting' | 'recording' | 'paused' | 'interrupted' | 'stopping' | 'error';
|
|
16
|
+
/** What the encounter is attached to. `standalone` records first, attaches later. */
|
|
17
|
+
export type SessionContextKind = 'person' | 'appointment' | 'standalone';
|
|
18
|
+
/**
|
|
19
|
+
* Legal basis recorded at capture time. Consent is what separates the recording
|
|
20
|
+
* from a problem, and "when" and "how" is what gets proven later.
|
|
21
|
+
* `implied_contract` covers recording as part of a contracted service.
|
|
22
|
+
*/
|
|
23
|
+
export type ConsentMode = 'verbal' | 'written' | 'implied_contract';
|
|
24
|
+
export type UploadState = 'pending' | 'uploading' | 'uploaded' | 'failed' | 'missing';
|
|
25
|
+
export type SttState = 'pending' | 'running' | 'done' | 'failed' | 'skipped';
|
|
26
|
+
export type GenerationStatus = 'pending' | 'running' | 'ready' | 'failed' | 'committed' | 'discarded';
|
|
27
|
+
export interface ScribeSession {
|
|
28
|
+
id: string;
|
|
29
|
+
tenantId: string;
|
|
30
|
+
subjectId?: string;
|
|
31
|
+
subjectName?: string;
|
|
32
|
+
contextKind: SessionContextKind;
|
|
33
|
+
appointmentId?: string;
|
|
34
|
+
ownerUserId: string;
|
|
35
|
+
status: SessionStatus;
|
|
36
|
+
locale: string;
|
|
37
|
+
mimeType?: string;
|
|
38
|
+
startedAt: string;
|
|
39
|
+
endedAt?: string;
|
|
40
|
+
/** Wall clock reported by the client. Moves the pill; not a record. */
|
|
41
|
+
wallDurationMs: number;
|
|
42
|
+
/** SUM(segments.duration_ms) — the only duration that counts for records, limits and billing. */
|
|
43
|
+
audioDurationMs: number;
|
|
44
|
+
segmentCount: number;
|
|
45
|
+
uploadedSegmentCount: number;
|
|
46
|
+
transcribedSegmentCount: number;
|
|
47
|
+
transcriptChars: number;
|
|
48
|
+
consentAt?: string;
|
|
49
|
+
consentMode?: ConsentMode;
|
|
50
|
+
retentionUntil?: string;
|
|
51
|
+
audioDeletedAt?: string;
|
|
52
|
+
error?: string;
|
|
53
|
+
metadata: Record<string, unknown>;
|
|
54
|
+
}
|
|
55
|
+
/** A word, offset RELATIVE to the start of its segment. */
|
|
56
|
+
export interface TranscriptWord {
|
|
57
|
+
/** word */
|
|
58
|
+
w: string;
|
|
59
|
+
/** start ms, relative to the segment */
|
|
60
|
+
s: number;
|
|
61
|
+
/** end ms, relative to the segment */
|
|
62
|
+
e: number;
|
|
63
|
+
/** confidence 0..1 */
|
|
64
|
+
c?: number;
|
|
65
|
+
/** speaker index, when diarized */
|
|
66
|
+
sp?: number;
|
|
67
|
+
}
|
|
68
|
+
export interface ScribeSegment {
|
|
69
|
+
sessionId: string;
|
|
70
|
+
segIndex: number;
|
|
71
|
+
tenantId: string;
|
|
72
|
+
storagePath?: string;
|
|
73
|
+
bytes?: number;
|
|
74
|
+
/** Offset of this segment from the start of the session. */
|
|
75
|
+
startOffsetMs: number;
|
|
76
|
+
/** From the STT response, not a JS clock. */
|
|
77
|
+
durationMs?: number;
|
|
78
|
+
/** Assembled from pieces of a segment interrupted mid-way. */
|
|
79
|
+
partial: boolean;
|
|
80
|
+
/**
|
|
81
|
+
* SYNTHETIC row marking a hole (sleep, mic lost, permission revoked). Carries
|
|
82
|
+
* no audio. It exists so the transcript admits the hole instead of silently
|
|
83
|
+
* splicing two moments together — a medico-legal requirement, not polish.
|
|
84
|
+
*/
|
|
85
|
+
gap: boolean;
|
|
86
|
+
uploadState: UploadState;
|
|
87
|
+
uploadedAt?: string;
|
|
88
|
+
sttState: SttState;
|
|
89
|
+
sttProvider?: string;
|
|
90
|
+
sttAttempts: number;
|
|
91
|
+
sttError?: string;
|
|
92
|
+
text?: string;
|
|
93
|
+
words?: TranscriptWord[];
|
|
94
|
+
confidence?: number;
|
|
95
|
+
}
|
|
96
|
+
export interface ScribeGeneration {
|
|
97
|
+
id: string;
|
|
98
|
+
tenantId: string;
|
|
99
|
+
sessionId: string;
|
|
100
|
+
/** Tenant template row. NULL when the generation came from a code preset. */
|
|
101
|
+
templateId?: string;
|
|
102
|
+
/** Preset key (`beauty.anamnesis.v1`) when there is no row. */
|
|
103
|
+
templateKey?: string;
|
|
104
|
+
tabOrder: number;
|
|
105
|
+
title?: string;
|
|
106
|
+
status: GenerationStatus;
|
|
107
|
+
/** Model output. Written once, NEVER mutated. */
|
|
108
|
+
markdown?: string;
|
|
109
|
+
/** The human's working copy. The difference between the two is what proves review. */
|
|
110
|
+
markdownEdited?: string;
|
|
111
|
+
/** Stage-1 extraction (two-stage). Reserved; the column exists from v0 on purpose. */
|
|
112
|
+
facts?: Record<string, unknown>;
|
|
113
|
+
model?: string;
|
|
114
|
+
promptVersion?: string;
|
|
115
|
+
inputTokens?: number;
|
|
116
|
+
outputTokens?: number;
|
|
117
|
+
error?: string;
|
|
118
|
+
documentId?: string;
|
|
119
|
+
createdBy?: string;
|
|
120
|
+
createdAt: string;
|
|
121
|
+
}
|
|
122
|
+
export type SectionShape = 'prose' | 'bullets' | 'keyvalue';
|
|
123
|
+
export interface NarrativeSectionDef {
|
|
124
|
+
/** Stable machine key. NEVER translated — it outlives heading renames. */
|
|
125
|
+
id: string;
|
|
126
|
+
/** Becomes `## <heading>` in the markdown. Translatable. */
|
|
127
|
+
heading: string;
|
|
128
|
+
/**
|
|
129
|
+
* The one per-section prompt the model sees. Write it as an instruction to a
|
|
130
|
+
* junior: what belongs here, what does not, in which voice.
|
|
131
|
+
*/
|
|
132
|
+
guidance: string;
|
|
133
|
+
/**
|
|
134
|
+
* Controls the SHAPE of the markdown. `keyvalue` exists so structured data
|
|
135
|
+
* comes out as `- **Label:** value` rather than a pipe table — the SDK
|
|
136
|
+
* renderer has no table, and a broken A4 prints garbage.
|
|
137
|
+
*/
|
|
138
|
+
shape?: SectionShape;
|
|
139
|
+
required?: boolean;
|
|
140
|
+
/** Drops the whole section, heading included, when the transcript said nothing. */
|
|
141
|
+
omitWhenEmpty?: boolean;
|
|
142
|
+
maxWords?: number;
|
|
143
|
+
/** Fact keys this section consumes in two-stage mode. Reserved. */
|
|
144
|
+
facts?: string[];
|
|
145
|
+
}
|
|
146
|
+
/** Where each header field is pulled from. Resolved from DATA, never asked of the model. */
|
|
147
|
+
export type NarrativeHeaderSource = 'subject.name' | 'subject.documentNumber' | 'subject.birthDate' | 'session.startedAt' | 'session.durationMinutes' | 'operator.name' | (string & {});
|
|
148
|
+
export interface NarrativeSchema {
|
|
149
|
+
kind: 'narrative';
|
|
150
|
+
sections: NarrativeSectionDef[];
|
|
151
|
+
/** Tone, person, tense, jargon level. Injected ahead of the sections. */
|
|
152
|
+
style?: string;
|
|
153
|
+
/** OUTPUT language, independent of the transcript's. */
|
|
154
|
+
outputLocale?: string;
|
|
155
|
+
/** Block above the first section, resolved from data. */
|
|
156
|
+
header?: {
|
|
157
|
+
fields: Array<{
|
|
158
|
+
label: string;
|
|
159
|
+
source: NarrativeHeaderSource;
|
|
160
|
+
}>;
|
|
161
|
+
};
|
|
162
|
+
/** Footer, e.g. 'AI-generated from a recording, reviewed by the professional.' */
|
|
163
|
+
disclaimer?: string;
|
|
164
|
+
/**
|
|
165
|
+
* Things the model must report as "not stated" instead of inferring, e.g.
|
|
166
|
+
* ['diagnosis', 'dosage', 'ICD']. The line between summarising and inventing.
|
|
167
|
+
*/
|
|
168
|
+
neverInfer?: string[];
|
|
169
|
+
}
|
|
170
|
+
export declare function isNarrativeSchema(schema: unknown): schema is NarrativeSchema;
|
|
171
|
+
/**
|
|
172
|
+
* A system template, defined in CODE rather than in a SQL seed. Fixing a prompt
|
|
173
|
+
* becomes a release instead of a migration editing a row the user may have
|
|
174
|
+
* changed, and the default does not eat the tenant's template quota. Customising
|
|
175
|
+
* forks it, and the fork is the row.
|
|
176
|
+
*/
|
|
177
|
+
export interface NarrativeTemplatePreset {
|
|
178
|
+
/** Versioned in the key itself: `beauty.anamnesis.v1`. */
|
|
179
|
+
key: string;
|
|
180
|
+
name: string;
|
|
181
|
+
description?: string;
|
|
182
|
+
/** plugin-forms category: anamnesis | evolution | report | contract | general. */
|
|
183
|
+
category: string;
|
|
184
|
+
specialty?: string;
|
|
185
|
+
schema: NarrativeSchema;
|
|
186
|
+
}
|
|
187
|
+
export interface ScribeLabels {
|
|
188
|
+
/** "Appointment" / "Visit" / "Meeting" */
|
|
189
|
+
sessionSingular: string;
|
|
190
|
+
sessionPlural: string;
|
|
191
|
+
/** "Start appointment" */
|
|
192
|
+
start: string;
|
|
193
|
+
pause: string;
|
|
194
|
+
resume: string;
|
|
195
|
+
finish: string;
|
|
196
|
+
discard: string;
|
|
197
|
+
/** "Generate document" / "Generate draft" */
|
|
198
|
+
generate: string;
|
|
199
|
+
/** "Client" / "Patient" */
|
|
200
|
+
subject: string;
|
|
201
|
+
transcript: string;
|
|
202
|
+
settingsTitle: string;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Where the model credential lives. See `lib/transport.ts` for why there are
|
|
206
|
+
* exactly these two values and no third.
|
|
207
|
+
*/
|
|
208
|
+
export type ScribeTransportKind = 'fayz' | 'edge';
|
|
209
|
+
export interface ScribeSttConfig {
|
|
210
|
+
/**
|
|
211
|
+
* Defaults to `'fayz'`: the platform API, the same broker the app assistant
|
|
212
|
+
* already uses — no secret and no deploy on the app side. `'edge'` keeps the
|
|
213
|
+
* own-edge-function path, for anyone running off-platform or needing a
|
|
214
|
+
* provider the broker does not offer.
|
|
215
|
+
*/
|
|
216
|
+
transport?: ScribeTransportKind;
|
|
217
|
+
/**
|
|
218
|
+
* Provider behind a one-method seam — swapping must not be a refactor. The
|
|
219
|
+
* platform broker speaks OpenAI; Deepgram requires `transport: 'edge'`.
|
|
220
|
+
*/
|
|
221
|
+
provider: 'deepgram' | 'openai';
|
|
222
|
+
/**
|
|
223
|
+
* Explicit, with no hidden default, because the choice shows: `whisper-1`
|
|
224
|
+
* returns duration and per-word offsets, while the `gpt-4o-*-transcribe`
|
|
225
|
+
* models are usually better at pt-BR but return text only — and then the
|
|
226
|
+
* authoritative duration has to come from somewhere else.
|
|
227
|
+
*/
|
|
228
|
+
model?: string;
|
|
229
|
+
locale: string;
|
|
230
|
+
/** Only Deepgram diarizes today; ignored on the `fayz` transport. */
|
|
231
|
+
diarize?: boolean;
|
|
232
|
+
/**
|
|
233
|
+
* Transcription edge function name. REQUIRED when `transport: 'edge'`: with no
|
|
234
|
+
* transport configured the plugin refuses to boot, because the only remaining
|
|
235
|
+
* fallback would be the browser's native recognizer, which ships the session
|
|
236
|
+
* audio to an undeclared third party. See the invariant in `createScribePlugin`.
|
|
237
|
+
*/
|
|
238
|
+
endpoint?: string;
|
|
239
|
+
}
|
|
240
|
+
export interface ScribeRetentionConfig {
|
|
241
|
+
/** Days until the audio may be deleted. Transcript and document survive. */
|
|
242
|
+
audioDays: number;
|
|
243
|
+
/** When true, no byte is recorded before the consent gate. */
|
|
244
|
+
requireConsent: boolean;
|
|
245
|
+
defaultConsentMode: ConsentMode;
|
|
246
|
+
}
|
|
247
|
+
export interface ScribeLimitsConfig {
|
|
248
|
+
minutesMonth?: string;
|
|
249
|
+
documentsMonth?: string;
|
|
250
|
+
}
|
|
251
|
+
export interface ScribePluginOptions {
|
|
252
|
+
/** `kind` of the `people` row allowed as subject ('customer', 'client', …). */
|
|
253
|
+
subjectKind?: string;
|
|
254
|
+
contextEntities?: string[];
|
|
255
|
+
labels?: Partial<ScribeLabels>;
|
|
256
|
+
/** The vertical's default templates — the ONLY new code a vertical writes. */
|
|
257
|
+
templatePresets?: NarrativeTemplatePreset[];
|
|
258
|
+
stt: ScribeSttConfig;
|
|
259
|
+
retention?: Partial<ScribeRetentionConfig>;
|
|
260
|
+
limits?: ScribeLimitsConfig;
|
|
261
|
+
/** Generation edge function name. */
|
|
262
|
+
generateEndpoint?: string;
|
|
263
|
+
scope?: 'universal' | 'vertical';
|
|
264
|
+
verticalId?: string;
|
|
265
|
+
}
|
|
266
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AASA;;;;GAIG;AACH,MAAM,MAAM,aAAa,GACrB,WAAW,GACX,QAAQ,GACR,aAAa,GACb,WAAW,GACX,cAAc,GACd,OAAO,GACP,YAAY,GACZ,WAAW,GACX,WAAW,GACX,QAAQ,CAAA;AAEZ,4FAA4F;AAC5F,eAAO,MAAM,qBAAqB,EAAE,aAAa,EAEhD,CAAA;AAED;;;;;GAKG;AACH,MAAM,MAAM,YAAY,GACpB,MAAM,GACN,SAAS,GACT,YAAY,GACZ,WAAW,GACX,QAAQ,GACR,aAAa,GACb,UAAU,GACV,OAAO,CAAA;AAEX,qFAAqF;AACrF,MAAM,MAAM,kBAAkB,GAAG,QAAQ,GAAG,aAAa,GAAG,YAAY,CAAA;AAExE;;;;GAIG;AACH,MAAM,MAAM,WAAW,GAAG,QAAQ,GAAG,SAAS,GAAG,kBAAkB,CAAA;AAEnE,MAAM,MAAM,WAAW,GAAG,SAAS,GAAG,WAAW,GAAG,UAAU,GAAG,QAAQ,GAAG,SAAS,CAAA;AACrF,MAAM,MAAM,QAAQ,GAAG,SAAS,GAAG,SAAS,GAAG,MAAM,GAAG,QAAQ,GAAG,SAAS,CAAA;AAC5E,MAAM,MAAM,gBAAgB,GAAG,SAAS,GAAG,SAAS,GAAG,OAAO,GAAG,QAAQ,GAAG,WAAW,GAAG,WAAW,CAAA;AAMrG,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,MAAM,CAAA;IACV,QAAQ,EAAE,MAAM,CAAA;IAChB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,WAAW,EAAE,kBAAkB,CAAA;IAC/B,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,WAAW,EAAE,MAAM,CAAA;IACnB,MAAM,EAAE,aAAa,CAAA;IACrB,MAAM,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,SAAS,EAAE,MAAM,CAAA;IACjB,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,uEAAuE;IACvE,cAAc,EAAE,MAAM,CAAA;IACtB,iGAAiG;IACjG,eAAe,EAAE,MAAM,CAAA;IACvB,YAAY,EAAE,MAAM,CAAA;IACpB,oBAAoB,EAAE,MAAM,CAAA;IAC5B,uBAAuB,EAAE,MAAM,CAAA;IAC/B,eAAe,EAAE,MAAM,CAAA;IACvB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,WAAW,CAAC,EAAE,WAAW,CAAA;IACzB,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAClC;AAED,2DAA2D;AAC3D,MAAM,WAAW,cAAc;IAC7B,WAAW;IACX,CAAC,EAAE,MAAM,CAAA;IACT,wCAAwC;IACxC,CAAC,EAAE,MAAM,CAAA;IACT,sCAAsC;IACtC,CAAC,EAAE,MAAM,CAAA;IACT,sBAAsB;IACtB,CAAC,CAAC,EAAE,MAAM,CAAA;IACV,mCAAmC;IACnC,EAAE,CAAC,EAAE,MAAM,CAAA;CACZ;AAED,MAAM,WAAW,aAAa;IAC5B,SAAS,EAAE,MAAM,CAAA;IACjB,QAAQ,EAAE,MAAM,CAAA;IAChB,QAAQ,EAAE,MAAM,CAAA;IAChB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,4DAA4D;IAC5D,aAAa,EAAE,MAAM,CAAA;IACrB,6CAA6C;IAC7C,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,8DAA8D;IAC9D,OAAO,EAAE,OAAO,CAAA;IAChB;;;;OAIG;IACH,GAAG,EAAE,OAAO,CAAA;IACZ,WAAW,EAAE,WAAW,CAAA;IACxB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,QAAQ,EAAE,QAAQ,CAAA;IAClB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,WAAW,EAAE,MAAM,CAAA;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,KAAK,CAAC,EAAE,cAAc,EAAE,CAAA;IACxB,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB;AAED,MAAM,WAAW,gBAAgB;IAC/B,EAAE,EAAE,MAAM,CAAA;IACV,QAAQ,EAAE,MAAM,CAAA;IAChB,SAAS,EAAE,MAAM,CAAA;IACjB,6EAA6E;IAC7E,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,+DAA+D;IAC/D,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,QAAQ,EAAE,MAAM,CAAA;IAChB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,MAAM,EAAE,gBAAgB,CAAA;IACxB,iDAAiD;IACjD,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,sFAAsF;IACtF,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,sFAAsF;IACtF,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IAC/B,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,SAAS,EAAE,MAAM,CAAA;CAClB;AAWD,MAAM,MAAM,YAAY,GAAG,OAAO,GAAG,SAAS,GAAG,UAAU,CAAA;AAE3D,MAAM,WAAW,mBAAmB;IAClC,0EAA0E;IAC1E,EAAE,EAAE,MAAM,CAAA;IACV,4DAA4D;IAC5D,OAAO,EAAE,MAAM,CAAA;IACf;;;OAGG;IACH,QAAQ,EAAE,MAAM,CAAA;IAChB;;;;OAIG;IACH,KAAK,CAAC,EAAE,YAAY,CAAA;IACpB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,mFAAmF;IACnF,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,mEAAmE;IACnE,KAAK,CAAC,EAAE,MAAM,EAAE,CAAA;CACjB;AAED,4FAA4F;AAC5F,MAAM,MAAM,qBAAqB,GAC7B,cAAc,GACd,wBAAwB,GACxB,mBAAmB,GACnB,mBAAmB,GACnB,yBAAyB,GACzB,eAAe,GACf,CAAC,MAAM,GAAG,EAAE,CAAC,CAAA;AAEjB,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,WAAW,CAAA;IACjB,QAAQ,EAAE,mBAAmB,EAAE,CAAA;IAC/B,yEAAyE;IACzE,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,wDAAwD;IACxD,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,yDAAyD;IACzD,MAAM,CAAC,EAAE;QAAE,MAAM,EAAE,KAAK,CAAC;YAAE,KAAK,EAAE,MAAM,CAAC;YAAC,MAAM,EAAE,qBAAqB,CAAA;SAAE,CAAC,CAAA;KAAE,CAAA;IAC5E,kFAAkF;IAClF,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,EAAE,CAAA;CACtB;AAED,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,OAAO,GAAG,MAAM,IAAI,eAAe,CAE5E;AAED;;;;;GAKG;AACH,MAAM,WAAW,uBAAuB;IACtC,0DAA0D;IAC1D,GAAG,EAAE,MAAM,CAAA;IACX,IAAI,EAAE,MAAM,CAAA;IACZ,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,kFAAkF;IAClF,QAAQ,EAAE,MAAM,CAAA;IAChB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,MAAM,EAAE,eAAe,CAAA;CACxB;AAMD,MAAM,WAAW,YAAY;IAC3B,0CAA0C;IAC1C,eAAe,EAAE,MAAM,CAAA;IACvB,aAAa,EAAE,MAAM,CAAA;IACrB,0BAA0B;IAC1B,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;IACd,MAAM,EAAE,MAAM,CAAA;IACd,OAAO,EAAE,MAAM,CAAA;IACf,6CAA6C;IAC7C,QAAQ,EAAE,MAAM,CAAA;IAChB,2BAA2B;IAC3B,OAAO,EAAE,MAAM,CAAA;IACf,UAAU,EAAE,MAAM,CAAA;IAClB,aAAa,EAAE,MAAM,CAAA;CACtB;AAED;;;GAGG;AACH,MAAM,MAAM,mBAAmB,GAAG,MAAM,GAAG,MAAM,CAAA;AAEjD,MAAM,WAAW,eAAe;IAC9B;;;;;OAKG;IACH,SAAS,CAAC,EAAE,mBAAmB,CAAA;IAC/B;;;OAGG;IACH,QAAQ,EAAE,UAAU,GAAG,QAAQ,CAAA;IAC/B;;;;;OAKG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,MAAM,EAAE,MAAM,CAAA;IACd,qEAAqE;IACrE,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB;AAED,MAAM,WAAW,qBAAqB;IACpC,4EAA4E;IAC5E,SAAS,EAAE,MAAM,CAAA;IACjB,8DAA8D;IAC9D,cAAc,EAAE,OAAO,CAAA;IACvB,kBAAkB,EAAE,WAAW,CAAA;CAChC;AAED,MAAM,WAAW,kBAAkB;IACjC,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,cAAc,CAAC,EAAE,MAAM,CAAA;CACxB;AAED,MAAM,WAAW,mBAAmB;IAClC,+EAA+E;IAC/E,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,eAAe,CAAC,EAAE,MAAM,EAAE,CAAA;IAC1B,MAAM,CAAC,EAAE,OAAO,CAAC,YAAY,CAAC,CAAA;IAC9B,8EAA8E;IAC9E,eAAe,CAAC,EAAE,uBAAuB,EAAE,CAAA;IAC3C,GAAG,EAAE,eAAe,CAAA;IACpB,SAAS,CAAC,EAAE,OAAO,CAAC,qBAAqB,CAAC,CAAA;IAC1C,MAAM,CAAC,EAAE,kBAAkB,CAAA;IAC3B,qCAAqC;IACrC,gBAAgB,CAAC,EAAE,MAAM,CAAA;IACzB,KAAK,CAAC,EAAE,WAAW,GAAG,UAAU,CAAA;IAChC,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB"}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
// scribe-generate — transcrição + template narrativo → documento em Markdown.
|
|
2
|
+
//
|
|
3
|
+
// Env: OPENAI_API_KEY (ou ANTHROPIC_API_KEY), SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY.
|
|
4
|
+
//
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Por que isto NÃO é uma tool do assistente de chat
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// O turn loop do chat tem teto de 4 rodadas, vive num hook React que morre na
|
|
9
|
+
// navegação, executa no plano do cliente, e persiste tudo que passa por ele no
|
|
10
|
+
// histórico de conversa do broker. Gerar um documento clínico precisa do
|
|
11
|
+
// oposto em todos os quatro pontos: sem teto de rodada, sem depender de aba,
|
|
12
|
+
// no servidor, e sem despejar 9k tokens de transcrição de saúde num store de
|
|
13
|
+
// conversa que ninguém revisou.
|
|
14
|
+
//
|
|
15
|
+
// Além disso a geração precisa ser AUDITÁVEL (modelo, versão de prompt, tokens)
|
|
16
|
+
// e REPETÍVEL. Um turno de chat não é nem um nem outro.
|
|
17
|
+
//
|
|
18
|
+
// O assistente entra depois, na tela do rascunho, para reescrever UMA seção —
|
|
19
|
+
// isso sim cabe folgado em 4 rodadas.
|
|
20
|
+
//
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// callModel: um ponto, um dia trocável
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Toda chamada de modelo passa por `callModel`. Quando o broker Fayz expuser um
|
|
25
|
+
// endpoint headless (`POST /agents/complete`, sem conversa persistida), a troca
|
|
26
|
+
// é o corpo desta função e nenhum app volta a segurar chave de modelo.
|
|
27
|
+
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.45.0'
|
|
28
|
+
import {
|
|
29
|
+
PROMPT_VERSION,
|
|
30
|
+
buildSystemPrompt,
|
|
31
|
+
buildUserPrompt,
|
|
32
|
+
deriveTitle,
|
|
33
|
+
renderTranscript,
|
|
34
|
+
type TranscriptLine,
|
|
35
|
+
} from '../../src/lib/prompt.ts'
|
|
36
|
+
import type { NarrativeSchema } from '../../src/types.ts'
|
|
37
|
+
|
|
38
|
+
const corsHeaders = {
|
|
39
|
+
'Access-Control-Allow-Origin': '*',
|
|
40
|
+
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const DEFAULT_MODEL = 'gpt-4o'
|
|
44
|
+
|
|
45
|
+
interface ModelResult {
|
|
46
|
+
text: string
|
|
47
|
+
model: string
|
|
48
|
+
inputTokens?: number
|
|
49
|
+
outputTokens?: number
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function callModel(args: { system: string; user: string; model?: string }): Promise<ModelResult> {
|
|
53
|
+
const key = Deno.env.get('OPENAI_API_KEY')
|
|
54
|
+
if (!key) throw new Error('OPENAI_API_KEY não configurada')
|
|
55
|
+
const model = args.model ?? Deno.env.get('SCRIBE_MODEL') ?? DEFAULT_MODEL
|
|
56
|
+
|
|
57
|
+
const res = await fetch('https://api.openai.com/v1/chat/completions', {
|
|
58
|
+
method: 'POST',
|
|
59
|
+
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
|
|
60
|
+
body: JSON.stringify({
|
|
61
|
+
model,
|
|
62
|
+
messages: [
|
|
63
|
+
{ role: 'system', content: args.system },
|
|
64
|
+
{ role: 'user', content: args.user },
|
|
65
|
+
],
|
|
66
|
+
// Baixa mas não zero: documento clínico quer consistência, e temperatura
|
|
67
|
+
// zero deixa o modelo repetitivo em texto longo.
|
|
68
|
+
temperature: 0.2,
|
|
69
|
+
max_tokens: 4000,
|
|
70
|
+
}),
|
|
71
|
+
})
|
|
72
|
+
if (!res.ok) throw new Error(`modelo ${res.status}: ${await res.text()}`)
|
|
73
|
+
|
|
74
|
+
const json = await res.json()
|
|
75
|
+
return {
|
|
76
|
+
text: json?.choices?.[0]?.message?.content ?? '',
|
|
77
|
+
model,
|
|
78
|
+
inputTokens: json?.usage?.prompt_tokens,
|
|
79
|
+
outputTokens: json?.usage?.completion_tokens,
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Monta a transcrição na ordem dos segmentos. Buraco entra como linha, não é
|
|
85
|
+
* pulado — a costura invisível é o modo de falha que este design recusa.
|
|
86
|
+
*/
|
|
87
|
+
async function loadTranscript(supabase: any, sessionId: string): Promise<TranscriptLine[]> {
|
|
88
|
+
const { data } = await supabase
|
|
89
|
+
.from('plg_scribe_segments')
|
|
90
|
+
.select('seg_index, start_offset_ms, text, gap, words')
|
|
91
|
+
.eq('session_id', sessionId)
|
|
92
|
+
.order('seg_index', { ascending: true })
|
|
93
|
+
if (!data) return []
|
|
94
|
+
|
|
95
|
+
return data.map((s: any) => ({
|
|
96
|
+
startOffsetMs: s.start_offset_ms ?? 0,
|
|
97
|
+
text: s.text ?? '',
|
|
98
|
+
gap: !!s.gap,
|
|
99
|
+
speaker: Array.isArray(s.words) && s.words.length > 0 ? s.words[0]?.sp : undefined,
|
|
100
|
+
}))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Resolve o schema: linha do tenant (forkada) ou preset vindo do cliente. Preset
|
|
105
|
+
* de sistema de propósito NÃO tem linha — ele não deve consumir a cota de
|
|
106
|
+
* templates do plano, e corrigir seu prompt deve ser release, não migration.
|
|
107
|
+
*/
|
|
108
|
+
async function resolveSchema(
|
|
109
|
+
supabase: any,
|
|
110
|
+
templateId: string | undefined,
|
|
111
|
+
inlineSchema: NarrativeSchema | undefined,
|
|
112
|
+
): Promise<{ schema: NarrativeSchema; name: string }> {
|
|
113
|
+
if (templateId) {
|
|
114
|
+
const { data } = await supabase
|
|
115
|
+
.from('plg_forms_templates')
|
|
116
|
+
.select('name, schema')
|
|
117
|
+
.eq('id', templateId)
|
|
118
|
+
.single()
|
|
119
|
+
if (data?.schema?.kind === 'narrative') return { schema: data.schema, name: data.name }
|
|
120
|
+
throw new Error('template não é narrativo')
|
|
121
|
+
}
|
|
122
|
+
if (inlineSchema?.kind === 'narrative') return { schema: inlineSchema, name: 'Documento' }
|
|
123
|
+
throw new Error('nenhum template informado')
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
Deno.serve(async (req: Request) => {
|
|
127
|
+
if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })
|
|
128
|
+
|
|
129
|
+
const supabase = createClient(
|
|
130
|
+
Deno.env.get('SUPABASE_URL') ?? '',
|
|
131
|
+
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '',
|
|
132
|
+
{ auth: { persistSession: false } },
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
let generationId: string | null = null
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
const body = await req.json()
|
|
139
|
+
const sessionId = body.sessionId as string
|
|
140
|
+
if (!sessionId) throw new Error('sessionId é obrigatório')
|
|
141
|
+
|
|
142
|
+
// Mesma checagem do scribe-transcribe: a service key contorna a RLS, então
|
|
143
|
+
// a autorização é explícita aqui.
|
|
144
|
+
const jwt = (req.headers.get('Authorization') ?? '').replace(/^Bearer\s+/i, '')
|
|
145
|
+
if (!jwt) return json({ error: 'não autenticado' }, 401)
|
|
146
|
+
const { data: caller } = await supabase.auth.getUser(jwt)
|
|
147
|
+
if (!caller?.user) return json({ error: 'não autenticado' }, 401)
|
|
148
|
+
|
|
149
|
+
const { data: session } = await supabase
|
|
150
|
+
.from('plg_scribe_sessions')
|
|
151
|
+
.select('id, tenant_id, subject_id, started_at, audio_duration_ms, locale')
|
|
152
|
+
.eq('id', sessionId)
|
|
153
|
+
.single()
|
|
154
|
+
if (!session) return json({ error: 'sessão não encontrada' }, 404)
|
|
155
|
+
|
|
156
|
+
const { data: membership } = await supabase
|
|
157
|
+
.schema('saas_core')
|
|
158
|
+
.from('tenant_members')
|
|
159
|
+
.select('user_id')
|
|
160
|
+
.eq('tenant_id', session.tenant_id)
|
|
161
|
+
.eq('user_id', caller.user.id)
|
|
162
|
+
.maybeSingle()
|
|
163
|
+
if (!membership) return json({ error: 'acesso negado' }, 403)
|
|
164
|
+
|
|
165
|
+
const { schema, name } = await resolveSchema(supabase, body.templateId, body.schema)
|
|
166
|
+
|
|
167
|
+
const lines = await loadTranscript(supabase, sessionId)
|
|
168
|
+
const hasAudio = lines.some((l) => !l.gap && l.text.trim())
|
|
169
|
+
if (!hasAudio) return json({ error: 'não há transcrição para gerar o documento' }, 422)
|
|
170
|
+
|
|
171
|
+
// A linha nasce em `running` ANTES da chamada ao modelo: uma geração que
|
|
172
|
+
// trava sem deixar rastro é indistinguível de uma que nunca foi pedida.
|
|
173
|
+
const { data: created, error: insErr } = await supabase
|
|
174
|
+
.from('plg_scribe_generations')
|
|
175
|
+
.insert({
|
|
176
|
+
tenant_id: session.tenant_id,
|
|
177
|
+
session_id: sessionId,
|
|
178
|
+
template_id: body.templateId ?? null,
|
|
179
|
+
template_key: body.templateKey ?? null,
|
|
180
|
+
tab_order: body.tabOrder ?? 0,
|
|
181
|
+
status: 'running',
|
|
182
|
+
prompt_version: PROMPT_VERSION,
|
|
183
|
+
created_by: caller.user.id,
|
|
184
|
+
})
|
|
185
|
+
.select('id')
|
|
186
|
+
.single()
|
|
187
|
+
if (insErr || !created) throw new Error(`falha ao criar a geração: ${insErr?.message}`)
|
|
188
|
+
generationId = created.id
|
|
189
|
+
|
|
190
|
+
let subjectName: string | undefined
|
|
191
|
+
if (session.subject_id) {
|
|
192
|
+
const { data: person } = await supabase
|
|
193
|
+
.from('people')
|
|
194
|
+
.select('name')
|
|
195
|
+
.eq('id', session.subject_id)
|
|
196
|
+
.maybeSingle()
|
|
197
|
+
subjectName = person?.name
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const result = await callModel({
|
|
201
|
+
system: buildSystemPrompt(schema),
|
|
202
|
+
user: buildUserPrompt({
|
|
203
|
+
schema,
|
|
204
|
+
transcript: renderTranscript(lines),
|
|
205
|
+
context: {
|
|
206
|
+
Titular: subjectName,
|
|
207
|
+
Data: new Date(session.started_at).toLocaleDateString(session.locale ?? 'pt-BR'),
|
|
208
|
+
'Duração (min)': String(Math.round((session.audio_duration_ms ?? 0) / 60000)),
|
|
209
|
+
},
|
|
210
|
+
}),
|
|
211
|
+
model: body.model,
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
const markdown = result.text.trim()
|
|
215
|
+
|
|
216
|
+
await supabase
|
|
217
|
+
.from('plg_scribe_generations')
|
|
218
|
+
.update({
|
|
219
|
+
status: 'ready',
|
|
220
|
+
// `markdown` é escrito uma vez e nunca mais. A edição do humano vai
|
|
221
|
+
// para `markdown_edited`, e a diferença entre os dois é a prova de que
|
|
222
|
+
// alguém revisou.
|
|
223
|
+
markdown,
|
|
224
|
+
title: deriveTitle(markdown, name),
|
|
225
|
+
model: result.model,
|
|
226
|
+
input_tokens: result.inputTokens,
|
|
227
|
+
output_tokens: result.outputTokens,
|
|
228
|
+
updated_at: new Date().toISOString(),
|
|
229
|
+
})
|
|
230
|
+
.eq('id', generationId)
|
|
231
|
+
|
|
232
|
+
await supabase
|
|
233
|
+
.from('plg_scribe_sessions')
|
|
234
|
+
.update({ status: 'generating', updated_at: new Date().toISOString() })
|
|
235
|
+
.eq('id', sessionId)
|
|
236
|
+
|
|
237
|
+
return json({ ok: true, generationId, markdown, model: result.model })
|
|
238
|
+
} catch (err) {
|
|
239
|
+
const message = String((err as Error)?.message ?? err)
|
|
240
|
+
if (generationId) {
|
|
241
|
+
await supabase
|
|
242
|
+
.from('plg_scribe_generations')
|
|
243
|
+
.update({ status: 'failed', error: message.slice(0, 500), updated_at: new Date().toISOString() })
|
|
244
|
+
.eq('id', generationId)
|
|
245
|
+
}
|
|
246
|
+
return json({ error: message }, 500)
|
|
247
|
+
}
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
function json(payload: unknown, status = 200): Response {
|
|
251
|
+
return new Response(JSON.stringify(payload), {
|
|
252
|
+
status,
|
|
253
|
+
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
|
254
|
+
})
|
|
255
|
+
}
|