diff --git a/test-apps/stock-watchlist-agent-js/.env.example b/test-apps/stock-watchlist-agent-js/.env.example index 9a5a744f..33c37256 100644 --- a/test-apps/stock-watchlist-agent-js/.env.example +++ b/test-apps/stock-watchlist-agent-js/.env.example @@ -1,9 +1,9 @@ # OpenAI OPENAI_API_KEY=sk-... # Optional model overrides -# OPENAI_MODEL=gpt-4o -# OPENAI_SEARCH_MODEL=gpt-4o-mini -# OPENAI_EVAL_MODEL=gpt-4o-mini +# OPENAI_MODEL=gpt-5.4-nano +# OPENAI_SEARCH_MODEL=gpt-5.4-nano +# OPENAI_EVAL_MODEL=gpt-5.4-nano # Datadog LLM Observability DD_API_KEY= diff --git a/test-apps/stock-watchlist-agent-js/README.md b/test-apps/stock-watchlist-agent-js/README.md index 8c54476a..b05c6896 100644 --- a/test-apps/stock-watchlist-agent-js/README.md +++ b/test-apps/stock-watchlist-agent-js/README.md @@ -6,6 +6,8 @@ JavaScript translation of `test-apps/stock-watchlist-agent`, built with OpenAI's ``` llmobs.trace(kind="agent", name="analyze_portfolio") ← evals attach here +├── resolve_tickers_from_images (workflow) ← only when image inputs are given +│ └── identify_ticker (llm, one per image) → OpenAI vision call, image_parts annotated └── orchestrator (OpenAI Responses ReAct loop) ├── delegate_research (tool, batched tickers) │ └── stock_researcher (OpenAI Responses ReAct loop) @@ -17,6 +19,8 @@ llmobs.trace(kind="agent", name="analyze_portfolio") ← evals attach her └── stock_researcher ... ``` +Inputs can be ticker symbols, images, or a mix of both. When images are provided, a vision step runs first inside the root `analyze_portfolio` span: one LLM call per image identifies the public company shown and returns its ticker symbol, and the resolved tickers are merged with any tickers passed directly. Images that cannot be matched to a public company come back as `UNKNOWN` and are skipped. Image resolution and research therefore share a single trace. + The orchestrator plans how to batch tickers by sector/theme, delegates batches to researcher agents, and synthesizes a portfolio briefing. Each researcher runs a multi-step ReAct loop with four research tools backed by OpenAI web search. Post-run evaluations (completeness, sentiment consistency, factual grounding) are submitted to LLM Observability. ## Development @@ -42,9 +46,27 @@ npm install /path/to/dd-trace-js/packages/dd-trace ```bash # Either export OPENAI_API_KEY or set it in .env + +# a) All three inputs are ticker symbols npm start -- AAPL GOOGL NVDA + +# b) First two inputs are images, third is a ticker symbol +npm start -- logos/apple.png logos/google.png NVDA + +# --image forces an argument to be read as an image +npm start -- AAPL --image logos/nvidia.png ``` +The `logos/` directory holds small sample wordmark images for Apple, Google, and NVIDIA so the image path can be run without supplying your own files. + +Arguments ending in `.png`, `.jpg`, `.jpeg`, `.gif`, or `.webp` are treated as local image files automatically. Use `--image ` to force an argument to be read as an image. Each image is read as base64 so the same bytes are sent to OpenAI and attached to the trace. Image-to-ticker translation uses `OPENAI_MODEL` (defaults to `gpt-5.4-nano`), so that model must support image inputs. + +### Images on spans + +`identify_ticker` is annotated as an `llm`-kind span whose user message carries `imageParts: [{ mimeType, content }]`, which the SDK emits as `image_parts: [{ mime_type, content }]`. + +To see an image input in Datadog, open the trace in **LLM Observability > Traces** and select the span named **`identify_ticker`**. + ## Running with Datadog LLM Observability ```bash @@ -102,6 +124,7 @@ When LLMObs is enabled, three evaluations run after each analysis and are submit ## Project Structure ``` +logos/ # Small sample images for the image-input path src/ ├── main.js # CLI entry point, eval runner ├── observability.js # .env loading, dd-trace-js initialization, LLMObs helpers @@ -111,5 +134,6 @@ src/ ├── orchestrator.js # ReAct orchestrator, delegation tool, agent span ├── researcher.js # Per-batch research agent with 4 tools ├── responses-agent.js # Generic Responses API function-calling loop - └── searcher.js # OpenAI Responses API web search helper + ├── searcher.js # OpenAI Responses API web search helper + └── vision.js # Image → ticker symbol translation ``` diff --git a/test-apps/stock-watchlist-agent-js/logos/apple.png b/test-apps/stock-watchlist-agent-js/logos/apple.png new file mode 100644 index 00000000..d06eb69f Binary files /dev/null and b/test-apps/stock-watchlist-agent-js/logos/apple.png differ diff --git a/test-apps/stock-watchlist-agent-js/logos/google.png b/test-apps/stock-watchlist-agent-js/logos/google.png new file mode 100644 index 00000000..c26905c4 Binary files /dev/null and b/test-apps/stock-watchlist-agent-js/logos/google.png differ diff --git a/test-apps/stock-watchlist-agent-js/logos/nvidia.png b/test-apps/stock-watchlist-agent-js/logos/nvidia.png new file mode 100644 index 00000000..fa43bccc Binary files /dev/null and b/test-apps/stock-watchlist-agent-js/logos/nvidia.png differ diff --git a/test-apps/stock-watchlist-agent-js/package-lock.json b/test-apps/stock-watchlist-agent-js/package-lock.json index 6fc0e734..d2f6c4d9 100644 --- a/test-apps/stock-watchlist-agent-js/package-lock.json +++ b/test-apps/stock-watchlist-agent-js/package-lock.json @@ -8,7 +8,7 @@ "name": "stock-watchlist-agent-js", "version": "0.1.0", "dependencies": { - "dd-trace": "latest", + "dd-trace": "^6.10.0", "dotenv": "^16.4.7", "openai": "^4.104.0" }, @@ -20,9 +20,9 @@ } }, "node_modules/@datadog/flagging-core": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@datadog/flagging-core/-/flagging-core-1.2.1.tgz", - "integrity": "sha512-qeDkki9fFlqyoZBrn7tneT6pZ04EKKvf3xxisYw1a74zbJihvQui/ARUsjXCurRpzpFqGGTJw/oz+HnXaKhcdw==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@datadog/flagging-core/-/flagging-core-2.0.2.tgz", + "integrity": "sha512-2+oWyqz/EMNXtsgyW3NtueFgL0TciWInyMg/6bEUZhfr7UgPzCQ88Nag9FGoPCig19F/tHXE5hLiQccjkRUMWQ==", "license": "Apache-2.0", "optional": true, "dependencies": { @@ -77,13 +77,13 @@ } }, "node_modules/@datadog/openfeature-node-server": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@datadog/openfeature-node-server/-/openfeature-node-server-2.0.0.tgz", - "integrity": "sha512-Ummu/Bd7ZJpCCNdFnZUt/JI+L1+8OMK53+MyIXbS7dCt4JXWWwelbpzSGqmC2jWbWQ/mTo4hEfnFw3kYOINbXA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@datadog/openfeature-node-server/-/openfeature-node-server-2.0.2.tgz", + "integrity": "sha512-647eJuiOVzCEk50His94wjSlOgJCbHIJv4cAW425eW3GqQJC2Y73qGBYV1/s9oBsAmsUNa90tc+ETunfUH2hKQ==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@datadog/flagging-core": "1.2.1" + "@datadog/flagging-core": "2.0.2" }, "engines": { "node": ">=18.0.0" @@ -93,16 +93,15 @@ } }, "node_modules/@datadog/pprof": { - "version": "5.15.1", - "resolved": "https://registry.npmjs.org/@datadog/pprof/-/pprof-5.15.1.tgz", - "integrity": "sha512-4mI750tX6okNROS4YKvGQjyAQ+VqfzDqzysCytOJhL2E2ktK/q4M0PtC7j70tiirGb/fO9fjma3IMNSRLYX+xQ==", - "hasInstallScript": true, + "version": "5.17.0", + "resolved": "https://registry.npmjs.org/@datadog/pprof/-/pprof-5.17.0.tgz", + "integrity": "sha512-BuedZ4vHzmQkitMMdIhVLQ+nPpHl7WRwcuumJeXt0ylhwGGwt7Kf+4CoS1tDB0FljJXi4izweFfFFTirrijP5w==", "license": "Apache-2.0", "optional": true, "dependencies": { "node-gyp-build": "^4.8.4", "pprof-format": "^2.2.1", - "source-map": "^0.7.4" + "source-map": "^0.8.0" }, "engines": { "node": ">=16" @@ -198,17 +197,17 @@ } }, "node_modules/@openfeature/core": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/@openfeature/core/-/core-1.11.0.tgz", - "integrity": "sha512-P0u3/ht/oZCQT89fOed+laLk0kZR529a825cS02uPDglxXbE97irWYpDAeRGGVETIzKfuy+H2g8c3Ccv/tXJNQ==", + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/@openfeature/core/-/core-1.12.0.tgz", + "integrity": "sha512-7PCPzyd1OC19begz30+CRknFB0ChtyaZUhk7YWrk+Bov1fpVw0HYkTXtoxxsvPfDZB9P9WsT7uixN+Z8f9+bcA==", "license": "Apache-2.0", "optional": true, "peer": true }, "node_modules/@openfeature/server-sdk": { - "version": "1.22.0", - "resolved": "https://registry.npmjs.org/@openfeature/server-sdk/-/server-sdk-1.22.0.tgz", - "integrity": "sha512-YBrf6SQkn0FNB/dRAtLEs41dvFMUE8CrQTwI+iLaMFUIqWlqGNJfGnulKSneEKS+2OgKTAC6DdmKcZ6tK7kBcg==", + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@openfeature/server-sdk/-/server-sdk-1.23.0.tgz", + "integrity": "sha512-JWeLvltJIV0AFgOfbw7hK9b9Rw/5wWq+RMhCIU9sJtAqarxr8hSPglG+JAGGhYdElAz3Qwe9W3ApWyPNkQEyqg==", "license": "Apache-2.0", "optional": true, "peer": true, @@ -216,7 +215,7 @@ "node": ">=20" }, "peerDependencies": { - "@openfeature/core": "^1.11.0" + "@openfeature/core": "^1.12.0" } }, "node_modules/@opentelemetry/api": { @@ -681,13 +680,13 @@ } }, "node_modules/dd-trace": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/dd-trace/-/dd-trace-6.4.0.tgz", - "integrity": "sha512-aRcf+OXejmPmOz5mTg5zkBTAONv+JwHeszdu/sxPEuZvkBYfjHod5XChgSV0rEDwOWvSbe0kaLPdWM/umIftsw==", + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/dd-trace/-/dd-trace-6.10.0.tgz", + "integrity": "sha512-sDRIz4mlwi6v48UatXtfuhG5rU/ymqddVqleUhBwMmGglEoW+CWyNklgQwAKfaZt4agU1kS0apoR/8JIeY0MIw==", "license": "(Apache-2.0 OR BSD-3-Clause)", "dependencies": { "dc-polyfill": "^0.1.11", - "import-in-the-middle": "^3.3.1", + "import-in-the-middle": "^3.3.2", "opentracing": ">=0.14.7" }, "engines": { @@ -698,8 +697,8 @@ "@datadog/native-appsec": "11.0.1", "@datadog/native-iast-taint-tracking": "4.2.0", "@datadog/native-metrics": "3.1.2", - "@datadog/openfeature-node-server": "2.0.0", - "@datadog/pprof": "5.15.1", + "@datadog/openfeature-node-server": "2.0.2", + "@datadog/pprof": "5.17.0", "@datadog/wasm-js-rewriter": "5.0.1", "@opentelemetry/api": ">=1.0.0 <1.10.0", "@opentelemetry/api-logs": "<1.0.0", @@ -1168,16 +1167,16 @@ } }, "node_modules/pprof-format": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/pprof-format/-/pprof-format-2.2.2.tgz", - "integrity": "sha512-hd90rHVDhNOhgHTmazVzDSVwTLOBjpZQ26AO/0j46sAFZ9uSWY0DcK2zJcwnuo2R6EzufBbOoWlPazA5nSyHcg==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pprof-format/-/pprof-format-2.3.1.tgz", + "integrity": "sha512-y51Z83qG2vEQBACPu6lkGFREVkHwQaCaNDdSFEMLIqSo3bmpADsbP6J3F2SSk7tYB741oTQ9Kt5YAdQsmiCRkA==", "license": "MIT", "optional": true }, "node_modules/source-map": { - "version": "0.7.6", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", - "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0.tgz", + "integrity": "sha512-d8EqvL+k/SOXCreS/SUzg2ciyHqBBLcN/yuRjFsbvVhHTE2pgei7oAhmPM7kWFbkX6OSMQfUq4KbkF3au9lhYQ==", "license": "BSD-3-Clause", "optional": true, "engines": { diff --git a/test-apps/stock-watchlist-agent-js/package.json b/test-apps/stock-watchlist-agent-js/package.json index 5c5c838e..de80c430 100644 --- a/test-apps/stock-watchlist-agent-js/package.json +++ b/test-apps/stock-watchlist-agent-js/package.json @@ -15,7 +15,7 @@ "node": ">=20" }, "dependencies": { - "dd-trace": "latest", + "dd-trace": "^6.10.0", "dotenv": "^16.4.7", "openai": "^4.104.0" } diff --git a/test-apps/stock-watchlist-agent-js/src/agents/orchestrator.js b/test-apps/stock-watchlist-agent-js/src/agents/orchestrator.js index 90c8942a..8e7d09b8 100644 --- a/test-apps/stock-watchlist-agent-js/src/agents/orchestrator.js +++ b/test-apps/stock-watchlist-agent-js/src/agents/orchestrator.js @@ -1,6 +1,7 @@ 'use strict' const { researchStocks } = require('./researcher') +const { resolveTickersFromImages } = require('./vision') const { runResponsesAgent } = require('./responses-agent') const { portfolioBriefingSchema, validatePortfolioBriefing } = require('../models') const { annotate, exportSpan, traceSpan } = require('../observability') @@ -106,13 +107,28 @@ function formatUtcNow () { return `${yyyy}-${mm}-${dd} ${hh}:${min} UTC` } -async function analyzePortfolio (tickers) { +async function analyzePortfolio (inputTickers, { images = [], onImagesResolved } = {}) { return traceSpan({ kind: 'agent', name: 'analyze_portfolio' }, async span => { const now = formatUtcNow() - const prompt = `Analyze these stock tickers: ${tickers.join(', ')}. Current time: ${now}` const spanContext = exportSpan(span) - annotate(span, { inputData: tickers, metadata: { generated_at: now } }) + let tickers = inputTickers + let identifications = [] + if (images.length > 0) { + const resolved = await resolveTickersFromImages(images) + identifications = resolved.identifications + tickers = [...new Set([...inputTickers, ...resolved.tickers])] + if (onImagesResolved) onImagesResolved(identifications) + if (tickers.length === 0) { + throw new Error('No ticker symbols could be identified from the provided image(s)') + } + } + + const prompt = `Analyze these stock tickers: ${tickers.join(', ')}. Current time: ${now}` + annotate(span, { + inputData: images.length > 0 ? { tickers: inputTickers, images } : inputTickers, + metadata: { generated_at: now, resolved_tickers: tickers }, + }) const briefing = await traceSpan({ kind: 'agent', name: 'orchestrator' }, async orchestratorSpan => { annotate(orchestratorSpan, { inputData: prompt, metadata: { generated_at: now } }) const result = await runResponsesAgent({ @@ -128,7 +144,7 @@ async function analyzePortfolio (tickers) { return validated }) annotate(span, { outputData: briefing }) - return { briefing, spanContext } + return { briefing, spanContext, tickers, identifications } }) } diff --git a/test-apps/stock-watchlist-agent-js/src/agents/responses-agent.js b/test-apps/stock-watchlist-agent-js/src/agents/responses-agent.js index dbdfdea8..fe15c8af 100644 --- a/test-apps/stock-watchlist-agent-js/src/agents/responses-agent.js +++ b/test-apps/stock-watchlist-agent-js/src/agents/responses-agent.js @@ -4,7 +4,7 @@ const { annotate, traceSpan } = require('../observability') const OpenAI = require('openai') const client = new OpenAI() -const DEFAULT_MODEL = process.env.OPENAI_MODEL || 'gpt-4o' +const DEFAULT_MODEL = process.env.OPENAI_MODEL || 'gpt-5.4-nano' function jsonSchemaFormat (name, schema) { return { diff --git a/test-apps/stock-watchlist-agent-js/src/agents/searcher.js b/test-apps/stock-watchlist-agent-js/src/agents/searcher.js index ab9372b5..9f783e86 100644 --- a/test-apps/stock-watchlist-agent-js/src/agents/searcher.js +++ b/test-apps/stock-watchlist-agent-js/src/agents/searcher.js @@ -13,7 +13,7 @@ const SEARCH_INSTRUCTIONS = [ async function search (query) { const response = await client.responses.create({ - model: process.env.OPENAI_SEARCH_MODEL || 'gpt-4o-mini', + model: process.env.OPENAI_SEARCH_MODEL || 'gpt-5.4-nano', instructions: SEARCH_INSTRUCTIONS, input: query, tools: [{ type: 'web_search' }], diff --git a/test-apps/stock-watchlist-agent-js/src/agents/vision.js b/test-apps/stock-watchlist-agent-js/src/agents/vision.js new file mode 100644 index 00000000..7e8002a3 --- /dev/null +++ b/test-apps/stock-watchlist-agent-js/src/agents/vision.js @@ -0,0 +1,117 @@ +'use strict' + +const fs = require('node:fs') +const path = require('node:path') +const { annotate, traceSpan } = require('../observability') +const { tickerFromImageSchema, validateTickerFromImage } = require('../models') +const { jsonSchemaFormat } = require('./responses-agent') +const OpenAI = require('openai') + +const client = new OpenAI() +const DEFAULT_MODEL = process.env.OPENAI_MODEL || 'gpt-5.4-nano' + +const IMAGE_MIME_TYPES = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', +} + +const VISION_PROMPT = `\ + +You identify the publicly traded company shown in an image and return its stock ticker symbol. + + + +1. Read every signal in the image: logos, wordmarks, product names, storefronts, chart labels, ticker tape text +2. Decide which single public company the image refers to +3. Map that company to the ticker symbol of its primary US listing (use the local exchange symbol if it is not US-listed) +4. Report how confident you are and what evidence you used + + + +- Return the ticker in uppercase with no exchange prefix (AAPL, not NASDAQ:AAPL) +- Prefer the parent company's ticker when the image shows a brand or product (e.g. Instagram -> META) +- If the image shows no identifiable public company, set ticker to "UNKNOWN" and confidence to "low" + + + +Return JSON only matching the TickerFromImage schema. +` + +function looksLikeImage (value) { + return Object.keys(IMAGE_MIME_TYPES).includes(path.extname(value).toLowerCase()) +} + +const IMAGE_QUESTION = 'Which publicly traded company does this image show? Return its ticker symbol.' + +function loadImage (imageInput) { + const filePath = path.resolve(imageInput) + const extension = path.extname(filePath).toLowerCase() + const mimeType = IMAGE_MIME_TYPES[extension] + if (!mimeType) { + throw new Error(`Unsupported image type "${extension}" for ${imageInput}`) + } + if (!fs.existsSync(filePath)) { + throw new Error(`Image not found: ${imageInput}`) + } + return { mimeType, base64: fs.readFileSync(filePath).toString('base64') } +} + +async function identifyTicker (imageInput, model = DEFAULT_MODEL) { + // Annotated as an `llm` span with imageParts: the LLMObs SDK only renders images + // on manually annotated llm-kind messages, not from provider auto-instrumentation. + return traceSpan({ kind: 'llm', name: 'identify_ticker', modelName: model, modelProvider: 'openai' }, async span => { + const { mimeType, base64 } = loadImage(imageInput) + annotate(span, { + inputData: [ + { role: 'system', content: VISION_PROMPT }, + { + role: 'user', + content: IMAGE_QUESTION, + imageParts: [{ mimeType, content: base64 }], + }, + ], + metadata: { model, image_input: imageInput }, + }) + + const response = await client.responses.create({ + model, + instructions: VISION_PROMPT, + input: [ + { + role: 'user', + content: [ + { type: 'input_text', text: IMAGE_QUESTION }, + { type: 'input_image', image_url: `data:${mimeType};base64,${base64}`, detail: 'auto' }, + ], + }, + ], + text: { format: jsonSchemaFormat('TickerFromImage', tickerFromImageSchema) }, + }) + + const result = validateTickerFromImage(JSON.parse((response.output_text || '').trim())) + const identified = { ...result, ticker: result.ticker.toUpperCase(), source: imageInput } + annotate(span, { outputData: [{ role: 'assistant', content: JSON.stringify(identified) }] }) + return identified + }) +} + +async function resolveTickersFromImages (imageInputs) { + return traceSpan({ kind: 'workflow', name: 'resolve_tickers_from_images' }, async span => { + annotate(span, { inputData: imageInputs }) + const identifications = await Promise.all(imageInputs.map(image => identifyTicker(image))) + const recognized = identifications.filter(item => item.ticker !== 'UNKNOWN') + const tickers = [...new Set(recognized.map(item => item.ticker))] + annotate(span, { outputData: { tickers, identifications } }) + return { tickers, identifications } + }) +} + +module.exports = { + identifyTicker, + resolveTickersFromImages, + looksLikeImage, + VISION_PROMPT, +} diff --git a/test-apps/stock-watchlist-agent-js/src/evals.js b/test-apps/stock-watchlist-agent-js/src/evals.js index 780217ec..39a464f9 100644 --- a/test-apps/stock-watchlist-agent-js/src/evals.js +++ b/test-apps/stock-watchlist-agent-js/src/evals.js @@ -51,7 +51,7 @@ async function runBooleanJudge ({ name, prompt, outputData }) { return traceSpan({ kind: 'task', name }, async span => { annotate(span, { inputData: { output_data: outputData } }) const response = await client.responses.create({ - model: process.env.OPENAI_EVAL_MODEL || 'gpt-4o-mini', + model: process.env.OPENAI_EVAL_MODEL || 'gpt-5.4-nano', input: prompt.replace('{{output_data}}', JSON.stringify(outputData, null, 2)), text: { format: jsonSchemaFormat(name, booleanJudgeSchema) }, }) @@ -69,7 +69,7 @@ async function runScoreJudge ({ name, prompt, outputData, minThreshold }) { return traceSpan({ kind: 'task', name }, async span => { annotate(span, { inputData: { output_data: outputData } }) const response = await client.responses.create({ - model: process.env.OPENAI_EVAL_MODEL || 'gpt-4o-mini', + model: process.env.OPENAI_EVAL_MODEL || 'gpt-5.4-nano', input: prompt.replace('{{output_data}}', JSON.stringify(outputData, null, 2)), text: { format: jsonSchemaFormat(name, scoreJudgeSchema) }, }) diff --git a/test-apps/stock-watchlist-agent-js/src/main.js b/test-apps/stock-watchlist-agent-js/src/main.js index 6847db8f..eb870021 100755 --- a/test-apps/stock-watchlist-agent-js/src/main.js +++ b/test-apps/stock-watchlist-agent-js/src/main.js @@ -3,8 +3,22 @@ const { flush, isLLMObsEnabled, mlApp, site, env } = require('./observability') const { analyzePortfolio } = require('./agents/orchestrator') +const { looksLikeImage } = require('./agents/vision') const { runEvaluations } = require('./evals') +const USAGE = [ + 'Usage: node src/main.js [TICKER|IMAGE ...]', + '', + 'Inputs may be ticker symbols or local image files (mixed freely).', + 'Images are translated to ticker symbols by an LLM before research begins.', + 'Use --image to force an argument to be treated as an image.', + '', + 'Examples:', + ' node src/main.js AAPL GOOGL NVDA', + ' node src/main.js logos/apple.png logos/google.png NVDA', + ' node src/main.js AAPL --image logos/nvidia.png', +].join('\n') + function printBriefing (briefing) { console.log('\n' + '='.repeat(60)) console.log(' STOCK WATCHLIST BRIEFING') @@ -44,30 +58,68 @@ function printBriefing (briefing) { console.log('\n' + '='.repeat(60) + '\n') } +function printIdentifications (identifications) { + console.log('\nImage inputs resolved to tickers:') + for (const item of identifications) { + const label = item.ticker === 'UNKNOWN' ? 'UNKNOWN (skipped)' : item.ticker + console.log(` ${item.source} -> ${label} (${item.company_name}, confidence: ${item.confidence})`) + console.log(` ${item.evidence}`) + } +} + function parseArgs (argv) { const args = argv.slice(2) if (args.includes('-h') || args.includes('--help')) { - console.log('Usage: node src/main.js [TICKER ...]') - console.log('Example: node src/main.js AAPL GOOGL NVDA') + console.log(USAGE) process.exit(0) } - return args.map(ticker => ticker.toUpperCase()) + + const tickers = [] + const images = [] + for (let i = 0; i < args.length; i++) { + const arg = args[i] + if (arg === '--image') { + const value = args[++i] + if (!value) { + throw new Error('--image requires a file path') + } + images.push(value) + } else if (looksLikeImage(arg)) { + images.push(arg) + } else { + tickers.push(arg.toUpperCase()) + } + } + return { tickers, images } } -async function main (tickers) { +async function main (inputTickers, images = []) { if (!process.env.OPENAI_API_KEY) { throw new Error('OPENAI_API_KEY is required') } - console.log(`Analyzing ${tickers.length} ticker(s): ${tickers.join(', ')}`) + const described = [ + ...inputTickers, + ...images.map(image => `${image} (image)`), + ] + console.log(`Analyzing ${described.length} input(s): ${described.join(', ')}`) if (isLLMObsEnabled()) { console.log(`LLMObs enabled: ml_app=${mlApp}, site=${site}, env=${env}`) } else { console.log('LLMObs disabled: set DD_API_KEY (or DD_LLMOBS_ENABLED=true) to submit traces') } + if (images.length > 0) { + console.log(`Translating ${images.length} image input(s) to ticker symbols...`) + } console.log('Running parallel analysis with web search...\n') - const { briefing, spanContext } = await analyzePortfolio(tickers) + const { briefing, spanContext, tickers } = await analyzePortfolio(inputTickers, { + images, + onImagesResolved: identifications => { + printIdentifications(identifications) + console.log('') + }, + }) if (isLLMObsEnabled() && spanContext) { console.log(`LLMObs trace context: trace_id=${spanContext.traceId}, span_id=${spanContext.spanId}`) } @@ -81,16 +133,16 @@ async function main (tickers) { } async function cli () { - const tickers = parseArgs(process.argv) - if (tickers.length === 0) { - console.error('Error: provide at least one ticker symbol') - console.error('Usage: node src/main.js [TICKER ...]') + const { tickers, images } = parseArgs(process.argv) + if (tickers.length === 0 && images.length === 0) { + console.error('Error: provide at least one ticker symbol or image') + console.error(USAGE) process.exitCode = 1 return } try { - await main(tickers) + await main(tickers, images) } finally { flush() } @@ -105,5 +157,6 @@ if (require.main === module) { module.exports = { main, + parseArgs, printBriefing, } diff --git a/test-apps/stock-watchlist-agent-js/src/models.js b/test-apps/stock-watchlist-agent-js/src/models.js index c40c28aa..a411ea13 100644 --- a/test-apps/stock-watchlist-agent-js/src/models.js +++ b/test-apps/stock-watchlist-agent-js/src/models.js @@ -48,6 +48,18 @@ const portfolioBriefingSchema = { required: ['analyses', 'market_overview', 'highlights', 'generated_at'], } +const tickerFromImageSchema = { + type: 'object', + additionalProperties: false, + properties: { + ticker: { type: 'string' }, + company_name: { type: 'string' }, + confidence: { type: 'string', enum: ['high', 'medium', 'low'] }, + evidence: { type: 'string' }, + }, + required: ['ticker', 'company_name', 'confidence', 'evidence'], +} + function assertString (value, path) { if (typeof value !== 'string' || value.length === 0) { throw new Error(`${path} must be a non-empty string`) @@ -98,10 +110,25 @@ function validatePortfolioBriefing (briefing) { return briefing } +function validateTickerFromImage (result) { + if (!result || typeof result !== 'object' || Array.isArray(result)) { + throw new Error('TickerFromImage must be an object') + } + assertString(result.ticker, 'ticker') + assertString(result.company_name, 'company_name') + if (!['high', 'medium', 'low'].includes(result.confidence)) { + throw new Error('confidence must be high, medium, or low') + } + assertString(result.evidence, 'evidence') + return result +} + module.exports = { stockAnalysisSchema, researchBatchResultSchema, portfolioBriefingSchema, + tickerFromImageSchema, validateResearchBatchResult, validatePortfolioBriefing, + validateTickerFromImage, }