@agent-native/core 0.161.1 → 0.161.4
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/corpus/templates/analytics/actions/compose-dashboard.ts +5 -2
- package/corpus/templates/analytics/actions/export-dashboard-panel-to-google-sheet.ts +11 -5
- package/corpus/templates/analytics/actions/migrate-first-party-analytics-to-bigquery.ts +89 -2
- package/corpus/templates/analytics/actions/update-dashboard.ts +14 -2
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/PanelEditorDialog.tsx +6 -0
- package/corpus/templates/analytics/server/lib/dashboard-panel-query.ts +89 -41
- package/corpus/templates/analytics/server/lib/dashboard-panel-source-resolver.ts +8 -3
- package/corpus/templates/analytics/server/lib/error-capture.ts +6 -0
- package/corpus/templates/analytics/server/lib/first-party-analytics-backend.ts +187 -44
- package/corpus/templates/analytics/server/lib/first-party-analytics.ts +44 -16
- package/corpus/templates/clips/actions/save-browser-transcript.ts +20 -4
- package/corpus/templates/clips/app/components/meetings/transcript-bubbles.tsx +167 -35
- package/corpus/templates/clips/desktop/src/lib/transcription-capture.ts +8 -1
- package/corpus/templates/clips/desktop/src/lib/transcription-engine.ts +39 -2
- package/corpus/templates/slides/actions/list-decks.ts +36 -1
- package/corpus/templates/slides/app/context/DeckContext.tsx +17 -6
- package/dist/agent/engine/builder-engine.js +37 -19
- package/dist/agent/engine/types.d.ts +29 -0
- package/dist/agent/engine/types.js +6 -0
- package/dist/agent/production-agent.js +2 -0
- package/dist/agent/run-manager.d.ts +6 -0
- package/dist/agent/run-manager.js +27 -0
- package/dist/agent/run-store.d.ts +5 -5
- package/dist/agent/run-store.js +52 -15
- package/dist/client/AssistantChat.d.ts +1 -0
- package/dist/client/AssistantChat.js +10 -1
- package/dist/db/client.js +10 -2
- package/dist/db/create-get-db.js +42 -0
- package/dist/observability/routes.d.ts +3 -3
- package/dist/provider-api/actions/custom-provider-registration.d.ts +2 -2
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/onboarding-html.js +22 -77
- package/dist/server/poll.d.ts +5 -5
- package/dist/server/poll.js +19 -26
- package/dist/server/release-migrations.js +4 -0
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/dist/shared/auth-copy.d.ts +7 -0
- package/dist/shared/auth-copy.js +77 -0
- package/dist/shared/mcp-embed-headers.js +8 -4
- package/dist/vite/client.js +94 -3
- package/dist/workspace-connections/migrations.d.ts +18 -0
- package/dist/workspace-connections/migrations.js +153 -0
- package/package.json +1 -1
|
@@ -34,6 +34,23 @@ export interface FirstPartyAnalyticsScope {
|
|
|
34
34
|
orgId: string | null;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Stored panel SQL is valid PostgreSQL but has no BigQuery equivalent. This is
|
|
39
|
+
* not a query failure: the panel cannot run for this scope until its SQL or the
|
|
40
|
+
* scope's sink changes, so retrying is pointless and callers render an
|
|
41
|
+
* explanatory state instead. `construct` names the exact syntax that has no
|
|
42
|
+
* mapping, and is the only part safe to show a user.
|
|
43
|
+
*/
|
|
44
|
+
export class FirstPartyAnalyticsUnsupportedSqlError extends Error {
|
|
45
|
+
readonly construct: string;
|
|
46
|
+
|
|
47
|
+
constructor(construct: string, message: string) {
|
|
48
|
+
super(message);
|
|
49
|
+
this.name = "FirstPartyAnalyticsUnsupportedSqlError";
|
|
50
|
+
this.construct = construct;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
37
54
|
interface FirstPartyAnalyticsBackendSetting {
|
|
38
55
|
sink?: unknown;
|
|
39
56
|
table?: unknown;
|
|
@@ -572,6 +589,59 @@ function rewriteOutsideSqlLiterals(
|
|
|
572
589
|
return result;
|
|
573
590
|
}
|
|
574
591
|
|
|
592
|
+
const SQL_LITERAL_PLACEHOLDER_PREFIX = "_fpa_lit_";
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Same intent as `rewriteOutsideSqlLiterals`, but `rewrite` sees one string
|
|
596
|
+
* with each literal stood in for by an identifier-shaped placeholder rather
|
|
597
|
+
* than a sequence of fragments split at every quote. A cast operand routinely
|
|
598
|
+
* sits on the far side of a literal — `'2026-08-01'::date`,
|
|
599
|
+
* `(COALESCE(properties, '{}'))::text` — and the fragment view cuts that
|
|
600
|
+
* operand in half, which is why both read as "invalid PostgreSQL cast".
|
|
601
|
+
*/
|
|
602
|
+
function rewriteWithMaskedSqlLiterals(
|
|
603
|
+
sql: string,
|
|
604
|
+
rewrite: (code: string) => string,
|
|
605
|
+
): string {
|
|
606
|
+
if (sql.includes(SQL_LITERAL_PLACEHOLDER_PREFIX)) {
|
|
607
|
+
throw new FirstPartyAnalyticsUnsupportedSqlError(
|
|
608
|
+
`an identifier reserved for query translation (${SQL_LITERAL_PLACEHOLDER_PREFIX}*)`,
|
|
609
|
+
`First-party BigQuery query cannot use identifiers starting with ${SQL_LITERAL_PLACEHOLDER_PREFIX}`,
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
const literals: string[] = [];
|
|
613
|
+
let masked = "";
|
|
614
|
+
let cursor = 0;
|
|
615
|
+
while (cursor < sql.length) {
|
|
616
|
+
const literalStart = sql.indexOf("'", cursor);
|
|
617
|
+
if (literalStart === -1) {
|
|
618
|
+
masked += sql.slice(cursor);
|
|
619
|
+
break;
|
|
620
|
+
}
|
|
621
|
+
masked += sql.slice(cursor, literalStart);
|
|
622
|
+
let literalEnd = literalStart + 1;
|
|
623
|
+
while (literalEnd < sql.length) {
|
|
624
|
+
if (sql[literalEnd] !== "'") {
|
|
625
|
+
literalEnd++;
|
|
626
|
+
continue;
|
|
627
|
+
}
|
|
628
|
+
if (sql[literalEnd + 1] === "'") {
|
|
629
|
+
literalEnd += 2;
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
literalEnd++;
|
|
633
|
+
break;
|
|
634
|
+
}
|
|
635
|
+
masked += `${SQL_LITERAL_PLACEHOLDER_PREFIX}${literals.length}_`;
|
|
636
|
+
literals.push(sql.slice(literalStart, literalEnd));
|
|
637
|
+
cursor = literalEnd;
|
|
638
|
+
}
|
|
639
|
+
return rewrite(masked).replace(
|
|
640
|
+
new RegExp(`${SQL_LITERAL_PLACEHOLDER_PREFIX}(\\d+)_`, "g"),
|
|
641
|
+
(whole, index: string) => literals[Number(index)] ?? whole,
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
|
|
575
645
|
function findMatchingSqlParen(sql: string, openIndex: number): number {
|
|
576
646
|
let depth = 0;
|
|
577
647
|
let inLiteral = false;
|
|
@@ -697,6 +767,46 @@ function coerceDateComparisonOperands(sql: string): string {
|
|
|
697
767
|
);
|
|
698
768
|
}
|
|
699
769
|
|
|
770
|
+
/**
|
|
771
|
+
* Extent of the operand a `::` cast at `castIndex` applies to.
|
|
772
|
+
*
|
|
773
|
+
* The function-call case is the trap: stopping at the matching `(` leaves the
|
|
774
|
+
* function name outside the rewritten CAST, so `sum(x)::numeric` became
|
|
775
|
+
* `sumCAST((x) AS NUMERIC)` and BigQuery answered `Function not found: SUMCAST`.
|
|
776
|
+
*/
|
|
777
|
+
function postgresCastOperandBounds(
|
|
778
|
+
code: string,
|
|
779
|
+
castIndex: number,
|
|
780
|
+
): { start: number; end: number } {
|
|
781
|
+
let end = castIndex;
|
|
782
|
+
while (end > 0 && /\s/.test(code[end - 1] ?? "")) {
|
|
783
|
+
end--;
|
|
784
|
+
}
|
|
785
|
+
let start = end;
|
|
786
|
+
if (code[end - 1] === ")") {
|
|
787
|
+
let depth = 0;
|
|
788
|
+
for (let index = end - 1; index >= 0; index--) {
|
|
789
|
+
if (code[index] === ")") depth++;
|
|
790
|
+
if (code[index] !== "(") continue;
|
|
791
|
+
depth--;
|
|
792
|
+
if (depth === 0) {
|
|
793
|
+
start = index;
|
|
794
|
+
break;
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
while (start > 0 && /[A-Za-z0-9_.$]/.test(code[start - 1] ?? "")) {
|
|
799
|
+
start--;
|
|
800
|
+
}
|
|
801
|
+
if (start === end) {
|
|
802
|
+
throw new FirstPartyAnalyticsUnsupportedSqlError(
|
|
803
|
+
"a PostgreSQL cast with no readable operand",
|
|
804
|
+
"First-party BigQuery query has an invalid PostgreSQL cast",
|
|
805
|
+
);
|
|
806
|
+
}
|
|
807
|
+
return { start, end };
|
|
808
|
+
}
|
|
809
|
+
|
|
700
810
|
function replacePostgresCastsInCode(code: string): string {
|
|
701
811
|
const castType = new RegExp(
|
|
702
812
|
"::\\s*(date|timestamp|timestamptz|int|int2|int4|int8|integer|float|float4|float8|double\\s+precision|numeric|text|varchar|boolean|bool|json|jsonb)\\b",
|
|
@@ -728,40 +838,16 @@ function replacePostgresCastsInCode(code: string): string {
|
|
|
728
838
|
};
|
|
729
839
|
const targetType = mappedType[normalizedType];
|
|
730
840
|
if (!targetType) {
|
|
731
|
-
throw new
|
|
841
|
+
throw new FirstPartyAnalyticsUnsupportedSqlError(
|
|
842
|
+
`a PostgreSQL ${normalizedType} cast`,
|
|
732
843
|
`First-party BigQuery query does not support PostgreSQL ${normalizedType} casts`,
|
|
733
844
|
);
|
|
734
845
|
}
|
|
735
846
|
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
let operandStart = operandEnd;
|
|
741
|
-
if (result[operandEnd - 1] === ")") {
|
|
742
|
-
let depth = 0;
|
|
743
|
-
for (let index = operandEnd - 1; index >= 0; index--) {
|
|
744
|
-
if (result[index] === ")") depth++;
|
|
745
|
-
if (result[index] !== "(") continue;
|
|
746
|
-
depth--;
|
|
747
|
-
if (depth === 0) {
|
|
748
|
-
operandStart = index;
|
|
749
|
-
break;
|
|
750
|
-
}
|
|
751
|
-
}
|
|
752
|
-
} else {
|
|
753
|
-
while (
|
|
754
|
-
operandStart > 0 &&
|
|
755
|
-
/[A-Za-z0-9_.$]/.test(result[operandStart - 1] ?? "")
|
|
756
|
-
) {
|
|
757
|
-
operandStart--;
|
|
758
|
-
}
|
|
759
|
-
}
|
|
760
|
-
if (operandStart === operandEnd) {
|
|
761
|
-
throw new Error(
|
|
762
|
-
"First-party BigQuery query has an invalid PostgreSQL cast",
|
|
763
|
-
);
|
|
764
|
-
}
|
|
847
|
+
const { start: operandStart, end: operandEnd } = postgresCastOperandBounds(
|
|
848
|
+
result,
|
|
849
|
+
match.index,
|
|
850
|
+
);
|
|
765
851
|
const operand = result.slice(operandStart, operandEnd).trim();
|
|
766
852
|
const castEnd = match.index + match[0].length;
|
|
767
853
|
result = `${result.slice(0, operandStart)}CAST(${operand} AS ${targetType})${result.slice(castEnd)}`;
|
|
@@ -810,12 +896,45 @@ function replaceBigQueryDateArithmetic(code: string): string {
|
|
|
810
896
|
return translated;
|
|
811
897
|
}
|
|
812
898
|
|
|
899
|
+
/**
|
|
900
|
+
* PostgreSQL truncates to the start of the ISO week (Monday); BigQuery's bare
|
|
901
|
+
* `WEEK` starts on Sunday, so the week mapping must name the weekday or the
|
|
902
|
+
* same query silently buckets differently on each backend.
|
|
903
|
+
*/
|
|
904
|
+
const BIGQUERY_DATE_TRUNC_PARTS: Record<string, string> = {
|
|
905
|
+
day: "DAY",
|
|
906
|
+
week: "WEEK(MONDAY)",
|
|
907
|
+
month: "MONTH",
|
|
908
|
+
quarter: "QUARTER",
|
|
909
|
+
year: "YEAR",
|
|
910
|
+
};
|
|
911
|
+
|
|
912
|
+
/**
|
|
913
|
+
* BigQuery JSONPath field names are always quoted here rather than only when
|
|
914
|
+
* they look unusual: an unquoted `$.$ai_model` is rejected outright, and an
|
|
915
|
+
* unquoted `$.page.title` silently reads a nested field the caller never asked
|
|
916
|
+
* for.
|
|
917
|
+
*/
|
|
918
|
+
function bigQueryJsonPath(key: string): string {
|
|
919
|
+
return `'$."${key.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"'`;
|
|
920
|
+
}
|
|
921
|
+
|
|
813
922
|
function translatePostgresJsonOperators(sql: string): string {
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
923
|
+
const jsonExtract = /\s*::\s*jsonb?\s*->>\s*'([^']*)'/gi;
|
|
924
|
+
let result = sql;
|
|
925
|
+
let match = jsonExtract.exec(result);
|
|
926
|
+
while (match) {
|
|
927
|
+
const { start, end } = postgresCastOperandBounds(result, match.index);
|
|
928
|
+
const operand = result.slice(start, end).trim();
|
|
929
|
+
const replacement = `JSON_VALUE(${operand}, ${bigQueryJsonPath(match[1] ?? "")})`;
|
|
930
|
+
result =
|
|
931
|
+
result.slice(0, start) +
|
|
932
|
+
replacement +
|
|
933
|
+
result.slice(match.index + match[0].length);
|
|
934
|
+
jsonExtract.lastIndex = start + replacement.length;
|
|
935
|
+
match = jsonExtract.exec(result);
|
|
936
|
+
}
|
|
937
|
+
return result;
|
|
819
938
|
}
|
|
820
939
|
|
|
821
940
|
function translateFirstPartyAnalyticsBigQuerySql(sql: string): string {
|
|
@@ -837,16 +956,20 @@ function translateFirstPartyAnalyticsBigQuerySql(sql: string): string {
|
|
|
837
956
|
`INTERVAL ${amount} ${unit.replace(/s$/i, "").toUpperCase()}`,
|
|
838
957
|
);
|
|
839
958
|
translated = rewriteSqlFunctionCalls(translated, "date_trunc", (args) => {
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
959
|
+
const unit = /^'([a-z]+)'$/i.exec(args[0] ?? "")?.[1]?.toLowerCase();
|
|
960
|
+
const datePart = unit ? BIGQUERY_DATE_TRUNC_PARTS[unit] : undefined;
|
|
961
|
+
if (args.length !== 2 || !datePart) {
|
|
962
|
+
throw new FirstPartyAnalyticsUnsupportedSqlError(
|
|
963
|
+
`date_trunc(${args[0] ?? "?"}, ...)`,
|
|
964
|
+
`First-party BigQuery query supports PostgreSQL date_trunc with ${Object.keys(BIGQUERY_DATE_TRUNC_PARTS).join(", ")}`,
|
|
843
965
|
);
|
|
844
966
|
}
|
|
845
|
-
return `DATE_TRUNC(CAST(${translatePostgresDateExpression(args[1] ?? "")} AS DATE),
|
|
967
|
+
return `DATE_TRUNC(CAST(${translatePostgresDateExpression(args[1] ?? "")} AS DATE), ${datePart})`;
|
|
846
968
|
});
|
|
847
969
|
translated = rewriteSqlFunctionCalls(translated, "to_char", (args) => {
|
|
848
970
|
if (args.length !== 2 || !/^'YYYY-MM-DD'$/i.test(args[1] ?? "")) {
|
|
849
|
-
throw new
|
|
971
|
+
throw new FirstPartyAnalyticsUnsupportedSqlError(
|
|
972
|
+
`to_char(..., ${args[1] ?? "?"})`,
|
|
850
973
|
"First-party BigQuery query only supports PostgreSQL to_char(..., 'YYYY-MM-DD') expressions",
|
|
851
974
|
);
|
|
852
975
|
}
|
|
@@ -854,7 +977,10 @@ function translateFirstPartyAnalyticsBigQuerySql(sql: string): string {
|
|
|
854
977
|
});
|
|
855
978
|
translated = rewriteSqlFunctionCalls(translated, "chr", (args) => {
|
|
856
979
|
if (args.length !== 1) {
|
|
857
|
-
throw new
|
|
980
|
+
throw new FirstPartyAnalyticsUnsupportedSqlError(
|
|
981
|
+
"a multi-argument chr(...) call",
|
|
982
|
+
"First-party BigQuery query has an invalid chr call",
|
|
983
|
+
);
|
|
858
984
|
}
|
|
859
985
|
return `CHR(${args[0]})`;
|
|
860
986
|
});
|
|
@@ -864,14 +990,15 @@ function translateFirstPartyAnalyticsBigQuerySql(sql: string): string {
|
|
|
864
990
|
translated = rewriteSqlFunctionCalls(translated, "split_part", (args) => {
|
|
865
991
|
const index = Number(args[2]);
|
|
866
992
|
if (args.length !== 3 || !Number.isInteger(index) || index < 1) {
|
|
867
|
-
throw new
|
|
993
|
+
throw new FirstPartyAnalyticsUnsupportedSqlError(
|
|
994
|
+
"split_part(...) with a non-literal or non-positive index",
|
|
868
995
|
"First-party BigQuery query only supports split_part with a positive integer index",
|
|
869
996
|
);
|
|
870
997
|
}
|
|
871
998
|
return `SPLIT(${args[0]}, ${args[1]})[SAFE_OFFSET(${index - 1})]`;
|
|
872
999
|
});
|
|
873
1000
|
}
|
|
874
|
-
translated =
|
|
1001
|
+
translated = rewriteWithMaskedSqlLiterals(translated, (code) => {
|
|
875
1002
|
let rewritten = replacePostgresCastsInCode(code);
|
|
876
1003
|
rewritten = rewritten
|
|
877
1004
|
.replace(/\bnow\s*\(\s*\)/gi, "CURRENT_TIMESTAMP()")
|
|
@@ -893,10 +1020,16 @@ function translateFirstPartyAnalyticsBigQuerySql(sql: string): string {
|
|
|
893
1020
|
[/\bINTERVAL\s*'/i, "PostgreSQL interval literals"],
|
|
894
1021
|
[/\bAT\s+TIME\s+ZONE\b/i, "AT TIME ZONE"],
|
|
895
1022
|
[/\bFILTER\s*\(\s*WHERE\b/i, "FILTER (WHERE ...)"],
|
|
1023
|
+
[/\bDISTINCT\s+ON\b/i, "SELECT DISTINCT ON"],
|
|
1024
|
+
// Anything the JSON translation above could not consume. BigQuery has no
|
|
1025
|
+
// such operators, so leaving it through buys a provider 400 instead of a
|
|
1026
|
+
// rendered explanation.
|
|
1027
|
+
[/->>|->|#>>|@>/, "PostgreSQL JSON operators"],
|
|
896
1028
|
];
|
|
897
1029
|
const incompatible = unsupported.find(([pattern]) => pattern.test(code));
|
|
898
1030
|
if (incompatible) {
|
|
899
|
-
throw new
|
|
1031
|
+
throw new FirstPartyAnalyticsUnsupportedSqlError(
|
|
1032
|
+
incompatible[1],
|
|
900
1033
|
`First-party analytics query uses unsupported PostgreSQL syntax (${incompatible[1]}) after BigQuery translation`,
|
|
901
1034
|
);
|
|
902
1035
|
}
|
|
@@ -977,6 +1110,16 @@ function addPartitionPrunedEventDeduplication(
|
|
|
977
1110
|
return result;
|
|
978
1111
|
}
|
|
979
1112
|
|
|
1113
|
+
/**
|
|
1114
|
+
* Throws `FirstPartyAnalyticsUnsupportedSqlError` when this SQL has no BigQuery
|
|
1115
|
+
* translation. Save-time validation runs on the panel's own (unscoped) SQL,
|
|
1116
|
+
* which is a subset of what the read path translates, so a pass here cannot
|
|
1117
|
+
* pass a construct through that the read path would then reject.
|
|
1118
|
+
*/
|
|
1119
|
+
export function assertFirstPartyAnalyticsBigQuerySql(sql: string): void {
|
|
1120
|
+
translateFirstPartyAnalyticsBigQuerySql(sql);
|
|
1121
|
+
}
|
|
1122
|
+
|
|
980
1123
|
export function renderFirstPartyAnalyticsBigQuerySql(
|
|
981
1124
|
scopedSql: string,
|
|
982
1125
|
args: Array<string | null>,
|
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
type DerivedExceptionFields,
|
|
11
11
|
} from "./error-capture.js";
|
|
12
12
|
import {
|
|
13
|
+
assertFirstPartyAnalyticsBigQuerySql,
|
|
14
|
+
type FirstPartyAnalyticsSink,
|
|
13
15
|
getFirstPartyAnalyticsBackend,
|
|
14
16
|
getFirstPartyAnalyticsTable,
|
|
15
17
|
insertFirstPartyAnalyticsRows,
|
|
@@ -1088,6 +1090,44 @@ function inferSchema(rows: Record<string, unknown>[]): {
|
|
|
1088
1090
|
}));
|
|
1089
1091
|
}
|
|
1090
1092
|
|
|
1093
|
+
/**
|
|
1094
|
+
* Which store this query actually runs against under a given sink. Save-time
|
|
1095
|
+
* validation and the read path both route through here: a panel validated for
|
|
1096
|
+
* one store and executed against the other is the whole bug class.
|
|
1097
|
+
*/
|
|
1098
|
+
function firstPartyAnalyticsQueryTarget(
|
|
1099
|
+
sql: string,
|
|
1100
|
+
sink: FirstPartyAnalyticsSink,
|
|
1101
|
+
): "sql-store" | "bigquery" {
|
|
1102
|
+
if (sink !== "bigquery") return "sql-store";
|
|
1103
|
+
const usesSessionRecordings = /\bsession_recordings\b/i.test(sql);
|
|
1104
|
+
const usesEventTables =
|
|
1105
|
+
/\banalytics_events\b|\banalytics_event_daily_rollups\b|\banalytics_user_days\b/i.test(
|
|
1106
|
+
sql,
|
|
1107
|
+
);
|
|
1108
|
+
if (usesSessionRecordings && usesEventTables) {
|
|
1109
|
+
throw new Error(
|
|
1110
|
+
"Cross-backend joins are not supported; query first-party event tables in BigQuery and session_recordings in the Analytics SQL store separately.",
|
|
1111
|
+
);
|
|
1112
|
+
}
|
|
1113
|
+
return usesSessionRecordings ? "sql-store" : "bigquery";
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/**
|
|
1117
|
+
* Save-time counterpart to `queryFirstPartyAnalytics`. `sink` is a mutable
|
|
1118
|
+
* per-scope setting, so generic PostgreSQL validation alone accepts panels the
|
|
1119
|
+
* live backend cannot execute.
|
|
1120
|
+
*/
|
|
1121
|
+
export async function validateFirstPartyAnalyticsSqlForScope(
|
|
1122
|
+
sql: string,
|
|
1123
|
+
scope: AnalyticsScope,
|
|
1124
|
+
): Promise<void> {
|
|
1125
|
+
validateFirstPartyAnalyticsSql(sql);
|
|
1126
|
+
const backend = await getFirstPartyAnalyticsBackend(scope);
|
|
1127
|
+
if (firstPartyAnalyticsQueryTarget(sql, backend.sink) !== "bigquery") return;
|
|
1128
|
+
assertFirstPartyAnalyticsBigQuerySql(sql);
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1091
1131
|
export async function queryFirstPartyAnalytics(
|
|
1092
1132
|
sql: string,
|
|
1093
1133
|
scope: AnalyticsScope,
|
|
@@ -1095,22 +1135,10 @@ export async function queryFirstPartyAnalytics(
|
|
|
1095
1135
|
): Promise<AnalyticsQueryResult> {
|
|
1096
1136
|
validateFirstPartyAnalyticsSql(sql);
|
|
1097
1137
|
const backend = await getFirstPartyAnalyticsBackend(scope);
|
|
1098
|
-
if (backend.sink === "bigquery") {
|
|
1099
|
-
const
|
|
1100
|
-
const
|
|
1101
|
-
|
|
1102
|
-
sql,
|
|
1103
|
-
);
|
|
1104
|
-
if (usesSessionRecordings && usesEventTables) {
|
|
1105
|
-
throw new Error(
|
|
1106
|
-
"Cross-backend joins are not supported; query first-party event tables in BigQuery and session_recordings in the Analytics SQL store separately.",
|
|
1107
|
-
);
|
|
1108
|
-
}
|
|
1109
|
-
if (!usesSessionRecordings) {
|
|
1110
|
-
const table = await getFirstPartyAnalyticsTable(backend.table);
|
|
1111
|
-
const scoped = scopedAnalyticsSql(sql, scope);
|
|
1112
|
-
return queryFirstPartyAnalyticsInBigQuery(scoped.sql, scoped.args, table);
|
|
1113
|
-
}
|
|
1138
|
+
if (firstPartyAnalyticsQueryTarget(sql, backend.sink) === "bigquery") {
|
|
1139
|
+
const table = await getFirstPartyAnalyticsTable(backend.table);
|
|
1140
|
+
const scoped = scopedAnalyticsSql(sql, scope);
|
|
1141
|
+
return queryFirstPartyAnalyticsInBigQuery(scoped.sql, scoped.args, table);
|
|
1114
1142
|
}
|
|
1115
1143
|
const scoped = scopedAnalyticsSql(sql, scope);
|
|
1116
1144
|
const wrappedSql = `SELECT * FROM (${scoped.sql}) AS first_party_analytics_query LIMIT ${MAX_QUERY_ROWS}`;
|
|
@@ -26,8 +26,19 @@ import { buildCaptionSegmentsFromText } from "../shared/transcript-segments.js";
|
|
|
26
26
|
import { booleanParam } from "./lib/cli-params.js";
|
|
27
27
|
import { isAutoTitleReplaceable } from "./lib/title-source.js";
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
// web-speech and macos-native are both mic-only engines — see
|
|
30
|
+
// transcription-engine.ts's file header. When a caller sends fullText with
|
|
31
|
+
// no segments (word-level timings were never captured), there's no
|
|
32
|
+
// per-line source to preserve, but for these two engines there's also no
|
|
33
|
+
// ambiguity: every word came from the mic. Leaving source undefined here
|
|
34
|
+
// falls through to resolveSpeaker's default and renders the whole thing as
|
|
35
|
+
// "Them". Whisper mixes mic + system, so it has no safe single-speaker guess.
|
|
36
|
+
function nativeSegmentsJson(
|
|
37
|
+
fullText: string,
|
|
38
|
+
engineSource?: "web-speech" | "macos-native" | "whisper",
|
|
39
|
+
): string {
|
|
40
|
+
const source = engineSource && engineSource !== "whisper" ? "mic" : undefined;
|
|
41
|
+
return JSON.stringify(buildCaptionSegmentsFromText(fullText, null, source));
|
|
31
42
|
}
|
|
32
43
|
|
|
33
44
|
// Real transcript segments supplied by a caller that already has accurate
|
|
@@ -45,6 +56,11 @@ const segmentSchema = z
|
|
|
45
56
|
text: z.string(),
|
|
46
57
|
// Stream the segment came from; the transcript UI maps mic→"Me", system→"Them".
|
|
47
58
|
source: z.enum(["mic", "system"]).optional(),
|
|
59
|
+
// Diarized speaker for this segment, when the provider identifies one.
|
|
60
|
+
// Declared so zod keeps it: an undeclared key is stripped before the array
|
|
61
|
+
// is serialized, which would drop a provider's speaker labels on save and
|
|
62
|
+
// leave the transcript unable to tell its speakers apart on reload.
|
|
63
|
+
speaker: z.string().nullable().optional(),
|
|
48
64
|
})
|
|
49
65
|
.transform((s) => {
|
|
50
66
|
if (s.endMs < s.startMs) {
|
|
@@ -74,7 +90,7 @@ export default defineAction({
|
|
|
74
90
|
.array(segmentSchema)
|
|
75
91
|
.optional()
|
|
76
92
|
.describe(
|
|
77
|
-
"
|
|
93
|
+
"Transcript segments with per-segment timings (ms) and the `mic`/`system` stream each came from. Stored verbatim when provided, instead of synthesizing timings from fullText. Timings are the engine's own where it reported them; the mic-only engines report none, so callers may send estimates to keep each segment's speaker.",
|
|
78
94
|
),
|
|
79
95
|
overwriteReady: booleanParam
|
|
80
96
|
.default(false)
|
|
@@ -97,7 +113,7 @@ export default defineAction({
|
|
|
97
113
|
const segmentsJson =
|
|
98
114
|
args.segments && args.segments.length > 0
|
|
99
115
|
? JSON.stringify(args.segments)
|
|
100
|
-
: nativeSegmentsJson(fullText);
|
|
116
|
+
: nativeSegmentsJson(fullText, args.source);
|
|
101
117
|
|
|
102
118
|
const [current] = await db
|
|
103
119
|
.select({
|