@dbx-tools/appkit-generator 0.0.0-stage → 0.9.136
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 +202 -0
- package/README.md +181 -2
- package/index.ts +44 -0
- package/lib/index.d.ts +40 -0
- package/lib/index.js +30 -0
- package/lib/src/assets.d.ts +33 -0
- package/lib/src/assets.js +224 -0
- package/lib/src/config.d.ts +75 -0
- package/lib/src/config.js +31 -0
- package/lib/src/dashboard.d.ts +30 -0
- package/lib/src/dashboard.js +257 -0
- package/lib/src/data.d.ts +17 -0
- package/lib/src/data.js +114 -0
- package/lib/src/defaults.d.ts +39 -0
- package/lib/src/defaults.js +36 -0
- package/lib/src/generate.d.ts +51 -0
- package/lib/src/generate.js +163 -0
- package/lib/src/genie.d.ts +51 -0
- package/lib/src/genie.js +84 -0
- package/lib/src/kpi.d.ts +22 -0
- package/lib/src/kpi.js +74 -0
- package/lib/src/manifest.json +72 -0
- package/lib/src/namespace.d.ts +36 -0
- package/lib/src/namespace.js +119 -0
- package/lib/src/operations.d.ts +42 -0
- package/lib/src/operations.js +96 -0
- package/lib/src/palette.d.ts +10 -0
- package/lib/src/palette.js +69 -0
- package/lib/src/plan.d.ts +21 -0
- package/lib/src/plan.js +33 -0
- package/lib/src/plugin.d.ts +619 -0
- package/lib/src/plugin.js +205 -0
- package/lib/src/research.d.ts +78 -0
- package/lib/src/research.js +235 -0
- package/lib/src/schema.d.ts +52 -0
- package/lib/src/schema.js +82 -0
- package/lib/src/structured.d.ts +27 -0
- package/lib/src/structured.js +30 -0
- package/lib/src/workflow.d.ts +314 -0
- package/lib/src/workflow.js +171 -0
- package/lib/tsconfig.tsbuildinfo +1 -0
- package/package.json +218 -4
- package/src/assets.ts +248 -0
- package/src/config.ts +98 -0
- package/src/dashboard.ts +295 -0
- package/src/data.ts +167 -0
- package/src/defaults.ts +61 -0
- package/src/generate.ts +250 -0
- package/src/genie.ts +111 -0
- package/src/kpi.ts +106 -0
- package/src/manifest.json +72 -0
- package/src/namespace.ts +192 -0
- package/src/operations.ts +171 -0
- package/src/palette.ts +81 -0
- package/src/plan.ts +59 -0
- package/src/plugin.ts +244 -0
- package/src/research.ts +382 -0
- package/src/schema.ts +114 -0
- package/src/structured.ts +54 -0
- package/src/workflow.ts +221 -0
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AppKit ToolProvider plugin for governed Databricks demo generation.
|
|
3
|
+
*
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { defineManifest, toPlugin, type IAppRouter } from "@databricks/appkit";
|
|
8
|
+
import { defineTool, type ToolRegistry } from "@databricks/appkit/beta";
|
|
9
|
+
import { pluginRegistry, ToolRegistryPlugin } from "@dbx-tools/appkit";
|
|
10
|
+
import { mastra } from "@dbx-tools/appkit-mastra";
|
|
11
|
+
import { webSearch } from "@dbx-tools/appkit-web-search";
|
|
12
|
+
import { errorUtils, log } from "@dbx-tools/shared-core";
|
|
13
|
+
import {
|
|
14
|
+
GENERATOR_ROUTES,
|
|
15
|
+
GeneratorClientConfigSchema,
|
|
16
|
+
GeneratorPluginClient,
|
|
17
|
+
BrandGenerationEventSchema,
|
|
18
|
+
BrandGenerationRequestSchema,
|
|
19
|
+
type BrandGenerationEvent,
|
|
20
|
+
} from "@dbx-tools/shared-generator/brand";
|
|
21
|
+
import { KpiResearchRequestSchema, KpiResearchResultSchema } from "@dbx-tools/shared-generator/kpi";
|
|
22
|
+
import {
|
|
23
|
+
CreateGeneratorPresentationRequestSchema,
|
|
24
|
+
GeneratedAssetsSchema,
|
|
25
|
+
GeneratedDashboardSchema,
|
|
26
|
+
GeneratedGenieSpaceSchema,
|
|
27
|
+
GeneratedSchemaSchema,
|
|
28
|
+
GeneratorPlanSchema,
|
|
29
|
+
MaterializeGeneratorDataRequestSchema,
|
|
30
|
+
MaterializedGeneratorDataSchema,
|
|
31
|
+
PlanGeneratorDataRequestSchema,
|
|
32
|
+
ProvisionGeneratorSchemaRequestSchema,
|
|
33
|
+
} from "@dbx-tools/shared-generator/workflow";
|
|
34
|
+
import {
|
|
35
|
+
resolveGeneratorConfig,
|
|
36
|
+
type GeneratorPluginConfig,
|
|
37
|
+
type ResolvedGeneratorConfig,
|
|
38
|
+
} from "./config.ts";
|
|
39
|
+
import manifest from "./manifest.json" with { type: "json" };
|
|
40
|
+
import { GeneratorOperations } from "./operations.ts";
|
|
41
|
+
import type { BrandResearchClient } from "./research.ts";
|
|
42
|
+
import { GENERATE_BRAND_TOOL_DESCRIPTION } from "./schema.ts";
|
|
43
|
+
import { createGeneratorWorkflow, type GeneratorWorkflow } from "./workflow.ts";
|
|
44
|
+
|
|
45
|
+
const logger = log.logger("generator");
|
|
46
|
+
const WORKFLOW_KEY = "generatorDemoAssets";
|
|
47
|
+
|
|
48
|
+
/** AppKit plugin exposing reusable generator tools and a hosted Mastra workflow. */
|
|
49
|
+
export class GeneratorPlugin extends ToolRegistryPlugin<GeneratorPluginConfig> {
|
|
50
|
+
static manifest = defineManifest<"generator">(manifest);
|
|
51
|
+
|
|
52
|
+
private readonly resolved: ResolvedGeneratorConfig = resolveGeneratorConfig(this.config);
|
|
53
|
+
private operations: GeneratorOperations | undefined;
|
|
54
|
+
private workflow: GeneratorWorkflow | undefined;
|
|
55
|
+
|
|
56
|
+
/** AppKit mount prefix used by browser clients. */
|
|
57
|
+
private get basePath(): string {
|
|
58
|
+
return `/api/${this.name}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Native tools exposed independently of the composed workflow. */
|
|
62
|
+
protected get toolRegistry(): ToolRegistry {
|
|
63
|
+
const operations = this.requireOperations();
|
|
64
|
+
const mutation = { effect: "write" as const, destructive: true, requiresUserContext: true };
|
|
65
|
+
return {
|
|
66
|
+
generate_brand: defineTool({
|
|
67
|
+
description: GENERATE_BRAND_TOOL_DESCRIPTION,
|
|
68
|
+
schema: BrandGenerationRequestSchema,
|
|
69
|
+
annotations: { effect: "read", requiresUserContext: true },
|
|
70
|
+
autoInheritable: false,
|
|
71
|
+
execute: (input, signal) =>
|
|
72
|
+
operations.generateBrand(BrandGenerationRequestSchema.parse(input), signal),
|
|
73
|
+
}),
|
|
74
|
+
research_kpis: defineTool({
|
|
75
|
+
description: "Research evidence-backed KPIs for company demo questions.",
|
|
76
|
+
schema: KpiResearchRequestSchema,
|
|
77
|
+
annotations: { effect: "read", requiresUserContext: true },
|
|
78
|
+
autoInheritable: false,
|
|
79
|
+
execute: (input, signal) =>
|
|
80
|
+
operations.researchKpis(KpiResearchRequestSchema.parse(input), signal),
|
|
81
|
+
}),
|
|
82
|
+
plan_demo_data: defineTool({
|
|
83
|
+
description: "Build a typed deterministic data plan from brand and KPI research.",
|
|
84
|
+
schema: PlanGeneratorDataRequestSchema,
|
|
85
|
+
annotations: { effect: "read", requiresUserContext: true },
|
|
86
|
+
autoInheritable: false,
|
|
87
|
+
execute: (input, signal) =>
|
|
88
|
+
operations.planData(PlanGeneratorDataRequestSchema.parse(input), signal),
|
|
89
|
+
}),
|
|
90
|
+
provision_schema: defineTool({
|
|
91
|
+
description: "Provision and grant the active user's deterministic Unity Catalog schema.",
|
|
92
|
+
schema: ProvisionGeneratorSchemaRequestSchema,
|
|
93
|
+
annotations: mutation,
|
|
94
|
+
autoInheritable: false,
|
|
95
|
+
execute: (input, signal) =>
|
|
96
|
+
operations.provisionSchema(ProvisionGeneratorSchemaRequestSchema.parse(input), signal),
|
|
97
|
+
}),
|
|
98
|
+
materialize_data: defineTool({
|
|
99
|
+
description: "Create generated Delta data and validate every planned KPI.",
|
|
100
|
+
schema: MaterializeGeneratorDataRequestSchema,
|
|
101
|
+
annotations: mutation,
|
|
102
|
+
autoInheritable: false,
|
|
103
|
+
execute: (input, signal) =>
|
|
104
|
+
operations.materializeData(MaterializeGeneratorDataRequestSchema.parse(input), signal),
|
|
105
|
+
}),
|
|
106
|
+
create_dashboard: defineTool({
|
|
107
|
+
description: "Create and publish an AI/BI dashboard with automatic companion Genie.",
|
|
108
|
+
schema: CreateGeneratorPresentationRequestSchema,
|
|
109
|
+
annotations: mutation,
|
|
110
|
+
autoInheritable: false,
|
|
111
|
+
execute: (input, signal) =>
|
|
112
|
+
operations.createDashboard(CreateGeneratorPresentationRequestSchema.parse(input), signal),
|
|
113
|
+
}),
|
|
114
|
+
create_genie_space: defineTool({
|
|
115
|
+
description: "Create a standalone Genie space when no dashboard is requested.",
|
|
116
|
+
schema: CreateGeneratorPresentationRequestSchema,
|
|
117
|
+
annotations: mutation,
|
|
118
|
+
autoInheritable: false,
|
|
119
|
+
execute: (input, signal) =>
|
|
120
|
+
operations.createGenie(CreateGeneratorPresentationRequestSchema.parse(input), signal),
|
|
121
|
+
}),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Capture sibling capabilities and register the workflow before Mastra boots. */
|
|
126
|
+
override async setup(): Promise<void> {
|
|
127
|
+
this.operations = new GeneratorOperations({
|
|
128
|
+
config: this.resolved,
|
|
129
|
+
researchClient: this.researchClient(),
|
|
130
|
+
});
|
|
131
|
+
this.workflow = createGeneratorWorkflow(this.operations);
|
|
132
|
+
pluginRegistry
|
|
133
|
+
.require(this.context, mastra, this)
|
|
134
|
+
.exports()
|
|
135
|
+
.registerWorkflow(WORKFLOW_KEY, this.workflow);
|
|
136
|
+
logger.info("ready", {
|
|
137
|
+
model: this.resolved.model ?? "automatic",
|
|
138
|
+
catalog: this.resolved.catalog ?? "brand-only",
|
|
139
|
+
warehouse: this.resolved.warehouseId ?? "brand-only",
|
|
140
|
+
workflow: WORKFLOW_KEY,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
shutdown(): void {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
override clientConfig() {
|
|
149
|
+
return GeneratorClientConfigSchema.parse({ basePath: this.basePath });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Stream standalone brand generation to the existing Brand page. */
|
|
153
|
+
override injectRoutes(router: IAppRouter): void {
|
|
154
|
+
this.route(router, {
|
|
155
|
+
name: "generateBrand",
|
|
156
|
+
method: "post",
|
|
157
|
+
path: GENERATOR_ROUTES.generateBrand,
|
|
158
|
+
handler: async (req, res) => {
|
|
159
|
+
const parsed = BrandGenerationRequestSchema.safeParse(req.body);
|
|
160
|
+
if (!parsed.success) {
|
|
161
|
+
res.status(400).json({ error: "Enter a brand name and an optional HTTPS website." });
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
res.set({
|
|
165
|
+
"Cache-Control": "no-cache, no-transform",
|
|
166
|
+
Connection: "keep-alive",
|
|
167
|
+
"Content-Type": "text/event-stream",
|
|
168
|
+
"X-Accel-Buffering": "no",
|
|
169
|
+
});
|
|
170
|
+
res.flushHeaders();
|
|
171
|
+
let closed = false;
|
|
172
|
+
const controller = new AbortController();
|
|
173
|
+
res.on("close", () => {
|
|
174
|
+
closed = true;
|
|
175
|
+
controller.abort();
|
|
176
|
+
});
|
|
177
|
+
const send = (event: BrandGenerationEvent) => {
|
|
178
|
+
if (closed) return;
|
|
179
|
+
const validated = BrandGenerationEventSchema.parse(event);
|
|
180
|
+
res.write(`event: ${validated.type}\ndata: ${JSON.stringify(validated)}\n\n`);
|
|
181
|
+
};
|
|
182
|
+
try {
|
|
183
|
+
const generated = await this.asUser(req)
|
|
184
|
+
.requireOperations()
|
|
185
|
+
.generateBrand(parsed.data, controller.signal, send);
|
|
186
|
+
send({ type: "result", package: generated });
|
|
187
|
+
} catch (cause) {
|
|
188
|
+
logger.warn("brand-route-failed", { error: errorUtils.toError(cause) });
|
|
189
|
+
send({
|
|
190
|
+
type: "error",
|
|
191
|
+
message:
|
|
192
|
+
"Brand generation failed. Retry the request; if it fails again, check the server logs.",
|
|
193
|
+
});
|
|
194
|
+
} finally {
|
|
195
|
+
if (!closed) res.end();
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Programmatic operations and workflow for trusted application code. */
|
|
202
|
+
override exports() {
|
|
203
|
+
return {
|
|
204
|
+
generateBrand: this.requireOperations().generateBrand.bind(this.requireOperations()),
|
|
205
|
+
researchKpis: this.requireOperations().researchKpis.bind(this.requireOperations()),
|
|
206
|
+
planData: this.requireOperations().planData.bind(this.requireOperations()),
|
|
207
|
+
provisionSchema: this.requireOperations().provisionSchema.bind(this.requireOperations()),
|
|
208
|
+
materializeData: this.requireOperations().materializeData.bind(this.requireOperations()),
|
|
209
|
+
createDashboard: this.requireOperations().createDashboard.bind(this.requireOperations()),
|
|
210
|
+
createGenie: this.requireOperations().createGenie.bind(this.requireOperations()),
|
|
211
|
+
workflow: this.workflow,
|
|
212
|
+
schemas: {
|
|
213
|
+
kpiResearch: KpiResearchResultSchema,
|
|
214
|
+
plan: GeneratorPlanSchema,
|
|
215
|
+
location: GeneratedSchemaSchema,
|
|
216
|
+
materialized: MaterializedGeneratorDataSchema,
|
|
217
|
+
dashboard: GeneratedDashboardSchema,
|
|
218
|
+
genie: GeneratedGenieSpaceSchema,
|
|
219
|
+
assets: GeneratedAssetsSchema,
|
|
220
|
+
},
|
|
221
|
+
client: GeneratorPluginClient,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Resolve the sibling web-search plugin without recreating its policy. */
|
|
226
|
+
private researchClient(): BrandResearchClient {
|
|
227
|
+
const sibling = pluginRegistry.require(this.context, webSearch, this).exports();
|
|
228
|
+
return {
|
|
229
|
+
search: sibling.search,
|
|
230
|
+
fetch: sibling.fetch,
|
|
231
|
+
fetchAssetPage: sibling.fetchAssetPage,
|
|
232
|
+
fetchImage: sibling.fetchImage,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Initialized operations or a clear lifecycle error. */
|
|
237
|
+
private requireOperations(): GeneratorOperations {
|
|
238
|
+
if (!this.operations) throw new Error("Generator plugin setup has not completed");
|
|
239
|
+
return this.operations;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Register AppKit generator tools and workflow integration. */
|
|
244
|
+
export const generator = toPlugin(GeneratorPlugin);
|
package/src/research.ts
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Brand research composed from the web-search plugin's search and fetch
|
|
3
|
+
* operations.
|
|
4
|
+
*
|
|
5
|
+
* @module
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
WebAssetPageFetchRequest,
|
|
10
|
+
WebImageFetchRequest,
|
|
11
|
+
WebImageFetchResult,
|
|
12
|
+
} from "@dbx-tools/appkit-web-search/fetch";
|
|
13
|
+
import type {
|
|
14
|
+
WebFetchRequest,
|
|
15
|
+
WebFetchResult,
|
|
16
|
+
WebSearchCitation,
|
|
17
|
+
WebSearchRequest,
|
|
18
|
+
WebSearchResult,
|
|
19
|
+
} from "@dbx-tools/appkit-web-search/schema";
|
|
20
|
+
import { errorUtils, log, stringUtils } from "@dbx-tools/shared-core";
|
|
21
|
+
import { discoverMobileAppVisuals } from "./assets.ts";
|
|
22
|
+
import {
|
|
23
|
+
brandRequestSubject,
|
|
24
|
+
type BrandGenerationProgressEvent,
|
|
25
|
+
type BrandGenerationRequest,
|
|
26
|
+
type BrandPackageSource,
|
|
27
|
+
type BrandVisualReference,
|
|
28
|
+
} from "./schema.ts";
|
|
29
|
+
|
|
30
|
+
const logger = log.logger("brand/research");
|
|
31
|
+
|
|
32
|
+
/** Default number of cited pages fetched for one brand. */
|
|
33
|
+
export const DEFAULT_MAX_SOURCES = 4;
|
|
34
|
+
|
|
35
|
+
/** Default character cap for each fetched page excerpt. */
|
|
36
|
+
export const DEFAULT_SOURCE_MAX_LENGTH = 8_000;
|
|
37
|
+
|
|
38
|
+
/** Upper bound that prevents one request from fanning out across too many sites. */
|
|
39
|
+
export const MAX_SOURCES = 8;
|
|
40
|
+
|
|
41
|
+
/** Upper bound on readable characters retained from one fetched page. */
|
|
42
|
+
export const MAX_SOURCE_MAX_LENGTH = 20_000;
|
|
43
|
+
|
|
44
|
+
/** Markup cap used for official and mobile page asset discovery. */
|
|
45
|
+
export const ASSET_HTML_MAX_LENGTH = 1_500_000;
|
|
46
|
+
|
|
47
|
+
/** Search and fetch operations supplied by the registered web-search plugin. */
|
|
48
|
+
export interface BrandResearchClient {
|
|
49
|
+
/** Search the web and return a synthesized answer with citations. */
|
|
50
|
+
search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>;
|
|
51
|
+
/** Fetch one cited page as text or HTML. */
|
|
52
|
+
fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult>;
|
|
53
|
+
/** Fetch expanded markup for deterministic in-process asset discovery. */
|
|
54
|
+
fetchAssetPage?(request: WebAssetPageFetchRequest, signal?: AbortSignal): Promise<WebFetchResult>;
|
|
55
|
+
/** Fetch one image for multimodal research and package export. */
|
|
56
|
+
fetchImage(request: WebImageFetchRequest, signal?: AbortSignal): Promise<WebImageFetchResult>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Research options controlling bounded page reads. */
|
|
60
|
+
export interface BrandResearchOptions {
|
|
61
|
+
/** Maximum number of unique sources to fetch. */
|
|
62
|
+
maxSources?: number;
|
|
63
|
+
/** Maximum text characters retained from each source. */
|
|
64
|
+
sourceMaxLength?: number;
|
|
65
|
+
/** Receives observable workflow progress. */
|
|
66
|
+
onProgress?: BrandProgressReporter;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Callback receiving one validated workflow progress event. */
|
|
70
|
+
export type BrandProgressReporter = (event: BrandGenerationProgressEvent) => void | Promise<void>;
|
|
71
|
+
|
|
72
|
+
/** One selected source and the text made available to synthesis. */
|
|
73
|
+
export interface BrandResearchSource extends BrandPackageSource {
|
|
74
|
+
/** Readable page text, omitted when the fetch failed. */
|
|
75
|
+
content?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Search answer, fetched sources, and official-page markup used by generation. */
|
|
79
|
+
export interface BrandResearch {
|
|
80
|
+
/** Web-search model's evidence summary. */
|
|
81
|
+
answer: string;
|
|
82
|
+
/** Endpoint or fallback that ran the web search. */
|
|
83
|
+
model: string;
|
|
84
|
+
/** Selected and deduplicated citations. */
|
|
85
|
+
sources: BrandResearchSource[];
|
|
86
|
+
/** URL of the first source selected as the likely official page. */
|
|
87
|
+
officialPageUrl: string;
|
|
88
|
+
/** Raw markup used only for deterministic asset discovery. */
|
|
89
|
+
officialPageHtml?: string;
|
|
90
|
+
/** iOS and Android app-store references used as supplemental visual research. */
|
|
91
|
+
visualReferences: BrandResearchVisualReference[];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Fetched image retained in memory for multimodal synthesis. */
|
|
95
|
+
export interface BrandResearchVisualImage {
|
|
96
|
+
url: string;
|
|
97
|
+
contentType?: string;
|
|
98
|
+
data?: Uint8Array;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Mobile-store page plus downloaded visual references. */
|
|
102
|
+
export interface BrandResearchVisualReference extends Omit<BrandVisualReference, "images"> {
|
|
103
|
+
images: BrandResearchVisualImage[];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Use expanded asset markup when supplied, retaining compatibility with direct clients. */
|
|
107
|
+
function fetchAssetPage(
|
|
108
|
+
client: BrandResearchClient,
|
|
109
|
+
request: WebAssetPageFetchRequest,
|
|
110
|
+
signal?: AbortSignal,
|
|
111
|
+
): Promise<WebFetchResult> {
|
|
112
|
+
return client.fetchAssetPage
|
|
113
|
+
? client.fetchAssetPage(request, signal)
|
|
114
|
+
: client.fetch({ ...request, format: "html" }, signal);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Build the focused, first-party search request for a brand. */
|
|
118
|
+
export function brandResearchQuery(request: BrandGenerationRequest): string {
|
|
119
|
+
const subject = brandRequestSubject(request);
|
|
120
|
+
const website = request.website ? ` The known official website is ${request.website}.` : "";
|
|
121
|
+
return stringUtils.toDescription(`
|
|
122
|
+
Research the official brand identity and design direction for "${subject}".${website}
|
|
123
|
+
The broader brief is: ${request.prompt ?? subject}.
|
|
124
|
+
Prefer first-party sources and cite the official website first. Identify
|
|
125
|
+
the canonical and short names, current tagline, plain-language
|
|
126
|
+
description, audiences, writing tone, brand colors as hex values,
|
|
127
|
+
typography, documentation, source repository, logo, icon, and favicon.
|
|
128
|
+
Distinguish documented facts from reasonable visual inferences.
|
|
129
|
+
`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Build a targeted mobile-store search query for visual references. */
|
|
133
|
+
function mobileStoreQuery(
|
|
134
|
+
request: BrandGenerationRequest,
|
|
135
|
+
platform: BrandVisualReference["platform"],
|
|
136
|
+
): string {
|
|
137
|
+
const subject = brandRequestSubject(request);
|
|
138
|
+
const site = platform === "ios" ? "apps.apple.com" : "play.google.com/store/apps";
|
|
139
|
+
return `site:${site} ${JSON.stringify(subject)} official app`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Find one matching store page and download representative images. */
|
|
143
|
+
async function researchMobileStore(
|
|
144
|
+
request: BrandGenerationRequest,
|
|
145
|
+
platform: BrandVisualReference["platform"],
|
|
146
|
+
client: BrandResearchClient,
|
|
147
|
+
signal?: AbortSignal,
|
|
148
|
+
): Promise<BrandResearchVisualReference | undefined> {
|
|
149
|
+
try {
|
|
150
|
+
const search = await client.search({ query: mobileStoreQuery(request, platform) }, signal);
|
|
151
|
+
const expectedHost = platform === "ios" ? "apps.apple.com" : "play.google.com";
|
|
152
|
+
const pageUrl = search.citations
|
|
153
|
+
.map(({ url }) => publicHttpsUrl(url))
|
|
154
|
+
.find((url) => url && new URL(url).hostname === expectedHost);
|
|
155
|
+
if (!pageUrl) return undefined;
|
|
156
|
+
const page = await fetchAssetPage(
|
|
157
|
+
client,
|
|
158
|
+
{ url: pageUrl, maxLength: ASSET_HTML_MAX_LENGTH },
|
|
159
|
+
signal,
|
|
160
|
+
);
|
|
161
|
+
const discovered = discoverMobileAppVisuals(page.content, page.url);
|
|
162
|
+
const images = await Promise.all(
|
|
163
|
+
discovered.imageUrls.slice(0, 4).map(async (url): Promise<BrandResearchVisualImage> => {
|
|
164
|
+
try {
|
|
165
|
+
const image = await client.fetchImage({ url }, signal);
|
|
166
|
+
return {
|
|
167
|
+
url: image.url,
|
|
168
|
+
contentType: image.contentType,
|
|
169
|
+
data: image.data,
|
|
170
|
+
};
|
|
171
|
+
} catch (error) {
|
|
172
|
+
logger.warn("mobile-image-fetch-failed", {
|
|
173
|
+
platform,
|
|
174
|
+
url,
|
|
175
|
+
error: errorUtils.errorMessage(error),
|
|
176
|
+
});
|
|
177
|
+
return { url };
|
|
178
|
+
}
|
|
179
|
+
}),
|
|
180
|
+
);
|
|
181
|
+
return {
|
|
182
|
+
platform,
|
|
183
|
+
appName: discovered.appName,
|
|
184
|
+
pageUrl: page.url,
|
|
185
|
+
images,
|
|
186
|
+
};
|
|
187
|
+
} catch (error) {
|
|
188
|
+
logger.warn("mobile-store-research-failed", {
|
|
189
|
+
platform,
|
|
190
|
+
error: errorUtils.errorMessage(error),
|
|
191
|
+
});
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Return a normalized public HTTPS URL or `undefined`. */
|
|
197
|
+
function publicHttpsUrl(value: string): string | undefined {
|
|
198
|
+
try {
|
|
199
|
+
const url = new URL(value);
|
|
200
|
+
return url.protocol === "https:" ? url.toString() : undefined;
|
|
201
|
+
} catch {
|
|
202
|
+
return undefined;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Compute a small first-party likelihood score while preserving search order. */
|
|
207
|
+
function sourceScore(
|
|
208
|
+
citation: WebSearchCitation,
|
|
209
|
+
request: BrandGenerationRequest,
|
|
210
|
+
index: number,
|
|
211
|
+
): number {
|
|
212
|
+
const url = new URL(citation.url);
|
|
213
|
+
const subject = brandRequestSubject(request);
|
|
214
|
+
const knownWebsite = request.website ? new URL(request.website) : undefined;
|
|
215
|
+
const normalizedName = subject.toLowerCase().replace(/[^a-z\d]/g, "");
|
|
216
|
+
const normalizedHost = url.hostname.toLowerCase().replace(/[^a-z\d]/g, "");
|
|
217
|
+
const sameKnownOrigin = knownWebsite?.origin === url.origin ? 1_000 : 0;
|
|
218
|
+
const hostMatchesName =
|
|
219
|
+
normalizedName.length >= 3 &&
|
|
220
|
+
(normalizedHost.includes(normalizedName) || normalizedName.includes(normalizedHost))
|
|
221
|
+
? 100
|
|
222
|
+
: 0;
|
|
223
|
+
const titleMatchesName = citation.title?.toLowerCase().includes(subject.toLowerCase()) ? 10 : 0;
|
|
224
|
+
return sameKnownOrigin + hostMatchesName + titleMatchesName - index;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Select unique HTTPS citations and put the likely official page first. */
|
|
228
|
+
function selectSources(
|
|
229
|
+
citations: readonly WebSearchCitation[],
|
|
230
|
+
request: BrandGenerationRequest,
|
|
231
|
+
maxSources: number,
|
|
232
|
+
): WebSearchCitation[] {
|
|
233
|
+
const subject = brandRequestSubject(request);
|
|
234
|
+
const candidates = request.website
|
|
235
|
+
? [{ url: request.website, title: `${subject} official website` }, ...citations]
|
|
236
|
+
: [...citations];
|
|
237
|
+
const unique = new Map<string, WebSearchCitation>();
|
|
238
|
+
for (const citation of candidates) {
|
|
239
|
+
const url = publicHttpsUrl(citation.url);
|
|
240
|
+
if (url && !unique.has(url)) unique.set(url, { ...citation, url });
|
|
241
|
+
}
|
|
242
|
+
return [...unique.values()]
|
|
243
|
+
.map((citation, index) => ({ citation, score: sourceScore(citation, request, index) }))
|
|
244
|
+
.sort((left, right) => right.score - left.score)
|
|
245
|
+
.slice(0, maxSources)
|
|
246
|
+
.map(({ citation }) => citation);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Fetch one citation as readable text without failing the whole research pass. */
|
|
250
|
+
async function fetchSource(
|
|
251
|
+
client: BrandResearchClient,
|
|
252
|
+
citation: WebSearchCitation,
|
|
253
|
+
sourceMaxLength: number,
|
|
254
|
+
onComplete: () => void | Promise<void>,
|
|
255
|
+
signal?: AbortSignal,
|
|
256
|
+
): Promise<BrandResearchSource> {
|
|
257
|
+
try {
|
|
258
|
+
const page = await client.fetch(
|
|
259
|
+
{ url: citation.url, format: "text", maxLength: sourceMaxLength },
|
|
260
|
+
signal,
|
|
261
|
+
);
|
|
262
|
+
return {
|
|
263
|
+
...citation,
|
|
264
|
+
url: page.url,
|
|
265
|
+
...(page.title ? { title: page.title } : {}),
|
|
266
|
+
fetched: true,
|
|
267
|
+
content: page.content,
|
|
268
|
+
};
|
|
269
|
+
} catch (error) {
|
|
270
|
+
logger.warn("source-fetch-failed", {
|
|
271
|
+
url: citation.url,
|
|
272
|
+
error: errorUtils.errorMessage(error),
|
|
273
|
+
});
|
|
274
|
+
return { ...citation, fetched: false };
|
|
275
|
+
} finally {
|
|
276
|
+
await onComplete();
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Search for a brand, fetch a bounded set of cited pages, and retain the first
|
|
282
|
+
* page's markup for logo and favicon discovery.
|
|
283
|
+
*/
|
|
284
|
+
export async function researchBrand(
|
|
285
|
+
request: BrandGenerationRequest,
|
|
286
|
+
client: BrandResearchClient,
|
|
287
|
+
options: BrandResearchOptions = {},
|
|
288
|
+
signal?: AbortSignal,
|
|
289
|
+
): Promise<BrandResearch> {
|
|
290
|
+
const subject = brandRequestSubject(request);
|
|
291
|
+
const maxSources = Math.min(Math.max(1, options.maxSources ?? DEFAULT_MAX_SOURCES), MAX_SOURCES);
|
|
292
|
+
const sourceMaxLength = Math.max(
|
|
293
|
+
1,
|
|
294
|
+
Math.min(options.sourceMaxLength ?? DEFAULT_SOURCE_MAX_LENGTH, MAX_SOURCE_MAX_LENGTH),
|
|
295
|
+
);
|
|
296
|
+
await options.onProgress?.({
|
|
297
|
+
type: "progress",
|
|
298
|
+
stage: "search",
|
|
299
|
+
message: `Searching for official ${subject} brand sources`,
|
|
300
|
+
});
|
|
301
|
+
const [search, iosReference, androidReference] = await Promise.all([
|
|
302
|
+
client.search({ query: brandResearchQuery(request) }, signal),
|
|
303
|
+
researchMobileStore(request, "ios", client, signal),
|
|
304
|
+
researchMobileStore(request, "android", client, signal),
|
|
305
|
+
]);
|
|
306
|
+
const visualReferences = [iosReference, androidReference].filter(
|
|
307
|
+
(reference): reference is BrandResearchVisualReference => reference !== undefined,
|
|
308
|
+
);
|
|
309
|
+
await options.onProgress?.({
|
|
310
|
+
type: "progress",
|
|
311
|
+
stage: "visuals",
|
|
312
|
+
message: `Reviewed ${visualReferences.length} mobile app references`,
|
|
313
|
+
current: visualReferences.length,
|
|
314
|
+
total: 2,
|
|
315
|
+
});
|
|
316
|
+
const citations = selectSources(search.citations, request, maxSources);
|
|
317
|
+
if (citations.length === 0) {
|
|
318
|
+
throw new Error(`Brand research for "${subject}" returned no public HTTPS sources to fetch.`);
|
|
319
|
+
}
|
|
320
|
+
let completedSources = 0;
|
|
321
|
+
await options.onProgress?.({
|
|
322
|
+
type: "progress",
|
|
323
|
+
stage: "fetch",
|
|
324
|
+
message: `Reading 0 of ${citations.length} sources`,
|
|
325
|
+
current: 0,
|
|
326
|
+
total: citations.length,
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
const official = citations[0];
|
|
330
|
+
const [sources, officialHtml] = await Promise.all([
|
|
331
|
+
Promise.all(
|
|
332
|
+
citations.map((citation) =>
|
|
333
|
+
fetchSource(
|
|
334
|
+
client,
|
|
335
|
+
citation,
|
|
336
|
+
sourceMaxLength,
|
|
337
|
+
async () => {
|
|
338
|
+
completedSources += 1;
|
|
339
|
+
await options.onProgress?.({
|
|
340
|
+
type: "progress",
|
|
341
|
+
stage: "fetch",
|
|
342
|
+
message: `Read ${completedSources} of ${citations.length} sources`,
|
|
343
|
+
current: completedSources,
|
|
344
|
+
total: citations.length,
|
|
345
|
+
});
|
|
346
|
+
},
|
|
347
|
+
signal,
|
|
348
|
+
),
|
|
349
|
+
),
|
|
350
|
+
),
|
|
351
|
+
fetchAssetPage(
|
|
352
|
+
client,
|
|
353
|
+
{
|
|
354
|
+
url: official.url,
|
|
355
|
+
maxLength: ASSET_HTML_MAX_LENGTH,
|
|
356
|
+
},
|
|
357
|
+
signal,
|
|
358
|
+
).catch((error: unknown) => {
|
|
359
|
+
logger.warn("official-page-html-fetch-failed", {
|
|
360
|
+
url: official.url,
|
|
361
|
+
error: errorUtils.errorMessage(error),
|
|
362
|
+
});
|
|
363
|
+
return undefined;
|
|
364
|
+
}),
|
|
365
|
+
]);
|
|
366
|
+
await options.onProgress?.({
|
|
367
|
+
type: "progress",
|
|
368
|
+
stage: "assets",
|
|
369
|
+
message: officialHtml
|
|
370
|
+
? "Found official page artwork"
|
|
371
|
+
: "Using the official site favicon fallback",
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
return {
|
|
375
|
+
answer: search.answer,
|
|
376
|
+
model: search.model,
|
|
377
|
+
sources,
|
|
378
|
+
officialPageUrl: officialHtml?.url ?? official.url,
|
|
379
|
+
...(officialHtml ? { officialPageHtml: officialHtml.content } : {}),
|
|
380
|
+
visualReferences,
|
|
381
|
+
};
|
|
382
|
+
}
|