ai-parrot 0.17.2__cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
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.
- agentui/.prettierrc +15 -0
- agentui/QUICKSTART.md +272 -0
- agentui/README.md +59 -0
- agentui/env.example +16 -0
- agentui/jsconfig.json +14 -0
- agentui/package-lock.json +4242 -0
- agentui/package.json +34 -0
- agentui/scripts/postinstall/apply-patches.mjs +260 -0
- agentui/src/app.css +61 -0
- agentui/src/app.d.ts +13 -0
- agentui/src/app.html +12 -0
- agentui/src/components/LoadingSpinner.svelte +64 -0
- agentui/src/components/ThemeSwitcher.svelte +159 -0
- agentui/src/components/index.js +4 -0
- agentui/src/lib/api/bots.ts +60 -0
- agentui/src/lib/api/chat.ts +22 -0
- agentui/src/lib/api/http.ts +25 -0
- agentui/src/lib/components/BotCard.svelte +33 -0
- agentui/src/lib/components/ChatBubble.svelte +63 -0
- agentui/src/lib/components/Toast.svelte +21 -0
- agentui/src/lib/config.ts +20 -0
- agentui/src/lib/stores/auth.svelte.ts +73 -0
- agentui/src/lib/stores/theme.svelte.js +64 -0
- agentui/src/lib/stores/toast.svelte.ts +31 -0
- agentui/src/lib/utils/conversation.ts +39 -0
- agentui/src/routes/+layout.svelte +20 -0
- agentui/src/routes/+page.svelte +232 -0
- agentui/src/routes/login/+page.svelte +200 -0
- agentui/src/routes/talk/[agentId]/+page.svelte +297 -0
- agentui/src/routes/talk/[agentId]/+page.ts +7 -0
- agentui/static/README.md +1 -0
- agentui/svelte.config.js +11 -0
- agentui/tailwind.config.ts +53 -0
- agentui/tsconfig.json +3 -0
- agentui/vite.config.ts +10 -0
- ai_parrot-0.17.2.dist-info/METADATA +472 -0
- ai_parrot-0.17.2.dist-info/RECORD +535 -0
- ai_parrot-0.17.2.dist-info/WHEEL +6 -0
- ai_parrot-0.17.2.dist-info/entry_points.txt +2 -0
- ai_parrot-0.17.2.dist-info/licenses/LICENSE +21 -0
- ai_parrot-0.17.2.dist-info/top_level.txt +6 -0
- crew-builder/.prettierrc +15 -0
- crew-builder/QUICKSTART.md +259 -0
- crew-builder/README.md +113 -0
- crew-builder/env.example +17 -0
- crew-builder/jsconfig.json +14 -0
- crew-builder/package-lock.json +4182 -0
- crew-builder/package.json +37 -0
- crew-builder/scripts/postinstall/apply-patches.mjs +260 -0
- crew-builder/src/app.css +62 -0
- crew-builder/src/app.d.ts +13 -0
- crew-builder/src/app.html +12 -0
- crew-builder/src/components/LoadingSpinner.svelte +64 -0
- crew-builder/src/components/ThemeSwitcher.svelte +149 -0
- crew-builder/src/components/index.js +9 -0
- crew-builder/src/lib/api/bots.ts +60 -0
- crew-builder/src/lib/api/chat.ts +80 -0
- crew-builder/src/lib/api/client.ts +56 -0
- crew-builder/src/lib/api/crew/crew.ts +136 -0
- crew-builder/src/lib/api/index.ts +5 -0
- crew-builder/src/lib/api/o365/auth.ts +65 -0
- crew-builder/src/lib/auth/auth.ts +54 -0
- crew-builder/src/lib/components/AgentNode.svelte +43 -0
- crew-builder/src/lib/components/BotCard.svelte +33 -0
- crew-builder/src/lib/components/ChatBubble.svelte +67 -0
- crew-builder/src/lib/components/ConfigPanel.svelte +278 -0
- crew-builder/src/lib/components/JsonTreeNode.svelte +76 -0
- crew-builder/src/lib/components/JsonViewer.svelte +24 -0
- crew-builder/src/lib/components/MarkdownEditor.svelte +48 -0
- crew-builder/src/lib/components/ThemeToggle.svelte +36 -0
- crew-builder/src/lib/components/Toast.svelte +67 -0
- crew-builder/src/lib/components/Toolbar.svelte +157 -0
- crew-builder/src/lib/components/index.ts +10 -0
- crew-builder/src/lib/config.ts +8 -0
- crew-builder/src/lib/stores/auth.svelte.ts +228 -0
- crew-builder/src/lib/stores/crewStore.ts +369 -0
- crew-builder/src/lib/stores/theme.svelte.js +145 -0
- crew-builder/src/lib/stores/toast.svelte.ts +69 -0
- crew-builder/src/lib/utils/conversation.ts +39 -0
- crew-builder/src/lib/utils/markdown.ts +122 -0
- crew-builder/src/lib/utils/talkHistory.ts +47 -0
- crew-builder/src/routes/+layout.svelte +20 -0
- crew-builder/src/routes/+page.svelte +539 -0
- crew-builder/src/routes/agents/+page.svelte +247 -0
- crew-builder/src/routes/agents/[agentId]/+page.svelte +288 -0
- crew-builder/src/routes/agents/[agentId]/+page.ts +7 -0
- crew-builder/src/routes/builder/+page.svelte +204 -0
- crew-builder/src/routes/crew/ask/+page.svelte +1052 -0
- crew-builder/src/routes/crew/ask/+page.ts +1 -0
- crew-builder/src/routes/integrations/o365/+page.svelte +304 -0
- crew-builder/src/routes/login/+page.svelte +197 -0
- crew-builder/src/routes/talk/[agentId]/+page.svelte +487 -0
- crew-builder/src/routes/talk/[agentId]/+page.ts +7 -0
- crew-builder/static/README.md +1 -0
- crew-builder/svelte.config.js +11 -0
- crew-builder/tailwind.config.ts +53 -0
- crew-builder/tsconfig.json +3 -0
- crew-builder/vite.config.ts +10 -0
- mcp_servers/calculator_server.py +309 -0
- parrot/__init__.py +27 -0
- parrot/__pycache__/__init__.cpython-310.pyc +0 -0
- parrot/__pycache__/version.cpython-310.pyc +0 -0
- parrot/_version.py +34 -0
- parrot/a2a/__init__.py +48 -0
- parrot/a2a/client.py +658 -0
- parrot/a2a/discovery.py +89 -0
- parrot/a2a/mixin.py +257 -0
- parrot/a2a/models.py +376 -0
- parrot/a2a/server.py +770 -0
- parrot/agents/__init__.py +29 -0
- parrot/bots/__init__.py +12 -0
- parrot/bots/a2a_agent.py +19 -0
- parrot/bots/abstract.py +3139 -0
- parrot/bots/agent.py +1129 -0
- parrot/bots/basic.py +9 -0
- parrot/bots/chatbot.py +669 -0
- parrot/bots/data.py +1618 -0
- parrot/bots/database/__init__.py +5 -0
- parrot/bots/database/abstract.py +3071 -0
- parrot/bots/database/cache.py +286 -0
- parrot/bots/database/models.py +468 -0
- parrot/bots/database/prompts.py +154 -0
- parrot/bots/database/retries.py +98 -0
- parrot/bots/database/router.py +269 -0
- parrot/bots/database/sql.py +41 -0
- parrot/bots/db/__init__.py +6 -0
- parrot/bots/db/abstract.py +556 -0
- parrot/bots/db/bigquery.py +602 -0
- parrot/bots/db/cache.py +85 -0
- parrot/bots/db/documentdb.py +668 -0
- parrot/bots/db/elastic.py +1014 -0
- parrot/bots/db/influx.py +898 -0
- parrot/bots/db/mock.py +96 -0
- parrot/bots/db/multi.py +783 -0
- parrot/bots/db/prompts.py +185 -0
- parrot/bots/db/sql.py +1255 -0
- parrot/bots/db/tools.py +212 -0
- parrot/bots/document.py +680 -0
- parrot/bots/hrbot.py +15 -0
- parrot/bots/kb.py +170 -0
- parrot/bots/mcp.py +36 -0
- parrot/bots/orchestration/README.md +463 -0
- parrot/bots/orchestration/__init__.py +1 -0
- parrot/bots/orchestration/agent.py +155 -0
- parrot/bots/orchestration/crew.py +3330 -0
- parrot/bots/orchestration/fsm.py +1179 -0
- parrot/bots/orchestration/hr.py +434 -0
- parrot/bots/orchestration/storage/__init__.py +4 -0
- parrot/bots/orchestration/storage/memory.py +100 -0
- parrot/bots/orchestration/storage/mixin.py +119 -0
- parrot/bots/orchestration/verify.py +202 -0
- parrot/bots/product.py +204 -0
- parrot/bots/prompts/__init__.py +96 -0
- parrot/bots/prompts/agents.py +155 -0
- parrot/bots/prompts/data.py +216 -0
- parrot/bots/prompts/output_generation.py +8 -0
- parrot/bots/scraper/__init__.py +3 -0
- parrot/bots/scraper/models.py +122 -0
- parrot/bots/scraper/scraper.py +1173 -0
- parrot/bots/scraper/templates.py +115 -0
- parrot/bots/stores/__init__.py +5 -0
- parrot/bots/stores/local.py +172 -0
- parrot/bots/webdev.py +81 -0
- parrot/cli.py +17 -0
- parrot/clients/__init__.py +16 -0
- parrot/clients/base.py +1491 -0
- parrot/clients/claude.py +1191 -0
- parrot/clients/factory.py +129 -0
- parrot/clients/google.py +4567 -0
- parrot/clients/gpt.py +1975 -0
- parrot/clients/grok.py +432 -0
- parrot/clients/groq.py +986 -0
- parrot/clients/hf.py +582 -0
- parrot/clients/models.py +18 -0
- parrot/conf.py +395 -0
- parrot/embeddings/__init__.py +9 -0
- parrot/embeddings/base.py +157 -0
- parrot/embeddings/google.py +98 -0
- parrot/embeddings/huggingface.py +74 -0
- parrot/embeddings/openai.py +84 -0
- parrot/embeddings/processor.py +88 -0
- parrot/exceptions.c +13868 -0
- parrot/exceptions.cpython-310-x86_64-linux-gnu.so +0 -0
- parrot/exceptions.pxd +22 -0
- parrot/exceptions.pxi +15 -0
- parrot/exceptions.pyx +44 -0
- parrot/generators/__init__.py +29 -0
- parrot/generators/base.py +200 -0
- parrot/generators/html.py +293 -0
- parrot/generators/react.py +205 -0
- parrot/generators/streamlit.py +203 -0
- parrot/generators/template.py +105 -0
- parrot/handlers/__init__.py +4 -0
- parrot/handlers/agent.py +861 -0
- parrot/handlers/agents/__init__.py +1 -0
- parrot/handlers/agents/abstract.py +900 -0
- parrot/handlers/bots.py +338 -0
- parrot/handlers/chat.py +915 -0
- parrot/handlers/creation.sql +192 -0
- parrot/handlers/crew/ARCHITECTURE.md +362 -0
- parrot/handlers/crew/README_BOTMANAGER_PERSISTENCE.md +303 -0
- parrot/handlers/crew/README_REDIS_PERSISTENCE.md +366 -0
- parrot/handlers/crew/__init__.py +0 -0
- parrot/handlers/crew/handler.py +801 -0
- parrot/handlers/crew/models.py +229 -0
- parrot/handlers/crew/redis_persistence.py +523 -0
- parrot/handlers/jobs/__init__.py +10 -0
- parrot/handlers/jobs/job.py +384 -0
- parrot/handlers/jobs/mixin.py +627 -0
- parrot/handlers/jobs/models.py +115 -0
- parrot/handlers/jobs/worker.py +31 -0
- parrot/handlers/models.py +596 -0
- parrot/handlers/o365_auth.py +105 -0
- parrot/handlers/stream.py +337 -0
- parrot/interfaces/__init__.py +6 -0
- parrot/interfaces/aws.py +143 -0
- parrot/interfaces/credentials.py +113 -0
- parrot/interfaces/database.py +27 -0
- parrot/interfaces/google.py +1123 -0
- parrot/interfaces/hierarchy.py +1227 -0
- parrot/interfaces/http.py +651 -0
- parrot/interfaces/images/__init__.py +0 -0
- parrot/interfaces/images/plugins/__init__.py +24 -0
- parrot/interfaces/images/plugins/abstract.py +58 -0
- parrot/interfaces/images/plugins/analisys.py +148 -0
- parrot/interfaces/images/plugins/classify.py +150 -0
- parrot/interfaces/images/plugins/classifybase.py +182 -0
- parrot/interfaces/images/plugins/detect.py +150 -0
- parrot/interfaces/images/plugins/exif.py +1103 -0
- parrot/interfaces/images/plugins/hash.py +52 -0
- parrot/interfaces/images/plugins/vision.py +104 -0
- parrot/interfaces/images/plugins/yolo.py +66 -0
- parrot/interfaces/images/plugins/zerodetect.py +197 -0
- parrot/interfaces/o365.py +978 -0
- parrot/interfaces/onedrive.py +822 -0
- parrot/interfaces/sharepoint.py +1435 -0
- parrot/interfaces/soap.py +257 -0
- parrot/loaders/__init__.py +8 -0
- parrot/loaders/abstract.py +1131 -0
- parrot/loaders/audio.py +199 -0
- parrot/loaders/basepdf.py +53 -0
- parrot/loaders/basevideo.py +1568 -0
- parrot/loaders/csv.py +409 -0
- parrot/loaders/docx.py +116 -0
- parrot/loaders/epubloader.py +316 -0
- parrot/loaders/excel.py +199 -0
- parrot/loaders/factory.py +55 -0
- parrot/loaders/files/__init__.py +0 -0
- parrot/loaders/files/abstract.py +39 -0
- parrot/loaders/files/html.py +26 -0
- parrot/loaders/files/text.py +63 -0
- parrot/loaders/html.py +152 -0
- parrot/loaders/markdown.py +442 -0
- parrot/loaders/pdf.py +373 -0
- parrot/loaders/pdfmark.py +320 -0
- parrot/loaders/pdftables.py +506 -0
- parrot/loaders/ppt.py +476 -0
- parrot/loaders/qa.py +63 -0
- parrot/loaders/splitters/__init__.py +10 -0
- parrot/loaders/splitters/base.py +138 -0
- parrot/loaders/splitters/md.py +228 -0
- parrot/loaders/splitters/token.py +143 -0
- parrot/loaders/txt.py +26 -0
- parrot/loaders/video.py +89 -0
- parrot/loaders/videolocal.py +218 -0
- parrot/loaders/videounderstanding.py +377 -0
- parrot/loaders/vimeo.py +167 -0
- parrot/loaders/web.py +599 -0
- parrot/loaders/youtube.py +504 -0
- parrot/manager/__init__.py +5 -0
- parrot/manager/manager.py +1030 -0
- parrot/mcp/__init__.py +28 -0
- parrot/mcp/adapter.py +105 -0
- parrot/mcp/cli.py +174 -0
- parrot/mcp/client.py +119 -0
- parrot/mcp/config.py +75 -0
- parrot/mcp/integration.py +842 -0
- parrot/mcp/oauth.py +933 -0
- parrot/mcp/server.py +225 -0
- parrot/mcp/transports/__init__.py +3 -0
- parrot/mcp/transports/base.py +279 -0
- parrot/mcp/transports/grpc_session.py +163 -0
- parrot/mcp/transports/http.py +312 -0
- parrot/mcp/transports/mcp.proto +108 -0
- parrot/mcp/transports/quic.py +1082 -0
- parrot/mcp/transports/sse.py +330 -0
- parrot/mcp/transports/stdio.py +309 -0
- parrot/mcp/transports/unix.py +395 -0
- parrot/mcp/transports/websocket.py +547 -0
- parrot/memory/__init__.py +16 -0
- parrot/memory/abstract.py +209 -0
- parrot/memory/agent.py +32 -0
- parrot/memory/cache.py +175 -0
- parrot/memory/core.py +555 -0
- parrot/memory/file.py +153 -0
- parrot/memory/mem.py +131 -0
- parrot/memory/redis.py +613 -0
- parrot/models/__init__.py +46 -0
- parrot/models/basic.py +118 -0
- parrot/models/compliance.py +208 -0
- parrot/models/crew.py +395 -0
- parrot/models/detections.py +654 -0
- parrot/models/generation.py +85 -0
- parrot/models/google.py +223 -0
- parrot/models/groq.py +23 -0
- parrot/models/openai.py +30 -0
- parrot/models/outputs.py +285 -0
- parrot/models/responses.py +938 -0
- parrot/notifications/__init__.py +743 -0
- parrot/openapi/__init__.py +3 -0
- parrot/openapi/components.yaml +641 -0
- parrot/openapi/config.py +322 -0
- parrot/outputs/__init__.py +32 -0
- parrot/outputs/formats/__init__.py +108 -0
- parrot/outputs/formats/altair.py +359 -0
- parrot/outputs/formats/application.py +122 -0
- parrot/outputs/formats/base.py +351 -0
- parrot/outputs/formats/bokeh.py +356 -0
- parrot/outputs/formats/card.py +424 -0
- parrot/outputs/formats/chart.py +436 -0
- parrot/outputs/formats/d3.py +255 -0
- parrot/outputs/formats/echarts.py +310 -0
- parrot/outputs/formats/generators/__init__.py +0 -0
- parrot/outputs/formats/generators/abstract.py +61 -0
- parrot/outputs/formats/generators/panel.py +145 -0
- parrot/outputs/formats/generators/streamlit.py +86 -0
- parrot/outputs/formats/generators/terminal.py +63 -0
- parrot/outputs/formats/holoviews.py +310 -0
- parrot/outputs/formats/html.py +147 -0
- parrot/outputs/formats/jinja2.py +46 -0
- parrot/outputs/formats/json.py +87 -0
- parrot/outputs/formats/map.py +933 -0
- parrot/outputs/formats/markdown.py +172 -0
- parrot/outputs/formats/matplotlib.py +237 -0
- parrot/outputs/formats/mixins/__init__.py +0 -0
- parrot/outputs/formats/mixins/emaps.py +855 -0
- parrot/outputs/formats/plotly.py +341 -0
- parrot/outputs/formats/seaborn.py +310 -0
- parrot/outputs/formats/table.py +397 -0
- parrot/outputs/formats/template_report.py +138 -0
- parrot/outputs/formats/yaml.py +125 -0
- parrot/outputs/formatter.py +152 -0
- parrot/outputs/templates/__init__.py +95 -0
- parrot/pipelines/__init__.py +0 -0
- parrot/pipelines/abstract.py +210 -0
- parrot/pipelines/detector.py +124 -0
- parrot/pipelines/models.py +90 -0
- parrot/pipelines/planogram.py +3002 -0
- parrot/pipelines/table.sql +97 -0
- parrot/plugins/__init__.py +106 -0
- parrot/plugins/importer.py +80 -0
- parrot/py.typed +0 -0
- parrot/registry/__init__.py +18 -0
- parrot/registry/registry.py +594 -0
- parrot/scheduler/__init__.py +1189 -0
- parrot/scheduler/models.py +60 -0
- parrot/security/__init__.py +16 -0
- parrot/security/prompt_injection.py +268 -0
- parrot/security/security_events.sql +25 -0
- parrot/services/__init__.py +1 -0
- parrot/services/mcp/__init__.py +8 -0
- parrot/services/mcp/config.py +13 -0
- parrot/services/mcp/server.py +295 -0
- parrot/services/o365_remote_auth.py +235 -0
- parrot/stores/__init__.py +7 -0
- parrot/stores/abstract.py +352 -0
- parrot/stores/arango.py +1090 -0
- parrot/stores/bigquery.py +1377 -0
- parrot/stores/cache.py +106 -0
- parrot/stores/empty.py +10 -0
- parrot/stores/faiss_store.py +1157 -0
- parrot/stores/kb/__init__.py +9 -0
- parrot/stores/kb/abstract.py +68 -0
- parrot/stores/kb/cache.py +165 -0
- parrot/stores/kb/doc.py +325 -0
- parrot/stores/kb/hierarchy.py +346 -0
- parrot/stores/kb/local.py +457 -0
- parrot/stores/kb/prompt.py +28 -0
- parrot/stores/kb/redis.py +659 -0
- parrot/stores/kb/store.py +115 -0
- parrot/stores/kb/user.py +374 -0
- parrot/stores/models.py +59 -0
- parrot/stores/pgvector.py +3 -0
- parrot/stores/postgres.py +2853 -0
- parrot/stores/utils/__init__.py +0 -0
- parrot/stores/utils/chunking.py +197 -0
- parrot/telemetry/__init__.py +3 -0
- parrot/telemetry/mixin.py +111 -0
- parrot/template/__init__.py +3 -0
- parrot/template/engine.py +259 -0
- parrot/tools/__init__.py +23 -0
- parrot/tools/abstract.py +644 -0
- parrot/tools/agent.py +363 -0
- parrot/tools/arangodbsearch.py +537 -0
- parrot/tools/arxiv_tool.py +188 -0
- parrot/tools/calculator/__init__.py +3 -0
- parrot/tools/calculator/operations/__init__.py +38 -0
- parrot/tools/calculator/operations/calculus.py +80 -0
- parrot/tools/calculator/operations/statistics.py +76 -0
- parrot/tools/calculator/tool.py +150 -0
- parrot/tools/cloudwatch.py +988 -0
- parrot/tools/codeinterpreter/__init__.py +127 -0
- parrot/tools/codeinterpreter/executor.py +371 -0
- parrot/tools/codeinterpreter/internals.py +473 -0
- parrot/tools/codeinterpreter/models.py +643 -0
- parrot/tools/codeinterpreter/prompts.py +224 -0
- parrot/tools/codeinterpreter/tool.py +664 -0
- parrot/tools/company_info/__init__.py +6 -0
- parrot/tools/company_info/tool.py +1138 -0
- parrot/tools/correlationanalysis.py +437 -0
- parrot/tools/database/abstract.py +286 -0
- parrot/tools/database/bq.py +115 -0
- parrot/tools/database/cache.py +284 -0
- parrot/tools/database/models.py +95 -0
- parrot/tools/database/pg.py +343 -0
- parrot/tools/databasequery.py +1159 -0
- parrot/tools/db.py +1800 -0
- parrot/tools/ddgo.py +370 -0
- parrot/tools/decorators.py +271 -0
- parrot/tools/dftohtml.py +282 -0
- parrot/tools/document.py +549 -0
- parrot/tools/ecs.py +819 -0
- parrot/tools/edareport.py +368 -0
- parrot/tools/elasticsearch.py +1049 -0
- parrot/tools/employees.py +462 -0
- parrot/tools/epson/__init__.py +96 -0
- parrot/tools/excel.py +683 -0
- parrot/tools/file/__init__.py +13 -0
- parrot/tools/file/abstract.py +76 -0
- parrot/tools/file/gcs.py +378 -0
- parrot/tools/file/local.py +284 -0
- parrot/tools/file/s3.py +511 -0
- parrot/tools/file/tmp.py +309 -0
- parrot/tools/file/tool.py +501 -0
- parrot/tools/file_reader.py +129 -0
- parrot/tools/flowtask/__init__.py +19 -0
- parrot/tools/flowtask/tool.py +761 -0
- parrot/tools/gittoolkit.py +508 -0
- parrot/tools/google/__init__.py +18 -0
- parrot/tools/google/base.py +169 -0
- parrot/tools/google/tools.py +1251 -0
- parrot/tools/googlelocation.py +5 -0
- parrot/tools/googleroutes.py +5 -0
- parrot/tools/googlesearch.py +5 -0
- parrot/tools/googlesitesearch.py +5 -0
- parrot/tools/googlevoice.py +2 -0
- parrot/tools/gvoice.py +695 -0
- parrot/tools/ibisworld/README.md +225 -0
- parrot/tools/ibisworld/__init__.py +11 -0
- parrot/tools/ibisworld/tool.py +366 -0
- parrot/tools/jiratoolkit.py +1718 -0
- parrot/tools/manager.py +1098 -0
- parrot/tools/math.py +152 -0
- parrot/tools/metadata.py +476 -0
- parrot/tools/msteams.py +1621 -0
- parrot/tools/msword.py +635 -0
- parrot/tools/multidb.py +580 -0
- parrot/tools/multistoresearch.py +369 -0
- parrot/tools/networkninja.py +167 -0
- parrot/tools/nextstop/__init__.py +4 -0
- parrot/tools/nextstop/base.py +286 -0
- parrot/tools/nextstop/employee.py +733 -0
- parrot/tools/nextstop/store.py +462 -0
- parrot/tools/notification.py +435 -0
- parrot/tools/o365/__init__.py +42 -0
- parrot/tools/o365/base.py +295 -0
- parrot/tools/o365/bundle.py +522 -0
- parrot/tools/o365/events.py +554 -0
- parrot/tools/o365/mail.py +992 -0
- parrot/tools/o365/onedrive.py +497 -0
- parrot/tools/o365/sharepoint.py +641 -0
- parrot/tools/openapi_toolkit.py +904 -0
- parrot/tools/openweather.py +527 -0
- parrot/tools/pdfprint.py +1001 -0
- parrot/tools/powerbi.py +518 -0
- parrot/tools/powerpoint.py +1113 -0
- parrot/tools/pricestool.py +146 -0
- parrot/tools/products/__init__.py +246 -0
- parrot/tools/prophet_tool.py +171 -0
- parrot/tools/pythonpandas.py +630 -0
- parrot/tools/pythonrepl.py +910 -0
- parrot/tools/qsource.py +436 -0
- parrot/tools/querytoolkit.py +395 -0
- parrot/tools/quickeda.py +827 -0
- parrot/tools/resttool.py +553 -0
- parrot/tools/retail/__init__.py +0 -0
- parrot/tools/retail/bby.py +528 -0
- parrot/tools/sandboxtool.py +703 -0
- parrot/tools/sassie/__init__.py +352 -0
- parrot/tools/scraping/__init__.py +7 -0
- parrot/tools/scraping/docs/select.md +466 -0
- parrot/tools/scraping/documentation.md +1278 -0
- parrot/tools/scraping/driver.py +436 -0
- parrot/tools/scraping/models.py +576 -0
- parrot/tools/scraping/options.py +85 -0
- parrot/tools/scraping/orchestrator.py +517 -0
- parrot/tools/scraping/readme.md +740 -0
- parrot/tools/scraping/tool.py +3115 -0
- parrot/tools/seasonaldetection.py +642 -0
- parrot/tools/shell_tool/__init__.py +5 -0
- parrot/tools/shell_tool/actions.py +408 -0
- parrot/tools/shell_tool/engine.py +155 -0
- parrot/tools/shell_tool/models.py +322 -0
- parrot/tools/shell_tool/tool.py +442 -0
- parrot/tools/site_search.py +214 -0
- parrot/tools/textfile.py +418 -0
- parrot/tools/think.py +378 -0
- parrot/tools/toolkit.py +298 -0
- parrot/tools/webapp_tool.py +187 -0
- parrot/tools/whatif.py +1279 -0
- parrot/tools/workday/MULTI_WSDL_EXAMPLE.md +249 -0
- parrot/tools/workday/__init__.py +6 -0
- parrot/tools/workday/models.py +1389 -0
- parrot/tools/workday/tool.py +1293 -0
- parrot/tools/yfinance_tool.py +306 -0
- parrot/tools/zipcode.py +217 -0
- parrot/utils/__init__.py +2 -0
- parrot/utils/helpers.py +73 -0
- parrot/utils/parsers/__init__.py +5 -0
- parrot/utils/parsers/toml.c +12078 -0
- parrot/utils/parsers/toml.cpython-310-x86_64-linux-gnu.so +0 -0
- parrot/utils/parsers/toml.pyx +21 -0
- parrot/utils/toml.py +11 -0
- parrot/utils/types.cpp +20936 -0
- parrot/utils/types.cpython-310-x86_64-linux-gnu.so +0 -0
- parrot/utils/types.pyx +213 -0
- parrot/utils/uv.py +11 -0
- parrot/version.py +10 -0
- parrot/yaml-rs/Cargo.lock +350 -0
- parrot/yaml-rs/Cargo.toml +19 -0
- parrot/yaml-rs/pyproject.toml +19 -0
- parrot/yaml-rs/python/yaml_rs/__init__.py +81 -0
- parrot/yaml-rs/src/lib.rs +222 -0
- requirements/docker-compose.yml +24 -0
- requirements/requirements-dev.txt +21 -0
parrot/clients/gpt.py
ADDED
|
@@ -0,0 +1,1975 @@
|
|
|
1
|
+
from typing import AsyncIterator, Dict, List, Optional, Union, Any, Tuple, Iterable
|
|
2
|
+
import base64
|
|
3
|
+
import io
|
|
4
|
+
import json
|
|
5
|
+
import mimetypes
|
|
6
|
+
import uuid
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import time
|
|
9
|
+
import asyncio
|
|
10
|
+
from logging import getLogger
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from dataclasses import is_dataclass
|
|
13
|
+
from PIL import Image
|
|
14
|
+
import pytesseract
|
|
15
|
+
from pydantic import BaseModel, ValidationError, TypeAdapter
|
|
16
|
+
from datamodel.parsers.json import json_decoder, json_decoder # pylint: disable=E0611 # noqa
|
|
17
|
+
from navconfig import config, BASE_DIR
|
|
18
|
+
from tenacity import (
|
|
19
|
+
AsyncRetrying,
|
|
20
|
+
stop_after_attempt,
|
|
21
|
+
wait_exponential,
|
|
22
|
+
retry_if_exception_type
|
|
23
|
+
)
|
|
24
|
+
from openai import AsyncOpenAI
|
|
25
|
+
from openai import APIConnectionError, RateLimitError, APIError, BadRequestError
|
|
26
|
+
from .base import AbstractClient
|
|
27
|
+
from ..models import (
|
|
28
|
+
AIMessage,
|
|
29
|
+
AIMessageFactory,
|
|
30
|
+
ToolCall,
|
|
31
|
+
CompletionUsage,
|
|
32
|
+
VideoGenerationPrompt,
|
|
33
|
+
StructuredOutputConfig,
|
|
34
|
+
OutputFormat
|
|
35
|
+
)
|
|
36
|
+
from ..models.openai import OpenAIModel
|
|
37
|
+
from ..models.outputs import (
|
|
38
|
+
SentimentAnalysis,
|
|
39
|
+
ProductReview
|
|
40
|
+
)
|
|
41
|
+
from ..models.detections import (
|
|
42
|
+
DetectionBox,
|
|
43
|
+
ShelfRegion,
|
|
44
|
+
IdentifiedProduct
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
getLogger('httpx').setLevel('WARNING')
|
|
49
|
+
getLogger('httpcore').setLevel('WARNING')
|
|
50
|
+
getLogger('openai').setLevel('INFO')
|
|
51
|
+
|
|
52
|
+
# Reasoning models like o3 / o3-pro / o3-mini and o4-mini
|
|
53
|
+
# (including deep-research variants) are Responses-only.
|
|
54
|
+
RESPONSES_ONLY_MODELS = {
|
|
55
|
+
"o3",
|
|
56
|
+
"o3-pro",
|
|
57
|
+
"o3-mini",
|
|
58
|
+
"o3-deep-research",
|
|
59
|
+
"o4-mini",
|
|
60
|
+
"o4-mini-deep-research",
|
|
61
|
+
"gpt-5-pro",
|
|
62
|
+
"gpt-5-mini",
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
STRUCTURED_OUTPUT_COMPATIBLE_MODELS = {
|
|
66
|
+
OpenAIModel.GPT_4O_MINI.value,
|
|
67
|
+
OpenAIModel.GPT_O4.value,
|
|
68
|
+
OpenAIModel.GPT_4O.value,
|
|
69
|
+
OpenAIModel.GPT4_1.value,
|
|
70
|
+
OpenAIModel.GPT_4_1_MINI.value,
|
|
71
|
+
OpenAIModel.GPT_4_1_NANO.value,
|
|
72
|
+
OpenAIModel.GPT5_MINI.value,
|
|
73
|
+
OpenAIModel.GPT5.value,
|
|
74
|
+
OpenAIModel.GPT5_CHAT.value,
|
|
75
|
+
OpenAIModel.GPT5_PRO.value,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
DEFAULT_STRUCTURED_OUTPUT_MODEL = OpenAIModel.GPT_4O_MINI.value
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class OpenAIClient(AbstractClient):
|
|
82
|
+
"""Client for interacting with OpenAI's API."""
|
|
83
|
+
|
|
84
|
+
client_type: str = 'openai'
|
|
85
|
+
model: str = OpenAIModel.GPT4_TURBO.value
|
|
86
|
+
client_name: str = 'openai'
|
|
87
|
+
_default_model: str = 'gpt-4o-mini'
|
|
88
|
+
|
|
89
|
+
def __init__(
|
|
90
|
+
self,
|
|
91
|
+
api_key: str = None,
|
|
92
|
+
base_url: str = "https://api.openai.com/v1",
|
|
93
|
+
**kwargs
|
|
94
|
+
):
|
|
95
|
+
self.api_key = api_key or config.get('OPENAI_API_KEY')
|
|
96
|
+
self.base_url = base_url
|
|
97
|
+
self.base_headers = {
|
|
98
|
+
"Content-Type": "application/json",
|
|
99
|
+
"Authorization": f"Bearer {self.api_key}"
|
|
100
|
+
}
|
|
101
|
+
super().__init__(**kwargs)
|
|
102
|
+
|
|
103
|
+
async def get_client(self) -> AsyncOpenAI:
|
|
104
|
+
"""Initialize the OpenAI client."""
|
|
105
|
+
return AsyncOpenAI(
|
|
106
|
+
api_key=self.api_key,
|
|
107
|
+
base_url=self.base_url,
|
|
108
|
+
timeout=config.get('OPENAI_TIMEOUT', 60),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
async def _download_openai_file(self, file_id: str) -> Optional[bytes]:
|
|
112
|
+
"""Download a file from OpenAI's Files API handling various SDK shapes."""
|
|
113
|
+
if not file_id:
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
files_resource = getattr(self.client, "files", None)
|
|
117
|
+
if files_resource is None:
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
candidate_methods = [
|
|
121
|
+
getattr(files_resource, "content", None),
|
|
122
|
+
getattr(files_resource, "retrieve_content", None),
|
|
123
|
+
getattr(files_resource, "download", None),
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
async def _invoke(method, *args, **kwargs):
|
|
127
|
+
if asyncio.iscoroutinefunction(method):
|
|
128
|
+
return await method(*args, **kwargs)
|
|
129
|
+
result = method(*args, **kwargs)
|
|
130
|
+
if asyncio.iscoroutine(result):
|
|
131
|
+
result = await result
|
|
132
|
+
return result
|
|
133
|
+
|
|
134
|
+
arg_permutations = [
|
|
135
|
+
((file_id,), {}),
|
|
136
|
+
((), {"id": file_id}),
|
|
137
|
+
((), {"id": file_id}),
|
|
138
|
+
((), {"file": file_id}),
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
for method in candidate_methods:
|
|
142
|
+
if method is None:
|
|
143
|
+
continue
|
|
144
|
+
|
|
145
|
+
result = None
|
|
146
|
+
for args, kwargs in arg_permutations:
|
|
147
|
+
try:
|
|
148
|
+
result = await _invoke(method, *args, **kwargs)
|
|
149
|
+
break
|
|
150
|
+
except TypeError:
|
|
151
|
+
continue
|
|
152
|
+
except Exception: # pylint: disable=broad-except
|
|
153
|
+
result = None
|
|
154
|
+
continue
|
|
155
|
+
|
|
156
|
+
if result is None:
|
|
157
|
+
continue
|
|
158
|
+
|
|
159
|
+
if isinstance(result, bytes):
|
|
160
|
+
return result
|
|
161
|
+
|
|
162
|
+
if isinstance(result, dict):
|
|
163
|
+
if isinstance(result.get("data"), bytes):
|
|
164
|
+
return result["data"]
|
|
165
|
+
if isinstance(result.get("content"), bytes):
|
|
166
|
+
return result["content"]
|
|
167
|
+
|
|
168
|
+
if hasattr(result, "content"):
|
|
169
|
+
content = result.content
|
|
170
|
+
if asyncio.iscoroutine(content):
|
|
171
|
+
content = await content
|
|
172
|
+
if isinstance(content, bytes):
|
|
173
|
+
return content
|
|
174
|
+
|
|
175
|
+
if hasattr(result, "read"):
|
|
176
|
+
read_method = result.read
|
|
177
|
+
data = await read_method() if asyncio.iscoroutinefunction(read_method) else read_method()
|
|
178
|
+
if isinstance(data, bytes):
|
|
179
|
+
return data
|
|
180
|
+
|
|
181
|
+
if hasattr(result, "body") and hasattr(result.body, "read"):
|
|
182
|
+
read_method = result.body.read
|
|
183
|
+
data = await read_method() if asyncio.iscoroutinefunction(read_method) else read_method()
|
|
184
|
+
if isinstance(data, bytes):
|
|
185
|
+
return data
|
|
186
|
+
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
async def _upload_file(
|
|
190
|
+
self,
|
|
191
|
+
file_path: Union[str, Path],
|
|
192
|
+
purpose: str = 'fine-tune'
|
|
193
|
+
) -> None:
|
|
194
|
+
"""Upload a file to OpenAI."""
|
|
195
|
+
with open(file_path, 'rb') as file:
|
|
196
|
+
await self.client.files.create(
|
|
197
|
+
file=file,
|
|
198
|
+
purpose=purpose
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
async def _chat_completion(
|
|
202
|
+
self,
|
|
203
|
+
model: str,
|
|
204
|
+
messages: Any,
|
|
205
|
+
use_tools: bool = False,
|
|
206
|
+
**kwargs
|
|
207
|
+
):
|
|
208
|
+
retry_policy = AsyncRetrying(
|
|
209
|
+
retry=retry_if_exception_type((APIConnectionError, RateLimitError, APIError)),
|
|
210
|
+
wait=wait_exponential(multiplier=1, min=2, max=10),
|
|
211
|
+
stop=stop_after_attempt(5),
|
|
212
|
+
reraise=True
|
|
213
|
+
)
|
|
214
|
+
if use_tools:
|
|
215
|
+
method = self.client.chat.completions.create
|
|
216
|
+
else:
|
|
217
|
+
method = getattr(self.client.chat.completions, 'parse', self.client.chat.completions.create)
|
|
218
|
+
async for attempt in retry_policy:
|
|
219
|
+
with attempt:
|
|
220
|
+
return await method(
|
|
221
|
+
model=model,
|
|
222
|
+
messages=messages,
|
|
223
|
+
**kwargs
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
def _is_responses_model(self, model_str: str) -> bool:
|
|
227
|
+
"""Return True if the selected model must go through Responses API."""
|
|
228
|
+
# allow aliases/enums already normalized to str
|
|
229
|
+
ms = (model_str or "").strip()
|
|
230
|
+
return ms in RESPONSES_ONLY_MODELS
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _prepare_responses_args(self, *, messages, args):
|
|
234
|
+
"""
|
|
235
|
+
Map your existing args/messages into Responses API fields.
|
|
236
|
+
|
|
237
|
+
- Lift the first system message into `instructions` when present
|
|
238
|
+
- Keep the rest as chat-style list under `input`
|
|
239
|
+
- Pass tools/response_format/temperature/max_output_tokens if provided
|
|
240
|
+
"""
|
|
241
|
+
|
|
242
|
+
def _as_response_content(role: str, content: Any, message: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
243
|
+
"""Translate chat `content` into Responses-style content blocks."""
|
|
244
|
+
|
|
245
|
+
def _normalize_text(text_value: Any, *, text_type: str) -> Optional[Dict[str, Any]]:
|
|
246
|
+
if text_value is None:
|
|
247
|
+
return None
|
|
248
|
+
text = text_value if isinstance(text_value, str) else str(text_value)
|
|
249
|
+
if not text:
|
|
250
|
+
return None
|
|
251
|
+
return {"type": text_type, "text": text}
|
|
252
|
+
|
|
253
|
+
if role == "tool":
|
|
254
|
+
tool_call_id = message.get("tool_call_id")
|
|
255
|
+
# Responses expects tool output blocks
|
|
256
|
+
if isinstance(content, list):
|
|
257
|
+
normalized_output = "\n".join(
|
|
258
|
+
str(part) if not isinstance(part, dict) else str(part.get("text") or part.get("output") or "")
|
|
259
|
+
for part in content
|
|
260
|
+
)
|
|
261
|
+
else:
|
|
262
|
+
normalized_output = "" if content is None else str(content)
|
|
263
|
+
|
|
264
|
+
block = {
|
|
265
|
+
"type": "tool_output",
|
|
266
|
+
"tool_call_id": tool_call_id,
|
|
267
|
+
"output": normalized_output,
|
|
268
|
+
}
|
|
269
|
+
if message.get("name"):
|
|
270
|
+
block["name"] = message["name"]
|
|
271
|
+
return [block]
|
|
272
|
+
|
|
273
|
+
text_type = "input_text" if role in {"user", "tool_user"} else "output_text"
|
|
274
|
+
|
|
275
|
+
parts: List[Dict[str, Any]] = []
|
|
276
|
+
|
|
277
|
+
def _append_text(value: Any):
|
|
278
|
+
block = _normalize_text(value, text_type=text_type)
|
|
279
|
+
if block:
|
|
280
|
+
parts.append(block)
|
|
281
|
+
|
|
282
|
+
if isinstance(content, list):
|
|
283
|
+
for item in content:
|
|
284
|
+
if isinstance(item, dict):
|
|
285
|
+
item_type = item.get("type")
|
|
286
|
+
|
|
287
|
+
if item_type in {
|
|
288
|
+
"input_text",
|
|
289
|
+
"output_text",
|
|
290
|
+
"input_image",
|
|
291
|
+
"input_audio",
|
|
292
|
+
"tool_output",
|
|
293
|
+
"tool_call",
|
|
294
|
+
"input_file",
|
|
295
|
+
"computer_screenshot",
|
|
296
|
+
"summary_text",
|
|
297
|
+
}:
|
|
298
|
+
parts.append(item)
|
|
299
|
+
continue
|
|
300
|
+
|
|
301
|
+
if item_type == "text":
|
|
302
|
+
_append_text(item.get("text"))
|
|
303
|
+
continue
|
|
304
|
+
|
|
305
|
+
if item_type is None and {"id", "function"}.issubset(item.keys()):
|
|
306
|
+
parts.append(
|
|
307
|
+
{
|
|
308
|
+
"type": "tool_call",
|
|
309
|
+
"id": item.get("id"),
|
|
310
|
+
"name": (item.get("function") or {}).get("name"),
|
|
311
|
+
"arguments": (item.get("function") or {}).get("arguments"),
|
|
312
|
+
}
|
|
313
|
+
)
|
|
314
|
+
continue
|
|
315
|
+
|
|
316
|
+
parts.append(item)
|
|
317
|
+
else:
|
|
318
|
+
_append_text(item)
|
|
319
|
+
else:
|
|
320
|
+
_append_text(content)
|
|
321
|
+
|
|
322
|
+
if role == "assistant" and message.get("tool_calls"):
|
|
323
|
+
for tool_call in message["tool_calls"]:
|
|
324
|
+
if isinstance(tool_call, dict):
|
|
325
|
+
parts.append(
|
|
326
|
+
{
|
|
327
|
+
"type": "tool_call",
|
|
328
|
+
"id": tool_call.get("id"),
|
|
329
|
+
"name": (tool_call.get("function") or {}).get("name"),
|
|
330
|
+
"arguments": (tool_call.get("function") or {}).get("arguments"),
|
|
331
|
+
}
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
return parts
|
|
335
|
+
|
|
336
|
+
instructions = None
|
|
337
|
+
input_msgs = []
|
|
338
|
+
for m in messages:
|
|
339
|
+
role = m.get("role")
|
|
340
|
+
if role == "system" and instructions is None:
|
|
341
|
+
sys_content = m.get("content")
|
|
342
|
+
if isinstance(sys_content, list):
|
|
343
|
+
instructions = " ".join(
|
|
344
|
+
part.get("text", "") if isinstance(part, dict) else str(part)
|
|
345
|
+
for part in sys_content
|
|
346
|
+
).strip()
|
|
347
|
+
else:
|
|
348
|
+
instructions = sys_content
|
|
349
|
+
continue
|
|
350
|
+
|
|
351
|
+
content_blocks = _as_response_content(role, m.get("content"), m)
|
|
352
|
+
msg_payload: Dict[str, Any] = {"role": role, "content": content_blocks}
|
|
353
|
+
|
|
354
|
+
if m.get("tool_calls"):
|
|
355
|
+
msg_payload["tool_calls"] = m["tool_calls"]
|
|
356
|
+
if m.get("tool_call_id"):
|
|
357
|
+
msg_payload["tool_call_id"] = m["tool_call_id"]
|
|
358
|
+
if m.get("name"):
|
|
359
|
+
msg_payload["name"] = m["name"]
|
|
360
|
+
|
|
361
|
+
input_msgs.append(msg_payload)
|
|
362
|
+
|
|
363
|
+
req = {
|
|
364
|
+
"instructions": instructions,
|
|
365
|
+
"input": input_msgs,
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if "tools" in args:
|
|
369
|
+
req["tools"] = args["tools"]
|
|
370
|
+
if "tool_choice" in args:
|
|
371
|
+
req["tool_choice"] = args["tool_choice"]
|
|
372
|
+
if "temperature" in args and args["temperature"] is not None:
|
|
373
|
+
req["temperature"] = args["temperature"]
|
|
374
|
+
if "max_tokens" in args and args["max_tokens"] is not None:
|
|
375
|
+
req["max_output_tokens"] = args["max_tokens"]
|
|
376
|
+
if "parallel_tool_calls" in args:
|
|
377
|
+
req["parallel_tool_calls"] = args["parallel_tool_calls"]
|
|
378
|
+
return req
|
|
379
|
+
|
|
380
|
+
@staticmethod
|
|
381
|
+
def _with_extra_body(payload: Dict[str, Any], extra_body: Dict[str, Any]) -> Dict[str, Any]:
|
|
382
|
+
merged = dict(payload)
|
|
383
|
+
existing_raw = merged.pop("extra_body", None)
|
|
384
|
+
existing = (
|
|
385
|
+
dict(existing_raw) if isinstance(existing_raw, dict) else {}
|
|
386
|
+
) | extra_body
|
|
387
|
+
if existing:
|
|
388
|
+
merged["extra_body"] = existing
|
|
389
|
+
return merged
|
|
390
|
+
|
|
391
|
+
async def _call_responses_create(self, payloads):
|
|
392
|
+
"""
|
|
393
|
+
Try several payload shapes against responses.create().
|
|
394
|
+
We retry not only on TypeError (client-side signature issues)
|
|
395
|
+
but also on BadRequestError when the server reports unknown params,
|
|
396
|
+
so we can fall back to older-SDK-compatible shapes.
|
|
397
|
+
"""
|
|
398
|
+
last_exc = None
|
|
399
|
+
for payload in payloads:
|
|
400
|
+
try:
|
|
401
|
+
return await self.client.responses.create(**payload)
|
|
402
|
+
except TypeError as exc:
|
|
403
|
+
last_exc = exc
|
|
404
|
+
except BadRequestError as exc:
|
|
405
|
+
# 2.6.0 returns 400 unknown_parameter for fields like "response", "modalities", etc.
|
|
406
|
+
msg = getattr(exc, "message", "") or ""
|
|
407
|
+
body = getattr(getattr(exc, "response", None), "json", lambda: {})()
|
|
408
|
+
code = (body.get("error") or {}).get("code", "")
|
|
409
|
+
param = (body.get("error") or {}).get("param", "")
|
|
410
|
+
if code == "unknown_parameter" or "Unknown parameter" in msg or param in {"response", "modalities", "video"}:
|
|
411
|
+
last_exc = exc
|
|
412
|
+
continue
|
|
413
|
+
raise # other 400s should bubble up
|
|
414
|
+
if last_exc:
|
|
415
|
+
raise last_exc
|
|
416
|
+
raise RuntimeError(
|
|
417
|
+
"OpenAI responses.create call failed without response"
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
async def _call_responses_stream(self, payloads):
|
|
421
|
+
"""
|
|
422
|
+
Try several payload shapes against responses.stream(), mirroring
|
|
423
|
+
the compatibility shims we use for responses.create().
|
|
424
|
+
"""
|
|
425
|
+
last_exc = None
|
|
426
|
+
for payload in payloads:
|
|
427
|
+
try:
|
|
428
|
+
return await self.client.responses.stream(**payload)
|
|
429
|
+
except TypeError as exc:
|
|
430
|
+
last_exc = exc
|
|
431
|
+
except BadRequestError as exc:
|
|
432
|
+
msg = getattr(exc, "message", "") or ""
|
|
433
|
+
body = getattr(getattr(exc, "response", None), "json", lambda: {})()
|
|
434
|
+
code = (body.get("error") or {}).get("code", "")
|
|
435
|
+
param = (body.get("error") or {}).get("param", "")
|
|
436
|
+
if code == "unknown_parameter" or "Unknown parameter" in msg or param in {"response", "modalities", "video"}:
|
|
437
|
+
last_exc = exc
|
|
438
|
+
continue
|
|
439
|
+
raise
|
|
440
|
+
if last_exc:
|
|
441
|
+
raise last_exc
|
|
442
|
+
raise RuntimeError(
|
|
443
|
+
"OpenAI responses.stream call failed without response"
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
async def _responses_completion(
|
|
447
|
+
self,
|
|
448
|
+
*,
|
|
449
|
+
model: str,
|
|
450
|
+
messages,
|
|
451
|
+
**args
|
|
452
|
+
):
|
|
453
|
+
"""
|
|
454
|
+
Adapter around OpenAI Responses API that mimics Chat Completions:
|
|
455
|
+
returns an object with `.choices[0].message` where `message` has
|
|
456
|
+
`.content: str` and `.tool_calls: list` (each item has `.id` and `.function.{name,arguments}`).
|
|
457
|
+
"""
|
|
458
|
+
# 1) Build request payload from chat-like messages/args
|
|
459
|
+
resp_format = args.get("response_format")
|
|
460
|
+
req = self._prepare_responses_args(messages=messages, args=args)
|
|
461
|
+
req["model"] = model
|
|
462
|
+
|
|
463
|
+
# 2) Call Responses API
|
|
464
|
+
payload_base = dict(req)
|
|
465
|
+
payload_base.pop("response", None)
|
|
466
|
+
payload_base.pop("response_format", None)
|
|
467
|
+
|
|
468
|
+
attempts: List[Dict[str, Any]] = []
|
|
469
|
+
if resp_format:
|
|
470
|
+
# 2.6-compatible first:
|
|
471
|
+
attempts.append({**payload_base, "response_format": resp_format})
|
|
472
|
+
# Fallback to future SDKs that accept namespaced `response`:
|
|
473
|
+
attempts.append(self._with_extra_body(payload_base, {"response": {"format": resp_format}}))
|
|
474
|
+
# Last resort: drop structured constraints
|
|
475
|
+
attempts.append(dict(payload_base))
|
|
476
|
+
else:
|
|
477
|
+
attempts.append(dict(payload_base))
|
|
478
|
+
|
|
479
|
+
resp = await self._call_responses_create(attempts)
|
|
480
|
+
|
|
481
|
+
# 3) Extract best-effort text
|
|
482
|
+
output_text = getattr(resp, "output_text", None)
|
|
483
|
+
if output_text is None:
|
|
484
|
+
output_text = ""
|
|
485
|
+
for item in getattr(resp, "output", []) or []:
|
|
486
|
+
for part in getattr(item, "content", []) or []:
|
|
487
|
+
# common shapes the SDK returns
|
|
488
|
+
if isinstance(part, dict):
|
|
489
|
+
if part.get("type") == "output_text":
|
|
490
|
+
output_text += part.get("text", "") or ""
|
|
491
|
+
elif (text := getattr(part, "text", None)):
|
|
492
|
+
output_text += text
|
|
493
|
+
|
|
494
|
+
# 4) Extract & normalize tool calls
|
|
495
|
+
# We shape them to look like Chat Completions tool_calls:
|
|
496
|
+
# {"id":..., "function": {"name": ..., "arguments": "<json string>"}}
|
|
497
|
+
norm_tool_calls = []
|
|
498
|
+
finish_reason = None
|
|
499
|
+
stop_reason = None
|
|
500
|
+
for item in getattr(resp, "output", []) or []:
|
|
501
|
+
for part in getattr(item, "content", []) or []:
|
|
502
|
+
if isinstance(part, dict) and part.get("type") == "tool_call":
|
|
503
|
+
_id = part.get("id") or part.get("tool_call_id") or str(uuid.uuid4())
|
|
504
|
+
_name = part.get("name")
|
|
505
|
+
_args = part.get("arguments", {})
|
|
506
|
+
# ensure arguments is a JSON string (Chat-style)
|
|
507
|
+
if not isinstance(_args, str):
|
|
508
|
+
try:
|
|
509
|
+
_args = self._json.dumps(_args)
|
|
510
|
+
except Exception:
|
|
511
|
+
_args = json.dumps(_args, default=str)
|
|
512
|
+
|
|
513
|
+
# tiny compatibility holders
|
|
514
|
+
class _Fn:
|
|
515
|
+
def __init__(self, name, arguments):
|
|
516
|
+
self.name = name
|
|
517
|
+
self.arguments = arguments
|
|
518
|
+
class _ToolCall:
|
|
519
|
+
def __init__(self, id, function):
|
|
520
|
+
self.id = id
|
|
521
|
+
self.function = function
|
|
522
|
+
|
|
523
|
+
norm_tool_calls.append(_ToolCall(_id, _Fn(_name, _args)))
|
|
524
|
+
|
|
525
|
+
finish_reason = finish_reason or getattr(item, "finish_reason", None)
|
|
526
|
+
if isinstance(item, dict):
|
|
527
|
+
finish_reason = finish_reason or item.get("finish_reason")
|
|
528
|
+
stop_reason = stop_reason or getattr(item, "stop_reason", None)
|
|
529
|
+
if isinstance(item, dict):
|
|
530
|
+
stop_reason = stop_reason or item.get("stop_reason")
|
|
531
|
+
|
|
532
|
+
# 5) Build a Chat-like container
|
|
533
|
+
class _Msg:
|
|
534
|
+
def __init__(self, content, tool_calls):
|
|
535
|
+
self.content = content
|
|
536
|
+
self.tool_calls = tool_calls
|
|
537
|
+
|
|
538
|
+
class _Choice:
|
|
539
|
+
def __init__(self, message, *, finish_reason=None, stop_reason=None):
|
|
540
|
+
self.message = message
|
|
541
|
+
self.finish_reason = finish_reason
|
|
542
|
+
self.stop_reason = stop_reason
|
|
543
|
+
|
|
544
|
+
class _CompatResp:
|
|
545
|
+
def __init__(self, raw, message, *, finish_reason=None, stop_reason=None):
|
|
546
|
+
self.raw = raw
|
|
547
|
+
self.choices = [_Choice(message, finish_reason=finish_reason, stop_reason=stop_reason)]
|
|
548
|
+
# Usage may or may not exist; keep attribute for downstream code
|
|
549
|
+
self.usage = getattr(raw, "usage", None)
|
|
550
|
+
|
|
551
|
+
message = _Msg(output_text or "", norm_tool_calls)
|
|
552
|
+
return _CompatResp(
|
|
553
|
+
resp,
|
|
554
|
+
message,
|
|
555
|
+
finish_reason=finish_reason,
|
|
556
|
+
stop_reason=stop_reason,
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
async def ask(
|
|
560
|
+
self,
|
|
561
|
+
prompt: str,
|
|
562
|
+
model: Union[str, OpenAIModel] = OpenAIModel.GPT4_1,
|
|
563
|
+
max_tokens: Optional[int] = None,
|
|
564
|
+
temperature: Optional[float] = None,
|
|
565
|
+
files: Optional[List[Union[str, Path]]] = None,
|
|
566
|
+
system_prompt: Optional[str] = None,
|
|
567
|
+
structured_output: Union[type, StructuredOutputConfig, None] = None,
|
|
568
|
+
user_id: Optional[str] = None,
|
|
569
|
+
session_id: Optional[str] = None,
|
|
570
|
+
tools: Optional[List[Dict[str, Any]]] = None,
|
|
571
|
+
use_tools: Optional[bool] = None,
|
|
572
|
+
deep_research: bool = False,
|
|
573
|
+
background: bool = False,
|
|
574
|
+
vector_store_ids: Optional[List[str]] = None,
|
|
575
|
+
enable_web_search: bool = True,
|
|
576
|
+
enable_code_interpreter: bool = False,
|
|
577
|
+
lazy_loading: bool = False,
|
|
578
|
+
) -> AIMessage:
|
|
579
|
+
"""Ask OpenAI a question with optional conversation memory.
|
|
580
|
+
|
|
581
|
+
Args:
|
|
582
|
+
prompt (str): The prompt to send to the model.
|
|
583
|
+
model (Union[str, OpenAIModel], optional): The model to use. Defaults to GPT4_TURBO.
|
|
584
|
+
max_tokens (Optional[int], optional): Maximum tokens for the response. Defaults to None.
|
|
585
|
+
temperature (Optional[float], optional): Sampling temperature. Defaults to None.
|
|
586
|
+
files (Optional[List[Union[str, Path]]], optional): Files to upload. Defaults to None.
|
|
587
|
+
system_prompt (Optional[str], optional): System prompt to prepend. Defaults to None.
|
|
588
|
+
structured_output (Union[type, StructuredOutputConfig, None], optional):
|
|
589
|
+
Structured output definition, supporting Pydantic models, dataclasses,
|
|
590
|
+
or explicit StructuredOutputConfig instances. Defaults to None.
|
|
591
|
+
user_id (Optional[str], optional): User ID for conversation memory. Defaults to None.
|
|
592
|
+
session_id (Optional[str], optional): Session ID for conversation memory. Defaults to None.
|
|
593
|
+
tools (Optional[List[Dict[str, Any]]], optional): Tools to register for this call. Defaults to None.
|
|
594
|
+
use_tools (Optional[bool], optional): Whether to use tools. Defaults to None.
|
|
595
|
+
deep_research (bool): If True, use OpenAI's deep research models (o3/o4-deep-research).
|
|
596
|
+
background (bool): If True, execute research in background (not yet supported).
|
|
597
|
+
vector_store_ids (Optional[List[str]]): Vector store IDs for file_search tool.
|
|
598
|
+
enable_web_search (bool): Enable web search preview tool (default: True for deep research).
|
|
599
|
+
enable_code_interpreter (bool): Enable code interpreter tool.
|
|
600
|
+
lazy_loading (bool): If True, enable dynamic tool searching.
|
|
601
|
+
|
|
602
|
+
Returns:
|
|
603
|
+
AIMessage: The response from the model.
|
|
604
|
+
|
|
605
|
+
"""
|
|
606
|
+
|
|
607
|
+
turn_id = str(uuid.uuid4())
|
|
608
|
+
original_prompt = prompt
|
|
609
|
+
_use_tools = use_tools if use_tools is not None else self.enable_tools
|
|
610
|
+
|
|
611
|
+
model_str = model.value if isinstance(model, Enum) else str(model)
|
|
612
|
+
|
|
613
|
+
# Deep research routing: switch to deep research model if requested
|
|
614
|
+
if deep_research:
|
|
615
|
+
# Use o3-deep-research as default deep research model
|
|
616
|
+
if model_str in {"gpt-4o-mini", "gpt-4o", "gpt-4-turbo", OpenAIModel.GPT4_1.value}:
|
|
617
|
+
model_str = "o3-deep-research"
|
|
618
|
+
self.logger.info(f"Deep research enabled: switching to {model_str}")
|
|
619
|
+
elif model_str not in RESPONSES_ONLY_MODELS:
|
|
620
|
+
# If not already a deep research model, switch to it
|
|
621
|
+
model_str = "o3-deep-research"
|
|
622
|
+
self.logger.info(f"Deep research enabled: switching to {model_str}")
|
|
623
|
+
|
|
624
|
+
messages, conversation_session, system_prompt = await self._prepare_conversation_context(
|
|
625
|
+
prompt, files, user_id, session_id, system_prompt
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
if files:
|
|
629
|
+
for file in files:
|
|
630
|
+
if isinstance(file, str):
|
|
631
|
+
file = Path(file)
|
|
632
|
+
if isinstance(file, Path):
|
|
633
|
+
await self._upload_file(file)
|
|
634
|
+
|
|
635
|
+
# Add search instruction if lazy loading is enabled
|
|
636
|
+
if lazy_loading and system_prompt:
|
|
637
|
+
system_prompt += "\n\nYou have access to a library of tools. Use the 'search_tools' function to find relevant tools."
|
|
638
|
+
elif lazy_loading and not system_prompt:
|
|
639
|
+
system_prompt = "You have access to a library of tools. Use the 'search_tools' function to find relevant tools."
|
|
640
|
+
|
|
641
|
+
if system_prompt:
|
|
642
|
+
messages.insert(0, {"role": "system", "content": system_prompt})
|
|
643
|
+
|
|
644
|
+
messages.append({"role": "user", "content": prompt})
|
|
645
|
+
|
|
646
|
+
all_tool_calls = []
|
|
647
|
+
|
|
648
|
+
output_config = self._get_structured_config(
|
|
649
|
+
structured_output
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
# Build tools for deep research or regular use
|
|
653
|
+
research_tools = []
|
|
654
|
+
if deep_research:
|
|
655
|
+
# For deep research, build specialized tools array
|
|
656
|
+
if enable_web_search:
|
|
657
|
+
research_tools.append({"type": "web_search_preview"})
|
|
658
|
+
|
|
659
|
+
if vector_store_ids:
|
|
660
|
+
research_tools.append({
|
|
661
|
+
"type": "file_search",
|
|
662
|
+
"vector_store_ids": vector_store_ids
|
|
663
|
+
})
|
|
664
|
+
|
|
665
|
+
if enable_code_interpreter:
|
|
666
|
+
research_tools.append({
|
|
667
|
+
"type": "code_interpreter",
|
|
668
|
+
"container": {"type": "auto"}
|
|
669
|
+
})
|
|
670
|
+
|
|
671
|
+
self.logger.info(f"Deep research tools configured: {len(research_tools)} tools")
|
|
672
|
+
|
|
673
|
+
# tools prep
|
|
674
|
+
if tools and isinstance(tools, list):
|
|
675
|
+
for tool in tools:
|
|
676
|
+
self.register_tool(tool)
|
|
677
|
+
|
|
678
|
+
# LAZY LOADING LOGIC
|
|
679
|
+
active_tool_names = set()
|
|
680
|
+
prepared_tools = None
|
|
681
|
+
|
|
682
|
+
if _use_tools:
|
|
683
|
+
if lazy_loading:
|
|
684
|
+
# Prepare only search_tools + explicitly passed tools?
|
|
685
|
+
# Using _prepare_lazy_tools which handles search_tools
|
|
686
|
+
prepared_tools = self._prepare_lazy_tools()
|
|
687
|
+
if prepared_tools:
|
|
688
|
+
active_tool_names.add("search_tools")
|
|
689
|
+
else:
|
|
690
|
+
prepared_tools = self._prepare_tools()
|
|
691
|
+
|
|
692
|
+
args = {}
|
|
693
|
+
if model_str in {
|
|
694
|
+
OpenAIModel.GPT_4O_MINI_SEARCH.value,
|
|
695
|
+
OpenAIModel.GPT_4O_SEARCH.value
|
|
696
|
+
}:
|
|
697
|
+
args['web_search_options'] = {
|
|
698
|
+
"web_search": True,
|
|
699
|
+
"web_search_model": "gpt-4o-mini"
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
# Merge research tools with regular tools
|
|
703
|
+
if deep_research and research_tools:
|
|
704
|
+
# For deep research, add research-specific tools
|
|
705
|
+
args['tools'] = research_tools
|
|
706
|
+
elif prepared_tools:
|
|
707
|
+
args['tools'] = prepared_tools
|
|
708
|
+
args['tool_choice'] = "auto"
|
|
709
|
+
args['parallel_tool_calls'] = True
|
|
710
|
+
|
|
711
|
+
if output_config and output_config.format == OutputFormat.JSON and model_str not in STRUCTURED_OUTPUT_COMPATIBLE_MODELS:
|
|
712
|
+
self.logger.warning(
|
|
713
|
+
"Model %s does not support structured outputs; switching to %s",
|
|
714
|
+
model_str,
|
|
715
|
+
DEFAULT_STRUCTURED_OUTPUT_MODEL
|
|
716
|
+
)
|
|
717
|
+
model_str = DEFAULT_STRUCTURED_OUTPUT_MODEL
|
|
718
|
+
|
|
719
|
+
if model_str != 'gpt-5-nano':
|
|
720
|
+
args['max_tokens'] = max_tokens or self.max_tokens
|
|
721
|
+
if temperature:
|
|
722
|
+
args['temperature'] = temperature
|
|
723
|
+
|
|
724
|
+
# -------- ROUTING: Responses-only vs Chat -----------
|
|
725
|
+
use_responses = self._is_responses_model(model_str)
|
|
726
|
+
resp_format = self._build_response_format_from(output_config) if output_config else None
|
|
727
|
+
|
|
728
|
+
if use_responses:
|
|
729
|
+
if output_config:
|
|
730
|
+
args['response_format'] = resp_format
|
|
731
|
+
response = await self._responses_completion(
|
|
732
|
+
model=model_str,
|
|
733
|
+
messages=messages,
|
|
734
|
+
**args
|
|
735
|
+
)
|
|
736
|
+
else:
|
|
737
|
+
if output_config:
|
|
738
|
+
args['response_format'] = resp_format
|
|
739
|
+
response = await self._chat_completion(
|
|
740
|
+
model=model_str,
|
|
741
|
+
messages=messages,
|
|
742
|
+
use_tools=_use_tools,
|
|
743
|
+
**args
|
|
744
|
+
)
|
|
745
|
+
|
|
746
|
+
result = response.choices[0].message
|
|
747
|
+
|
|
748
|
+
# ---------- Tool loop (works for both paths) ----------
|
|
749
|
+
while getattr(result, "tool_calls", None):
|
|
750
|
+
messages.append({
|
|
751
|
+
"role": "assistant",
|
|
752
|
+
"content": result.content,
|
|
753
|
+
"tool_calls": [
|
|
754
|
+
tc.model_dump() if hasattr(tc, "model_dump") else {
|
|
755
|
+
"id": tc.id,
|
|
756
|
+
"function": {
|
|
757
|
+
"name": getattr(tc.function, "name", None),
|
|
758
|
+
"arguments": getattr(tc.function, "arguments", "{}"),
|
|
759
|
+
},
|
|
760
|
+
}
|
|
761
|
+
for tc in result.tool_calls
|
|
762
|
+
]
|
|
763
|
+
})
|
|
764
|
+
|
|
765
|
+
found_new_tools = False
|
|
766
|
+
|
|
767
|
+
for tool_call in result.tool_calls:
|
|
768
|
+
tool_name = tool_call.function.name
|
|
769
|
+
try:
|
|
770
|
+
try:
|
|
771
|
+
tool_args = self._json.loads(tool_call.function.arguments)
|
|
772
|
+
except json.JSONDecodeError:
|
|
773
|
+
tool_args = json_decoder(tool_call.function.arguments)
|
|
774
|
+
|
|
775
|
+
tc = ToolCall(
|
|
776
|
+
id=getattr(tool_call, "id", ""),
|
|
777
|
+
name=tool_name,
|
|
778
|
+
arguments=tool_args
|
|
779
|
+
)
|
|
780
|
+
|
|
781
|
+
try:
|
|
782
|
+
start_time = time.time()
|
|
783
|
+
tool_result = await self._execute_tool(tool_name, tool_args)
|
|
784
|
+
execution_time = time.time() - start_time
|
|
785
|
+
|
|
786
|
+
# Lazy Loading Check
|
|
787
|
+
if lazy_loading and tool_name == "search_tools":
|
|
788
|
+
new_tools = self._check_new_tools(tool_name, str(tool_result))
|
|
789
|
+
if new_tools:
|
|
790
|
+
for nt in new_tools:
|
|
791
|
+
if nt not in active_tool_names:
|
|
792
|
+
active_tool_names.add(nt)
|
|
793
|
+
found_new_tools = True
|
|
794
|
+
|
|
795
|
+
tc.result = tool_result
|
|
796
|
+
tc.execution_time = execution_time
|
|
797
|
+
|
|
798
|
+
messages.append({
|
|
799
|
+
"role": "tool",
|
|
800
|
+
"tool_call_id": getattr(tool_call, "id", ""),
|
|
801
|
+
"name": tool_name,
|
|
802
|
+
"content": str(tool_result)
|
|
803
|
+
})
|
|
804
|
+
except Exception as e:
|
|
805
|
+
tc.error = str(e)
|
|
806
|
+
messages.append({
|
|
807
|
+
"role": "tool",
|
|
808
|
+
"tool_call_id": getattr(tool_call, "id", ""),
|
|
809
|
+
"name": tool_name,
|
|
810
|
+
"content": str(e)
|
|
811
|
+
})
|
|
812
|
+
|
|
813
|
+
all_tool_calls.append(tc)
|
|
814
|
+
|
|
815
|
+
except Exception as e:
|
|
816
|
+
all_tool_calls.append(ToolCall(
|
|
817
|
+
id=getattr(tool_call, "id", ""),
|
|
818
|
+
name=tool_name,
|
|
819
|
+
arguments={"_error": f"malformed tool args: {e}"}
|
|
820
|
+
))
|
|
821
|
+
messages.append({
|
|
822
|
+
"role": "tool",
|
|
823
|
+
"tool_call_id": getattr(tool_call, "id", ""),
|
|
824
|
+
"name": tool_name,
|
|
825
|
+
"content": f"Error decoding arguments: {e}"
|
|
826
|
+
})
|
|
827
|
+
|
|
828
|
+
# Re-prepare tools if new ones found
|
|
829
|
+
if lazy_loading and found_new_tools:
|
|
830
|
+
args['tools'] = self._prepare_tools(filter_names=list(active_tool_names))
|
|
831
|
+
# Note: We keep tool_choice='auto'
|
|
832
|
+
|
|
833
|
+
# continue via the same routed API
|
|
834
|
+
if use_responses:
|
|
835
|
+
if output_config:
|
|
836
|
+
args['response_format'] = resp_format
|
|
837
|
+
response = await self._responses_completion(
|
|
838
|
+
model=model_str,
|
|
839
|
+
messages=messages,
|
|
840
|
+
**args
|
|
841
|
+
)
|
|
842
|
+
else:
|
|
843
|
+
if output_config:
|
|
844
|
+
args['response_format'] = resp_format
|
|
845
|
+
response = await self._chat_completion(
|
|
846
|
+
model=model_str,
|
|
847
|
+
messages=messages,
|
|
848
|
+
use_tools=_use_tools,
|
|
849
|
+
**args
|
|
850
|
+
)
|
|
851
|
+
|
|
852
|
+
result = response.choices[0].message
|
|
853
|
+
|
|
854
|
+
# ---------- Finalization (unchanged) ----------
|
|
855
|
+
messages.append({"role": "assistant", "content": result.content})
|
|
856
|
+
|
|
857
|
+
response_text = result.content if isinstance(result.content, str) else self._json.dumps(result.content)
|
|
858
|
+
final_output = None
|
|
859
|
+
if output_config:
|
|
860
|
+
try:
|
|
861
|
+
if output_config.custom_parser:
|
|
862
|
+
final_output = output_config.custom_parser(response_text)
|
|
863
|
+
else:
|
|
864
|
+
final_output = await self._parse_structured_output(
|
|
865
|
+
response_text,
|
|
866
|
+
output_config
|
|
867
|
+
)
|
|
868
|
+
except Exception: # pylint: disable=broad-except
|
|
869
|
+
final_output = response_text
|
|
870
|
+
|
|
871
|
+
tools_used = [tc.name for tc in all_tool_calls]
|
|
872
|
+
assistant_response_text = result.content if isinstance(result.content, str) else self._json.dumps(result.content)
|
|
873
|
+
await self._update_conversation_memory(
|
|
874
|
+
user_id,
|
|
875
|
+
session_id,
|
|
876
|
+
conversation_session,
|
|
877
|
+
messages,
|
|
878
|
+
system_prompt,
|
|
879
|
+
turn_id,
|
|
880
|
+
original_prompt,
|
|
881
|
+
assistant_response_text,
|
|
882
|
+
tools_used
|
|
883
|
+
)
|
|
884
|
+
|
|
885
|
+
structured_payload = None
|
|
886
|
+
if final_output is not None and not (
|
|
887
|
+
isinstance(final_output, str) and final_output == response_text
|
|
888
|
+
):
|
|
889
|
+
structured_payload = final_output
|
|
890
|
+
|
|
891
|
+
ai_message = AIMessageFactory.from_openai(
|
|
892
|
+
response=response,
|
|
893
|
+
input_text=original_prompt,
|
|
894
|
+
model=model_str,
|
|
895
|
+
user_id=user_id,
|
|
896
|
+
session_id=session_id,
|
|
897
|
+
turn_id=turn_id,
|
|
898
|
+
structured_output=structured_payload
|
|
899
|
+
)
|
|
900
|
+
|
|
901
|
+
ai_message.tool_calls = all_tool_calls
|
|
902
|
+
return ai_message
|
|
903
|
+
|
|
904
|
+
async def ask_stream(
|
|
905
|
+
self,
|
|
906
|
+
prompt: str,
|
|
907
|
+
model: Union[str, OpenAIModel] = OpenAIModel.GPT4_TURBO,
|
|
908
|
+
max_tokens: int = None,
|
|
909
|
+
temperature: float = None,
|
|
910
|
+
files: Optional[List[Union[str, Path]]] = None,
|
|
911
|
+
system_prompt: Optional[str] = None,
|
|
912
|
+
user_id: Optional[str] = None,
|
|
913
|
+
session_id: Optional[str] = None,
|
|
914
|
+
tools: Optional[List[Dict[str, Any]]] = None,
|
|
915
|
+
structured_output: Union[type, StructuredOutputConfig, None] = None,
|
|
916
|
+
deep_research: bool = False,
|
|
917
|
+
agent_config: Optional[Dict[str, Any]] = None,
|
|
918
|
+
vector_store_ids: Optional[List[str]] = None,
|
|
919
|
+
enable_web_search: bool = True,
|
|
920
|
+
enable_code_interpreter: bool = False,
|
|
921
|
+
lazy_loading: bool = False,
|
|
922
|
+
) -> AsyncIterator[str]:
|
|
923
|
+
"""Stream OpenAI's response with optional conversation memory.
|
|
924
|
+
|
|
925
|
+
Args:
|
|
926
|
+
deep_research: If True, use deep research models with streaming
|
|
927
|
+
agent_config: Optional configuration (not used for OpenAI, for interface compatibility)
|
|
928
|
+
vector_store_ids: Vector store IDs for file_search tool
|
|
929
|
+
enable_web_search: Enable web search preview tool
|
|
930
|
+
enable_code_interpreter: Enable code interpreter tool
|
|
931
|
+
lazy_loading: If True, enable dynamic tool searching
|
|
932
|
+
"""
|
|
933
|
+
|
|
934
|
+
# Generate unique turn ID for tracking
|
|
935
|
+
turn_id = str(uuid.uuid4())
|
|
936
|
+
# Extract model value if it's an enum
|
|
937
|
+
model_str = model.value if isinstance(model, Enum) else model
|
|
938
|
+
|
|
939
|
+
# Deep research routing (same as in ask method)
|
|
940
|
+
if deep_research:
|
|
941
|
+
if model_str in {"gpt-4o-mini", "gpt-4o", "gpt-4-turbo", OpenAIModel.GPT4_1.value}:
|
|
942
|
+
model_str = "o3-deep-research"
|
|
943
|
+
self.logger.info(f"Deep research streaming enabled: switching to {model_str}")
|
|
944
|
+
elif model_str not in RESPONSES_ONLY_MODELS:
|
|
945
|
+
model_str = "o3-deep-research"
|
|
946
|
+
self.logger.info(f"Deep research streaming enabled: switching to {model_str}")
|
|
947
|
+
|
|
948
|
+
messages, conversation_session, system_prompt = await self._prepare_conversation_context(
|
|
949
|
+
prompt, files, user_id, session_id, system_prompt
|
|
950
|
+
)
|
|
951
|
+
|
|
952
|
+
# Upload files if they are path-like objects
|
|
953
|
+
if files:
|
|
954
|
+
for file in files:
|
|
955
|
+
if isinstance(file, str):
|
|
956
|
+
file = Path(file)
|
|
957
|
+
if isinstance(file, Path):
|
|
958
|
+
await self._upload_file(file)
|
|
959
|
+
|
|
960
|
+
if lazy_loading and system_prompt:
|
|
961
|
+
system_prompt += "\n\nYou have access to a library of tools. Use the 'search_tools' function to find relevant tools."
|
|
962
|
+
elif lazy_loading and not system_prompt:
|
|
963
|
+
system_prompt = "You have access to a library of tools. Use the 'search_tools' function to find relevant tools."
|
|
964
|
+
|
|
965
|
+
if system_prompt:
|
|
966
|
+
messages.insert(0, {"role": "system", "content": system_prompt})
|
|
967
|
+
|
|
968
|
+
# Build research tools if needed
|
|
969
|
+
research_tools = []
|
|
970
|
+
if deep_research:
|
|
971
|
+
if enable_web_search:
|
|
972
|
+
research_tools.append({"type": "web_search_preview"})
|
|
973
|
+
if vector_store_ids:
|
|
974
|
+
research_tools.append({
|
|
975
|
+
"type": "file_search",
|
|
976
|
+
"vector_store_ids": vector_store_ids
|
|
977
|
+
})
|
|
978
|
+
if enable_code_interpreter:
|
|
979
|
+
research_tools.append({
|
|
980
|
+
"type": "code_interpreter",
|
|
981
|
+
"container": {"type": "auto"}
|
|
982
|
+
})
|
|
983
|
+
self.logger.info(f"Deep research streaming tools: {len(research_tools)} tools")
|
|
984
|
+
|
|
985
|
+
# Prepare tools (Note: streaming with tools is more complex)
|
|
986
|
+
if tools and isinstance(tools, list):
|
|
987
|
+
for tool in tools:
|
|
988
|
+
self.register_tool(tool)
|
|
989
|
+
|
|
990
|
+
# LAZY LOADING LOGIC
|
|
991
|
+
active_tool_names = set()
|
|
992
|
+
tools_payload = None
|
|
993
|
+
|
|
994
|
+
if self.tools:
|
|
995
|
+
if lazy_loading:
|
|
996
|
+
tools_payload = self._prepare_lazy_tools()
|
|
997
|
+
if tools_payload:
|
|
998
|
+
active_tool_names.add("search_tools")
|
|
999
|
+
else:
|
|
1000
|
+
tools_payload = self._prepare_tools()
|
|
1001
|
+
|
|
1002
|
+
args: Dict[str, Any] = {}
|
|
1003
|
+
|
|
1004
|
+
# Merge research tools with regular tools (same logic as ask)
|
|
1005
|
+
if deep_research and research_tools:
|
|
1006
|
+
args['tools'] = research_tools
|
|
1007
|
+
elif tools_payload:
|
|
1008
|
+
args['tools'] = tools_payload
|
|
1009
|
+
args['tool_choice'] = "auto"
|
|
1010
|
+
args["parallel_tool_calls"] = True
|
|
1011
|
+
|
|
1012
|
+
max_tokens_value = max_tokens if max_tokens is not None else self.max_tokens
|
|
1013
|
+
if max_tokens_value is not None:
|
|
1014
|
+
args['max_tokens'] = max_tokens_value
|
|
1015
|
+
|
|
1016
|
+
temperature_value = temperature if temperature is not None else self.temperature
|
|
1017
|
+
if temperature_value is not None:
|
|
1018
|
+
args['temperature'] = temperature_value
|
|
1019
|
+
|
|
1020
|
+
# -------- structured output config (normalize + model guard) --------
|
|
1021
|
+
output_config = self._get_structured_config(structured_output)
|
|
1022
|
+
if output_config and output_config.format == OutputFormat.JSON and model_str not in STRUCTURED_OUTPUT_COMPATIBLE_MODELS:
|
|
1023
|
+
self.logger.warning(
|
|
1024
|
+
"Model %s does not support structured outputs; switching to %s",
|
|
1025
|
+
model_str,
|
|
1026
|
+
DEFAULT_STRUCTURED_OUTPUT_MODEL
|
|
1027
|
+
)
|
|
1028
|
+
model_str = DEFAULT_STRUCTURED_OUTPUT_MODEL
|
|
1029
|
+
|
|
1030
|
+
# Build the OpenAI response_format payload (dict) once
|
|
1031
|
+
resp_format = self._build_response_format_from(output_config) if output_config else None
|
|
1032
|
+
|
|
1033
|
+
use_responses = self._is_responses_model(model_str)
|
|
1034
|
+
assistant_content = ""
|
|
1035
|
+
|
|
1036
|
+
if use_responses:
|
|
1037
|
+
req = self._prepare_responses_args(messages=messages, args=args)
|
|
1038
|
+
req["model"] = model_str
|
|
1039
|
+
|
|
1040
|
+
payload_base = dict(req)
|
|
1041
|
+
payload_base.pop("response", None)
|
|
1042
|
+
payload_base.pop("response_format", None)
|
|
1043
|
+
attempts: List[Dict[str, Any]] = []
|
|
1044
|
+
if resp_format:
|
|
1045
|
+
attempts.extend(
|
|
1046
|
+
(
|
|
1047
|
+
{**payload_base, "response_format": resp_format},
|
|
1048
|
+
self._with_extra_body(
|
|
1049
|
+
payload_base, {"response": {"format": resp_format}}
|
|
1050
|
+
),
|
|
1051
|
+
dict(payload_base),
|
|
1052
|
+
)
|
|
1053
|
+
)
|
|
1054
|
+
else:
|
|
1055
|
+
attempts: List[Dict[str, Any]] = [dict(payload_base)]
|
|
1056
|
+
|
|
1057
|
+
stream_cm = await self._call_responses_stream(attempts)
|
|
1058
|
+
|
|
1059
|
+
async with stream_cm as stream:
|
|
1060
|
+
async for event in stream:
|
|
1061
|
+
event_type = getattr(event, "type", None)
|
|
1062
|
+
if event_type is None and isinstance(event, dict):
|
|
1063
|
+
event_type = event.get("type")
|
|
1064
|
+
|
|
1065
|
+
if event_type == "response.output_text.delta":
|
|
1066
|
+
delta = getattr(event, "delta", None)
|
|
1067
|
+
if delta is None and isinstance(event, dict):
|
|
1068
|
+
delta = event.get("delta")
|
|
1069
|
+
if delta:
|
|
1070
|
+
assistant_content += delta
|
|
1071
|
+
yield delta
|
|
1072
|
+
elif event_type == "response.output_text.done":
|
|
1073
|
+
text = getattr(event, "text", None)
|
|
1074
|
+
if text is None and isinstance(event, dict):
|
|
1075
|
+
text = event.get("text")
|
|
1076
|
+
if text:
|
|
1077
|
+
assistant_content += text
|
|
1078
|
+
yield text
|
|
1079
|
+
|
|
1080
|
+
final_response = None
|
|
1081
|
+
try:
|
|
1082
|
+
final_response = await stream.get_final_response()
|
|
1083
|
+
except Exception: # pylint: disable=broad-except
|
|
1084
|
+
final_response = None
|
|
1085
|
+
|
|
1086
|
+
if final_response and not assistant_content:
|
|
1087
|
+
output_text = getattr(final_response, "output_text", None) or ""
|
|
1088
|
+
if not output_text:
|
|
1089
|
+
for item in getattr(final_response, "output", []) or []:
|
|
1090
|
+
for part in getattr(item, "content", []) or []:
|
|
1091
|
+
text_part = None
|
|
1092
|
+
if isinstance(part, dict):
|
|
1093
|
+
if part.get("type") == "output_text":
|
|
1094
|
+
text_part = part.get("text", "")
|
|
1095
|
+
else:
|
|
1096
|
+
text_part = getattr(part, "text", None)
|
|
1097
|
+
if text_part:
|
|
1098
|
+
output_text += text_part
|
|
1099
|
+
if output_text:
|
|
1100
|
+
assistant_content = output_text
|
|
1101
|
+
yield output_text
|
|
1102
|
+
else:
|
|
1103
|
+
chat_args = dict(args)
|
|
1104
|
+
method = getattr(
|
|
1105
|
+
self.client.chat.completions, "parse",
|
|
1106
|
+
None
|
|
1107
|
+
) if output_config else None
|
|
1108
|
+
if callable(method):
|
|
1109
|
+
try:
|
|
1110
|
+
response_stream = await method( # pylint: disable=E1102 # noqa
|
|
1111
|
+
model=model_str,
|
|
1112
|
+
messages=messages,
|
|
1113
|
+
stream=True,
|
|
1114
|
+
response_format=(output_config.output_type if output_config else None),
|
|
1115
|
+
**chat_args
|
|
1116
|
+
)
|
|
1117
|
+
except TypeError:
|
|
1118
|
+
# parse() in this SDK may not accept stream=True → fallback to create()
|
|
1119
|
+
method = None
|
|
1120
|
+
else:
|
|
1121
|
+
response_stream = await self.client.chat.completions.create(
|
|
1122
|
+
model=model_str,
|
|
1123
|
+
messages=messages,
|
|
1124
|
+
stream=True,
|
|
1125
|
+
response_format=(output_config.output_type if output_config else None),
|
|
1126
|
+
**chat_args
|
|
1127
|
+
)
|
|
1128
|
+
|
|
1129
|
+
async for chunk in response_stream:
|
|
1130
|
+
if chunk.choices and chunk.choices[0].delta and chunk.choices[0].delta.content:
|
|
1131
|
+
text_chunk = chunk.choices[0].delta.content
|
|
1132
|
+
assistant_content += text_chunk
|
|
1133
|
+
yield text_chunk
|
|
1134
|
+
|
|
1135
|
+
# Update conversation memory if content was generated
|
|
1136
|
+
if assistant_content:
|
|
1137
|
+
messages.append({
|
|
1138
|
+
"role": "assistant",
|
|
1139
|
+
"content": assistant_content
|
|
1140
|
+
})
|
|
1141
|
+
# Update conversation memory
|
|
1142
|
+
await self._update_conversation_memory(
|
|
1143
|
+
user_id,
|
|
1144
|
+
session_id,
|
|
1145
|
+
conversation_session,
|
|
1146
|
+
messages,
|
|
1147
|
+
system_prompt,
|
|
1148
|
+
turn_id,
|
|
1149
|
+
prompt,
|
|
1150
|
+
assistant_content,
|
|
1151
|
+
[]
|
|
1152
|
+
)
|
|
1153
|
+
|
|
1154
|
+
async def batch_ask(self, requests) -> List[AIMessage]:
|
|
1155
|
+
"""Process multiple requests in batch."""
|
|
1156
|
+
# OpenAI doesn't have a native batch API like Claude, so we process sequentially
|
|
1157
|
+
# In a real implementation, you might want to use asyncio.gather for concurrency
|
|
1158
|
+
results = []
|
|
1159
|
+
for request in requests:
|
|
1160
|
+
result = await self.ask(**request)
|
|
1161
|
+
results.append(result)
|
|
1162
|
+
return results
|
|
1163
|
+
|
|
1164
|
+
def _encode_image_for_openai(
|
|
1165
|
+
self,
|
|
1166
|
+
image: Union[Path, bytes, Image.Image],
|
|
1167
|
+
low_quality: bool = False
|
|
1168
|
+
) -> Dict[str, Any]:
|
|
1169
|
+
"""Encode image for OpenAI's vision API."""
|
|
1170
|
+
if isinstance(image, Path):
|
|
1171
|
+
if not image.exists():
|
|
1172
|
+
raise FileNotFoundError(f"Image file not found: {image}")
|
|
1173
|
+
mime_type, _ = mimetypes.guess_type(str(image))
|
|
1174
|
+
mime_type = mime_type or "image/jpeg"
|
|
1175
|
+
with open(image, "rb") as f:
|
|
1176
|
+
encoded_data = base64.b64encode(f.read()).decode('utf-8')
|
|
1177
|
+
|
|
1178
|
+
elif isinstance(image, bytes):
|
|
1179
|
+
mime_type = "image/jpeg"
|
|
1180
|
+
encoded_data = base64.b64encode(image).decode('utf-8')
|
|
1181
|
+
|
|
1182
|
+
elif isinstance(image, Image.Image):
|
|
1183
|
+
buffer = io.BytesIO()
|
|
1184
|
+
if image.mode in ("RGBA", "LA", "P"):
|
|
1185
|
+
image = image.convert("RGB")
|
|
1186
|
+
image.save(buffer, format="JPEG")
|
|
1187
|
+
encoded_data = base64.b64encode(buffer.getvalue()).decode('utf-8')
|
|
1188
|
+
mime_type = "image/jpeg"
|
|
1189
|
+
|
|
1190
|
+
else:
|
|
1191
|
+
raise ValueError("Image must be a Path, bytes, or PIL.Image object.")
|
|
1192
|
+
|
|
1193
|
+
return {
|
|
1194
|
+
"type": "image_url",
|
|
1195
|
+
"image_url": {
|
|
1196
|
+
"url": f"data:{mime_type};base64,{encoded_data}",
|
|
1197
|
+
"detail": "low" if low_quality else "auto"
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
async def ask_to_image(
|
|
1202
|
+
self,
|
|
1203
|
+
prompt: str,
|
|
1204
|
+
image: Union[Path, bytes, Image.Image],
|
|
1205
|
+
reference_images: Optional[List[Union[Path, bytes, Image.Image]]] = None,
|
|
1206
|
+
model: str = "gpt-4-turbo",
|
|
1207
|
+
max_tokens: int = None,
|
|
1208
|
+
temperature: float = None,
|
|
1209
|
+
structured_output: Optional[type] = None,
|
|
1210
|
+
user_id: Optional[str] = None,
|
|
1211
|
+
session_id: Optional[str] = None,
|
|
1212
|
+
no_memory: bool = False,
|
|
1213
|
+
low_quality: bool = False
|
|
1214
|
+
) -> AIMessage:
|
|
1215
|
+
"""Ask OpenAI a question about an image with optional conversation memory."""
|
|
1216
|
+
turn_id = str(uuid.uuid4())
|
|
1217
|
+
|
|
1218
|
+
if no_memory:
|
|
1219
|
+
messages = []
|
|
1220
|
+
conversation_session = None
|
|
1221
|
+
system_prompt = None
|
|
1222
|
+
else:
|
|
1223
|
+
messages, conversation_session, system_prompt = await self._prepare_conversation_context(
|
|
1224
|
+
prompt, None, user_id, session_id, None
|
|
1225
|
+
)
|
|
1226
|
+
|
|
1227
|
+
content = [{"type": "text", "text": prompt}]
|
|
1228
|
+
|
|
1229
|
+
primary_image_content = self._encode_image_for_openai(image, low_quality=low_quality)
|
|
1230
|
+
content.insert(0, primary_image_content)
|
|
1231
|
+
|
|
1232
|
+
if reference_images:
|
|
1233
|
+
for ref_image in reference_images:
|
|
1234
|
+
ref_image_content = self._encode_image_for_openai(ref_image, low_quality=low_quality)
|
|
1235
|
+
content.insert(0, ref_image_content)
|
|
1236
|
+
|
|
1237
|
+
new_message = {"role": "user", "content": content}
|
|
1238
|
+
|
|
1239
|
+
if messages and messages[-1]["role"] == "user":
|
|
1240
|
+
messages[-1] = new_message
|
|
1241
|
+
else:
|
|
1242
|
+
messages.append(new_message)
|
|
1243
|
+
|
|
1244
|
+
response_format = None
|
|
1245
|
+
if structured_output:
|
|
1246
|
+
if hasattr(structured_output, 'model_json_schema'):
|
|
1247
|
+
response_format = {
|
|
1248
|
+
"type": "json_schema",
|
|
1249
|
+
"json_schema": {
|
|
1250
|
+
"name": structured_output.__name__.lower(),
|
|
1251
|
+
"schema": structured_output.model_json_schema()
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
elif isinstance(structured_output, dict):
|
|
1255
|
+
response_format = {
|
|
1256
|
+
"type": "json_schema",
|
|
1257
|
+
"json_schema": {
|
|
1258
|
+
"name": "response",
|
|
1259
|
+
"schema": structured_output
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
else:
|
|
1263
|
+
response_format = {"type": "json_object"}
|
|
1264
|
+
|
|
1265
|
+
response = await self.client.chat.completions.create(
|
|
1266
|
+
model=model,
|
|
1267
|
+
messages=messages,
|
|
1268
|
+
max_tokens=max_tokens or self.max_tokens,
|
|
1269
|
+
temperature=temperature or self.temperature,
|
|
1270
|
+
response_format=response_format
|
|
1271
|
+
)
|
|
1272
|
+
|
|
1273
|
+
result = response.choices[0].message
|
|
1274
|
+
|
|
1275
|
+
final_output = None
|
|
1276
|
+
assistant_response_text = ""
|
|
1277
|
+
if structured_output is not None:
|
|
1278
|
+
if isinstance(structured_output, dict):
|
|
1279
|
+
assistant_response_text = result.content
|
|
1280
|
+
try:
|
|
1281
|
+
final_output = self._parse_json_from_text(assistant_response_text)
|
|
1282
|
+
except Exception:
|
|
1283
|
+
final_output = assistant_response_text
|
|
1284
|
+
else:
|
|
1285
|
+
try:
|
|
1286
|
+
final_output = structured_output.model_validate_json(result.content)
|
|
1287
|
+
except Exception:
|
|
1288
|
+
try:
|
|
1289
|
+
final_output = structured_output.model_validate(result.content)
|
|
1290
|
+
except ValidationError:
|
|
1291
|
+
final_output = result.content
|
|
1292
|
+
|
|
1293
|
+
assistant_message = {
|
|
1294
|
+
"role": "assistant", "content": [{"type": "text", "text": result.content}]
|
|
1295
|
+
}
|
|
1296
|
+
messages.append(assistant_message)
|
|
1297
|
+
|
|
1298
|
+
# Update conversation memory
|
|
1299
|
+
await self._update_conversation_memory(
|
|
1300
|
+
user_id,
|
|
1301
|
+
session_id,
|
|
1302
|
+
conversation_session,
|
|
1303
|
+
messages,
|
|
1304
|
+
system_prompt,
|
|
1305
|
+
turn_id,
|
|
1306
|
+
prompt,
|
|
1307
|
+
assistant_response_text,
|
|
1308
|
+
[]
|
|
1309
|
+
)
|
|
1310
|
+
|
|
1311
|
+
usage = response.usage.model_dump() if response.usage else {}
|
|
1312
|
+
|
|
1313
|
+
ai_message = AIMessageFactory.from_openai(
|
|
1314
|
+
response=response,
|
|
1315
|
+
input_text=f"[Image Analysis]: {prompt}",
|
|
1316
|
+
model=model,
|
|
1317
|
+
user_id=user_id,
|
|
1318
|
+
session_id=session_id,
|
|
1319
|
+
turn_id=turn_id,
|
|
1320
|
+
structured_output=final_output
|
|
1321
|
+
)
|
|
1322
|
+
|
|
1323
|
+
ai_message.usage = CompletionUsage(
|
|
1324
|
+
prompt_tokens=usage.get("prompt_tokens", 0),
|
|
1325
|
+
completion_tokens=usage.get("completion_tokens", 0),
|
|
1326
|
+
total_tokens=usage.get("total_tokens", 0),
|
|
1327
|
+
extra_usage=usage
|
|
1328
|
+
)
|
|
1329
|
+
|
|
1330
|
+
ai_message.provider = "openai"
|
|
1331
|
+
|
|
1332
|
+
return ai_message
|
|
1333
|
+
|
|
1334
|
+
async def summarize_text(
|
|
1335
|
+
self,
|
|
1336
|
+
text: str,
|
|
1337
|
+
max_length: int = 500,
|
|
1338
|
+
min_length: int = 100,
|
|
1339
|
+
model: Union[OpenAIModel, str] = OpenAIModel.GPT4_TURBO,
|
|
1340
|
+
temperature: Optional[float] = None,
|
|
1341
|
+
user_id: Optional[str] = None,
|
|
1342
|
+
session_id: Optional[str] = None,
|
|
1343
|
+
) -> AIMessage:
|
|
1344
|
+
"""
|
|
1345
|
+
Generate a concise summary of *text* (single paragraph, stateless).
|
|
1346
|
+
"""
|
|
1347
|
+
turn_id = str(uuid.uuid4())
|
|
1348
|
+
|
|
1349
|
+
system_prompt = (
|
|
1350
|
+
"Your job is to produce a final summary from the following text and "
|
|
1351
|
+
"identify the main theme.\n"
|
|
1352
|
+
f"- The summary should be concise and to the point.\n"
|
|
1353
|
+
f"- The summary should be no longer than {max_length} characters and "
|
|
1354
|
+
f"no less than {min_length} characters.\n"
|
|
1355
|
+
"- The summary should be in a single paragraph.\n"
|
|
1356
|
+
"- Focus on the key information and main points.\n"
|
|
1357
|
+
"- Write in clear, accessible language."
|
|
1358
|
+
)
|
|
1359
|
+
|
|
1360
|
+
messages = [
|
|
1361
|
+
{"role": "system", "content": system_prompt},
|
|
1362
|
+
{"role": "user", "content": text},
|
|
1363
|
+
]
|
|
1364
|
+
|
|
1365
|
+
response = await self._chat_completion(
|
|
1366
|
+
model=model.value if isinstance(model, Enum) else model,
|
|
1367
|
+
messages=messages,
|
|
1368
|
+
max_tokens=self.max_tokens,
|
|
1369
|
+
temperature=temperature or self.temperature,
|
|
1370
|
+
use_tools=False,
|
|
1371
|
+
)
|
|
1372
|
+
|
|
1373
|
+
result = response.choices[0].message
|
|
1374
|
+
|
|
1375
|
+
return AIMessageFactory.from_openai(
|
|
1376
|
+
response=response,
|
|
1377
|
+
input_text=text,
|
|
1378
|
+
model=model,
|
|
1379
|
+
user_id=user_id,
|
|
1380
|
+
session_id=session_id,
|
|
1381
|
+
turn_id=turn_id,
|
|
1382
|
+
structured_output=None,
|
|
1383
|
+
)
|
|
1384
|
+
|
|
1385
|
+
async def translate_text(
|
|
1386
|
+
self,
|
|
1387
|
+
text: str,
|
|
1388
|
+
target_lang: str,
|
|
1389
|
+
source_lang: Optional[str] = None,
|
|
1390
|
+
model: Union[OpenAIModel, str] = OpenAIModel.GPT4_TURBO,
|
|
1391
|
+
temperature: float = 0.2,
|
|
1392
|
+
user_id: Optional[str] = None,
|
|
1393
|
+
session_id: Optional[str] = None,
|
|
1394
|
+
) -> AIMessage:
|
|
1395
|
+
"""
|
|
1396
|
+
Translate *text* from *source_lang* (auto‑detected if None) into *target_lang*.
|
|
1397
|
+
"""
|
|
1398
|
+
turn_id = str(uuid.uuid4())
|
|
1399
|
+
|
|
1400
|
+
if source_lang:
|
|
1401
|
+
system_prompt = (
|
|
1402
|
+
f"You are a professional translator. Translate the following text "
|
|
1403
|
+
f"from {source_lang} to {target_lang}.\n"
|
|
1404
|
+
"- Provide only the translated text, without any additional comments "
|
|
1405
|
+
"or explanations.\n"
|
|
1406
|
+
"- Maintain the original meaning and tone.\n"
|
|
1407
|
+
"- Use natural, fluent language in the target language.\n"
|
|
1408
|
+
"- Preserve formatting if present (line breaks, bullet points, etc.)."
|
|
1409
|
+
)
|
|
1410
|
+
else:
|
|
1411
|
+
system_prompt = (
|
|
1412
|
+
f"You are a professional translator. First detect the source "
|
|
1413
|
+
f"language of the following text, then translate it to {target_lang}.\n"
|
|
1414
|
+
"- Provide only the translated text, without any additional comments "
|
|
1415
|
+
"or explanations.\n"
|
|
1416
|
+
"- Maintain the original meaning and tone.\n"
|
|
1417
|
+
"- Use natural, fluent language in the target language.\n"
|
|
1418
|
+
"- Preserve formatting if present (line breaks, bullet points, etc.)."
|
|
1419
|
+
)
|
|
1420
|
+
|
|
1421
|
+
messages = [
|
|
1422
|
+
{"role": "system", "content": system_prompt},
|
|
1423
|
+
{"role": "user", "content": text},
|
|
1424
|
+
]
|
|
1425
|
+
|
|
1426
|
+
response = await self._chat_completion(
|
|
1427
|
+
model=model.value if isinstance(model, Enum) else model,
|
|
1428
|
+
messages=messages,
|
|
1429
|
+
max_tokens=self.max_tokens,
|
|
1430
|
+
temperature=temperature,
|
|
1431
|
+
)
|
|
1432
|
+
|
|
1433
|
+
return AIMessageFactory.from_openai(
|
|
1434
|
+
response=response,
|
|
1435
|
+
input_text=text,
|
|
1436
|
+
model=model,
|
|
1437
|
+
user_id=user_id,
|
|
1438
|
+
session_id=session_id,
|
|
1439
|
+
turn_id=turn_id,
|
|
1440
|
+
structured_output=None,
|
|
1441
|
+
)
|
|
1442
|
+
|
|
1443
|
+
async def extract_key_points(
|
|
1444
|
+
self,
|
|
1445
|
+
text: str,
|
|
1446
|
+
num_points: int = 5,
|
|
1447
|
+
model: Union[OpenAIModel, str] = OpenAIModel.GPT4_TURBO,
|
|
1448
|
+
temperature: float = 0.3,
|
|
1449
|
+
user_id: Optional[str] = None,
|
|
1450
|
+
session_id: Optional[str] = None,
|
|
1451
|
+
) -> AIMessage:
|
|
1452
|
+
"""
|
|
1453
|
+
Extract *num_points* bullet‑point key ideas from *text* (stateless).
|
|
1454
|
+
"""
|
|
1455
|
+
turn_id = str(uuid.uuid4())
|
|
1456
|
+
|
|
1457
|
+
system_prompt = (
|
|
1458
|
+
f"Extract the {num_points} most important key points from the following text.\n"
|
|
1459
|
+
"- Present each point as a clear, concise bullet point (•).\n"
|
|
1460
|
+
"- Focus on the main ideas and significant information.\n"
|
|
1461
|
+
"- Each point should be self‑contained and meaningful.\n"
|
|
1462
|
+
"- Order points by importance (most important first)."
|
|
1463
|
+
)
|
|
1464
|
+
|
|
1465
|
+
messages = [
|
|
1466
|
+
{"role": "system", "content": system_prompt},
|
|
1467
|
+
{"role": "user", "content": text},
|
|
1468
|
+
]
|
|
1469
|
+
|
|
1470
|
+
response = await self._chat_completion(
|
|
1471
|
+
model=model.value if isinstance(model, Enum) else model,
|
|
1472
|
+
messages=messages,
|
|
1473
|
+
max_tokens=self.max_tokens,
|
|
1474
|
+
temperature=temperature,
|
|
1475
|
+
)
|
|
1476
|
+
|
|
1477
|
+
return AIMessageFactory.from_openai(
|
|
1478
|
+
response=response,
|
|
1479
|
+
input_text=text,
|
|
1480
|
+
model=model,
|
|
1481
|
+
user_id=user_id,
|
|
1482
|
+
session_id=session_id,
|
|
1483
|
+
turn_id=turn_id,
|
|
1484
|
+
structured_output=None,
|
|
1485
|
+
)
|
|
1486
|
+
|
|
1487
|
+
async def analyze_sentiment(
|
|
1488
|
+
self,
|
|
1489
|
+
text: str,
|
|
1490
|
+
model: Union[OpenAIModel, str] = OpenAIModel.GPT4_TURBO,
|
|
1491
|
+
temperature: float = 0.1,
|
|
1492
|
+
user_id: Optional[str] = None,
|
|
1493
|
+
session_id: Optional[str] = None,
|
|
1494
|
+
) -> AIMessage:
|
|
1495
|
+
"""
|
|
1496
|
+
Perform sentiment analysis on *text* and return a structured explanation.
|
|
1497
|
+
"""
|
|
1498
|
+
turn_id = str(uuid.uuid4())
|
|
1499
|
+
|
|
1500
|
+
system_prompt = (
|
|
1501
|
+
"Analyze the sentiment of the following text and provide a structured response.\n"
|
|
1502
|
+
"Your response must include:\n"
|
|
1503
|
+
"1. Overall sentiment (Positive, Negative, Neutral, or Mixed)\n"
|
|
1504
|
+
"2. Confidence level (High, Medium, Low)\n"
|
|
1505
|
+
"3. Key emotional indicators found in the text\n"
|
|
1506
|
+
"4. Brief explanation of your analysis\n\n"
|
|
1507
|
+
"Format your answer clearly with numbered sections."
|
|
1508
|
+
)
|
|
1509
|
+
|
|
1510
|
+
messages = [
|
|
1511
|
+
{"role": "system", "content": system_prompt},
|
|
1512
|
+
{"role": "user", "content": text},
|
|
1513
|
+
]
|
|
1514
|
+
|
|
1515
|
+
response = await self._chat_completion(
|
|
1516
|
+
model=model.value if isinstance(model, Enum) else model,
|
|
1517
|
+
messages=messages,
|
|
1518
|
+
max_tokens=self.max_tokens,
|
|
1519
|
+
temperature=temperature,
|
|
1520
|
+
)
|
|
1521
|
+
|
|
1522
|
+
return AIMessageFactory.from_openai(
|
|
1523
|
+
response=response,
|
|
1524
|
+
input_text=text,
|
|
1525
|
+
model=model,
|
|
1526
|
+
user_id=user_id,
|
|
1527
|
+
session_id=session_id,
|
|
1528
|
+
turn_id=turn_id,
|
|
1529
|
+
structured_output=None,
|
|
1530
|
+
)
|
|
1531
|
+
|
|
1532
|
+
async def analyze_product_review(
|
|
1533
|
+
self,
|
|
1534
|
+
review_text: str,
|
|
1535
|
+
product_id: str,
|
|
1536
|
+
product_name: str,
|
|
1537
|
+
model: Union[OpenAIModel, str] = OpenAIModel.GPT4_TURBO,
|
|
1538
|
+
temperature: float = 0.1,
|
|
1539
|
+
user_id: Optional[str] = None,
|
|
1540
|
+
session_id: Optional[str] = None,
|
|
1541
|
+
) -> AIMessage:
|
|
1542
|
+
"""
|
|
1543
|
+
Analyze a product review and extract structured information.
|
|
1544
|
+
|
|
1545
|
+
Args:
|
|
1546
|
+
review_text (str): The product review text to analyze.
|
|
1547
|
+
product_id (str): Unique identifier for the product.
|
|
1548
|
+
product_name (str): Name of the product being reviewed.
|
|
1549
|
+
model (Union[OpenAIModel, str]): The model to use.
|
|
1550
|
+
temperature (float): Sampling temperature for response generation.
|
|
1551
|
+
user_id (Optional[str]): Optional user identifier for tracking.
|
|
1552
|
+
session_id (Optional[str]): Optional session identifier for tracking.
|
|
1553
|
+
"""
|
|
1554
|
+
turn_id = str(uuid.uuid4())
|
|
1555
|
+
|
|
1556
|
+
system_prompt = (
|
|
1557
|
+
f"You are a product review analysis expert. Analyze the given product review "
|
|
1558
|
+
f"for '{product_name}' (ID: {product_id}) and extract structured information. "
|
|
1559
|
+
f"Determine the sentiment (positive, negative, or neutral), estimate a rating "
|
|
1560
|
+
f"based on the review content (0.0-5.0 scale), and identify key product features "
|
|
1561
|
+
f"mentioned in the review."
|
|
1562
|
+
)
|
|
1563
|
+
|
|
1564
|
+
messages = [
|
|
1565
|
+
{"role": "system", "content": system_prompt},
|
|
1566
|
+
{"role": "user", "content": f"Product ID: {product_id}\nProduct Name: {product_name}\nReview: {review_text}"},
|
|
1567
|
+
]
|
|
1568
|
+
|
|
1569
|
+
# Use structured output with response_format
|
|
1570
|
+
response = await self._chat_completion(
|
|
1571
|
+
model=model.value if isinstance(model, Enum) else model,
|
|
1572
|
+
messages=messages,
|
|
1573
|
+
max_tokens=self.max_tokens,
|
|
1574
|
+
temperature=temperature,
|
|
1575
|
+
response_format={
|
|
1576
|
+
"type": "json_schema",
|
|
1577
|
+
"json_schema": {
|
|
1578
|
+
"name": "product_review_analysis",
|
|
1579
|
+
"schema": ProductReview.model_json_schema(),
|
|
1580
|
+
"strict": True
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
)
|
|
1584
|
+
|
|
1585
|
+
return AIMessageFactory.from_openai(
|
|
1586
|
+
response=response,
|
|
1587
|
+
input_text=review_text,
|
|
1588
|
+
model=model,
|
|
1589
|
+
user_id=user_id,
|
|
1590
|
+
session_id=session_id,
|
|
1591
|
+
turn_id=turn_id,
|
|
1592
|
+
structured_output=ProductReview,
|
|
1593
|
+
)
|
|
1594
|
+
|
|
1595
|
+
async def image_identification(
|
|
1596
|
+
self,
|
|
1597
|
+
*,
|
|
1598
|
+
image: Union[Path, bytes, Image.Image],
|
|
1599
|
+
detections: List[DetectionBox], # from parrot.models.detections
|
|
1600
|
+
shelf_regions: List[ShelfRegion], # "
|
|
1601
|
+
reference_images: Optional[List[Union[Path, bytes, Image.Image]]] = None,
|
|
1602
|
+
model: Union[OpenAIModel, str] = OpenAIModel.GPT_4_1_MINI,
|
|
1603
|
+
prompt: Optional[str] = None,
|
|
1604
|
+
temperature: float = 0.0,
|
|
1605
|
+
ocr_hints: bool = True,
|
|
1606
|
+
user_id: Optional[str] = None,
|
|
1607
|
+
session_id: Optional[str] = None,
|
|
1608
|
+
max_tokens: Optional[int] = None
|
|
1609
|
+
) -> List[IdentifiedProduct]:
|
|
1610
|
+
"""
|
|
1611
|
+
Step-2: Identify products using the detected boxes + reference images.
|
|
1612
|
+
|
|
1613
|
+
Returns a list[IdentifiedProduct] with bbox, type, model, confidence, features,
|
|
1614
|
+
reference_match, shelf_location, and position_on_shelf.
|
|
1615
|
+
"""
|
|
1616
|
+
_has_tesseract = True
|
|
1617
|
+
|
|
1618
|
+
def _crop_box(pil_img: Image.Image, box) -> Image.Image:
|
|
1619
|
+
# small padding to include context
|
|
1620
|
+
pad = 6
|
|
1621
|
+
x1 = max(0, box.x1 - pad)
|
|
1622
|
+
y1 = max(0, box.y1 - pad)
|
|
1623
|
+
x2 = min(pil_img.width, box.x2 + pad)
|
|
1624
|
+
y2 = min(pil_img.height, box.y2 + pad)
|
|
1625
|
+
return pil_img.crop((x1, y1, x2, y2))
|
|
1626
|
+
|
|
1627
|
+
def _shelf_and_position(box, regions: List[ShelfRegion]) -> Tuple[str, str]:
|
|
1628
|
+
# map to shelf by containment / Y overlap
|
|
1629
|
+
best = None
|
|
1630
|
+
best_overlap = 0
|
|
1631
|
+
for r in regions:
|
|
1632
|
+
rx1, ry1, rx2, ry2 = r.bbox.x1, r.bbox.y1, r.bbox.x2, r.bbox.y2
|
|
1633
|
+
ix1, iy1 = max(rx1, box.x1), max(ry1, box.y1)
|
|
1634
|
+
ix2, iy2 = min(rx2, box.x2), min(ry2, box.y2)
|
|
1635
|
+
ov = max(0, ix2 - ix1) * max(0, iy2 - iy1)
|
|
1636
|
+
if ov > best_overlap:
|
|
1637
|
+
best_overlap, best = ov, r
|
|
1638
|
+
shelf = best.level if best else "unknown"
|
|
1639
|
+
|
|
1640
|
+
# left/center/right inside the shelf bbox
|
|
1641
|
+
if best:
|
|
1642
|
+
mid = (box.x1 + box.x2) / 2.0
|
|
1643
|
+
thirds = (best.bbox.x1 + (best.bbox.x2 - best.bbox.x1) / 3.0,
|
|
1644
|
+
best.bbox.x1 + 2 * (best.bbox.x2 - best.bbox.x1) / 3.0)
|
|
1645
|
+
position = "left" if mid < thirds[0] else ("right" if mid > thirds[1] else "center")
|
|
1646
|
+
else:
|
|
1647
|
+
position = "center"
|
|
1648
|
+
return shelf, position
|
|
1649
|
+
|
|
1650
|
+
# --- prepare images ---
|
|
1651
|
+
if isinstance(image, (str, Path)):
|
|
1652
|
+
pil_image = Image.open(image).convert("RGB")
|
|
1653
|
+
elif isinstance(image, bytes):
|
|
1654
|
+
pil_image = Image.open(io.BytesIO(image)).convert("RGB")
|
|
1655
|
+
else:
|
|
1656
|
+
pil_image = image.convert("RGB")
|
|
1657
|
+
|
|
1658
|
+
# crops per detection
|
|
1659
|
+
crops = []
|
|
1660
|
+
for i, det in enumerate(detections, start=1):
|
|
1661
|
+
crop = _crop_box(pil_image, det)
|
|
1662
|
+
text_hint = ""
|
|
1663
|
+
if ocr_hints and _has_tesseract:
|
|
1664
|
+
try:
|
|
1665
|
+
text = pytesseract.image_to_string(crop)
|
|
1666
|
+
text_hint = text.strip()
|
|
1667
|
+
except Exception:
|
|
1668
|
+
text_hint = ""
|
|
1669
|
+
shelf, pos = _shelf_and_position(det, shelf_regions)
|
|
1670
|
+
crops.append({
|
|
1671
|
+
"id": i,
|
|
1672
|
+
"det": det,
|
|
1673
|
+
"shelf": shelf,
|
|
1674
|
+
"position": pos,
|
|
1675
|
+
"ocr": text_hint,
|
|
1676
|
+
"img_content": self._encode_image_for_openai(crop)
|
|
1677
|
+
})
|
|
1678
|
+
|
|
1679
|
+
# --- build messages (full image + crops + references) ---
|
|
1680
|
+
# Put references first, then the full scene, then each crop.
|
|
1681
|
+
content_blocks = []
|
|
1682
|
+
|
|
1683
|
+
# 1) reference images
|
|
1684
|
+
if reference_images:
|
|
1685
|
+
for ref in reference_images:
|
|
1686
|
+
content_blocks.append(self._encode_image_for_openai(ref))
|
|
1687
|
+
|
|
1688
|
+
# 2) full scene
|
|
1689
|
+
content_blocks.append(self._encode_image_for_openai(pil_image))
|
|
1690
|
+
|
|
1691
|
+
# 3) one block with per-detection crop + text hint
|
|
1692
|
+
# Images go as separate blocks; the textual metadata goes in one text block.
|
|
1693
|
+
meta_lines = ["DETECTIONS:"]
|
|
1694
|
+
for c in crops:
|
|
1695
|
+
d = c["det"]
|
|
1696
|
+
meta_lines.append(
|
|
1697
|
+
f"- id:{c['id']} bbox:[{d.x1},{d.y1},{d.x2},{d.y2}] class:{d.class_name} "
|
|
1698
|
+
f"shelf:{c['shelf']} position:{c['position']} ocr:{c['ocr'][:80] or 'None'}"
|
|
1699
|
+
)
|
|
1700
|
+
if prompt:
|
|
1701
|
+
text_block = prompt + "\n\nReturn ONLY JSON with top-level key 'items' that matches the provided schema." + "\n".join(meta_lines)
|
|
1702
|
+
else:
|
|
1703
|
+
text_block = (
|
|
1704
|
+
"Identify each detection by comparing with the reference images. "
|
|
1705
|
+
"Prefer visual features (shape, control panel, ink tank layout) and use OCR hints only as supportive evidence. "
|
|
1706
|
+
"Allowed product_type: ['printer','product_box','fact_tag','promotional_graphic','ink_bottle']. "
|
|
1707
|
+
"Models to look for (if any): ['ET-2980','ET-3950','ET-4950']. "
|
|
1708
|
+
"Return one item per detection id.\n"
|
|
1709
|
+
+ "\n".join(meta_lines)
|
|
1710
|
+
)
|
|
1711
|
+
content_blocks.append({"type": "text", "text": text_block})
|
|
1712
|
+
# add crops
|
|
1713
|
+
for c in crops:
|
|
1714
|
+
content_blocks.append(c["img_content"])
|
|
1715
|
+
|
|
1716
|
+
# --- JSON schema (strict) for enforcement ---
|
|
1717
|
+
# We wrap the array in an object {"items":[...]} so json_schema works consistently.
|
|
1718
|
+
item_schema = {
|
|
1719
|
+
"type": "object",
|
|
1720
|
+
"additionalProperties": False,
|
|
1721
|
+
"properties": {
|
|
1722
|
+
"detection_id": {"type": "integer", "minimum": 1},
|
|
1723
|
+
"product_type": {"type": "string"},
|
|
1724
|
+
"product_model": {"type": ["string", "null"]},
|
|
1725
|
+
"confidence": {"type": "number", "minimum": 0.0, "maximum": 1.0},
|
|
1726
|
+
"visual_features": {"type": "array", "items": {"type": "string"}},
|
|
1727
|
+
"reference_match": {"type": ["string", "null"]},
|
|
1728
|
+
"shelf_location": {"type": "string"},
|
|
1729
|
+
"position_on_shelf": {"type": "string"},
|
|
1730
|
+
"brand": {"type": ["string", "null"]},
|
|
1731
|
+
"advertisement_type": {"type": ["string", "null"]},
|
|
1732
|
+
},
|
|
1733
|
+
"required": [
|
|
1734
|
+
"detection_id","product_type","product_model","confidence","visual_features",
|
|
1735
|
+
"reference_match","shelf_location","position_on_shelf","brand","advertisement_type"
|
|
1736
|
+
],
|
|
1737
|
+
}
|
|
1738
|
+
resp_format = {
|
|
1739
|
+
"type": "json_schema",
|
|
1740
|
+
"json_schema": {
|
|
1741
|
+
"name": "identified_products",
|
|
1742
|
+
"strict": True,
|
|
1743
|
+
"schema": {
|
|
1744
|
+
"type": "object",
|
|
1745
|
+
"additionalProperties": False,
|
|
1746
|
+
"properties": {
|
|
1747
|
+
"items": {
|
|
1748
|
+
"type": "array",
|
|
1749
|
+
"items": item_schema,
|
|
1750
|
+
"minItems": len(detections), # drop or lower if this causes 400s
|
|
1751
|
+
}
|
|
1752
|
+
},
|
|
1753
|
+
"required": ["items"],
|
|
1754
|
+
},
|
|
1755
|
+
},
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
# ensure shelves/positions are precomputed in case the model drops them
|
|
1759
|
+
shelf_pos_map = {c["id"]: (c["shelf"], c["position"]) for c in crops}
|
|
1760
|
+
|
|
1761
|
+
# --- call OpenAI ---
|
|
1762
|
+
messages = [{"role": "user", "content": content_blocks}]
|
|
1763
|
+
response = await self.client.chat.completions.create(
|
|
1764
|
+
model=model.value if isinstance(model, Enum) else model,
|
|
1765
|
+
messages=messages,
|
|
1766
|
+
max_tokens=max_tokens or self.max_tokens,
|
|
1767
|
+
temperature=temperature or self.temperature,
|
|
1768
|
+
response_format=resp_format
|
|
1769
|
+
)
|
|
1770
|
+
|
|
1771
|
+
raw = response.choices[0].message.content or "{}"
|
|
1772
|
+
try:
|
|
1773
|
+
# data = json.loads(raw)
|
|
1774
|
+
data = json_decoder(raw)
|
|
1775
|
+
items = data.get("items") or data.get("detections") or []
|
|
1776
|
+
except Exception:
|
|
1777
|
+
# fallback: try best-effort parse if model didn’t honor schema
|
|
1778
|
+
data = self._json.loads(
|
|
1779
|
+
raw
|
|
1780
|
+
)
|
|
1781
|
+
items = data.get("items") or data.get("detections") or []
|
|
1782
|
+
|
|
1783
|
+
# --- build IdentifiedProduct list ---
|
|
1784
|
+
out: List[IdentifiedProduct] = []
|
|
1785
|
+
for idx, it in enumerate(items, start=1):
|
|
1786
|
+
det_id = int(it.get("detection_id") or idx)
|
|
1787
|
+
if not (1 <= det_id <= len(detections)):
|
|
1788
|
+
continue
|
|
1789
|
+
|
|
1790
|
+
det = detections[det_id - 1]
|
|
1791
|
+
shelf, pos = shelf_pos_map.get(det_id, ("unknown", "center"))
|
|
1792
|
+
|
|
1793
|
+
# allow model to override if present
|
|
1794
|
+
shelf = (it.get("shelf_location") or shelf)
|
|
1795
|
+
pos = (it.get("position_on_shelf") or pos)
|
|
1796
|
+
|
|
1797
|
+
# --- COERCION / DEFAULTS ---
|
|
1798
|
+
det_cls = det.class_name.lower()
|
|
1799
|
+
pt = (it.get("product_type") or "").strip().lower()
|
|
1800
|
+
pm = (it.get("product_model") or None)
|
|
1801
|
+
|
|
1802
|
+
# Default to detector class when empty
|
|
1803
|
+
if not pt:
|
|
1804
|
+
pt = "price_tag" if det_cls in ("price_tag", "fact_tag") else det_cls
|
|
1805
|
+
|
|
1806
|
+
# Shelf rule: middle/bottom should be boxes; detector box forces box
|
|
1807
|
+
if shelf in ("middle", "bottom") or det_cls == "product_box":
|
|
1808
|
+
if pt == "printer":
|
|
1809
|
+
pt = "product_box"
|
|
1810
|
+
|
|
1811
|
+
# Fill sensible models
|
|
1812
|
+
if pt in ("price_tag", "fact_tag") and not pm:
|
|
1813
|
+
pm = "price tag"
|
|
1814
|
+
if pt == "promotional_graphic" and not pm:
|
|
1815
|
+
# light OCR-based guess if you like; otherwise leave None
|
|
1816
|
+
pm = None
|
|
1817
|
+
|
|
1818
|
+
out.append(
|
|
1819
|
+
IdentifiedProduct(
|
|
1820
|
+
detection_box=det,
|
|
1821
|
+
product_type=it.get("product_type", "unknown"),
|
|
1822
|
+
product_model=it.get("product_model"),
|
|
1823
|
+
confidence=float(it.get("confidence", 0.5)),
|
|
1824
|
+
visual_features=it.get("visual_features", []),
|
|
1825
|
+
reference_match=it.get("reference_match"),
|
|
1826
|
+
shelf_location=shelf,
|
|
1827
|
+
position_on_shelf=pos,
|
|
1828
|
+
detection_id=det_id,
|
|
1829
|
+
brand=it.get("brand"),
|
|
1830
|
+
advertisement_type=it.get("advertisement_type"),
|
|
1831
|
+
)
|
|
1832
|
+
)
|
|
1833
|
+
return out
|
|
1834
|
+
|
|
1835
|
+
async def generate_video(
|
|
1836
|
+
self,
|
|
1837
|
+
prompt: Union[str, Any],
|
|
1838
|
+
*,
|
|
1839
|
+
model_name: str = "sora-2", # "sora-1" or "sora-2"
|
|
1840
|
+
duration: Optional[int] = None, # seconds (if your access supports it)
|
|
1841
|
+
ratio: Optional[str] = None, # "16:9", "9:16", "1:1", etc. (mapped to aspect_ratio)
|
|
1842
|
+
output_path: Optional[Union[str, Path]] = None,
|
|
1843
|
+
poll_interval: float = 2.0,
|
|
1844
|
+
timeout: float = 15 * 60, # 15 minutes
|
|
1845
|
+
extra: Optional[Dict[str, Any]] = None, # pass-through for future knobs (seed/fps/style/etc.)
|
|
1846
|
+
):
|
|
1847
|
+
"""
|
|
1848
|
+
Generate a video with Sora using the Videos API and return an AIMessage.
|
|
1849
|
+
|
|
1850
|
+
Notes:
|
|
1851
|
+
- Requires an openai 2.6.x build that exposes `client.videos`.
|
|
1852
|
+
- This function intentionally does NOT fall back to Responses for video,
|
|
1853
|
+
because 2.6.0 rejects a `response` object (400 unknown_parameter).
|
|
1854
|
+
"""
|
|
1855
|
+
start_ts = time.time()
|
|
1856
|
+
|
|
1857
|
+
# -------- 0) Verify Videos API exists in this installed client --------
|
|
1858
|
+
videos_res = getattr(self.client, "videos", None)
|
|
1859
|
+
if videos_res is None:
|
|
1860
|
+
import openai as _openai
|
|
1861
|
+
ver = getattr(_openai, "__version__", "unknown")
|
|
1862
|
+
raise RuntimeError(
|
|
1863
|
+
f"openai=={ver} does not expose `client.videos`; "
|
|
1864
|
+
"this build cannot generate video. Please upgrade to a build that includes the Videos API."
|
|
1865
|
+
)
|
|
1866
|
+
|
|
1867
|
+
# -------- 1) Normalize prompt + build create kwargs --------
|
|
1868
|
+
if isinstance(prompt, str):
|
|
1869
|
+
prompt_text = prompt
|
|
1870
|
+
create_kwargs: Dict[str, Any] = {"model": model_name, "prompt": prompt_text}
|
|
1871
|
+
else:
|
|
1872
|
+
# supports objects like your VideoPrompt with `.prompt` and maybe `.options`
|
|
1873
|
+
prompt_text = getattr(prompt, "prompt", None) or str(prompt)
|
|
1874
|
+
create_kwargs = {"model": model_name, "prompt": prompt_text}
|
|
1875
|
+
# if user supplied options, merge them
|
|
1876
|
+
opts = getattr(prompt, "options", None)
|
|
1877
|
+
if isinstance(opts, dict):
|
|
1878
|
+
create_kwargs |= opts
|
|
1879
|
+
|
|
1880
|
+
if duration is not None:
|
|
1881
|
+
create_kwargs["duration"] = duration
|
|
1882
|
+
if ratio:
|
|
1883
|
+
create_kwargs["aspect_ratio"] = ratio
|
|
1884
|
+
if extra:
|
|
1885
|
+
create_kwargs |= extra
|
|
1886
|
+
|
|
1887
|
+
# choose output file
|
|
1888
|
+
out_path = Path(output_path) if output_path else Path.cwd() / f"{int(start_ts)}_{model_name}.mp4"
|
|
1889
|
+
|
|
1890
|
+
# -------- 2) Run job (prefer create_and_poll) --------
|
|
1891
|
+
create_and_poll = getattr(videos_res, "create_and_poll", None)
|
|
1892
|
+
if callable(create_and_poll):
|
|
1893
|
+
video_obj = await create_and_poll(**create_kwargs)
|
|
1894
|
+
else:
|
|
1895
|
+
create = getattr(videos_res, "create", None)
|
|
1896
|
+
retrieve = getattr(videos_res, "retrieve", None)
|
|
1897
|
+
if not callable(create) or not callable(retrieve):
|
|
1898
|
+
import openai as _openai
|
|
1899
|
+
ver = getattr(_openai, "__version__", "unknown")
|
|
1900
|
+
raise RuntimeError(
|
|
1901
|
+
f"`client.videos` exists but lacks required methods in openai=={ver} "
|
|
1902
|
+
"(expected videos.create and videos.retrieve)."
|
|
1903
|
+
)
|
|
1904
|
+
job = await create(**create_kwargs)
|
|
1905
|
+
vid_id = getattr(job, "id", None) or getattr(job, "video_id", None)
|
|
1906
|
+
if not vid_id:
|
|
1907
|
+
raise RuntimeError(f"Videos.create returned no id: {job!r}")
|
|
1908
|
+
|
|
1909
|
+
status = getattr(job, "status", None) or "queued"
|
|
1910
|
+
start_poll = time.time()
|
|
1911
|
+
while status in ("queued", "in_progress", "processing", "running"):
|
|
1912
|
+
if (time.time() - start_poll) > timeout:
|
|
1913
|
+
raise RuntimeError(f"Video job {vid_id} timed out after {timeout}s")
|
|
1914
|
+
await asyncio.sleep(poll_interval)
|
|
1915
|
+
job = await retrieve(vid_id)
|
|
1916
|
+
status = getattr(job, "status", None)
|
|
1917
|
+
if status not in ("completed", "succeeded", "success"):
|
|
1918
|
+
err = getattr(job, "error", None) or getattr(job, "last_error", None)
|
|
1919
|
+
raise RuntimeError(f"Video job {vid_id} failed with status={status}, error={err}")
|
|
1920
|
+
video_obj = job
|
|
1921
|
+
|
|
1922
|
+
# -------- 3) Download the MP4 --------
|
|
1923
|
+
download = getattr(videos_res, "download_content", None)
|
|
1924
|
+
vid_id = getattr(video_obj, "id", None) or getattr(video_obj, "video_id", None)
|
|
1925
|
+
if callable(download) and vid_id:
|
|
1926
|
+
content = await download(vid_id)
|
|
1927
|
+
data = await content.aread() if hasattr(content, "aread") else bytes(content)
|
|
1928
|
+
out_path.write_bytes(data)
|
|
1929
|
+
else:
|
|
1930
|
+
url = getattr(video_obj, "url", None) or getattr(video_obj, "download_url", None)
|
|
1931
|
+
if url:
|
|
1932
|
+
# You can implement your own HTTP fetch here if needed
|
|
1933
|
+
raise RuntimeError(
|
|
1934
|
+
"download_content() is unavailable and direct URL download isn't implemented. "
|
|
1935
|
+
"Please enable videos.download_content in your SDK."
|
|
1936
|
+
)
|
|
1937
|
+
raise RuntimeError("Could not download video: no download method or URL available on video object.")
|
|
1938
|
+
|
|
1939
|
+
# -------- 4) Build saved_files + usage + raw_dump --------
|
|
1940
|
+
saved_files = [{
|
|
1941
|
+
"path": str(out_path),
|
|
1942
|
+
"mime_type": "video/mp4",
|
|
1943
|
+
"type": "video",
|
|
1944
|
+
"id": vid_id,
|
|
1945
|
+
"model": getattr(video_obj, "model", model_name),
|
|
1946
|
+
"duration": getattr(video_obj, "duration", None),
|
|
1947
|
+
}]
|
|
1948
|
+
|
|
1949
|
+
# usage is typically token-based for text; keep a minimal structure for consistency
|
|
1950
|
+
usage = getattr(video_obj, "usage", None) or {
|
|
1951
|
+
"total_tokens": 0,
|
|
1952
|
+
"input_tokens": 0,
|
|
1953
|
+
"output_tokens": 0,
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
# serialize the raw object if it’s a Pydantic-like model
|
|
1957
|
+
raw_dump = (
|
|
1958
|
+
video_obj.model_dump() if hasattr(video_obj, "model_dump")
|
|
1959
|
+
else getattr(video_obj, "__dict__", video_obj)
|
|
1960
|
+
)
|
|
1961
|
+
|
|
1962
|
+
execution_time = time.time() - start_ts
|
|
1963
|
+
|
|
1964
|
+
# -------- 5) Return AIMessage (drop-in) --------
|
|
1965
|
+
return AIMessageFactory.from_video(
|
|
1966
|
+
output=raw_dump or video_obj,
|
|
1967
|
+
files=saved_files,
|
|
1968
|
+
media=saved_files,
|
|
1969
|
+
input=prompt_text,
|
|
1970
|
+
model=model_name,
|
|
1971
|
+
provider="openai",
|
|
1972
|
+
usage=usage,
|
|
1973
|
+
response_time=execution_time,
|
|
1974
|
+
raw_response=raw_dump,
|
|
1975
|
+
)
|